第 1 章: 機械学習とはじめてのテスト¶
1.1 はじめに¶
この章では、機械学習とは何かを確認したうえで、テスト駆動開発(TDD)で最初のプログラムを Ruby で作ります。題材は「きのこの山派か、たけのこの里派か」を身長・体重・年代から判定する問題です。
この章ではまだ機械学習のアルゴリズムを使いません。人間が考えたルールで判定し、その正解率を測るところまで進みます。「ルールを人間が書く」とはどういうことかを先に体験しておくと、第 3 章で「ルールをデータから学ばせる」ことの意味がはっきりします。
Python 版の第 1 章 と同じ題材・同じ TODO リストで進めます。Ruby 版では次の 2 つと対比します。1 つは Python 版で、同じ動的型付けのスクリプト言語どうし、書き方がよく似ています。Ruby にも scikit-learn に似た API を持つ Rumale という機械学習ライブラリがあり、第 3 章から使います(ADR 010)。2 つめは TypeScript 版 と Kotlin 版 で、型の検査があるかないかの違いです。Ruby 版では型注釈(RBS・Steep)を使わず、型の誤りはテストで捕まえます。その代償がこの章のうちに一度、目に見える形で現れます。
1.2 機械学習とは¶
ルールを書くか、データに学ばせるか¶
従来のプログラミングでは、人間が判定のルールをコードとして書きます。
def predict_by_rule(features)
features.age_group == KINOKO_AGE_GROUP ? KINOKO : TAKENOKO
end
この書き方では、ルールの良し悪しは人間の観察力に依存します。特徴量が 3 つなら何とかなりますが、20 個・100 個になると人間には手に負えません。
機械学習は、この「ルール」をデータから自動で作ります。人間が与えるのは「入力(特徴量)」と「正解(ラベル)」の組で、ルールそのものはアルゴリズムが決めます。第 3 章で決定木を実装すると、「年代が 20 ならきのこ」に相当する分岐が、データから自動で決まる様子を見られます。
| 従来のプログラミング | 機械学習 | |
|---|---|---|
| 人間が書くもの | ルール | データと、学習のさせ方 |
| 出力 | 判定結果 | ルール(モデル)と、それを使った判定結果 |
| 得意なこと | 仕様がはっきりしている問題 | 仕様を言葉にしにくい問題 |
| 説明のしやすさ | コードを読めば分かる | モデルによる(決定木は読める、ニューラルネットは難しい) |
機械学習のワークフロー¶
本シリーズを通して、次の流れを繰り返します。
- データを集める・読み込む — この章で CSV を読み込みます
- 前処理する — 欠損値を埋め、訓練データとテストデータに分けます(第 2 章)
- モデルを学習させる — アルゴリズムにルールを作らせます(第 3 章以降)
- 評価する — 正解率などの指標で測ります(この章で正解率を実装します)
- 改善する — 特徴量を足す、別のモデルを試す、正則化する(第 9〜12 章)
この章では 1・4 を、いちばん単純な形で通します。端から端まで通すのが目的で、精度は求めません。
分類と回帰¶
教師あり学習は、予測するものの型で 2 つに分かれます。
| 種類 | 予測するもの | 例 | 本シリーズで扱う章 |
|---|---|---|---|
| 分類 | どのグループに属するか(離散値) | きのこ派/たけのこ派、アヤメの種類、生存/死亡 | 第 1・3・8・10・11 章 |
| 回帰 | 数値(連続値) | 映画の興行収入、住宅価格 | 第 7・9・12 章 |
この章は分類です。正解ラベルが「きのこ」「たけのこ」の 2 つなので、二値分類にあたります。
1.3 題材とデータ¶
データの入手と配置¶
本シリーズの学習データは、書籍『スッキリわかる Python による機械学習入門』(インプレス, 2020)の配布データを使います。配布データは書籍購入者のみ利用できるため、リポジトリには含まれていません。書籍サポートページ から sukkiri-ml-codes.zip を入手し、リポジトリの tmp/ に置いてから、リポジトリのルートで次を実行してください。
npx gulp data:setup
npx gulp data:check
apps/data/sukkiri-ml/ に学習データが配置されます。このディレクトリは .gitignore の対象なので、誤ってコミットされることはありません。すべての言語版が同じデータを参照します。
KvsT.csv¶
この章で使うのは KvsT.csv です。19 人分のデータが次の 4 列で記録されています。
| 列 | 意味 | 値 |
|---|---|---|
| 身長 | 身長(cm) | 整数 |
| 体重 | 体重(kg) | 整数 |
| 年代 | 年代 | 10, 20, 30, 40 のいずれか |
| 派閥 | きのこの山派かたけのこの里派か | きのこ または たけのこ |
「身長」「体重」「年代」が特徴量、「派閥」が正解ラベルです。列名が日本語であることと、ファイルの先頭に BOM(バイトオーダーマーク)が付いていることに注意してください。BOM については、Ruby では読み方しだいで結果が変わります。 後で確かめます。
1.4 TODO リストの作成¶
仕様をそのままコードにするには大きすぎるので、まず TODO リストに分解します。
TODO リスト
何をテストすべきだろうか——着手する前に、必要になりそうなテストをリストに書き出しておこう。
— テスト駆動開発
TODO リスト:
- CSV を読み込む
- BOM 付き CSV の 1 行を人物として読み込む
- 複数行を行の順に読み込む
- 特徴量と正解ラベルに分ける
- ルールで派閥を判定する
- 20 代ならきのこ派と判定する
- 20 代以外ならたけのこ派と判定する
- 正解率を計算する
- 実データで正解率を表示する
1.5 開発環境の準備¶
プロジェクトの構成¶
実装は apps/ruby/ に置きます。Bundler のプロジェクトが 1 つ、その中に章ごとのモジュールを並べる構成です。
apps/ruby/
├── .ruby-version
├── .rubocop.yml
├── .bundle/
│ └── config # gem の置き場
├── Gemfile
├── Gemfile.lock
├── Rakefile # check・run のタスク
├── lib/
│ ├── getting_started_ml.rb # モジュールの一覧
│ └── getting_started_ml/
│ ├── dataset.rb # 学習データの場所
│ └── chapter01.rb # 第 1 章
└── test/
├── test_helper.rb # SimpleCov と Minitest の準備
├── dataset_test.rb
├── chapter01_test.rb
└── kvst_data_test.rb # 実データを使うテスト
Gemfile は次のとおりです。
# frozen_string_literal: true
source "https://rubygems.org"
ruby ">= 3.3"
gem "csv", "~> 3.3"
group :development, :test do
gem "minitest", "~> 6.0"
gem "rake", "~> 13.0"
gem "rubocop", "~> 1.91", require: false
gem "simplecov", "~> 1.3", require: false
end
csv を Gemfile に書いているのは、Ruby 3.4 から csv が標準の gem から外れるためです。いまの Ruby 3.3 では書かなくても動きますが、版を上げた日に require "csv" が突然失敗しないように、依存を明示しておきます。
.bundle/config には gem の置き場を書きます。
---
BUNDLE_PATH: "vendor/bundle"
これで bundle install した gem は apps/ruby/vendor/bundle に入り、システムの Ruby を汚しません。Python 版の仮想環境(.venv)に近い役割です。
Ruby の開発環境は nix develop .#ruby に入ると揃います。Ruby 3.3.10、Bundler 2.7.2 です。macOS に付属する Ruby 2.6 は対象外です。 Data.define(Ruby 3.2 以降)や、ハッシュの値の省略記法(Ruby 3.1 以降)を使うので、2.6 では動きません。Gemfile の ruby ">= 3.3" が、古い Ruby で bundle install したときに止めてくれます。
nix develop .#ruby
cd apps/ruby
bundle install
環境確認テスト¶
いちばん最初に書くのは、環境が動くことを確かめるテストです。学習データの置き場を返すメソッドから始めます。
# frozen_string_literal: true
module GettingStartedMl
# 学習データのディレクトリを求める。
module Dataset
# 実データの置き場をテストや CI から差し替えるための環境変数の名前。
ENV_NAME = "ML_DATA_DIR"
# 既定の置き場。テストは apps/ruby で走るので、相対パスで apps/data に届く。
DEFAULT_DIR = File.join("..", "data", "sukkiri-ml")
# 学習データのディレクトリを返す。環境変数はテストで差し替えられるように引数で受け取る。
def self.dir(env = ENV)
value = env[ENV_NAME]
value.nil? || value.empty? ? DEFAULT_DIR : value
end
end
end
env = ENV が既定値つきの引数です。ふだんは本物の環境変数 ENV を読み、テストでは普通の Hash を渡して差し替えます。ENV と Hash は別のクラスですが、どちらも [] で値を引けるので、このメソッドから見れば区別が要りません。型ではなく「そのメソッドに応答するか」で扱う、いわゆるダックタイピングです。Rust 版では同じことをクロージャ(impl Fn(&str) -> Option<String>)の引数で書き、型で宣言しました。
先頭の # frozen_string_literal: true は、ファイル内の文字列リテラルを変更不可にする指示です。RuboCop が全ファイルに求めます。
テストは test/ に別ファイルで書きます。
# frozen_string_literal: true
require "test_helper"
class DatasetTest < Minitest::Test
def test_環境変数が無ければ既定の場所を使う
assert_equal File.join("..", "data", "sukkiri-ml"), GettingStartedMl::Dataset.dir({})
end
def test_環境変数があればその場所を使う
assert_equal "/tmp/data", GettingStartedMl::Dataset.dir({ "ML_DATA_DIR" => "/tmp/data" })
end
def test_環境変数が空なら既定の場所を使う
assert_equal File.join("..", "data", "sukkiri-ml"), GettingStartedMl::Dataset.dir({ "ML_DATA_DIR" => "" })
end
end
Minitest の約束は 2 つだけです。
Minitest::Testを継承したクラスに、test_で始まるメソッドを書く。 そのメソッドがテストになります。Python の unittest と同じ流儀ですassert_equal 期待値, 実際の値の順に書く。 Rust のassert_eq!(left, right)とは順番の慣習が逆です
メソッド名に日本語が使えるのは Rust と同じです。test_ の後ろを日本語にして、振る舞いをそのまま名前にしています。
test_helper.rb は各テストの先頭で読み込む準備のファイルです。
# frozen_string_literal: true
require "simplecov"
SimpleCov.start do
skip "/test/"
skip "/vendor/"
end
require "tmpdir"
require "minitest/autorun"
require "getting_started_ml"
SimpleCov はテスト対象を読み込むより前に始める必要があります。後から始めると、それまでに読み込んだファイルの行が計測されません。skip はカバレッジの集計から外すパスの指定です(テストコード自身と、vendor/ の gem)。
1.6 ルールで派閥を判定する¶
TODO リストは CSV の読み込みから始まっていますが、いちばん中心にある「判定」から着手します。CSV の読み込みは外側の関心事で、判定の仕様とは独立だからです。
Red: まだ無いものを呼ぶ¶
テストを先に書きます。
class Chapter01Test < Minitest::Test
C = GettingStartedMl::Chapter01
def features(height, weight, age_group)
C::Features.new(height:, weight:, age_group:)
end
def test_二十代はきのこ派と判定する
assert_equal C::KINOKO, C.predict_by_rule(features(170, 60, 20))
end
end
Chapter01 も Features もまだありません。実行すると、このテストは NameError で失敗します。
ここが Rust 版・Kotlin 版との違いです。静的型付け言語の Red はコンパイルエラーで、テストが走る前に止まりました。Ruby の Red は実行時エラーで、Python 版と同じく、テストを走らせて初めて「無い」と分かります。未定義の定数を参照しても、ファイルを読み込んだ時点では何も言われません。
C = GettingStartedMl::Chapter01 と、長いモジュール名に短い別名を付けている点に注目してください。これには理由があります(1.11 節で扱います)。
features(height:, weight:, age_group:) の height: は、Ruby 3.1 から使えるハッシュの値の省略記法で、height: height と同じ意味です。Rust の構造体のフィールドの省略記法と同じ発想です。
Green: 値オブジェクトとルール¶
特徴量の型を Data.define で作ります。
# 学習データ KvsT.csv の 1 行。身長・体重・年代と、正解ラベルの派閥を持つ。
Person = Data.define(:height, :weight, :age_group, :faction)
# 判定の手がかりになる特徴量。正解ラベルを持たない。
Features = Data.define(:height, :weight, :age_group)
Data.define は Ruby 3.2 で入った、不変の値オブジェクトを作る仕組みです。1 行で次がそろいます。
| 振る舞い | 中身 | ほかの言語版だと |
|---|---|---|
| キーワード引数のコンストラクタ | Features.new(height: 170, weight: 60, age_group: 20)。足りない・余計な引数は ArgumentError |
Kotlin の data class、Python の @dataclass(frozen=True) |
| 読み取りメソッド | features.age_group |
同上 |
| 値による比較 | == がフィールドの値で比べる |
Rust の #[derive(PartialEq)] |
| 不変 | セッターが無い | Rust の既定の不変 |
古くからある Struct は、フィールドを後から書き換えられます。特徴量や学習データの行を途中で書き換える理由は無いので、Data を選びました。「引数の個数を間違えたら落ちる」ことが、型の宣言を書かない Ruby では貴重な早期の検査になります。
ルールの本体は次のとおりです。
# 「20 代ならきのこ派」というルールの年代。
KINOKO_AGE_GROUP = 20
# きのこ派の呼び名。
KINOKO = "きのこ"
# たけのこ派の呼び名。
TAKENOKO = "たけのこ"
# 人間が決めたルールで派閥を判定する。
def predict_by_rule(features)
features.age_group == KINOKO_AGE_GROUP ? KINOKO : TAKENOKO
end
TDD の手順としては、まず KINOKO を返すだけの仮実装で Green にし、次の三角測量で本実装に進めています。
三角測量¶
年代が 20 以外のすべてでたけのこ派になることを確かめます。
def test_二十代以外はたけのこ派と判定する
[10, 30, 40, 50].each do |age_group|
assert_equal C::TAKENOKO, C.predict_by_rule(features(170, 60, age_group))
end
end
配列に each とブロックを渡して回します。Rust 版の for age_group in [10, 30, 40, 50] と同じ形ですが、Ruby では for 文よりもブロックを渡すほうが普通です。Minitest の assert_equal は失敗しても例外を投げてそのテストを止めるだけなので、どの値で落ちたかはメッセージの期待値と実際の値から読み取ります。
モジュールの形¶
章の関数はモジュールにまとめ、module_function を付けます。
module GettingStartedMl
# 第 1 章: 人間が決めたルールできのこ派・たけのこ派を判定する。
module Chapter01
# ...定数と Data.define...
module_function
def predict_by_rule(features)
# ...
end
end
end
module_function より後に書いたメソッドは、Chapter01.predict_by_rule(...) とモジュールから直接呼べる関数になります。クラスを作ってインスタンスを生成するほどではない、状態を持たない関数の集まりに使う書き方です。Python のモジュールの関数、Rust の pub fn に近い立ち位置です。この書き方には落とし穴が 1 つあり、1.11 節で踏みます。
1.7 失敗を例外で表す¶
CSV の読み込みに進む前に、失敗をどう表すかを決めます。Rust 版では enum Error を定義し、失敗しうる関数は Result<T, Error> を返しました。Ruby は逆に、失敗は例外を投げて表します。新しい例外クラスは作らず、標準の例外を使います。
| 失敗 | Ruby 版 | Rust 版 |
|---|---|---|
| 数値として読めない | ArgumentError |
Error::NotANumber |
| 列が無い | KeyError |
Error::MissingColumn |
| 予測と正解ラベルの件数が違う | ArgumentError |
Error::LengthMismatch |
| CSV を開けない | Errno::ENOENT など(標準ライブラリが投げる) |
Error::Io |
ArgumentError は「引数の値がおかしい」、KeyError は「キーが無い」を表す標準の例外で、Hash#fetch が投げるのも KeyError です。意味が合う標準の例外があるなら、それを使うほうが読み手に伝わります。
言語ごとの失敗の表し方を並べると、Ruby の位置が分かります。
| 言語版 | 失敗の表し方 | 網羅性の検査 |
|---|---|---|
| Rust | Result<T, Error> と enum Error |
match の網羅性をコンパイラが検査する |
| Go | (値, error) の多値返却 |
されない |
| Java | 検査例外 throws |
宣言はあるが、種類の網羅は検査されない |
| Ruby・Python・TypeScript・Kotlin | 例外 | されない |
Ruby では、メソッドがどの例外を投げうるかをどこにも宣言しません。呼び出し側が知る手段はドキュメントとテストだけです。そこで、失敗の種類ごとにテストを書き、「この入力ならこの例外とこのメッセージ」を仕様として固定します(1.8 節)。
1.8 CSV を読み込む¶
BOM が取り除かれるかどうかは読み方で決まる¶
本シリーズを通しての「BOM の落とし穴」です。Python 版・Kotlin 版・Java 版・C# 版・Go 版では、BOM 付きの CSV を素直に読むと、先頭の列名が "\uFEFF身長" になり、「身長」で引けませんでした。Rust 版では csv クレートが自分で BOM を取り除きました。
Ruby の csv は、読み方によって結果が変わります。
| 読み方 | 先頭の列名 |
|---|---|
CSV.foreach(path, headers: true)・CSV.read(path, headers: true)(ファイルを開く) |
"身長"(BOM が取り除かれる) |
CSV.parse(File.read(path), headers: true)(文字列を渡す) |
"\uFEFF身長"(BOM が残る) |
CSV.parse(File.read(path, encoding: "bom\|utf-8"), headers: true) |
"身長" |
ファイルを開く読み方なら、csv が BOM を取り除きます。いったん File.read で文字列にしてから CSV.parse に渡すと、csv から見れば「BOM で始まる文字列」でしかないので、そのまま列名に残ります。文字列から読むなら、ファイルを読む段階で encoding: "bom|utf-8" を指定して BOM を落とします。Python 版の encoding='utf-8-sig' と同じ役割の指定です。
Rust 版の結論は「どの層が面倒を見てくれるかはライブラリごとに違う」でした。Ruby では同じライブラリの中でも、入口によって違います。本章ではファイルを開く CSV.foreach を使い、BOM の除去を書きません。そのかわり、その前提をテストで固定します。
def test_BOM_付きの_CSV_を列名で読み込む
Dir.mktmpdir do |dir|
path = File.join(dir, "people.csv")
File.write(path, "\uFEFF身長,体重,年代,派閥\n170,60,20,きのこ\n")
assert_equal [C::Person.new(height: 170, weight: 60, age_group: 20, faction: C::KINOKO)], C.load_people(path)
end
end
Ruby の二重引用符の文字列では "\uFEFF" が BOM の 1 文字になります。BOM の文字をソースに直接書くと目に見えないので、エスケープで書きます。
Dir.mktmpdir にブロックを渡すと、一時ディレクトリを作ってブロックに渡し、ブロックを抜けるときに消してくれます。後始末を忘れようがない書き方で、Ruby ではファイルやロックなど「開いたら閉じる」ものにブロックを使うのが定石です。
assert_equal が Person の配列どうしをそのまま比べられるのは、Data.define の == がフィールドの値で比べるからです。
もし誰かが後で load_people を CSV.parse(File.read(...)) に書き換えたら、このテストは失敗します。ライブラリの親切に頼るときは、テストで確かめてから頼るのが安全です。
読み込みの実装¶
# CSV を読み込み、列名で値を取り出して人物のリストにする。
# CSV.foreach はファイルを開くときに BOM を取り除くので、ほかの言語版のような前処理は要らない。
def load_people(csv_file)
CSV.foreach(csv_file, headers: true).map do |row|
Person.new(
height: number(row, "身長"),
weight: number(row, "体重"),
age_group: number(row, "年代"),
faction: text(row, "派閥")
)
end
end
CSV.foreach にブロックを渡さずに呼ぶと、行を 1 つずつ返す Enumerator が返ります。それに map をつなげて、各行を Person に変えます。headers: true を付けると、1 行目が見出しとして扱われ、各行は列名で値を引ける CSV::Row になります。
列の取り出しは 2 段に分けます。
# 列名で数値を読む。整数として読めなければ ArgumentError を投げる。
def number(row, column)
cell = text(row, column)
Integer(cell, 10)
rescue ArgumentError
raise ArgumentError, "#{column} を数値として読めません: #{cell}"
end
# 列名でセルを読む。列が無ければ KeyError を投げる。
def text(row, column)
raise KeyError, "列がありません: #{column}" unless row.headers.include?(column)
row[column]
end
数値の変換に cell.to_i ではなく Integer(cell, 10) を使っているのが要点です。
"高い".to_iは0を返します。例外は出ません。身長 0 cm の人物が黙って紛れ込み、正解率がどこかでずれるだけですInteger("高い", 10)はArgumentErrorを投げます。第 2 引数の10は 10 進数として読む指定で、"010"のような値を 8 進数と解釈させないためです
to_i の寛容さは、スクリプトを手早く書くときには便利ですが、学習データの読み込みでは誤りを隠します。型の検査が無い言語ほど、入口で値を厳しく弾く必要があります。
rescue を def の直下に書くと、メソッド全体を begin ... rescue ... end で囲んだのと同じ意味になります。Integer が投げた素っ気ないメッセージの ArgumentError を受け止め、列名と値を入れたメッセージで投げ直しています。cell はメソッドの中の変数なので、rescue の中からも見えます。
列が無いときに row[column] は nil を返すだけで、例外は出ません。放っておくと Integer(nil, 10) で別の分かりにくいエラーになるので、text で先に列の有無を確かめて KeyError を投げます。
失敗の種類ごとにテストを書きます。
def test_数値でない値があれば読み込めない
Dir.mktmpdir do |dir|
path = File.join(dir, "people.csv")
File.write(path, "身長,体重,年代,派閥\n高い,60,20,きのこ\n")
error = assert_raises(ArgumentError) { C.load_people(path) }
assert_equal "身長 を数値として読めません: 高い", error.message
end
end
def test_列が無ければ読み込めない
Dir.mktmpdir do |dir|
path = File.join(dir, "people.csv")
File.write(path, "身長,体重,年代\n170,60,20\n")
error = assert_raises(KeyError) { C.load_people(path) }
assert_equal "列がありません: 派閥", error.message
end
end
assert_raises は、ブロックの中で指定の例外が投げられることを確かめ、その例外を返します。返った例外の message まで確かめるのは、Rust 版で unwrap_err() の to_string() を比べたのと同じ考え方です。Ruby ではメソッドが投げる例外をどこにも宣言しないので、このテストが事実上の宣言になります。
1.9 特徴量と正解ラベルに分ける¶
# 人物のリストを特徴量と正解ラベルに分ける。
def split_features_and_labels(people)
features = people.map do |person|
Features.new(height: person.height, weight: person.weight, age_group: person.age_group)
end
[features, people.map(&:faction)]
end
戻り値は 2 要素の配列で、呼び出し側は x, t = split_features_and_labels(people) と多重代入で受けます。Python 版のタプルのアンパックとほぼ同じ見た目です。
people.map(&:faction) は people.map { |person| person.faction } の短縮形です。&:faction はシンボルをブロックに変える書き方で、「各要素の faction メソッドを呼ぶ」という意味になります。
Rust 版では、ここで「借りるか、渡すか」「どこで clone() するか」を決める必要がありました。Ruby では配列もオブジェクトも参照で渡るので、その判断はありません。そのかわり、呼び出した先で配列を書き換えられても止める仕組みも無いので、書き換えないことは約束事になります。Data.define で作った Person と Features が不変なのは、その約束を型の側で少しでも担保するためです。
テストを書きます。
def test_人物のリストを特徴量と正解ラベルに分ける
people = [
C::Person.new(height: 170, weight: 60, age_group: 20, faction: C::KINOKO),
C::Person.new(height: 160, weight: 50, age_group: 30, faction: C::TAKENOKO)
]
x, t = C.split_features_and_labels(people)
assert_equal [features(170, 60, 20), features(160, 50, 30)], x
assert_equal [C::KINOKO, C::TAKENOKO], t
end
1.10 正解率を計算する¶
# 予測が正解ラベルと一致した割合を返す。件数が違えば ArgumentError を投げる。
def accuracy(predictions, labels)
unless predictions.size == labels.size
raise ArgumentError, "予測と正解ラベルの件数が違います: #{predictions.size} と #{labels.size}"
end
predictions.zip(labels).count { |prediction, label| prediction == label }.fdiv(labels.size)
end
zip で 2 つの配列を組にし、count にブロックを渡して一致した組を数え、fdiv で割ります。Rust 版の iter().zip().filter().count() とほぼ同じ流れを、1 行で書けます。
fdiv は浮動小数点数として割るメソッドです。Ruby の 14 / 19 は整数の割り算で 0 になります。Rust 版では correct as f64 と変換を必ず明示させられて、この事故が起きない仕組みでした。Ruby では型が教えてくれないので、fdiv を選ぶことと、テストで確かめることの 2 つで防ぎます。
テストは 3 つ書きます。
def test_全部当たれば正解率は一になる
assert_in_delta 1.0, C.accuracy([C::KINOKO, C::TAKENOKO], [C::KINOKO, C::TAKENOKO])
end
def test_半分当たれば正解率は零点五になる
assert_in_delta 0.5, C.accuracy([C::KINOKO, C::KINOKO], [C::KINOKO, C::TAKENOKO])
end
def test_件数が違えば正解率を求められない
error = assert_raises(ArgumentError) { C.accuracy([C::KINOKO], [C::KINOKO, C::TAKENOKO]) }
assert_equal "予測と正解ラベルの件数が違います: 1 と 2", error.message
end
浮動小数点数の比較には assert_in_delta(許容誤差つきの比較)を使います。1.0 と 0.5 は厳密に表せる値ですが、第 2 章以降で誤差の出る値を比べることを見越して、最初から許容誤差つきで書いておきます。
1.11 実データで正解率を表示する¶
データが無ければスキップする¶
実データを使うテストは別のファイルに置きます。
# frozen_string_literal: true
require "test_helper"
# 実データ(KvsT.csv)を使うテスト。学習データが無ければスキップする。
class KvsTDataTest < Minitest::Test
# module_function のモジュールを include すると、章の run が Minitest の run を上書きするので、
# include せずに定数と関数をモジュールから呼ぶ。
C = GettingStartedMl::Chapter01
def csv_file
path = File.join(GettingStartedMl::Dataset.dir, "KvsT.csv")
skip "学習データ KvsT.csv が配置されていない(gulp data:setup)のでスキップする" unless File.exist?(path)
path
end
def test_実データを読み込んで正解率を求める
people = C.load_people(csv_file)
assert_equal 19, people.size
x, t = C.split_features_and_labels(people)
predictions = x.map { |features| C.predict_by_rule(features) }
assert_in_delta 14.0 / 19, C.accuracy(predictions, t), 1e-12
end
end
Minitest には skip があります。呼ぶとそのテストはそこで止まり、失敗でも成功でもなく「スキップ」として数えられます。Rust 版では標準のテストにスキップが無く、早く戻って成功扱いにするしかありませんでした。Ruby 版では、結果の行にスキップの件数がそのまま出ます(1.12 節)。
csv_file の中で skip しているので、データが無ければ load_people まで進みません。テストの本体は「データがある」前提で素直に書けます。
期待値を 14.0 / 19 と書いているのは、「19 人中 14 人が当たる」という意味を残すためです。14.0 と小数点を付けているので、ここは浮動小数点数の割り算になります。
詰まった点: include したら Minitest が動かなくなった¶
コメントにある「include せずに」には、実際に詰まった経緯があります。
最初は、テストクラスで include GettingStartedMl::Chapter01 として、C. を付けずに predict_by_rule(...) と呼べるようにしていました。定数も KINOKO とそのまま書けて、見た目はすっきりします。ところが、章を実行する run メソッドを Chapter01 に足したところで、テストが 1 本も走らなくなりました。
private method `run' called for an instance of KvsTDataTest (NoMethodError)
原因は module_function の仕組みにあります。module_function は、各メソッドを「モジュールから直接呼べる関数」にすると同時に、プライベートなインスタンスメソッドとしても残します。そのモジュールを include すると、run がプライベートなインスタンスメソッドとしてテストクラスに入ります。
一方、Minitest は各テストを Minitest::Test#run というメソッドで実行します。include したモジュールは継承の探索順でスーパークラスより手前に入るので、章の run が Minitest の run を上書きしました。Minitest がテストを走らせようと run を呼ぶと、プライベートな章の run に当たって NoMethodError になったわけです。
直し方は、include をやめて C = GettingStartedMl::Chapter01 と別名を付け、C.predict_by_rule(...)・C::KINOKO とモジュール越しに呼ぶことです。モジュールの関数はテストクラスに混ざらないので、名前が衝突しません。
これは動的型付け言語の代償が目に見えた例です。
- Kotlin 版や Rust 版なら、同じ名前で別の意味のメソッドを持ち込めば、コンパイルの段階で重複や可視性の矛盾として止まります
- Ruby では、
includeもメソッドの定義も実行時に起こることなので、メソッド名の衝突は実行するまで検出できません。しかも壊れたのはrunを足した章のコードではなく、それと無関係なテストの仕組みの側でした
テストを書いていたから、run を足した直後に気づけました。型の検査を持たない Ruby 版にとって、テストは仕様の記録であると同時に、コンパイラが担うはずだった検査の代わりでもあります。型注釈(RBS・Steep)を入れれば一部は静的に捕まえられますが、本シリーズでは使いません。動的型付けのまま、何をテストで捕まえなければならないかを体験することが、Ruby 版の対比の軸だからです。
結果を表示する¶
# 実データでルールによる判定の正解率を表示する。
def run(out = $stdout)
people = load_people(File.join(Dataset.dir, "KvsT.csv"))
x, t = split_features_and_labels(people)
predictions = x.map { |features| predict_by_rule(features) }
out.puts "データ件数: #{people.size}"
out.puts format("ルールによる判定の正解率: %.4f", accuracy(predictions, t))
end
書き出し先を引数 out にして、既定値を標準出力 $stdout にしています。Rust 版の &mut impl std::io::Write と同じ設計ですが、Ruby では puts に応答するものなら何でも渡せます。テストでは StringIO を渡せば、出力を文字列として受け取れます。ここもダックタイピングです。
章を選んで実行するのは Rake のタスクです。
desc "章を選んで実行する(例: rake run[chapter01])"
task :run, [:chapter] do |_task, args|
$LOAD_PATH.unshift File.expand_path("lib", __dir__)
require "getting_started_ml"
GettingStartedMl.const_get(args.fetch(:chapter).capitalize).run
end
"chapter01".capitalize で "Chapter01" にし、const_get でその名前のモジュールを引いて run を呼びます。文字列からモジュールを引けるのは動的言語ならではで、章が増えてもタスクを書き換える必要がありません。Rust 版では match に章を 1 つずつ並べました。
実行します。
$ bundle exec rake 'run[chapter01]'
データ件数: 19
ルールによる判定の正解率: 0.7368
run[chapter01] を引用符で囲んでいるのは、zsh が [...] をファイル名のパターンとして展開しようとするためです。
正解率 0.7368 は、ほかの言語版と同じ値です。19 人中 14 人を正しく判定できました。乱数を使わない処理なので、言語が違っても値は一致します(第 2 章では、ここが崩れます)。
1.12 リファクタリング¶
整形と静的解析¶
RuboCop の設定は次のとおりです。
AllCops:
TargetRubyVersion: 3.3
NewCops: enable
SuggestExtensions: false
Exclude:
- "vendor/**/*"
# テストのメソッド名に日本語を使うので、ASCII 以外の識別子を許す。
Naming/AsciiIdentifiers:
Enabled: false
Style/StringLiterals:
EnforcedStyle: double_quotes
# テストのメソッド名は日本語で振る舞いを書くので、snake_case を求めない。
Naming/MethodName:
Exclude:
- "test/**/*"
既定のままだと、日本語のテストメソッド名が 2 つのルールに引っかかります。
Naming/AsciiIdentifiersは識別子に ASCII 以外の文字を使うことを咎めます。日本語のメソッド名を許すため、無効にしましたNaming/MethodNameはメソッド名に snake_case を求めます。test_BOM_付きの_CSV_を列名で読み込むのような名前が引っかかるので、test/の中だけ除外しました。lib/の中は snake_case のままです
NewCops: enable は、RuboCop の版を上げたときに増えた新しいルールを有効にする指定です。書かないと、新しいルールごとに「有効にするか決めてください」という警告が大量に出ます。vendor/ を除外しているのは、vendor/bundle に入った gem のコードまで検査しないためです。
わざと崩すとどうなるかを確かめました。使っていない変数を足すと Lint/UselessAssignment、文字列を単引用符で書くと Style/StringLiterals が検出され、rake check が失敗します。rake check は RuboCop → テストの順なので、RuboCop が失敗するとテストまで進みません。 警告を残したまま先へ進めないのは、Rust 版の -D warnings と同じ考え方です。
RuboCop は、1.11 節の run の衝突は検出できません。include とメソッドの定義は、どちらもそれだけ見れば正しい Ruby だからです。静的解析で捕まえられるのは「書き方」までで、「実行したら何と何がぶつかるか」はテストの領分です。
まとめて検査する¶
Rakefile の検査のタスクは次のとおりです。
Minitest::TestTask.create(:test) do |task|
task.libs << "lib" << "test"
task.test_globs = ["test/**/*_test.rb"]
end
RuboCop::RakeTask.new(:rubocop)
desc "整形・静的解析・テスト(カバレッジつき)をまとめて実行する"
task check: %i[rubocop test]
task default: :check
task check: %i[rubocop test] は、check が rubocop と test に依存するという宣言で、書いた順に実行されます。%i[...] はシンボルの配列を作る書き方です。
bundle exec rake check
bundle exec を付けるのは、Gemfile.lock に書かれた版の gem で実行するためです。付けないと、たまたま入っている別の版の RuboCop や Minitest が使われることがあります。
カバレッジ¶
学習データがある状態で bundle exec rake check を実行すると、テストは 13 runs・21 assertions・0 skips で全部通り、SimpleCov の行カバレッジは 40 / 45 行(88.88%)でした。
学習データを外す(ML_DATA_DIR=/nonexist bundle exec rake check)と、実データのテストがスキップされ、13 runs・19 assertions・1 skips になります。
| 学習データ | runs | assertions | skips |
|---|---|---|---|
| あり | 13 | 21 | 0 |
| なし | 13 | 19 | 1 |
スキップが 1 件として数えられています。 Rust 版では、データが無くても実データのテストが成功として数えられてしまうため、本当に走ったかをカバレッジの差で確かめる必要がありました。Minitest では結果の行を見るだけで分かります。アサーションの件数が 21 から 19 に減っているのも、実データのテストの 2 つのアサーションが走らなかった分です。
カバレッジは行単位で、SimpleCov が coverage/ に HTML の報告を書き出します。test_helper.rb で test/ と vendor/ を集計から外しているので、数字は lib/ のコードだけの割合です。
1.13 まとめ¶
この章では、ルールによる判定と正解率を TDD で実装し、実データで 0.7368 という値を得ました。Ruby に固有の論点は次のとおりです。
- Red は実行時エラー — Python 版と同じく、無いものを呼んだことはテストを走らせて初めて分かる。Rust 版・Kotlin 版のようにコンパイルでは止まらない
- 失敗は標準の例外で表す —
ArgumentError・KeyErrorを投げ、どの例外を投げうるかはassert_raisesのテストで固定する。数値の変換はto_iではなくInteger(cell, 10)で、不正な値を黙って 0 にしない Data.defineで値オブジェクトを作る — 不変で、==が値で比べ、引数の過不足をArgumentErrorで止める。型を宣言しない言語での貴重な早期の検査- BOM は読み方しだい —
CSV.foreach・CSV.readなら取り除かれ、CSV.parse(File.read(...))では残る。前提はテストで固定する - メソッド名の衝突は実行するまで分からない —
module_functionのモジュールをincludeしたら、章のrunがMinitest::Test#runを上書きした。型の検査を持たない Ruby では、テストがコンパイラの代わりを担う - スキップがある — 実データのテストは Minitest の
skipで飛ばし、結果の行にスキップの件数が出る
TODO リスト(この章の完了時点):
- CSV を読み込む
- 特徴量と正解ラベルに分ける
- ルールで派閥を判定する
- 正解率を計算する
- 実データで正解率を表示する
次の章では、アヤメのデータを読み込み、欠損値を補完して、訓練データとテストデータに分けます。そして、乱数の実装が言語ごとに違うことが、ここではじめて数値の不一致として現れます。