Lifetime Loss Forecasting Tutorial
This guide shows the baseline transition-matrix workflow for estimating portfolio lifetime loss.
Problem
Use this workflow when you already have:
- a loan-level portfolio snapshot
- transition input that describes monthly state migration
- an LGD assumption you want to apply across the book
This is the right starting point for reserve estimation prototypes, scenario baselines, and sanity checks before moving into simulation or rollforward-based workflows.
Inputs
Your portfolio DataFrame should include:
loan_idprincipalannual_rateterm_monthsstart_datestatusfico_score
Your transition input can be one of:
- a square transition matrix with the same states on rows and columns
- a transition ledger with
loan_id,period, andstatus - a loan history panel with
loan_id,fund_date, andas_of_date
If you pass a square matrix, it should:
- use the same states on rows and columns
- include a
Charged Offstate (or legacyDefault) - have each row sum to 1
Code
import pandas as pd
from cranalytics import (
forecast_lifetime_loss,
forecast_portfolio_states,
summarize_lifetime_loss,
)
portfolio_df = pd.DataFrame(
{
"loan_id": ["L101", "L102", "L103"],
"principal": [10000.0, 15000.0, 8000.0],
"annual_rate": [0.08, 0.10, 0.09],
"term_months": [36, 36, 24],
"start_date": [
pd.Timestamp("2024-01-01"),
pd.Timestamp("2024-02-01"),
pd.Timestamp("2024-01-15"),
],
"status": ["Current", "Delinquent", "Current"],
"fico_score": [720, 640, 700],
}
)
states = ["Current", "Delinquent", "Charged Off"]
migration_matrix = pd.DataFrame(
[[0.95, 0.04, 0.01], [0.10, 0.80, 0.10], [0.00, 0.00, 1.00]],
index=states,
columns=states,
)
initial_states = (
portfolio_df["status"]
.value_counts()
.reindex(migration_matrix.index, fill_value=0)
.astype(float)
)
state_forecast = forecast_portfolio_states(
migration_matrix,
initial_states,
n_periods=12,
)
print(state_forecast.head())
lifetime_loss = forecast_lifetime_loss(
portfolio_df,
migration_matrix,
lgd=0.55,
as_of_date=pd.Timestamp("2024-02-01"),
)
print(f"Estimated lifetime loss: ${lifetime_loss:,.2f}")
summary = summarize_lifetime_loss(
portfolio_df,
migration_matrix,
lgd=0.55,
as_of_date=pd.Timestamp("2024-02-01"),
)
print(summary)
# The same API also accepts transition ledgers or loan history panels:
history_df = pd.DataFrame(
{
"loan_id": ["L101", "L101", "L102", "L102"],
"fund_date": [
pd.Timestamp("2023-12-01"),
pd.Timestamp("2023-12-01"),
pd.Timestamp("2023-12-15"),
pd.Timestamp("2023-12-15"),
],
"as_of_date": [
pd.Timestamp("2024-01-01"),
pd.Timestamp("2024-02-01"),
pd.Timestamp("2024-01-01"),
pd.Timestamp("2024-02-01"),
],
"source_status": ["Current", "Current", "Current", "Charged Off"],
"current_balance": [10000.0, 9800.0, 15000.0, 0.0],
"annual_rate": [0.08, 0.08, 0.10, 0.10],
"term_months": [36, 36, 36, 36],
}
)
loss_from_history = forecast_lifetime_loss(
portfolio_df,
history_df,
lgd=0.55,
as_of_date=pd.Timestamp("2024-02-01"),
)
print(f"Estimated lifetime loss from history: ${loss_from_history:,.2f}")
Output
You should expect two useful views:
forecast_portfolio_states(...)returns a period-by-period state distributionsummarize_lifetime_loss(...)returns a compact reserve summary with:total_portfolio_balanceestimated_lifetime_lossreserve_ratiolgd_assumption
This workflow is intentionally simple. It is best used as a baseline, not as a full operating loss engine.
Explain a reserve change
Two forecast_lifetime_loss(...) numbers tell you how much the reserve moved,
not why. reserve_walk(...) bridges the prior reserve to the current one, one
input at a time:
from cranalytics.reserve import reserve_walk
from cranalytics.viz import plot_reserve_walk
walk = reserve_walk(
prior_portfolio,
current_portfolio,
{"migration_matrix": prior_matrix, "as_of_date": "2024-09-30", "lgd": 0.55},
{"migration_matrix": current_matrix, "as_of_date": "2024-12-31", "lgd": 0.60},
)
plot_reserve_walk(walk)
The steps run in a fixed order: Runoff, Seasoning (the remaining loans
age to the current date), New originations, Balance change, Credit
migration, then the assumptions: Transition matrix, LGD and
Prepayment and amortization. The step amounts add up to the current reserve
minus the prior one, and the last row equals forecast_lifetime_loss on the
current portfolio.
The order is part of the method
Each step is valued after the ones before it, so a change that involves two inputs is credited to the later step. A loan that is paid down and also rolls delinquent has its paydown valued at the old status; the extra loss from the new status shows up in Credit migration. Reordering the steps would change the attribution, never the total.
Use reserve_walk_by_segment(..., by="fico_band") (or "vintage", or any
column in both portfolios) for the same walk per segment. This explains changes
in the forecast; it is not an allowance rollforward of provision and realized
charge-offs.
Common errors
- Matrix states and portfolio statuses do not match. If your portfolio contains extra labels that are not in the matrix, normalize or filter them first.
- The matrix is not square or its rows do not sum to 1. The validator rejects invalid transition assumptions.
forecast_portfolio_states(...)still requires an explicit square matrix even thoughforecast_lifetime_loss(...)andsummarize_lifetime_loss(...)also accept ledgers and loan history.- You forget
as_of_date. If omitted, the current date is used, which changes remaining term calculations. - You need cashflow-level behavior, amortization effects, or dynamic risk assumptions. Move up to simulation or Rollforward instead of stretching this baseline too far.
Run the packaged demo end-to-end with:
python -m cranalytics.examples.core_lifetime_loss
Estimate transitions from observed monthly histories
estimate_cohort_matrix measures one-month transitions, with equal weight
per observed account-pair. A January Current / April Default pair cannot identify
a monthly transition and raises by default. The estimator does not forward-fill
states, interpolate events, or divide a multi-month transition into monthly ones.
Use gap_policy="exclude" only when you intend to estimate from the remaining
consecutive pairs. It excludes individual gapped pairs, retaining other consecutive
pairs in the same loan, and warns with the excluded and retained counts. At least
one consecutive pair must remain; otherwise estimation raises.
import pandas as pd
from cranalytics.loss_forecasting import estimate_cohort_matrix
history = pd.DataFrame({
"loan_id": ["A", "A", "A", "B", "B"],
"period": ["2024-01", "2024-02", "2024-04", "2024-01", "2024-02"],
"status": ["Current", "Current", "Default", "Current", "Default"],
})
matrix = estimate_cohort_matrix(history, gap_policy="exclude")
assert matrix.loc["Current", "Default"] == 0.5 # Two usable pairs; A's gap excluded.
diagnostics = matrix.attrs["transition_diagnostics"]
assert diagnostics["n_used_transitions"] == 2
assert diagnostics["n_excluded_gap_transitions"] == 1
print(diagnostics["excluded_pairs"])
print(matrix.attrs["transition_counts"])
Calendar dates and monthly Period values use reporting calendar months:
January 31 to February 28/29 is one period. Two dates in the same reporting month
for a loan are duplicates and raise. Unordered rows are sorted without changing
the input. Missing keys or states are rejected. For integer monthly ages, declare
period_kind="month_index"; each increment must mean one month. Convert YYYYMM
codes into calendar dates first. Other observation frequencies are unsupported.
Diagnostics include input observations, loans, last observations with no successor,
candidate/used/excluded pairs, missing months, per-state effective transition
counts, and self_transition_fallback_states. For compatibility, any state with
zero observed outward transitions receives a probability-one self-transition.
This is a sparse-data assumption, not proof of an absorbing state. Review those
states before forecasting, especially if exclusions removed all of their pairs.
Selection into complete histories can bias estimates; exclusion does not correct
that selection. The estimator is account-count-weighted, not balance-weighted.
estimate_cohort_matrix_from_loan_history(..., gap_policy="exclude") provides the
same explicit option after normalization. Direct history inputs to
forecast_lifetime_loss retain the safe default and reject gaps. To use exclusions,
estimate and inspect the matrix first, then pass it to the forecast. DataFrame
attributes do not survive CSV export: export the diagnostics separately if needed.