```python
import torch
import torch.nn.functional as F

import torchsde
import math
import matplotlib.pyplot as plt

import numpy as np

from tqdm.notebook import tqdm

from torch import _vmap_internals

import pandas as pd
```

```python
# !pip install scikit-learn
```

```python
from cfollmer.objectives import log_g, relative_entropy_control_cost, stl_relative_entropy_control_cost_xu
from cfollmer.sampler_utils import FollmerSDE
from cfollmer.trainers import basic_batched_trainer
from cfollmer.drifts import ResNetScoreNetwork, AbstractDrift
```

    functorch succesfully imported

# The Model

\begin{align}
\theta &\sim \mathcal{N}(\theta | 0, \sigma_w^2 \mathbb{I}) \\
y_i | x_i, \theta &\sim  \mathrm{Bernouli}\left[\mathrm{NN}_{\theta}\left(x_i \right)\right]
\end{align}

We want samples from $p(\theta | \{(y_i, x_i)\})$. Note $f(x; \theta)$ is a neural net with params $\theta$

## Loading the iris dataset

```python
a9a_train = pd.read_csv("../data/a9a.csv", header=None)
a9a_test = pd.read_csv("../data/a9a_t.csv", header=None)
```

```python
X_train, y_train = a9a_train.values[:,:-1],  a9a_train.values[:,-1]
X_test, y_test = a9a_test.values[:,:-1],  a9a_test.values[:,-1]

# X_train = np.concatenate((X_train, np.ones((X_train.shape[0],X_train.shape[1]))), axis=1)
# X_test = np.concatenate((X_test, np.ones((X_test.shape[0],X_train.shape[1]))), axis=1)

# import sklearn.linear_model
# from sklearn.linear_model import LogisticRegression
# from sklearn.neural_network import MLPClassifier

# mod = sklearn.neural_network.MLPClassifier((100,100),random_state=0).fit(X_train, y_train)
# print(mod.score(X_train, y_train))
# print(mod.score(X_test, y_test))
```

```python
device = "cuda" if torch.cuda.is_available() else "cpu"

X_train, y_train = a9a_train.values[:,:-1],  a9a_train.values[:,-1]
X_test, y_test = a9a_test.values[:,:-1],  a9a_test.values[:,-1]

X_train, X_test, y_train, y_test = \
    torch.tensor(X_train, dtype=torch.float32, device=device), \
    torch.tensor(X_test, dtype=torch.float32, device=device), \
    torch.tensor(y_train, dtype=torch.float32, device=device), \
    torch.tensor(y_test, dtype=torch.float32, device=device)
```

```python
X_train.shape
```

    torch.Size([32561, 123])

$$\DeclareMathOperator*{\argmin}{arg\,min}$$
$$\def\E{{\mathbb{E}}}$$
$$\def\rvu{{\mathbf{u}}}$$
$$\def\rvTheta{{\bm{\Theta}}}$$
$$\def\gU{{\mathcal{U}}}$$
$$\def\mX{{\mathbf{X}}}$$

## Controlled Schrodinger Follmer Sampler

The objevtive we are trying to implement is:

\begin{align}
  \mathbf{u}_t^{*}=  \argmin_{\rvu_t \in \mathcal{U}}\mathbb{E}\left[\frac{1}{2\gamma}\int_0^1||\rvu(t, \Theta_t)||^2 dt - \ln\left(\frac{ p(\mX | \Theta_1)p(\Theta_1)}{\mathcal{N}(\Theta_1|\mathbf{0}, \gamma \mathbb{I} )}\right)\right] \
\end{align}

Where:
\begin{align}
d\Theta_t = \rvu(t, \Theta_t)dt + \sqrt{\gamma} dB_t
\end{align}

To do so we use the EM discretisation.

```python
import torch.nn.functional as F

class ClassificationNetwork(object):

    def __init__(
        self, input_dim=1, output_dim=1, depth=None,
        width=20, width_seq=None, device="cpu", activation=F.relu
    ):

        self.device = device
        self.output_dim = output_dim
        self.input_dim = input_dim
        self.activation = activation

        self.depth = depth
        if not self.depth:
            self.depth = 1
        if not width_seq:
            self.width = width
            self.width_seq = [self.width] * (self.depth + 1)
            self.shapes = [(self.width_seq[i-1], self.width_seq[i])  for i in range(1,self.depth)]
            self.shapes += [(self.width_seq[-1], self.output_dim)]
            self.shapes = [(self.input_dim, self.width_seq[0])] + self.shapes

        self.dim = sum([wx * wy + wy for wx, wy in self.shapes])

    def forward(self, x, Θ):
        index = 0
        n, d = x.shape

#         dim_bl =  sum([wx * wy + wy for wx, wy in self.shapes[:-1]])
#         Θ[:dim_bl] = (Θ[:dim_bl] - Θ[:dim_bl].mean()) / Θ[:dim_bl].std()
#         σ_Θ, μ_Θ = Θ.std(), Θ.mean()
#         Θ = (Θ - μ_Θ) / σ_Θ

        for wx, wy in self.shapes[:-1]:
            x = F.linear(
                x,
                Θ[index: index + wx * wy].reshape(wy, wx),
                Θ[index + wx * wy: index + wx * wy + wy].reshape(1,wy)
            )
            x = self.activation(x)
            index += wx * wy  + wy
        wx, wy = self.shapes[-1]
        x = F.linear(
            x,
            Θ[index: index + wx * wy].reshape(wy, wx), #* σ_Θ + μ_Θ,
            Θ[index + wx * wy: index + wx * wy + wy].reshape(1,wy) # * σ_Θ + μ_Θ
        )
        return x.to(self.device)

    def map_forward(self, x, Θ):
        preds_func = lambda θ: self.forward(x, θ)
        batched_preds = torch._vmap_internals.vmap(preds_func)
        preds = torch.hstack(list(map(preds_func, Θ)))
        return preds
```

```python
class LinearClassifier(object):

    def __init__(
        self, input_dim=1, output_dim=1, device="gpu",
    ):

        self.device = device
        self.output_dim = output_dim
        self.input_dim = input_dim

        self.shapes = [(self.input_dim, self.output_dim)]

        self.dim = sum([wx * wy + wy for wx, wy in self.shapes])

    def forward(self, x, Θ):
        wx, wy = self.shapes[-1]
        x = F.linear(
            x,
            Θ[:wx * wy].reshape(wy, wx),
            Θ[wx * wy: wx * wy + wy].reshape(1,wy)
        )
        return x.to(self.device)

    def map_forward(self, x, Θ):
        preds_func = lambda θ: self.forward(x, θ)
        batched_preds = torch._vmap_internals.vmap(preds_func)
        preds = torch.hstack(list(map(preds_func, Θ)))
        return preds

```

```python
class SimpleForwardNetBN_larger(AbstractDrift):

    def __init__(self, input_dim=1, width=650, activation=torch.nn.Softplus):
        super(SimpleForwardNetBN_larger, self).__init__()

        self.nn = torch.nn.Sequential(
            torch.nn.Linear(input_dim + 1, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, input_dim )
        )

        self.nn[-1].weight.data.fill_(0.0)

#     def forward(self, x, t):
#         x = torch.cat((x, t), dim=-1)
#         return self.nn(x)

# class SimpleForwardNetBN_larger(AbstractDrift):

#     def __init__(self, input_dim=1, width=650, activation=torch.nn.Softplus):
#         super(SimpleForwardNetBN_larger, self).__init__()

#         self.nn = torch.nn.Sequential(
#             torch.nn.Linear(input_dim + 1, width), activation(),
#             torch.nn.Linear(width, width), activation(),
#             torch.nn.Linear(width, width), activation(),
#             torch.nn.Linear(width, width), activation(),
#             torch.nn.Linear(width, input_dim )
#         )

#         self.nn[-1].weight.data.fill_(0.0)

##     def forward(self, x):
##         return self.nn(x)
```

```python
dim = X_train.shape[1]

# net = ClassificationNetwork(
#     dim,1, device=device, depth=2, width=100, activation=F.softplus
# )

net = LinearClassifier(
    dim,1, device=device
)

def gaussian_prior(Θ, σ_w=2.8):
    """
    Logistic regresion bayesian prior
    """
    return -0.5 * (Θ**2).sum(axis=1) / σ_w

def laplace_prior(Θ, σ_w=1):
    """
    Logistic regresion bayesian prior
    """
    return -(torch.abs(Θ) ).sum(axis=1) / σ_w

def log_likelihood_vmap_nn(Θ, X, y, net=net):
    """
    Hoping this implementation is less buggy / faster

    still feels a bit slow.
    """
    pos_weights = torch.ones(X.shape[0], device=device)

    def loss(θ):
        preds = net.forward(X, θ)
        bce = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weights, reduction="sum")
        ll_bcs = -1.0 * bce(preds.reshape(-1), y.reshape(-1))
        return ll_bcs

    batched_loss =  torch._vmap_internals.vmap(loss)

    return batched_loss(Θ)
```

```python
net.dim,  X_train.shape[1] #, 1/Δt
```

    (124, 123)

```python
# from cfollmer.layers import ResBlock, get_timestep_embedding

# class ResNetScoreNetwork_(torch.nn.Module):

#     def __init__(self,
#                  input_dim: int = 123,
#                  pos_dim: int = 16,
#                  res_dim: int = 650,
#                  res_block_initial_widths=None,
#                  res_block_final_widths=None,
#                  res_block_inner_layers=None,
#                  activation=torch.nn.ReLU()):
#         super().__init__()
#         if res_block_initial_widths is None:
#             res_block_initial_widths = [res_dim, res_dim, res_dim,res_dim]
#         if res_block_final_widths is None:
#             res_block_final_widths = [res_dim, res_dim, res_dim, res_dim]
#         if res_block_inner_layers is None:
#             res_block_inner_layers = [128, 128]

#         self.temb_dim = pos_dim

#         # ResBlock Sequence
#         res_layers = []
#         initial_dim = input_dim
#         for initial, final in zip(res_block_initial_widths, res_block_final_widths):
#             res_layers.append(ResBlock(initial_dim, initial, final, res_block_inner_layers, activation))
#             initial_dim = initial + final
#         self.res_sequence = torch.nn.Sequential(*res_layers)

#         # Time FCBlock
#         self.time_block = torch.nn.Sequential(torch.nn.Linear(self.temb_dim, self.temb_dim * 2), activation)

#         # Final_block
#         self.final_block = torch.nn.Sequential(torch.nn.Linear(self.temb_dim * 2 + initial_dim, input_dim))

#     def forward(self, x, t):
#         # t needs the same shape as x (except for the final dim, which is 1)
#         t_emb = get_timestep_embedding(t, self.temb_dim)
#         t_emb = self.time_block(t_emb)
#         x_emb = self.res_sequence(x)
#         h = torch.cat([x_emb, t_emb], -1)
#         return self.final_block(h)
```

```python
# (ResNetScoreNetwork())
```

```python

class SimpleForwardNetBN_larger(AbstractDrift):

    def __init__(self, input_dim=1, width=2500, activation=torch.nn.Softplus):
        super(SimpleForwardNetBN_larger, self).__init__()

        self.nn = torch.nn.Sequential(
            torch.nn.Linear(input_dim + 1, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, width), torch.nn.BatchNorm1d(width, affine=False), activation(),
            torch.nn.Linear(width, input_dim )
        )

        self.nn[-1].weight.data.fill_(0.0)

#     def forward(self, x):
#         return self.nn(x)

γ =  0.2
Δt=0.01

dim= net.dim

prior = laplace_prior

sde, losses = basic_batched_trainer(
    γ, Δt, prior, log_likelihood_vmap_nn, dim, X_train, y_train,
    method="euler", stl="stl_xu", adjoint=False, optimizer=None,
    num_steps=79, batch_size_data=int(X_train.shape[0]), batch_size_Θ=30,
    batchnorm=True, device=device, lr=0.0001, drift=SimpleForwardNetBN_larger, schedule="linear",
    γ_min=0.2**2, γ_max=0.5**2
)
```

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

    /local/scratch/home/fav25/hjb2/lib/python3.8/site-packages/torch/nn/functional.py:2378: UserWarning: There is a performance drop because we have not yet implemented the batching rule for aten::batch_norm. Please file us an issue on GitHub so that we can prioritize its implementation. (Triggered internally at  /tmp/pip-req-build-t07f5mpb/functorch/csrc/BatchedFallback.cpp:106.)
      return torch.batch_norm(
    /tmp/ipykernel_1898/3495608044.py:40: UserWarning: torch.vmap is an experimental prototype that is subject to change and/or deletion. Please use at your own risk. There may be unexpected performance cliffs due to certain operators not being implemented. To see detailed performance warnings please use `torch._C._debug_only_display_vmap_fallback_warnings(True) before the call to `vmap`.
      batched_loss =  torch._vmap_internals.vmap(loss)

    0.9016560912132263
    0.7858908772468567
    0.7955307364463806
    0.6245638728141785
    0.49218639731407166
    0.5208956599235535
    0.64518803358078
    0.503056526184082
    0.49570998549461365
    0.4333386719226837
    0.4574110209941864
    0.449645072221756
    0.46681851148605347
    0.46798256039619446
    0.4521825313568115
    0.46005979180336
    0.4276396334171295
    0.43452852964401245
    0.45451417565345764
    0.4438866078853607
    0.44326794147491455
    0.44955554604530334
    0.421499103307724
    0.45189169049263
    0.4312673509120941
    0.4438117742538452
    0.41842103004455566
    0.41901183128356934
    0.42015302181243896
    0.42534106969833374
    0.41299861669540405
    0.4450279474258423
    0.4319508373737335
    0.41628560423851013
    0.4310545325279236
    0.42803049087524414
    0.4268939793109894
    0.4174824059009552
    0.45602527260780334
    0.43009093403816223
    0.43264931440353394
    0.4091968536376953
    0.44313010573387146
    0.45005524158477783
    0.40731415152549744
    0.40561431646347046
    0.4142289459705353
    0.41309916973114014
    0.42645812034606934
    0.4066483974456787
    0.41812485456466675
    0.40806445479393005
    0.4229845702648163
    0.4182996451854706
    0.43708571791648865
    0.42332205176353455
    0.4049936830997467
    0.40723368525505066
    0.4166981875896454
    0.42381641268730164
    0.4027542173862457
    0.41360896825790405
    0.4311537444591522
    0.40759679675102234
    0.4111192226409912
    0.40606966614723206
    0.39295586943626404
    0.4276207387447357
    0.40218740701675415
    0.4245770275592804
    0.42469602823257446
    0.41626647114753723
    0.4109540283679962
    0.412657767534256
    0.40242770314216614
    0.3974241316318512
    0.4154124855995178
    0.40533632040023804
    0.39964309334754944

```python
losses
```

    [tensor(0.9017),
     tensor(0.7859),
     tensor(0.7955),
     tensor(0.6246),
     tensor(0.4922),
     tensor(0.5209),
     tensor(0.6452),
     tensor(0.5031),
     tensor(0.4957),
     tensor(0.4333),
     tensor(0.4574),
     tensor(0.4496),
     tensor(0.4668),
     tensor(0.4680),
     tensor(0.4522),
     tensor(0.4601),
     tensor(0.4276),
     tensor(0.4345),
     tensor(0.4545),
     tensor(0.4439),
     tensor(0.4433),
     tensor(0.4496),
     tensor(0.4215),
     tensor(0.4519),
     tensor(0.4313),
     tensor(0.4438),
     tensor(0.4184),
     tensor(0.4190),
     tensor(0.4202),
     tensor(0.4253),
     tensor(0.4130),
     tensor(0.4450),
     tensor(0.4320),
     tensor(0.4163),
     tensor(0.4311),
     tensor(0.4280),
     tensor(0.4269),
     tensor(0.4175),
     tensor(0.4560),
     tensor(0.4301),
     tensor(0.4326),
     tensor(0.4092),
     tensor(0.4431),
     tensor(0.4501),
     tensor(0.4073),
     tensor(0.4056),
     tensor(0.4142),
     tensor(0.4131),
     tensor(0.4265),
     tensor(0.4066),
     tensor(0.4181),
     tensor(0.4081),
     tensor(0.4230),
     tensor(0.4183),
     tensor(0.4371),
     tensor(0.4233),
     tensor(0.4050),
     tensor(0.4072),
     tensor(0.4167),
     tensor(0.4238),
     tensor(0.4028),
     tensor(0.4136),
     tensor(0.4312),
     tensor(0.4076),
     tensor(0.4111),
     tensor(0.4061),
     tensor(0.3930),
     tensor(0.4276),
     tensor(0.4022),
     tensor(0.4246),
     tensor(0.4247),
     tensor(0.4163),
     tensor(0.4110),
     tensor(0.4127),
     tensor(0.4024),
     tensor(0.3974),
     tensor(0.4154),
     tensor(0.4053),
     tensor(0.3996)]

```python
plt.plot(losses[:])
```

```python
X_train.shape
```

    torch.Size([32561, 123])

```python
t_size = int(math.ceil(1.0/Δt))
ts = torch.linspace(0, 1, t_size).to(device)
no_posterior_samples = 100
Θ_0 = torch.zeros((no_posterior_samples, net.dim)).to(device)

Θ_1 = torchsde.sdeint(sde, Θ_0, ts, dt=Δt)[-1,...]
```

```python
fig, (ax1,ax2,ax3) = plt.subplots(1,3)

ax1.hist(Θ_1[:,0].cpu().detach().numpy())
ax2.hist(Θ_1[:,1].cpu().detach().numpy())
ax3.hist(Θ_1[:,2].cpu().detach().numpy())
```

```python
def predc(X, Θ):
    return torch.vstack([torch.sigmoid(net.forward(X, θ)[None,...]) for θ in Θ]).mean(dim=0)
```

```python
pred = predc(X_train, Θ_1)
```

```python

((pred > 0.5).float().flatten()== y_train).float().mean()
```

    tensor(0.8479, device='cuda:0')

```python
pred_test = predc(X_test.float(), Θ_1)
```

```python
((pred_test > 0.5).float().flatten() == y_test).float().mean()
```

    tensor(0.8501, device='cuda:0')

## MAP Baseline

We run the point estimate approximation (Maximum a posteriori) to double check what the learned weights look like.  We get the  exact same training accuracy as with the controlled model and similarly large weights for the non bias weights.

```python
Θ_map = torch.zeros((1, dim), requires_grad=True, device=device)
optimizer_map = torch.optim.Adam([Θ_map], lr=0.05)
#     optimizer = torch.optim.LBFGS(gpr.parameters(), lr=0.01)

losses_map = []
num_steps = 1000
for i in tqdm(range(num_steps)):
    optimizer_map.zero_grad()

    if isinstance(optimizer_map, torch.optim.LBFGS):
        def closure_map():
            loss_map = log_likelihood_vmap()
            optimizer_map.zero_grad()
            loss_map.backward()
            return loss

        optimizer_map.step(closure_map)
        losses_map.append(closure_map().item())
    else:
        loss_map = -(log_likelihood_vmap(Θ_map, X_train, y_train) + gaussian_prior(Θ_map))
        optimizer_map.zero_grad()
        loss_map.backward()
        print(loss_map.item())
        optimizer_map.step()
        losses_map.append(loss_map.item())

Θ_map
pred_map = torch.sigmoid(X_train.mm(Θ_map.T)).mean(axis=1)
((pred_map < 0.5).float() == y_train).float().mean(), Θ_map
```

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

    C:\Users\vargf\AppData\Local\Temp/ipykernel_2136/381526447.py:36: UserWarning: torch.vmap is an experimental prototype that is subject to change and/or deletion. Please use at your own risk. There may be unexpected performance cliffs due to certain operators not being implemented. To see detailed performance warnings please use `torch._C._debug_only_display_vmap_fallback_warnings(True) before the call to `vmap`.
      batched_loss =  torch._vmap_internals.vmap(loss_)

    55.45177459716797
    52.67886734008789
    50.03776931762695
    47.52959442138672
    45.154483795166016
    42.91158676147461
    40.79913330078125
    38.81449508666992
    36.95428466796875
    35.21445083618164
    33.590423583984375
    32.07719421386719
    30.66946029663086
    29.36173439025879
    28.148420333862305
    27.023902893066406
    25.982643127441406
    25.019207000732422
    24.128328323364258
    23.30495262145996
    22.54424476623535
    21.841611862182617
    21.192726135253906
    20.593515396118164
    20.040164947509766
    19.529117584228516
    19.057064056396484
    18.620927810668945
    18.21786880493164
    17.845247268676758
    17.500642776489258
    17.181812286376953
    16.886699676513672
    16.613401412963867
    16.360179901123047
    16.12543487548828
    15.907696723937988
    15.705615997314453
    15.517951011657715
    15.343571662902832
    15.181438446044922
    15.030595779418945
    14.890161514282227
    14.759340286254883
    14.637393951416016
    14.523643493652344
    14.417470932006836
    14.318305969238281
    14.225625991821289
    14.13895034790039
    14.057838439941406
    13.981886863708496
    13.910720825195312
    13.843996047973633
    13.781397819519043
    13.722637176513672
    13.66744327545166
    13.61557388305664
    13.566798210144043
    13.520904541015625
    13.477703094482422
    13.437012672424316
    13.398666381835938
    13.362512588500977
    13.328409194946289
    13.296223640441895
    13.265838623046875
    13.23713493347168
    13.210012435913086
    13.184370040893555
    13.160122871398926
    13.13718318939209
    13.115469932556152
    13.094917297363281
    13.07545280456543
    13.057011604309082
    13.039539337158203
    13.022977828979492
    13.00727653503418
    12.99238395690918
    12.978260040283203
    12.964859008789062
    12.952140808105469
    12.940072059631348
    12.92861557006836
    12.91773796081543
    12.907407760620117
    12.897597312927246
    12.88828182220459
    12.879429817199707
    12.87102222442627
    12.863033294677734
    12.855443000793457
    12.848231315612793
    12.841375350952148
    12.834863662719727
    12.8286714553833
    12.822789192199707
    12.81719970703125
    12.81188678741455
    12.80683708190918
    12.802037239074707
    12.797475814819336
    12.793142318725586
    12.78902530670166
    12.785109519958496
    12.781390190124512
    12.777854919433594
    12.774497032165527
    12.771309852600098
    12.768280029296875
    12.765399932861328
    12.762666702270508
    12.76007080078125
    12.757606506347656
    12.755267143249512
    12.753044128417969
    12.750932693481445
    12.748931884765625
    12.747032165527344
    12.745229721069336
    12.743517875671387
    12.741894721984863
    12.740355491638184
    12.738895416259766
    12.737510681152344
    12.736200332641602
    12.734954833984375
    12.73377513885498
    12.732657432556152
    12.731599807739258
    12.730597496032715
    12.729646682739258
    12.72874641418457
    12.727895736694336
    12.727088928222656
    12.726325988769531
    12.725605964660645
    12.724923133850098
    12.72427749633789
    12.723665237426758
    12.723087310791016
    12.722541809082031
    12.722025871276855
    12.721537590026855
    12.721076965332031
    12.720643997192383
    12.720233917236328
    12.719844818115234
    12.719480514526367
    12.719133377075195
    12.71881103515625
    12.718502044677734
    12.718212127685547
    12.717939376831055
    12.717683792114258
    12.717440605163574
    12.717211723327637
    12.716997146606445
    12.71679401397705
    12.716605186462402
    12.716425895690918
    12.716257095336914
    12.71609878540039
    12.715950012207031
    12.715808868408203
    12.715678215026855
    12.715555191040039
    12.715438842773438
    12.715330123901367
    12.715227127075195
    12.715131759643555
    12.715041160583496
    12.714957237243652
    12.71487808227539
    12.714803695678711
    12.714734077453613
    12.714668273925781
    12.714609146118164
    12.71455192565918
    12.714496612548828
    12.714447021484375
    12.714401245117188
    12.714357376098633
    12.714315414428711
    12.714277267456055
    12.714242935180664
    12.714208602905273
    12.714178085327148
    12.714149475097656
    12.714122772216797
    12.714097023010254
    12.714075088500977
    12.7140531539917
    12.714031219482422
    12.71401309967041
    12.713996887207031
    12.713979721069336
    12.713964462280273
    12.713949203491211
    12.713935852050781
    12.713923454284668
    12.713912963867188
    12.71390151977539
    12.713892936706543
    12.713883399963379
    12.713874816894531
    12.7138671875
    12.713861465454102
    12.71385383605957
    12.713848114013672
    12.71384048461914
    12.713836669921875
    12.713830947875977
    12.713824272155762
    12.713821411132812
    12.713817596435547
    12.713814735412598
    12.713810920715332
    12.713809967041016
    12.713804244995117
    12.7138032913208
    12.713800430297852
    12.713799476623535
    12.713797569274902
    12.713794708251953
    12.71379280090332
    12.713790893554688
    12.713790893554688
    12.713788986206055
    12.713787078857422
    12.713786125183105
    12.713785171508789
    12.713784217834473
    12.713783264160156
    12.713784217834473
    12.713781356811523
    12.713783264160156
    12.713780403137207
    12.71377944946289
    12.713780403137207
    12.713780403137207
    12.71377944946289
    12.713780403137207
    12.713778495788574
    12.713778495788574
    12.713778495788574
    12.713777542114258
    12.713777542114258
    12.713775634765625
    12.713775634765625
    12.713777542114258
    12.713776588439941
    12.713777542114258
    12.713776588439941
    12.713777542114258
    12.713777542114258
    12.713774681091309
    12.713775634765625
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713773727416992
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713776588439941
    12.713773727416992
    12.713776588439941
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713774681091309
    12.713775634765625
    12.713776588439941
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713776588439941
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713776588439941
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713774681091309
    12.713774681091309
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713773727416992
    12.713773727416992
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713773727416992
    12.713774681091309
    12.713773727416992
    12.713773727416992
    12.713774681091309
    12.713773727416992
    12.713775634765625
    12.713775634765625
    12.713774681091309
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713774681091309
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713776588439941
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625
    12.713775634765625

    (tensor(0., device='cuda:0'),
     tensor([[ 0.0000,  2.4463, -2.1438]], device='cuda:0', requires_grad=True))

notebooks/outdated_notebooks/a9a dataset NN classifeir.ipynb


```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
```

```python
plt.style.use('fivethirtyeight')
```

# Problem 11.7 - Fogler - 5ed

The gas-phase reversible reaction

\begin{equation}
A \leftrightarrows B
\end{equation}

is carried out under high pressure in a packed-bed reactor with pressure drop. The feed consists of both inerts **I** and Species **A** with the ratio of inerts to the species A being 2 to 1. The entering molar flow rate of A is 5 mol/min at a temperature of 300 K and a concentration of 2 mol/dm3. **Work this problem in terms of volume.**

_Additional information:_

Parameter | Value | Parameter | Value
- | - | - | -
Fao | 5.0 mol/min | T0 | 300 K
CA0 | 2 mol/dm$^3$ | Ti | 300 K
CI | 2 CA0 | k (300 K) |  0.1 1/min
CpI |18 cal/mol/K | Ua | 150 cal/dm3/min/K
CPA |160 cal/mol/K | $T_{ao}$ | 300 K
E | 10,000 cal/mol | V | 40 dm$^3$
$\Delta H$Rx | −20,000 cal/mol | $\alpha \rho_b$ | 0.02 1/dm$^3$
KC(300 K) | 1,000 | |
CPB | 160 cal/mol/K | m_c | 50 mol/min
$\rho_B$ |1.2 kg/dm3 | Cpcool | 20 cal/mol/K

1. **Adiabatic Operation**. Plot X, Xe, p, T, and the rate of disappearance as a function of V up to V = 40 dm3. Explain why the curves look the way they do.
2. Vary the ratio of inerts to A (0 ≤ ΘI ≤ 10) and the entering temperature, and describe what you find.
3. Plot the heat that must be removed along the reactor (Q ̇ vs. V) to maintain isothermal operation. Part (c) is “C” level of difficulty.

```python
# Data

Fa0 = 5.0           # initial molar flowrate A, mol/min
CA0 = 2             # initial concentration A, mol/dm$^3$
CI0 = 2*CA0         # initial concentration Inert, mol/dm3

T0 = 300            # initial reactor temperature, K
Ti = 300            # inlet inert temperature, K
Ta0 = 300           # inlet coolant temperature, K

CPA = 160           # cal/mol/K
CPB = 160           # cal/mol/K
CPI = 18            # cal/mol/K
Cpcool = 20         # cal/mol/K
Ua = 150            # cal/dm3/min/K

k_300 = 0.1         # 1/min
KC_300 = 1000
Ea = 10000          # cal/mol
DHRx = -20000       # cal/mol

V = 40              # dm$^3$

rho_B = 1.2         # kg/dm3
alpha_rho_b = 0.02  # 1/dm$^3$

m_c = 50             # mol/min

Rgas = 1.987
```

# Part (a) Adiabatic Operation

```python
def system(v, Y, parameters):

    x, t, p, ta = Y
    phii, to, if_adiabatic = parameters

    v = Vo * (1/p) * (t/to)
    fa = Fa0 * (1 - x)
    fb = Fa0 * x
    fi = Fi0

    ft = fa + fb + fi

    ca = fa / v
    cb = fb / v

    # Kinetic parameters
    k = k_300 * np.exp(-Ea/Rgas * (1/t - 1/300))
    kc = KC_300 * np.exp(-DHRx/Rgas * (1/t - 1/300))
    ra = -k * (ca - cb / kc)

    if if_adiabatic:
        ua = 0
    else:
        ua = Ua

    # Energy balance terms
    qg = ra * DHRx
    qr = ua * (t - ta)

    # System of ODEs
    dx_dv = -ra/Fa0
    dt_dv = (qg - qr) / (Fa0 * (CPA + phii * CPI + DCp * x))
    dp_dv = -alpha_rho_b / (2 * p) * (t/to) * (ft/Ft0)
    dta_dv = qr / (m_c * Cpcool)

    return [dx_dv, dt_dv, dp_dv, dta_dv]
```

## Preliminar calculations

```python
DCp = CPB - CPA - CPI
Vo = Fa0 / CA0           # inlet volumetric flow rate, dm3/min
Fi0 = Vo * CI0

Ft0 = Fa0 + Fi0

Ct0 = Ft0 / Vo
P0 = 8.314 * 10**3 * T0/Ct0
```

## Solving the ODEs system

```python
iCs = [0, T0, 1, Ta0]

Parameters = [Fi0/Fa0, T0, True]

fun = lambda v, Y: system(v, Y, Parameters)

vspan = [0, V]  #  independent variable range

solODE = solve_ivp(fun, vspan, iCs, t_eval=np.linspace(0, V), method='LSODA')
```

```python
Vreactor = solODE.t
Xa = solODE.y[0]
Temp_reactor = solODE.y[1]
p = solODE.y[2]
Temp_coolant = solODE.y[3]
```

## Plotting the result

```python
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 7), sharex=True)

ax1.plot(Vreactor, Xa, color='black')
ax1.set_ylabel('Conversion, adim')

ax2.plot(Vreactor, p, ls='--')
ax2.set_ylabel('P/P0, adim')

ax3.plot(Vreactor, Temp_reactor, color='red', ls='-.')
ax3.set_xlabel(r'Reactor volume, dm$^3$')
ax3.set_ylabel('Reactor temperature, K')

ax4.plot(Vreactor, Temp_coolant, color='green', ls=':')
ax4.set_xlabel(r'Reactor volume, dm$^3$')
ax4.set_ylabel('Coolant tempeture, K')

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

## Calculation of equilibrium conversion

```python
Kc = KC_300 * np.exp(-DHRx/Rgas * (1/Temp_reactor - 1/300))
Xe = Kc / (1 + Kc)
```

```python
fig, ax = plt.subplots(figsize=(6, 6))

ax.plot(Vreactor, Xe)
ax.set_xlabel(r'Reactor volume, dm$^3$')
ax.set_ylabel('Equilibrium conversion')

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

## Part (B)

### Vary the ratio of inerts to

Let's vary the ratio of inerts to A in the following values: [0, 1, 5, 10]

```python
vs_reactor = []
xas = []
p_ratios = []
temps_reactor = []
temps_coolant = []

phis = [0, 1, 5, 10]
for i in range(len(phis)):

    Parameters = [phis[i], T0, False]
    fun = lambda v, Y: system(v, Y, Parameters)
    solode = solve_ivp(fun, vspan, iCs, t_eval=np.linspace(0, V), method='LSODA')

    vs_reactor.append(solode.t)
    xas.append(solode.y[0])
    temps_reactor.append(solode.y[1])
    p_ratios.append(solode.y[2])
    temps_coolant.append(solode.y[3])
```

```python
linestyles = ['solid', 'dotted', 'dashed', 'dashdot']
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 7), sharex=True)

for i in range(len(phis)):
    ax1.plot(vs_reactor[i], xas[i],
            linestyle=linestyles[i],
            label=(r'$\phi_i$= %s' % phis[i]))

    ax2.plot(vs_reactor[i], p_ratios[i],
        linestyle=linestyles[i],
        label=(r'$\phi_i$ = %s' % phis[i]))

    ax3.plot(vs_reactor[i], temps_reactor[i],
        linestyle=linestyles[i],
        label=(r'$\phi_i$= %s' % phis[i]))

    ax4.plot(vs_reactor[i], temps_coolant[i],
        linestyle=linestyles[i],
        label=(r'$\phi_i$ = %s' % phis[i]))

    plt.legend(loc='best')

ax1.set_ylabel('Conversion, adim')
ax2.set_ylabel('P/P0, adim')
ax3.set_xlabel(r'Reactor volume, dm$^3$')
ax3.set_ylabel('Reactor temperature, K')
ax4.set_xlabel(r'Reactor volume, dm$^3$')
ax4.set_ylabel('Coolant tempeture, K')
plt.tight_layout()
plt.show()
```

### Vary the entering temperature

Let's vary the entering temperature in the following values: [280, 310, 330]

```python
vs_reactor = []
xas = []
p_ratios = []
temps_reactor = []
temps_coolant = []

T0s = [310, 320, 330]
for i in range(len(T0s)):

    Parameters = [2, T0s[i], False]
    iCs = [0, T0s[i], 1, Ta0]
    fun = lambda v, Y: system(v, Y, Parameters)
    solode = solve_ivp(fun, vspan, iCs, t_eval=np.linspace(0, V), method='LSODA')

    vs_reactor.append(solode.t)
    xas.append(solode.y[0])
    temps_reactor.append(solode.y[1])
    p_ratios.append(solode.y[2])
    temps_coolant.append(solode.y[3])
```

```python
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 7), sharex=True)

for i in range(len(T0s)):
    ax1.plot(vs_reactor[i], xas[i],
            linestyle=linestyles[i],
            label=(r'T0 = %s' % T0s[i]))

    ax2.plot(vs_reactor[i], p_ratios[i],
        linestyle=linestyles[i],
        label=(r'T0 = %s' % T0s[i]))

    ax3.plot(vs_reactor[i], temps_reactor[i],
        linestyle=linestyles[i],
        label=(r'T0 = %s' % T0s[i]))

    ax4.plot(vs_reactor[i], temps_coolant[i],
        linestyle=linestyles[i],
        label=(r'T0 = %s' % T0s[i]))

    plt.legend(loc='best')

ax1.set_ylabel('Conversion, adim')
ax2.set_ylabel('P/P0, adim')
ax3.set_xlabel(r'Reactor volume, dm$^3$')
ax3.set_ylabel('Reactor temperature, K')
ax4.set_xlabel(r'Reactor volume, dm$^3$')
ax4.set_ylabel('Coolant tempeture, K')
plt.tight_layout()
plt.show()
```

# Part C

In order to operate the reactor under isothermal conditions, the removed heat should be equal to the heat generated

\begin{align}
Q_g &= r_A \, DHRx \\
Q_r &= Ua \, (T - T_a)
\end{align}

Therefore, let's modify the system of ODEs to include this change. So,

\begin{equation}
qr = qg
\end{equation}

```python
def system2(v, Y, parameters):

    x, t, p, ta = Y
    phii, to, if_adiabatic = parameters

    v = Vo * (1/p) * (t/to)
    fa = Fa0 * (1 - x)
    fb = Fa0 * x
    fi = Fi0

    ft = fa + fb + fi

    ca = fa / v
    cb = fb / v

    # Kinetic parameters
    k = k_300 * np.exp(-Ea/Rgas * (1/t - 1/300))
    kc = KC_300 * np.exp(-DHRx/Rgas * (1/t - 1/300))
    ra = -k * (ca - cb / kc)

    if if_adiabatic:
        ua = 0
    else:
        ua = Ua

    # Energy balance terms
    qg = ra * DHRx
    qr = qg

    # System of ODEs
    dx_dv = -ra/Fa0
    dt_dv = (qg - qr) / (Fa0 * (CPA + phii * CPI + DCp * x))
    dp_dv = -alpha_rho_b / (2 * p) * (t/to) * (ft/Ft0)
    dta_dv = qr / (m_c * Cpcool)

    return [dx_dv, dt_dv, dp_dv, dta_dv]
```

```python
iCs = [0, T0, 1, Ta0]

Parameters = [2, T0, False]

fun = lambda v, Y: system2(v, Y, Parameters)

vspan = [0, V]  #  independent variable range

solODE = solve_ivp(fun, vspan, iCs, t_eval=np.linspace(0, V), method='LSODA')
```

```python
Vreactor = solODE.t
Xa = solODE.y[0]
Temp_reactor = solODE.y[1]
p = solODE.y[2]
Temp_coolant = solODE.y[3]
```

```python
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 7), sharex=True)

ax1.plot(Vreactor, Xa, color='black')
ax1.set_ylabel('Conversion, adim')

ax2.plot(Vreactor, p, ls='--')
ax2.set_ylabel('P/P0, adim')

ax3.plot(Vreactor, Temp_reactor, color='red', ls='-.')
ax3.set_xlabel(r'Reactor volume, dm$^3$')
ax3.set_ylabel('Reactor temperature, K')

ax4.plot(Vreactor, Temp_coolant, color='green', ls=':')
ax4.set_xlabel(r'Reactor volume, dm$^3$')
ax4.set_ylabel('Coolant tempeture, K')

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


# Tutorial 2: Convolutional Neural Networks
**Week 2, Day 1: Deep Learning**

**By Neuromatch Academy**

**Content creators**: Jorge A. Menendez, Carsen Stringer

**Content reviewers**: Roozbeh Farhoodi,  Madineh Sarvestani, Kshitij Dwivedi, Spiros Chavlis, Ella Batty, Michael Waskom

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

---
# Tutorial Objectives

In this short tutorial, we'll go through an introduction to 2D convolutions and apply a convolutional network to an image to prepare for creating normative models in tutorial 3.

In this tutorial, we will
* Understand the basics of 2D convolution
* Build a convolutional layer using PyTorch
* Visualize and analyze its outputs
* (Bonus) Build an encoding model from images to neural responses

##  Tutorial slides

 These are the slides for all videos in this tutorial.

```python
# @title Tutorial slides

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

---
# Setup

```python
# Imports

import os
import numpy as np
import torch
from torch import nn
from torch import optim
from matplotlib import pyplot as plt
import matplotlib as mpl
```

##  Figure settings

```python
#@title Figure settings
%config InlineBackend.figure_format = 'retina'
plt.style.use("https://raw.githubusercontent.com/NeuromatchAcademy/course-content/master/nma.mplstyle")
```

##  Data retrieval and loading

```python
# @title Data retrieval and loading

import hashlib
import requests

fname = "W3D4_stringer_oribinned6_split.npz"
url = "https://osf.io/p3aeb/download"
expected_md5 = "b3f7245c6221234a676b71a1f43c3bb5"

if not os.path.isfile(fname):
  try:
    r = requests.get(url)
  except requests.ConnectionError:
    print("!!! Failed to download data !!!")
  else:
    if r.status_code != requests.codes.ok:
      print("!!! Failed to download data !!!")
    elif hashlib.md5(r.content).hexdigest() != expected_md5:
      print("!!! Data download appears corrupted !!!")
    else:
      with open(fname, "wb") as fid:
        fid.write(r.content)
```

##  Plotting Functions

```python
# @title Plotting Functions

def show_stimulus(img, ax=None):
  """Visualize a stimulus"""
  if ax is None:
    ax = plt.gca()
  ax.imshow(img+0.5, cmap=mpl.cm.binary)
  ax.set_xticks([])
  ax.set_yticks([])
  ax.spines['left'].set_visible(False)
  ax.spines['bottom'].set_visible(False)

def plot_weights(weights, channels=[0], colorbar=True):
  """ plot convolutional channel weights
  Args:
        weights: weights of convolutional filters (conv_channels x K x K)
        channels: which conv channels to plot
  """
  wmax = torch.abs(weights).max()
  fig, axs = plt.subplots(1,len(channels), figsize=(12,2.5))
  for i, channel in enumerate(channels):
    im = axs[i].imshow(weights[channel,0], vmin=-wmax, vmax=wmax, cmap='bwr')
    axs[i].set_title('channel %d'%channel)

  if colorbar:
    ax = fig.add_axes([1, 0.1, 0.05, 0.8])
    plt.colorbar(im, ax=ax)
    ax.axis('off')

def plot_example_activations(stimuli, act, channels=[0]):
  """ plot activations act and corresponding stimulus
  Args:
        stimuli: stimulus input to convolutional layer (n x h x w) or (h x w)
        act: activations of convolutional layer (n_bins x conv_channels x n_bins)
        channels: which conv channels to plot
  """
  if stimuli.ndim>2:
    n_stimuli = stimuli.shape[0]
  else:
    stimuli = stimuli.unsqueeze(0)
    n_stimuli = 1

  fig, axs = plt.subplots(n_stimuli,1+len(channels),figsize=(12,12))

  # plot stimulus
  for i in range(n_stimuli):
    show_stimulus(stimuli[i].squeeze(), ax=axs[i, 0])
    axs[i, 0].set_title('stimulus')

    # plot example activations
    for k, (channel, ax) in enumerate(zip(channels, axs[i][1:])):
      img=ax.imshow(act[i,channel], vmin=-3, vmax=3, cmap='bwr')
      ax.set_xlabel('x-pos')
      ax.set_ylabel('y-pos')
      ax.set_title('channel %d'%channel)
  ax = fig.add_axes([1.05,0.8,0.01,0.1])
  plt.colorbar(img, cax=ax)
  ax.set_title('activation\n strength')
```

##  Helper Functions

```python
# @title Helper Functions

def load_data_split(data_name=fname):
  """Load mouse V1 data from Stringer et al. (2019)

  Data from study reported in this preprint:
  https://www.biorxiv.org/content/10.1101/679324v2.abstract

  These data comprise time-averaged responses of ~20,000 neurons
  to ~4,000 stimulus gratings of different orientations, recorded
  through Calcium imaginge. The responses have been normalized by
  spontaneous levels of activity and then z-scored over stimuli, so
  expect negative numbers. The repsonses were split into train and
  test and then each set were averaged in bins of 6 degrees.

  This function returns the relevant data (neural responses and
  stimulus orientations) in a torch.Tensor of data type torch.float32
  in order to match the default data type for nn.Parameters in
  Google Colab.

  It will hold out some of the trials when averaging to allow us to have test
  tuning curves.

  Args:
    data_name (str): filename to load

  Returns:
    resp_train (torch.Tensor): n_stimuli x n_neurons matrix of neural responses,
        each row contains the responses of each neuron to a given stimulus.
        As mentioned above, neural "response" is actually an average over
        responses to stimuli with similar angles falling within specified bins.
    resp_test (torch.Tensor): n_stimuli x n_neurons matrix of neural responses,
        each row contains the responses of each neuron to a given stimulus.
        As mentioned above, neural "response" is actually an average over
        responses to stimuli with similar angles falling within specified bins
    stimuli: (torch.Tensor): n_stimuli x 1 column vector with orientation
        of each stimulus, in degrees. This is actually the mean orientation
        of all stimuli in each bin.

  """
  with np.load(data_name) as dobj:
    data = dict(**dobj)
  resp_train = data['resp_train']
  resp_test = data['resp_test']
  stimuli = data['stimuli']

  # Return as torch.Tensor
  resp_train_tensor = torch.tensor(resp_train, dtype=torch.float32)
  resp_test_tensor = torch.tensor(resp_test, dtype=torch.float32)
  stimuli_tensor = torch.tensor(stimuli, dtype=torch.float32)

  return resp_train_tensor, resp_test_tensor, stimuli_tensor

def filters(out_channels=6, K=7):
  """ make example filters, some center-surround and gabors
  Returns:
      filters: out_channels x K x K
  """
  grid = np.linspace(-K/2, K/2, K).astype(np.float32)
  xx,yy = np.meshgrid(grid, grid, indexing='ij')

  # create center-surround filters
  sigma = 1.1
  gaussian = np.exp(-(xx**2 + yy**2)**0.5/(2*sigma**2))
  wide_gaussian = np.exp(-(xx**2 + yy**2)**0.5/(2*(sigma*2)**2))
  center_surround = gaussian - 0.5 * wide_gaussian

  # create gabor filters
  thetas = np.linspace(0, 180, out_channels-2+1)[:-1] * np.pi/180
  gabors = np.zeros((len(thetas), K, K), np.float32)
  lam = 10
  phi = np.pi/2
  gaussian = np.exp(-(xx**2 + yy**2)**0.5/(2*(sigma*0.4)**2))
  for i,theta in enumerate(thetas):
    x = xx*np.cos(theta) + yy*np.sin(theta)
    gabors[i] = gaussian * np.cos(2*np.pi*x/lam + phi)

  filters = np.concatenate((center_surround[np.newaxis,:,:],
                            -1*center_surround[np.newaxis,:,:],
                            gabors),
                           axis=0)
  filters /= np.abs(filters).max(axis=(1,2))[:,np.newaxis,np.newaxis]
  filters -= filters.mean(axis=(1,2))[:,np.newaxis,np.newaxis]
  # convert to torch
  filters = torch.from_numpy(filters)
  # add channel axis
  filters = filters.unsqueeze(1)

  return filters

def grating(angle, sf=1 / 28, res=0.1, patch=False):
  """Generate oriented grating stimulus

  Args:
    angle (float): orientation of grating (angle from vertical), in degrees
    sf (float): controls spatial frequency of the grating
    res (float): resolution of image. Smaller values will make the image
      smaller in terms of pixels. res=1.0 corresponds to 640 x 480 pixels.
    patch (boolean): set to True to make the grating a localized
      patch on the left side of the image. If False, then the
      grating occupies the full image.

  Returns:
    torch.Tensor: (res * 480) x (res * 640) pixel oriented grating image

  """

  angle = np.deg2rad(angle)  # transform to radians

  wpix, hpix = 640, 480  # width and height of image in pixels for res=1.0

  xx, yy = np.meshgrid(sf * np.arange(0, wpix * res) / res, sf * np.arange(0, hpix * res) / res)

  if patch:
    gratings = np.cos(xx * np.cos(angle + .1) + yy * np.sin(angle + .1))  # phase shift to make it better fit within patch
    gratings[gratings < 0] = 0
    gratings[gratings > 0] = 1
    xcent = gratings.shape[1] * .75
    ycent = gratings.shape[0] / 2
    xxc, yyc = np.meshgrid(np.arange(0, gratings.shape[1]), np.arange(0, gratings.shape[0]))
    icirc = ((xxc - xcent) ** 2 + (yyc - ycent) ** 2) ** 0.5 < wpix / 3 / 2 * res
    gratings[~icirc] = 0.5

  else:
    gratings = np.cos(xx * np.cos(angle) + yy * np.sin(angle))
    gratings[gratings < 0] = 0
    gratings[gratings > 0] = 1

  gratings -= 0.5

  # Return torch tensor
  return torch.tensor(gratings, dtype=torch.float32)
```

---
# Section 1: Introduction to 2D convolutions

## Section 1.1: What is a 2D convolution?

A 2D convolution is an integral of the product of a filter $f$ and an input image $I$ computed at various positions as the filter is slid across the input. The output of the convolution operation at position $(x,y)$ can be written as follows, where the filter $f$ is size $(K,K)$:
\begin{equation}
C(x,y) = \sum_{k_x=-K/2}^{K/2} \sum_{k_y=-K/2}^{K/2} f(k_x,k_y) I(x+k_x,y+k_y)
\end{equation}
This **convolutional filter** is often called a **kernel**.

Here is an illustration of a 2D convolution from this [article](https://towardsdatascience.com/light-on-math-machine-learning-intuitive-guide-to-convolution-neural-networks-e3f054dd5daa):

 Execute this cell to view convolution gif

```python
#@title
#@markdown Execute this cell to view convolution gif

from IPython.display import Image
Image(url='https://miro.medium.com/max/700/1*5BwZUqAqFFP5f3wKYQ6wJg.gif')
```

## Section 1.2: 2D convolutions in deep learning

###  Video 1: 2D Convolutions

```python
# @title Video 1: 2D Convolutions

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="BV1jw411d7Kg", width=730, height=410, 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="zgO9rHYbDxE", width=730, height=410, fs=1, rel=0)
  print('Video available at https://youtube.com/watch?v=' + video.id)
  display(video)

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

display(out)
```

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

This video covers convolutions and how to implement them in Pytorch.

 Click here for text recap of video

Recall Aude Oliva’s discussion of convolutions in the [intro](https://www.youtube.com/watch?v=IZvcy0Myb3M). Convolutional neural networks with several layers revolutionized the deep learning field, and in particular AlexNet, depicted here, was the first deep neural network to excel on the ImageNet classification task. The first layer in the network takes as input an image, runs convolutional filters on the image, rectifies the output, them downsamples the output (the pooling layer). The next layers repeat this process, and then at the end fully connected linear layers are attached which output a label for the image.

The main advantages of convolutional layers over fully connected layers are the reduction in parameters through weight-sharing, which we will get into shortly, and also the fact that the units have local receptive fields. These local receptive fields allow the network to pool over units in spatial proximity and helps the network learn translation invariant representations.

A convolution is the integral of the product of two functions, one of which is a stimulus and the other which is a filter. This integral is computed at all positions by sliding the filter weights across the stimulus. The output is shown in green. If you want to perform a convolution and get the same output as the input you need to pad the input by half the filter size on each side. We do this here, notice the empty boxes on the side - this is called “same” padding. Another parameter of this convolution computation is the stride -- how often the convolution is computed along the stimulus dimension. In this case we used a stride of 1, but we can increase the stride and in turn have fewer units.

All the units of this filter are called a single output **channel**. A convolutional layer often consists of multiple output channels each with their own filter weights. We call the number of output convolutional channels $C_{out}$.

How many weights does this conv layer have? Also if this were a fully connected linear layer like in tutorial 1 with the same number of outputs, how many parameters would this layer have? Discuss this with your group - in particular when there is a big parameter mismatch between these two when the stimulus dimensionality is large.

Now let’s implement this convolutional layer in pytorch. We will create a convolutional layer `ConvolutionalLayer` which takes as input a stimulus, which in our case are the gratings images. The convolutional layer is initialized with a few different parameters - first is the # of input channels $C_{in}$, which is 1 in our case. Next the number of convolutional channels which we’ll call $C_{out}$ which we can set to 6. Then the size of the filter $K$ which we set by default to 7. There’s also an optional `filters` input which we use to initialize the convolutional weights.  We set them as the weights of the conv layer we just created, and set the bias terms for the conv layer to zero.

Now we declare an `nn.conv2d` variable to be an attribute of the class `ConvolutionalLayer` called `conv`. For this convolutional layer we set the padding to half the filter size and the stride to 1 to get the same size output as the input.

What are some example filters we might use? One is a center-surround filter. It is positive in the middle and negative in the surround. Another is a gabor filter, which has a positive section next to a negative section. Look at the responses of these filters given the image. Both of these filter types are inspired by neurons recorded in the brain.

In fact, convolutional neural networks are inspired by the brain. In the retina there are a variety of cell types which we can think of as filters and each of these cells tile the entire visual space. The picture here shows the part of visual space each one of these cells responds to. In barrel cortex we have a similar situation where each whisker’s activation corresponds to a single cortical column of activity and the functions computed in each of these columns are similar.

Object recognition was essentially an unsolved problem in machine learning until the [advent](https://en.wikipedia.org/wiki/AlexNet) of techniques for effectively training *deep* convolutional neural networks. See Bonus Section 1 for more information on why we use CNNs to model the visual system.

Convolutional neural networks consist of 2D convolutional layers, RELU non-linearities, 2D pooling layers, and at the output, a fully connected layer. We will see an example network with all these components in tutorial 3.

A 2D convolutional layer has multiple output channels. Each output **channel** is the result of a 2D convolutional filter applied to the input. In the gif below, the input is in blue, the filter is in gray, and the output is in green. The number of units in the output channel depends on the *stride* you set. In the gif below, the stride is 1 because the input image is sampled at each position, a stride of 2 would mean skipping over input positions. In most applications, especially with small filter sizes, a stride of 1 is used.

(*Technical note*: if filter size *K* is odd and you set the *pad=K//2* and *stride=1* (as is shown below), you get a **channel** of units that is the same size as the input. See a more detailed explanation of strides and pads [here](https://theano-pymc.readthedocs.io/en/latest/tutorial/conv_arithmetic.html) if interested).

 Execute this cell to view convolution gif

```python
#@title
#@markdown Execute this cell to view convolution gif

from IPython.display import Image
Image(url='https://miro.medium.com/max/790/1*1okwhewf5KCtIPaFib4XaA.gif')
```

## Section 1.3: 2D convolutions in Pytorch

In Tutorial 1, fully connected linear layers were used to decode stimuli from neural activity. Convolutional layers are different from their fully connected counterparts in two ways:
  * In a fully connected layer, each unit computes a weighted sum over all the input units. In a convolutional layer, on the other hand, each unit computes a weighted sum over **only a small patch of the input**, referred to as the unit's **receptive field**. When the input is an image, the receptive field can be thought of as a local patch of pixels.
  * In a fully connected layer, each unit uses its own independent set of weights to compute the weighted sum. In a convolutional layer, all the units (within the same channel) **share the same weights**. This set of shared weights is called the **convolutional filter or kernel**. The result of this computation is a convolution, where each unit has computed the same weighted sum over a different part of the input. This reduces the number of parameters in the network substantially.

We will compute the difference in the number of weights for a fully connected layer versus a convolutional layer in the Think exercise below.

First, let's visualize the stimuli in the dataset from tutorial 1. During the neural recordings from [Stringer et al 2019](https://www.biorxiv.org/content/10.1101/679324v2.abstract), mice were presented oriented gratings:

 Execute this cell to plot example stimuli

```python
# @markdown Execute this cell to plot example stimuli

orientations = np.linspace(-90, 90, 5)

h = 3
n_col = len(orientations)
fig, axs = plt.subplots(1, n_col, figsize=(h * n_col, h))

h, w  = grating(0).shape  # height and width of stimulus
print('stimulus size: %i x %i' % (h, w))

for i, ori in enumerate(orientations):
  stimulus = grating(ori)
  axs[i].set_title(f'{ori: .0f}$^o$')
  show_stimulus(stimulus, axs[i])
```

Now let's implement 2D convolutional operations. We will use mutliple convolutional channels and implement this operation efficiently using pytorch. A *layer* of convolutional channels can be implemented with one line of code using the PyTorch class `nn.Conv2d()`, which requires the following arguments for initialization (see full documentation [here](https://pytorch.org/docs/master/generated/torch.nn.Conv2d.html)):
  * $C^{in}$: the number of input channels
  * $C^{out}$: the number of output channels (number of different convolutional filters)
  * $K$: the size of the $C^{out}$ different convolutional filters

When you run the network, you can input a stimulus of arbitrary size $(H^{in}, W^{in})$, but it needs to be shaped as a 4D input $(N, C^{in}, H^{in}, W^{in})$, where $N$ is the number of images. In our case, $C^{in}=1$ because there is only one color channel (our images are grayscale, but often $C^{in}=3$ in image processing).

```python
class ConvolutionalLayer(nn.Module):
  """Deep network with one convolutional layer
     Attributes: conv (nn.Conv2d): convolutional layer
  """
  def __init__(self, c_in=1, c_out=6, K=7, filters=None):
    """Initialize layer

    Args:
        c_in: number of input stimulus channels
        c_out: number of output convolutional channels
        K: size of each convolutional filter
        filters: (optional) initialize the convolutional weights

    """
    super().__init__()
    self.conv = nn.Conv2d(c_in, c_out, kernel_size=K,
                          padding=K//2, stride=1)
    if filters is not None:
      self.conv.weight = nn.Parameter(filters)
      self.conv.bias = nn.Parameter(torch.zeros((c_out,), dtype=torch.float32))

  def forward(self, s):
    """Run stimulus through convolutional layer

    Args:
        s (torch.Tensor): n_stimuli x c_in x h x w tensor with stimuli

    Returns:
        (torch.Tensor): n_stimuli x c_out x h x w tensor with convolutional layer unit activations.

    """
    a = self.conv(s)  # output of convolutional layer

    return a
```

See that `ConvolutionalLayer` takes as input `filters`. We have **predesigned some filters** that you can use by calling the `filters` function below. These are similar to filters we think are implemented in biological circuits such as the retina and the visual cortex. Some of them are **center-surround** filters and some of them are **gabor** filters. Check out this [website](http://www.cns.nyu.edu/~david/courses/perception/lecturenotes/ganglion/ganglion.html) for more details on center-surround filters, and this [website](https://en.wikipedia.org/wiki/Gabor_filter) for more details on gabor filters, if you're interested.

Create the filters and visualize two of them:

```python
example_filters = filters(out_channels=6, K=7)

plt.figure(figsize=(8,4))
plt.subplot(1,2,1)
plt.imshow(example_filters[0,0], vmin=-1, vmax=1, cmap='bwr')
plt.title('center-surround filter')
plt.axis('off')
plt.subplot(1,2,2)
plt.imshow(example_filters[4,0], vmin=-1, vmax=1, cmap='bwr')
plt.title('gabor filter')
plt.axis('off')
```

### Coding Exercise 1.3: 2D convolution in pytorch

We will now run the convolutional layer on our stimulus. We will use gratings stimuli made using the function `grating`, which returns a stimulus which is 48 x 64.

Reminder, `nn.Conv2d` takes in a tensor of size $(N, C^{in}, H^{in}, W^{in}$) where $N$ is the number of stimuli, $C^{in}$ is the number of input channels, and $(H^{in}, W^{in})$ is the size of the stimulus. We will need to add these first two dimensions to our stimulus, then input it to the convolutional layer.

We will plot the outputs of the convolution. `convout` is a tensor of size $(N, C^{out}, H^{in}, W^{in})$ where $N$ is the number of examples and $C^{out}$ are the number of convolutional channels. It is the same size as the input because we used a stride of 1 and padding that is half the kernel size.

```python
# Stimulus parameters
in_channels = 1 # how many input channels in our images
h = 48 # height of images
w = 64 # width of images

# Convolution layer parameters
K = 7 # filter size
out_channels = 6 # how many convolutional channels to have in our layer
example_filters = filters(out_channels, K) # create filters to use

convout = np.zeros(0) # assign convolutional activations to convout

################################################################################
## TODO for students: create convolutional layer in pytorch
# Complete and uncomment
# raise NotImplementedError("Student exercise: create convolutional layer")
################################################################################

### Initialize conv layer and add weights from function filters
# you need to specify :
# * the number of input channels c_in
# * the number of output channels c_out
# * the filter size K
convLayer = ConvolutionalLayer(c_in=in_channels, c_out=out_channels, K=K, filters=example_filters)

### Create stimuli (H_in, W_in)
orientations = [-90, -45, 0, 45, 90]
stimuli = torch.zeros((len(orientations), in_channels, h, w), dtype=torch.float32)
for i,ori in enumerate(orientations):
  stimuli[i,0] = grating(ori)

convout = convLayer(stimuli)
convout = convout.detach() # detach gradients

plot_example_activations(stimuli, convout, channels=np.arange(0, out_channels))
```

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W2D1_DeepLearning/solutions/W2D1_Tutorial2_Solution_712d995b.py)

*Example output:*

### Think! 1.3: Output and weight shapes in a convolutional layer

Let's think about the shape of the weights and outputs of `convLayer`:
  - How many convolutional activations are there in each channel, and why is it this size?
  - How many weights does `convLayer` have?
  - How many weights would it have if it were a fully connected layer?

Additionally, let's think about why the activations look the way they do. It seems like for all channels the activations are only non-zero for edges of the gratings (where the grating goes from white-to-black and from black-to-white).
  -  Channel 0 and 1 seem to respond to every edge regardless of the orientation, but their signs are different. What type of filter might produce these types of responses?
  - Channels 2-5 seem to respond differently depending on the orientation of the stimulus. What type of filter might produce these types of responses?

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W2D1_DeepLearning/solutions/W2D1_Tutorial2_Solution_f48d6ecf.py)

1. There are H * W activations in each channel because the stride in the
convolutional layer is set to 1, and the padding is set to K//2.

2. The convLayer has **K * K * C_out** weights and C_out bias terms.

3. A fully connected layer would have **(H * W) * C_out** weights and C_out bias terms.

4. A center-surround filter will respond to a change in luminance (black-to-white
or white-to-black) regardless of orientation.

5. A gabor filter of different orientations will respond this way. See the
exercise below for more explanation.

Please see Bonus Section 2 to visualize the convolutional filter weights. See Bonus Section 3 to use CNNs as encoding models of neurons (by fitting directly to neural responses).

---
# Summary

*Estimated timing of tutorial: 40 minutes*

In this notebook, we built a 2D convolutional layer which is meant to represent the responses of neurons in mouse visual cortex, or the responses of neurons
which are inputs to mouse visual cortex.

In Tutorial 3, we will add to this 2D convolutional layer a fully-connected layer and train this model to predict whether an orientation is left or right. We will see if the convolutional filters it learns are similar to mouse visual cortex. See Section 3 in the Bonus Tutorial to fit convolutional neural networks directly to neural activity.

---
# Bonus

## Bonus Section 1: Why CNN's?

CNN models are particularly [well-suited](https://www.nature.com/articles/nn.4244) to modeling the visual system for a number of reasons:

1. **Distributed computation**: like any other neural network, CNN's use **distributed** representations to compute -- much like the brain seems to do. Such models, therefore, provide us with a vocabulary with which to talk and think about such distributed representations. Because we know the exact function the model is built to perform (e.g. orientation discrimination), we can analyze its internal representations with respect to this function and begin to interpret why the representations look the way they do. Most importantly, we can then use these insights to analyze the structure of neural representations observed in recorded population activity. We can qualitatively and quantitatively compare the representations we see in the model and in a given population of real neurons to hopefully tease out the computations it performs.

2. **Hierarchical architecture**: like in any other deep learning architecture, each layer of a deep CNN comprises a non-linear transformation of the previous layer. Thus, there is a natural hierarchy whereby layers closer to the network output represent increasingly more abstract information about the input image. For example, in a network trained to do object recognition, the early layers might represent information about edges in the image, whereas later layers closer to the output might represent various object categories. This resembles the [hierarchical structure of the visual system](https://pubmed.ncbi.nlm.nih.gov/1822724/), where [lower-level areas](https://www.jneurosci.org/content/25/46/10577.short) (e.g. retina, V1) represent visual features of the sensory input and [higher-level areas](https://www.sciencedirect.com/science/article/pii/S089662731200092X) (e.g. V4, IT) represent properties of objects in the visual scene. We can then naturally use a single CNN to model multiple visual areas, using early CNN layers to model lower-level visual areas and late CNN layers to model higher-level visual areas.

  Relative to fully connected networks, CNN's, in fact, have further hierarchical structure built-in through the max pooling layers. Recall that each output of a convolution + pooling block is the result of processing a local patch of the inputs to that block. If we stack such blocks in a sequence, then the outputs of each block will be sensitive to increasingly larger regions of the initial raw input to the network: an output from the first block is sensitive to a single patch of these inputs, corresponding to its "receptive field"; an output from the second block is sensitive to a patch of outputs from the first block, which together are sensitive to a larger patch of raw inputs comprising the union of their receptive fields. Receptive fields thus get larger for deeper layers (see [here](http://colah.github.io/posts/2014-07-Conv-Nets-Modular/) for a nice visual depiction of this). This resembles primate visual systems, where neurons in higher-level visual areas respond to stimuli in wider regions of the visual field than neurons in lower-level visual areas.

3. **Convolutional layers**: through the weight sharing constraint, the outputs of each channel of a convolutional layer process different parts of the input image in exactly the same way. This architectural constraint effectively builds into the network the assumption that objects in the world typically look the same regardless of where they are in space. This is useful for modeling the visual system for two (largely separate) reasons:
  * Firstly, this assumption is generally valid in mammalian visual systems, since mammals tend to view the same object from many perspectives. Two neurons at a similar hierarchy in the visual system with different receptive fields could thus end up receiving statistically similar synaptic inputs, so that the synaptic weights developed over time may end up being similar as well.
  * Secondly, this architecture significantly improves object recognition ability. Object recognition was essentially an unsolved problem in machine learning until the [advent](https://en.wikipedia.org/wiki/AlexNet) of techniques for effectively training *deep* convolutional neural networks. Fully connected networks on their own can't achieve object recognition abilities anywhere close to human levels, making them bad models of human object recognition. Indeed, it is generally the case that [the better a neural network model is at object recognition, the closer the match between its representations and those observed in the brain](https://www.pnas.org/content/111/23/8619.short). That said, it is worth noting that our much simpler orientation discrimination task (in Tutorial 3) can be solved by relatively simple networks.

---
## Bonus Section 2: Understanding activations from weight

### Bonus Coding Exercise 2: Visualizing convolutional filter weights

Why do the activations look the way they do? Let's look at the weights (`convLayer.conv.weight.detach()`) of the convolutional filters and try to interpret them.

```python
################################################################################
## TODO for students: get weights
# Complete and uncomment
# raise NotImplementedError("Student exercise: get weights")
################################################################################

# get weights of conv layer in convLayer
weights = convLayer.conv.weight.detach()
print(weights.shape) # can you identify what each of the dimensions are?

plot_weights(weights, channels=np.arange(0, out_channels))
```

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W2D1_DeepLearning/solutions/W2D1_Tutorial2_Solution_20c66403.py)

*Example output:*

In the function `filters` we premade center-surround filters and Gabor filters of various orientations. [Gabor filters](https://en.wikipedia.org/wiki/Gabor_filter) have a positive region next to a negative region and the orientation of these regions of the filter determine the orientation of edges to which they respond.

In visual cortex, Hubel and Wiesel discovered simple cells, which would correspond to a unit in the channels 2-5 above with Gabor filters. There are also some neurons with activity that resembles center-surround filters, which would correspond to the first two convolutional channels above.

There were additional cells discovered by Hubel and Wiesel - **complex cells** - that respond to an oriented grating regardless of where the bars are exactly (note that the responses we see are specific to where the bars are). **These cells therefore have some level of translation invariance**. This is something that convolutional neural networks try to replicate -- e.g. a grating is still oriented horizontal even if it moves slightly, and a cat is still a cat even if it's in a different position in the image.

### Bonus Think! 2
  - How might you create a complex cell and have responses that are translation invariant?
  - How might you create a cell that responds to multiple orientations?

[*Click for solution*](https://github.com/NeuromatchAcademy/course-content/tree/master//tutorials/W2D1_DeepLearning/solutions/W2D1_Tutorial2_Solution_d290c523.py)

1. A complex cell combines two rectified Gabor filters of the same orientation but with
different phases. This is something a neural network can reproduce with a
RELU layer and combinations of convolutional channels. Additionally neural networks
employ a strategy called **pooling layers**, which **combine units in a small region and
take the maximum value of their activations as the output**. We will see these
layers in the next tutorial, where we will try to decode orientation directly from
images.

2. A cell that responds to multiple orientations would have to be **a sum of multiple
convolutional channels**. In the next bonus section, we will hook up this convolutional
layer to a fully connected layer.