Inspiration

We chose the Tangerine track, Resilience Rewired, because the question behind it is one every bank already has the data to answer and almost none do: for a given customer, how long could they last if life went wrong, and what should they do about it today?

The judges gave us a clean definition of runway. Our first instinct was to build the dashboard around it. Our second instinct was to ask whether it actually works. We computed the formula as of every month for every customer and compared it with what happened next. It turned out that 79% of the customers who later overdrew were showing 12+ months of runway three months before it happened. The formula is a good description of a customer's position and a poor predictor of trouble. That gap became the project: keep the judges' numbers exactly, then build what the numbers miss.

What it does

Resilience Engine takes any one of the 4,500 customers and, in seconds, does four things from their own transaction history.

1. Know the customer (judges' definitions, exact). From monthly_summary.csv and loans.csv:

$$ I = \operatorname{median}_m(\text{money_in}_m), \qquad S = \operatorname{median}_m(\text{money_out}_m), \qquad H = \operatorname{median}_m(\text{household_bills}_m) $$

$$ B = \tfrac{1}{6}\sum_{m=M-5}^{M} \text{avg_daily_balance}_m, \qquad P = \text{monthly_payment}\cdot\mathbb{1}{\text{status}\in{C,D}} $$

$$ \text{runway} = \begin{cases} 0 & B - C \le 0 \ \text{no shortfall (120+)} & S' - I' \le 0 \ \min!\left(\dfrac{B - C}{S' - I'},\ 120\right) & \text{otherwise} \end{cases} $$

2. Apply any shock. Every twist is a combination of a few parameters, so the three example twists and anything a judge invents live on the same path:

$$ I' = \rho I + \Delta I, \qquad S' = S(1+\sigma) + \beta H + \Delta H + E + \lambda P, \qquad C = C_0 + kS $$

Layoff on EI is $\rho = 0.55$; a furnace repair is $k = 1$; bills up 15% is $\beta = 0.15$. Two realism switches, off by default so the headline matches the judges, make the layoff honest: EI is capped at $\bar E = 729 \times \tfrac{52}{12} = \$3{,}159$ per month (canada.ca, 2026), and a layoff does not stop a pension:

$$ I' = \Pi + \min\big(0.55\,(I - \Pi),\ \bar E\big), \qquad \Pi = \min\big(\text{pension} + \text{interest},\ I\big) $$

The cap binds for 57% of customers, so for most of the book the flat 55% rule overstates post-layoff income.

3. Warn early. A validated early warning model scores every customer each month on the probability that their balance goes negative within three months:

$$ y_{i,t} = \mathbb{1}\Big{\min_{h\in{1,2,3}} \text{min_balance}{i,t+h} < 0\Big}, \qquad P(y{i,t}=1) = \sigma\big(w_0 + \mathbf{w}^\top \mathbf{x}_{i,t}\big) $$

Features use months $\le t$ only, and include each customer measured against their own past, $z_{i,t} = \dfrac{x_{i,t} - \bar x_{i,<t}}{s_{i,<t} + 1}$. The bank view ranks who to call this month and says why in plain language ("balance below own normal", "spending above own normal"). The customer sees it as a lock-screen nudge built from their own numbers, never as an alarm.

4. Make a plan, then grow. A goal plan (emergency fund of $n$ months of spending, saving a share $\alpha$ of the surplus) and a backup plan if the shock hits:

$$ \text{months to goal} = \frac{\max(0,\ nS - B)}{\alpha\,(I - S)}, \qquad \text{cut needed to last } T \text{ months} = \max!\Big(0,\ G - \frac{L}{T}\Big), \quad L = B - C,\ G = S' - I' $$

Once the cushion is full, an investing layer sizes a weekly contribution and picks the lower of the customer's stated risk tolerance and their measured risk capacity, then projects ten years by bootstrapping real monthly ETF returns (illustration, not advice).

How we built it

Python end to end: pandas 3, scikit-learn, hmmlearn, NumPy; Streamlit with native Altair charts for the app; matplotlib for the pitch charts. The data is the PKDD'99 Financial dataset (real anonymized bank records, 4,500 accounts).

  • engine.py is the single source of truth: profile, Shock dataclass, judges' runway, month-by-month path, plans, drivers, caveats. test_engine.py is an independent vectorized re-implementation of the judges' definitions; it matches the engine on all 4,500 accounts with 0 mismatches, then runs 27,637 hardening checks over random combined twists and confirms that a worse twist never gives a longer runway.
  • Early warning (features.py, baselines.py, early_warning.py): an account $\times$ month panel where a row is a prediction made at the end of month $t$. Train on $t \le$ 2023-09, freeze the alert threshold on 2023-10 to 2024-09 at 50% row recall, score the held-out year 2024-10 to 2025-09 exactly once. Rule, logistic regression and gradient boosting baselines; we ship the logistic regression because it is as good as the GBM on test and we can show its weights. 95% intervals from account-level bootstrap.
  • Monte Carlo (montecarlo.py): moving block bootstrap of the customer's own last 24 months (blocks of 3 so bad months cluster), with the within-month dip subtracted so survival means never going below zero inside any month:

$$ X_m = X_{m-1} + \tilde I_m - \tilde S_m - \beta H - E, \qquad \hat P(\text{survive } T) = \frac{1}{N}\sum_{n=1}^{N} \mathbb{1}\Big{\min_{m\le T}\big(X^{(n)}_m - \text{dip}^{(n)}_m\big) \ge 0\Big} $$

  • Evidence scripts (validate_runway.py, validate_mc.py, robustness.py, population.py, migration.py): every score rebuilt as of month $t$ from history $\le t$ only, so runway, Monte Carlo and the model are compared on identical rows and labels.
  • Migration matrix (migration.py): Stable / Stretched / Distressed states from the judges' $B_t / S_t$, with a Markov check of $P^k$ against actual $k$-month moves in the test year.
  • HMM (hmm.py, secondary): Gaussian HMM on scale-free behaviour features, forward-filtered only, with first-passage probability into the stress state $1 - \mathbf{p}t A{:,\mathcal N} (A_{\mathcal N,\mathcal N})^{h-1}\mathbf{1}$.

Challenges we ran into

  • Leakage was everywhere. The judges' $B$ uses the last six months, which for most accounts come after the overdraft, so "runway vs ever overdrew" on the full history is circular. We rebuilt every number as of each month. Penalty interest sits inside bank_fees and is charged because of the event; we stripped it. Standing orders are an end-of-data snapshot; a model with them scored AUC 0.969, without them 0.955, and we report the lower number because the higher one cheats.
  • Quiet months. 99% of accounts have deposit-only first months, which look like perfect health. A 6-month burn-in before any account enters the risk set fixed it.
  • The HMM wanted to smooth. predict_proba uses future months. We wrote our own forward filter so every state probability uses $x_{1:t}$ only, and dropped features (cash share, transaction count) that produced "quiet month vs busy month" artifact states instead of financial stress.
  • The formula's layoff is not a Canadian layoff. Flat 55% replacement ignores the EI maximum and cuts pensions. Both fixes are optional switches so the headline stays comparable, and the app warns whenever the cap would bind or a pension is present.
  • Edge cases the formula does not announce. $H = 0$ for accounts whose bills are paid elsewhere, so a +15% bills shock changes nothing; customers already in deficit; buffers wiped out on day one. Every branch of the plan returns a plain-language sentence instead of a division by zero.
  • Monte Carlo was too pessimistic. Real customers spend about 71% of their usual amount at their lowest balances. We measured that response curve on the train period and report the simulation as a stress view and a ranking, not a calibrated probability.

Accomplishments that we're proud of

  • Judges' numbers, exact, and proven exact: 0 mismatches against an independent implementation on all 4,500 accounts.
  • A finding that changes how the formula should be read. As a ranking of who overdraws next on 48,400 held-out account-months (96 first overdrafts): judges' runway AUC 0.65 [0.60, 0.71]; runway under EI 55% 0.73; Monte Carlo on the customer's own history 0.85 [0.82, 0.89]; early warning model 0.955 [0.94, 0.97]. The result holds across 3, 6 and 12 month horizons and whether the buffer is the judges' 6-month average or the actual balance.
  • A model the bank could run on Monday. At the frozen threshold it catches about 72% of first overdrafts with a median lead of 3 months while flagging roughly 2% of safe account-months, and its weights are readable. Demographics are never inputs; a fairness audit on gender, age, region and local unemployment shows event recall of 0.74 for women and 0.70 for men, with no segment left behind that has enough events to judge.
  • Population insight the formula alone gives. On EI 55%, 35% of customers run out within 6 months by the formula, 50% with the real EI cap. The most fragile group is middle-aged higher earners (35 to 49: 45% by the formula, 65% with the cap), and borrowers in good standing are about twice as exposed as customers with no loan (61% vs 30%).
  • Distress is sticky. Customers who are overdrawn or under one month of buffer are still there a year later 43% of the time, against 8% predicted by a memoryless migration matrix, which is the quantitative case for warning months early rather than reacting.
  • The warning signs are visible six months out. Among the 288 customers who overdrew, the median buffer falls from the 2.2 months everyone else holds to 0.4 months in the final month, while spending climbs to 1.23× income.

What we learned

  • A number can be exactly right by definition and still be the wrong number to act on. Validating the judges' formula against real outcomes taught us more about the data than any model did.
  • Time discipline is the whole game in financial prediction: as-of-$t$ features, time-ordered splits, frozen thresholds, forward filters. Every shortcut we tried produced a better AUC and a worse model.
  • Relative beats absolute. Features that compare a customer with their own past carry most of the signal; the same balance is fine for one customer and a crisis for another.
  • Simplicity and transparency won. A logistic regression we can explain matched the gradient boosting on the held-out year, and the explanation is what a banker or a customer actually needs.
  • Only about one flagged customer in eight really overdraws, so the right customer-facing output is a specific, gentle next step from their own numbers, not a red alert.

What's next for Resilience

  • Calibrated survival probabilities: fold the measured spending-feedback curve $f(X_{m-1}/S)$ into the Monte Carlo so the probability is a probability, not just a ranking, and validate it with Brier score as we already do for AUC.
  • Regime-switching simulation: draw bootstrap months conditional on the HMM state so a customer entering "tight" is simulated from tight months, not their whole history.
  • Close the loop: feed the nudges and plans back as actions and measure whether flagged customers who received one avoided the overdraft, turning the bank view from a list into an intervention with a measured lift.
  • Richer shocks: rate resets on variable loans ($\lambda P$ is already in the engine), partner income loss, and regional unemployment shocks applied to whole segments for a portfolio-level stress test.
  • Productionize: monthly re-scoring, drift monitoring on the frozen threshold, and a scheduled fairness report so the audit runs every month rather than once.

Built With

Share this project:

Updates

Submission history