Skip to content

API reference

Explainer

TriadExplainer / TriadExplanation — the main entry point and its result.

Mode-dispatching TRIAD explainer (spec sections 7.1 and 11.1).

TriadExplainer accepts a fit_lgbm_gam result, a raw LightGBM booster, or a fitted binary EBM, resolves exact vs approximate (SHAP) mode, and produces TriadExplanation objects: a long channels frame, epistemic scores (ES, ES_signed, KR), reason codes and waterfall plots.

TriadExplainer

TriadExplainer(model: Any, *, mode: str | None = None, tau_method: str = 'mom', pair_split: str = 'half', lam: float = 1.0, X_ref: DataFrame | None = None, bag_boosters: tuple[Any, ...] | list[Any] | None = None)

Decompose model predictions into I/D/M channels.

Parameters:

Name Type Description Default
model Any

LGBMGamResult (exact mode), a raw LightGBM booster (exact if additive and X_ref given, else SHAP mode with bag_boosters), or a fitted binary EBM (exact mode).

required
mode str | None

"exact", "shap" or None (inferred).

None
X_ref DataFrame | None

Reference population: training data for bin counts (exact mode on a raw booster) and for the SHAP-mode tau^2 estimate.

None
bag_boosters tuple[Any, ...] | list[Any] | None

Bootstrap replicas providing epistemic variance in SHAP mode.

None
Source code in src/triadxai/local.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def __init__(
    self,
    model: Any,
    *,
    mode: str | None = None,
    tau_method: str = "mom",
    pair_split: str = "half",
    lam: float = 1.0,
    X_ref: pd.DataFrame | None = None,
    bag_boosters: tuple[Any, ...] | list[Any] | None = None,
) -> None:
    if mode not in (None, "exact", "shap"):
        raise ValueError(f"unknown mode {mode!r}; expected 'exact', 'shap' or None")
    self._pair_split = pair_split
    if isinstance(model, LGBMGamResult):
        if (mode or "exact") == "exact":
            self._init_exact_lgbm(model, tau_method, lam)
        else:
            self._init_shap(
                model.booster,
                model.bag_boosters,
                model.X_train if X_ref is None else X_ref,
            )
    elif getattr(model, "term_scores_", None) is not None:
        if mode == "shap":
            raise ValueError("EBM models support exact mode only")
        self._init_exact_ebm(model, tau_method, lam)
    elif hasattr(model, "dump_model"):
        self._init_raw_booster(model, mode, tau_method, lam, X_ref, bag_boosters)
    else:
        raise ValueError(
            "unsupported model type: expected LGBMGamResult, a LightGBM Booster "
            "or a fitted EBM"
        )

shrinkage property

shrinkage: DataFrame

Audit frame of shrinkage parameters (spec 4.4).

explain

explain(X: DataFrame) -> TriadExplanation

Decompose the model score for each row of X.

Source code in src/triadxai/local.py
201
202
203
204
205
def explain(self, X: pd.DataFrame) -> TriadExplanation:
    """Decompose the model score for each row of X."""
    if self.mode == "exact":
        return self._explain_exact(X)
    return self._explain_shap(X)

TriadExplanation dataclass

TriadExplanation(channels: DataFrame, term_channels: DataFrame | None, score: ndarray, intercept: float, mode: str, approximate: bool)

Channel decomposition for a batch of instances.

epistemic_score

epistemic_score() -> pd.DataFrame

Per-instance ES, ES_signed and knowledge ratio KR (spec 7.1).

Source code in src/triadxai/local.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def epistemic_score(self) -> pd.DataFrame:
    """Per-instance ES, ES_signed and knowledge ratio KR (spec 7.1)."""
    tmp = self.channels.assign(
        abs_i=self.channels["I"].abs(),
        abs_dm=self.channels["D"].abs() + self.channels["M"].abs(),
        dm=self.channels["D"] + self.channels["M"],
    )
    grouped = tmp.groupby("instance")[["abs_i", "abs_dm", "dm"]].sum()
    es = grouped["abs_dm"]
    denom = grouped["abs_i"] + es
    kr = (grouped["abs_i"] / denom.where(denom > 0)).rename("KR")
    return pd.DataFrame({"ES": es, "ES_signed": grouped["dm"], "KR": kr}).rename_axis(
        "instance"
    )

plot_waterfall

plot_waterfall(i: int = 0, **kwargs: Any) -> Any

Three-channel waterfall for one instance (needs triadxai[viz]).

Source code in src/triadxai/local.py
66
67
68
69
70
def plot_waterfall(self, i: int = 0, **kwargs: Any) -> Any:
    """Three-channel waterfall for one instance (needs triadxai[viz])."""
    from .waterfall import plot_waterfall

    return plot_waterfall(self, i=i, **kwargs)

reasons

reasons(*, orientation: str = 'higher_is_better', k: int = 4, **kwargs: Any) -> list[list[Any]]

Ranked, group-tagged adverse-action reasons per instance (spec 8).

Source code in src/triadxai/local.py
55
56
57
58
59
60
61
62
63
64
def reasons(
    self, *, orientation: str = "higher_is_better", k: int = 4, **kwargs: Any
) -> list[list[Any]]:
    """Ranked, group-tagged adverse-action reasons per instance (spec 8)."""
    from .reasons import map_reasons

    return [
        map_reasons(group[["feature", "I", "D", "M"]], orientation=orientation, k=k, **kwargs)
        for _, group in self.channels.groupby("instance")
    ]

Contracts

Adapter-agnostic data contracts (TermData, BinnedTerm, ShapData).

Adapter-agnostic data contracts consumed by the TRIAD core.

Exact mode: TermData (per-term bin tensors: missing bin at index 0, unknown bin at index -1 along each axis) plus BinnedTerm (per-instance bin lookups). Approximate mode: ShapData (dense per-instance attributions from the deployed model plus bag-replica variance).

BinnedTerm dataclass

BinnedTerm(term_idx: int, bin_idx: tuple[ndarray, ...], missing: ndarray, oov: ndarray, decay: ndarray)

Per-instance bin lookups for one term at explanation time.

n_samples property

n_samples: int

Number of instances covered by this binning.

ModelData dataclass

ModelData(terms: tuple[TermData, ...], intercept: float, feature_names: tuple[str, ...])

Everything the exact-mode core needs from a fitted additive model.

ShapData dataclass

ShapData(phi: ndarray, base: float, v: ndarray, missing: ndarray, oov: ndarray, feature_names: tuple[str, ...])

Approximate-mode contract: deployed-model attributions + replica variance.

phi comes from the deployed model (e.g. LightGBM pred_contrib, base column excluded); v is the per-instance, per-feature variance of attributions across bag replicas (ddof=1). C1/C2 hold on phi; v only enters through the support weight.

n_features property

n_features: int

Number of features.

n_samples property

n_samples: int

Number of explained instances.

TermData dataclass

TermData(term_idx: int, feature_idxs: tuple[int, ...], f: ndarray, v: ndarray, n: ndarray)

Fitted bin-level data for one additive term.

Tensors f (centered bin scores), v (per-bin sampling variance) and n (per-bin training mass) share one shape; along each axis, index 0 is the missing bin and index -1 the unknown bin.

is_pair property

is_pair: bool

True for a pairwise interaction term.

Shrinkage

Empirical-Bayes τ² estimation (mom / mle / SHAP-mode).

Empirical-Bayes shrinkage strength estimation (spec section 4).

The support weight w = tau^2 / (tau^2 + v) is the posterior weight an empirical-Bayes observer puts on a fitted bin value versus the population prior 0 (terms are centered). tau^2 is the between-bin signal variance, estimated per term; v is the bin's sampling variance.

Shrinkage dataclass

Shrinkage(tau2: float, sigma2: float, k: float)

Per-term shrinkage summary (audit payload, spec section 4.4).

estimate_tau2

estimate_tau2(f: ndarray, v: ndarray, n: ndarray, *, method: str = 'mom') -> float

Estimate the between-bin signal variance tau^2 for one term.

Parameters:

Name Type Description Default
f ndarray

Bin values, bin sampling variances, bin training masses (any shape, flattened internally).

required
v ndarray

Bin values, bin sampling variances, bin training masses (any shape, flattened internally).

required
n ndarray

Bin values, bin sampling variances, bin training masses (any shape, flattened internally).

required
method str

"mom" (default): mass-weighted method of moments, max(0, sum(pi f^2) - sum(pi v)). "mle": 1-D marginal maximum likelihood, more stable for high-cardinality terms.

'mom'
Source code in src/triadxai/shrinkage.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def estimate_tau2(f: np.ndarray, v: np.ndarray, n: np.ndarray, *, method: str = "mom") -> float:
    """Estimate the between-bin signal variance tau^2 for one term.

    Parameters
    ----------
    f, v, n
        Bin values, bin sampling variances, bin training masses (any shape,
        flattened internally).
    method
        ``"mom"`` (default): mass-weighted method of moments,
        ``max(0, sum(pi f^2) - sum(pi v))``. ``"mle"``: 1-D marginal
        maximum likelihood, more stable for high-cardinality terms.
    """
    pi = _mass_weights(n)
    f_ = np.ravel(f).astype(float)
    v_ = np.ravel(v).astype(float)
    if method == "mom":
        return max(0.0, float((pi * f_**2).sum() - (pi * v_).sum()))
    if method == "mle":
        m = pi > 0.0

        def nll(log_tau2: float) -> float:
            s = math.exp(log_tau2) + v_[m]
            return float((pi[m] * (np.log(s) + f_[m] ** 2 / s)).sum())

        res = optimize.minimize_scalar(
            nll, bounds=(math.log(1e-12), math.log(1e6)), method="bounded"
        )
        tau2 = float(math.exp(res.x))
        return 0.0 if tau2 <= _TAU2_FLOOR else tau2
    raise ValueError(f"unknown tau^2 method {method!r}; expected 'mom' or 'mle'")

estimate_tau2_shap

estimate_tau2_shap(phi_ref: ndarray, v_ref: ndarray) -> np.ndarray

Per-feature tau^2 for approximate mode, from a reference population.

Method of moments about the zero prior: tau2_j = max(0, mean(phi_j^2) - mean(v_j)). SHAP attributions are centered on the training population, so a strongly nonzero mean signals a reference-population mismatch and is logged as a warning.

Source code in src/triadxai/shrinkage.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def estimate_tau2_shap(phi_ref: np.ndarray, v_ref: np.ndarray) -> np.ndarray:
    """Per-feature tau^2 for approximate mode, from a reference population.

    Method of moments about the zero prior:
    ``tau2_j = max(0, mean(phi_j^2) - mean(v_j))``. SHAP attributions are
    centered on the training population, so a strongly nonzero mean signals
    a reference-population mismatch and is logged as a warning.
    """
    mean = phi_ref.mean(axis=0)
    std = phi_ref.std(axis=0)
    off = np.abs(mean) > 0.05 * np.where(std > 0, std, 1.0)
    if off.any():
        logger.warning(
            "SHAP attributions off-center for feature indices %s; "
            "reference population may not match training data",
            np.flatnonzero(off).tolist(),
        )
    return np.maximum(0.0, (phi_ref**2).mean(axis=0) - v_ref.mean(axis=0))

fit_shrinkage

fit_shrinkage(term: TermData, *, method: str = 'mom') -> Shrinkage

Estimate tau^2 and the count-form shrinkage strength k for one term.

Source code in src/triadxai/shrinkage.py
74
75
76
77
78
79
80
81
82
83
84
85
def fit_shrinkage(term: TermData, *, method: str = "mom") -> Shrinkage:
    """Estimate tau^2 and the count-form shrinkage strength k for one term."""
    tau2 = estimate_tau2(term.f, term.v, term.n, method=method)
    pi = _mass_weights(term.n)
    sigma2 = float((pi * np.ravel(term.n) * np.ravel(term.v)).sum())
    if tau2 == 0.0:
        logger.warning(
            "term %d: tau^2 = 0 (pure-noise feature); entire contribution -> D",
            term.term_idx,
        )
        return Shrinkage(tau2=0.0, sigma2=sigma2, k=math.inf)
    return Shrinkage(tau2=tau2, sigma2=sigma2, k=sigma2 / tau2)

Decompose

Exact-mode I/D/M channel math.

Exact-mode channel decomposition (spec sections 3 and 6).

Each term's exact contribution f(x) is reallocated into I (supported), D (under-supported residual) and M (missing-bin routing). D is computed as the residual f - w*f so completeness (C1) holds to float roundoff.

TermChannels dataclass

TermChannels(term_idx: int, feature_idxs: tuple[int, ...], f: ndarray, w: ndarray, I: ndarray, D: ndarray, M: ndarray)

Per-instance channel values for one term.

aggregate_features

aggregate_features(channels: Sequence[TermChannels], binned: Sequence[BinnedTerm], n_features: int, *, pair_split: str = 'half') -> dict[str, np.ndarray]

Aggregate term-level channels to (n_samples, n_features) matrices.

Pair terms split their channels equally between the two participating features (spec 6, pair_split="half"). The oov matrix is the OR over all terms a feature participates in.

Source code in src/triadxai/decompose.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def aggregate_features(
    channels: Sequence[TermChannels],
    binned: Sequence[BinnedTerm],
    n_features: int,
    *,
    pair_split: str = "half",
) -> dict[str, np.ndarray]:
    """Aggregate term-level channels to (n_samples, n_features) matrices.

    Pair terms split their channels equally between the two participating
    features (spec 6, ``pair_split="half"``). The ``oov`` matrix is the OR
    over all terms a feature participates in.
    """
    if pair_split != "half":
        raise ValueError(f"unsupported pair_split {pair_split!r}; v0.1 implements 'half'")
    n_samples = channels[0].f.shape[0]
    out = {key: np.zeros((n_samples, n_features)) for key in ("I", "D", "M")}
    oov = np.zeros((n_samples, n_features), dtype=bool)
    for ch, bt in zip(channels, binned, strict=True):
        share = 1.0 / len(ch.feature_idxs)
        for j in ch.feature_idxs:
            for key in ("I", "D", "M"):
                out[key][:, j] += share * getattr(ch, key)
            oov[:, j] |= bt.oov
    out["oov"] = oov
    return out

decompose_term

decompose_term(term: TermData, binned: BinnedTerm, tau2: float) -> TermChannels

Reallocate one term's exact contributions into I/D/M channels.

Source code in src/triadxai/decompose.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def decompose_term(term: TermData, binned: BinnedTerm, tau2: float) -> TermChannels:
    """Reallocate one term's exact contributions into I/D/M channels."""
    f_hit = term.f[tuple(binned.bin_idx)]
    w = support_weights(term, binned, tau2)
    supported = w * f_hit
    residual = f_hit - supported
    miss = binned.missing
    return TermChannels(
        term_idx=term.term_idx,
        feature_idxs=term.feature_idxs,
        f=f_hit,
        w=w,
        I=np.where(miss, 0.0, supported),
        D=residual,
        M=np.where(miss, supported, 0.0),
    )

support_weights

support_weights(term: TermData, binned: BinnedTerm, tau2: float) -> np.ndarray

Effective support weight w per instance (variance form, spec 3.1).

Empty/unseen bins (zero training mass) get w = 0 regardless of their nominal variance; out-of-range instances are decayed (spec 3.4).

Source code in src/triadxai/decompose.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def support_weights(term: TermData, binned: BinnedTerm, tau2: float) -> np.ndarray:
    """Effective support weight w per instance (variance form, spec 3.1).

    Empty/unseen bins (zero training mass) get w = 0 regardless of their
    nominal variance; out-of-range instances are decayed (spec 3.4).
    """
    idx = tuple(binned.bin_idx)
    v_hit = term.v[idx]
    n_hit = term.n[idx]
    if tau2 <= 0.0:
        w = np.zeros_like(v_hit, dtype=float)
    else:
        w = tau2 / (tau2 + v_hit)
    w = np.where(n_hit <= 0.0, 0.0, w)
    return w * binned.decay

Decompose (SHAP)

Approximate-mode reallocation of TreeSHAP attributions.

Approximate-mode channel decomposition (approximate TRIAD, spec section 12).

Per-feature SHAP attributions phi from the deployed model are reallocated into I/D/M with the same routing as exact mode: the support weight uses the per-instance variance of phi across bag replicas instead of per-bin sampling variance. D is the residual phi - I - M, so C1 holds exactly.

ShapChannels dataclass

ShapChannels(I: ndarray, D: ndarray, M: ndarray, w: ndarray)

Dense (n_samples, n_features) channel matrices.

decompose_shap

decompose_shap(data: ShapData, tau2: ndarray) -> ShapChannels

Reallocate deployed-model attributions into I/D/M channels.

Parameters:

Name Type Description Default
data ShapData

Approximate-mode contract (attributions, replica variance, masks).

required
tau2 ndarray

Per-feature signal variance, shape (n_features,), from :func:triadxai.shrinkage.estimate_tau2_shap.

required
Source code in src/triadxai/decompose_shap.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def decompose_shap(data: ShapData, tau2: np.ndarray) -> ShapChannels:
    """Reallocate deployed-model attributions into I/D/M channels.

    Parameters
    ----------
    data
        Approximate-mode contract (attributions, replica variance, masks).
    tau2
        Per-feature signal variance, shape (n_features,), from
        :func:`triadxai.shrinkage.estimate_tau2_shap`.
    """
    if tau2.shape != (data.n_features,):
        raise ValueError(f"tau2 must have shape ({data.n_features},); got {tau2.shape}")
    denom = tau2[None, :] + data.v
    w = np.divide(
        np.broadcast_to(tau2[None, :], denom.shape),
        denom,
        out=np.zeros_like(denom),
        where=denom > 0,
    )
    supported = w * data.phi
    I = np.where(data.missing, 0.0, supported)  # noqa: E741
    M = np.where(data.missing, supported, 0.0)
    D = data.phi - I - M
    return ShapChannels(I=I, D=D, M=M, w=w)

Bagging

Bootstrap replicas as the epistemic-variance source.

Generic bootstrap bagging — the default epistemic-variance source.

No mainstream GBM exposes usable per-feature epistemic variance natively (see docs/DECISIONS.md), so TRIAD refits K bootstrap replicas and reads variance from their disagreement: per-bin shape variance in exact mode, per-instance attribution variance in approximate mode.

fit_bagged

fit_bagged(fit_fn: Callable[[DataFrame, ndarray, int], ModelT], X: DataFrame, y: ndarray, *, n_bags: int = 8, seed: int = 0) -> list[ModelT]

Fit n_bags replicas of a model on bootstrap resamples of (X, y).

fit_fn(X_boot, y_boot, bag_seed) receives a distinct seed per bag. Bootstraps that collapse to a single class (binary targets with rare positives) are redrawn.

Source code in src/triadxai/bagging.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def fit_bagged(
    fit_fn: Callable[[pd.DataFrame, np.ndarray, int], ModelT],
    X: pd.DataFrame,
    y: np.ndarray,
    *,
    n_bags: int = 8,
    seed: int = 0,
) -> list[ModelT]:
    """Fit ``n_bags`` replicas of a model on bootstrap resamples of (X, y).

    ``fit_fn(X_boot, y_boot, bag_seed)`` receives a distinct seed per bag.
    Bootstraps that collapse to a single class (binary targets with rare
    positives) are redrawn.
    """
    y = np.asarray(y)
    n = len(X)
    if len(y) != n:
        raise ValueError(f"X and y length mismatch: {n} vs {len(y)}")
    classes = np.unique(y)
    check_classes = len(classes) <= 20  # classification-like target
    if check_classes and len(classes) < 2:
        raise ValueError("y contains a single class; cannot fit bagged models")
    rng = np.random.default_rng(seed)
    models: list[ModelT] = []
    for bag in range(n_bags):
        for _ in range(_MAX_RESAMPLE_ATTEMPTS):
            idx = rng.integers(0, n, size=n)
            if not check_classes or len(np.unique(y[idx])) == len(classes):
                break
        else:
            raise ValueError(
                f"could not draw a bootstrap containing all classes in "
                f"{_MAX_RESAMPLE_ATTEMPTS} attempts (bag {bag})"
            )
        X_boot = X.iloc[idx].reset_index(drop=True) if hasattr(X, "iloc") else X[idx]
        bag_seed = seed * 10_000 + bag + 1
        models.append(fit_fn(X_boot, y[idx], bag_seed))
    return models

phi_variance

phi_variance(predict_contrib_fns: Sequence[Callable[[ndarray], ndarray]], X: ndarray) -> np.ndarray

Per-instance, per-feature variance of attributions across replicas.

Each callable maps X to an (n_samples, n_features) attribution matrix; the result is the elementwise sample variance (ddof=1) across replicas.

Source code in src/triadxai/bagging.py
65
66
67
68
69
70
71
72
73
74
75
76
77
def phi_variance(
    predict_contrib_fns: Sequence[Callable[[np.ndarray], np.ndarray]],
    X: np.ndarray,
) -> np.ndarray:
    """Per-instance, per-feature variance of attributions across replicas.

    Each callable maps X to an (n_samples, n_features) attribution matrix;
    the result is the elementwise sample variance (ddof=1) across replicas.
    """
    if len(predict_contrib_fns) < 2:
        raise ValueError("need at least 2 replicas to estimate variance")
    stack = np.stack([fn(X) for fn in predict_contrib_fns], axis=0)
    return stack.var(axis=0, ddof=1)

LightGBM

LightGBM adapter (both modes) + fit_lgbm_gam.

LightGBM adapter: exact GAM mode and approximate SHAP mode.

Exact mode requires an additive-constrained booster (every tree splits on at most one feature); shape functions are reconstructed from dump_model() and reconcile with predict(raw_score=True) exactly. Approximate mode uses LightGBM's native TreeSHAP (pred_contrib=True) on any booster. Per-bin / per-instance variances come from bagged replicas.

Boosters trained with init_score or zero_as_missing are not supported.

LGBMGamAdapter

LGBMGamAdapter(result: LGBMGamResult, *, lam: float = 1.0)

Exact-mode extraction from an additive-constrained LightGBM booster.

Source code in src/triadxai/lgbm.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def __init__(self, result: LGBMGamResult, *, lam: float = 1.0) -> None:
    booster = _as_booster(result.booster)
    dump = booster.dump_model()
    trees_by_feature, const = _group_trees(dump)  # raises if not additive
    bag_groups = [_group_trees(_as_booster(b).dump_model())[0] for b in result.bag_boosters]
    if not result.bag_boosters:
        logger.warning("no bag replicas: per-bin variances are zero; D will be degenerate")
    feature_names = tuple(dump["feature_names"])
    X_train = result.X_train
    self._lam = float(lam)
    self._feature_names = feature_names

    terms: list[TermData] = []
    specs: list[_BinSpec] = []
    intercept = const
    for j in range(len(feature_names)):
        trees = trees_by_feature.get(j)
        if not trees:
            continue  # feature never split on: contributes 0 (C8)
        name = feature_names[j]
        col = X_train[name] if name in X_train.columns else X_train.iloc[:, j]
        if isinstance(col.dtype, pd.CategoricalDtype):
            spec, f_raw, n = self._extract_categorical(j, trees, col)
        else:
            spec, f_raw, n = self._extract_numeric(j, trees, col)
        pi = n / n.sum()
        mu = float((pi * f_raw).sum())
        v = self._bin_variances(bag_groups, j, spec, pi, f_raw.shape[0])
        terms.append(
            TermData(
                term_idx=len(terms),
                feature_idxs=(j,),
                f=f_raw - mu,
                v=v,
                n=n,
            )
        )
        specs.append(spec)
        intercept += mu
    self.model_data = ModelData(
        terms=tuple(terms), intercept=float(intercept), feature_names=feature_names
    )
    self._specs = specs

bin

bin(X: DataFrame) -> list[BinnedTerm]

Bin new instances into each term's TermData layout.

Source code in src/triadxai/lgbm.py
292
293
294
295
296
297
298
299
300
301
302
def bin(self, X: pd.DataFrame) -> list[BinnedTerm]:
    """Bin new instances into each term's TermData layout."""
    out: list[BinnedTerm] = []
    for term, spec in zip(self.model_data.terms, self._specs, strict=True):
        name = self._feature_names[spec.feature_idx]
        col = X[name] if name in X.columns else X.iloc[:, spec.feature_idx]
        if spec.kind == "numeric":
            out.append(self._bin_numeric(term, spec, col))
        else:
            out.append(self._bin_categorical(term, spec, col))
    return out

LGBMGamResult dataclass

LGBMGamResult(booster: Booster, bag_boosters: tuple[Booster, ...], X_train: DataFrame)

A fitted additive-constrained booster plus its bag replicas.

fit_lgbm_gam

fit_lgbm_gam(X: DataFrame, y: ndarray, *, n_bags: int = 8, seed: int = 0, **lgbm_params: Any) -> LGBMGamResult

Fit an additive-constrained (GAM-mode) LightGBM classifier with bags.

Every tree is restricted to a single feature via interaction constraints, so the booster is an additive model and exact-mode TRIAD applies.

Source code in src/triadxai/lgbm.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def fit_lgbm_gam(
    X: pd.DataFrame,
    y: np.ndarray,
    *,
    n_bags: int = 8,
    seed: int = 0,
    **lgbm_params: Any,
) -> LGBMGamResult:
    """Fit an additive-constrained (GAM-mode) LightGBM classifier with bags.

    Every tree is restricted to a single feature via interaction constraints,
    so the booster is an additive model and exact-mode TRIAD applies.
    """
    y = np.asarray(y)
    if len(np.unique(y)) != 2:
        raise ValueError("fit_lgbm_gam requires a binary target in v0.1")
    if "init_score" in lgbm_params:
        raise ValueError("init_score is not supported: it breaks score reconciliation")
    params: dict[str, Any] = {
        "objective": "binary",
        "learning_rate": 0.05,
        "verbosity": -1,
        "interaction_constraints": [[j] for j in range(X.shape[1])],
    }
    params.update(lgbm_params)
    num_boost_round = int(params.pop("n_estimators", 300))

    def _fit(X_boot: pd.DataFrame, y_boot: np.ndarray, bag_seed: int) -> lgb.Booster:
        dataset = lgb.Dataset(X_boot, label=y_boot)
        return lgb.train({**params, "seed": bag_seed}, dataset, num_boost_round=num_boost_round)

    booster = _fit(X, y, seed)
    bags = fit_bagged(_fit, X, y, n_bags=n_bags, seed=seed)
    return LGBMGamResult(booster=booster, bag_boosters=tuple(bags), X_train=X)

is_additive

is_additive(booster: Any) -> bool

True iff every tree in the booster splits on at most one feature.

Source code in src/triadxai/lgbm.py
111
112
113
114
115
116
117
118
119
def is_additive(booster: Any) -> bool:
    """True iff every tree in the booster splits on at most one feature."""
    dump = _as_booster(booster).dump_model()
    for info in dump["tree_info"]:
        feats: set[int] = set()
        _collect_features(info["tree_structure"], feats)
        if len(feats) > 1:
            return False
    return True

lgbm_shap_data

lgbm_shap_data(booster: Any, X: DataFrame, *, bag_boosters: tuple[Any, ...] | list[Any]) -> ShapData

Approximate-mode contract from any LightGBM booster via native TreeSHAP.

Source code in src/triadxai/lgbm.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def lgbm_shap_data(
    booster: Any, X: pd.DataFrame, *, bag_boosters: tuple[Any, ...] | list[Any]
) -> ShapData:
    """Approximate-mode contract from any LightGBM booster via native TreeSHAP."""
    booster = _as_booster(booster)
    if len(bag_boosters) < 2:
        raise ValueError("need at least 2 bag replicas to estimate attribution variance")
    if len(X) == 0:
        raise ValueError("X is empty")
    dump = booster.dump_model()
    feature_names = tuple(dump["feature_names"])
    X = X[list(feature_names)] if all(n in X.columns for n in feature_names) else X
    contrib = booster.predict(X, pred_contrib=True)
    phi, base = contrib[:, :-1], float(contrib[0, -1])

    def _contrib_fn(bag: Any) -> Any:
        replica = _as_booster(bag)
        return lambda Xq: np.asarray(replica.predict(Xq, pred_contrib=True))[:, :-1]

    fns = [_contrib_fn(bag) for bag in bag_boosters]
    v = phi_variance(fns, X)
    missing = X.isna().to_numpy()

    oov = np.zeros_like(missing)
    infos = dump.get("feature_infos", {})
    pandas_categorical = booster.pandas_categorical or []
    cat_pos = 0
    for j, name in enumerate(feature_names):
        col = X.iloc[:, j]
        if isinstance(col.dtype, pd.CategoricalDtype) or col.dtype == object:
            if cat_pos < len(pandas_categorical):
                levels = set(pandas_categorical[cat_pos])
                seen = np.array([val in levels for val in col.to_numpy(dtype=object)])
                oov[:, j] = ~seen & ~missing[:, j]
            cat_pos += 1
            continue
        info = infos.get(name, {})
        lo, hi = info.get("min_value"), info.get("max_value")
        if lo is not None and hi is not None:
            x = col.to_numpy(dtype=float)
            oov[:, j] = ~missing[:, j] & ((x < lo) | (x > hi))
    return ShapData(phi=phi, base=base, v=v, missing=missing, oov=oov, feature_names=feature_names)

EBM

InterpretML EBM adapter (triadxai[ebm]).

InterpretML EBM adapter (optional extra: pip install triadxai[ebm]).

Reads fitted attributes only, so this module never imports interpret; the extra is needed to fit EBMs. Verified against interpret 0.6.16: term_scores_ tensors carry the missing bin at index 0 and the unknown bin at index -1 per axis; standard_deviations_ (outer-bag SDs) and bin_weights_ are aligned 1:1 with them; manual binning via searchsorted(cuts, x, side="right") + 1 reproduces eval_terms.

EBMAdapter

EBMAdapter(ebm: Any, *, lam: float = 1.0)

Exact-mode extraction from a fitted binary EBM classifier/regressor.

Source code in src/triadxai/ebm.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def __init__(self, ebm: Any, *, lam: float = 1.0) -> None:
    if getattr(ebm, "term_scores_", None) is None:
        raise ValueError(
            "model is not a fitted interpret EBM (no term_scores_); "
            "fit an ExplainableBoostingClassifier first (pip install triadxai[ebm])"
        )
    intercept = np.asarray(ebm.intercept_, dtype=float).ravel()
    if intercept.size != 1:
        raise ValueError("multiclass EBMs are not supported in v0.1; fit a binary classifier")
    terms: list[TermData] = []
    for i, term_features in enumerate(ebm.term_features_):
        sds = ebm.standard_deviations_[i]
        if sds is None:
            raise ValueError(
                f"term {i} has no standard_deviations_ (monotonized model?); "
                "TRIAD needs outer-bag variances"
            )
        terms.append(
            TermData(
                term_idx=i,
                feature_idxs=tuple(term_features),
                f=np.asarray(ebm.term_scores_[i], dtype=float),
                v=np.asarray(sds, dtype=float) ** 2,
                n=np.asarray(ebm.bin_weights_[i], dtype=float),
            )
        )
    if all(np.all(t.v == 0.0) for t in terms):
        logger.warning(
            "all outer-bag SDs are zero (outer_bags=1?); the D channel will be degenerate"
        )
    self.model_data = ModelData(
        terms=tuple(terms),
        intercept=float(intercept[0]),
        feature_names=tuple(ebm.feature_names_in_),
    )
    self._lam = float(lam)
    bounds = np.asarray(ebm.feature_bounds_, dtype=float)
    self._features = tuple(
        self._feature_info(kind, levels, bounds[j], self._main_term_for(terms, j))
        for j, (kind, levels) in enumerate(zip(ebm.feature_types_in_, ebm.bins_, strict=True))
    )

bin

bin(X: DataFrame) -> list[BinnedTerm]

Bin new instances into each term's tensor layout.

Source code in src/triadxai/ebm.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def bin(self, X: pd.DataFrame) -> list[BinnedTerm]:
    """Bin new instances into each term's tensor layout."""
    columns = {j: self._clean_column(X, j) for j in range(len(self.model_data.feature_names))}
    out: list[BinnedTerm] = []
    n_rows = len(X)
    for term in self.model_data.terms:
        ndim = len(term.feature_idxs)
        idxs: list[np.ndarray] = []
        missing = np.zeros(n_rows, dtype=bool)
        oov = np.zeros(n_rows, dtype=bool)
        decay = np.ones(n_rows)
        for dim, j in enumerate(term.feature_idxs):
            values, miss = columns[j]
            level = min(len(self._features[j].levels), ndim) - 1
            idx, dim_oov, dim_decay = self._bin_one(j, level, values, miss, term.f.shape[dim])
            idxs.append(idx)
            missing |= miss
            oov |= dim_oov
            decay = decay * dim_decay
        out.append(BinnedTerm(term.term_idx, tuple(idxs), missing, oov, decay))
    return out

Reasons

Reg-B-style reason-code mapping.

Reason-code mapping layer (spec section 8).

Deterministic post-processor from a local decomposition to ranked adverse-action reasons: Group A (derogatory information, driven by the I channel) versus Group B (insufficient information, driven by D + M). The mapping table ships configurable; legally operative wording stays with the lender's compliance function.

ReasonCode dataclass

ReasonCode(feature: str, group: str, subtype: str, contribution: float, text: str)

One ranked adverse-action reason.

map_reasons

map_reasons(instance_channels: DataFrame, *, dictionary: Mapping[str, str] | None = None, orientation: str = 'higher_is_better', k: int = 4, theta_abs: float = 0.05, theta_rel: float = 0.5) -> list[ReasonCode]

Map one instance's channels to ranked, group-tagged reason codes.

Parameters:

Name Type Description Default
instance_channels DataFrame

Frame with columns feature, I, D, M for one instance.

required
dictionary Mapping[str, str] | None

Optional feature -> approved reason text mapping; features not in the dictionary fall back to subtype templates.

None
orientation str

"higher_is_better" (default): negative contributions worsen the score. "lower_is_better" flips the sign convention.

'higher_is_better'
k int

Maximum number of reasons emitted.

4
theta_abs float

Group B is emitted only when |D + M| >= theta_abs and |D + M| >= theta_rel * |f| (spec 8.3, noise suppression).

0.05
theta_rel float

Group B is emitted only when |D + M| >= theta_abs and |D + M| >= theta_rel * |f| (spec 8.3, noise suppression).

0.05
Source code in src/triadxai/reasons.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def map_reasons(
    instance_channels: pd.DataFrame,
    *,
    dictionary: Mapping[str, str] | None = None,
    orientation: str = "higher_is_better",
    k: int = 4,
    theta_abs: float = 0.05,
    theta_rel: float = 0.5,
) -> list[ReasonCode]:
    """Map one instance's channels to ranked, group-tagged reason codes.

    Parameters
    ----------
    instance_channels
        Frame with columns ``feature``, ``I``, ``D``, ``M`` for one instance.
    dictionary
        Optional feature -> approved reason text mapping; features not in the
        dictionary fall back to subtype templates.
    orientation
        ``"higher_is_better"`` (default): negative contributions worsen the
        score. ``"lower_is_better"`` flips the sign convention.
    k
        Maximum number of reasons emitted.
    theta_abs, theta_rel
        Group B is emitted only when ``|D + M| >= theta_abs`` and
        ``|D + M| >= theta_rel * |f|`` (spec 8.3, noise suppression).
    """
    if orientation not in ("higher_is_better", "lower_is_better"):
        raise ValueError(
            f"unknown orientation {orientation!r}; "
            "expected 'higher_is_better' or 'lower_is_better'"
        )
    sign = 1.0 if orientation == "higher_is_better" else -1.0
    candidates: list[tuple[str, str, float, str]] = []
    for row in instance_channels.itertuples(index=False):
        info = sign * row.I
        epistemic = sign * (row.D + row.M)
        total = info + epistemic
        if info < 0.0:
            candidates.append(("A", "derogatory", info, row.feature))
        if (
            epistemic < 0.0
            and abs(epistemic) >= theta_abs
            and abs(epistemic) >= theta_rel * abs(total)
        ):
            subtype = "insufficient_missing" if abs(row.M) >= abs(row.D) else "insufficient_density"
            candidates.append(("B", subtype, epistemic, row.feature))
    candidates.sort(key=lambda item: abs(item[2]), reverse=True)
    codes = []
    for group, subtype, contribution, feature in candidates[:k]:
        text = (dictionary or {}).get(feature) or _DEFAULT_TEMPLATES[subtype].format(
            feature=feature
        )
        codes.append(
            ReasonCode(
                feature=feature,
                group=group,
                subtype=subtype,
                contribution=float(contribution),
                text=text,
            )
        )
    return codes

Waterfall

Three-channel waterfall plot (triadxai[viz]).

Three-channel waterfall plot (spec sections 7.1 and 9.3).

I segments are solid, D segments hatched and translucent (the direction of an under-supported contribution is the model's guess), M segments gray. Requires the viz extra (matplotlib).

plot_waterfall

plot_waterfall(explanation: Any, i: int = 0, *, max_features: int = 10, ax: Any = None) -> Any

Plot one instance's I/D/M decomposition as a stacked waterfall.

Source code in src/triadxai/waterfall.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def plot_waterfall(explanation: Any, i: int = 0, *, max_features: int = 10, ax: Any = None) -> Any:
    """Plot one instance's I/D/M decomposition as a stacked waterfall."""
    try:
        import matplotlib.patches as mpatches
        import matplotlib.pyplot as plt
    except ImportError as exc:
        raise ImportError(
            "waterfall plots require matplotlib; install with `pip install triadxai[viz]`"
        ) from exc

    rows = explanation.channels[explanation.channels["instance"] == i]
    if rows.empty:
        raise ValueError(f"no channels for instance {i}")
    rows = rows.assign(total=rows["I"] + rows["D"] + rows["M"])
    rows = rows.reindex(rows["total"].abs().sort_values(ascending=False).index)
    top = rows.head(max_features)
    rest = rows.iloc[max_features:]

    labels: list[str] = []
    segments: list[tuple[float, float, float]] = []
    for row in top.itertuples(index=False):
        marker = " *" if row.oov else ""
        labels.append(f"{row.feature}{marker}")
        segments.append((row.I, row.D, row.M))
    if not rest.empty:
        labels.append(f"other ({len(rest)})")
        segments.append((rest["I"].sum(), rest["D"].sum(), rest["M"].sum()))

    if ax is None:
        _, ax = plt.subplots(figsize=(8, 0.5 * (len(labels) + 2) + 1.5))
    y_positions = np.arange(len(labels), 0, -1)
    for y, (seg_i, seg_d, seg_m) in zip(y_positions, segments, strict=True):
        left = 0.0
        for value, color, hatch, alpha in (
            (seg_i, _COLOR_I, None, 1.0),
            (seg_d, _COLOR_D, "///", 0.6),
            (seg_m, _COLOR_M, None, 0.9),
        ):
            if value != 0.0:
                ax.barh(y, value, left=left, color=color, hatch=hatch, alpha=alpha, height=0.7)
                left += value
    total = float(explanation.score[i] - explanation.intercept)
    ax.barh(0, total, color="none", edgecolor="black", height=0.7)
    ax.axvline(0.0, color="black", linewidth=0.8)
    ax.set_yticks([*y_positions, 0])
    ax.set_yticklabels([*labels, "total (eta - beta0)"])
    ax.set_xlabel("logit contribution")
    title = f"TRIAD waterfall - instance {i}"
    if explanation.approximate:
        title += " (approximate: SHAP-based channels)"
    ax.set_title(title)
    ax.legend(
        handles=[
            mpatches.Patch(color=_COLOR_I, label="I - information"),
            mpatches.Patch(
                facecolor=_COLOR_D, hatch="///", alpha=0.6, label="D - epistemic (guess)"
            ),
            mpatches.Patch(color=_COLOR_M, label="M - missingness"),
        ],
        loc="best",
        fontsize=8,
    )
    if top["oov"].any():
        ax.annotate(
            "* outside training range / unseen category",
            xy=(0, -0.12),
            xycoords="axes fraction",
            fontsize=7,
        )
    return ax

Synthetic

Validation DGPs.

Synthetic data-generating processes for validation studies (spec 10.1).

All generators share an additive logistic base DGP with known shape functions; each study carves a controllable truth into it (density gap, missingness mechanism, pure-noise feature, interaction term).

SyntheticData dataclass

SyntheticData(X: DataFrame, y: ndarray, eta: ndarray, meta: Mapping[str, Any])

Generated design matrix, labels, true logit and DGP metadata.

make_additive

make_additive(n: int = 5000, *, seed: int = 0) -> SyntheticData

Additive logistic base DGP with three known shape functions.

Source code in src/triadxai/synthetic.py
55
56
57
58
59
60
61
62
63
64
65
def make_additive(n: int = 5000, *, seed: int = 0) -> SyntheticData:
    """Additive logistic base DGP with three known shape functions."""
    rng = np.random.default_rng(seed)
    x0, x1, x2 = _draw(rng, n)
    eta = _base_eta(x0, x1, x2)
    return SyntheticData(
        X=pd.DataFrame({"x0": x0, "x1": x1, "x2": x2}),
        y=_labels(rng, eta),
        eta=eta,
        meta={"dgp": "additive", "center": _CENTER},
    )

make_density_gap

make_density_gap(n: int = 5000, *, gap: tuple[float, float] = (1.0, 2.5), keep_frac: float = 0.02, seed: int = 0) -> SyntheticData

Base DGP with training mass carved out of a region of x0 (study 10.1.1).

Source code in src/triadxai/synthetic.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def make_density_gap(
    n: int = 5000,
    *,
    gap: tuple[float, float] = (1.0, 2.5),
    keep_frac: float = 0.02,
    seed: int = 0,
) -> SyntheticData:
    """Base DGP with training mass carved out of a region of x0 (study 10.1.1)."""
    rng = np.random.default_rng(seed)
    lo, hi = gap
    cols: list[np.ndarray] = []
    accepted = 0
    while accepted < n:
        x0, x1, x2 = _draw(rng, 2 * n)
        in_gap = (x0 > lo) & (x0 < hi)
        keep = ~in_gap | (rng.uniform(size=2 * n) < keep_frac)
        block = np.column_stack([x0[keep], x1[keep], x2[keep]])
        cols.append(block)
        accepted += len(block)
    data = np.concatenate(cols)[:n]
    x0, x1, x2 = data[:, 0], data[:, 1], data[:, 2]
    eta = _base_eta(x0, x1, x2)
    return SyntheticData(
        X=pd.DataFrame({"x0": x0, "x1": x1, "x2": x2}),
        y=_labels(rng, eta),
        eta=eta,
        meta={"dgp": "density_gap", "gap": gap, "keep_frac": keep_frac, "center": _CENTER},
    )

make_interacting

make_interacting(n: int = 5000, *, gap: tuple[float, float] = (1.0, 2.5), seed: int = 0) -> SyntheticData

Non-additive DGP (x0*x1 interaction) with a density gap in x0.

Ground truth for the approximate-mode study: an unconstrained booster must model the interaction, and bag disagreement should concentrate the D channel inside the carved gap.

Source code in src/triadxai/synthetic.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def make_interacting(
    n: int = 5000, *, gap: tuple[float, float] = (1.0, 2.5), seed: int = 0
) -> SyntheticData:
    """Non-additive DGP (x0*x1 interaction) with a density gap in x0.

    Ground truth for the approximate-mode study: an unconstrained booster
    must model the interaction, and bag disagreement should concentrate the
    D channel inside the carved gap.
    """
    base = make_density_gap(n, gap=gap, keep_frac=0.02, seed=seed)
    rng = np.random.default_rng(seed + 1)
    x0 = base.X["x0"].to_numpy()
    x1 = base.X["x1"].to_numpy()
    interaction = 1.0 * x0 * x1
    eta = base.eta + interaction
    return SyntheticData(
        X=base.X,
        y=_labels(rng, eta),
        eta=eta,
        meta={
            "dgp": "interacting",
            "gap": gap,
            "interaction_strength": 1.0,
            "center": _CENTER,
        },
    )

make_missingness

make_missingness(n: int = 5000, *, mechanism: str = 'MNAR', missing_rate: float = 0.3, missing_effect: float = 1.0, seed: int = 0) -> SyntheticData

Base DGP with MCAR / MAR / MNAR missingness injected into x0 (study 10.1.2).

MNAR plants missing_effect on the logit of masked rows (informative missingness); meta["eta_base"] retains the pre-effect logit so tests can recover the planted effect exactly.

Source code in src/triadxai/synthetic.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def make_missingness(
    n: int = 5000,
    *,
    mechanism: str = "MNAR",
    missing_rate: float = 0.3,
    missing_effect: float = 1.0,
    seed: int = 0,
) -> SyntheticData:
    """Base DGP with MCAR / MAR / MNAR missingness injected into x0 (study 10.1.2).

    MNAR plants ``missing_effect`` on the logit of masked rows (informative
    missingness); ``meta["eta_base"]`` retains the pre-effect logit so tests
    can recover the planted effect exactly.
    """
    rng = np.random.default_rng(seed)
    x0, x1, x2 = _draw(rng, n)
    eta_base = _base_eta(x0, x1, x2)
    if mechanism == "MCAR":
        p_miss = np.full(n, missing_rate)
    elif mechanism == "MAR":
        raw = expit(x1)
        p_miss = np.clip(missing_rate * raw / raw.mean(), 0.0, 1.0)
    elif mechanism == "MNAR":
        raw = expit(x0)
        p_miss = np.clip(missing_rate * raw / raw.mean(), 0.0, 1.0)
    else:
        raise ValueError(f"unknown mechanism {mechanism!r}; expected 'MCAR', 'MAR' or 'MNAR'")
    miss = rng.uniform(size=n) < p_miss
    eta = eta_base + (missing_effect * miss if mechanism == "MNAR" else 0.0)
    x0_observed = np.where(miss, np.nan, x0)
    return SyntheticData(
        X=pd.DataFrame({"x0": x0_observed, "x1": x1, "x2": x2}),
        y=_labels(rng, eta),
        eta=eta,
        meta={
            "dgp": "missingness",
            "mechanism": mechanism,
            "missing_rate": missing_rate,
            "missing_effect": missing_effect if mechanism == "MNAR" else 0.0,
            "eta_base": eta_base,
            "missing_mask": miss,
            "center": _CENTER,
        },
    )

make_noise_feature

make_noise_feature(n: int = 5000, *, seed: int = 0) -> SyntheticData

Base DGP plus a pure-noise feature with zero coefficient (study 10.1.3).

Source code in src/triadxai/synthetic.py
144
145
146
147
148
149
150
151
152
153
154
155
def make_noise_feature(n: int = 5000, *, seed: int = 0) -> SyntheticData:
    """Base DGP plus a pure-noise feature with zero coefficient (study 10.1.3)."""
    rng = np.random.default_rng(seed)
    x0, x1, x2 = _draw(rng, n)
    noise = rng.normal(0, 1, n)
    eta = _base_eta(x0, x1, x2)
    return SyntheticData(
        X=pd.DataFrame({"x0": x0, "x1": x1, "x2": x2, "noise": noise}),
        y=_labels(rng, eta),
        eta=eta,
        meta={"dgp": "noise_feature", "noise_column": "noise", "center": _CENTER},
    )

Metrics

Reconciliation / stability metrics.

Validation metrics: reconciliation, channel mass, bootstrap stability.

bootstrap_stability

bootstrap_stability(fit_fn: Callable[[DataFrame, ndarray, int], Any], X: DataFrame, y: ndarray, X_eval: DataFrame, *, n_boot: int = 10, seed: int = 0) -> pd.DataFrame

Across-refit SD of per-feature mean channel values (H3 protocol, spec 10.2).

fit_fn(X_boot, y_boot, boot_seed) must return a model consumable by :class:triadxai.local.TriadExplainer.

Source code in src/triadxai/metrics.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def bootstrap_stability(
    fit_fn: Callable[[pd.DataFrame, np.ndarray, int], Any],
    X: pd.DataFrame,
    y: np.ndarray,
    X_eval: pd.DataFrame,
    *,
    n_boot: int = 10,
    seed: int = 0,
) -> pd.DataFrame:
    """Across-refit SD of per-feature mean channel values (H3 protocol, spec 10.2).

    ``fit_fn(X_boot, y_boot, boot_seed)`` must return a model consumable by
    :class:`triadxai.local.TriadExplainer`.
    """
    rng = np.random.default_rng(seed)
    y = np.asarray(y)
    per_boot = []
    for boot in range(n_boot):
        idx = rng.integers(0, len(X), size=len(X))
        X_boot = X.iloc[idx].reset_index(drop=True)
        model = fit_fn(X_boot, y[idx], seed * 10_000 + boot + 1)
        explanation = TriadExplainer(model).explain(X_eval)
        per_boot.append(explanation.channels.groupby("feature")[["I", "D", "M"]].mean())
    stacked = pd.concat(per_boot, keys=range(n_boot))
    return stacked.groupby(level=1).std(ddof=1)

channel_mass

channel_mass(explanation: TriadExplanation) -> pd.Series

Portfolio share of mean |I|, |D|, |M| (sums to 1).

Source code in src/triadxai/metrics.py
33
34
35
36
37
38
39
def channel_mass(explanation: TriadExplanation) -> pd.Series:
    """Portfolio share of mean |I|, |D|, |M| (sums to 1)."""
    mass = explanation.channels[["I", "D", "M"]].abs().mean()
    total = mass.sum()
    if total == 0:
        return mass
    return mass / total

reconciliation_error

reconciliation_error(explanation: TriadExplanation, model: Any, X: DataFrame) -> float

Max |sum of channels + intercept - model raw score| (C2; 0 by construction).

Source code in src/triadxai/metrics.py
27
28
29
30
def reconciliation_error(explanation: TriadExplanation, model: Any, X: pd.DataFrame) -> float:
    """Max |sum of channels + intercept - model raw score| (C2; 0 by construction)."""
    totals = explanation.channels.groupby("instance")[["I", "D", "M"]].sum().sum(axis=1).to_numpy()
    return float(np.max(np.abs(totals + explanation.intercept - _raw_score(model, X))))