最近、Go言語にはまっています。速いし書きやすいしで賛否両論あると思うんですけど僕は好きです。そこで、Go言語で何か書きたいと思い、ニューラルネットワークをフルスクラッチ実装することにしました。といってもまだ学部二年生の私には理論を理解しきっていないふわふわした状態でした。しかし、いろいろネットの記事をあさりまくって何とかXORの学習ができるところまでこぎつけました。
お世話になったサイト一覧
pythonでニューラルネットワーク実装
行列演算と計算グラフ
誤差逆伝播法をはじめからていねいに
Pythonで3層パーセプトロンの誤差逆伝播を実装してみる
NumPyでニューラルネットワークを実装してみる 多層化と誤差逆伝播法編
他にもいろいろなサイトを見ました。書いていただいた方ありがとうございました。この中でも特に行列演算と計算グラフというサイトには助けられました。このサイトのおかげで間違っている部分を見つけることができました。誤差逆伝搬法についての記事が多いのは誤差逆伝搬法で詰まったからです。行列で微分するの辛すぎィ。本当に助かりました。ありがとうございました。
ここから実装をしていくよ
Go言語にはgonum/matrixなる行列計算のライブラリがあります。おそらく並列計算も実装されていてさぞかし効率的に計算できるのでしょう。しかし、一から作って完成させるにはやはり、行列計算から書かねば。また、pythonで実装している方はよく見かけますが(僕もpythonよく使います。)numpyの便利さがどれほどのものなのかわかってますか?僕はわかってなかったです。この実装が終わった時、なぜデータ分析でpythonが使われるか実感しました。そういう意味でも行列計算から書いていくのは良いのではないでしょうか?
プログラムの概形
package main
import (
"os"
"fmt"
"math"
"math/rand"
"time"
"strconv"
)
//大きい値を出力
func max(a float64,b float64) float64{
if a > b{return a} else{return b}
}
//行列を生成
func makeMatrix(i int,j int) [][]float64{
}
//行列を合計
func add(a [][]float64,b [][]float64) [][]float64{
}
//アダマール積
func adaMul(a [][]float64,b [][]float64) [][]float64{
}
//行列の内積
func multi(a [][]float64,b [][]float64) [][]float64{
}
//行列に定数を加算
func constAdd(n float64,mat [][]float64) [][]float64{
}
//行列を定数倍
func constMult(n float64,matrix [][]float64) [][]float64{
}
//行列の転置
func trans(a [][]float64) [][]float64{
}
//行列にベクトルを足し算
/*
[[1,2,3], [[1+1,2+1,3+4],
[4,5,6], + [[1,1,4]] = [4+1,5+1,6+4],
[7,8,9]] [7+1,8+1,9+4]]
*/
func addVector(mat [][]float64,vec [][]float64) [][]float64{
}
//要素1、大きさ1行n列のベクトルとの内積つまり、列方向の合計
func vecMul(mat [][]float64) [][]float64{
}
//シグモイド関数
func sigmoid(x [][]float64) [][]float64{
}
//シグモイド関数の微分(出力された値を引数に入れる)
func diffSigmoid(x [][]float64) [][]float64{
}
//ソフトマックス関数
func softmax(x [][]float64) [][]float64{
}
//二乗誤差
func square(y [][]float64,t [][]float64) float64 {
}
//i*jの中身平均0分散1の乱数の行列を生成
func makeWight(i int,j int) [][]float64{
}
//重みとかいろいろ
var w1_1,w1_0,w2_1,w2_0,layer_z1,layer_a1,layer_z2,layer_a2,dw1,db1,dw2,db2 [][]float64
var costList []float64
//重みの初期化
func initValue(){
}
//順伝搬
func forward(data [][]float64) [][]float64{
}
//コスト
func cost(x [][]float64,y [][]float64) float64{
return square(x,y)
}
//逆伝搬
func back(x [][]float64,y [][]float64){
}
//重みの更新
func update(alpha float64){
}
//学習
func train(x [][]float64,y [][]float64,alpha float64,epoc int){
}
func main(){
//トレーニングデータ
x := [][]float64{{1,1},{0,0},{0,1},{1,0}}
y := [][]float64{{1,0},{1,0},{0,1},{0,1}}
train(x,y,0.1,10000)
fmt.Println(forward(x))
//コストをテキストファイルに出力
file, _ := os.Create(`hogehoge.txt`)
defer file.Close()
for i:=0;i<len(costList);i++{
output := []byte(strconv.FormatFloat(costList[i], 'f', 15, 64) + " ")
file.Write(([]byte)(output))
}
}
行列計算の実装
ここではニューラルネットワークで使われる行列計算についてみていければいいなあと思います。行列は並列計算ができますが、これを読んでいる方が理解しやすいようにあえて並列計算は使いませんでした。(並列にする技術がありませんでした。)実際に使おうという場合にはちゃんとライブラリとか使って速いコードを書くと良いと思います。(当たり前)
内積
みんなご存じ内積です。英訳はinner productらしいです。英弱な私はmultiplicationでいいかなっと思って関数名をmultiにしてしまいました。
//内積
func multi(a [][]float64,b [][]float64) [][]float64{
if len(a[0]) != len(b){
println("ふええ~おにいちゃーん、内積が計算できないよぉ~~")
os.Exit(1)
}
ans := makeMatrix(len(a),len(b[0]))
for i:=0;i<len(a);i++{
for j:=0;j<len(b[0]);j++{
for k:=0;k<len(b);k++{
ans[i][j] += a[i][k] * b[k][j]
}
}
}
return ans
}
しっかり内積が計算できないものが引数に渡されたらプログラムが終了するようにしときます。でも、プログラムが終了すると精神的にダメージが来るのでメッセージはかわいいのにしときます。
アダマール積
変なのでてきました。行列の同じ位置の要素同士をかけ合わせたやつです。
A_{ij} × B_{ij}
これを全ての要素についてやります。数学的な表記は全然わからないので検索かけてください。
//アダマール積
func adaMul(a [][]float64,b [][]float64) [][]float64{
if (len(a) != len(b)) || (len(a[0]) != len(b[0])){
str := "ふええ~おにいちゃーん、大きさが違う行列は計算できないよぉ~~"
println(str)
os.Exit(1)
}
ans := makeMatrix(len(a),len(a[0]))
for i:=0;i<len(a);i++{
for j:=0;j<len(a[0]);j++{
ans[i][j] = a[i][j] * b[i][j]
}
}
return ans
}
エラーメッセージ可愛いですね。
行列を生成
いちいち行列をループ二回回すのも面倒なので要素がすべて0の行列を作る関数を作っときました。
//行列を生成
func makeMatrix(i int,j int) [][]float64{
ans := make([][]float64,i)
for k:=0;k<i;k++{
ans[k] = make([]float64,j)
}
return ans
}
行列の転置
行列計算に欠かせない処理ですよね。
//行列の転置
func trans(a [][]float64) [][]float64{
ans := makeMatrix(len(a[0]),len(a))
for i:=0;i<len(a[0]);i++{
for j:=0;j<len(a);j++{
ans[i][j] = a[j][i]
}
}
return ans
}
行列の足し算
アダマール積の加法バージョンです。
//行列を合計
func add(a [][]float64,b [][]float64) [][]float64{
if (len(a) != len(b)) || (len(a[0]) != len(b[0])){
str := "ふええ~おにいちゃーん、大きさが違う行列は足し算できないよぉ~~"
println(str)
os.Exit(1)
}
ans := makeMatrix(len(a),len(a[0]))
for i:=0;i<len(a);i++{
for j:=0;j<len(a[0]);j++{
ans[i][j] = a[i][j] + b[i][j]
}
}
return ans
}
エラーメッセージは可愛く
シグモイド関数
numpyだとシグモイド関数、ただの数の場合のやつ作っといてもうまく計算してくれるんですが、goだとそうもいかないので行列のすべての要素をシグモイド関数にぶっこみます。
//シグモイド関数
func sigmoid(x [][]float64) [][]float64{
ans := makeMatrix(len(x),len(x[0]))
for i:=0;i<len(x);i++{
for j:=0;j<len(x[0]);j++{
ans[i][j] = 1 / (1 + math.Exp(-x[i][j]))
}
}
return ans
}
シグモイド関数の微分
シグモイド関数の微分は計算後の値で書き表すことができます。
f(x)=\frac{1}{1+e^{-ax}}
\frac{df(x)}{dx} = af(x)(1-f(x))
といった感じで表すことができます。数式の表記法が間違っていたら教えてください。まあ要するに出力値に1から出力値を引いたものをかけると微分した値になるんです。式変形はググってください。たくさんあります。
//シグモイド関数の微分(出力された値を引数に入れる)
func diffSigmoid(x [][]float64) [][]float64{
ans := makeMatrix(len(x),len(x[0]))
for i:=0;i<len(x);i++{
for j:=0;j<len(x[0]);j++{
ans[i][j] = x[i][j] * (1.0 - x[i][j])
}
}
return ans
}
ソフトマックス関数
出力層でいろんな値が出てくるんですけど、それを合計で1になるようにしてくれる関数です。
f(x) = \frac{e^{x_i}}{e^{x_1}+e^{x_2}+...+e^{x_n}}
こんな感じです。今回は行方向にデータの合計を1に抑えたいのでそんな感じで実装します。
//ソフトマックス関数
func softmax(x [][]float64) [][]float64{
expMatrix := makeMatrix(len(x),1)
for i:=0;i<len(x);i++{
for j:=0;j<len(x[0]);j++{
expMatrix[i][0] += math.Exp(x[i][j])
}
}
ans := makeMatrix(len(x),len(x[0]))
for i:=0;i<len(x);i++{
for j:=0;j<len(x[0]);j++{
ans[i][j] = math.Exp(x[i][j]) / expMatrix[i][0]
}
}
return ans
}
二乗誤差
機械学習って、教師データと出力が同じ値にしたいんです。でもぱっと見じゃどのくらい近づいてるかわかんないじゃないですか。だから教師データと出力の差の二乗を取って足してあげるんですよ。
//二乗誤差
func square(y [][]float64,t [][]float64) float64 {
n := 0.0
for i:=0;i<len(y);i++{
for j:=0;j<len(y[0]);j++{
n += (t[i][j] - y[i][j]) * (t[i][j] - y[i][j])
}
}
return n/float64(len(y) * len(y[0]))
}
他にもクロスエントロピーとかがあります。ってかそっちの方が一般的です。たぶん...
重みの生成
ニューラルネットワークって入力されたデータと重みをとって伝播してくじゃないですか。そんで重みをイイ感じに修正して精度を上げるんですよね。でもさ、最初はどうすればいいのさって感じで初期値に平均0分散1の値を使ってやろうじゃないのって感じの関数を実装します。
//i行j列の平均0分散1の乱数の行列を生成
func makeWight(i int,j int) [][]float64{
ans := makeMatrix(i,j)
for ii:=0;ii<i;ii++{
for jj:=0;jj<j;jj++{
ans[ii][jj] = rand.NormFloat64()
}
}
return ans
}
カウンタ変数が気持ち悪いのはその時の僕の気持ちです。(謎)もう少しまともな実装ができるように精進します。
残りの最初の設計がガバガバすぎて実装中に必要になった関数
本当はこんなめんどくさいことしなくていいのかもしれないけど私の未熟さゆえに必要になってしまった者たちです。
行列にベクトルを足し算
全ての行にベクトルを足し算したいのでこんなものを書きました。コードにコメントアウトしてあるような感じの処理です。
//行列にベクトルを足し算
/*
[[1,2,3], [[1+1,2+1,3+4],
[4,5,6], + [[1,1,4]] = [4+1,5+1,6+4],
[7,8,9]] [7+1,8+1,9+4]]
*/
func addVector(mat [][]float64,vec [][]float64) [][]float64{
if len(vec) != 1 {
println("ふええ~おにいちゃーん、ベクトルじゃないないよ~~")
os.Exit(1)
} else if len(mat[0]) != len(vec[0]){
println("ふええ~おにいちゃーん、列の数が違うよ~~")
os.Exit(1)
}
ans := makeMatrix(len(mat),len(mat[0]))
for i:=0;i<len(mat);i++{
for j:=0;j<len(mat[0]);j++{
ans[i][j] = mat[i][j] + vec[0][j]
}
}
return ans
}
要素1、大きさ1行n列のベクトルとの内積
重みのバイアス項を分離してプログラムを書いたのでそのための関数です。うまく中に入れて書いてしまえれば必要ないと思います。
//要素1、大きさ1行n列のベクトルとの内積つまり、列方向の合計
func vecMul(mat [][]float64) [][]float64{
ans := makeMatrix(1,len(mat[0]))
for i:=0;i<len(mat);i++{
for j:=0;j<len(mat[0]);j++{
ans[0][j] += mat[i][j]
}
}
return ans
}
行列の定数倍
文字通りです。行列を定数倍します。他の関数でうまく処理をできたらいいんですけど私にはできなかったので書きました。
//行列を定数倍
func constMult(n float64,matrix [][]float64) [][]float64{
ans := makeMatrix(len(matrix),len(matrix[0]))
for i:=0;i<len(matrix);i++{
for j:=0;j<len(matrix[0]);j++{
ans[i][j] = n * matrix[i][j]
}
}
return ans
}
行列に数字を加算
これも他の関数でできそうでできなかったものです。すべての要素に同じ数を足します。
//行列に定数を加算
func constAdd(n float64,mat [][]float64) [][]float64{
ans := makeMatrix(len(mat),len(mat[0]))
for i:=0;i<len(mat);i++{
for j:=0;j<len(mat[0]);j++{
ans[i][j] = mat[i][j] + n
}
}
return ans
}
ニューラルネットワーク本体
ひとまず必要な関数とかの実装は終わりました。ここからはニューラルネットワークの実装に入っていきます。変数の初期化、順伝搬、逆伝搬、重みの更新の4つの関数にわけて実装します。
変数の初期化
ここでは、伝搬していくのに必要な重みとか係数とか言われるやつを作ります。他のサイトではだいたいWで書かれてます。
//重みの初期化
func initValue(){
//中間層のノードの数
midNode := 2
//データの次元数
dim := 2
//出力層のノード数
finNode := 2
//入力層から中間層への重み
w1_1 = makeWight(dim,midNode)
//入力層から中間層へのかけ合わせないやつ、バイアス項
w1_0 = makeWight(1,midNode)
//中間層から出力層への重み
w2_1 = makeWight(midNode,finNode)
//中間層から出力層へのかけ合わせないやつ、バイアス項
w2_0 = makeWight(1,finNode)
}
今回は三層の一番単純なニューラルネットワークを実装します。重みはバイアス項とそうでないものにわけて作りました。訓練データと順番に内積を取っていけるようにサイズは設定してあります。
順伝搬
上で作った行列を使って順番に内積を取っていきます。
//順伝搬
func forward(data [][]float64) [][]float64{
layer_z1 = addVector(multi(data,w1_1),w1_0)
layer_a1 = sigmoid(layer_z1)
layer_z2 = addVector(multi(layer_a1,w2_1), w2_0)
layer_a2 = softmax(layer_z2)
return layer_a2
}
逆伝搬
僕が一番苦戦したところです。一応XORは学習できてるし二乗誤差も減っていっているのでたぶんあってるとは思うんですけど、もし間違いを見つけていただいた方はじゃんじゃんコメントを送ってください。あとから一応更新の式を追記しようと思ってます。どうにも関数をたくさんつなげなければならなかったので式が長くなってます。ごめんなさい。
間違ってたのでちょっと修正しました。
//逆伝搬
func back(x [][]float64,y [][]float64){
//中間層-出力層の重みでの微分を求める
output_delta := add(layer_a2,constMult(-1,y))
dw2 = multi(trans(layer_a1),output_delta)
db2 = vecMul(output_delta)
//入力層-中間層の重みでの微分を求める
mid_delta := adaMul(multi(output_delta,trans(w2_1)),diffSigmoid(layer_a1))
dw1 = multi(trans(x),mid_delta)
db1 = vecMul(mid_delta)
}
重みの更新
上で求めた勾配をもとに重みを更新していきます。alphaは学習率です。
//重みの更新
func update(alpha float64){
w1_1 = add(w1_1,constMult(-alpha,dw1))
w1_0 = add(w1_0,constMult(-alpha,db1))
w2_1 = add(w2_1,constMult(-alpha,dw2))
w2_0 = add(w2_0,constMult(-alpha,db2))
}
ニューラルネットワークの学習をする
いままで作ってきた関数を全て結合してニューラルネットワークの学習ができる関数を作ります。x(説明変数)とy(目的変数)はデータです。alphaは学習率、epocは何回学習するかです。
func train(x [][]float64,y [][]float64,alpha float64,epoc int){
initValue()
for i:=0;i<epoc;i++{
forward(x)
back(x,y)
update(alpha)
if epoc % 10 == 0{
costList = append(costList,cost(forward(x),y))
}
}
}
学習結果
これらの関数を最初の概形の所にすべてコピペしますと、なんと!人工知能が自分で作れてしまいます!!!冗談はさておき、これで学習を行うと誤差の推移がhogehoge.txtというファイルに出力され、{{1,1},{0,0},{0,1},{1,0}}のデータに対して予測が行われます。
結果
[[0.9542617394571442 0.045738260542855735]
[0.9568965583091228 0.043103441690877244]
[0.03826649017685172 0.9617335098231483]
[0.038285526165873905 0.9617144738341261]]
{1,1},{0,0}の場合には左側の値がほぼ1になり、{1,0},{0,1}の場合には右側の値が
1になりました。
グラフは誤差の推移です。(グラフはpythonで書きました。グラフはやっぱpythonですね。)
最後に
Go言語ってまだ知らない方もいらっしゃると思うんですけどいい言語ですよ。次はこれをもう少し改良してMNIST の手書き数字の分類もやってみたいなあと思ってます。計算してるのは2コアのCPU君なので効率よくしても手書き数字が限界かなあと思うので限界に挑戦します。ソースコードはまだデバックなどでPrintがめちゃんこあるのでそれを綺麗にしてからgithubに上げようと思います。上げたらURL追記します。
追記しました。↓
ソースコード