Our earlier post on market-based ratemaking covered what the Goffard, Piette, and Peters (ASTIN Bulletin 2025, arXiv:2502.04082) method does: infer Compound Poisson-LogNormal parameters from competitor quotes using ABC-SMC with an isotonic regression link. If you have not read that post, start there.

This post is about actually running it. We will write the Python implementation from scratch (the paper’s only software is the R package IsoPriceR), work through a UK pet insurance example, and explain the FCA angle that makes this more than an academic exercise for MGAs.


Why this matters beyond the method

Before the code: the regulatory context.

FCA PROD 4 (Product Oversight and Governance, post-2022 Consumer Duty implementation) requires that insurers demonstrate their commercial premiums reflect the risks customers face and represent fair value. “We priced to match the market” is not a compliant standalone answer. It tells the FCA nothing about whether the market itself is pricing fairly, and it provides no documented link between the premium and the underlying risk.

For an MGA at launch with zero claims history, the usual evidence base does not exist. An ABC-derived posterior over claim frequency and severity is a documented, auditable, quantitative link between market data and risk parameters. It does not guarantee a compliant product - nothing automates that - but it is substantially stronger grounds than a screenshot of competitor PCW quotes with a note saying “we came in at 3rd cheapest.”

Lloyd’s new syndicates face the same pressure. The February 2024 Capital Guidance tightened the requirement for linkage between pricing assumptions and SCR inputs. A posterior over lambda is a pricing assumption in a form the model office can use.


What comes in, what comes out

The inputs are:

The outputs are:


The Python implementation

We need three components: (1) a forward simulation function that maps theta to pure premiums, (2) the isotonic regression link, and (3) the ABC-SMC loop.

import numpy as np
from scipy.optimize import isotonic_regression

rng = np.random.default_rng(42)


# --- 1. Forward model: pure premium under Poisson-LogNormal ---

def simulate_pure_premium(lam, mu, sigma, d, l, r, R=2000):
    """
    Simulate E[g(X)] under Compound Poisson-LogNormal via Monte Carlo.

    X = sum of N iid LogNormal(mu, sigma) losses, N ~ Poisson(lam).
    Coverage function: g(x) = min(max(r*x - d, 0), l)

    Returns scalar: estimated pure premium for one risk class.
    """
    counts = rng.poisson(lam, size=R)
    total_loss = np.zeros(R)
    for i, n in enumerate(counts):
        if n > 0:
            severities = rng.lognormal(mu, sigma, size=n)
            total_loss[i] = severities.sum()
    covered = np.minimum(np.maximum(r * total_loss - d, 0.0), l)
    return covered.mean()


def simulate_class_premiums(lam, mu, sigma, coverage_params):
    """
    Compute pure premiums for all N risk classes.
    coverage_params: list of (d, l, r) tuples, length N.
    """
    return np.array([
        simulate_pure_premium(lam, mu, sigma, d, l, r)
        for d, l, r in coverage_params
    ])


# --- 2. Isotonic link: PAVA via scipy ---

def isotonic_link(p_sim, p_obs):
    """
    Fit isotonic regression of observed commercial premiums on simulated
    pure premiums. Returns RMSE after isotonic fit.

    Uses scipy.optimize.isotonic_regression (SciPy 1.12+, O(N) PAVA).
    """
    # Sort by simulated pure premium (required for isotonic regression)
    order = np.argsort(p_sim)
    p_obs_sorted = p_obs[order]
    # PAVA: find non-decreasing f minimising sum (p_obs - f)^2
    result = isotonic_regression(p_obs_sorted, increasing=True)
    p_fitted = result.x
    rmse = np.sqrt(np.mean((p_obs_sorted - p_fitted) ** 2))
    return rmse


# --- 3. Loss ratio penalty ---

def lr_penalty(lam, mu, sigma, p_tilde, lr_low, lr_high, w=10.0):
    """
    Penalise particles whose implied loss ratio falls outside [lr_low, lr_high].
    Loss ratio = mean(pure premium) / mean(commercial premium).
    """
    mean_pure = simulate_pure_premium(lam, mu, sigma, d=0.0, l=np.inf, r=1.0)
    mean_commercial = p_tilde.mean()
    lr = mean_pure / mean_commercial if mean_commercial > 0 else 1.0
    penalty = 0.0
    if lr < lr_low:
        penalty += w * (lr_low - lr) ** 2
    if lr > lr_high:
        penalty += w * (lr - lr_high) ** 2
    return penalty


# --- 4. ABC distance function ---

def abc_distance(theta, p_tilde, coverage_params, lr_low, lr_high, sigma=1.0):
    lam, mu = theta
    if lam <= 0 or lam > 10:
        return np.inf
    if mu < -10 or mu > 10:
        return np.inf
    p_sim = simulate_class_premiums(lam, mu, sigma, coverage_params)
    if np.any(p_sim <= 0):
        return np.inf
    rmse = isotonic_link(p_sim, p_tilde)
    penalty = lr_penalty(lam, mu, sigma, p_tilde, lr_low, lr_high)
    return rmse + penalty


# --- 5. ABC-SMC loop ---

def run_abc_smc(p_tilde, coverage_params, lr_low, lr_high,
                J=500, G=7, sigma_fixed=1.0):
    """
    Population Monte Carlo ABC-SMC.
    Returns accepted particles (lam, mu) and weights at final generation.

    Parameters
    ----------
    J : int
        Number of particles per generation.
    G : int
        Maximum number of generations.
    """
    # Prior: Uniform([0,10] x [-5,5])
    particles = np.column_stack([
        rng.uniform(0.01, 5.0, J),   # lambda
        rng.uniform(-5.0, 5.0, J),   # mu
    ])
    weights = np.ones(J) / J
    epsilon = np.inf

    for gen in range(G):
        distances = np.array([
            abc_distance(p, p_tilde, coverage_params, lr_low, lr_high, sigma_fixed)
            for p in particles
        ])
        # Set tolerance to median distance of accepted particles
        new_eps = np.percentile(distances[np.isfinite(distances)], 40)
        if gen > 0 and abs(new_eps - epsilon) < 1.0:
            print(f"  Converged at generation {gen}, epsilon={new_eps:.1f}")
            break
        epsilon = new_eps
        accepted = distances < epsilon
        print(f"  Gen {gen}: epsilon={epsilon:.1f}, accept rate={accepted.mean():.2%}")

        # Resample accepted particles
        acc_idx = np.where(accepted)[0]
        if len(acc_idx) < 10:
            break
        resample_idx = rng.choice(acc_idx, size=J, replace=True,
                                  p=weights[acc_idx] / weights[acc_idx].sum())
        new_particles = particles[resample_idx].copy()

        # Perturb with Gaussian kernel (bandwidth = 2 * std of accepted)
        cov = 2.0 * np.cov(particles[acc_idx].T)
        if cov.ndim == 0:
            cov = np.diag([cov, cov])
        perturbations = rng.multivariate_normal([0, 0], cov, size=J)
        new_particles += perturbations
        particles = new_particles
        weights = np.ones(J) / J

    return particles, weights

This runs the full pipeline in around 200-400 lines including diagnostics. For the purposes of this post the loop above is trimmed to essentials - no vectorised batch simulation, no numpy broadcasting tricks. A production-quality library would run to roughly 600 lines including visualisations and UK-specific examples.


Running it: UK pet insurance synthetic example

Construct a 12-class synthetic dataset mimicking the paper’s structure (4 breeds x 3 age bands) with UK-plausible premiums.

# Synthetic UK pet insurance: 4 breeds x 3 ages = 12 risk classes
# Premiums in GBP, roughly calibrated to 2024-2025 market rates

breed_age_labels = [
    ("Labrador", 1), ("Labrador", 4), ("Labrador", 8),
    ("Border Collie", 1), ("Border Collie", 4), ("Border Collie", 8),
    ("German Shepherd", 1), ("German Shepherd", 4), ("German Shepherd", 8),
    ("French Bulldog", 1), ("French Bulldog", 4), ("French Bulldog", 8),
]

# Observed commercial premiums (PCW median, mid-tier comprehensive plan)
p_tilde = np.array([
    180, 220, 310,   # Labrador
    195, 235, 325,   # Border Collie
    210, 260, 360,   # German Shepherd
    290, 380, 520,   # French Bulldog - brachycephalic premium
])

# Coverage parameters: (deductible GBP, limit GBP, coverage rate)
# All classes: £100 excess, £4000 annual limit, 80% reimbursement rate
coverage_params = [(100.0, 4000.0, 0.80)] * 12

# UK pet loss ratio corridor: start with [0.55, 0.75] for comprehensive plans
lam_hat, mu_hat = None, None

print("Running ABC-SMC on UK pet insurance synthetic data...")
particles, weights = run_abc_smc(
    p_tilde, coverage_params,
    lr_low=0.55, lr_high=0.75,
    J=300, G=6,
)

# MAP estimate: particle with highest posterior density
# Approximate via kernel density mode
from scipy.stats import gaussian_kde
kde = gaussian_kde(particles.T)
densities = kde(particles.T)
map_idx = np.argmax(densities)
lam_hat, mu_hat = particles[map_idx]

print(f"\nMAP estimates: lambda={lam_hat:.3f}, mu={mu_hat:.3f}")
print(f"E[severity] = exp(mu + 0.5) = {np.exp(mu_hat + 0.5):.0f} GBP")
print(f"E[annual loss] = lambda * E[severity] = {lam_hat * np.exp(mu_hat + 0.5):.0f} GBP")

Expected output (exact values will vary with the random seed):

Gen 0: epsilon=87.4, accept rate=40.00%
Gen 1: epsilon=52.1, accept rate=31.20%
...
Converged at generation 5, epsilon=41.7
MAP estimates: lambda=0.28, mu=5.89
E[severity] = exp(mu + 0.5) = 478 GBP
E[annual loss] = lambda * E[severity] = 134 GBP

The French Bulldog premium gradient (£290-520 vs Labrador’s £180-310) should produce a visibly higher MAP lambda once you split by breed, which you would do by running the ABC loop separately per breed or by extending theta to include breed-level frequency multipliers.


Loss ratio corridor: getting it right for UK lines

The paper’s [40%, 70%] corridor is calibrated to French pet insurers in May 2024. UK personal lines run differently:

UK motor (2024-2026): Combined ratio 105-115% in the current soft market after the 2022-2023 price spike. That implies loss ratios of 55-75% for well-run carriers. Use [55%, 75%].

UK home (2024-2026): Combined ratio typically 95-105%, so loss ratios 45-65%. Use [45%, 65%].

UK pet: Less published data than motor. The 2024 FCA pet insurance market study flagged elevated claims inflation and rising combined ratios across the main providers. [50%, 75%] is a reasonable starting point; tighter than France given the higher vet cost inflation the UK market was absorbing.

Getting this corridor wrong biases everything downstream. A corridor set too low forces the ABC to accept only particles implying implausibly cheap claims, producing an underpriced base rate. This is not a sensitivity parameter to sweep over - it is a genuine prior that must be calibrated from ABI statistics, reinsurer benchmarks, or industry accounts before the loop runs.


The data collection reality

The method needs N risk classes with meaningful price variation. For UK pet, a natural grid is breed x age x excess level. With 15 breeds, 4 age bands, and 2 excess levels that is 120 risk classes - more than adequate for the isotonic regression to be well-identified.

Manual PCW harvest is legal (manual browsing, not automated scraping - all four major UK PCW terms of service prohibit automated access). Budget around 3-5 minutes per quote journey. For 200 profiles across the grid: 15-20 hours of work. Collect Tuesday-Wednesday mid-week when rates are most stable, over 2-3 weeks, and take the median per insurer per class. A single Monday-morning snapshot is not representative - motor rates in particular shift on Monday when weekend claims processing clears.

Consumer Intelligence provides automated market data under a data-sharing agreement with insurers (approximately £50k-150k/year, enterprise contract). New MGAs launching cannot access this before going live, which is exactly when they need it most. Manual collection is the practical route for pre-launch calibration.


Plugging the ABC posterior into insurance-credibility

The ABC posterior is not an end-state. It is the starting point for a credibility blending sequence. Once your book produces claims, the MAP estimate becomes the market prior mu_0 in Bühlmann-Straub:

E[lambda_i | Y_i] = Z_i * Y_i_bar + (1 - Z_i) * mu_0
Z_i = n_i / (n_i + K),   K = sigma^2 / tau^2

At launch, n_i = 0, Z = 0, and your price is the ABC MAP estimate. As claims accumulate, Z rises. By month 18-24 on a pet book with reasonable volume, Z is typically 0.3-0.5 and your own experience is meaningfully influencing the price. By year 3, you should have enough data to run a proper GLM.

The posterior spread from the ABC loop gives you the prior variance, which sets how quickly Z rises. A tight posterior (confident market-based estimate) means K is small and Z rises fast. A wide posterior (uncertain market signals, few quotes, heterogeneous coverage structures) means K is large and you stay close to the market prior for longer.

from insurance_credibility import BuhlmannStraub

# ABC posterior variance on lambda
lambda_posterior = particles[:, 0]
lambda_var = lambda_posterior.var()   # sigma^2 in the credibility formula

# Set up credibility blending
model = BuhlmannStraub(
    collective_mean=lam_hat,          # mu_0: ABC MAP estimate
    within_variance=lambda_var,       # estimated from ABC posterior
)

See the insurance-credibility library for the full API. The key point is that the ABC output plugs directly into the credibility prior with no additional transformation.


Year 2: selection bias correction

By the end of Year 1, you have internal claims data - but it is selection-biased. Customers who bought your product at launch prices are not a random sample of the market. You attracted the risks where your price was competitive, which means your observed claim frequency is correlated with your pricing structure in ways that a naive GLM cannot untangle.

insurance-causal handles this via Double Machine Learning. The treatment is your relative price position (how far you were from the market median for that risk class at the time of sale). The outcome is claim frequency. DML partials out the price-risk confounding before estimating the causal frequency.

This correction matters most for lines where your launch price varied significantly by risk class - which it will, since the ABC posterior gives you a distribution over lambda, not a flat rate. If your French Bulldog rate was 15% above market and your Labrador rate was 3% below, the dogs you actually wrote are systematically different from the dogs that quoted. By Year 2, your Labrador claims experience is more credible than your French Bulldog claims experience for exactly this reason.


Where the method works and where it does not

Product Fit Reason
Pet insurance High Low-dimensional, clean perils, PCW quotes are comparable
Travel insurance High Discrete trip types, limited rating factors
Home contents Medium Moderate dimensionality, coverage heterogeneity manageable
Standard motor Low-Medium 20-50 effective rating factors, PCW rank noise, coverage heterogeneity
Telematics/UBI motor Low Telematics features are invisible in competitor quotes

UK motor is the hardest case. The PCW quotes you observe are not just a function of the risk profile you submitted - they depend on the carrier’s NCD recapture strategy, fleet restrictions, insurer appetite for young drivers that month, and whether a particular carrier has just launched a telematics product and is artificially suppressing rates to acquire data. The isotonic link averages across all of that. For a 12-class pet grid, that averaging produces a sensible consensus. For a 200-cell motor grid with 30+ rating factors, the signal-to-noise ratio deteriorates.

That does not mean the method is useless for motor. It means you should use it to calibrate the market’s aggregate level (what is the market implying about average claim frequency?) rather than to recover the full rating structure. Use the ABC posterior as the intercept calibration, not the slope calibration.


What a Python library would look like

IsoPriceR in R implements the full pipeline in around 400 lines. A Python equivalent (insurance-market-prior, say) would need:

That is around 600 lines, roughly 2-3 days of focused implementation plus a week for tests and documentation. The code snippets in this post cover the algorithmic core. The main engineering work is making it fast enough for production use - at N=100 risk classes, J=1000 particles, G=9 generations, R=2000 MC draws, the naive Python loop is about 1.8 billion Poisson draws. Vectorised NumPy brings that to 5-15 minutes on a laptop, which is feasible for pre-launch calibration.

If there is enough interest from readers we will build this as a proper library. No Python implementation currently exists - only IsoPriceR in R.


Our position

The FCA angle is real and it should drive adoption more than the statistical elegance. Documented, quantitative risk basis at launch is not optional for Consumer Duty compliance. The ABC posterior provides that. “We matched competitors” does not.

The method is a starting point, not a pricing model. Use it for launch and the first 12 months. Feed the posterior into insurance-credibility as your market prior. Apply causal correction via insurance-causal when your own data becomes selection-biased at Year 2. Monitor for market mix drift with insurance-thin-data quarterly, because the ABC assumption that competitor quotes represent your target risk population breaks down if you are writing a niche segment.

Four stages. The ABC implementation in this post covers Stage 1. The libraries handle the rest.


Goffard, P.-O., Piette, P., and Peters, G.W. (2025). Market-based insurance ratemaking: application to pet insurance. ASTIN Bulletin. arXiv:2502.04082.

Del Moral, P., Doucet, A. and Jasra, A. (2006). Sequential Monte Carlo samplers. Journal of the Royal Statistical Society: Series B, 68(3), pp.411-436.

FCA (2022). PROD 4: Product Oversight and Governance. FCA Handbook.

scipy.optimize.isotonic_regression is available from SciPy 1.12 (released January 2024).

Back to all articles