Example 10: the pinned ratio, two ways¶
Some experience-rated contracts fix a loss ratio and solve the premium from
it. That inverts the usual build-up — the ratio is the input, the rate is the
output, and the renewal “selection” is formulaic: the one case where the
indication is the action, because the contract says so. This page runs the
two standard pins side by side on the same block. Under the gross pin,
claims / premium = LR*, premium is a function of the claims projection
alone and expenses are exhibit-only. Under the net pin,
claims / (premium − expenses) = LR*, the dollar expenses are projected
before the solve and feed the premium. Same nominal 0.85, materially
different deals — including one that loses money.
The constructors here arrived in ratingmodels 0.7.3. Every number on this
page is the output of this exact deterministic run, pinned by regression
tests in the projectionmodels and ratingmodels suites.
The pins, as algebra¶
Both are parameterizations of the fundamental insurance equation. The gross pin leaves no degree of freedom for expenses:
The net pin nets expenses \(E = F + VP\) out of the denominator — the dollar loads \(F\) ride the numerator, the percent-of-premium loads \(V\) cannot be “projected first” (they are circular in \(P\)) and divide instead:
One consequence worth holding onto: the net pin’s margin is claims-proportional, \(P - E - C = C\,(1-\text{LR}^*)/\text{LR}^*\), while the gross pin makes expenses and margin fight over the same \(P(1-\text{LR}^*)\).
The claims, projected¶
Twelve months of mature experience, two claim types, type-keyed trend. The contract pins the annual ratio, so the contract-year grain — exposure weighted — is the one that matters.
import numpy as np
import pandas as pd
from actuarialpy import Experience
import projectionmodels as pm
import ratingmodels as rm
KEYS = ["group_id"]
RENEWAL = pd.Timestamp("2027-07-01")
horizon = pm.ProjectionHorizon("2026-07-01", periods=24)
exposure = pd.DataFrame({
"group_id": "A",
"projection_period": pd.period_range("2026-07", periods=24, freq="M").astype(str),
"member_months": np.linspace(1_000.0, 1_046.0, 24).round(0),
})
months = pd.date_range("2025-07-01", periods=12, freq="MS")
history = pd.DataFrame(
[{"group_id": "A", "claim_type": ct, "incurred_month": m,
"reported_claims": base * (1 + tr) ** (i / 12) * 1_000.0,
"member_months": 1_000.0}
for ct, base, tr in (("inpatient", 260.0, 0.075),
("outpatient", 190.0, 0.060))
for i, m in enumerate(months)])
experience = Experience(
history, expense="reported_claims", exposure="member_months",
date="incurred_month", dimensions=[*KEYS, "claim_type"])
claim_trend = pm.TrendAssumption.from_values(
"claim_trend",
pd.DataFrame({"claim_type": ["inpatient", "outpatient"],
"annual_trend": [0.075, 0.060]}),
lookup=["claim_type"], rate_col="annual_trend")
claims = pm.project(
experience, exposure=exposure, exposure_col="member_months",
horizon=horizon, trend=claim_trend,
).project().summarize(by=["group_id", "projection_period"],
measures=["member_months", "projected_claims"])
claims["cy"] = np.where(claims["projection_period"] < "2027-07", "CY1", "CY2")
annual = claims.groupby("cy")[["member_months", "projected_claims"]].sum()
c1, c2 = (annual["projected_claims"] / annual["member_months"])[["CY1", "CY2"]]
# c1 = 497.3488, c2 = 531.5452 — blended claims trend +6.8757%
The solve, per contract¶
The only stage where the two contracts differ. Full credibility, because the
contract has already made the selection; the RateIndication call is
identical either way — retention= absorbs the pin.
gross = rm.RetentionLoad.from_gross_loss_ratio(
0.85, variable_items={"commission": 0.03})
net_cy1 = rm.RetentionLoad.from_net_loss_ratio(
0.85, fixed_expense=f1, variable_items={"commission": 0.03})
net_cy2 = rm.RetentionLoad.from_net_loss_ratio(
0.85, fixed_expense=f2, variable_items={"commission": 0.03})
def solve(contract_cy1, contract_cy2):
rate1 = contract_cy1.gross_rate(c1)
action = rm.RateIndication(
experience_loss_cost=c2, manual_loss_cost=c2, credibility=1.0,
current_rate=rate1, retention=contract_cy2,
).indicated_rate_change()
return rate1, action
gross_rate1, gross_action = solve(gross, gross)
# 585.1162, +6.8757% — the action IS the claims trend, identically
net_rate1, net_action = solve(net_cy1, net_cy2)
# 678.3747, +6.5667% — the slower expense trend dilutes the action
The gross action reproducing the claims trend is not a coincidence, it is the contract: \(P_2/P_1 = C_2/C_1\) when both are \(C/\text{LR}^*\). The net action sits 31 basis points lower because \(F\) trends at roughly 1%–3% while claims trend at 6.9% — expense discipline moderates the renewal, mechanically.
The exhibit¶
Aggregate to the contract year, check the ratio the contract actually pins, and let the margin say what kind of deal was signed.
def exhibit(pin, rate1, action):
premium = project_premium(rate1, action)
booking = project_booking(premium)
for frame in (premium, booking):
frame["cy"] = np.where(frame["projection_period"] < "2027-07", "CY1", "CY2")
C = annual["projected_claims"]
P = premium.groupby("cy")["premium"].sum()
E = booking.groupby("cy")["projected_expense"].sum()
ratio = C / P if pin == "gross" else C / (P - E)
margin = P * 0.15 - E if pin == "gross" else P - E - C
return ratio.round(4), margin.round(0)
exhibit("gross", gross_rate1, gross_action)
# ratio 0.8500 / 0.8500 margin −32,676 / −10,495
exhibit("net", net_rate1, net_action)
# ratio 0.8500 / 0.8500 margin 1,064,794 / 1,165,022
gross pin |
net pin |
|
|---|---|---|
in-force rate |
585.12 |
678.37 |
renewal action |
+6.88% |
+6.57% |
contract ratio, CY1 / CY2 |
0.8500 / 0.8500 |
0.8500 / 0.8500 |
margin, CY1 |
−32,676 |
1,064,794 |
margin, CY2 |
−10,495 |
1,165,022 |
Both contracts hold their ratio to the fourth decimal — annually. Monthly, the ratio wobbles as trend accrues against a rate that steps only at renewal; asserting the pin at any finer grain than the contract’s own is a false alarm generator. The margins are the story: the same nominal 0.85 is a 9% premium difference and, with a hospital surcharge riding the expense stack, the gross-pinned deal is underwater — fifteen retention points cannot cover roughly ninety per-member-month of expenses. The gross pin never consults the expense projection when setting premium, which is exactly why the exhibit has to; the net pin prices the expenses in and banks a claims-proportional margin. Which ratio a contract pins is not a bookkeeping detail. It is the deal.