Spaces:
Running on Zero
Running on Zero
| """Metadata encoder: turn each row's product-side fields into a fixed-length vector. | |
| Output layout (concatenated): | |
| [ TF-IDF on features_text (META_FEATURE_TFIDF_DIM) # product attributes | |
| | TF-IDF on categories_text (META_CATEGORY_TFIDF_DIM) # product taxonomy | |
| | numeric vector (META_NUM_DIM) # price + ratings + flags | |
| ] | |
| Total raw dim = META_TFIDF_DIM + META_NUM_DIM. | |
| The MLP inside the model maps this to META_HIDDEN_DIM. We keep encoding as a | |
| separate, plain-numpy step so it can be fit once on train and reused without | |
| touching the model. | |
| """ | |
| import logging | |
| import pickle | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Optional, Sequence | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.preprocessing import StandardScaler | |
| from . import config as cfg | |
| logger = logging.getLogger(__name__) | |
| # Columns we expect on the input df (produced by preprocess.join_and_clean). | |
| # Missing columns are handled gracefully. | |
| FEATURES_TEXT_COL = "features_text" | |
| CATEGORIES_TEXT_COL = "categories_text" | |
| PRICE_COL = "price" | |
| AVG_RATING_COL = "average_rating" | |
| RATING_NUMBER_COL = "rating_number" | |
| def _safe_text(series: pd.Series) -> pd.Series: | |
| return series.fillna("").astype(str) | |
| def _safe_numeric(series: pd.Series) -> pd.Series: | |
| return pd.to_numeric(series, errors="coerce") | |
| class MetaEncoder: | |
| """Fit on train df, then transform any df (train/val/test/new) to a (N, D) matrix.""" | |
| feature_tfidf_dim: int = cfg.META_FEATURE_TFIDF_DIM | |
| category_tfidf_dim: int = cfg.META_CATEGORY_TFIDF_DIM | |
| num_dim: int = cfg.META_NUM_DIM # price, avg_rating, log_rating_number, price_missing_flag | |
| # Filled by fit() | |
| feature_tfidf: Optional[TfidfVectorizer] = None | |
| category_tfidf: Optional[TfidfVectorizer] = None | |
| scaler: Optional[StandardScaler] = None | |
| price_median_: Optional[float] = None | |
| avg_rating_median_: Optional[float] = None | |
| rating_number_median_: Optional[float] = None | |
| def tfidf_dim(self) -> int: | |
| return self.feature_tfidf_dim + self.category_tfidf_dim | |
| def total_dim(self) -> int: | |
| return self.tfidf_dim + self.num_dim | |
| # ------------------------------------------------------------------ fit | |
| def fit(self, df: pd.DataFrame) -> "MetaEncoder": | |
| # 1. Fit text metadata as separate semantic sources. Keeping features | |
| # and categories apart gives cross-attention tokens real meaning. | |
| feat_text = _safe_text(df.get(FEATURES_TEXT_COL, pd.Series([""] * len(df)))) | |
| cat_text = _safe_text(df.get(CATEGORIES_TEXT_COL, pd.Series([""] * len(df)))) | |
| self.feature_tfidf = TfidfVectorizer( | |
| max_features=self.feature_tfidf_dim, | |
| ngram_range=(1, 2), | |
| min_df=2, | |
| max_df=0.95, | |
| stop_words="english", | |
| sublinear_tf=True, | |
| ) | |
| # If the corpus is too small to have any vocab, TF-IDF will raise. | |
| # Fall back to a single zero-dim by fitting on a synthetic vocab. | |
| try: | |
| self.feature_tfidf.fit(feat_text.str.strip()) | |
| except ValueError: | |
| logger.warning("Meta TF-IDF found no usable vocabulary; using zero features.") | |
| self.feature_tfidf = TfidfVectorizer(max_features=1) | |
| self.feature_tfidf.fit(["placeholder"]) | |
| self.category_tfidf = TfidfVectorizer( | |
| max_features=self.category_tfidf_dim, | |
| ngram_range=(1, 2), | |
| min_df=2, | |
| max_df=0.98, | |
| stop_words="english", | |
| sublinear_tf=True, | |
| ) | |
| try: | |
| self.category_tfidf.fit(cat_text.str.strip()) | |
| except ValueError: | |
| logger.warning("Category TF-IDF found no usable vocabulary; using zero features.") | |
| self.category_tfidf = TfidfVectorizer(max_features=1) | |
| self.category_tfidf.fit(["placeholder"]) | |
| # 2. Numeric features: price, avg_rating, log(rating_number+1), price_missing_flag | |
| self.price_median_ = float(_safe_numeric(df.get(PRICE_COL, pd.Series([np.nan]))).median()) | |
| if not np.isfinite(self.price_median_): | |
| self.price_median_ = 0.0 | |
| self.avg_rating_median_ = float(_safe_numeric(df.get(AVG_RATING_COL, pd.Series([np.nan]))).median()) | |
| if not np.isfinite(self.avg_rating_median_): | |
| self.avg_rating_median_ = 4.0 | |
| self.rating_number_median_ = float(_safe_numeric(df.get(RATING_NUMBER_COL, pd.Series([np.nan]))).median()) | |
| if not np.isfinite(self.rating_number_median_): | |
| self.rating_number_median_ = 0.0 | |
| num_mat = self._build_numeric(df) | |
| self.scaler = StandardScaler() | |
| self.scaler.fit(num_mat) | |
| return self | |
| # -------------------------------------------------------------- transform | |
| def transform(self, df: pd.DataFrame) -> np.ndarray: | |
| if self.feature_tfidf is None or self.scaler is None: | |
| raise RuntimeError("MetaEncoder.fit() must be called before transform().") | |
| structured = self.transform_structured(df) | |
| return np.hstack([ | |
| structured["features"], | |
| structured["categories"], | |
| structured["numeric"], | |
| ]).astype(np.float32) | |
| def transform_structured(self, df: pd.DataFrame) -> dict: | |
| """Return source-separated metadata matrices. | |
| Keys map directly to semantic meta tokens used by the upgraded model: | |
| features -> product attribute text, categories -> taxonomy text, | |
| numeric -> price/rating signals. | |
| """ | |
| if self.feature_tfidf is None or self.category_tfidf is None or self.scaler is None: | |
| raise RuntimeError("MetaEncoder.fit() must be called before transform_structured().") | |
| feat_text = _safe_text(df.get(FEATURES_TEXT_COL, pd.Series([""] * len(df)))).str.strip() | |
| cat_text = _safe_text(df.get(CATEGORIES_TEXT_COL, pd.Series([""] * len(df)))).str.strip() | |
| feat_mat = self.feature_tfidf.transform(feat_text).toarray().astype(np.float32) | |
| cat_mat = self.category_tfidf.transform(cat_text).toarray().astype(np.float32) | |
| feat_mat = self._pad_or_truncate(feat_mat, self.feature_tfidf_dim) | |
| cat_mat = self._pad_or_truncate(cat_mat, self.category_tfidf_dim) | |
| num_mat = self.scaler.transform(self._build_numeric(df)).astype(np.float32) | |
| return {"features": feat_mat, "categories": cat_mat, "numeric": num_mat} | |
| def _pad_or_truncate(mat: np.ndarray, dim: int) -> np.ndarray: | |
| if mat.shape[1] < dim: | |
| pad = np.zeros((mat.shape[0], dim - mat.shape[1]), dtype=np.float32) | |
| return np.hstack([mat, pad]) | |
| if mat.shape[1] > dim: | |
| return mat[:, :dim] | |
| return mat | |
| # ------------------------------------------------------------------ helpers | |
| def _build_numeric(self, df: pd.DataFrame) -> np.ndarray: | |
| price = _safe_numeric(df.get(PRICE_COL, pd.Series([np.nan] * len(df)))) | |
| price_missing = price.isna().astype(np.float32).values | |
| price = price.fillna(self.price_median_).astype(np.float32).values | |
| avg = _safe_numeric(df.get(AVG_RATING_COL, pd.Series([np.nan] * len(df)))) | |
| avg = avg.fillna(self.avg_rating_median_).astype(np.float32).values | |
| rnum = _safe_numeric(df.get(RATING_NUMBER_COL, pd.Series([np.nan] * len(df)))) | |
| rnum = rnum.fillna(self.rating_number_median_).astype(np.float32).values | |
| log_rnum = np.log1p(np.maximum(rnum, 0.0)) | |
| return np.vstack([price, avg, log_rnum, price_missing]).T # (N, 4) | |
| # ------------------------------------------------------------------ io | |
| def save(self, path: Path = None) -> Path: | |
| if path is None: | |
| path = cfg.META_ENCODER_PATH | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(path, "wb") as f: | |
| pickle.dump(self, f) | |
| logger.info("Saved MetaEncoder to %s", path) | |
| return path | |
| def load(cls, path: Path = None) -> "MetaEncoder": | |
| if path is None: | |
| path = cfg.META_ENCODER_PATH | |
| with open(path, "rb") as f: | |
| obj = pickle.load(f) | |
| if not isinstance(obj, cls): | |
| raise TypeError(f"Loaded object is not a MetaEncoder: {type(obj)}") | |
| return obj | |
| def fit_and_save(train_df: pd.DataFrame, path: Path = None) -> MetaEncoder: | |
| enc = MetaEncoder().fit(train_df) | |
| enc.save(path) | |
| return enc | |