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:
objectBind an experience dataset to its actuarial column roles and grain.
Role columns¶
expense,revenue,exposure,countMeasure 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.
dateThe experience date column (incurred month, policy month, …).
Grain metadata¶
dimensionsSegmentation 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_keysColumns 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_dateThe paid-through / as-of date of the data.
complete()uses it as the default valuation date.basisPer-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 anExperience.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
Experiencewith an expense column restated by a factor.The general counterpart to
complete()anddeseasonalize(): joins a factor by the keyon(a column already in the frame, optionally withinbysegments) and multiplies – or, withhow="divide", divides – the selected column(s) in place under the same name.factorsis a scalar, a Series indexed byon, or a tidy DataFrame keyed byby + on. This is the spine of experience-period restatement – trend, benefit / area / demographic relativities, network discounts – where the methodology is supplied as the factors rather than encoded here. Chain freely (exp.complete(...).adjust(trend).adjust(area, on="region")); withaudit_colthe cumulative restatement multiplier is carried across the chain. An absent key surfaces asNaNunlessdefaultis given (default=1.0to mean “no adjustment for this key”).
- aggregate(by: str | Iterable[str] | None = None, *, freq: str | None = None) Experience[source]¶
Return a new
Experiencesummed to a coarser grain.bynames 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
Experiencewith an exposure role must haveexposure_keysbound. The result’sexposure_keysare 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
Experiencewith paid amounts developed to ultimate.Grosses the expense (loss / claims) columns up to estimated ultimate in place under the same names –
completed = paid / completion_factor. Each row’s development period isdevelopment_months(date, valuation_date)(the conventionmake_completion_triangle()uses), or an explicitdevelopment_col.valuation_datedefaults to the object’s bound valuation date.factorsmay be a flat Series (one pattern, fromcompletion_factors()) or a tidy per-segment table fromcompletion_factors_by(); with the latter, passbynaming the grouping column(s). Only the numerator is developed – exposure is left untouched.Completing marks each developed column
"ultimate"inbasis, 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
Experiencewith the seasonal pattern divided out.Each selected column is divided by its row’s seasonal factor (as produced by
seasonality_factors()), in place under the same name. By default the expense columns are adjusted; passcolumnsto choose others. Only the numerator is touched.factorsmay be a flat Series (one pattern) or a tidy per-segment table fromseasonality_factors_by(); with the latter passby. Estimate factors on the broader pool, not on this object’s own (often thin) data. To put the pattern back, applyapply_seasonality()to.data.
- filter(mask: Any | None = None, *, query: str | None = None, copy: bool = True) Experience[source]¶
Return a new
Experienceover 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
Experiencefrom source tables: multi-table at the doorway, single-table inside.datais the table that defines the grain – one row per exposure unit (typically membership / eligibility). It is validated unique ongrain, contributes theexposurerole, and keeps all its other columns (entity attributes ride along). EachSourcespec 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 indataare surfaced perunmatched("warn"or"raise") – never dropped silently;wide_bypivots are recorded asPivotprovenance so they can be undone structurally bymelt().
The result is an ordinary single-grain
Experiencewithexposure_keysset tograin(uniqueness was just proven).
- melt(pivot: str | None = None) Experience[source]¶
Undo a recorded
wide_bypivot, returning a longExperience.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 byfrom_tables()can be melted, because only those have one right inverse.The melted frame repeats each exposure unit once per category, so
exposure_keysis 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
Experiencewith 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
Experiencewith a derived lifecycle status column.Derives active / first-year / termed from effective and termination dates as of a reference date (see
actuarialpy.derive_status()).
- class ExperienceSet(tab: Experience, listings: Mapping[str, Experience], manifest: Mapping[str, Any], _sources: Mapping[str, Any])[source]¶
Bases:
objectA coordinated bundle of grain-honest
Experiencemembers.Build with
from_tables().tabis the worksheet at the declared grain (viaExperience.from_tables); each namedSourcespec also yields a listing member at its own source grain, reachable bybook["claims"]. Members are ordinary, materializedExperienceobjects: what a member’s.datashows 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 plainExperienceobjects.- cohort(**predicates: Any) ExperienceSet[source]¶
A new
ExperienceSetrestricted 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– theSourcedeclarations 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:
objectOne measure table for
Experience.from_tables().Declares which columns of
datacarry which measure role (the same role vocabulary asExperience:expense,revenue,count), plus how the table reaches the grain:wide_byA 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.
dateThis table’s own date column (e.g.
incurred_date). It is floored to the constructor’speriodto 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).keysMaps 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.nameNames this source as a listing member of an
ExperienceSet(name="claims"->book["claims"]). Ignored byExperience.from_tables.
- class Pivot(by: str, role: str, value: str, columns: tuple[str, ...])[source]¶
Bases:
objectProvenance of one
wide_bypivot performed byExperience.from_tables().Records that categorical column
bywas pivoted so that measure columnvaluebecame the widecolumnsunder measure rolerole. Stored on the resultingExperiencesoExperience.melt()(and consumers such asprojectionmodels.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
daterole.
- 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,
Noneif 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.
- permissible_loss_ratio(expense_ratio: Any, profit_provision: Any = 0.0) Any[source]¶
Permissible (target / break-even) loss ratio.
PLR = 1 - expense_ratio - profit_provisionwhere 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.)
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:
objectChain-ladder development pattern fitted from a cumulative triangle.
Fit with
fit()from a cumulative development triangle (for example the output ofmake_completion_triangle()withcumulative=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_factors–1 / cdfby 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 withvalidate_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.
methodis"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
iwith 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).seis conditional on the fitted tail – atailfactor 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:
UserWarningEmitted when a segment has too little data to fit and is skipped or aggregated.
Filter it with the standard
warningsmachinery, 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
dfbygroupby, builds a development triangle for each segment (seemake_completion_triangle()), and fits aChainLadderto 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 aValueErrornaming the failing segment."skip": omit those segments from the result."aggregate": use the pooled pattern fit on the whole frame for them.
When
on_insufficientis"skip"or"aggregate"andwarnis true, anInsufficientDataWarningnaming the affected segments is emitted;warn=Falsesuppresses it (the standardwarningsfilters also apply). To ignore thin segments entirely, useon_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). SeeChainLadderfor 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, andcompletion_factor.on_insufficientandwarnbehave as inchain_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_colif supplied, otherwise computed asdevelopment_months(df[date_col], valuation_date)– the conventionmake_completion_triangle()uses, so factors fromcompletion_factors()orcompletion_factors_by()join by construction. The completed amount ispaid / factor(the divide convention, factors in(0, 1]).factorsmay 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 shapecompletion_factors_by()returns – joined onbyplus development period. The table must be unique onby + [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 staysNaN– a surfaced gap; a row whose group is absent from the factor table staysNaN; a negative development period (incurred aftervaluation_date) raises. Supply eitherdevelopment_col, or bothdate_colandvaluation_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 toapply_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. Requiresapriori_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 (weightemergedon chain ladder). Requiresapriori_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. Requiresexposure_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_color bothdate_colandvaluation_date; passbywith a per-segment factor table (and Cape Cod then derives one loss ratio per segment). Returnsdfwith anout_col(defaultf"{value_col}_ultimate").
- ibnr(completed, paid)[source]¶
IBNR as completed minus paid (the completed/paid identity).
Works element-wise on scalars or Series.
completedandpaidmust 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_colfor an origin month at a given valuation development period (whole months between origin and valuation, viadevelopment_months()).amount_colis treated as the incremental amount in each (origin, development period) cell; withcumulative=True– the default, and the usual basis for estimating development/completion factors – the cells are accumulated across development period. Setcumulative=Falseto 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.
dividefactors (completed = paid / factor) should satisfy0 < factor <= 1;multiplyfactors (completed = paid * factor) should satisfyfactor >= 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:
objectBü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:
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.
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:
objectBü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 ofr1D arraysX_iwhose 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_, andweights(per-risk total exposure), all aligned togroups_.- Return type:
- property k: float¶
K = EPV / VHM. Returns infinity when VHM = 0.
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
- 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 nativefloat;pandas.Seriesinputs return aSerieswith the index preserved; other array-like inputs return anumpy.ndarray. This is the atomic credibility operation; thezmay 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)).exposureis the volume credibility is based on (claim counts, member months, life-years, …) andfull_credibility_standardis the amount of that volume required for full (Z = 1) credibility – often a filed value. Scalars return a nativefloat;pandas.Seriesinputs return aSeries(index preserved); other array-likes return anumpy.ndarray, so credibility can be computed per group. Feed the result tocredibility_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) ** 2for claim frequency, wherezis the standard-normal quantile for two-sidedconfidenceandkis thetolerance. The classic 90% / 5% choice gives about 1082 claims. Supplyingseverity_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_ofis a single reference date (e.g. the experience as-of date). Withone_based=Truean 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_ofminus effective) is less thanfirst_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.
labelsoptionally 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 withis_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_endand the entity had not terminated beforeperiod_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.bandsare bin edges. For integer counts the natural form is left-closed (right=False), sobands=[0, 51, 76, 151, 251, 501, inf]yields[0, 51),[51, 76), …. A trailingfloat("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 + onwithfactor_col– per-segment factors (the shape the*_byestimators return).
and applied to
value_col:how="multiply"givesvalue * factor(loads, trend),how="divide"givesvalue / 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(NaNwhendefaultisNone– a surfaced gap, never silently filled); passdefault=1.0when a key missing from the table should mean “no adjustment”. Withaudit_col, the cumulative net multiplier applied tovalue_colis accumulated there (factorfor multiply,1 / factorfor 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
dfby value on one or more existing key columns.The single factor-join primitive behind grouped completion, seasonality, and
adjust().factorsis a tidy table containingkeysandfactor_col; each row ofdfis matched on itskeysvalues. The factor table must be unique onkeys– a duplicate would fan rows out on the join – so this raises otherwise. Returns a float array aligned todf’s row order (the frame’s own index never participates). An absent key givesdefault(NaNwhendefaultisNone– 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_colsis 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, requiresexposure_col) such as margin per exposure unit.
- margin(premium: Any, expenses: Any) Any[source]¶
Margin = premium - expenses, element-wise.
expensesshould 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
xproducesx_weighted= \(\sum wx / \sum w\) per group; the weight total is reported as{weight_col}_totalso 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
thresholdwith their excess amount.excess = loss - thresholdfor rows whereloss > threshold. This is the excess-over-threshold sample used to fit a tail (e.g. a generalized Pareto distribution inextremeloss) or a severity distribution inlossmodels; the threshold is the EVT exceedance threshold / pooling point.keep_colscarries 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. Summingpooled_colby group gives capped experience; summingexcess_colgives the pooled excess. The input is typically one row per claimant (e.g. the output ofsummarize_claimants).
- retained_cv(outcomes, retention, *, n_units=1)[source]¶
Coefficient of variation of the retained aggregate of
n_unitsiid units.Each unit’s outcome is retained (capped) at
retention–min(outcome, retention)– andn_unitssuch units are summed. For independent units this CV iscv(min(X, retention)) / sqrt(n_units), whereXis drawn from the per-unit outcome sampleoutcomes(array-like). Capping discards everything aboveretention, so only the body ofoutcomesmatters.- 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_unitsunits hits a target CV.Inverts
retained_cv(). The single-unit retained CV increases with the retention, so this solvesretained_cv(outcomes, u, n_units=n_units) == target_cvfor the retentionuby interpolation over a grid spanningbounds(defaultmin..maxofoutcomes). Targets below or above the achievable range clamp to the lower or upper bound. Holdingtarget_cvfixed, a largern_unitsyields 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 ofoutcomes.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 fillvalue_col,date_col, andexposure_col– or a plain DataFrame with those columns named explicitly.Aggregates
dfto thefreqgrain (summingvalue_coland, if given,exposure_col), forms the rate –value / exposure(the per-exposure rate) whenexposure_colis supplied, otherwisevalueitself – and fitslog(rate) = intercept + slope * tby ordinary least squares, withtin years from the first period. The fitted annual trend isexp(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 withtrend_factor()/TrendFit.factor()oradjust().Time is measured from actual period dates, so an occasional missing period is handled correctly. Requires at least
min_periodsdistinct periods with strictly positive rates (non-positive values, which cannot be logged, raise). Returns aTrendFit.
- 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:
objectResult of
fit_trend(): an exponential trend fitted to a rate series.annual_trendis the fitted multiplicative annual trend (exp(slope) - 1on the log scale).r_squaredis the goodness of fit,std_errorthe delta-method standard error ofannual_trend, and(ci_low, ci_high)its confidence interval (asymmetric – the endpoints are transformed from the log-scale slope interval).slopeandinterceptdescribe the underlyinglog(value) = intercept + slope * tfit withtmeasured in years from the first period.- property ci: tuple[float, float]¶
The confidence interval as a
(low, high)tuple.
- 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 fillamount_col,exposure_col, and – for date-range comparisons –date_col) or a plain DataFrame withamount_colnamed 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 booleanprior_filterandcurrent_filtermasks
- 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.
periodsis any set of dates; they are mapped to their period (month or quarter) and de-duplicated.holidaysis"us_federal"(pandas’ built-in US federal calendar),None(weekdays only), or a list of holiday dates.weekmaskcontrols 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_coland, if given,exposure_col). Withexposure_colthe factors are computed on the ratevalue / 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.
aggregateis"mean"or"median"(median is more robust to outlier months).excludedrops 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 thanmin_yearsyears 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 ofgroupbyand stacks the results into one row per(segment, season)– columns are the grouping column(s),season_name, andseasonal_factor– the shapedeseasonalize()andapply_seasonality()consume viaby=. Seasons absent from a segment’s history are omitted for that segment (they surface asNaNon join). Setwarn=Falseto silence the thin-historyInsufficientDataWarningper 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_colby each row’s seasonal factor, removing the pattern.factorsis 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 shapeseasonality_factors_by()returns – joined onbyplus season. The grouped join is by value (index irrelevant), the factor table must be unique onby + [season], and a row whose(group, season)is absent yieldsNaN.
- 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_colby each row’s seasonal factor, adding the pattern back.factorsmay be flat (Series indexed by season) or a tidy per-segment table joined onbyplus season; seedeseasonalize()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, andY.
- 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_pct(actual: Any, expected: Any) Any[source]¶
Calculate variance as percent of expected: actual / expected - 1.
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
mtimes: \(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
mtimes: \(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
amountdue intyears.amountandimay 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
amountaftertyears.amountandimay 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
nelement-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
nelement-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
nelement-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
nelement-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 deferreddeferyears.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
nelement-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
nelement-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
cashflowsdiscounted atrate.If
timesis omitted the cash flows are assumed to occur at times0, 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
ratesolvingnet_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
principalovernperiods at ratei.\(P = L / a_{\overline{n}|}\).
principalandimay 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.principalandimay 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, andbalance.
- discount_factors(spot_rates: Sequence[float], times: Sequence[float]) ndarray[source]¶
Discount factors \((1+s_t)^{-t}\) from spot rates at
times.