Skip to content

Loss Forecasting API Reference

Loss Forecasting

Use this module for baseline portfolio lifetime-loss estimates from a square transition matrix, a transition ledger, or a loan-history panel. Start with forecast_lifetime_loss() and treat the static-matrix result as a baseline; use portfolio simulation or Rollforward when you need dynamic cashflow behavior.

cranalytics.loss_forecasting

Loss forecasting utilities based on transition matrices.

estimate_cohort_matrix(df: pd.DataFrame, state_col: str = 'status', id_col: str = 'loan_id', period_col: str = 'period', *, period_kind: str = 'calendar', gap_policy: str = 'raise') -> pd.DataFrame

Estimate monthly, account-count-weighted transitions from a loan panel.

period_kind="calendar" accepts dates, monthly strings and monthly Periods. Dates in the same calendar month represent the same reporting period. period_kind="month_index" explicitly declares integer monthly ages; YYYYMM codes must instead be converted to calendar dates by the caller. Loan-period keys and states must be non-null, with one row per loan/month.

Only consecutive months contribute transitions. gap_policy="raise" rejects any gap; "exclude" warns and excludes just the gapped pairs, retaining consecutive pairs elsewhere in the same loan. No state filling or interpolation is performed. At least one usable pair is required.

Returns a square probability DataFrame with rows summing to one. attrs["transition_diagnostics"] reports frequency, observation/pair counts, excluded gap records, missing months, per-state effective counts and states assigned the self-transition fallback. attrs["transition_counts"] contains nonzero source/destination counts. These attributes are not carried through CSV export; inspect or export them separately before forecasting.

States without observed outward transitions retain the existing probability one self-transition assumption. This is a sparse-data fallback, not evidence that the state is economically absorbing. Last observations have no successor and contribute no transition. Rows are not balance-weighted; duplicating rows to simulate balance weighting is invalid under the unique-key contract.

estimate_cohort_matrix_from_loan_history(df: pd.DataFrame, *, state_col: str = 'transition_state', strict: bool = False, gap_policy: str = 'raise') -> pd.DataFrame

Estimate a cohort matrix directly from raw or canonical loan history.

forecast_lifetime_loss(portfolio_df: pd.DataFrame, migration_matrix: pd.DataFrame, lgd: float = 0.5, as_of_date: pd.Timestamp | None = None, amortize: bool = False, cpr: float = 0.0, *, history_state_col: str = 'transition_state', history_strict: bool = False, period_kind: str = 'calendar', gap_policy: str = 'raise') -> float

Estimate total lifetime loss over the remaining contractual term.

Formula: Loss = Exposure at Default (EAD) * Probability of Default (PD) * LGD.

Parameters:

Name Type Description Default
portfolio_df DataFrame

Loan-level portfolio snapshot validated by PortfolioSchema.

required
migration_matrix DataFrame

Square transition matrix, transition ledger, or loan-history panel used to estimate state migration.

required
lgd float

Loss-given-default assumption as a decimal.

0.5
as_of_date Timestamp | None

Forecast date used to calculate remaining contractual term. Defaults to the current date. Every loan must have originated on or before this date; later originations are rejected.

None
amortize bool

Whether to decay exposure using scheduled amortization and CPR. When True, the loss is the charge-offs projected by the same state-balance engine as simulate_portfolio_cashflows, counted within each loan's remaining contractual term. principal is today's outstanding balance; current balances amortize and prepay, while delinquent balances pay nothing until they cure.

False
cpr float

Annual constant prepayment rate used when amortize=True.

0.0
history_state_col str

Canonical state column for loan-history input.

'transition_state'
history_strict bool

Whether loan-history normalization should reject warnings.

False
period_kind str

For a transition ledger, "calendar" (dates or monthly periods) or "month_index" when period holds integer monthly ages. See :func:estimate_cohort_matrix.

'calendar'
gap_policy str

"raise" (default) or "exclude" non-consecutive monthly pairs when estimating from a ledger or loan history.

'raise'

Returns:

Type Description
float

Total estimated lifetime loss in portfolio currency units. Loans whose

float

status is already Charged Off (or legacy Default) or

float

Paid Off contribute nothing: a charged-off loan's loss has been

float

realized, and counting it again would double-count it. They are

float

dropped before statuses are matched to the matrix, so the matrix need

float

not include a Paid Off state.

Raises:

Type Description
ValueError

If statuses do not map to the transition state space or an input assumption is invalid.

Examples:

>>> import pandas as pd
>>> from cranalytics import forecast_lifetime_loss
>>> portfolio = pd.DataFrame({
...     "loan_id": ["L1"],
...     "principal": [1000.0],
...     "annual_rate": [0.10],
...     "term_months": [12],
...     "start_date": [pd.Timestamp("2024-01-01")],
...     "status": ["Current"],
...     "fico_score": [700],
... })
>>> states = ["Current", "Charged Off"]
>>> matrix = pd.DataFrame(
...     [[0.98, 0.02], [0.0, 1.0]], index=states, columns=states
... )
>>> loss = forecast_lifetime_loss(
...     portfolio, matrix, as_of_date=pd.Timestamp("2024-01-01")
... )
>>> bool(loss > 0)
True

.. warning:: Oversimplified Model for Baseline Testing Only This function uses a static transition matrix. You may pass that matrix directly, a transition ledger (loan_id, period, status), or a raw/ canonical loan-history panel (loan_id, fund_date, as_of_date) that is converted to a cohort matrix internally. The resulting forecast still assumes the matrix is static and the current principal balance does not amortize or prepay over the remaining term unless amortize=True is specified. With amortization it runs the cashflow engine behind cranalytics.simulation.simulate_portfolio_cashflows; use that function directly for the month-by-month cashflow path.

summarize_lifetime_loss(portfolio_df: pd.DataFrame, migration_matrix: pd.DataFrame, lgd: float = 0.5, as_of_date: pd.Timestamp | None = None, amortize: bool = False, cpr: float = 0.0, *, history_state_col: str = 'transition_state', history_strict: bool = False, period_kind: str = 'calendar', gap_policy: str = 'raise') -> dict[str, float]

Return a summary report for lifetime loss forecasting.

principal is each loan's current outstanding balance, so total_portfolio_balance counts only loans that still have one: loans already Charged Off (or legacy Default) or Paid Off are left out, just as they add nothing to estimated_lifetime_loss. reserve_ratio is the forecast loss over that current balance. A ratio against original balance would instead keep charged-off loans in the denominator.

Returns:

Type Description
dict[str, float]

total_portfolio_balance, estimated_lifetime_loss,

dict[str, float]

reserve_ratio (0 when there is no outstanding balance) and

dict[str, float]

lgd_assumption.

forecast_portfolio_states(migration_matrix: pd.DataFrame, initial_states: pd.Series, n_periods: int) -> pd.DataFrame

Project future state distributions using a Markov chain.

.. warning:: Static Matrix Assumption This function assumes transition probabilities are static across time (the Markov Property) and identical for all loans regardless of age (Months on Book) or risk factors (FICO). It should be used as a high-level heuristic or baseline test, not as a primary state forecaster.

Reserve Walk

Use reserve_walk() to explain why the forecast lifetime loss changed between two dates, and plot_reserve_walk() from cranalytics.viz to chart it as a waterfall.

cranalytics.reserve

Reserve walk: explain why the forecast lifetime loss moved between two dates.

Two forecast_lifetime_loss numbers say how much the reserve changed, not why. :func:reserve_walk bridges the prior reserve to the current one by moving from the prior state to the current state one input at a time. Each step's amount is the change in total forecast loss that input causes.

The steps run in this fixed order (the order is part of the method):

  1. Runoff: loans live at the prior date that are no longer live: absent from the current portfolio, or now Charged Off / Default / Paid Off. Removed at prior assumptions.
  2. Seasoning: the loans that remain age from the prior to the current date, so their remaining term shrinks. Nothing else changes.
  3. New originations: loans in the current portfolio that were not in the prior one, valued at their current rows under prior assumptions.
  4. Balance change: continuing loans move to their current principal, keeping their prior status.
  5. Credit migration: continuing loans move to their current status (and any other current loan attribute such as rate or term).
  6. Transition matrix: the matrix moves from prior to current.
  7. LGD: loss given default moves from prior to current.
  8. Prepayment and amortization: amortize and cpr move from prior to current.

Later steps are valued after earlier ones, so a change that involves two inputs is credited to the later step. A balance change on a loan that also rolled into delinquency is valued at its old status, and the interaction lands in Credit migration. Reordering the steps changes the attribution but never the total, and every step is present in the output even when it is zero. There is no scenario or overlay step: the forecast has no such input yet.

The walk explains changes in the forward-looking forecast. It is not an allowance rollforward (provision less realized charge-offs plus recoveries).

reserve_walk(prior_portfolio: pd.DataFrame, current_portfolio: pd.DataFrame, prior_assumptions: Mapping[str, Any], current_assumptions: Mapping[str, Any]) -> pd.DataFrame

Attribute the change in forecast lifetime loss between two dates.

Moves from the prior state to the current one in the fixed order described in :mod:cranalytics.reserve (runoff, seasoning, new originations, balance change, credit migration, then the transition matrix, LGD and prepayment/amortization assumptions). Each step is the change in total forecast loss from that one input, so steps later in the order pick up interactions with earlier ones.

Parameters:

Name Type Description Default
prior_portfolio DataFrame

Loan snapshot at the prior date (PortfolioSchema).

required
current_portfolio DataFrame

Loan snapshot at the current date. Loans are matched on loan_id.

required
prior_assumptions Mapping[str, Any]

Mapping with migration_matrix (matrix, transition ledger or loan history, as for forecast_lifetime_loss) and as_of_date, plus optional lgd (0.50), amortize (False) and cpr (0.0).

required
current_assumptions Mapping[str, Any]

Same keys for the current date. Its as_of_date must not be before the prior one.

required

Returns:

Type Description
DataFrame

A DataFrame with columns step, amount and cumulative. The

DataFrame

first row is Prior reserve and the last is Current reserve

DataFrame

(amount equals cumulative). Between them are the eight steps of

DataFrame

data:RESERVE_WALK_STEPS: amount is the step's change and

DataFrame

cumulative the running reserve after it. The step amounts sum to

DataFrame

the current reserve minus the prior one, and the last row equals

DataFrame

forecast_lifetime_loss on the current portfolio.

Raises:

Type Description
ValueError

If an assumptions mapping has unknown or missing keys, an invalid lgd or cpr, the current date precedes the prior one, or a portfolio or matrix is invalid.

Examples:

>>> import pandas as pd
>>> from cranalytics.reserve import reserve_walk
>>> states = ["Current", "Charged Off"]
>>> matrix = pd.DataFrame(
...     [[0.95, 0.05], [0.0, 1.0]], index=states, columns=states
... )
>>> loan = {"annual_rate": 0.1, "term_months": 24, "status": "Current"}
>>> prior = pd.DataFrame([
...     {"loan_id": "A", "principal": 1000.0,
...      "start_date": pd.Timestamp("2024-01-01"), **loan},
... ])
>>> current = prior.assign(principal=800.0)
>>> walk = reserve_walk(
...     prior, current,
...     {"migration_matrix": matrix, "as_of_date": "2024-06-01"},
...     {"migration_matrix": matrix, "as_of_date": "2024-06-01"},
... )
>>> walk[["step", "amount"]].iloc[[0, 4, 9]].round(2).to_dict("records")
[{'step': 'Prior reserve', 'amount': 311.32}, {'step': 'Balance change', 'amount': -62.26}, {'step': 'Current reserve', 'amount': 249.06}]

reserve_walk_by_segment(prior_portfolio: pd.DataFrame, current_portfolio: pd.DataFrame, prior_assumptions: Mapping[str, Any], current_assumptions: Mapping[str, Any], *, by: str) -> pd.DataFrame

The reserve walk split by segment.

Same walk as :func:reserve_walk, with the reserve at each state broken down by segment. A loan counts in the segment its row has at that state, so a loan that changes segment (for example a new FICO band) moves its reserve between segments in Credit migration. Each segment reconciles on its own, and the segments add up to the totals in :func:reserve_walk.

Parameters:

Name Type Description Default
prior_portfolio DataFrame

Loan snapshot at the prior date, as for :func:reserve_walk.

required
current_portfolio DataFrame

Loan snapshot at the current date.

required
prior_assumptions Mapping[str, Any]

Prior-date assumptions, as for :func:reserve_walk.

required
current_assumptions Mapping[str, Any]

Current-date assumptions.

required
by str

"fico_band" (from fico_score unless a fico_band column exists), "vintage" (calendar quarter of start_date), or the name of any column in both portfolios. Missing values become "Unknown".

required

Returns:

Type Description
DataFrame

A DataFrame with columns segment, step, amount and

DataFrame

cumulative: the rows of :func:reserve_walk for each segment, in

DataFrame

sorted segment order.

Raises:

Type Description
ValueError

As for :func:reserve_walk, or if by is not available.