Content-Based Filtering
Collaborative filtering recommends items based on the ratings of users who gave ratings similar to yours, and its limitations, the cold start problem and the inability to use side information, were exactly what it could not fix on its own. This page develops the algorithm that addresses them: content-based filtering, which recommends items by matching features of the user to features of the item, and which, built with neural networks the way this page builds it, is the approach behind many important commercial state-of-the-art recommender systems today.
Collaborative versus Content-Based Filtering
The two approaches answer the same question, “what should this user see next?”, from different directions:
- Collaborative filtering: recommend items based on ratings of users who gave similar ratings as you. The only input is the ratings matrix.
- Content-based filtering: recommend items based on features of the user and features of the item, using those features to decide which users and items are a good match for each other.
The bookkeeping notation carries over unchanged: \(r(i,j) = 1\) still marks that user \(j\) has rated item \(i\), and \(y^{(i,j)}\) is still that rating where it exists. What is new is the pair of feature vectors that content-based filtering requires. For movie recommendations, features might look like this:
User features, collected into a vector \(\vec{x}_u^{(j)}\) for user \(j\):
- Age of the user.
- Gender, as a one-hot feature (like the one-hot encoding used with decision trees), for example male / female / unknown.
- Country, a one-hot feature with about 200 possible values.
- Past behaviors, for example a thousand features recording which of the top thousand movies in the catalog the user has watched.
- Average rating per genre that the user has given, across all the romance movies the user has rated, what was the average rating, and likewise for action and every other genre. Notice this feature depends on the user’s ratings, and there is nothing wrong with that; a feature vector built partly from the user’s own ratings is a completely fine way to describe a user.
Movie features, collected into a vector \(\vec{x}_m^{(i)}\) for movie \(i\):
- Year of the movie.
- Genre or genres of the movie, if known.
- Critic reviews, one or several features capturing what critics said.
- Average rating of the movie, again a feature that depends on ratings, possibly broken out per country or per user demographic.
The two vectors can be wildly different sizes, the user features might be 1500 numbers while the movie features are just 50, and that is fine.
From \(\vec{w}, \vec{x}\) to \(\vec{v}_u, \vec{v}_m\)
Collaborative filtering predicted user \(j\)’s rating of movie \(i\) as \(\vec{w}^{(j)} \cdot \vec{x}^{(i)} + b^{(j)}\). Content-based filtering changes the cast of characters. First, drop \(b^{(j)}\) entirely, it turns out this does not hurt performance at all. Then replace both remaining pieces with new vectors:
\[ \hat{y}^{(i,j)} = \vec{v}_u^{(j)} \cdot \vec{v}_m^{(i)} \]
where \(\vec{v}_u^{(j)}\) (v for vector, u for user) is a list of numbers computed from the features \(\vec{x}_u^{(j)}\) of user \(j\), and \(\vec{v}_m^{(i)}\) (m for movie) is a list of numbers computed from the features \(\vec{x}_m^{(i)}\) of movie \(i\).
To see why a dot product between such vectors is a sensible prediction, imagine the learned vectors turned out to be interpretable. Say \(\vec{v}_u^{(j)} = (4.9,\ 0.1,\ \dots)\), where the first number captures how much this user likes romance movies and the second how much they like action, and \(\vec{v}_m^{(i)} = (4.5,\ 0.2,\ \dots)\), where the numbers capture how much this movie is a romance or an action movie. Multiplying element-wise and summing rewards every dimension where the user’s taste and the movie’s content line up, a big number times a big number, and ignores dimensions where either is near zero.
One constraint makes this work: although \(\vec{x}_u\) and \(\vec{x}_m\) can have very different sizes, \(\vec{v}_u\) and \(\vec{v}_m\) must have the same dimension, say 32 numbers each, or the dot product is not even defined. So the challenge is: given user features \(\vec{x}_u^{(j)}\), how do you compute a compact vector \(\vec{v}_u^{(j)}\) that represents the user’s preferences, and given movie features \(\vec{x}_m^{(i)}\), how do you compute \(\vec{v}_m^{(i)}\)? That is what the next section answers.
Review Questions
1. In one sentence each, how does collaborative filtering decide what to recommend, and how does content-based filtering decide?
Collaborative filtering recommends items based on ratings from users who gave ratings similar to yours, working from the ratings matrix alone. Content-based filtering recommends items by matching features of the user against features of the item to find good user-item pairings.
1. Give an example of a user feature that depends on the user’s own ratings. Is such a feature allowed?
The average rating the user has given per genre, for example the average across all romance movies they have rated. Yes, it is allowed, constructing a feature vector that depends on the user’s ratings is a completely fine way to describe a user, and it can be a powerful feature.
1. \(\vec{x}_u\) might have 1500 numbers and \(\vec{x}_m\) only 50. Which pair of vectors must nonetheless have matching sizes, and why?
The computed vectors \(\vec{v}_u\) and \(\vec{v}_m\) must have the same dimension (say, 32 each), because the prediction is their dot product \(\vec{v}_u^{(j)} \cdot \vec{v}_m^{(i)}\), which is only defined for vectors of equal length. The raw feature vectors they are computed from are free to differ in size.
1. True or false? Vector \(\vec{x}_u\) and vector \(\vec{x}_m\) must be of the same dimension, where \(\vec{x}_u\) is the input features vector for a user (age, gender, and so on) and \(\vec{x}_m\) is the input features vector for a movie (year, genre, and so on).
False. These vectors can be different dimensions, the user features might be 1500 numbers and the movie features 50. It is the computed vectors \(\vec{v}_u\) and \(\vec{v}_m\) that must match in size, so their dot product is defined.
Deep Learning for Content-Based Filtering
A good way to turn features into those matching vectors is deep learning, and this architecture is how many state-of-the-art commercial content-based systems are built. Two neural networks do the job:
- The user network takes the user features \(\vec{x}_u\) as input (age, gender, country, and so on) and, through a few dense hidden layers, outputs \(\vec{v}_u\). The unusual part: the output layer has 32 units, not 1, so the network’s output is a whole vector, not a single number.
- The item network (here, the movie network) takes \(\vec{x}_m\) as input and outputs \(\vec{v}_m\), also 32 numbers.
The two networks can have completely different hidden architectures, different numbers of layers, different units per layer; only the output layers must share the same dimension. Drawn as one diagram, the two networks run in parallel and meet at a dot product:
For binary labels (did the user engage or not), the same one change as in collaborative filtering applies: pass the dot product through the sigmoid, \(g\big(\vec{v}_u^{(j)} \cdot \vec{v}_m^{(i)}\big)\), and read it as the probability that \(y^{(i,j)} = 1\).
One Cost Function for Both Networks
This model has a lot of parameters, every layer of both networks has its usual weights, but there is no separate training procedure for the user and movie networks. A single cost function judges them jointly, and it is nearly the same one collaborative filtering used, a sum over every pair with a label:
\[ J = \sum_{(i,j)\,:\,r(i,j)=1} \left( \vec{v}_u^{(j)} \cdot \vec{v}_m^{(i)} - y^{(i,j)} \right)^2 \;+\; \text{(NN regularization term)} \]
Different parameter values produce different vectors \(\vec{v}_u\) and \(\vec{v}_m\), so minimizing \(J\) with gradient descent or any other optimizer tunes all the parameters of both networks toward vectors whose dot products match the observed ratings. Adding the usual neural network regularization term keeps the parameters small.
Finding Similar Items, Again
Just as collaborative filtering’s learned features enabled related-item lookup, the trained movie network gives every movie a description vector, and similar movies sit close together: movies \(k\) similar to movie \(i\) are those with small
\[ \left\lVert \vec{v}_m^{(k)} - \vec{v}_m^{(i)} \right\rVert^2 \]
One practical note that becomes important for scaling: this can all be pre-computed ahead of time. A compute server can run overnight, go through the entire catalog, and store the 10 or 20 most similar movies for every movie, so that when a user browses a movie tomorrow, the “similar to this” list is a plain table lookup, no neural network inference at serving time.
Back when decision trees were compared with neural networks, one advantage claimed for neural networks was that several of them can be plugged together and trained as one larger system. This architecture is exactly that advantage in action: a user network and a movie network, trained in concert through a single cost function, joined by nothing more than a dot product. One practical caveat from the trenches: developers building these systems commercially often spend much of their time carefully engineering the features fed into the two networks, and that investment tends to pay off.
The one limitation of the algorithm as described so far: with a large catalog, running it naively is computationally very expensive. Every prediction requires neural network inference, and a big service has far too many items to score them all for every user. The fix is next.
Review Questions
1. What is unusual about the output layers of the user and item networks, compared to the neural networks used for classification earlier in the course?
They output a whole vector, for example 32 units, rather than a single unit. The networks’ job is not to predict a label directly but to compute the description vectors \(\vec{v}_u\) and \(\vec{v}_m\), whose dot product then forms the prediction. The two networks’ hidden layers can differ freely; only the output dimensions must match.
1. How are the parameters of the two networks trained, and what would be wrong with training each network separately?
A single cost function sums the squared error \(\big(\vec{v}_u^{(j)} \cdot \vec{v}_m^{(i)} - y^{(i,j)}\big)^2\) over all rated pairs (plus regularization), and gradient descent tunes all parameters of both networks at once to minimize it. There is no sensible separate target for either network alone, a “correct” \(\vec{v}_u\) is only defined by how well its dot products with the \(\vec{v}_m\) vectors predict ratings, so the networks can only be judged, and trained, jointly.
1. Why is it significant that per-movie similarity lists can be pre-computed overnight?
Because it moves the expensive part, computing \(\vec{v}_m\) for every movie and finding the nearest vectors, out of the moment a user is on the site. At serving time, showing “movies similar to this one” is a lookup-table read, no inference needed. This pre-computation idea is also a key building block for scaling recommenders to huge catalogs.
1. If we find that two movies, \(i\) and \(k\), have vectors \(\vec{v}_m^{(i)}\) and \(\vec{v}_m^{(k)}\) that are similar to each other (i.e., \(\lVert \vec{v}_m^{(i)} - \vec{v}_m^{(k)} \rVert\) is small), then which of the following is likely to be true? Pick the best answer.
- The two movies are very dissimilar.
- The two movies are similar to each other and will be liked by similar users.
- We should recommend to users one of these two movies, but not both.
- A user that has watched one of these two movies has probably watched the other as well.
(b). Similar movies generate similar \(\vec{v}_m\)’s. The description vectors are trained so that dot products with user vectors predict ratings, so two movies sitting close together in that space appeal to the same users. Nothing about a small distance says the movies should not both be recommended, (c), or that watching one implies having watched the other, (d).
Recommending from a Large Catalogue
A movie streaming site may have thousands of movies; an ad system, millions of ads; a music service, tens of millions of songs; a large shopping site, tens of millions of products. When a user shows up, their features \(\vec{x}_u\) are available, but feeding millions of items through the item network and computing millions of dot products, every single time any user loads a page, is computationally infeasible.
Large-scale recommender systems handle this with two steps, retrieval and ranking.
Retrieval
The retrieval step quickly generates a large list of plausible candidates, aiming for broad coverage rather than precision. It is fine if the list includes items the user will not like; the goal is to make sure enough good candidates are in there somewhere. For a movie site, retrieval might assemble:
- For each of the last 10 movies the user watched, the 10 most similar movies, exactly the pre-computed \(\lVert \vec{v}_m^{(k)} - \vec{v}_m^{(i)} \rVert^2\) lookup from the previous section, so this costs a table read, not a network inference.
- The top 10 movies in each of the user’s 3 most-watched genres, say romance, comedy, and historical drama.
- The top 20 movies in the user’s country.
Combine these into one list, remove duplicates, and remove movies the user has already watched or purchased. The result is maybe 100 or a few hundred plausible candidates, produced very fast.
Ranking
The ranking step takes that shortlist and scores it properly with the learned model: feed the user vector and each retrieved movie’s vector through the network, compute the predicted rating for every pair, and display the list sorted by predicted rating.
One additional optimization makes ranking fast too. If \(\vec{v}_m\) has been pre-computed for every movie in the catalog, then when a user arrives, only the user network needs to run, once, to get \(\vec{v}_u\). Ranking a few hundred retrieved movies is then just a few hundred dot products against stored vectors, quick enough to do while the page loads.
How Many Items to Retrieve?
Retrieving more items tends to improve results, the shortlist is more likely to contain the true best recommendations, but makes the system slower. The way to choose between retrieving 100, 500, or 1000 items is to run offline experiments: check whether retrieving more items actually causes the model’s predictions for the displayed items to improve (for example, whether the estimated \(P(y^{(i,j)} = 1)\) of what ends up recommended gets meaningfully higher). If going from 100 to 500 retrieved items yields noticeably more relevant recommendations, the extra latency may be worth it. Together, retrieval for speed and ranking for accuracy let recommenders serve enormous catalogs with both fast and high-quality results.
Review Questions
1. Why can’t a large service simply run the neural network over every item in the catalog each time a user arrives?
Catalogs run to millions or tens of millions of items (ads, songs, products), and running item-network inference plus a dot product for every one of them, on every page load, for every user, is computationally infeasible. The cost of scoring the full catalog per request is what the retrieval-plus-ranking split exists to avoid.
1. Describe what the retrieval step and the ranking step each do, and what each is optimized for.
Retrieval quickly assembles a broad list of a few hundred plausible candidates from cheap sources, pre-computed similar-item lookups for recently watched movies, top movies in the user’s favorite genres, top movies in their country, then deduplicates and drops already-seen items; it is optimized for speed and coverage, and false positives are acceptable. Ranking then scores just that shortlist with the learned model and sorts by predicted rating; it is optimized for accuracy, and it is affordable because it runs on hundreds of items, not millions.
1. With \(\vec{v}_m\) pre-computed for the whole catalog, how much neural network inference happens at serving time, and how would you decide how many items to retrieve?
One inference: the user network runs once to produce \(\vec{v}_u\), and everything after that is dot products against stored movie vectors. The retrieval count (100 versus 500 versus 1000) is chosen by offline experiments, measuring whether retrieving more items leads to meaningfully more relevant recommendations, and trading that gain against the added slowness.
1. You have built a recommendation system to retrieve musical pieces from a large database of music, with separate retrieval and ranking steps. If you modify the algorithm so the retrieval step returns more items, which of these are likely to happen? (Check all that apply.)
- The quality of recommendations made to users should stay the same or improve.
- The system’s response time might decrease (users get recommendations more quickly).
- The system’s response time might increase (users have to wait longer to get recommendations).
- The quality of recommendations made to users should stay the same or worsen.
(a) and (c). A larger retrieved list gives the ranking step more options to choose from, so recommendations should stay the same or improve, and a larger list takes longer to score, so response time may increase. That trade, better results against added slowness, is exactly what the offline experiments above are for.
1. True or false? To speed up the response time of your recommendation system, you can pre-compute the vectors \(\vec{v}_m\) for all the items you might recommend. This can be done even before a user logs in to your website, and even before you know the \(\vec{x}_u\) or \(\vec{v}_u\) vector.
True. The movie network computes \(\vec{v}_m\) from movie features alone, its output does not depend on the user network at all, so a compute server can work through the whole catalog ahead of time. When a user shows up, only the user network needs to run, and ranking is just dot products against the stored vectors.
Ethical Use of Recommender Systems
Recommender systems are enormously profitable, and some of their use cases have left people and society worse off. The choices are worth examining, because “what should the system optimize?” is a genuine design decision. Consider a spectrum of goals a recommender might be configured for:
- Recommend movies the user is most likely to rate 5 stars. Seems fine, this is showing users what they will enjoy.
- Recommend products the user is most likely to purchase. Also quite reasonable.
- Show ads most likely to be clicked, weighted by the advertiser’s bid, since ad revenue depends on clicks times price-per-click. Profit-maximizing, with possible negative implications.
- Recommend products that generate the largest profit for the company, ranking the high-margin item above the most relevant one. Many sites do this today, and the user has no way to tell.
- Maximize user engagement, the total time spent on the site, since more time means more ads shown.
The first two seem innocuous; the last three may be fine, or may be genuinely problematic.
The ad-bidding amplifier. The advertising auction can amplify the best businesses, and the most harmful ones. In the travel industry, a company that gives users genuinely good trips tends to be more profitable, can therefore bid more for ads, gets more traffic, serves more users well, and becomes more profitable still, a virtuous cycle that statistically helps good companies. Now run the same loop on the payday loan industry, which charges extremely high interest rates, often to low-income individuals: the company most efficient at squeezing customers for every dollar is the most profitable, so it bids highest, so it gets the most traffic, so it can exploit even more people. The identical feedback loop amplifies the most exploitative player. There is no easy solution; one partial amelioration is refusing to sell ads to exploitative businesses, though defining “exploitative” precisely is itself a very difficult question.
Engagement maximization. It has been widely reported that maximizing watch time and time-on-site has led large social media and video platforms to amplify conspiracy theories and hate and toxicity, because that content is highly engaging, even when amplifying it harms individuals and society. A partial, imperfect amelioration is filtering out problematic content, hate speech, fraud, scams, certain violent content, though what exactly to filter is again surprisingly tricky to pin down, and remains something companies, individuals, and governments continue to wrestle with.
Transparency. Many users assume the app is recommending things it thinks they will like, and do not realize the ranking may instead be maximizing the company’s profit. Being transparent with users about the criteria behind recommendations is not always easy, but it builds trust and pushes these systems toward doing more good.
Anyone building a recommender, or any learning system, should think through not only the benefit it creates but the harm it could do, invite diverse perspectives and open debate, and only build things that leave people and society better off.
Review Questions
1. Rank these recommender goals from least to most ethically fraught, and say why: maximize engagement; recommend movies likely to be rated 5 stars; rank products by company profit.
Recommending movies likely to be rated 5 stars is the most innocuous, it directly serves the user’s own enjoyment. Ranking products by company profit is more fraught: the user assumes relevance but is being shown high-margin items, without transparency about the criteria. Maximizing engagement has proven the most harmful in practice, since highly engaging content includes conspiracy theories and toxic material, which the system then amplifies at scale.
1. Explain how the same ad-auction feedback loop can help society in one industry and harm it in another.
The loop is: more profitable → can bid more for ads → more traffic → more customers → more profitable. In travel, profitability tends to come from serving users well, so the loop amplifies good companies, a virtuous cycle. In payday lending, profitability comes from squeezing customers most efficiently, so the very same loop sends the most traffic to the most exploitative company. The auction amplifies whatever makes a business profitable, regardless of whether that thing is good for users.
1. Name two ameliorations discussed for recommender harms, and the shared difficulty both run into.
Refusing to sell ads to exploitative businesses, and filtering out problematic content such as hate speech, fraud, and scams. Both run into the same difficulty: drawing the definitional line, deciding precisely what counts as “exploitative” or “problematic” is itself a hard, contested problem that requires ongoing debate among companies, individuals, and governments.
TensorFlow Implementation
The two-tower architecture maps onto TensorFlow neatly, and most of the pieces are familiar from the neural network pages. Each tower is an ordinary Sequential stack of dense layers, relu activations for the hidden layers, and a final layer of 32 units with no activation, since it outputs the raw description vector. The new functions appear on the numbered lines:
import tensorflow as tf
num_user_features = 14
num_item_features = 16
num_outputs = 32
user_NN = tf.keras.models.Sequential([
tf.keras.layers.Dense(256, activation="relu"),
tf.keras.layers.Dense(128, activation="relu"),
tf.keras.layers.Dense(num_outputs),
])
item_NN = tf.keras.models.Sequential([
tf.keras.layers.Dense(256, activation="relu"),
tf.keras.layers.Dense(128, activation="relu"),
tf.keras.layers.Dense(num_outputs),
])
1input_user = tf.keras.layers.Input(shape=(num_user_features,))
vu = user_NN(input_user)
2vu = tf.keras.layers.UnitNormalization(axis=1)(vu)
input_item = tf.keras.layers.Input(shape=(num_item_features,))
vm = item_NN(input_item)
vm = tf.keras.layers.UnitNormalization(axis=1)(vm)
3output = tf.keras.layers.Dot(axes=1)([vu, vm])
4model = tf.keras.Model([input_user, input_item], output)
5cost_fn = tf.keras.losses.MeanSquaredError()
model.summary()- 1
-
tf.keras.layers.Inputdeclares an entry point for data and its shape. Feeding it throughuser_NNwires the user features into the tower that computes \(\vec{v}_u\). - 2
-
tf.keras.layers.UnitNormalizationrescales the vector to have length one (normalizing its l2 norm). This extra step is not required by the math above, but it makes the algorithm work a bit better in practice. The course’s original code callstf.linalg.l2_normalizehere, which does the same thing; newer versions of Keras require the layer form when building a model this way. - 3
-
tf.keras.layers.Dotis a special Keras layer, whereDensecomputes activations,Dotjust takes the dot product of the two vectors handed to it, producing the prediction \(\vec{v}_u \cdot \vec{v}_m\). - 4
-
tf.keras.Modelties everything into one trainable model by declaring its inputs (the user features and the item features) and its output (the dot product defined just above). - 5
-
tf.keras.losses.MeanSquaredErroris the squared-error cost from the formula earlier, ready for training the whole two-tower model end to end.
Model: "functional_2"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ input_layer │ (None, 14) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ input_layer_2 │ (None, 16) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ sequential │ (None, 32) │ 40,864 │ input_layer[0][0] │ │ (Sequential) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ sequential_1 │ (None, 32) │ 41,376 │ input_layer_2[0]… │ │ (Sequential) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ unit_normalization │ (None, 32) │ 0 │ sequential[0][0] │ │ (UnitNormalization) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ unit_normalization… │ (None, 32) │ 0 │ sequential_1[0][… │ │ (UnitNormalization) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dot (Dot) │ (None, 1) │ 0 │ unit_normalizati… │ │ │ │ │ unit_normalizati… │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 82,240 (321.25 KB)
Trainable params: 82,240 (321.25 KB)
Non-trainable params: 0 (0.00 B)
The summary shows the two Sequential towers as single rows feeding the Dot layer, one trainable model, tens of thousands of parameters across both towers, judged by a single mean squared error. Unlike collaborative filtering, which needed a custom training loop because its cost did not fit the layer paradigm, this architecture is built entirely from standard layers, so the ordinary compile and fit workflow trains it. The practice lab below puts these snippets to work in a full content-based recommender.
Review Questions
1. Why does this model train with the ordinary compile/fit workflow when collaborative filtering needed a custom training loop?
Because every piece of the two-tower architecture is a standard Keras layer: two Sequential stacks of Dense layers joined by a Dot layer, wrapped in a Model with a built-in MeanSquaredError loss. Collaborative filtering’s cost function, with its ratings mask and jointly-learned features, did not fit any standard layer type, so it needed the gradient-tape loop instead.
1. What does the l2 normalization step (UnitNormalization, or tf.linalg.l2_normalize in the course’s code) do to \(\vec{v}_u\) and \(\vec{v}_m\), and is it required?
It rescales each vector so its length (l2 norm) equals one, leaving only its direction. It is not required by the algorithm as derived, the dot product is defined either way, but normalizing both vectors makes the algorithm work a bit better in practice, so the implementation includes it.
1. The final Dense layer of each tower has 32 units and no activation function. Why no activation?
The output layer’s job is to produce the raw description vector, \(\vec{v}_u\) or \(\vec{v}_m\), whose entries may need to be any real numbers, positive or negative, large or small. An activation like relu or sigmoid would clip or squash those values. Any nonlinearity the model needs lives in the hidden layers’ relu activations; the final layer just projects down to the shared 32-dimensional space.
Lab: Deep Learning for Content-Based Filtering
This lab builds the two-tower recommender from this page on real data, the MovieLens ml-latest-small dataset (due to Harper and Konstan, 2015). The original dataset has roughly 9000 movies rated by 600 users on the 0.5-to-5 half-star scale, reduced here to movies from the year 2000 onward in popular genres, leaving \(n_u = 397\) users, \(n_m = 847\) movies, and 25{,}521 ratings. The two graded pieces are the pair of Sequential towers from the TensorFlow section and the squared-distance function behind finding similar items.
import csv
import pickle
from collections import defaultdict
import numpy.ma as ma
import pandas as pd
import tabulate
from numpy import genfromtxt
from tensorflow import keras
from sklearn.preprocessing import StandardScaler, MinMaxScaler
from sklearn.model_selection import train_test_split
from IPython.display import HTML
pd.set_option("display.precision", 1)The lab ships a handful of helper routines for loading the prepared data and pretty-printing tables. They contain no machine learning, only bookkeeping, so they are collected here in a collapsible block (lightly adapted from the lab’s recsysNN_utils.py to load from this site’s data folder):
data_dir = "../../media/content-based-filtering"
def load_data():
""" called to load preprepared data for the lab """
item_train = genfromtxt(f"{data_dir}/content_item_train.csv", delimiter=",")
user_train = genfromtxt(f"{data_dir}/content_user_train.csv", delimiter=",")
y_train = genfromtxt(f"{data_dir}/content_y_train.csv", delimiter=",")
with open(f"{data_dir}/content_item_train_header.txt", newline="") as f:
item_features = list(csv.reader(f))[0]
with open(f"{data_dir}/content_user_train_header.txt", newline="") as f:
user_features = list(csv.reader(f))[0]
item_vecs = genfromtxt(f"{data_dir}/content_item_vecs.csv", delimiter=",")
movie_dict = defaultdict(dict)
count = 0
with open(f"{data_dir}/content_movie_list.csv", newline="") as csvfile:
reader = csv.reader(csvfile, delimiter=",", quotechar='"')
for line in reader:
if count == 0:
count += 1 # skip header
else:
count += 1
movie_id = int(line[0])
movie_dict[movie_id]["title"] = line[1]
movie_dict[movie_id]["genres"] = line[2]
with open(f"{data_dir}/content_user_to_genre.pickle", "rb") as f:
user_to_genre = pickle.load(f)
return (item_train, user_train, y_train, item_features, user_features,
item_vecs, movie_dict, user_to_genre)
def split_str(ifeatures, smax):
""" split the feature name strings so tables fit """
ofeatures = []
for s in ifeatures:
if " " not in s:
if len(s) > smax:
mid = int(len(s) / 2)
s = s[:mid] + " " + s[mid:]
ofeatures.append(s)
return ofeatures
def pprint_train(x_train, features, vs, u_s, maxcount=5, user=True):
""" prints user_train or item_train nicely """
if user:
flist = [".0f", ".0f", ".1f",
".1f", ".1f", ".1f", ".1f", ".1f", ".1f", ".1f",
".1f", ".1f", ".1f", ".1f", ".1f", ".1f", ".1f"]
else:
flist = [".0f", ".0f", ".1f",
".0f", ".0f", ".0f", ".0f", ".0f", ".0f", ".0f",
".0f", ".0f", ".0f", ".0f", ".0f", ".0f", ".0f"]
head = features[:vs]
for i in range(u_s):
head[i] = "[" + head[i] + "]"
genres = features[vs:]
hdr = head + genres
disp = [split_str(hdr, 5)]
count = 0
for i in range(0, x_train.shape[0]):
if count == maxcount: break
count += 1
disp.append([x_train[i, 0].astype(int),
x_train[i, 1].astype(int),
x_train[i, 2].astype(float),
*x_train[i, 3:].astype(float)])
return tabulate.tabulate(disp, tablefmt="html", headers="firstrow",
floatfmt=flist, numalign="center")
def gen_user_vecs(user_vec, num_items):
""" given a user vector, return a matrix with that vector repeated
to match the number of movies """
return np.tile(user_vec, (num_items, 1))
def get_user_vecs(user_id, user_train, item_vecs, user_to_genre):
""" given a user id, return the user's vector repeated to match item_vecs,
and a y vector with the user's ratings (0 where not rated) """
if user_id not in user_to_genre:
print("error: unknown user id")
return None
user_vec_found = False
for i in range(len(user_train)):
if user_train[i, 0] == user_id:
user_vec = user_train[i]
user_vec_found = True
break
if not user_vec_found:
print("error in get_user_vecs, did not find uid in user_train")
num_items = len(item_vecs)
user_vecs = np.tile(user_vec, (num_items, 1))
y = np.zeros(num_items)
for i in range(num_items):
movie_id = item_vecs[i, 0]
rating = user_to_genre[user_id]["movies"].get(movie_id, 0)
y[i] = rating
return (user_vecs, y)
def print_pred_movies(y_p, item, movie_dict, maxcount=10):
""" print results of prediction for a new user, sorted, unscaled """
count = 0
disp = [["y_p", "movie id", "rating ave", "title", "genres"]]
for i in range(0, y_p.shape[0]):
if count == maxcount: break
count += 1
movie_id = item[i, 0].astype(int)
disp.append([np.around(y_p[i, 0], 1), movie_id,
np.around(item[i, 2].astype(float), 1),
movie_dict[movie_id]["title"], movie_dict[movie_id]["genres"]])
return tabulate.tabulate(disp, tablefmt="html", headers="firstrow")
def print_existing_user(y_p, y, user, items, ivs, uvs, movie_dict, maxcount=10):
""" print results of prediction for an existing user, sorted, unscaled """
count = 0
disp = [["y_p", "y", "user", "user genre ave", "movie rating ave", "title", "genres"]]
for i in range(0, y.shape[0]):
if y[i, 0] != 0:
if count == maxcount: break
count += 1
movie_id = items[i, 0].astype(int)
offsets = np.nonzero(items[i, ivs:] == 1)[0]
genre_ratings = user[i, uvs + offsets]
disp.append([y_p[i, 0], y[i, 0],
user[i, 0].astype(int),
np.array2string(genre_ratings,
formatter={"float_kind": lambda x: "%.1f" % x},
separator=",", suppress_small=True),
items[i, 2].astype(float),
movie_dict[movie_id]["title"],
movie_dict[movie_id]["genres"]])
return tabulate.tabulate(disp, tablefmt="html", headers="firstrow",
floatfmt=[".1f", ".1f", ".0f", ".2f", ".1f"])The Dataset
The table below shows the top 10 movies ranked by the number of ratings; they also happen to have high average ratings:
top10_df = pd.read_csv(f"{data_dir}/content_top10_df.csv")
top10_df| movie id | num ratings | ave rating | title | genres | |
|---|---|---|---|---|---|
| 0 | 4993 | 198 | 4.1 | Lord of the Rings: The Fellowship of the Ring,... | Adventure|Fantasy |
| 1 | 5952 | 188 | 4.0 | Lord of the Rings: The Two Towers, The | Adventure|Fantasy |
| 2 | 7153 | 185 | 4.1 | Lord of the Rings: The Return of the King, The | Action|Adventure|Drama|Fantasy |
| 3 | 4306 | 170 | 3.9 | Shrek | Adventure|Animation|Children|Comedy|Fantasy|Ro... |
| 4 | 58559 | 149 | 4.2 | Dark Knight, The | Action|Crime|Drama |
| 5 | 6539 | 149 | 3.8 | Pirates of the Caribbean: The Curse of the Bla... | Action|Adventure|Comedy|Fantasy |
| 6 | 79132 | 143 | 4.1 | Inception | Action|Crime|Drama|Mystery|Sci-Fi|Thriller |
| 7 | 6377 | 141 | 4.0 | Finding Nemo | Adventure|Animation|Children|Comedy |
| 8 | 4886 | 132 | 3.9 | Monsters, Inc. | Adventure|Animation|Children|Comedy|Fantasy |
| 9 | 7361 | 131 | 4.2 | Eternal Sunshine of the Spotless Mind | Drama|Romance|Sci-Fi |
Sorted by genre instead, the number of ratings per genre varies substantially. A movie can carry several genres, so these counts sum to more than the number of ratings:
bygenre_df = pd.read_csv(f"{data_dir}/content_bygenre_df.csv")
bygenre_df| genre | num movies | ave rating/genre | ratings per genre | |
|---|---|---|---|---|
| 0 | Action | 321 | 3.4 | 10377 |
| 1 | Adventure | 234 | 3.4 | 8785 |
| 2 | Animation | 76 | 3.6 | 2588 |
| 3 | Children | 69 | 3.4 | 2472 |
| 4 | Comedy | 326 | 3.4 | 8911 |
| 5 | Crime | 139 | 3.5 | 4671 |
| 6 | Documentary | 13 | 3.8 | 280 |
| 7 | Drama | 342 | 3.6 | 10201 |
| 8 | Fantasy | 124 | 3.4 | 4468 |
| 9 | Horror | 56 | 3.2 | 1345 |
| 10 | Mystery | 68 | 3.6 | 2497 |
| 11 | Romance | 151 | 3.4 | 4468 |
| 12 | Sci-Fi | 174 | 3.4 | 5894 |
| 13 | Thriller | 245 | 3.4 | 7659 |
Training Data
The features are exactly the kind described at the top of this page, a mix of original data and engineered features (in the spirit of the feature engineering page). The movie vector holds the release year and the movie’s genres as a one-hot vector over 14 genres, plus an engineered average rating. The user vector is all engineered, a per-genre average rating computed from that user’s ratings, which is the “average rating per genre” feature the page suggested earlier. A user id, rating count, and rating average are carried along too, but not used in training; they just help interpret the tables.
The training set consists of all the ratings made by the users, with some ratings repeated to boost the number of examples of underrepresented genres. It is split into two arrays with the same number of entries, a user array and an item array, plus the target ratings y_train:
item_train, user_train, y_train, item_features, user_features, \
item_vecs, movie_dict, user_to_genre = load_data()
num_user_features = user_train.shape[1] - 3 # remove userid, rating count and ave rating during training
num_item_features = item_train.shape[1] - 1 # remove movie id at train time
uvs = 3 # user genre vector start
ivs = 3 # item genre vector start
u_s = 3 # start of columns to use in training, user
i_s = 1 # start of columns to use in training, items
print(f"Number of training vectors: {len(item_train)}")Number of training vectors: 50884
The first few entries of the user array, with the bracketed columns being the ones excluded from training:
HTML(pprint_train(user_train, user_features, uvs, u_s, maxcount=5))| [user id] | [rating count] | [rating ave] | Act ion | Adve nture | Anim ation | Chil dren | Com edy | Crime | Docum entary | Drama | Fan tasy | Hor ror | Mys tery | Rom ance | Sci -Fi | Thri ller |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2 | 22 | 4.0 | 4.0 | 4.2 | 0.0 | 0.0 | 4.0 | 4.1 | 4.0 | 4.0 | 0.0 | 3.0 | 4.0 | 0.0 | 3.9 | 3.9 |
| 2 | 22 | 4.0 | 4.0 | 4.2 | 0.0 | 0.0 | 4.0 | 4.1 | 4.0 | 4.0 | 0.0 | 3.0 | 4.0 | 0.0 | 3.9 | 3.9 |
| 2 | 22 | 4.0 | 4.0 | 4.2 | 0.0 | 0.0 | 4.0 | 4.1 | 4.0 | 4.0 | 0.0 | 3.0 | 4.0 | 0.0 | 3.9 | 3.9 |
| 2 | 22 | 4.0 | 4.0 | 4.2 | 0.0 | 0.0 | 4.0 | 4.1 | 4.0 | 4.0 | 0.0 | 3.0 | 4.0 | 0.0 | 3.9 | 3.9 |
| 2 | 22 | 4.0 | 4.0 | 4.2 | 0.0 | 0.0 | 4.0 | 4.1 | 4.0 | 4.0 | 0.0 | 3.0 | 4.0 | 0.0 | 3.9 | 3.9 |
The per-genre averages belong to user 2, and zeros mark genres the user has not rated. Notice the user vector is identical on every row, since it repeats once for each movie this user rated. The item array pairs with it row by row:
HTML(pprint_train(item_train, item_features, ivs, i_s, maxcount=5, user=False))| [movie id] | year | ave rating | Act ion | Adve nture | Anim ation | Chil dren | Com edy | Crime | Docum entary | Drama | Fan tasy | Hor ror | Mys tery | Rom ance | Sci -Fi | Thri ller |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 6874 | 2003 | 4.0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
| 8798 | 2004 | 3.8 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
| 46970 | 2006 | 3.2 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 48516 | 2006 | 4.3 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 |
| 58559 | 2008 | 4.2 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
print(f"y_train[:5]: {y_train[:5]}")y_train[:5]: [4. 3.5 4. 4. 4.5]
Reading one training example across all three arrays, movie 6874 is an Action/Crime/Thriller from 2003, user 2 rates action movies 3.9 on average, MovieLens users gave the movie an average of 4, and \(y = 4\) says user 2 rated it a 4 as well.
Preparing the Training Data
The input features are scaled with scikit-learn’s StandardScaler, the same idea as the feature scaling page, and the target rating is scaled to \([-1, 1]\) with a MinMaxScaler. The inverse_transform check confirms scaling loses nothing:
item_train_unscaled = item_train
user_train_unscaled = user_train
y_train_unscaled = y_train
scalerItem = StandardScaler()
scalerItem.fit(item_train)
item_train = scalerItem.transform(item_train)
scalerUser = StandardScaler()
scalerUser.fit(user_train)
user_train = scalerUser.transform(user_train)
scalerTarget = MinMaxScaler((-1, 1))
scalerTarget.fit(y_train.reshape(-1, 1))
y_train = scalerTarget.transform(y_train.reshape(-1, 1))
print(np.allclose(item_train_unscaled, scalerItem.inverse_transform(item_train)))
print(np.allclose(user_train_unscaled, scalerUser.inverse_transform(user_train)))True
True
To evaluate the result, the data is split 80/20 into training and test sets, the train/test methodology from the neural network course. Using the same random_state for all three calls shuffles the user, item, and target arrays identically, keeping the rows matched up:
item_train, item_test = train_test_split(item_train, train_size=0.80, shuffle=True, random_state=1)
user_train, user_test = train_test_split(user_train, train_size=0.80, shuffle=True, random_state=1)
y_train, y_test = train_test_split(y_train, train_size=0.80, shuffle=True, random_state=1)
print(f"movie/item training data shape: {item_train.shape}")
print(f"movie/item test data shape: {item_test.shape}")movie/item training data shape: (40707, 17)
movie/item test data shape: (10177, 17)
Exercise 1: The Two Towers
The first graded exercise is the pair of networks from the TensorFlow section, a Sequential model per tower with a 256-unit relu layer, a 128-unit relu layer, and a linear output layer of num_outputs = 32 units. The two towers happen to be identical here, but they do not need to be; if the user content were much richer than the movie content, the user network could be made bigger. The provided wiring below uses the Keras functional API, exactly as on this page (with the same UnitNormalization adaptation of the lab’s tf.linalg.l2_normalize noted earlier):
num_outputs = 32
tf.random.set_seed(1)
user_NN = tf.keras.models.Sequential([
tf.keras.layers.Dense(256, activation="relu"),
tf.keras.layers.Dense(128, activation="relu"),
tf.keras.layers.Dense(num_outputs),
])
item_NN = tf.keras.models.Sequential([
tf.keras.layers.Dense(256, activation="relu"),
tf.keras.layers.Dense(128, activation="relu"),
tf.keras.layers.Dense(num_outputs),
])
# create the user input and point to the base network
input_user = tf.keras.layers.Input(shape=(num_user_features,))
vu = user_NN(input_user)
vu = tf.keras.layers.UnitNormalization(axis=1)(vu)
# create the item input and point to the base network
input_item = tf.keras.layers.Input(shape=(num_item_features,))
vm = item_NN(input_item)
vm = tf.keras.layers.UnitNormalization(axis=1)(vm)
# compute the dot product of the two vectors vu and vm
output = tf.keras.layers.Dot(axes=1)([vu, vm])
# specify the inputs and output of the model
model = tf.keras.Model([input_user, input_item], output)
model.summary()Model: "functional_5"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ input_layer_4 │ (None, 14) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ input_layer_6 │ (None, 16) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ sequential_2 │ (None, 32) │ 40,864 │ input_layer_4[0]… │ │ (Sequential) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ sequential_3 │ (None, 32) │ 41,376 │ input_layer_6[0]… │ │ (Sequential) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ unit_normalization… │ (None, 32) │ 0 │ sequential_2[0][… │ │ (UnitNormalization) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ unit_normalization… │ (None, 32) │ 0 │ sequential_3[0][… │ │ (UnitNormalization) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dot_1 (Dot) │ (None, 1) │ 0 │ unit_normalizati… │ │ │ │ │ unit_normalizati… │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 82,240 (321.25 KB)
Trainable params: 82,240 (321.25 KB)
Non-trainable params: 0 (0.00 B)
The lab’s public tests check each tower’s structure, three Dense layers with the right sizes and activations (adapted here to plain asserts):
def test_tower(target):
assert len(target.layers) == 3, f"Wrong number of layers. Expected 3 but got {len(target.layers)}"
expected = [(256, "relu"), (128, "relu"), (num_outputs, "linear")]
for i, (layer, (units, act)) in enumerate(zip(target.layers, expected)):
assert isinstance(layer, tf.keras.layers.Dense), f"Wrong type in layer {i}"
assert layer.units == units, f"Wrong number of units in layer {i}"
assert layer.activation.__name__ == act, f"Wrong activation in layer {i}"
print("All tests passed!")
test_tower(user_NN)
test_tower(item_NN)All tests passed!
All tests passed!
Training uses the mean squared error loss and the Adam optimizer, the standard compile/fit recipe this architecture was promised to support:
tf.random.set_seed(1)
cost_fn = tf.keras.losses.MeanSquaredError()
opt = keras.optimizers.Adam(learning_rate=0.01)
model.compile(optimizer=opt, loss=cost_fn)
tf.random.set_seed(1)
history = model.fit([user_train[:, u_s:], item_train[:, i_s:]], y_train, epochs=30, verbose=2)Epoch 1/30
1273/1273 - 1s - 849us/step - loss: 0.1240
Epoch 2/30
1273/1273 - 1s - 598us/step - loss: 0.1158
Epoch 3/30
1273/1273 - 1s - 588us/step - loss: 0.1114
Epoch 4/30
1273/1273 - 1s - 585us/step - loss: 0.1074
Epoch 5/30
1273/1273 - 1s - 605us/step - loss: 0.1044
Epoch 6/30
1273/1273 - 1s - 587us/step - loss: 0.1020
Epoch 7/30
1273/1273 - 1s - 598us/step - loss: 0.0996
Epoch 8/30
1273/1273 - 1s - 581us/step - loss: 0.0974
Epoch 9/30
1273/1273 - 1s - 659us/step - loss: 0.0953
Epoch 10/30
1273/1273 - 1s - 604us/step - loss: 0.0935
Epoch 11/30
1273/1273 - 1s - 677us/step - loss: 0.0919
Epoch 12/30
1273/1273 - 1s - 739us/step - loss: 0.0904
Epoch 13/30
1273/1273 - 1s - 679us/step - loss: 0.0891
Epoch 14/30
1273/1273 - 1s - 624us/step - loss: 0.0878
Epoch 15/30
1273/1273 - 1s - 598us/step - loss: 0.0864
Epoch 16/30
1273/1273 - 1s - 686us/step - loss: 0.0851
Epoch 17/30
1273/1273 - 1s - 732us/step - loss: 0.0838
Epoch 18/30
1273/1273 - 1s - 672us/step - loss: 0.0827
Epoch 19/30
1273/1273 - 1s - 671us/step - loss: 0.0817
Epoch 20/30
1273/1273 - 1s - 666us/step - loss: 0.0807
Epoch 21/30
1273/1273 - 1s - 598us/step - loss: 0.0798
Epoch 22/30
1273/1273 - 1s - 669us/step - loss: 0.0789
Epoch 23/30
1273/1273 - 1s - 650us/step - loss: 0.0780
Epoch 24/30
1273/1273 - 1s - 621us/step - loss: 0.0772
Epoch 25/30
1273/1273 - 1s - 650us/step - loss: 0.0765
Epoch 26/30
1273/1273 - 1s - 642us/step - loss: 0.0758
Epoch 27/30
1273/1273 - 1s - 637us/step - loss: 0.0751
Epoch 28/30
1273/1273 - 1s - 636us/step - loss: 0.0745
Epoch 29/30
1273/1273 - 1s - 634us/step - loss: 0.0739
Epoch 30/30
1273/1273 - 1s - 602us/step - loss: 0.0732
test_loss = model.evaluate([user_test[:, u_s:], item_test[:, i_s:]], y_test, verbose=0)
print(f"test loss: {test_loss:0.4f}")test loss: 0.0851
The test loss is comparable to the training loss, so the model has not substantially overfit the training data.
Predictions for a New User
Now the fun part. Create a brand-new user by filling in their per-genre averages, this one loves adventure and fantasy, and let the model recommend movies (after running the example, try editing the values to match your own tastes):
new_user_id = 5000
new_rating_ave = 0.0
new_action = 0.0
new_adventure = 5.0
new_animation = 0.0
new_childrens = 0.0
new_comedy = 0.0
new_crime = 0.0
new_documentary = 0.0
new_drama = 0.0
new_fantasy = 5.0
new_horror = 0.0
new_mystery = 0.0
new_romance = 0.0
new_scifi = 0.0
new_thriller = 0.0
new_rating_count = 3
user_vec = np.array([[new_user_id, new_rating_count, new_rating_ave,
new_action, new_adventure, new_animation, new_childrens,
new_comedy, new_crime, new_documentary,
new_drama, new_fantasy, new_horror, new_mystery,
new_romance, new_scifi, new_thriller]])item_vecs holds one vector per movie in the catalog. Repeating the user vector once per movie, scaling both with the scalers fitted above, predicting, and unscaling gives a predicted rating for every movie at once, exactly the “run the user network once, then dot products” serving pattern from the ranking section:
# generate and replicate the user vector to match the number of movies in the data set
user_vecs = gen_user_vecs(user_vec, len(item_vecs))
# scale our user and item vectors
suser_vecs = scalerUser.transform(user_vecs)
sitem_vecs = scalerItem.transform(item_vecs)
# make a prediction
y_p = model.predict([suser_vecs[:, u_s:], sitem_vecs[:, i_s:]], verbose=0)
# unscale y prediction
y_pu = scalerTarget.inverse_transform(y_p)
# sort the results, highest prediction first
sorted_index = np.argsort(-y_pu, axis=0).reshape(-1).tolist()
sorted_ypu = y_pu[sorted_index]
sorted_items = item_vecs[sorted_index]
HTML(print_pred_movies(sorted_ypu, sorted_items, movie_dict, maxcount=10))| y_p | movie id | rating ave | title | genres |
|---|---|---|---|---|
| 4.3 | 4896 | 3.8 | Harry Potter and the Sorcerer's Stone (a.k.a. Harry Potter and the Philosopher's Stone) (2001) | Adventure|Children|Fantasy |
| 4.3 | 40815 | 3.8 | Harry Potter and the Goblet of Fire (2005) | Adventure|Fantasy|Thriller |
| 4.2 | 8961 | 3.8 | Incredibles, The (2004) | Action|Adventure|Animation|Children|Comedy |
| 4.2 | 98809 | 3.8 | Hobbit: An Unexpected Journey, The (2012) | Adventure|Fantasy |
| 4.1 | 8368 | 3.9 | Harry Potter and the Prisoner of Azkaban (2004) | Adventure|Fantasy |
| 4.1 | 59501 | 3.5 | Chronicles of Narnia: Prince Caspian, The (2008) | Adventure|Children|Fantasy |
| 4.1 | 5816 | 3.6 | Harry Potter and the Chamber of Secrets (2002) | Adventure|Fantasy |
| 4.1 | 41566 | 3.4 | Chronicles of Narnia: The Lion, the Witch and the Wardrobe, The (2005) | Adventure|Children|Fantasy |
| 4.1 | 106489 | 3.6 | Hobbit: The Desolation of Smaug, The (2013) | Adventure|Fantasy |
| 4 | 5952 | 4 | Lord of the Rings: The Two Towers, The (2002) | Adventure|Fantasy |
The top of the list is dominated by adventure and fantasy titles, matching the tastes this user was given.
Predictions for an Existing User
The same machinery predicts for a user from the dataset, which allows comparing predictions against the ratings the user actually gave. For user 2:
uid = 2
# form a set of user vectors: the same vector, transformed and repeated
user_vecs, y_vecs = get_user_vecs(uid, user_train_unscaled, item_vecs, user_to_genre)
suser_vecs = scalerUser.transform(user_vecs)
sitem_vecs = scalerItem.transform(item_vecs)
y_p = model.predict([suser_vecs[:, u_s:], sitem_vecs[:, i_s:]], verbose=0)
y_pu = scalerTarget.inverse_transform(y_p)
sorted_index = np.argsort(-y_pu, axis=0).reshape(-1).tolist()
sorted_ypu = y_pu[sorted_index]
sorted_items = item_vecs[sorted_index]
sorted_user = user_vecs[sorted_index]
sorted_y = y_vecs[sorted_index]
HTML(print_existing_user(sorted_ypu, sorted_y.reshape(-1, 1), sorted_user,
sorted_items, ivs, uvs, movie_dict, maxcount=15))| y_p | y | user | user genre ave | movie rating ave | title | genres |
|---|---|---|---|---|---|---|
| 4.5 | 5.0 | 2 | [4.0] | 4.3 | Inside Job (2010) | Documentary |
| 4.4 | 4.0 | 2 | [4.0,4.1,4.0,4.0,3.9,3.9] | 4.1 | Inception (2010) | Action|Crime|Drama|Mystery|Sci-Fi|Thriller |
| 4.3 | 4.0 | 2 | [4.0,4.1,3.9] | 4.0 | Kill Bill: Vol. 1 (2003) | Action|Crime|Thriller |
| 4.2 | 4.5 | 2 | [4.0,4.1,4.0] | 4.2 | Dark Knight, The (2008) | Action|Crime|Drama |
| 4.2 | 3.5 | 2 | [4.0,4.1,4.0,3.9] | 3.8 | Collateral (2004) | Action|Crime|Drama|Thriller |
| 4.1 | 4.5 | 2 | [4.0,4.0] | 4.1 | Inglourious Basterds (2009) | Action|Drama |
| 4.1 | 3.5 | 2 | [4.0,4.0] | 3.9 | Django Unchained (2012) | Action|Drama |
| 4.1 | 4.0 | 2 | [4.1,4.0,3.9] | 4.3 | Departed, The (2006) | Crime|Drama|Thriller |
| 4.0 | 3.5 | 2 | [4.0,3.9,3.9] | 3.9 | Ex Machina (2015) | Drama|Sci-Fi|Thriller |
| 4.0 | 3.0 | 2 | [3.9] | 4.0 | Interstellar (2014) | Sci-Fi |
| 4.0 | 3.5 | 2 | [4.0,4.2,4.1] | 4.0 | Dark Knight Rises, The (2012) | Action|Adventure|Crime |
| 4.0 | 5.0 | 2 | [4.0,4.1,4.0] | 3.9 | Wolf of Wall Street, The (2013) | Comedy|Crime|Drama |
| 3.9 | 4.5 | 2 | [4.1,4.0,3.9] | 4.0 | Town, The (2010) | Crime|Drama|Thriller |
| 3.9 | 5.0 | 2 | [4.0] | 3.7 | Warrior (2011) | Drama |
| 3.9 | 4.0 | 2 | [4.0,4.0,3.9] | 4.0 | Shutter Island (2010) | Drama|Mystery|Thriller |
The predictions generally land within about 1 of the actual rating, though the model is not a very accurate predictor of how a user rates specific movies, especially when a rating differs a lot from the user’s genre average. (The lab displays 50 rows; 15 are shown here. Varying uid tries other users, though not every user id appears in the training set.)
Exercise 2: Squared Distance
The second graded function implements the similarity measure from finding similar items,
\[ \left\Vert \vec{v}_m^{(k)} - \vec{v}_m^{(i)} \right\Vert^2 = \sum_{l=1}^{n} \left( v_{m_l}^{(k)} - v_{m_l}^{(i)} \right)^2 \]
and although a summation often hints at a loop, element-wise subtraction, np.square, and np.sum do it in one line:
def sq_dist(a, b):
"""
Returns the squared distance between two vectors
Args:
a (ndarray (n,)): vector with n features
b (ndarray (n,)): vector with n features
Returns:
d (float) : distance
"""
d = np.sum(np.square(a - b))
return d
a1 = np.array([1.0, 2.0, 3.0]); b1 = np.array([1.0, 2.0, 3.0])
a2 = np.array([1.1, 2.1, 3.1]); b2 = np.array([1.0, 2.0, 3.0])
a3 = np.array([0, 1, 0]); b3 = np.array([1, 0, 0])
print(f"squared distance between a1 and b1: {sq_dist(a1, b1):0.3f}")
print(f"squared distance between a2 and b2: {sq_dist(a2, b2):0.3f}")
print(f"squared distance between a3 and b3: {sq_dist(a3, b3):0.3f}")squared distance between a1 and b1: 0.000
squared distance between a2 and b2: 0.030
squared distance between a3 and b3: 2.000
The expected values are 0.000, 0.030, and 2.000, all matched, and the lab’s public tests probe a few more hand-checkable cases:
def test_sq_dist(target):
assert np.isclose(target(np.array([1.0, 2.0, 3.0]), np.array([1.0, 2.0, 3.0])), 0), "Wrong value. Expected 0"
assert np.isclose(target(np.array([1.1, 2.1, 3.1]), np.array([1.0, 2.0, 3.0])), 0.03), "Wrong value. Expected 0.03"
assert np.isclose(target(np.array([0, 1]), np.array([1, 0])), 2), "Wrong value. Expected 2"
assert np.isclose(target(np.array([1, 1, 1, 1, 1]), np.array([0, 0, 0, 0, 0])), 5), "Wrong value. Expected 5"
print("All tests passed!")
test_sq_dist(sq_dist)All tests passed!
Finding Similar Movies
A matrix of distances between movies can be computed once when the model is trained and reused for new recommendations without retraining, this is precisely the pre-computation the page promised could run overnight. The first step is to get \(\vec{v}_m\) for every movie, by wrapping the trained item_NN in a small standalone model:
input_item_m = tf.keras.layers.Input(shape=(num_item_features,)) # input layer
vm_m = item_NN(input_item_m) # use the trained item_NN
vm_m = tf.keras.layers.UnitNormalization(axis=1)(vm_m) # incorporate normalization as in the original model
model_m = tf.keras.Model(input_item_m, vm_m)
model_m.summary()Model: "functional_6"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ input_layer_8 (InputLayer) │ (None, 16) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ sequential_3 (Sequential) │ (None, 32) │ 41,376 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ unit_normalization_4 │ (None, 32) │ 0 │ │ (UnitNormalization) │ │ │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 41,376 (161.62 KB)
Trainable params: 41,376 (161.62 KB)
Non-trainable params: 0 (0.00 B)
Running every (scaled) movie vector through it yields one 32-entry description vector per movie:
scaled_item_vecs = scalerItem.transform(item_vecs)
vms = model_m.predict(scaled_item_vecs[:, i_s:], verbose=0)
print(f"size of all predicted movie feature vectors: {vms.shape}")size of all predicted movie feature vectors: (847, 32)
Then sq_dist fills an \(847 \times 847\) matrix of distances between every pair of movies. Masking the diagonal (a movie’s distance to itself is always the minimum, and never a useful recommendation) with a NumPy masked array, the closest movie to each one comes from the minimum along each row:
count = 15 # number of movies to display (the lab displays 50)
dim = len(vms)
dist = np.zeros((dim, dim))
for i in range(dim):
for j in range(dim):
dist[i, j] = sq_dist(vms[i, :], vms[j, :])
m_dist = ma.masked_array(dist, mask=np.identity(dist.shape[0])) # mask the diagonal
disp = [["movie1", "genres", "movie2", "genres"]]
for i in range(count):
min_idx = np.argmin(m_dist[i])
movie1_id = int(item_vecs[i, 0])
movie2_id = int(item_vecs[min_idx, 0])
disp.append([movie_dict[movie1_id]["title"], movie_dict[movie1_id]["genres"],
movie_dict[movie2_id]["title"], movie_dict[movie2_id]["genres"]])
HTML(tabulate.tabulate(disp, tablefmt="html", headers="firstrow"))| movie1 | genres | movie2 | genres |
|---|---|---|---|
| Save the Last Dance (2001) | Drama|Romance | Mona Lisa Smile (2003) | Drama|Romance |
| Wedding Planner, The (2001) | Comedy|Romance | Sweetest Thing, The (2002) | Comedy|Romance |
| Hannibal (2001) | Horror|Thriller | Final Destination 2 (2003) | Horror|Thriller |
| Saving Silverman (Evil Woman) (2001) | Comedy|Romance | Sweetest Thing, The (2002) | Comedy|Romance |
| Down to Earth (2001) | Comedy|Fantasy|Romance | Bewitched (2005) | Comedy|Fantasy|Romance |
| Mexican, The (2001) | Action|Comedy | Rush Hour 2 (2001) | Action|Comedy |
| 15 Minutes (2001) | Thriller | Panic Room (2002) | Thriller |
| Enemy at the Gates (2001) | Drama | Finding Neverland (2004) | Drama |
| Heartbreakers (2001) | Comedy|Crime|Romance | Mona Lisa Smile (2003) | Drama|Romance |
| Spy Kids (2001) | Action|Adventure|Children|Comedy | Scooby-Doo (2002) | Adventure|Children|Comedy|Fantasy|Mystery |
| Along Came a Spider (2001) | Action|Crime|Mystery|Thriller | Red Dragon (2002) | Crime|Mystery|Thriller |
| Blow (2001) | Crime|Drama | 25th Hour (2002) | Crime|Drama |
| Bridget Jones's Diary (2001) | Comedy|Drama|Romance | Punch-Drunk Love (2002) | Comedy|Drama|Romance |
| Joe Dirt (2001) | Adventure|Comedy|Mystery|Romance | Alexander (2004) | Action|Adventure|Drama |
| Crocodile Dundee in Los Angeles (2001) | Comedy|Drama | Dumb and Dumberer: When Harry Met Lloyd (2003) | Comedy |
The model generally pairs each movie with one of overlapping genres, learned similarity, not a hand-coded genre match. This structure is the basis of many commercial recommender systems, and nothing about it is movie-specific. Expand the user content with whatever information is available, and swap movies for books, products, or anything else worth recommending.
That wraps up recommender systems: collaborative filtering learned from ratings alone, content-based filtering matched user and item features through two neural networks, retrieval and ranking scaled it to real catalogs, and the ethics section asked what these systems should optimize. The remaining part of the course turns to a different frontier altogether, reinforcement learning, culminating in landing a simulated moon lander.