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
|
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 |
False
|
cpr
|
float
|
Annual constant prepayment rate used when |
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'
|
gap_policy
|
str
|
|
'raise'
|
Returns:
| Type | Description |
|---|---|
float
|
Total estimated lifetime loss in portfolio currency units. Loans whose |
float
|
status is already |
float
|
|
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 |
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]
|
|
dict[str, float]
|
|
dict[str, float]
|
|
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):
- 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. - Seasoning: the loans that remain age from the prior to the current date, so their remaining term shrinks. Nothing else changes.
- New originations: loans in the current portfolio that were not in the prior one, valued at their current rows under prior assumptions.
- Balance change: continuing loans move to their current
principal, keeping their prior status. - Credit migration: continuing loans move to their current status (and any other current loan attribute such as rate or term).
- Transition matrix: the matrix moves from prior to current.
- LGD: loss given default moves from prior to current.
- Prepayment and amortization:
amortizeandcprmove 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 ( |
required |
current_portfolio
|
DataFrame
|
Loan snapshot at the current date. Loans are matched
on |
required |
prior_assumptions
|
Mapping[str, Any]
|
Mapping with |
required |
current_assumptions
|
Mapping[str, Any]
|
Same keys for the current date. Its |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
A DataFrame with columns |
DataFrame
|
first row is |
DataFrame
|
(amount equals cumulative). Between them are the eight steps of |
DataFrame
|
data: |
DataFrame
|
|
DataFrame
|
the current reserve minus the prior one, and the last row equals |
DataFrame
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If an assumptions mapping has unknown or missing keys, an
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: |
required |
current_portfolio
|
DataFrame
|
Loan snapshot at the current date. |
required |
prior_assumptions
|
Mapping[str, Any]
|
Prior-date assumptions, as for :func: |
required |
current_assumptions
|
Mapping[str, Any]
|
Current-date assumptions. |
required |
by
|
str
|
|
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
A DataFrame with columns |
DataFrame
|
|
DataFrame
|
sorted segment order. |
Raises:
| Type | Description |
|---|---|
ValueError
|
As for :func: |