Skip to content

第 11 章: 評価指標と交差検証

11.1 はじめに

ここまでの章では、モデルの良し悪しを 正解率 ひとつで測ってきました。第 3 章の決定木も、第 10 章のロジスティック回帰もランダムフォレストも、「テストデータの何割を当てたか」で比べています。

しかし正解率は、どちらを間違えたか を隠します。生存者を見逃したのか、亡くなった人を生存と言ったのか、数字は区別しません。この章では、その内訳を見るための指標(混同行列・適合率・再現率・F 値・ROC 曲線と AUC)を自作します。

もうひとつの主題は 交差検証 です。第 2 章から使ってきた「1 回だけ訓練データとテストデータに分ける」やり方は、たまたまの分け方に結果が左右されます。データを K 個に分け、順番にテストデータの役を回す K 分割交差検証で、その振れ幅を減らします。

Haskell 版の第 11 章 と同じ TODO リストで進め、分類器・前処理・分割の乱数と余りの配り方をそろえます。なでしこ3 版では、次の 4 点に注目してください。

  • 評価指標の命令も交差検証の命令もありません。 ここで作るものがそのまま最終実装です(ADR 015)。Haskell 版と同じく、自作の妥当性は「値の分かっている入力」「満たすべき性質」「ほかの言語版の数値」の 3 つで支えます
  • 評価関数も学習器も、無名関数で渡します。 関数(正解一覧, 予測一覧) を引数として渡し、関数を返す関数も書けます。ただし、名前を付けて定義した関数は値として渡せません。 名前を書いた場所でその場で呼ばれてしまうので、無名関数で包んでから渡します
  • 辞書の鍵にした数値は文字列に変わります。 Haskell 版は ROC 曲線で同じスコアをまとめるのに Double を鍵にした Map を使いました。なでしこ3 の辞書で同じことをすると、鍵は文字列になり、数値の 1 と文字列の「1」が同じ鍵になります。そこで、並べてから同じスコアの続く範囲をまとめる形にしました
  • 交差検証はモデルを何度も学習します。 命令数の上限(1 回の実行で 2 億)に最も当たりやすい章ですが、第 3 章の決定木を軽くしてあったおかげで、書き直さずに章の実行が約 1945 万命令に収まりました

データは Survived.csv(891 件)と cinema.csv(100 件)を使います。

11.2 正解率だけでは足りない理由

100 人のうち 5 人だけが病気で、残り 95 人が健康だとします。「全員が健康」と答えるだけの検査は、正解率 0.95 です。数字はよく見えますが、病気の人を 1 人も見つけていません。

正解と予測の組み合わせは 4 通りあります。正例(この章では「生存」)をどう当てたか・外したかで並べたものが 混同行列 です。

予測が正例 予測が負例
正解が正例 真陽性 偽陰性
正解が負例 偽陽性 真陰性

ここから 3 つの指標が出ます。

  • 適合率(precision) = 真陽性 ÷ (真陽性 + 偽陽性)。正例と予測したうち、本当に正例だった割合
  • 再現率(recall) = 真陽性 ÷ (真陽性 + 偽陰性)。本当の正例のうち、正例と予測できた割合
  • F 値(F1 score) = 適合率と再現率の調和平均

先ほどの検査は、正解率 0.95 でも再現率が 0 です。この例はそのままテストにしました。正解率は第 1 章の 正解率 をそのまま使います。

「全員を健康と答えても正解率は高くなる」で(健康予測と健康正解の正解率)と0.95が一致確認
「全員を健康と答えると再現率は零」で((健康正解と健康予測を「病気」で混同行列集計)の再現率算出)と0が一致確認

11.3 TODO リストの作成

TODO リスト:

  • 混同行列を数える
  • 適合率・再現率・F 値を求める
  • 正解率と平均二乗誤差を求める
  • ROC 曲線と AUC を求める
  • K 分割交差検証の分け方を作る
  • 分割ごとに学習して採点する
  • 実データ(Survived・cinema)で交差検証する

置き場は src/chapter11.nako3 1 つ、テストは test/chapter11_test.nako3 1 つです。第 1 章の 正解率、第 2 章の 表読込・列平均算出・欠損補完・並べ替え、第 3 章の 決定木学習・決定木予測、第 7 章の 線形回帰学習・線形回帰予測・二乗平均平方根誤差算出・平均絶対誤差算出 を、書き換えずに取り込んで使います。Haskell 版の第 11 章と同じく、第 10 章は使いません。

関数名はすべてのファイルをまたいで一意にする約束なので、平均 や 分割 のような汎用の名前は避け、評価平均算出・交差検証分割作成 のように章の題材の名詞を入れました。

11.4 混同行列を数える

Red: 最初のテスト

「正解と予測を一件ずつ数える」で([「1」, 「1」, 「0」, 「0」]と[「1」, 「0」, 「1」, 「0」]を「1」で混同行列集計)と(1と1と1と1の試験混同行列作成)が一致確認

4 件で 4 通りがちょうど 1 回ずつ出る並びにしました。1 つでも数え違えれば落ちます。試験混同行列作成 は、4 つの数から {"真陽性", "偽陽性", "偽陰性", "真陰性"} の辞書を作るテストの道具です。

Green: 型まで比べる

●(正解一覧と予測一覧を正例で)混同行列集計とは
    正解一覧と予測一覧の評価件数検査
    真陽性 = 0
    偽陽性 = 0
    偽陰性 = 0
    真陰性 = 0
    Iを 0から (正解一覧の要素数) - 1まで繰り返す
        正解正例 = (正解一覧[I] === 正例)
        予測正例 = (予測一覧[I] === 正例)
        もし、正解正例ならば
            もし、予測正例ならば
                真陽性 = 真陽性 + 1
            違えば
                偽陰性 = 偽陰性 + 1
            ここまで
        違えば
            もし、予測正例ならば
                偽陽性 = 偽陽性 + 1
            違えば
                真陰性 = 真陰性 + 1
            ここまで
        ここまで
    ここまで
    {"真陽性": 真陽性, "偽陽性": 偽陽性, "偽陰性": 偽陰性, "真陰性": 真陰性}で戻る
ここまで

Haskell 版は (Eq a) => で「同じかどうかを比べられる」ことだけを求めました。なでしこ3 には型が無いので、比べ方を自分で選びます。= は JavaScript の == と同じゆるい比較で、数値の 1 と文字列の「1」を等しいとみなします(第 2 章)。ラベルの型が混ざったときに黙って正例と数えないよう、型まで比べる === を使いました。その選択をテストで固定します。

「数値の一と文字列の一は別のラベルとして数える」で([1]と[「1」]を「1」で混同行列集計)と(0と1と0と0の試験混同行列作成)が一致確認

=== を = に戻すと、このテストだけが落ちることを確かめました。

4 通りの場合分けを漏らしても、なでしこ3 は何も言いません(Haskell 版は -Wall -Werror が止めます)。4 通りを 1 回ずつ出す最初のテストと、正例以外をまとめて負例と数えるテストが、その代わりです。

「正例以外はすべて負例として数える」で([「a」, 「b」, 「c」]と[「a」, 「a」, 「b」]を「a」で混同行列集計)と(1と1と0と1の試験混同行列作成)が一致確認

件数が違うときは黙って切り詰めない

●(正解一覧と予測一覧の)評価件数検査とは
    もし、(正解一覧の要素数) ≠ (予測一覧の要素数)ならば
        「正解と予測の件数が違います: {正解一覧の要素数} と {予測一覧の要素数}」のエラー発生
    ここまで
ここまで

なでしこ3 の配列は、範囲の外を読んでもエラーにならず、undefined を返します([1, 2, 3][5] も [1, 2, 3][0 - 1] も undefined でした)。件数を確かめずに数えると、84 件の正解と 85 件の予測を比べても何かの数字が返ります。先に長さを確かめてエラーにします。

TODO リスト:

  • 混同行列を数える

11.5 適合率・再現率・F 値

明白な実装

●(混同行列の)適合率算出とは
    (混同行列["真陽性"])と(混同行列["真陽性"] + 混同行列["偽陽性"])の評価比率で戻る
ここまで

●(混同行列の)F値算出とは
    適合率 = 混同行列の適合率算出
    再現率 = 混同行列の再現率算出
    (2 × 適合率 × 再現率)と(適合率 + 再現率)の評価比率で戻る
ここまで

定義をそのまま書いています。F値算出 のように、名前に半角の英字を混ぜても問題ありませんでした。

分母が 0 になる場合

●(分子と分母の)評価比率とは
    もし、分母 = 0ならば
        0で戻る
    ここまで
    分子 ÷ 分母で戻る
ここまで

なでしこ3 は 0 で割っても止まらず、NaN や Infinity を返します(第 7 章)。しかも 一致確認 は JSON にしてから比べるので、NaN は null になり、NULL と見分けられません(第 10 章)。落ちないぶん、自分で場合分けしないと静かに壊れます。 「分母が 0 なら 0」と決め、そのままテストにしました。

「正例と一度も予測しなければ適合率は零」で((0と0と5と5の試験混同行列作成)の適合率算出)と0が一致確認
「適合率も再現率も零なら F 値も零」で((0と1と1と1の試験混同行列作成)のF値算出)と0が一致確認

正例を決めると評価関数になる

●(指標関数と正例の)分類指標作成とは
    評価関数 = 関数(正解一覧, 予測一覧)
        混同行列 = 正解一覧と予測一覧を正例で混同行列集計
        指標関数(混同行列)で戻る
    ここまで
    評価関数で戻る
ここまで

混同行列から求める指標と正例を渡すと、「正解と予測を受け取って 1 つの数を返す関数」が返ります。返した無名関数は、渡された 指標関数 と 正例 を覚えています(クロージャ。第 10 章で確かめた性質です)。

Haskell 版は部分適用 classificationMetric precision "1" で同じことをしました。なでしこ3 では、渡す側で 1 つ手間が要ります。

適合率指標 = 関数(混同行列)
    混同行列の適合率算出で戻る
ここまで
採点関数 = 適合率指標と「1」の分類指標作成
「正例を決めれば正解と予測から採点する関数になる」で(採点関数([「1」, 「1」, 「0」], [「1」, 「0」, 「0」]))と1が一致確認

適合率算出 をそのまま渡せないかと考え、名前を値の位置に書いてみました。

●(値の)目印関数とは
    「呼ばれた:{値}」で戻る
ここまで

X = 目印関数
X を表示
(X の変数型確認)を表示
呼ばれた:
string

名前を書いた場所で、引数の無いまま関数が呼ばれ、返した文字列が X に入りました。 文法の検査(gonako lint)も通ります。関数(値) で包んだものは function 型の値になり、包み(3) と呼べます。そこで、名前を付けた関数は無名関数で包んでから渡すことにしました。

TODO リスト:

  • 適合率・再現率・F 値を求める

11.6 正解率と平均二乗誤差

正解率は 書きません。第 1 章の 正解率((予測と正解ラベルの)正解率)をそのまま使います。第 1 章の比較は = なので、ラベルが文字列でも数値でも通ります。

平均二乗誤差は新しく書きます。平均は、値が 1 つも無いときにエラーを起こす関数に分けました。

●(値一覧の)評価平均算出とは
    もし、(値一覧の要素数) = 0ならば
        「平均を求める値が 1 つもありません」のエラー発生
    ここまで
    総和 = 0
    Iを 0から (値一覧の要素数) - 1まで繰り返す
        総和 = 総和 + 値一覧[I]
    ここまで
    総和 ÷ (値一覧の要素数)で戻る
ここまで

●(正解一覧と予測一覧の)平均二乗誤差算出とは
    正解一覧と予測一覧の評価件数検査
    二乗誤差一覧 = []
    Iを 0から (正解一覧の要素数) - 1まで繰り返す
        誤差 = 予測一覧[I] - 正解一覧[I]
        二乗誤差一覧に(誤差 × 誤差)を配列追加
    ここまで
    二乗誤差一覧の評価平均算出で戻る
ここまで

0 から前の値へ順に足すのは、Haskell 版の sum と同じ順です。足す順を変えると最後の桁が変わりうるので、この章でも順をそろえます。配列合計 を使わなかったのは、NaN を黙って読み飛ばすからです(第 10 章)。

学習用テスト: 外れた予測への敏感さ

「誤差が小さく揃うと平均二乗誤差は一」で([0, 0, 0, 0]と[1, 1, 1, 1]の平均二乗誤差算出)と1が一致確認
「一件だけ大きく外れると平均二乗誤差は跳ね上がる」で([0, 0, 0, 0]と[0, 0, 0, 4]の平均二乗誤差算出)と4が一致確認

誤差の合計はどちらも 4 ですが、MSE は 1 と 4 で 4 倍違います。2 乗するとは、大きく外れた 1 件を重く見ることだ という性質を、数字で言葉にしたテストです。

TODO リスト:

  • 正解率と平均二乗誤差を求める(正解率は第 1 章の再利用)

11.7 ROC 曲線と AUC

閾値を動かすとどうなるか

分類器が「正例らしさ」のスコアを返すとき、どのスコアから上を正例と呼ぶかは決めの問題です。閾値を下げるほど、正例を取りこぼさなくなる代わりに、負例を正例と呼ぶ誤りが増えます。閾値を上から下まで動かしたときの (偽陽性率, 真陽性率) の軌跡が ROC 曲線 で、その下の面積が AUC です。

曲線の 1 点と、閾値の無い点

点は {"閾値", "偽陽性率", "真陽性率"} の辞書で表します。先頭の点は「どれも正例と予測しない」状態で、閾値がありません。Haskell 版は Maybe の Nothing、ほかの言語版は正の無限大を置いた場所に、なでしこ3 版は NULL を置きました。

「先頭はどれも正例と予測しない点」で(([0.9, 0.1]と[真, 偽]のROC曲線算出)[0])と{"閾値": NULL, "偽陽性率": 0, "真陽性率": 0}が一致確認

NULL は、Haskell 版の Maybe と違って、使う側に場合分けを書かせません。閾値を数として使う側が NULL の点を忘れても、動かすまで分かりません。

数値を辞書の鍵にするとどうなるか

Haskell 版は、同じスコアのラベルを Double を鍵にした Map で束ねました。PHP 版は配列のキーに浮動小数点を使えず、並べてから走る形に書き直しています。なでしこ3 の辞書ではどうなるかを、先に確かめました。

辞書 = {}
辞書[0.1] = 「a」
辞書[0.10] = 「b」
辞書[1] = 「c」
辞書[1.0] = 「d」
辞書[0.1 + 0.2] = 「e」
辞書[0.3] = 「f」
辞書[「1」] = 「j」
辞書[0.0000001] = 「p」
辞書をJSONエンコードして表示
(辞書のハッシュキー列挙)[0]の変数型確認を表示
{"0.1":"b","1":"j","0.30000000000000004":"e","0.3":"f","1e-7":"p"}
string

分かったことは 3 つです。

  • 0.1 と 0.10、1 と 1.0 は同じ鍵になる(同じ数値なので当然です)
  • 0.1 + 0.2 と 0.3 は別の鍵になる。数値は、元の数値に戻せる最短の文字列(0.30000000000000004・1e-7)に変わるので、違う数値が同じ鍵に潰れることはありませんでした
  • 鍵は文字列に変わる。 数値の 1 と文字列の「1」は同じ鍵になり、取り出した鍵は string 型です

スコアをまとめるだけなら使えますが、取り出した鍵を数値に戻して並べ直す手間がかかり、ラベルとスコアを取り違えたときに 1 と「1」が混ざります。そこで辞書は使わず、スコアの順に並べてから、同じスコアの続く範囲を 1 つの点にまとめました。

●(スコア一覧と正例一覧の)ROC曲線算出とは
    スコア一覧と正例一覧の評価件数検査
    件数 = スコア一覧の要素数
    正例数 = 0
    組一覧 = []
    Iを 0から 件数 - 1まで繰り返す
        もし、正例一覧[I] === 真ならば
            正例数 = 正例数 + 1
        ここまで
        組一覧に[(スコア一覧[I]), (正例一覧[I])]を配列追加
    ここまで
    負例数 = 件数 - 正例数
    もし、(正例数 = 0)または(負例数 = 0)ならば
        「正例と負例が両方ないと ROC 曲線を描けません」のエラー発生
    ここまで
    組一覧の0を表数値ソート
    曲線 = [{"閾値": NULL, "偽陽性率": 0, "真陽性率": 0}]
    真陽性 = 0
    偽陽性 = 0
    位置 = 件数 - 1
    (位置 ≧ 0)の間
        閾値 = 組一覧[位置][0]
        もし、組一覧[位置][1] === 真ならば
            真陽性 = 真陽性 + 1
        違えば
            偽陽性 = 偽陽性 + 1
        ここまで
        # 次に見る(1 つ小さい)スコアが違えば、ここまでで 1 つの点にする。
        点確定 = 真
        もし、位置 > 0ならば
            もし、組一覧[位置 - 1][0] = 閾値ならば
                点確定 = 偽
            ここまで
        ここまで
        もし、点確定ならば
            曲線に{"閾値": 閾値, "偽陽性率": 偽陽性 ÷ 負例数, "真陽性率": 真陽性 ÷ 正例数}を配列追加
        ここまで
        位置 = 位置 - 1
    ここまで
    曲線で戻る
ここまで

表数値ソート は小さい順に並べるので、後ろから前へ読んで、スコアの高いほうから閾値を下げます。点の中身は件数だけで決まるので、同じスコアの中の並び順は結果に影響しません。

最初は、まとめ終わったかを表す変数を まとめ終わり と名付け、「単語『ま』、演算子『=』が解決していません」で止まりました。ひらがなの「と」が助詞として読まれ、ま と め終(送り仮名の「わり」は名前から落ちる)に割られていました(第 1 章の約束を、また踏みました)。点確定 にしました。

同じスコアを 1 つの点にまとめること、わずかに違うスコアは別の点になることをテストにしました。

「同じスコアは一つの点にまとめる」で(([0.5, 0.5, 0.1]と[真, 偽, 偽]のROC曲線算出)の要素数)と3が一致確認
「値がわずかに違うスコアは別の点になる」で(([0.1 + 0.2, 0.3]と[真, 偽]のROC曲線算出)の要素数)と3が一致確認

台形則で面積を求める

●(曲線の)ROC曲線下面積算出とは
    面積 = 0
    Iを 1から (曲線の要素数) - 1まで繰り返す
        左点 = 曲線[I - 1]
        右点 = 曲線[I]
        面積 = 面積 + (右点["偽陽性率"] - 左点["偽陽性率"]) × (左点["真陽性率"] + 右点["真陽性率"]) ÷ 2
    ここまで
    面積で戻る
ここまで

突き合わせる相手がいないので、値の分かっている入力で押さえます。

「完全に分けられるスコアの AUC は一」で(([0.9, 0.8, 0.3, 0.1]と[真, 真, 偽, 偽]のROC曲線算出)のROC曲線下面積算出)と1が一致確認
「まったく分けられないスコアの AUC は零点五」で(([0.5, 0.5, 0.5, 0.5]と[真, 偽, 真, 偽]のROC曲線算出)のROC曲線下面積算出)と0.5が一致確認
「逆に並べたスコアの AUC は零」で(([0.1, 0.2, 0.8, 0.9]と[真, 真, 偽, 偽]のROC曲線算出)のROC曲線下面積算出)と0が一致確認

1.0 と 0.5 は Haskell 版と同じ両端で、0 はなでしこ3 版で足しました。もう 1 つ、別の定義と照らすテストを足しました。AUC は「正例と負例を 1 つずつ選んだとき、正例のスコアのほうが高い確率(同じなら半分と数える)」とも定義できます。同じスコアを多く含む 12 件で、組をすべて数えた値(0.7083…)と台形則の値が 1e-12 以内で一致することを確かめます。

同点スコア = [0.7, 0.2, 0.7, 0.4, 0.9, 0.4, 0.1, 0.7, 0.4, 0.2, 0.9, 0.55]
同点正例 = [真, 偽, 偽, 真, 真, 偽, 偽, 真, 真, 偽, 偽, 真]
「AUC は正例のスコアが負例より高い組の割合と一致する」で((同点スコアと同点正例のROC曲線算出)のROC曲線下面積算出)と(同点スコアと同点正例の試験組比較面積)を0.000000000001で近似確認

試験組比較面積 はテストの中に書いた数え上げの関数です。同じスコアをまとめ損ねると、台形の形が変わってこのテストが落ちます。 両端の値だけでは、同点の扱いまでは確かめられません。

TODO リスト:

  • ROC 曲線と AUC を求める

11.8 K 分割交差検証

なぜ分け方を入れ替えるのか

1 回の分割では、たまたま難しい行がテストデータに集まることがあります。データを K 個のかたまりに分け、順番にテストデータの役を回して K 回測り、平均を取ると、その運の要素が薄まります。

分け方は行の位置で持つ

分割は {"訓練": 位置一覧, "テスト": 位置一覧} の辞書です。特徴量そのものを持たず 行の位置 を持つので、同じ分け方を特徴量にも正解にも使い回せます。

●(位置一覧と分割数の)交差検証分割組立とは
    総数 = 位置一覧の要素数
    余り = 総数 % 分割数
    基本件数 = (総数 - 余り) ÷ 分割数
    分割一覧 = []
    開始 = 0
    Kを 0から 分割数 - 1まで繰り返す
        大きさ = 基本件数
        もし、K < 余りならば
            大きさ = 大きさ + 1
        ここまで
        訓練 = []
        テスト = []
        Iを 0から 総数 - 1まで繰り返す
            もし、(I ≧ 開始)かつ(I < 開始 + 大きさ)ならば
                テストに(位置一覧[I])を配列追加
            違えば
                訓練に(位置一覧[I])を配列追加
            ここまで
        ここまで
        分割一覧に{"訓練": 訓練, "テスト": テスト}を配列追加
        開始 = 開始 + 大きさ
    ここまで
    分割一覧で戻る
ここまで

●(件数と分割数とシードの)交差検証分割作成とは
    件数と分割数の交差検証分割数検査
    位置一覧 = []
    Iを 0から 件数 - 1まで繰り返す
        位置一覧にIを配列追加
    ここまで
    (位置一覧をシードで並べ替え)と分割数の交差検証分割組立で戻る
ここまで

数値は float64 だけなので、割り算は小数になります。整数の商は (総数 - 余り) ÷ 分割数 で求めました。余りを引いてから割るので、割り切れて整数になります。

並べ替えは第 2 章の 並べ替え(java.util.Random と同じ線形合同法)です。ここをそろえたことが、ほかの言語版と同じ分け方になる理由です。 第 2 章で確かめた並び(シード 0 で [0..9] が [4, 8, 9, 6, 3, 5, 2, 1, 7, 0])から、分け方もそのまま決まります。

「並べ替えは第 2 章と同じ線形合同法で行う」で(十分割の試験テスト位置抽出)と[[4, 8], [9, 6], [3, 5], [2, 1], [7, 0]]が一致確認
「訓練データは並べ替えた順のままテストデータを除く」で(十分割[1]["訓練"])と[4, 8, 3, 5, 2, 1, 7, 0]が一致確認

2 本目は Haskell 版に無いテストです。Haskell 版の訓練データは、並べ替えた順から「テストデータに入っていない位置」を残したものです。訓練データの並び順は、決定木の同点の倒し方(多数決で同数なら先に現れたラベル)と、線形回帰の足す順を決めます。 並びが違えば、同じ行の集まりでも結果が変わりうるので、並びまで固定しました。なでしこ3 版は、テストデータの範囲の前と後をつなげて同じ並びを作ります(位置に重複が無いので、Haskell 版の「入っていない位置を残す」と同じ結果になります)。

満たすべき性質

891 件を 5 分割すると [179, 178, 178, 178, 178] です。余りの 1 件を先頭の分割に配る ので、どの行もちょうど 1 回テストされます。Haskell 版は実データの報告でこの行を確かめましたが、分け方にデータは要らないので、なでしこ3 版はデータの無い環境でも走るテストにしました。

「余りは先頭の分割から一件ずつ配る」で((7と3の交差検証順次分割作成)の試験テスト件数抽出)と[3, 2, 2]が一致確認
「八百九十一件を五つに分けると先頭が百七十九件」で((891と5と0の交差検証分割作成)の試験テスト件数抽出)と[179, 178, 178, 178, 178]が一致確認

交差検証の分け方に求められることは、次の 2 つです。

「テストデータはすべての行をちょうど一度ずつ使う」で全テスト位置と[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]が一致確認
「訓練データとテストデータは重ならない」で重複件数と0が一致確認

全テスト位置 は 5 つの分割のテストデータの位置をつなげて 配列数値ソート したもの、重複件数 は訓練データの位置のうちテストデータにもあるものの数です(あわせて 10 件にならない分割も数えます)。「零と一致」は空振りしやすいので、訓練データにテストデータの行も入れるように壊して、このテストが落ちることを確かめました。 並べ替えを外すと、「並べ替えると先頭から順にはならない」を含む 4 本が落ちることも確かめています。

TODO リスト:

  • K 分割交差検証の分け方を作る

11.9 評価関数も学習器も、無名関数

学習器の約束

学習器は「訓練データを受け取って、予測する関数を返す関数」です。

●(列名一覧と深さ上限の)決定木学習器作成とは
    学習器 = 関数(特徴量一覧, 正解一覧)
        学習木 = 特徴量一覧と正解一覧を列名一覧と深さ上限で決定木学習
        予測器 = 関数(対象一覧)
            学習木で対象一覧を決定木予測で戻る
        ここまで
        予測器で戻る
    ここまで
    学習器で戻る
ここまで

第 10 章のモデルは {"名前", "学習"} の辞書で、学習 が 関数(特徴量一覧, 正解一覧, 列名一覧) でした。この章は Haskell 版の Trainer に合わせ、列名も深さも作るときに覚えさせて、辞書を使わず関数 1 つ にしました。名前を表に載せる必要も、重要度を取り出す必要も無いからです。線形回帰の学習器(線形回帰学習器作成)も同じ形で、分類(予測は文字列)と回帰(予測は数値)が同じ約束に乗ります。

交差検証の手順を 1 つの関数にする

●(学習器と特徴量一覧と正解一覧と分割一覧を評価関数で)交差検証実行とは
    得点一覧 = []
    Iを 0から (分割一覧の要素数) - 1まで繰り返す
        分割 = 分割一覧[I]
        訓練特徴量 = 特徴量一覧と(分割["訓練"])の行位置選択
        訓練正解 = 正解一覧と(分割["訓練"])の行位置選択
        テスト特徴量 = 特徴量一覧と(分割["テスト"])の行位置選択
        テスト正解 = 正解一覧と(分割["テスト"])の行位置選択
        予測関数 = 学習器(訓練特徴量, 訓練正解)
        テスト予測 = 予測関数(テスト特徴量)
        得点一覧に(評価関数(テスト正解, テスト予測))を配列追加
    ここまで
    得点一覧で戻る
ここまで

分割・学習・予測・採点が並んでいます。引数で受け取った関数は、学習器(…)・評価関数(…) とそのまま呼べました。呼べないのは辞書から取り出した関数だけ で(第 10 章)、変数や引数に入っている関数は呼べます。

行位置選択 は範囲の外の位置でエラーを起こします。なでしこ3 の配列は範囲の外を読んでもエラーにならないので、ここでも自分で確かめます。

Haskell 版の traverse は「どれか 1 つでも失敗したら全体が失敗」を型で表しました。なでしこ3 版では、どこかでエラーが起きればそこで止まり、エラーが呼び出し元へ伝わるだけです。5 分割なら必ず 5 回学習する点は同じです。

三角測量: 評価関数を差し替える、回帰も同じ関数で

交互特徴量 = 「a」と[0, 1, 0, 1, 0, 1]の試験単列特徴量作成
交互正解 = [「い」, 「ろ」, 「い」, 「ろ」, 「い」, 「ろ」]
正解率指標 = 関数(正解一覧, 予測一覧)
    予測一覧と正解一覧の正解率で戻る
ここまで
F値指標 = 関数(混同行列)
    混同行列のF値算出で戻る
ここまで
交互木 = [「a」]と1の決定木学習器作成
「評価関数を差し替えても同じ分割で採点できる(正解率)」で(交互木と交互特徴量と交互正解と直線分割を正解率指標で交差検証実行)と[1, 1, 1]が一致確認
「評価関数を差し替えても同じ分割で採点できる(F 値)」で(交互木と交互特徴量と交互正解と直線分割を(F値指標と「ろ」の分類指標作成)で交差検証実行)と[1, 1, 1]が一致確認

同じ分割(並べ替えずに 3 つに分けたもの)を 2 回渡して、評価関数だけを差し替えています。どの分割のテストデータにも「い」と「ろ」が 1 件ずつ入る並びです。

並びを [「い」, 「い」, 「ろ」, 「ろ」, 「い」, 「ろ」] に変えると、F 値は [0, 0, 0]、正解率は [0, 0, 0.5] になりました。1 つ目の分割はテストデータが「い」2 件で正例が入らず、F 値は 11.5 節で決めたとおり 0 です。2 つ目と 3 つ目の分割は、訓練データの「a = 1」の側が「い」と「ろ」1 件ずつの同数になり、多数決が先に現れた「い」を選ぶので、正例を一度も当てられません。Python の参照実装も同じ値を返しました。

「正例を一度も当てない分割は F 値が零になる」で(交互木と交互特徴量と偏り正解と直線分割を(F値指標と「ろ」の分類指標作成)で交差検証実行)と[0, 0, 0]が一致確認
「同じ分割でも正解率は零にならない分割がある」で(交互木と交互特徴量と偏り正解と直線分割を正解率指標で交差検証実行)と[0, 0, 0.5]が一致確認

Haskell 版の記事 は、同じ並びで F 値が [0.0, 1.0, 1.0] になったと書いています。なでしこ3 版と参照実装はどちらも [0, 0, 0] で、2 つ目の分割を手で追っても 0 になりました。Haskell 版はこの環境で動かせないので、どちらの記述が正しいかは確かめていません。正例が少ないデータを分けると F 値が 0 の分割ができ、平均を押し下げる という Haskell 版の指摘は、この例でも同じです。

回帰でも同じ関数が使えることは、線形回帰と MSE で確かめます。t = 2a はどの分割で学習しても完全に当たるので、MSE はどの分割でも 0(1e-18 未満)のはずです。

直線特徴量 = 「a」と[1, 2, 3, 4, 5, 6]の試験単列特徴量作成
直線分割 = 6と3の交差検証順次分割作成
二乗誤差指標 = 関数(正解一覧, 予測一覧)
    正解一覧と予測一覧の平均二乗誤差算出で戻る
ここまで
直線得点 = ([「a」]の線形回帰学習器作成)と直線特徴量と[2, 4, 6, 8, 10, 12]と直線分割を二乗誤差指標で交差検証実行

TODO リスト:

  • 分割ごとに学習して採点する

11.10 突き合わせる相手がいない

Haskell 版と同じく、この章には ライブラリとの比較の節がありません。なでしこ3 には評価指標の命令も交差検証の命令も無いからです。代わりにこの章がしたことは、Haskell 版と同じ 3 つです。

  1. 値の分かっている入力を使う — AUC の 1・0.5・0、t = 2a の MSE 0、4 通りが 1 回ずつ出る混同行列。なでしこ3 版では、AUC を組の数え上げと照らすテストを足した
  2. 満たすべき性質を書く — テストデータが全行をちょうど 1 度ずつ使う、訓練とテストが重ならない、MSE が外れ値に敏感。なでしこ3 版では、訓練データの並び順まで固定した
  3. ほかの言語版と数値を照らす — 11.11 節の 6 つの指標

3 番目は、まだ実データで実測していません(11.11 節の注記)。代わりに、実データと同じ形の自作データで Python の参照実装と照らしました。

11.11 実データで評価する

簡略化した前処理

●(CSV文字列の)生存評価前処理とは
    行一覧 = (CSV文字列を表読込)["行一覧"]
    年齢平均 = (行一覧と[「Age」]の列平均算出)["Age"]
    特徴量一覧 = []
    Iを 0から (行一覧の要素数) - 1まで繰り返す
        行 = 行一覧[I]
        客室 = 行の「Pclass」を評価必須数値取得
        年齢 = 行の「Age」を欠損許容数値取得
        もし、年齢 === NULLならば
            年齢 = 年齢平均
        ここまで
        男性 = 0
        もし、(行の「Sex」を列取得) === 「male」ならば
            男性 = 1
        ここまで
        特徴量一覧に{"Pclass": 客室, "Age": 年齢, "male": 男性}を配列追加
    ここまで
    正解一覧 = []
    Iを 0から (行一覧の要素数) - 1まで繰り返す
        # CSV取得 は数字だけのセルを数値にするので、ほかの言語版と同じく文字列のラベルにする。
        生存値 = 行一覧[I]の「Survived」を列取得
        正解一覧に「{生存値}」を配列追加
    ここまで
    {"特徴量一覧": 特徴量一覧, "正解一覧": 正解一覧}で戻る
ここまで

補完に使う平均値を 分割の前に全体から 求めているので、厳密にはテストデータの情報が訓練に漏れています(リーク)。第 8 章のパイプラインは分割のあとで補完しました。Haskell 版と同じく、ほかの言語版と条件をそろえるために、あえて簡略化した手順に合わせています。第 8 章の乗客の前処理(グループごとの中央値での補完、ダミー変数化)は、この手順と違うので使いませんでした。

なでしこ3 版で気をつけたのは 2 か所です。

  • 年齢の欠損は === NULL で確かめる。 年齢 0 と欠損を取り違えないよう、第 2 章から続けている型まで比べる === にしました
  • Survived は文字列にする。 CSV取得 は数字だけのセルを数値にするので、Survived は数値の 0 と 1 で読まれます。正例は Haskell 版と同じ文字列の「1」で、混同行列は === で比べるので、そのままでは生存者が 1 人もいないことになります。「{生存値}」 で文字列に戻しました(文字列の中に「」は入れ子にできないので、いったん変数に入れています)

cinema の前処理(映画評価前処理)は、第 2 章の 列平均算出 と 欠損補完 をそのまま使い、第 7 章と違って外れ値を除きません。Haskell 版と同じです。

報告を組み立てる

評価関数は、表示する順に [名前, 評価関数] の組で並べます。

●生存指標一覧作成とは
    正解率指標 = 関数(正解一覧, 予測一覧)
        予測一覧と正解一覧の正解率で戻る
    ここまで
    適合率指標 = 関数(混同行列)
        混同行列の適合率算出で戻る
    ここまで
    再現率指標 = 関数(混同行列)
        混同行列の再現率算出で戻る
    ここまで
    F値指標 = 関数(混同行列)
        混同行列のF値算出で戻る
    ここまで
    [[「正解率」, 正解率指標], [「適合率」, (適合率指標と生存正例の分類指標作成)], [「再現率」, (再現率指標と生存正例の分類指標作成)], [「F値」, (F値指標と生存正例の分類指標作成)]]で戻る
ここまで

無名関数は配列の要素に直接書けないので、いったん変数に入れてから並べます。cinema は第 7 章の 二乗平均平方根誤差算出 と 平均絶対誤差算出 を同じ形で包みます(映画指標一覧作成)。

報告を組み立てる 評価指標報告作成 は、2 つの CSV の文字列を受け取る関数で、ファイルを読むのは 第11章実行 だけです。指標ごとに 交差検証実行 を呼ぶので、Haskell 版と同じく、決定木を 4 指標 × 5 分割で 20 回、線形回帰を 2 指標 × 5 分割で 10 回学習します。

まず、テストの中に書いた自作の小さなデータ(乗客 18 行・映画 12 行。値は自分で考えたもの)で、報告全体を Python の参照実装と比べました。18 件なら分割は 4, 4, 4, 3, 3 です。

Survived(決定木、5 分割交差検証の平均)
  正解率: 0.7333
  適合率: 0.5333
  再現率: 0.6000
  F値: 0.5600
cinema(線形回帰、5 分割交差検証の平均)
  RMSE: 503.80
  MAE: 408.92
分割ごとのテストデータの件数(5 分割)
  18 件を自作: 4, 4, 4, 3, 3

1 文字も違わずに一致しました。このテストは学習データの無い環境でも走るので、実データのあるときだけ走る分岐に頼らずに報告の組み立てを確かめられます。

命令の数

交差検証はモデルを何度も学習するので、命令数の上限(1 回の実行で 2 億)に最も当たりやすい章です。実データと同じ大きさの自作データ(乗客 891 行・11 列、うち年齢の空欄 177 か所。映画 100 行、空欄 2 か所と外れ値 1 行。どれも乱数で作った架空の値)で、上限を外して命令の数を表示する計測用の処理系で数えました。

実行 命令の数
第11章実行(報告 1 回) 約 1945 万
test/chapter11_test.nako3(学習データがあるとき) 約 4042 万
test/chapter11_test.nako3(学習データが無いとき) 約 77 万

目安の 1.5 億命令を大きく下回ったので、書き直しはしていません。決定木の分割の探索は、第 10 章で第 3 章の 分割候補列挙 を数え上げに書き直してあり(約 858 万命令から約 179 万命令)、その恩恵をそのまま受けています。約 710 件で深さ 2 の木を 20 回学習しても、報告全体で 2 千万命令ほどです。

自作データと参照実装で突き合わせる

同じ自作データで、報告と 6 つの指標の完全な精度の値を Python の参照実装と比べました。参照実装は、Haskell 版と同じ規則(年齢の全体平均での補完、java.util.Random と同じ並べ替え、余りを先頭に配る分割、第 3 章の決定木、第 7 章の正規方程式と部分ピボット付きのガウスの消去法)で、なでしこ3 版と同じ順に足します。

指標 なでしこ3 版 参照実装
正解率 0.8125415855878476 0.8125415855878476
適合率 0.7757669575007037 0.7757669575007037
再現率 0.7247558606165021 0.7247558606165021
F 値 0.7482912107803442 0.7482912107803442
RMSE 429.52068173288865 429.52068173288865
MAE 328.09391237341424 328.09391237341424

6 つとも最後の桁まで一致し、報告の文字列も 1 文字も違いませんでした。 これらは自作のデータでの値で、実データの値ではありません。

自作データを ML_DATA_DIR で指して検査を走らせると、実データの分岐のうち、891 件になること・年齢の欠損が埋まること・分割ごとの MSE が RMSE の 2 乗と一致することの 3 本は通り、ほかの言語版の値と比べる 3 本だけが値の不一致で落ちました。書き間違いのエラーで止まるところはありません。

期待する出力

ほかの言語版の値から、実データでの報告は次のようになるはずです。

Survived(決定木、5 分割交差検証の平均)
  正解率: 0.7811
  適合率: 0.7759
  再現率: 0.6306
  F値: 0.6833
cinema(線形回帰、5 分割交差検証の平均)
  RMSE: 405.77
  MAE: 321.53
分割ごとのテストデータの件数(5 分割)
  891 件を自作: 179, 178, 178, 178, 178

Survived の決定木は、正解率 0.7811 に対して再現率が 0.6306 です。生存者のうち 4 割近くを見逃している ことが、正解率だけでは見えません。cinema の RMSE は MAE より大きく、11.6 節で見たとおり、大きく外れた予測があることを示します。

最後の行の 891 件の分け方だけは、データに頼らずに決まるので、11.8 節のテストで実測済みです。

実データでの実測について

この章は学習データの無い環境で書いたため、上の出力の 6 つの指標はまだなでしこ3 版の実装で実測していません。学習データを配置した環境で npx gulp apps:check:nadesiko3 を実行し、保留が消えて通ることを確かめてから、この注記を外します。実データと同じ形の自作データでは、報告の文字列と、完全な精度の 6 つの指標が Python の参照実装と最後の桁まで一致することを確かめています。自作データを ML_DATA_DIR で指して検査を走らせ、実データの分岐が書き間違いのエラーで止まらず、値の不一致だけで落ちることも確かめました。

一致する見込みの理由は 2 つです。

  1. 分割が同じ — 第 2 章で java.util.Random と同じ線形合同法を自作し、同じシードなら同じ並びになることを、Java 版・Elixir 版・PHP 版・Haskell 版と同じ並びで確かめています。余りの配り方と訓練データの並び順も、11.8 節のテストで固定しました
  2. 使う部品がほかの章で照らしてある — 第 3 章の決定木と第 7 章の線形回帰は、それぞれの章で自作データと参照実装を突き合わせています。表示の桁(小数第 2 位・第 4 位)は、足す順による最後の桁の違いよりずっと上にあります

11.12 品質チェック

./tools/check.sh

文法(gonako lint)・整形の差分・テスト・テストが最後まで走ったか・関数の網羅の 5 つをまとめて実行します(第 6 章)。

項目 結果
第 11 章のテスト 55 件(保留 6 件)
関数の網羅 251 件中 251 件(直接 203 件、間接 48 件)

検査に止められたところ

  1. まとめ終わり が割られた — 11.7 節のとおり、ひらがなの「と」で割られて「不完全な文です」で止まりました

止められなかったところのほうが重要です。

  • 名前を付けた関数を値の位置に書くと、その場で呼ばれる(11.5 節)。文法の検査は通り、エラーも出ません。この章では初めから無名関数で包んだので踏みませんでしたが、踏めば評価関数の代わりに NaN や文字列が渡り、評価関数(…) を呼んだところで初めて失敗します
  • === を = に戻しても、文法の検査も整形も通ります。 捕まえるのは「数値の一と文字列の一は別のラベルとして数える」のテストだけです(11.4 節)

11.13 可視化について

なでしこ3 版では Notebook と可視化を扱いません。ROC 曲線を描く手順は Python 版 と Kotlin 版 を参照してください。ROC曲線算出 は、ほかの版と同じく曲線を点の列として返すので、同じ観点で読めます。

11.14 まとめ

この章では、評価指標と交差検証をなでしこ3 で自作しました。

作ったもの 突き合わせた相手
混同行列・適合率・再現率・F 値 ライブラリなし(値の分かっている入力。自作データで参照実装と最後の桁まで一致)
正解率 第 1 章の 正解率 をそのまま再利用
MSE・RMSE・MAE MSE は自作、RMSE と MAE は第 7 章を再利用(実データでは分割ごとの MSE と RMSE の 2 乗を照らすテストを置いた)
ROC 曲線・AUC ライブラリなし(1・0.5・0 の値と、組の数え上げによる別の定義)
K 分割交差検証 ライブラリなし(満たすべき性質と、第 2 章の並べ替えから決まる分け方)

なでしこ3 版ならではの学びは 5 つです。

  1. 評価関数も学習器も、無名関数で渡せた — 引数で受け取った関数は 評価関数(…) とそのまま呼べ、関数を返す関数も書ける。Haskell 版の部分適用の代わりに、クロージャで正例や列名を覚えさせた
  2. 名前を付けた関数は値として渡せない — 名前を値の位置に書くと、その場で引数の無いまま呼ばれ、返した値が入る。文法の検査も通る。無名関数で包んでから渡す
  3. 辞書の鍵にした数値は文字列になる — 違う数値が同じ鍵に潰れることは無かったが、1 と「1」が同じ鍵になり、取り出した鍵は文字列になる。ROC 曲線は並べてから同じスコアの範囲をまとめる形にした
  4. 比べ方を自分で選ぶ — = はゆるい比較なので、ラベルは === で比べ、CSV で数値になった Survived は文字列に戻した。その選択をテストで固定した
  5. 部品を軽くしておけば、何度学習しても上限に収まる — 決定木 20 回と線形回帰 10 回の報告が約 1945 万命令。第 10 章で第 3 章の分割の探索を書き直してあったので、この章は書き直さずに済んだ

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

  • 混同行列を数える
  • 適合率・再現率・F 値を求める
  • 正解率と平均二乗誤差を求める(正解率は第 1 章の再利用)
  • ROC 曲線と AUC を求める(ライブラリが無いので自作が最終実装)
  • K 分割交差検証の分け方を作る(同上)
  • 分割ごとに学習して採点する
  • 実データ(Survived・cinema)で交差検証する(実データでの実測は未)

次の章では、正則化を使って過学習を抑えます。

なでしこ3 版のほかの章は なでしこ3 版のトップ から辿れます。