Home › Machine learning › Recommendation & ranking
algorithm medium

Deep ranking models for recommendation

Problem A feed, shop, store or video site has a few hundred to thousands of retrieved candidates per request and must order them by what this user will do next, using sparse IDs, behaviour history and several kinds of feedback.

Embedding tables plus a feature-interaction net predict several feedback signals per candidate; a weighted sum of them is the ranking score.

O(candidates × (sparse lookups × d + MLP + history length)) per request; memory-bound on embedding lookups
recommendationrankingembedding-tablesmulti-taskdlrmmmoe
Overview

Retrieval (two-tower) leaves a few hundred to thousands of candidates; the ranker may spend real compute on each. It looks up embeddings for sparse IDs (user, item, author, topic), mixes them with dense features, models feature interactions, optionally attends over the user's recent history, and outputs several predictions (click, like, dwell, share, hide). A weighted sum of them is the score; the top few are shown. Same shape: social and video feeds, shop listings, ads, news, music, jobs, app stores.

Key facts

Use when

  • Hundreds or more candidates per request, rich logged feedback, a latency budget for a model per candidate.
  • Feeds (social, video, news), explore pages, shop and app-store listings, ads, music, job boards.

Avoid when

Deep dive

How it works

Parts:

  • embedding tables — one row per hashed ID bucket, per sparse feature (feature hashing); most of the bytes
  • dense tower — numeric features (age of item, counts, ratios) through an MLP into the same width
  • interaction layer — pairwise dot products (DLRM), cross layers (DCN, DCN-V2) or a wide linear part (Wide & Deep)
  • sequence module — target attention over the user's recent events, weighted by the candidate (DIN) or a transformer over the history
  • shared trunk — experts or shared layers; MMoE gives each task its own softmax gate over the experts
  • heads — one sigmoid per feedback type (click, like, dwell > t, share, hide)
  • value combiner — score = Σ wₖ·pₖ, with negative weights for hide or report
  • feature service — user and item features at request time (feature store)
  1. Retrieve candidates (two-tower, social graph, popularity) and attach features: user side once per request, item side per candidate.
  2. Look up one embedding row per sparse feature; pool multi-valued features.
  3. Interact. DLRM stacks the dense vector and all embeddings and takes every pairwise dot product; DCN-V2 uses x_{l+1} = x₀ ⊙ (W x_l + b) + x_l (details).
  4. Attend over history (DIN): u = Σᵢ a(eᵢ, e_cand)·eᵢ with a small net a, so the same history yields a different interest vector for every candidate. This makes the user side per-candidate again.
  5. Predict every head from the trunk (MMoE: task_k = headₖ(Σᵢ softmax(Gₖx)ᵢ · expertᵢ(x))).
  6. Score with Σ wₖ·pₖ, take the top n (top-k selection); a re-ranker adds diversity.
  7. Train on logged impressions: one label per head, cross-entropy summed over heads; downsample negatives (negative sampling) and correct the odds afterwards.
 sparse ids ─hash─► embedding rows ─┐
 history ──► target attention ──────┤
 dense ──► dense MLP ───────────────┴─► interaction ─► shared trunk/experts
                                                           ├─ head click ─► p1 ┐
                                                           ├─ head like  ─► p2 ├─► Σ wk·pk ─► top-n
                                                           └─ head hide  ─► p3 ┘

Design points

  • Value weights. They encode what the product values (share above click, hide negative), are tuned in online experiments, and live in config. Calibrated heads keep them meaningful across retrains.
  • Position bias. Top slots get clicks regardless of quality. Common practice: train with display position as a feature, serve with one default value.
  • Exploration. A greedy ranker never learns about items it does not show; reserve a traffic slice for bandit exploration of new items.
  • Multi-task. Weakly related tasks hurt each other in a fully shared trunk; MMoE lets each task weight the experts itself (Ma et al. 2018).
  • Watch time. Covington et al. (YouTube) train logistic regression with positives weighted by watch time, to predict expected watch time.

Code

DLRM-shaped forward pass (two hashed tables, dense layer, pairwise dots, click and like heads, weighted value) with SGD on the heads and the two touched rows; the dense layer is frozen to keep the backward pass short.

const D: usize = 8; // embedding width
const ND: usize = 4; // dense features
const NB: usize = 1 << 10; // hash buckets per table
const NP: usize = 3; // pairs among 3 vectors (dense, user, topic)
const IN: usize = NP + D; // head input: pair dots ++ dense vector

fn hash(id: u64, salt: u64) -> usize { // multiplicative hash into the table
    ((id ^ salt).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 40) as usize % NB
}
fn sigmoid(z: f32) -> f32 { 1.0 / (1.0 + (-z).exp()) }
fn dot(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).map(|(x, y)| x * y).sum() }

struct Rng(u64);
impl Rng {
    fn next(&mut self) -> f32 { // xorshift64, uniform in [-1, 1)
        self.0 ^= self.0 << 13; self.0 ^= self.0 >> 7; self.0 ^= self.0 << 17;
        (self.0 >> 40) as f32 / (1u64 << 23) as f32 - 1.0
    }
}

struct Model {
    user: Vec<f32>,         // NB x D hashed tables
    topic: Vec<f32>,
    bottom: [[f32; ND]; D], // dense MLP layer (frozen in this sketch)
    head: [[f32; IN + 1]; 2], // heads: click, like (weights + bias)
    value_w: [f32; 2],      // value = 1*p_click + 3*p_like
}

struct Fwd { v: [[f32; D]; 3], x: [f32; IN], p: [f32; 2] }

impl Model {
    fn new(r: &mut Rng) -> Self {
        let mut tab = || (0..NB * D).map(|_| 0.1 * r.next()).collect::<Vec<_>>();
        let (user, topic) = (tab(), tab());
        let mut bottom = [[0.0; ND]; D];
        for row in bottom.iter_mut() { for w in row.iter_mut() { *w = 0.5 * r.next(); } }
        Model { user, topic, bottom, head: [[0.0; IN + 1]; 2], value_w: [1.0, 3.0] }
    }
    fn forward(&self, u: u64, t: u64, dense: &[f32; ND]) -> Fwd {
        let (hu, ht) = (hash(u, 1) * D, hash(t, 2) * D);
        let mut v = [[0.0; D]; 3];
        for i in 0..D { v[0][i] = dot(&self.bottom[i], dense).max(0.0); }
        v[1].copy_from_slice(&self.user[hu..hu + D]);
        v[2].copy_from_slice(&self.topic[ht..ht + D]);
        let mut x = [0.0; IN];
        for (k, (i, j)) in [(0, 1), (0, 2), (1, 2)].into_iter().enumerate() {
            x[k] = dot(&v[i], &v[j]); // pairwise dot interaction
        }
        x[NP..].copy_from_slice(&v[0]);
        let p = [0, 1].map(|h| sigmoid(dot(&self.head[h][..IN], &x) + self.head[h][IN]));
        Fwd { v, x, p }
    }
    fn value(&self, f: &Fwd) -> f32 { dot(&self.value_w, &f.p) }
    fn sgd(&mut self, u: u64, t: u64, dense: &[f32; ND], y: [f32; 2], lr: f32) {
        let f = self.forward(u, t, dense);
        let mut dv = [[0.0f32; D]; 3];
        for h in 0..2 {
            let dz = f.p[h] - y[h]; // d logloss / d logit
            for k in 0..IN {
                let dx = dz * self.head[h][k];
                if k < NP { let (i, j) = [(0, 1), (0, 2), (1, 2)][k];
                    for d in 0..D { dv[i][d] += dx * f.v[j][d]; dv[j][d] += dx * f.v[i][d]; }
                } else { dv[0][k - NP] += dx; }
                self.head[h][k] -= lr * dz * f.x[k];
            }
            self.head[h][IN] -= lr * dz;
        }
        let (hu, ht) = (hash(u, 1) * D, hash(t, 2) * D); // sparse update: two rows only
        for d in 0..D {
            self.user[hu + d] -= lr * dv[1][d];
            self.topic[ht + d] -= lr * dv[2][d];
        }
    }
}

fn fav(u: u64) -> u64 { (u * 3) % 8 } // user u likes topic fav(u)

fn main() {
    let mut r = Rng(0x1234_5678_9ABC_DEF1);
    let mut m = Model::new(&mut r);
    let mut feats = |r: &mut Rng| [r.next(), r.next(), r.next(), r.next()];
    for _ in 0..60_000 { // logged impressions: clicks follow the favourite topic
        let (u, t) = ((r.next().abs() * 16.0) as u64 % 16, (r.next().abs() * 8.0) as u64 % 8);
        let dense = feats(&mut r);
        let click = (t == fav(u)) as u8 as f32;
        m.sgd(u, t, &dense, [click, click * (dense[0] > 0.0) as u8 as f32], 0.05);
    }
    let n = 1000;
    let cands: Vec<(u64, [f32; ND])> = (0..n).map(|i| (i % 8, feats(&mut r))).collect();
    let score = |u: u64| -> Vec<f32> { cands.iter().map(|(t, d)| m.value(&m.forward(u, *t, d))).collect() };
    let s = score(5);
    assert_eq!(s.len(), n as usize);
    assert_eq!(s, score(5)); // deterministic
    let mut order: Vec<usize> = (0..s.len()).collect();
    order.sort_by(|&a, &b| s[b].total_cmp(&s[a]));
    assert!(order[..10].iter().all(|&i| cands[i].0 == fav(5))); // history match ranks first
    let t0 = std::time::Instant::now();
    let reqs = 2000;
    let sink: f32 = (0..reqs).map(|q| score(q % 16)[0]).sum();
    let dt = t0.elapsed().as_secs_f64();
    println!("top value {:.2} (sink {sink:.1}); {:.1} M candidates/s", s[order[0]], (n * reqs) as f64 / dt / 1e6);
}

Run on an Apple M4 Pro (measured): the asserts pass (the ten best candidates of user 5 all have the user's favourite topic) and scoring ran at 50-55 M candidates/s on one thread.

Complexity and constants

  • Per candidate: s sparse lookups of d floats (4·s·d bytes; 20 × 64 × 4 ≈ 5 KB, each row a likely cache miss), s(s+1)/2 dots of length d, one MLP; DIN adds L activation-net evaluations for history length L.
  • Memory: rows × d × 4 bytes; 10⁹ rows at d = 64 ≈ 256 GB in fp32, so tables are sharded.
  • Training updates only the touched rows (embeddings).

Efficiency

  • Cost: the toy above (tables 32 KB each, cache-resident) measured about 18-20 ns per candidate on one M4 Pro core. That is a floor: real tables do not fit cache, so 20 random 256-byte row reads per candidate are bound by memory latency and bandwidth. Per request: candidates × per-candidate cost, plus the feature fetches.
  • Levers:
    • compute user-side features and user-only layers once per request — saves their share of per-candidate work (arithmetic) — N ≫ 1 candidates
    • score candidates as one matrix batch — lets GEMM and GPU run efficiently (practice) — hundreds of candidates
    • fp16 or int8 table rows — 2-4× fewer bytes (arithmetic) — memory-bound tables (quantization); recheck calibration
    • cache hot rows, shard the rest — few rows take most lookups (heavy-tailed IDs) — embedding-table-sharding
    • cascade: a light model cuts N before the heavy one — cost ∝ N at each stage (practice) — N in the thousands
  • Naive → standard → state of the art:
    • features fetched per candidate over the network, scored one by one · a round trip per candidate
    • batched candidates, user features once, sharded tables, shared multi-task trunk · lookup-bound
    • target attention over retrieved history events, quantized and cached rows, GPU serving · lookup + attention bound

Implementation notes

  • Hash with a per-table salt; send rare IDs to one shared bucket.
  • Row-major tables, d a multiple of 16 floats (one cache line); batch a request's lookups so misses overlap.
  • Start heads at zero; set the bias to the log-odds of the positive rate.
  • Log the features used at serving and train on those; recomputing them later causes train-serve skew.

Pitfalls

  • Training on shown impressions only teaches the old ranker's taste; keep a randomised slice.
  • Click-only value amplifies clickbait; add dwell, share and negative feedback.
  • Uncalibrated heads: after a retrain the scale shifts and the effective weights move.
  • Hash collisions on frequent IDs: give them their own rows.
  • Late labels (dwell, purchase) counted as negatives bias probabilities down (CTR models).

Variants and alternatives

In production

  • Video: YouTube's two-stage system, a deep candidate generator and a deep ranker (Covington et al.).
  • App store: Wide & Deep is deployed in Google Play (paper).
  • Display ads and e-commerce: DIN serves the main traffic of Alibaba's display advertising system (paper).
  • Social feeds, explore pages, news, music and job boards use the same pattern (no per-company paper cited here).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
DLRMPython (PyTorch)facebookresearch/dlrmgit clone and run dlrm_s_pytorch.py————reference model and benchmark
TorchRecPython (PyTorch)pytorch/torchrecpip install torchrec————sharded embedding tables, training
NVIDIA MerlinPython / C++NVIDIA-Merlin/Merlinsee repo (containers)————GPU recsys stack (HugeCTR, Models)
TensorFlow RecommendersPython (TF)tensorflow/recommenderspip install tensorflow-recommenders————retrieval and ranking tasks, multi-task examples
DeepRecC++ / Pythonalibaba/DeepRecsee repo————sparse-model training and serving

Sources

  • Covington, Adams, Sargin, Deep Neural Networks for YouTube Recommendations, RecSys 2016. DOI
  • Cheng et al., Wide & Deep Learning for Recommender Systems, DLRS 2016. arXiv
  • Naumov et al., Deep Learning Recommendation Model for Personalization and Recommendation Systems, 2019. arXiv · code
  • Wang et al., Deep & Cross Network for Ad Click Predictions, ADKDD 2017. DOI
  • Wang et al., DCN V2, WWW 2021. arXiv
  • Zhou et al., Deep Interest Network for Click-Through Rate Prediction, KDD 2018. arXiv
  • Ma et al., Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts, KDD 2018. DOI

Builds on

Used by

Systems (2):

Alternatives and related

Matrix factorization for collaborative filteringA service holds a huge, mostly empty table of who interacted with what (ratings, plays, clicks, applications) and must predict the missing cells to suggest items or fill gaps, without any item descriptions.Two-tower retrievalA service with millions of items (products, videos, documents, ads) must pick a few hundred plausible ones for each user or query within milliseconds, so a costlier model can rank them.Click-probability prediction (CTR models)Ads, feeds, notifications, shops, app stores and job boards must rank or price items by the chance a user acts on them (click, install, buy, apply), from huge sparse categorical logs. The number must be a true probability, because bids and values multiply it.Learning to rankSearch, feeds, ads and product listings show a short ordered list per request, chosen from hundreds of candidates. The best items must sit at the top, but the only labels are graded judgements or position-biased clicks.Multi-armed banditsA team must repeatedly pick one of several options (page variant, ad, dose, backend, parameter setting) and only sees the result of the option it picked. It wants high total reward while it is still learning which option is best.Feature storeTeams that train models on logged data and serve them live compute the same inputs twice, in different code and at different times, so served values drift from training values, future data leaks into labels, and work is duplicated.Attention variantsTeams serving large sequence models find that memory for cached past context limits how many requests they can handle and how long a context they can support. They must choose an attention design that balances quality against that memory.CalibrationA trained classifier's confidence scores are over- or under-confident, so a "90 %" prediction is right far less or more than 90 % of the time. This matters when probabilities drive thresholds, costs or search decisions.Negative samplingA model must learn which of millions of words, items or entities go with an input, but scoring every candidate per training example is too slow, and the logs only record positives.Top-k selectionQueries like "top 10 by score", leaderboards and nearest-neighbour candidate lists need only the best few of many items, from arrays or streams, without paying to sort everything.Weight quantizationRunning a large language model for one user at a time is slow and needs a lot of GPU memory, because every weight must be read for each generated token. Engineers want faster, smaller deployments.