Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions src/xai_framework/explanation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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 '<br>' 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", "<br>")


def _jsonable(value: Any) -> Any:
if isinstance(value, np.generic):
return value.item()
Expand Down
179 changes: 179 additions & 0 deletions tests/test_explanation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>line"
assert _escape_markdown("crlf\r\nline") == "crlf<br>line"
assert _escape_markdown("combo\\path|part\nend") == "combo\\\\path\\|part<br>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<br>line2'
assert lines[4] == "| income\\|monthly | line1<br>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<br>with<br>newline |"
)
assert lines[3] == "| --- | --- | --- | --- | --- | --- |"

# Data rows should also escape features and values
assert lines[4] == "| feat\\|a | multi<br>line | +1 | +0.6667 | +0.6667 | +0.6667 |"
assert lines[5] == "| feat\\\\b | regular | +0.5 | +0.3333 | +0.3333 | +0.3333 |"
Loading