LedgerLens uses a Population Stability Index (PSI) based drift detection system to monitor when the production feature distribution diverges from the training-time distribution. When significant drift is detected, the system automatically retrains the ensemble models and, if the new models meet quality thresholds, promotes them to production.
PSI = Σ_i (observed_i - expected_i) × ln(observed_i / expected_i)
Where i iterates over bins of each feature's value distribution.
The PSI computation in detection/drift_monitor.py follows the methodology
described in:
- Yurdakul, B. (2017) "Statistical Properties of Population Stability Index", WUSS Conference, San Francisco, CA. PDF
This is the standard reference for PSI in credit scoring and tabular ML systems.
Each feature is discretised into 10 quantile-based bins using the training (reference) distribution. The same bin edges are applied to the current distribution to compute observed proportions. If quantile binning fails due to insufficient unique values, uniform-width bins are used as a fallback.
Both expected and observed proportions are clipped to ≥ 1e-4 before computation to prevent log(0) and division-by-zero errors (the standard "small constant fix" used in production PSI implementations).
| PSI Range | Classification | Action |
|---|---|---|
| PSI < 0.1 | No significant drift | No action |
| 0.1 ≤ PSI < 0.25 | Moderate drift | Monitor (logged) |
| PSI ≥ 0.25 | Significant drift | Trigger retraining |
These thresholds follow industry-standard practice from consumer credit modelling and have been validated across multiple domains.
When drift triggers retraining, the new ensemble models are evaluated against
a held-out test set and compared to the current production model's metrics
(from metrics.json).
A new model is promoted only if:
AUC-ROC_new ≥ AUC-ROC_old - 0.01
AND
F1_new ≥ F1_old - 0.01
For every model in the ensemble (Random Forest, XGBoost, LightGBM).
The 0.01 (1 percentage point) tolerance accounts for stochastic variation in training. If any model degrades beyond this tolerance, the new models are archived but not promoted, and the old production models remain live.
| Code | Meaning |
|---|---|
| 0 | No drift detected |
| 2 | Retrained and promoted |
| 3 | Retrained but not promoted (regression) |
| 1 | Fatal error |
┌──────────────────────┐
│ model_metadata.json │
│ (reference dist.) │
└──────┬───────────────┘
│
▼
┌──────────────┐ ┌─────────────────────────┐
│ Horizon API │───▶│ get_feature_data() │
│ (last N days)│ │ → feature_matrix │
└──────────────┘ └───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ DriftMonitor.compute() │
│ → DriftReport │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ any_drift_detected? │
└───┬───────────┬─────────┘
No │ │ Yes
▼ ▼
Exit 0 ┌─────────────────┐
│ Archive old │
│ models │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Retrain on │
│ latest labelled │
│ dataset │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Evaluate vs. │
│ old metrics │
└────────┬────────┘
│
┌───────────▼───────────┐
│ Metrics within │
│ tolerance? │
└───┬───────────┬───────┘
Yes │ │ No
▼ ▼
Promote + Archive only
Exit 2 Exit 3
| Path | Purpose |
|---|---|
detection/drift_monitor.py |
DriftMonitor class + DriftReport dataclass |
scripts/retrain_if_drifted.py |
CLI entry point for drift detection + retraining |
scripts/list_model_versions.py |
List archived model versions with metrics |
models/archive/{timestamp}/ |
Archived model artifacts |
reports/drift_report_{timestamp}.json |
Per-run drift detection report |
reports/retrain_report_{timestamp}.json |
Full retrain + promotion decision report |
.github/workflows/retrain.yml |
Scheduled weekly retraining workflow |
A drift_report_{timestamp}.json contains:
{
"generated_at": "2026-06-18T02:00:00Z",
"any_drift_detected": true,
"n_features_checked": 25,
"n_features_drifted": 3,
"features": [
{"feature": "benford_mad_24h", "psi": 0.32, "drift_flag": true},
{"feature": "counterparty_concentration_ratio", "psi": 0.05, "drift_flag": false}
]
}n_features_drifted: Count of features with PSI ≥ 0.25. If > 0, retraining trigger fires.psi: The PSI value for a single feature. Values above 0.25 indicate significant distribution shift.drift_flag:truewhen PSI ≥ 0.25.
pytest tests/test_drift_monitor.py tests/test_retrain_trigger.py -vEight tests cover:
- PSI below threshold (identical distributions)
- PSI above threshold (shifted distribution)
- Zero-frequency bin handling
- Report written to JSON with correct schema
- Promotion gate blocks regression
- Promotion gate allows improvement
- Archive created before promotion
- All exit codes (0, 2, 3, 1)
The ensemble calibrator (detection/ensemble_calibrator.py) finds Pareto-optimal
model weights at training time via NSGA-II. Those weights are fixed until the next
retraining cycle. In production, the three models (Random Forest, XGBoost,
LightGBM) may diverge in their false positive rates on specific asset pairs or
market regimes. EnsembleDynamicWeightController adjusts weights between retrains
using confirmed operator feedback.
-
An operator reviews a flagged wallet and marks it as a confirmed false positive (label = 0 in the annotation queue).
-
observe_false_positive(wallet, model_predictions, annotator_id, audit_trail_id)is called with the per-model probabilities at alert time and the operator's authenticated identity. -
For each model, an FP observation is recorded if the model predicted ≥ 0.5.
-
Once at least 10 confirmed FP feedbacks have been received, weights are recomputed inversely proportional to each model's FP rate:
raw_weight[model] = 1 / (fp_rate[model] + ε) normalised = raw_weight / sum(raw_weight) -
Weight updates are exponentially smoothed to prevent overcorrection:
new_weight = α × target + (1 − α) × old_weightwhere
α = ENSEMBLE_WEIGHT_SMOOTHING_ALPHA(default 0.1). -
Weights are bounded to [0.05, 0.80] per model to preserve ensemble diversity.
If all three models simultaneously exceed ENSEMBLE_SYSTEMIC_FP_THRESHOLD
(default 0.5), this signals a systemic issue (e.g., a major regime change) rather
than individual model failure. In this case:
- Weights are reset to training-time values.
- A structured
WARNINGlog is emitted so operators can investigate. - The Prometheus gauges reflect the reset weights immediately.
Tune ENSEMBLE_SYSTEMIC_FP_THRESHOLD to match your expected per-model FP rate
under normal operating conditions.
| Environment variable | Default | Description |
|---|---|---|
ENSEMBLE_WEIGHT_SMOOTHING_ALPHA |
0.1 |
EMA smoothing factor (0 = no update, 1 = instant) |
ENSEMBLE_SYSTEMIC_FP_THRESHOLD |
0.5 |
Per-model FP rate above which systemic reset fires |
One gauge per model is exposed:
ensemble_dynamic_weight_rf{} 0.34
ensemble_dynamic_weight_xgb{} 0.33
ensemble_dynamic_weight_lgbm{} 0.33
observe_false_positive requires both annotator_id (non-empty) and
audit_trail_id (non-empty, linking to the operator's verified audit trail entry).
Calling with empty strings raises ValueError. This prevents an unauthenticated or
anonymous annotator from manipulating ensemble weights.
from detection.ensemble_calibrator import (
EnsembleCalibrator, EnsembleDynamicWeightController
)
# 1. Get training-time weights from Pareto front
calibrator = EnsembleCalibrator()
training_weights = calibrator.select_operating_point()
# 2. Create controller
controller = EnsembleDynamicWeightController(training_weights)
# 3. Feed confirmed FP feedback (from annotation queue)
controller.observe_false_positive(
wallet="GABC...",
model_predictions={"rf": 0.82, "xgb": 0.79, "lgbm": 0.11},
annotator_id="analyst_alice",
audit_trail_id="audit-entry-uuid-1234",
)
# 4. Use updated weights for scoring
weights = controller.current_weights()
scorer = RiskScorer(weights=weights)Each weight update appends rows to the ensemble_weight_history table:
| Column | Type | Description |
|---|---|---|
model_name |
str | Model identifier |
weight |
float | Updated weight value |
fp_rate |
float | Observed FP rate at update time |
observation_count |
int | Total FP feedbacks received |
is_systemic_reset |
bool | True if this row was written as part of a systemic reset |
timestamp |
datetime | UTC timestamp of the update |
pytest tests/test_ensemble_dynamic_weights.py -v