Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,6 @@ contract.
## Non-goals for 0.1

- claiming complete parity with R `QCA`;
- mvQCA;
- tQCA;
- CCubes/eQMC performance parity.

Expand Down
4 changes: 1 addition & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@
- optional R-compatible calibration snapping, so extreme memberships can be
reported exactly as R does when replicating an existing analysis

## 0.3 — multi-value and performance
## 0.3 — performance

- mvQCA
- categorical-set expressions
- faster bitset/cube minimiser
- prime-implicant consistency filters
- row dominance
Expand Down
101 changes: 101 additions & 0 deletions docs/guide/multivalue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Multi-value QCA

A multi-value condition takes one of several unordered categories — regime type,
welfare regime, sector — rather than being present or absent. Forcing such a
condition into a binary set either loses information or invents a dichotomy the
concept does not have.

```python
from setqca.multivalue import MVQCA

result = MVQCA(consistency=0.8).fit(
data, outcome="Y", conditions=["regime", "wealth"]
)
print(result)
print(result.truth_table.to_frame())
print(result.summary_frame("parsimonious"))
```

Conditions hold integer category codes from `0`; the outcome is a membership in
`[0, 1]`. The workflow deliberately mirrors `FSQCA` and `CSQCA` — moving between
them is a change of estimator, not a change of method.

## Notation

`regime{0,2}*wealth{1}` reads "regime is 0 or 2, and wealth is 1". A condition
allowing *every* level constrains nothing and is omitted from the expression, so
the binary case reduces to familiar QCA notation.

## The property space

```python
result.domain # regime{0,1,2}, wealth{0,1}
result.domain.size # 6 logically possible configurations
```

Configurations are indexed in mixed radix, which generalises the binary minterm
and reduces to it exactly when every condition has two levels.

!!! warning "Declare levels that have no cases"
Levels are inferred from the data, which understates a category that is
theoretically possible but happens to be unobserved. That matters: an
unobserved level is a *remainder*, and remainders change the parsimonious
solution.

```python
MVQCA(levels={"regime": 4, "wealth": 2}).fit(...)
```

Declaring fewer levels than the data contain is an error.

## Why not Boolean dummies

The obvious shortcut is to encode `A{0,1,2}` as three binary indicators and
reuse the binary minimiser. **That transformation does not preserve the
semantics.**

The binary space contains points such as `A_0 = A_1 = 1` — a case that is
simultaneously in two mutually exclusive categories, which corresponds to no
configuration at all. The minimiser is free to build implicants across those
points, producing terms that look valid and describe nothing. Recovering a
multi-value expression afterwards requires exactly the mutual-exclusivity
constraints the encoding threw away.

So the cube algebra is implemented directly. A cube allows a **set** of levels
per condition, and merging generalises the binary rule:

> two cubes that agree on every condition but one merge into a single cube whose
> set at that condition is the union of the two.

Because the two cubes agree everywhere else, the merged cube covers exactly
their union and nothing more — the same property the binary rule relies on. A
test asserts precisely that, and another asserts every cube covers only real
configurations.

The exact cover is then solved by the **same verified solver the binary engine
uses**, so both inherit one exactness guarantee rather than two implementations.
Minimisation is checked against exhaustive enumeration for four different level
combinations, and against the binary minimiser for every three-condition
problem.

## Agreement with R

R `QCA` supports multi-value and writes literals as `regime[2]`. The truth table
and the parsimonious solution match exactly on the benchmarks in
`validation/fixtures/r_qca.json`.

The conservative solution can differ in *representation*:

```text
R: regime[2] + regime[1]*wealth[1]
setqca: regime{2} + regime{1,2}*wealth{1}
```

Both cover the same configurations and both cost two terms and three literals,
so both are minimal. The difference is that R writes single-value literals only,
while `setqca` also forms subset literals — and here R's `regime[1]*wealth[1]`
is a **proper subset** of `regime{1,2}*wealth{1}`, so R's term is not a prime
implicant. The parity tests therefore compare cost and coverage rather than
text, which is the comparison that carries meaning.

::: setqca.multivalue
12 changes: 6 additions & 6 deletions docs/guide/truth-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ recorded. A row's outcome code alone conflates situations that call for
different responses:

```python
table.positive_rows() # coded "1"
table.negative_rows() # coded "0"
table.positive_rows() # coded "1"
table.negative_rows() # coded "0"
table.contradictions() # coded "C"
table.remainders() # coded "R"
table.excluded_rows() # kept out by a *threshold*, not by the evidence
table.remainders() # coded "R"
table.excluded_rows() # kept out by a *threshold*, not by the evidence
print(table.summary())
```

Expand Down Expand Up @@ -124,8 +124,8 @@ stored and re-minimised without recalibrating or rebuilding:
text = table.to_json()
restored = TruthTable.from_json(text)

restored.minimize() # conservative
restored.minimize(include_remainders=True) # parsimonious
restored.minimize() # conservative
restored.minimize(include_remainders=True) # parsimonious
```

Both agree with the estimator exactly — there are tests asserting so. Only the
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ nav:
- Sufficiency diagnostics: guide/sufficiency-diagnostics.md
- Truth tables: guide/truth-tables.md
- Minimisation: guide/minimisation.md
- Multi-value QCA: guide/multivalue.md
- Robustness: guide/robustness.md
- Methodology: METHODOLOGY.md
- Architecture: ARCHITECTURE.md
Expand Down
5 changes: 5 additions & 0 deletions src/setqca/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
minimize_chart,
)
from .models import CSQCA, FSQCA, Direction
from .multivalue import MVQCA, MultiValueDomain, MultiValueResult, MultiValueTruthTable
from .results import FittedSolution, QCAResult
from .sets import Condition, Intersection, Negation, SetExpression, Union
from .truth_table import TruthCode, TruthTable, TruthTableRow, build_truth_table
Expand All @@ -77,6 +78,7 @@
__all__ = [
"CSQCA",
"FSQCA",
"MVQCA",
"AnchorSuggestion",
"BooleanSolution",
"CalibrationDiagnostics",
Expand All @@ -98,6 +100,9 @@
"Intersection",
"MinimalCover",
"MinimizationResult",
"MultiValueDomain",
"MultiValueResult",
"MultiValueTruthTable",
"NecessityAnalysis",
"NecessityCandidate",
"NecessityFit",
Expand Down
84 changes: 60 additions & 24 deletions src/setqca/minimize/qmc.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections import defaultdict
from collections.abc import Sequence
from dataclasses import dataclass

from .implicant import Implicant, minterm_to_implicant
Expand Down Expand Up @@ -119,32 +120,67 @@ def exact_minimum_covers(
"""
if not on_set:
return (BooleanSolution(()),)
cover_map = {m: tuple(i for i, p in enumerate(primes) if p.covers(m)) for m in on_set}
if any(not choices for choices in cover_map.values()):
raise RuntimeError("Prime-implicant chart cannot cover every positive row.")
covered = [frozenset(m for m in on_set if prime.covers(m)) for prime in primes]
literals = [prime.literals for prime in primes]
choices = solve_minimum_cover(covered, literals, on_set, max_solutions=max_solutions)
return tuple(BooleanSolution(tuple(primes[i] for i in indices)) for indices in choices)


def solve_minimum_cover(
covered: Sequence[frozenset[int]],
literals: Sequence[int],
on_set: set[int],
*,
max_solutions: int = 256,
) -> tuple[tuple[int, ...], ...]:
"""Solve a covering problem exactly, independently of what is being covered.

The exactness guarantee lives here, so every minimiser in the package —
binary and multi-value alike — shares one verified implementation rather
than repeating the search.

Parameters
----------
covered : sequence of frozenset of int
For each candidate, the elements of ``on_set`` it covers.
literals : sequence of int
Cost of each candidate, used as the secondary objective.
on_set : set of int
Elements that must be covered.
max_solutions : int, default 256
Upper bound on the number of tied minimum covers returned.

Returns
-------
tuple of tuple of int
Candidate indices, one tuple per tied minimum cover.

covered_by = {
index: frozenset(m for m in on_set if primes[index].covers(m))
for index in {i for choices in cover_map.values() for i in choices}
}
Raises
------
RuntimeError
If some element is covered by no candidate.
"""
cover_map = {m: tuple(i for i, reach in enumerate(covered) if m in reach) for m in on_set}
if any(not options for options in cover_map.values()):
raise RuntimeError("Prime-implicant chart cannot cover every positive row.")

# A minterm with a single candidate forces that prime into every cover.
essential = frozenset(choices[0] for choices in cover_map.values() if len(choices) == 1)
# A minterm with a single candidate forces that candidate into every cover.
essential = frozenset(options[0] for options in cover_map.values() if len(options) == 1)
start_uncovered = (
frozenset(on_set).difference(*(covered_by[i] for i in essential))
if (essential)
frozenset(on_set).difference(*(covered[i] for i in essential))
if essential
else frozenset(on_set)
)

def lower_bound(uncovered: frozenset[int]) -> int:
"""Return a lower bound on the number of further primes required."""
"""Return a lower bound on the number of further candidates required."""
blocked: set[int] = set()
bound = 0
for minterm in sorted(uncovered, key=lambda m: len(cover_map[m])):
choices = cover_map[minterm]
if blocked.isdisjoint(choices):
options = cover_map[minterm]
if blocked.isdisjoint(options):
bound += 1
blocked.update(choices)
blocked.update(options)
return bound

best_cost: tuple[int, int] | None = None
Expand All @@ -153,7 +189,7 @@ def lower_bound(uncovered: frozenset[int]) -> int:

def search(chosen: frozenset[int], uncovered: frozenset[int]) -> None:
nonlocal best_cost
current_cost = (len(chosen), sum(primes[i].literals for i in chosen))
current_cost = (len(chosen), sum(literals[i] for i in chosen))
if not uncovered:
if best_cost is None or current_cost < best_cost:
best_cost = current_cost
Expand All @@ -164,8 +200,8 @@ def search(chosen: frozenset[int], uncovered: frozenset[int]) -> None:
if best_cost is not None:
if current_cost >= best_cost:
return
# Any completion needs at least `lower_bound` further primes, and
# primes never reduce the literal count.
# Any completion needs at least `lower_bound` further candidates,
# and candidates never reduce the cost.
if (current_cost[0] + lower_bound(uncovered), current_cost[1]) > best_cost:
return
previous = seen.get(uncovered)
Expand All @@ -174,18 +210,18 @@ def search(chosen: frozenset[int], uncovered: frozenset[int]) -> None:
if previous is None or current_cost < previous:
seen[uncovered] = current_cost

# Branch on the minterm with the fewest candidates: every cover must
# Branch on the element with the fewest candidates: every cover must
# contain one of them, so this is a complete and narrow branching rule.
target = min(uncovered, key=lambda m: len(cover_map[m]))
candidates = sorted(
ordered = sorted(
cover_map[target],
key=lambda i: (-len(covered_by[i] & uncovered), primes[i].literals, i),
key=lambda i: (-len(covered[i] & uncovered), literals[i], i),
)
for index in candidates:
search(chosen | {index}, uncovered - covered_by[index])
for index in ordered:
search(chosen | {index}, uncovered - covered[index])

search(essential, start_uncovered)
return tuple(BooleanSolution(tuple(primes[i] for i in indices)) for indices in sorted(best))
return tuple(sorted(best))


def minimize(
Expand Down
52 changes: 52 additions & 0 deletions src/setqca/multivalue/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Multi-value QCA: categorical conditions with more than two levels.

A multi-value condition takes one of several unordered categories — regime type,
welfare regime, sector — rather than being present or absent. Forcing such a
condition into a binary set either loses information or invents a dichotomy the
concept does not have.

The cube algebra is implemented directly rather than by encoding categories as
Boolean indicators; see :mod:`setqca.multivalue._cube` for why that encoding is
unsound. The exact cover is solved by the same verified solver the binary
engine uses, so both inherit one exactness guarantee.

Examples
--------
>>> import pandas as pd
>>> from setqca.multivalue import MVQCA
>>> data = pd.DataFrame(
... {"regime": [0, 1, 2, 1], "wealth": [0, 1, 1, 0], "Y": [0.1, 0.9, 0.9, 0.2]}
... )
>>> result = MVQCA(consistency=0.8).fit(data, outcome="Y", conditions=["regime", "wealth"])
>>> print(result.summary_frame()) # doctest: +SKIP
"""

from __future__ import annotations

from ._cube import (
MultiValueCube,
MultiValueSolution,
minimize_multivalue,
prime_cubes,
)
from ._domain import MultiValueDomain
from ._model import (
MVQCA,
MultiValueResult,
MultiValueRow,
MultiValueTruthTable,
build_multivalue_truth_table,
)

__all__ = [
"MVQCA",
"MultiValueCube",
"MultiValueDomain",
"MultiValueResult",
"MultiValueRow",
"MultiValueSolution",
"MultiValueTruthTable",
"build_multivalue_truth_table",
"minimize_multivalue",
"prime_cubes",
]
Loading
Loading