Example 11: every package, one object¶
The whole ecosystem off a single construction call. Three source extracts —
membership, a claims listing, billing — become an ExperienceSet, and the
same book object then feeds studies, trend, projection, rating, severity
and frequency fitting, and tail fitting. Every number below is real output
from executing this page’s code against the released packages (seeded, so
it reproduces exactly).
Sources to workbook¶
import actuarialpy as ap
book = ap.ExperienceSet.from_tables(
membership, # one row per member-month
grain=["member_id", "month"], exposure="member_months",
sources=[
ap.Source(claims, expense="paid_amount", wide_by="claim_type",
date="incurred_date", name="claims"),
ap.Source(billing, revenue="premium"),
],
date="month", period="M", dimensions="group_id",
valuation_date="2026-06-30",
)
book.member_names # ('tab', 'claims')
book.reconcile() # ties each listing's totals to the tab
members: ('tab', 'claims') | tab rows: 4320
listing measure ties
claims paid_amount True
Two grain-honest members from one call: book.tab (member-month worksheet,
claim types pivoted to expense columns, grain verified unique) and
book["claims"] (the untouched listing at claim grain). reconcile()
proves nothing was dropped on the way in.
experiencestudies: the block’s performance¶
Study functions accept the set and route to the tab.
import experiencestudies as es
es.summary(book, "group_id")
group_id total_expense total_revenue loss_ratio
1102052 1349904.36 1267200.0 1.07
2203987 1041596.70 806400.0 1.29
Both groups run hot — the block needs a rate action, which the rest of the page quantifies.
actuarialpy: trend on the retained layer¶
Trend belongs on retained experience, so filter the shock layer out of the
listing first — filter is just a transformation — then aggregate and fit.
pooled = book["claims"].filter(query="paid_amount < 18_000")
monthly = (pooled.data
.groupby(pd.Grouper(key="incurred_date", freq="MS"))
["paid_amount"].sum().reset_index())
fit = ap.fit_trend(ap.Experience(monthly, expense="paid_amount",
date="incurred_date"))
AP trend (retained layer): +2.6% R2=0.01
The generator’s true trend is +7%; twelve monthly points of heavy-tailed claims recover +2.6% with an R² that says exactly how little the fit should be trusted — which is the honest reading a rate filing needs, not a defect of the tooling. Real work uses 24–36 months.
projectionmodels: the claim projection¶
project(book, ...) routes to the tab and melts the recorded claim-type
pivot into the projection dimension by itself; assumptions stay explicit
arguments.
import projectionmodels as pm
proj = pm.project(
book, exposure=future_membership,
horizon=pm.ProjectionHorizon("2027-01-01", periods=6),
trend=fit.annual_trend, credibility=0.85,
complement=pm.Assumption("manual", manual_rates,
lookup=["claim_type"], value_col="manual"),
).project()
proj.summarize(by=["group_id"])
group_id pmpm
1102052 501.87
2203987 597.37
ratingmodels: the renewal worksheet¶
One row per group off the same object; the multi-expense tab needs the
expense= selection, pooling takes the claimant and the point.
import ratingmodels as rm
rm.experience_rate(book, by="group_id",
expense=["inpatient", "outpatient"],
pooling_point=18_000.0, claimant_col="member_id",
trend_annual=fit.annual_trend, trend_years=1.5)
group_id pooled_excess loss_cost rate
1102052 151913.43 495.23 582.63
2203987 206456.99 542.51 638.25
Indicated rates of 583/638 against 480 premium — the quantified version of the loss ratios in the study section.
lossmodels: severity and frequency from the listing¶
The fitting integrations route the set to the claims listing (claim grain); handing them the tab is refused, because member-month sums are a compound distribution, not severity.
from lossmodels.integrations.actuarialpy import (
fit_frequency_from_experience, fit_severity_from_experience)
sev = fit_severity_from_experience(book, by="claim_type")
freq = fit_frequency_from_experience(book, freq="M")
LM severity: {'inpatient': 'burr', 'outpatient': 'burr'} | frequency: poisson
extremeloss: the tail above the pooling point¶
Extracting excesses is structural; choosing the threshold is judgment, so it stays an argument.
from extremeloss.integrations.actuarialpy import fit_gpd_from_experience
tail = fit_gpd_from_experience(book, threshold=18_000.0)
EL tail: GPD xi=-0.04 (30 exceedances over 18k)
risksim and the model boundary¶
The fitted models — not the experience — feed aggregate simulation. That is
the deliberate boundary: risksim and the collective-risk layer consume
frequency/severity models, so they never touch Experience at all.
import lossmodels as lm
crm = lm.CollectiveRiskModel(frequency=freq["best_model"],
severity=sev["outpatient"]["best_model"])
crm.sample(10_000, rng=rng).mean()
CRM aggregate mean (outpatient/mo): 148,116
The shape of the whole thing¶
Sources → bindings → consumers. One construction call builds every
grain-honest worksheet; studies, projection, and rating read the tab;
severity and tail fitting read the listing; simulation reads the fitted
models. Entering at a coarser grain (an aggregated extract bound directly
as an Experience) keeps everything at or above that grain and loses what
is below it — the tab can never yield a severity distribution, and the
guards say so out loud rather than fitting garbage. Bind the finest grain
you have, declare it, and derive everything coarser.