Go言語でニューラルネットワーク(フリーコネクト)を組んでみたので、記事を書いてみます。
モチベーション
- 今関わっている案件でGo言語を使っている(なのでGoで何か作ろうと思った)
- ゼロから作るDeepLearningに触発されて、一からニューラルネットワークを組んでみたかった
前提
- 行列計算はOSSを使う(今回はgonumを使ってます)
- NNの各レイヤーはゼロから実装
目標
- フリーコネクト(多層パーセプトロン)なレイヤーを何層か重ねて、MNISTの画像データを0-9に分類可能であること(分類時の精度を可視化出来ると望ましい)
リポジトリ
Githubにあげてます。
リポジトリは以下。
https://github.com/kurama554101/goMLLibrary
※まだReadmeかけてないです・・涙
実装したレイヤー
活性化関数
- Relu
- Sigmoid
- Tanh
- SoftmaxWithLoss
重み付きの素子
- Affine
最適化関数
- SGD(確率的勾配降下)
MNISTのデータを読み込んで分類するサンプル
ソースコード
下記を参照。
https://github.com/kurama554101/goMLLibrary/blob/master/main/MnistSample.go
構造
3層構造
- 1層目 : Affine+Relu
- 2層目 : Affine+Relu
- 3層目 : Affine+SoftmaxWithLoss
精度結果
MNISTテストデータ(1万件)に対しての分類精度は下記。
- loss : 0.07程度
- accuracy : 0.95 - 0.98程度
各レイヤーの詳細
ニューラルネットワークについて
活性化関数であっても、Affine変換なレイヤーであっても、
順伝搬+逆伝搬をするため以下のようなAPIが必要
- Forward()
- Backward()
活性化関数
まず、活性化関数を作った。
活性化関数は入力されたデータをどの程度次のレイヤーに伝搬させるか、を決める関数。
※内部にパラメーターを持たないため、ForwardとBackwardのみがあれば良い。
- Relu
// Relu : Relu関数
type Relu struct {
out mat.Matrix
}
func (relu *Relu) Forward(x mat.Matrix) mat.Matrix {
r, c := x.Dims()
dense := mat.NewDense(r, c, nil) // zero matrix
dense.Apply(func(i, j int, v float64) float64 {
if v > 0 {
return x.At(i, j)
} else {
return 0
}
}, x)
relu.out = dense
return dense
}
func (relu *Relu) Backward(dout mat.Matrix) mat.Matrix {
r, c := dout.Dims()
dense := mat.NewDense(r, c, nil)
dense.Apply(func(i, j int, v float64) float64 {
if relu.out.At(i, j) > 0 {
return v
} else {
return 0
}
}, dout)
return dense
}
- Sigmoid
// Sigmoid : シグモイド関数
type Sigmoid struct {
out mat.Matrix
}
func (sigmoid *Sigmoid) Forward(x mat.Matrix) mat.Matrix {
r, c := x.Dims()
dense := mat.NewDense(r, c, nil) // zero dense
dense.Apply(func(i, j int, v float64) float64 {
return 1.0 / (1.0 + math.Exp(-v))
}, x)
sigmoid.out = dense
return dense
}
func (sigmoid *Sigmoid) Backward(dout mat.Matrix) mat.Matrix {
r, c := dout.Dims()
dense := mat.NewDense(r, c, nil) // zero dense
dense.Apply(func(i, j int, v float64) float64 {
return v * (1.0 - sigmoid.out.At(i, j)) * sigmoid.out.At(i, j)
}, dout)
return dense
}
- Tanh
// Tanh : Tanh関数
type Tanh struct {
out mat.Matrix
}
func (tanh *Tanh) Forward(x mat.Matrix) mat.Matrix {
r, c := x.Dims()
dense := mat.NewDense(r, c, nil)
dense.Apply(func(i, j int, v float64) float64 {
return math.Tanh(v)
}, x)
tanh.out = dense
return dense
}
func (tanh *Tanh) Backward(dout mat.Matrix) mat.Matrix {
r, c := dout.Dims()
dense := mat.NewDense(r, c, nil)
dense.Apply(func(i, j int, v float64) float64 {
return v * (1 - math.Pow(tanh.out.At(i, j), 2))
}, dout)
return dense
}
- SoftmaxWithLoss
※softmaxやcrossEntropyの式は修正したい・・。
type SoftmaxWithLoss struct {
out mat.Matrix
t mat.Matrix
loss float64
}
func (s *SoftmaxWithLoss) Forward(x mat.Matrix, t mat.Matrix) (loss float64, accuracy float64) {
s.t = t
s.out = s.softmax(x)
s.loss = s.crossEntropyError(s.out, t)
accuracy = calcAccuracy(s.out, t)
return s.loss, accuracy
}
func (s *SoftmaxWithLoss) Backward() mat.Matrix {
r, c := s.t.Dims()
dense := mat.NewDense(r, c, nil)
bs := r
dense.Apply(func(i, j int, v float64) float64 {
return (v - s.t.At(i, j)) / float64(bs)
}, s.out)
return dense
}
func (s *SoftmaxWithLoss) softmax(x mat.Matrix) mat.Matrix {
r, c := x.Dims()
tmp := mat.DenseCopyOf(x)
dense := mat.NewDense(r, c, nil)
// 行の計算
for i := 0; i < r; i++ {
// 1つのデータのベクトルを取得し、合計値と要素の最大値を計算
// TODO : 処理に時間がかかるため、リファクタリングする
v := tmp.RowView(i)
max := mat.Max(v.T())
sum := 0.0
for k := 0; k < c; k++ {
sum += math.Exp(v.At(k, 0) - max)
}
// 各列の値を算出
for j := 0; j < c; j++ {
val := math.Exp(v.At(j, 0)-max) / sum
dense.Set(i, j, val)
}
}
return dense
}
// crossEntropyError : 実値と正解データから交差エントロピー誤差を算出
// x : 入力値, t : 正解データ
func (s *SoftmaxWithLoss) crossEntropyError(x mat.Matrix, t mat.Matrix) float64 {
xr, xc := x.Dims()
tr, tc := t.Dims()
// 実際のデータと正解データの行列の形が同じかを確認
if xr != tr || xc != tc {
// TODO エラー対応
return 0.0
}
// バッチサイズを取得(行数)
bs := xr
// 各値の交差エントロピーを求め、バッチサイズを考慮して平均を出力
dense := mat.NewDense(xr, xc, nil)
for i := 0; i < xr; i++ {
for j := 0; j < xc; j++ {
val := -1 * t.At(i, j) * math.Log(x.At(i, j)+delta)
dense.Set(i, j, val)
}
}
return mat.Sum(dense) / float64(bs)
}
Optimizer
次にOptimizerを作った。
Optimizerは勾配情報からパラメーターをアップデートするようなAPIを持てば良い。
※以下のような感じ。
func (sgd *SGD) Update(params map[string]mat.Matrix, grads map[string]mat.Matrix) {
for key, _ := range params {
//r, c := params[key].Dims()
dense := mat.DenseCopyOf(params[key])
// 学習率分だけ勾配を拡縮
dense.Apply(func(i, j int, v float64) float64 {
return v * sgd.lr
}, grads[key])
// 重みから勾配分(学習率を考慮)だけ差をとる
dense.Sub(params[key], dense)
// paramに戻す
params[key] = dense
}
}
Affine変換
活性化関数と違ってパラメーター(重みとバイアス)を持つため、勾配情報から重みをアップデートするためのAPIが必要。
- パラメーターと勾配情報があれば、Optimizerでアップデートが可能。
- なぜかgonumの転置のメソッドが上手く動いていない気がして、自分で作った。
- 最初作り終わった時にMNISTのデータで精度が全く出ず困ったが、理由は重みやバイアスの初期値を0リセットしていたためだった・・・涙(現状は標準偏差0.01、平均0の正規分布で初期化している)
type Affine struct {
w mat.Matrix
b mat.Vector
x mat.Matrix
dw mat.Matrix
db mat.Vector
}
func (aff *Affine) Forward(x mat.Matrix) mat.Matrix {
aff.x = x
batchSize, _ := aff.x.Dims()
_, outputSize := aff.w.Dims()
d := mat.NewDense(batchSize, outputSize, nil)
d.Mul(aff.x, aff.w)
d.Apply(func(i, j int, v float64) float64 {
return aff.b.AtVec(j) + v
}, d)
return d
}
func (aff *Affine) Backward(dout mat.Matrix) mat.Matrix {
// dxの計算
// r, _ := dout.Dims()
r, c := aff.x.Dims()
dx := mat.NewDense(r, c, nil)
dx.Mul(dout, util.Transpose(aff.w))
// dwの計算
r, c = aff.w.Dims()
dw := mat.NewDense(r, c, nil)
dw.Mul(util.Transpose(aff.x), dout)
aff.dw = dw
// dbの計算
r, c = dout.Dims()
db := mat.NewVecDense(aff.b.Len(), nil)
for i := 0; i < r; i++ {
for j := 0; j < c; j++ {
tmpVal := db.AtVec(j)
db.SetVec(j, tmpVal+dout.At(i, j))
}
}
aff.db = db
return dx
}
func (aff *Affine) UpdateParams(params map[string]mat.Matrix) {
// パラメータのアップデート
aff.w = params["w"]
aff.b = mat.DenseCopyOf(params["b"]).ColView(0)
// 勾配のリセット
aff.dw = nil
aff.db = nil
}
所感
- 自分が勉強不足な感もあって、思ったより作るのに時間がかかった。
- やはりpythonと比べると、コード量が絶対的に多いと思う。(numpyが優秀とか、pythonが書きやすいとかはあるかもしれない)
- gonumを使いきれてない・・。API読み直す。(API情報はここ)