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:

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:

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:

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:

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:

For new users:

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:

Online A/B testing is the only reliable measure. Key metrics to track:

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:

  1. Sparse matrix infrastructure that scales to production-sized catalogs without blowing up memory.
  2. Contrastive objectives with enough negatives to give the model a meaningful learning signal per batch.
  3. Explicit cold-start handling — because the model’s embedding table is useless for entities it has never seen.
  4. 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.

3.4k Views
3.0k Readers

Last updated 2026-04-13

← Back to all writing