Credit risk modeling — building a causal-inference-ready banking dataset¶
Use case: you want to evaluate a credit-line-increase intervention on a banking portfolio — does the treatment shift default risk? You need a dataset with a clean treatment / control split, a held-out validation window, per-entity ML features, and the heavy-tailed transaction noise that real banking data carries.
What this notebook does: start from the bundled banking template, customize it for the causal-uplift framing (narrow the treatment to a single target metric, keep the holdout split, turn on entity features), generate, then show the manifest payloads that act as your ground-truth oracle. The notebook stops at dataset hand-off — no model training, no sklearn. Once you have the dataset and the oracle, plug it into whatever causal workflow you prefer.
Expected run time: ~30–60 seconds end-to-end.
Setup¶
import json
from importlib.resources import files
from pathlib import Path
import pandas as pd
import yaml
import plotsim
print(f"plotsim {plotsim.__version__}")
What the banking template ships¶
plotsim.load_template('banking') returns a ready-to-generate config for a retail-banking warehouse. Before customizing, walk through what it carries so you know what you are starting from.
stock_cfg = plotsim.load_template("banking")
print(
f"entities: {len(stock_cfg.entities)} across {len({e.archetype for e in stock_cfg.entities})} archetypes"
)
print(f"periods: {stock_cfg.time_window.period_count()} ({stock_cfg.time_window.granularity})")
print(f"metrics: {[m.name for m in stock_cfg.metrics]}")
print(f"tables: {[t.name for t in stock_cfg.tables]}")
print(f"bridges: {[b.name for b in stock_cfg.bridges]}")
print(f"noise: family={stock_cfg.noise.noise_family} df={stock_cfg.noise.degrees_of_freedom}")
print(
f"holdout: target={stock_cfg.holdout.target_metric} periods={stock_cfg.holdout.holdout_periods}"
)
What you got, in one paragraph each:
- Metrics. Account balance, transaction volume, credit utilization, on-time payment ratio, delinquency risk, loan volume, default risk. Risk metrics carry negative polarity (high trajectory position → low risk score).
- Segments. Six customer cohorts: prime borrowers, subprime improving, mass market, deteriorating, HNW, new customers. The deteriorating cohort uses a
flat > growth > spike_then_crash @ 8 @ 16archetype that rises into default. - Lifecycle stages.
default_riskdrives aperforming → watch → past_due → defaultband (the engine writes astagecolumn on the activity fact). - Credit-score band (SCD2).
dim_customercarries acredit_score_bandcolumn that is versioned over time (super_prime / prime / near_prime / subprime) — each band crossing emits a new dim row withvalid_from/valid_to/is_current. - CDC audit on disbursements.
fct_loan_applicationscarries_inserted_at/_updated_at/_opcolumns so disbursement amendments are auditable. - Holdout. Last three months on
default_riskreserved for validation (the engine emits_train/_holdoutcompanions for every fact and event table). - Heavy-tailed noise. Student-t with 4 degrees of freedom on the additive jitter — real banking transaction amounts have fat tails that gaussian noise misses.
- Treatment. Half the
new_customercohort gets acredit_line_increaselift at period 6 (the bundled treatment is trajectory-wide; we'll narrow it in the next step).
Customize for causal uplift modeling¶
The cleanest way to layer customizations on top of a bundled template is to load its YAML as a dict, edit the dict, and pass the result back through plotsim.create(**dict). The builder validates every field exactly as it does for hand-written configs.
template_path = files("plotsim.configs.templates") / "banking.yaml"
spec = yaml.safe_load(template_path.read_text(encoding="utf-8"))
print("top-level keys:", sorted(spec))
Narrow the treatment to one metric¶
The bundled treatment shifts every metric for the treated half of the new_customer cohort. For clean causal inference on default risk specifically, you want every other metric to stay byte-identical between treatment and control — so the only difference attributable to the intervention is the shift on the target metric. Setting target_metric: default_risk on the treatment makes the engine apply the log-odds lift only when evaluating that one metric's effective position; every other metric reads the unshifted trajectory.
for segment in spec["segments"]:
if segment["name"] == "new_customer":
segment["treatment"]["target_metric"] = "default_risk"
print("updated treatment on segment 'new_customer':")
for k, v in segment["treatment"].items():
print(f" {k}: {v}")
Turn on entity features for the ML hand-off¶
Causal-inference workflows usually want per-entity aggregates (mean, std, slope, peak period) as ML features. entity_features: true adds an _entity_features.csv file with one row per customer and a column-per-metric-per-aggregate. Because holdout is enabled in this template, the engine automatically drops target-metric columns from the entity features (label-leakage protection) and aggregates only over the training periods.
spec["entity_features"] = True
print("holdout block (unchanged from template):")
for k, v in spec["holdout"].items():
print(f" {k}: {v}")
print("\nnoise block (unchanged — student-t df=4 ships in the template):")
for k, v in spec["noise"].items():
print(f" {k}: {v}")
Build and generate¶
plotsim.run(cfg, output_dir) runs the full pipeline in one call: generate → validate → build the ground-truth manifest → write the CSVs, the round-trip config.yaml, the validation report, the _train / _holdout companions, _entity_features.csv, and manifest.json. Returns the resolved output directory.
cfg = plotsim.create(**spec)
out_dir = Path("output_credit_risk")
plotsim.run(cfg, out_dir)
csv_files = sorted(p.name for p in out_dir.iterdir() if p.suffix == ".csv")
print(f"wrote {len(csv_files)} CSV files to {out_dir}/")
for name in csv_files:
print(f" {name}")
Three sanity checks: the train/holdout split landed, the entity-features file landed with target-metric columns stripped, and the treatment column is present on the activity fact.
train = pd.read_csv(out_dir / "fct_account_activity_train.csv")
holdout = pd.read_csv(out_dir / "fct_account_activity_holdout.csv")
print(f"train rows: {len(train):,} periods: {train['date_key'].nunique()}")
print(f"holdout rows: {len(holdout):,} periods: {holdout['date_key'].nunique()}")
features = pd.read_csv(out_dir / "_entity_features.csv")
default_risk_cols = [c for c in features.columns if "default_risk" in c]
print(f"\nentity features: {len(features):,} rows, {len(features.columns)} columns")
print(
f"default_risk columns in features (should be empty — label leakage protection): {default_risk_cols}"
)
manifest_obj = json.loads((out_dir / "manifest.json").read_text())
treatment_map = {
a["entity"]: a["treatment"]["group"]
for a in manifest_obj["archetype_assignments"]
if a.get("treatment")
}
treatment_counts = pd.Series(treatment_map).value_counts()
print("\ntreatment assignment (from manifest archetype_assignments):")
print(treatment_counts.to_string())
The manifest is your oracle¶
manifest.json records the engine's ground-truth payloads — the values that drove generation, not derived statistics. For causal-inference validation you want three sections in particular: the treatment cohort definition, per-archetype regression baselines, and variance partitions.
Treatment cohorts — what lift to expect, on which metric, for whom¶
Each cohort record names the treatment label, the per-arm member count, the mean log-odds lift (None for the control arm), the start period, and the per-metric target. Your causal model's recovered effect on default_risk should approximate mean_lift_log_odds for entities in the treatment arm; every other metric should be byte-identical between arms because target_metric narrows the lift to one signal.
manifest_obj = json.loads((out_dir / "manifest.json").read_text())
for cohort in manifest_obj["treatment_cohorts"]:
print(f"label={cohort['label']!r}")
print(f" n_entities: {cohort['n_entities']}")
print(f" mean_lift_log_odds: {cohort['mean_lift_log_odds']}")
print(f" start_period: {cohort['start_period']}")
print(f" target_metric: {cohort['target_metric']}")
print()
Regression pairs by archetype — what every metric pair's OLS β should land at¶
For every declared correlation, the engine fits OLS in both directions per archetype, pre-noise. These are the baselines your regression checks should reproduce on the noise-free signal.
by_arch = manifest_obj["regression_pairs_by_archetype"]
print(f"archetypes with regression payloads: {sorted(by_arch)}\n")
first_arch = sorted(by_arch)[0]
print(f"pairs under archetype {first_arch!r}:")
for pair in by_arch[first_arch]:
a, b = pair["metric_a"], pair["metric_b"]
print(
f" {a:22s} → {b:22s} R²={pair['r_squared']:.3f} β_a→b={pair['beta_a_to_b']:+.4g} n={pair['n_observations']:,}"
)
Variance partitions — how much of each metric's variance is explained at each level¶
Three-level nested ANOVA: variance between archetypes, between entities within archetype, and residual within-entity time-series variance. If your model attributes more variance to entity-level effects than this payload reports, you have over-fit to noise.
rows = []
for vp in manifest_obj["variance_partitions"]:
rows.append(
{
"metric": vp["metric"],
"frac_between": round(vp["fraction_between"], 3),
"frac_within_entity": round(vp["fraction_within_entity"], 3),
"frac_residual": round(vp["fraction_residual"], 3),
}
)
pd.DataFrame(rows)
Hand-off¶
Your dataset is ready. The output_credit_risk/ directory carries:
- The normalized star schema (one CSV per dim, fact, event, bridge).
- The train/holdout splits per fact and event table.
_entity_features.csv— per-entity ML feature matrix with target-metric leakage already stripped.manifest.json— the ground-truth oracle for treatment cohorts, regression baselines, and variance partitions.
Hand this to your causal-inference workflow. The manifest tells you what the treatment effect should be, what the per-archetype regression coefficients should land at, and how much variance is attributable to which level — so your recovered estimates have something specific to be evaluated against.