Skip to content

第 10 章: ロジスティック回帰とアンサンブル学習

10.1 はじめに

第 3 章では、決定木を自作してアヤメを分類しました。この章では、分類のアルゴリズムを 2 つ増やします。

  • ロジスティック回帰: 特徴量の重み付きの和から、各品種である確率を求めるモデルです。勾配降下法で重みを学習します
  • ランダムフォレスト: 少しずつ違うデータで学習した決定木をたくさん作り、多数決で予測するモデルです。複数のモデルを組み合わせる手法を アンサンブル学習 と呼びます

モデルが増えると、「学習させて正解率を測る」処理をモデルごとに書くのは重複です。この章では モデル共通の約束 を決めて、どのモデルも同じ モデル評価 に通せるようにします。最後に、どの特徴量が分類に効いたかを表す 特徴量の重要度 を自作します。

Haskell 版の第 10 章 と同じ TODO リストで進め、学習率・繰り返しの回数・木の数・乱数の使い方をそろえます。なでしこ3 版では、次の 4 点に注目してください。

  • ロジスティック回帰もランダムフォレストも、ここで作るものがそのまま最終実装です(ADR 015)。突き合わせる相手は、ほかの言語版の数値と、Python で書いた参照実装です
  • 型もインターフェースも無いので、約束を辞書で表します。 関数(…) で作った無名関数を辞書に入れ、「学習して、予測する関数を返す」ことを約束にします。Haskell 版が存在型まで持ち出したところが、なでしこ3 では辞書 1 つで済みます。そのかわり、約束を守っているかは誰も検査しません
  • 特徴量の重要度は、Java 版・Clojure 版・PHP 版の側に落ちるはずです。 第 3 章の ラベル計数 が辞書にラベルを現れた順に入れ、辞書がその順を保つからです。Haskell 版・Elixir 版は鍵の順の側でした
  • gonako には命令数の上限があります。 1 回の実行で 2 億命令を超えると止まり、上限は変えられません。素朴に書いたロジスティック回帰は 5000 回の繰り返しだけで約 4 億命令かかりました。計算の結果を変えずに書き方だけを変えて約 1.3 億命令まで減らし、報告はロジスティック回帰とそれ以外を別々の実行で出せるようにしました

データは第 2 章・第 3 章と同じアヤメのデータ(iris.csv)を使い、第 2 章の アヤメ前処理 で前処理します。

10.2 TODO リストの作成

TODO リスト:

  • ソフトマックス関数で確率に変換する
  • 合計が 1 になる確率にする
  • 大きな値でもあふれない
  • 交差エントロピーで損失を測る
  • ロジスティック回帰を学習して予測する
  • 分けられるデータを正しく予測する
  • 学習した品種が名前の順に並ぶ
  • 繰り返すほど損失が小さくなる
  • ランダムフォレストを作る
  • 多数決でまとめる
  • ブートストラップ標本を作る
  • 木ごとに使う特徴量を絞る
  • 特徴量の重要度を求める
  • 決定木 1 本の重要度を求める
  • 森の重要度を求める
  • すべてのモデルを同じ関数で評価する

置き場は src/chapter10.nako3 1 つで、先頭で第 3 章を取り込みます。第 3 章が第 1 章・第 2 章を取り込んでいるので、正解率・乱数・前処理もそのまま使えます。

!「./chapter03.nako3」を取り込む

10.3 ソフトマックス関数

ロジスティック回帰の仕組み

ロジスティック回帰は、品種ごとに「特徴量の重み付きの和」(スコア)を求め、それを確率に変換します。変換に使うのが ソフトマックス関数 です。各スコアの指数を取り、合計で割ります。

「合計が 1 になる」で(([1, 2, 3]のソフトマックス変換)の検査用総和)と1を0.000000000001で近似確認
「参照実装と同じ確率になる」で([1, 2, 3]のソフトマックス変換)と[0.09003057317038046, 0.24472847105479764, 0.6652409557748218]が一致確認
「同じスコアなら同じ確率になる」で([2, 2]のソフトマックス変換)と[0.5, 0.5]が一致確認

合計は 0.9999999999999999 になり、ちょうど 1 にはなりません。そこで B79 の準備でテスト補助に足した 近似確認(許容誤差つきの比較)を使います。

大きな値でもあふれない

なでしこ3 の数値は float64 だけです。EXP と LOG が端の値でどうふるまうかを、先に実測しました。

式 結果
1000のEXP Infinity(止まらない)
(0 - 1000)のEXP 0
Infinity ÷ (Infinity + Infinity) NaN
0のLOG -Infinity(止まらない)
(0 - 1)のLOG NaN
NaN = NaN・NaN === NaN どちらも偽

どれもエラーになりません。 Haskell 版と同じ側で、Elixir 版の :math.exp/1 のように落ちてはくれません。これをテストに書きます。

巨大指数 = 1000のEXP
「EXP は大きな値で止まらず無限大を返す」で(巨大指数 > 1e308)と真が一致確認
無限比 = 巨大指数 ÷ (巨大指数 + 巨大指数)
「無限大どうしを割ると NaN になり、自分自身とも等しくない」で(無限比 = 無限比)と偽が一致確認
「無限大も NaN も JSON にすると null になる」で[巨大指数, 無限比]と[NULL, NULL]が一致確認
「大きな値でもあふれない」で([1000, 1000]のソフトマックス変換)と[0.5, 0.5]が一致確認
「小さな値でも零で割らない」で([-1000, -1000]のソフトマックス変換)と[0.5, 0.5]が一致確認

3 つ目のテストは、テスト補助の落とし穴を書き残したものです。一致確認 は値を JSONエンコード してから比べますが、Infinity も -Infinity も NaN も null になります。 つまり 一致確認 では、NaN と NULL と無限大を見分けられません。無限大や NaN が出うる値は、一致確認 に渡す前に比較の式にしておきます。

最大値を引いてから指数を取れば、引いた分は分母と分子で打ち消し合うので、結果を変えずにあふれを防げます。

●(スコア一覧の)ソフトマックス変換とは
    結果 = []
    件数 = スコア一覧の要素数
    もし、件数 = 0ならば
        結果で戻る
    ここまで
    最大 = スコア一覧[0]
    Iを 0から 件数 - 1まで繰り返す
        もし、スコア一覧[I] > 最大ならば
            最大 = スコア一覧[I]
        ここまで
    ここまで
    指数一覧 = []
    指数和 = 0
    Iを 0から 件数 - 1まで繰り返す
        指数 = (スコア一覧[I] - 最大)のEXP
        指数一覧に指数を配列追加
        指数和 = 指数和 + 指数
    ここまで
    Iを 0から 件数 - 1まで繰り返す
        結果に(指数一覧[I] ÷ 指数和)を配列追加
    ここまで
    結果で戻る
ここまで

最大値を引く行を消すと、「大きな値でもあふれない」「小さな値でも零で割らない」「参照実装と同じ確率になる」の 3 つが落ちることを確かめました。

交差エントロピー

学習の目標は「正解の品種の確率を大きくする」ことです。その良し悪しを測るのが 交差エントロピー で、正解の品種の確率の対数の平均にマイナスを付けた値です。0のLOG は -Infinity なので、ごく小さい値(1e-12)を足してから対数を取ります。

「正解の確率が 0 でも無限大にならない」で([[0, 1]]と[0]の交差エントロピー)と27.631021115928547を0.000000001で近似確認

Haskell 版は「無限大ではない」ことだけを確かめましたが、ここでは値そのもの(-log(1e-12))と比べます。無限大は 一致確認 では null になってしまうので、値で比べるほうが確かです。

●(確率一覧と正解番号一覧の)交差エントロピーとは
    件数 = 確率一覧の要素数
    もし、件数 = 0ならば
        0で戻る
    ここまで
    対数和 = 0
    Iを 0から 件数 - 1まで繰り返す
        確率 = 確率一覧[I][正解番号一覧[I]]
        対数和 = 対数和 + ((確率 + 交差エントロピー微小値)のLOG)
    ここまで
    0 - 対数和 ÷ 件数で戻る
ここまで

TODO リスト:

  • ソフトマックス関数で確率に変換する
  • 交差エントロピーで損失を測る

10.4 ロジスティック回帰

足し算の順をそろえる

浮動小数点数の足し算は結合則が成り立たないので、足す順が違えば最後の桁が変わります。Haskell 版はリストの sum を避けて左からの畳み込みを明示しました。なでしこ3 では 繰り返す で左から足すので、書いたとおりの順になります。そろえるのは、ループの順(特徴量が外、品種が内)と、切片から足し始めることです。

●(行値一覧と重み一覧と切片一覧の)ロジスティック行スコアとは
    スコア = 切片一覧を配列複製
    Jを 0から (行値一覧の要素数) - 1まで繰り返す
        Kを 0から (スコアの要素数) - 1まで繰り返す
            スコア[K] = スコア[K] + 行値一覧[J] × 重み一覧[J][K]
        ここまで
    ここまで
    スコアで戻る
ここまで

切片の配列を書き換えないよう、配列複製 してから足し込みます。配列は参照で渡るからです。

学習と予測

重みは 重み[特徴量][品種] の配列の配列で持ちます。1 回の繰り返しで、全行の確率・損失・勾配を求めてから、重みと切片をまとめて更新します(バッチ勾配降下法)。最初に書いた素朴な形は次のとおりです(命令数の上限のために 10.8 節で書き直し、この形はテストの中に 比較用素朴ロジスティック学習 として残しています)。

    (反復回数)回
        確率一覧 = []
        Iを 0から 件数 - 1まで繰り返す
            確率一覧に((行一覧[I])と重みと切片のロジスティック行スコア)のソフトマックス変換を配列追加
        ここまで
        損失一覧に(確率一覧と正解番号一覧の交差エントロピー)を配列追加
        重み勾配 = 列数と品種数の零行列作成
        切片勾配 = (1と品種数の零行列作成)[0]
        Iを 0から 件数 - 1まで繰り返す
            # 誤差は「確率 − 正解」。正解の品種だけ 1 を引く。
            誤差 = 確率一覧[I]を配列複製
            正解番号 = 正解番号一覧[I]
            誤差[正解番号] = 誤差[正解番号] - 1
            …(重み勾配[J][K] と 切片勾配[K] に足し込む)
        ここまで
        …(重み[J][K] = 重み[J][K] - 学習率 × 重み勾配[J][K] ÷ 件数)
    ここまで

品種は名前の順に並べ、番号に直してから学習します。重複を除くのに辞書(ハッシュキー存在)を使い、並べるのに 配列ソート を使いました。配列ソート は文字列を文字コードの順に並べます(B・Iris-setosa・Iris-virginica・a・b の順になることを確かめました)。

小回帰 = 小特徴量と小正解を[「x」]と1と200でロジスティック学習
「品種を名前の順に並べる」で(小回帰["品種一覧"])と[「a」, 「b」]が一致確認
「分けられるデータを学習する」で(小回帰で小特徴量をロジスティック予測)と小正解が一致確認
逆順回帰 = 小特徴量と[「b」, 「b」, 「a」, 「a」]を[「x」]と1と200でロジスティック学習
「現れた順ではなく名前の順に並べる」で(逆順回帰["品種一覧"])と[「a」, 「b」]が一致確認

小特徴量 は x が 1・2・8・9 の 4 行、小正解 は a・a・b・b です。

損失は毎回は減らない

Haskell 版と同じく、学習率 1.0 で標準化していない値を学習させると、序盤は行きすぎて損失が増える回があります。なでしこ3 版でも最初の 4 回の損失は 0.6931・2.6327・1.1071・0.2023 で、Haskell 版の記事の値と同じでした。テストは「最後は最初より小さい」と「終盤は単調に減る」の 2 つにし、さらに参照実装の値と比べます。

「序盤の損失が参照実装と一致する(行きすぎて増える回がある)」で[損失一覧[1], 損失一覧[2], 損失一覧[3]]と[2.6326654708983397, 1.1071174480012165, 0.2022922458886253]を0.000000000001で近似確認
「損失は最初より最後のほうが小さくなる」で(損失一覧[49] < 損失一覧[0])と真が一致確認
「終盤の損失は単調に減る」で((損失一覧の40から50まで部分配列取得)の検査用単調減少判定)と真が一致確認

最後の桁は合わない:Go の math.Exp

参照実装の値と 一致確認(完全一致)で比べると、50 回の損失のうち 4 つ(5・7・37・38 回目)が最後の桁で違いました(5 回目が 0.2959405744982595 と 0.29594057449825945 など。差は最大で 5.9e-17)。足す順はそろえたので、原因を調べました。

gonako の EXP と LOG は、Go の math.Exp と math.Log をそのまま呼びます(処理系のソースの internal/mathlib/mathlib.go)。-10.3 から 10.3 までの 2000 個の値で Python(C の数学ライブラリ)と比べると、EXP は 294 個、LOG は正の値 1000 個のうち 73 個で、最後の 1 ビットが違いました。 Haskell 版・Python の参照実装は C の数学ライブラリの側、なでしこ3 版は Go の側です。

そのため、ロジスティック回帰の数値は 近似確認(誤差 1e-12)で比べます。正解率は「当たった件数 ÷ 全件数」なので、1 ビットの差で予測が変わらない限り一致します。

予測は、スコアが最大の品種を選びます。同じ値なら先に現れたほうです。

●(値一覧の)スコア最大添字とは
    最良 = 0
    Iを 1から (値一覧の要素数) - 1まで繰り返す
        もし、値一覧[I] > 値一覧[最良]ならば
            最良 = I
        ここまで
    ここまで
    最良で戻る
ここまで

Haskell 版は初期値に -Infinity を置きましたが、なでしこ3 には無限大の定数が無いので、先頭の要素から始めます。> を ≧ にすると、テストの「スコアが同じなら先に現れた品種を選ぶ」が落ちます。

TODO リスト:

  • ロジスティック回帰を学習して予測する

10.5 ランダムフォレスト

仕組み

ランダムフォレストは、次の 2 つの「ランダム」で少しずつ違う決定木を作り、多数決でまとめます。

  1. ブートストラップ標本: 訓練データから重複を許して同じ件数を選び直す
  2. 特徴量の部分集合: 木ごとに、使う特徴量を何個かに絞る

乱数の生成器を持ち回る

第 2 章で自作した乱数は、乱数生成器作成 が状態を持つ辞書を返し、次整数 がその辞書を書き換えて状態を進めます。辞書は参照で渡るので、同じ生成器を渡し続ければ、1 本目の続きから 2 本目の乱数を引けます。 Haskell 版が状態を引数と戻り値で受け渡したところを、なでしこ3 版は辞書の書き換えで表します。

●(生成器と件数の)ブートストラップ標本作成とは
    結果 = []
    (件数)回
        結果に(生成器と件数の次整数)を配列追加
    ここまで
    結果で戻る
ここまで
標本生成器 = 0の乱数生成器作成
最初標本 = 標本生成器と10のブートストラップ標本作成
「重複を許して件数ぶん選ぶ(参照実装と同じ並び)」で最初標本と[0, 8, 9, 7, 5, 3, 1, 1, 9, 4]が一致確認
「同じ状態からは同じ標本が出る」で((0の乱数生成器作成)と10のブートストラップ標本作成)と最初標本が一致確認
「状態を進めると別の標本になる」で(標本生成器と10のブートストラップ標本作成)と[7, 7, 3, 2, 5, 4, 4, 5, 1, 0]が一致確認

書き換えが見えにくいのは代償です。 標本生成器と10のブートストラップ標本作成 を 2 回書くと、同じ式なのに違う値が返ります。3 つ目のテストはそのことを書いています。

列の並べ替えも、第 2 章の 並べ替え(シードを受け取る)と同じ Fisher-Yates を、生成器を受け取る形で書きました(生成器並べ替え)。第 2 章のファイルは書き換えていません。

森を作る

第 3 章の 決定木学習 は、列名一覧で見る列を決めます。特徴量の辞書に列がいくつあっても、列名一覧に無い列は読みません。そこで、特徴量の辞書から列を削らずに、選んだ列名だけを渡せば部分集合になります。第 3 章を書き換える必要も、列を削った辞書を作り直す必要もありません。

    (森設定["木数"])回
        行番号一覧 = 生成器と件数のブートストラップ標本作成
        並び = 列名一覧を生成器で生成器並べ替え
        選択 = 並びの0から選択数まで部分配列取得
        # 列の順は元のまま残す。第 3 章は同じ不純度なら列の順で前の分割を選ぶので、
        # 並べ替えた順で渡すと同点の倒し方が変わり、ほかの言語版と木が違ってしまう。
        木列 = []
        …(列名一覧のうち、選択に入っている列だけを元の順で集める)
        …(行番号一覧の行を集めて 標本特徴量・標本正解 にする)
        木 = 標本特徴量と標本正解を木列と(森設定["深さ上限"])で決定木学習
        木一覧に{"列名一覧": 木列, "行番号一覧": 行番号一覧, "木": 木}を配列追加
    ここまで

森の設定は {"木数", "特徴量数", "深さ上限", "シード"} の辞書で渡します。引数を 7 つ並べるより、名前で読めるほうがなでしこ3 の文に合います。選んだ列を並べ替えた順のまま渡すように壊すと、自作データの報告のテストが落ちました。

「同じシードなら同じ森」は、森の辞書をそのまま 一致確認 で比べられます。テスト補助が JSON にしてから比べるので、辞書と配列の入れ子も中身で比べられるからです。

小森設定 = {"木数": 5, "特徴量数": 1, "深さ上限": NULL, "シード": 0}
小森 = 小特徴量と小正解を[「x」]と小森設定でランダムフォレスト学習
「同じシードなら同じ森になる」で(小特徴量と小正解を[「x」]と小森設定でランダムフォレスト学習)と小森が一致確認
「木ごとの標本が参照実装と一致する」で[小森["木一覧"][0]["行番号一覧"], 小森["木一覧"][4]["行番号一覧"]]と[[2, 3, 0, 2], [3, 0, 3, 0]]が一致確認

TODO リスト:

  • ランダムフォレストを作る

10.6 特徴量の重要度

計算方法

重要度は、「その特徴量で分割したことで、ジニ不純度がどれだけ減ったか」を件数で重み付けして足し上げ、合計が 1 になるように割ったものです。木は 種別 を持つ辞書なので、種別 を見て場合分けします。

●(木と特徴量一覧と正解一覧の)不純度減少列挙とは
    結果 = []
    もし、木["種別"] = 「葉」ならば
        結果で戻る
    ここまで
    …(分割の境界で左右に分ける)
    減少量 = (正解一覧の要素数) × ((正解一覧のジニ不純度) - 分割["不純度"])
    結果に[分割["特徴量"], 減少量]を配列追加
    …(左の部分木、右の部分木の順に再帰して、結果に足す)
    結果で戻る
ここまで

森の重要度は、木ごとの重要度をその木のブートストラップ標本の上で測り、木の数で割って足し合わせます。木が使わなかった特徴量は、その木では 0 です。結果は列名から重要度への辞書で、辞書が入れた順を保つので、列の順に並びます。

足す順が同点の倒し方を決める

Haskell 版の 10.8 節 では、重要度が Elixir 版と一致し、Java 版・Clojure 版・PHP 版とは 4 桁目がずれました。原因は第 3 章のジニ不純度で、ラベルの件数の二乗を どの順に足すか でした。Haskell の Data.Map は鍵の順(名前の順)、Java・PHP は現れた順です。

なでしこ3 版の第 3 章は、ラベル計数 が辞書にラベルを現れた順に入れ、ハッシュキー列挙 がその順で返します。現れた順の側に落ちるはずです。 まずジニ不純度 1 つで確かめました。

「ジニ不純度はラベルが現れた順に二乗を足す」で([「c」, 「b」, 「a」, 「a」, 「a」]のジニ不純度)と0.56が一致確認
「名前の順に並んでいれば名前の順の値になる」で([「a」, 「a」, 「a」, 「b」, 「c」]のジニ不純度)と0.5599999999999999が一致確認

同じラベルの組でも、並びが違えば最後の桁が変わります。次に、この差で 分割の選び方が変わる 小さなデータを、Python の参照実装で探して見つけました。

# 数学的には同点の 2 つの分割(p <= 3 と q <= 1.5)が、足す順の違いで 1 ulp ずれる。
# 現れた順に足すと q が選ばれ、名前の順に足す参照実装(Haskell 版の側)では p が選ばれる。
同点候補 = [{"p": 4, "q": 3}, {"p": 4, "q": 1}, {"p": 4, "q": 3}, {"p": 4, "q": 4}, {"p": 2, "q": 4}, {"p": 4, "q": 2}]
「同点の分割は足す順で倒れる向きが決まる」で(同点候補と[「b」, 「c」, 「a」, 「a」, 「c」, 「a」]を[「p」, 「q」]で最良分割探索)と{"特徴量": 「q」, "境界": 1.5, "不純度": 0.4666666666666666}が一致確認

参照実装で 2 つの候補の不純度を並べると、次のようになります。

足す順 p <= 3.0 q <= 1.5 選ばれる分割
現れた順(なでしこ3 版・Java 版の側) 0.46666666666666673 0.4666666666666666 q <= 1.5
名前の順(Haskell 版・Elixir 版の側) 0.4666666666666666 0.4666666666666666 p <= 3.0(同じ不純度なら列の順で前)

最後に、150 行の自作データで森の重要度まで確かめました(10.8 節)。

TODO リスト:

  • 特徴量の重要度を求める

10.7 モデル共通の約束

無名関数を辞書に入れる

なでしこ3 には型もインターフェースもありません。そこで、モデルを次の形の辞書で表すことを約束にします。

  • 名前: 表に載せる名前
  • 学習: 関数(特徴量一覧, 正解一覧, 列名一覧)。学習を済ませて、予測する関数 関数(特徴量一覧) を返す

第 1 章で、無名関数を辞書に入れて取り出して呼べることを確かめてありました。この章ではさらに、無名関数が作られたときの変数を覚えている(クロージャになる)ことを確かめてから使いました。

●(深さ上限の)決定木モデル作成とは
    学習 = 関数(特徴量一覧, 正解一覧, 列名一覧)
        学習木 = 特徴量一覧と正解一覧を列名一覧と深さ上限で決定木学習
        予測 = 関数(対象一覧)
            学習木で対象一覧を決定木予測で戻る
        ここまで
        予測で戻る
    ここまで
    {"名前": 「決定木({深さ上限の深さ呼称})」, "学習": 学習}で戻る
ここまで

学習 は作ったときの 深さ上限 を、予測 は学習した 学習木 を覚えています。深さ 1 と深さ 3 のモデルを続けて作っても、それぞれの設定が残ることをテストで確かめます。

深さ一モデル = 1の決定木モデル作成
深さ三モデル = 3の決定木モデル作成
「作ったモデルはそれぞれの設定を覚えている」で[深さ一モデル["名前"], 深さ三モデル["名前"]]と[「決定木(深さ 1)」, 「決定木(深さ 3)」]が一致確認

ロジスティック回帰とランダムフォレストも同じ形で書きます(ロジスティックモデル作成・森モデル作成)。

辞書から取り出した関数は、変数に入れてから呼ぶ

評価する側は、どのモデルも同じ手順で扱えます。

●(モデルと分割結果と列名一覧の)モデル評価とは
    学習関数 = モデル["学習"]
    訓練特徴量 = 分割結果["訓練特徴量"]
    テスト特徴量 = 分割結果["テスト特徴量"]
    予測関数 = 学習関数(訓練特徴量, 分割結果["訓練正解"], 列名一覧)
    訓練予測 = 予測関数(訓練特徴量)
    テスト予測 = 予測関数(テスト特徴量)
    [(訓練予測と(分割結果["訓練正解"])の正解率), (テスト予測と(分割結果["テスト正解"])の正解率)]で戻る
ここまで

最初は モデル["学習"](…) と辞書から取り出してそのまま呼ぼうとしましたが、「(...)の解析エラー」という文法エラーになりました。 いったん変数に入れてから呼びます。

種類の違うモデルを 1 つの配列に並べる

●比較モデル一覧作成とは
    [(比較木深さの決定木モデル作成), (1と5000のロジスティックモデル作成), ({"木数": 森木数既定, "特徴量数": 森特徴量数既定, "深さ上限": NULL, "シード": 比較乱数シード}の森モデル作成), ({"木数": 森木数既定, "特徴量数": 森特徴量数既定, "深さ上限": 比較木深さ, "シード": 比較乱数シード}の森モデル作成)]で戻る
ここまで

Haskell 版 では、型の違うモデルを 1 つのリストに並べるために、存在型(SomeClassifier)で包む必要がありました。なでしこ3 版では、どのモデルもただの辞書なので、そのまま配列に並びます。 型が無いことの利点です。

裏返せば、学習 のキーを打ち間違えても、学習 が予測する関数ではなく木そのものを返しても、動かすまで分かりません。Haskell 版で型検査が止めた誤りを、なでしこ3 版ではテストの「同じ評価でどのモデルも評価できる」が 3 つのモデルそれぞれについて止めます。

「同じ評価でどのモデルも評価できる(決定木)」で((NULLの決定木モデル作成)と小分割と[「x」]のモデル評価)と[1, 1]が一致確認
「同じ評価でどのモデルも評価できる(ロジスティック回帰)」で((1と200のロジスティックモデル作成)と小分割と[「x」]のモデル評価)と[1, 1]が一致確認
「同じ評価でどのモデルも評価できる(ランダムフォレスト)」で((小森設定の森モデル作成)と小分割と[「x」]のモデル評価)と[1, 1]が一致確認

TODO リスト:

  • すべてのモデルを同じ関数で評価する

10.8 実データで突き合わせる

報告を組み立てる

報告を組み立てる モデル比較報告作成 は CSV の文字列を受け取る関数で、ファイルを読むのは 第10章実行 だけです。まず自作の 15 行のデータ(3 品種 5 行ずつ、空欄 2 か所。値は自分で考えたもの)で、報告全体を Python の参照実装と比べました。

モデル 訓練データ   テストデータ
決定木(深さ 2)   0.9000  0.6000
ロジスティック回帰   0.9000  1.0000
ランダムフォレスト(100 本)    1.0000  0.8000
ランダムフォレスト(100 本・深さ 2)   0.9000  0.8000

ランダムフォレスト(100 本)の特徴量の重要度:
がく片長さ   0.4765
がく片幅    0.2161
花弁長さ    0.2476
花弁幅 0.0599

なでしこ3 版と参照実装の報告は 1 文字も違いませんでした。 このテストは、実装の 10 か所をわざと壊して確かめたとき、10 か所のうち 7 か所で落ちました(列を元の順に戻さない・生成器の続きで引かない・勾配を件数で割らない・重要度を正規化しない・重要度の森の深さを変える、など)。落ちなかった 3 か所(同点のときの最大添字・最大値を引くこと・対数に足す小さい値)は、それぞれ専用の小さなテストが落ちます。

命令数の上限に届く

次に、実データと同じ形の自作のアヤメ風データ(150 行・4 列・空欄 7 か所)で、章の実行を動かしました。

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

gonako は、1 回の実行で 2 億命令を超えると止まります。 処理系のソース(internal/vm/vm.go の DefaultOptions)に MaxInstructions: 200_000_000 と書かれていて、コマンドの引数でも環境変数でも変えられません。数えるのは実行全体です。エラー監視 で囲んでも、エラーならば の中の行で同じエラーになって止まり、捕まえられませんでした。

どこで命令を使っているかを知るため、処理系のソースの写しの上限だけを外し、実行した命令の数を表示するようにしてビルドし、部分ごとに数えました(この写しはリポジトリに入れていません)。小さな式ごとの命令の数は次のとおりです。

書き方 命令の数
A = A + B × C(変数だけ) 3
S = S + 行[J] × 重[J](添字 1 段) 約 7(繰り返しの 1 回を含めて約 11)
S = S + 行[0] × 重[0](添字が定数) 5
配[1] = 配[1] + B × 配[2](配列の要素へ書き込む) 9
2 次元の添字の要素へ書き込む 13
空の 繰り返す の 1 回 4
配列最大値・表列合計・表行列交換 など Go で書かれた命令 中の件数によらず 5〜6

配列の要素へ書き込むのと、繰り返しの 1 回が高くつきます。 素朴な書き方のロジスティック回帰は、1 行 1 回の繰り返しで、品種と特徴量の二重の繰り返しの中の 2 次元の書き込み(スコア[K] = …・重み勾配[J][K] = …)に約 760 命令を使っていました。

計算の結果を変えずに書き方を変える

ほかの言語版と数値を突き合わせるには、学習率・回数・木の数を変えられません。足す順と演算の順も変えられません(10.4 節)。そこで、同じ値を同じ順で計算したまま、命令の少ない書き方に置き換えました。

  1. 第 3 章の分割の候補を数え上げで求める。 候補ごとに左右の部分配列を作ってジニ不純度を数え直すのをやめ、境界を 1 つずつ右へずらしながら左右の件数を移します(第 3 章の 3.11 節)。森 100 本(深さの上限なし)が約 5300 万命令から約 1500 万命令になりました
  2. 重要度のために森を作り直さない。 表の 3 行目の森を学習したとき、森モデル作成 の辞書に 学習済み森 として覚えておき、重要度はその森で求めます。無名関数の中で辞書を書き換えると、配列に並べたモデルの辞書も書き換わります(辞書は参照で渡るため)
  3. ロジスティック回帰の内側を書き直す。
    • 重みを 重み転置[品種][特徴量] で持ち、品種ごとのスコアは配列の要素ではなく変数に足し込む
    • 品種が 3 つのときは、品種の繰り返しをやめて 3 つの変数で計算する(三品種誤差計算)。品種が 3 つでなければ、品種の数によらない書き方(品種誤差計算)を通る
    • 勾配は、行ごとの誤差を並べた 誤差表 を 表行列交換(Go で書かれた命令)で品種ごとの列にし、特徴量ごとに行の順に 0 から足す。切片の勾配は 表列合計(0 から行の順に足すことを処理系のソースで確かめた)で求める

品種が 3 つのときの 1 行の計算は次のようになります。

        得点零 = 切片零
        得点一 = 切片一
        得点二 = 切片二
        Jを 0から 列数 - 1まで繰り返す
            特徴値 = 行値[J]
            得点零 = 得点零 + 特徴値 × 重零[J]
            得点一 = 得点一 + 特徴値 × 重一[J]
            得点二 = 得点二 + 特徴値 × 重二[J]
        ここまで
        最大 = 得点零
        もし、得点一 > 最大ならば
            最大 = 得点一
        ここまで
        もし、得点二 > 最大ならば
            最大 = 得点二
        ここまで
        指数零 = (得点零 - 最大)のEXP
        指数一 = (得点一 - 最大)のEXP
        指数二 = (得点二 - 最大)のEXP
        指数和 = 指数零 + 指数一 + 指数二
        確率 = [指数零 ÷ 指数和, 指数一 ÷ 指数和, 指数二 ÷ 指数和]

ソフトマックス変換 と同じ規則で書いています。最大値は先頭から > で比べて選び、指数の和は左から足します(ソフトマックス変換 は 0 に足し始めますが、0 + 指数 は指数そのものです)。指数零 + 指数一 + 指数二 は左から結合するので、(指数零 + 指数一) + 指数二 の順になります。

品種の数で書き方を分けるのは、なでしこ3 には繰り返しの中身を展開する手段が無く、繰り返しの 1 回の命令がそのまま残るからです。「品種が 3 つ」はアヤメのための書き方で、一般の形は 品種誤差計算 のほうです。 両方の結果が同じであることはテストで守ります。

素朴な書き方と丸ごと一致させる

書き直す前の ロジスティック学習 を、テストの中に 比較用素朴ロジスティック学習 として残し、重み・切片・損失(100〜300 回ぶん)を 一致確認 で丸ごと比べます。品種が 2 つ・3 つ・4 つのデータ、学習率を変えたもの、標準化していない大きな値のものを入れました。

「4 列 3 品種で重み・切片・損失が素朴な書き方と丸ごと一致する」で(比較特徴量と比較正解をアヤメ特徴量列と1と300でロジスティック学習)と(比較特徴量と比較正解をアヤメ特徴量列と1と300で比較用素朴ロジスティック学習)が一致確認
「品種が 2 つでも素朴な書き方と丸ごと一致する」で(大値特徴量と二品種正解を[「x」, 「y」]と0.7と100でロジスティック学習)と(大値特徴量と二品種正解を[「x」, 「y」]と0.7と100で比較用素朴ロジスティック学習)が一致確認

このテストが空振りしていないことを、数学的には同じ値になる形にわざと崩して確かめました。指数の和を逆の順に足す・切片を最後に足す・学習率を先に件数で割る(学習率 × (勾配 ÷ 件数))のどれでも、最後の桁が変わって落ちます。

書き直しの前と後

命令の数を、自作のアヤメ風データ(150 行、訓練データ 105 行)で書き直しの前後に数えました。

部分 書き直す前 書き直した後
ロジスティック回帰 5000 回 約 3 億 9900 万 約 1 億 2600 万
ランダムフォレスト 100 本(深さの上限なし) 約 5300 万 約 1500 万
ランダムフォレスト 100 本(深さ 2) 約 4400 万 約 1050 万
報告全体(4 つのモデルと重要度) 約 5 億 5600 万 約 1 億 5800 万

報告全体は 2 億命令の内側に入りましたが、余裕は 4000 万命令ほどしかありません。実データでは値の種類が違うので、森の命令の数は変わります。そこで、報告を 2 つに分け、別々の実行で出せるようにしました。

実行 中身 命令の数
第10章実行 決定木・2 つの森の正解率と、森の特徴量の重要度 約 3100 万
第10章ロジスティック実行 ロジスティック回帰の正解率 約 1 億 2700 万

どちらも、選んだモデルの行だけを表にする モデル比較部分報告作成 を呼びます。4 つのモデルをまとめた報告(モデル比較報告作成)も残してあり、自作の 15 行のデータのテストではこちらを使います。

テストのファイルも分けました。test/chapter10_test.nako3 は学習データがあれば 第10章実行 を確かめ、ロジスティック回帰の実データは test/chapter10_logistic_test.nako3 が確かめます。自作のアヤメ風データで数えると、前者は約 7200 万命令、後者は約 1 億 2700 万命令で、上限に止められた「保留」は無くなりました。

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

書き直した後の実装で、報告全体を 4 つの自作のアヤメ風データで動かし、Python の参照実装と比べました(報告全体は上限を外した写しの処理系で動かしました。2 つに分けた実行は、本物の gonako でも動かして同じ行が出ることを確かめました)。参照実装は、第 3 章のジニ不純度でラベルの件数を「現れた順」に足すものと「名前の順」に足すものの 2 通りを用意しました。

データ 現れた順の参照実装 名前の順の参照実装 命令の数
値を 0.01 刻みにしたもの 3 つ 一致 一致 約 1.6 億
値を 0.05 刻みにして散らばりを 2 倍にしたもの 一致 重要度の 2 つが 4 桁目で違う 約 1.7 億

4 つ目のデータは、2 通りの参照実装の結果が分かれるものを探して見つけました(0.1 刻みの 40 個、0.05 刻みの 30 個では分かれず、散らばりを 2 倍にした 15 個目で分かれました)。分かれ方は実データと同じ形です。

特徴量 なでしこ3 版 現れた順の参照実装 名前の順の参照実装
がく片長さ 0.1682 0.1682 0.1682
がく片幅 0.1880 0.1880 0.1881
花弁長さ 0.3603 0.3603 0.3603
花弁幅 0.2834 0.2834 0.2833

4 つの正解率は 2 通りとも同じで、重要度だけが分かれました。なでしこ3 版は、現れた順の側(Java 版・Clojure 版・PHP 版の側)に落ちます。 Haskell 版の記事の「重要度は正解率よりもずっと実装に敏感である」という結論を、なでしこ3 版でも確かめられました。これらは自作のデータでの値で、実データの値ではありません。

期待する出力

ほかの言語版の値から、実データでの報告は次のようになるはずです。重要度は Java 版・Clojure 版・PHP 版の値です。4 つのモデルをまとめた形で示します(第10章実行 はロジスティック回帰の行を除いた形、第10章ロジスティック実行 は見出しとロジスティック回帰の行だけを出します)。

モデル 訓練データ   テストデータ
決定木(深さ 2)   0.9333  0.9556
ロジスティック回帰   0.9143  0.9111
ランダムフォレスト(100 本)    1.0000  0.9333
ランダムフォレスト(100 本・深さ 2)   0.9429  0.9556

ランダムフォレスト(100 本)の特徴量の重要度:
がく片長さ   0.1882
がく片幅    0.1271
花弁長さ    0.2708
花弁幅 0.4140

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

この章は学習データの無い環境で書いたため、上の表はまだなでしこ3 版の実装で実測していません。学習データを配置した環境で npx gulp apps:check:nadesiko3 を実行し、2 つのテストファイルの保留が消えて通ることを確かめてから、この注記を外します。自作のアヤメ風データでは、Python の参照実装と報告が 1 文字も違わないことを確かめています。学習データを ML_DATA_DIR で自作データに向けると、実データのテストは値の不一致だけで落ちることも確かめています。

10.9 可視化について

なでしこ3 版では Notebook と可視化を扱いません。モデルごとの正解率のグラフ、ロジスティック回帰の損失の推移、モデル別の特徴量の重要度、森の大きさと正解率の関係は、Python 版 と Kotlin 版 の可視化を参照してください。

ロジスティック学習 が返す辞書の 損失一覧 と、森重要度 が返す辞書は、ほかの版と同じ形のデータなので、同じ観点で読めます。

10.10 まとめ

この章では、ロジスティック回帰とランダムフォレストを自作し、モデルを辞書の約束でそろえてまとめて評価しました。

モデル 自作したもの 突き合わせた相手
ロジスティック回帰 ソフトマックス変換・交差エントロピー・ロジスティック学習 ライブラリなし(Python の参照実装と誤差 1e-12 で一致。書き直した後も、素朴な書き方と最後の桁まで一致)
ランダムフォレスト ブートストラップ標本作成・生成器並べ替え・ランダムフォレスト学習・森多数決 ライブラリなし(Python の参照実装と一致)
特徴量の重要度 不純度減少列挙・決定木重要度・森重要度 ライブラリなし(現れた順に足す参照実装と一致)

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

  1. 約束は辞書と無名関数で書ける — 無名関数は作られたときの変数を覚えている。どのモデルもただの辞書なので、Haskell 版のような存在型の包みが要らず、そのまま配列に並ぶ。約束を守っているかはテストだけが確かめる
  2. 辞書から取り出した関数は、変数に入れてから呼ぶ — モデル["学習"](…) は文法エラーになる
  3. EXP も LOG も止まらない — 無限大と NaN を返す。しかも 一致確認 は JSON で比べるので、無限大も NaN も null になって NULL と見分けられない
  4. 最後の 1 ビットは処理系の数学ライブラリで決まる — gonako の EXP・LOG は Go の math.Exp・math.Log で、C の数学ライブラリとは値の 1 割ほどで最後のビットが違う。足す順をそろえても、損失は完全には一致しない
  5. 辞書が入れた順を保つことが、版間の一致を分ける — 第 3 章のジニ不純度が現れた順に足すので、重要度は Java 版・Clojure 版・PHP 版の側に落ちる。自作データで、2 通りの参照実装が分かれる場合にもそちらの側と一致した
  6. 処理系には命令数の上限がある — 1 回の実行で 2 億命令。配列の要素への書き込みと繰り返しの 1 回が高くつくので、変数への足し込み・Go で書かれた命令(表行列交換・表列合計)・第 3 章の数え上げで、計算の結果を変えずに約 5.6 億命令を約 1.6 億命令にした。余裕を残すため、報告はロジスティック回帰とそれ以外を別々の実行で出す。素朴な書き方はテストに残し、丸ごと一致することを確かめ続ける

アヤメのテストデータ 45 件では、どのモデルも数件の差しかなく、1 回の分割での比較ではどのモデルが優れているとも言い切れません。次の章では、正解率以外の評価指標と、データの分け方を変えて何度も測る交差検証を学びます。

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