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
100 changes: 100 additions & 0 deletions docs/guide/calibration.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,106 @@ build_truth_table(data, outcome="Y", conditions=["A"], allow_crossover_cases=Tru
With the override, scores of exactly 0.5 are assigned to the *present* corner,
because corner assignment uses `x >= 0.5`.

## Specifications

A calibration is a decision worth recording. `CalibrationSpec` makes it a value
you can store, compare, ship in a replication package, and replay:

```python
from setqca import calibrate, direct_spec

spec = direct_spec(
"innovation",
full_out=20,
crossover=50,
full_in=80,
note="OECD reporting threshold; see section 3.2",
)
result = calibrate(raw["innovation"], spec)

result.values # the calibrated memberships
result.spec # what produced them
result.diagnostics # and what is worrying about them
```

The `note` carries the *reason* through serialisation, because the reason is
part of the specification:

```python
spec.to_json()
CalibrationSpec.from_json(text) # round-trips exactly
```

A specification is validated when it is written, not when it eventually meets
data — badly ordered anchors raise immediately.

### Indirect calibration

When theory dictates a shape the three-anchor transformation cannot express — a
plateau, a step, an asymmetric ramp — give the mapping explicitly:

```python
from setqca import indirect_spec

spec = indirect_spec(
"capacity",
mapping=((0, 0.0), (30, 0.5), (70, 0.5), (100, 1.0)),
note="no meaningful variation between 30 and 70",
)
```

Points are interpolated linearly and held flat beyond the ends. The mapping must
be non-decreasing, since a calibration that reverses direction is a different
concept, not a calibration.

## Diagnostics

A calibration can be arithmetically valid and analytically useless.

```python
from setqca import diagnose_calibration, diagnose_frame

print(diagnose_calibration(calibrated))
diagnose_frame(data) # one row per condition
```

Five failures are reported:

| Warning | Why it matters |
| --- | --- |
| Cases exactly at 0.5 | The truth-table corner is undefined. This is the only one that makes the vector *unusable*. |
| Pile-up near the crossover | Small anchor changes will move cases between corners, so the result is fragile. |
| Compression to the extremes | The calibration is effectively crisp; the fuzzy detail has been squeezed out. |
| Low variance | The condition barely varies and carries little information. |
| Never present / never absent | Every case falls on one side, so the condition cannot discriminate. |

None is fatal by itself — they are reported so you can decide, not enforced.

## Quantile helpers, and why they are not a calibration

```python
from setqca import suggest_anchors

print(suggest_anchors(raw["innovation"]))
```

```text
Suggested from quantiles (0.05, 0.5, 0.95): full_out=12, crossover=48, full_in=91
Quantiles describe the sample, not the concept. Anchors must be justified
substantively; these are a starting point for that argument, not a substitute
for it.
```

!!! danger "Data-driven anchors are not calibration"
A set defined by its own distribution cannot support a claim about set
membership. If the crossover is the sample median, then "more in than out"
means "above average for these cases" — which changes when you add a case,
and says nothing about the concept.

The helper exists to show you where your cases actually lie so you can
argue for anchors. It returns the caveat attached to the result, and
nothing in this package will apply quantile anchors for you.

## Reusing a calibration

`DirectCalibration` is a frozen dataclass, so a calibration is a value you can
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/robustness.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ Four scales are available:
from setqca.analysis.robustness import solution_similarity

similarity = solution_similarity(left_terms, right_terms, data)
similarity.identical # exact set equality
similarity.term_overlap # Jaccard over terms
similarity.identical # exact set equality
similarity.term_overlap # Jaccard over terms
similarity.configurational # Jaccard over the literals used
similarity.membership # fuzzy Jaccard over case membership
similarity.membership # fuzzy Jaccard over case membership
```

The last is the one that catches agreement the text hides: two solutions can be
Expand Down
30 changes: 29 additions & 1 deletion src/setqca/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,23 @@
robustness_analysis,
sufficiency_diagnostics,
)
from .calibration import DirectCalibration, calibrate_crisp, calibrate_direct
from .calibration import (
AnchorSuggestion,
CalibrationDiagnostics,
CalibrationMethod,
CalibrationResult,
CalibrationSpec,
DirectCalibration,
calibrate,
calibrate_crisp,
calibrate_direct,
crisp_spec,
diagnose_calibration,
diagnose_frame,
direct_spec,
indirect_spec,
suggest_anchors,
)
from .counterfactuals import (
CounterfactualAnalysis,
DirectionalExpectation,
Expand Down Expand Up @@ -61,7 +77,12 @@
__all__ = [
"CSQCA",
"FSQCA",
"AnchorSuggestion",
"BooleanSolution",
"CalibrationDiagnostics",
"CalibrationMethod",
"CalibrationResult",
"CalibrationSpec",
"CaseDiagnostic",
"CaseRole",
"Condition",
Expand Down Expand Up @@ -97,10 +118,16 @@
"__version__",
"build_chart",
"build_truth_table",
"calibrate",
"calibrate_crisp",
"calibrate_direct",
"classify_counterfactuals",
"crisp_spec",
"diagnose_calibration",
"diagnose_frame",
"direct_spec",
"evaluate_expression",
"indirect_spec",
"minimize",
"minimize_chart",
"necessity",
Expand All @@ -110,4 +137,5 @@
"simplify_expression",
"sufficiency",
"sufficiency_diagnostics",
"suggest_anchors",
]
81 changes: 81 additions & 0 deletions src/setqca/calibration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Calibration: turning raw measures into set memberships.

Calibration is where substantive knowledge enters a QCA, and where a result is
most easily manufactured. The primitives are here, along with reproducible
specifications, diagnostics for the failures that spoil a truth table, and
quantile helpers that are explicitly *not* a calibration.

Examples
--------
>>> from setqca.calibration import calibrate, direct_spec
>>> spec = direct_spec("innovation", full_out=20, crossover=50, full_in=80)
>>> result = calibrate([10, 50, 90], spec) # doctest: +SKIP
>>> result.diagnostics.warnings # doctest: +SKIP
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from ._diagnostics import (
AnchorSuggestion,
CalibrationDiagnostics,
CalibrationResult,
diagnose_calibration,
diagnose_frame,
suggest_anchors,
)
from ._direct import DirectCalibration, calibrate_crisp, calibrate_direct
from ._spec import (
CalibrationMethod,
CalibrationSpec,
crisp_spec,
direct_spec,
indirect_spec,
)

if TYPE_CHECKING: # pragma: no cover - imported for type checking only
import numpy.typing as npt

__all__ = [
"AnchorSuggestion",
"CalibrationDiagnostics",
"CalibrationMethod",
"CalibrationResult",
"CalibrationSpec",
"DirectCalibration",
"calibrate",
"calibrate_crisp",
"calibrate_direct",
"crisp_spec",
"diagnose_calibration",
"diagnose_frame",
"direct_spec",
"indirect_spec",
"suggest_anchors",
]


def calibrate(values: npt.ArrayLike, spec: CalibrationSpec) -> CalibrationResult:
"""Apply a specification and diagnose the result in one step.

Parameters
----------
values : array_like
Raw values, or calibrated ones for the identity method.
spec : CalibrationSpec
The calibration to apply.

Returns
-------
CalibrationResult
The calibrated values, the specification that produced them, and
diagnostics. Keeping the three together is what makes a calibration
reproducible rather than a number that appeared once.
"""
calibrated = spec.apply(values)
return CalibrationResult(
spec=spec,
values=calibrated,
diagnostics=diagnose_calibration(calibrated, name=spec.condition),
)
Loading
Loading