Matrix factorisation
Matrix factorisation describes every user and every item by a short list of hidden traits, learned from the ratings, so a missing rating becomes a multiplication instead of a lookup.
- 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
Matrix factorisation describes every person and every item with the same short list of hidden traits, then matches them.
The analogy you have already lived
Think of the sound settings on a music app: sliders for bass, treble and voice. You push the bass up because that is what you like.
Now imagine every song also came with its own slider positions, saying how much bass and treble it actually has. Then finding songs you will enjoy becomes a matching game. Your sliders against the song's sliders.
Matrix factorisation is that idea, with one twist. Nobody sets the sliders. The system works out both sets — yours and every song's — from nothing but a table of ratings.
Why it exists
Collaborative filtering compares whole rows of a giant, mostly empty table. That gets slow and unreliable when the table is 99 percent empty, because two people rarely share enough items to compare.
The trick is compression. Instead of storing a row of 40,000 numbers for each person, store a row of 50. Instead of storing a column of ten million numbers for each film, store 50.
Those 50 numbers are called latent factors — hidden traits the system invented for itself. Nobody labels them. When you look at what they learned, some are recognisable, like "how much action" or "how old". Most are not nameable at all, and that is fine.
The story that made it famous
In 2006, Netflix offered a million dollars to anyone who could beat their rating predictor by ten percent. Thousands of teams joined.
The single most useful idea to come out of that competition was this one. Simon Funk published a compact version of it on a blog in 2006, and almost every winning entry was built on top of it.
How it works, in one picture
the ratings table two small tables
people x films = people x traits times traits x films
(huge, mostly empty) (small, full) (small, full)
6 x 40,000 6 x 50 50 x 40,000Two small full tables, multiplied together, reproduce the big empty one.
Here is why that is useful. The big table has holes. The two small tables have no holes. So multiply them and you get a number for every cell, including the ones that were empty.
The empty cells are the recommendations.
This is the part that surprises everyone the first time. The system is not looking up an answer. It is rebuilding the whole table from a much smaller description, and the rebuilt version has opinions about cells that never existed.
Why compression forces it to learn something real
Squeezing a table of a million ratings into 50 traits per person leaves no room to memorise. There is not enough space.
So the only way to fit is to find patterns that apply to many people at once. "People who rated these four films highly also rate this fifth one highly" is a pattern worth storing. A single person's quirk is not.
Compression is the whole reason this works. It is the same reason a good summary of a book is more useful than a random half of the pages.
The honest part
The traits are not explainable. You will get a factor that separates films cleanly, look at it, and find no word in your language for what it separates.
That is a real problem when someone asks why a recommendation appeared. "Because your third hidden trait is high and this film's third hidden trait is high" is not an answer any user accepts.
And the cold-start problem does not go away. A new film has no ratings, so it has no traits, so it can never be recommended. Matrix factorisation makes existing data go further. It creates nothing from nothing. See the cold-start problem.
Remember this
- Every person and every item gets a short list of hidden traits, learned from ratings.
- Multiplying the two small tables fills in every missing cell.
- The traits are powerful and unexplainable, and new items still have none.
What to learn next
- Implicit feedback — the version that works on clicks instead of stars.
- The cold-start problem — the gap this method leaves open.
- Two-tower retrieval models — the same idea, with a neural network on each side.
Developer — Code and libraries.
Setup
pip install numpyOne library. The whole training run below finishes in well under a second on any laptop, because the table is six by six.
Matrix factorisation with biases, trained by hand
import numpy as np
USERS = ["Aarav", "Bhavna", "Chetan", "Divya", "Esha", "Farhan"]
FILMS = ["Sholay", "Lagaan", "3 Idiots", "Dangal", "Tumbbad", "Stree"]
R = np.array([
[5, 4, 5, 0, 1, 0],
[4, 5, 4, 5, 2, 1],
[5, 4, 0, 4, 1, 2],
[1, 2, 1, 0, 5, 4],
[2, 1, 0, 1, 4, 5],
[0, 1, 2, 0, 5, 4],
], dtype=float)
OBSERVED = [(u, i, R[u, i]) for u in range(6) for i in range(6) if R[u, i] > 0]
MU = float(np.mean([r for _, _, r in OBSERVED])) # the global average rating
K, LR, REG, EPOCHS = 2, 0.05, 0.05, 1000
rng = np.random.default_rng(0)
P = rng.normal(0, 0.1, (6, K)) # one row of hidden traits per person
Q = rng.normal(0, 0.1, (6, K)) # one row of hidden traits per film
bu = np.zeros(6) # "this person rates high or low in general"
bi = np.zeros(6) # "this film gets rated high or low in general"
def predict(u, i):
return MU + bu[u] + bi[i] + P[u] @ Q[i]
def rmse():
err = [(r - predict(u, i)) ** 2 for u, i, r in OBSERVED]
return float(np.sqrt(np.mean(err)))
print("observed ratings:", len(OBSERVED), " global mean:", round(MU, 3))
print("\nepoch training RMSE")
for epoch in range(EPOCHS + 1):
if epoch in (0, 10, 25, 50, 100, 250, 500, 1000):
print(f"{epoch:5d} {rmse():.4f}")
for u, i, r in OBSERVED:
e = r - predict(u, i) # how wrong we are on this one cell
pu, qi = P[u].copy(), Q[i].copy()
P[u] += LR * (e * qi - REG * pu) # walk each factor downhill
Q[i] += LR * (e * pu - REG * qi)
bu[u] += LR * (e - REG * bu[u])
bi[i] += LR * (e - REG * bi[i])
print("\nlearned film factors (2 hidden traits per film):")
for i, film in enumerate(FILMS):
print(f" {film:<9} [{Q[i, 0]:+.2f}, {Q[i, 1]:+.2f}] bias {bi[i]:+.2f}")
print("\nfilled-in cells (the ones that were 0):")
for u in range(6):
for i in range(6):
if R[u, i] == 0:
print(f" {USERS[u]:<7} {FILMS[i]:<9} {predict(u, i):.2f}")observed ratings: 29 global mean: 3.103
epoch training RMSE
0 1.6248
10 0.8758
25 0.4659
50 0.3487
100 0.1532
250 0.1495
500 0.1495
1000 0.1495
learned film factors (2 hidden traits per film):
Sholay [-0.64, -1.14] bias -0.02
Lagaan [-1.31, -0.09] bias -0.16
3 Idiots [-0.36, -1.21] bias +0.18
Dangal [-1.28, -0.03] bias -0.08
Tumbbad [+0.88, +1.28] bias -0.02
Stree [+1.38, +0.12] bias -0.13
filled-in cells (the ones that were 0):
Aarav Dangal 4.00
Aarav Stree 1.98
Chetan 3 Idiots 4.98
Divya Dangal 1.98
Esha 3 Idiots 2.67
Farhan Sholay 1.37
Farhan Dangal 1.45Look at the film factors before anything else
Every drama came out with a negative first number. Every horror film came out positive. Lagaan is -1.31, Dangal is -1.28, Stree is +1.38, Tumbbad is +0.88.
The model was handed 29 numbers and no genre labels. It invented a dimension that separates horror from drama, because that dimension explains the ratings better than anything else available.
That is what "latent factor" means, made concrete. The sign is arbitrary — run it with a different seed and horror may come out negative. The split is the real thing.
Now look at the filled-in cells
Chetan / 3 Idiots predicts 4.98, which is a confident recommendation for a drama fan. Divya / Dangal predicts 1.98, which is a confident refusal for a horror fan. Both are correct, and neither cell existed in the input.
Esha / 3 Idiots predicts 2.67, and that is the interesting one. It sits in the middle because Esha's evidence is thin and mixed. A middling score is the model saying it is unsure, and a good product treats that differently from a 4.98.
The number nobody quotes honestly
Training RMSE settled at 0.1495. That number means almost nothing.
Count the parameters: P has 12, Q has 12, plus 6 user biases and 6 item biases. That is 36 parameters fitted to 29 observations. There are more knobs than data points. The model can bend itself to the training set completely.
Report training error on a real project and you are reporting how well the model memorised. You need a held-out set split by time — train, test and validation splits covers the mechanics, and overfitting and underfitting covers why this happens.
Line by line, for the parts that are not obvious
MU + bu[u] + bi[i] + P[u] @ Q[i] — the prediction has four parts, and the first three are not the interesting model. MU is the global average. bu[u] says this person rates generously or harshly. bi[i] says this film is generally loved or disliked. Only P[u] @ Q[i] is personal taste.
The bias terms sound like plumbing and are not. On the Netflix data, the three bias terms alone explain a large share of the variance in ratings. Leave them out and the factors waste their capacity re-learning "Bhavna is a generous rater" instead of learning taste.
pu, qi = P[u].copy(), Q[i].copy() — this line is a genuine bug trap. The update to P[u] uses Q[i], and the update to Q[i] uses P[u]. Without the copies, the second line reads a P[u] that the first line already changed. The code still runs, converges to something, and is subtly wrong.
- REG * pu — weight decay, which pulls factors back toward zero unless the data pushes hard. This is the regularisation term, and it is what stops 36 parameters from perfectly memorising 29 ratings. Set REG = 0.0 and re-run: the training RMSE improves from 0.1495 to 0.1305, while Esha / 3 Idiots jumps from 2.67 to 3.18 on no new evidence. A better training number bought with a more confident guess is the trade you are trying to avoid.
rng = np.random.default_rng(0) — a fixed seed, so your output matches this page. Remove the seed and the numbers change every run, including the sign of the factors.
Common mistakes
Iterating over the zeros. OBSERVED contains only rated cells. Loop over all 36 cells and you teach the model that "not rated" means "rated zero", which drags every prediction down. When the data really is clicks rather than ratings, you must handle the zeros deliberately — see implicit feedback.
Initialising factors at zero. Set P and Q to zeros and they stay at exactly zero forever, because each factor update multiplies by the other factor. The bias terms still learn, so the code runs and the RMSE drops to 1.5592 — and every prediction is now MU + bu[u] + bi[i], with no personalisation at all. A model that quietly degrades into a popularity baseline is worse than one that crashes. Small random values break the symmetry.
A learning rate that is too large. Try LR = 0.5 and watch the RMSE grow instead of shrink, then turn into nan. Products of two growing factors diverge fast. Gradient descent explains the mechanism.
Choosing K by training error. More factors always fit the training set better. K must be chosen on a validation split, and on small catalogues the best value is often surprisingly low.
Forgetting to clip. Nothing constrains predictions to the rating scale. Clamp to [1, 5] before display.
Try it yourself
Set K = 1 and re-run. One trait still separates horror from drama, and the training RMSE rises to 0.4621. Then set K = 5 and watch it drop to 0.0734 on a table with 29 observations and 72 parameters.
That drop is not skill. It is memorisation, and seeing it happen on six rows makes the concept permanent.
What to learn next
- Implicit feedback — how this changes when nobody gives you stars.
- Ranking metrics — why RMSE was the wrong scoreboard.
- Two-tower retrieval models — the neural descendant of this model.
Researcher — Mathematics and papers.
Objective
The standard regularised formulation, following Koren, Bell and Volinsky (2009):
$$ \min_{P, Q, b_*} \sum_{(u,i) \in \mathcal{K}} \left( r_{ui} - \mu - b_u - b_i - \mathbf{p}_u^\top \mathbf{q}_i \right)^2 + \lambda \left( |\mathbf{p}_u|^2 + |\mathbf{q}_i|^2 + b_u^2 + b_i^2 \right) $$
Where:
- $\mathcal{K}$ — the set of observed $(u,i)$ pairs. The sum runs over observations only, never over the full grid.
- $\mu \in \mathbb{R}$ — the global mean rating.
- $b_u, b_i \in \mathbb{R}$ — user and item bias terms.
- $\mathbf{p}_u \in \mathbb{R}^k$, $\mathbf{q}_i \in \mathbb{R}^k$ — the latent factor vectors, $k \ll \min(|U|, |I|)$.
- $\lambda > 0$ — the regularisation strength.
Why this is not SVD
The name "SVD" attached itself to this family and is misleading. The truncated singular value decomposition minimises reconstruction error over all entries of a fully observed matrix, and has a unique solution given by the top singular triplets.
Here the sum runs over $\mathcal{K}$ only. Restricting the loss to observed entries makes the problem non-convex in $(P, Q)$ jointly, destroys the orthogonality structure, and removes uniqueness — the objective is invariant under $P \mapsto PA$, $Q \mapsto Q A^{-\top}$ for any invertible $A \in \mathbb{R}^{k \times k}$. Only the product $PQ^\top$ is identified, which is why individual factors have no stable interpretation across runs.
It is convex in $P$ with $Q$ fixed, and vice versa. That is exactly what ALS exploits.
Two optimisers
SGD (the code above). For each observation, with $e_{ui} = r_{ui} - \hat{r}_{ui}$:
$$ \mathbf{p}_u \leftarrow \mathbf{p}u + \gamma\,(e{ui}\,\mathbf{q}_i - \lambda \mathbf{p}_u), \qquad \mathbf{q}_i \leftarrow \mathbf{q}i + \gamma\,(e{ui}\,\mathbf{p}_u - \lambda \mathbf{q}_i) $$
Cost per epoch: $O(|\mathcal{K}| k)$. Simple, memory-light, and the standard choice for explicit-feedback data.
ALS. Fix $Q$ and solve for each $\mathbf{p}_u$ in closed form:
$$ \mathbf{p}u = \left( Q{I_u}^\top Q_{I_u} + \lambda \mathbf{I}k \right)^{-1} Q{I_u}^\top \mathbf{r}_u $$
Where $Q_{I_u}$ stacks the factor rows of the items $u$ rated. Each user solve is independent, so ALS parallelises across a cluster with no communication between solves. Cost per sweep: $O(|\mathcal{K}| k^2 + (|U| + |I|) k^3)$. ALS is the standard choice for implicit feedback, where every cell participates and SGD over $|U| \times |I|$ is infeasible — see Hu, Koren and Volinsky (2008) and implicit feedback.
Extensions that mattered
SVD++ (Koren, 2008) adds an implicit-feedback term to the user representation:
$$ \hat{r}_{ui} = \mu + b_u + b_i + \mathbf{q}_i^\top \left( \mathbf{p}u + |N(u)|^{-1/2} \sum{j \in N(u)} \mathbf{y}_j \right) $$
$N(u)$ is the set of items $u$ interacted with regardless of rating value, and $\mathbf{y}_j$ is a second item factor learned for that purpose. The insight is that which items a user chose to rate carries information independent of how they rated them. This was one of the largest single gains in the Netflix Prize.
timeSVD++ (Koren, 2009) makes $b_u$, $b_i$ and $\mathbf{p}_u$ functions of time, capturing rating drift and item ageing.
BPR-MF (Rendle et al., 2009) replaces squared error with a pairwise ranking loss, optimising $P(i \succ j \mid u)$ over sampled positive-negative pairs. This is the correct objective when the product is a ranked list rather than a predicted score.
The 2020 correction
Neural Collaborative Filtering (He et al., 2017) argued that replacing the dot product $\mathbf{p}_u^\top \mathbf{q}_i$ with a learned MLP over $[\mathbf{p}_u; \mathbf{q}_i]$ improves accuracy.
Rendle, Krichene, Zhang and Anderson (2020), Neural Collaborative Filtering vs. Matrix Factorization Revisited, re-ran the comparison with matched tuning effort. A properly regularised dot product matched or beat the learned similarity on the same benchmarks, and the MLP needed far more capacity to approximate a dot product than to use one. They also showed the dot product's inner-product structure is what makes efficient retrieval possible at all.
The practical reading: a well-tuned matrix factorisation is a serious baseline in 2026, not a historical footnote. Deep models earn their place through features — context, sequence, side information, multi-task objectives — rather than through replacing the interaction function.
Cost summary
| Quantity | Scale |
|---|---|
| Parameters | $k( |
| SGD epoch | $O( |
| ALS sweep | $O(\lvert\mathcal{K}\rvert k^2 + (\lvert U \rvert + \lvert I \rvert)k^3)$ |
| Serving one user's top-$N$ | $O(\lvert I \rvert k)$ exact, or ANN sublinear |
Because scores are inner products, item factors can be indexed with maximum inner product search and retrieval becomes sublinear. That property is inherited by two-tower models and is the reason the field kept the dot product.
Papers
- Funk (2006), Netflix Update: Try This at Home — the original blog post, still worth reading.
- Koren (2008), Factorization Meets the Neighborhood (SVD++), KDD.
- Hu, Koren and Volinsky (2008), Collaborative Filtering for Implicit Feedback Datasets, ICDM.
- Rendle et al. (2009), BPR: Bayesian Personalized Ranking from Implicit Feedback, UAI.
- Koren, Bell and Volinsky (2009), Matrix Factorization Techniques for Recommender Systems, IEEE Computer.
- Rendle et al. (2020), Neural Collaborative Filtering vs. Matrix Factorization Revisited — arxiv.org/abs/2005.09683
What to learn next
- Implicit feedback — the ALS variant and the confidence weighting it needs.
- Two-tower retrieval models — factorisation with learned encoders.
- Ranking metrics — evaluating the list rather than the score.