From f2972286fe21e9f1c132e568ccc9f8d6b1c8288b Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Sat, 16 May 2026 01:20:24 -0700 Subject: [PATCH 1/4] docs: add workflow doctest examples --- chainladder/workflow/gridsearch.py | 75 +++++++++++++++++++++++++++++- chainladder/workflow/voting.py | 35 ++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/chainladder/workflow/gridsearch.py b/chainladder/workflow/gridsearch.py index 227a84fb..28d74cda 100644 --- a/chainladder/workflow/gridsearch.py +++ b/chainladder/workflow/gridsearch.py @@ -54,6 +54,42 @@ class GridSearch(BaseEstimator): results_: DataFrame A DataFrame with each param_grid key as a column and the ``scoring`` score as the last column + + Examples + -------- + Each row of ``results_`` is one ``ParameterGrid`` draw; changing + ``param_grid`` changes how many fits run and the reported scores. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + clrd = cl.load_sample("clrd") + medmal = clrd.groupby("LOB").sum().loc["medmal"]["CumPaidLoss"] + prem = clrd.groupby("LOB").sum().loc["medmal"]["EarnedPremDIR"].latest_diagonal + pipe = cl.Pipeline( + [("dev", cl.Development()), ("benk", cl.Benktander())] + ) + param_grid = {"benk__n_iters": [1, 4]} + scoring = { + "IBNR": lambda m: float(np.nansum(m.named_steps.benk.ibnr_.values)) + } + grid = cl.GridSearch( + pipe, param_grid, scoring=scoring, n_jobs=1 + ).fit(medmal, benk__sample_weight=prem) + print(len(grid.results_)) + print(int(round(grid.results_["IBNR"].iloc[0], 0))) + print(int(round(grid.results_["IBNR"].iloc[1], 0))) + + .. testoutput:: + + 2 + 1624377 + 1442665 + """ def __init__(self, estimator, param_grid, scoring, verbose=0, @@ -139,7 +175,44 @@ class Pipeline(PipelineSL, EstimatorIO): ---------- named_steps: bunch object, a dictionary with attribute access Read-only attribute to access any step parameter by user given name. - Keys are step names and values are steps parameters.""" + Keys are step names and values are steps parameters. + + Examples + -------- + Hyper-parameters are set with the ``step__param`` naming convention from + scikit-learn. Here ``Development`` averaging changes aggregate IBNR from + the same ``Chainladder`` final step. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + tri = cl.load_sample("raa") + pipe = cl.Pipeline( + [ + ("dev", cl.Development(average="simple")), + ("cl", cl.Chainladder()), + ] + ) + ib_simple = int( + round(float(np.nansum(pipe.fit_predict(tri).ibnr_.values)), 0) + ) + pipe.set_params(dev__average="volume") + ib_volume = int( + round(float(np.nansum(pipe.fit_predict(tri).ibnr_.values)), 0) + ) + print(ib_simple) + print(ib_volume) + + .. testoutput:: + + 93643 + 52135 + + """ def fit(self, X, y=None, sample_weight=None, **fit_params): if sample_weight: diff --git a/chainladder/workflow/voting.py b/chainladder/workflow/voting.py index b1bd8723..0035be99 100644 --- a/chainladder/workflow/voting.py +++ b/chainladder/workflow/voting.py @@ -239,6 +239,41 @@ class VotingChainladder(_BaseChainladderVoting, MethodBase): 1988 23106.943030 1989 20004.502125 1990 21605.832631 + + ``weights`` and ``default_weighting`` change how sub-model ultimates are + blended; skewing weights toward ``Chainladder`` pulls the ensemble away + from ``BornhuetterFerguson`` on late accident years. + + .. testcode:: + + import numpy as np + + raa = cl.load_sample("raa") + cl_ult = cl.Chainladder().fit(raa).ultimate_ + apriori = cl_ult * 0 + (float(cl_ult.sum()) / 10) + estimators = [ + ("bcl", cl.Chainladder()), + ("bf", cl.BornhuetterFerguson(apriori=1.0)), + ] + even = cl.VotingChainladder( + estimators=estimators, + weights=None, + default_weighting=(0.5, 0.5), + ).fit(raa, sample_weight=apriori) + w = np.ones((1, 1, raa.shape[2], 2)) + w[..., 0] = 0.9 + w[..., 1] = 0.1 + skewed = cl.VotingChainladder(estimators=estimators, weights=w).fit( + raa, sample_weight=apriori + ) + print(round(float(even.ultimate_.values[0, 0, -1, 0]), 2)) + print(round(float(skewed.ultimate_.values[0, 0, -1, 0]), 2)) + + .. testoutput:: + + 19694.23 + 18660.8 + """ @_deprecate_positional_args From b3dd40f1705cd68dfc887d47852269e1c59f6994 Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Sat, 16 May 2026 15:52:05 -0700 Subject: [PATCH 2/4] docs: address workflow review feedback --- chainladder/workflow/gridsearch.py | 41 +++++++++++++----------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/chainladder/workflow/gridsearch.py b/chainladder/workflow/gridsearch.py index 28d74cda..a602abf0 100644 --- a/chainladder/workflow/gridsearch.py +++ b/chainladder/workflow/gridsearch.py @@ -57,38 +57,36 @@ class GridSearch(BaseEstimator): Examples -------- - Each row of ``results_`` is one ``ParameterGrid`` draw; changing - ``param_grid`` changes how many fits run and the reported scores. + Use ``GridSearch`` when you want to compare modeling choices with the + same scoring rule. Here the grid compares simple and volume averages by + reading the fitted development ``sigma_`` from each candidate pipeline. .. testsetup:: import chainladder as cl - import numpy as np - .. testcode:: clrd = cl.load_sample("clrd") medmal = clrd.groupby("LOB").sum().loc["medmal"]["CumPaidLoss"] - prem = clrd.groupby("LOB").sum().loc["medmal"]["EarnedPremDIR"].latest_diagonal pipe = cl.Pipeline( - [("dev", cl.Development()), ("benk", cl.Benktander())] + [("dev", cl.Development()), ("cl", cl.Chainladder())] ) - param_grid = {"benk__n_iters": [1, 4]} + param_grid = {"dev__average": ["simple", "volume"]} scoring = { - "IBNR": lambda m: float(np.nansum(m.named_steps.benk.ibnr_.values)) + "sigma": lambda m: float(m.named_steps.dev.sigma_.values.sum()) } grid = cl.GridSearch( pipe, param_grid, scoring=scoring, n_jobs=1 - ).fit(medmal, benk__sample_weight=prem) + ).fit(medmal) print(len(grid.results_)) - print(int(round(grid.results_["IBNR"].iloc[0], 0))) - print(int(round(grid.results_["IBNR"].iloc[1], 0))) + print(round(grid.results_["sigma"].iloc[0], 3)) + print(round(grid.results_["sigma"].iloc[1], 3)) .. testoutput:: 2 - 1624377 - 1442665 + 1.422 + 206.183 """ @@ -179,15 +177,14 @@ class Pipeline(PipelineSL, EstimatorIO): Examples -------- - Hyper-parameters are set with the ``step__param`` naming convention from - scikit-learn. Here ``Development`` averaging changes aggregate IBNR from - the same ``Chainladder`` final step. + Use ``Pipeline`` when the same triangle should pass through several + estimators as one workflow. The ``step__param`` naming convention lets you + change one step, here ``Development.average``, without rebuilding the + whole pipeline. .. testsetup:: import chainladder as cl - import numpy as np - .. testcode:: tri = cl.load_sample("raa") @@ -197,13 +194,9 @@ class Pipeline(PipelineSL, EstimatorIO): ("cl", cl.Chainladder()), ] ) - ib_simple = int( - round(float(np.nansum(pipe.fit_predict(tri).ibnr_.values)), 0) - ) + ib_simple = int(round(float(pipe.fit_predict(tri).ibnr_.sum()), 0)) pipe.set_params(dev__average="volume") - ib_volume = int( - round(float(np.nansum(pipe.fit_predict(tri).ibnr_.values)), 0) - ) + ib_volume = int(round(float(pipe.fit_predict(tri).ibnr_.sum()), 0)) print(ib_simple) print(ib_volume) From b98388e86f61c24bc05fe12b1d129dde95e3fd34 Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Thu, 11 Jun 2026 15:11:50 -0700 Subject: [PATCH 3/4] docs: address workflow example review feedback GridSearch: score the full per-age sigma_ vector instead of a summed sigma so candidates are compared on the underlying arrays, and explain why the simple and volume rows sit on different scales. Pipeline: motivate the estimator by comparing the user guide groupby pipeline against the same pipeline without groupby, showing pooled line-of-business patterns versus unstable standalone company patterns. VotingChainladder: give the first example an actuary-facing narrative, make the second example build on the first by reusing its estimators and apriori, and print the full ultimate vector instead of two scalars. --- chainladder/workflow/gridsearch.py | 89 ++++++++++++++++++++---------- chainladder/workflow/voting.py | 51 +++++++++-------- 2 files changed, 84 insertions(+), 56 deletions(-) diff --git a/chainladder/workflow/gridsearch.py b/chainladder/workflow/gridsearch.py index 871e5b8a..60e0e2d2 100644 --- a/chainladder/workflow/gridsearch.py +++ b/chainladder/workflow/gridsearch.py @@ -57,13 +57,17 @@ class GridSearch(BaseEstimator): Examples -------- - Use ``GridSearch`` when you want to compare modeling choices with the - same scoring rule. Here the grid compares simple and volume averages by - reading the fitted development ``sigma_`` from each candidate pipeline. + Suppose an actuary reserving the industry medical malpractice line wants + to see how the choice between simple and volume-weighted averaging + affects the variability of the fitted development factors. ``GridSearch`` + fits one pipeline per candidate in ``param_grid``, and the ``scoring`` + callables can record any fitted attribute, such as the full vector of + ``sigma_`` by development age. .. testsetup:: import chainladder as cl + .. testcode:: clrd = cl.load_sample("clrd") @@ -72,21 +76,27 @@ class GridSearch(BaseEstimator): [("dev", cl.Development()), ("cl", cl.Chainladder())] ) param_grid = {"dev__average": ["simple", "volume"]} - scoring = { - "sigma": lambda m: float(m.named_steps.dev.sigma_.values.sum()) - } + + def sigma_by_age(model): + sigma = model.named_steps.dev.sigma_ + return sigma.values[0, 0, 0, :].round(3).tolist() + grid = cl.GridSearch( - pipe, param_grid, scoring=scoring, n_jobs=1 + pipe, param_grid, scoring={"sigma": sigma_by_age}, n_jobs=1 ).fit(medmal) - print(len(grid.results_)) - print(round(grid.results_["sigma"].iloc[0], 3)) - print(round(grid.results_["sigma"].iloc[1], 3)) + for _, row in grid.results_.iterrows(): + print(row["dev__average"], row["sigma"]) .. testoutput:: - 2 - 1.422 - 206.183 + simple [1.163, 0.102, 0.057, 0.038, 0.026, 0.016, 0.007, 0.01, 0.003] + volume [116.206, 26.551, 18.805, 15.471, 11.936, 7.286, 3.458, 4.49, 1.978] + + Both candidates agree that development factor variability is concentrated + in the 12-24 age and tapers as the line matures. The two rows are on very + different scales because each averaging choice fits a different weighted + regression, so ``sigma_`` magnitudes are only comparable between + candidates that share an averaging method. """ @@ -178,33 +188,52 @@ class Pipeline(PipelineSL, EstimatorIO): Examples -------- - Use ``Pipeline`` when the same triangle should pass through several - estimators as one workflow. The ``step__param`` naming convention lets you - change one step, here ``Development.average``, without rebuilding the - whole pipeline. + A ``Pipeline`` wraps transformers and a final estimator into one compact + estimator, so an entire reserving analysis is specified by hyperparameters + alone. The user guide builds a pipeline that develops every company in the + CAS loss reserve database with pooled line-of-business patterns through + the ``groupby`` hyperparameter of ``Development``. Comparing it against + the same pipeline without ``groupby`` shows why that pooling matters: + standalone patterns are fit to each company's own thin data. .. testsetup:: import chainladder as cl + import pandas as pd + .. testcode:: - tri = cl.load_sample("raa") - pipe = cl.Pipeline( - [ - ("dev", cl.Development(average="simple")), - ("cl", cl.Chainladder()), - ] + clrd = cl.load_sample("clrd")["CumPaidLoss"] + industry = cl.Pipeline( + [("dev", cl.Development(groupby="LOB")), ("model", cl.Chainladder())] + ) + standalone = cl.Pipeline( + [("dev", cl.Development()), ("model", cl.Chainladder())] ) - ib_simple = int(round(float(pipe.fit_predict(tri).ibnr_.sum()), 0)) - pipe.set_params(dev__average="volume") - ib_volume = int(round(float(pipe.fit_predict(tri).ibnr_.sum()), 0)) - print(ib_simple) - print(ib_volume) + ibnr_industry = industry.fit(clrd).named_steps.model.ibnr_ + ibnr_standalone = standalone.fit(clrd).named_steps.model.ibnr_ + summary = pd.DataFrame( + { + "industry": ibnr_industry.groupby("LOB").sum().sum("origin").to_frame(), + "standalone": ibnr_standalone.groupby("LOB").sum().sum("origin").to_frame(), + } + ).astype(int).rename_axis(None) + print(summary) .. testoutput:: - 93643 - 52135 + industry standalone + comauto 1743192 1683207 + medmal 1330330 1455883 + othliab 1640597 -14285800 + ppauto 17138458 17327738 + prodliab 531648 577127 + wkcomp 2777812 2498151 + + The two pipelines differ in a single hyperparameter, yet the standalone + fit lets a handful of small companies with erratic development drive the + other liability line to a negative aggregate IBNR, while the pooled + patterns keep every line at a reasonable estimate. """ diff --git a/chainladder/workflow/voting.py b/chainladder/workflow/voting.py index 0035be99..0c321e27 100644 --- a/chainladder/workflow/voting.py +++ b/chainladder/workflow/voting.py @@ -205,6 +205,12 @@ class VotingChainladder(_BaseChainladderVoting, MethodBase): Examples -------- + An actuary reserving the RAA excess casualty book leans on the + development-based ``Chainladder`` method for the mature accident years + but shifts toward the more stable exposure-based methods in the recent, + least developed years. The ``weights`` matrix has one row per accident + year and one column per estimator, phasing the blend from ``Chainladder`` + to ``BornhuetterFerguson`` and ``CapeCod``. .. testsetup:: @@ -240,39 +246,32 @@ class VotingChainladder(_BaseChainladderVoting, MethodBase): 1989 20004.502125 1990 21605.832631 - ``weights`` and ``default_weighting`` change how sub-model ultimates are - blended; skewing weights toward ``Chainladder`` pulls the ensemble away - from ``BornhuetterFerguson`` on late accident years. + Building on the example above and reusing its ``estimators``, ``raa`` and + ``apriori``, the actuary can instead blend all three methods in every + accident year. Omitting ``weights`` applies ``default_weighting`` to each + accident year; here ``Chainladder`` receives half of the total weight. .. testcode:: - import numpy as np - - raa = cl.load_sample("raa") - cl_ult = cl.Chainladder().fit(raa).ultimate_ - apriori = cl_ult * 0 + (float(cl_ult.sum()) / 10) - estimators = [ - ("bcl", cl.Chainladder()), - ("bf", cl.BornhuetterFerguson(apriori=1.0)), - ] - even = cl.VotingChainladder( - estimators=estimators, - weights=None, - default_weighting=(0.5, 0.5), - ).fit(raa, sample_weight=apriori) - w = np.ones((1, 1, raa.shape[2], 2)) - w[..., 0] = 0.9 - w[..., 1] = 0.1 - skewed = cl.VotingChainladder(estimators=estimators, weights=w).fit( - raa, sample_weight=apriori + blended = cl.VotingChainladder( + estimators=estimators, default_weighting=(2, 1, 1) ) - print(round(float(even.ultimate_.values[0, 0, -1, 0]), 2)) - print(round(float(skewed.ultimate_.values[0, 0, -1, 0]), 2)) + blended.fit(raa, sample_weight=apriori) + print(blended.ultimate_) .. testoutput:: - 19694.23 - 18660.8 + 2261 + 1981 18834.000000 + 1982 16879.886803 + 1983 24052.325782 + 1984 28502.440672 + 1985 28581.789739 + 1986 19703.210223 + 1987 18348.274023 + 1988 23483.819232 + 1989 17908.906366 + 1990 19849.185129 """ From 9444d2b5db19d7e0bf1d42440185b5c0b91ee580 Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Fri, 12 Jun 2026 00:08:05 -0700 Subject: [PATCH 4/4] docs: rework workflow examples per review feedback - Score GridSearch candidates on full ldf_ vectors instead of sigma_, which are directly comparable across averaging methods - Replace the Pipeline example with a chained development, tail, and IBNR workflow that prints the full origin-by-origin IBNR - Move the groupby vs standalone comparison into the Development docstring examples to showcase the groupby parameter --- chainladder/development/development.py | 37 +++++++++ chainladder/workflow/gridsearch.py | 104 +++++++++++++------------ 2 files changed, 92 insertions(+), 49 deletions(-) diff --git a/chainladder/development/development.py b/chainladder/development/development.py index 6a455bb9..89901dc4 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -281,6 +281,43 @@ class Development(DevelopmentBase): 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 (All) 1.659537 1.35064 1.22277 1.119155 1.079301 1.039863 1.031011 0.997274 0.990571 0.999179 + Finally, the ``groupby`` parameter pools index levels together when + estimating patterns. Suppose an actuary developing every company in the + CAS loss reserve database wants industry line-of-business patterns + rather than patterns fit to each company's own thin data. Comparing the + pooled fit against the standalone fit shows why that pooling matters. + + .. testcode:: + + import pandas as pd + + clrd = cl.load_sample("clrd")["CumPaidLoss"] + industry_dev = cl.Development(groupby="LOB").fit_transform(clrd) + standalone_dev = cl.Development().fit_transform(clrd) + ibnr_industry = cl.Chainladder().fit(industry_dev).ibnr_ + ibnr_standalone = cl.Chainladder().fit(standalone_dev).ibnr_ + summary = pd.DataFrame( + { + "industry": ibnr_industry.groupby("LOB").sum().sum("origin").to_frame(), + "standalone": ibnr_standalone.groupby("LOB").sum().sum("origin").to_frame(), + } + ).astype(int).rename_axis(None) + print(summary) + + .. testoutput:: + + industry standalone + comauto 1743192 1683207 + medmal 1330330 1455883 + othliab 1640597 -14285800 + ppauto 17138458 17327738 + prodliab 531648 577127 + wkcomp 2777812 2498151 + + The two fits differ in a single parameter, yet the standalone fit lets a + handful of small companies with erratic development drive the other + liability line to a negative aggregate IBNR, while the pooled patterns + keep every line at a reasonable estimate. """ diff --git a/chainladder/workflow/gridsearch.py b/chainladder/workflow/gridsearch.py index 60e0e2d2..91ce6486 100644 --- a/chainladder/workflow/gridsearch.py +++ b/chainladder/workflow/gridsearch.py @@ -59,10 +59,10 @@ class GridSearch(BaseEstimator): -------- Suppose an actuary reserving the industry medical malpractice line wants to see how the choice between simple and volume-weighted averaging - affects the variability of the fitted development factors. ``GridSearch`` - fits one pipeline per candidate in ``param_grid``, and the ``scoring`` - callables can record any fitted attribute, such as the full vector of - ``sigma_`` by development age. + affects the fitted development pattern. ``GridSearch`` fits one pipeline + per candidate in ``param_grid``, and the ``scoring`` callables can record + any fitted attribute, such as the full vector of ``ldf_`` by development + age. .. testsetup:: @@ -77,26 +77,28 @@ class GridSearch(BaseEstimator): ) param_grid = {"dev__average": ["simple", "volume"]} - def sigma_by_age(model): - sigma = model.named_steps.dev.sigma_ - return sigma.values[0, 0, 0, :].round(3).tolist() + def ldf_by_age(model): + ldf = model.named_steps.dev.ldf_ + return ldf.values[0, 0, 0, :].round(3).tolist() grid = cl.GridSearch( - pipe, param_grid, scoring={"sigma": sigma_by_age}, n_jobs=1 + pipe, param_grid, scoring={"ldf": ldf_by_age}, n_jobs=1 ).fit(medmal) for _, row in grid.results_.iterrows(): - print(row["dev__average"], row["sigma"]) + print(row["dev__average"], row["ldf"]) .. testoutput:: - simple [1.163, 0.102, 0.057, 0.038, 0.026, 0.016, 0.007, 0.01, 0.003] - volume [116.206, 26.551, 18.805, 15.471, 11.936, 7.286, 3.458, 4.49, 1.978] + simple [6.076, 1.976, 1.384, 1.2, 1.102, 1.068, 1.039, 1.029, 1.018] + volume [5.856, 1.963, 1.376, 1.199, 1.099, 1.067, 1.039, 1.028, 1.018] - Both candidates agree that development factor variability is concentrated - in the 12-24 age and tapers as the line matures. The two rows are on very - different scales because each averaging choice fits a different weighted - regression, so ``sigma_`` magnitudes are only comparable between - candidates that share an averaging method. + Because both rows are loss development factors, they are directly + comparable age by age. Simple averaging gives every origin year an equal + vote, while volume weighting lets the origin years with the most losses + dominate, and for this triangle that distinction matters most at the + immature 12-24 age (6.076 versus 5.856). The two candidates converge as + the line matures, so the averaging choice mainly moves the reserve + carried for the most recent origin years. """ @@ -189,51 +191,55 @@ class Pipeline(PipelineSL, EstimatorIO): Examples -------- A ``Pipeline`` wraps transformers and a final estimator into one compact - estimator, so an entire reserving analysis is specified by hyperparameters - alone. The user guide builds a pipeline that develops every company in the - CAS loss reserve database with pooled line-of-business patterns through - the ``groupby`` hyperparameter of ``Development``. Comparing it against - the same pipeline without ``groupby`` shows why that pooling matters: - standalone patterns are fit to each company's own thin data. + estimator, so an entire reserving workflow is specified in one place. A + classic workflow chains three steps: select development patterns from + the triangle, extrapolate a tail beyond the observed ages, and hand the + completed patterns to an IBNR method. Fitting the pipeline runs every + step in order, and ``named_steps`` exposes each fitted step by name. .. testsetup:: import chainladder as cl - import pandas as pd .. testcode:: - clrd = cl.load_sample("clrd")["CumPaidLoss"] - industry = cl.Pipeline( - [("dev", cl.Development(groupby="LOB")), ("model", cl.Chainladder())] + genins = cl.load_sample("genins") + pipe = cl.Pipeline( + [ + ("dev", cl.Development(average="volume")), + ("tail", cl.TailCurve(curve="exponential")), + ("model", cl.Chainladder()), + ] ) - standalone = cl.Pipeline( - [("dev", cl.Development()), ("model", cl.Chainladder())] + pipe.fit(genins) + ibnr = ( + pipe.named_steps.model.ibnr_.to_frame(origin_as_datetime=False) + .iloc[:, 0] + .astype(int) + .rename("IBNR") ) - ibnr_industry = industry.fit(clrd).named_steps.model.ibnr_ - ibnr_standalone = standalone.fit(clrd).named_steps.model.ibnr_ - summary = pd.DataFrame( - { - "industry": ibnr_industry.groupby("LOB").sum().sum("origin").to_frame(), - "standalone": ibnr_standalone.groupby("LOB").sum().sum("origin").to_frame(), - } - ).astype(int).rename_axis(None) - print(summary) + print(ibnr) .. testoutput:: - industry standalone - comauto 1743192 1683207 - medmal 1330330 1455883 - othliab 1640597 -14285800 - ppauto 17138458 17327738 - prodliab 531648 577127 - wkcomp 2777812 2498151 - - The two pipelines differ in a single hyperparameter, yet the standalone - fit lets a handful of small companies with erratic development drive the - other liability line to a negative aggregate IBNR, while the pooled - patterns keep every line at a reasonable estimate. + 2001 115089 + 2002 254924 + 2003 628182 + 2004 865921 + 2005 1128201 + 2006 1570234 + 2007 2344628 + 2008 4120446 + 2009 4445414 + 2010 4772416 + Freq: Y-DEC, Name: IBNR, dtype: int64 + + Each step feeds the next: the volume-weighted patterns from ``dev`` are + extended past the edge of the triangle by the exponential tail in + ``tail``, and the chainladder ``model`` projects every origin year to + ultimate with those completed patterns. The full origin-by-origin IBNR + comes from the final step, while intermediate results such as the fitted + tail factor remain available through ``pipe.named_steps.tail``. """