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.
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 will reject 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. In that case, move up to the simulation or Rollforward workflow rather than stretching this baseline too far.
Run the packaged demo end-to-end with:
python -m cranalytics.examples.core_lifetime_loss