Spaces:
Sleeping
Sleeping
| """ | |
| ranking.py — query-time ranking formula. | |
| Combines four signals to produce a [0,1] final_score per candidate: | |
| final_score = α · rating_01 | |
| + β · aspect_weighted | |
| + γ · log_reviews_01 | |
| Where: | |
| rating_01 = avg_rating / 5 | |
| aspect_weighted = Σ_i w_i · aspect_i | |
| — aspect_i already in [0,1] (globally normalized in meta parquet) | |
| — for price, aspect_i is blended with the Google Maps tier: | |
| price_blended = 0.5·aspect_price + 0.5·tier_score | |
| so a cheap restaurant ($) gets a bonus independent of review sentiment. | |
| — w_i are user aspect preferences, auto-normalized to sum to 1 | |
| log_reviews_01 = log1p(num_reviews) / log1p(max_reviews_global) | |
| — global normalization keeps scores comparable across queries | |
| α/β/γ are auto-normalized to sum to 1, so final_score lives in [0,1]. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Sequence | |
| import numpy as np | |
| import pandas as pd | |
| # Google Maps price tier → affordability score (higher = cheaper) | |
| PRICE_TIER_MAP: dict[str, float] = {"$": 1.0, "$$": 0.75, "$$$": 0.25, "$$$$": 0.0} | |
| PRICE_TIER_NEUTRAL = 0.5 # for missing tier | |
| PRICE_TIER_BLEND = 0.5 # weight given to ABSA; remainder goes to tier | |
| ASPECTS: tuple[str, ...] = ("food", "service", "price", "wait_time") | |
| ASPECT_COLS: tuple[str, ...] = tuple(f"aspect_{a}" for a in ASPECTS) | |
| DEFAULT_ALPHA = 0.4 | |
| DEFAULT_BETA = 0.5 | |
| DEFAULT_GAMMA = 0.1 | |
| def tier_to_score(tier: str | None) -> float: | |
| """Map '$', '$$', ... → affordability score in [0,1]. Unknown → neutral 0.5.""" | |
| if tier is None or (isinstance(tier, float) and np.isnan(tier)): | |
| return PRICE_TIER_NEUTRAL | |
| return PRICE_TIER_MAP.get(str(tier).strip(), PRICE_TIER_NEUTRAL) | |
| def normalize_prefs(prefs: dict[str, float]) -> dict[str, float]: | |
| """Auto-normalize user aspect weights so they sum to 1.""" | |
| total = sum(max(0.0, prefs.get(a, 0.0)) for a in ASPECTS) | |
| if total <= 0: | |
| return {a: 0.25 for a in ASPECTS} | |
| return {a: max(0.0, prefs.get(a, 0.0)) / total for a in ASPECTS} | |
| def normalize_abg(alpha: float, beta: float, gamma: float) -> tuple[float, float, float]: | |
| total = max(0.0, alpha) + max(0.0, beta) + max(0.0, gamma) | |
| if total <= 0: | |
| return (DEFAULT_ALPHA, DEFAULT_BETA, DEFAULT_GAMMA) | |
| return (max(0.0, alpha) / total, max(0.0, beta) / total, max(0.0, gamma) / total) | |
| class RankingResult: | |
| ranked: pd.DataFrame | |
| alpha: float | |
| beta: float | |
| gamma: float | |
| user_prefs: dict[str, float] | |
| log_reviews_max: float | |
| def rank_candidates( | |
| candidates: pd.DataFrame, | |
| meta: pd.DataFrame, | |
| user_prefs: dict[str, float], | |
| alpha: float = DEFAULT_ALPHA, | |
| beta: float = DEFAULT_BETA, | |
| gamma: float = DEFAULT_GAMMA, | |
| log_reviews_max: float | None = None, | |
| ) -> RankingResult: | |
| """ | |
| Rank search candidates using the ABSA-weighted ranking formula. | |
| candidates | |
| DataFrame from similarity search. Must contain at least: | |
| gmap_id, avg_rating, num_of_reviews (or num_reviews). | |
| meta | |
| Full meta DataFrame with aspect_* cols + 'price' (Google tier). | |
| Only the rows matching candidate gmap_ids are used. | |
| user_prefs | |
| Dict of 4 aspect weights (food/service/price/wait_time), any | |
| magnitude — auto-normalized to sum to 1. | |
| alpha/beta/gamma | |
| Ranking-formula weights, auto-normalized to sum to 1. | |
| log_reviews_max | |
| Global max of log1p(num_reviews) used to scale review-count influence. | |
| Pass the cached value from state to keep ranking comparable across | |
| queries; if None, falls back to the max within the candidate set | |
| (degrades comparability slightly). | |
| """ | |
| alpha, beta, gamma = normalize_abg(alpha, beta, gamma) | |
| prefs = normalize_prefs(user_prefs) | |
| df = candidates.copy() | |
| # Reconcile review-count column name between similarity.search_* outputs | |
| # (they expose num_of_reviews from meta) and any callers that pre-merge. | |
| if "num_reviews" not in df.columns: | |
| if "num_of_reviews" in df.columns: | |
| df["num_reviews"] = df["num_of_reviews"].fillna(0) | |
| else: | |
| df["num_reviews"] = 0 | |
| # Attach aspect columns + price tier from meta (only if not already present). | |
| needed = ["price", *ASPECT_COLS] | |
| to_merge = [c for c in needed if c not in df.columns and c in meta.columns] | |
| if to_merge: | |
| df = df.merge(meta[["gmap_id", *to_merge]], on="gmap_id", how="left") | |
| # Blended price aspect: 50/50 ABSA + Google tier | |
| tier_score = df["price"].map(tier_to_score).astype(float) | |
| price_blended = (PRICE_TIER_BLEND * df["aspect_price"].fillna(0.5).astype(float) | |
| + (1 - PRICE_TIER_BLEND) * tier_score) | |
| df["aspect_price_blended"] = price_blended | |
| # Weighted aspect combination (user prefs × already-normalized aspect scores) | |
| df["aspect_weighted"] = ( | |
| prefs["food"] * df["aspect_food"].fillna(0.5).astype(float) | |
| + prefs["service"] * df["aspect_service"].fillna(0.5).astype(float) | |
| + prefs["price"] * price_blended | |
| + prefs["wait_time"] * df["aspect_wait_time"].fillna(0.5).astype(float) | |
| ) | |
| # Rating on fixed [0,1] scale (divide by the known 5-point ceiling) | |
| df["rating_01"] = df["avg_rating"].fillna(0).clip(0, 5) / 5.0 | |
| # Review-count on global log scale (fallback to local max if not passed) | |
| log_reviews = np.log1p(df["num_reviews"].fillna(0).clip(lower=0)) | |
| denom = float(log_reviews_max) if log_reviews_max is not None else float(max(log_reviews.max(), 1.0)) | |
| df["log_reviews_01"] = (log_reviews / denom).clip(0, 1) if denom > 0 else 0.0 | |
| df["final_score"] = ( | |
| alpha * df["rating_01"] | |
| + beta * df["aspect_weighted"] | |
| + gamma * df["log_reviews_01"] | |
| ) | |
| df = df.sort_values("final_score", ascending=False).reset_index(drop=True) | |
| return RankingResult( | |
| ranked=df, | |
| alpha=alpha, beta=beta, gamma=gamma, | |
| user_prefs=prefs, | |
| log_reviews_max=denom, | |
| ) | |