生成AIの仕組みを手を動かして掴んでみよう

## はじめに

2023年は、ChatGPTをはじめとする生成AIが大ブームでした。

たとえばChatGPTに「生成AIの仕組みを中学生でもわかるように教えてください」と質問すると以下のような返答が得られました。

>AI（人工知能）の仕組みを中学生にもわかるように説明すると、以下のようになります。
>
>1. **学習のプロセス**：AIは、大量のデータ（情報）を使って学習します。これは、私たちが学校で勉強しているのと似ています。たくさんの本や問題を解くことで、私たちは新しい知識を身につけます。AIもたくさんの例や情報を通して、「学習」します。
>2. **パターン認識**：AIはパターンや関連性を見つけるのが得意です。例えば、たくさんの犬の写真を見せたら、どの写真に犬がいるかを識別できるようになります。これは、私たちが数学の問題を解くときにパターンを見つけるのと似ています。
>3. **予測と意思決定**：学習した情報を基に、AIは予測や意思決定をします。例えば、天気のデータを基に明日の天気を予測したり、チェスのゲームで次の一手を決めたりします。
>4. **ニューラルネットワーク**：AIの中には、「ニューラルネットワーク」という仕組みを使うものがあります。これは、人間の脳の神経細胞（ニューロン）がつながっているのと似た方法で、たくさんの小さな処理ユニットが連携して働きます。
>5. **改善と進化**：AIは間違いから学んで、より良くなることができます。たとえば、間違った答えを出したとき、それを正しい情報で修正し、同じ間違いを繰り返さないように学習します。
>
>AIの技術は、これらの基本的なプロセスを高度に発展させたものです。日々新しい技術やアプローチが開発されており、人間のように考えたり学習したりする能力が進化しています。

あるいは、「生成AIからイメージされる絵画を印象派の画家のテイストで描いてください」とお願いすると下記のようにそれっぽい画像を生成してくれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/b9939ccf-bdd8-8219-7f92-18c86babf3a8.png)

わからない事を教えてくれたり、ほしいものを作ってくれたりAIは私達がもともとイメージしていた存在に近づいた気がします。この一年だけでもAIの民主化が急速に進んだので、このままの勢いで社会に溶け込んでいく事は間違いないでしょう。一方で、その勢いの早さゆえに、色んな方面から飛び交ってくるAIというワードに、わけもわからず振り回されてしまう事にもなりそうです。

かつてクラウドという概念をきいた時、物理的なサーバがこの世から消えてしまったんだと本気で思ってしまった人がいたよう（今もいるかもしれませんが…）に、AIも魔法のように思われてしまっているかもしれません。

そこで今回の記事では魔法のように思っちゃいそうなAIというものを、簡単な画像生成AIモデルを作る事でエンジニア感覚で掴んでもらおうと思います。

必要に応じて理論的な解説も少ししていますが、理論をがっつり理解してもらおうという趣旨は今回の記事にはありません。単純なプログラムをとりあえず動かしてみて、AIの仕組みに興味を持つキッカケ作りになれば良いなと思っています。

意味はわからなくてもとりあえず動かしてみて、興味を持ったら少しずつ調べていけばいいんじゃないの？という、小学校の理科の実験的なスタンスです。

興味を持ってしまった方を想定して、次のアクションとして良さそうな資料も最後にのせておきます。

:::note info
という感じでゆるふわな感じの内容にはなってますが、看過できない表現や間違いがあったらご指摘いただけると嬉しいです🙏
:::

## 生成AIの仕組み（ざっくりと）

さきほどChatGPTが中学生でもわかるように生成AIの仕組みを説明してくれましたが、ざっくりいってしまうと、下記の流れです。

1. たくさんの入力データ（画像、テキスト、音声など）から、データに含まれるパターンや特徴を学習する
2. 学んだパターンや特徴をもとに、新たなデータを生成する

これは、私達人間が抽象化によって物事を学んでいくイメージに近いです。たとえば、私達は今まで見たことない犬や猫をみても、「犬である」とか「猫である」とかの判定を正しくできます。たとえ一部の要素が隠れていても、汚れてしまっていても、正しく捉える事が可能です。また、ただ判定するだけでなく、犬っぽい絵や猫っぽい絵を描く事もできます。

前者は「識別」であり、後者は「生成」なのですが、それは恐らく、これまでの経験から犬っぽさや猫っぽさの特徴やパターンを学んでいるからこそ可能なはずです。よって、犬っぽさや猫っぽさの特徴やパターンの理解は、人間の活動の根幹を支えるものだと考えられます。そして、それは人間らしいAIを実現するにあたっても同様であろうと考えられます。

そういった「〇〇っぽさ」を抽出する為に、私達の人間の脳の中ではニューロンという神経細胞が情報を処理し、伝達しています。そして、AIのモデル構築においては、この脳のニューロンが行う情報処理を模倣した「ニューラルネットワーク」という数理モデルが使用されています。ニューラルネットワークでは、多くの小さな処理単位（ニューロン）が、情報を受け取り、処理し、次のニューロンに信号を送るという方法で動作します。ディープラーニングは、このようなニューラルネットワークの層が何層も重なっているネットワークを指します。

今回はEncoder-Decoderモデルのシンプルなニューラルネットワークをもとに、画像生成AIのモデルを作成します。

:::note info
ちなみに、抽出された「〇〇っぽさ」を潜在空間と呼んだりする事がありますが、これは抽出された「〇〇っぽさ」は決定的ではなく空間的な広がりを持っている事を意味します。だからこそ生成AIの創造性に人々は驚かされるようになりました。
:::

## 高解像度の画像を生成してみる

今回は、なるべく「Hello World的な単純な生成AIモデル」を作ります。複雑になると仕組みが掴みづらくなるからです。よって、低解像度の画像を高解像度にするAIを作成します。といっても、[CIFAR-10](https://www.cs.toronto.edu/~kriz/cifar.html)を8x8のサイズにリサイズしたデータを入力データとして、オリジナルの32x32の画像生成を試みる単純なモデルなので、生成AIというのはおこがましいレベルのものかもしれません。ただ、そのぶんエッセンスはつかめると思います。

### 必要なモジュールやライブラリのインポート

下記のモジュールをPythonのコードから呼び出せるようにライブラリをインストールしておき、ソースコード内にインポートします。Pythonのバージョンは3系以上であれば、特段問題なく動作すると思います。[Google Colaboratory](https://colab.research.google.com/?hl=ja)という便利な実行環境を使っていただくのが手軽ですので、初見の方はこれを機会に是非利用してみてください。

```python
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader, Dataset
import matplotlib.pyplot as plt
import random
import torch.nn.functional as F
from torchvision.datasets import CIFAR10
```

今回は[PyTorch](https://pytorch.org/docs/2.1/)という機械学習ライブラリを用いるのですが、PyTorchでは[テンソル](https://pytorch.org/docs/2.1/tensors.html)という数学の行列をモデル化したようなデータ構造を用いて効率的に機械学習に必要な計算を行います。やや専門的な話になりますが、このテンソルを用いて計算を行う事で、学習において重要な勾配の計算を効率的に行える事が大きなメリットだと考えています。

### データセットを作成する

入力データと教師データのセットを、訓練用とテスト用に作ります。それぞれ学習データとして8x8の低解像度画像、教師データとして32x32の高解像度の画像のペアを持ちます。

CIFAR-10は`torchvision.datasets`モジュールからクラスとして呼び出せるので、それを用いてデータをダウンロードします。`transforms.Compose`クラスを用いて、ダウンロードしたデータをPyTorchのテンソルに変換したり、リサイズしています。

通常は`DataLoaderのshuffle=True`を使うと、良い感じで訓練データとテストデータを振り分けてくれるのですが、今回は別々のDataLoaderから訓練データとテストデータを作成するアプローチをとった為、その方法が使えませんでした。よって、独自のサンプラーで共通のランダム化されたインデックスを作成して、データをシャッフルする工夫をしました。

他にもあらかじめ教師データと学習データのペアを作成しておく等の方法がありますが、目的としては学習時におけるデータをランダムにシャッフルする事です。データをランダムにする事で、モデルが特定の特徴を過度にとらえてしまう事を防いだり、汎用的な特徴をとらえやすい状況を作ってあげます。

```python
# 訓練データをシャッフルする為のサンプラー
from torch.utils.data import Sampler
class CustomRandomSampler(Sampler):
    def __init__(self, indexes):
        self.indexes = indexes

    def __iter__(self):
        return (self.indexes[i] for i in range(len(self.indexes)))

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

transform_lowres = transforms.Compose([
    transforms.Resize(8),  # 低解像度画像として8x8にリサイズ
    transforms.ToTensor()
])

transform_highres = transforms.ToTensor()

# オリジナル(32x32)のCIFAR10データセット (訓練データ)
train_dataset_original = CIFAR10(root='./data', train=True, transform=transform_highres, download=True)
# 低解像度(8x8)のCIFAR10データセット (訓練データ)
train_dataset_lowres = CIFAR10(root='./data', train=True, transform=transform_lowres, download=True)

# オリジナル(32x32)のCIFAR10データセット (テストデータ)
test_dataset_original = CIFAR10(root='./data', train=False, transform=transform_highres, download=True)
# 低解像度(8x8)のCIFAR10データセット (テストデータ)
test_dataset_lowres = CIFAR10(root='./data', train=False, transform=transform_lowres, download=True)

# DataLoaderのshuffle=Trueを使うと、高解像度と低解像度で別々の画像ペアが生成されてしまうので、共通のランダムインデックスでデータサンプリングを行うために指定
train_indexes = list(range(len(train_dataset_original)))
# インデックスをシャッフル
random.shuffle(train_indexes)
# インデックスのランダムな順序を使ってサンプリングを行うサンプラーを作成
train_sampler = CustomRandomSampler(train_indexes)

# 訓練用データセット
train_loader_original = DataLoader(train_dataset_original, batch_size=128, sampler=train_sampler)
train_loader_lowres = DataLoader(train_dataset_lowres, batch_size=128, sampler=train_sampler)

# テスト用データセット
test_loader_original = DataLoader(test_dataset_original, batch_size=128, shuffle=False)
test_loader_lowres = DataLoader(test_dataset_lowres, batch_size=128, shuffle=False)
```

### データセットを目視で確認する

作成したデータセットのペアが正しいか確認します。たとえば前処理のシャッフルの工程でミスをしてしまい、低解像度の画像と高解像度の画像のペアが間違っていたら無意味な事に時間をかけてしまう事になります。

一般的に機械学習の学習プロセスは時間がかかります。凡ミスによって悲劇が起こらないように、データの正しさをざっくりでもいいので目視で確認する時間を設けた方が良いと考えています。

```python
# 訓練用とテスト用のローダーからイテレータを作成
train_original_iterator = iter(train_loader_original)
train_lowres_iterator = iter(train_loader_lowres)
test_original_iterator = iter(test_loader_original)
test_lowres_iterator = iter(test_loader_lowres)

# それぞれのイテレータのnextメソッドを使って、1バッチ分のデータのみを取得。
train_original_images, _ = next(train_original_iterator)
train_lowres_images, _ = next(train_lowres_iterator)
test_original_images, _ = next(test_original_iterator)
test_lowres_images, _ = next(test_lowres_iterator)

# 訓練データを表示
fig, axes = plt.subplots(nrows=2, ncols=5, figsize=(15, 6), subplot_kw={'xticks':[], 'yticks':[]})
for ax, img in zip(axes[0], train_lowres_images):
    ax.imshow(torchvision.transforms.ToPILImage()(img))
    ax.title.set_text("Low Image - Train")
for ax, img in zip(axes[1], train_original_images):
    ax.imshow(torchvision.transforms.ToPILImage()(img))
    ax.title.set_text("Original Image - Train")
plt.tight_layout()
plt.show()

# テストデータを表示
fig, axes = plt.subplots(nrows=2, ncols=5, figsize=(15, 6), subplot_kw={'xticks':[], 'yticks':[]})
for ax, img in zip(axes[0], test_lowres_images):
    ax.imshow(torchvision.transforms.ToPILImage()(img))
    ax.title.set_text("Low Image - Test")
for ax, img in zip(axes[1], test_original_images):
    ax.imshow(torchvision.transforms.ToPILImage()(img))
    ax.title.set_text("Original Image - Test")
plt.tight_layout()
plt.show()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/66d2d985-9367-944f-5980-f6be21002f43.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/21cf7177-2166-d9a3-7c8e-30442e7fdd0d.png)


訓練データにしてもテストデータにしても、確かに低解像度と高解像度の画像のペアが揃っていそうな事がわかりました。

### 全結合層のEncoder-Decoderモデル

それではディープラーニングのEncoder-Decoderモデルを作っていきます。まずは全結合層を重ねたネットワークのクラスを作成します。

```python
class FCResUpgrader(nn.Module):
    def __init__(self):
        super(FCResUpgrader, self).__init__()

        # 3チャンネル、8x8の画像サイズを入力とする
        self._encoder = nn.Sequential(
            nn.Linear(8*8*3, 512),
            nn.ReLU(),
            nn.Linear(512, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, 64),
            nn.ReLU()
        )

        # 3チャンネル、32x32の画像サイズを出力とする
        self._decoder = nn.Sequential(
            nn.Linear(64, 128),
            nn.ReLU(),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.Linear(256, 512),
            nn.ReLU(),
            nn.Linear(512, 32*32*3),
            nn.Sigmoid()
        )

    def forward(self, x):
        x = self._encoder(x)
		#print("After encoder:", x.shape)
        x = self._decoder(x)
		#print("After decoder:", x.shape)
        return x

    def loss(self, x, target):
        y = self.forward(x)
        # 損失関数にMSEを使う
        return F.mse_loss(y, target)
```

`self._encoder`がエンコーダで、データの特徴すなわち〇〇っぽさを抽出する役割を持ちます。そして`self._decoder`がデコーダで、〇〇っぽさをもとにデータを生成する役割を持ちます。

リサイズされたCIFAR-10の画像は8x8で3チャンネル（RGB）あるので、`8*8*3=192`のピクセルデータをエンコーダの入力にいれています。エンコーダではすべての入力がフラットに受け入れられます。プログラマが理解しやすい表現でいうと、192個の要素がある一次元配列です。

192個の要素がある一次元配列がエンコーダに投入されると、一個一個のピクセル要素全てに重みをかけてバイアスを足して、512個の要素をもつ配列が新たに作成されます。同様の処理が繰り返されますがここからは256、128と段階的にサイズの小さい配列が作成されます。これはより重要な特徴だけを抽出しようという試みであり、最終的に64個の要素を持つ一次元配列として特徴が抽出されます。これがいわゆる「〇〇っぽさを」を表現した具体的なデータです。

64個の特徴を出力するまでの全ての層で、重みやバイアスをかけたり足したりしました。それらはまとめてパラメータと呼びます。このパラメータを、正解の教師データを生成できるように調整していくプロセスこそが、教師あり学習における学習です。このパラメータは最初はランダムですが、生成したデータと教師データの誤差を縮めるように、学習が進むたびに最適化されていきます。ちなみに、今回誤差の計算にはMSE（平均二乗誤差）を用いています。直感的には、正解とモデルが予測した出力を引いた数の二乗を誤差とみなしているという事です。

なお、ソースコード上は`nn.Linear`が全結合層を意味しています。全結合層では、y=wx+b（xがピクセルデータのような入力で、wが重みでbがバイアスです）のような線形の変換だけが行われます。これはいってしまえば入力データを拡大縮小したり、平行移動するだけの変換です。よって全結合層を何層も重ねようと、結局一層の全結合層で置き換える事が可能な表現力しか持ちません。そこで`nn.ReLU`のような活性化関数を作用させる事で、非線形性を導入します。これによって、ネットワークを深くするほど関数の表現力を向上させる事ができ、より複雑なパターンを学習する事が可能になります。

雑な表現になってしまうかもしれませんが、機械学習におけるモデルというのものは「現実の事象を近似できる数学的な関数をソフトウェアで表現可能にしたもの」だと捉えています。複雑な現実世界の事象を近似している関数なので、y=ax+bのような私達が直感的に予想できる単純なもののはずがありません。

たとえばアイスクリームの売上と気温の関係をグラフで表す事を考えてみても、単純な直線グラフでその関係性を表現しきるには無理がありそうですよね？だからこそ、活性化関数を用いて関数に非線形性を導入する事で表現力を増しています。

作成したネットワークのアーキテクチャをざっくり確認するには、print関数でインスタンス化した変数を出力してみるのが手軽です。

```python
test_model_v1 = FCResUpgrader()
# ネットワークのアーキテクチャを確認する
print(test_model_v1)
```

```py
FCResUpgrader(
  (_encoder): Sequential(
    (0): Linear(in_features=192, out_features=512, bias=True)
    (1): ReLU()
    (2): Linear(in_features=512, out_features=256, bias=True)
    (3): ReLU()
    (4): Linear(in_features=256, out_features=128, bias=True)
    (5): ReLU()
    (6): Linear(in_features=128, out_features=64, bias=True)
    (7): ReLU()
  )
  (_decoder): Sequential(
    (0): Linear(in_features=64, out_features=128, bias=True)
    (1): ReLU()
    (2): Linear(in_features=128, out_features=256, bias=True)
    (3): ReLU()
    (4): Linear(in_features=256, out_features=512, bias=True)
    (5): ReLU()
    (6): Linear(in_features=512, out_features=3072, bias=True)
    (7): Sigmoid()
  )
)
```

これに何の意味があるの？と思った方もいるかもしれません。今回は自分がコーディングした通りの結果になっているので、そこまで恩恵を感じないかもしれませんが、ネットワークが複雑になってきたり、他の人が作成したネットワークの構造の概要を把握するのには便利です。従来のコーディングのようなデバッグの観点は、機械学習のモデル構築の過程においても重要です。

#### 学習と検証

下記コードでは、作成したクラスを用いて全結合層のネットワークの学習と検証を行います。このあたりは今回は詳しくは触れませんが、機械学習における学習過程において最適化関数に何を用いるか？そして、そのハイパーパラメータ（今回でいうと`lr=0.001`）をどう設定するか？が学習の成功を左右するポイントになってきます。今回は最適化関数にAdamを用いていますが、それぞれの最適化関数の特徴とハイパーパラメータの持つ意味を理解しておくと良いでしょう。

もし、`lr=0.01`にすると学習の進みがとても遅くなる事が確認できると思います。そのあたりの面白さを体感してみてください。

他にも細かいところではCPUではなくGPUを使うようにしていたり、多次元の画像データをフラットなデータ構造に変換していたりします。後述する畳み込み演算では、CPUを使うかGPUを使うかでその計算スピードに顕著な差が出てきます。

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

model_v1 = FCResUpgrader().to(device)
optimizer = optim.Adam(model_v1.parameters(), lr=0.001)

n_epochs = 20

for epoch in range(n_epochs):

    # 訓練用ループ
    losses = []
    model_v1.train()
    for (lowres_images, _), (highres_images, _) in zip(train_loader_lowres, train_loader_original):
        lowres_images = lowres_images.view(lowres_images.size(0), -1).to(device)
        highres_images = highres_images.view(highres_images.size(0), -1).to(device)

        optimizer.zero_grad()
        loss = model_v1.loss(lowres_images, highres_images)
        loss.backward()
        optimizer.step()

        losses.append(loss.cpu().detach().numpy())

    # 検証用ループ
    val_losses = []
    model_v1.eval()
    for (lowres_images, _), (highres_images, _) in zip(test_loader_lowres, test_loader_original):
        lowres_images = lowres_images.view(lowres_images.size(0), -1).to(device)
        highres_images = highres_images.view(highres_images.size(0), -1).to(device)
        with torch.no_grad():
            val_loss = model_v1.loss(lowres_images, highres_images)
        val_losses.append(val_loss.cpu().detach().numpy())

    print(f"EPOCH:{epoch+1}, Train Loss: {np.mean(losses)}, Validation Loss: {np.mean(val_losses)}")
```

これを実行すると、下記のような結果が出ました。

```plaintext
EPOCH:1, Train Loss: 0.03759712353348732, Validation Loss: 0.03290833160281181
EPOCH:2, Train Loss: 0.030396172776818275, Validation Loss: 0.028874412178993225
EPOCH:3, Train Loss: 0.028294794261455536, Validation Loss: 0.026783447712659836
EPOCH:4, Train Loss: 0.025265995413064957, Validation Loss: 0.0247060377150774
EPOCH:5, Train Loss: 0.02351290173828602, Validation Loss: 0.023650242015719414
EPOCH:6, Train Loss: 0.02300468273460865, Validation Loss: 0.022232815623283386
EPOCH:7, Train Loss: 0.02197309583425522, Validation Loss: 0.02197849377989769
EPOCH:8, Train Loss: 0.021276917308568954, Validation Loss: 0.021412823349237442
EPOCH:9, Train Loss: 0.020959356799721718, Validation Loss: 0.021174855530261993
EPOCH:10, Train Loss: 0.02046099677681923, Validation Loss: 0.020360281690955162
EPOCH:11, Train Loss: 0.01973317377269268, Validation Loss: 0.01966647431254387
EPOCH:12, Train Loss: 0.019306175410747528, Validation Loss: 0.019108468666672707
EPOCH:13, Train Loss: 0.018590401858091354, Validation Loss: 0.01840880885720253
EPOCH:14, Train Loss: 0.018123380839824677, Validation Loss: 0.017980335280299187
EPOCH:15, Train Loss: 0.017476804554462433, Validation Loss: 0.017164655029773712
EPOCH:16, Train Loss: 0.01673976704478264, Validation Loss: 0.016591714695096016
EPOCH:17, Train Loss: 0.016374459490180016, Validation Loss: 0.01645936630666256
EPOCH:18, Train Loss: 0.01610993593931198, Validation Loss: 0.016172558069229126
EPOCH:19, Train Loss: 0.01595381274819374, Validation Loss: 0.01606779545545578
EPOCH:20, Train Loss: 0.01586763747036457, Validation Loss: 0.01601492427289486
```

Lossというのは、正解のデータとモデルの出力の誤差を意味しています。つまり、学習を繰り返すたびこの差が小さくなっていけばモデルの予測性能が向上している事を意味します。

ここで、Train LossとValidation Lossの2つを出しているのには理由があります。Train Lossはいわば算数ドリルの練習問題での正解率であり、Validation Lossは本番の試験での正解率です。この2つの指標を検証する事で、ドリルの練習問題は解けるけど本番の試験では全然点数とれないという状況を防ぎます。

ドリルの練習問題だけが解ける人は、繰り返すことによってパターンを覚えてしまっていたりして、算数の本質と異なる特徴を学習してしまっているという事ですが、機械学習のモデルの学習においてもそれと似たような事が起こってしまいます。

そのような状態は過学習と呼ばれていますが、それが起こらないように学習プロセスを最適化していく事が機械学習において重要です。

#### 生成された画像を確認する

モデルの予測性能が向上している事と、それに伴って過学習が起こっていない事が数値上はわかりました。しかしながら、実際どうなのか？は生成してみないとわかりません。学習したモデルから実際に高解像度画像を生成してみましょう。

下記のコードで学習したモデルを用いて、元の低解像度の画像（Low Image）、オリジナルの高解像度の画像（Original High Image）、モデルが生成した高解像度の画像（Upsampled Image）を表示しています。

```python
# テストデータのローダーからイテレータを作成
test_lowres_iterator = iter(test_loader_lowres)
test_original_iterator = iter(test_loader_original)

# イテレータを使用して画像を取得
lowres_images, _ = next(test_lowres_iterator)
original_images, _ = next(test_original_iterator)

# モデルを評価モードにする
model_v1.eval()

# 低解像度の画像をアップサンプリング
lowres_images_flatten = lowres_images.view(lowres_images.size(0), -1).to(device)
upsampled_images_output = model_v1(lowres_images_flatten)
upsampled_images = upsampled_images_output.cpu().view(-1, 3, 32, 32)

# 画像を5枚表示
num_sets_to_show = 5
fig, axes = plt.subplots(nrows=3, ncols=num_sets_to_show, figsize=(15, 9))

for i in range(num_sets_to_show):
    # 低解像度の画像を表示
    img_lowres = torchvision.transforms.ToPILImage()(lowres_images[i])
    axes[0, i].imshow(img_lowres)
    axes[0, i].set_title("Low Image")
    axes[0, i].axis('off')

    # オリジナルの高解像度の画像を表示
    img_original = torchvision.transforms.ToPILImage()(original_images[i])
    axes[1, i].imshow(img_original)
    axes[1, i].set_title("Original High Image")
    axes[1, i].axis('off')

    # アップサンプルされた画像を表示
    img_upsampled = torchvision.transforms.ToPILImage()(upsampled_images[i])
    axes[2, i].imshow(img_upsampled)
    axes[2, i].set_title("Upsampled Image")
    axes[2, i].axis('off')

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/74325d63-2a6a-0cea-ca30-b21c2a0a3114.png)

モデルが生成した画像をみると、せいぜい色味だけが再現されており、全体的にぼやっとした印象になってしまっています。

全体的にぼやっとした特徴になってしまっているのは、局所的な情報が抽出できていないからです。これは、全結合層が画像データの空間的な情報を考慮せずに、ピクセル1つ1つを一次元配列のフラットなデータ構造として学習する為と考えられます。

画像データはある一部分にフォーカスを当てた時、その空間に特徴がギュッと集まっているような構造になっている事が多いです。もし、その局所的な特徴が拾えないとしたら確かにぼやっとなっちゃう気はします。

よって、画像の特徴を抽出する為に全結合層のみでニューラルネットワークを構築する事は、あまり適切ではないと考えられます。

### 畳み込み層のEncoder-Decoderモデル

全結合層のモデルでは、画像の空間的な特徴をとらえきれず全体的にぼやっとした画像が生成されてしまう事が問題でした。そこで画像の局所的な特徴を抽出する為に、畳み込み層を用いたEncoder-Decoderモデルを作成します。

畳み込み層では特徴の抽出の為に、フィルターと呼ばれるパラメータを学習させます。

全結合層ではフラットな一次元配列として学習されていたのに対して、畳み込み層では、画像の左上の部分、真ん中上の部分、右上の部分、左真ん中の部分…というように、局所的な領域ごとに配列が複数作成されて、それぞれで特徴が抽出されるイメージです。よって、局所ごとの特徴抽出が期待できます。

```python
class ConvResUpgrader(nn.Module):
    def __init__(self):
        super(ConvResUpgrader, self).__init__()

        # Encoder部分
        # (W+P*2-Fw)/S+1
        self.encoder = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),  # 出力サイズ: 64x8x8
            nn.ReLU(),
            nn.MaxPool2d(2, stride=2),  # 出力サイズ: 64x4x4
            nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),  # 出力サイズ: 128x4x4
            nn.ReLU(),
            nn.MaxPool2d(2, stride=2)  # 出力サイズ: 128x2x2
        )

        # Decoder部分
        # 逆畳み込みの出力サイズの計算式 (W-1)*S-P*2+Fw
        self.decoder = nn.Sequential(
            nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2),  # 出力サイズ: 64x4x4
            nn.ReLU(),
            nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2),  # 出力サイズ: 32x8x8
            nn.ReLU(),
            nn.ConvTranspose2d(32, 3, kernel_size=3, stride=2, padding=1, output_padding=1),  # 出力サイズ: 3x16x16
            nn.ReLU(),
            nn.ConvTranspose2d(3, 3, kernel_size=3, stride=2, padding=1, output_padding=1),  # 出力サイズ: 3x32x32
            nn.Sigmoid()
        )

    def forward(self, x):
        x = self.encoder(x)
        #print("After encoder:", x.shape)
        x = self.decoder(x)
        #print("After decoder:", x.shape)
        return x

    def loss(self, x, target):
        y = self.forward(x)
        return F.mse_loss(y, target)
```

#### 学習と検証

全結合層の例とほとんど変わらないので、説明は割愛します。

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

model_v2 = ConvResUpgrader().to(device)
optimizer = optim.Adam(model_v2.parameters(), lr=0.001)

n_epochs = 20

for epoch in range(n_epochs):

    # 訓練用ループ
    losses = []
    model_v2.train()
    for (lowres_images, _), (highres_images, _) in zip(train_loader_lowres, train_loader_original):
        lowres_images = lowres_images.to(device)
        highres_images = highres_images.to(device)

        optimizer.zero_grad()
        loss = model_v2.loss(lowres_images, highres_images)
        loss.backward()
        optimizer.step()

        losses.append(loss.cpu().detach().numpy())

    # 検証用ループ
    val_losses = []
    model_v2.eval()
    for (lowres_images, _), (highres_images, _) in zip(test_loader_lowres, test_loader_original):
        lowres_images = lowres_images.to(device)
        highres_images = highres_images.to(device)
        with torch.no_grad():
            val_loss = model_v2.loss(lowres_images, highres_images)
        val_losses.append(val_loss.cpu().detach().numpy())

    print(f"EPOCH:{epoch+1}, Train Loss: {np.mean(losses)}, Validation Loss: {np.mean(val_losses)}")
```

これを実行すると、下記のような結果が出ました。順調に学習が進み、過学習も起こっていなさそうです。数値上、全結合層のモデルよりも誤差が小さくなっている事がわかります。

```plaintext
EPOCH:1, Train Loss: 0.03244047239422798, Validation Loss: 0.02147449180483818
EPOCH:2, Train Loss: 0.019813207909464836, Validation Loss: 0.018775014206767082
EPOCH:3, Train Loss: 0.018006855621933937, Validation Loss: 0.017443042248487473
EPOCH:4, Train Loss: 0.01694709062576294, Validation Loss: 0.01666424795985222
EPOCH:5, Train Loss: 0.016280807554721832, Validation Loss: 0.015967288985848427
EPOCH:6, Train Loss: 0.014644245617091656, Validation Loss: 0.013368339277803898
EPOCH:7, Train Loss: 0.013010459020733833, Validation Loss: 0.012686921283602715
EPOCH:8, Train Loss: 0.01246076449751854, Validation Loss: 0.012242954224348068
EPOCH:9, Train Loss: 0.012132235802710056, Validation Loss: 0.011992082931101322
EPOCH:10, Train Loss: 0.011868832632899284, Validation Loss: 0.011737932451069355
EPOCH:11, Train Loss: 0.01164541020989418, Validation Loss: 0.011515650898218155
EPOCH:12, Train Loss: 0.011455184780061245, Validation Loss: 0.011329974979162216
EPOCH:13, Train Loss: 0.011298870667815208, Validation Loss: 0.011190352961421013
EPOCH:14, Train Loss: 0.011171096004545689, Validation Loss: 0.011093166656792164
EPOCH:15, Train Loss: 0.011063354089856148, Validation Loss: 0.011036714538931847
EPOCH:16, Train Loss: 0.01096667442470789, Validation Loss: 0.010948289185762405
EPOCH:17, Train Loss: 0.010878926143050194, Validation Loss: 0.010858788155019283
EPOCH:18, Train Loss: 0.010799095965921879, Validation Loss: 0.010823029093444347
EPOCH:19, Train Loss: 0.010723302140831947, Validation Loss: 0.010733702220022678
EPOCH:20, Train Loss: 0.010653768666088581, Validation Loss: 0.010653169825673103
```

#### 生成された画像を確認する

下記のコードで学習したモデルを用いて、元の低解像度の画像（Low Image）、オリジナルの高解像度の画像（Original High Image）、モデルが生成した高解像度の画像（Upsampled Image）を表示しています。

```python
# テストデータのローダーからイテレータを作成
test_lowres_iterator = iter(test_loader_lowres)
test_original_iterator = iter(test_loader_original)

# イテレータを使用して画像を取得
lowres_images, _ = next(test_lowres_iterator)
original_images, _ = next(test_original_iterator)

# モデルを評価モードにする
model_v2.eval()

# 低解像度の画像をアップサンプリング
lowres_images = lowres_images.to(device)
upsampled_images_output = model_v2(lowres_images)
upsampled_images = upsampled_images_output.cpu().view(-1, 3, 32, 32)

# 画像を5枚表示
num_sets_to_show = 5
fig, axes = plt.subplots(nrows=3, ncols=num_sets_to_show, figsize=(15, 9))

for i in range(num_sets_to_show):
    # 低解像度の画像を表示
    img_lowres = torchvision.transforms.ToPILImage()(lowres_images[i])
    axes[0, i].imshow(img_lowres)
    axes[0, i].set_title("Low Image")
    axes[0, i].axis('off')

    # オリジナルの高解像度の画像を表示
    img_original = torchvision.transforms.ToPILImage()(original_images[i])
    axes[1, i].imshow(img_original)
    axes[1, i].set_title("Original High Image")
    axes[1, i].axis('off')

    # アップサンプルされた画像を表示
    img_upsampled = torchvision.transforms.ToPILImage()(upsampled_images[i])
    axes[2, i].imshow(img_upsampled)
    axes[2, i].set_title("Upsampled Image")
    axes[2, i].axis('off')

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/0aa196d6-de1e-a8e4-13cc-8fc8a7ab5c0b.png)

全結合層のモデルと比較して、局所的な特徴が捉えられていると思います。やや境界がはっきりし過ぎている感じはあるものの、だからこそ全結合層で生成したぼんやり画像よりは何が写っているのかがわかりやすくなっています。特に二枚目の船に関しては、判別がつくレベルで特徴が捉えられているのではないでしょうか？

このように学習データ自体はそのままで、アーキテクチャを変更しただけでも予測性能を向上させる事ができました。機械学習においてデータが最も重要だという事は疑う余地はありませんが、アーキテクチャをどのように設計するか？も重要という事がこの例では示されています。

それでは、最後に世の中で名の知られているアーキテクチャを利用して、予測性能の向上を試みてみましょう。

### ResNetのEncoder-Decoderモデル

冒頭でディープラーニングでは、何層も層が重なっているネットワークと說明しました。何層も層を重ねるのは、その方がモデルの予測性能が向上すると考えられているからです。ただ、何も考えず何層も重ねてしまうと、予測性能が逆に下がってしまう問題が知られています。

私がこれまで提供してきたモデルは、何も考えず何層も層を重ねたものです。よって、このまま層を増やしたところで性能が下がると考えられますので、別のアプローチが必要です。

[ResNet](https://arxiv.org/abs/1512.03385)は層を深くする事で予測性能の向上に成功した、代表的なアーキテクチャです。今回は画像を対象にしていますが、その基本的なアイディアは大規模言語モデルのベースとなっている[Transformer](https://proceedings.neurips.cc/paper_files/paper/2017/hash/3f5ee243547dee91fbd053c1c4a845aa-Abstract.html)という系列変換モデルでも活用されています。ResNetの核となるのは「残差接続」という概念で、これは各層の入力と出力の差（残差）を学習することに基づいています。具体的には、残差ブロックと呼ばれる入力をショートカットできる構造を用いて、ネットワーク内に組み込まれています。

ResNetのような、「これで組んでおけば良い性能がでるよ！」と世に知られている素晴らしいアーキテクチャは世の中に一般的に公開されています。これはプログラマの世界におけるライブラリのようなもので、機械学習のライブラリからも利用できる事が多いです。今回はPyTorchのプロジェクトから提供されている[torchvision](https://pytorch.org/vision/stable/)からそれを用います。

torchvisionからは下記のように利用できます。resnet18の18という数字は、ResNetのネットワーク層の数を示しています。何種類かバリエーションがあり、最大でresnet152という152層のものまで利用できます。今回は小規模なモデルを作成するので、最小のresnet18を利用しています。

https://pytorch.org/vision/main/models/resnet.html

```python
test_resnet = torchvision.models.resnet18(weights=None)
# ネットワークのアーキテクチャを確認する
print(test_resnet)
```

print関数の結果は下記の通りです。

```python
ResNet(
  (conv1): Conv2d(3, 64, kernel_size=(7, 7), stride=(2, 2), padding=(3, 3), bias=False)
  (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
  (relu): ReLU(inplace=True)
  (maxpool): MaxPool2d(kernel_size=3, stride=2, padding=1, dilation=1, ceil_mode=False)
  (layer1): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
    (1): BasicBlock(
      (conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer2): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer3): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (layer4): Sequential(
    (0): BasicBlock(
      (conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (downsample): Sequential(
        (0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
        (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      )
    )
    (1): BasicBlock(
      (conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (relu): ReLU(inplace=True)
      (conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
    )
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
  (fc): Linear(in_features=512, out_features=1000, bias=True)
)
```

出力結果をみてみると色々出力されていますが、Conv2d（畳み込み層）とLinear（全結合層）の数を数えると18層である事がわかります。

ちなみに、ところどころあらわれている`downsample`の箇所は残差接続を実現する為の次元調整に対して重要な役割を果たしています。たとえばlayer2の最初の入力は64チャネルですが、ブロック内で処理された出力は128チャネルになっています。そこで`downsample`の箇所で128チャネルに調整する事で、残差接続の加算操作を可能にしてます。

#### ResNetをカスタマイズする

ResNetを利用しようと思ってもそのままでは使えないので、色々と調整します

まず、オリジナルのResNetが入力として受け入れる画像サイズは224x224なので、今回の画像サイズ8x8に対応するように最初の畳み込み層を変更します。

そして、ネットワークの最後の全結合層およびその前のプーリング層を削除します。ResNetは画像生成タスクではなく、画像分類タスクの為のアーキテクチャなのですが、最後の2層の処理は画像分類の為に必要な層です。画像自体の特徴である〇〇っぽさの抽出は最後の2層の前で完了してる為、削除しています。

```python
class ResNetResUpgrader(nn.Module):
    def __init__(self):
        super(ResNetResUpgrader, self).__init__()
        modified_resnet = torchvision.models.resnet18(weights=None)
        # ResNet18の最初のconv層を変更して8x8の入力に対応させる。
        modified_resnet.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1)
        # エンコーダではResNetの特徴マップだけあればいいので、最後の全結合層とその前のGAPを削除する
        self.encoder = nn.Sequential(*list(modified_resnet.children())[:-2])

        # ここからデコーダ部分
        self.decoder = nn.Sequential(
          nn.ConvTranspose2d(512, 256, kernel_size=4, stride=2, padding=1),  # 出力サイズ: [256, 2, 2]
          nn.BatchNorm2d(256),
          nn.ReLU(),
          nn.ConvTranspose2d(256, 128, kernel_size=4, stride=2, padding=1),  # 出力サイズ: [128, 4, 4]
          nn.BatchNorm2d(128),
          nn.ReLU(),
          nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1),  # 出力サイズ: [64, 8, 8]
          nn.BatchNorm2d(64),
          nn.ReLU(),
          nn.ConvTranspose2d(64, 32, kernel_size=4, stride=2, padding=1),  # 出力サイズ: [32, 16, 16]
          nn.BatchNorm2d(32),
          nn.ReLU(),
          nn.ConvTranspose2d(32, 3, kernel_size=4, stride=2, padding=1),  # 出力サイズ: [3, 32, 32]
          nn.Sigmoid()
      )

    def forward(self, x):
        x_encoded = self.encoder(x)
        #print(f"Encoder output shape: {x_encoded.shape}")
        x_decoded = self.decoder(x_encoded)
        #print(f"Decoder output shape: {x_decoded.shape}")
        return x_decoded

    def loss(self, x, target):
        y = self.forward(x)
        return F.mse_loss(y, target)
```

#### 学習と検証

これまでと同様なので、説明は割愛します。

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

model_v3 = ResNetResUpgrader().to(device)
optimizer = optim.Adam(model_v3.parameters(), lr=0.001)

n_epochs = 20

for epoch in range(n_epochs):

    # 訓練用ループ
    losses = []
    model_v3.train()
    for (lowres_images, _), (highres_images, _) in zip(train_loader_lowres, train_loader_original):
        lowres_images = lowres_images.to(device)
        highres_images = highres_images.to(device)

        optimizer.zero_grad()
        loss = model_v3.loss(lowres_images, highres_images)
        loss.backward()
        optimizer.step()

        losses.append(loss.cpu().detach().numpy())

    # 検証用ループ
    val_losses = []
    model_v3.eval()
    for (lowres_images, _), (highres_images, _) in zip(test_loader_lowres, test_loader_original):
        lowres_images = lowres_images.to(device)
        highres_images = highres_images.to(device)
        with torch.no_grad():
            val_loss = model_v3.loss(lowres_images, highres_images)
        val_losses.append(val_loss.cpu().detach().numpy())

    print(f"EPOCH:{epoch+1}, Train Loss: {np.mean(losses)}, Validation Loss: {np.mean(val_losses)}")
```

これを実行すると、下記のような結果が出ました。順調に学習が進み、過学習も起こっていなさそうです。数値上は、畳み込み層のモデルと概ね同じですが、どんな画像が生成されるか気になるところです。

```plaintext
EPOCH:1, Train Loss: 0.03052784688770771, Validation Loss: 0.023873642086982727
EPOCH:2, Train Loss: 0.021382851526141167, Validation Loss: 0.020209364593029022
EPOCH:3, Train Loss: 0.018886161968111992, Validation Loss: 0.018641533330082893
EPOCH:4, Train Loss: 0.017242101952433586, Validation Loss: 0.0173017717897892
EPOCH:5, Train Loss: 0.016465099528431892, Validation Loss: 0.016620302572846413
EPOCH:6, Train Loss: 0.015530356205999851, Validation Loss: 0.015779713168740273
EPOCH:7, Train Loss: 0.014561211690306664, Validation Loss: 0.014951939694583416
EPOCH:8, Train Loss: 0.01399770937860012, Validation Loss: 0.014763329178094864
EPOCH:9, Train Loss: 0.01350134052336216, Validation Loss: 0.01435036025941372
EPOCH:10, Train Loss: 0.013081569224596024, Validation Loss: 0.01425033900886774
EPOCH:11, Train Loss: 0.012656770646572113, Validation Loss: 0.013981749303638935
EPOCH:12, Train Loss: 0.012333815917372704, Validation Loss: 0.013756594620645046
EPOCH:13, Train Loss: 0.012081785127520561, Validation Loss: 0.01348081137984991
EPOCH:14, Train Loss: 0.011860528029501438, Validation Loss: 0.013398748822510242
EPOCH:15, Train Loss: 0.011639641597867012, Validation Loss: 0.013543644919991493
EPOCH:16, Train Loss: 0.011446303687989712, Validation Loss: 0.013022080063819885
EPOCH:17, Train Loss: 0.011291079223155975, Validation Loss: 0.012551363557577133
EPOCH:18, Train Loss: 0.01116226240992546, Validation Loss: 0.012092187069356441
EPOCH:19, Train Loss: 0.01104024238884449, Validation Loss: 0.011966289021074772
EPOCH:20, Train Loss: 0.010915989987552166, Validation Loss: 0.011877089738845825
```

#### 生成された画像を確認する

```python
# テストデータのローダーからイテレータを作成
test_lowres_iterator = iter(test_loader_lowres)
test_original_iterator = iter(test_loader_original)

# イテレータを使用して画像を取得
lowres_images, _ = next(test_lowres_iterator)
original_images, _ = next(test_original_iterator)

# モデルを評価モードにする
model_v3.eval()

# 低解像度の画像をアップサンプリング
lowres_images = lowres_images.to(device)
upsampled_images_output = model_v3(lowres_images)
upsampled_images = upsampled_images_output.cpu().view(-1, 3, 32, 32)

# 画像を指定された構成で表示
num_sets_to_show = 5
fig, axes = plt.subplots(nrows=3, ncols=num_sets_to_show, figsize=(15, 9))

for i in range(num_sets_to_show):
    # 低解像度の画像を表示
    img_lowres = torchvision.transforms.ToPILImage()(lowres_images[i])
    axes[0, i].imshow(img_lowres)
    axes[0, i].set_title("Low Image")
    axes[0, i].axis('off')

    # オリジナルの高解像度の画像を表示
    img_original = torchvision.transforms.ToPILImage()(original_images[i])
    axes[1, i].imshow(img_original)
    axes[1, i].set_title("Original High Image")
    axes[1, i].axis('off')

    # アップサンプルされた画像を表示
    img_upsampled = torchvision.transforms.ToPILImage()(upsampled_images[i])
    axes[2, i].imshow(img_upsampled)
    axes[2, i].set_title("Upsampled Image")
    axes[2, i].axis('off')

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90032/df0ff84c-a975-83e4-5216-31c3864df7bb.png)

境界がはっきりし過ぎていた畳み込み層のモデルと比較して、なめらかな画像になっています。いわば、全結合層と畳み込み層のモデルの良いとこ取りのような仕上がりです。まだオリジナルの解像度を再現しているとは言い難いですが、これまでのどのモデルより実用的な雰囲気がうかがえます。

## まとめ

今回、低解像度のモデルから高解像度のモデルを作成する過程を手を動かして体験する事によって、生成AIの仕組みをエンジニアの感覚で掴んでもらおうという試みを行いました。

もともと32x32の画像を8x8にリサイズする過程でかなりの情報が削除されてしまっているので、そのデータから元データをいきなり再現する試みは少し無理があったかもしれません。たとえば、最初に8x8から16x16の高解像度化を実現し、そこからさらに32x32の高解像度化を試みるなどの段階的な手法をとると、8x8から32x32への高解像度化の予測性能を上げられると思います。

上記に加えて、今回作成したモデルではハイパーパラメータのチューニング、正規化や正則化などの予測性能向上の為の手法を試す余地があります。

あるいは[VAE](https://arxiv.org/abs/1312.6114)や[GAN](https://proceedings.neurips.cc/paper/2014/hash/5ca3e9b122f61f8f06494c97b1afccf3-Abstract.html)などの、発展的なアーキテクチャ実装にチャレンジしてみるのも良いでしょう。

是非、色々と試していただいて、予測性能が向上したAIを私にも共有いただけたら嬉しいです。

## 次のアクションとなる資料

- [ゼロから作るDeep Learning](https://www.oreilly.co.jp/books/9784873117584/) (鉄板です。すべてはここからと言っても過言ではないです)
- [CVMLエキスパートガイド](https://cvml-expertguide.net/) (ゼロから作るDeep Learningの内容を落とし込めてない方には難しいと思いますが、深掘りに興味がある初心者を意識した構成になっているので、ここからロードマップをひいても良いと思います)