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. The point was to implement every step myself instead of importing a pre-built actuarial package. Sub-Saharan Africa specifically because the standard models (calibrated on US/UK data) under-represent the constant background hazard rate that matters here, accidents, infectious disease, road traffic, and over-represent old-age mortality patterns that don't apply to a younger demographic.
02
How it fits together
03
What I read before writing code
- Read the original Gompertz (1825) paper and Makeham's (1860) extension. 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. Reading the original papers, not the textbook summaries, was where the intuition came from.
- Studied Cox (1972) Proportional Hazards literature. The C-index (concordance) is the survival-analysis equivalent of AUC. Industry-standard 'good' is > 0.7, 'excellent' is > 0.8.
- Read Klein & Moeschberger's 'Survival Analysis: Techniques for Censored and Truncated Data' for the censoring + truncation handling, most insurance data has both (right-censoring when policy term ends, left-truncation for delayed entry).
- Studied Monte Carlo simulation methods, specifically variance-reduction techniques (control variates, antithetic variables). Useful when you need 5,000 scenarios to converge to stable VaR estimates.
- Read Sub-Saharan Africa health stats from World Bank and WHO, life expectancy distributions, mortality ratios by age band, leading causes of death. Calibrated the synthetic profile distributions against these sources.
- Read about COVID-era mortality shocks across regions to calibrate the pandemic stress scenario. Sub-Saharan Africa's reported pandemic mortality was lower than other regions but 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.
- Aspirational target: Cox PH C-index above 0.75 (industry good).
- Had to be auditable, actuarial work is inherently regulated; black-box ML doesn't pass review.
05
The decisions that shaped it
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. Better fit, more honest model. Implemented the Makeham fit via maximum likelihood with scipy.optimize, not a canned package.
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 rock-solid for the regression part. I implemented Gompertz-Makeham fitting myself (where the learning was) and used lifelines for Cox where I just needed a working tool. Pick your battles. The point of the project was to learn, but learning everything from scratch is a different project.
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. The interaction was obvious in retrospect: a 25-year-old smoker has different relative risk than a 65-year-old smoker. Cox PH's 'proportional hazards' assumption is violated when interactions matter, and I had to learn that by debugging a low C-index.
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-3.5x, calibrated loosely to COVID excess-mortality data. 5,000 scenarios is enough for stable 99th-percentile VaR, anything less and the tail estimate jumps around between runs.
Decision 05
5,000 synthetic profiles calibrated to regional age distributions.
Same constraint as the credit scorecard, no real data. I built profiles whose age, sex, and risk-class distributions match Sub-Saharan Africa World Bank data. The model learns realistic patterns, not American actuarial textbook patterns. Would prefer real data; calibrated synthetic is the next-best honest alternative.
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, 8 seconds. Same results, 30x faster. The lesson: in numerical Python, the second time you write the loop you're doing it wrong. Vectorise from the start.
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 / value-right on phone, full row on desktop. The dashboard ships value to a wider audience because the experience scales (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. Standard PH assumption was being violated; the model needed help.
- 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+). More accurate, more honest.
- 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. Visual comparability matters when the audience is humans not models.
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 good initial parameter estimates so it converges. Bad initial estimates = optimiser diverges = no fit.
- 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. Diagnosing it via Schoenfeld residuals took me longer than it should have. Now I check residuals as part of every fit.
- Variance reduction in Monte Carlo (control variates, antithetic variables) sounded scary in textbooks but reduced my run time and improved tail-estimate stability noticeably once I implemented them. Good textbook techniques are usually less mysterious in code.
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 (more honest than uniform 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? That's the actuary's bread and butter and I skipped it. Also need to add LGD/EAD analogues for life products (sum-assured at risk). Adding next iteration.
What I learned
You learn actuarial science by writing the math. The textbook tells you Gompertz-Makeham; doing the gradient descent yourself shows you why the second term matters in the data you actually have. When the assumptions are violated (proportional hazards, no interaction), the model tells you with a low C-index, listen to it.