Skip to content

Sufficiency diagnostics

Parameters of fit summarise a solution in a few numbers. They do not say which cases produced those numbers — and that is usually the question you actually have. Which cases support this path? Which contradict it? Which outcomes does it fail to explain?

from setqca import sufficiency_diagnostics

diagnostics = sufficiency_diagnostics(
    data,
    outcome="SURV",
    terms=["DEV*URB*LIT*IND*STB", "DEV*~URB*LIT*~IND*STB"],
)
print(diagnostics)
print(diagnostics.to_frame())
print(diagnostics.cases_frame())

Terms are given as expression strings and parsed, so a solution can be pasted straight in. Case labels come from the frame index by default, or from a column you name — no particular schema is assumed.

The case typology

For a term X and outcome Y, with the crossover at 0.5:

Membership Role What it means
X > 0.5, Y > 0.5, X ≤ Y typical Supports the claim. These are the cases to study for the mechanism.
X > 0.5, Y > 0.5, X > Y deviant consistency (degree) Right corner, wrong magnitude — more in the term than in the outcome.
X > 0.5, Y ≤ 0.5 deviant consistency (kind) The term holds and the outcome does not. This is the case-level contradiction.
X ≤ 0.5, Y > 0.5 deviant coverage An outcome this term does not explain.
X ≤ 0.5, Y ≤ 0.5 individually irrelevant Outside both sets.
term = diagnostics.terms[0]
term.typical  # ('BE', 'CZ', 'NL')
term.contradictory  # cases where the term holds but the outcome does not
term.deviant_coverage  # outcomes this term misses
term.deviant_consistency  # both kinds of consistency deviance
term.uniquely_covered  # cases no other term reaches

Only consistency deviance counts against the claim

A deviant-coverage case is not evidence against sufficiency. It says the outcome occurred through some other path, which is exactly what a disjunctive solution expects. CaseRole.contradicts_sufficiency encodes the distinction.

Unique coverage

Raw coverage counts the outcome membership a term accounts for. Unique coverage counts only what no other term accounts for:

covU_i = [ Σ min(Xᵢ, Y) − Σ min(Xᵢ, max_{j≠i} Xⱼ, Y) ] / Σ Y

A term with substantial raw coverage but near-zero unique coverage is redundant in practice — drop it and the same cases are still explained:

diagnostics.redundant_terms

Two identical terms each have unique coverage of exactly zero, which is the degenerate case the property makes obvious.

A small divergence from R

R reports covU as NA for a single-term solution, since there is no other term to be unique against. setqca reports the raw coverage instead: with nothing to share with, everything the term covers is uniquely covered by it. Verified against R for every multi-term solution on the Lipset data.

Reading R's cases column

R's per-term cases column lists cases whose membership in the term exceeds the crossover. The typology splits that same set further, so R's list corresponds to typical plus deviant-in-degree, not to typical alone.

On the Lipset conservative solution R lists BE, CZ, NL, UK for the first term. setqca agrees on all four being in the term, and additionally reports that UK is deviant in degree — its membership in the term exceeds its membership in the outcome. That distinction is the point of the typology, and it is not visible from the cases column alone.

Choosing cases to study

The typology exists to support case selection in multi-method work:

  • Typical cases are where the proposed mechanism should be visible.
  • Deviant consistency cases are where it should be visible and is not — the most informative cases for revising the theory.
  • Deviant coverage cases point at paths the solution is missing.
  • Uniquely covered cases are the ones that justify keeping a term at all.

setqca.analysis.sufficiency

Case-level diagnostics for a sufficiency solution.

Parameters of fit summarise a solution in a few numbers. They do not say which cases produced those numbers, and that is usually the question a researcher actually has: which cases support this path, which contradict it, and which outcomes does it fail to explain.

Case typology

For a term X and outcome Y, with the crossover at 0.5 (Schneider and Rohlfing 2013):

  • X > 0.5, Y > 0.5, X <= Y: typical, supporting the claim.
  • X > 0.5, Y > 0.5, X > Y: deviant for consistency in degree, the right corner at the wrong magnitude.
  • X > 0.5, Y <= 0.5: deviant for consistency in kind. The term holds and the outcome does not; this is the case-level contradiction.
  • X <= 0.5, Y > 0.5: deviant for coverage, an outcome this term does not explain.
  • X <= 0.5, Y <= 0.5: individually irrelevant, outside both sets.
Unique coverage

Raw coverage counts outcome membership a term accounts for. Unique coverage counts only what no other term accounts for::

covU_i = [ sum(min(Xi, Y)) - sum(min(Xi, max_over_others(Xj), Y)) ] / sum(Y)

A term with substantial raw coverage but near-zero unique coverage is redundant in practice: drop it and the solution still explains the same cases.

References

Schneider, C. Q. and Rohlfing, I. (2013). Combining QCA and process tracing in set-theoretic multi-method research. Sociological Methods & Research 42(4), 559-597.

CaseRole

Bases: Enum

Where a case sits relative to one sufficiency claim.

contradicts_sufficiency property

contradicts_sufficiency: bool

Return whether this role counts against the sufficiency claim.

CaseDiagnostic dataclass

CaseDiagnostic(
    case: str,
    term_membership: float,
    outcome_membership: float,
    role: CaseRole,
    uniquely_covered: bool,
)

One case, judged against one term.

TermDiagnostics dataclass

TermDiagnostics(
    expression: str,
    fit: SufficiencyFit,
    unique_coverage: float,
    frequency: int,
    cases: tuple[CaseDiagnostic, ...],
)

One solution term, its fit, and every case's relation to it.

typical property

typical: tuple[str, ...]

Return cases supporting the claim.

deviant_consistency property

deviant_consistency: tuple[str, ...]

Return cases contradicting the claim, in kind or in degree.

contradictory property

contradictory: tuple[str, ...]

Return cases where the term holds but the outcome does not.

deviant_coverage property

deviant_coverage: tuple[str, ...]

Return outcome cases this term does not reach.

uniquely_covered property

uniquely_covered: tuple[str, ...]

Return cases this term covers that no other term does.

redundant property

redundant: bool

Return whether the term adds no coverage another term does not already give.

by_role

by_role(role: CaseRole) -> tuple[str, ...]

Return the labels of cases in one role.

Source code in src/setqca/analysis/sufficiency.py
def by_role(self, role: CaseRole) -> tuple[str, ...]:
    """Return the labels of cases in one role."""
    return tuple(item.case for item in self.cases if item.role is role)

SolutionDiagnostics dataclass

SolutionDiagnostics(
    outcome: str,
    terms: tuple[TermDiagnostics, ...],
    fit: SufficiencyFit,
)

Diagnostics for a whole disjunctive solution.

redundant_terms property

redundant_terms: tuple[TermDiagnostics, ...]

Return terms contributing no unique coverage.

to_frame

to_frame() -> DataFrame

Return one row per term, with fit and case counts.

Returns:

Type Description
DataFrame

Columns term, consistency, PRI, raw_coverage, unique_coverage, n, and one count per case role.

Source code in src/setqca/analysis/sufficiency.py
def to_frame(self) -> pd.DataFrame:
    """Return one row per term, with fit and case counts.

    Returns
    -------
    pandas.DataFrame
        Columns ``term``, ``consistency``, ``PRI``, ``raw_coverage``,
        ``unique_coverage``, ``n``, and one count per case role.
    """
    return pd.DataFrame(
        {
            "term": [term.expression for term in self.terms],
            "consistency": [term.fit.consistency for term in self.terms],
            "PRI": [term.fit.pri for term in self.terms],
            "raw_coverage": [term.fit.coverage for term in self.terms],
            "unique_coverage": [term.unique_coverage for term in self.terms],
            "n": [term.frequency for term in self.terms],
            **{
                role.value: [len(term.by_role(role)) for term in self.terms]
                for role in CaseRole
            },
        }
    )

cases_frame

cases_frame() -> DataFrame

Return one row per case per term, for case-oriented work.

Returns:

Type Description
DataFrame

Columns term, case, term_membership, outcome_membership, role and uniquely_covered.

Source code in src/setqca/analysis/sufficiency.py
def cases_frame(self) -> pd.DataFrame:
    """Return one row per case per term, for case-oriented work.

    Returns
    -------
    pandas.DataFrame
        Columns ``term``, ``case``, ``term_membership``,
        ``outcome_membership``, ``role`` and ``uniquely_covered``.
    """
    records = [
        {
            "term": term.expression,
            "case": item.case,
            "term_membership": item.term_membership,
            "outcome_membership": item.outcome_membership,
            "role": item.role.value,
            "uniquely_covered": item.uniquely_covered,
        }
        for term in self.terms
        for item in term.cases
    ]
    return pd.DataFrame.from_records(
        records,
        columns=[
            "term",
            "case",
            "term_membership",
            "outcome_membership",
            "role",
            "uniquely_covered",
        ],
    )

classify_case

classify_case(
    term_membership: float, outcome_membership: float
) -> CaseRole

Classify one case against one sufficiency claim.

Parameters:

Name Type Description Default
term_membership float

Membership of the case in the term.

required
outcome_membership float

Membership of the case in the outcome.

required

Returns:

Type Description
CaseRole

The case's role, per the typology in the module docstring.

Source code in src/setqca/analysis/sufficiency.py
def classify_case(term_membership: float, outcome_membership: float) -> CaseRole:
    """Classify one case against one sufficiency claim.

    Parameters
    ----------
    term_membership : float
        Membership of the case in the term.
    outcome_membership : float
        Membership of the case in the outcome.

    Returns
    -------
    CaseRole
        The case's role, per the typology in the module docstring.
    """
    in_term = term_membership > CROSSOVER
    in_outcome = outcome_membership > CROSSOVER

    if in_term and in_outcome:
        if term_membership <= outcome_membership:
            return CaseRole.TYPICAL
        return CaseRole.DEVIANT_CONSISTENCY_IN_DEGREE
    if in_term:
        return CaseRole.DEVIANT_CONSISTENCY_IN_KIND
    if in_outcome:
        return CaseRole.DEVIANT_COVERAGE
    return CaseRole.INDIVIDUALLY_IRRELEVANT

sufficiency_diagnostics

sufficiency_diagnostics(
    data: DataFrame,
    *,
    outcome: str,
    terms: Sequence[str | SetExpression],
    case_id: str | None = None,
) -> SolutionDiagnostics

Diagnose a disjunctive sufficiency solution case by case.

Parameters:

Name Type Description Default
data DataFrame

Calibrated memberships in [0, 1].

required
outcome str

Name of the outcome column.

required
terms sequence of str or SetExpression

The solution's terms. Strings are parsed, so ["DEV*URB", "LIT*~IND"] works directly.

required
case_id str

Column holding case labels. Defaults to the frame index, so no particular schema is assumed.

None

Returns:

Type Description
SolutionDiagnostics

Per-term fit including unique coverage, and every case's role.

Raises:

Type Description
ValueError

If no terms are given, or the data are not calibrated.

KeyError

If a named column is absent.

Examples:

>>> diagnostics = sufficiency_diagnostics(
...     data, outcome="SURV", terms=["DEV*URB*LIT*IND*STB"]
... )
>>> diagnostics.terms[0].typical
('BE', 'CZ', 'NL', 'UK')
Source code in src/setqca/analysis/sufficiency.py
def sufficiency_diagnostics(
    data: pd.DataFrame,
    *,
    outcome: str,
    terms: Sequence[str | SetExpression],
    case_id: str | None = None,
) -> SolutionDiagnostics:
    """Diagnose a disjunctive sufficiency solution case by case.

    Parameters
    ----------
    data : pandas.DataFrame
        Calibrated memberships in ``[0, 1]``.
    outcome : str
        Name of the outcome column.
    terms : sequence of str or SetExpression
        The solution's terms. Strings are parsed, so
        ``["DEV*URB", "LIT*~IND"]`` works directly.
    case_id : str, optional
        Column holding case labels. Defaults to the frame index, so no
        particular schema is assumed.

    Returns
    -------
    SolutionDiagnostics
        Per-term fit including unique coverage, and every case's role.

    Raises
    ------
    ValueError
        If no terms are given, or the data are not calibrated.
    KeyError
        If a named column is absent.

    Examples
    --------
    >>> diagnostics = sufficiency_diagnostics(  # doctest: +SKIP
    ...     data, outcome="SURV", terms=["DEV*URB*LIT*IND*STB"]
    ... )
    >>> diagnostics.terms[0].typical  # doctest: +SKIP
    ('BE', 'CZ', 'NL', 'UK')
    """
    if not terms:
        raise ValueError("At least one term is required.")
    validate_columns(data, [outcome])
    y = validate_membership(data[outcome].to_numpy(), name=outcome)

    if case_id is None:
        labels = [str(index) for index in data.index]
    else:
        validate_columns(data, [case_id])
        labels = [str(value) for value in data[case_id]]

    resolved = _memberships(data, terms)
    all_memberships = [membership for _, membership in resolved]

    diagnostics: list[TermDiagnostics] = []
    for position, (expression, membership) in enumerate(resolved):
        others = [other for index, other in enumerate(all_memberships) if index != position]
        covered_elsewhere = np.maximum.reduce(others) if others else np.zeros_like(membership)
        cases = tuple(
            CaseDiagnostic(
                case=label,
                term_membership=float(term_value),
                outcome_membership=float(outcome_value),
                role=classify_case(float(term_value), float(outcome_value)),
                uniquely_covered=bool(term_value > CROSSOVER and other_value <= CROSSOVER),
            )
            for label, term_value, outcome_value, other_value in zip(
                labels, membership, y, covered_elsewhere, strict=True
            )
        )
        diagnostics.append(
            TermDiagnostics(
                expression=expression,
                fit=sufficiency(membership, y),
                unique_coverage=_unique_coverage(membership, others, y),
                frequency=int(np.sum(membership > CROSSOVER)),
                cases=cases,
            )
        )

    overall = np.maximum.reduce(all_memberships)
    return SolutionDiagnostics(
        outcome=outcome,
        terms=tuple(diagnostics),
        fit=sufficiency(overall, y),
    )