<jupyter_start><jupyter_text>INFO8010: Homework 1In this first homework, you will familiarize yourself with the basics of differentiable programming and the PyTorch framework. At the end of this assignment, you should be able to build and train your own multi-layer perceptron. Although this assignment is optional, we **strongly** advise you to do it as you are expected to be comfortable with PyTorch at the end of this course.In the following, we provide some code blocks that are already running, along with explanations. You are asked to complete the code blocks in which the ` your code` comment is present. Sometimes, you will also have to motivate why you programmed certain things or guide us quickly through the results you obtained. If this is required you will see the following instruction:> your discussionPlease note that you will **not** have to handle in any sort of written report by the end of the assignment. We do however expect you to submit the notebook **with the solutions** to the exercises.  0. ImportsOur first step is very easy: we simply import the required libraries.<jupyter_code>import matplotlib.pyplot as plt
import numpy as np
import torch<jupyter_output><empty_output><jupyter_text>1. Tensors and basic operationsTensors are one of the main ingredients when it comes to modern deep learning frameworks. Almost all deep learning computations can be expressed as tensor operations which make computation fast and efficient, especially on graphics processing units (GPUs). We will now see how to manipulate tensors within the PyTorch framework.There are many valid definitions for tensors, but to keep it simple you can see them as multi-dimensional arrays. Zero-dimentional tensors are scalars, one-dimentional tensors are vectors, two-dimentional tensors are matrices, etc.In PyTorch, a tensor is an instance of the class `torch.Tensor`. PyTorch tensors are **very** similar to NumPy arrays.Almost any operations you could imagine can be performed on tensors (see [API](https://pytorch.org/docs/stable/tensors.html)).  1.1 TensorsThere are many ways to [create tensors](https://pytorch.org/docs/stable/tensors.htmltorch.Tensor), for instance:<jupyter_code># we can create a tensor from lists
t1 = torch.tensor([[1, 2], [-7, 9]])
print(t1)

# or from numpy arrays
t2 = torch.from_numpy(np.array([[1., 8.], [0., 3.]]))
print(t2)

# or we can create a 2x3x4 tensor filled with zeros
t3 = torch.zeros(2, 3, 4)
print(t3)<jupyter_output><empty_output><jupyter_text>As an array may contain different types of data (e.g. float, int, boolean), so can a tensor. Let's check what types of data are in `t1`, `t2` and `t3`:<jupyter_code>print(t1.dtype, t2.dtype, t3.dtype, sep='\n')<jupyter_output><empty_output><jupyter_text>As expected, PyTorch uses an integer data type to store integer values and a floating-point data type to store real values. However, unlike NumPy, PyTorch uses a **single-precision** floating-point data type (`float32`) by default.You can convert tensors from one type to another:<jupyter_code>print(t1.float(), t2.long(), t3.double(), sep='\n')<jupyter_output><empty_output><jupyter_text>Another important property of a tensor is its shape. You can use the `.shape` property or `.size()` method to check the dimensions of a tensor. Both return a `torch.Size` object that can be manipulated with its own operations. Most of the time, you will just use `torch.Size` objects as tuples or lists (e.g. to check the size of a tensor along one of its dimension).<jupyter_code># the full shape
print(t1.shape)

# the length of the tensor along its first dimension
print(t2.shape[0])

# the shape of the two last dimensions
print(t3.shape[-2:])<jupyter_output><empty_output><jupyter_text>1.2 OperationsWe now consider simple tensor operations on 1D (vectors) and 2D (matrices) tensors. Lets instantiate two vectors with 5 elements and two matrices of respectively 3x5 and 5x5 elements.<jupyter_code>v1 = torch.ones(5)
print(v1)

v2 = torch.randn(5)  # standard gaussian
print(v2)

m1 = torch.rand(3, 5)  # [0, 1) uniform
print(m1)

m2 = torch.eye(5)  # identity matrix
print(m2)<jupyter_output><empty_output><jupyter_text>The most basic operations are the addition, subtraction, multiplication and division. As in NumPy, but unlike MATLAB, the symbols `+, -, *, /` all perform **element-wise** operations.<jupyter_code># add v1 and v2
v_sum = v1 + v2
print(v_sum)

# subtract v1 from v2
v_sub = v1 - v2
print(v_sub)

# multiply the elements of v1 and v2
v_mul = v1 * v2
print(v_mul)

# divide the elements of v1 by v2
v_div = v1 / v2
print(v_div)<jupyter_output><empty_output><jupyter_text>When trying to perform an element-wise operation between tensors of different shapes, the tensors are [broadcast](https://numpy.org/doc/stable/user/basics.broadcasting.html) together, if possible. Notably, this allows to add or multiply tensors by scalars.<jupyter_code># add a scalar
print(v1 + 1)

# multiply by scalar
print(v2 * 2)

# broadcast substraction between vector and matrix
print(v2 - m1)<jupyter_output><empty_output><jupyter_text>Another basic operation with tensors is the inner product, denoted by the symbol `@`.<jupyter_code># scalar product between v1 and v2
print(v1 @ v2)

# matrix-vector product between m1 and v2
print(m1 @ v2)

# matrix-matrix product between m1 and m2
print(m1 @ m2)<jupyter_output><empty_output><jupyter_text>AggregationCommon opperations are to aggregate the values of a tensor along a dimension into a single value. For instance, computing the sum of the rows of a matrix . A lot of aggregation methods such as `.sum`, `.prod`, `.mean`, `.max`, ... are available.<jupyter_code># sum of rows of m1
print(m1.sum(dim=1))

# mean of columns of m2
print(m2.mean(dim=0))

# prod of the elements of v2
print(v2.prod())<jupyter_output><empty_output><jupyter_text>IndexingSometimes you would like to extract a sub-tensor from the tensor. This operation is called *slicing*.<jupyter_code># extract elements 0 (included) to 2 (not included)
print(v1[0:2])

# extract all elements but the last
print(v2[:-1])<jupyter_output><empty_output><jupyter_text>You may also extract a subset of the elements by passing a list of indices.<jupyter_code># extract the first, fourth and fifth elements
v2[[0, 3, 4]]<jupyter_output><empty_output><jupyter_text>Matrices can also be sliced/indexed.<jupyter_code>m1[:4, 2:-1]<jupyter_output><empty_output><jupyter_text>Squeeze and UnsqueezeSqueezing removes dimensions of size 1 from tensors. Unsqueeze adds a dimension of size 1.<jupyter_code># before unsqueeze
print(m1.shape)

# after unsqueeze
m1 = m1.unsqueeze(1)
print(m1.shape)

# after squeeze
m1 = m1.squeeze(1)
print(m1.shape)<jupyter_output><empty_output><jupyter_text>ViewSometimes, tensors don't have the correct shape. For example, you might want to process 3x32x32 images as vectors of 3072 elements. The `.view` method returns a new tensor with the same data, but of a different shape.<jupyter_code>images = torch.rand(10, 3, 32, 32)
print(images.shape)

images_as_vectors = images.view(10, -1)
print(images_as_vectors.shape)<jupyter_output><empty_output><jupyter_text>However, both tensors share the same underlying data, meaning that modifying one in-place will also modify the other.<jupyter_code>images[0, :] = torch.zeros_like(images[0])
images_as_vectors[0]<jupyter_output><empty_output><jupyter_text>It should be noted that slicing, indexing and squeezing operations actually create views of the tensor instead of making copies of the data. For a complete list of view operations in `torch`, see the [documentation](https://pytorch.org/docs/stable/tensor_view.html).  Other primitivesA **lot** of useful primitives are available in `torch`. Notable examples are [exp](https://pytorch.org/docs/stable/generated/torch.exp.html), [log](https://pytorch.org/docs/stable/generated/torch.log.html), [sqrt](https://pytorch.org/docs/stable/generated/torch.sqrt.html), [ceil](https://pytorch.org/docs/stable/generated/torch.ceil.html), [clamp](https://pytorch.org/docs/stable/generated/torch.expand.html), [sort](https://pytorch.org/docs/stable/generated/torch.sort.html), [argmax](https://pytorch.org/docs/stable/generated/torch.argmax.html), [stack](https://pytorch.org/docs/stable/generated/torch.stack.html), [cat](https://pytorch.org/docs/stable/generated/torch.cat.html), [where](https://pytorch.org/docs/stable/generated/torch.where.html) and so on. Always take a look at the [documentation](https://pytorch.org/docs/stable/index.html) before implementing something. And don't forget: Google is your best friend!  1.3 Try it yourself! Create a tensor `samples` with $10^{5}$ i.i.d. normally (mean 5 and standard deviation 2) distributed values.<jupyter_code>samples = # your code<jupyter_output><empty_output><jupyter_text>Extract the first 42 elements of `samples`.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Extract the last 666 elements of `samples`.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Extract all the elements of `samples` that have an odd index.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Create a view of shape 10x1000x10 of `samples`.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Compute the mean of `samples` using a `for` loop:<jupyter_code>%%timeit

# your code<jupyter_output><empty_output><jupyter_text>Compute the mean and standard deviation of `samples` with the appropriate PyTorch operators:<jupyter_code>%%timeit

# your code<jupyter_output><empty_output><jupyter_text>What do you observe when you compare the running time of the two code snippets?> your discussion Compute the absolute-value ($L_1$) and Euclidean ($L_2$) norms of `samples`, without loops.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Extract the first and last 13 elements of `samples` as two distinct vectors and compute their [outer product](https://en.wikipedia.org/wiki/Outer_product) matrix, without loops.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Extract the diagonal of this matrix.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>Trace the curve defined by the coordinates$$x(t) = 16 \sin(t)^3$$$$y(t) = 13 \cos(t) - 5 \cos(2t) - 2 \cos(3t) - \cos(4t)$$for $t \in [0, 2\pi[$.You can use `np.pi` for the value of $\pi$ and the `matplotlib` package to plot the curve.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>You can now send the result to your crush ;)  2. The autograd packageThe autograd package is what really makes PyTorch different from other algebraic language/libraries such as MATLAB or NumPy. Whenever you make operations in PyTorch, it will create a computation graph that can later be used to compute derivatives of the output quantities with respect to the input or other intermediate computation steps. The official documentation says> `torch.autograd` provides classes and functions implementing automatic differentiation of arbitrary scalar valued functions. It requires minimal changes to the existing code - you only need to declare tensors for which gradients should be computed with the `requires_grad=True` keyword.For more information about autograd you can check this short [tutorial](https://pytorch.org/tutorials/beginner/blitz/autograd_tutorial.html) and the [documentation](https://pytorch.org/docs/stable/autograd.html).  2.1 A guided tourLet's see how autograd may be used to compute the derivative(s) of the following function.<jupyter_code>def f(x):
    return x**2 + 2 * x - 0.5 * torch.sin(x * 3)

def f_prime(x):  # f'
    return 2 * x + 2 - 1.5 * torch.cos(x * 3)

def f_pprime(x):  # f''
    return 2 + 4.5 * torch.sin(x * 3)
x = torch.randn(1, requires_grad=True)  # without requires_grad, the computation graph is not created
print(x)

y = f(x)
print(y)

dy = torch.autograd.grad(y, x, create_graph=True)[0]  # create_graph is necessary for higher-order derivative
print(dy, f_prime(x))

ddy = torch.autograd.grad(dy, x)[0]
print(ddy, f_pprime(x))<jupyter_output><empty_output><jupyter_text>As expected, autograd is able to compute gradients automatically. We can exploit this to perform **gradient descent** with parametric approximators. Let's approximate the function `f` using a polynomial.<jupyter_code># learning set
x = torch.linspace(-3, 3, 1000)
y = f(x)

# parametric approximator (polynomial of degree 3)
def approximator(w, x):
    return torch.stack((torch.ones_like(x), x, x**2, x**3), dim=-1) @ w

# initialize parameters at random
w = torch.randn(4, requires_grad=True)<jupyter_output><empty_output><jupyter_text>Let's take a look at what happens before training<jupyter_code>plt.title('Before training')
plt.plot(x, y, label='Ground truth')
plt.plot(x, approximator(w, x).detach(), label='Approximation')
plt.legend()
plt.show()<jupyter_output><empty_output><jupyter_text>We perform gradient descent on the mean squared error (MSE) loss function in order to fit the learning set.<jupyter_code>lr = .001  # learning rate

for i in range(1000):
    y_pred = approximator(w, x)
    mse = torch.mean((y - y_pred)**2)  # mean squared error
    grad = torch.autograd.grad(mse, w)[0]
    
    with torch.no_grad():
        w -= lr * grad  # gradient descent update

plt.title('After training')
plt.plot(x, y, label='Ground truth')
plt.plot(x, approximator(w, x).detach(), label='Approximation')
plt.legend()
plt.show()<jupyter_output><empty_output><jupyter_text>If the approximator became larger, calling the `torch.autograd.grad` method with all parameter tensors would become quite cumbersome. Fortunately, PyTorch provides the `.backward()` method to acumulate (in the `.grad` property) the gradient values in **all** tensors that are in the computation graph. Later on in this notebook we will see how this, together with `Optimizer` objects, makes gradient descent very simple.<jupyter_code>x = torch.rand(5)
w = torch.rand(5, requires_grad=True)
y = w @ x
y.backward()

print(x, w.grad, sep='\n')<jupyter_output><empty_output><jupyter_text>Importantly, the construction of the computation graphs is expensive and memory consuming. If some computations do not use the gradients, like evaluating an approximator or updating the parameters, one can disable the creation of the computation graph with the `torch.no_grad()` context.<jupyter_code>w = torch.rand(100, 100, requires_grad=True)
x = torch.rand(100)

%timeit w @ x

with torch.no_grad():
    %timeit w @ x<jupyter_output><empty_output><jupyter_text>2.2 Try it yourself!We would like you to perform the same kind of curve fitting that we did a few cells above, but this time we want to use the `.backward()` method instead of `torch.autograd.grad`. Remember that PyTorch will keep **accumulating** the gradient values in the `.grad` property of each tensor, i.e. the values are not replaced but added together after each backward pass. Fortunately, you can reset the values of the gradient to 0 with the `.zero_()` in-place method (e.g. `w.grad.zero_()`). To make your life easier, you can start from the learning loop three cells above, but be careful to re-initialize the parameters at random before training and to not use the `torch.autograd.grad` function.<jupyter_code># your code<jupyter_output><empty_output><jupyter_text>3. The `torch.nn` and `torch.optim` modulesYou should now be able to define your own neural network and train it with tensorial operations and the autograd package. However this would require you to explicitely define every operation in the neural network and to keep track of all the parameters for performing gradient descent. Fortunately, PyTorch provides the `torch.nn` and `torch.optim` packages which implement everything you need to define and train a neural network efficiently.<jupyter_code>import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim<jupyter_output><empty_output><jupyter_text>`nn.functional`The `torch.nn.functional` (imported as `F` in this notebook) module implements many predefined functions which should simplify your life for building your own neural networks. The complete list can be accessed [here](https://pytorch.org/docs/stable/nn.functional.html). Let's play with some activation functions.<jupyter_code>x = torch.linspace(-5, 5, 100)

plt.plot(x, F.relu(x), label='ReLU')
plt.plot(x, F.elu(x), label='ELU')
plt.plot(x, F.silu(x), label='SiLU')
plt.legend()
plt.show()<jupyter_output><empty_output><jupyter_text>`nn.Module`PyTorch provides a very important base class named `nn.Module`. This class is used to build complex neural networks. In fact any class which inherits from it will automatically keep track of its parameters. To define your own `nn.Module` subclass you only need to implement the `__init__` constructor and the `forward` function. Let's see an example:<jupyter_code># we create a class 'SimpleParametricModel' which inherits from nn.Module
class SimpleParametricModel(nn.Module):
    def __init__(self):
        # nn.Module's constructor has to be called BEFORE adding any sub-modules
        super().__init__()
        
        # we add a linear layer to our module, which is itself a module
        self.linear = nn.Linear(in_features=3, out_features=1, bias=True)
        
        # we add an activation function to our module
        self.activation = nn.ReLU()
        
    # we define the forward pass
    def forward(self, x):
        x = self.linear(x)
        x = self.activation(x)
        return x<jupyter_output><empty_output><jupyter_text>We can now create an instance of the `SimpleParametricModel` class and have a look at it.<jupyter_code>model = SimpleParametricModel()
print(model)<jupyter_output><empty_output><jupyter_text>We observe that the two components are correctly registered. We can also inspect the parameters of our model with the `.parameters()` or `.state_dict()` methods.<jupyter_code>for p in model.parameters():
    print(p)
model.state_dict()<jupyter_output><empty_output><jupyter_text>As expected, the parameters of the linear layer are considered as parameters of the model itself.PyTorch implements many components that are commonly used in neural networks such as convolutions, linear layers, normalizations and activations. Most of them are available as ready-to-use modules in `torch.nn` (imported as `nn` in this notebook).Let's build a simple multi-layer perceptron (MLP) with two hidden layers and a ReLU activation function.<jupyter_code>class TwoLayerMLP(nn.Module):
    def __init__(self, input_features, output_features, hidden_features=16):
        super().__init__()
        
        self.l1 = nn.Linear(input_features, hidden_features)
        self.l2 = nn.Linear(hidden_features, hidden_features)
        self.l3 = nn.Linear(hidden_features, output_features)
        
        self.activation = nn.ReLU()

    def forward(self, x):
        h1 = self.activation(self.l1(x))
        h2 = self.activation(self.l2(h1))
        y = self.l3(h2)
        return y

# instantiate the network to have 3 input features and 1 output feature (and 16 hidden features)
network = TwoLayerMLP(3, 1)
print(network)
# we usually give batches of values to networks to take advantage of parallelism
# here the batch size is 32
x = torch.rand(8, 3)

# call network.forward under the hood
y = network(x)

print(x, y, sep='\n')<jupyter_output><empty_output><jupyter_text>`nn.Sequential`Writing the forward pass can become cumbersome and dirty if you have a large number of sequential layers. For that kind of networks, you can use `nn.Sequential` as parent instead of `nn.Module`, which calls the layers sequentially within its forward pass.<jupyter_code>class ElegantTwoLayerMLP(nn.Sequential):
    def __init__(self, input_features, output_features, hidden_features=16):
        super().__init__(
            nn.Linear(input_features, hidden_features),
            nn.ReLU(),
            nn.Linear(hidden_features, hidden_features),
            nn.ReLU(),
            nn.Linear(hidden_features, output_features),
        )

#  instantiate the network to have 1 input feature and 1 output feature    
network = ElegantTwoLayerMLP(1, 1)
print(network)<jupyter_output><empty_output><jupyter_text>OptimizersYou should now be able to define any kind of neural network you like. However if you want to optimize it you would still need to iterate through all the parameters to perform a gradient descent step. Fortunately, `torch.nn.optim` (imported as `optim`) implements classes that handle that for you! For example, let's say you would like to learn the function $f(x) = x^2$ with the previous neural network, using a MSE loss (often called the criterion) and stochastic gradient descent (SGD):<jupyter_code>def f(x):
    return x**2

# we use the MSE loss as criterion
criterion = nn.MSELoss()

# we create an instance of the SGD class that will make the updates for us
optimizer = optim.SGD(params=network.parameters(), lr=.01)

# Let's do some learning steps with randomly generated x values:
for i in range(1000):
    x = torch.randn(100, 1)
    y = f(x)
    y_pred = network(x)
    
    # we set all gradient values to 0
    optimizer.zero_grad()
    
    # we compute the loss
    loss = criterion(y_pred, y)
    
    # we perform the backward pass to accumulate the gradients
    loss.backward()
    
    # we update the parameters
    optimizer.step()<jupyter_output><empty_output><jupyter_text>Let's check the results.<jupyter_code>x = torch.linspace(-3, 3, 100).unsqueeze(1)

with torch.no_grad():
    y = network(x)

plt.plot(x, f(x), label='$f(x)$')
plt.plot(x, y, label='Approximation')
plt.legend()
plt.show()<jupyter_output><empty_output><jupyter_text>4. Training our first neural network classifierWe now have all the necessary knowledge to build and train our very first neural network on a simple binary classification task. To do this we will start by importing a toy example dataset from the `sklearn` library, and then create and use the resulting splits for training.<jupyter_code>from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

X, Y = make_moons(500, noise=0.1)  # create artificial data

X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.25, random_state=73)  # create splits

plt.scatter(X_train[:,0], X_train[:,1], c=Y_train)  # visualize the training data
plt.show()<jupyter_output><empty_output><jupyter_text>When we train a neural network in PyTorch, no matter how complex the model is, we always go through a training loop. In this loop we feed the data to the model and get its predictions. We then compare the predictions of the network to the ground truth and adjust the parameters of the model by performing gradient descent. We have already seen all the components that are necessary for going through this process, so the only thing that remains to be done is to put all the pieces of this notebook together and train our **first awesome neural network!**During the training stage we would like to keep track whether our model will improve over the different iterations. It is therefore good practice to monitor whether the loss we are minimizing decreases over time, and whether the overall performance of the model increases the more training iterations we perform. Remember that one of the key components of PyTorch are tensors, and we might not always have the data coming in this specific format. It is therefore necessary to convert it if needed.Once the data is in an appropriate format it can be given to the model, and we can obtain its predictions. This is what we usually call the **forward pass**. Once we obtain our predictions, we can compare how close they are to what we would like the network to predict: to do this we feed our predictions together with the true labels through the loss function which we are minimizing. At its early training stages the network will perform poorly, but it will improve as its weights/parameters are updated by gradient descent. We can do this very easily by obtaining the gradients of the parameters with respect to the loss function we are minimizing (**backward pass**) and adjusting these weights with the optimizer we defined previously.Once all of this is done we can measure the performance of our model, which in this case will be reflected by its accuracy in classifying the synthetic dataset we created beforehand.<jupyter_code>net = nn.Sequential(
    nn.Linear(X.shape[-1], 32),
    nn.ReLU(),
    nn.Linear(32, 32),
    nn.ReLU(),
    nn.Linear(32, 1),
    nn.Sigmoid(),
)
criterion = nn.BCELoss()
optimizer = optim.Adam(net.parameters(), lr=.01)
train_loss = []  # where we keep track of the loss
train_accuracy = []  # where we keep track of the accuracy of the model
iters = 128  # number of training iterations

X_train_t = torch.from_numpy(X_train).float()
Y_train_t = torch.from_numpy(Y_train).float()

for i in range(iters):
    y_pred = net(X_train_t)  # forward pass
    y_pred = y_pred.squeeze(-1)  # transform the 1-element vectors into scalars

    optimizer.zero_grad() # reset the gradients to 0
    loss = criterion(y_pred, Y_train_t)  # compute the loss
    loss.backward()  # obtain the gradients with respect to the loss
    optimizer.step()  # perform one step of gradient descent
    
    with torch.no_grad():
        y_pred_class = y_pred > 0.5  # we assign a label (0 or 1) based on the network's prediction
        accuracy = (Y_train_t == y_pred_class).float().mean()  # compute accuracy
    
    train_accuracy.append(accuracy.item())
    train_loss.append(loss.item())
fig, axs = plt.subplots(2, 1)

axs[0].set_title('Training Loss')
axs[0].plot(train_loss)
axs[0].set_xlabel('Iterations')
axs[0].set_ylabel('Loss (Binary Cross Entropy)')

axs[1].set_title('Training Accuracy')
axs[1].plot(train_accuracy)
axs[1].set_xlabel('Iterations')
axs[1].set_ylabel('Accuracy')

plt.tight_layout()
plt.show()
def plot_decision_boundary(X, y, model, steps=1000, cmap='Paired'):
    """
    Function to plot the decision boundary and data points of a model.
    Data points are colored based on their actual label.
    """
    cmap = plt.get_cmap(cmap)

    # Define region of interest by data limits
    xmin, xmax = X[:,0].min(), X[:,0].max()
    ymin, ymax = X[:,1].min(), X[:,1].max()
    steps = 1000
    x_span = np.linspace(xmin - .5, xmax + .5, steps)
    y_span = np.linspace(ymin - .5, ymax + .5, steps)
    xx, yy = np.meshgrid(x_span, y_span)

    # Make predictions across region of interest
    with torch.no_grad():
        grid = np.stack([xx, yy], axis=-1)
        grid = torch.from_numpy(grid).float()
        pred = model(grid).squeeze(-1)

    # Plot decision boundary in region of interest
    fig, ax = plt.subplots()
    ax.contourf(xx, yy, pred, cmap=cmap, alpha=0.5)

    # Get predicted labels on training data and plot
    train_labels = model(torch.from_numpy(X).float())
    ax.scatter(X[:,0], X[:,1], c=y.ravel(), cmap=cmap, lw=0)

    return fig

fig = plot_decision_boundary(X_test, Y_test, net, cmap = 'RdBu')<jupyter_output><empty_output><jupyter_text>4.1 Discuss the bugs After actively following the theoretical lectures of professor Louppe, our favourite Italian student <NAME> remembered that a common loss function one can use when dealing with classification problems is the Cross-Entropy loss. However after implementing it in PyTorch, Al was disappointed because his code was not working.What do you think are the causes of the bug(s)? Maybe, first check the documentation of [`torch.nn.CrossEntropyLoss`](https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html). Briefly discuss your ideas and **bonus** can you come up with a solution?<jupyter_code>net = nn.Sequential(
    nn.Linear(X.shape[-1], 32),
    nn.ELU(),
    nn.Linear(32, 32),
    nn.ELU(),
    nn.Linear(32, 1),
)

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=0.01)

train_loss = []
train_accuracy = []
iters = 420

X_train_t = torch.from_numpy(X_train).float()
Y_train_t = torch.from_numpy(Y_train).float()

for i in range(iters):
    y_pred = net(X_train_t)
    y_pred = y_pred.squeeze(-1)

    loss = criterion(y_pred, Y_train_t)
    loss.backward()
    optimizer.step()<jupyter_output><empty_output><jupyter_text>> your discussion<jupyter_code># your code (bonus)<jupyter_output><empty_output><jupyter_text>4.2 Try it yourself! Now try to play around with the neural network yourself: you are free to experiment with whatever you think is best exploring. Your goal is to come up with three modifications to the provided network architecture and to compare the performance of your modified models. As potential modifications you could experiment with changing the depth or width of the model, modifying the activation function, learning rate, optimizer, etc.Report your results with some appropriate plots and with a brief presentation of what you observe.<jupyter_code># your code<jupyter_output><empty_output>