almador2002 commited on
Commit
82e16ec
·
verified ·
1 Parent(s): 89b3f51

Upload recsys.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. recsys.py +270 -0
recsys.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TripAlchemy - Recommendation Engine (embedding-agnostic core)
3
+ ================================================================
4
+
5
+ This module is intentionally decoupled from any specific embedding model.
6
+ It takes a precomputed embedding matrix (produced by embeddings_compare_hf.py,
7
+ using the winning HF sentence-embedding model) and the experiences list, and
8
+ exposes a hybrid recommender:
9
+
10
+ final_score = ALPHA * text_embedding_similarity + (1 - ALPHA) * category_slider_match
11
+
12
+ Two ways a "query" reaches the text-embedding side:
13
+ 1. Free-text query from the user ("cozy jazz bar with cocktails") -> embedded
14
+ with the same model used to embed the dataset -> cosine similarity.
15
+ 2. No free text: sliders alone are converted into a synthetic natural-language
16
+ preference sentence (e.g. "An experience that is mostly culinary and
17
+ nightlife, with some art_culture") which is embedded instead. This keeps
18
+ the embedding step meaningful even in pure-slider mode, per the
19
+ assignment's "Recommendation with Embeddings" requirement.
20
+
21
+ The category-slider side is always a straightforward weighted dot product
22
+ against each experience's `category_scores`, so the sliders stay precise
23
+ even if the text-embedding side is fuzzy.
24
+
25
+ Usage (see bottom __main__ for a runnable sanity check):
26
+
27
+ engine = RecommendationEngine.load(
28
+ experiences_path="../data/experiences.json",
29
+ embeddings_path="../data/embeddings/winning_model.npz",
30
+ )
31
+ results = engine.recommend(
32
+ category_weights={"culinary": 0.9, "nightlife": 0.6},
33
+ free_text=None,
34
+ top_k=5,
35
+ )
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import Callable, Dict, List, Optional
45
+
46
+ import numpy as np
47
+
48
+ CATEGORIES = ["culinary", "historical", "shopping", "nature", "nightlife", "art_culture"]
49
+
50
+ try:
51
+ import faiss
52
+ _HAS_FAISS = True
53
+ except ImportError:
54
+ _HAS_FAISS = False
55
+
56
+
57
+ def _l2_normalize(mat: np.ndarray) -> np.ndarray:
58
+ norms = np.linalg.norm(mat, axis=1, keepdims=True)
59
+ norms[norms == 0] = 1e-9
60
+ return mat / norms
61
+
62
+
63
+ def sliders_to_sentence(category_weights: Dict[str, float]) -> str:
64
+ """Turn a slider dict into a natural-language preference sentence,
65
+ so pure-slider search still goes through the embedding model."""
66
+ ranked = sorted(category_weights.items(), key=lambda kv: -kv[1])
67
+ ranked = [(c, w) for c, w in ranked if w > 0.05]
68
+ if not ranked:
69
+ return "A well-rounded, broadly appealing travel experience."
70
+
71
+ strong = [c for c, w in ranked if w >= 0.66]
72
+ medium = [c for c, w in ranked if 0.33 <= w < 0.66]
73
+ labels = {
74
+ "culinary": "food and dining",
75
+ "historical": "history and heritage",
76
+ "shopping": "markets and shopping",
77
+ "nature": "nature and outdoors",
78
+ "nightlife": "nightlife and evening entertainment",
79
+ "art_culture": "art, museums and culture",
80
+ }
81
+ parts = []
82
+ if strong:
83
+ parts.append("mostly " + " and ".join(labels[c] for c in strong))
84
+ if medium:
85
+ parts.append("with some " + " and ".join(labels[c] for c in medium))
86
+ return "An experience that is " + ", ".join(parts) + "."
87
+
88
+
89
+ @dataclass
90
+ class RecommendationEngine:
91
+ experiences: List[dict]
92
+ embeddings: np.ndarray # (N, D), L2-normalized
93
+ embed_fn: Optional[Callable[[List[str]], np.ndarray]] = None # text -> (M, D)
94
+ model_name: str = "unknown"
95
+
96
+ def __post_init__(self):
97
+ assert len(self.experiences) == self.embeddings.shape[0], (
98
+ f"experiences ({len(self.experiences)}) and embeddings "
99
+ f"({self.embeddings.shape[0]}) must be the same length"
100
+ )
101
+ self.embeddings = _l2_normalize(self.embeddings.astype("float32"))
102
+ self._category_matrix = np.array(
103
+ [[e["category_scores"].get(c, 0.0) for c in CATEGORIES] for e in self.experiences],
104
+ dtype="float32",
105
+ )
106
+ if _HAS_FAISS:
107
+ self._index = faiss.IndexFlatIP(self.embeddings.shape[1])
108
+ self._index.add(self.embeddings)
109
+ else:
110
+ self._index = None
111
+
112
+ # ---- persistence -----------------------------------------------------
113
+
114
+ @classmethod
115
+ def load(cls, experiences_path: str, embeddings_path: str, embed_fn=None, model_name="unknown"):
116
+ with open(experiences_path, encoding="utf-8") as f:
117
+ data = json.load(f)
118
+ experiences = data["experiences"] if isinstance(data, dict) else data
119
+
120
+ npz = np.load(embeddings_path, allow_pickle=True)
121
+ embeddings = npz["embeddings"]
122
+ ids = list(npz["ids"]) if "ids" in npz else [e["id"] for e in experiences]
123
+
124
+ # re-order experiences to match embeddings row order, if ids provided
125
+ by_id = {e["id"]: e for e in experiences}
126
+ ordered = [by_id[i] for i in ids if i in by_id]
127
+ if len(ordered) != len(experiences):
128
+ missing = len(experiences) - len(ordered)
129
+ print(f"⚠️ {missing} experiences had no matching embedding row and were dropped")
130
+
131
+ return cls(experiences=ordered, embeddings=embeddings, embed_fn=embed_fn, model_name=model_name)
132
+
133
+ # ---- core recommendation ----------------------------------------------
134
+
135
+ def _text_similarity(self, query_text: str) -> np.ndarray:
136
+ if self.embed_fn is None:
137
+ raise RuntimeError(
138
+ "No embed_fn attached — pass one in (must match the model used to "
139
+ "build `embeddings`), or call recommend(..., use_text_similarity=False)."
140
+ )
141
+ q = self.embed_fn([query_text])
142
+ q = _l2_normalize(np.asarray(q, dtype="float32"))
143
+
144
+ if self._index is not None:
145
+ # Exact search (IndexFlatIP) over the whole collection via FAISS -
146
+ # same result as the brute-force dot product, but this is the path
147
+ # that scales once the dataset grows to 10k+ experiences.
148
+ n = self.embeddings.shape[0]
149
+ sims_sorted, idxs = self._index.search(q, n)
150
+ sims = np.empty(n, dtype="float32")
151
+ sims[idxs[0]] = sims_sorted[0]
152
+ return sims
153
+
154
+ return (self.embeddings @ q.T).ravel() # cosine sim since both L2-normalized
155
+
156
+ def _category_match(self, category_weights: Dict[str, float]) -> np.ndarray:
157
+ w = np.array([category_weights.get(c, 0.0) for c in CATEGORIES], dtype="float32")
158
+ if w.sum() == 0:
159
+ return np.ones(len(self.experiences), dtype="float32") * 0.5
160
+ w = w / (np.linalg.norm(w) + 1e-9)
161
+ cat = self._category_matrix / (np.linalg.norm(self._category_matrix, axis=1, keepdims=True) + 1e-9)
162
+ return cat @ w
163
+
164
+ def recommend(
165
+ self,
166
+ category_weights: Optional[Dict[str, float]] = None,
167
+ free_text: Optional[str] = None,
168
+ city_id: Optional[str] = None,
169
+ max_budget_usd: Optional[float] = None,
170
+ energy: Optional[str] = None,
171
+ alpha: float = 0.5,
172
+ top_k: int = 5,
173
+ use_text_similarity: bool = True,
174
+ diversify_by_category: bool = True,
175
+ ) -> List[dict]:
176
+ """
177
+ alpha: weight on text-embedding similarity vs. category-slider match.
178
+ alpha=1.0 -> pure embedding search, alpha=0.0 -> pure slider match.
179
+ """
180
+ category_weights = category_weights or {}
181
+ n = len(self.experiences)
182
+ text_sim = np.zeros(n, dtype="float32")
183
+
184
+ if use_text_similarity:
185
+ query_text = free_text or sliders_to_sentence(category_weights)
186
+ text_sim = self._text_similarity(query_text)
187
+ # normalize to 0..1 for blending
188
+ text_sim = (text_sim - text_sim.min()) / (text_sim.max() - text_sim.min() + 1e-9)
189
+
190
+ cat_match = self._category_match(category_weights)
191
+ cat_match = (cat_match - cat_match.min()) / (cat_match.max() - cat_match.min() + 1e-9)
192
+
193
+ final_score = alpha * text_sim + (1 - alpha) * cat_match
194
+
195
+ # hard filters
196
+ mask = np.ones(n, dtype=bool)
197
+ if city_id:
198
+ mask &= np.array([e["city_id"] == city_id for e in self.experiences])
199
+ if max_budget_usd is not None:
200
+ mask &= np.array([e["cost_usd"] <= max_budget_usd for e in self.experiences])
201
+ if energy:
202
+ mask &= np.array([e.get("energy_required") == energy for e in self.experiences])
203
+
204
+ final_score = np.where(mask, final_score, -1.0)
205
+
206
+ order = np.argsort(-final_score)
207
+ results = []
208
+ seen_categories = set()
209
+ for idx in order:
210
+ if final_score[idx] < 0:
211
+ continue
212
+ exp = self.experiences[idx]
213
+ if diversify_by_category and len(results) < top_k:
214
+ primary = max(exp["category_scores"], key=exp["category_scores"].get)
215
+ if primary in seen_categories and len(seen_categories) < len(CATEGORIES):
216
+ continue # skip to encourage variety across the top_k
217
+ seen_categories.add(primary)
218
+ results.append({**exp, "_match_score": round(float(final_score[idx]), 4)})
219
+ if len(results) >= top_k:
220
+ break
221
+
222
+ # backfill if diversify filtering left us short
223
+ if len(results) < top_k:
224
+ have_ids = {r["id"] for r in results}
225
+ for idx in order:
226
+ if len(results) >= top_k:
227
+ break
228
+ if final_score[idx] < 0:
229
+ continue
230
+ exp = self.experiences[idx]
231
+ if exp["id"] in have_ids:
232
+ continue
233
+ results.append({**exp, "_match_score": round(float(final_score[idx]), 4)})
234
+
235
+ return results
236
+
237
+
238
+ # =====================================================
239
+ # Sanity check (runs with ANY embedding matrix, real or placeholder)
240
+ # =====================================================
241
+
242
+ if __name__ == "__main__":
243
+ import argparse
244
+
245
+ parser = argparse.ArgumentParser()
246
+ parser.add_argument("--experiences", default="../data/experiences.json")
247
+ parser.add_argument("--embeddings", default="../data/embeddings/winning_model.npz")
248
+ args = parser.parse_args()
249
+
250
+ engine = RecommendationEngine.load(args.experiences, args.embeddings)
251
+ print(f"Loaded {len(engine.experiences)} experiences, embedding dim={engine.embeddings.shape[1]}")
252
+
253
+ print("\n--- Pure slider test: heavy culinary + nightlife ---")
254
+ for r in engine.recommend(
255
+ category_weights={"culinary": 0.9, "nightlife": 0.7},
256
+ use_text_similarity=False,
257
+ alpha=0.0,
258
+ top_k=5,
259
+ ):
260
+ print(f" [{r['_match_score']}] {r['title']} ({r['city_name']}) - {r['category_scores']}")
261
+
262
+ print("\n--- Budget filter test: nature, max $30 ---")
263
+ for r in engine.recommend(
264
+ category_weights={"nature": 1.0},
265
+ use_text_similarity=False,
266
+ alpha=0.0,
267
+ max_budget_usd=30,
268
+ top_k=5,
269
+ ):
270
+ print(f" [{r['_match_score']}] {r['title']} ({r['city_name']}) - ${r['cost_usd']}")