Skip to content

Commit d622ac1

Browse files
Add unique coverage and case-level sufficiency diagnostics (#14)
* feat: add unique coverage and case-level sufficiency diagnostics * feat: export sufficiency diagnostics from the package root * test: add R fixtures for per-term fit and unique coverage * test: cover the case typology and unique coverage * docs: add sufficiency diagnostics guide
1 parent cc48eb2 commit d622ac1

10 files changed

Lines changed: 948 additions & 7 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Sufficiency diagnostics
2+
3+
Parameters of fit summarise a solution in a few numbers. They do not say which
4+
cases produced those numbers — and that is usually the question you actually
5+
have. Which cases support this path? Which contradict it? Which outcomes does it
6+
fail to explain?
7+
8+
```python
9+
from setqca import sufficiency_diagnostics
10+
11+
diagnostics = sufficiency_diagnostics(
12+
data,
13+
outcome="SURV",
14+
terms=["DEV*URB*LIT*IND*STB", "DEV*~URB*LIT*~IND*STB"],
15+
)
16+
print(diagnostics)
17+
print(diagnostics.to_frame())
18+
print(diagnostics.cases_frame())
19+
```
20+
21+
Terms are given as expression strings and parsed, so a solution can be pasted
22+
straight in. Case labels come from the frame index by default, or from a column
23+
you name — no particular schema is assumed.
24+
25+
## The case typology
26+
27+
For a term `X` and outcome `Y`, with the crossover at 0.5:
28+
29+
| Membership | Role | What it means |
30+
| --- | --- | --- |
31+
| `X > 0.5`, `Y > 0.5`, `X ≤ Y` | **typical** | Supports the claim. These are the cases to study for the mechanism. |
32+
| `X > 0.5`, `Y > 0.5`, `X > Y` | **deviant consistency (degree)** | Right corner, wrong magnitude — more in the term than in the outcome. |
33+
| `X > 0.5`, `Y ≤ 0.5` | **deviant consistency (kind)** | The term holds and the outcome does not. This is the case-level contradiction. |
34+
| `X ≤ 0.5`, `Y > 0.5` | **deviant coverage** | An outcome this term does not explain. |
35+
| `X ≤ 0.5`, `Y ≤ 0.5` | **individually irrelevant** | Outside both sets. |
36+
37+
```python
38+
term = diagnostics.terms[0]
39+
term.typical # ('BE', 'CZ', 'NL')
40+
term.contradictory # cases where the term holds but the outcome does not
41+
term.deviant_coverage # outcomes this term misses
42+
term.deviant_consistency # both kinds of consistency deviance
43+
term.uniquely_covered # cases no other term reaches
44+
```
45+
46+
!!! note "Only consistency deviance counts against the claim"
47+
A deviant-coverage case is not evidence against sufficiency. It says the
48+
outcome occurred through some other path, which is exactly what a
49+
disjunctive solution expects. `CaseRole.contradicts_sufficiency` encodes
50+
the distinction.
51+
52+
## Unique coverage
53+
54+
Raw coverage counts the outcome membership a term accounts for. **Unique**
55+
coverage counts only what no other term accounts for:
56+
57+
```text
58+
covU_i = [ Σ min(Xᵢ, Y) − Σ min(Xᵢ, max_{j≠i} Xⱼ, Y) ] / Σ Y
59+
```
60+
61+
A term with substantial raw coverage but near-zero unique coverage is redundant
62+
in practice — drop it and the same cases are still explained:
63+
64+
```python
65+
diagnostics.redundant_terms
66+
```
67+
68+
Two identical terms each have unique coverage of exactly zero, which is the
69+
degenerate case the property makes obvious.
70+
71+
!!! info "A small divergence from R"
72+
R reports `covU` as `NA` for a single-term solution, since there is no other
73+
term to be unique against. `setqca` reports the raw coverage instead: with
74+
nothing to share with, everything the term covers is uniquely covered by it.
75+
Verified against R for every multi-term solution on the Lipset data.
76+
77+
## Reading R's `cases` column
78+
79+
R's per-term `cases` column lists cases whose membership in the term exceeds the
80+
crossover. The typology splits that same set further, so R's list corresponds to
81+
**typical plus deviant-in-degree**, not to typical alone.
82+
83+
On the Lipset conservative solution R lists `BE, CZ, NL, UK` for the first term.
84+
`setqca` agrees on all four being in the term, and additionally reports that UK
85+
is deviant in degree — its membership in the term exceeds its membership in the
86+
outcome. That distinction is the point of the typology, and it is not visible
87+
from the `cases` column alone.
88+
89+
## Choosing cases to study
90+
91+
The typology exists to support case selection in multi-method work:
92+
93+
- **Typical** cases are where the proposed mechanism should be visible.
94+
- **Deviant consistency** cases are where it should be visible and is not — the
95+
most informative cases for revising the theory.
96+
- **Deviant coverage** cases point at paths the solution is missing.
97+
- **Uniquely covered** cases are the ones that justify keeping a term at all.
98+
99+
::: setqca.analysis.sufficiency

docs/mathematical_validation.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ run over all cases.
3333
| Necessity consistency | `Σ min(X,Y) / Σ Y` | `metrics.necessity` | `TestNecessity` | `1e-9` vs R | ✅ Verified |
3434
| Necessity coverage | `Σ min(X,Y) / Σ X` | `metrics.necessity` | `TestNecessity` | `1e-9` vs R | ✅ Verified |
3535
| Relevance of necessity | `Σ (1−X) / Σ (1 − min(X,Y))` | `metrics.necessity` | `TestNecessity` | `1e-9` vs R | ✅ Verified |
36-
| Unique coverage | `cov(Tᵢ) − cov(⋃ⱼ≠ᵢ Tⱼ)` |||| ❌ Not implemented |
36+
| Unique coverage | `[Σ min(Xᵢ,Y) − Σ min(Xᵢ, max_{j≠i} Xⱼ, Y)] / Σ Y` | `analysis.sufficiency` | `test_sufficiency_diagnostics`, parity | `1e-9` vs R | ✅ Verified |
37+
| Case typology | crossover comparison of `X` and `Y` | `analysis.sufficiency` | `test_sufficiency_diagnostics` | exact | ✅ Tested |
3738
| Trivial necessity | `RoN` below threshold with high consistency | `analysis.necessity` | `test_necessity`, parity | `1e-9` vs R | ✅ Verified |
3839
| SUIN disjunction | `consistency(A+B) ≥ max over parts` | `analysis.necessity` | `test_necessity` | `1e-12` | ✅ Tested |
3940
| Direct calibration, logistic | see below | `calibration.DirectCalibration` | `TestCalibration`, parity | `1e-9` vs R | ✅ Verified |
@@ -111,10 +112,10 @@ poison downstream aggregation.
111112

112113
## Findings
113114

114-
1. **Unique coverage is absent.** Raw coverage is implemented and verified; the
115-
per-term unique coverage reported by other QCA software is not. Solutions
116-
currently expose overall and per-term fit through `FittedSolution.term_fits`,
117-
which is raw coverage per term. This is a gap, not a divergence.
115+
1. **Unique coverage is implemented and verified**, closing the gap this audit
116+
first recorded. It matches R's `covU` for every multi-term solution on the
117+
Lipset data. R leaves `covU` undefined for a one-term solution; setqca
118+
reports the raw coverage there, since there is no other term to share with.
118119
2. **Intermediate solutions now follow the standard algorithm.** Simplifying
119120
assumptions are derived from the parsimonious solution and split into easy
120121
and difficult counterfactuals, matching R `QCA` on the Lipset data.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- Calibration: guide/calibration.md
4545
- Expressions: guide/expressions.md
4646
- Necessity: guide/necessity.md
47+
- Sufficiency diagnostics: guide/sufficiency-diagnostics.md
4748
- Truth tables: guide/truth-tables.md
4849
- Minimisation: guide/minimisation.md
4950
- Methodology: METHODOLOGY.md

src/setqca/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@
99
from importlib.metadata import PackageNotFoundError
1010
from importlib.metadata import version as _version
1111

12-
from .analysis import NecessityAnalysis, NecessityCandidate, necessity_analysis
12+
from .analysis import (
13+
CaseDiagnostic,
14+
CaseRole,
15+
NecessityAnalysis,
16+
NecessityCandidate,
17+
SolutionDiagnostics,
18+
TermDiagnostics,
19+
necessity_analysis,
20+
sufficiency_diagnostics,
21+
)
1322
from .calibration import DirectCalibration, calibrate_crisp, calibrate_direct
1423
from .counterfactuals import (
1524
CounterfactualAnalysis,
@@ -50,6 +59,8 @@
5059
"CSQCA",
5160
"FSQCA",
5261
"BooleanSolution",
62+
"CaseDiagnostic",
63+
"CaseRole",
5364
"Condition",
5465
"Configuration",
5566
"CounterfactualAnalysis",
@@ -71,7 +82,9 @@
7182
"PrimeImplicantChart",
7283
"QCAResult",
7384
"SetExpression",
85+
"SolutionDiagnostics",
7486
"SufficiencyFit",
87+
"TermDiagnostics",
7588
"TruthCode",
7689
"TruthTable",
7790
"TruthTableRow",
@@ -90,4 +103,5 @@
90103
"parse_expression",
91104
"simplify_expression",
92105
"sufficiency",
106+
"sufficiency_diagnostics",
93107
]

src/setqca/analysis/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,23 @@
1616
NecessityCandidate,
1717
necessity_analysis,
1818
)
19+
from .sufficiency import (
20+
CaseDiagnostic,
21+
CaseRole,
22+
SolutionDiagnostics,
23+
TermDiagnostics,
24+
classify_case,
25+
sufficiency_diagnostics,
26+
)
1927

2028
__all__ = [
29+
"CaseDiagnostic",
30+
"CaseRole",
2131
"NecessityAnalysis",
2232
"NecessityCandidate",
33+
"SolutionDiagnostics",
34+
"TermDiagnostics",
35+
"classify_case",
2336
"necessity_analysis",
37+
"sufficiency_diagnostics",
2438
]

0 commit comments

Comments
 (0)