Skip to content

Calibration

Calibration converts raw measures into set memberships. It is the step where substantive knowledge enters the analysis, and it determines everything downstream. A truth table built on poor anchors is precisely wrong.

Direct calibration

Three anchors define the transformation:

Anchor Membership Meaning
full_out 1 - idm Fully outside the set
crossover 0.5 Maximum ambiguity
full_in idm Fully inside the set
from setqca import calibrate_direct

membership = calibrate_direct(
    raw_innovation,
    full_out=10,
    crossover=50,
    full_in=90,
)

With the default idm=0.95, the anchors map to approximately 0.05, 0.5 and 0.95 for an increasing set.

Increasing and decreasing sets

The anchor order selects the direction. full_out < crossover < full_in defines an increasing set; reversing the order defines a decreasing one.

# "low corruption": higher raw scores mean lower membership
low_corruption = calibrate_direct(corruption, full_out=80, crossover=50, full_in=20)

Anchors that are not strictly ordered around the crossover raise ValueError rather than producing an arbitrary curve.

Logistic versus piecewise

logistic = calibrate_direct(x, full_out=0, crossover=50, full_in=100)
piecewise = calibrate_direct(x, full_out=0, crossover=50, full_in=100, logistic=False)
Logistic (default) Piecewise
Endpoints Approached asymptotically, never exactly reached Exactly 0 and 1 at and beyond the anchors
Shape control idm below and above exponents
Use when You want the standard smooth transformation You need exact full membership for cases at or beyond an anchor

The logistic transformation is evaluated in a numerically stable form, so values far outside the anchors saturate cleanly to 0 or 1 rather than overflowing.

Shaping the piecewise curve

below and above are positive exponents applied on either side of the crossover. Raising above accelerates the approach to full membership:

calibrate_direct([75], full_out=0, crossover=50, full_in=100, logistic=False)  # 0.750
calibrate_direct([75], full_out=0, crossover=50, full_in=100, logistic=False, above=2.0)  # 0.875

The anchors themselves always map to 0, 0.5 and 1 regardless of the exponents.

Crisp calibration

calibrate_crisp cuts a raw variable into ordered integer categories using threshold semantics equivalent to R's findInterval.

from setqca import calibrate_crisp

binary = calibrate_crisp(gdp_per_capita, [20_000])
categories = calibrate_crisp(gdp_per_capita, [10_000, 20_000, 30_000])

One threshold yields a binary crisp set. Multiple thresholds yield categories 0..k; this signature is the forward-compatible API for multi-value QCA, which is a roadmap item rather than a current feature.

Thresholds are sorted automatically, and duplicates raise ValueError.

The 0.5 problem

A membership of exactly 0.5 has no defined truth-table corner: it is neither more in than out nor more out than in. setqca refuses to guess.

build_truth_table(data, outcome="Y", conditions=["A"])
# ValueError: At least one condition is exactly 0.5 ...

Resolve these cases substantively — by revisiting the anchors, or by making a documented decision about the case — or opt in explicitly:

build_truth_table(data, outcome="Y", conditions=["A"], allow_crossover_cases=True)

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:

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:

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:

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.

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

from setqca import suggest_anchors

print(suggest_anchors(raw["innovation"]))
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.

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 store, pass around, and apply to new cases — which is what you need when extending an analysis to additional cases without silently re-anchoring.

from setqca import DirectCalibration

spec = DirectCalibration(full_out=10, crossover=50, full_in=90)
train = spec.transform(raw_train)
holdout = spec.transform(raw_holdout)

setqca.calibration

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)
>>> result.diagnostics.warnings

AnchorSuggestion dataclass

AnchorSuggestion(
    quantiles: tuple[float, float, float],
    values: tuple[float, float, float],
    caveat: str = "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.",
)

Quantiles of a raw variable, offered as a starting point only.

Attributes:

Name Type Description
quantiles tuple[float, float, float]

The probabilities used.

values tuple[float, float, float]

The corresponding raw values, in ascending order.

caveat str

A standing reminder that these are not a calibration.

anchors property

anchors: tuple[float, float, float]

Return the suggested (full_out, crossover, full_in).

CalibrationDiagnostics dataclass

CalibrationDiagnostics(
    n: int,
    at_crossover: int,
    near_crossover: int,
    extreme: int,
    minimum: float,
    maximum: float,
    mean: float,
    standard_deviation: float,
    above_crossover: int,
    warnings: tuple[str, ...],
)

What a calibrated vector looks like, and what is worrying about it.

Attributes:

Name Type Description
n int

Number of cases.

at_crossover int

Cases with membership exactly 0.5. Their truth-table corner is undefined, so these block analysis rather than merely warning.

near_crossover int

Cases within CROSSOVER_BAND of 0.5.

extreme int

Cases within EXTREME_BAND of 0 or 1.

minimum, maximum, mean, standard_deviation

Summary statistics of the calibrated values.

above_crossover int

Cases that will be assigned the "present" corner.

warnings tuple[str, ...]

Human-readable descriptions of every issue found.

usable property

usable: bool

Return whether the vector can be used for a truth table at all.

Only an exact crossover membership makes a vector unusable; everything else is a judgement call left to the researcher.

pile_up_share property

pile_up_share: float

Return the proportion of cases bunched around the crossover.

compression_share property

compression_share: float

Return the proportion of cases pushed to the extremes.

CalibrationResult dataclass

CalibrationResult(
    spec: object,
    values: FloatArray,
    diagnostics: CalibrationDiagnostics,
)

Calibrated values together with the specification that produced them.

to_frame

to_frame() -> DataFrame

Return the calibrated values as a one-column frame.

Source code in src/setqca/calibration/_diagnostics.py
def to_frame(self) -> pd.DataFrame:
    """Return the calibrated values as a one-column frame."""
    condition = getattr(self.spec, "condition", "values")
    return pd.DataFrame({condition: self.values})

DirectCalibration dataclass

DirectCalibration(
    full_out: float,
    crossover: float,
    full_in: float,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
)

Three-anchor direct fuzzy-set calibration specification.

The logistic implementation follows the standard three-anchor direct calibration parameterisation: the crossover maps to 0.5 and the inclusion and exclusion anchors map to idm and 1-idm respectively.

transform

transform(values: ArrayLike) -> FloatArray

Calibrate raw values into fuzzy membership scores.

Parameters:

Name Type Description Default
values array_like

Raw numeric values on the original measurement scale.

required

Returns:

Type Description
FloatArray

Membership scores in [0, 1].

Source code in src/setqca/calibration/_direct.py
def transform(self, values: npt.ArrayLike) -> FloatArray:
    """Calibrate raw values into fuzzy membership scores.

    Parameters
    ----------
    values : array_like
        Raw numeric values on the original measurement scale.

    Returns
    -------
    FloatArray
        Membership scores in ``[0, 1]``.
    """
    x = as_float_array(values, name="values")
    if self.logistic:
        return self._logistic(x)
    return self._piecewise(x)

CalibrationMethod

Bases: Enum

How raw values become set memberships.

CalibrationSpec dataclass

CalibrationSpec(
    condition: str,
    method: CalibrationMethod = DIRECT,
    anchors: tuple[float, float, float] | None = None,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
    thresholds: tuple[float, ...] | None = None,
    mapping: tuple[tuple[float, float], ...] | None = None,
    note: str = "",
)

A complete, replayable description of one condition's calibration.

Parameters:

Name Type Description Default
condition str

Name of the condition this calibrates.

required
method CalibrationMethod

Which transformation to apply.

DIRECT
anchors tuple of float

(full_out, crossover, full_in) for the direct method.

None
idm float

Membership at the inclusion anchor, for the direct logistic form.

0.95
logistic bool

Use the logistic rather than piecewise direct transformation.

True
below float

Piecewise shaping exponents.

1.0
above float

Piecewise shaping exponents.

1.0
thresholds tuple of float

Cut points for the crisp method.

None
mapping tuple of (float, float)

(raw, membership) points for the indirect method. Interpolated linearly between points and held flat outside them.

None
note str

Free text recording why these choices were made. Carried through serialisation, because the reason is part of the specification.

''

apply

apply(values: ArrayLike) -> FloatArray

Calibrate raw values according to this specification.

Source code in src/setqca/calibration/_spec.py
def apply(self, values: npt.ArrayLike) -> FloatArray:
    """Calibrate raw values according to this specification."""
    if self.method is CalibrationMethod.DIRECT:
        assert self.anchors is not None
        return DirectCalibration(
            full_out=self.anchors[0],
            crossover=self.anchors[1],
            full_in=self.anchors[2],
            idm=self.idm,
            logistic=self.logistic,
            below=self.below,
            above=self.above,
        ).transform(values)
    if self.method is CalibrationMethod.CRISP:
        assert self.thresholds is not None
        categories = calibrate_crisp(values, self.thresholds)
        # A single threshold yields a binary set; several yield ordered
        # categories, rescaled onto [0, 1] so downstream code sees
        # memberships rather than raw category indices.
        top = len(self.thresholds)
        return (categories / top).astype(np.float64)
    if self.method is CalibrationMethod.INDIRECT:
        assert self.mapping is not None
        raw = as_float_array(values, name=self.condition)
        points = np.asarray([point[0] for point in self.mapping], dtype=np.float64)
        targets = np.asarray([point[1] for point in self.mapping], dtype=np.float64)
        interpolated: FloatArray = np.interp(raw, points, targets).astype(np.float64)
        return interpolated
    return validate_membership(values, name=self.condition)

with_anchors

with_anchors(
    anchors: tuple[float, float, float],
) -> CalibrationSpec

Return a copy with different anchors, for sensitivity work.

Source code in src/setqca/calibration/_spec.py
def with_anchors(self, anchors: tuple[float, float, float]) -> CalibrationSpec:
    """Return a copy with different anchors, for sensitivity work."""
    return replace(self, anchors=anchors)

to_dict

to_dict() -> dict[str, Any]

Return a plain dictionary suitable for JSON or YAML.

Source code in src/setqca/calibration/_spec.py
def to_dict(self) -> dict[str, Any]:
    """Return a plain dictionary suitable for JSON or YAML."""
    payload: dict[str, Any] = {
        "condition": self.condition,
        "method": self.method.value,
        "note": self.note,
    }
    if self.anchors is not None:
        payload["anchors"] = list(self.anchors)
        payload["idm"] = self.idm
        payload["logistic"] = self.logistic
        payload["below"] = self.below
        payload["above"] = self.above
    if self.thresholds is not None:
        payload["thresholds"] = list(self.thresholds)
    if self.mapping is not None:
        payload["mapping"] = [list(point) for point in self.mapping]
    return payload

from_dict classmethod

from_dict(payload: dict[str, Any]) -> CalibrationSpec

Rebuild a specification from :meth:to_dict output.

Raises:

Type Description
KeyError

If the payload lacks a condition or method.

ValueError

If the method is unknown, or the specification is invalid.

Source code in src/setqca/calibration/_spec.py
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> CalibrationSpec:
    """Rebuild a specification from :meth:`to_dict` output.

    Raises
    ------
    KeyError
        If the payload lacks a condition or method.
    ValueError
        If the method is unknown, or the specification is invalid.
    """
    anchors = payload.get("anchors")
    thresholds = payload.get("thresholds")
    mapping = payload.get("mapping")
    try:
        method = CalibrationMethod(payload["method"])
    except ValueError as error:
        raise ValueError(f"Unknown calibration method {payload['method']!r}.") from error
    return cls(
        condition=payload["condition"],
        method=method,
        anchors=None if anchors is None else (anchors[0], anchors[1], anchors[2]),
        idm=payload.get("idm", 0.95),
        logistic=payload.get("logistic", True),
        below=payload.get("below", 1.0),
        above=payload.get("above", 1.0),
        thresholds=None if thresholds is None else tuple(thresholds),
        mapping=None if mapping is None else tuple((p[0], p[1]) for p in mapping),
        note=payload.get("note", ""),
    )

to_json

to_json(
    *, indent: int | None = None, sort_keys: bool = False
) -> str

Serialise to a JSON string.

Parameters:

Name Type Description Default
indent int

Passed to :func:json.dumps for readable output.

None
sort_keys bool

Sort keys, which makes stored specifications diff cleanly.

False
Source code in src/setqca/calibration/_spec.py
def to_json(self, *, indent: int | None = None, sort_keys: bool = False) -> str:
    """Serialise to a JSON string.

    Parameters
    ----------
    indent : int, optional
        Passed to :func:`json.dumps` for readable output.
    sort_keys : bool, default False
        Sort keys, which makes stored specifications diff cleanly.
    """
    return json.dumps(self.to_dict(), indent=indent, sort_keys=sort_keys)

from_json classmethod

from_json(text: str) -> CalibrationSpec

Rebuild a specification from JSON.

Source code in src/setqca/calibration/_spec.py
@classmethod
def from_json(cls, text: str) -> CalibrationSpec:
    """Rebuild a specification from JSON."""
    return cls.from_dict(json.loads(text))

diagnose_calibration

diagnose_calibration(
    values: ArrayLike, *, name: str = "values"
) -> CalibrationDiagnostics

Inspect a calibrated vector for the failures that spoil a truth table.

Parameters:

Name Type Description Default
values array_like

Calibrated memberships in [0, 1].

required
name str

Name used in the warning messages.

"values"

Returns:

Type Description
CalibrationDiagnostics

Summary statistics and a list of warnings.

Raises:

Type Description
ValueError

If the values are not calibrated memberships.

Source code in src/setqca/calibration/_diagnostics.py
def diagnose_calibration(values: npt.ArrayLike, *, name: str = "values") -> CalibrationDiagnostics:
    """Inspect a calibrated vector for the failures that spoil a truth table.

    Parameters
    ----------
    values : array_like
        Calibrated memberships in ``[0, 1]``.
    name : str, default "values"
        Name used in the warning messages.

    Returns
    -------
    CalibrationDiagnostics
        Summary statistics and a list of warnings.

    Raises
    ------
    ValueError
        If the values are not calibrated memberships.
    """
    membership = validate_membership(values, name=name)
    n = int(membership.size)

    at_crossover = int(np.sum(np.isclose(membership, 0.5, atol=1e-12)))
    near_crossover = int(np.sum(np.abs(membership - 0.5) < CROSSOVER_BAND))
    extreme = int(np.sum((membership < EXTREME_BAND) | (membership > 1.0 - EXTREME_BAND)))
    above = int(np.sum(membership >= 0.5))
    deviation = float(np.std(membership))

    warnings: list[str] = []
    if at_crossover:
        warnings.append(
            f"{at_crossover} case(s) sit exactly at 0.5, where the truth-table "
            "corner is undefined; resolve them or set allow_crossover_cases"
        )
    if n and near_crossover / n > PILE_UP_SHARE:
        warnings.append(
            f"{near_crossover}/{n} cases lie within {CROSSOVER_BAND} of the crossover; "
            "small anchor changes will move them between corners"
        )
    if n and extreme / n > COMPRESSION_SHARE:
        warnings.append(
            f"{extreme}/{n} cases are at the extremes; the calibration is close to "
            "crisp and the fuzzy detail has been squeezed out"
        )
    if deviation < LOW_VARIANCE:
        warnings.append(
            f"standard deviation is {deviation:.3f}; the condition barely varies and "
            "will carry little information"
        )
    if above == 0:
        warnings.append("no case is above the crossover; the condition is never present")
    elif above == n:
        warnings.append("every case is above the crossover; the condition is never absent")

    return CalibrationDiagnostics(
        n=n,
        at_crossover=at_crossover,
        near_crossover=near_crossover,
        extreme=extreme,
        minimum=float(np.min(membership)),
        maximum=float(np.max(membership)),
        mean=float(np.mean(membership)),
        standard_deviation=deviation,
        above_crossover=above,
        warnings=tuple(warnings),
    )

diagnose_frame

diagnose_frame(
    data: DataFrame,
    columns: list[str] | tuple[str, ...] | None = None,
) -> DataFrame

Diagnose every calibrated column and return a tidy summary.

Parameters:

Name Type Description Default
data DataFrame

Calibrated memberships.

required
columns list of str

Columns to check. Defaults to every column.

None

Returns:

Type Description
DataFrame

One row per column, with the summary statistics and a joined warning string.

Source code in src/setqca/calibration/_diagnostics.py
def diagnose_frame(
    data: pd.DataFrame, columns: list[str] | tuple[str, ...] | None = None
) -> pd.DataFrame:
    """Diagnose every calibrated column and return a tidy summary.

    Parameters
    ----------
    data : pandas.DataFrame
        Calibrated memberships.
    columns : list of str, optional
        Columns to check. Defaults to every column.

    Returns
    -------
    pandas.DataFrame
        One row per column, with the summary statistics and a joined warning
        string.
    """
    names = list(data.columns) if columns is None else list(columns)
    records = []
    for name in names:
        diagnostics = diagnose_calibration(data[name].to_numpy(), name=name)
        records.append(
            {
                "condition": name,
                "n": diagnostics.n,
                "mean": diagnostics.mean,
                "sd": diagnostics.standard_deviation,
                "min": diagnostics.minimum,
                "max": diagnostics.maximum,
                "at_crossover": diagnostics.at_crossover,
                "near_crossover": diagnostics.near_crossover,
                "extreme": diagnostics.extreme,
                "above_crossover": diagnostics.above_crossover,
                "usable": diagnostics.usable,
                "warnings": "; ".join(diagnostics.warnings),
            }
        )
    return pd.DataFrame.from_records(records)

suggest_anchors

suggest_anchors(
    values: ArrayLike,
    *,
    quantiles: tuple[float, float, float] = (
        0.05,
        0.5,
        0.95,
    ),
) -> AnchorSuggestion

Report quantiles of a raw variable to inform an anchor decision.

This is a diagnostic, not a calibration. Data-driven anchors describe the sample rather than the concept, and a set defined by its own distribution cannot support a claim about set membership. Use the output to see where your cases actually lie, then argue for anchors on substantive grounds.

Parameters:

Name Type Description Default
values array_like

Raw, uncalibrated measures.

required
quantiles tuple of float

Probabilities for the exclusion, crossover and inclusion anchors.

(0.05, 0.50, 0.95)

Returns:

Type Description
AnchorSuggestion

The quantile values, with the caveat attached.

Raises:

Type Description
ValueError

If the quantiles are not strictly increasing and inside [0, 1].

Source code in src/setqca/calibration/_diagnostics.py
def suggest_anchors(
    values: npt.ArrayLike,
    *,
    quantiles: tuple[float, float, float] = (0.05, 0.50, 0.95),
) -> AnchorSuggestion:
    """Report quantiles of a raw variable to inform an anchor decision.

    This is a **diagnostic**, not a calibration. Data-driven anchors describe
    the sample rather than the concept, and a set defined by its own
    distribution cannot support a claim about set membership. Use the output to
    see where your cases actually lie, then argue for anchors on substantive
    grounds.

    Parameters
    ----------
    values : array_like
        Raw, uncalibrated measures.
    quantiles : tuple of float, default (0.05, 0.50, 0.95)
        Probabilities for the exclusion, crossover and inclusion anchors.

    Returns
    -------
    AnchorSuggestion
        The quantile values, with the caveat attached.

    Raises
    ------
    ValueError
        If the quantiles are not strictly increasing and inside ``[0, 1]``.
    """
    if not all(0.0 <= q <= 1.0 for q in quantiles):
        raise ValueError("Quantiles must lie in [0, 1].")
    if not quantiles[0] < quantiles[1] < quantiles[2]:
        raise ValueError("Quantiles must be strictly increasing.")

    raw = as_float_array(values, name="values")
    low, mid, high = (float(value) for value in np.quantile(raw, quantiles))
    if not low < mid < high:
        raise ValueError(
            "The requested quantiles are not distinct in this sample, so they "
            "cannot serve as anchors; the variable may be too concentrated."
        )
    return AnchorSuggestion(quantiles=quantiles, values=(low, mid, high))

calibrate_crisp

calibrate_crisp(
    values: ArrayLike, thresholds: ArrayLike
) -> NDArray[int64]

Calibrate a raw numeric variable into ordered crisp categories.

With one threshold this produces a binary crisp set. Multiple thresholds produce integer categories 0..k and form a future mvQCA-compatible API.

Source code in src/setqca/calibration/_direct.py
def calibrate_crisp(values: npt.ArrayLike, thresholds: npt.ArrayLike) -> npt.NDArray[np.int64]:
    """Calibrate a raw numeric variable into ordered crisp categories.

    With one threshold this produces a binary crisp set. Multiple thresholds
    produce integer categories ``0..k`` and form a future mvQCA-compatible API.
    """
    x = as_float_array(values, name="values")
    cuts = as_float_array(thresholds, name="thresholds")
    if np.any(np.diff(np.sort(cuts)) <= 0):
        raise ValueError("thresholds must be unique.")
    cuts = np.sort(cuts)
    return np.searchsorted(cuts, x, side="right").astype(np.int64)

calibrate_direct

calibrate_direct(
    values: ArrayLike,
    *,
    full_out: float,
    crossover: float,
    full_in: float,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
) -> FloatArray

Calibrate raw values with three-anchor direct fuzzy calibration.

Convenience wrapper around :class:DirectCalibration for one-shot use.

Parameters:

Name Type Description Default
values array_like

Raw numeric values to calibrate.

required
full_out float

The three qualitative anchors. full_out < crossover < full_in defines an increasing set; the reverse order defines a decreasing set.

required
crossover float

The three qualitative anchors. full_out < crossover < full_in defines an increasing set; the reverse order defines a decreasing set.

required
full_in float

The three qualitative anchors. full_out < crossover < full_in defines an increasing set; the reverse order defines a decreasing set.

required
idm float

Membership assigned to the inclusion anchor. Must lie in (0.5, 1).

0.95
logistic bool

Use the logistic transformation. When False, a piecewise linear/power transformation with exact endpoints is used instead.

True
below float

Positive exponents shaping the piecewise transformation on either side of the crossover. Ignored when logistic is True.

1.0
above float

Positive exponents shaping the piecewise transformation on either side of the crossover. Ignored when logistic is True.

1.0

Returns:

Type Description
FloatArray

Fuzzy membership scores in [0, 1].

Examples:

>>> calibrate_direct([10, 20, 30], full_out=10, crossover=20, full_in=30).round(2)
array([0.05, 0.5 , 0.95])
Source code in src/setqca/calibration/_direct.py
def calibrate_direct(
    values: npt.ArrayLike,
    *,
    full_out: float,
    crossover: float,
    full_in: float,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
) -> FloatArray:
    """Calibrate raw values with three-anchor direct fuzzy calibration.

    Convenience wrapper around :class:`DirectCalibration` for one-shot use.

    Parameters
    ----------
    values : array_like
        Raw numeric values to calibrate.
    full_out, crossover, full_in : float
        The three qualitative anchors. ``full_out < crossover < full_in``
        defines an increasing set; the reverse order defines a decreasing set.
    idm : float, default 0.95
        Membership assigned to the inclusion anchor. Must lie in ``(0.5, 1)``.
    logistic : bool, default True
        Use the logistic transformation. When ``False``, a piecewise
        linear/power transformation with exact endpoints is used instead.
    below, above : float, default 1.0
        Positive exponents shaping the piecewise transformation on either side
        of the crossover. Ignored when ``logistic`` is ``True``.

    Returns
    -------
    FloatArray
        Fuzzy membership scores in ``[0, 1]``.

    Examples
    --------
    >>> calibrate_direct([10, 20, 30], full_out=10, crossover=20, full_in=30).round(2)
    array([0.05, 0.5 , 0.95])
    """
    return DirectCalibration(
        full_out=full_out,
        crossover=crossover,
        full_in=full_in,
        idm=idm,
        logistic=logistic,
        below=below,
        above=above,
    ).transform(values)

crisp_spec

crisp_spec(
    condition: str,
    *,
    thresholds: tuple[float, ...],
    note: str = "",
) -> CalibrationSpec

Build a crisp threshold specification.

Source code in src/setqca/calibration/_spec.py
def crisp_spec(condition: str, *, thresholds: tuple[float, ...], note: str = "") -> CalibrationSpec:
    """Build a crisp threshold specification."""
    return CalibrationSpec(
        condition=condition,
        method=CalibrationMethod.CRISP,
        thresholds=thresholds,
        note=note,
    )

direct_spec

direct_spec(
    condition: str,
    *,
    full_out: float,
    crossover: float,
    full_in: float,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
    note: str = "",
) -> CalibrationSpec

Build a three-anchor direct calibration specification.

Source code in src/setqca/calibration/_spec.py
def direct_spec(
    condition: str,
    *,
    full_out: float,
    crossover: float,
    full_in: float,
    idm: float = 0.95,
    logistic: bool = True,
    below: float = 1.0,
    above: float = 1.0,
    note: str = "",
) -> CalibrationSpec:
    """Build a three-anchor direct calibration specification."""
    return CalibrationSpec(
        condition=condition,
        method=CalibrationMethod.DIRECT,
        anchors=(full_out, crossover, full_in),
        idm=idm,
        logistic=logistic,
        below=below,
        above=above,
        note=note,
    )

indirect_spec

indirect_spec(
    condition: str,
    *,
    mapping: tuple[tuple[float, float], ...],
    note: str = "",
) -> CalibrationSpec

Build an explicit monotone mapping specification.

Use this when theory dictates a shape the direct transformation cannot express — a plateau, a step, an asymmetric ramp. Points are interpolated linearly and held flat beyond the ends.

Source code in src/setqca/calibration/_spec.py
def indirect_spec(
    condition: str, *, mapping: tuple[tuple[float, float], ...], note: str = ""
) -> CalibrationSpec:
    """Build an explicit monotone mapping specification.

    Use this when theory dictates a shape the direct transformation cannot
    express — a plateau, a step, an asymmetric ramp. Points are interpolated
    linearly and held flat beyond the ends.
    """
    return CalibrationSpec(
        condition=condition,
        method=CalibrationMethod.INDIRECT,
        mapping=mapping,
        note=note,
    )

calibrate

calibrate(
    values: ArrayLike, spec: CalibrationSpec
) -> CalibrationResult

Apply a specification and diagnose the result in one step.

Parameters:

Name Type Description Default
values array_like

Raw values, or calibrated ones for the identity method.

required
spec CalibrationSpec

The calibration to apply.

required

Returns:

Type Description
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.

Source code in src/setqca/calibration/__init__.py
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),
    )