GamerQuant's picture
Update API tab with working Gradio runtime endpoints
94ea3aa
Raw
History Blame Contribute Delete
16.7 kB
"""
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)