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".
- 16 min read
- 3 reading levels
- Updated
Read these first
On this page 9
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 LOWNow 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
- The cold-start problem — what to do with a user who has generated nothing.
- Ranking metrics — how you score a model with no ratings to compare against.
- Matrix factorisation — the explicit-feedback version of the model below.
Developer — Code and libraries.
Setup
pip install numpySix 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.
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)}")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
- Ranking metrics — the right scoreboard for this kind of model.
- The cold-start problem — users with no behaviour at all.
- Two-tower retrieval models — the same objective with learned encoders and sampled negatives.
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:
- 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.
- 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
- Ranking metrics — the evaluation this objective is aiming at.
- Two-tower retrieval models — sampled softmax as the modern successor to BPR sampling.
- A/B testing a recommender — the only way to check whether the proxy still tracks value.