Skip to content

第 9 章: 特徴量エンジニアリング

9.1 はじめに

モデルの性能は、アルゴリズムよりも「どんな特徴量を渡すか」で大きく変わることがあります。元のデータから、モデルが学びやすい特徴量を作り出す作業を 特徴量エンジニアリング と呼びます。

この章では、ボストンの住宅価格データを題材に、次の 5 つの技法を TDD で自作します。

  • カテゴリ値をダミー変数(0 と 1 の列)にする
  • 特徴量を標準化する
  • 2 乗の項と交互作用の項(多項式特徴量)を作る
  • 外れ値を検出する
  • 別の表を結合して特徴量を増やす

最後に、作った特徴量で線形回帰の決定係数がどう変わるかを比べます。振る舞いと期待値は Haskell 版の第 9 章 に合わせました。

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

  • 既にある関数をどこまで使い回せるか。 ダミー変数は 第 13 章 の ボストンダミー変数化、線形回帰と決定係数は 第 7 章 の 線形回帰学習・決定係数算出、分割と補完は 第 2 章 の関数をそのまま取り込みます。第 13 章の標準化は使い回せませんでした。その理由が、この章の標準化の要点です
  • 文字コードを取り違えたとき、なでしこ3 は何をするか。 Shift_JIS のファイルを UTF-8 として読んでも、UTF-8 のファイルを Shift_JIS として読んでも、どちらもエラーになりません。どう化けるかを実測してテストに書きます
  • 統計のライブラリが無い。 Haskell 版は自作の標準偏差を statistics パッケージと突き合わせ、「n で割るか n−1 で割るか」をテストで固定しました。なでしこ3 には平均や標準偏差の命令もありません。突き合わせる相手を、手で計算できる値と、第 13 章の自作に置き換えます

Notebook による探索と可視化の節は設けません。標準化の前後の分布や天気ごとの利用者数のグラフは、Python 版の第 9 章 と Kotlin 版の第 9 章 を参照してください。

9.2 題材とデータ

学習データの入手と配置は 第 1 章 の「題材とデータ」を参照してください。この章では 3 つのファイルを使います。

ファイル 内容 区切り文字 文字コード
Boston.csv 地域ごとの住宅価格。100 件、14 列。CRIME は high・low・very_low のカテゴリ値、NOX と RAD に欠損値 カンマ ASCII の範囲だけ
bike.tsv 自転車シェアの日ごとの利用者数(cnt)と天気 ID(weather_id)。731 件 タブ ASCII の範囲だけ
weather.csv 天気 ID と天気の名前(晴れ・曇り・雨)。3 件 カンマ Shift_JIS

第 2 章の 表読込 は CSV取得 でカンマ区切りを読む関数です。タブ区切りのために、この章で区切り文字を選べる読み込みを足します。Shift_JIS は 第 1 章 で確かめた SJISファイル読 で読みます。

置き場は src/chapter09.nako3 1 つです。名前は、ほかの章と衝突しないように題材の名詞(住宅・天気・多項式・標準化器)を含めました。

!「./csv.nako3」を取り込む
!「./chapter02.nako3」を取り込む
!「./chapter07.nako3」を取り込む
!「./chapter13.nako3」を取り込む
!「./dataset.nako3」を取り込む
!「./number_format.nako3」を取り込む

# ボストンの住宅価格のデータの正解の列。
住宅目的列 = 「PRICE」
# カテゴリ値の列。
住宅カテゴリ列 = 「CRIME」
# 多項式特徴量を作る元の列。
住宅展開列 = [「RM」, 「LSTAT」, 「PTRATIO」]

9.3 TODO リストの作成

TODO リスト:

  • カテゴリ値をダミー変数にする(第 13 章の関数を確かめて使う)
  • 特徴量を標準化する
  • 平均と標準偏差を訓練データだけから求め、テストデータにも当てる
  • すべて同じ値の列で割り算をあふれさせない
  • n で割ることを、n−1 と取り違えていないことまで含めて固定する
  • 多項式特徴量を作る
  • 外れ値を検出する
  • 表を結合して特徴量を増やす
  • タブ区切りを読む
  • Shift_JIS を読む。取り違えたときの振る舞いを確かめる
  • 鍵で内部結合し、天気ごとに集計する
  • 特徴量の組み合わせごとに決定係数を比べる

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

CRIME は high・low・very_low の 3 つのカテゴリを取ります。これを 0 と 1 の列に開きます。列は 3 つではなく 2 つ です。low でも very_low でもなければ high だと分かるので、1 つは要りません(pandas の get_dummies(drop_first=True) と同じ)。

この処理は、第 13 章で主成分分析の前処理として ボストンカテゴリ列挙 と ボストンダミー変数化 がすでに作っていました。書き換えずに使い回せるかを、Haskell 版の第 9 章のテストをそのまま当てて確かめます。

「空欄を除いたカテゴリを辞書順に並べ、先頭を落とす」で(([「low」, 「high」, 「」, 「very_low」, 「low」])のボストンカテゴリ列挙)と[「low」, 「very_low」]が一致確認
二値表 = (「CRIME,ZN{改行}low,1{改行}high,2{改行}」を表読込)を住宅カテゴリ列でボストンダミー変数化
「カテゴリの列を除き、末尾にダミー変数の列を加える」で(二値表["列名一覧"])と[「ZN」, 「CRIME_low」]が一致確認
「カテゴリが一致すれば 1、それ以外は 0」で[二値表["行一覧"][0]["CRIME_low"], 二値表["行一覧"][1]["CRIME_low"]]と[1, 0]が一致確認

3 つとも通りました。Haskell 版との違いは 2 つあり、どちらもボストンのデータでは結果を変えません。

違い Haskell 版の categories・encode 第 13 章の ボストンダミー変数化
カテゴリの渡し方 呼ぶ側が categories で求めて渡す 表の値から中で求める
ダミー変数の値 文字列の "1"・"0" 数の 1・0

Haskell 版は「カテゴリに無い値はすべての列が 0」を、カテゴリを外から渡して確かめました。第 13 章の関数は外から渡せないので、同じ性質を「空欄の行」で確かめます。空欄はカテゴリに数えないので、どの列とも一致しません。

空欄表 = (「CRIME,ZN{改行}low,1{改行}high,2{改行},3{改行}」を表読込)を住宅カテゴリ列でボストンダミー変数化
「空欄はすべてのダミー変数を 0 にする」で(空欄表["行一覧"][2]["CRIME_low"])と0が一致確認

カテゴリの並べ替えに使う 配列ソート は、gonako のソースでは Go の文字列の < で比べています。これは UTF-8 のバイト順、つまり文字コードの順で、Haskell 版の Text の sort と同じ順です。

TODO リスト:

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

9.5 特徴量を標準化する

標準化とは

標準化 は、列ごとに「平均を引いて標準偏差で割る」変換です。単位の違う特徴量(部屋数の 6 と、税率の 300)を同じ物差しに乗せます。

第 13 章の標準化を使い回せなかった理由

第 13 章には ボストン標準化 もあります。ところが、これは 受け取った特徴量から平均と標準偏差を求め、その場で同じ特徴量を標準化する 関数です。主成分分析ではすべてのデータを一度に標準化するので、それで足りました。

この章では、平均と標準偏差を訓練データだけから求め、テストデータにも同じ値を使います。 テストデータの平均を使うと、本番では知り得ない情報がモデルに漏れます(リーケージ)。「覚えておいて、あとで別のデータに当てる」には、学習と適用を分けなければなりません。そこで、平均と標準偏差を覚えた辞書(標準化器)を返す関数と、それを当てる関数の 2 つに分けました。

●(特徴量一覧と列名一覧で)標準化器学習とは
    件数 = 特徴量一覧の要素数
    もし、件数 = 0ならば
        「特徴量が 1 件もありません」のエラー発生
    ここまで
    平均一覧 = {}
    偏差一覧 = {}
    Jを 0から (列名一覧の要素数) - 1まで繰り返す
        列名 = 列名一覧[J]
        和 = 0
        Iを 0から 件数 - 1まで繰り返す
            和 = 和 + (特徴量一覧[I]の列名を工学列値取得)
        ここまで
        平均 = 和 ÷ 件数
        二乗和 = 0
        Iを 0から 件数 - 1まで繰り返す
            偏差 = 特徴量一覧[I][列名] - 平均
            二乗和 = 二乗和 + 偏差 × 偏差
        ここまで
        標準偏差 = (二乗和 ÷ 件数)の平方根
        もし、標準偏差 = 0ならば
            標準偏差 = 1
        ここまで
        平均一覧[列名] = 平均
        偏差一覧[列名] = 標準偏差
    ここまで
    {"列名一覧": 列名一覧を配列複製, "平均一覧": 平均一覧, "標準偏差一覧": 偏差一覧}で戻る
ここまで

当てる側は、1 件の特徴量から新しい辞書を作って返します。配列と辞書は参照で渡るので、元の特徴量を書き換えないためです。平均を持たない列はそのまま残します。

●(標準化器で特徴量を)標準化器一件適用とは
    平均一覧 = 標準化器["平均一覧"]
    偏差一覧 = 標準化器["標準偏差一覧"]
    結果 = {}
    キー一覧 = 特徴量のハッシュキー列挙
    Iを 0から (キー一覧の要素数) - 1まで繰り返す
        列名 = キー一覧[I]
        もし、(平均一覧の列名がハッシュキー存在)ならば
            結果[列名] = (特徴量[列名] - 平均一覧[列名]) ÷ 偏差一覧[列名]
        違えば
            結果[列名] = 特徴量[列名]
        ここまで
    ここまで
    結果で戻る
ここまで

テストは、期待値を手で書き写すより、性質そのものを確かめます。

三件 = [{"a": 1}, {"a": 2}, {"a": 3}]
三件器 = 三件と[「a」]で標準化器学習
三件後 = 三件器で三件を標準化器適用
三件和 = 三件後[0]["a"] + 三件後[1]["a"] + 三件後[2]["a"]
「標準化すると平均が 0 になる」で三件和と0を0.000000000001で近似確認
「平均は訓練データから求める」で(三件器["平均一覧"]["a"])と2が一致確認
「標準化しても元の特徴量は書き換えない」で三件と[{"a": 1}, {"a": 2}, {"a": 3}]が一致確認

すべて同じ値の列

標準偏差が 0 だと割り算があふれます。第 7 章で見たとおり、なでしこ3 は 0 で割っても止まらず、NaN や Infinity を返して進みます。そこで標準偏差を 1 に置き換え、標準化した値が 0 になるようにしました。ボストンのデータでは、訓練データの CHAS(川に接しているか)がすべて 0 になる分割があり得ます。

定数器 = [{"a": 5}, {"a": 5}]と[「a」]で標準化器学習
「すべて同じ値の列は標準偏差を 1 とみなす」で(定数器["標準偏差一覧"]["a"])と1が一致確認
「すべて同じ値の列は標準化すると 0 になる」で(定数器で{"a": 5}を標準化器一件適用)と{"a": 0}が一致確認

n で割るか n−1 で割るか

標準偏差には 2 つの定義があります。偏差平方和を件数 n で割る 母標準偏差 と、n−1 で割る 標本標準偏差 です。この章は n で割ります(scikit-learn の StandardScaler と同じ)。

Haskell 版は statistics パッケージの variance(n で割る)と一致し、stdDev(n−1 で割る)とは一致しないことをテストで固定しました。なでしこ3 には平均や標準偏差を求める命令がありません(gonako doc 標準偏差・gonako doc 分散・gonako doc 平均 はどれも「一致する命令はありませんでした」です)。突き合わせる相手が無いので、手で計算できる標本 を使います。

標本 = [{"a": 2}, {"a": 4}, {"a": 4}, {"a": 4}, {"a": 5}, {"a": 5}, {"a": 7}, {"a": 9}]
標本器 = 標本と[「a」]で標準化器学習
「標準偏差は件数で割る(偏差平方和 32 ÷ 8 の平方根)」で(標本器["標準偏差一覧"]["a"])と2が一致確認
「件数から 1 を引いた数で割る標本標準偏差とは一致しない」で((標本器["標準偏差一覧"]["a"]) = ((32 ÷ 7)の平方根))と偽が一致確認
「第 13 章のボストン標準化と最後の桁まで一致する」で(標本器で標本を標準化器適用)と(標本と[「a」]のボストン標準化)が一致確認

平均 5 からの偏差平方和は 32 なので、n で割れば標準偏差はちょうど 2 です。偽 と比べるテストは空振りしやすいので、二乗和 ÷ 件数 を 二乗和 ÷ (件数 - 1) に変えて、落ちることを確かめました。上の 3 つがそろって落ち、「取り違えた定義」を名指しで示せます。

3 つ目は、第 13 章の ボストン標準化 との突き合わせです。同じデータに学習と適用を続けて行えば、第 13 章の関数と同じ計算になるはずで、実際に最後の桁まで一致しました。ライブラリの代わりに、別の章で別に書いた自作を突き合わせの相手にしたことになります。

なお、第 13 章の分散共分散行列は n−1 で割っています。寄与率は割る数によらないので結論は変わりませんが、「同じシリーズの中でも、どちらで割るかは場所ごとに決まっている」ことは、名前ではなくテストで固定しておくのが安全です。

TODO リスト:

  • 特徴量を標準化する

9.6 多項式特徴量を作る

2 乗の項と交互作用の項

線形回帰は直線しか引けませんが、特徴量に RM^2 や RM × LSTAT を加えれば、曲がった関係も表せます。これを 多項式特徴量 と呼びます。

列の組は、scikit-learn の PolynomialFeatures と同じ順(重複を許して 2 つ選ぶ)にそろえます。内側の繰り返しを I から 始めるだけで「重複を許す組」になります。

●(列名一覧の)重複組合せ列挙とは
    結果 = []
    Iを 0から (列名一覧の要素数) - 1まで繰り返す
        Jを Iから (列名一覧の要素数) - 1まで繰り返す
            結果に[列名一覧[I], 列名一覧[J]]を配列追加
        ここまで
    ここまで
    結果で戻る
ここまで

# 項の名前。scikit-learn の get_feature_names_out と同じ形(RM^2・RM LSTAT)にする。
●(左列名と右列名の)多項式項名作成とは
    もし、左列名 === 右列名ならば
        「{左列名}^2」で戻る
    ここまで
    「{左列名} {右列名}」で戻る
ここまで

列名の比較は === にしました。= は JavaScript の == と同じゆるい比較で、「0」 = 0 も真になります(第 2 章)。列名が数字だけの表を渡されても取り違えないためです。

展開と選択

「2 乗の項と交互作用の項を作る」で(([{"x": 2, "y": 3, "z": 5}])と([「x」, 「y」])で多項式特徴量展開)と[{"x": 2, "y": 3, "x^2": 4, "x y": 6, "y^2": 9}]が一致確認
「指定した列だけを選ぶ」で(([{"x": 1, "y": 2}])と([「y」])で特徴量列選択)と[{"y": 2}]が一致確認

多項式特徴量展開 は、指定した列と、その 2 乗・交互作用の項だけを持つ特徴量を作ります(指定しなかった z は落ちます)。特徴量列選択 は、そこから使う項だけを選びます。この 2 つを分けておくと、「3 列」「6 列」「9 列」の 3 通りを同じ展開の結果から作れます。

テストの期待値の辞書は、キーの順まで一致しています。一致確認 は JSON にしてから比べ、なでしこ3 の辞書はキーを入れた順を保つので、列の順が変われば落ちます。展開した特徴量の列が「元の列 → 項」の順に並ぶことまで、このテストが固定しています。

無い列を指定すれば、Haskell 版と同じ「列がありません: y」で止まります。

TODO リスト:

  • 多項式特徴量を作る

9.7 外れ値を検出する

四分位範囲(IQR)から大きく離れた値を外れ値とします。分位数は pandas の既定(linear)と同じく、位置が値の間にあれば前後から線形補間します。

●(値一覧と割合の)外れ値分位数算出とは
    もし、(値一覧の要素数) = 0ならば
        「値が 1 件もありません」のエラー発生
    ここまで
    整列値 = (値一覧を配列複製)の配列数値ソート
    位置 = ((整列値の要素数) - 1) × 割合
    下位 = 位置を切捨
    上位 = 位置を切上
    整列値[下位] + (整列値[上位] - 整列値[下位]) × (位置 - 下位)で戻る
ここまで

配列数値ソート は 渡した配列そのものを並べ替えます。訓練データの正解の値をそのまま渡すと、特徴量との対応が崩れます。複製してから並べ、テストで確かめました。

未整列値 = [4, 1, 3, 2]
「並んでいない値も並べてから求める」で(未整列値と0.75の外れ値分位数算出)と3.25が一致確認
「分位数を求めても元の配列は並べ替えない」で未整列値と[4, 1, 3, 2]が一致確認
「四分位範囲の 1.5 倍から外れた値を外れ値とする」で(([1, 2, 3, 4, 100])と1.5で四分位範囲外れ値判定)と[偽, 偽, 偽, 偽, 真]が一致確認

検出した外れ値は、訓練データからだけ 取り除きます(住宅外れ値除去)。テストデータは「本番で来るデータ」の代わりなので、都合の悪い値を取り除いてしまうと評価の意味がなくなります。

「訓練データからだけ外れ値を取り除く」で[外れ除去後["訓練正解"], 外れ除去後["テスト正解"]]と[[1, 2, 3, 4], [100]]が一致確認

送り仮名は名前から消える

四分位範囲外れ値判定 の中で、四分位範囲の幅を入れる変数を 範囲 と名付けたら、文法の検査が次のエラーで止まりました。

[文法エラー]src/chapter09.nako3(208行目): 関数『chapter09__四分位範囲外値判定』の定義で以下のエラーがありました。
[文法エラー]src/chapter09.nako3(211行目): 関数『範囲』に代入できません。

範囲 が組み込みの命令だったのは、これまでと同じ種類の衝突です(変数名を 四分位範囲幅 にしました)。目を引くのは 1 行目で、関数の名前が 四分位範囲外値判定 になっています。定義では 外れ値 と書いたのに、れ が消えています。

使い捨てのスクリプトで確かめると、●外れ値試験とは と定義した関数は 外値試験 でも 外れ値試験 でも呼べ、●区切り表読込とは は 区切表読込 でも呼べました。漢字に挟まれた送り仮名は、名前から落とされます。 送り仮名だけが違う 2 つの名前は同じ関数になるということで、名前をファイルをまたいで一意にする約束(ADR 015)は、送り仮名を除いた形で一意でなければなりません。

9.8 表を結合して特徴量を増やす

区切り文字を指定する

gonako doc TSV で調べると、CSV取得 とは別に TSV取得 があります。gonako のソース(internal/csvlib/csvlib.go)では、2 つは区切り文字が違うだけの同じ読み取りで、数字だけのセルを数にする変換も同じです。違いは 1 つで、セルの前の空白を落とすとき、TSV取得 はタブを落としません(タブを空白として落とすと、区切りそのものが消えるため)。

TSV取得 も BOM を取り除かず、空行を「空文字列 1 つだけの行」として読みます(ADR 015)。第 2 章の 表読込 がしていたことを、タブ区切りでも同じようにします。

●(本文を分割記号で)区切文字指定読込とは
    もし、分割記号 === 「,」ならば
        本文を表読込で戻る
    ここまで
    もし、分割記号 !== 「{タブ}」ならば
        「区切り文字に対応していません: {分割記号}」のエラー発生
    ここまで
    セル表 = (本文の(65279のCHR)を「」に単置換)をTSV取得
    セル表の分割セル表変換で戻る
ここまで

カンマ区切りは第 2 章の 表読込 にそのまま渡します。タブ区切りのファイルをうっかりカンマ区切りとして読むと、エラーにはならず 1 列の表 になります。これもテストにしました。

「タブ区切りでも BOM と空行を取り除く」で[BOM付タブ表["列名一覧"], BOM付タブ表["行一覧"]]と[[「dteday」, 「cnt」], [{"dteday": 「2011-01-01」, "cnt": 985}, {"dteday": 「2011-01-02」, "cnt": 801}]]が一致確認
「タブ区切りの文字列をカンマ区切りとして読むと 1 列になる」で((「a{タブ}b{改行}1{タブ}2{改行}」を「,」で区切文字指定読込)["列名一覧"])と[「a{タブ}b」]が一致確認

2011-01-01 は数字だけのセルではないので文字列のまま、985 は数になります。

Shift_JIS

weather.csv は Shift_JIS です。なでしこ3 には SJISファイル読 があり、追加のライブラリは要りません。gonako のソース(internal/nodelib/encoding.go)では、Go の golang.org/x/text/encoding/japanese の ShiftJIS で復号しています。

テストでは、一時フォルダに Shift_JIS と UTF-8 の 2 つのファイルを書き、それぞれを両方の読み方で読みます。書き出しにも SJISファイル保存 という対になる命令があるので、テストのためだけの変換は要りません。

文字コード場所 = 一時フォルダ作成
SJIS先 = 「{文字コード場所}/weather-sjis.csv」
UTF8先 = 「{文字コード場所}/weather-utf8.csv」
「weather_id,weather{改行}1,晴れ{改行}」をSJIS先へSJISファイル保存
「weather_id,weather{改行}1,晴れ{改行}」をUTF8先に保存
SJIS正読 = (SJIS先をSJISファイル読)を「,」で区切文字指定読込
「Shift_JIS の CSV を SJISファイル読 で読む」で(SJIS正読["行一覧"][0]["weather"])と「晴れ」が一致確認

取り違えたときに何が起きるか

シリーズでは言語ごとに違う振る舞いを見てきました。Haskell 版は、Shift_JIS を UTF-8 として読むと例外になり、逆向きは OS の iconv によって例外にも文字化けにもなりました。なでしこ3 の実測です。

SJIS誤読 = SJIS先を開く
「Shift_JIS を UTF-8 として読んでもエラーにならない」で(SJIS誤読で1から「weather_id」を文字検索)と1が一致確認
「Shift_JIS を UTF-8 として読むと、晴れは読めない」で(SJIS誤読で1から「晴れ」を文字検索)と0が一致確認
SJIS誤読天気 = (SJIS誤読を「,」で区切文字指定読込)["行一覧"][0]["weather"]
「晴れの 4 バイトは 4 文字に数えられる」で(SJIS誤読天気の文字数)と4が一致確認
「読めないバイトは置換文字 U+FFFD に置き換わらず、そのまま残る」で(SJIS誤読天気 === 「{65533のCHR}{65533のCHR}{65533のCHR}{65533のCHR}」)と偽が一致確認
「JSON にすると読めないバイトが 1 つずつ \ufffd になる」で(SJIS誤読天気をJSONエンコード)と『"\ufffd\ufffd\ufffd\ufffd"』が一致確認
UTF8誤読 = UTF8先をSJISファイル読
「UTF-8 を Shift_JIS として読んでもエラーにならない」で(UTF8誤読で1から「weather_id」を文字検索)と1が一致確認
「UTF-8 を Shift_JIS として読むと、晴れは別の文字に化ける」で((UTF8誤読を「,」で区切文字指定読込)["行一覧"][0]["weather"])と「譎エ繧{65533のCHR}」が一致確認
  • Shift_JIS を 開く(UTF-8 のまま読む)と、エラーにならず、読めないバイトがそのまま文字列に残ります。 gonako の 開く はファイルのバイト列を Go の文字列にするだけで、検査も置き換えもしません(internal/nodelib/file.go)。「晴れ」の 4 バイト(90 B0 82 EA)は、文字数 では 4 文字に数えられ、U+FFFD の 4 文字とは等しくなく、JSONエンコード したときに初めて \ufffd が 4 つになります。表示や比較のたびに扱いが変わる、いちばん気づきにくい化け方です
  • 使い捨てのスクリプトでは、「曇り」の 4 バイト(93 DC 82 E8)のうち DC 82 がたまたま UTF-8 として正しい 2 バイト(シリア文字の U+0702)になり、一部だけ別の文字として読めてしまう ことも見ました
  • UTF-8 を SJISファイル読 で読んでも、エラーになりません。 「晴れ」の 6 バイトが Shift_JIS の 2 バイト文字・半角カナとして解釈され、譎エ繧 と、Shift_JIS として読めない最後のバイトの置換文字 U+FFFD になります。改行は吸い込まれず、行の数は変わりませんでした
  • どちら向きでも、weather_id のような ASCII の部分は正しく読めます。 列名は合っているので、表として読めてしまい、化けるのは天気の名前だけです

Java 版は例外、Clojure 版は U+FFFD、Haskell 版は既定で例外でした。なでしこ3 は PHP 版と同じく どちら向きも静かに通る 側です。gonako の SJISファイル読 は復号に失敗したときにエラーを返す作りですが、x/text の Shift_JIS の復号は読めないバイトを U+FFFD に置き換えるので、そのエラーは実際には起きませんでした。

言語の既定の振る舞いが取り違えを止めてくれないので、止めるのはテストです。 この章の実データのテストは、天気の名前が「晴れ・曇り・雨」であることを報告の文字列ごと確かめます。第9章実行 で SJISファイル読 を 開く に取り違えれば、表示が化けて落ちます。

結合と集計

結合は、天気 ID から天気の行を引く辞書(索引)を作って引くだけです。

●(自転車表と天気表の)天気結合とは
    索引 = {}
    天気行一覧 = 天気表["行一覧"]
    Iを 0から (天気行一覧の要素数) - 1まで繰り返す
        索引[「{(天気行一覧[I]の天気結合キーを列取得)}」] = 天気行一覧[I]
    ここまで
    もし、((索引のハッシュキー列挙)の要素数) ≠ (天気行一覧の要素数)ならば
        「{天気結合キー} が一意ではありません」のエラー発生
    ここまで

気をつけたことが 2 つあります。

  • 辞書は同じキーを静かに上書きします。 Haskell 版の M.fromList と同じで、件数を比べて、鍵が一意でないことに気付けるようにしました
  • キーを文字列にそろえました。 表読込 も TSV取得 も数字だけのセルを数にするので、weather_id の値は数の 1 です。「{…}」 で文字列にしてからキーにします

引けない行は残しません(内部結合)。集計は天気ごとの平均利用者数を、多い順に並べます。

「天気ごとの平均利用者数を多い順に並べる」で((「weather,cnt{改行}雨,100{改行}晴れ,300{改行}晴れ,500{改行}」を表読込)の天気別平均利用者数算出)と[[「晴れ」, 400], [「雨」, 100]]が一致確認
「平均が同じなら天気の名前の順(文字コードの順)に並べる」で((「weather,cnt{改行}雨,100{改行}晴れ,100{改行}」を表読込)の天気別平均利用者数算出)と[[「晴れ」, 100], [「雨」, 100]]が一致確認

2 つ目は、平均が同じときの順です。Haskell 版は Map でまとめてから安定なソートで並べるので、同じ平均なら キーの順(文字コードの順)になります。なでしこ3 の辞書はキーを 入れた順 に並べるので、そのままでは現れた順(雨が先)になります。天気の名前を 配列ソート で並べてから、より大きいときだけ前へ動かす挿入ソートで並べ、Haskell 版と同じ順にしました。配列ソート を外すとこのテストが落ちることを確かめています。

TODO リスト:

  • 表を結合して特徴量を増やす

9.9 特徴量の効果を測る

線形回帰と決定係数

作った特徴量の効果は、線形回帰の 決定係数(R²)で測ります。線形回帰は 第 7 章 の 線形回帰学習(正規方程式を部分ピボット付きのガウスの消去法で解く)と 決定係数算出 を、書き換えずに取り込みます。Haskell 版の第 9 章は、同じ章の中に線形回帰を書き直していましたが、なでしこ3 版では第 7 章のモデル(列名と係数の辞書)がそのまま使えました。

「同じ列を 2 つ渡すと解けない」で同列学習を「解けません。特異行列です」でエラー確認

Haskell 版では「特徴量の列が互いに独立でないため、正規方程式を解けません」でした。止まる場所は同じで、文言だけが第 7 章のものになります。

前処理をまとめる

●(CSV文字列を割合とシードで)住宅前処理とは
    変換表 = (CSV文字列を表読込)を住宅カテゴリ列でボストンダミー変数化
    分離結果 = 変換表と住宅目的列の目的列分離
    列名一覧 = 分離結果["特徴量列"]
    分割結果 = (分離結果["行一覧"])と(分離結果["正解ラベル"])を割合とシードで訓練テスト分割
    平均一覧 = (分割結果["訓練特徴量"])と列名一覧の列平均算出
    訓練特徴量 = (分割結果["訓練特徴量"])と列名一覧を平均一覧で欠損補完
    テスト特徴量 = (分割結果["テスト特徴量"])と列名一覧を平均一覧で欠損補完
    訓練正解 = (分割結果["訓練正解"])の住宅目的値変換
    テスト正解 = (分割結果["テスト正解"])の住宅目的値変換
    {"列名一覧": 列名一覧, "訓練特徴量": 訓練特徴量, "テスト特徴量": テスト特徴量, "訓練正解": 訓練正解, "テスト正解": テスト正解}で戻る
ここまで

第 13 章の ボストン行列読込 は、分割せずにすべての行の平均で補完してから標準化します。主成分分析はそれで正しいのですが、この章は 分割してから訓練データの平均で補完する 必要があるので、使い回さずに第 2 章の関数を並べ直しました。第 13 章から使ったのはダミー変数の 2 つの関数だけです。

特徴量の組を比べる

3 通りの項の組を、同じ 住宅特徴量組評価 で測ります。

●(分割結果と展開列一覧を項名一覧で)住宅特徴量組評価とは
    訓練項 = ((分割結果["訓練特徴量"])と展開列一覧で多項式特徴量展開)と項名一覧で特徴量列選択
    テスト項 = ((分割結果["テスト特徴量"])と展開列一覧で多項式特徴量展開)と項名一覧で特徴量列選択
    標準化器 = 訓練項と項名一覧で標準化器学習
    訓練標準 = 標準化器で訓練項を標準化器適用
    テスト標準 = 標準化器でテスト項を標準化器適用
    モデル = 訓練標準と(分割結果["訓練正解"])を項名一覧で線形回帰学習
    訓練決定係数 = (分割結果["訓練正解"])と(モデルで訓練標準を線形回帰予測)の決定係数算出
    テスト決定係数 = (分割結果["テスト正解"])と(モデルでテスト標準を線形回帰予測)の決定係数算出
    [訓練決定係数, テスト決定係数]で戻る
ここまで

「展開 → 選択 → 標準化 → 学習 → 評価」が 1 行ずつ日本語の文として並びます。Haskell 版の do 記法と同じく、途中のどこで失敗してもエラーが呼び出し元まで伝わります(なでしこ3 は エラー発生 で止まり、エラー監視 で捕まえられます)。

ほかの言語版の数値と突き合わせる

学習データはこの環境に無いので、第 7 章 と同じ方法で確かめました。

  1. 自作のボストン風データ を作りました。列名・100 行・CRIME の 3 つのカテゴリ・NOX に 3 か所と RAD に 4 か所の欠損・PRICE の外れ値(上限の 50 に張り付いた行など)を実データとそろえた、架空の値です。自転車は 731 行のタブ区切り、天気は 3 行の Shift_JIS にしました。配布データではありません
  2. Python で参照実装 を書きました。Haskell 版と同じ規則(java.util.Random と同じ線形合同法による分割、n で割る標準偏差、線形補間の分位数)で、正規方程式はなでしこ3 版と同じ部分ピボット付きのガウスの消去法で解きます
  3. 参照実装そのものも、別の実装で確かめました。Go 版の第 9 章は分割に Go の math/rand を使うので、写しの分割だけを java.util.Random と同じ線形合同法に差し替えて自作データで動かし、報告の 10 行が参照実装と 1 文字も違わず一致しました

結果は次のとおりです。

突き合わせたもの 結果
第9章実行 の報告(自作データ、ML_DATA_DIR で指定) 参照実装と 1 文字も違わず一致
決定係数 8 個(完全な精度) 参照実装と 最後の桁まで一致(差 0)
テストに書いた 20 行の自作データの報告と決定係数 8 個 参照実装と一致(報告は文字列の一致、決定係数は 1e-12 以内の近似確認)

足す順と解き方をそろえたので、最後の桁まで一致しました。第 7 章で見たとおり、ガウスの消去法(なでしこ3 版)とガウス・ジョルダン法(Haskell 版)では 13〜14 桁目から違いが出ますが、表示する 4 桁には届きません。

表示の丸め方も確かめました。なでしこ3 の 小数点四捨五入 は、gonako のソースでは floor(値 × 10^桁 + 0.5) ÷ 10^桁 です。Haskell の printf は、最短の 10 進表現の桁を四捨五入します。ちょうど 5 で終わる値では 2 つが食い違いえますが、参照実装で両方の丸め方を試すと、自作データの 2 つの報告ではどちらも同じ文字列になりました。

自作データを ML_DATA_DIR で指して検査を走らせると、実データの分岐は 値の不一致だけで落ち、書き間違いのエラーでは止まりませんでした(訓練データ 70 件・テストデータ 30 件の検査は通ります)。

命令の数

gonako は 1 回の実行で 2 億命令を超えると止まります。この章は小さな表しか扱わないので余裕があります。計測用の gonako(実行した命令の数を数える写し)で測りました。

実行 命令の数
第9章実行(自作データ、実データと同じ大きさ) 約 149 万
test/chapter09_test.nako3(学習データなし) 約 53 万
test/chapter09_test.nako3(自作データを ML_DATA_DIR で指定) 約 309 万

線形回帰は 4 回で、いちばん大きい正規方程式でも 10×10 です。第 10 章(約 5.6 億命令で収まらなかった)とは 2 桁以上違います。

実データで測る

./bin/gonako main.nako3 chapter09

期待する出力は次のとおりです。Haskell 版 の出力と同じで、決定係数の 8 個は Java 版・Scala 版・Clojure 版・Elixir 版・PHP 版・Haskell 版の第 9 章と同じ値です。

訓練データ: 70 件, テストデータ: 30 件
特徴量の列: ZN, INDUS, CHAS, NOX, RM, AGE, DIS, RAD, TAX, PTRATIO, B, LSTAT, CRIME_low, CRIME_very_low
標準化した訓練データの RM: 平均 0.00, 標準偏差 1.00
決定係数:
  元の特徴量(3 列): 訓練 0.6056, テスト 0.6950
  2 乗の項を追加(6 列): 訓練 0.7740, テスト 0.8628
  交互作用の項も追加(9 列): 訓練 0.7953, テスト 0.8213
訓練データの PRICE の外れ値: 8 件
  外れ値を除いて 2 乗の項を追加: 訓練 0.6717, テスト 0.7947
天気ごとの平均利用者数: 晴れ=4876.8, 曇り=4052.7, 雨=1803.3

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

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

読み方は Haskell 版と同じです。

  • 2 乗の項 を加えると、テストデータの決定係数が 0.6950 から 0.8628 に上がります
  • 交互作用の項 も加えると、訓練データでは上がる(0.7740 → 0.7953)のに、テストデータでは下がります(0.8628 → 0.8213)。列を増やすと訓練データには合わせやすくなりますが、未知のデータへの当てはまりが良くなるとは限りません
  • 外れ値 を除いて学習すると、テストデータの決定係数は 0.7947 に下がります。テストデータには外れ値が残っているので、外れ値を学ばなかったモデルはそれを当てられません
  • 天気ごとの平均利用者数は、分割に関係しないので、どの版でも同じ値です

第9章実行 は、ファイルを読んで文字列を 特徴量工学報告作成 に渡すだけです。報告を組み立てる関数は文字列を受け取るので、20 行の自作データで直接呼ぶテストを置いています(実データの分岐は、データの無い環境では一度も実行されないため)。

●第9章実行とは
    住宅本文 = (「Boston.csv」のデータパス取得)を開く
    自転車本文 = (「bike.tsv」のデータパス取得)を開く
    天気本文 = (「weather.csv」のデータパス取得)をSJISファイル読
    住宅本文と自転車本文と天気本文で特徴量工学報告作成して戻る
ここまで

TODO リスト:

  • 特徴量の組み合わせごとに決定係数を比べる(実データでの実測は未確認。9.9 節の注記を参照)

9.10 Notebook による探索と可視化

なでしこ3 版では Notebook と可視化の節を設けません。標準化の前後の分布、特徴量と価格の関係、外れ値、天気ごとの利用者数のグラフは、Python 版の第 9 章 と Kotlin 版の第 9 章 の「Notebook による探索と可視化」の節を参照してください。

9.11 リファクタリング

品質チェック

./tools/check.sh
項目 結果
第 9 章のテスト 69 件(保留 1 件)
関数の網羅(全体) 213 件中 213 件(直接 170 件、間接 43 件)

この章で足した関数は 26 個で、すべてテストから名前が届きます。

検査に止められたところ

  1. 文字列の中に「」を入れ子にした — 「{(行一覧[I]の「weather」を列取得)}」 と書いて、字句解析が「『「』で始めた文字列の中に『「』を含めることはできません」で止まりました。列名を変数(天気名列)に入れました
  2. 範囲 が組み込みの命令だった — 9.7 節のとおりです。そのときのエラーで、送り仮名が名前から落とされることに気づきました

第 12 章・第 14 章から使う関数

第 12 章(正則化)でも標準化と多項式特徴量を使います。次の名前で公開しています。

関数 内容
(特徴量一覧と列名一覧で)標準化器学習 指定した列の平均と標準偏差(n で割る)を覚えた辞書を返す
(標準化器で特徴量を)標準化器一件適用 1 件を標準化した新しい辞書を返す。平均を持たない列はそのまま残す
(標準化器で特徴量一覧を)標準化器適用 配列を標準化する
(特徴量一覧と列名一覧の)特徴量行列変換 特徴量を列の順に並べた数値の行にする
(特徴量一覧と列名一覧で)多項式特徴量展開 指定した列と、2 乗・交互作用の項を持つ特徴量にする
(列名一覧の)多項式列名一覧作成 元の列の後ろに 2 乗と交互作用の項の名前を並べる

9.12 まとめ

この章では、特徴量エンジニアリングの 5 つの技法をなでしこ3 の TDD で自作し、特徴量の組ごとの決定係数を比べました。

技法 自作したもの 使い回したもの 落とし穴
ダミー変数 — 第 13 章の ボストンカテゴリ列挙・ボストンダミー変数化 カテゴリを外から渡せないので、「カテゴリに無い値」は空欄で確かめた
標準化 標準化器学習・標準化器適用 — 第 13 章の標準化は学習と適用が分かれていない。n で割るか n−1 で割るか
多項式特徴量 多項式特徴量展開・多項式項名作成 — 列数が急に増えて過学習しやすい
外れ値の検出 外れ値分位数算出・四分位範囲外れ値判定 — 配列数値ソート は元の配列を並べ替える
表の結合 区切文字指定読込・天気結合 第 2 章の 表読込 文字コードの取り違えが黙って通る。辞書の静かな上書き

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

  1. 文字コードの取り違えは、どちら向きでもエラーにならない — Shift_JIS を 開く で読むと読めないバイトがそのまま残り、JSONエンコード したときだけ \ufffd になる。UTF-8 を SJISファイル読 で読むと U+FFFD の混じった別の文字になる。Haskell 版の既定(例外)とは逆で、止めるのはテストだけ
  2. 統計の命令が無いので、手で計算できる標本と、別の章の自作を突き合わせの相手にした — 偏差平方和 32 の標本で「n で割れば 2」を固定し、第 13 章の ボストン標準化 と最後の桁まで一致した
  3. 使い回せるかどうかは、名前ではなく振る舞いで決まる — 第 13 章の ボストン標準化 と ボストン行列読込 は、学習と適用・分割と補完の順が違うので使えなかった。ダミー変数の 2 つは Haskell 版のテストを当てて使えると分かった
  4. 漢字に挟まれた送り仮名は名前から消える — 外れ値試験 と 外値試験 は同じ関数になる。名前の一意性は送り仮名を除いた形で考える
  5. 足す順と解き方をそろえれば、最後の桁まで一致する — 自作データで決定係数 8 個が参照実装と差 0 で一致し、分割だけを差し替えた Go 版とも報告が一致した

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

  • カテゴリ値をダミー変数にする(第 13 章の関数を確かめて使う)
  • 特徴量を標準化する
  • 多項式特徴量を作る
  • 外れ値を検出する
  • 表を結合して特徴量を増やす
  • 特徴量の組み合わせごとに決定係数を比べる(実データでの実測は未確認)

次の章では、分類のモデルを増やし、ロジスティック回帰と、第 3 章の決定木を組み合わせたランダムフォレストを実装します。

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