diff --git a/CHANGELOG.md b/CHANGELOG.md index f7852d9..8ce5db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- `Explanation.to_markdown()`: render explanations as Markdown tables with headlines and agreement summaries for reports, pull requests, and notebooks (#1). + ## [0.1.0] - 2026-09-21 Initial MVP. diff --git a/README.md b/README.md index cb106a9..b062938 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ Models fitted on DataFrames are handed DataFrames again, so you won't see | `feature_values` | the explained instance (local only) | | `prediction`, `base_value`, `target` | model output, reference value, class explained | | `top(k)`, `ranks()`, `normalized()` | quick views | -| `to_text()`, `to_dataframe()`, `to_dict()`, `plot()` | outputs | +| `to_text()`, `to_markdown()`, `to_dataframe()`, `to_dict()`, `plot()` | outputs | | `metadata` | method-specific extras (surrogate `local_r2`, coalition `algorithm` / `exact`, permutation `importances_std`, ...) | `ConsensusExplanation` adds `components`, `agreement`, `agreement_matrix` and diff --git a/src/xai_framework/explanation.py b/src/xai_framework/explanation.py index 738b0e4..e0c7bce 100644 --- a/src/xai_framework/explanation.py +++ b/src/xai_framework/explanation.py @@ -209,6 +209,62 @@ def _extra_text(self) -> str: r2 = self.metadata.get("local_r2") return f"Local surrogate fit R^2 = {r2:.2f}." if r2 is not None else "" + def to_markdown(self, k: int = 10) -> str: + """Render the explanation as a Markdown table. + + Parameters + ---------- + k + Maximum number of top features to include, ordered by importance + (largest ``|value|`` first). Default 10. + + Returns + ------- + str + A Markdown string with a headline, table, and optional agreement/extra summary. + """ + order = np.argsort(-np.abs(self.values), kind="stable")[:k] + has_values = self.feature_values is not None + + headers = ["feature"] + if has_values: + headers.append("value") + headers.append("attribution") + + comp_headers, comp_data = self._markdown_component_data() + headers.extend(comp_headers) + + rows: list[list[str]] = [] + for i in order: + name = self.feature_names[i] + val = self.values[i] + row = [name] + if has_values: + assert self.feature_values is not None + row.append(_fmt(self.feature_values[i])) + row.append(f"{val:+.4g}") + for comp_name in comp_headers: + row.append(comp_data[comp_name].get(name, "0")) + rows.append(row) + + lines = [ + self._headline(), + "", + "| " + " | ".join(_escape_markdown(h) for h in headers) + " |", + "| " + " | ".join("---" for _ in headers) + " |", + ] + for row in rows: + lines.append("| " + " | ".join(_escape_markdown(c) for c in row) + " |") + + extra = self._extra_text() + if extra: + lines.extend(["", extra]) + + return "\n".join(lines) + + def _markdown_component_data(self) -> tuple[list[str], dict[str, dict[str, str]]]: + return [], {} + # ------------------------------------------------------------------ plotting def plot( self, k: int = 10, ax: Any = None, show: bool = False, title: str | None = None @@ -292,6 +348,15 @@ def _extra_text(self) -> str: }[level] return f"Agreement between {methods}: {level} (rho = {self.agreement:.2f}) - {hint}." + def _markdown_component_data(self) -> tuple[list[str], dict[str, dict[str, str]]]: + headers = list(self.components.keys()) + data: dict[str, dict[str, str]] = {name: {} for name in headers} + for name, comp in self.components.items(): + norm = comp.normalized() + for feat, val in zip(comp.feature_names, norm, strict=False): + data[name][feat] = f"{val:+.4g}" + return headers, data + def __repr__(self) -> str: head = ", ".join(f"{f}={v:+.3g}" for f, v in self.top(3)) return ( @@ -306,6 +371,18 @@ def _fmt(value: Any) -> str: return str(value) +def _escape_markdown(text: Any) -> str: + """Escape text for safe inclusion in Markdown table headers and cells. + + Escapes backslashes, table delimiter pipes, and converts embedded + newlines into '
' so table rows do not break. + """ + s = str(text) + s = s.replace("\\", "\\\\") + s = s.replace("|", "\\|") + return s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "
") + + def _jsonable(value: Any) -> Any: if isinstance(value, np.generic): return value.item() diff --git a/tests/test_explanation.py b/tests/test_explanation.py index 0cc926c..5c0cd9e 100644 --- a/tests/test_explanation.py +++ b/tests/test_explanation.py @@ -96,3 +96,182 @@ def test_consensus_dataframe_has_component_columns(): d = exp.to_dict() assert set(d["components"]) == {"a", "b"} assert d["agreement"] is None + + +def test_to_markdown_local_classification(): + exp = make( + [0.3, -0.9, 0.0], + feature_values=np.array([1, 2, 3]), + target="yes", + prediction=0.8, + base_value=0.5, + ) + md = exp.to_markdown() + lines = md.splitlines() + + assert lines[0] == "Predicted class 'yes' with probability 0.800 (t, baseline 0.500)." + assert lines[1] == "" + assert lines[2] == "| feature | value | attribution |" + assert lines[3] == "| --- | --- | --- |" + # Ranked by magnitude: f1 (-0.9), f0 (0.3), f2 (0.0) + assert lines[4] == "| f1 | 2 | -0.9 |" + assert lines[5] == "| f0 | 1 | +0.3 |" + assert lines[6] == "| f2 | 3 | +0 |" + + +def test_to_markdown_k_limits_rows(): + exp = make([0.1, -0.9, 0.5], feature_values=np.array([1, 2, 3])) + md = exp.to_markdown(k=2) + lines = md.splitlines() + + assert lines[2] == "| feature | value | attribution |" + assert lines[3] == "| --- | --- | --- |" + # Only top 2 features rendered + assert len(lines) == 6 + assert lines[4] == "| f1 | 2 | -0.9 |" + assert lines[5] == "| f2 | 3 | +0.5 |" + + +def test_to_markdown_global_omits_value_column(): + glob = make([1.0, -0.5], scope="global") + md = glob.to_markdown() + lines = md.splitlines() + + assert lines[0] == "Global feature importance (t):" + assert lines[1] == "" + assert lines[2] == "| feature | attribution |" + assert lines[3] == "| --- | --- |" + assert lines[4] == "| f0 | +1 |" + assert lines[5] == "| f1 | -0.5 |" + + +def test_to_markdown_regression(): + reg = make([1.5, -2.0], task="regression", prediction=42.0, feature_values=np.array([10, 20])) + md = reg.to_markdown() + assert "Predicted value 42 (t)." in md + assert "| feature | value | attribution |" in md + assert "| f1 | 20 | -2 |" in md + + +def test_to_markdown_consensus_includes_component_columns_and_agreement(): + comps = {"coalition": make([1.0, 0.5]), "surrogate": make([0.9, 0.6])} + exp = ConsensusExplanation( + feature_names=["f0", "f1"], + values=np.array([1.0, 0.5]), + method="consensus", + scope="local", + feature_values=np.array([10, 20]), + components=comps, + agreement=0.9, + ) + md = exp.to_markdown() + lines = md.splitlines() + + assert lines[2] == "| feature | value | attribution | coalition | surrogate |" + assert lines[3] == "| --- | --- | --- | --- | --- |" + # coalition normalized: [1.0/1.5, 0.5/1.5] -> [+0.6667, +0.3333] + # surrogate normalized: [0.9/1.5, 0.6/1.5] -> [+0.6, +0.4] + assert lines[4] == "| f0 | 10 | +1 | +0.6667 | +0.6 |" + assert lines[5] == "| f1 | 20 | +0.5 | +0.3333 | +0.4 |" + assert lines[6] == "" + assert ( + lines[7] + == "Agreement between coalition + surrogate: high (rho = 0.90) - the ranking is reliable." + ) + + +def test_to_markdown_global_consensus(): + comps = {"a": make([1.0, 0.5]), "b": make([0.5, 1.0])} + exp = ConsensusExplanation( + feature_names=["f0", "f1"], + values=np.array([1.5, 1.5]), + method="consensus", + scope="global", + components=comps, + agreement=0.5, + ) + md = exp.to_markdown() + lines = md.splitlines() + + assert lines[0] == "Global feature importance (consensus):" + assert lines[2] == "| feature | attribution | a | b |" + assert lines[3] == "| --- | --- | --- | --- |" + assert "Agreement between a + b: moderate (rho = 0.50)" in lines[-1] + + +def test_to_markdown_surrogate_local_r2(): + exp = make([0.5, -0.5], feature_values=np.array([1, 2]), metadata={"local_r2": 0.94}) + md = exp.to_markdown() + assert "Local surrogate fit R^2 = 0.94." in md + + +def test_escape_markdown_helper(): + from xai_framework.explanation import _escape_markdown + + assert _escape_markdown("simple") == "simple" + assert _escape_markdown("pipe|char") == "pipe\\|char" + assert _escape_markdown("back\\slash") == "back\\\\slash" + assert _escape_markdown("new\nline") == "new
line" + assert _escape_markdown("crlf\r\nline") == "crlf
line" + assert _escape_markdown("combo\\path|part\nend") == "combo\\\\path\\|part
end" + + +def test_to_markdown_escapes_special_characters(): + """Verify pipes, backslashes, and newlines in feature names, feature values, and headers are escaped.""" + exp = Explanation( + feature_names=["income|monthly", "path\\name", "normal"], + values=np.array([1.5, -0.8, 0.2]), + method="tree_shap", + scope="local", + feature_values=np.array(["line1\nline2", "val|pipe", "val\\slash"]), + ) + md = exp.to_markdown() + lines = md.splitlines() + + # Headers line + assert lines[2] == "| feature | value | attribution |" + assert lines[3] == "| --- | --- | --- |" + + # Row 1: feature 'income|monthly' -> 'income\|monthly', value 'line1\nline2' -> 'line1
line2' + assert lines[4] == "| income\\|monthly | line1
line2 | +1.5 |" + # Row 2: feature 'path\name' -> 'path\\name', value 'val|pipe' -> 'val\|pipe' + assert lines[5] == "| path\\\\name | val\\|pipe | -0.8 |" + # Row 3: feature 'normal', value 'val\slash' -> 'val\\slash' + assert lines[6] == "| normal | val\\\\slash | +0.2 |" + + # Ensure table rows do not break (exactly 3 data rows) + assert len(lines) == 7 + + +def test_to_markdown_consensus_escapes_method_headers_and_values(): + """Verify custom consensus method headers with pipes, backslashes, and newlines are properly escaped.""" + feat_names = ["feat|a", "feat\\b"] + comp1 = Explanation(feature_names=feat_names, values=np.array([1.0, 0.5]), method="t") + comp2 = Explanation(feature_names=feat_names, values=np.array([0.8, 0.4]), method="t") + comps = { + "custom|method": comp1, + "algo\\v2": comp2, + "method\nwith\nnewline": comp1, + } + exp = ConsensusExplanation( + feature_names=feat_names, + values=np.array([1.0, 0.5]), + method="consensus", + scope="local", + feature_values=np.array(["multi\r\nline", "regular"]), + components=comps, + agreement=0.85, + ) + md = exp.to_markdown() + lines = md.splitlines() + + # Headers line should escape pipes, backslashes, and newlines in component method names + assert ( + lines[2] + == "| feature | value | attribution | custom\\|method | algo\\\\v2 | method
with
newline |" + ) + assert lines[3] == "| --- | --- | --- | --- | --- | --- |" + + # Data rows should also escape features and values + assert lines[4] == "| feat\\|a | multi
line | +1 | +0.6667 | +0.6667 | +0.6667 |" + assert lines[5] == "| feat\\\\b | regular | +0.5 | +0.3333 | +0.3333 | +0.3333 |"