| """ |
| HuggingFace Inference Endpoint Handler |
| |
| Custom handler for the Two-Tower recommendation model. |
| This file is required for deploying to HuggingFace Inference Endpoints. |
| |
| See: https://huggingface.co/docs/inference-endpoints/guides/custom_handler |
| |
| Input format: |
| { |
| "inputs": { |
| "user_wines": [ |
| {"embedding": [768 floats], "rating": 4.5}, |
| ... |
| ], |
| "candidate_wine": { |
| "embedding": [768 floats], |
| "color": "red", |
| "type": "still", |
| "style": "Classic", |
| "climate_type": "continental", |
| "climate_band": "cool", |
| "vintage_band": "medium" |
| } |
| } |
| } |
| |
| OR for batch scoring: |
| { |
| "inputs": { |
| "user_wines": [...], |
| "candidate_wines": [...] # Multiple candidates |
| } |
| } |
| |
| Output format: |
| { |
| "score": 75.5 # Single wine |
| } |
| OR |
| { |
| "scores": [75.5, 82.3, ...] # Batch |
| } |
| """ |
|
|
| import torch |
| from typing import Dict, List, Any |
|
|
| |
| CATEGORICAL_VOCABS = { |
| "color": ["red", "white", "rosé", "orange", "sparkling"], |
| "type": ["still", "sparkling", "fortified", "dessert"], |
| "style": [ |
| "Classic", |
| "Natural", |
| "Organic", |
| "Biodynamic", |
| "Conventional", |
| "Pet-Nat", |
| "Orange", |
| "Skin-Contact", |
| "Amphora", |
| "Traditional", |
| ], |
| "climate_type": ["cool", "moderate", "warm", "hot"], |
| "climate_band": ["cool", "moderate", "warm", "hot"], |
| "vintage_band": ["young", "developing", "mature", "non_vintage"], |
| } |
|
|
|
|
| class EndpointHandler: |
| """ |
| Custom handler for HuggingFace Inference Endpoints. |
| |
| Loads the Two-Tower model and handles inference requests. |
| """ |
|
|
| def __init__(self, path: str = ""): |
| """ |
| Initialize the handler. |
| |
| Args: |
| path: Path to the model directory (provided by HF Inference Endpoints) |
| """ |
| from model import TwoTowerModel |
|
|
| |
| if path: |
| self.model = TwoTowerModel.from_pretrained(path) |
| else: |
| self.model = TwoTowerModel.from_pretrained("swirl/two-tower-recommender") |
|
|
| self.model.eval() |
|
|
| |
| self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| self.model.to(self.device) |
|
|
| print(f"Two-Tower model loaded on {self.device}") |
|
|
| def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]: |
| """ |
| Handle inference request. |
| |
| Args: |
| data: Request payload with "inputs" key |
| |
| Returns: |
| Response with "score" or "scores" key |
| """ |
| inputs = data.get("inputs", data) |
|
|
| |
| user_wines = inputs.get("user_wines", []) |
|
|
| if not user_wines: |
| return {"error": "No user_wines provided"} |
|
|
| |
| if "candidate_wine" in inputs: |
| |
| return self._score_single(user_wines, inputs["candidate_wine"]) |
| elif "candidate_wines" in inputs: |
| |
| return self._score_batch(user_wines, inputs["candidate_wines"]) |
| else: |
| return {"error": "No candidate_wine or candidate_wines provided"} |
|
|
| def _score_single( |
| self, user_wines: List[Dict[str, Any]], candidate_wine: Dict[str, Any] |
| ) -> Dict[str, float]: |
| """Score a single candidate wine.""" |
| with torch.no_grad(): |
| |
| user_embeddings, user_ratings, user_mask = self._prepare_user_data( |
| user_wines |
| ) |
|
|
| |
| wine_embedding, wine_categorical = self._prepare_wine_data(candidate_wine) |
|
|
| |
| score = self.model( |
| user_embeddings, |
| user_ratings, |
| wine_embedding, |
| wine_categorical, |
| user_mask, |
| ) |
|
|
| return {"score": float(score.item())} |
|
|
| def _score_batch( |
| self, user_wines: List[Dict[str, Any]], candidate_wines: List[Dict[str, Any]] |
| ) -> Dict[str, List[float]]: |
| """Score multiple candidate wines.""" |
| with torch.no_grad(): |
| |
| user_embeddings, user_ratings, user_mask = self._prepare_user_data( |
| user_wines |
| ) |
|
|
| |
| user_vector = self.model.get_user_embedding( |
| user_embeddings, user_ratings, user_mask |
| ) |
|
|
| |
| scores = [] |
| for wine in candidate_wines: |
| wine_embedding, wine_categorical = self._prepare_wine_data(wine) |
| wine_vector = self.model.get_wine_embedding( |
| wine_embedding, wine_categorical |
| ) |
| score = self.model.score_from_embeddings(user_vector, wine_vector) |
| scores.append(float(score.item())) |
|
|
| return {"scores": scores} |
|
|
| def _prepare_user_data(self, user_wines: List[Dict[str, Any]]) -> tuple: |
| """ |
| Prepare user wine data for model input. |
| |
| Returns: |
| user_embeddings: (1, num_wines, 768) |
| user_ratings: (1, num_wines) |
| user_mask: (1, num_wines) |
| """ |
| embeddings = [] |
| ratings = [] |
|
|
| for wine in user_wines: |
| embedding = wine.get("embedding", [0.0] * 768) |
| rating = wine.get("rating", 3.0) |
|
|
| embeddings.append(embedding) |
| ratings.append(rating) |
|
|
| |
| user_embeddings = torch.tensor( |
| [embeddings], dtype=torch.float32, device=self.device |
| ) |
| user_ratings = torch.tensor([ratings], dtype=torch.float32, device=self.device) |
|
|
| |
| user_mask = torch.ones( |
| 1, len(user_wines), dtype=torch.float32, device=self.device |
| ) |
|
|
| return user_embeddings, user_ratings, user_mask |
|
|
| def _prepare_wine_data(self, wine: Dict[str, Any]) -> tuple: |
| """ |
| Prepare wine data for model input. |
| |
| Returns: |
| wine_embedding: (1, 768) |
| wine_categorical: (1, categorical_dim) |
| """ |
| |
| embedding = wine.get("embedding", [0.0] * 768) |
| wine_embedding = torch.tensor( |
| [embedding], dtype=torch.float32, device=self.device |
| ) |
|
|
| |
| categorical = self._encode_categorical(wine) |
| wine_categorical = torch.tensor( |
| [categorical], dtype=torch.float32, device=self.device |
| ) |
|
|
| return wine_embedding, wine_categorical |
|
|
| def _encode_categorical(self, wine: Dict[str, Any]) -> List[float]: |
| """ |
| One-hot encode categorical features. |
| |
| Args: |
| wine: Wine dict with categorical features |
| |
| Returns: |
| List of floats (one-hot encoded) |
| """ |
| encoding = [] |
|
|
| for feature, vocab in CATEGORICAL_VOCABS.items(): |
| value = wine.get(feature) |
| one_hot = [0.0] * len(vocab) |
|
|
| if value and value in vocab: |
| idx = vocab.index(value) |
| one_hot[idx] = 1.0 |
|
|
| encoding.extend(one_hot) |
|
|
| return encoding |
|
|
|
|
| |
| if __name__ == "__main__": |
| |
| handler = EndpointHandler() |
|
|
| |
| test_data = { |
| "inputs": { |
| "user_wines": [ |
| {"embedding": [0.1] * 768, "rating": 4.5}, |
| {"embedding": [0.2] * 768, "rating": 3.0}, |
| ], |
| "candidate_wine": { |
| "embedding": [0.15] * 768, |
| "color": "red", |
| "type": "still", |
| }, |
| } |
| } |
|
|
| result = handler(test_data) |
| print(f"Score: {result}") |
|
|