Source code for ratingmodels.decomposition
r"""Rate-change decomposition (contribution-to-change).
A total rate change factor :math:`F` is the product of identifiable driver
factors. Two attributions are provided:
* **Multiplicative** -- the factors themselves, :math:`F = \prod_i f_i`.
* **Additive (percentage points)** -- a log-share normalization so the parts
sum exactly to the total percentage change:
.. math::
c_i = \frac{\ln f_i}{\ln F}\,(F - 1), \qquad \sum_i c_i = F - 1.
If the supplied factors do not multiply to an independently computed total, a
``residual`` factor is added so the decomposition is exact and the unexplained
movement is explicit rather than hidden.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
import numpy as np
import pandas as pd
from ._utils import product
[docs]
@dataclass
class RateChangeDecomposition:
"""Result of :func:`decompose_rate_change`."""
total_factor: float
factors: pd.Series # multiplicative, incl. any residual
contributions: pd.Series # additive percentage points, sum == total-1
@property
def total_change(self) -> float:
return self.total_factor - 1.0
def to_frame(self) -> pd.DataFrame:
return pd.DataFrame(
{"factor": self.factors, "pct_point_contribution": self.contributions}
)
def __repr__(self) -> str: # pragma: no cover - cosmetic
return (
f"RateChangeDecomposition(total_change={self.total_change:+.4%}, "
f"drivers={list(self.factors.index)})"
)
[docs]
def decompose_rate_change(
factors: Mapping[str, float],
total_factor: float | None = None,
) -> RateChangeDecomposition:
r"""Attribute a rate change to multiplicative drivers.
Parameters
----------
factors : mapping
Named driver factors (e.g. ``{"trend": 1.075, "experience": 0.96,
"benefit": 1.02, "demographic": 1.01}``). Each must be positive.
total_factor : float, optional
Independently computed total change factor (indicated / current). If
given and it differs from the product of ``factors``, a ``residual``
factor is appended so the decomposition reconciles exactly. If omitted,
the total is taken to be the product of the supplied factors.
"""
names = list(factors.keys())
vals = np.array([float(v) for v in factors.values()], dtype=float)
if np.any(vals <= 0):
raise ValueError("all factors must be positive")
fac = dict(zip(names, vals, strict=True))
prod = product(vals)
if total_factor is None:
total = prod
else:
total = float(total_factor)
if total <= 0:
raise ValueError("total_factor must be positive")
residual = total / prod
if not np.isclose(residual, 1.0, rtol=1e-9, atol=1e-12):
fac["residual"] = residual
fseries = pd.Series(fac, name="factor")
# additive log-share attribution; handle the F == 1 limit gracefully
log_total = np.log(total)
if abs(log_total) < 1e-12:
contrib = pd.Series(0.0, index=fseries.index, name="pct_point_contribution")
else:
shares = np.log(fseries.to_numpy()) / log_total
contrib = pd.Series(
shares * (total - 1.0),
index=fseries.index,
name="pct_point_contribution",
)
return RateChangeDecomposition(
total_factor=total, factors=fseries, contributions=contrib
)