Commit ·
38136ed
1
Parent(s): be504c1
Deploy 3W 0624 BERT metadata fusion model
Browse files- .gitattributes +1 -35
- README.md +16 -8
- __pycache__/app.cpython-314.pyc +0 -0
- app.py +630 -0
- checkpoints/meta_acsa/best.pt +3 -0
- checkpoints/meta_acsa/history.json +62 -0
- checkpoints/meta_acsa/tokenizer/tokenizer.json +0 -0
- checkpoints/meta_acsa/tokenizer/tokenizer_config.json +15 -0
- data/aspect_dict.json +354 -0
- data/meta_encoder.pkl +0 -0
- reports/ablation_summary.json +122 -0
- reports/evaluation_comparison.json +689 -0
- requirements.txt +7 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/__pycache__/ablation.cpython-314.pyc +0 -0
- src/__pycache__/aspect_dict.cpython-314.pyc +0 -0
- src/__pycache__/baselines.cpython-314.pyc +0 -0
- src/__pycache__/config.cpython-314.pyc +0 -0
- src/__pycache__/data_download.cpython-314.pyc +0 -0
- src/__pycache__/dataset.cpython-314.pyc +0 -0
- src/__pycache__/evaluator.cpython-314.pyc +0 -0
- src/__pycache__/explainer.cpython-314.pyc +0 -0
- src/__pycache__/inference.cpython-314.pyc +0 -0
- src/__pycache__/meta_encoder.cpython-314.pyc +0 -0
- src/__pycache__/models.cpython-314.pyc +0 -0
- src/__pycache__/preprocess.cpython-314.pyc +0 -0
- src/__pycache__/trainer.cpython-314.pyc +0 -0
- src/__pycache__/utils.cpython-314.pyc +0 -0
- src/__pycache__/weak_labeling.cpython-314.pyc +0 -0
- src/ablation.py +383 -0
- src/aspect_dict.py +387 -0
- src/baselines.py +79 -0
- src/config.py +96 -0
- src/data_download.py +216 -0
- src/dataset.py +115 -0
- src/evaluator.py +326 -0
- src/explainer.py +726 -0
- src/inference.py +264 -0
- src/meta_encoder.py +208 -0
- src/models.py +613 -0
- src/preprocess.py +137 -0
- src/trainer.py +365 -0
- src/utils.py +17 -0
- src/weak_labeling.py +523 -0
.gitattributes
CHANGED
|
@@ -1,35 +1 @@
|
|
| 1 |
-
*.
|
| 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|