Bruno et al. on thresholded direct convolution

Reproducing Bruno, Camerini, Manna and Tomassetti (2006)

Author

Stephen J. Mildenhall

Published

2026-10-22 19:55 commit b5ca906+

1 Context and problem

Bruno et al. (2006) proposes a direct method for the aggregate claims distribution, competing with Panjer recursion, De Pril transforms and the FFT. The idea is thresholded direct convolution: at each convolution order, keep only the outcomes whose probability exceeds a cutoff chosen so that the retained mass and the first three moments are correct to a prescribed tolerance, here \(10^{-6}\), and discard the rest.

The claimed advantages are that no assumption is needed on the counting distribution, that severities need not be identically distributed, and that the method avoids the underflow that kills Panjer recursion at large \(\lambda\) and the overflow and bit-reversal overhead of the FFT. Tables 3 to 5 count multiplications, Table 4 reports timings, and the authors conclude their method is “more flexible, easier and cheaper than the (exact and approximated) methods using recursion and FFT”, with the advantage growing as the number of convolutions rises.

This is a method paper of the most useful kind for a reproduction: it gives the full input specification and tabulates the output distribution to six decimal places.

2 Paper solution and gold standard

Severity is a 14-point discrete law on the integers 14 to 60, Figure 1, the same for every claim. The three counting distributions are set out in Table 1.

Table 1: The three published tables, their counting distributions and what each tabulates.
Table Counting distribution Content
6 Poisson, \(\lambda = 504.814259\) probability and CDF at 28 quantiles
7 Poisson, \(\lambda = 91{,}000\) CDF at 28 quantiles, 92,832 convolutions
8 Poisson mixed by a generalized Pareto CDF at 17 quantiles

The quantiles are quoted as \(\mu + k\sigma\) for stated \(\mu\) and \(\sigma\) and then truncated to integers, so the comparison is unambiguous: evaluate at the integers printed. Table 2 checks the transcribed severity first.

Figure 1: Bruno et al (2006), Table 1.
%xmode minimal
import numpy as np
import pandas as pd
from scipy.integrate import quad
from scipy.special import gammaln
from bookstyle import GT, build

XS = [14, 15, 16, 17, 18, 19, 20, 24, 26, 28, 30, 31, 55, 60]
PS = [.0103301, .0307990, .0293511, .0103301, .0730414, .0111568, .0264554,
      .1002133, .0815418, .0252146, .0212857, .0254214, .0991756, .4556837]
DSEV = ('dsev [' + ' '.join(map(str, XS)) + '] ['
        + ' '.join(f'{q:.7f}' for q in PS) + ']')
x, p = np.array(XS, float), np.array(PS)
m1, m2 = (x * p).sum(), (x ** 2 * p).sum()
GT(pd.DataFrame([{'sum of probabilities': p.sum(), 'severity mean': m1,
                  'E[U^2]': m2, 'severity sd': np.sqrt(m2 - m1 ** 2)}]),
   formatters={'sum of probabilities': 10, 'severity mean': 6, 'E[U^2]': 4,
               'severity sd': 6})
Exception reporting mode: Minimal
Table 2: The transcribed 14 point severity, its total mass and its first two moments.
sum of probabilitiesseverity meanE[U^2]severity sd
1.000000000042.7611972,174.396918.597766

3 Table 6: Poisson, \(\lambda = 504.814259\)

The severity is integer valued, so the aggregate lives on the integer lattice and bs = 1 is exact. The support runs to \(658 \times 60 = 39{,}480\), comfortably inside \(2^{17}\). The target is Figure 2.

Figure 2: Bruno et al (2006), Table 6.
LAM6 = 504.814259
a6 = build(f'agg B {LAM6} claims {DSEV} poisson', log2=17, bs=1,
           padding=1, normalize=False)
GT(pd.DataFrame([
    {'quantity': 'mean', 'aggregate': a6.est_m, 'lambda E[U]': LAM6 * m1,
     'paper': 21586.462129},
    {'quantity': 'standard deviation', 'aggregate': a6.est_sd,
     'lambda E[U]': np.sqrt(LAM6 * m2), 'paper': 1047.695834},
    {'quantity': 'total mass', 'aggregate': a6.agg_density.sum(),
     'lambda E[U]': 1.0, 'paper': 1.0},
]).set_index('quantity'), float_format=6)
Table 3: The Table 6 aggregate’s mean, standard deviation and total mass, against the compound Poisson formulas and the printed values.
quantityaggregatelambda E[U]paper
mean21,586.46212921,586.46212921,586.462129
standard deviation1,047.6958341,047.6958341,047.695834
total mass1.0000001.0000001.000000
T6 = [(16347, .000000, .000000), (17395, .000000, .000017),
      (18443, .000004, .001051), (18967, .000015, .005405),
      (19491, .000051, .021317), (19595, .000062, .027153),
      (19700, .000075, .034326), (19752, .000082, .038411),
      (19805, .000090, .042970), (19857, .000098, .047852),
      (19910, .000107, .053271), (20014, .000125, .065289),
      (20538, .000235, .158591), (20800, .000292, .227754),
      (21062, .000340, .310857), (21324, .000371, .404499),
      (21586, .000381, .503536), (21848, .000367, .601928),
      (22110, .000332, .693829), (22372, .000283, .774593),
      (22634, .000227, .841425), (23158, .000122, .931859),
      (23681, .000052, .975826), (24205, .000018, .992972),
      (24729, .000005, .998325), (25777, .000000, .999947),
      (26824, .000000, .999999), (29968, .000000, 1.000000)]
d6 = a6.density_df
t6 = pd.DataFrame([{'x': xx, 'agg probability': d6.loc[xx, 'p_total'],
                    'Table 6 probability': pp, 'agg CDF': d6.loc[xx, 'F'],
                    'Table 6 CDF': cc} for xx, pp, cc in T6])
t6['CDF diff'] = (t6['agg CDF'] - t6['Table 6 CDF']).abs()
GT(t6.set_index('x'),
   formatters={'agg probability': 6, 'Table 6 probability': 6, 'agg CDF': 6,
               'Table 6 CDF': 6, 'CDF diff': '{x:.2e}'})
Table 4: All 28 rows of Table 6, probability and CDF, against the rebuilt values.
xagg probabilityTable 6 probabilityagg CDFTable 6 CDFCDF diff
16,3470.0000000.0000000.0000000.0000008.07e-08
17,3950.0000000.0000000.0000170.0000174.68e-08
18,4430.0000040.0000040.0010510.0010512.56e-07
18,9670.0000150.0000150.0054050.0054051.86e-07
19,4910.0000510.0000510.0213170.0213171.31e-07
19,5950.0000620.0000620.0271530.0271531.15e-07
19,7000.0000750.0000750.0343260.0343264.11e-08
19,7520.0000820.0000820.0384110.0384111.71e-07
19,8050.0000900.0000900.0429700.0429704.49e-07
19,8570.0000980.0000980.0478520.0478523.09e-07
19,9100.0001070.0001070.0532710.0532712.19e-07
20,0140.0001250.0001250.0652890.0652892.51e-07
20,5380.0002350.0002350.1585910.1585914.03e-07
20,8000.0002920.0002920.2277540.2277543.37e-07
21,0620.0003400.0003400.3108570.3108572.39e-07
21,3240.0003710.0003710.4044990.4044992.45e-08
21,5860.0003810.0003810.5035360.5035362.36e-07
21,8480.0003670.0003670.6019280.6019283.08e-07
22,1100.0003320.0003320.6938290.6938293.62e-07
22,3720.0002830.0002830.7745930.7745935.32e-08
22,6340.0002270.0002270.8414250.8414252.46e-07
23,1580.0001220.0001220.9318590.9318594.14e-07
23,6810.0000520.0000520.9758260.9758261.41e-07
24,2050.0000180.0000180.9929720.9929729.39e-08
24,7290.0000050.0000050.9983250.9983253.66e-07
25,7770.0000000.0000000.9999470.9999473.70e-07
26,8240.0000000.0000000.9999990.9999992.37e-07
29,9680.0000000.0000001.0000001.0000002.00e-15
GT(pd.DataFrame([{'rows compared': len(t6),
                  'largest CDF difference': float(t6['CDF diff'].max()),
                  'largest probability difference':
                      float((t6['agg probability']
                             - t6['Table 6 probability']).abs().max())}]),
   formatters={'largest CDF difference': '{x:.2e}',
               'largest probability difference': '{x:.2e}'})
Table 5: Worst difference over Table 6, in both the probability and the CDF column.
rows comparedlargest CDF differencelargest probability difference
284.49e-074.93e-07

In Table 4 and Table 5 both columns match to \(5\times10^{-7}\), which is exact at the six decimals printed. In Table 3 the aggregate mean agrees with \(\lambda \mathsf E[U]\) to twelve digits.

4 Table 8: Poisson mixed by a generalized Pareto

The counting law is \(N \mid \Theta \sim \mathrm{Poisson}(\Theta)\) with \(\Theta\) generalized Pareto, \(a = 3.4959\), \(b = 30.1234\), \(c = 1.99321\), in the Klugman parameterization

\[u(\lambda) = \frac{a^{b}\,\Gamma(b+c)}{\Gamma(b)\Gamma(c)} \cdot \frac{\lambda^{c-1}}{(a+\lambda)^{b+c}}.\]

This mixing family is not one of the built-in mixed frequencies, which cover gamma, inverse Gaussian, Sichel, Delaporte and beta. It does not need to be: the paper’s method and aggregate agree that what a compound distribution needs from the frequency is a probability vector, so compute \(p(n) = \mathsf E[e^{-\Theta}\Theta^n/n!]\) by quadrature and hand it over as a dfreq. The target is Figure 3.

The mixing moments are \(\mathsf E[\Theta] = ac/(b-1)\) and \(\mathsf E[\Theta^2] = a^2 c(c+1)/((b-1)(b-2))\), which give the independent check on the parameterization in Table 6, before any of it is used.

A, B, C = 3.4959, 30.1234, 1.99321
ETH = A * C / (B - 1)
ETH2 = A ** 2 * C * (C + 1) / ((B - 1) * (B - 2))
GT(pd.DataFrame([
    {'quantity': 'E[Theta] = E[N]', 'recomputed': ETH, 'paper': np.nan},
    {'quantity': 'aggregate mean', 'recomputed': ETH * m1, 'paper': 10.2310},
    {'quantity': 'aggregate standard deviation',
     'recomputed': np.sqrt(ETH * m2 + (ETH2 - ETH ** 2) * m1 ** 2),
     'paper': 24.0489},
]).set_index('quantity'), float_format=6)
Table 6: The mixing distribution’s moments, and the aggregate mean and standard deviation they imply, against the paper’s.
quantityrecomputedpaper
E[Theta] = E[N]0.239260—
aggregate mean10.23104110.231000
aggregate standard deviation24.04892424.048900
Figure 3: Bruno et al (2006), Table 8.
lc = np.log(A) * B + gammaln(B + C) - gammaln(B) - gammaln(C)
mix_pdf = lambda lam: np.exp(lc + (C - 1) * np.log(lam)
                             - (B + C) * np.log(A + lam))
pn = np.array([quad(lambda t: np.exp(-t + n * np.log(t) - gammaln(n + 1))
                    * mix_pdf(t), 0, np.inf, limit=400)[0]
               for n in range(401)])
keep = pn > 1e-14
ns, qs = np.arange(401)[keep], pn[keep]
FREQ = ('dfreq [' + ' '.join(map(str, ns)) + '] ['
        + ' '.join(f'{q:.14e}' for q in qs) + ']')
a8 = build(f'agg B8 {FREQ} {DSEV}', log2=16, bs=1, padding=1, normalize=False)

T8 = [(0, .798708), (4, .798708), (10, .798708), (16, .810615), (22, .831052),
      (28, .866018), (34, .874409), (46, .877189), (58, .896084),
      (70, .973562), (82, .980304), (106, .987965), (130, .997091),
      (202, .999813), (250, .999972), (491, 1.000000), (1212, 1.000000)]
t8 = pd.DataFrame([{'x': xx, 'agg CDF': a8.density_df.loc[xx, 'F'],
                    'Table 8 CDF': cc} for xx, cc in T8])
t8['diff'] = (t8['agg CDF'] - t8['Table 8 CDF']).abs()
GT(t8.set_index('x'),
   formatters={'agg CDF': 6, 'Table 8 CDF': 6, 'diff': '{x:.2e}'})
Table 7: All 17 rows of Table 8, the CDF under the generalized Pareto mixed Poisson, against the rebuilt values.
xagg CDFTable 8 CDFdiff
00.7987080.7987084.00e-07
40.7987080.7987084.00e-07
100.7987080.7987084.00e-07
160.8106150.8106154.93e-07
220.8310520.8310528.92e-08
280.8660180.8660188.98e-08
340.8744090.8744094.31e-07
460.8771890.8771893.67e-07
580.8960840.8960842.68e-07
700.9735620.9735626.24e-08
820.9803040.9803041.06e-07
1060.9879650.9879652.34e-07
1300.9970910.9970912.67e-07
2020.9998130.9998134.88e-08
2500.9999720.9999723.65e-07
4911.0000001.0000002.29e-09
1,2121.0000001.0000002.34e-12
GT(pd.DataFrame([{'counts retained above 1e-14': int(ns[-1]),
                  'sum of p(n)': float(pn.sum()),
                  'p(0)': float(pn[0]), 'Table 8 CDF at 0': 0.798708,
                  'agg mean': a8.est_m, 'agg sd': a8.est_sd,
                  'largest CDF difference': float(t8['diff'].max())}]),
   formatters={'sum of p(n)': 12, 'p(0)': 6, 'Table 8 CDF at 0': 6,
               'agg mean': 6, 'agg sd': 6,
               'largest CDF difference': '{x:.2e}'})
Table 8: The truncated frequency vector, the resulting moments, and the worst error over Table 8.
counts retained above 1e-14sum of p(n)p(0)Table 8 CDF at 0agg meanagg sdlargest CDF difference
170.9999999999980.7987080.79870810.23104224.0491324.93e-07

Table 7 and Table 8 have all seventeen values exact at six decimals, including \(p(0) = 0.798708\), which is the probability of no claim and so the CDF’s value everywhere below 14.

5 Table 7: \(\lambda = 91{,}000\), the case the competitors could not do

This is the paper’s showcase. At \(\lambda = 91{,}000\) the aggregate mean is 3.89 million, the method needs 92,832 convolutions, and Table 4 records that Panjer recursion and De Pril both underflow at \(\lambda\) as low as 1004.8. The authors report 1,950 seconds for their own method here. Their FFT comparison stops at \(\lambda = 5004.8\), where it took 3,850 seconds.

The support runs to \(92{,}832 \times 60 = 5.57\) million, so a grid of \(2^{23}\) at bs = 1 holds it whole and no padding is needed: with the support strictly inside the grid there is nowhere for aliasing to come from. The target is Figure 4.

Figure 4: Bruno et al (2006), Table 7.
import time
t0 = time.time()
a7 = build(f'agg B7 91000 claims {DSEV} poisson', log2=23, bs=1,
           padding=0, normalize=False)
elapsed = time.time() - t0
GT(pd.DataFrame([{'seconds': elapsed, 'grid points': 2 ** 23,
                  'x_max': a7.xs[-1], 'mean': a7.est_m, 'sd': a7.est_sd,
                  'paper mean': 3891268.954300, 'paper sd': 14066.631372,
                  'total mass': a7.agg_density.sum()}]),
   formatters={'seconds': 2, 'grid points': '{x:,.0f}', 'x_max': '{x:,.0f}',
               'mean': '{x:,.4f}', 'sd': '{x:,.4f}',
               'paper mean': '{x:,.4f}', 'paper sd': '{x:,.4f}',
               'total mass': 12})
Table 9: The \(\lambda = 91{,}000\) build, its grid, its run time and its moments against the paper’s.
secondsgrid pointsx_maxmeansdpaper meanpaper sdtotal mass
0.708,388,6088,924,1933,891,268.954314,066.62693,891,268.954314,066.63141.000000000000
T7 = [(3820935, .000000), (3835002, .000030), (3849069, .001327),
      (3856102, .006149), (3863135, .022643), (3864542, .028604),
      (3865949, .035817), (3866652, .039943), (3867355, .044447),
      (3868059, .049359), (3868762, .054687), (3870169, .066705),
      (3877202, .158658), (3880718, .226704), (3884235, .308708),
      (3887752, .401538), (3891268, .500249), (3894785, .598941),
      (3898302, .691642), (3901818, .773450), (3905335, .841343),
      (3912368, .933083), (3919402, .977145), (3926435, .993730),
      (3933468, .998627), (3947535, .999967), (3961602, 1.000000),
      (4003802, 1.000000)]
F7 = a7.density_df['F']
t7 = pd.DataFrame([{'x': xx, 'agg CDF': F7.loc[xx], 'Table 7 CDF': cc}
                   for xx, cc in T7])
t7['diff'] = (t7['agg CDF'] - t7['Table 7 CDF']).abs()
GT(t7.set_index('x'),
   formatters={'agg CDF': 6, 'Table 7 CDF': 6, 'diff': '{x:.2e}'})
Table 10: All 28 rows of Table 7, the CDF at \(\lambda = 91{,}000\), against the rebuilt values.
xagg CDFTable 7 CDFdiff
3,820,9350.0000000.0000002.64e-07
3,835,0020.0000300.0000303.70e-07
3,849,0690.0013270.0013272.03e-07
3,856,1020.0061490.0061493.37e-07
3,863,1350.0226430.0226432.25e-07
3,864,5420.0286040.0286044.82e-07
3,865,9490.0358170.0358174.29e-07
3,866,6520.0399430.0399431.04e-07
3,867,3550.0444470.0444471.90e-07
3,868,0590.0493590.0493595.24e-10
3,868,7620.0546870.0546874.45e-07
3,870,1690.0667050.0667051.06e-07
3,877,2020.1586580.1586582.92e-07
3,880,7180.2267040.2267042.36e-07
3,884,2350.3087080.3087081.90e-07
3,887,7520.4015380.4015383.02e-07
3,891,2680.5002490.5002493.90e-07
3,894,7850.5989410.5989414.53e-07
3,898,3020.6916420.6916423.46e-07
3,901,8180.7734500.7734501.18e-08
3,905,3350.8413430.8413432.87e-07
3,912,3680.9330830.9330831.29e-07
3,919,4020.9771450.9771453.92e-07
3,926,4350.9937300.9937302.21e-07
3,933,4680.9986270.9986273.89e-07
3,947,5350.9999670.9999678.37e-09
3,961,6021.0000001.0000003.11e-07
4,003,8021.0000001.0000007.43e-12
GT(pd.DataFrame([{'rows compared': len(t7),
                  'largest CDF difference': float(t7['diff'].max()),
                  'paper seconds for its own method': 1950,
                  'aggregate seconds': elapsed}]),
   formatters={'largest CDF difference': '{x:.2e}', 'aggregate seconds': 2})
Table 11: Worst error over Table 7, and the run time beside the paper’s own 1,950 seconds.
rows comparedlargest CDF differencepaper seconds for its own methodaggregate seconds
284.82e-071,9500.70

Table 10 and Table 11 have all 28 values exact at six decimals, in the fraction of a second Table 9 records.

6 Comments

6.1 The timing claim has not aged well

The paper’s case rests on operation counts and wall clock. Table 3 argues the FFT needs 6 to 41 times more real multiplications than direct thresholded convolution, and Table 4 shows the FFT taking 3,850 seconds at \(\lambda = 5004.8\) against 41 for the proposed method.

The multiplication count was never the right proxy, and the paper half says so: “the number of multiplications is only one of so many elements affecting the procedure’s processing time”. What it misses is the direction of the error. The authors count the FFT as \(O(N \log N)\) per convolution and then sum over 92,832 convolutions, because their formalization performs the convolutions one at a time, each an inverse transform of \(\phi_{n-1}\phi_1\). That is not how one computes a compound distribution with an FFT. The frequency enters through its probability generating function applied pointwise to the severity transform, so the whole compound distribution costs two transforms of length \(2^{23}\) regardless of whether \(\lambda\) is 500 or 91,000. Their equation (19) sums a per-convolution cost that a correct implementation never pays.

That is why the reproduction runs in seconds rather than hours, and why the ratio to their reported time is four orders of magnitude rather than the factor of 40 they anticipated in the other direction. Some of the gap is twenty years of hardware. The structural part is not.

6.2 What survives, and it is not nothing

The threshold idea is sound and aggregate uses a version of it. Table 8 needed the frequency vector only out to \(n = 17\), because \(p(n)\) had fallen below \(10^{-14}\) by then, and that truncation is exactly the paper’s first phase. Their control, choosing the cutoff so that the retained mass and the first three moments are accurate to a stated tolerance, rather than by eye, is a good discipline and the closest thing in the paper to a genuinely portable idea. aggregate’s validation reports the analogous quantity as a moment comparison after the fact.

The underflow point also survives. Panjer recursion really does fail at \(\lambda = 1004.8\) here while the transform does not, and that is a real structural difference between recursions that propagate a small \(g(0)\) and methods that do not. It just happens to be an argument for the FFT rather than against it.

6.3 The severity is the interesting part

The 14-point severity carries 45.6% of its mass at the single value 60 and another 9.9% at 55, so more than half the distribution sits on two atoms at the top of the range. That is what makes the case a fair stress test: the aggregate is a convolution of a very lumpy lattice law, and a method that smoothed it would show up immediately. Both the paper’s method and aggregate reproduce the probability column, not just the CDF, which is the stronger test of the two.