deepreads / src /models /deep.py
yash-b18
feat: fix Two-Tower collapse, redesign frontend, filter untitled items
2647d97
Raw History Blame Contribute Delete
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)
@torch.no_grad()
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))
@torch.no_grad()
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)
@torch.no_grad()
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