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 — study functions over the canonical
actuarialpy.Experience. 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 study functions — summary,
views, rolling, margin, claimants, pool_claimants,
actual_vs_expected, decompose_trend, and friends — take the canonical
Experience, which remembers the expense, revenue, exposure, and date
columns once — the recommended path for any multi-step analysis, with the free functions as the low-level escape hatch for one-off calculations. Its restatements (adjust, deseasonalize, complete,
filter, with_status) live on the object in actuarialpy and each return
a new Experience, so adjustments compose without mutating the source.
Quickstart¶
import pandas as pd
from actuarialpy import Experience
import experiencestudies as es
from experiencestudies import summarize_experience
df = pd.DataFrame({
"month": pd.date_range("2025-01-01", periods=12, freq="MS"),
"lob": ["auto"] * 6 + ["property"] * 6,
"claims": [820, 910, 875, 1010, 990, 1105, 380, 395, 402, 410, 425, 440.0],
"premium": [1500.0] * 6 + [600.0] * 6,
"earned_units": [1000] * 12,
})
# recommended: bind the column roles once, then every view derives from them
exp = Experience(df, expense="claims", revenue="premium",
exposure="earned_units", date="month")
es.summary(exp, "lob") # grouped experience summary
es.rolling(exp, 3) # trailing three-month monitor
# low-level: the same summary straight from a DataFrame, columns named explicitly
summarize_experience(
df, groupby="lob",
expense_cols="claims", revenue_cols="premium", exposure_cols="earned_units",
)
# lob | earned_units | ... | loss_ratio
# auto | 6000 | ... | 0.6344
# property | 6000 | ... | 0.6811
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) and are applied on the
Experience itself:
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
)
es.summary(restated, "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="claimant_id",
amount_cols="paid")
es.top_claimants(claims, claimant_col="claimant_id", amount_cols="paid", n=3)
# claimant_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¶
expected_from_rate and forecast_from_rate build expected or forecast
values from a rate basis; forecast_experience applies them across a frame;
compare_actual_to_expected aligns the actuals against the plan and
summarize_actual_vs_expected reports the variance in dollars, per
exposure, and as an actual-to-expected ratio — sums first, then ratios:
plan = es.forecast_experience(basis, rate_col="base_rate",
exposure_col="member_months",
annual_trend=0.07,
months_forward="months_forward")
merged = es.compare_actual_to_expected(
actual, plan[["segment", "month", "expected_expense"]],
on=["segment", "month"],
actual_col="claims", expected_col="expected_expense")
es.summarize_actual_vs_expected(merged, groupby="segment",
actual_cols="claims",
expected_cols="expected_expense",
exposure_cols="member_months")
# segment | actual | expected | variance | variance_per_member_months | actual_to_expected
# hmo | 17,574,301 | 17,875,730 | -301,429 | -8.37 | 0.9831
# ppo | 11,568,101 | 10,846,909 | +721,193 | +34.34 | 1.0665
Example 8 runs the whole monitoring cycle — plan, A/E, trailing monitor, claimant attribution of the miss, and a pooled restatement. The same frame shape runs a mortality or lapse study unchanged: actuals are deaths or lapses, the expected column is a table rate times exposure, and the A/E ratio is the study’s headline. (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:
es.to_excel_report(
{"experience": es.summary(exp, "lob"),
"rolling_12m": es.rolling(exp, 12, groupby="lob"),
"underwriting": uw},
"monitoring_pack.xlsx")
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 over the canonical Experience.
Built on the actuarialpy primitives (ratios, trend, credibility,
completion, seasonality, financial mathematics) and the ecosystem’s canonical
actuarialpy.Experience – which binds column roles, grain metadata,
and snapshot context once. Transformations (complete, adjust,
deseasonalize, filter, with_status) live on Experience in
actuarialpy; this package is the study layer: analytical consumers that
take an Experience and return worked summaries – experience summaries and
views, underwriting margin, actual-versus-expected, claimant and concentration
analysis, large-claimant pooling, cohort and duration studies,
driver/component and frequency-severity decomposition, rolling monitors, and
banded summaries.
Every study function accepts the Experience as its first argument
(summary(exp, by=...), margin(exp), pool_claimants(exp, ...)).
The underlying tidy-table functions (summarize_experience,
summarize_claimants, …) remain available for plain-DataFrame use.
- summary(exp: Experience, by: str | list[str] | None = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by optional grouping columns.
profile(e.g."health") may be passed to apply profile naming defaults such asmlrfor the ratio column.
- views(exp: Experience, views: dict[str, str | Iterable[str] | None], **kwargs: Any) dict[str, DataFrame][source]¶
Create several named grouped experience views.
- rolling(exp: Experience, window: int = 12, *, groupby: str | list[str] | None = None, date_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Create a rolling-period experience summary.
- margin(exp: Experience, by: 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
summary(), then adds the margin (total_revenue - total_expense), the margin ratio, and an optional per-exposure margin.
- frequency_severity(exp: Experience, *, 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.
Uses the bound
count,expense(as the loss), andexposureroles. The identityloss_per_exposure == frequency * severityholds for every row.
- decompose_trend(exp: Experience, *, 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.
Splits the bound frame into prior and current with the same comparison modes as
actuarialpy.trend_summary()–period_colwithprior_period/current_period, adate_colwith prior/current ranges (the bounddateis used when nodate_colis passed), or explicit masks – 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.
- components(exp: Experience, 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.
- component_summary(exp: Experience, 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.
- actual_vs_expected(exp: Experience, 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 bound expense columns are used.
- claimants(exp: Experience, 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.
- top_claimants(exp: Experience | DataFrame, claimant_col: str | None = None, *, 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.
Accepts an
Experience(bound expense columns are the default amounts) or a plain DataFrame with explicitamount_cols.
- claimant_concentration(exp: Experience, 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.
- pool_claimants(exp: Experience, 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.
- cohort(exp: Experience, *, 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.
- duration(exp: Experience, *, 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.
- by_status(exp: Experience, status_col: str, *, entity_col: str | None = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by a status column (see
Experience.with_status()).
- by_band(exp: Experience, value_col: str, bands: Any, *, labels: Any = None, **kwargs: Any) DataFrame[source]¶
Summarize experience by a size band on
value_col.
- credibility_weighted(exp: Experience, 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 (
summary()), 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.
- 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.
- 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', freq: str = 'M', window_basis: str = 'calendar') DataFrame[source]¶
Rolling sums and ratios by period and optional grouping.
window_basiscontrols what the window counts:"calendar"(default):windowcontiguous calendar periods atfreq. Each group is aggregated tofreqand reindexed onto its complete period range, so a missing period is a real gap – it is summed over the periods actually present and, unless the wholewindowis present, the row is treated as incomplete. This is the correct basis for a summary described in months."observations":windowconsecutive rows, regardless of the calendar spacing between them (the historical behaviour).
period_startandperiod_endbound each window.min_periodsis the minimum number of populated periods required to emit a window’s sum (default:window);drop_incomplete(defaultTrue) keeps only windows with a fullwindowperiods populated.
- 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.