Stability monitoring
Viability testing is a fit-time guardrail: it rejects groupings that don’t hold up on a dev sample or CV folds. Once carving is done, the same question comes back in production — do these carved features still hold on data the model has never seen?
evaluate_stability() answers it without any training
data: each carved feature already stores its own train reference (see
The reference), so a fitted — or reloaded — carver can score a new
sample on its own.
from AutoCarver import BinaryCarver
carver = BinaryCarver.load("carver.json")
report = carver.evaluate_stability(X_prod, y_prod)
report.summary # one row per feature
report.per_modality # one row per (feature, modality)
report.unstable_features # features flagged by any check
The target is optional. Labels usually lag in production, so
carver.evaluate_stability(X_prod) computes the population-side metrics
alone (PSI and the chi-square goodness-of-fit); the target-side ones are
reported as NaN.
- AutoCarver.stability.evaluate_stability(carver: BaseCarver, X: DataFrame, y: Series | None = None, *, alpha: float = 0.05) StabilityReport
Evaluates carved features on a new sample against their train reference.
The reference is
statistics, persisted on each feature at carving time, so no training data is needed at monitoring time. Production rates are recomputed with the carver’s own aggregator and target rate, which makes them directly comparable, and are then run throughtest_viability()— the same rank-inversion / Wilsonmin_freq/ distinct-rates suite the carver used to accept the combination at fit time.- Parameters:
carver (BaseCarver) – A fitted carver (in memory or reloaded from JSON).
X (pd.DataFrame) – New (production) sample, with the carver’s raw feature columns.
y (pd.Series, optional) – Target for
X. Without it only PSI and the chi-square goodness-of-fit on counts are computed, by defaultNone.alpha (float, optional) – Significance level for every test, by default
0.05.
- Return type:
Notes
Ordinal and multiclass carvers get PSI, the chi-square test and the full viability block, but no per-modality target-drift test: their rate is a ridit / correspondence-analysis score whose sampling variance is not recoverable from the three stored columns. The rate delta is still reported.
- class AutoCarver.stability.StabilityReport(per_modality: DataFrame, per_feature: DataFrame, alpha: float, has_target: bool)
Comparison of carved features between their train reference and a new sample.
- per_modality
One row per
(feature, label): reference and new count / frequency / target rate, the modality’s PSI contribution, the rate delta and — when a target-drift test applies — its p-value.- Type:
pd.DataFrame
- per_feature
One row per feature:
psiand its flag, the chi-square goodness-of-fit test, and (with a target) the viability verdict produced by the carver’s own fit-time robustness tests.- Type:
pd.DataFrame
- alpha
Significance level used by every test.
- Type:
float
- has_target
Whether a target was provided — without one, only the frequency-based metrics are computed.
- Type:
bool
- property summary: DataFrame
Per-feature verdicts, indexed by feature.
- to_json() dict[str, Any]
Converts to a JSON-serializable dict (for
json.dumpor MCP transport).Frames round-trip through pandas’ own JSON writer so numpy scalars become base types and
NaNbecomesnull.
- property unstable_features: list[str]
Features needing attention.
Flagged when the population shifted (PSI above 0.25), when the chi-square test is both significant and carries a non-negligible effect size (
chi2_cramervat or above 0.1 — significance alone would flag nearly everything on a large extract), when the carver’s viability filter no longer passes, or when the reference is too incomplete to judge (psi_flag == "unknown").
The reference
At carving time the winning combination’s per-modality statistics are stored on
the feature itself, as statistics — a
frame indexed by final label carrying:
countandfrequency— the population reference;the evaluator’s target rate (
target_mean,woe,odds_ratio,target_median,target_mean_ridit,target_mean_levelorca_score) — the target reference;stdfor continuous targets — the dispersion needed to test a mean shift.
These survive save() /
load(), together with any
per-feature state the target rate was fit on (the ridit reference marginal, the
correspondence-analysis axis). Features that were never carved — discretized
only — carry no statistics and are skipped with a warning.
Production statistics are recomputed with the carver’s own aggregator and target rate, so both sides of every comparison are like-for-like by construction. Comparing a carver against the very sample it was fitted on therefore returns a PSI of exactly zero and no drift anywhere — a useful sanity check.
Population drift
Two complementary readings of the same shift in modality frequencies.
PSI (Population Stability Index) is the industry-standard magnitude:
reported per feature (psi) and per modality (psi_contribution), with the
conventional verdict in psi_flag: stable below 0.1, moderate up to
0.25, shifted above. Both frequencies are floored so a modality that emptied
out contributes a large but finite amount instead of infinity.
A fourth verdict, unknown, means the reference itself was incomplete — a
manual split() leaves the
affected bins’ statistics unknowable (NaN), and dropping them would
silently renormalize the comparison onto a different support. The index is
then NaN rather than a plausible-looking number, and the feature is
reported as needing attention: unverifiable must never read as verified-stable.
Chi-square homogeneity is the significance-based counterpart: a two-sample
test on the 2 x k table of reference and production counts (chi2,
chi2_pvalue, chi2_significant). It is deliberately not a
goodness-of-fit against the reference frequencies — the reference is itself an
estimate from a finite train sample, and treating it as known truth would
understate the p-value. It stays on scipy.stats.chi2_contingency()
rather than Pearson’s \chi^2 because it needs the p-value and degrees of
freedom, which AutoCarver.stats.pearson_chi2() doesn’t return.
Because any chi-square grows with sample size, Cramér’s V is reported
beside it (chi2_cramerv) as the sample-size-independent effect size:
V = sqrt(chi2 / N) for a 2 x k table, conventionally negligible below
0.1. unstable_features requires
both significance and a non-negligible V, so a 300k-row extract doesn’t flag
every feature over a shift too small to act on.
- AutoCarver.stability.population_stability_index(ref_freq: Series, new_freq: Series, *, epsilon: float = 1e-06) tuple[float, Series]
Population Stability Index and its per-modality contributions.
Both frequencies are floored at
epsilonand renormalized so a modality that emptied out on either side yields a large-but-finite contribution instead ofinf. Conventional reading: below 0.1 stable, 0.1 to 0.25 moderate shift, above 0.25 significant shift.A reference carrying any
NaNbin (a manual split leaves the affected bins’ statistics unknowable) makes the index undefined: dropping those bins would silently renormalize the comparison onto a different support. Both the total and every contribution are thenNaN.- Parameters:
ref_freq (pd.Series) – Reference (train) frequency per modality —
feature.statistics["frequency"].new_freq (pd.Series) – Frequency per modality observed on the new sample.
epsilon (float, optional) – Floor applied to both frequencies, by default
1e-6.
- Returns:
The PSI, and its per-modality contributions (indexed like
ref_freq).- Return type:
tuple[float, pd.Series]
- AutoCarver.stability.chi2_homogeneity(ref_count: Series, new_count: Series) tuple[float, float, int, float]
Chi-square test of homogeneity between the reference and the new sample.
A two-sample test on the
2 x ktable of per-modality counts, not a goodness-of-fit against fixed frequencies: the reference is itself an estimate from a finite train sample, and treating its frequencies as known truth would understate the p-value. Expected counts come from the table’s own margins, so no modality can be compared against a mis-scaled expectation. Modalities empty in both samples carry no information and are dropped.The statistic grows with sample size, so a large production extract will flag shifts that are real but negligible. Cramér’s V is returned alongside it as the sample-size-independent effect size — for a
2 x ktableV = sqrt(chi2 / N), bounded in[0, 1], conventionally read as negligible below 0.1, small to 0.3, moderate to 0.5, large above.- Returns:
Statistic, two-sided p-value, degrees of freedom and Cramér’s V. All
nan/0when the table is degenerate (an incomplete reference, fewer than two informative modalities, or an empty sample).- Return type:
tuple[float, float, int, float]
Target drift
Every modality’s rate delta is reported (rate_delta). Whether it comes with
a significance test depends on the target:
Carver |
Test |
p-value |
|---|---|---|
Binary, one-vs-rest ( |
Pooled two-proportion z-test |
|
Continuous |
Welch t-test (uses the stored |
|
Continuous |
none |
|
Ordinal, multiclass |
none |
|
Only two rates admit a test from the stored statistics. target_median does
not: the stored std describes the spread of values, so feeding it to a
standard-error-of-the-mean formula would test the wrong quantity. Ordinal and
multiclass rates are bounded ridit / correspondence-analysis scores whose
sampling variance cannot be recovered from the three stored columns either. In
every such case the rate delta is still reported and the viability block below
still runs — only the p-value is withheld.
A multiclass target carrying a class unseen at fit time raises: the correspondence-analysis axis is fixed at carving time and cannot project a class it never saw.
- AutoCarver.stability.two_proportion_test(ref_rate: Series, ref_count: Series, new_rate: Series, new_count: Series) Series
Per-modality two-sided p-value for a change in a binary target rate.
Pooled-proportion z-test. Both rates must already be probabilities — pass them through
to_probability()first when the carver’s target rate iswoeorodds_ratio.
- AutoCarver.stability.welch_test(ref_mean: Series, ref_std: Series, ref_count: Series, new_mean: Series, new_std: Series, new_count: Series) Series
Per-modality two-sided Welch p-value for a change in a continuous target mean.
Returns
nanwherever an input isnan— notably for carvers fitted before thestdcolumn was persisted, and for singleton modalities.
Re-running the viability filter
The most direct question is also the cheapest: would this combination still have been accepted, had production been the dev sample?
test_viability() is called with the
stored train rates as the reference and the production rates as the candidate,
so the report’s viable / info columns are produced by exactly the
machinery described in Viability testing — rank inversion, Wilson
min_freq, distinct target rates — with the same human-readable failure
messages (“Inversion of target rates per modality”, “Non-representative
modality for min_freq=…”, “Non-distinct target rates per consecutive
modalities”).
A rank inversion here is the strongest possible signal: the carved ordering that the whole model rests on no longer holds.
MCP
The same evaluation is exposed as an MCP tool (see LLM & MCP Integration):
evaluate_stability(path="holdout.csv", target="y")
It returns the report’s JSON form: unstable_features, per_feature and
per_modality.