チラ裏備忘録

情報整理

【内部処理】畳み込みの高速処理を実現する方法

畳み込みをforループを用いずにどのように実装されているのかが気になっていたのですが,調べてみると,どうやらnn.Unfoldというメソッドを使うことで行列演算へと帰着させる方法が存在するようです.そこでnn.Unfoldを用いて畳み込みを実装してみました.
(尚,PyTorchが実際にこのような方法を用いてnn.Conv2d等の畳み込み演算を実装しているかまでは確認していません.あくまでそういった実装も可能というだけです)

検証のため,nn.Unfoldを用いた実装による畳み込みの出力と,通常通りnn.Conv2dを介した出力を比較してみました.

実装

batch_size,in_channel,out_channelは任意に変更可能です.

batch_size = 2
in_channel = 3
out_channel = 2

input = torch.FloatTensor(np.random.rand(batch_size, in_channel, 3, 3))
input2 = torch.nn.Unfold(kernel_size=(2, 2), stride=(1, 1), padding=(0, 0))(input)

c = nn.Conv2d(in_channel, out_channel, 2)
print(f'Conv2d層の重み:\n{c.weight.data}\nバイアス:\n{c.bias.data}')

w = c.weight.data.detach().clone()
w = w.view(out_channel, -1)
b = c.bias.data
print('\nカーネルを展開')
print(w)

print('\n展開した入力とカーネルで行列積を行い,整形')
print((torch.matmul(w, input2) + b.view(1, out_channel, 1)).view(batch_size, out_channel, 2, 2))

print('\n通常の畳み込みによる処理')
print(c(input))
出力
Conv2d層の重み:
tensor([[[[ 0.1203,  0.1741],
          [-0.2618,  0.2749]],

         [[ 0.0496,  0.2472],
          [-0.1281,  0.0517]],

         [[ 0.0583,  0.2524],
          [ 0.2381,  0.2859]]],


        [[[ 0.2665, -0.1610],
          [ 0.1499,  0.1978]],

         [[ 0.0339,  0.1021],
          [ 0.0370,  0.0104]],

         [[ 0.1742,  0.2682],
          [-0.0080, -0.2463]]]])
バイアス:
tensor([-0.2232,  0.1847])

カーネルを展開
tensor([[ 0.1203,  0.1741, -0.2618,  0.2749,  0.0496,  0.2472, -0.1281,  0.0517,
          0.0583,  0.2524,  0.2381,  0.2859],
        [ 0.2665, -0.1610,  0.1499,  0.1978,  0.0339,  0.1021,  0.0370,  0.0104,
          0.1742,  0.2682, -0.0080, -0.2463]])

展開した入力とカーネルで行列積を行い,整形
tensor([[[[0.6216, 0.1176],
          [0.6010, 0.4215]],

         [[0.8527, 0.7857],
          [0.6273, 0.5581]]],


        [[[0.7947, 0.3132],
          [0.3201, 0.7330]],

         [[0.6220, 0.8391],
          [0.3936, 0.4128]]]])

通常の畳み込みによる処理
tensor([[[[0.6216, 0.1176],
          [0.6010, 0.4215]],

         [[0.8527, 0.7857],
          [0.6273, 0.5581]]],


        [[[0.7947, 0.3132],
          [0.3201, 0.7330]],

         [[0.6220, 0.8391],
          [0.3936, 0.4128]]]], grad_fn=<ThnnConv2DBackward>)

2つの出力が等しいことから,畳み込みはnn.Unfoldを用いて実装できることがわかりました.

おまけ(nn.Linear)

線形変換のnn.Linearもtorch.matmulで超簡単に実装できます.
 y = x A^T + b

in_features = 4
out_features = 2
dim = 3
l = nn.Linear(in_features, out_features)
input = torch.randn(1, dim, in_features)
l(input), torch.matmul(input, l.weight.data.T) + l.bias.data
出力
(tensor([[[-0.6326,  0.3161],
          [-0.0485,  0.2758],
          [-0.4873,  0.5317]]], grad_fn=<AddBackward0>),
 tensor([[[-0.6326,  0.3161],
          [-0.0485,  0.2758],
          [-0.4873,  0.5317]]]))

活性化関数と重みの初期値の関係

『ゼロから作るDeep Learning』を久々に読み返すと,活性化関数と重みの初期値の関係についての記載がありました.
恥ずかしながら初期値についてはあまり意識せず適当に決めていたので,意識付けのためにも,これらの関係について検証を行ってみました(n番煎じ).

本書曰く,

活性化関数にReLUを使う場合は「Heの初期値」,sigmoidやtanhなどのS字カーブのときは「Xavierの初期値」を使う

ということらしいです.
前層のノード数をnとするとき,Heの初期値では\sqrt{\frac{2}{n}}を,Xavierの初期値では\sqrt{\frac{1}{n}}標準偏差とするガウス分布によって初期化を行います.

モデルの定義

import torch
import torch.nn as nn
import matplotlib.pyplot as plt

class TestModel(nn.Module):
    def __init__(self, hidden_num, layer_num, init_func=None, act_func=None):
        super(TestModel, self).__init__()

        self.act_dist = []
        def forward_func(module, input, outputs):
            self.act_dist.append(outputs.view(-1).detach().clone().numpy())

        if init_func == 'he_normal':
            init = nn.init.kaiming_normal_
        elif init_func == 'he_uniform':
            init = nn.init.kaiming_uniform_
        elif init_func == 'xavier':
            init = nn.init.xavier_normal_
        else:
            init = nn.init.normal_

        layers = []
        for _ in range(layer_num):
            l = nn.Linear(hidden_num, hidden_num)
            init(l.weight)
            layers.append(l)
            
            if act_func == 'relu':
                act = nn.ReLU()
            elif act_func == 'sigmoid':
                act = nn.Sigmoid()
            elif act_func == 'tanh':
                act = nn.Tanh()
            else:
                act = nn.Identity()

            act.register_forward_hook(forward_func)
            layers.append(act)

        self.layers = nn.Sequential(*layers)

    def forward(self, x):
        x = self.layers(x)
        return x

順伝播の実行と分布の出力

hidden_num = 100
layer_num = 5
init_func = 'he_normal'
act_func = 'relu'
input = torch.randn(1000, 1, hidden_num)
model = TestModel(hidden_num, layer_num, init_func=init_func, act_func=act_func)

outputs = model(input)

fig = plt.figure(figsize=(24, 4))
fig.suptitle(f'Initialization: {init_func}, Activation: {act_func}', fontsize=20)
for i, ad in enumerate(model.act_dist):
    plt.subplot(1,len(model.act_dist), i+1)
    plt.hist(ad, bins=50)
    if act_func == 'relu':
        plt.xlim([0.0, 1.0])
        plt.ylim([0, 10000])
    elif act_func == 'sigmoid':
        plt.xlim([0.0, 1.0])
        plt.ylim([0, 6000])
    elif act_func == 'tanh':
        plt.xlim([-1.0, 1.0])
        plt.ylim([0, 5000])

検証

隠れ層の次元が100の線形結合を5層つなげて活性化関数の出力の分布をヒストグラムにしました.

ReLU

Xavierの初期値
f:id:spookyboogie:20220202055353p:plain

Heの初期値
f:id:spookyboogie:20220202055510p:plain
確かに「Xavierの初期値」では分布に偏りが見られます.

Sigmoid

Xavierの初期値
f:id:spookyboogie:20220202060304p:plain

Heの初期値
f:id:spookyboogie:20220202060514p:plain
「Heの初期値」では,「Xavierの初期値」に比べて僅かですが3層目以降の分布が歪になる傾向が見られました.
また,「Heの初期値」では1層目の分布が,山を均したような形状になっています.『活性化関数の出力の分布が広い方が表現力が高い』と本書に記載がありましたが,Sigmoidでは0.5から離れるにつれて傾きが小さくなるため,誤差消失が起きる可能性が高まります.一概に広い→良いとは言えないのかもしれません.

Tanh

Xavierの初期値
f:id:spookyboogie:20220202061708p:plain

Heの初期値
f:id:spookyboogie:20220202061732p:plain
「Xavierの初期値」では,程よく0.0付近に集中している一方で,「Heの初期値」では,出力が広く分布しており,1層目では-1.0と1.0の度数が高いことがわかります.これも先程のSigmoid同様に,Tanhの外形を考えると誤差消失の観点から好ましくないのだと思われます.


これまでは重みの初期値はPyTorchのデフォルトのままでしたが,今後は気を配ろうと思います.

int64型のnumpy配列では,ToTensor()による正規化が行われない?

型をuint8にすると正しく[0, 1]になりました.以後,気をつけます.

import numpy as np
import torchvision.transforms as transforms

t = transforms.ToTensor()

a = np.random.randint(0, 255, size=(4, 3))

print(a.dtype)
print(t(a)) # なぜか[0, 1]にならない

a = a.astype('uint8')

print(a.dtype)
print(t(a)) # dtypeを変更すると[0, 1]になる

# output
int64
tensor([[[129,  27,  22],
         [ 23, 107,  42],
         [ 62,  44, 228],
         [206, 226,  22]]])
uint8
tensor([[[0.5059, 0.1059, 0.0863],
         [0.0902, 0.4196, 0.1647],
         [0.2431, 0.1725, 0.8941],
         [0.8078, 0.8863, 0.0863]]])

nn.○○とnn.functional.○○の違い

qiita.com

nn.ReLU()は、nn.Moduleを作ります。つまり、nn.Sequential()に追加できます。反対にnn.functional.reluは、relu関数を呼ぶだけで、もし記述するならforward methodの方に書きます。好きな方を使えば良いみたいですが、個人的には、print(model)とモデルの構造を表示させたときにreluも表示させたいので、nn.ReLU()の方を使います。

nn.functionalだとあくまでforward処理途中に関数を挟み込むだけなので,モデル出力には反映されないみたいです.

また,nnの場合は,予めパラメータを渡し,クラスをインスタンス化します.
そのため,何度も繰り返し使う場合等は,コードが簡略化でき,可読性向上にも繋がるかと思います.

AAP = torch.nn.AdaptiveAvgPool2d(output_size=1) # パラメータを設定し,インスタンス化
AAP(test_tensor1)
AAP(test_tensor2)
...

eval()時の注意点

www.hellocybernetics.tech

PyTorchではmodel.train()やmodel.eval()によって、モデルのモードを切り替えますが、これらのメソッドによってドロップアウトを行うか否かを自動で切り替えてくれるのはドロップアウトクラス(torch.nn.Dropout)の方です。torch.nn.functional.dropoutの方は、model.eval()などが働きません。関数の引数でtorch.nn.functional.dropout(training=False)などとしなければならないのです。

また,functionalでは,eval()時にオフにされるべき層などが正しく機能しない場合があるそうです.


総括すると,基本的にnnで良さそうですね.

Adaptive_avg_pool2dとavg_pool2dの違い

discuss.pytorch.org

test_input = Image.open('test_image.jpeg') # 1024x768
plt.figure(figsize=(12,4))

# avg_pool2d
outputs1 = F.avg_pool2d(torchvision.transforms.ToTensor()(test_input), kernel_size=(4, 4), stride=2, padding=1)
print(outputs1.shape)
plt.subplot(1,2,1), plt.imshow(outputs1.permute(1,2,0))

# adaptive_avg_pool2d
w, h = test_input.size
outputs2 = F.adaptive_avg_pool2d(torchvision.transforms.ToTensor()(test_input), output_size=(int(h*0.5), int(w*0.5)))
print(outputs2.shape)
plt.subplot(1,2,2), plt.imshow(outputs2.permute(1,2,0))

# torch.Size([3, 384, 512])
# torch.Size([3, 384, 512])

f:id:spookyboogie:20210207074729j:plain

avg_pool2dは,カーネルサイズやストライド,パディングを指定し,畳み込み演算を行います.(恐らくカーネルの各係数は,1/(カーネルの画素数))
一方,adaptive_avg_pool2dは,出力サイズを指定すると,自動的にカーネルサイズ等を決定してくれる模様.

リサイズ

tzmi.hatenablog.com
tzmi.hatenablog.com

上記記事によると,縮小(downsample)はadaptive_avg_pool2d,拡大(upsample)はF.interpolate(レイヤとして使用する場合はnn.Upsample)で良さそう?

レイヤとして使用する場合の意味
関数として利用するか,クラスをインスタンス化して利用するかの違いです.
nn.Upsampleは後者にあたり,init時にパラメータを渡してレイヤをインスタンス化しておき,forward上でレイヤとして利用します.
(必ずしもnn.Upsampleである必要はなく,F.interpolateをforward内で使用することも可能かと思います)

# 関数として利用
x = torch.randn(1,1,4,4)
x_out = F.interpolate(x, (8,8)) # 関数として利用

# クラスをインスタンス化して利用
x = torch.randn(1,1,4,4)
m = nn.Upsample((8,8)) # インスタンス化
x_out = m(x)

PyTorchで線形回帰(二次関数)

はじめに

PyTorchで単純な線形回帰を行いたいと思います.
nn.Linear()を使ってやるのもいいのですが,今回は,重みとバイアス用のTensorを自分で定義する方針で行いました.

色々読み込み

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np

データの準備

N = 100
x = np.linspace(-15, 15, N)
y = -2 * x**2 + 10*x + 32*np.random.randn(N)

x = x.astype('float32')
y = y.astype('float32')

x = torch.from_numpy(x)
y = torch.from_numpy(y)

w0 = torch.tensor(1.0, requires_grad=True)
w1 = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)

今回は, y = -2 x^2 + 10 x + Noiseを線形回帰させます.
よって,なるべく w_0 = -2, w_1 = 10に近づくことが理想です.
また,バイアス項は,N(0, 1)に従う乱数によってバラつくので,0付近に近づくと思います.

グラフ

f:id:spookyboogie:20200926233557p:plain

モデル定義

def model(x):
  return w0*x**2 + w1*x + b

PyTorchのモデルというと,class MyModel(nn.Module): という定義の仕方が主ですが,
今回はnnモジュールを使った複雑な計算は必要としないので,関数として定義します.

損失関数と最適化手法の定義

criterion = nn.MSELoss()
optimizer = torch.optim.SGD([w0, w1, b], lr=1.0e-5)

損失関数は平均二乗誤差,最適化手法はSGDで学習率は0.00001としました.
また,勾配計算によって更新したいパラメータは,第一引数にリストで渡してやれば良いっぽいです.
(nn.Moduleによる定義では,モデル全体のパラメータを対象にしたいため,model.parameters()としていました)

学習

losses = []
epochs = 10000
es_count = 0
patience = 3

for epoch in range(epochs):
  optimizer.zero_grad()

  pred = model(x.view(-1, 1))

  loss = criterion(pred, y.view(-1, 1))
  loss.backward()
  optimizer.step()

  losses.append(loss.item())
  if epoch % 100 == 0:
    print("epoch {}, loss: {}".format(epoch+1, losses[epoch]))

  # Early Stopping
  if epoch > 0 and losses[epoch - 1] < losses[epoch]:
    es_count += 1
    if es_count >= patience:
      break
  else:
    es_count = 0

print('loss:', loss.item())
print('w0:', w0.item())
print('w1:', w1.item())
print('b:', b.item())

100epochごとに結果を表示します.

es_countというのは,EarlyStoppingという機能を実装するための変数です.
EarlyStoppingは,指定の回数以上損失が上がり続けた段階で学習をストップさせるというものです.
具体的には,1epoch前のlossと比較し,上回っていればカウント.3(=patience)回連続で続けば学習ストップ,としています.
(今回は10000epochまでlossが上昇することはなかったため,不要でしたが・・・)

結果

epoch 1, loss: 102432.0390625
epoch 101, loss: 5998.56396484375
epoch 201, loss: 4711.43408203125
epoch 301, loss: 3763.884765625
epoch 401, loss: 3066.32275390625
epoch 501, loss: 2552.794921875
epoch 601, loss: 2174.745361328125
epoch 701, loss: 1896.4339599609375
epoch 801, loss: 1691.5440673828125
epoch 901, loss: 1540.706298828125
epoch 1001, loss: 1429.658447265625

(中略)

epoch 9001, loss: 1118.526123046875
epoch 9101, loss: 1118.5107421875
epoch 9201, loss: 1118.49560546875
epoch 9301, loss: 1118.480224609375
epoch 9401, loss: 1118.4647216796875
epoch 9501, loss: 1118.4493408203125
epoch 9601, loss: 1118.434326171875
epoch 9701, loss: 1118.4189453125
epoch 9801, loss: 1118.403564453125
epoch 9901, loss: 1118.3885498046875
loss: 1118.3734130859375
w0: -2.0004830360412598
w1: 10.306112289428711
b: 0.38533857464790344

 w_0は-2に, w_1は10に近づいているので,それなりに近似できていることがわかります.

グラフ

f:id:spookyboogie:20200926233611p:plain
青が元の関数,オレンジが予測です.
見事に重なっていますね.

lossの推移(おまけ)

f:id:spookyboogie:20200926235032p:plain

断崖絶壁です.これを見る限り,1000epochもすれば十分そうですね.

PyTorchでMNIST

はじめに

PyTorchを触ってみました.
試しに,単純なパーセプトロンのみでMNIST分類をやってみます.

すべてGoogle Colabの環境で実行しました.
Python: 3.6.9
PyTorch: 1.6.0+cu101

色々読み込み

import torch, torch.nn as nn, torchvision
from torchvision import models, transforms
import matplotlib.pyplot as plt

データローダ作成

transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5, ), (0.5, ))])
trainset = torchvision.datasets.MNIST(root='./', train=True, download=True, transform=transform)
testset = torchvision.datasets.MNIST(root='./', train=False, download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=32, shuffle=True)
testloader = torch.utils.data.DataLoader(testset, batch_size=32, shuffle=False)
transforms.Compose()めちゃ便利

transforms.Compose([処理1, 処理2, ...])と書くだけで,上から順に加工処理を適用してくれる.
適当に抜粋↓

transforms.Resize(size) # 画像の短辺がこのサイズにフィットするようリサイズ
transforms.CenterCrop() # 画像の中央を(おそらく)短辺の長さで正方形に切り取る
transforms.RandomHorizontalFlip() # ランダムで反転
transforms.ToTensor() # Tensor型に変換 ※必須級
transforms.Normalize(mean, std) # 引数はTensor型に限る(故に最後に配置すると良さそう)

ちなみに,transforms.Normalizeは引数をタプルで渡しますが,
transforms.Normalize((0.1, 0.2, 0.3), (1.1, 1.2, 1.3) )のように書くと,左からR, G, Bの各チャネルに対応するそうです.

モデル定義

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

    self.layer1 = nn.Sequential(
      nn.Linear(784, 1000),
      nn.Sigmoid()
    )
    self.classifier = nn.Sequential(
      nn.Linear(1000, 10),
      nn.Softmax(dim=1)
    )

  def forward(self, x):
    x = self.layer1(x)
    return self.classifier(x)

学習モデル.中間層のユニット数は1,000としました.
forward()に順伝播の処理順を示します.

モデルのインスタンス

device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
net = Net()to(device)

PyTorchはTensorごとにリソース(CPU or GPU)を選択できます.
CUDAが利用できる環境であればtorch.cuda.is_available()はTrueを返すので,deviceの中身はtorch.device('cuda:0')となります.
そしてインスタンス生成時にnet = Net().to(device)としてやれば,モデルに含まれるパラメータをすべてGPU上で処理できるようになります.

損失関数と最適化手法の決定

criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=1e-3)

損失関数はクロスエントロピー
最適化手法はAdamで,学習率は0.001です.

学習

train_loss_history = []
val_loss_history = []
acc_history = []
epochs = 10
for epoch in range(epochs):
    train_loss_sum = 0.0
    val_loss_sum = 0.0
    
    net.train() # 学習モード
    for x, y in trainloader:
        # 勾配を初期化
        optimizer.zero_grad()

        # データ整形 & GPU処理用に変換
        x = x.view(-1, 28*28)
        x = x.to(device)
        y = y.to(device)
        
        # 順伝播
        outputs = net(x)
        loss = criterion(outputs, y)

        # 逆伝播(バックプロパゲーション)
        loss.backward()

        # 重みの更新
        optimizer.step()

        # lossの累積を加算
        train_loss_sum += loss.item()
      
    net.eval() # 評価モード
    correct = 0.0
    with torch.no_grad():
      for x, y in testloader:
        # データ整形 & GPU処理用に変換
        x = x.view(-1, 28*28)
        x = x.to(device)
        y = y.to(device)
        
        # 順伝播
        outputs = net(x)
        loss = criterion(outputs, y)

        # lossの累積を加算
        val_loss_sum += loss.item()

        # ラベルと一致していたら正答としてカウント
        pred = outputs.argmax(dim=1, keepdim=True)
        correct += pred.eq(y.view_as(pred)).sum().item()

    # 色々表示
    train_loss = train_loss_sum / len(trainloader)
    val_loss = val_loss_sum / len(testloader)
    accuracy = correct / len(testset)
    print('Epoch:{}, train_loss: {:.3f}, val_loss: {:.3f}, val_accuracy: {}'
          .format(epoch + 1, train_loss, val_loss, accuracy))
    
    # 記録
    train_loss_history.append(train_loss)
    val_loss_history.append(val_loss)
    acc_history.append(accuracy)

x = x.to(device)は,先程と同じGPU処理用の命令です.
モデルではnet.to(device)とすることで,その内部に含まれるパラメータすべてに適用しました.
データローダから新たに取り出してきたTensor達は未だCPU用なので,同様に適用させる必要があります.

net.eval()を実行することで,推論時にDropout層やBatchNormalization層をオフにできます.
(今回の例では上記レイヤは使っていないので,なくても問題ありません)

with torch.no_grad():をつけると,勾配計算が省略されるため,メモリ節約になるそうです.

ラベルと一致していたら…の部分の挙動の詳細

outputsの形状は(32, 10)です.(32: バッチサイズ, 10: クラス数)
まず,argmax(指定した軸(ここでは1)に沿って,最大値のindexを返す)を適用します.
ここで,keepdim=Trueをつけることで,2階のテンソルという情報が維持され,predの形状は(32, 1)となります.

続いて,pred.eq(y.view_as(pred)).sum().item()についてです.
a.view_as(b)は,aの形状をbの形状に合わせて変換します.
(view()という似た名前の形状変換用のメソッドがありますが,その仲間だと思います)

a = torch.arange(0, 12) # shape=(12, )
b = torch.Tensor(12).reshape(3, 4) # shape=(3, 4)

a.shape # 変換前
# torch.Size([12])

a = a.view_as(b) # bの形状に合わせてaを変換

a.shape # 変換後
# torch.Size([3, 4])

勿論,aとbの要素数は一致している必要があります.

eqは,2つのTensorを要素ごとに比較します.
要素ごとに,一致していればTrue, 違えばFalseが格納されたTensorを返します.

a = torch.tensor([1,2,3,4,5])
b = torch.tensor([0,2,0,4,5])
a.eq(b)

# tensor([False,  True, False,  True,  True])

勿論,比較対象同士の形状が同じでなければエラーが出るので注意しましょう.

a = torch.tensor([0,1,2,3,4,5,6,7,8,9])
b = torch.tensor([0,1,2])
a.eq(b)

# The size of tensor a (10) must match the size of tensor b (3) at non-singleton dimension 0

つまり,pred.eq(y.view_as(pred))は,『yをpredの形状に合わせ,yとpredの各要素を比較し,TrueかFalseが計32(=batch_size)個入ったTensorを求める』という処理です.

そして,PythonではTrueは1,Falseは0として扱われるため,sum()を適用することでTrueの数の累積を取ります.
また,sum()を適用したままではTensor型なので,実際の値を取得するためにitem()を適用します.

こうして,32個中いくつ正解できたか,という正答数が得られるので,これをすべて合算して全テストデータ数で割ることで正答率を算出しています.

学習結果

出力
Epoch:1, train_loss: 1.615, val_loss: 1.549, val_accuracy: 0.9172
Epoch:2, train_loss: 1.528, val_loss: 1.529, val_accuracy: 0.9356
Epoch:3, train_loss: 1.513, val_loss: 1.509, val_accuracy: 0.9561
Epoch:4, train_loss: 1.503, val_loss: 1.501, val_accuracy: 0.9632
Epoch:5, train_loss: 1.499, val_loss: 1.498, val_accuracy: 0.9658
Epoch:6, train_loss: 1.495, val_loss: 1.496, val_accuracy: 0.9686
Epoch:7, train_loss: 1.492, val_loss: 1.494, val_accuracy: 0.97
Epoch:8, train_loss: 1.490, val_loss: 1.493, val_accuracy: 0.971
Epoch:9, train_loss: 1.488, val_loss: 1.491, val_accuracy: 0.9732
Epoch:10, train_loss: 1.486, val_loss: 1.494, val_accuracy: 0.9692
lossグラフ

f:id:spookyboogie:20200926002426p:plain

精度グラフ

f:id:spookyboogie:20200926002501p:plain