Download src/models/deep.py from yashb18/deepreads: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/yashb18/deepreads/resolve/main/src/models/deep.py
- Command line
-
hf download hf://spaces/yashb18/deepreads/src/models/deep.py
-
curl -L -o deep.py https://huggingface.co/spaces/yashb18/deepreads/resolve/main/src/models/deep.py
14.5 kB
| """ | |
| Deep learning model: Two-Tower Neural Recommender. | |
| Architecture: | |
| User Tower : user_id embedding β MLP β L2-normalised user vector | |
| Item Tower : item_id embedding + metadata features (+ optional title tokens) | |
| β MLP β L2-normalised item vector | |
| Score : dot product of user and item vectors | |
| Training : binary cross-entropy with in-batch negative sampling | |
| At inference: | |
| 1. Pre-compute all item tower embeddings (once). | |
| 2. For each user, compute user embedding on-the-fly. | |
| 3. Score against all item embeddings via dot product. | |
| 4. Return top-K items. | |
| Explainability: | |
| Provides a metadata-grounded language explanation (no hallucination) | |
| via the explainability module. | |
| """ | |
| from __future__ import annotations | |
| import pickle | |
| from pathlib import Path | |
| from typing import Optional | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.utils.data import DataLoader | |
| from src.data.dataset import InferenceDataset, InteractionDataset | |
| from src.utils.logging import get_logger | |
| from src.utils.seed import set_seed | |
| logger = get_logger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Sub-modules | |
| # --------------------------------------------------------------------------- | |
| class MLP(nn.Module): | |
| """Generic multi-layer perceptron with ReLU activations and dropout.""" | |
| def __init__( | |
| self, | |
| input_dim: int, | |
| hidden_dims: list[int], | |
| output_dim: int, | |
| dropout: float = 0.2, | |
| ) -> None: | |
| super().__init__() | |
| layers: list[nn.Module] = [] | |
| in_dim = input_dim | |
| for h in hidden_dims: | |
| layers += [nn.Linear(in_dim, h), nn.ReLU(), nn.Dropout(dropout)] | |
| in_dim = h | |
| layers.append(nn.Linear(in_dim, output_dim)) | |
| self.net = nn.Sequential(*layers) | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| return self.net(x) | |
| class UserTower(nn.Module): | |
| """ | |
| Encodes a user into a fixed-size embedding vector. | |
| Args: | |
| n_users: Vocabulary size for user IDs. | |
| embedding_dim: ID embedding dimension. | |
| output_dim: Final embedding dimension. | |
| hidden_dims: MLP hidden layer sizes. | |
| dropout: Dropout probability. | |
| """ | |
| def __init__( | |
| self, | |
| n_users: int, | |
| embedding_dim: int, | |
| output_dim: int, | |
| hidden_dims: list[int], | |
| dropout: float, | |
| ) -> None: | |
| super().__init__() | |
| self.embedding = nn.Embedding(n_users, embedding_dim, padding_idx=0) | |
| self.mlp = MLP(embedding_dim, hidden_dims, output_dim, dropout) | |
| def forward(self, user_idx: torch.Tensor) -> torch.Tensor: | |
| emb = self.embedding(user_idx) | |
| out = self.mlp(emb) | |
| return nn.functional.normalize(out, dim=-1) | |
| class ItemTower(nn.Module): | |
| """ | |
| Encodes an item into a fixed-size embedding vector. | |
| Optionally incorporates item metadata features alongside the ID embedding. | |
| Args: | |
| n_items: Vocabulary size for item IDs. | |
| embedding_dim: ID embedding dimension. | |
| output_dim: Final embedding dimension. | |
| hidden_dims: MLP hidden layer sizes. | |
| dropout: Dropout probability. | |
| n_meta_features: Number of continuous metadata features (0 = ID-only). | |
| """ | |
| def __init__( | |
| self, | |
| n_items: int, | |
| embedding_dim: int, | |
| output_dim: int, | |
| hidden_dims: list[int], | |
| dropout: float, | |
| n_meta_features: int = 0, | |
| ) -> None: | |
| super().__init__() | |
| self.embedding = nn.Embedding(n_items, embedding_dim, padding_idx=0) | |
| self.n_meta = n_meta_features | |
| input_dim = embedding_dim + n_meta_features | |
| self.mlp = MLP(input_dim, hidden_dims, output_dim, dropout) | |
| def forward( | |
| self, | |
| item_idx: torch.Tensor, | |
| item_features: Optional[torch.Tensor] = None, | |
| ) -> torch.Tensor: | |
| emb = self.embedding(item_idx) | |
| if self.n_meta > 0 and item_features is not None: | |
| emb = torch.cat([emb, item_features], dim=-1) | |
| out = self.mlp(emb) | |
| return nn.functional.normalize(out, dim=-1) | |
| class TwoTowerModel(nn.Module): | |
| """ | |
| Full Two-Tower model with user and item towers. | |
| Args: | |
| n_users: Number of users. | |
| n_items: Number of items. | |
| embedding_dim: Embedding size for ID lookup tables. | |
| output_dim: Shared embedding space dimension. | |
| hidden_dims: MLP hidden layer sizes. | |
| dropout: Dropout probability. | |
| n_meta_features: Item metadata feature count. | |
| """ | |
| def __init__( | |
| self, | |
| n_users: int, | |
| n_items: int, | |
| embedding_dim: int = 64, | |
| output_dim: int = 64, | |
| hidden_dims: list[int] | None = None, | |
| dropout: float = 0.2, | |
| n_meta_features: int = 0, | |
| ) -> None: | |
| super().__init__() | |
| if hidden_dims is None: | |
| hidden_dims = [256, 128] | |
| self.user_tower = UserTower(n_users, embedding_dim, output_dim, hidden_dims, dropout) | |
| self.item_tower = ItemTower(n_items, embedding_dim, output_dim, hidden_dims, dropout, n_meta_features) | |
| def forward( | |
| self, | |
| user_idx: torch.Tensor, | |
| item_idx: torch.Tensor, | |
| item_features: Optional[torch.Tensor] = None, | |
| ) -> torch.Tensor: | |
| """ | |
| Compute dot-product similarity scores for (user, item) pairs. | |
| Returns: | |
| Tensor of shape (batch_size,) with similarity scores in [-1, 1]. | |
| """ | |
| u_emb = self.user_tower(user_idx) | |
| i_emb = self.item_tower(item_idx, item_features) | |
| return (u_emb * i_emb).sum(dim=-1) | |
| # --------------------------------------------------------------------------- | |
| # Trainer | |
| # --------------------------------------------------------------------------- | |
| class TwoTowerTrainer: | |
| """ | |
| Handles training, validation, and inference for the Two-Tower model. | |
| Args: | |
| model: TwoTowerModel instance. | |
| device: Torch device string. | |
| lr: Learning rate. | |
| weight_decay: L2 regularisation. | |
| patience: Early stopping patience (epochs). | |
| model_path: Where to save the best model checkpoint. | |
| """ | |
| def __init__( | |
| self, | |
| model: TwoTowerModel, | |
| device: str = "cpu", | |
| lr: float = 1e-3, | |
| weight_decay: float = 1e-4, | |
| patience: int = 5, | |
| model_path: Path = Path("models/two_tower.pt"), | |
| ) -> None: | |
| self.model = model.to(device) | |
| self.device = device | |
| # Embedding tables have sparse gradients β weight decay kills infrequently-updated | |
| # user/item embeddings. Use two param groups: embeddings get no weight decay. | |
| emb_params = [p for n, p in model.named_parameters() if "embedding" in n] | |
| mlp_params = [p for n, p in model.named_parameters() if "embedding" not in n] | |
| self.optimizer = torch.optim.Adam([ | |
| {"params": emb_params, "weight_decay": 0.0}, | |
| {"params": mlp_params, "weight_decay": weight_decay}, | |
| ], lr=lr) | |
| # BPR: no temperature needed (raw dot product used for ranking) | |
| self.patience = patience | |
| self.model_path = model_path | |
| self._best_val_loss = float("inf") | |
| self._patience_counter = 0 | |
| def _step(self, batch: dict[str, torch.Tensor]) -> torch.Tensor: | |
| """ | |
| Bayesian Personalized Ranking (BPR) loss. | |
| For each (user, pos_item, neg_item) triple: | |
| loss = -log_sigmoid(score(user, pos_item) - score(user, neg_item)) | |
| This directly optimizes for ranking positives above negatives without | |
| relying on in-batch diversity, avoiding embedding collapse. | |
| """ | |
| user_idx = batch["user_idx"].to(self.device) | |
| pos_item_idx = batch["pos_item_idx"].to(self.device) | |
| neg_item_idx = batch["neg_item_idx"].to(self.device) | |
| pos_feat = batch.get("pos_item_features") | |
| neg_feat = batch.get("neg_item_features") | |
| if pos_feat is not None: | |
| pos_feat = pos_feat.to(self.device) | |
| if neg_feat is not None: | |
| neg_feat = neg_feat.to(self.device) | |
| u_emb = self.model.user_tower(user_idx) # (B, D) | |
| pos_emb = self.model.item_tower(pos_item_idx, pos_feat) # (B, D) | |
| neg_emb = self.model.item_tower(neg_item_idx, neg_feat) # (B, D) | |
| pos_scores = (u_emb * pos_emb).sum(dim=-1) # (B,) | |
| neg_scores = (u_emb * neg_emb).sum(dim=-1) # (B,) | |
| loss = -F.logsigmoid(pos_scores - neg_scores).mean() | |
| return loss | |
| def train_epoch(self, loader: DataLoader) -> float: | |
| """Train for one epoch and return mean loss.""" | |
| self.model.train() | |
| total_loss = 0.0 | |
| for batch in loader: | |
| self.optimizer.zero_grad() | |
| loss = self._step(batch) | |
| loss.backward() | |
| self.optimizer.step() | |
| total_loss += loss.item() | |
| return total_loss / len(loader) | |
| def val_epoch(self, loader: DataLoader) -> float: | |
| """Evaluate on validation set and return mean loss.""" | |
| self.model.eval() | |
| total_loss = 0.0 | |
| for batch in loader: | |
| loss = self._step(batch) | |
| total_loss += loss.item() | |
| return total_loss / len(loader) | |
| def fit( | |
| self, | |
| train_loader: DataLoader, | |
| val_loader: DataLoader, | |
| epochs: int, | |
| ) -> list[dict[str, float]]: | |
| """ | |
| Train the model with early stopping. | |
| Args: | |
| train_loader: Training DataLoader. | |
| val_loader: Validation DataLoader. | |
| epochs: Maximum epochs. | |
| Returns: | |
| List of per-epoch dicts with 'train_loss' and 'val_loss'. | |
| """ | |
| history = [] | |
| for epoch in range(1, epochs + 1): | |
| train_loss = self.train_epoch(train_loader) | |
| val_loss = self.val_epoch(val_loader) | |
| history.append({"epoch": epoch, "train_loss": train_loss, "val_loss": val_loss}) | |
| logger.info( | |
| "Epoch %d/%d β train_loss: %.4f val_loss: %.4f", | |
| epoch, epochs, train_loss, val_loss, | |
| ) | |
| if val_loss < self._best_val_loss: | |
| self._best_val_loss = val_loss | |
| self._patience_counter = 0 | |
| self._save_checkpoint() | |
| logger.info(" β New best val_loss. Checkpoint saved.") | |
| else: | |
| self._patience_counter += 1 | |
| if self._patience_counter >= self.patience: | |
| logger.info("Early stopping at epoch %d.", epoch) | |
| break | |
| # Restore best checkpoint | |
| self._load_checkpoint() | |
| return history | |
| def _save_checkpoint(self) -> None: | |
| self.model_path.parent.mkdir(parents=True, exist_ok=True) | |
| torch.save(self.model.state_dict(), self.model_path) | |
| def _load_checkpoint(self) -> None: | |
| if self.model_path.exists(): | |
| self.model.load_state_dict(torch.load(self.model_path, map_location=self.device, weights_only=True)) | |
| def build_item_embeddings( | |
| self, | |
| n_items: int, | |
| item_features: np.ndarray | None = None, | |
| batch_size: int = 2048, | |
| ) -> np.ndarray: | |
| """ | |
| Pre-compute item tower embeddings for all items. | |
| Args: | |
| n_items: Total number of items. | |
| item_features: Optional metadata features array (n_items, n_meta). | |
| batch_size: Inference batch size. | |
| Returns: | |
| Numpy array of shape (n_items, output_dim). | |
| """ | |
| self.model.eval() | |
| dataset = InferenceDataset(n_items, item_features) | |
| loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0) | |
| all_embeddings = [] | |
| for batch in loader: | |
| item_idx = batch["item_idx"].to(self.device) | |
| feat = batch.get("item_features") | |
| if feat is not None: | |
| feat = feat.to(self.device) | |
| emb = self.model.item_tower(item_idx, feat) | |
| all_embeddings.append(emb.cpu().numpy()) | |
| return np.vstack(all_embeddings) | |
| def get_user_embedding( | |
| self, | |
| user_idx: int, | |
| ) -> np.ndarray: | |
| """ | |
| Compute the user embedding for a single user. | |
| Args: | |
| user_idx: Encoded user integer index. | |
| Returns: | |
| Numpy array of shape (output_dim,). | |
| """ | |
| self.model.eval() | |
| uid_tensor = torch.LongTensor([user_idx]).to(self.device) | |
| emb = self.model.user_tower(uid_tensor) | |
| return emb.squeeze(0).cpu().numpy() | |
| def recommend( | |
| self, | |
| user_idx: int, | |
| item_embeddings: np.ndarray, | |
| top_k: int = 10, | |
| exclude_items: set[int] | None = None, | |
| ) -> list[tuple[int, float]]: | |
| """ | |
| Recommend top-K items for a user using pre-computed item embeddings. | |
| Args: | |
| user_idx: Encoded user index. | |
| item_embeddings: Pre-computed (n_items, dim) item embeddings. | |
| top_k: Number of recommendations. | |
| exclude_items: Item indices to exclude (e.g., already seen). | |
| Returns: | |
| List of (item_idx, score) sorted descending. | |
| """ | |
| user_emb = self.get_user_embedding(user_idx) | |
| scores = item_embeddings @ user_emb # (n_items,) | |
| if exclude_items: | |
| excl = np.array([iid for iid in exclude_items if 0 <= iid < len(scores)], dtype=np.int64) | |
| if len(excl): | |
| scores[excl] = -np.inf | |
| top_indices = np.argpartition(scores, -top_k)[-top_k:] | |
| top_indices = top_indices[np.argsort(scores[top_indices])[::-1]] | |
| return [(int(i), float(scores[i])) for i in top_indices] | |
| def build_item_feature_matrix( | |
| item_features: "pd.DataFrame", # noqa: F821 | |
| meta_cols: list[str], | |
| ) -> np.ndarray: | |
| """ | |
| Build a dense numpy array of item metadata features for the item tower. | |
| Args: | |
| item_features: Item features DataFrame sorted by item_idx. | |
| meta_cols: Columns to include as continuous features. | |
| Returns: | |
| Float32 array of shape (n_items, len(meta_cols)). | |
| """ | |
| import pandas as pd | |
| df = item_features.sort_values("item_idx") | |
| available = [c for c in meta_cols if c in df.columns] | |
| arr = df[available].fillna(0.0).values.astype(np.float32) | |
| return arr | |