Measure drift by operational segment#
Goal. Work through a bounded, reproducible example and inspect the evidence before connecting an external service.
Prerequisites. Base FraudTwin install. Optional extras and Docker commands are clearly marked.
Produces. Tables, fingerprints, manifests, and verification output.
Source size. The default cells generate approximately 1,000 logical payments; increase duration and population together for a 10,000-payment run.
Offline path. All marked offline cells run without Docker or network services. Service cells are optional and explicitly marked in notebook metadata.
Cleanup. Outputs are written under a temporary directory; remove any local run directory if you changed the output location.
Set up a deterministic source run
import json
from pathlib import Path
import polars as pl
from fraudtwin.config import load_config
from fraudtwin.generation import generate
root = next(
(p for p in (Path.cwd(), *Path.cwd().parents) if (p / "configs" / "minimal.yaml").exists()),
Path.cwd(),
)
base = load_config(root / "configs" / "minimal.yaml")
# Scale the population so the bounded example produces about 1,000 payments.
population = base.population.model_copy(
update={
"customers": 200,
"accounts": 300,
"cards": 240,
"devices": 240,
"pix_keys": 160,
"merchants": 60,
}
)
simulation = base.simulation.model_copy(update={"duration_days": 10})
fraud = base.fraud.model_copy(update={"enabled": True, "target_rate": 0.05})
config = base.model_copy(
update={"population": population, "simulation": simulation, "fraud": fraud}
)
data = generate(config, write=False)
run_id = data.run_id
payments = pl.DataFrame([item.model_dump(mode="json") for item in data.behavior.payments])
print({"run_id": run_id, "payments": len(payments), "events": len(data.behavior.payment_events)})
{'run_id': 'RUN-2a3ad02ee370aeb8', 'payments': 1092, 'events': 4699}
Inspect schema, grain, and counts
from fraudtwin.ml.drift import DriftConfig, compare_windows
rows = payments.to_dicts()
config_drift = DriftConfig(
reference_name="baseline", comparison_name="current", minimum_samples=1, fields=("amount",)
)
segments = ["merchant_id", "payer_account_id"]
reports = {}
report_rows = []
for segment in segments:
groups = sorted({str(r.get(segment)) for r in rows})[:3]
reports[segment] = {}
for group in groups:
reference = [r for r in rows if str(r.get(segment)) == group]
comparison = [dict(r, amount=float(r.get("amount") or 0) * 1.1) for r in reference]
fingerprint = compare_windows(reference, comparison, config_drift).fingerprint
reports[segment][group] = fingerprint
report_rows.append(
{
"segment": segment,
"group": group,
"fingerprint_prefix": f"{fingerprint[:12]}...",
}
)
# Keep full fingerprints in reports, but show a readable summary in the tutorial.
display(pl.DataFrame(report_rows).sort(["segment", "group"]))
| segment | group | fingerprint_prefix |
|---|---|---|
| str | str | str |
| "merchant_id" | "MER-000001" | "46f6de5cdc11..." |
| "merchant_id" | "MER-000002" | "de769f110625..." |
| "merchant_id" | "MER-000003" | "b933a626fba8..." |
| "payer_account_id" | "ACC-000001" | "445849ba7649..." |
| "payer_account_id" | "ACC-000003" | "167d3b5bbad6..." |
| "payer_account_id" | "ACC-000004" | "89daa1c1f334..." |
Run the core operation
display(
pl.DataFrame(
[
{"segment": key, "groups": len(value), "status": "review"}
for key, value in reports.items()
]
)
)
| segment | groups | status |
|---|---|---|
| str | i64 | str |
| "merchant_id" | 3 | "review" |
| "payer_account_id" | 3 | "review" |
Measure and interpret the result
Feature drift is a P(X) change; segment mix is domain shift; degraded matured-label metrics indicate concept/performance drift.
Exercise a parameter or failure mode
alerts = [{"segment": key, "action": "investigate"} for key in reports]
display(pl.DataFrame(alerts))
| segment | action |
|---|---|
| str | str |
| "merchant_id" | "investigate" |
| "payer_account_id" | "investigate" |
Write a compact artifact and fingerprint
from fraudtwin.reproducibility import sha256_json
assert reports
report_fingerprint = sha256_json(reports)
report_preview = pl.DataFrame(
[
{"segment": segment, "group": group, "fingerprint": f"{fingerprint[:12]}..."}
for segment, groups in sorted(reports.items())
for group, fingerprint in sorted(groups.items())
]
)
display(report_preview)
artifact_summary = {
"segments": len(reports),
"groups": sum(len(groups) for groups in reports.values()),
"report_fingerprint": f"{report_fingerprint[:12]}...",
"label_policy": "exclude_unresolved",
}
print(json.dumps(artifact_summary, indent=2))
| segment | group | fingerprint |
|---|---|---|
| str | str | str |
| "merchant_id" | "MER-000001" | "46f6de5cdc11..." |
| "merchant_id" | "MER-000002" | "de769f110625..." |
| "merchant_id" | "MER-000003" | "b933a626fba8..." |
| "payer_account_id" | "ACC-000001" | "445849ba7649..." |
| "payer_account_id" | "ACC-000003" | "167d3b5bbad6..." |
| "payer_account_id" | "ACC-000004" | "89daa1c1f334..." |
{
"segments": 2,
"groups": 6,
"report_fingerprint": "185d5136f64e...",
"label_policy": "exclude_unresolved"
}
Verify invariants and clean up
Use a scheduled report and calibrate thresholds against seasonal variation.
Optional service integration
# A compact inspection is more useful than printing an entire run.
sample_columns = [
c for c in ("payment_id", "amount", "initiated_at", "payer_account_id") if c in payments.columns
]
sample_rows = payments.select(sample_columns).head(8).to_dicts()
print(f"Sample payments ({len(sample_rows)} of {payments.height} rows):")
for row in sample_rows:
print(
f" - {row.get('payment_id')}: amount={row.get('amount')}, "
f"initiated_at={row.get('initiated_at')}, payer={row.get('payer_account_id')}"
)
nulls = {name: count for name, count in payments.null_count().to_dicts()[0].items() if count}
print("\nData quality summary:")
print(f" rows: {payments.height}")
print(f" columns: {payments.width}")
if not nulls:
print(" nulls: none")
else:
print(" columns with nulls:")
for name, count in sorted(nulls.items()):
print(f" - {name}: {count}")
Sample payments (8 of 1092 rows):
- PAY-00000001: amount=70.7, initiated_at=2026-01-03T16:25:00Z, payer=ACC-000123
- PAY-00000002: amount=25.52, initiated_at=2026-01-05T11:37:00Z, payer=ACC-000174
- PAY-00000003: amount=18.37, initiated_at=2026-01-05T11:21:00Z, payer=ACC-000174
- PAY-00000004: amount=5.54, initiated_at=2026-01-02T22:30:00Z, payer=ACC-000003
- PAY-00000005: amount=13.71, initiated_at=2026-01-02T09:41:00Z, payer=ACC-000029
- PAY-00000006: amount=70.06, initiated_at=2026-01-04T10:40:00Z, payer=ACC-000179
- PAY-00000007: amount=42.73, initiated_at=2026-01-02T18:04:00Z, payer=ACC-000247
- PAY-00000008: amount=36.42, initiated_at=2026-01-05T09:27:00Z, payer=ACC-000255
Data quality summary:
rows: 1092
columns: 15
columns with nulls:
- card_id: 565
- merchant_id: 565
- payee_account_id: 86
- payee_institution_id: 86
- payee_pix_key_id: 857
- payer_institution_id: 86
- payer_pix_key_id: 857
Review the expected outcome
summary = {
"run_id": run_id,
"payments": len(data.behavior.payments),
"payment_events": len(data.behavior.payment_events),
"fraud_records": len(data.behavior.fraud_records),
}
assert summary["payments"] == len(payments)
assert summary["payments"] > 0
print(json.dumps(summary, indent=2, default=str))
{
"run_id": "RUN-2a3ad02ee370aeb8",
"payments": 1092,
"payment_events": 4699,
"fraud_records": 51
}
Record the generated shape and tutorial contract.#
summary = {
"payments": len(data.behavior.payments),
"events": len(data.behavior.payment_events),
}
print(summary)
assert summary["payments"] >= 0
{'payments': 1092, 'events': 4699}