第 2 章: データの前処理と三角測量¶
2.1 はじめに¶
第 1 章では、欠損のないきれいなデータを読み込み、人間が書いたルールで判定しました。現実のデータには、空欄(欠損値)が混ざっています。
この章では、アヤメ(iris)のデータを題材に、欠損値を補完し、データを 訓練データ と テストデータ に分けるところまでを TDD で実装します。
Python 版の第 2 章・Scala 版の第 2 章 と同じ TODO リストで進めます。Python 版は pandas、Kotlin 版は Kotlin DataFrame を使いましたが、Clojure 版は Java 版・Scala 版と同じく データフレームのライブラリを使わず、小さな表を自分で組み立てます。Clojure では、その表が素のマップとベクタになります。次の 3 点に注目してください。
nilで欠損値を表す — Clojure にはOptionもOptionalもありません。Ruby 版・Python 版と同じnil/Noneの世界ですが、keepやorといった「nilを前提にした道具」が標準でそろっているのが違いです- マップは値で比べられる — Java 版・C# 版は「
doubleの配列を包んでequalsを自分で書く」工夫が要りましたが、Clojure のマップとベクタは宣言なしで中身が比較されます。第 1 章と同じ性質がここでも効きます - Java 版・Scala 版と同じ乱数で分ける — 分割に
java.util.Randomと Fisher-Yates を使うので、並べ替えの結果も、そこから求まる平均値も Java 版・Scala 版と一致します。標準のshuffleを使わない理由がここにあります
2.2 題材とデータ¶
iris.csv¶
iris.csv には、アヤメの花 150 件の測定値と品種が記録されています。
| 列 | 意味 | Clojure 版での読み方 |
|---|---|---|
| がく片長さ | がく片の長さ | (number row :がく片長さ) → double か nil |
| がく片幅 | がく片の幅 | (number row :がく片幅) → double か nil |
| 花弁長さ | 花弁の長さ | (number row :花弁長さ) → double か nil |
| 花弁幅 | 花弁の幅 | (number row :花弁幅) → double か nil |
| 種類 | 品種(3 種類が 50 件ずつ) | (text row :種類) → 文字列 |
特徴量の 4 列には合わせて 7 件の欠損値があります。Clojure には「値があるかもしれない」を表す型がないので、無いことは nil で表します。
| 言語 | 欠損値の表し方 | 値を取り出す |
|---|---|---|
| Clojure | nil |
or・keep・some?・when-let |
| Scala | Option[Double](Some/None) |
getOrElse・map・flatMap・match |
| F# | float option(Some/None) |
Option.defaultValue・match |
| Java | OptionalDouble |
orElse・ifPresent |
| Ruby・Python | nil/None |
\|\|・or・compact/is None |
型で表せないぶん弱いのは確かです。そのかわり、Clojure の標準のコレクション関数は nil を第一級の値として扱うように作られています。この章で使う keep(nil を落としながら写す)と or(nil なら次を返す)がその例で、Option の flatMap・getOrElse に当たる働きを、包みを剥がす手間なしにします。
訓練データとテストデータ¶
学習に使うデータでそのまま性能を測ると、「答えを覚えただけ」のモデルを高く評価してしまいます。そこでデータを 2 つに分けます。この章では 150 件を訓練データ 105 件・テストデータ 45 件に分けます。
Java 版・Scala 版と数値が一致する理由¶
分割の前に並べ替えますが、この並べ替えに java.util.Random と Fisher-Yates を使います。java.util.Random は乱数の作り方が仕様として決まっているので、同じシードなら JVM 上のどの言語からでも同じ数列が出ます。Java 版・Scala 版と同じ手順で並べ替えれば、訓練データに入る行が一致し、そこから求まる平均値も一致します。この章の終わりで実際に確かめます。
Clojure には shuffle がありますが、これは使えません。(shuffle coll) は内部で java.util.Collections/shuffle を引数 1 つで呼ぶので、シードを渡す口がありません。再現性が要る場面では自分で書く、という判断になります。
2.3 開発環境の準備¶
第 1 章で作った apps/clojure/ にファイルを足します。
apps/clojure/
├── deps.edn
├── src/
│ └── getting_started_ml/
│ ├── dataset.clj
│ ├── chapter01.clj
│ ├── chapter02.clj # この章
│ └── main.clj
└── test/
└── getting_started_ml/
├── chapter02_test.clj
└── iris_data_test.clj # 実データを使うテスト
依存は第 1 章のまま(org.clojure/data.csv だけ)で足ります。
2.4 TODO リストの作成¶
TODO リスト:
- 表を読み込む
- 数値の列を読む。空欄は欠損値にする
- 数値として読めない値・列の不足を弾く
- 列ごとに欠損値の数を数える
- 平均値で欠損値を補完する
- 欠損値を除いて列ごとの平均値を求める
- 欠損値を平均値で置き換える
- 特徴量と正解ラベルに分ける
- 訓練データとテストデータに分ける
- 指定した割合で分ける
- 特徴量と正解ラベルの対応を崩さない
- シードを指定すれば同じ分け方になる
- 前処理をまとめる
- 実データで前処理の結果を表示する
2.5 不変の表で読み込む¶
データの表し方を決める¶
最初に決めるのは、表をどう表すかです。第 1 章では「人物」という 1 つのマップにしましたが、この章では列の並びも扱うので、表そのものが必要になります。
| 対象 | 表し方 |
|---|---|
| 読み込んだ行 | 列名のキーワードから文字列へのマップ({:がく片長さ "5.1" :種類 "Iris-setosa"}) |
| 表 | {:columns [キーワード...] :rows [行...]} |
| 補完した後の特徴量 | 列名のキーワードから double へのマップ({:がく片長さ 5.1 ...}) |
| 分割の結果 | {:x-train ... :x-test ... :t-train ... :t-test ...} |
Scala 版は Row・Table・Features・TrainTestSplit の 4 つの case class を宣言しました。Clojure 版はどれも素のマップです。新しい型を宣言しないことの意味は 2 つあります。
- すべてのコレクション関数がそのまま使える。
Table用のmapを書く必要はありません - 何が入っているかはコードとテストからしか分からない。
FeaturesとRowがどちらもマップなので、「補完済みかどうか」を型で区別できません。Scala 版がRowとFeaturesを分けて「欠損値が残っていないこと」を戻り値の型で保証したところは、Clojure 版ではテストで保証することになります
表を {:columns ... :rows ...} にしたのは、列の順を保つためです。マップの鍵の並びは保証されないので(8 個を超えるとハッシュマップになり、並びが変わります)、「がく片長さ、がく片幅、花弁長さ、花弁幅、種類」の順で出力したいなら、その順をベクタで別に持つしかありません。
テストファースト¶
セルを読むところから始めます。
(deftest 行の読み出し
(testing "数値の列を読む"
(is (== 5.1 (ch/number {:がく片長さ "5.1"} :がく片長さ))))
(testing "空欄の列は欠損値になる"
(is (nil? (ch/number {:がく片長さ ""} :がく片長さ)))
(is (ch/missing? {:がく片長さ ""} :がく片長さ)))
(testing "数値として読めない列は失敗する"
(is (thrown-with-msg? IllegalArgumentException #"がく片長さ を数値として読めません: たくさん"
(ch/number {:がく片長さ "たくさん"} :がく片長さ))))
(testing "列が無ければ失敗する"
(is (thrown-with-msg? IllegalArgumentException #"列がありません: 花弁幅"
(ch/text {:がく片長さ "5.1"} :花弁幅))))
(testing "文字列の列を読む"
(is (= "Iris-setosa" (ch/text {:種類 "Iris-setosa"} :種類)))))
テストが行のリテラルをそのまま書けます。 素のマップなのでコンストラクタも組み立て関数も要らず、{:がく片長さ "5.1"} と書けば 1 行です。型を宣言する言語では、この 1 行のためにフィクスチャの用意が要ります。動的型付けの代償を払っているぶんの見返りが、ここに出ています。
Green: 明白な実装¶
(defn text
"文字列の列を読む。列が無ければ失敗する。"
[row column]
(if (contains? row column)
(get row column)
(throw (IllegalArgumentException. (str "列がありません: " (name column))))))
(defn missing?
"セルが空欄かどうかを返す。"
[row column]
(str/blank? (text row column)))
(defn number
"数値の列を読む。空欄なら nil を返す。数値として読めなければ失敗する。
Clojure には Option が無いので、欠損値は nil で表す。"
[row column]
(let [cell (str/trim (text row column))]
(when-not (str/blank? cell)
(try
(Double/parseDouble cell)
(catch NumberFormatException _
(throw (IllegalArgumentException.
(str (name column) " を数値として読めません: " cell))))))))
読みどころは number の when-not です。when-not は条件が偽のときに本体を評価し、真なら nil を返します。 つまり「空欄なら nil」が、else 節を書かずに済みます。Scala 版の if cell.trim.isEmpty then None else Some(...) に当たる形が、nil を暗黙の戻り値にすることで片側だけの式になりました。
if/when が式であること、そして nil が「無い」の自然な表現であることが組み合わさると、こういう省き方ができます。読みやすさは慣れ次第で、「when は nil を返しうる」を知らないと戻り値が見えません。
missing? の末尾の ? は「真偽値を返す」という Clojure の命名の慣習です(Ruby の ? と同じ)。(str/blank? ...) も同じ慣習に沿っています。
行末の空欄の落とし穴¶
Scala 版・Java 版は、ここで split(",", -1) の -1 に苦しみました。"4,5," を素直に split すると末尾の空文字列が落ち、列の数が合わなくなるからです。
clojure.data.csv ではこの問題は起きません。 確かめました。
(spit "/tmp/trail.csv" "\uFEFFa,b,c\n1,,3\n4,5,\n")
(with-open [r (io/reader "/tmp/trail.csv")]
(println "行 =" (pr-str (vec (rest (csv/read-csv r))))))
行 = [["1" "" "3"] ["4" "5" ""]]
途中の空欄も行末の空欄も、空文字列として残っています。clojure.data.csv は文字列を分割するのではなく CSV として解析するので、split の都合に振り回されません。「文字列操作で済ませる」か「解析器を使う」かの違いで、後者を選んだぶんの手間がここで返ってきています。
いっぽうで、第 1 章で見た BOM は相変わらず取り除いてくれません(上の出力でも列名は省いていますが、先頭には BOM が残ります)。取り除いてくれるものと、くれないものが同じライブラリの中に混ざるので、どちらなのかは実際に動かして確かめるしかありません。
表として読み込む¶
(def ^:private bom
"UTF-8 の BOM。data.csv は取り除かないので、先頭の列名から自分で取り除く。"
"\uFEFF")
(defn load-table
"CSV を読み込んで表にする。列の順は CSV の順のまま。"
[csv-file]
(with-open [reader (io/reader csv-file)]
(let [[header & rows] (csv/read-csv reader)
columns (mapv #(keyword (str/replace-first % bom "")) header)]
{:columns columns
:rows (mapv #(zipmap columns %) rows)})))
第 1 章の load-people とほぼ同じ形ですが、2 つ違います。
columnsをmapvにしました。 第 1 章はmap(遅延)のままでしたが、ここでは列の並びを表に入れて外へ持ち出すので、with-openを抜ける前に確定させる必要があります。遅延シーケンスとwith-openの組み合わせは第 1 章で踏んだ落とし穴で、値を外に返す形に変えた瞬間にまた効いてきます- 行を人物に変換しません。
zipmapで列名を付けるところまでで止め、数値への変換は後の工程(fill-missing)に回しています。欠損値の補完に平均値が要り、その平均値は訓練データからしか計算できないので、「読む」と「数値にする」を分けないと順番が組めないからです
列ごとの欠損値の数¶
(defn count-missing
"列ごとに欠損値の数を数える。列の順は表の列の順のまま。"
[{:keys [columns rows]}]
(mapv (fn [column] [column (count (filter #(missing? % column) rows))]) columns))
[{:keys [columns rows]}] が分配束縛(destructuring)です。引数のマップから :columns と :rows を取り出して、同じ名前の局所変数に束縛します。(let [columns (:columns table) rows (:rows table)] ...) を 1 行にしたもので、マップを引数に取る関数がこれだけ読みやすくなるのは、素のマップで通す設計の見返りです。
戻り値をマップではなくベクタのベクタ([[:がく片長さ 2] [:がく片幅 1] ...])にしているのは、ここでも順を保つためです。
(deftest 欠損値を数える
(testing "列ごとに欠損値を数える。列の順は表の列の順のまま"
(let [table {:columns [:がく片長さ :種類]
:rows [{:がく片長さ "5.1" :種類 "Iris-setosa"}
{:がく片長さ "" :種類 "Iris-setosa"}
{:がく片長さ "" :種類 "Iris-virginica"}]}]
(is (= [[:がく片長さ 2] [:種類 0]] (ch/count-missing table))))))
期待値が [[:がく片長さ 2] [:種類 0]] とそのまま書けます。ベクタもキーワードも値で比べられるので、比較のための道具は何も要りません。
2.6 平均値で欠損値を補完する¶
平均値を求める¶
(defn column-means
"欠損値を除いて、列ごとの平均値を求める。"
[rows columns]
(into {} (map (fn [column]
(let [values (keep #(number % column) rows)]
(when (empty? values)
(throw (IllegalArgumentException. (str "値がすべて空欄です: " (name column)))))
[column (/ (reduce + values) (count values))])))
columns))
keep が、この章でいちばん Clojure らしい関数です。 (keep f coll) は f を各要素に適用し、結果が nil でないものだけを残します。map してから filter some? するのを 1 つにしたもので、Scala 版の rows.flatMap(_.number(column))(Option を平らにする)と同じ働きです。
(keep #(number % column) rows) ; Clojure: nil が落ちる
rows.flatMap(_.number(column)) // Scala: None が落ちる
「欠損値を除いて集計する」が 1 つの関数で書けるという点で、nil は Option に負けていません。負けるのは「除き忘れたときに気づけるかどうか」で、Scala なら Option[Double] を + に渡した時点でコンパイルが止まりますが、Clojure は実行して NullPointerException が出るまで分かりません。
(into {} (map f) columns) の形にも触れておきます。map に 1 引数(変換関数だけ)を渡すと、コレクションを返す代わりに トランスデューサ(変換の手続きそのもの)を返します。それを into に渡すと、中間のシーケンスを作らずに変換しながらマップへ積み上げます。(into {} (map f columns)) と書いても結果は同じですが、こちらは一度シーケンスを作ります。変換とその適用先を分けて書けるのがトランスデューサの要点です。
(deftest 欠損値の補完
(testing "欠損値を除いて列ごとの平均値を求める"
(let [rows [{:がく片長さ "1.0"} {:がく片長さ ""} {:がく片長さ "3.0"}]]
(is (= {:がく片長さ 2.0} (ch/column-means rows [:がく片長さ])))))
(testing "値がすべて空欄なら平均値を求められない"
(is (thrown-with-msg? IllegalArgumentException #"値がすべて空欄です: がく片長さ"
(ch/column-means [{:がく片長さ ""}] [:がく片長さ])))))
補完して特徴量にする¶
(defn fill-missing
"欠損値を列ごとに指定した値で補完し、特徴量にする。元の行は変えない。"
[rows columns fill-values]
(mapv (fn [row]
(into {} (map (fn [column]
[column (or (number row column) (fill-value fill-values column))]))
columns))
rows))
(or a b) は「a が nil(か false)なら b」です。number が nil を返したときだけ補完する、が 1 つの式で書けました。Scala 版の .getOrElse(...)、Ruby 版の || に当たります。
そして or は短絡評価です。値があれば fill-value は呼ばれません。これが次の落とし穴の伏線になります。
get の既定値は先に評価される¶
補完する値が無ければ失敗させたい。最初はこう書きました。
;; 落ちた版
(get fill-values column (throw (IllegalArgumentException. (str "補完する値がありません: " (name column)))))
(get m k not-found) は「k が無ければ not-found を返す」なので、一見よさそうです。しかしテストが落ちました。 補完する値がそろっているはずのケースで、例外が飛んだのです。
理由は単純で、get はマクロではなく関数です。関数の引数は呼ぶ前にすべて評価されるので、(throw ...) は「鍵があるかどうかに関係なく」先に実行されます。確かめました。
(println "既定値(鍵なし) ="
(try (get {} :花弁幅 (throw (IllegalArgumentException. "補完する値がありません: 花弁幅")))
(catch IllegalArgumentException e (str "例外: " (.getMessage e)))))
(println "既定値(鍵あり) ="
(try (get {:花弁幅 1.0} :花弁幅 (throw (IllegalArgumentException. "補完する値がありません: 花弁幅")))
(catch IllegalArgumentException e (str "例外: " (.getMessage e)))))
既定値(鍵なし) = 例外: 補完する値がありません: 花弁幅
既定値(鍵あり) = 例外: 補完する値がありません: 花弁幅
鍵があっても例外が飛びます。 すぐ上で使った or が短絡するので同じ感覚で書いてしまいましたが、or はマクロ、get は関数、という違いがここに出ます。Scala 版の getOrElse は既定値が名前渡し(by-name)なので遅延しますし、Java 版の orElseThrow は関数を受け取ります。「既定値が遅延するかどうか」は言語・API ごとに違うので、思い込まずに確かめるところでした。
直した形が、分けて書く版です。
(defn- fill-value
"補完する値を返す。無ければ失敗する。
get の既定値は先に評価されるので、例外は when-not で投げる。"
[fill-values column]
(when-not (contains? fill-values column)
(throw (IllegalArgumentException. (str "補完する値がありません: " (name column)))))
(get fill-values column))
contains? で確かめてから get します。when-not はマクロなので、条件が真のときにしか本体を評価しません。docstring に理由を残したのは、同じ誤りを繰り返さないためです。
このバグを見つけたのはテストでした。テストを先に書いていなければ、「補完する値が無いときだけ落ちるはず」の例外が常時飛ぶことに、実データを流すまで気づけません。
(testing "欠損値を平均値で補完する。元の行は変えない"
(let [rows [{:がく片長さ "1.0"} {:がく片長さ ""}]]
(is (= [{:がく片長さ 1.0} {:がく片長さ 2.0}]
(ch/fill-missing rows [:がく片長さ] {:がく片長さ 2.0})))))
(testing "補完する値が無ければ失敗する"
(is (thrown-with-msg? IllegalArgumentException #"補完する値がありません: がく片長さ"
(ch/fill-missing [{:がく片長さ ""}] [:がく片長さ] {}))))
「元の行は変えない」をテスト名に書いていますが、Clojure では確かめるまでもありません。マップもベクタも不変なので、fill-missing が入力を書き換える書き方が存在しないからです。それでも名前に残したのは、ほかの言語版と読み比べたときに「ここは何も手当てしていない」ことが伝わるようにするためです。
2.7 特徴量と正解ラベルに分ける¶
(def target
"アヤメのデータの正解ラベルの列。"
:種類)
(defn split-features-and-target
"正解ラベルの列を取り出し、残りの列を特徴量の列にする。"
[{:keys [columns rows]} target-column]
{:columns (vec (remove #(= target-column %) columns))
:rows rows
:labels (mapv #(text % target-column) rows)})
戻り値を 3 要素のベクタではなくマップにしました。第 1 章では [x t] のベクタを返して (let [[x t] ...]) で受けましたが、3 つになると位置で覚えるのが辛くなります。マップで返せば、受ける側が {:keys [columns rows labels]} と名前で取り出せます。 Scala 版はここでタプル (Vector[String], Vector[Row], Vector[String]) を返していて、型で見分けがつかない同じ型が 2 つ並んでいます。名前で受けるほうが取り違えにくい場面です。
rows をそのまま通していることに注意してください。正解ラベルの列は行から取り除いていません。取り除くのは「特徴量として扱う列の一覧」からだけで、後の fill-missing が columns の並びだけを見て新しいマップを作るので、余った :種類 は自然に落ちます。行を削って回るより、使う列を指定するほうが工程が少ない、という選択です。
2.8 訓練データとテストデータに分ける¶
仮実装から¶
最初のテストは件数だけを見ます。
(testing "テストデータの割合で分ける"
(let [x (vec (range 10))
split (ch/split-train-test x (mapv str x) 0.3 0)]
(is (= [7 3 7 3] [(count (:x-train split)) (count (:x-test split))
(count (:t-train split)) (count (:t-test split))]))))
0.3 を掛けて切り上げるので、10 件なら 3 件がテストデータです。4 つの件数をベクタにまとめて 1 つの is で比べているのは、ベクタが値で比べられるからできる書き方です。失敗したときも、期待と実際のベクタが並んで表示されます。
三角測量: 並び順に頼らない分け方にする¶
先頭から順に分ければ件数のテストは通りますが、それでは困ります。iris.csv は品種ごとに並んでいるので、先頭 105 件を取ると訓練データに Iris-virginica がほとんど入りません。並べ替えが要ります。
(testing "同じシードなら同じ並びになる"
(let [items (vec (range 10))]
(is (= (ch/shuffle-with-seed items 0) (ch/shuffle-with-seed items 0)))
(is (not= (ch/shuffle-with-seed items 0) (ch/shuffle-with-seed items 1)))))
(testing "並べ替えても要素は変わらない"
(is (= (vec (range 10)) (vec (sort (ch/shuffle-with-seed (vec (range 10)) 0))))))
「同じシードなら同じ、違うシードなら違う」「並べ替えても要素は変わらない」の 2 つで、並べ替えの性質を挟み撃ちにします。中身がどう並ぶかは、この時点では決めていません。
Green: シード付きの乱数で並べ替える¶
(defn shuffle-with-seed
"シードを使って Fisher-Yates のシャッフルで並べ替える。元のベクタは変えない。
java.util.Random を使うので、Java 版・Scala 版と同じ並びになる。"
[items seed]
(let [random (Random. seed)
shuffled (object-array items)]
(doseq [i (range (dec (count items)) 0 -1)]
(let [j (.nextInt random (inc i))
tmp (aget shuffled i)]
(aset shuffled i (aget shuffled j))
(aset shuffled j tmp)))
(vec shuffled)))
この関数だけ、Clojure らしくありません。 object-array で Java の配列を作り、aset で書き換えています。不変のデータを旨とする言語で、意図して可変の配列を使っています。
理由は「Java 版・Scala 版と同じ並びにする」という目的から来ています。Fisher-Yates は「末尾から順に、ランダムに選んだ要素と入れ替える」というアルゴリズムで、入れ替えの順序そのものが結果を決めます。不変のベクタで同じことをやると、assoc を 2 回ずつ重ねる形になり、書いたコードと元のアルゴリズムの対応が読み取りにくくなります。ここでは「Java 版と同じ手順であることが一目で分かる」ことを優先しました。
安全なのは、可変性が関数の中に閉じているからです。object-array が作るのはこの呼び出し専用の配列で、外からは見えません。最後に (vec shuffled) で不変のベクタに戻すので、呼び出し側から見れば純粋な関数です。局所的な可変は、外に漏れなければ設計を壊しません。
(Random. seed) の末尾のドットがコンストラクタの呼び出し、(.nextInt random (inc i)) の先頭のドットがインスタンスメソッドの呼び出しです。第 1 章で見た Java の相互運用が、そのまま使えています。
doseq は副作用のための反復です。for は遅延シーケンスを返すので、ここで使うと何も起きません(返り値を誰も見ないため評価されない)。副作用が目的のときは doseq、値が欲しいときは for、という使い分けです。遅延評価のある言語で繰り返し出会う区別で、第 1 章の map と mapv の話と根は同じです。
Java 版・Scala 版と同じ並びになることを確かめる¶
(testing "Java 版・Scala 版と同じ並びになる"
(is (= [4 8 9 6 3 5 2 1 7 0] (ch/shuffle-with-seed (vec (range 10)) 0))))
[4 8 9 6 3 5 2 1 7 0] は、Java 版・Scala 版と同じ並びです。 言語をまたいだ約束を、テストで固定しました。片方を変えればもう片方が落ちるので、この 1 行が「同じ乱数を使い続ける」という設計判断の見張り番になります。
分割の本体¶
(defn split-train-test
"並べ替えてから、テストデータの割合(切り上げ)で訓練データとテストデータに分ける。"
[x t test-size seed]
(when-not (= (count x) (count t))
(throw (IllegalArgumentException. (str "件数が違います: " (count x) " と " (count t)))))
(let [shuffled (shuffle-with-seed (mapv vector x t) seed)
train-count (- (count shuffled) (long (Math/ceil (* (count shuffled) test-size))))
[train test] (split-at train-count shuffled)]
{:x-train (mapv first train) :x-test (mapv first test)
:t-train (mapv second train) :t-test (mapv second test)}))
(mapv vector x t) が要点です。map は複数のコレクションを受け取れます。 x と t を同時にたどって [特徴量 ラベル] の組を作るので、並べ替えても対応が崩れません。Scala 版の x.zip(t) に当たりますが、Clojure では zip という専用の関数はなく、map に vector を渡すことでそうなります。関数を値として渡せることの、素直な応用です。
対応が崩れないことは、テストで別に確かめます。
(testing "分けても特徴量と正解ラベルの対応は崩れない"
(let [x (vec (range 10))
split (ch/split-train-test x (mapv str x) 0.3 0)]
(is (= (mapv str (:x-train split)) (:t-train split)))
(is (= (mapv str (:x-test split)) (:t-test split)))))
特徴量を 0, 1, 2...、ラベルをその文字列版にしておくと、どんな並べ替えをされても「ラベルは特徴量の文字列版」という関係だけは保たれるはずです。並び順を書き下さずに対応関係だけを確かめる書き方で、shuffle-with-seed の実装を変えてもこのテストは通り続けます。
戻り値は 4 つの鍵を持つマップです。Scala 版は TrainTestSplit[X, T] という型引数 2 つのクラスを宣言しましたが、動的型付けならジェネリクスは要りません。ここに入るのが Long でも特徴量のマップでも、同じ関数が通ります。上のテストが (range 10) で済んでいるのはそのおかげです。
2.9 前処理をまとめる¶
(defn prepare-iris
"iris.csv を読み込み、分割してから訓練データの平均値で両方を補完する。"
[csv-file test-size seed]
(let [{:keys [columns rows labels]} (split-features-and-target (load-table csv-file) target)
split (split-train-test rows labels test-size seed)
means (column-means (:x-train split) columns)]
(assoc split
:x-train (fill-missing (:x-train split) columns means)
:x-test (fill-missing (:x-test split) columns means))))
順序が大事です。 分割してから、訓練データの平均値で両方を補完しています。先に全体の平均で補完すると、テストデータの情報が訓練データの前処理に混ざります(データリーク)。テストデータは「まだ見ていないデータ」のつもりで扱うので、そこから計算した値を学習側に持ち込んではいけません。
assoc は「マップの一部を差し替えた新しいマップを返す」関数です。split の 4 つの鍵のうち 2 つだけを置き換えるのが 1 つの式で書けます。:t-train・:t-test はそのまま残るので、書き忘れる余地がありません。Scala 版は TrainTestSplit(...) を 4 引数で組み立て直していて、変えない 2 つも書くことになっていました。素のマップで通す設計が効く場面です。
prepare-iris の戻り値には、補完済みの特徴量と補完前の行が混ざらないようになっています。ただし型では区別できません。:x-train に補完前の行が入っていても、Clojure は何も言いません。そこはテストで固定します。
2.10 実データで前処理の結果を表示する¶
実データのテスト¶
実データを使うテストは、第 1 章と同じく名前空間を分けて iris_data_test.clj に置き、データが無ければ早期に戻ります。
(deftest 実データの列ごとの欠損値の数を数える
(when-let [path (csv-file)]
(let [table (ch/load-table path)]
(is (= 150 (count (:rows table))))
(is (= [[:がく片長さ 2] [:がく片幅 1] [:花弁長さ 2] [:花弁幅 2] [:種類 0]]
(ch/count-missing table))))))
(deftest 実データを百五件と四十五件に分けて欠損値を補完する
(when-let [path (csv-file)]
(let [split (ch/prepare-iris path 0.3 0)]
(is (= [105 45 105 45] [(count (:x-train split)) (count (:x-test split))
(count (:t-train split)) (count (:t-test split))]))
(is (every? (fn [features] (every? some? (vals features))) (:x-train split))))))
2 つめのテストの最後の is が、Scala 版なら型が保証していたことを確かめています。
(every? (fn [features] (every? some? (vals features))) (:x-train split))
「すべての行の、すべての値が nil でない」。Scala 版は Features の値が Vector[Double] なので None が入りようがなく、この表明そのものが書けません(書く必要がありません)。Clojure 版では、補完が済んでいることを実行時に確かめるしかありません。動的型付けの代償を、テストの 1 行で払っている形です。
(deftest 実データを百五件と四十五件に分けて欠損値を補完する) と数字を漢数字にしているのは、deftest の名前がシンボルになるためです。シンボルは数字で始められないので、105 をそのまま名前の先頭に置けません。日本語の名前がそのまま使えるぶん、こういう小さな制約に当たります。
Java 版・Scala 版との突き合わせ¶
この章の山場のテストです。
(deftest 訓練データの平均値はJava版Scala版と一致する
;; java.util.Random を使うので、分かれる行も平均値も Java 版・Scala 版と同じになる
(when-let [path (csv-file)]
(let [{:keys [columns rows labels]} (ch/split-features-and-target (ch/load-table path) ch/target)
split (ch/split-train-test rows labels 0.3 0)
means (ch/column-means (:x-train split) columns)]
(doseq [[column want] {:がく片長さ 0.4215384615384616 :がく片幅 0.43826923076923074
:花弁長さ 0.47644230769230766 :花弁幅 0.4475}]
(is (< (abs (- (get means column) want)) 1e-12) (name column)))
(is (= ["Iris-setosa" "Iris-virginica" "Iris-setosa"] (take 3 (:t-test split)))))))
4 列の平均値が、Java 版・Scala 版の値と小数点以下 16 桁まで一致しました。 平均値が一致するということは、訓練データに入った 105 行がまったく同じということです。java.util.Random の数列が仕様で決まっていて、Fisher-Yates の手順をそろえたので、3 つの言語で同じ行が同じ側に分かれました。
doseq でテーブル駆動にしているのは、4 列を 4 行の is で書くより、期待値の表として読めるからです。is の 2 つ目の引数((name column))は失敗したときの手がかりで、どの列が合わなかったかが分かります。
浮動小数点数なので = ではなく許容誤差付きで比べています。第 1 章の正解率と同じ理由です。
テストデータの先頭 3 件のラベルも固定しました。平均値が一致すれば行も一致するはずですが、並び順まで同じであることは平均値では確かめられません(平均は順に依存しないので)。1 行足すだけで、並びまで込みの約束になります。
結果を表示する¶
(defn run
"アヤメのデータの前処理の結果を表示する。"
[]
(let [csv-file (str (dataset/dir) "/iris.csv")
table (load-table csv-file)
split (prepare-iris csv-file 0.3 0)]
(println (str "データ件数: " (count (:rows table))))
(println (str "欠損値の数: "
(str/join ", " (map (fn [[column n]] (str (name column) "=" n))
(count-missing table)))))
(println (str "訓練データ: " (count (:x-train split)) " 件, "
"テストデータ: " (count (:x-test split)) " 件"))
(println (str "特徴量: " (str/join ", " (map name (keys (first (:x-train split)))))))))
main.clj の対応表に 1 行足せば、章を選んで実行できます。
(def ^:private chapters
{"chapter01" chapter01/run
"chapter02" chapter02/run})
第 1 章で見たとおり、足すのはマップの 1 行だけです。使い方のメッセージ((sort (keys chapters)))も自動で追いつきます。
実行します。
$ clojure -M:run chapter02
データ件数: 150
欠損値の数: がく片長さ=2, がく片幅=1, 花弁長さ=2, 花弁幅=2, 種類=0
訓練データ: 105 件, テストデータ: 45 件
特徴量: がく片長さ, がく片幅, 花弁長さ, 花弁幅
最後の行の「特徴量」に注意してください。(keys (first (:x-train split))) でマップの鍵をそのまま並べています。4 つなので Clojure の小さなマップ(配列マップ)になり、たまたま挿入した順に出ています。鍵が 8 個を超えるとハッシュマップになり、この順は崩れます。 ここで正しい順に見えているのは偶然に近く、順が要る場所では :columns を使うべきです——この表示は「何の列が入っているか」を確かめるためのものなので、そのままにしました。第 3 章では、この鍵の並びを列の一覧として使うことになるので、その性質にもう一度触れます。
テストを走らせます。
$ clojure -M:test
Ran 15 tests containing 52 assertions.
0 failures, 0 errors.
(第 1 章・第 3 章のテストも含めた数です。)
2.11 可視化について¶
Clojure 版には Notebook の節を設けません。欠損値の分布や、品種ごとの特徴量の散布図は、Python 版の第 2 章 と Kotlin 版の第 2 章 の「Notebook で探索する」の節を参照してください。訓練データに入る行は言語ごとに違いますが(Clojure 版は Java 版・Scala 版と同じです)、「Iris-setosa は花弁長さ・花弁幅がほかの 2 品種よりはっきり小さい」という傾向は同じで、第 3 章の決定木はこの傾向を自動で見つけます。
2.12 まとめ¶
この章では、データフレームのライブラリを使わずに、欠損値を含むデータを前処理し、訓練データとテストデータに分けました。Clojure に固有の論点は次のとおりです。
- 型を宣言せず、素のマップとベクタで通す — 表は
{:columns ... :rows ...}、特徴量も分割の結果もマップ。分配束縛({:keys [columns rows]})で名前で受け取れ、assocで一部だけ差し替えられる。ジェネリクスも要らない - 欠損値は
nil—Optionは無いが、keep(nilを落として写す)とor(nilなら次)で「欠損値を除いて集計する」「無ければ補う」が 1 つの式になる。弱いのは、除き忘れてもコンパイル時に気づけないこと - 補完済みかどうかは型で表せない — Scala 版が
RowとFeaturesを分けて保証したことを、Clojure 版は(every? some? (vals features))というテストで保証する getの既定値は先に評価される —orはマクロなので短絡するが、getは関数なので既定値が必ず評価される。「無ければ例外」はwhen-notとcontains?に分けて書く。テストが落ちて初めて気づいたclojure.data.csvは行末の空欄を落とさない — Scala 版・Java 版がsplit(",", -1)で手当てした問題は起きない。いっぽう BOM は相変わらず残る。同じライブラリの中で親切な部分とそうでない部分が混ざる- 並べ替えだけは可変の配列で書いた — Java 版・Scala 版と同じ Fisher-Yates であることを読めるようにするため。
object-arrayとasetの可変性は関数の中に閉じていて、(vec shuffled)で不変に戻る shuffleにはシードを渡せない — 再現性が要るなら自分で書くしかない。その代わり[4 8 9 6 3 5 2 1 7 0]という並びと、訓練データの平均値(がく片長さ 0.4215384615384616 ほか)が Java 版・Scala 版と一致した
TODO リスト(この章の完了時点):
- 表を読み込む
- 平均値で欠損値を補完する
- 特徴量と正解ラベルに分ける
- 訓練データとテストデータに分ける
- 前処理をまとめる
- 実データで前処理の結果を表示する
次の章では、この訓練データから決定木を自作します。木を表す判別共用体が Clojure には無いので、葉と節をどちらもマップで表すことになります。そして JVM の機械学習ライブラリ Tribuo の決定木と予測を突き合わせ、Java 版・Scala 版と同じ結果が出るかを確かめます。