3. Linear Models in Pytorch¶
Now that we have some basic knowledge of Torch tensors, let’s see how we can implement the linear model earlier using Pytorch.
3.1. Data Preparation¶
Let’s first repeat the same data preparation process, first generating a synthetic dataset of 100 data points, then perform an 80%:20% split as training and validation dataset, respectively.
import numpy as np
import torch
true_b = 1
true_w = 2
N = 100
# Data Generation
np.random.seed(42)
x = np.random.rand(N, 1)
# Guassian noise to add some randomness to y
epsilon = (.1 * np.random.randn(N, 1))
y = true_b + true_w * x + epsilon
# Shuffles the indices
idx = np.arange(N)
np.random.shuffle(idx)
# Uses first 80 random indices for train
train_idx = idx[:int(N*.8)]
# Uses the remaining indices for validation
val_idx = idx[int(N*.8):]
# Generates train and validation sets
x_train, y_train = x[train_idx], y[train_idx]
x_val, y_val = x[val_idx], y[val_idx]
No matter you have a GPU or not, the best practice is to use .to(device)
method to make your code GPU ready.
device = 'cuda' if torch.cuda.is_available() else 'cpu'
# Our data was in Numpy arrays, but we need to transform them
# into PyTorch's Tensors and then we send them to the
# chosen device
x_train_tensor = torch.as_tensor(x_train).float().to(device)
y_train_tensor = torch.as_tensor(y_train).float().to(device)
# Here we can see the difference - notice that .type() is more
# useful since it also tells us WHERE the tensor is (device)
print(type(x_train), type(x_train_tensor), x_train_tensor.type())
<class 'numpy.ndarray'> <class 'torch.Tensor'> torch.cuda.FloatTensor
We normally can turn a tensor back to a Numpy array using sourceTensor.numpy()
, but now we have GPU tensor, which cannot be directly handled by Numpy. We have turn it back to a CPU tensor first before converting to a Numpy array.
back_to_numpy = x_train_tensor.cpu().numpy()
Good Practice
It is a good practice to always first cpu()
and then numpy()
, even if you are using a CPU. It follows the same principle of to(device): you may share your code with others who may be using a GPU.
3.2. Creating Parameters¶
What distinguishes a tensor used for training data (or validation, or test) — like the ones we’ve just created — from a tensor used as a (trainable) parameter/weight?
The latter (a parameter) requires the computation of its gradients, so we can update their values (the parameters’ values).
In Pytorch, we use the requires_grad=True
argument to tell PyTorch to compute gradients for us.
A tensor for a learnable parameter requires a gradient!
Good Practice
To make GPU ready code, we should specify the device at the moment of creation to avoid shadowing the gradient requirement.
# We can specify the device at the moment of creation
# RECOMMENDED!
# Step 0 - Initializes parameters "b" and "w" randomly
torch.manual_seed(42)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
w = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
print(b, w)
tensor([0.1940], device='cuda:0', requires_grad=True) tensor([0.1391], device='cuda:0', requires_grad=True)
Note
Notice that even with the same seed value, because Pytorch and Numpy are two different packages, they have different implemenations of the randn()
method, thus different results.
3.3. Autograd¶
In Pytorch, we don’t need to worry about partial derivatives, chain rule, or anything like it. Autograd is PyTorch’s automatic differentiation package.
3.3.1. backward()
¶
To tell PyTorch to compute all gradients, we use the backward()
method. It will compute gradients for all (requiring gradient) tensors involved in the computation of a given variable.
Recall that we need to compute the partial derivatives of the loss function w.r.t. our parameters. Hence, we need to invoke the backward()
method from the corresponding Python variable: loss.backward()
.
# Step 1 - Computes our model's predicted output - forward pass
yhat = b + w * x_train_tensor
# Step 2 - Computes the loss
# We are using ALL data points, so this is BATCH gradient
# descent. How wrong is our model? That's the error!
error = (yhat - y_train_tensor)
# It is a regression, so it computes mean squared error (MSE)
loss = (error ** 2).mean()
# Step 3 - Computes gradients for both "b" and "w" parameters
# No more manual computation of gradients!
# b_grad = 2 * error.mean()
# w_grad = 2 * (x_tensor * error).mean()
loss.backward()
We have set requires_grad=True
to both b
and w
, so they are obviously included in the list of gradient calculation. We use them both to compute yhat
, so it will also make it to the list. Then we use yhat
to compute the error
, so error
is also on the list.
x_train_tensor
and y_train_tensor
however, are not gradient-requiring tensors, so backward()
does not care about them.
print(error.requires_grad, yhat.requires_grad, b.requires_grad, w.requires_grad)
print(y_train_tensor.requires_grad, x_train_tensor.requires_grad)
True True True True
False False
3.3.2. grad¶
We can inspect the actual values of the gradients by looking at the grad
attribute of a tensor.
print(b.grad, w.grad)
tensor([-3.3881], device='cuda:0') tensor([-1.9439], device='cuda:0')
3.3.3. Accumulated Gradients¶
Let’s run the backward function again:
# Step 1 - Computes our model's predicted output - forward pass
yhat = b + w * x_train_tensor
# Step 2 - Computes the loss
# We are using ALL data points, so this is BATCH gradient
# descent. How wrong is our model? That's the error!
error = (yhat - y_train_tensor)
# It is a regression, so it computes mean squared error (MSE)
loss = (error ** 2).mean()
# Step 3 - Computes gradients for both "b" and "w" parameters
# No more manual computation of gradients!
# b_grad = 2 * error.mean()
# w_grad = 2 * (x_tensor * error).mean()
loss.backward()
print(b.grad, w.grad)
tensor([-6.7762], device='cuda:0') tensor([-3.8878], device='cuda:0')
Note
If we ran this above code again, the gradient of \(b\) and \(w\) exactly doubled. This is because Pytorch implements an accumlated gradients to circumvent hardware limitations. If a minibatch is still too big to fit in memory, we can split it further into “subminibatch”, that’s when the aggregated gradients become useful.
3.3.4. zero_¶
For training problem that does not have memory limitations, every time we use the gradients to update the parameters, we need to zero the
gradients afterward. This what zero_()
is good for.
# This code will be placed _after_ Step 4
# (updating the parameters)
b.grad.zero_(), w.grad.zero_()
(tensor([0.], device='cuda:0'), tensor([0.], device='cuda:0'))
Important
In PyTorch, every method that ends with an underscore (_), like
the requires_grad_()
and zero_()
method above, makes changes in-place,
in other words, they will modify the underlying variable.
3.4. Put it all together¶
# Sets learning rate - this is "eta" ~ the "n"-like Greek letter
lr = 0.1
# Step 0 - Initializes parameters "b" and "w" randomly
torch.manual_seed(42)
b = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
w = torch.randn(1, requires_grad=True, dtype=torch.float, device=device)
# Defines number of epochs
n_epochs = 1000
for epoch in range(n_epochs):
# Step 1 - Computes model's predicted output - forward pass
yhat = b + w * x_train_tensor
# Step 2 - Computes the loss
# We are using ALL data points, so this is BATCH gradient
# descent. How wrong is our model? That's the error!
error = (yhat - y_train_tensor)
# It is a regression, so it computes mean squared error (MSE)
loss = (error ** 2).mean()
# Step 3 - Computes gradients for both "b" and "w"
# parameters. No more manual computation of gradients!
# b_grad = 2 * error.mean()
# w_grad = 2 * (x_tensor * error).mean()
# We just tell PyTorch to work its way BACKWARDS
# from the specified loss!
loss.backward()
# Step 4 - Updates parameters using gradients and
# the learning rate. But not so fast...
# FIRST ATTEMPT - just using the same code as before
# AttributeError: 'NoneType' object has no attribute 'zero_'
# b = b - lr * b.grad
# w = w - lr * w.grad
# print(b)
# SECOND ATTEMPT - using in-place Python assingment
# RuntimeError: a leaf Variable that requires grad
# has been used in an in-place operation.
# b -= lr * b.grad
# w -= lr * w.grad
# THIRD ATTEMPT - NO_GRAD for the win!
# We need to use NO_GRAD to keep the update out of
# the gradient computation. Why is that? It boils
# down to the DYNAMIC GRAPH that PyTorch uses...
with torch.no_grad():
b -= lr * b.grad
w -= lr * w.grad
# PyTorch is "clingy" to its computed gradients, we
# need to tell it to let it go...
b.grad.zero_()
w.grad.zero_()
print(b, w)
tensor([1.0235], device='cuda:0', requires_grad=True) tensor([1.9690], device='cuda:0', requires_grad=True)
In the first attempt, if we use the same update structure as in our Numpy code, we’ll get a weird error but we can get a hint of what’s going on by looking at the tensor itself — once again, we “lost” the gradient while reassigning the update results to our parameters. Thus, the grad attribute turns out to be None, and it raises the error…
Important
We use with torch.no_grad():
to ensure the update is not tracked by the dyanmic computation graph mechanism of Pytorch. We will talk about compuation graph next lab.