Skip to content

009 Rust 版のビルド・テスト・静的解析・機械学習・API ライブラリの選定

「機械学習から始めるプログラミング入門」Rust 版で使うライブラリを決める。

日付: 2026-09-20

ステータス

2026-09-20 提案されました

2026-09-21 承認されました(第 1〜15 章の執筆で確かめました)

コンテキスト

執筆計画 の「対象言語」では、Rust 版の機械学習ライブラリの候補を linfa・ndarray・polars としていた。第 2 波の計画では「linfa の対応範囲の確認に最も時間がかかる見込み」として Rust を最後に置き、Rust 版は Go 版・TypeScript 版と同じ「ライブラリが限られる環境」になると見込んでいた。

B44 のステップ 1 で、使い捨ての Cargo プロジェクトによって次を確かめた。見込みは外れ、linfa は決定木からリッジ/ラッソ・交差検証まで揃っていた。

確認したこと 結果 確認方法
linfa の版とライセンス 0.8.1、MIT OR Apache-2.0。trees・linear・logistic・clustering・reduction・preprocessing・elasticnet が同じ 0.8.1 でそろう crates.io、Cargo.toml の license
linfa にあるもの 決定木(DecisionTree、feature_importance() つき)・重回帰(LinearRegression)・ロジスティック回帰・K-means・主成分分析(Pca、explained_variance_ratio() つき)・リッジとラッソ(ElasticNet::ridge()/lasso())・混同行列(confusion_matrix)と適合率/再現率/F 値・ROC と AUC(roc()・area_under_curve())・交差検証(cross_validate_single) 使い捨てのプロジェクトで全部実行
linfa に無いもの ランダムフォレスト(linfa-trees は決定木だけ)、欠損値の補完、ダミー変数化(linfa-preprocessing にあるのは線形スケーリング・正規化・白色化・TF-IDF) クレートのソースの src/ と lib.rs
linfa の標準化 LinearScaler::standard() は母標準偏差(n で割る)。scikit-learn の StandardScaler と同じで、gonum の stat.StdDev(標本標準偏差)とは違う 1〜4 の 4 件を標準化して実測
行列 ndarray。linfa 0.8.1 が使うのは ndarray 0.16 で、最新は 0.17 cargo build の型エラー
BLAS 不要。linfa-linalg(純 Rust)で cargo build が通る 同上
乱数 linfa は rand 0.8 系(linfa が rand 0.8、linfa-clustering が rand_xoshiro 0.6)。rand 0.9・0.10 の RNG を KMeans::params_with_rng に渡すと「two types coming from two different versions of the same crate are different types」で落ちる 同上
乱数の並び StdRng::seed_from_u64(0) と Fisher-Yates で [9, 3, 6, 4, 8, 1, 5, 2, 0, 7]。Java([4 8 9 6 3 5 2 1 7 0])とも Go([6 8 2 3 7 5 9 1 0 4])とも違う 使い捨てのプロジェクトで実行
CSV csv 1.4。BOM を自動で取り除く("\uFEFF身長" を読むと "身長" になる) ヘッダーのバイト列を表示
Shift_JIS encoding_rs 0.8 の SHIFT_JIS.decode で読める。誤って UTF-8 として読むと String::from_utf8 が Err になる 同上
API axum 0.8.9 と tokio 1.53.1 で Router を作り、ポートを開けることを確認 同上
直列化 serde 1.0・serde_json 1.0。#[derive(Serialize, Deserialize)] で構造体をそのまま JSON にできる 同上
整形・静的解析 cargo fmt --check は差分をファイル名と行番号つきで出す。clippy の既定で「unused variable」「the loop variable i is only used to index」「binary comparison to literal Option::None」を検出し、-D warnings で警告がエラーになる わざと崩したファイル
カバレッジ flake.lock の nixpkgs に cargo-llvm-cov 0.6.20 と cargo-tarpaulin 0.35.0 がある nix eval
Nix 環境 rustc 1.91.1、cargo 1.91.0、rustfmt 1.8.0、clippy 0.1.91、rust-analyzer。edition 2024 nix develop .#rust

各章で確かめた結果

章 確かめたこと
1 csv クレートが BOM を自動で取り除くので、ほかの言語版で書いてきた BOM の除去が要らない。#[cfg(test)] mod tests で実装と同じファイルにテストを置ける。関数名に日本語を使えるが、識別子に空白は入れられない(fn linfa の… はコンパイルエラー)。標準のテストにスキップが無いので、実データのテストは早期に戻る形にし、走ったかはカバレッジの差(データあり 70.09%、なし 51.96%)で見る。Cargo のテストはパッケージのルートで走るので相対パスの ../data/sukkiri-ml が届く
2 欠損値は Result<Option<f64>> で表せる(Go 版の「値と ok とエラー」の 3 値より素直)。f64 を持つ型には Eq が付かないので PartialEq だけを derive する。rand は linfa に合わせて 0.8 系に固定する。StdRng::seed_from_u64(0) の Fisher-Yates は [9, 3, 6, 4, 8, 1, 5, 2, 0, 7] で、Java 版・Go 版と分かれる行も訓練データの平均値も一致しない(件数 105 件・45 件は一致する)
3 決定木は enum Tree { Leaf, Node } と Box で表せ、match の網羅性をコンパイラが検査する(Go 版の既定の分岐が要らない)。max_by_key は同点のとき最後の要素を返すので、「同数なら先に現れたほう」にするには厳密な不等号で書く(実際に 2 件のテストが落ちて気づいた)。f64 は Ord ではないので sort_by に partial_cmp を使う。linfa にラベルを渡すには整数への符号化が要る
4〜6 rand 0.8.8 の StdRng は「再現可能と考えるべきではない」とドキュメントが明言している(実体は ChaCha12。再現が要るなら rand_chacha を直接使えとある)。Rust 版の数値の再現性は Cargo.lock をコミットしていることに依存する。rustfmt は CRLF を指摘しない(newline_style = Auto。Go 版の gofmt は指摘する)ので .gitattributes に apps/rust/** text=auto eol=lf を足した。カバレッジのリージョン数は rustc の版で変わる(1.91.1 で 1758、1.97.1 で 1714)ので、CI と手元の数字を比べない。テストは 47 件で、データなしでも全部 ok と出る(スキップの表示が無い)ため、--nocapture の eprintln! かカバレッジの差(データあり 85.67%、なし 73.66%)で判別する。clippy の実測の指摘は needless_bool・ptr_arg・needless_range_loop・partialeq_to_none など
7 ndarray には連立方程式を解く関数が無い(ndarray-linalg は LAPACK を要求する)ので、正規方程式は掃き出し法を自作した。linfa-linear の LinearRegression::default() は素の最小二乗で、自作の切片・係数と実データでも 1e-6 以内で一致した(Tribuo のようにトレーナーを選ぶ手間が無い)
8 linfa-trees は非決定的なことがある。 Survived.csv では同じデータ・同じ深さでも実行ごとにテストの正解率が 0.788〜0.810 で揺れる(ダミー変数で同じ不純度の分割候補が並ぶため。iris では揺れない)。出力を固定するテストの対象にできないので、run の出力から linfa の行を外し、範囲で確かめるテストにした。自作のほうが再現性が高いという逆転が起きている。 serde は trait object を保存できない(Deserialize が実装されていないというコンパイルエラーになる)ので、学習済みの前処理は enum で表す
9 linfa-preprocessing の LinearScaler::standard() は母標準偏差(n で割る)で確定した。 架空の値でも実データ(Boston の RM 列 30 件)でも自作と 1e-12 以内で一致する。scikit-learn と同じで、Tribuo・gonum(標本標準偏差)とは違う。encoding_rs の decode は 3 つ組(Cow<str>・使った符号化・had_errors)を返し、Shift_JIS を UTF-8 として読むと had_errors == true、String::from_utf8 は Err になる。linfa に 1 列だけ渡す API は無いので、1 列の Array2<f64> と Array1<()> で DatasetBase::from を使う
10 linfa-logistic の MultiLogisticRegression は既定で L2 の強さ alpha が 1.0。 既定のままだと自作(正則化なし)と予測が 4 件ずれる。alpha を 1e-8 にするとテストデータ 45 件の予測が完全に一致する。繰り返しは既定の 100 回(L-BFGS)で収束しており、Tribuo の既定 5 エポックのような不足は起きない。String のラベルをそのまま渡せる(第 3 章の決定木のような番号付けが要らない)。学習済みモデルのメソッド名は classes()(labels() ではない)。ランダムフォレストは linfa 0.8.1 に無いので自作が最終実装
11 linfa の confusion_matrix・roc・area_under_curve・cross_validate_single を自作と突き合わせられる。ROC には linfa::dataset::Pr 型の確率と &[bool] の正解を渡す
12 linfa-elasticnet の ElasticNet::ridge()・lasso() と突き合わせられる。目的関数の流儀(平均で割るかどうか)が違うので、alpha の値はそのまま比べられない
13 linfa-reduction の Pca は explained_variance_ratio() で寄与率、components() で固有ベクトルを返す。符号の向きはそろわないので、自作と比べるときはそろえる。乱数を使わないので、寄与率はほかの言語版と一致する
14 linfa-clustering に渡す RNG は rand 0.8 系(rand_xoshiro 0.6 の Xoshiro256Plus)でなければならない。 rand 0.9 以降の SmallRng を KMeans::params_with_rng に渡すと「two types coming from two different versions of the same crate」で落ちる。linfa は k-means++ で初期中心を選ぶので、クラスタ数が多いと自作(ランダムな初期化)より SSE が小さいことが多いが、絶対ではない(実データのクラスタ数 9 では自作のほうが小さかった)。SSE は linfa が返さないので、中心を受け取って自作と同じ式で測り直す
15 axum 0.8 の Json 抽出器は JSON として読めないと既定で 400 を返すので、422 に統一するには生の Request から自分で extract する。ハンドラーで共有する状態は Arc で包み、置き場の trait に + Send + Sync を課す(満たさない型はコンパイルが通らない)。(StatusCode, Json<T>) のタプルがそのまま応答になる。各章の run は同期の関数なので、tokio::runtime::Runtime::new() と block_on で非同期の世界に入る

linfa の決定木(linfa-trees 0.8.1)の既定値と自作との違い

第 3 章で自作と突き合わせた結果、深さ 1・2 では予測が完全に一致し、深さ 3 では正解率が同じ(0.9111)でも予測が 2 件分かれ、深さ 4 以上は正解率も分かれた(自作 0.9111、linfa 0.8444)。ソース(decision_trees/hyperparams.rs・algorithm.rs)を読んで理由を確かめた。

項目 linfa 自作
既定の停止条件 min_weight_split: 2.0、min_weight_leaf: 1.0、min_impurity_decrease: 1e-5 深さの上限だけ
枝刈りを切れるか 切れない。 min_impurity_decrease に f64::EPSILON 未満(0 を含む)を渡すと ParamGuard::check_ref が Err を返す —
学習と予測の比較 学習は <= split_value、予測は < split_value で、境界とちょうど等しい値の扱いが逆になる どちらも <=
同じ値とみなす差 固定の 1e-5 f64::EPSILON
分割の同点 score < best_score で先勝ち(自作と同じ) 先勝ち
葉のラベルの同点 find_modal_class が HashMap を走査するので順に依存しうる 先に現れたほう

深さ 3 以上で分かれる主因は min_impurity_decrease: 1e-5 の事前枝刈りである。

決定

用途 採用 バージョン ライセンス 初出
言語・ビルド Rust(edition 2024)、Cargo 1.91 — 第 1 章
テスト 標準の #[test](cargo test)と #[cfg(test)] mod tests Rust と同じ — 第 1 章
整形 rustfmt(cargo fmt --check で検査) 1.8.0 — 第 1 章(記事での解説は第 5 章)
静的解析 clippy(cargo clippy -- -D warnings) 0.1.91 — 第 1 章(記事での解説は第 5 章)
カバレッジ cargo-llvm-cov 0.6.20 MIT OR Apache-2.0 第 1 章(記事での解説は第 5 章)
データの表現 構造体と HashMap<String, String>(セルの文字列)。polars を使わない — — 第 1 章
CSV csv 1.4 Unlicense/MIT 第 1 章
文字コード encoding_rs(Shift_JIS の CSV) 0.8 (BSD 3 条項 / Apache-2.0 / MIT) 第 9 章
乱数 rand の StdRng::seed_from_u64 と Fisher-Yates、rand_xoshiro(linfa に渡す用) rand 0.8、rand_xoshiro 0.6(Cargo.lock で固定) MIT OR Apache-2.0 第 2 章
行列 ndarray 0.16(0.17 にしない) MIT OR Apache-2.0 第 7 章
機械学習 linfa(trees・linear・logistic・clustering・reduction・preprocessing・elasticnet) 0.8.1 MIT OR Apache-2.0 第 3 章
直列化 serde・serde_json(trait object は保存できないので、学習済みの前処理は enum で表す) 1.0 MIT OR Apache-2.0 第 8 章
API axum と tokio axum 0.8、tokio 1 MIT 第 15 章
  • polars は使わない。 ほかの言語版と同じく、構造体と HashMap でデータを表す。データフレームのライブラリを使わないのはシリーズ全体の方針
  • ndarray は 0.16 に固定する。 linfa 0.8.1 が ndarray 0.16 に依存しており、0.17 を直接入れると同じ名前の別の型になって linfa に渡せない。「クレートの版が型を分ける」ことは Rust 固有の論点なので、第 7 章で明示的に扱う
  • rand は 0.8、rand_xoshiro は 0.6 に固定する。 理由は同じ。linfa に RNG を渡す場面(K-means の初期中心)で版がそろっていないとコンパイルが通らない
  • linfa に無いものだけを自作の最終実装とする。 ランダムフォレスト(第 10 章)・欠損値の補完とダミー変数化(第 8 章)・特徴量の追加(第 9 章)が該当する
  • 各章は「自作してから linfa と突き合わせる」構成にする。 Java 版(Tribuo)・C# 版(ML.NET)と同じ流れ。Go 版・TypeScript 版のような「ライブラリが無いので自作のみ」の章は少ない
  • 依存は Cargo.toml にまとめ、使う章に入ってから追加する

章ごとのライブラリへの置き換え方針

章 置き換え 方針
3 linfa-trees の DecisionTree 自作のジニ不純度の決定木と突き合わせる。max_depth の指定と特徴量重要度も比べる
7 ndarray と linfa-linear の LinearRegression 正規方程式を ndarray で解いてから、linfa の重回帰と突き合わせる
8 linfa-trees(分類)。前処理は自作 欠損値の補完・ダミー変数化は linfa-preprocessing に無いので自作。分類器の突き合わせは、linfa 側が非決定的なので範囲で確かめる
9 linfa-preprocessing の LinearScaler::standard() 自作の標準化と突き合わせる(母標準偏差で一致することを実測で確かめた)
10 linfa-logistic の MultiLogisticRegression(alpha を 1e-8 にする) ロジスティック回帰は突き合わせる。既定の alpha は 1.0 なので、正則化なしの自作と比べるなら下げる。ランダムフォレストは linfa に無いので自作のみ
11 linfa の confusion_matrix・roc・cross_validate_single 自作の評価指標・ROC・交差検証と突き合わせる
12 linfa-elasticnet の ElasticNet::ridge()・lasso() 自作のリッジ・ラッソと突き合わせる(目的関数の流儀の違いに注意する)
13 linfa-reduction の Pca 自作の固有値分解と突き合わせる。寄与率は explained_variance_ratio() で取れる。符号はそろえる
14 linfa-clustering の KMeans 自作の K-means と突き合わせる。シードは rand_xoshiro の RNG で渡す

検討した代替案

代替案 採用しなかった理由
polars(データフレーム) ほかの言語版と同じく構造体と HashMap で表す。シリーズの方針
ndarray 0.17(最新) linfa 0.8.1 が 0.16 に依存しており、混ぜると型が合わない
rand 0.9・0.10(最新) 同じ理由。linfa に RNG を渡せない
ndarray-linalg(BLAS バックエンド) linfa-linalg(純 Rust)で足り、OpenBLAS の導入が要らない
smartcore linfa で必要なアルゴリズムが揃っており、依存を増やす理由が無い
cargo-tarpaulin cargo-llvm-cov のほうが rustc の計測機構をそのまま使う。どちらも固定の nixpkgs にある
actix-web・Rocket(API) 第 15 章の主題(層の分離と統合テスト)には axum で足り、tokio との組み合わせが素直
標準ライブラリだけの HTTP サーバー Rust の標準ライブラリに HTTP サーバーは無い。Go 版とはここが違う

影響

  • 良い影響: linfa が揃っているので、「自作してからライブラリと突き合わせる」という Python 版・Java 版と同じ流れをほぼ全章で書ける
  • 良い影響: クレートの版が型を分けるという Rust 固有の論点(ndarray 0.16/0.17、rand 0.8/0.9)を、実際に落ちた例とともに示せる
  • 良い影響: csv クレートが BOM を自動で取り除くので、ほかの言語版で毎回書いた BOM の除去が要らない。その差自体が記事の題材になる
  • 悪い影響: 最新版のクレートを使えない箇所がある(ndarray・rand)。理由を第 7 章・第 2 章に明記する
  • 悪い影響: 第 2 波の計画にあった「Rust 版は自作の比重が大きい」という前提が崩れたので、対比の軸を Java 版・C# 版(ライブラリが揃った静的型付け言語)に差し替える

コンプライアンス

  • apps/rust/Cargo.toml に記載された依存が本 ADR の表と一致している
  • Rust CI(.github/workflows/rust-ci.yml)がグリーンである
  • わざと違反を入れると cargo fmt --check・cargo clippy -- -D warnings が失敗する(B44 で確かめる)

備考

  • 著者: claude-code/claude-opus-5