| """Explainability for the Proposed Model (BertMetaFusionACSAModel). |
| |
| Three views: |
| 1. Cross-Attention weights over metadata tokens (per aspect, per example) |
| 2. Integrated Gradients on the input text (per aspect, per example) |
| 3. Aspect-level aggregation plots (category x aspect) |
| |
| Saves an HTML report combining (1) and (2), plus PNG figures for (3). |
| """ |
| import html as html_lib |
| import logging |
| from pathlib import Path |
| from typing import List, Optional, Dict, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| import torch |
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import seaborn as sns |
|
|
| from . import config as cfg |
| from .evaluator import load_meta_acsa |
| from .meta_encoder import MetaEncoder |
| from .models import META_NUM_META_TOKENS |
|
|
|
|
| logger = logging.getLogger(__name__) |
|
|
| META_TOKEN_NAMES = [f"meta_chunk_{i+1}" for i in range(META_NUM_META_TOKENS)] |
| SEMANTIC_META_TOKEN_NAMES = ["features", "categories", "numeric"] |
|
|
|
|
| |
| |
| |
|
|
| def _normalize(arr): |
| arr = np.asarray(arr, dtype=np.float64) |
| if arr.size == 0: |
| return np.zeros_like(arr) |
| finite = arr[np.isfinite(arr)] |
| if finite.size == 0 or finite.max() == finite.min(): |
| return np.zeros_like(arr) |
| return (arr - finite.min()) / (finite.max() - finite.min() + 1e-9) |
|
|
|
|
| def _normalize_for_display(scores, mask=None): |
| arr = np.asarray(scores, dtype=np.float64) |
| if mask is not None: |
| arr = arr * np.asarray(mask, dtype=np.float64) |
| arr = np.abs(arr) |
| if arr.size == 0 or float(arr.max()) <= 0: |
| return np.zeros_like(arr) |
| denom = np.percentile(arr[arr > 0], 95) if np.any(arr > 0) else arr.max() |
| denom = max(float(denom), 1e-9) |
| return np.clip(arr / denom, 0.0, 1.0) |
|
|
|
|
| def _color_for_score(score: float, label: int) -> str: |
| score = float(np.clip(score, 0.0, 1.0)) |
| if score <= 0: |
| return "rgba(255,255,255,0)" |
| alpha = 0.18 + 0.72 * score |
| if label == 2: |
| return f"rgba(255, 80, 80, {alpha:.3f})" |
| if label == 1: |
| return f"rgba(80, 180, 110, {alpha:.3f})" |
| return f"rgba(150, 150, 150, {alpha:.3f})" |
|
|
|
|
| def _merge_wordpieces(tokens, scores): |
| words, vals = [], [] |
| for tok, score in zip(tokens, scores): |
| tok = str(tok) |
| if not tok or (tok.startswith("[") and tok.endswith("]")): |
| continue |
| if tok.startswith("##") and words: |
| words[-1] += tok[2:] |
| vals[-1] = max(vals[-1], float(score)) |
| else: |
| words.append(tok) |
| vals.append(float(score)) |
| return words, vals |
|
|
|
|
| def _top_terms(tokens, scores, top_k: int = 6): |
| words, vals = _merge_wordpieces(tokens, scores) |
| pairs = [(w, v) for w, v in zip(words, vals) if _is_informative_term(w) and v > 0] |
| pairs.sort(key=lambda x: x[1], reverse=True) |
| return _dedupe_terms(pairs, top_k=top_k) |
|
|
|
|
| def _explanation_quality(top_terms, meta_source: str, meta_weight: float) -> str: |
| n_terms = len(top_terms or []) |
| if n_terms >= 3 and meta_source and meta_weight >= 0.45: |
| return "strong" |
| if n_terms >= 1 and meta_source: |
| return "moderate" |
| return "weak" |
|
|
|
|
| def _make_explanation_sentence(aspect: str, pred_label: str, top_terms, meta_source: str, meta_weight: float) -> str: |
| terms = [t for t, _ in (top_terms or [])[:4]] |
| text_part = ", ".join(f'"{t}"' for t in terms) if terms else "no strong text token" |
| meta_part = f'"{meta_source}" ({meta_weight:.3f})' if meta_source else "no dominant metadata source" |
| return ( |
| f'{aspect} is predicted as {pred_label}. ' |
| f'The main text evidence is {text_part}, and the strongest metadata source is {meta_part}.' |
| ) |
|
|
|
|
| def _render_token_html(tokens, scores, label, top_k: int = 18): |
| norm = _normalize_for_display(scores) |
| positive_idx = np.where(norm > 0)[0] |
| if len(positive_idx) == 0: |
| words, vals = _merge_wordpieces(tokens, norm) |
| else: |
| ranked = positive_idx[np.argsort(norm[positive_idx])] |
| top_idx = set(ranked[-min(top_k, len(ranked)):].tolist()) |
| display = np.zeros_like(norm) |
| for i in top_idx: |
| display[i] = max(float(norm[i]), 0.35) |
| words, vals = _merge_wordpieces(tokens, display) |
| spans = [] |
| for word, val in zip(words, vals): |
| color = _color_for_score(val, label) |
| weight = "600" if val >= 0.55 else "400" |
| border = " border-bottom:1px solid rgba(0,0,0,.18);" if val >= 0.35 else "" |
| spans.append( |
| f'<span style="background-color:{color};padding:2px 4px;' |
| f'border-radius:4px;margin:1px;font-weight:{weight};{border}">' |
| f'{html_lib.escape(word)}</span>' |
| ) |
| return " ".join(spans) |
|
|
|
|
| def _meta_summary_from_row(row) -> Dict[str, str]: |
| def first_existing(names, default=""): |
| for name in names: |
| if name not in row: |
| continue |
| val = row[name] |
| if isinstance(val, (list, tuple)): |
| return "; ".join(map(str, val[:8])) |
| if pd.notna(val): |
| return str(val) |
| return default |
|
|
| features = first_existing(["features_text", "features", "description", "title"]) |
| categories = first_existing(["categories_text", "category", "leaf_category", "main_category"]) |
| numeric_parts = [] |
| for col in ["price", "average_rating", "rating_number"]: |
| if col in row and pd.notna(row[col]): |
| numeric_parts.append(f"{col}={row[col]}") |
| return { |
| "features": features[:420], |
| "categories": categories[:220], |
| "numeric": ", ".join(numeric_parts) if numeric_parts else "not available", |
| } |
|
|
|
|
| def _top_meta_source(meta_attn, token_names=None) -> Tuple[str, float]: |
| arr = np.asarray(meta_attn, dtype=np.float64).reshape(-1) |
| if arr.size == 0: |
| return "", 0.0 |
| names = token_names or META_TOKEN_NAMES |
| idx = int(np.argmax(arr)) |
| name = names[idx] if idx < len(names) else f"meta_{idx+1}" |
| return name, float(arr[idx]) |
|
|
| _ASPECT_EVIDENCE_KEYWORDS = { |
| "SIZE": {"size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length"}, |
| "MATERIAL": {"material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "leather", "wool"}, |
| "QUALITY": {"quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "tear", "torn"}, |
| "APPEARANCE": {"look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design"}, |
| "STYLE": {"style", "stylish", "flattering", "casual", "formal", "dress", "shirt", "fashion", "compliments"}, |
| "VALUE": {"price", "worth", "value", "money", "cheap", "expensive", "discount", "penny", "cost"}, |
| } |
|
|
| _SENTIMENT_EVIDENCE_KEYWORDS = { |
| "good", "great", "love", "loved", "perfect", "nice", "excellent", "comfortable", "soft", |
| "bad", "poor", "cheap", "terrible", "awful", "small", "large", "tight", "loose", "thin", |
| "worth", "disappointed", "return", "returned", "recommend", "flattering", "beautiful", |
| } |
|
|
| _STOPWORDS = { |
| "a", "an", "the", "and", "or", "but", "if", "then", "than", "so", "as", "at", "by", "for", "from", |
| "in", "into", "of", "on", "to", "with", "without", "is", "are", "was", "were", "be", "been", "being", |
| "it", "its", "this", "that", "these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "she", "they", |
| "them", "his", "her", "their", "very", "really", "just", "also", "too", "would", "could", "should", "can", |
| "will", "did", "do", "does", "have", "has", "had", "there", "here", "about", "after", "before", |
| } |
|
|
|
|
| def _clean_term(term: str) -> str: |
| return str(term).lower().replace("##", "").strip(".,!?;:'\"()[]{}<>/\\|`~@#$%^&*_+=") |
|
|
|
|
| def _is_informative_term(term: str) -> bool: |
| clean = _clean_term(term) |
| if len(clean) < 2 or clean in _STOPWORDS: |
| return False |
| return any(ch.isalpha() for ch in clean) |
|
|
|
|
| def _dedupe_terms(terms, top_k: int = 6): |
| seen, out = set(), [] |
| for term, score in terms: |
| clean = _clean_term(term) |
| if not _is_informative_term(clean) or clean in seen: |
| continue |
| seen.add(clean) |
| out.append((clean, float(score))) |
| if len(out) >= top_k: |
| break |
| return out |
|
|
| def _lexical_evidence_scores(tokens, aspect_idx: int): |
| aspect = cfg.ASPECTS[aspect_idx] |
| aspect_terms = _ASPECT_EVIDENCE_KEYWORDS.get(aspect, set()) |
| scores = [] |
| prev = "" |
| for tok in tokens: |
| clean = str(tok).lower().replace("##", "") |
| clean = clean.strip(".,!?;:'\"()[]{}") |
| if not clean or clean.startswith("["): |
| scores.append(0.0) |
| prev = clean |
| continue |
| score = 0.0 |
| if clean in aspect_terms: |
| score += 1.0 |
| if clean in _SENTIMENT_EVIDENCE_KEYWORDS: |
| score += 0.55 |
| if prev in {"not", "no", "never", "too", "very", "really"} and score > 0: |
| score += 0.25 |
| scores.append(score) |
| prev = clean |
| return np.asarray(scores, dtype=np.float32) |
|
|
|
|
| def _has_visible_scores(scores) -> bool: |
| arr = np.asarray(scores, dtype=np.float64) |
| return bool(arr.size and np.isfinite(arr).any() and float(np.nanmax(np.abs(arr))) > 1e-12) |
| |
| |
| |
|
|
| def _ig_for_aspect(model, tokenizer, meta_encoder, row, device, |
| aspect_idx: int, n_steps: int = 30): |
| """Gradient x embedding attribution for one aspect head. |
| |
| Captum's LayerIntegratedGradients can be brittle with wrapped transformer |
| modules in notebooks. This path keeps the same goal, but computes a direct |
| first-order attribution from the input embeddings, then falls back to |
| lexical evidence if gradients are unavailable. |
| """ |
| model.eval() |
| text = str(row["full_text"]) |
| enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True, |
| padding="max_length", return_tensors="pt") |
| input_ids = enc["input_ids"].to(device) |
| attention_mask = enc["attention_mask"].to(device) |
| tokens = tokenizer.convert_ids_to_tokens(input_ids[0].detach().cpu().numpy().tolist()) |
| mask = attention_mask[0].detach().cpu().numpy().astype(bool) |
|
|
| meta_vec = torch.from_numpy( |
| meta_encoder.transform(pd.DataFrame([row])) |
| ).float().to(device) |
|
|
| try: |
| model.zero_grad(set_to_none=True) |
| embeds = model.bert.embeddings(input_ids=input_ids) |
| embeds = embeds.detach().requires_grad_(True) |
| bert_out = model.bert( |
| inputs_embeds=embeds, |
| attention_mask=attention_mask, |
| return_dict=True, |
| ) |
| text_vec = bert_out.last_hidden_state[:, 0, :] |
|
|
| |
| meta_tokens = model.meta_tokenizer(meta_vec) |
| meta_for_concat = meta_vec.clone() |
| numeric_start = cfg.META_TFIDF_DIM |
| meta_for_concat[:, numeric_start:] = ( |
| meta_for_concat[:, numeric_start:] * cfg.META_NUMERIC_TOKEN_SCALE |
| ) |
| concat_meta = model.concat_meta_mlp(meta_for_concat) |
| concat_base = model.concat_norm( |
| model.concat_fusion(torch.cat([text_vec, concat_meta], dim=-1)) |
| ) |
| aspect_fused, _, _ = model.aspect_fusion(text_vec, meta_tokens) |
| text_expanded = text_vec.unsqueeze(1).expand(-1, model.num_aspects, -1) |
| aspect_delta = aspect_fused - text_expanded |
| scale = torch.clamp(model.cross_residual_scale, 0.0, 1.0) |
| aspect_repr = model.aspect_refine_norm( |
| concat_base.unsqueeze(1) + scale * aspect_delta |
| ) |
| logits = model.heads.forward_per_aspect(aspect_repr) |
| target_class = int(logits[0, aspect_idx, :].argmax(dim=-1).item()) |
| target_logit = logits[0, aspect_idx, target_class] |
| target_logit.backward() |
|
|
| grad = embeds.grad.detach()[0] |
| attr = (grad * embeds.detach()[0]).sum(dim=-1).detach().cpu().numpy() |
| attr = np.abs(attr) * mask |
| if not _has_visible_scores(attr): |
| attr = _lexical_evidence_scores(tokens, aspect_idx) * mask |
| return tokens, attr, mask, target_class |
| except Exception as e: |
| logger.warning("Gradient attribution failed for aspect %s: %s; using lexical evidence.", |
| cfg.ASPECTS[aspect_idx], e) |
| with torch.no_grad(): |
| out = model(input_ids, attention_mask, meta_vec) |
| target_class = int(out["logits"][0, aspect_idx, :].argmax(dim=-1).item()) |
| attr = _lexical_evidence_scores(tokens, aspect_idx) * mask |
| return tokens, attr, mask, target_class |
|
|
| def _attention_for_aspect(model, tokenizer, meta_encoder, row, device, |
| aspect_idx: int): |
| """Fallback when captum is not installed: use the model's own |
| cross-attention output as a coarse word-level proxy is impossible |
| (cross-attn is over meta tokens, not BERT tokens), so we return BERT |
| self-attention from [CLS] -> tokens as a rough text attribution. |
| """ |
| model.eval() |
| text = str(row["full_text"]) |
| enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True, |
| padding="max_length", return_tensors="pt") |
| input_ids = enc["input_ids"].to(device) |
| attention_mask = enc["attention_mask"].to(device) |
| meta_vec = torch.from_numpy( |
| meta_encoder.transform(pd.DataFrame([row])) |
| ).float().to(device) |
|
|
| with torch.no_grad(): |
| out = model(input_ids, attention_mask, meta_vec, output_attentions=True) |
|
|
| pred_class = int(out["logits"][0, aspect_idx, :].argmax().item()) |
| tokens = tokenizer.convert_ids_to_tokens(input_ids[0].cpu().numpy().tolist()) |
| mask = attention_mask[0].cpu().numpy().astype(bool) |
|
|
| bert_attn = out.get("bert_attentions") |
| if bert_attn: |
| last = bert_attn[-1][0] |
| cls_attn = last[:, 0, :].mean(0).cpu().numpy() |
| else: |
| cls_attn = np.zeros_like(mask, dtype=np.float32) |
| cls_attn = np.asarray(cls_attn, dtype=np.float32) * mask |
| if not _has_visible_scores(cls_attn): |
| cls_attn = _lexical_evidence_scores(tokens, aspect_idx) * mask |
| return tokens, cls_attn, mask, pred_class |
|
|
|
|
| |
| |
| |
|
|
| def _meta_token_names_from_output(out, attn): |
| names = out.get("meta_token_names") |
| if names is not None: |
| return list(names) |
| arr = np.asarray(attn) |
| width = int(arr.shape[-1]) if arr.ndim else int(arr.size) |
| if width == 3: |
| return SEMANTIC_META_TOKEN_NAMES |
| return [f"meta_chunk_{i+1}" for i in range(width)] |
|
|
|
|
| def get_meta_attention(model, tokenizer, meta_encoder, row, device): |
| """Return display meta attention, predictions, names, and optional per-aspect attention.""" |
| text = str(row["full_text"]) |
| enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True, |
| padding="max_length", return_tensors="pt") |
| input_ids = enc["input_ids"].to(device) |
| attention_mask = enc["attention_mask"].to(device) |
| meta_vec = torch.from_numpy( |
| meta_encoder.transform(pd.DataFrame([row])) |
| ).float().to(device) |
| with torch.no_grad(): |
| out = model(input_ids, attention_mask, meta_vec) |
|
|
| raw_attn = out["meta_attn_weights"][0].cpu().numpy() |
| token_names = _meta_token_names_from_output(out, raw_attn) |
| if raw_attn.ndim == 1: |
| display_attn = raw_attn |
| aspect_attn = None |
| else: |
| aspect_attn = raw_attn |
| if "global_meta_attn_weights" in out and out["global_meta_attn_weights"] is not None: |
| display_attn = out["global_meta_attn_weights"][0].cpu().numpy() |
| else: |
| display_attn = raw_attn.mean(axis=0) |
|
|
| preds = out["logits"][0].argmax(dim=-1).cpu().numpy() |
| return display_attn, preds, token_names, aspect_attn |
|
|
| |
| |
| |
|
|
| def _render_meta_bar_html(meta_attn: np.ndarray, token_names: Optional[List[str]] = None) -> str: |
| """Render a tiny inline bar chart for one metadata-attention vector.""" |
| meta_attn = np.asarray(meta_attn, dtype=np.float64).reshape(-1) |
| token_names = token_names or META_TOKEN_NAMES |
| norm = _normalize(meta_attn) |
| parts = ['<div style="display:flex;gap:6px;align-items:end;height:60px;' |
| 'margin-top:8px;">'] |
| for i, (n, raw) in enumerate(zip(norm, meta_attn)): |
| name = token_names[i] if i < len(token_names) else f"meta_{i+1}" |
| h = int(8 + 50 * float(n)) |
| parts.append( |
| f'<div style="text-align:center;width:80px;">' |
| f'<div style="background:#5b8def;height:{h}px;' |
| f'border-radius:3px 3px 0 0;"></div>' |
| f'<div style="font-size:11px;margin-top:2px;color:#666;">' |
| f'{html_lib.escape(name)}<br/>{float(raw):.3f}</div></div>' |
| ) |
| parts.append('</div>') |
| return "".join(parts) |
|
|
|
|
| def _render_meta_matrix_html(meta_attn_by_aspect, token_names: Optional[List[str]] = None) -> str: |
| """Render aspect-specific metadata attention as compact rows.""" |
| if meta_attn_by_aspect is None: |
| return "" |
| arr = np.asarray(meta_attn_by_aspect, dtype=np.float64) |
| if arr.ndim != 2: |
| return "" |
| token_names = token_names or META_TOKEN_NAMES |
| parts = ['<div style="margin-top:8px;display:grid;gap:6px;">'] |
| for i, aspect in enumerate(cfg.ASPECTS): |
| if i >= arr.shape[0]: |
| break |
| weights = arr[i] |
| norm = _normalize(weights) |
| parts.append('<div style="display:flex;align-items:center;gap:8px;">') |
| parts.append(f'<div style="width:95px;font-size:12px;font-weight:600;">{aspect}</div>') |
| for j, (n, raw) in enumerate(zip(norm, weights)): |
| name = token_names[j] if j < len(token_names) else f"meta_{j+1}" |
| w = int(35 + 75 * float(n)) |
| parts.append( |
| f'<div title="{html_lib.escape(name)}: {float(raw):.3f}" ' |
| f'style="background:#dbe7ff;border-left:4px solid #5b8def;' |
| f'width:{w}px;padding:2px 4px;border-radius:4px;font-size:11px;">' |
| f'{html_lib.escape(name)} {float(raw):.2f}</div>' |
| ) |
| parts.append('</div>') |
| parts.append('</div>') |
| return "".join(parts) |
|
|
| def _render_meta_summary_html(meta_summary: Dict[str, str]) -> str: |
| rows = [] |
| for name in ["features", "categories", "numeric"]: |
| value = html_lib.escape(str(meta_summary.get(name, ""))) |
| rows.append( |
| f'<div><b>{html_lib.escape(name)}:</b> ' |
| f'<span style="color:#444">{value or "not available"}</span></div>' |
| ) |
| return '<div class="meta-box">' + "".join(rows) + '</div>' |
|
|
|
|
| def build_explanation_html(examples: List[dict], output_path: Path): |
| parts = [ |
| "<!doctype html><html><head><meta charset='utf-8'>", |
| "<title>ACSA + Meta Fusion Explanations</title>", |
| "<style>", |
| "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 1180px; margin: 24px auto; padding: 0 16px; color: #222; }", |
| ".example { border: 1px solid #d8d8d8; padding: 16px; margin-bottom: 24px; border-radius: 8px; background:#fff; }", |
| ".meta { font-size: 13px; color: #666; margin-bottom: 8px; }", |
| ".meta-box { background:#f6f8fb; padding:10px 12px; border-radius:6px; font-size:13px; line-height:1.55; margin:10px 0 12px; }", |
| ".review-box { background:#fafafa; padding:10px 12px; border-radius:6px; margin-bottom:12px; line-height:1.55; }", |
| ".aspect-row { padding: 10px 0; border-bottom: 1px dashed #e6e6e6; line-height: 1.7; }", |
| ".aspect-label { display: inline-block; min-width: 130px; font-weight: 700; }", |
| ".pred-pos { color: #21834a; font-weight: 700; }", |
| ".pred-neg { color: #bd2f24; font-weight: 700; }", |
| ".pred-na { color: #777; font-weight: 600; }", |
| ".evidence { margin-top:6px; font-size:13px; color:#444; }", |
| ".chip { display:inline-block; background:#eef2ff; border:1px solid #d6def8; padding:1px 6px; border-radius:999px; margin:1px 3px 1px 0; font-size:12px; }", |
| "h1 { margin-bottom: 8px; } h2 { margin-top: 8px; }", |
| ".legend { background: #f8f8f8; padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height:1.5; }", |
| ".section-h { font-weight:700;margin-top:16px;color:#333;font-size:14px; }", |
| "</style></head><body>", |
| "<h1>Aspect-Level Sentiment with Metadata Fusion: Explanations</h1>", |
| '<div class="legend">' |
| 'Highlights show the strongest text evidence for each aspect. ' |
| '<span style="background:rgba(80,180,110,0.7);padding:2px 6px;border-radius:3px">green</span> = Positive, ' |
| '<span style="background:rgba(255,80,80,0.7);padding:2px 6px;border-radius:3px">red</span> = Negative, ' |
| '<span style="background:rgba(150,150,150,0.7);padding:2px 6px;border-radius:3px">grey</span> = Not_Mentioned. ' |
| 'Metadata rows show which source the model attends to for each aspect.' |
| '</div>', |
| ] |
| for i, ex in enumerate(examples): |
| parts.append(f'<div class="example"><h2>Example {i+1}</h2>') |
| parts.append(f'<div class="meta">Rating: {ex.get("rating", "?")} | Category: {html_lib.escape(str(ex.get("category", "?")))}</div>') |
| parts.append(f'<div class="review-box">{html_lib.escape(ex["text"])}</div>') |
| parts.append(_render_meta_summary_html(ex.get("meta_summary", {}))) |
|
|
| parts.append('<div class="section-h">Per-aspect prediction, text evidence, and metadata source</div>') |
| token_names = ex.get("meta_token_names") |
| meta_by_aspect = ex.get("meta_attn_by_aspect") |
| for idx, a in enumerate(ex["aspects"]): |
| pred = a["pred_label"] |
| name = cfg.LABEL_NAMES[pred] |
| css = {0: "pred-na", 1: "pred-pos", 2: "pred-neg"}[pred] |
| parts.append('<div class="aspect-row">') |
| parts.append(f'<span class="aspect-label">{a["aspect"]}:</span> <span class="{css}">{name}</span>') |
|
|
| if "tokens" in a and "scores" in a: |
| terms = a.get("top_terms") or [] |
| chips = "".join(f'<span class="chip">{html_lib.escape(t)}</span>' for t, _ in terms[:6]) |
| if chips: |
| parts.append(f'<div class="evidence"><b>Top text evidence:</b> {chips}</div>') |
| tok_html = _render_token_html(a["tokens"], a["scores"], pred) |
| parts.append(f'<div style="margin-top:6px">{tok_html}</div>') |
|
|
| if meta_by_aspect is not None and idx < len(meta_by_aspect): |
| source, weight = _top_meta_source(meta_by_aspect[idx], token_names) |
| else: |
| source, weight = _top_meta_source(ex.get("meta_attn", []), token_names) |
| quality = a.get("evidence_quality", "weak") |
| explanation = a.get("explanation_sentence", "") |
| if source: |
| parts.append( |
| f'<div class="evidence"><b>Top metadata source:</b> ' |
| f'<span class="chip">{html_lib.escape(source)} {weight:.3f}</span> ' |
| f'<span class="chip">quality: {html_lib.escape(quality)}</span></div>' |
| ) |
| if explanation: |
| parts.append( |
| f'<div class="evidence"><b>Explanation:</b> ' |
| f'{html_lib.escape(explanation)}</div>' |
| ) |
| parts.append('</div>') |
|
|
| parts.append('<div class="section-h">Metadata cross-attention (global / averaged)</div>') |
| parts.append(_render_meta_bar_html(np.asarray(ex["meta_attn"]), token_names)) |
| if meta_by_aspect is not None: |
| parts.append('<div class="section-h">Aspect-specific metadata attention</div>') |
| parts.append(_render_meta_matrix_html(np.asarray(meta_by_aspect), token_names)) |
| parts.append('</div>') |
|
|
| parts.append('</body></html>') |
| output_path = Path(output_path) |
| output_path.write_text("\n".join(parts), encoding="utf-8") |
| logger.info("Wrote explanation HTML to %s", output_path) |
|
|
|
|
| def write_explanation_summary(examples: List[dict], output_path: Path): |
| rows = [] |
| for ex_idx, ex in enumerate(examples, start=1): |
| token_names = ex.get("meta_token_names") |
| meta_by_aspect = ex.get("meta_attn_by_aspect") |
| for aspect_idx, aspect_data in enumerate(ex.get("aspects", [])): |
| if meta_by_aspect is not None and aspect_idx < len(meta_by_aspect): |
| source, weight = _top_meta_source(meta_by_aspect[aspect_idx], token_names) |
| else: |
| source, weight = _top_meta_source(ex.get("meta_attn", []), token_names) |
| pred_name = cfg.LABEL_NAMES[int(aspect_data.get("pred_label", 0))] |
| rows.append({ |
| "example": ex_idx, |
| "rating": ex.get("rating"), |
| "category": ex.get("category"), |
| "aspect": aspect_data.get("aspect"), |
| "pred_label": pred_name, |
| "evidence_quality": aspect_data.get("evidence_quality", "weak"), |
| "explanation_sentence": aspect_data.get("explanation_sentence", ""), |
| "top_text_terms": "; ".join(t for t, _ in aspect_data.get("top_terms", [])[:8]), |
| "top_meta_source": source, |
| "top_meta_weight": weight, |
| "features": ex.get("meta_summary", {}).get("features", ""), |
| "categories": ex.get("meta_summary", {}).get("categories", ""), |
| "numeric": ex.get("meta_summary", {}).get("numeric", ""), |
| "text": ex.get("text", ""), |
| }) |
| pd.DataFrame(rows).to_csv(output_path, index=False) |
| logger.info("Wrote explanation summary to %s", output_path) |
|
|
| |
| |
| |
|
|
| def explain_examples(test_df, n_examples: int = 8, method: str = "ig", |
| output_path: Optional[Path] = None, |
| checkpoint_dir: Optional[Path] = None, |
| meta_encoder: Optional[MetaEncoder] = None): |
| """Pick a mix of ratings from test_df and produce an explanation HTML.""" |
| if output_path is None: |
| output_path = cfg.REPORT_DIR / f"explanation_{method}.html" |
|
|
| model, tokenizer, meta_encoder, device = load_meta_acsa(checkpoint_dir, meta_encoder) |
|
|
| |
| per_rating = max(1, n_examples // 5) |
| chosen = [] |
| for r in [1, 2, 3, 4, 5]: |
| sub = test_df[test_df["rating"] == r] |
| if len(sub) > 0: |
| chosen.append(sub.sample(n=min(per_rating, len(sub)), |
| random_state=cfg.RANDOM_SEED)) |
| if not chosen: |
| chosen = [test_df.sample(n=min(n_examples, len(test_df)), |
| random_state=cfg.RANDOM_SEED)] |
| selected = pd.concat(chosen).head(n_examples).reset_index(drop=True) |
|
|
| try: |
| import captum |
| captum_ok = True |
| except ImportError: |
| logger.warning("captum not installed; falling back to BERT [CLS]-attention as proxy.") |
| captum_ok = False |
| method = "attention" |
|
|
| examples_data = [] |
| for _, row in selected.iterrows(): |
| meta_attn, _, meta_token_names, meta_attn_by_aspect = get_meta_attention(model, tokenizer, meta_encoder, row, device) |
| ex = { |
| "text": str(row["full_text"]), |
| "rating": int(row["rating"]), |
| "category": str(row.get("leaf_category", "")), |
| "meta_summary": _meta_summary_from_row(row), |
| "meta_attn": meta_attn.tolist(), |
| "meta_token_names": meta_token_names, |
| "meta_attn_by_aspect": (meta_attn_by_aspect.tolist() if meta_attn_by_aspect is not None else None), |
| "aspects": [], |
| } |
| for i, aspect in enumerate(cfg.ASPECTS): |
| try: |
| if method == "ig" and captum_ok: |
| tokens, attr, mask, pred = _ig_for_aspect( |
| model, tokenizer, meta_encoder, row, device, i, |
| ) |
| else: |
| tokens, attr, mask, pred = _attention_for_aspect( |
| model, tokenizer, meta_encoder, row, device, i, |
| ) |
| scores = np.abs(attr) * mask |
| display_scores = _normalize_for_display(scores) |
| top_terms = _top_terms(tokens, display_scores) |
| if meta_attn_by_aspect is not None and i < len(meta_attn_by_aspect): |
| source, weight = _top_meta_source(meta_attn_by_aspect[i], meta_token_names) |
| else: |
| source, weight = _top_meta_source(meta_attn, meta_token_names) |
| pred_name = cfg.LABEL_NAMES[int(pred)] |
| quality = _explanation_quality(top_terms, source, weight) |
| ex["aspects"].append({ |
| "aspect": aspect, "pred_label": int(pred), |
| "tokens": tokens, "scores": scores, |
| "top_terms": top_terms, |
| "evidence_quality": quality, |
| "explanation_sentence": _make_explanation_sentence( |
| aspect, pred_name, top_terms, source, weight, |
| ), |
| }) |
| except Exception as e: |
| logger.warning("Attribution failed for aspect %s: %s", aspect, e) |
| |
| ex["aspects"].append({"aspect": aspect, "pred_label": 0}) |
| examples_data.append(ex) |
|
|
| build_explanation_html(examples_data, output_path) |
| summary_path = Path(output_path).with_name(Path(output_path).stem + "_summary.csv") |
| write_explanation_summary(examples_data, summary_path) |
| return examples_data |
|
|
|
|
| |
| |
| |
|
|
| def plot_aspect_distribution(df_with_preds: pd.DataFrame, |
| output_path: Optional[Path] = None): |
| """Bar chart of Positive/Negative share per aspect (over mentioned rows).""" |
| if output_path is None: |
| output_path = cfg.REPORT_DIR / "aspect_distribution.png" |
| rows = [] |
| for a in cfg.ASPECTS: |
| col = f"pred_{a}" if f"pred_{a}" in df_with_preds.columns else f"aspect_{a}" |
| if col not in df_with_preds.columns: |
| continue |
| vc = df_with_preds[col].value_counts() |
| n_pos = int(vc.get(1, 0)); n_neg = int(vc.get(2, 0)) |
| n_mentioned = n_pos + n_neg |
| if n_mentioned == 0: |
| continue |
| rows.append({"aspect": a, "positive": n_pos / n_mentioned, |
| "negative": n_neg / n_mentioned, "n_mentioned": n_mentioned}) |
| if not rows: |
| logger.warning("No data to plot for aspect distribution.") |
| return None |
|
|
| dfp = pd.DataFrame(rows) |
| fig, ax = plt.subplots(figsize=(10, 5)) |
| x = np.arange(len(dfp)); w = 0.35 |
| ax.bar(x - w/2, dfp["positive"], w, label="Positive share", color="#2a9d4f") |
| ax.bar(x + w/2, dfp["negative"], w, label="Negative share", color="#c0392b") |
| ax.set_xticks(x); ax.set_xticklabels(dfp["aspect"], rotation=20) |
| ax.set_ylabel("Share among mentioned reviews") |
| ax.set_title("Aspect-level sentiment distribution") |
| ax.legend() |
| for i, r in dfp.iterrows(): |
| ax.text(i, max(r["positive"], r["negative"]) + 0.02, |
| f"n={r['n_mentioned']}", ha="center", fontsize=9) |
| plt.tight_layout(); plt.savefig(output_path, dpi=120); plt.close() |
| logger.info("Saved aspect distribution plot to %s", output_path) |
| return dfp |
|
|
|
|
| def plot_category_aspect_heatmap(agg_df: pd.DataFrame, metric: str = "negative_share", |
| output_path: Optional[Path] = None): |
| if agg_df is None or agg_df.empty: |
| return None |
| if output_path is None: |
| output_path = cfg.REPORT_DIR / f"category_aspect_{metric}.png" |
| pivot = agg_df.pivot(index="category", columns="aspect", values=metric) |
| fig, ax = plt.subplots(figsize=(10, max(4, len(pivot) * 0.4))) |
| cmap = "RdYlGn_r" if "negative" in metric else "RdYlGn" |
| sns.heatmap(pivot, annot=True, fmt=".2f", cmap=cmap, ax=ax, |
| cbar_kws={"label": metric}) |
| ax.set_title(f"{metric.replace('_', ' ').title()} by category 闂?aspect") |
| plt.tight_layout(); plt.savefig(output_path, dpi=120); plt.close() |
| logger.info("Saved heatmap to %s", output_path) |
| return pivot |
|
|