Spaces:
Sleeping
Sleeping
Upload recsys.py with huggingface_hub
Browse files
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']}")
|