Example 2: pricing a book, in columns¶
Everything in ratingmodels follows the
vectorization contract: scalar in,
float out; column in, column out. That makes column-wise the default way to
run anything bigger than one case — the objects are the same ones the scalar
API uses, their fields are just columns, and one call prices the book. This
page rates a three-group block end to end without a single Python loop:
claim file -> grouped pooling -> experience and manual rates
-> credibility -> indication -> per-case decomposition
-> capped renewals -> pricing scenarios -> book-level uplift
Every number on this page is the output of this exact run, pinned by a
regression test in the ratingmodels suite.
The book¶
One row per group, plus a large-claim file and each group’s routine (bulk) claims:
import numpy as np
import pandas as pd
import ratingmodels as rm
book = pd.DataFrame(
{
"exposure": [9_600.0, 14_400.0, 6_000.0], # member-months here
"current": [545.0, 560.0, 530.0], # charged today
"base": [420.0, 435.0, 410.0], # manual base loss cost
"area": [1.05, 0.98, 1.12], # manual relativities
"industry": [1.10, 1.00, 0.95],
"n_claims": [820.0, 1_450.0, 260.0], # credibility counts
},
index=pd.Index(["G1", "G2", "G3"], name="group"),
)
large = pd.DataFrame({"group": ["G1", "G1", "G2", "G3", "G3"],
"amount": [390e3, 310e3, 420e3, 610e3, 260e3]})
bulk = pd.Series([3.65e6, 5.88e6, 2.03e6], index=book.index)
Pool the claim file¶
Grouped questions take by=. One pass over the claim file pools every
group at once:
_, excess = rm.pool_claims(large["amount"], 250_000, by=large["group"])
incurred = bulk + large.groupby("group")["amount"].sum()
excess # G1 200,000 G2 170,000 G3 370,000
incurred # G1 4,350,000 G2 6,300,000 G3 2,900,000
Experience and manual rates¶
The same constructors as the scalar workflow — the fields are columns now. Validation stays row-level: one bad row fails the call, and the error names the offending index label.
retention = rm.RetentionLoad(fixed_expense=12.0, variable_expense_ratio=0.11,
profit_margin=0.03, lae_ratio=0.02)
experience = rm.ExperienceRate(
incurred_claims=incurred,
exposure=book["exposure"],
trend_annual=0.07, trend_years=1.5, # factor 1.1068, broadcast
pooled_excess=excess,
pooling_charge=28.0,
retention=retention,
)
manual = rm.ManualRate(
book["base"],
{"area": book["area"], "industry": book["industry"]},
retention=retention,
)
z = rm.limited_fluctuation_credibility(book["n_claims"], n_full=1_082)
experience.loss_cost() # G1 506.47 G2 499.17 G3 494.71
manual.loss_cost() # G1 485.10 G2 426.30 G3 436.24
z # G1 0.871 G2 1.000 G3 0.490
The indication¶
Every derived quantity comes back as a Series on the book’s index, so the
priced book is one assign:
indication = rm.RateIndication(
experience_loss_cost=experience.loss_cost(),
manual_loss_cost=manual.loss_cost(),
credibility=z,
current_rate=book["current"],
trend_total_factor=experience.trend_factor(),
retention=retention,
)
priced = book.assign(
blended=indication.blended_loss_cost().round(2),
indicated=indication.indicated_rate().round(2),
change=indication.indicated_rate_change().round(4),
)
# exposure current blended indicated change
# group
# G1 9600.0 545.0 503.70 611.37 0.1218
# G2 14400.0 560.0 499.17 605.99 0.0821
# G3 6000.0 530.0 464.90 565.35 0.0667
Why each rate moved¶
The decomposition runs per case; to_frame() stacks it into a tidy
(case, driver) long table, and the percentage-point contributions sum to
each row’s total change exactly:
d = indication.rate_change_decomposition()
d.to_frame().round(4).loc["G1"]
# factor pct_point_contribution
# driver
# trend 1.1068 0.1075
# experience 1.0383 0.0399
# benefit 1.0000 0.0000
# demographic 1.0000 0.0000
# residual 0.9761 -0.0257
np.allclose(d.contributions.sum(axis=1), np.asarray(d.total_factor) - 1)
# True
Constrain to bookable renewals¶
Caps and floors may be per-row vectors. capped reflects only a binding
cap or floor — G1’s 10% cap binds; G2 and G3 renew at the formula:
action = rm.renew(book["current"], indication.indicated_rate(),
cap=pd.Series([0.10, 0.10, 0.12], index=book.index),
floor=0.0)
action.to_frame().round(4)
# current_rate indicated_rate proposed_rate indicated_change proposed_change capped
# group
# G1 545.0 611.3669 599.50 0.1218 0.1000 True
# G2 560.0 605.9872 605.99 0.0821 0.0821 False
# G3 530.0 565.3475 565.35 0.0667 0.0667 False
The book as one evaluation¶
A PricingEvaluation built from columns is the book. scenario_frame
takes it directly, and actions can be a per-case vector, a mapping, or one
number that broadcasts:
evaluation = rm.PricingEvaluation(
loss_cost=indication.blended_loss_cost(),
current_rate=book["current"],
retention=retention,
exposure=book["exposure"],
persistency=pd.Series([0.90, 0.95, 0.80], index=book.index),
)
tidy = rm.scenario_frame(evaluation, {
"formula": indication.indicated_rate_change(), # per-case vector
"issued": action.proposed_change, # the capped actions
"plan": 0.05, # one number, broadcast
})
tidy.pivot(index="case", columns="scenario", values="margin_ratio").round(4)
# scenario formula issued plan
# case
# G1 0.03 0.013 -0.0288
# G2 0.03 0.030 0.0037
# G3 0.03 0.030 0.0163
The formula column is the algebra closing: at the indicated rate the
margin ratio equals the retention’s profit_margin exactly (see
margin and denominators). The
issued column prices the concession — G1’s cap costs 1.7 points of
margin.
One number for the meeting¶
The closed-form uplift answers “issued actions must be how much higher to hold the book’s 3% target?” — persistency-and-exposure weighted, agreeing with the scalar mapping form to floating point:
rm.uplift_for_target_margin(evaluation, action.proposed_change,
target_margin=0.03)
# +0.6332%
The receipts¶
The contract is pinned against row-by-row scalar loops throughout the test
suite; on this page’s renewal, max |vector − loop| is exactly 0.0. The
same workflow ships as a runnable script in the repository,
examples/vectorized_book.py,
and the contract itself — return-type rules, broadcasting, labeled
elementwise validation, index-alignment guards, by= — is specified in
conventions and summarized in
ratingmodels: columns in, columns out.