File size: 21,134 Bytes
38136ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
"""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+")

# Category -> which aspects are extra-salient (lowered threshold for detection)
# High-precision phrase rules for clothing reviews. These catch common cases
# that generic sentence sentiment may score as neutral.
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",
    ]),
]

# Terms mined from product descriptions are useful model inputs, but many are
# too generic to be aspect evidence in a review. Filtering happens only inside
# this labeler; data/aspect_dict.json remains unchanged for reproducibility.
LABEL_NOISE_TERMS = {
    "breathable", "comfort", "easy", "gift", "hand", "high", "keep",
    "lightweight", "long", "look", "only", "please", "pockets", "pull",
    "quality", "size", "spandex",
}

# Local sentiment must be aspect-specific. A global word such as
# "uncomfortable" cannot legitimately make every aspect Negative.
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()

    # ---- atomic signals ----
    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
    # ---- META-DEPENDENT mechanisms ----

    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:
            # Fall back to global median if category not found
            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  # Expensive + bad rating -> VALUE Negative
        if price < median * 0.5 and rating >= 4:
            return 1  # Cheap + good rating -> VALUE Positive
        if price > median * 2.0 and rating <= 3:
            return 2  # Very expensive + mediocre -> VALUE Negative
        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
    # ---- per-review labeling ----
    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)

        # --- META-DEPENDENT: detect salient aspects from product features ---
        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

        # --- Phase 1: text-based detection ---
        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

        # --- Phase 1b: high-precision clothing phrase rules ---
        self._apply_explicit_phrase_rules(text, labels, evidence)
        # --- Phase 2: controlled metadata inference ---
        # Text labels remain authoritative. An unlabeled aspect receives a
        # metadata label only when an extreme review rating and a product signal
        # agree. This avoids turning every product attribute into sentiment.
        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}]"
                    )

        # --- Phase 3: price-aware VALUE inference ---
        # Price can label VALUE when its category-relative signal and review
        # rating agree, even if the text does not use a price keyword.
        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

    # ---- dataframe-level ----
    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