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

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

二次元配列を一次元にする 後編

記事更新日 2024年12月17日


はじめに

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

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

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

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

今回は、4回目で2次元配列から1次元配列へ変換するという話題の続きです。正直、F#の記事って、アクセス数も検索数も低いんで、(会社の都合上)記事に登場する頻度はとっても低くなってしまうのですが、趣味のページとして残していきたいと思ってます。

尚、前回の「二次元配列を一次元にする 前編」の記事はこちらになります。

前回の話を3行で・・・

前回のあらすじ F#の配列には便利な関数が多くあるものの、その関数が1次元配列でしか利用できないものも多く、AIでよく使う2次元以上では利用できません。それだったら、一旦、2次元配列を1次元配列へ変換してしまえばいいんだろ?ということで、効率の良い変換方法を探しています。スライスは、配列に一定以上のサイズが無いと効果を発揮しないようです。

生成AIに聞いてみる

なんか、2次元配列から1次元配列へ変換するのに、もっと効率的な方法は無いのでしょうか?最近のプログラミングと言えば、「わからなかったら、とりあえず生成AIに聞いてみよう」が当たり前ですから、私も生成AIに聞いてみたいと思います。いくつかのAIに尋ねてみますが、どのAIに対しても質問文は、

F#にて、2次元配列から1次元配列へ変換する効率的な方法を教えてください。

で固定とします。さて、どんな回答が来るのでしょうか?

Google Geminiの回答

さて、Pixelを使い、Youtube PremiumにもGoogle Oneにも課金しているGoogle派の私としてのファーストチョイスは、やはりGeminiです。Geminiに上記質問したところ、以下の様なコードが返ってきました。

/// Google Geminiによるサンプルコード /// let twoDimensionalArray = [|[|1; 2; 3|]; [|4; 5; 6|]|] // (筆者コメント)パイプライン演算子を使った変換式 let oneDimensionalArray = twoDimensionalArray |> Seq.collect id |> Seq.toArray printfn "%A" oneDimensionalArray // 出力: [|1; 2; 3; 4; 5; 6|]

途中パイプライン演算子という、F#でよく使われる表現が入っているので、ちょっと分かりにくいですね。パイプライン演算子は、式と式を繋げる演算子で、式が繋がってわかりやすくなることから、F#ではよく使われます。個人的には、初心者に優しくないしデバッグもしにくくなるので、あまり使わないのですが、やっぱりF#の特長でもあるので、説明しておきましょう。

パイプライン演算子は、ある値や関数の後に|>という記号を付けることで、次の式(関数)に繋げることが出来るという演算子です。|>の左にある値や計算結果の戻り値は、右に書かれる関数の引数として渡されていきます。右の関数に引数が2つある場合は、一番最後(最も右)の引数になります。そして、パイプライン演算子でいくつ繋いだとしても、最終的には一番右側の関数の戻り値が、式全体の戻り値となります。例えばこんな感じです。

/// パイプライン演算子の使い方 /// let func1 x = x * 2 let func2 x y = x * y // 引数が2つある let pipetest x = x + 1 |> func1 |> func2 3 //パイプライン演算子を使った関数 let ans = pipetest 1 //ここが実際に計算する式 // val ans: int = 12

上記のlet ans = pipetest 1式の計算は、次のような順番で行われます。

とこんな感じで続いていって、答えは12となります。パイプライン演算子は、慣れないと分かりにくいですが、とにかく「前の答えを一番右の引数に代入」とだけ覚えればOKです。

で、元に戻ってGeminiのコードをよくよく見てみると、これ元の配列は2次元配列じゃなくて、ジャグ配列※1じゃないですか!ジャグ配列と2次元配列は違うものですよ。これじゃあ、使えないのか・・・ でも、文章をよく読んでみると、良いことも書いてある。

Array.concat: シンプルですが、すべての要素を一度にメモリに読み込むため、大規模な配列ではパフォーマンスが低下する可能性があります。

おや?concatは遅いと書いてある。じゃあ、concatの代わりに、上のコードを使うと早くなるのかな?と言うことで、前回のスライスのコードのconcatを、Gemini推奨のコードに置き換えて、次のような試験コードを作ってみました。

/// 前回のスライスコードのconcatを、Gemini推奨コードに置き換えたコード /// let ary2d = createRandomArray2D 64 64 //コピー元の64x64配列を作成 //一時代入用に配列を要素に持つジャグ配列を定義 let jagary1d = Array.create 64 (Array.create 64 0.0) // 100,000回繰り返す for count in [1 .. 100000] do // 行毎にスライスして、一時台入用配列に代入 for y = 0 to 63 do jagary1d[y] <- ary2d[y, *] //行をスライスして代入 //Geminiによるconcatの代わりのパイプラインコードを改変 let ary1d = jagary1d |> Seq.collect id |> Seq.toArray ()

さてさて、concatより早くなったかな。ということで、前回と同じように、上記コードを10回実施して、平均を取りました。その結果、平均時間は2642.5ms。あれ?前回測った他の方式のタイムって、どうでしたっけ?

試験サイズ・回数 全要素コピー スライス+concat スライス+Gemini案
64x64@100,000 1059.6ms 1522.3ms 2642.5ms

おいおい、concatより更に遅くなってるじゃないか!!concatがメモリ全読みで効率悪いって話はどうなったんだ?そう言えば、「大規模な配列」と書いてあったから、配列を大きくすれば良いのね?じゃあ、もっと大きな配列で試せば良いかな?どれどれ・・・

試験サイズ・回数 全要素コピー スライス+concat スライス+Gemini案
64x64@100,000 1059.6ms 1522.3ms 2642.5ms
256x256@10,000 4627.2ms 4946.6ms 11354.7ms
1024x1024@200 6348.2ms 1340.4ms 2923.5ms

いやいや、むしろ配列が大きい方がconcatとの差が大きくなってるじゃないか!! 結局、concatの方がましなのか。頼むよ、Geminiさん、信頼してたのに。

OpenAI ChatGPTの回答

次にChatGPTです。ChatGPTにはMicrosoftの資本が大量に入っていますし、Googleよりも.NET言語が得意な可能性はありますね。ということで、早速ChatGPTにも聞いてみましたところ、次のようなコードを紹介されました。

/// OpenAI ChatGPT4によるサンプルコード /// let twoDArray = array2D [[1; 2; 3]; [4; 5; 6]; [7; 8; 9]] let oneDArray = Array2D.flatten twoDArray //=>ここはエラー

いやーん、またジャグ配列を2次元配列として扱っている。しかも、それ以前にArray2D.flattenという存在しない関数を出してきました。flattenは1次元配列にしかないんですよ。1次元配列の関数が2次元配列で使えないという事態を回避するためにいろいろ頑張っているのに、これじゃあ本末転倒・・・ とにかく、このままだと実行出来ないので、評価のしようが無い。

が、回答の下の方を見ていくと、別のコード例にちょっと興味深い内容が記載されています。

/// OpenAI ChatGPT4によるサンプルコード その2/// let oneDArray = twoDArray |> Seq.cast<int> |> Seq.toArray

ん?この1行で2次元配列を1次元配列へ変換する事ができるのか? コードを見ると、2次元配列をSeq.cast<int>で、Array型をSeq型に型変換して、最後にSeq.toArrayでまた1次元の配列型へもどすってことをやっているようです。ちなみに、Seq型はシーケンス型と呼ばれ、Array型のように計算をするのではなく、データリストを格納する等「静的」に使われるもので、1次元配列のみ存在します。そのため、「Array型からSeq型に型変換するときに、強制的に1次元となる」という性質を利用しているようです。

パイプライン演算子を使っているとはいえ、たった一行で書けるってことは、結構シンプルなのかも。ただ、ChatGPTは恐ろしい注釈も付けています。

Seq.cast はキャストを伴うため、若干パフォーマンスが低くなる可能性があります。

あまりパフォーマンスは良くないかも知れない。ただ、やるしかない。上のコードとちょこっと改変し、試してみました。

/// ChatGPT4によるサンプルコード その2を改変/// let ary2d = createRandomArray2D 64 64 //コピー元の64x64配列を作成 // 100,000回繰り返す for count in [1 .. 100000] do let ary1d = ary2d |> Seq.cast<float> |> Seq.toArray //2d=>1d変換 ()

コードは圧倒的に短くなりました。とても美しいコードだ!!しかし、実行時間はどうでしょう。結果は次の通りです。

試験サイズ・回数 全要素コピー スライス+concat ChatGPTの一行案
64x64@100,000 1059.6ms 1522.3ms 1917.7ms
256x256@10,000 4627.2ms 4946.6ms 31094.5ms

全要素コピーどころか、concatを使用したスライスよりも遅い。しかも、配列サイズが大きくなると、遅くなるタイプのようだ。コードは極めてシンプルで、究極なのに、遅い、遅すぎる。やはり、ChatGTPが言うように、型変換するキャストはコストが高いんですね。

Microsoft Copilotの回答

F#はMicrosoftの製品ですから、いくらコパイ(Copilot)がChatGPT(GPT-x)ベースとは言いえ、普通は自社関連の情報を自社のディクショナリーを使ってたっぷりと学習させてあるでしょう。だとすると、もしかして、一番良い回答を出してくれるかも!!というわけで、Office365にあるCopilotに聞いてみました。うん、そもそも弊社IT部門の推奨AIですしね、最初からこれを使うべきなのかもしれない。

/// Microsoft Copilotによるサンプルコード/// let twoDimArray = [| [|1; 2; 3|]; [|4; 5; 6|]; [|7; 8; 9|] |] let oneDimArray = twoDimArray |> Array.concat

またまた、ジャグ配列。一緒じゃないか(怒)! AIは、みんなジャグ配列(array array型)と2次元配列(array2d型)の違いが理解できていないようで。そして、2次元配列はconcat出来ないし。残念ながら、全体的にChatGPTよりも内容が薄いですし、コパイから有用な情報は取得できませんでした。Microsoftさん、コパイにF#学習させてないのかな?いや、F#なんて、そもそも学習する材料が無いのか・・・

いや、仕方ない、最終手段として「ジャグ配列は2次元配列ではない」と追記してみました。すると、こんなコードを表示しました。

/// Microsoft Copilotによるサンプルコード、ジャグ配列排除/// let rows = array2D.GetLength(0) let cols = array2D.GetLength(1) let result = Array.zeroCreate<'T> (rows * cols) Array2D.iteri (fun i j value -> result.[i * cols + j] <- value) array2D

お?これも実体は一行だけど、これって全要素コピーを短く書いただけのコードでは?Array2D.iteriというのは、配列の全要素分、与えられた関数を実行するって関数なので、その中に「2次元配列の各要素を1次元配列へコピーする」という関数を与えただけです。しかし・・・

この方法は、各要素を手動でコピーするため、パフォーマンスが高い場合があります

何だと、速くなるかも知れないだと?全要素コピーと同じ事をやるわけだけど、でも専用関数なので早くなる可能性はあるんだ。やってみよう!

/// Copilot推奨 Array2D.iteriを使用したコード /// let ary2d = createRandomArray2D 64 64 //コピー元の64x64配列を作成 //一時代入用に配列を要素に持つジャグ配列を定義 let ary1d = Array.create 64 (Array.create 64 0.0) // 100,000回繰り返す for count in [1 .. 100000] do // iteriを使用して、全要素コピー Array2D.iteri (fun i j value -> ary1d[i * size + j] <- value) ary2d ()
試験サイズ・回数 全要素コピー スライス+concat Copilot iteri関数
64x64@100,000 1059.6ms 1522.3ms 1565.9ms
256x256@10,000 4627.2ms 11354.7ms 5371.9ms

あれ?速くない。配列のサイズも変えて計算してみましたが、正直それほど速くない、というか普通の全要素コピーとあまり変わらない・・・ 結局、関数呼び出して、計算して、を繰り返すだけなので、単純コピーより、劇的に速くなることはないんですね。

ちなみに、GeminiやChatGPTでも「ジャグ配列は2次元配列ではない」といれると、ほぼ同じようなコードとなってます。うーん、そんなものか。

結論

以上の通り、色々と試してみましたが結論としては、

ということになります。どちらも、どちらかというと力業なコードになるので、思ったよりいけている方法は無かったという印象です。さて、この2つの方法と、先に出たiteriを使った方法で、どれぐらいのサイズで処理速度が逆転するのかを調べてみました。配列は縦横同サイズの正方行列を用いて、配列サイズ(一辺の要素数)を変えながら、2次元配列から1次元配列への変換を1000回行うというものです。その結果は、下のグラフの通りになりました。

fig.2

ご免なさい、わかりにくいグラフで。なんか、対数とっても今ひとつなので、そのまま実数で表示しています。で、要素毎コピーとスライス+concatの処理時間が逆転するのは、列数448を超えたところですね。そのタイミングで、全要素コピーよりiteriが速くなるのですが、結局スライス+concatが速くなるので、まさに中間って感じ。細かい数字はPC依存(CPU依存)なんでしょうけど、分岐点はざっくりとこの程度のサイズだと思っていただければ。

といわけで、2次元配列を1次元配列に変換するためのコードを最後に載せておきます。配列の列数によって変換方法を変えるコードです。配列が小さいときは「全要素コピー」、配列が大きいときは「スライス+concat」を使用します。引数となる2次元配列は「ジェネリック型」になっていますので、要素はどんな型でも大丈夫なはずです・・・ですが、AIの計算は繰り返し、そして大量になる事が多いので、このコードだとちょっと無駄が多いです。ですから、実際に実装するときは、無灘部分を省いた方が、良いかも知れません。

というわけで、また次回(いつになるのか?)お会いしましょう!

/// 2次元配列から1次元配列へ変換する、速度重視の関数 /// /// 単純に各要素を代入 let copyAllElements (baseAry:'a[,]) = // 縦横の配列の長さを取得 let (length1, length2) = (Array2D.length1 baseAry, Array2D.length2 baseAry) let ary1d:'a[] = Array.zeroCreate (length1 * length2) // コピー先の配列 // 各要素を代入 for x = 0 to (length2 - 1) do for y = 0 to (length1 - 1) do let i = y * length1 + x ary1d[i] <- baseAry[y, x] ary1d //戻り値 /// スライス + concat let sliceAndConcat (baseAry:'a[,]) = // 縦横の配列の長さを取得 let (length1, length2) = (Array2D.length1 baseAry, Array2D.length2 baseAry) let size = length1 * length2 let jag1d = Array.create size (Array.zeroCreate length1) // 一時コピー用ジャグ配列 // スライス毎に代入 for y = 0 to (length2 - 1) do jag1d[y] <- baseAry[y, *] Array.concat jag1d //Concatとして戻す /// 2次元配列から1次元配列へ変換する関数。 /// 配列の要素はジェネリック型です。 /// 2次元配列('a array2d) -> 配列サイズ(int) -> 変換後1次元配列('a array) let convert2dto1d (ary2d:'a[,]) targetSize = let length2 = Array2D.length2 ary2d // 2次元目の長さを取る // 長さによって実行する関数を変える if length2 < targetSize then // 長さがターゲットより短いとき(戻り値) copyAllElements ary2d else // 長さがターゲットより長いとき(戻り値) sliceAndConcat ary2d
/// 実行例 /// /// 64x64 要素の型Single(Float32)で、1次元配列へ変換する /// // サンプルとして、Single型の64x64の2次元配列を作成 let createNum y x = float32(y) * 64f + float32(x) + 1f let ary2d = Array2D.init 64 64 createNum // 変換関数呼び出し(列数448以上からconcatを使う) let ary1d = convert2dto1d ary2d 448
(担当M)
***

※1; 配列の要素が配列であるような配列。1次元配列の要素が1次元配列だった場合、見た目上2次元配列のように見えるが、真の2次元配列ではない。したがって、2次元配列向けに用意されている関数も使用できない。詳しくは、こちらの記事で。