Skip to main content

Constrained portfolio rate optimisation for UK personal lines insurance, with FCA ENBP enforcement, demand-linked objectives, and efficient frontier generation

Project description

insurance-optimise

PyPI Python Tests License

Flat loading on a price comparison website leaves money in every segment where your elasticity varies. This library finds the right multiplier for each risk.

You have a pricing model. It tells you the right technical price for each risk. But "technically correct" isn't the only constraint. You also have:

  • FCA PS21/11: renewal premiums cannot exceed what a new customer would be quoted (ENBP)
  • Consumer Duty: you need to demonstrate fair value, not just set prices actuarially
  • A target loss ratio you're trying to hit
  • A retention floor you can't fall below without the underwriting team getting anxious
  • Rate-change limits — you can't shock customers with 40% increases even if the model says so

The question is: what set of price multipliers maximises profit subject to all of these constraints simultaneously?

That's what this library solves.

Why bother

Benchmarked against naive logistic regression and flat pricing on a synthetic UK motor PCW quote panel — 50,000 quotes, true price elasticity −2.0, confounded assignment.

Metric Naive logistic regression DML ElasticityEstimator Notes
Estimated elasticity biased (conflates risk and price effects) near −2.0 true effect is −2.0
Absolute bias substantial (overestimates sensitivity) near zero primary metric
95% CI valid No Yes Neyman-orthogonal
Optimiser performance vs flat loading baseline (misprices elastic segments) revenue improvement in heterogeneous books scales with elasticity variance

Segments with heterogeneous elasticities (young drivers vs mature drivers on PCWs) are systematically mispriced by flat loading. The optimiser captures revenue by pricing to each segment's actual demand curve, subject to hard FCA constraints.

Run on Databricks


Read more: Your Rate Changes Are Leaving Money on the Table — why manual scenario-in-a-spreadsheet pricing is guaranteed to be suboptimal, and how constrained optimisation fixes it.

What it does

  • Maximise expected profit (or minimise combined ratio) subject to any combination of:
    • ENBP constraint — FCA PS21/11 hard ceiling per renewal policy
    • Loss ratio bounds (deterministic or Branda 2014 stochastic formulation)
    • Volume retention floor
    • GWP bounds
    • Maximum rate change per policy
    • Technical floor — price >= cost
  • Analytical gradients throughout — fast enough for N=10,000 policies in SLSQP
  • Efficient frontier sweep — show the pricing team the profit-retention trade-off curve
  • Scenario mode — run under pessimistic/central/optimistic elasticity assumptions
  • JSON audit trail — every run produces evidence of ENBP enforcement for FCA scrutiny

Install

pip install insurance-optimise

Quick start

import numpy as np
import polars as pl
from insurance_optimise import PortfolioOptimiser, ConstraintConfig

# Synthetic UK motor renewal book — 500 policies
# In production, these come from your technical model and elasticity estimator
rng = np.random.default_rng(42)
n = 500

technical_price   = rng.uniform(300, 1200, n)          # GLM output
expected_loss_cost = technical_price * rng.uniform(0.55, 0.75, n)  # expected claims
p_renewal         = rng.uniform(0.70, 0.95, n)          # renewal probability at current price
price_elasticity  = rng.uniform(-2.5, -0.8, n)          # from insurance-elasticity
is_renewal        = rng.choice([True, False], n, p=[0.7, 0.3])
# ENBP: FCA PS21/11 — renewal premium cannot exceed new business quote
enbp              = technical_price * rng.uniform(1.05, 1.25, n)  # must exceed technical_price

config = ConstraintConfig(
    lr_max=0.70,
    retention_min=0.85,
    max_rate_change=0.20,
    enbp_buffer=0.01,   # 1% safety margin below ENBP
    technical_floor=True,
)

opt = PortfolioOptimiser(
    technical_price=technical_price,
    expected_loss_cost=expected_loss_cost,
    p_demand=p_renewal,
    elasticity=price_elasticity,
    renewal_flag=is_renewal,
    enbp=enbp,
    constraints=config,
)

result = opt.optimise()

print(result)
# OptimisationResult(converged=True, N=500, profit=..., gwp=..., lr=0.681)

print(result.profit)         # shorthand alias for result.expected_profit

# Attach optimal prices back to your data
df = pl.DataFrame({
    "technical_price":    technical_price.tolist(),
    "optimal_multiplier": result.multipliers.tolist(),
    "optimal_premium":    result.new_premiums.tolist(),
})

# Save audit trail for FCA
result.save_audit("renewal_run_2025_q1_audit.json")

Efficient frontier

The frontier tells your pricing team: "if we're willing to lose X points of retention, we gain Y points of profit margin." This is the conversation that actually needs to happen in pricing reviews.

from insurance_optimise import EfficientFrontier

frontier = EfficientFrontier(
    opt,
    sweep_param="volume_retention",
    sweep_range=(0.80, 0.96),
    n_points=15,
)
result = frontier.run()
print(result.data)  # DataFrame: epsilon, profit, gwp, loss_ratio, retention

frontier.plot()  # matplotlib

Scenario mode

Elasticity estimates carry uncertainty. The simplest honest approach is to run under three scenarios and report the spread:

result_scenarios = opt.optimise_scenarios(
    elasticity_scenarios=[
        price_elasticity * 0.75,   # pessimistic (customers more price-sensitive)
        price_elasticity,          # central estimate
        price_elasticity * 1.25,   # optimistic (customers less price-sensitive)
    ],
    scenario_names=["pessimistic", "central", "optimistic"],
)
print(result_scenarios.summary())
# scenario     converged    profit    gwp    loss_ratio
# pessimistic  True         1.1M      8.5M   0.692
# central      True         1.3M      8.8M   0.681
# optimistic   True         1.5M      9.1M   0.672

Constraint reference

Constraint Config parameter Notes
FCA ENBP enbp_buffer=0.01 Applied as upper bound on renewal multiplier
Max LR lr_max=0.70 Deterministic or stochastic (Branda 2014)
Min LR lr_min=0.55 Prevents unsustainable cross-subsidies
Min GWP gwp_min=50_000_000 Portfolio size floor
Max GWP gwp_max=100_000_000 Optional ceiling
Min retention retention_min=0.85 Renewal book only
Max rate change max_rate_change=0.20 Per policy, both directions
Technical floor technical_floor=True Enforces price >= cost
Stochastic LR stochastic_lr=True Requires claims_variance input

Demand models

Two built-in demand models:

Log-linear (default): x(m) = x0 * m^epsilon

Constant price elasticity. Works well with outputs from insurance-elasticity. Demand is always positive. Gradient is analytic and fast.

Logistic: x(m) = sigmoid(alpha + beta * m * tc)

Demand is bounded in (0,1). More appropriate for renewal probabilities when you want them to stay interpretable as probabilities. Requires conversion from elasticity estimate to logistic parameters.

Solver details

Primary solver is SLSQP via scipy.optimize.minimize. Analytical gradients are provided for the objective and all constraints — without them, SLSQP uses finite differences (2N extra evaluations per iteration, prohibitively slow for large N).

SLSQP is known to sometimes report success when starting from the initial point without moving. The library uses ftol=1e-9 (tighter than scipy's default 1e-6) and verifies constraint satisfaction after solve. If you see converged=False, the solution may still be useful but treat it with caution.

For N > 5,000, consider segment aggregation before optimising.

Regulatory context

Under FCA Consumer Duty (effective July 2023), firms must demonstrate that pricing practices deliver fair value. Under PS21/11, renewal premiums must not exceed the ENBP — this is not a soft target, it is enforceable.

This library enforces ENBP at the code level. The JSON audit trail records the constraint configuration, the solution, and whether ENBP was binding for each renewal policy. You can show this to the FCA.

Commercial tools (Akur8, WTW Radar, Earnix) do not expose their optimisation methodology. This library does.

Pipeline position

[Technical model (GLM/GBM)]
        ↓ technical_price, expected_loss_cost
[insurance-elasticity]
        ↓ p_demand, elasticity, enbp
[insurance-optimise]  ← this library
        ↓ optimal_multiplier per policy
[Rating engine / ratebook update]

Related libraries

Library Why it's relevant
insurance-elasticity Price elasticity and demand modelling — provides the p_demand and elasticity inputs this library requires
insurance-survival Survival-adjusted CLV — use CLV outputs to inform retention constraints rather than setting them arbitrarily
insurance-causal-policy SDID causal evaluation — after running the optimiser, use this to prove the rate change achieved what it was supposed to
insurance-monitoring Model monitoring — the optimised strategy will degrade as the portfolio drifts; this library catches when it needs refreshing

All Burning Cost libraries →

Source repos

This package consolidates two previously separate libraries:

  • insurance-optimise — core portfolio optimiser (v0.1.x), now v0.2.0 with demand subpackage
  • insurance-dro — archived; scenario-based robust optimisation absorbed into ScenarioObjective and CVaRConstraint in this package. Full Distributionally Robust Optimisation (Wasserstein DRO) was evaluated and deprioritised in favour of the simpler scenario sweep — see the design rationale in scenarios.py.

Performance

Benchmarked against naive logistic regression (for elasticity estimation) and flat pricing (for commercial impact) on synthetic UK motor PCW quote panel — 50,000 quotes, true price elasticity −2.0, confounded assignment (high-risk customers face higher prices and have lower sensitivity). Full notebook: notebooks/benchmark_demand.py.

Metric Naive logistic regression DML ElasticityEstimator Notes
Estimated elasticity biased (conflates risk and price effects) near −2.0 true effect is −2.0
Absolute bias substantial (direction: overestimates sensitivity) near zero primary metric
95% CI valid no yes Neyman-orthogonal

The benchmark then uses the estimated elasticities to compare revenue per quote under demand-curve-aware pricing against flat loading across all segments. Segments with heterogeneous elasticities (young drivers vs. mature drivers on PCWs, for example) are systematically mispriced by flat loading — the optimiser captures revenue by pricing to each segment's actual demand curve.

When to use: New business pricing on price comparison websites where some segments are highly elastic and others are captive. The combination of DML elasticity estimation and constrained optimisation is justified when elasticity varies materially across the book and the ENBP constraint is binding.

When NOT to use: When price is randomly assigned (genuine A/B test) — naive regression is unbiased and DML adds no value. When the book is small or the treatment variation is thin, the DML confidence intervals will be wide and the optimiser will produce near-flat recommendations anyway.

References

  • FCA PS21/11 (ENBP): https://www.fca.org.uk/publication/policy/ps21-11.pdf
  • Branda (2014): stochastic LR constraint via one-sided Chebyshev inequality
  • Emms & Haberman (2005): theoretical foundation for demand-linked insurance pricing
  • Spedicato, Dutang & Petrini (2018): ML-then-optimise pipeline in practice

Related Libraries

Library What it does
insurance-demand Conversion and retention modelling — demand curves from this library are the primary input to the optimiser
insurance-elasticity Causal price elasticity — elasticity estimates define the demand response surface the optimiser maximises over
insurance-deploy Model deployment — optimised rates flow into the champion/challenger deployment framework

Licence

BSD-3

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

insurance_optimise-0.3.2.tar.gz (107.2 kB view details)

Uploaded Source

Built Distribution

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

insurance_optimise-0.3.2-py3-none-any.whl (72.3 kB view details)

Uploaded Python 3

File details

Details for the file insurance_optimise-0.3.2.tar.gz.

File metadata

  • Download URL: insurance_optimise-0.3.2.tar.gz
  • Upload date:
  • Size: 107.2 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 insurance_optimise-0.3.2.tar.gz
Algorithm Hash digest
SHA256 f70f7e8b8129980929e52b90ed799a2fcf337678119dfd3bf2dc8ce4fa7ce6f7
MD5 b8d523ac1076d6301f751ac088be4c82
BLAKE2b-256 21f10668828add55efd8b3a7b7c641df9917cfa2a16c621671f295f47aa125ed

See more details on using hashes here.

File details

Details for the file insurance_optimise-0.3.2-py3-none-any.whl.

File metadata

  • Download URL: insurance_optimise-0.3.2-py3-none-any.whl
  • Upload date:
  • Size: 72.3 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 insurance_optimise-0.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 5e48459b996eb8e348cdcbb1f4d57d3455253d28d2ede1e98f49876d84757ba3
MD5 1d077b1f4052c2dfef2c01d86d141554
BLAKE2b-256 565d851805341ba8b2e79289f957423e5395cd158659c362a3f899c70f5a29dc

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