---
title: Using lattice buckets
subtitle: "Exact bucket sizes for discrete severities on a dyadic lattice"
categories: [numerics, severity, reinsurance]
date: last-modified
jupyter:
jupytext:
text_representation:
extension: .qmd
format_name: quarto
format_version: '1.0'
kernelspec:
display_name: Python 3 (ipykernel)
language: python
name: python3
---
A discrete severity whose atoms are all whole multiples of a step $h$ has an aggregate on the same lattice $h\mathbb Z$, whatever the frequency. An exact and small grid therefore exists: put every atom on a grid point and size the grid to hold the support and one claim. The bucket sizer^[From aggregate 1.26.0] ([Using FFT windows](windows.qmd)) finds that grid for integer, fractional, and signed atoms, with one rule for an `Aggregate`, a `Portfolio`, and a discrete bivariate axis.
The rule has three parts. **The step.** Every float is a dyadic rational, so "on a lattice" needs a depth and a tolerance. Let $M=\max|x_j|$. Search the powers of two $2^{-r}$ from the coarsest at or below $M$ down to $2^{-D}M$, where $D$ is the larger of 16 and the requested `log2`, and take the first at which every atom is within $10^{-12}M$ of $2^{-r}\mathbb Z$. The step is $h=\gcd_i(p_i)/2^r$, with $p_i$ the rounded $x_i2^r$, written $h=n/2^s$ with $n$ odd. Decimal atoms such as 0.1, and thirds, never meet the test and have no lattice. The atoms are those of every view the grid holds: gross after any layer, ceded and net of each occurrence layer, and the points of an aggregate cover. **The candidates.** In order of preference, the candidate buckets are the largest nice bucket dividing $h$ and then $h$ itself. A nice bucket is one in the set used by `round_bucket`: $2^{-s}$ for a fractional step (0.75 gives 0.25, 1.5 gives 0.5), and the largest of $\{1,2,4,5,8\}\cdot10^k$ dividing an integer step (3 gives 1, 15 gives 5, and 1000 stays 1000). **The fit.** The sizer takes the first candidate whose grid holds both the window and one claim within the requested `log2`, then shrinks `log2` to what those two need. If no candidate fits, the standard sizing methods select the bucket, the atoms do not sit on the grid, and the sizer warns.
```{python}
import warnings
import numpy as np
import pandas as pd
import scipy.stats as ss
from aggregate import build, Aggregate, Portfolio, __version__
from aggregate.utilities import _dyadic_lattice_step, _lattice_candidates
from style import wprint, GT, figures, refuse
figures()
print(__version__)
```
## Lattice buckets for three-atom severities
Each example has a three-atom severity with probabilities 1/4, 1/2, and 1/4 and a fixed or Poisson count, mostly of 3 claims. The column `cap` is the requested `log2`, `step` is the lattice step $h$, and `on_grid` shows whether every atom lands on a grid point.
```{python}
rows = [ # atoms, claim count, frequency, log2 cap
('0.25 0.5 1.75', 3, 'fixed', 16), ('-1.75 0.25 0.5', 3, 'fixed', 16),
('0.75 1.5 2.25', 3, 'fixed', 16), ('0.75 1.5 2.25', 3, 'fixed', 4),
('1.5 3 4.5', 3, 'fixed', 16), ('3 6 9', 3, 'fixed', 16),
('3 6 9', 3, 'poisson', 16), ('3 6 9', 1000000, 'poisson', 16),
('5 10 15', 3, 'fixed', 16), ('5 10 15', 3, 'poisson', 16),
('1000 2000 5000', 3, 'fixed', 16), ('1000 2000 5000', 3, 'poisson', 16),
('0.1 0.2 0.3', 3, 'fixed', 16)]
out = []
for atoms, n, freq, cap in rows:
a = build(f'agg T {n} claims dsev [{atoms}] [.25 .5 .25] {freq}', log2=cap)
k = (np.array(atoms.split(), dtype=float) - a.x_min) / a.bs
out.append(dict(atoms=atoms, claims=n, freq=freq, cap=cap,
step=a._severity_lattice(), bs=a.bs, log2=a.log2,
on_grid=bool(np.array_equal(k, np.round(k)))))
table = pd.DataFrame(out)
GT(table, float_format='.6g')
```
The quarter-point atoms need only 32 buckets of 0.25. The decimal atoms 0.1, 0.2, and 0.3 have no lattice and take the standard bucket $2^{-16}$ on 65,536 buckets, with the atoms off the grid. A lattice of 0.75 takes the nice bucket 0.25, unless the cap is too tight for it: at `log2` 4 it takes the step 0.75 itself. A fixed and a Poisson count on the same atoms get the same bucket: 5 for the atoms 5, 10, and 15, and 1 for the atoms 3, 6, and 9.
The aggregate with a million claims keeps the step 3 as its bucket. Its count, not its atoms, sets the width of its band of mass, 75,052, and the nice bucket 1 would need `log2` 17, more than the cap of 16. The nice bucket is held to the cap, so `log2` never grows just to make the bucket nicer, and the step 3 fits on $2^{15}$ buckets. Only the exact step may take `log2` past the cap, and only in the `windowed` sizing method.
```{python}
a = build('agg T 1000000 claims dsev [3 6 9] [.25 .5 .25] poisson')
w = a.bs_window_df.loc['windowed']
print(a.bs, a.log2, f'band width {w["W"]:,.0f}',
'log2 needed at bs 1:', int(np.ceil(np.log2(w['W'] + 1))))
```
## A windowed grid that holds one claim
A concentrated aggregate has all its mass in a narrow band far above 0, and the `windowed` sizing method ([Using FFT windows](windows.qmd)) places the grid over that band. The severity is discretized on the same number of buckets wherever the grid starts, so the grid must span one claim as well as the band. One hundred claims on the atoms 1000 and 1001 have a band about 59 wide, which needs `log2` 6 at bucket 1, while a claim of 1001 needs `log2` 10. The sizer takes the larger of the two. The aggregate is then the exact binomial law on 100,000 to 100,100, on a grid starting at 99,640.
```{python}
a = build('agg T 100 claims dsev [1000 1001] fixed')
wprint(a.bs_window_df.loc['windowed', 'note'])
xs = np.arange(100000, 100101)
err = a.density_df.p_total.reindex(xs) - ss.binom(100, 0.5).pmf(xs - 100000)
print(a.bs, a.log2, a.x_min, f'largest error {err.abs().max():.1e}')
```
## The lattice step and candidate buckets
The private functions `_dyadic_lattice_step` and `_lattice_candidates` in `aggregate.utilities` implement the first two parts of the rule. The first returns the step and the largest distance of an atom from its lattice point, or `None` when there is no lattice. The second returns the candidate buckets in order of preference.
```{python}
for atoms in ([0.25, 0.5, 1.75], [0.75, 1.5, 2.25], [3, 6, 9],
[200000, 400000, 1000000], [0.1, 0.2, 0.3], [1 / 3, 2 / 3],
[0, 1, 100000]):
fit = _dyadic_lattice_step(atoms)
print(f'{str(atoms):32s} {str(fit):24s}',
_lattice_candidates(fit[0]) if fit else '')
```
The search starts at the largest atom, so the test does not depend on scale: atoms in the hundreds of thousands find their step just as small integers do. The atoms 0, 1 and 100,000 have the integer step 1, but it is more than 16 binary levels below the largest atom, too fine to be a sensible bucket at the default depth, so the search finds no lattice.
## Lattice notes in bs_window_df
In the frame `bs_window_df` each sizing method that used the lattice records it in the `note` column, with the step, the residual, and whether the bucket divides the step. The string `bs_explanation` gives the winning method and the window.
```{python}
a = build('agg T 3 claims dsev [0.75 1.5 2.25] [.25 .5 .25] fixed')
for method, row in a.bs_window_df.iterrows():
wprint(f'{method:15s} {row["bs"]:<8g} {row["note"]}')
wprint(a.bs_explanation)
```
A bucket given explicitly, here `bs=0.5`, is used as given, and the grid keeps the requested `log2` of 16. Setting `bs` is the way to override the lattice. If the bucket misses the lattice, the note records the miss. No lattice warning is raised, because the bucket was chosen, and the wrong distribution shows in the verdict and its warning ([Using validation](validation.qmd)).
```{python}
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
a = build('agg P 3 claims dsev [0.75 1.5 2.25] [.25 .5 .25] fixed', bs=0.5)
print(a.bs, a.log2)
wprint(a.bs_window_df.loc['exact_discrete', 'note'])
wprint(str(caught[0].message))
```
## Atoms with floating-point noise
Arithmetic that should land on the lattice, such as a conditional mean or a scaled atom, often misses it by a few units in the last place. The tolerance $10^{-12}M$ treats the error as noise, not as a finer lattice: the bucket is the clean one, the residual goes in the note, and the mass of each atom is in one bucket under both `dsev_bucket` schemes.
```{python}
noisy = '0.25000000000000006 0.5000000000000001 1.7499999999999998'
for scheme in ('linear', 'nearest'):
a = build(f'agg N 1 claim dsev [{noisy}] [.25 .5 .25] fixed',
dsev_bucket=scheme)
print(scheme, a.bs, a.log2, 'nonzero buckets', (a.sev_density > 0).sum())
wprint(a.bs_window_df.loc['exact_discrete', 'note'])
```
## Reinsured discrete severities
A layer or a reinsurance cover changes the amounts the grid must hold, so the lattice step is taken over every view of the severity. The atoms 1000, 2000, and 3000 net of an occurrence layer `1500 xs 250` are 250, 500, and 1500, and the ceded amounts are 750 and 1500. The step of all these amounts is 250, whose largest nice divisor is 50. A share that leaves the dyadic lattice, such as `30% po` of integer atoms, has no step, and the standard sizing methods select the bucket.
```{python}
a = build('agg N 1 claim dsev [1000 2000 3000] occurrence net of 1500 xs 250 fixed')
p = a.density_df.p_total
print(a._severity_lattice(), a.bs, a.log2, p[p > 1e-12].round(4).to_dict())
```
A reinsured aggregate is sized from 0 and does not use a window, because a ceded amount can fall below the window. The next aggregate has a gross mean of 27 and cedes 2 on every outcome.
```{python}
a = build('agg A 3 claims dsev [8 10] fixed aggregate ceded to 2 xs 4')
p = a.density_df.p_total
print(a.bs, a.log2, a.x_min, p[p > 1e-12].round(4).to_dict())
```
## A lattice too fine for the log2 cap
An exact grid exists for every lattice, but it may not fit the requested `log2`. The aggregate below has support up to 100,100, which needs `log2` 17 at the step 1. At the default cap of 16 it builds on bucket 2, with the atom at 1001 between grid points. The build completes, because an aggregate is often one unit of something larger. The warning `LatticeGridWarning`^[From aggregate 1.48.0] gives the `log2` to pass, and the verdict contains the same numbers. The kind `nttagg` ([Using `nttagg`](nttagg.qmd)) raises an error in the same position.
```{python}
program = 'agg AWK dfreq [1:100] dsev [1000 1001]'
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
a = build(program)
print(a.bs, a.log2)
wprint(str(caught[0].message))
wprint(a.validation_description)
a = build(program, log2=17)
print(a.bs, a.log2, a.validation_description)
```
## Lattice buckets for a Portfolio
A `Portfolio` whose units are all discrete takes the step of their pooled atoms, and the rule selects the bucket once, against the window of the `Portfolio`. Its grid must hold the largest claim of any unit, it does not use a window when a unit is reinsured, and it warns as an `Aggregate` does when the lattice does not fit. Poisson units on steps 2 and 5 pool to step 1, so every atom of both units is a whole number of buckets from the origin.
```{python}
p = build('port Steps agg S2 3 claims dsev [2 4 6] poisson '
'agg S5 3 claims dsev [5 10 15] poisson')
x0 = float(p.density_df.index[0])
print(p.bs, p.log2, (np.array([2, 4, 6, 5, 10, 15]) - x0) / p.bs)
```
A `Portfolio` of one unit takes the grid of that unit, so the two objects hold the same law on the same grid.
```{python}
a = build('agg A dfreq [1] dsev [3]')
p = build('port P agg A dfreq [1] dsev [3]')
print(a.bs, a.log2, a.x_min, '|', p.bs, p.log2, float(p.density_df.index[0]))
```
A signed `Portfolio` of units on quarter points gets the bucket 0.25, and `log2` shrinks to the 8 it needs. Every atom of every unit is again a whole number of buckets from the origin of the grid, $-32.75$.
```{python}
p = build('port Signed '
'agg A 3 claims dsev [-1.75 0.25 0.5] [.25 .5 .25] poisson '
'agg B 2 claims dsev [-0.5 0.75] poisson '
'agg C 1 claim dsev [0.25 1.5] fixed')
x0 = float(p.density_df.index[0])
atoms = np.array([-1.75, 0.25, 0.5, -0.5, 0.75, 1.5])
print(p.bs, p.log2, x0, (atoms - x0) / p.bs)
wprint(p.bs_explanation)
```
## A Portfolio of many signed units
The grid of a signed `Portfolio` starts at the sum of the origins of its units, which with many units is far below the moment window. Two hundred units on the atoms $-1.75$, 0.25, and 0.5 start at $-350$, while the window starts near $-136$. A `log2` measured from the window would be 10, putting the grid on $[-350, -94)$, below most of the mass. The sizer measures the need from the realized origin instead and takes `log2` 11, so the `Portfolio` keeps all of its mass and its exact mean, $-37.5$. A `Portfolio` of many signed units with no lattice can still lose mass in this way.
```{python}
aggs = [Aggregate(name=f'U{i}', exp_en=1, freq_name='fixed',
sev_name='dhistogram', sev_xs=[-1.75, 0.25, 0.5],
sev_ps=[.25, .5, .25]) for i in range(200)]
p = Portfolio('Many', aggs)
p.update(log2=16, bs=0)
s = p.density_df.p_total
print(p.bs, p.log2, float(p.density_df.index[0]))
print(f'mass {s.sum():.15f}, mean {(s * s.index).sum():.10f}, exact {-0.1875 * 200}')
```
## Bivariate axes on and off a lattice
A discrete bivariate axis on a lattice takes the bucket of the rule, and the given joint matrix lands at exact indices. An axis off any lattice is sized freely, like a copula axis, and the matrix is spread over it with the units' `dsev_bucket` scheme. The margins of the bivariate severity, the row and column sums of the matrix `S` below, still equal the marginal severities `g0` and `g1`. The axes `[0 0.1 0.2]` and `[0 0.1 0.3000001]` have no lattice and get 1,024 buckets of $2^{-11}$ and $2^{-10}$.
```{python}
for axis in ['[0 1 2]', '[0.25 0.75]', '[0 0.1 0.2]', '[0 0.1 0.3000001]']:
b = build(f'bivariate BV dfreq [1 2] [.5 .5] dbvsev {axis} [0 1 2]')
S = np.asarray(b._S)
g0, g1 = b._gs
margins = (np.allclose(S.sum(axis=1), g0, rtol=0, atol=1e-14)
and np.allclose(S.sum(axis=0), g1, rtol=0, atol=1e-14))
wprint(f'{axis:20s} bs {b.bs}, on lattice {b._on_lattice}, '
f'grid {[len(x) for x in b.axis_xs]}, margins match {margins}')
```
## Probabilities of a discrete severity that do not sum to 1
The probabilities of a `dsev` clause must sum to 1. A sum within a relative $10^{-5}$ of 1 is normalized when the severity is constructed, so probabilities printed to six digits give the moments of the law they round. The first line below shows the standard deviation and skewness of 100 claims on the atoms 999, 1000, and 1001 with such probabilities. A sum outside that tolerance is refused.
```{python}
a = build('agg R 100 claims dsev [999 1000 1001] [0.333333 0.222222 0.444444] fixed')
print(f'{a.actual_sd:.4f} {a.actual_skew:.4f}')
refuse('agg Q 2 claims dsev [1 2] [0.3 0.3] fixed')
```