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 |"