Pythonで利用できるリバーシのライブラリを作りました

現在、技術書典16に向けて、リバーシ強化学習で攻略する内容の本を書いています。自分自身は、普段からゲームAIをC#C++で実装しているのですが、現在執筆中の本では、人口が多く、比較的行数が少なくなりがちなPythonを利用しています。しかしながら、8x8のフルサイズのリバーシPythonで全てを実装すると、探索などやろうものならとてつもない時間を要します。そこで、新たにPython用のリバーシライブラリを開発しました。バックエンドはC++で実装しているため、Pythonのみで実装する場合に比べて遥かに高速に動作します。また、AVX2を用いて処理を高速化していることから、AVX2に対応しているCPUで最も高速に動作します。

実はすでにcreversiという高速なリバーシライブラリが存在するのですが、M1搭載MacBook Airだとコンパイルエラーになったため、1から実装することにしました。とはいっても、過去に開発したリバーシの思考エンジンのコードを流用できたので1週間足らずで完成しました(開発期間の大半は、setup.pyの書き方に悩まされてました・・・)。

以下で公開しているので良かったら使ってください。ReadMe.mdに細かい説明があります。
緩いライセンスにしたかったのですが、GPL3ライセンスのプロジェクトのコードをかなり参考にしているため、本ライブラリもGPL3ライセンスです。

github.com

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

Softmax層

Softmax関数を計算する層です。出力層でよく用いられる活性化関数の一種ですが、少し特殊なため前回は実装しませんでした。
今まで実装した活性化関数(sigmoid, tanh, ReLU)は、変数を1つ受け取って1つの値を出力する関数でした。対して、Softmax関数は変数をn個受け取って、n個の値を出力する関数です。Softmax関数への入力をx_1, x_2, ... , x_n、出力を y_1, y_2, ... , y_nとしたとき、k番目の出力y_kは以下のようになります。

 y_k = \frac{\exp(x_k)}{\sum_{i} \exp(x_i)}

上の式から、Softmax関数の出力の和\sum_{i}y_iは1となるため、確率分布を表現する際によく用いられます。Softmax関数は多変数関数なので偏微分をします。y_kx_i偏微分すると次式のようになります。


 \frac{\partial y_k}{\partial x_i}=
\begin{cases}
  y_k(1 - y_k)&  (k = i)\\
  -y_k y_i &  (k \neq i)
  \end{cases}

k=iの時、シグモイド関数微分と全く同じ見た目になりますが、これはSoftmax関数がシグモイド関数を多変数に拡張したものだからです。実際、Sotmax関数の2変数バージョンは、式変形をするとシグモイド関数に一致します。

Softmax層の逆伝播

Softmax層は、逆伝播の際に入力x_1, x_2, ... , x_nそれぞれに勾配を伝えます。ここでは、入力 x_kに関する勾配\delta_{x_k}を求めてみます。
Softmax層の出力側から逆伝播してきた勾配を \delta_{y_1}, \delta_{y_2}, ... , \delta_{y_n}とします。このとき、\delta_{x_k}の値は次式のように表せます。

\delta_{x_k} = \sum_{i} \frac{\partial y_i}{\partial x_k} \delta_{y_i}

 これをSoftmax関数の微分を用いて式変形していきます。

\sum_{i} \frac{\partial y_i}{\partial x_k} \delta_{y_i} = y_k\left(-\sum_{i \neq k} y_i \delta_{y_i} + (1 - y_k)\delta_{y_k}\right) = y_k\left(\delta_{y_k} - \sum_{i}y_i\delta_{y_i}\right)

よって、\delta_{x_k} = y_k\left(\delta_{y_k} - \sum_{i}y_i\delta_{y_i}\right)と求まります。

実装

それではSoftmax層を実装していきます。バッチ処理に対応するため、Softmax層は行列を入力にとり、入力行列の各列に対してSoftmax関数を適用していきます。

using MathNet.Numerics.LinearAlgebra.Single;

namespace NeuralNET.Layers.Activation
{
    /// <summary>
    /// ソフトマックス関数
    /// </summary>
    public class SoftmaxLayer : IActivationLayer
    {
        DenseMatrix? output;
        readonly bool SAVE_OUTPUT_REF;

        public SoftmaxLayer() : this(false) { }

        public SoftmaxLayer(bool saveOutputRef) => this.SAVE_OUTPUT_REF = saveOutputRef;

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y)
        {
            x.ColumnSoftmax(y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Forward(DenseMatrix x)
        {
            var y = DenseMatrix.Create(x.RowCount, x.ColumnCount, 0.0f);
            Forward(x, y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Backward(DenseMatrix dOutput, DenseMatrix res)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            this.output.PointwiseMultiply(dOutput, res);
            var colSums = (DenseVector)res.ColumnSums();
            dOutput.SubtractRowVector(colSums, res);
            res.PointwiseMultiply(this.output, res);
            return res;
        }

        public DenseMatrix Backward(DenseMatrix dOutput)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            var res = DenseMatrix.Create(dOutput.RowCount, dOutput.ColumnCount, 0.0f);
            return Backward(dOutput, res);
        }

        void SaveOutput(DenseMatrix output)
        {
            if (this.SAVE_OUTPUT_REF)
            {
                this.output = output;
                return;
            }

            this.output = output.CopyToOrClone(this.output);
        }
    }
}

上のコードでは、いくつかオリジナルの関数を用意しています。まず、DenseMatrix.ColumnSoftmaxメソッドは、DenseMatrixの各列に対してSoftmax関数を適用した行列を返します。
そして、DenseMatrix.SubtractRowVectorメソッドでは、行列と行ベクトルとの引き算を行います。この引き算では、行列の各行から行ベクトルが引かれます。NumPyでいうブロードキャストです。
全てのコードを載せると長すぎるので、詳しくはリポジトリを参照してください。

次回

次回は損失関数を実装していきます。

今回のコミットは以下です。
(2024/04/08 追記) Softmax関数のオーバーフロー対策を追加で実装しました。

github.com

C#で自作のディープラーニングフレームワークを作る その2(活性化層の実装)

活性化層

今回は、活性化関数に相当する活性化層を実装します。ただし、Softmax関数に関しては少し特殊なので次回に。

活性化層のインターフェース

活性化層は、基本的に行列を入力して、入力行列と同じ大きさの行列が出力されます。前回と同様、出力先を呼び出し元で与える場合と、メソッド内で動的確保する場合の2種類のメソッドを用意しています。

using MathNet.Numerics.LinearAlgebra.Single;

namespace NeuralNET.Layers.Activation
{
    /// <summary>
    /// 活性化層(活性化関数)が実装するインターフェース
    /// </summary>
    public interface IActivationLayer : ILayer
    {
        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y);
        public DenseMatrix Forward(DenseMatrix x);

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

シグモイド層

シグモイド関数(標準シグモイド関数) f(x) = \frac{1}{1 - e^{-x}}を計算する層です。この関数は、よく中間層の活性化関数や2値分類問題において出力層の活性化関数に用いられます。微分するとf'(x) = f(x)(1 - f(x))となり、元の関数が導関数に登場する形になります。それゆえ、以下のSigmoidLayerクラスでは、順伝播時に出力を保存します。

(2024/04/07 追記) SigmoidLayer、TanhLayer、ReLULayerそれぞれについて、Backwardメソッドにバグがあったので修正しました。

using MathNet.Numerics.LinearAlgebra.Single;

namespace NeuralNET.Layers.Activation
{
    /// <summary>
    /// 標準シグモイド関数
    /// </summary>
    public class SigmoidLayer : IActivationLayer
    {
        DenseMatrix? output;
        readonly bool SAVE_OUTPUT_REF;

        public SigmoidLayer() : this(false) { }

        public SigmoidLayer(bool saveOutputRef) => this.SAVE_OUTPUT_REF = saveOutputRef;

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y)
        {
            x.PointwiseSigmoid(y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Forward(DenseMatrix x)
        {
            var y = DenseMatrix.Create(x.RowCount, x.ColumnCount, 0.0f);
            Forward(x, y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Backward(DenseMatrix dOutput, DenseMatrix res)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            this.output.Negate(res);
            res.Add(1.0f, res);
            res.PointwiseMultiply(this.output, res);
            res.PointwiseMultiply(dOutput, res);
            return res;
        }

        public DenseMatrix Backward(DenseMatrix dOutput)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            var res = DenseMatrix.Create(this.output.RowCount, this.output.ColumnCount, 0.0f);
            return Backward(dOutput, res);
        }

        void SaveOutput(DenseMatrix output)
        {
            if (this.SAVE_OUTPUT_REF)
            {
                this.output = output;
                return;
            }

            this.output = output.CopyToOrClone(this.output);
        }
    }
}

DenseMatrix.PointwiseSigmoidメソッドは自作の拡張メソッドであり、LinearAlgebraExtensionsクラス内で以下のように実装しています。
(2024/04/07 追記) シグモイド関数のバグを修正しました.

public static void PointwiseSigmoid(this DenseMatrix x, DenseMatrix y)
{
       x.Negate(y);
       y.PointwiseExp(y);
       y.Add(1.0f, y);
       y.Map(x => 1.0f / x, y);
}

複数のメソッドを組み合わせて表現しているので、ぱっと見、コードからは数式が浮かびにくいです。まあ、しょうがない。

tanh

tanh関数(双曲正接関数)を計算する層です。値域は異なりますが、tanh関数はシグモイド関数の一種です。そのため、シグモイド関数と同様に導関数に自分自身が登場します。

\{\tanh(x)\}' = \frac{1}{\cosh(x)} = 1 - \tanh^2(x)

using MathNet.Numerics.LinearAlgebra.Single;

namespace NeuralNET.Layers.Activation
{
    /// <summary>
    /// tanh関数
    /// </summary>
    public class TanhLayer : IActivationLayer
    {
        DenseMatrix? output;
        readonly bool SAVE_OUTPUT_REF;

        public TanhLayer() : this(false) { }

        public TanhLayer(bool saveOutputRef) => this.SAVE_OUTPUT_REF = saveOutputRef;

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y)
        {
            x.PointwiseTanh(y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Forward(DenseMatrix x)
        {
            var y = DenseMatrix.Create(x.RowCount, x.ColumnCount, 0.0f);
            Forward(x, y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Backward(DenseMatrix dOutput, DenseMatrix res)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            this.output.PointwiseMultiply(this.output, res);
            res.Negate(res);
            res.Add(1.0f, res);
            res.PointwiseMultiply(dOutput, res);
            return res;
        }

        public DenseMatrix Backward(DenseMatrix dOutput)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            var res = DenseMatrix.Create(this.output.RowCount, this.output.ColumnCount, 0.0f);
            return Backward(dOutput, res);
        }

        void SaveOutput(DenseMatrix output)
        {
            if (this.SAVE_OUTPUT_REF)
            {
                this.output = output;
                return;
            }

            this.output = output.CopyToOrClone(this.output);
        }
    }
}

Math.NETには、行列の要素ごとにtanh関数を適用するメソッドが用意されているので、容易に実装できます。

ReLU層

ReLU関数を計算する層です。ReLU関数は、入力が0以下なら0を、0以上なら入力された値をそのまま出力する単純な関数です。中間層の活性化関数に非常によく用いられます。ReLUの導関数はReLU関数の出力が0以下ならば0を、そうでなければ1をとる単純な関数です*1シグモイド関数導関数と比べると0と1しか出力しないので、勾配消失が起きづらいというメリットがあります。

using MathNet.Numerics.LinearAlgebra.Single;

namespace NeuralNET.Layers.Activation
{
    /// <summary>
    /// ReLU関数
    /// </summary>
    public class ReLULayer : IActivationLayer
    {
        DenseMatrix? output;
        readonly bool SAVE_OUTPUT_REF;

        public ReLULayer() : this(false) { }

        public ReLULayer(bool saveOutputRef) => this.SAVE_OUTPUT_REF = saveOutputRef;

        public DenseMatrix Forward(DenseMatrix x, DenseMatrix y)
        {
            x.PointwiseMaximum(0.0f, y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Forward(DenseMatrix x)
        {
            var y = DenseMatrix.Create(x.RowCount, x.ColumnCount, 0.0f);
            Forward(x, y);
            SaveOutput(y);
            return y;
        }

        public DenseMatrix Backward(DenseMatrix dOutput, DenseMatrix res)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            this.output.PointwiseSign(res);
            res.PointwiseMultiply(dOutput, res);
            return res;
        }

        public DenseMatrix Backward(DenseMatrix dOutput)
        {
            if (this.output is null)
                throw new InvalidOperationException("Backward method must be called after forward.");

            var res = DenseMatrix.Create(this.output.RowCount, this.output.ColumnCount, 0.0f);
            return Backward(dOutput, res);
        }

        void SaveOutput(DenseMatrix output)
        {
            if (this.SAVE_OUTPUT_REF)
            {
                this.output = output;
                return;
            }

            this.output = output.CopyToOrClone(this.output);
        }
    }
}

すでにMath.NET側で用意されている、DenseMatrix.PointwiseMaximumメソッドとDenseMatrix.PointwiseSignメソッドを利用して容易に実装できます。

次回

次回は分類問題でよく用いられるSoftmax関数を実装します。

今回のコミットは以下です。
まだテストをしていないので、後々バグが見つかる可能性もあります。

github.com

*1:厳密にはReLU関数は、x=0微分不可能なのですが、便宜上x=0の時は、勾配が0になるとしています。

Apple Silicon上でMath.NETを使う(OpenBLASの利用)

はじめに

色々あって、M1チップ搭載のMacBook Airを手に入れました。
自分自身、WIndows上で数値計算C# + Math.NETを用いているので、Macでも同様にMath.NETを使って書いたコードを動かせるようにしました。
しかしながら、なぜかMath.NETのManagedコード*1しか動きませんでした。
しばらく格闘した末に、Math.NETでOpenBLASを使えるようにできたので、その記録を残します。

環境

チップ: Apple M1
メモリ: 8 GB

つまりは2020年のMacBook Airです。

Math.NETの導入

Windowsで導入する場合と特に変わりません。NuGetからインストールするだけです。今回はOpenBLASを用いたいので、MathNet .Numerics.Provider.OpenBLASをインストールします。
末尾に.Winと書かれたものを間違えてインストールしないでください。こいつはWindows版です。

OpenBLASのインストール

以下の記事に書かれている通りにインストールします。

qiita.com

OpenBLASが使えない!!

ここまでの手順でOpenBLASを使えると思ったのですが、なんとLinearAlgebraControl.TryUseNativeOpenBLASメソッドがfalseを返しやがります。
ここで原因を調べるために、MathNet.Numerics.Providers.OpenBLAS.OpenBlasProvider.Loadメソッドを呼び出してみました。すると、libMathNetNumericsOpenBLAS.dylibが見つからないという例外が発生しました。どうやら、Math.NETは直接OpenBLASを呼び出しているわけではなく、libMathNetNumericsOpenBLASという名前のラッパーからOpenBLASの関数を呼び出しているようです。そこで、Math.NET公式のGitHubリポジトリでコードを確認してみます。

"src/NativeProviders/"にラッパーらしきものを見つけました。

src/NativeProviders/

Commonディレクトリ内にはラッパーのインターフェースがあり、その他のディレクトリには、名前の通りMKLやOpenBLAS、CUDAのラッパーのコードがあります。
Macに関係ありそうなOSXディレクトリには以下のshファイルがあるのみでした。

export INTEL=/opt/intel
export MKL=$INTEL/mkl
export OPENMP=$INTEL/lib
export OUT=../../../out/MKL/OSX

mkdir -p $OUT/x64
mkdir -p $OUT/x86

clang++ -std=c++11 -D_M_X64 -DGCC -m64 --shared -fPIC -o $OUT/x64/libMathNetNumericsMKL.dylib -I$MKL/include -I../Common -I../MKL ../MKL/memory.c ../MKL/capabilities.cpp ../MKL/vector_functions.c ../Common/blas.c ../Common/lapack.cpp ../MKL/fft.cpp  $MKL/lib/libmkl_intel_lp64.a $MKL/lib/libmkl_core.a $MKL/lib/libmkl_intel_thread.a -L$OPENMP -liomp5 -lpthread -lm

cp $OPENMP/libiomp5.dylib  $OUT/x64/

clang++ -std=c++11 -D_M_IX86 -DGCC -m32 --shared -fPIC -o $OUT/x86/libMathNetNumericsMKL.dylib -I$MKL/include -I../Common -I../MKL ../MKL/memory.c ../MKL/capabilities.cpp ../MKL/vector_functions.c ../Common/blas.c ../Common/lapack.cpp ../MKL/fft.cpp  $MKL/lib/libmkl_intel_lp64.a $MKL/lib/libmkl_core.a $MKL/lib/libmkl_intel_thread.a -L$OPENMP -liomp5 -lpthread -lm

cp $OPENMP/libiomp5.dylib  $OUT/x86/

どうやらMacでは、Intel MKLの利用が想定されているようです。今までMacIntelプロセッサを採用していましたからね。しかしながら、Apple SiliconはARMベースです。Intel MKLは動きません*2。そこで、OpenBLASのラッパーをMac上でビルドします。

OpenBLASラッパーのビルド

まずは、"src/NativeProviders/"内のCommonディレクトリとOpenBLASディレクトリ内の全てのファイルを同じディレクトリにまとめます。そして、そのディレクトリをカレントディレクトリとしてターミナルを開き、以下のコマンドでコンパイルします。

clang++ -std=c++11 --shared -fPIC -o libMathNetNumericsOpenBLAS.dylib -I/opt/homebrew/opt/openblas/include capabilities.cpp blas.c lapack.cpp -L/opt/homebrew/opt/openblas/lib/ -lopenblas

あとは吐き出されたlibMathNetNumericsOpenBLAS.dylibを実行ファイルと同じ階層におけば、無事Math.NETからOpenBLASが使えます。

余談(Apple Silicon向けの最適化)

実は、AppleBLASLAPACK公式に提供しています。ですので、Math.NETの"src/NativeProviders/Common"に従って、AppleBLASをラップすれば、Apple Silicon向けに最適化できるはずです。時間があればいつかやりたいと思っています。

ベンチマーク

とりあえず4096x4096の巨大な行列積の計算速度をManagedとOpenBLASで比較してみましょう。
以下のコードがベンチマークに用いたコードです。USE_MANAGED定数をtrueにするとManagedモードで、falseにするとOpenBLASモードでMath.NETが動作します。

using System;
using System.Diagnostics;

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


const bool USE_MANAGED = true;

const int NUM_ROWS = 4096;
const int NUM_COLS = 4096;

var rand = new ContinuousUniform();
var lhs = DenseMatrix.CreateRandom(NUM_ROWS, NUM_COLS, rand);
var rhs = DenseMatrix.CreateRandom(NUM_ROWS, NUM_COLS, rand);
var product = DenseMatrix.Create(NUM_ROWS, NUM_COLS, 0.0f);

if(USE_MANAGED)
    LinearAlgebraControl.UseManaged();
else if(LinearAlgebraControl.TryUseNativeOpenBLAS())
    Console.WriteLine("Use OpenBLAS");

var sw = new Stopwatch();

sw.Start();
lhs.Multiply(rhs, product);
sw.Stop();

Console.WriteLine($"{sw.ElapsedMilliseconds}[ms]");

結果は以下の表の通りです。

実行時間
Managed 10901[ms]
OpenBLAS 524[ms]

20倍くらい違いますね。
まあ、Managedコードを確認すると、かなり単純な実装なので当たり前と言えば当たり前です。

終わりに

やはりWindowsからMacに移行すると色々と苦労します(逆も然り)。
しかも今のMacはプロセッサまで異なりますからね。でも、.NETランタイムの導入はとても簡単だったのが良かったです。
今ではC#やF#もJava並みに何処でも動きますね。

*1:C#で実装された数値計算ルーチンのこと。.NETランタイムが動く環境であれば必ず動作するが、使い物にならないくらい遅い。ソースコードを覗くと、特に工夫がなされているわけではなかったので、おそらく互換性のためにあるだけだと思われる。

*2:Rosettaを使えば、それなりの速度で動きますけど今回は扱いません。

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

技術書典16に申し込みます

技術書典16にサークルで申し込みをします

見出しの通りです。「マジックタンノリターンズ~タンノはオロチを倒せない~」というサークル名で 技術書典16に申し込みます。以下がサークルの公式Twitterアカウントです。
twitter.com

メンバー

ハンドルネーム (Twitterアカウント)

Yoka (https://twitter.com/Kalmia24879903)

人参食べたい (https://twitter.com/A_kyoutoyenamta)

初学者リョウ (?)

ゆざきりつ (今回は不参加)

技術書典とは

技術書典とは、自分で書いた技術書を頒布・販売するイベントです。言うなれば、技術書版コミケです。オンラインとオフラインの両方の形式で開催され、オフライン会場であれば今年は2024/05/26 (日) に池袋サンシャインシティ 展示ホールD(文化会館ビル2F)で開催されます。楽しそうですね。オンラインの場合は、2023/05/25 (土) 〜2023/06/09(日)の期間内であればいつでもやっており、技術書典 :技術書のオンラインマーケット開催中から電子書籍の形式でいつでも購入できます。

techbookfest.org

頒布予定の新刊

リバーシ強化学習!!

物理本で頒布予定です。
リバーシAIを作りながら強化学習を学ぶ書籍です。まずは4x4の狭い盤面を用いて、テーブルベースの価値関数を用いた強化学習手法について学び、8x8のフルサイズの盤面では、線形モデルやDNNなどを用いて方策や価値関数を関数近似して強いAIを開発していきます。また、最後には探索手法についても触れ、さらなる強さを追求します。

頒布予定の既刊

タンノ電子計算vol.2~手持ちの測定器で電波天文~ (人参食べたい)

物理本で頒布予定です。
書籍の詳細については技術書典15用の記事をご覧ください。

その他

他にも電子書籍として、技術書典14、15で頒布した本をオンラインマーケットにて購入可能です。

当選しますように

以上、サークルが頒布予定の書籍の概要です。頒布予定の書籍の一覧から分かる通り、メンバーそれぞれが興味を持ったことを書籍にしているので、分野は多岐にわたります。
メンバーの平均年齢は低く、怖い人もいないので、興味があれば気軽に話しかけてみてください(オフライン会場に当選した場合)。当選しますように・・・

C#でtemplateっぽいことをしてみたかった話

きっかけ

今、研究でゲームAIをC#で書いているのですが、「相手がパスした後に呼び出されるメソッド」と「相手が通常の着手を行った後に呼び出されるメソッド」が必要になる場面がありました。しかし、これらの2つのメソッドは一部分だけが異なり、残りは概ね同じ処理なので出来れば1つのメソッドに統合したいです。C++であればテンプレート引数に何でもとれるので以下のように実装できます(注: 実際のコードはもっと複雑です)。

template<bool AFTER_PASS>
void VisitNode(Node node)
{
	/*
	 *
	 * 共通の処理
	 *
	*/

	if constexpr (AFTER_PASS)
	{
		// 相手がパスした場合の処理
	}
	else
	{
		// 相手が通常の着手をした場合の処理
	}

	/*
	 *
	 * 共通の処理
	 *
	*/
}

このコードはコンパイラによって以下の2つの関数に自動的に展開されます。つまり上のコードのif文は実行時には消えるというわけです。このほうがいちいち2つの関数を定義しなくていいのでシンプルですね。

template<>
void VisitNode<true>(Node node)
{
	/*
	 *
	 * 共通の処理
	 *
	*/

	// 相手がパスした場合の処理

	/*
	 *
	 * 共通の処理
	 *
	*/
}

template<>
void VisitNode<false>(Node node)
{
	/*
	 *
	 * 共通の処理
	 *
	*/

	// 相手が通常の着手をした場合の処理

	/*
	 *
	 * 共通の処理
	 *
	*/
}

さて、C#でもこれと同じようなことをやってみようとすると1つの問題にぶつかります。C#にはC++のtemplateと似た概念としてジェネリック(もしくはジェネリクス)というものあります。しかし、ジェネリックはtemplateの下位互換であり、C++でいうtemplate引数に型引数しかとれません。bool型の値を渡すことなどはできないのです。

ジェネリックメソッドの展開

C#ジェネリックでは、型引数に値型を与えた場合に限り、C++のtemplateと同様にコンパイル時にそれぞれの型に応じて展開されます。例えば、以下のようなFoo<T>というジェネリックメソッドについて考えてみましょう(namespaceやclassは省略しています)。

static void Main()
{
	Foo<int>();
	Foo<long>();
}

static void Foo<T>() where T : struct
{
	if(typeof(T) == typeof(int))
		Console.WriteLine("Hello 32-bit signed integer!!");
	else if(typeof(T) == typeof(long))		
		Console.WriteLine("Hello 64-bit signed integer!!");
	else
		Console.WriteLine($"Hello struct!!");
}

このFoo<T>メソッドは、Mainメソッド内でFoo<int>()、Foo<long>()と呼び出されています。この場合は、以下のような2つのメソッドがコンパイラによって展開されます。したがってFoo<T>内のif文は実行時には存在しないことになります。

static void Main()
{
	Foo<int>();
	Foo<long>();
}

static void Foo<int>() 
{
	Console.WriteLine("Hello 32-bit signed integer!!");
}

static void Foo<long>() 
{
	Console.WriteLine("Hello 64-bit signed integer!!");
}

以上のことを踏まえると、C++のtemplate引数にbool型の値を与えるときと同じようなことをC#ジェネリックで実現できそうです。

実際に自分がやったこと

まず以下のinterfaceと構造体を定義します。

interface IFlag { }
struct True : IFlag { }
struct False : IFlag { }

これらを定義すれば、先ほどのC++のコードをC#で以下のように記述できます。

void VisitNode<AfterPass>(Node node) where AfterPass : struct, IFlag
{
	/*
	 *
	 * 共通の処理
	 *
	*/

	if (typeof(AfterPass) == typeof(True))
	{
		// 相手がパスした場合の処理
	}
	else
	{
		// 相手が通常の着手をした場合の処理
	}

	/*
	 *
	 * 共通の処理
	 *
	*/
}

このようにすれば、相手がパスした場合と相手が通常の着手をした場合のメソッドをわざわざ作らずとも1つのメソッドにまとめることができました。if文に関しては、typeof(AfterPass)がコンパイル時に決まるので実行時には消えています。
どうでもいい話ですけど、if (typeof(AfterPass) == typeof(True)) の部分が、if(AfterPass == true)と書いているみたいで少しバカみたいですね。

整数などを引数に取らせたい場合はどうするか(未解決)

整数なども苦行ではありますが、One構造体やTwo構造体を作ればできそうではありますが、typeof(T)同士の大小比較はできないのであまりできることはなさそうです。そういった場合は、Source Generatorなどを代替手段に使うか、条件に応じてメソッドを作るしかなさそうです。何か他に案があれば誰かコメントください。