actuarialpy

Shared actuarial primitives and general tooling — the calculation core the rest of the ecosystem builds on. Ratios and per-exposure metrics, chain-ladder development and IBNR, credibility, trend, seasonal factors, financial mathematics, exposure and lifecycle bases, size banding, pooling, margins, and weighted rollups, applied to claims, exposure, and premium data. It does not perform data preparation or encode filed methodology: the caller supplies the table and selects the method. Every result is a DataFrame or Series, and the only dependencies are numpy and pandas.

actuarialpy also owns the ecosystem’s shared data contract: the canonical Experience container (below). The study layer that builds on both — experience summaries, actual-versus-expected, claimant, cohort, and decomposition analyses, and the underwriting income statement — lives in experiencestudies, which depends on actuarialpy and never the other way around.

Quickstart

Pass an aggregate at the grain you are analysing and call the primitive you need — the free functions accept scalars, numpy arrays, or pandas Series and return the same type:

import pandas as pd
import actuarialpy as ap

ap.loss_ratio(1_240_000, 1_500_000)     # 0.827
ap.per_exposure(1_240_000, 12_000)      # 103.33 per exposure unit
ap.severity(1_240_000, 3_875)           # 320.00 per claim

# fit a trend on a monthly panel and project it forward
monthly = pd.DataFrame({
    "month": pd.date_range("2025-01-01", periods=12, freq="MS"),
    "loss_ratio": [0.802, 0.810, 0.807, 0.815, 0.818, 0.822,
                   0.825, 0.831, 0.828, 0.836, 0.840, 0.845],
})
fit = ap.fit_trend(monthly, date_col="month", value_col="loss_ratio")
fit.annual_trend                                     # 0.0552
fit.r_squared                                        # 0.974
ap.project_forward(0.845, fit.annual_trend, months=12)   # 0.8916
ap.trend_factor(fit.annual_trend, months=18)             # 1.0839

Build the aggregate with pandas at the grain that matches the question — typically a single groupby that sums claims, counts exposure from the exposure table, and joins premium. For repeated experience-analysis workflows on such a table, the canonical Experience object binds the column roles once; the study views over it live in experiencestudies.

The Experience container

Experience is the ecosystem’s canonical semantic wrapper for historical actuarial data: it binds column roles, grain metadata, and snapshot context once, so experiencestudies, projectionmodels, and ratingmodels consume one object instead of re-declaring columns.

import actuarialpy as ap

exp = ap.Experience(
    panel,
    expense="paid_claims", revenue="premium",       # measure roles: at least
    exposure="member_months", count="claim_count",  #   one required, none mandatory
    date="incurred_month",
    dimensions=["group_id", "claim_type"],          # segmentation, lookups, grain defaults
    exposure_keys=["member_id", "incurred_month"],  # one row per exposure unit (opt-in guard)
    valuation_date="2026-06-30",
)

Three kinds of metadata do three different jobs. The measure and date roles name what the columns mean. dimensions are segmentation columns — consumers use them as defaults for reporting cuts, assumption lookups, and projection grain; they say nothing about row grain. exposure_keys identify one exposure unit: when bound, construction validates the frame is unique on them, so long (service-line-grain) data is rejected at the door instead of silently overcounting every per-exposure figure. Leave them unbound and no grain safety is claimed.

The object holds no actuarial judgment. Its public methods are immutable transformations — each takes caller-supplied assumptions as arguments and returns a new Experience, so restatements chain:

work = (
    exp.filter(query="group_id == 1102052")
       .complete(completion_factors)     # develops to ultimate; valuation date
       .adjust(1.03)                     #   defaults from the object
       .deseasonalize(seasonal_factors)
)

complete() also tracks state: it marks each developed column "ultimate" in the object’s basis, and completing a column already on an ultimate basis raises — the double-development mistake is an error, not a silent overstatement. Data that arrives already developed declares it at construction (basis={"paid_claims": "ultimate"}).

Multi-table sources bind at the doorway. Experience.from_tables builds the experience tab from source extracts – a grain-defining table (membership, validated unique) plus any number of Source specs naming their role in the same vocabulary (expense, revenue, count), an optional wide_by categorical to pivot (claim types become expense columns, recorded as provenance), and each table’s own date floored to the grain period. One fixed algorithm: finer tables aggregate up, coarser tables are refused (allocation is judgment), unmatched keys are surfaced, empty cells are structural zeros.

exp = ap.Experience.from_tables(
    membership, grain=["member_id", "month"], exposure="member_months",
    sources=[
        ap.Source(claim_lines, expense="paid_amount",
                    wide_by="claim_type", date="incurred_date"),
        ap.Source(billing, revenue="billed_premium"),
    ],
    date="month", period="M", dimensions="group_id",
)

Coarser worksheets derive from it structurally: exp.aggregate(by="group_id", freq="MS") sums the measure roles to a new validated grain (and requires exposure_keys, since summing exposure is only provably safe on a proven grain), and exp.melt() undoes a recorded pivot when a consumer needs the categories long.

One construction call: the ExperienceSet workbook. When the same sources feed several grains, ExperienceSet.from_tables builds them together: the worksheet (book.tab) plus one listing member per named Source spec (book["claims"]), each an ordinary, materialized, grain-honest Experience. Consumers accept the set and route themselves – studies, rating, and projection to the tab; severity and tail fitting to the claims listing.

book = ap.ExperienceSet.from_tables(
    membership, grain=["member_id", "month"], exposure="member_months",
    sources=[ap.Source(claim_lines, expense="paid_amount",
                       wide_by="claim_type", date="incurred_date",
                       name="claims"),
             ap.Source(billing, revenue="billed_premium")],
    date="month", period="M", dimensions="group_id",
)
book.cohort(group_id="1102052")   # re-derives every member from the sources
book.reconcile()                  # ties each listing's totals to the tab

cohort(...) filters the grain table (the population authority) and rebuilds all members – propagation by reconstruction, never mutation. reconcile() surfaces exclusions (orphan keys) instead of dropping them silently. One construction call is universal; one instance never holds two grains.

Everything analytical is a function that accepts an Experience — a split enforced by a test (no public method on the class may return anything else). Here in actuarialpy, fit_trend and trend_summary are Experience-native (ap.fit_trend(work) resolves the value, date, and exposure columns from the bound roles); the study summaries live in experiencestudies, projections in projectionmodels, and worksheet construction in ratingmodels.

Credibility

Credibility primitives live here — experiencestudies, projectionmodels, and ratingmodels delegate to them rather than re-implementing. Limited-fluctuation (classical) credibility from exposure:

import actuarialpy as ap

z = ap.limited_fluctuation_z(exposure=96_000, full_credibility_standard=120_000)
# -> 0.894

Greatest-accuracy (Bühlmann–Straub) credibility across risk classes, fit straight from a tidy frame of group / value / weight:

import pandas as pd
import actuarialpy as ap

exp = pd.DataFrame({
    "product": ["motor", "motor", "marine", "marine"],
    "paid":    [125_000, 130_000, 88_000, 91_000],
    "exposure": [1010, 1008, 640, 655],
})

model = ap.BuhlmannStraub.from_frame(
    exp, group="product", value="paid", weight="exposure",
)

model.k                    # Bühlmann k = EPV / VHM
model.z(weight=1_000)      # credibility Z for 1,000 units of exposure

model.premium(risk_mean, weight) then blends a class’s own mean toward the overall mean at that credibility.

Financial mathematics

Time value of money on the same numpy/pandas footing — rate conversions, present/future values, annuities-certain, loan amortization, and day-count conventions:

import actuarialpy as ap

ap.present_value(1000, 0.05, 3)          # 863.84  — discount at 5% for 3 yrs
ap.future_value(1000, 0.05, 3)           # 1157.63
ap.annuity_immediate(0.05, 10)           # 7.7217  — PV of 1/yr, 10 yrs @ 5%
ap.annuity_due(0.05, 10)                 # 8.1078

# a deferred pension: 30k a year for 25 years, first payment in 20 years
30_000 * ap.annuity_due(0.045, 25) * ap.discount_factor(0.045, 20)
                                         # 192,752.68

# level-payment loan: 200k principal, 6% nominal, 30 years monthly
ap.level_payment(200_000, 0.06 / 12, 360)          # 1199.10 per month
ap.amortization_schedule(200_000, 0.06 / 12, 360)  # full schedule (DataFrame)

ap.year_fraction("2024-01-01", "2024-07-01", convention="30/360")  # 0.5

The element-wise functions honor the ecosystem vectorization contract: a scalar rate returns a float, while a numpy array or pandas Series returns the same kind on the same index — so a per-scenario or per-period rate column maps straight to a result column. The cash-flow reductions (net_present_value, internal_rate_of_return, present_value_curve) take a whole stream and return a scalar; everything else mirrors its input.

import pandas as pd

rates = pd.Series([0.03, 0.05, 0.07], index=["low", "base", "high"], name="rate")

ap.discount_factor(rates, 10)     # Series on the same index: 0.7441, 0.6139, 0.5083
ap.annuity_immediate(rates, 20)   # Series: 14.8775, 12.4622, 10.5940

# assign results straight back onto a scenario frame
book = pd.DataFrame({"rate": [0.03, 0.05, 0.07]})
book["discount_10y"] = ap.discount_factor(book["rate"], 10)
book["annuity_20y"] = ap.annuity_immediate(book["rate"], 20)

Exposure and age bases

Exact and rounded ages, and exposure-years over a study window — the inputs an actual-to-expected study needs:

import pandas as pd
import actuarialpy as ap

ap.age("1980-06-15", "2026-06-30")                   # 46.04  — exact
ap.age("1980-06-15", "2026-06-30", basis="last")     # 46     — age last birthday
ap.age("1980-06-15", "2026-06-30", basis="nearest")  # 46     — age nearest birthday

# fraction of a study window each life is exposed
cohort = pd.DataFrame({
    "entry": ["2024-03-01", "2024-01-15"],
    "term":  ["2025-09-01", "2025-12-31"],
})
ap.add_exposure_column(cohort, entry_col="entry", exit_col="term",
                       study_start="2024-01-01", study_end="2025-12-31")

Pooling and retention

Row-level pooling is one call — pool_losses splits each loss at the pooling point into a retained and an excess column, leaving attribution to the caller (Example 8 runs it at claimant level, Example 2 uses the grouped ratingmodels wrapper on a claim file):

import pandas as pd
import actuarialpy as ap

claims = pd.DataFrame({"claim_id": [1, 2, 3],
                       "paid": [80_000.0, 310_000.0, 95_000.0]})
ap.pool_losses(claims, loss_col="paid", pooling_point=250_000.0)
#  claim_id     paid  pooled_loss  excess_loss
#         1   80,000       80,000            0
#         2  310,000      250,000       60,000
#         3   95,000       95,000            0

The module also includes two retention-stability primitives: retained_cv(outcomes, retention, n_units) is the coefficient of variation of the retained aggregate of n_units i.i.d. units each capped at retention, and retention_for_target_cv inverts it — the retention at which retained volatility hits a target, which is the basis for a size-graded pooling schedule:

import numpy as np

rng = np.random.default_rng(3)
outcomes = rng.lognormal(9.0, 1.4, size=20_000)   # per-unit annual outcomes

ap.retained_cv(outcomes, retention=100_000, n_units=40)   # 0.2119
ap.retained_cv(outcomes, retention=250_000, n_units=40)   # 0.2735

ap.retention_for_target_cv(outcomes, n_units=40, target_cv=0.10)    # 14,912
ap.retention_for_target_cv(outcomes, n_units=160, target_cv=0.10)   # 83,652

Raising the retention raises the retained CV — more volatile large claims kept — and the inverse call reads a schedule straight off the data: at a 10% stability standard, four times the units supports a five-and-a-half-times retention.

Weighted rollups

Quantities that are already rates at the row level — rate actions, persistency — cannot be summed. weighted_mean and weighted_summary average them with a required, named weight and report the weight total beside every average:

ap.weighted_summary(book, value_cols="rate_action",
                    weight_col="premium", groupby="cohort")

The composed two-tier underwriting income statement that used to sit beside these rollups (UnderwritingSummary, underwriting_summary) now lives in experiencestudies — it is an assembled report rather than an atomic calculation. The margin primitives (margin, margin_ratio, add_margin) remain here, and the shared margin definitions are on the conventions page.

Development and completion

actuarialpy.reserving fits chain-ladder development patterns from cumulative triangles: ChainLadder.fit (volume-weighted or simple age-to-age factors, optional tail), project for per-origin ultimates and IBNR, and completion_factors / apply_completion for the completion-factor workflow.

As of 0.40.0 the pattern carries its own uncertainty — the distribution-free standard errors of Mack (1993):

cl = ChainLadder.fit(triangle)           # volume-weighted
cl.mack_sigma_squared(triangle)          # variance parameters per period
cl.mack_standard_errors(triangle)
# latest | ultimate | ibnr | se | cv     (per origin, plus a Total row
#                                         with the cross-origin covariance)

Two honesty notes, stated rather than hidden: the errors are defined for the volume-weighted estimator only (Mack’s model is that estimator, so method="simple" refuses), and a fitted tail is treated as deterministic — ultimates and standard errors scale by it exactly. Verified against the published Taylor–Ashe results (total reserve 18,680,856; total s.e. 2,447,095).

API reference

actuarialpy: shared actuarial primitives and general tooling.

Calculation building blocks on tidy tables: ratios and per-exposure metrics, chain-ladder development and IBNR, credibility, trend, seasonality, financial mathematics (time value of money), exposure and lifecycle bases, size banding, pooling, margins, weighted rollups, and comparison/contribution helpers. Every result is a DataFrame or Series, and the only dependencies are numpy and pandas.

class Experience(data: ~pandas.DataFrame, expense: tuple[str, ...] = (), revenue: tuple[str, ...] = (), exposure: tuple[str, ...] = (), count: tuple[str, ...] = (), date: str | None = None, dimensions: tuple[str, ...] = (), exposure_keys: tuple[str, ...] = (), valuation_date: ~pandas.Timestamp | None = None, basis: ~collections.abc.Mapping[str, str] = <factory>, pivots: tuple[~actuarialpy.frame.Pivot, ...] = ())[source]

Bases: object

Bind an experience dataset to its actuarial column roles and grain.

Role columns

expense, revenue, exposure, count

Measure roles; each accepts one column name or several. At least one measure role (expense, revenue, or count) must be bound, but no particular one is mandatory – a claims-only frame, a premium-only frame, and a count study are all legal.

date

The experience date column (incurred month, policy month, …).

Grain metadata

dimensions

Segmentation columns – group, product, claim type. Consumers use these as defaults for reporting groupings, assumption lookups, and projection grain. They say nothing about row grain.

exposure_keys

Columns that identify one exposure unit, e.g. ("member_id", "month"). When bound, construction validates that the frame is unique on these columns, so repeated exposure units (long, service-line-grain data) are rejected instead of silently overcounting every per-exposure figure. Leave unbound to skip the guard; no grain safety is claimed without it.

Snapshot context

valuation_date

The paid-through / as-of date of the data. complete() uses it as the default valuation date.

basis

Per-column transformation state, e.g. {"paid_claims": "ultimate"}. complete() refuses columns already marked "ultimate" and marks the columns it completes, so accidental double development is an error rather than a silent overstatement. Any string is legal; only "ultimate" currently carries enforcement.

The object is immutable and holds no actuarial judgment: every public method takes its assumptions as arguments (completion factors, adjustment factors, seasonal factors) and returns a new Experience. Analytical consumers – summaries, trend fits, projections, rates – are functions in this package and downstream packages that accept an Experience.

The frame is not defensively copied on construction; transformations always return new objects over new frames.

adjust(factors: float | int | Series | DataFrame, *, on: str | Iterable[str] | None = None, columns: str | Iterable[str] | None = None, by: str | Iterable[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. factors is a scalar, 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. An absent key surfaces as NaN unless default is given (default=1.0 to mean “no adjustment for this key”).

aggregate(by: str | Iterable[str] | None = None, *, freq: str | None = None) Experience[source]

Return a new Experience summed to a coarser grain.

by names the grouping columns; freq (a pandas offset alias such as "MS", "QS", "YS") additionally floors the bound date role into the grouping. All measure-role columns are summed – aggregation is structural – and non-measure, non-key columns are dropped, since they need not be constant at the coarser grain.

Summing the exposure role is only provably safe when the input grain was validated, so an Experience with an exposure role must have exposure_keys bound. The result’s exposure_keys are the new grain (uniqueness holds by construction of the groupby).

complete(factors: Series, *, valuation_date: Any = None, columns: str | Iterable[str] | None = None, development_col: str | None = None, by: str | Iterable[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. Each row’s development period is development_months(date, valuation_date) (the convention make_completion_triangle() uses), or an explicit development_col. valuation_date defaults to the object’s bound valuation date. 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). Only the numerator is developed – exposure is left untouched.

Completing marks each developed column "ultimate" in basis, and completing a column already marked "ultimate" raises – the double-development mistake is an error, not a silent overstatement.

deseasonalize(factors: Series, *, columns: str | Iterable[str] | None = None, freq: str = 'M', by: str | Iterable[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. By default the expense columns are adjusted; pass columns to choose others. Only the numerator is touched. factors may be a flat Series (one pattern) or a tidy per-segment table from seasonality_factors_by(); with the latter pass by. 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.

filter(mask: Any | None = None, *, query: str | None = None, copy: bool = True) Experience[source]

Return a new Experience over a filtered dataset.

Use either a boolean mask or a pandas query string.

classmethod from_tables(data: DataFrame, *, grain: str | Iterable[str], exposure: str | Iterable[str] | None = None, sources: Iterable[Source] = (), date: str | None = None, period: str | None = None, dimensions: str | Iterable[str] = (), valuation_date: Any | None = None, basis: Mapping[str, str] | None = None, unmatched: str = 'warn') Experience[source]

Build an Experience from source tables: multi-table at the doorway, single-table inside.

data is the table that defines the grain – one row per exposure unit (typically membership / eligibility). It is validated unique on grain, contributes the exposure role, and keeps all its other columns (entity attributes ride along). Each Source spec is then brought to the grain by one fixed, auditable algorithm:

  • tables at a finer grain are aggregated up (grouped and summed or counted) – aggregation is structural, so the constructor may do it;

  • tables at a coarser grain (missing a grain column) are refused – allocation downward is judgment, so the caller must do it before binding;

  • grain cells with no rows get 0.0 (the absence of claims is zero claims), and rows whose keys don’t exist in data are surfaced per unmatched ("warn" or "raise") – never dropped silently;

  • wide_by pivots are recorded as Pivot provenance so they can be undone structurally by melt().

The result is an ordinary single-grain Experience with exposure_keys set to grain (uniqueness was just proven).

melt(pivot: str | None = None) Experience[source]

Undo a recorded wide_by pivot, returning a long Experience.

The categorical column comes back (and joins dimensions), the original measure column returns under its recorded role, and the wide columns disappear. Purely structural: only pivots recorded by from_tables() can be melted, because only those have one right inverse.

The melted frame repeats each exposure unit once per category, so exposure_keys is cleared – summing the exposure role across categories on the result would overcount, and no grain safety is claimed. Per-category pipelines (projection base rates) consume it correctly.

with_roles(*, data: DataFrame | None = None, expense: str | Iterable[str] | None = None, revenue: str | Iterable[str] | None = None, exposure: str | Iterable[str] | None = None, count: str | Iterable[str] | None = None, date: str | None = None, dimensions: str | Iterable[str] | None = None, exposure_keys: str | Iterable[str] | None = None, valuation_date: Any | None = None, basis: Mapping[str, str] | None = None) Experience[source]

Return a new Experience with updated data, roles, or metadata.

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()).

class ExperienceSet(tab: Experience, listings: Mapping[str, Experience], manifest: Mapping[str, Any], _sources: Mapping[str, Any])[source]

Bases: object

A coordinated bundle of grain-honest Experience members.

Build with from_tables(). tab is the worksheet at the declared grain (via Experience.from_tables); each named Source spec also yields a listing member at its own source grain, reachable by book["claims"]. Members are ordinary, materialized Experience objects: what a member’s .data shows is exactly what a consumer receives.

cohort(...) is the only cross-member operation: it filters the grain table on its own columns (the population authority) and re-derives every member from the filtered sources – propagation by reconstruction, never by mutation. Worksheet-local transformations stay on the members and return plain Experience objects.

cohort(**predicates: Any) ExperienceSet[source]

A new ExperienceSet restricted to a population.

Predicates name columns of the grain table (the population authority) with a value or list of values. Every member is re-derived: the grain table is filtered directly, and each source table is semi-joined to the surviving grain keys on the grain columns it shares – propagation by reconstruction, so nothing can go stale.

classmethod from_tables(data: DataFrame, *, grain: str | Iterable[str], exposure: str | Iterable[str] | None = None, sources: Iterable[Source] = (), date: str | None = None, period: str | None = None, dimensions: str | Iterable[str] = (), valuation_date: Any | None = None, unmatched: str = 'warn') ExperienceSet[source]

One construction call: the tab plus a listing per named spec.

Takes exactly the arguments of Experience.from_tables – the Source declarations already carry everything both members need (roles, the table’s own date, the pivot categorical).

reconcile() DataFrame[source]

Tie each named listing’s measure totals to the tab.

Returns one row per (listing, measure): source total, tab total, difference, and whether they tie. A nonzero difference is the surfaced exclusions (orphan keys that never joined) – the check an actuary does by hand between the claims extract and the worksheet.

class Source(data: DataFrame, expense: str | Iterable[str] = (), revenue: str | Iterable[str] = (), count: str | Iterable[str] = (), wide_by: str | None = None, date: str | None = None, agg: str = 'sum', rename: Mapping[str, str] | None = None, keys: Mapping[str, str] | None = None, name: str | None = None)[source]

Bases: object

One measure table for Experience.from_tables().

Declares which columns of data carry which measure role (the same role vocabulary as Experience: expense, revenue, count), plus how the table reaches the grain:

wide_by

A categorical column (claim type, service line) to pivot: each category becomes its own column under the spec’s measure role. Requires the spec to name exactly one measure column.

date

This table’s own date column (e.g. incurred_date). It is floored to the constructor’s period to produce the grain’s date column – choosing which date (incurred vs paid) is the caller’s judgment; flooring it is calendar arithmetic.

agg

"sum" (default) or "count" (rows per grain cell, e.g. a claim count from claim IDs).

keys

Maps this table’s join-key column names onto the grain table’s names (keys={"mbr_id": "member_id"}), for sources that spell the same key differently.

name

Names this source as a listing member of an ExperienceSet (name="claims" -> book["claims"]). Ignored by Experience.from_tables.

class Pivot(by: str, role: str, value: str, columns: tuple[str, ...])[source]

Bases: object

Provenance of one wide_by pivot performed by Experience.from_tables().

Records that categorical column by was pivoted so that measure column value became the wide columns under measure role role. Stored on the resulting Experience so Experience.melt() (and consumers such as projectionmodels.project) can undo the reshape structurally – the inverse of a recorded pivot has exactly one right answer.

resolve_amount(exp: Experience, amount_col: str | None = None) tuple[DataFrame, str][source]

Return (frame, column) for an amount: explicit, single expense, or summed.

With one bound expense column the frame is returned as-is; with several, a temporary row-wise total is added so callers see one amount column.

resolve_date(exp: Experience, date_col: str | None = None) str[source]

Return an explicit date column or the bound date role.

single_role(roles: Iterable[str], role_name: str) str[source]

Return the single column bound to a role, or raise a helpful error.

Consumers that need exactly one column for a role (one claims column, one exposure column) use this to turn the bound tuple into a column name.

single_role_or_none(roles: Iterable[str]) str | None[source]

Return the single bound column, None if unbound, or raise if several.

actual_to_expected(actual: Any, expected: Any) Any[source]

Calculate actual-to-expected: actual divided by expected.

combined_ratio(losses: Any, expenses: Any, revenue: Any) Any[source]

Calculate combined ratio: (losses + expenses) divided by revenue.

expense_ratio(expenses: Any, revenue: Any) Any[source]

Calculate an expense ratio: expenses divided by revenue.

frequency(claim_count: Any, exposure: Any) Any[source]

Calculate claim frequency: claim count divided by exposure.

indicated_change(required: Any, current: Any) Any[source]

Indicated change from current to required amount.

loss_ratio(losses_or_expenses: Any, revenue: Any) Any[source]

Calculate a loss ratio: losses or expenses divided by revenue.

per_exposure(amount: Any, exposure: Any) Any[source]

Calculate amount per exposure unit.

permissible_loss_ratio(expense_ratio: Any, profit_provision: Any = 0.0) Any[source]

Permissible (target / break-even) loss ratio.

PLR = 1 - expense_ratio - profit_provision where both loadings are expressed as a fraction of premium. Also called the zero-margin or target loss ratio: the loss ratio at which premium exactly covers losses, expenses, and the profit/contingency provision. Works element-wise on scalars or Series. (Shops that load fixed expenses on a loss basis instead use (1 - V - Q) / (1 + G); this implements the premium-basis form.)

pure_premium(losses: Any, exposure: Any) Any[source]

Calculate pure premium: losses divided by exposure.

ratio(numerator: Any, denominator: Any) Any[source]

Calculate a generic ratio as numerator divided by denominator.

required_revenue(expense: Any, target_ratio: Any) Any[source]

Revenue needed for an expense amount to hit a target ratio.

safe_divide(numerator: Any, denominator: Any, *, fill_value: float = np.nan) Any[source]

Safely divide numerator by denominator.

The return type mirrors the input: scalars return scalars, array-likes return NumPy arrays, and pandas inputs return pandas objects with their index (and name) preserved – so results can be assigned straight back onto the source DataFrame. Zero denominators are returned as fill_value.

severity(losses: Any, claim_count: Any) Any[source]

Calculate severity: losses divided by claim count.

class ChainLadder(age_to_age: Series, cdf: Series, completion_factors: Series, tail: float, method: str)[source]

Bases: object

Chain-ladder development pattern fitted from a cumulative triangle.

Fit with fit() from a cumulative development triangle (for example the output of make_completion_triangle() with cumulative=True):

  • age_to_age – link (age-to-age) factors, indexed by their starting development period.

  • cdf – cumulative development factor to ultimate by development period, including the tail.

  • completion_factors1 / cdf by development period: the proportion of ultimate emerged by each development period. These are divide-convention factors in (0, 1] (completed = paid / factor), so they line up with validate_completion_factors() and downstream completion.

Use project() to apply the pattern to a triangle and get per-origin ultimate and IBNR.

classmethod fit(triangle: DataFrame, *, method: str = 'volume', tail: float = 1.0) ChainLadder[source]

Estimate the development pattern from a cumulative triangle.

method is "volume" (volume-weighted age-to-age factors, the default) or "simple" (straight average of individual link ratios). tail (>= 1) extends development beyond the latest observed development period.

mack_sigma_squared(triangle: DataFrame) Series[source]

Mack’s variance parameters \(\sigma_k^2\) per development period.

The chain-ladder variance assumption is \(\mathrm{Var}(C_{i,k+1} \mid C_{i,k}) = \sigma_k^2\, C_{i,k}\); the unbiased estimator (Mack, 1993) is

\[\hat\sigma_k^2 = \frac{1}{n_k - 1} \sum_i C_{i,k} \left( \frac{C_{i,k+1}}{C_{i,k}} - \hat f_k \right)^2 .\]

The final development period has only one observed link ratio, so its \(\sigma^2\) cannot be estimated; Mack’s log-linear extrapolation is used: \(\hat\sigma_{K-1}^2 = \min(\hat\sigma_{K-2}^4 / \hat\sigma_{K-3}^2, \min(\hat\sigma_{K-3}^2, \hat\sigma_{K-2}^2))\).

Only defined for method="volume" – Mack’s model is the volume-weighted estimator; the assumptions do not describe the simple-average factors.

mack_standard_errors(triangle: DataFrame) DataFrame[source]

Per-origin and total reserve standard errors (Mack, 1993).

The distribution-free chain-ladder mean squared error: for origin i with ultimate \(\hat C_{iK}\),

\[\widehat{\mathrm{mse}}_i = \hat C_{iK}^2 \sum_k \frac{\hat\sigma_k^2}{\hat f_k^2} \left( \frac{1}{\hat C_{ik}} + \frac{1}{S_k} \right),\]

summing over the unobserved development periods, with \(S_k\) the column sum entering \(\hat f_k\); the total adds Mack’s cross-origin covariance term (estimation error is shared, process error is not), computed pairwise over the development periods unobserved by both origins of each pair, so the result does not depend on the triangle’s row order.

Returns one row per origin plus "Total": latest, ultimate, ibnr, se, cv (se / ibnr). se is conditional on the fitted tail – a tail factor beyond the triangle carries no estimated variance and is treated as deterministic (stated here rather than hidden).

project(triangle: DataFrame) DataFrame[source]

Project ultimate and IBNR per origin by applying the fitted pattern.

For each origin, takes its latest observed cumulative amount and multiplies by the cumulative development factor at that development period. Returns one row per origin with the latest development period, latest cumulative, development factor applied, ultimate, and IBNR (ultimate minus latest).

exception InsufficientDataWarning[source]

Bases: UserWarning

Emitted when a segment has too little data to fit and is skipped or aggregated.

Filter it with the standard warnings machinery, e.g. warnings.filterwarnings("ignore", category=InsufficientDataWarning).

chain_ladder_by(df: DataFrame, *, groupby: str | list[str], origin_col: str, valuation_col: str, amount_col: str, cumulative: bool = True, method: str = 'volume', tail: float = 1.0, on_insufficient: str = 'raise', warn: bool = True) dict[Any, ChainLadder][source]

Fit a chain-ladder development pattern per segment of df.

Groups df by groupby, builds a development triangle for each segment (see make_completion_triangle()), and fits a ChainLadder to each. Returns {segment_key: ChainLadder} – the key is a scalar for a single grouping column, or a tuple for several.

Segments too small to fit (fewer than two origins or development periods, a zero cumulative, and so on) are handled by on_insufficient:

  • "raise" (default): raise a ValueError naming the failing segment.

  • "skip": omit those segments from the result.

  • "aggregate": use the pooled pattern fit on the whole frame for them.

When on_insufficient is "skip" or "aggregate" and warn is true, an InsufficientDataWarning naming the affected segments is emitted; warn=False suppresses it (the standard warnings filters also apply). To ignore thin segments entirely, use on_insufficient="skip", warn=False.

completion_factors(triangle: DataFrame, *, method: str = 'volume', tail: float = 1.0) Series[source]

Completion factors by development period, via chain-ladder.

Convenience wrapper around ChainLadder: returns the proportion of ultimate emerged by each development period (1 / cdf) estimated from a cumulative triangle. Divide-convention factors in (0, 1] (completed = paid / factor). See ChainLadder for the full pattern and per-origin ultimate/IBNR.

completion_factors_by(df: DataFrame, *, groupby: str | list[str], origin_col: str, valuation_col: str, amount_col: str, cumulative: bool = True, method: str = 'volume', tail: float = 1.0, on_insufficient: str = 'raise', warn: bool = True, development_name: str = 'development_month') DataFrame[source]

Completion factors per segment as a tidy table.

Convenience over chain_ladder_by(): one row per (segment, development period) with the completion factor, ready to review, pivot, or join. Columns are the grouping column(s), development_name, and completion_factor. on_insufficient and warn behave as in chain_ladder_by().

apply_completion(df: DataFrame, factors: Series | DataFrame, *, value_col: str, date_col: str | None = None, valuation_date: Any = None, development_col: str | None = None, by: str | list[str] | None = None, factor_col: str = 'completion_factor', development_name: str = 'development_month', out_col: str | None = None, copy: bool = True) DataFrame[source]

Develop a paid amount to estimated ultimate with completion factors.

For each row the development period is taken from development_col if supplied, otherwise computed as development_months(df[date_col], valuation_date) – the convention make_completion_triangle() uses, so factors from completion_factors() or completion_factors_by() join by construction. The completed amount is paid / factor (the divide convention, factors in (0, 1]).

factors may be either of:

  • a flat Series indexed by development period (one pattern for the whole frame), or

  • a tidy DataFrame of per-segment factors – grouping column(s), a development-period column (development_name) and a factor column (factor_col), the shape completion_factors_by() returns – joined on by plus development period. The table must be unique on by + [development] (a duplicate would fan out the data); this is checked.

The join is by value, never index alignment, so the frame’s own index is irrelevant. A row past its (group’s) largest development period is taken as fully complete (factor 1.0); a development period inside the fitted range but absent stays NaN – a surfaced gap; a row whose group is absent from the factor table stays NaN; a negative development period (incurred after valuation_date) raises. Supply either development_col, or both date_col and valuation_date.

develop_ultimate(df: DataFrame, factors: Series | DataFrame, *, method: str = 'bornhuetter_ferguson', value_col: str, date_col: str | None = None, valuation_date: Any = None, development_col: str | None = None, apriori_col: str | None = None, exposure_col: str | None = None, by: str | list[str] | None = None, factor_col: str = 'completion_factor', development_name: str = 'development_month', out_col: str | None = None, copy: bool = True) DataFrame[source]

Develop a paid amount to estimated ultimate by a chosen reserving method.

All methods share one input – the proportion emerged at each row’s development period, joined exactly as apply_completion() does (flat Series or per-segment table, beyond-the-triangle rows fully emerged). They differ only in how they combine that with the paid-to-date and an a priori expectation:

  • "chain_ladder"paid / emerged. Ignores the a priori; equivalent to apply_completion(). Volatile for immature periods (a thin latest diagonal drives the whole tail).

  • "bornhuetter_ferguson"paid + apriori * (1 - emerged). Takes the unemerged portion from the a priori rather than from the data, so it is stable for green periods. Requires apriori_col (an expected ultimate per row – an input, e.g. a plan, budget, or manual times exposure).

  • "benktander" – one Bornhuetter-Ferguson iteration using the BF ultimate as the a priori: paid + bf * (1 - emerged). A credibility blend sitting between BF and chain ladder (weight emerged on chain ladder). Requires apriori_col.

  • "cape_cod" – Bornhuetter-Ferguson with the a priori derived from the data: a single expected loss ratio per segment, sum(paid) / sum(exposure * emerged), times each row’s exposure. Requires exposure_col (an on-level premium / exposure per row). The loss ratio is mechanical; the exposure base is an input.

The library applies a method; it does not pick the a priori or the exposure base. Supply either development_col or both date_col and valuation_date; pass by with a per-segment factor table (and Cape Cod then derives one loss ratio per segment). Returns df with an out_col (default f"{value_col}_ultimate").

ibnr(completed, paid)[source]

IBNR as completed minus paid (the completed/paid identity).

Works element-wise on scalars or Series. completed and paid must be on the same basis; the result is the amount bridging paid-to-date to ultimate.

lag_months(incurred_date, valuation_date)

Whole months of development between incurred (origin) and valuation.

Either argument may be a scalar, a Series, or array-like, in any combination (e.g. a column of incurred dates against a single valuation date). The result is a Series when either argument is a Series, otherwise a scalar.

development_months(incurred_date, valuation_date)[source]

Whole months of development between incurred (origin) and valuation.

Either argument may be a scalar, a Series, or array-like, in any combination (e.g. a column of incurred dates against a single valuation date). The result is a Series when either argument is a Series, otherwise a scalar.

make_completion_triangle(df: DataFrame, *, origin_col: str, valuation_col: str, amount_col: str, cumulative: bool = True, index_name: str = 'origin_period', development_name: str = 'development_month') DataFrame[source]

Build a development (completion) triangle by origin period and development period.

Each cell aggregates amount_col for an origin month at a given valuation development period (whole months between origin and valuation, via development_months()). amount_col is treated as the incremental amount in each (origin, development period) cell; with cumulative=True – the default, and the usual basis for estimating development/completion factors – the cells are accumulated across development period. Set cumulative=False to return the incremental triangle, or if your input amounts are already cumulative-to-date snapshots.

This consumes a compact development aggregate (one row per origin x valuation, i.e. months x months); it does not require transaction/line-level data.

validate_completion_factors(factors: DataFrame, factor_col: str = 'completion_factor', *, method: str = 'divide') None[source]

Validate completion-factor values for a selected convention.

divide factors (completed = paid / factor) should satisfy 0 < factor <= 1; multiply factors (completed = paid * factor) should satisfy factor >= 1. Useful as a sanity check on estimated factors before they are applied upstream.

class Buhlmann(overall_mean: float, epv: float, vhm: float, n_obs: int)[source]

Bases: object

Bühlmann credibility model.

This implementation assumes each risk has the same number of observations.

Parameters:
  • overall_mean (float) – Estimated collective mean.

  • epv (float) – Estimated expected process variance (EPV).

  • vhm (float) – Estimated variance of hypothetical means (VHM).

  • n_obs (int) – Number of observations per risk.

classmethod fit(data: Any) Buhlmann[source]

Fit a Bühlmann credibility model from data.

Parameters:

data (array-like, shape (m, n)) – Observations for m risks, each with n observations.

Returns:

Fitted Bühlmann model.

Return type:

Buhlmann

Notes

Estimators used:

  • overall_mean = mean of all observations

  • EPV = average of within-risk sample variances

  • VHM = sample variance of risk means minus EPV / n, floored at 0

property k: float

K = EPV / VHM. Returns infinity when VHM = 0.

premium(risk_mean: Any) Any[source]

Compute the Bühlmann credibility premium Z * risk_mean + (1 - Z) * overall_mean.

Parameters:

risk_mean (float or array-like) – Risk-specific sample mean(s).

Returns:

Credibility-weighted premium(s).

Return type:

float or numpy.ndarray

property z: float

Credibility factor Z = n / (n + K). Returns 0 when K is infinite.

class BuhlmannStraub(overall_mean: float, epv: float, vhm: float, weights: Any)[source]

Bases: object

Bühlmann-Straub credibility model.

This implementation allows different exposure weights by risk and period.

Parameters:
  • overall_mean (float) – Estimated collective mean.

  • epv (float) – Estimated expected process variance (EPV).

  • vhm (float) – Estimated variance of hypothetical means (VHM).

  • weights (array-like) – Total weight (exposure) for each risk.

classmethod fit(data: Any, weights: Any) BuhlmannStraub[source]

Fit a Bühlmann-Straub model from observations and weights.

Accepts either a 2D array (equal period counts) or a sequence of 1D arrays per risk (unequal period counts). The estimators are the general unbiased forms

\[\hat s^2 = \frac{\sum_i\sum_j w_{ij}(X_{ij}-\bar X_i)^2}{\sum_i(n_i-1)}, \quad \hat a = \frac{\sum_i m_i(\bar X_i-\bar X)^2 - (r-1)\hat s^2} {m - \sum_i m_i^2/m},\]

with \(k=\hat s^2/\hat a\) and \(Z_i=m_i/(m_i+k)\); a negative \(\hat a\) is floored at 0. For equal period counts this reduces to the usual estimator; unlike a divide-by-mean-weight approximation it stays unbiased when risks have different period counts or exposures.

Parameters:
  • data (array-like) – Either shape (r, n) (equal periods) or a sequence of r 1D arrays X_i whose lengths may differ.

  • weights (array-like) – Exposure weights matching the shape/structure of data.

classmethod from_frame(df, *, group: str, value: str, weight: str, period: str | None = None) BuhlmannStraub[source]

Fit from long-format data: one row per (risk, period).

Parameters:
  • df (pandas.DataFrame) – Long-format observations.

  • group (str) – Column names for the risk identifier, the per-unit observation (e.g. loss per member-month), and the exposure weight.

  • value (str) – Column names for the risk identifier, the per-unit observation (e.g. loss per member-month), and the exposure weight.

  • weight (str) – Column names for the risk identifier, the per-unit observation (e.g. loss per member-month), and the exposure weight.

  • period (str, optional) – Period column; used only to order observations within a risk. The number of observations per risk may differ.

Returns:

Fitted model with groups_ (risk labels), risk_means_, and weights (per-risk total exposure), all aligned to groups_.

Return type:

BuhlmannStraub

property k: float

K = EPV / VHM. Returns infinity when VHM = 0.

premium(risk_mean: Any, weight: Any) Any[source]

Compute the Bühlmann-Straub premium Z_i * risk_mean_i + (1 - Z_i) * overall_mean.

Parameters:
  • risk_mean (float or array-like) – Risk-specific weighted mean(s).

  • weight (float or array-like) – Total exposure weight(s).

Returns:

Credibility-weighted premium(s).

Return type:

float or numpy.ndarray

z(weight: Any) Any[source]

Credibility factor for a given total risk weight: Z_i = w_i / (w_i + K).

Parameters:

weight (float or array-like) – Total exposure weight(s).

Returns:

Credibility factor(s).

Return type:

float or numpy.ndarray

credibility_weighted_estimate(observed: Any, complement: Any, z: Any) Any[source]

Blend an observed estimate with its complement at credibility z.

Returns z * observed + (1 - z) * complement. Scalar inputs return a native float; pandas.Series inputs return a Series with the index preserved; other array-like inputs return a numpy.ndarray. This is the atomic credibility operation; the z may come from a model below, a filed credibility formula, or any other source.

limited_fluctuation_z(exposure: Any, full_credibility_standard: float) Any[source]

Limited-fluctuation (classical) credibility factor – the square-root rule.

Returns Z = min(1, sqrt(exposure / full_credibility_standard)). exposure is the volume credibility is based on (claim counts, member months, life-years, …) and full_credibility_standard is the amount of that volume required for full (Z = 1) credibility – often a filed value. Scalars return a native float; pandas.Series inputs return a Series (index preserved); other array-likes return a numpy.ndarray, so credibility can be computed per group. Feed the result to credibility_weighted_estimate() to blend experience with its complement.

full_credibility_claims(*, confidence: float = 0.90, tolerance: float = 0.05, severity_cv: float | None = None) float[source]

Classical full-credibility standard, in expected number of claims.

Returns the expected claim count for full credibility under the limited-fluctuation model: (z / k) ** 2 for claim frequency, where z is the standard-normal quantile for two-sided confidence and k is the tolerance. The classic 90% / 5% choice gives about 1082 claims. Supplying severity_cv (the coefficient of variation of individual claim severity) inflates it to (z / k) ** 2 * (1 + severity_cv ** 2) for aggregate losses rather than pure frequency.

Many shops use a filed standard instead; pass that straight to limited_fluctuation_z().

add_months_in_force(df: DataFrame, *, effective_col: str, period_start, period_end, termination_col: str | None = None, out_col: str = 'months_in_force', copy: bool = True) DataFrame[source]

Add whole months of overlap between each entity’s in-force window and a period.

The in-force window is [effective, termination] (a missing termination means the period end). The result is clipped to [period_start, period_end] and floored at 0. Month counting is inclusive of both endpoint months, so a full coverage of an N-month period returns N.

add_tenure(df: DataFrame, effective_col: str, as_of, *, tenure_col: str = 'tenure_months', one_based: bool = False, copy: bool = True) DataFrame[source]

Add tenure in whole months from each entity’s effective date to as_of.

as_of is a single reference date (e.g. the experience as-of date). With one_based=True an entity effective in the as-of month has tenure 1 rather than 0, matching “months of experience” conventions.

derive_status(df: DataFrame, *, effective_col: str, as_of, termination_col: str | None = None, first_year_months: int = 12, status_col: str = 'status', labels: dict[str, str] | None = None, copy: bool = True) DataFrame[source]

Derive an active / first-year / termed status as of a reference date.

Classification (in precedence order):

  • termed: a termination date is present and on/before as_of.

  • first_year: not termed and tenure (as_of minus effective) is less than first_year_months. The window is a parameter because “first year” means the first 12 months in some shops and the first policy year in others.

  • active: in force beyond the first-year window.

labels optionally remaps the three canonical values, e.g. {"first_year": "First Year Account", "termed": "Term"}.

earned_exposure(df: DataFrame, exposure_col: str, *, effective_col: str, period_start, period_end, termination_col: str | None = None, period_months: int | None = None, out_col: str | None = None, copy: bool = True) DataFrame[source]

Prorate a full-period exposure by the fraction of the period in force.

earned = exposure * months_in_force / period_months. Use this when each row carries a full-period exposure (e.g. annualized) that must be reduced for mid-period entry or termination. If your data is already monthly, filtering to in-force months with is_in_force() is usually simpler.

is_in_force(df: DataFrame, *, effective_col: str, period_start, period_end, termination_col: str | None = None) Series[source]

Boolean Series: in force at any point during [period_start, period_end].

In force when effective on/before period_end and the entity had not terminated before period_start (a missing termination date means still in force).

age(date_of_birth, as_of, basis: str = 'exact') float[source]

Age of a life at a date on a given basis.

Parameters:
  • date_of_birth (date-like) – Date of birth and the valuation date.

  • as_of (date-like) – Date of birth and the valuation date.

  • basis ({"exact", "last", "nearest"}) – "exact" returns the fractional age; "last" is age last birthday (completed years, ALB); "nearest" is age nearest birthday (ANB).

Returns:

Fractional age for "exact"; an integer age for "last" and "nearest".

Return type:

float or int

exposure_years(entry, exit, study_start, study_end, *, convention: str = 'actual/365') float[source]

Exposure (in years) a record contributes within a study window.

The exposure is the overlap of [entry, exit] with [study_start, study_end], measured under the given day-count convention. Returns 0 when the record and study window do not overlap.

add_exposure_column(df: DataFrame, entry_col: str, exit_col: str, study_start, study_end, *, exposure_col: str = 'exposure_years', convention: str = 'actual/365', copy: bool = True) DataFrame[source]

Add an exposure-years column for each record over a study window.

Useful for building the denominator of an actual-to-expected study.

assign_band(df: DataFrame, value_col: str, bands: Sequence[float], *, labels: Sequence[str] | None = None, band_col: str = 'band', right: bool = False, copy: bool = True) DataFrame[source]

Assign each row to an ordered size band based on value_col.

bands are bin edges. For integer counts the natural form is left-closed (right=False), so bands=[0, 51, 76, 151, 251, 501, inf] yields [0, 51), [51, 76), …. A trailing float("inf") captures the open top band. The resulting column is an ordered categorical so downstream group-bys keep band order.

adjust(df: DataFrame, factors: float | int | Series | DataFrame, *, value_col: str, on: str | list[str] | None = None, by: str | list[str] | None = None, how: str = 'multiply', factor_col: str = 'factor', out_col: str | None = None, audit_col: str | None = None, default: float | None = None, copy: bool = True) DataFrame[source]

Multiply or divide a column by a factor joined on a key.

The general factor-application primitive behind trend, benefit / area / demographic relativities, network discounts – any per-key multiplier. The factor for each row is taken from one of:

  • a scalar factors – one factor for every row (e.g. a single trend factor);

  • a Series indexed by on – one key column (e.g. an area factor by region);

  • a tidy DataFrame keyed by by + on with factor_col – per-segment factors (the shape the *_by estimators return).

and applied to value_col: how="multiply" gives value * factor (loads, trend), how="divide" gives value / factor (backing a factor out).

The join is by value (the frame’s index never participates); the factor table must be unique on its keys – a duplicate would fan out the data – which is enforced. An absent key gives default (NaN when default is None – a surfaced gap, never silently filled); pass default=1.0 when a key missing from the table should mean “no adjustment”. With audit_col, the cumulative net multiplier applied to value_col is accumulated there (factor for multiply, 1 / factor for divide), so a chain of adjustments leaves a per-row record of total restatement.

factor_lookup(df: DataFrame, factors: DataFrame, keys: str | Iterable[str], *, factor_col: str, default: float | None = None) ndarray[source]

Join a factor onto df by value on one or more existing key columns.

The single factor-join primitive behind grouped completion, seasonality, and adjust(). factors is a tidy table containing keys and factor_col; each row of df is matched on its keys values. The factor table must be unique on keys – a duplicate would fan rows out on the join – so this raises otherwise. Returns a float array aligned to df’s row order (the frame’s own index never participates). An absent key gives default (NaN when default is None – a surfaced gap, never silently filled).

add_margin(df: DataFrame, *, premium_col: str, expense_cols: str | Iterable[str], out_col: str = 'margin', ratio_col: str | None = None, exposure_col: str | None = None, per_exposure_col: str | None = None, copy: bool = True) DataFrame[source]

Add an underwriting-margin column (premium minus summed expense columns).

expense_cols is summed row-wise and may mix losses and loadings (e.g. claims, retention, commission, allocated overhead). Optionally also add the margin ratio (ratio_col) and a per-exposure margin (per_exposure_col, requires exposure_col) such as margin per exposure unit.

margin(premium: Any, expenses: Any) Any[source]

Margin = premium - expenses, element-wise.

expenses should already be the total of losses plus any loadings.

margin_ratio(margin_amount: Any, premium: Any) Any[source]

Margin as a fraction of premium = margin / premium.

weighted_mean(values: Any, weights: Any, *, skipna: bool = False) float[source]

Weighted mean with validated, explicit weights.

Parameters:
  • values (array-like) – Row-level rates or ratios to average.

  • weights (array-like) – Non-negative, finite weights, same length as values, with a positive total.

  • skipna (bool) – When True, pairs where the value is NaN are dropped before averaging. Default False: a NaN value propagates to the result, so missing data surfaces instead of silently shrinking the base.

weighted_summary(df: DataFrame, *, value_cols: str | Iterable[str], weight_col: str, groupby: str | Iterable[str] | None = None, skipna: bool = False) DataFrame[source]

Grouped weighted means of one or more value columns.

Each value column x produces x_weighted = \(\sum wx / \sum w\) per group; the weight total is reported as {weight_col}_total so the base of every average is visible.

Typical use: premium-weighted rate actions by cohort, exposure-weighted persistency by segment.

excess_over_threshold(df: DataFrame, loss_col: str, threshold: float, *, keep_cols: str | Iterable[str] | None = None, excess_col: str = 'excess') DataFrame[source]

Return losses strictly above threshold with their excess amount.

excess = loss - threshold for rows where loss > threshold. This is the excess-over-threshold sample used to fit a tail (e.g. a generalized Pareto distribution in extremeloss) or a severity distribution in lossmodels; the threshold is the EVT exceedance threshold / pooling point. keep_cols carries identifier or covariate columns through.

pool_losses(df: DataFrame, loss_col: str, pooling_point: float, *, pooled_col: str = 'pooled_loss', excess_col: str = 'excess_loss', copy: bool = True) DataFrame[source]

Split each loss into a pooled (capped) portion and an excess portion.

pooled = min(loss, pooling_point) is the retained amount used in the group’s experience; excess = max(loss - pooling_point, 0) is the portion pooled across the block. Summing pooled_col by group gives capped experience; summing excess_col gives the pooled excess. The input is typically one row per claimant (e.g. the output of summarize_claimants).

retained_cv(outcomes, retention, *, n_units=1)[source]

Coefficient of variation of the retained aggregate of n_units iid units.

Each unit’s outcome is retained (capped) at retentionmin(outcome, retention) – and n_units such units are summed. For independent units this CV is cv(min(X, retention)) / sqrt(n_units), where X is drawn from the per-unit outcome sample outcomes (array-like). Capping discards everything above retention, so only the body of outcomes matters.

Parameters:
  • outcomes (array-like) – Per-unit outcome sample (e.g. one value per member-year, claim, or risk).

  • retention (float or array-like) – Cap applied to each unit. Scalar returns a float; an array returns the CV at each retention.

  • n_units (int, default 1) – Number of independent units in the aggregate.

Returns:

Coefficient of variation of the retained aggregate.

Return type:

float or numpy.ndarray

retention_for_target_cv(outcomes, n_units, target_cv, *, bounds=None, n_grid=256)[source]

Retention at which the retained aggregate of n_units units hits a target CV.

Inverts retained_cv(). The single-unit retained CV increases with the retention, so this solves retained_cv(outcomes, u, n_units=n_units) == target_cv for the retention u by interpolation over a grid spanning bounds (default min..max of outcomes). Targets below or above the achievable range clamp to the lower or upper bound. Holding target_cv fixed, a larger n_units yields a higher retention (more independent units stabilize the aggregate, so less needs to be capped) – i.e. the basis for a size-graded retention rule.

Parameters:
  • outcomes (array-like) – Per-unit outcome sample.

  • n_units (int) – Number of independent units in the aggregate.

  • target_cv (float) – Desired coefficient of variation of the retained aggregate.

  • bounds (tuple(float, float), optional) – (lo, hi) retention search bounds. Defaults to the min and max of outcomes.

  • n_grid (int, default 256) – Number of grid points spanning bounds.

Returns:

The retention level, clamped to bounds.

Return type:

float

annualized_trend(current: Any, prior: Any, months_between: float) Any[source]

Annualize change between two values separated by a number of months.

midpoint_trend_factor(base_midpoint, projection_midpoint, annual_trend: Any) Any[source]

Trend factor between base and projection midpoints.

period_change(current: Any, prior: Any) Any[source]

Calculate period-over-period change: current / prior - 1.

project_forward(value: Any, annual_trend: Any, months: float) Any[source]

Project a value forward using an annual trend rate.

fit_trend(df: DataFrame | Experience, *, value_col: str | None = None, date_col: str | None = None, exposure_col: str | None = None, freq: str = 'M', min_periods: int = 3, confidence: float = 0.95) TrendFit[source]

Fit an exponential trend to a rate series by log-linear regression.

Accepts an Experience – the bound expense, date, and exposure roles fill value_col, date_col, and exposure_col – or a plain DataFrame with those columns named explicitly.

Aggregates df to the freq grain (summing value_col and, if given, exposure_col), forms the rate – value / exposure (the per-exposure rate) when exposure_col is supplied, otherwise value itself – and fits log(rate) = intercept + slope * t by ordinary least squares, with t in years from the first period. The fitted annual trend is exp(slope) - 1.

Unlike annualized_trend() (a two-point CAGR between a single current and prior value), this uses every period, so one noisy month does not swing the estimate, and it returns goodness of fit and a confidence interval – what a developed (rather than received) trend is judged on. It does not select the trend: the window, the rate basis (allowed vs paid), any benefit leveraging, and the blend with external trends remain judgment. Run it on completed, deseasonalized history (complete -> deseasonalize -> fit_trend) so runout and seasonality do not contaminate the slope; apply the result with trend_factor()/TrendFit.factor() or adjust().

Time is measured from actual period dates, so an occasional missing period is handled correctly. Requires at least min_periods distinct periods with strictly positive rates (non-positive values, which cannot be logged, raise). Returns a TrendFit.

class TrendFit(annual_trend: float, r_squared: float, std_error: float, ci_low: float, ci_high: float, confidence: float, n_periods: int, slope: float, intercept: float)[source]

Bases: object

Result of fit_trend(): an exponential trend fitted to a rate series.

annual_trend is the fitted multiplicative annual trend (exp(slope) - 1 on the log scale). r_squared is the goodness of fit, std_error the delta-method standard error of annual_trend, and (ci_low, ci_high) its confidence interval (asymmetric – the endpoints are transformed from the log-scale slope interval). slope and intercept describe the underlying log(value) = intercept + slope * t fit with t measured in years from the first period.

property ci: tuple[float, float]

The confidence interval as a (low, high) tuple.

factor(months: float) float[source]

Trend factor over months at the fitted rate: (1 + annual_trend) ** (months / 12).

trend_factor(annual_trend: Any, months: float) Any[source]

Convert an annual trend rate into a trend factor over a number of months.

trend_summary(df: DataFrame | Experience, *, 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, groupby=None, amount_col: str | None = None, exposure_col: str | None = None, prior_filter=None, current_filter=None, prior_label: str = 'prior', current_label: str = 'current') DataFrame[source]

Summarize current vs prior trend by optional grouping.

Accepts an Experience (bound expense / exposure / date roles fill amount_col, exposure_col, and – for date-range comparisons – date_col) or a plain DataFrame with amount_col named explicitly.

Supported comparison modes: - period_col='year', prior_period=2025, current_period=2026 - date_col='incurred_date' with prior/current start and end dates - explicit boolean prior_filter and current_filter masks

business_days_in_period(periods: Any, *, freq: str = 'M', holidays: Any = 'us_federal', weekmask: str = 'Mon Tue Wed Thu Fri') Series[source]

Count business days (weekdays minus holidays) in each distinct period.

periods is any set of dates; they are mapped to their period (month or quarter) and de-duplicated. holidays is "us_federal" (pandas’ built-in US federal calendar), None (weekdays only), or a list of holiday dates. weekmask controls which weekdays count. Returns a Series indexed by period start timestamp.

add_business_days(df: DataFrame, date_col: str, *, freq: str = 'M', out_col: str = 'business_days', holidays: Any = 'us_federal', weekmask: str = 'Mon Tue Wed Thu Fri', copy: bool = True) DataFrame[source]

Add a column with the number of business days in each row’s period.

Divide a paid-amount column by this to get an amount-per-business-day series that is comparable across short and long months.

seasonality_factors(df: DataFrame, *, date_col: str, value_col: str, exposure_col: str | None = None, freq: str = 'M', method: str = 'ratio_to_moving_average', aggregate: str = 'mean', exclude: Iterable[int] | None = None, min_years: int = 2) Series[source]

Estimate seasonal factors – one multiplier per calendar period, mean 1.0.

The series is first aggregated to the period grain (summing value_col and, if given, exposure_col). With exposure_col the factors are computed on the rate value / exposure, the right basis when exposure itself moves with the season; without it they are computed on the value directly.

Methods:

  • "ratio_to_moving_average" (default): classical multiplicative decomposition. Each period is divided by a centered moving average (which removes trend and level), and the seasonal factor for a calendar period is the average of those ratios across years. Robust to trend and exposure growth.

  • "period_share": each period expressed as a share of its own year’s average, then averaged by calendar period. Simpler, but assumes little within-year trend.

aggregate is "mean" or "median" (median is more robust to outlier months). exclude drops whole years from the estimate – e.g. exclude=[2020, 2021] to keep COVID-distorted years out of the factors. A warning is raised when fewer than min_years years inform any period. Factors are normalized to average exactly 1.0.

seasonality_factors_by(df: DataFrame, *, groupby: str | list[str], date_col: str, value_col: str, exposure_col: str | None = None, freq: str = 'M', method: str = 'ratio_to_moving_average', aggregate: str = 'mean', exclude: Iterable[int] | None = None, min_years: int = 2, season_name: str = 'season', warn: bool = True) DataFrame[source]

Seasonal factors per segment as a tidy table.

Fits seasonality_factors() within each segment of groupby and stacks the results into one row per (segment, season) – columns are the grouping column(s), season_name, and seasonal_factor – the shape deseasonalize() and apply_seasonality() consume via by=. Seasons absent from a segment’s history are omitted for that segment (they surface as NaN on join). Set warn=False to silence the thin-history InsufficientDataWarning per segment.

deseasonalize(df: DataFrame, factors: Series | DataFrame, *, date_col: str, value_col: str, freq: str = 'M', by: str | list[str] | None = None, factor_col: str = 'seasonal_factor', season_name: str = 'season', out_col: str | None = None, copy: bool = True) DataFrame[source]

Divide value_col by each row’s seasonal factor, removing the pattern.

factors is either a flat Series indexed by season (one pattern for the frame) or a tidy per-segment DataFrame – grouping column(s), a season column (season_name) and a factor column (factor_col), the shape seasonality_factors_by() returns – joined on by plus season. The grouped join is by value (index irrelevant), the factor table must be unique on by + [season], and a row whose (group, season) is absent yields NaN.

apply_seasonality(df: DataFrame, factors: Series | DataFrame, *, date_col: str, value_col: str, freq: str = 'M', by: str | list[str] | None = None, factor_col: str = 'seasonal_factor', season_name: str = 'season', out_col: str | None = None, copy: bool = True) DataFrame[source]

Multiply value_col by each row’s seasonal factor, adding the pattern back.

factors may be flat (Series indexed by season) or a tidy per-segment table joined on by plus season; see deseasonalize() for the grouped-table contract.

to_period(values, freq: str)[source]

Convert scalar or array-like date values to pandas Period values.

add_period_column(df: DataFrame, date_col: str, freq: str, period_col: str | None = None, *, copy: bool = True) DataFrame[source]

Add a pandas Period column from a date column.

Common frequencies include M, Q, and Y.

absolute_change(current: Any, prior: Any) Any[source]

Calculate current minus prior.

percent_change(current: Any, prior: Any) Any[source]

Calculate percent change: current / prior - 1.

basis_point_change(current_ratio: Any, prior_ratio: Any) Any[source]

Calculate basis point change between two decimal ratios.

variance(actual: Any, expected: Any) Any[source]

Calculate actual minus expected.

variance_pct(actual: Any, expected: Any) Any[source]

Calculate variance as percent of expected: actual / expected - 1.

share_of_total(component, total)[source]

Calculate component share of total.

contribution_to_change(component_change, total_change)[source]

Calculate component contribution to a total change.

top_contributors(df: DataFrame, amount_col: str, *, n: int = 10, ascending: bool = False, by_abs: bool = False) DataFrame[source]

Return top contributors by signed or absolute amount.

component_contribution(df: DataFrame, *, component_cols, total_col: str | None = None, prefix: str = 'share', copy: bool = True) DataFrame[source]

Add component share-of-total columns for a set of component columns.

discount_factor(i: Any, t: float = 1.0) Any[source]

Discount factor \(v^t = (1+i)^{-t}\).

Accepts a scalar rate, a NumPy array, or a pandas Series and returns the same kind (a Series keeps its index and name), so a column of rates maps to a column of factors.

accumulation_factor(i: Any, t: float = 1.0) Any[source]

Accumulation factor \((1+i)^t\).

Scalar in, scalar out; array or Series in, same out (index preserved).

effective_discount(i: Any) Any[source]

Effective rate of discount \(d = i/(1+i) = 1 - v\).

Scalar in, scalar out; array or Series in, same out (index preserved).

force_of_interest(i: Any) Any[source]

Force of interest \(\delta = \ln(1+i)\).

Scalar in, scalar out; array or Series in, same out (index preserved).

rate_from_force(delta: Any) Any[source]

Effective rate from the force of interest: \(i = e^\delta - 1\).

Scalar in, scalar out; array or Series in, same out (index preserved).

nominal_interest(i: Any, m: int) Any[source]

Nominal interest convertible m times: \(i^{(m)} = m[(1+i)^{1/m}-1]\).

Scalar in, scalar out; array or Series in, same out (index preserved).

nominal_discount(i: Any, m: int) Any[source]

Nominal discount convertible m times: \(d^{(m)} = m[1-v^{1/m}]\).

Scalar in, scalar out; array or Series in, same out (index preserved).

rate_from_nominal_interest(nominal: Any, m: int) Any[source]

Effective rate from a nominal interest rate: \((1+i^{(m)}/m)^m - 1\).

Scalar in, scalar out; array or Series in, same out (index preserved).

rate_from_nominal_discount(nominal: Any, m: int) Any[source]

Effective rate from a nominal discount rate: \((1-d^{(m)}/m)^{-m} - 1\).

Scalar in, scalar out; array or Series in, same out (index preserved).

present_value(amount: Any, i: Any, t: float) Any[source]

Present value of a single amount due in t years.

amount and i may be scalars, arrays, or pandas Series and broadcast together; a pandas operand carries its index (and name) to the result.

future_value(amount: Any, i: Any, t: float) Any[source]

Accumulated value of a single amount after t years.

amount and i may be scalars, arrays, or pandas Series and broadcast together; a pandas operand carries its index (and name) to the result.

annuity_immediate(i: Any, n: int) Any[source]

Present value of an annuity-immediate \(a_{\overline{n}|}=(1-v^n)/i\).

Accepts a scalar rate, array, or pandas Series and returns the same kind (a Series keeps its index and name). The \(i = 0\) limit evaluates to n element-wise.

annuity_due(i: Any, n: int) Any[source]

Present value of an annuity-due \(\ddot a_{\overline{n}|}=(1-v^n)/d\).

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to n element-wise.

accumulated_immediate(i: Any, n: int) Any[source]

Accumulated value of an annuity-immediate \(s_{\overline{n}|}\).

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to n element-wise.

accumulated_due(i: Any, n: int) Any[source]

Accumulated value of an annuity-due \(\ddot s_{\overline{n}|}\).

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to n element-wise.

perpetuity_immediate(i: Any) Any[source]

Present value of a perpetuity-immediate \(1/i\).

Requires i > 0 (element-wise for array or Series input). Scalar in, scalar out; array or Series in, same out (index preserved).

perpetuity_due(i: Any) Any[source]

Present value of a perpetuity-due \(1/d\).

Requires i > 0 (element-wise for array or Series input). Scalar in, scalar out; array or Series in, same out (index preserved).

deferred_annuity_immediate(i: Any, n: int, defer: int) Any[source]

Present value of an n-year annuity-immediate deferred defer years.

Scalar in, scalar out; array or Series in, same out (index preserved).

annuity_continuous(i: Any, n: int) Any[source]

Present value of a continuous annuity \(\bar a_{\overline{n}|}=(1-v^n)/\delta\).

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to n element-wise.

annuity_immediate_mthly(i: Any, n: int, m: int) Any[source]

Present value of an m-thly annuity-immediate \(a^{(m)}_{\overline{n}|}\).

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to n element-wise.

increasing_annuity_immediate(i: Any, n: int) Any[source]

Present value of an increasing annuity \((Ia)_{\overline{n}|}\).

Payments of 1, 2, …, n at times 1, …, n. Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to \(n(n+1)/2\) element-wise.

decreasing_annuity_immediate(i: Any, n: int) Any[source]

Present value of a decreasing annuity \((Da)_{\overline{n}|}\).

Payments of n, n-1, …, 1 at times 1, …, n. Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = 0\) limit evaluates to \(n(n+1)/2\) element-wise.

geometric_annuity_immediate(i: Any, n: int, growth: float) Any[source]

Present value of a geometrically increasing annuity-immediate.

Payments \(1, (1+g), (1+g)^2, \ldots\) at times \(1, \ldots, n\):

\[\frac{1 - \left(\frac{1+g}{1+i}\right)^n}{i - g}, \qquad i \neq g.\]

Scalar in, scalar out; array or Series in, same out (index preserved). The \(i = g\) limit evaluates to \(n/(1+i)\) element-wise.

net_present_value(rate: float, cashflows: Sequence[float], times: Sequence[float] | None = None) float[source]

Net present value of cashflows discounted at rate.

If times is omitted the cash flows are assumed to occur at times 0, 1, 2, ....

internal_rate_of_return(cashflows: Sequence[float], times: Sequence[float] | None = None, *, low: float = -0.9999, high: float = 1e6, tol: float = 1e-10) float[source]

Internal rate of return: the rate solving net_present_value == 0.

Uses a bracketed bisection over (low, high), which is robust for the usual single-sign-change cash-flow streams. Raises if no sign change is found in the search range (e.g. all-positive or all-negative flows).

level_payment(principal: Any, i: Any, n: int) Any[source]

Level payment amortizing principal over n periods at rate i.

\(P = L / a_{\overline{n}|}\). principal and i may be scalars, arrays, or pandas Series and broadcast together; a pandas operand carries its index (and name) to the result.

outstanding_balance(principal: Any, i: Any, n: int, t: int) Any[source]

Prospective outstanding loan balance just after the t-th payment.

principal and i may be scalars, arrays, or pandas Series and broadcast together; a pandas operand carries its index (and name) through.

amortization_schedule(principal: float, i: float, n: int, payment: float | None = None) DataFrame[source]

Amortization schedule with the interest/principal split and balance.

Returns one row per period with columns period, payment, interest, principal, and balance.

discount_factors(spot_rates: Sequence[float], times: Sequence[float]) ndarray[source]

Discount factors \((1+s_t)^{-t}\) from spot rates at times.

present_value_curve(cashflows: Sequence[float], spot_rates: Sequence[float], times: Sequence[float]) float[source]

Present value of cashflows discounted on a spot-rate curve.

year_fraction(start: object, end: object, convention: str = 'actual/365') float[source]

Year fraction between two dates under a day-count convention.

Supported conventions: "actual/365", "actual/360", "30/360" (US/NASD), and "actual/actual" (ISDA).