Exact compound distributions with the nttagg kind and the number-theoretic transform
numerics
Author
Stephen J. Mildenhall
Published
2026-10-30
When the claim count \(N\) and the severity \(X\) take finitely many nonnegative integer values with rational probabilities, every probability of \(S = X_1 + \cdots + X_N\) is rational. Write \(\Pr(N=k) = a_k/d_N\) and \(\Pr(X=j) = b_j/d_X\), with \(n\) the largest count. Then \(\mathsf{P}z^S = \sum_k (a_k/d_N)\bigl(\sum_j b_j z^j/d_X\bigr)^k\) is a polynomial and \(D = d_N d_X^n\) clears its denominators, so \(\Pr(S=s) = H_s/D\) with integer \(H_s\). The number-theoretic transform (NTT) is the FFT over \(\mathbb{Z}/p\mathbb{Z}\) for primes \(p\) with \(2^r \mid p-1\), where \(2^r\) is the transform length. It computes each \(H_s \bmod p\) exactly, and the Chinese remainder theorem recovers \(H_s\) once the product of the primes exceeds \(D\).
The float FFT computes the same polynomial in double precision. Its error is roughly constant in absolute terms, near \(10^{-17}\), so the relative error in a probability of size \(10^{-k}\) is of order \(10^{k-17}\): about fifteen figures in the body of the distribution and none below \(10^{-17}\). A comparison with the exact answer shows where the float answer loses accuracy.
The DecL kind nttagg1 builds an aggregate.ntt.AggregateNTT, an ordinary Aggregate on the integer lattice, with bucket size 1, that also stores the exact answer. The program has a dfreq clause followed by a dsev clause, with no exposure, layers, or reinsurance. Outcomes are nonnegative integers, and probabilities are exact as written: 0.3 is \(3/10\), not the double nearest it. A probability may be an arithmetic expression, such as (1-1/1000), evaluated exactly, and an omitted probability list gives equal exact weights.
nttagg Dice dfreq[1:6]dsev[1:6]nttagg Book dfreq[0123][0.40.30.20.1]dsev[125][1/21/31/6]nttagg Cat dfreq[0:8]dsev[110][(1-1/1000)1/1000]
The Book example has 0 to 3 claims, each of size 1, 2, or 5. Its pprogram shows the program with the probabilities reduced to fractions, and qd prints the usual summary.
Code
a = build('nttagg Book dfreq [0 1 2 3] [0.4 0.3 0.2 0.1] ''dsev [1 2 5] [1/2 1/3 1/6]')print(a.pprogram)qd(a)
nttagg Book
dfreq [0 1 2 3] [2/5 3/10 1/5 1/10]
dsev [1 2 5] [1/2 1/3 1/6]
Aggregate object: Book. Frequency distribution empirical. Severity
dhistogram, [1, 5], bounded; atoms [1 2 5]. Updated with bucket size 1
and log2 = 4. Validation: not unreasonable.
Mean SD CV Skew P01 Median P99
X
Freq 1 1 1 0.6
Sev 2 1.4142 0.70711 1.4142 1 1 5
Agg 2 2.4495 1.2247 1.4153 0 1 10
The attribute p_exact is a Series of reduced Fractions indexed by loss, here over the whole support 0 to 15. Losses of 13 and 14 are impossible, and the largest loss, 15, needs three claims of 5, with probability \((1/10)(1/6)^3 = 1/2160\).
Any quantity computed from the exact probabilities in rational arithmetic is also exact. For Book the total probability, the mean, \(\Pr(S \le 2)\), and the stop-loss premium \(\mathsf{P}(S-5)^+\) are \(1\), \(2\), \(7/10\), and \(23/90\).
Code
pe = {int(x): p for x, p in a.p_exact.items()}print(sum(pe.values()), sum(x * p for x, p in pe.items()),sum(p for x, p in pe.items() if x <=2),sum(max(x -5, 0) * p for x, p in pe.items()))
1 2 7/10 23/90
The exact moments are a column of stats_df, computed in rational arithmetic and rounded to float at the end. Validation compares the FFT moments, in the empirical column, with the exact moments. For Book the two agree to the six figures shown.
A probability in an nttagg list may be an arithmetic expression, and it is evaluated exactly. In the Cat example the expression (1-1/1000) is exactly \(999/1000\): a claim is 1 with that probability and 10 otherwise, and there are 0 to 8 claims, each count with probability \(1/9\). A loss of \(10k\) needs exactly \(k\) claims, all large, so \(\Pr(S=10k) = 1/(9\cdot 1000^k)\). A loss of 9 is impossible, and a loss of 8 needs eight small claims.
For the Cat example, the frame accuracy_df lists the exact probability, correctly rounded to a float, beside the raw FFT answer, with the relative error computed exactly. The FFT error is never more than about \(2\times10^{-17}\), so its relative error grows as the probability falls, reaching about 9% at \(10^{-16}\). At smaller probabilities the FFT answer is noise, and it is negative at a loss of 60.
The attribute ntt_audit summarizes the comparison. The noise floor is the largest absolute FFT value on a bucket whose exact probability is zero, and the maximum relative error is taken over the buckets whose exact probability is above it. The unresolved buckets have a positive exact probability at or below the floor.
Code
for k in ['noise_floor', 'max_rel_err', 'rel_err_buckets','unresolved_buckets']:print(f'{k:20s}{c.ntt_audit[k]:.4g}')
The Deep example has the severity of Cat, 1 or 10, and a claim count equally likely to be any of 0 to 120. Its largest loss has probability \(\Pr(S = 1200) = 1/(121\cdot 10^{360})\), far below the smallest double, about \(5\times10^{-324}\). The column p_exact_float underflows to zero at losses of 1,190 and 1,200, while p_exact_log10, computed from the integers, stays finite. The float answer p_total is zero at all four losses.
The exact_tail chart shows the exact mass and survival function on a base-10 logarithmic scale beside the float FFT answer. The float answer stops near \(10^{-15}\), and the exact answer continues to \(10^{-362}\).
The optional compiled package aggregate-ntt speeds up the exact calculation in large cases, and the library uses it when it is installed. The function engine_status() returns a dictionary showing whether it is used and, if not, why. Passing ntt_engine='python' to build forces the pure-Python calculation, and for Deep the two give identical exact probabilities.
An nttagg program is refused when its probabilities do not sum to exactly 1, when an outcome is not a nonnegative integer, or when a probability raises a number to a fractional power, whose result need not be rational. The support must also fit the grid, which build limits to \(2^{16}\) buckets by default. An nttagg cannot be a unit of a portfolio.
Code
refuse('nttagg X dfreq [0 1] [1/2 1/3] dsev [1]','nttagg X dfreq [0 1] dsev [1.5 2]','nttagg X dfreq [0 1] dsev [1 2] [(1/2)**(1/2) 1/2]','nttagg X dfreq [1:100] dsev [1:100000]','port P nttagg X dfreq [1] dsev [1]')
nttagg frequency: probabilities must sum to exactly 1; shortfall is 1/6
nttagg outcomes must be nonnegative integers, got '1.5'; clear denominators and
rescale the lattice yourself
nttagg exponent must be a whole number, got 1/2
X: the exact support 0..10000000 needs log2 >= 24, got 16. Pass build(...,
log2=24) or add hints{log2=24} to the declaration.
DecL parse error at line 1, column 8: Unexpected 'nttagg'. Did you mean: agg,
tags?
---title: Using `nttagg`subtitle: "Exact compound distributions with the nttagg kind and the number-theoretic transform"categories: [numerics]date: last-modifiedjupyter: jupytext: text_representation: extension: .qmd format_name: quarto format_version: '1.0' kernelspec: display_name: Python 3 (ipykernel) language: python name: python3---When the claim count $N$ and the severity $X$ take finitely many nonnegative integer values with rational probabilities, every probability of $S = X_1 + \cdots + X_N$ is rational. Write $\Pr(N=k) = a_k/d_N$ and $\Pr(X=j) = b_j/d_X$, with $n$ the largest count. Then $\mathsf{P}z^S = \sum_k (a_k/d_N)\bigl(\sum_j b_j z^j/d_X\bigr)^k$ is a polynomial and $D = d_N d_X^n$ clears its denominators, so $\Pr(S=s) = H_s/D$ with integer $H_s$. The number-theoretic transform (NTT) is the FFT over $\mathbb{Z}/p\mathbb{Z}$ for primes $p$ with $2^r \mid p-1$, where $2^r$ is the transform length. It computes each $H_s \bmod p$ exactly, and the Chinese remainder theorem recovers $H_s$ once the product of the primes exceeds $D$.The float FFT computes the same polynomial in double precision. Its error is roughly constant in absolute terms, near $10^{-17}$, so the relative error in a probability of size $10^{-k}$ is of order $10^{k-17}$: about fifteen figures in the body of the distribution and none below $10^{-17}$. A comparison with the exact answer shows where the float answer loses accuracy.The DecL kind `nttagg`^[From aggregate 1.1.0] builds an `aggregate.ntt.AggregateNTT`, an ordinary `Aggregate` on the integer lattice, with bucket size 1, that also stores the exact answer. The program has a `dfreq` clause followed by a `dsev` clause, with no exposure, layers, or reinsurance. Outcomes are nonnegative integers, and probabilities are exact as written: `0.3` is $3/10$, not the double nearest it. A probability may be an arithmetic expression, such as `(1-1/1000)`, evaluated exactly, and an omitted probability list gives equal exact weights.```declnttagg Dice dfreq [1:6] dsev [1:6]nttagg Book dfreq [0 1 2 3] [0.4 0.3 0.2 0.1] dsev [1 2 5] [1/2 1/3 1/6]nttagg Cat dfreq [0:8] dsev [1 10] [(1-1/1000) 1/1000]``````{python}from fractions import Fractionfrom aggregate import build, qd, __version__from aggregate.ntt import engine_statusfrom aggregate.charts import build_chart_docfrom aggregate.plots import plot_chartdocfrom style import wprint, GT, refuse, figuresfigures()print(__version__)```## Exact inputs and exact probabilitiesThe `Book` example has 0 to 3 claims, each of size 1, 2, or 5. Its `pprogram` shows the program with the probabilities reduced to fractions, and `qd` prints the usual summary.```{python}a = build('nttagg Book dfreq [0 1 2 3] [0.4 0.3 0.2 0.1] ''dsev [1 2 5] [1/2 1/3 1/6]')print(a.pprogram)qd(a)```The attribute `p_exact` is a Series of reduced `Fraction`s indexed by loss, here over the whole support 0 to 15. Losses of 13 and 14 are impossible, and the largest loss, 15, needs three claims of 5, with probability $(1/10)(1/6)^3 = 1/2160$.```{python}wprint(' '.join(str(p) for p in a.p_exact))```Any quantity computed from the exact probabilities in rational arithmetic is also exact. For `Book` the total probability, the mean, $\Pr(S \le 2)$, and the stop-loss premium $\mathsf{P}(S-5)^+$ are $1$, $2$, $7/10$, and $23/90$.```{python}pe = {int(x): p for x, p in a.p_exact.items()}print(sum(pe.values()), sum(x * p for x, p in pe.items()),sum(p for x, p in pe.items() if x <=2),sum(max(x -5, 0) * p for x, p in pe.items()))```The exact moments are a column of `stats_df`, computed in rational arithmetic and rounded to float at the end. Validation compares the FFT moments, in the `empirical` column, with the exact moments. For `Book` the two agree to the six figures shown.```{python}GT(a.stats_df.loc['agg', ['exact', 'empirical']], float_format='.6g')```## Exact arithmetic in a probability listA probability in an `nttagg` list may be an arithmetic expression, and it is evaluated exactly. In the `Cat` example the expression `(1-1/1000)` is exactly $999/1000$: a claim is 1 with that probability and 10 otherwise, and there are 0 to 8 claims, each count with probability $1/9$. A loss of $10k$ needs exactly $k$ claims, all large, so $\Pr(S=10k) = 1/(9\cdot 1000^k)$. A loss of 9 is impossible, and a loss of 8 needs eight small claims.```{python}c = build('nttagg Cat dfreq [0:8] dsev [1 10] [(1-1/1000) 1/1000]')print(c.pprogram)print(c.p_exact_at([9, 10, 20, 80]))print(c.p_exact_at(8), c.p_exact_at(8) == Fraction(999, 1000) **8/9)print(all(c.p_exact_at(10* k) == Fraction(1, 9*1000** k)for k inrange(1, 9)))```## Accuracy of the float FFT in the tailFor the `Cat` example, the frame `accuracy_df` lists the exact probability, correctly rounded to a float, beside the raw FFT answer, with the relative error computed exactly. The FFT error is never more than about $2\times10^{-17}$, so its relative error grows as the probability falls, reaching about 9% at $10^{-16}$. At smaller probabilities the FFT answer is noise, and it is negative at a loss of 60.```{python}df = c.accuracy_df.loc[10:80:10, ['p_exact_float', 'p_fft', 'rel_err']]GT(df.rename(index=int), float_format='.3e')```The attribute `ntt_audit` summarizes the comparison. The noise floor is the largest absolute FFT value on a bucket whose exact probability is zero, and the maximum relative error is taken over the buckets whose exact probability is above it. The unresolved buckets have a positive exact probability at or below the floor.```{python}for k in ['noise_floor', 'max_rel_err', 'rel_err_buckets','unresolved_buckets']:print(f'{k:20s}{c.ntt_audit[k]:.4g}')```## Probabilities below the smallest doubleThe `Deep` example has the severity of `Cat`, 1 or 10, and a claim count equally likely to be any of 0 to 120. Its largest loss has probability $\Pr(S = 1200) = 1/(121\cdot 10^{360})$, far below the smallest double, about $5\times10^{-324}$. The column `p_exact_float` underflows to zero at losses of 1,190 and 1,200, while `p_exact_log10`, computed from the integers, stays finite. The float answer `p_total` is zero at all four losses.```{python}d = build('nttagg Deep dfreq [0:120] dsev [1 10] [(1-1/1000) 1/1000]')print(d.p_exact_at(1200) == Fraction(1, 121*10**360))GT(d.density_df.loc[[300, 600, 1190, 1200], ['p_total', 'p_exact_float', 'p_exact_log10']], float_format='.4g')```The `exact_tail` chart shows the exact mass and survival function on a base-10 logarithmic scale beside the float FFT answer. The float answer stops near $10^{-15}$, and the exact answer continues to $10^{-362}$.```{python}fig = plot_chartdoc(build_chart_doc(d, 'exact_tail'))```## The optional compiled package `aggregate-ntt`The optional compiled package `aggregate-ntt` speeds up the exact calculation in large cases, and the library uses it when it is installed. The function `engine_status()` returns a dictionary showing whether it is used and, if not, why. Passing `ntt_engine='python'` to `build` forces the pure-Python calculation, and for `Deep` the two give identical exact probabilities.```{python}wprint(engine_status())dp = build('nttagg DeepPython dfreq [0:120] dsev [1 10] [(1-1/1000) 1/1000]', ntt_engine='python')print(d.ntt_audit['engine'], dp.ntt_audit['engine'], dp.p_exact.equals(d.p_exact))```## Refused programsAn `nttagg` program is refused when its probabilities do not sum to exactly 1, when an outcome is not a nonnegative integer, or when a probability raises a number to a fractional power, whose result need not be rational. The support must also fit the grid, which `build` limits to $2^{16}$ buckets by default. An `nttagg` cannot be a unit of a portfolio.```{python}refuse('nttagg X dfreq [0 1] [1/2 1/3] dsev [1]','nttagg X dfreq [0 1] dsev [1.5 2]','nttagg X dfreq [0 1] dsev [1 2] [(1/2)**(1/2) 1/2]','nttagg X dfreq [1:100] dsev [1:100000]','port P nttagg X dfreq [1] dsev [1]')```