Source code for projectionmodels.book

"""
Book projection -- aggregate group projections into the book budget.
====================================================================
Rolls up in-force renewals and new business into total premium, claims, and loss
ratio (by group and by month). Totals use the EXPECTED (renewal-weighted) figures,
so a group contributes in proportion to how likely it is to renew.
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd

from .group import GroupProjection


@dataclass(frozen=True)
class BookResult:
    premium: float                      # expected (renewal-weighted) book premium
    claims: float
    loss_ratio: float
    by_group: pd.DataFrame              # per-group expected premium/claims/LR/renewal
    monthly: pd.DataFrame               # expected premium/claims by month, summed over the book


[docs] class BookProjection: """Roll a set of group projections up into the book: totals, per-group, monthly. Accepts :class:`GroupProjection` objects (or their results) and aggregates the *expected* -- renewal-probability-weighted -- premium and claims, so a group contributes in proportion to how likely it is to renew. Exposes ``premium`` / ``claims`` / ``loss_ratio`` (book totals), ``by_group`` (one row per group with its expected figures and renewal probability), and ``monthly`` (expected premium and claims by projection month, summed over the book); the assembled :class:`BookResult` sits on ``result``. All inputs must share one projection horizon -- the monthly frames are summed elementwise. Parameters ---------- projections : sequence of GroupProjection or GroupProjectionResult The in-force renewals and new business to aggregate. labels : sequence of str, optional Group labels for ``by_group`` (default ``grp_0``, ``grp_1``, ...); must match ``projections`` in length. """ def __init__(self, projections, labels=None): results = [p.result if isinstance(p, GroupProjection) else p for p in projections] if not results: raise ValueError("no projections supplied") labels = list(labels) if labels is not None else [f"grp_{i}" for i in range(len(results))] rows = [] prem_m = np.zeros(len(results[0].monthly)) clm_m = np.zeros_like(prem_m) months = results[0].monthly["month"].to_numpy() for lab, r in zip(labels, results, strict=True): rows.append({"group": lab, "premium": r.expected_premium, "claims": r.expected_claims, "loss_ratio": (r.expected_claims / r.expected_premium if r.expected_premium else np.nan), "renewal_prob": r.renewal_prob}) prem_m += r.monthly["premium"].to_numpy() * r.renewal_prob clm_m += r.monthly["claims"].to_numpy() * r.renewal_prob by_group = pd.DataFrame(rows) monthly = pd.DataFrame({"month": months, "premium": prem_m, "claims": clm_m}) monthly["loss_ratio"] = monthly["claims"] / monthly["premium"] P, C = float(by_group["premium"].sum()), float(by_group["claims"].sum()) self.result = BookResult(premium=P, claims=C, loss_ratio=C / P, by_group=by_group, monthly=monthly) @property def premium(self): return self.result.premium @property def claims(self): return self.result.claims @property def loss_ratio(self): return self.result.loss_ratio @property def by_group(self): return self.result.by_group @property def monthly(self): return self.result.monthly
def project_book(projections, labels=None) -> BookResult: """Functional form: returns the :class:`BookResult`.""" return BookProjection(projections, labels=labels).result