Representation Learning from Sparse Behavioral Data
Many real-world machine learning applications rely on modeling user behavior signals. Recommender systems, advertising platforms, and marketplace ranking models all share the same fundamental problem: users generate very few explicit labels, and the signals they do produce are noisy, biased, and sparse. In a typical e-commerce catalog with millions of items, even highly active users interact with a tiny fraction of the inventory. Learning useful representations under these conditions is one of the harder problems in applied ML.
This post walks through the core techniques I have found effective in production: contrastive learning with negative sampling, practical handling of sparse interaction matrices, cold-start mitigation, and evaluation strategies that go beyond offline metrics.
Behavioral Signals and Why They Are Hard
Typical behavioral signals include clicks, purchases, add-to-cart events, page dwell time, and session-level interaction sequences. Unlike supervised classification where labels are explicit and (ideally) clean, behavioral data has several properties that make it difficult:
- Implicit feedback only. A user not clicking an item does not mean they dislike it — they may never have seen it. The absence of signal is not a negative label, but most training setups must treat it as one.
- Extreme sparsity. Interaction matrices are typically 99.9%+ empty. A user-item matrix with 10M users and 1M items has 10 trillion possible entries; even a billion recorded interactions fills only 0.01%.
- Position bias and selection bias. Items shown at the top of a page get clicked more regardless of relevance. Data reflects the previous ranking policy, not ground truth preferences.
- Non-stationarity. User preferences shift over time. Seasonal trends, viral events, and catalog changes mean the distribution is constantly moving.
The fundamental challenge is not fitting the data you have — it is learning something meaningful from the data you are missing.
Handling Sparse Interaction Matrices
Before building any model, you need infrastructure that can efficiently represent and manipulate sparse data. Dense matrices are out of the question at production scale. scipy.sparse provides the foundation.
import numpy as np
from scipy import sparse
from collections import defaultdict
def build_interaction_matrix(interaction_log, num_users, num_items, min_interactions=5):
"""
Build a sparse user-item interaction matrix from raw logs.
Filters users with fewer than min_interactions to reduce noise
from drive-by visitors who contribute little signal.
"""
user_counts = defaultdict(int)
for user_id, item_id, timestamp in interaction_log:
user_counts[user_id] += 1
# Filter low-activity users — these add noise without enough signal
active_users = {u for u, c in user_counts.items() if c >= min_interactions}
rows, cols, values = [], [], []
for user_id, item_id, timestamp in interaction_log:
if user_id not in active_users:
continue
rows.append(user_id)
cols.append(item_id)
# Log-scale interaction counts to dampen power-law effects.
# Without this, heavy users dominate the embedding space.
values.append(1.0)
interaction_matrix = sparse.csr_matrix(
(values, (rows, cols)),
shape=(num_users, num_items),
dtype=np.float32,
)
# Collapse duplicates by summing, then apply log scaling
interaction_matrix.sum_duplicates()
interaction_matrix.data = np.log1p(interaction_matrix.data)
density = interaction_matrix.nnz / (num_users * num_items)
print(f"Matrix: {num_users} users x {num_items} items, "
f"{interaction_matrix.nnz:,} interactions, "
f"density={density:.6%}")
return interaction_matrix
A few things worth noting in practice:
- Log-scaling interaction counts prevents power users from dominating the embedding space. Without it, the model essentially memorizes a handful of heavy users.
- Filtering low-activity users is important. Users with one or two interactions contribute almost no learning signal but increase the matrix size and noise. The threshold depends on the domain — I have used values between 3 and 20 in different systems.
- CSR format (Compressed Sparse Row) is the right choice when you need efficient row slicing, which is exactly what you do when sampling positive pairs per user.
Contrastive Learning with Negative Sampling
The core idea behind contrastive learning for embeddings is straightforward: push user and item embeddings closer together when the user interacted with the item (positive pair), and push them apart when the user did not (negative pair). The challenge is in the details — particularly how you sample negatives.
Here is a complete PyTorch implementation:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
class InteractionDataset(Dataset):
"""
Generates (user, positive_item, negative_items) tuples from a
sparse interaction matrix.
Negative sampling strategy: uniform random from items the user has
NOT interacted with. In practice you may want popularity-weighted
negatives, but uniform is a reasonable starting point.
"""
def __init__(self, interaction_matrix, num_negatives=5):
self.interaction_matrix = interaction_matrix
self.num_negatives = num_negatives
self.num_items = interaction_matrix.shape[1]
# Pre-compute positive items per user for fast lookup
self.user_positives = {}
csr = interaction_matrix.tocsr()
for user_id in range(csr.shape[0]):
pos_items = csr[user_id].indices
if len(pos_items) > 0:
self.user_positives[user_id] = pos_items
self.valid_users = list(self.user_positives.keys())
def __len__(self):
return len(self.valid_users)
def __getitem__(self, idx):
user_id = self.valid_users[idx]
pos_items = self.user_positives[user_id]
# Sample one positive item uniformly
pos_item = pos_items[np.random.randint(len(pos_items))]
# Sample negatives — reject if they are actually positives
pos_set = set(pos_items)
neg_items = []
while len(neg_items) < self.num_negatives:
candidate = np.random.randint(self.num_items)
if candidate not in pos_set:
neg_items.append(candidate)
return (
torch.tensor(user_id, dtype=torch.long),
torch.tensor(pos_item, dtype=torch.long),
torch.tensor(neg_items, dtype=torch.long),
)
class ContrastiveEmbeddingModel(nn.Module):
"""
Two-tower embedding model with contrastive (InfoNCE) loss.
User and item towers share the same embedding dimensionality but
have separate parameters. A learned temperature parameter controls
the sharpness of the softmax over similarities.
"""
def __init__(self, num_users, num_items, embedding_dim=128):
super().__init__()
self.user_embeddings = nn.Embedding(num_users, embedding_dim)
self.item_embeddings = nn.Embedding(num_items, embedding_dim)
# Learned temperature — initialized to 0.07 following CLIP
self.log_temperature = nn.Parameter(torch.tensor(np.log(0.07)))
# Xavier init for stable training start
nn.init.xavier_uniform_(self.user_embeddings.weight)
nn.init.xavier_uniform_(self.item_embeddings.weight)
def forward(self, user_ids, pos_item_ids, neg_item_ids):
# Embed and L2-normalize so dot product = cosine similarity
user_emb = F.normalize(self.user_embeddings(user_ids), dim=-1)
pos_emb = F.normalize(self.item_embeddings(pos_item_ids), dim=-1)
neg_emb = F.normalize(self.item_embeddings(neg_item_ids), dim=-1)
temperature = self.log_temperature.exp()
# Positive similarity: (batch_size,)
pos_score = (user_emb * pos_emb).sum(dim=-1) / temperature
# Negative similarities: (batch_size, num_negatives)
neg_scores = torch.bmm(
neg_emb, user_emb.unsqueeze(-1)
).squeeze(-1) / temperature
# InfoNCE: softmax over [positive, negatives], cross-entropy on index 0
logits = torch.cat([pos_score.unsqueeze(-1), neg_scores], dim=-1)
labels = torch.zeros(logits.size(0), dtype=torch.long, device=logits.device)
loss = F.cross_entropy(logits, labels)
return loss
def train_embeddings(interaction_matrix, num_epochs=10, embedding_dim=128,
num_negatives=10, lr=1e-3, batch_size=2048):
"""Full training loop for contrastive user-item embeddings."""
num_users, num_items = interaction_matrix.shape
dataset = InteractionDataset(interaction_matrix, num_negatives=num_negatives)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True,
num_workers=4, pin_memory=True)
model = ContrastiveEmbeddingModel(num_users, num_items, embedding_dim)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-5)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_epochs)
for epoch in range(num_epochs):
model.train()
total_loss = 0.0
num_batches = 0
for user_ids, pos_items, neg_items in loader:
user_ids = user_ids.to(device)
pos_items = pos_items.to(device)
neg_items = neg_items.to(device)
loss = model(user_ids, pos_items, neg_items)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
total_loss += loss.item()
num_batches += 1
scheduler.step()
avg_loss = total_loss / num_batches
print(f"Epoch {epoch+1}/{num_epochs} — loss: {avg_loss:.4f}, "
f"lr: {scheduler.get_last_lr()[0]:.6f}")
return model
A few design choices worth calling out:
- InfoNCE loss (also called NT-Xent) outperforms simple triplet loss in my experience. It uses all negatives simultaneously rather than comparing against a single negative, giving the model richer gradient signal per batch.
- Learned temperature lets the model control how peaky the similarity distribution is. A fixed temperature is a hyperparameter you would otherwise have to tune per dataset.
- Gradient clipping is essential. Sparse interaction data produces occasional large gradient spikes, especially early in training when embeddings are random.
- Number of negatives matters a lot. Too few (1-3) and the model does not see enough of the item space per step. Too many (50+) and training slows down without proportional gains. 5-20 is a practical range.
The Training Pipeline
The overall training flow looks like this:
┌──────────────────┐
│ Interaction Log │ Raw events: (user, item, timestamp, event_type)
│ (click, purchase,│
│ view, add-cart) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Sparse Matrix │ scipy.sparse CSR, log-scaled counts,
│ Construction │ filtered by min_interactions
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Positive Pair │ For each user, sample from their
│ Sampling │ observed interactions
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Negative Sampler │ Uniform random from unobserved items
│ (per batch) │ (or popularity-weighted for harder negatives)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Contrastive Loss │ InfoNCE over [pos, neg_1, ..., neg_k]
│ (InfoNCE) │ with learned temperature
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Updated │ L2-normalized user & item embeddings
│ Embeddings │ ready for ANN retrieval
└──────────────────┘
Sequence Modeling
User behavior is inherently sequential, and the order of interactions carries real signal. A user who browsed running shoes, then looked at socks, then viewed a water bottle is in a different state than one who browsed those same items in reverse.
Transformer-based sequence models (SASRec, BERT4Rec) have largely replaced RNNs for this purpose. The self-attention mechanism handles variable-length sequences naturally, and positional encodings capture temporal ordering. In practice, I have found that:
- Sequence length matters less than you expect. Truncating to the last 20-50 interactions works well for most domains. Longer histories add compute cost without proportional accuracy gains.
- Causal masking (SASRec-style) is preferable to bidirectional masking (BERT4Rec-style) when you are serving predictions in real time, because the model architecture matches the inference setting.
- Combining sequence embeddings with the static contrastive embeddings described above — for example, by concatenating and projecting — consistently outperforms either approach alone.
Cold-Start Strategies
New users and new items have no interaction history, so embedding-based methods produce random or near-zero representations. This is the cold-start problem, and it requires explicit handling.
For new items:
- Content-based fallback. Use item metadata (title, category, image features, description embeddings) to initialize the item embedding. A simple approach is to train a regression model that maps content features to the embedding space learned by the contrastive model, then use its predictions as initial embeddings for new items.
- Popularity baseline. Until an item accumulates enough interactions, rank it using global or category-level popularity. This is crude but effective — popular items are popular for a reason, and this at least avoids surfacing random inventory.
- Exploration injection. Reserve a small fraction of traffic (1-5%) for surfacing new items with unknown quality. This gathers signal faster but requires careful calibration to avoid degrading user experience.
For new users:
- Session-based models. Even without historical data, a user’s current session provides signal. A lightweight session encoder (even a simple average of item embeddings viewed in the current session) can produce a reasonable user representation within a few interactions.
- Demographic or contextual priors. Device type, geographic region, referral source, and time of day carry weak but non-zero signal. These features can be combined with a default embedding to produce a better-than-random starting point.
Cold-start is not a problem you solve once. It is a continuous process — every system has new users and new items arriving constantly, and the fraction of cold-start traffic determines the ceiling on your overall system quality.
Evaluation: Offline Metrics vs Online Results
One of the most persistent traps in recommendation systems is optimizing offline metrics that do not predict online performance. I have seen teams spend months improving recall@k on held-out data only to see neutral or negative A/B test results.
Offline metrics that are useful as guardrails:
| Metric | What it measures | Pitfall |
|---|---|---|
| Recall@K | Fraction of relevant items in top-K | Ignores ranking within K |
| NDCG@K | Rank-weighted relevance | Sensitive to what counts as “relevant” |
| MRR | Rank of first relevant item | Only cares about position 1 |
| Hit Rate@K | Whether any relevant item appears in top-K | Binary, no gradient signal for improvement |
Why offline metrics mislead:
- They evaluate on historical data, which reflects the old ranking policy. An embedding model that learns to mimic the old system will score well offline but add nothing online.
- They treat unobserved interactions as negatives. If your new model surfaces a genuinely good item the user has never seen, offline evaluation penalizes this.
- They do not capture engagement dynamics. A user who sees slightly better recommendations may browse longer, creating a feedback loop that offline metrics cannot model.
Online A/B testing is the only reliable measure. Key metrics to track:
- Engagement rate (clicks, add-to-cart, purchases per session) — the primary signal.
- Diversity — are we showing a healthy variety, or has the model collapsed to recommending the same popular items to everyone?
- Coverage — what fraction of the catalog is being recommended to at least one user? Low coverage means long-tail items are invisible.
- Session length and return rate — proxy for user satisfaction beyond immediate clicks.
A practical pattern I have found effective: use offline metrics as a fast filter to discard clearly bad models, then A/B test the top 2-3 candidates. Offline evaluation tells you which models are broken; online evaluation tells you which models are better.
def compute_recall_at_k(model, interaction_matrix, k=20, num_eval_users=5000):
"""
Compute Recall@K on a held-out test set.
Splits each user's interactions into train (80%) and test (20%),
generates top-K predictions from train embeddings, and measures
recall against the test set.
"""
model.eval()
device = next(model.parameters()).device
csr = interaction_matrix.tocsr()
recalls = []
eval_users = np.random.choice(
list(range(csr.shape[0])), size=min(num_eval_users, csr.shape[0]),
replace=False
)
with torch.no_grad():
all_item_embs = F.normalize(
model.item_embeddings.weight, dim=-1
) # (num_items, dim)
for user_id in eval_users:
items = csr[user_id].indices
if len(items) < 5:
continue
# 80/20 split
split = int(0.8 * len(items))
test_items = set(items[split:])
user_emb = F.normalize(
model.user_embeddings(
torch.tensor([user_id], device=device)
), dim=-1
) # (1, dim)
scores = (user_emb @ all_item_embs.T).squeeze(0) # (num_items,)
top_k = scores.topk(k).indices.cpu().numpy()
hits = len(set(top_k) & test_items)
recalls.append(hits / len(test_items))
mean_recall = np.mean(recalls)
print(f"Recall@{k}: {mean_recall:.4f} (evaluated on {len(recalls)} users)")
return mean_recall
Conclusion
Representation learning from sparse behavioral data is fundamentally about making the most of limited, noisy signal. The key ingredients are:
- Sparse matrix infrastructure that scales to production-sized catalogs without blowing up memory.
- Contrastive objectives with enough negatives to give the model a meaningful learning signal per batch.
- Explicit cold-start handling — because the model’s embedding table is useless for entities it has never seen.
- Online evaluation as the final arbiter. Offline metrics are necessary but not sufficient.
The techniques here are not novel individually, but the difference between a research prototype and a production system is almost entirely in how carefully these pieces are integrated — how negatives are sampled, how sparsity is handled, how new items are bootstrapped, and how you measure whether any of it actually works.
Mattia Gaggi is an applied machine learning engineer working on production ML systems.
Last updated 2026-04-13