C#で自作のディープラーニングフレームワークを作る その1(算術層の実装)

動機

1年半ほど前に、配属された研究室から、 「ゼロから作るDeep LearningPythonで学ぶディープラーニングの理論と実装」を貸与されました。しかしながら、卒業研究ではディープラーニングを使わなかったので特に読んでいませんでした。そこで、ゆっくりとこの本を読みながら自作のディープラーニングフレームワークを作っていこうと思い立ちました。

実は、かなり前に深層学習の青本(第1版)を読んだことがあったので、「ゼロから作る~」は割とスラスラ読めました。この本は、青本と比べると内容は薄いですが、実装に重きを置いているため青本とは全く違った面白さがありました。

www.oreilly.co.jp

使用言語及びライブラリ

「ゼロから作る~」はDeZeroという名前のディープラーニングフレームワークPythonとNumPyで実装することを目的として内容が進んでいきます。しかしながら、自分は頭が悪く、Pythonのような動的型付け言語でそこそこ大きな規模のコードを書くと訳が分からなくなるので、静的型付け言語であるC#を用いてDeZeroのようなフレームワークを作っていこうと思います。

使用言語: C#(.NET 8)
使用ライブラリ: Math.NET

ディープニューラルネットワーク(DNN)の計算では、行列計算を多用するのですが、高速な行列計算を実装するには、それだけでハイパフォーマンスコンピューティング(HPC)の多くの知識が要求されるので、そこはMath.NETというライブラリに頼ります。

この記事の流れ

この記事は、ほとんど開発日記なので、DNNの解説などはそこまで書きません。そのため、図などは非常に少ないです。だって図を作るの面倒くさい・・・
DNNや計算グラフなどの詳細は「ゼロから~」や他の文献を参照してください。

自動微分と計算グラフ

DNNの学習の際には微分を多用します。微分の計算には、以下の微分係数の定義を用いて計算する数値微分という方法があるのですが、パフォーマンス上はあまりよろしくありません。

f'(x) = \lim_{h\to 0} \frac{f(x + h) - f(x)}{h}

そこで、関数そのものを計算グラフというデータ構造で保持をすることで微分の計算を効率化します。
ここでいうグラフとは、離散数学アルゴリズムで登場する方のグラフです。
計算グラフは、ノードに演算を持つグラフであり、入力された値がエッジに沿ってノードを通過していき、最終的な計算結果が出力されます。これを順伝播といいます。数値がノードを通過すると、そのノード内で演算が行われます。例えば、乗算ノードであれば、入力側に2つのエッジがあり、そのエッジから2つの値が入力され、出力側のエッジから2つの値の積が出力されます。

微分の計算の際には、順伝播とは逆向きに数値がエッジを辿ってノードを通過していきます。これを逆伝播といいます。
各ノードについて事前に導関数を実装しておけば、微分の連律鎖に従って計算できます。例えば、\sin(x)ノードなら、逆伝播の際には\cos(x)を計算すればよいです。

なお、以降は計算グラフのノードを、層またはレイヤーと呼びます。なぜこのように呼ぶのかというと、最終的にDNNの1層分の計算も計算グラフのノードとして定義するからです。

設計

とりあえず設計は行き当たりばったりで行っていきます。
フレームワーク名は、Neural.NETとします(雑)。

まずは、すべての層が実装するインターフェースを定義します。
各層は、順伝播を行うメソッドと逆伝播を行うメソッドを持つのですが、引数の数が層の種類によって異なります。
例えば、活性化層(活性化関数)であれば引数は1つですし、乗算層であれば引数は2つです。そこら辺の細かい違いをどう実装するのか考えるのが面倒になったので、まずは特にメソッドを持たないILayerインタフェースを作り、そのILayerインターフェースから、さらにIActivationLayerインターフェースやIArithmeticLayerインターフェースなどを派生させるような設計にしました。これが良い設計なのかどうかは知りません・・・

namespace NeuralNET.Layers
{
    /// <summary>
    /// 計算グラフの各層が実装するインターフェース
    /// </summary>
    public interface ILayer { }
}

加算や乗算などの算術計算を行う層が実装するインターフェースであるIArithmeticLayerを定義します。

using MathNet.Numerics.LinearAlgebra.Single;

using NeuralNET.Layers;

namespace Neural.NET.Layers.Arithmetic
{
    /// <summary>
    /// 算術計算を行う層が実装するインターフェース.
    /// </summary>
    public interface IArithmeticLayer : ILayer
    {
        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y, DenseMatrix res);
        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y);

        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput, (DenseMatrix dx, DenseMatrix dy) res);
        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput);
    }
}

Forwardメソッドが順伝播、Backwardが逆伝播です。バッチ処理にも対応するため、データはすべてDenseMatrix(密行列)オブジェクトで受け取ります。ForwardメソッドとBackwardメソッドが2種類ありますが、引数が多いほうは、呼び出し元から演算結果を格納するDenseMatrixオブジェクトを受け取り、引数が少ないほうは、結果を格納するDenseMatrixオブジェクトをメソッド内でその都度生成します。後者のメソッドのほうが呼び出しが楽な反面、ヒープアロケーションが頻発するのでパフォーマンス的によろしくないです。とりあえず2つ用意しておきました。

以下、このインターフェースを持ちいて、加算層と乗算層を実装していきます。

加算層

以下のAddLayerクラスが加算を行う層です。微分の線形性から加算層の逆伝播は単に後ろの層から伝播してきた値をそのまま伝えるだけです。

using MathNet.Numerics.LinearAlgebra.Single;

namespace Neural.NET.Layers.Arithmetic
{
    /// <summary>
    /// 加算層
    /// </summary>
    public class AddLayer : IArithmeticLayer
    {
        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y, DenseMatrix res)
        {
            x.Add(y, res);
            return res;
        }

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y) => x + y;

        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput, (DenseMatrix dx, DenseMatrix dy) res)
        {
            dOutput.CopyTo(res.dx);
            dOutput.CopyTo(res.dy);
            return res;
        }

        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput) 
            => ((DenseMatrix)dOutput.Clone(), (DenseMatrix)dOutput.Clone());
    }
}

乗算層

以下のMultiplyLayerクラスが乗算を行う層です。
f(x, y) = xy偏微分すると、\frac{\partial f}{\partial x} = y\frac{\partial f}{\partial y} = x より、逆伝播の際には、xyの値を順伝播のときとは逆のエッジに伝えればよいです。

乗算層の場合は、逆伝播のために入力された値を保持しておく必要があります。保持の仕方には、入力のコピーを保持する方法と参照を保持する方法の2種類があります。参照を保持するほうがパフォーマンス的には良いのですが、入力されたDenseMatrixオブジェクトは、順伝播を計算した後に呼び出し元で内容が書き換えられる可能性があります。その場合、逆伝播の結果が狂ってしまいます。そこで、デフォルトでは入力のコピーを保持するようにし、MultiplyLayerのコンストラクタでsaveInputRefにtrueが与えられたら入力の参照を保持するようにします。このようにしておけば事故は起きにくくなるでしょう。

なお、ここで計算する乗算は要素ごとの積(アダマール積)なので、PointwiseMultiplyメソッドで積を計算します。

using MathNet.Numerics.LinearAlgebra.Single;

using NeuralNET;

namespace Neural.NET.Layers.Arithmetic
{
    public class MultiplyLayer : IArithmeticLayer
    {
        DenseMatrix? x;
        DenseMatrix? y;
        bool saveRef;

        /// <summary>
        /// 乗算層のインスタンスを生成します.
        /// </summary>
        /// <param name="saveInputRef">この層への入力の参照を保持するかどうか</param>
        public MultiplyLayer(bool saveInputRef = false) => this.saveRef = saveInputRef;

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y, DenseMatrix res)
        {
            SaveInput(x, y);
            x.PointwiseMultiply(y, res);
            return res;
        }

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y) 
        { 
            SaveInput(x, y);
            return (DenseMatrix)x.PointwiseMultiply(y); 
        }

        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput, (DenseMatrix dx, DenseMatrix dy) res)
        {
            dOutput.PointwiseMultiply(this.y, res.dx);
            dOutput.PointwiseMultiply(this.x, res.dy);
            return res;
        }

        public (DenseMatrix dx, DenseMatrix dy) Backward(DenseMatrix dOutput)
        {
            var dx = (DenseMatrix)dOutput.PointwiseMultiply(this.y);
            var dy = (DenseMatrix)dOutput.PointwiseMultiply(this.x);
            return (dx, dy);
        }

        void SaveInput(DenseMatrix x, DenseMatrix y)
        {
            if (this.saveRef)
            {
                (this.x, this.y) = (x, y);
                return;
            }

            this.x = x.CopyToOrClone(this.x);
            this.y = y.CopyToOrClone(this.y);
        }
    }
}

テスト

「ゼロから作る~」にあったリンゴとみかんのサンプルを用いてテストしてみましょう。
面倒なので、パブリックスタティックヴォイドメインは書かずにトップレベステートメントでサクッと書いちゃいます。

最初のほうでIntel Math Kernel Library(MKL)を有効化します。MKLは、なんか数値計算を高速にやってくれるライブラリです。
Intel製なので、Intelプロセッサに最適化されています。Math.NETは、MKLのほかにもOpenBLASやCUDAを利用できます。

using System;

using MathNet.Numerics.Providers.LinearAlgebra;
using MathNet.Numerics.LinearAlgebra.Single;

using Neural.NET.Layers.Arithmetic;

// Intel Math Kernal Library(MKL)を有効化する.
if (LinearAlgebraControl.TryUseNativeMKL())
    Console.WriteLine("Use Intel MKL");

var apple = DenseMatrix.Create(1, 1, 100.0f);
var appleNum = DenseMatrix.Create(1, 1, 2.0f);
var orange = DenseMatrix.Create(1, 1, 150.0f);
var orangeNum = DenseMatrix.Create(1, 1, 3.0f); 
var tax = DenseMatrix.Create(1, 1, 1.1f);

// layer
var mulAppleLayer = new MultiplyLayer(saveInputRef:true);
var mulOrangelayer = new MultiplyLayer(saveInputRef: true);
var addAppleOrangeLayer = new AddLayer();
var mulTaxLayer = new MultiplyLayer(saveInputRef: true);

// forward
var applePrice = mulAppleLayer.Forward(apple, appleNum);
var orangePrice = mulOrangelayer.Forward(orange, orangeNum);
var allPrice = addAppleOrangeLayer.Forward(applePrice, orangePrice);
var price = mulTaxLayer.Forward(allPrice, tax);

// backward
var dPrice = DenseMatrix.Create(1, 1, 1.0f);
(var dAllPrice, var dTax) = mulTaxLayer.Backward(dPrice);
(var dApplePrice, var dOrangePrice) = addAppleOrangeLayer.Backward(dAllPrice);
(var dOrange, var dOrangeNum) = mulOrangelayer.Backward(dOrangePrice);
(var dApple, var dAppleNum) = mulAppleLayer.Backward(dApplePrice);

Console.WriteLine(price[0, 0]);
Console.WriteLine($"{dAppleNum[0, 0]} {dApple[0, 0]} {dOrange[0, 0]} {dOrangeNum[0, 0]} {dTax[0, 0]}");

スカラも1x1の密行列で与えるため、オリジナルのコードよりも煩雑です。そう考えるとダックタイピングで書けるPythonのほうがシンプルかもです。これを実行するとオリジナルのPythonコードと全く同じ結果になります。浮動小数点誤差でちょっとだけ値が異なりますが。

次回

次回は活性化層を実装していきます。

リポジトリ

ここまでのコミットです。
VisualStudioの機能でリポジトリを作って初コミットしたので、コミットメッセージが自動的に「プロジェクト ファイルを追加します。」という謎メッセージになってしまっているのはご愛嬌(絶対、機械翻訳)。

github.com