# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# # What is `torch.nn` *really*?
#
# ## A journey: from neural net "from scratch", to fully utilizing `torch.nn`

# PyTorch provides an elegantly designed module called `torch.nn` to help you create and
#  train neural networks. In order to fully utilize its power, you need to really understand exactly what it's doing. To develop this understanding, we will first train basic neural net on the MNIST data set without using any features from `torch.nn`; we will initially only use the most basic PyTorch tensor functionality. Then, we will incrementally add one feature from `torch.nn` at a time, showing exactly what each piece does, and how it works to make the code either more concise, or more flexible.
#
# **This tutorial assumes you are already have PyTorch installed, and are familiar with the basics of tensor operations.** (If you're familiar with Numpy array operations, you'll find the PyTorch tensor operations used here nearly identical).

# ## MNIST data setup

# We will use the classic [MNIST](http://deeplearning.net/data/mnist/) dataset, which consists of black-and-white images hand-drawn digits (between 0 and 9).
#
# We will use [pathlib](https://docs.python.org/3/library/pathlib.html) for dealing with paths (part of the Python 3 standard library), and will download the dataset using [requests](http://docs.python-requests.org/en/master/). We will only import modules when we use them, so you can see exactly what's being used at each point.

# +
from pathlib import Path
import requests

DATA_PATH = Path('data')
PATH = DATA_PATH/'mnist'

PATH.mkdir(parents=True, exist_ok=True)

URL='http://deeplearning.net/data/mnist/'
FILENAME='mnist.pkl.gz'

if not (PATH/FILENAME).exists():
    content = requests.get(URL+FILENAME).content
    (PATH/FILENAME).open('wb').write(content)
# -

# This dataset is in numpy array format, and has been stored using pickle, a python-specific format for serializing data.

# +
import pickle, gzip

with gzip.open(PATH/FILENAME, 'rb') as f:
    ((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding='latin-1')
# -

# Each image is 28 x 28, and is being stored as a flattened row of length 784 (=28x28). Let's take a look at one; we need to reshape it to 2d first.

# +
from matplotlib import pyplot
import numpy as np

pyplot.imshow(x_train[0].reshape((28,28)), cmap="gray")
x_train.shape
# -

# PyTorch uses `torch.tensor`, rather than numpy arrays, so we need to convert our data.

# +
import torch 

x_train,y_train,x_valid,y_valid = map(torch.tensor, (x_train,y_train,x_valid,y_valid))
n,c = x_train.shape
x_train, x_train.shape, y_train.min(), y_train.max()
# -

# ## Neural net from scratch (no `torch.nn`)

# Let's first create a model using nothing but PyTorch tensor operations. We're assuming you're already familiar with the basics of neural networks. If you're not, you can learn them at [course.fast.ai](http://course.fast.ai).
#
# PyTorch provides methods to create random or zero-filled tensors, which we used to create our weights and bias for a simple linear model.  These are just regular tensors, with one very special addition: we tell PyTorch that they require a gradient. This causes PyTorch to record all of the operations done on the tensor, so that it can calculate the gradient during back-propagation *automatically*!
#
# For the weights, we set `requires_grad` **after** the initialization, since we don't want that step included in the gradient. (Note that a trailling `_` in PyTorch makes that operation in-place.)

# +
import math

weights = torch.randn(784,10)/math.sqrt(784)
weights.requires_grad_()
bias = torch.zeros(10, requires_grad=True)


# -

# Thanks to PyTorch's ability to calculate gradients automatically, we can use any standard Python function (or callable object) as a model! So let's just write a plain matrix multiplication and broadcasted addition to create a simple linear model. We also need an activation function, so we'll write `softmax` and use it. Remember: although PyTorch provides lots of pre-written loss functions, activation functions, and so forth, you can easily write your own using plain python. PyTorch will even create fast GPU or vectorized CPU code for your function automatically!

# +
# TODO: softmax
# -

def model(xb):
    xb = (xb @ weights) + bias
    return softmax(xb)


# We will call our function on one batch of data (in this case, 64 images).  This is one *forward pass*.  Note that our predictions won't be any better than random at this stage, since we start with random weights.

# +
bs=64 # batch size

preds = model(x_train[0:bs])
preds[0], preds.shape
# -

# As you see, the `preds` tensor contains not only the tensor values, but also a gradient function. We'll use this later to do backprop.
#
# Let's implement negative log-likelihood to use as the loss function (again, we can just use standard Python):

# +
# TODO: implement nll_loss
# -

# Let's check our loss with our random model, so we can see if we improve after a backprop pass later.

loss_fn(preds, y_train[0:bs])

# We can now run a training loop.  For each iteration, we will:
#
# - select a mini-batch of data (of size `bs`)
# - use the model to make predictions
# - calculate the loss
# - `loss.backward()` updates the gradients of the model, in this case, `weights` and `bias`.
# - We now use these gradients to update the weights and bias.  We do this within the `torch.no_grad()` context manager, because we do not want these actions to be recorded for our next calculation of the gradient.  You can read more about how PyTorch's Autograd records operations [here](https://pytorch.org/docs/stable/notes/autograd.html).
# - We then set the gradients to zero, so that we are ready for the next loop.  Otherwise, our gradients would record a running tally of all the operations that had happened (i.e. `loss.backward()` *adds* the gradients to whatever is already stored, rather than replacing them).

# Handy tip: you can use the standard python debugger to step through PyTorch code, allowing you to check the various variable values at each step. Uncomment `set_trace()` below to try it out.

# +
from IPython.core.debugger import set_trace

lr = 0.5   # learning rate
epochs = 2 # how many epochs to train for

for epoch in range(epochs):
    for i in range((n-1)//bs + 1):
#         set_trace()
        start_i = i*bs
        end_i = start_i+bs
        xb = x_train[start_i:end_i]
        yb = y_train[start_i:end_i]
        pred = model(xb)
        loss = loss_fn(pred, yb)

        loss.backward()
        with torch.no_grad():
            weights -= weights.grad * lr
            bias -= bias.grad * lr
            weights.grad.zero_()
            bias.grad.zero_()
# -

# That's it: we've created and trained a minimal neural network (in this case, a logistic regression, since we have no hidden layers) entirely from scratch!
#
# Let's check the loss and compare to what we got earlier. We expect that the loss will have decreased, and it has.

loss_fn(model(x_train[0:bs]), y_train[0:bs])

# ## Using `torch.nn.functional`

# We will now refactor our code, so that it does the same thing as before, only we'll start taking advantage of PyTorch's `nn` classes to make it more concise and flexible. At each step, we should be making our code one or more of: shorter, more understandable, and/or more flexible.
#
# The first and easiest step is to make our code shorter by replacing our hand-written activation and loss functions with those from `torch.nn.functional` (which is generally imported into the namespace `F` by convention). This module contains all the functions in the `torch.nn` library (whereas other parts of the library contain classes). As well as a wide range of loss and activation functions, you'll also find here some convenient functions for creating neural nets, such as pooling functions. (There are also functions for doing convolutions, linear layers, etc, but as we'll see, these are usually better handled using other parts of the library.)

# +
import torch.nn.functional as F

def model(xb):
    xb = (xb @ weights) + bias
    return F.log_softmax(xb, dim=-1)


# -

loss_fn = F.nll_loss

# ## Refactor using nn.Module

# Next up, we'll use `nn.Module` and `nn.Parameter`, for a clearer and more concise training loop. We subclass `nn.Module` (which itself is a class and able to keep track of state).  In this case, we want to create a class that holds our weights, bias, and method for the forward step.  `nn.Module` has a number of attributes and methods (such as `.parameters()` and `.zero_grad()`) which we will be using.
#
# **NB**: `torch.nn.Module` (uppercase M) is a PyTorch specific concept, and is a class we'll be using a lot. torch.nn.Module is not to be confused with the Python concept of a (lowercase m) [module](https://docs.python.org/3/tutorial/modules.html), which is a file of Python code that can be imported.
#
# (The weights in the class below are initialized with [Xavier initialisation](http://proceedings.mlr.press/v9/glorot10a/glorot10a.pdf) by dividing with 1/sqrt(n).)

# +
from torch import nn

class Mnist_Logistic(nn.Module):
    def __init__(self):
        super().__init__()
        self.weights = nn.Parameter(torch.randn(784,10)/math.sqrt(784))
        self.bias = nn.Parameter(torch.zeros(10))

    def forward(self, xb):
        xb = (xb @ self.weights) + self.bias
        return F.log_softmax(xb, dim=-1)


# -

# We instantiate our model:

model = Mnist_Logistic()

# We calculate the loss in the same way as before. Note that `nn.Module` objects are used as if they are functions (i.e they are *callable*), but behind the scenes Pytorch will call our `forward` method automatically.

loss_fn(model(x_train[0:bs]), y_train[0:bs])


# Now for our training loop.  Before we had to update the values for each parameter by name, and manually zero out the grads for each parameter separately, like this:
#
# ```
# with torch.no_grad():
#     weights -= weights.grad * lr
#     bias -= bias.grad * lr
#     weights.grad.zero_()
#     bias.grad.zero_()
# ```
#
# Now we can take advantage of model.parameters() and model.zero_grad() (which are both defined by PyTorch for `nn.Module`) to make those steps more concise and less prone to the error of forgetting some of our parameters, particularly if we had a more complicated model:
#
# ```
# with torch.no_grad():
#     for p in model.parameters(): p -= p.grad * lr
#     model.zero_grad()
# ```

def fit():
    for epoch in range(epochs):
        for i in range((n-1)//bs + 1):
            start_i = i*bs
            end_i = start_i+bs
            xb = x_train[start_i:end_i]
            yb = y_train[start_i:end_i]
            pred = model(xb)
            loss = loss_fn(pred, yb)

            loss.backward()
            with torch.no_grad():
                for p in model.parameters(): p -= p.grad * lr
                model.zero_grad()


fit()

loss_fn(model(x_train[0:bs]), y_train[0:bs])


# ## Refactor using nn.Linear

# We continue to refactor our code.  Now, instead of manually defining `self.weights` and `self.bias`, and calculating `(xb @ self.weights) + self.bias`, we will instead use the Pytorch class [nn.Linear](https://pytorch.org/docs/stable/nn.html#linear-layers) for a linear layer, which does all that for us. Pytorch has many types of predefined layers that can greatly simplify our code, and often makes it faster too.

class Mnist_Logistic(nn.Module):
    def __init__(self):
        super().__init__()
        self.lin = nn.Linear(784,10)

    def forward(self, xb): return F.log_softmax(self.lin(xb), dim=-1)


# We instantiate our model and calculate the loss in the same way as before:

model = Mnist_Logistic()
loss_fn(model(x_train[0:bs]), y_train[0:bs])

# Note that we are still able to use our same `fit` method as before.

fit()

loss_fn(model(x_train[0:bs]), y_train[0:bs])

# ## Refactor using optim

# Pytorch also has a package with various optimization algorithms, `torch.optim`. We can use the `step` method from our optimizer to take a forward step, instead of manually updating each parameter.
#
# This will let us replace our previous:
#
# ```
# with torch.no_grad():
#     for p in model.parameters(): p -= p.grad * lr
#     model.zero_grad()
# ```
#
# and instead use:
#
# ```
# opt.step()
# opt.zero_grad()
# ```
#
# `optim.zero_grad()` resets the gradient to 0 and we need to call it before computing the gradient for the next minibatch.

from torch import optim


def get_model():
    model = Mnist_Logistic()
    return model, optim.SGD(model.parameters(), lr=lr)


model,opt = get_model()
loss_fn(model(x_train[0:bs]), y_train[0:bs])

for epoch in range(epochs):
    for i in range((n-1)//bs + 1):
        start_i = i*bs
        end_i = start_i+bs
        xb = x_train[start_i:end_i]
        yb = y_train[start_i:end_i]
        pred = model(xb)
        loss = loss_fn(pred, yb)

        loss.backward()
        opt.step()
        opt.zero_grad()

loss_fn(model(x_train[0:bs]), y_train[0:bs])

# ## Refactor using Dataset

# PyTorch has an abstract Dataset class.  A Dataset must have a length and a way of indexing into it. [This tutorial](https://pytorch.org/tutorials/beginner/data_loading_tutorial.html) walks through a nice example of creating a custom FacialLandmarkDataset class as a subclass of Dataset. 
#
# PyTorch's [TensorDataset](https://pytorch.org/docs/stable/_modules/torch/utils/data/dataset.html#TensorDataset) is a Dataset wrapping tensors. By definining a length and way of indexing, this also gives us a way to iterate, index, and slice along the first dimension of a tensor. This will make it easier to access both the independent and dependent variables in the same line as we train.

from torch.utils.data import TensorDataset

model = Mnist_Logistic()

model,opt = get_model()

# Both `x_train` and `y_train` can be combined in a single TensorDataset, which will be easier to iterate over and slice.

train_ds = TensorDataset(x_train, y_train)

# Previously, we had to iterate through minibatches of x and y values separately:
#
# ```
#     xb = x_train[start_i:end_i]
#     yb = y_train[start_i:end_i]
# ```
#
# Now, we can do these two steps together (and we no longer need to declare `start_i` and `end_i` since we are not reusing them):
#
# ```
#     xb,yb = train_ds[i*bs : i*bs+bs]
# ```

for epoch in range(epochs):
    for i in range((n-1)//bs + 1):
        xb,yb = train_ds[i*bs : i*bs+bs]
        pred = model(xb)
        loss = loss_fn(pred, yb)

        loss.backward()
        opt.step()
        opt.zero_grad()

loss_fn(model(x_train[0:bs]), y_train[0:bs])

# ## Refactor using DataLoader

# Pytorch's DataLoader is responsible for managing batches.  This will make it easier to iterate over batches.  Later on, we will see that it can handle other actions we want to take with batches, such as shuffling, moving data to the GPU, padding jagged arrays, and more.
#
#
# to easily iterate over our dataset's minibatches as we train. Rather than having to use `train_ds[i*bs : i*bs+bs]`, the DataLoader gives us each minibatch automatically.

from torch.utils.data import DataLoader

model,opt = get_model()

train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs)

# Previously, our loop iterated over batches (xb, yb) like this:
#
# ```
# for i in range((n-1)//bs + 1):
#     xb,yb = train_ds[i*bs : i*bs+bs]
#     pred = model(xb)
#     ...
# ```
#
# Now, our loop is much cleaner, as (xb, yb) are loaded automatically from the data loader:
#
# ```
# for xb,yb in train_dl:
#     pred = model(xb)
#     ...
# ```
#

for epoch in range(epochs):
    for xb,yb in train_dl:
        pred = model(xb)
        loss = loss_fn(pred, yb)

        loss.backward()
        opt.step()
        opt.zero_grad()

loss_fn(model(x_train[0:bs]), y_train[0:bs])

# # Add validation

# ## First try

# In section 1, we were just trying to get a reasonable training loop set up for use on our training data.  In reality, you **always** should also have a [validation set](http://www.fast.ai/2017/11/13/validation-sets/), in order to identify if you are overfitting.
#
# Shuffling the training data is [important](https://www.quora.com/Does-the-order-of-training-data-matter-when-training-neural-networks) to prevent correlation between batches and overfitting. On the other hand, the validation loss will be identical whether we shuffle or not we shuffle the validation set. Since shuffling takes extra time, it makes no sense to shuffle the validation data.
#
# The batch size for the validation set is twice as large as that for the training set. This is [because](http://forums.fast.ai/t/why-is-batch-size-doubled-for-val-batches/720) the validation set does not need backpropagation and thus takes less memory (it doesn't need to store the gradients). We take advantage of this to use a larger batch size and compute the loss more quickly.

model,opt = get_model()

# +
train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs, shuffle=True)

valid_ds = TensorDataset(x_valid, y_valid)
valid_dl = DataLoader(valid_ds, batch_size=bs*2)
# -

loss_fn(model(x_valid[0:bs]), y_valid[0:bs])

# We will calculate and print the validation loss at the end of each epoch.

for epoch in range(epochs):
    model.train()
    for xb,yb in train_dl:
        pred = model(xb)
        loss = loss_fn(pred, yb)

        loss.backward()
        opt.step()
        opt.zero_grad()
        
    model.eval()
    with torch.no_grad():
        valid_loss = sum(loss_fn(model(xb), yb)
                         for xb,yb in valid_dl)

    print(epoch, valid_loss/len(valid_dl))


# ## Create fit() and get_data()

# Since we go through a similar process twice of calculating the loss for both the training set and the validation set, let's make that into its own method. `loss_batch` computes the loss for one batch.
#
# The primary difference is that we are not performing backpropagation on the validation set. Our method handles this by accepting an optional argument for the optimizer.  We pass an optimizer in for the training set, and use it to perform backprop.  For the validation set, we don't pass an optimizer, so the method doesn't perform backprop.

def loss_batch(model, loss_fn, xb, yb, opt=None):
    loss = loss_fn(model(xb), yb)

    if opt is not None:
        loss.backward()
        opt.step()
        opt.zero_grad()
        
    return loss.item(), len(xb)


# Up until now, we have primarily been running our training loop as a script, but we are now ready to make it into a method.  `fit` runs the necessary operations to train our model and compute the training and validation losses for each epoch.

# +
import numpy as np

def fit(epochs, model, loss_fn, opt, train_dl, valid_dl):
    for epoch in range(epochs):
        model.train()
        for xb,yb in train_dl: loss_batch(model, loss_fn, xb, yb, opt)

        model.eval()
        with torch.no_grad():
            losses,nums = zip(*[loss_batch(model, loss_fn, xb, yb)
                                for xb,yb in valid_dl])
        val_loss = np.sum(np.multiply(losses,nums)) / np.sum(nums)

        print(epoch, val_loss)


# -

# `get_data` returns dataloaders for the training and validation sets.

def get_data(train_ds, valid_ds, bs):
    return (DataLoader(train_ds, batch_size=bs, shuffle=True),
            DataLoader(valid_ds, batch_size=bs*2))


# Now, our whole process of obtaining the data loaders and fitting the model can be run in 3 lines of code:

# +
train_dl,valid_dl = get_data(train_ds, valid_ds, bs)

model,opt = get_model()

fit(epochs, model, loss_fn, opt, train_dl, valid_dl)


# -

# # Switch to CNN

# In our work above, we were using a simple single-layer logistic model.  We would now like to switch to a convolutional neural network. 

# ## First try

# We are now going to build our neural network with three convolutional layers. Because none of the functions in the previous section assume anything about the model form, we'll be able to use them to train a CNN without any modification.
#
# We will use Pytorch's predefined [Conv2d](https://pytorch.org/docs/stable/nn.html#torch.nn.Conv2d) function as our convolutional layer. We define a CNN with 3 convolutional layers. Each convolution is followed by a ReLU.  At the end, we perform an average pooling and then a log_softmax.

class Mnist_CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1,  16, kernel_size=3, stride=2, padding=1)
        self.conv2 = nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1)
        self.conv3 = nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1)

    def forward(self, xb):
        xb = xb.view(-1,1,28,28)
        xb = F.relu(self.conv1(xb))
        xb = F.relu(self.conv2(xb))
        xb = F.relu(self.conv3(xb))
        xb = F.avg_pool2d(xb, 4)
        xb = xb.view(-1,xb.size(1))
        return F.log_softmax(xb, dim=-1)


lr=0.1

# [Momentum](http://cs231n.github.io/neural-networks-3/#sgd) is a variation on stochastic gradient descent that takes previous updates into account as well and generally leads to faster training.

model = Mnist_CNN()
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)

xb, yb = next(iter(valid_dl))
loss_fn(model(xb), yb)

fit(epochs, model, loss_fn, opt, train_dl, valid_dl)


# ## nn.Sequential

# Finally, we will integrate all the neural network's operations into a [Sequential](https://pytorch.org/docs/stable/nn.html#torch.nn.Sequential) object. A Sequential object runs each of the functions contained within it, in a sequential manner. This is a simpler way of writing our neural network. 
#
# We want to be able to easily define a **custom layer** from a given function.  For instance, PyTorch doesn't have a `view` layer (`view` is PyTorch's version of reshape) and we need to create one for our network. `Lambda` will create an `nn.Module` (roughly equivalent to what is often thought of as a layer) that we can then use when defining a network with `Sequential`.

class Lambda(nn.Module):
    def __init__(self, func):
        super().__init__()
        self.func=func
        
    def forward(self, x): return self.func(x)


# We can simplify our model a little by replacing the `nll_loss` loss function with `cross_entropy`. `cross_entropy` combines the softmax activation and negative log likelihood loss together into a single function, which results in less code, and is more numerically stable.

loss_fn = F.cross_entropy

# The model created with `Sequential` is relatively straightforward for the programmer to apply:

model = nn.Sequential(
    Lambda(lambda x: x.view(-1,1,28,28)),
    nn.Conv2d(1,  16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
    nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1), nn.ReLU(),
    nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1), nn.ReLU(),
    nn.AvgPool2d(4),
    Lambda(lambda x: x.view(x.size(0),-1))
)

opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)

xb, yb = next(iter(valid_dl))
loss_fn(model(xb), yb)

fit(epochs, model, loss_fn, opt, train_dl, valid_dl)



StarcoderdataJupyterScriptsDedupFiltered