LED通信事業プロジェクト エンジニアブログ

F#で行列計算をしよう #2

行列同士の計算

記事更新日 2024年4月2日


はじめに

Microsoft F#というのは、Microsoft .NETグループの一言語ですが、唯一の関数型言語です。関数型言語は副作用※1が少なく、その結果完成したコードにエラーが少ないというメリットがあるのですが、それ以外にもう一つメリットがあります。それは、関数型言語は「行列計算を駆使したDeep Learning系AI処理を書きやすい」というものです。

なのですが、何とF#で行列計算をしようと思っても、Microsoftの公式のリファレンスぐらいしか出ておらず、しかも、それすらも追加された機能が反映されていなかったりして、折角の高機能が台無しです。調べても誰もまとめている気配が無い。仕方ないので私が作るしか無い、ということでF#で行列の計算をする方法を何回かに分けた連載という形でお伝えしようかと思います。

真面目に頭から読む、というよりはF#を調べているときに検索に引っかかったらよいですね、ぐらいの記事にしていくつもりです。とはいえ、こういう内容って、AIだと正しいか正しくないかわかりにくいことが多いので、あればあるで誰かの役には立つんじゃないかと思っています。

ちなみに、AIそのものの作成方法(誤差逆伝搬法とか)や、行列計算そのものの意味(内積・外積の意味とか計算方法とか)とか、そういったものの説明はしません。そんな大層な内容ではございませんで、あくまで「プログラミング中に困って検索したら引っかかる」ことを目的としています。

今回は、2回目で行列の計算についての話題です。

前回の記事はこちらから。

配列同士の足し算

F#における行列は「配列」または「アレイ」とよばれる変数型で扱われます。この配列型を計算するときはどうしたらよいでしょう。例えば、配列同士の各要素を足し算する場合を考えてみましょう。

数学的には、下図のように行列を足し算の記号で繋げれば、各要素が足し算されます※1

fig.1

じゃあ、これをF#でそのまま書くとどうなるでしょうか?一次元配列で試してみます。

// ダメな例 let ary1 = Array.create 3 1 //1次元配列、[|1, 1, 1|] let ary2 = Array.create 3 2 //1次元配列、[|2, 2, 2|] let answer = ary1 + ary2 //Error発生!!

F#において配列をそのまま+*などの普通の演算子で計算することはできません。というか、ほとんどのプログラミング言語では、この書き方だとエラーになるんじゃないでしょうか?それじゃあ、プログラミングで行列同士の計算をするには、どうしたら良いでしょうか?

誰もが思いつく方法は、各要素それぞれに、つまりひとつひとつ抽出して計算するって方法です。この方法をF#で書くとこんな感じになります。

// answer = ary1 + ary2 // 各要素毎に足し算をする let ary1 = Array.create 3 1 //1次元配列、[|1, 1, 1|] let ary2 = Array.create 3 2 //1次元配列、[|2, 2, 2|] let answer = Array.create 3 0 //答え投入用 // for文を使って各要素を足す for i in [0 .. (ary1.Length - 1)] do answer[i] <- ary1[i] + ary2[i] //val answer: int[] = [|3; 3; 3|]

for文を使って書きます。F#で繰り返し計算は再帰関数を使うのが美しいコードとされますが、配列で各要素へアクセスして同じ計算をさせる、つまり途中の条件もない単純な繰り返し処理は、さすがにfor文で書いた方が見た目が良いです。

この方法は、確かに答えもあっていますし、それ故何の問題もないのですが、それでも配列の各要素毎に計算するっていうのは、正直かなりかっこ悪い。そこで、ちょっとF#っぽい関数を使ってみようと思います。それがMAP関数と呼ばれるものです。

MAP関数

MAP関数とは、配列内の全要素に同じ計算を行うというものです。例えば、全要素に3をかけるとか、全要素を二乗するとか、そういった計算が可能です。全要素に同じ計算をします。下の例では、行列の各要素に1を足すという計算をします。

fig.2
MAP関数の意味

y=x+1という式の、引数xに計算対象の行列の要素を代入し、その結果であるyが結果の行列の要素になる、という具合です。そして、この計算こそがMAP関数です。「各要素に行う計算」というのは、どんな計算でも構いません。MAP関数は、行列の各要素に、指定した計算式を実行し、その結果を別の行列に入れる、ということをします。

下にF#にMAP関数の使用例を挙げてみます。

let ary1 = [|1; 2; 3|] let mapfunc a = a + 1 //引数に1を足す関数 let answer = Array.map mapfunc ary1 //MAP関数実行 //val answer: int[] = [|2; 3; 4|] let mapfunc2 a b = a * b //第1引数と第1引数のかけ算の関数 let answer2 = Array.map (mapfunc2 5) ary1 //MAP関数実行 //val answer2: int[] = [|5; 10; 15|] //let answer2 = Array.map mapfunc2 ary1 <=これだと引数が足りなくてError!!

Array.mapが、F#のMAP関数です。MAP関数の引数は[計算したい関数][計算する配列]の二つです。MAP関数そのものは、多くのプログラミング言語に実装されています。しかし、一般的な例えばオブジェクト指向の言語ですと、引数に「関数」を持たせるためにラムダ式やらデリゲートやら考慮しなければならない面倒な事があり、特殊な文法の記述が必要になります。しかし、関数型のF#にとっては、最初から値も関数も同じ扱いです。複雑な手続きも特殊な文法も必要なく、普通に書けばそれで動きます。例えば事前にMAPで計算させる関数を作っておいて、MAP関数にそれを代入すれば良いだけです。この手法は、別にMAP関数に限らず、F#ではよく使う手法なため、躓くことはないでしょう。

尚、F#のルールで[計算したい関数]の最後の引数が、計算対象となる配列の要素となります。だから、引数が1つの関数の場合は、引数を入れずに書いて、引数が2つの関数なら、一つ目の引数に何らかの値を入れていないとエラーになります。もちろん、引数が無い(unit)とかもエラーになります。

と、まあMAP関数の話をしたわけですが、ここで最初の目的を思い出してください。目的は、行列同士の足し算をすることでした。MAP関数は便利な機能ではあるのですが、一つの行列を対象にした計算しかできません。行列と行列を足すには、二つの行列の要素を引数にする必要があります。そう考えると、MAP関数は行列同士の足し算にはどう考えても使えません。

しかし、F#にはとてもおしゃれな関数があります。それが、Array.map2です。「2」ですよ。この関数は、[計算したい関数]に二つの引数をとる関数を設定し、二つの引数がそれぞれ、別の配列の要素とできる関数です。文字で書いてもわからないと思うので、MAP2関数を使った例を書いています。

let ary1 = [|1; 2; 3|] let ary2 = [|4; 5; 6|] let mapfunc a b = a + b //引数同士の足し算をする関数 let answer = Array.map2 mapfunc ary1 ary2 //MAP2関数実行 //val answer: int[] = [|5; 7; 9|]

この例として、[計算したい関数]を2つの引数を足す単純な関数にしました。MAP2関数は2つの配列の要素を引数として計算する関数です。2つの要素の次元、行数、列数が合っていないとエラーになります。しかし、これを使うことで行列同士の足し算が簡単にできます。もちろん、足し算だけじゃなくて、引き算でもいいですし、1万行レベルの複雑な計算であろうとなんであろうと、引数が2つある関数さえ作れば、何だって計算できます。このシンプルさはF#の素晴らしいところ(のひとつ)です。

関数の速度

for文を使って各要素にアクセスしながら計算しても、MAP2関数を使って一気に計算しても、計算結果は同じです。しかし、計算速度は結構違います。

試しに、次のようなコードを作ってみました。サイズが10,000の2つの配列を足すというものです。

/// 計算時間比較 /// 乱数配列作成 let ary = Array.create 10000 0.0 // サイズ指定のため配列(中身は意味なし) let rnd = new System.Random() //乱数の宣言 let mapfunc a = rnd.NextDouble() //乱数を生成(0.0~1.0)する関数=>MAP用 let ary1 = Array.map mapfunc ary //乱数配列1 let ary2 = Array.map mapfunc ary //乱数配列2 let sw = new System.Diagnostics.Stopwatch() //ストップウォッチ ///// for文で各要素にアクセスし和算 ///// let answer1 = Array.create 10000 0.0 //結果代入用 do sw.Reset() //ストップウォッチリセット sw.Start() //ストップウォッチスタート // for文で各要素を足し算する for count in [1 .. 10000] do for i in [0 .. (ary1.Length - 1)] do answer1[i] <- ary1[i] + ary2[i] //各要素同士を足す do sw.Stop() //ストップウォッチストップ let time1 = sw.ElapsedMilliseconds //経過時間を代入(for文) ///// MAP2関数で一気に和算 ///// let mutable answer2 = Array.empty //結果代入用 let mapPlus a b = a + b //要素の足し算関数 do sw.Reset() //ストップウォッチリセット sw.Start() //ストップウォッチスタート // for文で各要素を足し算する for count in [1 .. 10000] do answer2 <- Array.map2 mapPlus ary1 ary2 do sw.Stop() //ストップウォッチストップ let time2 = sw.ElapsedMilliseconds //経過時間を代入(MAP2)

さて、使用した2つの配列は、いずれも要素が乱数の配列です。こんなコードで作りました。

/// 乱数配列作成 let ary = Array.create 10000 0.0 // サイズ指定のため配列(中身は意味なし) let rnd = new System.Random() //乱数の宣言 let mapfunc a = rnd.NextDouble() //乱数を生成(0.0~1.0)する関数=>MAP用 let ary1 = Array.map mapfunc ary //乱数配列1

これは、(効果があるとは思えませんが)念のため単純計算の繰り返しによる中間コードの最適化が起こらないように、2つの配列の各要素は乱数になるようしています。乱数化の際は普通の(MAP2ではない)MAP関数を使っています。乱数数値を生成する関数をMAPの引数にして、配列を計算することで、各要素が乱数(正確には擬似乱数)になります。と、まあ乱数要素の配列は、別にどうでもいいので、詳しくは説明しませんが、MAP関数にはこういった使い方もあるというのを知って頂ければ。

let sw = new System.Diagnostics.Stopwatch() //ストップウォッチ

また、正確に時間を計るためにSystem.Diagnostics.Stopwatch()、通称ストップウォッチを使っています。ある計算にかかった時間を計る際、一般的にはシステムの「時計」を使うと思います。例えば、開始時間と終了時間を記録しておいて、差分を経過時間とするって方法です。F#でも、結果の時間が分単位以上であればその方法を使用します。しかし、Windows + .NETの環境だと、時計の精度が低く10msec以下は正確には測れないのです。つまり、ミリ秒単位の測定に時計が使えないのです。ちなみに、同じコードでもRaspberry Piとか別のOSで動かすと時計がミリ秒単位で正確なため、msec単位も正確に測れます。Windowsめ・・・

というわけで、msecを測定したい場合はストップウォッチがお勧めです。startからstopまでミリ秒単位で正確に測ってくれます。そして、どれぐらい時間がかかったかはElapsedMillisecondsで、ミリ秒で返してくれます。この辺のテクニックは、C#とかVB.netでも共通なので、ストップウォッチは是非覚えておいてほしいテクニックです。

というわけで、本題に戻りまして、念のため10回ほど、このコードを回して、for文とMAP2関数の時間を比べてみました。結果は以下の通り。

For文 MAP2
最小 1324ms 214ms
最大 1392ms 244ms
平均 1336.4ms 221.4ms

MAP2はFor文の1/5の時間で済んでます。つまり5倍速。シャアより速い! 私の環境がRyzen7のノートなので、Windowsに最適化されたIntelのCPUならもっと差が縮まるかも知れません。だとしても、MAP2関数の方が圧倒的に速いというのは変わらないでしょう。

MAP2関数の方が速い理由は様々あると思います。それでも確実に言えるのは「マイクロソフトがMAP等の関数を作る際には、その関数が配列要素一つ一つにアクセスするよりも、絶対に計算が速くなるように関数を作っている」ということです。じゃなきゃ、誰も使ってくれませんので当然ですよね。そういうことなので、配列を計算するときは、例えば今回のMAPだけでなく、要素の最大値を取るとかそんな処理をしたいときは、まずそれ用の関数が無いか調べるべきと考えます。

調べるべきとか偉そうなことを言ってしまったので、よく使う関数を一つ紹介したい思います。配列の各要素に対して、ある式の条件に合うかどうかを調べるArray.forallという関数です。使い方は、ほぼMAP関数と変わりません。下の例は、配列の中身はすべて偶数であるかどうかを調べてたものです。

/// 全ての要素が条件に合うかを調べるforall関数 let ary = [|0; 2; 4; 6; 8; 10|] //調べる配列 // 条件式(偶数かどうか?) let isEven a = a % 2 = 0 //2で割り切れる場合はTrue、そうで無い場合はFalse let result = Array.forall isEven ary // val result: bool = true

配列の要素がすべて偶数なので、Array.forall関数の結果はTrueになります。他にも、MAP2のように2つの配列を引数とできるArray.forall2の様な関数もありますし、一つでも条件を満たす要素があるかどうかを調べるArray.existsという関数もあります。

いずれにしても全てが関数で構成させるF#では、代入するための条件式を作るのも簡単ですので、こういった関数を使いやすいんですよね。

尚、このブログでもF#にどんな関数があるか紹介していく予定です。

あれ?多次元配列は?

さて、今まで配列の関数に関して、特にMAP関数に関して説明してきました。しかし、真面目にコードまで見てくださっている方なら、きっと違和感を覚えましたよね。数式の図は2次元配列の例なのに、実際にF#で計算する例はなんで1次元配列ばかりなの?って。

そうなんです。そこなんですよ、問題は。残念ながら全ての機能が2次元以上で使えるわけじゃないんです。先ほど紹介した関数以外にも、1次元配列の関数はとても沢山あります。それこそ、覚えきれないほどに。しかし、2次元の配列には関数がほんの少ししかありません。例えば、2次元以上であってもMAP関数は存在しますが、先ほど紹介し、且つ頻繁に使いそうなMAP2関数は存在しません。むむむ。このままでは単純な足し算すらできないんですよ。どうしたらよいのでしょうか?結局、要素毎にアクセスしなければいけないのか・・・

次回は、1次元配列にしか存在しない関数を、2次元の配列で使うにはどうするのか?というところを説明したいと思います。また、次回もお楽しみに!


※1; 行列計算の大前提として「行数と列数の条件」が合っていないと計算ができない。例えば、足し算と引き算の場合行と列の数が同じでないと計算できない。今後は、特に理由がない限り、行数、列数に関しては、計算条件があっているものとする。