Example 1: experience to a renewal rate

The first two boxes of the workflow, end to end: read a monthly experience panel with actuarialpy — frequency-severity, trend decomposition, seasonality, credibility — then carry the projected loss cost into a ratingmodels indication and constrain it into a bookable renewal. Every number on this page is the output of this exact fixed-seed run, pinned by a regression test in the ratingmodels suite.

The panel

Three years of monthly experience for one block, two segments with a shifting mix (the HMO segment grows while the PPO shrinks), genuine seasonality, and frequency and severity trends of +2% and +4.5% a year baked into the generator:

import numpy as np
import pandas as pd
import actuarialpy as ap
import ratingmodels as rm

rng = np.random.default_rng(42)
months = pd.date_range("2023-01-01", "2025-12-01", freq="MS")
rows = []
for seg, mm0, growth, f0, s0 in [("ppo", 5200, -0.010, 0.30, 950.0),
                                 ("hmo", 3100, +0.055, 0.34, 880.0)]:
    for i, m in enumerate(months):
        yrs = i / 12.0
        mm = mm0 * (1 + growth) ** yrs
        season = 1.0 + 0.06 * np.cos(2 * np.pi * (m.month - 1.5) / 12)
        freq = f0 * 1.02 ** yrs * season * (1 + rng.normal(0, 0.015))
        sev = s0 * 1.045 ** yrs * (1 + rng.normal(0, 0.01))
        cc = freq * mm
        rows.append((m, seg, mm, cc, cc * sev, 393.0 * mm))
df = pd.DataFrame(rows, columns=["month", "segment", "member_months",
                                 "claim_count", "allowed", "premium"])
df["year"] = df["month"].dt.year

Read the experience

Bind the column roles once and every view derives from them:

exp = ap.Experience(df, expense="allowed", revenue="premium",
                    exposure="member_months", date="month", count="claim_count")

exp.frequency_severity(groupby="year")
#  year  frequency  severity  loss_per_exposure
#  2023     0.3182    938.35             298.58
#  2024     0.3256    980.67             319.29
#  2025     0.3314   1024.87             339.60

The identity loss_per_exposure == frequency * severity holds on every row — see Rates, exposure, and decomposition.

Decompose the change

Where did the 2024 → 2025 change come from? With mix_by, the LMDI split separates within-segment frequency and severity movement from the effect of the book shifting toward the HMO segment:

d = exp.decompose_trend(period_col="year", prior_period=2024,
                        current_period=2025, mix_by="segment").iloc[0]
# loss_per_exposure : 319.29 -> 339.60   (trend 1.0636)
# frequency_trend 1.0156   severity_trend 1.0465   mix_trend 1.0007
# effects: frequency +5.09  severity +14.98  mix +0.24  (sum +20.31, exact)

The generator’s +2% frequency and +4.5% severity come back almost exactly; the mix term is small because the segments’ cost levels are close. Both reconciliations — multiplicative and additive — are exact by construction.

Seasonality and trend

Fit monthly factors on the aggregated panel, deseasonalize, and fit the underlying trend on what remains:

dm = df.groupby("month", as_index=False)[["allowed", "member_months"]].sum()
factors = ap.seasonality_factors(dm, date_col="month", value_col="allowed",
                                 exposure_col="member_months")
# January 1.049, July 0.940 — the winter peak the generator planted

dm2 = ap.deseasonalize(dm, factors, date_col="month", value_col="allowed")
fit = ap.fit_trend(dm2, value_col="allowed_deseasonalized",
                   date_col="month", exposure_col="member_months")
# annual_trend 0.0666   r² 0.961   (true combined trend: 1.02 × 1.045 − 1 = 6.6%)

Project and blend

Trend the 2025 loss cost 18 months to the rating-period midpoint, take the credibility from the claim volume, and blend against a manual:

proj = 339.60 * ap.trend_factor(fit.annual_trend, months=18)   # -> 374.10

std = ap.full_credibility_claims(severity_cv=1.2)               # -> 2,641 claims
z = ap.limited_fluctuation_z(34_234, std)                       # -> 1.000
ap.limited_fluctuation_z(1_200, std)                            # -> 0.674 for a small case

manual = rm.ManualRate(base_loss_cost=248.0, factors={"area": 1.06, "industry": 0.97})
ind = rm.RateIndication(experience_loss_cost=proj,
                        manual_loss_cost=manual.loss_cost(),   # 254.99
                        credibility=z, current_rate=393.0,
                        target_loss_ratio=0.85)
ind.indicated_rate()          # -> 440.11
ind.indicated_rate_change()   # -> +12.0%

At 34,000 claims the block is fully credible even on the aggregate-loss standard, so the manual carries no weight here — the second line shows the credibility a 1,200-claim case would get, which is where the blend earns its keep.

Constrain it, and price the constraint

A renewal corridor caps the change; the pricing layer then says exactly what the cap costs:

final = rm.corridor(current_rate=393.0, indicated_rate=ind.indicated_rate(),
                    max_up=0.09, max_down=0.03)               # -> 428.37 (+9.0%)

ret = rm.RetentionLoad(fixed_expense=22.0, variable_expense_ratio=0.10,
                       profit_margin=0.02)
pe = rm.PricingEvaluation(loss_cost=proj, current_rate=393.0, retention=ret)
pe.at(ind.indicated_rate() / 393.0 - 1).margin_rate   # ->  0.00% of premium
pe.at(final / 393.0 - 1).margin_rate                  # -> −2.47% of premium

The margin at the indicated rate is exactly zero — the 0.85 target and this retention describe the same economics, so the two layers agree by identity, not coincidence. The corridor releases 9 of the indicated 12 points; the remaining 3 show up as a −2.47% margin at the capped rate. That is the cost of the constraint, quantified.