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()