A credit-risk analyst can build an excellent vintage matrix in R. Yet every month the same analyst may still extract data, rerun calculations, compare cohorts, inspect MOB curves, identify divergence, filter products and channels, assess EAD, investigate and prepare commentary.
The calculation is automated. The investigation often is not. Vintage analysis becomes operationally powerful when cohort calculation, baseline comparison, deterioration detection and investigation are separated into controlled analytical layers.
What does vintage analysis reveal?
The companion Credit Vintage Analysis research develops portfolio behaviour and interpretation.
How do we operationalise it?
This build begins where that methodology ends: repeatable calculation, monitoring and investigation at portfolio scale.
Controlled dates turn portfolio records into comparable cohorts
An original hypothetical account-observation dataset uses loan_id, customer_id, origination_date, observation_date, product, channel, ead, default_flag, dpd and risk_grade. Exact schemas vary. Engineering requires a consistent origination definition, controlled observation dates, reproducible outcomes and uniquely identifiable exposures.
Monthly grouping is illustrative; quarterly or another defensible granularity may better fit production volume and risk emergence.
Calendar-date comparison mixes seasoning. Vintage analysis aligns Vintage A at MOB 6 with Vintage B at MOB 6, not whichever observations happen to share a reporting month.
library(dplyr)
library(lubridate)
portfolio_vintage <- portfolio %>%
mutate(
vintage = floor_date(origination_date, "month"),
mob = interval(origination_date, observation_date) %/% months(1)
)Use a cumulative default rate consistently
The denominator is the controlled origination population. Each loan contributes once; ever_default records whether its first default occurred on or before the MOB. Delinquency, loss or balance deterioration could replace this outcome, but should not be mixed into the same metric.
cohort_accounts <- portfolio_vintage %>%
group_by(vintage) %>%
summarise(eligible_accounts = n_distinct(loan_id), .groups = "drop")
vintage_summary <- portfolio_vintage %>%
group_by(vintage, mob) %>%
summarise(defaults_to_date = n_distinct(loan_id[ever_default == 1]),
.groups = "drop") %>%
left_join(cohort_accounts, by = "vintage") %>%
mutate(default_rate = defaults_to_date / eligible_accounts)ead_vintage <- portfolio_vintage %>%
group_by(vintage, mob) %>%
summarise(
eligible_ead = sum(origination_ead[!duplicated(loan_id)], na.rm = TRUE),
defaulted_ead = sum(origination_ead[!duplicated(loan_id)] * ever_default[!duplicated(loan_id)], na.rm = TRUE),
ead_default_rate = defaulted_ead / eligible_ead,
.groups = "drop"
)This freezes exposure at origination for a coherent illustrative denominator. Current EAD, default-date EAD and survival-adjusted designs answer different questions and require explicit eligibility, timing and denominator rules. Account weighting can hide a few large deteriorating exposures; EAD weighting can reveal their economic concentration.
The vintage matrix is the deterministic foundation
The following entirely hypothetical portfolio contains five monthly cohorts. Rates are cumulative, so observed values never fall. January and February establish a stable reference, March is weaker, April diverges at MOB 4, and May is too immature for a conclusion.
| Vintage | MOB 1 | MOB 2 | MOB 3 | MOB 4 | MOB 5 | MOB 6 |
|---|---|---|---|---|---|---|
| 2026-01 · reference | 0.2% | 0.4% | 0.6% | 0.8% | 1.0% | 1.2% |
| 2026-02 · reference | 0.2% | 0.4% | 0.5% | 0.8% | 1.1% | 1.3% |
| 2026-03 · weaker | 0.3% | 0.5% | 0.8% | 1.2% | 1.6% | 2.0% |
| 2026-04 · diverging | 0.3% | 0.6% | 0.9% | 1.6% △ | 2.2% ▲ | 2.7% ▲ |
| 2026-05 · immature | 0.2% | 0.5% | NYO — | NYO — | NYO — | NYO — |
Not Yet Observed is not zero. A genuine observed 0.0% is a performance result; a future cell contains no observation. Storage and presentation must preserve that distinction.
A baseline makes divergence measurable
Baselineₘ is a reference at equivalent MOB: perhaps a historical median, selected reference vintages or weighted historical average. No construction is universally correct. Here the median is illustrative.
RelativeDeviationᵥ,ₘ = (Metricᵥ,ₘ − Baselineₘ) / Baselineₘ
Positive deviation means deterioration when higher is worse. Compare MOB 6 with MOB 6. Relative change improves scale comparison but can exaggerate movement where the baseline is very small.
baseline <- vintage_summary %>% group_by(mob) %>% summarise(baseline_rate = median(default_rate, na.rm = TRUE), .groups = "drop") vintage_diagnostics <- vintage_summary %>% left_join(baseline, by = "mob") %>% mutate(deviation = default_rate - baseline_rate)
This begins diagnostics; it is not complete decision logic. One point may be noise.
Priorityᵥ,ₘ = f(Deviation, Persistence, Velocity, Exposure)
The threshold c, lookback, materiality logic and priority policy are controlled configuration—not universal constants. A small severe vintage may matter less immediately than moderate deterioration across a major cohort.
- Divergence
- Persistence
- Velocity
- Materiality
- Investigation priority
The automation opportunity begins after the chart
A reusable Vintage Engine would expose conceptual, controlled interfaces—not one analyst's notebook:
build_vintages() calculate_mob() calculate_vintage_metric() calculate_baseline() detect_deviation() measure_persistence() compare_segments() rank_vintages()
Versioned portfolio snapshot plus methodology and configuration.
Cohorts, MOB, performance, baseline, deviation, persistence and exposure materiality.
Structured diagnostics with lineage, observation status and comparable seasoning.
The engine does not need an LLM. Its path is Portfolio Data → Cohort Assignment → MOB → Performance → Matrix → Baseline → Deviation → Persistence → Materiality.
Place an AI Portfolio Analyst above the engine
The Agent consumes structured deterministic outputs. It asks which vintages are deteriorating, when divergence began, whether it persists, whether it is material, what segment explains it and where analyst attention belongs.
| Vintage Engine | AI Portfolio Analyst |
|---|---|
| Builds cohorts | Investigates cohorts |
| Calculates MOB | Interprets seasoning |
| Calculates metrics and baseline | Explains divergence |
| Detects deviation and persistence | Investigates drivers |
| Returns structured data | Synthesises analyst-ready evidence |
get_vintage_matrix() compare_vintage_to_baseline() get_vintage_deviation() get_segment_breakdown(vintage) compare_account_vs_ead() get_top_deteriorating_vintages()
These are proposed interfaces, not claims of existing production tools.
Which recent vintages are materially underperforming the historical baseline?
The Agent calls get_top_deteriorating_vintages(); it does not guess. From the hypothetical matrix and a January–February reference it can return:
- Divergence begins
- MOB 4
- Current deviation
- +1.45 pp vs 1.25% MOB 6 baseline
- Persistence
- 3 observed MOB points
- Exposure
- €42m
- Concentration
- Product B / Channel X
- Account / EAD signal
- Moderate / high
- Next investigation
- Underwriting and channel mix
Start broad, then isolate the source
Is deterioration broad-based or concentrated?
get_segment_breakdown(vintage) can recursively compare product, channel, risk grade and geography where analytically relevant. The hierarchy varies; the principle is progressive isolation using the same governed data.
Ask whether the signal exists in accounts, exposure or both
Metricᵃᶜᶜᵒᵘⁿᵗˢ elevated alone may indicate many smaller cases. Metricᴱᴬᴰ elevated alone may reveal a few large exposures. Both elevated indicates broad and economically meaningful deterioration. Denominator differences must remain visible.
Separate performance from composition
A deteriorating vintage can reflect worse borrower performance, different borrower composition, or both. Compare risk-grade, product and channel mix and the exposure distribution. If the break coincides with a changed origination month, the Agent may examine acquisition channel, average exposure or approval-strategy mix.
Seasoning, baseline and alert controls keep the workflow honest
A cohort at MOB 2 cannot be compared with MOB 12. The Agent must state that evidence is immature.
Future cells remain Not Yet Observed, distinct from observed zero.
Monitor whether macro or portfolio conditions changed; historical average is not permanent truth.
Retain snapshot, outcome, denominator, configuration and engine versions behind every result.
Backtest the alert, not only the rate
If the engine would have flagged April at MOB 4, later ask whether divergence persisted, outcomes worsened, the alert was early, or it was noise. Compare champion and challenger baselines, persistence logic, materiality logic and thresholds on historical vintages without optimising blindly to historical noise.
Agent guardrails
- Never invent missing portfolio data or calculate uncontrolled metrics from prose.
- Never compare non-equivalent MOB periods or treat NYO as zero.
- Never infer causality without evidence.
- Never change baseline methodology or risk thresholds autonomously.
- Never classify a vintage without structured engine evidence and lineage.
- Escalate immature, missing, conflicting or out-of-scope evidence to analyst review.
The resolve is recurring portfolio intelligence
The finished path is Methodology → R implementation → deterministic analytical engine → AI-assisted workflow → analyst review. R preserves cohort arithmetic. The engine standardises comparison and diagnostics. The Agent queries controlled evidence, ranks attention and frames investigation. The analyst owns interpretation and action.