# Tutorial 2: Out-of-distribution (OOD) Learning

**Week 3, Day 4: Continual Learning**

**By Neuromatch Academy**

__Content creators:__  Avishree Khare, Het Shah, Joshua Vogelstein

__Content reviewers:__ Arush Tagade, Jeremy Forest, Kelson Shilling-Scrivo

__Content editors:__ Anoop Kulkarni, Spiros Chavlis

__Production editors:__ Deepak Raya, Spiros Chavlis

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

---
# Tutorial Objectives

Deep Learning has seen tremendous growth in recent years thanks to more data, more compute and *very* deep neural networks. Although these networks perform extremely well on the specified task, they fail to generalize to newer tasks.

In this tutorial, we will explore Out-of-distribution (OOD) Learning and the several OOD paradigms that have been gaining popularity in recent years. We will understand what OOD really means and how it is different from anything else that we've looked at so far. We'll also take a look at Transfer Learning, Multi-task Learning and Meta-Learning which aim to facilitate OOD Learning in different ways. Here is a list of topics that we would be covering in this tutorial:

1. Introduction to OOD Learning
2. Transfer Learning
3. Multi-Task Learning
4. Meta-Learning

```python
# @title Tutorial slides

# @markdown These are the slides for the videos in this tutorials

# @markdown If you want to locally download the slides, click [here](https://osf.io/dqnm6/download)
from IPython.display import IFrame
IFrame(src=f"https://mfr.ca-1.osf.io/render?url=https://osf.io/dqnm6/?direct%26mode=render%26action=download%26mode=render", width=854, height=480)
```

---
# Setup

```python
# @title Install dependencies
!pip install Pillow --quiet
!pip install pandas --quiet

!pip install git+https://github.com/NeuromatchAcademy/evaltools --quiet
from evaltools.airtable import AirtableForm

# generate airtable form
atform = AirtableForm('appn7VdPRseSoMXEG','W3D4_T2','https://portal.neuromatchacademy.org/api/redirect/to/1d7fcd5d-f1e9-4ac5-ae58-b0ade54a4f87')
```

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

```python
# Imports
import os
import copy
import time
import torch
import torchvision

import numpy as np
import matplotlib.pyplot as plt

import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.optim import lr_scheduler
from torch.utils.data import Dataset, DataLoader, random_split

from torchvision.datasets import CelebA, Omniglot
from torchvision import datasets, models, transforms
```

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

```python
# @title Plotting functions

def visualize_siamese_sample(sample):
  to_PIL = transforms.ToPILImage()
  fig, axs = plt.subplots(1, 2, figsize=(8, 10))
  x1, x2, y = sample
  label = y.item()
  similarity = "Same character" if label == 1.0 else "Different characters"
  print(f"-------------------- Label: {label} ({similarity}) -------------------")
  axs[0].imshow(to_PIL(x1), cmap='gray')
  axs[1].imshow(to_PIL(x2), cmap='gray')
  # axs[0].set_title(f'Label: {label} ({similarity})')

  for i in range(2):
    axs[i].xaxis.set_ticks([])
    axs[i].yaxis.set_ticks([])

  plt.tight_layout()
  plt.show()

def visualize_mtfl_sample(sample):
  to_PIL = transforms.ToPILImage()
  img, labels = sample

  fig, ax = plt.subplots()
  ax.xaxis.set_ticks([])
  ax.yaxis.set_ticks([])
  print(f"Labels: {labels.tolist()}")
  ax.imshow(to_PIL(img))
  plt.show()

def visualize_one_shot_sample(sample):
  to_PIL = transforms.ToPILImage()
  query_img, support_imgs, support_labels, similarity = sample
  n_rows = 1
  n_cols = len(support_imgs) + 1
  fig, axs = plt.subplots(n_rows, n_cols, figsize=(n_cols*4, 10))

  axs[0].imshow(to_PIL(query_img), cmap='gray')
  axs[0].set_title('Query Image')
  axs[0].xaxis.set_ticks([])
  axs[0].yaxis.set_ticks([])

  for i, (s_img, s_label) in enumerate(zip(support_imgs, support_labels)):
    axs[i+1].imshow(to_PIL(s_img), cmap='gray')
    label = s_label.item()
    similarity = "Same character" if label == 1.0 else "Different characters"
    axs[i+1].set_title(f"Support Image {i+1} \n "
                       f"Label: {s_label.item()} ({similarity})")
    axs[i+1].xaxis.set_ticks([])
    axs[i+1].yaxis.set_ticks([])

  plt.tight_layout()
  plt.show()
```

```python
# @title Dataset Definitions and Helper Functions

# @markdown `pandas` and `PIL` libraries should be installed.
import pandas as pd
from PIL import Image

"""
Datasets for N-way 1-shot classification on the Omniglot Dataset
"""

class SiameseOmniglotDataset(Dataset):
  def __init__(self, num_samples=10000,
               data_transforms=transforms.ToTensor(), download=True):
    self.dataset = Omniglot(root='./data/',
                            background=True,
                            download=download,
                            transform=data_transforms)
    self.num_samples = num_samples
    self.num_classes = len(self.dataset._characters)
    self.classes = range(self.num_classes)
    self.instances_per_class = dict()
    self.get_instances_per_class()

  def __getitem__(self, idx):
    if idx % 2 == 0:
      return self.get_instances_from_same_class()
    return self.get_instances_from_diff_classes()

  def get_instances_from_same_class(self):
    c = random.randint(0, self.num_classes-1)
    [ch_1, ch_2] = random.sample(self.instances_per_class[c], 2)
    return ch_1, ch_2, torch.tensor([1.0])

  def get_instances_from_diff_classes(self):
    [c1, c2] = random.sample(self.classes, 2)
    [ch_1] = random.sample(self.instances_per_class[c1], 1)
    [ch_2] = random.sample(self.instances_per_class[c2], 1)
    return ch_1, ch_2, torch.tensor([0.0])

  def get_random_instance(self):
    ids = np.random.randint(0, len(self.dataset), 2)
    character_1 = self.dataset[ids[0]][0]
    character_2 = self.dataset[ids[1]][0]
    label_1 = self.dataset[ids[0]][1]
    label_2 = self.dataset[ids[1]][1]
    similarity = torch.tensor([label_1 == label_2], dtype=torch.float)
    return character_1, character_2, similarity

  def __len__(self):
    return self.num_samples

  def get_instances_per_class(self):
    for (image, label) in self.dataset:
      if label in self.instances_per_class:
        self.instances_per_class[label].append(image)
      else:
        self.instances_per_class[label] = [image]

class NWayOneShotOmniglotDataset(Dataset):
  def __init__(self, data_transforms=transforms.ToTensor(), n_ways=5,
               download=True):
    self.dataset = Omniglot(root='./data/',
                            background=False,
                            download=download,
                            transform=data_transforms)
    self.size = len(self.dataset)
    self.num_classes = len(self.dataset._characters)
    self.classes = range(self.num_classes)
    self.n_ways = n_ways
    self.instances_per_class = dict()
    self.get_instances_per_class()

  def __getitem__(self, idx):
    query_img, query_class = self.dataset[idx]

    # Find (n_ways - 1) distinct characters different from query_class
    support_imgs = []
    support_labels = []
    support_classes = random.sample([c for c in self.classes if c != query_class], self.n_ways-1)

    # Find 1 support image from query_class
    support_classes.append(query_class)

    random.shuffle(support_classes)

    for c in support_classes:
      [img] = random.sample(self.instances_per_class[c], 1)
      support_imgs.append(img)
      support_labels.append(torch.tensor([c == query_class], dtype=torch.float))

    _, similarity = torch.max(torch.tensor(support_labels), axis=0)
    return query_img, support_imgs, support_labels, similarity

  def __len__(self):
    return self.size

  def get_instances_per_class(self):
    for (image, label) in self.dataset:
      if label in self.instances_per_class:
        self.instances_per_class[label].append(image)
      else:
        self.instances_per_class[label] = [image]

class MTFLDataset(Dataset):
  def __init__(self, data_file,
               num_samples=10000,
               data_transforms=transforms.ToTensor(), ):

    self.df = pd.read_csv(data_file, sep=' ', header=None,
                          skipinitialspace=True, nrows=num_samples)
    self.df.iloc[:, 0] = self.df.iloc[:, 0].apply(lambda s: s.replace('\\', '/'))
    self.transform = data_transforms

  def __getitem__(self, idx):
    item = self.df.iloc[idx]
    img_name = item[0]
    labels = (item[11:] - 1)  # 1-indexed to 0-indexed

    img = Image.open(img_name)
    img = self.transform(img)

    return img, torch.from_numpy(np.array(labels, dtype=np.float32)).long()

  def __len__(self):
    return len(self.df)

"""
Load datasets
"""

def get_train_val_datasets(background_dataset_size=10000,
                           val_split=0.2, download=True):

  dataset_size = background_dataset_size
  val_split = 0.2
  train_size = int(dataset_size * (1 - val_split))
  val_size = dataset_size - train_size

  background_dataset = SiameseOmniglotDataset(num_samples=dataset_size,
                                              download=download)
  train_dataset, val_dataset = random_split(background_dataset, [train_size, val_size])

  return train_dataset, val_dataset

def get_test_dataset(n_ways=5, download=True):
  return NWayOneShotOmniglotDataset(n_ways=n_ways, download=download)

def get_train_val_datasets_mtfl(dataset_size=10000, val_split=0.2):

  data_transforms = transforms.Compose([transforms.Resize((256, 256)),
                                        transforms.ToTensor()])

  train_size = int(dataset_size * (1 - val_split))
  val_size = dataset_size - train_size

  background_dataset = MTFLDataset(data_file='/content/MTFL/training.txt',
                                   num_samples=dataset_size,
                                   data_transforms=data_transforms)

  train_dataset, val_dataset = random_split(background_dataset, [train_size, val_size])

  return train_dataset, val_dataset
```

```python
# @title Training/Evaluation Helper Functions

# @markdown `train_multi_task(model, trainloader, valloader, criterion, optimizer, epochs=10, device='cpu')`
def train_multi_task(model, trainloader, valloader, criterion, optimizer,
                     epochs=10, device='cpu'):

  best_model_weights = copy.deepcopy(model.state_dict())
  best_overall_acc = 0.0

  for ep in range(epochs):
    print("")
    print("======== Epoch {:} / {:} ========".format(ep + 1, epochs))

    running_corrects = np.zeros(4)
    running_loss = 0.0
    model.train()
    model.to(device)
    for inps, labels in trainloader:
      inps = inps.to(device)
      labels = labels.to(device)
      outs = model(inps)
      overall_loss = 0.0

      for i in range(4):
        overall_loss += criterion(outs[i].to(device), labels[:, i].to(device))
        _, preds = torch.max(outs[i], 1)
        running_corrects[i] += torch.sum(preds == labels[:, i].data)

      running_loss += overall_loss.item()

      optimizer.zero_grad()
      overall_loss.backward()
      optimizer.step()

    train_loss = running_loss/(len(trainloader) * inps.size(0))
    train_accs = running_corrects / (len(trainloader) * inps.size(0))
    train_overall_acc = np.mean(train_accs)
    running_corrects_val = np.zeros(4)

    model.eval()
    with torch.no_grad():
      for inps, labels in valloader:
        inps = inps.to(device)
        labels = labels.to(device)
        outs = model(inps)

        overall_loss = 0.0

        for i in range(4):
          _, preds = torch.max(outs[i], 1)
          running_corrects_val[i] += torch.sum(preds == labels[:, i].data)

      val_accs = running_corrects_val / (len(valloader) * inps.size(0))

      val_overall_acc = np.mean(val_accs)

      if val_overall_acc > best_overall_acc:
        best_overall_acc = val_overall_acc
        best_model_weights = copy.deepcopy(model.state_dict())

    print(f"Training => Avg. Task Accuracy: {train_overall_acc}")
    print(f"Validation => Avg. Task Accuracy: {val_overall_acc}")

  model.load_state_dict(best_model_weights)
  return model

# @markdown `train_siamese_network(model, criterion, optimizer, train_loader, device='cpu', print_freq=100)`
def train_siamese_network(model, criterion, optimizer, train_loader,
                          device="cpu", print_freq=100):
  model.to(device)
  running_loss = 0.0
  correct = 0.0
  total = 0.0
  for batch_idx, data in enumerate(train_loader):
    x1 = data[0].to(device)
    x2 = data[1].to(device)
    y = data[2].to(device)

    optimizer.zero_grad() #Set parameter gradients to zero

    y_pred = model(x1, x2)
    # print(y_pred.shape, y.shape)
    loss = criterion(y_pred.view(-1), y.view(-1))
    loss.backward() #Set the values for gradients (`grad`) of all the parameters
    optimizer.step() #Update the parameter values using the computed gradients

    running_loss += loss.item()

    y_out = torch.round(torch.sigmoid(y_pred)) #To do: do necessary modifs from sigmoid to regular
    correct += y_out.eq(y.to(device)).sum()
    total += y_out.shape[0]

    # if (batch_idx % print_freq) == (print_freq - 1):
    #   print(f"Batch: {batch_idx} Running loss: {running_loss / print_freq} Train accuracy: {acc}")
    #   running_loss = 0.0

  print(f"Training => Average Loss: {running_loss/len(train_loader)} | Accuracy: {correct/total}")

# @markdown `evaluate_siamese_network(model, criterion, val_loader, device='cpu')`
def evaluate_siamese_network(model, criterion, val_loader, device='cpu'):
  model.eval()
  model.to(device)
  correct = 0.0
  total = 0.0
  val_loss = 0.0
  with torch.no_grad():
    for (x1, x2, y) in val_loader:
      y_pred = model(x1.to(device), x2.to(device))
      loss = criterion(y_pred.view(-1), y.to(device).view(-1))
      val_loss += loss.item()
      y_out = torch.round(torch.sigmoid(y_pred)) #To do: do necessary modifs from sigmoid to regular
      correct += y_out.eq(y.to(device)).sum()
      total += y_out.shape[0]

  val_acc = correct/total
  val_loss = val_loss/len(val_loader)

  print(f"Validation => Average Loss: {val_loss} | Accuracy: {val_acc}")

def evaluate_one_shot(model, test_loader, device='cpu'):
  model.eval()
  model.to(device)

  correct = 0.0
  total = 0.0

  with torch.no_grad():
    for (query_img, support_imgs, support_labels, similarity) in test_loader:
      x_query = query_img.to(device)
      y_pred_fc = [model(x_query, x_support.to(device)) for x_support in support_imgs]
      y_pred = [torch.sigmoid(pred) for pred in y_pred_fc]
      _, y_out = torch.max(torch.cat(y_pred, dim=1), 1)
      correct += y_out.eq(similarity.to(device)).sum()
      total += y_out.shape[0]

  print(f'Testing (One-shot) => Accuracy: {correct/total}')
```

```python
# @title Set random seed

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

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

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

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

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

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

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

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

def set_device():
  device = "cuda" if torch.cuda.is_available() else "cpu"
  if device != "cuda":
    print("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
```

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

    Random seed 2021 has been set.
    GPU is enabled in this notebook.

---
# Section 1: Introduction to Out-of-Distribution (OOD) Learning

*Time estimate: ~5mins*

In this section, we'll take a brief look at what OOD Learning is and how it can be useful in solving various problems with traditional Deep Learning.

```python
# @title Video 1: The Future of Learning
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"BV1764y167fj", 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"zMQySpHBoGs", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

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

# add event to airtable
atform.add_event('Video 1: The Future of Learning')

display(out)
```

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

## Think! 1.1: What are the problems that *you* want to solve?

Think of a problem that you would want to have solved using Deep Learning. Don't restrict yourself to the problems you have looked at so far!

Take 2 mins to think in silence and jot down a brief description of the problem here.

```python
# @markdown
Problem_that_you_want_to_solve = '' # @param {type:"string"}
```

Now discuss the following as a group:

1. Can these problems be solved using the techniques that you have looked at so far? If not, why?
2. Is there an OOD element attached to any of the problems, which makes them harder to solve?
3. Can you think of ways you could solve these problems? (Don't worry if you struggle, we'll hopefully find some suggestions by the end of this tutorial)

---
# Section 2: Transfer and Multi-Task Learning

*Time estimate: ~35mins*

In this tutorial we will learn transfer learning and multi-task learning. Transfer learning is a machine learning method where a model which is trained on a task (or dataset) can be used as a good initialization for training on a completely different and unrelated task (or dataset). Transfer learning is a very commonly used practice in a lot of Deep Learning works. This method is not limited to any particular subdomain and is widely used in almost all the domains, namely Computer Vision, Natural Language Processing, Reinforcement Learning, etc.

Multi-task learning aims to learn multiple tasks simultaneously using shared knowledge across these tasks. We can use Transfer learning to initialize the shared parts of the network.

We aim to learn these concepts via a simple problem of attributes classification for the celebA dataset.

```python
# @title Video 2: Transfer and Multi-Tasking Learning
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"BV18w41197o8", 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"PplDj0vXuIY", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

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

# add event to airtable
atform.add_event('Video 2: Transfer and Multi-Tasking Learning')

display(out)
```

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

## Section 2.1: Getting the data
We will be using the [MTFL](http://mmlab.ie.cuhk.edu.hk/projects/TCDCN.html) dataset for demonstrating the concepts of Transfer and Multi-Task Learning.

```python
# @title Download and unzip the dataset
import requests, zipfile

# originally from 'http://mmlab.ie.cuhk.edu.hk/projects/TCDCN/data/MTFL.zip'
os.chdir('/content')
name = 'MTFL'
fname = f"{name}.zip"
url = "https://osf.io/u5emj/download"

if not os.path.exists(name):
  print("Start downloading and unzipping `MTFL` dataset...")
  r = requests.get(url, allow_redirects=True)
  with open(fname, 'wb') as fd:
    fd.write(r.content)
  with zipfile.ZipFile(fname, 'r') as zip_ref:
    zip_ref.extractall(name)
  print("Download completed.")
else:
  print('Data has been already downloaded.')

print('Change direcrtory!')
os.chdir(name)
print(f'Current dir: {os.getcwd()}')
```

    Start downloading and unzipping `MTFL` dataset...
    Download completed.
    Change direcrtory!
    Current dir: /content/MTFL

Lets load the dataset into dataloaders which will help us in training the model!

You can check the implementation  of the `get_train_val_datasets_mtfl()` in the hidden cell `Dataset Definition and Helper Functions`

```python
"""
Create dataloaders for the train and validation datasets
"""

train_dataset, val_dataset = get_train_val_datasets_mtfl()

# Change this for a different batch size
batch_size = 16

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

train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    num_workers=2,
    worker_init_fn=seed_worker,
    generator=g_seed
    )

val_loader = DataLoader(
    dataset=val_dataset,
    batch_size=batch_size,
    num_workers=2,
    worker_init_fn=seed_worker,
    generator=g_seed
    )
```

We visualize the samples from the dataset, we can see  that the input will be an image and there are multiple labels for the same image. The description of each label -

 - gender : 0 - male, 1 - female
 - smiling : 0 - yes, 1 - no
 - wearing glasses : 0 - yes, 1 - no
 - head pose : 0° - 0, +30° - 1, -30° - 2, +60° - 3, -60° - 4

```python
sample = train_dataset[0]
visualize_mtfl_sample(sample)
```

Can you interpret the above image and the labels corresponding to the image? Let's have a look at some more instances from the dataset.

## Section 2.2: Defining the model

A typical multi-task model looks like below, with shared layers being layers where knowledge is shared. Task-specific layers, as the name suggests, are specific to the tasks.

For the shared layers we will use a pre-trained backbone network. Here you can try out various models, we have provided an example of using resnet18 backbone. We have to remove the last fully connected layer, because that is specifically used for classificiation task. Futher, for each task we have some fully connected layers, you can play around with more layers on your own!

### Coding Exercise 2.2: Creating a Multi-Task model

Complete the custom model class `Multi_task_model`.
Complete the `forward()` function, by adding your solution to collect outputs for all the tasks using the fully connected for the corresponding task.

You can find an entire list of pre-trained models provided by PyTorch [here](https://pytorch.org/vision/stable/models.html)

```python
class Multi_task_model(nn.Module):
  def __init__(self, pretrained=True, num_tasks=4, load_file=None,
               num_labels_per_task=[2, 2, 2, 5]):
    super(Multi_task_model, self).__init__()
    self.backbone = models.resnet18(pretrained=pretrained)  # You can play around with different pre-trained models
    if load_file:
      self.backbone.load_state_dict(torch.load(load_file))
    self.backbone = torch.nn.Sequential(*(list(self.backbone.children())[:-1]))  # Remove the last fully connected layer

    if pretrained:
      for param in self.backbone.parameters():
        param.requires_grad = False

    self.fcs = []

    self.num_tasks = num_tasks

    for i in range(self.num_tasks):
      self.fcs.append(nn.Sequential(
          nn.Linear(512, 128),
          nn.ReLU(),
          nn.Dropout(0.4),
          nn.Linear(128, num_labels_per_task[i]),
          ################################
          # Add more layers if you want! #
          ################################
          nn.Softmax(dim=1),
      ))

      self.fcs = nn.ModuleList(self.fcs)

  def forward(self, x):
    x = self.backbone(x)
    x = torch.flatten(x, 1)
    outs = []
    #################################################
    # Collect outputs for each task
    # Fill in missing code below (...),
    # then remove or comment the line below to test your implementation
    # raise NotImplementedError("Complete the model!")
    #################################################
    for i in range(self.num_tasks):
      outs.append(self.fcs[i](x))
    return outs

# add event to airtable
atform.add_event('Coding Exercise 2.2: Creating a Multi-Task model')
```

```python
# to_remove solution
class Multi_task_model(nn.Module):
  def __init__(self, pretrained=True, num_tasks=4, load_file=None,
               num_labels_per_task=[2, 2, 2, 5]):
    super(Multi_task_model, self).__init__()
    self.backbone = models.resnet18(pretrained=pretrained)  # You can play around with different pre-trained models
    if load_file:
      self.backbone.load_state_dict(torch.load(load_file))
    self.backbone = torch.nn.Sequential(*(list(self.backbone.children())[:-1]))  # Remove the last fully connected layer

    if pretrained:
      for param in self.backbone.parameters():
        param.requires_grad = False

    self.fcs = []

    self.num_tasks = num_tasks

    for i in range(self.num_tasks):
      self.fcs.append(nn.Sequential(
          nn.Linear(512, 128),
          nn.ReLU(),
          nn.Dropout(0.4),
          nn.Linear(128, num_labels_per_task[i]),
          ################################
          # Add more layers if you want! #
          ################################
          nn.Softmax(dim=1),
      ))

      self.fcs = nn.ModuleList(self.fcs)

  def forward(self, x):
    x = self.backbone(x)
    x = torch.flatten(x, 1)
    outs = []
    for i in range(self.num_tasks):
      outs.append(self.fcs[i](x))
    return outs

# add event to airtable
atform.add_event('Coding Exercise 2.2: Creating a Multi-Task model')
```

To avoid any major blackout due to multiple downloads, we download a pretrained ResNet model locally.

**Note:** If `pretrained=False` and `load_file` exists, we load the pretrained model from a file. If you want to use a different model, set `load_file` to `None`.

```python
# @title Download `resnet18` pretrained
url = "https://osf.io/2kd98/download"
fname = "resnet18-f37072fd.pth"
r = requests.get(url, allow_redirects=True)

with open(fname, 'wb') as fd:
  fd.write(r.content)
```

```python
"""
Initialize two models with and without pre-trained weights
"""
model_with_pre_trained_backbone = Multi_task_model(pretrained=False,
                                                   load_file=fname).to(DEVICE)
model_without_pre_trained_backbone = Multi_task_model(pretrained=False).to(DEVICE)
```

Let's define the loss function and optimizer. Since this is a classification task, we will be using cross-entropy loss. For the optimizer we will be using Stochastic Gradient descent.

```python
criterion = nn.CrossEntropyLoss()

# Observe that all parameters are being optimized
optimizer_without_pre_trained_backbone = optim.SGD(
    model_without_pre_trained_backbone.parameters(),
    lr=0.001,
    momentum=0.9
    )

optimizer_pre_trained_backbone = optim.SGD(
    model_with_pre_trained_backbone.parameters(),
    lr=0.001,
    momentum=0.9
    )
```

## Section 2.3: Training model without pretrained backbone

```python
model_without_pre_trained_backbone = train_multi_task(
    model_without_pre_trained_backbone,
    train_loader,
    val_loader,
    criterion,
    optimizer_without_pre_trained_backbone,
    epochs=5, device=DEVICE
    )
```

    ======== Epoch 1 / 5 ========

    /usr/local/lib/python3.7/dist-packages/torch/nn/functional.py:718: UserWarning: Named tensors and all their associated APIs are an experimental feature and subject to change. Please do not use them for anything important until they are released as stable. (Triggered internally at  /pytorch/c10/core/TensorImpl.h:1156.)
      return torch.max_pool2d(input, kernel_size, stride, padding, dilation, ceil_mode)

    Training => Avg. Task Accuracy: 0.687125
    Validation => Avg. Task Accuracy: 0.69675

    ======== Epoch 2 / 5 ========
    Training => Avg. Task Accuracy: 0.7114687500000001
    Validation => Avg. Task Accuracy: 0.7155

    ======== Epoch 3 / 5 ========
    Training => Avg. Task Accuracy: 0.7226250000000001
    Validation => Avg. Task Accuracy: 0.71575

    ======== Epoch 4 / 5 ========
    Training => Avg. Task Accuracy: 0.73009375
    Validation => Avg. Task Accuracy: 0.712125

    ======== Epoch 5 / 5 ========
    Training => Avg. Task Accuracy: 0.7365937499999999
    Validation => Avg. Task Accuracy: 0.726125

## Section 2.4: Training model with pretrained backbone

```python
model_pre_trained_backbone = train_multi_task(model_with_pre_trained_backbone,
                                              train_loader,
                                              val_loader,
                                              criterion,
                                              optimizer_pre_trained_backbone,
                                              epochs=5, device=DEVICE)
```

    ======== Epoch 1 / 5 ========
    Training => Avg. Task Accuracy: 0.7601562499999999
    Validation => Avg. Task Accuracy: 0.81575

    ======== Epoch 2 / 5 ========
    Training => Avg. Task Accuracy: 0.8253125000000001
    Validation => Avg. Task Accuracy: 0.83375

    ======== Epoch 3 / 5 ========
    Training => Avg. Task Accuracy: 0.84871875
    Validation => Avg. Task Accuracy: 0.836625

    ======== Epoch 4 / 5 ========
    Training => Avg. Task Accuracy: 0.8640000000000001
    Validation => Avg. Task Accuracy: 0.839

    ======== Epoch 5 / 5 ========
    Training => Avg. Task Accuracy: 0.8728437500000001
    Validation => Avg. Task Accuracy: 0.83975

## Section 2.5: Section Summary

We can summarize this section as follows

* Transfer learning helps in initializing the model better and thus makes learning of the model faster. It also gives better performance compared to the same model that was trained from scratch. Below is a table which contains validation accuracies after 5 epochs (your accuracies can be slightly different when you run the training loops. It will also depend on the number of epochs and the model you have implemented)-

|           Method          | Validation Accuracy |
|:-------------------------:|:-------------------:|
|   With Transfer Learning  |      **0.779**      |
| Without Transfer Learning |        0.728        |

* Multi-task learning helps us learn multiple tasks together, by sharing knowledge across layers. Task specific layers then help the model learn task specific features.

---
# Section 3: Interpretability

```python
# @title Video 3: Interpretability
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"BV1Ey4y1j7Jj", 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"P_QtnN1_wMM", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

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

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

display(out)
```

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

Lets' find the most important feature for Cancer prediction and see if it belongs to the set of two most important features for the same problem!

## Section 3.1: Defining the problem setup

Imagine that you want to apply all the knowledge that you have gained from this course to a real health problem, say predicting cancer. You develop an algorithm for predicting cancer using certain input features from patient data.

This is a composite model that takes in three binary variables: does the patient have a family history of cancer (1=yes, 0=no), does the patient smoke (1=yes, 0=no), and is the patient young (age < 40) (1=yes, 0=no).

The algorithm does not favour either of the outcomes, so $P(cancer) = P(no\_cancer) = .5$

You wish to find the most important feature according to the model and you have the following list of conditional probabilities.

\\

\begin{align}
P(family\_history = 1  | no\_cancer) = .1 &, P(family\_history = 1 | cancer) = .9 \\
P(smoker = 1 | no\_cancer) = .05 &, P(smoker = 1| cancer) = .8 \\
P(young = 1 | no\_cancer) = .01 &, P(young = 1 | cancer) = .71
\end{align}

## Section 3.2: Defining a feature importance metric

Let's define a metric that can help us quantify how important a feature (or a given set of features) is!

### Think! 3.2.1: Think of a suitable feature importance metric!

Take a moment to think about feature importance and a simple metric for quantifying it! Remember that the feature importance should depend on how useful the feature is for predicting the outcome *alone*.

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type your answer here and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q1' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type your answer here and click on `Submit!`', placeholder='Type something')

    Button(description='Submit!', style=ButtonStyle())

```python
# to_remove explanation

"""
Right the model answer here.
""";
```

Now discuss the following with the group:

1. Each member of the group should briefly talk about the feature metric they came up with and why they think it is suitable for the given problem setup.
2. Identify the most important characterics of a good feature importance metric. (As different members of the group used different criteria to select their metrics, find out the most important criteria that helped!). Try to limit yourself to the two most important characteristics.

Note down the most important characteristic here!

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type the most important characteristic and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q2' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type the most important characteristic and click on `Submit!`', placeholder='Type something')

    Button(description='Submit!', style=ButtonStyle())

Also note down the second most important characteristic here.

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type the second most important characteristic and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q3' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type the second most important characteristic and click on `Submit!`', placeholder='Type somet…

    Button(description='Submit!', style=ButtonStyle())

Thank you for coming up with some guidelines for what makes a feature importance metric good!

Although the metrics defined by you must be great, we here define a simple metric for maintaining consistency throughout the rest of this tutorial.

Let's assume that we wish to find the K best features. We know that a set of K features would be most important if they can predict the outcome with high confidence and better than any other set of the same size! We could hence try to predict the outcome using only the given set of features and record the accuracy. If this accuracy is higher than that using any other set of the same size, then we can say that the given set has the most important K features!

In essence, we would want to find the accuracies considering all feature sets of size K and then pick the one with the highest accuracy.

If we wish to define a loss instead (the lower the better), we could simply extend the above notion as follows:

\begin{equation}
Loss = 1 - Accuracy
\end{equation}

### Think! 3.2.2: Is our feature importance metric good?

Now that we have provided a new feature importance metric, think of the following:

1. What do you think of this new feature importance metric, and how is it different from the one that you came up with?
2. Does it satify the criteria that you came up with?
3. If not, why? Does the metric need to be modified or the criteria?

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type your answer here and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q4' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type your answer here and click on `Submit!`', placeholder='Type something')

    Button(description='Submit!', style=ButtonStyle())

```python
# to_remove explanation

"""
Write the model answer here.
""";
```

## Section 3.3: Finding the most important feature

Considering the feature importance metric that we defined above, let's try to find the most important and the two most important features!

The accuracy can be calculated using the probabilities specified above. Here are the accuracies for different feature sets! (Note that we have not detailed the calculation of these accuracies. If you are curious, refer to the bonus section!)

For a detailed explanation, see *Bonus 3*.

|           Feature(s)          | Accuracy |
|:---------------------------:|:-------------------:|
|   Family History            |        0.90         |
| Is a Smoker                 |        0.875        |
| Is Young                    |        0.85         |
| Family History, Is a Smoker |        0.917        |
| Is a Smoker, Is Young       |        0.941        |
| Family History, Is Young    |        0.931        |

### Think! 3.3.1: Finding important features

Given the table above, what is the single most important feature?

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type your answer here and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q5' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type your answer here and click on `Submit!`', placeholder='Type something')

    Button(description='Submit!', style=ButtonStyle())

```python
# to_remove explanation

"""
Answer: Family History

Why?

The most important feature would be the one which gives the highest accuracy
when used alone. This makes Family History the most important feature using it
alone results in an accuracy of 0.9 as opposed to 0.875 and 0.85 when using
`Is_a_smoker` and `Is_young`, respectively
""";
```

Can you also find out the second most important features?

```python
# @title Student Response
from ipywidgets import widgets

text=widgets.Textarea(
   value='Type your answer here and click on `Submit!`',
   placeholder='Type something',
   description='',
   disabled=False
)

button = widgets.Button(description="Submit!")

display(text,button)

def on_button_clicked(b):
   atform.add_answer('q6' , text.value)
   print("Submission successful!")

button.on_click(on_button_clicked)
```

    Textarea(value='Type your answer here and click on `Submit!`', placeholder='Type something')

    Button(description='Submit!', style=ButtonStyle())

```python
# to_remove explanation

"""
Answer: Is a Smoker and Is Young
""";
```

The most important feature is not one of the two most important features! Can you find out why this is the case?

## Section 3.4: Section Summary

In this section, we discussed the following:

* What is Interpretability and why is it important?

* How can input features be used to understand the model's predictions (aka feature importances)?

---
# Summary

Recall the problem that you set out to solve at the beginning of this tutorial.

```python
# @markdown *Run me to print the problem that you wanted to solve!*
print(Problem_that_you_want_to_solve)
```

Take a moment to think about the following:

1. Did this tutorial help you find some ideas to solve this problem?
2. Can you now come up with a solution to this problem by using all the tools that you learnt from this course?

We sincerely hope that your answers to these two questions are yes and YES! Recall all that you learnt from this course and how this knowledge and practical experience can help with a problem that *you* want to solve. Go back and experiment with your solutions and see if you can solve this problem that matters to you!

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

  <a href= "https://portal.neuromatchacademy.org/api/redirect/to/1d7fcd5d-f1e9-4ac5-ae58-b0ade54a4f87?data=eyJmb3JtX2lkIjogImFwcG43VmRQUnNlU29NWEVHIiwgInRhYmxlX25hbWUiOiAiVzNENF9UMiIsICJhbnN3ZXJzIjoge30sICJldmVudHMiOiBbeyJldmVudCI6ICJpbml0IiwgInRzIjogMTYyOTQ1MTgzMS45NzMwODE4fSwgeyJldmVudCI6ICJWaWRlbyAxOiBUaGUgRnV0dXJlIG9mIExlYXJuaW5nIiwgInRzIjogMTYyOTQ1MTgzNy4xNzMzODd9LCB7ImV2ZW50IjogIlZpZGVvIDI6IFRyYW5zZmVyIGFuZCBNdWx0aS1UYXNraW5nIExlYXJuaW5nIiwgInRzIjogMTYyOTQ1MjU3My4wNTQzNTA5fSwgeyJldmVudCI6ICJDb2RpbmcgRXhlcmNpc2UgMi4yOiBDcmVhdGluZyBhIE11bHRpLVRhc2sgbW9kZWwiLCAidHMiOiAxNjI5NDUzNzU5LjE2MTIwNzR9LCB7ImV2ZW50IjogIkNvZGluZyBFeGVyY2lzZSAyLjI6IENyZWF0aW5nIGEgTXVsdGktVGFzayBtb2RlbCIsICJ0cyI6IDE2Mjk0NTM3NjEuMzg3MzUwNn0sIHsiZXZlbnQiOiAiQ29kaW5nIEV4ZXJjaXNlIDIuMjogQ3JlYXRpbmcgYSBNdWx0aS1UYXNrIG1vZGVsIiwgInRzIjogMTYyOTQ1Mzc5My4zMTMwMDZ9LCB7ImV2ZW50IjogIlZpZGVvIDM6IEludGVycHJldGFiaWxpdHkiLCAidHMiOiAxNjI5NDU1MDI4LjQ5NDM3OTN9LCB7ImV2ZW50IjogInVybCBnZW5lcmF0ZWQiLCAidHMiOiAxNjI5NDU1MzMyLjYxOTUxNTR9XX0%3D" target="_blank">

---
# Bonus 1: Introduction to Meta Learning

*Time estimate: ~20mins*

In this section, we will *literally* learn to learn by exploring the concept of Meta Learning. Meta Learning attempts to improve the generalization capabilities of neural networks by teaching them *how to learn new tasks fast*. We aim to introduce you to the following topics:

1. Meta-Learning and its applications
2. Few-shot classification (the most common application of Meta-Learning in supervised classification settings)
3. One-shot Learning with Convolutional Siamese Networks

```python
# @title Video 4: Meta Learning
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"BV1zU4y1n77d", 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"8grBwtrzcDI", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

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

# add event to airtable
atform.add_event('Video 4: Meta Learning')

display(out)
```

Meta-Learning is most commonly observed as Few-shot Learning in Supervised learning settings.

Few-shot Learning aims to answer the following question: How can a neural network learn a task well with *very little* data?

In the context of supervised classification, K-shot learning refers to learning with only K examples of each class. An extension of this would be N-way K-shot learning, which attempts to train a network with only K examples of each of the N classes. Let's take an example of a 5-way 1-shot problem:

Now that we know what Few-shot learning is, let's take a look at a One-shot classification problem using Siamese Networks!

## Bonus Section 1.1: Introduction to Omniglot

The Omniglot data set is a a standardized benchmark for evaluating the performance of Few-shot Learning algorithms. It contains 1623 different handwritten characters from 50 different alphabets. The alphabets range from well-established international languages like Latin and Korean to lesser known local dialects. Fictitious character sets such as Aurek-Besh and Klingon are also included. Here are a few examples from the Omniglot dataset.

## Bonus Section 1.2: Convolutional Siamese Networks

The simplest way to evaluate where an image belongs to a class is by comparing it with other images of the same class. Convolutional Siamese networks help us in comparing two images and quantify how similar or different they are from one another. This is known as verification.

Verification of two images is done as follows: Both the images are passed through the convolutional network to generate *feature vectors* representing them. These feature vectors are then compared for similarity. (Find a better image for siamese network maybe)

How can we compare if two vectors are similar? By using a distance metric! **L1 distance** is one such metric that evaluates the distance between two vectors by computing the absolute value of the difference of invidual components of both vectors. More specifically, given vectors `v1` and `v2`, the component-wise L1 distance would be as follows:

```
l1_distance = abs(v1 - v2)
```

After this, the problem boils down to a binary classification problem where two images either belong to the same class or not!

### Bonus Coding Exercise 1.2: Creating a Convolutional Siamese Network

Let's create a Convolutional Siamese Network! The structure of the network is created in the `ConvSiameseNet` class. You have to compute the L1 distance (`l1_distance`) between the feature vectors (`x1_fv` and `x2_fv`). You can use `torch.abs()` to compute the absolute value of a tensor.

```python
# Define the Siamese Network
class ConvSiameseNet(nn.Module):
  """
  Convolutional Siamese Network from "Siamese Neural Networks for One-shot Image Recognition"
  Paper can be found at http://www.cs.toronto.edu/~rsalakhu/papers/oneshot1.pdf
  """

  def __init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(1, 64, 10)
    self.conv2 = nn.Conv2d(64, 128, 7)
    self.conv3 = nn.Conv2d(128, 128, 4)
    self.conv4 = nn.Conv2d(128, 256, 4)
    self.pool = nn.MaxPool2d(2, 2)
    self.fc1 = nn.Linear(256*6*6, 4096)
    self.fc2 = nn.Linear(4096, 1)

  def model(self, x):
    x = self.pool(F.relu(self.conv1(x)))
    x = self.pool(F.relu(self.conv2(x)))
    x = self.pool(F.relu(self.conv3(x)))
    x = F.relu(self.conv4(x))
    x = torch.flatten(x, 1)
    x = torch.sigmoid(self.fc1(x))
    return x

  def forward(self, x1, x2):
    x1_fv = self.model(x1)
    x2_fv = self.model(x2)
    ############################################################################
    ## TODO: Calculate the component-wise l1_distance between x1_fv and x2_fv ##
    # Fill out function and remove
    raise NotImplementedError("Student exercise: Calculate l1_distance")
    ############################################################################
    # Calculate L1 distance (as l1_distance) between x1_fv and x2_fv
    l1_distance = ...

    return self.fc2(l1_distance)
```

```python
# to_remove solution
# Define the Siamese Network
class ConvSiameseNet(nn.Module):
  """
  Convolutional Siamese Network from "Siamese Neural Networks for One-shot Image Recognition"
  Paper can be found at http://www.cs.toronto.edu/~rsalakhu/papers/oneshot1.pdf
  """

  def __init__(self):
    super().__init__()
    self.conv1 = nn.Conv2d(1, 64, 10)
    self.conv2 = nn.Conv2d(64, 128, 7)
    self.conv3 = nn.Conv2d(128, 128, 4)
    self.conv4 = nn.Conv2d(128, 256, 4)
    self.pool = nn.MaxPool2d(2, 2)
    self.fc1 = nn.Linear(256*6*6, 4096)
    self.fc2 = nn.Linear(4096, 1)

  def model(self, x):
    x = self.pool(F.relu(self.conv1(x)))
    x = self.pool(F.relu(self.conv2(x)))
    x = self.pool(F.relu(self.conv3(x)))
    x = F.relu(self.conv4(x))
    x = torch.flatten(x, 1)
    x = torch.sigmoid(self.fc1(x))
    return x

  def forward(self, x1, x2):
    x1_fv = self.model(x1)
    x2_fv = self.model(x2)
    # Calculate L1 distance (as l1_distance) between x1_fv and x2_fv
    l1_distance = torch.abs(x1_fv - x2_fv)

    return self.fc2(l1_distance)
```

## Bonus Section 1.3: Verification using Siamese Networks

Let's train the siamese network we created on verification of images. We'll take pairs of images from the Omniglot dataset and train the network to identify if the pairs belong to the same class or not.

Let's first get the Omniglot dataset with pairs of images. A pair is labelled "1" if the images are of the same character, and "0" otherwise.

```python
# @title Download Omniglot dataset with pairs
import zipfile, os, requests

# original location: https://github.com/brendenlake/omniglot/tree/master/python
os.chdir('/content')
print(f'Change dir: {os.getcwd()}')

dirname = 'data/omniglot-py/'
if not os.path.exists(dirname):
  os.makedirs(dirname)

fname = 'images_background.zip'
url = "https://osf.io/6hq9u/download"

if not os.path.exists(dirname + 'images_background'):
  print('Downlading the dataset...')
  r = requests.get(url, allow_redirects=True)
  with open(dirname + fname, 'wb') as fd:
    fd.write(r.content)
  with zipfile.ZipFile(dirname + fname, 'r') as zip_ref:
    zip_ref.extractall(dirname)
  print('Dataset is downloaded.')
else:
  print('Dataset has already been downloaded.')
```

```python
"""
Load train and validation datasets for training the Siamese Network
"""

train_dataset, val_dataset = get_train_val_datasets(background_dataset_size=10000, val_split=0.2, download=False)
```

```python
"""
Visualize a sample from the training dataset
"""

# Change this to visualize another sample from the dataset
sample_idx = 1
sample = train_dataset[sample_idx]

visualize_siamese_sample(sample)
```

```python
"""
Create dataloaders for the train and validation datasets
"""

# Change this for a different batch size
batch_size = 16

train_loader = DataLoader(
    dataset=train_dataset,
    batch_size=batch_size,
    num_workers=2,
    worker_init_fn=seed_worker,
    generator=g_seed
    )

val_loader = DataLoader(
    dataset=val_dataset,
    batch_size=batch_size,
    num_workers=2,
    worker_init_fn=seed_worker,
    generator=g_seed
    )
```

Now that we have the necessary dataloaders, let's train a siamese network!

```python
"""
Train the network on Omniglot
"""

siamese_net = ConvSiameseNet()

criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(siamese_net.parameters(), lr=0.0001)

# Change this to train for any number of epochs
num_epochs = 10

for epoch in range(num_epochs):
  print(f"\n Epoch {epoch + 1} / {num_epochs} ================================")

  train_siamese_network(siamese_net, criterion, optimizer, train_loader, DEVICE)
  evaluate_siamese_network(siamese_net, criterion, val_loader, DEVICE)
```

## Bonus Section 1.4: One-shot Classification with Siamese Networks

Now that we have a siamese network trained on verification, let's try to extend it to one-shot classification. How can we do this?

Let's first try to understand what the current network offers: Given two images, find out if they belong to the same class or not.

What does N-way one-shot classification ask for? Given one image from each of the N classes (the support set), find out which class does the query image belong to.

Take a moment to think about how the verification network can be extended to work in the one-shot setting.

Given a query image, compare it with different images in the support set using our siamese network. Pick the class that is most similar to the query image!

Let's get the test dataset where one sample corresponds to a query image and N support images!

```python
# @title Download the test dataset
import zipfile, os, requests

# original location: https://github.com/brendenlake/omniglot/tree/master/python

dirname = 'data/omniglot-py/'
if not os.path.exists(dirname):
  os.makedirs(dirname)

fname = 'images_evaluation.zip'
url = "https://osf.io/uq4gw/download"

if not os.path.exists(dirname + 'images_evaluation'):
  print('Downlading the dataset...')
  r = requests.get(url, allow_redirects=True)
  with open(dirname + fname, 'wb') as fd:
    fd.write(r.content)
  with zipfile.ZipFile(dirname + fname, 'r') as zip_ref:
    zip_ref.extractall(dirname)
  print('Dataset is downloaded.')
else:
  print('Dataset has already been downloaded.')
```

```python
"""
Load the test dataset for N-way One-shot classification
"""

# Change this to change the number of classes in the support set
n_ways = 5

test_dataset = get_test_dataset(n_ways=n_ways, download=True)
```

```python
"""
Visualize a sample from the test dataset
"""

# Change this to visualize another sample from the dataset
sample_idx = 1
sample = test_dataset[sample_idx]

visualize_one_shot_sample(sample)
```

We are in the endgame now. Let's look at how our siamese network performs on N-way One-shot classification!

```python
"""
Evaluate our Siamese Network on N-way One-shot classification
"""

batch_size = 16

test_loader = DataLoader(
    dataset=test_dataset,
    batch_size=batch_size,
    num_workers=2,
    worker_init_fn=seed_worker,
    generator=g_seed
    )

evaluate_one_shot(siamese_net, test_loader, DEVICE)
```

Congratulations! We successfully solved our first Meta-Learning problem. Feel free to run through this section again with different parameters and compare the differences!

## Bonus Section 1.5: Section Summary

In this section, we learnt the following (try to answer these questions to ensure that you understood the content fairly well):

* What is Meta-Learning and how is it used to solve various problems with Deep Learning?

* What is Few-shot Learning? We took a look at what is N-way K-shot classification using the Omniglot dataset and Convolutional Siamese Networks.

---
# Bonus 2: Continual and Life-Long Learning

```python
# @title Video 5: Continual and Life-Long Learning
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"BV1uX4y1c7Yh", 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"-lmY_lc75As", width=854, height=480, fs=1, rel=0)
  print("Video available at https://youtube.com/watch?v=" + video.id)
  display(video)

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

# add event to airtable
atform.add_event('Video 5: Continual and Life-Long Learning')

display(out)
```

## Bonus Think! 2: Why are Continual and Life-Long Learning tough?

Take a moment to think about the following:

1. You have had an extensive tutorial on Continual Learning. Can you quickly recall the different problems with Continual Learning and how these problems were solved?
2. What do you think makes Life-Long Learning tough to implement? How is this learning paradigm different from those that you have seen before?

---
# Bonus 3 - Feature Importance Tutorial

If you wish to understand how the accuracies are calculated in Section 3.3, continue reading!

Recall the problem setup:

There exist binary-valued random variables $X^{(1)}, X^{(2)}, X^{(3)}, Y \in {0,1}$ such that $X^{(1)}, X^{(2)}$, and $X^{(3)}$ are conditionally independent (given $Y$).

Let $P(Y=1) = 1/2$. Then the joint distribution of $X^{(1)}, X^{(2)}, X^{(3)}, Y$ is specified by the conditional probabilities $P(X_i=1 | Y=0)$ and $P(X_i=1 | Y=1)$, for $i=1,2,3$:

\begin{align}
P(X^{(1)}=1|Y=0)=0.10 &, P(X^{(1)}=1|Y=1)=0.0.90 \\
P(X^{(2)}=1|Y=0)=0.05 &, P(X^{(2)}=1|Y=1)=0.0.80 \\
P(X^{(3)}=1|Y=0)=0.01 &, P(X^{(1)}=1|Y=1)=0.0.71
\end{align}

## Define a feature importance metric

Here we'll use 0-1 loss to define feature importance. We can imagine using $X^{(1)}, X^{(2)}$, and $X^{(3)}$ to predict $Y$. $Y$ can only be 0 or 1, so your predictions must be 0 or 1 and will either be right or wrong (i.e, perfect for 0-1 loss).

## Calculate the individual feature importance (0-1 loss) for  $X^{(1)}$, $X^{(2)}$, and $X^{(3)}$

**Hint:** You can accomplish this by finding the probability that  X  and  Y  match. You're given $P(X^{(1)}=1|Y=0)$, but you need $P(X^{(1)}=0|Y=0)$ . Recall that $P(Y)=1/2$ and compute an equally weighted average of the two accuracies you can compute given the two possible values of $X^{(1)}$ .

**Solution:** If $P(X^{(1)} = 1|Y = 0)=0.10$, then $P(X^{(1)} = 0|Y = 0)=0.90$.

There's a symmetry we can take advantage of here: we know that 90% of the time $Y=1$ we have $X=1$, which implies that 90% of the time $X=1$ we have $Y=1$, which is a more natural way of thinking about prediction.

This means we predict correctly 90% of the time when $Y=0$, and we predict correctly 90% of the time when $Y=1$. Since $P(Y)=1/2$, we have 0-1 loss $L = 1-(0.5 \times 0.9 + 0.5 \times 0.9)=0.1$.

In total, we have:

\begin{align}
L_1 =& 1 - (0.5 \times 0.9 + 0.5 \times 0.9) = 0.1 \\
L_2 =& 1 - (0.5 \times 0.95 + 0.5 \times 0.8) = 0.125 \\
L_3 =& 1 - (0.5 \times 0.99 + 0.5 \times 0.71) = 0.15.
\end{align}

**Check:** If you did not observe that feature $X^{(1)}$ is the most important feature by virtue of having the smallest 0-1 loss (0.1), check your calculations before proceeding.

### Calculate the importance of feature pairs (0-1 loss) for $\{X^{(1)}, X^{(2)}\}$, $\{X^{(1)}, X^{(3)}\}$, and $\{X^{(2)}, X^{(3)}\}$

### Let's start with $\{X^{(1)}, X^{(2)}\}$

Working out these calculations is a bit tedious, so we'll provide various tables and descriptions of their origins.

First, we compute conditional PMFs $P\left( X^{(1)}, X^{(2)}∣Y \right)$ by multiplying the provided conditionally independent probabilities together.

**Conditional PMFs:**

| $P(X_1,X_2|Y=0)$ |$X_2=0$  |$X_2=1$ |
|------------------|---------|--------|
| $X_1=0$          | 0.855   | 0.045  |
| $X_1=1$          | 0.095   | 0.005  |

| $P(X_1,X_2|Y=1)$ |$X_2=0$  |$X_2=1$ |
|------------------|---------|--------|
| $X_1=0$          | 0.02    | 0.08   |
| $X_1=1$          | 0.18    | 0.72   |

We can then obtain a marginal PMF for $\{X^{(1)}, X^{(2)}\}$ by element-wise averaging these two 2x2 tables because $P(Y)=1/2$.

**Marginal PMF:**

| $P(X_1,X_2)$ |$X_2=0$  |$X_2=1$ |
|--------------|---------|--------|
| $X_1=0$      | 0.4375  | 0.0625 |
| $X_1=1$      | 0.1375  | 0.3625 |

We now use the conditional PMFs to evaluate the conditional probability expressions we care about: $P\left( Y∣X^{(1)}, X^{(2)} \right)$.

**Conditional Probabilities: $P \left( Y=\{0, 1\}|X_1,X_2 \right)$**

\begin{equation}
\frac{P(X_1,X_2 | Y=0)}{P(X_1,X_2|Y=0) + P(X_1,X_2|Y=1)}
\end{equation}

| $P(Y=0|X_1,X_2)$ |$X_2=0$   |$X_2=1$   |
|------------------|----------|----------|
| $X_1=0$          | 0.977143 | 0.36     |
| $X_1=1$          | 0.345455 | 0.006897 |

\begin{equation}
\frac{P(X_1, X_2 | Y=1)}{P(X_1,X_2|Y=0) + P(X_1,X_2|Y=1)}
\end{equation}

| $P(Y=1|X_1,X_2)$ |$X_2=0$    |$X_2=1$   |
|------------------|-----------|----------|
| $X_1=0$          | 0.022857  | 0.64     |
| $X_1=1$          | 0.654545  | 0.993103 |

We now implement a 0-1 classifier that predicts $\hat{Y}=0$ or $\hat{Y}=1$ based on the element-wise maximum in these two tables. For example, when $X^{(1)}=0$ and $X^{(2)}=0$, there's a 0.977 probability that $Y=0$ and a 0.023 probability that $Y=1$. We would of course predict $\hat{Y}=1$ , and we expect to be correct 97.7% of the time.

We now know the expected probability of success for our classifier for each $\{X^{(1)}, X^{(2)}\}$ combination.

**Maximum Conditional Probability:**

| $P(\text{Correct}|X_1,X_2)$ |$X_2=0$   |$X_2=1$   |
|-----------------------------|----------|----------|
| $X_1=0$                     | 0.977143 | 0.64     |
| $X_1=1$                     | 0.654545 | 0.993103 |

Only one task remains for you to complete: compute the expected 0-1 loss for this classifier.

**Hint:** We know how likely we are to be correct given some $\{X(1), X(2)\}$ combination. We also know how likely those combinations are to appear.

**Solution:**

```python
marginal = np.array([[0.4375, 0.0625],
                     [0.1375, 0.3625]])
max_cond_prob = np.array([[0.977, 0.64],
                          [0.6545, 0.993]])
# We need the sum-product of these two arrays.
# (1) element-wise multiplication to produce one array.
# (2) sum the elements of the resulting array
accuracy = np.sum(np.multiply(marginal, max_cond_prob))
print(accuracy)
```

```python
# 0-1 loss is 1 - accuracy.
print(1 - accuracy)
```

**Check:** You should have gotten 0-1 loss for $\{X^{(1)}, X^{(2)}\}$ to be 0.0826.

### Let's move on to  $\{X^{(2)}, X^{(3)}\}$

Our claim was that the most important single feature, already found to be $\{X^{(1)}$\}, is not in the most important pair of features, so we just need to show that the 0-1 loss for $\{X^{(2)}, X^{(3)}\}$ is less than 0.0826.

First, we compute conditional PMFs $P \left( X^{(2)}, X^{(3)}∣Y \right)$ by multiplying conditionally independent probabilities together.

**Conditional PMFs:**

| $P(X_2,X_3|Y=0)$ |$X_3=0$  |$X_3=1$ |
|------------------|---------|--------|
| $X_2=0$          | 0.9405  | 0.0095 |
| $X_2=1$          | 0.0495  | 0.0005 |

| $P(X_2,X_3|Y=1)$ |$X_3=0$  |$X_3=1$ |
|-----------------|---------|--------|
| $X_2=0$          | 0.058   | 0.142  |
| $X_2=1$          | 0.232   | 0.568  |

We can then obtain a marginal PMF for $\{X^{(2)}, X^{(3)|\}$ by element-wise averaging these two 2x2 tables because $P(Y)=1/2$.

**Marginal PMF:**

| $P(X_2,X_3)$   |$X_3=0$    |$X_3=1$  |
|----------------|-----------|---------|
| $X_2=0$        | 0.49925   | 0.07575 |
| $X_2=1$        | 0.14075   | 0.28425 |

We now use the conditional PMFs to evaluate the conditional probability expressions we care about: $P \left( Y∣X^{(2)}, X^{(3)} \right)$.

**Conditional Probabilities: $P \left( Y=\{0, 1\}|X_2, X_3 \right)$**

\begin{equation}
\frac{P(X_2,X_3 | Y=0)}{P(X_2, X_3|Y=0) + P(X_2, X_3|Y=1)}
\end{equation}

| $P(Y=0|X_2,X_3)$ | $X_3=0$     |$X_3=1$      |
|------------------|-------------|-------------|
| $X_2=0$          | 0.941912869 | 0.062706271 |
| $X_2=1$          | 0.175843694 | 0.000879507 |

\begin{equation}
\frac{P(X_2,X_3 | Y=1)}{P(X_2, X_3|Y=0) + P(X_2, X_3|Y=1)}
\end{equation}

| $P(Y=0|X_2,X_3)$ | $X_3=0$     |$X_3=1$      |
|------------------|-------------|-------------|
| $X_2=0$          | 0.058087131 | 0.937293729 |
| $X_2=1$          | 0.824156306 | 0.999120493 |

We now implement a 0-1 classifier that predicts $\hat{Y}=0$ or $\hat{Y}=1$ based on the element-wise maximum in these two tables. For example, when $X^{(2)}=0$ and $X^{(3)}=0$, there's a 0.942 probability that $Y=0$ and a 0.058 probability that $Y=1$. We would of course predict $\hat{Y}=1$ , and we expect to be correct 94.2% of the time.

We now know the expected probability of success for our classifier for each $\{X^{(2)}, X^{(3)}\}$ combination.

**Maximum Conditional Probability:**

| $P(\text{Correct}|X_2,X_3)$ |$X_3=0$      |$X_3=1$      |
|-----------------------------|-------------|-------------|
| $X_1=0$                     | 0.941912869 | 0.937293729 |
| $X_1=1$                     | 0.824156306 | 0.999120493 |

Only one task remains for you to complete: compute the expected 0-1 loss for this classifier.

**Hint:** We know how likely we are to be correct given some $\{X(2), X(3)\}$ combination. We also know how likely those combinations are to appear.

**Solution:**

```python
marginal = np.array([[0.49925, 0.07575],
                     [0.14075, 0.28425]])
max_cond_prob = np.array([[0.941912869, 0.937293729],
                          [0.824156306,0.999120493]])
# We need the sum-product of these two arrays.
# (1) element-wise multiplication to produce one array.
# (2) sum the elements of the resulting array
accuracy = np.sum(np.multiply(marginal, max_cond_prob))
print(accuracy)
```

```python
# 0-1 loss is 1 - accuracy.
print(1 - accuracy)
```

**Check:** You should have gotten 0-1 loss for $\{X^{(2)}, X^{(3)}\}$ to be 0.05875.

## Conclusion

We demonstrated that our intuition could be misleading - it's not always the case that a feature that ranks highly among individual features would necessarily warrant being included at all when larger sets of features are considered.

Something to think about: What does this tell you about stepwise feature selection approaches?


```python
%pylab inline
import pandas as pd

import numpy as np
from __future__ import division
import itertools

import matplotlib.pyplot as plt
import seaborn as sns

import logging
logger = logging.getLogger()
```

    Populating the interactive namespace from numpy and matplotlib

4 Mining Data Streams
============

Data:
1. database
2. stream:
    + lost forever if the data arrived is not processed immediately or stored.
    + the data arrives so rapidly that it isn't feasible to store all.

    solution: summarization
        + sample/filter, then estimate
        + fixed-length "window"

### 4.1 The Stream Data Model

Examples of Steam Sources:

+ Sensor Data:
  Deploy a million sensors

+ Image Data:
  satellites, surveilance camera

+ Internet and Web Traffic

##### 4.1.3 Stream Queries
two ways:

1. standing queries
   preset, permanetly execting, and produce outputs at appropriate times.

2. ad-hoc queries
   a question asked once about the current state of a steam or streams.
   + a common sample approach is to store a **sliding windows** of each stream in the working store.

**generalizations** about stream algorithms:

1. Often, an **approximate answer** is much more **efficient** than an **exact solution**.

2. **hash function** introduces useful **randomness** intho the algorithm's behavior $\to$ approximate answer

### 4.2 Sampling Data in a Stream

extract reliable samples from a stream:
select $S \subset B$, $\, s.t. \, E[f(S)] = E[f(B)]$.

Example
Prob: What fraction of the typical user's queries were repeated over the past month?

given: a user has issued $s$ search queries one time, $d$ queries two times, and no queires more than twice.

solutions:

- _REAL_: $$tf = \frac{d}{s+d}$$

- _BAD_: store 1/10 th of the stream **elements**.
  + for s: $1/10 s$ one time
  + for d:
    $(1/10)*(1/10)*d = 1/100 d$ two times,
    $1/10*9/10 + 9/10*1/10 d = 18/100d$ one time.
  + calc:
  $$\hat{tf} = \frac{1/100d}{1/10s + 1/100d + 18/100d} = \frac{d}{10s + 19d}$$

- _GOOD_: pick 1/10th of the **users** and take all their searches for the sample.
  $$\hat{tf} = \frac{d}{s+d}$$

  sample: **hash**
  obtain a sample consisting of any rational fraction $a/b$ of the users by hashing user names to $b$ buckets. Add the search query to the sample if the hash value is less than $a$.

  **The General Sampling Problem**
  Our steam $x$ consists of tuples with $n$ components $\{x_n\}$(eg: user, query, time), and a subset of the components are the _key_ components $x_k$(eg: user).
  To take a sample of size $a/b$, we hash the _key_ value $h(x_k)$ for each tuple to $b$ buckets, and accept the tuple if $h(x_k) < a$.

  **varying the sample size**
  storage is limited, while users/queries grows as time goes on  ==> decrease the select fraction $a/b$.

  solution:
  1. $h(x) = \{0, 1, \dots, B-1\}$, $B$ is sufficient large.
  2. maintain a threshold $t$, we accept when $h(x) < t$.
  3. $t = t - 1$ if the allotted space is exceeded. remove all samples $h(x) = t$.
     [opt] efficient:
     + lower $t$ by more than 1.
     + maintaining an index on the hash value to find all those tuples quickly.

### 4.3 Filtering Streams

#### 4.3.2 The Bloom Filter

##### component
0. key values: $ S = \{s_1, s_2, \dotsm, s_m\}$.

1. array $B$ of $n$ bits, initially all 0's.

2. $h_i(s_j) \in [1, n], \quad i = 1, 2, \dotsm, k$.

##### intialization
1. $B$ are all 0's.

2. $B[h_i(s_j)] = 1$ for every hash function and every key value.

##### test
to test a key $K$:
if $ALL_{i=1}^{k} (h_i(K)) = 1$, then allow it, $K \in S$;  else reject it.

##### Analysis
we have $n$ targets, $k \times m$ darts.

1. a given dart will NOT hit a given target: $$ P = (n - 1) / n $$

2. all darts will NOT hit a given target:
   \begin{align}
        P &= (\frac{n-1}{n})^{k m} \\
          &= (1 - \frac{1}{n})^{n \frac{1}{n} k m} \\
          &\approx e^{-\frac{k m}{n}}
   \end{align}
   namely, the possibility of a bit remained 0 is $e^{-\frac{k m}{n}}$.

3. the possibility of a bit remained 1 is $1 - e^{-\frac{k m}{n}}$.

4. for a key $K$, the possibility of all of bits of $h_i(K)$ remained 1 is: $$ (1 - e^{-\frac{k m}{n}})^{k} $$

conclusion:
P[false negtive] = 0, P[false positive] = $ (1 - e^{-\frac{k m}{n}})^{k} $.

### 4.4 Counting Distinct Elements in a Stream
Solution:

1. keep a list of all the elements seens so far in main memeory. $\gets$ the number of distinct elements is not too great.

2. use more machines.

3. the Flajolet-Martin algorithm.

#### 4.4.2 the Flajolet-Martin Algorithm
**tail length** for $a$ and $h$:
the number of 0's at the end of the bit string $h(a)$,
where $a$ is a stream element, and $h$ is a hash function which maps the elements of an universal set to a bit-string that is sufficiently long.

**estimate**:
We use estimate $2^R$ for the number of distinct elements seen in the stream,
where $R$ is the maximum tail length of any $a$ seen so far in the stream.

**original idea**:
$\text{different elements }\uparrow \implies \text{ different bit-string } h(a) \uparrow \implies \text{ "unusual" value occurs } \implies $ use unusual properity to estimate.

##### Analysis
1. for a given $a$, the probility of $h(a)$ ending in at least $r$ 0's is: $(\frac{1}{2})^r$.

2. for $m$ distinct elements, the probility of none of them ending in at least $r$ 0's is:
   \begin{align}
       \neg P &= (1 - (\frac{1}{2})^r)^m \\
         &= (1 - 2^{-r})^{2^r 2^{-r} m} \\
         &\approx e^{-\frac{m}{2^r}}
   \end{align}

3. the probility of at least one of them ending in at least $r$ 0's is: $P = 1 - e^{-\frac{m}{2^r}}$

```python
x = np.linspace(0, 8, 100)

p = 1 - np.exp(-x)

plt.plot(x, p)
plt.xlabel(r'$\frac{m}{2^r}$')
plt.ylabel(r'probility of $h(a)$ ending in at least $r$ 0s')
```

namely,
\begin{equation}
    P \to \begin{cases}
        1 \quad \text{if } m \gg 2^r \\
        0 \quad \text{if } m \ll 2^r
    \end{cases}
\end{equation}
hence, $2^R$ is unlikely to be either much too high or much too low.

##### Combining Estimates
Suppose we have $k$ hash functions, then $k$ estimates $E_i$ of $m$ are obtained, but how to combine those estimates?

Solutions:

1. Average(E_i)
   cons: overestimate $\Leftarrow$ if $E_p \gg m \gg E_q$, $Ave(E_p, E_q) \to E_p$.

2. Median(E_i)
   cons: estimate is always a power of 2.

3. hybrid
   1. group $h_i$ to $b$ buckets containing $x$ hash functions.
      $x \geq C log_2 m$ in order to guarantee that any possible average can be obtained.
   2. average $E(h_i)$ in every buckets, obtain $E_b$.
   3. $\text{median}(E_b) \approx m$ when $b$ is sufficiently large,

space requirements:
keep one integer $R$ per hash function.

in practice, the **time** it takes to compute hash values would be the more significant limitation on the **number** of hash functions we use.

```python
logger.setLevel('WARN')

# exercise 4.4.1

def Flajolet_Martin_algorithm(stream, hash_func, bits):
    R = 0

    for e in stream:
        h = hash_func(e)
        logger.info('e: {}, h:{}'.format(e, h))

        mask = 1

        for r in range(bits):
            logger.info('mask: {}'.format(mask))
            if not (h & mask):
                mask <<= 1
            else:
                break

        logger.info('r: {}, R: {}'.format(r, R))

        if(r > R):
            R = r

    return 2**R

stream = np.array([3, 1, 4, 1, 5, 9, 2, 6, 5], dtype=np.int)
print('count unique elements, real: {}'.format(len(np.unique(stream))))

print('h(x) = x % 32, counts: {}'.format(Flajolet_Martin_algorithm(stream, lambda x: (x) % 32, 5)))
print('h(x) = (2x + 1) % 32, counts: {}'.format(Flajolet_Martin_algorithm(stream, lambda x: (2*x + 1) % 32, 5)))
print('h(x) = (3x + 7) % 32, counts: {}'.format(Flajolet_Martin_algorithm(stream, lambda x: (3*x + 7) % 32, 5)))
print('h(x) = 4x % 32, counts: {}'.format(Flajolet_Martin_algorithm(stream, lambda x: (4*x + 0) % 32, 5)))
```

    count unique elements, real: 7
    h(x) = x % 32, counts: 4
    h(x) = (2x + 1) % 32, counts: 1
    h(x) = (3x + 7) % 32, counts: 16
    h(x) = 4x % 32, counts: 16

```python
# exercise 4.4.2
x = range(1, 33)

y = map(lambda x: (2*x + 1) % 32, x)
df = pd.DataFrame({'y':y})
df.plot(kind='hist', xlim=(0,32), legend=False)
```

```python
y = map(lambda x: (3*x + 7) % 32, x)
df = pd.DataFrame({'y' : y})
df.plot(kind='hist', xlim=(0,32), legend=False)
```

```python
y = map(lambda x: (4*x + 0) % 32, x)
df = pd.DataFrame({'y':y})
df.plot(kind='hist', xlim=(0,32), legend=False)
```

### 4.5 Estimating Moments

suppose we have a stream $s = e_1 e_2 \dotsm e_n \in {S}^n$.

**kth-order moment** = $\sum_{i \in S} m_i^k$,
where $m_i$ is the number of occurrences of the $i$-th element in $S$.

Example:

1. 0th moment: the number of distinct elements, define $0^0 = 0$.

2. 1th moment: the length of the stream.

3. 2nd moment: named the *surprise number*, since it measure how uneven the distribution of elements in the stream is.

#### 4.5.2 The Alon-Matias-Szegedy Algorithm for Second Moments

We compute some number of variables,
for each variable $X$, we store:

1. $X.element \in S$.

2. $X.value = 1 + \sum_{i=k+1}^{n} I(e_i = X.element)$
   where $k = random(1,n)$ and $X.element = e_k$.

   namely, $X.value$ is the times $e_i$ picked randomly occurs in the position among $i, i+1, \dotsc, n$.

an **estimate** of the 2nd moment from any variable $X$ is: $$ E_x = n(2 X.value - 1)$$
2nd moment $\gets Ave_{x \in S} (E_x)$

##### Analysis: Why it works
Let $s = e_1 e_2 \dotsm e_n$, and $e \in S = {S_1, S_2, \dotsc, S_u}$.
    $c_i$ is the number of times $e_i$ appears among positions $i, i+1, \dotsc, n$.

for any picked position $i$,
\begin{align}
    E[n(2X.value - 1)] &= n(2 E[X.value] - 1) \\
                       &= n(2 \frac{1}{n} \sum_{i=1}^{n} (c_i) - 1) \\
                       &= \sum_{i=1}^{n} (2 c_i - 1) \\
                       &\text{for every $S_j$, existing an arithmetic progression } \sum_a 1+3+ \dotsb + (2m_a -1) \\
                       &= \color{red}{ \sum_{j=1}^{u} \sum_{k=1}^{m_{S_j}} (2k - 1) } \\
                       &= \sum_{j=1}^{u} \frac{m_{S_j}}{2} (1 + (2 m_{S_j} - 1)) \\
                       &= \sum_{j=1}^{u} m^2_{S_j} \quad = \text{ 2nd moment}
\end{align}

##### Higher-Order Moments
for any $k \geq 2$ and $v = X.value$,
estimate $k$th moments is $$n (v^k - (v-1)^k)$$.

because for given a, $\sum_{v=1}^{m_a} (v^k - (v-1)^k) = m_a^k$.

#### 4.5.5 Dealing With Infinite
In practice, $n$ is not fixed, but it grows with time.

+ store variable: $> log_2 n$ bits.

+ break the condition: every position is picked in **equal probility**.

**Solution**:
idea: maintain as many variable $X$ as possible at all times, and to throw some out as the stream grows.

Suppose we have space to store $s$ variables, and we have seen $n$ stream elements.
hence, the probility of any particular position is picked is $\frac{s}{n}$.

When the $(n+1)$th element arrivs, pick that position with probility $\frac{s}{n+1}$.
+ if not picked, $s$ remained.
+ if picked, one of $s$ is ruled out in equal probility, and replaced by the $(n+1)$ position of element $e(n+1)$ with initial value $1$.

**Analysis**: how the solution correct the probility of any position picked?

1. for the $(n+1)$th position, the probility of being picked is: $$\frac{s}{n+1}$$

2. for the original $s$ position, the probility of being picked(remained) is:
$$ \frac{s}{n} (1 - \frac{s}{n+1} \frac{1}{s}) = \frac{s}{n+1} $$

```python
#todo code
```

### 4.6 Counting Ones in a Window

**Question**: how many 1's are there in the last $k$ bits of a window with length $N$, for any $k \leq N$.

**solutions**:

1. exact counts: store all $N$ bits at cost.

2. approxicate counts: use $O(lg^2 N)$ bits to represent the window, in the Datar-Gionis-Indyk-Motwani Algorithm.

#### 4.6.2  The Datar-Gionis-Indyk-Motwani Algorithm

##### buckets
we divide the window into buckets.

**construction**

1. The timestamp of its right end.

2. The number of 1's in the bucket.

**rules**

1. The right end of a bucket is always a position with a 1.

2. Every position with a 1 is in some bucket.

3. No position is in more than one bucket.

4. There are **one or two** buckets of **any given size, up to some maximum size**.
   namely, suppose the size of the maximum bucket is $2^j$,
   then, the buckets of the windows should contain all size $2^j, 2^{j-1}, \dotsc, 2^0$, and any size of those should appear either one or two times.

5. All sizes must be a power of 2.

6. Buckets cannot **decrease** in size as we move to the left.

##### Storage Requirements

1. the maximum size of a bucket is $N$, hence it can be represented by $O(lg N)$ bits.

2. there are at most two buckets for every size from 1 to the maximu size, $2 * j = 2 lg N$，
   namely, $O(lg N)$ buckets.

above all, the total space required for all the buckets representing a window of size $N$ is $O(lg^2 N)$.

##### Query Answering

suppose a window of length $N$ is represented by all buckets $\{b_0, b_1, \dotsc, b_j, \dotsc, b_n\}$, where $b_j$ owns two attributes: timestamp, and size.

for a given $k$, the answer is $$\frac{1}{2} b_j.size + \sum_{i=0}^{j-1} b_i.size \quad \text{for } b_{j+1}\text{.timestamp} > k \geq b_j\text{.timestamp}$$

**error analysis**:
supposet $c$ is the correct counts.

there are two cases for estimate:

1. underestimate.
   + in the worst case, all the 1's of $b_j$ are actually within the query range $k$.
     $\implies$ the estimate misses half bucket $b_j$, namely, $\frac{1}{2} b_j.size = 2^{j-1}$ 1's.
   + $c$ is at least $\frac{1 (1 - 2^{j+1})}{1 - 2} = 2^{j+1} - 1$, since there is at least one bucket of each of the size $2^{j-1}, 2^{j-2}, \dotsc, 1$.
   + in all,
     \begin{align}
         error &= \frac{miss}{c} \\
               &= \frac{2^{j-1}}{2^{j+1} - 1} \\
               &\leq \frac{1}{2}
     \end{align}

2. overestimate.
   + in the worse case, only the rightmost bit of bucket $b_j$ is within range $k$.
     $implies$ we overestimate $\frac{b_j.size}{2} - 1 = 2^{j-1} -1$ 1's.
   + $c$ is at least $1 + 2^j -1 = 2^j$ 1's.
   + in all,
     \begin{align}
         error &= \frac{overestimate}{c} \\
               &= \frac{2^{j-1} - 1}{2^j} \\
               &\leq \frac{1}{2}
     \end{align}

**conclusion**: error is no more than 50% greater than $c$.

##### Maintaining
At $t$ time, a new bit enters:

1. Check the leftmost bucket.
   if $b_n.timestamp = t - N$, drop it.

2. Check the value of the new bit.
   if it is 0, do nothing;
   if it is 1, create a new bucket with $t$ and size $1$.
   + Check if three same size buckets existed,
     1. if yes, combine the left two adjacent buckets of those, and check the new size bucket again (repeat).
     2. if no, the round end.

     at most $O(lg N)$ times to check.

```python
#todo code
```

##### Reducing the Error

idea:
reduce the error $\gets$ more buckets of **small** sizes $\gets$ relax condition: we allow either $r-1$ or $r$ of each of the exponentially growing sizes for $r > 2$.

combine rules:
If we get $r+1$ buckets of size $2^j$, combine the leftmost two into a bucket of size $2^{j+1}$.

error analysis:

1. overestimate.
   overestimate is $2^{j-1} - 1$.
   $c$ is at least $1 + (r-1)(2^{j-1} + 2^{j-2} + \dotsb + 1) = 1 + (r-1)(2^j-1)$.
   the fractional error is $$\frac{2^{j-1} - 1}{1 + (r-1)(2^j - 1)} \leq \frac{1}{r-1}$$

2. underestimate.

conclusion: by picking $r$ sufficiently large, we can limit the error to any desired $\epsilon > 0$.

##### Extensions
question: the sum of the last $k$ **integers** for any $1 \leq k \leq N$.

there are two cases:

1. unlikely work out.
   when the stream contains both positive and negative integers.

2. work.
   when the steam contains only positive integers in the range 1 to $2^m$.
   solution:
   + we can treat each of the m bits of each integers as if it were a separate stream.
   + use the DGIM method to count $c_i$ in $i$th bit stream.
   + the sum of the integers is: $$\sum_{i=0}^{m-1} c_i 2^i$$

```python
#todo exercise
```

### 4.7 Decaying Windows

#### exponentially decaying window
Let a steam $= \{a_1, a_2, \dotsc, a_t\}$,
we define the exponentially decaying window for this stream to be the sum: $$\sum_{i=0}^{t-1} a_{t-i} (1-c)^i$$

maintain:
when a new element $a_{t+1}$ arrives, all we need to do is:

1. Multiply the current sum by $1-c$.

2. Add $a_{t+}$.

#### Finding the Most Popular Elements

When a new ticket arrives on the stream, do the following:

1. For each movie, multiply its score by $(1-c)$.

2. Suppose the new ticket is for movie $M$.
   1. if a score for $M$ existed, add 1.
   2. if not, create one and initialize it to 1.

3. If any score is below the threshold $1/2$, drop that score.

analysis:

Note that the sum of all scores is $1/c$,
there cannot be more than $2/c$ moview with score of $1/2$ or more. $\gets$ $1/c = 1/2 \times 2/c$.

hence, $2/c$ is a limit on the number of movies being counted at any time.


```python
try:
    from openmdao.utils.notebook_utils import notebook_mode
except ImportError:
    !python -m pip install openmdao[notebooks]
```

# BalanceComp

`BalanceComp` is a specialized implementation of [ImplicitComponent](../../core_features/working_with_components/implicit_component.ipynb) that is intended to provide a simple way to implement most implicit equations without the need to define your own residuals.

## BalanceComp Options

```python
import openmdao.api as om
om.show_options_table("openmdao.components.balance_comp.BalanceComp")
```

## BalanceComp Constructor

The call signature for the `BalanceComp` constructor is:

```{eval-rst}
    .. automethod:: openmdao.components.balance_comp.BalanceComp.__init__()
        :noindex:
```

## Using the BalanceComp

`BalanceComp` allows you to add one or more state variables and its associated
implicit equations.  For each ``balance`` added to the component it
solves the following equation:

$$
  \begin{align}
  \mathcal{R}_{name} =
  \frac{f_{mult}(x,...) \times f_{lhs}(x,...) - f_{rhs}(x,...)}{f_{norm}(f_{rhs}(x,...))}
  \end{align}
$$

The optional normalization function $f_{norm}(f_{rhs})$ is computed as:

$$
  \begin{align}
  f_{norm}(f_{rhs}(x,...)) =
  \begin{cases}
   \left| f_{rhs} \right|, & \text{if normalize and } \left| f_{rhs} \right| \geq 2 \\
   0.25 f_{rhs}^2 + 1,     & \text{if normalize and } \left| f_{rhs} \right| < 2 \\
   1,                      & \text{if not normalize}
  \end{cases}
  \end{align}
$$

The following inputs and outputs are associated with each implicit state.

```{eval-rst}
=========== ======= ====================================================
Name        I/O     Description
=========== ======= ====================================================
{name}      output  implicit state variable
lhs:{name}  input   left-hand side of equation to be balanced
rhs:{name}  input   right-hand side of equation to be balanced
mult:{name} input   left-hand side multiplier of equation to be balanced
=========== ======= ====================================================
```

The default value for the `rhs:{name}` input can be set to via the
`rhs_val` argument (see arguments below). If the rhs value is fixed (e.g. 0),
then the input can be left unconnected. The `lhs:{name}` input must always have
something connected to it and should be dependent upon the value of the implicit state variable.

The multiplier is optional and will default to 1.0 if not connected.

`BalanceComp` supports vectorized implicit states. Simply provide a default
value or shape when adding the balance that reflects the correct shape.

You can provide the arguments to create a balance when instantiating a `BalanceComp`
or you can use the ``add_balance`` method to create one or more state variables after
instantiation.  The constructor accepts all the same arguments as the ``add_balance``
method:

```{eval-rst}
    .. automethod:: openmdao.components.balance_comp.BalanceComp.add_balance
       :noindex:
```

Note that the `kwargs` arguments can include any of the keyword arguments normally available
when creating an output variable with the
[add_output](../../../_srcdocs/packages/core/component.html#openmdao.core.component.Component.add_output) method of a `Component`.

## Example:  Scalar Root Finding

The following example uses a BalanceComp to implicitly solve the
equation:

$$
    2 \cdot x^2 = 4
$$

Here, our LHS is connected to a computed value for $x^2$, the multiplier is 2, and the RHS
is 4.  The expected solution is $x=\sqrt{2}$.  We initialize $x$ with a value of 1 so that
it finds the positive root.

```python
import openmdao.api as om

prob = om.Problem()

bal = om.BalanceComp()
bal.add_balance('x', use_mult=True)

exec_comp = om.ExecComp('y=x**2', x={'val': 1}, y={'val': 1})

prob.model.add_subsystem(name='exec', subsys=exec_comp)
prob.model.add_subsystem(name='balance', subsys=bal)

prob.model.connect('balance.x', 'exec.x')
prob.model.connect('exec.y', 'balance.lhs:x')

prob.model.linear_solver = om.DirectSolver(assemble_jac=True)
prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=False, maxiter=100, iprint=0)

prob.setup()

prob.set_val('balance.rhs:x', 4)
prob.set_val('balance.mult:x', 2.)

# A reasonable initial guess to find the positive root.
prob['balance.x'] = 1.0

prob.run_model()

print(prob.get_val('balance.x'))
```

```python
import numpy as np
from numpy.testing import assert_almost_equal

assert_almost_equal(prob.get_val('balance.x'), np.sqrt(2), decimal=7)
```

Alternatively, we could simplify the code by using the `mult_val` argument.

```python
prob = om.Problem()

bal = om.BalanceComp()
bal.add_balance('x', use_mult=True, mult_val=2.0)

exec_comp = om.ExecComp('y=x**2', x={'val': 1}, y={'val': 1})

prob.model.add_subsystem(name='exec', subsys=exec_comp)
prob.model.add_subsystem(name='balance', subsys=bal)

prob.model.connect('balance.x', 'exec.x')
prob.model.connect('exec.y', 'balance.lhs:x')

prob.model.linear_solver = om.DirectSolver(assemble_jac=True)
prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=False, maxiter=100, iprint=0)

prob.setup()

prob.set_val('balance.rhs:x', 4)

# A reasonable initial guess to find the positive root.
prob.set_val('balance.x', 1.0)

prob.run_model()
print(prob.get_val('balance.x'))
```

```python
assert_almost_equal(prob.get_val('balance.x'), np.sqrt(2), decimal=7)
```

## Example:  Vectorized Root Finding

The following example uses a BalanceComp to implicitly solve the equation:

$$
    b \cdot x + c  = 0
$$

for various values of $b$, and $c$.  Here, our LHS is connected to a computed value of
the linear equation.  The multiplier is one and the RHS is zero (the defaults), and thus
they need not be connected.

```python
n = 100

prob = om.Problem()

exec_comp = om.ExecComp('y=b*x+c',
                        b={'val': np.random.uniform(0.01, 100, size=n)},
                        c={'val': np.random.rand(n)},
                        x={'val': np.zeros(n)},
                        y={'val': np.ones(n)})

prob.model.add_subsystem(name='exec', subsys=exec_comp)
prob.model.add_subsystem(name='balance', subsys=om.BalanceComp('x', val=np.ones(n)))

prob.model.connect('balance.x', 'exec.x')
prob.model.connect('exec.y', 'balance.lhs:x')

prob.model.linear_solver = om.DirectSolver(assemble_jac=True)
prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=False, maxiter=100, iprint=0)

prob.setup()

prob.set_val('balance.x', np.random.rand(n))

prob.run_model()

b = prob.get_val('exec.b')
c = prob.get_val('exec.c')

print(prob.get_val('balance.x'))
```

```python
print(-c/b)  # expected
```

```python
assert_almost_equal(prob.get_val('balance.x'), -c/b, decimal=6)
assert_almost_equal(-c/b, prob.get_val('balance.x'), decimal=6)  # expected
```

## Example:  Providing an Initial Guess for a State Variable

`BalanceComp` has a `guess_func` option that can be used to supply an initial guess
value for the state variables.  This option provides the same functionality as the
[guess_nonlinear](../../../_srcdocs/packages/core/implicitcomponent.html#openmdao.core.implicitcomponent.ImplicitComponent.guess_nonlinear)
method of `ImplicitComponent`.

The Kepler example script shows how `guess_func` can be used.

```python
prob = om.Problem()

bal = om.BalanceComp()

bal.add_balance(name='E', val=0.0, units='rad', eq_units='rad', rhs_name='M')

# Use M (mean anomaly) as the initial guess for E (eccentric anomaly)
def guess_function(inputs, outputs, residuals):
    if np.abs(residuals['E']) > 1.0E-2:
        outputs['E'] = inputs['M']

bal.options['guess_func'] = guess_function

# ExecComp used to compute the LHS of Kepler's equation.
lhs_comp = om.ExecComp('lhs=E - ecc * sin(E)',
                       lhs={'val': 0.0, 'units': 'rad'},
                       E={'val': 0.0, 'units': 'rad'},
                       ecc={'val': 0.0})

prob.model.add_subsystem(name='balance', subsys=bal,
                         promotes_inputs=['M'],
                         promotes_outputs=['E'])

prob.model.set_input_defaults('M', 85.0, units='deg')

prob.model.add_subsystem(name='lhs_comp', subsys=lhs_comp,
                         promotes_inputs=['E', 'ecc'])

# Explicit connections
prob.model.connect('lhs_comp.lhs', 'balance.lhs:E')

# Set up solvers
prob.model.linear_solver = om.DirectSolver()
prob.model.nonlinear_solver = om.NewtonSolver(solve_subsystems=False, maxiter=100, iprint=2)

prob.setup()

prob.set_val('ecc', 0.6)

prob.run_model()

print(np.degrees(prob.get_val('E')))
```

```python
from openmdao.utils.assert_utils import assert_near_equal

assert_almost_equal(np.degrees(prob.get_val('E')), 115.9, decimal=1)
```