# Tutorial 2: Regularization techniques part 2
**Week 1, Day 5: Regularization**

**By Neuromatch Academy**

__Content creators:__ Ravi Teja Konkimalla, Mohitrajhu Lingan Kumaraian, Kevin Machado Gamboa, Kelson Shilling-Scrivo, Lyle Ungar

__Content reviewers:__ Piyush Chauhan, Kelson Shilling-Scrivo, Siwei Bai

__Content editors:__ Roberto Guidotti, Spiros Chavlis

__Production editors:__ Saeed Salehi, Spiros Chavlis

**Our 2021 Sponsors, including Presenting Sponsor Facebook Reality Labs**

---
# Tutorial Objectives

1.   Regularization as shrinkage of overparameterized models: L1, L2
2.   Regularization by Dropout
3.   Regularization by Data Augmentation
4.   Perils of Hyper-Parameter Tuning
5.   Rethinking generalization

```

# @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/7um6p/?direct%26mode=render%26action=download%26mode=render", width=854, height=480)
```

---
# Setup
Note that some of the code for today can take up to an hour to run. We have therefore "hidden" that code and shown the resulting outputs.

```
# @title Install dependencies
!sudo apt-get install -y ffmpeg --quiet
!pip install imageio-ffmpeg --quiet
```

```
# Imports
from __future__ import print_function

import copy
import torch
import random
import pathlib

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation

import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

from torchvision import datasets, transforms
from torchvision.datasets import ImageFolder
from torch.optim.lr_scheduler import StepLR

from tqdm.auto import tqdm
from IPython.display import HTML, display
```

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

```
%%capture
# @title Loading Animal Faces
!rm -r AnimalFaces32x32/
!git clone https://github.com/arashash/AnimalFaces32x32
!rm -r afhq/
!unzip ./AnimalFaces32x32/afhq_32x32.zip
```

```
%%capture
# @title Loading Animal Faces Randomized data

!rm -r Animal_faces_random/
!git clone https://github.com/Ravi3191/Animal_faces_random.git
!rm -r afhq_random_32x32/
!unzip ./Animal_faces_random/afhq_random_32x32.zip
!rm -r afhq_10_32x32/
!unzip ./Animal_faces_random/afhq_10_32x32.zip
```

```
# @title Plotting functions

def imshow(img):
  img = img / 2 + 0.5  # unnormalize
  npimg = img.numpy()
  plt.imshow(np.transpose(npimg, (1, 2, 0)))
  plt.axis(False)
  plt.show()

def plot_weights(norm, labels, ws, title='Weight Size Measurement'):
  plt.figure(figsize=[8, 6])
  plt.title(title)
  plt.ylabel('Frobenius Norm Value')
  plt.xlabel('Model Layers')
  plt.bar(labels, ws)
  plt.axhline(y=norm,
              linewidth=1,
              color='r',
              ls='--',
              label='Total Model F-Norm')
  plt.legend()
  plt.show()

def visualize_data(dataloader):

  for idx, (data,label) in enumerate(dataloader):
    plt.figure(idx)
    # Choose the datapoint you would like to visualize
    index = 22

    # choose that datapoint using index and permute the dimensions
    # and bring the pixel values between [0,1]
    data = data[index].permute(1, 2, 0) * \
           torch.tensor([0.5, 0.5, 0.5]) + \
           torch.tensor([0.5, 0.5, 0.5])

    # Convert the torch tensor into numpy
    data = data.numpy()

    plt.imshow(data)
    plt.axis(False)
    image_class = classes[label[index].item()]
    print(f'The image belongs to : {image_class}')

  plt.show()
```

```
# @title Helper functions

## Network Class - Animal Faces
class AnimalNet(nn.Module):
  def __init__(self):
    super(AnimalNet, self).__init__()
    self.fc1 = nn.Linear(3 * 32 * 32, 128)
    self.fc2 = nn.Linear(128, 32)
    self.fc3 = nn.Linear(32, 3)

  def forward(self, x):
    x = x.view(x.shape[0], -1)
    x = F.relu(self.fc1(x))
    x = F.relu(self.fc2(x))
    x = self.fc3(x)
    output = F.log_softmax(x, dim=1)
    return output

# Simple Net
class Net(nn.Module):
  def __init__(self):
    super(Net, self).__init__()

    self.fc1 = nn.Linear(1, 300)
    self.fc2 = nn.Linear(300, 500)
    self.fc3 = nn.Linear(500, 1)

  def forward(self, x):
    x = F.leaky_relu(self.fc1(x))
    x = F.leaky_relu(self.fc2(x))
    output = self.fc3(x)
    return output

# Network Class - Animal Faces
class BigAnimalNet(nn.Module):
  def __init__(self):
    super(BigAnimalNet, self).__init__()
    self.fc1 = nn.Linear(3*32*32, 124)
    self.fc2 = nn.Linear(124, 64)
    self.fc3 = nn.Linear(64, 3)

  def forward(self, x):
    x = x.view(x.shape[0],-1)
    x = F.leaky_relu(self.fc1(x))
    x = F.leaky_relu(self.fc2(x))
    x = self.fc3(x)
    output = F.log_softmax(x, dim=1)
    return output

def train(args, model, train_loader, optimizer, epoch,
          reg_function1=None, reg_function2=None, criterion=F.nll_loss):
  """
  Trains the current inpur model using the data
  from Train_loader and Updates parameters for a single pass
  """

  device = args['device']
  model.train()
  for batch_idx, (data, target) in enumerate(train_loader):
    data, target = data.to(device), target.to(device)
    optimizer.zero_grad()
    output = model(data)
    if reg_function1 is None:
      loss = criterion(output, target)
    elif reg_function2 is None:
      loss = criterion(output, target)+args['lambda']*reg_function1(model)
    else:
      loss = criterion(output, target)+args['lambda1']*reg_function1(model)+args['lambda2']*reg_function2(model)
    loss.backward()
    optimizer.step()

  return model

def test(model, test_loader, loader='Test', criterion=F.nll_loss,
         device='cpu'):
  """
  Tests the current Model
  """
  model.eval()
  test_loss = 0
  correct = 0
  with torch.no_grad():
    for data, target in test_loader:
      data, target = data.to(device), target.to(device)
      output = model(data)
      test_loss += criterion(output, target, reduction='sum').item()  # sum up batch loss
      pred = output.argmax(dim=1, keepdim=True)  # get the index of the max log-probability
      correct += pred.eq(target.view_as(pred)).sum().item()

  test_loss /= len(test_loader.dataset)
  def __init__(self):
    super(BigAnimalNet, self).__init__()
    self.fc1 = nn.Linear(3*32*32, 124)
    self.fc2 = nn.Linear(124, 64)
    self.fc3 = nn.Linear(64, 3)

  def forward(self, x):
    x = x.view(x.shape[0],-1)
    x = F.leaky_relu(self.fc1(x))
    x = F.leaky_relu(self.fc2(x))
    x = self.fc3(x)
    output = F.log_softmax(x, dim=1)
    return output
  return 100. * correct / len(test_loader.dataset)

def main(args, model, train_loader, val_loader, test_data,
         reg_function1=None, reg_function2=None, criterion=F.nll_loss):
  """
  Trains the model with train_loader and tests the learned model using val_loader
  """

  device = args['device']

  model = model.to(device)
  optimizer = optim.SGD(model.parameters(), lr=args['lr'], momentum=args['momentum'])

  val_acc_list, train_acc_list,param_norm_list = [], [], []
  for epoch in tqdm(range(args['epochs'])):
    trained_model = train(args, model, train_loader, optimizer, epoch,
                          reg_function1=reg_function1,
                          reg_function2=reg_function2)
    train_acc = test(trained_model, train_loader, loader='Train', device=device)
    val_acc = test(trained_model, val_loader, loader='Val', device=device)
    param_norm = calculate_frobenius_norm(trained_model)
    train_acc_list.append(train_acc)
    val_acc_list.append(val_acc)
    param_norm_list.append(param_norm)

  return val_acc_list, train_acc_list, param_norm_list, model

def calculate_frobenius_norm(model):
    norm = 0.0
    # Sum the square of all parameters
    for name,param in model.named_parameters():
        norm += torch.norm(param).data**2
    # Return a square root of the sum of squares of all the parameters
    return norm**0.5

def early_stopping_main(args, model, train_loader, val_loader, test_data):

  device = args['device']

  model = model.to(device)
  optimizer = optim.SGD(model.parameters(), lr=args['lr'], momentum=args['momentum'])

  best_acc  = 0.0
  best_epoch = 0

  # Number of successive epochs that you want to wait before stopping training process
  patience = 20

  # Keps track of number of epochs during which the val_acc was less than best_acc
  wait = 0

  val_acc_list, train_acc_list = [], []
  for epoch in tqdm(range(args['epochs'])):
    trained_model = train(args, model, device, train_loader, optimizer, epoch)
    train_acc = test(trained_model, train_loader, loader='Train', device=device)
    val_acc = test(trained_model, val_loader, loader='Val', device=device)
    if (val_acc > best_acc):
      best_acc = val_acc
      best_epoch = epoch
      best_model = copy.deepcopy(trained_model)
      wait = 0
    else:
      wait += 1
    if (wait > patience):
      print(f'early stopped on epoch: {epoch}')
      break
    train_acc_list.append(train_acc)
    val_acc_list.append(val_acc)

  return val_acc_list, train_acc_list, best_model, best_epoch
```

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

```
# @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("WARNING: For this notebook to perform best, "
        "if possible, in the menu under `Runtime` -> "
        "`Change runtime type.`  select `GPU` ")
  else:
    print("GPU is enabled in this notebook.")

  return device
```

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

```
# @title Dataloaders for the Dataset
## Dataloaders for the Dataset
batch_size = 128
classes = ('cat', 'dog', 'wild')

train_transform = transforms.Compose([
     transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
     ])
data_path = pathlib.Path('.')/'afhq' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)

####################################################
g_seed = torch.Generator()
g_seed.manual_seed(SEED)

##Dataloaders for the  Original Dataset
img_train_data, img_val_data,_ = torch.utils.data.random_split(img_dataset,
                                                               [100, 100, 14430])

#Creating train_loader and Val_loader
train_loader = torch.utils.data.DataLoader(img_train_data,
                                           batch_size=batch_size,
                                           worker_init_fn=seed_worker,
                                           num_workers=2,
                                           generator=g_seed)
val_loader = torch.utils.data.DataLoader(img_val_data,
                                         batch_size=1000,
                                         num_workers=2,
                                         worker_init_fn=seed_worker,
                                         generator=g_seed)

#creating test dataset
test_transform = transforms.Compose([
     transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
     ])
img_test_dataset = ImageFolder(data_path/'val', transform=test_transform)

####################################################

## Dataloaders for the  Random Dataset

# splitting randomized data into training and validation data
data_path = pathlib.Path('.')/'afhq_random_32x32/afhq_random' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)
random_img_train_data, random_img_val_data,_ = torch.utils.data.random_split(img_dataset, [100,100,14430])

#Randomized train and validation dataloader
rand_train_loader = torch.utils.data.DataLoader(random_img_train_data,
                                                batch_size=batch_size,
                                                num_workers=2,
                                                worker_init_fn=seed_worker,
                                                generator=g_seed)
rand_val_loader = torch.utils.data.DataLoader(random_img_val_data,
                                              batch_size=1000,
                                              num_workers=2,
                                              worker_init_fn=seed_worker,
                                              generator=g_seed)

####################################################

## Dataloaders for the Partially Random Dataset

# Splitting data between training and validation dataset for partially randomized data
data_path = pathlib.Path('.')/'afhq_10_32x32/afhq_10' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)
partially_random_train_data, partially_random_val_data, _ = torch.utils.data.random_split(img_dataset, [100,100,14430])

#Training and Validation loader for partially randomized data
partial_rand_train_loader = torch.utils.data.DataLoader(partially_random_train_data,
                                                        batch_size=batch_size,
                                                        num_workers=2,
                                                        worker_init_fn=seed_worker,
                                                        generator=g_seed)
partial_rand_val_loader = torch.utils.data.DataLoader(partially_random_val_data,
                                                      batch_size=1000,
                                                      num_workers=2,
                                                      worker_init_fn=seed_worker,
                                                      generator=g_seed)
```

---
# Section 1: L1 and L2 Regularization

```
# @title Video 1: L1 and L2 regression
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"BV19h41167H7", 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"oQNdloKdysM", 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')

display(out)
```

Some of you might have already come across L1 and L2 regularization before in other courses. L1 and L2 are the most common types of regularization. These update the general cost function by adding another term known as the regularization term.

***Cost function = Loss (say, binary cross entropy) + Regularization term***

This regularization term makes the parameters smaller, giving simpler models that will overfit less.

Discuss among your teammates whether the above assumption is good or bad?

## Section 1.1: Unregularized Model

```
# @markdown #### Dataloaders for Regularization
data_path = pathlib.Path('.')/'afhq' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)

# Splitting dataset
reg_train_data, reg_val_data,_ = torch.utils.data.random_split(img_dataset,
                                                               [30, 100, 14500])
g_seed = torch.Generator()
g_seed.manual_seed(SEED)

# Creating train_loader and Val_loader
reg_train_loader = torch.utils.data.DataLoader(reg_train_data,
                                               batch_size=batch_size,
                                               worker_init_fn=seed_worker,
                                               num_workers=2,
                                               generator=g_seed)
reg_val_loader = torch.utils.data.DataLoader(reg_val_data,
                                             batch_size=1000,
                                             worker_init_fn=seed_worker,
                                             num_workers=2,
                                             generator=g_seed)
```

Now let's train a model without any regularization and keep it aside as our benchmark for this section.

```
# Set the arguments
args = {
    'epochs': 150,
    'lr': 5e-3,
    'momentum': 0.99,
    'device': DEVICE,
}

# intialize the model
set_seed(seed=SEED)
model = AnimalNet()

# Train the model
val_acc_unreg, train_acc_unreg, param_norm_unreg, _ = main(args,
                                                           model,
                                                           reg_train_loader,
                                                           reg_val_loader,
                                                           img_test_dataset)

# Train and Test accuracy plot
plt.figure(figsize=(8, 6))
plt.plot(val_acc_unreg, label='Val Accuracy', c='red', ls='dashed')
plt.plot(train_acc_unreg, label='Train Accuracy', c='red', ls='solid')
plt.axhline(y=max(val_acc_unreg), c='green', ls='dashed')
plt.title('Unregularized Model')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')
plt.legend()
plt.show()
print('maximum Validation Accuracy reached:%f' % max(val_acc_unreg))
```

## Section 1.2: L1 Regularization

L1 (or "LASSO") Regularization uses a penalty which is the sum of the absolute value of all the weights in the DLN, resulting in the following loss function (L  is the usual Cross Entropy loss):

\begin{equation}
L_R=L + \lambda \sum \left| w^{(r)}_{ij} \right|
\end{equation}

At a high level, L1 Regularization is similar to L2 Regularization since it leads to smaller weights. (You will see the analogy in the next subsection.) It results in the following weight update equation when using Stochastic Gradient Descent:

\begin{equation}
w^{(r)}_{ij}←w^{(r)}_{ij}−\eta \lambda \text{sgn}\left(w^{(r)}_{ij}\right)−\eta \frac{\partial L}{\partial w_{ij}^{r}}
\end{equation}

where  $\text{sgn}(\cdot)$ is the sign function, such that

\begin{equation}
\text{sgn(w)} =
\left\{
  \begin{array}{ll}
  +1 & \mbox{if  } w > 0 \\
  -1 & \mbox{if  } w < 0 \\
  0 & \mbox{if  } w = 0
  \end{array}
\right.
\end{equation}

### Coding Exercise 1.1: L1 Regularization

Write a function which calculates the L1 norm of all the tensors of a Pytorch model.

```
def l1_reg(model):
  """
    Inputs: Pytorch model
    This function calculates the l1 norm of the all the tensors in the model
  """
  l1 = 0.0
  ####################################################################
  # Fill in all missing code below (...),
  # then remove or comment the line below to test your function
  raise NotImplementedError("Complete the l1_reg function")
  ####################################################################
  for param in model.parameters():
    l1 += ...

  return l1

set_seed(seed=SEED)
## uncomment to test
# net = nn.Linear(20, 20)
# print(f'L1 norm of the model: {l1_reg(net)}')
```

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

```
Random seed 2021 has been set.
L1 norm of the model: 48.445133209228516
```

Now, let's train a classifier which uses L1 regularization. Tune the hyperparameter `lambda` such that the validation accuracy is higher than that of the unregularized model.

```
# Set the arguments
args = {
    'epochs': 150,
    'lr': 5e-3,
    'momentum': 0.99,
    'device': DEVICE,
    'lambda': 0.0  # <<<<<<<< Tune the hyperparameter lambda
}

# intialize the model
set_seed(seed=SEED)
model = AnimalNet()

# Train the model
val_acc_l1reg, train_acc_l1reg, param_norm_l1reg, _ = main(args,
                                                           model,
                                                           reg_train_loader,
                                                           reg_val_loader,
                                                           img_test_dataset,
                                                           reg_function1=l1_reg)

# Train and Test accuracy plot
plt.figure(figsize=(8, 6))
plt.plot(val_acc_l1reg, label='Val Accuracy L1 Regularized',
         c='red', ls='dashed')
plt.plot(train_acc_l1reg, label='Train Accuracy L1 regularized',
         c='red', ls='solid')
plt.axhline(y=max(val_acc_l1reg), c='green', ls='dashed')
plt.title('L1 regularized model')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')
plt.legend()
plt.show()
print('maximum Validation Accuracy reached:%f'%max(val_acc_l1reg))
```

What value of $\lambda$, i.e., `lambda`, worked for L1 Regularization?

## Section 1.3: L2 / Ridge Regularization

L2 Regularization, sometimes referred to as “Weight Decay”, is widely used. It works by adding a quadratic penalty term to the Cross Entropy Loss Function  L, which results in a new Loss Function  LR  given by:

\begin{equation}
L_R = L + \lambda \sum \left( w^{(r)}_{ij} \right)^2
\end{equation}

In order to get further insight into L2 Regularization, we investigate its effect on the Gradient Descent based update equations for the weight and bias parameters. Taking the derivative on both sides of the above equation, we obtain

\begin{equation}
\frac{\partial L_r}{\partial w^{(r)}_{ij}}=\frac{\partial L}{\partial w^{(r)}_{ij}} + \lambda w^{(r)}_{ij}cdot
\end{equation}
Thus the weight update rule becomes:

\begin{equation}
w^{(r)}_{ij}←w^{(r)}_{ij}−η\frac{\partial L}{\partial W^{(r)}_{ij}}−\eta \lambda w^{(r)}_{ij}=(1−\eta \lambda)w^{(r)}_{ij} − \eta \frac{\partial L}{\partial w^{(r)}_{ij}}
\end{equation}

where, $\eta$ is learning rate.

### Coding Exercise 1.2: L2 Regularization

Write a function which calculates the L2 norm of all the tensors of a Pytorch model. (What did we call this before?)

```
def l2_reg(model):

  """
    Inputs: Pytorch model
    This function calculates the l2 norm of the all the tensors in the model
  """

  l2 = 0.0
  ####################################################################
  # Fill in all missing code below (...),
  # then remove or comment the line below to test your function
  raise NotImplementedError("Complete the l2_reg function")
  ####################################################################
  for param in model.parameters():
    l2 += ...

  return l2

set_seed(SEED)
## uncomment to test
# net = nn.Linear(20, 20)
# print(f'L2 norm of the model: {l2_reg(net)}')
```

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

```
Random seed 2021 has been set.
L2 norm of the model: 7.328375816345215
```

Now we'll train a classifier which uses L2 regularization. Tune the hyperparameter `lambda` such that the val accuracy is higher than that of the unregularized model.

```
# Set the arguments
args = {
    'test_batch_size': 1000,
    'epochs': 150,
    'lr': 5e-3,
    'momentum': 0.99,
    'device': DEVICE,
    'lambda': 0.0  # <<<<<<<< Tune the hyperparameter lambda
}

# intialize the model
set_seed(seed=SEED)
model = AnimalNet()

# Train the model
val_acc_l2reg, train_acc_l2reg, param_norm_l2reg, model = main(args,
                                                               model,
                                                               train_loader,
                                                               val_loader,
                                                               img_test_dataset,
                                                               reg_function2=l2_reg)

## Train and Test accuracy plot
plt.figure(figsize=(8, 6))
plt.plot(val_acc_l2reg, label='Val Accuracy L2 regularized',
         c='red', ls='dashed')
plt.plot(train_acc_l2reg, label='Train Accuracy L2 regularized',
         c='red', ls='solid')
plt.axhline(y=max(val_acc_l2reg), c='green', ls='dashed')
plt.title('L2 Regularized Model')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')
plt.legend()
plt.show()
print('maximum Validation Accuracy reached:%f'%max(val_acc_l2reg))
```

What value of Lambda worked for L2 Regularization?

```
# @markdown #### Visualize all of them together (Run Me!)
args = {'test_batch_size': 1000,
        'epochs': 150,
        'lr': 5e-3,
        'momentum': 0.99,
        'device': DEVICE,
        'lambda1': 0.001,
        'lambda2': 0.001
        }

# Intialize the model
set_seed(seed=SEED)
model = AnimalNet()
val_acc_l1l2reg, train_acc_l1l2reg, param_norm_l1l2reg, _ = main(args,
                                                                 model,
                                                                 train_loader,
                                                                 val_loader,
                                                                 img_test_dataset,
                                                                 reg_function1=l1_reg,
                                                                 reg_function2=l2_reg)

plt.figure(figsize=(12, 6))
plt.plot(val_acc_l2reg, c='red', ls='dashed')
plt.plot(train_acc_l2reg, label='L2 regularized', c='red', ls='solid')
plt.axhline(y=max(val_acc_l2reg), c='red', ls='dashed')
plt.plot(val_acc_l1reg, c='green', ls = 'dashed')
plt.plot(train_acc_l1reg, label='L1 regularized', c='green', ls='solid')
plt.axhline(y=max(val_acc_l1reg), c='green', ls='dashed')
plt.plot(val_acc_unreg, c='blue', ls = 'dashed')
plt.plot(train_acc_unreg, label='Unregularized', c='blue', ls='solid')
plt.axhline(y=max(val_acc_unreg), c='blue', ls='dashed')
plt.plot(val_acc_l1l2reg, c='orange', ls='dashed')
plt.plot(train_acc_l1l2reg, label='L1+L2 regularized', c='orange', ls='solid')
plt.axhline(y=max(val_acc_l1l2reg), c='orange', ls = 'dashed')

plt.title('Unregularized Vs L1-Regularized vs L2-regularized Vs L1+L2 regularized')
plt.xlabel('epoch')
plt.ylabel('Accuracy(%)')
plt.legend()
plt.show()
```

Now, let's visualize what these different regularization does to the parameters of the model. We observe the effect by computing the size (technically, the Frobenius norm) of the model parameters

```
# @markdown #### Visualize Norm of the Models (Train Me!)
plt.figure(figsize=(8, 6))
plt.plot(param_norm_unreg, label='Unregularized', c='blue')
plt.plot(param_norm_l1reg, label='L1 Regularized', c='green')
plt.plot(param_norm_l2reg, label='L2 Regularized', c='red')
plt.plot(param_norm_l1l2reg, label='L1+L2 Regularized', c='orange')
plt.title('Parameter Norm as a function of training Epoch')
plt.xlabel('epoch')
plt.ylabel('Parameter Norms')
plt.legend()
plt.show()
```

In the above plots, you should have seen that even after the model achieves 100% train accuracy the val accuracies are fluctuating. This suggests that the model is still trying to learn something. Why would this be the case?

---
# Section 2: Dropout

```
# @title Video 2: Dropout
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"BV1gU4y1G7V2", 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"UZfUzawej3A", 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')

display(out)
```

In dropout, we literally drop out (zero out) some neurons during training. Throughout training, on each iteration, standard dropout zeros out some fraction (usually 1/2) of the nodes in each layer before calculating the subsequent layer. Randomly selecting different subsets to dropout introduces noise into the process and reduces overfitting.

Now let's revisit the toy dataset that we generated above to visualize how the dropout stabilizes training on a noisy dataset. We will slightly modify the architecture we used above to add dropout layers.

```
# Network Class - 2D
class NetDropout(nn.Module):
  def __init__(self):
    super(NetDropout, self).__init__()

    self.fc1 = nn.Linear(1, 300)
    self.fc2 = nn.Linear(300, 500)
    self.fc3 = nn.Linear(500, 1)
    # We add two dropout layers
    self.dropout1 = nn.Dropout(0.4)
    self.dropout2 = nn.Dropout(0.2)

  def forward(self, x):
    x = F.leaky_relu(self.dropout1(self.fc1(x)))
    x = F.leaky_relu(self.dropout2(self.fc2(x)))
    output = self.fc3(x)
    return output
```

```
# @markdown #### Run to train the default network
set_seed(seed=SEED)
# creating train data
X = torch.rand((10, 1))
X.sort(dim = 0)
Y = 2*X + 2*torch.empty((X.shape[0], 1)).normal_(mean=0, std=1)  # adding small error in the data

X = X.unsqueeze_(1)
Y = Y.unsqueeze_(1)

# creating test dataset
X_test = torch.linspace(0, 1, 40)
X_test = X_test.reshape((40, 1, 1))

# train the network on toy dataset
model = Net()
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-4)
max_epochs = 10000
iters = 0

running_predictions = np.empty((40, (int)(max_epochs/500 + 1)))

train_loss = []
test_loss = []
model_norm = []

for epoch in tqdm(range(max_epochs)):

  #training
  model_norm.append(calculate_frobenius_norm(model))
  model.train()
  optimizer.zero_grad()
  predictions = model(X)
  loss = criterion(predictions,Y)
  loss.backward()
  optimizer.step()

  train_loss.append(loss.data)
  model.eval()
  Y_test = model(X_test)
  loss = criterion(Y_test, 2*X_test)
  test_loss.append(loss.data)

  if (epoch % 500 == 0 or epoch == max_epochs - 1):
    running_predictions[:, iters] = Y_test[:, 0, 0].detach().numpy()
    iters += 1
```

```
# train the network on toy dataset

# Intialize the model
set_seed(seed=SEED)
model = NetDropout()
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-4)
max_epochs = 10000
iters = 0

running_predictions_dp = np.empty((40, (int)(max_epochs / 500)))

train_loss_dp = []
test_loss_dp = []
model_norm_dp = []

for epoch in tqdm(range(max_epochs)):

  # training
  model_norm_dp.append(calculate_frobenius_norm(model))
  model.train()
  optimizer.zero_grad()
  predictions = model(X)
  loss = criterion(predictions, Y)
  loss.backward()
  optimizer.step()

  train_loss_dp.append(loss.data)
  model.eval()
  Y_test = model(X_test)
  loss = criterion(Y_test, 2*X_test)
  test_loss_dp.append(loss.data)

  if (epoch % 500 == 0 or epoch == max_epochs):
    running_predictions_dp[:, iters] = Y_test[:, 0, 0].detach().numpy()
    iters += 1
```

Now that we have finished training, let's see how the model has evolved over the training process.

```
# @markdown #### Animation! (Run Me!)
set_seed(seed=SEED)

fig = plt.figure(figsize=(8, 6))
ax = plt.axes()
def frame(i):
  ax.clear()
  ax.scatter(X[:, 0, :].numpy(), Y[:, 0, :].numpy())
  plot = ax.plot(X_test[:, 0, :].detach().numpy(),
                 running_predictions_dp[:, i])
  title = f"Epoch: {i*500}"
  plt.title(title)
  ax.set_xlabel("X axis")
  ax.set_ylabel("Y axis")
  return plot

anim = animation.FuncAnimation(fig, frame, frames=range(20),
                               blit=False, repeat=False,
                               repeat_delay=10000)
html_anim = HTML(anim.to_html5_video());
plt.close()
display(html_anim)
```

```
# @markdown #### Plot the train and test losses
plt.figure(figsize=(8, 6))
plt.plot(test_loss_dp, label='test_loss dropout', c='blue', ls='dashed')
plt.plot(test_loss, label='test_loss', c='red', ls='dashed')
plt.ylabel('loss')
plt.xlabel('epochs')
plt.title('dropout vs without dropout')
plt.legend()
plt.show()
```

```
# @markdown #### Plot the train and test losses
plt.figure(figsize=(8, 6))
plt.plot(train_loss_dp, label='train_loss dropout', c='blue', ls='dashed')
plt.plot(train_loss, label='train_loss', c='red', ls='dashed')
plt.ylabel('loss')
plt.xlabel('epochs')
plt.title('dropout vs without dropout')
plt.legend()
plt.show()
```

```
# @markdown #### Plot model weights with epoch
plt.figure(figsize=(8, 6))
plt.plot(model_norm_dp, label='dropout')
plt.plot(model_norm, label='no dropout')
plt.ylabel('norm of the model')
plt.xlabel('epochs')
plt.legend()
plt.title('Size of the model vs Epochs')
plt.show()
```

Do you think this performed better than the initial model?

## Section 2.1: Dropout Implementation Caveats

*  Dropout is used only during training, during testing the complete model weights are used and hence it is important to use model.eval() before testing the model.

* Dropout reduces the capacity of the model during training and hence as a general practice wider networks are used when using dropout. If you are using a dropout with a random probability of 0.5 then you might want to double the number of hidden neurons in that layer.

Now, let's see how dropout fares on the Animal Faces Dataset. We first modify the existing model to include dropout and then train the model.

```
# Network Class - Animal Faces
class AnimalNetDropout(nn.Module):
  def __init__(self):
    super(AnimalNetDropout, self).__init__()
    self.fc1 = nn.Linear(3*32*32, 248)
    self.fc2 = nn.Linear(248, 210)
    self.fc3 = nn.Linear(210, 3)
    self.dropout1 = nn.Dropout(p=0.5)
    self.dropout2 = nn.Dropout(p=0.3)

  def forward(self, x):
    x = x.view(x.shape[0], -1)
    x = F.leaky_relu(self.dropout1(self.fc1(x)))
    x = F.leaky_relu(self.dropout2(self.fc2(x)))
    x = self.fc3(x)
    output = F.log_softmax(x, dim=1)
    return output
```

```
# Set the argumrnts
args = {
    'test_batch_size': 1000,
    'epochs': 200,
    'lr': 5e-3,
    'batch_size': 32,
    'momentum': 0.9,
    'device': DEVICE,
    'log_interval': 100
}

# intialize the model
set_seed(seed=SEED)
model = AnimalNetDropout()

# Train the model with Dropout
val_acc_dropout, train_acc_dropout, _, model_dp = main(args,
                                                       model,
                                                       train_loader,
                                                       val_loader,
                                                       img_test_dataset)

# intialize the BigAnimalNet model
set_seed(seed=SEED)
model = BigAnimalNet()

# Train the model
val_acc_big, train_acc_big, _, model_big = main(args,
                                                model,
                                                train_loader,
                                                val_loader,
                                                img_test_dataset)

# Train and Test accuracy plot
plt.plot(val_acc_big, label='Val - Big', c='blue', ls='dashed')
plt.plot(train_acc_big, label='Train - Big', c='blue', ls='solid')
plt.plot(val_acc_dropout, label='Val - DP', c='magenta', ls='dashed')
plt.plot(train_acc_dropout, label='Train - DP', c='magenta', ls='solid')
plt.title('Dropout')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')
plt.legend()
plt.show()
```

When do you think dropouts can perform bad and do you think their placement within a model matters?

---
# Section 3: Data Augmentation

```
# @title Video 3: Data Augmentation
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"BV1Xw411d7Pz", 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"nm44FhjL3xc", 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')

display(out)
```

Data augmentation is often used to increase the number of training samples. Now we will explore the effects of data augmentation on regularization. Here regularization is achieved by adding noise into training data after every epoch.

Pytorch's torchvision module provides a few built-in data augmentation techniques, which we can use on image datasets. Some of the techniques we most frequently use are:

*   Random Crop
*   Random Rotate
*   Vertical Flip
*   Horizontal Flip

```
# @markdown ####  Data Loader without Data Augmentation

# For reproducibility
g_seed = torch.Generator()
g_seed.manual_seed(SEED)

train_transform = transforms.Compose([
     transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
     ])
data_path = pathlib.Path('.')/'afhq' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)

# Splitting dataset
img_train_data, img_val_data,_ = torch.utils.data.random_split(img_dataset, [250,100,14280])

# Creating train_loader and Val_loader
train_loader = torch.utils.data.DataLoader(img_train_data,
                                           batch_size=batch_size,
                                           num_workers=2,
                                           worker_init_fn=seed_worker,
                                           generator=g_seed)
val_loader = torch.utils.data.DataLoader(img_val_data,
                                         batch_size=1000,
                                         num_workers=2,
                                         worker_init_fn=seed_worker,
                                         generator=g_seed)
```

Define a DataLoader using [torchvision.transforms](https://pytorch.org/docs/stable/torchvision/transforms.html) which randomly augments the data for us.

```
# Data Augmentation using transforms
new_transforms = transforms.Compose([
                                     transforms.RandomHorizontalFlip(p=0.1),
                                     transforms.RandomVerticalFlip(p=0.1),
                                     transforms.ToTensor(),
                                     transforms.Normalize((0.5, 0.5, 0.5),
                                                          (0.5, 0.5, 0.5))
                                     ])

data_path = pathlib.Path('.')/'afhq'  # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=new_transforms)
# Splitting dataset
new_train_data, _,_ = torch.utils.data.random_split(img_dataset,
                                                    [250, 100, 14280])

# For reproducibility
g_seed = torch.Generator()
g_seed.manual_seed(SEED)

# Creating train_loader and Val_loader
new_train_loader = torch.utils.data.DataLoader(new_train_data,
                                               batch_size=batch_size,
                                               worker_init_fn=seed_worker,
                                               generator=g_seed)
```

```
# Set the arguments
args = {
    'epochs': 250,
    'lr': 1e-3,
    'momentum': 0.99,
    'device': DEVICE,
}

# Intialize the model
set_seed(seed=SEED)
model_aug = AnimalNet()

# train the model
val_acc_dataaug, train_acc_dataaug, param_norm_datadug, _ = main(args,
                                                                 model_aug,
                                                                 new_train_loader,
                                                                 val_loader,
                                                                 img_test_dataset)
# Intialize the model
set_seed(seed=SEED)
model_pure = AnimalNet()

val_acc_pure, train_acc_pure, param_norm_pure, _, = main(args,
                                                         model_pure,
                                                         train_loader,
                                                         val_loader,
                                                         img_test_dataset)

# Train and Test accuracy plot
plt.figure(figsize=(8, 6))
plt.plot(val_acc_pure, label='Val Accuracy Pure',
         c='red', ls='dashed')
plt.plot(train_acc_pure, label='Train Accuracy Pure',
         c='red', ls='solid')
plt.plot(val_acc_dataaug, label='Val Accuracy data augment',
         c='blue', ls='dashed')
plt.plot(train_acc_dataaug, label='Train Accuracy data augment',
         c='blue', ls='solid')
plt.axhline(y=max(val_acc_pure), c='red', ls='dashed')
plt.axhline(y=max(val_acc_dataaug), c='blue', ls='dashed')
plt.title('Data Augmentation')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')
plt.legend()
plt.show()
```

```
# Plot together: without and with augmenetation
plt.figure(figsize=(8, 6))
plt.plot(param_norm_pure, c='red', label='Without Augmentation')
plt.plot(param_norm_datadug, c='blue', label='With Augmentation')
plt.title('Norm of parameters as a function of training epoch')
plt.xlabel('epoch')
plt.ylabel('Norm of model parameters')
plt.legend()
plt.show()
```

Can you think of more ways of augmenting training data? (Think of other problems beyond object recogition.)

### Think! 3.1: Thought Question
Why does it work better to regularize an overparameterized ANN than to start with a smaller one? Think about  the regularization  methods you know.
Each group has a 10 min discussion.

---
# Section 4: Stochastic Gradient Descent

```
# @title Video 4: SGD
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"BV1nM4y1K7wP", 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"rjzlFvJhNqE", 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')

display(out)
```

## Section 4.1: Learning Rate
In this section, we will see how learning rate can act as regularizer while training a neural network. In summary:

*   Smaller learning rates regularize less. They slowly converge to deep minima.
*   Larger learning rates regularizes more by missing local minima and converging to broader, flatter minima, which often generalize better.

But beware, a very large learning rate may result in overshooting or finding a really bad local minimum.

In the block below, we will train the Animal Net model with different learning rates and see how that affects the regularization.

```
# @markdown #### Generating Data Loaders

# For reproducibility
g_seed = torch.Generator()
g_seed.manual_seed(SEED)

batch_size = 128
train_transform = transforms.Compose([
     transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
     ])

data_path = pathlib.Path('.')/'afhq' # using pathlib to be compatible with all OS's
img_dataset = ImageFolder(data_path/'train', transform=train_transform)
img_train_data, img_val_data, = torch.utils.data.random_split(img_dataset, [11700,2930])

full_train_loader = torch.utils.data.DataLoader(img_train_data,
                                                batch_size=batch_size,
                                                num_workers=2,
                                                worker_init_fn=seed_worker,
                                                generator=g_seed)
full_val_loader = torch.utils.data.DataLoader(img_val_data,
                                              batch_size=1000,
                                              num_workers=2,
                                              worker_init_fn=seed_worker,
                                              generator=g_seed)

test_transform = transforms.Compose([
     transforms.ToTensor(),
     transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))    # [TO-DO]
     ])
img_test_dataset = ImageFolder(data_path/'val', transform=test_transform)
# img_test_loader = DataLoader(img_test_dataset, batch_size=batch_size,shuffle=False, num_workers=1)
classes = ('cat', 'dog', 'wild')
```

```
# Set the arguments
args = {
    'test_batch_size': 1000,
    'epochs': 350,
    'batch_size': 32,
    'momentum': 0.99,
    'device': DEVICE
}

learning_rates = [5e-4, 1e-3, 5e-3]
acc_dict = {}

for i, lr in enumerate(learning_rates):
    # Initialize the model
    set_seed(seed=SEED)
    model = AnimalNet()
    # Learning rate
    args['lr'] = lr
    # Train the model
    val_acc, train_acc, param_norm, _ = main(args,
                                             model,
                                             train_loader,
                                             val_loader,
                                             img_test_dataset)
    # store the outputs
    acc_dict[f'val_{i}'] = val_acc
    acc_dict[f'train_{i}'] = train_acc
    acc_dict[f'param_norm_{i}'] = param_norm
```

```
# @markdown #### Plot Train and Validation accuracy (Run me)
plt.figure(figsize=(8, 6))
plt.plot(acc_dict['val_0'], linestyle='dashed',
         label='lr = 5e-4 - validation',
         c='blue')
plt.plot(acc_dict['train_0'], label = '5e-4 - train', c='blue')
plt.plot(acc_dict['val_1'], linestyle='dashed',
         label='lr = 1e-3 - validation',
         c='green')
plt.plot(acc_dict['train_1'], label='1e-3 - train', c='green')
plt.plot(acc_dict['val_2'], linestyle='dashed',
         label='lr = 5e-3 - validation',
         c='purple')
plt.plot(acc_dict['train_2'],label = '5e-3 - train', c='purple')
plt.title('Optimal Learning Rate')
plt.ylabel('Accuracy (%)')
plt.xlabel('Epoch')

print(f"Maximum Test Accuracy obtained with lr = 5e-4: {max(acc_dict['val_0'])}")
print(f"Maximum Test Accuracy obtained with lr = 1e-3: {max(acc_dict['val_1'])}")
print(f"Maximum Test Accuracy obtained with lr = 5e-3: {max(acc_dict['val_2'])}")
plt.legend()
plt.show()
```

```
# @markdown #### Plot parametric norms (Run me)
plt.figure(figsize=(8, 6))
plt.plot(acc_dict['param_norm_0'],label='lr = 5e-4',c='blue')
plt.plot(acc_dict['param_norm_1'],label = 'lr = 1e-3',c='orange')
plt.plot(acc_dict['param_norm_2'],label ='lr = 5e-3', c='magenta')
plt.legend()
plt.xlabel('epoch')
plt.ylabel('parameter norms')
plt.show()
```

In the model above, we observe something different from what we expected. Why do you think this is happening?

---
# Section 5: Hyperparameter Tuning

```
# @title Video 5: Hyperparameter tuning
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"BV1E44y127Sn", 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"HgkiKRYc-3A", 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')

display(out)
```

Hyper-Parameter tuning is often difficult and time consuming.  It is a key part of training any Deep Learning model to give good generalization. There are a few techniques that we can use to guide us during the search.

*   Grid Search: Try all possible combinations of hyperparameters
*   Random Search: Randomly try different combinations of hyperparameters
*   Coordinate-wise Gradient Descent: Start at one set of hyperparameters and try changing one at a time, accept any changes that reduce your validation error
*   Bayesian Optimization/ Auto ML:  Start from a set of hyperparameters that have worked well on a similar problem, and then do some sort of local exploration (e.g., gradient descent) from there.

There are lots of choices, like what range to explore over, which parameter to optimize first, etc. Some hyperparameters don’t matter much (people use a dropout of either 0.5 or 0, but not much else).  Others can matter a lot more (e.g., size and depth of the neural net). The key is to see what worked on similar problems.

One can automate the process of tuning the network Architecture using "Neural Architecture Search", which designs new architectures using a few building blocks (Linear, Convolutional, Convolution Layers, etc.) and optimizes the design based on performance using a wide range of techniques such as Grid Search, Reinforcement Learning, GD, Evolutionary Algorithms, etc. This obviously requires very high computer power. Read this [article](https://lilianweng.github.io/lil-log/2020/08/06/neural-architecture-search.html) to learn more about NAS.

Which regularization technique today do you think had the biggest effect on the network? Why might do you think so? Can you apply all of the regularization methods on the same network?

---
# Section 6: Adversarial  Attacks

```
# @title Video 6: Adversarial Attacks
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"BV19o4y1X74u", 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"LzPPoiKi5jE", 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')

display(out)
```

Designing perturbations to the input data to trick a machine learning model is called an "adversarial attack". These attacks are an inevitable consequence of learning in high dimensional space with complex decision boundaries. Depending on the application, these attacks can be very dangerous.

Hence, it is important for us to build models which can defend against such attacks. One possible way to do it is by regularizing the networks, which smooths the decision boundaries. A few ways of building models robust to such attacks are:

*   [Defensive Distillation](https://deepai.org/machine-learning-glossary-and-terms/defensive-distillation) : Models trained via distillation are less prone to such attacks as they are trained on soft labels as there is an element of randomness in the training process.
*   [Feature Squeezing](https://evademl.org/squeezing/): Identifies adversarial attacks for on-line classifiers whose model is being used by comparing model's perdiction before and after squeezing the input.
* [SGD](https://arxiv.org/abs/1706.06083) You can also pick weight to minimize what the adversary is trying to maximize via SGD.

In the optional supplemental project, you can design an attack and defend your model against it using regularization techniques you learned this week.

---
# Optional Supplements

1.   [Understanding Generalization](https://docs.google.com/document/d/1XOaTXYBleQlDNFM1-t512RHfJXRwA4-LIejuBA6pbLY/edit)
2.   [Adversarial Attacks](https://openai.com/blog/adversarial-example-research/)


##### Copyright 2021-2022 Digital Advantage - Deep Insider.

```python
#@title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
```

連載『ニューラルネットワーク入門』
# 「NumPyでニューラルネットワークをフルスクラッチ実装してみよう」

     Deep Insiderで記事を読む

     Google Colabで実行する

     AWS  SageMaker Studio Labで実行する

     GitHubでソースコードを見る

※上から順に実行してください。上のコードで実行したものを再利用しているところがあるため、すべて実行しないとエラーになるコードがあります。
　すべてのコードを一括実行したい場合は、Colabであればメニューバーから［ランタイム］－［すべてのセルを実行］をクリックしてください。

## ■本ノートブックの目的

ニューラルネットワーク（以下、ニューラルネット）の仕組みを、数学理論と**Python＋NumPyのコードから学ぶ**ことを狙っています。「線形代数を使ったニューラルネットワークの基礎を押さえたい！」という方にピッタリです。

### ●本ノートブックのポイント

- 線形代数（linear algebra、行列演算）、つまりNumPyを使用します。
- 「[Pythonでニューラルネットワークを書いてみよう](https://atmarkit.itmedia.co.jp/ait/articles/2202/09/news027.html)」（基礎編）で`for`ループを使っていた繰り返し処理部分のコードを、NumPyを使うコードに置き換えるだけにしています。
- 基礎編と同様に、入力データはバッチサイズの行列（二次元配列）では扱わず、1件ずつのベクトル（一次元配列）で扱います（その分、よりシンプルな線形代数の計算式となっています）。
- ※微分の導関数は、そのままコードとして記載することで、微分の計算は取り上げません。

※以下の3つの図に記載された数式の意味は後述します。

## ■NumPyのインポート

今回はNumPyを利用するため、`numpy`モジュールをインポートします。

```python
import numpy as np
```

## ■訓練（学習）処理全体の実装

深層「学習」のメインは、ニューラルネットの「訓練」処理ですよね。ここから書いていきます。

```python
# 取りあえず仮で、空の関数を定義して、コードが実行できるようにしておく
def forward_prop(cache_mode=False):
    " 順伝播を行う関数。"
    return None, None, None

y_true = np.array([1.0])  # 正解値
def back_prop(y_true, cached_outs, cached_sums):
    " 逆伝播を行う関数。"
    return None, None

LEARNING_RATE = 0.1 # 学習率（lr）
def update_params(grads_w, grads_b, lr=0.1):
    " パラメーター（重みとバイアス）を更新する関数。"
    return None, None

# ---ここまでは仮の実装。ここからが必要な実装---

# 訓練処理
y_pred, cached_outs, cached_sums = forward_prop(cache_mode=True)  # （1）
grads_w, grads_b = back_prop(y_true, cached_outs, cached_sums)  # （2）
weights, biases = update_params(grads_w, grads_b, LEARNING_RATE)  # （3）

print(f'予測値：{y_pred}')  # 予測値： None
print(f'正解値：{y_true}')  # 正解値：[1.]
```

ニューラルネットの訓練に必要なことは、以下の3つだけ。

1. **順伝播：** `forward_prop()◎`数として実装
2. **逆伝播：** `back_prop()`関数として実装。損失（予測と正解の誤差）の計算はここで行う
3. **パラメーター（重みとバイアス）の更新：** `update_params()`関数として実装。これによりモデルが**最適化**される

##■モデルの定義と、仮の訓練データ

入力層のノードが2個、隠れ層のノードが3個、出力層のノードが1個のモデル（`model`変数）を定義してみましょう。

```python
# ニューラルネットワークは3層構成
layers = [
    2,  # 入力層の入力（特徴量）の数
    3,  # 隠れ層1のノード（ニューロン）の数
    1]  # 出力層のノードの数

# 重みとバイアスの初期値
weights = [
    np.array([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]), # 入力層→隠れ層1
    np.array([[0.0, 0.0, 0.0]]) # 隠れ層1→出力層
]
biases = [
    np.array([0.0, 0.0, 0.0]),  # 隠れ層1
    np.array([0.0])  # 出力層
]

# モデルを定義
model = (layers, weights, biases)

# 仮の訓練データ（1件分）を準備
x = np.array([0.05, 0.1])  # x_1とx_2の2つの特徴量
```

## ■おまけ：重みやバイアスの初期値をNumPyで自動生成する方法

```python
# ニューラルネットワークは3層構成
layers = [
    2,  # 入力層の入力（特徴量）の数
    3,  # 隠れ層1のノード（ニューロン）の数
    1]  # 出力層のノードの数

def init_parameters(layers):
    weights = []
    biases = []

    n_prev_nodes = 1
    for layer_i, n_curr_nodes in enumerate(layers):
        if layer_i == 0:
            n_prev_nodes = n_curr_nodes
            continue

        w = np.zeros((n_curr_nodes, n_prev_nodes))
        b = np.zeros(n_curr_nodes)

        weights.append(w)
        biases.append(b)

        n_prev_nodes = n_curr_nodes

    return (weights, biases)

weights, biases = init_parameters(layers)
print(f'weights={weights}'.replace('\n      ', ''))
print(f'biases={biases}')
# weights=[array([[0., 0.], [0., 0.], [0., 0.]]), array([[0., 0., 0.]])]
# biases=[array([0., 0., 0.]), array([0.])]
```

## ■ステップ1. 順伝播の実装

### ●1つの層における順伝播の処理

「1つの層」内にある「全ノード」における順伝播の処理をまとめてコーディングします。

```python
# 取りあえず仮で、空の関数を定義して、コードが実行できるようにしておく
def summation(x, W, b):
    " 重み付き線形和の関数。"
    return np.array([0.0])

def sigmoid(x):
    " シグモイド関数。"
    return x

def identity(x):
    " 恒等関数。"
    return x

W = np.array([[0.0, 0.0]])  # 重み（仮の値）
b = np.array([0.0])  # バイアス（仮の値）

next_x = x  # 訓練データをノードへの入力に使う

# ---ここまでは仮の実装。ここからが必要な実装---

# 1つの層内にある全ノードの処理（1）： 重み付き線形和 u=Σx_i*w_i+b
sums = summation(next_x, W, b)

# 1つの層内にある全ノードの処理（2）： 活性化関数 z=f(u)
is_hidden_layer = True
if is_hidden_layer:
    # 隠れ層（シグモイド関数）
    outs = sigmoid(sums)
else:
    # 出力層（恒等関数）
    outs = identity(sums)
```

1つのノードの順伝播処理に必要なことは、以下の2つの数学関数だけ。

1. **重み付き線形和の関数：** `summation()`関数として実装。$u=\sum_{i=1}^{n} x_i w_i + b$
2. **活性化関数：** ここでは`sigmoid()`関数や`identity()`関数として実装。$z=f(u)$

行列を使うことで、1つの層内にある全てのノード分の値を並列的にまとめて計算し、その結果を`sums`と`outs`という変数にまとめて格納しています。

### ●重み付き線形和

重み付き線形和（weighted linear summation、以下では「線形和」と表記）とは、あるノードへの複数の入力（$x_1$、$x_2$など）に、それぞれの重み（$w_11$、$w_21$など）を掛けて足し合わせて、最後にバイアス（$b_1$）を足した値です（上の図の左）。

今回の実装における、入力／重み／バイアスの行列内容について確認しておきます。

- __入力：__ $\boldsymbol{x}=\left(
\begin{array}{c}
x_1 \\
x_2 \\
\vdots \\
x_m
\end{array}
\right)$ のようなベクトル

- __重み：__ $\boldsymbol{W}=\left(
\begin{array}{cccc}
w_{11} & w_{21} & \ldots & w_{m1} \\
w_{12} & w_{22} & \ldots & w_{m2} \\
\vdots & \vdots & \ddots & \vdots \\
w_{1n} & w_{2n} & \ldots & w_{mn}
\end{array}
\right)$ のような行列

- __バイアス：__ $\boldsymbol{b}=\left(
\begin{array}{c}
b_1  \\
b_2  \\
\vdots  \\
b_n
\end{array}
\right)$ のようなベクトル

※一般的な行列の定義は、以下のようになりますが、上の$\boldsymbol{W}$はこれを転置した形になっている点に注意してください。前の層を基準に重みを並べると下のようになりますが（＝前の層のノード$1$～$m$の$m$行が縦に並ぶ）、今の層を基準に重みを並べると上のようになります（＝今の層のノード$1$～$n$の$n$行が縦に並ぶ）。
$$
\left(
\begin{array}{cccc}
w_{11} & w_{12} & \ldots & w_{1n} \\
w_{21} & w_{22} & \ldots & w_{2n} \\
\vdots & \vdots & \ddots & \vdots \\
w_{m1} & w_{m2} & \ldots & w_{mn}
\end{array}
\right)=\boldsymbol{W}^{\top}
$$

ベクトルである$\boldsymbol{x}$（入力）と$\boldsymbol{b}$（バイアス）、行列である$\boldsymbol{W}$（重み）の3つの変数を使って上の図のような重み付き線形和の計算式を成立させるには次のような計算式を組み立てればよいです。これが冒頭の図1に掲載した数式です。
$$
\begin{align}
\boldsymbol{u}&=\boldsymbol{W}\boldsymbol{x}+\boldsymbol{b} \\
&=\left(
\begin{array}{cccc}
w_{11} & w_{21} & \ldots & w_{m1} \\
w_{12} & w_{22} & \ldots & w_{m2} \\
\vdots & \vdots & \ddots & \vdots \\
w_{1n} & w_{2n} & \ldots & w_{mn}
\end{array}
\right)\left(
\begin{array}{c}
x_{1} \\
x_{2} \\
\vdots \\
x_{m}
\end{array}
\right)+\left(
\begin{array}{c}
b_{1} \\
b_{2} \\
\vdots \\
b_{n}
\end{array}
\right) \\
&=\left(
\begin{array}{cccc}
x_1 w_{11}+x_2 w_{21}+\ldots +x_m w_{m1}+b_1 \\
x_1 w_{12}+x_2 w_{22} + \ldots +x_m w_{m2}+b_2 \\
\vdots\\
x_1 w_{1n}+x_2 w_{2n} + \ldots +x_m w_{mn}+b_n
\end{array}
\right)
\end{align}
$$

```python
def summation(x, W, b):
    """
    重み付き線形和の関数。
    ※1データ分×全ノード数分を処理する前提。
    - 引数：
    x： 入力データを一次元配列値（各要素はfloat値）で指定する。
    W： 重みを二次元配列値（各要素はfloat値）で指定する。
    b： バイアスを一次元配列値（各要素はfloat値）で指定する。
    - 戻り値：
    線形和の計算結果を一次元配列値（各要素はfloat値）で返す。
    """
    linear_sums = np.dot(W, x) + b
    # linear_sums = np.dot(x, W.T) + b  # こう書いてもOK
    return linear_sums
```

コメントアウトした行にあるように`np.dot(x, W.T) + b`と書いた場合は、以下の計算式になり、結果は同じです。

$$
\begin{align}
\boldsymbol{u}&=\boldsymbol{x}\boldsymbol{W}^{\top}+\boldsymbol{b} \\
&=\left(
\begin{array}{cccc}
x_{1}, & x_{2}, & \cdots, & x_{m}
\end{array}
\right)\left(
\begin{array}{cccc}
w_{11} & w_{12} & \ldots & w_{1n} \\
w_{21} & w_{22} & \ldots & w_{2n} \\
\vdots & \vdots & \ddots & \vdots \\
w_{m1} & w_{m2} & \ldots & w_{mn}
\end{array}
\right)+\left(
\begin{array}{c}
b_{1} \\
b_{2} \\
\vdots \\
b_{n}
\end{array}
\right) \\
&=\left(
\begin{array}{cccc}
x_1 w_{11}+x_2 w_{21}+\ldots +x_m w_{m1}+b_1 \\
x_1 w_{12}+x_2 w_{22} + \ldots +x_m w_{m2}+b_2 \\
\vdots\\
x_1 w_{1n}+x_2 w_{2n} + \ldots +x_m w_{mn}+b_n
\end{array}
\right)
\end{align}
$$

同様の要領で、他に定義する関数も行列／ベクトル対応にしていきましょう。次に、次回の逆伝播（の中で使う偏微分）で必要となる線形和（linear **sum**mation）の偏導関数（partial **der**ivative function）を実装しておきます。重み付き線形和の偏導関数にもベクトル（$\boldsymbol{x}$／$\boldsymbol{b}$）や行列（$\boldsymbol{W}$）を指定できるようにします。

```python
def sum_der(x, W, b, with_respect_to='W'):
    """
    重み付き線形和の関数の偏導関数。
    ※1データ分×全ノード数分を処理する前提。
    - 引数：
    x： 入力データを一次元配列値で指定する。
    W： 重みを二次元配列値で指定する。
    b： バイアスを一次元配列値で指定する。
    with_respect_to: 何に関して偏微分するかを指定する。
       'W'＝ 重み、'b'＝ バイアス、'x'＝ 入力。
    - 戻り値：
    with_respect_toが、
        'W'の場合は二次元配列値（行ベクトル）で、
        'b'の場合は一次元配列値（ベクトル）で、
        'x'の場合は二次元配列値（行列）で、
        線形和の偏微分の計算結果（偏微分係数）を返す。
    """
    if with_respect_to == 'W':
        return x.reshape(1, len(x))  # 線形和uを各重みw_ijで偏微分するとx_iになる（iはノード番号）
    elif with_respect_to == 'b':
        return np.ones(len(b))  # 線形和uをバイアスb_jで偏微分すると1になる
    elif with_respect_to == 'x':
        return W  # 線形和uを各入力x_iで偏微分するとw_ijになる

```

理論的にバイアス（$\boldsymbol{b}$）は、前の層には関係がなく「今の層のノード数（$j=1,2,\cdots,n$の$n$個）」だけ計算すればよいので、計算結果は$n$個の要素を持つベクトル（一次元配列）になります。

一方で、重み（$\boldsymbol{W}$）や入力（$\boldsymbol{x}$）は、「今の層のノード数（$n$個）」×「前の層のノード数（$i=1,2,\cdots,m$の$m$個）」を計算するので、計算結果は$n$行$m$列の行列（二次元配列）になります。

しかし上のコードでは、重み（$\boldsymbol{W}$）が$n$行$m$列の行列ではなく、$1$行$m$列の行ベクトルになっている点に注意してください。これは実際には$j$行$m$列を意図しており、今の層の何ノード目（$j=1,2,\cdots,n$）であっても、計算結果が同じ値となるので、しかも行ベクトルの方が逆伝播のNumPyによる計算がしやすかったので、$1$行に要約しました。

### ●活性化関数：シグモイド関数

隠れ層では、最も基礎的なシグモイド関数（Sigmoid function）を固定的に使うことにします。導関数も実装しておきます。

$$
f(x) = \frac{1}{1+e^{-x}}
$$

```python
def sigmoid(x):
    """
    シグモイド関数。
    - 引数：
    x： 入力データを一次元配列値で指定する。
    - 戻り値：
    シグモイド関数の計算結果を一次元配列値で返す。
    """
    return 1.0 / (1.0 + np.exp(-x))
```

$$
 f'(x)=f(x)(1-f(x))
$$

```python
def sigmoid_der(x):
    """
    シグモイド関数の（偏）導関数。
    - 引数：
    x： 入力データを一次元配列値で指定する。
    - 戻り値：
    シグモイド関数の（偏）微分の計算結果（微分係数）を一次元配列値で返す。
    """
    output = sigmoid(x)
    return output * (1.0 - output)
```

### ●活性化関数：恒等関数

出力層では、回帰問題をイメージして、そのままの値を出力する活性化関数である恒等関数（Identity function）を使用します。導関数も実装しておきます。

$$
f(x) = x
$$

```python
def identity(x):
    """
    恒等関数の関数。
    - 引数：
    x： 入力データを一次元配列値で指定する。
    - 戻り値：
    恒等関数の計算結果（そのまま）を一次元配列値で返す。
    """
    return x
```

$$
f'(x) = 1
$$

```python
def identity_der(x):
    """
    恒等関数の（偏）導関数。
    - 引数：
    x： 入力データを一次元配列値で指定する。
    - 戻り値：
    恒等関数の（偏）微分の計算結果（微分係数）を一次元配列値で返す。
    """
    return np.ones(len(x))
```

### ●順伝播の処理全体の実装

ニューラルネットには、層があり、その中に複数のノードが存在するという構造です。従って、

- 各層を1つずつ処理する`for`ループと、
  - 層の中の全ノードをまとめて処理する行列計算、の2段階構造が必要で、
    - 行列計算を使った「順伝播の処理」

を記述すればよいわけです。

```python
def forward_prop(layers, weights, biases, x, cache_mode=False):
    """
    順伝播を行う関数。
    - 引数：
    (layers, weights, biases)： モデルを指定する。
    x： 入力データ（一次元配列値）を指定する。
    cache_mode： 予測時はFalse、訓練時はTrueにする。これにより戻り値が変わる。
    - 戻り値：
    cache_modeがFalse時は予測値のみを返す。True時は、予測値だけでなく、
        キャッシュに記録済みの線形和（Σ）値と、活性化関数の出力値も返す。
    """

    cached_sums = []  # 記録した全ノードの線形和（Σ）の値
    cached_outs = []  # 記録した全ノードの活性化関数の出力値

    # まずは、入力層を順伝播する
    # print(f'■第1層（入力層）-全て（{len(x)}個）の特徴量：')
    # print(f'　●入力データ： ', end='')
    cached_outs.append(x)  # 何も処理せずに出力値を記録
    # print(f'何もしない＝out({x})')
    next_x = x  # 現在の層の出力（x）＝次の層への入力（next_x）

    # 次に、隠れ層や出力層を順伝播する
    SKIP_INPUT_LAYER = 1
    for layer_i, layer in enumerate(layers):  # 各層を処理
        if layer_i == 0:
            continue  # 入力層は上で処理済み

        # 層ごとに全ノードまとめて処理を行う
        sums = []  # 現在の層の全ノードの線形和
        outs = []  # 現在の層の全ノードの（活性化関数の）出力
        # print(f'■第{layer_i+1}層-全ノード：')

        # 層ごとに全ノードの重みとバイアスを取得
        W = weights[layer_i - SKIP_INPUT_LAYER]
        b = biases[layer_i - SKIP_INPUT_LAYER]

        # 1つの層内にある全ノードの処理（1）： 重み付き線形和
        # print(f'　●重み付き線形和： ', end='')
        sums = summation(next_x, W, b)
        # print(f'W({W})・x({next_x})＋b({b})＝sum({sums})'.replace('\n', ''))

        # 1つの層内にある全ノードの処理（2）： 活性化関数
        if layer_i < len(layers)-1:  # -1は出力層以外の意味
            # 隠れ層（シグモイド関数）
            # print(f'　●活性化関数（隠れ層はシグモイド関数）： ', end='')
            outs = sigmoid(sums)
            # print(f'sigmoid({sums})＝out({outs})')
        else:
            # 出力層（恒等関数）
            # print(f'　●活性化関数（出力層は恒等関数）： ', end='')
            outs = identity(sums)
            # outs = sigmoid(sums)
            # print(f'identity({sums})＝out({outs})')

        # 各層内の全ノードの線形和と出力を記録
        cached_sums.append(sums)
        cached_outs.append(outs)
        next_x = outs  # 現在の層の出力（outs）＝次の層への入力（next_x）

    if cache_mode:
        return (cached_outs[-1], cached_outs, cached_sums)

    return cached_outs[-1]

# 訓練時の（1）順伝播の実行例
y_pred, cached_outs, cached_sums = forward_prop(*model, x, cache_mode=True)
# ※先ほど作成したモデルと訓練データを引数で受け取るよう改変した

print(f'cached_outs={cached_outs}')
print(f'cached_sums={cached_sums}')
# 出力例：
# cached_outs=[array([0.05, 0.1 ]), array([0.5, 0.5, 0.5]), array([0.])]  # 入力層／隠れ層1／出力層
# cached_sums=[array([0., 0., 0.]), array([0.])]  # 隠れ層1／出力層（※入力層はない）
```

数値が**0.0**ばかりなので、別の計算パターンのコードも入れておきました。

```python
# 記事にはないが、別の計算パターンでもチェックしてみよう
x3 = np.array([0.05, 0.1])
layers3 = [2, 2, 2]
weights3 = [
    np.array([[0.15, 0.2], [0.25, 0.3]]),
    np.array([[0.4, 0.45], [0.5,0.55]])
]
biases3 = [
    np.array([0.35, 0.35]),
    np.array([0.6, 0.6])
]
model3 = (layers3, weights3, biases3)

y_pred3, cached_outs3, cached_sums3 = forward_prop(*model3, x3, cache_mode=True)
print(f'y_pred={y_pred3}')
print(f'cached_outs={cached_outs3}')
print(f'cached_sums={cached_sums3}')
# y_pred=[1.10590597 1.2249214 ]
# cached_outs=[array([0.05, 0.1 ]), array([0.59326999, 0.59688438]), array([1.10590597, 1.2249214 ])]
# cached_sums=[array([0.3775, 0.3925]), array([1.10590597, 1.2249214 ])]
```

ここまでの順伝播のコード中に仕込んでいる`print()`関数（※全てコメントアウトしています）のコメントを解除すると、以下のように途中の計算内容が順番にテキスト出力されます。計算内容の検証用の機能です。

### ●順伝播による予測の実行例

非常にシンプルで原始的な実装ですが、このように任意の層数とノード数の全結合のDNN（Deep Neural Network）のアーキテクチャーを定義して、DNNモデルによる予測が行えます。

```python
# 異なるDNNアーキテクチャーを定義してみる
layers2 = [
    2,  # 入力層の入力（特徴量）の数
    3,  # 隠れ層1のノード（ニューロン）の数
    2,  # 隠れ層2のノード（ニューロン）の数
    1]  # 出力層のノードの数

# 重みとバイアスの初期値
weights2 = [
    np.array([[-0.2, 0.4], [-0.4, -0.5], [-0.4, -0.5]]), # 入力層→隠れ層1
    np.array([[-0.2, 0.4, 0.9], [-0.4, -0.5, -0.2]]), # 隠れ層1→隠れ層2
    np.array([[-0.5, 1.0]]) # 隠れ層2→出力層
]
biases2 = [
    np.array([0.1, -0.1, 0.1]),  # 隠れ層1
    np.array([0.2, -0.2]),  # 隠れ層2
    np.array([0.3])  # 出力層
]

# モデルを定義
model2 = (layers2, weights2, biases2)

# 仮の訓練データ（1件分）を準備
x2 = np.array([2.3, 1.5])  # x_1とx_2の2つの特徴量

# 予測時の（1）順伝播の実行例
y_pred = forward_prop(*model2, x2)
print(y_pred)  # [0.38288404]
```

### ●今後のステップの準備：関数への仮引数の追加

```python
def back_prop(layers, weights, biases, y_true, cached_outs, cached_sums):
    " 逆伝播を行う関数。"
    return None, None

def update_params(layers, weights, biases, grads_w, grads_b, lr=0.1):
    " パラメーター（重みとバイアス）を更新する関数。"
    return None, None
```

## ■ステップ2. 逆伝播の実装

### ●逆伝播の目的と全体像

逆伝播の目的は、誤差（厳密には予測値に関する損失関数の偏微分係数）などの数値（本ノートブックでは**誤差情報**と呼ぶ）をニューラルネットに逆方向で流すこと（＝逆伝播）によって「**重みとバイアスの勾配を計算すること**」です（下の図）。

順伝播（`forward_prop()`関数）では、計算途中に出た計算結果である「予測値（`y_pred`）」や「各ノードでの活性化関数の出力値（`cached_outs`）」と「線形和の値（`cached_sums`）」を返すだけでした。

逆伝播（`back_prop()関数`）では、計算途中に出た計算結果である「各ノードへの入力の勾配（＝逆伝播していく誤差情報）」だけでなく、「各重みの勾配（`grads_w`）」「各バイアスの勾配（`grads_b`）」の計算も必要です（下の図）。

逆伝播では、$x_1$／$w_1$／$x_2$／$w_2$／……／$x_n$／$w_n$／$b$という大量の変数に関して、損失関数の偏微分係数（＝勾配）を計算する必要があります。

### ●損失関数：二乗和誤差

損失関数として、最も基礎的な二乗和誤差（SSE：Sum of Squared Error）を使うことにします。導関数も実装しておきます。

$$
\begin{align}
L &= \frac{1}{2}\sum_{i=1}^{n}(\hat{y}_{i}-y_{i})^{2} \\
&\color{gray}{= \frac{1}{2}\sum_{i番目=1から}^{データ数まで総和}(予測値_{i番目}-正解値_{i番目　})^{2乗}}
\end{align}
$$

総和（$\Sigma$）する処理は関数外で行う仕様のため、リスト13では総和していません。次の導関数（リスト14）も同様の仕様です。

```python
def sseloss(y_pred, y_true):
    """
    二乗和誤差（Sum of Squared Error）の関数。
    - 引数：
    y_pred： モデルの最終出力値＝予測値（predicted value）を一次元配列値で指定する。
    y_true： 目的となる値＝正解値（true/actual value）を一次元配列値で指定する。
    - 戻り値：
    二乗和誤差の計算結果を一次元配列値で返す。
    """
    return 0.5 * (y_pred - y_true) ** 2
```

$$
\begin{align}
\frac{\partial L}{\partial\hat{y}} &= \sum_{i=1}^{n}(\hat{y}_{i}-y_{i}) \\
&\color{gray}{= \sum_{i番目=1から}^{データ数まで総和}{予測値_{i番目}-正解値_{i番目　}}}
\end{align}
$$

$\partial$は偏微分を表し、「ラウンドディー」などと呼びます。

```python
def sseloss_der(y_pred, y_true):
    """
    二乗和誤差（Sum of Squared Error）の偏導関数。
    予測値（y_pred）に関して二乗和誤差関数（sseloss()）を偏微分する。
    - 引数：
    y_pred： モデルの最終出力値＝予測値（predicted value）を一次元配列値で指定する。
    y_true： 目的となる値＝正解値（true/actual value）を一次元配列値で指定する。
    - 戻り値：
    二乗和誤差の偏微分の計算結果（偏微分係数）を一次元配列値で返す。
    """
    return y_pred - y_true
```

偏導関数の式`y_pred - y_true`は、予測値と正解値の「誤差（Error、ズレ）」となっています。

**誤差逆伝播法**（error backpropagation）とは、この「誤差」の数値（厳密には、予測値に関しての損失関数の偏微分係数）が誤差情報としてニューラルネットを「逆」向きに「伝播」していく過程で、本来の目的である各重みと各バイアスの勾配を求める方法です。

### ●1つのノードにおける逆伝播の処理

基本的なニューラルネットワークでは、線形和関数／活性化関数／損失関数の3つの関数を使います。これら3つの関数の関係をPythonコード的に表現すると、

　　Loss(  # 損失関数。数式では$L$と表記
　　　　activation(  # 活性化関数（出力層にある$j$番目のノード）。数式では$z_j$と表記
　　　　　　summation(  # 線形和関数。数式では$u_j$と表記
　　　　　　　　next_x,  # ノードへの入力
　　　　　　　　w,  # 重み
　　　　　　　　b  # バイアス
　　　　　　)
　　　　)
　　)

のような入れ子構造になっています。

`損失関数( 活性化関数( 線形和関数( 入力、重み、バイアス ) ) )`という入れ子の関数は数学で**合成関数**と呼ばれます。

合成関数を微分するときの公式が**連鎖律**です。連鎖律を使うと、まるでマジックのように各関数の偏微分係数の掛け算だけの式に変化します（下の図）。

※損失関数の数式では予測値、つまり出力層の「活性化関数」の出力値を$\hat{y}$と表現しましたが、この図では（出力層にある$j$番目のノードにおける）「活性化関数」をコード的に$activation_j()$、数式では$z_j$と表現しています。また、（出力層にある$j$番目のノードにおける）「線形和関数」をコード的に$summation_j()$、数式では$u_j$と表現しています。

上の図は各重みに関して損失関数を偏微分する例ですが、各バイアスや各入力に関して損失関数を偏微分する際も連鎖律の形はほぼ同じです（下の図）。ただし入力については、前の層のノードごとに、今の層からの全てのエッジから来る各誤差情報（偏微分係数）を合計する必要があるので注意してください。

上の図を数式で表現すると次のようになります。

$$
\begin{align}
\frac{\partial L}{\partial w_{ij}} &= \frac{\partial L}{\partial z_j}\frac{\partial z_j}{\partial u_j}\frac{\partial u_j}{\partial w_{ij}}  \\
\frac{\partial L}{\partial b_{j}} &= \frac{\partial L}{\partial z_j}\frac{\partial z_j}{\partial u_j}\frac{\partial u_j}{\partial b_{j}} \\
\frac{\partial L}{\partial x_{i}} &=  \sum_{j=1}^{n}(\frac{\partial L}{\partial z_j}\frac{\partial z_j}{\partial u_j}\frac{\partial u_j}{\partial x_{i}})
\end{align}
$$

共通する計算の部分を抽出すると、次のように（今の層にある$j$番目のノードにおける）$\delta$（デルタ）の数式を定義できますね。

$$
\delta_j = \frac{\partial L}{\partial z_j}\frac{\partial z_j}{\partial u_j}
$$

よって最終的には、よりシンプルに次の式にまとめられます。これらが冒頭の図2に掲載した数式です。

$$
\begin{align}
\frac{\partial L}{\partial w_{ij}} &= \delta_j\frac{\partial u_j}{\partial w_{ij}}  \\
\frac{\partial L}{\partial b_{j}} &= \delta_j\frac{\partial u_j}{\partial b_{j}} \\
\frac{\partial L}{\partial x_{i}} &=  \sum_{j=1}^{n}(\delta_j\frac{\partial u_j}{\partial x_{i}})
\end{align}
$$

ここまでの説明は出力層におけるものですが、他の層でも同様の計算式になるので共通化することが可能です。具体的に各層における各ノードの計算は、

　　「逆伝播していく誤差情報」×「活性化関数の偏微分」×「線形和関数の偏微分」

という掛け算に共通化できます（下の図）。

出力層から隠れ層まで全て、以下の4工程のワンパターンで実装できます（下の図）。

- （1）逆伝播していく誤差情報
- （2）活性化関数を偏微分
- （3）線形和を重み／バイアス／入力で偏微分
- （4）各重み／バイアス／各入力の勾配を計算

※（1）～（4）は層ごとにまとめた処理として実装していきます。

### ●（1）逆伝播していく誤差情報

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
layer_i = 2  # 2：出力層、1：隠れ層1、0：入力層
layer_max_i = 2  # 最後の層（＝出力層）のインデックス
is_output_layer = (layer_i == layer_max_i)  # 出力層か（True）、隠れ層か（False）

# 入力層／隠れ層1／出力層にある各ノードの（活性化関数の）出力値
cached_outs = [
    np.array([0.05, 0.1]),
    np.array([0.5, 0.5, 0.5]),
    np.array([0.0])
]
y_true = np.array([1.0])  # 正解値
grads_x = []  # 入力の勾配
# ---ここまでは仮の実装。ここからが必要な実装---

if is_output_layer:
    # 出力層（損失関数の偏微分係数）
    y_pred = cached_outs[layer_i]
    back_error = sseloss_der(y_pred, y_true)  # 逆伝播していく誤差情報
else:
    # 隠れ層（次の層への入力の偏微分係数）
    back_error = grads_x[-1]  # 最後に追加された入力の勾配

# print(f'back_error={back_error}')  # back_error=[-1.]
```

### ●（2）活性化関数を偏微分

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
SKIP_INPUT_LAYER = 1  # 入力層を飛ばす
cached_sums = [
    np.array([0.0, 0.0, 0.0]),  # 隠れ層1
    np.array([0.0])  # 出力層（※入力層はない）
]
layer_sums = cached_sums[layer_max_i - SKIP_INPUT_LAYER]  # 出力層
# ---ここまでは仮の実装。ここからが必要な実装---

if is_output_layer:
    # 出力層（恒等関数の微分）
    active_der = identity_der(layer_sums)
else:
    # 隠れ層（シグモイド関数の微分）
    active_der = sigmoid_der(layer_sums)

# print(f'active_der={active_der}')  # active_der=[1.]
```

### ●（3）線形和を重み／バイアス／入力で偏微分

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
PREV_LAYER = 1  # 前の層を指定するため
node_i = 0  # ノード番号

# 重みとバイアスの初期値
weights = [
    np.array([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]), # 入力層→隠れ層1
    np.array([[0.0, 0.0, 0.0]]) # 隠れ層1→出力層
]
biases = [
    np.array([0.0, 0.0, 0.0]),  # 隠れ層1
    np.array([0.0])  # 出力層
]
# 入力層／隠れ層1／出力層にある各ノードの（活性化関数の）出力値
cached_outs = [
    np.array([0.05, 0.1]),
    np.array([0.5, 0.5, 0.5]),
    np.array([0.0])
]
# ---ここまでは仮の実装。ここからが必要な実装---

W = weights[layer_i - SKIP_INPUT_LAYER]
b = biases[layer_i - SKIP_INPUT_LAYER]
x = cached_outs[layer_i - PREV_LAYER]  # 前の層の出力（out）＝今の層への入力（x）
sum_der_w = sum_der(x, W, b, with_respect_to='W')
sum_der_b = sum_der(x, W, b, with_respect_to='b')
sum_der_X = sum_der(x, W, b, with_respect_to='x')

# print(f'W={W}')  # W=[[0. 0. 0.]]
# print(f'b={b}')  # b=[0.]
# print(f'x={x}')  # x=[0.5 0.5 0.5]
# print(f'sum_der_w={sum_der_w}')  # sum_der_w=[[0.5 0.5 0.5]]
# print(f'sum_der_b={sum_der_b}')  # sum_der_b=[1.]
# print(f'sum_der_X={sum_der_X}')  # sum_der_X=[[0. 0. 0.]]
```

### ●（4）各重み／バイアス／各入力の勾配を計算

まずは共通の計算部分であるデルタ（`delta`変数）を計算します。

```python
delta = back_error * active_der

# print(f'back_error({back_error})×active_der({active_der})＝delta({delta})')
# 出力例： back_error([-1.])×active_der([1.])＝delta([-1.])
```

要素ごとの掛け算（アダマール積：$\odot$）をしています。数学的に表現すると次のような計算になります。

$$
\begin{align}
\mathrm{delta} &= \mathrm{back\_error} \odot \mathrm{active\_der} \\
&= (\frac{\partial L}{\partial z_1}, \frac{\partial L}{\partial z_2}, \cdots, \frac{\partial L}{\partial z_n}) \odot(\frac{\partial z_1}{\partial u_1}, \frac{\partial z_2}{\partial u_2}, \cdots, \frac{\partial z_n}{\partial u_n}) \\
&= (\frac{\partial L}{\partial z_1} \frac{\partial z_1}{\partial u_1}, \frac{\partial L}{\partial z_2} \frac{\partial z_2}{\partial u_2}, \cdots, \frac{\partial L}{\partial z_n} \frac{\partial z_n}{\partial u_n}) \\
&= (\delta_1, \delta_2, \cdots, \delta_n) \\
&= \boldsymbol{\delta}
\end{align}
$$

次に1つの層内にある全てのバイアスの勾配（`layer_grads_b`変数）を計算します。

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
layer_grads_b = []  # 層ごとの、バイアス勾配のリスト
# ---ここまでは仮の実装。ここからが必要な実装---

# 1つのノードに対して、バイアスは「1つ」だけ
layer_grads_b = delta * sum_der_b

# print(f'delta({delta})×sum_der_b({sum_der_b})＝layer_grads_b({layer_grads_b})')
# 出力例： delta([-1.])×sum_der_b([1.])＝layer_grads_b([-1.])
```

これも要素ごとの掛け算（アダマール積：$\odot$）です。数学的に表現すると次のような計算になります。

$$
\begin{align}
\mathrm{layer\_grads\_b} &= \mathrm{delta} \odot \mathrm{sum\_der\_b} \\
&= (\delta_1, \delta_2, \cdots, \delta_n) \odot (\frac{\partial u_1}{\partial b_1}, \frac{\partial u_2}{\partial b_2}, \cdots, \frac{\partial u_n}{\partial b_n}) \\
&= (\delta_1 \frac{\partial u_1}{\partial b_1}, \delta_2 \frac{\partial u_2}{\partial b_2}, \cdots, \delta_n \frac{\partial u_n}{\partial b_n}) \\
&= \left(\frac{\partial L}{\partial b_{1}}, \frac{\partial L}{\partial b_{2}}, \cdots, \frac{\partial L}{\partial b_{n}}\right) \\
&= \frac{\partial L}{\partial \boldsymbol{b}}
\end{align}
$$

バイアスの勾配の計算結果（一次元配列値）は、各要素に「今の層にあるノード数分（$n$個）の偏微分係数」がノード順に並んでいますね。この並び順は、重み付き線形和の数式で使った$\boldsymbol{b}$と同じですね。

念のため、各重み／バイアス／入力の偏微分計算の図を再掲しておきます。例えば図の左側の縦中央にある「損失関数を／バイアス $b_j$ で偏微分」の計算結果が、上記の一次元配列値にある1つの要素に対応しています。

さらに1つの層内にある全ての重みの勾配（`layer_grads_W`変数）と全ての入力の勾配（`layer_grads_x`変数）を計算します。

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
node_count = len(layer_sums)
# ---ここまでは仮の実装。ここからが必要な実装---

# 1つのノードに対して、重みと入力は「前の層のノードの数」だけある

# 重みは「今の層のノード」×「前の層のノード」の行列で取得する
layer_grads_W = np.dot(delta.reshape(node_count, 1), sum_der_w)

# 入力は「前の層のノード」ごとに「今の層からのエッジ」を全て合計する
layer_grads_x = np.dot(delta, sum_der_X)
# layer_grads_x = np.dot(sum_der_X.T, delta)  # こう書いてもOK

# print(f'delta.T({delta.reshape(node_count, 1)})・sum_der_w({sum_der_w})＝layer_grads_W({layer_grads_W})')
# print(f'delta({delta})・sum_der_X({sum_der_X})＝layer_grads_x({layer_grads_x})')
# print(f'sum_der_X.T({sum_der_X.T})・delta({delta})＝layer_grads_x({np.dot(sum_der_X.T, delta)})'.replace('\n', ''))
# 出力例：
# delta.T([[-1.]])・sum_der_w([[0.5 0.5 0.5]])＝layer_grads_W([[-0.5 -0.5 -0.5]])
# delta([-1.])・sum_der_X([[0. 0. 0.]])＝layer_grads_x([0. 0. 0.])
# sum_der_X.T([[0.] [0.] [0.]])・delta([-1.])＝layer_grads_x([0. 0. 0.])
```

まずは「重みの勾配の計算内容」を見てみます。`reshape(node_count, 1)`は$n$（＝今の層にあるノードの数）行×$1$列の列ベクトルに変換する処理です。

`sum_der_w`は$n$行$m$列の行列ではなく、$1$行$m$（＝前の層にあるノードの数）列の行ベクトルになっている点に注意してください。前述の「重み付き線形和」の偏微分のコード部分でも説明しましたが、これは実際には$j$行$m$列を意図しており、今の層の何ノード目（$j=1,2,\cdots,n$）であっても、計算結果が同じ値となるので、しかも行ベクトルの方が逆伝播のNumPyによる計算がしやすかったので、$1$行に要約しました。

重みの勾配の計算は、数学的に表現すると以下のように表現できます。

$$
\begin{align}
\mathrm{layer\_grads\_w} &= \mathrm{delta}^{\top} \cdot \mathrm{sum\_der\_w} \\
&= \left(\begin{array}{c}\delta_1 \\ \delta_2 \\ \vdots \\ \delta_n \end{array}\right) (\frac{\partial u_j}{\partial w_{1j}}, \frac{\partial u_j}{\partial w_{2j}}, \cdots, \frac{\partial u_j}{\partial w_{mj}}) \\
&= \left(
\begin{array}{cccc}
\delta_1 \frac{\partial u_j}{\partial w_{1j}} & \delta_1 \frac{\partial u_j}{\partial w_{2j}} & \ldots & \delta_1 \frac{\partial u_j}{\partial w_{mj}} \\
\delta_2 \frac{\partial u_j}{\partial w_{1j}} & \delta_2 \frac{\partial u_j}{\partial w_{2j}} & \ldots & \delta_2 \frac{\partial u_j}{\partial w_{mj}} \\
\vdots & \vdots & \ddots & \vdots \\
\delta_n \frac{\partial u_j}{\partial w_{1j}} & \delta_n \frac{\partial u_j}{\partial w_{2j}} & \ldots & \delta_n \frac{\partial u_j}{\partial w_{mj}}
\end{array}
\right) \\
※jは今の層に&あるノード番号（1～n）を意味し、\\
　このn個が行&列の1行目～n行目まで並んでいます。\\
　このため例え&ば\frac{\partial u_j}{\partial w_{1j}}=\frac{\partial u_1} {\partial w_{11}}～\frac{\partial u_n}{\partial w_{1n}}は全て同じ値です。 \\
　よって以下の&ように書くこともできます。 \\
&= \left(
\begin{array}{cccc}
\delta_1 \frac{\partial u_1}{\partial w_{11}} & \delta_1 \frac{\partial u_1}{\partial w_{21}} & \ldots & \delta_1 \frac{\partial u_1}{\partial w_{m1}} \\
\delta_2 \frac{\partial u_2}{\partial w_{12}} & \delta_2 \frac{\partial u_2}{\partial w_{22}} & \ldots & \delta_2 \frac{\partial u_2}{\partial w_{m2}} \\
\vdots & \vdots & \ddots & \vdots \\
\delta_n \frac{\partial u_n}{\partial w_{1n}} & \delta_n \frac{\partial u_n}{\partial w_{2n}} & \ldots & \delta_n \frac{\partial u_n}{\partial w_{mn}}
\end{array}
\right) \\
&= \left(
\begin{array}{cccc}
\frac{\partial L}{\partial w_{11}} & \frac{\partial L}{\partial w_{21}} & \ldots & \frac{\partial L}{\partial w_{m1}} \\
\frac{\partial L}{\partial w_{12}} & \frac{\partial L}{\partial w_{22}} & \ldots & \frac{\partial L}{\partial w_{m2}} \\
\vdots & \vdots & \ddots & \vdots \\
\frac{\partial L}{\partial w_{1n}} & \frac{\partial L}{\partial w_{2n}} & \ldots & \frac{\partial L}{\partial w_{mn}}
\end{array}
\right) \\
&= \frac{\partial L}{\partial \boldsymbol{W}}
\end{align}
$$

重みの勾配の計算結果（二次元配列値）は、各行に「今の層にあるノード数分（$n$個）の偏微分係数」がノード順に並び、各列に「前の層にあるノード数分（$m$個）の偏微分係数」がノード順に並びます。この並び順は、重み付き線形和の数式で使った$\boldsymbol{W}$と同じですね。

次に「入力の勾配の計算」を見てみます。

`sum_der_X`は$n$行$m$列の行列です。

入力の勾配の計算は、数学的に表現すると以下のように表現できます。

$$
\begin{align}
\mathrm{layer\_grads\_x} &= \mathrm{delta} \cdot \mathrm{sum\_der\_X} \\
&= (\delta_1, \delta_2, \cdots, \delta_n) \left(
\begin{array}{cccc}
\frac{\partial u_1}{\partial x_1} & \frac{\partial u_1}{\partial x_2} & \ldots & \frac{\partial u_1}{\partial x_m} \\
\frac{\partial u_2}{\partial x_1} & \frac{\partial u_2}{\partial x_2} & \ldots & \frac{\partial u_2}{\partial x_m} \\
\vdots & \vdots & \ddots & \vdots \\
\frac{\partial u_n}{\partial x_1} & \frac{\partial u_n}{\partial x_2} & \ldots & \frac{\partial u_n}{\partial x_m}
\end{array}
\right) \\
&= (\sum_{j=1}^{n} \delta_j \frac{\partial u_j}{\partial x_1}, \sum_{j=1}^{n} \delta_j \frac{\partial u_j}{\partial x_2}, \cdots, \sum_{j=1}^{n} \delta_j \frac{\partial u_j}{\partial x_m}) \\
&= (\frac{\partial L}{\partial x_1}, \frac{\partial L}{\partial x_2}, \cdots, \frac{\partial L}{\partial x_m}) \\
&= \frac{\partial L}{\partial \boldsymbol{x}}
\end{align}
$$

入力の勾配の計算結果（一次元配列値）は、各要素に__「前」__の層の出力（＝今の層の入力）にある$m$個の偏微分係数がノード順に並びます。この並び順は、重み付き線形和の数式で使った$\boldsymbol{x}$と同じですね。

注意点として、「前の層の出力」ごとに「今の層からのエッジ」（$n$個）の計算結果を全て合計する必要があります。（上の数式にもある）行列計算の中で総和（$\Sigma$）する計算部分がこの合計処理に該当します。

### ●逆伝播の処理全体の実装

ニューラルネットには、層があり、その中に複数のノードが存在するという構造です。  従って、

- **逆順に**各層を1つずつ処理する`for`ループと、
  - 層の中の全ノードをまとめて処理する行列計算、の2段階構造が必要で、
    - 行列計算を使った「逆伝播の処理」

を記述すればよいわけです。

```python
def back_prop(layers, weights, biases, y_true, cached_outs, cached_sums):
    """
    逆伝播を行う関数。
    - 引数：
    (layers, weights, biases)： モデルを指定する。
    y_true： 正解値（出力層のノードが複数ある場合もあるのでリスト値）。
    cached_outs： 順伝播で記録した活性化関数の出力値。予測値を含む。
    cached_sums： 順伝播で記録した線形和（Σ）値。
    - 戻り値：
    重みの勾配とバイアスの勾配を返す。
    """

    # ネットワーク全体で勾配を保持するためのリスト
    grads_w = []  # 重みの勾配
    grads_b = []  # バイアスの勾配
    grads_x = []  # 入力の勾配

    layer_count = len(layers)
    layer_max_i = layer_count-1
    SKIP_INPUT_LAYER = 1
    PREV_LAYER = 1
    rng = range(SKIP_INPUT_LAYER, layer_count)  # 入力層以外の層インデックス
    for layer_i in reversed(rng):  # 各層を逆順に処理

        layer_sums = cached_sums[layer_i - SKIP_INPUT_LAYER]
        node_count = len(layer_sums)
        is_output_layer = (layer_i == layer_max_i)

        # 層ごとに全ノードまとめて処理を行う
        # print(f'■第{layer_i+1}層-全て（{node_count}個）のノード：')

        # （1）逆伝播していく誤差情報
        # print(f'　●（1）逆伝播していく誤差情報', end='')
        if is_output_layer:
            # 出力層（損失関数の偏微分係数）
            y_pred = cached_outs[layer_i]
            back_error = sseloss_der(y_pred, y_true)  # 逆伝播していく誤差情報
            # print(f'（出力層は損失関数：二乗和誤差）の偏微分係数＝{back_error})')
        else:
            # 隠れ層（次の層への入力の偏微分係数）
            back_error = grads_x[-1]  # 最後に追加された入力の勾配
            # print(f'（隠れ層は次の層への入力）の偏微分係数＝{back_error})')

        # （2）活性化関数を偏微分
        # print(f'　●（2）活性化関数', end='')
        if is_output_layer:
            # 出力層（恒等関数の微分）
            active_der = identity_der(layer_sums)
            # print(f'（出力層は恒等関数）({layer_sums})の偏微分＝{active_der}')
        else:
            # 隠れ層（シグモイド関数の微分）
            active_der = sigmoid_der(layer_sums)
            # print(f'sigmoid_der({layer_sums})＝layer_sums({active_der})')
            # print(f'（隠れ層はシグモイド関数）({layer_sums})の偏微分＝{active_der}')

        # （3）線形和を重み／バイアス／入力で偏微分
        # print(f'　●（3）線形和関数の偏微分：')
        W = weights[layer_i - SKIP_INPUT_LAYER]
        b = biases[layer_i - SKIP_INPUT_LAYER]
        x = cached_outs[layer_i - PREV_LAYER]  # 前の層の出力＝今の層への入力
        sum_der_w = sum_der(x, W, b, with_respect_to='W')
        sum_der_b = sum_der(x, W, b, with_respect_to='b')
        sum_der_X = sum_der(x, W, b, with_respect_to='x')
        # print(f'　　○重み({W})で偏微分＝{sum_der_w}'.replace('\n', ''))
        # print(f'　　○バイアス({b})で偏微分＝{sum_der_b}')
        # print(f'　　○入力({x})で偏微分＝{sum_der_X}'.replace('\n', ''))

        # （4）各重み／バイアス／各入力の勾配を計算
        # print(f'　●（4）各重み／バイアス／各入力の勾配を計算： ')
        delta = back_error * active_der
        # print(f'　　○デルタ： 逆伝播していく誤差情報({back_error})×活性化関数の偏微分({active_der})＝{delta}'.replace('\n', ''))

        # 1つのノードに対して、バイアスは「1つ」だけ
        layer_grads_b = delta * sum_der_b
        # print(f'　　○バイアスの勾配： デルタ({delta})×線形和関数をバイアスで偏微分({sum_der_b})＝{layer_grads_b}'.replace('\n', ''))

        # 1つのノードに対して、重みと入力は「前の層のノードの数」だけある

        # 重みは「今の層のノード」×「前の層のノード」の行列で取得する
        layer_grads_W = np.dot(delta.reshape(node_count, 1), sum_der_w)
        # print(f'　　○重みの勾配： デルタの列ベクトル({delta.reshape(node_count, 1)})・線形和関数を重みで偏微分({sum_der_w})＝{layer_grads_W}'.replace('\n', ''))

        # 入力は「前の層のノード」ごとに「今の層からのエッジ」を全て合計する
        layer_grads_x = np.dot(delta, sum_der_X)
        # print(f'　　○入力の勾配： デルタ({delta})・線形和関数を入力で偏微分({sum_der_X})＝{layer_grads_x}'.replace('\n', ''))

        # 層ごとの勾配を、ネットワーク全体用のリストに格納
        grads_w.append(layer_grads_W)
        grads_b.append(layer_grads_b)
        grads_x.append(layer_grads_x)

    # print(f'■第1層-全て（{len(cached_sums[0])}個）の特徴量：')
    # print(f'　●（1）逆伝播していく誤差情報： ', end='')
    # print(f'【入力層】次の層への入力の偏微分係数＝{grads_x[-1]})')

    # 保持しておいた各勾配（※逆順で追加したので反転が必要）を戻り値で返す
    grads_w.reverse()
    grads_b.reverse()
    return (grads_w, grads_b)  # grads_xは最適化で不要なので返していない
```

### ●逆伝播の実行例

以下のようなコードを書けば、順伝播から逆伝播までを続けて実行できます。

```python
x = np.array([0.05, 0.1])
layers = [2, 2, 2]
weights = [
    np.array([[0.15, 0.2], [0.25, 0.3]]),
    np.array([[0.4, 0.45], [0.5,0.55]])
]
biases = [
    np.array([0.35, 0.35]),
    np.array([0.6, 0.6])
]
model = (layers, weights, biases)
y_true = np.array([0.01, 0.99])

# （1）順伝播の実行例
y_pred, cached_outs, cached_sums = forward_prop(*model, x, cache_mode=True)
print(f'y_pred={y_pred}')
print(f'cached_outs={cached_outs}')
print(f'cached_sums={cached_sums}')
# 出力例：
# y_pred=[1.10590597 1.2249214 ]
# cached_outs=[array([0.05, 0.1 ]), array([0.59326999, 0.59688438]), array([1.10590597, 1.2249214 ])]
# cached_sums=[array([0.3775, 0.3925]), array([1.10590597, 1.2249214 ])]

# （2）逆伝播の実行例
grads_w, grads_b = back_prop(*model, y_true, cached_outs, cached_sums)
print(f'grads_w={grads_w}'.replace('\n      ', ''))
print(f'grads_b={grads_b}'.replace('\n      ', ''))
# 出力例：
# grads_w=[array([[0.00670603, 0.01341205], [0.00748746, 0.01497492]]), array([[0.65016812, 0.65412915], [0.13937182, 0.14022092]])]
# grads_b=[array([0.13412051, 0.14974924]), array([1.09590597, 0.2349214 ])]
```

ここまでの逆伝播のコード中に仕込んでいる`print()`関数（※全てコメントアウトしています）のコメントを解除すると、以下のように途中の計算内容が順番にテキスト出力されます。計算内容の検証用の機能です。

## ■ステップ3. パラメーター（重みとバイアス）更新の実装

パラメーター（各重みと各バイアス）を更新する目的は、「**ニューラルネットのモデルを最適化すること**」です。下の図は「最適化の**参考イメージ**」です。

※なお、本稿で説明するのは最も基礎的な**勾配降下法**（**Gradient Descent**）です。後述するSGD（確率的勾配降下法）もその一種で、他にはRMSPropやAdamなどより応用的な手法があります。SGD以外の場合は、重みパラメーターの更新方法も少し変わってきます。

### ●1つのパラメーターの更新

最も基本的なSGD（確率的勾配降下法）の場合、1つの重み／バイアスのパラメーター更新は以下の計算方法で行えます。なお、$\eta$は「イータ」と読みます。

数式で表現すると、以下のようになります。これらが冒頭の図3に掲載した数式です。

$$
\begin{align}
w_{ij} &\leftarrow w_{ij}-\eta\frac{\partial L}{\partial w_
{ij}} \\
b_j &\leftarrow b_j-\eta\frac{\partial L}{\partial b_j}
\end{align}
$$

### ●1つの層内にある全パラメーターの更新

NumPyの二次元配列や一次元配列を使う場合、多次元配列内の各要素をまとめて計算可能です。その場合の数式は次のように表現できるでしょう。

$$
\begin{align}
\boldsymbol{W} &\leftarrow \boldsymbol{W}-\eta\frac{\partial L}{\partial \boldsymbol{W}} \\
\boldsymbol{b} &\leftarrow \boldsymbol{b}-\eta\frac{\partial L}{\partial \boldsymbol{b}}
\end{align}
$$

```python
# 取りあえず仮で、変数を定義して、コードが実行できるようにしておく
W = np.array([[0.0]])  # 重み（行列）
b = np.array([0.0])  # バイアス（ベクトル）
grad_W = np.array([[0.2]])  # 重みの勾配（行列）
grad_b = np.array([0.2])  # バイアスの勾配（ベクトル）
LEARNING_RATE = 0.1  # 学習率（lr）
lr = LEARNING_RATE
# ---ここまでは仮の実装。ここからが必要な実装---

W = W - lr * grad_W  # 重みパラメーターの更新

b = b - lr * grad_b  # バイアスパラメーターの更新
```

### ●パラメーター更新の処理全体の実装

ニューラルネットには、層があり、その中に複数のノードが存在するという構造ですので、

- 各層を1つずつ処理する`for`ループと、
  - 層の中の全ノードをまとめて処理する行列計算、の2段階構造が必要で、
    - 行列計算を使った「パラメーター更新の処理」

を記述すればよいわけです。

```python
def update_params(layers, weights, biases, grads_w, grads_b, lr=0.1):
    """
    パラメーター（重みとバイアス）を更新する関数
    - 引数：
    (layers, weights, biases)： モデルを指定する。
    grads_w： 重みの勾配。
    grads_b： バイアスの勾配。
    lr： 学習率（learning rate）。最適化を進める量を調整する。
    - 戻り値：
    新しい重みとバイアスを返す。
    """

    # ネットワーク全体で勾配を保持するためのリスト
    new_weights = [] # 重み
    new_biases = [] # バイアス

    SKIP_INPUT_LAYER = 1
    for layer_i, layer in enumerate(layers):  # 各層を処理
        if layer_i == 0:
            continue  # 入力層はスキップ

        # 層ごとに全ノードまとめて処理を行う

        b = biases[layer_i - SKIP_INPUT_LAYER]
        grad_b = grads_b[layer_i - SKIP_INPUT_LAYER]
        layer_b = b - lr * grad_b  # バイアスパラメーターの更新

        W = weights[layer_i - SKIP_INPUT_LAYER]
        grad_W = grads_w[layer_i - SKIP_INPUT_LAYER]
        layer_W = W - lr * grad_W  # 重みパラメーターの更新

        new_weights.append(layer_W)
        new_biases.append(layer_b)

    return (new_weights, new_biases)
```

### ●パラメーター更新の実行例

以下のようなコードを書けば、順伝播から逆伝播、パラメーター更新までを続けて実行できます。

```python
layers = [2, 2, 2]
weights = [
    np.array([[0.15, 0.2], [0.25, 0.3]]),
    np.array([[0.4, 0.45], [0.5,0.55]])
]
biases = [
    np.array([0.35, 0.35]),
    np.array([0.6, 0.6])
]
model = (layers, weights, biases)

# 元の重み
print(f'old-weights={weights}'.replace('\n      ', ''))
print(f'old-biases={biases}')
# old-weights=[array([[0.15, 0.2 ], [0.25, 0.3 ]]), array([[0.4 , 0.45], [0.5 , 0.55]])]
# old-biases=[array([0.35, 0.35]), array([0.6, 0.6])]

# （1）順伝播の実行例
x = np.array([0.05, 0.1])
y_pred, cached_outs, cached_sums = forward_prop(*model, x, cache_mode=True)

# （2）逆伝播の実行例
y_true = np.array([0.01, 0.99])
grads_w, grads_b = back_prop(*model, y_true, cached_outs, cached_sums)
print(f'grads_w={grads_w}'.replace('\n      ', ''))
print(f'grads_b={grads_b}')
# grads_w=[array([[0.00670603, 0.01341205], [0.00748746, 0.01497492]]), array([[0.65016812, 0.65412915], [0.13937182, 0.14022092]])]
# grads_b=[array([0.13412051, 0.14974924]), array([1.09590597, 0.2349214 ])]

# （3）パラメーター更新の実行例
LEARNING_RATE = 0.1 # 学習率（lr）
weights, biases = update_params(*model, grads_w, grads_b, lr=LEARNING_RATE)

# 更新後の新しい重み
print(f'new-weights={weights}'.replace('\n      ', ''))
print(f'new-biases={biases}')
# new-weights=[array([[0.1493294 , 0.19865879], [0.24925125, 0.29850251]]), array([[0.33498319, 0.38458708], [0.48606282, 0.53597791]])]
# new-biases=[array([0.33658795, 0.33502508]), array([0.4904094 , 0.57650786])]

# モデルの最適化
model = (layers, weights, biases)
```

## ■3つのステップを呼び出す最適化処理の実装

### ●最適化処理：学習方法と勾配降下法

代表的な学習方法を簡単にまとめておきます。

- **オンライン学習**（**Online training**）： データ1件ずつ訓練していくこと
- **ミニバッチ学習**（**Mini-batch training**）： 小さなまとまりのデータごとに訓練していくこと
- **バッチ学習**（**Batch training**）： データ全件で訓練していくこと

学習方法ごとに、勾配降下法をまとめると以下のようになります。

- **SGD**（**Stochastic Gradient Descent**）： オンライン学習
- **ミニバッチSGD**（**Mini-batch SGD**）： ミニバッチ学習。単に**ミニバッチ勾配降下法**（**Mini-batch Gradient Descent**）とも呼ぶ
- **最急降下法**（**Steepest Descent**）： バッチ学習。**バッチ勾配降下法**（**Batch Gradient Descent**）とも呼ぶ

コード内容も簡単なので少し難易度を上げて、あえて全ての学習方法に対応できる実装コードにしてみます。

### ●最適化の処理全体の実装

訓練処理では、エポック（＝全データ分で1回の訓練）があり、その中にイテレーション（＝バッチサイズごとでのパラメーターの更新）が存在するという構造ですので、

- エポックを1回ずつ処理する`for`ループと、
  - その中にデータを1件ずつ処理する`for`ループの2段階構造を用意し、
    - その中に「ステップ1. 順伝播」「ステップ2. 逆伝播」と、
    - イテレーションごとに「ステップ3. パラメーターの更新」

を記述するようにします（※あくまで筆者による実装方針の例です）。

階層が深くなる上にコードの行数が少し長いので、説明の都合上、上の箇条書きの前半2行を`train()`親関数、後半2行を`optimize()`子関数、という親子関係の2つの関数に分けて記述します。※1つの関数として実装した方がシンプルになって見通しもよくなるので、本来であればそうした方がよいと思います。

```python
import random

# 取りあえず仮で、空の関数を定義して、コードが実行できるようにしておく
def optimize(model, x, y, data_i, last_i, batch_i, batch_size, acm_g, lr=0.1):
    " モデルを最適化する関数（子関数）。"
    loss = 0.1
    return model, loss, batch_i, acm_g

# ---ここまでは仮の実装。ここからが必要な実装---

def train(model, x, y, batch_size=32, epochs=10, lr=0.1, verbose=10):
    """
    モデルの訓練を行う関数（親関数）。
    - 引数：
    model： モデルをタプル「(layers, weights, biases)」で指定する。
    x： 訓練データ（各データが行、各特徴量が列の、2次元リスト値）。
    y： 訓練ラベル（各データが行、各正解値が列の、2次元リスト値）。
    batch_size： バッチサイズ。何件のデータをまとめて処理するか。
    epochs： エポック数。全データ分で何回、訓練するか。
    lr： 学習率（learning rate）。最適化を進める量を調整する。
    verbose： 訓練状況を何エポックおきに出力するか。
    - 戻り値：
    損失値の履歴を返す。これを使って損失値の推移グラフが描ける。
    """
    loss_history = []  # 損失値の履歴

    data_size = len(y)  # 訓練データ数
    data_indexes = range(data_size)  # 訓練データのインデックス

    # 各エポックを処理
    for epoch_i in range(1, epochs + 1):  # 経過表示用に1スタート

        acm_loss = 0  # 損失値を蓄積（accumulate）していく

        # 訓練データのインデックスをシャッフル（ランダムサンプリング）
        random_indexes = random.sample(data_indexes, data_size)
        last_i = random_indexes[-1]  # 最後の訓練データのインデックス

        # 親関数で管理すべき変数
        acm_g = (None, None)  # 重み／バイアスの勾配を蓄積していくため
        batch_i = 0  # バッチ番号をインクリメントしていくため

        # 訓練データを1件1件処理していく
        for data_i in random_indexes:

            # 親子に分割したうちの子関数を呼び出す
            model, loss, batch_i, acm_g = optimize(
                model, x, y, data_i, last_i, batch_i, batch_size, acm_g, lr)

            acm_loss += loss  # 損失値を蓄積

        # エポックごとに損失値を計算。今回の実装では「平均」する
        layers = model[0]  # レイヤー構造
        out_count = layers[-1]  # 出力層のノード数
        # 「訓練データ数（イテレーション数×バッチサイズ）×出力ノード数」で平均
        epoch_loss = acm_loss / (data_size * out_count)

        # 訓練状況を出力
        if verbose != 0 and \
            (epoch_i % verbose == 0 or epoch_i == 1 or epoch_i == EPOCHS):
            print(f'[Epoch {epoch_i}/{EPOCHS}] train_loss: {epoch_loss}')

        loss_history.append(epoch_loss)  # 損失値の履歴として保存

    return model, loss_history

# サンプル実行用の仮のモデルとデータ
layers = [2, 2, 2]
weights = [
    np.array([[0.15, 0.2], [0.25, 0.3]]),
    np.array([[0.4, 0.45], [0.5,0.55]])
]
biases = [
    np.array([0.35, 0.35]),
    np.array([0.6, 0.6])
]
model = (layers, weights, biases)
x = np.array([[0.05, 0.1]])
y = np.array([[0.01, 0.99]])

# モデルを訓練する
BATCH_SIZE = 2  # バッチサイズ
EPOCHS = 1  # エポック数
LEARNING_RATE = 0.02 # 学習率（lr）
model, loss_history = train(model, x, y, BATCH_SIZE, EPOCHS, LEARNING_RATE)
# 出力例：
# [Epoch 1/1] train_loss: 0.05
```

```python
def accumulate(list1, list2):
    "2つのリストの値を足し算する関数。"
    new_list = []
    for item1, item2 in zip(list1, list2):
        # ※全体の重み勾配は行数と列数が同じではないので層ごとに処理する必要がある。
        np_sum = np.array(item1) + np.array(item2)  # NumPyなら行列データをまとめて処理できる
        new_list.append(np_sum)
    return new_list
```

```python
def mean_element(list1, data_count):
    "1つのリストの値をデータ数で平均する関数。"
    new_list = []
    for item1 in list1:
        # ※全体の重み勾配は行数と列数が同じではないので層ごとに処理する必要がある。
        np_mean = np.array(item1) / data_count  # NumPyなら行列データをまとめて処理できる
        new_list.append(np_mean)
    return new_list
```

```python
def optimize(model, x, y, data_i, last_i, batch_i, batch_size, acm_g, lr=0.1):
    "train()親関数から呼ばれる、最適化のための子関数。"

    layers = model[0]  # レイヤー構造
    each_x = np.array(x[data_i])  # 1件分の訓練データ
    y_true = np.array(y[data_i])  # 1件分の正解値

    # ステップ（1）順伝播
    y_pred, outs, sums = forward_prop(*model, each_x, cache_mode=True)

    # ステップ（2）逆伝播
    gw, gb = back_prop(*model, y_true, outs, sums)

    # 各勾配を蓄積（accumulate）していく
    if batch_i == 0:
        acm_gw = gw
        acm_gb = gb
    else:
        acm_gw = accumulate(acm_g[0], gw)
        acm_gb = accumulate(acm_g[1], gb)
    batch_i += 1  # バッチ番号をカウントアップ＝現在のバッチ数

    # 訓練状況を評価するために、損失値を取得
    loss = 0.0
    for output, target in zip(y_pred, y_true):
        loss += sseloss(output, target)

    # バッチサイズごとで後続の処理に進む
    if batch_i % BATCH_SIZE != 0 and data_i != last_i:
        return model, loss, batch_i, (acm_gw, acm_gb)  # バッチ内のデータごと

    layers = model[0]  # レイヤー構造
    out_count = layers[-1]  # 出力層のノード数

    # 平均二乗誤差なら平均する（損失関数によって異なる）
    grads_w = mean_element(acm_gw, batch_i * out_count)  # 「バッチサイズ ×
    grads_b = mean_element(acm_gb, batch_i * out_count)  #   出力ノード数」で平均
    batch_i = 0  # バッチ番号を初期化して次のイテレーションに備える

    # ステップ（3）パラメーター（重みとバイアス）の更新
    weights, biases = update_params(*model, grads_w, grads_b, lr)

    # モデルをアップデート（＝最適化）
    model = (layers, weights, biases)

    return model, loss, batch_i, (acm_gw, acm_gb)  # イテレーションごと

# サンプル実行
model, loss_history = train(model, x, y, BATCH_SIZE, EPOCHS, LEARNING_RATE)
# 出力例：
# [Epoch 1/1] train_loss: 0.31404948868496607
```

## ■回帰問題を解くデモ

特徴量（入力データ）は$x_1$（X軸）と$x_2$（Y軸）の座標です。その座標点における色、具体的にはオレンジ色（**-1**）～灰色（**0**）～青色（**1**）を予測する回帰問題となります。

まず訓練データを用意します。このデモの訓練データは、「[回帰問題をディープラーニング（基本のDNN）で解こう](https://atmarkit.itmedia.co.jp/ait/articles/2005/25/news011.html)」でも使っているライブラリー「playground-data」の平面（Plain）データセットです。

```python
!pip install playground-data
```

```python
# playground-dataライブラリのplygdataパッケージを「pg」という別名でインポート
import plygdata as pg

# 問題種別で「分類（Classification）」を選択し、
# データ種別で「2つのガウシアンデータ（TwoGaussData）」を選択する場合の、
# 設定値を定数として定義
PROBLEM_DATA_TYPE = pg.DatasetType.RegressPlane

# 各種設定を定数として定義
TRAINING_DATA_RATIO = 0.5  # データの何％を訓練【Training】用に？ (残りは精度検証【Validation】用) ： 50％
DATA_NOISE = 0.0           # ノイズ： 0％

# 定義済みの定数を引数に指定して、データを生成する
data_list = pg.generate_data(PROBLEM_DATA_TYPE, DATA_NOISE)

# データを「訓練用」と「精度検証用」を指定の比率で分割し、さらにそれぞれを「データ（X）」と「教師ラベル（y）」に分ける
X_train, y_train, _, _ = pg.split_data(data_list, training_size=TRAINING_DATA_RATIO)

# それぞれ5件ずつ出力
print('X_train:'); print(X_train[:5])
print('y_train:'); print(y_train[:5])
```

次に、モデルを定義します。

```python
layers = [2, 3, 1]
weights = [
    np.array([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]),
    np.array([[0.0, 0.0, 0.0]])
]
biases = [
    np.array([0.0, 0.0, 0.0]),
    np.array([0.0])
]
model = (layers, weights, biases)
```

訓練「前」のモデルによる予測状態を図示します。

```python
# `draw_decision_boundary()`関数がクラス内の`predict()`メソッドを呼び出す仕様のため
class MyModel:
    def __init__(self, l, w, b):
        self.layers = l
        self.weights = w
        self.biases = b

    def predict(self, x, batch_size=1, verbose=False):
        probability = []
        for each_x in x:
            y = forward_prop(self.layers, self.weights, self.biases, each_x)
            probability.append(y[0])
        return probability

# 出力のグラフ表示
trained_model = MyModel(*model)
fig, ax = pg.plot_points_with_playground_style(X_train, y_train, None, None, figsize = (6, 6), dpi = 100)
pg.draw_decision_boundary(fig, ax, trained_model=trained_model, discretize=False)
```

それぞれの丸い点の座標は、訓練データ1件1件の特徴量を表します。その点の色が正解ラベルです。例えば左下の座標点でれば、色はオレンジ色、つまり**-1.0**に近い値が正解となります。右上が青色、つまり**1.0**に近い値が正解です。

モデルによる予測値は、背景色として描画されています。上の図は全面が灰色です。これは、どの座標を入力しても、**0.0**が予測されることを意味します。これをニューラルネットで学習することで、オレンジ色の座標点の背景色はオレンジ色に、青色の座標点の背景色は青色に描画されるようにします。

最後に、訓練処理の`train()`関数を呼び出すだけです。

```python
import matplotlib.pyplot as plt

BATCH_SIZE = 4   # バッチサイズ
EPOCHS = 100     # エポック数
LERNING_RATE = 0.02  # 学習係数

model, loss_history = train(model, X_train, y_train, BATCH_SIZE, EPOCHS, LEARNING_RATE)

# 学習結果（損失）のグラフを描画
epochs = len(loss_history)
plt.plot(range(1, epochs + 1), loss_history, marker='.', label='loss (Training data)')
plt.legend(loc='best')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()
```

重みやバイアスはモデル（タプル型オブジェクト）の中に格納されています。

```python
print(f'weights={model[1]}'.replace('\n      ', ''))
print(f'biases={model[2]}')
```

訓練「後」のモデルによる予測状態を図示します。

```python
# 出力のグラフ表示
trained_model = MyModel(*model)
fig, ax = pg.plot_points_with_playground_style(X_train, y_train, None, None, figsize = (6, 6), dpi = 100)
pg.draw_decision_boundary(fig, ax, trained_model=trained_model, discretize=False)
```

線形代数（NumPy）なしで作ってきた自作のニューラルネットで、確かに回帰問題を解けることが確認できました。

# お疲れさまでした。線形代数編（第4回）は修了です。