14. AI技術: 機械学習(Deep Learning)入門 2

Photo by Claudio Schwarz on Unsplash

入門2 機械学習用フレームワークの利用

Flux パッケージ

ここまで感じただろうが,今現在の深層学習には一定の「やりかた」があり,それに沿うならばプログラムはどうしたってかなり似たようなものになる. ならば,そうした点を共通化し,かつ,計算時間の掛かりそうな箇所に高速なライブラリを組み込むような使いやすいフレームワークライブラリがあればユーザは大助かりだ.

そうした需要にこたえ,深層学習用の多くのフレームワークが存在する. Julia についてもやはり存在し,パッケージに Flux というものがあるので今回はこれを使ってみよう.

ちなみに,Flux の使い方については Flux マニュアル を見るとよいだろう. なお,サンプルが Flux Model Zoo に載っているので,参考にするとよいだろう.

有名な機械学習用ライブラリ/フレームワークとして他に Caffe, Keras (高水準ライブラリ,TensorFlow などの他の低水準ライブラリを下部に使う), TensorFlow, Chainer などが知られている(主に他の言語用だが).

今回のターゲット問題

今回は典型的(かつ有名)な学習問題である「手書き数字の認識問題」をターゲットとしよう. 学習に使える実データとして有名なものに MNIST というものがある.

これは 70,000枚(学習用 60,000枚 + 学習成果検証用 10,000枚)の手書き数字画像とその「正解」情報からなるデータセットで,各画像は以下のようになっている.

  • 色はモノクロ.濃淡が 0以上1以下の実数で表されている.
  • サイズは 28 $\times$ 28 ドット.
  • 本来のデータは 20 $\times$ 20 ドットの白黒二色のものだが,anti-aliasing アルゴリズムで 0以上1未満の濃淡に変換するなどしている. サイズが大きくなっているのは畳み込みなどの画像処理をしやすくするために各辺に「余白」をつけているのだと思われる. ただ,単に余白を足したのではなく,「大きめのキャンバスの中心に画像の中心をあわせて配置」しているので,余白は必ずしも各辺で 4ドットずつになっていないので要注意.

たとえばその 10枚目のデータを画像として見てみると

となっている(この画像では 0 $\cong$ 黒, 1 $\cong$ 白として表示されている).ちなみにこれは数字の「4」だそうな.

MNIST のデータは本家の web からダウンロードしてもよいが,それだとちょいとした前処理が必要となる. しかし実は,Flux パッケージは綺麗に処理された MNIST データをダウンロードする機能をもっているので,その機能を使おう(どうやるかはプログラムを見ればわかる).

今回用意する neural netowrk

少し要素数などが先週より大きいので,丁寧に準備しよう. ただし,以下では意外に小さい NN を用意するので驚くかもね.

さて,今回は,

  • 入力は要素数 784 ($= 28\times 28$) の実数ベクトル
  • 出力は数字 0 ~ 9 に対する「確率」を表すベクトル. つまり,要素数 10 の実数ベクトル $\boldsymbol{p}$で,$0 \leq p_i \leq 1$ かつ $\sum_i p_i = 1$.
  • 通常の密結合 NN を用いる.中間(network)層は 2つ.
  • 1つ目の中間層の出力サイズは 32 で,活性関数は ReLU.
  • 2つ目の中間層の出力サイズは 10 で,活性関数の代わりに softmax 関数で正規化して出力が確率分布として成り立つようにする.(注: 2つ目の中間層の出力が最終出力)

という NN を Flux で用意して,この中に含まれるパラメータを(学習によって)修正することにしよう.

なお,12. AI技術: 機械学習(Deep Learning)入門のプログラム中にも登場したが, softmax 関数というのは実数の列(負の実数もOK)を確率分布として解釈できる数列に(かつ,各要素は単調に)変換する関数の一つで, ベクトル $\boldsymbol{a} = \{ a_i \}$ に対して

$\displaystyle\mbox{ SoftMax }(\boldsymbol{a})_i = \frac{\exp(a_i)}{\sum_i \exp(a_i)}$

と定義できる. $\exp$ 関数で強引に正の値に変換してから合計値で正規化しているという,シンプルな変換だ. 単調性と確率分布の性質を満たそうとするとたぶんこれが最初の候補だろう. ちなみに,すべての $a_i$ が正ならば, $\exp$ での変換が不要で,合計値で割って単純に正規化したほうが楽だろうな.

あと,これまた同じ回のプログラム中に登場したが,出力の「誤差」を測る方法として出力ベクトル $\boldsymbol{y}$ と真値ベクトル $\boldsymbol{z}$ に対する Cross Entropy

$\displaystyle\mbox{ CrossEntropy }(\boldsymbol{y}, \boldsymbol{z}) = - \sum_i \, z_i \, \exp( y_i )$

を使う.

入力独立変数に対して非対称な関数なので入力の順序に注意.目くじら立てるほどの本質的な違いはないと思ってもまあいいが.順序は定義によるので,プログラムの source を確認しておく必要がある.Flux パッケージの crossentropy 関数は上の順序になっている.

これは,確率分布間の「違い」を数字にする方法の一つで,まあ,ノルム「のようなもの」と思えば良い.
実際,クロスエントロピーは $\boldsymbol{z}$ のエントロピー + カルバックライブラー情報量 $\mbox{KL}(\boldsymbol{z}, \boldsymbol{y})$で,そもそも $\mbox{KL}$ は情報幾何の世界ではノルム「もどき」として知られている量なのだ.

実際にやってみる

あとは少しずつプログラムを作っていくだけだ.

まずはいつものように Flux などのパッケージの使用宣言だ.

1
2
3
4
5
using Flux
using Flux: onehotbatch, onecold, crossentropy, train!, throttle
using MLDatasets
using Statistics
using Base.Iterators: repeated

次に、MLDatasets package の機能で MNIST データをダウンロードし,NN で扱えるように変換しよう. まずはダウンロードだ.これは一回だけやれば良い.あとは SSD/HDD に保存される.

1
2
# データをダウンロードする.一回だけで良い.
MNIST.download(; i_accept_the_terms_of_use = true)

次に,これを変数に入れよう.

1
2
# 学習に使うデータ.60000個ある.
raw_imgs, labels = MNIST.traindata()

raw_imgs 変数に画像データが, labels 変数にその画像がどの数字を描いたものか,が入る.

ただ,raw_imgs 変数は3次元配列だし,画像用の特殊な型(f0r8形式)で扱いにくいので,次のようにして扱いやすくしておこう.

1
2
3
# 扱いやすいように変換しておく.
data_number = size(labels)[1]
imgs = [ float.(raw_imgs[:,:,i]') for i in 1:data_number ]

ダウンロードしたデータをちょっと見ておこう.

それには画像データを画像で見てみるのが良いので,ColorTypes package を使うことにしよう. インストールしてない場合はインストールしておこう.

1
2
using Pkg
Pkg.add("ColorTypes")

その後,次のようにすると,行列データを(以下の場合はグレイ表現の)画像で見ることができる.

1
2
3
using ColorTypes

Gray.( imgs[1] )

ちなみにこれは 0から9 のどの数字かというと,

1
labels[1]

5

ということで,正解は 5 だそうだ.

次に,これらを,学習プログラムに渡せるよう,固まりのデータに変換する.

1
2
3
4
5
6
7
# 「画像 = 行列」となっているデータをベクトルに変換し,
# それを横にくっつけて一つの大きな行列に.
X = hcat( reshape.(imgs, :)... )

# 正解の値を,0:9 に対応するように,要素が 「真偽値」(= 1 or 0) の
# 10次元ベクトルに変換する
Y = onehotbatch(labels, 0:9)

変換した結果を見ておこう. 特に,Y が何を意味するのか,よくみるとわかるだろう.

1
X
784×60000 Matrix{Float32}:
0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0  …  0.0  0.0  0.0  0.0  0.0  0.0  0.0
0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0     0.0  0.0  0.0  0.0  0.0  0.0  0.0
0.0  0.0  0.0  0.0  0.0  0.0  0.0  0.0     0.0  0.0  0.0  0.0  0.0  0.0  0.0
…略…
1
Y
10×60000 OneHotMatrix(::Vector{UInt32}) with eltype Bool:
 ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  …  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅
 ⋅  ⋅  ⋅  1  ⋅  ⋅  1  ⋅  1  ⋅  ⋅  ⋅  ⋅     ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅
 ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅     ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅
 ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  1  ⋅  1     ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅
 ⋅  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅     ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅
 1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  …  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  1  ⋅  ⋅
 ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅     ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅
 ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅     1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅
 ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅     ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  1
 ⋅  ⋅  ⋅  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅     ⋅  ⋅  1  ⋅  1  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅  ⋅

では,NN を作ろう. Flux では大変簡単に,以下のように NN を構成できる.

1
2
3
4
5
6
7
8
m = Chain(
  Dense(28^2, 32, relu),
  # 密結合第1層.28^2 の入力を受けて,32 の出力を返す.活性関数は ReLU.
  Dense(32, 10),
  # 密結合第2層.32 の入力を受けて 10 の出力をそのまま返す.
  softmax
  # 出力直前に 正規化
  )

意味は,上の注釈でわかるだろう.

さて,この学習「していない」 NN での出力を念の為に確認しておこう. 1つ目のデータを入れるとどういう答えが返ってくるか…

1
r = m( X[:,1] )
10-element Array{Float32,1}:
 0.09637651
 0.12438649
 0.08440215
 0.09990795
 0.0857012
 0.11381065
 0.11615292
 0.11861699
 0.09201259
 0.06863261

これが NN の出力で,この画像が「 0 ~ 9 である確率 」を並べたもの,と解釈することになる. わかりやすいようにグラフで見ておこう.

1
2
3
using Plots

bar( 0:9, r )

これだと,この画像は "2" である確率が一番高いことになり,正解の "5" を当てられていないことがわかる.

同様にもう二つほど見ておこう. データの 2つ目は

1
Gray.( imgs[2] )

となっていて,明らかに正解は "0" だ.まあ確認しておくか.

1
labels[2]
0

ふむ.ではこの画像データを NN に入れると…

1
bar( 0:9, m( X[:,2] ) )

これも当然うまくいってない.

データの3つ目も確認しておこう. (画像をみるのはもう省略して)真値は

1
labels[3]
4

となるので "4" が真値で,NN が出す確率は

1
bar( 0:9, m( X[:,3] ) )

となっていてこれももちろんうまくいってない.

さて,では学習に必要な関数の準備をしよう.

1
2
3
4
5
# 損失関数.要は出力誤差.今回は Cross Entropy で.
loss(x, y) = crossentropy(m(x), y)

# 正解率.この文脈だと精度ともいう.
accuracy(x, y) = mean(onecold(m(x)) .== onecold(y))

NN の正解率を測ることができるようになったので,学習していない現状の NN での正解率をみておこう.

1
2
# 初期のパラメータの NN だと精度は?
accuracy(X, Y)
0.0945

ふ~む.ランダムな状態の NN での正解率が 9.45% ということで,まあ自然だな(ランダムに数字を一つだせば,正解率は平均で 10% になるはずなので).

ではデータセットの設定と,パラメータをどうやって修正していくかの方法の指定,そして画面表示設定等をしてしまおう.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# データセット.大きくすれば結果は良くなるが,時間もかかる.
# コンパイル用に小さなデータを,本番用に大きなデータを用意しておく.
small_dataset = repeated((X, Y), 1)
large_dataset = repeated((X, Y), 500)

# 損失関数を小さくする方向をどう決めるか.学習をどう行うか.
# ADAM 法がよく使われるようだ.パラメータは適当.
opt = ADAM(0.01)

# 学習が進んでいく途中での画面表示等を設定.
# cb とは call back のこと.
evalcb = () -> @show(loss(X, Y))

これで準備ができたので,早速実行しよう. ただし,一回小さなデータで関数をコンパイルしておく.

1
train!(loss, params(m), small_dataset, opt, cb = throttle(evalcb, 10))
loss(X, Y) = 1.9413911f0

では,大きめのデータで学習させてみよう.

1
@time train!(loss, params(m), large_dataset, opt, cb = throttle(evalcb, 10))
loss(X, Y) = 0.26127964f0
loss(X, Y) = 0.13625997f0
loss(X, Y) = 0.097146265f0
loss(X, Y) = 0.07342944f0
loss(X, Y) = 0.057304744f0
loss(X, Y) = 0.0455831f0
loss(X, Y) = 0.03663983f0
loss(X, Y) = 0.029523531f0
loss(X, Y) = 0.023770217f0
loss(X, Y) = 0.019059699f0
 96.623440 seconds (392.26 k allocations: 116.986 GiB, 4.65% gc time, 0.12% compilation time)

計算時間は CPU 等によってひどく異なるので,私有環境で上の 10倍以上の時間がかかっても不思議ではない.

さて,学習が終了したようなので,結果をチェックしてみよう.

1
2
# 学習後の NN の精度.
accuracy(X, Y)
0.9972

ふむ.正解率は 99.7% か.学習前は 9.5% だったことを考えると,2分弱の学習としてはたいへん上出来だ.

最初の 3つのデータについて,個別に学習結果をチェックしてみよう. まずは 1つ目のデータに対する NN の回答は,

1
bar( 0:9, m( X[:,1] ) )

となる.これだと "5" である確率が80%以上で,正解をきちんと当てていると言えるな.

次に 2つ目のデータだ.

1
bar( 0:9, m( X[:,2] ) )

これだと "0" である確率がほぼ 100% だ.正解を完全に当てている.

3つ目も見てみよう.

1
bar( 0:9, m( X[:,3] ) )

これも "4" である確率が約 90% で,正解をきちんと当てている.

次に,学習に使っていない,テスト用データを対象としてこの NN の性能を見よう. まず,テスト用データのダウンロードと整形だ.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# テスト用に使うデータ.10000個.上と内容は重複なしのはず.
raw_imgs_check, labels_check = MNIST.testdata()

# 使いやすいように変換.
check_number = size(labels_check)[1]
imgs_check = [ float.(raw_imgs_check[:,:,i]') for i in 1:check_number ]

# Flux 用の形に.
X_check = hcat( reshape.(imgs_check, :)... )
Y_check = Flux.onehotbatch(labels_check, 0:9)

この NN を適用した場合の精度を見よう.

1
accuracy(X_check, Y_check)
0.958

ふむ,未知のデータに対しても約 96% の確率で正解を出せるということだな. どうやらこの NN の学習はうまくいった,と言ってよいだろう.

おまけ: GPU を使った場合

上記の計算は小さめとはいえ約 5分かかっている. これに対し,特殊なハードウェアの力を借りて計算したらどうなるか,という例を示しておこう. 今回は nVIDIA 社の GPU (まあ,要はグラフィックカードだ) を使ってみる,という例になる.

残念ながら下記の code は nVIDIA 社の GPU がインストールされていない環境では動かない. nVIDIA 社のグラフィックカードが刺さっている環境で試そう.

準備

nVIDIA 社の GPU を Flux から使うには,事前に

  • グラフィックカードのデバイスドライバ: まあこれは Flux に関係なくインストールしているだろう.
  • CUDA Toolkit : CUDA という専用言語で GPU を扱うためのライブラリ.nVIDIA製. Windows だと,ダウンロードしてダブルクリックするだけでインストールできる. ただし,ファイルサイズが 2GB を超えるのでダウンロードの際は覚悟しよう.
  • cuDNN : 深層 NN 用ライブラリ.nVIDIA製.cuDNN のインストール方法 を読むと(デバイスドライバと CUDA Toolkit も含めて)インストール方法がわかる.まあ,解凍してファイルをいくつかコピーするだけだ.

のインストールを上の順序でしておく必要がある…のだが,実は,下記のCUDA パッケージ1をインストールすると自動でやってくれる.楽だ.

というわけで,Julia で CUDA パッケージを使えるようにインストールしよう.

1
2
using Pkg
Pkg.add("CUDA")

ダウンロード等にかなり時間がかかる. 授業中に下記のコードにチャレンジするのであれば,できれば事前にこのインストールをやっておこう.

あとは次のように,必要なデータや計算ルーチンを GPU に渡すようにしてプログラムを書けば良い.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using Flux
using Flux: onehotbatch, onecold, crossentropy, train!, throttle
using MLDatasets
using Statistics
using Base.Iterators: repeated
using CUDA

raw_imgs, labels = MNIST.traindata()
data_number = size(labels)[1]
imgs = [ float.(raw_imgs[:,:,i]') for i in 1:data_number ]

oX = hcat(float.(reshape.(imgs, :))...)
oY = onehotbatch(labels, 0:9)

X = copy(oX)  |> gpu
Y = copy(oY) |> gpu

m = Chain(
  Dense(28^2, 32, relu),
  Dense(32, 10),
  softmax) |> gpu

loss(x, y) = crossentropy(m(x), y)

function accuracy(m, x, y)
    m = m |> cpu
    r = mean( (onecold(m(x))) .== (onecold(y)))
    m = m |> gpu
    return r
end

# コンパイル用に小さなデータを,本番用に大きなデータを用意しておく.
small_dataset = repeated((X, Y), 1) |> gpu
large_dataset = repeated((X, Y), 500) |> gpu
opt = ADAM()
evalcb = () -> @show(loss(X, Y))

  GPU に渡すデータのサイズをむやみに大きくしないようにしよう. 上の例で言えば,large_dataset をあまり大きくすると,GPU の挙動が変になったりして,OS までトラブったりするぞ.

これで準備ができた. 念の為に初期の精度を測っておく.

1
accuracy(m, oX, oY)
0.10885

ふむ.平均正解率 10.9% というところで,まあ妥当だ.

では学習させてみよう. ただし,一回小さなデータで関数をコンパイルしておく.

1
train!(loss, params(m), small_dataset, opt, cb = throttle(evalcb, 10))

では,大きめのデータで学習させてみよう. このデータを CPU で学習したときは 100秒近くかかったが…

1
@time train!(loss, params(m), large_dataset, opt, cb = throttle(evalcb, 10))
loss(X, Y) = 2.194913f0
 7.985506 seconds (832.80 k allocations: 53.118 MiB, 1.24% gc time, 0.21% compilation time)

おお! さすが. CPU だと 97秒かかったこの学習が,この環境だとたった 8秒で学習が終わる. 12倍は速くなった,ということだな.

この例で使っているGPUボードは動画用であって計算用途向きではないので,本格的な計算用 GPUボードだったらどれくらい速くなるんだろう,と思ってしまうな.

さて,学習後の NN の精度も見てみよう.

1
accuracy(m, oX, oY)
0.9505833333333333

正解率が 95% ということで,確かによく学習できているようだ.

未知のデータについてもこの NN の能力を確かめておこう.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# テスト用に使うデータ.10000個.上と内容は重複なしのはず.
raw_imgs_check, labels_check = MNIST.testdata()

# 使いやすいように変換.
check_number = size(labels_check)[1]
imgs_check = [ float.(raw_imgs_check[:,:,i]') for i in 1:check_number ]

# Flux 用の形に.
X_check = hcat( reshape.(imgs_check, :)... )
Y_check = Flux.onehotbatch(labels_check, 0:9)

accuracy(m, X_check, Y_check)
0.9443

未知のデータに対しても 94% の正答率だ.たった 8秒の学習なのに,大変よく出来たと言えよう.

ということで,GPU などの特殊なハードウェアの力を借りることができれば,こうした学習も大変速く行えることがわかる.デスクトップ PC 用のグラフィックカードであればそう高いものでもないので,機械学習を少し勉強して見るならばこうしたハードウェアの調達も検討しておくと良いだろう.

レポート

下記要領でレポートを出してみよう.

  • e-mail にて,
  • 題名を 2021-numerical-analysis-report-14 として,
  • 教官宛(アドレスは web の "TOP" を見よう)に,
  • 自分の学籍番号と名前を必ず書き込んで,
  • 内容はテキストでも良いし,pdf などの電子ファイルを添付しても良いので,

下記の内容を実行して,結果や解析,感想等をレポートとして提出しよう.

  1. 上の例で NN の中間層を増やしてみよう.ただし,計算時間は増大するので,バランスが厳しいかも.

  2. nVIDIA のグラフィックカードが刺さっている PC環境を探すなどして,可能ならば GPU での計算例も試してみよう.

  3. Flux Model Zoo を見て,他の例を試してみよう.ただし,知らないことばかりの場合は無理しなくて良い.

  1. 少し前までは Julia では NVidia 社の GPU を使うには CuArrays package を使うもの,だったが,今は CUDA package を使え,ということになっている.そのせいか,最新の環境だと CuArrays package はインストールすらできない. ↩︎