Recommender Systems

Ranking metrics

A recommender is judged on the order of a short list, not on how close its numbers are, so accuracy metrics mislead and position-aware metrics like nDCG are what you actually report.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why the metric you reach for first is the wrong one
  4. The metrics, in plain words
  5. Why several metrics, and not one
  6. The honest part
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Ranking metrics score the order of a list, giving far more credit for a good item at the top than for the same item further down.

The analogy you have already lived

You ask a friend where to eat. He sends twenty suggestions in a message.

You read the first three. Maybe the fourth. Then you pick one and stop reading.

Suggestion number fourteen could have been the best restaurant in the city and it would have made no difference to your evening. It was never seen. That is the fact every ranking metric is built around: position is not a detail, position is the product.

Why the metric you reach for first is the wrong one

The first instinct is to measure how close the predicted numbers are to the real ones. Predict 4.2 when the person gave 4, and you were off by 0.2. Average that error over everything and report it.

That is RMSE, and it was the official measure of the Netflix Prize. It is also the wrong tool for almost every real product.

Here is why. A recommender shows a list of ten. Nobody sees the predicted numbers. What matters is which ten items were chosen and in what order.

A model can predict every rating slightly badly and still produce a perfect ordering. Another model can predict ratings well across the boring middle of the catalogue, where errors are cheap and plentiful. It can still get the top of every list wrong. RMSE prefers that second model.

Measure the thing the user experiences. The user experiences an ordered list.

The metrics, in plain words

Precision@k. Out of the first k items you showed, what fraction were good? Show ten, four were good, precision is 0.4.

Recall@k. Out of everything that was good, what fraction made it into the top k? This is the one that punishes a system for missing things.

MRR. How far down was the first good item? Position 1 scores 1. Position 4 scores one quarter. Useful when the user needs one right answer, like a search box.

MAP. Like precision, but re-measured at every good item and averaged. It rewards clustering the good items early rather than spreading them out.

nDCG. The one people actually report. Each position gets a discount, so a good item near the top is worth more than the same item lower down. Then the whole score is divided by the best possible arrangement, so a perfect list always scores exactly 1.

That last division matters. It makes scores comparable between a user with two good items and a user with fifty.

Why several metrics, and not one

Look at three systems ranking the same five films, where the user later watched three of them.

   user actually watched:  Dangal, Lagaan, Stree

   model A:  Dangal  Lagaan  Stree   Sholay  Tumbbad     good ones at 1, 2, 3
   model B:  Sholay  Tumbbad Dangal  Lagaan  Stree       good ones at 3, 4, 5
   model C:  Dangal  Sholay  Lagaan  Tumbbad Stree       good ones at 1, 3, 5

All three found all three films inside five slots. So precision@5 and recall@5 are identical for all three, and by those numbers the systems are equally good.

They are not equally good, and one glance at the lists shows it. Model A is perfect and model B is close to the worst arrangement possible. A metric that cannot see the difference is not measuring the product.

The developer block runs exactly this and shows which metrics notice.

The honest part

Every number on this page is computed against what the user did after being shown something by an older system. You never observe what they would have enjoyed from items nobody showed them.

So a high offline score partly means "this model agrees with the model we already had". That is a genuinely uncomfortable fact, and it does not go away with a bigger test set.

Offline metrics are for catching regressions and for cutting a hundred ideas down to three. Deciding which of those three ships needs live traffic — see A/B testing a recommender.

There is a second honest point. Accuracy metrics say nothing about whether the list is worth looking at. Ten near-identical items can score beautifully. Track coverage and diversity beside them; diversity and filter bubbles covers those.

Remember this

  • Judge the order of the list, not how close the numbers are.
  • nDCG discounts by position and normalises, so it is comparable across users.
  • Offline scores measure agreement with the old system, so they rank ideas rather than settle them.

What to learn next

Developer — Code and libraries.

Setup

bash
python3 --version

Standard library only, one import math. Writing these five metrics yourself once is worth more than importing them a hundred times, because the off-by-one errors live in the details.

Five metrics, three rankings

ranking_metrics.py
import math

# What this user actually went on to watch. This is the ground truth.
RELEVANT = {"Dangal", "Lagaan", "Stree"}

# Three different systems, each returning the same five films in a different order.
RANKINGS = {
    "model A": ["Dangal", "Lagaan", "Stree", "Sholay", "Tumbbad"],
    "model B": ["Sholay", "Tumbbad", "Dangal", "Lagaan", "Stree"],
    "model C": ["Dangal", "Sholay", "Lagaan", "Tumbbad", "Stree"],
}


def precision_at_k(ranked, relevant, k):
    hits = sum(1 for item in ranked[:k] if item in relevant)
    return hits / k


def recall_at_k(ranked, relevant, k):
    hits = sum(1 for item in ranked[:k] if item in relevant)
    return hits / len(relevant)


def reciprocal_rank(ranked, relevant):
    for position, item in enumerate(ranked, start=1):
        if item in relevant:
            return 1 / position
    return 0.0


def average_precision(ranked, relevant):
    hits, total = 0, 0.0
    for position, item in enumerate(ranked, start=1):
        if item in relevant:
            hits += 1
            total += hits / position          # precision measured at each hit
    return total / len(relevant)


def dcg(gains):
    # position 1 gets discount log2(2) = 1, so the top slot is undiscounted
    return sum(g / math.log2(i + 1) for i, g in enumerate(gains, start=1))


def ndcg_at_k(ranked, relevant, k):
    gains = [1.0 if item in relevant else 0.0 for item in ranked[:k]]
    ideal = sorted([1.0] * len(relevant) + [0.0] * (k - len(relevant)), reverse=True)[:k]
    return dcg(gains) / dcg(ideal)


header = f"{'':9}{'P@3':>7}{'P@5':>7}{'R@5':>7}{'MRR':>7}{'AP':>7}{'nDCG@5':>9}"
print(header)
for name, ranked in RANKINGS.items():
    print(f"{name:9}"
          f"{precision_at_k(ranked, RELEVANT, 3):>7.2f}"
          f"{precision_at_k(ranked, RELEVANT, 5):>7.2f}"
          f"{recall_at_k(ranked, RELEVANT, 5):>7.2f}"
          f"{reciprocal_rank(ranked, RELEVANT):>7.2f}"
          f"{average_precision(ranked, RELEVANT):>7.2f}"
          f"{ndcg_at_k(ranked, RELEVANT, 5):>9.3f}")

print("\nwhere each model put the three films the user actually watched:")
for name, ranked in RANKINGS.items():
    spots = [str(ranked.index(f) + 1) for f in ["Dangal", "Lagaan", "Stree"]]
    print(f"  {name:9} positions {', '.join(spots)}")
Output
             P@3    P@5    R@5    MRR     AP   nDCG@5
model A     1.00   0.60   1.00   1.00   1.00    1.000
model B     0.33   0.60   1.00   0.33   0.48    0.618
model C     0.67   0.60   1.00   1.00   0.76    0.885

where each model put the three films the user actually watched:
  model A   positions 1, 2, 3
  model B   positions 3, 4, 5
  model C   positions 1, 3, 5

Read the columns, not the rows

P@5 and R@5 are identical for all three models. 0.60 and 1.00, three times over. Two of the most commonly quoted metrics in the field cannot tell a perfect ranking from a near-worst one. This is not a trick example; it happens whenever k is large enough to contain everything relevant.

P@3 splits them properly: 1.00, 0.33, 0.67. Cutting at the depth users actually reach is often the single most useful change you can make to your evaluation.

MRR ties model A and model C at 1.00. Both put a good film first, and MRR stops looking after the first hit. It is the right metric when the product needs one answer, and the wrong metric when the product shows a list.

AP and nDCG@5 separate all three, in the right order. 1.00 / 0.48 / 0.76 and 1.000 / 0.618 / 0.885. These are the two to report for a list-shaped product.

One table, three models, and five metrics disagreeing about which is best. That disagreement is the reason you choose the metric from the interface, before you look at any results.

Line by line, for the parts that are not obvious

math.log2(i + 1) with i starting at 1. Position 1 gives log2(2) = 1, so the top slot is not discounted at all. Start the enumeration at 0 by mistake and you divide by log2(1) = 0, and get ZeroDivisionError on the very first item. This is the most common bug in hand-written nDCG.

ideal = sorted(...) — the best achievable arrangement for this user, given how many relevant items they have. Dividing by it is what puts every user on the same 0-to-1 scale. Without normalising, a user with 50 relevant items dominates the average and your metric becomes a measurement of who is most active.

sorted([1.0] * len(relevant) + [0.0] * (k - len(relevant)), ...)[:k] — when a user has more relevant items than k, the padding list length goes negative, [0.0] * -2 is empty, and the slice still returns k ones. The behaviour is correct, and it is correct by accident. Write a test for the len(relevant) > k case before you trust it.

total / len(relevant) in average_precision — dividing by the number of relevant items, not by the number of hits found. Dividing by hits would give a perfect score to a system that found one relevant item at position 1 and missed everything else.

return 0.0 at the end of reciprocal_rank — the no-hit case. A metric function must handle an empty intersection without raising, because it will happen constantly on real data.

Common mistakes

Reporting RMSE for a ranking product. It answers a question nobody asked. Use it only when the interface literally shows a predicted number to the user.

Choosing k after seeing the results. Fix k from the interface: how many items does the page show above the fold? Report that k, and report it consistently.

Averaging over interactions instead of over users. Sum the per-interaction scores and heavy users decide your metric. Compute a score per user, then average the users. Report the median too — a mean improvement often hides a majority getting worse.

Comparing nDCG across different candidate sets. The number depends on what was in the pool. Two papers reporting nDCG@10 on the same dataset are frequently not comparable, because they sampled negatives differently.

Evaluating with sampled negatives without checking. Ranking a positive against 100 sampled negatives is much cheaper than ranking against the full catalogue, and the resulting metric can order two models differently from the full-catalogue metric. Krichene and Rendle (2020) demonstrated this on standard benchmarks. Sample if you must, and know what it costs you.

Reporting only the mean. Give a confidence interval. On 10,000 users, a 0.002 nDCG difference is noise, and it gets shipped as a win more often than anyone admits.

Try it yourself

Add a fourth model that returns ["Dangal", "Lagaan", "Sholay", "Tumbbad", "Stree"] and predict its numbers before running. Two good items at the top, one stranded at position 5.

Then change RELEVANT to graded values instead of a set — {"Dangal": 3, "Lagaan": 1, "Stree": 1} — and change gains to use 2 ** rel - 1. That is the standard graded form of nDCG.

The winner does not change, and the gaps do. Model C climbs from 0.885 to 0.970, because it put the one highly-relevant film in the top slot and its two misplacements were cheap items. Model B drops from 0.618 to 0.531.

Deciding what "relevant" means moved the scores more than switching between two of the models did. That decision usually gets ten minutes of thought and deserves a week.

What to learn next

Researcher — Mathematics and papers.

Definitions

For user $u$ with relevance labels $\mathrm{rel}(u, i) \ge 0$ and a ranked list $\pi_u$ where $\pi_u(j)$ is the item at position $j$:

Precision and recall at $k$:

$$ P@k = \frac{1}{k}\sum_{j=1}^{k} \mathbb{1}[\mathrm{rel}(u, \pi_u(j)) > 0], \qquad R@k = \frac{\sum_{j=1}^{k} \mathbb{1}[\mathrm{rel}(u,\pi_u(j)) > 0]}{|{i : \mathrm{rel}(u,i) > 0}|} $$

Average precision, with $R_u$ the relevant set:

$$ AP@k = \frac{1}{|R_u|}\sum_{j=1}^{k} P@j \cdot \mathbb{1}[\pi_u(j) \in R_u] $$

MAP is $AP$ averaged over users. It approximates the area under the precision-recall curve.

Discounted cumulative gain, in the exponential-gain form standard since Burges et al. (2005):

$$ DCG@k = \sum_{j=1}^{k} \frac{2^{\mathrm{rel}(u, \pi_u(j))} - 1}{\log_2(j + 1)}, \qquad nDCG@k = \frac{DCG@k}{IDCG@k} $$

$IDCG@k$ is $DCG@k$ evaluated on the relevance-sorted ideal permutation. With binary relevance, $2^1 - 1 = 1$ and the exponential form collapses to the linear one used in the code above.

The $1/\log_2(j+1)$ discount is a modelling choice, not a derivation. Järvelin and Kekäläinen (2002) introduced it as a smooth, slowly decaying weight. Rank-biased precision (Moffat and Zobel, 2008) replaces it with a geometric discount $p^{j-1}$ derived from an explicit user model — the probability the user continues to the next item — which makes the assumption inspectable and tunable.

The metric is a user model

Every ranking metric encodes an assumption about how the list is consumed.

MetricImplicit user behaviour
$P@k$examines exactly $k$ items, position-blind within them
$MRR$stops at the first relevant item
$MAP$examines the full list, values early clustering
$nDCG$examines with logarithmically decaying attention
$RBP$continues to the next item with fixed probability $p$

Choosing a metric is choosing a user model. Report the one whose assumption matches your interface, and say which one you chose. A ranked feed with infinite scroll and a single-answer search box should not be evaluated the same way.

Sampled metrics are not order-preserving

Evaluating against the full catalogue costs $O(|I|)$ per user. The widespread shortcut ranks each held-out positive against $m$ sampled negatives, usually $m = 100$.

Krichene and Rendle (2020), On Sampled Metrics for Item Recommendation, show this is not a benign approximation. The sampled metric is a non-linear, non-monotone function of the true metric, so two models can swap order between the sampled and exact evaluation. They demonstrate the inversion on real benchmarks and give corrected estimators that recover consistency.

The practical rule: compute exact metrics whenever the catalogue permits it, and when it does not, use their correction rather than the raw sampled value.

Offline bias, restated precisely

Held-out interactions were generated under the logging policy $\pi_0$. The observed relevance label for $(u,i)$ exists only if $o_{ui} = 1$, and $P(o_{ui}=1)$ depends on $\pi_0$. The naive estimate of any of the metrics above is therefore an estimate of performance on the distribution the old system induced.

The inverse-propensity-weighted form for a position-dependent examination model $P(o = 1 \mid \text{rank } j) = \theta_j$:

$$ \widehat{\Delta}{\text{IPS}}(\pi) = \frac{1}{|D|}\sum{(u,i) \in D} \frac{\lambda!\left(\mathrm{rank}(i \mid \pi, u)\right)}{\theta_{\mathrm{rank}(i \mid \pi_0, u)}} $$

Where $\lambda(\cdot)$ is the metric's rank weight (for DCG, $\lambda(j) = 1/\log_2(j+1)$) and $\theta_j$ is the examination propensity at the position where the interaction was logged. Joachims, Swaminathan and Schnabel (2017) give the estimator and the propensity-estimation procedure via result randomisation.

Two conditions are unavoidable: propensities must be bounded away from zero, so the logging policy needs stochasticity; and impressions must have been logged, which is a decision made in the data pipeline long before evaluation.

Beyond accuracy

Report at least one of each, or the accuracy number will flatter a degenerate system:

  • Catalogue coverage: $|\bigcup_u \mathrm{top}_N(u)| / |I|$.
  • Intra-list diversity: mean pairwise distance within a slate.
  • Gini coefficient over item exposure counts — inequality of attention.
  • Novelty: mean self-information $-\log_2 p(i)$ of recommended items, with $p(i)$ the item's popularity share.
  • Serendipity: relevance conditioned on the item being unexpected relative to a popularity baseline.

Details and code are in diversity and filter bubbles.

Papers

  • Järvelin and Kekäläinen (2002), Cumulated Gain-Based Evaluation of IR Techniques, ACM TOIS — the DCG paper.
  • Burges et al. (2005), Learning to Rank Using Gradient Descent, ICML — the exponential gain formulation.
  • Moffat and Zobel (2008), Rank-Biased Precision for Measurement of Retrieval Effectiveness, ACM TOIS.
  • Joachims, Swaminathan and Schnabel (2017), Unbiased Learning-to-Rank with Biased Feedback, WSDM — arxiv.org/abs/1608.04468
  • Krichene and Rendle (2020), On Sampled Metrics for Item Recommendation, KDD.
  • Cañamares and Castells (2020), On Target Item Sampling in Offline Recommender System Evaluation, RecSys.

What to learn next