ibnr 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. ibnr/__init__.py +7 -0
  2. ibnr/data/__init__.py +3 -0
  3. ibnr/data/schedule_p.py +297 -0
  4. ibnr/gallery/__init__.py +29 -0
  5. ibnr/gallery/bayesian/__init__.py +0 -0
  6. ibnr/gallery/bayesian/_toolchain.py +70 -0
  7. ibnr/gallery/bayesian/clark_growth_curve/__init__.py +3 -0
  8. ibnr/gallery/bayesian/clark_growth_curve/card.md +70 -0
  9. ibnr/gallery/bayesian/clark_growth_curve/model.py +279 -0
  10. ibnr/gallery/bayesian/clark_growth_curve/model.stan +69 -0
  11. ibnr/gallery/bayesian/compartmental/__init__.py +3 -0
  12. ibnr/gallery/bayesian/compartmental/card.md +169 -0
  13. ibnr/gallery/bayesian/compartmental/model.py +502 -0
  14. ibnr/gallery/bayesian/compartmental/model.stan +160 -0
  15. ibnr/gallery/bayesian/compartmental/model_lognormal.stan +184 -0
  16. ibnr/gallery/bayesian/england_verrall_odp/__init__.py +3 -0
  17. ibnr/gallery/bayesian/england_verrall_odp/card.md +99 -0
  18. ibnr/gallery/bayesian/england_verrall_odp/model.py +294 -0
  19. ibnr/gallery/bayesian/england_verrall_odp/model.stan +67 -0
  20. ibnr/gallery/bayesian/meyers_ccl/__init__.py +3 -0
  21. ibnr/gallery/bayesian/meyers_ccl/card.md +232 -0
  22. ibnr/gallery/bayesian/meyers_ccl/model.py +390 -0
  23. ibnr/gallery/bayesian/meyers_ccl/model.stan +76 -0
  24. ibnr/gallery/bayesian/meyers_ccl/model_numpyro.py +140 -0
  25. ibnr/gallery/bayesian/meyers_ccl/model_pymc.py +139 -0
  26. ibnr/gallery/bayesian/meyers_csr/__init__.py +3 -0
  27. ibnr/gallery/bayesian/meyers_csr/card.md +109 -0
  28. ibnr/gallery/bayesian/meyers_csr/model.py +247 -0
  29. ibnr/gallery/bayesian/meyers_csr/model.stan +76 -0
  30. ibnr/gallery/entry.py +56 -0
  31. ibnr/gallery/nn/__init__.py +2 -0
  32. ibnr/gallery/nn/transformer/__init__.py +4 -0
  33. ibnr/gallery/nn/transformer/card.md +138 -0
  34. ibnr/gallery/nn/transformer/config.py +50 -0
  35. ibnr/gallery/nn/transformer/model.py +451 -0
  36. ibnr/gallery/nn/transformer/network.py +212 -0
  37. ibnr/gallery/nn/transformer_ml/__init__.py +4 -0
  38. ibnr/gallery/nn/transformer_ml/card.md +73 -0
  39. ibnr/gallery/nn/transformer_ml/config.py +60 -0
  40. ibnr/gallery/nn/transformer_ml/model.py +448 -0
  41. ibnr/gallery/nn/transformer_ml/network.py +284 -0
  42. ibnr/gallery/registry.py +42 -0
  43. ibnr/gallery/statistical/__init__.py +4 -0
  44. ibnr/gallery/statistical/clark/__init__.py +3 -0
  45. ibnr/gallery/statistical/clark/card.md +96 -0
  46. ibnr/gallery/statistical/clark/model.py +291 -0
  47. ibnr/gallery/statistical/copula_glm/__init__.py +3 -0
  48. ibnr/gallery/statistical/copula_glm/card.md +77 -0
  49. ibnr/gallery/statistical/copula_glm/model.py +344 -0
  50. ibnr/gallery/statistical/sur/__init__.py +3 -0
  51. ibnr/gallery/statistical/sur/card.md +84 -0
  52. ibnr/gallery/statistical/sur/model.py +366 -0
  53. ibnr/kernels/__init__.py +4 -0
  54. ibnr/kernels/calibration.py +71 -0
  55. ibnr/kernels/contract.py +370 -0
  56. ibnr/kernels/harness.py +332 -0
  57. ibnr/kernels/multiline.py +351 -0
  58. ibnr/kernels/nn_contract.py +441 -0
  59. ibnr/kernels/parity.py +161 -0
  60. ibnr/kernels/predictive.py +105 -0
  61. ibnr/kernels/scores.py +39 -0
  62. ibnr/triangle/__init__.py +3 -0
  63. ibnr/triangle/core.py +239 -0
  64. ibnr/triangle/io.py +340 -0
  65. ibnr/triangle/transforms.py +189 -0
  66. ibnr/triangle/validate.py +69 -0
  67. ibnr-0.2.0.dist-info/METADATA +224 -0
  68. ibnr-0.2.0.dist-info/RECORD +70 -0
  69. ibnr-0.2.0.dist-info/WHEEL +4 -0
  70. ibnr-0.2.0.dist-info/licenses/LICENSE +373 -0
ibnr/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """ibnr: gallery-centric probabilistic loss reserving on a long-format triangle layer."""
2
+
3
+ from ibnr.triangle import Triangle, TriangleMeta
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["Triangle", "TriangleMeta", "__version__"]
ibnr/data/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ibnr.data.schedule_p import load_schedule_p
2
+
3
+ __all__ = ["load_schedule_p"]
@@ -0,0 +1,297 @@
1
+ """Adapter for the CAS Schedule P gold mart (cas-schedule-p-data-model repo).
2
+
3
+ The data pipeline lives in that repo; this package only consumes its published
4
+ gold mart. The mart of record is ``mart_reserving_model_training``: long-format
5
+ loss observations keyed by company x line_of_business x accident_year x
6
+ development_age x statement_year. Schema (verified 2026-06-12 rebuild):
7
+
8
+ company_code, company_name, line_of_business, statement_year, accident_year,
9
+ development_age (years, 1-10), calendar_age, incurred_loss, cum_paid_loss,
10
+ bulk_loss, earned_prem_net, earned_prem_direct, case_reserve, loss_ratio,
11
+ paid_to_incurred_ratio, ata_factor_into_this_age, publish_id
12
+
13
+ Note: the mart's ``incurred_loss`` is gross of bulk+IBNR and its
14
+ ``case_reserve`` is incurred - paid (so it also contains bulk). The adapter
15
+ derives ``reported_loss`` = incurred_loss - bulk_loss (paid + true case),
16
+ which is what Meyers' monograph calls "incurred".
17
+
18
+ Two ways to point this adapter at data (either directly or via the
19
+ IBNR_SCHEDULE_P_WAREHOUSE environment variable; when neither is given the
20
+ default is ``DEFAULT_SOURCE`` — the newest GitHub release, ``@latest``
21
+ resolved to its concrete publish_id up front):
22
+
23
+ 1. a local warehouse directory — parquet under ``warehouse/`` with the active
24
+ publish chosen by ``warehouse/_active_manifest.json`` (the sibling checkout
25
+ of cas-schedule-p-data-model);
26
+ 2. a GitHub release spec ``github://<owner>/<repo>@<publish_id>`` — the data
27
+ repo publishes each gold promote as a release tagged with its publish_id,
28
+ carrying every gold table plus a ``manifest.json`` (asset name, sha256,
29
+ bytes per table). Assets are downloaded once via the ``gh`` CLI (which
30
+ supplies auth for the private repo) into a local cache
31
+ (``~/.cache/ibnr`` or IBNR_CACHE_DIR), sha256-verified, then read locally.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import shutil
40
+ import subprocess
41
+ from pathlib import Path
42
+
43
+ import ibis
44
+
45
+ from ibnr.triangle.core import Triangle
46
+ from ibnr.triangle.io import from_long, resolve_backend
47
+
48
+ ENV_VAR = "IBNR_SCHEDULE_P_WAREHOUSE"
49
+ CACHE_ENV_VAR = "IBNR_CACHE_DIR"
50
+ GITHUB_SCHEME = "github://"
51
+ TRAINING_MART = "mart_reserving_model_training"
52
+
53
+ #: where the data comes from when nothing else is specified: the newest gold
54
+ #: publish on GitHub. ``@latest`` resolves to a concrete publish_id through gh
55
+ #: before anything is cached or read, so results artifacts always stamp the
56
+ #: exact publish even in default-configured dev.
57
+ DEFAULT_SOURCE = "github://EKtheSage/cas-schedule-p-data-model@latest"
58
+ LATEST = "latest"
59
+
60
+ #: mart column -> triangle field name. Loss fields are cumulative; premiums,
61
+ #: reserves and bulk are eval-date snapshots carried along as fields.
62
+ DEFAULT_FIELDS = {
63
+ "cum_paid_loss": "paid_loss",
64
+ "incurred_loss": "incurred_loss", # gross of bulk+IBNR
65
+ "bulk_loss": "bulk_loss",
66
+ "case_reserve": "case_reserve",
67
+ "earned_prem_net": "earned_premium",
68
+ "earned_prem_direct": "earned_premium_direct",
69
+ }
70
+
71
+ #: derived field: triangle field name -> ibis expression over the mart table
72
+ DERIVED_FIELDS = {
73
+ # Meyers' "incurred": net of bulk+IBNR, i.e. paid + case reserves
74
+ "reported_loss": lambda t: t.incurred_loss - t.bulk_loss,
75
+ }
76
+
77
+ SEGMENTS = ["company_code", "company_name", "line_of_business"]
78
+
79
+
80
+ def active_mart_path(warehouse: str | Path | None = None, mart: str = TRAINING_MART) -> Path:
81
+ """Resolve a mart's parquet path — from a local warehouse's active-publish
82
+ manifest, or from a cached (downloading if needed) GitHub release when
83
+ ``warehouse`` is a ``github://owner/repo@publish_id`` spec."""
84
+ source = _resolve_source(warehouse)
85
+ if _is_github_spec(source):
86
+ repo, tag = _parse_github_spec(str(source))
87
+ return _release_asset(repo, tag, mart)
88
+ warehouse = Path(source)
89
+ manifest = json.loads((warehouse / "_active_manifest.json").read_text())
90
+ rel = Path(manifest["tables"][mart])
91
+ # manifest paths are relative to the data-model repo root ("warehouse\...")
92
+ return warehouse.parent / rel
93
+
94
+
95
+ def active_publish_id(warehouse: str | Path | None = None) -> str:
96
+ """The gold publish's version stamp — stamp this into every results
97
+ artifact so figures trace back to an exact data publish."""
98
+ source = _resolve_source(warehouse)
99
+ if _is_github_spec(source):
100
+ repo, tag = _parse_github_spec(str(source))
101
+ manifest, _ = _release_manifest(repo, tag)
102
+ return str(manifest["publish_id"])
103
+ warehouse = Path(source)
104
+ manifest = json.loads((warehouse / "_active_manifest.json").read_text())
105
+ return str(manifest["publish_id"])
106
+
107
+
108
+ def _resolve_source(warehouse: str | Path | None) -> str | Path:
109
+ """Explicit argument > env var > the GitHub data repo's newest publish."""
110
+ if warehouse is None:
111
+ warehouse = os.environ.get(ENV_VAR) or DEFAULT_SOURCE
112
+ return warehouse
113
+
114
+
115
+ def pinned_source(warehouse: str | Path | None = None, mart: str = TRAINING_MART) -> str:
116
+ """Resolve a warehouse argument to a CONCRETE, worker-safe source string.
117
+
118
+ For a GitHub spec, ``@latest`` is pinned to its publish_id and the mart
119
+ asset is downloaded into the cache up front. The parallel harness hands
120
+ THIS string to its workers, so they never re-resolve ``@latest`` (a race
121
+ against a release published mid-run would split the study across two data
122
+ versions) and never call gh concurrently — they only read the local cache.
123
+ Local warehouse paths pass through unchanged.
124
+ """
125
+ source = _resolve_source(warehouse)
126
+ if _is_github_spec(source):
127
+ repo, tag = _parse_github_spec(str(source))
128
+ _release_asset(repo, tag, mart) # warm the cache before workers spawn
129
+ return f"{GITHUB_SCHEME}{repo}@{tag}"
130
+ return str(source)
131
+
132
+
133
+ # -- GitHub release consumption ---------------------------------------------------
134
+ #
135
+ # The data repo publishes each gold promote as an immutable release tagged with
136
+ # its publish_id; assets are the gold tables plus manifest.json. We download
137
+ # through the gh CLI (it carries auth for the private repo), cache per
138
+ # (repo, publish_id), and verify sha256 against the manifest.
139
+
140
+
141
+ def _is_github_spec(source: str | Path) -> bool:
142
+ return isinstance(source, str) and source.startswith(GITHUB_SCHEME)
143
+
144
+
145
+ def _parse_github_spec(spec: str) -> tuple[str, str]:
146
+ """``github://owner/repo@publish_id`` -> (``owner/repo``, ``publish_id``).
147
+
148
+ ``@latest`` is resolved to the repo's newest release tag (one gh call,
149
+ cached per process) so caching and provenance always see a concrete id.
150
+ """
151
+ body = spec[len(GITHUB_SCHEME) :]
152
+ repo, _, tag = body.partition("@")
153
+ if not tag or repo.count("/") != 1 or not all(repo.split("/")):
154
+ raise ValueError(
155
+ f"bad GitHub release spec {spec!r}; expected {GITHUB_SCHEME}owner/repo@publish_id"
156
+ )
157
+ if tag == LATEST:
158
+ tag = _latest_tag(repo)
159
+ return repo, tag
160
+
161
+
162
+ _LATEST_TAGS: dict[str, str] = {}
163
+
164
+
165
+ def _latest_tag(repo: str) -> str:
166
+ if repo not in _LATEST_TAGS:
167
+ if shutil.which("gh") is None:
168
+ raise RuntimeError(
169
+ f"resolving @{LATEST} for {repo} needs the GitHub CLI (gh); "
170
+ "install it and run `gh auth login`, or pin a publish_id"
171
+ )
172
+ result = subprocess.run(
173
+ ["gh", "release", "view", "--repo", repo, "--json", "tagName", "--jq", ".tagName"],
174
+ capture_output=True,
175
+ text=True,
176
+ )
177
+ if result.returncode != 0 or not result.stdout.strip():
178
+ raise RuntimeError(
179
+ f"could not resolve the latest release of {repo}: "
180
+ f"{result.stderr.strip() or result.stdout.strip()}"
181
+ )
182
+ _LATEST_TAGS[repo] = result.stdout.strip()
183
+ return _LATEST_TAGS[repo]
184
+
185
+
186
+ def _cache_dir(repo: str, tag: str) -> Path:
187
+ root = os.environ.get(CACHE_ENV_VAR)
188
+ root = Path(root) if root else Path.home() / ".cache" / "ibnr"
189
+ return root / repo.replace("/", "__") / tag
190
+
191
+
192
+ def _gh_download(repo: str, tag: str, pattern: str, dest: Path) -> None:
193
+ if shutil.which("gh") is None:
194
+ raise RuntimeError(
195
+ "the GitHub CLI (gh) is required to fetch data releases from the "
196
+ f"private {repo} repo; install it and run `gh auth login`"
197
+ )
198
+ dest.mkdir(parents=True, exist_ok=True)
199
+ result = subprocess.run(
200
+ [
201
+ "gh",
202
+ "release",
203
+ "download",
204
+ tag,
205
+ "--repo",
206
+ repo,
207
+ "--pattern",
208
+ pattern,
209
+ "--dir",
210
+ str(dest),
211
+ "--clobber",
212
+ ],
213
+ capture_output=True,
214
+ text=True,
215
+ )
216
+ if result.returncode != 0:
217
+ raise RuntimeError(
218
+ f"gh release download failed for {repo}@{tag} ({pattern}): "
219
+ f"{result.stderr.strip() or result.stdout.strip()}"
220
+ )
221
+
222
+
223
+ def _release_manifest(repo: str, tag: str) -> tuple[dict, Path]:
224
+ cache = _cache_dir(repo, tag)
225
+ path = cache / "manifest.json"
226
+ if not path.exists():
227
+ _gh_download(repo, tag, "manifest.json", cache)
228
+ manifest = json.loads(path.read_text())
229
+ if str(manifest["publish_id"]) != tag:
230
+ raise ValueError(
231
+ f"release {repo}@{tag} carries manifest for publish "
232
+ f"{manifest['publish_id']!r} — publishes are immutable, refusing to mix"
233
+ )
234
+ return manifest, cache
235
+
236
+
237
+ def _release_asset(repo: str, tag: str, table: str) -> Path:
238
+ manifest, cache = _release_manifest(repo, tag)
239
+ try:
240
+ entry = manifest["tables"][table]
241
+ except KeyError:
242
+ raise KeyError(
243
+ f"release {repo}@{tag} has no table {table!r}; available: {sorted(manifest['tables'])}"
244
+ ) from None
245
+ path = cache / entry["asset"]
246
+ if not path.exists() or path.stat().st_size != int(entry["bytes"]):
247
+ _gh_download(repo, tag, entry["asset"], cache)
248
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
249
+ if digest != entry["sha256"]:
250
+ path.unlink(missing_ok=True)
251
+ raise RuntimeError(
252
+ f"sha256 mismatch for {entry['asset']} from {repo}@{tag}: "
253
+ f"got {digest}, manifest says {entry['sha256']}"
254
+ )
255
+ return path
256
+
257
+
258
+ def load_schedule_p(
259
+ warehouse: str | Path | None = None,
260
+ *,
261
+ lines: list[str] | None = None,
262
+ companies: list[str] | None = None,
263
+ fields: dict[str, str] | None = None,
264
+ derived: dict | None = None,
265
+ backend: str | None = None,
266
+ ) -> Triangle:
267
+ """Load the reserving training mart as a Triangle.
268
+
269
+ Mapping: origin_period = Jan 1 of accident_year; dev_lag = development_age
270
+ in months; eval_date = Dec 31 of statement_year (annual statement date).
271
+ Values are USD thousands. ``lines``/``companies`` filter line_of_business /
272
+ company_code before materializing.
273
+ """
274
+ fields = fields or DEFAULT_FIELDS
275
+ derived = DERIVED_FIELDS if derived is None else derived
276
+ con = resolve_backend(backend)
277
+ t = con.read_parquet(str(active_mart_path(warehouse)))
278
+ if lines:
279
+ t = t.filter(t.line_of_business.isin(lines))
280
+ if companies:
281
+ t = t.filter(t.company_code.isin(companies))
282
+ t = t.select(
283
+ *SEGMENTS,
284
+ origin_period=ibis.date(t.accident_year, 1, 1),
285
+ dev_lag=(t.development_age * 12).cast("int64"),
286
+ eval_date=ibis.date(t.statement_year, 12, 31),
287
+ **{new: t[old] for old, new in fields.items()},
288
+ **{name: expr(t) for name, expr in derived.items()},
289
+ )
290
+ return from_long(
291
+ t,
292
+ fields=[*fields.values(), *derived],
293
+ measure="cumulative",
294
+ origin_grain="Y",
295
+ dev_grain="Y",
296
+ units="USD thousands",
297
+ )
@@ -0,0 +1,29 @@
1
+ """The model gallery. Public API: list(), get(), fit()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+
7
+ from ibnr.gallery import registry as _registry
8
+
9
+ # importing the subpackages self-registers their entries
10
+ from ibnr.gallery import statistical as _statistical # noqa: F401
11
+ from ibnr.gallery.bayesian import clark_growth_curve as _clark_growth_curve # noqa: F401
12
+ from ibnr.gallery.bayesian import compartmental as _compartmental # noqa: F401
13
+ from ibnr.gallery.bayesian import england_verrall_odp as _england_verrall_odp # noqa: F401
14
+ from ibnr.gallery.bayesian import meyers_ccl as _meyers_ccl # noqa: F401
15
+ from ibnr.gallery.bayesian import meyers_csr as _meyers_csr # noqa: F401
16
+ from ibnr.gallery.entry import GalleryEntry
17
+ from ibnr.gallery.nn import transformer as _nn_transformer # noqa: F401
18
+ from ibnr.gallery.nn import transformer_ml as _nn_transformer_ml # noqa: F401
19
+
20
+ get = _registry.get
21
+ fit = _registry.fit
22
+
23
+
24
+ def list() -> builtins.list[str]: # noqa: A001 - mirrors the designed public API
25
+ """Names of all registered gallery entries."""
26
+ return sorted(_registry.entries())
27
+
28
+
29
+ __all__ = ["GalleryEntry", "fit", "get", "list"]
File without changes
@@ -0,0 +1,70 @@
1
+ """Windows C++ toolchain wiring shared by the Bayesian backends.
2
+
3
+ Both cmdstan (via cmdstanpy) and PyMC (via PyTensor) need a C/C++ compiler on
4
+ Windows. Modern RTools installs (43/44/45) ship a plain ``make`` and a g++ under
5
+ ``x86_64-w64-mingw32.static.posix``; these helpers put them on PATH so the
6
+ backends can compile. No-ops on non-Windows or when a toolchain is already set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import platform
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ # RTools roots newest-first; each carries a posix g++ and a usr/bin make.
17
+ _RTOOLS_ROOTS = ("C:/rtools45", "C:/rtools44", "C:/rtools43", "C:/rtools40")
18
+
19
+
20
+ def _rtools_bindirs() -> tuple[Path, Path] | None:
21
+ for root in _RTOOLS_ROOTS:
22
+ gxx = Path(root) / "x86_64-w64-mingw32.static.posix" / "bin"
23
+ mk = Path(root) / "usr" / "bin"
24
+ if gxx.exists() and mk.exists():
25
+ return gxx, mk
26
+ return None
27
+
28
+
29
+ def ensure_stan_toolchain() -> None:
30
+ """Best-effort: put an RTools g++/make on PATH so cmdstan can compile.
31
+
32
+ cmdstanpy assumes mingw32-make/RTools40; modern RTools ship plain ``make``,
33
+ so we also set ``MAKE``.
34
+ """
35
+ if platform.system() != "Windows":
36
+ return
37
+ if shutil.which("g++") and (shutil.which("mingw32-make") or shutil.which("make")):
38
+ os.environ.setdefault("MAKE", "mingw32-make" if shutil.which("mingw32-make") else "make")
39
+ return
40
+ dirs = _rtools_bindirs()
41
+ if dirs is not None:
42
+ gxx, mk = dirs
43
+ os.environ["PATH"] = f"{gxx};{mk};{os.environ['PATH']}"
44
+ os.environ.setdefault("MAKE", "make")
45
+
46
+
47
+ def ensure_pytensor_cxx() -> str | None:
48
+ """Point PyTensor at an RTools g++ so it compiles C ops instead of falling
49
+ back to the (very slow) Python evaluation path.
50
+
51
+ Returns the g++ path configured, or None if nothing was needed/found.
52
+ Idempotent: a no-op once ``pytensor.config.cxx`` is already set.
53
+ """
54
+ if platform.system() != "Windows":
55
+ return None
56
+ import pytensor
57
+
58
+ if pytensor.config.cxx:
59
+ return pytensor.config.cxx
60
+ dirs = _rtools_bindirs()
61
+ if dirs is None:
62
+ return None
63
+ gxx, mk = dirs
64
+ gxx_exe = gxx / "g++.exe"
65
+ if not gxx_exe.exists():
66
+ return None
67
+ if str(gxx) not in os.environ["PATH"]:
68
+ os.environ["PATH"] = f"{gxx};{mk};{os.environ['PATH']}"
69
+ pytensor.config.cxx = str(gxx_exe)
70
+ return str(gxx_exe)
@@ -0,0 +1,3 @@
1
+ from ibnr.gallery.bayesian.clark_growth_curve.model import ClarkGrowthCurve
2
+
3
+ __all__ = ["ClarkGrowthCurve"]
@@ -0,0 +1,70 @@
1
+ # clark_growth_curve — Bayesian Clark (Cape Cod)
2
+
3
+ **Family:** bayesian · **Reference:** Clark, "LDF Curve-Fitting and
4
+ Stochastic Reserving: A Maximum Likelihood Approach", *CAS Forum* (Fall
5
+ 2003) for the likelihood. **There is no published Stan ground truth for a
6
+ Bayesian Clark** — the priors below are this package's specification, and
7
+ the milestone-5 NumPyro/PyMC ports must hold them (and the plug-in phi)
8
+ constant. The statistical `clark` entry is the MLE twin sharing the exact
9
+ likelihood, parameterization, and age convention.
10
+
11
+ ## Model
12
+
13
+ Incremental paid losses:
14
+
15
+ ```
16
+ X[w,d] ~ ODP: E[X] = elr * premium[w] * (G(x_hi) - G(x_lo)), Var[X] = phi * E[X]
17
+ G = loglogistic (default) or weibull, ages from the average accident date
18
+ (x = 12d - 6 for annual grains, first period starts at 0)
19
+ ```
20
+
21
+ Cape Cod form only — one ELR against earned premium, Clark's recommendation
22
+ for triangle-sized data. A free-ultimates (LDF) Bayesian variant is a
23
+ scaffold exercise: eject this entry and swap `logelr` for a vector.
24
+
25
+ Priors:
26
+
27
+ | parameter | prior | rationale |
28
+ |---|---|---|
29
+ | logelr | normal(-0.4, sqrt(10)) | the Meyers-family variance-10 ELR prior |
30
+ | omega | lognormal(log 1.5, 0.5) | curve shape; mass on ~0.6–4 |
31
+ | theta | lognormal(log(4 * grain), 1) | curve scale; median at 4 dev periods, tracks the grain |
32
+
33
+ ## Dispersion phi — plug-in from the MLE twin
34
+
35
+ `fit()` first runs the statistical `clark` entry (same curve, cape_cod) and
36
+ passes its Pearson scale into Stan as data — the same treatment as
37
+ `england_verrall_odp`'s phi. With phi fixed, the ODP quasi-likelihood is a
38
+ proper likelihood for `(logelr, omega, theta)` up to a constant.
39
+
40
+ ## Data contract
41
+
42
+ `kernels.contract.odp_stan_data` (incremental cells, premium required,
43
+ negative increments rejected). The Stan data block additionally carries the
44
+ per-cell `age_lo`/`age_hi` and the curve code — ages are data, not model
45
+ logic, so ports cannot drift on the convention.
46
+
47
+ ## Predictive distribution
48
+
49
+ Posterior draws of `(logelr, omega, theta)` × scaled-Poisson process draws
50
+ `phi * Poisson(mu/phi)` per future cell, truncated at the triangle's final
51
+ age (no tail), paid-to-date anchored — identical mechanics to the MLE twin
52
+ with the posterior replacing the delta-method MVN. Fully-developed origins
53
+ are constant.
54
+
55
+ ## Validation
56
+
57
+ Retrospective Meyers protocol on paid: `scripts/meyers_validation.py
58
+ --model clark_growth_curve`. Results in
59
+ `analysis/results/clark_growth_curve_validation.csv`. No monograph bar
60
+ exists; compare against the paid panel (england_verrall_odp, meyers_csr,
61
+ clark MLE) on identical cohorts.
62
+
63
+ **Result (2026-07-20, loglogistic, 96/200 completed — the rest rejected for
64
+ negative paid increments): combined KS D = 63.4* vs crit 13.9, percentiles
65
+ piled at ~0 — within noise of the MLE twin's 61.3*, confirming the
66
+ posterior tracks the MLE.** The failure is the model, not the inference:
67
+ the post-1997 settlement speedup plus the loglogistic tail mass (see the
68
+ MLE twin's card for the weibull ablation, D = 49.6*). In the gallery this
69
+ entry is the growth-curve baseline for ELPD/stacking; meyers_csr (D = 4.1,
70
+ passes) is what calibrated paid reserving looks like in this window.