Collaborative Filtering from Scratch

Before we can write a model in PyTorch, we first need to learn the basics of object-oriented programming and Python. If you haven’t done any object-oriented programming before, we will give you a quick introduction here, but we would recommend looking up a tutorial and getting some practice before moving on.

The key idea in object-oriented programming is the class. We have been using classes throughout this book, such as DataLoader, string, and Learner. Python also makes it easy for us to create new classes. Here is an example of a simple class:

In [ ]:

  1. class Example:
  2. def __init__(self, a): self.a = a
  3. def say(self,x): return f'Hello {self.a}, {x}.'

The most important piece of this is the special method called __init__ (pronounced dunder init). In Python, any method surrounded in double underscores like this is considered special. It indicates that there is some extra behavior associated with this method name. In the case of __init__, this is the method Python will call when your new object is created. So, this is where you can set up any state that needs to be initialized upon object creation. Any parameters included when the user constructs an instance of your class will be passed to the __init__ method as parameters. Note that the first parameter to any method defined inside a class is self, so you can use this to set and get any attributes that you will need:

In [ ]:

  1. ex = Example('Sylvain')
  2. ex.say('nice to meet you')

Out[ ]:

  1. 'Hello Sylvain, nice to meet you.'

Also note that creating a new PyTorch module requires inheriting from Module. Inheritance is an important object-oriented concept that we will not discuss in detail here—in short, it means that we can add additional behavior to an existing class. PyTorch already provides a Module class, which provides some basic foundations that we want to build on. So, we add the name of this superclass after the name of the class that we are defining, as shown in the following example.

The final thing that you need to know to create a new PyTorch module is that when your module is called, PyTorch will call a method in your class called forward, and will pass along to that any parameters that are included in the call. Here is the class defining our dot product model:

In [ ]:

  1. class DotProduct(Module):
  2. def __init__(self, n_users, n_movies, n_factors):
  3. self.user_factors = Embedding(n_users, n_factors)
  4. self.movie_factors = Embedding(n_movies, n_factors)
  5. def forward(self, x):
  6. users = self.user_factors(x[:,0])
  7. movies = self.movie_factors(x[:,1])
  8. return (users * movies).sum(dim=1)

If you haven’t seen object-oriented programming before, then don’t worry, you won’t need to use it much in this book. We are just mentioning this approach here, because most online tutorials and documentation will use the object-oriented syntax.

Note that the input of the model is a tensor of shape batch_size x 2, where the first column (x[:, 0]) contains the user IDs and the second column (x[:, 1]) contains the movie IDs. As explained before, we use the embedding layers to represent our matrices of user and movie latent factors:

In [ ]:

  1. x,y = dls.one_batch()
  2. x.shape

Out[ ]:

  1. torch.Size([64, 2])

Now that we have defined our architecture, and created our parameter matrices, we need to create a Learner to optimize our model. In the past we have used special functions, such as cnn_learner, which set up everything for us for a particular application. Since we are doing things from scratch here, we will use the plain Learner class:

In [ ]:

  1. model = DotProduct(n_users, n_movies, 50)
  2. learn = Learner(dls, model, loss_func=MSELossFlat())

We are now ready to fit our model:

In [ ]:

  1. learn.fit_one_cycle(5, 5e-3)
epochtrain_lossvalid_losstime
00.9931680.99016800:12
10.8848210.91126900:12
20.6718650.87567900:12
30.4717270.87820000:11
40.3613140.88420900:12

The first thing we can do to make this model a little bit better is to force those predictions to be between 0 and 5. For this, we just need to use sigmoid_range, like in <>. One thing we discovered empirically is that it’s better to have the range go a little bit over 5, so we use (0, 5.5):

In [ ]:

  1. class DotProduct(Module):
  2. def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
  3. self.user_factors = Embedding(n_users, n_factors)
  4. self.movie_factors = Embedding(n_movies, n_factors)
  5. self.y_range = y_range
  6. def forward(self, x):
  7. users = self.user_factors(x[:,0])
  8. movies = self.movie_factors(x[:,1])
  9. return sigmoid_range((users * movies).sum(dim=1), *self.y_range)

In [ ]:

  1. model = DotProduct(n_users, n_movies, 50)
  2. learn = Learner(dls, model, loss_func=MSELossFlat())
  3. learn.fit_one_cycle(5, 5e-3)
epochtrain_lossvalid_losstime
00.9737450.99320600:12
10.8691320.91432300:12
20.6765530.87019200:12
30.4853770.87386500:12
40.3778660.87761000:11

This is a reasonable start, but we can do better. One obvious missing piece is that some users are just more positive or negative in their recommendations than others, and some movies are just plain better or worse than others. But in our dot product representation we do not have any way to encode either of these things. If all you can say about a movie is, for instance, that it is very sci-fi, very action-oriented, and very not old, then you don’t really have any way to say whether most people like it.

That’s because at this point we only have weights; we do not have biases. If we have a single number for each user that we can add to our scores, and ditto for each movie, that will handle this missing piece very nicely. So first of all, let’s adjust our model architecture:

In [ ]:

  1. class DotProductBias(Module):
  2. def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
  3. self.user_factors = Embedding(n_users, n_factors)
  4. self.user_bias = Embedding(n_users, 1)
  5. self.movie_factors = Embedding(n_movies, n_factors)
  6. self.movie_bias = Embedding(n_movies, 1)
  7. self.y_range = y_range
  8. def forward(self, x):
  9. users = self.user_factors(x[:,0])
  10. movies = self.movie_factors(x[:,1])
  11. res = (users * movies).sum(dim=1, keepdim=True)
  12. res += self.user_bias(x[:,0]) + self.movie_bias(x[:,1])
  13. return sigmoid_range(res, *self.y_range)

Let’s try training this and see how it goes:

In [ ]:

  1. model = DotProductBias(n_users, n_movies, 50)
  2. learn = Learner(dls, model, loss_func=MSELossFlat())
  3. learn.fit_one_cycle(5, 5e-3)
epochtrain_lossvalid_losstime
00.9291610.93630300:13
10.8204440.86130600:13
20.6216120.86530600:14
30.4046480.88644800:13
40.2929480.89258000:13

Instead of being better, it ends up being worse (at least at the end of training). Why is that? If we look at both trainings carefully, we can see the validation loss stopped improving in the middle and started to get worse. As we’ve seen, this is a clear indication of overfitting. In this case, there is no way to use data augmentation, so we will have to use another regularization technique. One approach that can be helpful is weight decay.

Weight Decay

Weight decay, or L2 regularization, consists in adding to your loss function the sum of all the weights squared. Why do that? Because when we compute the gradients, it will add a contribution to them that will encourage the weights to be as small as possible.

Why would it prevent overfitting? The idea is that the larger the coefficients are, the sharper canyons we will have in the loss function. If we take the basic example of a parabola, y = a * (x**2), the larger a is, the more narrow the parabola is (<>).

In [ ]:

  1. #hide_input
  2. #id parabolas
  3. x = np.linspace(-2,2,100)
  4. a_s = [1,2,5,10,50]
  5. ys = [a * x**2 for a in a_s]
  6. _,ax = plt.subplots(figsize=(8,6))
  7. for a,y in zip(a_s,ys): ax.plot(x,y, label=f'a={a}')
  8. ax.set_ylim([0,5])
  9. ax.legend();

Collaborative Filtering from Scratch - 图1

So, letting our model learn high parameters might cause it to fit all the data points in the training set with an overcomplex function that has very sharp changes, which will lead to overfitting.

Limiting our weights from growing too much is going to hinder the training of the model, but it will yield a state where it generalizes better. Going back to the theory briefly, weight decay (or just wd) is a parameter that controls that sum of squares we add to our loss (assuming parameters is a tensor of all parameters):

  1. loss_with_wd = loss + wd * (parameters**2).sum()

In practice, though, it would be very inefficient (and maybe numerically unstable) to compute that big sum and add it to the loss. If you remember a little bit of high school math, you might recall that the derivative of p**2 with respect to p is 2*p, so adding that big sum to our loss is exactly the same as doing:

  1. parameters.grad += wd * 2 * parameters

In practice, since wd is a parameter that we choose, we can just make it twice as big, so we don’t even need the *2 in this equation. To use weight decay in fastai, just pass wd in your call to fit or fit_one_cycle:

In [ ]:

  1. model = DotProductBias(n_users, n_movies, 50)
  2. learn = Learner(dls, model, loss_func=MSELossFlat())
  3. learn.fit_one_cycle(5, 5e-3, wd=0.1)
epochtrain_lossvalid_losstime
00.9720900.96236600:13
10.8755910.88510600:13
20.7237980.83988000:13
30.5860020.82322500:13
40.4909800.82306000:13

Much better!

Creating Our Own Embedding Module

So far, we’ve used Embedding without thinking about how it really works. Let’s re-create DotProductBias without using this class. We’ll need a randomly initialized weight matrix for each of the embeddings. We have to be careful, however. Recall from <> that optimizers require that they can get all the parameters of a module from the module’s parameters method. However, this does not happen fully automatically. If we just add a tensor as an attribute to a Module, it will not be included in parameters:

In [ ]:

  1. class T(Module):
  2. def __init__(self): self.a = torch.ones(3)
  3. L(T().parameters())

Out[ ]:

  1. (#0) []

To tell Module that we want to treat a tensor as a parameter, we have to wrap it in the nn.Parameter class. This class doesn’t actually add any functionality (other than automatically calling requires_grad_ for us). It’s only used as a “marker” to show what to include in parameters:

In [ ]:

  1. class T(Module):
  2. def __init__(self): self.a = nn.Parameter(torch.ones(3))
  3. L(T().parameters())

Out[ ]:

  1. (#1) [Parameter containing:
  2. tensor([1., 1., 1.], requires_grad=True)]

All PyTorch modules use nn.Parameter for any trainable parameters, which is why we haven’t needed to explicitly use this wrapper up until now:

In [ ]:

  1. class T(Module):
  2. def __init__(self): self.a = nn.Linear(1, 3, bias=False)
  3. t = T()
  4. L(t.parameters())

Out[ ]:

  1. (#1) [Parameter containing:
  2. tensor([[-0.9595],
  3. [-0.8490],
  4. [ 0.8159]], requires_grad=True)]

In [ ]:

  1. type(t.a.weight)

Out[ ]:

  1. torch.nn.parameter.Parameter

We can create a tensor as a parameter, with random initialization, like so:

In [ ]:

  1. def create_params(size):
  2. return nn.Parameter(torch.zeros(*size).normal_(0, 0.01))

Let’s use this to create DotProductBias again, but without Embedding:

In [ ]:

  1. class DotProductBias(Module):
  2. def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
  3. self.user_factors = create_params([n_users, n_factors])
  4. self.user_bias = create_params([n_users])
  5. self.movie_factors = create_params([n_movies, n_factors])
  6. self.movie_bias = create_params([n_movies])
  7. self.y_range = y_range
  8. def forward(self, x):
  9. users = self.user_factors[x[:,0]]
  10. movies = self.movie_factors[x[:,1]]
  11. res = (users*movies).sum(dim=1)
  12. res += self.user_bias[x[:,0]] + self.movie_bias[x[:,1]]
  13. return sigmoid_range(res, *self.y_range)

Then let’s train it again to check we get around the same results we saw in the previous section:

In [ ]:

  1. model = DotProductBias(n_users, n_movies, 50)
  2. learn = Learner(dls, model, loss_func=MSELossFlat())
  3. learn.fit_one_cycle(5, 5e-3, wd=0.1)
epochtrain_lossvalid_losstime
00.9621460.93695200:14
10.8580840.88495100:14
20.7408830.83854900:14
30.5924970.82359900:14
40.4735700.82426300:14

Now, let’s take a look at what our model has learned.