The Big Picture
The core challenge in modern recommendation systems is retrieval at scale: given billions of items and millisecond latency constraints, how do you efficiently find the top-K items relevant to a user?
Two-Tower models (also called dual-encoder or siamese towers) solve this by learning separate embeddings for users and items, then using fast approximate nearest neighbor search to find matches. They're the backbone of production recommendation systems at companies with massive catalogs.
The Two-Tower Concept
The architecture is elegantly simple:
- User Tower: Takes user features (ID, demographics, behavior history) and produces a user embedding
- Item Tower: Takes item features (ID, content, metadata) and produces an item embedding
- Similarity: The two embeddings are in the same space. A dot product (or cosine similarity) between them scores how relevant an item is to a user
Where It Fits in the RecSys Pipeline
A complete recommendation system typically has three stages:
Key Components
User Tower
Encodes user identity and context. Inputs: user ID, age, location, device, browsing history, past interactions.
Item Tower
Encodes item identity and content. Inputs: item ID, title, category, brand, tags, popularity stats.
Similarity Function
Usually L2-normalized dot product or cosine similarity. Enables fast MIPS (Maximum Inner Product Search).
ANN Index
Indexes all item embeddings for sub-linear retrieval. FAISS IVF-PQ is the industry standard.
collaborative_filtering_guide.html. Two-tower extends these ideas with deep learning and dense features.
A Concrete Example: Ad Platform Retrieval
Let's walk through a real-world scenario: an ad/e-commerce platform needs to retrieve relevant products for a user's search query in milliseconds.
User Features
When a user visits the platform, we collect their context:
User ID: user_12345 Demographic Features: - Age: 28 - Gender: Female - Location: New York, NY - Device: Mobile Behavior Features: - Last 5 items viewed: [item_001, item_042, item_999, ...] - Search history: ["summer dresses", "blue shoes", "workout gear"] - Purchase history: [purchase_1, purchase_2, ...] - Dwell time avg: 3.2 seconds - Click-through rate: 0.12
Item Features
Each item in the catalog has rich metadata:
Item ID: item_042 Content Features: - Title: "Lightweight Summer Dress - Navy Blue" - Description embedding: [0.2, -0.5, 0.8, ...] (from BERT) Categorical Features: - Category: Apparel - Subcategory: Dresses - Brand: StyleCo - Color: Blue - Season: Summer Popularity Features: - Total views: 50,000 - Total clicks: 5,000 - CTR: 0.10 - Age: 15 days (newer items often get a boost)
Computing a Similarity Score
The two towers independently process their inputs:
Numeric Example
Simplified example with 4-dimensional embeddings:
User embedding (normalized): u = [0.50, 0.25, -0.50, 0.50] Item embedding (normalized): v = [0.60, 0.20, -0.40, 0.60] Dot product (similarity score): score = u · v = (0.50)(0.60) + (0.25)(0.20) + (-0.50)(-0.40) + (0.50)(0.60) = 0.30 + 0.05 + 0.20 + 0.30 = 0.85 Interpretation: Score of 0.85 (out of max 1.0) = highly relevant match
Retrieval Pipeline at Query Time
User Tower Architecture
The user tower transforms heterogeneous user signals into a fixed-size dense embedding suitable for fast similarity computation.
Input Features
A user tower typically consumes:
- User ID embedding: Learned embedding for the user ID itself (~128-256 dims)
- Dense features: Age, income level, engagement metrics (normalized to [0,1])
- Categorical features: Gender, location, device type (converted to embeddings or one-hot)
- Sequence features: Last N items viewed, search queries (aggregated via pooling)
- Context features: Time of day, day of week, device type, network quality
Architecture Overview
Key Design Choices
L2 Normalization
All embeddings are L2-normalized to unit vectors. This constrains the dot product to [-1, 1] and enables efficient MIPS algorithms for ANN search.
Output Dimensions
Typically 256-512 dimensions. Larger dims = more capacity but slower inference. Smaller dims = faster but less expressiveness.
Batch Normalization
Applied after activation functions. Stabilizes training and reduces internal covariate shift. Often turned off at serving time.
Dropout & Regularization
Dropout at 0.2-0.3 helps prevent overfitting. L2 regularization on weights also common (λ ~1e-5).
Code Structure (Conceptual)
class UserTower:
def __init__(self, output_dim=256):
self.user_id_embedding = nn.Embedding(num_users, 128)
self.fc1 = nn.Linear(1024, 512) # input concat size
self.fc2 = nn.Linear(512, 256)
self.fc3 = nn.Linear(256, output_dim)
self.bn1 = nn.BatchNorm1d(512)
self.bn2 = nn.BatchNorm1d(256)
self.dropout = nn.Dropout(0.2)
def forward(self, user_id, demographics, behavior, context):
# Lookup user embedding
user_emb = self.user_id_embedding(user_id)
# Concatenate all features
x = torch.cat([user_emb, demographics, behavior, context], dim=1)
# MLP
x = self.fc1(x)
x = self.bn1(x)
x = F.relu(x)
x = self.fc2(x)
x = self.bn2(x)
x = F.relu(x)
x = self.dropout(x)
# Output layer
x = self.fc3(x)
# L2 normalize
x = F.normalize(x, p=2, dim=1)
return xItem Tower Architecture
The item tower encodes item identity and content features into a shared embedding space with the user tower.
Input Features
- Item ID embedding: Learned representation of item ID (~128-256 dims)
- Title/content embedding: Text embeddings from pre-trained models like BERT or averaged word2vec
- Categorical features: Category, brand, color, size (embeddings or one-hot)
- Popularity features: Total views, CTR, age since creation (normalized)
- Price & quality signals: Price range, rating, review count
Architecture Diagram
bert_guide.html for details.
Shared Embedding Space
The user and item towers both output to the same d-dimensional space (e.g., 256 dims). This is crucial:
- Dot product between user and item embeddings is meaningful
- Enables efficient similarity search
- Allows for unified training on positive and negative pairs
Handling Text Content
For items with rich textual descriptions:
# Option 1: Pre-computed BERT embeddings
item_title_embedding = precomputed_bert_embeddings[item_id] # Shape: (768,)
# Option 2: Average word embeddings
word_embeddings = [word2vec[word] for word in title.split()]
item_title_embedding = np.mean(word_embeddings, axis=0) # Shape: (300,)
# Option 3: No text encoder, use category + popularity only
item_title_embedding = category_embedding + popularity_features
# In practice: concatenate with other features
item_input = np.concatenate([
item_id_embedding, # 128 dims
item_title_embedding, # 768 dims (BERT)
category_embedding, # 64 dims
popularity_features # 10 dims
]) # Total: ~970 dimsServing Optimization: Precomputed Embeddings
At serving time, all item embeddings can be precomputed and indexed:
# Offline: Compute all item embeddings once
all_item_embeddings = {}
for item_id in item_catalog:
item_features = load_item_features(item_id)
item_embeddings[item_id] = item_tower.forward(item_features).numpy()
# Save to disk and load at serving time
np.save('item_embeddings.npy', all_item_embeddings)
# At serving: zero latency for item encoding!
# Only encode the user, then do ANN searchThe Similarity Function
The core operation that makes two-tower efficient is the similarity function. After computing user and item embeddings, we need a fast way to score how well they match.
Dot Product vs Cosine Similarity
When embeddings are L2-normalized (unit vectors), dot product and cosine similarity are equivalent:
Dot Product: score = u · v = Σ(u_i * v_i) Range: [-1, 1] when L2-normalized Cosine Similarity: score = (u · v) / (||u|| * ||v||) Range: [-1, 1] When ||u|| = 1 and ||v|| = 1 (L2-normalized): Dot product = Cosine similarity Benefit: Dot product is MUCH faster to compute than cosine (no division/norm needed).
Why L2-Normalize?
L2 normalization is critical for production two-tower systems:
Stable Training
Constrains embeddings to unit hypersphere, preventing magnitude explosion during training.
MIPS Algorithms
Enables Maximum Inner Product Search (MIPS) via algorithms like FAISS IVF-PQ, which require normalized vectors.
Interpretability
A score of 0.95 always means "very similar" regardless of overall embedding magnitude.
Distance Metrics
Can convert easily: ||u - v||^2 = 2 - 2(u·v) for normalized vectors.
Temperature Scaling
In some cases, you might want softer or harder discrimination between similar items:
# Temperature scaling divides the score before softmax # Lower temperature = sharper distribution # Higher temperature = softer distribution score_raw = u · v score_scaled = score_raw / temperature # In loss computation: # temperature = 0.07 (common for contrastive learning) # makes the model learn sharper distinctions loss = -log(exp(score_pos / τ) / Σ exp(score_neg / τ))
Dot Product Geometry
Comparison to Cross-Encoders
Two-tower (dual-encoder) scores items independently. Cross-encoders score user-item pairs jointly:
| Aspect | Two-Tower (Dual-Encoder) | Cross-Encoder |
|---|---|---|
| Computation | u · v (dot product) | Network(concat(u, v)) |
| Speed | O(d) per comparison, ANN possible | O(d²) per comparison, no ANN |
| Expressiveness | Lower (additive similarity) | Higher (joint modeling) |
| Use Case | Retrieval (billions of items) | Ranking (hundreds of items) |
| Latency | Sub-millisecond | 10-100ms per item |
Training: Negative Sampling
The critical challenge in training two-tower models is how to select negative examples. Positives (clicked items) are rare, so you must be strategic about which negatives to use.
The Negative Sampling Problem
In a typical e-commerce platform:
- User sees ~100 items
- User clicks ~1-2 items (positive examples)
- ~98 items are not clicked (potential negatives)
If you treat all non-clicks as negatives, the model learns that "almost everything is irrelevant," which is too easy. You need hard negatives.
In-Batch Negatives
The simplest and most efficient approach: treat other items in the batch as negatives.
Hard Negative Mining
In-batch negatives are fast but can include easy negatives. Hard negatives are items the model scores high but the user didn't interact with:
InfoNCE Loss (Contrastive Loss)
The standard loss for two-tower training:
InfoNCE Loss (Normalized Temperature-scaled Cross-Entropy): L = -log(exp(s_pos / τ) / Σ_j exp(s_j / τ)) Where: s_pos = u · v_positive (similarity with positive item) s_j = u · v_j (similarity with negative items) τ = temperature (typically 0.07-0.1) This is equivalent to softmax cross-entropy where: - Positive item should have high probability - Negative items should have low probability As τ → 0: sharper distinction (harder training) As τ → 1: softer distinction (easier training)
Why In-Batch Negatives Work
- Efficiency: No extra computation. One batch forward pass gives O(batch_size²) similarity scores.
- Statistical Effect: Larger batch sizes = more negatives = more challenging loss. Typical: batch size 256-4096.
- Implicit Hard Negative Mining: If someone clicks item A in one request and item B in another, they might appear in the same batch as negatives for each other, which is a natural hard negative.
Training Pipeline & Data
Building a production two-tower model requires careful data preparation, debiasing, and training infrastructure.
Positive Examples
What counts as a positive interaction?
- Click: User explicitly clicked an item. Binary signal.
- Dwell Time: User spent > N seconds on item. Stronger signal than click alone.
- Purchase: Strongest signal. User bought the item.
- Add to Wishlist: Implicit preference signal.
- Interaction Score: Weighted combination (e.g., 1 click + 2 dwell time + 5 purchase).
Negative Examples
- Impressions without clicks: Item was shown, user didn't click. "Easy" negatives.
- Hard negatives: Items with high model scores but no click. Require offline mining.
- Random negatives: Sample uniformly from catalog. Biased but simple.
- Popularity-weighted negatives: Sample negatives proportional to their popularity. Helps with debiasing.
Debiasing
Logged interactions have several biases:
Training Data Format
Typical training example:
{
"user_id": 12345,
"item_id": 42,
"label": 1, # 1 = positive (click), 0 = negative
"user_features": {
"age": 28,
"gender": "F",
"location": "NY",
"device": "mobile",
"historical_items": [item_1, item_2, ...],
"historical_avg_dwell": 3.2
},
"item_features": {
"title": "Summer Dress Blue",
"title_embedding": [0.2, -0.5, ...], # from BERT
"category": "Apparel",
"price": 49.99,
"ctr": 0.10,
"popularity_score": 0.85,
"age_days": 15
}
}
Online vs Offline Training
- Offline Training: Train on historical logs once per day/week. Fast, reproducible. But model sees stale data.
- Online Learning: Update model on live interactions continuously. Captures trends but adds complexity.
- Hybrid: Offline training + online fine-tuning on recent data. Common in production.
Practical Training Loop
def train_two_tower(user_tower, item_tower, train_loader, num_epochs):
optimizer = Adam(lr=0.001)
temperature = 0.07
for epoch in range(num_epochs):
total_loss = 0
for batch_idx, batch in enumerate(train_loader):
user_feats = batch['user_features'] # Shape: (batch_size, user_feat_dim)
item_feats = batch['item_features'] # Shape: (batch_size, item_feat_dim)
labels = batch['label'] # Shape: (batch_size,)
# Forward pass
user_emb = user_tower(user_feats) # (batch_size, emb_dim)
item_emb = item_tower(item_feats) # (batch_size, emb_dim)
# Compute all pairwise similarities (batch_size × batch_size)
scores = torch.mm(user_emb, item_emb.t()) # Dot product
# Scale by temperature
scores = scores / temperature
# Create labels: diagonal is 1 (positives), rest are 0 (in-batch negatives)
batch_labels = torch.eye(batch_size) # Ideally also matches actual labels
# InfoNCE loss
loss = F.cross_entropy(scores, batch_labels)
# Backward
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Epoch {epoch} - Avg Loss: {total_loss / len(train_loader):.4f}")Approximate Nearest Neighbor (ANN) Serving
After training, you have a user tower and item tower. At serving time, you need to find the top-K items most similar to a user's embedding. With 100M+ items, exact search is infeasible.
The Retrieval Challenge
At query time:
- User comes with features (ID, demographics, context)
- Encode into embedding: O(1) operation
- Find top-1000 items from 100M catalog
- Latency budget: ~10-50ms
Brute-force: compare user embedding with all 100M item embeddings
# Brute force - too slow! scores = user_embedding @ all_item_embeddings.T # (100M,) operation top_k_idx = np.argsort(scores)[-1000:] # Get top 1000 # Time: ~100M × 256 float multiplies = 25B FLOPs # At 10B FLOPs/sec → ~2.5 seconds per request! # Need < 50ms → need 50× speedup
FAISS IVF-PQ: The Industry Standard
FAISS (Facebook AI Similarity Search) IVF-PQ is the most widely used ANN method:
- IVF (Inverted File): Cluster item embeddings into buckets. Search only relevant buckets.
- PQ (Product Quantization): Compress embeddings using learned codebooks. Reduces memory 16-32×.
HNSW (Hierarchical Navigable Small World)
Alternative to FAISS, increasingly popular for real-time systems:
- Graph-based: Items connected in hierarchical graph structure
- Search: Navigate graph from random start, greedily move toward user embedding
- Advantages: Fast search (O(log N)), dynamic updates (can add items without reindexing)
- Disadvantages: Higher memory than IVF-PQ, slower bulk insertion
ScaNN (Scalable Nearest Neighbors)
Google's recent ANN method, optimized for modern CPUs:
- Combines vector quantization with learnable distance metrics
- Faster than FAISS on modern hardware (AVX-512, GPU support)
- Good for hybrid retrieval (dense + sparse features)
Recall vs Latency Trade-off
# FAISS search: nprobe controls recall/latency
index = faiss.read_index('item_embeddings.index')
# nprobe = 10: search ~10% of clusters
index.nprobe = 10
distances, indices = index.search(user_embedding, k=1000)
# Recall: ~85%, Latency: ~15ms
# nprobe = 50: search ~50% of clusters
index.nprobe = 50
distances, indices = index.search(user_embedding, k=1000)
# Recall: ~97%, Latency: ~50ms
# nprobe = 1000: search all clusters (exact search)
index.nprobe = 1000
distances, indices = index.search(user_embedding, k=1000)
# Recall: 100%, Latency: ~500ms (too slow!)Advanced Techniques
Beyond the basic two-tower architecture, several advanced techniques improve performance in production systems.
Asymmetric Loss
All positives are not equal. Items clicked by many users are less informative (everyone likes them). Rare items need stronger signal:
# Asymmetric loss: down-weight easy positives
# popular_item_click_count = 100,000 users clicked this
# rare_item_click_count = 10 users clicked this
def asymmetric_loss(scores, labels, item_click_counts):
# Higher click count → lower weight
weight = 1.0 / (1.0 + item_click_counts / 1000)
loss = F.cross_entropy(scores, labels, reduction='none')
weighted_loss = (loss * weight).mean()
return weighted_loss
Popularity Debiasing
Correct for the fact that popular items were shown more often and received more clicks:
# Inverse propensity weighting
# propensity = P(item shown) ∝ popularity
def debias_loss(scores, labels, item_propensity):
# Down-weight clicks on popular items
weight = 1.0 / item_propensity
loss = F.cross_entropy(scores, labels, reduction='none')
debiased_loss = (loss * weight).mean()
return debiased_loss
Feature Hashing for High-Cardinality Features
User IDs and item IDs can have billions of unique values. Embeddings are large. Solution: hash to fixed size:
# Feature hashing: map any ID to fixed-size vector
def hash_feature(feature_value, num_buckets=100000):
hash_idx = hash(feature_value) % num_buckets
return hash_idx
# Usage:
user_id = 999999999 # Very large ID
hashed_user_id = hash_feature(user_id) # 0-99999
user_embedding = embedding_lookup[hashed_user_id]
# Trade-off: saves memory, but collisions (multiple IDs → same embedding)Multi-Task Two-Tower
One tower, multiple objectives (CTR, CVR, latency):
# Share user/item embeddings, but predict multiple tasks
class MultiTaskTwoTower(nn.Module):
def __init__(self):
self.user_tower = UserTower(output_dim=256)
self.item_tower = ItemTower(output_dim=256)
# Task-specific heads
self.ctr_head = nn.Linear(256, 1) # CTR prediction
self.cvr_head = nn.Linear(256, 1) # Conversion prediction
self.latency_head = nn.Linear(256, 1) # Expected latency
def forward(self, user_features, item_features):
user_emb = self.user_tower(user_features)
item_emb = self.item_tower(item_features)
# Shared similarity score
base_score = torch.sum(user_emb * item_emb, dim=1)
# Task-specific predictions
ctr_logits = self.ctr_head(user_emb + item_emb)
cvr_logits = self.cvr_head(user_emb + item_emb)
latency = self.latency_head(user_emb)
return base_score, ctr_logits, cvr_logits, latency
Item Freshness & Time Decay
New items should get a boost. Add age-based features:
# Time decay in item features
import time
def compute_item_freshness(item_creation_time):
age_hours = (time.time() - item_creation_time) / 3600
freshness = math.exp(-0.1 * age_hours) # Exponential decay
return freshness
# Add to item features:
item_features = torch.cat([
item_id_embedding,
title_embedding,
category_embedding,
freshness_score
]) # Boosts new items in the embedding
Hard Negative Mining Strategies
Offline Mining: After each training epoch, score all items for each user. Hard negatives are high-scoring items that weren't clicked.
# Offline hard negative mining
def mine_hard_negatives(user_embeddings, item_embeddings,
interaction_matrix, k_negatives=100):
"""
interaction_matrix: sparse matrix where 1 = clicked, 0 = not clicked
Returns: hard_negatives = items with high scores but label=0
"""
scores = user_embeddings @ item_embeddings.T # (num_users, num_items)
hard_negatives = {}
for user_idx in range(len(user_embeddings)):
clicked_items = interaction_matrix[user_idx].nonzero()[1]
# Get top-K items by score
top_items = np.argsort(scores[user_idx])[-1000:]
# Filter to items that weren't clicked
hard_negs = [item for item in top_items if item not in clicked_items][:k_negatives]
hard_negatives[user_idx] = hard_negs
return hard_negatives
Streaming Hard Negative Mining: During training, track items with high scores but label=0 and add them to future batches.
When to Use Two-Tower vs Alternatives
Two-tower is powerful, but not always the best choice. Here's how it compares to other recommendation approaches:
Comparison Table
| Approach | Scalability | Real-time Features | Interactions Modeling | Use Case |
|---|---|---|---|---|
| Two-Tower | Excellent (ANN) | Yes (user features) | Limited (dot product) | Retrieval at scale |
| Item CF | Good | No | Item-to-item | Cold-start, simple |
| Matrix Factorization | Good | No | User-item latent | Classic rating prediction |
| BM25 | Excellent | No | None (text search) | Keyword search baseline |
| DeepFM | Moderate | Yes | Excellent | Ranking, CTR prediction |
| Cross-Encoder | Poor (O(N·d²)) | Yes | Excellent | Ranking top-100 items |
Two-Tower Strengths
Scale
Handle 100M+ items with millisecond latency. ANN indexing enables efficient search.
Real-time Context
Encode user demographics, device, time-of-day at serving time. Always fresh.
Simplicity
Easy to train: symmetric towers, in-batch negatives, standard losses.
Serving Latency
User encoding ~5-10ms, ANN search ~10-50ms. Sub-100ms end-to-end.
Two-Tower Limitations
- Dot Product Interactions: Can only model simple additive interactions. Can't capture complex cross-features (e.g., user age × item price).
- Static Item Embeddings: Item embeddings precomputed offline. Can't incorporate real-time item popularity or trending signals easily.
- Symmetry: User and item embeddings from different towers but in same space. Some recommend learning separate interaction matrices instead.
When to Use Alternatives
fm_deepfm_guide.html for details.
Typical Hybrid Pipeline
Python Implementation
A complete, practical two-tower implementation using NumPy and Pandas (no external ML libraries). This demonstrates the core concepts.
Complete Code
import numpy as np
import pandas as pd
from collections import defaultdict
# ============================================================================
# USER TOWER
# ============================================================================
class UserTower:
"""User embedding tower: transforms user features to fixed-size embedding"""
def __init__(self, input_dim=128, hidden_dim=64, output_dim=32,
dropout_rate=0.1):
self.input_dim = input_dim
self.hidden_dim = hidden_dim
self.output_dim = output_dim
self.dropout_rate = dropout_rate
# Initialize weights
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
def relu(self, x):
return np.maximum(0, x)
def relu_grad(self, x):
return (x > 0).astype(float)
def l2_normalize(self, x):
"""L2 normalize each row"""
return x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-8)
def forward(self, x):
"""Forward pass: x shape (batch_size, input_dim)"""
# Hidden layer
z1 = np.dot(x, self.W1) + self.b1
a1 = self.relu(z1)
a1 = np.dropout(a1, keep_prob=1-self.dropout_rate) \
if np.random.rand() > 0.9 else a1
# Output layer
z2 = np.dot(a1, self.W2) + self.b2
# L2 normalize
embedding = self.l2_normalize(z2)
return embedding
def backward(self, dLdz2, x, z1, a1, learning_rate=0.01):
"""Simple gradient descent (no momentum)"""
batch_size = x.shape[0]
# Backprop through output layer
dW2 = np.dot(a1.T, dLdz2) / batch_size
db2 = np.sum(dLdz2, axis=0, keepdims=True) / batch_size
dLdz1 = np.dot(dLdz2, self.W2.T)
dLdz1 *= self.relu_grad(z1)
# Backprop through hidden layer
dW1 = np.dot(x.T, dLdz1) / batch_size
db1 = np.sum(dLdz1, axis=0, keepdims=True) / batch_size
# Update weights
self.W1 -= learning_rate * dW1
self.b1 -= learning_rate * db1
self.W2 -= learning_rate * dW2
self.b2 -= learning_rate * db2
# ============================================================================
# ITEM TOWER
# ============================================================================
class ItemTower:
"""Item embedding tower: transforms item features to fixed-size embedding"""
def __init__(self, input_dim=128, hidden_dim=64, output_dim=32,
dropout_rate=0.1):
self.input_dim = input_dim
self.hidden_dim = hidden_dim
self.output_dim = output_dim
self.dropout_rate = dropout_rate
# Initialize weights (same architecture as user tower)
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
def relu(self, x):
return np.maximum(0, x)
def relu_grad(self, x):
return (x > 0).astype(float)
def l2_normalize(self, x):
"""L2 normalize each row"""
return x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-8)
def forward(self, x):
"""Forward pass: x shape (batch_size, input_dim)"""
z1 = np.dot(x, self.W1) + self.b1
a1 = self.relu(z1)
z2 = np.dot(a1, self.W2) + self.b2
embedding = self.l2_normalize(z2)
return embedding
def backward(self, dLdz2, x, z1, a1, learning_rate=0.01):
"""Simple gradient descent"""
batch_size = x.shape[0]
dW2 = np.dot(a1.T, dLdz2) / batch_size
db2 = np.sum(dLdz2, axis=0, keepdims=True) / batch_size
dLdz1 = np.dot(dLdz2, self.W2.T)
dLdz1 *= self.relu_grad(z1)
dW1 = np.dot(x.T, dLdz1) / batch_size
db1 = np.sum(dLdz1, axis=0, keepdims=True) / batch_size
self.W1 -= learning_rate * dW1
self.b1 -= learning_rate * db1
self.W2 -= learning_rate * dW2
self.b2 -= learning_rate * db2
# ============================================================================
# TWO-TOWER MODEL
# ============================================================================
class TwoTowerModel:
"""Complete two-tower recommender with training and inference"""
def __init__(self, user_feature_dim=128, item_feature_dim=128,
embedding_dim=32, temperature=0.07):
self.user_tower = UserTower(input_dim=user_feature_dim,
output_dim=embedding_dim)
self.item_tower = ItemTower(input_dim=item_feature_dim,
output_dim=embedding_dim)
self.temperature = temperature
self.embedding_dim = embedding_dim
# Cache for inference
self.user_embedding_cache = {}
self.item_embeddings = None
self.item_ids = None
def compute_similarity_scores(self, user_emb, item_emb):
"""Dot product: shape (batch_size, batch_size)"""
return np.dot(user_emb, item_emb.T)
def infonce_loss(self, scores):
"""
InfoNCE loss: each user's positive is on diagonal
scores: (batch_size, batch_size)
"""
batch_size = scores.shape[0]
# Scale by temperature
scores = scores / self.temperature
# Softmax: exp(score) / sum(exp(scores))
exp_scores = np.exp(scores - np.max(scores, axis=1, keepdims=True))
probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True)
# Cross-entropy: -log(prob of positive)
# Positive is on diagonal
loss = -np.log(np.diag(probs) + 1e-10).mean()
return loss, probs
def train_epoch(self, user_features, item_features, labels,
batch_size=32, learning_rate=0.01):
"""
Train for one epoch
user_features: (num_samples, user_feature_dim)
item_features: (num_samples, item_feature_dim)
labels: (num_samples,) binary {0, 1}
"""
num_samples = len(user_features)
indices = np.arange(num_samples)
np.random.shuffle(indices)
total_loss = 0
num_batches = 0
for i in range(0, num_samples, batch_size):
batch_indices = indices[i:i+batch_size]
user_batch = user_features[batch_indices]
item_batch = item_features[batch_indices]
# Forward pass
user_emb = self.user_tower.forward(user_batch)
item_emb = self.item_tower.forward(item_batch)
# Compute in-batch negatives
scores = self.compute_similarity_scores(user_emb, item_emb)
# Compute loss
loss, probs = self.infonce_loss(scores)
total_loss += loss
num_batches += 1
# Note: simplified backprop (full implementation requires
# storing activations and computing gradients through towers)
if num_batches % 10 == 0:
print(f"Batch {num_batches} - Loss: {loss:.4f}")
return total_loss / num_batches
def precompute_item_embeddings(self, all_item_features, all_item_ids):
"""Precompute all item embeddings for fast retrieval"""
self.item_embeddings = self.item_tower.forward(all_item_features)
self.item_ids = all_item_ids
print(f"Precomputed embeddings for {len(all_item_ids)} items")
def retrieve_top_k(self, user_features, k=10):
"""Retrieve top-K items for a user (brute-force, no ANN)"""
user_emb = self.user_tower.forward(user_features.reshape(1, -1))
# Compute similarity with all items
scores = np.dot(user_emb, self.item_embeddings.T).flatten()
# Get top-K
top_k_indices = np.argsort(scores)[-k:][::-1]
top_k_items = self.item_ids[top_k_indices]
top_k_scores = scores[top_k_indices]
return list(zip(top_k_items, top_k_scores))
# ============================================================================
# SYNTHETIC DATA GENERATION
# ============================================================================
def generate_synthetic_data(num_users=1000, num_items=500,
user_feature_dim=128, item_feature_dim=128,
interaction_density=0.01):
"""
Generate synthetic user-item interaction dataset
"""
# Generate random user features
user_features = np.random.randn(num_users, user_feature_dim).astype(np.float32)
user_features = (user_features - user_features.mean()) / (user_features.std() + 1e-8)
# Generate random item features
item_features = np.random.randn(num_items, item_feature_dim).astype(np.float32)
item_features = (item_features - item_features.mean()) / (item_features.std() + 1e-8)
# Generate interactions
interactions = []
for user_id in range(num_users):
# Randomly select items this user interacted with
num_interactions = max(1, int(num_items * interaction_density))
interacted_items = np.random.choice(num_items, size=num_interactions,
replace=False)
for item_id in interacted_items:
interactions.append({
'user_id': user_id,
'item_id': item_id,
'user_features': user_features[user_id],
'item_features': item_features[item_id],
'label': 1
})
# Add negative examples (non-interactions)
num_negatives = len(interactions) * 3
for _ in range(num_negatives):
user_id = np.random.randint(0, num_users)
item_id = np.random.randint(0, num_items)
# Skip if already positive
if any(i['user_id'] == user_id and i['item_id'] == item_id
for i in interactions):
continue
interactions.append({
'user_id': user_id,
'item_id': item_id,
'user_features': user_features[user_id],
'item_features': item_features[item_id],
'label': 0
})
return interactions, user_features, item_features
# ============================================================================
# MAIN: TRAIN AND EVALUATE
# ============================================================================
if __name__ == '__main__':
print("Generating synthetic data...")
interactions, all_user_features, all_item_features = \
generate_synthetic_data(num_users=100, num_items=50,
user_feature_dim=32, item_feature_dim=32,
interaction_density=0.05)
print(f"Generated {len(interactions)} interactions")
# Prepare training data
user_feats_train = np.array([i['user_features'] for i in interactions])
item_feats_train = np.array([i['item_features'] for i in interactions])
labels_train = np.array([i['label'] for i in interactions])
# Create and train model
model = TwoTowerModel(user_feature_dim=32, item_feature_dim=32,
embedding_dim=16)
print("\nTraining...")
for epoch in range(3):
print(f"\n--- Epoch {epoch + 1} ---")
avg_loss = model.train_epoch(user_feats_train, item_feats_train,
labels_train, batch_size=32)
print(f"Avg Loss: {avg_loss:.4f}")
# Precompute item embeddings
print("\nPrecomputing item embeddings...")
item_ids = np.arange(len(all_item_features))
model.precompute_item_embeddings(all_item_features, item_ids)
# Inference: retrieve top-5 items for a test user
print("\n--- Inference Example ---")
test_user_features = all_user_features[0]
print(f"Retrieving top-5 items for user 0")
top_items = model.retrieve_top_k(test_user_features, k=5)
print(f"Top-5 items: {top_items}")
# Compute embedding quality metric (simple: average similarity with positives)
print("\n--- Quality Metrics ---")
user_emb = model.user_tower.forward(all_user_features[:10])
item_emb = model.item_tower.forward(all_item_features[:10])
scores = np.dot(user_emb, item_emb.T)
print(f"Sample scores (first 10x10): \n{scores}")
print(f"Mean score: {scores.mean():.4f}")
print(f"Max score: {scores.max():.4f}")
print(f"Min score: {scores.min():.4f}")
Key Implementation Notes
No Framework Dependency
Uses only NumPy and Pandas. Easier to understand core mechanics without framework abstractions.
Simple Optimization
Basic gradient descent (no momentum/Adam). In production, use optimized frameworks (PyTorch, TensorFlow).
L2 Normalization
Applied after output layer. Essential for stable training and MIPS compatibility.
In-Batch Negatives
Diagonal of similarity matrix = positive pairs. Rest = in-batch negatives for contrastive loss.
Running the Code
python two_tower_simple.py # Output: # Generating synthetic data... # Generated 750 interactions # # Training... # --- Epoch 1 --- # Batch 10 - Loss: 0.5823 # Avg Loss: 0.4921 # # --- Epoch 2 --- # Batch 10 - Loss: 0.3412 # Avg Loss: 0.3156 # # Inference Example # Retrieving top-5 items for user 0 # Top-5 items: [(42, 0.82), (17, 0.79), ...]