"""Preprocess raw review + meta data: clean, join, stratified split.""" import html import logging import re from typing import Optional import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from . import config as cfg logger = logging.getLogger(__name__) _URL_RE = re.compile(r"https?://\S+|www\.\S+") _HTML_RE = re.compile(r"<[^>]+>") _WHITESPACE_RE = re.compile(r"\s+") def clean_text(text: str) -> str: """Light cleaning suitable for BERT input. NOTE: do NOT remove stopwords or lemmatize - BERT needs the original context. """ if not isinstance(text, str): return "" text = html.unescape(text) text = _URL_RE.sub(" ", text) text = _HTML_RE.sub(" ", text) text = _WHITESPACE_RE.sub(" ", text).strip() return text def _features_to_text(features) -> str: """Flatten meta `features` field (which can be list or string) to one blob.""" if features is None: return "" if isinstance(features, (list, tuple, np.ndarray)): return " ".join(str(x) for x in features if x) return str(features) def _categories_to_text(categories) -> str: if categories is None: return "" if isinstance(categories, (list, tuple, np.ndarray)): return " > ".join(str(x) for x in categories if x) return str(categories) def join_and_clean(reviews_df: pd.DataFrame, meta_df: pd.DataFrame) -> pd.DataFrame: """Inner join review + meta on parent_asin, clean text, build derived columns.""" logger.info("Pre-join: %d reviews, %d meta items.", len(reviews_df), len(meta_df)) # Subset meta to columns we'll use meta_cols = ["parent_asin", "title", "features", "categories", "main_category", "price", "average_rating", "rating_number"] meta_cols = [c for c in meta_cols if c in meta_df.columns] meta_sub = meta_df[meta_cols].copy() meta_sub = meta_sub.rename(columns={"title": "product_title"}) # Build flattened meta text fields if "features" in meta_sub.columns: meta_sub["features_text"] = meta_sub["features"].apply(_features_to_text) else: meta_sub["features_text"] = "" if "categories" in meta_sub.columns: meta_sub["categories_text"] = meta_sub["categories"].apply(_categories_to_text) meta_sub["leaf_category"] = meta_sub["categories"].apply( lambda c: (c[-1] if isinstance(c, (list, tuple, np.ndarray)) and len(c) > 0 else "") ) else: meta_sub["categories_text"] = "" meta_sub["leaf_category"] = "" # Inner join merged = reviews_df.merge(meta_sub, on="parent_asin", how="inner", suffixes=("", "_meta")) logger.info("After inner join: %d rows", len(merged)) # Clean review text if "title" in merged.columns: merged["title"] = merged["title"].fillna("").astype(str).apply(clean_text) else: merged["title"] = "" merged["text"] = merged["text"].fillna("").astype(str).apply(clean_text) # Combine title + body for model input merged["full_text"] = (merged["title"] + " " + merged["text"]).str.strip() # Filter junk merged = merged[merged["full_text"].str.len() >= cfg.MIN_REVIEW_LENGTH].copy() # Ensure rating is numeric merged["rating"] = pd.to_numeric(merged["rating"], errors="coerce") merged = merged.dropna(subset=["rating"]).copy() merged["rating"] = merged["rating"].astype(int) # Overall sentiment label from rating (3-class, used for baseline comparison) # 1-2 = Negative (2), 3 = Neutral (1), 4-5 = Positive (0) # We'll align with: 0=Negative, 1=Neutral, 2=Positive for baseline def rating_to_overall(r): if r <= 2: return 0 # Negative if r == 3: return 1 # Neutral return 2 # Positive merged["overall_label"] = merged["rating"].apply(rating_to_overall) logger.info("Final rows: %d", len(merged)) logger.info("Rating distribution:\n%s", merged["rating"].value_counts().sort_index().to_string()) return merged.reset_index(drop=True) def stratified_split(df: pd.DataFrame, label_col: str = "rating"): """Stratified split by rating to keep class balance across train/val/test.""" train_val, test = train_test_split( df, test_size=cfg.TEST_RATIO, stratify=df[label_col], random_state=cfg.RANDOM_SEED ) val_size_relative = cfg.VAL_RATIO / (cfg.TRAIN_RATIO + cfg.VAL_RATIO) train, val = train_test_split( train_val, test_size=val_size_relative, stratify=train_val[label_col], random_state=cfg.RANDOM_SEED ) logger.info("Split sizes: train=%d val=%d test=%d", len(train), len(val), len(test)) return train.reset_index(drop=True), val.reset_index(drop=True), test.reset_index(drop=True) def preprocess_pipeline(): """Run the full preprocessing pipeline from raw files.""" logger.info("Loading raw data...") reviews_df = pd.read_parquet(cfg.RAW_REVIEWS_PATH) meta_df = pd.read_parquet(cfg.RAW_META_PATH) processed = join_and_clean(reviews_df, meta_df) processed.to_parquet(cfg.PROCESSED_PATH, index=False) logger.info("Saved processed data to %s", cfg.PROCESSED_PATH) return processed