External predictions, temporal backtesting, and feature shift#

Goal. Align external predictions to point-in-time rows, evaluate matured labels, inspect temporal windows, and compute distribution shift. The notebook adds a real scikit-learn t-SNE view of the feature space.

Audience. Data/ML scientists, fraud analysts, platform engineers, and researchers.

Prerequisites. Python 3.12+, a clean checkout, and the base FraudTwin install. The workflow is deterministic and runs offline; service integrations are deliberately out of scope here.

Source size. 1,000–10,000 logical payments. Every section writes only compact summaries, manifests, or fingerprints to a temporary directory.

Interpretation. Synthetic evidence demonstrates mechanics and invariants, not production prevalence or model performance guarantees.

Evaluation timing. This notebook uses a three-day delayed batch prediction time so the configured delayed labels can mature. Real-time predictions should expect more unresolved labels.

External predictions and temporal backtesting#

from collections import Counter
from pathlib import Path

from fraudtwin.config import load_config
from fraudtwin.generation import generate
from fraudtwin.ml.baseline import PredictionRecord
from fraudtwin.reproducibility import sha256_json

root = next(
    (p for p in (Path.cwd(), *Path.cwd().parents) if (p / "configs" / "minimal.yaml").is_file()),
    Path.cwd(),
)
config = load_config(root / "configs" / "benchmarks" / "camouflage-v1.yaml")
evaluation_delay_seconds = 3 * 86_400
config = config.model_copy(
    update={
        "dataset": config.dataset.model_copy(
            update={"prediction_delay_seconds": evaluation_delay_seconds}
        )
    }
)
data = generate(config, write=False)
dataset = data.require_dataset()
rows = dataset.frame.to_dicts()
print("Source dataset")
print(f"  Run ID: {data.run_id}")
print(f"  point-in-time rows: {len(rows):,}")
print(f"  evaluation delay: {evaluation_delay_seconds // 86_400} days")
assert len(rows) > 500
Source dataset
  Run ID: RUN-3c8a07d6ac6d0773
  point-in-time rows: 612
  evaluation delay: 3 days
predictions = [
    PredictionRecord(
        event_id=r["event_id"],
        prediction_timestamp=r["prediction_time"],
        fraud_score=min(1.0, float(r["amount"]) / 5000.0),
    )
    for r in rows
]
print("Predictions")
print(f"  records: {len(predictions):,}")
print(f"  target: {predictions[0].target}")
assert len(predictions) == len(rows)
Predictions
  records: 612
  target: ('event_id', 'EVT-00000001')
targets = {p.target for p in predictions}
row_targets = {("event_id", r["event_id"]) for r in rows}
print("Prediction alignment")
print(f"  prediction targets: {len(targets):,}")
print(f"  point-in-time targets: {len(row_targets):,}")
assert targets == row_targets
Prediction alignment
  prediction targets: 612
  point-in-time targets: 612
split_counts = Counter(r["split"] for r in rows)
label_counts = Counter("matured" if r["label"] is not None else "unresolved" for r in rows)
print("Dataset splits")
for name, count in sorted(split_counts.items()):
    print(f"  {name}: {count:,}")
print("Label maturity")
for name, count in sorted(label_counts.items()):
    print(f"  {name}: {count:,}")
Dataset splits
  test: 133
  train: 353
  validation: 126
Label maturity
  matured: 78
  unresolved: 534
matured = [r for r in rows if r["label"] is not None]
print("Label policy")
print(f"  matured: {len(matured):,}")
print(f"  unresolved: {len(rows) - len(matured):,}")
print("  policy: exclude_unresolved")
Label policy
  matured: 78
  unresolved: 534
  policy: exclude_unresolved
thresholds = [
    {"threshold": t, "flagged": sum(p.fraud_score >= t for p in predictions)}
    for t in (0.25, 0.5, 0.75)
]
print("Threshold review")
for item in thresholds:
    print(f"  score >= {item['threshold']}: {item['flagged']:,} flagged")
assert thresholds[0]["flagged"] >= thresholds[-1]["flagged"]
Threshold review
  score >= 0.25: 21 flagged
  score >= 0.5: 18 flagged
  score >= 0.75: 9 flagged
windows = Counter(r["business_event_time"].date().isoformat() for r in rows)
print("Replay windows")
print(f"  windows: {len(windows)}")
print(f"  largest window: {max(windows.values()):,} rows")
assert windows
Replay windows
  windows: 4
  largest window: 228 rows
coverage = {
    "pit_rows": len(rows),
    "predictions": len(predictions),
    "matured_labels": len(matured),
    "unresolved_labels": len(rows) - len(matured),
}
print("Coverage")
print(f"  point-in-time rows: {coverage['pit_rows']:,}")
print(f"  predictions: {coverage['predictions']:,}")
print(f"  matured labels: {coverage['matured_labels']:,}")
print(f"  unresolved labels: {coverage['unresolved_labels']:,}")
assert coverage["pit_rows"] == coverage["predictions"]
Coverage
  point-in-time rows: 612
  predictions: 612
  matured labels: 78
  unresolved labels: 534
manifest = {
    "run_id": data.run_id,
    "coverage": coverage,
    "thresholds": thresholds,
    "label_policy": "exclude_unresolved",
}
manifest["fingerprint"] = sha256_json(manifest)
print("Replay manifest")
print(f"  Run ID: {manifest['run_id']}")
print(f"  coverage fields: {len(manifest['coverage'])}")
print(f"  thresholds: {len(manifest['thresholds'])}")
print(f"  label policy: {manifest['label_policy']}")
print(f"  fingerprint: {manifest['fingerprint'][:12]}...")
Replay manifest
  Run ID: RUN-3c8a07d6ac6d0773
  coverage fields: 4
  thresholds: 3
  label policy: exclude_unresolved
  fingerprint: 7e3356480a58...
assert manifest["fingerprint"] == sha256_json(
    {k: v for k, v in manifest.items() if k != "fingerprint"}
)
print("Prediction alignment verified")
print("  identity: stable event ID")
print(f"  mature labels used for evaluation: {coverage['matured_labels']:,}")
print("  policy: exclude_unresolved")
Prediction alignment verified
  identity: stable event ID
  mature labels used for evaluation: 78
  policy: exclude_unresolved

Feature-space analysis#

The next cells use the existing ml extra. t-SNE is exploratory: it preserves local neighborhoods, is not a classifier, and must not be interpreted as a causal separation. The plot is optional and uses matplotlib when installed.

import polars as pl

from fraudtwin.ml.drift import DriftConfig, compare_performance, compare_windows
from fraudtwin.ml.metrics import auc, pr_auc

feature_names = [
    name
    for name in (
        "amount",
        "transaction_count_1h",
        "transaction_amount_24h",
        "device_age_days",
        "customers_per_device_24h",
        "credit_utilization",
    )
    if name in dataset.frame.columns
]
feature_frame = (
    dataset.frame.select(feature_names + ["label", "split"])
    .with_columns(
        [pl.col(name).cast(pl.Float64).fill_null(0.0).alias(name) for name in feature_names]
    )
    .drop_nulls(subset=["split"])
)
print("Feature matrix")
print(f"  rows: {feature_frame.height:,}")
print(f"  features ({len(feature_names)}): {', '.join(feature_names)}")
assert feature_frame.height > 100
Feature matrix
  rows: 612
  features (6): amount, transaction_count_1h, transaction_amount_24h, device_age_days, customers_per_device_24h, credit_utilization
numeric = feature_frame.select(feature_names).to_numpy()
try:
    from sklearn.decomposition import PCA
    from sklearn.manifold import TSNE
    from sklearn.preprocessing import StandardScaler

    scaled = StandardScaler().fit_transform(numeric)
    pca = PCA(n_components=2, random_state=42).fit_transform(scaled)
    sample_n = min(240, len(scaled))
    perplexity = min(30, max(5, sample_n // 10))
    embedding = TSNE(
        n_components=2,
        random_state=42,
        init="pca",
        learning_rate="auto",
        perplexity=perplexity,
        max_iter=350,
    ).fit_transform(scaled[:sample_n])
    assert embedding.shape == (sample_n, 2)
except ImportError:
    pca = None
    embedding = numeric[: min(240, len(numeric))]

try:
    import matplotlib.pyplot as plt

    fig, axes = plt.subplots(1, 2, figsize=(11, 4), constrained_layout=True)
    if pca is not None:
        axes[0].scatter(pca[:, 0], pca[:, 1], s=10, alpha=0.55, color="#2563eb")
        axes[0].set_title("PCA overview")
        axes[0].set_xlabel("component 1")
        axes[0].set_ylabel("component 2")
    else:
        axes[0].axis("off")
        axes[0].text(0.5, 0.5, "PCA unavailable\ninstall scikit-learn", ha="center", va="center")
    axes[1].scatter(embedding[:, 0], embedding[:, 1], s=14, alpha=0.7, color="#dc2626")
    axes[1].set_title("t-SNE local neighborhoods")
    axes[1].set_xlabel("component 1")
    axes[1].set_ylabel("component 2")
    fig.suptitle("Feature-space view")
    plt.show()
except ImportError:
    print("Install matplotlib to render the feature-space plot.")
../_images/7cefb658947895b92f86dc26746cec30c99547f884bad62ba50f5fa4636f0834.png
reference_rows = rows[: len(rows) // 2]
comparison_rows = rows[len(rows) // 2 :]
drift = compare_windows(
    reference_rows,
    comparison_rows,
    DriftConfig(reference_name="train-window", comparison_name="replay-window", minimum_samples=10),
)
print("Drift comparison")
print(f"  metrics: {len(drift.metrics)}")
print(f"  alerts: {len(drift.alerts)}")
print(f"  fingerprint: {drift.fingerprint[:12]}...")
assert drift.reference_count + drift.comparison_count == len(rows)
Drift comparison
  metrics: 137
  alerts: 63
  fingerprint: 37ce0d2b6f00...
matured_rows = [row for row in rows if row["label"] is not None]
prediction_scores = {prediction.event_id: prediction.fraud_score for prediction in predictions}
reference_rows = [row for row in matured_rows if row["split"] == "train"]
comparison_rows = [row for row in matured_rows if row["split"] == "test"]


def performance_values(candidate_rows):
    labels = [int(row["label"] == "FRAUD") for row in candidate_rows]
    scores = [prediction_scores[row["event_id"]] for row in candidate_rows]
    row_ids = [row["dataset_row_id"] for row in candidate_rows]
    return {
        "pr_auc": pr_auc(labels, scores, row_ids),
        "roc_auc": auc(labels, scores, row_ids),
    }


reference_values = performance_values(reference_rows)
comparison_values = performance_values(comparison_rows)
if (
    len(matured_rows) >= 20
    and all(value is not None for value in reference_values.values())
    and all(value is not None for value in comparison_values.values())
):
    performance = compare_performance(
        reference_values,
        comparison_values,
        config=DriftConfig(reference_name="train", comparison_name="test", minimum_samples=10),
        label_policy="exclude_unresolved",
    )
    print("Performance comparison")
    print(f"  metrics: {len(performance)}")
    print(f"  alerts: {sum(metric.alerted for metric in performance)}")
else:
    print("Performance comparison deferred")
    print(f"  matured rows: {len(matured_rows):,}")
    print("  reason: each window needs both FRAUD and LEGITIMATE matured labels")
    print("  policy: exclude_unresolved")
Performance comparison deferred
  matured rows: 78
  reason: each window needs both FRAUD and LEGITIMATE matured labels
  policy: exclude_unresolved
analysis_manifest = {
    "run_id": data.run_id,
    "feature_names": feature_names,
    "tsne_points": int(embedding.shape[0]),
    "drift_fingerprint": drift.fingerprint,
    "label_policy": "exclude_unresolved",
}
analysis_manifest["fingerprint"] = sha256_json(analysis_manifest)
print("Analysis artifact")
print(f"  Run ID: {analysis_manifest['run_id']}")
print(f"  Features: {len(analysis_manifest['feature_names'])}")
print(f"  t-SNE points: {analysis_manifest['tsne_points']:,}")
print(f"  Drift fingerprint: {analysis_manifest['drift_fingerprint'][:12]}...")
print(f"  Manifest fingerprint: {analysis_manifest['fingerprint'][:12]}...")
print(f"  Label policy: {analysis_manifest['label_policy']}")
assert analysis_manifest["fingerprint"] == sha256_json(
    {k: v for k, v in analysis_manifest.items() if k != "fingerprint"}
)
Analysis artifact
  Run ID: RUN-3c8a07d6ac6d0773
  Features: 6
  t-SNE points: 240
  Drift fingerprint: 37ce0d2b6f00...
  Manifest fingerprint: 8a457ce5b190...
  Label policy: exclude_unresolved

Verification and next step#

Re-run the offline cells from a clean checkout and compare the printed fingerprints. For service-backed publication, continue with the relevant integration runbook after this notebook; do not treat synthetic metrics as a deployment SLO.