"""Multilingual text features for the free-text ``description`` field. The descriptions mix English, transliterated Kannada and native Kannada script and often state the traffic impact directly ("road closed", "slow moment", "traffic normal"). We encode them with a multilingual sentence-transformer (LaBSE-style) that understands Kannada, cache the raw embeddings to disk, and let the modelling layer reduce them with a train-fitted PCA. A TF-IDF + SVD fallback is provided for CPU-only / offline environments (``GRIDLOCK_NO_TRANSFORMER=1``) so the pipeline always runs. """ from __future__ import annotations import hashlib import re import numpy as np import pandas as pd from . import config as C _ANON_TOKEN_RE = re.compile(r"\[(?:PERSON|LOCATION|PHONE|EMAIL|ID)\]") _WS_RE = re.compile(r"\s+") def clean_text(series: pd.Series) -> pd.Series: """Strip anonymisation tokens and collapse whitespace.""" s = series.fillna("").astype(str) s = s.str.replace(_ANON_TOKEN_RE, " ", regex=True) s = s.str.replace(_WS_RE, " ", regex=True).str.strip() return s def _cache_key(texts: list[str]) -> str: h = hashlib.md5() h.update(C.EMBED_MODEL_NAME.encode()) h.update(str(len(texts)).encode()) h.update("".join(texts[:50]).encode("utf-8", "ignore")) return h.hexdigest()[:12] def _transformer_embeddings(texts: list[str]) -> np.ndarray: from sentence_transformers import SentenceTransformer model = SentenceTransformer(C.EMBED_MODEL_NAME) emb = model.encode( texts, batch_size=64, show_progress_bar=True, convert_to_numpy=True, normalize_embeddings=True, ) return emb.astype(np.float32) def _tfidf_embeddings(texts: list[str], n_components: int = 64) -> np.ndarray: """Offline fallback: char+word TF-IDF reduced with truncated SVD. Character n-grams make this robust to Kannada script and transliteration. """ from sklearn.decomposition import TruncatedSVD from sklearn.feature_extraction.text import TfidfVectorizer vec = TfidfVectorizer( analyzer="char_wb", ngram_range=(2, 4), min_df=3, max_features=20000 ) X = vec.fit_transform(texts) k = min(n_components, X.shape[1] - 1, max(2, X.shape[0] - 1)) svd = TruncatedSVD(n_components=k, random_state=C.RANDOM_STATE) return svd.fit_transform(X).astype(np.float32) def compute_embeddings(df: pd.DataFrame, use_cache: bool = True) -> np.ndarray: """Return a (n_rows, dim) raw embedding matrix, cached to disk by content.""" texts = clean_text(df[C.TEXT_COLUMN]).tolist() key = _cache_key(texts) cache_path = C.PROCESSED_DIR / f"text_embeddings_{key}.npy" if use_cache and cache_path.exists(): return np.load(cache_path) if C.USE_TRANSFORMER: try: emb = _transformer_embeddings(texts) except Exception as exc: # pragma: no cover - network/model failure print(f"[text_features] transformer unavailable ({exc}); using TF-IDF fallback") emb = _tfidf_embeddings(texts) else: emb = _tfidf_embeddings(texts) if use_cache: np.save(cache_path, emb) np.save(C.EMBED_CACHE, emb) return emb if __name__ == "__main__": # pragma: no cover d = pd.read_parquet(C.FEATURES_PARQUET if C.FEATURES_PARQUET.exists() else C.CLEAN_PARQUET) e = compute_embeddings(d) print("embeddings shape:", e.shape, "dtype:", e.dtype)