第 4 章: バージョン管理とデータ管理¶
4.1 はじめに¶
第 1 部では、TDD でデータを読み込み、前処理し、決定木で分類するプログラムをなでしこ3 で作りました。第 2 部では、そのプログラムを「変更を楽に安全にできる」状態に保つための道具立てを、バージョン管理(第 4 章)、パッケージ管理と静的解析(第 5 章)、タスクランナーと CI/CD(第 6 章)の順に整えます。
この章ではバージョン管理を扱います。機械学習のプロジェクトでは、コードに加えて 学習データ と 学習済みモデル というファイルが登場します。これらをコードと同じようにコミットしてよいとは限りません。
Git の使い方は言語に依存しないので、Python 版の第 4 章・Haskell 版の第 4 章 と同じ構成で進めます。なでしこ3 版で注目するのは次の 3 点です。
- 処理系そのものを
bin/に入れる。ほかの言語版では処理系は Nix の環境が用意しましたが、なでしこ3 版はgonakoをgo installでapps/nadesiko3/bin/に入れます。これもコミットしません - 改行を守る道具が 1 つもない。
gonakoは CRLF のファイルをそのまま動かし、lint も通し、整形も CRLF のまま出力します。守りは.gitattributesだけです - 再現性を守るテストが本当に効いているかを確かめる。「偽と一致する」を確かめるテストは、式の読まれ方を間違えていても通ってしまうことがあります
4.2 コミットメッセージの重要性¶
コミット履歴は「なぜこの変更をしたのか」の記録です。機械学習のプロジェクトでは、「前処理を変えたら正解率が変わった」「ほかの言語版と数値が合わない原因をテストに残した」といった変化の理由を、あとから追跡できることが特に重要になります。
コミットは次の 2 つを守ると追いやすくなります。
- 1 コミット 1 目的 — 機能追加と設定変更、実装と記事を 1 つのコミットに混ぜない
- なぜを書く — 何をしたかは差分を見れば分かります。差分に残らないのは「なぜそうしたか」です
なでしこ3 版では、コードに残らない判断がいくつもありました。「処理系は Go 版の nadesiko3go を使う」「Nix の Go は 1.25.5 なので、GOTOOLCHAIN で 1.26 に切り替える」「テスティングフレームワークが無いのでテスト補助を自作する」。これらは ADR 015 にまとめ、実装より先にコミットしています。
4.3 Conventional Commits¶
フォーマット¶
<type>(<scope>): <subject>
<body>
<footer>
- type — 変更の種類(
feat・fix・docs・refactor・test・build・ci・chore) - scope — 変更の対象(
nadesiko3・getting-start-mlなど) - subject — 何をしたかを一行で
コミットタイプ¶
| type | 使う場面 | なでしこ3 版での例 |
|---|---|---|
feat |
機能の追加 | 章の実装を足す |
fix |
不具合の修正 | 0 = 「」 が真になり、値が 0 のセルを欠損値と数えていたのを直す |
docs |
ドキュメント | 記事を書く、ADR を足す |
refactor |
振る舞いを変えない改善 | 重複した関数をまとめる |
test |
テストだけの変更 | 再現性のテストを足す |
build |
ビルドの設定・依存 | tools/install-gonako.sh の版を上げる |
ci |
CI の設定 | .github/workflows/nadesiko3-ci.yml を直す |
なでしこ3 版には外部のライブラリが 1 つも無いので、build の出番はほとんどありません。変わるとすれば処理系(gonako)の版だけです。
実践例¶
feat(nadesiko3): 第 2〜3 章の乱数・前処理・決定木を実装する(B77)
- java.util.Random と同じ線形合同法を、状態と乗数を 24 ビットずつに分けて float64 の範囲で実装
- 0 = 「」 が真になるゆるい比較を避け、空欄の判定を === にする
- 名前を「終了」にすると黙って終わるため、check.sh で検査結果報告まで届いたかを確かめる
body に書いたのは、差分を見ても分からないことだけです。「なぜ 24 ビットに分けるのか」「なぜ = ではなく === なのか」「なぜ check.sh が出力を読むのか」。
4.4 何をコミットし、何をコミットしないか¶
Git に入れるのは、人が書いたもの と 再生成できないもの です。
| 分類 | 例 | コミットするか |
|---|---|---|
| ソースコード | src/・test/・tools/ の .nako3、main.nako3 |
する |
| 検査と導入のスクリプト | tools/check.sh・tools/install-gonako.sh |
する |
| 記事・ADR | docs/ の Markdown |
する |
| 処理系 | bin/gonako |
しない(install-gonako.sh で再生成できる) |
| 学習データ | apps/data/ |
しない(再配布できない) |
| 学習済みモデル | model/ |
しない(再生成できる) |
学習データ¶
本シリーズが使う学習データは書籍の購入者だけが使えるもので、再配布できません。リポジトリのルートの .gitignore で apps/data/ を除外しています。
なでしこ3 版に固有のファイル¶
apps/nadesiko3/.gitignore は 2 行だけです。
# 処理系 gonako の入れ先(tools/install-gonako.sh)
bin/
# 学習済みモデルの保存先(第 15 章)
model/
ビルドの成果物がありません。 なでしこ3 のプログラムはバイトコードの VM で直接動くので、Haskell 版の dist-newstyle/・Rust 版の target/ にあたるものができません。カバレッジの計測結果(Haskell 版の *.tix)も、仕組みが無いのでできません。
代わりに除外するのが bin/ の処理系そのものです。32 MB ほどの実行ファイルで、tools/install-gonako.sh を実行すれば同じ版がいつでも作り直せます。
除外されていることを確かめる¶
git check-ignore -v で、どのファイルの何行目の規則で除外されたかが分かります。
git check-ignore -v apps/data/sukkiri-ml/KvsT.csv apps/nadesiko3/bin/ apps/nadesiko3/bin/gonako apps/nadesiko3/model/ tmp/sukkiri-ml-codes.zip
.gitignore:8:apps/data/ apps/data/sukkiri-ml/KvsT.csv
apps/nadesiko3/.gitignore:2:bin/ apps/nadesiko3/bin/
apps/nadesiko3/.gitignore:2:bin/ apps/nadesiko3/bin/gonako
apps/nadesiko3/.gitignore:5:model/ apps/nadesiko3/model/
.gitignore:12:tmp/ tmp/sukkiri-ml-codes.zip
コミットするファイルは除外されていないことも確かめます。除外されていなければ何も表示されず、終了コードが 1 になります。
git check-ignore -v apps/nadesiko3/src/chapter01.nako3 apps/nadesiko3/tools/check.sh; echo "exit=$?"
exit=1
改行を守るのは .gitattributes だけ¶
Windows で作業した人のコミットに CRLF が混ざると、差分が全行に出て読めなくなります。Haskell 版では fourmolu が CRLF を指摘しました。gonako はどうかを確かめます。
printf 'A = 1\r\nもし、A = 1ならば\r\n 「x」を表示\r\nここまで\r\n' > crlf.nako3
./bin/gonako crlf.nako3; echo "run=$?"
./bin/gonako lint crlf.nako3; echo "lint=$?"
./bin/gonako format crlf.nako3 | diff -q crlf.nako3 - > /dev/null; echo "format差分=$?"
x
run=0
crlf.nako3: 文法エラーはありません
lint=0
format差分=0
3 つとも何も言いません。 実行もでき、lint も通り、整形の結果も CRLF のまま(元のファイルと差分なし)です。check.sh の整形の検査は「gonako format の出力と元のファイルの差分」を見るので、CRLF のファイルはそのまま通ります。
| ファイル | なでしこ3 の道具が見るか | .gitattributes が守るか |
|---|---|---|
src/・test/・tools/ の .nako3 |
見ない(CRLF のまま通す) | 守る |
tools/*.sh |
見ない | 守る(*.sh text eol=lf でも守られる) |
.github/workflows/nadesiko3-ci.yml |
見ない | apps/nadesiko3/** の外。YAML は別途 |
改行については、道具の守りが 1 層もありません。 リポジトリのルートの .gitattributes に足した 1 行がすべてです。
apps/nadesiko3/** text=auto eol=lf
git ls-files --eol で、いま入っているファイルがすべて LF であることを確かめられます。
git ls-files --eol apps/nadesiko3 | awk '{print $1, $2}' | sort | uniq -c
22 i/lf w/lf
4.5 データの入手手順をコードにする¶
データをコミットしない代わりに、データの入手と配置の手順 をリポジトリに残します。配置と確認は Gulp のタスク(ops/scripts/data.js)で、全言語で同じものを使います。
npx gulp data:setup
npx gulp data:check
プログラムからデータの場所を知る¶
なでしこ3 版では、src/dataset.nako3 が学習データの場所を決めます。
データ環境変数名 = 「ML_DATA_DIR」
既定データ場所 = 「../data/sukkiri-ml」
●(設定値の)データ場所決定とは
もし、設定値 = 「」ならば
既定データ場所で戻る
ここまで
設定値で戻る
ここまで
●(ファイル名の)データパス取得とは
設定値 = データ環境変数名の環境変数取得
(設定値のデータ場所決定)とファイル名のデータパス組立で戻る
ここまで
環境変数を読むところ(データパス取得)と、値から場所を決めるところ(データ場所決定)を分けています。 後者は文字列を受け取って文字列を返すだけなので、環境変数を書き換えずにテストできます。
「環境変数が空なら既定の場所を使う」で(「」のデータ場所決定)と「../data/sukkiri-ml」が一致確認
「環境変数があればその場所を使う」で(「/tmp/ml」のデータ場所決定)と「/tmp/ml」が一致確認
なでしこ3 の 環境変数取得 は、設定されていない環境変数に対して空文字列を返します。Haskell 版の lookupEnv が Maybe String で「無い」と「空」を区別したのに対し、なでしこ3 では区別できません。どちらも既定の場所を使うことにしています。
設定値 = 「」 の比較は、第 2 章で見た「ゆるい比較」です。環境変数は必ず文字列なので、ここでは 0 = 「」 のような取り違えは起きません。
4.6 データが無い環境でもテストを通す¶
学習データはリポジトリに無いので、CI でも、データを持っていない読者の手元でも、データを使うテストはそのままでは落ちます。データが無いときは「落ちる」のではなく「外す」のが正しい振る舞いです。
なでしこ3 版では、テスト補助の 保留 を使います。
もし、(「KvsT.csv」のデータ存在確認)ならば
「ルールによる判定の正解率がほかの言語版と一致する」で(第1章実行)と「データ件数: 19{改行}ルールによる判定の正解率: 0.7368{改行}」が一致確認
違えば
「ルールによる判定の正解率がほかの言語版と一致する」を「学習データがありません」で保留
ここまで
データが無い状態で走らせると、こう出ます。
保留: ルールによる判定の正解率がほかの言語版と一致する(学習データがありません)
検査 14 件、失敗 0 件、保留 1 件
保留の数が最後の行に出ます。 外したことが黙って消えず、理由まで表示されます。これはテスト補助を自作したときに(第 1 章)、Hspec の 3 pending にならって入れた仕組みです。
スキップの仕組みは一段しかない¶
| 言語版 | 外す仕組み | 段数 |
|---|---|---|
| PHP 版 | #[Group('data')](実行前に外す)+ markTestSkipped(実行時に外す) |
2 |
| Haskell 版 | pendingWith(実行時に外す) |
1 |
| なでしこ3 版 | もし で分けて 保留(実行時に外す) |
1 |
Haskell 版と同じく一段です。そして、なでしこ3 版には一段であることの別の弱さがあります。
外した分岐は一度も実行されない¶
違えば の側に回った分岐の中身は、データの無い環境では一度も実行されません。第 1 章で見たとおり、なでしこ3 の lint はかっこの中の未定義の名前を素通りさせるので、書き間違いが残っていても気づけません。
そこで第 2 章からは、実データと同じ形の自作のデータを ML_DATA_DIR で指して走らせ、落ちるのが「値の不一致」だけで、書き間違いによるエラーではないことを確かめています。
ML_DATA_DIR=/path/to/自作データ ./tools/check.sh
失敗: 補完後の訓練データの平均がほかの言語版と一致する
期待: 0.4215384615384616
実際: 0.5052884615384615
期待値と違うのは、自作のデータだからです。大事なのは、[文法エラー] や [実行時エラー] で途中が止まらず、最後まで比べられていることです。
Haskell 版は「外したぶんがカバレッジの数字に出る」ことを利点にしました。なでしこ3 版にはカバレッジの仕組みが無いので、この差は数字に出ません。第 5 章で作る「関数の網羅」の検査でも、名前が現れるかを静的に見るだけなので、外した分岐の中の関数も「届く」と数えてしまいます。そのため、学習データが無くても報告を組み立てる関数を確かめられるよう、自作の小さなデータで報告の関数を直接呼ぶテストを第 5 章で足しました。
4.7 実験を再現できるようにする¶
機械学習では「同じコードを動かしたのに結果が違う」ことが起きます。原因のほとんどは乱数です。
乱数のシード¶
第 2 章で見たとおり、なでしこ3 版は java.util.Random と同じ 48 ビットの線形合同法を、24 ビットずつに分けて自作しています。なでしこ3 の 乱数 はシードを受け取らないので、組み込みの乱数を使う限り、実験は再現できません。
生成器は辞書で、書き換えて進む¶
なでしこ3 版の生成器は {"上位": …, "下位": …} の辞書で、次整数 を呼ぶたびに辞書を書き換えて進みます。
生成器 = 0の乱数生成器作成
A = 生成器と100の次整数
B = 生成器と100の次整数
Haskell 版は状態を引数と戻り値で受け渡したので、「シードを設定し忘れると型が合わない」という守りがありました。なでしこ3 版には型がありませんが、生成器はシードからしか作れないので、「シードを設定し忘れる」ことは起きません。一方で、同じ生成器を別々の処理で共有すると、呼んだ順で並びが変わります。並べ替えの関数(並べ替え)は呼ばれるたびにシードから生成器を作り直すので、ほかの処理の影響を受けません。
テストで固定する¶
第 2 章の「ほかの言語版と同じ並び」のテストに加えて、この章で 3 本足しました。
シード一並び = 100と1で20を整数列取得
シード二並び = 100と2で20を整数列取得
「シードが違えば並びが変わる」で((シード一並びをJSONエンコード) = (シード二並びをJSONエンコード))と偽が一致確認
一回目分割 = 十件と十件ラベルを0.3と42で訓練テスト分割
二回目分割 = 十件と十件ラベルを0.3と42で訓練テスト分割
「同じシードなら何度分けても同じ分割になる」で一回目分割と二回目分割が一致確認
別シード分割 = 十件と十件ラベルを0.3と7で訓練テスト分割
「シードが違えば分割が変わる」で((一回目分割["訓練特徴量"])をJSONエンコード = ((別シード分割["訓練特徴量"])をJSONエンコード))と偽が一致確認
空振りしていないことを確かめる¶
この 3 本は、書いたときから通りました。すでにある振る舞いを守るテストなので当然です。ただし、「偽と一致する」を確かめるテストは空振りしやすいものです。式の読まれ方を間違えて、比べたいものとは別の何かが偽になっていても、テストは通ってしまいます。第 1 章で見たとおり、なでしこ3 は助詞とかっこの位置で式の読まれ方が変わります。
そこで、わざとシードを同じにしたら落ちることを確かめました。
失敗: シードが違えば分割が変わる
期待: false
実際: true
失敗: シードが違えば並びが変わる
期待: false
実際: true
シードを同じにすれば比較が真になり、テストが落ちます。つまり、このテストはちゃんと 2 つの並びを比べています。通るテストを書いたら、それが落ちうることも一度は確かめる——TDD の Red を飛ばしたテストには、この一手間が要ります。
仕様で決まっていること、決まっていないこと¶
| 事柄 | 根拠 | 確かさ |
|---|---|---|
| 同じシードで同じ並びを返す | 生成器はシードからしか作らず、ほかに入力が無い | テストで確かめた |
| Java 版・Haskell 版などと同じ並びになる | ほかの言語版のテストの値、Python の参照実装と比べた | 手順を写し取ってそろえた結果 |
| 24 ビットに分けた計算が正確である | どの積も 2^53 未満に収まる(float64 で正確に表せる) | IEEE 754 の倍精度という仕様に頼っている。なでしこ3 の数値が float64 であることは docs/differences.md に書かれている |
XOR が 32 ビットの符号付き整数で計算する |
実測(2147483648と1のXOR が負になる) |
実装に頼っている。初期化の XOR を 24 ビットずつにしたので、幅が変わっても 32 ビット以上なら影響しない |
4.8 TDD とコミットのタイミング¶
- テストが通らない状態ではコミットしない
- 実装と記事は別のコミットにする
- 学習データ・モデル・
bin/がステージングされていないことを、コミットの前にgit statusで確かめる
なでしこ3 版では、CI の定義(.github/workflows/nadesiko3-ci.yml)と apps:check:nadesiko3 タスクを、ウォーキングスケルトン(B76)のうちに入れています。品質チェックの仕組みは、守るコードが少ないうちに入れるほうが楽です。
4.9 まとめ¶
- Conventional Commits — type と scope で、変更の種類と対象が分かるコミットメッセージを書く。コードに残らない判断(Go 版の処理系を使う、
GOTOOLCHAINで切り替える、テスト補助を自作する)は ADR にして、実装より前にコミットする。外部のライブラリが無いのでbuildの出番は処理系の版を上げるときだけ - コミットしないものを決める — 再配布できない学習データと、再生成できる
bin/(処理系)・model/を除外する。ビルドの成果物もカバレッジの計測結果も無いので、.gitignoreは 2 行で済む - 改行は
.gitattributesだけが守る —gonakoは CRLF のファイルをそのまま実行し、lint も通し、整形も CRLF のまま出力する。道具の守りが 1 層も無い - 入手手順をコードにする — データの場所は
ML_DATA_DIRで差し替える。環境変数を読むところと、値から場所を決めるところを分け、後者をテストする。環境変数取得は設定が無いと空文字列を返し、「無い」と「空」を区別できない - データが無くてもテストを通す —
もしで分けて保留にする一段構え。外した分岐は一度も実行されず、lint も書き間違いを捕まえないので、自作のデータをML_DATA_DIRで指して最後まで走ることを確かめる - 再現性 — 組み込みの
乱数はシードを受け取らないので、自作の生成器を使う。生成器はシードからしか作れないのでシードの設定し忘れは起きないが、共有すると呼んだ順で並びが変わる - 通るテストが落ちうることを確かめる — 「偽と一致する」テストは空振りしやすい。わざとシードを同じにして落ちることを確かめた
次の章では、依存と版の管理、gonako lint と gonako format の守備範囲、そしてカバレッジの仕組みが無い言語で、その代わりを自作することを扱います。
なでしこ3 版のほかの章は なでしこ3 版のトップ から辿れます。