TL;DR
Recommendation is a ranking task, so accuracy metrics from Part 11 do not transfer. Score with recall and NDCG at a fixed cutoff, on a leave one out split, and always report catalogue coverage next to them.
Run a popularity baseline before anything else. On the dataset built below it scored recall@10 of 0.2669, beating a 64 factor ALS model at 0.0796 by more than three times. Factor count was the single knob that decided whether matrix factorisation was worth having.
Cold start does not raise an error. A brand new user handed to a trained model came back with five item ids and five scores of exactly zero, and nothing in the stack noticed.
Five recommendations came back for a user who had never clicked anything, and every score was 0. Not an exception, not a warning, just item ids 4, 3, 2, 1 and 0 in that order, which is what you get when a zero vector is multiplied against every item factor and the tie is broken by index. That output shipped to production once in a project I worked on, and for eleven days roughly nine percent of sessions were served the five lowest numbered items in the catalogue. Nobody caught it, because a recommender that returns something always looks healthier than one that raises.
Recommendation as a ranking problem
Every model so far in this series has predicted a value for a row. A churn classifier takes one customer and returns one probability. Recommenders do something structurally different: given one user, they order the entire catalogue and hand back the top few. Nothing about that is captured by accuracy, and only loosely by AUC, because a user never sees position 400 and does not care how well it was scored.
Two metrics carry most of the load. Recall at K asks a simple question: of the items a user actually went on to consume, how many appeared in the top K we showed. Normalised discounted cumulative gain, usually written NDCG, asks the same thing but pays attention to position, discounting a correct item at rank 9 relative to the same item at rank 1. Both need a cutoff, and that cutoff should match the real surface. Ten is conventional. If your carousel shows four tiles, measure at four.
A third number belongs next to them, and it gets left out constantly. Catalogue coverage counts how much of your inventory ever reaches anybody. A recommender that shows the same forty items to three thousand users can post a perfectly respectable recall score while doing nothing a merchandising team would call recommendation. Report coverage alongside accuracy or you will optimise your way into a shop window with four products in it.
One more distinction shapes everything downstream. Explicit feedback means a user told you a rating, a five star review or a thumbs up. Implicit feedback means you inferred preference from behaviour: a play, a click, a purchase. Implicit data is far more common and far more awkward, because absence is ambiguous. A film you never watched might be one you would love or one you would hate, and the data cannot tell you which. Hu, Koren and Volinsky worked that ambiguity into the loss function directly, treating every unobserved pair as a weak negative and every observed one as a positive with confidence proportional to interaction strength. That framing underpins the ALS implementation used below.
Two shapes of collaborative filtering
Collaborative filtering means recommending from behaviour patterns across users rather than from item attributes. It comes in two families, and they fail in different places, which is why I usually run both.
Neighbourhood methods compute similarity directly. Item to item cosine similarity, the older and simpler shape, asks which items get consumed by overlapping sets of users, then recommends neighbours of whatever a user already touched. Amazon built its early engine on exactly this, and it survives because it is cheap to update, trivially explainable, and degrades gracefully. Add one interaction and you can serve a new recommendation immediately, with no retraining.
Matrix factorisation takes a different route. It decomposes the sparse user by item matrix into two dense low rank matrices, one row of latent factors per user and one per item, such that their dot product approximates the observed interactions. Alternating least squares, ALS, fits it by holding item factors fixed and solving for user factors, then reversing, repeating until it settles. Each least squares step has a closed form, which is why ALS parallelises well and why it remains the default for implicit feedback at scale.
Leave one out evaluation for implicit feedback
Where the project stands: through Part 19 the Telco churn model had been trained, tuned, rebuilt as a network and finally backtested across forecast horizons. Churn has no user by item interaction matrix in it, so this part steps sideways to a second dataset and comes back to churn at the end. MovieLens is the standard public choice here, and the small release carries 100,836 ratings from 610 users across 9,742 movies, available from GroupLens. Rather than make every number in this part depend on a download, I generate a synthetic implicit feedback matrix with a fixed seed, so you can reproduce each figure exactly by running the code as printed. Swapping in MovieLens is two lines, shown at the end of this section.
Splitting comes next, and random row splitting is wrong here for the same structural reason it was wrong in Part 19. Interactions are not independent rows; they belong to users. Hold out random cells and a user can appear in both train and test, which measures interpolation. Leave one out per user is the standard fix: for every user with at least two interactions, remove exactly one item, train on everything that remains, then check whether the removed item comes back in the top K. Where you have timestamps, remove the most recent item instead of a random one, which is closer to what deployment asks.
To run all of this against MovieLens instead, read ratings.csv, keep rows with a rating of 4 or higher as implicit positives, and factorise the user and movie columns into contiguous integer codes before building the sparse matrix. Everything after that line is unchanged.
ParameterWarning: Method expects CSR input, and was passed coo_matrix instead. Handing it the transpose, items by users, produces no warning at all. It trains happily, returns plausible looking recommendations, and every single one is wrong. Assert your shape against (n_users, n_items) before fitting. That assertion has caught this for me twice.Baselines that beat matrix factorisation
Four models go in: recommend whatever is most popular, item to item cosine, ALS with 16 latent factors, and ALS with 64. Only one line differs between the last two.
Read the coverage column before drawing conclusions, because it changes the story. Popularity reached recall of 0.2669 while touching 4.3 percent of the catalogue, meaning 34 items served 2,814 users. ALS at 64 factors covered 81 percent of inventory and scored terribly. Item to item sat between the two on coverage at 43 percent and won on both accuracy metrics, which is why it is my pick here. Where a merchandising team needs breadth, the honest framing is not that one model is better, it is that popularity buys accuracy with catalogue diversity and you should decide consciously what that trade is worth.
Why did more factors hurt so much. Rank controls capacity, and with 800 items, 2 percent density and a median history of 11 interactions, there is nowhere near enough signal to estimate 64 numbers per user. Each user vector starts fitting the specific quirks of eleven observations rather than a taste direction. Sweeping the parameter makes the shape obvious, and the confidence weight interacts with it.
| Factors | alpha 1.0 | alpha 10.0 | alpha 40.0 |
|---|---|---|---|
| 16 | 0.1219 | 0.2313 | 0.1915 |
| 32 | 0.0732 | 0.1869 | 0.1819 |
| 64 | 0.0402 | 0.0796 | 0.0888 |
Two readings fall out of that grid. Factor count dominates: moving from 16 to 64 cost more recall than any other change in the sweep. Confidence weight matters almost as much, and the default of 1.0 is close to useless on this data, tripling in quality once alpha reached 10. Regularization, the parameter people reach for first, barely registered. If you tune one thing in an ALS model, tune rank, and use the search machinery from Part 15 to do it rather than guessing.
War story
A media client replaced a popularity module with a 128 factor ALS model I built, on the strength of an offline NDCG that looked strong because I had evaluated it on a random cell split rather than leave one out per user. Click through on the recommendation rail dropped 12 percent in the first week and 9 percent in the second. Three weeks passed before anybody connected the two, because the rail was measured by a different team on a different dashboard.
Diagnosis took an afternoon once I finally rebuilt the evaluation properly. Dropping to 24 factors recovered the rail to roughly baseline. Shipping the ensemble described below eventually put it 6 percent above where it started. My rule since then: no recommender goes live until its offline score has beaten most popular on the same split, in writing, with the number attached.
Cold start and what an empty user vector returns
Cold start names three separate problems that get lumped together. A new user has no history, so there is no vector to look up. A new item has no interactions, so it can never be recommended by any collaborative method and will not be until somebody finds it another way. A new system has neither, which is why every recommender project begins with content rules or popularity and earns its way toward collaborative filtering.
Severity of the new user case is easy to underestimate, so measure it directly. Below is what happens when a trained model meets a user row containing nothing at all.
That is the failure I opened with, reproduced in three lines. A zero user vector dotted against every item factor gives zero for all of them, argsort breaks the tie by position, and out come the first five item ids in reverse. Every downstream check passes: the response is well formed, the right length, correctly typed. Guard against it explicitly by checking that the maximum returned score exceeds zero and routing to a fallback when it does not, because no library is going to raise on your behalf.
Contrast that silence with an error the same library does raise, which is worth meeting once so you recognise it. Passing the full training matrix to a batch recommend call, instead of the sliced rows for the users you asked about, fails immediately.
Note the second half of that fix, because it is the part that bites silently. Row i of the sliced matrix must correspond to user i of the id array. Slice with a differently ordered index and every recommendation is served to the wrong person, with no error anywhere. Serving logic therefore needs a routing decision made before the model is consulted at all.
Fallbacks worth building, and one that failed
Averages hide cold start completely, which is why segmenting by history length is the most useful table in this whole part. Breaking the same evaluation into buckets shows where each model earns its keep.
| Training history | Users | ALS 16 | Item item | Most popular |
|---|---|---|---|---|
| 1 to 2 items | 337 | 0.1395 | 0.2582 | 0.2908 |
| 3 to 5 items | 430 | 0.2093 | 0.3302 | 0.3116 |
| 6 to 15 items | 983 | 0.2503 | 0.2869 | 0.2675 |
| 16 to 40 items | 808 | 0.2673 | 0.2933 | 0.2475 |
| 41 or more items | 256 | 0.2031 | 0.2344 | 0.2188 |
Three findings sit in that chart and all three are actionable. ALS is at its weakest exactly where users are newest, climbing from 0.1395 at one or two items to 0.2673 by the sixteen to forty bucket, so a global average understates it for engaged users and overstates it for everybody else. Popularity moves the opposite way, strongest on thin history and steadily overtaken as history accumulates. Beyond forty items every model declines, which surprised me until I looked closer: users with very long histories have already consumed most of what their taste cluster contains, so the held out item tends to be an unusual one.
Reading the crossover suggests an obvious fallback for the thinnest bucket, so I built it and it did not work. Given one or two known items, infer the user taste cluster, then recommend the most popular items inside that cluster. Segmented popularity, essentially, and a design I have seen recommended plenty of times.
Worse by a wide margin, and the reason is instructive. Inferring a cluster from one or two items is close to a coin flip, so a large share of those users get filtered into the wrong segment entirely. Meanwhile the restriction throws away the genuinely popular items outside that cluster, which were carrying most of the baseline hit rate. Two errors compound. Segmentation only pays once your segment assignment is more reliable than the signal you are discarding to make it, and with two observations it is not. Similar reasoning to the trap in correlation versus causation: a relationship that holds in aggregate is not automatically usable on an individual.
My take
Route by history length rather than trying to build one model that covers everybody. Below three interactions, serve popularity, optionally filtered by something you actually know such as a chosen category or a landing page, never by an inferred cluster. Between three and roughly fifteen, item to item cosine wins and updates instantly as interactions arrive. Above that, blend the neighbourhood score with ALS.
Cold items need the opposite treatment: reserve a fixed slot for new inventory rather than hoping collaborative signal finds it, because it cannot. An explicit exploration budget of one slot in ten costs a measurable amount of short term accuracy and is the only mechanism that stops your catalogue calcifying around whatever was popular the month you launched.
Bringing this back to churn
Two threads connect this part to the Telco project we have been carrying since Part 4. First, retention actions are a ranking problem wearing a classification costume. A churn model gives you a probability per customer, but the retention team can call maybe two hundred people a week, so what actually ships is a ranked list with a cutoff. Precision at 200 is the metric that matters, and it is not the same as AUC, exactly as recall at 10 is not accuracy here. Rebuilding the churn evaluation as a top K ranking exercise changes which model you pick, and usually favours the one with better calibration in the high probability tail rather than the best average.
Second, cold start applies to churn just as literally. A customer who signed up nine days ago has almost no behavioural features, and the Telco tenure column encodes exactly that: predictions for very short tenure customers rest on demographics and contract type rather than anything the customer has done. Bucket your churn evaluation by tenure the same way the table above buckets by history, and you will find the same shape, a model that looks fine on average and is close to guessing on the newest cohort. Which is precisely the cohort where retention spend has the most room to change the outcome. Feature availability by tenure is the same discipline covered in cleaning messy data, applied to a segment rather than a column.
Recommender stack I would build first
Build in this order and stop as soon as the next step stops paying. Ship most popular first, with a real leave one out score recorded, because that number is the bar every later model must clear. Add item to item cosine second, since on this data it won every history bucket except the very thinnest and needs no retraining to absorb a new interaction. Introduce ALS third, at a low rank, and tune factors before you touch anything else. Route by history length rather than replacing one model with another. Reserve an exploration slot for cold items from day one, because retrofitting it later means arguing for a deliberate accuracy loss after everybody has grown attached to the current number.
My honest verdict on matrix factorisation after this exercise: it is not the starting point its reputation suggests. On a catalogue of 800 items with two percent density, a 64 factor model lost to fifteen lines of counting by a factor of more than three, and even a well chosen rank finished behind item to item cosine. Reach for ALS when you have millions of interactions, tens of thousands of items and users with substantial histories. Below that, neighbourhood methods and a popularity floor will take you further, and they are far easier to explain when somebody asks why a particular item appeared.
Take whatever recommender you currently run, split its offline evaluation by user history length, and compare each bucket against most popular on the identical split. If popularity wins a bucket, route that bucket to popularity today. That one change has improved more recommendation rails for me than any modelling work I have done on top of them. Part 21 begins the production phase of this series, turning the notebook code you have accumulated across Parts 3 to 20 into a package somebody else can install and run.
References
- AlternatingLeastSquares API reference, implicit 0.7.2 documentation
- Hu, Koren and Volinsky, Collaborative Filtering for Implicit Feedback Datasets, ICDM 2008
- MovieLens latest datasets, GroupLens Research
- scipy.sparse matrix reference, SciPy 1.15.3


DrJha