CMacD/AIC_PHASE1_POC
0
1#!/usr/bin/env python2# coding: utf-83"""4ML classifier — TF-IDF text classifier (Phase 1, Step 2b).5 6For each TEXT-type attribute this builds a TF-IDF feature matrix from the7historical key-column text (word bigrams) and trains a classifier to predict the8most likely label for each new product. Where BM25 ranks by keyword relevance,9the classifier learns patterns; Ensemble fuses the two.10 11Classifier backend12------------------13As of 2026-06-06 the backend is **LinearSVC**. A head-to-head on this project's14sparse TF-IDF features showed it matching or beating the previous XGBoost backend15on accuracy at ~2.5x less compute (linear models are the canonical strong choice16for high-dimensional sparse text; gradient-boosted trees are in their weakest17regime there). The retired XGBoost configuration is preserved, commented, below18(`_XGB_PARAMS`) for easy revival. Output is keyed/tagged 'ML' and consumed19unchanged by Ensemble.20 21What it does22------------23 1. For each TEXT-type attribute, builds a TF-IDF feature matrix from the24 historical key column text. Word bigrams are used (not just single words)25 because compound terms like "dark chocolate" or "reduced fat" carry more26 signal than either word alone.27 28 2. Optionally augments the training data by creating masked copies of each29 row where a random subset of key columns is replaced with "missing". This30 teaches the model to make predictions even when some columns are blank,31 which is common for genuinely new products in the flat file.32 33 3. Trains the classifier on the augmented features and predicts the top label34 plus confidence score for each new product.35 36 4. Results are grouped by unique key combination so the output is one row per37 distinct product type rather than one row per individual product.38 39Results are stored in the returned dict as 'ML_{attrG}' and combined with40the BM25 predictions in Ensemble.41 42Code style43----------44Functions are written to be read straight through. Steps are broken into named45variables rather than chained. If something is not immediately obvious from the46code it has a comment. Keep it that way.47"""48 49import re50import warnings51from concurrent.futures import ThreadPoolExecutor52 53import numpy as np54import pandas as pd55import nltk56from nltk.corpus import stopwords57from sklearn.feature_extraction.text import TfidfVectorizer58from sklearn import preprocessing59from sklearn.svm import LinearSVC60from sklearn.calibration import CalibratedClassifierCV61# import xgboost as xgb # retired in favour of LinearSVC — see _make_classifier()62# and the preserved _XGB_PARAMS block below.63 64from ml_package import routing65 66warnings.filterwarnings('ignore')67nltk.download('punkt', quiet=True)68nltk.download('punkt_tab', quiet=True)69nltk.download('stopwords', quiet=True)70 71 72# ── Auto-NUMERIC detection (mirrors Ensemble._infer_attr_type) ────────────────73_NUMERIC_RE = re.compile(r'^\s*[\d#][\d\s\.\-\/]*', re.IGNORECASE)74_NUMERIC_THRESHOLD = 0.6075_NULL_STRS = {'', 'nan', 'none', 'missing', 'null', 'na', 'null value'}76 77# ── Stopwords ─────────────────────────────────────────────────────────────────78# 'no' and 'to' are kept (removed from NLTK stopwords) because they carry79# product-attribute signal in this domain.80# NOTE: 'chocolate', 'original', etc. intentionally excluded from removals —81# these ARE discriminating features for product attribute classification.82_DOMAIN_STOPWORDS = [83 'ss', 'unknown', 'undefined', 'company', 'category', 'llc', 'inc', 'ltd',84 'to', 'oz', 'lt', 'ct', '', 'value', 'not', 'available', 'key', 'label',85 'may', 'great', 'from', 'of', 'for', 'null', 'nav', 'card', 'mix', 'nut',86 '&', 'kit', 'sauce', 'dish', 'cup', 'bx', 'envlp', 'can', 'bag', 'btl',87 'rfg', 'cnstr', 'unf', '*', '+', '/', 'abc', 'a', '-',88]89_NOT_STOPWORDS = {'no', 'to'}90_STOPWORDS = frozenset(91 (set(stopwords.words('english')) | set(_DOMAIN_STOPWORDS)) - _NOT_STOPWORDS92)93 94# ── Training-data augmentation ────────────────────────────────────────────────95# For each training row, N masked copies are created where a random subset of96# key columns is set to 'missing' while one randomly-chosen anchor column is97# always kept. Teaches the classifier to predict from partial key-column98# combinations — useful when new flat-file products arrive with some columns blank.99_AUG_N_COPIES = 3 # total training rows become (1 + N) × original100_aug_rng = np.random.default_rng(42)101 102# ── Model configuration ───────────────────────────────────────────────────────103# Word n-grams (1-2): bigrams capture compound product terms like104# "dark chocolate" vs "milk chocolate".105# min_df=1 keeps rare tokens — product catalogs are small, so rare terms106# are still signal. sublinear_tf dampens frequency dominance of common tokens.107_tfidf = TfidfVectorizer(ngram_range=(1, 2), min_df=1, sublinear_tf=True, analyzer='word')108 109# Number of candidate labels the classifier returns per product — mirrors _BM25_TOP_K.110_ML_TOP_K = 3111 112# Per-attribute routing — whether the classifier runs at all — lives in113# ml_package.routing and is applied in _process_one_xgb_attr below. It skips114# identity/derived composites (openness ~ 1, e.g. Franchise_Packtype_RPTG ~2,795)115# while keeping genuine classifications (e.g. Tool_Franchise_TH ~263), and116# replaces the earlier blunt class-count cap. See routing.skip_learned_methods().117 118 119def _make_classifier():120 """121 Build a fresh classifier for the learnable TEXT tier (one per attribute so122 parallel calls don't share state).123 124 Backend: LinearSVC — a 2026-06-06 head-to-head on this project's sparse125 TF-IDF features showed it matching/beating XGBoost on accuracy at ~2.5x less126 compute. LogReg (one-vs-rest) is a faster, slightly-less-accurate alternative.127 """128 return LinearSVC(max_iter=5000, random_state=42)129 # Speed-first alternative (add: from sklearn.linear_model import LogisticRegression130 # and from sklearn.multiclass import OneVsRestClassifier):131 # return OneVsRestClassifier(LogisticRegression(solver="liblinear", max_iter=1000))132 133 134def _ml_scores(model, features):135 """136 Return an [n_samples, n_classes] confidence matrix aligned to model.classes_.137 138 Uses predict_proba when the estimator provides it (LogReg / the old XGB);139 otherwise softmaxes LinearSVC's decision_function margins into 0..1. Either140 way Ensemble's contract holds — it reads the per-row `score` as a 0..1141 confidence (score / 100) regardless of which classifier produced it.142 """143 if hasattr(model, "predict_proba"):144 return model.predict_proba(features)145 margins = model.decision_function(features)146 if margins.ndim == 1: # binary problem → two columns147 margins = np.column_stack([-margins, margins])148 margins = margins - margins.max(axis=1, keepdims=True)149 exp = np.exp(margins)150 return exp / exp.sum(axis=1, keepdims=True)151 152 153# ── Preserved for reference: the retired XGBoost backend ──────────────────────154# Replaced by LinearSVC (see _make_classifier) on 2026-06-06. To revive:155# uncomment `import xgboost as xgb` above, uncomment this block, and return156# `xgb.XGBClassifier(**_XGB_PARAMS)` from _make_classifier().157# n_estimators=150 + learning_rate=0.1: more trees, smaller steps.158# subsample=0.8 / colsample_bytree=0.4: row / column subsampling for sparse TF-IDF.159# tree_method='hist': faster histogram splits. n_jobs=2.160# _XGB_PARAMS = dict(161# n_estimators=150,162# learning_rate=0.1,163# max_depth=6,164# subsample=0.8,165# colsample_bytree=0.4,166# min_child_weight=1,167# tree_method='hist',168# n_jobs=2,169# random_state=42,170# verbosity=0,171# )172 173 174def _calibration_split(y, frac_cal: float = 0.25, seed: int = 42):175 """176 Held-out split for Platt calibration that never drops a class.177 178 Every class keeps >=1 sample in the fit set (so the base estimator learns all179 of them — important because the minority classes here are genuinely rare);180 classes with >=2 samples also contribute ~frac_cal of their rows to the181 calibration set. Returns (fit_idx, cal_idx) as int arrays.182 """183 rng = np.random.default_rng(seed)184 y = np.asarray(y)185 fit_idx, cal_idx = [], []186 for c in np.unique(y):187 idx = np.where(y == c)[0]188 rng.shuffle(idx)189 if len(idx) < 2:190 fit_idx.extend(idx.tolist()) # lone sample stays in fit191 continue192 n_cal = min(len(idx) - 1, max(1, int(round(len(idx) * frac_cal))))193 cal_idx.extend(idx[:n_cal].tolist())194 fit_idx.extend(idx[n_cal:].tolist())195 return np.array(fit_idx, dtype=int), np.array(cal_idx, dtype=int)196 197 198def _fit_calibrated(train_features, y_encoded):199 """200 Fit LinearSVC and Platt-calibrate its confidence onto a proper probability201 scale. A bare softmax over hundreds of classes yields a ~0.07 top-1 even202 when the model is right, which drowns ML out of the BM25+ML score fusion and203 floods QC with false MEDIUM flags. Sigmoid (Platt) calibration on a held-out204 split maps the SVM margins to calibrated probabilities; predict_proba then205 feeds Ensemble unchanged via _ml_scores.206 207 Robust to genuinely-rare classes: _calibration_split keeps every class in the208 fit set, and we fall back to the uncalibrated LinearSVC if calibration can't209 be fit (too few rows, or only one class in the calibration split).210 """211 fit_i, cal_i = _calibration_split(y_encoded)212 if cal_i.size and len(np.unique(y_encoded[cal_i])) >= 2:213 try:214 base = _make_classifier()215 base.fit(train_features[fit_i], y_encoded[fit_i])216 # Calibrate the prefit base. sklearn >= 1.6 removed cv='prefit' in217 # favour of FrozenEstimator; older sklearn still uses cv='prefit'.218 # Try the modern path first, fall back for < 1.6.219 try:220 from sklearn.frozen import FrozenEstimator # sklearn >= 1.6221 calibrated = CalibratedClassifierCV(FrozenEstimator(base), method='sigmoid')222 except ImportError:223 calibrated = CalibratedClassifierCV(base, method='sigmoid', cv='prefit')224 calibrated.fit(train_features[cal_i], y_encoded[cal_i])225 return calibrated226 except Exception: # noqa: BLE001 — degrade gracefully227 # Calibration is a best-effort confidence refinement. A genuine228 # failure is worth a brief note (helps diagnose) but not the full229 # traceback — fall through to the uncalibrated classifier.230 print(" ML confidence calibration unavailable — using raw scores")231 clf = _make_classifier()232 clf.fit(train_features, y_encoded)233 return clf234 235 236def _is_numeric_attr(attr_key_cols: list, data_df: pd.DataFrame) -> bool:237 """Return True if >60% of unique non-null key-column values are numeric/range."""238 for col in attr_key_cols:239 if col not in data_df.columns:240 continue241 vals = (242 data_df[col].dropna().astype(str).str.strip()243 .pipe(lambda s: s[~s.str.lower().isin(_NULL_STRS)])244 .unique()245 )246 if len(vals) == 0:247 continue248 numeric_match_count = sum(bool(_NUMERIC_RE.match(v)) for v in vals)249 numeric_fraction = numeric_match_count / len(vals)250 if numeric_fraction >= _NUMERIC_THRESHOLD:251 return True252 return False253 254 255def _augment_training(df: pd.DataFrame, attr_key_cols: list) -> pd.DataFrame:256 """257 Return df plus _AUG_N_COPIES masked copies for training-data augmentation.258 259 Each copy masks a random subset of key columns to 'missing' (p=0.5 per260 column per row), while one randomly-chosen anchor column per row is always261 preserved. No-op when attr_key_cols has only one column.262 """263 available_cols = [c for c in attr_key_cols if c in df.columns]264 if len(available_cols) < 2:265 return df266 267 copies = [df]268 for _ in range(_AUG_N_COPIES):269 aug = df.copy()270 anchor_col_index = _aug_rng.integers(0, len(available_cols), size=len(aug))271 for col_index, col in enumerate(available_cols):272 mask = (_aug_rng.random(len(aug)) < 0.5) & (anchor_col_index != col_index)273 aug.loc[mask, col] = 'missing'274 copies.append(aug)275 return pd.concat(copies, ignore_index=True)276 277 278def _remove_duplicate_words(text: str) -> str:279 """Remove duplicate words from a string while preserving order."""280 words = text.split()281 return ' '.join(dict.fromkeys(words))282 283 284def _build_features(train_df: pd.DataFrame, test_df: pd.DataFrame):285 """286 Concatenate all key columns into a single feature string per row,287 apply stopword removal and deduplication, and fit-transform TF-IDF288 on training data / transform test data.289 290 Returns (train_df_with_ftr, test_df_with_ftr).291 """292 train_df['ftr'] = train_df.iloc[:, :-1].astype(str).apply(' '.join, axis=1)293 test_df['ftr'] = test_df.astype(str).apply(' '.join, axis=1)294 295 def _clean(text_series):296 return (297 text_series.str.lower().astype(str)298 .apply(_remove_duplicate_words)299 .apply(lambda x: ' '.join(w for w in x.split() if w not in _STOPWORDS))300 )301 302 train_df['ftr'] = _clean(train_df['ftr'])303 test_df['ftr'] = _clean(test_df['ftr'])304 return train_df, test_df305 306 307def _process_one_xgb_attr(mdm_col: str, meta_df: pd.DataFrame,308 history_df: pd.DataFrame,309 flat_file_df: pd.DataFrame):310 """311 Train and predict for a single attribute. Returns (key, DataFrame) or None.312 Creates fresh TF-IDF and classifier instances so parallel calls don't share state.313 """314 attr_key_cols = list(meta_df.loc[meta_df['Attribute Group name'] == mdm_col,315 'Attribute Name in MDM'])316 317 meta_type_vals = (318 meta_df.loc[meta_df['Attribute Group name'] == mdm_col, 'Type']319 .dropna().astype(str).str.strip().str.upper()320 .pipe(lambda s: s[~s.isin({'', 'NAN', 'NONE', 'NA'})])321 )322 if len(meta_type_vals):323 type_val = meta_type_vals.iloc[0]324 is_vocab = type_val in ('VOCAB', 'DERIVED', 'CATEGORICAL')325 type_label = 'short fixed-list' if is_vocab else 'numeric/range'326 print(f" ML {mdm_col}: skipped — analyst-marked as {type_label}")327 return None328 if _is_numeric_attr(attr_key_cols, history_df):329 print(f" ML {mdm_col}: skipped — auto-detected as numeric/range")330 return None331 332 try:333 # Routing: skip the classifier for identity/derived composites (openness334 # ~ 1) and pathological label spaces — Lookup carries them instead.335 skip, _ = routing.skip_learned_methods(history_df, attr_key_cols, mdm_col)336 if skip:337 print(f" ML {mdm_col}: skipped — resolved by lookup (no modelling needed)")338 return None339 340 train_df = (341 history_df[attr_key_cols + [mdm_col]]342 .replace(r'(OZ|LB)', ' ', regex=True)343 .dropna(subset=[mdm_col])344 )345 train_df.fillna('missing', inplace=True)346 # "NULL VALUE" is a Circana sentinel for unfilled cells. Both tokens347 # are stopwords, so the feature string collapses to "" and triggers348 # library warnings. Normalise to 'missing' before feature building.349 train_df.replace(re.compile(r'^\s*null\s+value\s*$', re.IGNORECASE),350 'missing', inplace=True)351 352 train_df = _augment_training(train_df, attr_key_cols)353 354 test_df = (355 flat_file_df[attr_key_cols]356 .replace(r'(OZ|LB)', ' ', regex=True)357 .copy()358 )359 if test_df.empty:360 return None361 test_df.fillna('missing', inplace=True)362 test_df.replace(re.compile(r'^\s*null\s+value\s*$', re.IGNORECASE),363 'missing', inplace=True)364 365 train_df, test_df = _build_features(train_df, test_df)366 367 # Fresh TF-IDF per attribute — required for parallel safety.368 tfidf = TfidfVectorizer(ngram_range=(1, 2), min_df=1, sublinear_tf=True, analyzer='word')369 370 train_features = tfidf.fit_transform(train_df['ftr'])371 test_features = tfidf.transform(test_df['ftr'])372 373 label_encoder = preprocessing.LabelEncoder()374 label_encoder.fit(train_df[mdm_col])375 print(f" ML {mdm_col}: {len(test_df)} products | {len(label_encoder.classes_)} known values")376 377 y_encoded = label_encoder.transform(train_df[mdm_col])378 clf = _fit_calibrated(train_features, y_encoded)379 380 class_labels = label_encoder.inverse_transform(clf.classes_)381 382 # Calibrated confidence matrix [n_products, n_classes] — Platt-scaled383 # predict_proba (see _fit_calibrated); _ml_scores reads it unchanged.384 proba_scores = _ml_scores(clf, test_features)385 386 # Top-K candidates — mirrors BM25 so Ensemble can fuse score distributions387 # from both methods rather than comparing only single top picks.388 k = min(_ML_TOP_K, proba_scores.shape[1])389 top_indices = np.argsort(-proba_scores, axis=1)[:, :k]390 top_scores = np.take_along_axis(proba_scores, top_indices, axis=1)391 top_labels = class_labels[top_indices]392 393 # Expand test_df to k rows per product, one per candidate label.394 expanded = test_df.loc[test_df.index.repeat(k)].reset_index(drop=True)395 expanded[mdm_col] = top_labels.ravel()396 expanded['score'] = (top_scores.ravel() * 100).round(2)397 398 grouped = (399 expanded.groupby(attr_key_cols + [mdm_col, 'score'])['ftr']400 .count()401 )402 grouped = grouped.reset_index(name='Record')403 grouped = grouped.replace('MISSING', '', regex=True)404 grouped['method'] = 'ML'405 return (f'ML_{mdm_col}', grouped)406 407 except Exception as exc:408 print(f" ERROR: ML failed for '{mdm_col}': {exc}")409 return None410 411 412def runML(flat_file_df: pd.DataFrame, meta_df: pd.DataFrame,413 history_df: pd.DataFrame) -> dict:414 """415 Train and run the TF-IDF classifier for all eligible attributes in parallel.416 417 Parameters418 ----------419 flat_file_df : DataFrame420 New products to classify (the flat-file CSV, string-coerced).421 meta_df : DataFrame422 META sheet — defines attribute groups, key columns, and type flags.423 history_df : DataFrame424 Historical FINAL data used as the labelled training set.425 426 Returns427 -------428 dict mapping 'ML_{attrG}' → prediction DataFrame with columns:429 attr_key_cols + [attrG, score, Record, method].430 431 Attributes are processed in parallel across 4 workers; the LinearSVC backend432 is single-threaded per fit, so 4 attributes train concurrently.433 """434 attrs = meta_df['Attribute Group name'].unique().tolist()435 ml_results = {}436 437 with ThreadPoolExecutor(max_workers=4) as executor:438 futures = [439 executor.submit(_process_one_xgb_attr, col, meta_df, history_df, flat_file_df)440 for col in attrs441 ]442 for future in futures:443 result = future.result()444 if result is not None:445 key, df = result446 ml_results[key] = df447 448 return ml_results449 