Help us understand the problem. What is going on with this article?

Goでゼロからニューラルネットワークを組んでみた

More than 1 year has passed since last update.

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情報はここ)
access
SDNからセンサ、家電、電子書籍まで。ACCESSはあらゆるレイヤのデバイス、サービスを「繋げて」いきます。
http://jp.access-company.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away