TCG学習モデルと問題点

実は重大なミスが発覚して、実験結果は全く無意味なものである。

モデル1

仮定

  1. アクションがターンの最後の方であるほど、そのターン中に獲得したアドバンテージに寄与する。
  2. 報酬はその行動が良いか悪いかの指標であり、悪い行動はそぎ落とし、良い行動は伸ばす
  3. アドバンテージを獲得すれば勝利に繋がる

更新式

 Q\leftarrow Reward \times DiscountRate^i + Q
i: ターン中に行ったアクションのケツからの順番
Reward:(自分が得たアド - 相手が得たアド) in the 2 turns

実行したアクションに対して、重み付けした報酬を加えて補正する

結果

  • 700episodeでルールベースに対してそこそこの勝率(~60%)が得られる。
  • 攻撃すれば勝てる場面でも攻撃せずに、デッキ切れで敗北を重ねている。

考察

アドバンテージを取れば勝てるという人間的な仮定は成り立たない この結論を出すには実験がまだ不十分である。

現在のQ値に報酬を加算するという数学的根拠のないアルゴリズムにも関わらず、そこそこ学習する。

更新式から予想される問題点
ターン始めの行動の学習が進みにくい。

良アクション→悪アクション→良アクションで最終的にアドを獲得してしまった場合、途中の悪アクションが過大評価されてしまい、いつまで経っても悪アクションが抜けなくなってしまう。

得たアドバンテージのうち、何(ライフ、ハンド、ボード)を重視するかは設計者に委ねられる。

モデル2

仮定

モデル1に同じ

更新式

 Q \leftarrow Reward \times DiscountRate^i \times DiscoutRateByTurn^t + Q
i: ターン中に行ったアクションのケツからの順番
Reward:(自分が得たアド - 相手が得たアド) in the 2 turns
t:現在のターン数

報酬をターン経過で割引して、速く倒せばより多い報酬を得られることを教える

結果

  • 1000episodeでルールベースに対して勝率40%程度
  • 成長は続けているが、少々効率が悪い

考察

ターンが経過すればするほど報酬の絶対値が小さくなって、学習が進みにくくなっているのと考えられる。
報酬が負である場合はターンによる割引を行う必要がない。

モデル3

仮定

  1. 勝敗に対して報酬を与えれば、それ以前の行動も最適化される

更新式

 Q_{last} \leftarrow   \{ \begin{array}{l}+1 (if \ win) \\ -1 (if \ lose)\end{array}
 Q_{t} \leftarrow DiscountRate \times \max(Q_{t+1})

最後のアクションは勝敗によって学習
それ以外は割引した次状態の価値で学習

備考

  • DDQN
  • Experience Replay

結果

実験中

  • 成長が異常に遅い

- 学習しない
コードに問題が発覚して、再実験の必要あり。

考察

1episodeあたりアクションはおおよそ100個に対して、報酬を得て更新できるアクションはただ一つ。その報酬が初期行動に反映されるまで何episodeかかるのやら。

成長が遅すぎて成長しているのかすらわからない。成長しなかった。

計算式はQ学習の理論に則ったものであるが、それがTCG問題に適しているかは別の話。

TCGにおけるアクションは「タイミング等価」ではない。
ブロック崩しであればフレーム毎にアクションでき、将棋であれば自分の手番に1手指せるが、アクションはタイミング的に"等価"である。対称的、時間並進で不変、的な意味合いである。

ところがTCG、いや遊戯王において、メインフェイズに行うアクションとチェーンを行うというアクションはタイミングが異なるため交換不可能である。(ルール的にはスペルスピードが関係している)

アクションのタイミングが等価でないにも関わらず、それらを行動順に並べて”次状態”としても良いだろうか。

優先権が渡されるタイミングを”状態”と定義することは可能だが、優先権が”渡される”のは相手が何か発動やフェイズの移行を行うからで、相手が何も行動しなかった場合、渡した優先権は。。。


メインフェイズで行うスペルスピード1の行動をアイドルコマンドと呼ぶことにする。アイドルコマンドを実行して、相手が何も発動しなかった場合、次状態は次のアイドルコマンドを選択する時となる。発動した場合は、スペルスピード2or3の状態が次状態となる。

さて、これをそのまま学習に使って良いのだろうか。Q値を更新したいアクションは同じなのに、次状態のタイミングが一意に定まらないのはまずいのではなかろうか。

というのも既存の強化学習アルゴリズムは状態観測のタイミングが等価であることを前提としているからだ。

既存の強化学習問題は状態観測のタイミングは環境が支配しているが、TCG問題では状態観測のタイミングは他方のエージェントの行動に依存している。

他方のエージェントの行動によって次状態が変化してしまう。

次状態は定義できない?



モデル1やモデル2はそこそこ学習したが、はっきり言って知的な行動とは言えなかったし、学習を進めれば知的になるという数学的な裏付けはない。
コードの欠陥が発覚した今評価すると、モデル1とモデル2は偶然が重なって学習することができたと考えられる。

#ToDo
#ToDo
#ToDo

Cythonの問題点と活用法

Cythonの問題点

エディタのサポートが壊滅的

Hover, tokenization, syntax highlightその他もろもろ、あったらいいな機能が皆無。

自分で作ればいいじゃん!→技術的な壁

ちょっとしたもの(keywordのハイライトとか)なら作れるけど、定義済みの変数とか使用されていない変数、存在するモジュールにトークナイズとなるとうーん。労力に見合わないかなと。

したがって、若干色付けされた白地のコードをlinter無しで書くことになる。

コンパイル処理が入るためミスがあれば大体そこで気付けるが、大規模なコードには向かない。

Cythonの活用法

Cythonで実装を書くのではなく、C/C++で実装したものをPythonから呼び出すためのインターフェイスを提供するツールとして用いるのがベストだと思う。

Cythonを書く量を最小限にして、ヒューマンエラーを抑えていこうという作戦。

具体的な手順

目標:高速なモジュールの作成
アウトライン

  • (Cythonパッケージやビルドツールのインストール)
  • C/C++で実装
  • Pythonから呼びたいものだけCythonでラップ
  • 使いやすいようにPythonでラップ

例はC++で行うがCでも同様。

パッケージの作成

$ mkdir my_module
$ cd my_module

今後出てくる.はこのディレクトリを指すことにする。

ヘッダの作成

実装するものをまとめたヘッダファイルを作成

// my_module_c.h
#ifndef MY_MODULE_C_H
#define MY_MODULE_C_H

#include <vector>
#include <string>

// Goに倣って、外部に公開する関数はキャピタライズ
double Sum_c(std::vector<double>& data);

class MyClass_c {
    public:
        MyClass_c(std::string name);
        void PrintCount();
    private:
    // Pythonに倣って_を付けることで明示的にprivateと宣言
    // 引数との名前衝突も避けられる
        std::string _name;
        int _count;
        void addCount();
};

#endif // MY_MODULE_C_H

実装

C++で処理を実装する

// my_module_c.cpp
#include "my_module_c.h"
#include <iostream>

double Sum_c(std::vector<double>& data) {
    double result = 0;
    for (double v : data) {
        result += v;
    }
    return result;
}

MyClass_c::MyClass_c(std::string name) {
    _name = name;
    _count = 0;
}

void MyClass_c::PrintCount() {
    std::cout << "I'm " << _name << ".\n"; 
    std::cout << "My count is " << _count << ".\n";
    addCount();
}

void MyClass_c::addCount() {
    _count++;
}
コラム::テストしてみる

Cythonに行く前に、C/C++で書いたコードが確かに動くか確認してみる。

// my_module_c_test.cpp
#include "my_module_c.h"
#include <iostream>
using namespace std;

int main() {
    const int n = 3;
    vector<double> data = {2.4, 4.6, 5.0};
    double total = Sum_c(data);
    double expected = 12.0;
    cout << "Sum_c test "
         << "result: " << total 
         << ", expected: " << expected
         << ", " << ((total == expected)? "ok" : "err")
         << endl;
    
    cout << "MyClass test" << endl;
    MyClass* ptr = new MyClass("Bob");
    if (ptr == nullptr) {
        cout << "instance not created" << endl;
        exit(1);
    }
    cout << "MyClass::PrintCount" << endl;
    for (int i = 0; i < 3; i++) {
        ptr->PrintCount();
    }
    delete ptr;
}

コンパイル&実行

g++ -c my_module_c.cpp -o my_module_c.o
g++ my_module_c_test.cpp  my_module_c.o -o my_module_c_test
Remove-Item my_module_c.o
./my_module_c_test
Remove-Item my_module_c_test.exe

実行結果

$ ./test_c
Sum_c test
result: 12, expected: 12, ok

MyClass test
MyClass::PrintCount
I'm Bob.
My count is 0.
I'm Bob.
My count is 1.
I'm Bob.
My count is 2.

Cythonで実装するとデバッグが困難だが、C/C++単体で動くように実装することでC/C++のサポート溢れるデバッグツールでデバッグが可能

お片付け

ファイルが増えてきたのでお片付け

$ mkdir src
$ mv my_module_c.cpp ./src/my_module_c.cpp
$ mkdir include
$ mv my_module_c.h ./include/my_module_c.h

Cythonでラップ

Pythonに公開したい関数だけCythonでラップ。
これがインターフェースとなる。

# my_module_cy.pyx
from libcpp.vector cimport vector
from libcpp.string cimport string

cdef extern from "my_module_c.h": # extern fromでヘッダ読み込み
# namespace有り: cdef extern from "some.h" namespace "somename":
    double Sum_c(vector[double]& data) 

    cdef cppclass MyClass_c:
        MyClass_c(const string name)
        void PrintCount()


def sum_cy(vector[double] data: list[float]) -> float:
    return Sum_c(data)


cdef class MyClass_cy:
    # インスタンス変数の列挙
    cdef MyClass_c* _ptr
    def __cinit__(self, *args, **kwargs) -> None:
        # cレベルの初期化
        # __init__関数と同じ引数で呼ばれる
        pass


    def __init__(self, name: str) -> None:
        cdef string _name = bytes(name, encoding='utf-8')
        self._ptr = new MyClass_c(_name)


    def __dealloc__(self) -> None:
        # cレベルのメモリ開放
        del self._ptr


    def print_count(self) -> None:
        self._ptr.PrintCount()

コンパイル

  1. Cythonコード→C/C++コード
  2. コンパイル & ビルド

という2段階で行う。

環境によって操作が異なる部分をsetuptoolsモジュールで覆うことで隠してしまおう、という理由でsetup.pyを書く。

from setuptools import setup, Extension
from Cython.Build import cythonize

name = 'my_module_compiled' # 実際に利用するモジュールの名前
sources = [  # コンパイルするソースファイルの列挙
    'my_module_cy.pyx',
    './src/my_module_c.cpp'
]
extra_compile_args = [] # コンパイラオプション


ext = Extension(
     name, 
     sources=sources, 
     language='c++', # 変数不可(?)
     extra_compile_args=extra_compile_args
)

setup(
    ext_modules = cythonize(ext, language_level="3"), # python3用にコンパイル
    include_dirs = ['./include/'] # ヘッダの在りかを指定
)

実行

# -i オプションでカレントディレクトリにバイナリがコピーされる
$ python setup.py build_ext -i

実行結果

...
  ライブラリ build\temp.win-amd64-3.9\Release\./src\my_module_compiled.cp39-win_amd64.lib とオブジェクト build\temp.win-amd64-3.9\Release\./src\my_module_compiled.cp39-win_amd64.exp を作成中
コード生成しています。
コード生成が終了しました。
copying build\lib.win-amd64-3.9\my_module_compiled.cp39-win_amd64.pyd -> 

Pythonでラップ

アノテーション書いたり、DocString書いたり。
なくても良い。
保守性を考えるとない方が良いが、利便性を考えるとあった方が良い。

# my_module_py.py
import my_module_compiled as mmcy

def sum_py(data: list[float]) -> float:
    return mmcy.sum_cy(data)


class MyClass_py(mmcy.MyClass_cy): # 継承可能
    def __init__(self, name: str) -> None:
        super().__init__(name)


    def print_count(self) -> None:
        super().print_count()

Cythonで書いた__cinit__も当然継承される。
引数を*args, **kwargsとしたのはこのため。

簡単なテスト

# my_module_py_test.py
import my_module_py as mmpy

def main():
    data = [3.3, 4.7, 5]
    total = mmpy.sum_py(data)
    print(' + '.join(list(map(str, data))), '=', total)

    obj = mmpy.MyClass_py('Bob')
    for _ in range(3):
        obj.print_count()


if __name__ == '__main__':
    main()

実行結果

3.3 + 4.7 + 5 = 13.0
I'm Bob.
My count is 0.
I'm Bob.
My count is 1.
I'm Bob.
My count is 2.

Cython Tips

わずかばかりのCythonの知見のメモ。

  • 特殊メソッド__XXX__cdefではなくdefで定義
  • list[T] ↔ vector[T]で交換可能
  • ポインタ、アドレス演算子NULLは使用可能
  • ただし、ポインタはcdefで定義する
  • np.ndarray[T] ↔ vector[T]は交換可能
  • 三項演算子?は使用不可
  • インクリメント・デクリメント不可
  • inline不可

実験と実用の乖離

以前、強化学習効率化のために自分の時間リソースを消費して、パフォーマンスの向上を図った。
nnt339es.hatenablog.com

実験結果によると、純 Python ならば 400ms かかる処理を 30ms で処理できるはずだ。

ところが実際のアプリケーションに導入してみると、体感 1.5 倍~2.0 倍程度しか出ない。(時間がかかる実験なので正確な実験はまだ行っていない)

実験と実用で異なるのはネットワークの大きさだ。
実験は1桁オーダーであるのに対して、実用ネットワークは 1000x100 の行列を扱う。

double 型で 8Bytes だから、データサイズは 8\times10^5Bytes となる。

自マシンの L1 キャッシュは 128kB だから、L1 キャッシュにはデータが乗り切らない。したがって、計算中にメモリアクセスが発生してパフォーマンスが低下すると予想される。

この仮説を裏付けるには、ネットワーク構造を大きくしていってパフォーマンスが低下する行列の大きさを調べればよい。#ToDo

今回行列計算にはライブラリを通して BLAS を利用している。
しかし、OpenMP を利用して余っている計算リソースを全てつぎ込んで行列演算を実装したほうがもしかしたら速くなるかもしれない。
それを確かめるために、Eigen と自作 OpenMP 行列演算プログラムの実行時間、あるいは FLOPS を比較する実験を行う必要がある。#ToDo

あるいは学習過程を並列化できるならば演算はコア1で行っても問題ないのだが、残念ながらそのような手法はまだ思いついていない。

以上、実験と実用の乖離に対する考察と ToDo リストでした。

Cythonバイナリをラップ

前回、Cythonコンパイルしたモジュールについて、インテリセンスが効かないという点が欠点だと挙げた。
nnt339es.hatenablog.com

そこで、このモジュールを再びPythonでラップすることで使いやすさの向上を図ってみた。

ラップ構造

c++ \subset Cython \subsetPython
c++で実装したクラスをCythonでラップすることによってPythonから呼び出せるCコードを変換する。

Cythonを経由するため、コンパイルが楽という利点がある。

バイナリモジュール(Cython)

コンパイルすると[module_name].pydバイナリが生成される。

そのままでも

import module_name

で利用できるが、私の愛用しているPylanceではインテジェンスか効かず、引数や戻り値の型もわからない。

Pythonでラップ

簡単のため、次のようなCythonを用意する。

# cy_person.pyx
# cythonソースの拡張子は.pyx -> .pyd: 実行形式のモジュール
cdef cyPerson:
    cdef str _name
    def __init__(self, str name) -> None:  
        self._name = name
    

    def introduce(self) -> None:
        print(f"I'm {self._name}")

これをラップする。

from cy_person import cyPerson

class Person(cyPerson):
    def __init__(self, name: str) -> None:
        super().__init__(name)


    def introduce(self) -> None:
        super().introduce()

ただ継承して、親のメソッドを呼び出しているだけであるが、実際これが機能する。

パフォーマンス

irisデータセット学習時間(100回統計、層構造[4, 6, 3]、エポック50)

ラップ無し ラップ有り
平均[ms] 30.81 30.94
標準偏差[ms] 2.30 6.15

パフォーマンスは問題なし。

まとめ

Cythonで実装したクラスをPythonで継承して型ヒントを付けてやることで、インテリジェンスの効く使い勝手の良いクラスを利用できる。

Pythonのニューラルネット学習を高速化

前回Goニューラルネットワークを作成して、コンパイル言語らしく良いパフォーマンスを出してくれた。
https://nnt339es.hatenablog.com/entry/2020/11/24/195430nnt339es.hatenablog.com

このままGoに移行しても良いと思ったが、リファクタリングにはそれなりのコストがかかるため、Python のまま高速化できないか模索してみた。

  • 没案

\*\* Cython
まず考えたのはCythonによるリファクタリングだ。

CythonとはPythonで書いたものを C 言語に翻訳してコンパイルするための言語である。

元々PythonCによる拡張が可能。公式ドキュメント
ドキュメントの例にもあるようにPython.hヘッダを利用して、関数がPyObjectという形で引数を受け取って処理する、というような記法になる。

これを1つ1つ実装するのは大変なのでPythonコードからCコードを生成してしまおうというのがCythonの試みだ。

詳しいことは別の記事にして、なぜ没だったか説明しよう。

結論から言うと、速くならなかったからだ。

通常、Pythonの圧倒的に処理の遅い for 文の数値計算Cythonで書き直すと処理速度がぐっと上がるが、私のコードは計算部分はnumpyに 100%任せているのでその恩恵を受けられなかった。むしろ関数呼び出しのオーバーヘッドが増えたのか、処理は遅くなった。

比較的少ないCythonについての文献を頑張って漁って実装した見返りがこれとは。。。

\*\* BLAS の更新
次のアプローチはnumpyの処理速度を上げることで、BLAS 変えてコンパイルすることを検討した。HPC の講義中に導入できなかった BLAS

最速を目指したかったのでまずATLASコンパイルを行ったが、大量のエラーに対して半日で心が折れてOpenBLASにした。

OpenBLASも全くコンパイルできず、諦めて windows 用のコンパイル済みバイナリをネットから拾ってきた。

OpenBLASを使ったnumpyコンパイルでまた四苦八苦。コンパイルできたと思ったら dll の置き場所がわからなくてギブアップ。

何も得られないまま数日無駄にした。BLAS うらめしや
numpyが元々OpenBLASコンパイルされていることは言わないお約束。

次に思いついたのが、ネットワークの部分だけCで実装してPythonから呼び出すことだ。
結論から言うと、これは上手くいったが、それは長い長い道のりだった。

\*\* 線形代数ライブラリ Eigen
最初OpenMPを使った線形演算関数を作っていたが、こんなの無駄だと気付きライブラリを探したところ、Eigenというライブラリが紹介されていた。
API が直感的で可読性の高いコードが書けるとのことだが、使い勝手はnumpyには及ばず。
導入がインクルードだけで良いことは評価できる。

要はBLASのラッパなので高速。

\*\* C++との闘い
Atcoder で使用していたため基礎はわかっていたが、コンストラクタの書き方は?ヘッダの書き方は?と計算処理以外の所でよく詰まった。

VSCode のインテリセンスが強力であることと、使用者が多いためコンパイルエラーが起こっても答えが Google に絶対あるというのが助かった。

\*\* Cython でラップ
Pythonから C で実装したニューラルネットを使用するためにCythonでラップしてモジュールとしてコンパイルする。(Cythonに使った時間は無駄じゃなかった!)

CコードをPythonっぽく書けると聞くと良さそうに思えるが、実際には悪い点だらけだ。

Cython使用者がメチャクチャ少ないため、日本語文献はもちろん英語で聞いても Google 先生は的確に答えてはくれない。

使用者が少ないため、ノウハウがそこらへんに落ちていない。Cythonから C 関数へ配列ポインタの渡し方さえ私にはわからない。そのためstd::vectorで楽をしている。

Pythonコードは確実にコンパイルできるが、どの程度Cらしい文法を使えるのかはっきりしていない。(Google 先生もあまり良い答えはくれない。)ポインタは使えるのか?アドレス演算子は?new は?delete は?

VScode でインテリセンスが効かない。。。作ったモジュールもインテリセンスなし。。。
nnt339es.hatenablog.com

Cythonについての記事はもう少し理解が進んだらまとめます。
ラッパーコードだけ載せるとこんな感じ。

> |python|
> from libcpp.vector cimport vector
> import numpy as np
> cimport numpy as np
> DTYPE = np.float64
> ctypedef np.float64_t DTYPE_t

# C++で実装したクラスの読み込み

cdef extern from "network.hpp":
cdef cppclass Network:
Network(vector[int] layer*structure, double learningRate)
vector[double] outputs(vector[double] input*)
void train(vector[vector[double]] inputs, vector[vector[double]] expecteds)
void trainForEpoch(vector[vector[double]] inputs, vector[vector[double]] expecteds, int epoch)

cdef class cNetwork:
cdef Network\* network
def **init**(self, vector[int] layer_structure: list[int], double learning_rate: float) -> None:
self.network = new Network(layer_structure, learning_rate)

def __dealloc__(self):
if self.network != NULL:
del *self.network


cpdef outputs(self, np.ndarray[DTYPE_t, ndim=1] input_: np.ndarray):
cdef vector[double] vin = input_
cdef vector[double] x = self.network.outputs(vin)
cdef np.ndarray[DTYPE_t, ndim=1] res = np.asarray(x, dtype=DTYPE)
return res


cpdef train(self, vector[vector[double]] inputs: list[np.ndarray], vector[vector[double]] expecteds: list[np.ndarray]):
self.network.train(inputs, expecteds)


cpdef trainForEpoch(self, vector[vector[double]] inputs: list[np.ndarray], vector[vector[double]] expecteds: list[np.ndarray], int epoch):
self.network.trainForEpoch(inputs, expecteds, epoch)

|

継承の問題点

2週間ほど前にGois Godを始めて、言語の特徴として継承がないことに驚いた。
今まで触れてきた言語は皆オブジェクト指向で継承システムを有していたので、新しい言語(2000年以降に開発された言語を指す)が継承をサポートしていないことに違和感を覚えたのである。

しかし、Goの理念やオブジェクト指向について理解を深めていくうちに、継承というシステムにいかに問題点が多いか気づかされ、自身の浅慮を思い知った。

  • 継承の問題点

自分はまだまだ未熟で大きな声では言えないが、継承が「共通部分を再利用するためのシステム」という認識は「誤った認識」だと考えている。
入門書(入門サイト)がどのように継承を解説しているか十分な調査はしていないが、私が初学時に得たのはこの認識であった。

スーパークラス(親クラス、継承元)の設計は、サブクラス(子クラス、継承する側)の性質から抜き取ってはならないはずだ。
後々サブクラスと同列のクラスの実装を行う際、そのような設計を行っているとス―パークラスも書き換えなければならないからだ。
実際にはスーパークラスの書き換えが行われず、余計な機能が継承されてバグや解読不能なコードになっているのだろう。

DogクラスとCatクラスを実装するからAnimalを実装するというのはやってはいけないのである。
初めにAnimalクラスが要請されて、その後Dogクラスが要請されたならばDogクラスはAnimalクラスを継承すべきだろう。

犬と猫は走る
→おそらく動物には足があるだろうと、Animalクラスで足を追加
→後々蛇クラスの実装が要請されてAnimalクラスを継承
→足のある蛇の完成 or Animalクラス及びそのサブクラスの変更要請

帰納から"真のスーパークラス”を作成するのはリスクの高い作業なのである。
継承の本質は演繹なのだ。
しかし、既存の継承には帰納からの設計を制限する機能はない。

  • 共通部分の切り出し

具体例が追加されて共通部分が発生したならば、その部分を関数で切り出してしまえば良い。
最近はこの考えのもとプログラミングを行っている。

継承はメソッド存在の保証(抽象基底クラス)や独自の名前でラップするのに利用している(同じint型でも、それがIDを指すのか、重さを指すのか等を変数名ではなく型として書くため)。

Goではメソッド・プロパティの存在保証はインターフェイスによって行われる。

勾配降下法の計算方法

ニューラルネットワークの学習アルゴリズムのメモ

表記の定義

定義記号(左辺を右辺で定義)  :=
ベクトル: \boldsymbol{x} = \begin{pmatrix} x_1 \\ x_2 \\ \vdots \end{pmatrix}
ベクトルの第i成分:  x_i
ベクトルのスライス:  \boldsymbol{x}_{[i:j]} := \begin{pmatrix} x_i \\ \vdots \\ x_{j-1} \end{pmatrix} \quad (i < j)

行列:  W
行列の第i行目ベクトル : W_{i:}
行列の第j列目ベクトル : W_{: j}
行列の第i行第j列成分:  W_{ij}

ベクトルの内積:  \boldsymbol{x} \cdot \boldsymbol{y} := \sum_{i} x_{i} y_{i}

ベクトルの要素積:   \boldsymbol{x} * \boldsymbol{y} := \begin{pmatrix} x_1 y_1 \\ x_2 y_2\\ \vdots \end{pmatrix}

ベクトルの直積:  \boldsymbol{x} {\otimes} \boldsymbol{y} := \begin{pmatrix} x_1 y_1  & x_1 y_2 & \cdots \\ x_2 y_1 & \ddots \\ \vdots \end{pmatrix}

ベクトルと行列の積:  W \boldsymbol{x} := \begin{pmatrix} W_{1:} \cdot \boldsymbol{x} \\ W_{2:} \cdot \boldsymbol{x}\\ \vdots \end{pmatrix}

ベクトルで偏微分:  \frac{\partial}{\partial \boldsymbol{x}} := \begin{pmatrix} \frac{\partial}{\partial x_1} \\ \frac{\partial}{\partial x_2} \\ \vdots \end{pmatrix}
行列で偏微分:  \frac{\partial}{\partial W} :=  \begin{pmatrix} \frac{\partial}{\partial W_{11}} & \frac{\partial}{\partial W_{12}} & \cdots \\ \frac{\partial}{\partial W_{21}} & \ddots \\ \vdots \end{pmatrix}

ベクトル関数\boldsymbol{f}:  \boldsymbol{f}(\boldsymbol{x}) := \begin{pmatrix} f(x_1) \\ f(x_2) \\ \vdots \end{pmatrix} (各成分の関数自体は等しい)

ネットワークの構造

ネットワークは層構造を成していて、特に最初の層を「入力層」、最後の層を「出力層」、それ以外を「隠れ層」と呼ぶ。

計算するにあたり、今回は任意の層1つに注目して次の値を定義する。

層の入力ベクトル: \boldsymbol{x}
層のウェイト: W
層のバイアス: \boldsymbol{b}
層の中間生成ベクトル: \boldsymbol{y} := W\boldsymbol{x} + \boldsymbol{b}
層の活性化関数 : \boldsymbol{f}
層の出力ベクトル:  \boldsymbol{z} := \boldsymbol{f}(\boldsymbol{y})

ウェイト・バイアスの修正方法

今回提示する修正法は、ニューラルネットの出力が期待される値とどれくらいずれているかの指標である損失関数 Eが小さくなるようにウェイト・バイアスを修正する勾配降下法である。
すなわち、

 W \leftarrow W -\alpha\frac{\partial E}{\partial W}
 \boldsymbol{b}  \leftarrow  \boldsymbol{b} -\alpha\frac{\partial E}{\partial \boldsymbol{b}}

という式をもってウェイトWとバイアス\boldsymbol{b}を更新する。
ここで、\alphaは学習率であり、勾配をどれだけ下るかの”重み”である。

計算

求めたいものは \frac{\partial E}{\partial W}だが、微分合成がどうなっているのかよくわからないため、まずその成分での微分 \frac{\partial E}{\partial W_{ij}}を考える。


 \begin{align} 
\frac{\partial E}{\partial W_{ij}} &= \frac{\partial E}{\partial \boldsymbol{y}} \cdot \frac{\partial \boldsymbol{y}}{\partial W_{ij}} \\
&=\frac{\partial E}{\partial \boldsymbol{y}} \cdot \frac{\partial}{\partial W_{ij}} \begin{pmatrix} W_{1:} \cdot \boldsymbol{x} + b_1\\ W_{2:} \cdot \boldsymbol{x} + b_2 \\ \vdots \end{pmatrix}  \quad (\boldsymbol{y}を展開)\\
&=\frac{\partial E}{\partial \boldsymbol{y}} \cdot \begin{pmatrix} 0 \\ \vdots \\ x_j \\ \vdots \\ 0 \end{pmatrix} \quad (偏微分によって第i成分以外ゼロになる) \\
&= \frac{\partial E}{\partial y_i}x_j \\
&= \frac{\partial E}{\partial \boldsymbol{z}} \cdot \frac{\partial \boldsymbol{z}}{\partial y_i}x_j \\
&= \frac{\partial E}{\partial \boldsymbol{z}} \cdot \frac{\partial \boldsymbol{f}(\boldsymbol{y})}{\partial y_i}x_j \\
&= \frac{\partial E}{\partial \boldsymbol{z}} \cdot \begin{pmatrix} 0 \\ \vdots \\ f'(y_i) \\ \vdots \\ 0 \end{pmatrix} x_j  \quad (偏微分によって第i成分以外ゼロになる)\\
&= \frac{\partial E}{\partial z_i} f'(y_i) x_j \\
\end{align}

したがって、行列表示に直すと、


 \frac{\partial E}{\partial W} = (\frac{\partial E}{\partial \boldsymbol{z}} * \boldsymbol{f'}(\boldsymbol{y})) \otimes \boldsymbol{x}

(見づらいが\boldsymbol{z}はベクトルである。)

出力層である場合

この層が出力層である場合、損失関数Eは出力層の出力\boldsymbol{z}で計算されるため具体的に計算することができる。
簡単なニューラルネットでは\frac{\partial E}{\partial \boldsymbol{z}}を簡単にするために損失関数Eを、


 \frac{\partial E}{\partial \boldsymbol{z}} = \boldsymbol{z} - \boldsymbol{t}  \quad (\boldsymbol{t}:期待される出力)

となるように決められていたりする。
計算すると、その場合の損失関数は二乗誤差関数であるとわかる。

隠れ層である場合

この層の出力\boldsymbol{z}は次の層の入力ベクトルである。
そこで、次の層の種々の値をチルダ(\tilde{\cdot})を付けて表すことにする。
つまり、


 \boldsymbol{z} = \tilde{\boldsymbol{x}}

したがって、


 \begin{align}
\frac{\partial E}{\partial z_i} &= \frac{\partial E}{\partial \tilde{x}_i} \\
&= \frac{\partial E}{\partial \tilde{\boldsymbol{y}}} \cdot \frac{\partial \tilde{\boldsymbol{y}}}{\partial \tilde{x}_i} \\
&= \frac{\partial E}{\partial \tilde{\boldsymbol{y}}} \cdot \frac{\partial}{\partial \tilde{x}_i} \begin{pmatrix} \tilde{W}_{1:} \cdot \tilde{\boldsymbol{x}} + \tilde{b}_1 \\ \tilde{W}_{2:} \cdot \tilde{\boldsymbol{x}} + \tilde{b}_2 \\ \vdots \end{pmatrix} \\
& = \frac{\partial E}{\partial \tilde{\boldsymbol{y}}} \cdot \tilde{W}_{: i} \quad (全成分を偏微分した結果) \\
&= (\frac{\partial E}{\partial \tilde{\boldsymbol{z}}} * \tilde{\boldsymbol{f'}}(\tilde{\boldsymbol{y}})) \cdot \tilde{W}_{\cdot i} \quad 
\end{align}

最後の式変形は"計算"の途中式を追うと、\frac{\partial E}{\partial y_i} = \frac{\partial E}{\partial z_i} f'(y_i)であることから。

この層の次のレイヤーが出力層だとすると、\frac{\partial E}{\partial \tilde{\boldsymbol{z}}} * \tilde{\boldsymbol{f'}}(\tilde{\boldsymbol{y}}) は計算出来て、もちろん \tilde{W}も既知だから \frac{\partial E}{\partial z_i}が求まって、行列表示としてまとめると、


 \frac{\partial E}{\partial W} = (\frac{\partial E}{\partial \tilde{\boldsymbol{y}}} \tilde{W} * \boldsymbol{f'}(\boldsymbol{y})) \otimes \boldsymbol{x}

これを繰り返すことで全ての隠れ層について、その勾配 \frac{\partial E}{\partial W}を計算することができる。

バイアスについて


 \begin{align} 
\frac{\partial E}{\partial b_{i}} &= \frac{\partial E}{\partial \boldsymbol{y}} \cdot \frac{\partial \boldsymbol{y}}{\partial b_{i}} \\
&=\frac{\partial E}{\partial \boldsymbol{y}} \cdot \frac{\partial}{\partial b_{i}} \begin{pmatrix} W_{1:} \cdot \boldsymbol{x} + b_1\\ W_{2:} \cdot \boldsymbol{x} + b_2 \\ \vdots \end{pmatrix} \\
&=\frac{\partial E}{\partial \boldsymbol{y}} \cdot \begin{pmatrix} 0 \\ \vdots \\ 1  \\ \vdots \\ 0 \end{pmatrix} \quad (第i成分だけ残る)\\
&= \frac{\partial E}{\partial y_i} \\
&= \frac{\partial E}{\partial z_i} f'(y_i) \\
\end{align}

したがって、

 \frac{\partial E}{\partial \boldsymbol{b}} = \frac{\partial E}{\partial \boldsymbol{z}} * \boldsymbol{f'}(\boldsymbol{y})

ウェイトの場合と同様に \frac{\partial E}{\partial \boldsymbol{z}}を出力層から遡って計算すればよい。

numpyでの実装

import numpy as np

ベクトル: \boldsymbol{x} → 一次元配列x(例:np.array([...])
行列:W → 二次元配列W(例:np.array([[...] ... ])

ベクトルのスライス: x[k:l]
行列の第i行目ベクトル : W[i, :]
行列の第j列目ベクトル : W[:, j]
行列の第i行第j列成分: W[i, j]

ベクトルの内積: x @ y
ベクトルの要素積: x * y
ベクトルの直積: np.outer(x, y)
ベクトルと行列の積: W @ x