Lavender825 commited on
Commit
38136ed
·
1 Parent(s): be504c1

Deploy 3W 0624 BERT metadata fusion model

Browse files
Files changed (45) hide show
  1. .gitattributes +1 -35
  2. README.md +16 -8
  3. __pycache__/app.cpython-314.pyc +0 -0
  4. app.py +630 -0
  5. checkpoints/meta_acsa/best.pt +3 -0
  6. checkpoints/meta_acsa/history.json +62 -0
  7. checkpoints/meta_acsa/tokenizer/tokenizer.json +0 -0
  8. checkpoints/meta_acsa/tokenizer/tokenizer_config.json +15 -0
  9. data/aspect_dict.json +354 -0
  10. data/meta_encoder.pkl +0 -0
  11. reports/ablation_summary.json +122 -0
  12. reports/evaluation_comparison.json +689 -0
  13. requirements.txt +7 -0
  14. src/__init__.py +0 -0
  15. src/__pycache__/__init__.cpython-314.pyc +0 -0
  16. src/__pycache__/ablation.cpython-314.pyc +0 -0
  17. src/__pycache__/aspect_dict.cpython-314.pyc +0 -0
  18. src/__pycache__/baselines.cpython-314.pyc +0 -0
  19. src/__pycache__/config.cpython-314.pyc +0 -0
  20. src/__pycache__/data_download.cpython-314.pyc +0 -0
  21. src/__pycache__/dataset.cpython-314.pyc +0 -0
  22. src/__pycache__/evaluator.cpython-314.pyc +0 -0
  23. src/__pycache__/explainer.cpython-314.pyc +0 -0
  24. src/__pycache__/inference.cpython-314.pyc +0 -0
  25. src/__pycache__/meta_encoder.cpython-314.pyc +0 -0
  26. src/__pycache__/models.cpython-314.pyc +0 -0
  27. src/__pycache__/preprocess.cpython-314.pyc +0 -0
  28. src/__pycache__/trainer.cpython-314.pyc +0 -0
  29. src/__pycache__/utils.cpython-314.pyc +0 -0
  30. src/__pycache__/weak_labeling.cpython-314.pyc +0 -0
  31. src/ablation.py +383 -0
  32. src/aspect_dict.py +387 -0
  33. src/baselines.py +79 -0
  34. src/config.py +96 -0
  35. src/data_download.py +216 -0
  36. src/dataset.py +115 -0
  37. src/evaluator.py +326 -0
  38. src/explainer.py +726 -0
  39. src/inference.py +264 -0
  40. src/meta_encoder.py +208 -0
  41. src/models.py +613 -0
  42. src/preprocess.py +137 -0
  43. src/trainer.py +365 -0
  44. src/utils.py +17 -0
  45. src/weak_labeling.py +523 -0
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.pt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,21 @@
1
- ---
2
- title: Sentiment Analysis 3W0624
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ACSA Clothing Sentiment
3
+ emoji: "👕"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
+ python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
+ # Aspect-Level Sentiment Analysis for Clothing Reviews
14
+
15
+ Interactive demo and research dashboard for a BERT metadata-fusion ACSA model trained on 30,000 clothing reviews.
16
+
17
+ ## Included inference artifacts
18
+
19
+ - Proposed metadata-fusion checkpoint and tokenizer
20
+ - Matching `meta_encoder.pkl`
21
+ - Overall, aspect-level, and ablation evaluation reports
__pycache__/app.cpython-314.pyc ADDED
Binary file (46.2 kB). View file
 
app.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Polished Hugging Face Spaces frontend for ACSA Clothing.
2
+
3
+ Two-end design:
4
+ 1. Customer-facing review analyzer.
5
+ 2. Research dashboard for metrics, ablation, and explanation evidence.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import html
11
+ import json
12
+ import re
13
+ import traceback
14
+ from functools import lru_cache
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Iterable, List, Tuple
17
+
18
+ # Keep the app robust across Spaces images where hub/gradio versions differ.
19
+ try:
20
+ import huggingface_hub as _hf_hub
21
+
22
+ if not hasattr(_hf_hub, "HfFolder"):
23
+ class _HfFolderCompat:
24
+ @staticmethod
25
+ def get_token():
26
+ try:
27
+ return _hf_hub.get_token()
28
+ except Exception:
29
+ return None
30
+
31
+ @staticmethod
32
+ def save_token(token):
33
+ return None
34
+
35
+ @staticmethod
36
+ def delete_token():
37
+ return None
38
+
39
+ _hf_hub.HfFolder = _HfFolderCompat
40
+ except Exception:
41
+ pass
42
+
43
+ import gradio as gr
44
+
45
+ from src import config as cfg
46
+ from src.inference import AspectPredictor
47
+
48
+ ROOT = Path(__file__).resolve().parent
49
+ REPORT_DIR = ROOT / "reports"
50
+ CHECKPOINT_DIR = ROOT / "checkpoints" / "meta_acsa"
51
+ CHECKPOINT_PATH = CHECKPOINT_DIR / "best.pt"
52
+ META_ENCODER_PATH = ROOT / "data" / "meta_encoder.pkl"
53
+
54
+ ASPECT_DESCRIPTIONS = {
55
+ "SIZE": "Fit, length, sizing accuracy, runs large or small.",
56
+ "MATERIAL": "Fabric feel, thickness, breathability, comfort.",
57
+ "QUALITY": "Workmanship, durability, seams, washing performance.",
58
+ "APPEARANCE": "Color, pattern, image consistency, visual look.",
59
+ "STYLE": "Cut, silhouette, fashionability, styling appeal.",
60
+ "VALUE": "Price fairness, worthiness, return or repurchase intent.",
61
+ }
62
+
63
+ LABEL_COLORS = {
64
+ "Positive": "#15803d",
65
+ "Negative": "#b91c1c",
66
+ "Not_Mentioned": "#64748b",
67
+ }
68
+
69
+ LABEL_BG = {
70
+ "Positive": "#dcfce7",
71
+ "Negative": "#fee2e2",
72
+ "Not_Mentioned": "#f1f5f9",
73
+ }
74
+
75
+ ASPECT_KEYWORDS = {
76
+ "SIZE": ["size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length", "runs small", "runs large", "too small", "too large"],
77
+ "MATERIAL": ["material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "breathable", "comfortable"],
78
+ "QUALITY": ["quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "broken", "ripped", "tear", "torn", "defect", "zipper", "button"],
79
+ "APPEARANCE": ["look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design", "pattern", "shown"],
80
+ "STYLE": ["style", "stylish", "flattering", "casual", "formal", "fashion", "silhouette", "cut", "shape"],
81
+ "VALUE": ["price", "worth", "value", "money", "expensive", "overpriced", "cheap", "discount", "buy again", "purchase", "return"],
82
+ }
83
+
84
+ NEGATIVE_CUES = {
85
+ "bad", "poor", "terrible", "awful", "cheap", "scratchy", "itchy", "thin", "small", "large", "tight", "loose",
86
+ "broke", "broken", "ripped", "torn", "overpriced", "return", "returned", "waste", "disappointed", "uncomfortable",
87
+ "shrunk", "defective", "wrong",
88
+ }
89
+
90
+ POSITIVE_CUES = {
91
+ "love", "loved", "great", "good", "perfect", "excellent", "nice", "soft", "comfortable", "beautiful", "pretty",
92
+ "flattering", "worth", "recommend", "amazing", "stylish", "durable", "cute", "buy again",
93
+ }
94
+
95
+ SIZE_NEGATIVE_PATTERNS = [
96
+ r"\bruns?\s+(a\s+little\s+)?small\b",
97
+ r"\bruns?\s+(a\s+little\s+)?large\b",
98
+ r"\btoo\s+(small|large|big|tight|loose)\b",
99
+ r"\b(size|fit|fits|fitting)\b[^.!?]{0,35}\b(small|large|big|tight|loose)\b",
100
+ ]
101
+
102
+ def _safe_json(path: Path) -> Dict[str, Any]:
103
+ if not path.exists():
104
+ return {}
105
+ try:
106
+ return json.loads(path.read_text(encoding="utf-8"))
107
+ except Exception:
108
+ return {}
109
+
110
+
111
+ def _safe_csv(path: Path, limit: int = 80) -> List[Dict[str, str]]:
112
+ if not path.exists():
113
+ return []
114
+ try:
115
+ with path.open(newline="", encoding="utf-8") as f:
116
+ return list(csv.DictReader(f))[:limit]
117
+ except Exception:
118
+ return []
119
+
120
+
121
+ def _pct(value: Any) -> str:
122
+ try:
123
+ return f"{100 * float(value):.2f}%"
124
+ except Exception:
125
+ return "-"
126
+
127
+
128
+ def _num(value: Any, default: float = 0.0) -> float:
129
+ try:
130
+ if value in (None, ""):
131
+ return default
132
+ return float(value)
133
+ except Exception:
134
+ return default
135
+
136
+
137
+ def _int(value: Any, default: int = 0) -> int:
138
+ try:
139
+ if value in (None, ""):
140
+ return default
141
+ return int(float(value))
142
+ except Exception:
143
+ return default
144
+
145
+
146
+ def _model_issues() -> List[str]:
147
+ issues = []
148
+ if not CHECKPOINT_PATH.exists():
149
+ issues.append("Missing checkpoints/meta_acsa/best.pt")
150
+ elif CHECKPOINT_PATH.stat().st_size < 1_000_000:
151
+ size = CHECKPOINT_PATH.stat().st_size
152
+ try:
153
+ head = CHECKPOINT_PATH.read_bytes()[:160].decode("utf-8", errors="replace")
154
+ except Exception as exc:
155
+ head = f"<could not read file head: {exc}>"
156
+ issues.append(
157
+ "checkpoints/meta_acsa/best.pt looks too small. "
158
+ f"Runtime path={CHECKPOINT_PATH}; runtime size={size} bytes; "
159
+ f"file head={head!r}. "
160
+ "It may be a Git LFS/Xet pointer or the running Space has not synced the real 303 MB model file."
161
+ )
162
+ if not (CHECKPOINT_DIR / "tokenizer").exists():
163
+ issues.append("Missing checkpoints/meta_acsa/tokenizer/")
164
+ if not META_ENCODER_PATH.exists():
165
+ issues.append("Missing data/meta_encoder.pkl")
166
+ return issues
167
+
168
+
169
+ @lru_cache(maxsize=1)
170
+ def _predictor() -> AspectPredictor:
171
+ issues = _model_issues()
172
+ if issues:
173
+ raise FileNotFoundError("; ".join(issues))
174
+ return AspectPredictor(checkpoint_dir=CHECKPOINT_DIR)
175
+
176
+
177
+ def _meta(features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any) -> Dict[str, Any]:
178
+ return {
179
+ "features_text": features or "",
180
+ "categories_text": categories or "",
181
+ "price": _num(price),
182
+ "average_rating": _num(avg_rating),
183
+ "rating_number": _int(rating_count),
184
+ }
185
+
186
+
187
+ def _status_html() -> str:
188
+ issues = _model_issues()
189
+ if not issues:
190
+ return "<div class='status ok'><b>Model ready.</b> All required inference artifacts are available.</div>"
191
+ items = "".join(f"<li>{html.escape(x)}</li>" for x in issues)
192
+ return f"<div class='status warn'><b>Model artifacts need attention.</b><ul>{items}</ul></div>"
193
+
194
+
195
+ def _error_html(title: str, exc: Exception) -> str:
196
+ msg = html.escape(str(exc) or exc.__class__.__name__)
197
+ detail = html.escape(traceback.format_exc(limit=2))
198
+ return (
199
+ "<div class='error-card'>"
200
+ f"<h3>{html.escape(title)}</h3>"
201
+ f"<p>{msg}</p>"
202
+ f"<details><summary>Technical detail</summary><pre>{detail}</pre></details>"
203
+ "</div>"
204
+ )
205
+
206
+
207
+
208
+ def _contains_phrase(text: str, phrase: str) -> bool:
209
+ if " " in phrase:
210
+ return phrase in text
211
+ return re.search(r"\b" + re.escape(phrase) + r"\b", text) is not None
212
+
213
+
214
+ def _extract_evidence(review_text: str) -> Dict[str, List[str]]:
215
+ text = str(review_text or "")
216
+ lower = text.lower()
217
+ result: Dict[str, List[str]] = {}
218
+ for aspect, terms in ASPECT_KEYWORDS.items():
219
+ hits: List[str] = []
220
+ for term in terms:
221
+ if _contains_phrase(lower, term.lower()) and term not in hits:
222
+ hits.append(term)
223
+ cue_hits = []
224
+ for cue in sorted(POSITIVE_CUES | NEGATIVE_CUES):
225
+ if _contains_phrase(lower, cue.lower()):
226
+ cue_hits.append(cue)
227
+ if hits:
228
+ merged = hits + [c for c in cue_hits if c not in hits]
229
+ else:
230
+ merged = []
231
+ result[aspect] = merged[:8]
232
+ return result
233
+
234
+
235
+ def _confidence(result: Dict[str, Any], aspect: str) -> float:
236
+ details = result.get("aspect_details", {}) or {}
237
+ val = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None
238
+ return _num(val, 0.0)
239
+
240
+
241
+ def _apply_demo_calibration(result: Dict[str, Any], review_text: str) -> Dict[str, Any]:
242
+ """Conservative display-time calibration for the HF demo.
243
+
244
+ It prevents overall positivity from spreading to aspects with no textual
245
+ evidence, while preserving the raw model confidence in aspect_details.
246
+ """
247
+ aspects = dict(result.get("aspects", {}) or {})
248
+ evidence = _extract_evidence(review_text)
249
+ raw_aspects = dict(aspects)
250
+ lower = str(review_text or "").lower()
251
+
252
+ for aspect in cfg.ASPECTS:
253
+ hits = evidence.get(aspect, [])
254
+ if aspect == "SIZE" and any(re.search(p, lower) for p in SIZE_NEGATIVE_PATTERNS):
255
+ aspects[aspect] = "Negative"
256
+ if not hits:
257
+ evidence[aspect] = ["size issue"]
258
+ continue
259
+ if not hits:
260
+ # A metadata-only model signal is useful internally, but the demo
261
+ # should not claim aspect sentiment without review evidence.
262
+ aspects[aspect] = "Not_Mentioned"
263
+ elif aspects.get(aspect) == "Not_Mentioned" and _confidence(result, aspect) >= 0.90:
264
+ # Keep strong mention signals visible, but do not force sentiment.
265
+ aspects[aspect] = raw_aspects.get(aspect, "Not_Mentioned")
266
+
267
+ result["raw_model_aspects"] = raw_aspects
268
+ result["aspects"] = aspects
269
+ result["text_evidence_by_aspect"] = evidence
270
+ result["display_calibrated"] = True
271
+ return result
272
+ def _highlight_evidence(evidence: Any) -> str:
273
+ if not evidence:
274
+ return "<span class='muted'>No explicit text evidence returned.</span>"
275
+ if isinstance(evidence, str):
276
+ evidence = [evidence]
277
+ chips = []
278
+ for item in list(evidence)[:5]:
279
+ text = str(item).strip()
280
+ if text:
281
+ chips.append(f"<span class='chip'>{html.escape(text)}</span>")
282
+ return "".join(chips) or "<span class='muted'>No explicit text evidence returned.</span>"
283
+
284
+
285
+ def _aspect_cards(result: Dict[str, Any]) -> str:
286
+ aspects = result.get("aspects", {}) or {}
287
+ evidence = result.get("text_evidence_by_aspect", {}) or {}
288
+ details = result.get("aspect_details", {}) or {}
289
+ parts = ["<div class='aspect-grid'>"]
290
+ for aspect in cfg.ASPECTS:
291
+ label = aspects.get(aspect, "Not_Mentioned")
292
+ color = LABEL_COLORS.get(label, "#64748b")
293
+ bg = LABEL_BG.get(label, "#f1f5f9")
294
+ conf = details.get(aspect, {}).get("confidence") if isinstance(details, dict) else None
295
+ conf_html = f"<span class='confidence'>conf {float(conf):.2f}</span>" if isinstance(conf, (int, float)) else ""
296
+ parts.append(
297
+ f"<section class='aspect-card' style='border-top-color:{color}'>"
298
+ f"<div class='aspect-top'><b>{html.escape(aspect)}</b>{conf_html}</div>"
299
+ f"<div class='label-pill' style='color:{color};background:{bg}'>{html.escape(label)}</div>"
300
+ f"<p>{html.escape(ASPECT_DESCRIPTIONS.get(aspect, ''))}</p>"
301
+ f"<div class='evidence'>{_highlight_evidence(evidence.get(aspect))}</div>"
302
+ "</section>"
303
+ )
304
+ parts.append("</div>")
305
+ return "".join(parts)
306
+
307
+
308
+ def _flatten_attention(result: Dict[str, Any]) -> List[Tuple[str, str, float]]:
309
+ rows: List[Tuple[str, str, float]] = []
310
+ by_aspect = result.get("meta_attention_by_aspect", {}) or {}
311
+ if isinstance(by_aspect, dict):
312
+ for aspect, weights in by_aspect.items():
313
+ if isinstance(weights, dict):
314
+ for name, val in weights.items():
315
+ rows.append((str(aspect), str(name), _num(val)))
316
+ global_attn = result.get("meta_attention", {}) or {}
317
+ if isinstance(global_attn, dict):
318
+ for name, val in global_attn.items():
319
+ rows.append(("GLOBAL", str(name), _num(val)))
320
+ return rows
321
+
322
+
323
+ def _attention_html(result: Dict[str, Any]) -> str:
324
+ rows = _flatten_attention(result)
325
+ if not rows:
326
+ return "<div class='empty'>No metadata attention returned by this checkpoint.</div>"
327
+ max_v = max(v for _, _, v in rows) or 1.0
328
+ grouped: Dict[str, List[Tuple[str, float]]] = {}
329
+ for aspect, name, val in rows:
330
+ grouped.setdefault(aspect, []).append((name, val))
331
+ parts = ["<div class='attn-grid'>"]
332
+ for aspect in ["GLOBAL"] + [a for a in cfg.ASPECTS if a in grouped]:
333
+ if aspect not in grouped:
334
+ continue
335
+ parts.append(f"<section class='attn-card'><h4>{html.escape(aspect)} metadata use</h4>")
336
+ for name, val in sorted(grouped[aspect], key=lambda x: x[1], reverse=True):
337
+ width = max(5, min(100, 100 * val / max_v))
338
+ parts.append(
339
+ f"<div class='bar-row'><span>{html.escape(name)}</span>"
340
+ f"<div class='bar-bg'><div class='bar-fill' style='width:{width:.1f}%'></div></div>"
341
+ f"<em>{val:.3f}</em></div>"
342
+ )
343
+ parts.append("</section>")
344
+ parts.append("</div>")
345
+ return "".join(parts)
346
+
347
+
348
+ def _overall_label(result: Dict[str, Any]) -> str:
349
+ overall = result.get("overall")
350
+ if isinstance(overall, dict):
351
+ return str(overall.get("label") or overall.get("prediction") or overall)
352
+ if overall:
353
+ return str(overall)
354
+ labels = list((result.get("aspects") or {}).values())
355
+ if labels.count("Negative") >= 1:
356
+ return "Negative"
357
+ if labels.count("Positive") >= 1:
358
+ return "Positive"
359
+ return "Neutral / Not enough aspect signal"
360
+
361
+
362
+ def _diagnosis_html(result: Dict[str, Any]) -> str:
363
+ aspects = result.get("aspects", {}) or {}
364
+ overall = _overall_label(result)
365
+ positives = [a for a, v in aspects.items() if v == "Positive"]
366
+ negatives = [a for a, v in aspects.items() if v == "Negative"]
367
+ meta_summary = result.get("metadata_summary") or {}
368
+ top_meta = result.get("top_meta_source") or "-"
369
+ risk = "High" if len(negatives) >= 2 or overall == "Negative" else "Medium" if negatives else "Low"
370
+ return (
371
+ "<section class='diagnosis'>"
372
+ "<h3>Business Diagnosis</h3>"
373
+ f"<div class='diag-row'><span>Overall</span><b>{html.escape(overall)}</b></div>"
374
+ f"<div class='diag-row'><span>Risk level</span><b>{risk}</b></div>"
375
+ f"<div class='diag-row'><span>Positive drivers</span><b>{html.escape(', '.join(positives) or '-')}</b></div>"
376
+ f"<div class='diag-row'><span>Negative drivers</span><b>{html.escape(', '.join(negatives) or '-')}</b></div>"
377
+ f"<div class='diag-row'><span>Top metadata source</span><b>{html.escape(str(top_meta))}</b></div>"
378
+ f"<p>{html.escape(_recommendation(positives, negatives))}</p>"
379
+ f"<details><summary>Metadata summary</summary><pre>{html.escape(json.dumps(meta_summary, ensure_ascii=False, indent=2))}</pre></details>"
380
+ "</section>"
381
+ )
382
+
383
+
384
+ def _recommendation(positives: Iterable[str], negatives: Iterable[str]) -> str:
385
+ positives = list(positives)
386
+ negatives = list(negatives)
387
+ if negatives:
388
+ return "Prioritize the negative aspects in PDP copy, sizing guidance, QA checks, and return-reason analysis."
389
+ if positives:
390
+ return "Use the positive aspect signals as merchandising highlights and customer-facing selling points."
391
+ return "The review does not contain a strong aspect-level sentiment signal; use it as low-priority qualitative feedback."
392
+
393
+
394
+ def analyze_review(review: str, features: str, categories: str, price: Any, avg_rating: Any, rating_count: Any):
395
+ if not str(review or "").strip():
396
+ msg = "<div class='empty'>Enter a customer review, then click Analyze Review.</div>"
397
+ return msg, "", "", []
398
+ try:
399
+ result = _predictor().predict(review, _meta(features, categories, price, avg_rating, rating_count))
400
+ result = _apply_demo_calibration(result, review)
401
+ rows = [[a, result.get("aspects", {}).get(a, "Not_Mentioned"), ASPECT_DESCRIPTIONS.get(a, ""), ", ".join(result.get("text_evidence_by_aspect", {}).get(a, []))] for a in cfg.ASPECTS]
402
+ return _aspect_cards(result), _attention_html(result), _diagnosis_html(result), rows
403
+ except Exception as exc:
404
+ return _error_html("Prediction could not run", exc), "", "", []
405
+
406
+
407
+ def _dashboard_payload() -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
408
+ comparison = _safe_json(REPORT_DIR / "evaluation_comparison.json")
409
+ overall = comparison.get("overall_3class_comparison", {}) if isinstance(comparison, dict) else {}
410
+ proposed = comparison.get("proposed_per_aspect", {}) if isinstance(comparison, dict) else {}
411
+ no_meta = comparison.get("acsa_no_meta_per_aspect", {}) if isinstance(comparison, dict) else {}
412
+ return overall, proposed, no_meta
413
+
414
+
415
+ def _metric_cards() -> str:
416
+ overall, proposed, no_meta = _dashboard_payload()
417
+ proposed_overall = overall.get("Proposed_BERT_Meta_Fusion__overall_head", {})
418
+ proposed_aspect = proposed.get("overall", {})
419
+ no_meta_aspect = no_meta.get("overall", {})
420
+ metadata_acc_gain = _num(proposed_aspect.get("mean_accuracy")) - _num(no_meta_aspect.get("mean_accuracy"))
421
+ cards = [
422
+ ("Proposed Overall Accuracy", _pct(proposed_overall.get("accuracy")), "Joint-trained overall head"),
423
+ ("Proposed Aspect Accuracy", _pct(proposed_aspect.get("mean_accuracy")), "Mean accuracy across six aspects"),
424
+ ("Metadata Accuracy Gain", f"{metadata_acc_gain:+.4f}", "Proposed vs baseline without metadata"),
425
+ ]
426
+ return "<div class='metric-card-grid'>" + "".join(
427
+ f"<section class='metric-card'><span>{html.escape(k)}</span><b>{html.escape(v)}</b><em>{html.escape(s)}</em></section>"
428
+ for k, v, s in cards
429
+ ) + "</div>"
430
+
431
+
432
+ def _overall_accuracy_rows() -> List[List[Any]]:
433
+ overall, _, _ = _dashboard_payload()
434
+ selected = [
435
+ ("TF-IDF + Logistic Regression", "Baseline_1_TFIDF_LogReg"),
436
+ ("BERT Overall Classifier", "Baseline_2_BERT_overall_3class"),
437
+ ("Proposed BERT + Metadata Fusion", "Proposed_BERT_Meta_Fusion__overall_head"),
438
+ ]
439
+ return [[
440
+ label,
441
+ round(_num(overall.get(key, {}).get("macro_f1")), 4),
442
+ round(_num(overall.get(key, {}).get("accuracy")), 4),
443
+ ] for label, key in selected]
444
+
445
+
446
+ def _aspect_overall_rows() -> List[List[Any]]:
447
+ _, proposed, no_meta = _dashboard_payload()
448
+ selected = [
449
+ ("BERT ACSA (text-only)", no_meta.get("overall", {})),
450
+ ("Proposed BERT + Metadata Fusion", proposed.get("overall", {})),
451
+ ]
452
+ return [[label, round(_num(block.get("mean_macro_f1")), 4), round(_num(block.get("mean_accuracy")), 4)] for label, block in selected]
453
+
454
+
455
+ def _aspect_rows() -> List[List[Any]]:
456
+ _, proposed_block, no_meta_block = _dashboard_payload()
457
+ proposed = proposed_block.get("per_aspect", {})
458
+ no_meta = no_meta_block.get("per_aspect", {})
459
+ rows = []
460
+ for aspect in cfg.ASPECTS:
461
+ proposed_metrics = proposed.get(aspect, {})
462
+ text_metrics = no_meta.get(aspect, {})
463
+ rows.append([
464
+ aspect,
465
+ round(_num(text_metrics.get("macro_f1")), 4),
466
+ round(_num(proposed_metrics.get("macro_f1")), 4),
467
+ round(_num(proposed_metrics.get("macro_f1")) - _num(text_metrics.get("macro_f1")), 4),
468
+ round(_num(text_metrics.get("accuracy")), 4),
469
+ round(_num(proposed_metrics.get("accuracy")), 4),
470
+ round(_num(proposed_metrics.get("accuracy")) - _num(text_metrics.get("accuracy")), 4),
471
+ ])
472
+ return rows
473
+
474
+
475
+ def _ablation_rows() -> List[List[Any]]:
476
+ data = _safe_json(REPORT_DIR / "ablation_summary.json")
477
+ labels = [
478
+ ("Proposed", "Proposed"),
479
+ ("w/o Text Metadata (keep numeric)", "A1_no_text_meta"),
480
+ ("w/o Numeric Metadata", "A2_no_numeric_meta"),
481
+ ("w/o Cross-Attention (Concat Fusion)", "A3_concat_fusion"),
482
+ ]
483
+ return [
484
+ [label, round(_num(data.get(key, {}).get("mean_macro_f1")), 4), round(_num(data.get(key, {}).get("mean_accuracy")), 4)]
485
+ for label, key in labels
486
+ ]
487
+
488
+
489
+ def refresh_dashboard():
490
+ return _metric_cards(), _overall_accuracy_rows(), _aspect_overall_rows(), _aspect_rows(), _ablation_rows()
491
+
492
+
493
+ CSS = """'
494
+ :root { --orange: #f97316; --ink: #111827; --muted: #64748b; --line: #e5e7eb; --soft: #f8fafc; }
495
+ .gradio-container { max-width: 1240px !important; margin: 0 auto !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
496
+ .hero { margin: 24px 0 18px; padding: 28px 30px; border: 1px solid #dbe3ee; border-radius: 8px; background: linear-gradient(135deg, #ffffff 0%, #f4f7fb 100%); }
497
+ .hero h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.15; color: var(--ink); }
498
+ .hero p { margin: 0; color: #475569; font-size: 14px; }
499
+ .status { margin: 0 0 14px; padding: 12px 14px; border-radius: 8px; font-size: 13px; }
500
+ .status.ok { color: #166534; background: #ecfdf5; border: 1px solid #bbf7d0; }
501
+ .status.warn { color: #9a3412; background: #fff7ed; border: 1px solid #fed7aa; }
502
+ .input-panel, .output-panel { box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #fff; }
503
+ .output-panel { min-height: 470px; }
504
+ .result-placeholder { display: grid; min-height: 230px; place-items: center; border: 1px dashed #cbd5e1; border-radius: 8px; color: var(--muted); background: #fbfdff; font-size: 14px; }
505
+ .diagnosis-placeholder { margin-top: 12px; padding: 14px; border: 1px solid #e2e8f0; border-radius: 8px; color: var(--muted); background: #f8fafc; font-size: 13px; }
506
+ .analyze-button, .analyze-button button { display: flex !important; width: 100% !important; min-height: 44px !important; margin-top: 12px !important; align-items: center !important; justify-content: center !important; background: #f97316 !important; border: 1px solid #f97316 !important; border-radius: 6px !important; color: #ffffff !important; font-weight: 700 !important; }
507
+ .analyze-button button { color: #ffffff !important; }
508
+ .aspect-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
509
+ .aspect-card { border: 1px solid var(--line); border-top: 4px solid #94a3b8; border-radius: 8px; padding: 13px; background: #fff; min-height: 150px; }
510
+ .aspect-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ink); }
511
+ .confidence { color: var(--muted); font-size: 12px; font-weight: 500; }
512
+ .label-pill { display: inline-block; margin: 9px 0 8px; padding: 4px 9px; border-radius: 999px; font-weight: 800; font-size: 13px; }
513
+ .aspect-card p { margin: 0 0 8px; color: #475569; font-size: 12px; min-height: 34px; }
514
+ .chip { display: inline-block; margin: 2px 5px 2px 0; padding: 3px 8px; border: 1px solid #c7d2fe; border-radius: 999px; background: #eef2ff; color: #3730a3; font-size: 12px; }
515
+ .muted, .empty { color: var(--muted); font-size: 13px; }
516
+ .attn-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 4px; }
517
+ .attn-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fff; }
518
+ .attn-card h4 { margin: 0 0 8px; font-size: 14px; color: var(--ink); }
519
+ .bar-row { display: grid; grid-template-columns: 112px 1fr 48px; gap: 8px; align-items: center; margin-top: 7px; color: #475569; font-size: 12px; }
520
+ .bar-bg { height: 9px; border-radius: 999px; background: #edf2f7; overflow: hidden; }
521
+ .bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #f97316, #2563eb); }
522
+ .diagnosis { border: 1px solid #cbd5e1; border-radius: 8px; padding: 15px; background: #fbfdff; }
523
+ .diagnosis h3 { margin: 0 0 10px; font-size: 18px; }
524
+ .diag-row { display: grid; grid-template-columns: 150px 1fr; padding: 7px 0; border-bottom: 1px solid #e8edf4; color: #475569; }
525
+ .diag-row b { color: var(--ink); }
526
+ .diagnosis p { margin: 12px 0 0; color: #334155; }
527
+ .error-card { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 8px; padding: 14px; }
528
+ .error-card h3 { margin: 0 0 6px; }
529
+ .error-card pre { white-space: pre-wrap; color: #7f1d1d; }
530
+ .metric-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
531
+ .metric-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 14px; }
532
+ .metric-card span { display: block; color: var(--muted); font-size: 13px; }
533
+ .metric-card b { display: block; margin: 6px 0; font-size: 26px; color: var(--ink); }
534
+ .metric-card em { color: #475569; font-style: normal; font-size: 12px; }
535
+ @media (max-width: 860px) { .aspect-grid, .attn-grid, .metric-card-grid { grid-template-columns: 1fr; } .hero h1 { font-size: 24px; } }
536
+ """
537
+
538
+
539
+ def build_app() -> gr.Blocks:
540
+ with gr.Blocks(css=CSS, title="ACSA Clothing Sentiment") as demo:
541
+ gr.HTML(
542
+ "<div class='hero'><h1>Aspect-Level Sentiment Analysis for Clothing Reviews</h1>"
543
+ "<p>BERT + metadata fusion for SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE.</p></div>"
544
+ )
545
+ gr.HTML(_status_html())
546
+
547
+ with gr.Tabs():
548
+ with gr.Tab("Customer Demo"):
549
+ with gr.Row(equal_height=False):
550
+ with gr.Column(scale=5):
551
+ with gr.Group(elem_classes="input-panel"):
552
+ review = gr.Textbox(
553
+ label="Customer review",
554
+ lines=4,
555
+ value="The size runs really small, I ordered an XL but it fits like a Medium. The fabric feels soft and the print looks great.",
556
+ )
557
+ features = gr.Textbox(label="Product features", value="100% Cotton, Slim Fit, Machine Wash Cold")
558
+ categories = gr.Textbox(label="Product categories", value="Clothing > Men > T-Shirts > Graphic Tees")
559
+ with gr.Group(elem_classes="input-panel"):
560
+ with gr.Row():
561
+ price = gr.Number(label="Price", value=19.99)
562
+ avg_rating = gr.Number(label="Average rating", value=4.2)
563
+ rating_count = gr.Number(label="Rating count", value=312, precision=0)
564
+ run = gr.Button("Analyze Review", variant="primary", elem_classes=["analyze-button"])
565
+ with gr.Column(scale=7, elem_classes="output-panel"):
566
+ pred = gr.HTML("<div class='result-placeholder'>Enter a review and click Analyze Review to generate aspect-level results.</div>")
567
+ diagnosis = gr.HTML("<div class='diagnosis-placeholder'>The business diagnosis and metadata contribution summary will appear here.</div>")
568
+ with gr.Row():
569
+ attn = gr.HTML(label="Metadata attention")
570
+ table = gr.Dataframe(
571
+ headers=["Aspect", "Prediction", "Business Meaning", "Evidence"],
572
+ label="Aspect-level result table",
573
+ interactive=False,
574
+ )
575
+ run.click(analyze_review, [review, features, categories, price, avg_rating, rating_count], [pred, attn, diagnosis, table])
576
+
577
+ with gr.Tab("Research Dashboard"):
578
+ gr.Markdown("### Research Dashboard")
579
+ gr.Markdown("Metrics are loaded from the bundled 30K experiment reports. All values use the same held-out test split.")
580
+ refresh = gr.Button("Refresh Dashboard", variant="primary")
581
+ metric_cards = gr.HTML(_metric_cards())
582
+
583
+ gr.Markdown("#### 1. Overall Sentiment Accuracy")
584
+ overall_accuracy_table = gr.Dataframe(
585
+ headers=["Model", "Macro-F1", "Accuracy"],
586
+ value=_overall_accuracy_rows(),
587
+ label="Overall: two baselines and the Proposed overall head",
588
+ interactive=False,
589
+ )
590
+
591
+ gr.Markdown("#### 2. Aspect-Level Overall Comparison")
592
+ aspect_overall_table = gr.Dataframe(
593
+ headers=["Model", "Mean Macro-F1", "Mean Accuracy"],
594
+ value=_aspect_overall_rows(),
595
+ label="Text-only BERT ACSA versus metadata fusion",
596
+ interactive=False,
597
+ )
598
+
599
+ gr.Markdown("#### 3. Six-Aspect Comparison")
600
+ aspect_table = gr.Dataframe(
601
+ headers=["Aspect", "Text-only F1", "Proposed F1", "F1 Delta", "Text-only Acc", "Proposed Acc", "Acc Delta"],
602
+ value=_aspect_rows(),
603
+ label="SIZE, MATERIAL, QUALITY, APPEARANCE, STYLE, and VALUE",
604
+ interactive=False,
605
+ )
606
+
607
+ gr.Markdown("#### 4. Ablation Study")
608
+ ablation_table = gr.Dataframe(
609
+ headers=["Variant", "Mean Macro-F1", "Mean Accuracy"],
610
+ value=_ablation_rows(),
611
+ label="Contribution of text metadata, numeric metadata, and cross-attention",
612
+ interactive=False,
613
+ )
614
+ refresh.click(
615
+ refresh_dashboard,
616
+ outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table],
617
+ )
618
+ demo.load(
619
+ refresh_dashboard,
620
+ outputs=[metric_cards, overall_accuracy_table, aspect_overall_table, aspect_table, ablation_table],
621
+ )
622
+ return demo
623
+
624
+
625
+ if __name__ == "__main__":
626
+ build_app().launch()
627
+
628
+
629
+
630
+
checkpoints/meta_acsa/best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1da47bfa11ea92ac848c4e6fe5ce0d40d8ca9e27b6293352f29f63ea89d4184e
3
+ size 475307814
checkpoints/meta_acsa/history.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "epoch": 1,
4
+ "train_loss": 0.9039416376044527,
5
+ "f1_SIZE": 0.7230990555118656,
6
+ "acc_SIZE": 0.7271111111111112,
7
+ "f1_MATERIAL": 0.6654843509317002,
8
+ "acc_MATERIAL": 0.7,
9
+ "f1_QUALITY": 0.585804771941796,
10
+ "acc_QUALITY": 0.6326666666666667,
11
+ "f1_APPEARANCE": 0.6350415468329998,
12
+ "acc_APPEARANCE": 0.7264444444444444,
13
+ "f1_STYLE": 0.5774676931456201,
14
+ "acc_STYLE": 0.6557777777777778,
15
+ "f1_VALUE": 0.6057815207573581,
16
+ "acc_VALUE": 0.7835555555555556,
17
+ "macro_f1_mean": 0.6321131565202233,
18
+ "accuracy_mean": 0.7042592592592593,
19
+ "overall_macro_f1": 0.6143208856855018,
20
+ "overall_accuracy": 0.8904444444444445
21
+ },
22
+ {
23
+ "epoch": 2,
24
+ "train_loss": 0.6360917452946929,
25
+ "f1_SIZE": 0.7759371773100997,
26
+ "acc_SIZE": 0.7846666666666666,
27
+ "f1_MATERIAL": 0.721426536509325,
28
+ "acc_MATERIAL": 0.7633333333333333,
29
+ "f1_QUALITY": 0.7274409373517191,
30
+ "acc_QUALITY": 0.7806666666666666,
31
+ "f1_APPEARANCE": 0.6959203657359841,
32
+ "acc_APPEARANCE": 0.7986666666666666,
33
+ "f1_STYLE": 0.7077716078981217,
34
+ "acc_STYLE": 0.7997777777777778,
35
+ "f1_VALUE": 0.619469941784759,
36
+ "acc_VALUE": 0.7753333333333333,
37
+ "macro_f1_mean": 0.7079944277650014,
38
+ "accuracy_mean": 0.7837407407407407,
39
+ "overall_macro_f1": 0.7092251136942966,
40
+ "overall_accuracy": 0.8906666666666667
41
+ },
42
+ {
43
+ "epoch": 3,
44
+ "train_loss": 0.5028722208143642,
45
+ "f1_SIZE": 0.7946539664969906,
46
+ "acc_SIZE": 0.8051111111111111,
47
+ "f1_MATERIAL": 0.7450986969151404,
48
+ "acc_MATERIAL": 0.788,
49
+ "f1_QUALITY": 0.7390603308103053,
50
+ "acc_QUALITY": 0.7853333333333333,
51
+ "f1_APPEARANCE": 0.7166504945528084,
52
+ "acc_APPEARANCE": 0.8162222222222222,
53
+ "f1_STYLE": 0.7235185865127159,
54
+ "acc_STYLE": 0.8153333333333334,
55
+ "f1_VALUE": 0.6484376637531702,
56
+ "acc_VALUE": 0.8142222222222222,
57
+ "macro_f1_mean": 0.7279032898401886,
58
+ "accuracy_mean": 0.804037037037037,
59
+ "overall_macro_f1": 0.693463952578998,
60
+ "overall_accuracy": 0.89
61
+ }
62
+ ]
checkpoints/meta_acsa/tokenizer/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
checkpoints/meta_acsa/tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": true,
5
+ "is_local": false,
6
+ "local_files_only": false,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }
data/aspect_dict.json ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "SIZE": [
3
+ "alterations",
4
+ "baggy",
5
+ "big",
6
+ "boxy fit",
7
+ "breathable",
8
+ "comfort",
9
+ "easy",
10
+ "fit",
11
+ "fit perfectly",
12
+ "fits",
13
+ "fitting",
14
+ "gift",
15
+ "great fit",
16
+ "hand",
17
+ "high",
18
+ "huge",
19
+ "keep",
20
+ "large",
21
+ "length",
22
+ "lightweight",
23
+ "long",
24
+ "look",
25
+ "loose",
26
+ "loose fit",
27
+ "narrow",
28
+ "only",
29
+ "oversized",
30
+ "perfect fit",
31
+ "petite",
32
+ "please",
33
+ "pockets",
34
+ "poor fit",
35
+ "pull",
36
+ "quality",
37
+ "right size",
38
+ "roomy",
39
+ "runs big",
40
+ "runs large",
41
+ "runs small",
42
+ "runs tight",
43
+ "short",
44
+ "shorter than expected",
45
+ "size",
46
+ "size down",
47
+ "size up",
48
+ "sizing",
49
+ "small",
50
+ "snug",
51
+ "spandex",
52
+ "stretched out",
53
+ "tight",
54
+ "tiny",
55
+ "too big",
56
+ "too large",
57
+ "too long",
58
+ "too loose",
59
+ "too short",
60
+ "too small",
61
+ "too tight",
62
+ "true to size",
63
+ "tts",
64
+ "waist",
65
+ "wide",
66
+ "wrong size"
67
+ ],
68
+ "MATERIAL": [
69
+ "back",
70
+ "blend",
71
+ "breathable",
72
+ "bulky",
73
+ "cashmere",
74
+ "cheap fabric",
75
+ "cheap material",
76
+ "comfort",
77
+ "comfortable fabric",
78
+ "cotton",
79
+ "denim",
80
+ "durable",
81
+ "easy",
82
+ "elastic",
83
+ "fabric",
84
+ "fabric feels",
85
+ "feel",
86
+ "feeling",
87
+ "feels",
88
+ "fleece",
89
+ "hand",
90
+ "heavier",
91
+ "heavier than expected",
92
+ "heavy",
93
+ "heavy fabric",
94
+ "high",
95
+ "itchy",
96
+ "keep",
97
+ "leather",
98
+ "lightweight",
99
+ "linen",
100
+ "long",
101
+ "look",
102
+ "lycra",
103
+ "material",
104
+ "nylon",
105
+ "only",
106
+ "please",
107
+ "pockets",
108
+ "polyester",
109
+ "pull",
110
+ "quality",
111
+ "rayon",
112
+ "rough",
113
+ "scratchy",
114
+ "see through",
115
+ "see-through",
116
+ "sheer",
117
+ "silk",
118
+ "size",
119
+ "soft",
120
+ "spandex",
121
+ "stiff",
122
+ "stretch",
123
+ "stretchy",
124
+ "suede",
125
+ "synthetic",
126
+ "texture",
127
+ "thick",
128
+ "thin",
129
+ "too heavy",
130
+ "transparent",
131
+ "uncomfortable fabric",
132
+ "velvet",
133
+ "warm",
134
+ "weight",
135
+ "wool"
136
+ ],
137
+ "QUALITY": [
138
+ "after wash",
139
+ "after washing",
140
+ "breathable",
141
+ "broke",
142
+ "broken",
143
+ "button",
144
+ "buttons",
145
+ "cheaply made",
146
+ "comfort",
147
+ "construction",
148
+ "craftsmanship",
149
+ "defect",
150
+ "defective",
151
+ "durability",
152
+ "durable",
153
+ "easy",
154
+ "faded",
155
+ "faded after wash",
156
+ "fading",
157
+ "falling apart",
158
+ "fell apart",
159
+ "first wash",
160
+ "flimsy",
161
+ "fragile",
162
+ "hand",
163
+ "hem",
164
+ "high",
165
+ "holes",
166
+ "keep",
167
+ "lasted",
168
+ "lasts",
169
+ "lightweight",
170
+ "long",
171
+ "look",
172
+ "loose thread",
173
+ "loose threads",
174
+ "made well",
175
+ "only",
176
+ "pilled",
177
+ "pilling",
178
+ "please",
179
+ "pockets",
180
+ "poor quality",
181
+ "poorly made",
182
+ "pull",
183
+ "quality",
184
+ "rip",
185
+ "ripped",
186
+ "seam",
187
+ "seams",
188
+ "shedding",
189
+ "shrink",
190
+ "shrunk",
191
+ "shrunk after wash",
192
+ "shrunk after washing",
193
+ "size",
194
+ "spandex",
195
+ "stitch",
196
+ "stitching",
197
+ "sturdy",
198
+ "tear",
199
+ "threads",
200
+ "tore",
201
+ "torn",
202
+ "well constructed",
203
+ "well made",
204
+ "well-made",
205
+ "zipper"
206
+ ],
207
+ "APPEARANCE": [
208
+ "as advertised",
209
+ "as described",
210
+ "as pictured",
211
+ "as shown",
212
+ "beautiful",
213
+ "breathable",
214
+ "bright",
215
+ "color",
216
+ "color is off",
217
+ "color off",
218
+ "colors",
219
+ "colour",
220
+ "comfort",
221
+ "darker",
222
+ "design",
223
+ "different color",
224
+ "different from photo",
225
+ "different from picture",
226
+ "dull",
227
+ "easy",
228
+ "faded color",
229
+ "gift",
230
+ "gorgeous",
231
+ "graphic",
232
+ "hand",
233
+ "high",
234
+ "keep",
235
+ "lighter",
236
+ "lightweight",
237
+ "logo",
238
+ "long",
239
+ "looked like",
240
+ "looks like",
241
+ "matches the picture",
242
+ "misleading photo",
243
+ "off color",
244
+ "only",
245
+ "pattern",
246
+ "photo",
247
+ "picture",
248
+ "please",
249
+ "print",
250
+ "pull",
251
+ "quality",
252
+ "shade",
253
+ "size",
254
+ "spandex",
255
+ "stunning",
256
+ "tone",
257
+ "true color",
258
+ "true to color",
259
+ "ugly",
260
+ "vibrant"
261
+ ],
262
+ "STYLE": [
263
+ "boho",
264
+ "breathable",
265
+ "casual",
266
+ "chic",
267
+ "classic",
268
+ "collar",
269
+ "comfort",
270
+ "compliment",
271
+ "complimented",
272
+ "compliments",
273
+ "cut",
274
+ "cute",
275
+ "easy",
276
+ "elegant",
277
+ "fashion",
278
+ "fashionable",
279
+ "flattering",
280
+ "formal",
281
+ "hand",
282
+ "high",
283
+ "lightweight",
284
+ "long",
285
+ "look",
286
+ "modern",
287
+ "neckline",
288
+ "only",
289
+ "outfit",
290
+ "please",
291
+ "pockets",
292
+ "preppy",
293
+ "professional",
294
+ "pull",
295
+ "quality",
296
+ "received compliments",
297
+ "shape",
298
+ "shapeless",
299
+ "silhouette",
300
+ "size",
301
+ "sleeves",
302
+ "spandex",
303
+ "style",
304
+ "stylish",
305
+ "trendy",
306
+ "unflattering",
307
+ "vintage"
308
+ ],
309
+ "VALUE": [
310
+ "affordable",
311
+ "bargain",
312
+ "breathable",
313
+ "cheap price",
314
+ "comfort",
315
+ "cost",
316
+ "discount",
317
+ "easy",
318
+ "exchanged",
319
+ "expensive",
320
+ "for the price",
321
+ "for this price",
322
+ "gift",
323
+ "good deal",
324
+ "great deal",
325
+ "hand",
326
+ "high",
327
+ "keep",
328
+ "lightweight",
329
+ "long",
330
+ "money",
331
+ "money back",
332
+ "money well spent",
333
+ "not worth",
334
+ "not worth it",
335
+ "only",
336
+ "overpriced",
337
+ "please",
338
+ "price",
339
+ "pricey",
340
+ "pull",
341
+ "quality",
342
+ "refund",
343
+ "return",
344
+ "returning",
345
+ "size",
346
+ "spandex",
347
+ "value",
348
+ "value for money",
349
+ "waste of money",
350
+ "wasted money",
351
+ "worth",
352
+ "worth it"
353
+ ]
354
+ }
data/meta_encoder.pkl ADDED
Binary file (5.27 kB). View file
 
reports/ablation_summary.json ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Proposed": {
3
+ "mean_macro_f1": 0.7181058563683197,
4
+ "mean_accuracy": 0.7976666666666666,
5
+ "per_aspect": {
6
+ "SIZE": {
7
+ "macro_f1": 0.7995265557571497,
8
+ "accuracy": 0.8084444444444444
9
+ },
10
+ "MATERIAL": {
11
+ "macro_f1": 0.7375608350812924,
12
+ "accuracy": 0.7748888888888888
13
+ },
14
+ "QUALITY": {
15
+ "macro_f1": 0.7225683708431877,
16
+ "accuracy": 0.7782222222222223
17
+ },
18
+ "APPEARANCE": {
19
+ "macro_f1": 0.686600260772051,
20
+ "accuracy": 0.8044444444444444
21
+ },
22
+ "STYLE": {
23
+ "macro_f1": 0.7160335416761091,
24
+ "accuracy": 0.806
25
+ },
26
+ "VALUE": {
27
+ "macro_f1": 0.6463455740801278,
28
+ "accuracy": 0.814
29
+ }
30
+ }
31
+ },
32
+ "A1_no_text_meta": {
33
+ "mean_macro_f1": 0.6776628048319866,
34
+ "mean_accuracy": 0.7492962962962962,
35
+ "per_aspect": {
36
+ "SIZE": {
37
+ "macro_f1": 0.740598233449823,
38
+ "accuracy": 0.7404444444444445
39
+ },
40
+ "MATERIAL": {
41
+ "macro_f1": 0.7266970223932114,
42
+ "accuracy": 0.766
43
+ },
44
+ "QUALITY": {
45
+ "macro_f1": 0.6823502460160048,
46
+ "accuracy": 0.7208888888888889
47
+ },
48
+ "APPEARANCE": {
49
+ "macro_f1": 0.6240159686247212,
50
+ "accuracy": 0.7208888888888889
51
+ },
52
+ "STYLE": {
53
+ "macro_f1": 0.6629232328067269,
54
+ "accuracy": 0.736
55
+ },
56
+ "VALUE": {
57
+ "macro_f1": 0.6293921257014325,
58
+ "accuracy": 0.8115555555555556
59
+ }
60
+ }
61
+ },
62
+ "A2_no_numeric_meta": {
63
+ "mean_macro_f1": 0.6921631412840942,
64
+ "mean_accuracy": 0.7652592592592593,
65
+ "per_aspect": {
66
+ "SIZE": {
67
+ "macro_f1": 0.7671582372002913,
68
+ "accuracy": 0.7706666666666667
69
+ },
70
+ "MATERIAL": {
71
+ "macro_f1": 0.6872063428009021,
72
+ "accuracy": 0.716
73
+ },
74
+ "QUALITY": {
75
+ "macro_f1": 0.7196709259733175,
76
+ "accuracy": 0.7668888888888888
77
+ },
78
+ "APPEARANCE": {
79
+ "macro_f1": 0.664309170886516,
80
+ "accuracy": 0.7724444444444445
81
+ },
82
+ "STYLE": {
83
+ "macro_f1": 0.6772600684297027,
84
+ "accuracy": 0.7562222222222222
85
+ },
86
+ "VALUE": {
87
+ "macro_f1": 0.6373741024138356,
88
+ "accuracy": 0.8093333333333333
89
+ }
90
+ }
91
+ },
92
+ "A3_concat_fusion": {
93
+ "mean_macro_f1": 0.7129304180101735,
94
+ "mean_accuracy": 0.7861851851851851,
95
+ "per_aspect": {
96
+ "SIZE": {
97
+ "macro_f1": 0.7743558111930783,
98
+ "accuracy": 0.7795555555555556
99
+ },
100
+ "MATERIAL": {
101
+ "macro_f1": 0.7265180298120851,
102
+ "accuracy": 0.7606666666666667
103
+ },
104
+ "QUALITY": {
105
+ "macro_f1": 0.7193048709446473,
106
+ "accuracy": 0.7726666666666666
107
+ },
108
+ "APPEARANCE": {
109
+ "macro_f1": 0.6751065643648765,
110
+ "accuracy": 0.7922222222222223
111
+ },
112
+ "STYLE": {
113
+ "macro_f1": 0.714064488352906,
114
+ "accuracy": 0.7906666666666666
115
+ },
116
+ "VALUE": {
117
+ "macro_f1": 0.6682327433934478,
118
+ "accuracy": 0.8213333333333334
119
+ }
120
+ }
121
+ }
122
+ }
reports/evaluation_comparison.json ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "overall_3class_comparison": {
3
+ "Baseline_1_TFIDF_LogReg": {
4
+ "macro_f1": 0.6065885032485002,
5
+ "accuracy": 0.8164444444444444
6
+ },
7
+ "Baseline_2_BERT_overall_3class": {
8
+ "macro_f1": 0.7179307477990319,
9
+ "accuracy": 0.9002222222222223
10
+ },
11
+ "Baseline_3_BERT_ACSA_no_meta__aggregated_to_overall": {
12
+ "macro_f1": 0.4793355643956521,
13
+ "accuracy": 0.6584444444444445
14
+ },
15
+ "Proposed_BERT_Meta_Fusion__overall_head": {
16
+ "macro_f1": 0.697132916417921,
17
+ "accuracy": 0.8935555555555555
18
+ },
19
+ "Proposed_BERT_Meta_Fusion__aggregated_to_overall_(reference)": {
20
+ "macro_f1": 0.5042876450582995,
21
+ "accuracy": 0.6951111111111111
22
+ }
23
+ },
24
+ "proposed_per_aspect": {
25
+ "per_aspect": {
26
+ "SIZE": {
27
+ "macro_f1": 0.7995265557571497,
28
+ "accuracy": 0.8084444444444444,
29
+ "confusion_matrix": [
30
+ [
31
+ 1048,
32
+ 138,
33
+ 72
34
+ ],
35
+ [
36
+ 285,
37
+ 1854,
38
+ 254
39
+ ],
40
+ [
41
+ 41,
42
+ 72,
43
+ 736
44
+ ]
45
+ ],
46
+ "report": {
47
+ "Not_Mentioned": {
48
+ "precision": 0.7627365356622998,
49
+ "recall": 0.8330683624801272,
50
+ "f1-score": 0.7963525835866262,
51
+ "support": 1258.0
52
+ },
53
+ "Positive": {
54
+ "precision": 0.8982558139534884,
55
+ "recall": 0.7747597158378604,
56
+ "f1-score": 0.8319497419789096,
57
+ "support": 2393.0
58
+ },
59
+ "Negative": {
60
+ "precision": 0.6930320150659134,
61
+ "recall": 0.866902237926973,
62
+ "f1-score": 0.7702773417059131,
63
+ "support": 849.0
64
+ },
65
+ "accuracy": 0.8084444444444444,
66
+ "macro avg": {
67
+ "precision": 0.7846747882272339,
68
+ "recall": 0.8249101054149869,
69
+ "f1-score": 0.7995265557571497,
70
+ "support": 4500.0
71
+ },
72
+ "weighted avg": {
73
+ "precision": 0.8216517567655182,
74
+ "recall": 0.8084444444444444,
75
+ "f1-score": 0.810362832403517,
76
+ "support": 4500.0
77
+ }
78
+ }
79
+ },
80
+ "MATERIAL": {
81
+ "macro_f1": 0.7375608350812924,
82
+ "accuracy": 0.7748888888888888,
83
+ "confusion_matrix": [
84
+ [
85
+ 885,
86
+ 196,
87
+ 99
88
+ ],
89
+ [
90
+ 307,
91
+ 2119,
92
+ 271
93
+ ],
94
+ [
95
+ 74,
96
+ 66,
97
+ 483
98
+ ]
99
+ ],
100
+ "report": {
101
+ "Not_Mentioned": {
102
+ "precision": 0.6990521327014217,
103
+ "recall": 0.75,
104
+ "f1-score": 0.723630417007359,
105
+ "support": 1180.0
106
+ },
107
+ "Positive": {
108
+ "precision": 0.8899622007559849,
109
+ "recall": 0.78568780126066,
110
+ "f1-score": 0.8345805435210712,
111
+ "support": 2697.0
112
+ },
113
+ "Negative": {
114
+ "precision": 0.5662368112543963,
115
+ "recall": 0.7752808988764045,
116
+ "f1-score": 0.6544715447154471,
117
+ "support": 623.0
118
+ },
119
+ "accuracy": 0.7748888888888888,
120
+ "macro avg": {
121
+ "precision": 0.7184170482372677,
122
+ "recall": 0.770322900045688,
123
+ "f1-score": 0.7375608350812924,
124
+ "support": 4500.0
125
+ },
126
+ "weighted avg": {
127
+ "precision": 0.7950833567640129,
128
+ "recall": 0.7748888888888888,
129
+ "f1-score": 0.7805518645117191,
130
+ "support": 4500.0
131
+ }
132
+ }
133
+ },
134
+ "QUALITY": {
135
+ "macro_f1": 0.7225683708431877,
136
+ "accuracy": 0.7782222222222223,
137
+ "confusion_matrix": [
138
+ [
139
+ 1939,
140
+ 419,
141
+ 163
142
+ ],
143
+ [
144
+ 290,
145
+ 1360,
146
+ 59
147
+ ],
148
+ [
149
+ 42,
150
+ 25,
151
+ 203
152
+ ]
153
+ ],
154
+ "report": {
155
+ "Not_Mentioned": {
156
+ "precision": 0.8538088947600176,
157
+ "recall": 0.7691392304641016,
158
+ "f1-score": 0.8092654424040067,
159
+ "support": 2521.0
160
+ },
161
+ "Positive": {
162
+ "precision": 0.753880266075388,
163
+ "recall": 0.7957870099473376,
164
+ "f1-score": 0.7742670082550527,
165
+ "support": 1709.0
166
+ },
167
+ "Negative": {
168
+ "precision": 0.4776470588235294,
169
+ "recall": 0.7518518518518519,
170
+ "f1-score": 0.5841726618705037,
171
+ "support": 270.0
172
+ },
173
+ "accuracy": 0.7782222222222223,
174
+ "macro avg": {
175
+ "precision": 0.695112073219645,
176
+ "recall": 0.7722593640877636,
177
+ "f1-score": 0.7225683708431877,
178
+ "support": 4500.0
179
+ },
180
+ "weighted avg": {
181
+ "precision": 0.793288512065599,
182
+ "recall": 0.7782222222222223,
183
+ "f1-score": 0.7824682480252049,
184
+ "support": 4500.0
185
+ }
186
+ }
187
+ },
188
+ "APPEARANCE": {
189
+ "macro_f1": 0.686600260772051,
190
+ "accuracy": 0.8044444444444444,
191
+ "confusion_matrix": [
192
+ [
193
+ 2098,
194
+ 260,
195
+ 145
196
+ ],
197
+ [
198
+ 273,
199
+ 1404,
200
+ 131
201
+ ],
202
+ [
203
+ 31,
204
+ 40,
205
+ 118
206
+ ]
207
+ ],
208
+ "report": {
209
+ "Not_Mentioned": {
210
+ "precision": 0.8734388009991674,
211
+ "recall": 0.8381941669996005,
212
+ "f1-score": 0.855453618756371,
213
+ "support": 2503.0
214
+ },
215
+ "Positive": {
216
+ "precision": 0.823943661971831,
217
+ "recall": 0.7765486725663717,
218
+ "f1-score": 0.7995444191343963,
219
+ "support": 1808.0
220
+ },
221
+ "Negative": {
222
+ "precision": 0.29949238578680204,
223
+ "recall": 0.6243386243386243,
224
+ "f1-score": 0.40480274442538594,
225
+ "support": 189.0
226
+ },
227
+ "accuracy": 0.8044444444444444,
228
+ "macro avg": {
229
+ "precision": 0.6656249495859335,
230
+ "recall": 0.7463604879681989,
231
+ "f1-score": 0.686600260772051,
232
+ "support": 4500.0
233
+ },
234
+ "weighted avg": {
235
+ "precision": 0.8294470045910427,
236
+ "recall": 0.8044444444444444,
237
+ "f1-score": 0.8140632080530185,
238
+ "support": 4500.0
239
+ }
240
+ }
241
+ },
242
+ "STYLE": {
243
+ "macro_f1": 0.7160335416761091,
244
+ "accuracy": 0.806,
245
+ "confusion_matrix": [
246
+ [
247
+ 1880,
248
+ 242,
249
+ 118
250
+ ],
251
+ [
252
+ 290,
253
+ 1585,
254
+ 150
255
+ ],
256
+ [
257
+ 39,
258
+ 34,
259
+ 162
260
+ ]
261
+ ],
262
+ "report": {
263
+ "Not_Mentioned": {
264
+ "precision": 0.851063829787234,
265
+ "recall": 0.8392857142857143,
266
+ "f1-score": 0.8451337379186334,
267
+ "support": 2240.0
268
+ },
269
+ "Positive": {
270
+ "precision": 0.8516926383664697,
271
+ "recall": 0.782716049382716,
272
+ "f1-score": 0.815748841996912,
273
+ "support": 2025.0
274
+ },
275
+ "Negative": {
276
+ "precision": 0.3767441860465116,
277
+ "recall": 0.6893617021276596,
278
+ "f1-score": 0.48721804511278194,
279
+ "support": 235.0
280
+ },
281
+ "accuracy": 0.806,
282
+ "macro avg": {
283
+ "precision": 0.693166884733405,
284
+ "recall": 0.7704544885986966,
285
+ "f1-score": 0.7160335416761091,
286
+ "support": 4500.0
287
+ },
288
+ "weighted avg": {
289
+ "precision": 0.8265767678080969,
290
+ "recall": 0.806,
291
+ "f1-score": 0.813219381907331,
292
+ "support": 4500.0
293
+ }
294
+ }
295
+ },
296
+ "VALUE": {
297
+ "macro_f1": 0.6463455740801278,
298
+ "accuracy": 0.814,
299
+ "confusion_matrix": [
300
+ [
301
+ 2944,
302
+ 324,
303
+ 172
304
+ ],
305
+ [
306
+ 231,
307
+ 630,
308
+ 46
309
+ ],
310
+ [
311
+ 35,
312
+ 29,
313
+ 89
314
+ ]
315
+ ],
316
+ "report": {
317
+ "Not_Mentioned": {
318
+ "precision": 0.9171339563862928,
319
+ "recall": 0.8558139534883721,
320
+ "f1-score": 0.8854135338345864,
321
+ "support": 3440.0
322
+ },
323
+ "Positive": {
324
+ "precision": 0.6408952187182095,
325
+ "recall": 0.6945975744211687,
326
+ "f1-score": 0.6666666666666666,
327
+ "support": 907.0
328
+ },
329
+ "Negative": {
330
+ "precision": 0.2899022801302932,
331
+ "recall": 0.5816993464052288,
332
+ "f1-score": 0.3869565217391304,
333
+ "support": 153.0
334
+ },
335
+ "accuracy": 0.814,
336
+ "macro avg": {
337
+ "precision": 0.6159771517449318,
338
+ "recall": 0.7107036247715898,
339
+ "f1-score": 0.6463455740801278,
340
+ "support": 4500.0
341
+ },
342
+ "weighted avg": {
343
+ "precision": 0.840130627156933,
344
+ "recall": 0.814,
345
+ "f1-score": 0.8243763490852736,
346
+ "support": 4500.0
347
+ }
348
+ }
349
+ }
350
+ },
351
+ "overall": {
352
+ "mean_macro_f1": 0.7181058563683197,
353
+ "mean_accuracy": 0.7976666666666666
354
+ }
355
+ },
356
+ "acsa_no_meta_per_aspect": {
357
+ "per_aspect": {
358
+ "SIZE": {
359
+ "macro_f1": 0.723496978308766,
360
+ "accuracy": 0.7164444444444444,
361
+ "confusion_matrix": [
362
+ [
363
+ 1035,
364
+ 152,
365
+ 71
366
+ ],
367
+ [
368
+ 686,
369
+ 1445,
370
+ 262
371
+ ],
372
+ [
373
+ 49,
374
+ 56,
375
+ 744
376
+ ]
377
+ ],
378
+ "report": {
379
+ "Not_Mentioned": {
380
+ "precision": 0.5847457627118644,
381
+ "recall": 0.8227344992050875,
382
+ "f1-score": 0.6836195508586526,
383
+ "support": 1258.0
384
+ },
385
+ "Positive": {
386
+ "precision": 0.8741681790683605,
387
+ "recall": 0.6038445465942331,
388
+ "f1-score": 0.7142857142857143,
389
+ "support": 2393.0
390
+ },
391
+ "Negative": {
392
+ "precision": 0.6908077994428969,
393
+ "recall": 0.8763250883392226,
394
+ "f1-score": 0.7725856697819314,
395
+ "support": 849.0
396
+ },
397
+ "accuracy": 0.7164444444444444,
398
+ "macro avg": {
399
+ "precision": 0.7165739137410405,
400
+ "recall": 0.7676347113795144,
401
+ "f1-score": 0.723496978308766,
402
+ "support": 4500.0
403
+ },
404
+ "weighted avg": {
405
+ "precision": 0.7586645430509182,
406
+ "recall": 0.7164444444444444,
407
+ "f1-score": 0.7167120762023909,
408
+ "support": 4500.0
409
+ }
410
+ }
411
+ },
412
+ "MATERIAL": {
413
+ "macro_f1": 0.6869602136807388,
414
+ "accuracy": 0.7111111111111111,
415
+ "confusion_matrix": [
416
+ [
417
+ 819,
418
+ 273,
419
+ 88
420
+ ],
421
+ [
422
+ 535,
423
+ 1896,
424
+ 266
425
+ ],
426
+ [
427
+ 72,
428
+ 66,
429
+ 485
430
+ ]
431
+ ],
432
+ "report": {
433
+ "Not_Mentioned": {
434
+ "precision": 0.5743338008415148,
435
+ "recall": 0.6940677966101695,
436
+ "f1-score": 0.6285495011511896,
437
+ "support": 1180.0
438
+ },
439
+ "Positive": {
440
+ "precision": 0.8483221476510067,
441
+ "recall": 0.7030033370411568,
442
+ "f1-score": 0.7688564476885644,
443
+ "support": 2697.0
444
+ },
445
+ "Negative": {
446
+ "precision": 0.5780691299165673,
447
+ "recall": 0.7784911717495987,
448
+ "f1-score": 0.6634746922024624,
449
+ "support": 623.0
450
+ },
451
+ "accuracy": 0.7111111111111111,
452
+ "macro avg": {
453
+ "precision": 0.6669083594696962,
454
+ "recall": 0.7251874351336417,
455
+ "f1-score": 0.6869602136807388,
456
+ "support": 4500.0
457
+ },
458
+ "weighted avg": {
459
+ "precision": 0.7390612855879498,
460
+ "recall": 0.7111111111111111,
461
+ "f1-score": 0.7174753297814658,
462
+ "support": 4500.0
463
+ }
464
+ }
465
+ },
466
+ "QUALITY": {
467
+ "macro_f1": 0.6921391557031549,
468
+ "accuracy": 0.7277777777777777,
469
+ "confusion_matrix": [
470
+ [
471
+ 1961,
472
+ 448,
473
+ 112
474
+ ],
475
+ [
476
+ 536,
477
+ 1112,
478
+ 61
479
+ ],
480
+ [
481
+ 42,
482
+ 26,
483
+ 202
484
+ ]
485
+ ],
486
+ "report": {
487
+ "Not_Mentioned": {
488
+ "precision": 0.7723513194170933,
489
+ "recall": 0.777865926219754,
490
+ "f1-score": 0.775098814229249,
491
+ "support": 2521.0
492
+ },
493
+ "Positive": {
494
+ "precision": 0.7011349306431274,
495
+ "recall": 0.6506729081334114,
496
+ "f1-score": 0.6749620637329287,
497
+ "support": 1709.0
498
+ },
499
+ "Negative": {
500
+ "precision": 0.5386666666666666,
501
+ "recall": 0.7481481481481481,
502
+ "f1-score": 0.6263565891472869,
503
+ "support": 270.0
504
+ },
505
+ "accuracy": 0.7277777777777777,
506
+ "macro avg": {
507
+ "precision": 0.6707176389089625,
508
+ "recall": 0.7255623275004378,
509
+ "f1-score": 0.6921391557031549,
510
+ "support": 4500.0
511
+ },
512
+ "weighted avg": {
513
+ "precision": 0.7312838383821326,
514
+ "recall": 0.7277777777777777,
515
+ "f1-score": 0.7281445681469509,
516
+ "support": 4500.0
517
+ }
518
+ }
519
+ },
520
+ "APPEARANCE": {
521
+ "macro_f1": 0.6134243985372673,
522
+ "accuracy": 0.7042222222222222,
523
+ "confusion_matrix": [
524
+ [
525
+ 1902,
526
+ 472,
527
+ 129
528
+ ],
529
+ [
530
+ 558,
531
+ 1161,
532
+ 89
533
+ ],
534
+ [
535
+ 45,
536
+ 38,
537
+ 106
538
+ ]
539
+ ],
540
+ "report": {
541
+ "Not_Mentioned": {
542
+ "precision": 0.7592814371257485,
543
+ "recall": 0.7598881342389133,
544
+ "f1-score": 0.7595846645367412,
545
+ "support": 2503.0
546
+ },
547
+ "Positive": {
548
+ "precision": 0.6947935368043088,
549
+ "recall": 0.6421460176991151,
550
+ "f1-score": 0.6674331704512791,
551
+ "support": 1808.0
552
+ },
553
+ "Negative": {
554
+ "precision": 0.3271604938271605,
555
+ "recall": 0.5608465608465608,
556
+ "f1-score": 0.41325536062378165,
557
+ "support": 189.0
558
+ },
559
+ "accuracy": 0.7042222222222222,
560
+ "macro avg": {
561
+ "precision": 0.5937451559190726,
562
+ "recall": 0.6542935709281964,
563
+ "f1-score": 0.6134243985372673,
564
+ "support": 4500.0
565
+ },
566
+ "weighted avg": {
567
+ "precision": 0.715222552222505,
568
+ "recall": 0.7042222222222222,
569
+ "f1-score": 0.7080144112598379,
570
+ "support": 4500.0
571
+ }
572
+ }
573
+ },
574
+ "STYLE": {
575
+ "macro_f1": 0.651480797158933,
576
+ "accuracy": 0.7168888888888889,
577
+ "confusion_matrix": [
578
+ [
579
+ 1722,
580
+ 406,
581
+ 112
582
+ ],
583
+ [
584
+ 563,
585
+ 1356,
586
+ 106
587
+ ],
588
+ [
589
+ 47,
590
+ 40,
591
+ 148
592
+ ]
593
+ ],
594
+ "report": {
595
+ "Not_Mentioned": {
596
+ "precision": 0.7384219554030875,
597
+ "recall": 0.76875,
598
+ "f1-score": 0.7532808398950132,
599
+ "support": 2240.0
600
+ },
601
+ "Positive": {
602
+ "precision": 0.7524972253052165,
603
+ "recall": 0.6696296296296296,
604
+ "f1-score": 0.7086490723804547,
605
+ "support": 2025.0
606
+ },
607
+ "Negative": {
608
+ "precision": 0.40437158469945356,
609
+ "recall": 0.6297872340425532,
610
+ "f1-score": 0.4925124792013311,
611
+ "support": 235.0
612
+ },
613
+ "accuracy": 0.7168888888888889,
614
+ "macro avg": {
615
+ "precision": 0.6317635884692525,
616
+ "recall": 0.6893889545573942,
617
+ "f1-score": 0.651480797158933,
618
+ "support": 4500.0
619
+ },
620
+ "weighted avg": {
621
+ "precision": 0.7273109741667446,
622
+ "recall": 0.7168888888888889,
623
+ "f1-score": 0.7195786412327918,
624
+ "support": 4500.0
625
+ }
626
+ }
627
+ },
628
+ "VALUE": {
629
+ "macro_f1": 0.6489411814960196,
630
+ "accuracy": 0.8326666666666667,
631
+ "confusion_matrix": [
632
+ [
633
+ 3129,
634
+ 155,
635
+ 156
636
+ ],
637
+ [
638
+ 331,
639
+ 534,
640
+ 42
641
+ ],
642
+ [
643
+ 46,
644
+ 23,
645
+ 84
646
+ ]
647
+ ],
648
+ "report": {
649
+ "Not_Mentioned": {
650
+ "precision": 0.892470051340559,
651
+ "recall": 0.9095930232558139,
652
+ "f1-score": 0.9009501871580766,
653
+ "support": 3440.0
654
+ },
655
+ "Positive": {
656
+ "precision": 0.75,
657
+ "recall": 0.5887541345093715,
658
+ "f1-score": 0.6596664607782582,
659
+ "support": 907.0
660
+ },
661
+ "Negative": {
662
+ "precision": 0.2978723404255319,
663
+ "recall": 0.5490196078431373,
664
+ "f1-score": 0.38620689655172413,
665
+ "support": 153.0
666
+ },
667
+ "accuracy": 0.8326666666666667,
668
+ "macro avg": {
669
+ "precision": 0.6467807972553636,
670
+ "recall": 0.6824555885361075,
671
+ "f1-score": 0.6489411814960196,
672
+ "support": 4500.0
673
+ },
674
+ "weighted avg": {
675
+ "precision": 0.8435380988214732,
676
+ "recall": 0.8326666666666667,
677
+ "f1-score": 0.8348168397604616,
678
+ "support": 4500.0
679
+ }
680
+ }
681
+ }
682
+ },
683
+ "overall": {
684
+ "mean_macro_f1": 0.6694071208141467,
685
+ "mean_accuracy": 0.7348518518518518
686
+ }
687
+ },
688
+ "note": "The Proposed model jointly trains per-aspect heads and an overall sentiment head on the shared fused representation. The overall_head result is the primary overall metric. The aggregated_to_overall result (voting from per-aspect predictions) is included for reference. Baseline 3 vs Proposed isolates the marginal value of metadata cross-attention fusion."
689
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio==5.49.1
2
+ torch>=2.0.0
3
+ transformers>=4.35.0,<5.0.0
4
+ huggingface-hub>=0.30.0
5
+ numpy>=1.24.0
6
+ pandas>=2.0.0
7
+ scikit-learn>=1.3.0
src/__init__.py ADDED
File without changes
src/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (193 Bytes). View file
 
src/__pycache__/ablation.cpython-314.pyc ADDED
Binary file (23.3 kB). View file
 
src/__pycache__/aspect_dict.cpython-314.pyc ADDED
Binary file (13.4 kB). View file
 
src/__pycache__/baselines.cpython-314.pyc ADDED
Binary file (4.73 kB). View file
 
src/__pycache__/config.cpython-314.pyc ADDED
Binary file (2.88 kB). View file
 
src/__pycache__/data_download.cpython-314.pyc ADDED
Binary file (14.8 kB). View file
 
src/__pycache__/dataset.cpython-314.pyc ADDED
Binary file (8.92 kB). View file
 
src/__pycache__/evaluator.cpython-314.pyc ADDED
Binary file (19.3 kB). View file
 
src/__pycache__/explainer.cpython-314.pyc ADDED
Binary file (52.1 kB). View file
 
src/__pycache__/inference.cpython-314.pyc ADDED
Binary file (20.3 kB). View file
 
src/__pycache__/meta_encoder.cpython-314.pyc ADDED
Binary file (16.2 kB). View file
 
src/__pycache__/models.cpython-314.pyc ADDED
Binary file (38.4 kB). View file
 
src/__pycache__/preprocess.cpython-314.pyc ADDED
Binary file (9.4 kB). View file
 
src/__pycache__/trainer.cpython-314.pyc ADDED
Binary file (25.6 kB). View file
 
src/__pycache__/utils.cpython-314.pyc ADDED
Binary file (1.31 kB). View file
 
src/__pycache__/weak_labeling.cpython-314.pyc ADDED
Binary file (27.8 kB). View file
 
src/ablation.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ablation experiments for the Proposed BERT + Meta Cross-Attention model.
2
+
3
+ Three ablations are defined here:
4
+
5
+ A1 -- "remove text-type metadata":
6
+ Mask the TF-IDF slice of the meta vector (features_text + categories_text)
7
+ to zeros, keep only the numeric slice (price, average_rating, log_rating_number,
8
+ price_missing_flag). Verifies whether textual product attributes are the main
9
+ source of the fusion gain.
10
+
11
+ A2 -- "remove numeric metadata":
12
+ Mask the numeric slice, keep only the TF-IDF text-meta slice. Strict
13
+ contrast to A1.
14
+
15
+ A3 -- "replace Cross-Attention with concatenation":
16
+ Keep both meta sources intact, but replace the MetaTokenizer +
17
+ Multi-Head Cross-Attention fusion with a static [text_cls ; meta_vec]
18
+ concatenation followed by a linear projection back to BERT hidden size.
19
+ Quantifies the algorithmic benefit of Cross-Attention vs. static fusion.
20
+
21
+ Design notes
22
+ ------------
23
+ * For A1/A2 we deliberately keep the model architecture *unchanged* and only
24
+ zero out the masked slice of the encoded meta vector. This isolates the
25
+ information content of each meta sub-source from architectural confounders
26
+ (e.g. MLP input dimension shrinkage). The MaskedMetaEncoder wraps an already-
27
+ fit MetaEncoder so we never re-fit on test/val data.
28
+
29
+ * For A3 we add a new model class `BertConcatFusionACSAModel` whose forward
30
+ signature is identical to `BertMetaFusionACSAModel` (same inputs, same output
31
+ keys minus `meta_attn_weights`). This lets us reuse most of `train_meta_acsa`
32
+ via a small dedicated training loop.
33
+
34
+ * The training hyperparameters (epochs, batch size, LRs, class weighting) are
35
+ passed through unchanged so the only thing varying between Proposed and
36
+ ablations is what the prompt says is varying.
37
+ """
38
+ import json
39
+ import logging
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+ from typing import Optional
43
+
44
+ import numpy as np
45
+ import pandas as pd
46
+ import torch
47
+ import torch.nn as nn
48
+ from torch.utils.data import DataLoader
49
+ from transformers import AutoModel, AutoTokenizer, get_linear_schedule_with_warmup
50
+ from tqdm import tqdm
51
+
52
+ from . import config as cfg
53
+ from .dataset import MetaACSADataset
54
+ from .meta_encoder import MetaEncoder
55
+ from .models import (
56
+ GatedAspectSemanticMetaFusionACSAModel, BertMetaFusionACSAModel,
57
+ MetaEncoderMLP, _PerAspectHeads, _aspect_loss, compute_class_weights,
58
+ )
59
+ from .trainer import get_device, _evaluate_per_aspect
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Masked meta encoder for A1 / A2
66
+ # ---------------------------------------------------------------------------
67
+
68
+ @dataclass
69
+ class MaskedMetaEncoder:
70
+ """Wraps a fit MetaEncoder and zeros out one of its two slices.
71
+
72
+ The base encoder produces vectors laid out as
73
+ [ TF-IDF (cfg.META_TFIDF_DIM dims) | numeric (cfg.META_NUM_DIM dims) ].
74
+
75
+ Setting `mask` to "text" zeros the TF-IDF slice (A1 -- remove text-meta).
76
+ Setting `mask` to "numeric" zeros the numeric slice (A2 -- remove numeric meta).
77
+ """
78
+ base: MetaEncoder
79
+ mask: str # "text" or "numeric"
80
+
81
+ def __post_init__(self):
82
+ if self.mask not in {"text", "numeric"}:
83
+ raise ValueError(f"mask must be 'text' or 'numeric', got {self.mask!r}")
84
+
85
+ @property
86
+ def total_dim(self) -> int:
87
+ return self.base.total_dim
88
+
89
+ def transform(self, df: pd.DataFrame) -> np.ndarray:
90
+ # Copy so we never mutate any buffer the base encoder might be holding.
91
+ mat = self.base.transform(df).copy()
92
+ tfidf_dim = self.base.tfidf_dim
93
+ if self.mask == "text":
94
+ mat[:, :tfidf_dim] = 0.0
95
+ else: # numeric
96
+ mat[:, tfidf_dim:] = 0.0
97
+ return mat
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # A3: Concatenation fusion variant
102
+ # ---------------------------------------------------------------------------
103
+
104
+ class BertConcatFusionACSAModel(nn.Module):
105
+ """A3 ablation: BERT + Meta MLP + [text;meta] concat + linear -> per-aspect heads.
106
+
107
+ Architecture is intentionally identical to BertMetaFusionACSAModel except
108
+ the fusion block:
109
+ Proposed: fused = CrossAttention(Q=text_cls, KV=MetaTokenizer(meta_enc))
110
+ A3: fused = LayerNorm( Linear( [text_cls ; meta_enc] ) )
111
+
112
+ All other components -- BERT encoder, MetaEncoderMLP, per-aspect heads,
113
+ class-weighted loss -- are unchanged so the comparison isolates the fusion
114
+ mechanism.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ bert_name: str = cfg.BERT_MODEL_NAME,
120
+ meta_in_dim: int = cfg.META_TFIDF_DIM + cfg.META_NUM_DIM,
121
+ num_aspects: int = cfg.NUM_ASPECTS,
122
+ num_classes: int = cfg.NUM_CLASSES,
123
+ dropout: float = 0.3,
124
+ class_weights: Optional[torch.Tensor] = None,
125
+ ):
126
+ super().__init__()
127
+ self.bert = AutoModel.from_pretrained(bert_name)
128
+ hidden = self.bert.config.hidden_size
129
+
130
+ self.num_aspects = num_aspects
131
+ self.num_classes = num_classes
132
+ self.aspect_names = list(cfg.ASPECTS)
133
+ self.meta_in_dim = meta_in_dim
134
+
135
+ self.meta_mlp = MetaEncoderMLP(meta_in_dim, hidden=cfg.META_HIDDEN_DIM,
136
+ dropout=dropout)
137
+ # Static fusion: concat then project back to BERT hidden size.
138
+ self.fusion = nn.Sequential(
139
+ nn.Linear(hidden + cfg.META_HIDDEN_DIM, hidden),
140
+ nn.GELU(),
141
+ nn.Dropout(0.1),
142
+ )
143
+ self.fusion_norm = nn.LayerNorm(hidden)
144
+ self.heads = _PerAspectHeads(in_dim=hidden, num_aspects=num_aspects,
145
+ num_classes=num_classes, dropout=dropout)
146
+
147
+ self.class_weights = class_weights
148
+
149
+ def forward(
150
+ self,
151
+ input_ids,
152
+ attention_mask,
153
+ meta_features,
154
+ labels: Optional[torch.Tensor] = None,
155
+ output_attentions: bool = False,
156
+ ):
157
+ bert_out = self.bert(
158
+ input_ids=input_ids,
159
+ attention_mask=attention_mask,
160
+ output_attentions=output_attentions,
161
+ return_dict=True,
162
+ )
163
+ text_vec = bert_out.last_hidden_state[:, 0, :] # [CLS]
164
+ meta_vec = self.meta_mlp(meta_features) # (B, META_HIDDEN_DIM)
165
+
166
+ cat = torch.cat([text_vec, meta_vec], dim=-1) # (B, H + meta_hidden)
167
+ fused = self.fusion_norm(self.fusion(cat)) # (B, H)
168
+
169
+ logits = self.heads(fused)
170
+ loss = _aspect_loss(logits, labels, self.class_weights) if labels is not None else None
171
+
172
+ return {
173
+ "loss": loss,
174
+ "logits": logits,
175
+ "meta_attn_weights": None, # no cross-attn in this variant
176
+ "bert_attentions": bert_out.attentions if output_attentions else None,
177
+ "fused_state": fused,
178
+ "text_state": text_vec,
179
+ }
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Training loop for A3 (concat fusion). A1/A2 reuse train_meta_acsa with a
184
+ # masked encoder.
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def train_concat_fusion_acsa(
188
+ train_df, val_df, meta_encoder,
189
+ bert_name: str = cfg.BERT_MODEL_NAME,
190
+ epochs: int = cfg.DEFAULT_EPOCHS,
191
+ batch_size: int = cfg.DEFAULT_BATCH_SIZE,
192
+ lr_bert: float = cfg.DEFAULT_LR_BERT,
193
+ lr_heads: float = cfg.DEFAULT_LR_HEADS,
194
+ weight_decay: float = cfg.DEFAULT_WEIGHT_DECAY,
195
+ use_class_weights: bool = True,
196
+ output_dir: Optional[Path] = None,
197
+ seed: int = cfg.RANDOM_SEED,
198
+ ):
199
+ """Train the A3 concatenation-fusion variant.
200
+
201
+ Mirrors trainer.train_meta_acsa but instantiates BertConcatFusionACSAModel.
202
+ """
203
+ if output_dir is None:
204
+ output_dir = cfg.CHECKPOINT_DIR / "ablation_A3_concat"
205
+ output_dir = Path(output_dir); output_dir.mkdir(parents=True, exist_ok=True)
206
+
207
+ torch.manual_seed(seed); np.random.seed(seed)
208
+ device = get_device()
209
+ logger.info("Device: %s", device)
210
+
211
+ tokenizer = AutoTokenizer.from_pretrained(bert_name)
212
+
213
+ aspect_cols = [f"aspect_{a}" for a in cfg.ASPECTS]
214
+ class_weights = (compute_class_weights(train_df, aspect_cols, cfg.NUM_CLASSES).to(device)
215
+ if use_class_weights else None)
216
+
217
+ model = BertConcatFusionACSAModel(
218
+ bert_name=bert_name,
219
+ meta_in_dim=meta_encoder.total_dim,
220
+ class_weights=class_weights,
221
+ ).to(device)
222
+
223
+ train_ds = MetaACSADataset(train_df, tokenizer, meta_encoder)
224
+ val_ds = MetaACSADataset(val_df, tokenizer, meta_encoder)
225
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
226
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
227
+
228
+ bert_params = list(model.bert.named_parameters())
229
+ other_params = [(n, p) for n, p in model.named_parameters()
230
+ if not n.startswith("bert.")]
231
+ no_decay = ["bias", "LayerNorm.weight"]
232
+ grouped = [
233
+ {"params": [p for n, p in bert_params if not any(nd in n for nd in no_decay)],
234
+ "weight_decay": weight_decay, "lr": lr_bert},
235
+ {"params": [p for n, p in bert_params if any(nd in n for nd in no_decay)],
236
+ "weight_decay": 0.0, "lr": lr_bert},
237
+ {"params": [p for n, p in other_params if not any(nd in n for nd in no_decay)],
238
+ "weight_decay": weight_decay, "lr": lr_heads},
239
+ {"params": [p for n, p in other_params if any(nd in n for nd in no_decay)],
240
+ "weight_decay": 0.0, "lr": lr_heads},
241
+ ]
242
+ optimizer = torch.optim.AdamW(grouped)
243
+ total_steps = max(len(train_loader) * epochs, 1)
244
+ scheduler = get_linear_schedule_with_warmup(
245
+ optimizer, num_warmup_steps=int(cfg.WARMUP_RATIO * total_steps),
246
+ num_training_steps=total_steps,
247
+ )
248
+
249
+ best_f1 = -1.0; history = []
250
+ for epoch in range(epochs):
251
+ model.train(); running = 0.0
252
+ pbar = tqdm(train_loader, desc=f"[epoch {epoch+1}/{epochs}] ablation_A3_concat")
253
+ for batch in pbar:
254
+ batch = {k: v.to(device) for k, v in batch.items()}
255
+ optimizer.zero_grad()
256
+ out = model(batch["input_ids"], batch["attention_mask"],
257
+ batch["meta_features"], labels=batch["labels"])
258
+ out["loss"].backward()
259
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
260
+ optimizer.step(); scheduler.step()
261
+ running += out["loss"].item()
262
+ pbar.set_postfix({"loss": f"{out['loss'].item():.4f}"})
263
+
264
+ avg_loss = running / max(len(train_loader), 1)
265
+ val = _evaluate_per_aspect(model, val_loader, device, with_meta=True)
266
+ logger.info("Epoch %d | loss=%.4f | val_macro_f1=%.4f | val_acc=%.4f",
267
+ epoch+1, avg_loss, val["macro_f1_mean"], val["accuracy_mean"])
268
+ history.append({"epoch": epoch+1, "train_loss": avg_loss, **val})
269
+
270
+ if val["macro_f1_mean"] > best_f1:
271
+ best_f1 = val["macro_f1_mean"]
272
+ torch.save({"model_state_dict": model.state_dict(),
273
+ "config": {"bert_name": bert_name,
274
+ "meta_in_dim": meta_encoder.total_dim,
275
+ "variant": "A3_concat"}},
276
+ output_dir / "best.pt")
277
+ tokenizer.save_pretrained(output_dir / "tokenizer")
278
+ logger.info("Saved new best A3_concat (macro_f1=%.4f)", best_f1)
279
+
280
+ with open(output_dir / "history.json", "w") as f:
281
+ json.dump(history, f, indent=2)
282
+ return model, history
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Checkpoint loading + per-aspect prediction for ablation models
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def _load_ckpt(path: Path, device):
290
+ return torch.load(path, map_location=device, weights_only=False)
291
+
292
+
293
+ def load_meta_acsa_for_ablation(checkpoint_dir: Path, meta_encoder, device=None):
294
+ """Load a BertMetaFusionACSAModel checkpoint (used for A1/A2)."""
295
+
296
+ if device is None:
297
+ device = get_device()
298
+ ckpt = _load_ckpt(checkpoint_dir / "best.pt", device)
299
+ bert_name = ckpt.get("config", {}).get("bert_name", cfg.BERT_MODEL_NAME)
300
+ meta_in_dim = ckpt.get("config", {}).get("meta_in_dim", meta_encoder.total_dim)
301
+ architecture = ckpt.get("config", {}).get("architecture", "legacy_meta_acsa")
302
+ if architecture == "gated_aspect_semantic_meta_acsa":
303
+ model = GatedAspectSemanticMetaFusionACSAModel(bert_name=bert_name, meta_in_dim=meta_in_dim).to(device)
304
+ else:
305
+ model = BertMetaFusionACSAModel(bert_name=bert_name, meta_in_dim=meta_in_dim).to(device)
306
+ model.load_state_dict(ckpt["model_state_dict"], strict=False)
307
+ model.eval()
308
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir / "tokenizer")
309
+ return model, tokenizer, device
310
+
311
+
312
+ def load_concat_fusion_acsa(checkpoint_dir: Path, meta_encoder, device=None):
313
+ """Load a BertConcatFusionACSAModel checkpoint (A3)."""
314
+ if device is None:
315
+ device = get_device()
316
+ ckpt = _load_ckpt(checkpoint_dir / "best.pt", device)
317
+ bert_name = ckpt.get("config", {}).get("bert_name", cfg.BERT_MODEL_NAME)
318
+ meta_in_dim = ckpt.get("config", {}).get("meta_in_dim", meta_encoder.total_dim)
319
+ model = BertConcatFusionACSAModel(bert_name=bert_name, meta_in_dim=meta_in_dim).to(device)
320
+ model.load_state_dict(ckpt["model_state_dict"], strict=False)
321
+ model.eval()
322
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir / "tokenizer")
323
+ return model, tokenizer, device
324
+
325
+
326
+ def predict_per_aspect_for_ablation(model, tokenizer, test_df, meta_encoder, device,
327
+ batch_size: int = 32):
328
+ """Run a meta-using per-aspect model over test_df.
329
+
330
+ Accepts both BertMetaFusionACSAModel (A1/A2) and BertConcatFusionACSAModel
331
+ (A3) -- the forward signature is the same.
332
+ """
333
+ test_df = test_df.reset_index(drop=True)
334
+ ds = MetaACSADataset(test_df, tokenizer, meta_encoder)
335
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
336
+
337
+ all_preds = [[] for _ in range(cfg.NUM_ASPECTS)]
338
+ all_labels = [[] for _ in range(cfg.NUM_ASPECTS)]
339
+ with torch.no_grad():
340
+ for batch in tqdm(loader, desc="predict per-aspect (ablation)"):
341
+ batch = {k: v.to(device) for k, v in batch.items()}
342
+ out = model(batch["input_ids"], batch["attention_mask"],
343
+ batch["meta_features"])
344
+ preds = out["logits"].argmax(dim=-1).cpu().numpy()
345
+ labels = batch["labels"].cpu().numpy()
346
+ for i in range(cfg.NUM_ASPECTS):
347
+ all_preds[i].extend(preds[:, i].tolist())
348
+ all_labels[i].extend(labels[:, i].tolist())
349
+ return all_preds, all_labels
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Registry of variants
354
+ # ---------------------------------------------------------------------------
355
+
356
+ ABLATION_VARIANTS = {
357
+ "A1": {
358
+ "name": "A1_no_text_meta",
359
+ "description": "Remove text-type metadata (TF-IDF on features/categories); "
360
+ "keep numeric (price, ratings).",
361
+ "checkpoint_subdir": "ablation_A1_no_text_meta",
362
+ "fusion": "cross_attention",
363
+ "meta_mask": "text",
364
+ },
365
+ "A2": {
366
+ "name": "A2_no_numeric_meta",
367
+ "description": "Remove numeric metadata (price, ratings); "
368
+ "keep text-type (TF-IDF on features/categories).",
369
+ "checkpoint_subdir": "ablation_A2_no_numeric_meta",
370
+ "fusion": "cross_attention",
371
+ "meta_mask": "numeric",
372
+ },
373
+ "A3": {
374
+ "name": "A3_concat_fusion",
375
+ "description": "Replace Cross-Attention fusion with [text;meta] concat + Linear; "
376
+ "both meta sources kept.",
377
+ "checkpoint_subdir": "ablation_A3_concat",
378
+ "fusion": "concat",
379
+ "meta_mask": None,
380
+ },
381
+ }
382
+
383
+
src/aspect_dict.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aspect keyword dictionary for clothing domain.
2
+
3
+ Seed words for each aspect. Can be extended using metadata's `features`/`categories` text.
4
+ """
5
+ import json
6
+ import re
7
+ from collections import Counter
8
+ from typing import Dict, List, Iterable
9
+
10
+ from .config import ASPECT_DICT_PATH
11
+
12
+
13
+ # Seed dictionary - manually curated for clothing domain
14
+ SEED_ASPECT_DICT: Dict[str, List[str]] = {
15
+ "SIZE": [
16
+ "alterations",
17
+ "baggy",
18
+ "big",
19
+ "boxy fit",
20
+ "fit",
21
+ "fit perfectly",
22
+ "fits",
23
+ "fitting",
24
+ "great fit",
25
+ "huge",
26
+ "large",
27
+ "length",
28
+ "long",
29
+ "loose",
30
+ "loose fit",
31
+ "narrow",
32
+ "oversized",
33
+ "perfect fit",
34
+ "petite",
35
+ "poor fit",
36
+ "right size",
37
+ "roomy",
38
+ "runs big",
39
+ "runs large",
40
+ "runs small",
41
+ "runs tight",
42
+ "short",
43
+ "shorter than expected",
44
+ "size",
45
+ "size down",
46
+ "size up",
47
+ "sizing",
48
+ "small",
49
+ "snug",
50
+ "stretched out",
51
+ "tight",
52
+ "tiny",
53
+ "too big",
54
+ "too large",
55
+ "too long",
56
+ "too loose",
57
+ "too short",
58
+ "too small",
59
+ "too tight",
60
+ "true to size",
61
+ "tts",
62
+ "waist",
63
+ "wide",
64
+ "wrong size",
65
+ ],
66
+ "MATERIAL": [
67
+ "blend",
68
+ "breathable",
69
+ "bulky",
70
+ "cashmere",
71
+ "cheap fabric",
72
+ "cheap material",
73
+ "comfortable fabric",
74
+ "cotton",
75
+ "denim",
76
+ "elastic",
77
+ "fabric",
78
+ "fabric feels",
79
+ "feel",
80
+ "feeling",
81
+ "feels",
82
+ "fleece",
83
+ "heavy",
84
+ "heavier",
85
+ "heavier than expected",
86
+ "heavy fabric",
87
+ "itchy",
88
+ "leather",
89
+ "lightweight",
90
+ "linen",
91
+ "lycra",
92
+ "material",
93
+ "nylon",
94
+ "polyester",
95
+ "rayon",
96
+ "rough",
97
+ "scratchy",
98
+ "see through",
99
+ "see-through",
100
+ "sheer",
101
+ "silk",
102
+ "soft",
103
+ "spandex",
104
+ "stiff",
105
+ "stretch",
106
+ "stretchy",
107
+ "suede",
108
+ "synthetic",
109
+ "texture",
110
+ "thick",
111
+ "thin",
112
+ "too heavy",
113
+ "transparent",
114
+ "uncomfortable fabric",
115
+ "velvet",
116
+ "warm",
117
+ "weight",
118
+ "wool",
119
+ ],
120
+ "QUALITY": [
121
+ "after wash",
122
+ "after washing",
123
+ "broke",
124
+ "broken",
125
+ "button",
126
+ "buttons",
127
+ "cheaply made",
128
+ "construction",
129
+ "craftsmanship",
130
+ "defect",
131
+ "defective",
132
+ "durability",
133
+ "durable",
134
+ "faded",
135
+ "faded after wash",
136
+ "fading",
137
+ "falling apart",
138
+ "fell apart",
139
+ "first wash",
140
+ "flimsy",
141
+ "fragile",
142
+ "hem",
143
+ "holes",
144
+ "lasted",
145
+ "lasts",
146
+ "loose thread",
147
+ "loose threads",
148
+ "made well",
149
+ "pilled",
150
+ "pilling",
151
+ "poor quality",
152
+ "poorly made",
153
+ "quality",
154
+ "rip",
155
+ "ripped",
156
+ "seam",
157
+ "seams",
158
+ "shedding",
159
+ "shrink",
160
+ "shrunk",
161
+ "shrunk after wash",
162
+ "shrunk after washing",
163
+ "stitch",
164
+ "stitching",
165
+ "sturdy",
166
+ "tear",
167
+ "threads",
168
+ "tore",
169
+ "torn",
170
+ "well constructed",
171
+ "well made",
172
+ "well-made",
173
+ "zipper",
174
+ ],
175
+ "APPEARANCE": [
176
+ "as advertised",
177
+ "as described",
178
+ "as pictured",
179
+ "as shown",
180
+ "beautiful",
181
+ "bright",
182
+ "color",
183
+ "color is off",
184
+ "color off",
185
+ "colors",
186
+ "colour",
187
+ "darker",
188
+ "design",
189
+ "different color",
190
+ "different from picture",
191
+ "different from photo",
192
+ "dull",
193
+ "faded color",
194
+ "graphic",
195
+ "gorgeous",
196
+ "lighter",
197
+ "logo",
198
+ "looked like",
199
+ "looks like",
200
+ "matches the picture",
201
+ "misleading photo",
202
+ "off color",
203
+ "pattern",
204
+ "photo",
205
+ "picture",
206
+ "print",
207
+ "shade",
208
+ "stunning",
209
+ "tone",
210
+ "true color",
211
+ "true to color",
212
+ "ugly",
213
+ "vibrant",
214
+ ],
215
+ "STYLE": [
216
+ "boho",
217
+ "casual",
218
+ "chic",
219
+ "classic",
220
+ "collar",
221
+ "compliment",
222
+ "complimented",
223
+ "compliments",
224
+ "cut",
225
+ "cute",
226
+ "elegant",
227
+ "fashion",
228
+ "fashionable",
229
+ "flattering",
230
+ "formal",
231
+ "modern",
232
+ "neckline",
233
+ "outfit",
234
+ "preppy",
235
+ "professional",
236
+ "received compliments",
237
+ "shape",
238
+ "shapeless",
239
+ "silhouette",
240
+ "sleeves",
241
+ "style",
242
+ "stylish",
243
+ "trendy",
244
+ "unflattering",
245
+ "vintage",
246
+ ],
247
+ "VALUE": [
248
+ "affordable",
249
+ "bargain",
250
+ "cheap price",
251
+ "cost",
252
+ "discount",
253
+ "exchanged",
254
+ "expensive",
255
+ "for the price",
256
+ "for this price",
257
+ "good deal",
258
+ "great deal",
259
+ "money",
260
+ "money back",
261
+ "money well spent",
262
+ "not worth",
263
+ "not worth it",
264
+ "overpriced",
265
+ "price",
266
+ "pricey",
267
+ "refund",
268
+ "return",
269
+ "returning",
270
+ "value",
271
+ "value for money",
272
+ "waste of money",
273
+ "wasted money",
274
+ "worth",
275
+ "worth it",
276
+ ],
277
+ }
278
+ def get_aspect_dict() -> Dict[str, List[str]]:
279
+ """Return seed dict (callers can extend it)."""
280
+ return {k: list(v) for k, v in SEED_ASPECT_DICT.items()}
281
+
282
+
283
+ # Tokens / phrases we consider noise when mining metadata text
284
+ _STOPWORDS = set("""
285
+ the and for with from this that have has had not your you our their these
286
+ those there here when where what which who whom how why because all any
287
+ its his her item product use used using also more most less other than just
288
+ new cost free buy bought made make piece pieces set sets pack two one three
289
+ inch inches cm mm gram fl oz lb pounds black white grey gray
290
+ are can will would could should closure imported machine women men kids ladies
291
+ amazon brand sleeve sleeves shirt shirts dress dresses pants jacket jackets
292
+ """.split())
293
+
294
+
295
+
296
+ _GENERIC_MINED_TOKENS = {
297
+ "are", "can", "will", "would", "could", "should", "closure", "imported",
298
+ "machine", "women", "woman", "men", "kids", "ladies", "perfect", "nice",
299
+ "great", "good", "comfortable", "casual", "design", "wear", "wash",
300
+ "fit", "fabric", "material", "polyester", "cotton", "soft",
301
+ }
302
+
303
+ _AMBIGUOUS_SINGLE_TOKENS = {
304
+ "cheap", "perfect", "comfortable", "design", "style", "fit", "wash",
305
+ }
306
+ def _tokenize(text: str) -> List[str]:
307
+ return re.findall(r"[a-z][a-z\-]+", text.lower())
308
+
309
+
310
+ def extend_aspect_dict_from_metadata(
311
+ meta_texts: Iterable[str],
312
+ aspect_dict: Dict[str, List[str]] = None,
313
+ top_k: int = 20,
314
+ min_count: int = 5,
315
+ ) -> Dict[str, List[str]]:
316
+ """Mine frequent domain words from metadata text to extend aspect seed dict.
317
+
318
+ For each aspect, if a metadata token co-occurs frequently with any seed word
319
+ in the same metadata blob, add it to the aspect's vocabulary.
320
+ """
321
+ if aspect_dict is None:
322
+ aspect_dict = get_aspect_dict()
323
+
324
+ # Build per-aspect co-occurrence counter
325
+ aspect_cooc = {a: Counter() for a in aspect_dict}
326
+
327
+ for text in meta_texts:
328
+ if not isinstance(text, str) or not text:
329
+ continue
330
+ tokens = set(_tokenize(text))
331
+ if not tokens:
332
+ continue
333
+ # For each aspect, check if any seed appears in this blob
334
+ for aspect, seeds in aspect_dict.items():
335
+ seed_set = set(_tokenize(" ".join(seeds)))
336
+ if tokens & seed_set:
337
+ # Co-occurring tokens -> aspect candidates
338
+ for t in tokens:
339
+ if t in _STOPWORDS or t in seed_set:
340
+ continue
341
+ if t in _GENERIC_MINED_TOKENS or t in _AMBIGUOUS_SINGLE_TOKENS:
342
+ continue
343
+ if len(t) < 4:
344
+ continue
345
+ aspect_cooc[aspect][t] += 1
346
+
347
+ # Add top-k high frequency co-occurring tokens to each aspect
348
+ extended = {a: list(v) for a, v in aspect_dict.items()}
349
+ for aspect, counter in aspect_cooc.items():
350
+ new_words = [w for w, c in counter.most_common(top_k * 3) if c >= min_count]
351
+ # Filter words that look generic or ambiguous outside local context.
352
+ new_words = [
353
+ w for w in new_words
354
+ if w not in _STOPWORDS
355
+ and w not in _GENERIC_MINED_TOKENS
356
+ and w not in _AMBIGUOUS_SINGLE_TOKENS
357
+ ][:top_k]
358
+ extended[aspect].extend(new_words)
359
+
360
+ # Dedup
361
+ for a in extended:
362
+ extended[a] = sorted(set(extended[a]))
363
+
364
+ return extended
365
+
366
+
367
+ def save_aspect_dict(aspect_dict: Dict[str, List[str]], path=ASPECT_DICT_PATH):
368
+ with open(path, "w", encoding="utf-8") as f:
369
+ json.dump(aspect_dict, f, indent=2)
370
+
371
+
372
+ def load_aspect_dict(path=ASPECT_DICT_PATH) -> Dict[str, List[str]]:
373
+ with open(path, encoding="utf-8") as f:
374
+ return json.load(f)
375
+
376
+
377
+ def compile_aspect_patterns(aspect_dict: Dict[str, List[str]]):
378
+ """Compile regex patterns for each aspect (whole-phrase matching, case-insensitive)."""
379
+ patterns = {}
380
+ for aspect, keywords in aspect_dict.items():
381
+ # Sort longest first so multi-word phrases match before single tokens
382
+ sorted_kws = sorted(set(keywords), key=lambda s: -len(s))
383
+ escaped = [re.escape(kw) for kw in sorted_kws]
384
+ pattern = r"\b(?:" + "|".join(escaped) + r")\b"
385
+ patterns[aspect] = re.compile(pattern, flags=re.IGNORECASE)
386
+ return patterns
387
+
src/baselines.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TF-IDF + Logistic Regression baseline (traditional ML reference)."""
2
+ import json
3
+ import logging
4
+ import pickle
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.feature_extraction.text import TfidfVectorizer
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.metrics import f1_score, accuracy_score, classification_report
13
+ from sklearn.pipeline import Pipeline
14
+
15
+ from . import config as cfg
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def train_tfidf_baseline(
22
+ train_df: pd.DataFrame,
23
+ val_df: pd.DataFrame,
24
+ test_df: pd.DataFrame,
25
+ text_col: str = "full_text",
26
+ label_col: str = "overall_label",
27
+ output_dir: Optional[Path] = None,
28
+ ):
29
+ """Train TF-IDF + LogReg on overall 3-class sentiment. Returns metrics dict."""
30
+ if output_dir is None:
31
+ output_dir = cfg.CHECKPOINT_DIR / "tfidf_baseline"
32
+ output_dir = Path(output_dir)
33
+ output_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ pipeline = Pipeline([
36
+ ("tfidf", TfidfVectorizer(
37
+ max_features=20000,
38
+ ngram_range=(1, 2),
39
+ min_df=2,
40
+ max_df=0.95,
41
+ stop_words="english",
42
+ sublinear_tf=True,
43
+ )),
44
+ ("clf", LogisticRegression(
45
+ max_iter=1000,
46
+ class_weight="balanced",
47
+ random_state=cfg.RANDOM_SEED,
48
+ C=1.0,
49
+ )),
50
+ ])
51
+
52
+ X_train = train_df[text_col].fillna("").astype(str).values
53
+ y_train = train_df[label_col].values
54
+ X_val = val_df[text_col].fillna("").astype(str).values
55
+ y_val = val_df[label_col].values
56
+ X_test = test_df[text_col].fillna("").astype(str).values
57
+ y_test = test_df[label_col].values
58
+
59
+ logger.info("Training TF-IDF + LogReg on %d samples...", len(X_train))
60
+ pipeline.fit(X_train, y_train)
61
+
62
+ metrics = {}
63
+ for split_name, X, y in [("val", X_val, y_val), ("test", X_test, y_test)]:
64
+ preds = pipeline.predict(X)
65
+ metrics[f"{split_name}_macro_f1"] = float(f1_score(y, preds, average="macro", zero_division=0))
66
+ metrics[f"{split_name}_accuracy"] = float(accuracy_score(y, preds))
67
+ metrics[f"{split_name}_weighted_f1"] = float(f1_score(y, preds, average="weighted", zero_division=0))
68
+ metrics[f"{split_name}_report"] = classification_report(
69
+ y, preds, target_names=["Negative", "Neutral", "Positive"], output_dict=True, zero_division=0
70
+ )
71
+
72
+ with open(output_dir / "model.pkl", "wb") as f:
73
+ pickle.dump(pipeline, f)
74
+ with open(output_dir / "metrics.json", "w") as f:
75
+ json.dump({k: v for k, v in metrics.items() if not k.endswith("_report")}, f, indent=2)
76
+
77
+ logger.info("TF-IDF baseline | val Macro-F1=%.4f | test Macro-F1=%.4f",
78
+ metrics["val_macro_f1"], metrics["test_macro_f1"])
79
+ return pipeline, metrics
src/config.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Global configuration for the ACSA-Clothing project (v2: with metadata fusion)."""
2
+ from pathlib import Path
3
+
4
+ # --- Paths ---
5
+ ROOT = Path(__file__).resolve().parent.parent
6
+ DATA_DIR = ROOT / "data"
7
+ CHECKPOINT_DIR = ROOT / "checkpoints"
8
+ OUTPUT_DIR = ROOT / "outputs"
9
+ REPORT_DIR = ROOT / "reports"
10
+
11
+ for d in (DATA_DIR, CHECKPOINT_DIR, OUTPUT_DIR, REPORT_DIR):
12
+ d.mkdir(parents=True, exist_ok=True)
13
+
14
+ # Raw data files
15
+ RAW_REVIEWS_PATH = DATA_DIR / "raw_reviews.parquet"
16
+ RAW_META_PATH = DATA_DIR / "raw_meta.parquet"
17
+
18
+ # Processed
19
+ PROCESSED_PATH = DATA_DIR / "processed_reviews.parquet"
20
+ LABELED_PATH = DATA_DIR / "labeled_reviews.parquet"
21
+ ASPECT_DICT_PATH = DATA_DIR / "aspect_dict.json"
22
+
23
+ # Splits
24
+ TRAIN_PATH = DATA_DIR / "train.parquet"
25
+ VAL_PATH = DATA_DIR / "val.parquet"
26
+ TEST_PATH = DATA_DIR / "test.parquet"
27
+
28
+ # Meta encoder artifacts (vectorizer, scaler, etc.)
29
+ META_ENCODER_PATH = DATA_DIR / "meta_encoder.pkl"
30
+
31
+ # --- HuggingFace Dataset ---
32
+ HF_DATASET = "McAuley-Lab/Amazon-Reviews-2023"
33
+ CATEGORY = "Clothing_Shoes_and_Jewelry"
34
+ HF_REVIEW_CONFIG = f"raw_review_{CATEGORY}"
35
+ HF_META_CONFIG = f"raw_meta_{CATEGORY}"
36
+
37
+ # --- Aspects ---
38
+ # 6 high-level aspects relevant to clothing reviews.
39
+ ASPECTS = ["SIZE", "MATERIAL", "QUALITY", "APPEARANCE", "STYLE", "VALUE"]
40
+ NUM_ASPECTS = len(ASPECTS)
41
+
42
+ # Per-aspect classes: 0 = Not_Mentioned, 1 = Positive, 2 = Negative
43
+ LABEL_MAP = {"Not_Mentioned": 0, "Positive": 1, "Negative": 2}
44
+ LABEL_NAMES = ["Not_Mentioned", "Positive", "Negative"]
45
+ NUM_CLASSES = 3
46
+
47
+ # --- Overall (3-class) label codes (used by baselines + overall_label column) ---
48
+ # 0 = Negative, 1 = Neutral, 2 = Positive
49
+ OVERALL_LABEL_NAMES = ["Negative", "Neutral", "Positive"]
50
+ OVERALL_NUM_CLASSES = 3
51
+
52
+ # --- Model ---
53
+ BERT_MODEL_NAME = "bert-base-uncased" # 12-layer BERT backbone for the 10W experiment
54
+ MAX_LENGTH = 256
55
+
56
+ # --- Metadata encoder dims ---
57
+ META_FEATURE_TFIDF_DIM = 64 # TF-IDF dim for product features_text
58
+ META_CATEGORY_TFIDF_DIM = 36 # TF-IDF dim for categories_text
59
+ META_TFIDF_DIM = META_FEATURE_TFIDF_DIM + META_CATEGORY_TFIDF_DIM
60
+ META_NUM_DIM = 4 # price, average_rating, log(rating_number), price_missing_flag
61
+ META_HIDDEN_DIM = 128 # MLP output dim
62
+ META_CROSSATTN_HEADS = 4 # multi-head cross-attention heads
63
+ META_NUMERIC_TOKEN_SCALE = 0.10 # keep numeric metadata, but damp it by default
64
+ CROSS_ATTN_RESIDUAL_SCALE = 0.7 # start cross-attn as a refinement over concat fusion
65
+ OVERALL_AUX_WEIGHT = 0.3 # weight for the overall sentiment auxiliary loss in joint training
66
+
67
+ # --- Training ---
68
+ DEFAULT_BATCH_SIZE = 16
69
+ DEFAULT_EPOCHS = 3
70
+ DEFAULT_LR_BERT = 2e-5
71
+ DEFAULT_LR_HEADS = 1e-3
72
+ DEFAULT_WEIGHT_DECAY = 0.01
73
+ WARMUP_RATIO = 0.1
74
+
75
+ # --- Split ratios ---
76
+ TRAIN_RATIO = 0.7
77
+ VAL_RATIO = 0.15
78
+ TEST_RATIO = 0.15
79
+ RANDOM_SEED = 42
80
+
81
+ # --- Weak labeling ---
82
+ VADER_POSITIVE_THRESHOLD = 0.05
83
+ VADER_NEGATIVE_THRESHOLD = -0.05
84
+ RATING_POSITIVE_THRESHOLD = 4
85
+ RATING_NEGATIVE_THRESHOLD = 2
86
+ MIN_REVIEW_LENGTH = 20
87
+ AUDIT_SAMPLE_SIZE = 200
88
+
89
+ # When the product's `features` field explicitly lists an aspect-related
90
+ # attribute (e.g. "100% cotton" -> MATERIAL aspect is salient),
91
+ # bump the prior probability that the corresponding aspect is mentioned/relevant.
92
+ META_PRIOR_BOOST = True
93
+
94
+
95
+
96
+
src/data_download.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download Amazon Reviews 2023 (Clothing_Shoes_and_Jewelry).
2
+
3
+ 銆愰噸瑕佸彉鏇淬€戝畬鍏ㄦ姏寮?HuggingFace 鏁版嵁闆?API,鏀逛负鐩存帴浠?McAuley Lab 瀹樻柟
4
+ HTTPS 鏈嶅姟鍣ㄦ祦寮忔媺鍙栧師濮?jsonl.gz 鏂囦欢銆傚師鍥?
5
+ 1. 鏂扮増 datasets 宸插簾寮?loading script, load_dataset(..., trust_remote_code=True)
6
+ 鎶?"Dataset scripts are no longer supported"銆?2. McAuley-Lab/Amazon-Reviews-2023 浠撳簱鐨?main 鍒嗘敮鍙湁鑴氭湰鍜?raw 鐩綍,
7
+ parquet 鏂囦欢鍒嗘暎鍦?PR 鍒嗘敮(refs/pr/10)涓?涓?Clothing 绫荤洰骞朵笉瀹屾暣,
8
+ 渚濊禆 HF 璧板緱闈炲父鏇叉姌涓斿鏄撹俯鍧戙€?3. 瀹樻柟鏂囨。 (amazon-reviews-2023.github.io) 缁欑殑鐩撮摼鏈€绋?
9
+ https://mcauleylab.ucsd.edu/public_datasets/data/amazon_2023/...
10
+ 涓€浠?jsonl.gz,娴佸紡 GET + gunzip + readline 涓変欢濂楀嵆鍙€? 瀵?sample 妯″紡灏ゅ叾鍙嬪ソ鈥斺€斾笅澶?N 鏉″氨鎺愭柇杩炴帴,涓嶇敤涓嬪畬鏁翠釜鏂囦欢銆?4. UCSD 鏈嶅姟鍣ㄥ湪鍥藉唴璁块棶閫氬父姣?huggingface.co 绋冲畾 (HF 缁忓父 SNI 闃绘柇)銆?
11
+ 銆?026-05 淇銆?- pyarrow 鍐?parquet 鏃朵細鎸夊垪鎺ㄦ柇绫诲瀷, Amazon meta 鐨?price 瀛楁鏄? "鍙兘鏁板瓧 / 鍙兘 '鈥? (em-dash) / 鍙兘 'None' / 鍙兘绌? 鐨勮剰娣峰悎瀛楁,
12
+ pyarrow 璇曞浘鎺ㄦ垚 double 鏃堕亣鍒?'鈥? 鐩存帴 ArrowInvalid銆? 瑙e喅: 鍐?parquet 鍓嶇敤 _clean_meta_for_parquet 鎶?price 寮鸿浆 float銆?- 鍚屾椂澧炲姞 jsonl 鍏滃簳鐩? meta 鎷夊畬鍏堝瓨涓€浠藉師濮?jsonl, 鍐嶅啓 parquet銆? 杩欐牱涓囦竴 parquet 鍙堝嚭鍒殑闂, 涓嬫鑳界绾ф仮澶? 涓嶅繀閲嶆柊鐖?25 鍒嗛挓銆?- reviews parquet 濡傛灉宸茬粡瀛樺湪, 璺宠繃 reviews 涓嬭浇闃舵, 鐩存帴澶嶇敤銆?"""
13
+ import gzip
14
+ import json
15
+ import logging
16
+ import os
17
+ from collections import defaultdict
18
+ from typing import Iterable, Iterator
19
+
20
+ import pandas as pd
21
+ import requests
22
+ from tqdm import tqdm
23
+
24
+ from . import config as cfg
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # McAuley Lab 瀹樻柟鐩撮摼妯℃澘
31
+ _BASE = "https://mcauleylab.ucsd.edu/public_datasets/data/amazon_2023/raw"
32
+ _REVIEW_URL_TMPL = _BASE + "/review_categories/{cat}.jsonl.gz"
33
+ _META_URL_TMPL = _BASE + "/meta_categories/meta_{cat}.jsonl.gz"
34
+
35
+
36
+ def _stream_jsonl_gz(url: str) -> Iterator[dict]:
37
+ """Stream a gzipped JSONL file and yield parsed rows.
38
+
39
+ The function intentionally avoids downloading the full file to disk, so
40
+ sample mode can stop early after enough valid records have been collected.
41
+ """
42
+ logger.info("GET %s", url)
43
+ headers = {"User-Agent": "Mozilla/5.0 (acsa_clothing downloader)"}
44
+ with requests.get(url, stream=True, headers=headers, timeout=(15, 60)) as r:
45
+ r.raise_for_status()
46
+ r.raw.decode_content = False
47
+ with gzip.GzipFile(fileobj=r.raw) as gz:
48
+ for raw_line in gz:
49
+ if not raw_line:
50
+ continue
51
+ try:
52
+ yield json.loads(raw_line)
53
+ except json.JSONDecodeError as e:
54
+ logger.warning("Skipping one invalid JSON line: %s", e)
55
+ continue
56
+
57
+ def _records_to_df(records: Iterable[dict]) -> pd.DataFrame:
58
+ return pd.DataFrame(list(records))
59
+
60
+
61
+ def _clean_meta_for_parquet(df: pd.DataFrame) -> pd.DataFrame:
62
+ """鎶?meta 涓細璁?pyarrow 鎺ㄦ柇绫诲瀷澶辫触鐨勮剰鍊兼竻娲楁帀銆?
63
+ Amazon 鍏冩暟鎹噷 price 鏄?"鍙兘鏁板瓧 / 鍙兘 '鈥? / 鍙兘 'None' / 鍙兘绌? 鐨勬贩鍚堝瓧娈?
64
+ pandas 榛樿鎺ㄦ垚 object, pyarrow 钀?parquet 鏃跺嵈璇曞浘鎺ㄦ垚 double,
65
+ 閬囧埌 '鈥? (em-dash) 杩欑闈炴暟瀛楀瓧绗︿覆灏辨姏 ArrowInvalid銆? 鎵€浠ヨ繖閲屾墜鍔ㄦ妸 price 寮哄埗杞垚 float64, 鏃犳硶瑙f瀽鐨勪竴寰嬪彉 NaN銆? """
66
+ if "price" in df.columns:
67
+ df["price"] = pd.to_numeric(df["price"], errors="coerce")
68
+ return df
69
+
70
+
71
+ def _backup_jsonl(records: list, parquet_path) -> str:
72
+ """鍦ㄥ啓 parquet 鍓嶅厛钀戒竴浠藉師濮?jsonl 鍏滃簳銆?
73
+ 璺緞灏辨槸 parquet 鍚屽悕鎹㈠悗缂€銆傚嵆浣垮悗缁?to_parquet 澶辫触涔熶笉蹇呴噸鐖€? """
74
+ raw_jsonl_path = os.path.splitext(str(parquet_path))[0] + ".jsonl"
75
+ os.makedirs(os.path.dirname(raw_jsonl_path) or ".", exist_ok=True)
76
+ with open(raw_jsonl_path, "w", encoding="utf-8") as f:
77
+ for row in records:
78
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
79
+ logger.info("Backup jsonl saved to %s (%d rows)", raw_jsonl_path, len(records))
80
+ return raw_jsonl_path
81
+
82
+
83
+ def download_sample(n_reviews: int = 30000, min_text_len: int = 20,
84
+ min_reviews_per_product: int = 2,
85
+ max_reviews_per_product: int = 5,
86
+ scan_multiplier: int = 5,
87
+ force: bool = False):
88
+ """Sample 妯″紡: 娴佸紡璇诲彇鍓?N 鏉″彲鐢ㄨ瘎璁?鍐嶆媺瀵瑰簲 meta銆?
89
+ 'Usable' 鎸? text 闈炵┖涓旈暱搴?>= min_text_len, 涓?parent_asin 闈炵┖銆? """
90
+ review_url = _REVIEW_URL_TMPL.format(cat=cfg.CATEGORY)
91
+ meta_url = _META_URL_TMPL.format(cat=cfg.CATEGORY)
92
+
93
+ # ===== Reviews 闃舵 =====
94
+ # 濡傛灉 reviews parquet 宸插瓨鍦?(涓婃璺戞垚鍔熶簡, 鏄?meta 闃舵宕╃殑),
95
+ # 鐩存帴璇诲嚭鏉ュ鐢?seen_asins, 璺宠繃杩欐閲嶆柊涓嬭浇銆?
96
+ if os.path.exists(cfg.RAW_REVIEWS_PATH) and not force:
97
+ logger.info("Reviews parquet 宸插瓨鍦? 澶嶇敤: %s", cfg.RAW_REVIEWS_PATH)
98
+ reviews_df = pd.read_parquet(cfg.RAW_REVIEWS_PATH)
99
+ seen_asins = set(reviews_df["parent_asin"].dropna().unique().tolist())
100
+ logger.info("浠庡凡鏈?reviews 涓仮澶?%d 鏉? %d 涓?unique parent_asins",
101
+ len(reviews_df), len(seen_asins))
102
+ else:
103
+ logger.info(
104
+ "Streaming reviews (grouped sample, target=%d, min_per_product=%d, max_per_product=%d, scan_multiplier=%d) from %s ...",
105
+ n_reviews, min_reviews_per_product, max_reviews_per_product, scan_multiplier, review_url,
106
+ )
107
+ candidate_target = max(n_reviews, n_reviews * max(scan_multiplier, 1))
108
+ candidates = []
109
+ pbar = tqdm(total=candidate_target, desc="review candidates")
110
+ for row in _stream_jsonl_gz(review_url):
111
+ text = (row.get("text") or "").strip()
112
+ if len(text) < min_text_len:
113
+ continue
114
+ if not row.get("parent_asin"):
115
+ continue
116
+ candidates.append(row)
117
+ pbar.update(1)
118
+ if len(candidates) >= candidate_target:
119
+ break
120
+ pbar.close()
121
+
122
+ by_asin = defaultdict(list)
123
+ for row in candidates:
124
+ by_asin[row["parent_asin"]].append(row)
125
+
126
+ eligible = {asin: rows for asin, rows in by_asin.items() if len(rows) >= min_reviews_per_product}
127
+ kept = []
128
+ for asin, rows in sorted(eligible.items(), key=lambda x: len(x[1]), reverse=True):
129
+ for row in rows[:max_reviews_per_product]:
130
+ kept.append(row)
131
+ if len(kept) >= n_reviews:
132
+ break
133
+ if len(kept) >= n_reviews:
134
+ break
135
+
136
+ if len(kept) < n_reviews:
137
+ used = {id(row) for row in kept}
138
+ for row in candidates:
139
+ if id(row) in used:
140
+ continue
141
+ kept.append(row)
142
+ if len(kept) >= n_reviews:
143
+ break
144
+
145
+ reviews_df = _records_to_df(kept[:n_reviews])
146
+ seen_asins = set(reviews_df["parent_asin"].dropna().unique().tolist())
147
+ per_product = reviews_df.groupby("parent_asin").size()
148
+ logger.info(
149
+ "Got %d reviews, %d unique parent_asins, avg_reviews_per_product=%.2f, products_with_%d_plus_reviews=%d.",
150
+ len(reviews_df), len(seen_asins), len(reviews_df) / max(len(seen_asins), 1),
151
+ min_reviews_per_product, int((per_product >= min_reviews_per_product).sum()),
152
+ )
153
+ reviews_df.to_parquet(cfg.RAW_REVIEWS_PATH, index=False)
154
+
155
+ # ===== Meta 闃舵 =====
156
+ # 鍐嶆媺 meta,鍖归厤 parent_asin
157
+ # 娉ㄦ剰: meta 娌℃湁淇濊瘉鎺掑簭,鐞嗚涓婅鎵畬鏁翠唤鎵嶈兘纭繚鍛戒腑鎵€鏈?asin銆?
158
+ # 浣?meta 姣?reviews 灏忓緱澶?(Clothing meta 鍑犵櫨 MB 鍘嬬缉),瀹屾暣鎵竴閬嶅彲鎺ュ彈銆? logger.info("Streaming meta to match parent_asins from %s ...", meta_url)
159
+ meta_kept = []
160
+ remaining = set(seen_asins)
161
+ pbar = tqdm(total=len(seen_asins), desc="meta")
162
+ for row in _stream_jsonl_gz(meta_url):
163
+ asin = row.get("parent_asin")
164
+ if asin in remaining:
165
+ meta_kept.append(row)
166
+ remaining.discard(asin)
167
+ pbar.update(1)
168
+ if not remaining:
169
+ break # 鍏ㄩ儴鍛戒腑,鎻愬墠鏂紑
170
+ pbar.close()
171
+
172
+ # 1) 鍏堣惤 jsonl 鍏滃簳, 鍝€曚笅闈?to_parquet 鐐镐簡涔熶笉蹇呴噸鐖?
173
+ _backup_jsonl(meta_kept, cfg.RAW_META_PATH)
174
+
175
+ # 2) 鍐嶅仛娓呮礂 + 鍐?parquet
176
+ meta_df = _records_to_df(meta_kept)
177
+ logger.info(
178
+ "Got %d meta entries (matched %.1f%% of review asins).",
179
+ len(meta_df),
180
+ 100 * len(meta_df) / max(len(seen_asins), 1),
181
+ )
182
+ meta_df = _clean_meta_for_parquet(meta_df)
183
+ meta_df.to_parquet(cfg.RAW_META_PATH, index=False)
184
+ return reviews_df, meta_df
185
+
186
+
187
+ def download_full():
188
+ """Download the full review and metadata tables. Very large; use with care."""
189
+ review_url = _REVIEW_URL_TMPL.format(cat=cfg.CATEGORY)
190
+ meta_url = _META_URL_TMPL.format(cat=cfg.CATEGORY)
191
+
192
+ logger.info("Downloading FULL reviews from %s ...", review_url)
193
+ reviews = []
194
+ for row in tqdm(_stream_jsonl_gz(review_url), desc="reviews"):
195
+ reviews.append(row)
196
+ reviews_df = _records_to_df(reviews)
197
+ logger.info("Reviews: %d rows", len(reviews_df))
198
+ reviews_df.to_parquet(cfg.RAW_REVIEWS_PATH, index=False)
199
+
200
+ logger.info("Downloading FULL meta from %s ...", meta_url)
201
+ metas = []
202
+ for row in tqdm(_stream_jsonl_gz(meta_url), desc="meta"):
203
+ metas.append(row)
204
+
205
+ # 钀?jsonl 鍏滃簳
206
+ _backup_jsonl(metas, cfg.RAW_META_PATH)
207
+
208
+ meta_df = _records_to_df(metas)
209
+ logger.info("Meta: %d rows", len(meta_df))
210
+ meta_df = _clean_meta_for_parquet(meta_df)
211
+ meta_df.to_parquet(cfg.RAW_META_PATH, index=False)
212
+ return reviews_df, meta_df
213
+
214
+
215
+
216
+
src/dataset.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PyTorch Dataset classes.
2
+
3
+ We provide three:
4
+ - ACSADataset text only, per-aspect labels -> Baseline 3 (no meta)
5
+ - MetaACSADataset text + meta features -> Proposed model
6
+ - OverallSentimentDataset text only, single 3-class -> Baseline 2 (BERT-overall)
7
+ """
8
+ from typing import Optional
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import torch
13
+ from torch.utils.data import Dataset
14
+
15
+ from . import config as cfg
16
+ from .meta_encoder import MetaEncoder
17
+
18
+
19
+ class _BaseTextDataset(Dataset):
20
+ def __init__(self, df: pd.DataFrame, tokenizer, max_length: int = cfg.MAX_LENGTH,
21
+ text_col: str = "full_text"):
22
+ self.df = df.reset_index(drop=True)
23
+ self.tokenizer = tokenizer
24
+ self.max_length = max_length
25
+ self.text_col = text_col
26
+
27
+ def __len__(self):
28
+ return len(self.df)
29
+
30
+ def _encode_text(self, text: str):
31
+ enc = self.tokenizer(
32
+ text,
33
+ max_length=self.max_length,
34
+ padding="max_length",
35
+ truncation=True,
36
+ return_tensors="pt",
37
+ )
38
+ return enc["input_ids"].squeeze(0), enc["attention_mask"].squeeze(0)
39
+
40
+
41
+ class ACSADataset(_BaseTextDataset):
42
+ """Per-aspect multi-head training, text-only. (Baseline 3.)"""
43
+
44
+ def __init__(self, df, tokenizer, **kw):
45
+ super().__init__(df, tokenizer, **kw)
46
+ self.aspect_cols = [f"aspect_{a}" for a in cfg.ASPECTS]
47
+ missing = [c for c in self.aspect_cols if c not in self.df.columns]
48
+ if missing:
49
+ raise KeyError(f"ACSADataset: missing aspect label columns {missing}. "
50
+ f"Did you run weak labeling (script 03)?")
51
+
52
+ def __getitem__(self, idx):
53
+ row = self.df.iloc[idx]
54
+ input_ids, attn = self._encode_text(str(row[self.text_col]))
55
+ labels = torch.tensor([int(row[c]) for c in self.aspect_cols], dtype=torch.long)
56
+ return {"input_ids": input_ids, "attention_mask": attn, "labels": labels}
57
+
58
+
59
+ class MetaACSADataset(_BaseTextDataset):
60
+ """Per-aspect multi-head training, text + metadata. (Proposed model.)
61
+
62
+ The metadata is pre-encoded to a numpy matrix of shape (N, meta_dim) using
63
+ a fit MetaEncoder, and indexed in lock-step with the text rows.
64
+
65
+ If the df contains an 'overall_label' column (0=Neg, 1=Neu, 2=Pos), it is
66
+ returned as part of each batch for the joint overall auxiliary loss.
67
+ """
68
+
69
+ def __init__(self, df, tokenizer, meta_encoder: MetaEncoder, **kw):
70
+ super().__init__(df, tokenizer, **kw)
71
+ self.aspect_cols = [f"aspect_{a}" for a in cfg.ASPECTS]
72
+ missing = [c for c in self.aspect_cols if c not in self.df.columns]
73
+ if missing:
74
+ raise KeyError(f"MetaACSADataset: missing aspect label columns {missing}.")
75
+
76
+ self.has_overall = "overall_label" in self.df.columns
77
+
78
+ # Pre-encode metadata once, in the same row order as self.df.
79
+ self.meta_matrix = meta_encoder.transform(self.df)
80
+ assert self.meta_matrix.shape[0] == len(self.df), \
81
+ "meta matrix and df length mismatch"
82
+
83
+ def __getitem__(self, idx):
84
+ row = self.df.iloc[idx]
85
+ input_ids, attn = self._encode_text(str(row[self.text_col]))
86
+ labels = torch.tensor([int(row[c]) for c in self.aspect_cols], dtype=torch.long)
87
+ meta = torch.from_numpy(self.meta_matrix[idx]).float()
88
+ item = {
89
+ "input_ids": input_ids,
90
+ "attention_mask": attn,
91
+ "meta_features": meta,
92
+ "labels": labels,
93
+ }
94
+ if self.has_overall:
95
+ item["overall_labels"] = torch.tensor(int(row["overall_label"]), dtype=torch.long)
96
+ return item
97
+
98
+
99
+ class OverallSentimentDataset(_BaseTextDataset):
100
+ """Single 3-class overall sentiment label. (Baseline 2: BERT-overall.)"""
101
+
102
+ def __init__(self, df, tokenizer, label_col: str = "overall_label", **kw):
103
+ super().__init__(df, tokenizer, **kw)
104
+ self.label_col = label_col
105
+ if self.label_col not in self.df.columns:
106
+ raise KeyError(f"OverallSentimentDataset: missing {self.label_col} column.")
107
+
108
+ def __getitem__(self, idx):
109
+ row = self.df.iloc[idx]
110
+ input_ids, attn = self._encode_text(str(row[self.text_col]))
111
+ return {
112
+ "input_ids": input_ids,
113
+ "attention_mask": attn,
114
+ "labels": torch.tensor(int(row[self.label_col]), dtype=torch.long),
115
+ }
src/evaluator.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation: per-aspect metrics, aggregated overall comparison.
2
+
3
+ Two label encodings live in this project (keep them straight!):
4
+ Per-aspect (ACSA): 0=Not_Mentioned, 1=Positive, 2=Negative
5
+ Overall (3-class): 0=Negative, 1=Neutral, 2=Positive
6
+
7
+ `aspect_to_overall_sentiment` translates between them explicitly.
8
+ """
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Dict, Optional, Union
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import torch
17
+ from torch.utils.data import DataLoader
18
+ from transformers import AutoTokenizer
19
+ from sklearn.metrics import (
20
+ f1_score, accuracy_score, confusion_matrix, classification_report,
21
+ )
22
+ from tqdm import tqdm
23
+
24
+ from . import config as cfg
25
+ from .dataset import ACSADataset, MetaACSADataset, OverallSentimentDataset
26
+ from .models import GatedAspectSemanticMetaFusionACSAModel, BertMetaFusionACSAModel, BertACSAModel, BertOverallModel
27
+ from .meta_encoder import MetaEncoder
28
+ from .trainer import get_device
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Label-code constants (DO NOT change carelessly; many tests depend on them).
34
+ ASPECT_NOT_MENTIONED = 0
35
+ ASPECT_POSITIVE = 1
36
+ ASPECT_NEGATIVE = 2
37
+
38
+ OVERALL_NEGATIVE = 0
39
+ OVERALL_NEUTRAL = 1
40
+ OVERALL_POSITIVE = 2
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Loaders (all use weights_only=False 鈥?these are our own checkpoints)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _load_ckpt(path: Path, device):
48
+ return torch.load(path, map_location=device, weights_only=False)
49
+
50
+
51
+ def load_meta_acsa(checkpoint_dir: Path = None, meta_encoder: Optional[MetaEncoder] = None,
52
+ device=None):
53
+ if checkpoint_dir is None:
54
+ checkpoint_dir = cfg.CHECKPOINT_DIR / "meta_acsa"
55
+ checkpoint_dir = Path(checkpoint_dir)
56
+ if device is None:
57
+ device = get_device()
58
+ ckpt = _load_ckpt(checkpoint_dir / "best.pt", device)
59
+ bert_name = ckpt.get("config", {}).get("bert_name", cfg.BERT_MODEL_NAME)
60
+ meta_in_dim = ckpt.get("config", {}).get("meta_in_dim",
61
+ cfg.META_TFIDF_DIM + cfg.META_NUM_DIM)
62
+ architecture = ckpt.get("config", {}).get("architecture", "legacy_meta_acsa")
63
+ if meta_encoder is None:
64
+ meta_encoder = MetaEncoder.load()
65
+ if meta_encoder.total_dim != meta_in_dim:
66
+ raise ValueError(
67
+ f"Meta encoder dim {meta_encoder.total_dim} != checkpoint dim {meta_in_dim}. "
68
+ f"Re-fit the encoder on the same train split used for training."
69
+ )
70
+ if architecture == "gated_aspect_semantic_meta_acsa":
71
+ model = GatedAspectSemanticMetaFusionACSAModel(
72
+ bert_name=bert_name,
73
+ meta_in_dim=meta_in_dim,
74
+ ).to(device)
75
+ else:
76
+ model = BertMetaFusionACSAModel(bert_name=bert_name, meta_in_dim=meta_in_dim).to(device)
77
+ model.load_state_dict(ckpt["model_state_dict"], strict=False)
78
+ model.eval()
79
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir / "tokenizer")
80
+ return model, tokenizer, meta_encoder, device
81
+
82
+
83
+ def load_acsa(checkpoint_dir: Path = None, device=None):
84
+ if checkpoint_dir is None:
85
+ checkpoint_dir = cfg.CHECKPOINT_DIR / "acsa"
86
+ checkpoint_dir = Path(checkpoint_dir)
87
+ if device is None:
88
+ device = get_device()
89
+ ckpt = _load_ckpt(checkpoint_dir / "best.pt", device)
90
+ bert_name = ckpt.get("config", {}).get("bert_name", cfg.BERT_MODEL_NAME)
91
+ model = BertACSAModel(bert_name=bert_name).to(device)
92
+ model.load_state_dict(ckpt["model_state_dict"])
93
+ model.eval()
94
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir / "tokenizer")
95
+ return model, tokenizer, device
96
+
97
+
98
+ def load_bert_overall(checkpoint_dir: Path = None, device=None):
99
+ if checkpoint_dir is None:
100
+ checkpoint_dir = cfg.CHECKPOINT_DIR / "bert_overall"
101
+ checkpoint_dir = Path(checkpoint_dir)
102
+ if device is None:
103
+ device = get_device()
104
+ ckpt = _load_ckpt(checkpoint_dir / "best.pt", device)
105
+ bert_name = ckpt.get("config", {}).get("bert_name", cfg.BERT_MODEL_NAME)
106
+ model = BertOverallModel(bert_name=bert_name).to(device)
107
+ model.load_state_dict(ckpt["model_state_dict"])
108
+ model.eval()
109
+ tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir / "tokenizer")
110
+ return model, tokenizer, device
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Inference loops (return predictions in the same row order as test_df)
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def predict_per_aspect(model, tokenizer, test_df, device,
118
+ meta_encoder: Optional[MetaEncoder] = None,
119
+ batch_size: int = 32):
120
+ """Run a per-aspect model over test_df. Returns:
121
+ all_preds : list of NUM_ASPECTS lists, each length N
122
+ all_labels : same shape, ground-truth aspect labels (if present)
123
+ """
124
+ test_df = test_df.reset_index(drop=True)
125
+ if meta_encoder is not None:
126
+ ds = MetaACSADataset(test_df, tokenizer, meta_encoder)
127
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
128
+ with_meta = True
129
+ else:
130
+ ds = ACSADataset(test_df, tokenizer)
131
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
132
+ with_meta = False
133
+
134
+ all_preds = [[] for _ in range(cfg.NUM_ASPECTS)]
135
+ all_labels = [[] for _ in range(cfg.NUM_ASPECTS)]
136
+ with torch.no_grad():
137
+ for batch in tqdm(loader, desc="predict per-aspect"):
138
+ batch = {k: v.to(device) for k, v in batch.items()}
139
+ if with_meta:
140
+ out = model(batch["input_ids"], batch["attention_mask"],
141
+ batch["meta_features"])
142
+ else:
143
+ out = model(batch["input_ids"], batch["attention_mask"])
144
+ preds = out["logits"].argmax(dim=-1).cpu().numpy()
145
+ labels = batch["labels"].cpu().numpy()
146
+ for i in range(cfg.NUM_ASPECTS):
147
+ all_preds[i].extend(preds[:, i].tolist())
148
+ all_labels[i].extend(labels[:, i].tolist())
149
+ return all_preds, all_labels
150
+
151
+
152
+ def predict_overall_from_proposed(model, tokenizer, test_df, device,
153
+ meta_encoder: MetaEncoder,
154
+ batch_size: int = 32):
155
+ """Use the Proposed model's overall_head to predict 3-class overall sentiment.
156
+
157
+ Returns (preds_list, labels_list) where labels come from 'overall_label' column.
158
+ """
159
+ test_df = test_df.reset_index(drop=True)
160
+ ds = MetaACSADataset(test_df, tokenizer, meta_encoder)
161
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
162
+
163
+ all_preds, all_labels = [], []
164
+ with torch.no_grad():
165
+ for batch in tqdm(loader, desc="predict overall (proposed head)"):
166
+ batch = {k: v.to(device) for k, v in batch.items()}
167
+ out = model(batch["input_ids"], batch["attention_mask"],
168
+ batch["meta_features"])
169
+ preds = out["overall_logits"].argmax(dim=-1).cpu().numpy()
170
+ all_preds.extend(preds.tolist())
171
+ if "overall_labels" in batch:
172
+ all_labels.extend(batch["overall_labels"].cpu().numpy().tolist())
173
+ else:
174
+ all_labels.extend(test_df["overall_label"].iloc[
175
+ len(all_labels):len(all_labels)+len(preds)].astype(int).tolist())
176
+ return all_preds, all_labels
177
+
178
+
179
+ def evaluate_per_aspect(all_preds, all_labels):
180
+ results = {"per_aspect": {}}
181
+ f1s, accs = [], []
182
+ for i, aspect in enumerate(cfg.ASPECTS):
183
+ y_true, y_pred = all_labels[i], all_preds[i]
184
+ report = classification_report(
185
+ y_true, y_pred,
186
+ labels=list(range(cfg.NUM_CLASSES)),
187
+ target_names=cfg.LABEL_NAMES, output_dict=True, zero_division=0,
188
+ )
189
+ cm = confusion_matrix(y_true, y_pred, labels=list(range(cfg.NUM_CLASSES))).tolist()
190
+ f1 = f1_score(y_true, y_pred, average="macro", zero_division=0)
191
+ acc = accuracy_score(y_true, y_pred)
192
+ results["per_aspect"][aspect] = {
193
+ "macro_f1": float(f1), "accuracy": float(acc),
194
+ "confusion_matrix": cm, "report": report,
195
+ }
196
+ f1s.append(f1); accs.append(acc)
197
+ results["overall"] = {
198
+ "mean_macro_f1": float(np.mean(f1s)) if f1s else 0.0,
199
+ "mean_accuracy": float(np.mean(accs)) if accs else 0.0,
200
+ }
201
+ return results
202
+
203
+
204
+ def predict_overall(model, tokenizer, test_df, device, batch_size: int = 32):
205
+ test_df = test_df.reset_index(drop=True)
206
+ ds = OverallSentimentDataset(test_df, tokenizer)
207
+ loader = DataLoader(ds, batch_size=batch_size, shuffle=False)
208
+ all_preds, all_labels = [], []
209
+ with torch.no_grad():
210
+ for batch in loader:
211
+ batch = {k: v.to(device) for k, v in batch.items()}
212
+ out = model(**batch)
213
+ all_preds.extend(out["logits"].argmax(dim=-1).cpu().numpy().tolist())
214
+ all_labels.extend(batch["labels"].cpu().numpy().tolist())
215
+ return all_preds, all_labels
216
+
217
+
218
+ def overall_metrics(y_true, y_pred):
219
+ return {
220
+ "test_macro_f1": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
221
+ "test_accuracy": float(accuracy_score(y_true, y_pred)),
222
+ "test_weighted_f1": float(f1_score(y_true, y_pred, average="weighted", zero_division=0)),
223
+ }
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Aggregation: per-aspect predictions -> single overall label
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def aspect_to_overall_sentiment(aspect_preds) -> np.ndarray:
231
+ """Improved voting rule for aggregating per-aspect 鈫?overall.
232
+
233
+ Rules (applied per review):
234
+ 1. Count n_pos and n_neg among MENTIONED aspects (skip Not_Mentioned).
235
+ 2. If no aspect is mentioned at all 鈫?NEUTRAL (conservative default).
236
+ 3. If n_neg 鈮?2 鈫?NEGATIVE (multiple negative aspects = clearly unhappy).
237
+ 4. If n_neg == 1 and n_pos == 0 鈫?NEGATIVE.
238
+ 5. If n_pos 鈮?2 and n_neg == 0 鈫?POSITIVE.
239
+ 6. If n_pos > n_neg (but not all positive) 鈫?POSITIVE.
240
+ 7. If n_neg > n_pos 鈫?NEGATIVE.
241
+ 8. Otherwise (tied, or only 1 pos and 0 neg) 鈫?NEUTRAL.
242
+
243
+ Input (per-aspect): 0=NM, 1=Pos, 2=Neg
244
+ Output (overall): 0=Neg, 1=Neu, 2=Pos
245
+ """
246
+ preds = np.asarray(aspect_preds)
247
+ if preds.ndim == 1:
248
+ preds = preds[None, :]
249
+
250
+ n_pos = (preds == ASPECT_POSITIVE).sum(axis=1)
251
+ n_neg = (preds == ASPECT_NEGATIVE).sum(axis=1)
252
+ n_mentioned = n_pos + n_neg
253
+
254
+ out = np.full(preds.shape[0], OVERALL_NEUTRAL, dtype=np.int64)
255
+
256
+ # No aspects mentioned 鈫?Neutral
257
+ # n_neg 鈮?2 鈫?Negative (strong signal)
258
+ out[n_neg >= 2] = OVERALL_NEGATIVE
259
+ # n_neg == 1, n_pos == 0 鈫?Negative
260
+ out[(n_neg == 1) & (n_pos == 0)] = OVERALL_NEGATIVE
261
+ # n_pos 鈮?2, n_neg == 0 鈫?Positive (strong signal)
262
+ out[(n_pos >= 2) & (n_neg == 0)] = OVERALL_POSITIVE
263
+ # n_pos > n_neg and n_pos 鈮?2 鈫?Positive
264
+ out[(n_pos > n_neg) & (n_pos >= 2)] = OVERALL_POSITIVE
265
+ # n_neg > n_pos 鈫?Negative (override the 鈮? positive if more negatives)
266
+ out[n_neg > n_pos] = OVERALL_NEGATIVE
267
+
268
+ return out
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Drilldown: category x aspect aggregation (application-layer)
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def aggregate_aspect_distribution_by_category(test_df, aspect_preds_per_aspect,
276
+ category_col: str = "leaf_category",
277
+ top_k_cats: int = 10):
278
+ df = test_df.copy().reset_index(drop=True)
279
+ preds = np.array(aspect_preds_per_aspect).T # (N, num_aspects)
280
+ if preds.shape[0] != len(df):
281
+ logger.warning("preds rows (%d) != df rows (%d); skipping aggregation.",
282
+ preds.shape[0], len(df))
283
+ return None
284
+ for i, a in enumerate(cfg.ASPECTS):
285
+ df[f"pred_{a}"] = preds[:, i]
286
+
287
+ if category_col not in df.columns:
288
+ logger.warning("No %s column for drilldown.", category_col)
289
+ return None
290
+ df = df[df[category_col].notna() &
291
+ (df[category_col].astype(str).str.len() > 0)]
292
+ if df.empty:
293
+ return None
294
+
295
+ top_cats = df[category_col].value_counts().head(top_k_cats).index.tolist()
296
+ rows = []
297
+ for cat in top_cats:
298
+ sub = df[df[category_col] == cat]
299
+ if len(sub) < 5:
300
+ continue
301
+ for a in cfg.ASPECTS:
302
+ p = sub[f"pred_{a}"]
303
+ mentioned = p[p != ASPECT_NOT_MENTIONED]
304
+ if len(mentioned) == 0:
305
+ pos_share, neg_share = 0.0, 0.0
306
+ else:
307
+ pos_share = float((mentioned == ASPECT_POSITIVE).mean())
308
+ neg_share = float((mentioned == ASPECT_NEGATIVE).mean())
309
+ rows.append({
310
+ "category": cat, "aspect": a, "n_total": len(sub),
311
+ "n_mentioned": int(len(mentioned)),
312
+ "positive_share": pos_share, "negative_share": neg_share,
313
+ })
314
+ return pd.DataFrame(rows)
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Single-review formatted output (for the customer's stated need)
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def format_aspect_summary(per_aspect_pred: np.ndarray) -> Dict[str, str]:
322
+ """For one review: {'SIZE': 'Positive', 'MATERIAL': 'Not_Mentioned', ...}."""
323
+ return {aspect: cfg.LABEL_NAMES[int(per_aspect_pred[i])]
324
+ for i, aspect in enumerate(cfg.ASPECTS)}
325
+
326
+
src/explainer.py ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explainability for the Proposed Model (BertMetaFusionACSAModel).
2
+
3
+ Three views:
4
+ 1. Cross-Attention weights over metadata tokens (per aspect, per example)
5
+ 2. Integrated Gradients on the input text (per aspect, per example)
6
+ 3. Aspect-level aggregation plots (category x aspect)
7
+
8
+ Saves an HTML report combining (1) and (2), plus PNG figures for (3).
9
+ """
10
+ import html as html_lib
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import List, Optional, Dict, Tuple
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import torch
18
+ import matplotlib
19
+ matplotlib.use("Agg")
20
+ import matplotlib.pyplot as plt
21
+ import seaborn as sns
22
+
23
+ from . import config as cfg
24
+ from .evaluator import load_meta_acsa
25
+ from .meta_encoder import MetaEncoder
26
+ from .models import META_NUM_META_TOKENS
27
+
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ META_TOKEN_NAMES = [f"meta_chunk_{i+1}" for i in range(META_NUM_META_TOKENS)]
32
+ SEMANTIC_META_TOKEN_NAMES = ["features", "categories", "numeric"]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Low-level rendering helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _normalize(arr):
40
+ arr = np.asarray(arr, dtype=np.float64)
41
+ if arr.size == 0:
42
+ return np.zeros_like(arr)
43
+ finite = arr[np.isfinite(arr)]
44
+ if finite.size == 0 or finite.max() == finite.min():
45
+ return np.zeros_like(arr)
46
+ return (arr - finite.min()) / (finite.max() - finite.min() + 1e-9)
47
+
48
+
49
+ def _normalize_for_display(scores, mask=None):
50
+ arr = np.asarray(scores, dtype=np.float64)
51
+ if mask is not None:
52
+ arr = arr * np.asarray(mask, dtype=np.float64)
53
+ arr = np.abs(arr)
54
+ if arr.size == 0 or float(arr.max()) <= 0:
55
+ return np.zeros_like(arr)
56
+ denom = np.percentile(arr[arr > 0], 95) if np.any(arr > 0) else arr.max()
57
+ denom = max(float(denom), 1e-9)
58
+ return np.clip(arr / denom, 0.0, 1.0)
59
+
60
+
61
+ def _color_for_score(score: float, label: int) -> str:
62
+ score = float(np.clip(score, 0.0, 1.0))
63
+ if score <= 0:
64
+ return "rgba(255,255,255,0)"
65
+ alpha = 0.18 + 0.72 * score
66
+ if label == 2:
67
+ return f"rgba(255, 80, 80, {alpha:.3f})"
68
+ if label == 1:
69
+ return f"rgba(80, 180, 110, {alpha:.3f})"
70
+ return f"rgba(150, 150, 150, {alpha:.3f})"
71
+
72
+
73
+ def _merge_wordpieces(tokens, scores):
74
+ words, vals = [], []
75
+ for tok, score in zip(tokens, scores):
76
+ tok = str(tok)
77
+ if not tok or (tok.startswith("[") and tok.endswith("]")):
78
+ continue
79
+ if tok.startswith("##") and words:
80
+ words[-1] += tok[2:]
81
+ vals[-1] = max(vals[-1], float(score))
82
+ else:
83
+ words.append(tok)
84
+ vals.append(float(score))
85
+ return words, vals
86
+
87
+
88
+ def _top_terms(tokens, scores, top_k: int = 6):
89
+ words, vals = _merge_wordpieces(tokens, scores)
90
+ pairs = [(w, v) for w, v in zip(words, vals) if _is_informative_term(w) and v > 0]
91
+ pairs.sort(key=lambda x: x[1], reverse=True)
92
+ return _dedupe_terms(pairs, top_k=top_k)
93
+
94
+
95
+ def _explanation_quality(top_terms, meta_source: str, meta_weight: float) -> str:
96
+ n_terms = len(top_terms or [])
97
+ if n_terms >= 3 and meta_source and meta_weight >= 0.45:
98
+ return "strong"
99
+ if n_terms >= 1 and meta_source:
100
+ return "moderate"
101
+ return "weak"
102
+
103
+
104
+ def _make_explanation_sentence(aspect: str, pred_label: str, top_terms, meta_source: str, meta_weight: float) -> str:
105
+ terms = [t for t, _ in (top_terms or [])[:4]]
106
+ text_part = ", ".join(f'"{t}"' for t in terms) if terms else "no strong text token"
107
+ meta_part = f'"{meta_source}" ({meta_weight:.3f})' if meta_source else "no dominant metadata source"
108
+ return (
109
+ f'{aspect} is predicted as {pred_label}. '
110
+ f'The main text evidence is {text_part}, and the strongest metadata source is {meta_part}.'
111
+ )
112
+
113
+
114
+ def _render_token_html(tokens, scores, label, top_k: int = 18):
115
+ norm = _normalize_for_display(scores)
116
+ positive_idx = np.where(norm > 0)[0]
117
+ if len(positive_idx) == 0:
118
+ words, vals = _merge_wordpieces(tokens, norm)
119
+ else:
120
+ ranked = positive_idx[np.argsort(norm[positive_idx])]
121
+ top_idx = set(ranked[-min(top_k, len(ranked)):].tolist())
122
+ display = np.zeros_like(norm)
123
+ for i in top_idx:
124
+ display[i] = max(float(norm[i]), 0.35)
125
+ words, vals = _merge_wordpieces(tokens, display)
126
+ spans = []
127
+ for word, val in zip(words, vals):
128
+ color = _color_for_score(val, label)
129
+ weight = "600" if val >= 0.55 else "400"
130
+ border = " border-bottom:1px solid rgba(0,0,0,.18);" if val >= 0.35 else ""
131
+ spans.append(
132
+ f'<span style="background-color:{color};padding:2px 4px;'
133
+ f'border-radius:4px;margin:1px;font-weight:{weight};{border}">'
134
+ f'{html_lib.escape(word)}</span>'
135
+ )
136
+ return " ".join(spans)
137
+
138
+
139
+ def _meta_summary_from_row(row) -> Dict[str, str]:
140
+ def first_existing(names, default=""):
141
+ for name in names:
142
+ if name not in row:
143
+ continue
144
+ val = row[name]
145
+ if isinstance(val, (list, tuple)):
146
+ return "; ".join(map(str, val[:8]))
147
+ if pd.notna(val):
148
+ return str(val)
149
+ return default
150
+
151
+ features = first_existing(["features_text", "features", "description", "title"])
152
+ categories = first_existing(["categories_text", "category", "leaf_category", "main_category"])
153
+ numeric_parts = []
154
+ for col in ["price", "average_rating", "rating_number"]:
155
+ if col in row and pd.notna(row[col]):
156
+ numeric_parts.append(f"{col}={row[col]}")
157
+ return {
158
+ "features": features[:420],
159
+ "categories": categories[:220],
160
+ "numeric": ", ".join(numeric_parts) if numeric_parts else "not available",
161
+ }
162
+
163
+
164
+ def _top_meta_source(meta_attn, token_names=None) -> Tuple[str, float]:
165
+ arr = np.asarray(meta_attn, dtype=np.float64).reshape(-1)
166
+ if arr.size == 0:
167
+ return "", 0.0
168
+ names = token_names or META_TOKEN_NAMES
169
+ idx = int(np.argmax(arr))
170
+ name = names[idx] if idx < len(names) else f"meta_{idx+1}"
171
+ return name, float(arr[idx])
172
+
173
+ _ASPECT_EVIDENCE_KEYWORDS = {
174
+ "SIZE": {"size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length"},
175
+ "MATERIAL": {"material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "leather", "wool"},
176
+ "QUALITY": {"quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "tear", "torn"},
177
+ "APPEARANCE": {"look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design"},
178
+ "STYLE": {"style", "stylish", "flattering", "casual", "formal", "dress", "shirt", "fashion", "compliments"},
179
+ "VALUE": {"price", "worth", "value", "money", "cheap", "expensive", "discount", "penny", "cost"},
180
+ }
181
+
182
+ _SENTIMENT_EVIDENCE_KEYWORDS = {
183
+ "good", "great", "love", "loved", "perfect", "nice", "excellent", "comfortable", "soft",
184
+ "bad", "poor", "cheap", "terrible", "awful", "small", "large", "tight", "loose", "thin",
185
+ "worth", "disappointed", "return", "returned", "recommend", "flattering", "beautiful",
186
+ }
187
+
188
+ _STOPWORDS = {
189
+ "a", "an", "the", "and", "or", "but", "if", "then", "than", "so", "as", "at", "by", "for", "from",
190
+ "in", "into", "of", "on", "to", "with", "without", "is", "are", "was", "were", "be", "been", "being",
191
+ "it", "its", "this", "that", "these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "she", "they",
192
+ "them", "his", "her", "their", "very", "really", "just", "also", "too", "would", "could", "should", "can",
193
+ "will", "did", "do", "does", "have", "has", "had", "there", "here", "about", "after", "before",
194
+ }
195
+
196
+
197
+ def _clean_term(term: str) -> str:
198
+ return str(term).lower().replace("##", "").strip(".,!?;:'\"()[]{}<>/\\|`~@#$%^&*_+=")
199
+
200
+
201
+ def _is_informative_term(term: str) -> bool:
202
+ clean = _clean_term(term)
203
+ if len(clean) < 2 or clean in _STOPWORDS:
204
+ return False
205
+ return any(ch.isalpha() for ch in clean)
206
+
207
+
208
+ def _dedupe_terms(terms, top_k: int = 6):
209
+ seen, out = set(), []
210
+ for term, score in terms:
211
+ clean = _clean_term(term)
212
+ if not _is_informative_term(clean) or clean in seen:
213
+ continue
214
+ seen.add(clean)
215
+ out.append((clean, float(score)))
216
+ if len(out) >= top_k:
217
+ break
218
+ return out
219
+
220
+ def _lexical_evidence_scores(tokens, aspect_idx: int):
221
+ aspect = cfg.ASPECTS[aspect_idx]
222
+ aspect_terms = _ASPECT_EVIDENCE_KEYWORDS.get(aspect, set())
223
+ scores = []
224
+ prev = ""
225
+ for tok in tokens:
226
+ clean = str(tok).lower().replace("##", "")
227
+ clean = clean.strip(".,!?;:'\"()[]{}")
228
+ if not clean or clean.startswith("["):
229
+ scores.append(0.0)
230
+ prev = clean
231
+ continue
232
+ score = 0.0
233
+ if clean in aspect_terms:
234
+ score += 1.0
235
+ if clean in _SENTIMENT_EVIDENCE_KEYWORDS:
236
+ score += 0.55
237
+ if prev in {"not", "no", "never", "too", "very", "really"} and score > 0:
238
+ score += 0.25
239
+ scores.append(score)
240
+ prev = clean
241
+ return np.asarray(scores, dtype=np.float32)
242
+
243
+
244
+ def _has_visible_scores(scores) -> bool:
245
+ arr = np.asarray(scores, dtype=np.float64)
246
+ return bool(arr.size and np.isfinite(arr).any() and float(np.nanmax(np.abs(arr))) > 1e-12)
247
+ # ---------------------------------------------------------------------------
248
+ # Integrated Gradients (per aspect)
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def _ig_for_aspect(model, tokenizer, meta_encoder, row, device,
252
+ aspect_idx: int, n_steps: int = 30):
253
+ """Gradient x embedding attribution for one aspect head.
254
+
255
+ Captum's LayerIntegratedGradients can be brittle with wrapped transformer
256
+ modules in notebooks. This path keeps the same goal, but computes a direct
257
+ first-order attribution from the input embeddings, then falls back to
258
+ lexical evidence if gradients are unavailable.
259
+ """
260
+ model.eval()
261
+ text = str(row["full_text"])
262
+ enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True,
263
+ padding="max_length", return_tensors="pt")
264
+ input_ids = enc["input_ids"].to(device)
265
+ attention_mask = enc["attention_mask"].to(device)
266
+ tokens = tokenizer.convert_ids_to_tokens(input_ids[0].detach().cpu().numpy().tolist())
267
+ mask = attention_mask[0].detach().cpu().numpy().astype(bool)
268
+
269
+ meta_vec = torch.from_numpy(
270
+ meta_encoder.transform(pd.DataFrame([row]))
271
+ ).float().to(device)
272
+
273
+ try:
274
+ model.zero_grad(set_to_none=True)
275
+ embeds = model.bert.embeddings(input_ids=input_ids)
276
+ embeds = embeds.detach().requires_grad_(True)
277
+ bert_out = model.bert(
278
+ inputs_embeds=embeds,
279
+ attention_mask=attention_mask,
280
+ return_dict=True,
281
+ )
282
+ text_vec = bert_out.last_hidden_state[:, 0, :]
283
+
284
+ # Mirror GatedAspectSemanticMetaFusionACSAModel.forward from embeddings.
285
+ meta_tokens = model.meta_tokenizer(meta_vec)
286
+ meta_for_concat = meta_vec.clone()
287
+ numeric_start = cfg.META_TFIDF_DIM
288
+ meta_for_concat[:, numeric_start:] = (
289
+ meta_for_concat[:, numeric_start:] * cfg.META_NUMERIC_TOKEN_SCALE
290
+ )
291
+ concat_meta = model.concat_meta_mlp(meta_for_concat)
292
+ concat_base = model.concat_norm(
293
+ model.concat_fusion(torch.cat([text_vec, concat_meta], dim=-1))
294
+ )
295
+ aspect_fused, _, _ = model.aspect_fusion(text_vec, meta_tokens)
296
+ text_expanded = text_vec.unsqueeze(1).expand(-1, model.num_aspects, -1)
297
+ aspect_delta = aspect_fused - text_expanded
298
+ scale = torch.clamp(model.cross_residual_scale, 0.0, 1.0)
299
+ aspect_repr = model.aspect_refine_norm(
300
+ concat_base.unsqueeze(1) + scale * aspect_delta
301
+ )
302
+ logits = model.heads.forward_per_aspect(aspect_repr)
303
+ target_class = int(logits[0, aspect_idx, :].argmax(dim=-1).item())
304
+ target_logit = logits[0, aspect_idx, target_class]
305
+ target_logit.backward()
306
+
307
+ grad = embeds.grad.detach()[0]
308
+ attr = (grad * embeds.detach()[0]).sum(dim=-1).detach().cpu().numpy()
309
+ attr = np.abs(attr) * mask
310
+ if not _has_visible_scores(attr):
311
+ attr = _lexical_evidence_scores(tokens, aspect_idx) * mask
312
+ return tokens, attr, mask, target_class
313
+ except Exception as e:
314
+ logger.warning("Gradient attribution failed for aspect %s: %s; using lexical evidence.",
315
+ cfg.ASPECTS[aspect_idx], e)
316
+ with torch.no_grad():
317
+ out = model(input_ids, attention_mask, meta_vec)
318
+ target_class = int(out["logits"][0, aspect_idx, :].argmax(dim=-1).item())
319
+ attr = _lexical_evidence_scores(tokens, aspect_idx) * mask
320
+ return tokens, attr, mask, target_class
321
+
322
+ def _attention_for_aspect(model, tokenizer, meta_encoder, row, device,
323
+ aspect_idx: int):
324
+ """Fallback when captum is not installed: use the model's own
325
+ cross-attention output as a coarse word-level proxy is impossible
326
+ (cross-attn is over meta tokens, not BERT tokens), so we return BERT
327
+ self-attention from [CLS] -> tokens as a rough text attribution.
328
+ """
329
+ model.eval()
330
+ text = str(row["full_text"])
331
+ enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True,
332
+ padding="max_length", return_tensors="pt")
333
+ input_ids = enc["input_ids"].to(device)
334
+ attention_mask = enc["attention_mask"].to(device)
335
+ meta_vec = torch.from_numpy(
336
+ meta_encoder.transform(pd.DataFrame([row]))
337
+ ).float().to(device)
338
+
339
+ with torch.no_grad():
340
+ out = model(input_ids, attention_mask, meta_vec, output_attentions=True)
341
+
342
+ pred_class = int(out["logits"][0, aspect_idx, :].argmax().item())
343
+ tokens = tokenizer.convert_ids_to_tokens(input_ids[0].cpu().numpy().tolist())
344
+ mask = attention_mask[0].cpu().numpy().astype(bool)
345
+
346
+ bert_attn = out.get("bert_attentions")
347
+ if bert_attn:
348
+ last = bert_attn[-1][0] # (heads, seq, seq)
349
+ cls_attn = last[:, 0, :].mean(0).cpu().numpy()
350
+ else:
351
+ cls_attn = np.zeros_like(mask, dtype=np.float32)
352
+ cls_attn = np.asarray(cls_attn, dtype=np.float32) * mask
353
+ if not _has_visible_scores(cls_attn):
354
+ cls_attn = _lexical_evidence_scores(tokens, aspect_idx) * mask
355
+ return tokens, cls_attn, mask, pred_class
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Cross-Attention weights over meta tokens (per aspect, per example)
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def _meta_token_names_from_output(out, attn):
363
+ names = out.get("meta_token_names")
364
+ if names is not None:
365
+ return list(names)
366
+ arr = np.asarray(attn)
367
+ width = int(arr.shape[-1]) if arr.ndim else int(arr.size)
368
+ if width == 3:
369
+ return SEMANTIC_META_TOKEN_NAMES
370
+ return [f"meta_chunk_{i+1}" for i in range(width)]
371
+
372
+
373
+ def get_meta_attention(model, tokenizer, meta_encoder, row, device):
374
+ """Return display meta attention, predictions, names, and optional per-aspect attention."""
375
+ text = str(row["full_text"])
376
+ enc = tokenizer(text, max_length=cfg.MAX_LENGTH, truncation=True,
377
+ padding="max_length", return_tensors="pt")
378
+ input_ids = enc["input_ids"].to(device)
379
+ attention_mask = enc["attention_mask"].to(device)
380
+ meta_vec = torch.from_numpy(
381
+ meta_encoder.transform(pd.DataFrame([row]))
382
+ ).float().to(device)
383
+ with torch.no_grad():
384
+ out = model(input_ids, attention_mask, meta_vec)
385
+
386
+ raw_attn = out["meta_attn_weights"][0].cpu().numpy()
387
+ token_names = _meta_token_names_from_output(out, raw_attn)
388
+ if raw_attn.ndim == 1:
389
+ display_attn = raw_attn
390
+ aspect_attn = None
391
+ else:
392
+ aspect_attn = raw_attn
393
+ if "global_meta_attn_weights" in out and out["global_meta_attn_weights"] is not None:
394
+ display_attn = out["global_meta_attn_weights"][0].cpu().numpy()
395
+ else:
396
+ display_attn = raw_attn.mean(axis=0)
397
+
398
+ preds = out["logits"][0].argmax(dim=-1).cpu().numpy()
399
+ return display_attn, preds, token_names, aspect_attn
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # HTML report builder (combines text IG + meta attention)
403
+ # ---------------------------------------------------------------------------
404
+
405
+ def _render_meta_bar_html(meta_attn: np.ndarray, token_names: Optional[List[str]] = None) -> str:
406
+ """Render a tiny inline bar chart for one metadata-attention vector."""
407
+ meta_attn = np.asarray(meta_attn, dtype=np.float64).reshape(-1)
408
+ token_names = token_names or META_TOKEN_NAMES
409
+ norm = _normalize(meta_attn)
410
+ parts = ['<div style="display:flex;gap:6px;align-items:end;height:60px;'
411
+ 'margin-top:8px;">']
412
+ for i, (n, raw) in enumerate(zip(norm, meta_attn)):
413
+ name = token_names[i] if i < len(token_names) else f"meta_{i+1}"
414
+ h = int(8 + 50 * float(n))
415
+ parts.append(
416
+ f'<div style="text-align:center;width:80px;">'
417
+ f'<div style="background:#5b8def;height:{h}px;'
418
+ f'border-radius:3px 3px 0 0;"></div>'
419
+ f'<div style="font-size:11px;margin-top:2px;color:#666;">'
420
+ f'{html_lib.escape(name)}<br/>{float(raw):.3f}</div></div>'
421
+ )
422
+ parts.append('</div>')
423
+ return "".join(parts)
424
+
425
+
426
+ def _render_meta_matrix_html(meta_attn_by_aspect, token_names: Optional[List[str]] = None) -> str:
427
+ """Render aspect-specific metadata attention as compact rows."""
428
+ if meta_attn_by_aspect is None:
429
+ return ""
430
+ arr = np.asarray(meta_attn_by_aspect, dtype=np.float64)
431
+ if arr.ndim != 2:
432
+ return ""
433
+ token_names = token_names or META_TOKEN_NAMES
434
+ parts = ['<div style="margin-top:8px;display:grid;gap:6px;">']
435
+ for i, aspect in enumerate(cfg.ASPECTS):
436
+ if i >= arr.shape[0]:
437
+ break
438
+ weights = arr[i]
439
+ norm = _normalize(weights)
440
+ parts.append('<div style="display:flex;align-items:center;gap:8px;">')
441
+ parts.append(f'<div style="width:95px;font-size:12px;font-weight:600;">{aspect}</div>')
442
+ for j, (n, raw) in enumerate(zip(norm, weights)):
443
+ name = token_names[j] if j < len(token_names) else f"meta_{j+1}"
444
+ w = int(35 + 75 * float(n))
445
+ parts.append(
446
+ f'<div title="{html_lib.escape(name)}: {float(raw):.3f}" '
447
+ f'style="background:#dbe7ff;border-left:4px solid #5b8def;'
448
+ f'width:{w}px;padding:2px 4px;border-radius:4px;font-size:11px;">'
449
+ f'{html_lib.escape(name)} {float(raw):.2f}</div>'
450
+ )
451
+ parts.append('</div>')
452
+ parts.append('</div>')
453
+ return "".join(parts)
454
+
455
+ def _render_meta_summary_html(meta_summary: Dict[str, str]) -> str:
456
+ rows = []
457
+ for name in ["features", "categories", "numeric"]:
458
+ value = html_lib.escape(str(meta_summary.get(name, "")))
459
+ rows.append(
460
+ f'<div><b>{html_lib.escape(name)}:</b> '
461
+ f'<span style="color:#444">{value or "not available"}</span></div>'
462
+ )
463
+ return '<div class="meta-box">' + "".join(rows) + '</div>'
464
+
465
+
466
+ def build_explanation_html(examples: List[dict], output_path: Path):
467
+ parts = [
468
+ "<!doctype html><html><head><meta charset='utf-8'>",
469
+ "<title>ACSA + Meta Fusion Explanations</title>",
470
+ "<style>",
471
+ "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 1180px; margin: 24px auto; padding: 0 16px; color: #222; }",
472
+ ".example { border: 1px solid #d8d8d8; padding: 16px; margin-bottom: 24px; border-radius: 8px; background:#fff; }",
473
+ ".meta { font-size: 13px; color: #666; margin-bottom: 8px; }",
474
+ ".meta-box { background:#f6f8fb; padding:10px 12px; border-radius:6px; font-size:13px; line-height:1.55; margin:10px 0 12px; }",
475
+ ".review-box { background:#fafafa; padding:10px 12px; border-radius:6px; margin-bottom:12px; line-height:1.55; }",
476
+ ".aspect-row { padding: 10px 0; border-bottom: 1px dashed #e6e6e6; line-height: 1.7; }",
477
+ ".aspect-label { display: inline-block; min-width: 130px; font-weight: 700; }",
478
+ ".pred-pos { color: #21834a; font-weight: 700; }",
479
+ ".pred-neg { color: #bd2f24; font-weight: 700; }",
480
+ ".pred-na { color: #777; font-weight: 600; }",
481
+ ".evidence { margin-top:6px; font-size:13px; color:#444; }",
482
+ ".chip { display:inline-block; background:#eef2ff; border:1px solid #d6def8; padding:1px 6px; border-radius:999px; margin:1px 3px 1px 0; font-size:12px; }",
483
+ "h1 { margin-bottom: 8px; } h2 { margin-top: 8px; }",
484
+ ".legend { background: #f8f8f8; padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height:1.5; }",
485
+ ".section-h { font-weight:700;margin-top:16px;color:#333;font-size:14px; }",
486
+ "</style></head><body>",
487
+ "<h1>Aspect-Level Sentiment with Metadata Fusion: Explanations</h1>",
488
+ '<div class="legend">'
489
+ 'Highlights show the strongest text evidence for each aspect. '
490
+ '<span style="background:rgba(80,180,110,0.7);padding:2px 6px;border-radius:3px">green</span> = Positive, '
491
+ '<span style="background:rgba(255,80,80,0.7);padding:2px 6px;border-radius:3px">red</span> = Negative, '
492
+ '<span style="background:rgba(150,150,150,0.7);padding:2px 6px;border-radius:3px">grey</span> = Not_Mentioned. '
493
+ 'Metadata rows show which source the model attends to for each aspect.'
494
+ '</div>',
495
+ ]
496
+ for i, ex in enumerate(examples):
497
+ parts.append(f'<div class="example"><h2>Example {i+1}</h2>')
498
+ parts.append(f'<div class="meta">Rating: {ex.get("rating", "?")} | Category: {html_lib.escape(str(ex.get("category", "?")))}</div>')
499
+ parts.append(f'<div class="review-box">{html_lib.escape(ex["text"])}</div>')
500
+ parts.append(_render_meta_summary_html(ex.get("meta_summary", {})))
501
+
502
+ parts.append('<div class="section-h">Per-aspect prediction, text evidence, and metadata source</div>')
503
+ token_names = ex.get("meta_token_names")
504
+ meta_by_aspect = ex.get("meta_attn_by_aspect")
505
+ for idx, a in enumerate(ex["aspects"]):
506
+ pred = a["pred_label"]
507
+ name = cfg.LABEL_NAMES[pred]
508
+ css = {0: "pred-na", 1: "pred-pos", 2: "pred-neg"}[pred]
509
+ parts.append('<div class="aspect-row">')
510
+ parts.append(f'<span class="aspect-label">{a["aspect"]}:</span> <span class="{css}">{name}</span>')
511
+
512
+ if "tokens" in a and "scores" in a:
513
+ terms = a.get("top_terms") or []
514
+ chips = "".join(f'<span class="chip">{html_lib.escape(t)}</span>' for t, _ in terms[:6])
515
+ if chips:
516
+ parts.append(f'<div class="evidence"><b>Top text evidence:</b> {chips}</div>')
517
+ tok_html = _render_token_html(a["tokens"], a["scores"], pred)
518
+ parts.append(f'<div style="margin-top:6px">{tok_html}</div>')
519
+
520
+ if meta_by_aspect is not None and idx < len(meta_by_aspect):
521
+ source, weight = _top_meta_source(meta_by_aspect[idx], token_names)
522
+ else:
523
+ source, weight = _top_meta_source(ex.get("meta_attn", []), token_names)
524
+ quality = a.get("evidence_quality", "weak")
525
+ explanation = a.get("explanation_sentence", "")
526
+ if source:
527
+ parts.append(
528
+ f'<div class="evidence"><b>Top metadata source:</b> '
529
+ f'<span class="chip">{html_lib.escape(source)} {weight:.3f}</span> '
530
+ f'<span class="chip">quality: {html_lib.escape(quality)}</span></div>'
531
+ )
532
+ if explanation:
533
+ parts.append(
534
+ f'<div class="evidence"><b>Explanation:</b> '
535
+ f'{html_lib.escape(explanation)}</div>'
536
+ )
537
+ parts.append('</div>')
538
+
539
+ parts.append('<div class="section-h">Metadata cross-attention (global / averaged)</div>')
540
+ parts.append(_render_meta_bar_html(np.asarray(ex["meta_attn"]), token_names))
541
+ if meta_by_aspect is not None:
542
+ parts.append('<div class="section-h">Aspect-specific metadata attention</div>')
543
+ parts.append(_render_meta_matrix_html(np.asarray(meta_by_aspect), token_names))
544
+ parts.append('</div>')
545
+
546
+ parts.append('</body></html>')
547
+ output_path = Path(output_path)
548
+ output_path.write_text("\n".join(parts), encoding="utf-8")
549
+ logger.info("Wrote explanation HTML to %s", output_path)
550
+
551
+
552
+ def write_explanation_summary(examples: List[dict], output_path: Path):
553
+ rows = []
554
+ for ex_idx, ex in enumerate(examples, start=1):
555
+ token_names = ex.get("meta_token_names")
556
+ meta_by_aspect = ex.get("meta_attn_by_aspect")
557
+ for aspect_idx, aspect_data in enumerate(ex.get("aspects", [])):
558
+ if meta_by_aspect is not None and aspect_idx < len(meta_by_aspect):
559
+ source, weight = _top_meta_source(meta_by_aspect[aspect_idx], token_names)
560
+ else:
561
+ source, weight = _top_meta_source(ex.get("meta_attn", []), token_names)
562
+ pred_name = cfg.LABEL_NAMES[int(aspect_data.get("pred_label", 0))]
563
+ rows.append({
564
+ "example": ex_idx,
565
+ "rating": ex.get("rating"),
566
+ "category": ex.get("category"),
567
+ "aspect": aspect_data.get("aspect"),
568
+ "pred_label": pred_name,
569
+ "evidence_quality": aspect_data.get("evidence_quality", "weak"),
570
+ "explanation_sentence": aspect_data.get("explanation_sentence", ""),
571
+ "top_text_terms": "; ".join(t for t, _ in aspect_data.get("top_terms", [])[:8]),
572
+ "top_meta_source": source,
573
+ "top_meta_weight": weight,
574
+ "features": ex.get("meta_summary", {}).get("features", ""),
575
+ "categories": ex.get("meta_summary", {}).get("categories", ""),
576
+ "numeric": ex.get("meta_summary", {}).get("numeric", ""),
577
+ "text": ex.get("text", ""),
578
+ })
579
+ pd.DataFrame(rows).to_csv(output_path, index=False)
580
+ logger.info("Wrote explanation summary to %s", output_path)
581
+
582
+ # ---------------------------------------------------------------------------
583
+ # High-level: pick examples, run all attributions, save HTML
584
+ # ---------------------------------------------------------------------------
585
+
586
+ def explain_examples(test_df, n_examples: int = 8, method: str = "ig",
587
+ output_path: Optional[Path] = None,
588
+ checkpoint_dir: Optional[Path] = None,
589
+ meta_encoder: Optional[MetaEncoder] = None):
590
+ """Pick a mix of ratings from test_df and produce an explanation HTML."""
591
+ if output_path is None:
592
+ output_path = cfg.REPORT_DIR / f"explanation_{method}.html"
593
+
594
+ model, tokenizer, meta_encoder, device = load_meta_acsa(checkpoint_dir, meta_encoder)
595
+
596
+ # Sample across ratings to get diversity
597
+ per_rating = max(1, n_examples // 5)
598
+ chosen = []
599
+ for r in [1, 2, 3, 4, 5]:
600
+ sub = test_df[test_df["rating"] == r]
601
+ if len(sub) > 0:
602
+ chosen.append(sub.sample(n=min(per_rating, len(sub)),
603
+ random_state=cfg.RANDOM_SEED))
604
+ if not chosen:
605
+ chosen = [test_df.sample(n=min(n_examples, len(test_df)),
606
+ random_state=cfg.RANDOM_SEED)]
607
+ selected = pd.concat(chosen).head(n_examples).reset_index(drop=True)
608
+
609
+ try:
610
+ import captum # noqa: F401
611
+ captum_ok = True
612
+ except ImportError:
613
+ logger.warning("captum not installed; falling back to BERT [CLS]-attention as proxy.")
614
+ captum_ok = False
615
+ method = "attention"
616
+
617
+ examples_data = []
618
+ for _, row in selected.iterrows():
619
+ meta_attn, _, meta_token_names, meta_attn_by_aspect = get_meta_attention(model, tokenizer, meta_encoder, row, device)
620
+ ex = {
621
+ "text": str(row["full_text"]),
622
+ "rating": int(row["rating"]),
623
+ "category": str(row.get("leaf_category", "")),
624
+ "meta_summary": _meta_summary_from_row(row),
625
+ "meta_attn": meta_attn.tolist(),
626
+ "meta_token_names": meta_token_names,
627
+ "meta_attn_by_aspect": (meta_attn_by_aspect.tolist() if meta_attn_by_aspect is not None else None),
628
+ "aspects": [],
629
+ }
630
+ for i, aspect in enumerate(cfg.ASPECTS):
631
+ try:
632
+ if method == "ig" and captum_ok:
633
+ tokens, attr, mask, pred = _ig_for_aspect(
634
+ model, tokenizer, meta_encoder, row, device, i,
635
+ )
636
+ else:
637
+ tokens, attr, mask, pred = _attention_for_aspect(
638
+ model, tokenizer, meta_encoder, row, device, i,
639
+ )
640
+ scores = np.abs(attr) * mask
641
+ display_scores = _normalize_for_display(scores)
642
+ top_terms = _top_terms(tokens, display_scores)
643
+ if meta_attn_by_aspect is not None and i < len(meta_attn_by_aspect):
644
+ source, weight = _top_meta_source(meta_attn_by_aspect[i], meta_token_names)
645
+ else:
646
+ source, weight = _top_meta_source(meta_attn, meta_token_names)
647
+ pred_name = cfg.LABEL_NAMES[int(pred)]
648
+ quality = _explanation_quality(top_terms, source, weight)
649
+ ex["aspects"].append({
650
+ "aspect": aspect, "pred_label": int(pred),
651
+ "tokens": tokens, "scores": scores,
652
+ "top_terms": top_terms,
653
+ "evidence_quality": quality,
654
+ "explanation_sentence": _make_explanation_sentence(
655
+ aspect, pred_name, top_terms, source, weight,
656
+ ),
657
+ })
658
+ except Exception as e:
659
+ logger.warning("Attribution failed for aspect %s: %s", aspect, e)
660
+ # Still record the prediction even without scores
661
+ ex["aspects"].append({"aspect": aspect, "pred_label": 0})
662
+ examples_data.append(ex)
663
+
664
+ build_explanation_html(examples_data, output_path)
665
+ summary_path = Path(output_path).with_name(Path(output_path).stem + "_summary.csv")
666
+ write_explanation_summary(examples_data, summary_path)
667
+ return examples_data
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # Aggregation plots (application-layer)
672
+ # ---------------------------------------------------------------------------
673
+
674
+ def plot_aspect_distribution(df_with_preds: pd.DataFrame,
675
+ output_path: Optional[Path] = None):
676
+ """Bar chart of Positive/Negative share per aspect (over mentioned rows)."""
677
+ if output_path is None:
678
+ output_path = cfg.REPORT_DIR / "aspect_distribution.png"
679
+ rows = []
680
+ for a in cfg.ASPECTS:
681
+ col = f"pred_{a}" if f"pred_{a}" in df_with_preds.columns else f"aspect_{a}"
682
+ if col not in df_with_preds.columns:
683
+ continue
684
+ vc = df_with_preds[col].value_counts()
685
+ n_pos = int(vc.get(1, 0)); n_neg = int(vc.get(2, 0))
686
+ n_mentioned = n_pos + n_neg
687
+ if n_mentioned == 0:
688
+ continue
689
+ rows.append({"aspect": a, "positive": n_pos / n_mentioned,
690
+ "negative": n_neg / n_mentioned, "n_mentioned": n_mentioned})
691
+ if not rows:
692
+ logger.warning("No data to plot for aspect distribution.")
693
+ return None
694
+
695
+ dfp = pd.DataFrame(rows)
696
+ fig, ax = plt.subplots(figsize=(10, 5))
697
+ x = np.arange(len(dfp)); w = 0.35
698
+ ax.bar(x - w/2, dfp["positive"], w, label="Positive share", color="#2a9d4f")
699
+ ax.bar(x + w/2, dfp["negative"], w, label="Negative share", color="#c0392b")
700
+ ax.set_xticks(x); ax.set_xticklabels(dfp["aspect"], rotation=20)
701
+ ax.set_ylabel("Share among mentioned reviews")
702
+ ax.set_title("Aspect-level sentiment distribution")
703
+ ax.legend()
704
+ for i, r in dfp.iterrows():
705
+ ax.text(i, max(r["positive"], r["negative"]) + 0.02,
706
+ f"n={r['n_mentioned']}", ha="center", fontsize=9)
707
+ plt.tight_layout(); plt.savefig(output_path, dpi=120); plt.close()
708
+ logger.info("Saved aspect distribution plot to %s", output_path)
709
+ return dfp
710
+
711
+
712
+ def plot_category_aspect_heatmap(agg_df: pd.DataFrame, metric: str = "negative_share",
713
+ output_path: Optional[Path] = None):
714
+ if agg_df is None or agg_df.empty:
715
+ return None
716
+ if output_path is None:
717
+ output_path = cfg.REPORT_DIR / f"category_aspect_{metric}.png"
718
+ pivot = agg_df.pivot(index="category", columns="aspect", values=metric)
719
+ fig, ax = plt.subplots(figsize=(10, max(4, len(pivot) * 0.4)))
720
+ cmap = "RdYlGn_r" if "negative" in metric else "RdYlGn"
721
+ sns.heatmap(pivot, annot=True, fmt=".2f", cmap=cmap, ax=ax,
722
+ cbar_kws={"label": metric})
723
+ ax.set_title(f"{metric.replace('_', ' ').title()} by category 闂?aspect")
724
+ plt.tight_layout(); plt.savefig(output_path, dpi=120); plt.close()
725
+ logger.info("Saved heatmap to %s", output_path)
726
+ return pivot
src/inference.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-facing inference for the Proposed model."""
2
+ from typing import Dict, List, Optional
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import torch
7
+
8
+ from . import config as cfg
9
+ from .evaluator import load_meta_acsa, format_aspect_summary
10
+
11
+
12
+ _EXPECTED_META_KEYS = (
13
+ "features_text", "categories_text",
14
+ "price", "average_rating", "rating_number",
15
+ )
16
+
17
+
18
+ def _meta_dict_to_df(meta: Dict) -> pd.DataFrame:
19
+ """Build a one-row DataFrame the MetaEncoder can transform."""
20
+ row = {k: meta.get(k) for k in _EXPECTED_META_KEYS}
21
+ return pd.DataFrame([row])
22
+
23
+
24
+ def _attention_names(out: Dict, attn_array) -> List[str]:
25
+ names = out.get("meta_token_names")
26
+ if names is not None:
27
+ return list(names)
28
+ arr = np.asarray(attn_array)
29
+ width = int(arr.shape[-1]) if arr.ndim else int(arr.size)
30
+ if width == 3:
31
+ return ["features", "categories", "numeric"]
32
+ return [f"meta_chunk_{i + 1}" for i in range(width)]
33
+
34
+
35
+ def _attention_dict(names: List[str], weights) -> Dict[str, float]:
36
+ arr = np.asarray(weights, dtype=np.float64).reshape(-1)
37
+ return {name: float(w) for name, w in zip(names, arr)}
38
+
39
+ def _top_attention_item(attn: Dict[str, float]) -> Dict[str, float]:
40
+ if not attn:
41
+ return {"source": "", "weight": 0.0}
42
+ source = max(attn, key=attn.get)
43
+ return {"source": source, "weight": float(attn[source])}
44
+
45
+
46
+ def _meta_summary(meta: Dict) -> Dict[str, str]:
47
+ features = str(meta.get("features_text") or meta.get("features") or "")[:420]
48
+ categories = str(meta.get("categories_text") or meta.get("category") or "")[:220]
49
+ numeric = []
50
+ for key in ("price", "average_rating", "rating_number"):
51
+ val = meta.get(key)
52
+ if val is not None:
53
+ numeric.append(f"{key}={val}")
54
+ return {
55
+ "features": features,
56
+ "categories": categories,
57
+ "numeric": ", ".join(numeric) if numeric else "not available",
58
+ }
59
+
60
+ _ASPECT_EVIDENCE_KEYWORDS = {
61
+ "SIZE": {"size", "fit", "fits", "fitting", "small", "large", "big", "tight", "loose", "xl", "medium", "waist", "length"},
62
+ "MATERIAL": {"material", "fabric", "cotton", "polyester", "soft", "scratchy", "thin", "thick", "stretch", "leather", "wool"},
63
+ "QUALITY": {"quality", "stitch", "stitching", "seam", "wash", "washed", "durable", "cheap", "broke", "tear", "torn"},
64
+ "APPEARANCE": {"look", "looks", "color", "colour", "photo", "picture", "beautiful", "cute", "print", "design"},
65
+ "STYLE": {"style", "stylish", "flattering", "casual", "formal", "dress", "shirt", "fashion", "compliments"},
66
+ "VALUE": {"price", "worth", "value", "money", "cheap", "expensive", "discount", "penny", "cost"},
67
+ }
68
+
69
+ _SENTIMENT_EVIDENCE_KEYWORDS = {
70
+ "good", "great", "love", "loved", "perfect", "nice", "excellent", "comfortable", "soft",
71
+ "bad", "poor", "cheap", "terrible", "awful", "small", "large", "tight", "loose", "thin",
72
+ "worth", "disappointed", "return", "returned", "recommend", "flattering", "beautiful",
73
+ }
74
+ _STOPWORDS = {
75
+ "a", "an", "the", "and", "or", "but", "if", "then", "than", "so", "as", "at", "by", "for", "from",
76
+ "in", "into", "of", "on", "to", "with", "without", "is", "are", "was", "were", "be", "been", "being",
77
+ "it", "its", "this", "that", "these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "she", "they",
78
+ "them", "his", "her", "their", "very", "really", "just", "also", "too", "would", "could", "should", "can",
79
+ "will", "did", "do", "does", "have", "has", "had", "there", "here", "about", "after", "before",
80
+ }
81
+
82
+
83
+ def _clean_term(term: str) -> str:
84
+ return str(term).lower().strip(".,!?;:'\"()[]{}<>/\\|`~@#$%^&*_+=")
85
+
86
+
87
+ def _is_informative_term(term: str) -> bool:
88
+ clean = _clean_term(term)
89
+ if len(clean) < 2 or clean in _STOPWORDS:
90
+ return False
91
+ return any(ch.isalpha() for ch in clean)
92
+
93
+
94
+ def _text_evidence_by_aspect(review_text: str, top_k: int = 8) -> Dict[str, List[str]]:
95
+ raw_terms = [_clean_term(t) for t in str(review_text).split()]
96
+ result = {}
97
+ for aspect, aspect_terms in _ASPECT_EVIDENCE_KEYWORDS.items():
98
+ hits = []
99
+ for term in raw_terms:
100
+ if not _is_informative_term(term):
101
+ continue
102
+ if term in aspect_terms or term in _SENTIMENT_EVIDENCE_KEYWORDS:
103
+ if term not in hits:
104
+ hits.append(term)
105
+ if len(hits) >= top_k:
106
+ break
107
+ result[aspect] = hits
108
+ return result
109
+
110
+ def _format_confidence(out: Dict, row_idx: int = 0) -> Dict[str, Dict[str, float]]:
111
+ probs = torch.softmax(out["logits"][row_idx], dim=-1).detach().cpu().numpy()
112
+ result = {}
113
+ for i, aspect in enumerate(cfg.ASPECTS):
114
+ cls = int(np.argmax(probs[i]))
115
+ result[aspect] = {
116
+ "label": cfg.LABEL_NAMES[cls],
117
+ "confidence": float(probs[i, cls]),
118
+ "class_probs": {
119
+ cfg.LABEL_NAMES[j]: float(probs[i, j])
120
+ for j in range(len(cfg.LABEL_NAMES))
121
+ },
122
+ }
123
+ return result
124
+
125
+
126
+ def _attention_insights(attn_payload: Dict) -> Dict:
127
+ insights = {}
128
+ if "meta_attention" in attn_payload:
129
+ insights["top_meta_source"] = _top_attention_item(attn_payload["meta_attention"])
130
+ if "meta_attention_by_aspect" in attn_payload:
131
+ insights["top_meta_source_by_aspect"] = {
132
+ aspect: _top_attention_item(weights)
133
+ for aspect, weights in attn_payload["meta_attention_by_aspect"].items()
134
+ }
135
+ return insights
136
+
137
+ def _format_attention(out: Dict) -> Dict:
138
+ """Format legacy 1D attention or new aspect-specific 2D attention."""
139
+ if "meta_attn_weights" not in out or out["meta_attn_weights"] is None:
140
+ return {}
141
+
142
+ aspect_attn = out["meta_attn_weights"].detach().cpu().numpy()[0]
143
+ names = _attention_names(out, aspect_attn)
144
+
145
+ if aspect_attn.ndim == 1:
146
+ return {"meta_attention": _attention_dict(names, aspect_attn)}
147
+
148
+ result = {
149
+ "meta_attention_by_aspect": {
150
+ aspect: _attention_dict(names, aspect_attn[i])
151
+ for i, aspect in enumerate(cfg.ASPECTS)
152
+ }
153
+ }
154
+ if "global_meta_attn_weights" in out and out["global_meta_attn_weights"] is not None:
155
+ global_attn = out["global_meta_attn_weights"].detach().cpu().numpy()[0]
156
+ result["meta_attention"] = _attention_dict(names, global_attn)
157
+ else:
158
+ result["meta_attention"] = _attention_dict(names, aspect_attn.mean(axis=0))
159
+ return result
160
+
161
+
162
+ def _slice_model_out(out: Dict, start: int, end: int) -> Dict:
163
+ sliced = {}
164
+ for k, v in out.items():
165
+ sliced[k] = v[start:end] if torch.is_tensor(v) else v
166
+ return sliced
167
+
168
+
169
+ class AspectPredictor:
170
+ """Load the Proposed model and expose one-shot and batch predictions."""
171
+
172
+ def __init__(self, checkpoint_dir=None, device=None):
173
+ self.model, self.tokenizer, self.meta_encoder, self.device = load_meta_acsa(
174
+ checkpoint_dir=checkpoint_dir, device=device,
175
+ )
176
+
177
+ def predict(self, review_text: str, product_meta: Dict,
178
+ return_attention: bool = True) -> Dict:
179
+ enc = self.tokenizer(
180
+ review_text,
181
+ max_length=cfg.MAX_LENGTH,
182
+ truncation=True,
183
+ padding="max_length",
184
+ return_tensors="pt",
185
+ )
186
+ input_ids = enc["input_ids"].to(self.device)
187
+ attn_mask = enc["attention_mask"].to(self.device)
188
+
189
+ meta_df = _meta_dict_to_df(product_meta)
190
+ meta_vec = torch.from_numpy(self.meta_encoder.transform(meta_df)).float().to(self.device)
191
+
192
+ with torch.no_grad():
193
+ out = self.model(input_ids, attn_mask, meta_vec)
194
+
195
+ preds = out["logits"][0].argmax(dim=-1).cpu().numpy()
196
+ result = {
197
+ "aspects": format_aspect_summary(preds),
198
+ "aspect_details": _format_confidence(out, 0),
199
+ "metadata_summary": _meta_summary(product_meta),
200
+ }
201
+ if "overall_logits" in out and out["overall_logits"] is not None:
202
+ overall_probs = torch.softmax(out["overall_logits"][0], dim=-1).detach().cpu().numpy()
203
+ overall_cls = int(np.argmax(overall_probs))
204
+ result["overall"] = {
205
+ "label": cfg.OVERALL_LABEL_NAMES[overall_cls],
206
+ "confidence": float(overall_probs[overall_cls]),
207
+ "class_probs": {
208
+ cfg.OVERALL_LABEL_NAMES[j]: float(overall_probs[j])
209
+ for j in range(len(cfg.OVERALL_LABEL_NAMES))
210
+ },
211
+ }
212
+ if return_attention:
213
+ attn_payload = _format_attention(out)
214
+ result.update(attn_payload)
215
+ result.update(_attention_insights(attn_payload))
216
+ return result
217
+
218
+ def predict_batch(self, reviews: List[Dict], batch_size: int = 16) -> List[Dict]:
219
+ """Each item is {'review_text': str, 'product_meta': {...}}."""
220
+ results = []
221
+ for i in range(0, len(reviews), batch_size):
222
+ chunk = reviews[i:i + batch_size]
223
+ enc = self.tokenizer(
224
+ [r["review_text"] for r in chunk],
225
+ max_length=cfg.MAX_LENGTH,
226
+ truncation=True,
227
+ padding="max_length",
228
+ return_tensors="pt",
229
+ )
230
+ input_ids = enc["input_ids"].to(self.device)
231
+ attn_mask = enc["attention_mask"].to(self.device)
232
+
233
+ meta_df = pd.concat(
234
+ [_meta_dict_to_df(r["product_meta"]) for r in chunk],
235
+ ignore_index=True,
236
+ )
237
+ meta_vec = torch.from_numpy(self.meta_encoder.transform(meta_df)).float().to(self.device)
238
+
239
+ with torch.no_grad():
240
+ out = self.model(input_ids, attn_mask, meta_vec)
241
+ preds = out["logits"].argmax(dim=-1).cpu().numpy()
242
+
243
+ for j in range(len(chunk)):
244
+ item = {
245
+ "aspects": format_aspect_summary(preds[j]),
246
+ "aspect_details": _format_confidence(out, j),
247
+ "metadata_summary": _meta_summary(chunk[j]["product_meta"]),
248
+ }
249
+ if "overall_logits" in out and out["overall_logits"] is not None:
250
+ overall_probs = torch.softmax(out["overall_logits"][j], dim=-1).detach().cpu().numpy()
251
+ overall_cls = int(np.argmax(overall_probs))
252
+ item["overall"] = {
253
+ "label": cfg.OVERALL_LABEL_NAMES[overall_cls],
254
+ "confidence": float(overall_probs[overall_cls]),
255
+ "class_probs": {
256
+ cfg.OVERALL_LABEL_NAMES[k]: float(overall_probs[k])
257
+ for k in range(len(cfg.OVERALL_LABEL_NAMES))
258
+ },
259
+ }
260
+ attn_payload = _format_attention(_slice_model_out(out, j, j + 1))
261
+ item.update(attn_payload)
262
+ item.update(_attention_insights(attn_payload))
263
+ results.append(item)
264
+ return results
src/meta_encoder.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metadata encoder: turn each row's product-side fields into a fixed-length vector.
2
+
3
+ Output layout (concatenated):
4
+ [ TF-IDF on features_text (META_FEATURE_TFIDF_DIM) # product attributes
5
+ | TF-IDF on categories_text (META_CATEGORY_TFIDF_DIM) # product taxonomy
6
+ | numeric vector (META_NUM_DIM) # price + ratings + flags
7
+ ]
8
+ Total raw dim = META_TFIDF_DIM + META_NUM_DIM.
9
+
10
+ The MLP inside the model maps this to META_HIDDEN_DIM. We keep encoding as a
11
+ separate, plain-numpy step so it can be fit once on train and reused without
12
+ touching the model.
13
+ """
14
+ import logging
15
+ import pickle
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Optional, Sequence
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ from sklearn.feature_extraction.text import TfidfVectorizer
23
+ from sklearn.preprocessing import StandardScaler
24
+
25
+ from . import config as cfg
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Columns we expect on the input df (produced by preprocess.join_and_clean).
32
+ # Missing columns are handled gracefully.
33
+ FEATURES_TEXT_COL = "features_text"
34
+ CATEGORIES_TEXT_COL = "categories_text"
35
+ PRICE_COL = "price"
36
+ AVG_RATING_COL = "average_rating"
37
+ RATING_NUMBER_COL = "rating_number"
38
+
39
+
40
+ def _safe_text(series: pd.Series) -> pd.Series:
41
+ return series.fillna("").astype(str)
42
+
43
+
44
+ def _safe_numeric(series: pd.Series) -> pd.Series:
45
+ return pd.to_numeric(series, errors="coerce")
46
+
47
+
48
+ @dataclass
49
+ class MetaEncoder:
50
+ """Fit on train df, then transform any df (train/val/test/new) to a (N, D) matrix."""
51
+
52
+ feature_tfidf_dim: int = cfg.META_FEATURE_TFIDF_DIM
53
+ category_tfidf_dim: int = cfg.META_CATEGORY_TFIDF_DIM
54
+ num_dim: int = cfg.META_NUM_DIM # price, avg_rating, log_rating_number, price_missing_flag
55
+
56
+ # Filled by fit()
57
+ feature_tfidf: Optional[TfidfVectorizer] = None
58
+ category_tfidf: Optional[TfidfVectorizer] = None
59
+ scaler: Optional[StandardScaler] = None
60
+ price_median_: Optional[float] = None
61
+ avg_rating_median_: Optional[float] = None
62
+ rating_number_median_: Optional[float] = None
63
+
64
+ @property
65
+ def tfidf_dim(self) -> int:
66
+ return self.feature_tfidf_dim + self.category_tfidf_dim
67
+
68
+ @property
69
+ def total_dim(self) -> int:
70
+ return self.tfidf_dim + self.num_dim
71
+
72
+ # ------------------------------------------------------------------ fit
73
+ def fit(self, df: pd.DataFrame) -> "MetaEncoder":
74
+ # 1. Fit text metadata as separate semantic sources. Keeping features
75
+ # and categories apart gives cross-attention tokens real meaning.
76
+ feat_text = _safe_text(df.get(FEATURES_TEXT_COL, pd.Series([""] * len(df))))
77
+ cat_text = _safe_text(df.get(CATEGORIES_TEXT_COL, pd.Series([""] * len(df))))
78
+
79
+ self.feature_tfidf = TfidfVectorizer(
80
+ max_features=self.feature_tfidf_dim,
81
+ ngram_range=(1, 2),
82
+ min_df=2,
83
+ max_df=0.95,
84
+ stop_words="english",
85
+ sublinear_tf=True,
86
+ )
87
+ # If the corpus is too small to have any vocab, TF-IDF will raise.
88
+ # Fall back to a single zero-dim by fitting on a synthetic vocab.
89
+ try:
90
+ self.feature_tfidf.fit(feat_text.str.strip())
91
+ except ValueError:
92
+ logger.warning("Meta TF-IDF found no usable vocabulary; using zero features.")
93
+ self.feature_tfidf = TfidfVectorizer(max_features=1)
94
+ self.feature_tfidf.fit(["placeholder"])
95
+
96
+ self.category_tfidf = TfidfVectorizer(
97
+ max_features=self.category_tfidf_dim,
98
+ ngram_range=(1, 2),
99
+ min_df=2,
100
+ max_df=0.98,
101
+ stop_words="english",
102
+ sublinear_tf=True,
103
+ )
104
+ try:
105
+ self.category_tfidf.fit(cat_text.str.strip())
106
+ except ValueError:
107
+ logger.warning("Category TF-IDF found no usable vocabulary; using zero features.")
108
+ self.category_tfidf = TfidfVectorizer(max_features=1)
109
+ self.category_tfidf.fit(["placeholder"])
110
+
111
+ # 2. Numeric features: price, avg_rating, log(rating_number+1), price_missing_flag
112
+ self.price_median_ = float(_safe_numeric(df.get(PRICE_COL, pd.Series([np.nan]))).median())
113
+ if not np.isfinite(self.price_median_):
114
+ self.price_median_ = 0.0
115
+ self.avg_rating_median_ = float(_safe_numeric(df.get(AVG_RATING_COL, pd.Series([np.nan]))).median())
116
+ if not np.isfinite(self.avg_rating_median_):
117
+ self.avg_rating_median_ = 4.0
118
+ self.rating_number_median_ = float(_safe_numeric(df.get(RATING_NUMBER_COL, pd.Series([np.nan]))).median())
119
+ if not np.isfinite(self.rating_number_median_):
120
+ self.rating_number_median_ = 0.0
121
+
122
+ num_mat = self._build_numeric(df)
123
+ self.scaler = StandardScaler()
124
+ self.scaler.fit(num_mat)
125
+ return self
126
+
127
+ # -------------------------------------------------------------- transform
128
+ def transform(self, df: pd.DataFrame) -> np.ndarray:
129
+ if self.feature_tfidf is None or self.scaler is None:
130
+ raise RuntimeError("MetaEncoder.fit() must be called before transform().")
131
+
132
+ structured = self.transform_structured(df)
133
+ return np.hstack([
134
+ structured["features"],
135
+ structured["categories"],
136
+ structured["numeric"],
137
+ ]).astype(np.float32)
138
+
139
+ def transform_structured(self, df: pd.DataFrame) -> dict:
140
+ """Return source-separated metadata matrices.
141
+
142
+ Keys map directly to semantic meta tokens used by the upgraded model:
143
+ features -> product attribute text, categories -> taxonomy text,
144
+ numeric -> price/rating signals.
145
+ """
146
+ if self.feature_tfidf is None or self.category_tfidf is None or self.scaler is None:
147
+ raise RuntimeError("MetaEncoder.fit() must be called before transform_structured().")
148
+
149
+ feat_text = _safe_text(df.get(FEATURES_TEXT_COL, pd.Series([""] * len(df)))).str.strip()
150
+ cat_text = _safe_text(df.get(CATEGORIES_TEXT_COL, pd.Series([""] * len(df)))).str.strip()
151
+ feat_mat = self.feature_tfidf.transform(feat_text).toarray().astype(np.float32)
152
+ cat_mat = self.category_tfidf.transform(cat_text).toarray().astype(np.float32)
153
+ feat_mat = self._pad_or_truncate(feat_mat, self.feature_tfidf_dim)
154
+ cat_mat = self._pad_or_truncate(cat_mat, self.category_tfidf_dim)
155
+ num_mat = self.scaler.transform(self._build_numeric(df)).astype(np.float32)
156
+ return {"features": feat_mat, "categories": cat_mat, "numeric": num_mat}
157
+
158
+ @staticmethod
159
+ def _pad_or_truncate(mat: np.ndarray, dim: int) -> np.ndarray:
160
+ if mat.shape[1] < dim:
161
+ pad = np.zeros((mat.shape[0], dim - mat.shape[1]), dtype=np.float32)
162
+ return np.hstack([mat, pad])
163
+ if mat.shape[1] > dim:
164
+ return mat[:, :dim]
165
+ return mat
166
+
167
+ # ------------------------------------------------------------------ helpers
168
+ def _build_numeric(self, df: pd.DataFrame) -> np.ndarray:
169
+ price = _safe_numeric(df.get(PRICE_COL, pd.Series([np.nan] * len(df))))
170
+ price_missing = price.isna().astype(np.float32).values
171
+ price = price.fillna(self.price_median_).astype(np.float32).values
172
+
173
+ avg = _safe_numeric(df.get(AVG_RATING_COL, pd.Series([np.nan] * len(df))))
174
+ avg = avg.fillna(self.avg_rating_median_).astype(np.float32).values
175
+
176
+ rnum = _safe_numeric(df.get(RATING_NUMBER_COL, pd.Series([np.nan] * len(df))))
177
+ rnum = rnum.fillna(self.rating_number_median_).astype(np.float32).values
178
+ log_rnum = np.log1p(np.maximum(rnum, 0.0))
179
+
180
+ return np.vstack([price, avg, log_rnum, price_missing]).T # (N, 4)
181
+
182
+ # ------------------------------------------------------------------ io
183
+ def save(self, path: Path = None) -> Path:
184
+ if path is None:
185
+ path = cfg.META_ENCODER_PATH
186
+ path = Path(path)
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ with open(path, "wb") as f:
189
+ pickle.dump(self, f)
190
+ logger.info("Saved MetaEncoder to %s", path)
191
+ return path
192
+
193
+ @classmethod
194
+ def load(cls, path: Path = None) -> "MetaEncoder":
195
+ if path is None:
196
+ path = cfg.META_ENCODER_PATH
197
+ with open(path, "rb") as f:
198
+ obj = pickle.load(f)
199
+ if not isinstance(obj, cls):
200
+ raise TypeError(f"Loaded object is not a MetaEncoder: {type(obj)}")
201
+ return obj
202
+
203
+
204
+ def fit_and_save(train_df: pd.DataFrame, path: Path = None) -> MetaEncoder:
205
+ enc = MetaEncoder().fit(train_df)
206
+ enc.save(path)
207
+ return enc
208
+
src/models.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Models for ACSA with metadata fusion.
2
+
3
+ Three architectures:
4
+ - BertMetaFusionACSAModel: BERT + Meta Cross-Attention + per-aspect heads (Proposed)
5
+ - BertACSAModel: BERT + per-aspect heads (Baseline 3, ablation w/o meta)
6
+ - BertOverallModel: BERT + single 3-class head (Baseline 2)
7
+
8
+ Design notes
9
+ ------------
10
+ * `class_weights` is intentionally NOT a buffer. We tried that before and got a
11
+ `Unexpected key(s) in state_dict: 'class_weights'` on every reload because the
12
+ eval-time constructor doesn't know the training-time class_weights. Now we
13
+ keep it as a plain attribute that does not enter state_dict. The training
14
+ loop is responsible for re-instantiating the loss with the right weights.
15
+
16
+ * The "metadata token" trick. The cross-attention layer wants a sequence of
17
+ K/V tokens, not a single vector. We split the encoded meta vector into
18
+ `META_NUM_META_TOKENS` virtual tokens of equal length so the attention head
19
+ has structure to operate over. Each chunk roughly corresponds to a slice of
20
+ TF-IDF + a slice of numeric features, which makes the attention weight
21
+ vector interpretable as "how much did the model rely on meta chunk i".
22
+
23
+ * Forward returns:
24
+ {
25
+ "logits": (B, num_aspects, num_classes),
26
+ "loss": scalar or None,
27
+ "meta_attn_weights": (B, num_aspects, num_meta_tokens) or None,
28
+ "bert_attentions": tuple of BERT self-attn (only if output_attentions=True),
29
+ }
30
+ """
31
+ import logging
32
+ import math
33
+ from typing import List, Optional
34
+
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+ from transformers import AutoModel
39
+
40
+ from . import config as cfg
41
+
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Split the encoded meta vector into this many "tokens" before cross-attention.
46
+ # Each token gets its own linear projection to BERT's hidden size.
47
+ META_NUM_META_TOKENS = 4
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Shared helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class MetaEncoderMLP(nn.Module):
55
+ """Encode raw meta features (numpy-shaped) -> META_HIDDEN_DIM vector."""
56
+
57
+ def __init__(self, in_dim: int, hidden: int = cfg.META_HIDDEN_DIM,
58
+ dropout: float = 0.2):
59
+ super().__init__()
60
+ self.net = nn.Sequential(
61
+ nn.Linear(in_dim, hidden * 2),
62
+ nn.GELU(),
63
+ nn.Dropout(dropout),
64
+ nn.Linear(hidden * 2, hidden),
65
+ )
66
+
67
+ def forward(self, x):
68
+ return self.net(x)
69
+
70
+
71
+ class MetaTokenizer(nn.Module):
72
+ """Split a (B, meta_hidden) vector into (B, num_tokens, bert_hidden) so it
73
+ can serve as K/V in a multi-head cross-attention.
74
+
75
+ We do this by chunking the meta vector and projecting each chunk
76
+ independently to BERT's hidden size. Each chunk represents a *piece* of
77
+ the metadata (a slice of the encoded TF-IDF + numeric features); the
78
+ cross-attention weight over chunks is what gets visualized for explanation.
79
+ """
80
+
81
+ def __init__(self, meta_hidden: int, bert_hidden: int,
82
+ num_tokens: int = META_NUM_META_TOKENS):
83
+ super().__init__()
84
+ assert meta_hidden % num_tokens == 0, (
85
+ f"META_HIDDEN_DIM={meta_hidden} must be divisible by "
86
+ f"num_tokens={num_tokens}"
87
+ )
88
+ self.num_tokens = num_tokens
89
+ self.chunk_size = meta_hidden // num_tokens
90
+ self.proj = nn.ModuleList([
91
+ nn.Linear(self.chunk_size, bert_hidden) for _ in range(num_tokens)
92
+ ])
93
+
94
+ def forward(self, meta_vec):
95
+ # meta_vec: (B, meta_hidden) -> list of (B, chunk_size)
96
+ chunks = meta_vec.chunk(self.num_tokens, dim=-1)
97
+ projected = [proj(chunk) for proj, chunk in zip(self.proj, chunks)]
98
+ # stack -> (B, num_tokens, bert_hidden)
99
+ return torch.stack(projected, dim=1)
100
+
101
+
102
+ class CrossAttentionFusion(nn.Module):
103
+ """Multi-head cross-attention: Q = text [CLS], K=V = meta tokens.
104
+
105
+ Returns:
106
+ fused: (B, bert_hidden) text vector enriched by meta
107
+ attn_weights: (B, num_meta_tokens) averaged over heads, used for XAI
108
+ """
109
+
110
+ def __init__(self, hidden: int, num_heads: int = cfg.META_CROSSATTN_HEADS,
111
+ dropout: float = 0.1):
112
+ super().__init__()
113
+ assert hidden % num_heads == 0
114
+ self.hidden = hidden
115
+ self.num_heads = num_heads
116
+ self.head_dim = hidden // num_heads
117
+
118
+ self.q_proj = nn.Linear(hidden, hidden)
119
+ self.k_proj = nn.Linear(hidden, hidden)
120
+ self.v_proj = nn.Linear(hidden, hidden)
121
+ self.out_proj = nn.Linear(hidden, hidden)
122
+ self.dropout = nn.Dropout(dropout)
123
+ self.norm = nn.LayerNorm(hidden)
124
+
125
+ def forward(self, text_vec, meta_tokens):
126
+ # text_vec: (B, H)
127
+ # meta_tokens: (B, T, H)
128
+ B, T, H = meta_tokens.shape
129
+ q = self.q_proj(text_vec).view(B, self.num_heads, 1, self.head_dim)
130
+ k = self.k_proj(meta_tokens).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
131
+ v = self.v_proj(meta_tokens).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
132
+ # k, v: (B, heads, T, head_dim)
133
+ scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
134
+ # scores: (B, heads, 1, T)
135
+ attn = F.softmax(scores, dim=-1)
136
+ attn_drop = self.dropout(attn)
137
+ ctx = torch.matmul(attn_drop, v) # (B, heads, 1, head_dim)
138
+ ctx = ctx.transpose(1, 2).contiguous().view(B, H)
139
+ ctx = self.out_proj(ctx)
140
+
141
+ fused = self.norm(text_vec + ctx) # residual + LN
142
+ avg_attn = attn.mean(dim=1).squeeze(1) # (B, T) 闂?heads averaged
143
+ return fused, avg_attn
144
+
145
+
146
+
147
+ class SemanticMetaTokenizer(nn.Module):
148
+ """Project source-separated metadata into interpretable tokens."""
149
+
150
+ token_names = ["features", "categories", "numeric"]
151
+
152
+ def __init__(self, bert_hidden: int, dropout: float = 0.1):
153
+ super().__init__()
154
+ self.feature_proj = nn.Sequential(
155
+ nn.Linear(cfg.META_FEATURE_TFIDF_DIM, bert_hidden),
156
+ nn.GELU(),
157
+ nn.Dropout(dropout),
158
+ nn.LayerNorm(bert_hidden),
159
+ )
160
+ self.category_proj = nn.Sequential(
161
+ nn.Linear(cfg.META_CATEGORY_TFIDF_DIM, bert_hidden),
162
+ nn.GELU(),
163
+ nn.Dropout(dropout),
164
+ nn.LayerNorm(bert_hidden),
165
+ )
166
+ self.numeric_proj = nn.Sequential(
167
+ nn.Linear(cfg.META_NUM_DIM, bert_hidden),
168
+ nn.GELU(),
169
+ nn.Dropout(dropout),
170
+ nn.LayerNorm(bert_hidden),
171
+ )
172
+
173
+ def forward(self, meta_features):
174
+ f_end = cfg.META_FEATURE_TFIDF_DIM
175
+ c_end = f_end + cfg.META_CATEGORY_TFIDF_DIM
176
+ feat = meta_features[:, :f_end]
177
+ cat = meta_features[:, f_end:c_end]
178
+ num = meta_features[:, c_end:c_end + cfg.META_NUM_DIM]
179
+ return torch.stack([
180
+ self.feature_proj(feat),
181
+ self.category_proj(cat),
182
+ self.numeric_proj(num * cfg.META_NUMERIC_TOKEN_SCALE),
183
+ ], dim=1)
184
+
185
+
186
+ class GatedAspectSemanticCrossAttention(nn.Module):
187
+ """Aspect-specific cross-attention with a conservative metadata gate."""
188
+
189
+ def __init__(self, hidden: int, num_aspects: int = cfg.NUM_ASPECTS,
190
+ num_heads: int = cfg.META_CROSSATTN_HEADS, dropout: float = 0.1):
191
+ super().__init__()
192
+ assert hidden % num_heads == 0
193
+ self.hidden = hidden
194
+ self.num_aspects = num_aspects
195
+ self.num_heads = num_heads
196
+ self.head_dim = hidden // num_heads
197
+ self.aspect_embeddings = nn.Parameter(torch.randn(num_aspects, hidden) * 0.02)
198
+ self.q_proj = nn.Linear(hidden, hidden)
199
+ self.k_proj = nn.Linear(hidden, hidden)
200
+ self.v_proj = nn.Linear(hidden, hidden)
201
+ self.out_proj = nn.Linear(hidden, hidden)
202
+ self.gate = nn.Linear(hidden * 2, hidden)
203
+ nn.init.constant_(self.gate.bias, -1.0)
204
+ self.dropout = nn.Dropout(dropout)
205
+ self.norm = nn.LayerNorm(hidden)
206
+
207
+ def forward(self, text_vec, meta_tokens):
208
+ B, T, H = meta_tokens.shape
209
+ aspect_queries = text_vec.unsqueeze(1) + self.aspect_embeddings.unsqueeze(0)
210
+ q = self.q_proj(aspect_queries).view(B, self.num_aspects, self.num_heads, self.head_dim)
211
+ q = q.transpose(1, 2)
212
+ k = self.k_proj(meta_tokens).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
213
+ v = self.v_proj(meta_tokens).view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
214
+ attn = F.softmax(torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim), dim=-1)
215
+ ctx = torch.matmul(self.dropout(attn), v)
216
+ ctx = ctx.transpose(1, 2).contiguous().view(B, self.num_aspects, H)
217
+ ctx = self.out_proj(ctx)
218
+ text_expanded = text_vec.unsqueeze(1).expand(-1, self.num_aspects, -1)
219
+ gate = torch.sigmoid(self.gate(torch.cat([text_expanded, ctx], dim=-1)))
220
+ fused = self.norm(text_expanded + gate * ctx)
221
+ return fused, attn.mean(dim=1), gate.mean(dim=-1)
222
+
223
+
224
+ class GatedGlobalSemanticCrossAttention(nn.Module):
225
+ """Global text-to-metadata cross-attention for the overall head."""
226
+
227
+ def __init__(self, hidden: int, num_heads: int = cfg.META_CROSSATTN_HEADS,
228
+ dropout: float = 0.1):
229
+ super().__init__()
230
+ self.attn = CrossAttentionFusion(hidden, num_heads=num_heads, dropout=dropout)
231
+ self.gate = nn.Linear(hidden * 2, hidden)
232
+ nn.init.constant_(self.gate.bias, -1.0)
233
+ self.norm = nn.LayerNorm(hidden)
234
+
235
+ def forward(self, text_vec, meta_tokens):
236
+ fused_raw, attn = self.attn(text_vec, meta_tokens)
237
+ ctx = fused_raw - text_vec
238
+ gate = torch.sigmoid(self.gate(torch.cat([text_vec, ctx], dim=-1)))
239
+ fused = self.norm(text_vec + gate * ctx)
240
+ return fused, attn, gate.mean(dim=-1)
241
+
242
+ class _PerAspectHeads(nn.Module):
243
+ """num_aspects independent MLP heads."""
244
+
245
+ def __init__(self, in_dim: int, num_aspects: int, num_classes: int,
246
+ dropout: float = 0.3):
247
+ super().__init__()
248
+ self.heads = nn.ModuleList([
249
+ nn.Sequential(
250
+ nn.Dropout(dropout),
251
+ nn.Linear(in_dim, 256),
252
+ nn.GELU(),
253
+ nn.Linear(256, num_classes),
254
+ )
255
+ for _ in range(num_aspects)
256
+ ])
257
+
258
+ def forward(self, x):
259
+ # x: (B, in_dim) -> (B, num_aspects, num_classes)
260
+ return torch.stack([h(x) for h in self.heads], dim=1)
261
+
262
+ def forward_per_aspect(self, x):
263
+ # x: (B, num_aspects, in_dim) -> (B, num_aspects, num_classes)
264
+ return torch.stack([h(x[:, i, :]) for i, h in enumerate(self.heads)], dim=1)
265
+
266
+
267
+ def _aspect_loss(logits, labels, class_weights=None):
268
+ """Sum of cross-entropy over aspects, averaged."""
269
+ num_aspects = logits.shape[1]
270
+ loss = 0.0
271
+ for i in range(num_aspects):
272
+ w = class_weights[i] if class_weights is not None else None
273
+ loss = loss + F.cross_entropy(logits[:, i, :], labels[:, i], weight=w)
274
+ return loss / num_aspects
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Models
279
+ # ---------------------------------------------------------------------------
280
+
281
+ class BertMetaFusionACSAModel(nn.Module):
282
+ """Proposed model: BERT + Meta Cross-Attention + per-aspect heads."""
283
+
284
+ def __init__(
285
+ self,
286
+ bert_name: str = cfg.BERT_MODEL_NAME,
287
+ meta_in_dim: int = cfg.META_TFIDF_DIM + cfg.META_NUM_DIM,
288
+ num_aspects: int = cfg.NUM_ASPECTS,
289
+ num_classes: int = cfg.NUM_CLASSES,
290
+ dropout: float = 0.3,
291
+ class_weights: Optional[torch.Tensor] = None,
292
+ ):
293
+ super().__init__()
294
+ self.bert = AutoModel.from_pretrained(bert_name)
295
+ hidden = self.bert.config.hidden_size
296
+
297
+ self.num_aspects = num_aspects
298
+ self.num_classes = num_classes
299
+ self.aspect_names = list(cfg.ASPECTS)
300
+ self.meta_in_dim = meta_in_dim
301
+
302
+ self.meta_mlp = MetaEncoderMLP(meta_in_dim, hidden=cfg.META_HIDDEN_DIM,
303
+ dropout=dropout)
304
+ self.meta_tokenizer = MetaTokenizer(
305
+ meta_hidden=cfg.META_HIDDEN_DIM, bert_hidden=hidden,
306
+ num_tokens=META_NUM_META_TOKENS,
307
+ )
308
+ self.fusion = CrossAttentionFusion(hidden=hidden,
309
+ num_heads=cfg.META_CROSSATTN_HEADS,
310
+ dropout=0.1)
311
+ self.heads = _PerAspectHeads(in_dim=hidden, num_aspects=num_aspects,
312
+ num_classes=num_classes, dropout=dropout)
313
+
314
+ # Overall sentiment auxiliary head (3-class: Neg/Neu/Pos).
315
+ # Shares the fused representation with per-aspect heads; trained jointly
316
+ # with weight cfg.OVERALL_AUX_WEIGHT when overall_labels are provided.
317
+ self.overall_head = nn.Sequential(
318
+ nn.Dropout(dropout),
319
+ nn.Linear(hidden, 256),
320
+ nn.GELU(),
321
+ nn.Linear(256, cfg.OVERALL_NUM_CLASSES),
322
+ )
323
+
324
+ # Stored as plain attribute (NOT a buffer). Re-supplied at train time.
325
+ self.class_weights = class_weights
326
+
327
+ def forward(
328
+ self,
329
+ input_ids,
330
+ attention_mask,
331
+ meta_features,
332
+ labels: Optional[torch.Tensor] = None,
333
+ overall_labels: Optional[torch.Tensor] = None,
334
+ output_attentions: bool = False,
335
+ ):
336
+ bert_out = self.bert(
337
+ input_ids=input_ids,
338
+ attention_mask=attention_mask,
339
+ output_attentions=output_attentions,
340
+ return_dict=True,
341
+ )
342
+ text_vec = bert_out.last_hidden_state[:, 0, :] # [CLS]
343
+
344
+ meta_vec = self.meta_mlp(meta_features) # (B, meta_hidden)
345
+ meta_tokens = self.meta_tokenizer(meta_vec) # (B, T, H)
346
+ fused, meta_attn = self.fusion(text_vec, meta_tokens)
347
+
348
+ logits = self.heads(fused)
349
+ overall_logits = self.overall_head(fused)
350
+
351
+ # Joint loss: L_aspect + lambda * L_overall
352
+ loss = None
353
+ if labels is not None:
354
+ loss = _aspect_loss(logits, labels, self.class_weights)
355
+ if overall_labels is not None:
356
+ loss_overall = F.cross_entropy(overall_logits, overall_labels)
357
+ loss = loss + cfg.OVERALL_AUX_WEIGHT * loss_overall
358
+
359
+ return {
360
+ "loss": loss,
361
+ "logits": logits,
362
+ "overall_logits": overall_logits,
363
+ "meta_attn_weights": meta_attn, # (B, T) -- for XAI
364
+ "bert_attentions": bert_out.attentions if output_attentions else None,
365
+ "fused_state": fused,
366
+ "text_state": text_vec,
367
+ }
368
+
369
+
370
+
371
+ class GatedAspectSemanticMetaFusionACSAModel(nn.Module):
372
+ """Proposed v2: semantic meta tokens + aspect queries + gated fusion.
373
+
374
+ The aspect heads receive aspect-specific fused states, while the overall
375
+ head keeps a separate global fused state so aspect-level noise does not
376
+ dilute document-level sentiment.
377
+ """
378
+
379
+ meta_token_names = SemanticMetaTokenizer.token_names
380
+
381
+ def __init__(
382
+ self,
383
+ bert_name: str = cfg.BERT_MODEL_NAME,
384
+ meta_in_dim: int = cfg.META_TFIDF_DIM + cfg.META_NUM_DIM,
385
+ num_aspects: int = cfg.NUM_ASPECTS,
386
+ num_classes: int = cfg.NUM_CLASSES,
387
+ dropout: float = 0.3,
388
+ class_weights: Optional[torch.Tensor] = None,
389
+ ):
390
+ super().__init__()
391
+ expected_dim = cfg.META_TFIDF_DIM + cfg.META_NUM_DIM
392
+ if meta_in_dim != expected_dim:
393
+ raise ValueError(f"Semantic meta fusion expects meta_in_dim={expected_dim}, got {meta_in_dim}")
394
+ self.bert = AutoModel.from_pretrained(bert_name)
395
+ hidden = self.bert.config.hidden_size
396
+
397
+ self.num_aspects = num_aspects
398
+ self.num_classes = num_classes
399
+ self.aspect_names = list(cfg.ASPECTS)
400
+ self.meta_in_dim = meta_in_dim
401
+
402
+ self.meta_tokenizer = SemanticMetaTokenizer(bert_hidden=hidden, dropout=0.1)
403
+ self.concat_meta_mlp = MetaEncoderMLP(meta_in_dim, hidden=cfg.META_HIDDEN_DIM,
404
+ dropout=dropout)
405
+ self.concat_fusion = nn.Sequential(
406
+ nn.Linear(hidden + cfg.META_HIDDEN_DIM, hidden),
407
+ nn.GELU(),
408
+ nn.Dropout(0.1),
409
+ )
410
+ self.concat_norm = nn.LayerNorm(hidden)
411
+ self.aspect_refine_norm = nn.LayerNorm(hidden)
412
+ self.global_refine_norm = nn.LayerNorm(hidden)
413
+ self.cross_residual_scale = nn.Parameter(
414
+ torch.tensor(float(cfg.CROSS_ATTN_RESIDUAL_SCALE))
415
+ )
416
+ self.aspect_mix_gate = nn.Linear(hidden * 2, 1)
417
+ self.global_mix_gate = nn.Linear(hidden * 2, 1)
418
+ nn.init.zeros_(self.aspect_mix_gate.weight)
419
+ nn.init.zeros_(self.global_mix_gate.weight)
420
+ nn.init.zeros_(self.aspect_mix_gate.bias)
421
+ nn.init.zeros_(self.global_mix_gate.bias)
422
+ self.aspect_fusion = GatedAspectSemanticCrossAttention(
423
+ hidden=hidden,
424
+ num_aspects=num_aspects,
425
+ num_heads=cfg.META_CROSSATTN_HEADS,
426
+ dropout=0.1,
427
+ )
428
+ self.global_fusion = GatedGlobalSemanticCrossAttention(
429
+ hidden=hidden,
430
+ num_heads=cfg.META_CROSSATTN_HEADS,
431
+ dropout=0.1,
432
+ )
433
+ self.heads = _PerAspectHeads(in_dim=hidden, num_aspects=num_aspects,
434
+ num_classes=num_classes, dropout=dropout)
435
+ self.overall_head = nn.Sequential(
436
+ nn.Dropout(dropout),
437
+ nn.Linear(hidden, 256),
438
+ nn.GELU(),
439
+ nn.Linear(256, cfg.OVERALL_NUM_CLASSES),
440
+ )
441
+ self.class_weights = class_weights
442
+
443
+ def forward(
444
+ self,
445
+ input_ids,
446
+ attention_mask,
447
+ meta_features,
448
+ labels: Optional[torch.Tensor] = None,
449
+ overall_labels: Optional[torch.Tensor] = None,
450
+ output_attentions: bool = False,
451
+ ):
452
+ bert_out = self.bert(
453
+ input_ids=input_ids,
454
+ attention_mask=attention_mask,
455
+ output_attentions=output_attentions,
456
+ return_dict=True,
457
+ )
458
+ text_vec = bert_out.last_hidden_state[:, 0, :]
459
+ meta_tokens = self.meta_tokenizer(meta_features)
460
+
461
+ meta_for_concat = meta_features.clone()
462
+ numeric_start = cfg.META_TFIDF_DIM
463
+ meta_for_concat[:, numeric_start:] = (
464
+ meta_for_concat[:, numeric_start:] * cfg.META_NUMERIC_TOKEN_SCALE
465
+ )
466
+ concat_meta = self.concat_meta_mlp(meta_for_concat)
467
+ concat_base = self.concat_norm(
468
+ self.concat_fusion(torch.cat([text_vec, concat_meta], dim=-1))
469
+ )
470
+
471
+ aspect_fused, aspect_attn, aspect_gate = self.aspect_fusion(text_vec, meta_tokens)
472
+ global_fused, global_attn, global_gate = self.global_fusion(text_vec, meta_tokens)
473
+
474
+ concat_expanded = concat_base.unsqueeze(1).expand(-1, self.num_aspects, -1)
475
+ scale = torch.clamp(self.cross_residual_scale, 0.0, 1.0)
476
+ aspect_mix_raw = torch.sigmoid(
477
+ self.aspect_mix_gate(torch.cat([concat_expanded, aspect_fused], dim=-1))
478
+ )
479
+ aspect_mix = torch.clamp(aspect_mix_raw + (scale - 0.5), 0.0, 1.0)
480
+ aspect_repr = self.aspect_refine_norm(
481
+ (1.0 - aspect_mix) * concat_expanded + aspect_mix * aspect_fused
482
+ )
483
+
484
+ global_mix_raw = torch.sigmoid(
485
+ self.global_mix_gate(torch.cat([concat_base, global_fused], dim=-1))
486
+ )
487
+ global_mix = torch.clamp(global_mix_raw + (scale - 0.5), 0.0, 1.0)
488
+ global_repr = self.global_refine_norm(
489
+ (1.0 - global_mix) * concat_base + global_mix * global_fused
490
+ )
491
+
492
+ logits = self.heads.forward_per_aspect(aspect_repr)
493
+ overall_logits = self.overall_head(global_repr)
494
+
495
+ loss = None
496
+ if labels is not None:
497
+ loss = _aspect_loss(logits, labels, self.class_weights)
498
+ if overall_labels is not None:
499
+ loss = loss + cfg.OVERALL_AUX_WEIGHT * F.cross_entropy(
500
+ overall_logits, overall_labels
501
+ )
502
+
503
+ return {
504
+ "loss": loss,
505
+ "logits": logits,
506
+ "overall_logits": overall_logits,
507
+ "meta_attn_weights": aspect_attn,
508
+ "global_meta_attn_weights": global_attn,
509
+ "meta_gate": aspect_gate,
510
+ "global_meta_gate": global_gate,
511
+ "fusion_mix_gate": aspect_mix.squeeze(-1),
512
+ "global_fusion_mix_gate": global_mix.squeeze(-1),
513
+ "meta_token_names": self.meta_token_names,
514
+ "bert_attentions": bert_out.attentions if output_attentions else None,
515
+ "fused_state": aspect_repr,
516
+ "global_fused_state": global_repr,
517
+ "text_state": text_vec,
518
+ }
519
+
520
+ class BertACSAModel(nn.Module):
521
+ """Baseline 3: same as Proposed but with NO metadata path.
522
+
523
+ Same per-aspect head structure as Proposed so the comparison isolates
524
+ the value of the Cross-Attention meta fusion.
525
+ """
526
+
527
+ def __init__(
528
+ self,
529
+ bert_name: str = cfg.BERT_MODEL_NAME,
530
+ num_aspects: int = cfg.NUM_ASPECTS,
531
+ num_classes: int = cfg.NUM_CLASSES,
532
+ dropout: float = 0.3,
533
+ class_weights: Optional[torch.Tensor] = None,
534
+ ):
535
+ super().__init__()
536
+ self.bert = AutoModel.from_pretrained(bert_name)
537
+ hidden = self.bert.config.hidden_size
538
+ self.num_aspects = num_aspects
539
+ self.num_classes = num_classes
540
+ self.aspect_names = list(cfg.ASPECTS)
541
+
542
+ self.heads = _PerAspectHeads(in_dim=hidden, num_aspects=num_aspects,
543
+ num_classes=num_classes, dropout=dropout)
544
+ self.class_weights = class_weights
545
+
546
+ def forward(self, input_ids, attention_mask,
547
+ labels: Optional[torch.Tensor] = None,
548
+ output_attentions: bool = False):
549
+ bert_out = self.bert(
550
+ input_ids=input_ids, attention_mask=attention_mask,
551
+ output_attentions=output_attentions, return_dict=True,
552
+ )
553
+ text_vec = bert_out.last_hidden_state[:, 0, :]
554
+ logits = self.heads(text_vec)
555
+ loss = _aspect_loss(logits, labels, self.class_weights) if labels is not None else None
556
+ return {
557
+ "loss": loss,
558
+ "logits": logits,
559
+ "bert_attentions": bert_out.attentions if output_attentions else None,
560
+ "text_state": text_vec,
561
+ }
562
+
563
+
564
+ class BertOverallModel(nn.Module):
565
+ """Baseline 2: BERT fine-tuned for overall 3-class sentiment (Neg/Neu/Pos)."""
566
+
567
+ def __init__(self, bert_name: str = cfg.BERT_MODEL_NAME,
568
+ num_classes: int = cfg.OVERALL_NUM_CLASSES,
569
+ dropout: float = 0.3):
570
+ super().__init__()
571
+ self.bert = AutoModel.from_pretrained(bert_name)
572
+ hidden = self.bert.config.hidden_size
573
+ self.classifier = nn.Sequential(
574
+ nn.Dropout(dropout),
575
+ nn.Linear(hidden, 256),
576
+ nn.GELU(),
577
+ nn.Linear(256, num_classes),
578
+ )
579
+ self.num_classes = num_classes
580
+
581
+ def forward(self, input_ids, attention_mask, labels=None, output_attentions=False):
582
+ out = self.bert(input_ids=input_ids, attention_mask=attention_mask,
583
+ output_attentions=output_attentions, return_dict=True)
584
+ logits = self.classifier(out.last_hidden_state[:, 0, :])
585
+ loss = F.cross_entropy(logits, labels) if labels is not None else None
586
+ return {
587
+ "loss": loss,
588
+ "logits": logits,
589
+ "bert_attentions": out.attentions if output_attentions else None,
590
+ }
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Class-weight helper for the aspect heads
595
+ # ---------------------------------------------------------------------------
596
+
597
+ def compute_class_weights(df, aspect_cols: List[str],
598
+ num_classes: int = cfg.NUM_CLASSES) -> torch.Tensor:
599
+ """Per-aspect inverse-frequency class weights, clipped to [0.2, 5.0]."""
600
+ import numpy as np
601
+ out = []
602
+ for col in aspect_cols:
603
+ counts = df[col].value_counts().reindex(range(num_classes), fill_value=0).values
604
+ counts = counts.astype(float) + 1.0
605
+ inv = counts.sum() / (num_classes * counts)
606
+ out.append(np.clip(inv, 0.2, 5.0))
607
+ return torch.from_numpy(np.array(out, dtype=np.float32))
608
+
609
+
610
+
611
+
612
+
613
+
src/preprocess.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Preprocess raw review + meta data: clean, join, stratified split."""
2
+ import html
3
+ import logging
4
+ import re
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from sklearn.model_selection import train_test_split
10
+
11
+ from . import config as cfg
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ _URL_RE = re.compile(r"https?://\S+|www\.\S+")
18
+ _HTML_RE = re.compile(r"<[^>]+>")
19
+ _WHITESPACE_RE = re.compile(r"\s+")
20
+
21
+
22
+ def clean_text(text: str) -> str:
23
+ """Light cleaning suitable for BERT input.
24
+
25
+ NOTE: do NOT remove stopwords or lemmatize - BERT needs the original context.
26
+ """
27
+ if not isinstance(text, str):
28
+ return ""
29
+ text = html.unescape(text)
30
+ text = _URL_RE.sub(" ", text)
31
+ text = _HTML_RE.sub(" ", text)
32
+ text = _WHITESPACE_RE.sub(" ", text).strip()
33
+ return text
34
+
35
+
36
+ def _features_to_text(features) -> str:
37
+ """Flatten meta `features` field (which can be list or string) to one blob."""
38
+ if features is None:
39
+ return ""
40
+ if isinstance(features, (list, tuple, np.ndarray)):
41
+ return " ".join(str(x) for x in features if x)
42
+ return str(features)
43
+
44
+
45
+ def _categories_to_text(categories) -> str:
46
+ if categories is None:
47
+ return ""
48
+ if isinstance(categories, (list, tuple, np.ndarray)):
49
+ return " > ".join(str(x) for x in categories if x)
50
+ return str(categories)
51
+
52
+
53
+ def join_and_clean(reviews_df: pd.DataFrame, meta_df: pd.DataFrame) -> pd.DataFrame:
54
+ """Inner join review + meta on parent_asin, clean text, build derived columns."""
55
+ logger.info("Pre-join: %d reviews, %d meta items.", len(reviews_df), len(meta_df))
56
+
57
+ # Subset meta to columns we'll use
58
+ meta_cols = ["parent_asin", "title", "features", "categories", "main_category", "price", "average_rating", "rating_number"]
59
+ meta_cols = [c for c in meta_cols if c in meta_df.columns]
60
+ meta_sub = meta_df[meta_cols].copy()
61
+ meta_sub = meta_sub.rename(columns={"title": "product_title"})
62
+
63
+ # Build flattened meta text fields
64
+ if "features" in meta_sub.columns:
65
+ meta_sub["features_text"] = meta_sub["features"].apply(_features_to_text)
66
+ else:
67
+ meta_sub["features_text"] = ""
68
+ if "categories" in meta_sub.columns:
69
+ meta_sub["categories_text"] = meta_sub["categories"].apply(_categories_to_text)
70
+ meta_sub["leaf_category"] = meta_sub["categories"].apply(
71
+ lambda c: (c[-1] if isinstance(c, (list, tuple, np.ndarray)) and len(c) > 0 else "")
72
+ )
73
+ else:
74
+ meta_sub["categories_text"] = ""
75
+ meta_sub["leaf_category"] = ""
76
+
77
+ # Inner join
78
+ merged = reviews_df.merge(meta_sub, on="parent_asin", how="inner", suffixes=("", "_meta"))
79
+ logger.info("After inner join: %d rows", len(merged))
80
+
81
+ # Clean review text
82
+ if "title" in merged.columns:
83
+ merged["title"] = merged["title"].fillna("").astype(str).apply(clean_text)
84
+ else:
85
+ merged["title"] = ""
86
+ merged["text"] = merged["text"].fillna("").astype(str).apply(clean_text)
87
+
88
+ # Combine title + body for model input
89
+ merged["full_text"] = (merged["title"] + " " + merged["text"]).str.strip()
90
+
91
+ # Filter junk
92
+ merged = merged[merged["full_text"].str.len() >= cfg.MIN_REVIEW_LENGTH].copy()
93
+
94
+ # Ensure rating is numeric
95
+ merged["rating"] = pd.to_numeric(merged["rating"], errors="coerce")
96
+ merged = merged.dropna(subset=["rating"]).copy()
97
+ merged["rating"] = merged["rating"].astype(int)
98
+
99
+ # Overall sentiment label from rating (3-class, used for baseline comparison)
100
+ # 1-2 = Negative (2), 3 = Neutral (1), 4-5 = Positive (0)
101
+ # We'll align with: 0=Negative, 1=Neutral, 2=Positive for baseline
102
+ def rating_to_overall(r):
103
+ if r <= 2: return 0 # Negative
104
+ if r == 3: return 1 # Neutral
105
+ return 2 # Positive
106
+ merged["overall_label"] = merged["rating"].apply(rating_to_overall)
107
+
108
+ logger.info("Final rows: %d", len(merged))
109
+ logger.info("Rating distribution:\n%s", merged["rating"].value_counts().sort_index().to_string())
110
+
111
+ return merged.reset_index(drop=True)
112
+
113
+
114
+ def stratified_split(df: pd.DataFrame, label_col: str = "rating"):
115
+ """Stratified split by rating to keep class balance across train/val/test."""
116
+ train_val, test = train_test_split(
117
+ df, test_size=cfg.TEST_RATIO, stratify=df[label_col], random_state=cfg.RANDOM_SEED
118
+ )
119
+ val_size_relative = cfg.VAL_RATIO / (cfg.TRAIN_RATIO + cfg.VAL_RATIO)
120
+ train, val = train_test_split(
121
+ train_val, test_size=val_size_relative,
122
+ stratify=train_val[label_col], random_state=cfg.RANDOM_SEED
123
+ )
124
+ logger.info("Split sizes: train=%d val=%d test=%d", len(train), len(val), len(test))
125
+ return train.reset_index(drop=True), val.reset_index(drop=True), test.reset_index(drop=True)
126
+
127
+
128
+ def preprocess_pipeline():
129
+ """Run the full preprocessing pipeline from raw files."""
130
+ logger.info("Loading raw data...")
131
+ reviews_df = pd.read_parquet(cfg.RAW_REVIEWS_PATH)
132
+ meta_df = pd.read_parquet(cfg.RAW_META_PATH)
133
+
134
+ processed = join_and_clean(reviews_df, meta_df)
135
+ processed.to_parquet(cfg.PROCESSED_PATH, index=False)
136
+ logger.info("Saved processed data to %s", cfg.PROCESSED_PATH)
137
+ return processed
src/trainer.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training loops for ACSA and baseline models."""
2
+ import json
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch.utils.data import DataLoader
10
+ from transformers import AutoTokenizer, get_linear_schedule_with_warmup
11
+ from tqdm import tqdm
12
+
13
+ from . import config as cfg
14
+ from .models import (
15
+ GatedAspectSemanticMetaFusionACSAModel, BertMetaFusionACSAModel, BertACSAModel, BertOverallModel,
16
+ compute_class_weights,
17
+ )
18
+ from .dataset import ACSADataset, MetaACSADataset, OverallSentimentDataset
19
+ from .meta_encoder import MetaEncoder
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def get_device():
26
+ if torch.cuda.is_available():
27
+ return torch.device("cuda")
28
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
29
+ return torch.device("mps")
30
+ return torch.device("cpu")
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Generic per-aspect evaluation (used by both Proposed and Baseline 3)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def _evaluate_per_aspect(model, loader, device, with_meta: bool):
38
+ from sklearn.metrics import f1_score, accuracy_score
39
+ model.eval()
40
+ all_preds = [[] for _ in range(cfg.NUM_ASPECTS)]
41
+ all_labels = [[] for _ in range(cfg.NUM_ASPECTS)]
42
+ with torch.no_grad():
43
+ for batch in loader:
44
+ batch = {k: v.to(device) for k, v in batch.items()}
45
+ if with_meta:
46
+ out = model(batch["input_ids"], batch["attention_mask"],
47
+ batch["meta_features"])
48
+ else:
49
+ out = model(batch["input_ids"], batch["attention_mask"])
50
+ preds = out["logits"].argmax(dim=-1).cpu().numpy()
51
+ labels = batch["labels"].cpu().numpy()
52
+ for i in range(cfg.NUM_ASPECTS):
53
+ all_preds[i].extend(preds[:, i].tolist())
54
+ all_labels[i].extend(labels[:, i].tolist())
55
+
56
+ metrics = {}
57
+ f1s, accs = [], []
58
+ for i, aspect in enumerate(cfg.ASPECTS):
59
+ f1 = f1_score(all_labels[i], all_preds[i], average="macro", zero_division=0)
60
+ acc = accuracy_score(all_labels[i], all_preds[i])
61
+ metrics[f"f1_{aspect}"] = float(f1)
62
+ metrics[f"acc_{aspect}"] = float(acc)
63
+ f1s.append(f1); accs.append(acc)
64
+ metrics["macro_f1_mean"] = float(np.mean(f1s)) if f1s else 0.0
65
+ metrics["accuracy_mean"] = float(np.mean(accs)) if accs else 0.0
66
+ return metrics
67
+
68
+
69
+ def _evaluate_overall_head(model, loader, device):
70
+ """Evaluate the overall sentiment auxiliary head on a dataloader.
71
+
72
+ Returns metrics prefixed with 'overall_' so they don't collide with
73
+ per-aspect metric keys.
74
+ """
75
+ from sklearn.metrics import f1_score, accuracy_score
76
+ model.eval()
77
+ all_preds, all_labels = [], []
78
+ with torch.no_grad():
79
+ for batch in loader:
80
+ batch = {k: v.to(device) for k, v in batch.items()}
81
+ if "overall_labels" not in batch:
82
+ return {}
83
+ out = model(batch["input_ids"], batch["attention_mask"],
84
+ batch["meta_features"])
85
+ preds = out["overall_logits"].argmax(dim=-1).cpu().numpy()
86
+ all_preds.extend(preds.tolist())
87
+ all_labels.extend(batch["overall_labels"].cpu().numpy().tolist())
88
+ if not all_labels:
89
+ return {}
90
+ return {
91
+ "overall_macro_f1": float(f1_score(all_labels, all_preds, average="macro", zero_division=0)),
92
+ "overall_accuracy": float(accuracy_score(all_labels, all_preds)),
93
+ }
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Train: Proposed (BERT + Meta Cross-Attention + multi-head)
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def train_meta_acsa(
101
+ train_df, val_df, meta_encoder: MetaEncoder,
102
+ bert_name: str = cfg.BERT_MODEL_NAME,
103
+ epochs: int = cfg.DEFAULT_EPOCHS,
104
+ batch_size: int = cfg.DEFAULT_BATCH_SIZE,
105
+ lr_bert: float = cfg.DEFAULT_LR_BERT,
106
+ lr_heads: float = cfg.DEFAULT_LR_HEADS,
107
+ weight_decay: float = cfg.DEFAULT_WEIGHT_DECAY,
108
+ use_class_weights: bool = True,
109
+ output_dir: Optional[Path] = None,
110
+ seed: int = cfg.RANDOM_SEED,
111
+ ):
112
+ if output_dir is None:
113
+ output_dir = cfg.CHECKPOINT_DIR / "meta_acsa"
114
+ output_dir = Path(output_dir); output_dir.mkdir(parents=True, exist_ok=True)
115
+
116
+ torch.manual_seed(seed); np.random.seed(seed)
117
+ device = get_device()
118
+ logger.info("Device: %s", device)
119
+
120
+ tokenizer = AutoTokenizer.from_pretrained(bert_name)
121
+
122
+ aspect_cols = [f"aspect_{a}" for a in cfg.ASPECTS]
123
+ class_weights = (compute_class_weights(train_df, aspect_cols, cfg.NUM_CLASSES).to(device)
124
+ if use_class_weights else None)
125
+ if class_weights is not None:
126
+ logger.info("Per-aspect class weights:\n%s", class_weights.cpu().numpy())
127
+
128
+ model = GatedAspectSemanticMetaFusionACSAModel(
129
+ bert_name=bert_name,
130
+ meta_in_dim=meta_encoder.total_dim,
131
+ class_weights=class_weights,
132
+ ).to(device)
133
+
134
+ train_ds = MetaACSADataset(train_df, tokenizer, meta_encoder)
135
+ val_ds = MetaACSADataset(val_df, tokenizer, meta_encoder)
136
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
137
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
138
+
139
+ bert_params = list(model.bert.named_parameters())
140
+ other_params = [(n, p) for n, p in model.named_parameters()
141
+ if not n.startswith("bert.")]
142
+ no_decay = ["bias", "LayerNorm.weight"]
143
+ grouped = [
144
+ {"params": [p for n, p in bert_params if not any(nd in n for nd in no_decay)],
145
+ "weight_decay": weight_decay, "lr": lr_bert},
146
+ {"params": [p for n, p in bert_params if any(nd in n for nd in no_decay)],
147
+ "weight_decay": 0.0, "lr": lr_bert},
148
+ {"params": [p for n, p in other_params if not any(nd in n for nd in no_decay)],
149
+ "weight_decay": weight_decay, "lr": lr_heads},
150
+ {"params": [p for n, p in other_params if any(nd in n for nd in no_decay)],
151
+ "weight_decay": 0.0, "lr": lr_heads},
152
+ ]
153
+ optimizer = torch.optim.AdamW(grouped)
154
+ total_steps = max(len(train_loader) * epochs, 1)
155
+ scheduler = get_linear_schedule_with_warmup(
156
+ optimizer, num_warmup_steps=int(cfg.WARMUP_RATIO * total_steps),
157
+ num_training_steps=total_steps,
158
+ )
159
+
160
+ best_f1 = -1.0; history = []
161
+ for epoch in range(epochs):
162
+ model.train(); running = 0.0
163
+ pbar = tqdm(train_loader, desc=f"[epoch {epoch+1}/{epochs}] meta_acsa")
164
+ for batch in pbar:
165
+ batch = {k: v.to(device) for k, v in batch.items()}
166
+ optimizer.zero_grad()
167
+ out = model(batch["input_ids"], batch["attention_mask"],
168
+ batch["meta_features"], labels=batch["labels"],
169
+ overall_labels=batch.get("overall_labels"))
170
+ out["loss"].backward()
171
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
172
+ optimizer.step(); scheduler.step()
173
+ running += out["loss"].item()
174
+ pbar.set_postfix({"loss": f"{out['loss'].item():.4f}"})
175
+
176
+ avg_loss = running / max(len(train_loader), 1)
177
+ val = _evaluate_per_aspect(model, val_loader, device, with_meta=True)
178
+
179
+ # Also evaluate overall head on val set
180
+ val_overall = _evaluate_overall_head(model, val_loader, device)
181
+ val.update(val_overall)
182
+
183
+ logger.info("Epoch %d | loss=%.4f | val_macro_f1=%.4f | val_acc=%.4f | val_overall_f1=%.4f",
184
+ epoch+1, avg_loss, val["macro_f1_mean"], val["accuracy_mean"],
185
+ val.get("overall_macro_f1", 0.0))
186
+ history.append({"epoch": epoch+1, "train_loss": avg_loss, **val})
187
+
188
+ if val["macro_f1_mean"] > best_f1:
189
+ best_f1 = val["macro_f1_mean"]
190
+ torch.save({"model_state_dict": model.state_dict(),
191
+ "config": {"bert_name": bert_name,
192
+ "meta_in_dim": meta_encoder.total_dim,
193
+ "architecture": "gated_aspect_semantic_meta_acsa",
194
+ "meta_token_names": GatedAspectSemanticMetaFusionACSAModel.meta_token_names}},
195
+ output_dir / "best.pt")
196
+ tokenizer.save_pretrained(output_dir / "tokenizer")
197
+ logger.info("Saved new best meta_acsa (macro_f1=%.4f)", best_f1)
198
+
199
+ with open(output_dir / "history.json", "w") as f:
200
+ json.dump(history, f, indent=2)
201
+ return model, history
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Train: Baseline 3 (BERT-ACSA, no meta 鈥?ablation)
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def train_acsa(
209
+ train_df, val_df,
210
+ bert_name: str = cfg.BERT_MODEL_NAME,
211
+ epochs: int = cfg.DEFAULT_EPOCHS,
212
+ batch_size: int = cfg.DEFAULT_BATCH_SIZE,
213
+ lr_bert: float = cfg.DEFAULT_LR_BERT,
214
+ lr_heads: float = cfg.DEFAULT_LR_HEADS,
215
+ weight_decay: float = cfg.DEFAULT_WEIGHT_DECAY,
216
+ use_class_weights: bool = True,
217
+ output_dir: Optional[Path] = None,
218
+ seed: int = cfg.RANDOM_SEED,
219
+ ):
220
+ if output_dir is None:
221
+ output_dir = cfg.CHECKPOINT_DIR / "acsa"
222
+ output_dir = Path(output_dir); output_dir.mkdir(parents=True, exist_ok=True)
223
+
224
+ torch.manual_seed(seed); np.random.seed(seed)
225
+ device = get_device()
226
+ tokenizer = AutoTokenizer.from_pretrained(bert_name)
227
+
228
+ aspect_cols = [f"aspect_{a}" for a in cfg.ASPECTS]
229
+ class_weights = (compute_class_weights(train_df, aspect_cols, cfg.NUM_CLASSES).to(device)
230
+ if use_class_weights else None)
231
+ model = BertACSAModel(bert_name=bert_name, class_weights=class_weights).to(device)
232
+
233
+ train_ds = ACSADataset(train_df, tokenizer)
234
+ val_ds = ACSADataset(val_df, tokenizer)
235
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
236
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
237
+
238
+ bert_params = list(model.bert.named_parameters())
239
+ head_params = list(model.heads.named_parameters())
240
+ no_decay = ["bias", "LayerNorm.weight"]
241
+ grouped = [
242
+ {"params": [p for n, p in bert_params if not any(nd in n for nd in no_decay)],
243
+ "weight_decay": weight_decay, "lr": lr_bert},
244
+ {"params": [p for n, p in bert_params if any(nd in n for nd in no_decay)],
245
+ "weight_decay": 0.0, "lr": lr_bert},
246
+ {"params": [p for _, p in head_params], "weight_decay": weight_decay, "lr": lr_heads},
247
+ ]
248
+ optimizer = torch.optim.AdamW(grouped)
249
+ total_steps = max(len(train_loader) * epochs, 1)
250
+ scheduler = get_linear_schedule_with_warmup(
251
+ optimizer, int(cfg.WARMUP_RATIO * total_steps), total_steps,
252
+ )
253
+
254
+ best_f1 = -1.0; history = []
255
+ for epoch in range(epochs):
256
+ model.train(); running = 0.0
257
+ pbar = tqdm(train_loader, desc=f"[epoch {epoch+1}/{epochs}] acsa-no-meta")
258
+ for batch in pbar:
259
+ batch = {k: v.to(device) for k, v in batch.items()}
260
+ optimizer.zero_grad()
261
+ out = model(batch["input_ids"], batch["attention_mask"], labels=batch["labels"])
262
+ out["loss"].backward()
263
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
264
+ optimizer.step(); scheduler.step()
265
+ running += out["loss"].item()
266
+ pbar.set_postfix({"loss": f"{out['loss'].item():.4f}"})
267
+
268
+ val = _evaluate_per_aspect(model, val_loader, device, with_meta=False)
269
+ logger.info("Epoch %d | val_macro_f1=%.4f | val_acc=%.4f",
270
+ epoch+1, val["macro_f1_mean"], val["accuracy_mean"])
271
+ history.append({"epoch": epoch+1, "train_loss": running/max(len(train_loader),1),
272
+ **val})
273
+
274
+ if val["macro_f1_mean"] > best_f1:
275
+ best_f1 = val["macro_f1_mean"]
276
+ torch.save({"model_state_dict": model.state_dict(),
277
+ "config": {"bert_name": bert_name}},
278
+ output_dir / "best.pt")
279
+ tokenizer.save_pretrained(output_dir / "tokenizer")
280
+
281
+ with open(output_dir / "history.json", "w") as f:
282
+ json.dump(history, f, indent=2)
283
+ return model, history
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Train: Baseline 2 (BERT-overall)
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def train_bert_overall(
291
+ train_df, val_df,
292
+ bert_name: str = cfg.BERT_MODEL_NAME,
293
+ epochs: int = cfg.DEFAULT_EPOCHS,
294
+ batch_size: int = cfg.DEFAULT_BATCH_SIZE,
295
+ lr: float = cfg.DEFAULT_LR_BERT,
296
+ weight_decay: float = cfg.DEFAULT_WEIGHT_DECAY,
297
+ output_dir: Optional[Path] = None,
298
+ seed: int = cfg.RANDOM_SEED,
299
+ ):
300
+ from sklearn.metrics import f1_score, accuracy_score
301
+ if output_dir is None:
302
+ output_dir = cfg.CHECKPOINT_DIR / "bert_overall"
303
+ output_dir = Path(output_dir); output_dir.mkdir(parents=True, exist_ok=True)
304
+
305
+ torch.manual_seed(seed); np.random.seed(seed)
306
+ device = get_device()
307
+ tokenizer = AutoTokenizer.from_pretrained(bert_name)
308
+ model = BertOverallModel(bert_name=bert_name).to(device)
309
+
310
+ train_ds = OverallSentimentDataset(train_df, tokenizer)
311
+ val_ds = OverallSentimentDataset(val_df, tokenizer)
312
+ train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0)
313
+ val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0)
314
+
315
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
316
+ total_steps = max(len(train_loader) * epochs, 1)
317
+ scheduler = get_linear_schedule_with_warmup(
318
+ optimizer, int(cfg.WARMUP_RATIO * total_steps), total_steps,
319
+ )
320
+
321
+ best_f1 = -1.0; history = []
322
+ for epoch in range(epochs):
323
+ model.train(); running = 0.0
324
+ pbar = tqdm(train_loader, desc=f"[epoch {epoch+1}/{epochs}] bert-overall")
325
+ for batch in pbar:
326
+ batch = {k: v.to(device) for k, v in batch.items()}
327
+ optimizer.zero_grad()
328
+ out = model(**batch)
329
+ out["loss"].backward()
330
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
331
+ optimizer.step(); scheduler.step()
332
+ running += out["loss"].item()
333
+ pbar.set_postfix({"loss": f"{out['loss'].item():.4f}"})
334
+
335
+ model.eval(); all_p, all_l = [], []
336
+ with torch.no_grad():
337
+ for batch in val_loader:
338
+ batch = {k: v.to(device) for k, v in batch.items()}
339
+ out = model(**batch)
340
+ all_p.extend(out["logits"].argmax(dim=-1).cpu().numpy().tolist())
341
+ all_l.extend(batch["labels"].cpu().numpy().tolist())
342
+ metrics = {
343
+ "macro_f1": float(f1_score(all_l, all_p, average="macro", zero_division=0)),
344
+ "accuracy": float(accuracy_score(all_l, all_p)),
345
+ "weighted_f1": float(f1_score(all_l, all_p, average="weighted", zero_division=0)),
346
+ }
347
+ logger.info("Epoch %d | val_macro_f1=%.4f | val_acc=%.4f",
348
+ epoch+1, metrics["macro_f1"], metrics["accuracy"])
349
+ history.append({"epoch": epoch+1, "train_loss": running/max(len(train_loader),1),
350
+ **metrics})
351
+
352
+ if metrics["macro_f1"] > best_f1:
353
+ best_f1 = metrics["macro_f1"]
354
+ torch.save({"model_state_dict": model.state_dict(),
355
+ "config": {"bert_name": bert_name}},
356
+ output_dir / "best.pt")
357
+ tokenizer.save_pretrained(output_dir / "tokenizer")
358
+
359
+ with open(output_dir / "history.json", "w") as f:
360
+ json.dump(history, f, indent=2)
361
+ return model, history
362
+
363
+
364
+
365
+
src/utils.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility helpers."""
2
+ import logging
3
+ import sys
4
+
5
+
6
+ def setup_logging(level=logging.INFO):
7
+ logging.basicConfig(
8
+ level=level,
9
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
10
+ handlers=[logging.StreamHandler(sys.stdout)],
11
+ force=True,
12
+ )
13
+ # Quiet HuggingFace
14
+ logging.getLogger("transformers").setLevel(logging.WARNING)
15
+ logging.getLogger("datasets").setLevel(logging.WARNING)
16
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
17
+ logging.getLogger("filelock").setLevel(logging.WARNING)
src/weak_labeling.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weak supervision label generation for ACSA v2: META-DEPENDENT labeling.
2
+
3
+ Key design principle: the labeling rules MUST depend on metadata so that a
4
+ text-only model (Baseline 3) cannot learn the full labeling function. Only a
5
+ model that sees both text AND metadata can reconstruct the labels perfectly.
6
+
7
+ Three meta-dependent labeling mechanisms:
8
+ 1. Meta-Aware Aspect Detection: if the product's features_text mentions an
9
+ aspect (e.g. "100% Cotton" -> MATERIAL), we treat that aspect as "salient"
10
+ for this SKU. If the review text doesn't explicitly mention it via keyword
11
+ but the user gave a low/high rating, we infer aspect sentiment from rating.
12
+ Rationale: a product that advertises its material invites material-based
13
+ evaluation. A 1-star review on such a product likely reflects material
14
+ disappointment even if the word "fabric" never appears.
15
+ 2. Price-Aware VALUE: products priced above 1.5x the category median with
16
+ low ratings get VALUE=Negative; products below 0.5x median with high
17
+ ratings get VALUE=Positive 鈥?even without "price"/"worth" in the text.
18
+ 3. Category salience prior: Shoes 鈫?SIZE is extra-salient; Dresses/Tops 鈫? APPEARANCE is extra-salient. For salient aspects in the product's category,
19
+ we lower the threshold for marking them as mentioned.
20
+
21
+ For each review row, for each aspect, we assign one of:
22
+ 0 = Not_Mentioned, 1 = Positive, 2 = Negative
23
+
24
+ Output columns: aspect_<NAME> (int), evidence_<NAME> (JSON list)
25
+ """
26
+ import json
27
+ import logging
28
+ import re
29
+ from typing import Dict, List, Optional, Set, Tuple
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ from tqdm import tqdm
34
+
35
+ from . import config as cfg
36
+ from .aspect_dict import compile_aspect_patterns
37
+
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
42
+
43
+ # Category -> which aspects are extra-salient (lowered threshold for detection)
44
+ # High-precision phrase rules for clothing reviews. These catch common cases
45
+ # that generic sentence sentiment may score as neutral.
46
+ EXPLICIT_PHRASE_RULES = [
47
+ ("SIZE", 2, [
48
+ r"\bruns?\s+(a\s+little\s+)?small\b",
49
+ r"\bruns?\s+(a\s+little\s+)?large\b",
50
+ r"\btoo\s+(small|large|big|tight|loose|short|long)\b",
51
+ r"\b(size|fit|fits|fitting)\b[^.!?]{0,35}\b(small|large|big|tight|loose)\b",
52
+ r"\bwrong\s+size\b",
53
+ ]),
54
+ ("SIZE", 1, [
55
+ r"\btrue\s+to\s+size\b",
56
+ r"\bperfect\s+fit\b",
57
+ r"\bfit(s)?\s+perfectly\b",
58
+ ]),
59
+ ("MATERIAL", 2, [
60
+ r"\bheavier\s+than\s+expected\b",
61
+ r"\btoo\s+heavy\b",
62
+ r"\b(feels?|felt)\s+heavy\b",
63
+ r"\b(stiff|scratchy|itchy|rough|bulky)\b",
64
+ r"\bsee[- ]through\b",
65
+ r"\btoo\s+thin\b",
66
+ ]),
67
+ ("MATERIAL", 1, [
68
+ r"\bsoft\s+fabric\b",
69
+ r"\bfabric\s+(feels?\s+)?soft\b",
70
+ r"\bbreathable\b",
71
+ r"\blightweight\b",
72
+ ]),
73
+ ("QUALITY", 2, [
74
+ r"\bfell\s+apart\b",
75
+ r"\bfalling\s+apart\b",
76
+ r"\b(broke|broken|ripped|tore|torn)\b",
77
+ r"\bloose\s+threads?\b",
78
+ r"\b(pilling|pilled|faded|shrunk)\b",
79
+ r"\bshrunk\s+after\s+wash(ing)?\b",
80
+ ]),
81
+ ("QUALITY", 1, [
82
+ r"\bwell[- ]made\b",
83
+ r"\bgood\s+quality\b",
84
+ r"\bhigh\s+quality\b",
85
+ r"\bdurable\b",
86
+ ]),
87
+ ("APPEARANCE", 2, [
88
+ r"\b(color|colour)\s+(is\s+)?off\b",
89
+ r"\bdifferent\s+from\s+(the\s+)?(picture|photo)\b",
90
+ r"\bmisleading\s+photo\b",
91
+ r"\blooks?\s+cheap\b",
92
+ r"\bugly\b",
93
+ ]),
94
+ ("APPEARANCE", 1, [
95
+ r"\bas\s+(pictured|shown|described|advertised)\b",
96
+ r"\bmatches\s+the\s+picture\b",
97
+ r"\b(beautiful|gorgeous|vibrant|stunning)\b",
98
+ ]),
99
+ ("STYLE", 2, [
100
+ r"\bunflattering\b",
101
+ r"\bshapeless\b",
102
+ r"\bawkward\s+cut\b",
103
+ ]),
104
+ ("STYLE", 1, [
105
+ r"\bflattering\b",
106
+ r"\bstylish\b",
107
+ r"\breceived\s+compliments\b",
108
+ ]),
109
+ ("VALUE", 2, [
110
+ r"\bnot\s+worth(\s+it)?\b",
111
+ r"\boverpriced\b",
112
+ r"\bwaste\s+of\s+money\b",
113
+ r"\bwasted\s+money\b",
114
+ ]),
115
+ ("VALUE", 1, [
116
+ r"\bworth\s+it\b",
117
+ r"\bgood\s+deal\b",
118
+ r"\bgreat\s+deal\b",
119
+ r"\bfor\s+the\s+price\b",
120
+ ]),
121
+ ]
122
+
123
+ # Terms mined from product descriptions are useful model inputs, but many are
124
+ # too generic to be aspect evidence in a review. Filtering happens only inside
125
+ # this labeler; data/aspect_dict.json remains unchanged for reproducibility.
126
+ LABEL_NOISE_TERMS = {
127
+ "breathable", "comfort", "easy", "gift", "hand", "high", "keep",
128
+ "lightweight", "long", "look", "only", "please", "pockets", "pull",
129
+ "quality", "size", "spandex",
130
+ }
131
+
132
+ # Local sentiment must be aspect-specific. A global word such as
133
+ # "uncomfortable" cannot legitimately make every aspect Negative.
134
+ ASPECT_LOCAL_CUES = {
135
+ "SIZE": {
136
+ 1: [r"\b(true\s+to\s+size|perfect\s+fit|fits?\s+perfectly)\b"],
137
+ 2: [r"\b(too\s+(small|large|big|tight|loose|short|long)|runs?\s+(small|large)|wrong\s+size)\b"],
138
+ },
139
+ "MATERIAL": {
140
+ 1: [r"\b(soft\s+(fabric|material)|breathable\s+(fabric|material)|fabric\s+feels?\s+soft)\b"],
141
+ 2: [r"\b(scratchy|itchy|rough|stiff|bulky|see[- ]through|too\s+(heavy|thin))\b"],
142
+ },
143
+ "QUALITY": {
144
+ 1: [r"\b(well[- ]made|good\s+quality|high\s+quality|durable|sturdy)\b"],
145
+ 2: [r"\b(fell\s+apart|falling\s+apart|broke|broken|ripped|torn|pilling|pilled|faded|shrunk|loose\s+threads?)\b"],
146
+ },
147
+ "APPEARANCE": {
148
+ 1: [r"\b(beautiful|gorgeous|vibrant|stunning|matches?\s+(the\s+)?(picture|photo))\b"],
149
+ 2: [r"\b(color|colour)\s+(is\s+)?off\b", r"\b(different\s+from\s+(the\s+)?(picture|photo)|misleading\s+photo|ugly)\b"],
150
+ },
151
+ "STYLE": {
152
+ 1: [r"\b(flattering|stylish|fashionable|received\s+compliments?)\b"],
153
+ 2: [r"\b(unflattering|shapeless|awkward\s+cut)\b"],
154
+ },
155
+ "VALUE": {
156
+ 1: [r"\b(worth\s+it|good\s+deal|great\s+deal|value\s+for\s+money)\b"],
157
+ 2: [r"\b(not\s+worth(\s+it)?|overpriced|waste(d)?\s+of\s+money)\b"],
158
+ },
159
+ }
160
+ CATEGORY_SALIENCE = {
161
+ "Shoes": {"SIZE", "QUALITY"},
162
+ "Boots": {"SIZE", "QUALITY"},
163
+ "Sandals": {"SIZE", "QUALITY", "APPEARANCE"},
164
+ "Sneakers": {"SIZE", "QUALITY"},
165
+ "Dresses": {"APPEARANCE", "STYLE", "SIZE"},
166
+ "Tops": {"APPEARANCE", "MATERIAL"},
167
+ "T-Shirts": {"MATERIAL", "SIZE"},
168
+ "Sweaters": {"MATERIAL", "QUALITY"},
169
+ "Jeans": {"SIZE", "QUALITY"},
170
+ "Pants": {"SIZE", "QUALITY"},
171
+ "Jackets": {"QUALITY", "MATERIAL"},
172
+ "Coats": {"QUALITY", "MATERIAL"},
173
+ "Jewelry": {"VALUE", "APPEARANCE", "QUALITY"},
174
+ "Watches": {"VALUE", "QUALITY"},
175
+ "Handbags": {"QUALITY", "VALUE"},
176
+ "Socks": {"MATERIAL", "SIZE"},
177
+ "Underwear": {"MATERIAL", "SIZE"},
178
+ "Lingerie": {"MATERIAL", "SIZE", "APPEARANCE"},
179
+ "Swimwear": {"SIZE", "APPEARANCE"},
180
+ "Activewear": {"MATERIAL", "SIZE"},
181
+ }
182
+
183
+
184
+ def split_sentences(text: str) -> List[str]:
185
+ if not text:
186
+ return []
187
+ return [s.strip() for s in _SENT_SPLIT_RE.split(text.strip()) if s.strip()]
188
+
189
+
190
+ class WeakLabeler:
191
+ """Apply weak supervision rules to assign aspect-level sentiment labels."""
192
+
193
+ def __init__(self, aspect_dict: Dict[str, List[str]],
194
+ category_median_prices: Optional[Dict[str, float]] = None):
195
+ self.aspect_dict = aspect_dict
196
+ self.aspects = list(aspect_dict.keys())
197
+ self.label_aspect_dict = {
198
+ aspect: [term for term in terms if term.strip().lower() not in LABEL_NOISE_TERMS]
199
+ for aspect, terms in aspect_dict.items()
200
+ }
201
+ self.patterns = compile_aspect_patterns(self.label_aspect_dict)
202
+ self.category_median_prices = category_median_prices or {}
203
+
204
+ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
205
+ self.vader = SentimentIntensityAnalyzer()
206
+
207
+ # ---- atomic signals ----
208
+ def _sentence_sentiment(self, sentence: str) -> int:
209
+ """1 (pos), 2 (neg), 0 (neutral/unknown)."""
210
+ score = self.vader.polarity_scores(sentence)["compound"]
211
+ if score >= cfg.VADER_POSITIVE_THRESHOLD:
212
+ return 1
213
+ if score <= cfg.VADER_NEGATIVE_THRESHOLD:
214
+ return 2
215
+ return 0
216
+
217
+ def _rating_sentiment(self, rating: int) -> int:
218
+ if rating >= cfg.RATING_POSITIVE_THRESHOLD:
219
+ return 1
220
+ if rating <= cfg.RATING_NEGATIVE_THRESHOLD:
221
+ return 2
222
+ return 0
223
+
224
+ def _meta_prior(self, avg_rating: float) -> int:
225
+ if not np.isfinite(avg_rating):
226
+ return 0
227
+ if avg_rating >= 4.3:
228
+ return 1
229
+ if avg_rating <= 2.7:
230
+ return 2
231
+ return 0
232
+
233
+ def _local_cue_sentiment(self, aspect: str, sentence: str) -> int:
234
+ """Return sentiment only for cues that belong to this aspect."""
235
+ text = str(sentence or "").lower()
236
+ for pattern in ASPECT_LOCAL_CUES.get(aspect, {}).get(2, []):
237
+ if re.search(pattern, text, flags=re.IGNORECASE):
238
+ return 2
239
+ for pattern in ASPECT_LOCAL_CUES.get(aspect, {}).get(1, []):
240
+ if re.search(pattern, text, flags=re.IGNORECASE):
241
+ return 1
242
+ return 0
243
+
244
+ def _resolve_text_sentiment(self, aspect: str, sentence: str) -> int:
245
+ """Use aspect-specific local evidence before sentence-level sentiment."""
246
+ local = self._local_cue_sentiment(aspect, sentence)
247
+ if local != 0:
248
+ return local
249
+ return self._sentence_sentiment(sentence)
250
+ def _allow_meta_inference(self, aspect: str, rating: int, meta_avg_rating: float,
251
+ features_salient: Set[str], category_salient: Set[str]) -> bool:
252
+ """Allow metadata-only labels only for high-confidence extreme ratings.
253
+
254
+ Text evidence always has priority. Metadata fills otherwise unlabeled
255
+ cases, preserving a real but bounded role for product context.
256
+ """
257
+ if rating <= 1:
258
+ return aspect in features_salient or aspect in category_salient
259
+ if rating >= 5:
260
+ return (
261
+ aspect in features_salient
262
+ and np.isfinite(meta_avg_rating)
263
+ and meta_avg_rating >= 4.3
264
+ )
265
+ return False
266
+ # ---- META-DEPENDENT mechanisms ----
267
+
268
+ def _detect_salient_aspects_from_features(
269
+ self, features_text: str
270
+ ) -> Set[str]:
271
+ """Check which aspects are mentioned in the product's features_text.
272
+ Returns a set of aspect names that the product explicitly advertises.
273
+ """
274
+ if not features_text:
275
+ return set()
276
+ salient = set()
277
+ ft_lower = features_text.lower()
278
+ for aspect in self.aspects:
279
+ if self.patterns[aspect].search(ft_lower):
280
+ salient.add(aspect)
281
+ return salient
282
+
283
+ def _get_category_salient_aspects(self, leaf_category: str) -> Set[str]:
284
+ """Which aspects are extra-salient for this product category."""
285
+ if not leaf_category:
286
+ return set()
287
+ for cat_key, aspects in CATEGORY_SALIENCE.items():
288
+ if cat_key.lower() in leaf_category.lower():
289
+ return aspects
290
+ return set()
291
+
292
+ def _price_value_signal(
293
+ self, price: float, rating: int, leaf_category: str
294
+ ) -> int:
295
+ """Price-aware VALUE labeling: expensive + low rating = VALUE Negative,
296
+ cheap + high rating = VALUE Positive.
297
+ Returns 0 (no signal), 1 (positive), 2 (negative).
298
+ """
299
+ if not np.isfinite(price) or price <= 0:
300
+ return 0
301
+ median = self.category_median_prices.get(leaf_category)
302
+ if median is None or median <= 0:
303
+ # Fall back to global median if category not found
304
+ median = self.category_median_prices.get("__global__")
305
+ if median is None or median <= 0:
306
+ return 0
307
+
308
+ if price > median * 1.5 and rating <= 2:
309
+ return 2 # Expensive + bad rating -> VALUE Negative
310
+ if price < median * 0.5 and rating >= 4:
311
+ return 1 # Cheap + good rating -> VALUE Positive
312
+ if price > median * 2.0 and rating <= 3:
313
+ return 2 # Very expensive + mediocre -> VALUE Negative
314
+ return 0
315
+
316
+ def _apply_explicit_phrase_rules(
317
+ self,
318
+ text: str,
319
+ labels: Dict[str, int],
320
+ evidence: Dict[str, List[str]],
321
+ ) -> None:
322
+ """Apply high-precision clothing phrase rules after sentence matching.
323
+
324
+ Negative rules can override positive/default labels because explicit
325
+ complaints are usually more informative for ACSA than global rating.
326
+ Positive rules fill only unlabeled aspects to avoid washing out a
327
+ complaint already found in text.
328
+ """
329
+ lower = str(text or "").lower()
330
+ for aspect, sentiment, patterns in EXPLICIT_PHRASE_RULES:
331
+ for pat in patterns:
332
+ m = re.search(pat, lower, flags=re.IGNORECASE)
333
+ if not m:
334
+ continue
335
+ snippet = m.group(0)
336
+ if labels[aspect] == 0 or sentiment == 2:
337
+ labels[aspect] = sentiment
338
+ evidence[aspect].append(f"[phrase-rule: {snippet}]")
339
+ break
340
+ # ---- per-review labeling ----
341
+ def label_review(
342
+ self,
343
+ text: str,
344
+ rating: int,
345
+ meta_avg_rating: float = float("nan"),
346
+ features_text: str = "",
347
+ leaf_category: str = "",
348
+ price: float = float("nan"),
349
+ ) -> Tuple[Dict[str, int], Dict[str, List[str]]]:
350
+ labels = {a: 0 for a in self.aspects}
351
+ evidence = {a: [] for a in self.aspects}
352
+
353
+ sentences = split_sentences(text)
354
+
355
+ # --- META-DEPENDENT: detect salient aspects from product features ---
356
+ features_salient = self._detect_salient_aspects_from_features(features_text)
357
+ category_salient = self._get_category_salient_aspects(leaf_category)
358
+ all_salient = features_salient | category_salient
359
+
360
+ # --- Phase 1: text-based detection ---
361
+ for sent in sentences:
362
+ for aspect in self.aspects:
363
+ if not self.patterns[aspect].search(sent):
364
+ continue
365
+ sentiment = self._resolve_text_sentiment(aspect, sent)
366
+ if sentiment == 0:
367
+ continue
368
+ evidence[aspect].append(sent)
369
+ if labels[aspect] == 0:
370
+ labels[aspect] = sentiment
371
+ elif sentiment == 2:
372
+ labels[aspect] = 2
373
+
374
+ # --- Phase 1b: high-precision clothing phrase rules ---
375
+ self._apply_explicit_phrase_rules(text, labels, evidence)
376
+ # --- Phase 2: controlled metadata inference ---
377
+ # Text labels remain authoritative. An unlabeled aspect receives a
378
+ # metadata label only when an extreme review rating and a product signal
379
+ # agree. This avoids turning every product attribute into sentiment.
380
+ if cfg.META_PRIOR_BOOST:
381
+ for aspect in self.aspects:
382
+ if labels[aspect] != 0:
383
+ if aspect in features_salient:
384
+ evidence[aspect].append("[meta-corroborated: feature source]")
385
+ elif aspect in category_salient:
386
+ evidence[aspect].append("[meta-corroborated: category source]")
387
+ continue
388
+ if self._allow_meta_inference(
389
+ aspect, rating, meta_avg_rating, features_salient, category_salient
390
+ ):
391
+ labels[aspect] = 2 if rating <= 1 else 1
392
+ source = "feature" if aspect in features_salient else "category"
393
+ evidence[aspect].append(
394
+ f"[meta-inferred: {source} source, rating={rating}]"
395
+ )
396
+
397
+ # --- Phase 3: price-aware VALUE inference ---
398
+ # Price can label VALUE when its category-relative signal and review
399
+ # rating agree, even if the text does not use a price keyword.
400
+ if labels["VALUE"] == 0:
401
+ price_signal = self._price_value_signal(price, rating, leaf_category)
402
+ if price_signal != 0:
403
+ labels["VALUE"] = price_signal
404
+ evidence["VALUE"].append(
405
+ f"[price-inferred: price={price:.1f}, "
406
+ f"cat_median={self.category_median_prices.get(leaf_category, '?')}, "
407
+ f"rating={rating}]"
408
+ )
409
+
410
+ return labels, evidence
411
+
412
+ # ---- dataframe-level ----
413
+ def label_dataframe(
414
+ self,
415
+ df: pd.DataFrame,
416
+ text_col: str = "full_text",
417
+ rating_col: str = "rating",
418
+ meta_avg_rating_col: str = "average_rating",
419
+ features_text_col: str = "features_text",
420
+ leaf_category_col: str = "leaf_category",
421
+ price_col: str = "price",
422
+ ) -> pd.DataFrame:
423
+ all_labels = {a: [] for a in self.aspects}
424
+ all_evidence = {a: [] for a in self.aspects}
425
+
426
+ has_meta_rating = meta_avg_rating_col in df.columns
427
+ has_features = features_text_col in df.columns
428
+ has_category = leaf_category_col in df.columns
429
+ has_price = price_col in df.columns
430
+
431
+ if not has_meta_rating:
432
+ logger.info("[weak_labeling] No %s column; meta prior disabled.", meta_avg_rating_col)
433
+ if not has_features:
434
+ logger.info("[weak_labeling] No %s column; feature-based salience disabled.", features_text_col)
435
+ if not has_price:
436
+ logger.info("[weak_labeling] No %s column; price-aware VALUE disabled.", price_col)
437
+
438
+ for _, row in tqdm(df.iterrows(), total=len(df), desc="weak labeling"):
439
+ meta_avg = float(row[meta_avg_rating_col]) if has_meta_rating else float("nan")
440
+ feat_text = str(row[features_text_col]) if has_features else ""
441
+ cat = str(row[leaf_category_col]) if has_category else ""
442
+ price = float(row[price_col]) if has_price else float("nan")
443
+
444
+ labels, evidence = self.label_review(
445
+ str(row[text_col]),
446
+ int(row[rating_col]),
447
+ meta_avg_rating=meta_avg,
448
+ features_text=feat_text,
449
+ leaf_category=cat,
450
+ price=price,
451
+ )
452
+ for a in self.aspects:
453
+ all_labels[a].append(labels[a])
454
+ all_evidence[a].append(evidence[a])
455
+
456
+ result = df.copy()
457
+ for a in self.aspects:
458
+ result[f"aspect_{a}"] = all_labels[a]
459
+ result[f"evidence_{a}"] = [json.dumps(e) for e in all_evidence[a]]
460
+ return result
461
+
462
+
463
+ def compute_category_median_prices(df: pd.DataFrame,
464
+ price_col: str = "price",
465
+ cat_col: str = "leaf_category") -> Dict[str, float]:
466
+ """Compute median price per leaf category. Used by Price-Aware VALUE labeling."""
467
+ result = {}
468
+ if price_col not in df.columns or cat_col not in df.columns:
469
+ return result
470
+ prices = pd.to_numeric(df[price_col], errors="coerce")
471
+ global_median = float(prices.median())
472
+ if np.isfinite(global_median):
473
+ result["__global__"] = global_median
474
+ for cat, group in df.groupby(cat_col):
475
+ cat_prices = pd.to_numeric(group[price_col], errors="coerce").dropna()
476
+ if len(cat_prices) >= 5:
477
+ result[str(cat)] = float(cat_prices.median())
478
+ logger.info("Computed median prices for %d categories (global=%.1f)",
479
+ len(result) - 1, result.get("__global__", 0))
480
+ return result
481
+
482
+
483
+ def label_distribution_summary(df: pd.DataFrame) -> pd.DataFrame:
484
+ rows = []
485
+ for a in cfg.ASPECTS:
486
+ col = f"aspect_{a}"
487
+ if col not in df.columns:
488
+ continue
489
+ vc = df[col].value_counts().sort_index()
490
+ rows.append({
491
+ "aspect": a,
492
+ "not_mentioned": int(vc.get(0, 0)),
493
+ "positive": int(vc.get(1, 0)),
494
+ "negative": int(vc.get(2, 0)),
495
+ "mentioned_pct": round(100 * (1 - vc.get(0, 0) / max(len(df), 1)), 2),
496
+ })
497
+ return pd.DataFrame(rows)
498
+
499
+
500
+ def save_audit_sample(df: pd.DataFrame, n: int = cfg.AUDIT_SAMPLE_SIZE, path=None):
501
+ if path is None:
502
+ path = cfg.REPORT_DIR / "weak_label_audit_sample.csv"
503
+
504
+ cols = ["full_text", "rating", "overall_label", "parent_asin",
505
+ "average_rating", "leaf_category", "price", "features_text"]
506
+ cols += [f"aspect_{a}" for a in cfg.ASPECTS]
507
+ cols += [f"evidence_{a}" for a in cfg.ASPECTS]
508
+ cols = [c for c in cols if c in df.columns]
509
+
510
+ by_rating = df.groupby("rating", group_keys=False)
511
+ sample = by_rating.apply(lambda x: x.sample(n=min(max(n // 5, 1), len(x)),
512
+ random_state=cfg.RANDOM_SEED))
513
+ sample = sample.reset_index(drop=True)
514
+ if len(sample) > n:
515
+ sample = sample.sample(n=n, random_state=cfg.RANDOM_SEED)
516
+ keep = [c for c in cols if c in sample.columns]
517
+ sample[keep].to_csv(path, index=False)
518
+ logger.info("Saved %d audit samples to %s", len(sample), path)
519
+ return sample
520
+
521
+
522
+
523
+