Skip to content

Case study

Life Insurance Risk Model

Actuarial risk model for Sub-Saharan Africa

  • Python
  • lifelines
  • NumPy
  • Next.js
  • Recharts

01

The problem

I wanted to learn actuarial science by doing, not by reading textbooks. Built a full life insurance risk model for Sub-Saharan Africa from scratch, mortality, survival analysis, premium pricing, Monte Carlo VaR. Every step is implemented myself rather than imported from an actuarial package. Sub-Saharan Africa specifically, because the standard models are calibrated on US and UK data. They under-represent the constant background hazard rate that matters here: accidents, infectious disease, road traffic. They also over-represent old-age mortality patterns that do not apply to a younger population.

02

How it fits together

Three survival models feed one price, and the price feeds a tail simulation. The stress scenario is applied by age band rather than as one flat multiplier.Drag sideways if it runs past the edge

03

What this is built on

  • Gompertz (1825) and Makeham (1860) are the basis. Pure Gompertz models exponential mortality increase with age; Makeham adds a constant background hazard. For Sub-Saharan Africa, where the constant hazard is materially higher (malaria, road traffic, infectious disease), the Makeham term is essential. The original papers, rather than the textbook summaries, are where the second term's role becomes clear.
  • Cox (1972) Proportional Hazards is the survival-analysis standard. The C-index (concordance) is the equivalent of AUC. Industry-standard 'good' is above 0.7, 'excellent' above 0.8.
  • Klein & Moeschberger's 'Survival Analysis' is the reference for censoring and truncation handling. Most insurance data has both: right-censoring when a policy term ends, left-truncation for delayed entry.
  • Monte Carlo variance-reduction techniques (control variates, antithetic variables) stabilise VaR at 5,000 scenarios.
  • Sub-Saharan Africa health stats from the World Bank and WHO set the profile calibration: life expectancy distributions, mortality ratios by age band, leading causes of death.
  • COVID-era mortality data set the pandemic stress calibration. Sub-Saharan Africa's reported pandemic mortality was lower than other regions, with higher uncertainty bands.

04

What I couldn't do

  • No real African insurance data, most insurers don't share, and what's published is too aggregate to fit individual-level models.
  • Pandemic was recent. Any stress test had to take that seriously without overfitting to a one-time event.
  • Target: Cox PH C-index above 0.75 (industry good).
  • Actuarial work is regulated. Black-box ML does not pass review.

05

The decisions that shaped it

  1. Decision 01

    Gompertz-Makeham, not just Gompertz.

    Pure Gompertz models the exponential mortality increase with age. Makeham adds a constant background hazard rate, accidents, infectious disease, baseline causes that don't depend on age. For Sub-Saharan Africa, where the constant hazard is materially higher (malaria, road traffic), the Makeham term matters. Implemented via maximum likelihood with scipy.optimize rather than a canned package.

  2. Decision 02

    Cox Proportional Hazards via lifelines, not bespoke.

    Implementing Cox PH from scratch is a rabbit hole, Breslow ties, partial likelihood, baseline hazard estimation. lifelines is solid for the regression part. I implemented Gompertz-Makeham fitting myself, where the learning was, and used lifelines for Cox.

  3. Decision 03

    Add age × risk-class interaction term to Cox PH.

    First Cox PH had C-index of 0.62, below industry standard. The marginal effect of being a smoker is different at age 25 vs age 65. Adding the interaction term (age × risk_class) jumped C-index to 0.77. Cox PH's proportional-hazards assumption is violated when interactions matter, and the low C-index is what surfaced it.

  4. Decision 04

    Monte Carlo VaR with 5,000 scenarios + pandemic shock.

    VaR at 95% and 99% gives the insurer two budget constraints. Single-point projections are useless for capital reserves; only the tail matters. The pandemic scenario multiplies mortality by 1.8x to 3.5x, calibrated to COVID excess-mortality data. 5,000 scenarios is enough for a stable 99th-percentile VaR.

  5. Decision 05

    5,000 synthetic profiles calibrated to regional age distributions.

    Same constraint as the credit scorecard, no real data. The profiles match Sub-Saharan Africa World Bank data on age, sex, and risk class. The model learns regional patterns, not American textbook ones. Real data would be better, and calibrated synthetic is the next best alternative.

  6. Decision 06

    Vectorise Monte Carlo in NumPy, not pandas.

    First implementation looped over 5,000 scenarios in a pandas DataFrame. Took 4 minutes per run. Rewrote in NumPy with vectorised sampling. Same results, 30x faster, 8 seconds per run.

  7. Decision 07

    Mobile-first dashboard with responsive percentile cells.

    Actuarial dashboards are usually desktop-first. The percentile table (P50, P75, P95, P99 of loss distribution) overflowed on phones. Rewrote each cell as a responsive grid with label-left and value-right on phone, full row on desktop (commits 3882545, b89e8c2).

06

What broke and how I changed course

  • First Cox PH had C-index of 0.62, below industry standard. Realised I'd left out interaction effects between age and risk class. Adding the interaction term jumped C-index to 0.77.
  • Monte Carlo initially ran in pandas. 4 minutes per run. Rewrote in NumPy with vectorised sampling. 8 seconds. Same results.
  • Pandemic stress factor was originally a single 2.5x multiplier across all ages. Realised the COVID excess-mortality data shows much higher concentration at older ages. Re-calibrated to age-band-specific multipliers: 1.5x at 30-50, 3.5x at 70+.
  • Survival curve plots had inconsistent y-axis ranges across risk groups, making them hard to compare. Standardised the y-axis range and added grid lines so the curves compare directly.

07

What I didn't know, and how I learned

  • I had not used scipy.optimize for MLE before this project. Spent a day learning the BFGS optimiser's quirks and how to set initial parameter estimates so it converges.
  • Censoring and truncation are subtle. My first survival fit ignored left-truncation (delayed entry into the risk pool), which biased the hazard rate downward. Re-read Klein & Moeschberger, fixed the entry-time handling.
  • Cox PH's proportional-hazards assumption is exactly that, an assumption. When violated, the model is wrong in subtle ways. Schoenfeld residuals are what surface a violation. I check them as part of every fit.
  • Variance reduction in Monte Carlo (control variates, antithetic variables) reduced the run time and stabilised the tail estimates once implemented.

08

What shipped

  • Gompertz-Makeham mortality model fitted from scratch via MLE
  • Cox PH C-index 0.78 on a held-out 30 percent, 0.77 in-sample, with 0 of 5 covariates breaching proportional hazards
  • Kaplan-Meier curves with log-rank tests across risk groups
  • 5,000-scenario Monte Carlo VaR (95%, 99%)
  • Pandemic stress test calibrated to COVID excess-mortality data
  • Age-band-specific shock multipliers
  • Vectorised NumPy implementation (8s for full Monte Carlo)
  • Live Next.js + Recharts mobile-responsive dashboard

09

What's next

I'd add a reserves projection module: given current policies and the mortality model, what's the IBNR (incurred but not reported) estimate? I also need to add LGD and EAD analogues for life products (sum-assured at risk).

What I learned

The textbook explains Gompertz-Makeham. Writing the gradient descent yourself shows why the second term matters in the data you actually have.