ml-models / Retrieval and recommendation / Learning to rank core

Learning to rank

Search, recommendations and retrieval-augmented generation all end the same way: a list of candidates has to be put in order, and only the top few will be looked at. This page explains why regression and classification losses are the wrong target for that, defines NDCG by hand on a five-document query, derives the pairwise lambda gradient that XGBoost's rank:ndcg objective (LambdaMART) feeds to its trees, and compares it with RankNet and ListNet.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. DCG and NDCG
  5. RankNet: a pairwise logistic loss
  6. LambdaRank: scale each pair by |ΔNDCG|
  7. What XGBoost adds
  8. ListNet: a listwise cross-entropy
  9. Worked example
  10. NDCG by hand
  11. The lambdas
  12. A few Newton steps
  13. Implementation
  14. Tradeoffs
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

A user types a query and a first-stage retriever (BM25, or a vector index) returns a few hundred candidate documents. For each query-document pair we have features (text match scores, embedding similarity, freshness, click history) and, for training, a graded relevance label from human judges or clicks: 0 = irrelevant up to, say, 3 = perfect. We want a function f(features) → score such that sorting by score puts the most relevant documents first.

The obvious move is regression: predict the label, minimize squared error. But the score is only ever used to sort within one query. Its absolute value doesn't matter, scores from different queries are never compared, and an error at position 1 costs far more than the same error at position 80 that nobody scrolls to. A loss that ignores all three can prefer the worse ranker. Take three documents with labels 2, 1, 0:

ModelScoresMSE vs labelsOrderNDCG
A1.1, 1.4, 0.00.3232nd-best document first0.797
B10.0, 5.0, 0.026.667exactly right1.000

Squared error prefers A by a factor of 82; any ranking metric prefers B, which is perfect. Learning to rank (LTR) is the family of methods that optimize the ordering directly.

Intuition

There are three ways to turn "get the order right" into a loss, named by how many documents each loss term looks at:

  • Pointwise: one document at a time. Regress the label or classify relevant vs not. Simple and often a decent baseline, but it spends capacity on calibrating values the sort throws away.
  • Pairwise: two documents from the same query at a time. For each pair with different labels, push the more relevant one's score above the other's. Scores become relative, which is what sorting needs. RankNet is the classic form: a logistic loss on the score difference.
  • Listwise: the whole list at once. Either define a probability distribution over the list and match it to the labels' (ListNet), or optimize a list metric like NDCG.

The difficulty is that list metrics are step functions of the scores: nudging a score changes nothing until two documents swap, and then the metric jumps. Their gradient is zero almost everywhere. The trick behind LambdaRank and LambdaMART is to skip the loss and write down the gradient directly: take RankNet's pairwise gradient and multiply it by how much NDCG would change if the two documents swapped places. Pairs whose swap matters near the top get big gradients; pairs shuffling around at the bottom get almost none. Handing those gradients to gradient-boosted trees gives LambdaMART, which is what XGBoost's default ranking objective rank:ndcg implements.

What one loss term looks at: pointwise, pairwise, listwise Three panels, each showing the same five documents of one query. Pointwise: each document is compared on its own with its label, five separate terms. Pairwise: a term compares two documents with different labels and asks the higher-labelled one to score higher. Listwise: one term takes the whole list, turns scores and labels into softmax distributions and compares them. pointwise d1 · s=0.2 label 3 d2 · s=0.6 label 2 d3 · s=0.9 label 0 d4 · s=-0.4 label 1 d5 · s=0.1 label 0 one term per document pairwise d1 · s=0.2 d2 · s=0.6 d3 · s=0.9 d4 · s=-0.4 d5 · s=0.1 s_d1 > s_d3 ? one term per pair, labels 3 vs 0 listwise d1 · s=0.2 d2 · s=0.6 d3 · s=0.9 d4 · s=-0.4 d5 · s=0.1 softmax(s) vs softmax(y) one term for the whole list
Lambda weights on the pairs of one ranked list The five example documents in the order the model currently ranks them: d3 (label 0), d2 (label 2), d1 (label 3), d5 (label 0), d4 (label 1). Arcs connect every pair with different labels. Each arc is labelled with the change in NDCG if the pair swapped places. The arc between d1 and d3, which would move the label-3 document from rank 3 to rank 1, carries 0.373; the pair at ranks 4 and 5 carries only 0.005. On the right, the resulting per-document lambda gradient: negative means the score should go up. rank λ gradient (−: push up) 1 d3 · label 0 · s=0.9 +0.368 2 d2 · label 2 · s=0.6 -0.072 3 d1 · label 3 · s=0.2 -0.333 4 d5 · label 0 · s=0.1 +0.052 5 d4 · label 1 · s=-0.4 -0.015 d2 ↔ d3: |Δ| 0.118 d1 ↔ d3: |Δ| 0.373 arc width ∝ |ΔNDCG| of swapping the pair; dashed arcs have |Δ| < 0.1

Mechanics

DCG and NDCG

Discounted cumulative gain scores a ranked list of labels y1, …, yn (position 1 at the top) by summing each document's gain times a discount for its position:

DCG@k  = Σ_{i=1..k} gain(y_i) · 1 / log2(i + 1)
gain(y) = 2^y − 1      (exponential gain, XGBoost's default)
        = y            (linear gain, scikit-learn's ndcg_score)
NDCG@k = DCG@k / IDCG@k          IDCG = DCG of the labels sorted best-first

The discount falls fast at first (1, 0.63, 0.5, 0.43, …) then slowly, so the top few positions dominate. Exponential gain makes a label-3 document worth 7 label-1 documents, which matches how much more a "perfect" answer is worth than a marginal one. Dividing by IDCG puts every query on a 0 to 1 scale so queries with many relevant documents don't dominate the average. XGBoost computes the gain as a bit shift (CalcDCGGain), which is why labels are capped at 31, and the discount with 0-based ranks as 1/log2(idx + 2) (CalcDCGDiscount). scikit-learn's _dcg_sample_scores uses the label itself as gain, and by default averages the gains of tied scores (_tie_averaged_dcg) so the metric doesn't depend on how a sort breaks ties.

RankNet: a pairwise logistic loss

For a pair where document h has a higher label than l, model the probability that h should rank above l as σ(sh − sl) and minimize its negative log: L = log(1 + exp(−(s_h − s_l))). The gradient with respect to sh is σ(sh − sl) − 1, always negative (raise h), and the opposite for l. It shrinks to 0 as the pair becomes confidently ordered. Each document's total gradient is the sum over every pair it is in.

LambdaRank: scale each pair by |ΔNDCG|

RankNet treats every misordered pair alike. LambdaRank multiplies each pair's gradient by |ΔNDCGhl|, the change in NDCG from swapping the two documents' positions in the current ranking, and does not define the loss at all:

λ_hl   = (σ(s_h − s_l) − 1) · |ΔNDCG_hl|          added to h's gradient, subtracted from l's
hess_hl = 2 · σ(1 − σ) · |ΔNDCG_hl|                added to both
ΔNDCG_hl = (gain_h − gain_l) · (disc[rank_h] − disc[rank_l]) / IDCG

That is XGBoost's LambdaGrad: equal labels give zero, the metric delta comes from DeltaNDCG, and the gradient and hessian are at lines 132-133. Repulse flips the sign for the lower document. No loss function is written down anywhere: the gradients are the definition.

LambdaMART is LambdaRank's gradients plugged into gradient-boosted regression trees (MART = multiple additive regression trees). Each boosting round computes λ and the hessian for every document in every query, then fits a tree with the usual Newton leaf values −Σg/Σh. The trees see per-document targets like any regression problem; all the ranking knowledge is in how the targets were computed.

What XGBoost adds

  • Pair selection. With the default lambdarank_pair_method="topk", every document in the top k (default 32) is paired with every document below it (MakePairs); the alternative "mean" samples lambdarank_num_pair_per_sample pairs per document.
  • Score normalization (lambdarank_score_normalization, default on): |Δ| is divided by |sh − sl| + 0.01 (lines 124-126), so pairs the model already separates widely push less.
  • Gradient normalization (lambdarank_normalization, default on): for top-k the query's gradients are scaled by log2(1 + Σλ)/Σλ, for mean by 1/(pairs per sample) (CalcLambdaForGroup), which keeps queries with many pairs from dominating.
  • Other objectives. rank:pairwise is plain RankNet (|Δ| = 1) and rank:map uses ΔMAP (DeltaMAP) for binary labels; all three are registered at lines 662-672.
  • Position debiasing. lambdarank_unbiased=True learns how much click labels are biased by the position the document was shown at, and divides it out (Unbiased LambdaMART).

ListNet: a listwise cross-entropy

ListNet turns both the scores and the labels into a probability for each document of being ranked first, via softmax, and minimizes the cross-entropy between them: L = −Σ softmax(y)_i · log softmax(s)_i. It is smooth and cheap (linear in the list length, no pairs), and works naturally with neural rankers trained by backprop. It doesn't target a cutoff like NDCG@10 the way lambdas do.

ComponentShape/ParamsMeaningSource
qid [n_samples], sorted Which query each row belongs to. Gradients and NDCG are computed per group; rows must be sorted by qid. XGBRanker
label [n_samples], integers 0..31 Graded relevance; gain 2y − 1 with the default ndcg_exp_gain=True. MaxRel, CalcDCGGain
objective "rank:ndcg" (default), "rank:pairwise", "rank:map" Which |Δmetric| scales each pair's gradient. lambdarank_obj.cc
lambdarank_pair_method, lambdarank_num_pair_per_sample "topk", k = 32 by default Which pairs contribute: everything below each of the top k, or sampled pairs per document. LambdaRankParam
λ, hessian one pair per (higher-label, lower-label) couple (σ − 1)|Δ| and 2σ(1 − σ)|Δ|, added to the higher document and repelled from the lower. LambdaGrad, Repulse
eval_metric ndcg@32 by default Reported per query and averaged; with top-k pairing the default metric's cutoff is the pair k. RankEvalMetric
ndcg_score y_true, y_score [n_queries, n_docs]; k, ignore_ties scikit-learn's NDCG: linear gain, tie-averaged, one row per query. _ranking.py

Worked example

One query, five documents. The labels and the current model scores are:

d1d2d3d4d5
Label32010
Score0.20.60.9-0.40.1

NDCG by hand

Sorting by score gives the order d3, d2, d1, d5, d4:

RankDocLabelGain 2y−1DiscountGain × discountIdeal labelIdeal gain × discount
1d300 1.0000.000 37.000
2d223 0.6311.893 21.893
3d137 0.5003.500 10.500
4d500 0.4310.000 00.000
5d411 0.3870.387 00.000
SumDCG 5.780IDCG 9.393

NDCG = 5.780 / 9.393 = 0.6153. The best document is stuck at rank 3 behind an irrelevant one. With linear gain (scikit-learn's convention) the same ranking scores 0.6612: the label-3 document matters less, so misplacing it costs less. NDCG@3, which ignores ranks 4 and 5, is 0.5741. The same scores give three different numbers, so always say which gain and cutoff a reported NDCG uses.

The lambdas

Nine of the ten pairs have different labels (d3 and d5 are both 0 and are skipped). For each, ΔNDCG of the swap and the resulting λ, then each document's total:

Higher labelLower labelRanks now|ΔNDCG|LambdaRank λRankNet λ
d2 (2)d3 (0) 2 vs 1 0.1179 -0.0677-0.5744
d1 (3)d3 (0) 3 vs 1 0.3726 -0.2490-0.6682
d4 (1)d3 (0) 5 vs 1 0.0653 -0.0513-0.7858
d1 (3)d2 (2) 3 vs 2 0.0558 -0.0334-0.5987
d2 (2)d5 (0) 2 vs 4 0.0640 -0.0241-0.3775
d2 (2)d4 (1) 2 vs 5 0.0520 -0.0140-0.2689
d1 (3)d5 (0) 3 vs 4 0.0517 -0.0245-0.4750
d1 (3)d4 (1) 3 vs 5 0.0723 -0.0256-0.3543
d4 (1)d5 (0) 5 vs 4 0.0047 -0.0029-0.6225
Per-document gradientd1d2d3d4d5
LambdaRank-0.333-0.072+0.368-0.015+0.052
RankNet-2.096-0.622+2.028-0.785+1.475

Both sum to zero over the query: pairwise gradients only move documents relative to each other. The lambdas pour almost everything into the top of the list: d1 gets -0.333 and d3, the irrelevant document at rank 1, gets +0.368, mostly from the one swap worth 0.373 NDCG. d5, irrelevant but already at rank 4, gets only +0.052. RankNet's gradients are larger everywhere and spread out: it pushes d5 down with +1.475, about three quarters as hard as d3 (+2.028), because every misordered pair counts the same to it.

A few Newton steps

Treating the five scores as free parameters and applying the update s ← s − 0.5 · g / h per document (what a boosting round does with one leaf per document) gives:

StepLambdaRank scores (d1, d2, d3, d4, d5)NDCGRankNet scoresNDCG
0 0.20, 0.60, 0.90, -0.40, 0.100.6153 0.20, 0.60, 0.90, -0.40, 0.100.6153
1 0.86, 0.87, 0.15, -0.31, -0.340.8354 0.76, 0.77, 0.10, -0.16, -0.410.8354
2 1.26, 0.96, -0.23, -0.58, -0.670.9926 1.15, 0.87, -0.35, -0.04, -0.791.0000
3 1.58, 0.81, -0.55, -0.82, -0.960.9926 1.51, 0.95, -0.71, 0.03, -1.121.0000
4 1.88, 0.71, -0.85, -1.03, -1.240.9926 1.85, 1.02, -1.04, 0.06, -1.421.0000
5 2.16, 0.64, -1.14, -1.21, -1.520.9926 2.17, 1.09, -1.35, 0.06, -1.711.0000

Both fix the top two within two steps. After that the lambdas stop caring: d3 (label 0) stays just above d4 (label 1) at ranks 3 and 4, a swap worth only 0.0074 NDCG, while the effort goes into widening the margin of the top document. RankNet does reach a perfect order here, because it pushes every misordered pair equally. That is the trade: lambdas concentrate on the positions a user sees, which is the point when only the first page is evaluated, at the cost of caring little about the tail. The listwise ListNet loss for the starting scores is 1.725, against 1.057 if the scores equalled the labels.

Implementation

With XGBoost, a ranker is a regressor plus query ids. Rows must be grouped and sorted by qid; the tutorial sorts them first:

import numpy as np
import xgboost as xgb

# X: [n, n_features], y: graded labels 0..4, qid: query id per row
order = np.argsort(qid, kind="stable")
X, y, qid = X[order], y[order], qid[order]

ranker = xgb.XGBRanker(
    objective="rank:ndcg",              # LambdaMART; "rank:pairwise" is RankNet
    lambdarank_pair_method="topk",      # pair each of the top k with everything below
    lambdarank_num_pair_per_sample=10,  # k: match it to the cutoff you care about
    eval_metric="ndcg@10",
    n_estimators=500, learning_rate=0.05, max_depth=6,
    early_stopping_rounds=50,
)
ranker.fit(X, y, qid=qid, eval_set=[(X_val, y_val)], eval_qid=[qid_val])

scores = ranker.predict(X_test)      # only meaningful for ordering within a query

Evaluating with scikit-learn's ndcg_score needs one row per query (pad or loop), and it uses linear gain, so its number will differ from XGBoost's ndcg@10 on the same predictions. The XGBoost tutorial notes that scikit-learn's metrics don't know about query groups. XGBRanker.score does: it takes a DataFrame with a qid column and returns the model's last evaluation metric.

In a neural ranker (a cross-encoder scoring query-document pairs), the same losses are a few lines over a [queries, docs] score tensor: RankNet is softplus(-(s_i - s_j)) over masked pairs, ListNet is a cross-entropy between softmax(labels) and log_softmax(scores), and a LambdaRank weight is a detached |ΔNDCG| tensor multiplying the RankNet term.

Tradeoffs

  • Pointwise vs pairwise vs listwise. Pointwise is easiest and lets you reuse classifiers, but optimizes the wrong thing. Pairwise costs up to O(n²) pairs per query; top-k pairing caps it at about k·n. Listwise matches the metric best but needs whole lists per batch.
  • The cutoff k. Set the pair k and the eval cutoff to the number of results users actually see. A small k ignores the tail entirely, as the worked example shows.
  • Gain. Exponential gain heavily rewards the best labels; linear gain is gentler. Pick one, and report it with the number.
  • Labels. Human judgments are expensive; clicks are cheap but biased toward what was already shown at the top. Unbiased LambdaMART or randomized interleaving corrects for that, at the cost of more modeling.
  • Trees vs neural rankers. LambdaMART on hand-built features is still a strong, fast, cheap baseline. Cross-encoders read the text directly and win on semantic matching, but cost a transformer pass per query-document pair, so they rerank only the top few dozen from a cheaper stage.
  • Grouping leaks. Splits must keep each query wholly in train or test (see data leakage); a query split across both lets the model memorize it.
Why can't you just minimize NDCG directly with gradient descent?

NDCG depends on the scores only through their sort order, so it is piecewise constant: its gradient is zero everywhere except at ties, where it is undefined. Lambdas supply a usable gradient instead.

In the example, why does the d1–d3 pair get the largest lambda?

Swapping them moves the label-3 document (gain 7) from rank 3 to rank 1 and the irrelevant one down, a change of 0.3726 NDCG, more than any other single swap.

Adding 100 to every score for one query: what happens to its NDCG and its lambdas?

Nothing. Both depend only on the order and on score differences within the query.

Why do the per-document gradients of a query sum to zero?

Every pair adds λ to one document and −λ to the other (Repulse), so the contributions cancel in total.

Connections

Read first

Read next

No page builds on this one yet.

References

  • Burges et al. Learning to Rank using Gradient Descent. ICML, 2005. (RankNet.)
  • Burges, Ragno and Le. Learning to Rank with Nonsmooth Cost Functions. NeurIPS, 2006. (LambdaRank.)
  • Cao, Qin, Liu, Tsai and Li. Learning to Rank: From Pairwise Approach to Listwise Approach. ICML, 2007. (ListNet.)
  • Burges. From RankNet to LambdaRank to LambdaMART: An Overview. Microsoft Research Technical Report MSR-TR-2010-82, 2010.
  • Järvelin and Kekäläinen. Cumulated Gain-Based Evaluation of IR Techniques. ACM TOIS, 2002. (DCG and NDCG.)
  • Hu, Wang, Peng and Li. Unbiased LambdaMART: An Unbiased Pairwise Learning-to-Rank Algorithm. WWW, 2019.
  • XGBoost v3.4.2: lambdarank_obj.h, CalcLambdaForGroup, ranking_utils.h, learning-to-rank tutorial.
  • scikit-learn 1.9.1: dcg_score, ndcg_score.