第 9 章: 特徴量エンジニアリング¶
9.1 はじめに¶
モデルの性能は、アルゴリズムよりも「どんな特徴量を渡すか」で大きく変わることがあります。元のデータから、モデルが学びやすい特徴量を作り出す作業を 特徴量エンジニアリング と呼びます。
この章では、ボストンの住宅価格データを題材に、次の 5 つの技法を TDD で自作します。
- カテゴリ値をダミー変数(0 と 1 の列)にする
- 特徴量を標準化する
- 2 乗の項と交互作用の項(多項式特徴量)を作る
- 外れ値を検出する
- 別の表を結合して特徴量を増やす
標準化は Tribuo の MeanStdDevTransformation と突き合わせます。最後に、作った特徴量で線形回帰の決定係数がどう変わるかを実データで測ります。
Python 版の第 9 章 と同じ TODO リストで進め、同じ JVM・同じ Tribuo を使う Java 版・Kotlin 版 と、関数型の F# 版 を対比します。Kotlin 版は Kotlin DataFrame の convert・add・innerJoin・groupBy で表を操作しました。Scala 版は Java 版と同じく、データフレームのライブラリを使わずに第 2 章の Table・Row・Features と不変コレクションで書きます。
9.2 題材とデータ¶
学習データの入手と配置は 第 1 章 の「題材とデータ」を参照してください。この章では 3 つのファイルを使います。
| ファイル | 内容 | 区切り文字 | 文字コード |
|---|---|---|---|
Boston.csv |
地域ごとの住宅価格。100 件、14 列。CRIME は high・low・very_low のカテゴリ値、NOX と RAD に欠損値 |
カンマ | ASCII の範囲だけ(BOM なし) |
bike.tsv |
自転車シェアの日ごとの利用者数(cnt)と天気 ID(weather_id)。731 件 |
タブ | ASCII の範囲だけ(BOM なし) |
weather.csv |
天気 ID と天気の名前(晴れ・曇り・雨)。3 件 | カンマ | Shift_JIS(UTF-8 としては読めない) |
第 2 章の Table.load は「BOM 付きの UTF-8 のカンマ区切り」を読むメソッドです。Boston.csv は BOM が無くても読めるのでそのまま使い、bike.tsv と weather.csv は、この章で区切り文字と文字コードを指定できる読み込みを足します(第 2 章の Table は変更しません)。
9.3 TODO リストの作成¶
TODO リスト:
- カテゴリ値をダミー変数にする
- 先頭を除いたカテゴリを辞書順に求める
- カテゴリごとに 0 と 1 の列を作る
- カテゴリに無い値はすべての列を 0 にする
- 特徴量を標準化する
- 訓練データから平均と標準偏差を求める
- 訓練データの平均と標準偏差で別のデータを標準化する
- Tribuo の
MeanStdDevTransformationと突き合わせる - 多項式特徴量を作る
- 2 乗の項を加える
- 交互作用の項を加える
- 使う項を選ぶ
- 外れ値を検出する
- 分位数を線形補間で求める
- 四分位範囲(IQR)で外れ値を判定する
- 訓練データから外れ値の行を取り除く
- 表を結合して特徴量を増やす
- 区切り文字と文字コードを指定して読み込む
- 天気 ID で 2 つの表を結合する
- 天気ごとの平均利用者数を求める
- 特徴量の組み合わせごとに決定係数を比べる
この章のコードは machinelearning.chapter09 パッケージに置き、技法ごとにファイルを分けます。
| ファイル | 役割 |
|---|---|
Dummies.scala |
ダミー変数 |
Standardizer.scala |
標準化(case class) |
TribuoStandardization.scala |
Tribuo の標準化の呼び出し |
PolynomialFeatures.scala |
多項式特徴量と項(Term) |
Outliers.scala |
外れ値の検出 |
DelimitedFiles.scala・BikeWeather.scala |
区切り文字・文字コードを指定した読み込みと、表の結合 |
LinearModel.scala・Boston.scala |
正規方程式による線形回帰、決定係数(Scores)、Boston データの前処理 |
9.4 カテゴリ値をダミー変数にする¶
カテゴリを求める¶
CRIME のようなカテゴリ値は、そのままでは線形回帰に渡せません。カテゴリごとに「その値なら 1、それ以外は 0」の列を作ります。3 つのカテゴリなら 2 列あれば区別できる(2 列とも 0 なら残りの 1 つ)ので、Python 版の pd.get_dummies(drop_first=True) と同じく、辞書順で先頭のカテゴリを除きます。
test("先頭を除いたカテゴリを辞書順に返す") {
assert(
Dummies.categories(Vector("low", "high", "very_low", "low")) === Vector("low", "very_low")
)
}
test("欠損値(空欄)はカテゴリに数えない") {
assert(Dummies.categories(Vector("low", "", "high")) === Vector("low"))
}
第 2 章の Row は空欄を空文字列のまま持つので、trim.nonEmpty で除きます(Kotlin 版は欠損値を null で表し filterNotNull() で除きました)。
/** 欠損値(空欄)を除いたカテゴリを辞書順に並べ、先頭を除いて返す(pandas の drop_first=True と同じ)。 */
def categories(values: Vector[String]): Vector[String] =
values.filter(_.trim.nonEmpty).distinct.sorted.drop(1)
Java 版の stream().filter(...).distinct().sorted().skip(1).toList() と、やることは同じです。Scala の Vector はコレクションそのものが filter・distinct・sorted・drop を持つので、ストリームに変換して戻す往復が要りません。sorted が引数なしで書けるのは、String に Ordering の given があるからです。
表に列を加える¶
ダミー変数の列は、表の末尾に「列名_カテゴリ」という名前で加えます。
test("カテゴリごとに 0 と 1 の列を作り元の列を取り除く") {
val encoded =
Dummies.encode(crimeTable("low", "high", "very_low"), "CRIME", Vector("low", "very_low"))
assert(encoded.columns === Vector("RM", "CRIME_low", "CRIME_very_low"))
assert(encoded.rows.map(_.text("RM")) === Vector("6.0", "6.0", "6.0"))
assert(encoded.rows.map(_.text("CRIME_low")) === Vector("1", "0", "0"))
assert(encoded.rows.map(_.text("CRIME_very_low")) === Vector("0", "0", "1"))
}
Table は不変の case class なので、行ごとに新しい Row を作ります。
/** 列を取り除き、カテゴリごとに「列名_カテゴリ」の列を末尾に加える。値が一致すれば "1"、それ以外は "0"。 */
def encode(table: Table, column: String, categories: Vector[String]): Table =
val dummyColumns = categories.map(category => s"${column}_$category")
Table(
table.columns.filterNot(_ == column) ++ dummyColumns,
table.rows.map(encodeRow(_, column, categories))
)
private def encodeRow(row: Row, column: String, categories: Vector[String]): Row =
val value = row.text(column)
val dummies =
categories.map(category => s"${column}_$category" -> (if category == value then "1" else "0"))
Row(row.cells.removed(column) ++ dummies)
Java 版は new HashMap<>(row.cells()) と写してから remove・put で書き換えました。Scala の Map は不変なので、removed と ++ が新しい Map を返します。「写してから壊す」ではなく「元を残したまま別の値を作る」書き方になり、元の行が変わらないことをテストで確かめる必要すらありません。
セルは文字列のまま "1"・"0" にしておきます。こうすると、第 2 章の Preprocessing.splitFeaturesAndTarget・columnMeans・fillMissing を、ダミー変数の列にもそのまま使えます。カテゴリを引数で受け取るのは Kotlin 版・Java 版と同じ理由で、訓練データで決めたカテゴリをテストデータにも当てはめ、列をそろえるためです。
9.5 特徴量を標準化する¶
標準化とは¶
列ごとに平均を引いて標準偏差で割り、平均 0・標準偏差 1 にそろえることを 標準化 と呼びます。単位や桁の違う列(部屋数と税率など)を同じ尺度で比べられるようになります。平均と標準偏差は訓練データだけで求め、テストデータにも同じ値を使います。テストデータの平均を使うと、テストデータの情報が学習に漏れるからです。
平均と標準偏差を求める¶
第 14 章(K-means)でもこのクラスを使うので、第 2 章の Features の Vector をそのまま受け取れる形にします。
test("訓練データから列ごとの平均と標準偏差を求める") {
val standardizer = Standardizer.fit(Vector(row(1, 10), row(2, 10), row(3, 40)))
assert(standardizer.means("RM") === 2.0 +- 1e-12)
assert(standardizer.means("LSTAT") === 20.0 +- 1e-12)
assert(standardizer.stds("RM") === math.sqrt(2.0 / 3) +- 1e-12)
assert(standardizer.stds("LSTAT") === math.sqrt(200.0) +- 1e-12)
}
test("平均と標準偏差は列の順を保つ") {
val standardizer = Standardizer.fit(Vector(row(1, 10), row(2, 40)))
assert(standardizer.means.keys.toVector === Columns)
}
RM の標準偏差を √(2/3) としているのは、件数(3)で割る標準偏差だからです。scikit-learn の StandardScaler と同じ定義にしました。
テストを実行すると、まだ Standardizer が無いのでコンパイルで失敗します。
[error] -- [E006] Not Found Error: .../chapter09/StandardizerSpec.scala:14:23
[error] 14 | val standardizer = Standardizer.fit(Vector(row(1, 10), row(2, 10), row(3, 40)))
[error] | ^^^^^^^^^^^^
[error] | Not found: Standardizer - did you mean standardizer?
Scala 3 のコンパイラは、綴りの近い名前(ここでは同じ行で定義している standardizer)を候補として出してくれます。実装は次のとおりです。
case class Standardizer(means: SeqMap[String, Double], stds: SeqMap[String, Double]):
require(means.keySet == stds.keySet, "平均と標準偏差の列が違います")
/** 1 件の特徴量を標準化する。平均と標準偏差を持たない列はそのまま残す。 */
def transform(features: Features): Features =
Features(
features.columns,
features.columns.zip(features.values).map { (column, value) =>
means.get(column).fold(value)(mean => (value - mean) / stds(column))
}
)
/** 特徴量のリストを標準化する。 */
def transform(x: Vector[Features]): Vector[Features] = x.map(transform)
object Standardizer:
/** 特徴量のすべての列について、平均と標準偏差を求める。 */
def fit(x: Vector[Features]): Standardizer =
require(x.nonEmpty, "特徴量が 1 件もありません")
val stats = x.head.columns.map { column =>
val values = x.map(_.value(column))
val mean = values.sum / values.size
val std = math.sqrt(values.map(value => (value - mean) * (value - mean)).sum / values.size)
(column, mean, if std == 0 then 1.0 else std)
}
Standardizer(
SeqMap.from(stats.map((column, mean, _) => column -> mean)),
SeqMap.from(stats.map((column, _, std) => column -> std))
)
Java 版・Kotlin 版との違いを 4 つ挙げます。
- 順序を保つ
Map— Java 版は「順序を保ちたいのでLinkedHashMapに写してからCollections.unmodifiableMapで包む」と 2 段構えでした。Scala には不変で挿入順を保つscala.collection.immutable.SeqMapがあるので、型でそのまま表せます。SeqMap[String, Double]と書けば「順序のある、変更できない対応表」であることが呼び出し側にも伝わります - 列ごとの統計を 1 回で作る — 平均と標準偏差を別々のループで作らず、
(列名, 平均, 標準偏差)のタプルのVectorを 1 回作ってから 2 つのSeqMapに分けます。可変のMapにputしていく Java 版と違い、途中の状態がありません Optionで「その列を標準化するか」を表す —means.get(column)はOption[Double]を返すので、fold(value)(...)で「無ければ元の値のまま」と書けます。Java 版のif (means.containsKey(column))とmeans.get(column)の 2 回引きが 1 回になります- 配列を壊さない — Java 版は
Features.values()が配列の写しを返すことに頼って、その場で書き換えていました。Scala のFeaturesはVector[Double]を持つ不変の case class なので、mapした結果で新しいFeaturesを作るだけです。「標準化する列に無い列はそのまま残す」テストもassert(standardizer.transform(row(3, 7)) === row(2, 7))と、case class の等価判定でそのまま書けます
require は、満たされなければ IllegalArgumentException を投げます。Java 版が if (...) throw new IllegalArgumentException(...) と書いていた表明を 1 行で書けます。
Tribuo の標準化と突き合わせる¶
Tribuo には MeanStdDevTransformation という標準化があります。Java 向けの API ですが、Scala からもそのまま呼べます。
/** 訓練データの値から平均と標準偏差を求め、別の値を標準化する。 */
def standardize(train: Vector[Double], values: Vector[Double]): Vector[Double] =
val statistics = MeanStdDevTransformation().createStats()
train.foreach(statistics.observeValue)
val transformer = statistics.generateTransformer()
values.map(transformer.transform)
statistics.observeValue と transformer.transform は Java のメソッドですが、Scala 側で関数が求められる場所に書けば自動的に関数値になります(イータ拡張)。Java 版の statistics::observeValue と同じことを、記号なしで書けます。
まず、自作の標準化と同じ値になるはずだと考えて、そのまま比べるテストを書いてみました。最初に書いたテストは、未使用の val があるというコンパイルエラーになりました。
[error] -- [E198] Unused Symbol Error: .../chapter09/TribuoStandardizationSpec.scala:8:14
[error] 8 | private val Test = Vector(6.2, 8.0)
[error] | ^^^^
[error] | unused private member
[error] one error found
第 5 章で -Wunused:all -Xfatal-warnings を設定したので、テストコードでも使っていない値はエラーになります。使う値だけを残して書き直すと、今度はテストが落ちました。
[info] - Tribuo の標準化は自作の標準化と同じ値になる *** FAILED ***
[info] 2.197401062294143 did not equal 1.9030051422496395 +- 1.0E-12 (TribuoStandardizationSpec.scala:12)
2 つの値の比は 0.866 で、√(3/4) です。訓練データは 4 件なので、Tribuo は件数から 1 を引いた 3 で割る 不偏標準偏差 を使っていることが分かります。Kotlin 版(ADR 002)・Java 版(ADR 005)で確かめた結果と、値まで同じです。この事実をテストに残します。
test("Tribuo の MeanStdDevTransformation は件数から 1 を引いて割る標準偏差を使う") {
val mean = Train.sum / Train.size
val sumOfSquares = Train.map(value => (value - mean) * (value - mean)).sum
val sampleStd = math.sqrt(sumOfSquares / (Train.size - 1))
val standardized = TribuoStandardization.standardize(Train, Values)
assert(standardized(0) === (6.2 - mean) / sampleStd +- 1e-12)
assert(standardized(1) === (8.0 - mean) / sampleStd +- 1e-12)
}
test("自作の標準化に件数から決まる係数を掛けると Tribuo の値になる") {
val standardizer = Standardizer.fit(Train.map(Samples.rm))
val ratio = math.sqrt((Train.size - 1.0) / Train.size)
val tribuo = TribuoStandardization.standardize(Train, Values)
Values.zip(tribuo).foreach { (value, expected) =>
assert(standardizer.transform(Samples.rm(value)).value("RM") * ratio === expected +- 1e-12)
}
}
同じ「標準偏差」でも、ライブラリによって割る数が違います。件数が多ければ差は小さくなりますが、この章の訓練データは 70 件なので、係数は √(69/70) ≒ 0.993 です。線形回帰では係数の大きさが変わるだけで予測は変わりませんが、第 14 章の K-means のように距離を使うアルゴリズムでは、どちらの定義を使ったかを記事とコードで明示しておきます。F# 版は ML.NET を相手にして、既定では平均を引かない(fixZero = true)という別の癖に出会いました。「ライブラリの正規化は、名前が同じでも定義が同じとは限らない」という教訓は、どの言語でも同じです。
9.6 多項式特徴量を作る¶
2 乗の項と交互作用の項¶
部屋数(RM)と価格の関係が直線でなく曲線なら、RM の 2 乗の列を加えると線形回帰でも曲線を表せます。2 つの列の積(交互作用の項)を加えると、「部屋数が多く、かつ低所得者の割合が低い」のような組み合わせの効果を表せます。
test("2 列なら 2 乗の列と 2 つの列の積の列を加える") {
val columns = Vector("RM", "LSTAT")
val x = Vector(Features(columns, Vector(2, 5)), Features(columns, Vector(3, 7)))
val features = PolynomialFeatures.expand(x, columns)
assert(features.head.columns === Vector("RM", "LSTAT", "RM^2", "RM LSTAT", "LSTAT^2"))
assert(features.map(_.value("RM LSTAT")) === Vector(10.0, 21.0))
assert(features.map(_.value("LSTAT^2")) === Vector(25.0, 49.0))
}
1 列・2 列・3 列と三角測量し、3 列では scikit-learn の PolynomialFeatures と同じ並びの 9 列になることを確かめました。項は、左右 2 つの列名を持つ case class で表します。
/** 2 次の項。left と right が同じなら 2 乗の項を表す。 */
case class Term(left: String, right: String):
/** 項の名前。scikit-learn の get_feature_names_out と同じ形("RM^2"、"RM LSTAT")にする。 */
def name: String = if left == right then s"$left^2" else s"$left $right"
/** 重複を許して 2 つの列を選ぶ組を、scikit-learn の PolynomialFeatures と同じ順に並べる。 */
def pairsWithReplacement(columns: Vector[String]): Vector[Term] =
columns.indices.toVector.flatMap(i => columns.drop(i).map(right => Term(columns(i), right)))
Kotlin 版は Pair<String, String> と別の関数 termName を使い、Java 版は Pair という record にメソッドを持たせました。Scala 版は Term という名前の case class にします。Pair より、この章で何を表す組なのかがはっきりします。name に引数が無いので、term.name と値のように読めるのも Scala らしいところです。
二重ループは indices.flatMap と drop(i) で書きました。Java 版の for (int i...) for (int j = i...) と同じ順に並びます。
/** 指定した列の後ろに、2 乗の項と交互作用の項を加える。 */
def expand(x: Vector[Features], columns: Vector[String]): Vector[Features] =
val terms = pairsWithReplacement(columns)
val names = columns ++ terms.map(_.name)
x.map(features =>
Features(
names,
columns.map(features.value) ++ terms.map(term =>
features.value(term.left) * features.value(term.right)
)
)
)
Java 版は結果の double[] を確保して添字で埋めましたが、Scala では「元の列の値」と「項の値」を作って ++ でつなぐだけです。添字の計算(values[columns.size() + k])が消えるので、ずれる余地がありません。
使う項を選ぶ select も用意し、「元の特徴量だけ」「2 乗の項を追加」「交互作用の項も追加」を同じ流れで比べられるようにしました。
9.7 外れ値を検出する¶
第 1 四分位数(Q1)と第 3 四分位数(Q3)の差を 四分位範囲(IQR) と呼びます。Q1 − 1.5 × IQR より小さい値と、Q3 + 1.5 × IQR より大きい値を外れ値とみなします。
test("四分位数の位置が値の間にあれば前後の値から線形補間する") {
assert(Outliers.quantile(Vector(4.0, 1.0, 3.0, 2.0), 0.25) === 1.75 +- 1e-12)
}
test("第 3 四分位数から IQR の 1.5 倍より大きい値を外れ値とする") {
assert(
Outliers.iqrOutliers(Vector(1.0, 2.0, 3.0, 4.0, 100.0)) ===
Vector(false, false, false, false, true)
)
}
/** 分位数を求める。位置が値の間にあれば前後の値から線形補間する(pandas の quantile の既定と同じ)。 */
def quantile(values: Vector[Double], q: Double): Double =
val sorted = values.sorted
val position = (sorted.size - 1) * q
val lower = math.floor(position).toInt
val upper = math.ceil(position).toInt
sorted(lower) + (sorted(upper) - sorted(lower)) * (position - lower)
/** 第 1 四分位数から IQR の k 倍より小さい値と、第 3 四分位数から k 倍より大きい値を外れ値とする。 */
def iqrOutliers(values: Vector[Double], k: Double = DefaultK): Vector[Boolean] =
val q1 = quantile(values, FirstQuartile)
val q3 = quantile(values, ThirdQuartile)
val iqr = q3 - q1
values.map(value => value < q1 - k * iqr || value > q3 + k * iqr)
Java 版は既定の引数が無いので iqrOutliers(values) と iqrOutliers(values, k) の 2 つをオーバーロードしました。Scala は Kotlin 版と同じく k: Double = DefaultK と既定値を書けるので、メソッドは 1 つで済みます。
外れ値を除く removeTargetOutliers は、訓練データから正解(価格)が外れ値の行だけを取り除き、テストデータには手を付けません。テストデータは「本番で来るデータ」の代わりなので、外れ値を含んでいても評価から外してはいけないからです。
def removeTargetOutliers(
split: TrainTestSplit[Features, Double]
): TrainTestSplit[Features, Double] =
val kept = split.xTrain.zip(split.tTrain).zip(iqrOutliers(split.tTrain)).collect {
case (pair, false) => pair
}
TrainTestSplit(kept.map(_._1), split.xTest, kept.map(_._2), split.tTest)
Java 版は「残す添字の List<Integer> を作り、特徴量と正解をそれぞれ添字で引く」と書きました。Scala では特徴量・正解・外れ値かどうかの 3 つを zip でそろえ、collect に case (pair, false) というパターンを渡して「外れ値でない組」だけを残せます。添字が出てこないので、特徴量と正解がずれる心配がありません。
9.8 表を結合して特徴量を増やす¶
タブ区切りと Shift_JIS¶
区切り文字と文字コードを受け取って Table を作る DelimitedFiles.load を足します。
/** 1 行目を列名として読み込む。文字コードが違えば MalformedInputException を投げる。 */
def load(file: Path, charset: Charset, delimiter: String): Table =
val separator = Pattern.quote(delimiter)
val lines = Files.readAllLines(file, charset).asScala.toVector
val columns = lines.head.split(separator, -1).toVector
val rows = lines.tail.filter(_.trim.nonEmpty).map(toRow(columns, _, separator))
Table(columns, rows)
split は正規表現を受け取るので、区切り文字を Pattern.quote で囲んでおきます(| などを区切り文字にしても誤動作しないように)。bike.tsv は "\t" と UTF-8、weather.csv は "," と Charset.forName("Shift_JIS") で読み込みます。Scala の標準ライブラリには CSV の読み込みが無いので、第 2 章と同じく Java の Files.readAllLines を asScala.toVector で不変のコレクションに変えて使います。
文字コードを間違えたときの振る舞いは、Java 版と同じでした。
test("Shift_JIS のファイルを UTF-8 として読むと例外になる") {
val csvFile = writeFile("weather.csv", "weather_id,weather\n1,晴れ\n", BikeWeather.ShiftJis)
assertThrows[MalformedInputException] {
DelimitedFiles.load(csvFile, StandardCharsets.UTF_8, ",")
}
}
Files.readAllLines は、復号できないバイトがあると置き換えずに MalformedInputException で知らせます。Kotlin 版で使った Kotlin DataFrame は、UTF-8 として解釈できないバイトを置換文字(U+FFFD)に置き換えて読み込みを続けました。文字化けしたデータが黙って入ってこない点では、JVM の標準の読み込み方のほうが安全です。JVM の言語(Java・Kotlin・Scala)でも、どのライブラリで読むかで振る舞いが変わることが分かります。
Map による結合と集計¶
利用者数の表に、天気 ID をキーにして天気の名前を加えます。Kotlin 版は innerJoin を使いました。Scala 版は Java 版と同じく、天気の表を「天気 ID → 行」の Map にしてから、利用者数の行ごとに引きます。
/** 天気 ID をキーにした Map を引いて、天気の列を加える(内部結合)。天気の表に無い ID の行は残さない。 */
def joinWeather(bike: Table, weather: Table): Table =
val byId = weather.rows.map(row => row.text(Key) -> row).toMap
require(byId.size == weather.rows.size, s"$Key が一意ではありません")
val added = weather.columns.filterNot(_ == Key)
val rows = bike.rows.flatMap(row => byId.get(row.text(Key)).map(join(row, _, added)))
Table(bike.columns ++ added, rows)
flatMap と Option の組み合わせが、この章でいちばん Scala らしい行です。byId.get(...) は Option[Row] を返すので、map で結合し、flatMap で None(天気の表に無い天気 ID)を落とします。「探す・あれば使う・無ければ捨てる」が 1 行になり、Java 版の filter(containsKey) と map(get) の 2 段が要りません。
ただし、Scala の toMap はキーが重複すると後の値で静かに上書きします。Java 版の Collectors.toMap は例外を投げるので、そこだけ振る舞いが違います。「天気 ID が一意でなければ結合の前に気付ける」という Java 版の安全性を保つために、require で件数を確かめました。便利な既定が、必ずしも安全な既定とは限りません。
天気ごとの平均利用者数は、groupBy と sortBy で求めます。
/** 天気ごとの平均利用者数を、多い順に並べて返す。 */
def meanCountByWeather(joined: Table): Vector[(String, Double)] =
joined.rows
.groupBy(_.text("weather"))
.view
.mapValues(rows => rows.flatMap(_.number("cnt")).sum / rows.size)
.toVector
.sortBy(-_._2)
Java 版は「groupingBy が返す HashMap は順序を持たないので、並べ替えてから LinkedHashMap::new に集める」と書きました。Scala 版は、順序が要る結果を Map ではなく Vector[(String, Double)] で返します。順序を持たない型に順序を期待しない ほうが、型と意図が一致します。.view を挟むと mapValues が遅延評価になり、中間の Map を作らずに済みます。
9.9 特徴量の効果を測る¶
Boston データの前処理¶
Boston.prepare は、CRIME をダミー変数にしてから、第 2 章の関数で「特徴量と正解に分ける → シード付きで分割する → 訓練データの平均値で両方の欠損値を補完する」を行います。
def prepare(csvFile: Path, testSize: Double, seed: Long): TrainTestSplit[Features, Double] =
val table = Table.load(csvFile)
val encoded =
Dummies.encode(table, Category, Dummies.categories(table.rows.map(_.text(Category))))
val (columns, rows, target) = Preprocessing.splitFeaturesAndTarget(encoded, Target)
val split = Preprocessing.splitTrainTest(rows, target.map(_.toDouble), testSize, seed)
val means = Preprocessing.columnMeans(split.xTrain, columns)
TrainTestSplit(
Preprocessing.fillMissing(split.xTrain, columns, means),
Preprocessing.fillMissing(split.xTest, columns, means),
split.tTrain,
split.tTest
)
第 2 章の splitFeaturesAndTarget は (列名, 行, 正解) のタプルを返すので、val (columns, rows, target) = ... と一度に受け取れます。Java 版は同じことをするために FeaturesAndTarget という record を用意しました。
Kotlin 版では、欠損のある整数の列(RAD)が Int? として読み込まれ、平均値の Double を入れられないという型の問題が実データで見つかりました。Scala 版の Row はセルを文字列で持ち、number で読むときに Option[Double] にするので、この問題は起きません。列の型を読み込み時に推論しない代わりに、読むたびに解釈する設計の違いです。
線形回帰と決定係数¶
決定係数を測るために、この章に正規方程式を解く最小の LinearModel を置きました。Tribuo の DenseMatrix のコレスキー分解で XᵀX β = Xᵀt を解きます。
/** 先頭に 1 の列を加えた計画行列 X で、正規方程式 XᵀX β = Xᵀt を解く。 */
def fit(rows: Vector[Vector[Double]], t: Vector[Double]): LinearModel =
val design = rows.map(row => (1.0 +: row).toArray).toArray
val x = DenseMatrix.createDenseMatrix(design)
val transposed = x.transpose()
val cholesky = transposed
.matrixMultiply(x)
.choleskyFactorization()
.toScala
.getOrElse(throw IllegalArgumentException("特徴量の列が互いに独立でないため、正規方程式を解けません"))
val target = DenseVector.createDenseVector(t.toArray)
val beta = cholesky.solve(transposed.leftMultiply(target)).toArray.toVector
LinearModel(beta.head, beta.tail)
Tribuo は Java のライブラリなので、行列は Array[Array[Double]]、choleskyFactorization() の戻り値は java.util.Optional です。Scala の不変コレクションとの境目は次の 3 か所だけで済みました。
| 境目 | 橋渡し |
|---|---|
Vector[Vector[Double]] → Array[Array[Double]] |
rows.map(row => (1.0 +: row).toArray).toArray |
java.util.Optional[T] → Option[T] |
scala.jdk.OptionConverters の .toScala |
Array[Double] → Vector[Double] |
.toVector |
Optional を Option に変えてしまえば、getOrElse で「解けない」ことを例外にできます(Java 版の orElseThrow、Kotlin 版の orElseThrow { ... } に当たります)。1.0 +: row で先頭に 1 を足せるので、Java 版の System.arraycopy は要りません。
特徴量の組ごとの評価は Boston.scoreFeatureSet にまとめました。訓練データとテストデータのそれぞれで多項式特徴量を作って項を選び、訓練データで Standardizer を fit し、両方を transform してから学習します。
def scoreFeatureSet(
split: TrainTestSplit[Features, Double],
columns: Vector[String],
terms: Vector[String]
): Scores =
val train = PolynomialFeatures.select(PolynomialFeatures.expand(split.xTrain, columns), terms)
val test = PolynomialFeatures.select(PolynomialFeatures.expand(split.xTest, columns), terms)
val standardizer = Standardizer.fit(train)
val xTrain = standardizer.transform(train).map(_.values)
val xTest = standardizer.transform(test).map(_.values)
val model = LinearModel.fit(xTrain, split.tTrain)
Scores(
LinearModel.rSquared(split.tTrain, model.predict(xTrain)),
LinearModel.rSquared(split.tTest, model.predict(xTest))
)
Kotlin 版は訓練とテストの決定係数を Pair<Double, Double> で返しましたが、Scala 版は Java 版と同じく Scores(train: Double, test: Double) という case class にしました。タプルの _1・_2 ではなく scores.train・scores.test と読めます。タプルは作るのが楽ですが、読む人には名前が要ります。
価格を 3 × RM² + 1 にした架空のデータで、「2 乗の項が無いと当てきれない」「2 乗の項を加えると訓練データもテストデータも決定係数が 1 になる」ことを確かめてから、実データに進みました。
実データで測る¶
sbt "run chapter09" の出力です。
訓練データ: 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
「標準化した訓練データの RM」は、標準化した訓練データにもう一度 Standardizer.fit を当て、その平均と標準偏差を表示しています。
- 2 乗の項 を加えると、テストデータの決定係数が 0.6950 から 0.8628 に上がりました
- 交互作用の項 も加えると、訓練データでは上がる(0.7740 → 0.7953)のに、テストデータでは下がりました(0.8628 → 0.8213)。列を増やすと訓練データには合わせやすくなりますが、未知のデータへの当てはまりが良くなるとは限りません
- 外れ値 を除いて学習すると、テストデータの決定係数は 0.7947 に下がりました。テストデータには外れ値が残っているので、外れ値を学ばなかったモデルはそれを当てられません
- 天気ごとの平均利用者数は、ほかの版と同じ値になりました(分割に関係しないため)
決定係数の 8 つの数値は、Java 版の記事とすべて一致しました。 第 2 章で書いたとおり、Scala 版の Preprocessing.splitTrainTest は java.util.Random と Fisher-Yates のシャッフルで Java 版(Collections.shuffle)と同じ並びを作ります。同じ行が訓練データとテストデータに入るので、そのあとの標準化・多項式特徴量・正規方程式まで含めて同じ値になります。Kotlin 版は kotlin.random.Random(0) を使うので分け方が違い、2 乗の項でテスト 0.6457 → 0.7975 と別の値になりました。F# 版はさらに別の乱数生成器です。
「実装が正しい」ことの確かめ方が 1 つ増えた とも言えます。言語をまたいでも、乱数の作り方と手順をそろえれば数値は完全に一致します。逆に言えば、数値が合わないときは乱数か手順のどちらかが違う、と切り分けられます。「2 乗の項で上がる」「交互作用の項ではテストデータが下がる」という結論は、Java 版・Python 版と同じです。
この出力は FeatureEngineeringDataSpec で固定しています。学習データが無い環境では、assume で実データのテストをスキップします。
private def requireData(): org.scalatest.Assertion =
assume(
Vector(bostonCsv, bikeTsv, weatherCsv).forall(Files.exists(_: Path)),
"学習データ Boston.csv・bike.tsv・weather.csv が配置されていない(gulp data:setup)"
)
assume は Assertion を返すので、-Wvalue-discard の設定では戻り値を捨てるとエラーになります。第 2 章と同じく、呼ぶ側で requireData(): Unit と型を書いて捨てることを明示します。
9.10 Notebook による探索と可視化¶
Scala 版では Notebook と可視化の節を設けません。標準化の前後の分布、特徴量と価格の関係、外れ値、天気ごとの利用者数のグラフは、Python 版の第 9 章 と Kotlin 版の第 9 章 の「Notebook による探索と可視化」の節を参照してください。
9.11 リファクタリング¶
TODO リストをすべて終えてから、sbt "scalafmtAll; scalafmtCheckAll; test" で整形と検査をかけました。
- 警告はすべてエラー —
-Wunused:allがテストの未使用のvalを、-Wvalue-discardがassumeの戻り値を捨てているところを止めました。どちらも抑制せずに直しています Mapを返すか、Vectorを返すか — 並び順に意味があるmeanCountByWeatherはVector[(String, Double)]を返し、列名で引くStandardizerの平均・標準偏差はSeqMapを返します。「順序が要るか」「キーで引くか」で型を選び分けましたTermに名前を付けた — 組を(String, String)のタプルのままにせず、nameを持つ case class にしました
第 14 章から使う API¶
第 14 章(K-means)でも標準化を使うので、Standardizer は次の形で公開しています。
| API | 内容 |
|---|---|
Standardizer.fit(x: Vector[Features]): Standardizer |
すべての列の平均と標準偏差(件数で割る)を求める |
standardizer.transform(x: Vector[Features]): Vector[Features] |
リストを標準化する |
standardizer.transform(features: Features): Features |
1 件を標準化する。平均を持たない列はそのまま残す |
standardizer.means: SeqMap[String, Double]・stds |
列名の順を保った、不変の対応表 |
Standardizer(means, stds) |
平均と標準偏差を直接与えて作る(列が食い違えば IllegalArgumentException) |
K-means は距離を使うので、標準偏差の定義(件数で割る)を変えると結果が変わります。Tribuo の MeanStdDevTransformation(件数から 1 を引く)ではなく、この Standardizer を使うことを第 14 章でも明記します。
9.12 まとめ¶
この章では、特徴量エンジニアリングの 5 つの技法を Scala の TDD で自作し、標準化を Tribuo と突き合わせました。
| 技法 | 自作したもの | 突き合わせたライブラリ | 落とし穴 |
|---|---|---|---|
| ダミー変数 | Dummies.categories・encode |
— | 訓練データとテストデータで列をそろえる |
| 標準化 | Standardizer |
Tribuo の MeanStdDevTransformation |
標準偏差の定義がライブラリで違う、分散 0 の列、テストデータの平均を使わない |
| 多項式特徴量 | PolynomialFeatures.expand・Term |
— | 列数が急に増えて過学習しやすい |
| 外れ値の検出 | Outliers.quantile・iqrOutliers |
— | 検出はできても、除くかどうかはデータの意味で決める |
| 表の結合 | DelimitedFiles.load・BikeWeather.joinWeather |
— | 区切り文字と文字コード、内部結合で消える行、toMap の静かな上書き |
実データでは、2 乗の項を加えるとテストデータの決定係数が 0.6950 から 0.8628 に上がり、交互作用の項を加えると 0.8213 に、外れ値を除くと 0.7947 に下がりました。これらの値は Java 版と完全に一致しました。
Scala 版ならではの学びもありました。
- 順序のある不変の
Mapが型で書ける — Java 版がLinkedHashMap+unmodifiableMapの 2 段で表したものを、SeqMapの 1 語で表せる。順序が要らない結果はMap、順序に意味がある結果はVector[(K, V)]と、型で意図を表す Optionが結合と既定値をまとめる — 内部結合はflatMap+Option、「標準化しない列」はmeans.get(column).fold(value)(...)で、条件分岐を書かずに済む- 添字が消える —
zip・collect・++で、特徴量と正解、元の列と項を対応づけられる。Java 版の添字の計算が無くなった分、ずれる余地も無い - Java の API との境目は小さい — Tribuo の行列と
Optionalは、toArray・toScala・toVectorの 3 つだけで橋渡しできた。メソッド参照もイータ拡張でそのまま書ける - 乱数と手順をそろえれば数値は一致する — Java 版と同じ
java.util.Randomの Fisher-Yates を使ったので、決定係数まで完全に一致した
次の章では、分類のモデルを増やし、ロジスティック回帰と、第 3 章の決定木を組み合わせたランダムフォレストを実装します。