Skip to main content

Constrained rate change optimiser for UK personal lines insurance pricing

Project description

rate-optimiser

Python License: MIT PyPI

Constrained rate change optimiser for UK personal lines insurance pricing.

The insurance analogue of Markowitz portfolio optimisation. Takes policy-level GLM outputs and demand model predictions; finds multiplicative factor adjustments that meet loss ratio and volume targets simultaneously; traces the efficient frontier of achievable rate strategies.

No open-source tool does this. Commercial tools (Radar Optimiser, Earnix, Akur8) have opaque solvers, inflexible constraint specifications, and no Python API. This library is an auditable, extensible alternative built on scipy and numpy.


The problem

A UK motor pricing team wants to take +3.5% rate on renewal. Before submitting to the underwriting director, they need answers to three questions:

  1. Which rating factors should move, and by how much?
  2. Does the proposed strategy hit the LR target without breaching the volume budget?
  3. Is the renewal price compliant with FCA PS21/5 (no renewal exceeds NB equivalent)?

Current tooling forces them to hand-code scenarios in Excel or run ad hoc simulations. The efficient frontier - the full set of achievable (LR, volume) outcomes - is never computed. Shadow prices on constraints (what is a 1pp LR improvement actually worth in volume terms?) are unknown.

This library solves those three questions formally. You specify constraints, it finds the minimum-dislocation rate strategy that satisfies them, and it maps the entire frontier so you can see what trade-offs you are making.


What this is not

  • Not a GLM fitting tool. Use statsmodels, scikit-learn, or Emblem. This library consumes their outputs.
  • Not a real-time quote engine. Radar Live and Earnix handle individual-level pricing at point of quote. This is an offline rate strategy tool.
  • Not a reserving or capital tool. Out of scope.

Installation

uv add rate-optimiser

With stochastic module (requires cvxpy):

uv add "rate-optimiser[stochastic]"

From source with uv:

git clone https://github.com/burning-cost/rate-optimiser
cd rate-optimiser
uv sync --extra dev

Quick start

import polars as pl
from rate_optimiser import (
    PolicyData, FactorStructure, DemandModel,
    RateChangeOptimiser, EfficientFrontier,
    LossRatioConstraint, VolumeConstraint,
    ENBPConstraint, FactorBoundsConstraint,
)
from rate_optimiser.demand import make_logistic_demand, LogisticDemandParams

# 1. Load your GLM outputs
#    Required columns: policy_id, channel, renewal_flag,
#    technical_premium, current_premium
#    Your demand model populates renewal_prob
df = pl.read_parquet("policies.parquet")
data = PolicyData(df)

# 2. Factor structure - describes the multiplicative tariff
factor_names = ["f_age_band", "f_ncb", "f_vehicle_group", "f_region", "f_tenure_discount"]
fs = FactorStructure(
    factor_names=factor_names,
    factor_values=df.select(factor_names),   # current relativity values per policy
    renewal_factor_names=["f_tenure_discount"],  # renewal-only; ENBP-relevant
)

# 3. Demand model - wrap your logistic model or any callable
#    This form: logit(p) = intercept + beta * log(price_ratio) + tenure_coef * tenure
params = LogisticDemandParams(intercept=1.0, price_coef=-2.0, tenure_coef=0.05)
demand = make_logistic_demand(params)
#    Or pass any sklearn estimator:
#    demand = DemandModel(my_catboost_model, feature_names=["age", "tenure", "ncb"])

# 4. Configure the optimiser
opt = RateChangeOptimiser(data=data, demand=demand, factor_structure=fs)
opt.add_constraint(LossRatioConstraint(bound=0.72))         # max 72% LR
opt.add_constraint(VolumeConstraint(bound=0.97))             # max 3% volume loss
opt.add_constraint(ENBPConstraint(channels=["PCW", "direct"]))  # FCA PS21/5
opt.add_constraint(FactorBoundsConstraint(lower=0.90, upper=1.15, n_factors=fs.n_factors))

# 5. Check feasibility at current rates before solving
print(opt.feasibility_report())

# 6. Solve
result = opt.solve()
print(result.summary())

# result.factor_adjustments: {"f_age_band": 1.04, "f_ncb": 1.02, ...}
# result.expected_lr: 0.7198
# result.expected_volume: 0.9712
# result.shadow_prices: {"loss_ratio_ub": 0.031, "volume_lb": 0.0, "enbp": 0.0}

# 7. Trace the efficient frontier
frontier = EfficientFrontier(opt)
frontier_df = frontier.trace(lr_range=(0.68, 0.78), n_points=20)
# Returns DataFrame: lr_target, expected_lr, expected_volume, shadow_lr, feasible, ...

frontier.plot()  # matplotlib efficient frontier chart

The efficient frontier

The core insight borrowed from Markowitz: rather than solving for a single rate strategy, trace the full Pareto frontier of achievable (LR, volume) pairs.

frontier_df = frontier.trace(lr_range=(0.68, 0.78), n_points=20)
print(frontier.shadow_price_summary())
 lr_target  expected_lr  expected_volume  shadow_lr  shadow_volume
      0.78        0.777            0.973       0.02           0.00
      0.76        0.758            0.971       0.04           0.00
      0.74        0.739            0.968       0.08           0.00
      0.72        0.720            0.963       0.15           0.00
      0.70        0.700            0.954       0.31           0.01
      0.68        0.680            0.937       0.72           0.08

The shadow_lr column is the Lagrange multiplier on the LR constraint: the marginal dislocation cost of a one-unit tightening of the target. A rising shadow price signals you are approaching the frontier's knee - the point where further LR improvement costs disproportionate volume. That is a number worth putting in front of a commercial director.


Stochastic formulation (Branda approach)

The deterministic constraint E[LR] <= target uses point estimates of claims. The stochastic formulation requires P(LR <= target) >= alpha - the LR must stay below the target with confidence level alpha.

Reformulated via normal approximation (appropriate for large books):

E[LR] + z_alpha * sigma[LR] <= target

where sigma[LR] comes from the GLM's variance estimates.

from rate_optimiser.stochastic import ClaimsVarianceModel, StochasticRateOptimiser

variance_model = ClaimsVarianceModel.from_tweedie(
    mean_claims=data.df["technical_premium"].to_numpy(),
    dispersion=1.2,  # from your Tweedie GLM summary
    power=1.5,
)

opt = StochasticRateOptimiser(
    data=data, demand=demand, factor_structure=fs,
    variance_model=variance_model,
    lr_bound=0.72,
    alpha=0.95,  # 95% confidence
)
result = opt.solve()

The stochastic solver will recommend a higher rate than the deterministic one because it must maintain the LR constraint with high probability, not just in expectation. The difference between the two solutions quantifies the uncertainty premium in the rate strategy.


ENBP constraint (FCA PS21/5)

PS21/5 prohibits renewal premiums above the NB equivalent through the same channel. ENBPConstraint enforces this formally:

opt.add_constraint(ENBPConstraint(channels=["PCW", "direct"]))

The constraint is channel-specific, as the regulation requires. The shadow price tells you the cost (in objective terms) of the regulatory constraint - how much additional dislocation the insurer incurs to comply. This is directly relevant to PS21/5 impact analyses.

The NB-equivalent is computed by applying all factor adjustments excluding renewal-only factors (e.g., tenure discounts, NCB-at-renewal). Declare renewal-only factors in the FactorStructure:

fs = FactorStructure(
    factor_names=["f_age_band", "f_ncb", "f_tenure_discount"],
    factor_values=df.select(["f_age_band", "f_ncb", "f_tenure_discount"]),
    renewal_factor_names=["f_tenure_discount"],  # excluded from NB equivalent
)

Features

  • Constrained SLSQP optimisation via scipy. Finds minimum-dislocation factor adjustments meeting multiple simultaneous constraints.
  • Efficient frontier tracing. Parametric sweep over LR targets, returning the full (LR, volume) tradeoff surface with shadow prices at each point.
  • Shadow prices on all constraints. Lagrange multipliers extracted from the SLSQP solution. Tells you which constraints are binding and at what cost.
  • FCA PS21/5 ENBP constraint. Channel-aware, renewal-only factor aware. Not available in any other open-source tool.
  • Stochastic chance-constrained formulation. Branda (2013) approach: P(LR <= target) >= alpha using GLM variance estimates.
  • sklearn-compatible demand model interface. Pass any sklearn estimator or a simple callable.
  • Feasibility reporting. Before running the solver, check whether your constraints are satisfiable at current rates.
  • Multiple objective functions. Minimum dislocation (||m-1||^2), premium-weighted dislocation, minimum absolute dislocation.

Methodology

The optimisation problem:

minimise   sum_k (m_k - 1)^2
subject to E[LR(m)] <= LR_target
           E[vol_ratio(m)] >= vol_bound
           m_k in [m_k_min, m_k_max]  for all k
           pi_i^renewal <= pi_i^NB_equiv  (ENBP)

Decision variables m_k are multiplicative adjustments to each rating factor's relativities. A value of 1.05 means factor k's relativities are uniformly scaled up by 5% - a parallel shift on the log scale.

The demand model enters through the volume and LR constraints: p_i(pi_i / pi_market_i) is the probability that policy i renews at the adjusted premium. This makes both constraints nonlinear in m, which is why the problem requires SLSQP or a similar nonlinear solver.

The efficient frontier is traced by solving this problem for a range of LR_target values and collecting the resulting (expected_LR, expected_volume) pairs - directly analogous to the Markowitz frontier construction.

Academic foundations

  • Branda, M. (2013). "Optimization Approaches to Multiplicative Tariff of Rates." ASTIN Colloquium, Hague.
  • Guven, S. and McPhail, J. (2013). "Beyond the Cost Model: Understanding Price Elasticity." CAS Spring Forum.
  • Emms, P. and Haberman, S. (2005). "Pricing General Insurance Using Optimal Control Theory." ASTIN Bulletin 35(2).
  • FCA (2021). PS21/5: General Insurance Pricing Practices.

Development

git clone https://github.com/burning-cost/rate-optimiser
cd rate-optimiser
uv sync --extra dev
uv run pytest -v

Tests run on Databricks (see repo CI); do not run locally on resource-constrained machines.


Other Burning Cost libraries

Model building

Library Description
shap-relativities Extract rating relativities from GBMs using SHAP
insurance-interactions Automated GLM interaction detection via CANN and NID scores
insurance-cv Walk-forward cross-validation respecting IBNR structure

Uncertainty quantification

Library Description
insurance-conformal Distribution-free prediction intervals for Tweedie models
bayesian-pricing Hierarchical Bayesian models for thin-data segments
credibility Bühlmann-Straub credibility weighting

Deployment and optimisation

Library Description
insurance-demand Conversion, retention, and price elasticity modelling

Governance

Library Description
insurance-fairness Proxy discrimination auditing for UK insurance models
insurance-causal Double Machine Learning for causal pricing inference
insurance-monitoring Model monitoring: PSI, A/E ratios, Gini drift test

Spatial

Library Description
insurance-spatial BYM2 spatial territory ratemaking for UK personal lines

All libraries →


Licence

MIT. See LICENSE.


Contributing

Issues and pull requests welcome. The priority backlog:

  1. Competitive equilibrium module: Lerner index pricing (pi* = c + 1/beta) as a baseline.
  2. Bayesian demand model integration: propagate posterior uncertainty over beta through the optimiser.
  3. Multi-period optimisation: Emms/Haberman (2005) HJB framework for dynamic pricing.
  4. Consumer Duty fair value checker: flag optimised rates that systematically disadvantage protected groups.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rate_optimiser-0.2.0.tar.gz (164.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rate_optimiser-0.2.0-py3-none-any.whl (32.9 kB view details)

Uploaded Python 3

File details

Details for the file rate_optimiser-0.2.0.tar.gz.

File metadata

  • Download URL: rate_optimiser-0.2.0.tar.gz
  • Upload date:
  • Size: 164.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for rate_optimiser-0.2.0.tar.gz
Algorithm Hash digest
SHA256 cf7801c8c8bfc749034836bd3dcf5939e20d58b2328f80aab9a31027406dfc3f
MD5 bcc8e8983adfd3190a88dfba7ae1700a
BLAKE2b-256 e8a2e888d2e77259ab5b59225def00ec882f602cb8f2ba8d9955af4516300ac5

See more details on using hashes here.

File details

Details for the file rate_optimiser-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: rate_optimiser-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for rate_optimiser-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d794eecb7c4c3f5c723b704701e8cbf74091724449e150ea4e36a5d2d7bad428
MD5 a03858c1b66cf3f8f23d637991d0ccbf
BLAKE2b-256 11546ecdf996ad604bb41f0ccb752e26db9343e4ec324e5c8df797245c6a374f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page