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 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 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: object

Bind an experience dataset to its actuarial column roles.

Experience is 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 new Experience objects rather than changing stored data in place.

Bind count (a claim or service count) to unlock the frequency-severity views: frequency_severity() and decompose_trend() (frequency x severity, optionally x mix). fit_trend() regresses a developed trend on the bound history.

Grain matters. Experience aggregates 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. Experience does 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 use bind(), which sources exposure from a correctly-grained table (e.g. a health book’s eligibility) via Count and 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 actual is 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 Experience with an expense column restated by a factor.

The general counterpart to complete() and deseasonalize(): joins a factor by the key on (a column already in the frame, optionally within by segments) and multiplies – or, with how="divide", divides – the selected column(s) in place under the same name, so every downstream view composes on the restated series. factors is a scalar (one factor for all rows), a Series indexed by on, or a tidy DataFrame keyed by by + 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")); with audit_col the cumulative restatement multiplier is carried across the chain, one value per row, for a reviewable audit trail. An absent key surfaces as NaN unless default is given (default=1.0 to 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 (see summarize_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 Experience with 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 is development_months(date, valuation_date) (the convention make_completion_triangle() uses), or an explicit development_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.

factors may be a flat Series (one pattern, from completion_factors()) or a tidy per-segment table from completion_factors_by(); with the latter, pass by naming 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, claims paid-to-date as of valuation_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 metric with a complement at credibility z.

Computes the grouped summary (by()), 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.

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_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 prior_filter / current_filter 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.

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 Experience with 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; pass columns to choose others. Only the numerator is touched: exposure is left alone, so a deseasonalized per-exposure rate is simply deseasonalized claims over unchanged exposure.

factors may be a flat Series (one pattern) or a tidy per-segment table from seasonality_factors_by(); with the latter, pass by naming 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, apply apply_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 Experience object 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 bound exposure – the per-exposure trend – across the bound date; pass value_col / exposure_col to override, or leave the exposure unbound to trend the raw amount. Returns a TrendFit (see fit_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), and exposure roles, so the columns are specified once on the object. The identity loss_per_exposure == frequency * severity holds 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 at pooling_point (see actuarialpy.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 Experience object 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 Experience with 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 with by_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); 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.

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_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') DataFrame[source]

Calculate rolling sums and ratios by period and optional grouping.

The output includes period_start and period_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: 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.