第3回 なぜニューラルネットワークで学習できるのか?

  1. ニューラルネットワークとは何か?
  2. ニューラルネットワーク学習のしくみ
  3. ニューラルネットワークを実装する
  4. まとめ

1. ニューラルネットワークとは何か?

1.1. ニューラルネットワークの定義

ニューラルネットワーク (neural network) とは、 モデルとして以下のような架空の装置を使った 教師つき機械学習の方式である:

ニューロン (ノード) 入力1 入力2 入力3 入力4 出力1 出力2

上の図の ○ 部分は人間の神経回路を模したニューロン (neuron) という架空の装置である。 (機械学習の文脈では、実際の生物との関連はあまり考えないので、 ニューロンではなくノード (node) と呼ぶことが多い。) ニューラルネットワークの入力と出力は、以下のように定義される:

入力と出力は、0〜1 の範囲で数値化できるものであればなんでもよい (実際には、入力はこの範囲内でなくてもよいが、ここでは簡単のため 0〜1 を仮定する)。 したがって、画像のようなものも「各ピクセルの RGB値」を個別の入力と考えれば、 「ある大きさの画像 = ある個数の数値からなるベクトル」と定義できるから、 入力として使えるわけである。いっぽう、出力が画像であってもかまわない (実際そういうニューラルネットワークもある)。 他の機械学習と同様に、ニューラルネットワークも訓練データを与え、 入力と出力の相関 (関数) を学習させる。

ニューラルネットワークの各ノードは、非常に単純な機械である。 これは得られた入力を重みづけて合計し、その値がある特定の しきい値 (threshold) を超えたら 1 を、 そうでなければ 0 を出力する (実際はもうすこし複雑だが、説明は後述する)。

百聞は一見にしかずということで、以下のデモを見てほしい。 これは 3層のニューラルネットワーク (576ノード + 100ノード + 10ノード) を 使って、手書き数字認識をおこなうものである。各マスは1つのノードに対応している。 左側の欄に数字を描くと、右側に認識結果が表示される。 数字は 24×24ピクセルで構成されており、これが 24×24=576ノードの入力となっている。 中間の各マスにマウスカーソルを置くと、ノード間の接続の強さが表示される。 これほど単純な仕組みでかくも複雑な処理が実現できるのは、驚くべきことである。

  Answer:  
ニューラルネットワークを使った手書き数字認識 (MNIST) のデモ
演習3-1. 入力と出力を定義する

ニューラルネットワークを使って以下の関数を学習したい。 「入力」と「出力」はどのような量になりうるか。

  1. 過去10日間の気温を入力し、明日の気温を予想する関数。
  2. 決められた大きさの画像を入力し、そこにネコが写っているかどうかを判定する関数。
  3. 決められた大きさの画像を入力し、そこに写っている動物の種類が 「ネコ」「イヌ」「ウシ」「ウマ」のどれかを判定する関数。

1.2. ニューラルネットワークはなぜこれほど流行しているのか?

今日、ニューラルネットワークが使われている大きな理由は 「汎用性」および 「スケーラビリティの良さ」である。 つまり:

この違いは先に紹介した決定木と比べると顕著である。 決定木では、入力パラメータの数はそれほど多くはできない (せいぜい数十個) し、 一般的に出力は「ひとつの値」しか得られない。 また、決定木では生成される木の複雑さには限りがある。 これに対してニューラルネットワークは数百〜数万個の入力をとることができ、 モデルが巨大化しても、現実的な時間内で学習可能なことが多い。 なぜこのような特徴が生じるのか、これを次に説明しよう。

ニューラルネットワークに要求される条件

じつはニューラルネットワークを使うためには、以下のことが必要である:

  1. 学習の度合いが「損失 (loss)」として数値化できること。
  2. 損失を表す関数が微分可能 (differentiable) であること。

損失とは「現在の学習の悪さ」を表す数値である。 損失は小さいほどよく、損失 0 が最小である (負の数はとらないものとする)。 なぜ「良さ」ではなく「悪さ」を表すのかというと、 「理想の学習状態 = 損失 0」と定義すると理解しやすいからである。 もし尺度を逆にして「良さ」を定義しようとすると、 その最高値がいくつなのかはっきりしないし、 たとえ「良さ 1.0 = 最高」と定義したとしても、 「良さ 0 (最悪の状態)」というものがいったいどんな状態なのか、想像しにくい。 一般に、良いものは一意に定義可能だが、 悪いものには無数の「悪くなりかた」があるからである。

演習3-2. 損失を定義する

以下の学習タスクにおける「損失」を定義せよ (微分可能かどうかは考えなくてよい)。 損失が 0 の状態とはどんな状態か? 損失が大きい (悪い) 状態とは、どんな状態か?

  1. 過去10日間の気温を入力し、明日の気温を予想するタスク。
  2. 画像にネコが何匹含まれているかどうかを判定するタスク。
  3. 画像に含まれるネコの大きさを判定するタスク。

ニューラルネットワークの欠点

このようにもてはやされているニューラルネットワークであるが、 当然ながら欠点も存在する。とくに大きな欠点は 「ブラックボックス性」である。 伝統的な機械学習の方法 (決定木や回帰など) と比べると、ニューラルネットワークは 数学的にモデル化するには複雑すぎ、ふるまいが予測不可能なことが多い。 とくに大規模なニューラルネットワークになると、そもそも 「なぜ動くのか」を開発者自身もはっきり説明できないことが多く、 もっぱら経験による推測に頼ることになる。あとで説明するように 「とりあえず実験すると性能が上がるが、なぜかはよくわからない」という テクニックも数多く存在する。近年 「説明可能なニューラルネットワーク (explainable neural network)」という 分野で活発な研究がおこなわれているが、 まだブレイクスルーと言える結果は出ていない。 率直にいえば、 ニューラルネットワークなぞ使わずにすめば、使わないほうがよいのである。

1.3. 簡単なニューラルネットワーク

たとえば以下のような 1つのノードだけをもつニューラルネットワークを考えよう。 これは入力 x1, x2, x3 を重みづけて足し、 その値があるしきい値 C を超えていれば 1 を、そうでなければ 0 を 出力するものとする。式で書くと、以下のようになる:

ここでは入力 x1, x2, x3 に対応する 重み (weight) を、 それぞれ w1, w2, w3 と表している。 さらに簡単のため、しきい値をつねに 0 として、 かわりに左辺に バイアス (bias) を加える方式に変形する:

ここで、バイアスは b で表している。 なお、重み・バイアスは、負の値になることもありうる。 この装置を図にすると、以下のようになる:

ノード x1 x2 x3 w1 w2 w3 b y

以上の処理を Python で書いてみると、以下のようなコードになる:

if w1*x1 + w2*x2 + w3*x3 + b < 0:
    y = 0
else:  # w1*x1 + w2*x2 + w3*x3 + b >= 0:
    y = 1
演習3-3. ニューラルネットワークを手計算する

上の例で (w1, w2, w3, b) = (1.0, 0.5, -2.0, 1.0) とするとき、 以下の問に答えよ:

実際には、上の定義は正確ではない。 あとで説明するように、ニューラルネットワークの出力は 微分可能でなければならないのである。 これを解決するために、if文のかわりに 活性化関数 (activation function) というものを使う。 活性化関数とは、以下のような性質をみたす微分可能な関数のことである:

活性化関数にはさまざまな種類があるが、よく使われるのは シグモイド関数 (sigmoid function): σ(x) である。 これは以下のような性質をもつ:

+1 0 x
σ(x) = 1 / (1 + e-x)

実際にはシグモイド関数は、x=0 以上になったからといって、 すぐに y=0 → 1 に変化したりしない。しかしニューラルネットワークにおいては 関数が微分可能であることが重要なので、このような関数を使っている。 シグモイド関数を使うと、上の if文とほぼ同じ結果が得られる。 すでに関数 sigmoid が定義されているとして、 上のコードを書き直してみると:

y = sigmoid(w1*x1 + w2*x2 + w3*x3 + b)
これだけなのである。
演習3-4. ニューラルネットワークをPythonで計算する

(w1, w2, w3, b) = (1.0, 0.5, -2.0, 1.0) とするとき、 上の Python の式を使って、以下の y を求めよ。

ここで、関数 sigmoid は以下のように定義するものとする:

from math import exp
def sigmoid(x):
    return 1 / (1 + exp(-x))

2. ニューラルネットワーク学習のしくみ

さて、上で定義した単一のノードからなるニューラルネットワーク (まだノードが1つしかないので「ネットワーク」とは呼べないが) を 実際に学習 (訓練) させることを考える。 上の例では、ネットワークの「機能」は w1, w2, w3, b という 4つのパラメータのみによって決まっていた。 繰り返すが、機械学習とは「探索」であるから、ここでの目的は 「与えられた x1, x2, x3 に対して、 望みの出力 y が得られるような 4つの値 (w1, w2, w3 および b)」を 探せばよいことになる。 これがニューラルネットワークの学習である。

2.1. 最適化問題と勾配降下法

さて、実際の探索に入るまえに、 最適化問題 (optimization problem) というものを説明する。 これは機械学習 (およびその他の探索問題) でよく使われるアイデアなので ぜひとも覚えてほしい。最適化問題を一言でいうと:

ある与えられた関数に対して、その出力値が最小 (または最大) になるような入力値を見つけること。
例として、以下の簡単な最適化問題をやってみよう。 下の10個のスライダーを動かして、損失 (loss) がなるべく小さくなるように してほしい。最適な解を見つければ、損失は 0 になるはずである:
損失 (ゼロが目標)

上の例を実際にやってみると、なかなか一筋縄ではいかないことがわかる。 各スライダーは相互に影響しあっているため、あるスライダーを 動かしたときの変化が、別のスライダーによって変わることがあるためだ。

では、まったく同じ問題の「ヒントつきのバージョン」を紹介する。 これは各スライダーの下に、それをどちらの方向に動かせば損失が下がるかという ヒントが矢印「↓」「↑」で表示される (矢印が表示されない場合は、 スライダーをどちらに動かしても損失が増えてしまうことを示す)。

損失 (ゼロが目標)

ヒントを使うと、損失をかなり簡単に下げることができる。 (コツは、矢印をたよりに「複数のスライダーを少しずつ動かす」ことである。) 人によっては、完全に 0 にできるポイントを見つけられたかもしれない。 各スライダーは 11段階 (0〜10) あるので、 もしヒントがなければ、最適な解を見つけるのに 1110 = 25,937,424,601通りの組み合せを試す必要があった。 このように、ヒントを使って入力値を少しずつ変化させ、 最適解を見つける方法を 勾配降下法 (gradient descent) という。

もうすこし抽象的に考えると、最適化問題とは、たとえば以下のような 曲線で表された関数に対して、もっとも低い (あるいは高い) 点を 見つけることに相当する。このとき、関数の全体像はわからないが、 現在いる地点 x0 の近傍が見えており、この地点の微分が計算できたとする。 現在の入力に対する微分を計算したものを 勾配 (gradient) という。 実際には、入力は 10個の値から成り立つ「10要素のベクトル」とみなせるため、 その勾配も 10要素のベクトルとなる。 上でヒントとして表示された矢印「↑」「↓」は各パラメータの勾配の向きを表しており、 これに従ってスライダーを動かせば最適解に近づけるわけである。

全体像は不明 勾配 近傍だけ が見える 0 x0

ニューラルネットワークの学習における根本的な戦略は、 この勾配降下法を使うことである。 これがニューラルネットワークの「究極の原理」といってもよい。 最初にニューラルネットワークの出力 (および損失関数) が 「微分可能でなければならない」としたのは、このためである。 勾配降下法では、入力を少しずつ変化させながら解に近づいていく。 勾配降下法は必ずしも完璧な解に到達できる保証があるわけではないが、 「十分に実用的な結果」が得られるまで処理を繰り返せばよく、 この点ではコンピュータで実行させるのに向いている処理であるといえる。

では次に、勾配降下法を使って 実際のニューラルネットワークを学習させる方法を説明する。

2.2. 勾配降下法を使う

ニューラルネットワークの学習にあたっては、 まず損失関数 (loss function) というものを定義しなければならない。 1.3 の例にあるニューラルネットワークは、 以下のように定義されていた (σ はシグモイド関数をあらわす) :

y = σ(w1·x1 + w2·x2 + w3·x3 + b)

ここで学習したい正解値を y0 とすると、 損失関数 L は「出力 y と y0 の差」として定義できる:

L = (y - y0)2
ここで二乗しているのは、正負を無視するためである。 絶対値を使う手もあるが、二乗のほうが微分しやすいのでこうしている。 実際の機械学習では、y0 はひとつではなく、 訓練データの数だけ存在するので、損失関数はそれらすべてを平均したものになる:
LMSE = Σ (y - y0)2 / N

ここでの損失は、いわゆる平均二乗誤差 (Mean Squared Error, MSE) と 呼ばれるものである (N は訓練データの個数)。 この値を最小化するために勾配降下法を使えば、 ニューラルネットワークの学習ができる。

さて、勾配降下法には微分が必要ということは上で述べたが、 具体的には何を微分すればよいのか? もう一度、整理すると

ここで注意したいのは、 各入力 x1, x2, x3 と正解 y0 は 訓練データによって決まっており、変えることはできないということである。 我々が変えられるのは (w1, w2, w3 および b) だけだ。 これを念頭に置いて LMSE を書き直してみよう。 まず、訓練データを以下のように仮定する:

x1x2x3y0
0001
0101
1010

上の値を使って LMSE を書きなおすと:

LMSE = (y(0,0,0) - 1)2 + (y(0,1,0) - 1)2 + (y(1,0,1) - 0)2
となる (本来は全体を 3 で割るべきだが、定数なので無視した)。 繰り返すが、ここでの各 y は w1, w2, w3 および b の関数でもあるので、 つまり LMSE 全体も w1, w2, w3 および b の 値で定まる関数ということになる:
LMSE = N(w1, w2, w3, b)

あとは勾配降下法を使って、これが最小値をとるように w1, w2, w3 および b の 各値を求めれば、学習は完了である。

演習3-5. LMSEを計算する

(w1, w2, w3, b) = (1.0, 0.5, -2.0, 1.0) とするとき、 上の訓練データに対する LMSEを求めよ。 (計算には Python を使うこと)

ヒント:

# 重みとバイアスを決める。
w1 = 1.0
w2 = 0.5
w3 = -2.0
b = 1.0
# 3つの訓練データに対する出力を計算する。
ya = sigmoid(w1*0 + w2*0 + w3*0 + b)
yb = sigmoid(w1*0 + w2*1 + w3*0 + b)
yc = sigmoid(w1*1 + w2*0 + w3*1 + b)
loss = ...

上の例で (w1, w2, w3, b) をいろいろな値に変化させたとき、 LMSE はどのように変化するのだろうか? 4次元の図は描けないが、以下の例は (w3 と b は固定して) w1 と w2 の値を それぞれ -2.0 〜 +2.0 の範囲で変化させたときの LMSE の値をプロットしたものである。 図中でもっとも青い部分が LMSE が最小の点である:

w1 w2 +2 -2 -2 +2

実際の勾配降下法は、次のようなアルゴリズムである:

  1. まず、(w1, w2, w3, b) を ランダムな値に設定する。
  2. 与えられた全訓練データに対して、損失関数 LMSE の勾配を計算する。
  3. 勾配をもとに、(w1, w2, w3, b) の値を すこしだけ変化させる。
  4. 損失が十分に少なくなるまで、2. と 3. のステップを繰り返す。

ここでいう「損失関数 LMSE の勾配」とは、 与えられた全訓練データに対する平均となっていることに注意しよう。 比喩的にいえば、勾配とは、訓練データから寄せられる『不満の声』を集めたものである。 一度にすべての不満に対応できるわけではないので、ニューラルネットワークは これらの意見の「中間をとって」学習していく。 こうするとそのうち不満の声は次第に少なくなる (これ自体が大きな発見であった)、 という仕組みである。

(ちなみに、全訓練データを見てから学習する処理を バッチ学習 (batch learning) という。 これと相対する概念はオンライン学習 (online learning) で、 訓練データが追加されるにつれて学習結果も更新していく方法である。)

LMSE に対する勾配 LMSE は、 次のように定義される:

LMSE = (LMSE/w1, LMSE/w2, LMSE/w3, LMSE/b)

ようするに、これは関数 LMSE を w1, w2, w3 および b それぞれに対して 微分 (偏微分) したものである。

演習3-6. 偏微分を計算する

偏微分 (partial derivative) とは、 複数の変数からなる関数を、ある特定の変数についてのみ (他は定数とみなして) 微分することである。「y を x で微分したもの」を通常の微分では dy/dx のように書くが、偏微分では y/x のように書く。

高校で習った合成関数の微分の公式 f2(x)′ = 2·f(x)·f′(x) を使って、 LMSE の各成分を計算すると:

LMSE/w1= 2·(y(0,0,0) - 1)·y/w1+ 2·(y(0,1,0) - 1)·y/w1+ 2·(y(1,0,1) - 0)·y/w1
LMSE/w2= 2·(y(0,0,0) - 1)·y/w2+ 2·(y(0,1,0) - 1)·y/w2+ 2·(y(1,0,1) - 0)·y/w2
LMSE/w3= 2·(y(0,0,0) - 1)·y/w3+ 2·(y(0,1,0) - 1)·y/w3+ 2·(y(1,0,1) - 0)·y/w3
LMSE/b= 2·(y(0,0,0) - 1)·y/b+ 2·(y(0,1,0) - 1)·y/b+ 2·(y(1,0,1) - 0)·y/b

(注意: y/w1, y/w2, y/w3, y/b は関数なので、値は毎回違う)

さらに f(g(x))′ = f′(g(x))·g′(x) であることを利用して、 y/w1, y/w2, y/w3, y/b はそれぞれ以下のように求められる。
y = σ(w1·x1 + w2·x2 + w3·x3 + b)

y/w1 = σ′(w1·x1 + w2·x2 + w3·x3 + b) · x1
y/w2 = σ′(w1·x1 + w2·x2 + w3·x3 + b) · x2
y/w3 = σ′(w1·x1 + w2·x2 + w3·x3 + b) · x3
y/b = σ′(w1·x1 + w2·x2 + w3·x3 + b) · 1
ここで、シグモイド関数の微分 σ′(x) は、以下のように簡単に表すことができる:
σ′(x) = e-x / (1 + e-x)2
= σ(x)·(1 - σ(x)) = y·(1 - y)

これが何を意味しているかというと、 以前計算した y の値を覚えておけば、 その微分は y * (1-y) だけで求められる のである。 シグモイド関数のこの性質を使うと、出力 y に対する勾配を簡単に求めることができる。

def d_sigmoid(y):
    return y * (1-y)
演習3-7. LMSEを計算する

ランダムに初期化された w1, w2, w3, b に対して、上の訓練データに対する損失関数の勾配 dw1 (LMSE/w1)、 dw2 (LMSE/w2)、 dw3 (LMSE/w3)、 db (LMSE/b) をそれぞれ求める以下のプログラムを完成させよ:

# 重みとバイアスをランダムに初期化する。
from random import random
w1 = random()-0.5  # [-0.5, +0.5)の範囲の乱数。
w2 = random()-0.5
w3 = random()-0.5
b = random()-0.5
# 3つの訓練データに対する出力を計算する。
ya = sigmoid(w1*0 + w2*0 + w3*0 + b)
yb = sigmoid(w1*0 + w2*1 + w3*0 + b)
yc = sigmoid(w1*1 + w2*0 + w3*1 + b)
# それぞれの y が計算されたときのシグモイド関数の微分を求める。
dsa = d_sigmoid(ya)
dsb = d_sigmoid(yb)
dsc = d_sigmoid(yc)
# 損失関数の各成分に対する勾配を求める。
dw1 = ???????
dw2 = ???????
dw3 = ???????
db = ???????

勾配が求まったら、あとはそれに応じて、損失関数が減少するように (w1, w2, w3, b) の値を変化させればよい。 ただし、ここで注意すべきは変化させる方向である。 勾配 (微分) というのは、入力値が正に変化したときの損失関数の変化を あらわすので、関数の値を減少 (-) させたいなら、 勾配の符号とは逆の方向に入力値を変化させなければならない

負 (-) の場合正 (+) の場合
勾配が 負 (-) なら 正 (+) の向きへ 勾配が 正 (+) なら 負 (-) の向きへ

したがって、勾配が求まった後で実際に w1, w2, w3, b を変化させるコードは次のようになる:

alpha = 0.1
w1 -= alpha * dw1
w2 -= alpha * dw2
w3 -= alpha * dw3
b -= alpha * db

ここで alpha という係数をかけていることに注意。 これは 学習率 (learning rate) と呼ばれるもので、 重み・バイアスを変化させる割合を決めるものである。 勾配降下法では、勾配をもとに重み・バイアスをちょっとずつ 変化させていく必要がある (あまり急激に変化させると丁度いい点を通過してしまう)。 たいていのニューラルネットワークの学習では、学習率として 0.01 や 0.001 といった数値を使っている。

演習3-8. 勾配降下法の練習

勾配降下法を使って、以下の関数

y = 2x4 - 4x3 + 3x + 2
が最小になるような点を求めたい。
  1. この関数の x = 1 における値と、そのときの勾配 dy/dx を求めよ。
  2. 値を減少させるには、x を正/負のどちらに変化させるべきか?
  3. 学習率 alpha = 0.1 として、x を変化させたときの値を求めよ。

3. ニューラルネットワークを実装する

では Python を使って実際にニューラルネットワークを実装してみよう。

3.1. 単一ノードの学習

最初に、1つのノードだけからなるニューラルネットワークを考える。 ニューラルネットワークの学習では、重みとバイアスを 勾配降下法を使って継続的に変化させている。 ここでは、ひとつのノードの状態を保持するクラス Node を考える。

まず、データの初期化と出力の計算部分だけを実装する:

from random import random
# 1つのノードを定義する。
class Node:
    def __init__(self):
        # 重みとバイアスをランダムに初期化する。
        self.w1 = random()-0.5
        self.w2 = random()-0.5
        self.w3 = random()-0.5
        self.b = random()-0.5
        self.x1 = self.x2 = self.x3 = self.y = None
        self.loss = self.dw1 = self.dw2 = self.dw3 = self.db = 0
        return

    def forward(self, x1, x2, x3):
        # 与えられた入力に対する出力を計算する。
        # このとき、入力と出力を保存しておく。
        self.x1 = x1
        self.x2 = x2
        self.x3 = x3
        self.y = sigmoid(self.w1*x1 + self.w2*x2 + self.w3*x3 + self.b)
        return self.y

上のメソッド forward() は、与えられた入力に対する出力を計算する。 また、このときの x1, x2, x3 および y の値を保存しておく。

つぎに、勾配降下法をおこなう部分を定義する:

    def mse_loss(self, y0):
        # 与えられた正解に対する損失を求める。
        self.loss += (self.y - y0)**2
        # 損失関数の微分を計算する。
        delta = 2*(self.y - y0)
        return delta

    def backward(self, delta):
        # self.y が計算されたときのシグモイド関数の微分を求める。
        ds = d_sigmoid(self.y)
        # 各偏微分を計算する。
        self.dw1 += delta * ds * self.x1
        self.dw2 += delta * ds * self.x2
        self.dw3 += delta * ds * self.x3
        self.db += delta * ds
        return

上のメソッド mse_loss() は正解 ya を受けとり、 それに対する (平均二乗誤差を使った) 損失を求め、 さらにその微分 delta を計算する。 ここでいう delta とは、下の式で表される 2·(y(0,0,0) - 1) などの部分である。 続けて呼ばれるメソッド backward() はこの delta を受けとり、 それに合わせて勾配の各成分 self.dw1, self.dw2, self.dw3, self.db を決定する。 これは求めたい成分のうち、各訓練データ 1個に対応する 1つの項のみを計算していることに注意。 すべての訓練データに対して mse_loss() および backward() を 呼び終わると、self.loss には損失関数の値が格納されており、 self.dw1, self.dw2, self.dw3, self.db にはそれぞれ最終的な勾配が格納されている。 (このようにメソッドを mse_loss()backward() の 2つに分けている理由はあとで説明する。)

変数 1回目 2回目 3回目
self.loss= (y(0,0,0) - 1)2+ (y(0,1,0) - 1)2+ (y(1,0,1) - 0)2
self.dw1= 2·(y(0,0,0) - 1)·y/w1+ 2·(y(0,1,0) - 1)·y/w1+ 2·(y(1,0,1) - 0)·y/w1
self.dw2= 2·(y(0,0,0) - 1)·y/w2+ 2·(y(0,1,0) - 1)·y/w2+ 2·(y(1,0,1) - 0)·y/w2
self.dw3= 2·(y(0,0,0) - 1)·y/w3+ 2·(y(0,1,0) - 1)·y/w3+ 2·(y(1,0,1) - 0)·y/w3
self.db= 2·(y(0,0,0) - 1)·y/b+ 2·(y(0,1,0) - 1)·y/b+ 2·(y(1,0,1) - 0)·y/b

では、このクラスを実際に使ってみよう。

# ノードを初期化。
n1 = Node()
# 1つめの訓練データに対する勾配を計算する。
y = n1.forward(0,0,0)
delta = n1.mse_loss(1)
n1.backward(delta)
# 2つめの訓練データに対する勾配を計算する。
y = n1.forward(0,1,0)
delta = n1.mse_loss(1)
n1.backward(delta)
# 3つめの訓練データに対する勾配を計算する。
y = n1.forward(1,0,1)
delta = n1.mse_loss(0)
n1.backward(delta)

ここまで完了したあとで、インスタンスの self.dw1, self.dw2, self.dw3, self.db にはそれぞれ勾配が 格納されているはずである。 これを使って、実際に損失が減る方向へと 重み・バイアスを変化させるには、さらに別のメソッドが必要である:

    def update(self, alpha):
        # 現在の勾配をもとに、損失が減る方向へ重み・バイアスを変化させる。
        self.w1 -= alpha * self.dw1
        self.w2 -= alpha * self.dw2
        self.w3 -= alpha * self.dw3
        self.b -= alpha * self.db
        # 計算用の変数をクリアしておく。
        self.loss = self.dw1 = self.dw2 = self.dw3 = self.db = 0
        return

いっさいがっさいをまとめると、以下のようになる。 ここでは損失が十分に少なくなるであろうと期待して、 処理を100回繰り返している:

# ノードを初期化。
n1 = Node()
# 100回繰り返す。
for i in range(100):
    # 各訓練データに対する勾配を計算する。
    y = n1.forward(0,0,0)
    delta = n1.mse_loss(1)
    n1.backward(delta)
    y = n1.forward(0,1,0)
    delta = n1.mse_loss(1)
    n1.backward(delta)
    y = n1.forward(1,0,1)
    delta = n1.mse_loss(0)
    n1.backward(delta)
    # 現在の損失を表示する。
    print(n1.loss)
    # 重み・バイアスを学習率 0.01 で変化させる。
    n1.update(0.01)
演習3-9. 勾配降下法をPythonで実行する
  1. 上の Python プログラムを実際に実行し、 損失が減少していく様子を観察せよ。最終的な損失の値はいくつになるか?
  2. 学習率 alpha を 0.1 に変更すると、最終的な損失はいくつになるか?
  3. 学習率 0.1 でループ 1000回を繰り返すようにすると、最終的な損失はいくつになるか?
  4. 学習率 0.1 でループ 1000回を繰り返したあとの、 (0,0,0), (0,1,0), (1,0,1) それぞれの入力に対する ノードの出力を求めよ。

学習における繰り返し回数を反復回数 (iteration) と呼ぶ。 学習率や反復回数を変えると、損失が変わることに注目してほしい。 一般的に、反復回数が多くなるほど損失は減少するが、 ある程度までくるとあまり減少しなくなる。 学習率が大きくなると損失はより速く減少するようになるが、 大きすぎると「ほどよい地点」を通りすぎてしまい、 損失はある時点で止まるか、逆に上昇してしまうこともありうる。 学習率や反復回数のような値は、学習によって得られた値ではなく、 「学習方法に関する値」であるので、ハイパーパラメータと呼ぶ。 ハイパーパラメータは通常、人間があらかじめ決定しておく。

3.2. ノードの数を増やす

さて、これまでは 1つの値だけを出力する単一のノードについてのみ扱ってきたが、 これを複数のノードに拡張するにはどうすればよいだろうか。 ニューラルネットワークでは、出力で得たい値と同じ個数だけノードが必要である。

そこで、ここでは複数のノードをまとめた レイヤー (layer、層) というものを定義しよう。 レイヤーとは:

レイヤー 入力1 入力2 入力3 出力1 出力2

上の例では入力が3個、出力が2個となっているが、出力のほうが多くてもかまわない。 注意すべきなのは「入力側にある点線の ○ は、実際に計算をおこなうノードではない」 ということである。 したがって、このレイヤーにおける本物のノードは 2個である。 またここでの配線は全接続であるから、 出力のノード1個に対して入力の個数ぶん (nin個) だけ配線が必要である。 したがって、全部で nin×nout本の接続 (connection) が存在する。

さて、このようなレイヤーを Python のクラスで実装してみよう:

from random import random
# 入力 nin個、出力 nout個のレイヤーを定義する。
class Layer:
    def __init__(self, nin, nout):
        self.nin = nin
        self.nout = nout
        # 重み・バイアスを初期化する。
        self.w = [ [ random()-0.5 for j in range(self.nin) ] for i in range(self.nout) ]
        self.b = [ random()-0.5 for i in range(self.nout) ]
        # 計算用の変数を初期化する。
        self.x = self.y = None
        self.dw = [ [ 0 for j in range(self.nin) ] for i in range(self.nout) ]
        self.db = [ 0 for i in range(self.nout) ]
        self.loss = 0
        return

やっていることは基本的にノードが 1つのときと同じである。 ノードが 1つのときは重み・バイアスにそれぞれ個別の変数を使っていたが、 ここでは Python のリストを活用している。 変数 wdw はそれぞれ 「『nin個の要素をもつリスト』を要素としてもつ nout個のリスト」 になっていることに注意。つまり

となる。

演習3-10. レイヤーのデータ構造を推測する

上の例で示したレイヤーを実際に Python で表現するために

layer1 = Layer(3, 2)
を実行した。 layer1.w および layer1.b は どのような構造のリストになっているか、想像せよ。
forward() メソッドも同様に実装すると、次のようになる。 なお、ここでは入力・出力ともに単一の値ではなく、値のリストになっている:
    def forward(self, x):
        # xは nin個の要素をもつ入力値のリスト。
        self.x = x
        self.y = []
        for i in range(self.nout):
            # i番目のノードの重みリスト・バイアスを取り出す。
            w = self.w[i]
            b = self.b[i]
            z = b  # 最初にバイアスを足しておく。
            for j in range(self.nin):
                # j番目の入力を重みをつけて足す。
                w1 = w[j]
                x1 = x[j]
                z += w1*x1
            # 合計値をsigmoid()に通し、i番目のノードの出力とする。
            self.y.append(sigmoid(z))
        # yは nout個の要素をもつ出力値のリスト
        return self.y

以上のコードはわざと手続き的に書いてあるが、 リスト内包表記および zip() 関数を使って もうすこし「Python的に」書いてみると、以下のようになる:

    def forward(self, x):  # Pythonicバージョン
        # xは nin個の要素をもつ入力値のリスト。
        # 与えられた入力に対する各ノードの出力を計算する。
        self.x = x
        self.y = [
            sigmoid(sum( w1*x1 for (w1,x1) in zip(w, x) ) + b)
            for (w,b) in zip(self.w, self.b)
        ]
        # yは nout個の要素をもつ出力値のリスト
        return self.y

mse_loss()update() メソッドも同様に実装する。 ここでも入力の y0 は値のリストと仮定する。

    def mse_loss(self, y0s):
        # 与えられた正解に対する損失を求める。
        self.loss += sum( (y1-y0)**2 for (y1,y0) in zip(self.y, y0s) )
        # 損失関数の微分を計算する。
        delta = [ 2*(y1-y0) for (y1,y0) in zip(self.y, y0s) ]
        return delta

    def backward(self, delta):
        # self.y が計算されたときのシグモイド関数の微分を求める。
        ds = [ d_sigmoid(y1) for y1 in self.y ]
        # 各偏微分を計算する。
        for i in range(self.nout):
            for j in range(self.nin):
                self.dw[i][j] += delta[i] * ds[i] * self.x[j]
        for i in range(self.nout):
            self.db[i] += delta[i] * ds[i]
        return

    def update(self, alpha):
        # 現在の勾配をもとに、損失が減る方向へ重み・バイアスを変化させる。
        for i in range(self.nout):
            for j in range(self.nin):
                self.w[i][j] -= alpha * self.dw[i][j]
        for i in range(self.nout):
            self.b[i] -= alpha * self.db[i]
        # 計算用の変数をクリアしておく。
        for i in range(self.nout):
            for j in range(self.nin):
                self.dw[i][j] = 0
        for i in range(self.nout):
            self.db[i] = 0
        self.loss = 0
        return
演習3-11. Layer クラスを使う

上の Layer クラスを完成させ、 これを使って 演習 3-9. と 同等のネットワーク (入力値 3個、出力値 1個) を実現せよ。

layer1 = Layer(3, 1)

学習率 alpha を 0.1 にして ループを 100回繰り返し、損失がほぼ同じになることを確認せよ。

3.3. 多層化する (誤差逆伝播法)

さて、ようやく複数のレイヤーを重ねて、 真のニューラルネットワークを構築できる段階まで到達した。 最初の例で示した、以下のようなネットワークを構築してみる。

レイヤー1 レイヤー2

ここでは 2つの Layer インスタンスを使う。 最初のレイヤーは入力値×4個と出力値×3個をもっており、 2番目のレイヤーは入力値×3個と出力値×2個をもっている。 1番目のレイヤーへの入力が「ネットワークへの入力」であり、 2番目のレイヤーの出力が「ネットワークの最終的な出力」となる。 各レイヤーのノードの個数 (= 出力値の個数) は任意に決められるが、 複数のレイヤーを重ねるときは 各レイヤー入力値の個数 (nin) が、 そのひとつ前のレイヤー出力値の個数 (nout) と一致していなければならない。

上の例では、layer1 インスタンスの nout と、 layer2 インスタンスの nin が同じである必要がある:

layer1 = Layer(4, 3)
layer2 = Layer(3, 2)

ここで、多層ニューラルネットワークにきわめて重要なもうひとつの 技術である 誤差逆伝播法 (backpropagation) を説明する。 もう一度最初に戻って考えてみると、ニューラルネットワークの学習における目標は 「損失を最小化するよう重み・バイアス (w, b) の値を調整する」ことであった。 レイヤーが1つだけのとき、これは損失関数の勾配に合わせて w, b の値を変化させるだけであった。 しかし、レイヤーは実際には (x, w, b) を入力とする関数なのだから、 x の値を変化させても損失は減らすことができるはずである。

以前の レイヤー x y w, b 最後の レイヤー 損失

とはいえ、レイヤーが 1つだけのときは、 x は実際の訓練データなわけであるから、変化させることはできない。 しかしこれより前にもレイヤーがあるときは、 x はひとつ前のレイヤーの出力である。 したがって、もしこのレイヤーを「よりよい出力」を出すように 調整できれば、さらに損失が減らせるはずである。 これが誤差逆伝播法のアイデアである。 つまり:

このプロセスはレイヤーをさかのぼっていき、最初のレイヤーに到達するまで続く。 最初のレイヤーはもう x を調整することはできないため、伝播はそこで終わる。 このようにすると、複数のレイヤーが協調的に学習することが可能になる。 この協調性がニューラルネットワークの性能につながっている。

では実際に、どのように x の値を調整すればよいだろうか? ここでも勾配を使う。あるレイヤーが 2つの入力 x1, x2 および 3つの出力 y1, y2, y3 をもつとき、 各出力を x1 と x2 でそれぞれ偏微分すると次のようになる:

y1 = σ(w11·x1 + w12·x2 + b1)
y2 = σ(w21·x1 + w22·x2 + b2)
y3 = σ(w31·x1 + w32·x2 + b3)

y1/x1 = σ′(w11·x1 + w12·x2 + b1) · w11
y2/x1 = σ′(w21·x1 + w22·x2 + b2) · w21
y3/x1 = σ′(w31·x1 + w32·x2 + b2) · w31
y1/x2 = σ′(w11·x1 + w12·x2 + b1) · w12
y2/x2 = σ′(w21·x1 + w22·x2 + b2) · w22
y3/x2 = σ′(w31·x1 + w32·x2 + b2) · w32

このように、x1, x2 への各微分は 3つずつできるのだが、 この x1, x2 はひとつ前のレイヤーの出力であることに注意。 ひとつ前のレイヤーから見れば、前レイヤーの出力 y1′, y2′ が 3つの異なる訓練データに対して使われたときの損失と同じことなので、 最終的な微分はこれらを足し合わせたものになる。

y1′ y2′ x1 x2 y1 y2 y3 ひとつ前の レイヤー レイヤー

さて、実際に求めたいのは損失関数 LMSE に対する偏微分 LMSE/x1 および LMSE/x2 であった:

LMSE/x1 = 2·(y - y0) · (y1/x1 + y2/x1 + y3/x1)
LMSE/x2 = 2·(y - y0) · (y1/x2 + y2/x2 + y3/x2)

ここで 2·(y - y0) と 表されている部分は何だろうか? これは backward() が受けとっていた値 delta である。 つまり、前レイヤーに対する勾配にもすべて delta が 掛けられているのである。 これに合わせ、backward() メソッドの最後の部分を 以下のように変更する:

    def backward(self, delta):
        # self.y が計算されたときのシグモイド関数の微分を求める。
        ds = [ d_sigmoid(y1) for y1 in self.y ]
        # 各偏微分を計算する。
        for i in range(self.nout):
            for j in range(self.nin):
                self.dw[i][j] += delta[i] * ds[i] * self.x[j]
        for i in range(self.nout):
            self.db[i] += delta[i] * ds[i]
        # 各入力値の微分を求める。
        dx = [
            sum( delta[j]*ds[j]*self.w[j][i] for j in range(self.nout) )
            for i in range(self.nin)
        ]
        return dx

ここでもう一度思い出してほしいのだが、 この backward() が返す値 dx は、 ひとつ前のレイヤーに対する勾配である。 つまり、前レイヤーの backward() が受けとる delta になっているのである。 このように次々と delta を掛けながら backward() が呼ばれ、 勾配が計算されていく。数学的には、これは微分における 連鎖律 (chain rule) を利用したアルゴリズムになっている。 この処理の流れを図示すると、以下のようになる:

x y y y y0 delta delta delta forward forward forward backward backward backward mse_loss レイヤー1 レイヤー2 レイヤー3

このように拡張した Layer クラスを使うには、以下のようにすればよい。 layer1, layer2, ..., layerN の N個のレイヤーがあったとすると:

x = 入力データ
y0 = 正解データ
# 訓練データの各入力に対する出力を計算する。
y = layer1.forward(x)  # 1番目のレイヤー
y = layer2.forward(y)  # 2番目のレイヤー
...
y = layerN.forward(y)  # 最後のレイヤー
# 正解データに対する損失を計算する。
delta = layerN.mse_loss(y0)
# 各レイヤーの勾配を計算していく。
delta = layerN.backward(delta)  # 最後のレイヤー
...
delta = layer2.backward(delta)  # 2番目のレイヤー
delta = layer1.backward(delta)  # 1番目のレイヤー

多層ニューラルネットワークの使う際の処理をまとめると:

実際には、mse_loss() メソッドは独立した関数として定義でき、 Layerクラスに所属する必要はない。

演習3-12. ニューラルネットワークを使ってピタゴラスの定理を学習する

多層ニューラルネットワークを使って、3変数のピタゴラスの定理の関数 y = √((x12 + x22 + x32) / 3) を学習したい。入力値 x1, x2, x3 は どれも 0〜1 の範囲とし、出力値は 0〜1 の範囲である。

まず、100個のランダムな訓練データを用意する。

# 100個分のランダムな訓練データを作成する。
from random import random, seed
from math import sqrt
# つねに乱数値を一定にする。
seed(0)
data = []
for i in range(100):
    x = [ random(), random(), random() ]                  # 入力
    y0 = [ sqrt((x[0]*x[0] + x[1]*x[1] + x[2]*x[2])/3) ]  # 正解
    data.append((x, y0))

次に、2つのレイヤーを使った学習プログラムを考える:

layer1 = Layer(3, 3)
layer2 = Layer(3, 1)
# 1000回繰り返す。
for i in range(1000):
    for (x,y0) in data:
        # 入力に対する出力を計算する。
        y = layer1.forward(x)
        y = layer2.forward(y)
        # 損失を計算する。
        delta = layer2.mse_loss(y0)
        # 勾配を計算する。
        delta = layer2.backward(delta)
        delta = layer1.backward(delta)
    # 現在の損失を表示する。
    print(layer2.loss)
    # 重み・バイアスを学習率 0.1 で変化させる。
    layer1.update(0.1)
    layer2.update(0.1)
  1. 上のコードを実行し、最終的な損失がいくつになるか調べよ。
  2. 繰り返し回数を 5000回にすると損失はどれだけ下がるか?
  3. layer1layer2 の間に、 3つのノードを含むレイヤーをもう1つ追加し、 繰り返し回数を 5000回として、 最終的な損失がどのように変化するか調べよ。

以上で見てきたように、ニューラルネットワークは 入力と出力が 0〜1 の範囲に収まるような任意の関数を学習することができる。 また、ノードの個数・レイヤーを増やすことでより ニューラルネットワークの能力 (capacity) が上がり、 より複雑な学習ができるようになるが、 より訓練にも時間がかかるようになる (勾配消失問題)。

発展課題. 別のプログラミング言語で実装

上にあげた Layerクラスと、それを使ったピタゴラスの定理の学習を Python以外のプログラミング言語 (C, Java など) で書いてみよう。

3.4. なぜ層を増やすと学習性能が上がるのか?

上で見たように、ニューラルネットワークでは、レイヤーを追加すると より複雑なモデルを学習できるようになることが知られている。 この理由はまだ数学的に完全に解明されているわけではないが、 おおまかに次のように考えられている。

以下の 2つの層をもつネットワークでは 中間にあるノード A と B が入力に対してなんらかの特徴を学習する。 A と B の重み・バイアスはランダムに初期化されているので、 通常これらのノードはそれぞれ異なった特徴を学習するはずである。 そして最後のノードが、A と B の出力を利用して正しい答えを導きだす 方法を学習する。つまり最後のノードにとってノード A と B は 「部下」のような役割を果たす。

A B ある特徴を学習 別の特徴を学習 A,Bの成果を使って さらに学習

4. まとめ


クリエイティブ・コモンズ・ライセンス
この作品は、クリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスの下に提供されています。
Yusuke Shinyama