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

青く光る人工ニューラルネットワークのイメージ
Photo by Alina Grubnyak on Unsplash

機械学習とは,データを手がかりに「入力から良い答を返す関数」を作る技術だ.今回はライブラリの便利さに隠れがちな仕組みを,数式とJuliaの素朴なプログラムから確かめる.

この回の到達目標

  1. 見通すニューラルネットワークを「パラメータをもつ関数」として説明できる.
  2. 組み立てる重み・バイアス・活性化関数から小さなNNをJuliaで実装できる.
  3. 学習させる損失と勾配を用いてパラメータを更新する流れを理解する.
  4. 確かめる学習用データと未知データを分け,分類性能を評価できる.
1 概念をつかむ 2 1変数関数で試す 3 アヤメを分類する 4 未知データで確かめる

1. AIと機械学習の全体像

人工知能は,情報技術が長年追い続けてきた夢の技術であり,その研究には長い歴史がある. 昨今は Deep Learning に代表される機械学習がたいへん注目を集めているが,AIを支える技術はそれだけではない.多くの要素技術があり,それぞれに得手不得手がある. 今回から,AIに関する機械学習と,そのほかの要素技術を順に学んでいこう.

Deep Learning(深層学習)とは

今回は,近年たいへん発達し注目されている機械学習技術の一つ,Deep Learningについて,その本質を簡単に学ぼう. 機械学習のライブラリやフレームワークは数多く存在する.しかし前半では,あえてそれらにNN本体を任せず,自分でプログラムすることで内部の仕組みを理解する.

Deep Learningとは,多層の neural network(NNと略されることも多い)を数学的にモデル化して利用する技術である. 層を何段も重ねた「深い」ネットワークの内部パラメータを学習できることから,deepという名前がついている. 画像分類問題で画期的な結果が示されて以来,たいへん活発な研究分野であり続けている.

一方,発展の速い分野であるため,細かな技術論に気を取られて「結局,何をしているのか」が見えにくくなることもある. 今回は基本的な考え方を理解できるよう,解説と素朴なプログラミングを重視して進めよう.

機械学習が作るもの

全体像を示そう. まず,機械学習でやりたいことは何かというと,

機械学習でやりたいこと入力を与えると,良い答えを返す関数を作る

なのだ. これがうまくできれば,われわれが知能を使って行っている作業のうち多くをコンピュータに代わりにさせることができる,というわけだ1

そして,機械学習の様々な技術は,この「関数を作り,改善すること」を如何に上手にやるか,という技術なのだ.

今回扱う deep learning では,関数を neural network で作り,そのパラメータを改善していく技術だ. シンプルな仕組みながらこれが大変うまくいく問題が多く,そのために期待されているのだ.

ここから何度も現れる言葉を,先に対応づけておこう.

用語 この授業での意味 記号・例
入力 NNに与える情報 $x$, アヤメの4つの測定値
モデル 入力から出力を計算する関数 $\mathrm{NN}(H,x)$
パラメータ 学習によって調整する数値 重み $C$, バイアス $b$, まとめて $H$
教師情報 入力に対する望ましい答え $g(x)$, アヤメの種類
損失 NNの出力と教師情報のずれ $f(H,x)$
学習 損失が小さくなるようパラメータを更新すること $H \leftarrow H - \gamma,\mathrm{grad}_H f$
検証 学習に使わなかったデータで性能を確かめること 後半の15個のアヤメ

Neural Network(ニューラルネットワーク)

ニューロン(神経細胞)の構造

ニューロン(神経細胞)の構造図.Public domain; created by LadyofHats.

Neural Network とは,上の図のような神経細胞(ニューロン)によって構成された神経網を真似たシンプルな数学モデルだ.

生物のニューロン間で信号が伝わるイメージ

もとの神経網の仕組みをおおまかにいうと,まず,ニューロンの電気信号が軸索を通じてそのニューロン末端のシナプスに届き,その結合を介して信号を他のニューロンに伝えるようになっている. ニューロンはそのようにして複数のニューロンからやってきた信号を受け取るわけだが,集まった信号が一定以上の大きさであれば「反応(興奮や発火と呼ばれる)」して新たに電気信号を作り,他のニューロンへ信号を伝える. あとはこの繰り返し,という仕組みで,この「網の形」と「シナプス結合の強さ」等々によって情報処理がうまくいくようになっている,と考えられている.

これをモデル化して,いわゆる有向グラフの「ノード」をニューロンの信号受信&処理部分とすると次の図のような感じだ.

生物のニューロンを数理モデルに対応させた図

少し解説しよう.生物の仕組みと数理モデルの対応は,次の3点に整理できる.

  • 信号を送ってくるノード $i$ からの信号をノード $k$ がどれだけ受け入れるかは調整パラメータである 重み係数 $C_{ik}$ として表される.
  • ノード $k$ の発火のしやすさは調整パラメータである バイアス $b_k$ で表される.
  • 入力信号が多いと出力信号が作られる「発火」という現象は,入力の合計を $r$ として非線形関数 $\phi(r)$ で表される. この関数のことを 活性化関数 (activation function)と呼んだりする. 活性化関数にはいくつか提案されているものが有り,典型的なのは次のようなものだ. 活性化関数を変えると NN の挙動が結構変わるので,実際に NN を使う場合はいくつか試してみると良い. 活性化関数の例
名前 関数 グラフ
シグモイド関数(sigmoid) $\displaystyle \sigma(r) = \frac{1}{ 1 + e^{-r} }$
tanh 関数 $\displaystyle \tanh(r) = \frac{ e^r - e^{-r} }{ e^r + e^{-r}}$
正規化線形関数(ReLU) $\mbox{ReLU}(r) = \max(0,r)$

そしてこのノードを集めて層を作り,その層を繋げたものが neural network だ.イメージは次のような感じになる.

複数のニューロンを層として接続したneural network全体のイメージ

図:neural network 全体のイメージ

そして,入力に対して出てくる出力が望ましいものになるべく近づくよう,パラメータ $C,b$ を調整していく(これが学習),ということを繰り返すことでこの neural network を望ましいものへと改善していくのだ.

この調整,つまり学習をどう行うかだが,これは最小化問題という文脈の問題を解決する技術が使える. すぐ後で解説するのでそこまで待とう.

2. 手作りNNで1変数関数を学習する

Step 1:ターゲットを決める

機械学習の重要な本質の一つは「明示的なアルゴリズムではどうやって解決方法を実現したら良いかわからない問題」に解決方法を与えるところにある. しかしまあ,今回は最初の入門問題として,大変簡単な問題を「解決できないふりをして」取り組むことにしよう.

今回扱う問題は, $x \in [0,1]$ に対して実際は

$g(x) = \left\{\begin{array}{rcl} 1 & : & 1 / 3 < x < 2 / 3, \cr 0 & : & \mbox{ otherwise } \end{array}\right.$

である関数を,その関数形を知らない状態で データ $\{ x_k, g(x_k) \}_{k = 1}^{N_d}$ だけをもらってその情報から近似関数を作ろう,という問題としよう. もちろん,この問題は通常は「補間」技術を用いたほうが筋も結果も計算時間も良いのだが,今回は敢えて機械学習の練習問題としてこれを扱おう.

Step 2:neural networkを設計する

スカラー実数 $x$ を入力とし出力はスカラー実数であるような,大変単純な NN を今回は考えよう.

まず,NN の構造を説明しよう. NN は結構多めの「層」をつなげて,そこに現れる多数のパラメータを「学習」によってチューンすることで所望の出力を得られるようにしよう,という関数だ. その層だが,典型的には一層ずつは以下のような形をしている. 入力に重みを掛け,バイアスを加え,活性化関数を通す1層の構造

そして今回の問題では,こうしたパラメータをもつ層が 3つであるような NN を考えよう. 大雑把には下図のような設計になる.

入力1,中間層2,出力1からなる今回のneural network設計

より具体的には, 入力を $x$, 出力を $y$, 中間層(ベクトル, 要素数 $n$)の出力をそれぞれ $\boldsymbol{r}^{(1)}$, $\boldsymbol{r}^{(2)}$ として, 密結合係数として $n$ 次元ベクトル $\boldsymbol{C}_1, \boldsymbol{C}_3$ と $n \times n$ 行列 $C_2$, バイアスとして $n$ 次元ベクトル $\boldsymbol{b}_1$, $\boldsymbol{b}_2$, スカラー $b_3$ を用意して,NN が

$\left\{\begin{array}{rcl} \boldsymbol{r}^{(1)} & = & \mbox{ ReLU }( x \boldsymbol{C}_1 + \boldsymbol{b}_1), \cr \boldsymbol{r}^{(2)} & = & \mbox{ ReLU }( C_2 \boldsymbol{r}^{(1)} + \boldsymbol{b}_2), \cr \mbox{ 出力 } y & = & \boldsymbol{C}_3 \cdot \boldsymbol{r}^{(2)} + b_3 . \end{array}\right.$

となっているケース(ほぼ最小限セットだな)を考える. $n$ はそうだなあ,たぶん 5 ~ 10 ぐらいでうまくいくだろう(かなり無駄が多いけどな).

さて,関数としてこの全体を NN という名前で呼ぶことにしよう. つまり,$\mbox{output } y = \mbox{NN}( H, \mbox{ input } x)$ という感じだ.

ただし $H$ はこの NN のパラメータ $C_1 \cdots C_3$, $b_1 \cdots b_3$ を適当に一つにまとめたデータだ. 今回は,たとえば下図のようにまとめればよいだろう.

ニューラルネットワークのパラメータを行列Hにまとめる方法

このようにパラメータを一つの量としてまとめておくことで,これらのパラメータを同時に更新して NN を「改善」する手法が使いやすくなる. 具体的には,プログラム中で gradient を利用「できる」ことがその恩恵にあたる.

Step 3:損失を小さくして学習する

使い方は簡単だ.

まず,既知のデータから一つ適当な $x_k$ を関数 NN に入れると値が出てくるので,これとデータに入っている $g(x_k)$ (教師情報. 出力の正解のこと)を比べて誤差 $f(H, x_k)$ を計算する. 今回の場合,$x_k$ も $g(x_k)$ もスカラーなので,誤差関数 $f$ もスカラーで正なものとして定義しておくと良いだろう.

そして,この誤差 $f(H, x_k)$ が小さくなるように NN のパラメータ $H = ($ $\boldsymbol{C}_1$, $\boldsymbol{b}_1$, $C_2$, $\boldsymbol{b}_2$, $\boldsymbol{C}_3$, $b_3$ $)$を修正するのだ!

  1. 1入力するデータ $x_k$ をNNへ
  2. 2予測する$\mathrm{NN}(H,x_k)$ を計算
  3. 3比べる正解 $g(x_k)$ との損失 $f$
  4. 4勾配を求めるどちらへ直せば損失が減るか
  5. 5更新する$H$ を少し動かし,1へ戻る
図:NNの学習は「予測・比較・更新」を何度も繰り返すループである.
どうやってパラメータを直す?

これは「最小化問題」という情報系の古典的な問題であり,さまざまな技術が開発されている.

今回は一番シンプルな勾配法

$\left\{\begin{array}{rcl} H_{ \mbox{new}} & = & H + \Delta H, \cr \Delta H & = & - \gamma \, \mbox{ grad}_H \, f(H) . \end{array}\right.$

に沿ってパラメータ $H$ を改善していく手法でいこう.

back propagation と自動微分の背景,学習係数 $\gamma$ の決め方については,第3節「自動微分と学習係数」で説明する.

そしてこの修正プロセスを,データの個数だけ繰り返せば良いんじゃね? というのが今回の全体の大雑把な NN の使い方だ.

これでうまくいくのかって? まあ,まずはやってみるのが一番だ.

Step 4:Juliaで実装し,結果を確かめる

パッケージと問題の準備

まず、これまで Plots パッケージをインストールしていないならば次のようにしてインストールしておこう.

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

次に、今回 gradient を使いたいので,その機能が入っているパッケージ ForwardDiff もインストールしておこう. また,その時点での計算経過状況がわかる便利な ProgressMeter もインストールしておこう.

1using Pkg
2Pkg.add("ForwardDiff")
3Pkg.add("ProgressMeter")

あとは少しずつプログラムを作っていくだけだ. 次に、パッケージの利用宣言と,問題のパラメータを設定してしまおう.

1using LinearAlgebra
2using ForwardDiff: gradient  # 誤差の勾配を求めるのに使う
3
4n = 10 # 中間層のサイズ.これは大変小さいほうだ.

次に、対象関数を教師情報の代わりに作ってしまおう.

1predict(x) = round( sin(π*x)/√3 )

プロットして確認しておこう.

1using Plots
2
3X = 0:0.01:1.0
4plot(X, predict)

target

次に,NN を作ってしまおう.gradient を計算するライブラリの都合を考慮して,(データに多少無駄が入るが)次のような感じになる.

 1relu(x) = (x > 0.0) ? x : 0.0 # まず ReLU を実装して,
 2
 3# Neural Network. 
 4# 説明したとおり,パラメータをまとめて大きな行列としている.
 5# ここでは下記引数の M のことで,上図や全体では H のこと.
 6function nn(M, input) 
 7    C1 = view( M, :, 1 )
 8    b1 = view( M, :, 2 )
 9
10    C2 = view( M, :, 3:n+2 )
11    b2 = view( M, :, n+3 )
12
13    C3 = view( M, :, n+4 ) 
14    b3 = M[1, n+5]         
15    # 1 x 1 行列を参照すると行列のままなので,スカラーとしてコピー.
16
17    r1 = relu.( input * C1 + b1 ) # 中間層1 出力
18    r2 = relu.( C2 * r1 + b2 )    # 中間層2 出力
19    r3 = dot(C3, r2) + b3    # 出力
20
21    return r3
22
23end

誤差の計算は,単なる(スカラー値の)差の二乗にしておこう.

1# 損失関数.要は,出力の誤差.
2loss(x,y) = (x - y)^2

さてそろそろ計算そのものの準備に入ろう.まずは,肝心のパラメータ群の初期値を乱数で適当に生成する.

1# NN のパラメータ行列 H の初期値を乱数で生成.これを少しずつ修正する.
2H = rand(n,n+5) .- 0.5
10×15 Matrix{Float64}:
-0.191512    0.336439   0.211643   …   0.436184   -0.329562   -0.0760974
-0.079406    0.141096   0.495058      -0.149962   -0.199281    0.427208
 0.210504    0.421664   0.148529      -0.0633213  -0.0070928   0.109265
…略…
-0.0102435  -0.339593  -0.486077       0.394068   -0.426519   -0.169021
 0.21125     0.325412   0.230288      -0.260383   -0.137298   -0.156577
-0.242179    0.493012   0.0207173      0.154483    0.215999   -0.224654

このパラメータで関数 NN はどうなっているかをプロットして見ておこう.

1# 最初はこんな関数が実現されている(乱数によるのでやるたびに異なる)
2plot( X, x-> nn(H, x))

initial nn

乱数で作っているので当たり前だけど,ターゲット関数とはまるで異なるよな.

では,肝心の計算だ! かなり簡単だぞ.

 1f(H, x) = @inbounds loss(predict(x), nn(H,x))  
 2# gradient の為に,出力の誤差を関数の形に書く.
 3
 4using ProgressMeter
 5
 6@showprogress for i in 1:200000  # 200000個のデータがある想定で.
 7   x = rand()  # 適当に input x を選んで(本来は集めたデータの入力値)
 8   output = f(H,x) # 出力のズレ. 小さくしたい.
 9
10   grad_f = @inbounds gradient(H -> f(H,x), H) 
11   # 誤差の H に対する勾配
12
13   grad_size = norm(grad_f)^2 # 勾配の大きさ
14
15   if grad_size < 0.05 # 学習係数の調整(数字は適当)
16        γ = 0.1
17    else
18        γ = output / grad_size
19    end
20
21    H +=  -γ * grad_f # パラメータ H を修正.
22end
Progress: 100%|███████████████████████████████| Time: 0:00:34
実際の問題では

ここでは教師データとして関数 predict を直接使っている.本来は,観測や実験などで得た有限個のデータを用意し,そこから学習させることになる.

さて, 学習がうまくいったのかどうか,学習して作り出した近似関数をグラフでチェックしよう.

1plot( X, x-> nn(H, x))

final nn

目標 学習対象となる区分的な関数
学習前 乱数で初期化した学習前のNNの出力
学習後 学習後に目標へ近づいたNNの出力
図:同じNNでも,パラメータ $H$ を更新すると,出力の形が目標へ近づく.

おお! なんかうまくいったことがわかる. なんにも考えないでループを回せばうまくいくんだから,機械学習というのは確かに「良い」方法と言えるのでは,と思うのも無理のないところだ2

ここまでの要点NNを関数として定義する出力と正解のずれを損失にする損失の勾配を使ってパラメータを更新する

3. 自動微分と学習係数

何層もあるような neural network で作った関数 $f$ に対して $\mbox{ grad}_H \, f(H)$ をどうやって計算したら良いのか? という問題には,back propagation と呼ばれる技術が「再発見」されたことである意味解決した. ちなみにこの back propagation は自動微分と呼ばれる技術の一部で応用数学では広く知られたものなので,情報系研究者の勉強不足と指摘されてもしかたない. ただし,これだけでは「10層以上の深い層を持つ neural network」の改善がうまくいかず,しばらくこの分野の研究は停滞した.その後,特別な構造のneural network を作ったり ReLU 活性化関数を使うなどの工夫を重ねることで改善がなされて再びこの分野が脚光を浴び,現状に至っている.

補足:自動微分の再発見 実は自動微分は「各分野で再発見されることで有名な技術」で,講演時に「20回以上再発見されている」と言っている人が居た. この回数が本当なのかはともかく,再発見されることが多いのは確かだ.自動微分そのものについては,例えば Griewank, Andreas. "On automatic differentiation." Mathematical Programming: recent developments and applications 6.6 (1989): 83-107 が,図もあって分かりやすい.

さて,勾配法の式に現れる係数 $\gamma > 0$ は,学習係数と呼ばれるパラメータに対応している.スカラー関数 $f$ に対して数値計算的には

\[ \gamma = \frac{f(H)}{ \left\| \mbox{grad}_H f(H) \right\|^2 } \]

という感じに計算すると妥当な感じだ. ただし,この計算式をそのまま使うと $\mbox{grad}_H f(H) \cong \boldsymbol{0}$ の時に不安定になるので, $\mbox{grad}_H f(H)$ がある程度小さい時はこの $\gamma$ を適当な数字に決めてしまうなどの対応をしておくと良い.

この式が得られる理由 実現を期待している $f(H+\Delta H) \cong 0$ の左辺を Taylor 展開した1次近似式 $f(H) + \mbox{grad}_H f(H) \cdot \Delta H\cong 0$ の $\Delta H$ に,勾配法が提案する $\Delta H = - \gamma \, \mbox{ grad}_H f(H)$ を代入すると得られる.

4. アヤメの分類に適用する

ヒオウギアヤメ(Iris setosa)の花
この画像ファイルクリエイティブ・コモンズ 表示-継承 3.0 非移植ライセンスのもとに利用を許諾されています。

例えば,がく片の長さと幅,花びらの長さと幅という 4つの数字と「その花の種類(3種類: ヒオウギアヤメ, ブルーフラッグ, バージニカ)」をデータ化した UCI のアヤメのデータ をもとに,その4つの数字だけから花の種類を当てる問題を考えよう.

このデータだけではなく, UCI の Machine Learning Repository には機械学習に使えそうな多くの「データ」があるので見てみると良いだろう. ちなみにこのアヤメのデータは現時点だと「最も人気のある」ものだね.

上のアヤメのデータだが,直接自分でファイルをダウンロードしてそのファイルを読み込む形で Julia に入力してもよいが,これは有名なデータなので RDatasetsMLDatasets といったいろいろなパッケージがこのデータを自動でダウンロードしてくれる. 今回は下記に示すように, MLDatasets パッケージをインストールしてそこから読み込もう.

パッケージ構成について MLDatasets パッケージの機能は以前は Flux パッケージに含まれていたものだ.今でも一応含まれているが,いずれ削除されるだろうから今から分けておくのがベターだね.

なお,データの使い方等は MLDatasets.jl を見ると,このアヤメ(iris)のデータをどう使えばよいかが書いてある.

Step 5:データを読み込み,観察する

まず,MLDatasets パッケージをインストールしていない人はインストールしよう.

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

それから,「表で表されるような」データを管理するのに適したパッケージ DataFrames と 機械学習フレームワークである Flux パッケージもインストールしよう. この中にある機能や関数をいくつか使うのだ.

第13回との関係

ここではNN本体を引き続き自分で定義し,Fluxからは gradientcrossentropysoftmax などの部品を借りる.Fluxを使ってモデルや学習処理をより体系的に組み立てる方法は,第13回で詳しく扱う予定だ.

1Pkg.add("DataFrames")
2Pkg.add("Flux")

次に,使いそうなパッケージの利用宣言だ.

1using LinearAlgebra
2using DataFrames
3using Flux 
4# Flux にも gradient (同じ機能)があるので ForwardDiff は不要.
5using MLDatasets
6using Statistics
7using ProgressMeter

そして,アヤメのデータを読み込もう.

1iris = Iris( as_df = false )
2# アヤメのデータをダウンロードし,かつ,iris という変数に入れる.
3# 今回は単なる配列形式でのダウンロードで良いだろう.

データに初めてアクセスする際に,ネットワーク経由でデータが(原理的には)一回だけダウンロードされる. その際, おそらく次のように「データセットをダウンロードしますか?」と聞いてくる.

  環境によっては,このメッセージが返ってくるまで数分かかることもあるので,じっと待とう!

このメッセージが出てきたら,その中に含まれる stdin> の右側にある入力欄に y (とEnterキーを)を入力しよう

This program has requested access to the data dependency Iris.
which is not currently installed. It can be installed automatically, and you will not see this message again.

Dataset: The Iris dataset
Website: https://archive.ics.uci.edu/ml/datasets/Iris

Do you want to download the dataset from ["https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data"] to "c:\julia\PKG\v1.10\scratchspaces\124859b0-ceae-595e-8997-d05f6a7a8dfe\datadeps\Iris"?
[y/n]
stdin> □□□□□□□□□

するとダウンロードが始まり,完了すると次のような出力が出るはずだ.

dataset Iris:
  metadata   =>    Dict{String, Any} with 4 entries
  features   =>    4×150 Matrix{Float64}
  targets    =>    1×150 Matrix{InlineStrings.String15}
  dataframe  =>    nothing

次に,使いやすいようにデータを分離して変数に格納しておこう.

1features_raw = iris.features
2labels_raw   = reshape(iris.targets, :, 1)
3# 花の4つの数字情報と,花の種類を配列に入れる.
4# 扱いやすいよう,種類の配列を転置しておく.
150×1 Matrix{InlineStrings.String15}:
 "Iris-setosa"
 "Iris-setosa"
 "Iris-setosa"
  …略…

2次元配列 features_raw の i列目に i番目の花の 4つの数字データが入り,1次元配列 labels_raw[i] にそのアヤメの種類が入るという格好だ.

ちなみに,150個あるデータのうち,最初の50個が "iris setosa"(ヒオウギアヤメ)のもので, 次の50個が "iris versicolor"(ブルーフラッグ アヤメ), 最後の50個が "iris virginica"(バージニカ アヤメ)のものである.

データをグラフで事前確認する

とりあえずこれらのデータで花の種類を分類できそうかどうか,次のようにしてちょっとグラフで見てみよう. ただし,われわれは3次元グラフまでしか理解できないので,とりあえず 3つの数字(がくの長さ,幅,花びらの長さ)でプロットしてみる.

まず,面倒だが,データを花の種類ごとに分けてみる.

 1X1 = features_raw[1, 1:50]
 2Y1 = features_raw[2, 1:50]
 3Z1 = features_raw[3, 1:50]
 4W1 = features_raw[4, 1:50]
 5
 6X2 = features_raw[1, 51:100]
 7Y2 = features_raw[2, 51:100]
 8Z2 = features_raw[3, 51:100]
 9W2 = features_raw[4, 51:100]
10
11X3 = features_raw[1, 101:150]
12Y3 = features_raw[2, 101:150]
13Z3 = features_raw[3, 101:150]
14W3 = features_raw[4, 101:150]

そしてこれを以下のようにプロットしてみよう.

1using Plots
2
3default( markersize = 4, camera = (30,10) )
4
5scatter(  X1, Y1, Z1, xaxis=("sepal length (cm)"), yaxis=("sepal width (cm)"), zaxis=("petal length (cm)"), label = "Iris setosa" )
6scatter!( X2, Y2, Z2, label = "Iris versicolor" )
7scatter!( X3, Y3, Z3, label = "Iris virginica" )

アヤメ3種類の特徴量を3次元散布図で比較したグラフ

ふむ,目で見ても結構別れているので,NN に学習させてもうまくいくと期待して良さそうな気がするね.

Step 6:分類ラベルを数値に変換する

アヤメの種類が文字列のままだと使いにくいので,次の形に変換しておこう.

 1# 名前をもらって,どの分類かに書き換える関数.
 2function RawToNum(str)
 3    if str == "Iris-setosa"
 4        return [ 1.0, 0, 0 ]
 5    elseif str == "Iris-versicolor"
 6        return [ 0, 1.0, 0 ]
 7    else
 8        return [ 0, 0, 1.0 ]
 9    end
10end
11
12# この labels にベクトルの形で分類データが入る.
13labels = RawToNum.(labels_raw)
150×1 Matrix{Vector{Float64}}:
 [1.0, 0.0, 0.0]
 [1.0, 0.0, 0.0]
 [1.0, 0.0, 0.0]
 ⋮
 [0.0, 0.0, 1.0]
 [0.0, 0.0, 1.0]
 [0.0, 0.0, 1.0]

なぜこんな形で分類結果を表記するのかは,NN の出力と合わせるためだ.

あとでわかってくるが,これは確率分布表記をしていると思えば良い. たとえば最初の [1.0, 0.0, 0.0] というのはこのデータがヒオウギアヤメである確率が 1.0, ブルーフラッグである確率が 0, バージニカである確率が 0 だ,という意味だ.

ヒオウギアヤメ1.00.00.0
ブルーフラッグ0.01.00.0
バージニカ0.00.01.0
図:3種類のどれが正解かを,3成分のベクトルで表す(one-hot表現).

未知データとして検証用データを取り分ける

さて次に,このデータのうち一部を「学習後の NN の能力チェック用」に分離してとっておき,それには NN の学習が済むまで参照しないようにしよう. 全部でたった 150個しかデータがないのでもったいないが,これを別にしておかないと能力チェックが困難になってしまう.

全150個各種類50個ずつ
学習用 135個各種類45個・90% 検証用 15個各種類5個・10%
図:検証用の15個は学習中に見せない.だから「初めて見るデータ」への性能を確かめられる.
1# 学習後のチェックに使うデータをとっておく.学習には用いない.
2# 3種類の花のデータを5個ずつ,チェック用に抜き出しておく.
3
4toCheckIDs = vcat( 46:50, 96:100, 146:150 )  
5features_raw_toCheck = features_raw[:, toCheckIDs ]
6labels_toCheck = labels[toCheckIDs]
15-element Vector{Vector{Float64}}:
 [1.0, 0.0, 0.0]
 [1.0, 0.0, 0.0]
 ⋮

残りは学習に使えるデータだ.

1# 学習に使うデータ.
2
3SampleIDs = setdiff( 1:150, toCheckIDs )
4features_raw_sample = features_raw[:, SampleIDs ]
5labels_sample = labels[SampleIDs]
135-element Vector{Vector{Float64}}:
 [1.0, 0.0, 0.0]
 [1.0, 0.0, 0.0]
 ⋮

入力値の大きさを揃える

次に,花のがく片の長さ…等の数字を正規化しよう. それぞれの数字の大きさ等が異なるのに NN で混ぜるのは NN の性能を下げるだけなので,こうしておこう.

1# 入力データの数字的な偏りをなるべくなくしておく.
2# ただし,事前に使える平均,偏差はサンプル値からしかとれない.
3
4Std = [ std( features_raw_sample[i, :] ) for i in 1:4 ]
5Mean = [ mean( features_raw_sample[i, :] ) for i in 1:4 ]
6
7features_toCheck = (features_raw_toCheck .- Mean) ./ Std
8features_sample  = (features_raw_sample  .- Mean) ./ Std

Step 7:分類用NNを作る

さて,肝心の NN そのものを定義しよう. 先の例題よりずっと難しいはずの問題だから,少し n を大きくし,また 中間層も増やそう.

 1# NN の定義.少し層を増やした.まあこれでも小さい方だな.
 2
 3n = 20
 4
 5function nn(M, input) 
 6    Ci = view( M, :, 1:4 )
 7    bi = view( M, :, 5 )
 8
 9    C1 = view( M, :, 6:n+5 )
10    b1 = view( M, :, n+6 )
11
12    C2 = view( M, :, n+7:2n+6 )
13    b2 = view( M, :, 2n+7 )
14
15    C3 = view( M, :, 2n+8:3n+7 )
16    b3 = view( M, :, 3n+8 )
17
18    C4 = view( M, :, 3n+9:4n+8 )
19    b4 = view( M, :, 4n+9 )
20
21    C5 = view( M, :, 4n+10:5n+9 )
22    b5 = view( M, :, 5n+10 )
23
24    Co = ( view( M, :, 5n+11:5n+13 ) )' # 転置
25    bo = view( M, 1:3, 5n+14 )
26
27    r1 =  sigmoid.( Ci * input + bi ) # 入力値処理
28
29    r2 = sigmoid.( C1 * r1 + b1 ) 
30    r3 = sigmoid.( C2 * r2 + b2 ) # 中間層.ReLU や TanhShrink だとうまくいかない.
31    r4 = sigmoid.( C3 * r3 + b3 )
32    r5 = sigmoid.( C4 * r4 + b4 )
33    r6 = sigmoid.( C5 * r5 + b5 )
34
35    output = softmax( Co * r6 + bo ) # 出力値処理
36
37    return output
38end

あとはこの NN のパラメータの初期値を用意すれば良い.

1# NN のパラメータ行列 H の初期値を乱数で生成.これを少しずつ修正する.
2H = 10.0 * ( rand(n,5n+14) .- 0.5 )
20×114 Matrix{Float64}:
  3.48686    2.3188    -3.89427   …   1.55079     0.318162  -2.86661
   1.51789    1.12628   -4.03771       0.379845    1.5239     1.45174
  -3.32401    1.14799   -3.27312       3.24742    -3.83468    0.361319
   2.26955    0.518749   3.30638      -2.05172     4.13538    2.21918
  -2.66358    3.05112   -4.17199      -4.80824    -2.95954    1.99533
  -3.68804    3.13945   -3.80117   …   4.06731    -1.69564    2.77939
  …略…
  -1.34464    3.50084   -1.07792   …   0.0815698   0.931987   0.275821
  -1.18023   -4.90877    1.27035       2.15832    -3.96766   -0.694207
   2.09718    4.40493    1.61797       3.61398     1.57454   -0.368338
   1.16779   -2.33449   -0.847865      0.626561    1.42367    1.16325
  -2.30204   -0.939517  -3.5287        3.39793     4.78647    2.55517

損失関数を定義する

あとは誤差(損失関数)を定義しないといけないね.

1# 結果の誤差は,分類問題での定番である crossentropy にて.
2loss( output, v_true ) = Flux.crossentropy( output, v_true )

Step 8:NNを学習させる

少し強引だが,下記のように学習させてしまおう.

 1# NN の学習
 2
 3# 誤差の式を簡単に書いておいて…
 4g(H, i) = loss( nn(H, features_sample[:, i]), labels_sample[i] ) 
 5
 6itr = 500
 7datasize = size(labels_sample)[1]
 8
 9@showprogress for i in 1:(itr * datasize)
10    
11   num = rand(1:datasize) # データの順序に依存しないように乱数で
12
13   grad_f = Flux.gradient(M -> g(M, num), H)[1] 
14   # 誤差の H に対する勾配. 
15   # 今回は Flux の gradient を使う(機能は同じ.こちらは最後に [1] をつけて結果を取り出すところが違う)
16
17   grad_size = norm(grad_f)^2 # 勾配の大きさ
18
19   if grad_size < 0.01 # 学習係数の調整(数字は適当)
20        γ = 0.1
21    else
22        γ = g(H,num) / grad_size
23    end
24
25    H +=  -γ * grad_f # パラメータ H を修正.
26end
Progress: 100%|███████████████████████████████| Time: 0:00:24

Step 9:学習結果を検証する

まず既知のデータで確かめる

うまく学習できたか,少し見てみよう. まずはサンプルデータの1番目だ.

1# チェックのための簡易表記
2nn_sample(i) = nn( H, features_sample[:, i] )
3
4nn_sample(1)
3-element Vector{Float64}:
 0.9999760809896794
 1.0352755316079227e-6
 2.2883734788971494e-5

この数字は,NN が「1番目のデータはどの分類のアヤメであるかの確率出力を出した」と思えば良い. だからこの場合はこのデータは 1種類目のアヤメ,つまりヒオウギアヤメである確率が高いと言っているわけだ.

実際は

1labels_sample[1]
3-element Vector{Float64}:
 1.0
 0.0
 0.0

であるので,確かにそうであることがわかる.

あと 2点ほど手動でチェックしてみよう.次に,2種類目のアヤメのデータであるはずの 70番目のデータに対する NN の出力を見ると,

1nn_sample(70)
3-element Vector{Float64}:
 4.729875190326946e-7
 0.9999387870065163
 6.07400059647937e-5

となっており,やはりこの場合も正しく学習できていることがわかる.

同様に,3種類目のアヤメのデータである 130番目のデータを NN に入力すると,

1nn_sample(130)
3-element Vector{Float64}:
 7.677247257844097e-6
 9.108595051576306e-5
 0.9999012368022264

となり,このデータに対してもやはり正しく学習できていることが確認できる.

この問題設定だと,おそらくほぼすべてのデータに対して NN が正しく学習できているだろう. 初期値が乱数によるので,もちろん人によって結果は微妙に異なるが.

これは次のように確かめられる. まず,NN の確率出力をもらってどう判断するか,という操作を次のような関数にしよう.

 1# 出力値による判断を明確にする関数
 2function decision(x)
 3  v = similar(x)
 4  max_i = argmax(x)
 5
 6  for i in 1:size(x)[1]
 7    if i == max_i
 8      v[i] = 1.0
 9    else
10      v[i] = 0.0
11    end
12  end
13
14  return v
15end

こうしておいて,学習に使ったサンプルデータと,NN の出力による判断値の違いを次のようにまとめる.

1error_sample = labels_sample - decision.( nn_sample.(1:135) )
135-element Vector{Vector{Float64}}:
 [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]

たしかに学習はほとんどうまくいっているように見える.ミスが少ないことを確かめておこう.

1sum( norm.(error_sample) )
0.0

うむ,学習に使ったデータ, つまり,既知のデータに対しては(教員のこのケースだと) 学習後の NN は 100% うまく判定できることがこれで確認できた.

次に未知のデータで確かめる

では,肝心の,「初めてみるデータに対して, NN は正しく判定できるか」をチェックしよう. まずは手動でいくつか確認してみよう.

1# 初めて見るデータに対しての NN の判断
2nn_toCheck(i) = nn( H, features_toCheck[:, i])
3
4nn_toCheck(1)
3-element Vector{Float64}:
 0.9997765544219128
 2.0048155539720798e-7
 0.00022324509653181044

これは本来の 46番目のデータ(花は一種類目であるヒオウギアヤメ)に対する NN の判定だ. ふむ,確かに正しいぞ.

ほかも見てみよう.

1nn_toCheck(6)
3-element Vector{Float64}:
 4.565957981689223e-7
 0.999950768484614
 4.877491958804138e-5

そしてこれは本来の 96番目のデータ(花は二種類目)に対する NN の判定だ. これも確かに正しい.

次はどうかな.

1nn_toCheck(11)
3-element Vector{Float64}:
 1.1539563189818402e-5
 7.649254704851003e-5
 0.9999119678897618

これは本来の 146番目のデータ(花は三種類目)に対する NN の判定だ. そしてこれも確かに正しい.

では,NN が学習過程で触ることのなかった未知の 15個のデータに対して,一気にその判定結果をチェックしよう.

1error_toCheck = labels_toCheck - decision.( nn_toCheck.(1:15) )
15-element Vector{Vector{Float64}}:
 [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.0, 1.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]

おお. 判定ミスはたったの一箇所だ. 未知のデータに対しては,14/15 = 93.3% の正解率というところか. こんな簡単な作りの NN に 135個のデータを与えて学習させることでこの判断正解率なので,なかなか良いと考えて良さそうだ. もっとたくさんデータが与えて学習させれば,よりしっかりとした NN を作ることができるだろうと期待できる.

学習に使ったデータ100%135 / 135
未知の検証データ93.3%14 / 15
図:既知データでの高い正解率だけでは不十分である.未知データの結果と比べて,汎化性能や過学習の可能性を考える.

というわけで,今回は十分に良い機械学習ができたと言えよう. ただし,過学習(過学習についてはレポートにて)の恐れはあるのでそこは調べておいて,機械学習の現場では忘れないようにしよう.

今回つかんでおきたい一本の流れ

  1. データ入力と,期待する出力を用意する
  2. モデル重み・バイアス・活性化関数でNNを作る
  3. 損失NNの出力と正解のずれを数値にする
  4. 学習勾配を使って損失が減る方向へパラメータを動かす
  5. 検証学習に使っていないデータでも性能を確かめる

第13回では,この流れをFluxのモデル・最適化機能を使って,より見通しよく,発展的な問題へ適用する予定だ.

発展・補足編

コラム:学習後のパラメータを眺める

修正後のパラメータ群の数字を以下のようにして見てみると,

1H
10×15 Matrix{Float64}:
 -1.78223      1.24544      0.0178613  …   0.455777    1.9399      0.0819192
 -2.77921      0.974607     0.574123      -0.687438    0.657092    0.427208
 -1.21902      0.427793     0.157514      -0.698706   -0.256942    0.109265
 -2.78219      0.973285    -0.197257      -0.281036   -0.469055   -0.18677
 -0.00698442  -0.00986948  -0.414849      -0.321732    0.175606    0.489055
 -2.49921      1.7486      -0.141537   …  -0.310385    0.206846    0.259345
 -0.00676826  -0.00744929  -0.333699      -0.499914   -0.465664   -0.218318
 -0.0102435   -0.339593    -1.60041        0.414517   -2.36933    -0.169021
  0.141009    -0.0435219    0.415448      -1.12365    -0.426072   -0.156577
 -1.69088      1.17848      0.012796       0.0514368  -0.0514553  -0.224654

という感じだ. よーく見ると,たとえば $\boldsymbol{C}_1$ (上の第1列相当), $\boldsymbol{b}_1$ (上の第2列相当) は第 1, 2, 3, 4, 6, 10 成分が比較的大きいので,それらの成分で最初の中間層の出力 $\boldsymbol{r}^{(1)}$ の性質がおおよそ見えるのではないか,などということが考察される.そのあたりを追いかけてみるのも面白いだろう.

レポート No.12

提出ファイルについての注意

近年はセキュリティ上の懸念から,実行形式のプログラムなどをメールに添付すると,受信側サーバがメールそのものを拒絶することがある. そういう問題を避けるため,レポートをファイルで提出するときは,実行形式などの危険視されやすい形式を避けよう.

要するに,レポートはPDFファイルにして送るのが良い,と思っておこう.

以下の課題について,自らの将来のスキルアップにつながるように調査と考察を行い,
     学籍番号-氏名-12.pdf
というファイルとしてレポートを作成し、 webフォーム から教官宛に提出しよう.

なお,レポートを $\TeX$ 等で作成したものを印刷した「紙媒体」を教官に直接手渡す形で提出してもよいが、物質によるレポート提出は常に破損や紛失の可能性があるのであまりお勧めはしないぞ.

課題

  1. 最初のターゲット問題の2次元バージョンに取り組んでみよう.

    具体的には,図
    の中で定義される関数 $S_c$ に従ってデータ $D = \{ \boldsymbol{x}_k, \boldsymbol{y}_k = S_c(\boldsymbol{x}_k) \}_{k=1}^N$ が $N \cong 10,000$ 程度で得られているとする($N$ は好きに設定して良い).

    関数 $S_c$ が未知である想定のもとに,今回の授業と同様に NN を構成し,データ $D$ を使ってパラメータを学習させて,NN が関数 $S_c$ を近似的に構成するようにしてみよう.

    注: この問題は,2つのパラメータ( $\Omega$ 上の座標 $(x,y)$ のこと)から 2種類の分類(0 か 1 か)ができるように,という機械学習を行うことに相当する.

  2. 第13回の先取りとして,資料入門2 機械学習用フレームワークの利用を読み,package Flux を一通り使ってみてその様子を報告せよ.

  3. 「過学習」について,文献などを用いて調べ,自分なりに対応策を考えてみよう.考えた対応策がうまくいくかは,細かい工夫によって随分変わったりするので,ここではそこまで実現可能性や効率等についてあまり考えなくて良い.

  1. 頭脳労働の多くはこうした能力に大いに依存している.例えば翻訳,プログラミング,検査,その他,具体的な例はいくらでもあるだろう. ↩︎

  2. もちろん,実際の問題はデータ集めからして大変なわけで,そうそうお気楽に考えてはいかんが. ↩︎