The sample workspace¶
Create the sample workspace, on the workbench's welcome screen (File › New Workspace brings it up), writes a
sample into a new folder, Community Lab in your Documents folder unless you choose another place: four provinces,
two experiments, four scripts and a mortality table, every one of them runnable as it stands. Every file is
generated from the engine's own templates, presets and defaults, so the sample cannot describe a parameter, a
template or a table the engine does not have, and the test suite checks every province and experiment file against
the engine and compiles every script.
| File | What it is |
|---|---|
README.md |
What is in the workspace and how to run it. |
scenarios/baseline.province.json |
The default province. |
scenarios/ageing.province.json |
An older age profile, a TFR of 1.6 and faster mortality improvement. |
scenarios/pandemic.province.json |
The default province living through a year of pandemic mortality from its second year. |
scenarios/stressed-basis.province.json |
The default province living on bases/stressed-sa-2024.csv. |
experiments/sam-life-stresses.experiment.json |
SAM's life underwriting stresses on the burial society, lived through: mortality +15% at every age, and a catastrophe month. |
experiments/old-age-grant.experiment.json |
A 20% higher older persons grant: what it costs and what it buys. |
scripts/first-look.js |
Two simulated years and a look round: the indicators, who lives where, the last births and deaths. |
scripts/ae-by-age.js |
A/E by ten-year band over five years, a plot, and the table saved as CSV. |
scripts/pooled-experience.js |
Sixteen seeds pooled on the worker pool: crude rates against the basis. |
scripts/premium-check.js |
The funeral premium cut and raised by a fifth, on six seeds. |
bases/stressed-sa-2024.csv |
The sa-2024 preset, 15% heavier at every age. |
The files follow, exactly as the sample writes them.
The provinces¶
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
"title": "The default province",
"notes": "Unity Province on the default basis: published South African mortality (sa-2024) and fertility, the 2026/27 tax tables, a repo rate of 6.75%.",
"seed": "agincourt-12",
"basis": {}
}
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
"title": "An ageing province",
"notes": "An older age profile, fertility well below replacement and faster mortality improvement: more pensioners, fewer workers, a heavier grant bill.",
"seed": "ageing-1",
"basis": {
"ageProfile": "ageing",
"tfr": 1.6,
"mortalityImprovement": 0.015
}
}
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
"title": "A pandemic year",
"notes": "The default province, living through a year of pandemic mortality from its second year: every age a quarter heavier, and those of sixty and over a further 44%.",
"seed": "agincourt-12",
"basis": {},
"shocks": [
{
"kind": "mortality",
"label": "Pandemic: all ages ×1.25",
"fromMonth": 12,
"months": 12,
"factor": 1.25
},
{
"kind": "mortality",
"label": "Pandemic: 60 and over a further ×1.44",
"fromMonth": 12,
"months": 12,
"factor": 1.44,
"minAge": 60
}
]
}
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/province.json",
"title": "Living on a supplied table",
"notes": "The province lives on bases/stressed-sa-2024.csv instead of its preset: every death channel follows the table, and A/E is measured against it.",
"seed": "agincourt-12",
"basis": {},
"mortality": "bases/stressed-sa-2024.csv"
}
See Province files for every field.
The experiments¶
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/experiment.json",
"notes": "SAM life underwriting stresses, lived through: mortality +15% at every age for the whole run, and a catastrophe month of 1.5 extra deaths per 1,000 lives.",
"title": "SAM life stresses on the burial society",
"question": "Does the burial society survive the standard formula's life stresses, lived through rather than assumed?",
"audience": "actuarial",
"arms": [
{
"id": "mortality-15",
"label": "Mortality +15% on every age",
"shocks": [
{
"kind": "mortality",
"label": "SAM mortality stress +15%",
"fromMonth": 0,
"months": 120,
"factor": 1.15
}
]
},
{
"id": "catastrophe",
"label": "Catastrophe: +1.5‰ of lives in one month",
"shocks": [
{
"kind": "mortality",
"label": "SAM catastrophe month",
"fromMonth": 12,
"months": 1,
"factor": 2.8
}
]
}
],
"seeds": 8,
"years": 10
}
{
"$schema": "https://intelligentactuaries.com/schemas/community-lab/experiment.json",
"notes": "A 20% higher older persons grant: what it costs the fiscus, and what it buys in poverty and health.",
"title": "Raise the old-age grant",
"question": "What does a 20% higher older persons grant cost the fiscus, and what does it buy in poverty and health?",
"audience": "government",
"arms": [
{
"id": "grant-up",
"label": "Old-age grant +20% (R2,880)",
"params": {
"oldAgeGrant": 2880
}
}
],
"seeds": 8,
"years": 10
}
See Experiment files for every field and how the result is read.
The scripts¶
// A first look: build the province, live two years, and read it.
// Run it with Ctrl+Enter (Cmd+Enter on a Mac). Two simulated years take about ten seconds.
const p = await province({ seed: 'first-look' });
print(`${p.basis.regionName} Province on ${p.date}: ${p.people().length} residents in ${p.households().length} households. Basis ${p.basisHash}.`);
p.run({ years: 2 });
print(`Two years on, ${p.date}:`);
const ind = p.indicators();
const show = ['population', 'deaths_per_1000', 'ae', 'e0', 'scheme_reserve', 'poverty', 'unemployment', 'gini'];
table(METRICS.filter((m) => show.includes(m.id)).map((m) => ({ indicator: m.label, value: ind[m.id], unit: m.unit })));
// Who lives where
const byCity = {};
for (const person of p.people()) byCity[person.city] = (byCity[person.city] ?? 0) + 1;
table(Object.entries(byCity).map(([city, residents]) => ({ city, residents })));
// What happened: the last few births and deaths in the ledger
table(p.events().filter((e) => e.kind === 'birth' || e.kind === 'death').slice(-8));
// Actual against expected deaths by age band, on the basis the province was built on.
// Five simulated years, about twenty seconds. One province is about 400 people, so the
// bands are noisy: scripts/pooled-experience.js pools seeds on the worker pool instead.
const p = await province({ seed: 'ae-by-age' });
p.run({ years: 5 });
const bands = {};
for (const r of p.experience({ ageWidth: 10 })) {
const b = (bands[r.age] ??= { band: `${r.age}-${r.age + 9}`, person_years: 0, deaths: 0, expected: 0 });
b.person_years += r.person_years;
b.deaths += r.deaths;
b.expected += r.expected_deaths;
}
const rows = Object.values(bands).map((b) => ({ ...b, ae: b.expected > 0 ? b.deaths / b.expected : null }));
table(rows, ['band', 'person_years', 'deaths', 'expected', 'ae']);
plot({ x: rows.map((b) => b.band), series: { 'A/E': rows.map((b) => b.ae) }, title: 'Actual / expected deaths by age band', yLabel: 'A/E' });
const D = rows.reduce((a, b) => a + b.deaths, 0);
const E = rows.reduce((a, b) => a + b.expected, 0);
print(`Pooled A/E ${(D / E).toFixed(3)}: ${D} deaths against ${E.toFixed(1)} expected.`);
await writeFile('results/ae-by-age.csv', csv(rows));
print('Saved results/ae-by-age.csv');
// Experience pooled over seeds: the same basis lived 16 times on the worker pool,
// enough deaths for a period table. About three minutes with eight workers.
const exp = await pooledExperience({ seeds: 16, years: 10, ageWidth: 5 });
print(exp.headline.map((h) => `${h.label}: ${h.value}`).join(' · '));
// Crude central rates against the true basis, men, by five-year band
const cells = {};
for (const r of exp.rows.filter((r) => r.sex === 'M')) {
const c = (cells[r.age] ??= { age: r.age, deaths: 0, person_years: 0, expected: 0 });
c.deaths += r.deaths;
c.person_years += r.person_years;
c.expected += r.expected_deaths;
}
const rows = Object.values(cells).filter((c) => c.person_years > 0).map((c) => ({
age: c.age,
m_actual: c.deaths / c.person_years,
m_basis: c.expected / c.person_years,
deaths: c.deaths,
}));
plot({ x: rows.map((r) => r.age), series: { experience: rows.map((r) => r.m_actual || null), basis: rows.map((r) => r.m_basis) }, title: 'Men: crude death rates against the basis', yLabel: 'deaths per person-year', log: true });
await writeFile('results/pooled-experience.csv', csv(exp.rows));
print(`Saved ${exp.rows.length} cells to results/pooled-experience.csv, with the true basis in the export's provenance.`);
// Is the funeral premium adequate? The lab's template, run on the worker pool:
// the baseline and two arms (the premium cut and raised by a fifth) on the same
// 6 seeds for 8 years. About two minutes with eight workers.
const spec = template('premium-adequacy', 8);
spec.seeds = 6;
const r = await experiment(spec);
const m = (id) => METRICS.find((x) => x.id === id);
table(
r.arms.slice(1).flatMap((arm) =>
['scheme_reserve', 'scheme_ruin', 'loss_ratio'].map((id) => ({
arm: arm.label,
indicator: m(id).label,
baseline: r.arms[0].metrics[id].mean,
effect: arm.effects[id].mean,
lo95: arm.effects[id].lo,
hi95: arm.effects[id].hi,
})),
),
);
print('An interval that does not cross zero is an effect these seeds can tell from the province\'s own randomness.');
See the Script API for every function they use.
The mortality table¶
bases/stressed-sa-2024.csv is the sa-2024 preset with every q multiplied by 1.15, by single age and sex. Its
first rows:
age,qx_m,qx_f
0,0.028326,0.024522
1,0.001885,0.003986
2,0.000345,0.001037
3,0.000209,0.000352
4,0.000206,0.000151
5,0.000221,0.000084
6,0.000240,0.000062
See Mortality tables for the format.