Skip to content

第 2 章: データの前処理と三角測量

2.1 はじめに

この章では、機械学習の前に必ず必要になる前処理を実装します。題材はアヤメ(iris)のデータで、150 件の測定値から品種を当てる問題です。第 3 章で決定木を学習させるための土台を作ります。

この章の山場は乱数生成器を自作することです。なでしこ3 の 乱数 にはシードを渡す口が無く、並びを再現できません。そこで java.util.Random と同じ 48 ビットの線形合同法を書きます。

ここでなでしこ3 に固有の難しさがあります。数値が float64 だけなのです。Haskell 版は Int が溢れると折り返すので仕様をそのまま書けました。PHP 版は溢れると float に化けるので乗算を分けました。なでしこ3 は最初から float しか無いので、PHP 版よりさらに細かく分けます。

Python 版の第 2 章 と同じ題材・同じ TODO リストで進めます。

2.2 題材とデータ

iris.csv

iris.csv には、アヤメの花 150 件の測定値と品種が記録されています。

列 意味 なでしこ3 版での読み方
がく片長さ がく片の長さ 行の「がく片長さ」を欠損許容数値取得 → 数値、空欄なら NULL
がく片幅 がく片の幅 同上
花弁長さ 花弁の長さ 同上
花弁幅 花弁の幅 同上
種類 品種(3 種類が 50 件ずつ) 行の「種類」を列取得 → 文字列

値は 0.0〜1.0 に収まる形で配布されています。特徴量の 4 列には合わせて 7 件の欠損値があります。

欠損は NULL で表し、読み取りの失敗はエラーで表します。 「列名を間違えた」「数値として読めない」はエラーを起こし、「値が空欄だった」は NULL を返します。Haskell 版が Either String (Maybe Double) の二重の型で分けたものを、なでしこ3 では「エラーか、値か NULL か」で分けます。ただし型の検査が無いので、NULL の扱いを忘れても誰も止めてくれません。

訓練データとテストデータ

学習に使うデータでそのまま性能を測ると、「答えを覚えただけ」のモデルを高く評価してしまいます。そこでデータを 2 つに分けます。この章では 150 件を訓練データ 105 件・テストデータ 45 件に分けます。

なぜ乱数を自作するのか

分割の前に並べ替えます。並べ替えには乱数が要ります。

ほかの言語版の乱数と並びが一致しなければ、訓練データに入る 105 行が変わり、そこから求まる平均値も、第 3 章の決定木の境界も変わります。章をまたいだ突き合わせができなくなります。そこでなでしこ3 版も、Elixir 版・PHP 版・Haskell 版と同じく java.util.Random と同じ乱数生成器を自作します。

2.3 開発環境の準備

第 1 章で作った apps/nadesiko3/ にファイルを足します。

apps/nadesiko3/
├── src/
│   ├── chapter02.nako3   # この章
│   ├── csv.nako3         # この章で列の順と欠損値の読み取りを足す
│   └── random.nako3      # この章(自作の乱数)
└── test/
    ├── chapter02_test.nako3
    ├── csv_test.nako3
    └── random_test.nako3

外部のライブラリは使わないので、足すものはファイルだけです。

2.4 TODO リストの作成

TODO リスト:

  • 乱数生成器を自作する
  • シードから状態を作る
  • 範囲を指定して整数を 1 つ返す
  • Fisher-Yates で並べ替える
  • シード 0 の並びが JVM の言語版と一致する
  • 表を読み込む
  • 数値の列を読む。空欄は欠損値にする
  • 列ごとに欠損値の数を数える
  • 平均値で欠損値を補完する
  • 特徴量と正解ラベルに分ける
  • 訓練データとテストデータに分ける
  • 前処理をまとめる
  • 実データで前処理の結果を表示する

2.5 乱数生成器を自作する

java.util.Random の仕様

java.util.Random は 48 ビットの状態を持ち、次の式で状態を進めます。

状態 = (状態 × 0x5DEECE66D + 0xB) を 2^48 で割った余り

シードからの初期化は (シード XOR 0x5DEECE66D) の下 48 ビットです。整数を 1 つ取り出すときは、状態の上位 31 ビットを使います。

float64 では 2^53 までしか正確でない

なでしこ3 の数値は IEEE 754 の倍精度浮動小数点数(float64)だけで、整数を正確に表せるのは 2^53 までです。48 ビットの状態に 35 ビットの乗数を掛けると、積は 2^83 に達します。そのまま掛けると下の桁が失われます。

「{25214903917 × 281474976710655}」を表示
7.097364492798979e+24

24 ビットずつに分けて掛ける

状態と乗数をどちらも 24 ビットずつに分けます。

状態 = 上位 × 2^24 + 下位       (上位・下位はどちらも 24 ビット)
乗数 = 1502 × 2^24 + 15525485   (0x5DE と 0xECE66D)

展開すると 4 つの積になります。

状態 × 乗数 = 上位×1502 × 2^48 + (上位×15525485 + 下位×1502) × 2^24 + 下位×15525485

2^48 の余りを取るので、1 つめの項は丸ごと消えます。 残りの積はどれも 24 ビット × 24 ビット以下なので 2^48 未満に収まり、float64 で誤差なく計算できます。

二十四ビット = 16777216
乗数上位 = 1502
乗数下位 = 15525485
加数 = 11

# 状態を 1 つ進める。状態 = 状態 × 乗数 + 加数(2^48 の剰余)。生成器を書き換える。
●(生成器を)状態前進とは
    上位 = 生成器["上位"]
    下位 = 生成器["下位"]
    下位積 = 下位 × 乗数下位 + 加数
    新下位 = 下位積 % 二十四ビット
    繰上 = (下位積 - 新下位) ÷ 二十四ビット
    中位 = 上位 × 乗数下位 + 下位 × 乗数上位 + 繰上
    生成器["上位"] = 中位 % 二十四ビット
    生成器["下位"] = 新下位
ここまで

最初は 繰上 を 桁上がり と書き、「が」が助詞として読まれて「桁上」「り」に割られました。第 1 章の約束(名前にひらがなを混ぜない)をここでも守ります。

生成器は {"上位": …, "下位": …} の辞書で、関数の中で書き換えます。辞書は参照で渡るので、呼ぶ側の生成器もそのまま進みます。Haskell 版は状態を引数と戻り値で受け渡しましたが、なでしこ3 では書き換える形が自然です。

初期化の XOR も 24 ビットずつ

シードの初期化には XOR が要ります。なでしこ3 の XOR は 32 ビットの符号付き整数として計算します。

「{2147483648と1のXOR}」を表示
-2147483647

48 ビットの値をそのまま渡すと壊れるので、XOR も 24 ビットずつ行います。

●(シードの)乱数生成器作成とは
    シード下位 = シード % 二十四ビット
    シード上位 = (シード - シード下位) ÷ 二十四ビット % 二十四ビット
    {"上位": シード上位と乗数上位のXOR, "下位": シード下位と乗数下位のXOR}で戻る
ここまで

範囲を指定して整数を 1 つ返す

nextInt(上限) は、上限が 2 の冪のときだけ別の式を使い、それ以外は剰余の偏りを避けるために範囲をはみ出す値を捨てて引き直します。ここまで java.util.Random に合わせます。

●(生成器と上限の)次整数とは
    もし、上限 ≦ 0ならば
        「上限は正の数でなければなりません: {上限}」のエラー発生
    ここまで
    もし、(上限と(0 - 上限)のAND) = 上限ならば
        積 = 上限 × (生成器から31を上位ビット取得)
        (積 - 積 % 三十一ビット) ÷ 三十一ビットで戻る
    ここまで
    ここから、真の間
        ビット列 = 生成器から31を上位ビット取得
        値 = ビット列 % 上限
        もし、ビット列 - 値 + (上限 - 1) < 三十一ビットならば
            値で戻る
        ここまで
    ここまで
ここまで

Java の (bits - val + (bound - 1)) < 0 は、32 ビットの整数が溢れて負になることで「はみ出した」を判定しています。なでしこ3 の数値は溢れないので、2^31 と比べる形に書き直しました。

Fisher-Yates で並べ替える

●(配列をシードで)並べ替えとは
    結果 = []
    Iを 0から (配列の要素数) - 1まで繰り返す
        結果に配列[I]を配列追加
    ここまで
    生成器 = シードの乱数生成器作成
    位置 = (結果の要素数) - 1
    位置 ≧ 1の間
        J = 生成器と(位置 + 1)の次整数
        退避 = 結果[位置]
        結果[位置] = 結果[J]
        結果[J] = 退避
        位置 = 位置 - 1
    ここまで
    結果で戻る
ここまで

最初に配列を複製しています。 配列は参照で渡るので、そのまま交換すると呼ぶ側の配列まで並べ替わってしまいます。「元の配列は書き換えない」をテストにしました。

山場のテスト: JVM の言語版と一致する

「java.util.Random と同じ並びを返す」で(100と0で5を整数列取得)と[60, 48, 29, 47, 15]が一致確認
「二の冪の範囲でも同じ並びを返す」で(16と42で5を整数列取得)と[11, 0, 10, 0, 4]が一致確認
「大きなシードでも同じ並びを返す」で(1000と12345678901で5を整数列取得)と[194, 414, 37, 222, 97]が一致確認
「ほかの言語版と一致する」で([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]を0で並べ替え)と[4, 8, 9, 6, 3, 5, 2, 1, 7, 0]が一致確認
検査 9 件、失敗 0 件、保留 0 件

シード 0 の [60, 48, 29, 47, 15] と並べ替えの [4, 8, 9, 6, 3, 5, 2, 1, 7, 0] は、Java 版・Elixir 版・PHP 版・Haskell 版のテストにある値です。「大きなシード」の行は、Python で書いた java.util.Random の参照実装から取りました。シードの上位 24 ビットが 0 でない場合を確かめるためです。シードが 0 や 42 のままでは、初期化の上位半分の XOR が正しいかどうかを確かめられません。

2.6 表を読み込む

列の順を保つ

欠損値の数を列の順に表示したいので、CSV を「列名の配列」と「行の配列」を持つ表として読みます。

読込表 = 「b,a{改行}1,2{改行}」を表読込
「列名は CSV に現れた順に並べる」で(読込表["列名一覧"])と[「b」, 「a」]が一致確認
「行は列名から値への辞書にする」で(読込表["行一覧"])と[{"b": 1, "a": 2}]が一致確認

なでしこ3 の辞書はキーを入れた順を保ちます。JSONエンコード もその順に並びます。Haskell 版は Map がキーの順に並ぶので列名の並びを別に持ち回りましたが、なでしこ3 では辞書のままでも順が崩れません。それでも列名の配列を別に持つのは、表示したい順を明示するためです。

このテストでは最初、(読込表["列名一覧"]) のかっこを付けずに書いて、添字の ["列名一覧"] が効かず表全体が渡りました。引数の位置に添字の式を書くときもかっこで囲みます。

空欄を NULL として読む

●(行の列名を)欠損許容数値取得とは
    値 = 行の列名を列取得
    もし、(値の変数型確認) = 「number」ならば
        値で戻る
    ここまで
    もし、値 === 「」ならば
        NULLで戻る
    ここまで
    照合結果 = 値を「^-?[0-9]+(\.[0-9]+)?$」で正規表現マッチ
    もし、照合結果 = NULLならば
        「{列名} を数値として読めません: {値}」のエラー発生
    ここまで
    値 + 0で戻る
ここまで

=== を使っている理由は、次の節で分かります。

2.7 欠損値を数える——0 と空欄が等しい

列ごとに空欄のセルを数えます。最初はこう書きました。

もし、(行一覧[J]の列名を列取得) = 「」ならば
    件数 = 件数 + 1
ここまで

自作のデータのテストは通りました。ところが、あとで述べる自作の「アヤメ風」のデータ(2.11 節)で報告を出すと、がく片長さを 2 か所だけ空欄にしたのに 4 と数えました。

なでしこ3 の = は JavaScript の == と同じゆるい比較で、0 = 「」 が真になります。

もし、0 = 「」ならば「0 と空文字列は等しい」を表示。
もし、「0」 = 0ならば「文字列の0と数値の0は等しい」を表示。
0 と空文字列は等しい
文字列の0と数値の0は等しい

CSV取得 は数字だけのセルを数値にするので、値が 0 のセルは数値の 0 になり、空文字列と「等しい」と判定されました。型まで比べる === があります。 テストを足してから直しました。

読込表 = 「a,b{改行}0,{改行}0,0{改行}」を表読込
「値が零のセルは欠損値として数えない」で(読込表を欠損値計数)と[[「a」, 0], [「b」, 1]]が一致確認
失敗: 値が零のセルは欠損値として数えない
  期待: [["a",0],["b",1]]
  実際: [["a",2],["b",2]]
# なでしこ3 の「=」は JavaScript の「==」と同じゆるい比較で、0 = 「」 が真になる。
# 値が 0 のセルを空欄と数えないよう、型まで比べる「===」を使う。
もし、(行一覧[J]の列名を列取得) === 「」ならば
    件数 = 件数 + 1
ここまで

アヤメのデータは値を 0〜1 に変換して配布されているので、値がちょうど 0 のセルがあれば、同じ誤りで欠損値の数がずれます。型の検査の無い言語で、比較の意味まで確かめずに書くと、こういう誤りが残ります。

2.8 平均値で欠損値を補完する

欠損を除いて列ごとの平均を求め、それで補完します。

●(行一覧と列名一覧の)列平均算出とは
    平均一覧 = {}
    Iを 0から (列名一覧の要素数) - 1まで繰り返す
        列名 = 列名一覧[I]
        総和 = 0
        件数 = 0
        Jを 0から (行一覧の要素数) - 1まで繰り返す
            値 = 行一覧[J]の列名を欠損許容数値取得
            もし、値 ≠ NULLならば
                総和 = 総和 + 値
                件数 = 件数 + 1
            ここまで
        ここまで
        もし、件数 = 0ならば
            「値がすべて空欄です: {列名}」のエラー発生
        ここまで
        平均一覧[列名] = 総和 ÷ 件数
    ここまで
    平均一覧で戻る
ここまで

変数名は最初 合計 と書き、「関数『合計』に代入できません」と言われました。合計 は組み込みの命令です(第 1 章の 一致 と同じ)。

補完(欠損補完)は、行ごとに列名から数値への辞書を作り、NULL なら平均で置き換えます。補完する値が無い列があればエラーにします。

2.9 訓練データとテストデータに分ける

●(特徴量一覧と正解一覧を割合とシードで)訓練テスト分割とは
    総数 = 特徴量一覧の要素数
    もし、総数 ≠ (正解一覧の要素数)ならば
        「件数が違います: {総数} と {正解一覧の要素数}」のエラー発生
    ここまで
    組一覧 = []
    Iを 0から 総数 - 1まで繰り返す
        組一覧に[特徴量一覧[I], 正解一覧[I]]を配列追加
    ここまで
    組一覧 = 組一覧をシードで並べ替え
    訓練件数 = 総数 - (総数 × 割合を切り上げ)
    …
ここまで

引数が 4 つあっても、と・を・と・で の助詞で受け取れます。呼ぶ側は 十件と十件ラベルを0.25と0で訓練テスト分割 と書きます。

特徴量と正解ラベルを組にしてから並べ替えます。別々に並べ替えると対応が崩れます。「分割しても特徴量とラベルの組が崩れない」をテストで確かめています。テストデータの件数は切り上げで、10 件の 25% は 2.5 件なので 3 件です。

2.10 前処理をまとめる

●(CSV文字列を割合とシードで)アヤメ前処理とは
    読込表 = CSV文字列を表読込
    分離結果 = 読込表とアヤメ目的列の目的列分離
    列名一覧 = 分離結果["特徴量列"]
    分割結果 = (分離結果["行一覧"])と(分離結果["正解ラベル"])を割合とシードで訓練テスト分割
    平均一覧 = (分割結果["訓練特徴量"])と列名一覧の列平均算出
    分割結果["訓練特徴量"] = (分割結果["訓練特徴量"])と列名一覧を平均一覧で欠損補完
    分割結果["テスト特徴量"] = (分割結果["テスト特徴量"])と列名一覧を平均一覧で欠損補完
    分割結果で戻る
ここまで

平均は分割のあと、訓練データだけから求めます。 テストデータまで含めた平均で補完すると、テストデータの情報が訓練に漏れます。自作のデータで、テストデータ側の空欄が訓練データの平均(1・5・3 の平均の 3)で埋まることを確かめています。

2.11 実データで前処理の結果を表示する

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

ほかの言語版は、ここで実データを使って結果がそろうことを確かめました。この章は学習データの無い環境で書いたため、実データのテストは保留になっています。

そこで、数値の正しさを別の方法でも確かめました。

  1. 実データと同じ形の「アヤメ風」のデータ(150 行、4 列、7 か所の空欄、値は 0〜1)を自分で作る(配布データではない)
  2. 同じ前処理を Python で書いた参照実装で計算する(乱数は 2.5 節の参照実装)
  3. なでしこ3 版の結果と比べる
比べたもの 結果
訓練データ・テストデータの件数 105 件・45 件で一致
補完前の訓練データの列ごとの平均(4 列) 4 列とも最後の桁まで一致(花弁長さは 0.49946153846153857)
報告の文字列 一致(欠損値の数を含む)

この突き合わせで、2.7 節の「0 と空欄が等しい」誤りが見つかりました。自作のデータには値が 0 のセルが 10 個あり、それを欠損値と数えていたのです。自作の数行のデータでは起きない誤りが、実データに近い量と形のデータで初めて表に出ました。

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

もし、(「iris.csv」のデータ存在確認)ならば
    前処理結果 = ((「iris.csv」のデータパス取得)を開く)を0.3と0でアヤメ前処理
    …
    「訓練データとテストデータの件数がほかの言語版と一致する」で[訓練一覧の要素数, (前処理結果["テスト特徴量"])の要素数]と[105, 45]が一致確認
    「補完後の訓練データの平均がほかの言語版と一致する」で((がく片長さ一覧の配列合計) ÷ (がく片長さ一覧の要素数))と0.4215384615384616が一致確認
    …
違えば
    「前処理の結果がほかの言語版と一致する」を「学習データがありません」で保留
ここまで

期待値の 105 件・45 件・0.4215384615384616 は、Java 版・Elixir 版・PHP 版・Haskell 版のテストにある値です。保留にした分岐は一度も実行されないので、lint を素通りする書き間違い(第 1 章)が残っていても気づけません。 そこで自作のデータを ML_DATA_DIR で指して実行し、落ちるのが「値の不一致」だけで、書き間違いによるエラーではないことを確かめました。

結果

./bin/gonako main.nako3 chapter02

期待する出力は、ほかの言語版と同じ次の 4 行です。

データ件数: 150
欠損値の数: がく片長さ=2, がく片幅=1, 花弁長さ=2, 花弁幅=2, 種類=0
訓練データ: 105 件, テストデータ: 45 件
特徴量: がく片長さ, がく片幅, 花弁長さ, 花弁幅

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

この章は学習データの無い環境で書いたため、上の 4 行と平均値 0.4215384615384616 はまだなでしこ3 版の実装で実測していません。学習データを配置した環境で npx gulp apps:check:nadesiko3 を実行し、保留が消えて通ることを確かめてから、この注記を外します。自作のアヤメ風データでは、Python の参照実装と最後の桁まで一致することを確かめています。

2.12 可視化について

Notebook による探索と可視化の節は設けません。Python 版 と Kotlin 版 の可視化の節を参照してください。

2.13 まとめ

この章では、アヤメのデータを読み込み、欠損値を補完して、訓練データとテストデータに分けました。なでしこ3 に固有の論点は次のとおりです。

  1. 数値が float64 だけ — 整数を正確に表せるのは 2^53 まで。java.util.Random の 48 ビットの状態に 35 ビットの乗数を掛けるため、状態と乗数を 24 ビットずつに分け、2^48 以上の項を捨てる形に展開した
  2. XOR は 32 ビットの符号付き整数で計算する — 初期化の XOR も 24 ビットずつ行う
  3. Java の「溢れて負になる」判定を書き直す — なでしこ3 の数値は溢れないので、2^31 と比べる
  4. 配列と辞書は参照で渡る — 並べ替えは複製してから行う。生成器は辞書を書き換えて進める
  5. = はゆるい比較 — 0 = 「」 も 「0」 = 0 も真。空欄の判定には型まで比べる === を使う
  6. 組み込みの名前・助詞と衝突する — 合計 は命令の名前、桁上がり は「が」で割られる
  7. 自作のデータと参照実装で突き合わせる — 実データが無くても、同じ形のデータと別の言語で書いた参照実装で、数値を最後の桁まで確かめられる。数行のテストデータでは出ない誤りが見つかった

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

  • 乱数生成器を自作する
  • 表を読み込む
  • 平均値で欠損値を補完する
  • 特徴量と正解ラベルに分ける
  • 訓練データとテストデータに分ける
  • 前処理をまとめる
  • 実データで前処理の結果を表示する(実データでの実測は未確認。2.11 の注記を参照)

次の章では、補完した特徴量で決定木を自作します。なでしこ3 には決定木のライブラリも行列の命令も無いので、ここで作る木がそのまま最終実装になります。

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