diff --git a/chainladder/core/style.py b/chainladder/core/style.py index a7c156c69..bd2ccad5a 100644 --- a/chainladder/core/style.py +++ b/chainladder/core/style.py @@ -34,6 +34,15 @@ del annotations +def _get_starting_dev_lags(tri: Triangle) -> list[str]: + """ + Extract starting development lag intervals for pattern and non-pattern triangles. + """ + if tri.is_pattern: + return [str(x).split("-")[0] for x in tri.development] + return [str(x) for x in tri.development] + + class Styler(_PandasStyler): """ Styles a Triangle according to the data with HTML and CSS. @@ -141,6 +150,154 @@ def _copy( ) return styler + def _compute(self) -> Styler: + """ + Execute the style functions built up in `self._todo`, or restore pre-computed + styles for concatenated stylers whose shapes were padded. + """ + if getattr(self, "_concatenated_ctx", None) is not None: + self.ctx = copy_module.deepcopy(self._concatenated_ctx) + self.ctx_index = copy_module.deepcopy(self._concatenated_ctx_index) + self.ctx_columns = copy_module.deepcopy(self._concatenated_ctx_columns) + return self + return super()._compute() + + def _snapshot_context(self) -> None: + """ + Cache pre-computed style contexts before DataFrame shape modifications. + """ + if getattr(self, "_concatenated_ctx", None) is None: + self._compute() + self._concatenated_ctx = copy_module.deepcopy(self.ctx) + self._concatenated_ctx_index = copy_module.deepcopy(self.ctx_index) + self._concatenated_ctx_columns = copy_module.deepcopy(self.ctx_columns) + + def _pad_columns(self, other: Styler) -> None: + """ + Pad column dimensions with NaNs so self and other share identical column labels. + """ + if len(self.data.columns) >= len(other.data.columns): + target_cols = self.data.columns + padded_data = other.data.copy() + orig_len = len(padded_data.columns) + for c in target_cols[orig_len:]: + padded_data[c] = np.nan + padded_data.columns = target_cols + other.data = padded_data + other.columns = target_cols + if orig_len < len(target_cols): + other.format(na_rep="", subset=list(target_cols[orig_len:])) + else: + self._snapshot_context() + new_cols = other.data.columns[len(self.data.columns) :] + for c in new_cols: + self.data[c] = np.nan + self.columns = self.data.columns + self.format(na_rep="", subset=list(new_cols)) + for prev in self.concatenated: + if isinstance(prev, Styler): + prev._snapshot_context() + for c in new_cols: + prev.data[c] = np.nan + prev.columns = self.data.columns + prev.format(na_rep="", subset=list(new_cols)) + other.data.columns = self.data.columns + other.columns = self.data.columns + + def concat(self, other: Any) -> Styler: + """ + Append another Styler to combine the output into a single table. + + Parameters + ---------- + other : Styler + The other Styler object to concatenate. The Styler can wrap + another Triangle, link ratios, or development patterns. If the + columns differ, they must share compatible development periods. + + Returns + ------- + Styler + ``self``, with ``other`` appended to its concatenated list. + + Raises + ------ + TypeError + If ``other`` is not a Styler. + ValueError + If the number of index levels differ, or if the development periods + between Triangles are incompatible. + + Examples + -------- + .. code-block:: python + + import chainladder as cl + + raa = cl.load_sample("raa") + dev = cl.Development().fit(raa) + + # Concatenate loss triangle with link ratios and selected factors + exhibit = ( + raa.style + .highlight_lower_triangle() + .concat( + raa.link_ratio.style.format( + precision=3, na_rep="" + ).highlight_diagonal() + ) + .concat(dev.ldf_.style.format(precision=3, na_rep="")) + .concat(dev.cdf_.style.format(precision=3, na_rep="")) + ) + """ + if isinstance(other, Triangle): + raise TypeError( + "`other` must be of type `Styler`. Use `Triangle.style` for a Triangle." + ) + if not isinstance(other, _PandasStyler): + raise TypeError("`other` must be of type `Styler`") + if self.data.index.nlevels != other.data.index.nlevels: + raise ValueError( + "number of index levels must be same in `other` " + "as in `Styler`. See documentation for suggestions." + ) + + other_copy: Styler = other._copy(deepcopy=True) + other_copy.data = other.data.copy() + + if self.data.columns.equals(other.data.columns): + if isinstance(other_copy, Styler): + other_copy._snapshot_context() + super().concat(other_copy) + return self + + if not hasattr(other_copy, "_triangle"): + super().concat(other_copy) + return self + + if self._triangle.is_val_tri != other_copy._triangle.is_val_tri: + raise ValueError( + "Cannot concatenate a valuation Triangle with a development Triangle." + ) + if self._triangle.development_grain != other_copy._triangle.development_grain: + raise ValueError( + "Development grains must match to concatenate stylers: " + f"'{self._triangle.development_grain}' != '{other_copy._triangle.development_grain}'." + ) + + dev1 = _get_starting_dev_lags(self._triangle) + dev2 = _get_starting_dev_lags(other_copy._triangle) + min_len = min(len(dev1), len(dev2)) + if dev1[:min_len] != dev2[:min_len]: + raise ValueError( + "Development periods must be compatible to concatenate stylers." + ) + + other_copy._snapshot_context() + self._pad_columns(other_copy) + self.concatenated.append(other_copy) + return self + @staticmethod def _mask_style( _data: pd.DataFrame, diff --git a/chainladder/core/tests/test_style.py b/chainladder/core/tests/test_style.py index 7f746eca0..8f577e106 100644 --- a/chainladder/core/tests/test_style.py +++ b/chainladder/core/tests/test_style.py @@ -877,3 +877,298 @@ def test_apply_from_triangle_sparse_mask(raa) -> None: styler_bool._compute() styled_bool = {k for k, v in styler_bool.ctx.items() if v} assert styled_bool == {(0, 0)} + + +def test_concat_identical_columns(raa) -> None: + """ + Check that concatenating stylers with identical columns succeeds. + """ + dev = cl.Development().fit(raa) + s1 = raa.link_ratio.style + s2 = dev.ldf_.style + res = s1.concat(s2) + assert res is s1 + assert len(s1.concatenated) == 1 + assert "foot0_row0" in s1.to_html() + + +def test_concat_triangle_and_link_ratio(raa) -> None: + """ + Check that concatenating a Triangle with its link ratios pads the missing + column on the link ratios with blanks and preserves caller immutability. + """ + s1 = raa.style + lr_styler = raa.link_ratio.style + orig_cols = list(lr_styler.data.columns) + + res = s1.concat(lr_styler) + assert res is s1 + assert len(s1.concatenated) == 1 + + # Caller's other styler data was not mutated + assert list(lr_styler.data.columns) == orig_cols + + # Rendered table has triangle columns + html = s1.to_html() + for col in s1.data.columns: + assert f">{col}<" in html + + +def test_concat_link_ratio_and_cdf(raa) -> None: + """ + Check that concatenating link ratios with CDF patterns (different column labels + like '12-24' vs '12-Ult') succeeds due to compatible development periods. + """ + dev = cl.Development().fit(raa) + s1 = raa.link_ratio.style.format(precision=3, na_rep="") + s2 = dev.cdf_.style.format(precision=3, na_rep="") + res = s1.concat(s2) + assert res is s1 + assert len(s1.concatenated) == 1 + html = s1.to_html() + assert "foot0_row0" in html + + +def test_concat_multiple_chaining(raa) -> None: + """ + Check that chaining multiple concats (Triangle + link ratios + ldf + cdf) works. + """ + dev = cl.Development().fit(raa) + s = ( + raa.style + .concat(raa.link_ratio.style.format(precision=4, na_rep="")) + .concat(dev.ldf_.style.format(precision=4, na_rep="")) + .concat(dev.cdf_.style.format(precision=4, na_rep="")) + ) + assert len(s.concatenated) == 3 + html = s.to_html() + assert "foot0_row0" in html + assert "foot1_row0" in html + assert "foot2_row0" in html + + +def test_concat_widens_self_with_tail(raa) -> None: + """ + Check that concatenating an other styler with additional tail columns + widens self.data and pads prior rows with blanks. + """ + pipe = cl.Pipeline([ + ("dev", cl.Development()), + ("tail", cl.TailConstant(1.05)), + ]).fit(raa) + tail_step = pipe.named_steps.tail + + s1 = raa.link_ratio.style.format(precision=4, na_rep="") + orig_len = len(s1.data.columns) + s2 = tail_step.ldf_.style.format(precision=4, na_rep="") + tail_len = len(s2.data.columns) + assert tail_len > orig_len + + s1.concat(s2) + assert len(s1.data.columns) == tail_len + # Check that to_string and to_html render without error + text = s1.to_string() + assert len(text) > 0 + html = s1.to_html() + assert "foot0_row0" in html + + +def test_concat_widening_updates_prior_concatenated(raa) -> None: + """ + Check that when a tail styler widens self, all previously concatenated + stylers are also widened to match the new column structure. + """ + pipe = cl.Pipeline([ + ("dev", cl.Development()), + ("tail", cl.TailConstant(1.05)), + ]).fit(raa) + tail_step = pipe.named_steps.tail + + dev = cl.Development().fit(raa) + s1 = raa.link_ratio.style.format(precision=4, na_rep="") + s2 = dev.ldf_.style.format(precision=4, na_rep="") + s3 = tail_step.ldf_.style.format(precision=4, na_rep="") + + s = s1.concat(s2).concat(s3) + assert len(s.concatenated) == 2 + # Verify s2 copy in s.concatenated was widened to match s.data.columns + assert len(s.concatenated[0].data.columns) == len(s.data.columns) + assert len(s.concatenated[1].data.columns) == len(s.data.columns) + assert len(s.to_html()) > 0 + + +def test_concat_preserves_styles(raa) -> None: + """ + Check that styles applied to self and other (e.g. highlight_diagonal) + are both preserved in the concatenated HTML output. + """ + s1 = raa.style.highlight_diagonal(color="#FFF2CC") + s2 = raa.link_ratio.style.highlight_diagonal(color="#DDEBF7") + s = s1.concat(s2) + html = s.to_html() + assert "#FFF2CC" in html + assert "#DDEBF7" in html + + +def test_concat_preserves_lower_triangle_styling(raa) -> None: + """ + Check that lower triangle highlights are preserved when concatenated. + """ + s1 = raa.style.highlight_lower_triangle(color="#BDD7EE") + s2 = raa.link_ratio.style.highlight_lower_triangle(color="#F8CBAD") + s = s1.concat(s2) + html = s.to_html() + assert "#BDD7EE" in html + assert "#F8CBAD" in html + + +def test_concat_rejects_raw_triangle(raa) -> None: + """ + Check that passing a raw Triangle raises TypeError with a helpful hint. + """ + with pytest.raises( + TypeError, match="must be of type `Styler`. Use `Triangle.style`" + ): + raa.style.concat(raa.link_ratio) + + +def test_concat_rejects_non_styler(raa) -> None: + """ + Check that passing an invalid type raises TypeError. + """ + with pytest.raises(TypeError, match="must be of type `Styler`"): + raa.style.concat([1, 2, 3]) + + +def test_concat_rejects_mismatched_index_levels(raa) -> None: + """ + Check that mismatched index levels raises ValueError. + """ + df_multi = pd.DataFrame( + [[1] * len(raa.style.data.columns)], + index=pd.MultiIndex.from_tuples([("A", "B")]), + columns=raa.style.data.columns, + ) + with pytest.raises(ValueError, match="number of index levels"): + raa.style.concat(df_multi.style) + + +def test_concat_rejects_incompatible_grain(raa) -> None: + """ + Check that triangles with different development grains raise ValueError. + """ + quarterly = cl.load_sample("quarterly")["paid"] + with pytest.raises(ValueError, match="Development grains must match"): + raa.style.concat(quarterly.style) + + +def test_concat_rejects_incompatible_lags(raa) -> None: + """ + Check that triangles with incompatible development lags raise ValueError. + """ + t1 = raa.copy() + t2 = raa.copy() + t2.development = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + with pytest.raises(ValueError, match="Development periods must be compatible"): + t1.style.concat(t2.style) + + +def test_concat_rejects_valuation_with_development(raa) -> None: + """ + Check that mixing valuation and development mode triangles raises ValueError. + """ + with pytest.raises( + ValueError, match="valuation Triangle with a development Triangle" + ): + raa.dev_to_val().style.concat(raa.style) + + +def test_concat_output_formats(raa) -> None: + """ + Check that concatenated Styler supports to_html, to_latex, and to_string. + """ + s = raa.style.concat(raa.link_ratio.style.format(precision=3, na_rep="")) + assert len(s.to_html()) > 0 + assert len(s.to_string()) > 0 + assert len(s.to_latex()) > 0 + + +def test_concat_copy_preserves_concatenated(raa) -> None: + """ + Check that _copy on a concatenated Styler deep-copies the concatenated list. + """ + s = raa.style.concat(raa.link_ratio.style) + s_copy = s._copy(deepcopy=True) + assert isinstance(s_copy, Styler) + assert len(s_copy.concatenated) == 1 + assert s_copy.concatenated is not s.concatenated + assert len(s_copy.to_html()) > 0 + + +def test_concat_widening_preserves_highlights_on_self(raa) -> None: + """ + Check that widening self when appending a longer styler preserves highlights + on self without shape mismatch errors during rendering. + """ + short_styler = ( + raa + .iloc[..., :5] + .style.highlight_diagonal(props="background-color: #AABBCC;") + .highlight_lower_triangle() + ) + longer_styler = raa.link_ratio.style.format(precision=3, na_rep="") + combined = short_styler.concat(longer_styler) + html = combined.to_html() + assert "background-color: #AABBCC;" in html + assert len(combined.data.columns) == len(longer_styler.data.columns) + + +def test_concat_identical_columns_does_not_mutate_other_on_later_widening(raa) -> None: + """ + Check that concatenating with identical columns does not store other by reference, + so subsequent widening never mutates the caller's original other Styler. + """ + orig_other = raa.iloc[..., :5].style + orig_cols = list(orig_other.data.columns) + base = raa.iloc[..., :5].style.concat(orig_other) + # Subsequent concatenation with a wider styler widens base and prior elements + wider = raa.style + base.concat(wider) + # Ensure caller's orig_other was not mutated + assert list(orig_other.data.columns) == orig_cols + assert len(orig_other.data.columns) == 5 + + +def test_concat_with_plain_pandas_styler(raa) -> None: + """ + Check concatenation behavior with a plain pandas DataFrame Styler. + """ + # Matching columns succeeds + matching_df_style = raa.to_frame().style + s = raa.style.concat(matching_df_style) + assert len(s.concatenated) == 1 + + # Mismatched columns raises ValueError from pandas + mismatched_df_style = pd.DataFrame({"different_col": [1, 2]}).style + with pytest.raises(ValueError, match="same columns"): + raa.style.concat(mismatched_df_style) + + +def test_concat_prior_identical_columns_styler_frozen_on_subsequent_widen( + raa, +) -> None: + """ + Check that stylers concatenated via identical columns have their styles + frozen so subsequent widening does not cause shape mismatch during rendering. + """ + s1 = raa.iloc[..., :5].style + s2 = raa.iloc[..., :5].style.highlight_diagonal(props="background-color: #112233;") + s1.concat(s2) + + # Now widen with s3 having 10 columns + s3 = raa.style + s1.concat(s3) + + html = s1.to_html() + assert "background-color: #112233;" in html + assert len(s1.concatenated[0].data.columns) == 10 diff --git a/docs/user_guide/style.ipynb b/docs/user_guide/style.ipynb index 69a8d7831..863071207 100644 --- a/docs/user_guide/style.ipynb +++ b/docs/user_guide/style.ipynb @@ -193,6 +193,43 @@ " na_rep=\"\",\n", ")" ] + }, + { + "cell_type": "markdown", + "id": "0b2db163db4544da", + "metadata": {}, + "source": [ + "## Concatenating Stylers\n", + "\n", + "Actuarial exhibits frequently combine historical triangles, development link ratios, and selected patterns into a single table. Using {meth}`~chainladder.Styler.concat`, you can vertically stack styled Triangles and models while each component maintains its own formatting, highlighting, and column conventions.\n", + "\n", + "`Styler.concat()` automatically aligns link ratios and pattern developments to the triangle's development lags, padding unmatched cells cleanly with blanks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8f4b5697803484c", + "metadata": {}, + "outputs": [], + "source": [ + "# Create development model and selected factors\n", + "dev = cl.Development().fit(raa)\n", + "\n", + "# Format the triangle, link ratios, and selected patterns\n", + "triangle_styler = (\n", + " raa.style\n", + " .highlight_lower_triangle()\n", + " .highlight_diagonal()\n", + " .format(precision=0, thousands=\",\")\n", + ")\n", + "link_ratio_styler = raa.link_ratio.style.format(precision=3, na_rep=\"\")\n", + "ldf_styler = dev.ldf_.style.format(precision=3, na_rep=\"\")\n", + "cdf_styler = dev.cdf_.style.format(precision=3, na_rep=\"\")\n", + "\n", + "# Concatenate into a unified actuarial exhibit\n", + "triangle_styler.concat(link_ratio_styler).concat(ldf_styler).concat(cdf_styler)" + ] } ], "metadata": {