# Introduction to Deep Learning with PyTorch

In this notebook, you'll get introduced to [PyTorch](http://pytorch.org/), a framework for building and training neural networks. PyTorch in a lot of ways behaves like the arrays you love from Numpy. These Numpy arrays, after all, are just tensors. PyTorch takes these tensors and makes it simple to move them to GPUs for the faster processing needed when training neural networks. It also provides a module that automatically calculates gradients (for backpropagation!) and another module specifically for building neural networks. All together, PyTorch ends up being more coherent with Python and the Numpy/Scipy stack compared to TensorFlow and other frameworks.

## Neural Networks

Deep Learning is based on artificial neural networks which have been around in some form since the late 1950s. The networks are built from individual parts approximating neurons, typically called units or simply "neurons." Each unit has some number of weighted inputs. These weighted inputs are summed together (a linear combination) then passed through an activation function to get the unit's output.

Mathematically this looks like:

$$
\begin{align}
y &= f(w_1 x_1 + w_2 x_2 + b) \\
y &= f\left(\sum_i w_i x_i \right)
\end{align}
$$

With vectors this is the dot/inner product of two vectors:

$$
h = \begin{bmatrix}
x_1 \, x_2 \cdots  x_n
\end{bmatrix}
\cdot
\begin{bmatrix}
           w_1 \\
           w_2 \\
           \vdots \\
           w_n
\end{bmatrix}
$$

### Stack them up!

We can assemble these unit neurons into layers and stacks, into a network of neurons. The output of one layer of neurons becomes the input for the next layer. With multiple input units and output units, we now need to express the weights as a matrix.

We can express this mathematically with matrices again and use matrix multiplication to get linear combinations for each unit in one operation. For example, the hidden layer ($h_1$ and $h_2$ here) can be calculated

$$
\vec{h} = [h_1 \, h_2] =
\begin{bmatrix}
x_1 \, x_2 \cdots \, x_n
\end{bmatrix}
\cdot
\begin{bmatrix}
           w_{11} & w_{12} \\
           w_{21} &w_{22} \\
           \vdots &\vdots \\
           w_{n1} &w_{n2}
\end{bmatrix}
$$

The output for this small network is found by treating the hidden layer as inputs for the output unit. The network output is expressed simply

$$
y =  f_2 \! \left(\, f_1 \! \left(\vec{x} \, \mathbf{W_1}\right) \mathbf{W_2} \right)
$$

## Tensors

It turns out neural network computations are just a bunch of linear algebra operations on *tensors*, a generalization of matrices. A vector is a 1-dimensional tensor, a matrix is a 2-dimensional tensor, an array with three indices is a 3-dimensional tensor (RGB color images for example). The fundamental data structure for neural networks are tensors and PyTorch (as well as pretty much every other deep learning framework) is built around tensors.

With the basics covered, it's time to explore how we can use PyTorch to build a simple neural network.

```python
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

import numpy as np
import torch
import helper
```

```python
torch.__version__
```

    '0.4.1'

First, let's see how we work with PyTorch tensors. These are the fundamental data structures of neural networks and PyTorch, so it's imporatant to understand how these work.

```python
x = torch.rand(3, 2)
x
```

    tensor([[0.7874, 0.0186],
            [0.6477, 0.8665],
            [0.8316, 0.9007]])

```python
y = torch.ones(x.size())
y
```

    tensor([[1., 1.],
            [1., 1.],
            [1., 1.]])

```python
z = x + y
z
```

    tensor([[1.7874, 1.0186],
            [1.6477, 1.8665],
            [1.8316, 1.9007]])

In general PyTorch tensors behave similar to Numpy arrays. They are zero indexed and support slicing.

```python
z[0]
```

    tensor([1.7874, 1.0186])

```python
z[:, 1:]
```

    tensor([[1.0186],
            [1.8665],
            [1.9007]])

Tensors typically have two forms of methods, one method that returns another tensor and another method that performs the operation in place. That is, the values in memory for that tensor are changed without creating a new tensor. In-place functions are always followed by an underscore, for example `z.add()` and `z.add_()`.

```python
# Return a new tensor z + 1
z.add(1)
```

    tensor([[2.7874, 2.0186],
            [2.6477, 2.8665],
            [2.8316, 2.9007]])

```python
# z tensor is unchanged
z
```

    tensor([[1.7874, 1.0186],
            [1.6477, 1.8665],
            [1.8316, 1.9007]])

```python
# Add 1 and update z tensor in-place
z.add_(1)
```

    tensor([[2.7874, 2.0186],
            [2.6477, 2.8665],
            [2.8316, 2.9007]])

```python
# z has been updated
z
```

    tensor([[2.7874, 2.0186],
            [2.6477, 2.8665],
            [2.8316, 2.9007]])

### Reshaping

Reshaping tensors is a really common operation. First to get the size and shape of a tensor use `.size()`. Then, to reshape a tensor, use `.resize_()`. Notice the underscore, reshaping is an in-place operation.

```python
z.size()
```

    torch.Size([3, 2])

```python
z.resize_(2, 3)
```

    tensor([[2.7874, 2.0186, 2.6477],
            [2.8665, 2.8316, 2.9007]])

```python
z
```

    tensor([[2.7874, 2.0186, 2.6477],
            [2.8665, 2.8316, 2.9007]])

## Numpy to Torch and back

Converting between Numpy arrays and Torch tensors is super simple and useful. To create a tensor from a Numpy array, use `torch.from_numpy()`. To convert a tensor to a Numpy array, use the `.numpy()` method.

```python
a = np.random.rand(4,3)
a
```

    array([[0.572874  , 0.48383819, 0.14225159],
           [0.513618  , 0.18499296, 0.293969  ],
           [0.58667168, 0.53795494, 0.60351935],
           [0.53810245, 0.80955249, 0.1895296 ]])

```python
b = torch.from_numpy(a)
b
```

    tensor([[0.5729, 0.4838, 0.1423],
            [0.5136, 0.1850, 0.2940],
            [0.5867, 0.5380, 0.6035],
            [0.5381, 0.8096, 0.1895]], dtype=torch.float64)

```python
b.numpy()
```

    array([[0.572874  , 0.48383819, 0.14225159],
           [0.513618  , 0.18499296, 0.293969  ],
           [0.58667168, 0.53795494, 0.60351935],
           [0.53810245, 0.80955249, 0.1895296 ]])

The memory is shared between the Numpy array and Torch tensor, so if you change the values in-place of one object, the other will change as well.

```python
# Multiply PyTorch Tensor by 2, in place
b.mul_(2)
```

    tensor([[1.1457, 0.9677, 0.2845],
            [1.0272, 0.3700, 0.5879],
            [1.1733, 1.0759, 1.2070],
            [1.0762, 1.6191, 0.3791]], dtype=torch.float64)

```python
# Numpy array matches new values from Tensor
a
```

    array([[1.145748  , 0.96767638, 0.28450319],
           [1.02723599, 0.36998591, 0.58793799],
           [1.17334336, 1.07590987, 1.2070387 ],
           [1.07620489, 1.61910497, 0.3790592 ]])

## Feedforward neural networks with PyTorch

Next I'll show you how to use this pattern to build a neural network with PyTorch. First up, we need to get our dataset. This is provided through the `torchvision` package. The code below will download the MNIST dataset, then create training and test datasets for us.

```python
import matplotlib.pyplot as plt
from torchvision import datasets, transforms
```

```python
# Define a transform to normalize the data
transform = transforms.Compose([transforms.ToTensor(),
                              transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
                             ])
# Download and load the training data
trainset = datasets.MNIST('MNIST_data/', download=True, train=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True)

# Download and load the test data
testset = datasets.MNIST('MNIST_data/', download=True, train=False, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=64, shuffle=True)
```

    Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
    Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz
    Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz
    Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz
    Processing...
    Done!

```python
dataiter = iter(trainloader)
images, labels = dataiter.next()
```

We have the training data loaded into `trainloader` and we make that an iterator with `iter(trainloader)`. We'd use this to loop through the dataset for training, but here I'm just grabbing the first batch so we can check out the data. We can see below that `images` is just a tensor with size (64, 1, 28, 28). So, 64 images per batch, 1 color channel, and 28x28 images.

```python
images.size()
```

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

```python
plt.imshow(images[1].numpy().squeeze(), cmap='Greys_r')
```

## Building networks with PyTorch

Here I'll use PyTorch to build a simple feedfoward network to classify the MNIST images. That is, the network will receive a digit image as input and predict the digit in the image.

To build a neural network with PyTorch, you use the `torch.nn` module. The network itself is a class inheriting from `torch.nn.Module`. You define each of the operations separately, like `nn.Linear(784, 128)` for a fully connected linear layer with 784 inputs and 128 units.

The class needs to include a `forward` method that implements the forward pass through the network. In this method, you pass some input tensor `x` through each of the operations you defined earlier. The `torch.nn` module also has functional equivalents for things like ReLUs in `torch.nn.functional`. This module is usually imported as `F`. Then to use a ReLU activation on some layer (which is just a tensor), you'd do `F.relu(x)`. Below are a few different commonly used activation functions.

So, for this network, I'll build it with three fully connected layers, then a softmax output for predicting classes. The softmax function is similar to the sigmoid in that it squashes inputs between 0 and 1, but it's also normalized so that all the values sum to one like a proper probability distribution.

```python
784 ** 0.5
```

    28.0

```python
from torch import nn
from torch import optim
import torch.nn.functional as F
from torch.autograd import Variable
```

```python
class Network(nn.Module):
    def __init__(self):
        super().__init__()
        # Defining the layers, 128, 64, 10 units each
        self.fc1 = nn.Linear(784, 128)
        self.fc2 = nn.Linear(128, 64)
        # Output layer, 10 units - one for each digit
        self.fc3 = nn.Linear(64, 10)

    def forward(self, x):
        ''' Forward pass through the network, returns the output logits '''

        x = self.fc1(x)
        x = F.relu(x)
        x = self.fc2(x)
        x = F.relu(x)
        x = self.fc3(x)

        return x

    def predict(self, x):
        ''' This function for predicts classes by calculating the softmax '''
        logits = self.forward(x)
        return F.softmax(logits)

net = Network()
net
```

    Network(
      (fc1): Linear(in_features=784, out_features=128, bias=True)
      (fc2): Linear(in_features=128, out_features=64, bias=True)
      (fc3): Linear(in_features=64, out_features=10, bias=True)
    )

### Initializing weights and biases

The weights and such are automatically initialized for you, but it's possible to customize how they are initialized. The weights and biases are tensors attached to the layer you defined, you can get them with `net.fc1.weight` for instance.

```python
print(net.fc1.weight)
print(net.fc1.bias)
```

    Parameter containing:
    tensor([[ 0.0198,  0.0243,  0.0137,  ...,  0.0208,  0.0012,  0.0042],
            [ 0.0059, -0.0110, -0.0070,  ...,  0.0064, -0.0180, -0.0252],
            [ 0.0262, -0.0165,  0.0177,  ..., -0.0305,  0.0070,  0.0248],
            ...,
            [-0.0179, -0.0347, -0.0118,  ..., -0.0051, -0.0000, -0.0082],
            [-0.0178,  0.0232, -0.0057,  ..., -0.0175, -0.0165, -0.0259],
            [-0.0114, -0.0228, -0.0230,  ...,  0.0098, -0.0059,  0.0215]],
           requires_grad=True)
    Parameter containing:
    tensor([-0.0141, -0.0042, -0.0019,  0.0256, -0.0140,  0.0321,  0.0168,  0.0096,
            -0.0265, -0.0306, -0.0341, -0.0302,  0.0253, -0.0305,  0.0197,  0.0327,
            -0.0335,  0.0273, -0.0262, -0.0108,  0.0160, -0.0339,  0.0307,  0.0056,
            -0.0326, -0.0036, -0.0012, -0.0148,  0.0264, -0.0038,  0.0072, -0.0286,
            -0.0317, -0.0351,  0.0084,  0.0266,  0.0243, -0.0050,  0.0056,  0.0162,
            -0.0179, -0.0043,  0.0194, -0.0290,  0.0177, -0.0114, -0.0317, -0.0142,
            -0.0121,  0.0013, -0.0064, -0.0168, -0.0187,  0.0123,  0.0223,  0.0297,
            -0.0138, -0.0114, -0.0184, -0.0305,  0.0117,  0.0018, -0.0301,  0.0046,
            -0.0194, -0.0145, -0.0238, -0.0259,  0.0227, -0.0041, -0.0150,  0.0212,
             0.0302,  0.0066, -0.0096, -0.0207,  0.0217,  0.0027,  0.0321, -0.0209,
             0.0313, -0.0081,  0.0257,  0.0129, -0.0166, -0.0005, -0.0170,  0.0044,
            -0.0207, -0.0011, -0.0063, -0.0328, -0.0260, -0.0285,  0.0304, -0.0181,
             0.0126,  0.0171, -0.0001,  0.0028,  0.0192,  0.0193, -0.0352,  0.0240,
             0.0310, -0.0341, -0.0254,  0.0289,  0.0228,  0.0073, -0.0197, -0.0136,
            -0.0300, -0.0146, -0.0161,  0.0061,  0.0268,  0.0299, -0.0251,  0.0259,
             0.0119,  0.0155, -0.0158, -0.0235, -0.0251, -0.0338,  0.0191,  0.0141],
           requires_grad=True)

For custom initialization, we want to modify these tensors in place. These are actually autograd *Variables*, so we need to get back the actual tensors with `net.fc1.weight.data`. Once we have the tensors, we can fill them with zeros (for biases) or random normal values.

```python
# Set biases to all zeros
net.fc1.bias.data.fill_(0)
```

    tensor([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., 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., 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., 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., 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., 0.,
            0., 0.])

```python
# sample from random normal with standard dev = 0.01
net.fc1.weight.data.normal_(std=0.01)
```

    tensor([[ 0.0189, -0.0049, -0.0173,  ..., -0.0066,  0.0076,  0.0074],
            [-0.0016, -0.0208,  0.0043,  ...,  0.0079, -0.0186, -0.0067],
            [ 0.0040,  0.0143, -0.0177,  ...,  0.0020, -0.0082,  0.0222],
            ...,
            [ 0.0094,  0.0045,  0.0008,  ...,  0.0053, -0.0025,  0.0000],
            [ 0.0069, -0.0047,  0.0026,  ..., -0.0045,  0.0044,  0.0128],
            [ 0.0160,  0.0088,  0.0046,  ...,  0.0174, -0.0020,  0.0017]])

### Forward pass

Now that we have a network, let's see what happens when we pass in an image.

```python
# Grab some data
dataiter = iter(trainloader)
images, labels = dataiter.next()
images.resize_(64, 1, 784)

# Need to wrap it in a Variable, will explain in next notebook
inputs = Variable(images)

# Forward pass through the network
img_idx = 0
logits = net.forward(inputs[img_idx,:])

# Predict the class from the network output
ps = F.softmax(logits)

img = images[img_idx]
helper.view_classify(img.resize_(1, 28, 28), ps)
```

As you can see above, our network has basically no idea what this digit is. It's because we haven't trained it yet, all the weights are random! Next up, we'll look at training this network so it learns how to properly classify these digits.

Part 1 - Neural Networks with PyTorch.ipynb


#Imports

```
import numpy as np
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
from IPython import display
plt.style.use('seaborn-white')
```

# Read and process data.

Download the file from this URL: https://drive.google.com/file/d/1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS/view?usp=sharing

```
import gdown
gdown.download('https://drive.google.com/uc?id=1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS','text.txt', quiet=False)
```

    Downloading...
    From: https://drive.google.com/uc?id=1UWWIi-sz9g0x3LFvkIZjvK1r2ZaCqgGS
    To: /content/text.txt
    100%|██████████| 10.3k/10.3k [00:00<00:00, 4.22MB/s]

    'text.txt'

```
data = open('text.txt', 'r').read()
```

Process data and calculate indices

```
chars = list(set(data))
data_size, X_size = len(data), len(chars)
print("Corona Virus article has %d characters, %d unique characters" %(data_size, X_size))
char_to_idx = {ch:i for i,ch in enumerate(chars)}
idx_to_char = {i:ch for i,ch in enumerate(chars)}
```

    Corona Virus article has 10223 characters, 75 unique characters

# Constants and Hyperparameters

```
Hidden_Layer_size = 10 #size of the hidden layer
Time_steps = 10 # Number of time steps (length of the sequence) used for training
learning_rate = 1e-1 # Learning Rate
weight_sd = 0.1 #Standard deviation of weights for initialization
z_size = Hidden_Layer_size + X_size #Size of concatenation(H, X) vector
```

# Activation Functions and Derivatives

```
def sigmoid(x): # sigmoid function
  return 1/(1+np.exp(-x))

def dsigmoid(y): # derivative of sigmoid function
  return y * (1-y)

def tanh(x): # tanh function
  return np.tanh(x)

def dtanh(y): # derivative of tanh
  return 1-np.square(y)
```

# Quiz Question 1

What is the value of sigmoid(0) calculated from  your code? (Answer up to 1 decimal point, e.g. 4.2 and NOT 4.29999999, no rounding off).

# Quiz Question 2

What is the value of dsigmoid(sigmoid(0)) calculated from your code?? (Answer up to 2 decimal point, e.g. 4.29 and NOT 4.29999999, no rounding off).

# Quiz Question 3

What is the value of tanh(dsigmoid(sigmoid(0))) calculated from your code?? (Answer up to 5 decimal point, e.g. 4.29999 and NOT 4.29999999, no rounding off).

# Quiz Question 4

What is the value of dtanh(tanh(dsigmoid(sigmoid(0)))) calculated from your code?? (Answer up to 5 decimal point, e.g. 4.29999 and NOT 4.29999999, no rounding off).

```
print('Quiz 1', sigmoid(0))
print('Quiz 2', dsigmoid(sigmoid(0)))
print('Quiz 3', tanh(dsigmoid(sigmoid(0))))
print('Quiz 4', dtanh(tanh(dsigmoid(sigmoid(0)))))
```

    Quiz 1 0.5
    Quiz 2 0.25
    Quiz 3 0.24491866240370913
    Quiz 4 0.940014848806378

# Parameters

```
class Param:
    def __init__(self, name, value):
      self.name = name
      self.v = value # parameter value
      self.d = np.zeros_like(value) # derivative
      self.m = np.zeros_like(value) # momentum for Adagrad
```

We use random weights with normal distribution (0, weight_sd) for  tanh  activation function and (0.5, weight_sd) for  `sigmoid`  activation function.

Biases are initialized to zeros.

# LSTM
You are making this network, please note f, i, c and o (also "v") in the image below:

Please note that we are concatenating the old_hidden_vector and new_input.

# Quiz Question 4

In the class definition below, what should be size_a, size_b, and size_c? ONLY use the variables defined above.

```
size_a = Hidden_Layer_size
size_b = z_size
size_c = X_size

class Parameters:
    def __init__(self):
        self.W_f = Param('W_f', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_f = Param('b_f', np.zeros((size_a, 1)))

        self.W_i = Param('W_i', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_i = Param('b_i', np.zeros((size_a, 1)))

        self.W_C = Param('W_C', np.random.randn(size_a, size_b) * weight_sd)
        self.b_C = Param('b_C', np.zeros((size_a, 1)))

        self.W_o = Param('W_o', np.random.randn(size_a, size_b) * weight_sd + 0.5)
        self.b_o = Param('b_o', np.zeros((size_a, 1)))

        #For final layer to predict the next character
        self.W_v = Param('W_v', np.random.randn(X_size, size_a) * weight_sd)
        self.b_v = Param('b_v', np.zeros((size_c, 1)))

    def all(self):
        return [self.W_f, self.W_i, self.W_C, self.W_o, self.W_v,
               self.b_f, self.b_i, self.b_C, self.b_o, self.b_v]

parameters = Parameters()
```

Look at these operations which we'll be writing:

**Concatenation of h and x:**

$z\:=\:\left[h_{t-1},\:x\right]$

$f_t=\sigma\left(W_f\cdot z\:+\:b_f\:\right)$

$i_i=\sigma\left(W_i\cdot z\:+\:b_i\right)$

$\overline{C_t}=\tanh\left(W_C\cdot z\:+\:b_C\right)$

$C_t=f_t\ast C_{t-1}+i_t\ast \overline{C}_t$

$o_t=\sigma\left(W_o\cdot z\:+\:b_i\right)$

$h_t=o_t\ast\tanh\left(C_t\right)$

**Logits:**

$v_t=W_v\cdot h_t+b_v$

**Softmax:**

$\hat{y}=softmax\left(v_t\right)$

```
def forward(x, h_prev, C_prev, p = parameters):
    assert x.shape == (X_size, 1)
    assert h_prev.shape == (Hidden_Layer_size, 1)
    assert C_prev.shape == (Hidden_Layer_size, 1)

    z = np.row_stack((h_prev, x))
    f = sigmoid(np.dot(parameters.all()[0].v, z)+ parameters.all()[5].v)
    i = sigmoid(np.dot(parameters.all()[1].v, z)+ parameters.all()[6].v)
    C_bar = tanh(np.dot(parameters.all()[2].v, z)+ parameters.all()[7].v)

    C = f*C_prev + i*C_bar
    o = sigmoid(np.dot(parameters.all()[3].v, z)+parameters.all()[8].v)
    h = o*tanh(C)

    v = np.dot(parameters.all()[4].v, h)+parameters.all()[9].v
    y = np.exp(v) / np.sum(np.exp(v)) #softmax

    return z, f, i, C_bar, C, o, h, v, y
```

You must finish the function above before you can attempt the questions below.

# Quiz Question 5

What is the output of 'print(len(forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)), parameters)))'?

```
print(len(forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)), parameters)))
```

    9

# Quiz Question 6.

Assuming you have fixed the forward function, run this command:
z, f, i, C_bar, C, o, h, v, y = forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)))

Now, find these values:

1.   print(z.shape)
2.   print(np.sum(z))
3.   print(np.sum(f))

Copy and paste exact values you get in the logs into the quiz.

```
z, f, i, C_bar, C, o, h, v, y = forward(np.zeros((X_size, 1)), np.zeros((Hidden_Layer_size, 1)), np.zeros((Hidden_Layer_size, 1)))
```

```
print(z.shape)
print(np.sum(z))
print(np.sum(f))
```

    (85, 1)
    0.0
    5.0

# Backpropagation

Here we are defining the backpropagation. It's too complicated, here is the whole code. (Please note that this would work only if your earlier code is perfect).

```
def backward(target, dh_next, dC_next, C_prev,
             z, f, i, C_bar, C, o, h, v, y,
             p = parameters):

    assert z.shape == (X_size + Hidden_Layer_size, 1)
    assert v.shape == (X_size, 1)
    assert y.shape == (X_size, 1)

    for param in [dh_next, dC_next, C_prev, f, i, C_bar, C, o, h]:
        assert param.shape == (Hidden_Layer_size, 1)

    dv = np.copy(y)
    dv[target] -= 1

    p.W_v.d += np.dot(dv, h.T)
    p.b_v.d += dv

    dh = np.dot(p.W_v.v.T, dv)
    dh += dh_next
    do = dh * tanh(C)
    do = dsigmoid(o) * do
    p.W_o.d += np.dot(do, z.T)
    p.b_o.d += do

    dC = np.copy(dC_next)
    dC += dh * o * dtanh(tanh(C))
    dC_bar = dC * i
    dC_bar = dtanh(C_bar) * dC_bar
    p.W_C.d += np.dot(dC_bar, z.T)
    p.b_C.d += dC_bar

    di = dC * C_bar
    di = dsigmoid(i) * di
    p.W_i.d += np.dot(di, z.T)
    p.b_i.d += di

    df = dC * C_prev
    df = dsigmoid(f) * df
    p.W_f.d += np.dot(df, z.T)
    p.b_f.d += df

    dz = (np.dot(p.W_f.v.T, df)
         + np.dot(p.W_i.v.T, di)
         + np.dot(p.W_C.v.T, dC_bar)
         + np.dot(p.W_o.v.T, do))
    dh_prev = dz[:Hidden_Layer_size, :]
    dC_prev = f * dC

    return dh_prev, dC_prev
```

# Forward and Backward Combined Pass

Let's first clear the gradients before each backward pass

```
def clear_gradients(params = parameters):
    for p in params.all():
        p.d.fill(0)
```

Clip gradients to mitigate exploding gradients

```
def clip_gradients(params = parameters):
    for p in params.all():
        np.clip(p.d, -1, 1, out=p.d)
```

Calculate and store the values in forward pass. Accumulate gradients in backward pass and clip gradients to avoid exploding gradients.

input, target are list of integers, with character indexes.
h_prev is the array of initial h at  h−1  (size H x 1)
C_prev is the array of initial C at  C−1  (size H x 1)
Returns loss, final  hT  and  CT

```
def forward_backward(inputs, targets, h_prev, C_prev):
    global paramters

    # To store the values for each time step
    x_s, z_s, f_s, i_s,  = {}, {}, {}, {}
    C_bar_s, C_s, o_s, h_s = {}, {}, {}, {}
    v_s, y_s =  {}, {}

    # Values at t - 1
    h_s[-1] = np.copy(h_prev)
    C_s[-1] = np.copy(C_prev)

    loss = 0
    # Loop through time steps
    assert len(inputs) == Time_steps
    for t in range(len(inputs)):
        x_s[t] = np.zeros((X_size, 1))
        x_s[t][inputs[t]] = 1 # Input character

        (z_s[t], f_s[t], i_s[t],
        C_bar_s[t], C_s[t], o_s[t], h_s[t],
        v_s[t], y_s[t]) = \
            forward(x_s[t], h_s[t - 1], C_s[t - 1]) # Forward pass

        loss += -np.log(y_s[t][targets[t], 0]) # Loss for at t

    clear_gradients()

    dh_next = np.zeros_like(h_s[0]) #dh from the next character
    dC_next = np.zeros_like(C_s[0]) #dh from the next character

    for t in reversed(range(len(inputs))):
        # Backward pass
        dh_next, dC_next = \
            backward(target = targets[t], dh_next = dh_next,
                     dC_next = dC_next, C_prev = C_s[t-1],
                     z = z_s[t], f = f_s[t], i = i_s[t], C_bar = C_bar_s[t],
                     C = C_s[t], o = o_s[t], h = h_s[t], v = v_s[t],
                     y = y_s[t])

    clip_gradients()

    return loss, h_s[len(inputs) - 1], C_s[len(inputs) - 1]
```

# Sample the next character

```
def sample(h_prev, C_prev, first_char_idx, sentence_length):
    x = np.zeros((X_size, 1))
    x[first_char_idx] = 1

    h = h_prev
    C = C_prev

    indexes = []

    for t in range(sentence_length):
        _, _, _, _, C, _, h, _, p = forward(x, h, C)
        idx = np.random.choice(range(X_size), p=p.ravel())
        x = np.zeros((X_size, 1))
        x[idx] = 1
        indexes.append(idx)

    return indexes
```

# Training (Adagrad)

Update the graph and display a sample output

```
def update_status(inputs, h_prev, C_prev):
    #initialized later
    global plot_iter, plot_loss
    global smooth_loss

    # Get predictions for 200 letters with current model

    sample_idx = sample(h_prev, C_prev, inputs[0], 200)
    txt = ''.join(idx_to_char[idx] for idx in sample_idx)

    # Clear and plot
    plt.plot(plot_iter, plot_loss)
    display.clear_output(wait=True)
    plt.show()

    #Print prediction and loss
    print("----\n %s \n----" % (txt, ))
    print("iter %d, loss %f" % (iteration, smooth_loss))
```

# Update Parameters

\begin{align}
\theta_i &= \theta_i - \eta\frac{d\theta_i}{\sum dw_{\tau}^2} \\
d\theta_i &= \frac{\partial L}{\partial \theta_i}
\end{align}

```
def update_paramters(params = parameters):
    for p in params.all():
        p.m += p.d * p.d # Calculate sum of gradients
        #print(learning_rate * dparam)
        p.v += -(learning_rate * p.d / np.sqrt(p.m + 1e-8))
```

To delay the keyboard interrupt to prevent the training from stopping in the middle of an iteration

```
# Exponential average of loss
# Initialize to a error of a random model
smooth_loss = -np.log(1.0 / X_size) * Time_steps

iteration, pointer = 0, 0

# For the graph
plot_iter = np.zeros((0))
plot_loss = np.zeros((0))
```

# Training Loop

```
iter = 1000
while iter > 0:
  # Reset
  if pointer + Time_steps >= len(data) or iteration == 0:
      g_h_prev = np.zeros((Hidden_Layer_size, 1))
      g_C_prev = np.zeros((Hidden_Layer_size, 1))
      pointer = 0

  inputs = ([char_to_idx[ch]
              for ch in data[pointer: pointer + Time_steps]])
  targets = ([char_to_idx[ch]
              for ch in data[pointer + 1: pointer + Time_steps + 1]])

  loss, g_h_prev, g_C_prev = \
      forward_backward(inputs, targets, g_h_prev, g_C_prev)
  smooth_loss = smooth_loss * 0.999 + loss * 0.001

  # Print every hundred steps
  if iteration % 100 == 0:
      update_status(inputs, g_h_prev, g_C_prev)

  update_paramters()

  plot_iter = np.append(plot_iter, [iteration])
  plot_loss = np.append(plot_loss, [loss])

  pointer += Time_steps
  iteration += 1
  iter = iter -1
```

# Quiz Question 7.

Run the above code for 50000 iterations making sure that you have 100 hidden layers and time_steps is 40. What is the loss value you're seeing?


```python
!pip install -q sympy
!pip install -q nbinteract

import sympy
solve = lambda x,y: sympy.solve(x-y)[0] if len(sympy.solve(x-y))==1 else "Not Single Solution"
import matplotlib.pyplot as plt
plt.style.use('ggplot')
%matplotlib inline

from ipywidgets import interact, interactive, fixed, interact_manual
import ipywidgets as widgets
from IPython.display import display
import nbinteract as nbi
import numpy as np

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

# Supply, Demand, and Equilibrium

## Set-up

With this notebook, we are going to plot and solve equations, hopefully giving some more hands on exposure to the materials that you've already seen in class. **To get started, in the menu above, click `Cell` and select `Run All` from the drop-down menu, then you should be good to go!**

The traditional demand and supply equations follow the form $ Quantity = f(Price)$, where the quantity of a good demanded or supplied is a function of its price. An example of a demand equation (indicated by the use of $_d$'s) is below:

$$Quantity_{d}=-5 * Price_{d} + 3$$

*Note: The interpretation of this equation is that for every unit that price increases, we see a five unit decrease in the quantity demanded, meaning that there is a negative relationship between the two variables. This intuitively makes sense and is often the case for demand equations -- as a good goes up in price, all else equal, less of that product will be bought. Though there are some instances where this is not the case, and it is likely that the relationship would not follow a straight line, the general relationships and concepts are displayed with our simple case.*

In order to be able to plot with price on the y-axis and quantity on the x-axis, we need to shift around our equations. We will use the inverse-supply and inverse-demand equations, which follow the form $ Price = f(Quantity)$. Below we've taken the example demand equation from above and changed it into the inverse-demand form by solving the equation for price. It might be a good exercise to do this by hand.

$$Price_{d}=-1/5*Quantity_{d}+3/5$$

## Solving Equations with SymPy

In order to treat variables like the symbols you would using pen and paper, we have to declare them as such first. We are going to use the inverse forms, so our equations will both be a function of quantity. We create a variable for quantity below.

```python
Q = sympy.Symbol("Q")
```

Now when we call this variable, we can see that it is a symbolic variable, and not some other Python type.

```python
Q
```

    Q

And with our symbol, we can set up simple expressions.

```python
Q*Q
```

    Q**2

```python
Q+5
```

    Q + 5

```python
Q+Q
```

    2*Q

The expression for our inverse-supply equation will be

```python
Q*3 + 4
```

    3*Q + 4

So we will give that expression a name, `supply`.

```python
supply = 3*Q + 4
supply
```

    3*Q + 4

We can then substitue in values for our quantity variable. We do so in the following cell. Using the method `subs`, we take the equation we already defined, `supply`, and plug in the value 3 in place of `Q`.

```python
supply.subs(Q, 3)
```

    13

Next we'll define an inverse-demand equation.

```python
demand = -4*Q+15
demand
```

    -4*Q + 15

We are now able to take our supply and demand equations and find where they intersect. When we use the `solve` function, it will tell us the x-value of the point where the two lines intercept. This is the equilibrium quantity, which we will call that value `Q_star`.

```python
Q_star = solve(demand, supply) # our version of solve is simplified for single solution systems
Q_star
```

    11/7

We can then substitute `Q_star` back into our original inverse-supply and inverse-demand equations to solve for our equilibrium price.

```python
demand.subs(Q, Q_star)
```

    91/9

```python
supply.subs(Q, Q_star)
```

    91/9

## Visualizing those supply and demand equations

```python
def plot_equation(equation, price_start, price_end, label=None):
    plot_prices = [price_start, price_end]
    plot_quantities = [equation.subs(list(equation.free_symbols)[0], c) for c in plot_prices]
    plt.plot(plot_prices, plot_quantities, label=label)

def plot_intercept(eq1, eq2):
    ex = sympy.solve(eq1-eq2)[0]
    why = eq1.subs(list(eq1.free_symbols)[0], ex)
    plt.scatter([ex], [why])
    return (ex, why)

plot_equation(demand, -10, 10)
plot_equation(supply, -10, 10)
plot_intercept(supply, demand);
```

## Adding Sliders

```python
!jupyter nbextension enable --py widgetsnbextension
```

    Enabling notebook extension jupyter-js-widgets/extension...
          - Validating: OK

```python
nbi.multiple_choice(question="What can cause a rightward shift in the demand curve?",
                    choices=['The enactment of a price ceiling', 'The invention of more efficient manufacturing process', 'A change in consumer preferences'],
                    answers=2)
```

Failed to display Jupyter Widget of type VBox.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

```python
!jupyter nbextension enable --py --sys-prefix widgetsnbextension
```

    Enabling notebook extension jupyter-js-widgets/extension...
          - Validating: OK

```python
widgets.IntSlider()
```

Failed to display Jupyter Widget of type IntSlider.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

```python
# need to move supply to outside of functions (for interchangability)
# add in equilibriums

def shift_demand():
    equation = demand
    def shift_helper(shift):
        plot_equation(equation, -10, 10)
        plot_equation(supply, -10, 10)
        old = plot_intercept(equation, supply)
        print('Original Intercept:', old)

        if shift != 0:
            plot_equation(equation + shift, -10, 10, 'shifted')
            new = plot_intercept(equation + shift, supply)
            print('New intercept:', new)
            print('Change in Quantity:', round(float(new[0]-old[0]), 2))
            print('Change in Price:', round(float(new[1]-old[1]), 2))
        else:
            print('Nothing shifted yet, use the slider to move the line!')
        plt.xlim(-10,10)
        plt.ylim(-10,30)
        plt.legend()
    interact(shift_helper, shift=(-12, 12, 3))

shift_demand()
```

Failed to display Jupyter Widget of type interactive.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

## Consumer and Producer Surplus

```python
import sympy
import matplotlib.pyplot as plt
import matplotlib.patches as patches
p = sympy.Symbol("p")
def Equilibrium(demandParam, supplyParam, priceStart):
    demandEquation = demandParam - p
    # change the slope
    supplyEquation = p * (supplyParam/10)
    priceEnd = sympy.solve(demandEquation)[0]
    prices = []
    demandQ = []
    supplyQ = []
    for price in range(priceStart,priceEnd+1):
        prices += [price]
        demandQ += [demandEquation.subs(p,price)]
        supplyQ += [supplyEquation.subs(p,price)]

    equilibriumP = sympy.solve(demandEquation-supplyEquation)[0]
    equilibriumQ = demandEquation.subs(p,equilibriumP)

    triangle1 = patches.Polygon([[equilibriumQ,equilibriumP],[0,equilibriumP],[0,priceEnd]],True,color="green")
    triangle2 = patches.Polygon([[equilibriumQ,equilibriumP],[0,equilibriumP],[0,0]],True,color="red")
    currentAxis = plt.gca()
    currentAxis.add_patch(triangle1)
    currentAxis.add_patch(triangle2)

    plt.plot(demandQ,prices)
    plt.plot(supplyQ,prices)
    plt.legend(["Demand","Supply"])
    plt.plot(equilibriumQ,equilibriumP, 'ro')
    plt.xlabel("Supply and Demand Quantity")
    plt.ylabel("Price")
    plt.ylim(0, 15)
    plt.xlim(0, 10)
    plt.show()
    print("The equilibrium price is "+str(equilibriumP)+" and equilibrium quantity is "+str(equilibriumQ)+".")
    print("The consumer surplus at this equilibrium "+ str(round((priceEnd-equilibriumP)*(equilibriumQ)*.5, 2)))
    print("The producer surplus at this equilibrium "+str(round((equilibriumP)*(equilibriumQ)*.5, 2)))
slider1 = widgets.IntSlider(min=5, max=15,step=1,value=10)
slider2 = widgets.IntSlider(min=1, max=20,step=1,value=10)
slider3 = widgets.IntSlider(min=-5, max=5,step=1,value=0)
display(widgets.interactive(Equilibrium, demandParam=slider1, supplyParam=slider2, priceStart=slider3))
```

Failed to display Jupyter Widget of type interactive.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

## Shifts in Intercept

```python

```

## Effects of Taxes, Tariffs, Floors, and Ceilings

```python
def eqSolve(eq1,eq2,tax):
    demandP = sympy.solve(eq1-q,p)[0]
    supplyP = sympy.solve(eq2-q,p)[0]
    demandP = demandP-cTax
    supplyP = supplyP+pTax

    demandQ = sympy.solve(demandP-p,q)[0]
    supplyQ = sympy.solve(supplyP-p,q)[0]

    return sympy.solve((demandP-supplyP, demandQ-supplyQ,tax-cTax-pTax), q,p,cTax,pTax)[q]
```

```python
import sympy
import matplotlib.pyplot as plt
import matplotlib.patches as patches
p = sympy.Symbol("p")
q = sympy.Symbol("q")
cTax = sympy.Symbol("cTax")
pTax = sympy.Symbol("pTax")

def EquilibriumTax(demandParam,supplyParam,priceStart,priceEnd,tax):
    demandEquation = demandParam - p
    supplyEquation = p * supplyParam
    prices = []
    demand = []
    supply = []
    for price in range(priceStart,priceEnd+1):
        prices += [price]
        demand += [demandEquation.subs(p,price)]
        supply += [supplyEquation.subs(p,price)]

    nonTaxPrice = sympy.solve(demandEquation-supplyEquation)[0]
    nonTaxQ = demandEquation.subs(p,nonTaxPrice)

    equilibriumQ = eqSolve(demandEquation,supplyEquation,tax)
    equilibriumP1 = sympy.solve(demandEquation-equilibriumQ)[0]
    equilibriumP2 = sympy.solve(supplyEquation-equilibriumQ)[0]

    triangle1 = patches.Polygon([[nonTaxQ,nonTaxPrice],[equilibriumQ,nonTaxPrice],[equilibriumQ,equilibriumP1]],True,color="green")
    triangle2 = patches.Polygon([[nonTaxQ,nonTaxPrice],[equilibriumQ,nonTaxPrice],[equilibriumQ,equilibriumP2]],True)
    currentAxis = plt.gca()
    currentAxis.add_patch(triangle1)
    currentAxis.add_patch(triangle2)

    rect1 = patches.Rectangle((0,nonTaxPrice),equilibriumQ,equilibriumP1-nonTaxPrice,linewidth=1,facecolor="red")
    rect2 = patches.Rectangle((0,nonTaxPrice),equilibriumQ,equilibriumP2-nonTaxPrice,linewidth=1,facecolor="yellow")
    currentAxis.add_patch(rect1)
    currentAxis.add_patch(rect2)

    plt.plot(demand,prices)
    plt.plot(supply,prices)

    plt.legend([rect1,rect2,triangle1,triangle2], ["Consumer Tax","Producer Tax","Consumer Deadweight Loss","Producer Deadweight Loss"])
    plt.plot(equilibriumQ,equilibriumP1, 'ro')
    plt.plot(equilibriumQ,equilibriumP2, 'ro')
    plt.xlabel("Supply and Demand Quantity")
    plt.ylabel("Price")
    plt.show()
    print("The equilibrium prices are "+str(equilibriumP1)+" and "+str(equilibriumP2)+" and equilibrium quantity is "+str(equilibriumQ)+".")
    print("Taxes raised from consumers equals "+str(equilibriumQ*(equilibriumP1-nonTaxPrice)))
    print("Taxes raised from producers equals "+str(equilibriumQ*(nonTaxPrice-equilibriumP2)))
    print("Total taxes raised equals "+str(equilibriumQ*tax))

slider1 = widgets.IntSlider(min=5, max=15,step=1,value=10)
slider2 = widgets.IntSlider(min=1, max=5,step=1,value=3)
slider3 = widgets.IntSlider(min=-5, max=5,step=1,value=0)
slider4 = widgets.IntSlider(min=5, max=10,step=1,value=5)
slider5 = widgets.IntSlider(min=2, max=8,step=1,value=4)
display(widgets.interactive(EquilibriumTax, demandParam=slider1, supplyParam=slider2, priceStart=slider3, priceEnd=slider4, tax=slider5))
```

Failed to display Jupyter Widget of type interactive.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

## Shifts in Slope and effect on DWL, etc.

```python

```


```python
from IPython.display import Image
from IPython.core.display import HTML
from sympy import *; x,h,y = symbols("x h y")
Image(url= "https://i.imgur.com/pnSQKJk.png")

```

```python
dF = diff(1/ (4*sin(x) + 6*cos(x)))
dF.subs(x,0)   #gives the correct slope of the tangent line
```

$\displaystyle - \frac{1}{9}$

```python
x0 = 0; y0 = 1/6
MofT = dF.subs(x,0)
y = y0 + MofT*(x - x0)
print(y)
```

    0.166666666666667 - x/9

```python
# b = 0.166666666666667
```

```python
Image(url= "https://i.imgur.com/A9gEwzV.png")
```

```python

```


# Դասախոսություն 19
## Scikit-learn: Preprocessing
## Mariam Arzumanyan

Այսօր ուսումնասիրելու ենք sklearn.preprocessing-ի package-ը, որը մեզ հնարավորություն է տալիս ստանդարտացնելու մեր տվյալները։

Նախ, պետք է ներբեռնենք համապատասխան մոդուլները։

```python
from sklearn import preprocessing
import numpy as np
```

sklearn.preprocessing-ից մեզ հետաքրքրում է StandardScaler class-ը։
https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html#sklearn.preprocessing.StandardScaler

```python
from sklearn.preprocessing import StandardScaler
```

Ստանդարտացնում է feature-ները՝ հեռացնելով միջինը և չափավորելով ստանդարդ շեղումները։

```python
x= [[0,0], [1,0], [1,1]]
scaler = StandardScaler()
scaler.fit(x)
```

    StandardScaler()

```python
print(scaler.mean_)
```

    [0.66666667 0.33333333]

```python
print(scaler.var_)
```

    [0.22222222 0.22222222]

```python
print(scaler.transform([[2,2]]))
```

    [[2.82842712 3.53553391]]

```python
z_1=(2-2/3)/np.sqrt(0.22222)
z_1
```

    2.828441266987881

```python
z_2=(2-1/3)/np.sqrt(0.22222)
z_2
```

    3.535551583734851

```python
print(scaler.transform(x))
```

    [[-1.41421356 -0.70710678]
     [ 0.70710678 -0.70710678]
     [ 0.70710678  1.41421356]]

```python
print(scaler.transform([[6,2], [1,1]]))
```

    [[11.3137085   3.53553391]
     [ 0.70710678  1.41421356]]

```python
print(scaler.transform(x))
```

    [[-1.41421356 -0.70710678]
     [ 0.70710678 -0.70710678]
     [ 0.70710678  1.41421356]]

```python
scaler.get_params(deep=True)
```

    {'copy': True, 'with_mean': True, 'with_std': True}

```python
scaler.get_params(deep=False)
```

    {'copy': True, 'with_mean': True, 'with_std': True}

### Տնային Առաջադրանք 1
Հետևյալ տվյալների համար օգտագործեք StandardScaler()-ը ներքևում նշված հարցերին պատասխանելու համար։

```python
x_1= [[1,1], [1,1], [1,0], [0,0], [0,0]]
x_2= [[2,5], [4.5, 8]]
x_3= [[1,1], [1,0], [0,0], [0,0], [0,1]]
```

Բոլոր 3 տվյալների համար, գրեք կոդ։
* Ներբեռնեք անհրաժեշտ մոդուլները։
* Fit x_1:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_2-ն ու x_3-ը։

* Fit x_2:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_1-ն ու x_3-ը։

* Fit x_3:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_2-ն ու x_1-ը։

## MinMaxScaler

class sklearn.preprocessing.MinMaxScaler(feature_range=(0, 1), *, copy=True, clip=False)

Այս ֆունկցիան ձևափոխում է տվյալները, ու բոլոր տվյալները լրացնում որոշակի տիրույթում։

\begin{equation}
X_{std} = (X - X.min(axis=0)) / (X.max(axis=0) - X.min(axis=0))
\end{equation}
\begin{equation}
X_{scaled} = X_{std} * (max - min) + min
\end{equation}

```python
from sklearn.preprocessing import MinMaxScaler
x= [[0,-1], [2,0], [1,1]]
scaler = MinMaxScaler()
scaler.fit(x)
```

    MinMaxScaler()

```python
print(scaler.data_max_)
print(scaler.data_min_)
```

    [2. 1.]
    [ 0. -1.]

```python
print(scaler.transform([[2,2]]))
```

    [[1.  1.5]]

```python
print(scaler.transform([[3,2]]))
```

    [[1.5 1.5]]

```python
z_1=[(3-0)/(2-0), (2-(-1))/(1-(-1))]
z_1
```

    [1.5, 1.5]

### Տնային Առաջադրանք 2
Հետևյալ տվյալների համար օգտագործեք MinMaxScaler()-ը ներքևում նշված հարցերին պատասխանելու համար։

```python
x_1= [[1,1], [1,1], [1,0], [0,0], [0,0]]
x_2= [[2,5], [4.5, 8]]
x_3= [[1,1], [1,0], [0,0], [0,0], [0,1]]
```

Բոլոր 3 տվյալների համար, գրեք կոդ։
* Ներբեռնեք անհրաժեշտ մոդուլները։
* Fit x_1:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_2-ն ու x_3-ը։

* Fit x_2:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_1-ն ու x_3-ը։

* Fit x_3:
* Գտեք mean-ն ու var-ը։
* Transform արեք x_2-ն ու x_1-ը։
* Համեմատեք այս վարժությունը StandardScaler()-ի պատասխանների հետ։

## OrdinalEncoder

class sklearn.preprocessing.OrdinalEncoder(*, categories='auto', dtype=<class 'numpy.float64'>, handle_unknown='error', unknown_value=None)

Այս ֆունկցիան փոխարինում է կատեգորիկ տվյալները թվային առանցքում, 0-ից մինչև կատեգորիաների քանակից մինուս 1։

```python
from sklearn.preprocessing import OrdinalEncoder
enc = OrdinalEncoder()
X=[["Male"], ["Male"], ["Female"]]
enc.fit(X)
```

    OrdinalEncoder()

```python
enc.categories_
```

    [array(['Female', 'Male'], dtype=object)]

```python
print(enc.transform([["Female"], ["Male"]]))
```

    [[0.]
     [1.]]

```python
x=[[-1, "zebra"], [-2, "lion"], [0, "tiger"], [-1, "lion"]]
enc.fit(x)
enc.categories_
```

    [array([-2, -1, 0], dtype=object),
     array(['lion', 'tiger', 'zebra'], dtype=object)]

```python
enc.transform(x)
```

    array([[1., 2.],
           [0., 0.],
           [2., 1.],
           [1., 0.]])

Թվերի դեպքում ալգորիթմը սկսում է ամենափոքր թվից, տառերի դեպքում ալգորիթմը սկսում է աշխատել այբենական կարգով։

## Տնային Առաջադրանք 3

Հետևյալ տվյալների համար, պատասխանեք ներքևում տրված հարցերին։

```python
x_1=[[0, "zebra"], [-1, "lion"], [0, "tiger"], [0, "lion"], [-1, "zebra"]]
x_2=[["bird", "boy", -10],["fish", "boy", 4], ["mammal", "girl", 2], ["fish", "girl", 1]]
x_3=[["bird", "girl", 4],["fish", "girl", 2], ["mammal", "boy", 2], ["fish", "girl", 1]]
```

Բոլոր 3 տվյալների համար, գրեք կոդ։
* Ներբեռնեք անհրաժեշտ մոդուլները։
* Fit x_1:
* Գտեք բոլոր կատեգորիաները։
* Transform արեք x_1-ն։
* inverse transform արեք x_1-ն։

* Fit x_2:
* Գտեք բոլոր կատեգորիաները։
* Transform արեք x_2-ն։
* inverse transform արեք x_2-ն։

* Fit x_3:
* Գտեք բոլոր կատեգորիաները։
* Transform արեք x_3-ն։
* inverse transform արեք x_3-ն։
* Համեմատեք այս վարժությունը  պատասխանները x_1-ի, x_2-ի ու x_3-ի համար։

## OneHotEncoder

class sklearn.preprocessing.OneHotEncoder(*, categories='auto', drop=None, sparse=True, dtype=<class 'numpy.float64'>, handle_unknown='error')

```python
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder(handle_unknown='ignore')
X=[["Male"], ["Male"], ["Female"]]
enc.fit(X)
enc.categories_
```

    [array(['Female', 'Male'], dtype=object)]

```python
enc.transform(X).toarray()
```

    array([[0., 1.],
           [0., 1.],
           [1., 0.]])

```python
X=[["Male", 1], ["Male", 0], ["Female",2]]
enc.fit(X)
enc.categories_
```

    [array(['Female', 'Male'], dtype=object), array([0, 1, 2], dtype=object)]

```python
s=enc.transform(X)
s.shape
s.toarray()
```

    array([[0., 1., 0., 1., 0.],
           [0., 1., 1., 0., 0.],
           [1., 0., 0., 0., 1.]])

## Binarizer

class sklearn.preprocessing.Binarizer(*, threshold=0.0, copy=True)

Կախված շեմից, այս ֆունկցիան մեզ վերադարձնում է կամ 0 կամ 1։

```python
from sklearn.preprocessing import Binarizer
scaler=Binarizer()
x=[[1,1], [2,-1], [0,1], [0,0], [-2, 1]]
scaler.fit(x)
```

    Binarizer()

```python
scaler.transform(x)
```

    array([[1, 1],
           [1, 0],
           [0, 1],
           [0, 0],
           [0, 1]])

```python
scaler=Binarizer(threshold=1.1)
scaler.fit(x)
```

    Binarizer(threshold=1.1)

```python
scaler.transform(x)
```

    array([[0, 0],
           [1, 0],
           [0, 0],
           [0, 0],
           [0, 0]])

## KBinsDiscretizer

class sklearn.preprocessing.KBinsDiscretizer(n_bins=5, *, encode='onehot', strategy='quantile', dtype=None)[source]

Այս ֆունկցիան բաժանում է անընդհատ տվյալների բազմությունը վերջավոր խմբերի։

```python
from sklearn.preprocessing import KBinsDiscretizer
x= [[0,-1], [2,0], [1,1]]
scaler=KBinsDiscretizer(n_bins=2, encode='ordinal', strategy='uniform')
scaler.fit(x)
```

    KBinsDiscretizer(encode='ordinal', n_bins=2, strategy='uniform')

```python
print(scaler.transform([[2,2], [1,-1]]))
```

    [[1. 1.]
     [1. 0.]]

```python
print(scaler.transform([[0,6], [1,-3]]))
```

    [[0. 1.]
     [1. 0.]]

```python
x=scaler.transform(x)
x
```

    array([[0., 1.],
           [2., 2.],
           [1., 2.]])

```python
age=[[1],[1.5], [1], [2],[2],[2],[3],[3],[3],[4],[5],[6],[7],[8],[9], [10]]
scaler=KBinsDiscretizer(n_bins=3, encode='ordinal', strategy='uniform')
scaler.fit(age)
```

    KBinsDiscretizer(encode='ordinal', n_bins=3, strategy='uniform')

```python
scaler.transform(age)
```

    array([[0.],
           [0.],
           [0.],
           [0.],
           [0.],
           [0.],
           [0.],
           [0.],
           [0.],
           [1.],
           [1.],
           [1.],
           [2.],
           [2.],
           [2.],
           [2.]])


# Exercise class 1 BBM: Solutions

A different kind of solutions as you might be used to: an attempt to solve all exercises using `sympy` and `scipy`. Interactive version is available at https://mybinder.org/v2/gh/TAdeJong/python-atomic-orbitals/master.

Before we start with the actual solutions, we import a bunch of things:

```python
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
%matplotlib inline

#Pretty printing of sympy functions
from IPython.display import display, Math
#Pretty printing of tables (Somewhat overkill, but so what)
import pandas as pd

from sympy.physics.hydrogen import R_nl
from sympy import var, simplify
from sympy import init_printing, latex
from sympy.utilities.lambdify import lambdify
from sympy import integrate, oo
import scipy.constants as sc
from scipy.constants import physical_constants as constants
```

```python
init_printing()

# electronVolt is nice to have on hand
eV = constants["electron volt"][0]

# We set up both common symbols as well as a dictionary to easily subsitute later on
var('n l r m_e m_c m_p alpha c h hbar Z')
subs = {m_e: sc.m_e, m_p: sc.m_p, alpha:sc.alpha, c: sc.c, h: sc.h, hbar: sc.hbar}
```

## Exercise 1
As the only difference between Hydrogen and Deuterium is the mass of the core, the reduced mass difference should yield only a minor difference in wavelength. We define a reduced mass `mu`, an energy function `E_nl`, a wavelength conversion from energy `wavelength(energy)` and a energy difference `dE`. As all symbols used are `sympy` vars, these remain symbolic functions until we substitute in values.

```python

# Reduced mass for an electron with mass m_e and a core of mass m_c
mu = m_e*m_c / (m_e+m_c)

def E_nl(n, mu=mu,Z=1):
    return Z**2 * (alpha**2 * mu * c**2 / 2) / n**2
def dE(n_1, n_2, mu=mu, Z=1):
    """Energy difference between twoE_n states for a given reduced mass and Z"""
    return Z**2 * (alpha**2 * mu * c**2 / 2)*(1/n_1**2 - 1/n_2**2)

def wavelength(energy):
    return h*c/energy

E_Balmer = dE(2, 3)
display(Math("E_{{Balmer}} = {}".format(latex(E_Balmer))))
l_H = wavelength(E_Balmer).subs({m_c: m_p})
l_D = wavelength(E_Balmer).subs({m_c: 2*m_p})
display(simplify(l_H - l_D))
print("Wavelength difference: {} nm".format((l_H-l_D).evalf(subs=subs)*1e9))
```

## Exercise 2
Helium has two protons, so twice the charge. So mass differences are of relatively minor effect now, the difference in electrostatic potential has the major influence.
The Hydrogen lines listed are the Balmer lines, so $n \to 2$.

As we have: \\( E_{nl}(Z) = Z^2 \frac{E_1}{n^2} \\), the factor $Z^2=4$ for Helium can be compensated by taking a twice as high energy level, so $2n \to 4$, yielding lines in the same energy range.
However, odd initial states are also possible, yielding additional lines inbetween the existing lines.

A small remaining difference in wavelength can be explained by the core mass difference.

```python
l_H = simplify(wavelength(dE(2, n_2=var('n')))).subs({m_c: m_p})
display(Math("\lambda_H = {}".format(latex(l_H))))
l_He = simplify(wavelength(dE(4, n_2=var('n'), Z=2))).subs({m_c: 4*m_p})
display(Math("\lambda_{{He^+}} = {}".format(latex(l_He))))

# Make a dictionary with the resulting wavelengths: substitute values of constants and lambdify for the initial Energy level n
data = {"H (nm)": lambdify(n, l_H.subs(subs) * 1e9)(np.arange(3,7)),
        "He+ (nm)": lambdify(n, l_He.subs(subs) * 1e9)(np.arange(6,13))}
display(data)
```

$$\lambda_H = \frac{2 h n^{2} \left(m_{e} + m_{p}\right)}{\alpha^{2} c m_{e} m_{p} \left(0.25 n^{2} - 1\right)}$$

$$\lambda_{He^+} = \frac{h n^{2} \left(m_{e} + 4 m_{p}\right)}{8 \alpha^{2} c m_{e} m_{p} \left(0.0625 n^{2} - 1\right)}$$

    {'H (nm)': array([656.46960616, 486.27378234, 434.17301994, 410.29350385]),
     'He+ (nm)': array([656.20160872, 541.31109137, 486.07526572, 454.29342142,
            433.99577296, 420.10790823, 410.12600545])}

## Exercise 3
The (equivalent) Bohr radius $a_0$ is only dependent on the electrostatic potential $V(r) = -\frac{e^2}{4\pi \epsilon_0}\frac{1}{r}$, so we can calculate an equivalent Bohr radius for any single electron ion by substituting $e^2 \to Ze^2$, or $\alpha \to Z\alpha$ in the Bohr radius $a_0$.
The same holds for the Bohr energy, but there it occurs quadratic.

```python
a_0 = hbar/(Z*alpha*m_e*c)
display(Math("a_0 = {}".format(latex(a_0))))
display(Math("a_0(Z=91) = {} nm".format(a_0.subs({**subs, Z: 91})*1e9)))
```

$$a_0 = \frac{\hbar}{Z \alpha c m_{e}}$$

$$a_0(Z=91) = 0.000581513418186603 nm$$

For relativistic effects to be negligible, the energy of a level should be negligible compared to the rest mass of the electron. We look at the $n=2 \to n=1$ transition here:

```python
dE_U = dE(1,2,Z=91).subs({m_c: 235*m_p})
display(dE(1,2,Z=Z), dE_U)
l_U = simplify(wavelength(dE_U))
display(Math("\lambda_U = {} = {}nm".format(latex(l_U), l_U.subs(subs)*1e9)))
# Let's compare with the rest mass (equivalent energy) of the electron to see if this is a relativisticly relevant energy:
display(Math("m_e = {}eV,\ E_{{2 \\to1}} = {}eV".format((m_e*c**2).subs(subs)/eV, dE_U.subs(subs)/eV)))
display("A factor of only order 6 means relativistic effects are definitely not negligible here.")
display("Just for fun we check how large the mass effect is here,"
        " by comparing with the case $m_c=1$: {}eV".format(dE(1,2,Z=91).subs({**subs, m_c: 1*sc.m_p})/eV))
```

## Exercise 4

To calculate the expectation values of any operator $\hat{A}$ in terms of $r$, we compute the following integral:

$$\int_0^\infty \hat A R_{nl}(r) r^2 dr$$

Using `sympy`'s `integrate`, we avoid doing many partial integrations by hand:

```python
display(integrate(r * R_nl(1, 0, r, 1)**2 * r**2))
display(Math("\\langle r \\rangle _{{1s}} = {} a_0".format(integrate(r * R_nl(1, 0, r, 1)**2 * r**2, (r,0,oo)))))
display(Math("\\langle r \\rangle _{{2s}} = {} a_0".format(integrate(r * R_nl(2, 0, r, 1)**2 * r**2, (r,0,oo)))))
display(Math("\\langle r \\rangle _{{2p}} = {} a_0".format(integrate(r * R_nl(2, 1, r, 1)**2 * r**2, (r,0,oo)))))
```

## Exercise 5
For $\langle r^{-3}\rangle$, we only need to substitute the correct operator of course. The integrals however not neccesarily converge:

```python
display(integrate(R_nl(1, 0, r, 1)**2 * r**-1))
display(Math("\\langle r^{{-3}} \\rangle _{{1s}} = {} a_0".format(integrate(r**-3 * R_nl(1, 0, r, 1)**2 * r**2, (r,0,oo)))))
# The 2s case actually trips sympy...
#display(Math("\\langle r^{{-3}} \\rangle _{{2s}} = {} a_0".format(integrate(R_nl(2, 0, r, 1)**2 * r**-1, (r,0,oo)))))
display(Math("\\langle r^{{-3}} \\rangle _{{2p}} = {} a_0".format(integrate(R_nl(2, 1, r, 1)**2 * r**-1, (r,0,oo)))))

```

## Exercise 6
The classical turning point is where the kinetic energy is equal to the potential, where the potential is the electrostatic potential plus the effective potential due to the orbital angular momentum.
We will work in units $eV$ and $a_0$ to plot the curves to get a general idea and then use sympy to solve analytically.

```python
V = -alpha*hbar*c/r + hbar**2/(2*m_e) * l*(l+1)/r**2 # alpha*hbar*c = e^2/4 pi epsilon_0
V = V.subs({r: r*a_0})
display(V)
V = V.subs({Z: 1})

# OK, let's first plot this thing:
Vplot = V.subs(subs)/eV

x_np = np.linspace(0.001, 30, 300)
cmap = matplotlib.cm.get_cmap('tab20c')
linestyles = ['-', '--', '-.', ':']

for l_np in [0,1]:
    display(Vplot.subs({l:l_np}))
    plt.plot(x_np,lambdify(r, Vplot.subs({l:l_np}))(x_np),
                 color=cmap(4*2-4+l_np),
                 linestyle=linestyles[l_np],
                 label="$n = {}, l = {}$".format(2, l_np))
    plt.legend()
plt.ylim([-15, 5])
plt.xlim([0, 30])
E_2 = -1*E_nl(2, mu=m_e).subs(subs)/eV
plt.axhline(E_2)
plt.xlabel('$r (a_0)$');
plt.ylabel('$E (eV)$');

```

Now we use `sympy.sovlers.solve` to solve the equations. It solves by putting the object equal to zero, so we substract $- E_2$ and after that solve for r.

```python
from sympy.solvers import solve
turning_point_equation = V + E_nl(2, mu=m_e)
display(turning_point_equation.simplify())
```

```python
solutions = solve(turning_point_equation, r)
solutions
```

```python
# Solutions for 2s, cast to float to compare with plot
[float(sol.subs({l:0})) for sol in solutions]
```

```python
# Solutions for 2p
[float(sol.subs({l:1})) for sol in solutions]
```