Recommender Systems

Implicit feedback

Almost nobody rates anything, so real recommenders learn from clicks, plays and purchases — data with no negatives in it, where a zero means "never saw it" rather than "disliked it".

Read these first

On this page 9
  1. The short answer
  2. The analogy you have already lived
  3. Why this is the normal case
  4. The three things that make it hard
  5. The idea that fixes most of it
  6. Where you have already seen this
  7. The honest part
  8. Remember this
  9. 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

Implicit feedback is learning from what people did, when nobody ever told you what they liked.

The analogy you have already lived

Think of the small restaurant you go to. There is a feedback form near the door. You have never filled it in. Neither has anybody else.

But the waiter knows things anyway. He noticed you finished the dal and left the salad. He noticed you came back on Thursday. He noticed you ordered the same thing twice.

He never got a rating. He got behaviour, and behaviour turned out to be enough.

Why this is the normal case

Every textbook example of a recommender uses star ratings. Real systems almost never have them.

Ask a hundred people to rate a film and perhaps one does. The people who do rate are unusual — they are the ones with strong opinions, so their ratings do not represent everyone else.

Meanwhile every user generates behaviour constantly. A click. A play. Thirty seconds of watching. A purchase. A scroll past.

That behaviour is implicit feedback: information about preference that the user never intended to give you. It is abundant, free and messy. Star ratings are explicit feedback: scarce, clean and rare.

The three things that make it hard

One. There are no negatives.

You know Divya played this song. You do not know she dislikes the 39,999 songs she did not play. She has never heard of most of them.

   explicit data:   5 = loved it,  1 = hated it,  blank = did not rate
   implicit data:   1 = did it,    blank = ??? 

That question mark is the whole problem. A blank might mean dislike. It much more often means "never came across it".

Two. Doing something is not the same as liking it.

You clicked because the thumbnail was misleading. You watched because the next episode auto-played while you were asleep. You bought a gift for somebody else.

Explicit ratings are quiet but honest. Implicit signals are loud but unreliable.

Three. You only ever see what the system showed.

You cannot click a song that was never on your screen. So your behaviour is partly a record of your taste, and partly a record of what yesterday's recommender decided to show you. The system is grading its own homework.

The idea that fixes most of it

Split one number into two.

Preference answers a yes-or-no question: did this person interact at all? That is what you are trying to predict.

Confidence answers a different question: how sure are you about that? One play is weak evidence. Forty plays is strong evidence. Never played is very weak evidence of dislike.

   played 40 times  ->  preference YES,  confidence HIGH
   played once      ->  preference YES,  confidence LOW
   never played     ->  preference NO,   confidence VERY LOW

Now the "never played" cells still take part in training, but they barely count. They nudge instead of shouting. That single change is what makes implicit models work.

Where you have already seen this

  • Spotify builds your taste from plays and skips, not from ratings.
  • Amazon learns from purchases and views, not from reviews.
  • YouTube counts watch time, not thumbs.
  • A news app learns from what you opened and how long you stayed.

The honest part

A skip is not a dislike. A long watch is not enjoyment. A purchase is not satisfaction.

Implicit signals are proxies, and every proxy drifts from the thing it stands for. A system trained to maximise clicks will find the headlines that get clicked and regretted. That is not a failure of the model; the model did exactly what it was told.

So the best teams collect a little explicit feedback anyway. A thumbs-down button. A "not interested" option. A survey shown to one user in a thousand.

They use it to check that the loud implicit signal still points at something people value.

Remember this

  • Most real data is behaviour, not ratings, and it contains no true negatives.
  • Split every signal into preference (did it happen) and confidence (how sure are we).
  • Implicit signals are proxies for enjoyment, and they drift.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy

Six listeners, six songs, play counts instead of stars. Everything below runs in under a second on a laptop.

Implicit ALS, written out

This is the algorithm from Hu, Koren and Volinsky (2008), the one behind most production implicit recommenders. Every cell of the matrix takes part in training, weighted by how much you trust it.

ials.py
import numpy as np

USERS = ["Aarav", "Bhavna", "Chetan", "Divya", "Esha", "Farhan"]
SONGS = ["Ghazal-1", "Ghazal-2", "Ghazal-3", "Techno-1", "Techno-2", "Techno-3"]

# Play counts from a music app. A 0 means "never played", NOT "disliked".
PLAYS = np.array([
    [12,  9,  0,  0,  0,  0],
    [15,  0,  8,  0,  1,  0],
    [ 0, 11, 14,  1,  0,  0],
    [ 0,  0,  1, 16, 12,  0],
    [ 1,  0,  0,  0, 14, 18],
    [ 0,  1,  0, 13,  0, 15],
], dtype=float)

PREF = (PLAYS > 0).astype(float)     # what we want to predict: touched it or not
ALPHA, K, REG, SWEEPS = 40.0, 2, 1.0, 20
CONF = 1.0 + ALPHA * PLAYS           # how much we trust each cell of PREF


def ials(pref, conf, k, reg, sweeps, seed=0):
    """Implicit ALS: every cell takes part, weighted by confidence."""
    rng = np.random.default_rng(seed)
    X = rng.normal(0, 0.01, (pref.shape[0], k))
    Y = rng.normal(0, 0.01, (pref.shape[1], k))
    for _ in range(sweeps):
        for solve_users in (True, False):
            A, B = (X, Y) if solve_users else (Y, X)
            Pm, Cm = (pref, conf) if solve_users else (pref.T, conf.T)
            BtB = B.T @ B                                   # shared by every row
            for r in range(A.shape[0]):
                c = Cm[r]
                # B^T C B  ==  B^T B + B^T (C - I) B, because C is diagonal
                M = BtB + B.T @ ((c - 1.0)[:, None] * B) + reg * np.eye(k)
                A[r] = np.linalg.solve(M, B.T @ (c * Pm[r]))
    return X, Y


def top_unplayed(scores, u, n=2):
    cand = [i for i in range(len(SONGS)) if PLAYS[u, i] == 0]
    cand.sort(key=lambda i: (-scores[u, i], SONGS[i]))
    return "  ".join(f"{SONGS[i]} {scores[u, i]:+.3f}" for i in cand[:n])


print("one play and twelve plays are not the same evidence:")
for u, i in [(0, 0), (1, 4), (4, 0), (4, 5)]:
    print(f"  {USERS[u]:<7} {SONGS[i]:<9} plays {int(PLAYS[u, i]):>3}   "
          f"preference {PREF[u, i]:.0f}   confidence {CONF[u, i]:>6.1f}")

X, Y = ials(PREF, CONF, K, REG, SWEEPS)
S = X @ Y.T
print("\nwith confidence weighting (ALPHA = 40):")
for u in range(len(USERS)):
    print(f"  {USERS[u]:<7} {top_unplayed(S, u)}")

X0, Y0 = ials(PREF, np.ones_like(CONF), K, REG, SWEEPS)
S0 = X0 @ Y0.T
print("\nignoring play counts (ALPHA = 0, every touch weighted the same):")
for u in range(len(USERS)):
    print(f"  {USERS[u]:<7} {top_unplayed(S0, u)}")
Output
one play and twelve plays are not the same evidence:
  Aarav   Ghazal-1  plays  12   preference 1   confidence  481.0
  Bhavna  Techno-2  plays   1   preference 1   confidence   41.0
  Esha    Ghazal-1  plays   1   preference 1   confidence   41.0
  Esha    Techno-3  plays  18   preference 1   confidence  721.0

with confidence weighting (ALPHA = 40):
  Aarav   Ghazal-3 +0.780  Techno-2 +0.724
  Bhavna  Ghazal-2 +1.024  Techno-1 +0.964
  Chetan  Ghazal-1 +0.988  Techno-2 +0.982
  Divya   Techno-3 +1.223  Ghazal-2 +0.796
  Esha    Techno-1 +0.994  Ghazal-3 +0.994
  Farhan  Techno-2 +0.993  Ghazal-3 +0.991

ignoring play counts (ALPHA = 0, every touch weighted the same):
  Aarav   Ghazal-3 +0.238  Techno-1 +0.232
  Bhavna  Techno-3 +0.220  Techno-1 +0.141
  Chetan  Techno-3 +0.231  Techno-2 +0.141
  Divya   Ghazal-1 +0.352  Ghazal-2 +0.337
  Esha    Ghazal-3 +0.350  Techno-1 +0.083
  Farhan  Ghazal-3 +0.338  Techno-2 +0.068

The two blocks at the bottom are the lesson

The play data was built so each listener has exactly one unheard song from their own cluster. Ghazal listeners should be offered the missing ghazal. Techno listeners should be offered the missing techno track.

With confidence weighting, all six are right. Aarav gets Ghazal-3, Divya gets Techno-3, Farhan gets Techno-2.

Without it, five of the six are wrong. Bhavna, a ghazal listener, is offered Techno-3. Divya, a techno listener, is offered Ghazal-1.

The only difference between the two runs is ALPHA. When every touch counts the same, Bhavna's single accidental play of Techno-2 weighs exactly as much as her fifteen plays of Ghazal-1. The stray play drags her into the wrong cluster.

Now read the margins, because they matter more than the wins

Esha's top two are +0.994 and +0.994. The gap is smaller than the third decimal place.

That is six rows of data telling you the truth: this ranking is a coin flip. On a toy matrix, an impressive result and a lucky result look identical. Do not conclude from this page that iALS "works" — conclude that confidence weighting changes the answer, and go measure it properly on your own data with ranking metrics.

The hyperparameters are fragile here too. Change REG from 1.0 to 0.1 and the model gets two of the six wrong.

Line by line, for the parts that are not obvious

PREF = (PLAYS > 0) and CONF = 1 + ALPHA * PLAYS — the split described in the beginner block, in two lines. The model predicts PREF. CONF decides how loudly each cell argues. An unplayed cell has confidence 1.0, so it still participates and still says "probably no", quietly.

BtB + B.T @ ((c - 1.0)[:, None] * B) — this is the trick that makes the whole method affordable. The exact update needs $B^\top C_r B$ for every row, where $C_r$ is a diagonal matrix of that row's confidences. Building an $n \times n$ diagonal matrix per row would be hopeless. Since $C_r = I + (C_r - I)$ and only interacted cells make $C_r - I$ non-zero, you compute B.T @ B once and add a correction that touches only the non-zero entries.

np.linalg.solve(M, ...) — solving M x = b rather than computing inv(M) @ b. It is faster and numerically better behaved. Reach for np.linalg.inv in production code and a reviewer should ask you why.

reg * np.eye(k) — regularisation, and here it also guarantees M is invertible. A user who has interacted with fewer than k items gives a singular matrix without it, and you get LinAlgError: Singular matrix.

The loop over both sides — alternating least squares. Fix the items, solve exactly for every user; fix the users, solve exactly for every item. Each half is a convex problem with a closed-form answer, which is why there is no learning rate anywhere in this file.

Common mistakes

Feeding play counts in as if they were ratings. Forty plays is not "rated 40 out of 5". Counts are unbounded, heavy-tailed and dominated by a few obsessives. If you must use the count, compress it first with log(1 + plays) — the Hu, Koren and Volinsky paper offers exactly that as an alternative confidence function.

Sampling only the positives. A model trained on interactions alone learns "everything is good" and cannot rank. You need the zeros, either weighted like the code above or sampled as negatives like BPR does.

Forgetting that exposure is not random. Your log contains what the old system showed. Items never shown look identical to items shown and rejected. Log impressions, not only clicks, and you can tell those two apart. Most teams discover this after a year.

Treating a long dwell time as enjoyment. Autoplay, a phone left face-up and a confusing page all produce long sessions. Cap dwell-based signals, and require an explicit positive action for the strongest weights.

Using RMSE to evaluate it. There is no rating to be wrong about. Score the ranking instead — see ranking metrics.

Try it yourself

Change Bhavna's stray Techno-2 play from 1 to 10 and re-run with ALPHA = 40. Her top pick is still Ghazal-2, but the gap to the techno track shrinks from 0.060 to 0.008.

Push it to 25 and the order flips. Somewhere between 20 and 25 plays, the model stops reading that play as an accident and starts reading it as a taste.

You do not get to choose that threshold by eye on real data. It falls out of ALPHA, and ALPHA is tuned against an online experiment — see A/B testing a recommender.

What to learn next

Researcher — Mathematics and papers.

The iALS objective

Hu, Koren and Volinsky (2008) replace the sum over observed entries with a sum over all entries, weighted:

$$ \min_{X, Y} \sum_{u \in U} \sum_{i \in I} c_{ui} \left( p_{ui} - \mathbf{x}_u^\top \mathbf{y}_i \right)^2 + \lambda \left( \sum_u |\mathbf{x}_u|^2 + \sum_i |\mathbf{y}_i|^2 \right) $$

Where:

  • $p_{ui} = \mathbb{1}[r_{ui} > 0]$ — binary preference derived from any interaction count $r_{ui}$.
  • $c_{ui} = 1 + \alpha r_{ui}$ — confidence. The linear form is the paper's default; $c_{ui} = 1 + \alpha \log(1 + r_{ui}/\epsilon)$ is the alternative for heavy-tailed counts.
  • $\alpha$ — how much one interaction is worth relative to a non-interaction. Values of 15 to 40 are common.
  • $\lambda$ — regularisation, usually scaled by the number of observations per row.

The baseline confidence of $1$ on every cell is the design decision that matters. Unobserved pairs are treated as weak negatives rather than as missing, which converts an unconstrained problem into a well-posed one at the cost of a systematic assumption you should be able to defend.

The closed-form updates

With $Y \in \mathbb{R}^{n \times k}$ fixed and $C^u = \operatorname{diag}(c_{u1}, \dots, c_{un})$:

$$ \mathbf{x}_u = \left( Y^\top C^u Y + \lambda \mathbf{I}_k \right)^{-1} Y^\top C^u \mathbf{p}_u $$

Computed naively this is $O(n k^2)$ per user, giving $O(mnk^2)$ per sweep — infeasible. The paper's identity

$$ Y^\top C^u Y = Y^\top Y + Y^\top (C^u - \mathbf{I}) Y $$

lets $Y^\top Y$ be computed once per sweep at $O(nk^2)$, while the correction term has support only on ${ i : r_{ui} > 0 }$. Total cost per sweep drops to

$$ O!\left( k^2 N + k^3 (m + n) \right) $$

with $N$ the number of non-zeros, $m = |U|$, $n = |I|$. Linear in the data, cubic only in the small factor dimension, and every row solve is independent, so it distributes without communication.

The alternative: pairwise ranking

BPR (Rendle et al., 2009) attacks the same data with a different loss. Instead of regressing towards $p_{ui}$, it maximises the probability that an observed item outranks an unobserved one:

$$ \max_\Theta \sum_{(u,i,j) \in D_S} \ln \sigma!\left( \hat{x}{uij}(\Theta) \right) - \lambda |\Theta|^2, \qquad \hat{x}{uij} = \hat{x}{ui} - \hat{x}{uj} $$

$D_S = {(u,i,j) : i \in I_u^+, \, j \notin I_u^+}$, sampled rather than enumerated, and $\sigma$ is the logistic function. The objective is a smoothed AUC, which aligns the loss with the ranking task instead of with a reconstruction task.

The practical difference: iALS uses full-matrix weighted least squares and is exact per sweep; BPR uses sampled SGD and scales to settings where even the weighted formulation is too large. Neither dominates. Rendle, Krichene, Zhang and Anderson (2022), Revisiting the Performance of iALS on Item Recommendation Benchmarks, showed iALS with careful regularisation and enough factors matches or beats far newer models on standard benchmarks — another instance of the tuning-effort asymmetry.

Exposure and position bias

The missing-not-at-random structure is the deepest problem in this data, and confidence weighting does not address it. Observing $(u,i)$ requires two events: exposure and then interaction.

$$ P(\text{click}{ui} = 1) = \underbrace{P(o{ui} = 1)}{\text{was it shown}} \cdot \underbrace{P(\text{relevant}{ui} = 1 \mid o_{ui} = 1)}_{\text{what you want to model}} $$

Fitting clicks directly estimates the product. Joachims, Swaminathan and Schnabel (2017), Unbiased Learning-to-Rank with Biased Feedback, correct for this by weighting each observed click by the inverse of its propensity $P(o_{ui}=1)$, estimated from a position-bias model fitted with result randomisation or swap experiments.

Two consequences worth internalising:

  1. Impressions must be logged. Without them, exposure is unidentifiable and no correction is possible after the fact. This is a data-engineering decision made long before any modelling.
  2. Propensity variance is the binding constraint. Rarely shown items have tiny propensities and huge inverse weights. Clipping trades a little bias for a large variance reduction, and is standard.

Liang et al. (2016), Modeling User Exposure in Recommendation, model exposure as a latent variable instead, which avoids needing external propensity estimates at the cost of a harder inference problem.

Papers

  • Hu, Koren and Volinsky (2008), Collaborative Filtering for Implicit Feedback Datasets, ICDM.
  • Rendle et al. (2009), BPR: Bayesian Personalized Ranking from Implicit Feedback, UAI — arxiv.org/abs/1205.2618
  • He et al. (2016), Fast Matrix Factorization for Online Recommendation with Implicit Feedback (eALS), SIGIR.
  • Liang et al. (2016), Modeling User Exposure in Recommendation, WWW — arxiv.org/abs/1510.07025
  • Joachims, Swaminathan and Schnabel (2017), Unbiased Learning-to-Rank with Biased Feedback, WSDM — arxiv.org/abs/1608.04468
  • Rendle et al. (2022), Revisiting the Performance of iALS on Item Recommendation Benchmarks — arxiv.org/abs/2110.14037

What to learn next