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 functionssummarize_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 functionssummary, 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 as mlr for 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), and exposure roles. The identity loss_per_exposure == frequency * severity holds 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_col with prior_period / current_period, a date_col with prior/current ranges (the bound date is used when no date_col is passed), or explicit masks – then decomposes the change via decompose_per_exposure_trend() using the bound count, expense (as the loss), and exposure roles. Pass mix_by to add the third LMDI mix term; groupby reports 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 actual is 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 explicit amount_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 at pooling_point (see actuarialpy.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 metric with a complement at credibility z.

Computes the grouped summary (summary()), then blends metric toward complement using z (see actuarialpy.credibility_weighted_estimate()). z may be a scalar or values aligned to the grouped rows. When complement is omitted the book-level value of metric is 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); the health profile names it mlr and life benefit_ratio. profile only 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_col can 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_months months 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; with first_month this 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_col is supplied. The API matches trend_summary and 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_analysis because 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 prior to current.

With mix_by omitted this is the two-way split: both frames are summarized with frequency_severity_summary() (optionally by the on keys), aligned, and the change reported two exact ways:

  • Multiplicative trend: loss_per_exposure_trend == frequency_trend * severity_trend, where frequency_trend and severity_trend are the period-over-period ratios of frequency and severity.

  • Additive dollars: loss_per_exposure_change == frequency_effect + severity_effect via 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 the mix_by cells. 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_trend and loss_per_exposure_change == frequency_effect + severity_effect + mix_effect.

A list of columns in mix_by defines 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. on and mix_by are orthogonal: on groups the output rows, mix_by defines 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 * severity holds for every row: frequency is claims per exposure unit, severity is loss per claim, and loss_per_exposure is 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_basis controls what the window counts:

  • "calendar" (default): window contiguous calendar periods at freq. Each group is aggregated to freq and 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 whole window is present, the row is treated as incomplete. This is the correct basis for a summary described in months.

  • "observations": window consecutive rows, regardless of the calendar spacing between them (the historical behaviour).

period_start and period_end bound each window. min_periods is the minimum number of populated periods required to emit a window’s sum (default: window); drop_incomplete (default True) keeps only windows with a full window periods 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: object

Two-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_exposure property 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 exposure so 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).

profile renames only the loss-ratio column to the domain’s ratio name ("health" -> mlr, "life" -> benefit_ratio); labels renames 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 via labels, 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 plus gross_margin_ratio and gain_ratio, and per-exposure columns when exposure_col is 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 on and the actual-to-expected ratio, variance, and variance percent are computed. Use how="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 as NaN (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 into amount_x / amount_y), the output columns are named "{actual_col}_{suffixes[0]}" and "{expected_col}_{suffixes[1]}" – by default amount_actual and amount_expected. Pass suffixes=("actual", "forecast") for amount_actual / amount_forecast. When the two columns already have distinct names they are left unchanged.

expected_from_rate(rate, exposure)[source]

Expected amount from a per-exposure rate and exposure.

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.

forecast_from_rate(base_rate, exposure, *, annual_trend: float = 0.0, months_forward: float = 0.0)[source]

Forecast an amount from base rate, exposure, trend, and elapsed months.