LoginSignup
20
7

More than 3 years have passed since last update.

Goで機械学習しよう!~Gorgoniaでニューラルネットワークを構築する~

Last updated at Posted at 2019-12-18

はじめに

本記事は QualiArts Advent Calendar 2019 19日目の記事です。

TL;DR

Gorgoniaを使い、簡単なニューラルネットワークを構築した。
その過程で躓いたポイントと、最終的な結果を示す。

この記事のターゲット

  • Goを使っている(または興味があり、ある程度記法は知っている)
  • 機械学習に興味がある
  • Gorgoniaについては全く知らない

背景

弊社ではアゲテクを始めとして、数多くの勉強会が開かれており、その中には機械学習に関するものもあります。
私もその勉強会に参加しており、去年あたりには「ゼロから作るDeep Learning 2」を勉強会の参加者で輪読し、実際にPythonで作るなどしていました。

一方で、私は最近業務で使う言語がJavaからGoになり、日々Go力を高めるべく頑張っています。

そこで今回は、Goを使って「ゼロから作るDeep Learning 2」の1章にある Two Layer Neural Network を作ってみることにしました。
Goで機械学習をするにあたって、完全にスクラッチで作っても良いのですが、機械学習に使えるライブラリがあると聞いたので、今回はそれを使ってみます。
なお、この勉強をするにあたって、もくもく会の時間は大いに活用させてもらいました。

Gorgoniaとは

https://github.com/gorgonia/gorgonia
GorgoniaはGoで機械学習をするためのライブラリで、TensorFlowなどのようなものです。
Gorgoniaの作者が特徴として述べているものはいくつかありますが、個人的にその中でも重要と感じているのは

  • かなり速い
  • 分散コンピューティングをサポートしている

あたりかなと思っています。
これらはGoによる実装だからこそなのではないかと。多分。他の言語でできないわけではないですが。
逆に、それ以外の特徴についてはGoで機械学習をする明確な理由にはならないかもしれません。

Gorgoniaでは基本的にノードとノードをつなげてグラフを構築し、計算を実行することになります。
例えば、X+Y=Zを表現するグラフは以下のようになります。

グラフ例: X+Y=Z

ここで丸で表されているものはノードです。
これをプログラム上で実装すると以下のようになります。

g := gorgonia.NewGraph()

var x, y, z *gorgonia.Node
var err error

x = gorgonia.NewScalar(g, gorgonia.Float64, gorgonia.WithName("x"))
y = gorgonia.NewScalar(g, gorgonia.Float64, gorgonia.WithName("y"))
z, _ = gorgonia.Add(x, y)

今回はスカラ値同士の計算でしたが、行列に対しても同様の方法で計算することができます。
加えて、SoftMaxなどのニューラルネットワークを構築するにあたって便利なノードも用意されています。

対象とする問題

うずまき
先述したとおり、ここでは「ゼロから作るDeep Learning 2」の1章にある Two Layer Neural Network を作り、うずまき状に配置された(線形分離できない)点をうまくクラスタリングする問題に適用させます。

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

今回実装するニューラルネットワークを以下に示します。
TowLayerNeuralNetwork
このように、X座標とY座標をもとに、クラス1~3のどれに分類されるかを推定するものになっています。

処理の流れとしては以下のようになります
処理の流れ
MatMulやSigmoidについては今回は説明を省きます。
ぜひ「ゼロから作るDeep Learning 2」を読んでください。おすすめです。
ざっくりいうと、XYからSigmoidまでが入力層から隠れ層までの処理、SigmoidからCまでが隠れ層から出力層までの処理になります。
そして、右側にある重み(Weight1,Weight2)とバイアス(Bias1,Bias2)が最適化の対象になります。
これらを最適化することで、ニューラルネットワーク全体が問題に対して最適化し、正しい推論ができるようになります。

実際に作ってみた

早速、実際に作ったコードを示します。

main.go
package main

import (
    nnmodel "adventcalendar/simplenn/nnmodel/twolayernn"
    crand "crypto/rand"
    "fmt"
    "github.com/pkg/errors"
    "github.com/seehuhn/mt19937"
    "gorgonia.org/gorgonia"
    "gorgonia.org/tensor"
    "log"
    "math"
    "math/big"
    "math/rand"
    _ "net/http/pprof"
)

const (
    // 次元数(入力)
    dimensionNum = 2
    // クラス数(出力)
    classNum = 3
    // サンプル数
    sampleNum = 100
    // 隠れ層のサイズ
    hiddenSize = 10
    // エポック数
    maxEpoch = 300000
    // バッチサイズ
    batchSize = 30
)

func main() {
    seed, err := crand.Int(crand.Reader, big.NewInt(math.MaxInt64))
    if err != nil {
        log.Fatal(err)
    }
    //乱数の生成にはメルセンヌ・ツイスタを使用
    rng := rand.New(mt19937.New())
    rand.Seed(seed.Int64())

    g := gorgonia.NewGraph()

    //2層ニューラルネットワークを構築
    model := nnmodel.New(g, dimensionNum, hiddenSize, classNum, sampleNum * classNum)

    //学習前の正答率を確認
    err = checkAccuracy(g, model, dimensionNum, classNum, sampleNum, rng)
    if err != nil {
        log.Fatal(err)
    }

    //学習
    err = train(g, model, dimensionNum, classNum, sampleNum, maxEpoch, batchSize, rng)
    if err != nil {
        log.Fatal(err)
    }

    //学習後の正答率を確認
    err = checkAccuracy(g, model, dimensionNum, classNum, sampleNum, rng)
    if err != nil {
        log.Fatal(err)
    }
}

//入力されたニューラルネットワーク用いtestCount回推論、正答率を計算する
func checkAccuracy(g *gorgonia.ExprGraph, model *nnmodel.TwoLayerNeuralNetworkModel, dimensionNum, classNum, testCount int, rng *rand.Rand) error {
    vm := gorgonia.NewTapeMachine(g)
    //テストデータを生成
    inputDense, expectedDense := createTestData(dimensionNum, classNum, testCount, rng)

    //入力ノードにテストデータを注入
    if err := gorgonia.Let(model.Input, inputDense); err != nil {
        return errors.WithStack(err)
    }

    //実行
    if err := vm.RunAll(); err != nil {
        return errors.WithStack(err)
    }

    var t tensor.Tensor
    var ok bool
    if t, ok = model.Output.Value().(tensor.Tensor); !ok {
        return errors.New("expects a tensor")
    }
    //推論結果を比較しやすい形に変形
    actual, err := tensor.Argmax(t, 1)
    if err != nil {
        panic(err)
    }
    actualData := actual.Data().([]int)

    //期待される結果を比較しやすい形に変形
    expected, err := tensor.Argmax(expectedDense, 1)
    if err != nil {
        return errors.WithStack(err)
    }
    expectedData := expected.Data().([]int)
    var correctAnswerNum int
    for i := 0; i < testCount; i++ {
        //推論結果と期待される結果を比較
        if actualData[i] == expectedData[i] {
            correctAnswerNum++
        }
    }
    fmt.Printf("%d問中 %d問正答 正答率 %.5f\n", testCount, correctAnswerNum, float64(correctAnswerNum)/float64(testCount))
    return nil
}

//学習
func train(g *gorgonia.ExprGraph, model *nnmodel.TwoLayerNeuralNetworkModel, dimensionNum int, classNum int, sampleNum int, maxEpoch int, batchSize int, rng *rand.Rand) error {
    //最適化には基本的な最適化手法を利用
    solver := gorgonia.NewVanillaSolver(gorgonia.WithBatchSize(float64(batchSize)), gorgonia.WithLearnRate(1))
    //勾配を計算するためのノードを登録
    if err := model.RegistryCalcGradNode(); err != nil {
        return errors.WithStack(err)
    }

    vm := gorgonia.NewTapeMachine(g, gorgonia.BindDualValues(model.Learnables()...))

    //トレーニング用のデータを生成
    inputDense, expectedDense := createTrainingData(dimensionNum, classNum, sampleNum, rng)
    for i := 0; i < maxEpoch; i++ {
        //入力のノードにトレーニング用のデータを注入
        if err := gorgonia.Let(model.Input, inputDense); err != nil {
            return errors.WithStack(err)
        }
        //期待される結果のためのノード(勾配計算のためのノード)にトレーニング用のデータを注入
        if err := gorgonia.Let(model.Expected, expectedDense); err != nil {
            return errors.WithStack(err)
        }

        if err := vm.RunAll(); err != nil {
            return errors.WithStack(err)
        }

        //1万回に1回進捗を出力
        if i%10000 == 0 {
            fmt.Printf("loop:%d, cost:%v\n", i, model.CostValue)
        }

        //勾配から重みとバイアスを更新
        if err := solver.Step(gorgonia.NodesToValueGrads(model.Learnables())); err != nil {
            return errors.WithStack(err)
        }

        vm.Reset()
    }

    return nil
}

//トレーニング用データの生成
func createTrainingData(dimensionNum int, classNum int, sampleNum int, rng *rand.Rand) (*tensor.Dense, *tensor.Dense) {
    inputArray := make([]float64, sampleNum*classNum*dimensionNum)
    outputArray := make([]float64, sampleNum*classNum*classNum)
    for j := 0; j < classNum; j++ {
        for i := 0; i < sampleNum; i++ {
            rate := float64(i) / float64(sampleNum)
            theta := float64(j)*4.0 + 4.0*rate + rng.NormFloat64() * 0.2

            ix := sampleNum*j + i
            inputArray[ix*dimensionNum] = rate * math.Sin(theta)
            inputArray[ix*dimensionNum+1] = rate * math.Cos(theta)
            outputArray[ix*classNum+j] = 1
        }
    }
    input := tensor.New(tensor.Of(tensor.Float64), tensor.WithShape(sampleNum*classNum, dimensionNum), tensor.WithBacking(inputArray))
    output := tensor.New(tensor.Of(tensor.Float64), tensor.WithShape(sampleNum*classNum, classNum), tensor.WithBacking(outputArray))
    return input, output
}

//テスト用データを生成
func createTestData(dimensionNum int, classNum int, sampleNum int, rng *rand.Rand) (*tensor.Dense, *tensor.Dense) {
    inputArray := make([]float64, sampleNum*classNum*dimensionNum)
    outputArray := make([]float64, sampleNum*classNum*classNum)
    for i := 0; i < sampleNum*classNum; i++ {
        class := rng.Intn(classNum)
        rate := rng.Float64()
        theta := float64(class)*4.0 + 4.0*rate + rng.NormFloat64() * 0.2

        inputArray[i*dimensionNum] = rate * math.Sin(theta)
        inputArray[i*dimensionNum+1] = rate * math.Cos(theta)
        outputArray[i*classNum+class] = 1
    }
    input := tensor.New(tensor.Of(tensor.Float64), tensor.WithShape(sampleNum*classNum, dimensionNum), tensor.WithBacking(inputArray))
    output := tensor.New(tensor.Of(tensor.Float64), tensor.WithShape(sampleNum*classNum, classNum), tensor.WithBacking(outputArray))
    return input, output
}
model.go
package twolayernn

import (
    "github.com/pkg/errors"
    "gorgonia.org/gorgonia"
    "gorgonia.org/tensor"
)

//2層ニューラルネットワーク
type TwoLayerNeuralNetworkModel struct {
    inputSize, hiddenSize, outputSize, sampleNum int
    // グラフ
    graph *gorgonia.ExprGraph
    //重み
    weight1, weight2 *gorgonia.Node
    //バイアス
    bias1, bias2 *gorgonia.Node
    //推論用
    Input, Output *gorgonia.Node
    OutputValue   gorgonia.Value
    //勾配計算用
    Expected  *gorgonia.Node
    CostValue gorgonia.Value
}

//各層のノードを生成し、モデルに設定して返す
func New(graph *gorgonia.ExprGraph, inputSize, hiddenSize, outputSize, sampleNum int) *TwoLayerNeuralNetworkModel {
    weight1 := gorgonia.NewMatrix(graph, tensor.Float64, gorgonia.WithShape(inputSize, hiddenSize), gorgonia.WithName("Weight1"), gorgonia.WithInit(gorgonia.Gaussian(0, 1)))
    weight2 := gorgonia.NewMatrix(graph, tensor.Float64, gorgonia.WithShape(hiddenSize, outputSize), gorgonia.WithName("Weight2"), gorgonia.WithInit(gorgonia.Gaussian(0, 1)))
    bias1 := gorgonia.NewMatrix(graph, tensor.Float64, gorgonia.WithShape(1, hiddenSize), gorgonia.WithName("Bias1"), gorgonia.WithInit(gorgonia.Zeroes()))
    bias2 := gorgonia.NewMatrix(graph, tensor.Float64, gorgonia.WithShape(1, outputSize), gorgonia.WithName("Bias2"), gorgonia.WithInit(gorgonia.Zeroes()))

    input := gorgonia.NewTensor(graph, tensor.Float64, 2, gorgonia.WithShape(sampleNum, inputSize), gorgonia.WithName("Input"))

    model := &TwoLayerNeuralNetworkModel{
        graph:      graph,
        inputSize:  inputSize,
        hiddenSize: hiddenSize,
        outputSize: outputSize,
        sampleNum:  sampleNum,
        Input:      input,
        weight1:    weight1,
        weight2:    weight2,
        bias1:      bias1,
        bias2:      bias2,
    }

    model.RegistryForwardNode()

    return model
}

//推論のためのノードを登録
func (m *TwoLayerNeuralNetworkModel) RegistryForwardNode() {
    layer1Node1 := gorgonia.Must(gorgonia.Mul(m.Input, m.weight1))
    layer1Node2 := gorgonia.Must(gorgonia.BroadcastAdd(layer1Node1, m.bias1, nil, []byte{0}))
    layer1Result := gorgonia.Must(gorgonia.Sigmoid(layer1Node2))

    layer2Node1 := gorgonia.Must(gorgonia.Mul(layer1Result, m.weight2))
    layer2Result := gorgonia.Must(gorgonia.BroadcastAdd(layer2Node1, m.bias2, nil, []byte{0}))

    m.Output = layer2Result
    gorgonia.Read(m.Output, &m.OutputValue)
}

//勾配計算のためのノードを登録
func (m *TwoLayerNeuralNetworkModel) RegistryCalcGradNode() error {
    expectedNode := gorgonia.NewTensor(m.graph, tensor.Float64, 2, gorgonia.WithShape(m.sampleNum, m.outputSize), gorgonia.WithName("Expected"))

    softMaxNode := gorgonia.Must(gorgonia.SoftMax(m.Output))
    logNode := gorgonia.Must(gorgonia.Log(softMaxNode))
    lossesNode := gorgonia.Must(gorgonia.HadamardProd(expectedNode, logNode))
    sumNode := gorgonia.Must(gorgonia.Sum(lossesNode, 1))
    costNode := gorgonia.Must(gorgonia.Mean(sumNode))
    costNode = gorgonia.Must(gorgonia.Neg(costNode))

    if _, err := gorgonia.Grad(costNode, m.Learnables()...); err != nil {
        return errors.WithStack(err)
    }

    m.Expected = expectedNode
    gorgonia.Read(costNode, &m.CostValue)

    return nil
}

//最適化対象のノードを返す
func (m *TwoLayerNeuralNetworkModel) Learnables() gorgonia.Nodes {
    return gorgonia.Nodes{m.weight1, m.weight2, m.bias1, m.bias2}
}

躓いたポイント

以下に私が躓いたポイントを挙げていきます。
注意:

  • もしかしたらドキュメントを読めば一発でわかるところもあるかもしれませんが、あくまで私が躓いたポイントとして参考にしていただけますと幸いです
  • 間違って理解していることがあるかもしれません。その場合は(やさしめに)指摘していただけますと幸いです
    • 特にあとにも述べますが、学習については挙動がなんか変な気がします。そのあたりは怪しいと思ってください

いつ推論(計算)が行われているのか

        if err := vm.RunAll(); err != nil {

ここです。
つまり、ここに来るまでは、いくらノードから値を取得しようとしても(値がどのように変化しているか順次見たくても)できません。
ノード間でどのように値が変化しているか見たいときは、ノードをそれぞれ保存しておき、RunAll後にそれらの中身を見る感じになります。

ネットワークの構築いつやればいいのか

最初に1度作ればOK。
基本的には入力は最初に作ったノードに対して設定することになる。
なお、実行中にネットワークの形が変わるものについては NewTapeMachine ではなくNewLispMachine を使うと良いようだ。

新しいノードを作っても入力が変わらない

新しくノードを作って繋げるのではなく、Letを使ってノードに設定されている値を変更してください。

        if err := gorgonia.Let(model.Input, inputDense); err != nil {

全く学習しない…入力に何を入れても結果が変わらない…といったとき原因はこれでした。
ノードを新たに作ってつないでも、エラーもなく、一見動いているように見えていたのがやっかいでした…

gorgonia.Readしても値が入らない

注意すべき点は2点(ほぼ1点)

  • Readしてもすぐには値は入らない
  • RunAll時に値が入るので、それ以前では値に変化がない

なので、ネットワーク構築時にReadして、値を入れる先を宣言しておき、RunAll後にその値を確認することになります。

日本語の資料があまり存在しない

英語勉強しましょう。
というか、英語の資料もあんまりなかった…
今回は多くはコードのコメントを読んで作りました。
ちなみに、公式で簡単な日本語HowToはあります。
https://gorgonia.org/ja/getting-started/

実際に動かしてみた

100問中 29問正答 正答率 0.29000
loop:0, cost:1.7385908191512776
loop:10000, cost:0.6770933921640884
loop:20000, cost:0.5105559634421961
loop:30000, cost:0.3175025750533134
loop:40000, cost:0.2149465930726537
loop:50000, cost:0.17103209507830444
loop:60000, cost:0.14561705043095338
loop:70000, cost:0.12851492871747155
loop:80000, cost:0.11599694288788873
loop:90000, cost:0.10629851042907813
loop:100000, cost:0.0984684457069787
loop:110000, cost:0.09194986231927145
loop:120000, cost:0.08639503639137137
loop:130000, cost:0.08157314760891886
loop:140000, cost:0.07732048419832993
loop:150000, cost:0.07351182698109086
loop:160000, cost:0.0700437655186587
loop:170000, cost:0.06682843597875264
loop:180000, cost:0.06380077476043042
loop:190000, cost:0.06093315815927715
loop:200000, cost:0.05823311847815662
loop:210000, cost:0.05571901710043393
loop:220000, cost:0.05340074490836961
loop:230000, cost:0.05127632607523461
loop:240000, cost:0.049335546033291704
loop:250000, cost:0.047563983465209195
loop:260000, cost:0.045945800020561936
loop:270000, cost:0.04446535332907093
loop:280000, cost:0.043108010222600704
loop:290000, cost:0.041860487253972234
100問中 72問正答 正答率 0.72000

うん、学習はしているっぽい。
けど!やたら!学習が!遅い!(30万epockでやっと正答率7割超え)

残った課題

学習遅すぎ

前述の通り。多分何か間違えてる。
(ゼロから作る~では300エポックで0.1程度)
せっかく早いライブラリを使っているにも関わらず、この結果はちょっとね!
学習周りのノードの設定、または傾きの出し方が間違えているのではないかと疑っている。

入力の形を変更したいけどできない

今回、本当は正答率の確認に300問出すつもりはなかったが、もとのサンプル数が300で、そこから1問用に形を変えようとするとエラーになり、それを改善することはできなかったため、やむなく300問出して、そのうちの100問を使うことで正答率を計算した。
できないということはないと思うので、これを調査する必要がある。

まとめ

  • 一応Gorgoniaで簡単なニューラルネットワークを構築することはできた
    • 簡単とは言わないが、処理の流れが頭にあれば、ある程度イメージ通りに作りやすいライブラリだとは思う
  • ただ、現状は学習の速度が異常に遅い。これはライブラリの問題というより、実装の問題の可能性が高い
    • 学習周りが特に怪しい。調査の必要あり
20
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
7