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\) whencv_severityis 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().nandn_fullare in consistent units (claims, policies, exposure units, …). Elementwise: a Series ofnreturns a Series ofZ.
- 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 ofZ.
- 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 aBuhlmannStraubResultwith per-group credibility and credibility-weighted means.- Parameters:
data (DataFrame) – Long-format data: one row per (group, period).
group (str) – Column names.
valueis the per-unit observation (e.g. loss per member-month);exposureis the weight \(m_{ij}\).period (str) – Column names.
valueis the per-unit observation (e.g. loss per member-month);exposureis the weight \(m_{ij}\).value (str) – Column names.
valueis the per-unit observation (e.g. loss per member-month);exposureis the weight \(m_{ij}\).exposure (str) – Column names.
valueis the per-unit observation (e.g. loss per member-month);exposureis 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:
objectResult 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:
objectA 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_levelhas 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
GLMRelativitieswhen variables are correlated.
- class GLMRelativities(family: str = 'poisson', var_power: float | None = None, max_iter: int = 100, tol: float = 1e-08)[source]¶
Bases:
objectGLM (log-link) relativity estimator, fit via statsmodels.
- Parameters:
family ({"poisson", "gamma", "tweedie"}) – Response distribution.
"tweedie"requiresvar_powerin (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
- 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
predictorsagainstresponse.Aggregate vs. rate responses.
exposureenters 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 passexposure; pass it asweightsinstead, 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 insideFrequencySeverityModelis fit).An explicit
offsetcolumn (already on the log scale) may also be supplied.weightsare variance weights (statsmodelsvar_weights): the variance of row \(i\) is \(\phi V(\mu_i)/w_i\).base_levelsmaps 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).
exposuremultiplies the mean;offsetis 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; columnspredicted,ci_low,ci_high. Withexposure, 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 columnscoef,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 topearson_chi2_on the training data."deviance"– \(\mathrm{sign}(y-\hat\mu)\sqrt{w\,d_i}\); the squared deviance residuals sum todeviance_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
FactorTableobjects.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.0for unknown levels – matching howpredict()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 fromrelativity_table()(and cat x cat cells fromrelativities_["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:
objectA pure-premium model composed of a frequency GLM and a severity GLM.
- Parameters:
frequency (GLMRelativities, optional) – Unfitted component models. Default
family="poisson"for frequency andfamily="gamma"for severity – the classical pairing.severity (GLMRelativities, optional) – Unfitted component models. Default
family="poisson"for frequency andfamily="gamma"for severity – the classical pairing.
- frequency, severity
The fitted component models (all their diagnostics –
relativity_table,residuals,summary– apply per part).- Type:
- 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
predicttreats 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_countand totalclaim_amountover 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 incombined_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 incombined_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 > 0andclaim_amount > 0, with responseclaim_amount / claim_countand prior weightclaim_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; columnspredicted,ci_low,ci_high. Withexposure, all three are on the total scale.predictedequalspure_premium_prediction()exactly.- Return type:
pandas.DataFrame
Expected loss: total (with
exposure) or per exposure unit.Exactly
frequency_prediction(data, exposure) * severity_prediction(data)– the frequency x severity identity.
- to_factor_tables() dict[source]¶
Combined pure-premium relativities as
FactorTableobjects.One table per variable, built from the
combinedcolumn ofcombined_relativities()(frequency x severity) withdefault=1.0for unknown levels – the pure-premium plan you would actually apply, ready for the build-up and renewal machinery. Interaction terms are excluded (aFactorTableis single-variable by contract); read their cells fromcombined_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 offactor(each row is one observation of its level), viaratingmodels.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 totalresponseandfull_credibilityis 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
observedandrelativitycolumns 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 intoother_labelbefore 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_exposureand its row count at leastmin_n. At least one must be given.min_n (float / int, optional) – Keep a level only if its total exposure is at least
min_exposureand its row count at leastmin_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 iflevelswas a Series, else an ndarray).summary– a DataFrame indexed by original level with columnsn,exposure,collapsed; apply the same recode to future data by mapping levels wherecollapsedis True.- Return type:
(recoded, summary)
- gini_coefficient(actual, predicted, exposure=None, normalize: bool = True, by=None) float | Series[source]¶
Ordered-Lorenz Gini of
predictedas a risk ranker foractual.- 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
actualitself, 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
predictedand split inton_bandsbands of (approximately) equal total exposure. Within each band the table reports exposure, the exposure-weighted actual and predicted means, andlift– 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. Withby(group labels aligned withactual), 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 inton_bandsgroups 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 – soactualandpredictedare treated symmetrically and should both be on the total scale, as frommodel.predict(df, exposure=...). A well-calibrated model hasae_rationear 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. Withby(group labels aligned withactual), 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.
expectedshould be on the same total scale asactual(e.g. include exposure), as frommodel.predict(df, exposure=...).expected (array-like) – Observed outcomes and model expectations, row-aligned.
expectedshould be on the same total scale asactual(e.g. include exposure), as frommodel.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-namedmodel_1,model_2, …). Each model must expose the fitted interface (predict, family deviance); i.e. anyGLMRelativities-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 metricsdeviance,null_deviance,deviance_explained,gini,ae_ratio, andcalibration_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, andcalibration_errorare 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_fractionof 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.
trainholds rows withdata[date] < cutoffandtestthe rest.- Parameters:
data (DataFrame)
date (str) – Column to cut on. Datetime-like columns coerce
cutoffthroughpandas.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:
objectBuild 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
retentionis 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).
- 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:
objectDevelop 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_claimsandexposureare the sums of the bound expense and exposure roles. Withpooling_point(andclaimant_colnaming the claimant identifier), each claimant’s total is capped at the pooling point and the excess feedspooled_excess– the same splitpool_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).
- 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 singleExperienceRate(the primitive: one experience-rated group, one worksheet row). Withby, 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)whereexcess = sum(max(0, claim - pooling_point)).With
by(group labels aligned withclaims), pooling is applied within each group and both returns are Series indexed by group – onegroupbypass 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
extremelosspackage); this helper gives the simple group-level estimate. Withby, the charge is computed per group (exposurethen 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_costto a charged base rate with aratingmodels.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
relativityis 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:
objectResult 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
relativitycolumn or asfactor_cols(per-row factors that are multiplied together). Withby(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:
objectA single build-up operation.
operandis the factor (multiply / segment), amount (add), or value (start);weightis used bysegment_multiplyonly. 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
amountto 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
factorto a fractionweightof the running total.\(\text{running} \leftarrow \text{running}\,(1 - w + w f)\).
- evaluate(steps: Sequence[Step]) BuildUpResult[source]¶
Run an ordered sequence of
Stepand return aBuildUpResult.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:
objectFluent 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:
objectResult 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 anentitycolumn carrying the shared Series index (or positions). Forsegment_multiplytheoperandshown 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
- 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_rateis the participating sharep; 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:
objectExpense 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 inprofit_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_ratioon the returned instance holds \((1-\text{LR}^*)/\text{LR}^*\), not a loss adjustment expense. If the contract’s claims measure includes LAE, passloss_costinclusive of LAE rather than settinglae_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()returnsloss_ratioidentically 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 ofimplied_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:
objectDevelop 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
Zassigned 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 whenZ = 0),benefitanddemographic– supplied adjustment factors.
Any remaining movement (rate adequacy / loading) is absorbed by an explicit
residualfactor 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, aresidualfactor 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:
objectResult of
decompose_rate_change().For a scalar decomposition
factorsandcontributionsare Series indexed by driver. For a vectorized one they are DataFrames (rows = cases, columns = drivers) andtotal_factoris 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).capandfloormay 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 tostepgrid.
- 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 * exposureis premium. Premium equals rate (and counts weight equally) when omitted.bands (sequence of float) – Interior band edges as decimal changes, e.g.
-0.05for -5%. Edges are extended with-inf/+inf, sokedges givek + 1bands; 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_changeandrealized_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 – withcurrent_rate–indicated_changeandrealized_change.- Return type:
pandas.Series or pandas.DataFrame
- class RatingPlan(base_rate: float, factors: ~typing.Mapping[str, ~ratingmodels.relativity.FactorTable] = <factory>)[source]¶
Bases:
objectA 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 fittedGLMRelativitiesorFrequencySeverityModel–to_factor_tables()supplies the factors andbase_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.
modelneedsto_factor_tables()andbase_value_– bothGLMRelativitiesandFrequencySeverityModelqualify.base_rateoverrides 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
premiumcolumn (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}_factorper variable,combined_relativity,rate, andpremiumwhenexposureis 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
datathat the plan has no factor for.- Returns:
Indexed by
(variable, level)with columnn(row count). Empty means every level is covered. Run this before rating a new census; anything listed here is whatunknown="default"would silently default andunknown="error"would refuse.- Return type:
pandas.DataFrame
- class PlanComparison(current_rate: Series, proposed_rate: Series, exposure: Series)[source]¶
Bases:
objectPer-case comparison of two rating plans on one census.
- by(labels) DataFrame[source]¶
Premium-weighted average change per group – who absorbs the move.
labelsis 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().
- 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-casechange.- Return type:
- 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:
objectAssemble experience periods into the inputs a rate indication takes.
RateIndicationconsumes 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 areon_level_factors()(theon_level_factorcolumn) andactuarialpy.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.
- 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
revenuerole and losses from the boundexpenserole, summed perfreqperiod of the bounddaterole (annual by default). The factor arguments stay explicit – on-leveling, development, and trend are judgment this object composes, not derives.
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_costbecomesadjusted_losses / exposureandcurrent_premiumbecomeson_level_premium, so the indication’s ownexperience_loss_ratio()reproduces this exhibit’s aggregate ratio exactly.exposureis the total over the experience period, in the same units asmanual_loss_costandcurrent_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.08for +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).
0means premium is earned the instant it is written – the in-force approximation;1.0is 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)andmean_excess(d)(see module docstring). Both must accept a float and return a float.pooling_point (float) – The per-claim attachment
dabove 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)_+]\)), andpooling_charge(after margin and expense gross-up). The final value is whatexperience_rateexpects as itspooling_chargeinput.- Return type:
pandas.Series
- Raises:
TypeError – If
severitylacks 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
RenewalActionout.capandfloormay 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:
objectResult of
renew(). Fields are floats for a scalar renewal and Series/arrays for a vectorized one.
- 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_rateandpremiumcolumns; the group total is the sum ofpremium. Fully vectorized –base_ratemay 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:
objectA 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:
marginequalsgross 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’sprofit_margin. Without one the indication grosses by target loss ratio, expenses are unmodeled here, and margin equals gross margin.
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.
- 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:
objectResult of evaluating one case at one rate action.
Per-exposure fields are always present. Dollar fields require
exposureon the evaluation and areNoneotherwise;expected_*fields additionally requirepersistencyand are the renewal-probability-weighted expectations (the deterministic counterpart of a retention Bernoulli).
- 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.