🗃

Math&Julia #09|ニューラルネットワーク:Flux » Iris品種識別, MNIST手書き数字識別

に公開

はじめに

Fluxを使ってニューラルネットワークを実装します。ニューラルネットワークのモデルには様々なものがありますが、本稿では多層パーセプトロン(Multilayer perceptron; MLP)を採用します。ハンズオンでは次にあげる定番の題材を実践します。

  • アヤメの品種を識別する(Irisデータセット)
  • 手書き数字を識別する(MNISTデータセット)

MLPはシンプルでありながら、これらどちらの題材でも一定の性能を発揮するので習作に適しています。

ニューラルネットワークを実装

ニューラルネットワークの実装にはFluxの他にMLUtilsを使います。MLUtilsを使ってデータの前処理を実装することで、Fluxで実装したコードにスムーズに接続します。

実装にあたっては予測性能の高さよりも簡素なコードの維持を重視しています。そのため、90%以上の正解率を確保できるのであれば、更に上を目指すような発展的なコードは実装しないスタンスです。

Julia
using CairoMakie, Flux, MLDatasets, MLUtils, DataFrames, Statistics, Random, Printf

struct Subset                 # サブセット
    X::Matrix{Float32}        # 特徴量(正規化済み)
    y::Matrix{UInt32}         # 正解値(One-hot化済み)
end   

struct Metrics                # 評価指標
    train_accu::Float64       # 訓練セットの正解率
    train_loss::Float64       # 訓練セットの損失
    valid_accu::Float64       # 検証セットの正解率
    valid_loss::Float64       # 検証セットの損失
end

@kwdef struct HyperParameter  # ハイパーパラメータ
    epochs   ::Int64          # エポック数
    batchsize::Int64          # バッチサイズ
    η        ::Float64        # 学習率
end

const Dataset = MLDatasets.SupervisedDataset                     # エイリアス(型の短縮名を定義)

reshape_pass(X)  = X                                             # 特徴量の形状を変換(パススルー)
reshape_3d_2d(X) = reshape(X, :, size(X, 3))                     # 特徴量の形状を変換(3次元→2次元)
scaler_zscore(X) = (X .- mean(X, dims=2)) ./ std(X, dims=2)      # 特徴量をz-score正規化(dims=2: 行ごとに正規化)
scaler_minmax(X) = X ./ 255                                      # 特徴量をMin-Max正規化(256階調画像専用)
onehot_class(y)  = Flux.onehotbatch(y, unique(y))                # 正解値をOne-hot化(クラスラベルで使用)
onehot_num(y)    = Flux.onehotbatch(y, 0:9)                      # 正解値をOne-hot化(0~9の数値ラベルで使用)
loss(m, X, y)    = Flux.crossentropy(m(X), y)                    # 損失関数(交差エントロピー誤差)
accu(m, X, y)    = mean(Flux.onecold(m(X)) .== Flux.onecold(y))  # 正解率(予測値と正解値を比較して算出)

# データセットをサブセットに分割(データの前処理を含む)
function subsets(ds::Dataset, reshape::Function, scaler::Function, onehot::Function, sz::Int64)
    function preproc()                            # データの前処理
        X    = reshape(ds.features)               # 特徴量を取得して形状を変換
        y    = ds.targets                         # 正解値を取得
        X, y = shuffleobs((X, y))                 # 特徴量と正解値の対応関係を維持したまま全体をシャッフル
        X, y = X[:,1:sz], y[1:sz]                 # 使用するサイズ分だけ抽出
        (X = scaler(X), y = onehot(y))            # 特徴量を正規化、正解値をOne-hot化
    end        
    function split(prep)                          # サブセットに分割
        train, valid, test = splitobs((prep.X, prep.y); at=(0.6, 0.2))
        ( train = Subset(train[1], train[2]),     # 訓練セット (60%)
          valid = Subset(valid[1], valid[2]),     # 検証セット (20%)
          test  = Subset(test[1],  test[2]) )     # テストセット(20%)
    end
    split(preproc())
end

# 訓練セットを使ってモデルを訓練
function fit(model::Chain, train::Subset, valid::Subset, hp::HyperParameter)
    function metrics()                            # 評価指標を計算
        Metrics( accu(model, train.X, train.y),   # 訓練セットの正解率
                 loss(model, train.X, train.y),   # 訓練セットの損失
                 accu(model, valid.X, valid.y),   # 検証セットの正解率
                 loss(model, valid.X, valid.y) )  # 検証セットの損失
    end        
    function progress(mt::Metrics, epoch::Int64)  # 訓練の進捗状況を表示
        @printf "epoch = %d, "        epoch
        @printf "train accu = %.4f, " mt.train_accu
        @printf "valid accu = %.4f\n" mt.valid_accu
    end
    function results(mt::Metrics)                 # 訓練の最終結果を表示
        println("Metrics:")
        @printf "  train accu = %.4f\n"   mt.train_accu
        @printf "  valid accu = %.4f\n"   mt.valid_accu
        @printf "  train loss = %.4f\n"   mt.train_loss
        @printf "  valid loss = %.4f\n\n" mt.valid_loss
    end
    mini = DataLoader((train.X, train.y), batchsize=hp.batchsize, shuffle=true)
    opt = Flux.setup(Adam(hp.η), model)           # オプティマイザ:Adam
    log = Metrics[]                               # 評価指標のログ
    for epoch in 1:hp.epochs
        Flux.train!(model, mini, opt) do m, X, y  # 訓練(ミニバッチ学習)
            loss(m, X, y)                         # 損失を計算
        end        
        mt = metrics()                            # 評価指標を計算
        push!(log, mt)                            # 評価指標をログに記録
        epoch % 10 == 0 && progress(mt, epoch)    # 訓練の進捗状況を表示
    end
    results(log[end])                             # 訓練の最終結果を表示
    log
end

# テストセットを使ってモデルの性能を評価
function evaluate(model::Chain, test::Subset, sz::Int64)
    pred_y = Flux.onecold(model(test.X))[1:sz]    # モデルにテストセットを投入して予測値ベクトルを取得
    true_y = Flux.onecold(test.y)[1:sz]           # 正解値ベクトルを取得
    for (i, (p, t)) in enumerate(zip(pred_y, true_y))
        @printf "data = %2d, " i                  # データの連番
        @printf "pred =%2d, "  p                  # 予測値
        @printf "true =%2d: "  t                  # 正解値
        @printf "%s\n"         p == t ? "✓ PASS" : "✗ FAIL" # 判定(正解/不正解)
    end
    println("Metrics:")
    @printf "  test accu = %.4f\n"   accu(model, test.X, test.y)
    @printf "  test loss = %.4f\n\n" loss(model, test.X, test.y)
end

# 訓練プロセスでの評価指標の変化を可視化
function vis_metrics(log::Vector{Metrics}, epochs::Int64, title)
    fig = Figure(size=(500, 500))
    xticks, yticks = 0:10:epochs, 0:0.2:1.2
    ax  = Axis(fig[1,1], xticks=xticks, yticks=yticks, xlabel="epochs", ylabel="loss", backgroundcolor=:grey98, title=title)
    lines!(ax, getfield.(log, :train_accu), label="train accu", color=:red3)
    lines!(ax, getfield.(log, :valid_accu), label="valid accu", color=:steelblue)
    lines!(ax, getfield.(log, :train_loss), label="train loss", color=:red3,      linestyle=(:dash, 1))
    lines!(ax, getfield.(log, :valid_loss), label="valid loss", color=:steelblue, linestyle=(:dash, 1))
    ylims!(ax, 0.0, 1.2)
    axislegend(ax, position=:rc)
    display(fig)
    fig
end

ハンズオン

ニューラルネットワークではモデルの重みを乱数で初期化します。そのため訓練していないモデルを使うと当てずっぽうに予測を行います。しかし、訓練を経てモデルの重みが最適化されると適切に予測できるようになります。この印象的な変化をハンズオンで定量的に評価します。

ハンズオンの全体コードは次のようになっています。これを出力結果を確認しながら段階的に実行していきます。

  • アヤメの品種を識別する(Irisデータセット)
Julia
# Iris dataset
Random.seed!(3407)
hp = HyperParameter(epochs=50, batchsize=16, η=0.01)
model = Chain(Dense(4, 10, relu), Dense(10, 3), softmax)  # 入力層:4、中間層:10、出力層:3
ds = Iris(as_df=false)
ss = subsets(ds, reshape_pass, scaler_zscore, onehot_class, 150)
println("モデルの性能を評価(訓練前)")
evaluate(model, ss.test, 20)
println("モデルを訓練")
log = fit(model, ss.train, ss.valid, hp)
fig = vis_metrics(log, hp.epochs, "Iris dataset")
save("09-fig-iris.png", fig)
println("モデルの性能を評価(訓練後)")
evaluate(model, ss.test, 20)
  • 手書き数字を識別する(MNISTデータセット)
Julia
# MNIST dataset
Random.seed!(3407)
hp = HyperParameter(epochs=50, batchsize=128, η=0.02)
model = Chain(Dense(784, 128, relu), Dense(128, 10), softmax)  # 入力層:784、中間層:128、出力層:10
ds = MNIST(:train)
ss = subsets(ds, reshape_3d_2d, scaler_minmax, onehot_num, 10000)
println("モデルの性能を評価(訓練前)")
evaluate(model, ss.test, 20)
println("モデルを訓練")
log = fit(model, ss.train, ss.valid, hp)
fig = vis_metrics(log, hp.epochs, "MNIST dataset")
save("09-fig-mnist.png", fig)
println("モデルの性能を評価(訓練後)")
evaluate(model, ss.test, 20)

アヤメの品種を識別する(Irisデータセット)

Irisデータセットには、特徴量としてアヤメの 4部位の寸法(がく片の長さと幅、花弁の長さと幅)、そしてこれに対応する正解値(品種名)が入っています。品種名は setosa, versicolor, virginica の 3種類です。

1. モデルの構築とデータセットの読み込み

Irisデータセットはサンプル数が150件しか無いので、150を指定してすべてのサンプルを読み込みます。

Julia
# Iris dataset
Random.seed!(3407)
hp = HyperParameter(epochs=50, batchsize=16, η=0.01)
model = Chain(Dense(4, 10, relu), Dense(10, 3), softmax)  # 入力層:4、中間層:10、出力層:3
ds = Iris(as_df=false)
ss = subsets(ds, reshape_pass, scaler_zscore, onehot_class, 150)

2. 訓練前の性能を評価

モデルにテストセットを投入して「訓練前」の性能を評価します。

Julia
println("モデルの性能を評価(訓練前)")
evaluate(model, ss.test, 20)
実行結果
モデルの性能を評価(訓練前)
data =  1, pred = 3, true = 2: ✗ FAIL
data =  2, pred = 3, true = 2: ✗ FAIL
data =  3, pred = 3, true = 2: ✗ FAIL
data =  4, pred = 3, true = 2: ✗ FAIL
data =  5, pred = 3, true = 3: ✓ PASS
data =  6, pred = 1, true = 3: ✗ FAIL
data =  7, pred = 1, true = 1: ✓ PASS
data =  8, pred = 3, true = 2: ✗ FAIL
data =  9, pred = 3, true = 3: ✓ PASS
data = 10, pred = 3, true = 3: ✓ PASS
data = 11, pred = 1, true = 1: ✓ PASS
data = 12, pred = 3, true = 2: ✗ FAIL
data = 13, pred = 1, true = 2: ✗ FAIL
data = 14, pred = 1, true = 1: ✓ PASS
data = 15, pred = 1, true = 1: ✓ PASS
data = 16, pred = 3, true = 3: ✓ PASS
data = 17, pred = 1, true = 1: ✓ PASS
data = 18, pred = 3, true = 1: ✗ FAIL
data = 19, pred = 3, true = 3: ✓ PASS
data = 20, pred = 3, true = 2: ✗ FAIL
Metrics:
  test accu = 0.5667
  test loss = 0.9876

訓練前の正解率(test accu)は約56.7%です。3通りの中から正解を引き当てれば良いので、当てずっぽうでも正解しやすいです。

3. モデルを訓練

モデルに訓練セットを投入して訓練(重みを最適化)します。

Julia
println("モデルを訓練")
log = fit(model, ss.train, ss.valid, hp)
fig = vis_metrics(log, hp.epochs, "Iris dataset")
save("09-fig-iris.png", fig)
実行結果
モデルを訓練
epoch = 10, train accu = 0.9222, valid accu = 0.9000
epoch = 20, train accu = 0.9778, valid accu = 0.9333
epoch = 30, train accu = 0.9778, valid accu = 0.9333
epoch = 40, train accu = 1.0000, valid accu = 0.9333
epoch = 50, train accu = 1.0000, valid accu = 0.9333
Metrics:
  train accu = 1.0000
  valid accu = 0.9333
  train loss = 0.0256
  valid loss = 0.1308


図 1 訓練プロセス(Irisデータセット)

検証セットの正解率(valid accu)が約93.3%になっているので、次の性能評価でも90%以上が期待できます。

4. 訓練後の性能を評価

モデルにテストセットを投入して「訓練後」の性能を評価します。

Julia
println("モデルの性能を評価(訓練後)")
evaluate(model, ss.test, 20)
実行結果
モデルの性能を評価(訓練後)
data =  1, pred = 2, true = 2: ✓ PASS
data =  2, pred = 2, true = 2: ✓ PASS
data =  3, pred = 2, true = 2: ✓ PASS
data =  4, pred = 2, true = 2: ✓ PASS
data =  5, pred = 3, true = 3: ✓ PASS
data =  6, pred = 3, true = 3: ✓ PASS
data =  7, pred = 1, true = 1: ✓ PASS
data =  8, pred = 2, true = 2: ✓ PASS
data =  9, pred = 3, true = 3: ✓ PASS
data = 10, pred = 3, true = 3: ✓ PASS
data = 11, pred = 1, true = 1: ✓ PASS
data = 12, pred = 2, true = 2: ✓ PASS
data = 13, pred = 3, true = 2: ✗ FAIL
data = 14, pred = 1, true = 1: ✓ PASS
data = 15, pred = 1, true = 1: ✓ PASS
data = 16, pred = 3, true = 3: ✓ PASS
data = 17, pred = 1, true = 1: ✓ PASS
data = 18, pred = 3, true = 1: ✗ FAIL
data = 19, pred = 3, true = 3: ✓ PASS
data = 20, pred = 2, true = 2: ✓ PASS
Metrics:
  test accu = 0.9333
  test loss = 0.1462

モデルの訓練を経て、正解率(test accu)が約56.7%から約93.3%まで上がりました。

手書き数字を識別する(MNISTデータセット)

MNISTデータセットには、特徴量として手書き数字(0~9)の画素データ、そしてこれに対応する正解値(0から9までの整数)が入っています。画素データの仕様は28×28ピクセル、グレースケール256階調(0~255)です。

1. モデルの構築とデータセットの読み込み

MNISTデータセットのサンプル数は60,000件もあって、すべて使うと時間が無駄になるので10,000を指定して件数を絞ります。これによって予測精度が大きく下がりますが目標自体は達成できます。

Julia
# MNIST dataset
Random.seed!(3407)
hp = HyperParameter(epochs=50, batchsize=128, η=0.02)
model = Chain(Dense(784, 128, relu), Dense(128, 10), softmax)  # 入力層:784、中間層:128、出力層:10
ds = MNIST(:train)
ss = subsets(ds, reshape_3d_2d, scaler_minmax, onehot_num, 10000)

2. 訓練前の性能を評価

モデルにテストセットを投入して「訓練前」の性能を評価します。

Julia
println("モデルの性能を評価(訓練前)")
evaluate(model, ss.test, 20)
実行結果
モデルの性能を評価(訓練前)
data =  1, pred = 2, true =10: ✗ FAIL
data =  2, pred = 9, true = 4: ✗ FAIL
data =  3, pred = 6, true = 1: ✗ FAIL
data =  4, pred = 2, true = 2: ✓ PASS
data =  5, pred = 5, true = 6: ✗ FAIL
data =  6, pred = 5, true = 9: ✗ FAIL
data =  7, pred = 6, true = 5: ✗ FAIL
data =  8, pred = 5, true = 2: ✗ FAIL
data =  9, pred = 2, true = 2: ✓ PASS
data = 10, pred = 6, true = 3: ✗ FAIL
data = 11, pred = 5, true = 2: ✗ FAIL
data = 12, pred = 6, true = 5: ✗ FAIL
data = 13, pred = 7, true = 9: ✗ FAIL
data = 14, pred = 6, true = 3: ✗ FAIL
data = 15, pred = 9, true = 5: ✗ FAIL
data = 16, pred = 5, true = 6: ✗ FAIL
data = 17, pred = 2, true = 2: ✓ PASS
data = 18, pred = 5, true = 3: ✗ FAIL
data = 19, pred = 5, true =10: ✗ FAIL
data = 20, pred = 6, true = 1: ✗ FAIL
Metrics:
  test accu = 0.0850
  test loss = 2.3027

訓練前の正解率(test accu)は約8.5%です。さすがに、当てずっぽうで10通りの中から正解を引き当てるのは困難です。これだけ正解率が低いと訓練のしがいがあります。

3. モデルを訓練

モデルに訓練セットを投入して訓練(重みを最適化)します。

Julia
println("モデルを訓練")
log = fit(model, ss.train, ss.valid, hp)
fig = vis_metrics(log, hp.epochs, "MNIST dataset")
save("09-fig-mnist.png", fig)
実行結果
モデルを訓練
epoch = 10, train accu = 0.9330, valid accu = 0.9075
epoch = 20, train accu = 0.9552, valid accu = 0.9155
epoch = 30, train accu = 0.9778, valid accu = 0.9260
epoch = 40, train accu = 0.9890, valid accu = 0.9280
epoch = 50, train accu = 0.9958, valid accu = 0.9345
Metrics:
  train accu = 0.9958
  valid accu = 0.9345
  train loss = 0.0325
  valid loss = 0.2427


図 2 訓練プロセス(MNISTデータセット)

検証セットの正解率(valid accu)が約93.5%になっているので、次の性能評価でも90%以上が期待できます。

4. 訓練後の性能を評価

モデルにテストセットを投入して「訓練後」の性能を評価します。

Julia
println("モデルの性能を評価(訓練後)")
evaluate(model, ss.test, 20)
実行結果
モデルの性能を評価(訓練後)
data =  1, pred =10, true =10: ✓ PASS
data =  2, pred = 4, true = 4: ✓ PASS
data =  3, pred = 1, true = 1: ✓ PASS
data =  4, pred = 2, true = 2: ✓ PASS
data =  5, pred = 6, true = 6: ✓ PASS
data =  6, pred = 9, true = 9: ✓ PASS
data =  7, pred = 5, true = 5: ✓ PASS
data =  8, pred = 2, true = 2: ✓ PASS
data =  9, pred = 2, true = 2: ✓ PASS
data = 10, pred = 3, true = 3: ✓ PASS
data = 11, pred = 2, true = 2: ✓ PASS
data = 12, pred = 5, true = 5: ✓ PASS
data = 13, pred = 4, true = 9: ✗ FAIL
data = 14, pred = 3, true = 3: ✓ PASS
data = 15, pred = 5, true = 5: ✓ PASS
data = 16, pred = 6, true = 6: ✓ PASS
data = 17, pred = 2, true = 2: ✓ PASS
data = 18, pred = 3, true = 3: ✓ PASS
data = 19, pred =10, true =10: ✓ PASS
data = 20, pred = 1, true = 1: ✓ PASS
Metrics:
  test accu = 0.9225
  test loss = 0.3078

モデルの訓練を経て、正解率(test accu)が約8.5%から約92.3%まで上がりました。

参考までにチューニングに関心がある場合は、データの読み込みを現在の10,000件から60,000件に増やすだけでも正解率が97%くらいまで上がります。ただし抜本的な解決策としては、モデルを現在のMLPからCNN(画像認識に適している)などに変更するのが良さそうです。

Discussion