Why This Guide Exists
Insurers that bet $500 million on ML-driven pricing models without proper guardrails saw their combined ratio rise by 12 points within two years. In 2023, the NAIC market conduct database flagged 18 pricing-related violations where carriers couldn’t explain model decisions to regulators. I’ve reviewed a dozen implementations and the difference between a 3-point lift in loss ratio and a regulatory fine often comes down to whether the team treated pricing as a modeling exercise or a governance nightmare.
Who This Is For
You’re a pricing actuary or data science lead at a Tier-2 or Tier-3 P/C insurer. You must ship a GLM-grade model that auditors and regulators will accept. You have 3–6 months, a $75k cloud budget, and a compliance team that will veto anything without lineage, explainability, and rate manual integration. You’re not here to build the next Lemonade; you’re here to ship something that underpins a product filing in two states next quarter.
What “ML Pricing” Actually Means in Insurance
Real insurance ML pricing models are hybrid GLM-ML ensembles: GLMs for regulatory filings, ML for signal extraction, and strict post-processing to keep rates monotonic and file-ready. Anything else is a toy. I’ve seen teams waste six months on a pure XGBoost model only to retool it into a GLM-calibrated ensemble when the filing department rejected the “black box” label.
Key constraints:
- Rate manuals require explicit factors and ranges, not feature importance charts.
- Regulatory filings demand “as if” GLM documentation for every parameter.
- Underwriting guidelines must map directly to model outputs without manual overrides 80% of the time.
- You cannot retrain daily; quarterly or monthly is the realistic cadence for most carriers.
Trade-offs You Must Accept Day One
If you choose pure ML:
- You’ll need a parallel GLM for filings (doubles maintenance).
- You’ll lose monotonicity guarantees unless you bake them in (extra constraints = slower training).
- Regulators will ask for “model cards” and you’ll have to generate them from feature attribution outputs.
If you choose hybrid GLM-ML:
- You’ll spend 40% of effort aligning ML signals with GLM factors.
- You’ll need a rollback mechanism when ML signals drift beyond GLM tolerance.
Step 1: Data Assembly — The 90-Day Grind
I’ve reviewed six pricing implementations that stalled before modeling because the data team treated “loss runs” as a single table. It’s not. You need:
- Historical policy-level exposure, premium, and loss data (minimum 5 years, ideally 7+).
- Detailed claims data with paid loss, open reserves, and claim attributes (injury type, attorney involvement, repair cost drivers).
- External data: credit scores, property characteristics, telematics snapshots, and bureau data (CLUE, MVR, ISO).
- Underwriting rules in force at policy inception (not just current rules).
- Regulatory rate filings for the same period to audit against approved rates.
| Data Asset | Granularity | Retention | Typical Cleaning Effort |
|---|---|---|---|
| Policy Master | Policy + Coverage + Endorsements | 10 years | 3–4 weeks (merge endorsements, handle mid-term changes) |
| Loss Runs | Claim + Transaction + Reserve | 7 years | 6–8 weeks (close open claims, adjust paid vs. incurred, handle salvage) |
| Bureau Data | Annual snapshot per policy | 5 years | 2–3 weeks (standardize codes, impute missing) |
| Telematics | Trip-level + aggregated scores | 3 years rolling | 4–6 weeks (filter low-mileage outliers, align to policy periods) |
| Rate Manuals | State + Coverage + Factor + Effective Date | Current + 2 prior versions | 1–2 weeks (parse PDFs or extract from legacy systems) |
Resource estimate: 2 FTE data engineers + 1 FTE actuary for 90 days. Budget: $90k (including cloud ETL).
Critical Data Quality Checks
- Premium leakage check: Compare written premium in policy data vs. GL. If >2% variance, flag for root cause before modeling.
- Loss ratio sanity:
- Compute LR by state-coverage-year. If any cell is <30% or >150% without clear justification, investigate data gaps (e.g., closed claims not booked).
Step 2: Feature Engineering — The Signal Kitchen Sink
Tier 1: Core Pricing Features
These are the GLM factors you’ll calibrate later. The ML part extracts signals from them.
age,gender,marital_statuszip3(3-digit ZIP for territory granularity)vehicle_age,vehicle_value,vehicle_symbollimit_liability,limit_compyears_licensed,mvr_pointscredit_score_tier(FICO 8, VantageScore 4)
Tier 2: External Signals
These are the ML differentiators. Treat them as “feature candidates” until you prove lift.
property_age(from public records)crime_score_zip3(neighborhood risk)telematics_risk_score(aggregated from trip data)bureau_mvr_count_last3yiso_protection_classflood_zone(FEMA data)fire_district_score(ISO fire suppression)
Tier 3: Interaction & Derived Features
Build these in a feature store. Do not recalc on every model run.
age_times_credit_score(nonlinear term)vehicle_value_over_limit(exposure signal)years_licensed_over_vehicle_age(young driver penalty attenuation)territory_crime_times_vehicle_symbol(high crime + flashy car interaction)
Temporal Features (The Silent Killer)
Your loss data is time-dependent. If you don’t encode time explicitly, your model will learn spurious trends.
policy_effective_year(categorical)vintage_month(policy age in months)inflation_adj_factor(claims paid vs. CPI)cat_event_flag(CAT year dummy)
Code Snippet: Feature Store Ingestion (Python)
import pandas as pd
from feast import FeatureStore
# Load policy data with features
policy_df = pd.read_parquet("data/policy_master_clean.parquet")
# Define features
features = {
"driver": ["age", "gender", "credit_score_tier"],
"vehicle": ["vehicle_age", "vehicle_value", "vehicle_symbol"],
"territory": ["zip3", "crime_score_zip3", "flood_zone"],
"temporal": ["policy_effective_year", "vintage_month"]
}
# Initialize Feast and push to online store
fs = FeatureStore(repo_path="feature_repo")
fs.materialize_incremental("2024-01-01")
Resource estimate: 1 FTE data scientist + 0.5 FTE engineer for 6 weeks. Budget: $15k (Feast cloud tier).
Step 3: Target Construction — The Art of Defining “Good”
Option A: Pure Loss Ratio Target (Simple, Risky)
Target = incurred losses / earned premium. Problem: noisy at policy level, sensitive to reserve changes. Regulators hate it.
Option B: Loss Cost Plus LAE (Recommended)
Target = (incurred_loss + allocated_LAE) / on_level_exposure. Why?
- LAE is a real cost you can’t ignore.
- On-level exposure adjusts premium to current rates (removes rate change noise).
- Auditors prefer it because it’s close to the GLM target.
Option C: Pure Premium (Hybrid)
Target = (paid_loss + allocated_LAE) / earned_exposure. Use when you have lag in incurred data. Problem: understates tail risk.
Data Leakage Pitfall
Never include future claims in target construction. Even a 3-month lag can leak information if you’re not careful. In 2022, a carrier’s model leaked because they used “claims closed within 6 months” as target and trained on policies written 9 months ago. Their validation LR was 85%; production was 112%.
Step 4: Model Selection — GLM vs. Hybrid vs. Pure ML
| Model Type | Pros | Cons | Regulatory Audit Score (1-5) | Training Time |
|---|---|---|---|---|
| GLM (Tweedie) | Monotonic by design, filing-ready, fast inference | Misses nonlinear interactions, hard to encode external signals | 5 | 1 day |
| Gradient Boosting (XGBoost) | Captures complex interactions, high lift potential | Non-monotonic, hard to explain, slow inference for rating | 1 | 2–4 hours |
| Hybrid GLM-ML | GLM for factors + ML for residual signal, auditable | Doubles maintenance, needs alignment layer | 4 | |
| Poisson Regression + Neural Boosting | Handles zero-inflated data well, interpretable via SHAP | Requires careful initialization, slower convergence | 3 | 6–8 hours |
My recommendation: start with a hybrid GLM-ML ensemble. Use GLM as the base, then train an XGBoost residual model on (actual LR - GLM predicted LR). At inference, clip residuals to ±20% to prevent wild swings. This keeps the model monotonic and auditable while extracting 3–5 points of lift.
Step 5: Training Pipeline — The Three-Bucket Split
Bucket 1: Training (70% of data)
Use policy data from 5+ years ago to capture claim development. Exclude the most recent 12 months (they’re still developing).
Bucket 2: Validation (15%)
Use data from 24–12 months ago. This is your “as if” period—you’ll compare model predictions to actual loss costs in this window.
Bucket 3: Holdout (15%)
Use the most recent 12 months. This is your bleeding edge—your model should not overfit to recent CAT activity.
Code Snippet: Splitting Logic
from sklearn.model_selection import train_test_split
# Define time split
train_end = pd.Timestamp("2021-12-31")
val_end = pd.Timestamp("2022-12-31")
train = policy_df[policy_df["policy_effective_date"] <= train_end]
val = policy_df[(policy_df["policy_effective_date"] > train_end) &
(policy_df["policy_effective_date"] <= val_end)]
test = policy_df[policy_df["policy_effective_date"] > val_end]
# Stratify by state and coverage to avoid skew
train, _ = train_test_split(train, test_size=0.3, stratify=train[["state", "coverage"]])
Step 6: GLM Baseline — The “Nothing Breaks” Model
Before touching ML, build a GLM that matches your current rate manual. This is your baseline and your rollback plan.
Steps
- Start with Poisson GLM with log link.
- Use
statsmodelsfor interpretability. Later, port toscikit-learnfor pipeline consistency. - Enforce monotonicity on key factors:
age,credit_score_tier,vehicle_age. - Fit on loss cost + LAE / on-level exposure.
- Compare actual vs. predicted LR by state-coverage. If any cell deviates >15%, investigate (data error, filing error, or missing factor).
Code Snippet: GLM Monotonic Constraints
import statsmodels.api as sm
import statsmodels.formula.api as smf
# Define formula with monotonic terms
formula = (
"loss_cost ~ age + C(gender) + credit_score_tier + "
"vehicle_age + vehicle_value + zip3 + "
"C(coverage) + C(state)"
)
# Fit with constraints
mod = smf.glm(
formula,
data=train,
family=sm.families.Poisson(),
var_weights=train["on_level_exposure"]
)
result = mod.fit_constrained({
"age": "age >= 0", # Implicit monotonic
"credit_score_tier": "credit_score_tier >= 0",
"vehicle_age": "vehicle_age >= 0"
})
Resource estimate: 1 FTE actuary for 2 weeks. Budget: $5k.
Step 7: Hybrid Ensemble — The ML Lift Layer
Step 7.1: Residual Target
Compute residual = actual_loss_ratio - glmm_predicted_loss_ratio.
Truncate residuals to [-0.2, 0.2] to prevent wild swings in rating.
Step 7.2: Feature Selection
Use SHAP to rank features. Keep only those with SHAP importance >0.01. In practice, this reduces feature count from 45 to 12–15.
Step 7.3: Model Training
Train XGBoost on residuals with:
- Objective: reg:squarederror
- Max depth: 6
- Learning rate: 0.05
- Subsample: 0.8
- Early stopping on validation set (patience=10)
Step 7.4: Calibration
Scale predictions to match the GLM’s expected loss ratio. Formula:
final_prediction = glmm_prediction * (1 + residual_prediction)
Code Snippet: Hybrid Inference
import xgboost as xgb
import joblib
# Load models
glmm = joblib.load("models/glmm_baseline.joblib")
xgb_model = xgb.Booster()
xgb_model.load_model("models/residual_xgb.json")
def predict_hybrid(policy_data):
# GLM prediction (in log space)
glmm_pred = glmm.predict(policy_data)
# ML residual prediction
ml_features = policy_data[ml_feature_list]
dmatrix = xgb.DMatrix(ml_features)
residual_pred = xgb_model.predict(dmatrix)
# Clip residual to prevent wild swings
residual_pred = np.clip(residual_pred, -0.2, 0.2)
# Final prediction
final_pred = glmm_pred * (1 + residual_pred)
return final_pred
Comments