第 4 章: ライブラリのエコシステムの比較¶
4.1 突き合わせの目的¶
各言語版の第 3〜14 章では、アルゴリズムを TDD で自作したあと、その言語の機械学習ライブラリに同じデータを渡して結果を突き合わせました。目的は 2 つです。
- 自作が正しいことの裏付け: 仕組みの違う実装どうしで結果が一致すれば、自作が妥当に動いていると言える
- ライブラリの振る舞いを知る: 既定値や定義の違いは、ドキュメントを読むだけでは見落とす。学習用テストで確かめてから使う
ライブラリの選定の理由は、各言語の ADR にあります(001 Python・002 Kotlin・003 TypeScript・004 F#・005 Java・006 C#・007 Scala・008 Go・009 Rust・010 Ruby・011 Clojure・012 Elixir・013 PHP・014 Haskell)。本章の結果も、各 ADR と各言語版の記事から取っています。
4.2 章ごとの突き合わせの結果¶
記号は次の意味です。
- 一致: 同じ入力で、予測・係数・指標などが一致した(浮動小数点の桁の範囲で)
- 近似: 一致させるために換算や条件の調整が要った、または一部が一致しなかった
- 比較: 仕組みや乱数が違うので一致は求めず、正解率や SSE を比べた
- 省略: 置き換え先が無い・使えないので、自作を最終実装にした
第 1 波
| 章 | Python(scikit-learn) | Kotlin(Tribuo) | TypeScript(ml.js 系) | F#(ML.NET・FSharp.Stats) |
|---|---|---|---|---|
| 3 決定木 | 一致(深さ 4 通りで 45 件すべて) | 近似(深さ 2 まで一致、3 以上で 1 件違う) | 近似(深さ 3 だけ 1 件違う) | 近似(単一の決定木が無く、1 本だけの FastTree で代用。深さ 2 で 45 件すべて一致) |
| 7 線形回帰 | 一致(評価指標) | 一致(SLMTrainer(true)・LARSTrainer で予測が小数第 13 位まで) |
一致(係数・予測が小数第 9 位まで) | 一致(FSharp.Stats の LinearRegression.fit、小数第 9 位まで) |
| 8 前処理パイプライン | scikit-learn の Pipeline そのものを実装に使った |
近似(深さ 1〜6 は一致、7〜10 で 1〜7 件違う。クラスの重みは自作) | 近似(深さ 1〜6 は 179 件一致、7〜10 で違う。クラスの重みは自作) | 近似(ML.NET の行の重みでクラスの重みを表せた) |
| 9 標準化 | 一致(ddof=0 にそろえる) |
近似(不偏標準偏差なので √((n−1)/n) を掛ける) | 省略(標準化のライブラリを採用せず自作を最終実装に) | 一致(fixZero = false にそろえる) |
| 10 ロジスティック回帰・ランダムフォレスト | ロジスティック回帰は一致(正則化なしにそろえる)、森は比較 | 比較(500 エポックで自作と同じ正解率) | 比較(ml-logistic-regression に切片が無い) | 比較 |
| 11 評価指標・交差検証 | 一致(同じ分割を渡すと交差検証の平均も一致) | 一致(LabelEvaluator・KFoldSplitter) |
一致(ml-confusion-matrix)。交差検証は分け方が違う | 一致(評価指標)。交差検証は分け方を ML.NET が決めるので比べていない |
| 12 リッジ・ラッソ回帰 | 一致(Ridge) |
近似(ElasticNetCDTrainer の罰則を換算して 1e-6 以内) |
近似(ラッソの lambda は標準化した尺度) |
近似(FSharp.Stats のリッジ回帰は使えず、ML.NET の SDCA を換算して小数第 3 位まで) |
| 13 主成分分析 | 一致(符号をそろえる) | 省略(Tribuo に PCA が無い。固有値分解は Tribuo) | 一致(ml-pca、符号をそろえる) | 一致(FSharp.Stats の PCA.compute) |
| 14 K-means | 一致(同じ初期中心) | 比較(初期中心を渡せないので SSE) | 一致(同じ初期中心で 100 通りすべて) | 一致(FSharp.Stats、100 通りすべて)。ML.NET とは SSE を比較 |
第 2 波
| 章 | Java(Tribuo) | C#(ML.NET) | Scala(Tribuo) | Go(gonum) | Rust(linfa) |
|---|---|---|---|---|---|
| 3 決定木 | 近似(Kotlin 版と同じ傾向) | 近似(1 本だけの FastTree で代用) | 近似(Java 版と同じ) | 省略(gonum に決定木が無い) | 近似(深さ 1・2 は一致、3 以上は既定の枝刈りで分かれる) |
| 7 線形回帰 | 一致 | 一致 | 一致 | 一致(単回帰は stat.LinearRegression。重回帰は突き合わせ先が無い) |
一致(linfa-linear と 1e-6 以内) |
| 8 前処理パイプライン | 近似(前処理は自作) | 近似(前処理は自作) | 近似(前処理は自作) | 省略(前処理も分類器も自作) | 近似(linfa 側が非決定的。範囲で確かめる) |
| 9 標準化 | 近似(不偏標準偏差を換算) | 近似 | 近似 | 近似(stat.StdDev は標本標準偏差) |
一致(linfa は母標準偏差で、自作と 1e-12 以内) |
| 10 ロジスティック回帰・ランダムフォレスト | 比較(エポック数を増やす) | 比較 | 比較 | 省略(どちらも gonum に無い) | 一致(alpha を 1e-8 に下げると 45 件すべて一致)。森は省略 |
| 11 評価指標・交差検証 | 一致 | 一致 | 一致 | 一致(stat.ROC と AUC のみ。混同行列と交差検証は自作) |
一致 |
| 12 リッジ・ラッソ回帰 | 近似(罰則を換算) | 近似 | 近似 | 省略(gonum に無い) | 近似(目的関数の流儀が違う) |
| 13 主成分分析 | 省略(Tribuo に PCA が無い) | 近似 | 省略(同上) | 一致(stat.PC、符号をそろえる) |
一致(linfa-reduction、符号をそろえる) |
| 14 K-means | 比較(初期中心を渡せない) | 比較 | 比較 | 省略(gonum に無い) | 比較(初期中心のシードは渡せるが k-means++ で選び方が違う) |
第 3 波
| 章 | Ruby(Rumale) | Clojure(Tribuo) | Elixir(Scholar) | PHP(Rubix ML) | Haskell(hmatrix・statistics) |
|---|---|---|---|---|---|
| 3 決定木 | 近似(深さ 1〜5 は予測が完全に一致、制限なしで 1 件分かれる) | 近似(Java 版・Scala 版と同じ。相違件数まで一致) | 省略(Scholar に決定木が無い) | 近似(既定のままでは比べられず、4 つの引数を明示して深さ 1〜5 は完全一致。制限なしで 0.9333 対 0.9111) | 省略(決定木のライブラリが無い) |
| 7 線形回帰 | 一致(tol: 1e-10 で 3.5e-5 以内。既定のままでは切片が 0.0044) |
一致(Java 版・Scala 版と 1 ビットも違わない) | 近似(pinv の SVD が最小二乗解に届かない。標準化して有効数字 4 桁) |
一致(Ridge(0.0)。切片が 12〜13 桁まで) |
一致(自作・<\>・linearSolveLS の 3 つが 12 桁) |
| 8 前処理パイプライン | 近似(欠損値の補完・ダミー変数化・クラスの重みは自作) | 近似(クラスの重みは自作) | 近似(グループ別の補完・基準列の削除が無い) | 近似(足りない口が 3 つ。クラスの重みは分類器にも前処理にも無い) | 省略(前処理のライブラリが無い) |
| 9 標準化 | 近似(n−1 の標本標準偏差なので √((n−1)/n) を掛ける) | 近似(Tribuo は不偏標準偏差) | 一致(Scholar は n で割る母標準偏差) | 一致(ZScaleStandardizer と 1e-12) |
一致(statistics の sqrt . variance と浮動小数点数として完全一致) |
| 10 ロジスティック回帰・ランダムフォレスト | 一致(reg_param: 0.0・tol: 1e-8 で予測が完全に一致)。森は比較 |
比較(500 エポックにすると一致) | 一致(alpha: 0.0 で完全一致)。森は省略 |
比較(LogisticRegression は 2 クラス専用。森は正解率が一致、重要度は一致しない) |
省略(どちらも無い) |
| 11 評価指標・交差検証 | 一致(1e-12 以内。KFold の 5 分割が行単位で自作と一致) |
一致(Java 版と完全に一致) | 一致(ただし分類の指標はf64 を渡しても f32 で返るので 1e-7 まで) | 近似(正解率・誤差は 1e-12 で一致。FBeta はマクロ平均で食い違う。ROC・AUC は無いので自作) |
省略。突き合わせの節が存在しないシリーズ唯一の章 |
| 12 リッジ・ラッソ回帰 | 近似(中心化して fit_bias: false。リッジ 1.38e-5、ラッソは完全に一致) |
近似(罰則を換算) | 近似(solver: :cholesky にすると 1 ビットも違わない)。ラッソは省略 |
一致(リッジは 4.4e-15〜1.6e-13)。ラッソは省略 | 一致(リッジは実データで最大差 8.0e-15)。ラッソは省略 |
| 13 主成分分析 | 近似(寄与率は Rumale に無いので自作。max_iter: 10_000・tol: 1e-12 で主成分の差 6.3e-5) |
省略(Tribuo に PCA が無い。固有値分解のみ) | 一致(寄与率まで持つ。寄与率 6.9e-17、主成分 1.1e-14) | 一致(寄与率 2.78e-16、主成分 1.97e-14。公開 API が狭く、復元の工夫が要った) | 一致(eigSH と自作の Jacobi 法で寄与率 5.55e-17、主成分 1.41e-14) |
| 14 K-means | 比較(SSE も n_init も無い。k=1 の SSE 2640.00 は一致) |
比較(初期中心を渡せない) | 比較(初期中心を渡せない。Scholar のほうが 30 倍以上遅い) | 比較(初期中心は渡せた。ただしミニバッチ K-means) | 省略(K-means のライブラリが無い) |
Python は、scikit-learn がほぼすべての章に突き合わせ先を持ち、多くの章で一致しました。ほかの言語は、「手法が無い」「初期中心を渡せない」「定義の尺度が違う」ために、換算や比較にとどめた章があります。それでも、第 7 章(線形回帰)は 14 言語すべてで一致し、第 11 章(評価指標)は突き合わせ先のある 13 言語すべてで一致しました。
置き換えの範囲は、言語によって大きく違います。 第 3 波でその幅が最も広がりました。
| 置き換えの範囲 | 言語 | 突き合わせられない主な章 |
|---|---|---|
| ほぼ全章 | Python・Rust・PHP | — |
| 多くの章 | Kotlin・Java・Scala・Clojure・C#・F#・TypeScript・Ruby | 13(Tribuo に PCA が無い)など |
| 狭い | Elixir | 3・8 の一部・10 の森・12 のラッソ・14(決定木も森もラッソも無い) |
| 線形代数だけ | Haskell | 3・8・10・11・14(機械学習のライブラリが無い) |
| 数値計算のみ | Go | 3・8・10・12・14(gonum は数値計算のライブラリで、機械学習のライブラリではない) |
第 3 波は、この軸で 2 つの版が正面から対照をなしました。 PHP の Rubix ML は決定木もランダムフォレストも持ち、第 3 章から第 14 章まで突き合わせられます。Elixir の Scholar は本書の背骨である決定木が丸ごと無く、第 3 章の時点で「自作がそのまま最終実装」になりました。Ruby(Rumale)と Clojure(Tribuo)はその中間です。
そして Haskell が最も極端でした。 機械学習のライブラリが無く(hlearn は保守が止まっています)、突き合わせられるのは線形代数の層(hmatrix)と統計(statistics)だけです。その結果、第 11 章は「突き合わせの節が存在しない」シリーズ唯一の章になりました。代わりに「値の分かっている入力」(AUC の 1.0 と 0.5、t=2a の MSE 0)・「満たすべき性質」(全行をちょうど 1 度テストする、訓練とテストが重ならない)・「ほかの言語版の数値」の 3 つで正しさを支えています。
4.3 「ライブラリにある」と「そのまま比べられる」は別¶
第 3 波で新しく出てきた論点です。ライブラリに手法があっても、自作と同じ条件に設定できるとは限りません。
PHP の Rubix\ML\Classifiers\ClassificationTree は決定木を持っています。しかし既定のままでは、次の理由で自作と比べられませんでした。
CART::split()が使う列をarray_randで毎回選ぶ(maxFeaturesの既定はround(sqrt(n)))- 連続値の候補を分位点で丸める(
maxBinsの既定は1 + round(log2(m))) - 葉の大きさと不純度の下限に既定値がある
maxLeafSize: 1・minPurityIncrease: 0.0・maxFeatures: 列数・maxBins: 大きい値 の 4 つを明示して初めて、深さ 1〜5 で完全に一致しました。Elixir 版がこの章で「突き合わせる相手がいない」という仕事をしたところで、PHP 版には「相手はいるが、そろえる」という別の仕事が現れたということです。
同じことが Ruby でも起きました。Rumale::LinearModel::LinearRegression は、Numo::Linalg が読み込まれていないと L-BFGS(tol の既定は 1e-4)で解き、solver: "svd" を指定しても黙って L-BFGS に落ちます。既定のままでは切片が 0.0044(自作は 6035.99)、テストの決定係数が −1.246 になり、例外も警告も出ません。tol: 1e-10 にして初めて、自作との差が 3.5e-5 以内になります。
そろえきれない場合もあります。PHP 版で、深さを制限しないときだけ 0.9333 対 0.9111 と食い違った原因は境界の取り方でした——自作は隣り合う値の中点、Rubix ML はデータに実在する値を使います。これは引数で変えられません。
4.4 学習用テストで見つかったライブラリの癖¶
突き合わせの前に書いた学習用テストは、ライブラリの思わぬ振る舞いを数多く見つけました。種類ごとに整理します。
既定値が「何もしない」ではない¶
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Python | scikit-learn の LogisticRegression |
既定で L2 正則化(C=1.0)を掛ける。自作と比べるには C=np.inf にする |
| Kotlin・Java・Scala・Clojure | Tribuo の LogisticRegressionTrainer |
既定(AdaGrad・5 エポック)では学習が足りない。500 エポックで自作と同じ正解率 |
| Kotlin | Tribuo の RandomForestTrainer |
minChildWeight の既定値 5 では訓練データを分け切らない |
| TypeScript | ml-cart | README に無い gainThreshold: 0.01 が既定に含まれ、利得の小さい分割をしない |
| TypeScript | ml-regression-lasso | 既定の反復回数では収束しないまま、例外にならずに係数を返す |
| F# | ML.NET の FastTree | 葉ごとの最小件数の既定値が 10 で、小さなテストデータでは分けられない |
| F# | ML.NET の NormalizeMeanVariance |
既定(fixZero)では平均を引かない |
| Rust | linfa-trees の DecisionTree |
既定で min_impurity_decrease: 1e-5 の事前枝刈りが効く。0 を渡すと ParamGuard が Err を返すので、枝刈りを完全に切れない |
| Rust | linfa-logistic の MultiLogisticRegression |
既定で L2 の強さ alpha が 1.0。1e-8 に下げると自作(正則化なし)と 45 件すべて一致した |
| Ruby | Rumale の LinearRegression・Ridge・PCA |
既定の解き方が黙って落ちる。 solver: "svd" を渡しても L-BFGS で解き、PCA の solver: "evd" は警告 1 行で固定小数点法に落ちる |
| Ruby | Rumale の LogisticRegression |
既定は reg_param: 1.0・tol: 1e-4 で、切片まで正則化する |
| Elixir | Scholar の LogisticRegression |
既定の alpha: 1.0(L2)が 105 件のデータに強すぎる(訓練 0.6571)。alpha: 0.0 で自作と完全一致 |
| Elixir | Nx | Nx.tensor([1.0]) の既定の型が f32。 type: :f64 を明示しないと重回帰の係数が 0.9999998807907104 になる |
| PHP | Rubix ML の Ridge |
l2Penalty の既定が 1.0。 省くと最小二乗ではなく別のモデルになる |
| PHP | Rubix ML の LogisticRegression |
既定の学習率 0.01 では訓練 0.6762(自作 0.9143)。1.0 にすると 0.9333。正則化(l2Penalty の既定 1e-4)はここでは効いていない——Scholar の alpha: 1.0 より 4 桁小さい |
Elixir の f32 は、この表の中でいちばん見つけにくい種類でした。 丸めた表示では気づけません(切片が f64 6114.595505694404、f32 6114.59716796875 で、どちらも 6114.60 と表示されます)。
定義や尺度が違う¶
標準偏差を「件数で割るか、件数 − 1 で割るか」は、シリーズ全体でいちばん多く踏んだ落とし穴です。縦持ちで整理します。
| 割り方 | ライブラリ |
|---|---|
| n(母標準偏差) | scikit-learn の StandardScaler、linfa-preprocessing の LinearScaler::standard()、Scholar の StandardScaler、Rubix ML の ZScaleStandardizer、statistics の variance |
| n−1(標本標準偏差) | pandas の std()、Tribuo の MeanStdDevTransformation(Kotlin・Java・Scala・Clojure)、ml-matrix の standardDeviation、gonum の stat.StdDev、Rumale の StandardScaler、statistics の stdDev |
- Haskell の statistics は、1 つのライブラリの中で両方を持っています。
varianceは n で割る最尤推定、stdDevは n−1 で割る標本標準偏差の平方根です。名前から定義を推測すると間違えます。 Haskell 版はsqrt . varianceが自作と浮動小数点数として完全に一致することと、stdDevとは一致しないことを、両方テストに残しました - Ruby の Rumale だけが、第 3 波で n−1 側でした。 Scholar・Rubix ML は n 側なので、同じ第 3 波の中で分かれています。ライブラリの癖は言語の性質ではないことが、ここでも出ています
罰則と指標の定義も、ライブラリごとに違いました。
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Kotlin | Tribuo の ElasticNetCDTrainer |
罰則は alpha × l1Ratio × 件数。l1Ratio=0 は拒否される(Clojure 版は 1e-12 を渡した) |
| TypeScript | ml-regression-lasso | 特徴量と正解の両方を標準化した尺度の罰則 |
| F# | ML.NET の SDCA | L2 の罰則は、自作の alpha の 2 / 件数 倍 |
| F# | FSharp.Stats の PCA.compute |
VarianceOfComponent は、名前に反して分散ではなく標準偏差を返す |
| Rust | linfa-elasticnet | ラッソの目的関数が「平均で割らない」流儀なので、alpha の値をほかの言語版と比べられない |
| Ruby | Rumale の Ridge・Lasso |
リッジは「二乗誤差 ÷ n + reg_param・(‖w‖² + b²)」、ラッソは「½‖誤差‖² + reg_param・(‖w‖₁ + |b|)」。どちらも切片を正則化するので、自分で中心化して fit_bias: false を渡すと一致した |
| PHP | Rubix ML の FBeta |
2 値でもマクロ平均(クラスごとの適合率・再現率を平均してから調和平均)なので、自作 0.8 対 Rubix 0.7895。どちらも正しい F 値で、定義が違う |
| PHP | Rubix ML の Metric |
「大きいほどよい」に統一されているので、誤差指標は負値を返す |
| PHP | Rubix ML の PrincipalComponentAnalysis |
共分散を n で割る(自作は n−1)。寄与率は定数倍で約分されるので一致する |
並び順・符号・番号が違う¶
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Python・TypeScript・F# | 主成分分析 | 固有ベクトルの符号に決まりが無い。向きをそろえてから比べた |
| Kotlin・Clojure | Tribuo の eigenDecomposition() |
対称でない行列では空の Optional を返す。固有値は大きい順 |
| TypeScript | ml-matrix の EVD |
固有値を小さい順に並べる |
| TypeScript | ml-regression-multivariate-linear | 切片が係数の最後に並ぶ |
| F# | FSharp.Stats・ML.NET の K-means | クラスタ番号が 1 から始まる |
| Go | gonum の mat.EigenSym |
固有値を小さい順に返し、固有ベクトルは列に並ぶ |
| Rust | linfa-trees | 学習は <= split_value、予測は < split_value で、境界ちょうどの値の扱いが学習と予測で逆になる |
| Elixir | Scholar の confusion_matrix/3 |
[tn, fp, fn, tp] の順 |
| Elixir | Nx.LinAlg.eigh |
対称でない行列を黙って受ける(上三角だけを使い、例外も警告も出ない)。固有値は降順、固有ベクトルは列 |
| PHP | Rubix ML の Reports\ConfusionMatrix |
行が予測・列が正解(教科書や Scholar とは逆向き) |
| Ruby | Rumale の confusion_matrix |
行が正解・列が予測で、ラベルの昇順に並ぶ。Precision・Recall・FScore は既定(average: "binary")で y_true の最大のラベルを陽性とみなす |
| Haskell | hmatrix の trustSym |
対称かを確かめず上三角だけを見る。Elixir 版の Nx.LinAlg.eigh と同じ落とし穴(Haskell 版は m /= transpose m で自分から確かめた) |
同数・境界・余りの扱いが違う¶
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Kotlin | Tribuo の CART | 多数決が同数の葉のラベルの選び方と、同じ不純度の分割候補の選び方が自作と違う |
| TypeScript | ml-cart | 境界ちょうどの値を右に進める。多数決が同数なら訓練データ全体で先に現れたラベルを選ぶ |
| TypeScript | ml-cross-validation | 余りの行が分割数以上だと、行を捨てる |
| TypeScript | ml-confusion-matrix | 正例を一度も予測しないと、適合率が NaN になる |
| Rust | linfa-trees の find_modal_class |
HashMap を走査するので、葉のラベルが同数のときの選び方が順に依存しうる |
| Ruby | Rumale の決定木 | 節ごとに特徴量を Array#sample でランダムな順に並べるので、同じ不純度の分割が並ぶと自作と違う分割を選ぶ |
| PHP | Rubix ML の ClassificationTree |
分割の境界がデータに実在する値(自作は隣り合う値の中点)。深さを制限しないときだけ食い違った |
| PHP | Rubix ML の KMostFrequent |
arsort なので、同点なら先に現れたほうが残る |
「K 分割の余りをどうするか」は、3 通りに分かれました。
| 余りの扱い | ライブラリ |
|---|---|
| 先頭の分割に配る | 自作(全 14 言語)、Tribuo、Rumale の KFold |
| 捨てる | Scholar の ModelSelection.k_fold_split/2、Rubix ML の Labeled::fold() |
| 分割数以上なら捨てる | ml-cross-validation(TypeScript) |
Scholar と Rubix ML は、891 件を 5 分割すると 178 件 × 5 になり、1 件がどの分割でもテストされません。別々に書かれた 2 つのライブラリが、まったく同じ振る舞いをしていました。自作は 179, 178, 178, 178, 178 で全行をちょうど 1 度テストします。
Ruby は逆に、行の分け方まで一致しました。 Rumale の KFold の shuffle: true は、第 2 章の shuffle と同じ Random.new(seed) と Array#shuffle を使うので、891 件の 5 分割が行単位で自作と一致します。
決定的でない¶
第 3 波で、この節がいちばん大きくなりました。 「同じ入力に同じ出力」が成り立たないライブラリは、linfa-trees だけではありませんでした。
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Rust | linfa-trees | 同じデータ・同じ深さでも、実行ごとにテストの正解率が 0.788〜0.810 で揺れる(第 8 章の Survived.csv。ダミー変数で同じ不純度の分割候補が並ぶため。iris では揺れない) |
| PHP | Rubix ML の ClassificationTree |
array_rand で列を選び、シードを渡す口が無い。 既定のまま 5 回走らせて 0.8889, 0.8889, 0.8889, 0.8889, 0.8222 と揺れた |
| PHP | Rubix ML の RandomForest・SoftmaxClassifier |
モデルがシードを受け取らず、大域の mt_rand から引く。 種を付けないと実行のたびに正解率が変わる(softmax で 0.4889〜0.7556) |
| PHP | Rubix ML の KFold::test() |
内部で randomize() を呼ぶがシードを渡す口が無い。5 回で 0.7718〜0.7819 と揺れた |
| Ruby | Rumale のモデル全般 | random_seed を省くと既定値が srand になり、モデルを作るだけでプロセス全体の乱数の種が入れ替わる。 KFold は shuffle: false でもシードを渡す必要がある |
| Elixir | Scholar の KMeans |
:key を渡さないと System.system_time() で鍵を作るので、結果が毎回変わる |
PHP 版では、同じ問題に 2 つの章が別の手で対処しました。 第 3 章は引数を明示してくじ引き自体を起こさない(maxFeatures を列数にすれば array_rand の結果に依存しない)、第 10 章は大域の mt_rand に種を付ける。シードを渡す口が無い以上、外から固定するか、乱数が効かない設定にするかの二択になります。
第 2 波で「自作のほうが再現性が高いという逆転」が linfa で起きたことを書きました。第 3 波では、それが例外ではないと分かりました。 ライブラリは「自分で書くより正しい」とは限らず、「自分で書くより再現する」とも限りません。
使えない・手法が無い¶
| 言語 | ライブラリ | 見つかったこと |
|---|---|---|
| Kotlin・Java・Scala・Clojure | Tribuo | PCA のモジュールが無い。K-means は初期中心を渡せない |
| F# | ML.NET | 単一の決定木(CART)の学習器が無い。K-means は初期中心を渡せない |
| F# | FSharp.Stats 0.6.0 | リッジ回帰が、どの大きさの罰則の行列でも次元が合わない例外を投げる |
| TypeScript | ml.js 系 | 回帰の評価指標が無い。featureImportance() は型定義に無い |
| Go | gonum | 決定木・ランダムフォレスト・K-means・ロジスティック回帰・正則化付き回帰が無い |
| Go | gonum の stat.ROC |
入力が昇順でないと error ではなくパニックする。AUC は返さない |
| Rust | linfa・ndarray | ランダムフォレスト・欠損値の補完・ダミー変数化が無い。連立方程式を解く関数が無い |
| Ruby | Rumale・Numo | PCA の寄与率が無い。欠損値の補完・基準列を落とすダミー変数化・クラスの重みが無い。Numo に連立方程式を解く関数が無い |
| Elixir | Scholar | 決定木・ランダムフォレスト・ラッソが無い。 SimpleImputer はグループ別の統計量を持てず、OneHotEncoder は最初のカテゴリを落とせない |
| PHP | Rubix ML | 素の線形回帰・ラッソ・ROC 曲線・AUC が無い。 LogisticRegression は 2 クラス専用(多クラスは SoftmaxClassifier)。PrincipalComponentAnalysis が公開しているのは lossiness() と transform() だけで、固有ベクトルも平均も寄与率も取れない |
| PHP | MathPHP | 実データで落ちた。 Eigenvalue::jacobiMethod() が返したばかりの固有値を同じ行列とともに Eigenvector::eigenvectors() に渡すと BadDataException(掃き出し法で零空間を探す実装が 15×15 の浮動小数点に耐えない) |
| Haskell | — | 機械学習のライブラリが無い(hlearn は保守が止まっている)。決定木・ランダムフォレスト・ロジスティック回帰・K-means・評価指標・前処理はすべて自作 |
「無いものをどう埋めたか」も、版によって違いました。
- PHP は、素の線形回帰が無いところを
Ridge(0.0)の縮退で代用しました。正則化を 0 にしたリッジが最小二乗にあたることを、自作と突き合わせて確かめています - PHP の第 13 章は、公開されていない値を外から復元しました。主成分は
transform()が線形変換であることを使って「平均+単位ベクトル」を通し、寄与率は次元数を 1 ずつ増やして1 - lossiness()の差分を取ります。リフレクションを使わず、内部の名前にも依存しません - Haskell の第 13 章は、hmatrix の
eigSH(DSYEV)が 15×15 の実データに耐えました。PHP 版で MathPHP の固有ベクトルが落ちた場所を、そのまま通り抜けています
4.5 C ライブラリへの依存——確認すべきことが 3 段ある¶
第 3 波の Haskell で初めて出てきた論点です。 ネイティブのライブラリに依存すると、「入るかどうか」だけでは足りません。
hmatrix は BLAS/LAPACK を要求します。確認は 3 段ありました。
- ビルドが通るか。 素の Nix 環境では
configureがMissing (or bad) C libraries: blas, lapackで止まります。ここを通っただけで採用を決めたのが、最初の誤りでした - 呼び出しが通るか。 nixpkgs の
openblasは既定がblas64 = true(ILP64。整数引数が 64 ビット)で、32 ビット整数で呼ぶ hmatrix と ABI が噛み合いません。 ビルドは成功するのに、2×2 の連立方程式すら解けませんでした(linearSolveがNothingを返し、<\>はcode -7、pinvはcode -2)。blasとlapack(isILP64 = false)に差し替えて解決しています。2×2 のDGESVで「行列の次数が不正」と言われたら、数値の問題ではなく ABI の不一致を疑う、という診断がここで得られました - 環境を変えたあと作り直されるか。 環境定義を直しても、cabal の store に残った成果物は作り直されません。
LIBRARY_PATHは環境ごとに変わるのに、cabal はそれをパッケージのハッシュに含めないので、ILP64 の BLAS にリンク済みのものが使われ続けます。store の該当エントリを退避してから作り直す必要がありました
Elixir 版が EXLA(巨大な XLA のバイナリ)を避けたのとは、判断が逆になっています。BLAS/LAPACK は数値計算の標準的な土台で Nix でも軽く入るため、依存を受け入れました。その代わりに、「ライブラリがビルドできた」は「使える」ではないという確認の段が 2 つ増えています。
似た形は第 3 波でもう 2 回起きました。PHP の素の Nix 環境にはカバレッジドライバ(xdebug も pcov も)が無く、php.withExtensions で pcov を足す必要がありました。Clojure の環境には clj-kondo も cljfmt も入っておらず、Haskell の環境には fourmolu も hlint もありませんでした。道具立てが環境に足りないことは、第 3 波では例外ではなく既定でした。
4.6 同じ計算を違う入れ物で渡すと、精度が変わる¶
第 7 章の線形回帰で、3 つの版が「SVD で解いた」と言いながら 3 通りの結果になりました。
| 版 | ライブラリ | 解き方 | 結果 |
|---|---|---|---|
| Elixir | Scholar の LinearRegression |
Nx.LinAlg.pinv(SVD) |
最小二乗解に届かない。 特異値 [12988, 2648, 1589, 4.28] のデータで、残差平方和が自作 11083165.68 < Scholar 14073810.01。列を標準化してから渡すと有効数字 4 桁までそろう |
| PHP | Rubix ML の Ridge |
正規方程式を inverse() で解く(自作は LU 分解) |
12〜13 桁まで一致した(切片 6114.5955056944 対 6114.5955056945) |
| Haskell | hmatrix の <\>(DGELSS) |
計画行列を渡すと SVD、正規方程式を渡しても同じ関数 | 計画行列なら 12 桁、正規方程式なら 9 桁(6114.5955055106010) |
Haskell 版は、この対照を 1 つのライブラリの中で見せました。 同じ実データを 4 通り(自作のガウス・ジョルダン法/<\> に計画行列/linearSolveLS に計画行列/<\> に正規方程式)で解くと、最初の 3 つは 12 桁まで一致し、最後だけが 9 桁に落ちます。XᵀX を作ると条件数が 2 乗になるという教科書どおりのことが、同じ関数に別の入れ物で渡しただけで出ました。
面白いのは、自作も正規方程式を解いているのに 12 桁一致していることです。違いは部分ピボット選択つきの消去法と DGELSS(特異値の打ち切りあり)で、残差平方和は 4 つとも下 2 桁しか違いません。どれも最小二乗解の底にはいて、底が平らなので係数のほうが先にぶれる、というのが答えでした。
Elixir 版が「SVD のほうが数値的に安定である」という一般論に裏切られ、PHP 版が「正規方程式は条件数が悪くなる」という一般論に裏切られています。アルゴリズムの理論的な安定性より、その実装の精度のほうが効く場合があるということです。
4.7 ライブラリが無いことが、安全な設計を強制した例¶
第 3 波では、モデルの保存・復元でも版ごとに事情が分かれました。
| 版 | 保存の方法 | 用心 |
|---|---|---|
| Ruby | Marshal |
RuboCop の Security/MarshalLoad を理由つきで 1 行だけ無効にした |
| Clojure | EDN(pr-str/read-string) |
学習済みパイプラインがマップ・ベクタ・キーワードだけなので、値として = で比べられる |
| Elixir | :erlang.term_to_binary |
復元には :safe を渡す |
| PHP | serialize |
unserialize() に allowed_classes は必須(既定でどんなクラスでも作る)。しかも unserialize() も mkdir() も失敗を例外ではなく警告で知らせる |
| Haskell | Data.Binary |
用心が要らなかった |
Haskell で用心が要らなかったのは、ライブラリが足りなかったからです。 binary に Text の Binary インスタンスが無く、孤児インスタンスを避けて保存の形式を手書きすることになりました。その結果、「壊れたファイルから任意のものが作られない」という性質が設計に組み込まれました。PHP の allowed_classes・Elixir の :safe にあたる用心が、そもそも要らなくなっています。
(ただし別の注意は増えました。decodeOrFail は「途中まで読めたら成功」なので、余りが空かまで見る必要があります。)
往復の精度も分かれました。 Clojure の EDN と Elixir の :erlang.term_to_binary は Double を往復しても桁が落ちず、PHP の serialize も serialize_precision の既定 -1 が最短往復表記を選ぶので、実データでも 1 ビット一致します。
4.8 エコシステムの成熟度と、自作の意味¶
14 言語を比べると、ライブラリが成熟しているほど「一致」が増え、成熟していないほど「近似」「比較」「省略」が増えました。
| 言語 | 一致した章 | 近似・比較・省略の主な理由 |
|---|---|---|
| Python | 第 3・7・9・11・12・13・14 章(第 10 章のロジスティック回帰も) | 乱数の使い方が違う(ランダムフォレスト) |
| Kotlin・Java・Scala・Clojure | 第 7・11 章 | 同数・境界の扱い、不偏標準偏差、罰則の尺度、手法が無い(PCA)、初期中心を渡せない(いずれも Tribuo の性質) |
| TypeScript | 第 7・11・13・14 章 | 同数・境界の扱い、罰則の尺度、切片が無い |
| C#・F# | 第 7・9・11・13・14 章(F#) | 単一の決定木が無い、リッジ回帰が使えない、初期中心を渡せない(ML.NET の性質) |
| Go | 第 7・11・13 章(いずれも一部) | 手法そのものが無い章が 5 つ |
| Rust | 第 7・9・10・11・13 章 | 既定の枝刈りと正則化、目的関数の流儀、k-means++、ライブラリ側が非決定的 |
| Ruby | 第 7・10・11 章(ラッソも) | 既定の解き方、n−1 の標準偏差、切片も正則化する、PCA の寄与率が無い、K-means に SSE が無い |
| Elixir | 第 9・10・11・13 章 | 決定木・森・ラッソが無い、SVD が最小二乗解に届かない、指標が f32 に落ちる |
| PHP | 第 7・9・12・13 章 | 既定値をそろえる手間、F 値の定義、ROC が無い、シードを渡す口が無い |
| Haskell | 第 7・9・12・13 章 | 機械学習のライブラリが無く、線形代数と統計だけが相手 |
同じライブラリを使う言語は、同じ結果になりました。 Java・Scala・Kotlin・Clojure(Tribuo)と C#・F#(ML.NET)は、突き合わせの結果も見つかった癖もほぼ同じです。第 3 波で Clojure が同じ Tribuo 4.3.2 を同じ JDK 21 で呼んだ結果、自作と CART の相違件数(深さ 1〜10 で 0,0,0,0,2,0,0,0,1,0)まで Java 版・Scala 版と一致しました。ライブラリの癖は言語の性質ではなく、ライブラリの性質だということが、4 言語で確かめられたことになります。
ただし、「一致しない」ことは失敗ではありません。一致しなかった原因を学習用テストで突き止めたことで、ライブラリの定義(同数の扱い・罰則の尺度・F 値の平均の取り方)を正確に理解できました。ライブラリが無い部分は、自作が最終実装になります。
そして、ライブラリが無い版でも正しさは支えられました。 Haskell 版の第 11 章は、突き合わせる相手が 1 つも無い状態で、「値の分かっている入力」「満たすべき性質」「ほかの言語版の数値」の 3 つで正しさを示しています。ほかの言語版の数値が突き合わせの相手になるのは、14 言語で同じ題材を書いたシリーズならではの支え方です。第 3 波の 3 言語(Elixir・PHP・Haskell)が乱数生成器を自作して JVM の言語版と並びをそろえたのは、まさにこのためでした。
4.9 まとめ¶
- 章ごとの突き合わせ — 第 7 章(線形回帰)は 14 言語すべてで、第 11 章(評価指標)は相手のいる 13 言語すべてで一致した。ほかの章は、ライブラリの成熟度によって一致・近似・比較・省略に分かれた
- 置き換えの範囲は言語で大きく違う — PHP(Rubix ML)はほぼ全章、Elixir(Scholar)は決定木が無いので狭く、Haskell は機械学習のライブラリが無く線形代数だけ。第 11 章は「突き合わせの節が存在しない」シリーズ唯一の章になった
- 「ある」と「そのまま比べられる」は別 — Rubix ML の決定木は 4 つの引数を明示して初めて比べられ、Rumale の線形回帰は
tolを下げるまで切片が 0.0044 だった。相手がいることは、そろえる仕事が要ることでもある - 既定値を疑う — 正則化・エポック数・学習率・葉の最小件数・
fixZero・min_impurity_decrease・Nx の f32・Ridgeのl2Penaltyなど、「何もしない」と思った既定値が結果を変えた - 定義をそろえる — 標準偏差の割り方は 11 のライブラリで割れた。statistics のように 1 つのライブラリが両方を持つこともあり、名前から推測すると間違える。K 分割の余りは「配る/捨てる」で分かれ、Scholar と Rubix ML が同じ「捨てる」だった
- 決定的とは限らない — linfa-trees・Rubix ML の 3 つのクラス・Rumale のモデル・Scholar の KMeans が、種を渡さないと揺れた。シードを渡す口が無い場合は、くじ引きを起こさない設定にするか、大域に種を付けるしかない
- C ライブラリへの依存は 3 段で確認する — ビルドが通るか、呼び出しが通るか(ABI の整数幅)、環境を変えたあと作り直されるか。ビルドが通っただけでは使えない
- 同じ計算でも入れ物で精度が変わる — 「SVD で解いた」が 3 つの版で 3 通りになった。アルゴリズムの理論的な安定性より、その実装の精度のほうが効く場合がある
- 癖はライブラリの性質 — 同じ Tribuo を使う 4 言語(Java・Kotlin・Scala・Clojure)は、相違件数まで同じだった
- 一致しないことも学び — 原因を学習用テストで突き止め、ライブラリの定義を理解した。手法が無い部分は自作を最終実装にし、ほかの言語版の数値を突き合わせの相手にした
次の章では、目的に応じた読み方を案内します。