Skip to content

第 1 章: 機械学習とはじめてのテスト

1.1 はじめに

この章では、機械学習とは何かを確認したうえで、テスト駆動開発(TDD)で最初のプログラムを Haskell で作ります。題材は「きのこの山派か、たけのこの里派か」を身長・体重・年代から判定する問題です。

この章ではまだ機械学習のアルゴリズムを使いません。人間が考えたルールで判定し、その正解率を測るところまで進みます。

Python 版の第 1 章 と同じ題材・同じ TODO リストで進めます。Haskell 版では次の 3 つと対比します。1 つは Rust 版 と F# 版 で、失敗を例外ではなく型で表す点です。2 つめは Elixir 版・Clojure 版 で、純粋関数でデータを変換していく流儀が近いところです。3 つめは PHP 版・Ruby 版 で、例外を投げる言語との正反対の設計です(ADR 014)。

Haskell 版はシリーズの最後の言語版です。機械学習のライブラリが無いので、アルゴリズムはすべて自作になります。突き合わせられるのは線形代数の層(第 7・12・13 章の hmatrix)だけで、これは Elixir 版(Scholar に決定木が無い)よりさらに狭い範囲です。

1.2 機械学習とは

ルールを書くか、データに学ばせるか

従来のプログラミングでは、人間が判定のルールをコードとして書きます。

predictByRule :: Features -> Faction
predictByRule features
  | featuresAgeGroup features == kinokoAgeGroup = Kinoko
  | otherwise = Takenoko

この書き方では、ルールの良し悪しは人間の観察力に依存します。特徴量が 3 つなら何とかなりますが、20 個・100 個になると人間には手に負えません。

機械学習は、この「ルール」をデータから自動で作ります。人間が与えるのは「入力(特徴量)」と「正解(ラベル)」の組で、ルールそのものはアルゴリズムが決めます。

従来のプログラミング 機械学習
人間が書くもの ルール データと、学習のさせ方
出力 判定結果 ルール(モデル)と、それを使った判定結果
得意なこと 仕様がはっきりしている問題 仕様を言葉にしにくい問題
説明のしやすさ コードを読めば分かる モデルによる(決定木は読める、ニューラルネットは難しい)

機械学習のワークフロー

本シリーズを通して、次の流れを繰り返します。

  1. データを集める・読み込む — この章で CSV を読み込みます
  2. 前処理する — 欠損値を埋め、訓練データとテストデータに分けます(第 2 章)
  3. モデルを学習させる — 決定木・回帰などを自作します(第 3 章以降)
  4. 評価する — 正解率などの指標で測ります(この章で正解率から始めます)
  5. 改善する — 特徴量を作り直し、ハイパーパラメータを調整します(第 9 章以降)

分類と回帰

教師あり学習は、予測するものの型で 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(バイトオーダーマーク)が付いていることに注意してください。cassava も BOM を取り除きません。 後で確かめます。

1.4 TODO リストの作成

仕様をそのままコードにするには大きすぎるので、まず TODO リストに分解します。

TODO リスト

何をテストすべきだろうか——着手する前に、必要になりそうなテストをリストに書き出しておこう。

— テスト駆動開発

TODO リスト:

  • CSV を読み込む
  • BOM 付き CSV を列名で読み込む
  • 数値でない値・列の不足を弾く
  • 特徴量と正解ラベルに分ける
  • ルールで派閥を判定する
  • 20 代ならきのこ派と判定する
  • 20 代以外ならたけのこ派と判定する
  • 正解率を計算する
  • 実データで正解率を表示する

1.5 開発環境の準備

プロジェクトの構成

apps/haskell/ に cabal のプロジェクトを作ります。

apps/haskell/
├── getting-started-ml.cabal
├── cabal.project
├── fourmolu.yaml
├── .hlint.yaml
├── src/
│   └── GettingStartedMl/
│       ├── Chapter01.hs
│       ├── Csv.hs
│       └── Dataset.hs
├── test/
│   ├── Spec.hs
│   └── GettingStartedMl/
│       ├── Chapter01Spec.hs
│       ├── CsvSpec.hs
│       └── DatasetSpec.hs
└── tools/
    └── coverage-threshold.sh

getting-started-ml.cabal はこうします。

cabal-version:      2.4
name:               getting-started-ml
version:            0.1.0.0

common warnings
    -- 網羅していないパターンマッチを、テストではなくコンパイルで捕まえる。
    ghc-options: -Wall -Werror

library
    import:           warnings
    exposed-modules:  GettingStartedMl.Chapter01
                    , GettingStartedMl.Csv
                    , GettingStartedMl.Dataset
    hs-source-dirs:   src
    build-depends:    base >=4.17 && <5
                    , bytestring
                    , cassava
                    , containers
                    , directory
                    , filepath
                    , text
                    , unordered-containers
                    , vector
    default-language: Haskell2010

test-suite spec
    import:           warnings
    type:             exitcode-stdio-1.0
    main-is:          Spec.hs
    other-modules:    GettingStartedMl.Chapter01Spec
                    , GettingStartedMl.CsvSpec
                    , GettingStartedMl.DatasetSpec
    hs-source-dirs:   test
    build-depends:    base
                    , getting-started-ml
                    , hspec
                    , bytestring
                    , cassava
                    , containers
                    , text
                    , vector
                    , directory
    build-tool-depends: hspec-discover:hspec-discover
    default-language: Haskell2010

最初に 2 つ、つまずきやすい点があります。

1 つめは -Wall -Werror を common に書いて両方から取り込んでいることです。この版では警告をエラーとして扱います。網羅していないパターンマッチや使っていない束縛を、テストではなくコンパイルで捕まえるためです。

2 つめは テストスイートの build-depends をライブラリと別に書く必要があることです。ライブラリに bytestring を書いても、テストからは見えません。「Could not load module 'Data.ByteString.Lazy'. It is a member of the hidden package」というエラーが出たら、テスト側に足し忘れています。

test/Spec.hs は 1 行だけです。

{-# OPTIONS_GHC -F -pgmF hspec-discover #-}

これで hspec-discover が test/ 以下の *Spec.hs を集めて、spec という名前の値を実行してくれます。テストファイルを足すたびに登録する手間が要りません(ただし other-modules には書きます)。

Nix 環境と、道具が見えていた落とし穴

環境の準備で、PHP 版 と同じことが起きました。整形の道具(fourmolu)も静的解析の道具(hlint)も、Nix の環境に入っていなかったのです。

nix develop .#haskell
which fourmolu hlint
/usr/local/bin/fourmolu
/usr/local/bin/hlint

/usr/local/bin は Nix の外、つまり手元の機械に入っているものです。これが見えていると、検査が通るかどうかが機械ごとに変わります。版が違えば整形の結果も変わります。環境を切り替える意味がありません。

ops/nix/environments/haskell/shell.nix に足しました。

buildInputs = with packages; [
  ghc
  stack
  cabal-install
  haskell-language-server
  # 整形・静的解析・テストの道具。素の環境には無く、手元の PATH のものが
  # 見えてしまっていたので、環境の側で版を固定する。
  haskellPackages.fourmolu
  haskellPackages.hlint
  haskellPackages.hspec-discover
  # hmatrix は BLAS/LAPACK の C ライブラリを要求する。
  openblas
  pkg-config
];

openblas は第 7 章で使う hmatrix のためです。hmatrix は BLAS と LAPACK という C のライブラリを要求し、素の環境では configure の段階で止まります。

Error: [Cabal-4345]
Missing dependencies on foreign libraries:
* Missing (or bad) C libraries: blas, lapack

shellHook で場所を伝えると通ります。

export LIBRARY_PATH="${packages.openblas}/lib:$LIBRARY_PATH"
export PKG_CONFIG_PATH="${packages.openblas.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"

C ライブラリへの依存を環境に足すかどうかは、判断の要る選択です。 Elixir 版 では EXLA(巨大な XLA のバイナリ)を「記事の『動かしてみる』の敷居が上がる」という理由で避けました。今回は足す側に倒しています。BLAS と LAPACK は数値計算の標準的な土台で Nix でも軽く入ること、そして足さなければ突き合わせる相手が 1 つも無くなることが理由です(ADR 014)。

最初のテストを走らせる

cd apps/haskell
cabal build
cabal test --test-show-details=direct
GettingStartedMl.Chapter01
  ルールによる判定
    二十代はきのこ派と判定する [✔]

Hspec のテスト名には日本語をそのまま書けます。 it "二十代はきのこ派と判定する" のように、確かめたいことを文として書きます。

1.6 派閥を型で表す

取りうる値を数え上げる

まず「派閥」をどう表すかを決めます。文字列で持つこともできますが、この版では型で数え上げます。

-- | 派閥。取りうる値を型で数え上げるので、綴りの間違いはコンパイルで止まる。
data Faction
  = Kinoko
  | Takenoko
  deriving (Eq, Show)

これは代数的データ型(ADT)で、Rust 版 の enum、F# 版 の判別共用体、Scala 版 の enum にあたります。

文字列で持つ版(Ruby 版・Elixir 版・PHP 版)と比べたときの違いは 2 つあります。

1 つは、綴りを間違えるとコンパイルが止まることです。Kinoco と書けばその場で分かります。文字列なら実行してみるまで分かりません。

もう 1 つは、場合分けの網羅をコンパイラが確かめてくれることです。-Wall -Werror を入れてあるので、Kinoko の場合だけ書いて Takenoko を忘れると、警告がエラーになって止まります。これはPHP 版の共用型(Leaf|Node)が「網羅性までは強制しない」と書いたところの、ちょうど強い版です。

レコードでデータを表す

-- | 学習データ KvsT.csv の 1 行。身長・体重・年代と、正解ラベルの派閥を持つ。
data Person = Person
  { personHeight :: Int
  , personWeight :: Int
  , personAgeGroup :: Int
  , personFaction :: Faction
  }
  deriving (Eq, Show)

-- | 判定の手がかりになる特徴量。正解ラベルを持たない。
data Features = Features
  { featuresHeight :: Int
  , featuresWeight :: Int
  , featuresAgeGroup :: Int
  }
  deriving (Eq, Show)

フィールド名に接頭辞(person・features)を付けているのは、Haskell のレコードのフィールド名がモジュール全体で 1 つの関数になるからです。Person と Features の両方に height と書くと、名前がぶつかってコンパイルが通りません(DuplicateRecordFields を使えば書けますが、この版では素直に接頭辞を付けます)。

deriving (Eq, Show) で、値の比較と表示が自動で作られます。中身で比較されるので、テストで shouldBe がそのまま使えます。PHP 版が assertEquals(中身)と assertSame(同一性)を使い分けたのに対して、Haskell では区別そのものがありません。値に同一性という概念が無いからです。

1.7 ルールで派閥を判定する

Red: まだ無いものを呼ぶ

TDD の最初の一歩は、まだ存在しないものを呼ぶテストです。

テスト駆動開発は次の 3 つのステップで進む。

  1. レッド: 動作しないテストを 1 つ書く。おそらくは最初、コンパイルできない
  2. グリーン: そのテストを迅速に動作させる。そのためには罪を犯してもよい
  3. リファクタリング: テストを通すために発生した重複をすべて除去する

— テスト駆動開発

spec :: Spec
spec = do
  describe "ルールによる判定" $ do
    it "二十代はきのこ派と判定する" $
      predictByRule (Features 170 60 20) `shouldBe` Kinoko

「おそらくは最初、コンパイルできない」という但し書きが、Haskell ではそのまま当てはまります。関数が無いので型検査の段階で止まります。

Green: ガードで書く

-- | 人間が決めたルールで派閥を判定する。
predictByRule :: Features -> Faction
predictByRule features
  | featuresAgeGroup features == kinokoAgeGroup = Kinoko
  | otherwise = Takenoko

| で始まる行はガードです。条件を上から試して、最初に真になったものの右辺を返します。if ... then ... else でも書けますが、条件が増えたときに素直に並べられるのでガードを使います。

三角測量: 20 代以外も確かめる

    it "二十代以外はたけのこ派と判定する" $
      map (predictByRule . Features 170 60) [10, 30, 40, 50]
        `shouldBe` [Takenoko, Takenoko, Takenoko, Takenoko]

三角測量では、2 つ以上の例があるときのみ、一般化を行う。

— テスト駆動開発

predictByRule . Features 170 60 という書き方に触れておきます。Features は 3 引数のコンストラクタですが、2 つだけ渡すと「残り 1 つを受け取る関数」になります(部分適用)。それを . で predictByRule とつなぐと、「年代を受け取って派閥を返す関数」ができます。

最初は \age -> predictByRule (Features 170 60 age) と書いていました。hlint がこれを指摘しました。

Warning: Avoid lambda
Found:
  \ age -> predictByRule (Features 170 60 age)
Perhaps:
  predictByRule . Features 170 60

静的解析が「同じ意味でもっと短く書ける」と教えてくれる例です。指摘に従うかどうかは場合によりますが、ここは素直なので従いました。

1.8 失敗を型で表す

正解率を計算するとき、予測と正解ラベルの件数が違ったらどうするか。この版では例外を投げません。型で表します。

-- | 予測が正解ラベルと一致した割合を返す。件数が違えば 'Left' を返す。
--
-- 「同じかどうかを比べられる」ことだけが要るので、派閥に固定せず Eq の型クラスで書く。
accuracy :: (Eq a) => [a] -> [a] -> Either String Double
accuracy predictions labels
  | length predictions /= length labels =
      Left (printf "予測と正解ラベルの件数が違います: %d と %d" (length predictions) (length labels))
  | null labels = Left "正解ラベルがありません"
  | otherwise =
      Right (fromIntegral hits / fromIntegral (length labels))
  where
    hits = length (filter id (zipWith (==) predictions labels))

戻り値の型が Double ではなく Either String Double になっています。「String の失敗か、Double の成功か、どちらか」という意味です。

引数の型が [Faction] ではなく (Eq a) => [a] になっている点にも触れておきます。この関数に必要なのは「2 つの値が同じかどうかを比べられる」ことだけで、それが派閥である必要はありません。Eq という型クラス(「等しいかを比べられる型」の集まり)で条件だけを書いておくと、第 3 章で決定木の予測(アヤメの品種)を測るときにも同じ関数が使えます。型クラスは第 3 章で改めて扱います。

    it "件数が違えば正解率を求められない" $
      accuracy [Kinoko] [Kinoko, Takenoko]
        `shouldBe` Left "予測と正解ラベルの件数が違います: 1 と 2"

テストが「例外が飛ぶこと」ではなく「どんな値が返るか」を確かめている点に注目してください。失敗は特別な仕組みではなく、ただの値です。

言語 失敗の表し方 呼ぶ側が無視できるか
Haskell Either String a できない(型が違うので使えない)
Rust Result<T, E> できない(#[must_use])
F# Result<'T, 'TError> できない
Go error の戻り値 できる(_ で捨てられる)
Ruby・Elixir・PHP・Python 例外 できる(捕まえなければ落ちる)

Haskell と Rust と F# は「失敗を無視できない」側です。Either String Double を Double として使おうとすれば型が合わず、コンパイルが止まります。

失敗を連ねる

CSV の 1 行を Person にするとき、4 つの列すべてが読めて初めて成功します。どれか 1 つでも失敗したら全体が失敗です。

-- | 1 行を人物にする。どれか 1 つでも失敗すれば全体が 'Left' になる。
toPerson :: Row -> Either String Person
toPerson row =
  Person
    <$> number row "身長"
    <*> number row "体重"
    <*> number row "年代"
    <*> (column row "派閥" >>= parseFaction)

<$> と <*> は、「包みの中の値に関数を適用する」ための演算子です。ここでは Either が包みで、どれか 1 つでも Left なら、最初の Left がそのまま全体の答えになります。if を 4 回書く代わりに、演算子でつなぐだけで「どれか 1 つでも失敗したら失敗」が表せます。

Rust の ? 演算子が同じ役割を果たしますが、あちらは早期リターン、こちらは式の合成という違いがあります。

リストに対しても同じことができます。

-- | CSV を読み込み、列名で値を取り出して人物のリストにする。
loadPeople :: BL.ByteString -> Either String [Person]
loadPeople contents = parseRows contents >>= traverse toPerson

traverse toPerson は「リストの各要素に toPerson を適用し、全部成功したら結果のリストを、1 つでも失敗したら最初の失敗を返す」という意味です。map してから失敗を集める、という 2 段階が 1 つの関数になっています。

1.9 CSV を読み込む

cassava も BOM を取り除かない

KvsT.csv の先頭には BOM が付いています。cassava はこれを取り除きません。確かめてみます。

print (headerOf csv)
Right ["\239\187\191shincho","taijyu"]

先頭の列名に BOM の 3 バイト(\239\187\191)が残っています。cassava は値をバイト列として返すので、ここでは UTF-8 の符号化そのものが見えています。Go 版・Clojure 版・Elixir 版・PHP 版 と同じ落とし穴です。

自分で取り除きます。

-- | UTF-8 の BOM。記事とコードに実物を混ぜないようバイト列で書く。
bom :: BS.ByteString
bom = BS.pack [0xEF, 0xBB, 0xBF]

-- | 先頭の BOM を取り除く。
stripBom :: BL.ByteString -> BL.ByteString
stripBom contents
  | BL.fromStrict bom `BL.isPrefixOf` contents = BL.drop 3 contents
  | otherwise = contents

この章いちばんの落とし穴: ByteString のリテラルに日本語を書くと壊れる

ここで、Haskell を使ううえで避けて通れない問題にぶつかります。最初、列名の比較をこう書いていました。

-- これは動かない。
column :: Row -> BS.ByteString -> Either String BS.ByteString
column row name =
  case HM.lookup name row of
    Nothing -> Left ("列がありません: " <> BSC.unpack name)
    Just value -> Right value

テストはこう落ちました。

expected: Right [Left "列がありません: 体重"]
 but got: Right [Left "列がありません: SÍ"]

原因は 2 つ重なっています。

1 つめ。Data.ByteString.Char8 の unpack は、1 バイトを 1 文字として読みます。 UTF-8 の「体重」は 6 バイトですが、これを 6 文字として解釈するので化けます。Char8 という名前のとおり、このモジュールは「文字が 1 バイトに収まる」前提で書かれています。ASCII 専用だと思ってください。

2 つめ。OverloadedStrings を使っているとき、ByteString のリテラルに日本語を書くと、同じように 1 文字が 1 バイトに詰められます。 つまり "体重" :: ByteString は、UTF-8 の 6 バイトではなく、壊れた 2 バイトになります。

expected: Right [Right "170"]
 but got: Right [Left "列がありません: 身長"]

エラーメッセージは正しく表示されているのに列が見つからない、という不可解な状態になりました。メッセージを作る側は直したが、探している列名のほうが壊れていたのです。

対処は、境界でだけ符号化・復号することです。

-- | 列名で値を読む。列が無ければ 'Left' を返す。
column :: Row -> Text -> Either String Text
column row name =
  case HM.lookup (TE.encodeUtf8 name) row of
    Nothing -> Left ("列がありません: " <> T.unpack name)
    Just value -> Right (TE.decodeUtf8Lenient (trim value))

列名と値は Text で扱い、cassava に渡すときだけ encodeUtf8 でバイト列にします。Text は文字の列を表す型で、リテラルに日本語を書いても壊れません。

テストのデータを作るときも同じ罠があります。

-- | UTF-8 の CSV をテストのために組み立てる。
--
-- 'Data.ByteString.Lazy.Char8' の @pack@ は 1 文字を 1 バイトに詰めるので、
-- 日本語をそのまま渡すと壊れたバイト列になる。テストのデータでも必ず
-- UTF-8 として符号化する。
utf8Csv :: T.Text -> BL.ByteString
utf8Csv = BL.fromStrict . TE.encodeUtf8

同じ間違いを、実装で 1 回、テストで 1 回踏みました。 型が ByteString であることは合っていて、中身の解釈だけが違うので、型検査では捕まりません。「バイト列」と「文字列」を区別する言語では、どこで変換するかを決めておく必要があります。

ちなみに、BOM をテストで書くときにも落とし穴があります。

-- 誤り: Text では U+00EF U+00BB U+00BF の 3 文字になる
let csv = utf8Csv "\239\187\191身長,体重\n170,60\n"

-- 正しい: BOM は U+FEFF の 1 文字
let csv = utf8Csv "\65279身長,体重\n170,60\n"

\239\187\191 は「UTF-8 で符号化したときのバイト列」で、Text のリテラルとしては別物です。Text に書くなら U+FEFF(10 進で 65279)の 1 文字です。バイト列と文字列を行き来するときは、どちらの世界の話をしているかを毎回確かめる必要があります。

数値を読む

-- | 列名で整数を読む。読めなければ 'Left' を返す。
number :: Row -> Text -> Either String Int
number row name = do
  cell <- column row name
  case BSC.readInt (TE.encodeUtf8 cell) of
    Just (value, rest) | BS.null rest -> Right value
    _ -> Left (T.unpack name <> " を数値として読めません: " <> T.unpack cell)

BSC.readInt は「読めた整数と、残りのバイト列」を返します。BS.null rest で残りが空であることを確かめているのが大事です。これを忘れると "170cm" が 170 として通ってしまいます。テストで確かめます。

    it "途中までしか数値でない値も弾く" $ do
      let csv = utf8Csv "身長\n170cm\n"
      fmap (fmap (`number` "身長")) (parseRows csv)
        `shouldBe` Right [Left "身長 を数値として読めません: 170cm"]

PHP 版が (int) '170cm' を 170 にしてしまう (int) キャストを避けて filter_var を選んだのと、同じ判断です。緩い変換が用意されていても、厳しいほうを選びます。

do 記法を使っている点にも触れておきます。Either のような「失敗するかもしれない計算」を順に並べるとき、do で書くと途中の失敗が自動的に全体の失敗になります。命令型の見た目ですが、実際には >>= の連なりです。

1.10 実データで正解率を表示する

IO を境界に閉じ込める

実データを読むには、ファイルを読む必要があります。ファイルの読み込みは副作用なので、Haskell では型に現れます。

-- | 実データでルールによる判定の正解率を表示する。
--
-- ファイルを読むところだけが 'IO' で、そこから先は純粋関数になっている。
run :: IO (Either String String)
run = do
  file <- Dataset.path "KvsT.csv"
  contents <- BL.readFile file
  pure (report contents)

-- | 読み込んだ中身から報告の文字列を作る。純粋関数なのでテストで確かめられる。
report :: BL.ByteString -> Either String String
report contents = do
  people <- loadPeople contents
  let (x, t) = splitFeaturesAndLabels people
  rate <- accuracy (map predictByRule x) t
  pure (printf "データ件数: %d\nルールによる判定の正解率: %.4f\n" (length people) rate)

run と report を分けているのがこの節の要点です。run は IO の中にいてファイルを読みますが、report は純粋関数です。バイト列を受け取って文字列を返すだけで、外の世界に触れません。

この分け方には実利があります。report はテストで直接確かめられます。 ファイルを用意する必要も、後片付けも要りません。PHP 版が「echo せずに文字列を返す」ことでテスト可能にしたのと同じ考え方ですが、Haskell では型がその区別を強制します。report の中で誤ってファイルを読もうとすれば、型が合わずコンパイルが止まります。

本シリーズを通して、この構造は変わりません。データの読み込みだけが IO で、前処理も学習も評価もすべて純粋関数です。 第 15 章で API を書くときに、この分け方がそのまま層の分離になります。

データが無ければテストを外す

実データは配布物なのでリポジトリに含まれません。CI にも置きません。

  describe "実データ" $ do
    it "ルールによる判定の正解率がほかの言語版と一致する" $ do
      -- 学習データは配布物なのでリポジトリに無い。無ければこのテストは外す。
      found <- Dataset.exists "KvsT.csv"
      unless found $ pendingWith "学習データがありません"
      actual <- run
      actual `shouldBe` Right "データ件数: 19\nルールによる判定の正解率: 0.7368\n"

Hspec では pendingWith で「保留」にします。

ML_DATA_DIR=/nonexistent cabal test --test-show-details=direct
      # PENDING: 学習データがありません
23 examples, 0 failures, 1 pending

保留の数がはっきり表示されます。 PHP 版の Skipped: 34、Elixir 版の「1 excluded」と同じで、「テストが減っていることに気づかない」事故を避けられます。

結果

cabal repl lib:getting-started-ml
データ件数: 19
ルールによる判定の正解率: 0.7368

0.7368 は Python 版 をはじめとするほかの 13 言語版とすべて一致します。 19 人のうち 14 人を正しく判定できた、ということです。

1.11 リファクタリング

整形

fourmolu で整形を検査します。

fourmolu --mode check src test

違反があるときの終了コードは 100 です。 1 でも 8 でもありません。

言語版 整形の道具 違反の終了コード
Haskell fourmolu 100
PHP PHP-CS-Fixer 8
Elixir mix format 1
Rust cargo fmt 1

終了コードの癖は、壊してみるまで分かりません。CI で「失敗したのに気づかない」ことを避けるために、この版でも 4 つの検査すべてを実際に壊して確かめました。

静的解析

hlint は「もっと短く書ける」を指摘します。

hlint src test
No hints

指摘があると終了コードは 1 です。

加えて、GHC 自身の -Wall -Werror があります。hlint が「書き方」を、GHC が「型と網羅性」を見ます。 守備範囲が違うので両方走らせます。PHP 版で PHP-CS-Fixer と PHPStan が分かれていたのと同じ構図です。

カバレッジ——ここでも自分で判定する

GHC には HPC というカバレッジの仕組みが組み込まれています。

cabal test --enable-coverage

そして、cabal にも「カバレッジが N% を下回ったら失敗させる」機能がありません。 PHP 版と同じ状況です。HPC が出す HTML の総計を読んで判定するスクリプトを書きました。

./tools/coverage-threshold.sh 80
式カバレッジ: 98% (242/246), しきい値: 80%

しきい値を割ると終了コード 3 で落ちます。この数字に決まりはなく、プロジェクトの約束です。

式カバレッジ: 98% (242/246), しきい値: 99%
カバレッジがしきい値を下回りました: 98% < 99%

第 3 波で 3 回続けて、カバレッジの仕組みに手を入れることになりました。 Elixir には既定のしきい値があり、PHP にはドライバすら無く、Haskell には仕組みはあるがしきい値が無い。「カバレッジをどう測り、どこで失敗させるか」は、言語が決めてくれることもあれば、プロジェクトが決めることもあります。

まとめて検査する

リポジトリのルートから次で走ります。

npx gulp apps:check:haskell
式カバレッジ: 98% (242/246), しきい値: 80%

4 つとも、わざと壊して落ちることを確かめました。

検査 違反したときの終了コード
fourmolu(整形) 100
hlint(静的解析) 1
cabal test(テスト) 1
カバレッジのしきい値(自作) 3(自分で決めた)

1.12 まとめ

この章では、ルールによる判定と正解率を TDD で実装し、実データで 0.7368 という値を得ました。Haskell に固有の論点は次のとおりです。

  1. 失敗を型で表す — Either String a を返す。呼ぶ側は無視できず、テストは「返る値」を確かめる。例外を投げる Ruby 版・Elixir 版・PHP 版とは正反対で、Rust 版・F# 版と同じ流儀
  2. 失敗は演算子で連ねられる — <$>・<*> で「どれか 1 つでも失敗したら失敗」が書け、traverse でリスト全体に広げられる
  3. 取りうる値を型で数え上げる — data Faction = Kinoko | Takenoko と書けば、綴りの間違いも場合分けの漏れもコンパイルで止まる(-Wall -Werror)
  4. ByteString のリテラルに日本語を書くと壊れる — OverloadedStrings も Char8.unpack も 1 文字を 1 バイトとして扱う。実装で 1 回、テストで 1 回、同じ間違いを踏んだ。列名と値は Text で持ち、境界でだけ符号化・復号する
  5. BOM を書くときはバイト列か文字列かを区別する — Text のリテラルでは U+FEFF の 1 文字(\65279)。\239\187\191 は別物
  6. IO を境界に閉じ込める — ファイルを読む run と、純粋関数の report を分ける。分けたことが型で保証され、report はテストで直接確かめられる
  7. readInt は残りを確かめる — BS.null rest を忘れると "170cm" が 170 として通る
  8. テストスイートの依存はライブラリと別に書く — ライブラリに書いた build-depends はテストから見えない
  9. 終了コードの癖は壊してみるまで分からない — fourmolu は 100、hlint は 1、cabal test は 1
  10. 道具は環境の側で固定する — fourmolu も hlint も素の Nix 環境に無く、手元の /usr/local/bin が見えていた(PHP 版の pcov と同じ形)

TODO リスト(この章の完了時点):

  • CSV を読み込む
  • 特徴量と正解ラベルに分ける
  • ルールで派閥を判定する
  • 正解率を計算する
  • 実データで正解率を表示する

次の章では、アヤメのデータを読み込み、欠損値を補完して、訓練データとテストデータに分けます。Haskell 版も java.util.Random と同じ線形合同法を自作します。PHP 版では「整数が溢れると float に化ける」ために乗算を分割する必要がありましたが、Haskell の Int は Java と同じく折り返すので、仕様をそのまま書けます。

Haskell 版のほかの章は Haskell 版のトップ から辿れます。