From 4a07452eb132d3ac669b3c4f8490d172b07aa58e Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Sat, 25 Apr 2026 01:49:14 -0700 Subject: [PATCH] DOCS: Add examples to Triangle methods and properties (#704) Adds doctest-validated Examples blocks to 19 Triangle methods and properties, matching the Polars-style format used in the constructor PR: no imports, "..." continuation lines, and strict output indentation. Methods with Examples added to existing docstrings: incr_to_cum, cum_to_incr, dev_to_val, val_to_dev, grain, trend, shift, sort_axis, development_correlation, valuation_correlation. Properties with Examples added to existing docstrings: link_ratio, is_full. Properties with full docstrings written from scratch: age_to_age, is_ultimate, is_pattern, is_val_tri, latest_diagonal, origin, development. All 19 example blocks validated with strict doctest (no NORMALIZE_WHITESPACE flag). Also adds .scratch/ to .gitignore for local handoff notes and throwaway verification scripts. --- .gitignore | 5 +- chainladder/core/triangle.py | 529 ++++++++++++++++++++++++++++++++++- 2 files changed, 528 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 84c45c47..c5a75803 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,7 @@ coverage_html_report **/Untitled*.ipynb # IntelliJ .idea folder -.idea \ No newline at end of file +.idea + +# Local scratch directory (handoff notes, throwaway scripts, etc.) +.scratch/ \ No newline at end of file diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 9cd4f825..8436914a 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -461,6 +461,31 @@ def columns_label(self) -> list: @property def origin(self): + """ + Origin periods of the Triangle as a ``PeriodIndex``. + + The frequency of the index reflects ``origin_grain`` (e.g. annual, + quarterly, monthly). When the Triangle holds aggregated patterns with a + single origin row, the property returns ``Series(['(All)'])`` instead. + + Returns + ------- + PeriodIndex or Series + One entry per origin period of the Triangle. + + Examples + -------- + Annual-origin Triangle. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.origin.year.tolist() + [2007, 2008, 2009, 2010, 2011, 2012, 2013] + + The number of origin periods matches ``Triangle.shape[-2]``. + + >>> len(tr.origin) == tr.shape[-2] + True + """ if self.is_pattern and len(self.odims) == 1: return pd.Series(["(All)"]) else: @@ -489,6 +514,39 @@ def origin(self, value): @property def development(self): + """ + Development periods of the Triangle as a Series. + + For a development-lag Triangle (``is_val_tri=False``), values are + integer lags expressed in months from the start of the origin period. + For a valuation Triangle (``is_val_tri=True``), values are calendar + valuation labels formatted at ``development_grain``. For pattern + Triangles, labels carry their from/to development range (e.g. + ``"12-24"`` or ``"108-Ult"``). + + Returns + ------- + Series + One entry per development column of the Triangle. + + Examples + -------- + Annual-grain development on a loss Triangle is reported as month lags. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.development.tolist() + [12, 24, 36, 48, 60, 72, 84] + + On a valuation Triangle the labels become calendar periods. + + >>> tr.dev_to_val().development.tolist() + ['2007', '2008', '2009', '2010', '2011', '2012', '2013'] + + On a link-ratio (pattern) Triangle the labels span the from/to lags. + + >>> tr.link_ratio.development.tolist() + ['12-24', '24-36', '36-48', '48-60', '60-72', '72-84'] + """ ddims = self.ddims.copy() if self.is_val_tri: formats = {"Y": "%Y", "S": "%YQ%q", "Q": "%YQ%q", "M": "%Y-%m"} @@ -524,6 +582,31 @@ def set_index(self, value, inplace=False): @property def is_val_tri(self): + """ + Indicates whether the development axis is expressed in valuation + periods rather than development lags. + + Returns + ------- + bool + ``True`` if the development axis is a ``DatetimeIndex`` of + valuation dates (as produced by :meth:`dev_to_val`); ``False`` if + development is expressed as integer lags in months. + + Examples + -------- + A development-lag Triangle has integer lags on the development axis. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.is_val_tri + False + + Calling ``dev_to_val`` reshapes the development axis into calendar + valuation periods. + + >>> tr.dev_to_val().is_val_tri + True + """ return type(self.ddims) == pd.DatetimeIndex @property @@ -535,6 +618,22 @@ def is_full(self) -> bool: ------- bool + + Examples + -------- + A loaded sample loss Triangle is upper-triangular: future cells below + the latest diagonal are NaN, so ``is_full`` is ``False``. + + >>> tr = cl.load_sample('ukmotor') + >>> bool(tr.is_full) + False + + A ``cdf_`` Triangle from a fitted development model has every cell + populated, so it is full. + + >>> cdf = cl.Development().fit(tr).cdf_ + >>> bool(cdf.is_full) + True """ return self.nan_triangle.sum().sum() == np.prod(self.shape[-2:]) @@ -542,20 +641,101 @@ def is_full(self) -> bool: @property def is_pattern(self) -> bool: + """ + Indicates whether the Triangle holds development patterns rather than + observed values. + + Pattern Triangles are produced by methods such as :attr:`link_ratio`, + :attr:`age_to_age`, and the ``ldf_`` / ``cdf_`` attributes of fitted + development estimators. They typically carry ``is_cumulative=False`` and + their cells are unitless ratios. + + Returns + ------- + bool + ``True`` if the Triangle is a set of development patterns, + otherwise ``False``. + + Examples + -------- + A loss Triangle is not a pattern. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.is_pattern + False + + Calling ``link_ratio`` returns a Triangle of age-to-age factors, which + is flagged as a pattern. + + >>> tr.link_ratio.is_pattern + True + """ return self._pattern - + @is_pattern.setter def is_pattern(self, pattern: bool): self._pattern = pattern @property def is_ultimate(self) -> np.bool: + """ + Indicates whether the Triangle includes an ultimate valuation column. + + ``True`` when at least one cell carries the sentinel ultimate + valuation date (``options.ULT_VAL``), as produced by reserving methods + such as :class:`~chainladder.Chainladder` or any model returning an + ``ultimate_`` Triangle. + + Returns + ------- + bool + ``True`` if the Triangle has an ultimate column, otherwise ``False``. + + Examples + -------- + A loaded sample triangle has no ultimate column. + + >>> tr = cl.load_sample('ukmotor') + >>> bool(tr.is_ultimate) + False + + Fitting a chainladder model produces an ``ultimate_`` Triangle whose + single development column is the ultimate valuation. + + >>> ult = cl.Chainladder().fit(tr).ultimate_ + >>> bool(ult.is_ultimate) + True + """ return sum(self.valuation >= options.ULT_VAL[:4]) > 0 @property def latest_diagonal(self) -> Triangle: """ - The latest diagonal of the triangle. + The latest diagonal of the Triangle, collapsed to a single development + column. + + For each origin period, the cell on the most recent valuation diagonal + is selected. The result is a Triangle with one development column + labeled by the latest valuation date. + + Returns + ------- + Triangle + Single-development-column Triangle of the most recent value for + each origin period. + + Examples + -------- + >>> tr = cl.load_sample('ukmotor') + >>> tr.latest_diagonal + 2013 + 2007 12690.0 + 2008 12746.0 + 2009 12993.0 + 2010 11093.0 + 2011 10217.0 + 2012 9650.0 + 2013 6283.0 """ return self[self.valuation == self.valuation_date].sum(axis="development") @@ -570,6 +750,22 @@ def link_ratio(self) -> Triangle: ------- Triangle object in link ratio form. + + Examples + -------- + >>> tr = cl.load_sample('ukmotor') + >>> tr.link_ratio + 12-24 24-36 36-48 48-60 60-72 72-84 + 2007 1.915694 1.336902 1.190391 1.098935 1.049902 1.02753 + 2008 1.925269 1.295729 1.118225 1.085655 1.051911 NaN + 2009 1.902870 1.234826 1.148734 1.105317 NaN NaN + 2010 1.804424 1.261032 1.135066 NaN NaN NaN + 2011 1.902892 1.293782 NaN NaN NaN NaN + 2012 1.891415 NaN NaN NaN NaN NaN + + Each cell is the ratio of the cumulative value at the next development + period to the value at the current period. ``link_ratio`` carries + ``is_pattern=True`` and ``is_cumulative=False`` on the returned Triangle. """ # Case where triangle is not a set of link ratios or development patterns. @@ -590,6 +786,27 @@ def link_ratio(self) -> Triangle: @property def age_to_age(self): + """ + Alias for :attr:`link_ratio`. Returns the same Triangle of age-to-age + development factors. + + Returns + ------- + Triangle + Triangle of age-to-age ratios. + + Examples + -------- + >>> tr = cl.load_sample('ukmotor') + >>> tr.age_to_age + 12-24 24-36 36-48 48-60 60-72 72-84 + 2007 1.915694 1.336902 1.190391 1.098935 1.049902 1.02753 + 2008 1.925269 1.295729 1.118225 1.085655 1.051911 NaN + 2009 1.902870 1.234826 1.148734 1.105317 NaN NaN + 2010 1.804424 1.261032 1.135066 NaN NaN NaN + 2011 1.902892 1.293782 NaN NaN NaN NaN + 2012 1.891415 NaN NaN NaN NaN NaN + """ return self.link_ratio def incr_to_cum(self, inplace=False): @@ -603,6 +820,47 @@ def incr_to_cum(self, inplace=False): Returns ------- Updated instance of triangle accumulated along the origin + + Examples + -------- + Construct an incremental triangle and accumulate it along the development axis. + + >>> df = pd.DataFrame( + ... data={ + ... 'origin': [1981, 1981, 1981, 1981, 1982, 1982, 1982, 1983, 1983, 1984], + ... 'development': [1981, 1982, 1983, 1984, 1982, 1983, 1984, 1983, 1984, 1984], + ... 'reported': [5012, 3257, 2638, 898, 106, 4179, 1111, 3410, 5582, 5655], + ... } + ... ) + >>> tr = cl.Triangle( + ... data=df, + ... origin='origin', + ... development='development', + ... columns=['reported'], + ... cumulative=False, + ... ) + >>> tr + 12 24 36 48 + 1981 5012.0 3257.0 2638.0 898.0 + 1982 106.0 4179.0 1111.0 NaN + 1983 3410.0 5582.0 NaN NaN + 1984 5655.0 NaN NaN NaN + + >>> tr.incr_to_cum() + 12 24 36 48 + 1981 5012.0 8269.0 10907.0 11805.0 + 1982 106.0 4285.0 5396.0 NaN + 1983 3410.0 8992.0 NaN NaN + 1984 5655.0 NaN NaN NaN + + By default ``incr_to_cum`` returns a new Triangle. Pass ``inplace=True`` to + mutate the calling Triangle instead. + + >>> tr.is_cumulative + False + >>> _ = tr.incr_to_cum(inplace=True) + >>> tr.is_cumulative + True """ if inplace: xp = self.get_array_module() @@ -658,6 +916,23 @@ def cum_to_incr(self, inplace=False): Returns ------- Updated instance of triangle accumulated along the origin + + Examples + -------- + ``cl.load_sample('ukmotor')`` is a cumulative Triangle. ``cum_to_incr`` + differences each cell against the prior development period, returning + per-period increments. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.cum_to_incr() + 12 24 36 48 60 72 84 + 2007 3511.0 3215.0 2266.0 1712.0 1059.0 587.0 340.0 + 2008 4001.0 3702.0 2278.0 1180.0 956.0 629.0 NaN + 2009 4355.0 3932.0 1946.0 1522.0 1238.0 NaN NaN + 2010 4295.0 3455.0 2023.0 1320.0 NaN NaN NaN + 2011 4150.0 3747.0 2320.0 NaN NaN NaN NaN + 2012 5102.0 4548.0 NaN NaN NaN NaN NaN + 2013 6283.0 NaN NaN NaN NaN NaN NaN """ if inplace: v = self.valuation_date @@ -737,6 +1012,34 @@ def dev_to_val(self, inplace=False): Triangle Updated instance of the triangle with valuation periods. + Examples + -------- + ``cl.load_sample('ukmotor')`` is a 7x7 cumulative Triangle in development + form. Each column represents months of development from the origin year. + + >>> tr = cl.load_sample('ukmotor') + >>> tr + 12 24 36 48 60 72 84 + 2007 3511.0 6726.0 8992.0 10704.0 11763.0 12350.0 12690.0 + 2008 4001.0 7703.0 9981.0 11161.0 12117.0 12746.0 NaN + 2009 4355.0 8287.0 10233.0 11755.0 12993.0 NaN NaN + 2010 4295.0 7750.0 9773.0 11093.0 NaN NaN NaN + 2011 4150.0 7897.0 10217.0 NaN NaN NaN NaN + 2012 5102.0 9650.0 NaN NaN NaN NaN NaN + 2013 6283.0 NaN NaN NaN NaN NaN NaN + + Calling ``dev_to_val`` reshapes the columns from development lags to + valuation periods, so each column corresponds to a calendar year. + + >>> tr.dev_to_val() + 2007 2008 2009 2010 2011 2012 2013 + 2007 3511.0 6726.0 8992.0 10704.0 11763.0 12350.0 12690.0 + 2008 NaN 4001.0 7703.0 9981.0 11161.0 12117.0 12746.0 + 2009 NaN NaN 4355.0 8287.0 10233.0 11755.0 12993.0 + 2010 NaN NaN NaN 4295.0 7750.0 9773.0 11093.0 + 2011 NaN NaN NaN NaN 4150.0 7897.0 10217.0 + 2012 NaN NaN NaN NaN NaN 5102.0 9650.0 + 2013 NaN NaN NaN NaN NaN NaN 6283.0 """ if self.is_val_tri: if inplace: @@ -778,6 +1081,23 @@ def val_to_dev(self, inplace=False): Returns ------- Updated instance of triangle with development lags + + Examples + -------- + ``val_to_dev`` is the inverse of ``dev_to_val``. Round-tripping a + development triangle through valuation form and back returns the + original layout. + + >>> tr = cl.load_sample('ukmotor') + >>> tr.dev_to_val().val_to_dev() + 12 24 36 48 60 72 84 + 2007 3511.0 6726.0 8992.0 10704.0 11763.0 12350.0 12690.0 + 2008 4001.0 7703.0 9981.0 11161.0 12117.0 12746.0 NaN + 2009 4355.0 8287.0 10233.0 11755.0 12993.0 NaN NaN + 2010 4295.0 7750.0 9773.0 11093.0 NaN NaN NaN + 2011 4150.0 7897.0 10217.0 NaN NaN NaN NaN + 2012 5102.0 9650.0 NaN NaN NaN NaN NaN + 2013 6283.0 NaN NaN NaN NaN NaN NaN """ if not self.is_val_tri: if inplace: @@ -824,6 +1144,77 @@ def grain(self, grain="", trailing=False, inplace=False): Returns ------- Triangle + + Examples + -------- + Build a quarterly origin / quarterly development Triangle (OQDQ). + + >>> df = pd.DataFrame( + ... data={ + ... 'origin': [ + ... '2022Q1', '2022Q1', '2022Q1', '2022Q1', '2022Q1', '2022Q1', '2022Q1', '2022Q1', + ... '2022Q2', '2022Q2', '2022Q2', '2022Q2', '2022Q2', '2022Q2', '2022Q2', + ... '2022Q3', '2022Q3', '2022Q3', '2022Q3', '2022Q3', '2022Q3', + ... '2022Q4', '2022Q4', '2022Q4', '2022Q4', '2022Q4', + ... '2023Q1', '2023Q1', '2023Q1', '2023Q1', + ... '2023Q2', '2023Q2', '2023Q2', + ... '2023Q3', '2023Q3', + ... '2023Q4', + ... ], + ... 'development': [ + ... '2022Q1', '2022Q2', '2022Q3', '2022Q4', '2023Q1', '2023Q2', '2023Q3', '2023Q4', + ... '2022Q2', '2022Q3', '2022Q4', '2023Q1', '2023Q2', '2023Q3', '2023Q4', + ... '2022Q3', '2022Q4', '2023Q1', '2023Q2', '2023Q3', '2023Q4', + ... '2022Q4', '2023Q1', '2023Q2', '2023Q3', '2023Q4', + ... '2023Q1', '2023Q2', '2023Q3', '2023Q4', + ... '2023Q2', '2023Q3', '2023Q4', + ... '2023Q3', '2023Q4', + ... '2023Q4', + ... ], + ... 'reported': [ + ... 100, 200, 300, 400, 480, 540, 580, 600, + ... 110, 220, 320, 420, 500, 560, 600, + ... 120, 240, 350, 450, 520, 580, + ... 130, 250, 370, 470, 540, + ... 140, 260, 380, 480, + ... 150, 270, 390, + ... 160, 280, + ... 170, + ... ], + ... } + ... ) + >>> tr = cl.Triangle( + ... data=df, + ... origin='origin', + ... development='development', + ... columns=['reported'], + ... cumulative=True, + ... ) + >>> tr + 3 6 9 12 15 18 21 24 + 2022Q1 100.0 200.0 300.0 400.0 480.0 540.0 580.0 600.0 + 2022Q2 110.0 220.0 320.0 420.0 500.0 560.0 600.0 NaN + 2022Q3 120.0 240.0 350.0 450.0 520.0 580.0 NaN NaN + 2022Q4 130.0 250.0 370.0 470.0 540.0 NaN NaN NaN + 2023Q1 140.0 260.0 380.0 480.0 NaN NaN NaN NaN + 2023Q2 150.0 270.0 390.0 NaN NaN NaN NaN NaN + 2023Q3 160.0 280.0 NaN NaN NaN NaN NaN NaN + 2023Q4 170.0 NaN NaN NaN NaN NaN NaN NaN + + Convert to annual origin / annual development. Origins are summed within + each calendar year and development periods are aggregated to year-end. + + >>> tr.grain('OYDY') + 12 24 + 2022 1090.0 2320.0 + 2023 1320.0 NaN + + Convert origin to annual but keep development quarterly (``OYDQ``). + + >>> tr.grain('OYDQ') + 3 6 9 12 15 18 21 24 + 2022 100.0 310.0 640.0 1090.0 1500.0 1860.0 2130.0 2320.0 + 2023 140.0 410.0 810.0 1320.0 NaN NaN NaN NaN """ ograin_old, ograin_new = self.origin_grain, grain[1:2] dgrain_old, dgrain_new = self.development_grain, grain[-1] @@ -957,6 +1348,42 @@ def trend( ------- Triangle updated with multiplicative trend applied. + + Examples + -------- + >>> df = pd.DataFrame( + ... data={ + ... 'origin': [2020, 2020, 2020, 2021, 2021, 2022], + ... 'development': [2020, 2021, 2022, 2021, 2022, 2022], + ... 'reported': [100, 200, 300, 110, 220, 120], + ... } + ... ) + >>> tr = cl.Triangle( + ... data=df, + ... origin='origin', + ... development='development', + ... columns=['reported'], + ... cumulative=True, + ... ) + + Apply a 10% annual trend along the origin axis. The latest origin year + (2022) is unchanged; older origins are scaled up by ``1.10`` per year + of distance from the latest origin. + + >>> tr.trend(0.10, axis='origin') + 12 24 36 + 2020 121.0 242.0 363.0 + 2021 121.0 242.0 NaN + 2022 120.0 NaN NaN + + Apply a 10% annual trend along the valuation axis instead. The latest + diagonal is unchanged and earlier diagonals are scaled up. + + >>> tr.trend(0.10, axis='valuation') + 12 24 36 + 2020 121.0 220.0 300.0 + 2021 121.0 220.0 NaN + 2022 120.0 NaN NaN """ if axis not in ["origin", "valuation", 2, -2]: raise ValueError( @@ -1023,6 +1450,17 @@ def development_correlation(self, p_critical=0.5): ------- DevelopmentCorrelation object with t, t_critical, t_expectation, t_variance, and range attributes. + + Examples + -------- + >>> tr = cl.load_sample('raa') + >>> dc = tr.development_correlation() + >>> bool(dc.t_critical.iloc[0, 0]) + False + + ``t_critical`` reports whether the calculated rank correlation falls + outside the no-correlation confidence interval. ``False`` indicates the + development factors are not significantly correlated. """ return DevelopmentCorrelation(self, p_critical) @@ -1046,6 +1484,18 @@ def valuation_correlation(self, p_critical=0.1, total=False): ValuationCorrelation object with z, z_critical, z_expectation and z_variance attributes. + Examples + -------- + >>> tr = cl.load_sample('raa') + >>> vc = tr.valuation_correlation() + >>> vc.z_critical + 1982 1983 1984 1985 1986 1987 1988 1989 1990 + 1981 False False False False False False False False False + + Each cell of ``z_critical`` flags whether the calendar-period z-statistic + for that valuation falls outside the no-effect confidence interval. + ``False`` everywhere means no calendar period shows a significant + large-or-small bias on its diagonal. """ return ValuationCorrelation(self, p_critical, total) @@ -1067,6 +1517,45 @@ def shift(self, periods=-1, axis=3): Triangle updated with shifted elements + Examples + -------- + >>> df = pd.DataFrame( + ... data={ + ... 'origin': [2020, 2020, 2020, 2021, 2021, 2022], + ... 'development': [2020, 2021, 2022, 2021, 2022, 2022], + ... 'reported': [100, 200, 300, 110, 220, 120], + ... } + ... ) + >>> tr = cl.Triangle( + ... data=df, + ... origin='origin', + ... development='development', + ... columns=['reported'], + ... cumulative=True, + ... ) + >>> tr + 12 24 36 + 2020 100.0 200.0 300.0 + 2021 110.0 220.0 NaN + 2022 120.0 NaN NaN + + Shift one period along the development axis (the default). Values move + right by one column and the leading column is filled with zeros. + + >>> tr.shift() + 12 24 36 + 2020 0.0 100.0 200.0 + 2021 0.0 110.0 220.0 + 2022 0.0 120.0 NaN + + Shift one period along the origin axis. Each origin row's data moves + down by one and the first origin row is zeroed out. + + >>> tr.shift(periods=-1, axis='origin') + 12 24 36 + 2020 0.0 0.0 0.0 + 2021 100.0 200.0 300.0 + 2022 110.0 220.0 NaN """ axis = self._get_axis(axis) if axis < 2: @@ -1119,13 +1608,43 @@ def sort_axis(self, axis): Parameters ---------- - axis : in or str - The axis for sorting + axis : int or str + The axis on which to sort. May be specified as an integer + (``0``, ``1``, ``2``, ``3``) or by name (``'index'``, ``'columns'``, + ``'origin'``, ``'development'``). Returns ------- Triangle - updated with shifted elements + New Triangle with the requested axis sorted in ascending order. + + Examples + -------- + Build a Triangle with two columns supplied in non-alphabetical order. + + >>> df = pd.DataFrame( + ... data={ + ... 'origin': [2020, 2020, 2021, 2021], + ... 'development': [2020, 2021, 2021, 2021], + ... 'reported': [100, 200, 110, 110], + ... 'paid': [50, 100, 60, 60], + ... } + ... ) + >>> tr = cl.Triangle( + ... data=df, + ... origin='origin', + ... development='development', + ... columns=['reported', 'paid'], + ... cumulative=True, + ... ) + >>> list(tr.columns) + ['reported', 'paid'] + + Sorting on the columns axis returns a new Triangle with columns in + alphabetical order. + + >>> list(tr.sort_axis('columns').columns) + ['paid', 'reported'] """ axis = self._get_axis(axis)