# 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
```

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
#torch.is_tensor(x)
```




    tensor([[0.7640, 0.1932],
            [0.0554, 0.8443],
            [0.0084, 0.7823]])




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




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




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




    tensor([[1.7640, 1.1932],
            [1.0554, 1.8443],
            [1.0084, 1.7823]])



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


```python
z[0]
```




    tensor([1.7640, 1.1932])




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




    tensor([[1.1932],
            [1.8443],
            [1.7823]])



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.7640, 2.1932],
            [2.0554, 2.8443],
            [2.0084, 2.7823]])




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




    tensor([[1.7640, 1.1932],
            [1.0554, 1.8443],
            [1.0084, 1.7823]])




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




    tensor([[2.7640, 2.1932],
            [2.0554, 2.8443],
            [2.0084, 2.7823]])




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




    tensor([[2.7640, 2.1932],
            [2.0554, 2.8443],
            [2.0084, 2.7823]])



### 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.7640, 2.1932, 2.0554],
            [2.8443, 2.0084, 2.7823]])




```python
z
```




    tensor([[2.7640, 2.1932, 2.0554],
            [2.8443, 2.0084, 2.7823]])



## 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
#torch.is_tensor(a)
```




    array([[ 0.12711662,  0.92413542,  0.42696275],
           [ 0.32314096,  0.68522034,  0.63669388],
           [ 0.85865113,  0.141158  ,  0.69359639],
           [ 0.1663766 ,  0.66859912,  0.6212608 ]])




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




    tensor([[0.1271, 0.9241, 0.4270],
            [0.3231, 0.6852, 0.6367],
            [0.8587, 0.1412, 0.6936],
            [0.1664, 0.6686, 0.6213]], dtype=torch.float64)




```python
c = b.numpy()
c
```




    array([[ 0.12711662,  0.92413542,  0.42696275],
           [ 0.32314096,  0.68522034,  0.63669388],
           [ 0.85865113,  0.141158  ,  0.69359639],
           [ 0.1663766 ,  0.66859912,  0.6212608 ]])



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([[0.5085, 3.6965, 1.7079],
            [1.2926, 2.7409, 2.5468],
            [3.4346, 0.5646, 2.7744],
            [0.6655, 2.6744, 2.4850]], dtype=torch.float64)




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




    array([[ 0.50846648,  3.69654169,  1.70785102],
           [ 1.29256382,  2.74088136,  2.54677551],
           [ 3.4346045 ,  0.56463199,  2.77438555],
           [ 0.66550639,  2.67439646,  2.48504322]])




```python
c
```




    array([[ 0.50846648,  3.69654169,  1.70785102],
           [ 1.29256382,  2.74088136,  2.54677551],
           [ 3.4346045 ,  0.56463199,  2.77438555],
           [ 0.66550639,  2.67439646,  2.48504322]])




```python

```

0a7f9c4117045638a4641ba19357ad0db43b151b
ipynb
Jupyter Notebook
Part 1 - Tensors in PyTorch.ipynb
OanaGaskey/-DeepLearning-PyTorch
7f0dcbeb5957a2b698dd7aadbf0997c2df38f01f
MIT
Part 1 - Tensors in PyTorch.ipynb
OanaGaskey/-DeepLearning-PyTorch
7f0dcbeb5957a2b698dd7aadbf0997c2df38f01f
MIT
Part 1 - Tensors in PyTorch.ipynb
OanaGaskey/-DeepLearning-PyTorch
7f0dcbeb5957a2b698dd7aadbf0997c2df38f01f
MIT
Qwen/Qwen-72B
1. YES
2. YES