Spaces:
Sleeping
Sleeping
File size: 8,765 Bytes
ce7c1f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | """SHAP-based top-token explanations for the linear models (§3.4).
Why only Logistic Regression and only top-token bar charts?
-----------------------------------------------------------
- README §3 deliverables list ``lr_shap_classaction.png`` and
``lr_shap_casetype.png`` — both are LR.
- For a sparse linear model on TF-IDF features, SHAP's ``LinearExplainer``
reduces to the model's own (mean-centred) coefficients × feature value,
so we get exact, fast attributions without sampling. This is the
right tool — kernel/tree explainers would be slow and stochastic here.
- Bar chart of the top-k positive- and negative-impact tokens is what
slide 8 needs: a story like "the words 'class' and 'representative'
push the model toward 'class action sought'".
For the multi-class case-type plot we tile per-class bar charts so each
of the 5 case-type groups gets its own most-distinctive vocabulary.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from src.utils import RESULTS_DIR, get_logger
logger = get_logger(__name__)
@dataclass(frozen=True)
class TokenImpact:
"""One row of the bar chart: a token + its average signed SHAP value."""
token: str
mean_shap: float
def _ensure_linear(pipe: Pipeline) -> tuple[TfidfVectorizer, LogisticRegression]:
"""Defensive unpack — only LR + TF-IDF pipelines are supported here."""
vectorizer = pipe.named_steps.get("tfidf")
classifier = pipe.named_steps.get("clf")
if not isinstance(vectorizer, TfidfVectorizer):
raise TypeError("explain.py expects a 'tfidf' TfidfVectorizer step.")
if not isinstance(classifier, LogisticRegression):
raise TypeError(
f"SHAP top-token explanation is implemented for LogisticRegression "
f"only (got {type(classifier).__name__})."
)
return vectorizer, classifier
def _shap_values(
vectorizer: TfidfVectorizer,
classifier: LogisticRegression,
X_texts: np.ndarray,
*,
background_size: int = 100,
) -> tuple[np.ndarray, list[str]]:
"""Return (shap_values, vocabulary) where shap_values has shape (n_samples, n_features).
For multi-class LR with K classes, shap returns a list of K arrays; we
stack into shape (K, n_samples, n_features) so downstream code can
pick a class index.
"""
import shap # local import: optional dependency
X = vectorizer.transform(X_texts)
background_size = min(background_size, X.shape[0])
rng = np.random.default_rng(42)
bg_idx = rng.choice(X.shape[0], size=background_size, replace=False)
background = X[bg_idx]
explainer = shap.LinearExplainer(classifier, background)
values = explainer.shap_values(X)
vocab = vectorizer.get_feature_names_out().tolist()
# Normalize shape to (K, n_samples, n_features) across SHAP versions:
# - old (<=0.42) : list of K arrays, each (N, F)
# - new multi-class : ndarray (N, F, K)
# - binary : ndarray (N, F)
if isinstance(values, list):
values = np.stack(values, axis=0)
elif values.ndim == 3:
values = np.transpose(values, (2, 0, 1))
else:
values = values[np.newaxis, ...]
return values, vocab
def top_tokens_per_class(
shap_values: np.ndarray,
vocab: list[str],
*,
top_k: int = 15,
) -> list[list[TokenImpact]]:
"""Return, for each class, the top-k tokens by absolute mean SHAP impact.
Each token's score keeps its sign so the bar chart shows direction:
positive bars push *toward* the class, negative bars push *away*.
"""
out: list[list[TokenImpact]] = []
n_classes = shap_values.shape[0]
for cls in range(n_classes):
mean_shap = shap_values[cls].mean(axis=0)
# Some sklearn versions emit a 1-D ndarray, others a matrix-like; flatten safely.
mean_shap = np.asarray(mean_shap).ravel()
idx = np.argsort(-np.abs(mean_shap))[:top_k]
rows = [TokenImpact(token=vocab[i], mean_shap=float(mean_shap[i])) for i in idx]
out.append(rows)
return out
def _plot_binary(
tokens: list[TokenImpact],
*,
positive_label: str,
negative_label: str,
title: str,
output_path: Path,
) -> None:
fig, ax = plt.subplots(figsize=(7, 6))
tokens_sorted = sorted(tokens, key=lambda t: t.mean_shap)
labels = [t.token for t in tokens_sorted]
values = [t.mean_shap for t in tokens_sorted]
colors = ["#1f77b4" if v >= 0 else "#d62728" for v in values]
ax.barh(labels, values, color=colors)
ax.axvline(0, color="black", linewidth=0.5)
ax.set_xlabel(f"← pushes toward '{negative_label}' | pushes toward '{positive_label}' →")
ax.set_title(title)
fig.tight_layout()
fig.savefig(output_path, dpi=150)
plt.close(fig)
logger.info("Saved SHAP plot → %s", output_path)
def _plot_multiclass(
tokens_per_class: list[list[TokenImpact]],
label_names: list[str],
*,
title: str,
output_path: Path,
) -> None:
"""Tile one mini bar chart per class so each row of slide 9 has its own vocabulary."""
n_classes = len(tokens_per_class)
n_cols = min(3, n_classes)
n_rows = (n_classes + n_cols - 1) // n_cols
fig, axes = plt.subplots(n_rows, n_cols, figsize=(5 * n_cols, 4 * n_rows))
axes = np.atleast_1d(axes).ravel()
for idx, (cls_name, tokens) in enumerate(zip(label_names, tokens_per_class)):
tokens_sorted = sorted(tokens, key=lambda t: t.mean_shap)
labels = [t.token for t in tokens_sorted]
values = [t.mean_shap for t in tokens_sorted]
colors = ["#1f77b4" if v >= 0 else "#d62728" for v in values]
axes[idx].barh(labels, values, color=colors)
axes[idx].axvline(0, color="black", linewidth=0.5)
axes[idx].set_title(cls_name, fontsize=10)
# Hide any leftover panels in the grid.
for k in range(len(label_names), len(axes)):
axes[k].set_visible(False)
fig.suptitle(title, fontsize=12)
fig.tight_layout(rect=(0, 0, 1, 0.97))
fig.savefig(output_path, dpi=150)
plt.close(fig)
logger.info("Saved SHAP plot → %s", output_path)
def explain_lr(
pipe: Pipeline,
X_test: np.ndarray,
label_names: list[str],
*,
task: str,
top_k: int = 15,
output_dir: Path | None = None,
) -> Path:
"""Top entry point: pickled LR pipeline → SHAP PNG under results/."""
vectorizer, classifier = _ensure_linear(pipe)
shap_values, vocab = _shap_values(vectorizer, classifier, X_test)
tokens_per_class = top_tokens_per_class(shap_values, vocab, top_k=top_k)
out_dir = output_dir or RESULTS_DIR
tag = "classaction" if task == "class_action" else "casetype"
output_path = out_dir / f"lr_shap_{tag}.png"
if len(label_names) == 2:
# Binary LR has 1 row of shap values, signed toward class index 1.
_plot_binary(
tokens_per_class[-1],
positive_label=label_names[1],
negative_label=label_names[0],
title=f"SHAP top tokens — LR · {task}",
output_path=output_path,
)
else:
_plot_multiclass(
tokens_per_class,
label_names,
title=f"SHAP top tokens — LR · {task}",
output_path=output_path,
)
return output_path
def build_arg_parser():
import argparse
parser = argparse.ArgumentParser(description="Generate SHAP top-token plot for an LR pipeline.")
parser.add_argument("--task", required=True, choices=["class_action", "case_type"])
parser.add_argument("--pipeline", help="Path to pickled LR pipeline. Defaults to models/lr_{tag}.pkl.")
parser.add_argument("--top-k", type=int, default=15)
parser.add_argument("--text-source", default="long_ref",
choices=["long_ref", "long_pred", "source_text"])
return parser
def main() -> None:
import pickle
from src.classify.data import load_classification_data
from src.utils import MODELS_DIR
args = build_arg_parser().parse_args()
tag = "classaction" if args.task == "class_action" else "casetype"
pipeline_path = Path(args.pipeline) if args.pipeline else MODELS_DIR / f"lr_{tag}.pkl"
with pipeline_path.open("rb") as fh:
pipe = pickle.load(fh)
data = load_classification_data(task=args.task, text_source=args.text_source)
explain_lr(pipe, data.X_test, data.label_names, task=args.task, top_k=args.top_k)
if __name__ == "__main__":
main()
|