TeXsorflow(LaTeXニューラルネットワーク)を作った
これはMuroran Institute of Technology Advent Calendar 22日目の記事です.
はじめに
ニューラルネットワーク構築用パッケージ TeXsorflow*1を作りました. つまり,
実装しました.
ニューラルネットワークとは
機械学習の一分野で人間の脳を数理モデル化したものです.教師データの入力を基に誤差を算出し,誤差を少なくするように重みやバイアスを調整することで学習を行います.
作ったもの
今回はLaTeXで分類問題を解くニューラルネットワークを実装しました.教師データから学習を行い,推論フェーズで出した答えと教師データの解答をPDFに出力します.
想定する環境
データ
今回はirisのデータを使って,種(Species)の分類を行います. がく片長(Sepal Length),がく片幅(Sepal Width),花びら長(Petal Length),花びら幅(Petal Width)を入力として持ち,種(Species)を出力とします.
モデル
モデルは以下のような設定です.
- 入力層ユニット数 4
- 隠れ層ユニット数 3
- 隠れ層レイヤー数 1
- 出力層ユニット数 3
実装しなければならないこと
- 活性化関数
- ReLU
- ソフトマックス関数
- CSVの読み込み
- フォワードプロパゲーション
- バックプロパゲーション(誤差逆伝播法)
実装
主に使うパッケージ
- FP
- pgfmath
- arrayjobx
- datatool
- ifthen
マクロなどのグローバル化
グローバルなマクロ
今回,繰り返し処理はpgfパッケージにあるforeachコマンドを使って行います.
\foreach \x in {1,...,100}{% \pgfmathsetmacro{\result}{10} }
その際に,foreach内でマクロに代入を行うと,グループ内はローカルのため,次のループに入る時,値が消えます. 上の例だと,\resultに10を代入しても,繰り返すと,次の代入まで0になります. よって,マクロを以下のようにグローバルにします.
\xdef{\result}{\result}
グローバルな配列
LaTeXにはarrayjobxという配列構造を扱えるパッケージがあります. 使い方は以下を参考にしました.
- arrayjobx
天地有情 [LaTeX] arrayjob --- 配列データ構造を操作する
さて,このarrayjobxで生成した配列はグローバルではありません,なので,前節と同じ理由で値が消えたりします.なので,これをグローバルに対応させます.
\let\newglobalarray\newarray \patchcmd{\newglobalarray}{\edef}{\xdef}{}{}
配列を生成する\newarrayを\newglobalarrayにコピーして,定義内の\edefを全て\xdefに置き換えて,グローバル化します.
次にマクロを展開して配列に代入できるマクロを定義します. 本来,arrayjobxは以下のようにして値の代入ができます.
\newglobalarray\hogearray \hogearray(1,1)=1
しかし,代入する対象が展開が必要なマクロなどの場合は,展開の順序を考慮する必要があります. よって,代入の対象を展開してから配列に代入するマクロを定義します.
\newcommand{\assignArray}[2]{% \begingroup\edef\xx{\endgroup\noexpand#1={#2}}\xx% }%
活性化関数の実装
隠れ層の活性化関数にはReLUを使い,出力層の活性化関数にはソフトマックス関数を使います.
ReLU
ReLUとその微分は以下のような式で表します.
[tex: \displaystyle f(x) = \max(0, x)] [tex: \displaystyle f'(x) = \left\{ \begin{array}{l} 1 & (x > 0) \\ 0 & (otherwise) \end{array} \right. ]
これは数式を解釈できるpgfパッケージのpgfmathsetmacroコマンドを使えば簡単に実装できます.
\newcommand{\activationFunction@texnn}[1]{% \pgfmathsetmacro{\x@texnn}{#1}% \pgfmathsetmacro{\y@texnn}{max(0, \x@texnn)}% }% % arg1: x \newcommand{\activationFunctionDiff@texnn}[1]{% \pgfmathsetmacro{\x@texnn}{#1}% \pgfmathsetmacro{\y@texnn}{\x@texnn>0 ? 1 : 0}% }%
ソフトマックス関数
出力層はソフトマックス関数を活性化関数とします. ソフトマックス関数とその微分は以下の式で表します.
[tex: \displaystyle f(x_i) = \frac{\exp(x_i)}{\sum_{j=0}^N \exp(x_j)}] [tex: \displaystyle f'(x_i) = \left\{ \begin{array}{l} f(x_i)(1 - f(x_i)) & (i = j) \\ -f(x_i)f(x_j) & (i \neq j) \end{array} \right. ]
こちらはfpパッケージという固定小数点演算用のパッケージを使って実装しています.
% arg1: x \newcommand{\softmax}{% \checkZs@iris(3,1) % 配列から値を読む(\cachedataに格納される) \pgfmathsetmacro{\z@one}{\cachedata} % \cachedataを別の変数に格納 \checkZs@iris(3,2)% \pgfmathsetmacro{\z@two}{\cachedata}% \checkZs@iris(3,3)% \pgfmathsetmacro{\z@three}{\cachedata}% \FPeval{\z@denom}{(exp(\z@one)) + exp(\z@two) + exp(\z@three)} % ソフトマックスの分母部分を計算 \FPeval{\y@texnn}{(exp(\z@one) / \z@denom)} % ソフトマックスの分子部分を計算 \assignArray{\As@iris(3, 1)}{\y@texnn} % 計算結果を配列に格納 \FPeval{\y@texnn}{(exp(\z@two) / \z@denom)}% \assignArray{\As@iris(3, 2)}{\y@texnn}% \FPeval{\y@texnn}{(exp(\z@three) / \z@denom)}% \assignArray{\As@iris(3, 3)}{\y@texnn}% }%
CSVの読み込み
ニューラルネットワークもデータがなければ始まりません.CSVを読み込んで配列に格納します.
外部ファイルからの読み込み
外部からCSVを読み込むにはdatatoolパッケージを使います.また,それを配列に格納するために配列データ構造を使えるarrayjobxパッケージを使います. 使い方は以下を参考にしました.
- datatool
天地有情 [LaTeX] datatool --- CSVデータからグラフやテーブルを作成
\DTLloaddb{irisDB}{iris_random.csv} % csvを読み込む \newcounter{step@dtl} %データ数をカウント \setcounter{step@dtl}{0}% \dataheight=\value{inputNum} % 列数を指定し,一次元配列を二次元として扱う \DTLforeach{irisDB}{\slIris=sepalLength,\swIris=sepalWidth,\plIris=petalLength,\pwIris=petalWidth,\correctIris=species}{% \stepcounter{step@dtl}% % 各列を配列に代入 \assignArray{\irisSVArray(\value{step@dtl}, 1)}{\slIris}% \assignArray{\irisSVArray(\value{step@dtl}, 2)}{\swIris}% \assignArray{\irisSVArray(\value{step@dtl}, 3)}{\plIris}% \assignArray{\irisSVArray(\value{step@dtl}, 4)}{\pwIris}% \assignArray{\irisCorrectArray(\value{step@dtl})}{\correctIris}% }{}% \setcounter{dataNum@iris}{\value{step@dtl}}%
重み,バイアスの初期化
重みとバイアスは乱数で初期化します.
\newcommand{\initWeights}{% % 入力層から隠れ層への重み \foreach \x in {1,...,\value{inputNum}}{% \foreach \y in {1,...,\value{layerSize}}{% \FPrandom{\result} % 0.0〜1.0の乱数を生成 \pgfmathsetmacro{\initialVal}{\result*0.1} \assignArray{\weights@ItoH@iris(\y,\x)}{\initialVal} }% }% % 隠れ層から出力層への重み \foreach \x in {1,...,\value{layerSize}}{% \foreach \y in {1,...,\value{classNum}}{% \FPrandom{\result}% \pgfmathsetmacro{\initialVal}{\result*0.1} \assignArray{\weights@HtoO@iris(\y,\x)}{\initialVal} }% }% }% \newcommand{\initBias}{% % 入力層から隠れ層へのバイアス \foreach \x in {1,...,\value{inputNum}}{% \FPrandom{\result}% \pgfmathsetmacro{\initialVal}{\result*0.1} \assignArray{\bias@ItoH@iris(\x)}{\initialVal} }% % 隠れ層から出力層へのバイアス \foreach \x in {1,...,\value{layerSize}}{% \FPrandom{\result}% \pgfmathsetmacro{\initialVal}{\result*0.1} \assignArray{\bias@HtoO@iris(\x)}{\initialVal} }% }%
フォワードプロパゲーション
現在の重みで入力層から出力層まで計算を行います. 大まかな手順としては
- 番目のデータを入力層の ] = ] に入力
- 隠れ層の入力,出力を求める
- 出力層の入力,出力を求める
まずは,それぞれの層のを求めます.
\newcommand{\evalZ@iris}[1]{% % case of input Layer \ifthenelse{#1=1}{ % 入力層のzにデータを入力 \foreach \x in {1,...,\value{inputNum}}{% \checkirisSVArray(\value{learningStep},\x)% \assignArray{\Zs@iris(#1,\x)}{\cachedata}% }% }% {% % case of output layer \ifthenelse{#1=\value{layerNum}}{ % 出力層のzを求める \foreach \x in {1,...,\value{classNum}}{% \Zs@iris(#1,\x)=0% \pgfmathsetmacro{\sum@tmp}{0}% \foreach \y in {1,...,\value{layerSize}}{% \checkweights@HtoO@iris(\x, \y)% \pgfmathsetmacro{\w@tmp}{\cachedata}% \checkAs@iris(2, \y)% \pgfmathsetmacro{\a@tmp}{\cachedata}% \pgfmathsetmacro{\sum@tmp}{(\w@tmp * \a@tmp) + \sum@tmp}% \xdef\sum@tmp{\sum@tmp}% }% \checkbias@HtoO@iris(\x)% \pgfmathsetmacro{\b@tmp}{\cachedata}% \pgfmathsetmacro{\sum@tmp}{(\sum@tmp) + \b@tmp}% \xdef\sum@tmp{\sum@tmp}% \assignArray{\Zs@iris(#1,\x)}{\sum@tmp}% }% }% % case of hidden layer {% \foreach \x in {1,...,\value{layerSize}}{ % 隠れ層のzを求める \Zs@iris(#1,\x)=0% \pgfmathsetmacro{\sum@tmp}{0}% \foreach \y in {1,...,\value{inputNum}}{% \checkweights@ItoH@iris(\x, \y)% \pgfmathsetmacro{\w@tmp}{\cachedata}% \checkAs@iris(1, \y)% \pgfmathsetmacro{\a@tmp}{\cachedata}% \pgfmathsetmacro{\sum@tmp}{(\w@tmp * \a@tmp) + \sum@tmp}% \xdef\sum@tmp{\sum@tmp}% }% \checkbias@ItoH@iris(\x)% \pgfmathsetmacro{\b@tmp}{\cachedata}% \pgfmathsetmacro{\sum@tmp}{(\sum@tmp) + \b@tmp}% \assignArray{\Zs@iris(#1,\x)}{\sum@tmp}% \xdef\sum@tmp{\sum@tmp}% }% }% }% }%
次に活性化関数を使ってを求めます.
\newcommand{\evalA@iris}[1]{% % case of input Layer \ifthenelse{#1=1}{ % 入力層のaにデータを入力 \foreach \x in {1,...,\value{inputNum}}{% \checkZs@iris(#1, \x)% \assignArray{\As@iris(#1,\x)}{\cachedata}% } }% {% % case of output layer \ifthenelse{#1=\value{layerNum}}{ % 出力層のaをソフトマックスで求める \foreach \x in {1,...,\value{classNum}}{% \softmax \assignArray{\As@iris(#1,\x)}{\y@texnn}% }% }% % case of hidden layer { % 隠れ層のaをReLUで求める \foreach \x in {1,...,\value{layerSize}}{% \checkZs@iris(#1, \x)% \activationFunction@texnn{\cachedata}% \assignArray{\As@iris(#1,\x)}{\y@texnn}% }% }% }% }%
バックプロパゲーション(誤差逆伝播法)
フォワードプロパゲーションで出力を求めました.次にこれが元の正解とどれほど離れているかという誤差を算出します.今回,誤差関数に交差エントロピーを使いました.
変数は教師データを["setosa", "versicolor", "virginica"]の順番でone-hot表現したものです.
one-hot表現は以下のマクロで定義しています.
% get one hot embedding % setosa => 0 % versicolor => 1 % virginica => 2 \newcommand{\onehotembedding}[1]{% \readarray{oneHotTeacher}{0&0&0}% %\def\teacher{\getArrayValue{irisCorrectArray(#1)}}% \checkirisCorrectArray(#1) % 正解データを取得 \ifthenelse{\equal{\cachedata}{setosa}}{ % 正解データの文字列と比較 \oneHotTeacher(1)=1% }% {% \ifthenelse{\equal{\cachedata}{versicolor}}{ % 正解データの文字列と比較 \oneHotTeacher(2)=1% }% {% \oneHotTeacher(3)=1% }% }% }%
1つのデータに対する交差エントロピーを求めるマクロ.
\pgfmathsetmacro{\cost}{0}% \newcommand{\costFunc}[1]{% \onehotembedding{#1}% 正解データをone-hot表現する \checkoneHotTeacher(1)% \pgfmathsetmacro{\t@one}{\cachedata}% \checkoneHotTeacher(2)% \pgfmathsetmacro{\t@two}{\cachedata}% \checkoneHotTeacher(3)% \pgfmathsetmacro{\t@three}{\cachedata}% \checkAs@iris(3, 1)% \pgfmathsetmacro{\a@one}{\cachedata}% \checkAs@iris(3, 2)% \pgfmathsetmacro{\a@two}{\cachedata}% \checkAs@iris(3, 3)% \pgfmathsetmacro{\a@three}{\cachedata}% % 交差エントロピーを求める % 対数の中が0になるのを防ぐため+0.0001する \pgfmathsetmacro{\cost}{(-\t@one*ln(\a@one+0.0001)-\t@two*ln(\a@two+0.0001)-\t@three*ln(\a@three+0.0001))} }%
それぞれのユニットの誤差を求める.
偏微分の連鎖律から
ここで,
より,
特に出力層の活性化関数はソフトマックス関数なので,
出力層の誤差から隠れ層の誤差は
これをLaTeXで実装すると以下のような感じになりました.
\newcommand{\evalUnitError}{% % evaluation output unit error \foreach \x in {1,...,\value{classNum}}{% \checkAs@iris(3, \x)% \pgfmathsetmacro{\a@tmp}{\cachedata}% \checkoneHotTeacher(\x)% \pgfmathsetmacro{\t@tmp}{\cachedata}% \pgfmathsetmacro{\delta@tmp}{(\a@tmp - \t@tmp)} % ユニットの誤差を算出 \assignArray{\OUnitError(\x)}{\delta@tmp}% }% % evaluation hidden unit error \foreach \x in {1,...,\value{layerSize}}{% \checkZs@iris(2, \x)% \activationFunctionDiff@texnn{\cachedata}% \checkOUnitError(1)% \pgfmathsetmacro{\deltaOne}{\cachedata}% \checkOUnitError(2)% \pgfmathsetmacro{\deltaTwo}{\cachedata}% \checkOUnitError(3)% \pgfmathsetmacro{\deltaThree}{\cachedata}% \checkweights@HtoO@iris(1,\x)% \pgfmathsetmacro{\w@one}{\cachedata}% \checkweights@HtoO@iris(2,\x)% \pgfmathsetmacro{\w@two}{\cachedata}% \checkweights@HtoO@iris(3,\x)% \pgfmathsetmacro{\w@three}{\cachedata}% \pgfmathsetmacro{\delta@tmp}{((\deltaOne*\w@one) + \deltaTwo*\w@two + \deltaThree*\w@three)*\y@texnn } % ユニットの誤差を算出 \assignArray{\HUnitError(\x)}{\delta@tmp}% }% }%
重みとバイアスの更新
ユニットの誤差が求まったので重みとバイアスを更新します. 更新には勾配降下法を使います.それぞれ更新式は以下になります.
は学習率です.
それでは,重みとバイアスの更新をします.
% 重みの更新 \newcommand{\updateWeights}{% % 入力層から隠れ層への重みの更新 \foreach \x in {1,...,\value{inputNum}}{% \foreach \y in {1,...,\value{layerSize}}{% \checkweights@ItoH@iris(\y,\x)% \pgfmathsetmacro{\w@tmp}{\cachedata}% \checkHUnitError(\y)% \pgfmathsetmacro{\delta@tmp}{\cachedata}% \checkAs@iris(1, \x)% \pgfmathsetmacro{\a@tmp}{\cachedata}% \pgfmathsetmacro{\updateW}{(\w@tmp) - \learningRate*\delta@tmp*\a@tmp}% \typeout{\updateW = \w@tmp - \learningRate*\delta@tmp*\a@tmp} % 更新 \assignArray{\weights@ItoH@iris(\y,\x)}{\updateW}% }% }% % 隠れ層から出力層への重みの更新 \foreach \x in {1,...,\value{layerSize}}{% \foreach \y in {1,...,\value{classNum}}{% \checkweights@HtoO@iris(\y,\x)% \pgfmathsetmacro{\w@tmp}{\cachedata}% \checkOUnitError(\y)% \pgfmathsetmacro{\delta@tmp}{\cachedata}% \checkAs@iris(2, \x)% \pgfmathsetmacro{\a@tmp}{\cachedata}% \pgfmathsetmacro{\updateW}{(\w@tmp) - \learningRate*\delta@tmp*\a@tmp}% \typeout{\updateW = \w@tmp - \learningRate*\delta@tmp*\a@tmp} % 更新 \assignArray{\weights@HtoO@iris(\y,\x)}{\updateW}% }% }% }% % バイアスの更新 \newcommand{\updateBias}{% % 入力層から隠れ層へのバイアスの更新 \foreach \x in {1,...,\value{layerSize}}{% \checkbias@ItoH@iris(\x)% \pgfmathsetmacro{\b@tmp}{\cachedata}% \checkHUnitError(\x)% \pgfmathsetmacro{\delta@tmp}{\cachedata}% \pgfmathsetmacro{\updateB}{(\b@tmp) - \learningRate*\delta@tmp} % 更新 \typeout{\updateB = \b@tmp - \learningRate*\delta@tmp} \assignArray{\bias@ItoH@iris(\x)}{\updateB}% }% % 隠れ層から出力層への重みの更新 \foreach \x in {1,...,\value{classNum}}{% \checkbias@HtoO@iris(\x)% \pgfmathsetmacro{\b@tmp}{\cachedata}% \checkOUnitError(\x)% \pgfmathsetmacro{\delta@tmp}{\cachedata}% \pgfmathsetmacro{\updateB}{(\b@tmp) - \learningRate*\delta@tmp} % 更新 \typeout{\updateB = \b@tmp - \learningRate*\delta@tmp} \assignArray{\bias@HtoO@iris(\x)}{\updateB}% }% }%
学習を行う
実際に学習を行います. 今までの実装した処理は全てマクロになっているので,これらを一連の流れとして繰り返します.
\newcommand{\trainingProcess}{% \evalZ@iris{1}% \evalA@iris{1}% \evalZ@iris{2}% \evalA@iris{2}% \evalZ@iris{3}% \evalA@iris{3}% \costFunc{\x}% \pgfmathsetmacro{\totalCost}{(\cost) + \totalCost}% \xdef\totalCost{\totalCost}% \evalUnitError% \updateWeights \updateBias \stepcounter{learningStep}% \addSupervisorToArray \initWeights \initBias \foreach \epoch in {1,...,100}{% \pgfmathsetmacro{\totalCost}{0} \setcounter{learningStep}{1} \readarray{OUnitError}{0&0&0} \readarray{HUnitError}{0&0&0} \foreach \x in {1,...,135}{% \trainingProcess }% \foreach \x in {136,...,150}{% \testProcess } }
一通り学習が終わったら推論を行います. データを入力したら種を答えるようにします.
\newcommand{\answer}[1]{% \onehotembedding{#1} \checkoneHotTeacher(1)% \pgfmathsetmacro{\t@one}{\cachedata}% \checkoneHotTeacher(2)% \pgfmathsetmacro{\t@two}{\cachedata}% \checkoneHotTeacher(3)% \pgfmathsetmacro{\t@three}{\cachedata}% \checkAs@iris(3, 1)% \pgfmathsetmacro{\a@one}{\cachedata}% \checkAs@iris(3, 2)% \pgfmathsetmacro{\a@two}{\cachedata}% \checkAs@iris(3, 3)% \pgfmathsetmacro{\a@three}{(\cachedata)}% \pgfmathsetmacro{\myAns}{(max(max(\a@one, \a@two), \a@three))}% }% % show answer \newcommand{\showAnswer}{ \FPifeq{\myAns}{\a@one} answer is ``setosa'' \\% \else \FPifeq{\myAns}{\a@two} answer is ``versicolor'' \\% \else answer is ``virginica'' \\ \fi% \ifthenelse{\t@one=1}{% solution is ``setosa'' \\% }% {% \ifthenelse{\t@two=1}{% solution is ``versicolor'' \\% }{% solution is ``virginica'' \\% }% }% }%
条件分岐で出力層の出力でそれぞれ最大だった時の表示を変えているだけです. one-hot表現したも同様に条件分岐で正解を表示しています.
結果
それでは実際に学習と推論の結果を見せます.学習の条件としては
- 訓練データ数 135
- テストデータ数 15
- エポック数 100
- 学習率 0.1
として行いました.この条件だと, 学習から推論までタイプセットにかかる時間はおよそ10分程度です.重みとバイアスの初期値と更新値,誤差,分類結果,正解を表示させると,ページ数は156ページになりました.
しかし,
全然,当たりません.というか,分類結果が全然変わりません.何があっても"versicolor"に分類されてしまいます.データ数が足りないのかモデルが悪いのか分かりません.学習率は0.001~0.5で変えましたが,結果は変わりませんでした. 誤差もほぼ変化ありません.4ページ目に載っている誤差と155ページ目に載っている誤差はどっちもおよそ155でした.
重みやバイアスも変わっているはずなのですが...
ユニット数などは実装段階で固定してしまっているので修正して試す時間もないので今回はここで諦めました.
一応,PDFも上げておきます.
ソースコードも上げときます.
おわりに
ニューラルネットワークに対する知識が半端なのに他の言語でプロトタイプ作らずにいきなりLaTeXで実装したのがダメでしたね.後でPythonで実装して同じ条件で学習をやってみてどうなるか見てみます.
おまけ
学習はうまく行きませんでしたが,せっかくLaTeXでやってるのでTikZを使ってパラメータを可視化できるようにしました.
visualize_TeX_NN.pdf - Google ドライブ
*1:名前は今,適当につけました.これ以降出ません