# Tutorial 1: Gradient Descent and AutoGrad
**Week 1, Day 2: Linear Deep Learning**

**By Neuromatch Academy**

__Content creators:__ Saeed Salehi, Vladimir Haltakov, Andrew Saxe

__Content reviewers:__ Polina Turishcheva, Antoine De Comite, Kelson Shilling-Scrivo

__Content editors:__ Anoop Kulkarni, Spiros Chavlis

__Production editors:__ Khalid Almubarak, Spiros Chavlis

---
#Tutorial Objectives

Day 2 Tutorial 1 will continue on buiding PyTorch skillset and motivate its core functionality, Autograd. In this notebook, we will cover the key concepts and ideas of:

* Gradient descent
* PyTorch Autograd
* PyTorch nn module

```python
# @title Tutorial slides

# @markdown These are the slides for the videos in this tutorial
from IPython.display import IFrame
IFrame(src=f"https://mfr.ca-1.osf.io/render?url=https://osf.io/3qevp/?direct%26mode=render%26action=download%26mode=render", width=854, height=480)
```

---
# Setup

This a GPU-Free tutorial!

```python
# @title Install dependencies
!pip install git+https://github.com/NeuromatchAcademy/evaltools --quiet

from evaltools.airtable import AirtableForm
# init airtable form
atform = AirtableForm('appn7VdPRseSoMXEG','W1D2_T1','https://portal.neuromatchacademy.org/api/redirect/to/9c55f6cb-cdf9-4429-ac1c-ec44fe64c303')
```

      Building wheel for evaltools (setup.py) ... done

```python
# Imports
import torch
import numpy as np
from torch import nn
from math import pi
import matplotlib.pyplot as plt
```

```python
# @title Figure settings
import ipywidgets as widgets       # interactive display
%config InlineBackend.figure_format = 'retina'
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/content-creation/main/nma.mplstyle")
```

```python
# @title Plotting functions

from mpl_toolkits.axes_grid1 import make_axes_locatable

def ex3_plot(model, x, y, ep, lss):
  f, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
  ax1.set_title("Regression")
  ax1.plot(x, model(x).detach().numpy(), color='r', label='prediction')
  ax1.scatter(x, y, c='c', label='targets')
  ax1.set_xlabel('x')
  ax1.set_ylabel('y')
  ax1.legend()

  ax2.set_title("Training loss")
  ax2.plot(np.linspace(1, epochs, epochs), losses, color='y')
  ax2.set_xlabel("Epoch")
  ax2.set_ylabel("MSE")

  plt.show()

def ex1_plot(fun_z, fun_dz):
  """Plots the function and gradient vectors

  """
  x, y = np.arange(-3, 3.01, 0.02), np.arange(-3, 3.01, 0.02)
  xx, yy = np.meshgrid(x, y, sparse=True)
  zz = fun_z(xx, yy)
  xg, yg = np.arange(-2.5, 2.6, 0.5), np.arange(-2.5, 2.6, 0.5)
  xxg, yyg = np.meshgrid(xg, yg, sparse=True)
  zxg, zyg = fun_dz(xxg, yyg)

  plt.figure(figsize=(8, 7))
  plt.title("Gradient vectors point towards steepest ascent")
  contplt = plt.contourf(x, y, zz, levels=20)
  plt.quiver(xxg, yyg, zxg, zyg, scale=50, color='r', )
  plt.xlabel('$x$')
  plt.ylabel('$y$')
  ax = plt.gca()
  divider = make_axes_locatable(ax)
  cax = divider.append_axes("right", size="5%", pad=0.05)
  cbar = plt.colorbar(contplt, cax=cax)
  cbar.set_label('$z = h(x, y)$')
  plt.show()
```

```python
# @title Set random seed

# @markdown Executing `set_seed(seed=seed)` you are setting the seed

# for DL its critical to set the random seed so that students can have a
# baseline to compare their results to expected results.
# Read more here: https://pytorch.org/docs/stable/notes/randomness.html

# Call `set_seed` function in the exercises to ensure reproducibility.
import random
import torch

def set_seed(seed=None, seed_torch=True):
  if seed is None:
    seed = np.random.choice(2 ** 32)
  random.seed(seed)
  np.random.seed(seed)
  if seed_torch:
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.cuda.manual_seed(seed)
    torch.backends.cudnn.benchmark = False
    torch.backends.cudnn.deterministic = True

  print(f'Random seed {seed} has been set.')

# In case that `DataLoader` is used
def seed_worker(worker_id):
  worker_seed = torch.initial_seed() % 2**32
  np.random.seed(worker_seed)
  random.seed(worker_seed)
```

```python
# @title Set device (GPU or CPU). Execute `set_device()`
# especially if torch modules used.

# inform the user if the notebook uses GPU or CPU.

def set_device():
  device = "cuda" if torch.cuda.is_available() else "cpu"
  if device != "cuda":
    print("GPU is not enabled in this notebook. \n"
          "If you want to enable it, in the menu under `Runtime` -> \n"
          "`Hardware accelerator.` and select `GPU` from the dropdown menu")
  else:
    print("GPU is enabled in this notebook. \n"
          "If you want to disable it, in the menu under `Runtime` -> \n"
          "`Hardware accelerator.` and select `None` from the dropdown menu")

  return device
```

```python
SEED = 2021
set_seed(seed=SEED)
DEVICE = set_device()
```

    Random seed 2021 has been set.
    GPU is not enabled in this notebook.
    If you want to enable it, in the menu under `Runtime` ->
    `Hardware accelerator.` and select `GPU` from the dropdown menu

---
# Section 0: Introduction

Today, we will go through 3 tutorials. Starting with Gradient Descent, the workhorse of deep learning algorithms, in this tutorial. The second tutorial will help us build a better intuition about neural networks and basic hyper-parameters. Finally, in tutorial 3, we learn about the learning dynamics, what the (a good) deep network is learning, and why sometimes they may perform poorly.

```python
# @title Video 0: Introduction
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1Qf4y1578t", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"i7djAv2jnzY", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 0:Introduction')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

---
# Section 1: Gradient Descent Algorithm

*Time estimate: ~30-45 mins*

Since the goal of most learning algorithms is **minimizing the risk (also known as the cost or loss) function**, optimization is often the core of most machine learning techniques! The gradient descent algorithm, along with its variations such as stochastic gradient descent, is one of the most powerful and popular optimization methods used for deep learning. Today we will introduce the basics, but you will learn much more about Optimization in the coming days (Week 1 Day 4).

## Section 1.1: Gradients & Steepest Ascent

```python
# @title Video 1: Gradient Descent
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1Pq4y1p7em", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"UwgA_SgG0TM", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 1: Gradient Descent')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

Before introducing the gradient descent algorithm, let's review a very important property of gradients. The gradient of a function always points in the direction of the steepest ascent. The following exercise will help clarify this.

### Analytical Exercise 1.1: Gradient vector (Optional)

Given the following function:

\begin{equation}
z = h(x, y) = \sin(x^2 + y^2)
\end{equation}

find the gradient vector:

\begin{equation}
  \begin{bmatrix}
  \dfrac{\partial z}{\partial x} \\ \\ \dfrac{\partial z}{\partial y}
  \end{bmatrix}
\end{equation}

*hint: use the chain rule!*

**Chain rule**: For a composite function $F(x) = g(h(x)) \equiv (g \circ h)(x)$:

\begin{equation}
F'(x) = g'(h(x)) \cdot h'(x)
\end{equation}

or differently denoted:

\begin{equation}
\frac{dF}{dx} = \frac{dg}{dh} ~ \frac{dh}{dx}
\end{equation}

---
#### Solution:
We can rewrite the function as a composite function:

\begin{equation}
z = f\left( g(x,y) \right), ~~ f(u) = \sin(u), ~~ g(x, y) = x^2 + y^2
\end{equation}

Using chain rule:

\begin{align}
\dfrac{\partial z}{\partial x} &= \dfrac{\partial f}{\partial g} \dfrac{\partial g}{\partial x} = \cos(g(x,y)) ~ (2x) = \cos(x^2 + y^2) \cdot 2x \\ \\
\dfrac{\partial z}{\partial y} &= \dfrac{\partial f}{\partial g} \dfrac{\partial g}{\partial y} = \cos(g(x,y)) ~ (2y) = \cos(x^2 + y^2) \cdot 2y
\end{align}

### Coding Exercise 1.1: Gradient Vector

Implement (complete) the function which returns the gradient vector for $z=\sin(x^2 + y^2)$.

```python
def fun_z(x, y):
  """Function sin(x^2 + y^2)

  Args:
    x (float, np.ndarray): variable x
    y (float, np.ndarray): variable y

  Return:
    z (float, np.ndarray): sin(x^2 + y^2)
  """
  z = np.sin(x**2 + y**2)
  return z

def fun_dz(x, y):
  """Function sin(x^2 + y^2)

  Args:
    x (float, np.ndarray): variable x
    y (float, np.ndarray): variable y

  Return:
    (tuple): gradient vector for sin(x^2 + y^2)
  """
  #################################################
  ## Implement the function which returns gradient vector
  ## Complete the partial derivatives dz_dx and dz_dy
  # Complete the function and remove or comment the line below
  #################################################
  dz_dx = np.cos(x**2 + y**2) *2*x
  dz_dy = np.cos(x**2 + y**2) *2*y
  return (dz_dx, dz_dy)

#add event to airtable
atform.add_event('Coding Exercise 1.1: Gradient Vector')

## Uncomment to run
ex1_plot(fun_z, fun_dz)
```

We can see from the plot that for any given $x_0$ and $y_0$, the gradient vector $\left[ \dfrac{\partial z}{\partial x}, \dfrac{\partial z}{\partial y}\right]^{\top}_{(x_0, y_0)}$ points in the direction of $x$ and $y$ for which $z$ increases the most. It is important to note that gradient vectors only see their local values, not the whole landscape! Also, length (size) of each vector, which indicates the steepness of the function, can be very small near local plateaus (i.e. minima or maxima).

Thus, we can simply use the aforementioned formula to find the local minima.

In 1847, Augustin-Louis Cauchy used **negative of gradients**  to develop the Gradient Descent algorithm as an **iterative** method to **minimize** a **continuous** and (ideally) **differentiable function** of **many variables**.

```python
# @title Video 2: Gradient Descent - Discussion
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1Rf4y157bw", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"8s22ffAfGwI", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 2: Gradient Descent ')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

## Section 1.2: Gradient Descent Algorithm

Let $f(\mathbf{w}): \mathbb{R}^d \rightarrow \mathbb{R}$ be a differentiable function. Gradient Descent is an iterative algorithm for minimizing the function $f$, starting with an initial value for variables $\mathbf{w}$, taking steps of size $\eta$ (learning rate) in the direction of the negative gradient at the current point to update the variables $\mathbf{w}$.

\begin{equation}
\mathbf{w}^{(t+1)} = \mathbf{w}^{(t)} - \eta \nabla f (\mathbf{w}^{(t)})
\end{equation}

where $\eta > 0$ and $\nabla f (\mathbf{w})= \left( \frac{\partial f(\mathbf{w})}{\partial w_1}, ..., \frac{\partial f(\mathbf{w})}{\partial w_d} \right)$. Since negative gradients always point locally in the direction of steepest descent, the algorithm makes small steps at each point **towards** the minimum.

**Vanilla Algorithm**

---
> **inputs**: initial guess $\mathbf{w}^{(0)}$, step size $\eta > 0$, number of steps $T$

> *For* $t = 0, 2, \dots , T-1$ *do* \
$\qquad$ $\mathbf{w}^{(t+1)} = \mathbf{w}^{(t)} - \eta \nabla f (\mathbf{w}^{(t)})$\
*end*

> *return*: $\mathbf{w}^{(t+1)}$

---

Hence, all we need is to calculate the gradient of the loss function with respect to the learnable parameters (i.e. weights):

\begin{equation}
\dfrac{\partial Loss}{\partial \mathbf{w}} = \left[ \dfrac{\partial Loss}{\partial w_1}, \dfrac{\partial Loss}{\partial w_2} , ..., \dfrac{\partial Loss}{\partial w_d} \right]^{\top}
\end{equation}

### Analytical Exercise 1.2: Gradients

Given $f(x, y, z) = \tanh \left( \ln \left[1 + z \frac{2x}{sin(y)} \right] \right)$, how easy is it to derive $\dfrac{\partial f}{\partial x}$, $\dfrac{\partial f}{\partial y}$ and $\dfrac{\partial f}{\partial z}$? (*hint: you don't have to actually calculate them!*)

## Section 1.3: Computational Graphs and Backprop

```python
# @title Video 3: Computational Graph
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1c64y1B7ZG", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"2z1YX5PonV4", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 3: Computational Graph ')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

 *Exercise 1.2* is an example of how overwhelming the derivation of gradients can get, as the number of variables and nested functions increases. This function is still extraordinarily simple compared to the loss functions of modern neural networks. So how can we (as well as PyTorch and similar frameworks) approach such beasts?

Let’s look at the function again:

\begin{equation}
f(x, y, z) = \tanh \left(\ln \left[1 + z \frac{2x}{sin(y)} \right] \right)
\end{equation}

We can build a so-called computational graph (shown below) to break the original function into smaller and more approachable expressions.

Starting from $x$, $y$, and $z$ and following the arrows and expressions, you would see that our graph returns the same function as $f$. It does so by calculating intermediate variables $a,b,c,d,$ and $e$. This is called the **forward pass**.

Now, let’s start from $f$, and work our way against the arrows while calculating the gradient of each expression as we go. This is called the **backward pass**, from which the **backpropagation of errors** algorithm gets its name.

By breaking the computation into simple operations on intermediate variables, we can use the chain rule to calculate any gradient:

\begin{equation}
\dfrac{\partial f}{\partial x} = \dfrac{\partial f}{\partial e}~\dfrac{\partial e}{\partial d}~\dfrac{\partial d}{\partial c}~\dfrac{\partial c}{\partial a}~\dfrac{\partial a}{\partial x} = \left( 1-\tanh^2(e) \right) \cdot \frac{1}{d+1}\cdot z \cdot \frac{1}{b} \cdot 2
\end{equation}

Conveniently, the values for $e$, $b$, and $d$ are available to us from when we did the forward pass through the graph. That is, the partial derivatives have simple expressions in terms of the intermediate variables $a,b,c,d,e$ that we calculated and stored during the forward pass.

### Analytical Exercise 1.3: Chain Rule (Optional)
For the function above, calculate the $\dfrac{\partial f}{\partial y}$ using the computational graph and chain rule.

---
#### Solution:
\begin{equation}
\dfrac{\partial f}{\partial y} = \dfrac{\partial f}{\partial e}~\dfrac{\partial e}{\partial d}~\dfrac{\partial d}{\partial c}~\dfrac{\partial c}{\partial b}~\dfrac{\partial b}{\partial y} = \left( 1-\tanh^2(e) \right) \cdot \frac{1}{d+1}\cdot z \cdot \frac{-a}{b^2} \cdot \cos(y)
\end{equation}

For more: [Calculus on Computational Graphs: Backpropagation](https://colah.github.io/posts/2015-08-Backprop/)

---
# Section 2: PyTorch AutoGrad

*Time estimate: ~30-45 mins*

```python
# @title Video 4: Auto-Differentiation
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1UP4y1s7gv", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"IBYFCNyBcF8", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 4: Auto-Differentiation ')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

Deep learning frameworks such as PyTorch, JAX, and TensorFlow come with a very efficient and sophisticated set of algorithms, commonly known as Automatic Differentiation. AutoGrad is PyTorch's automatic differentiation engine. Here we start by covering the essentials of AutoGrad, and you will learn more in the coming days.

## Section 2.1: Forward Propagation

Everything starts with the forward propagation (pass). PyTorch tracks all the instructions, as we declare the variables and operations, and it builds the graph when we call the `.backward()` pass. PyTorch rebuilds the graph every time we iterate or change it (or simply put, PyTorch uses a dynamic graph).

For gradient descent, it is only required to have the gradients of cost function with respect to the variables we wish to learn. These variables are often called "learnable / trainable parameters" or simply "parameters" in PyTorch. In neural nets, weights and biases are often the learnable parameters.

### Coding Exercise 2.1: Buiding a Computational Graph

In PyTorch, to indicate that a certain tensor contains learnable parameters, we can set the optional argument `requires_grad` to `True`. PyTorch will then track every operation using this tensor while configuring the computational graph. For this exercise, use the provided tensors to build the following graph, which implements a single neuron with scalar input and output.

```python
#add event to airtable
atform.add_event('Coding Exercise 2.1: Computational Graph ')

class SimpleGraph:
  def __init__(self, w, b):
    """Initializing the SimpleGraph

    Args:
      w (float): initial value for weight
      b (float): initial value for bias
    """
    assert isinstance(w, float)
    assert isinstance(b, float)
    self.w = torch.tensor([w], requires_grad=True)
    self.b = torch.tensor([b], requires_grad=True)

  def forward(self, x):
    """Forward pass

    Args:
      x (torch.Tensor): 1D tensor of features

    Returns:
      torch.Tensor: model predictions
    """
    assert isinstance(x, torch.Tensor)
    #################################################
    ## Implement the the forward pass to calculate prediction
    ## Note that prediction is not the loss, but the value after `tanh`
    #################################################
    prediction = torch.tanh(self.w * x + self.b)
    return prediction

def sq_loss(y_true, y_prediction):
  """L2 loss function

  Args:
    y_true (torch.Tensor): 1D tensor of target labels
    y_prediction (torch.Tensor): 1D tensor of predictions

  Returns:
    torch.Tensor: L2-loss (squared error)
  """
  assert isinstance(y_true, torch.Tensor)
  assert isinstance(y_prediction, torch.Tensor)
  #################################################
  ## Implement the L2-loss (squred error) given true label and prediction
  #################################################
  loss = (y_true - y_prediction)**2
  return loss

feature = torch.tensor([1])  # input tensor
target = torch.tensor([7])  # target tensor
## Uncomment to run
simple_graph = SimpleGraph(-0.5, 0.5)
print(f"initial weight = {simple_graph.w.item()}, "
      f"\ninitial bias = {simple_graph.b.item()}")
prediction = simple_graph.forward(feature)
square_loss = sq_loss(target, prediction)
print(f"for x={feature.item()} and y={target.item()}, "
      f"prediction={prediction.item()}, and L2 Loss = {square_loss.item()}")
```

    initial weight = -0.5,
    initial bias = 0.5
    for x=1 and y=7, prediction=0.0, and L2 Loss = 49.0

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content-dl/tree/main//tutorials/W1D2_LinearDeepLearning/solutions/W1D2_Tutorial1_Solution_6668feea.py)

It is important to appreciate the fact that PyTorch can follow our operations as we arbitrarily go through classes and functions.

## Section 2.2: Backward Propagation

Here is where all the magic lies. In PyTorch, `Tensor` and `Function` are interconnected and build up an acyclic graph, that encodes a complete history of computation. Each variable has a `grad_fn` attribute that references a function that has created the Tensor (except for Tensors created by the user - these have `None` as `grad_fn`).  The example below shows that the tensor `c = a + b` is created by the `Add` operation and the gradient function is the object `<AddBackward...>`. Replace `+` with other single operations (e.g., `c = a * b` or `c = torch.sin(a)`) and examine the results.

```python
a = torch.tensor([1.0], requires_grad=True)
b = torch.tensor([-1.0], requires_grad=True)
c = a + b
print(f'Gradient function = {c.grad_fn}')
```

    Gradient function = <AddBackward0 object at 0x7ff37990aa90>

For more complex functions, printing the `grad_fn` would only show the last operation, even though the object tracks all the operations up to that point:

```python
print(f'Gradient function for prediction = {prediction.grad_fn}')
print(f'Gradient function for loss = {square_loss.grad_fn}')
```

    Gradient function for prediction = <TanhBackward object at 0x7ff37185f9d0>
    Gradient function for loss = <PowBackward0 object at 0x7ff37185f850>

Now let's kick off the backward pass to calculate the gradients by calling `.backward()` on the tensor we wish to initiate the backpropagation from. Often, `.backward()` is called on the loss, which is the last node on the graph. Before doing that, let's calculate the loss gradients by hand:

$$\frac{\partial{loss}}{\partial{w}} = - 2 x (y_t - y_p)(1 - y_p^2)$$

$$\frac{\partial{loss}}{\partial{b}} = - 2 (y_t - y_p)(1 - y_p^2)$$

Where $y_t$ is the target (true label), and $y_p$ is the prediction (model output). We can then compare it to PyTorch gradients, which can be obtained by calling `.grad` on the relevant tensors.

**Important Notes**
* Learnable parameters (i.e. `reguires_grad` tensors) are "contagious". Let's look at a simple example: `Y = W @ X`, where `X` is the feature tensors and `W` is the weight tensor (learnable parameters, `reguires_grad`), the newly generated output tensor `Y` will be also `reguires_grad`. So any operation that is applied to `Y` will be part of the computational graph. Therefore, if we need to plot or store a tensor that is `reguires_grad`, we must first `.detach()` it from the graph by calling the `.detach()` method on that tensor.

* `.backward()` accumulates gradients in the leaf nodes (i.e., the input nodes to the node of interest). We can call `.zero_grad()` on the loss or optimizer to zero out all `.grad` attributes (see [autograd.backward](https://pytorch.org/docs/stable/autograd.html#torch.autograd.backward) for more).

* Recall that in python we can access variables and associated methods with `.method_name`. You can use the command `dir(my_object)` to observe all variables and associated methods to your object, e.g., `dir(simple_graph.w)`.

```python
# analytical gradients (remember detaching)
ana_dloss_dw = - 2 * feature * (target - prediction.detach())*(1 - prediction.detach()**2)
ana_dloss_db = - 2 * (target - prediction.detach())*(1 - prediction.detach()**2)

square_loss.backward()  # first we should call the backward to build the graph
autograd_dloss_dw = simple_graph.w.grad  # we calculate the derivative w.r.t weights
autograd_dloss_db = simple_graph.b.grad  # we calculate the derivative w.r.t bias

print(ana_dloss_dw == autograd_dloss_dw)
print(ana_dloss_db == autograd_dloss_db)
```

References and more:
* [A GENTLE INTRODUCTION TO TORCH.AUTOGRAD](https://pytorch.org/tutorials/beginner/blitz/autograd_tutorial.html)

* [AUTOMATIC DIFFERENTIATION PACKAGE - TORCH.AUTOGRAD](https://pytorch.org/docs/stable/autograd.html)

* [AUTOGRAD MECHANICS](https://pytorch.org/docs/stable/notes/autograd.html)

* [AUTOMATIC DIFFERENTIATION WITH TORCH.AUTOGRAD](https://pytorch.org/tutorials/beginner/basics/autogradqs_tutorial.html)

---
# Section 3: PyTorch's Neural Net module (`nn.Module`)

*Time estimate: ~30 mins*

```python
# @title Video 5: PyTorch `nn` module
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1MU4y1H7WH", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"jzTbQACq7KE", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 5: PyTorch `nn` module')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

PyTorch provides us with ready-to-use neural network building blocks, such as layers (e.g. linear, recurrent, ...), different activation and loss functions, and much more, packed in the [`torch.nn`](https://pytorch.org/docs/stable/nn.html) module. If we build a neural network using `torch.nn` layers, the weights and biases are already in `requires_grad` mode and will be registered as model parameters.

For training, we need three things:

*   **Model parameters** - Model parameters refer to all the learnable parameters of the model, which are accessible by calling `.parameters()` on the model. Please note that NOT all the `requires_grad` tensors are seen as model parameters. To create a custom model parameter, we can use [`nn.Parameter`](https://pytorch.org/docs/stable/generated/torch.nn.parameter.Parameter.html) (*A kind of Tensor that is to be considered a module parameter*).

*   **Loss function** - The loss that we are going to be optimizing, which is often combined with regularization terms (conming up in few days).

*   **Optimizer** - PyTorch provides us with many optimization methods (different versions of gradient descent). Optimizer holds the current state of the model and by calling the `step()` method, will update the parameters based on the computed gradients.

You will learn more details about choosing the right model architecture, loss function, and optimizer later in the course.

## Section 3.1: Training loop in PyTorch

We use a regression problem to study the training loop in PyTorch.

The task is to train a wide nonlinear (using $\tanh$ activation function) neural net for a simple $\sin$ regression task. Wide neural networks are thought to be really good at generalization.

```python
# @markdown #### Generate the sample dataset
set_seed(seed=SEED)
n_samples = 32
inputs = torch.linspace(-1.0, 1.0, n_samples).reshape(n_samples, 1)
noise = torch.randn(n_samples, 1) / 4
targets = torch.sin(pi * inputs) + noise
plt.figure(figsize=(8, 5))
plt.scatter(inputs, targets, c='c')
plt.xlabel('x (inputs)')
plt.ylabel('y (targets)')
plt.show()
```

Let's define a very wide (512 neurons) neural net with one hidden layer and `Tanh` activation function.

```python
## A Wide neural network with a single hidden layer
class WideNet(nn.Module):

  def __init__(self):
    """Initializing the WideNet
    """
    n_cells = 512
    super().__init__()
    self.layers = nn.Sequential(
        nn.Linear(1, n_cells),
        nn.Tanh(),
        nn.Linear(n_cells, 1),
    )

  def forward(self, x):
    """Forward pass

    Args:
      x (torch.Tensor): 2D tensor of features

    Returns:
      torch.Tensor: model predictions
    """
    return self.layers(x)
```

We can now create an instance of our neural net and print its parameters.

```python
# creating an instance
set_seed(seed=SEED)
wide_net = WideNet()
print(wide_net)
```

    Random seed 2021 has been set.
    WideNet(
      (layers): Sequential(
        (0): Linear(in_features=1, out_features=512, bias=True)
        (1): Tanh()
        (2): Linear(in_features=512, out_features=1, bias=True)
      )
    )

```python
# Create a mse loss function
loss_function = nn.MSELoss()

# Stochstic Gradient Descent optimizer (you will learn about momentum soon)
lr = 0.003  # learning rate
sgd_optimizer = torch.optim.SGD(wide_net.parameters(), lr=lr, momentum=0.9)
```

The training process in PyTorch is interactive - you can perform training iterations as you wish and inspect the results after each iteration.

Let's perform one training iteration. You can run the cell multiple times and see how the parameters are being updated and the loss is reducing. This code block is the core of everything to come: please make sure you go line-by-line through all the commands and discuss their purpose with the pod.

```python
# Reset all gradients to zero
sgd_optimizer.zero_grad()

# Forward pass (Compute the output of the model on the features (inputs))
prediction = wide_net(inputs)

# Compute the loss
loss = loss_function(prediction, targets)
print(f'Loss: {loss.item()}')

# Perform backpropagation to build the graph and compute the gradients
loss.backward()

# Optimizer takes a tiny step in the steepest direction (negative of gradient)
# and "updates" the weights and biases of the network
sgd_optimizer.step()
```

    Loss: 0.4656757116317749

### Coding Exercise 3.1: Training Loop
Using everything we've learned so far, we ask you to complete the `train` function below.

```python
def train(features, labels, model, loss_fun, optimizer, n_epochs):

  """Training function

  Args:
    features (torch.Tensor): features (input) with shape torch.Size([n_samples, 1])
    labels (torch.Tensor): labels (targets) with shape torch.Size([n_samples, 1])
    model (torch nn.Module): the neural network
    loss_fun (function): loss function
    optimizer(function): optimizer
    n_epochs (int): number of training iterations

  Returns:
    list: record (evolution) of training losses
  """
  loss_record = []  # keeping recods of loss

  for i in range(n_epochs):
    #################################################
    ## Implement the missing parts of the training loop
    # Complete the function and remove or comment the line below
    #################################################
    optimizer.zero_grad()  # set gradients to 0
    predictions =  model(features)  # Compute model prediction (output)
    loss = loss_fun(predictions, labels)  # Compute the loss
    loss.backward()  # Compute gradients (backward pass)
    optimizer.step()  # update parameters (optimizer takes a step)

    loss_record.append(loss.item())
  return loss_record

#add event to airtable
atform.add_event('Coding Exercise 3.1: Training Loop')

set_seed(seed=2021)
epochs = 1847 # Cauchy, Exercices d'analyse et de physique mathematique (1847)
## Uncomment to run
losses = train(inputs, targets, wide_net, loss_function, sgd_optimizer, epochs)
ex3_plot(wide_net, inputs, targets, epochs, losses)
```

---
# Summary

In this tutorial we covered one of the most basic concepts of deep learning; the computational graph and how a network learns via gradient descent and the backpropagation algorithm. We have seen all of these using PyTorch modules and we compared the analytical solutions with the ones provided directly by the PyTorch module.

```python
# @title Video 6: Tutorial 1 Wrap-up
from ipywidgets import widgets

out2 = widgets.Output()
with out2:
  from IPython.display import IFrame
  class BiliVideo(IFrame):
    def __init__(self, id, page=1, width=400, height=300, **kwargs):
      self.id=id
      src = "https://player.bilibili.com/player.html?bvid={0}&page={1}".format(id, page)
      super(BiliVideo, self).__init__(src, width, height, **kwargs)

  video = BiliVideo(id=f"BV1Pg41177VU", width=854, height=480, fs=1)
  print("Video available at https://www.bilibili.com/video/{0}".format(video.id))
  display(video)

out1 = widgets.Output()
with out1:
  from IPython.display import YouTubeVideo
  video = YouTubeVideo(id=f"TvZURbcnXc4", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

out = widgets.Tab([out1, out2])
out.set_title(0, 'Youtube')
out.set_title(1, 'Bilibili')

#add event to airtable
atform.add_event('Video 6: Tutorial 1 Wrap-up')

display(out)
```

    Tab(children=(Output(), Output()), _titles={'0': 'Youtube', '1': 'Bilibili'})

```python
# @title Airtable Submission Link
from IPython import display as IPyDisplay
IPyDisplay.HTML(
    f"""
  <div>
    <a href= "{atform.url()}" target="_blank">
    </a>
    </div>""" )
```

  <a href= "https://portal.neuromatchacademy.org/api/redirect/to/9c55f6cb-cdf9-4429-ac1c-ec44fe64c303?data=eyJmb3JtX2lkIjogImFwcG43VmRQUnNlU29NWEVHIiwgInRhYmxlX25hbWUiOiAiVzFEMl9UMSIsICJhbnN3ZXJzIjoge30sICJldmVudHMiOiBbeyJldmVudCI6ICJpbml0IiwgInRzIjogMTYyNzk3NjM5Ny4zMzg4MTU1fSwgeyJldmVudCI6ICJWaWRlbyAwOkludHJvZHVjdGlvbiIsICJ0cyI6IDE2Mjc5NzY1MzEuMTY5MDczfSwgeyJldmVudCI6ICJWaWRlbyAxOiBHcmFkaWVudCBEZXNjZW50IiwgInRzIjogMTYyNzk3NzQwMy42NDMzODI1fSwgeyJldmVudCI6ICJDb2RpbmcgRXhlcmNpc2UgMS4xOiBHcmFkaWVudCBWZWN0b3IiLCAidHMiOiAxNjI3OTc4MDUwLjIwNDAyODF9LCB7ImV2ZW50IjogIlZpZGVvIDI6IEdyYWRpZW50IERlc2NlbnQgIiwgInRzIjogMTYyNzk3ODA2MC4xNzQzNjc3fSwgeyJldmVudCI6ICJWaWRlbyAzOiBDb21wdXRhdGlvbmFsIEdyYXBoICIsICJ0cyI6IDE2Mjc5NzkyOTUuNzEyNzM4fSwgeyJldmVudCI6ICJWaWRlbyA0OiBBdXRvLURpZmZlcmVudGlhdGlvbiAiLCAidHMiOiAxNjI3OTc5ODc3LjAzMDk5NX0sIHsiZXZlbnQiOiAiQ29kaW5nIEV4ZXJjaXNlIDIuMTogQ29tcHV0YXRpb25hbCBHcmFwaCAiLCAidHMiOiAxNjI3OTgwNjI0LjgzNDk4MTR9LCB7ImV2ZW50IjogIkNvZGluZyBFeGVyY2lzZSAyLjE6IENvbXB1dGF0aW9uYWwgR3JhcGggIiwgInRzIjogMTYyNzk4MDY2MS44NzU4Mjg3fSwgeyJldmVudCI6ICJWaWRlbyA1OiBQeVRvcmNoIGBubmAgbW9kdWxlIiwgInRzIjogMTYyNzk4MjAyNi4xMzU4NjQzfSwgeyJldmVudCI6ICJDb2RpbmcgRXhlcmNpc2UgMy4xOiBUcmFpbmluZyBMb29wIiwgInRzIjogMTYyNzk4MzczNS42Nzc4NzY3fSwgeyJldmVudCI6ICJWaWRlbyA2OiBUdXRvcmlhbCAxIFdyYXAtdXAiLCAidHMiOiAxNjI3OTgzNzY0LjUwNjgwOTd9LCB7ImV2ZW50IjogInVybCBnZW5lcmF0ZWQiLCAidHMiOiAxNjI3OTgzOTQzLjEzMTE4MjJ9XX0%3D" target="_blank">


#  Machine Learning con Applicazioni
#### Andrea Sala - 983944

###  Introduzione
Il dataset scelto per questo progetto consiste in circa 6000 entries riguardanti alcune bottiglie di vino bianco e rosso della variante portoghese Vinho Verde. Ogni bottiglia è caratterizzata da 12 variabili di input, corrispondenti ad alcune caratteristiche chimico-fisiche del vino, e da una variabile di output di tipo sensoriale (un punteggio dato dai sommelier all'apertura della bottiglia). L'obiettivo di questo studio è quello di allenare un predittore attraverso diversi algoritmi di supervised learning. Il predittore allenato dovrebbe essere in grado di classificare il vino - ovvero predirre il suo punteggio- prese in ingresso le variabili di input. In altre parole, si cerca di scoprire se esiste una dipendenza funzionale della qualità del vino dalle variabili fisico-chimiche dello stesso.

###  Caricamento del dataset e preprocessing

Il dataset è stato caricato da un file ```.csv``` disponibile gratuitamente sulla piattaforma _Kaggle_, e immaganizzato in un ```pandas.DataFrame```. Inoltre, per chiarezza, la variabile di output è stata rinominata _score_, mentre la variabile _style_ (_red_,_white_) è stata resa una variabile binaria (0,1).

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.decomposition import PCA
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score, accuracy_score
import time
from IPython.display import Image
from tqdm.notebook import tqdm

plt.style.use('seaborn')

import warnings
warnings.filterwarnings('ignore')
```

```python
#centering all images
from IPython.core.display import HTML
HTML("""
<style>
.output_png {
    display: table-cell;
    text-align: center;
    vertical-align: middle;
}
</style>
""")
```

```python
fileName = "wine_dataset.csv"
df = pd.read_csv(fileName)
df['style'] = df['style'].replace('red', 0)
df['style'] = df['style'].replace('white',1)
df['score'] = df['quality']
df['score'].astype(int)
del df['quality']
y = df['score']

print("Dataset shape:")
print(df.shape)
```

    Dataset shape:
    (6497, 13)

La variabile di output presenta una distribuzione non isotropa, ma centrata molto sui punteggi nell intervallo \[5,7\]: questo renderà più difficile predirre gli output con punteggio molto alto (8,9) o molto basso (3), a meno che non vengano usate delle tecniche per aumentare il numero di dati in queste classi.

```python
sns.countplot(df['score'])
plt.show()
```

###  Tecniche di oversampling: SMOTE

Come si può vedere dal grafico poco sopra, le classi non sono per nulla bilanciate. In particolare, solo le tre classi centrali (con punteggio 5, 6, 7) presentano un numero adeguato di entries, mentre le altre classi presentano un bassissimo numero di dati. Questo _imbalance_ è dannoso per gli algoritmi che andrò ad allenare, in quanto avranno a disposizione troppi pochi dati per imparare a riconoscere i dati appartenenti alle classi più povere.

Per questo motivo ho fatto uso di tecniche di oversampling, in particolare di _SMOTE_ (Synthetic Minority Over-sampling TEchnique). Questa tecinca va a campionare nuovi dati per le classi di minoranza, secondo questo procedimento:

- Viene preso un dato appartenente ad una classe minoritaria e vengono calcolati i suoi primi k vicini (nel mio caso k=4)
- Viene scelto uno a caso tra questi k vicini e si traccia il segmento che congiunge questi due punti nello spazio dei parametri
- Viene estratto un punto a caso che giace su questo segmento: questo è il nuovo dato classificato con lo stesso label del dato di partenza

In questo modo la distribuzione del dataset cambia e diventa molto più bilanciata, facilitando il lavoro ai predittori che userò in questo studio. Per decidere quanti dati dovevano venire ricampionati, ho scelto dei numeri che mi garantissero che la distribuzione finale fosse piccata al centro ma ben distribuita (vedi figura sotto).

```python
from imblearn.over_sampling import SMOTE, RandomOverSampler
train_cols = ['fixed_acidity','volatile_acidity','citric_acid','residual_sugar','chlorides',
               'free_sulfur_dioxide','density','pH','sulphates','alcohol', 'style']
X = df[train_cols]
y = df['score']
weights = {3:800, 4:1500, 5:2300, 6:2836, 7:1800, 8:1200, 9:300}
oversample = SMOTE(k_neighbors=4, sampling_strategy=weights)

XX, y = oversample.fit_resample(X, y)
XX.shape
print("Successfully generated {} new entries".format(XX.shape[0] - X.shape[0]))
```

    Successfully generated 4239 new entries

```python
sns.countplot(y)
plt.show()
```

###  Matrice di correlazione
Ho calcolato e rappresentato la matrice di correlazione tra tutte le variabili presenti nel dataset. Come si vede in figura, tutte le varibili di input sono tra loro poco correlate; si è scelto di rimuovere solo la variabile _total\_sulfur\_dioxide_  perché presenta due coefficienti di correlazione alti  (>0.7) con due altre variaibli di input.

Un problema che emerge dal plot della matrice di correlazione è che nessuna delle variabili di input presenta coefficienti di correlazione lineare elevati con la variabile _score_. Dalla matrice di correlazione possiamo quindi estrarre due informazioni:
- Il metodo della regressione lineare può essere scartato a priori, in quanto si basa su combinazioni lineari delle variabili di input per costruire un predittore, ma combinando linearmente queste variabili di input è molto difficile ottenere la forma funzionale dell'output.
- In ogni caso, non ci dovremo aspettare degli _score_ troppo alti per gli algoritmi che andremo ad allenare, in quanto il dataset mostra poche correlazioni con la variabile di output.

```python
CorMat = df.corr().round(2)
fig = plt.figure(figsize=(10,8))
sns.heatmap(data=CorMat, annot=True, cmap='viridis')
plt.show()
```

Successivamente, ho diviso il dataset in _training_ e _test_ per poi procedere all'allenamento di alcuni semplici algoritmi di supervised learning.

```python
X_train, X_test, y_train, y_test = train_test_split(XX, y, train_size=0.8)

print(X_train.shape, X_test.shape)
```

    (8588, 11) (2148, 11)

###  Support Vector Machine

Il primo predittore che ho usato è un Support Vector Classifier (SVC). Questo è un predittore lineare, che va a costruire un iperpiano di separazione tra le classi (confrontando una coppia di classi alla volta, in gergo _one versus one_). L'equazione dell'iperpiano è

$$f(x) = x^T\beta + \beta_0 = 0 $$

Il problema di minimizzazione nel caso di classi non completamente separabili è
$$ \min_{\mathbb{R}^p} \beta \quad : \quad y_i (x_i^T\beta + \beta_0) \geq M (1-\xi_i)$$
con i vincoli
$$\xi_i\geq0 \quad\mbox{e}\quad \Sigma \xi_i \leq C$$

Le variabili $\xi_i$ sono dette _slack variables_ e sono quelle che permettono a determinati punti di giacere dalla parte sbagliata dell'iperpiano di separazione. Le misclassificazioni si hanno per $\xi_i > 1$, ma il parametro $C$ limita il numero massimo di misclassificazioni con il vincolo $\Sigma \xi_i\leq C$.

Nonostante un classificatore SVC sia per definizione lineare, posso estendere questo algortitmo ad un caso di separazione non lineare grazie all'utilizzo di kernel non lineari. Ho provato ad allenare quattro diversi SVC, con kernel rispettivamente:
- lineare - $K(x,x') = \langle x,x'\rangle$
- radial basis function -  $K(x,x')=\exp(-\gamma ||x-x'||^2)$
- polinomiale di grado 3 -  $K(x,x')=(1 + \langle x,x'\rangle)^3$
- kernel a sigmoide - $K(x,x') = \tanh (\gamma \langle x,x'\rangle + c)$

I risultati di accuracy sul dataset di test sono mostrati di seguito.

```python
from sklearn import svm
```

```python
svm_lin = svm.SVC(kernel='linear', C=10, decision_function_shape='ovo').fit(X_train, y_train)
score = svm_lin.score(X_test,y_test)
print("SVM score with linear kernel: {}".format(score))
```

    SVM score with linear kernel: 0.4245810055865922

```python
svm_rbf = svm.SVC(kernel='rbf', C=5, gamma=2,decision_function_shape='ovo').fit(X_train, y_train)
score = svm_rbf.score(X_test,y_test)
print("SVM score with RBF kernel: {}".format(score))
```

    SVM score with RBF kernel: 0.7574487895716946

```python
svm_poly = svm.SVC(kernel='poly', C=5, degree=4, decision_function_shape='ovo').fit(X_train, y_train)
score = svm_poly.score(X_test,y_test)
print("SVM score with polynomial kernel: {}".format(score))
```

    SVM score with polynomial kernel: 0.36312849162011174

```python
svm_fitter = svm.SVC(kernel='sigmoid', C=10, gamma=2.5, decision_function_shape='ovo').fit(X_train, y_train)
score = svm_fitter.score(X_test, y_test)
print("SVM score with sigmoid kernel: {}".format(score))
```

    SVM score with sigmoid kernel: 0.25931098696461824

Si osserva che le prestazioni dell'algoritmo sono tendenzialmente basse, indice del fatto che non è facile allenare un SVC su un dataset di questo tipo. Tra i kernel usati c'è però un'eccezione: le prestazioni migliori sono offerte dal Support Vector Machine con **kernel a base radiale**. Ho quindi scelto di concentrarmi su questo algoritmo e cercare di ottimizzarne i parametri.

#####  SVM con Kernel RBF - Ottimizzazione dei parametri

Il parametro $\gamma$ è quello all'interno del kernel
$$ K(x,x') = \exp(-\gamma ||x-x'||^2) $$

Ho allenato l'algoritmo scegliendo $\gamma$ diversi e osservando per quale valore di $\gamma$ l'accuracy sul training set è massimizzata.

Il parametro $C$ del Support Vector Machine è quello responsabile dell'ampiezza della banda di separazione. Un margine di separazione stretto corrisponde a $C$ grande, ovvero tengo basso il numero di $\xi_i>0$ e impedisco le misclassificazioni. Il rischio di tenere $C$ grande è quello di avere un margine di separazione frastagliato e di andare in **overfitting**.
Viceversa, avere $C$ piccolo signfica permettere più misclassificazioni ed avere quindi un boundary più liscio: si introduce un bias ma si evita l'overifitting. La scelta ottimale è evidentemente una via di mezzo tra questi due poli, ma per capire il parametro migliore è stato necessario provare alcune configurazioni e vedere quale produce lo score più alto:

```python
gammas = [0.1, 1.0, 2.5, 5, 7.5, 10, 20]
Cs = [0.01, 0.1, 0.5, 1.0, 2.5, 5, 10, 20]
gBest, cBest, scoreBest = gammas[0], Cs[0], 0

scores = np.zeros((len(gammas),len(Cs)))
trScores = np.zeros((len(gammas),len(Cs)))

for i,c in tqdm(enumerate(Cs), total=len(Cs)):
    for j,g in tqdm(enumerate(gammas), total=len(gammas), leave=False):
        fitter =  svm.SVC(kernel='rbf', gamma=g, C=c, decision_function_shape='ovo').fit(X_train, y_train)
        trPred, tsPred = fitter.predict(X_train), fitter.predict(X_test)
        scores[j][i] = accuracy_score(tsPred,y_test)
        trScores[j,i] = accuracy_score(trPred,y_train)
        if (scores[j][i]>scoreBest):
            scoreBest = scores[j][i]
            gBest, cBest = g, c

```

      0%|          | 0/8 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

```python
fig, (ax1,ax2) = plt.subplots(1,2, figsize=(16,6))

ax1 = sns.heatmap(trScores, annot=True, ax=ax1, xticklabels = Cs, yticklabels=gammas, fmt='.2', cmap='viridis')
ax1.set_title("Training accuracies -  SVM", fontsize = 19)
ax1.set_ylabel("gamma", fontsize = 18)
ax1.set_xlabel("C", fontsize = 18)

ax2 = sns.heatmap(scores, annot=True, ax=ax2, xticklabels = Cs, yticklabels=gammas, fmt='.2', cmap='viridis')
ax2.set_title("Test accuracies - SVM", fontsize = 19)
ax2.set_ylabel("gamma", fontsize = 18)
ax2.set_xlabel("C", fontsize = 18)
plt.show()
```

```python
print("Support Vector Machine - Best Model: ")
print("C: {:2}".format(cBest))
print("Gamma: {:2}".format(gBest))
print("Accuracy: {:.4}".format(scoreBest))
```

    Support Vector Machine - Best Model:
    C: 10
    Gamma: 1.0
    Accuracy: 0.7537

I risultati mettono in mostra che la configurazione migliore di algoritmo SVC si ottiene con i parametri $\gamma$ =1, C=10 . In questo modo si ottiene una accuracy di 0.75, che è da considerarsi comunque un risultato discreto visti i risultati degli altri Support Vector Classifier. Sicuramente si cercheranno altri algoritmi per migliorare l'accuracy sul test set.

### Decision Tree Classifiers

```python
from sklearn.tree import DecisionTreeClassifier
```

Un _Decision Tree Classifier_ è un algoritmo che permette di andare a classificare dei dati attraverso una successione di divisioni binarie. Il modello impara a predirre i valori della variabile di target costruendo delle regole decisionali sulle variabili di input. Questo processo di suddivisione viene iterato finché non si è riuscito a separare le classi in regioni (idealmente) disgiunte nello spazio dei parametri.

Alcuni dei parametri regolabili all'interno di un Decision Tree Classifier sono:

- criterion: La funzione costo "locale" per misurare l'impurità di ciascun nodo dell'albero. In questo lavoro è stato usato l'indice di impurità di Gini.
- max_depth: La profondità massima dell'albero.
- min_samples: numero minimo di dati in un nodo.
- max_features: Il massimo numero di features da usare per lo splitting del nodo.
- ccp_alpha: Parametro di pruning (vedi sotto)

Il parametro di pruning $ccp_\alpha$ è dato dalla teoria di Minimal Cost-Complexity Pruning.
Questo parametro è usato per definire la misura di cost-complexity $R_\alpha(T)$ per un albero $T$:
$$ R_\alpha(T) = R(T) + \alpha |\tilde{T}| $$

con $|\tilde{T}|$ numero di nodi terminali in un albero, e $R(T)$ è il tasso di classificazioni errate nei nodi terminali.

Si definisce per ogni nodo la quantità $\alpha_{\mbox{eff}}$, che è il valore per cui l'impurità di un singolo nodo è uguale alla somma delle impurità alla stessa profondità:

$$ R_\alpha(T_{-t}) = R(t) + \alpha_{\mbox{eff}} |\tilde{T}| $$

Il processo di Minimal Cost-Complexity Pruning consiste nella potatura del nodo con minore $\alpha_{\mbox{eff}}$ ad ogni step. Il processo si ferma quando tutti gli $\alpha_{\mbox{eff}}$ rimasti sono maggiori del parametro $ccp_\alpha$.

Ho provato ad allenare un singolo Decision Tree con diverse scelte del parametro di pruning e della massima profondità dell'albero. I risultati sul test set sono mostrati in figura:

```python
max_depths = [1+4*d for d in range(3,14)]
ccps = [0, 0.00001, 0.001, 0.01, 0.1, 1]

top_depth, top_ccp = max_depths[0], ccps[0]
topScore = 0
treeScores = np.zeros((len(ccps),len(max_depths)))
treeScoresTrain = np.zeros((len(ccps),len(max_depths)))

for j,md in tqdm(enumerate(max_depths),total=len(max_depths)):
    for i,pr in tqdm(enumerate(ccps),total=len(ccps), leave=False):

        tree = DecisionTreeClassifier(criterion='gini', max_depth=md, ccp_alpha=pr, random_state=3*i*j).fit(X_train, y_train)
        thisScore = tree.score(X_test,y_test)

        predicted = tree.predict(X_test)
        treeScoresTrain[i,j] = tree.score(X_train,y_train)
        treeScores[i,j] = thisScore

        if (thisScore > topScore):
            top_depth, top_ccp, topScore = md, pr, thisScore
```

      0%|          | 0/11 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

      0%|          | 0/6 [00:00<?, ?it/s]

```python
fig, (ax1,ax2) = plt.subplots(1,2, figsize=(16,6))

ax1 = sns.heatmap(treeScoresTrain, annot=True, ax=ax1, xticklabels = max_depths, yticklabels=ccps, fmt='.2', cmap='viridis')
ax1.set_title("Training accuracies - Decision Tree", fontsize = 19)
ax1.set_ylabel("Pruning parameter", fontsize = 18)
ax1.set_xlabel("max_depth", fontsize = 18)

ax2 = sns.heatmap(treeScores, annot=True, ax=ax2, xticklabels = max_depths, yticklabels=ccps, fmt='.2', cmap='viridis')
ax2.set_title("Test accuracies - Decision Tree", fontsize = 19)
ax2.set_ylabel("Pruning parameter", fontsize = 18)
ax2.set_xlabel("max_depth", fontsize = 18)
plt.show()
```

Si osserva che i risultati migliori si ottengono con un parametro di pruning tenuto basso, e con una profondità massima dell'albero di 29. Notare che avere una profondità massima grande a piacere non migliora le prestazioni dell'algoritmo, perché si cade come al solito in un regime di overfitting.

Tenendo conto che un singolo albero è un predittore debole, una accuracy di 0.68 è un risultato buono, che indica che le prestazioni di questo algoritmo sono state sfruttate al massimo. Il risultato ottenuto è paragonabile a quello ottenuto con SVM, ma viene raggiunto in un tempo computazionale estremamente inferiore.

```python
print("Decision Tree Classifier - Best Model: ")
print("Max depth: {}".format(top_depth))
print("Pruning parameter: {}".format(top_ccp))
print("Accuracy: {:.4}".format(topScore))
```

    Decision Tree Classifier - Best Model:
    Max depth: 29
    Pruning parameter: 1e-05
    Accuracy: 0.6881

### Random Forest

```python
from sklearn.ensemble import RandomForestClassifier
```

Un _Random Forest classifier_ è un algoritmo che utilizza un insieme di Decisional Trees per costruire un prdittore più robusto. Calcolando il valor medio dei risultati trovati con i singoli alberi decisionali, esso cerca di migliorare l'accuracy di predizione sul dataset. Il costruttore RandomForestClassifier prende in ingresso molti parametri; di questi, io mi sono concentrato sul numero di alberi all'interno della foresta e sulla profondità massima di ogni albero.

Facendo variare questi due parametri ho cercato la cofigurazione più efficiente per predirre lo score sulle bottiglie di vino presenti nel mio dataset.

```python
Ntrees = [5,10,20,50,100,200,500,1000]
Depth = [1+4*d for d in range(0,10)]

topN, topMaxDepth, topRFScore = max_depths[0], ccps[0], 0

TestAcc = np.zeros((len(Depth), len(Ntrees)))
TrainAcc = np.zeros((len(Depth), len(Ntrees)))
for j,n in tqdm(enumerate(Ntrees), total=len(Ntrees)):
    for i,d in enumerate(Depth):
        RT = RandomForestClassifier(n_estimators=n, bootstrap=True, criterion='gini', max_depth=d)
        RT.fit(X_train,y_train)
        TestAcc[i,j] = RT.score(X_test,y_test)
        TrainAcc[i,j] = RT.score(X_train, y_train)

        if (TestAcc[i,j] > topRFScore):
            topN, topMaxDepth, topRFScore = n, d, TestAcc[i,j]
```

      0%|          | 0/8 [00:00<?, ?it/s]

```python
f,(ax1,ax2) = plt.subplots(nrows=1, ncols=2, figsize=(16,6))

ax1 = sns.heatmap(TrainAcc, annot=True, ax=ax1, xticklabels = Ntrees, yticklabels=Depth, fmt='.2', cmap='viridis')
ax1.set_title("Training accuracies -  Random Forest", fontsize = 19)
ax1.set_xlabel("Number of trees", fontsize = 18)
ax1.set_ylabel("Max depth", fontsize = 18)

#fig, ax = plt.subplots(figsize = (10, 6))
ax2 = sns.heatmap(TestAcc, annot=True, ax=ax2, xticklabels = Ntrees, yticklabels=Depth, fmt='.2', cmap='viridis')
ax2.set_title("Test accuracies - Random Forest", fontsize = 19)
ax2.set_xlabel("Number of trees", fontsize = 18)
ax2.set_ylabel("Max depth", fontsize = 18)
plt.show()
```

```python
print("Random Forest Classifier - Best Model: ")
print("Max depth: {}".format(topMaxDepth))
print("Number of estimators: {}".format(topN))
print("Accuracy: {:.4}".format(topRFScore))
```

    Random Forest Classifier - Best Model:
    Max depth: 33
    Number of estimators: 1000
    Accuracy: 0.811

Gli algoritmi di Random Forest presentano risultati migliori sia rispetto al singolo albero decisionale, che rispetto al Support Vector Machine. Infatti, si riesce a costruire un algoritmo che garantisce uno score di circa 81% quando messo alla prova sul test set. Bisogna altresì notare che la precisione con cui lo score del vino viene stimata con l'algoritmo Random Forest Classifier viene raggiunta al prezzo di un tempo di computazione superiore. In ogni caso il Random Forest diventa l'algoritmo che meglio predice la variabile di target dopo aver ottimizzato i suoi parametri.

Resta ancora un algoritmo nella famiglia degli alberi decisionali da usare:

### Gradient Boosting Classifier

```python
from sklearn.ensemble import GradientBoostingClassifier
```

L'ultimo algoritmo che è stato usato per predirre lo score delle bottiglie di vino è stato il _Gradient Boosting Classifier_.

Il Gradient Boosting Classifier (chiamato anche Boosted Decision Tree) è un algoritmo simite al Random Forest, nel senso che combina un insieme di Decision Tree. La differenza sostanziale sta nel fatto che l'algoritmo pesa i classificatori in base ai risultati di tutti i classificatori precedenti. Ogni algoritmo prova a migliorare le proprie performance assegnando un peso maggiore alle predizioni errate e la funzione di loss viene minimizzata albero dopo albero. Il modello finale sarà quindi dato dalla media pesata degli output di tutti i classificatori: gli ultimi classificatori avranno un peso maggiore perché hanno imparato dagli errori degli alberi precedenti.

Nello studio di questo algoritmo ho analizzato i seguenti parametri:

- loss:  la funzione di loss, è stata utilizzata la *deviance*, ovvero la classificazione secondo una regressione logistica
- n_estimators: numero di alberi decisionali utilizzati.

- subsample:  sottoinsieme di dati da utilizzare ad ogni step.

- max_depth:  profondità massima di ogni albero.

I risultati sui dataset di training e test sono mostrati nei plot subito sotto.

```python
max_depth = [1+4*d for d in range(2,10)]
n_trees = [5,10,20,50,100,200,500]

topScoreGBC, topD, topNt = 0, max_depth[0], n_trees[0]

GBC_Test_Scores = GBC_Train_Scores = np.zeros((len(max_depth), len(n_trees)))

for i, dep in tqdm(enumerate(max_depth), total=len(max_depth)):
    for j, n in tqdm(enumerate(n_trees), total=len(n_trees), leave=False):

        fitter = GradientBoostingClassifier(n_estimators=n, max_depth=dep, loss='deviance').fit(X_train,y_train)

        GBC_Train_Scores[i,j] = fitter.score(X_train,y_train)
        GBC_Test_Scores[i,j] = fitter.score(X_test,y_test)

        if (GBC_Test_Scores[i,j] > topScoreGBC):
            topNt, topD, topScoreGBC = n, d, GBC_Test_Scores[i,j]

```

      0%|          | 0/8 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

      0%|          | 0/7 [00:00<?, ?it/s]

```python
f,(ax1,ax2) = plt.subplots(nrows=1, ncols=2, figsize=(16,6))

ax1 = sns.heatmap(GBC_Train_Scores, annot=True, ax=ax1, xticklabels = n_trees, yticklabels=max_depth, fmt='.2', cmap='viridis')
ax1.set_title("Training accuracies -  Gradient Boosting Classifier", fontsize = 19)
ax1.set_xlabel("Number of trees", fontsize = 18)
ax1.set_ylabel("Max depth", fontsize = 18)

#fig, ax = plt.subplots(figsize = (10, 6))
ax2 = sns.heatmap(GBC_Test_Scores, annot=True, ax=ax2, xticklabels = n_trees, yticklabels=max_depth, fmt='.2', cmap='viridis')
ax2.set_title("Test accuracies - Gradient Boosting Classifier", fontsize = 19)
ax2.set_xlabel("Number of trees", fontsize = 18)
ax2.set_ylabel("Max depth", fontsize = 18)
plt.show()
```

```python
print("Random Forest Classifier - Best Model: ")
print("Max depth: {}".format(topD))
print("Number of estimators: {}".format(topNt))
print("Accuracy: {:.4}".format(topScoreGBC))
```

    Random Forest Classifier - Best Model:
    Max depth: 37
    Number of estimators: 500
    Accuracy: 0.8147

Sorprendentemente, l'algoritmo di Gradient Boosting non riesce a superare di molto le prestazioni sul test set rispetto al Random Forest. Questo significa che già con il Random Forest si erano sfruttate al massimo le correlazioni all'interno del dataset e probabilmente non si riesce ad ottenere una predizione migliore. Ricordiamo infatti che lo score sulle bottiglie di vino è stato dato a livello sensoriale, dunque non è detto che ci sia una correlazione stretta tra le variabili di input e lo score.

Detto questo, l'81% raggiunto dal Gradient Boosting Classifier resta lo score più alto raggiunto in questo lavoro. Va anche considerato che il tempo di computazione è di oltre 10 volte superiore rispetto a quello impiegato pdall'algoritmo di Random Forest per convergere.

Vista la povertà iniziale del dataset, specialmente nelle classi con score molto alto o molto basso, posso affermare che i risultati che sono riuscito ad ottenere siano apprezzabili. Certamente, se ci fossero stati dei dati meglio distribuiti si sarebbe riuscito ad ottenere uno score più alto, in quanto la tecnica SMOTE riesce a produrre nuovi dati ma ovviamente non genera nuova informazione.

###   PCA e riduzione dimensionale

Alla fine, ho cercato di analizzare il dataset dal punto di vista della riduzione dimensionale, sfruttando il potente algoritmo della Principal Component Analysis. L'idea sottostante alla PCA è quella di identificare le variabili di input corrispondenti alla maggiore varianza, ovvero quali variabili hanno una distribuzione più singnificativa. Prima di tutto, per applicare la PCA è necessario che la matrice di design sia centrata, dunque ho sottratto la media in modo da avere una matrice con colonne a media nulla:
$$ X \rightarrow X - \langle X\rangle$$

```python
feature_cols= ['fixed_acidity','volatile_acidity','citric_acid','residual_sugar','chlorides',
               'free_sulfur_dioxide','density','pH','sulphates','alcohol', 'style']

dfC = XX[feature_cols]
dfC = XX - XX.mean(axis=0)

X_train, X_test, y_train, y_test = train_test_split(dfC, y, test_size=0.2)
y_train, y_test = y_train.astype(int), y_test.astype(int)

pca95 = PCA(n_components = .95) #fraction of the variance to be kept
X_pca95 = pca95.fit_transform(X_train)
print(X_pca95.shape)

pca90 = PCA(n_components = .9) #fraction of the variance to be kept
X_pca90 = pca90.fit_transform(X_train)
print(X_pca90.shape)
```

    (8588, 1)
    (8588, 1)

Innanzitutto ho provato a vedere quante componenti principali vengono salvate se si tiene il 95% della varianza. I risultati indicano che sono sufficienti due componenti per spiegare il 95% della varianza, mentre ne basta una per il 90%. Questo indica che esiste una componente principale con un autovalore molto maggiore degli altri. Per verificare questo, ho applicato la singular value decomposition e ho stampato la matrice $\Sigma^2$ e ne ho osservato gli autovalori:

```python
from numpy import linalg as LA
from sympy import init_printing, Matrix
```

```python
feature_cols = ['fixed_acidity','volatile_acidity','citric_acid','residual_sugar','chlorides',
                'free_sulfur_dioxide','density','pH','sulphates','alcohol', 'style']

Xt = dfC.values
y_pca = y.values
```

```python
Xp = Xt.T #trasposta della matrice di design
U, s, Vh = LA.svd(Xp, full_matrices=False)
```

```python
Matrix(np.round(np.diag(s)))
#matrice delle varianze delle componenti principali
```

$\displaystyle \left[\begin{array}{ccccccccccc}2233.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 446.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 136.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 110.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 39.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 17.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 13.0 & 0.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 12.0 & 0.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 10.0 & 0.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 3.0 & 0.0\\0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0 & 0.0\end{array}\right]$

La matrice conferma le mie ipotesi, ovvero che c'è un autovalore che contiene la maggior parte della varianza.

Successivamente, ho mantenuto solo 2 e 3 componenti principali per vedere come le varie entries del mio dataset si dispongono se proiettate su uno spazio vettoriale più piccolo. I risultati mostrano come le classi siano molto sovrapposte: questo non è da attribuire al mancato funzionamento della PCA, ma piuttosto al fatto che anche sullo  spazio completo 10-dimensionale le classi non sono separabili, e infatti nessun algoritmo riesce ad ottenere uno score elevato.

Un altro fatto molto interessante da notare è che quando si proietta l'intero spazio dei parametri su uno spazio di dimensione ridotta, si notano gli effetti dell'oversampling SMOTE eseguito in precedenza: i punti relativi alle classi ricampionate sono tutti lungo la stessa linea, perché sono stati generati lungo i segmenti congiungenti punti preesistenti.

Ho comunque scelto di riportare i grafici dello spazio dei parametri ridotto per illustrare il funzionamento della PCA.

#### Proiezione 2D

```python
#scelgo autovettori con autovalori maggiori
P = U[:,:2] # primi 2 autovalori

R = P.T @ Xp
R.shape
```

    (2, 10736)

```python
y_pca.shape
```

    (10736,)

```python
#distribuzione dei dati
plt.figure(figsize = (11, 8))
plt.xlabel("Principal component 1", fontsize=15)
plt.ylabel("Princial component 2", fontsize=15)
plt.scatter(R[0], R[1], c=y_pca, cmap='rainbow')
plt.title("PCA in 2 dimensioni", fontsize = 19)
plt.colorbar()
plt.show()
```

#### Proiezione 3D

```python
#Rifaccio con 3 autovalori
P3 = U[:,:3]
R3 = P3.T @ Xp
R3.shape
```

    (3, 10736)

```python
#distribuzione dei dati
from mpl_toolkits.mplot3d import Axes3D

fig = plt.figure(figsize=(20,20))

ax = Axes3D(fig,  auto_add_to_figure=False)
fig.add_axes(ax)
ax.set_title("PCA in 3 dimensioni", fontsize=35)
ax.scatter(R3[0], R3[1],R3[2], c=y_pca, s=60, cmap='rainbow', marker='o')
ax.set_xlabel('PC1', fontsize=20)
ax.set_ylabel('PC2', fontsize=20)
ax.set_zlabel('PC3',fontsize=20)
ax.set_xlim(-10,150)
ax.set_ylim(-20,30)
ax.set_zlim(-7,5)
ax.view_init(10,5)

plt.show()
```

Da ultimo, ho osservato come a riduzione dimensionale porta a un graduale peggioramento delle performance dei miei classificatori. A titolo di esempio, ho preso un Random Forest Classifier (con tutti i parametri fissati) e l'ho allenato su dataset di dimensione via via crescente. In seguito ho stampato lo score sul test set al crescere del numero di dimensioni. SI può osservare che l'aggiunta di dimensioni porta sempre con sè un aumento della precisione. Questo conferma che le mie variabili non sono correlate e che ogni feature porta con sè dell'informazione che, se eliminassi quella variabile, verrebe persa.

```python
Xtrainpca, Xtestpca, ytrainpca, ytestpca = train_test_split(df[feature_cols],df['score'],test_size=0.2)

for n in [2,4,6,8,10]:
    pca = PCA(n_components = n)
    XnDtrain = pca.fit_transform(Xtrainpca)
    XnDtest = pca.fit_transform(Xtestpca)

    RFC = RandomForestClassifier(n_estimators=200, max_depth=35).fit(XnDtrain,ytrainpca)
    score = RFC.score(XnDtest,ytestpca)
    print("Score of Random Forest with {:2} dimensions: {}".format(n,score))

RFC_ = RandomForestClassifier().fit(Xtrainpca,ytrainpca)
score_ = RFC_.score(Xtestpca, ytestpca)
print("Score of Random Forest with full dataset : {}".format(score_))
```

    Score of Random Forest with  2 dimensions: 0.37538461538461537
    Score of Random Forest with  4 dimensions: 0.5376923076923077
    Score of Random Forest with  6 dimensions: 0.59
    Score of Random Forest with  8 dimensions: 0.5930769230769231
    Score of Random Forest with 10 dimensions: 0.6069230769230769
    Score of Random Forest with full dataset : 0.6715384615384615

```python

```


| 　 | 1, 1, 2 | 1, 2 | 6 | 1 |
| :--- | --- | --- | --- | --- |
| **3** | ■ | ■ | ■ | 　 |
| **2** | 　 | 　 | ■ | ■ |
| **1, 1** | ■ | 　 | ■ | 　 |
| **2** | 　 | ■ | ■ | 　 |
| **3** | ■ | ■ | ■ | 　 |
| **1, 1** | ■ | 　 | ■ | 　 |

https://www.nonograms.org/nonograms/i/4353

```python
def len_lines(qs):
    cont = False
    result = []
    for q in qs:
        if q == 0:
            cont = False
        else:
            if cont:
                result[-1] += 1
            else:
                result.append(1)
            cont = True
    return result
```

```python
import dwavebinarycsp as dbc

row_constraints = [[1, 1, 2], [1, 2], [6], [1]]
col_constraints = [[3], [2], [1, 1], [2], [3], [1, 1]]

csp = dbc.ConstraintSatisfactionProblem(dbc.BINARY)
for i, lengths in enumerate(row_constraints):
    csp.add_constraint(lambda *qs, lengths=lengths: len_lines(qs) == lengths, [f'q{i}{j}' for j in range(len(col_constraints))])
for j, lengths in enumerate(col_constraints):
    csp.add_constraint(lambda *qs, lengths=lengths: len_lines(qs) == lengths, [f'q{i}{j}' for i in range(len(row_constraints))])

bqm = dbc.stitch(csp)
bqm
```

    BinaryQuadraticModel({'q00': -4.0, 'q01': 0.0, 'q02': -4.0, 'q03': 0.0, 'q04': -4.0, 'q05': -4.0, 'aux0': 2.0, 'q10': -4.0, 'q11': 2.0, 'q12': -6.0, 'q13': -6.0, 'q14': -8.0, 'q15': -4.0, 'q20': 8.0, 'q21': 6.0, 'q22': 6.0, 'q23': 6.0, 'q24': 8.0, 'q25': 6.0, 'aux1': 0.0, 'q30': 0.0, 'q31': 2.0, 'q32': -4.0, 'q33': -2.0, 'q34': -4.0, 'q35': 0.0}, {('q01', 'q00'): 4.0, ('q02', 'q00'): -4.0, ('q02', 'q01'): 4.0, ('q03', 'q00'): 4.0, ('q03', 'q01'): -4.0, ('q03', 'q02'): 4.0, ('q04', 'q00'): -4.0, ('q04', 'q01'): 4.0, ('q04', 'q02'): -4.0, ('q04', 'q03'): 4.0, ('q05', 'q00'): -4.0, ('q05', 'q01'): 4.0, ('q05', 'q02'): -4.0, ('q05', 'q03'): 4.0, ('q05', 'q04'): -4.0, ('q10', 'aux0'): -2.0, ('q11', 'aux0'): 0.0, ('q11', 'q10'): 4.0, ('q12', 'aux0'): -2.0, ('q12', 'q10'): 4.0, ('q12', 'q11'): 4.0, ('q13', 'aux0'): 0.0, ('q13', 'q10'): 0.0, ('q13', 'q11'): 0.0, ('q13', 'q12'): 2.0, ('q14', 'aux0'): 0.0, ('q14', 'q10'): 2.0, ('q14', 'q11'): -4.0, ('q14', 'q12'): 4.0, ('q14', 'q13'): 4.0, ('q15', 'aux0'): -2.0, ('q15', 'q10'): 2.0, ('q15', 'q11'): 0.0, ('q15', 'q12'): 2.0, ('q15', 'q13'): 4.0, ('q15', 'q14'): 2.0, ('q21', 'q20'): -4.0, ('q22', 'q20'): -4.0, ('q22', 'q21'): -4.0, ('q23', 'q20'): -4.0, ('q23', 'q21'): -4.0, ('q23', 'q22'): -4.0, ('q24', 'q20'): -4.0, ('q24', 'q21'): -4.0, ('q24', 'q22'): -4.0, ('q24', 'q23'): -4.0, ('q25', 'q20'): -4.0, ('q25', 'q21'): -4.0, ('q25', 'q22'): -4.0, ('q25', 'q23'): -4.0, ('q25', 'q24'): -4.0, ('q30', 'aux1'): -4.0, ('q31', 'aux1'): -4.0, ('q31', 'q30'): 4.0, ('q32', 'aux1'): 4.0, ('q32', 'q30'): 0.0, ('q32', 'q31'): 0.0, ('q33', 'aux1'): 4.0, ('q33', 'q30'): 0.0, ('q33', 'q31'): 0.0, ('q33', 'q32'): 4.0, ('q34', 'aux1'): 4.0, ('q34', 'q30'): 0.0, ('q34', 'q31'): 0.0, ('q34', 'q32'): 4.0, ('q34', 'q33'): 4.0, ('q35', 'aux1'): -4.0, ('q35', 'q30'): 4.0, ('q35', 'q31'): 4.0, ('q35', 'q32'): 0.0, ('q35', 'q33'): 0.0, ('q35', 'q34'): 0.0, ('q10', 'q00'): 0.0, ('q20', 'q00'): 0.0, ('q20', 'q10'): -4.0, ('q30', 'q00'): 4.0, ('q30', 'q10'): 0.0, ('q30', 'q20'): 0.0, ('q11', 'q01'): -4.0, ('q21', 'q01'): 4.0, ('q21', 'q11'): -2.0, ('q31', 'q01'): -2.0, ('q31', 'q11'): 4.0, ('q31', 'q21'): -2.0, ('q12', 'q02'): 4.0, ('q22', 'q02'): -2.0, ('q22', 'q12'): -2.0, ('q32', 'q02'): 0.0, ('q32', 'q12'): -2.0, ('q32', 'q22'): 4.0, ('q13', 'q03'): -4.0, ('q23', 'q03'): 4.0, ('q23', 'q13'): -2.0, ('q33', 'q03'): -2.0, ('q33', 'q13'): 4.0, ('q33', 'q23'): -2.0, ('q14', 'q04'): 0.0, ('q24', 'q04'): 0.0, ('q24', 'q14'): -4.0, ('q34', 'q04'): 4.0, ('q34', 'q14'): 0.0, ('q34', 'q24'): 0.0, ('q15', 'q05'): 4.0, ('q25', 'q05'): -2.0, ('q25', 'q15'): -2.0, ('q35', 'q05'): 0.0, ('q35', 'q15'): -2.0, ('q35', 'q25'): 4.0}, 6.0, Vartype.BINARY)

```python
import sympy

expr = bqm.offset
for symb, coef in bqm.linear.items():
    expr += sympy.symbols(symb) * coef
for (symb1, symb2), coef in bqm.quadratic.items():
    expr += sympy.symbols(symb1) * sympy.symbols(symb2) * coef

sympy.init_printing()
expr
```

$$- 2.0 aux_{0} q_{10} - 2.0 aux_{0} q_{12} - 2.0 aux_{0} q_{15} + 2.0 aux_{0} - 4.0 aux_{1} q_{30} - 4.0 aux_{1} q_{31} + 4.0 aux_{1} q_{32} + 4.0 aux_{1} q_{33} + 4.0 aux_{1} q_{34} - 4.0 aux_{1} q_{35} + 4.0 q_{00} q_{01} - 4.0 q_{00} q_{02} + 4.0 q_{00} q_{03} - 4.0 q_{00} q_{04} - 4.0 q_{00} q_{05} + 4.0 q_{00} q_{30} - 4.0 q_{00} + 4.0 q_{01} q_{02} - 4.0 q_{01} q_{03} + 4.0 q_{01} q_{04} + 4.0 q_{01} q_{05} - 4.0 q_{01} q_{11} + 4.0 q_{01} q_{21} - 2.0 q_{01} q_{31} + 4.0 q_{02} q_{03} - 4.0 q_{02} q_{04} - 4.0 q_{02} q_{05} + 4.0 q_{02} q_{12} - 2.0 q_{02} q_{22} - 4.0 q_{02} + 4.0 q_{03} q_{04} + 4.0 q_{03} q_{05} - 4.0 q_{03} q_{13} + 4.0 q_{03} q_{23} - 2.0 q_{03} q_{33} - 4.0 q_{04} q_{05} + 4.0 q_{04} q_{34} - 4.0 q_{04} + 4.0 q_{05} q_{15} - 2.0 q_{05} q_{25} - 4.0 q_{05} + 4.0 q_{10} q_{11} + 4.0 q_{10} q_{12} + 2.0 q_{10} q_{14} + 2.0 q_{10} q_{15} - 4.0 q_{10} q_{20} - 4.0 q_{10} + 4.0 q_{11} q_{12} - 4.0 q_{11} q_{14} - 2.0 q_{11} q_{21} + 4.0 q_{11} q_{31} + 2.0 q_{11} + 2.0 q_{12} q_{13} + 4.0 q_{12} q_{14} + 2.0 q_{12} q_{15} - 2.0 q_{12} q_{22} - 2.0 q_{12} q_{32} - 6.0 q_{12} + 4.0 q_{13} q_{14} + 4.0 q_{13} q_{15} - 2.0 q_{13} q_{23} + 4.0 q_{13} q_{33} - 6.0 q_{13} + 2.0 q_{14} q_{15} - 4.0 q_{14} q_{24} - 8.0 q_{14} - 2.0 q_{15} q_{25} - 2.0 q_{15} q_{35} - 4.0 q_{15} - 4.0 q_{20} q_{21} - 4.0 q_{20} q_{22} - 4.0 q_{20} q_{23} - 4.0 q_{20} q_{24} - 4.0 q_{20} q_{25} + 8.0 q_{20} - 4.0 q_{21} q_{22} - 4.0 q_{21} q_{23} - 4.0 q_{21} q_{24} - 4.0 q_{21} q_{25} - 2.0 q_{21} q_{31} + 6.0 q_{21} - 4.0 q_{22} q_{23} - 4.0 q_{22} q_{24} - 4.0 q_{22} q_{25} + 4.0 q_{22} q_{32} + 6.0 q_{22} - 4.0 q_{23} q_{24} - 4.0 q_{23} q_{25} - 2.0 q_{23} q_{33} + 6.0 q_{23} - 4.0 q_{24} q_{25} + 8.0 q_{24} + 4.0 q_{25} q_{35} + 6.0 q_{25} + 4.0 q_{30} q_{31} + 4.0 q_{30} q_{35} + 4.0 q_{31} q_{35} + 2.0 q_{31} + 4.0 q_{32} q_{33} + 4.0 q_{32} q_{34} - 4.0 q_{32} + 4.0 q_{33} q_{34} - 2.0 q_{33} - 4.0 q_{34} + 6.0$$

```python
from dwave.system.samplers import DWaveSampler
from dwave.system.composites import EmbeddingComposite

sampler = EmbeddingComposite(DWaveSampler())
response = sampler.sample(bqm, num_reads=1000)
```

```python
import pandas as pd

df = pd.DataFrame([{'check': csp.check(sample), 'energy': energy, 'occurr': num_occurrences, 'chain': chain_break_fraction, **sample} for sample, energy, num_occurrences, chain_break_fraction in response.data()])
df
```

      aux0
      aux1
      chain
      check
      energy
      occurr
      q00
      q01
      q02
      q03
      ...
      q22
      q23
      q24
      q25
      q30
      q31
      q32
      q33
      q34
      q35

      0
      0
      1
      0.115385
      True
      -84.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      0
      0
      0

      1
      0
      0
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      2
      0
      0
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      3
      1
      0
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      4
      0
      0
      0.153846
      False
      -80.0
      3
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      5
      0
      1
      0.115385
      False
      -80.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      6
      0
      1
      0.076923
      False
      -80.0
      8
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      7
      1
      1
      0.115385
      False
      -80.0
      3
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      8
      1
      1
      0.076923
      False
      -80.0
      5
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      9
      0
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      0
      0
      0

      10
      1
      0
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      11
      0
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      0
      0
      0

      12
      0
      0
      0.115385
      False
      -80.0
      3
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      13
      1
      0
      0.153846
      False
      -80.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      14
      0
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      15
      0
      1
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      16
      1
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      17
      0
      1
      0.076923
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      18
      1
      0
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      19
      0
      1
      0.153846
      False
      -80.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      20
      1
      1
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      0
      0
      1

      21
      0
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      22
      0
      1
      0.230769
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      23
      0
      1
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      0
      0
      1

      24
      1
      0
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      25
      0
      1
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      26
      0
      0
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      27
      1
      1
      0.192308
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      28
      0
      1
      0.115385
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      0

      29
      1
      0
      0.153846
      False
      -80.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...

      240
      1
      1
      0.230769
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      241
      1
      1
      0.307692
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      1

      242
      1
      1
      0.269231
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      0

      243
      1
      1
      0.192308
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      244
      1
      1
      0.192308
      False
      -72.0
      5
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      245
      1
      1
      0.192308
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      246
      0
      1
      0.192308
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      247
      0
      1
      0.269231
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      248
      0
      1
      0.269231
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      249
      1
      1
      0.230769
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      250
      0
      1
      0.230769
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      251
      1
      1
      0.269231
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      0
      1
      0
      1

      252
      1
      1
      0.192308
      False
      -72.0
      5
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      253
      0
      1
      0.192308
      False
      -72.0
      5
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      254
      0
      1
      0.192308
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      1
      0
      0
      1
      0
      1

      255
      1
      1
      0.230769
      False
      -72.0
      3
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      256
      0
      1
      0.192308
      False
      -72.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      257
      1
      1
      0.230769
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      258
      0
      1
      0.230769
      False
      -72.0
      4
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      259
      0
      1
      0.230769
      False
      -72.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      260
      0
      1
      0.192308
      False
      -72.0
      6
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      261
      1
      1
      0.230769
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      262
      1
      1
      0.230769
      False
      -72.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      263
      1
      1
      0.192308
      False
      -72.0
      6
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      264
      1
      1
      0.192308
      False
      -72.0
      2
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      265
      0
      1
      0.192308
      False
      -72.0
      16
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      266
      0
      1
      0.230769
      False
      -72.0
      4
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      267
      0
      1
      0.192308
      False
      -72.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      268
      0
      0
      0.230769
      False
      -68.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      1
      0
      1
      0
      1

      269
      1
      1
      0.269231
      False
      -68.0
      1
      1
      0
      1
      0
      ...
      1
      1
      1
      1
      0
      0
      1
      1
      0
      1

270 rows × 30 columns

```python
df[df['check']].to_dict()
```

    {'aux0': {0: 0},
     'aux1': {0: 1},
     'chain': {0: 0.11538461538461539},
     'check': {0: True},
     'energy': {0: -84.0},
     'occurr': {0: 1},
     'q00': {0: 1},
     'q01': {0: 0},
     'q02': {0: 1},
     'q03': {0: 0},
     'q04': {0: 1},
     'q05': {0: 1},
     'q10': {0: 1},
     'q11': {0: 0},
     'q12': {0: 0},
     'q13': {0: 1},
     'q14': {0: 1},
     'q15': {0: 0},
     'q20': {0: 1},
     'q21': {0: 1},
     'q22': {0: 1},
     'q23': {0: 1},
     'q24': {0: 1},
     'q25': {0: 1},
     'q30': {0: 0},
     'q31': {0: 1},
     'q32': {0: 0},
     'q33': {0: 0},
     'q34': {0: 0},
     'q35': {0: 0}}

```python
response.info['timing']
```

    {'qpu_sampling_time': 314960,
     'qpu_anneal_time_per_sample': 20,
     'qpu_readout_time_per_sample': 274,
     'qpu_access_time': 324792,
     'qpu_access_overhead_time': 4233,
     'qpu_programming_time': 9832,
     'qpu_delay_time_per_sample': 21,
     'total_post_processing_time': 2278,
     'post_processing_overhead_time': 712,
     'total_real_time': 324792,
     'run_time_chip': 314960,
     'anneal_time_per_run': 20,
     'readout_time_per_run': 274}

```python
sampler.properties['child_properties']['chip_id']
```

    'DW_2000Q_5'

```python
!pip list | grep dwave
```

    dwave-cloud-client     0.4.16
    dwave-hybrid           0.2.1
    dwave-neal             0.4.2
    dwave-networkx         0.6.9
    dwave-ocean-sdk        1.0.3
    dwave-qbsolv           0.2.9
    dwave-system           0.5.4
    dwave-tabu             0.1.3
    dwavebinarycsp         0.0.9