Learning to Rank (LTR)

ML-based ranking with LambdaMART, feature engineering, and evaluation metrics

advanced50 min
On this page

The Big Picture

Ranking is the core problem in search engines, recommendation systems, and information retrieval. Given a query and a large corpus of documents, the goal is to order documents by relevance so the most useful results appear first.

From Manual to Learned Ranking

Manual Ranking (BM25): Traditional retrieval uses term-matching heuristics like BM25 (see BM25 & Inverted Index Guide). BM25 computes a static relevance score for each query-document pair based on term frequency and inverse document frequency. Simple and fast, but ignores semantic context and cannot adapt to different query intents.

Learned Ranking (LTR): Learning to Rank replaces manual heuristics with a learned function trained on labeled data. The system learns which signals (features) are predictive of relevance and how to combine them optimally.

The LTR Training Paradigm

LTR requires three components:

  • Training Data: Query-document pairs labeled with relevance (0 = not relevant, 1 = relevant, 2 = highly relevant, etc.). Labels come from human judgments or click signals.
  • Feature Extraction: For each query-document pair, compute numerical features (BM25 score, document length, embedding similarity, etc.).
  • Ranking Function: A model (tree, neural network, linear) that maps features to a relevance score. Documents are ranked by this score.

Three Paradigms

Pointwise

Treat each document independently. Predict relevance score for each doc. Ignore relative ordering.

Simple Regression

Pairwise

Learn from document pairs. Which doc is more relevant? Optimize pair accuracy.

RankNet LambdaRank

Listwise

Optimize the entire ranking list directly. Approximate ranking metrics like NDCG.

ListMLE SoftRank

The Ranking Pipeline

Ranking Pipeline
1. Retrieval BM25 / Dense Embeddings 2. Reranking (LTR) LambdaMART or BERT 3. Business Logic Diversity, CTR Business Rules 4. Final Results Top-K Ranked Documents LTR reranks top-1000 from retrieval to top-10 for display
Why LTR works: A single ranking function learned on task-specific data beats hand-tuned heuristics. The model learns which features matter for your specific search domain (e-commerce, medical, news, etc.).

Features for Learning to Rank

The quality of an LTR system depends critically on the features used. Features are the "language" the ranking model uses to understand query-document relevance.

Categories of Features

Query Features

  • Query length: Number of terms in the query.
  • Query type: Is it a navigational query (looking for a specific site), informational (research), or transactional (ready to buy)? Encoded as a categorical feature.
  • Reformulation rate: How often this query is reformulated by users (proxy for query clarity).
  • Query language: Categorical: English, Spanish, etc.

Document Features

  • BM25 score: Classic term-matching score. See BM25 & Inverted Index Guide.
  • TF-IDF: Term frequency times inverse document frequency.
  • Document length: Number of terms. Longer documents may have more relevant content but are also noisier.
  • PageRank: Graph centrality score. Pages linked from many sources are often more authoritative.
  • Document age: Freshness. Recent documents may be more relevant for current events.

Query-Document Features

  • Exact match: Does the document contain the exact query phrase? Binary feature.
  • BM25(query, document): Direct term matching between query and document.
  • Embedding similarity: Cosine similarity between query embedding and document embedding. Captures semantic similarity.
  • Anchor text match: How many incoming links have anchor text matching the query?
  • Title match: Does the document title contain query terms?

Context Features

  • Position in initial ranking: Documents retrieved higher are often more relevant (retrieval bias).
  • Time of day: Query intent varies by time. Morning = informational, evening = entertainment.
  • User history: CTR of documents similar to this one for the same user in the past.
  • Device type: Mobile vs. desktop queries may have different relevant documents.

Feature Normalization

Features have different scales (BM25 ≈ 0–100, embedding similarity ≈ 0–1). Normalization ensures each feature contributes fairly to the model:

Method Formula Use Case
Min-Max x' = (x - min) / (max - min) Bounds features to [0, 1]. Use when you know the range.
Z-Score x' = (x - μ) / σ Centers and scales. Handles outliers better.
Rank-Based x' = rank(x) / N Monotonic transformation. Robust to outliers. Popular in LTR.

Feature Importance Analysis

Once trained, which features does the model rely on most? Use SHAP values (for tree models like LambdaMART) or attention weights (for neural models) to understand feature importance.

Feature engineering is crucial: A good set of features + simple model beats a complex model with poor features. Spend time understanding your domain and creating features that correlate with relevance.

Example Feature Vector

For the query "python machine learning tutorial" and a document about scikit-learn:

Feature Value Normalized
BM25 Score 42.5 0.85
Embedding Similarity 0.78 0.78
Document Length (terms) 1250 0.63
Title Match 1.0 (yes) 1.0
PageRank 8.2 0.72
Exact Match 0.0 (no) 0.0

Evaluation Metrics

How do you measure if ranking is good? Ranking metrics account for position: top positions matter more. A perfect ranking at position 10 is worse than a perfect ranking at position 1.

Normalized Discounted Cumulative Gain (NDCG)

NDCG is the most important LTR metric. It measures ranking quality considering both relevance and position.

DCG@k = Σ(i=1 to k) [ rel_i / log₂(i+1) ]

Where rel_i is the relevance label (0, 1, 2, 3, ...) of the document at position i.

IDCG@k = DCG of the ideal ranking (all most-relevant documents at top).

NDCG@k = DCG@k / IDCG@k. Normalized to [0, 1], where 1.0 is perfect.

Worked Example: Computing NDCG@3

Query: "how to bake bread"

Relevance labels: 0 = not relevant, 1 = somewhat relevant, 2 = highly relevant

NDCG Computation Example
System A (Your Model) Pos 1: rel=2 Pos 2: rel=0 Pos 3: rel=1 DCG@3 = 2/log₂(2) + 0/log₂(3) + 1/log₂(4) = 2/1 + 0/1.58 + 1/2 = 2.0 + 0 + 0.5 = 2.5 Ideal Ranking Pos 1: rel=2 Pos 2: rel=2 Pos 3: rel=1 IDCG@3 = 2/1 + 2/1.58 + 1/2 = 4.27 NDCG@3 = 2.5 / 4.27 = 0.585

Other Ranking Metrics

Metric Definition When to Use
MRR (Mean Reciprocal Rank) 1 / (position of first relevant). For finding one key result (e.g., "where is NYC?"). Navigational queries. Simple, but ignores multiple relevant results.
MAP (Mean Avg. Precision) Average precision across all positions where a relevant doc appears. Older metric. Less position-sensitive than NDCG.
ERR (Expected Reciprocal Rank) Models cascaded user behavior: user clicks until satisfied. More realistic than DCG. When clicks are available. Better reflects user satisfaction.
Precision@K Fraction of top-K results that are relevant. Simple cutoff. Doesn't reward ranking order.
Recall@K Fraction of all relevant docs in top-K. Coverage. Less useful for ranking.

Choosing K

Should you report NDCG@5 or NDCG@10? It depends on your use case:

  • K=5: Mobile search, where users see few results per screen.
  • K=10: Desktop search, standard benchmark.
  • K=1: Highly focused tasks (e.g., "best restaurant near me").
LTR models are trained to optimize NDCG (listwise approaches) or approximations of it (LambdaRank). Use the same metric for training and evaluation to ensure consistency.

Pointwise Approaches

Pointwise methods treat ranking as a classification or regression problem on individual documents, ignoring their relative ordering.

Pointwise as Regression

Predict the relevance score of each document independently:

  • Train a regression model (linear regression, neural network) on labeled query-document pairs.
  • Loss: Mean Squared Error (MSE) = (y_true - y_pred)²
  • At ranking time, score all candidate documents and rank by predicted score.

Pointwise as Classification

Treat relevance as a discrete label:

  • Binary: Relevant (1) vs. Not Relevant (0). Loss: Binary cross-entropy.
  • Multi-class: Graded (0 = bad, 1 = fair, 2 = good, 3 = excellent). Loss: Cross-entropy.
The Pointwise Problem: Minimizing document-level loss does not guarantee good rankings. A model might rank an irrelevant document above a relevant one if both losses are small. Pointwise ignores relative ordering completely.

Comparison with Ranking Objectives

Pointwise vs. Ranking Objectives
Pointwise: Document-Level Loss Train on individual docs Loss(doc1): (rel_true - pred)² Loss(doc2): (rel_true - pred)² Ignores which doc ranks higher Ranking-Aware: Ordering Loss Pairwise: Rank doc1 > doc2 Listwise: Optimize full ranking Loss: Pair accuracy or NDCG Directly optimizes ranking order Example: Ranking [doc_A (rel=1), doc_B (rel=2)] Pointwise: Might give same loss if both predicted well individually Pairwise/Listwise: Penalizes wrong order (doc_B should rank above doc_A)

When to Use Pointwise

Pointwise methods are rarely used for production ranking, but they're useful:

  • As a baseline or sanity check.
  • When you have massive data but limited compute (simple models train fast).
  • For warm-start: pre-train a pointwise model, then fine-tune with pairwise/listwise.

For a comparison with logistic regression, see Logistic Regression Guide.

Recommendation: Skip pointwise for serious ranking tasks. Go straight to pairwise (RankNet, LambdaRank) or listwise (ListMLE). They're not much harder to implement and dramatically better in practice.

Pairwise Approaches: RankNet & LambdaRank

Pairwise methods learn from document pairs: which document is more relevant? This directly optimizes relative ordering.

RankNet

Core idea: For each query, compare pairs of documents. For each pair (doc_i, doc_j):

P(doc_i ranked above doc_j | features) = σ(s_i - s_j)

where σ is the sigmoid function and s_i, s_j are model scores.

Loss for a pair: Binary cross-entropy on pairwise preference.

  • If doc_i is more relevant than doc_j (rel_i > rel_j), we want P(doc_i > doc_j) = 1.
  • Loss = -[y_ij * log(P) + (1 - y_ij) * log(1 - P)]
  • where y_ij = 1 if rel_i > rel_j, else 0.

Pair Sampling

For n documents per query, there are O(n²) pairs. Sampling strategies:

Strategy Cost Trade-offs
All pairs: Compare every pair. O(n²) per query Expensive but comprehensive. Use for small candidate sets.
Random pairs: Sample k random pairs. O(k) per query Fast. May miss important pairs. Good for large sets.
Contrastive pairs: Sample pairs with different labels (rel_i ≠ rel_j). O(k) per query Balances signal. Ignores same-label pairs (which still matter).

LambdaRank: Incorporating Ranking Metrics

The insight: Not all pairs matter equally. Swapping doc_A and doc_B changes NDCG differently than swapping doc_K and doc_L.

Lambda: A weighting factor for each pair gradient proportional to the NDCG change from swapping them.

λ_ij = (∂C/∂s_i) × |ΔNDCG_ij|

where ΔNDCG_ij = change in NDCG if docs i and j swap positions.

Algorithm:

  1. Compute pairwise RankNet gradients.
  2. For each pair, multiply gradient by the NDCG change from swapping them.
  3. Pairs that change NDCG more get higher gradient weight.
  4. Update model in gradient direction.
LambdaRank: Weighted Pair Gradients
Current Ranking Pos 1 (rel=3) Pos 2 (rel=1) Pos 3 (rel=0) Swap Pos 1 & 2: Pos 1 (rel=1) Pos 2 (rel=3) Pos 3 (rel=0) ΔNDCG = -0.8 (bad swap) λ = high weight! Swap Pos 2 & 3: Pos 1 (rel=3) Pos 2 (rel=0) Pos 3 (rel=1) ΔNDCG = -0.1 (minor) λ = low weight Pairs with high NDCG impact get stronger gradient signals
Why LambdaRank: It directly optimizes ranking quality by weighting gradient updates by their impact on the metric. This is why LambdaRank is more effective than vanilla RankNet.

Implementation Notes

LambdaRank is typically implemented inside a Gradient Boosting framework (LambdaMART, see Section 7). For a standalone neural implementation, you'd compute:

  1. Score documents with neural network.
  2. For each pair (i, j) with rel_i > rel_j, compute RankNet loss.
  3. Multiply gradient by |ΔNDCG_ij|.
  4. Backpropagate weighted gradients.

Listwise Approaches

Listwise methods optimize the entire ranking list directly, not individual documents or pairs. This directly aligns training with ranking metrics like NDCG.

ListNet

Idea: Model the probability of entire rankings. For each query, we have a true ranking (ideal ordering) and a predicted ranking. Minimize KL divergence between them.

Given documents with relevance labels, compute:

  • True distribution: P(ranking | true labels) = probability of an ordering proportional to relevance.
  • Predicted distribution: P(ranking | model scores) = probability proportional to model scores.
  • Loss: KL divergence = Σ P_true(π) * log(P_true(π) / P_pred(π)) summed over all permutations π.

Since there are n! permutations, this is intractable for large n. ListNet uses a top-k approximation: only consider top-K documents and top-K permutations.

ListMLE (Maximum Likelihood Estimation)

Idea: Model the probability of the ideal permutation directly using a factorized distribution.

For the ideal ranking π = (i₁, i₂, ..., i_k):

P(π) = P(i₁ first) × P(i₂ second | i₁ first) × ... (chain rule)

Each factor uses a softmax over remaining documents:

P(i_j | i₁, ..., i_{j-1}) = exp(s_ij) / Σ_k≠{i₁...i_{j-1}} exp(s_k)

Loss: Negative log-likelihood of the true ranking.

ListMLE is more efficient than ListNet and handles variable-length lists naturally.

SoftRank

Idea: Use a differentiable approximation to permutation operations. Instead of hard sorting, use soft permutation matrices that approximate sorting.

Enables direct gradient flow through ranking operations, allowing end-to-end optimization of NDCG.

Listwise vs. Pointwise/Pairwise

Listwise Probability Distribution
Ideal Ranking (True Labels) Doc A (rel=3) → Doc C (rel=2) → Doc B (rel=1) Model's Ranking (Scores) Doc A (score=0.9) → Doc B (score=0.7) → Doc C (score=0.6) ListMLE Loss NLL of true permutation given model scores Listwise optimizes the entire ranking directly, not individual docs or pairs. Directly optimizes ranking metrics like NDCG.

Advantages & Challenges

Advantages:

  • Directly optimizes ranking metrics (NDCG, etc.).
  • Accounts for position importance naturally.
  • Often outperforms pointwise/pairwise in practice.

Challenges:

  • Exponential permutation space: expensive even with approximations.
  • Harder to implement than pointwise/pairwise.
  • Requires careful truncation (top-K) to be tractable.
When to use listwise: When you have moderate candidate set sizes (K ≤ 100) and can afford the computation. The improved ranking quality is often worth it.

LambdaMART: The Industrial Standard

LambdaMART = LambdaRank gradients + MART (Multiple Additive Regression Trees). This combination dominates production ranking systems because it's fast, accurate, and interpretable.

MART (Gradient Boosting)

MART is gradient boosting on regression trees. It's equivalent to XGBoost (see XGBoost Guide). Algorithm:

  1. Start with an initial prediction (usually zero or constant).
  2. Compute residuals: true_label - current_prediction.
  3. Fit a regression tree to predict these residuals.
  4. Add the tree's predictions to the overall model (with learning rate shrinkage).
  5. Repeat until convergence.

LambdaMART Algorithm

Replace the residuals in step 2 with LambdaRank gradients:

  1. For each query, compute model scores on current documents.
  2. Compute pairwise RankNet gradients (like section 5).
  3. Weight each gradient by NDCG change: λ = gradient × |ΔNDCG|.
  4. Fit a regression tree to these weighted gradients.
  5. Add tree to the ensemble.
  6. Repeat for n_estimators iterations.

Why this works: Trees naturally find feature non-linearities and interactions. Each tree corrects ranking errors in the previous ensemble, weighted by how much they hurt NDCG.

LambdaMART Training Loop
Iteration t Score Docs Using ensemble Compute λ Weighted gradients Fit Tree To λ gradients Update Model Add tree + shrink Key Insight: Trees optimize gradient reduction → maximizes NDCG improvement Each tree specializes in correcting mistakes that hurt ranking most

Hyperparameters

Parameter Default Interpretation
n_estimators 100-500 Number of trees. Higher = more expressive but risk overfitting.
learning_rate (shrinkage) 0.01-0.1 Scale each tree's contribution. Lower = slower convergence but often better generalization.
max_depth 3-7 Tree depth. Shallow trees prevent overfitting.
min_samples_leaf 10-20 Minimum docs per leaf. Higher = more regularization.
subsample 0.8-1.0 Fraction of docs used per tree. Reduces overfitting.

Why LambdaMART Dominates

  • Speed: Training is fast (seconds to minutes for millions of query-document pairs). Inference is <1ms per query.
  • Accuracy: Listwise optimization (NDCG weighting) + tree expressiveness. Hard to beat in practice.
  • Interpretability: Feature importance is clear. Feature interactions learned automatically.
  • Robustness: Trees handle outliers and missing values naturally.
  • Maturity: Many implementations available (LightGBM, XGBoost, custom solutions at big companies).
Production standard: If you're building a ranking system and don't know where to start, use LambdaMART. It's battle-tested and will likely beat more complex approaches.

Neural Learning to Rank

Neural networks can replace trees for ranking. Advantages: end-to-end learning, semantic understanding. Disadvantages: slower inference, more data hungry.

DSSM (Deep Structured Semantic Model)

Architecture:

  • Query encoder: MLP that maps query text/features to embedding.
  • Document encoder: MLP that maps document text/features to embedding.
  • Relevance score = cosine similarity between embeddings.

Training: Pointwise or pairwise loss on embeddings.

Inference: Pre-compute document embeddings once. For a query, compute embedding once and score all cached docs via dot product (very fast).

BERT Cross-Encoder Reranker

Architecture:

[CLS] query [SEP] document [SEP] → BERT → relevance score

  • BERT sees query and document together, enabling interaction awareness.
  • Output: [CLS] token passed through MLP → relevance score (0–1).

Training: Pairwise loss: margin loss = max(0, 1 - score_positive + score_negative).

Inference latency: ~50–200ms per query (depends on document count). Slower than LambdaMART but more accurate for small result sets.

Production use: Use LambdaMART to rank top-1000 candidates, then BERT cross-encoder to rerank top-10 for display (cascade).

Distillation

Idea: Train a small, fast model to mimic a large, accurate model.

Process:

  1. Train a large BERT cross-encoder on labeled data (teacher).
  2. Run teacher on all unlabeled data. Soft labels = model confidence.
  3. Train a small model (smaller BERT, neural net) on soft labels (student).
  4. Student learns to mimic teacher's scoring without full BERT overhead.

Achieves 80-90% of teacher's accuracy at 10-20% of latency.

Neural LTR Pipeline
Retrieval Top 1000 LambdaMART Top 100 BERT Cross-Encoder Top 10 Final Results Display < 1ms < 1ms 50-200ms Cascade Strategy Fast models filter candidates, slow models rerank small sets. Balances quality (BERT is more accurate) and latency (<1000ms total SLA).

Neural vs. LambdaMART

Aspect LambdaMART Neural (BERT)
Training time Minutes Hours to days
Inference latency < 1ms 50-200ms
Accuracy Strong (≈80-85% NDCG) Very strong (≈85-90% NDCG)
Interpretability Good (feature importance) Poor (black box)
Production readiness Battle-tested Increasingly popular

See Hybrid Search & Reranking Guide for more on BERT-based reranking.

Recommendation: Start with LambdaMART. As your system matures and you have more data, add BERT reranking on top for marginal gains. Most production systems use a hybrid approach.

Training Data: Judgments, Clicks & Debiasing

The quality of training data determines the quality of the ranking model. Two sources: human judgments and click signals. Each has pros and cons.

Human Judgments

Process:

  • Curate representative queries.
  • Retrieve top documents per query (maybe 100-1000).
  • Human judges rate relevance: 0 (bad), 1 (fair), 2 (good), 3 (perfect).
  • Resolve disagreements via consensus or majority vote.

Advantages: High quality, no position bias, explicit labels.

Disadvantages: Expensive ($1000s for decent set), time-consuming, limited coverage (only labeled queries).

Click Data

Process: Log user clicks from search engine. Assume clicked documents are relevant, unclicked = not relevant.

Advantages: Free, massive scale, real user feedback.

Critical flaw: Position bias. Users are more likely to click documents shown higher, regardless of relevance. A bad document at position 1 gets more clicks than an excellent document at position 10.

Position Bias in Click Data
Position P(Click) 1 2 3 4 5 Position Bias Clicks ↑ higher positions even if doc is irrelevant Problem: Position 1 gets clicks not because it's good, but because it's first.

Click Models for Debiasing

Position Bias Model (PBM): Assumes click probability factors into examination probability and relevance:

P(click_i) = P(exam_i) × P(relevant_i)

Where P(exam_i) depends only on position (position bias), and P(relevant_i) depends on the document.

Algorithm: EM iterations to estimate both biases. Once estimated, reweight clicks by 1/P(exam_i) (Inverse Propensity Weighting, IPS).

Dynamic Bayesian Network (DBN): More sophisticated. Models user behavior: examine position 1, if not satisfied examine position 2, etc. Captures examination bias more accurately.

Unbiased LTR

Idea: Train models to predict true relevance, not click probability. Use debiased click labels:

Unbiased label = P(relevant | click, position) ∝ P(click | relevant) × P(relevant)

This inverts position bias, giving true relevance estimates independent of position.

Online Evaluation: Interleaving

Problem: Offline metrics don't tell you if a new ranking model is better in production. Users might interact differently.

Interleaving: Show results from model A and model B interleaved (alternating positions). Count wins: how often users click on docs from A vs. B?

Advantage: Directly measures user preference without explicit labels.

Disadvantage: Slower than offline evaluation. Need thousands of interleaved sessions for statistical significance.

Best practice: Start with human judgments to build an initial model. Gradually incorporate debiased clicks as your confidence grows. Run interleaving experiments to validate offline metrics.

When to Use What: Comparison & Architecture

Choosing an LTR approach depends on data size, latency constraints, and accuracy requirements.

Method Comparison

Method Data Latency Accuracy Best For
Pointwise Small-large <1ms 70-75% Baseline, warm-start
Pairwise (RankNet) Medium-large <1ms 75-80% Neural ranking starter
Listwise (ListMLE) Medium <1ms 78-82% When NDCG is critical
LambdaMART Medium-large <1ms 80-85% Production default
Neural (DSSM) Large <1ms 78-82% Embedding-based ranker
BERT Cross-Encoder Large 50-200ms 85-90% Top-K reranking (top-100)

Production Architecture: The Cascade

Most production systems use a cascade: fast rough ranking → slow accurate ranking.

Production Ranking Cascade
Stage 1: Retrieval BM25 / Vector Retrieval Top 1000 < 50ms Stage 2: LambdaMART Tree Ensemble Rerank Top 100 < 50ms Stage 3: BERT Cross-Encoder Rerank Top 10 150-300ms Stage 4: Business Diversity, Ads, Freshness Final 10 < 50ms Efficiency Ratio 1000 docs 1x cost 100 docs 0.1x cost 10 docs 10x cost/doc

Decision Tree: Choosing Your Approach

Start with: LambdaMART on BM25 features. Train on human judgments or debiased clicks. Evaluate on NDCG@10.

If latency allows (>50ms per query): Add BERT cross-encoder on top-100 candidates.

If you have massive data (millions of queries): Consider neural methods (DSSM, BERT) for better semantic understanding.

For cold-start (no training data): Use rule-based features + pointwise regression to bootstrap. Collect human judgments. Graduate to pairwise/listwise.

Benchmarks: LETOR & MSLR-WEB

LETOR: Public benchmark. Queries from Microsoft Bing. Small (~30K documents per query). Standard for academic papers.

MSLR-WEB10K & WEB30K: Larger benchmarks from Microsoft. Millions of query-document pairs. More representative of production. NDCG@10 is standard metric.

Typical performance:

  • Random baseline (NDCG@10): ≈0.40
  • BM25 baseline: ≈0.60
  • LambdaMART state-of-art: ≈0.74
Practical advice: Implement your first LTR system with LambdaMART + cascade. Get it into production, measure real NDCG/clicks. Only optimize further if A/B tests show user benefit.

Python Implementation: Complete LTR Pipeline

Build a complete learning-to-rank system from scratch using NumPy and Pandas. This code demonstrates the concepts without heavy dependencies.

NDCG Evaluator

import numpy as np
from typing import List, Tuple

class NDCGEvaluator:
    """Compute DCG, NDCG@K, and MRR for ranked lists."""

    @staticmethod
    def dcg(relevances: np.ndarray, k: int = 10) -> float:
        """Discounted Cumulative Gain @ K.

        Args:
            relevances: array of relevance scores (0, 1, 2, 3, ...)
            k: cutoff position

        Returns:
            DCG@K value
        """
        rel_k = relevances[:k]
        positions = np.arange(1, len(rel_k) + 1)
        discounts = 1.0 / np.log2(positions + 1)
        return np.sum(rel_k * discounts)

    @staticmethod
    def idcg(relevances: np.ndarray, k: int = 10) -> float:
        """Ideal DCG (best possible ranking)."""
        ideal_rel = np.sort(relevances)[::-1]
        return NDCGEvaluator.dcg(ideal_rel, k)

    @staticmethod
    def ndcg(relevances: np.ndarray, k: int = 10) -> float:
        """Normalized DCG @ K."""
        dcg_val = NDCGEvaluator.dcg(relevances, k)
        idcg_val = NDCGEvaluator.idcg(relevances, k)
        return dcg_val / idcg_val if idcg_val > 0 else 0.0

    @staticmethod
    def mrr(relevances: np.ndarray) -> float:
        """Mean Reciprocal Rank of first relevant doc."""
        for i, rel in enumerate(relevances):
            if rel > 0:
                return 1.0 / (i + 1)
        return 0.0

    @staticmethod
    def evaluate_ranking(
        true_relevances: np.ndarray,
        predicted_scores: np.ndarray,
        k: int = 10
    ) -> dict:
        """Full ranking evaluation.

        Args:
            true_relevances: ground truth relevance labels
            predicted_scores: model predictions
            k: cutoff

        Returns:
            dict with NDCG, MRR, and ranking
        """
        # Sort by predicted scores
        ranking_idx = np.argsort(predicted_scores)[::-1]
        ranked_rel = true_relevances[ranking_idx]

        return {
            'ndcg@10': NDCGEvaluator.ndcg(ranked_rel, 10),
            'ndcg@5': NDCGEvaluator.ndcg(ranked_rel, 5),
            'mrr': NDCGEvaluator.mrr(ranked_rel),
            'ranked_indices': ranking_idx,
            'ranked_relevances': ranked_rel
        }

Pointwise LTR: Logistic Regression

class PointwiseLTR:
    """Pointwise ranking via logistic regression.

    Treats each document independently. Predicts relevance probability.
    """

    def __init__(self, learning_rate: float = 0.01, iterations: int = 100):
        self.lr = learning_rate
        self.iterations = iterations
        self.weights = None
        self.bias = 0.0

    def sigmoid(self, z: np.ndarray) -> np.ndarray:
        return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

    def fit(self, X: np.ndarray, y: np.ndarray):
        """Train on query-document feature vectors.

        Args:
            X: feature matrix (n_samples, n_features)
            y: binary relevance labels {0, 1}
        """
        n_samples, n_features = X.shape
        self.weights = np.zeros(n_features)

        for _ in range(self.iterations):
            # Forward pass
            z = X @ self.weights + self.bias
            predictions = self.sigmoid(z)

            # Gradient
            error = predictions - y
            grad_w = (X.T @ error) / n_samples
            grad_b = np.mean(error)

            # Update
            self.weights -= self.lr * grad_w
            self.bias -= self.lr * grad_b

    def predict(self, X: np.ndarray) -> np.ndarray:
        """Score documents."""
        z = X @ self.weights + self.bias
        return self.sigmoid(z)

Pairwise LTR: RankNet-Style

class PairwiseLTR:
    """Pairwise ranking: learn from document pairs.

    Loss: binary cross-entropy on whether doc_i > doc_j.
    """

    def __init__(self, learning_rate: float = 0.01, iterations: int = 100):
        self.lr = learning_rate
        self.iterations = iterations
        self.weights = None
        self.bias = 0.0

    def sigmoid(self, z: np.ndarray) -> np.ndarray:
        return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

    def create_pairs(self, X: np.ndarray, y: np.ndarray) -> Tuple:
        """Generate pairwise training examples.

        For each query, create pairs (i, j) where rel_i > rel_j.
        """
        pairs = []
        labels = []

        for i in range(len(y)):
            for j in range(i + 1, len(y)):
                if y[i] != y[j]:
                    pairs.append((X[i], X[j]))
                    labels.append(1 if y[i] > y[j] else 0)

        return pairs, labels

    def fit(self, X: np.ndarray, y: np.ndarray):
        """Train on pairwise examples.

        Args:
            X: feature matrix
            y: relevance labels (0, 1, 2, 3, ...)
        """
        pairs, pair_labels = self.create_pairs(X, y)
        n_features = X.shape[1]
        self.weights = np.zeros(n_features)

        for _ in range(self.iterations):
            for (x_i, x_j), label in zip(pairs, pair_labels):
                # Score difference
                s_i = x_i @ self.weights + self.bias
                s_j = x_j @ self.weights + self.bias
                s_diff = s_i - s_j

                # Sigmoid probability
                p_ij = self.sigmoid(s_diff)

                # Loss gradient: cross-entropy on pairwise preference
                error = p_ij - label

                # Update weights
                grad = error * (x_i - x_j)
                self.weights -= self.lr * grad / len(pairs)

    def predict(self, X: np.ndarray) -> np.ndarray:
        """Score documents."""
        return X @ self.weights + self.bias

Simplified LambdaMART-style GBDT

class SimplifiedLambdaMART:
    """Gradient boosting with LambdaRank gradients.

    Simplified: uses decision stumps instead of full trees.
    Demonstrates the core LambdaRank concept.
    """

    def __init__(self, n_trees: int = 50, learning_rate: float = 0.1):
        self.n_trees = n_trees
        self.lr = learning_rate
        self.trees = []
        self.predictions = None

    def compute_lambda_gradients(
        self,
        X: np.ndarray,
        y: np.ndarray,
        current_scores: np.ndarray
    ) -> np.ndarray:
        """Compute LambdaRank gradients.

        For each document pair, weight gradient by NDCG change.
        Simplified: use 1 / (1 + exp(-(s_i - s_j))) as gradient.
        """
        n = len(y)
        gradients = np.zeros(n)

        # For each pair of documents with different labels
        for i in range(n):
            for j in range(i + 1, n):
                if y[i] != y[j]:
                    # RankNet gradient
                    s_diff = current_scores[i] - current_scores[j]
                    p_ij = 1.0 / (1.0 + np.exp(-np.clip(s_diff, -500, 500)))

                    # Target: 1 if y_i > y_j, else 0
                    target = 1.0 if y[i] > y[j] else 0.0
                    error = p_ij - target

                    # Approximate NDCG change (simplified)
                    ndcg_change = abs(y[i] - y[j]) / (n * np.log2(10))
                    lambda_ij = error * ndcg_change

                    # Accumulate gradients
                    if y[i] > y[j]:
                        gradients[i] += lambda_ij
                        gradients[j] -= lambda_ij
                    else:
                        gradients[i] -= lambda_ij
                        gradients[j] += lambda_ij

        return gradients

    def fit(self, X: np.ndarray, y: np.ndarray):
        """Train ensemble.

        Args:
            X: feature matrix (n_samples, n_features)
            y: relevance labels
        """
        self.predictions = np.zeros(len(y))

        for t in range(self.n_trees):
            # Compute lambda gradients
            gradients = self.compute_lambda_gradients(X, y, self.predictions)

            # Fit a simple linear model to gradients
            feature_idx = np.random.randint(X.shape[1])
            x_feat = X[:, feature_idx]

            # Threshold based on feature
            threshold = np.median(x_feat)
            direction = 1.0 if np.corrcoef(x_feat, gradients)[0, 1] > 0 else -1.0

            # Tree prediction: left/right based on threshold
            tree_pred = np.where(x_feat > threshold, direction, -direction)

            # Update ensemble
            self.predictions += self.lr * tree_pred
            self.trees.append((feature_idx, threshold, direction))

    def predict(self, X: np.ndarray) -> np.ndarray:
        """Score documents."""
        scores = np.zeros(len(X))
        for feature_idx, threshold, direction in self.trees:
            tree_pred = np.where(
                X[:, feature_idx] > threshold, direction, -direction
            )
            scores += self.lr * tree_pred
        return scores

Complete LTR Pipeline

class LTRPipeline:
    """End-to-end learning to rank pipeline.

    Retrieval → Feature Extraction → Ranking Model → Evaluation
    """

    def __init__(self, ranker_type: str = 'lambdamart'):
        """
        Args:
            ranker_type: 'pointwise', 'pairwise', or 'lambdamart'
        """
        self.ranker_type = ranker_type
        if ranker_type == 'pointwise':
            self.ranker = PointwiseLTR()
        elif ranker_type == 'pairwise':
            self.ranker = PairwiseLTR()
        else:
            self.ranker = SimplifiedLambdaMART()

        self.evaluator = NDCGEvaluator()
        self.feature_scaler_mean = None
        self.feature_scaler_std = None

    def normalize_features(self, X: np.ndarray, fit: bool = False) -> np.ndarray:
        """Z-score normalization."""
        if fit:
            self.feature_scaler_mean = np.mean(X, axis=0)
            self.feature_scaler_std = np.std(X, axis=0) + 1e-8

        return (X - self.feature_scaler_mean) / self.feature_scaler_std

    def train(
        self,
        X_train: np.ndarray,
        y_train: np.ndarray
    ):
        """Train ranking model.

        Args:
            X_train: training features (n_samples, n_features)
            y_train: relevance labels
        """
        X_normalized = self.normalize_features(X_train, fit=True)
        self.ranker.fit(X_normalized, y_train)

    def rank_candidates(
        self,
        X_candidates: np.ndarray
    ) -> Tuple[np.ndarray, np.ndarray]:
        """Rank candidate documents.

        Returns:
            (ranked_indices, ranked_scores)
        """
        X_normalized = self.normalize_features(X_candidates, fit=False)
        scores = self.ranker.predict(X_normalized)

        # Sort by score (descending)
        ranking_idx = np.argsort(scores)[::-1]
        return ranking_idx, scores[ranking_idx]

    def evaluate(
        self,
        X_test: np.ndarray,
        y_test: np.ndarray
    ) -> dict:
        """Evaluate on test set.

        Returns:
            dict with metrics
        """
        ranking_idx, scores = self.rank_candidates(X_test)
        ranked_rel = y_test[ranking_idx]

        return {
            'ndcg@10': self.evaluator.ndcg(ranked_rel, 10),
            'ndcg@5': self.evaluator.ndcg(ranked_rel, 5),
            'mrr': self.evaluator.mrr(ranked_rel),
            'ranking': ranking_idx,
            'scores': scores
        }

Complete Working Example

# Synthetic dataset: 10 queries, 100 docs each
np.random.seed(42)
n_queries = 10
docs_per_query = 100
n_features = 5

# Generate synthetic data
X_all = []
y_all = []

for q in range(n_queries):
    # Features: BM25, length, embedding similarity, etc.
    X_q = np.random.randn(docs_per_query, n_features)

    # Relevance: correlated with first feature
    y_q = (
        (X_q[:, 0] > 0).astype(int) +  # BM25 match
        (X_q[:, 1] > 0.5).astype(int) * 2  # Long doc
    )
    y_q = np.clip(y_q, 0, 3)  # Graded relevance

    X_all.append(X_q)
    y_all.append(y_q)

# Flatten for training
X_train = np.vstack(X_all[:7])
y_train = np.hstack(y_all[:7])
X_test = np.vstack(X_all[7:])
y_test = np.hstack(y_all[7:])

# Train all three methods
methods = ['pointwise', 'pairwise', 'lambdamart']
results = {}

for method in methods:
    pipeline = LTRPipeline(ranker_type=method)
    pipeline.train(X_train, y_train)
    metrics = pipeline.evaluate(X_test, y_test)
    results[method] = metrics

    print(f"{method.upper()}:")
    print(f"  NDCG@10: {metrics['ndcg@10']:.4f}")
    print(f"  NDCG@5:  {metrics['ndcg@5']:.4f}")
    print(f"  MRR:     {metrics['mrr']:.4f}")
    print()

# Compare improvements
print("Comparison vs. Pointwise:")
pointwise_ndcg = results['pointwise']['ndcg@10']
for method in ['pairwise', 'lambdamart']:
    improvement = (
        (results[method]['ndcg@10'] - pointwise_ndcg) / pointwise_ndcg * 100
    )
    print(f"  {method}: +{improvement:.2f}%")

Key Takeaways from Implementation

  • NDCGEvaluator: Always compute NDCG@K to evaluate ranking quality.
  • PointwiseLTR: Simple baseline. Ignores ordering. Usually beats random but loses to pairwise/listwise.
  • PairwiseLTR: Better than pointwise because it learns preferences. Scales to large candidate sets.
  • LambdaMART: Industry standard combines gradient boosting with LambdaRank gradients. Hard to beat.
  • Feature normalization: Critical for all methods. Z-score normalization or rank-based normalization.
  • Ensemble importance: LambdaMART uses many weak learners. Each tree corrects previous mistakes.

Next Steps

This code is educational. For production:

  • Use LightGBM or XGBoost with objective='rank:ndcg' for LambdaMART.
  • Handle variable-length query result sets (different query → different number of docs).
  • Implement group-level metrics (NDCG averaged across queries).
  • Add cross-validation for hyperparameter tuning.
  • Integrate feature generation from actual search index (BM25, embeddings, etc.).
Challenge: Extend this code to ListMLE. Instead of pairwise gradients, compute the probability of the true permutation and minimize its negative log-likelihood.
End of Learning to Rank (LTR)