Skip to content

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_id
  • principal
  • annual_rate
  • term_months
  • start_date
  • status
  • fico_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, and status
  • a loan history panel with loan_id, fund_date, and as_of_date

If you pass a square matrix, it should:

  • use the same states on rows and columns
  • include a Charged Off state (or legacy Default)
  • 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 distribution
  • summarize_lifetime_loss(...) returns a compact reserve summary with:
  • total_portfolio_balance
  • estimated_lifetime_loss
  • reserve_ratio
  • lgd_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 though forecast_lifetime_loss(...) and summarize_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.