ratingmodels

The pricing layer of the ecosystem: manual and experience rate construction, credibility blending, rate indication and rate-change decomposition, GLM relativity estimation with diagnostics, frequency–severity models, credibility-smoothed factors, out-of-sample validation (splits, calibration, actual-to-expected, Gini and lift), renewal constraints, and rate-dislocation reporting — an auditable path from base rate to filed rate, validated along the way. Depends on actuarialpy for its credibility and trend primitives and on statsmodels for GLM estimation. Everything is vectorized under one contract: the same call that rates one group rates a whole book of columns.

Quickstart

Blend an experience rate with a manual rate and read the indicated change:

import ratingmodels as rm

z = rm.limited_fluctuation_credibility(n=96_000, n_full=120_000)

manual = rm.ManualRate(base_loss_cost=480, factors={"area": 1.05, "industry": 0.97})

indication = rm.RateIndication(
    experience_loss_cost=512,
    manual_loss_cost=manual.loss_cost(),
    credibility=z,
    current_rate=560,
    target_loss_ratio=0.85,
)

indication.indicated_rate_change()        # blended, credibility-weighted change
indication.rate_change_decomposition()    # attribute the change to each driver

Columns in, columns out

Every numeric argument accepts a scalar or a column, under one contract (the vectorization convention): scalar in, float out — exactly the call above; Series in, Series out, elementwise, index preserved, scalars broadcasting. So the quickstart is the book-level code — swap floats for columns:

import pandas as pd
import ratingmodels as rm

book = pd.DataFrame(
    {"n": [820.0, 1450.0, 260.0],
     "base": [420.0, 435.0, 410.0],
     "area": [1.05, 0.98, 1.12],
     "exp_lc": [506.5, 499.2, 494.7],
     "current": [545.0, 560.0, 530.0]},
    index=pd.Index(["G1", "G2", "G3"], name="group"),
)

z = rm.limited_fluctuation_credibility(book["n"], n_full=1_082)
manual = rm.ManualRate(book["base"], factors={"area": book["area"]})

indication = rm.RateIndication(
    experience_loss_cost=book["exp_lc"],
    manual_loss_cost=manual.loss_cost(),
    credibility=z,
    current_rate=book["current"],
)

book["change"] = indication.indicated_rate_change()   # one change per row
rm.renew(book["current"], indication.indicated_rate(),
         cap=0.10, floor=0.0).to_frame()               # tidy renewal actions

For this workflow end to end on a three-group book — pooling to book-level uplift — see Example 2: pricing a book, in columns.

Validation stays row-level — one bad row fails the call and the error names the offending index label — and helpers that reduce across inputs (product, the build-up engine, blend, trend) raise on mismatched Series indexes rather than silently aligning to NaN. Aggregations grow a by= for the grouped question: base_rate_from_experience(..., by="segment") returns a DataFrame of base rates (one per segment), and pool_claims(amounts, point, by=groups) pools a whole claim file in one pass.

The build-up engine

Rate build-ups are a sequence of typed steps — start, add, multiply, checkpoint — evaluated into a result that carries the full audit trail:

import ratingmodels as rm

result = rm.evaluate([
    rm.start("Par base claim cost", 941.63),
    rm.add("$30 specialist copay", -11.44),
    rm.multiply("Rating region", 1.083),
    rm.checkpoint("Net claim cost"),
])

result.value        # final per-unit value
result.to_frame()   # every step as a DataFrame — inputs, factors, running total

Because each step is explicit, the build-up is reproducible and reviewable: the same object renders the number and the audit trail behind it. Operands take columns too — per-group bases, factors, even segment_multiply weights and participation shares — and the breakdown switches to tidy long format, one row per (step, entity), with value and every checkpoint returned as a Series on the shared index.

GLM relativities

GLMRelativities estimates rating factors jointly — correcting for the correlation between rating variables that one-way analysis cannot — with a log-link GLM. Estimation is delegated to statsmodels.GLM: a mature estimator owns the solver, convergence, covariance, and the fitted null model, while ratingmodels owns the actuarial layer — the design encoding and base-level semantics, coefficient-to-relativity conversion, prediction with unseen-level fallback, and the exhibits. Poisson, gamma, and Tweedie variance functions; exposure as a log offset for aggregate responses; prior weights; categorical predictors (base level = most populous, or set explicitly) and continuous covariates in the same linear predictor:

import ratingmodels as rm

model = rm.GLMRelativities(family="poisson").fit(
    df,
    response="claims",
    predictors=["area", "industry"],     # categorical -> relativities
    continuous=["age"],                  # numeric, enters the predictor directly
    exposure="member_months",            # log offset
    base_levels={"area": "A"},
)

model.relativities_["area"]     # multiplicative factors, base level = 1.0
model.base_value_               # exp(intercept): the base rate
model.summary()                 # coef, SE, z, relativity per term
model.predict(new_df, exposure="member_months")
model.to_factor_tables()        # {"area": FactorTable, ...} for the build-up
model.results_                  # the fitted statsmodels results object

Standard errors use the Pearson-estimated dispersion (quasi-likelihood — the robust default for pricing data, where overdispersion is the norm); dispersion_, null_deviance_, deviance_explained_, and a converged_ flag are exposed alongside. Unseen levels at prediction time fall back to the base level, and to_factor_tables() turns the fitted relativities into named FactorTable lookups with the same unknown-level behavior — the bridge from estimation into the build-up and renewal machinery. Anything the wrapper does not surface is one attribute away: results_ is the fitted statsmodels object (results_.get_influence(), results_.get_prediction(...), Wald tests, …).

Aggregate vs. rate responses

Exposure enters this model in one of two ways, and the difference matters for everything except Poisson. When the response is an aggregate — claim counts or total amounts — exposure is a log offset: E[Y] = e·exp(Xβ), so pass exposure="member_months". When the response is already a rate — pure premium, loss per unit, anything divided by exposure — do not pass exposure; pass it as weights instead, so the variance scales as V(μ)/e. The two parameterizations coincide only at variance power p = 1; for gamma and Tweedie, the weights form is the one consistent with a response averaged over e independent claims. It is exactly how the severity component of the frequency–severity model is fit: response amount/count, weights count. weights are variance weights throughout (statsmodels var_weights): the variance of row i is φ·V(μᵢ)/wᵢ.

Diagnostics

A model that produces relativities but cannot be interrogated is half a model. Every fitted GLM exposes its residuals and the uncertainty of every factor:

model.relativity_table(confidence_level=0.95)
# (variable, level) -> coef, se, relativity, ci_low, ci_high, is_base

model.residuals(df, kind="deviance")       # index-aligned Series
model.residuals(df, kind="pearson")        # squares sum to pearson_chi2_
model.residuals(df, kind="standardized")   # leverage-adjusted, ~N(0,1) scale

The relativity intervals are exp(coef ± z·se) on the quasi-likelihood standard errors — the base level is shown at 1.0 with no interval (it is the reference, fixed by construction, not estimated), and continuous covariates appear as per-unit factors. Column names for residuals default to those used at fit, so model.residuals(validation_frame) just works; plotting deviance or standardized residuals against fitted values and against each rating variable is the standard check that the variance function and link are adequate.

The adapter is held to a contract: the test suite fits statsmodels independently — its own family objects, offset construction, and weights, on the exact design matrix GLMRelativities built — and asserts the marshaling conventions and the in-package evaluation math (residuals, relativity intervals, family deviance) agree across every family.

There is deliberately no penalized (ridge/lasso) fit: shrinkage would invalidate exactly this covariance machinery. When thin levels need stabilizing, use credibility smoothing — the actuarial answer, with the uncertainty story intact. Should regularization at scale ever become a genuine requirement, glum is the designated engine for that job, behind this same API.

Interactions

When the effect of one variable depends on the level of another — urban manufacturing is worse than urban or manufacturing suggests — add the pair to the design. Categorical × categorical uses treatment coding (an indicator per observed non-base × non-base cell, so main effects keep their interpretation and unobserved cells cannot alias the design); categorical × continuous fits one slope modifier per non-base level:

model = rm.GLMRelativities(family="poisson").fit(
    df, response="claims", predictors=["area", "industry"],
    exposure="member_months",
    interactions=[("area", "industry")],
)

model.relativities_["area:industry"]   # MultiIndex (area, industry) -> factor
model.relativity_table()               # adds ("area:industry", "B | mfg") rows

The interaction factor multiplies on top of both main effects. to_factor_tables() deliberately excludes interactions — a FactorTable is single-variable by contract; read cells from relativities_["a:b"].

Prediction intervals

predict_interval puts delta-method confidence bounds on the fitted mean for any frame — the uncertainty of the rate the model assigns to a cell, from the quasi-likelihood coefficient covariance (it matches results_.get_prediction to numerical precision):

model.predict_interval(new_business, exposure="member_months")
# predicted | ci_low | ci_high        (index-aligned with the input)

This is an interval for the mean, not for individual outcomes — a single group’s claims vary far more than its expected claims. For outcome distributions, simulate frequency and severity instead.

Frequency–severity models

The standard pricing decomposition — loss_per_exposure = frequency × severity, the ecosystem convention — fit as two log-link GLMs and composed into one pure-premium model:

model = rm.FrequencySeverityModel().fit(
    df,
    claim_count="claim_count",
    claim_amount="claim_amount",
    exposure="exposure",
    frequency_predictors=["area", "industry", "tier"],
    severity_predictors=["industry", "tier"],   # severity thins out fast
)

model.frequency_prediction(df, exposure="exposure")   # expected counts
model.severity_prediction(df)                         # expected cost per claim
model.pure_premium_prediction(df, exposure="exposure")  # exactly their product

model.combined_relativities()["industry"]
# level -> frequency | severity | combined  (combined = product)
model.base_value_        # pure premium per exposure unit at base levels
model.to_factor_tables() # combined relativities as FactorTable lookups

Frequency (Poisson by default) fits on every record with exposure as a log offset; severity (Gamma by default) fits only on records with claims, weighted by claim count — the average of k claims carries k claims’ worth of information. Because both links are logs, the pure-premium relativity of a level is the product of its frequency and severity relativities, and fitting the pieces separately shows why a level is expensive — more claims, larger claims, or both — which a single Tweedie fit cannot. Variables used by only one component pass through with the other’s factor at 1.0. Each component is a full GLMRelativities, so every diagnostic above (relativity_table, residuals, summary) applies per part.

Rows with positive amounts but zero counts raise (severity is undefined there); claims closed at zero amount are excluded from the severity fit with a warning and still count toward frequency.

predict_interval exists here too – component log-scale variances add under the stated independence of the two fits, and predicted equals pure_premium_prediction exactly – so moving from a GLM to a frequency-severity model keeps its error bars.

Both components accept interaction terms (frequency_interactions=[("area", "industry")]; severity defaults to the frequency list). Categorical × categorical cells surface in combined_relativities() under an "a:b" key with a MultiIndex of level pairs, combined being the per-cell frequency × severity product; to_factor_tables() excludes interactions, exactly as the GLM does.

Credibility-smoothed relativities

Sparse levels produce unstable one-way relativities. The classical actuarial answer is neither dropping them nor generic regularization but credibility: shrink each level toward a complement, in proportion to the evidence behind it —

rm.credibility_relativities(
    df, factor="industry", response="claims", exposure="exposure",
    method="buhlmann",          # Z estimated by empirical Bühlmann–Straub
    prior=1.0,                  # or a mapping of current filed factors
)
# level -> n | exposure | response | observed | credibility | prior | relativity

relativity = Z·observed + (1−Z)·prior, per level. With method="buhlmann" (the default), Z comes from the empirical Bühlmann–Straub estimators across levels — the credibility math lives in actuarialpy, as everywhere in the ecosystem. With method="limited_fluctuation", the square-root rule Z = min(1, √(n/full_credibility)) applies against a full-credibility standard in response units (for claim counts, full_credibility_standard). A scalar prior of 1.0 shrinks toward “no effect”; passing the current filed factors as the prior shrinks toward the existing plan instead.

The blunt companion for levels too thin to carry a column at all:

recoded, summary = rm.collapse_sparse_levels(
    df["industry"], exposure=df["exposure"], min_exposure=1_000,
)
df["industry_grouped"] = recoded    # thin levels -> "Other"

summary records which levels collapsed, so the same recode applies to future data.

Validation

A pricing model should be judged on data it did not see, and the shape of the held-out data matters: rows of the same group are correlated, and the deployed model always predicts forward in time. The splits encode both facts, with no scikit-learn dependency:

train, valid = rm.temporal_split(df, date="experience_month", cutoff="2025-01-01")
train, valid = rm.group_split(df, group="group_id", test_fraction=0.25,
                              weights="exposure", random_state=0)
train, valid = rm.random_split(df, test_fraction=0.25, random_state=0)

group_split keeps every group whole on one side (scattering a group’s rows across train and test leaks its risk level into validation); temporal_split cuts at a date for the honest out-of-time test. Each returns (train, test) with row order preserved, and raises rather than silently returning an empty side.

Ordering, level, and segments

A rating plan is judged on segmentation — how well predictions order risks — and on calibration — whether they are right on the level. The four exhibits, all exposure-weighted:

pred = model.predict(valid, exposure="exposure")

rm.gini_coefficient(valid["claims"], pred, exposure=valid["exposure"])

rm.lift_table(valid["claims"], pred, exposure=valid["exposure"], n_bands=10)
# band | n | exposure | predicted_mean | actual_mean | lift

rm.calibration_table(valid["claims"], pred, exposure=valid["exposure"])
# band | n | exposure | predicted_mean | actual_mean | ae_ratio

rm.actual_expected_table(valid["claims"], pred, exposure=valid["exposure"],
                         by={"area": valid["area"], "tier": valid["tier"]})
# (variable, level) | n | exposure | actual | expected | means | ae_ratio

A model that segments shows lift rising monotonically across bands; the Gini summarizes the same ordering in one number, comparable across books. A model that is calibrated shows ae_ratio near 1.0 in every calibration band — systematic drift is the signature of over-shrunk predictions — and in every segment of the A/E exhibit, which takes one variable, several at once (tidy (variable, level) output), or none for the overall row. gini_coefficient, lift_table, and calibration_table all take by= group labels to score every segment of a validation frame in one call.

Comparing candidates

compare_models scores fitted GLMs side by side on one frame — pass the validation split for an honest comparison:

rm.compare_models({"full": full_model, "no_industry": smaller_model},
                  valid, response="claims", exposure="exposure")
# family | n_params | converged | dispersion | deviance | null_deviance
#   | deviance_explained | gini | ae_ratio | calibration_error

Deviance is family-specific (comparable within a family); gini, ae_ratio, and calibration_error compare across families. No AIC is reported — the standard errors are quasi-likelihood, so a true likelihood is not available.

Rate indications

RateIndication blends experience against a manual, grosses up through RetentionLoad, and reads off the indicated rate and change — but it consumes point inputs: a trended, developed loss cost and an on-level premium. ExperienceExhibit is where those come from, with every adjustment a visible worksheet column:

ex = rm.ExperienceExhibit(
    earned_premium=[1_000_000, 1_100_000],
    losses=[700_000, 650_000],
    on_level_factors=olf["on_level_factor"],      # from on_level_factors
    development_factors=proj["development_factor"],  # from ChainLadder
    trend_factors=[1.05, 1.02],
    period_labels=["CY2023", "CY2024"],
)
ex.exhibit()   # premium | OLF | on-level premium | losses | dev | trend
#              #   | adjusted_losses | loss_ratio | weight

ind = ex.to_indication(manual_loss_cost=70.0, credibility=0.6,
                       current_rate=90.0, exposure=24_000, retention=ret)
ind.indicated_rate_change()

The wiring is exact by construction: the indication’s own experience_loss_ratio() reproduces the exhibit’s aggregate ratio, and at full credibility the indicated rate is retention.gross_rate(...) of the assembled loss cost — the same expense algebra as the build-up and PricingEvaluation, not a second implementation that can drift.

Rating plans

A fitted model is not yet a plan. RatingPlan is the implemented object — a base rate plus a FactorTable per rating variable — that rates a census with the full build-up visible, audits its own coverage, and round-trips through a dict for filing and version control:

plan = rm.RatingPlan.from_model(model)        # factors + base_value_
plan.validate(census)                          # levels the plan cannot rate
rated = plan.rate(census, exposure="members")  # base_rate | {var}_factor ...
#   | combined_relativity | rate | premium

plan.rate(census, unknown="error")     # unmapped level -> hard stop, not 1.0
plan.average_relativity(census, exposure="members")   # off-balance check
rebuilt = rm.RatingPlan.from_dict(plan.to_dict())     # schema-versioned

plan.rate(...)["premium"] reproduces model.predict(...) exactly when the plan came from from_model — the plan is the model, restated as tables.

Comparing the plan you have against the plan you propose is a first-class operation:

comp = rm.compare_rating_plans(current, proposed, census, exposure="members")
comp.summary()        # premiums, avg change, share increasing/decreasing
comp.dislocation()    # the banded exhibit (next section)
comp.by(census["region"])   # who absorbs the move

Rate dislocation

An average rate change hides everything operational — who takes a large increase, how much premium sits in each band, and what the constraints cost. Band the book by rate change:

rm.rate_dislocation(
    current_rate=df["current_rate"],
    proposed_rate=df["proposed_rate"],
    exposure=df["exposure"],
    bands=[-0.10, -0.05, 0.0, 0.05, 0.10],
)
# band          | n | exposure | current_premium | proposed_premium
#   | avg_change | exposure_share        (+ an "All" total row)

Bands are (low, high] with empty bands kept, so the exhibit shape is stable across runs; because the default edges include 0.0, increases and decreases are always separated. And quantify the gap between the indication and what was actually proposed — what capping left on the table, and the rate action still owed:

rm.constraint_impact(
    indicated_rate=df["indicated"],
    proposed_rate=df["issued"],
    exposure=df["exposure"],
    current_rate=df["current_rate"],
    by=df["segment"],           # which segments absorbed the capping
)
# premium_shortfall | premium_excess | n_below/above | exposure_below/above
#   | indicated_change | realized_change | remaining_change

Both are pure comparisons of rate vectors, so any source of “current” and “proposed” works — a renewal run (renew), a re-rated plan, or scenario output.

On-level factors

Historical premium was earned at historical rates; the indication needs it at today’s. on_level_factors is the parallelogram method computed in closed form — the earned rate index is a piecewise-linear function of time and is integrated exactly, so the classic textbook case (+10% mid-year, annual policies, calendar-year period) reproduces 1.1 / 1.0125 to machine precision rather than to grid resolution:

rm.on_level_factors(
    periods=[("2023-01-01", "2023-12-31"), ("2024-01-01", "2024-12-31")],
    rate_changes=[("2023-07-01", 0.08), ("2024-04-01", 0.05)],
    policy_term=1.0,          # 0 = instant earning; 1.0 = annual parallelogram
)
# period_start | period_end | average_earned_index | current_index
#   | on_level_factor

Pooling charges from a severity model

experience_rate takes pooling_charge as an input; this is where it comes from. Any severity object exposing the two-method tail protocol — sf(x) and mean_excess(d) — prices the excess layer above a pooling point, returned as an auditable build-up:

charge = rm.pooling_charge_from_severity(
    severity, pooling_point=250_000, expected_frequency=0.7,
    expense_ratio=0.08, risk_margin=0.05,
)
# exceedance_probability | mean_excess | expected_excess_per_claim
#   | pure_excess_cost | pooling_charge

The protocol is duck-typed and deliberately tiny: lossmodels distributions (and layers) satisfy it, extremeloss GPD tail fits satisfy it with their closed-form mean excess, and any custom object with the two methods qualifies — no cross-package dependency in either direction.

Pricing scenarios and margin

The indication answers what does the formula say; management pricing asks what margin falls out at the action actually issued, after concessions, at plan — and what action produces zero or a target margin. PricingEvaluation evaluates a case at any rate action with the same expense algebra as the gross-up, so at the indicated rate the margin ratio equals the retention’s profit_margin exactly:

import ratingmodels as rm

ret = rm.RetentionLoad(fixed_expense=8, variable_expense_ratio=0.10,
                       profit_margin=0.03, lae_ratio=0.02)
case = rm.PricingEvaluation(loss_cost=410, current_rate=470, retention=ret,
                            exposure=14_400, persistency=0.85)

case.at(0.062, name="issued")        # premium, gross margin, margin, ratio
case.rate_change_for_margin(0.03)    # closed form: P(m) = (L(1+lae)+F)/(1-V-m)
case.zero_margin_rate_change()       # the m = 0 special case

Evaluate named actions across a book into one tidy long table — cohort rollups and key-case exhibits are then pivots of library output — and solve the exhibit input “actions must be X% higher to hold the target margin” in closed form:

tidy = rm.scenario_frame(book, {"formula": formula_actions,
                                "issued": issued_actions, "plan": 0.118})
tidy.pivot(index="case", columns="scenario", values="margin_ratio")

rm.uplift_for_target_margin(book, issued_actions, target_margin=0.03)

book is either a mapping of scalar evaluations or a single vector PricingEvaluation built from columns — loss costs, current rates, exposures, persistencies as Series — in which case at() evaluates every case at once, ScenarioOutcome.to_frame() is one tidy row per case, and actions may be per-case Series. The uplift solve is the same closed form either way; the two paths agree to floating point.

Scenario names are your vocabulary — the library evaluates actions and reports margin; what “issued” or a concession budget means stays with the caller. Margin definitions are shared ecosystem-wide; see conventions.

API reference

ratingmodels – actuarial pricing and rate-indication tools.

A small, dependency-light toolkit for the group rating workflow: credibility, trend, manual and experience rate construction, credibility blending, rate indication, rate-change decomposition, GLM relativity estimation, and renewal constraints. Part of the OpenActuarial ecosystem.

Quick start:

import ratingmodels as rm

exp = rm.ExperienceRate(
    incurred_claims=4_200_000, exposure=9_600,
    trend_annual=0.075, trend_years=1.5,
    pooled_excess=350_000, pooling_charge=4.0,
    target_loss_ratio=0.85,
)
man = rm.ManualRate(base_loss_cost=480, factors={"area": 1.05, "industry": 0.97})
z = rm.limited_fluctuation_credibility(n=9_600, n_full=12_000)
ind = rm.RateIndication(
    experience_loss_cost=exp.loss_cost(),
    manual_loss_cost=man.loss_cost(),
    credibility=z, current_rate=520, target_loss_ratio=0.85,
    trend_total_factor=exp.trend_factor(),
)
round(ind.indicated_rate_change(), 4)
full_credibility_standard(p: float = 0.90, k: float = 0.05, cv_severity: float | None = None) float[source]

Expected claim count required for full credibility.

Delegates to actuarialpy.full_credibility_claims(). Returns \((z_{(1+p)/2}/k)^2\), inflated by \(1 + \mathrm{cv}^2\) when cv_severity is supplied (aggregate losses rather than pure frequency).

>>> round(full_credibility_standard(0.90, 0.05))
1082
limited_fluctuation_credibility(n: float | int | ndarray | Series | Sequence[float], n_full: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Partial credibility by the square-root rule, min(1, sqrt(n / n_full)).

Delegates to actuarialpy.limited_fluctuation_z(). n and n_full are in consistent units (claims, policies, exposure units, …). Elementwise: a Series of n returns a Series of Z.

buhlmann_credibility(exposure: float | int | ndarray | Series | Sequence[float], epv: float | int | ndarray | Series | Sequence[float], vhm: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Bühlmann credibility factor \(Z = n / (n + k)\), k = EPV/VHM.

This is the credibility factor given structural parameters; the greatest-accuracy estimators (fitting EPV/VHM from data) live in actuarialpy.Buhlmann / actuarialpy.BuhlmannStraub. Elementwise: a Series of exposures returns a Series of Z.

buhlmann_straub(data: DataFrame, group: str, period: str, value: str, exposure: str) BuhlmannStraubResult[source]

Empirical Bühlmann-Straub credibility from grouped exposure data.

Thin wrapper over actuarialpy.BuhlmannStraub.from_frame() (the general unbiased estimators) that returns a BuhlmannStraubResult with per-group credibility and credibility-weighted means.

Parameters:
  • data (DataFrame) – Long-format data: one row per (group, period).

  • group (str) – Column names. value is the per-unit observation (e.g. loss per member-month); exposure is the weight \(m_{ij}\).

  • period (str) – Column names. value is the per-unit observation (e.g. loss per member-month); exposure is the weight \(m_{ij}\).

  • value (str) – Column names. value is the per-unit observation (e.g. loss per member-month); exposure is the weight \(m_{ij}\).

  • exposure (str) – Column names. value is the per-unit observation (e.g. loss per member-month); exposure is the weight \(m_{ij}\).

class BuhlmannStraubResult(k: float, epv: float, vhm: float, overall_mean: float, group_means: Series, credibility: Series, credibility_weighted: Series)[source]

Bases: object

Result of an empirical Bühlmann-Straub fit, keyed by group.

trend_factor(annual_trend: float | int | ndarray | Series | Sequence[float], years: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

\((1 + \text{annual\_trend})^{\text{years}}\), elementwise.

trend_factor_between(annual_trend: float | int | ndarray | Series | Sequence[float], experience_period: tuple[str | date | datetime | Series, str | date | datetime | Series], rating_period: tuple[str | date | datetime | Series, str | date | datetime | Series]) float | int | ndarray | Series | Sequence[float][source]

Midpoint-to-midpoint trend factor from two date ranges.

apply_trend(value: float | int | ndarray | Series | Sequence[float], annual_trend: float | int | ndarray | Series | Sequence[float], years: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Trend a value forward (or back, for negative years), elementwise.

combine_trend(frequency_trend: float | int | ndarray | Series | Sequence[float], severity_trend: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Combine frequency and severity trends: \((1+t_f)(1+t_s)-1\).

split_total_trend(total_trend: float | int | ndarray | Series | Sequence[float], frequency_trend: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Back out the severity trend implied by a total and a frequency trend.

period_midpoint(start: str | date | datetime | Series, end: str | date | datetime | Series)[source]

Midpoint date of a period [start, end] (inclusive endpoints).

Vectorized over datetime-like Series/arrays (returns Timestamps).

years_between(start: str | date | datetime | Series, end: str | date | datetime | Series) float | int | ndarray | Series | Sequence[float][source]

Fractional years between two dates using a 365.25-day year.

Accepts scalar dates (returns float) or datetime-like Series/arrays (returns a Series/array of year gaps, elementwise; scalars broadcast).

class FactorTable(name: str, factors: Mapping, default: float = 1.0)[source]

Bases: object

A named lookup of level -> multiplicative relativity.

Parameters:
  • name (str) – Rating variable name (e.g. "area").

  • factors (mapping) – Level -> relativity. The base level should map to 1.0 by convention.

  • default (float) – Relativity returned for unknown levels. Default 1.0.

apply(levels: Sequence) ndarray | Series[source]

Vectorized lookup: relativity for every element of levels.

A Series in gives a Series out on the same index (unknown levels get default); any other sequence gives a numpy array.

normalized(base_level) FactorTable[source]

Rebase so base_level has relativity 1.0.

one_way_relativities(data: DataFrame, factor: str, response: str, exposure: str | None = None, base_level=None) Series[source]

One-way relativities: each level’s (exposure-weighted) mean / overall mean.

Does not adjust for correlation with other rating variables; use GLMRelativities when variables are correlated.

class GLMRelativities(family: str = 'poisson', var_power: float | None = None, max_iter: int = 100, tol: float = 1e-08)[source]

Bases: object

GLM (log-link) relativity estimator, fit via statsmodels.

Parameters:
  • family ({"poisson", "gamma", "tweedie"}) – Response distribution. "tweedie" requires var_power in (1, 2).

  • var_power (float, optional) – Tweedie variance power \(p\) in \(V(\mu)=\mu^p\).

  • max_iter (int) – Maximum solver iterations (passed to statsmodels).

  • tol (float) – Solver convergence tolerance (passed to statsmodels).

coefficients_

Fitted \(\beta\) including the intercept.

Type:

pandas.Series

relativities_

Per-variable multiplicative relativities (base level = 1.0).

Type:

dict[str, pandas.Series]

base_value_

\(\exp(\text{intercept})\), the fitted base level.

Type:

float

results_

The underlying fitted results object – the common actuarial outputs live on this class, but nothing statistical is walled off.

Type:

statsmodels GLMResults

n_iter_

Solver iterations used.

Type:

int

deviance_

Final deviance. These attributes are populated by fit().

Type:

float

property deviance_explained_: float

Proportion of null deviance explained, 1 - deviance/null_deviance.

The GLM analogue of \(R^2\): 0 means the predictors add nothing over the intercept(+offset)-only model, 1 means a saturated fit.

fit(data: DataFrame, response: str, predictors: Sequence[str], exposure: str | None = None, offset: str | None = None, weights: str | None = None, base_levels: Mapping[str, object] | None = None, continuous: Sequence[str] = (), interactions: Sequence[tuple] = ()) GLMRelativities[source]

Fit relativities for predictors against response.

Aggregate vs. rate responses. exposure enters as a log offset, which is correct when the response is an aggregate – claim counts or total amounts, \(E[Y] = e\,\exp(X\beta)\). When the response is already a rate (divided by exposure: pure premium, loss per unit), do not pass exposure; pass it as weights instead, so the variance scales as \(V(\mu)/e\). The two parameterizations coincide only for Poisson (\(p=1\)); for Gamma and Tweedie the weights form is the one consistent with a response averaged over \(e\) independent claims (it is exactly how the severity model inside FrequencySeverityModel is fit).

An explicit offset column (already on the log scale) may also be supplied. weights are variance weights (statsmodels var_weights): the variance of row \(i\) is \(\phi V(\mu_i)/w_i\). base_levels maps a predictor to its reference level (relativity 1.0); unspecified predictors use their most populous level as the base.

predict(data: DataFrame, exposure: str | None = None, offset: str | None = None) ndarray[source]

Predicted mean for new rows.

Categorical levels unseen in fitting fall back to the base level (relativity 1.0). exposure multiplies the mean; offset is a column already on the log scale.

predict_interval(data: DataFrame, confidence_level: float = 0.95, exposure: str | None = None, offset: str | None = None) DataFrame[source]

Predicted mean with its confidence interval, per row.

The interval is for the fitted mean (the rate the model assigns to this cell), not for an individual outcome: the delta method on the link scale, \(\exp(\hat\eta \pm z\,\sqrt{x^\top \Sigma x})\) with \(\Sigma\) the quasi-likelihood coefficient covariance. Individual outcomes vary far more than the mean; for that question a frequency-severity simulation is the right tool, not a GLM interval.

Returns:

Index-aligned with data; columns predicted, ci_low, ci_high. With exposure, all three are on the total scale.

Return type:

pandas.DataFrame

relativity_table(confidence_level: float = 0.95) DataFrame[source]

Every fitted relativity with its confidence interval, in one table.

The interval is computed on the coefficient scale and exponentiated: \(\exp(\hat\beta \pm z_{\alpha}\,\mathrm{se})\), using the quasi-likelihood standard errors (Pearson dispersion). Base levels appear with relativity 1.0 and no interval – the reference is fixed by construction, not estimated. Continuous covariates appear under level "(per +1)": the multiplicative effect of a one-unit increase.

Returns:

Indexed by (variable, level) with columns coef, se, relativity, ci_low, ci_high, is_base.

Return type:

pandas.DataFrame

residuals(data: DataFrame, kind: str = 'deviance', response: str | None = None, exposure=_UNSET, offset=_UNSET, weights=_UNSET) Series[source]

Per-row residuals on data, as a Series aligned to its index.

Parameters:
  • data (DataFrame) – Rows to evaluate – typically the training frame, but any frame with the model’s columns works (e.g. a validation split).

  • kind ({"deviance", "pearson", "standardized", "response"}) –

    • "response" – raw \(y - \hat\mu\).

    • "pearson"\((y-\hat\mu)\sqrt{w}/\sqrt{V(\hat\mu)}\); the squared Pearson residuals sum to pearson_chi2_ on the training data.

    • "deviance"\(\mathrm{sign}(y-\hat\mu)\sqrt{w\,d_i}\); the squared deviance residuals sum to deviance_ on the training data.

    • "standardized" – Pearson scaled by \(\sqrt{\hat\phi\,(1-h_i)}\) with \(h_i\) the IRLS hat value, so values beyond \(\pm 2\) flag unusual rows on a common scale. Leverage is exact on the training data (on new data \(h_i\) is the same formula, not a true leverage).

  • response (str, optional) – Column names; each defaults to the column used in fit().

  • exposure (str, optional) – Column names; each defaults to the column used in fit().

  • offset (str, optional) – Column names; each defaults to the column used in fit().

  • weights (str, optional) – Column names; each defaults to the column used in fit().

Notes

Plotting deviance or standardized residuals against fitted values and against each rating variable is the standard check that the variance function and link are adequate; structure in these plots means the relativities are absorbing the wrong shape.

summary() DataFrame[source]

Coefficient table: estimate, quasi-likelihood SE, z, relativity.

Standard errors use the Pearson-estimated dispersion (quasi-likelihood / quasi-Poisson style), which is the robust default for pricing data where overdispersion is the norm.

to_factor_tables() dict[source]

The fitted categorical relativities as FactorTable objects.

The bridge from estimation to application: each rating variable becomes a named lookup that plugs directly into the build-up and renewal machinery, with default=1.0 for unknown levels – matching how predict() treats levels unseen at fit time. Continuous covariates and interaction terms have no single-variable level->factor form and are not included; read their effects from relativity_table() (and cat x cat cells from relativities_["a:b"]).

Returns:

One table per categorical predictor, keyed by variable name.

Return type:

dict of str -> FactorTable

class FrequencySeverityModel(frequency: ~ratingmodels.relativity.GLMRelativities = <factory>, severity: ~ratingmodels.relativity.GLMRelativities = <factory>)[source]

Bases: object

A pure-premium model composed of a frequency GLM and a severity GLM.

Parameters:
  • frequency (GLMRelativities, optional) – Unfitted component models. Default family="poisson" for frequency and family="gamma" for severity – the classical pairing.

  • severity (GLMRelativities, optional) – Unfitted component models. Default family="poisson" for frequency and family="gamma" for severity – the classical pairing.

frequency, severity

The fitted component models (all their diagnostics – relativity_table, residuals, summary – apply per part).

Type:

GLMRelativities

property base_value_: float

Pure premium per exposure unit at base levels.

combined_relativities() dict[source]

Per-variable pure-premium relativities: frequency x severity.

Variables appearing in only one component contribute that component’s relativities unchanged (the other’s factor is 1.0); levels missing from a component take that component’s base, 1.0 – matching how its predict treats unseen levels.

Returns:

Per variable, indexed by level, with columns frequency, severity, combined.

Return type:

dict of str -> pandas.DataFrame

fit(data: DataFrame, claim_count: str, claim_amount: str, exposure: str | None = None, frequency_predictors: Sequence[str] = (), severity_predictors: Sequence[str] | None = None, frequency_continuous: Sequence[str] = (), severity_continuous: Sequence[str] | None = None, frequency_interactions: Sequence[tuple] = (), severity_interactions: Sequence[tuple] | None = None, base_levels: Mapping[str, object] | None = None) FrequencySeverityModel[source]

Fit both components from one claims frame.

Parameters:
  • data (DataFrame) – One row per risk/cell with total claim_count and total claim_amount over the period.

  • claim_count (str) – Count and aggregate amount columns.

  • claim_amount (str) – Count and aggregate amount columns.

  • exposure (str, optional) – Exposure column; enters the frequency model as a log offset.

  • frequency_predictors (sequence of str) – Categorical rating variables per component. Severity defaults to the frequency list – pass an explicit (possibly shorter) list when severity supports fewer variables, which is common: severity fits on claims only and thins out fast.

  • severity_predictors (sequence of str) – Categorical rating variables per component. Severity defaults to the frequency list – pass an explicit (possibly shorter) list when severity supports fewer variables, which is common: severity fits on claims only and thins out fast.

  • frequency_continuous (sequence of str) – Continuous covariates per component (severity defaults to the frequency list).

  • severity_continuous (sequence of str) – Continuous covariates per component (severity defaults to the frequency list).

  • frequency_interactions (sequence of pairs) – Interaction terms per component, as in GLMRelativities.fit() (severity defaults to the frequency list). Categorical x categorical interactions surface in combined_relativities() under an "a:b" key with a MultiIndex of level pairs.

  • severity_interactions (sequence of pairs) – Interaction terms per component, as in GLMRelativities.fit() (severity defaults to the frequency list). Categorical x categorical interactions surface in combined_relativities() under an "a:b" key with a MultiIndex of level pairs.

  • base_levels (mapping, optional) – Predictor -> reference level, shared by both components.

Notes

Severity is fit on rows with claim_count > 0 and claim_amount > 0, with response claim_amount / claim_count and prior weight claim_count. Rows with claims closed at zero amount still count toward frequency; if there are many of them, consider whether a zero-mass component belongs in the model.

frequency_prediction(data: DataFrame, exposure: str | None = None) ndarray[source]

Expected claim counts (with exposure) or claim rate per unit.

predict_interval(data: DataFrame, confidence_level: float = 0.95, exposure: str | None = None) DataFrame[source]

Predicted pure premium with its confidence interval, per row.

The pure premium is \(\exp(\eta_f + \eta_s)\); on the log scale the variances of the two component linear predictors add, assuming the frequency and severity coefficient estimates are independent – the standard frequency-severity assumption (the two GLMs are fit to different responses), stated here because it is an assumption, not a theorem. The interval is for the mean pure premium of a cell, not for an individual outcome; individual losses vary enormously more than their expectation.

Returns:

Index-aligned with data; columns predicted, ci_low, ci_high. With exposure, all three are on the total scale. predicted equals pure_premium_prediction() exactly.

Return type:

pandas.DataFrame

pure_premium_prediction(data: DataFrame, exposure: str | None = None) ndarray[source]

Expected loss: total (with exposure) or per exposure unit.

Exactly frequency_prediction(data, exposure) * severity_prediction(data) – the frequency x severity identity.

severity_prediction(data: DataFrame) ndarray[source]

Expected cost per claim.

summary() DataFrame[source]

Both component coefficient tables, stacked under a model key.

to_factor_tables() dict[source]

Combined pure-premium relativities as FactorTable objects.

One table per variable, built from the combined column of combined_relativities() (frequency x severity) with default=1.0 for unknown levels – the pure-premium plan you would actually apply, ready for the build-up and renewal machinery. Interaction terms are excluded (a FactorTable is single-variable by contract); read their cells from combined_relativities().

credibility_relativities(data: DataFrame, factor: str, response: str, exposure: str | None = None, prior=1.0, method: str = 'buhlmann', full_credibility: float | None = None, base_level=None) DataFrame[source]

One-way relativities shrunk toward a prior by credibility, per level.

Sparse levels produce unstable observed relativities; the classical actuarial answer is not to drop them or regularize generically but to credibility-weight them against a complement:

\[\text{relativity}_\ell = Z_\ell \cdot \text{observed}_\ell + (1 - Z_\ell) \cdot \text{prior}_\ell .\]
Parameters:
  • data (DataFrame) – One row per observation.

  • factor (str) – The rating variable to smooth and the response column.

  • response (str) – The rating variable to smooth and the response column.

  • exposure (str, optional) – Exposure column. Level weights and the observed relativities are exposure-weighted when given; otherwise each row has weight 1.

  • prior (float, mapping, or Series) – The complement of credibility on the relativity scale. The default 1.0 shrinks toward “no effect”; a mapping/Series (e.g. the current filed factors) shrinks each level toward its existing relativity. Levels missing from a mapping fall back to 1.0.

  • method ({"buhlmann", "limited_fluctuation"}) –

    How \(Z_\ell\) is estimated:

    • "buhlmann" (default) – empirical Bühlmann-Straub across the levels of factor (each row is one observation of its level), via ratingmodels.buhlmann_straub() / actuarialpy.BuhlmannStraub. Greatest-accuracy credibility: \(Z = w/(w + k)\) with \(k\) estimated from the data.

    • "limited_fluctuation" – the square-root rule \(Z = \min(1, \sqrt{n_\ell / n_{\text{full}}})\) where \(n_\ell\) is the level’s total response and full_credibility is the full-credibility standard in the same units (for claim counts, e.g. ratingmodels.full_credibility_standard()).

  • full_credibility (float, optional) – Required when method="limited_fluctuation".

  • base_level (optional) – When given, the observed and relativity columns are each rebased so this level equals 1.0.

Returns:

Indexed by level with columns n, exposure, response, observed, credibility, prior, relativity.

Return type:

pandas.DataFrame

Notes

With the default scalar prior of 1.0, the Bühlmann-Straub form is exactly the credibility-weighted mean divided by the collective mean: shrinking the relativity toward 1 and shrinking the level mean toward the overall mean are the same operation.

collapse_sparse_levels(levels, exposure=None, min_exposure: float | None = None, min_n: int | None = None, other_label='Other')[source]

Recode levels below an exposure or count threshold into one bucket.

The blunt companion to credibility_relativities(): rather than shrinking a thin level’s relativity, fold the level into other_label before fitting, so the design matrix never carries columns the data cannot support.

Parameters:
  • levels (array-like) – The categorical column (Series in, Series out on the same index).

  • exposure (array-like, optional) – Aligned exposure; level totals are sums of this when given, row counts otherwise.

  • min_exposure (float / int, optional) – Keep a level only if its total exposure is at least min_exposure and its row count at least min_n. At least one must be given.

  • min_n (float / int, optional) – Keep a level only if its total exposure is at least min_exposure and its row count at least min_n. At least one must be given.

  • other_label – Label assigned to collapsed levels. Must not already be a kept level.

Returns:

recoded – the recoded labels (Series if levels was a Series, else an ndarray). summary – a DataFrame indexed by original level with columns n, exposure, collapsed; apply the same recode to future data by mapping levels where collapsed is True.

Return type:

(recoded, summary)

gini_coefficient(actual, predicted, exposure=None, normalize: bool = True, by=None) float | Series[source]

Ordered-Lorenz Gini of predicted as a risk ranker for actual.

Parameters:
  • actual (array-like) – Observed outcome per record (losses, claim counts, pure premium).

  • predicted (array-like) – Model prediction used to order records from lowest to highest risk.

  • exposure (array-like, optional) – Weights (earned exposure). Equal weights if omitted.

  • normalize (bool) – If True (default), divide by the Gini of the perfect model that sorts by actual itself, so 1.0 means perfect segmentation and 0.0 means no segmentation. If False, return the raw ordered-Lorenz Gini.

  • by (array-like, optional) – Group labels aligned with actual. When given, the Gini is computed within each group and a Series indexed by group is returned – one call scores every segment of a validation frame.

lift_table(actual, predicted, exposure=None, n_bands: int = 10, by=None) DataFrame[source]

Exposure-weighted lift table: records banded by predicted risk.

Records are sorted by predicted and split into n_bands bands of (approximately) equal total exposure. Within each band the table reports exposure, the exposure-weighted actual and predicted means, and lift – the band’s actual mean relative to the overall actual mean. A model that segments well shows lift rising monotonically across bands.

Returns:

Indexed 1..n_bands with columns n, exposure, predicted_mean, actual_mean, lift. With by (group labels aligned with actual), one table is built per group and the result carries a (group, band) MultiIndex.

Return type:

pandas.DataFrame

calibration_table(actual, predicted, exposure=None, n_bands: int = 10, by=None) DataFrame[source]

Calibration across the prediction range: actual vs. predicted by band.

The companion to lift_table(): lift asks whether predictions order risks; calibration asks whether they are right on the level. Records are banded into n_bands groups of (approximately) equal exposure by predicted value, and each band reports per-unit actual and predicted means (band totals over band exposure) and their ratio – so actual and predicted are treated symmetrically and should both be on the total scale, as from model.predict(df, exposure=...). A well-calibrated model has ae_ratio near 1.0 in every band; a systematic drift (low bands above 1, high bands below) is the classic signature of over-shrunk predictions.

Returns:

Indexed 1..n_bands with columns n, exposure, predicted_mean, actual_mean, ae_ratio. With by (group labels aligned with actual), one table per group under a (group, band) MultiIndex.

Return type:

pandas.DataFrame

actual_expected_table(actual, expected, exposure=None, by=None, include_total: bool = True) DataFrame[source]

Actual-to-expected exhibit: totals, means, and A/E ratio by segment.

The workhorse validation exhibit: for each segment, the total actual, total expected, their exposure-weighted means, and the A/E ratio. An A/E near 1.0 in every segment of a variable means the model has captured that variable’s effect; a pattern across levels means residual signal.

Parameters:
  • actual (array-like) – Observed outcomes and model expectations, row-aligned. expected should be on the same total scale as actual (e.g. include exposure), as from model.predict(df, exposure=...).

  • expected (array-like) – Observed outcomes and model expectations, row-aligned. expected should be on the same total scale as actual (e.g. include exposure), as from model.predict(df, exposure=...).

  • exposure (array-like, optional) – Weights for the mean columns. Row counts when omitted.

  • by (array-like, mapping, or DataFrame, optional) –

    • omitted – a single overall row.

    • array of labels – one row per level.

    • mapping/DataFrame of name -> labels – one block per variable, stacked tidily under a (variable, level) MultiIndex; one call audits every rating variable of a validation frame.

  • include_total (bool) – Append an overall row (labelled "All"). Default True.

Returns:

Columns n, exposure, actual, expected, actual_mean, expected_mean, ae_ratio.

Return type:

pandas.DataFrame

compare_models(models, data: DataFrame, response: str, exposure: str | None = None, offset: str | None = None, weights: str | None = None, n_bands: int = 10) DataFrame[source]

Side-by-side scorecard for fitted GLMs on one evaluation frame.

Every model is scored on the same data – pass a held-out validation frame (see ratingmodels.temporal_split() / ratingmodels.group_split()) for an honest comparison, or the training frame for an in-sample one.

Parameters:
  • models (mapping or sequence) – name -> fitted GLMRelativities (or a sequence, auto-named model_1, model_2, …). Each model must expose the fitted interface (predict, family deviance); i.e. any GLMRelativities-compatible object.

  • data (DataFrame / str) – Evaluation frame and its column names, as in GLMRelativities.fit.

  • response (DataFrame / str) – Evaluation frame and its column names, as in GLMRelativities.fit.

  • exposure (DataFrame / str) – Evaluation frame and its column names, as in GLMRelativities.fit.

  • offset (DataFrame / str) – Evaluation frame and its column names, as in GLMRelativities.fit.

  • weights (DataFrame / str) – Evaluation frame and its column names, as in GLMRelativities.fit.

  • n_bands (int) – Bands for the calibration-error summary.

Returns:

One row per model: family, n_params, converged, dispersion (training), then evaluation-frame metrics deviance, null_deviance, deviance_explained, gini, ae_ratio, and calibration_error (the exposure-weighted mean absolute deviation of band-level A/E from 1.0).

Return type:

pandas.DataFrame

Notes

Deviance is family-specific: it is comparable between models of the same family, while gini, ae_ratio, and calibration_error are comparable across families. No AIC is reported – the standard errors are quasi-likelihood, so a true likelihood is not available.

random_split(data: DataFrame, test_fraction: float = 0.25, random_state=None) tuple[DataFrame, DataFrame][source]

Rows-at-random split into (train, test).

Appropriate only when rows are independent; with repeated observations of the same policy or group, use group_split() instead.

Parameters:
  • data (DataFrame)

  • test_fraction (float) – Target share of rows in the test side.

  • random_state (optional) – Seed or Generator for numpy.random.default_rng().

group_split(data: DataFrame, group: str, test_fraction: float = 0.25, weights: str | None = None, random_state=None) tuple[DataFrame, DataFrame][source]

Group-preserving random split: every group lands whole on one side.

Groups are shuffled and assigned to the test side until it holds at least test_fraction of the total weight, so the realized share slightly overshoots the target by up to one group.

Parameters:
  • data (DataFrame)

  • group (str) – Column identifying the unit that must not straddle the split (policy, employer group, account, …).

  • test_fraction (float) – Target share of total weight in the test side.

  • weights (str, optional) – Column whose per-group totals define “share” – typically exposure or premium. Rows count equally when omitted.

  • random_state (optional) – Seed or Generator for numpy.random.default_rng().

temporal_split(data: DataFrame, date: str, cutoff) tuple[DataFrame, DataFrame][source]

Out-of-time split at cutoff: train strictly before, test at/after.

The honest validation shape for a model that will predict forward in time. train holds rows with data[date] < cutoff and test the rest.

Parameters:
  • data (DataFrame)

  • date (str) – Column to cut on. Datetime-like columns coerce cutoff through pandas.Timestamp (so "2025-01-01" works); other ordered columns (period strings, year integers) compare as-is.

  • cutoff – The boundary value; the first value belonging to the test side.

class ManualRate(base_loss_cost: float | int | ~numpy.ndarray | ~pandas.Series | ~typing.Sequence[float], factors: ~typing.Mapping[str, float | int | ~numpy.ndarray | ~pandas.Series | ~typing.Sequence[float]] = <factory>, target_loss_ratio: float | int | ~numpy.ndarray | ~pandas.Series | ~typing.Sequence[float] = 0.85, retention: ~ratingmodels.loading.RetentionLoad | None = None)[source]

Bases: object

Build a manual rate from a base and a set of named relativities.

Every numeric field follows the vectorization contract: Series-valued bases and factors build the whole book’s manual rates in one object, and loss_cost() / rate() / breakdown() come back per row.

Parameters:
  • base_loss_cost (float or array-like) – Base loss cost (per exposure unit) at the rating-period level (see ratingmodels.base_rate_from_experience() to derive it).

  • factors (mapping) – Named relativities, e.g. {"area": 1.05, "industry": 0.97, ...}; values may be scalars or Series columns.

  • target_loss_ratio (float) – Claims / premium target used to gross up to a charged rate. Ignored when retention is supplied.

  • retention (RetentionLoad, optional) – Full expense / profit loading. When provided, the charged rate is built with the fundamental insurance equation instead of a single loss ratio, and fixed expense is applied per exposure unit (flat across cells).

breakdown() BuildUpResult[source]

Audit trail of the manual claims build-up (base x each relativity).

The final running total equals loss_cost() up to floating point.

loss_cost() float | int | ndarray | Series | Sequence[float][source]

Expected manual loss cost (before expense/margin loading).

rate() float | int | ndarray | Series | Sequence[float][source]

Charged manual rate per exposure unit.

Uses retention (the full gross-up) when supplied, otherwise loss cost / target_loss_ratio.

steps() list[source]

The manual claims build-up as an ordered list of steps.

manual_loss_cost(base_loss_cost: float | int | ndarray | Series | Sequence[float], factors: Sequence[float | int | ndarray | Series | Sequence[float]]) float | int | ndarray | Series | Sequence[float][source]

Base loss cost scaled by the product of relativities.

Elementwise: pass columns (a Series of base rates and Series factors) to price every row at once; scalars broadcast.

aggregate_demographic_factor(census: DataFrame, factor_col: str, weight_col: str = 'count', by: str | Sequence[str] | None = None) float | Series[source]

Weighted average of a unit-level demographic factor (e.g. an age/sex factor weighted by member counts).

With by (a column or list of columns), aggregates within each group and returns a Series indexed by group – one demographic factor per group from a single census frame.

class ExperienceRate(incurred_claims: float | int | ndarray | Series | Sequence[float], exposure: float | int | ndarray | Series | Sequence[float], trend_annual: float | int | ndarray | Series | Sequence[float] = 0.0, trend_years: float | int | ndarray | Series | Sequence[float] = 1.0, pooled_excess: float | int | ndarray | Series | Sequence[float] = 0.0, pooling_charge: float | int | ndarray | Series | Sequence[float] = 0.0, benefit_factor: float | int | ndarray | Series | Sequence[float] = 1.0, demographic_factor: float | int | ndarray | Series | Sequence[float] = 1.0, target_loss_ratio: float | int | ndarray | Series | Sequence[float] = 0.85, retention: RetentionLoad | None = None)[source]

Bases: object

Develop an experience rate from incurred claims and exposure.

Every numeric field follows the vectorization contract: pass columns (Series of claims, exposures, per-group trends…) and every derived quantity – pooled_loss_cost(), loss_cost(), rate() – comes back as a Series on the same index. Scalars broadcast, so a single trend assumption prices against per-group claims.

Parameters:
  • incurred_claims (float) – Total incurred (completed) claims over the experience period.

  • exposure (float) – Exposure units (member-months, policy months, earned exposures, …).

  • trend_annual (float) – Annual claims trend.

  • trend_years (float) – Years from experience midpoint to rating midpoint.

  • pooled_excess (float) – Claim dollars removed by pooling (from pool_claims()). Default 0.

  • pooling_charge (float) – Pooling charge added back, per exposure unit. Default 0.

  • benefit_factor (float) – Multiplicative adjustments for benefit/demographic changes between the experience and rating periods. Default 1.0.

  • demographic_factor (float) – Multiplicative adjustments for benefit/demographic changes between the experience and rating periods. Default 1.0.

  • target_loss_ratio (float) – Claims / premium target used to load to a charged rate.

classmethod from_experience(exp: Experience, *, expense: str | Sequence[str] | None = None, pooling_point: float | None = None, claimant_col: str | None = None, trend_annual: Numeric = 0.0, trend_years: Numeric = 1.0, pooling_charge: Numeric = 0.0, benefit_factor: Numeric = 1.0, demographic_factor: Numeric = 1.0, target_loss_ratio: Numeric = 0.85, retention: RetentionLoad | None = None) ExperienceRate[source]

Build the worksheet row from the canonical Experience.

incurred_claims and exposure are the sums of the bound expense and exposure roles. With pooling_point (and claimant_col naming the claimant identifier), each claimant’s total is capped at the pooling point and the excess feeds pooled_excess – the same split pool_claims() makes from a list of large claims. Everything else (trend, pooling charge, factors, retention) is judgment supplied by the caller, exactly as in the scalar constructor.

loss_cost() float | int | ndarray | Series | Sequence[float][source]

Trended, pooled, adjusted experience loss cost (charge added back).

pooled_loss_cost() float | int | ndarray | Series | Sequence[float][source]

Pooled (capped) claims per exposure unit, before trend.

rate() float | int | ndarray | Series | Sequence[float][source]

Charged experience rate per exposure unit.

Uses retention (the full gross-up) when supplied, otherwise loss cost / target_loss_ratio.

experience_rate(exp: Experience, *, by: str | list[str] | None = None, **kwargs) ExperienceRate | pd.DataFrame[source]

Experience rates from the canonical Experience.

Without by, returns the single ExperienceRate (the primitive: one experience-rated group, one worksheet row). With by, builds one worksheet row per segment of the bound frame and returns a tidy DataFrame of the rate components – book-level sugar over the classmethod.

pool_claims(claims, pooling_point: float, by=None) tuple[float, float] | tuple[Series, Series][source]

Split claims into a pooled (capped) total and the excess above P.

Returns (capped_total, excess) where excess = sum(max(0, claim - pooling_point)).

With by (group labels aligned with claims), pooling is applied within each group and both returns are Series indexed by group – one groupby pass pools a whole claim file.

expected_excess_charge(claims, pooling_point: float, exposure: float | int | ndarray | Series | Sequence[float], by=None) float | int | ndarray | Series | Sequence[float][source]

Naive pooling charge per exposure unit: observed excess spread over exposure.

A filed pooling charge is normally derived from book-wide excess experience or an EVT tail model (see the extremeloss package); this helper gives the simple group-level estimate. With by, the charge is computed per group (exposure then aligns to the group index – a Series/mapping keyed by group, or a scalar broadcast to all groups).

base_rate_from_experience(data: pd.DataFrame | Experience, exposure: str | None = None, loss: str | None = None, relativity: str | None = None, factor_cols: Sequence[str] | None = None, by: str | Sequence[str] | None = None) BaseRateResult | pd.DataFrame[source]

Indicated base loss cost from book experience (off-balance method).

Returns \(B = \sum_i L_i / \sum_i e_i r_i\) together with the average relativity and average loss cost. Gross base_loss_cost to a charged base rate with a ratingmodels.RetentionLoad.

Parameters:
  • data (DataFrame) – One row per risk or rating cell.

  • exposure (str) – Column names for exposure (e.g. member-months) and trended/developed loss.

  • loss (str) – Column names for exposure (e.g. member-months) and trended/developed loss.

  • relativity (str, optional) – Column of precomputed relativities \(r_i\).

  • factor_cols (sequence of str, optional) – Columns of individual rating factors to multiply into \(r_i\) (used when relativity is not supplied).

  • by (str or sequence of str, optional) – Segment column(s). When given, a base rate is backed out within each segment and the result is a DataFrame indexed by segment with columns base_loss_cost, average_relativity, average_loss_cost, total_exposure – one call, one base rate per row.

class BaseRateResult(base_loss_cost: float, average_relativity: float, average_loss_cost: float, total_exposure: float)[source]

Bases: object

Result of base_rate_from_experience().

average_relativity(data: DataFrame, exposure: str, relativity: str | None = None, factor_cols: Sequence[str] | None = None, by: str | Sequence[str] | None = None) float | Series[source]

Exposure-weighted average relativity \(\bar r = \sum e_i r_i / \sum e_i\).

Supply relativities either as a single relativity column or as factor_cols (per-row factors that are multiplied together). With by (a column or list of columns), the average is computed within each group and a Series indexed by group is returned.

off_balance_factor(current_avg_relativity: float | int | ndarray | Series | Sequence[float], new_avg_relativity: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Off-balance correction \(\bar r_0 / \bar r_1\) from revising relativities.

Elementwise: Series of segment averages give a Series of corrections.

rebalance_base_rate(current_base: float | int | ndarray | Series | Sequence[float], current_avg_relativity: float | int | ndarray | Series | Sequence[float], new_avg_relativity: float | int | ndarray | Series | Sequence[float], overall_change: float | int | ndarray | Series | Sequence[float] = 0.0) float | int | ndarray | Series | Sequence[float][source]

Off-balanced new base rate \(B_1 = B_0 (\bar r_0/\bar r_1)(1+\Delta)\).

Holds the overall premium level neutral when relativities change, then applies the intended overall rate change overall_change (\(\Delta\)).

class Step(op: str, label: str, operand: float | int | ndarray | Series | Sequence[float] = 1.0, weight: float | int | ndarray | Series | Sequence[float] = 1.0)[source]

Bases: object

A single build-up operation. operand is the factor (multiply / segment), amount (add), or value (start); weight is used by segment_multiply only. Operands may be scalars or vectors.

start(label: str, value: float | int | ndarray | Series | Sequence[float]) Step[source]

Set the running total to value (normally the first step).

multiply(label: str, factor: float | int | ndarray | Series | Sequence[float]) Step[source]

Multiply the running total by factor (a relativity or trend).

add(label: str, amount: float | int | ndarray | Series | Sequence[float]) Step[source]

Add amount to the running total (negative for a copay credit).

segment_multiply(label: str, factor: float | int | ndarray | Series | Sequence[float], weight: float | int | ndarray | Series | Sequence[float]) Step[source]

Apply factor to a fraction weight of the running total.

\(\text{running} \leftarrow \text{running}\,(1 - w + w f)\).

checkpoint(label: str) Step[source]

Record a labeled subtotal without changing the running total.

evaluate(steps: Sequence[Step]) BuildUpResult[source]

Run an ordered sequence of Step and return a BuildUpResult.

The running total starts at 0; a leading start() sets the base. Vector operands (Series / arrays) make the whole build-up elementwise; see the module notes on vectorized build-ups.

class BuildUp[source]

Bases: object

Fluent builder for a build-up; sugar over a list of Step.

>>> r = (BuildUp()
...      .start("Par Base", 941.63)
...      .add("$30 specialist copay", -11.44)
...      .multiply("Rating Region", 1.083)
...      .checkpoint("Medical Par Base Claim Cost")
...      .evaluate())
class BuildUpResult(value: float | int | ~numpy.ndarray | ~pandas.Series | ~typing.Sequence[float], breakdown: ~pandas.DataFrame, subtotals: dict, steps: list = <factory>)[source]

Bases: object

Result of evaluating a build-up.

value

Final running total; a Series (index preserved) for a vectorized build-up.

Type:

float or pandas.Series

breakdown

Scalar build-up: one row per step with columns step, operation, label, operand, running_total. Vectorized build-up: tidy long format, one row per (step, entity), with an entity column carrying the shared Series index (or positions). For segment_multiply the operand shown is the effective factor \((1 - w + w f)\), so the column reconciles by multiplication.

Type:

pandas.DataFrame

subtotals

Ordered mapping of checkpoint label -> running total at that point (floats, or Series for a vectorized build-up).

Type:

dict

steps

The raw steps (nominal factor and weight preserved).

Type:

list[Step]

subtotal(label: str) float | int | ndarray | Series | Sequence[float][source]

Running total recorded at the named checkpoint.

participation_blend(par: BuildUpResult | float | Series | ndarray, nonpar: BuildUpResult | float | Series | ndarray, participation_rate: float | int | ndarray | Series | Sequence[float], label: str = 'Blended Claim Cost') BuildUpResult[source]

Two-stream participation blend \(\text{par}\,p + \text{nonpar}\,(1-p)\) (e.g. a health book’s in-/out-of-network split).

participation_rate is the participating share p; it may be a Series for per-row participation.

combine_streams(streams: Mapping[str, BuildUpResult | float | Series | ndarray], label: str = 'Combined') BuildUpResult[source]

Additively combine named streams (e.g. {"Medical": ..., "Drug": ...}).

Implemented as a build-up (start + adds) so the result carries a running total and an audit trail. Vector-valued streams combine elementwise.

class RetentionLoad(fixed_expense: float | int | ndarray | Series | Sequence[float] = 0.0, variable_expense_ratio: float | int | ndarray | Series | Sequence[float] = 0.0, profit_margin: float | int | ndarray | Series | Sequence[float] = 0.0, lae_ratio: float | int | ndarray | Series | Sequence[float] = 0.0)[source]

Bases: object

Expense and profit loads used to gross claims up to a charged rate.

Parameters:
  • fixed_expense (float) – Flat operating expense per exposure unit (a dollar amount, not a percentage of premium). Default 0.

  • variable_expense_ratio (float) – Sum of percent-of-premium loads: commission, premium tax, exchange / regulatory fees, and any admin expressed as a percentage of premium. Default 0.

  • profit_margin (float) – Target underwriting profit / contribution to surplus, as a percentage of premium. Default 0.

  • lae_ratio (float) – Loss adjustment expense as a percentage of claims. Default 0.

expense_and_profit_ratio(loss_cost: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Share of the gross rate going to expense and profit (1 - loss ratio).

classmethod from_gross_loss_ratio(loss_ratio: float | int | ndarray | Series | Sequence[float], variable_items: Mapping[str, float] | None = None) RetentionLoad[source]

Retention for a contract that pins the gross loss ratio.

The contract fixes \(C/P = \text{LR}^*\), so the premium is fully determined by claims: \(P = C/\text{LR}^*\). In the fundamental equation this is \(F = 0\) with the whole percent-of-premium retention pinned at \(V + Q = 1 - \text{LR}^*\).

Parameters:
  • loss_ratio (float or array-like) – The contractual claims / premium ratio, in (0, 1). A Series rates a book of pinned-ratio groups elementwise.

  • variable_items (mapping, optional) – Known percent-of-premium components inside the retention (e.g. {"commission": 0.03, "premium_tax": 0.023}). Itemizing does not change the premium — the contract pins the total — it only splits the retention: the remainder (1 - loss_ratio) - sum(items) lands in profit_margin. Items exceeding the contractual retention raise, since the contract cannot cover them.

Notes

Dollar expenses (a flat fee per exposure unit) do not belong here: a gross-ratio contract leaves no degree of freedom for them to move the premium. Project them separately and reconcile against the retention \(P(1 - \text{LR}^*)\); what remains is the margin.

classmethod from_items(fixed_expense: float = 0.0, variable_items: Mapping[str, float] | None = None, profit_margin: float = 0.0, lae_ratio: float = 0.0) RetentionLoad[source]

Construct from an itemized mapping of percent-of-premium loads.

variable_items (e.g. {"commission": 0.04, "premium_tax": 0.023, "aca_fees": 0.005, "admin_pct": 0.06}) is summed into the variable expense ratio.

classmethod from_net_loss_ratio(loss_ratio: float | int | ndarray | Series | Sequence[float], fixed_expense: float | int | ndarray | Series | Sequence[float] = 0.0, variable_items: Mapping[str, float] | None = None) RetentionLoad[source]

Retention for a contract that pins the loss ratio net of expenses.

The contract fixes \(C/(P - E) = \text{LR}^*\) with expenses \(E = F + V P\). Solving:

\[P - F - V P = C/\text{LR}^* \;\Longrightarrow\; P = \frac{C/\text{LR}^* + F}{1 - V}.\]

The claims gross-up \(1/\text{LR}^*\) is carried through the percent-of-claims slot — \(C\,(1 + \tfrac{1-\text{LR}^*}{\text{LR}^*}) = C/\text{LR}^*\) — so lae_ratio on the returned instance holds \((1-\text{LR}^*)/\text{LR}^*\), not a loss adjustment expense. If the contract’s claims measure includes LAE, pass loss_cost inclusive of LAE rather than setting lae_ratio.

Parameters:
  • loss_ratio (float or array-like) – The contractual claims / (premium − expenses) ratio, in (0, 1).

  • fixed_expense (float or array-like) – Dollar expenses per exposure unit netted out by the contract (e.g. a flat admin fee). Default 0.

  • variable_items (mapping, optional) – Percent-of-premium expenses netted out by the contract, summed into \(V\).

Notes

The margin under this contract is claims-proportional: \(P - E - C = C\,(1-\text{LR}^*)/\text{LR}^*\) — in contrast with the gross form, where expenses plus margin are premium-proportional. implied_net_loss_ratio() returns loss_ratio identically for instances built here.

gross_rate(loss_cost: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Gross a loss cost up to a charged rate via \((L(1+\text{lae})+F)/(1-V-Q)\).

Elementwise: a Series of loss costs (and/or Series-valued loads for per-row retention structures) returns a Series of charged rates.

implied_loss_ratio(loss_cost: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Loss ratio implied at a given claims level (claims / gross rate).

With a non-zero fixed expense this varies with the claims level; with only percentage loads it equals 1 - variable_expense_ratio - profit_margin.

implied_net_loss_ratio(loss_cost: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Loss ratio net of expenses: \(C / (P - F - V P)\).

Expenses are the fixed and variable loads; the profit provision is the carrier’s and stays inside the denominator’s premium. For a retention built with from_net_loss_ratio() this returns the contractual ratio identically; for any other retention it is the net-basis counterpart of implied_loss_ratio().

property variable_and_profit: float

Combined percent-of-premium load \(V + Q\).

gross_rate(loss_cost: float | int | ndarray | Series | Sequence[float], retention: RetentionLoad) float | int | ndarray | Series | Sequence[float][source]

Functional form of RetentionLoad.gross_rate().

permissible_loss_ratio(retention: RetentionLoad, loss_cost: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Functional form of RetentionLoad.implied_loss_ratio().

blend(experience: float | int | ndarray | Series | Sequence[float], manual: float | int | ndarray | Series | Sequence[float], credibility: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

\(Z \cdot \text{experience} + (1-Z)\cdot \text{manual}\), elementwise.

class RateIndication(experience_loss_cost: float | int | ndarray | Series | Sequence[float], manual_loss_cost: float | int | ndarray | Series | Sequence[float], credibility: float | int | ndarray | Series | Sequence[float], current_rate: float | int | ndarray | Series | Sequence[float], target_loss_ratio: float | int | ndarray | Series | Sequence[float] = 0.85, current_premium: float | int | ndarray | Series | Sequence[float] | None = None, exposure: float | int | ndarray | Series | Sequence[float] | None = None, trend_total_factor: float | int | ndarray | Series | Sequence[float] = 1.0, benefit_factor: float | int | ndarray | Series | Sequence[float] = 1.0, demographic_factor: float | int | ndarray | Series | Sequence[float] = 1.0, retention: RetentionLoad | None = None)[source]

Bases: object

Develop an indicated rate from experience and manual inputs.

Every numeric field follows the vectorization contract: pass columns (Series of loss costs, credibilities, current rates…) to run the whole book’s indication in one object; each method returns a Series on the shared index, and rate_change_decomposition() returns per-case driver tables.

Parameters:
  • experience_loss_cost (float or array-like) – Trended, pooled, adjusted experience loss cost (per exposure unit) (see ratingmodels.ExperienceRate).

  • manual_loss_cost (float) – Manual loss cost at the rating-period level (see ratingmodels.ManualRate).

  • credibility (float) – Credibility Z assigned to experience, in [0, 1].

  • current_rate (float) – Current charged rate per exposure unit.

  • target_loss_ratio (float) – Claims / premium target used to load claims to a charged rate.

  • current_premium (float, optional) – On-level earned premium over the experience period; required only for the loss-ratio method.

  • exposure (float, optional) – Exposure units over the experience period; required for the loss-ratio method (with current_premium) to form an experience loss ratio.

  • trend_total_factor (float) – Total claims trend factor \((1+t)^\Delta\); used by the loss-ratio method’s trend-only side and by the decomposition. Default 1.0.

  • benefit_factor (float) – Driver factors for the rate-change decomposition. Default 1.0.

  • demographic_factor (float) – Driver factors for the rate-change decomposition. Default 1.0.

indicated_rate() float | int | ndarray | Series | Sequence[float][source]

Indicated charged rate (build-up method).

indicated_rate_change() float | int | ndarray | Series | Sequence[float][source]

Proportional change implied by the build-up indicated rate.

loss_ratio_indication() float | int | ndarray | Series | Sequence[float][source]

Credibility-weighted loss-ratio rate change.

Experience side: exp_LR / target_LR - 1. Trend-only side: trend_total_factor - 1.

rate_change_decomposition() RateChangeDecomposition[source]

Decompose the build-up indicated change into named drivers.

Drivers:

  • trend – the claims trend factor,

  • experience – the credibility effect, blended/manual claims (equals 1 when Z = 0),

  • benefit and demographic – supplied adjustment factors.

Any remaining movement (rate adequacy / loading) is absorbed by an explicit residual factor so the parts reconcile to the total.

decompose_rate_change(factors: Mapping[str, float | int | ndarray | Series | Sequence[float]], total_factor: float | int | ndarray | Series | Sequence[float] | None = None) RateChangeDecomposition[source]

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. Values may be scalars (one decomposition) or vectors under the vectorization contract (one decomposition per row; scalars broadcast).

  • total_factor (float or array-like, 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.

class RateChangeDecomposition(total_factor: float | int | ndarray | Series | Sequence[float], factors: Series | DataFrame, contributions: Series | DataFrame)[source]

Bases: object

Result of decompose_rate_change().

For a scalar decomposition factors and contributions are Series indexed by driver. For a vectorized one they are DataFrames (rows = cases, columns = drivers) and total_factor is a Series/array.

cap_change(change: float | int | ndarray | Series | Sequence[float], cap: float | int | ndarray | Series | Sequence[float] | None = None, floor: float | int | ndarray | Series | Sequence[float] | None = None) float | int | ndarray | Series | Sequence[float][source]

Clip a proportional rate change to [floor, cap] (either may be None).

cap and floor may themselves be vectors for per-row limits.

apply_cap(current_rate: float | int | ndarray | Series | Sequence[float], indicated_rate: float | int | ndarray | Series | Sequence[float], cap: float | int | ndarray | Series | Sequence[float] | None = None, floor: float | int | ndarray | Series | Sequence[float] | None = None) float | int | ndarray | Series | Sequence[float][source]

Return the charged rate after capping the implied change, elementwise.

band(change: float | int | ndarray | Series | Sequence[float], deadband: float = 0.0, step: float | None = None) float | int | ndarray | Series | Sequence[float][source]

Snap a change to zero within deadband; optionally to step grid.

round_rate(rate: float | int | ndarray | Series | Sequence[float], ndigits: int = 2) float | int | ndarray | Series | Sequence[float][source]

Round a rate to a filed precision (default cents), elementwise.

corridor(current_rate: float | int | ndarray | Series | Sequence[float], indicated_rate: float | int | ndarray | Series | Sequence[float], max_up: float, max_down: float) float | int | ndarray | Series | Sequence[float][source]

Limit a single renewal move to [-max_down, +max_up] proportionally.

rate_dislocation(current_rate, proposed_rate, exposure=None, bands=(-0.10, -0.05, 0.0, 0.05, 0.10), include_total: bool = True) DataFrame[source]

Band the book by rate change and report premium in each band.

Parameters:
  • current_rate (array-like) – Per-case rates; the change is proposed/current - 1.

  • proposed_rate (array-like) – Per-case rates; the change is proposed/current - 1.

  • exposure (array-like, optional) – Units each rate applies to, so rate * exposure is premium. Premium equals rate (and counts weight equally) when omitted.

  • bands (sequence of float) – Interior band edges as decimal changes, e.g. -0.05 for -5%. Edges are extended with -inf/+inf, so k edges give k + 1 bands; a band’s interval is half-open, (low, high]. The default edges include 0.0, so increases and decreases are always separated.

  • include_total (bool) – Append an "All" row. Default True.

Returns:

One row per band (empty bands kept, so the exhibit shape is stable) with columns n, exposure, current_premium, proposed_premium, avg_change (premium-weighted: proposed/current - 1), exposure_share.

Return type:

pandas.DataFrame

constraint_impact(indicated_rate, proposed_rate, exposure=None, current_rate=None, by=None) Series | DataFrame[source]

What the gap between indicated and proposed rates costs.

Caps, floors, and concessions move issued rates off the indication; this quantifies the move in premium terms – the shortfall left on the table where proposed sits below indicated, the excess where it sits above, and the further average change still needed to reach the indication.

Parameters:
  • indicated_rate (array-like) – The formula answer and the rate actually proposed/issued.

  • proposed_rate (array-like) – The formula answer and the rate actually proposed/issued.

  • exposure (array-like, optional) – Units per case; premium is rate * exposure. Omitted = 1 per case.

  • current_rate (array-like, optional) – When given, indicated_change and realized_change (both premium-weighted against current) are also reported.

  • by (array-like, optional) – Group labels; returns one row per group (a DataFrame) instead of a Series – which segments absorbed the capping is usually the actionable question.

Returns:

Metrics: n, exposure, n_below / exposure_below / premium_shortfall (proposed < indicated), n_above / exposure_above / premium_excess (proposed > indicated), indicated_premium, proposed_premium, remaining_change (indicated/proposed - 1, the future rate action still owed), and – with current_rateindicated_change and realized_change.

Return type:

pandas.Series or pandas.DataFrame

class RatingPlan(base_rate: float, factors: ~typing.Mapping[str, ~ratingmodels.relativity.FactorTable] = <factory>)[source]

Bases: object

A complete multiplicative rating plan.

Parameters:
  • base_rate (float) – The rate at base levels of every variable (per exposure unit).

  • factors (mapping of str -> FactorTable) – One table per rating variable, keyed by variable name.

Notes

RatingPlan.from_model(model) builds a plan directly from a fitted GLMRelativities or FrequencySeverityModelto_factor_tables() supplies the factors and base_value_ the base rate.

average_relativity(data: DataFrame, columns: Mapping | None = None, exposure: str | None = None) Series[source]

Exposure-weighted average factor per variable, and combined.

The plan’s off-balance diagnostic: a combined average of 1.0 means the factors are balanced on this census; anything else is what a base-rate correction would need to absorb.

classmethod from_dict(d: Mapping) RatingPlan[source]

Rebuild a plan from to_dict() output.

classmethod from_model(model, base_rate: float | None = None) RatingPlan[source]

Build a plan from a fitted model.

model needs to_factor_tables() and base_value_ – both GLMRelativities and FrequencySeverityModel qualify. base_rate overrides the fitted base (e.g. after an off-balance correction).

rate(data: DataFrame, columns: Mapping | None = None, exposure: str | None = None, unknown: str = 'default') DataFrame[source]

Rate every row: the full multiplicative build-up, decomposed.

Parameters:
  • data (DataFrame) – One row per unit to rate.

  • columns (mapping, optional) – Rating variable -> column name, where names differ.

  • exposure (str, optional) – Exposure column; adds a premium column (rate x exposure).

  • unknown ({"default", "error"}) – Policy for levels the plan has no factor for. "default" applies the table’s default; "error" raises, listing every offending (variable, level).

Returns:

Index-aligned with data: base_rate, one {variable}_factor per variable, combined_relativity, rate, and premium when exposure is given.

Return type:

pandas.DataFrame

to_dict() dict[source]

A plain-dict form for filing, audit, and version control.

Round-trips exactly through from_dict(). If the dict will pass through JSON, note that JSON object keys are always strings: non-string level keys (e.g. integer territory codes) come back as strings, and lookups against the original typed levels will then fall to the default. Use string levels for JSON-borne plans.

validate(data: DataFrame, columns: Mapping | None = None) DataFrame[source]

Levels present in data that the plan has no factor for.

Returns:

Indexed by (variable, level) with column n (row count). Empty means every level is covered. Run this before rating a new census; anything listed here is what unknown="default" would silently default and unknown="error" would refuse.

Return type:

pandas.DataFrame

class PlanComparison(current_rate: Series, proposed_rate: Series, exposure: Series)[source]

Bases: object

Per-case comparison of two rating plans on one census.

by(labels) DataFrame[source]

Premium-weighted average change per group – who absorbs the move.

labels is an array/Series aligned with the census rows.

property change: Series

Per-case rate change, proposed/current - 1.

dislocation(bands=(-0.10, -0.05, 0.0, 0.05, 0.10)) DataFrame[source]

The banded dislocation exhibit; see rate_dislocation().

summary() Series[source]

The one-screen comparison: premiums, average change, direction.

compare_rating_plans(current: RatingPlan, proposed: RatingPlan, data: DataFrame, columns: Mapping | None = None, exposure: str | None = None, unknown: str = 'default') PlanComparison[source]

Rate one census under two plans and compare.

Both plans are applied to the same rows with the same column mapping and unknown-level policy, so every difference in the result is a plan difference, not a data difference.

Returns:

With summary(), dislocation(), by(labels), and the per-case change.

Return type:

PlanComparison

class ExperienceExhibit(earned_premium: object, losses: object, on_level_factors: object = 1.0, trend_factors: object = 1.0, development_factors: object = 1.0, weights: object | None = None, period_labels: Sequence | None = None)[source]

Bases: object

Assemble experience periods into the inputs a rate indication takes.

RateIndication consumes point inputs – a trended, developed experience loss cost and an on-level premium. This is the object that produces them from per-period columns, with every adjustment a visible column of the worksheet: premium times its on-level factor, losses times development and trend, a loss ratio per period, and the weighted total. The natural factor producers are on_level_factors() (the on_level_factor column) and actuarialpy.reserving.ChainLadder (per-origin development factors), but any source works – this object composes, it does not re-derive.

Parameters:
  • earned_premium (array-like) – Earned premium per experience period, at historical rate level.

  • losses (array-like) – Incurred losses per period (at whatever development and trend level the factor arguments then adjust for).

  • on_level_factors (scalar or array) – Per-period multiplicative adjustments. Default 1.0.

  • trend_factors (scalar or array) – Per-period multiplicative adjustments. Default 1.0.

  • development_factors (scalar or array) – Per-period multiplicative adjustments. Default 1.0.

  • weights (array-like, optional) – Weights for the diagnostic weighted loss ratio. Default: on-level premium, making the weighted ratio identical to the aggregate ratio – which is also the convention to_indication() uses, since the indication is built from period totals.

  • period_labels (sequence, optional) – Exhibit index; defaults to 0..n-1.

property adjusted_losses: float

Total developed, trended losses across the periods.

exhibit() DataFrame[source]

The worksheet: one row per period, every adjustment a column.

property experience_loss_ratio: float

Weighted per-period loss ratio (aggregate ratio by default).

classmethod from_experience(exp: Experience, *, freq: str = 'YE', on_level_factors: object = 1.0, trend_factors: object = 1.0, development_factors: object = 1.0, weights: object | None = None) ExperienceExhibit[source]

Build the worksheet from the canonical Experience.

Premium comes from the bound revenue role and losses from the bound expense role, summed per freq period of the bound date role (annual by default). The factor arguments stay explicit – on-leveling, development, and trend are judgment this object composes, not derives.

property on_level_premium: float

Total on-level earned premium across the periods.

to_indication(manual_loss_cost: float, credibility: float, current_rate: float, exposure: float, retention: RetentionLoad | None = None, target_loss_ratio: float = 0.85, **kwargs) RateIndication[source]

Wire the assembled totals into a RateIndication.

experience_loss_cost becomes adjusted_losses / exposure and current_premium becomes on_level_premium, so the indication’s own experience_loss_ratio() reproduces this exhibit’s aggregate ratio exactly. exposure is the total over the experience period, in the same units as manual_loss_cost and current_rate. Remaining keyword arguments (trend_total_factor, benefit_factor, …) pass through.

on_level_factors(periods, rate_changes, policy_term: float = 1.0, current_date=None) DataFrame[source]

On-level factors per experience period, by the exact parallelogram.

Parameters:
  • periods (sequence of (start, end)) – Experience periods, start < end. Floats or datetime-likes.

  • rate_changes (sequence of (date, change)) – Rate-change history as decimal changes (0.08 for +8%). Multiple changes on one date compound. May be empty (all factors 1.0).

  • policy_term (float) – Policy term in the same units as float dates (years for datetime input). 0 means premium is earned the instant it is written – the in-force approximation; 1.0 is the classic annual-policy parallelogram.

  • current_date (optional) – The “as of” for the current rate level. Default: after every listed change (the full cumulative index). Pass a date to exclude changes after it.

Returns:

One row per period: period_start, period_end, average_earned_index, current_index, on_level_factor.

Return type:

pandas.DataFrame

Examples

The textbook case – one +10% change mid-year, annual policies, calendar year period: an eighth of the earned premium sits in the new-rate triangle, so the average index is 1.0125 and the factor 1.1/1.0125.

pooling_charge_from_severity(severity, pooling_point: float, expected_frequency: float, expense_ratio: float = 0.0, risk_margin: float = 0.0) Series[source]

Expected pooling charge per exposure unit, decomposed.

Parameters:
  • severity – Any object with sf(x) and mean_excess(d) (see module docstring). Both must accept a float and return a float.

  • pooling_point (float) – The per-claim attachment d above which losses are pooled.

  • expected_frequency (float) – Expected claims per exposure unit (the same exposure unit the charge should be quoted in).

  • expense_ratio (float) – Expense provision as a share of the charge: the pure charge is divided by 1 - expense_ratio. Must lie in [0, 1).

  • risk_margin (float) – Proportional loading on the pure excess cost, applied before the expense gross-up.

Returns:

The build-up, each step auditable: exceedance_probability (\(S(d)\)), mean_excess (\(e(d)\)), expected_excess_per_claim (\(S(d)\,e(d) = E[(X-d)_+]\)), pure_excess_cost (frequency \(\times\; E[(X-d)_+]\)), and pooling_charge (after margin and expense gross-up). The final value is what experience_rate expects as its pooling_charge input.

Return type:

pandas.Series

Raises:
  • TypeError – If severity lacks the protocol methods.

  • ValueError – For an infinite mean excess (a tail with \(\xi \ge 1\) has no finite pooling cost at any attachment) or invalid loadings.

renew(current_rate: float | int | ndarray | Series | Sequence[float], indicated_rate: float | int | ndarray | Series | Sequence[float], cap: float | int | ndarray | Series | Sequence[float] | None = None, floor: float | int | ndarray | Series | Sequence[float] | None = None, round_to: int | None = 2) RenewalAction[source]

Apply caps/floors (and optional rounding) to an indicated rate.

Elementwise: Series in, Series-valued RenewalAction out. cap and floor may be scalars or per-row vectors.

class RenewalAction(current_rate: float | int | ndarray | Series | Sequence[float], indicated_rate: float | int | ndarray | Series | Sequence[float], proposed_rate: float | int | ndarray | Series | Sequence[float], indicated_change: float | int | ndarray | Series | Sequence[float], proposed_change: float | int | ndarray | Series | Sequence[float], capped: bool | ndarray | Series)[source]

Bases: object

Result of renew(). Fields are floats for a scalar renewal and Series/arrays for a vectorized one.

to_frame() DataFrame[source]

One tidy row per renewal (a single row for a scalar action).

unit_level_renewal(census: DataFrame, base_rate: float | int | ndarray | Series | Sequence[float], factor_cols: list[str], count_col: str = 'count') DataFrame[source]

Re-rate each census row as base_rate * product(factor_cols).

Returns the census with unit_rate and premium columns; the group total is the sum of premium. Fully vectorized – base_rate may be a scalar or a per-row vector.

class PricingEvaluation(loss_cost: float | int | ndarray | Series | Sequence[float], current_rate: float | int | ndarray | Series | Sequence[float], retention: RetentionLoad | None = None, exposure: float | int | ndarray | Series | Sequence[float] | None = None, persistency: float | int | ndarray | Series | Sequence[float] | None = None)[source]

Bases: object

A case’s pricing state, evaluable at arbitrary rate actions.

Parameters:
  • loss_cost (float) – Expected loss cost per exposure unit over the rating period (trended, pooled, credibility-blended – e.g. RateIndication.blended_loss_cost()).

  • current_rate (float) – Current charged rate per exposure unit that rate changes apply to.

  • retention (RetentionLoad, optional) – Expense structure. When omitted, no expenses are modeled: margin equals gross margin (premium less losses) and the inverse solve reduces to \(P = L / (1 - m)\).

  • exposure (float, optional) – Rating-period exposure units; enables dollar outputs.

  • persistency (float in [0, 1], optional) – Renewal probability; enables expected_* outputs (premium and margin scaled by the probability the case is still on the books).

at(rate_change: float | int | ndarray | Series | Sequence[float], *, name: str | None = None) ScenarioOutcome[source]

Evaluate the case at a given proportional rate change.

Elementwise: with a vector evaluation and/or a vector of rate changes, every field of the outcome is a Series/array per case.

classmethod from_indication(indication: RateIndication, *, exposure: float | None = None, persistency: float | None = None) PricingEvaluation[source]

Adopt a RateIndication’s blended loss cost, rate, and retention.

With a retention on the indication, evaluating at indicated_rate_change() returns a margin ratio equal to the retention’s profit_margin. Without one the indication grosses by target loss ratio, expenses are unmodeled here, and margin equals gross margin.

premium_for_margin(target_margin: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Charged rate (per exposure unit) at which the margin ratio equals the target.

Closed form: \(P = (L(1+\text{lae}) + F) / (1 - V - m)\). The target may be negative (a planned loss) but must satisfy \(m < 1 - V\) for a positive, finite rate.

rate_change_for_margin(target_margin: float | int | ndarray | Series | Sequence[float]) float | int | ndarray | Series | Sequence[float][source]

Proportional rate change that yields the target margin ratio.

zero_margin_rate_change() float | int | ndarray | Series | Sequence[float][source]

Rate change at which the underwriting margin is exactly zero.

class ScenarioOutcome(name: str | None, rate_change: float | int | ndarray | Series | Sequence[float], premium_rate: float | int | ndarray | Series | Sequence[float], loss_cost: float | int | ndarray | Series | Sequence[float], loss_and_lae: float | int | ndarray | Series | Sequence[float], expense_rate: float | int | ndarray | Series | Sequence[float], loss_ratio: float | int | ndarray | Series | Sequence[float], gross_margin_rate: float | int | ndarray | Series | Sequence[float], margin_rate: float | int | ndarray | Series | Sequence[float], margin_ratio: float | int | ndarray | Series | Sequence[float], exposure: float | int | ndarray | Series | Sequence[float] | None = None, persistency: float | int | ndarray | Series | Sequence[float] | None = None, premium: float | int | ndarray | Series | Sequence[float] | None = None, gross_margin: float | int | ndarray | Series | Sequence[float] | None = None, margin: float | int | ndarray | Series | Sequence[float] | None = None, expected_premium: float | int | ndarray | Series | Sequence[float] | None = None, expected_margin: float | int | ndarray | Series | Sequence[float] | None = None)[source]

Bases: object

Result of evaluating one case at one rate action.

Per-exposure fields are always present. Dollar fields require exposure on the evaluation and are None otherwise; expected_* fields additionally require persistency and are the renewal-probability-weighted expectations (the deterministic counterpart of a retention Bernoulli).

as_dict() dict[str, Any][source]

Plain-dict view, one tidy row.

to_frame() DataFrame[source]

Tidy view: one row per case (a single row for a scalar outcome).

Vector outcomes take their row index from the evaluation’s Series inputs; columns whose inputs were not supplied (premium without exposure, …) are omitted.

scenario_frame(cases: Mapping[Any, PricingEvaluation] | PricingEvaluation, scenarios: Mapping[str, float | int | ndarray | Series | Sequence[float] | Mapping[Any, float]]) DataFrame[source]

Evaluate named rate actions across cases into one tidy long table.

Parameters:
  • cases (Mapping[case_id, PricingEvaluation] or PricingEvaluation) – The book: either a mapping of scalar evaluations keyed however the caller identifies cases, or a single vector evaluation built from columns, whose Series index provides the case ids.

  • scenarios (Mapping[str, float | array-like | Mapping[case_id, float]]) – Each scenario is a rate change: a single float applied to every case, a per-case vector aligned with a vector evaluation, or a per-case mapping. A per-case mapping must cover every case – a missing action is an error, not a silent skip.

Returns:

One row per (case, scenario): case, scenario, rate_change, per-exposure economics, and dollar / persistency-weighted columns where the evaluation carries exposure and persistency. Any summary view – a cohort rollup, a key-case exhibit – is a pivot or groupby of this table.

Return type:

pd.DataFrame

uplift_for_target_margin(cases: Mapping[Any, PricingEvaluation] | PricingEvaluation, base_changes: Mapping[Any, float] | float | int | ndarray | Series | Sequence[float], target_margin: float, *, mode: str = 'multiplicative', weight_by_persistency: bool = True) float[source]

Uniform uplift to a book’s rate actions that holds an aggregate margin.

Answers the exhibit input “to achieve the same target margin, rate actions must be X% higher”: when achieved actions slip below formula (concessions, caps), this is the across-the-board adjustment that restores the book’s aggregate margin ratio to the target.

Let case \(g\) have base premium \(P_g\) (at its base change), per-unit cost \(K_g = L_g(1+\text{lae}_g) + F_g\), variable load \(V_g\), and weight \(w_g\) = exposure units (times persistency when weight_by_persistency). The aggregate margin ratio is

\[m(P) = \frac{\sum_g w_g \left(P_g (1 - V_g) - K_g\right)} {\sum_g w_g P_g},\]

which is a ratio of functions affine in the uplift, so the solve is closed-form – no iteration:

  • multiplicative – new change \(a_g' = (1 + a_g)(1 + u) - 1\), so \(P_g(u) = P_g (1+u)\) and with \(A = \sum w P (1-V)\), \(B = \sum w K\), \(C = \sum w P\):

    \[1 + u = \frac{B}{A - m^\* C}.\]
  • additive – new change \(a_g' = a_g + u\), so \(P_g(u) = P_g + r_g u\) with current rate \(r_g\), and with \(A' = \sum w r (1-V)\), \(C' = \sum w r\):

    \[u = \frac{B + m^\* C - A}{A' - m^\* C'}.\]

Returns the uplift u. Feasibility (a positive solution exists and every resulting premium is positive) is validated with explicit errors.