prabalGaur commited on
Commit
4a86259
·
verified ·
1 Parent(s): 740230a

Upload community_contributions/CynthiaOmovoiye/pricing_pipeline.py with huggingface_hub

Browse files
community_contributions/CynthiaOmovoiye/pricing_pipeline.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pricing_pipeline.py – Week 8 price-attack pipeline
2
+ # Data build, retrieval, and evaluation helpers for The Price is Right.
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Optional
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ # Optional deps: fail loudly only when the feature is used
16
+ def _require(module: str):
17
+ try:
18
+ return __import__(module)
19
+ except ImportError as e:
20
+ raise ImportError(f"Install {module} for this feature. {e}") from e
21
+
22
+
23
+ # --- Constants (match course) ---
24
+ CATEGORY_NAMES = [
25
+ "Automotive",
26
+ "Electronics",
27
+ "Office_Products",
28
+ "Tools_and_Home_Improvement",
29
+ "Cell_Phones_and_Accessories",
30
+ "Toys_and_Games",
31
+ "Appliances",
32
+ "Musical_Instruments",
33
+ ]
34
+
35
+ # Configurable price bands (as in spec)
36
+ DEFAULT_PRICE_BINS = [0, 25, 50, 100, 200, 350, 500, 1000]
37
+ MIN_PRICE = 0.50
38
+ MAX_PRICE = 999.49
39
+ MIN_CHARS = 600
40
+ MAX_TEXT_EACH = 3000
41
+ MAX_TEXT_TOTAL = 4000
42
+ REMOVALS = [
43
+ "Part Number",
44
+ "Best Sellers Rank",
45
+ "Batteries Included?",
46
+ "Batteries Required?",
47
+ "Item model number",
48
+ ]
49
+ RANDOM_STATE = 42
50
+ EVAL_SIZE = 200
51
+
52
+
53
+ def _norm(s: Optional[str]) -> str:
54
+ return re.sub(r"\s+", " ", (s or "").lower().strip())
55
+
56
+
57
+ def get_weight(details: dict) -> float:
58
+ w = details.get("Item Weight") or details.get("item weight")
59
+ if not w:
60
+ return 0.0
61
+ parts = str(w).split()
62
+ if len(parts) < 2:
63
+ return 0.0
64
+ try:
65
+ amt = float(parts[0])
66
+ except ValueError:
67
+ return 0.0
68
+ u = parts[1].lower()
69
+ if u == "pounds":
70
+ return amt
71
+ if u == "ounces":
72
+ return amt / 16
73
+ if u == "grams":
74
+ return amt / 453.592
75
+ if u == "kilograms":
76
+ return amt * 2.20462
77
+ return 0.0
78
+
79
+
80
+ def get_brand(details: dict) -> str:
81
+ if not details:
82
+ return ""
83
+ b = details.get("Brand") or details.get("brand") or details.get("Manufacturer") or details.get("manufacturer")
84
+ return (b or "").strip()[:200]
85
+
86
+
87
+ def simplify(t: Any) -> str:
88
+ """Convert to single-line text; flatten lists/dicts to readable form (no Python repr)."""
89
+ if t is None:
90
+ return ""
91
+ if isinstance(t, list):
92
+ parts = [simplify(x) for x in t if x is not None]
93
+ s = " ".join(p for p in parts if p)
94
+ elif isinstance(t, dict):
95
+ parts = [f"{k}: {simplify(v)}" for k, v in t.items() if v is not None]
96
+ s = " ".join(parts)
97
+ else:
98
+ s = str(t)
99
+ s = s.replace("\n", " ").replace("\r", "").replace("\t", "").replace(" ", " ").strip()
100
+ return s[:MAX_TEXT_EACH]
101
+
102
+
103
+ def scrub(title: str, description: Any, features: Any, details: dict) -> str:
104
+ details = dict(details or {})
105
+ for r in REMOVALS:
106
+ details.pop(r, None)
107
+ out = (title or "") + "\n"
108
+ if description:
109
+ out += simplify(description) + "\n"
110
+ if features:
111
+ out += simplify(features) + "\n"
112
+ if details:
113
+ out += json.dumps(details) + "\n"
114
+ out = re.sub(
115
+ r"\b(?=[A-Z0-9]{7,}\b)(?=.*[A-Z])(?=.*\d)[A-Z0-9]+\b",
116
+ "",
117
+ out,
118
+ ).strip()[:MAX_TEXT_TOTAL]
119
+ return out
120
+
121
+
122
+ def parse_raw_row(row: dict, category: str, item_cls: type) -> Optional[Any]:
123
+ """Parse one row from Amazon-Reviews-2023 raw_meta_* into Item (or item_cls)."""
124
+ try:
125
+ price = float(row["price"])
126
+ except (ValueError, TypeError):
127
+ return None
128
+ if not (MIN_PRICE <= price <= MAX_PRICE):
129
+ return None
130
+ title = row.get("title") or ""
131
+ description = row.get("description")
132
+ features = row.get("features")
133
+ raw_details = row.get("details")
134
+ if isinstance(raw_details, str):
135
+ try:
136
+ details = json.loads(raw_details)
137
+ except Exception:
138
+ details = {}
139
+ else:
140
+ details = raw_details or {}
141
+ weight = get_weight(details)
142
+ brand = get_brand(details)
143
+ full = scrub(title, description, features, details)
144
+ if brand:
145
+ full = f"Brand: {brand}\n{full}"
146
+ if len(full) < MIN_CHARS:
147
+ return None
148
+ # Item has: title, category, price, full, weight, summary (optional)
149
+ return item_cls(
150
+ title=title,
151
+ category=category,
152
+ price=price,
153
+ full=full,
154
+ weight=weight if weight else None,
155
+ summary=full[:2000],
156
+ )
157
+
158
+
159
+ def dedup_key(item: Any) -> str:
160
+ title = getattr(item, "title", "") or ""
161
+ full = getattr(item, "full", None) or getattr(item, "summary", None) or ""
162
+ return _norm(title) + " | " + _norm(full)
163
+
164
+
165
+ def price_band(price: float, bins: list[float]) -> tuple[float, float]:
166
+ for i in range(len(bins) - 1):
167
+ if bins[i] <= price < bins[i + 1]:
168
+ return (bins[i], bins[i + 1])
169
+ return (bins[-2], bins[-1])
170
+
171
+
172
+ def build_support_from_raw(
173
+ load_dataset_fn: Callable,
174
+ item_cls: type,
175
+ frozen_eval_keys: set[str],
176
+ price_bins: Optional[list[float]] = None,
177
+ target_per_bucket: int = 400,
178
+ cache_path: Optional[Path] = None,
179
+ ) -> list[Any]:
180
+ """
181
+ Load 8 categories from Amazon-Reviews-2023, parse, dedupe, exclude eval overlap,
182
+ stratified sample by (category, price_band). Returns list of item_cls instances.
183
+ """
184
+ if price_bins is None:
185
+ price_bins = DEFAULT_PRICE_BINS
186
+
187
+ if cache_path and cache_path.exists():
188
+ with open(cache_path) as f:
189
+ data = json.load(f)
190
+ return [item_cls.model_validate(d) for d in data]
191
+
192
+ raw_items = []
193
+ for cat in CATEGORY_NAMES:
194
+ try:
195
+ ds = load_dataset_fn("McAuley-Lab/Amazon-Reviews-2023", f"raw_meta_{cat}", "full")
196
+ except Exception as e:
197
+ raise RuntimeError(f"Failed to load raw_meta_{cat}: {e}") from e
198
+ for row in ds:
199
+ it = parse_raw_row(row, cat, item_cls)
200
+ if it is not None:
201
+ raw_items.append(it)
202
+
203
+ seen = set()
204
+ deduped = []
205
+ for it in raw_items:
206
+ k = dedup_key(it)
207
+ if k in frozen_eval_keys or k in seen:
208
+ continue
209
+ seen.add(k)
210
+ deduped.append(it)
211
+
212
+ rng = np.random.default_rng(RANDOM_STATE)
213
+ buckets = {}
214
+ for it in deduped:
215
+ band = price_band(it.price, price_bins)
216
+ key = (it.category, band)
217
+ buckets.setdefault(key, []).append(it)
218
+
219
+ support_items = []
220
+ for key, lst in buckets.items():
221
+ if len(lst) <= target_per_bucket:
222
+ support_items.extend(lst)
223
+ else:
224
+ idx = rng.choice(len(lst), size=target_per_bucket, replace=False)
225
+ support_items.extend([lst[i] for i in idx])
226
+
227
+ rng.shuffle(support_items)
228
+
229
+ if cache_path:
230
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
231
+ with open(cache_path, "w") as f:
232
+ json.dump([it.model_dump() for it in support_items], f, indent=0)
233
+
234
+ return support_items
235
+
236
+
237
+ def product_text(item: Any, include_weight: bool = True) -> str:
238
+ """Normalized product text: title, category, brand, weight, description."""
239
+ parts = [f"Title: {getattr(item, 'title', '') or ''}", f"Category: {getattr(item, 'category', '') or ''}"]
240
+ brand = getattr(item, "brand", None) or ""
241
+ if brand:
242
+ parts.append(f"Brand: {brand}")
243
+ if include_weight:
244
+ w = getattr(item, "weight", None)
245
+ if w is not None and w > 0:
246
+ parts.append(f"Weight: {w}")
247
+ full = getattr(item, "full", None) or getattr(item, "summary", None) or ""
248
+ if full:
249
+ parts.append(f"Description: {full[:3000]}")
250
+ return "\n".join(parts).strip()
251
+
252
+
253
+ def build_tfidf_index(texts: list[str], max_features: int = 8000, ngram_range: tuple[int, int] = (1, 2)):
254
+ sklearn = _require("sklearn")
255
+ vec = sklearn.feature_extraction.text.TfidfVectorizer(
256
+ max_features=max_features,
257
+ stop_words="english",
258
+ ngram_range=ngram_range,
259
+ )
260
+ mat = vec.fit_transform(texts)
261
+ return vec, mat
262
+
263
+
264
+ def build_dense_index(
265
+ texts: list[str],
266
+ model_name: str = "BAAI/bge-small-en-v1.5",
267
+ cache_path: Optional[Path] = None,
268
+ device: Optional[str] = None,
269
+ ):
270
+ """Build dense embeddings with sentence-transformers. Returns (model, embeddings matrix)."""
271
+ if cache_path and cache_path.exists():
272
+ emb = np.load(cache_path)
273
+ return None, emb
274
+ st = _require("sentence_transformers")
275
+ model = st.SentenceTransformer(model_name, device=device)
276
+ emb = model.encode(texts, show_progress_bar=True, convert_to_numpy=True)
277
+ if cache_path:
278
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
279
+ np.save(cache_path, emb)
280
+ return model, emb
281
+
282
+
283
+ def retrieve_tfidf(
284
+ query_text: str,
285
+ pool_texts: list[str],
286
+ pool_items: list[Any],
287
+ vectorizer: Any,
288
+ pool_mat: Any,
289
+ k: int = 5,
290
+ exclude_key: Optional[str] = None,
291
+ dedup_key_fn: Callable[[Any], str] = dedup_key,
292
+ ):
293
+ sklearn = _require("sklearn")
294
+ qvec = vectorizer.transform([query_text])
295
+ sim = sklearn.metrics.pairwise.cosine_similarity(qvec, pool_mat).ravel()
296
+ order = np.argsort(-sim)
297
+ out = []
298
+ for i in order:
299
+ if len(out) >= k:
300
+ break
301
+ cand = pool_items[i]
302
+ if exclude_key and dedup_key_fn(cand) == exclude_key:
303
+ continue
304
+ out.append((cand, float(sim[i])))
305
+ return out
306
+
307
+
308
+ def load_embedding_model(model_name: str = "BAAI/bge-small-en-v1.5", device: Optional[str] = None):
309
+ """Load sentence-transformers model (e.g. when embeddings were loaded from cache)."""
310
+ st = _require("sentence_transformers")
311
+ return st.SentenceTransformer(model_name, device=device)
312
+
313
+
314
+ def retrieve_dense(
315
+ query_text: str,
316
+ pool_emb: np.ndarray,
317
+ pool_items: list[Any],
318
+ model: Any,
319
+ k: int = 5,
320
+ exclude_key: Optional[str] = None,
321
+ dedup_key_fn: Callable[[Any], str] = dedup_key,
322
+ ):
323
+ if model is None:
324
+ raise ValueError("Dense retrieval requires a loaded model when cache was used for embeddings only")
325
+ qemb = model.encode([query_text], convert_to_numpy=True)
326
+ norms = np.linalg.norm(pool_emb, axis=1, keepdims=True)
327
+ norms = np.where(norms == 0, 1e-9, norms)
328
+ qn = np.linalg.norm(qemb) or 1e-9
329
+ sim = (np.dot(qemb, pool_emb.T).ravel() / (qn * norms.ravel())).clip(-1, 1)
330
+ order = np.argsort(-sim)
331
+ out = []
332
+ for i in order:
333
+ if len(out) >= k:
334
+ break
335
+ cand = pool_items[i]
336
+ if exclude_key and dedup_key_fn(cand) == exclude_key:
337
+ continue
338
+ out.append((cand, float(sim[i])))
339
+ return out
340
+
341
+
342
+ def mmr_select(
343
+ scored: list[tuple[Any, float]],
344
+ pool_texts: list[str],
345
+ pool_items: list[Any],
346
+ lambda_: float = 0.7,
347
+ k: int = 5,
348
+ ) -> list[Any]:
349
+ """Maximal marginal relevance: balance relevance and diversity. scored = [(item, score), ...]."""
350
+ if not scored or k <= 0:
351
+ return []
352
+ idx_map = {id(pool_items[i]): i for i in range(len(pool_items))}
353
+ text_by_id = {id(pool_items[i]): pool_texts[i] for i in range(len(pool_items))}
354
+ selected = []
355
+ selected_ids = set()
356
+ remaining = list(scored)
357
+ while len(selected) < k and remaining:
358
+ best_score = -1e9
359
+ best_idx = -1
360
+ for idx, (item, rel) in enumerate(remaining):
361
+ if id(item) in selected_ids:
362
+ continue
363
+ # MMR: lambda * rel - (1-lambda) * max_sim to selected
364
+ if not selected:
365
+ mmr = rel
366
+ else:
367
+ sim_to_selected = []
368
+ for s in selected:
369
+ ti = text_by_id.get(id(s), "")
370
+ tj = text_by_id.get(id(item), "")
371
+ if ti and tj:
372
+ # simple Jaccard for diversity
373
+ a, b = set(ti.lower().split()), set(tj.lower().split())
374
+ inter = len(a & b)
375
+ union = len(a | b) or 1
376
+ sim_to_selected.append(inter / union)
377
+ else:
378
+ sim_to_selected.append(0.0)
379
+ max_sim = max(sim_to_selected) if sim_to_selected else 0.0
380
+ mmr = lambda_ * rel - (1 - lambda_) * max_sim
381
+ if mmr > best_score:
382
+ best_score = mmr
383
+ best_idx = idx
384
+ if best_idx < 0:
385
+ break
386
+ chosen = remaining.pop(best_idx)
387
+ selected.append(chosen[0])
388
+ selected_ids.add(id(chosen[0]))
389
+ return selected
390
+
391
+
392
+ def neighbor_price_stats(items: list[Any]) -> dict[str, float]:
393
+ prices = [getattr(it, "price", 0) for it in items if getattr(it, "price", None) is not None]
394
+ if not prices:
395
+ return {"mean": 0.0, "median": 0.0, "min": 0.0, "max": 0.0, "std": 0.0, "q25": 0.0, "q75": 0.0}
396
+ arr = np.array(prices)
397
+ return {
398
+ "mean": float(np.mean(arr)),
399
+ "median": float(np.median(arr)),
400
+ "min": float(np.min(arr)),
401
+ "max": float(np.max(arr)),
402
+ "std": float(np.std(arr)) if len(arr) > 1 else 0.0,
403
+ "q25": float(np.percentile(arr, 25)),
404
+ "q75": float(np.percentile(arr, 75)),
405
+ }
406
+
407
+
408
+ def indices_same_category_and_band(
409
+ pool_items: list[Any],
410
+ category: str,
411
+ price: float,
412
+ price_bins: list[float],
413
+ adjacent: bool = True,
414
+ ) -> list[int]:
415
+ """Indices into pool_items with same category and same (or adjacent if adjacent=True) price band."""
416
+ band = price_band(price, price_bins)
417
+ band_idx = next((i for i in range(len(price_bins) - 1) if (price_bins[i], price_bins[i + 1]) == band), 0)
418
+ if adjacent:
419
+ bands_ok = [
420
+ (price_bins[j], price_bins[j + 1])
421
+ for j in range(max(0, band_idx - 1), min(len(price_bins) - 1, band_idx + 2))
422
+ ]
423
+ else:
424
+ bands_ok = [band]
425
+ out = []
426
+ for i, it in enumerate(pool_items):
427
+ if getattr(it, "category", "") != category:
428
+ continue
429
+ if price_band(getattr(it, "price", 0), price_bins) in bands_ok:
430
+ out.append(i)
431
+ return out
432
+
433
+
434
+ def detect_device() -> str:
435
+ """Return 'cuda', 'mps', or 'cpu'. Prefer CUDA, then MPS (Apple), then CPU."""
436
+ try:
437
+ import torch
438
+ if torch.cuda.is_available():
439
+ return "cuda"
440
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
441
+ return "mps"
442
+ except ImportError:
443
+ pass
444
+ return "cpu"
445
+
446
+
447
+ def has_mlx() -> bool:
448
+ try:
449
+ import mlx.core
450
+ return True
451
+ except ImportError:
452
+ return False
453
+
454
+
455
+ def has_bitsandbytes() -> bool:
456
+ try:
457
+ import bitsandbytes
458
+ return True
459
+ except ImportError:
460
+ return False
461
+
462
+
463
+ def metrics(truths: list[float], guesses: list[float]) -> dict[str, float]:
464
+ t = np.asarray(truths, dtype=float)
465
+ g = np.asarray(guesses, dtype=float)
466
+ aae = float(np.mean(np.abs(g - t)))
467
+ mse = float(np.mean((g - t) ** 2))
468
+ ss_res = np.sum((t - g) ** 2)
469
+ ss_tot = np.sum((t - np.mean(t)) ** 2)
470
+ r2 = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0
471
+ return {"AAE": aae, "MSE": mse, "R2": r2}