Skip to content

第 2 章: データ構造の比較

2.1 データの表し方

機械学習のコードの大半は、表形式のデータを読み、列を選び、欠損値を埋め、行を分ける処理です。14 言語は、この表を別々の形で表しました。

第 1 波・第 2 波

言語 表の表し方 1 行の型 列の選び方
Python pandas の DataFrame 無し(列ごとに dtype を持つ) 列名の文字列(df["RM"])
Kotlin Kotlin DataFrame の DataFrame 無し(列ごとに型を持つ) 列名の文字列
TypeScript 型付きレコードの配列(IrisRow[]) Record<Feature, number \| null> などのオブジェクトの型 プロパティ名(列名の和集合の型で検査される)
F# レコードのリスト、特徴量は Map<string, float> 読み込みは型プロバイダの行の型、以降は Map Map のキー(列名の文字列)
Java record のリストと Table Row(Map<String, String> を包む) 列名の文字列
C# record のリストと Table Row(IReadOnlyDictionary<string, string> を包む) 列名の文字列
Scala case class と不変のコレクション Row(Map[String, String]) 列名の文字列
Go 構造体と map[string]string Row(非公開の cells) 列名の文字列
Rust 構造体と HashMap<String, String> Row(非公開の cells) 列名の文字列

第 3 波

言語 表の表し方 1 行の型 列の選び方
Ruby Hash の配列と Struct/Data Hash(列名から値) 列名の文字列
Clojure 素のマップとベクタ キーワードを鍵にしたマップ キーワード
Elixir 素のマップとリスト アトムを鍵にしたマップ アトム
PHP 素の配列と readonly class 連想配列(PHPStan の配列の形で表す) 列名の文字列
Haskell レコードと Map・リスト レコード、特徴量は Map Text Double Map の鍵(Text)

第 2 波・第 3 波の 10 言語は、第 1 波の TypeScript 版・F# 版と同じくデータフレームを使わない道を採りました。どれも「セルは文字列で持ち、読み出すときに数値へ変換する」形です。CSV のセルは本来ただの文字列で、空欄かどうか・数値として読めるかどうかは読み出す側の関心なので、この形にすると欠損値の扱いを 1 か所にまとめられます。

データフレームを使った 2 言語

Python と Kotlin は、データフレームのライブラリを使いました。列ごとの集計(平均・欠損値の数)や、列の追加・削除が 1 行で書けるので、第 2 章の前処理は短く書けます。一方で、列の名前は文字列なので、列名の書き間違いは実行するまで分かりません。

データフレームを使わなかった 12 言語

TypeScript と F#、そして第 2 波・第 3 波の 10 言語は、データフレームを使わずに、言語の型でデータを表しました。

  • TypeScript は、列名の配列を as const で型にし、1 行を Record<Feature, number | null> で表しました。存在しない列名を書くと型チェックで誤りになります。ただし、型は実行時に消えるので、CSV の中身が本当にその形かは、読み込みの学習用テストで確かめました(TypeScript 版第 2 章)
  • F# は、FSharp.Data の 型プロバイダ で、架空の値のサンプルから CSV の型をコンパイル時に作りました。列名を書き間違えるとコンパイルエラーになります。ただし、特徴量は Map<string, float> に移し替えています(F# 版第 2 章)
  • 第 2 波の 5 言語 は、Table(列の一覧と行)と Row(列名からセルの文字列)という同じ形にそろえました。列の選び方は文字列なので、列名の書き間違いは実行するまで分かりません。そのかわり、「どの列にも同じ処理」を書きやすく、第 8 章の前処理パイプライン(欠損値の補完・ダミー変数化)が素直に書けました
  • 第 3 波の 5 言語 も同じ方針ですが、鍵の型が言語ごとに違います。 Clojure はキーワード、Elixir はアトム、Ruby と PHP は文字列、Haskell は Text です。この違いが、あとで「列の順」の問題として効いてきます

「列の順」を別に持ち回った 3 言語

第 3 波で新しく出てきた論点が、連想配列が列の順を保つかどうかです。

言語 鍵の並び 列の順をどう保ったか
Clojure 9 件目からハッシュマップになり挿入順を保たない :columns のベクタで持ち回る
Elixir 32 件を境に flatmap から hashmap に変わり、キーの順を保たない 列の順はリストで持ち回る
Haskell Data.Map は常に鍵の順に並ぶ 列の順は別のリストで持ち回る
PHP 連想配列は挿入順を保つ そのままでよい
Ruby Hash は挿入順を保つ そのままでよい

Clojure と Elixir の「件数が少ないうちは挿入順に見える」振る舞いは、小さなテストでは気づけない種類の落とし穴です。列が 9 列・32 列を超えたところで初めて崩れます。この 3 言語は、いずれも「表の列の順はデータ構造の性質に頼らず、明示的に持ち回る」という同じ結論に達しました。

2.2 欠損値の表し方

iris の特徴量の 4 列には、合わせて 7 件の空欄があります。14 言語は、空欄を次のように表しました。

第 1 波・第 2 波

言語 空欄の値 補完の後の型 補完漏れの見つけ方
Python NaN(pandas が読み込むときに変換) 同じ(float64 の列) テストで欠損値の数を数える
Kotlin null(Kotlin DataFrame) 同じ列の型 テストで欠損値の数を数える
TypeScript null(number \| null) number(Record<K, number>) 型チェック(補完前のデータを渡すと誤り)
F# None(float option) float(Map<string, float>) コンパイル(補完前のデータを渡すと誤り)
Java 空文字列のセル、読み出しは OptionalDouble Features(欠損値を持てない) コンパイル(Row を渡すと誤り)
C# 空文字列のセル、読み出しは double? Features コンパイル
Scala 空文字列のセル、読み出しは Option[Double] Features コンパイル
Go 空文字列のセル、読み出しは (float64, bool, error) Features コンパイル(ただし「値と ok」は無視できる)
Rust 空文字列のセル、読み出しは Result<Option<f64>> Features コンパイル

第 3 波

言語 空欄の値 読み取りの失敗 補完漏れの見つけ方
Ruby nil 例外 テストで欠損値の数を数える
Clojure nil 例外 テストで欠損値の数を数える
Elixir nil 例外 テストで欠損値の数を数える
PHP ?float(null を許す型) 例外 型宣言と PHPStan レベル 9
Haskell Maybe Double Either String(型が別) コンパイル

「値が無い」をどう表すかは、言語の道具立てがそのまま出た部分です。Option 系(F#・Scala・Rust・Haskell の Maybe)と null 許容(Kotlin・TypeScript・C#・PHP)と nil/None(Python・Ruby・Clojure・Elixir)と OptionalDouble(Java)は同じことを別の形で書いており、Go だけが多値返却(値・見つかったか・エラー)になりました。Go 版では 3 つの戻り値のうち「ok」を読み飛ばしてもコンパイルが通るので、型による強制は 14 言語でいちばん弱くなっています。

失敗(読めなかった)と欠損(空欄だった)を別の型で分けているのは、Haskell・Rust・F# の 3 言語だけです。 Haskell は失敗を Either String、欠損を Maybe Double で表し、ほかの 11 言語は失敗を例外か多値返却で扱っています。Haskell 版の第 2 章では、この分離が 「テストの中でも let Right x = ... は書けない」(-Wincomplete-uni-patterns が -Werror で止める)という厳しさとして現れました。expectRight を書いて「失敗したらその場で落ちる」ことを明示しています。

  • TypeScript と F# は、補完の前と後を 別の型 にしました。第 3 章の決定木は補完後の型しか受け取らないので、補完を忘れたデータを渡すと、テストを実行する前に誤りになります
  • PHP の ?float は、Scala の Option[Double] ほど強くありません。 null を取り出す前に検査したかどうかを型が強制しないからです。ただし PHPStan のレベル 9 は ?float を float として使おうとすると指摘するので、静的解析が Option の役割を肩代わりします
  • PHP では ?: と if ($value) が 0.0 を偽とするので、0.0〜1.0 に正規化されたデータでは欠損値の判定に使えません(!== null で書く)。Elixir の || も補完する値が nil のとき黙って nil を返すので、「無ければ例外」は Map.fetch/2 で書いています。Clojure の get の既定値は先に評価されるので、同じことを when-not で書きました。「無いことを nil で表す」言語は、3 言語とも同じ場所でつまずいています
  • F# 版の第 8 章では、乗客のレコードに年齢と乗船港の型を型引数で持たせ(PassengerOf<'Age, 'Embarked>)、補完の前後を別の型にしました

欠損値の扱いは、「型で防ぐ」言語と「テストで確かめる」言語に分かれました。どちらの場合も、補完に使う平均値は 訓練データだけ から求める(データリークを防ぐ)という手順は 14 言語で同じです。

2.3 分割と乱数

ここが、第 3 波でいちばん大きく変わった節です。

第 2 波までの前提: 「違うのは言語ではなく乱数生成器」

14 言語とも、「シードで行の順番を並べ替え、テストデータの件数(割合を掛けて切り上げ)を除いた先頭を訓練データにする」という同じ手順で分けています。違うのは、並べ替えに使う乱数生成器です。

言語 乱数生成器 並べ替え
Python NumPy の np.random.default_rng(seed) permutation
Kotlin 標準ライブラリの kotlin.random.Random(seed) shuffled
TypeScript 自作の mulberry32(Math.random はシードを指定できない) 自作の Fisher–Yates のシャッフル
F# .NET の System.Random(seed) 自作の Fisher–Yates のシャッフル
Java java.util.Random(seed) 自作の Fisher–Yates のシャッフル
C# .NET の System.Random(seed) 自作の Fisher–Yates のシャッフル
Scala java.util.Random(seed) 自作の Fisher–Yates のシャッフル
Go math/rand の rand.New(rand.NewSource(seed)) 自作の Fisher–Yates のシャッフル
Rust rand 0.8 の StdRng::seed_from_u64(seed) 自作の Fisher–Yates のシャッフル
Ruby Random.new(seed) と Array#shuffle(random:) 標準の shuffle
Clojure java.util.Random(seed) 自作の Fisher–Yates のシャッフル
Elixir 自作の線形合同法(java.util.Random と同じ) 自作の Fisher–Yates のシャッフル
PHP 自作の線形合同法(同上) 自作の Fisher–Yates のシャッフル
Haskell 自作の線形合同法(同上) 自作の Fisher–Yates のシャッフル(不変のリストでは添字を書き換えられないので Map に写してから交換する)

件数(iris なら訓練データ 105 件・テストデータ 45 件)は 14 言語で一致します。Python 版の第 2 章では、同じシードでも scikit-learn の train_test_split とは分け方が違うことも確かめました。

第 3 波で起きたこと: 3 言語が乱数生成器を自作してそろえた

第 2 波まで、乱数生成器を自作したのは TypeScript だけでした。理由は「Math.random にシードを指定できないから」、つまり代わりが無いからです。

第 3 波では、Elixir・PHP・Haskell の 3 言語が、代わりがあるのに自作しました。 理由が違います。

言語 標準にあるもの それでも自作した理由
Elixir Nx.Random(Threefry)、Erlang の :rand どちらもほかの言語版と並びが合わない。 シード 0 の Nx.Random の並びは [2, 7, 9, 6, 0, 8, 1, 3, 4, 5] で、どの言語版とも一致しなかった
PHP mt_rand(メルセンヌ・ツイスタ) 同上。ほかの言語版と並びが合わない
Haskell System.Random 同上

3 言語とも、java.util.Random と同じ 48 ビットの線形合同法(乗数 0x5DEECE66D、加数 11、シードの撹拌、2 の冪のときだけ別式、剰余の偏りを避ける棄却)を写し取りました。ライブラリが無いから自作したのではなく、ほかの言語版と数値を突き合わせるために自作した——これが第 3 波でいちばん面白い選択です(ADR 012・ADR 013・ADR 014)。

java.util.Random を選んだのには根拠があります。第 2 波の時点で、java.util.Random はアルゴリズム(線形合同法)が仕様として文書化されている数少ない例だと分かっていました。仕様が公開されているから写せるのであって、System.Random や StdRng を写すことはできません。

14 言語で 8 通りになった

0〜9 を同じシード 0 で並べ替えると、次のようになります。

乱数生成器 言語 shuffle(0..10, 0)
java.util.Random と、それを写した自作 Java・Scala・Clojure・Elixir・PHP・Haskell [4, 8, 9, 6, 3, 5, 2, 1, 7, 0]
.NET の System.Random C#・F# (両者で一致。値は各版の第 2 章を参照)
Ruby の Random と Array#shuffle Ruby [2, 8, 4, 9, 1, 6, 7, 3, 0, 5]
Go の math/rand Go [6, 8, 2, 3, 7, 5, 9, 1, 0, 4]
Rust の rand 0.8 の StdRng Rust [9, 3, 6, 4, 8, 1, 5, 2, 0, 7]
NumPy の default_rng Python (Python 版の第 2 章を参照)
kotlin.random.Random Kotlin (Kotlin 版の第 2 章を参照)
自作の mulberry32 TypeScript (TypeScript 版の第 2 章を参照)

9 言語で 7 通りだった結果が、14 言語で 8 通りになりました。 言語を 5 つ増やして、結果は 1 通りしか増えていません。増えたのは Ruby の 1 通りだけで、Clojure・Elixir・PHP・Haskell の 4 言語はすべて既存の java.util.Random の並びに合流したからです。

この合流には、実行環境の違いを越えた意味があります。JVM の Java・Scala・Clojure は「同じ実行環境の乱数生成器を使っているから一致する」のに対し、BEAM の Elixir、PHP、GHC の Haskell は「同じ漸化式を別々に書いたから一致する」のです。訓練データのがく片長さの平均 0.4215384615384616 が、6 言語で 1e-15 まで一致しました。

自作したことで可能になったこと

数値がそろうと、章をまたいだ突き合わせができるようになります。第 3 波の各版は、第 3 章以降のほとんどの数値を「Java 版・Scala 版・Clojure 版・Elixir 版・PHP 版と一致した」という形で確かめました。

章 そろった数値
3 深さごとの正解率、深さ 2 の木の境界(0.2950・0.6500)
7 切片 6114.60、R² 0.6184、MAE 302.20、RMSE 376.14
8 891 件(生存 342/死亡 549)、訓練 712/テスト 179、深さ 5 の正解率と人数、学習した前処理の値
9 決定係数 8 個(0.6056/0.6950、0.7740/0.8628、0.7953/0.8213、0.6717/0.7947)
10 自作モデルの正解率 8 個
11 正解率 0.7811・適合率 0.7759・再現率 0.6306・F 値 0.6833・RMSE 405.77・MAE 321.53
12 alpha ごとの実験表、選んだ alpha(10.0)、テストの決定係数(線形 0.5224、リッジ 0.6243)、ラッソが 0 にした 3 列
13 寄与率(PC1 0.4110、累積 0.8427)、必要な主成分の数 6、主成分の係数
14 SSE の 10 行、クラスタごとの件数と平均支出額

これは「アルゴリズムが正しく写せている」ことの、いちばん強い証拠です。 言語も、データ構造も、書き方もまるで違うのに、同じ手順を踏めば同じ数値が出る。逆に、一致しなかった箇所(後述の 3 か所)はすべて、その言語に固有の理由が突き止められました。

第 3 章の決定木の正解率

分け方の違いは、そのまま正解率の違いになります。第 3 章の iris の決定木(深さの上限ごと)の正解率を、乱数生成器のまとまりごとに並べます。

java.util.Random の並びを共有する 6 言語(Java・Scala・Clojure・Elixir・PHP・Haskell)

深さ 訓練 テスト
1 0.6762 0.6444
2 0.9333 0.9556
3 0.9524 0.9556
4 0.9619 0.9556
5 0.9810 0.9333
制限なし 1.0000 0.9333

6 行すべてが 6 言語で一致します。 実行環境は JVM・BEAM・PHP・GHC の 4 つにまたがり、木を表す型も sealed interface・マップ・配列・代数的データ型とばらばらです。

第 1 波の 4 言語

深さ Python 訓練 Python テスト Kotlin 訓練 Kotlin テスト TypeScript 訓練 TypeScript テスト C#・F# 訓練 C#・F# テスト
1 0.6857 0.6222 0.6762 0.6444 0.6952 0.6000 0.6952 0.6000
2 0.9333 0.9556 0.9429 0.9333 0.9238 0.9778 0.9619 0.8889
3 0.9619 0.9111 0.9619 0.9333 0.9429 0.9556 0.9714 0.8889
4 0.9714 0.9111 0.9810 0.8889 0.9524 0.9556 0.9810 0.9111
5 0.9714 0.9111 0.9810 0.8889 0.9810 0.9111 1.0000 0.9111
制限なし 1.0000 0.9111 1.0000 0.8444 1.0000 0.9333 1.0000 0.9111

Go・Rust・Ruby

深さ Go 訓練 Go テスト Rust 訓練 Rust テスト Ruby 訓練 Ruby テスト
1 0.6857 0.6222 0.7143 0.5556 0.6762 0.6444
2 0.9333 0.9556 0.9524 0.9111 0.9333 0.9556
3 0.9429 0.9556 0.9619 0.9111 0.9524 0.9556
4 0.9524 0.9778 0.9714 0.9111 0.9524 0.9556
5 0.9714 0.9778 0.9810 0.9111 0.9714 0.9556
制限なし 1.0000 1.0000 1.0000 0.9111 1.0000 0.9556
  • 深さ 2 のテストデータの正解率は、0.8889(C#・F#)から 0.9778(TypeScript)まで、45 件中 40〜44 件の幅で違います。1 件の違いが 0.0222 の差になるので、1 回の分割の正解率でモデルやアルゴリズムの優劣を決めるのは危険です(各版の第 11 章で交差検証を扱います)
  • Go 版は「制限なし」でテストデータの正解率が 1.0000 になりました。 45 件を全部当てています。これはアルゴリズムが優れているのではなく、たまたま分けやすい 45 件がテストデータに入っただけです。逆に Rust 版は深さ 2 以降どの深さでも 0.9111 で動きません
  • Ruby 版は、深さ 1・2・3 が java.util.Random の 6 言語と偶然一致しますが、深さ 4 以降で分かれます。 分け方が違うのに一部の行が一致することがあるので、「数行が一致した」だけでは分割が同じだとは言えません
  • それでも、読み取れる傾向は 14 言語で同じです。深さを増やすと訓練データの正解率は 1.0 に近づき、テストデータの正解率は頭打ちになります(過学習)
  • 深さ 2 の木は、14 言語とも花弁幅だけで 3 品種を分けました。最初の境界の値は、Python・Java・Scala・Go・Rust と第 3 波の 5 言語すべてが 0.2950、Kotlin・TypeScript・F#・C# が 0.2750 です

シードだけでは再現できないもの

各版の第 4 章では、シードを固定しても数値が再現できなくなる要因を調べました。再現性の保証は、14 言語で 4 段階に分かれます。

段階 言語 根拠
仕様として保証されている Java・Scala・Clojure java.util.Random はアルゴリズム(線形合同法)が仕様として文書化されている。JDK の版が変わっても同じ乱数列になることが約束されている
自分のコードなので保証できる TypeScript・Elixir・PHP・Haskell 乱数生成器そのものがリポジトリのコードとして固定されている。TypeScript は mulberry32、ほかの 3 言語は java.util.Random の写し
保証は無いが、実測で確かめた Python・Kotlin・F#・C#・Go・Ruby F#・C# はシード付きの乱数列が .NET 8.0.22・9.0.11・10.0.1 で同じだったことを学習用テストで確かめた。Go は手元の 1.25.5 と 1.26.5 で同じ並びを確かめた。Ruby の並びを決めるのは Ruby 本体なので、固定しているのは Gemfile.lock ではなく Ruby の版(2.6.10 と 3.3.10 で同じ並びであることを実測した)
保証されないと明言されている Rust StdRng のドキュメントは「再現可能と考えるべきではない」と明言している(実体は ChaCha12。再現が要るなら rand_chacha を直接使えとある)。Cargo.lock で rand 0.8.8 に固定して再現している

第 3 波で、2 段目の言語が 1 つから 4 つに増えました。 乱数生成器を自作した言語は、アルゴリズムをリポジトリのコードとして固定でき、記事の中で並びを完全に説明できます。「乱数生成器は魔法ではなく 3 行の漸化式である」ことを、4 言語の第 2 章が示しています。

アルゴリズムを写すとは、コードを貼ることではない

java.util.Random を写すという同じ作業が、言語によって違う手間になりました。原因は整数が溢れたときの振る舞いで、14 言語で 4 通りに分かれます。

振る舞い 言語
折り返す(上位ビットが捨てられる) Java・Kotlin・Scala・C#・Go・Haskell(Int は 64 ビット)
多倍長整数になる(正確なまま) Elixir・Clojure・Ruby・Python
パニックか折り返し Rust(デバッグビルドではパニック、リリースでは折り返す)
float に化けて静かに精度を失う PHP だけ
  • Haskell はいちばん楽でした。 Int が Java と同じく折り返すので、仕様の式をそのまま書けます。なお Haskell には Integer(多倍長)もあり、そちらを選べば Elixir 版のように正確なまま大きくなります。「型を選ぶ」作業が、ここでは「どの数の世界で計算するかを選ぶ」ことになりました
  • Elixir は書き換えが要りました。 Java は棄却の条件を int のオーバーフローで負になることを使って < 0 と書いていますが、Elixir の整数は多倍長で溢れないので、オーバーフローの境界である 0x80000000 と直接比べる形に書き換えています
  • PHP がいちばん厄介でした。 48 ビットの state に 35 ビットの乗数を素直に掛けると 83 ビットになり、64 ビットを超えた時点で float に化けて下位ビットを静かに捨てます。線形合同法は下位ビットが次の状態を決めるので致命的です。state を上下 24 ビットに分けて掛け、48 ビットで足し合わせることで解決しました。しかもこれは例外ではなく非推奨の警告で通り過ぎるので、第 1 章で phpunit.xml に failOnDeprecation="true" を入れておいたことが効いています

アルゴリズムを写すとは、コードを貼ることではありません。 同じ漸化式が、言語の整数の性質に応じて 3 通りの書き方になりました。

2.4 表の結合とカテゴリ値

第 9 章では、自転車の利用者数(bike.tsv)と天気の対応表(Shift_JIS の weather.csv)を結合しました。

Shift_JIS を取り違えたときの振る舞いは 5 通りある

言語 Shift_JIS の読み込み 文字コードを指定し忘れたとき
Python pd.read_csv(..., encoding="shift_jis") UnicodeDecodeError で失敗した
Kotlin DataFrame.readCSV(..., charset = ...) 例外にならず、文字化けした
TypeScript TextDecoder("shift_jis") で文字列にしてから csv-parse に渡す 例外にならず、文字化けした(U+FFFD)
F# CodePagesEncodingProvider を登録してから StreamReader で読む 文字化けすることを学習用テストで確かめた
Java・Scala InputStreamReader に Charset.forName("Shift_JIS") を渡す Files.readAllLines は MalformedInputException を投げる
C# CodePagesEncodingProvider を登録してから読む 例外にならず、文字化けした
Go golang.org/x/text/encoding/japanese(標準ライブラリでは読めない) 例外にならず、U+FFFD に置き換わる(デコーダが黙って置換する)
Rust encoding_rs の SHIFT_JIS.decode String::from_utf8 が Err を返す。decode は had_errors で知らせる
Ruby encoding: "Shift_JIS:UTF-8" CSV::InvalidEncodingError を投げる
Clojure java.io.InputStreamReader に文字コードを渡す 例外を投げず、U+FFFD(置換文字)に置き換えて読み進む(同じ JVM でも Java 版の Files.readAllLines とは違う)
Elixir codepagex(CP932。設定ファイルで符号化表を組み込む) 例外も置換文字も出ず、String.valid?/1 が false の不正バイナリが値に入る
PHP mbstring の CP932(追加の依存が要らない) どちら向きも例外にならない(下記)
Haskell mkTextEncoding が iconv の名前をそのまま受け取る(base だけで読める) 片方向だけ例外(下記)

第 3 波で、この論点がいちばん深くなりました。

  • Clojure は、同じ JVM の Java 版と振る舞いが違いました。 java.io.InputStreamReader は文字コードを間違えても例外を投げず U+FFFD に置き換えますが、Java 版・Scala 版が使う Files.readAllLines は MalformedInputException を投げます。「JVM だから同じ」ではなく、どの API を通るかで決まります
  • Elixir は 3 つめの振る舞いでした。 例外でも置換文字でもなく、不正なバイナリがそのまま値に入ります。String.valid?/1 が false になるので検出はできます。逆に UTF-8 を CP932 として読むと codepagex が {:error, ...} を返すので、取り違えは片方向だけ静かに通ります
  • PHP は 4 つめで、どちら向きも例外になりません。 Shift_JIS を UTF-8 として読むと不正なバイト列がそのまま入り、UTF-8 を CP932 として読むと化けたうえで UTF-8 としては正しい文字列になります。後者は mb_check_encoding でも見抜けません。読み込む前に mb_check_encoding($bytes, 'UTF-8') で判定はできます。なお洗浄したときの置換文字は U+FFFD ではなく ?(0x3F)です
  • Haskell は、ばらばらだった振る舞いを 1 つの言語の中で名前で選べます。 mkTextEncoding は iconv の名前をそのまま受け取るので、UTF-8 なら例外、UTF-8//IGNORE なら黙って落とし、UTF-8//TRANSLIT なら U+FFFD に置換します。既定が例外である点が効きます。 ただし逆向き(UTF-8 を CP932 で読む)は例外にならず文字化けするので、片方向だけ静かに通る点は Elixir と同じです

文字コードの指定を忘れたときの振る舞いは、例外・置換文字・不正バイナリ・無言の文字化けの 4 種類に分かれ、しかも同じ言語でも API と向きで変わります。黙って化ける環境では、テストで期待する文字列(「晴れ」)と比べて初めて気付けます。14 言語とも、Shift_JIS で書いた小さなフィクスチャを読むテストで確かめています。

BOM を取り除くのは誰の仕事か

学習データの CSV は BOM 付きなので、素直に読むと先頭の列名が "\uFEFF身長" になり、「身長」で引けません。どの層が面倒を見てくれるかは、ライブラリごとに違います。

BOM をどうしたか 言語・ライブラリ
ライブラリが自動で取り除く Rust(csv クレート)、Ruby(CSV.read/CSV.foreach はファイルを開くときに取り除く)
読み込みの指定で取り除ける Python(encoding="utf-8-sig")
自分で取り除く処理を書いた Kotlin・TypeScript・F#・Java・C#・Scala・Go・Clojure(clojure.data.csv)・Elixir(NimbleCSV)・PHP(fgetcsv)・Haskell(cassava)

第 3 波で足したのは、取り除かない側に Clojure・Elixir・PHP・Haskell の 4 言語、取り除く側に Ruby の 1 言語です。取り除かない言語が 9 つ、取り除く言語が 3 つになりました。

Ruby には注意点があります。BOM を取り除くのは CSV.read/CSV.foreach(ファイルを開く経路)だけで、CSV.parse(File.read(...)) のように文字列から読むと先頭の見出しに BOM が残ります。文字列から読むときは encoding: "bom|utf-8" が要ります。「ライブラリが面倒を見てくれる」は、どの入り口を通るかまで含めて確かめる必要があります。

Haskell では、この論点がもう一段深くなりました。BOM は「バイト列か文字列か」で書き方が変わります。 Text のリテラルでは U+FEFF の 1 文字(\65279)ですが、ByteString では UTF-8 の 3 バイト(\239\187\191)で、両者は別物です。同じ章で、ByteString のリテラルに日本語を書いて壊すという失敗を実装とテストで 1 回ずつ踏んでいます(OverloadedStrings も Data.ByteString.Char8 の unpack も 1 文字を 1 バイトとして扱うため)。型は ByteString で合っていて中身の解釈だけが違うので、型検査では捕まりません。列名と値は Text で持ち、境界でだけ符号化・復号する形に落ち着きました。

他人のライブラリの親切に頼るときは、テストで確かめてから頼るのが安全です。

結合とカテゴリ値

天気ごとの平均利用者数(晴れ 4876.8・曇り 4052.7・雨 1803.3)は、分割に関係しないので 14 言語で一致します。データフレームを使わない 12 言語は、どれも「対応表を Map(辞書)にして引く」形で結合を書きました。

カテゴリ値(第 8・9 章の性別・乗船港・CRIME)は、14 言語ともダミー変数(0 と 1 の列)にしました。ダミー変数化の関数は、機械学習ライブラリに含まれていないことが多く(Tribuo・gonum・linfa にはありません)、ほとんどの言語で自作が最終実装になっています。ライブラリにある場合でも、基準列(最初のカテゴリ)を落とせないことがほとんどでした——Rumale の OneHotEncoder(整数のカテゴリだけを受け取り、先頭を落とせない)、Scholar の OneHotEncoder(drop="first" 相当が無い)、Rubix ML の OneHotEncoder(基準列を落とさず、カテゴリの順が「最初に現れた順」)。同じ穴が 3 つのライブラリで空いています。

2.5 同じ計算でも数値が分かれた 3 か所

分割がそろっても、すべての数値が一致したわけではありません。 一致しなかった 3 か所は、いずれもデータ構造の性質そのものが原因でした。

和を積む順で 1 ulp ずれる

第 10 章の特徴量の重要度 4 個は、マップの走査順で 2 つに分かれました。

結果 言語 マップの並び
0.1882 / 0.1271 / 0.2708 / 0.4140 Java・Clojure・PHP 挿入順(PHP の連想配列、Clojure の小さなマップ)
0.1882 / 0.1265 / 0.2713 / 0.4140 Elixir・Haskell 鍵の順(Data.Map、Elixir のマップ)

原因は、ジニ不純度を求めるときにラベルの出現数をどの順でたどるかです。浮動小数点数の加算は結合則が成り立たないので、和を積む順が変わると最後の桁に 1 ulp の差が付きます。そして、その 1 ulp が同点だった分割の勝敗をひっくり返します。

Elixir 版の記事では「Elixir 版だけ 4 桁目がずれた」(1558 回の分割のうち 269 回が同点)と書かれ、PHP 版の記事では「PHP の連想配列が挿入順を保つのでずれを踏まない」と書かれました。Haskell 版で、その説明のちょうど裏返しが確かめられます——Data.Map は鍵の順にたどるので、Elixir と同じ側に落ちました。木の分け方そのものは変わらないので、正解率は 6 言語すべてで一致しています。

「マップが挿入順を保つか」は、見た目の便利さの問題ではなく、数値の問題でもあります。

Haskell 版では、同じ理由で sum を書いてよいか毎回考えることになりました。リストの sum は右結合になりうるので、学習の計算は foldl' を明示しています。速度のためではなく、数値をほかの言語版と合わせるためです。

丸めの向きが偶数側になる

第 14 章で、Milk の平均がちょうど 34708.5 になる場所があります。ここで PHP と Haskell だけがほかの言語版とずれました。

言語 既定の丸め 結果
PHP sprintf('%.0f') は偶数側(round() は 0.5 を上に丸めるので、同じ言語の中で向きが違う) 34708
Haskell printf "%.0f" も round も偶数側(銀行家の丸め) 34708
Java・Elixir ほか 0.5 を上に丸める 34709

両方とも roundHalfUp を自作してそろえました。 PHP 版は sprintf と round で向きが違うことに、Haskell 版は printf と round の両方が偶数側であることに、それぞれ同じ場所でぶつかっています。roundHalfUp value = floor (value + 0.5) の 1 行です。

丸めの向きは、言語の「正しさ」の問題ではありません(統計的には偶数側への丸めのほうが偏りが少ない)。14 の版で同じ表を載せると決めた以上、どちらかにそろえる必要がある、というだけのことです。

正規方程式を解く実装で 12 桁目が分かれる

第 15 章の予測値は、分割が完全に一致していても、4 つの値に分かれました。

値 言語
7730.457421687023 Java・Scala・Clojure
7730.457421687022 Haskell(第 3 波で JVM にいちばん近い)
7730.457421687019 PHP
7730.457421687016 Elixir

差は約 1e-12 で、違うのは正規方程式を解く実装の丸めの順だけです。第 7 章の切片で 13 桁目にあった差が、特徴量を掛けて足すぶん 12 桁目に出ています。

数値をそろえる努力には、そろえられる限界があります。 乱数は仕様を写せば一致しますが、線形代数の解法は、同じ数学を別の手順で計算する以上、最後の桁までは一致しません。Haskell 版の第 7 章では、同じ実データを 4 通り(自作のガウス・ジョルダン法、<\> に計画行列、linearSolveLS、<\> に正規方程式)で解いて比べ、<\> に正規方程式を渡したものだけが 9 桁に落ちることを確かめています(XᵀX を作ると条件数が 2 乗になるという教科書どおりのことが、同じ関数に別の入れ物で渡しただけで出ました)。

2.6 まとめ

  1. データフレームか、言語の型か — データフレームを使ったのは Python と Kotlin だけで、ほかの 12 言語は言語の型(レコード・構造体・Map・素の配列)で列と欠損値を表した。第 3 波では Clojure・Elixir・Haskell の 3 言語が「列の順を別のリストで持ち回る」という同じ結論に達した(マップが挿入順を保たないか、鍵の順に並ぶため)
  2. 欠損値は型で防ぐか、テストで確かめるか — 「値が無い」の表し方は Option/Maybe・null 許容・nil・多値返却に分かれた。失敗と欠損を別の型で分けているのは Haskell・Rust・F# の 3 言語だけで、Go の多値返却だけは読み飛ばせてしまう。PHP の ?float は型だけでは弱く、PHPStan が Option の役割を肩代わりした
  3. 違うのは言語ではなく乱数生成器。そして乱数生成器は選べる — Elixir・PHP・Haskell の 3 言語が java.util.Random と同じ線形合同法を自作し、Java・Scala・Clojure と分割が完全に一致した。 9 言語で 7 通りだった結果は、14 言語でも 8 通りにしか増えていない。ライブラリが無いからではなく、ほかの言語版と数値を突き合わせるために自作したのがこの 3 言語の選択である
  4. アルゴリズムを写すとは、コードを貼ることではない — 整数が溢れたときの振る舞いが 4 通りある(折り返す・多倍長になる・パニック・float に化ける)ので、同じ漸化式が 3 通りの書き方になった。PHP だけが 4 つめで、乗算を上下 24 ビットに分ける必要があった
  5. 再現性はシードだけでは決まらない — 保証のされ方は 4 段階に分かれた。乱数生成器を自作した言語が第 3 波で 1 つから 4 つに増え、「自分のコードなので保証できる」側が最も厚くなった。Rust は「再現可能と考えるべきではない」と明言されている
  6. BOM と Shift_JIS は、どの層が面倒を見るかで決まる — BOM を取り除くライブラリは 3 言語(Rust・Ruby・Python の指定)だけ。Shift_JIS の取り違えの振る舞いは例外・置換文字・不正バイナリ・無言の文字化けの 4 種類で、同じ JVM でも Java と Clojure で違い、Haskell は 1 つの言語の中で名前で選べる
  7. 分割がそろっても、すべてが一致するわけではない — 和を積む順(マップの走査順)で 1 ulp、丸めの向きで 1、正規方程式の解き方で 12 桁目が分かれた。一致しなかった箇所はすべて、データ構造か数値計算の性質として理由が突き止められた

次の章では、同じアルゴリズムを 14 言語でどう書いたかを比べます。