| """Weak supervision label generation for ACSA v2: META-DEPENDENT labeling. |
| |
| Key design principle: the labeling rules MUST depend on metadata so that a |
| text-only model (Baseline 3) cannot learn the full labeling function. Only a |
| model that sees both text AND metadata can reconstruct the labels perfectly. |
| |
| Three meta-dependent labeling mechanisms: |
| 1. Meta-Aware Aspect Detection: if the product's features_text mentions an |
| aspect (e.g. "100% Cotton" -> MATERIAL), we treat that aspect as "salient" |
| for this SKU. If the review text doesn't explicitly mention it via keyword |
| but the user gave a low/high rating, we infer aspect sentiment from rating. |
| Rationale: a product that advertises its material invites material-based |
| evaluation. A 1-star review on such a product likely reflects material |
| disappointment even if the word "fabric" never appears. |
| 2. Price-Aware VALUE: products priced above 1.5x the category median with |
| low ratings get VALUE=Negative; products below 0.5x median with high |
| ratings get VALUE=Positive 鈥?even without "price"/"worth" in the text. |
| 3. Category salience prior: Shoes 鈫?SIZE is extra-salient; Dresses/Tops 鈫? APPEARANCE is extra-salient. For salient aspects in the product's category, |
| we lower the threshold for marking them as mentioned. |
| |
| For each review row, for each aspect, we assign one of: |
| 0 = Not_Mentioned, 1 = Positive, 2 = Negative |
| |
| Output columns: aspect_<NAME> (int), evidence_<NAME> (JSON list) |
| """ |
| import json |
| import logging |
| import re |
| from typing import Dict, List, Optional, Set, Tuple |
|
|
| import numpy as np |
| import pandas as pd |
| from tqdm import tqdm |
|
|
| from . import config as cfg |
| from .aspect_dict import compile_aspect_patterns |
|
|
|
|
| logger = logging.getLogger(__name__) |
|
|
| _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") |
|
|
| |
| |
| |
| EXPLICIT_PHRASE_RULES = [ |
| ("SIZE", 2, [ |
| r"\bruns?\s+(a\s+little\s+)?small\b", |
| r"\bruns?\s+(a\s+little\s+)?large\b", |
| r"\btoo\s+(small|large|big|tight|loose|short|long)\b", |
| r"\b(size|fit|fits|fitting)\b[^.!?]{0,35}\b(small|large|big|tight|loose)\b", |
| r"\bwrong\s+size\b", |
| ]), |
| ("SIZE", 1, [ |
| r"\btrue\s+to\s+size\b", |
| r"\bperfect\s+fit\b", |
| r"\bfit(s)?\s+perfectly\b", |
| ]), |
| ("MATERIAL", 2, [ |
| r"\bheavier\s+than\s+expected\b", |
| r"\btoo\s+heavy\b", |
| r"\b(feels?|felt)\s+heavy\b", |
| r"\b(stiff|scratchy|itchy|rough|bulky)\b", |
| r"\bsee[- ]through\b", |
| r"\btoo\s+thin\b", |
| ]), |
| ("MATERIAL", 1, [ |
| r"\bsoft\s+fabric\b", |
| r"\bfabric\s+(feels?\s+)?soft\b", |
| r"\bbreathable\b", |
| r"\blightweight\b", |
| ]), |
| ("QUALITY", 2, [ |
| r"\bfell\s+apart\b", |
| r"\bfalling\s+apart\b", |
| r"\b(broke|broken|ripped|tore|torn)\b", |
| r"\bloose\s+threads?\b", |
| r"\b(pilling|pilled|faded|shrunk)\b", |
| r"\bshrunk\s+after\s+wash(ing)?\b", |
| ]), |
| ("QUALITY", 1, [ |
| r"\bwell[- ]made\b", |
| r"\bgood\s+quality\b", |
| r"\bhigh\s+quality\b", |
| r"\bdurable\b", |
| ]), |
| ("APPEARANCE", 2, [ |
| r"\b(color|colour)\s+(is\s+)?off\b", |
| r"\bdifferent\s+from\s+(the\s+)?(picture|photo)\b", |
| r"\bmisleading\s+photo\b", |
| r"\blooks?\s+cheap\b", |
| r"\bugly\b", |
| ]), |
| ("APPEARANCE", 1, [ |
| r"\bas\s+(pictured|shown|described|advertised)\b", |
| r"\bmatches\s+the\s+picture\b", |
| r"\b(beautiful|gorgeous|vibrant|stunning)\b", |
| ]), |
| ("STYLE", 2, [ |
| r"\bunflattering\b", |
| r"\bshapeless\b", |
| r"\bawkward\s+cut\b", |
| ]), |
| ("STYLE", 1, [ |
| r"\bflattering\b", |
| r"\bstylish\b", |
| r"\breceived\s+compliments\b", |
| ]), |
| ("VALUE", 2, [ |
| r"\bnot\s+worth(\s+it)?\b", |
| r"\boverpriced\b", |
| r"\bwaste\s+of\s+money\b", |
| r"\bwasted\s+money\b", |
| ]), |
| ("VALUE", 1, [ |
| r"\bworth\s+it\b", |
| r"\bgood\s+deal\b", |
| r"\bgreat\s+deal\b", |
| r"\bfor\s+the\s+price\b", |
| ]), |
| ] |
|
|
| |
| |
| |
| LABEL_NOISE_TERMS = { |
| "breathable", "comfort", "easy", "gift", "hand", "high", "keep", |
| "lightweight", "long", "look", "only", "please", "pockets", "pull", |
| "quality", "size", "spandex", |
| } |
|
|
| |
| |
| ASPECT_LOCAL_CUES = { |
| "SIZE": { |
| 1: [r"\b(true\s+to\s+size|perfect\s+fit|fits?\s+perfectly)\b"], |
| 2: [r"\b(too\s+(small|large|big|tight|loose|short|long)|runs?\s+(small|large)|wrong\s+size)\b"], |
| }, |
| "MATERIAL": { |
| 1: [r"\b(soft\s+(fabric|material)|breathable\s+(fabric|material)|fabric\s+feels?\s+soft)\b"], |
| 2: [r"\b(scratchy|itchy|rough|stiff|bulky|see[- ]through|too\s+(heavy|thin))\b"], |
| }, |
| "QUALITY": { |
| 1: [r"\b(well[- ]made|good\s+quality|high\s+quality|durable|sturdy)\b"], |
| 2: [r"\b(fell\s+apart|falling\s+apart|broke|broken|ripped|torn|pilling|pilled|faded|shrunk|loose\s+threads?)\b"], |
| }, |
| "APPEARANCE": { |
| 1: [r"\b(beautiful|gorgeous|vibrant|stunning|matches?\s+(the\s+)?(picture|photo))\b"], |
| 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"], |
| }, |
| "STYLE": { |
| 1: [r"\b(flattering|stylish|fashionable|received\s+compliments?)\b"], |
| 2: [r"\b(unflattering|shapeless|awkward\s+cut)\b"], |
| }, |
| "VALUE": { |
| 1: [r"\b(worth\s+it|good\s+deal|great\s+deal|value\s+for\s+money)\b"], |
| 2: [r"\b(not\s+worth(\s+it)?|overpriced|waste(d)?\s+of\s+money)\b"], |
| }, |
| } |
| CATEGORY_SALIENCE = { |
| "Shoes": {"SIZE", "QUALITY"}, |
| "Boots": {"SIZE", "QUALITY"}, |
| "Sandals": {"SIZE", "QUALITY", "APPEARANCE"}, |
| "Sneakers": {"SIZE", "QUALITY"}, |
| "Dresses": {"APPEARANCE", "STYLE", "SIZE"}, |
| "Tops": {"APPEARANCE", "MATERIAL"}, |
| "T-Shirts": {"MATERIAL", "SIZE"}, |
| "Sweaters": {"MATERIAL", "QUALITY"}, |
| "Jeans": {"SIZE", "QUALITY"}, |
| "Pants": {"SIZE", "QUALITY"}, |
| "Jackets": {"QUALITY", "MATERIAL"}, |
| "Coats": {"QUALITY", "MATERIAL"}, |
| "Jewelry": {"VALUE", "APPEARANCE", "QUALITY"}, |
| "Watches": {"VALUE", "QUALITY"}, |
| "Handbags": {"QUALITY", "VALUE"}, |
| "Socks": {"MATERIAL", "SIZE"}, |
| "Underwear": {"MATERIAL", "SIZE"}, |
| "Lingerie": {"MATERIAL", "SIZE", "APPEARANCE"}, |
| "Swimwear": {"SIZE", "APPEARANCE"}, |
| "Activewear": {"MATERIAL", "SIZE"}, |
| } |
|
|
|
|
| def split_sentences(text: str) -> List[str]: |
| if not text: |
| return [] |
| return [s.strip() for s in _SENT_SPLIT_RE.split(text.strip()) if s.strip()] |
|
|
|
|
| class WeakLabeler: |
| """Apply weak supervision rules to assign aspect-level sentiment labels.""" |
|
|
| def __init__(self, aspect_dict: Dict[str, List[str]], |
| category_median_prices: Optional[Dict[str, float]] = None): |
| self.aspect_dict = aspect_dict |
| self.aspects = list(aspect_dict.keys()) |
| self.label_aspect_dict = { |
| aspect: [term for term in terms if term.strip().lower() not in LABEL_NOISE_TERMS] |
| for aspect, terms in aspect_dict.items() |
| } |
| self.patterns = compile_aspect_patterns(self.label_aspect_dict) |
| self.category_median_prices = category_median_prices or {} |
|
|
| from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer |
| self.vader = SentimentIntensityAnalyzer() |
|
|
| |
| def _sentence_sentiment(self, sentence: str) -> int: |
| """1 (pos), 2 (neg), 0 (neutral/unknown).""" |
| score = self.vader.polarity_scores(sentence)["compound"] |
| if score >= cfg.VADER_POSITIVE_THRESHOLD: |
| return 1 |
| if score <= cfg.VADER_NEGATIVE_THRESHOLD: |
| return 2 |
| return 0 |
|
|
| def _rating_sentiment(self, rating: int) -> int: |
| if rating >= cfg.RATING_POSITIVE_THRESHOLD: |
| return 1 |
| if rating <= cfg.RATING_NEGATIVE_THRESHOLD: |
| return 2 |
| return 0 |
|
|
| def _meta_prior(self, avg_rating: float) -> int: |
| if not np.isfinite(avg_rating): |
| return 0 |
| if avg_rating >= 4.3: |
| return 1 |
| if avg_rating <= 2.7: |
| return 2 |
| return 0 |
|
|
| def _local_cue_sentiment(self, aspect: str, sentence: str) -> int: |
| """Return sentiment only for cues that belong to this aspect.""" |
| text = str(sentence or "").lower() |
| for pattern in ASPECT_LOCAL_CUES.get(aspect, {}).get(2, []): |
| if re.search(pattern, text, flags=re.IGNORECASE): |
| return 2 |
| for pattern in ASPECT_LOCAL_CUES.get(aspect, {}).get(1, []): |
| if re.search(pattern, text, flags=re.IGNORECASE): |
| return 1 |
| return 0 |
|
|
| def _resolve_text_sentiment(self, aspect: str, sentence: str) -> int: |
| """Use aspect-specific local evidence before sentence-level sentiment.""" |
| local = self._local_cue_sentiment(aspect, sentence) |
| if local != 0: |
| return local |
| return self._sentence_sentiment(sentence) |
| def _allow_meta_inference(self, aspect: str, rating: int, meta_avg_rating: float, |
| features_salient: Set[str], category_salient: Set[str]) -> bool: |
| """Allow metadata-only labels only for high-confidence extreme ratings. |
| |
| Text evidence always has priority. Metadata fills otherwise unlabeled |
| cases, preserving a real but bounded role for product context. |
| """ |
| if rating <= 1: |
| return aspect in features_salient or aspect in category_salient |
| if rating >= 5: |
| return ( |
| aspect in features_salient |
| and np.isfinite(meta_avg_rating) |
| and meta_avg_rating >= 4.3 |
| ) |
| return False |
| |
|
|
| def _detect_salient_aspects_from_features( |
| self, features_text: str |
| ) -> Set[str]: |
| """Check which aspects are mentioned in the product's features_text. |
| Returns a set of aspect names that the product explicitly advertises. |
| """ |
| if not features_text: |
| return set() |
| salient = set() |
| ft_lower = features_text.lower() |
| for aspect in self.aspects: |
| if self.patterns[aspect].search(ft_lower): |
| salient.add(aspect) |
| return salient |
|
|
| def _get_category_salient_aspects(self, leaf_category: str) -> Set[str]: |
| """Which aspects are extra-salient for this product category.""" |
| if not leaf_category: |
| return set() |
| for cat_key, aspects in CATEGORY_SALIENCE.items(): |
| if cat_key.lower() in leaf_category.lower(): |
| return aspects |
| return set() |
|
|
| def _price_value_signal( |
| self, price: float, rating: int, leaf_category: str |
| ) -> int: |
| """Price-aware VALUE labeling: expensive + low rating = VALUE Negative, |
| cheap + high rating = VALUE Positive. |
| Returns 0 (no signal), 1 (positive), 2 (negative). |
| """ |
| if not np.isfinite(price) or price <= 0: |
| return 0 |
| median = self.category_median_prices.get(leaf_category) |
| if median is None or median <= 0: |
| |
| median = self.category_median_prices.get("__global__") |
| if median is None or median <= 0: |
| return 0 |
|
|
| if price > median * 1.5 and rating <= 2: |
| return 2 |
| if price < median * 0.5 and rating >= 4: |
| return 1 |
| if price > median * 2.0 and rating <= 3: |
| return 2 |
| return 0 |
|
|
| def _apply_explicit_phrase_rules( |
| self, |
| text: str, |
| labels: Dict[str, int], |
| evidence: Dict[str, List[str]], |
| ) -> None: |
| """Apply high-precision clothing phrase rules after sentence matching. |
| |
| Negative rules can override positive/default labels because explicit |
| complaints are usually more informative for ACSA than global rating. |
| Positive rules fill only unlabeled aspects to avoid washing out a |
| complaint already found in text. |
| """ |
| lower = str(text or "").lower() |
| for aspect, sentiment, patterns in EXPLICIT_PHRASE_RULES: |
| for pat in patterns: |
| m = re.search(pat, lower, flags=re.IGNORECASE) |
| if not m: |
| continue |
| snippet = m.group(0) |
| if labels[aspect] == 0 or sentiment == 2: |
| labels[aspect] = sentiment |
| evidence[aspect].append(f"[phrase-rule: {snippet}]") |
| break |
| |
| def label_review( |
| self, |
| text: str, |
| rating: int, |
| meta_avg_rating: float = float("nan"), |
| features_text: str = "", |
| leaf_category: str = "", |
| price: float = float("nan"), |
| ) -> Tuple[Dict[str, int], Dict[str, List[str]]]: |
| labels = {a: 0 for a in self.aspects} |
| evidence = {a: [] for a in self.aspects} |
|
|
| sentences = split_sentences(text) |
|
|
| |
| features_salient = self._detect_salient_aspects_from_features(features_text) |
| category_salient = self._get_category_salient_aspects(leaf_category) |
| all_salient = features_salient | category_salient |
|
|
| |
| for sent in sentences: |
| for aspect in self.aspects: |
| if not self.patterns[aspect].search(sent): |
| continue |
| sentiment = self._resolve_text_sentiment(aspect, sent) |
| if sentiment == 0: |
| continue |
| evidence[aspect].append(sent) |
| if labels[aspect] == 0: |
| labels[aspect] = sentiment |
| elif sentiment == 2: |
| labels[aspect] = 2 |
|
|
| |
| self._apply_explicit_phrase_rules(text, labels, evidence) |
| |
| |
| |
| |
| if cfg.META_PRIOR_BOOST: |
| for aspect in self.aspects: |
| if labels[aspect] != 0: |
| if aspect in features_salient: |
| evidence[aspect].append("[meta-corroborated: feature source]") |
| elif aspect in category_salient: |
| evidence[aspect].append("[meta-corroborated: category source]") |
| continue |
| if self._allow_meta_inference( |
| aspect, rating, meta_avg_rating, features_salient, category_salient |
| ): |
| labels[aspect] = 2 if rating <= 1 else 1 |
| source = "feature" if aspect in features_salient else "category" |
| evidence[aspect].append( |
| f"[meta-inferred: {source} source, rating={rating}]" |
| ) |
|
|
| |
| |
| |
| if labels["VALUE"] == 0: |
| price_signal = self._price_value_signal(price, rating, leaf_category) |
| if price_signal != 0: |
| labels["VALUE"] = price_signal |
| evidence["VALUE"].append( |
| f"[price-inferred: price={price:.1f}, " |
| f"cat_median={self.category_median_prices.get(leaf_category, '?')}, " |
| f"rating={rating}]" |
| ) |
|
|
| return labels, evidence |
|
|
| |
| def label_dataframe( |
| self, |
| df: pd.DataFrame, |
| text_col: str = "full_text", |
| rating_col: str = "rating", |
| meta_avg_rating_col: str = "average_rating", |
| features_text_col: str = "features_text", |
| leaf_category_col: str = "leaf_category", |
| price_col: str = "price", |
| ) -> pd.DataFrame: |
| all_labels = {a: [] for a in self.aspects} |
| all_evidence = {a: [] for a in self.aspects} |
|
|
| has_meta_rating = meta_avg_rating_col in df.columns |
| has_features = features_text_col in df.columns |
| has_category = leaf_category_col in df.columns |
| has_price = price_col in df.columns |
|
|
| if not has_meta_rating: |
| logger.info("[weak_labeling] No %s column; meta prior disabled.", meta_avg_rating_col) |
| if not has_features: |
| logger.info("[weak_labeling] No %s column; feature-based salience disabled.", features_text_col) |
| if not has_price: |
| logger.info("[weak_labeling] No %s column; price-aware VALUE disabled.", price_col) |
|
|
| for _, row in tqdm(df.iterrows(), total=len(df), desc="weak labeling"): |
| meta_avg = float(row[meta_avg_rating_col]) if has_meta_rating else float("nan") |
| feat_text = str(row[features_text_col]) if has_features else "" |
| cat = str(row[leaf_category_col]) if has_category else "" |
| price = float(row[price_col]) if has_price else float("nan") |
|
|
| labels, evidence = self.label_review( |
| str(row[text_col]), |
| int(row[rating_col]), |
| meta_avg_rating=meta_avg, |
| features_text=feat_text, |
| leaf_category=cat, |
| price=price, |
| ) |
| for a in self.aspects: |
| all_labels[a].append(labels[a]) |
| all_evidence[a].append(evidence[a]) |
|
|
| result = df.copy() |
| for a in self.aspects: |
| result[f"aspect_{a}"] = all_labels[a] |
| result[f"evidence_{a}"] = [json.dumps(e) for e in all_evidence[a]] |
| return result |
|
|
|
|
| def compute_category_median_prices(df: pd.DataFrame, |
| price_col: str = "price", |
| cat_col: str = "leaf_category") -> Dict[str, float]: |
| """Compute median price per leaf category. Used by Price-Aware VALUE labeling.""" |
| result = {} |
| if price_col not in df.columns or cat_col not in df.columns: |
| return result |
| prices = pd.to_numeric(df[price_col], errors="coerce") |
| global_median = float(prices.median()) |
| if np.isfinite(global_median): |
| result["__global__"] = global_median |
| for cat, group in df.groupby(cat_col): |
| cat_prices = pd.to_numeric(group[price_col], errors="coerce").dropna() |
| if len(cat_prices) >= 5: |
| result[str(cat)] = float(cat_prices.median()) |
| logger.info("Computed median prices for %d categories (global=%.1f)", |
| len(result) - 1, result.get("__global__", 0)) |
| return result |
|
|
|
|
| def label_distribution_summary(df: pd.DataFrame) -> pd.DataFrame: |
| rows = [] |
| for a in cfg.ASPECTS: |
| col = f"aspect_{a}" |
| if col not in df.columns: |
| continue |
| vc = df[col].value_counts().sort_index() |
| rows.append({ |
| "aspect": a, |
| "not_mentioned": int(vc.get(0, 0)), |
| "positive": int(vc.get(1, 0)), |
| "negative": int(vc.get(2, 0)), |
| "mentioned_pct": round(100 * (1 - vc.get(0, 0) / max(len(df), 1)), 2), |
| }) |
| return pd.DataFrame(rows) |
|
|
|
|
| def save_audit_sample(df: pd.DataFrame, n: int = cfg.AUDIT_SAMPLE_SIZE, path=None): |
| if path is None: |
| path = cfg.REPORT_DIR / "weak_label_audit_sample.csv" |
|
|
| cols = ["full_text", "rating", "overall_label", "parent_asin", |
| "average_rating", "leaf_category", "price", "features_text"] |
| cols += [f"aspect_{a}" for a in cfg.ASPECTS] |
| cols += [f"evidence_{a}" for a in cfg.ASPECTS] |
| cols = [c for c in cols if c in df.columns] |
|
|
| by_rating = df.groupby("rating", group_keys=False) |
| sample = by_rating.apply(lambda x: x.sample(n=min(max(n // 5, 1), len(x)), |
| random_state=cfg.RANDOM_SEED)) |
| sample = sample.reset_index(drop=True) |
| if len(sample) > n: |
| sample = sample.sample(n=n, random_state=cfg.RANDOM_SEED) |
| keep = [c for c in cols if c in sample.columns] |
| sample[keep].to_csv(path, index=False) |
| logger.info("Saved %d audit samples to %s", len(sample), path) |
| return sample |
|
|
|
|
|
|
|
|
|
|