実践編Practice

深層学習の一気通貫(CNN)

小さな画像データセットを題材に、データの前処理から畳み込みニューラルネットワーク(CNN)の学習・評価までを1本の流れでたどります。前処理・深層学習・評価という単体で学んだ技術を、手描き数字の分類という具体的な課題でつなぐ総合演習です。すべて手元やColabでそのまま動かせるノートブックつきで進めます。

この教材は日本語のみで提供していますThis learning material is available in Japanese only.

課題設定The task

手描き数字を、CNNで見分ける

深層学習と聞くと、巨大なモデルと大量のGPU計算を思い浮かべるかもしれません。しかし本質は、単体の技術を1本の流れに組み立てることにあります。この演習では、8×8ピクセルの手描き数字画像という小さなデータを使い、前処理からCNNの学習・評価までを一気通貫でたどります。データが小さいので、特別な環境がなくてもノートPCやColabで数分あれば最後まで動きます。

やることは、次の一続きの流れです。深層学習の実務でくり返し現れる、基本の型です。

  • データ入手:scikit-learn 付属の手描き数字データ(digits)を読み込む。画像と正解ラベルの組。
  • 前処理:画素値を0から1に正規化し、CNNが受け取る形(チャネル付きの配列)に整え、学習用・検証用・テスト用に分ける。
  • モデル定義:畳み込み層・プーリング層・全結合層を PyTorch で組み、小さなCNNを作る。
  • 学習と評価:損失を下げるよう重みを更新し、テストデータで正解率・混同行列・分類レポートを確かめる。

最終的に得られるのは、手描き数字を9割前後の精度で当てる分類器と、「どの数字を取り違えやすいか」「なぜそうなるか」という読み解きです。小さなデータでも、深層学習の流れそのものは大きな課題とまったく同じです。

この演習のパイプライン

画像データ Digit images 前処理 Preprocess CNN Conv net 学習 Train 評価 Evaluate 正規化・形状変換・分割 畳み込み・プーリング 損失・最適化

使う技術Techniques used

組み合わせる3つのモジュール

この演習は、単体で学んだ次の3モジュールを1本につなぎます。まだ学んでいない技術があれば、先にそのモジュールで基礎を押さえてから戻ってくると、各ステップの意味がはっきりします。CNNの畳み込みの仕組みそのものは深層学習モジュールで詳しく扱っているので、ここでは流れを組み立てることに集中します。

深層学習

  • 畳み込み・プーリング・活性化関数・誤差逆伝播の考え方。CNNを PyTorch で組むStep 3・Step 4の土台。

開発データの前処理

  • 正規化・形状変換・学習用とテスト用への分割。モデルに入れる前の下ごしらえで、Step 2で使う。

機械学習

  • 正解率・混同行列・適合率・再現率・F値による評価と、過学習の考え方。Step 5・Step 6で使う。

ノートブックで実行Run the notebook

このページのコードを、そのまま動かす

Colab / JupyterColab / Jupyter

深層学習の一気通貫(CNN)ノートブック

このページで読むコードと同じ内容を、上から順に実行できるノートブックにまとめてあります。Colab で開けば、インストール込みで環境構築なしにそのまま動かせます。手元の Jupyter で動かしたい場合は .ipynb をダウンロードしてください。

最初のセルで !pip install torch scikit-learn matplotlib を実行します。digits データは scikit-learn に同梱されているため、データのダウンロードは不要で、完全にオフラインでも動きます。CPU だけで数分あれば最後まで実行できます。

1

データ入手:手描き数字を読み込む

Collect the image data

何を・なぜWhat & why

まず題材となる画像データを用意します。ここでは sklearn.datasets.load_digits を使います。これは 8×8ピクセル・グレースケールの手描き数字(0から9)を1797枚集めたデータセットで、各画素は0から16の整数、正解ラベル(どの数字か)も付いています。scikit-learn に同梱されているので、ネットワーク接続なしにすぐ読み込めます。小さいので、CNNの流れを試すのにちょうどよい素材です。

読み込んだら、まず形と中身を確認します。深層学習では「入力の形(shape)」を取り違えると動かないので、最初にここを押さえるのが定石です。

Pythonload_data.py
import numpy as np
from sklearn.datasets import load_digits

digits = load_digits()
X = digits.images   # (1797, 8, 8) 各画素は 0..16 の整数
y = digits.target   # (1797,)      正解ラベル 0..9

print("images:", X.shape, "labels:", y.shape, "classes:", np.unique(y))
print("pixel min/max:", X.min(), X.max())
出力例 / Output
images: (1797, 8, 8) labels: (1797,) classes: [0 1 2 3 4 5 6 7 8 9]
pixel min/max: 0.0 16.0

1枚が8×8の小さな画像で、全部で1797枚、10クラス(0から9)がほぼ均等に含まれています。試しに1枚を matplotlib で表示すると、粗いながらも数字の形が見て取れます。

Pythonpeek_image.py
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 5, figsize=(8, 2))
for i in range(5):
    ax[i].imshow(X[i], cmap="gray_r")
    ax[i].set_title(f"label: {y[i]}")
    ax[i].axis("off")
plt.tight_layout()
plt.show()

この Step のまとめ

load_digits で 1797 枚の 8×8 手描き数字を読み込みました。入力は (枚数, 高さ, 幅) の3次元配列、画素値は 0 から 16。次の Step で、これをCNNが受け取れる形に整えて分割します。

2

前処理:正規化・形状変換・分割

Preprocess and split

何を・なぜWhat & why

生のデータをそのままCNNに渡すことはできません。3つの下ごしらえをします。

  • 正規化:画素値を 0 から 16 の整数から 0 から 1 の実数に直します(16で割る)。値の範囲をそろえると学習が安定し、収束が速くなります。
  • 形状変換:CNNは (枚数, チャネル, 高さ, 幅) の4次元配列を受け取ります。今回は白黒なのでチャネルは1。(1797, 8, 8) を (1797, 1, 8, 8) にします。
  • 分割:データを学習用・検証用・テスト用の3つに分けます。学習用でモデルを訓練し、検証用で学習中の様子を見て、テスト用は最後の評価まで一切触れません。train_test_split の stratify で、各クラスの比率を分割後もそろえます。

「テスト用は最後まで見ない」のが鉄則です。テストで何度も調整すると、テストに合わせ込んでしまい、本当の汎化性能が分からなくなります。

Pythonpreprocess.py
import torch
from sklearn.model_selection import train_test_split

# 正規化(0..1)と形状変換(チャネル次元を追加)
X = (X / 16.0).astype("float32")
X = X[:, None, :, :]        # (N, 1, 8, 8)

# まずテストを切り分け、残りを学習用と検証用に分ける(stratifyで比率維持)
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)
X_tr, X_va, y_tr, y_va = train_test_split(
    X_tr, y_tr, test_size=0.2, random_state=42, stratify=y_tr)

# PyTorch のテンソルに変換
Xtr = torch.tensor(X_tr); ytr = torch.tensor(y_tr)
Xva = torch.tensor(X_va); yva = torch.tensor(y_va)
Xte = torch.tensor(X_te); yte = torch.tensor(y_te)

print("train:", X_tr.shape[0], "val:", X_va.shape[0], "test:", X_te.shape[0])
出力例 / Output
train: 1149 val: 288 test: 360

この Step のまとめ

画素を 0 から 1 に正規化し、(N, 1, 8, 8) の形に整え、学習1149・検証288・テスト360 に分けました。stratify で各数字の比率は3分割後も保たれます。下ごしらえが済んだので、いよいよモデルを組みます。

3

CNNを定義する

Define the CNN

何を・なぜWhat & why

いよいよ本体のCNNを組みます。畳み込みニューラルネットワークは、画像の局所的な模様(線や角)を畳み込み層でとらえ、プーリング層で少しずつ位置のずれに強くしながら情報をまとめ、最後に全結合層で分類します。小さな8×8画像なので、層は最小限で十分です。次の構成にします。

  • 畳み込み層1:Conv2d(1, 8, kernel_size=3, padding=1)。1チャネルの入力から8枚の特徴マップを作る。
  • プーリング:MaxPool2d(2) で 8×8 を 4×4 に縮小。
  • 畳み込み層2:Conv2d(8, 16, kernel_size=3, padding=1)。8チャネルから16チャネルへ。
  • プーリング:MaxPool2d(2) で 4×4 を 2×2 に縮小。ここで 16 × 2 × 2 = 64 個の数値になる。
  • 全結合層:Linear(64, 32) と Linear(32, 10)。最後は10クラス分の出力。

活性化関数には ReLU を使います。負の値を0にする単純な関数で、非線形性を与えつつ学習を安定させます。padding=1 は畳み込みで画像の端が縮まないようにする設定です。

Pythonmodel.py
import torch.nn as nn

torch.manual_seed(42)

class SmallCNN(nn.Module):
    def __init__(self, n_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, kernel_size=3, padding=1),   # 8x8 -> 8x8
            nn.ReLU(),
            nn.MaxPool2d(2),                              # 8x8 -> 4x4
            nn.Conv2d(8, 16, kernel_size=3, padding=1),  # 4x4 -> 4x4
            nn.ReLU(),
            nn.MaxPool2d(2),                              # 4x4 -> 2x2
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(16 * 2 * 2, 32),
            nn.ReLU(),
            nn.Linear(32, n_classes),
        )

    def forward(self, x):
        return self.classifier(self.features(x))

model = SmallCNN()
n_params = sum(p.numel() for p in model.parameters())
print("params:", n_params)
出力例 / Output
params: 3658

この Step のまとめ

畳み込み2層・プーリング2層・全結合2層の小さなCNNを組みました。学習するパラメータはわずか 3,658 個で、これでも digits なら十分に働きます。プーリングで 8×8 → 4×4 → 2×2 と縮み、最後の全結合層の入力が 16×2×2=64 になる点が形合わせの要です。

4

学習:損失を下げて重みを育てる

Train the network

何を・なぜWhat & why

学習とは、モデルの予測と正解のずれ(損失)を小さくするように、重みを少しずつ調整する作業です。多クラス分類なので損失関数は CrossEntropyLoss(交差エントロピー)を使います。重みの更新には Adam という最適化手法を使います。学習は次の流れをくり返します。

  • データを小さなまとまり(ミニバッチ)に分けて順に入力する。
  • 予測を出し、正解との損失を計算する(順伝播)。
  • 損失を各重みで微分し(loss.backward()、誤差逆伝播)、その向きに重みを更新する(optimizer.step())。
  • データ全体を1周するのが1エポック。これを何エポックか繰り返す。

各エポックの終わりに、学習には使っていない検証データで正解率を測り、学習が進んでいるかを見守ります。optimizer.zero_grad() で勾配を毎回リセットするのを忘れないのが定石です。

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

n_epochs = 30
batch = 64
n = Xtr.shape[0]

for epoch in range(1, n_epochs + 1):
    model.train()
    perm = torch.randperm(n)              # 毎エポック順番をシャッフル
    total = 0.0
    for i in range(0, n, batch):
        idx = perm[i:i+batch]
        xb, yb = Xtr[idx], ytr[idx]
        optimizer.zero_grad()
        out = model(xb)
        loss = criterion(out, yb)
        loss.backward()                   # 誤差逆伝播
        optimizer.step()                  # 重みを更新
        total += loss.item() * xb.size(0)

    # 検証データで正解率を測る(勾配は不要)
    model.eval()
    with torch.no_grad():
        va_pred = model(Xva).argmax(1)
        va_acc = (va_pred == yva).float().mean().item()
    if epoch % 5 == 0 or epoch == 1:
        print(f"epoch {epoch:2d}  train_loss {total/n:.4f}  val_acc {va_acc:.3f}")
出力例 / Output
epoch  1  train_loss 2.3019  val_acc 0.101
epoch  5  train_loss 1.9005  val_acc 0.545
epoch 10  train_loss 0.7289  val_acc 0.840
epoch 15  train_loss 0.4439  val_acc 0.872
epoch 20  train_loss 0.3153  val_acc 0.920
epoch 25  train_loss 0.2429  val_acc 0.924
epoch 30  train_loss 0.1983  val_acc 0.948

最初のエポックでは正解率が約0.1(10クラスをでたらめに当てる水準)ですが、エポックを重ねるごとに損失が下がり、検証の正解率が上がっていきます。30エポックで検証正解率は0.95近くに達しました。乱数の状態によって数値は多少変わりますが、右肩上がりの傾向は変わりません。

この Step のまとめ

順伝播・損失計算・誤差逆伝播・重み更新という基本のループを回し、損失が下がり検証正解率が上がる様子を確認しました。この4手順は、どんなに大きな深層学習モデルでも変わらない中心の仕組みです。

5

評価:テストデータで実力を測る

Evaluate on the test set

何を・なぜWhat & why

学習が終わったら、これまで一度も使っていないテストデータで最終評価をします。正解率だけでは「どの数字を、何と取り違えたか」が分かりません。そこで混同行列(confusion_matrix)と分類レポート(classification_report:クラスごとの適合率・再現率・F値)も見ます。機械学習モジュールで学んだ評価指標が、そのまま深層学習でも役立ちます。

Pythonevaluate.py
from sklearn.metrics import confusion_matrix, classification_report

model.eval()
with torch.no_grad():
    pred = model(Xte).argmax(1).numpy()

acc = (pred == y_te).mean()
print("test accuracy:", round(float(acc), 3))
print(confusion_matrix(y_te, pred))
print(classification_report(y_te, pred, digits=2))
出力例 / Output
test accuracy: 0.914
[[35  0  0  0  1  0  0  0  0  0]
 [ 0 26  2  2  0  1  1  0  2  2]
 [ 0  0 34  0  0  0  0  0  1  0]
 [ 0  0  0 36  0  0  0  0  0  1]
 [ 0  0  0  0 36  0  0  0  0  0]
 [ 0  0  0  0  0 34  0  0  0  3]
 [ 1  0  0  0  1  0 34  0  0  0]
 [ 0  0  0  0  0  0  0 36  0  0]
 [ 0  6  1  0  1  0  0  0 25  2]
 [ 0  0  0  0  2  1  0  0  0 33]]
              precision    recall  f1-score   support

           0       0.97      0.97      0.97        36
           1       0.81      0.72      0.76        36
           2       0.92      0.97      0.94        35
           3       0.95      0.97      0.96        37
           4       0.88      1.00      0.94        36
           5       0.94      0.92      0.93        37
           6       0.97      0.94      0.96        36
           7       1.00      1.00      1.00        36
           8       0.89      0.71      0.79        35
           9       0.80      0.92      0.86        36

    accuracy                           0.91       360
   macro avg       0.91      0.91      0.91       360
weighted avg       0.91      0.91      0.91       360

テスト正解率は約0.91。混同行列の対角成分(正しく当てた数)が大きく、全体としてよく分類できています。一方で対角から外れた数字を読むと、取り違えの癖が見えます。たとえば「8」を「1」と、「1」を「8」と間違える例がいくつかあります。8×8と解像度が粗いため、形の似た数字は人間でも紛らわしく、モデルも同じところでつまずきます。誤分類の実例を数件、取り出してみましょう。

Pythonmisclassified.py
# 誤分類したテスト例のインデックスを取り出す
wrong = np.where(pred != y_te)[0]
print("misclassified:", len(wrong), "of", len(y_te))
for i in wrong[:5]:
    print(f"index {i:3d}  true {y_te[i]}  pred {pred[i]}")
出力例 / Output
misclassified: 31 of 360
index   2  true 8  pred 1
index   3  true 1  pred 8
index  18  true 9  pred 4
index  22  true 8  pred 9
index  25  true 5  pred 9

この Step のまとめ

正解率・混同行列・分類レポートで、モデルの実力と取り違えの癖を確かめました。数字の「8」と「1」、「9」と「4」のように形の似たクラスで誤りが出やすい、という具体的な傾向が読み取れます。次はこの結果をどう解釈するかを考えます。

6

考察:何が言えて、どこへ進むか

Discussion & next steps

読み解きReading the result

小さなCNNでも、前処理から学習・評価までを正しくつなげば、手描き数字を9割前後の精度で分類できました。要点は、深層学習の実務がいつも同じ型を踏むことです。データを整え、入力の形を合わせ、損失を下げ、触っていないデータで評価する。この流れは、8×8の digits でも、もっと大きな画像でもまったく同じです。今回の小ささは、その型を素早く一巡するのに向いていました。

注意点Caveats

気をつけることLimits

  • 過学習:学習を長く回しすぎると、学習データには強いのに新しいデータで精度が落ちることがあります。学習と検証の正解率の差を見張るのが目安です。
  • データ量:digits は1797枚と小さく、簡単な課題です。難しい画像ではもっと多くのデータと大きなモデルが要ります。
  • 乱数依存:初期値やシャッフルの乱数で数値は少し動きます。manual_seed で固定しても環境差は残ります。

次に試すことToward more

  • 層やチャネルを増やして、精度がどう変わるかを見る。
  • Dropout やデータ拡張で過学習を抑える。
  • より大きな MNIST や Fashion-MNIST に同じ流れを当てはめる。

精度の数字を鵜呑みにしない

正解率が高くても、それは今回のデータでの話です。実際に使う場面のデータが学習データと違えば、性能は変わります。混同行列で「どこを間違えるか」まで見て、用途に照らして十分かを判断することが大切です。数字は出発点であって、結論ではありません。

発展課題Try more

こう変えたら、どうなる?

一気通貫の流れができたら、少しずつ条件を変えて挙動を確かめましょう。答えは用意しません。手を動かして、数字がどう動くかを観察するのが目的です。

MNIST・Fashion-MNIST に広げる

  • 28×28の MNIST(手書き数字)や Fashion-MNIST(衣類画像)に同じ流れを当てはめる。入力の大きさが変わるので、プーリング後の形と全結合層の入力サイズを合わせ直す。torchvision.datasets から取得できます。

ネットワークを深くする

  • 畳み込み層やチャネル数を増やし、精度と学習時間がどう変わるかを比べる。大きくすれば必ず良くなるわけではない点にも注目。

過学習を抑える

  • nn.Dropout を全結合層の前に入れる、あるいは画像を少し回転・平行移動するデータ拡張を加えて、検証・テストの精度が上がるかを見る。

学習曲線を描く

  • エポックごとの学習損失と検証正解率を記録し、matplotlib で折れ線に描く。過学習が始まる点(検証が頭打ちになる時期)を目で確かめる。

まとめWrap-up

つないで見えたもの、次へ

この演習では、単体で学んだ3つの技術を1本の流れにつなぎ、小さな画像データから手描き数字の分類器を作りました。振り返ると、それぞれのモジュールが次の役割を担っていました。

  • 前処理:画素を正規化し、CNNが受け取る形に整え、学習・検証・テストに分けた。
  • 深層学習:畳み込み・プーリング・全結合でCNNを組み、損失を下げて学習した。
  • 機械学習(評価):正解率・混同行列・分類レポートで実力と癖を測った。

一つの技術だけでは完結しなかった課題が、順につなぐと最後まで解けました。各技術の詳しい理論やAPIは、下の各モジュールに戻ると確認できます。

次の演習・段階へ

おつかれさまでした

ノートブックを開き、エポック数や層の構成を少し変えて、もう一度上から実行してみてください。検証正解率の伸び方や、混同行列の取り違えの場所がどう動くかを見ると、深層学習のパイプライン全体の勘所がつかめます。参考書としては、斎藤康毅『ゼロから作る Deep Learning』が、畳み込みや誤差逆伝播を手を動かして理解するのに向いています。