ecom-qa / src /models /sentiment_model.py
rnyx's picture
Initial deploy
f48b219
Raw
History Blame Contribute Delete
7.62 kB
"""
src/models/sentiment_model.py
Fix #6 — Replaces binary SST-2 with:
• 5-class star-rating sentiment (nlptown/bert-base-multilingual-uncased-sentiment)
• Aspect-level sentiment breakdown (keyword-extracted aspects + per-sentence scoring)
Also covers Fix #4 (multilingual) for reviews.
"""
import re
import logging
from collections import defaultdict
from transformers import pipeline
logger = logging.getLogger(__name__)
SENTIMENT_MODEL = "nlptown/bert-base-multilingual-uncased-sentiment"
# Common e-commerce product aspects and their keyword triggers
ASPECT_KEYWORDS = {
"Battery": ["battery", "charge", "charging", "power", "mah", "backup"],
"Display": ["display", "screen", "resolution", "brightness", "colour", "color", "oled", "amoled", "lcd"],
"Camera": ["camera", "photo", "picture", "video", "megapixel", "mp", "selfie", "lens"],
"Performance": ["performance", "speed", "fast", "slow", "lag", "processor", "chip", "ram", "snapdragon", "apple a"],
"Build Quality": ["build", "quality", "material", "plastic", "metal", "glass", "premium", "cheap", "durable", "fragile"],
"Price / Value": ["price", "value", "worth", "expensive", "cheap", "affordable", "overpriced", "budget", "cost"],
"Delivery": ["delivery", "shipping", "packaging", "arrived", "damage", "box"],
"Software": ["software", "ui", "ux", "android", "ios", "update", "bloatware", "interface", "app"],
"Sound": ["sound", "speaker", "audio", "volume", "bass", "microphone", "earphone"],
"Size / Weight": ["size", "weight", "heavy", "light", "compact", "bulky", "portable"],
}
STAR_MAP = {
"1 star": 1,
"2 stars": 2,
"3 stars": 3,
"4 stars": 4,
"5 stars": 5,
}
def _star_to_sentiment(star: int) -> str:
if star >= 4:
return "Positive"
if star == 3:
return "Neutral"
return "Negative"
class SentimentModel:
"""
Analyses product review text and returns:
- Overall star distribution (1–5)
- Overall sentiment summary
- Per-aspect sentiment breakdown
"""
def __init__(self):
self._pipe = None
def _get_pipe(self):
if self._pipe is None:
logger.info("Loading 5-class sentiment model (%s)…", SENTIMENT_MODEL)
self._pipe = pipeline(
"text-classification",
model=SENTIMENT_MODEL,
tokenizer=SENTIMENT_MODEL,
top_k=None, # return all classes
truncation=True,
max_length=512,
)
return self._pipe
# ── Sentence splitting ────────────────────────────────────────────────────
@staticmethod
def _split_sentences(text: str) -> list[str]:
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
# Keep only sentences with >= 5 words (filter noise)
return [s for s in sentences if len(s.split()) >= 5]
# ── Aspect detection ──────────────────────────────────────────────────────
@staticmethod
def _detect_aspects(sentence: str) -> list[str]:
low = sentence.lower()
return [aspect for aspect, keywords in ASPECT_KEYWORDS.items()
if any(kw in low for kw in keywords)]
# ── Score a single piece of text ─────────────────────────────────────────
def _score_text(self, text: str) -> dict:
"""Returns {star_label: score} dict."""
pipe = self._get_pipe()
try:
results = pipe(text[:512])[0] # list of {label, score}
return {r["label"]: r["score"] for r in results}
except Exception as e:
logger.warning("Sentiment scoring failed: %s", e)
return {"3 stars": 1.0}
@staticmethod
def _best_star(scores: dict) -> int:
return STAR_MAP.get(max(scores, key=scores.get), 3)
# ── Public interface ──────────────────────────────────────────────────────
def analyze(self, context: str, question: str = "") -> dict:
"""
Analyse context text for sentiment.
Returns overall summary + per-aspect breakdown.
"""
sentences = self._split_sentences(context)
if not sentences:
return {"summary": "Not enough review text to analyse.", "aspects": {}}
# ── Overall distribution ──────────────────────────────────────────────
star_counts = defaultdict(int)
star_scores = defaultdict(float)
aspect_data = defaultdict(lambda: {"positive": 0, "neutral": 0, "negative": 0, "sentences": []})
for sent in sentences[:100]: # cap at 100 sentences for latency
scores = self._score_text(sent)
star = self._best_star(scores)
sentiment = _star_to_sentiment(star)
star_counts[star] += 1
star_scores[star] += scores.get(f"{star} stars" if star > 1 else "1 star", 0)
for aspect in self._detect_aspects(sent):
aspect_data[aspect][sentiment.lower()] += 1
aspect_data[aspect]["sentences"].append({
"text": sent[:120],
"sentiment": sentiment,
"stars": star,
})
total = max(sum(star_counts.values()), 1)
avg_stars = sum(k * v for k, v in star_counts.items()) / total
# ── Build aspect summary ──────────────────────────────────────────────
aspect_summary = {}
for aspect, data in aspect_data.items():
pos = data["positive"]
neu = data["neutral"]
neg = data["negative"]
total_asp = pos + neu + neg or 1
dominant = max(("Positive", pos), ("Neutral", neu), ("Negative", neg), key=lambda x: x[1])[0]
aspect_summary[aspect] = {
"dominant_sentiment": dominant,
"positive_pct": round(100 * pos / total_asp),
"neutral_pct": round(100 * neu / total_asp),
"negative_pct": round(100 * neg / total_asp),
"review_count": total_asp,
"sample_sentences": data["sentences"][:3],
}
# ── Distribution percentages ──────────────────────────────────────────
distribution = {f"{i} star{'s' if i > 1 else ''}": round(100 * star_counts.get(i, 0) / total)
for i in range(5, 0, -1)}
overall_sentiment = (
"Positive" if avg_stars >= 3.5
else "Negative" if avg_stars < 2.5
else "Mixed"
)
return {
"summary": f"{overall_sentiment} ({avg_stars:.1f}/5 avg across {total} sentences)",
"average_stars": round(avg_stars, 2),
"overall_sentiment": overall_sentiment,
"star_distribution": distribution,
"sentences_analysed": total,
"aspects": aspect_summary,
}