Reserving and pricing feel like separate disciplines but they feed each other directly. If reserves are inadequate, the loss ratios your pricing team are loading into the rate review are understated. If they are over-adequate, you are chasing phantom profitability. Either way, the pricing actuary who cannot read a reserve analysis is flying partially blind.

This post works through the chain ladder method in Python using the chainladder library (500+ GitHub stars, actively maintained by CAS volunteers). We cover: what a loss development triangle is, how to build and manipulate one, volume-weighted versus simple average development factors, IBNR calculation, and how to visualise development patterns. In part 2 we will add stochastic reserving via the bootstrap ODP method.


What is a loss development triangle?

When a claim is reported, the insurer does not pay it immediately. Losses develop over time: initial reserves get revised upward or downward, partial payments are made, coverage disputes are resolved. At any point in time, your reported losses for a given accident year are less than the ultimate losses you will eventually pay.

A loss development triangle captures this process. Rows are accident years (or underwriting years, or policy years). Columns are development ages, typically in months: 12, 24, 36, and so on. Each cell contains cumulative losses reported as of that age. The right edge of the triangle, the latest diagonal, is where you are right now. Everything to the right of the diagonal is unknown: that is the IBNR (incurred but not reported) you need to estimate.

         12      24      36      48      60
1981   5,012   8,269  10,907  11,805  13,539  ...
1982     106   4,285   5,396  10,666  13,782  ...
1983   3,410   8,992  13,873  16,141  18,735  ...
...
1990   2,063     NaN     NaN     NaN     NaN

The 1990 row has only one data point: we are at development age 12. The 1981 row is complete at age 120. Chain ladder uses the completed rows to estimate development factors, then projects the incomplete rows to ultimate.


Setup

pip install chainladder

We will use the RAA dataset throughout: a classic 10x10 cumulative triangle of general liability losses, originally from the 1989 CAS study on loss development. It is widely used as a textbook example precisely because its development factors are well-behaved, which makes it a good teaching dataset before you get to real-world misbehaviour.

import chainladder as cl
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

# Load the RAA triangle
triangle = cl.load_sample('raa')
print(triangle)

Output:

         12       24       36       48       60       72       84       96      108      120
1981  5012.0   8269.0  10907.0  11805.0  13539.0  16181.0  18009.0  18608.0  18662.0  18834.0
1982   106.0   4285.0   5396.0  10666.0  13782.0  15599.0  15496.0  16169.0  16704.0      NaN
1983  3410.0   8992.0  13873.0  16141.0  18735.0  22214.0  22863.0  23466.0      NaN      NaN
1984  5655.0  11555.0  15766.0  21266.0  23425.0  26083.0  27067.0      NaN      NaN      NaN
1985  1092.0   9565.0  15836.0  22169.0  25955.0  26180.0      NaN      NaN      NaN      NaN
1986  1513.0   6445.0  11702.0  12935.0  15852.0      NaN      NaN      NaN      NaN      NaN
1987   557.0   4020.0  10946.0  12314.0      NaN      NaN      NaN      NaN      NaN      NaN
1988  1351.0   6947.0  13112.0      NaN      NaN      NaN      NaN      NaN      NaN      NaN
1989  3133.0   5395.0      NaN      NaN      NaN      NaN      NaN      NaN      NaN      NaN
1990  2063.0      NaN      NaN      NaN      NaN      NaN      NaN      NaN      NaN      NaN

The chainladder library stores triangles as its own Triangle object. It is backed by NumPy arrays but displays as a labelled grid. It handles the NaN-filled lower-right of the triangle automatically.


Development factors: volume-weighted vs simple average

The chain ladder method works by computing age-to-age development factors: the ratio of losses at age N+1 to losses at age N, across all years that have data at both ages. There are two standard approaches.

Volume-weighted factors

Volume-weighted factors weight each year’s ratio by the losses at the earlier age. A year with 20,000 of losses at age 12 carries 10 times the weight of a year with 2,000. This is the default in chainladder and the approach most practitioners use for large, volatile loss triangles where size matters.

dev_vw = cl.Development()  # volume-weighted by default
dev_vw.fit(triangle)

print(dev_vw.ldf_)

Output:

          12-24     24-36     36-48     48-60     60-72     72-84     84-96    96-108   108-120
(All)  2.999359  1.623523  1.270888  1.171675  1.113385  1.041935  1.033264  1.016936  1.009217

The 12-24 factor of 3.00 means that, on volume-weighted average across all ten years, cumulative losses at age 24 are three times losses at age 12. The factors taper toward 1.0 at later ages as development matures.

Simple average factors

Simple average gives equal weight to each year regardless of size. For homogeneous books where size variation is noise rather than signal, it can be more stable.

dev_simple = cl.Development(average='simple')
dev_simple.fit(triangle)

print(dev_simple.ldf_)

Output:

          12-24     24-36    36-48     48-60     60-72     72-84     84-96    96-108   108-120
(All)  8.206099  1.695894  1.31451  1.182926  1.126962  1.043328  1.034355  1.017995  1.009217

The 12-24 factor jumps to 8.21 under simple average. The 1982 accident year had only 106 of losses at age 12 and 4,285 at age 24, giving a raw link ratio of 40.4. Under volume weighting that outlier is suppressed; under simple averaging it drags the mean up substantially. This is exactly the kind of distortion that makes volume weighting the default for most reserving work.

CDF to ultimate

The cumulative development factor (CDF) from age N to ultimate is the product of all the individual age-to-age factors from age N onwards. It tells you how much remaining development to expect from where you are now.

print(dev_vw.cdf_)

Output:

         12-Ult    24-Ult    36-Ult    48-Ult    60-Ult    72-Ult    84-Ult    96-Ult   108-Ult
(All)  8.920234  2.974047  1.831848  1.441392  1.230198  1.104917  1.060448  1.026309  1.009217

A 1990 accident year at age 12 needs a factor of 8.92 applied to its current losses to project to ultimate. 1989 at age 24 needs 2.97. By age 96, less than 3% of development remains.


Running the chain ladder projection

With development factors estimated, the chain ladder projection is straightforward: multiply each year’s latest diagonal by the CDF for that development age.

The chainladder library follows a scikit-learn-style API. You fit the Development estimator first, use transform to apply the fitted factors to the triangle, then fit Chainladder to produce the ultimate estimates.

import pandas as pd

# Step 1: fit development factors
dev = cl.Development()
dev.fit(triangle)

# Step 2: apply to the triangle
triangle_developed = dev.transform(triangle)

# Step 3: project to ultimate
cl_model = cl.Chainladder()
cl_model.fit(triangle_developed)

# IBNR by accident year
print(cl_model.ibnr_.to_frame())

# Total
print(f"\nTotal IBNR: {float(cl_model.ibnr_.sum()):,.0f}")

Output:

                    2261
1981-01-01           NaN
1982-01-01    153.953917
1983-01-01    617.370924
1984-01-01  1636.142163
1985-01-01  2746.736343
1986-01-01  3649.103184
1987-01-01  5435.302590
1988-01-01  10907.192510
1989-01-01  10649.984101
1990-01-01  16339.442529

Total IBNR: 52,135

The 1981 row shows NaN because it is fully developed: there is no remaining IBNR. The 1990 row, with only one diagonal of data, carries the largest IBNR at 16,339. The numbers here are in thousands of dollars (the RAA dataset is not currency-labelled but is conventionally quoted in thousands).

You can also compare reported losses against projected ultimates in a single summary table:

summary = pd.concat(
    [cl_model.latest_diagonal.to_frame(),
     cl_model.ultimate_.to_frame(),
     cl_model.ibnr_.to_frame()],
    axis=1
)
summary.columns = ['Reported', 'Ultimate', 'IBNR']
summary['% Developed'] = (summary['Reported'] / summary['Ultimate'] * 100).round(1)
print(summary.dropna())

Output:

            Reported      Ultimate         IBNR  % Developed
1982-01-01   16704.0  16857.953917    153.953917         99.1
1983-01-01   23466.0  24083.370924    617.370924         97.4
1984-01-01   27067.0  28703.142163   1636.142163         94.3
1985-01-01   26180.0  28926.736343   2746.736343         90.5
1986-01-01   15852.0  19501.103184   3649.103184         81.3
1987-01-01   12314.0  17749.302590   5435.302590         69.4
1988-01-01   13112.0  24019.192510  10907.192510         54.6
1989-01-01    5395.0  16044.984101  10649.984101         33.6
1990-01-01    2063.0  18402.442529  16339.442529         11.2

The 1982 accident year is 99% developed: the chain ladder adds only 154 of IBNR. The 1990 accident year is 11% developed, meaning 89% of its ultimate losses are still to emerge.


Visualising the development pattern

Seeing the development factors visually makes it much easier to spot anomalies: a factor that sits above or below the trend, a year that behaved differently from the cohort.

import matplotlib.pyplot as plt

# Individual link ratios as a DataFrame
link_ratios = triangle.link_ratio.to_frame()

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Left panel: scatter of all age-to-age ratios with volume-weighted mean
ax = axes[0]
for year in link_ratios.index:
    ax.plot(link_ratios.columns, link_ratios.loc[year], 'o', alpha=0.5, color='steelblue')

ldf_series = dev_vw.ldf_.to_frame().iloc[0]
ax.plot(ldf_series.index, ldf_series.values, 'r-o', linewidth=2, label='Volume-weighted LDF')
ax.set_title('Age-to-age link ratios: RAA triangle')
ax.set_xlabel('Development period')
ax.set_ylabel('Link ratio')
ax.legend()
ax.tick_params(axis='x', rotation=45)

# Right panel: CDF to ultimate
ax2 = axes[1]
cdf_series = dev_vw.cdf_.to_frame().iloc[0]
ax2.bar(range(len(cdf_series)), cdf_series.values, color='steelblue', alpha=0.7)
ax2.set_xticks(range(len(cdf_series)))
ax2.set_xticklabels(cdf_series.index, rotation=45)
ax2.set_title('CDF to ultimate by development age')
ax2.set_xlabel('Development period')
ax2.set_ylabel('CDF to ultimate')

plt.tight_layout()
plt.savefig('raa_development_pattern.png', dpi=150)
plt.show()

The left panel shows all individual link ratios as points, with the volume-weighted LDF overlaid in red. For the RAA data, the 12-24 period is visibly noisy: the 1982 accident year outlier (link ratio 40.4) appears as a clear high point. This is exactly the kind of plot you want before accepting your factor selections.

The right panel shows the CDF to ultimate by development age. It illustrates the exposure the older accident years have already worked off versus the residual uncertainty in the newer ones.


Where chain ladder breaks down

Chain ladder is a workhorse, not a silver bullet. It assumes that future development patterns will look like past ones. That assumption fails in predictable ways.

Thin data. With fewer than five or six accident years contributing to an age-to-age factor, the volume-weighted average is dominated by one or two observations. A single large claim in the early years of a new line can produce a 12-24 factor that is two or three times higher than reality. In practice, many actuaries cap the number of years used in the factor calculation, or exclude the most recent year when it behaves as a clear outlier.

Changing mix. If the mix of business changes materially between cohorts, historic development patterns may not apply to newer ones. A book that added a new class with faster settlement in 2022 will see development patterns shift; chain ladder applied to the combined triangle will blend the old and new patterns in a way that can be meaningless for projecting the newest years.

Operational time. Chain ladder works in calendar-year development ages. Some lines develop better on a claims-handled basis (operational time). If claims processing speed has changed, perhaps due to a staffing change or a new TPA, the development triangle in calendar time will look different from prior years even if the underlying liability profile has not changed. The 72-84 column might now represent “claims processed to 80% completion” rather than “12 months of incremental development”. You will not see this in the triangle until it is too late.

Superimposed inflation. If economic or social inflation is running at a different rate than is embedded in the historical development triangle, your ultimates will be understated. Chain ladder has no mechanism to capture this; you need to either trend the triangle explicitly before applying development factors, or use an alternative method (Bornhuetter-Ferguson with explicit a priori loss ratios, or a generalised linear model for the full triangle).

The bottom line: treat chain ladder estimates as a starting point for analysis, not an endpoint. Run it alongside Bornhuetter-Ferguson. Look at the diagnostic plots. Ask whether anything has changed since your oldest accident years that would invalidate the assumption that history repeats.


Part 2: stochastic reserving

In part 2 we cover stochastic reserving: the bootstrap ODP (over-dispersed Poisson) method, also available in chainladder via cl.BootstrapODPSample. Where this post gives you the point estimate (52,135 of IBNR), part 2 gives you the distribution: what 75th-percentile reserves look like, how to get a range of outcomes, and why the reserving risk capital charge at Lloyd’s is based on this kind of model.

# Preview: stochastic chain ladder
from chainladder import BootstrapODPSample

sampler = BootstrapODPSample(n_sims=5000, random_state=42)
sampler.fit(triangle)
cl_stochastic = cl.Chainladder().fit(sampler.transform(triangle))

# Distribution of total IBNR across 5000 simulations
ibnr_dist = cl_stochastic.ibnr_.sum('origin')
print(ibnr_dist.quantile(0.75))  # 75th percentile reserve

If you are working through a reserve review and want to understand the variability around your point estimates before then, the chainladder documentation covers the full range of stochastic methods.


The chainladder library handles triangle data correctly, exposes a clean sklearn-style API, and makes it straightforward to run both deterministic and stochastic methods without writing the development factor arithmetic yourself. For pricing actuaries who want to follow a reserve analysis intelligently, or for reserving actuaries moving their workflow into Python, it is the right starting point.

The full working code for this post is in the burning-cost-examples repository.

Back to all articles