experiencestudies¶
The study layer of the ecosystem: experience summaries and views,
actual-versus-expected and simple forecasting, claimant and concentration
analysis, cohort and duration studies, driver and frequency–severity
decomposition, rolling monitors, banded summaries, and the two-tier
underwriting income statement — tied together by the fluent Experience
object. Where actuarialpy answers “what is the loss ratio /
development factor / credibility weight for this table?”, experiencestudies
answers “how is this block performing, why is it moving, and where is the risk
concentrated?”. It does not perform data preparation or encode filed
methodology: the caller supplies the tidy table and selects the analysis.
Every result is a DataFrame or Series.
There are two interfaces. The free functions — summarize_experience,
summarize_actual_vs_expected, summarize_claimants, cohort_summary,
decompose_per_exposure_trend, frequency_severity_summary,
rolling_summary, summarize_by_band, and the forecasting helpers — each
take a DataFrame and return a DataFrame. The Experience object is a
fluent wrapper that remembers the expense, revenue, exposure, and date columns
once, then exposes the same analyses as chainable methods, each restatement
returning a new Experience so adjustments compose without mutating the
source.
Quickstart¶
import pandas as pd
from experiencestudies import Experience, summarize_experience
df = pd.DataFrame({
"month": pd.date_range("2025-01-01", periods=12, freq="MS"),
"lob": ["med"] * 6 + ["rx"] * 6,
"claims": [820, 910, 875, 1010, 990, 1105, 380, 395, 402, 410, 425, 440.0],
"premium": [1500.0] * 6 + [600.0] * 6,
"member_months": [1000] * 12,
})
# free-function form
summarize_experience(
df, groupby="lob",
expense_cols="claims", revenue_cols="premium", exposure_cols="member_months",
)
# lob | member_months | ... | loss_ratio
# med | 6000 | ... | 0.6344
# rx | 6000 | ... | 0.6811
# fluent form — bind the column roles once, then every view derives from them
exp = Experience(df, expense="claims", revenue="premium",
exposure="member_months", date="month")
exp.by("lob") # the same grouped summary
exp.rolling(3) # trailing three-month monitor
Per-exposure output columns are the mechanical {name}_per_{exposure_col};
domain names (a health shop’s mlr or _pmpm) are opt-in via profile /
labels on the output views, never in the calculation.
Restatements compose¶
Adjustments return a new Experience, so a restated view is a chain. The
seasonal and completion factors come from actuarialpy
(seasonality_factors, completion_factors); experiencestudies applies
them through the fluent lens:
restated = (
exp.adjust(1.03) # apply a 3% trend/restatement factor
.deseasonalize(seasonal_factors) # divide out a seasonal shape
.complete(completion_factors, valuation_date="2025-12-31") # gross up to ultimate
)
restated.by("lob") # terminal summary of the restated view
Binding count (a claim or service count) unlocks the frequency–severity
views: frequency_severity() and decompose_trend(), which splits a
per-exposure movement into exact frequency, severity, and (optionally) mix
effects — Example 1 runs both on a full
panel.
Claimants and concentration¶
Identify and rank large claimants, and measure how concentrated the losses are:
import experiencestudies as es
by_claimant = es.summarize_claimants(claims, claimant_col="member_id",
amount_cols="paid")
es.top_claimants(claims, claimant_col="member_id", amount_cols="paid", n=3)
# member_id | paid | rank | share_of_total | cumulative_share
# m1 | 550,000 | 1 | 0.7534 | 0.7534
# m2 | 96,000 | 2 | 0.1315 | 0.8849
# m5 | 61,000 | 3 | 0.0836 | 0.9685
es.claim_concentration(by_claimant, top_n=[1, 3], thresholds=[100_000])
# claimant_count | total_amount | ... | amount_over_100000 | share_over_100000
large_claimant_flags marks claimants over a threshold for downstream
pooling or exclusion work.
Actual versus expected and forecasting¶
summarize_actual_vs_expected compares realized experience against an
expected column and reports the variance and actual-to-expected ratio.
expected_from_rate and forecast_from_rate build expected or forecast
values from a rate basis; forecast_experience projects an experience frame
forward; compare_actual_to_expected reports the resulting variance. (For
full multi-period claim, premium, and expense projections with renewal rate
actions and scenarios, use projectionmodels.)
Underwriting income statement¶
underwriting_summary (and the UnderwritingSummary object) build the
two-tier underwriting result — gross margin (revenue less loss expense,
operating expense excluded) and gain/(loss) (gross margin less operating
expense) — with each ratio’s denominator an explicit parameter, since real
exhibits mix them. The shared definitions are pinned on the
conventions page.
es.underwriting_summary(
book, groupby="cohort",
revenue_cols=["premium", "refund"], loss_cols="claims",
expense_cols="expense", exposure_col="member_months",
premium_col="premium",
)
# cohort | ... | loss_ratio | expense_ratio | combined_ratio | gain_ratio
# existing | ... | 0.8223 | 0.0903 | 0.9126 | 0.0871
# new | ... | 0.8904 | 0.1267 | 1.0172 | -0.0173
Components are summed first, so every ratio is a ratio of sums, and the
identity gain ratio = 1 − combined ratio holds exactly whenever all ratios
share one denominator.
Reporting¶
to_excel_report writes a dict of named views to a multi-sheet Excel
workbook (one sheet per key). The values are plain DataFrames, so any summary
on this page — grouped experience, an underwriting statement, a rolling
monitor — can be a sheet. It needs the excel extra:
pip install "experiencestudies[excel]"
Relationship to actuarialpy¶
experiencestudies depends on actuarialpy and never the other way around —
the dependency is strictly one-directional. The size-banding split is the
clearest example: the assign_band primitive lives in actuarialpy, while
summarize_by_band (which needs an experience summary) lives here. The same
split holds throughout: credibility, trend, completion, and seasonality are
computed by the core, and this package composes them into studies.
API reference¶
experiencestudies: experience reporting and analysis on tidy tables.
Built on the actuarialpy primitives (ratios, trend, credibility,
completion, seasonality, financial mathematics). This package adds the study
layer: experience summaries and views, actual-versus-expected, claimant and
concentration analysis, cohort and duration studies, driver/component and
frequency-severity decomposition, rolling monitors, banded summaries, simple
forecasting, the underwriting income statement, and the fluent
Experience object that ties them together.
- class Experience(data: DataFrame, expense: str | list[str], revenue: str | list[str], exposure: str | list[str] | None = None, date: str | None = None, profile: str | None = None, count: str | None = None, copy: bool = False)[source]¶
Bases:
objectBind an experience dataset to its actuarial column roles.
Experienceis the recommended entry point for repeated experience-analysis workflows. It stores common column roles once and delegates calculations to the package’s free functions. The object is immutable: methods return DataFrames or newExperienceobjects rather than changing stored data in place.Bind
count(a claim or service count) to unlock the frequency-severity views:frequency_severity()anddecompose_trend()(frequency x severity, optionally x mix).fit_trend()regresses a developed trend on the bound history.Grain matters.
Experienceaggregates by summing the bound columns, so it expects rows at the grain of the exposure unit – one row per unit (a member-month in a health book, a policy-month in life), with the exposure column = 1 (or the eligible fraction). If your data is long (one row per service line or transaction, so the same exposure unit repeats across several rows), summing the exposure column overcounts it, and every per-exposure figure – the per-exposure rate, frequency, the loss-ratio denominator – is wrong by the number of rows per unit.Experiencedoes not detect this: it has no record key, so it cannot tell a long frame from a wide one. For long or multi-table warehouse data, either aggregate to the exposure grain first, or usebind(), which sources exposure from a correctly-grained table (e.g. a health book’s eligibility) viaCountand never sums a repeated column.- actual_vs_expected(expected: str | list[str], *, actual: str | list[str] | None = None, groupby: str | list[str] | None = None, exposure: str | list[str] | None = None, **kwargs: Any) DataFrame[source]¶
Summarize actual-versus-expected experience.
If
actualis omitted, the object’s bound expense columns are used.
- adjust(factors: float | int | Series | DataFrame, *, on: str | list[str] | None = None, columns: str | list[str] | None = None, by: str | list[str] | None = None, how: str = 'multiply', factor_col: str = 'factor', audit_col: str | None = None, default: float | None = None) Experience[source]¶
Return a new
Experiencewith an expense column restated by a factor.The general counterpart to
complete()anddeseasonalize(): joins a factor by the keyon(a column already in the frame, optionally withinbysegments) and multiplies – or, withhow="divide", divides – the selected column(s) in place under the same name, so every downstream view composes on the restated series.factorsis a scalar (one factor for all rows), a Series indexed byon, or a tidy DataFrame keyed byby + on.This is the spine of experience-period restatement – trend, benefit / area / demographic relativities, network discounts – where the methodology is supplied as the factors rather than encoded here. Chain freely (
exp.complete(...).adjust(trend).adjust(area, on="region")); withaudit_colthe cumulative restatement multiplier is carried across the chain, one value per row, for a reviewable audit trail. An absent key surfaces asNaNunlessdefaultis given (default=1.0to mean “no adjustment for this key”).
- by(groupby: str | list[str] | None = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by optional grouping columns.
- by_band(value_col: str, bands: Any, *, labels: Any = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by a size band on
value_col(seesummarize_by_band).
- by_status(status_col: str, *, entity_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by a status column.
- claimant_concentration(claimant_col: str, *, amount_cols: str | list[str] | None = None, groupby: str | list[str] | None = None, **kwargs: Any) DataFrame[source]¶
Summarize how concentrated experience is among top claimants.
- claimants(claimant_col: str, *, amount_cols: str | list[str] | None = None, groupby: str | list[str] | None = None, exposure_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Aggregate the experience to claimant/member/risk level.
- cohort(*, entity_col: str, start_date_col: str, duration_months: int = 12, groupby: str | list[str] | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Summarize each entity’s first N months or cohort-duration window.
- complete(factors: Series, *, valuation_date: Any = None, columns: str | list[str] | None = None, development_col: str | None = None, by: str | list[str] | None = None, date_col: str | None = None) Experience[source]¶
Return a new
Experiencewith paid amounts developed to ultimate.Grosses the expense (loss / claims) columns up to estimated ultimate in place under the same names –
completed = paid / completion_factor– so downstream views (trend(),rolling(),by(), …) then run on the completed series. Each row’s development period isdevelopment_months(date, valuation_date)(the conventionmake_completion_triangle()uses), or an explicitdevelopment_col. The join is by value, so the frame’s index is irrelevant; rows past the triangle’s last development period are taken as fully complete, and only recent, immature months actually move.factorsmay be a flat Series (one pattern, fromcompletion_factors()) or a tidy per-segment table fromcompletion_factors_by(); with the latter, passbynaming the grouping column(s) to join on group plus development period. Only the numerator is developed – exposure is left untouched. This applies to the latest-diagonal shape (one row per incurred month,claimspaid-to-date as ofvaluation_date); a frame already on an ultimate basis must not be completed again.
- component_summary(component_cols: str | list[str], *, groupby: str | list[str] | None = None, exposure_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Summarize component amounts, per-exposure values, and shares.
- components(component_cols: str | list[str], *, exposure_col: str | None = None, groupby: str | list[str] | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Explain component drivers between two periods.
- credibility_weighted(groupby: str | list[str], *, z: Any, metric: str = 'loss_ratio', complement: float | None = None, out_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Blend each group’s
metricwith a complement at credibilityz.Computes the grouped summary (
by()), then blendsmetrictowardcomplementusingz(seeactuarialpy.credibility_weighted_estimate()).zmay be a scalar or values aligned to the grouped rows. Whencomplementis omitted the book-level value ofmetricis used as the complement of credibility.
- decompose_trend(*, count_col: str | None = None, loss_col: str | None = None, exposure_col: str | None = None, mix_by: str | Iterable[str] | None = None, groupby: str | list[str] | None = None, period_col: str | None = None, prior_period: Any = None, current_period: Any = None, date_col: str | None = None, prior_start: Any = None, prior_end: Any = None, current_start: Any = None, current_end: Any = None, prior_filter: Any = None, current_filter: Any = None) DataFrame[source]¶
Decompose the per-exposure loss trend between two periods of the bound data.
Splits the bound frame into prior and current with the same comparison modes as
trend()–period_colwithprior_period/current_period, adate_colwith prior/current ranges (the bounddateis used when nodate_colis passed), or explicitprior_filter/current_filtermasks – then decomposes the change viadecompose_per_exposure_trend(), using the boundcount,expense(as the loss), andexposureroles. Passmix_byto add the third LMDI mix term;groupbyreports one decomposition per group.
- deseasonalize(factors: Series, *, columns: str | list[str] | None = None, freq: str = 'M', by: str | list[str] | None = None, date_col: str | None = None) Experience[source]¶
Return a new
Experiencewith the seasonal pattern divided out.Each selected column is divided by its row’s seasonal factor (as produced by
seasonality_factors()), in place under the same name, so every downstream view –trend(),rolling(),by(), and the rest – then operates on the deseasonalized series. By default the expense (loss / claims) columns are adjusted; passcolumnsto choose others. Only the numerator is touched: exposure is left alone, so a deseasonalized per-exposure rate is simply deseasonalized claims over unchanged exposure.factorsmay be a flat Series (one pattern) or a tidy per-segment table fromseasonality_factors_by(); with the latter, passbynaming the grouping column(s) to join on group plus season. Estimate factors on the broader pool, not on this object’s own (often thin) data. To put the pattern back, applyapply_seasonality()to.data.
- duration(*, entity_col: str, start_date_col: str, max_duration_month: int | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by duration month since entity start.
- filter(mask: Any | None = None, *, query: str | None = None, copy: bool = True) Experience[source]¶
Return a new
Experienceobject over a filtered dataset.Use either a boolean mask or a pandas query string.
- fit_trend(*, value_col: str | None = None, exposure_col: str | None = None, date_col: str | None = None, freq: str = 'M', min_periods: int = 3, confidence: float = 0.95) TrendFit[source]¶
Fit an exponential trend to the bound experience by log-linear regression.
Defaults to the bound
expense(claims) over the boundexposure– the per-exposure trend – across the bounddate; passvalue_col/exposure_colto override, or leave the exposure unbound to trend the raw amount. Returns aTrendFit(seefit_trend()). Run on completed, deseasonalized history.
- frequency_severity(*, count_col: str | None = None, loss_col: str | None = None, exposure_col: str | None = None, groupby: str | list[str] | None = None) DataFrame[source]¶
Per-group claim frequency, severity, and per-exposure loss (see
frequency_severity_summary).Uses the bound
count,expense(as the loss), andexposureroles, so the columns are specified once on the object. The identityloss_per_exposure == frequency * severityholds for every row.
- margin(groupby: str | list[str] | None = None, *, margin_col: str = 'margin', ratio_col: str = 'margin_ratio', per_exposure_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Underwriting margin (revenue net of expense) by optional grouping.
Aggregates the bound expense and revenue roles with
by(), then adds the margin (total_revenue - total_expense), the margin ratio, and an optional per-exposure margin.
- pool_claimants(claimant_col: str, pooling_point: float, *, amount_cols: str | list[str] | None = None, groupby: str | list[str] | None = None, amount_name: str = 'total_expense', **kwargs: Any) DataFrame[source]¶
Aggregate to claimant level and split each claimant into pooled/excess.
Summarizes the experience to claimant grain (
claimants()) and caps each claimant’s total atpooling_point(seeactuarialpy.pool_losses()), returning pooled and excess columns for capped experience and the excess hand-off to tail modeling.
- rolling(window: int = 12, *, groupby: str | list[str] | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Create a rolling-period experience summary.
- top_claimants(claimant_col: str, *, amount_cols: str | list[str] | None = None, amount_col: str | None = None, groupby: str | list[str] | None = None, n: int = 25, **kwargs: Any) DataFrame[source]¶
Return top claimants by amount.
- trend(*, amount_col: str | None = None, exposure_col: str | None = None, groupby: str | list[str] | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Compare amount or per-exposure experience between two periods.
- views(views: dict[str, str | Iterable[str] | None], **kwargs: Any) dict[str, DataFrame][source]¶
Create several named grouped experience views.
- with_roles(*, data: DataFrame | None = None, expense: str | list[str] | None = None, revenue: str | list[str] | None = None, exposure: str | list[str] | None = None, date: str | None = None, profile: str | None = None, count: str | None = None, copy: bool | None = None) Experience[source]¶
Return a new
Experienceobject with updated data or roles.
- with_status(*, effective_col: str, as_of: Any, termination_col: str | None = None, first_year_months: int = 12, status_col: str = 'status', labels: dict[str, str] | None = None) Experience[source]¶
Return a new
Experiencewith a derived lifecycle status column.Derives active / first-year / termed from effective and termination dates as of a reference date (see
actuarialpy.derive_status()). Summarize the result withby_status().
- status_summary(df: DataFrame, *, status_col: str, entity_col: str | None = None, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, profile: str | None = None) DataFrame[source]¶
Summarize experience by status, optionally adding entity counts.
- summarize_experience(df: DataFrame, *, groupby: str | Iterable[str] | None = None, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, ratio_col: str | None = None, ratio_name: str | None = None, total_expense_name: str = 'total_expense', total_revenue_name: str = 'total_revenue', profile: str | None = None, labels: dict[str, str] | None = None) DataFrame[source]¶
Summarize experience by grouping columns.
Amounts and exposures are aggregated first. Ratios and per-exposure metrics are calculated after aggregation, which avoids averaging row-level ratios.
By default the ratio column is named
loss_ratio(general across lines of business); thehealthprofile names itmlrandlifebenefit_ratio.profileonly supplies light defaults and does not rename total expense or total revenue.
- summarize_views(df: DataFrame, *, views: dict[str, str | Iterable[str] | None], expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, ratio_col: str | None = None, ratio_name: str | None = None, total_expense_name: str = 'total_expense', total_revenue_name: str = 'total_revenue', profile: str | None = None) dict[str, DataFrame][source]¶
Create multiple experience summary views from the same input data.
- summarize_actual_vs_expected(df: DataFrame, *, groupby: str | Iterable[str] | None = None, actual_cols: str | Iterable[str], expected_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, actual_name: str = 'actual', expected_name: str = 'expected', ae_name: str = 'actual_to_expected', variance_name: str = 'variance', variance_pct_name: str = 'variance_pct') DataFrame[source]¶
Summarize actual-versus-expected results by optional grouping columns.
Actual and expected amounts are aggregated before ratios are calculated. This makes the function suitable for claim costs, benefits, expenses, revenue, or any other actual-versus-expected measure.
- claim_concentration(df: DataFrame, *, amount_col: str = 'total_expense', groupby: str | Iterable[str] | None = None, top_n: Sequence[int] = (10, 25), thresholds: Sequence[float] = (50_000, 100_000, 250_000)) DataFrame[source]¶
Summarize how concentrated total amounts are among top claimants.
The input should generally be one row per claimant within the requested grouping level, such as the output of
summarize_claimants.
- large_claimant_flags(df: DataFrame, *, amount_col: str = 'total_expense', thresholds: Sequence[float] = (50_000, 100_000, 250_000)) DataFrame[source]¶
Add boolean flags for claimants above one or more amount thresholds.
- summarize_claimants(df: DataFrame, *, claimant_col: str, amount_cols: str | Iterable[str], groupby: str | Iterable[str] | None = None, exposure_col: str | None = None, amount_name: str = 'total_expense') DataFrame[source]¶
Aggregate experience to claimant/member/risk level.
claimant_colcan be a member ID, policy ID, claim group ID, or another entity identifier. The function is descriptive; it does not cap, pool, or otherwise adjust the underlying amounts.
- top_claimants(df: DataFrame, *, claimant_col: str, amount_cols: str | Iterable[str] | None = None, amount_col: str | None = None, groupby: str | Iterable[str] | None = None, n: int = 25, amount_name: str = 'total_expense') DataFrame[source]¶
Return the top claimants by amount, optionally within each group.
- cohort_summary(df: DataFrame, *, entity_col: str, date_col: str, start_date_col: str, duration_months: int = 12, groupby: str | Iterable[str] | None = None, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, profile: str | None = None) DataFrame[source]¶
Summarize each entity’s first N months or cohort-duration window.
Each entity is clipped to its own first
duration_monthsmonths of duration (month 1 is the entity’s start month), aligning entities by tenure rather than calendar time. The output also reports how much of that window is actually present, so partial (not-yet-mature) cohorts can be spotted and excluded:months_observed: count of distinct duration months present (1..N).last_month: latest experience month observed; withfirst_monththis gives the available range.complete: whether the full window is present, i.e.months_observed == duration_months.
For example, to keep only cohorts with a full first year:
cohorts = exp.cohort(entity_col="group", start_date_col="effective_date") mature = cohorts[cohorts["complete"]]
- cohort_summary_by_period(cohort_df: DataFrame, *, cohort_date_col: str = 'first_month', freq: str = 'Q', entity_col: str | None = None, expense_col: str = 'total_expense', revenue_col: str = 'total_revenue', exposure_cols: str | Iterable[str] | None = None) DataFrame[source]¶
Roll entity-level cohort summaries into cohort month/quarter/year buckets.
- duration_summary(df: DataFrame, *, entity_col: str, date_col: str, start_date_col: str, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, max_duration_month: int | None = None) DataFrame[source]¶
Summarize experience by duration month since entity start.
- component_driver_analysis(df: DataFrame, *, period_col: str | None = None, prior_period=None, current_period=None, date_col: str | None = None, prior_start=None, prior_end=None, current_start=None, current_end=None, prior_filter=None, current_filter=None, component_cols: str | Iterable[str], exposure_col: str | None = None, groupby: str | Iterable[str] | None = None) DataFrame[source]¶
Explain component drivers of change between two periods.
The primary comparison is based on component totals, or component amount per exposure when
exposure_colis supplied. The API matchestrend_summaryand supports period-column, date-range, or explicit-filter comparisons.
- component_trend(*args, **kwargs) DataFrame[source]¶
Alias for
component_driver_analysis.The preferred name is
component_driver_analysisbecause the function explains drivers of total component change, not just component-specific trend.
- summarize_components(df: DataFrame, *, groupby: str | Iterable[str] | None = None, component_cols: str | Iterable[str], exposure_col: str | None = None, total_col: str = 'total_expense', include_shares: bool = True) DataFrame[source]¶
Summarize component/category amounts, per-exposure values, and shares.
- decompose_per_exposure_trend(prior: DataFrame, current: DataFrame, *, count_col: str, loss_col: str, exposure_col: str, on: str | Iterable[str] | None = None, mix_by: str | Iterable[str] | None = None) DataFrame[source]¶
Decompose the per-exposure loss change from
priortocurrent.With
mix_byomitted this is the two-way split: both frames are summarized withfrequency_severity_summary()(optionally by theonkeys), aligned, and the change reported two exact ways:Multiplicative trend:
loss_per_exposure_trend == frequency_trend * severity_trend, wherefrequency_trendandseverity_trendare the period-over-period ratios of frequency and severity.Additive dollars:
loss_per_exposure_change == frequency_effect + severity_effectvia a symmetric (midpoint) split, so the contributions sum exactly to the per-exposure change.
Pass
mix_by(a column or list of columns) to add a third mix component. The per-exposure loss is then decomposed into frequency, severity, and the effect of the exposure composition shifting across themix_bycells. Frequency and severity are measured within each cell (free of composition), and mix captures the aggregate movement that comes purely from the cell weights changing – the piece the two-way otherwise misattributes to frequency and severity. The split uses the LMDI (logarithmic mean Divisia index) convention, which is order-free and reconciles exactly:loss_per_exposure_trend == frequency_trend * severity_trend * mix_trendandloss_per_exposure_change == frequency_effect + severity_effect + mix_effect.A list of columns in
mix_bydefines the cells as their cross – one blended mix term, not a per-column attribution; to attribute mix to each dimension separately, run the decomposition once per dimension.onandmix_byare orthogonal:ongroups the output rows,mix_bydefines the mix cells within each group. Every cell must have positive count, loss, and exposure in both periods.
- frequency_severity_summary(df: DataFrame, *, count_col: str, loss_col: str, exposure_col: str, groupby: str | Iterable[str] | None = None) DataFrame[source]¶
Per-group claim frequency, severity, and per-exposure loss.
Counts, losses, and exposure are aggregated first, then the rates are derived after aggregation (avoiding averaging row-level rates). The identity
loss_per_exposure == frequency * severityholds for every row:frequencyis claims per exposure unit,severityis loss per claim, andloss_per_exposureis loss per exposure unit (the pure premium).
- rolling_summary(df: DataFrame, *, date_col: str, window: int = 12, groupby: str | Iterable[str] | None = None, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, min_periods: int | None = None, drop_incomplete: bool = True, ratio_col: str = 'loss_ratio') DataFrame[source]¶
Calculate rolling sums and ratios by period and optional grouping.
The output includes
period_startandperiod_end. By default only complete rolling windows are returned; for a 12-month window, the first output row appears after 12 months of data are available.
- summarize_by_band(df: DataFrame, value_col: str, bands: Sequence[float], *, labels: Sequence[str] | None = None, expense_cols: str | Iterable[str], revenue_cols: str | Iterable[str], exposure_cols: str | Iterable[str] | None = None, band_col: str = 'band', ratio_col: str | None = None, right: bool = False, profile: str | None = None) DataFrame[source]¶
Assign size bands then summarize experience grouped by band.
Returns one row per band in band order (empty bands included), with the same aggregates, loss ratio, and per-exposure metrics as
summarize_experience().
- class UnderwritingSummary(revenue: Mapping[str, float], losses: Mapping[str, float], expenses: Mapping[str, float] | float = 0.0, exposure: float | None = None, premium_label: str = 'premium', loss_ratio_denominator: str = 'total_revenue', expense_ratio_denominator: str = 'premium', gain_denominator: str = 'total_revenue')[source]¶
Bases:
objectTwo-tier underwriting income statement for a single entity or period.
- Parameters:
revenue (Mapping[str, float]) – Labeled revenue components (e.g.
{"premium": ..., "refund": ...}). Offsets such as refunds should be signed (negative). The library never interprets the labels; it only sums them.losses (Mapping[str, float]) – Labeled loss components – claim or benefit expense by whatever categories the caller uses.
expenses (Mapping[str, float] | float) – Operating expense, itemized or as a single amount. Default 0.
exposure (float, optional) – Exposure units (member months, policy months, earned exposures, …) for per-exposure figures. Required only when a
*_per_exposureproperty is accessed.premium_label (str) – Which revenue component is the gross premium, used when a denominator is
"premium". Default"premium".loss_ratio_denominator (str) –
"total_revenue"or"premium". Defaults follow the common exhibit convention: loss and gain ratios over total revenue, expense ratio over gross premium.expense_ratio_denominator (str) –
"total_revenue"or"premium". Defaults follow the common exhibit convention: loss and gain ratios over total revenue, expense ratio over gross premium.gain_denominator (str) –
"total_revenue"or"premium". Defaults follow the common exhibit convention: loss and gain ratios over total revenue, expense ratio over gross premium.
Examples
>>> uw = UnderwritingSummary( ... revenue={"premium": 1_200_000.0, "refund": -4_000.0}, ... losses={"claims": 1_090_000.0}, ... expenses=110_000.0, ... exposure=3_000.0, ... ) >>> round(uw.gross_margin, 0) 106000.0 >>> round(uw.gain, 0) -4000.0
- property combined_ratio: float¶
Loss ratio plus expense ratio, each on its own denominator.
- property expense_ratio: float¶
Operating expense over the
expense_ratio_denominator.
- classmethod from_per_exposure(*, revenue_per_exposure: Mapping[str, float], loss_per_exposure: Mapping[str, float], expense_per_exposure: Mapping[str, float] | float = 0.0, exposure: float, **kwargs: Any) UnderwritingSummary[source]¶
Build a summary from per-exposure components and total exposure.
Forecast exhibits are usually stated per exposure unit (PMPM in a health shop, per policy month in life); this converts each component to amounts by
exposureso totals, per-exposure figures, and ratios all come from one set of inputs.
- property gain: float¶
gross margin less operating expense.
- Type:
Tier two
- property gain_ratio: float¶
Gain / (loss) over the
gain_denominator.
- property gross_margin: float¶
total revenue less loss expense (operating expense excluded).
- Type:
Tier one
- property gross_margin_ratio: float¶
Gross margin over the
loss_ratio_denominator(its complement).
- property loss_ratio: float¶
Loss expense over the
loss_ratio_denominator.
- reconciliation() float[source]¶
gain_ratio - (1 - combined_ratio): the mixed-denominator gap.Zero when every denominator is the same series; otherwise the size of the drift introduced by quoting the loss, expense, and gain ratios over different bases. Useful as an exhibit footnote or a data-quality check.
- statement(*, profile: str | None = None, labels: Mapping[str, str] | None = None) Series[source]¶
Exhibit-shaped Series: components, subtotals, tiers, then ratios.
- to_frame(*, profile: str | None = None, labels: Mapping[str, str] | None = None) DataFrame[source]¶
One tidy row of every total and ratio (per-exposure when given).
profilerenames only the loss-ratio column to the domain’s ratio name ("health"->mlr,"life"->benefit_ratio);labelsrenames any output column. Calculations are unaffected.
- underwriting_summary(df: DataFrame, *, groupby: str | Iterable[str] | None = None, revenue_cols: str | Iterable[str], loss_cols: str | Iterable[str], expense_cols: str | Iterable[str], exposure_col: str | None = None, premium_col: str | None = None, loss_ratio_denominator: str = 'total_revenue', expense_ratio_denominator: str = 'premium', gain_denominator: str = 'total_revenue', profile: str | None = None, labels: dict[str, str] | None = None) DataFrame[source]¶
Grouped two-tier underwriting summary from a tidy table.
Component columns are summed first and every ratio is computed on the aggregated totals (ratio of sums, never an average of row-level ratios) – the same contract as
actuarialpy.summarize_experience().- Parameters:
df (pd.DataFrame) – One row per entity / period at whatever grain is being rolled up.
groupby (str | Iterable[str], optional) – Grouping columns; omit for a single all-rows summary.
revenue_cols (str | Iterable[str]) – Component columns for each tier. Revenue offsets (refunds) should be signed.
loss_cols (str | Iterable[str]) – Component columns for each tier. Revenue offsets (refunds) should be signed.
expense_cols (str | Iterable[str]) – Component columns for each tier. Revenue offsets (refunds) should be signed.
exposure_col (str, optional) – Exposure column; adds
{amount}_per_{exposure_col}output columns. Domain-style names (a health shop’s_pmpm) are applied vialabels, never inferred from the column name.premium_col (str, optional) – Gross premium column, required when any denominator is
"premium".loss_ratio_denominator (str) –
"total_revenue"or"premium"; see the module docstring for the convention discussion.expense_ratio_denominator (str) –
"total_revenue"or"premium"; see the module docstring for the convention discussion.gain_denominator (str) –
"total_revenue"or"premium"; see the module docstring for the convention discussion.profile (str, optional) – Renames only the loss-ratio column to the domain’s ratio name (
"health"->mlr,"life"->benefit_ratio).labels (dict, optional) – Explicit output column renames, applied after
profile.
- Returns:
Group keys, component sums,
total_revenue,total_loss,total_expense,gross_margin,gain, the three ratios plusgross_margin_ratioandgain_ratio, and per-exposure columns whenexposure_colis given.- Return type:
pd.DataFrame
- to_excel_report(views: dict[str, DataFrame], path: str | Path, *, index: bool = False) Path[source]¶
Write a dictionary of DataFrames to an Excel workbook, one sheet per view.
- compare_actual_to_expected(actual: DataFrame, expected: DataFrame, *, on: str | Iterable[str], actual_col: str, expected_col: str, how: Literal['left', 'right', 'outer', 'inner', 'cross'] = 'left', suffixes: tuple[str, str] = ('actual', 'expected')) DataFrame[source]¶
Join actual and expected tables and calculate A/E and variance metrics.
The two frames are merged on
onand the actual-to-expected ratio, variance, and variance percent are computed. Usehow="outer"so that keys present on only one side – for example forecast months that do not have actuals yet – are kept, with the missing side coming back asNaN(so an unavailable actual is distinguishable from a true zero).Column-name collisions are handled automatically. If the actual and expected amount columns share a name (e.g. both frames call their value column
"amount", which a plain merge would turn intoamount_x/amount_y), the output columns are named"{actual_col}_{suffixes[0]}"and"{expected_col}_{suffixes[1]}"– by defaultamount_actualandamount_expected. Passsuffixes=("actual", "forecast")foramount_actual/amount_forecast. When the two columns already have distinct names they are left unchanged.
- forecast_experience(df: DataFrame, *, rate_col: str, exposure_col: str, annual_trend: float | str = 0.0, months_forward: float | str = 0.0, forecast_col: str = 'expected_expense', trended_rate_col: str = 'expected_rate', copy: bool = True) DataFrame[source]¶
Create forecast/expected amounts from rates, exposures, and trend.