Spaces:
Runtime error
Runtime error
| """ | |
| train.py β Wedding Decor Price Pipeline | |
| β’ Pulls training data from Supabase DB (or a local CSV as fallback) | |
| β’ Generates CLIP embeddings with parallel download + batched GPU/CPU inference | |
| β’ Builds stacking ensemble: XGBoost (CPU) + LightGBM + ExtraTrees + MLP β LogisticRegression | |
| β’ Saves artifacts to LOCAL_ARTIFACTS dir; app.py is responsible for uploading to Supabase Storage | |
| CPU optimisations (no accuracy loss): | |
| XGBoost device='cpu', tree_method='hist' | |
| CLIP freed from RAM before stacking trains | |
| All transformers persisted in a single transforms.joblib dict | |
| """ | |
| import os, io, json, re, warnings, argparse | |
| from pathlib import Path | |
| from concurrent.futures import ThreadPoolExecutor | |
| import numpy as np | |
| import pandas as pd | |
| import requests | |
| from PIL import Image | |
| import joblib | |
| import torch | |
| import open_clip | |
| from sklearn.preprocessing import LabelEncoder, StandardScaler, OneHotEncoder, MinMaxScaler | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.decomposition import PCA | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.ensemble import StackingClassifier, ExtraTreesClassifier | |
| from sklearn.neural_network import MLPClassifier | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import accuracy_score, classification_report | |
| from xgboost import XGBClassifier | |
| from lightgbm import LGBMClassifier | |
| warnings.filterwarnings('ignore', message='.*Falling back to prediction using DMatrix.*') | |
| warnings.filterwarnings('ignore', message='.*X does not have valid feature names.*') | |
| # ββ DEFAULTS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DEFAULT_ARTIFACTS = os.getenv("ARTIFACTS_DIR", "/tmp/artifacts") | |
| DEFAULT_CACHE = os.getenv("EMBEDDINGS_CACHE", "/tmp/embeddings_cache.json") | |
| MAX_ROWS = int(os.getenv("MAX_ROWS", 10000)) | |
| MAX_WORKERS = int(os.getenv("MAX_WORKERS", 8)) | |
| BATCH_SIZE = int(os.getenv("BATCH_SIZE", 32)) | |
| RANDOM_STATE = 42 | |
| PRICE_LOW, PRICE_HIGH = 1000, 500_000 | |
| FILTER_TERMS = ['verified_icon', 'location'] | |
| PRICE_BUDGET_MAX = 15_000 | |
| PRICE_MID_MAX = 80_000 | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # ββ HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def parse_price(text): | |
| if pd.isna(text) or text == '': return None | |
| text = str(text).lower().replace(',', '') | |
| if any(x in text for x in ['request', 'contact', 'onwards', 'call']): return None | |
| m = re.search(r'\d+', text) | |
| if m: | |
| val = float(m.group()) | |
| return val if PRICE_LOW <= val <= PRICE_HIGH else None | |
| return None | |
| def normalize_price_tier(tag): | |
| if pd.isna(tag) or str(tag).strip() == "": | |
| return None | |
| t = str(tag).strip().lower().replace("_", "-") | |
| if t in {"budget", "low", "economy"}: | |
| return "Budget" | |
| if t in {"mid", "mid-range", "midrange", "standard"}: | |
| return "Mid-Range" | |
| if t in {"premium", "high", "luxury"}: | |
| return "Premium" | |
| return None | |
| def tier_from_price(price): | |
| if pd.isna(price): | |
| return None | |
| p = float(price) | |
| if p < PRICE_LOW or p > PRICE_HIGH: | |
| return None | |
| if p < PRICE_BUDGET_MAX: | |
| return "Budget" | |
| if p < PRICE_MID_MAX: | |
| return "Mid-Range" | |
| return "Premium" | |
| def extract_domain(url): | |
| if not url or pd.isna(url): return 'unknown' | |
| m = re.search(r'https?://(?:www\.)?([^/]+)', str(url)) | |
| return m.group(1) if m else 'unknown' | |
| # ββ STEP 1: LOAD DATA βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_data(csv_path: str) -> pd.DataFrame: | |
| """Load from CSV (which was built from Supabase by app.py or provided directly).""" | |
| print(f"[train] Loading data from {csv_path}") | |
| df = pd.read_csv(csv_path) | |
| # Backward-compatible column normalization. | |
| if 'Name' not in df.columns: | |
| title = df.get('title', df.get('name', pd.Series('unknown', index=df.index))).fillna('').astype(str) | |
| desc = df.get('description', pd.Series('', index=df.index)).fillna('').astype(str) | |
| df['Name'] = np.where(title.str.strip() != '', title, desc.str.slice(0, 180)) | |
| if 'Description' not in df.columns: | |
| df['Description'] = df.get('description', pd.Series('', index=df.index)).fillna('').astype(str) | |
| if 'Seed Price' not in df.columns: | |
| df['Seed Price'] = df.get('price_text', df.get('seed_price', pd.Series('', index=df.index))).fillna('').astype(str) | |
| if 'Price INR' not in df.columns: | |
| df['Price INR'] = df.get('price_inr', pd.Series(np.nan, index=df.index)) | |
| if 'Price Range Tag' not in df.columns: | |
| df['Price Range Tag'] = df.get('price_range_tag', pd.Series('', index=df.index)).fillna('').astype(str) | |
| if 'Image URL' not in df.columns: | |
| df['Image URL'] = df.get('image_url', pd.Series('', index=df.index)).fillna('').astype(str) | |
| if 'Storage URL' not in df.columns: | |
| df['Storage URL'] = pd.Series('', index=df.index) | |
| if 'Source URL' not in df.columns: | |
| df['Source URL'] = df.get('source_url', pd.Series('', index=df.index)).fillna('').astype(str) | |
| if 'Source Domain' not in df.columns: | |
| df['Source Domain'] = df.get('source_domain', pd.Series('unknown', index=df.index)).fillna('unknown').astype(str) | |
| if 'row_id' not in df.columns: | |
| source_id = df.get('id', df.index) | |
| df['row_id'] = source_id.astype(str) | |
| df['Name'] = df['Name'].fillna('unknown').astype(str) | |
| name_and_desc = (df['Name'].fillna('') + ' ' + df.get('Description', pd.Series('', index=df.index)).fillna('')).str.lower() | |
| df = df[~name_and_desc.str.contains('|'.join(FILTER_TERMS), na=False)].copy() | |
| df['price_inr'] = pd.to_numeric(df['Price INR'], errors='coerce') | |
| missing_price = df['price_inr'].isna() | |
| df.loc[missing_price, 'price_inr'] = df.loc[missing_price, 'Seed Price'].apply(parse_price) | |
| df['price_tier_tagged'] = df['Price Range Tag'].apply(normalize_price_tier) | |
| # Keep rows that have either direct tier labels or parseable numeric prices. | |
| df = df[df['price_tier_tagged'].notna() | df['price_inr'].notna()].copy() | |
| df = df.head(MAX_ROWS).reset_index(drop=True) | |
| if len(df) == 0: | |
| raise ValueError("No usable rows after data cleaning. Need tagged tiers or parseable prices.") | |
| pmin = float(df['price_inr'].dropna().min()) if df['price_inr'].notna().any() else 0.0 | |
| pmax = float(df['price_inr'].dropna().max()) if df['price_inr'].notna().any() else 0.0 | |
| tagged_cnt = int(df['price_tier_tagged'].notna().sum()) | |
| print(f"[train] Clean rows: {len(df)} | tagged: {tagged_cnt} | βΉ{pmin:,.0f}ββΉ{pmax:,.0f}") | |
| return df | |
| # ββ STEP 2: CLIP EMBEDDINGS βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_clip(): | |
| print(f"[train] Loading CLIP on {device}β¦") | |
| m, _, p = open_clip.create_model_and_transforms("ViT-B-32", pretrained="laion2b_s34b_b79k") | |
| return m.to(device).eval(), p | |
| def _download(row): | |
| headers = {'User-Agent': 'Mozilla/5.0'} | |
| for url in [row.get('Image URL'), row.get('Storage URL'), row.get('Source URL')]: | |
| if not url or pd.isna(url): continue | |
| if not str(url).startswith(('http://', 'https://')): continue | |
| try: | |
| r = requests.get(url, timeout=10, headers=headers) | |
| r.raise_for_status() | |
| if 'text/html' in r.headers.get('Content-Type', ''): continue | |
| return row['row_id'], Image.open(io.BytesIO(r.content)).convert('RGB') | |
| except Exception: continue | |
| return row['row_id'], None | |
| def compute_embeddings(df: pd.DataFrame, clip_model, preprocess, cache_path: str) -> dict: | |
| embed_map = {} | |
| if os.path.exists(cache_path): | |
| with open(cache_path) as f: | |
| raw = json.load(f) | |
| embed_map = {k: np.array(v, dtype=np.float32) for k, v in raw.items() if k in df['row_id'].values} | |
| print(f"[train] Cache hit: {len(embed_map)} embeddings.") | |
| to_do = df[~df['row_id'].isin(embed_map.keys())].to_dict('records') | |
| if not to_do: | |
| return embed_map | |
| print(f"[train] Downloading {len(to_do)} imagesβ¦") | |
| with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex: | |
| results = list(ex.map(_download, to_do)) | |
| valid = [(rid, img) for rid, img in results if img is not None] | |
| print(f"[train] Encoding {len(valid)} imagesβ¦") | |
| for i in range(0, len(valid), BATCH_SIZE): | |
| batch = valid[i:i+BATCH_SIZE] | |
| rids = [b[0] for b in batch] | |
| imgs = [b[1] for b in batch] | |
| t = torch.stack([preprocess(img) for img in imgs]).to(device) | |
| with torch.no_grad(): | |
| embs = clip_model.encode_image(t) | |
| embs = embs / embs.norm(dim=-1, keepdim=True) | |
| embs = embs.cpu().float().numpy() | |
| for rid, emb in zip(rids, embs): | |
| embed_map[rid] = emb | |
| with open(cache_path, 'w') as f: | |
| json.dump({k: v.tolist() for k, v in embed_map.items()}, f) | |
| print(f"[train] Cache saved β {cache_path}") | |
| return embed_map | |
| # ββ STEP 3: FEATURE ENGINEERING βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_features(df_ready, embed_map, fit=True, saved=None): | |
| T = saved or {} | |
| # A) CLIP β StandardScaler β PCA(95%) | |
| X_clip = np.vstack([embed_map[str(i)] for i in df_ready['row_id']]) | |
| if fit: | |
| cs = StandardScaler(); X_cs = cs.fit_transform(X_clip) | |
| pc = PCA(n_components=0.95, random_state=RANDOM_STATE); X_pca = pc.fit_transform(X_cs) | |
| T['clip_scaler'] = cs; T['pca'] = pc | |
| else: | |
| X_pca = T['pca'].transform(T['clip_scaler'].transform(X_clip)) | |
| # B) TF-IDF (250 features, 1-2 gram) | |
| names = df_ready['Name'].fillna('unknown') | |
| if fit: | |
| tf = TfidfVectorizer(max_features=250, stop_words='english', ngram_range=(1,2)) | |
| X_text = tf.fit_transform(names).toarray(); T['tfidf'] = tf | |
| else: | |
| X_text = T['tfidf'].transform(names).toarray() | |
| # C) Meta-features | |
| df_ready = df_ready.copy() | |
| df_ready['name_length'] = df_ready['Name'].astype(str).apply(len) | |
| df_ready['word_count'] = df_ready['Name'].astype(str).apply(lambda x: len(x.split())) | |
| df_ready['avg_word_length'] = df_ready['name_length'] / (df_ready['word_count'] + 1e-5) | |
| df_ready['caps_ratio'] = df_ready['Name'].astype(str).apply(lambda x: sum(1 for c in x if c.isupper()) / (len(x)+1e-5)) | |
| mc = ['name_length','word_count','avg_word_length','caps_ratio'] | |
| if fit: | |
| ms = MinMaxScaler(); X_meta = ms.fit_transform(df_ready[mc]); T['meta_scaler'] = ms | |
| else: | |
| X_meta = T['meta_scaler'].transform(df_ready[mc]) | |
| # D) Domain OHE | |
| df_ready['Domain_Source'] = df_ready.get('Source Domain', pd.Series('unknown', index=df_ready.index)).fillna('unknown') | |
| df_ready['Domain_URL'] = df_ready.get('Source URL', pd.Series('unknown', index=df_ready.index)).apply(extract_domain) | |
| if fit: | |
| ohe = OneHotEncoder(sparse_output=False, handle_unknown='ignore') | |
| X_dom = ohe.fit_transform(df_ready[['Domain_Source','Domain_URL']]); T['ohe'] = ohe | |
| else: | |
| X_dom = T['ohe'].transform(df_ready[['Domain_Source','Domain_URL']]) | |
| return np.hstack([X_pca, X_text, X_meta, X_dom]), T | |
| # ββ STEP 4: STACKING CLASSIFIER βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_stacking(): | |
| return StackingClassifier( | |
| estimators=[ | |
| ('xgb', XGBClassifier(n_estimators=600, learning_rate=0.03, max_depth=6, | |
| subsample=0.8, colsample_bytree=0.7, | |
| tree_method='hist', device='cpu', # CPU for HF free tier | |
| random_state=RANDOM_STATE, eval_metric='mlogloss')), | |
| ('lgbm', LGBMClassifier(n_estimators=600, learning_rate=0.03, num_leaves=31, | |
| subsample=0.8, colsample_bytree=0.7, | |
| random_state=RANDOM_STATE, verbose=-1)), | |
| ('et', ExtraTreesClassifier(n_estimators=500, max_depth=15, | |
| max_features='sqrt', n_jobs=-1, random_state=RANDOM_STATE)), | |
| ('mlp', MLPClassifier(hidden_layer_sizes=(128,64), activation='relu', solver='adam', | |
| alpha=0.01, max_iter=300, early_stopping=True, random_state=RANDOM_STATE)), | |
| ], | |
| final_estimator=LogisticRegression(max_iter=1000), | |
| cv=5, n_jobs=1, passthrough=False, | |
| ) | |
| # ββ STEP 5: MAIN ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def train(csv_path: str, artifacts_dir: str = DEFAULT_ARTIFACTS, | |
| cache_path: str = DEFAULT_CACHE) -> dict: | |
| out = Path(artifacts_dir) | |
| out.mkdir(parents=True, exist_ok=True) | |
| # 1. Load data | |
| df = load_data(csv_path) | |
| # 2. CLIP embeddings | |
| clip_model, preprocess = load_clip() | |
| embed_map = compute_embeddings(df, clip_model, preprocess, cache_path) | |
| df_ready = df[df['row_id'].isin(embed_map.keys())].copy() | |
| print(f"[train] Rows with embeddings: {len(df_ready)}") | |
| # Prepare target labels before feature building so X and y always align. | |
| df_ready['price_tier'] = df_ready['price_tier_tagged'] | |
| fill_mask = df_ready['price_tier'].isna() | |
| df_ready.loc[fill_mask, 'price_tier'] = df_ready.loc[fill_mask, 'price_inr'].apply(tier_from_price) | |
| df_ready = df_ready.dropna(subset=['price_tier']).copy() | |
| # Free CLIP memory before ensemble training | |
| del clip_model | |
| if device == 'cuda': torch.cuda.empty_cache() | |
| # 3. Features | |
| X, transforms = build_features(df_ready, embed_map, fit=True) | |
| print(f"[train] Feature matrix: {X.shape}") | |
| # 4. Target | |
| if len(df_ready) < 30: | |
| raise ValueError(f"Only {len(df_ready)} rows with embeddings and target labels. Need at least 30.") | |
| class_counts = df_ready['price_tier'].value_counts() | |
| if class_counts.shape[0] < 2: | |
| raise ValueError("Need at least 2 price tiers in training data.") | |
| le = LabelEncoder() | |
| y = le.fit_transform(df_ready['price_tier']) | |
| use_stratify = class_counts.min() >= 2 | |
| X_train, X_test, y_train, y_test = train_test_split( | |
| X, y, test_size=0.2, random_state=RANDOM_STATE, stratify=y if use_stratify else None | |
| ) | |
| # 5. Train | |
| print("[train] Fitting stacking ensemble (5-fold CV) β 5β15 min on CPUβ¦") | |
| clf = build_stacking() | |
| clf.fit(X_train, y_train) | |
| # 6. Evaluate | |
| y_pred = clf.predict(X_test) | |
| acc = accuracy_score(y_test, y_pred) | |
| report_dict = classification_report(y_test, y_pred, target_names=le.classes_, output_dict=True, zero_division=0) | |
| print(f"[train] β Accuracy: {acc:.2%}") | |
| print(classification_report(y_test, y_pred, target_names=le.classes_, zero_division=0)) | |
| # 7. Save β only 3 files needed by app.py | |
| joblib.dump(clf, out / "model.joblib", compress=3) | |
| joblib.dump(transforms, out / "transforms.joblib", compress=3) | |
| joblib.dump(le, out / "label_encoder.joblib", compress=3) | |
| print(f"[train] Saved artifacts to {out}") | |
| return { | |
| "accuracy": float(acc), | |
| "precision": float(report_dict["macro avg"]["precision"]), | |
| "recall": float(report_dict["macro avg"]["recall"]), | |
| "f1": float(report_dict["macro avg"]["f1-score"]), | |
| "support": int(report_dict["macro avg"]["support"]), | |
| "training_image_count": int(len(df_ready)), | |
| } | |
| # ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--csv", default=os.getenv("CSV_PATH", "decor_multisite.csv")) | |
| ap.add_argument("--out", default=DEFAULT_ARTIFACTS) | |
| ap.add_argument("--cache", default=DEFAULT_CACHE) | |
| args = ap.parse_args() | |
| train(args.csv, args.out, args.cache) | |