はじめに
「プログラミング」初学者のための『ゼロから作るDeep Learning』攻略ノートです。『ゼロつくシリーズ』学習の補助となるように適宜解説を加えています。本と一緒に読んでください。
関数やクラスとして実装される処理の塊を細かく分解して、1つずつ実行結果を見ながら処理の意図を確認していきます。
この記事は、3.3節「多次元配列の計算」と3.4節「ニューラルネットワークの実装」の内容です。3層のニューラルネットワークの順伝播の計算処理を確認します。
【前節の内容】
【他の節の内容】
【この節の内容】
3.3 多次元配列の計算
1つの値・要素をスカラと言います。要素を横に並べたものを1次元配列またはベクトルと言います。要素を縦にも横にも並べた(1次元配列を縦に並べた)ものを2次元配列または行列と言います。
要素を縦横奥の3次元に並べたものであれば3次元配列となります。さらにそれをN次元にまで拡張した(一般化)ものを(3次元配列も含めて)多次元配列と言います。文脈によっては、2次元配列も多次元配列に含めて呼ばれます。本では7章で4次元配列まで登場します。
行列(2次元配列)の計算については「行列の積【ゼロつく1のノート(数学)】 - からっぽのしょこ」で詳しく扱います(複雑な概念ですがしっかり理解したければ参照してください)。計算自体はnp.dot()
で行えるので、3.3.1-3項の内容を把握しておけば問題ありません。
3.3.3 ニューラルネットワークの行列の積
ニューラルネットワークでは、複数データの重み付き和の計算に行列の積が登場します。この項では、np.dot()
を使った行列の積の計算を確認します。
利用するライブラリを読み込みます。
# 3.3.3項で利用するライブラリ import numpy as np
・数式の確認
まずは、ニューラルネットワークにおける行列の積の計算を数式で確認します。
入力を$\mathbf{X}$で表します。この例では、要素数が2の1次元配列(ベクトル)とします。
Pythonのインデックスに合わせて、添字を0から割り当てています。
行列の積を計算する際には、1行2列の2次元配列(行列)として扱います。「1行2列の2次元配列」のことを「$(1 \times 2)$の配列」と表現することもあります(shape
メソッドの返り値と合わせるために丸括弧を付けました)。
重みを$\mathbf{W}$で表します。この例では、2行3列の2次元配列(行列)とします。この場合は、$(2 \times 3)$の配列とも呼びます。
$w_{0,0}$は、$\mathbf{W}$の0行0列目の要素を表し、この例だと値は1です。1行2列目の要素であれば、$w_{1,2} = 6$です。こちらもPythonのインデックスに合わせています。
入力$\mathbf{X}$と重み$\mathbf{W}$の行列の積を$\mathbf{Y}$とします。バイアスは、$\mathbf{Y}$の形状に影響しないので省略します。
行列の積は「$\cdot$」を使って次のように表すこともあります。
このため、行列の積のことをドット積とも呼びます。
「$(1 \times 2)$の配列$\mathbf{X}$」と「$(2 \times 3)$の配列$\mathbf{W}$」の行列の積なので、$\mathbf{Y}$は要素数が3の1次元配列になります。
「$\mathbf{Y}$の(0行)0列目の要素$y_0$」は、「$\mathbf{X}$の0行目の要素$x_0,\ x_1$」と「$\mathbf{W}$の0列目の要素$w_{0,0},\ w_{1,0}$」を使って、次のように計算します。
同様に、「(0行)1列目の$y_1$」は、「0行目の$x_0,\ x_1$」と「1列目の$w_{0,1},\ w_{1,1}$」を使って
と計算します。「(0行)2列目の$y_2$」は、「0行目の$x_0,\ x_1$」と「2列目の$w_{0,2},\ w_{1,2}$」を使って
と計算します。
よって、$\mathbf{Y}$の計算結果は次になります。
$\mathbf{Y}$の各要素の計算式は、重み付き和の計算式(3.4)の形をしていることが分かります(バイアスを省略しています)。「入力の数」と「対応する重みの数」が一致するように、「$\mathbf{X}$の列数」と「$\mathbf{W}$の行数」が一致する必要があります。複数のデータで効率よく重み付き和の計算が行えるように、行列の積が定義されているとも言えます。
活性化関数も形状に影響せず、また行列の積が登場しないので、省略します。
では、同じ計算をPythonでもやってみましょう。
・処理の確認
次は、NumPyを使って確認します。
入力$\mathbf{X}$をNumPy配列X
として作成します。
# 入力を作成 X = np.array([1, 2]) print(X) print(X.shape) print(X.ndim)
[1 2]
(2,)
1
X
は、要素数が2
の1次元配列です。X.shape
でX
の形状、X.ndim
でX
の次元数を調べられます。shape
メソッドの返り値(2,)
の要素数と、ndim
メソッドの返り値1
は一致します。
同様に、重み$\mathbf{W}$も作成します。
# 重みを作成 W = np.array([[1, 3, 5], [2, 4, 6]]) print(W) print(W.shape) print(W.ndim)
[[1 3 5]
[2 4 6]]
(2, 3)
2
W
は、$(2 \times 3)$の2次元配列です。
$\mathbf{X}$と$\mathbf{W}$の行列の積(ドット積)をnp.dot()
で計算して、出力$\mathbf{Y}$を求めます。
# 出力を計算 Y = np.dot(X, W) print(Y) print(Y.shape) print(Y.ndim)
[ 5 11 17]
(3,)
1
出力$\mathbf{Y}$が得られました。
この節では、ニューラルネットワークの重み付き和の計算を確認しました。また前節では、活性化関数の計算を確認しました。この2種類の計算が、ニューラルネットワークの中心的な計算です。次節では、ニューラルネットワークの計算を確認します。
3.4 3層のニューラルネットワークの順伝播
これまでに、入力とパラメータ(重みとバイアス)と出力の関係(2.3節)・重み付き和(2.3.2項・3.1節)、活性化関数(3.2節)、2次元配列・行列の積(3.3節)を学びました。これらの処理を組み合わせることで、ニューラルネットワークの推論を行えます。この節では、3層のニューラルネットワークの推論処理を実装するのを通じて、ニューラルネットワークの基本的な計算を体感します。
利用するライブラリを読み込みます。
# 3.4節で利用するライブラリ import numpy as np
この節では、シグモイド関数の計算を行います。そのため、「3.2.4:シグモイド関数の実装【ゼロつく1のノート(実装)】 - からっぽのしょこ」で実装(関数定義)したシグモイド関数sigmoid()
を実行しておく必要があります。
3.4.2 各層における信号伝達の確認
3つの層で行う計算(処理)を1層ずつ丁寧に確認していきます。この資料では、「本に載っている図」と「数式」と「プログラム(コード)」を頭の中で統合することを目指します。(上手くいけば、図を見ると数式をイメージでき、数式がコードに見え、数式への忌避感が薄れます?。例えば、図3-17は重み付き和の計算式(3.8)を表しており、式(3.9)はA1 = np.dot(X, W1) + B1
のことです。どれも同じ計算を表しており、よく見ると何となく字面も似ています。)
・第0層の処理
ニューラルネットワークの入力(第1層の入力)を$\mathbf{X}$とします。この例では、次の値を持つ要素数2の1次元配列(ベクトル)とします。
1行2列の2次元配列(行列)とみなして計算します。以降は、「1行2列の2次元配列」を「$(1 \times 2)$の配列」と表現することにします。
入力$\mathbf{X}$をNumPy配列X
として作成します。
# ニューラルネットワークの入力を作成 X = np.array([1.0, 0.5]) print(X) print(X.shape) print(X.ndim)
[1. 0.5]
(2,)
1
「数式上の入力$\mathbf{X}$」と同じ「プログラム上の入力X
」を作成できました。
ここまでは第1層の前に行う処理なので、第0層の処理と表現しました。続いて、第1層の計算を行います。
・第1層の処理
第1層の重みを$\mathbf{W}^{(1)}$、第1層のバイアスを$\mathbf{B}^{(1)}$と表記することにします。それぞれの形状と具体的な値を次のように設定します。
「$\mathbf{X}$の列数」と同じ「$\mathbf{W}^{(1)}$の行数」である必要があります。また、「$\mathbf{W}^{(1)}$の列数」と同じ「$\mathbf{B}^{(1)}$の要素数(列数)」である必要があります。
(ところで、突然右肩に(1)が出てくると身構えてしまうかもしれませんが、どの層の変数なのかを区別するためのもので数学的な意味はないです。プログラム上の変数名W1, b1
の1と同じです。しかし、$\mathbf{W}$の下付きの添字は通常の表記法ではありません。個人的な意見ですが、記憶に留めない方がいいと思います。この資料では使いません。)
X
と同様に、第1層のパラメータ(重みとバイアス)$\mathbf{W}^{(1)},\ \mathbf{B}^{(1)}$を作成します。変数名をW1, B1
とします。
# 第1層の重みを作成 W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]]) print(W1) print(W1.shape) print(W1.ndim)
[[0.1 0.3 0.5]
[0.2 0.4 0.6]]
(2, 3)
2
# 第1層のバイアスを作成 B1 = np.array([0.1, 0.2, 0.3]) print(B1) print(B1.shape) print(B1.ndim)
[0.1 0.2 0.3]
(3,)
1
第1層の重み付き和(重み付き入力とバイアスの総和)を$\mathbf{A}^{(1)}$と表記することにします。$\mathbf{A}^{(1)}$は、$\mathbf{X}$と$\mathbf{W}^{(1)}$の行列の積(ドット積)に$\mathbf{B}^{(1)}$を加えたものです。次の式で定義されます。
$\mathbf{A}^{(1)}$の1番目の要素$a_1$の計算式が式(3.8)です(図3-17)。やっていることは式(3.4)と同じです。他の要素も同様に計算されます。つまり、行列の積を計算することで、全ての要素の重み付き和の計算を1度に行えます。(そのように行列の積が定義されています。)
$\mathbf{A}^{(1)}$は、「$(1 \times 2)$の配列」と「$(2 \times 3)$の配列」の積なので、「$(1 \times 3)$の配列」になります。和の計算では形状は変わりません。
$\mathbf{A}^{(1)}$を計算します。行列の積はnp.dot()
を使って計算できます。
# 第1層の重み付きの和を計算:式(3.9) A1 = np.dot(X, W1) + B1 print(A1) print(A1.shape) print(A1.ndim)
[0.3 0.7 1.1]
(3,)
1
が求まりました。
$\mathbf{A}^{(1)}$の各要素を活性化したものを第1層の出力$\mathbf{Z}^{(1)}$とします。この例では、活性化関数としてシグモイド関数を使います。シグモイド関数(活性化関数)を$h(\cdot)$で表すと、次の式で表現できます(図3-18)。
活性化関数の計算では、配列の形状は変わりません。
3.2.4項で実装したsigmoid()
を使って、$\mathbf{Z}^{(1)}$を計算します。
# 第1層の出力を計算(シグモイド関数による活性化) Z1 = sigmoid(A1) print(Z1) print(Z1.shape) print(Z1.ndim)
[0.57444252 0.66818777 0.75026011]
(3,)
1
NameError: name 'sigmoid' is not defined
(sigmoid
が定義されていない)というエラーメッセージが出る場合は、sigmoid()
の関数定義(def
文)を再度実行する必要があります。
が求まりました。
ここまでが第1層の計算です。続いて、$\mathbf{Z}^{(1)}$を第2層の入力として、第2層の出力$\mathbf{Z}^{(2)}$を求めていきます。
・第2層の処理
第2層では、第1層のときと同様に処理します。
第2層の重みを$\mathbf{W}^{(2)}$、第2層のバイアスを$\mathbf{B}^{(2)}$とします。第1層の出力(第2層の入力)$\mathbf{Z}^{(1)}$の形状に応じて、それぞれ次のように設定します。
第2層のパラメータ$\mathbf{W}^{(2)},\ \mathbf{B}^{(2)}$を作成します。
# 第2層の重みを作成 W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]]) print(W2) print(W2.shape) print(W2.ndim)
[[0.1 0.4]
[0.2 0.5]
[0.3 0.6]]
(3, 2)
2
# 第2層のバイアスを作成 B2 = np.array([0.1, 0.2]) print(B2) print(B2.shape) print(B2.ndim)
[0.1 0.2]
(2,)
1
第2層の重み付き和
を計算します(図3-19)。$\mathbf{A}^{(2)}$は、「$(1 \times 3)$の配列」と「$(3 \times 2)$の配列」の積なので、「$(1 \times 2)$の配列」になります。
# 第2層の重み付き和を計算 A2 = np.dot(Z1, W2) + B2 print(A2) print(A2.shape) print(A2.ndim)
[0.51615984 1.21402696]
(2,)
1
が求まりました。
シグモイド関数による活性化を行い、第2層の出力$\mathbf{Z}^{(2)}$とします(図3-19)。
# 第2層の出力を計算(シグモイド関数による活性化) Z2 = sigmoid(A2) print(Z2) print(Z2.shape) print(Z2.ndim)
[0.62624937 0.7710107 ]
(2,)
1
が求まりました。
ここまでが第2層の計算です。続いて、$\mathbf{Z}^{(2)}$を第3層の入力として、全体の出力$\mathbf{Y}$を求めていきます。
・第3層の処理
これまでと同様に、第3層の重みとバイアスを$\mathbf{W}^{(3)},\ \mathbf{B}^{(3)}$とします。第2層の出力(第3層の入力)$\mathbf{Z}^{(2)}$の形状に応じて、それぞれ次のように設定します。
# 第3層の重みを作成 W3 = np.array([[0.1, 0.3], [0.2, 0.4]]) print(W3) print(W3.shape) print(W3.ndim)
[[0.1 0.3]
[0.2 0.4]]
(2, 2)
2
# 第3層のバイアスを作成 B3 = np.array([0.1, 0.2]) print(B3) print(B3.shape) print(B3.ndim)
[0.1 0.2]
(2,)
1
第3層の重み付き和
を計算します(図3-20)。$\mathbf{A}^{(3)}$は、「$(1 \times 2)$の配列」と「$(2 \times 2)$の配列」の積なので、「$(1 \times 2)$の配列」になります。
# 第3層の重み付き和を計算 A3 = np.dot(Z2, W3) + B3 print(A3) print(A3.shape) print(A3.ndim)
[0.31682708 0.69627909]
(2,)
1
が求まりました。
最終層(第3層)では、活性化を行わないことにします。第3層の重み付き和$\mathbf{A}^{(3)}$を、そのまま全体の出力(第3層の出力)$\mathbf{Y}$とします。次の式で表せます(図3-20)。
このように、入力をそのまま出力する関数を恒等関数と言います。恒等関数を$f(\cdot)$で表すと、$f(x) = x$です。
恒等関数をidentity_function()
として定義(作成)しておきます。
# 恒等関数の実装 def identity_function(x): return x
恒等関数は、回帰問題において最終層の活性化関数としてよく使われます(この本では回帰問題を扱いません)。2クラス分類問題では、シグモイド関数がよく使われます(2クラス分類も扱いません)。多クラス分類問題では、次節で実装するソフトマックス関数がよく使われます(この本で扱う手書き数字認識は他クラス分類に該当します)。
identity_function()
を使って、$\mathbf{Y}$を計算します。
# ニューラルネットワークの出力を計算(恒等関数による活性化) Y = identity_function(A3) print(Y)
[0.31682708 0.69627909]
この処理は不要に思えますが、「重み付き和の計算」→「(活性化)関数の計算」→「(次の層に)出力」の流れを統一しておくことで便利になることもあります。
例えば、3.4.3項で作成する関数forward()
と3.6.2項で作成するpredict()
は、恒等関数identity_function()
をソフトマックス関数softmax()
に変更するだけで実装できます。
他にも、流れが一貫していることで、後で見返したときに解釈しやすくなります。また、identity_function()
を使うことで、活性化を行わないことを明示できます。
最終的な出力
が得られました(めでたしめでたし)。
以上が3層のニューラルネットワークの推論で行う処理です。もっと層を増やす場合も(基本的には)隠れ層の計算を繰り返すので、3層のニューラルネットワークをイメージできたということはディープなニューラルネットワーク(の基本)をイメージできたということです。次項では、ここまでの処理を関数として実装します。
3.4.3 実装のまとめ
3.4.2項で行った処理を2つの関数にまとめます。ここで実装するニューラルネットワークは、あくまで本格的に実装していくニューラルネットワークをイメージするためのものです。
・処理の確認
全てのパラメータを1つのディクショナリ型の変数に格納しておきます。ディクショナリとしておくことで、パラメータを出し入れしやすくなります。ニューラルネットワークを実装する前に、ディクショナリの扱いを復習しておきましょう。
{}
で空のディクショナリを作成します。
# 空のディクショナリを作成 dic = {} print(dic)
{}
dic
にキーと値を追加します。
# 新たなキーを指定して値を代入 dic['疲れた'] = '赤い雄牛' print(dic)
{'疲れた': '赤い雄牛'}
ディクショナリにキーを指定すると値を取り出せます。
# キーを指定して対応する値を取得 val = dic['疲れた'] print(val)
赤い雄牛
翼を授けるうぅ。
あと一息!ではありませんが、この項でこの節は終わりです。そこまでで一区切りなので是非やってしまいましょう。
・実装
まずは、ニューラルネットワークで用いる全てのパラメータをまとめたディクショナリ型の変数を作成する関数を実装します。各パラメータの値は3.4.2項と同じものです。
# パラメータの初期化(作成)関数の実装 def init_network(): # 空のディクショナリを作成 network = {} # 第1層のパラメータを格納 network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]]) # 重み network['b1'] = np.array([0.1, 0.2, 0.3]) # バイアス # 第2のパラメータを格納 network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]]) # 重み network['b2'] = np.array([0.1, 0.2]) # バイアス # 第3のパラメータを格納 network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]]) # 重み network['b3'] = np.array([0.1, 0.2]) # バイアス # 全てのパラメータを格納したディクショナリを出力 return network
実装した関数を試してみましょう。
# パラメータ(重みとバイアス)を初期化(作成) network = init_network() print(network)
{'W1': array([[0.1, 0.3, 0.5],
[0.2, 0.4, 0.6]]), 'b1': array([0.1, 0.2, 0.3]), 'W2': array([[0.1, 0.4],
[0.2, 0.5],
[0.3, 0.6]]), 'b2': array([0.1, 0.2]), 'W3': array([[0.1, 0.3],
[0.2, 0.4]]), 'b3': array([0.1, 0.2])}
この変数(オブジェクト)は後で使います。
ここでは使いませんが、keys()
メソッドで全てのキーを取得できます。
# ディクショナリのキーを確認 print(network.keys())
dict_keys(['W1', 'b1', 'W2', 'b2', 'W3', 'b3'])
こちらも使いませんが、values()
メソッドで全ての値を取得できます。
# ディクショナリの値を確認 print(network.values())
dict_values([array([[0.1, 0.3, 0.5],
[0.2, 0.4, 0.6]]), array([0.1, 0.2, 0.3]), array([[0.1, 0.4],
[0.2, 0.5],
[0.3, 0.6]]), array([0.1, 0.2]), array([[0.1, 0.3],
[0.2, 0.4]]), array([0.1, 0.2])])
次に、3層のニューラルネットワークの計算を行う関数を実装します。前項での処理に、init_network()
で作成されたディクショナリから各パラメータを取り出す処理を加えます。
# ニューラルネットワーク(順伝播)を定義 def forward(network, x): # ディクショナリから各パラメータを取得 W1, W2, W3 = network['W1'], network['W2'], network['W3'] # 重み b1, b2, b3 = network['b1'], network['b2'], network['b3'] # バイアス # 第1層の計算 a1 = np.dot(x, W1) + b1 # 重み付き和 z1 = sigmoid(a1) # 活性化 # 第2層の計算 a2 = np.dot(z1, W2) + b2 # 重み付き和 z2 = sigmoid(a2) # 活性化 # 第3層の計算 a3 = np.dot(z2, W3) + b3 # 重み付き和 y = identity_function(a3) # そのまま # ニューラルネットワークの出力を出力 return y
実装した関数を試してみましょう。
ニューラルネットワークの入力(第1層の入力)X
を作成して、先ほど作成した全てのパラメータnetwork
と共にforward()
に渡します。
# 入力を作成 X = np.array([1.0, 0.5]) # 出力を計算 Y = forward(network, X) print(Y) print(Y.shape) print(Y.ndim)
[0.31682708 0.69627909]
(2,)
1
さて、先ほどと同じ値が出力されましたか?(ちなみに私は一発では実装できませんでした。そんなものです。)
以上で、(回帰問題に用いられるタイプの)ニューラルネットワーク(の推論部分)を実装できました。次節では、分類問題で用いるソフトマックス関数を実装します。
参考文献
おわりに
それっぽい内容になってきましたね。
- 2021.07.24:加筆修正しました。
【次節の内容】