Skip to content

第 8 章: 実践的な分類と前処理パイプライン

8.1 はじめに

ここまでの章のデータは、比較的きれいでした。アヤメのデータは数値ばかりで、欠損値も数件です。この章では、実務でよく出会う形のデータ を扱います。タイタニック号の乗客データには、文字列の列があり、欠損値が 177 件あり、正解ラベルの偏りがあります。

この章でやることは 3 つです。

  1. 前処理を部品にして、順に並べる(パイプライン) — 欠損値の補完、カテゴリのダミー変数化。訓練データで学習した値を、テストデータや新しいデータにも同じように当てる
  2. クラスの重みを付ける — 生存者(342 人)より死亡者(549 人)が多いので、素直に学習すると「死亡」に寄る。少数のクラスに重みを付けて、見落としを減らす
  3. 学習済みのパイプラインを保存して読み込む — 学習のたびに計算し直すのではなく、ファイルから読み込んで使う

なでしこ3 には、機械学習のライブラリも前処理のライブラリもありません(ADR 015)。補完も、ダミー変数化も、決定木も全部自作です。Haskell 版と同じく 突き合わせる相手が 1 つもない章 で、自作の正しさを何で支えるのかが主題になります。

なでしこ3 版では、次の 4 点に注目してください。

  • 前処理を「種別」を持つ辞書で表す。Haskell 版は前処理の種類を直和型で数え上げ、書き漏らしをコンパイラが止めました。なでしこ3 には型が無いので、前処理["種別"] を見て場合分けし、知らない種別はエラーにして、漏れはテストで捕まえます
  • 保存は JSONエンコード と 保存、読み込みは 開く と JSONデコード。辞書と配列がそのまま JSON になるので保存は 3 行で済みますが、JSONデコード は後ろに続くごみを黙って無視します。読み込んだ JSON の形を自分で確かめます
  • 足す順を変えない。重み付きの不純度は、浮動小数点数を足す順で最後の桁が変わります。Haskell 版と同じ木にするため、足す順まで合わせます
  • gonako には 1 回の実行で 2 億命令という上限がある。最初に書いた素直な実装は、自作のデータのテストで上限に届いて止まりました

8.2 題材とデータ

Survived.csv

Survived.csv は 891 人分の乗客記録で、次の 11 列を持ちます(列の説明と欠損値の数は Haskell 版の記録による)。

列 内容 欠損値
PassengerId 乗客の ID なし
Survived 生存(1)か死亡(0)か なし
Pclass 客室の等級(1・2・3) なし
Sex 性別(male / female) なし
Age 年齢 177 件
SibSp 同乗した兄弟姉妹・配偶者の数 なし
Parch 同乗した親・子の数 なし
Ticket チケット番号 なし
Fare 運賃 なし
Cabin 客室番号 687 件
Embarked 乗船した港(C / Q / S) 2 件

Survived.csv は BOM 付きです。第 2 章の 表読込 が先頭の BOM を落とすので、先頭の列名は PassengerId として読めます。

使う特徴量と、使わない列

ほかの言語版と同じく、PassengerId・Ticket・Cabin は使わず、7 列を特徴量にします。

# モデルに渡す特徴量の列。PassengerId・Ticket・Cabin は使わない。
乗客特徴量列 = [「Pclass」, 「Sex」, 「Age」, 「SibSp」, 「Parch」, 「Fare」, 「Embarked」]

年齢はグループごとの中央値で補完する

Age の欠損 177 件は、全体の中央値ではなく 等級と性別のグループごとの中央値 で埋めます。Haskell 版が訓練データから求めた中央値は、1 等の男性が 45、3 等の女性が 22 で、23 歳も違いました。

セルは文字列にそろえてから比べる

なでしこ3 版に固有の下ごしらえが 1 つあります。第 1 章で見たとおり、CSV取得 は 数字だけのセルを数値にし、それ以外は文字列のまま にします。Pclass は数値の 1、Sex は文字列の female になります。一方、架空の乗客のように自分で書いた行は 「1」 という文字列かもしれません。

グループやカテゴリを比べるときに数値と文字列が混ざると、同じ等級が別のグループになります。そこで、比べる前に必ず文字列にそろえる関数を 1 つ用意しました。

# 列の値を、前後の空白を落とした文字列で読む。列が無いか NULL なら空欄とみなす。
# CSV取得 は数字だけのセルを数値にするので、グループやカテゴリを比べる前に文字列にそろえる。
●(行の列名を)乗客セル文字列とは
    もし、(行の列名がハッシュキー存在) = 偽ならば
        「」で戻る
    ここまで
    値 = 行[列名]
    もし、値 === NULLならば
        「」で戻る
    ここまで
    「{値}」をトリムして戻る
ここまで

# 値が空欄なら真を返す。値が 0 のセルを空欄と数えないよう「===」で比べる。
●(行の列名を)乗客欠損判定とは
    (行の列名を乗客セル文字列) === 「」で戻る
ここまで

SibSp や Parch には値が 0 のセルがたくさんあります。第 2 章で踏んだとおり、なでしこ3 の = は 0 = 「」 を真にします。試しに 乗客欠損判定 を (行[列名]) = 「」 と素直に書き換えると、「値が零のセルは欠損ではない」と「空白だけのセルは欠損とみなす」の 2 つのテストが落ちることを確かめました。

8.3 TODO リストの作成

TODO リスト(第 8 章)

- [ ] 中央値を計算する
- [ ] 前処理を「学習してから適用する」部品として表す
- [ ] 年齢をグループごとの中央値で補完する
- [ ] 乗船した港を最頻値で補完する
- [ ] カテゴリ値をダミー変数にする(基準の列を落とす)
- [ ] 重み付きのジニ不純度と、クラスの重みを計算する
- [ ] クラスの重みを付けた決定木を学習する
- [ ] 前処理と決定木をパイプラインにつなぐ
- [ ] 学習済みのパイプラインを保存して読み込む
- [ ] 実データでクラスの重みの効果を確かめる

Haskell 版と同じ並びです。テストは test/chapter08_test.nako3 に書き、最初に全部を書いてから実装しました。関数が 1 つも無い段階で実行すると、[文法エラー]test/chapter08_test.nako3(42行目): 不完全な文です。単語『chapter08_test__中央値元配列』、単語『chapter08_test__乗客中央値』が解決していません。 で止まります。

8.4 前処理を「種別」を持つ辞書で表す

前処理には共通の形があります。訓練データから値を学び(学習)、その値でデータを変換する(適用) の 2 段です。

Haskell 版はこれを 2 つの直和型 Step(学習前)と FittedStep(学習済み)に分けました。なでしこ3 版では、どちらも 「種別」を持つ辞書 で表します。

種別 学習前の辞書 学習済みの辞書が持つ値
グループ中央値補完 列・グループ列 中央値(グループごと)・全体中央値
最頻値補完 列 値
ダミー変数化 列一覧 対応(列ごとの、基準を落としたカテゴリ)

学習と適用は、種別を見て場合分けします。知らない種別はエラーにします。

# 訓練データから変換に必要な値を求め、学習済みの前処理を返す。
●(前処理と対象表で)乗客前処理学習とは
    種別 = 前処理["種別"]
    もし、種別 = 「グループ中央値補完」ならば
        前処理と対象表で乗客グループ中央値学習して戻る
    ここまで
    もし、種別 = 「最頻値補完」ならば
        前処理と対象表で乗客最頻値学習して戻る
    ここまで
    もし、種別 = 「ダミー変数化」ならば
        前処理と対象表で乗客ダミー変数学習して戻る
    ここまで
    「知らない前処理です: {種別}」のエラー発生
ここまで
知らない前処理 = 関数()
    {"種別": 「標準化」}と性別表で乗客前処理学習
ここまで
「知らない前処理は学習できない」で知らない前処理を「知らない前処理です: 標準化」でエラー確認

Haskell 版では、種類を増やして節を書き忘れればコンパイルが止まりました。なでしこ3 版では 実行して初めて分かります。場合分けの最後の行を必ずエラーにしておくのは、書き忘れたときに「何もせず素通りする」のを防ぐためです。

学習前と学習済みを型で分けられないので、「学習していない前処理を適用する」ことも書けてしまいます。ここは型の無い言語の弱いところです。学習した直後の前処理を適用する流れは 8.9 節の 乗客パイプライン学習 の中にだけ書き、外からは学習済みのパイプラインを通して使うようにしました。

表は第 2 章と同じ形で、書き換えない

表は第 2 章の 表読込 と同じ {"列名一覧": [...], "行一覧": [...]} の辞書で表します。配列と辞書は参照で渡る ので、適用は必ず行を複製してから書き換え、新しい表を返します。

「補完しても元の表を書き換えない」で(年齢表の「Age」を試験列抽出)と[40, 50, 10, 「」]が一致確認

8.5 中央値と、グループごとの補完

中央値

# 中央値。件数が偶数なら中央の 2 つの平均。元の配列は並べ替えない。
●(値一覧の)乗客中央値とは
    件数 = 値一覧の要素数
    もし、件数 = 0ならば
        「値がありません」のエラー発生
    ここまで
    並び = 値一覧を配列複製
    並びの配列数値ソート
    中央 = (件数 - (件数 % 2)) ÷ 2
    もし、(件数 % 2) = 1ならば
        並び[中央]で戻る
    ここまで
    (並び[中央 - 1] + 並び[中央]) ÷ 2で戻る
ここまで

配列数値ソート は元の配列を書き換えるので、配列複製 してから並べます。「中央値を求めても元の配列を並べ替えない」をテストにしました。

グループのキーを JSON にする

グループは「Pclass と Sex の値の組」です。Haskell 版は値のリスト [Text] をそのまま Map のキーにできました。なでしこ3 の辞書のキーは文字列なので、値の配列を JSONエンコード した文字列 をキーにします。

# 行のグループを表す文字列。グループ列の値を並べた配列を JSON にしたもので、辞書のキーに使う。
●(行とグループ列一覧の)乗客グループキーとは
    値一覧 = []
    Iを 0から (グループ列一覧の要素数) - 1まで繰り返す
        値一覧に(行のグループ列一覧[I]を乗客セル文字列)を配列追加
    ここまで
    値一覧をJSONエンコードで戻る
ここまで

値をタブなどでつなぐ方法(PHP 版)と違い、値にどんな文字が入っていてもキーが衝突しません。1 等・女性のキーは ["1","female"] です。

テストでこのキーを書くときに 1 つ引っかかりました。「」の文字列の中では {...} が式の埋め込みになり、\" のような書き方もできません。 キーは埋め込みをしない『』の文字列で書きます。

「学習した中央値をグループごとに持つ」で学習年齢補完と{"種別": 「グループ中央値補完」, "列": 「Age」, "グループ列": [「Pclass」], "中央値": {『["1"]』: 45, 『["3"]』: 10}, "全体中央値": 40}が一致確認

知らないグループは全体の中央値で

訓練データに無かったグループ(たとえば 2 等だけがテストデータにいる)は、全体の中央値で埋めます。

# 空欄に入れる値。グループの中央値を知らなければ全体の中央値を使う。
●(学習済みと行で)乗客補完値決定とは
    もし、学習済み["種別"] = 「最頻値補完」ならば
        学習済み["値"]で戻る
    ここまで
    キー = 行と(学習済み["グループ列"])の乗客グループキー
    中央値一覧 = 学習済み["中央値"]
    もし、(中央値一覧のキーがハッシュキー存在)ならば
        中央値一覧[キー]で戻る
    ここまで
    学習済み["全体中央値"]で戻る
ここまで

Haskell 版は補完した値を "10.0" のような文字列にして列に書き戻しました。なでしこ3 版は数値のまま書き戻します。どちらも特徴量にするときに同じ数になります。

8.6 乗船した港を最頻値で補完する

Embarked の欠損 2 件は、いちばん多い港で埋めます。同数のときは 値の順で前のもの を選びます(Haskell 版の M.Map がキーの順に並ぶことから来た規則です)。

    候補一覧 = 件数一覧のハッシュキー列挙
    もし、(候補一覧の要素数) = 0ならば
        「値がすべて空欄です: {列名}」のエラー発生
    ここまで
    候補一覧の配列ソート
    最頻 = 候補一覧[0]
    Iを 1から (候補一覧の要素数) - 1まで繰り返す
        もし、件数一覧[候補一覧[I]] > 件数一覧[最頻]ならば
            最頻 = 候補一覧[I]
        ここまで
    ここまで

辞書はキーを入れた順を保つので、そのままでは「先に現れた順」になります。値の順にするには 配列ソート で並べ直します。配列ソート が何の順で並べるかは、gonako のソースで確かめました。Go の文字列の比較(UTF-8 のバイト順)による安定な並べ替え で、Haskell の Text の順(文字コードの順)と同じ結果になります。

同数港表 = [「Embarked」]と[[「S」], [「C」], [「」]]から試験表作成
「同数なら値の順で前のものを選ぶ」で(((港補完と同数港表で乗客前処理学習)と同数港表で乗客前処理適用)の「Embarked」を試験列抽出)と[「S」, 「C」, 「C」]が一致確認

8.7 カテゴリ値をダミー変数にする

基準の列を落とす

Sex の値が female と male なら、Sex_male の 1 列だけを作ります。Sex_female は Sex_male から決まるので要りません。カテゴリを値の順に並べ、先頭(基準)を落とします。

「基準の列を落とす」で(性別変換["列名一覧"])と[「Pclass」, 「Sex_male」]が一致確認
「カテゴリに一致すれば一、しなければ零」で(性別変換の「Sex_male」を試験列抽出)と[1, 0]が一致確認

1 行目の (性別変換["列名一覧"]) は、最初はかっこ無しで書いていました。引数の位置に添字の式をかっこ無しで書くと、辞書全体が渡ります(第 2 章で踏んだ落とし穴と同じ)。失敗の表示は 期待: null で、実際: には表全体が出ていました。

知らないカテゴリは全部 0

テストデータに訓練データに無いカテゴリが現れたら、どのダミー列も 0 になります。

「学習していないカテゴリはすべて零になる」で((性別学習と([「Pclass」, 「Sex」]と[[2, 「unknown」]]から試験表作成)で乗客前処理適用)の「Sex_male」を試験列抽出)と[0]が一致確認

列の順

ダミー変数化のあとの列は、元の列を取り除いて末尾にダミー列を足した順になります。Survived.csv のパイプラインでは Pclass, Age, SibSp, Parch, Fare, Sex_male, Embarked_Q, Embarked_S です。この順は決定木の同点の扱い(列の順で前のものを選ぶ)に効く ので、学習済みのパイプラインに 列一覧 として保存し、予測のときもこの順で特徴量を作ります。

8.8 クラスの重みを付けた決定木を作る

第 3 章を書き換えずに、重み付きの版を足す

第 3 章の決定木は、件数でジニ不純度を測ります。この章では 1 件ごとに重みを持たせ、件数の代わりに重みの合計 で割合を求めます。第 3 章は書き換えず、この章に重み付きの学習を足しました。木の形(「種別」を持つ辞書)は第 3 章と同じにしたので、予測は第 3 章の 決定木予測 をそのまま使えます。

「学習した木は第 3 章の予測でたどれる」で((四値列と[0, 1, 0, 1]を[「a」]とNULLと「none」で乗客重み付き決定木学習)で四値列を決定木予測)と[0, 1, 0, 1]が一致確認

重み付きのジニ不純度

# 重み付きのジニ不純度。ラベルの件数の代わりに重みの合計で割合を求める。
●(ラベル一覧と重み一覧の)乗客重み付きジニ不純度とは
    もし、(ラベル一覧の要素数) ≠ (重み一覧の要素数)ならば
        「ラベルと重みの件数が違います: {ラベル一覧の要素数} と {重み一覧の要素数}」のエラー発生
    ここまで
    総和 = 重み一覧の乗客重み総和
    もし、総和 ≦ 0ならば
        「重みがありません」のエラー発生
    ここまで
    合計一覧 = ラベル一覧と重み一覧の乗客ラベル重み合計
    二乗和 = 0
    Iを 0から (合計一覧の要素数) - 1まで繰り返す
        割合 = 合計一覧[I][1] ÷ 総和
        二乗和 = 二乗和 + 割合 × 割合
    ここまで
    1 - 二乗和で戻る
ここまで

ラベル [0, 1] に重み [3, 1] を付けると、割合は 3/4 と 1/4 になり、不純度は 1 − (9/16 + 1/16) = 0.375 です。

balanced の重み

balanced は、クラスの件数に反比例する重みです。件数 ÷ (クラスの数 × そのクラスの件数) で求めます。

# クラスの件数に反比例する重み(件数 ÷ (クラスの数 × そのクラスの件数))。
●(ラベル一覧の)乗客均衡重み算出とは
    件数一覧 = ラベル一覧のラベル計数
    クラス数 = (件数一覧のハッシュキー列挙)の要素数
    総数 = ラベル一覧の要素数
    結果 = []
    Iを 0から 総数 - 1まで繰り返す
        結果に(総数 ÷ (クラス数 × 件数一覧[ラベル一覧[I]]))を配列追加
    ここまで
    結果で戻る
ここまで

ラベル [1, 0, 0, 0] なら、1 は 4 ÷ (2 × 1) = 2、0 は 4 ÷ (2 × 3) = 2/3 です。クラスごとの重みの合計がどちらも 2 にそろいます。件数は第 3 章の ラベル計数 をそのまま使いました。

同点のときの決め方

balanced はクラスごとの重みの合計をぴったりそろえるので、2 クラスでは葉の多数決が同点になりやすくなります。同点なら 先に現れたラベル を選びます。この決め方が結果を左右することを、Haskell 版と同じテストで固定しました。

三同値 = [{"a": 1}, {"a": 1}, {"a": 1}]
「重みを付けなければ多数派が葉になる」で(三同値と[1, 0, 0]を[「a」]と0と「none」で乗客重み付き決定木学習)と{"種別": 「葉」, "ラベル": 0}が一致確認
# balanced はクラスごとの重みの合計をそろえるので同点になり、先に現れた少数派の 1 が葉になる。
「balanced にすると少数派が葉に選ばれる」で(三同値と[1, 0, 0]を[「a」]と0と「balanced」で乗客重み付き決定木学習)と{"種別": 「葉」, "ラベル": 1}が一致確認

足す順をそろえる

重み付きの不純度には、第 3 章に無かった難しさがあります。重みを付けなければ重みはすべて 1 で、合計は整数なので足す順に関係なく同じ値になります。balanced の重みは 712 ÷ (2 × 件数) のような割り切れない数で、足す順が違うと最後の桁が変わります。

最後の桁の違いは、ふつうは結果に効きません。効くのは、数学的には同じ不純度になる候補が 2 つ並んだときです。「同じなら先に見つけたほう」の規則が、最後の桁の違いで「小さいほう」に変わり、別の木になります。Haskell 版と同じ木にするには、Haskell 版と同じ順で足す必要があります。Haskell 版の sum は前から順に足すので、なでしこ3 版も前から順に足し、左右の合計もそれぞれの先頭から足し直します。

参照実装を Python で書くときも同じ注意が要ります。Python 3.12 以降の sum は浮動小数点数を補正しながら足す(Neumaier の方法)ので、前から順に足した値と最後の桁が違うことがあります。参照実装では sum を使わず、ループで前から足しました。

2 億命令の上限に届く

最初は、分割の候補ごとに左右のラベルと重みの配列を作り、乗客重み付きジニ不純度 を呼んでいました。第 3 章の書き方そのままです。自作の 20 行のデータのテストは通りました。

ところが、実データと同じ形の 891 行の自作データを ML_DATA_DIR で指してテストを動かすと、途中で次のエラーで止まりました。

[実行時エラー]test/chapter08_test.nako3(334行目): 実行した命令が多すぎます。終わらない繰り返しになっていませんか。

gonako のソースを読むと、1 回の実行で実行できる命令の数に 2 億という上限 があります(internal/vm/vm.go の DefaultOptions の MaxInstructions: 200_000_000)。無限ループで止まらなくなるのを防ぐためのもので、コマンドの引数では変えられません。テストファイル 1 つが 1 回の実行なので、テストファイル全体で 2 億命令に収める必要があります。

候補の数は「列の値の種類」だけあり、候補ごとに右側を足し直すので、運賃のように値の種類が多い列では件数の 2 乗に比例して増えます。足す順を変えずに(右側を後ろから引き算で求めると最後の桁が変わる)、1 回あたりの命令を減らしました。

        もし、前値 ≠ 後値ならば
            右和 = []
            Jを 1から 種類数まで繰り返す
                右和に0を配列追加
            ここまで
            右順 = []
            右合計 = 0
            Jを Iから 件数 - 1まで繰り返す
                右番号 = 番号列[J]
                右重み = 組一覧[J][2]
                右合計 = 右合計 + 右重み
                もし、(右順の要素数) < 種類数ならば
                    もし、(右順から右番号を配列検索) < 0ならば
                        右順に右番号を配列追加
                        右和[右番号] = 右重み
                        続ける
                    ここまで
                ここまで
                右和[右番号] = 右和[右番号] + 右重み
            ここまで
            左不純度 = 左和と左順と左合計の乗客集計ジニ
            右不純度 = 右和と右順と右合計の乗客集計ジニ
            不純度 = (左合計 × 左不純度 + 右合計 × 右不純度) ÷ 総和
            候補一覧に{"特徴量": 列名, "境界": (前値 + 後値) ÷ 2, "不純度": 不純度}を配列追加
        ここまで

変えたのは 3 点です。ラベルに 0 から番号を振って、ラベルごとの合計を辞書ではなく配列で持つ。左側は前から順に足していくだけで済むので(先頭から足し直したときと同じ順になる)、候補ごとに足し直さない。右側は配列を作らず、合計をその場で数える。ラベルが現れた順(右順)も数えておき、ジニ不純度の二乗和を足す順も Haskell 版と同じにします。

891 行の自作データで、報告を作る処理(深さ 5 の木を 2 本学習して評価し、保存と読み込みをする)が約 2.6 秒で終わるようになりました。同じ処理を 1 回の実行で繰り返すと 5 回目の途中で上限に届いたので、1 回あたり 4〜5 千万命令です。実データでは運賃の値の種類が自作データより少ないので、候補も少なくなります。

8.9 前処理とモデルをパイプラインにつなぐ

前処理の順番と学習の順番

パイプラインの定義も辞書です。

# Survived.csv 用のパイプライン。年齢・港の補完とダミー変数化の後に分類する。
●(深さ上限と付け方の)乗客パイプライン構築とは
    前処理一覧 = [「Age」と[「Pclass」, 「Sex」]のグループ中央値補完作成, 「Embarked」の最頻値補完作成, [「Sex」, 「Embarked」]のダミー変数化作成]
    {"前処理": 前処理一覧, "深さ上限": 深さ上限, "クラス重み": 付け方}で戻る
ここまで

学習では、それぞれの前処理を、前の前処理で変換したあとのデータで学習 します。港の最頻値は年齢を補完したあとの表で、ダミー変数化は港を補完したあとの表で学びます。ダミー変数化を先にすると、港の欠損が「どのカテゴリでもない行」になってしまいます。

●(定義と対象表と正解一覧で)乗客パイプライン学習とは
    前処理一覧 = 定義["前処理"]
    学習済み一覧 = []
    途中表 = 対象表
    Iを 0から (前処理一覧の要素数) - 1まで繰り返す
        学習済み = (前処理一覧[I])と途中表で乗客前処理学習
        途中表 = 学習済みと途中表で乗客前処理適用
        学習済み一覧に学習済みを配列追加
    ここまで
    特徴量一覧 = 途中表を乗客特徴量変換
    列名一覧 = 途中表["列名一覧"]
    木 = 特徴量一覧と正解一覧を列名一覧と(定義["深さ上限"])と(定義["クラス重み"])で乗客重み付き決定木学習
    {"前処理": 学習済み一覧, "列一覧": 列名一覧, "木": 木}で戻る
ここまで

学習した値を保持して、テストデータに同じ変換を当てる

学習済みのパイプラインは、学習した前処理の並び・前処理のあとの列の順・木の 3 つを持ちます。予測するときは、テストデータから何も学ばず、保持している値で変換します。

# 学習済みの前処理を順に当てて、データを変換する。訓練データにもテストデータにも同じ変換になる。
●(学習済みと対象表で)乗客前処理変換とは
    前処理一覧 = 学習済み["前処理"]
    途中表 = 対象表
    Iを 0から (前処理一覧の要素数) - 1まで繰り返す
        途中表 = (前処理一覧[I])と途中表で乗客前処理適用
    ここまで
    途中表で戻る
ここまで

テストでは、訓練データ 4 行で学んだ中央値(1 等が 30、3 等が 21)が、テストデータの 3 等の男性の空欄に入ることを確かめます。

「前処理は訓練データで学んだ値を保持する」で(小学習["前処理"][0]["中央値"])と{『["1"]』: 30, 『["3"]』: 21}が一致確認
「テストデータにも訓練データで学んだ値で補完する」で((小学習と([「Pclass」, 「Sex」, 「Age」]と[[3, 「male」, 「」]]から試験表作成)で乗客前処理変換)["行一覧"][0]["Age"])と21が一致確認

前処理が済んだ表を特徴量にする

最後に、前処理が済んだ表を数値の特徴量にします。欠損値が残っていたり、数値として読めない値があればエラーにします。

「欠損値が残っていれば特徴量にできない」で欠損特徴量を「欠損値が残っています: Age」でエラー確認
「数値として読めない値は特徴量にできない」で読めない特徴量を「Age を数値として読めません: 若い」でエラー確認

8.10 モデルを保存して読み込む

JSON にして 3 行で保存する

学習済みのパイプラインは辞書と配列と数値と文字列だけでできているので、JSONエンコード でそのまま JSON になります。形式の名前と版を付けて保存します。

# 学習済みのパイプラインを、形式の名前と版を付けた JSON にして保存する。フォルダが無ければ作る。
●(学習済みをパスに)乗客モデル保存とは
    (パスのパス抽出)にフォルダ作成
    保存物 = {"形式": 乗客モデル形式, "形式版": 乗客モデル形式版, "モデル": 学習済み}
    (保存物をJSONエンコード)をパスに保存
ここまで

Haskell 版は binary に Text のインスタンスが無く、保存の形式を 1 バイト単位で手で書きました。なでしこ3 版は逆に、書く手間はほとんどありません。テストの 4 行のパイプラインを保存すると、次の JSON になりました(読みやすく字下げしたもの)。

{
  "形式": "乗客パイプライン",
  "形式版": 1,
  "モデル": {
    "前処理": [
      {
        "種別": "グループ中央値補完",
        "列": "Age",
        "グループ列": ["Pclass"],
        "中央値": {"[\"1\"]": 30, "[\"3\"]": 21},
        "全体中央値": 22
      },
      {"種別": "ダミー変数化", "対応": [{"列": "Sex", "カテゴリ": ["male"]}]}
    ],
    "列一覧": ["Pclass", "Age", "Sex_male"],
    "木": {
      "種別": "節",
      "分割": {"特徴量": "Pclass", "境界": 2, "不純度": 0},
      "左": {"種別": "葉", "ラベル": 1},
      "右": {"種別": "葉", "ラベル": 0}
    }
  }
}

日本語はそのまま書き出されます(gonako の JSONエンコード は非 ASCII を \u にしない)。数値は 往復しても値が変わらない最短の桁 で書かれます(0.1 + 0.2 は 0.30000000000000004)。不純度や境界を保存して読み戻しても、最後の桁まで同じ値に戻ります。

読み込んだ JSON の形を確かめる

問題は読み込みです。ほかの言語版は「ファイルの中身で任意のものを作らせない」用心をしていました。PHP 版は unserialize() に allowed_classes、Elixir 版は :erlang.binary_to_term に :safe、Clojure 版は clojure.edn/read-string です。

なでしこ3 の JSONデコード が作るのは辞書・配列・文字列・数値・真偽値・NULL だけなので、任意のものが作られる心配はありません。その代わり、形が違っても何も言わずに読み込みます。木の節に 境界 が無ければ、予測のときに値の無いものとの比較になり、エラーにならずに右の部分木へ進みます(境界の無い節に 0 を渡しても 100 を渡しても、右の葉になりました)。そこで、読み込んだ JSON の形(種別・必要なキー・値の種類)を自分で確かめます。

ここで 2 つの事実に出会いました。

  1. 変数型確認 は、辞書・配列・NULL のどれにも object と答えます(JavaScript の typeof と同じ)。辞書と配列を見分けられないので、JSONエンコード した文字列の先頭の文字({ か [ か)で見分ける関数を書きました
  2. JSONデコード は、先頭の値を 1 つ読んだところで止まり、後ろに続く文字を無視します。{"a":1} ごみ を渡すと {"a":1} が返ります。Haskell 版の decodeOrFail が「途中まで読めたら成功」を返し、rest を確かめたのと同じ問題です
# 値の種類を返す。変数型確認 は辞書・配列・NULL をどれも「object」と答えるので、JSON の先頭の文字で見分ける。
●(値の)乗客値種類とは
    型 = 値の変数型確認
    もし、型 !== 「object」ならば
        型で戻る
    ここまで
    先頭 = (値をJSONエンコード)の1だけ文字左部分
    もし、先頭 = 『{』ならば
        「辞書」で戻る
    ここまで
    もし、先頭 = 「[」ならば
        「配列」で戻る
    ここまで
    「null」で戻る
ここまで

余りの確かめ方は、保存するときに JSONエンコード の出力をそのまま書いていることを使います。読み込んだ値をもう一度 JSON にして、ファイルの中身と比べます。余りがあれば一致しません。

●(パスの)乗客モデル読込とは
    失敗文言 = 「モデルとして読めません: {パス}」
    本文 = (パスを開く)をトリム
    保存物 = NULL
    エラー監視
        保存物 = 本文をJSONデコード
    エラーならば
        失敗文言のエラー発生
    ここまで
    もし、(保存物をJSONエンコード) !== 本文ならば
        失敗文言のエラー発生
    ここまで
    もし、(保存物と[[「形式」, 「string」], [「形式版」, 「number」], [「モデル」, 「辞書」]]の乗客辞書形確認) = 偽ならば
        失敗文言のエラー発生
    ここまで
    もし、保存物["形式"] !== 乗客モデル形式ならば
        失敗文言のエラー発生
    ここまで
    もし、保存物["形式版"] !== 乗客モデル形式版ならば
        「対応していない形式のモデルです: {保存物["形式版"]}」のエラー発生
    ここまで
    もし、((保存物["モデル"])の乗客モデル形確認) = 偽ならば
        失敗文言のエラー発生
    ここまで
    保存物["モデル"]で戻る
ここまで

乗客モデル形確認 は、前処理の並び(種別ごとに必要なキーと値の種類)・列の並び(文字列の配列)・木(葉はラベルの数値、節は 特徴量 と 境界 と左右の部分木)を順にたどって確かめます。木は再帰でたどります。

最初は失敗の文言を入れる変数を 読めない と名付けて、関数『読』に代入できません。 という文法エラーになりました。読 はファイルを読む命令の名前で、読めない が 読 + めない に割られたのです。送り仮名の付いた名前は、漢字の部分が命令の名前と重なる ことがあります。

往復することと、壊れたファイルをテストする

テストは、一時フォルダ(一時フォルダ作成)に書いて、最後に消します。model/ には書きません。

保存場所 = 一時フォルダ作成
保存先 = 「{保存場所}/model/spec.json」
小学習を保存先に乗客モデル保存
「保存先のフォルダが無ければ作ってから保存する」で(保存先が存在)と真が一致確認
「学習済みのパイプラインは保存して復元できる」で(保存先の乗客モデル読込)と小学習が一致確認
「復元したパイプラインで予測できる」で((保存先の乗客モデル読込)と小乗客表で乗客パイプライン予測)と[1, 1, 0, 0]が一致確認

壊れたファイルは、正しく保存したファイルの中身を 1 か所だけ書き換えて作りました。

壊し方 期待するエラー
JSON でない文字列 モデルとして読めません
モデル が配列 モデルとして読めません
形式 が別の名前 モデルとして読めません
正しい中身の後ろに ごみ モデルとして読めません
"形式版":1 を 2 に 対応していない形式のモデルです: 2
葉の "種別":"葉" を "枝" に モデルとして読めません
節の "境界" を別の名前に モデルとして読めません
前処理の種別を 標準化 に モデルとして読めません
"全体中央値" を別の名前に モデルとして読めません
正しい中身 = 保存先を開く
(正しい中身 & 「 ごみ」)を壊れた先に保存
「後ろに余計なものが付いたファイルは読み込めない」で壊れたモデルを「モデルとして読めません: {壊れた先}」でエラー確認

最後に一時フォルダを消し、「後片付けで一時フォルダを消す」ことも検査にしました。ファイル削除 は Go の os.Remove で、空のフォルダも消せますが中身のあるフォルダは消せないので、ファイル・model・一時フォルダの順に消します。後片付けの行をわざと外すと、この検査が落ちることを確かめました。

8.11 実データでクラスの重みの効果を確かめる

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

学習データはこの環境に無いので、これまでの章と同じく、自作のデータと Python の参照実装で数値を確かめました。参照実装は、Haskell 版と同じ規則(java.util.Random の分割・グループのキー・値の順・同点の扱い・足す順)で書いた使い捨てのスクリプトです。

自作のデータは 2 つです。

データ 形 使い方
20 行の自作データ 実データと同じ 11 列、年齢の空欄 3 件、港の空欄 1 件 テストファイルに直接書き、乗客報告作成 を呼ぶ
891 行の自作データ 実データと同じ 11 列・BOM 付き、年齢の空欄 177 件・客室の空欄 687 件・港の空欄 2 件・生存 342 人 ML_DATA_DIR で指して実データの分岐を動かす

どちらも乱数で作った架空の値で、配布データの行は含みません。

20 行の自作データは、none と balanced で見つけた生存者の数が変わるものを選びました。報告は次のとおりで、なでしこ3 版と参照実装の出力は 1 文字も違いませんでした。これをテストの期待値にしています。

データ件数: 20(生存 11, 死亡 9)
訓練データ: 16 件, テストデータ: 4 件
classWeight=none: 訓練 1.000, テスト 0.750, 生存者 1 人中 1 人を発見
classWeight=balanced: 訓練 1.000, テスト 0.750, 生存者 1 人中 0 人を発見
保存したモデル: 読み込めました
架空の乗客の予測: 1, 1

891 行の自作データでは、報告の文字列が一致したうえで、保存したモデルの JSON を参照実装のモデルと突き合わせ、学習した中央値・最頻値・列の順と、深さ 5 の木の 51 個の節と葉(境界と不純度を含む)が浮動小数点数の最後の桁まで一致 しました。

データが無ければ保留にする

実データのテストは、Haskell 版と同じ期待値で書き、データが無ければ保留にします。第 8 章の実行は、model/survived.json に学習済みのパイプラインを保存します。

./bin/gonako main.nako3 chapter08

期待する出力は次のとおりです。

データ件数: 891(生存 342, 死亡 549)
訓練データ: 712 件, テストデータ: 179 件
classWeight=none: 訓練 0.854, テスト 0.799, 生存者 79 人中 59 人を発見
classWeight=balanced: 訓練 0.848, テスト 0.804, 生存者 79 人中 65 人を発見
保存したモデル: 読み込めました
架空の乗客の予測: 1, 0

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

この章は学習データの無い環境で書いたため、上の出力と深さ 2 の値(見つけた生存者が 41 人から 68 人)はまだなでしこ3 版の実装で実測していません。学習データを配置した環境で npx gulp apps:check:nadesiko3 を実行し、保留が消えて通ることを確かめてから、この注記を外します。自作の 20 行のデータでは Python の参照実装と報告が 1 文字も違わないこと、891 行の自作データでは報告と保存したモデルが最後の桁まで一致することを確かめています。891 行の自作データを ML_DATA_DIR で指して ./tools/check.sh を実行すると、落ちるのは実データの値の不一致だけ(2 件)で、検査結果報告の行まで届きました。

実データの分岐では、期待値の比較のために 第8章実行 を呼びます。これは章を動かしたときと同じ model/survived.json に書くので、テストを始める前に無かったファイルとフォルダだけを後片付けします。学習は決定的なので、もともとあったファイルは同じ中身で上書きされます。

効果は深さによって変わる

深さによる効果の違い(深さ 2 では見つけた生存者が 41 人から 68 人に増え、テストの正解率は 0.765 から 0.737 に下がる)は、Haskell 版の 8.11 節の表を参照してください。なでしこ3 版では深さ 2 の 2 つの数(41 と 68)をテストに固定しています。重みは「正解率」と「見落としの少なさ」を交換している という読み方は、どの言語版でも同じです。

突き合わせる相手が 1 つも無いとき、何が正しさを支えるのか

支え 確かめられること
小さなテスト(74 件、うち保留 3 件) 1 つ 1 つの関数の振る舞い(空欄の扱い・同点の扱い・知らないグループやカテゴリ・壊れたファイル)
第 3 章の決定木との縮退 重みがすべて 1 なら第 3 章と同じ木。予測は第 3 章の関数をそのまま使う
自作データと参照実装 20 行と 891 行で、報告と保存したモデルが最後の桁まで一致すること
ほかの言語版の数値 実データでの正解率・見つけた生存者・架空の乗客の予測(データのある環境で確かめる)

Haskell 版には「木の形そのものをテストで固定できる」(代数的データ型をそのまま書ける)という支えがありました。なでしこ3 版でも、木は辞書なので {"種別": 「節」, "分割": {...}, "左": ..., "右": ...} とそのまま期待値に書けます。保存したモデルが JSON なので、参照実装とモデルの中身を丸ごと突き合わせられる のは、なでしこ3 版で新しく得た支えです。

8.12 品質チェック

./tools/check.sh

npx gulp apps:check:nadesiko3 で、文法・整形・テスト・テストが最後まで走ったか・関数の網羅の 5 つをまとめて実行します。第 8 章を足したあとの関数の網羅は 98 件中 98 件(直接 68 件、間接 30 件) でした。第 8 章のテストファイルは、学習データが無ければ約 1.3 秒、891 行の自作データを指すと約 3 秒で終わります。

検査に止められたところ

  1. 「{」 が文字列の埋め込みの始まりになる — 辞書と配列を見分ける関数で先頭の文字を 「{」 と比べたら、展開あり文字列で値の埋め込み{...}が対応していません。 で止まりました。『』の文字列で書きます
  2. 「」の中に「」は入れ子にできない — 報告の最後の行で、埋め込みの中に 予測一覧を「, 」で配列結合 と書いて止まりました。いったん変数に入れてから埋め込みます
  3. 読めない が 読 に割られる — 8.10 節のとおりです
  4. 引数を 対象 と名付けると、渡した値が入らない — ラベルの数を数える関数の引数を 対象 にしたら、文法の検査は通るのに、どのラベルも数えられず 0 件になりました。関数の中で 対象 を表示すると空でした。対象 は AJAX受信 などが結果を入れるシステムの変数の名前です。目標値 に変えて直りました。テストの「生存者 0 人中 1 人を発見」という、ありえない数で気づきました
  5. テスト用の関数の助詞を取り違える — テストの中で表を組み立てる (列名一覧と値表から)試験表作成 を「A から B の」で呼んで、未解決の単語があります で止まりました。定義の助詞と呼び出しの助詞は一致させます
  6. 2 億命令の上限 — 8.8 節のとおりです。学習データの無い環境では実データの分岐が動かないので、自作の 891 行のデータを指して動かさなければ気づきませんでした

4 と 6 は、どちらも文法の検査が何も言わないまま、実行して初めて分かった問題です。4 はテストの期待値で、6 は実データと同じ大きさのデータで捕まえました。小さなデータのテストが通っても、実データと同じ量のデータで一度は動かす 必要があります。

8.13 探索と可視化

Notebook による探索と可視化の節は設けません。年齢の分布や決定木の図は Python 版 と Kotlin 版 を参照してください。

8.14 まとめ

この章では、タイタニック号の乗客データを題材に、前処理パイプラインとクラスの重みを付けた決定木を、なでしこ3 の TDD ですべて自作しました。なでしこ3 に固有の論点は次のとおりです。

  1. 前処理を「種別」を持つ辞書で表す — 場合分けの最後を必ずエラーにして、知らない種別が素通りしないようにする。漏れはテストで捕まえる
  2. セルは文字列にそろえてから比べる — CSV取得 は数字だけのセルを数値にする。グループやカテゴリは文字列で比べ、空欄は === で判定する
  3. グループのキーは値の配列を JSON にした文字列 — 辞書のキーは文字列だけなので、どんな値でも衝突しない形にする
  4. 配列と辞書は参照で渡る — 前処理は行を複製してから書き換え、新しい表を返す。元の表が変わらないことをテストにする
  5. 足す順をそろえる — 重み付きの不純度は足す順で最後の桁が変わり、同点の扱いを通して木の形に効く。Haskell 版と同じく前から順に足す。参照実装でも Python の sum を使わない
  6. 1 回の実行に 2 億命令の上限がある — 素直な実装は自作の 891 行のデータで上限に届いた。足す順を変えずに、合計をその場で数える形にした
  7. 保存は JSON で簡単、読み込みは形を確かめる — 変数型確認 は辞書と配列を見分けず、JSONデコード は後ろのごみを無視する。形・形式の名前・版・余りを確かめて、壊れたファイルはエラーにする
  8. 名前の落とし穴がさらに 2 つ — 読めない は 読 に割られ、引数の 対象 には値が入らない

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

  • 中央値を計算する
  • 前処理を「学習してから適用する」部品として表す
  • 年齢をグループごとの中央値で補完する
  • 乗船した港を最頻値で補完する
  • カテゴリ値をダミー変数にする(基準の列を落とす)
  • 重み付きのジニ不純度と、クラスの重みを計算する
  • クラスの重みを付けた決定木を学習する
  • 前処理と決定木をパイプラインにつなぐ
  • 学習済みのパイプラインを保存して読み込む
  • 実データでクラスの重みの効果を確かめる(実データでの実測は未確認。8.11 の注記を参照)

次の章では、特徴量そのものを作り変える 特徴量エンジニアリング を扱います。

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