第 9 章: 特徴量エンジニアリング¶
9.1 はじめに¶
モデルの性能は、アルゴリズムよりも「どんな特徴量を渡すか」で大きく変わることがあります。元のデータから、モデルが学びやすい特徴量を作り出す作業を 特徴量エンジニアリング と呼びます。
この章では、ボストンの住宅価格データを題材に、次の 5 つの技法を TDD で自作します。
- カテゴリ値をダミー変数(0 と 1 の列)にする
- 特徴量を標準化する
- 2 乗の項と交互作用の項(多項式特徴量)を作る
- 外れ値を検出する
- 別の表を結合して特徴量を増やす
標準化は Tribuo の MeanStdDevTransformation と突き合わせます。最後に、作った特徴量で線形回帰の決定係数がどう変わるかを実データで測ります。
Python 版の第 9 章 と同じ TODO リストで進め、Kotlin 版の第 9 章 と対比します。Kotlin 版は Kotlin DataFrame の convert・add・innerJoin・groupBy で表を操作しました。Java 版はデータフレームのライブラリを使わず、第 2 章の Table・Row・Features と Stream API で同じことを書きます。
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 としては読めない) |
文字コードは、ファイルを UTF-8 と Shift_JIS のそれぞれで復号できるかを試して確かめました。weather.csv だけが 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 乗の項を加える
- 2 つの列の積(交互作用の項)を加える
- IQR で外れ値を検出する
- タブ区切り・Shift_JIS のファイルを読み込んで結合する
- 特徴量の組み合わせごとに決定係数を測る
この章のクラスは chapter09 パッケージに置き、技法ごとにクラスを分けます。
| クラス | 役割 |
|---|---|
Dummies |
ダミー変数 |
Standardizer |
標準化(record) |
TribuoStandardization |
Tribuo の標準化の呼び出し |
PolynomialFeatures |
多項式特徴量 |
Outliers |
外れ値の検出 |
DelimitedFiles・BikeWeather |
区切り文字・文字コードを指定した読み込みと、表の結合 |
LinearModel・Scores・Boston |
正規方程式による線形回帰、決定係数、Boston データの前処理 |
9.4 カテゴリ値をダミー変数にする¶
カテゴリを求める¶
CRIME のようなカテゴリ値は、そのままでは線形回帰に渡せません。カテゴリごとに「その値なら 1、それ以外は 0」の列を作ります。3 つのカテゴリなら 2 列あれば区別できる(2 列とも 0 なら残りの 1 つ)ので、Python 版の pd.get_dummies(drop_first=True) と同じく、辞書順で先頭のカテゴリを除きます。
@Test
@DisplayName("先頭を除いたカテゴリを辞書順に返す")
void categoriesWithoutFirst() {
assertThat(Dummies.categories(List.of("low", "high", "very_low", "low")))
.containsExactly("low", "very_low");
}
@Test
@DisplayName("欠損値(空欄)はカテゴリに数えない")
void categoriesIgnoreMissing() {
assertThat(Dummies.categories(List.of("low", "", "high"))).containsExactly("low");
}
Kotlin 版は欠損値を null で表し、filterNotNull() で除きました。Java 版の Row は空欄を空文字列のまま持つので、isBlank() で除きます。
/** 欠損値(空欄)を除いたカテゴリを辞書順に並べ、先頭を除いて返す(pandas の drop_first=True と同じ)。 */
public static List<String> categories(List<String> values) {
return values.stream().filter(v -> !v.isBlank()).distinct().sorted().skip(1).toList();
}
distinct().sorted().skip(1) は Kotlin の distinct().sorted().drop(1) とほぼ同じ形です。
表に列を加える¶
ダミー変数の列は、表の末尾に「列名_カテゴリ」という名前で加えます。
@Test
@DisplayName("カテゴリごとに 0 と 1 の列を作り元の列を取り除く")
void encodesDummies() {
Table encoded =
Dummies.encode(crimeTable("low", "high", "very_low"), "CRIME", List.of("low", "very_low"));
assertThat(encoded.columns()).containsExactly("RM", "CRIME_low", "CRIME_very_low");
assertThat(encoded.rows()).extracting(r -> r.text("RM")).containsExactly("6.0", "6.0", "6.0");
assertThat(encoded.rows()).extracting(r -> r.text("CRIME_low")).containsExactly("1", "0", "0");
assertThat(encoded.rows())
.extracting(r -> r.text("CRIME_very_low"))
.containsExactly("0", "0", "1");
}
Table は変更できない record なので、行ごとにセルの Map を写し、元の列を取り除いてダミー変数のセルを加えた新しい Row を作ります。
/** 列を取り除き、カテゴリごとに「列名_カテゴリ」の列を末尾に加える。値が一致すれば "1"、それ以外は "0"。 */
public static Table encode(Table table, String column, List<String> categories) {
List<String> dummyColumns = categories.stream().map(c -> column + "_" + c).toList();
List<String> columns =
Stream.concat(
table.columns().stream().filter(c -> !c.equals(column)), dummyColumns.stream())
.toList();
List<Row> rows = table.rows().stream().map(row -> encodeRow(row, column, categories)).toList();
return new Table(columns, rows);
}
private static Row encodeRow(Row row, String column, List<String> categories) {
Map<String, String> cells = new HashMap<>(row.cells());
String value = cells.remove(column);
for (String category : categories) {
cells.put(column + "_" + category, category.equals(value) ? "1" : "0");
}
return new Row(cells);
}
セルは文字列のまま "1"・"0" にしておきます。こうすると、第 2 章の Preprocessing.splitFeaturesAndTarget・columnMeans・fillMissing を、ダミー変数の列にもそのまま使えます。カテゴリを引数で受け取るのは Kotlin 版と同じ理由で、訓練データで決めたカテゴリをテストデータにも当てはめ、列をそろえるためです。カテゴリに無い値の行は、すべての列が "0" になることもテストで確かめました。
9.5 特徴量を標準化する¶
標準化とは¶
列ごとに平均を引いて標準偏差で割り、平均 0・標準偏差 1 にそろえることを 標準化 と呼びます。単位や桁の違う列(部屋数と税率など)を同じ尺度で比べられるようになります。平均と標準偏差は訓練データだけで求め、テストデータにも同じ値を使います。テストデータの平均を使うと、テストデータの情報が学習に漏れるからです。
平均と標準偏差を求める¶
Kotlin 版は Standardizer を data class にし、列名から平均・標準偏差への Map を持たせました。Java 版は record にします。第 14 章(K-means)でもこのクラスを使うので、第 2 章の Features のリストをそのまま受け取れる形にします。
@Test
@DisplayName("訓練データから列ごとの平均と標準偏差を求める")
void fitsMeansAndStds() {
var standardizer = Standardizer.fit(List.of(row(1, 10), row(2, 10), row(3, 40)));
assertThat(standardizer.means().get("RM")).isCloseTo(2.0, within(1e-12));
assertThat(standardizer.means().get("LSTAT")).isCloseTo(20.0, within(1e-12));
assertThat(standardizer.stds().get("RM")).isCloseTo(Math.sqrt(2.0 / 3), within(1e-12));
assertThat(standardizer.stds().get("LSTAT")).isCloseTo(Math.sqrt(200.0), within(1e-12));
}
RM の標準偏差を √(2/3) としているのは、件数(3)で割る標準偏差だからです。scikit-learn の StandardScaler と同じ定義にしました。
テストを実行すると、まだ Standardizer が無いのでコンパイルで失敗します。
src/test/java/chapter09/StandardizerTest.java:22: エラー: シンボルを見つけられません
var standardizer = Standardizer.fit(List.of(row(1, 10), row(2, 10), row(3, 40)));
^
シンボル: 変数 Standardizer
場所: クラス StandardizerTest
src/test/java/chapter09/StandardizerTest.java:33: エラー: シンボルを見つけられません
var standardizer = new Standardizer(Map.of("RM", 2.0), Map.of("RM", 0.5));
^
シンボル: クラス Standardizer
場所: クラス StandardizerTest
実装は次のとおりです。
public record Standardizer(Map<String, Double> means, Map<String, Double> stds) {
public Standardizer {
if (!means.keySet().equals(stds.keySet())) {
throw new IllegalArgumentException("平均と標準偏差の列が違います");
}
means = Collections.unmodifiableMap(new LinkedHashMap<>(means));
stds = Collections.unmodifiableMap(new LinkedHashMap<>(stds));
}
/** 特徴量のすべての列について、平均と標準偏差を求める。 */
public static Standardizer fit(List<Features> x) {
if (x.isEmpty()) {
throw new IllegalArgumentException("特徴量が 1 件もありません");
}
Map<String, Double> means = new LinkedHashMap<>();
Map<String, Double> stds = new LinkedHashMap<>();
for (String column : x.getFirst().columns()) {
double[] values = x.stream().mapToDouble(f -> f.value(column)).toArray();
double mean = mean(values);
double variance = mean(Arrays.stream(values).map(v -> (v - mean) * (v - mean)).toArray());
double std = Math.sqrt(variance);
means.put(column, mean);
stds.put(column, std == 0 ? 1.0 : std);
}
return new Standardizer(means, stds);
}
private static double mean(double[] values) {
return Arrays.stream(values).average().orElseThrow();
}
/** 1 件の特徴量を標準化する。平均と標準偏差を持たない列はそのまま残す。 */
public Features transform(Features features) {
List<String> columns = features.columns();
double[] values = features.values();
for (int i = 0; i < values.length; i++) {
String column = columns.get(i);
if (means.containsKey(column)) {
values[i] = (values[i] - means.get(column)) / stds.get(column);
}
}
return new Features(columns, values);
}
/** 特徴量のリストを標準化する。 */
public List<Features> transform(List<Features> x) {
return x.stream().map(this::transform).toList();
}
}
Kotlin 版との違いを 3 つ挙げます。
Mapの写し方 —Map.copyOfは変更できない写しを作りますが、順序を保ちません。列の順を保ちたいので、LinkedHashMapに写してからCollections.unmodifiableMapで包みました。Kotlin のassociateWithは最初から挿入順のLinkedHashMapを返します- 標準偏差の計算 — Kotlin 版は Kotlin DataFrame の
std(ddof = 0)を呼びました。Java 版はライブラリを使わないので、分散(平均との差の 2 乗の平均)の平方根を自分で書きます - 配列の写し —
Features.values()は配列の写しを返す(第 2 章)ので、transformで書き換えても元の特徴量は変わりません
transform には 1 件の特徴量を受け取るものと、リストを受け取るものを用意しました。すべて同じ値の列は、標準偏差を 1 にして値が 0 になるようにしています(0 で割って NaN になるのを避けるため)。
Tribuo の標準化と突き合わせる¶
Tribuo には MeanStdDevTransformation という標準化があります。Java から直接呼べるので、Kotlin 版の橋渡しのコードとほぼ同じ形になります。
/** 訓練データの値から平均と標準偏差を求め、別の値を標準化する。 */
public static List<Double> standardize(List<Double> train, List<Double> values) {
TransformStatistics statistics = new MeanStdDevTransformation().createStats();
train.forEach(statistics::observeValue);
Transformer transformer = statistics.generateTransformer();
return values.stream().map(transformer::transform).toList();
}
まず、自作の標準化と同じ値になるはずだと考えて、そのまま比べるテストを書いてみました。
double own = standardizer.transform(Samples.rm(8.0)).value("RM");
assertThat(own)
.isCloseTo(
TribuoStandardization.standardize(train, List.of(8.0)).getFirst(), within(1e-12));
java.lang.AssertionError:
Expecting actual:
2.197401062294143
to be close to:
1.9030051422496395
by less than 1.0E-12 but difference was 0.2943959200445035.
2 つの値の比は 0.866 で、√(3/4) です。訓練データは 4 件なので、Tribuo は件数から 1 を引いた 3 で割る 不偏標準偏差 を使っていることが分かります。Kotlin 版で確かめた結果(ADR 002)と同じです。この事実をテストに残します。
@Test
@DisplayName("Tribuo の MeanStdDevTransformation は件数から 1 を引いて割る標準偏差を使う")
void tribuoUsesSampleStd() {
double mean = TRAIN.stream().mapToDouble(Double::doubleValue).average().orElseThrow();
double sumOfSquares = TRAIN.stream().mapToDouble(v -> (v - mean) * (v - mean)).sum();
double sampleStd = Math.sqrt(sumOfSquares / (TRAIN.size() - 1));
List<Double> standardized = TribuoStandardization.standardize(TRAIN, TEST);
assertThat(standardized.get(0)).isCloseTo((6.2 - mean) / sampleStd, within(1e-12));
assertThat(standardized.get(1)).isCloseTo((8.0 - mean) / sampleStd, within(1e-12));
}
@Test
@DisplayName("自作の標準化に件数から決まる係数を掛けると Tribuo の値になる")
void ownTimesRatioEqualsTribuo() {
var standardizer = Standardizer.fit(TRAIN.stream().map(Samples::rm).toList());
double ratio = Math.sqrt((TRAIN.size() - 1.0) / TRAIN.size());
List<Double> tribuo = TribuoStandardization.standardize(TRAIN, TEST);
for (int i = 0; i < TEST.size(); i++) {
double own = standardizer.transform(Samples.rm(TEST.get(i))).value("RM");
assertThat(own * ratio).isCloseTo(tribuo.get(i), within(1e-12));
}
}
同じ「標準偏差」でも、ライブラリによって割る数が違います。件数が多ければ差は小さくなりますが、この章の訓練データは 70 件なので、係数は √(69/70) ≒ 0.993 です。線形回帰では係数の大きさが変わるだけで予測は変わりませんが、第 14 章の K-means のように距離を使うアルゴリズムでは、どちらの定義を使ったかを記事とコードで明示しておきます。
9.6 多項式特徴量を作る¶
2 乗の項と交互作用の項¶
部屋数(RM)と価格の関係が直線でなく曲線なら、RM の 2 乗の列を加えると線形回帰でも曲線を表せます。2 つの列の積(交互作用の項)を加えると、「部屋数が多く、かつ低所得者の割合が低い」のような組み合わせの効果を表せます。
@Test
@DisplayName("2 列なら 2 乗の列と 2 つの列の積の列を加える")
void twoColumns() {
var columns = List.of("RM", "LSTAT");
var x =
List.of(
new Features(columns, new double[] {2, 5}), new Features(columns, new double[] {3, 7}));
List<Features> features = PolynomialFeatures.expand(x, columns);
assertThat(features.getFirst().columns())
.containsExactly("RM", "LSTAT", "RM^2", "RM LSTAT", "LSTAT^2");
assertThat(features).extracting(f -> f.value("RM LSTAT")).containsExactly(10.0, 21.0);
assertThat(features).extracting(f -> f.value("LSTAT^2")).containsExactly(25.0, 49.0);
}
1 列・2 列・3 列と三角測量し、3 列では scikit-learn の PolynomialFeatures と同じ並びの 9 列になることを確かめました。列の組は、重複を許して 2 つを選ぶ組を並べて作ります。Kotlin 版は Pair<String, String> と別の関数 termName を使いましたが、Java 版は組に名前付きの record を使い、項の名前をそのメソッドにしました。
public record Pair(String left, String right) {
/** 項の名前。scikit-learn の get_feature_names_out と同じ形("RM^2"、"RM LSTAT")にする。 */
public String name() {
return left.equals(right) ? left + "^2" : left + " " + right;
}
}
/** 重複を許して 2 つの列を選ぶ組を、scikit-learn の PolynomialFeatures と同じ順に並べる。 */
public static List<Pair> pairsWithReplacement(List<String> columns) {
List<Pair> pairs = new ArrayList<>();
for (int i = 0; i < columns.size(); i++) {
for (int j = i; j < columns.size(); j++) {
pairs.add(new Pair(columns.get(i), columns.get(j)));
}
}
return List.copyOf(pairs);
}
expand は、指定した列の値の後ろに、組ごとの積を並べた新しい Features を作ります。使う項を選ぶ select も用意し、「元の特徴量だけ」「2 乗の項を追加」「交互作用の項も追加」を同じ流れで比べられるようにしました。
9.7 外れ値を検出する¶
第 1 四分位数(Q1)と第 3 四分位数(Q3)の差を 四分位範囲(IQR) と呼びます。Q1 − 1.5 × IQR より小さい値と、Q3 + 1.5 × IQR より大きい値を外れ値とみなします。
@Test
@DisplayName("四分位数の位置が値の間にあれば前後の値から線形補間する")
void quantileInterpolates() {
assertThat(Outliers.quantile(List.of(4.0, 1.0, 3.0, 2.0), 0.25)).isCloseTo(1.75, within(1e-12));
}
@Test
@DisplayName("第 3 四分位数から IQR の 1.5 倍より大きい値を外れ値とする")
void upperOutlier() {
assertThat(Outliers.iqrOutliers(List.of(1.0, 2.0, 3.0, 4.0, 100.0)))
.containsExactly(false, false, false, false, true);
}
/** 分位数を求める。位置が値の間にあれば前後の値から線形補間する(pandas の quantile の既定と同じ)。 */
public static double quantile(List<Double> values, double q) {
List<Double> sorted = values.stream().sorted().toList();
double position = (sorted.size() - 1) * q;
int lower = (int) Math.floor(position);
int upper = (int) Math.ceil(position);
return sorted.get(lower) + (sorted.get(upper) - sorted.get(lower)) * (position - lower);
}
/** 第 1 四分位数から IQR の k 倍より小さい値と、第 3 四分位数から k 倍より大きい値を外れ値とする。 */
public static List<Boolean> iqrOutliers(List<Double> values, double k) {
double q1 = quantile(values, FIRST_QUARTILE);
double q3 = quantile(values, THIRD_QUARTILE);
double iqr = q3 - q1;
return values.stream().map(v -> v < q1 - k * iqr || v > q3 + k * iqr).toList();
}
/** k を 1.5 にして外れ値を求める。 */
public static List<Boolean> iqrOutliers(List<Double> values) {
return iqrOutliers(values, DEFAULT_K);
}
Kotlin 版は k: Double = 1.5 と既定の引数で書きました。Java には既定の引数が無いので、引数の少ないメソッドを オーバーロード して既定値を渡します。
外れ値を除く removeTargetOutliers は、訓練データから正解(価格)が外れ値の行だけを取り除き、テストデータには手を付けません。テストデータは「本番で来るデータ」の代わりなので、外れ値を含んでいても評価から外してはいけないからです。
9.8 表を結合して特徴量を増やす¶
タブ区切りと Shift_JIS¶
区切り文字と文字コードを受け取って Table を作る DelimitedFiles.load を足します。
/** 1 行目を列名として読み込む。文字コードが違えば MalformedInputException を投げる。 */
public static Table load(Path file, Charset charset, String delimiter) throws IOException {
String separator = Pattern.quote(delimiter);
List<String> lines = Files.readAllLines(file, charset);
List<String> columns = List.of(lines.getFirst().split(separator));
List<Row> rows =
lines.stream()
.skip(1)
.filter(line -> !line.isBlank())
.map(line -> toRow(columns, line.split(separator, -1)))
.toList();
return new Table(columns, rows);
}
String.split は正規表現を受け取るので、区切り文字を Pattern.quote で囲んでおきます(| などを区切り文字にしても誤動作しないように)。bike.tsv は "\t" と UTF-8、weather.csv は "," と Charset.forName("Shift_JIS") で読み込みます。
文字コードを間違えたときの振る舞いは、Kotlin 版と Java 版で違います。Kotlin 版の記事にあるとおり、Kotlin DataFrame は UTF-8 として解釈できないバイトを置換文字(U+FFFD)に置き換えて読み込みを続けました。Java の Files.readAllLines は置き換えずに MalformedInputException を投げます。この違いをテストに残しました。
@Test
@DisplayName("Shift_JIS のファイルを UTF-8 として読むと例外になる")
void shiftJisIsNotUtf8() throws IOException {
Path csvFile = directory.resolve("weather.csv");
Files.writeString(csvFile, "weather_id,weather\n1,晴れ\n", Charset.forName("Shift_JIS"));
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> DelimitedFiles.load(csvFile, StandardCharsets.UTF_8, ","))
.isInstanceOf(java.nio.charset.MalformedInputException.class);
}
Files.readAllLines は、復号できないバイトがあると置き換えずに例外で知らせる、と API ドキュメントに書かれています。文字化けしたデータが黙って入ってこない点では、この読み込み方のほうが安全です。
Map による結合と集計¶
利用者数の表に、天気 ID をキーにして天気の名前を加えます。Kotlin 版は innerJoin を使いました。Java 版は、天気の表を「天気 ID → 行」の Map にしてから、利用者数の行ごとに引きます。
/** 天気 ID をキーにした Map を引いて、天気の列を加える(内部結合)。天気の表に無い ID の行は残さない。 */
public static Table joinWeather(Table bike, Table weather) {
Map<String, Row> byId =
weather.rows().stream()
.collect(Collectors.toMap(row -> row.text(KEY), Function.identity()));
List<String> added = weather.columns().stream().filter(c -> !KEY.equals(c)).toList();
List<Row> rows =
bike.rows().stream()
.filter(row -> byId.containsKey(row.text(KEY)))
.map(row -> join(row, byId.get(row.text(KEY)), added))
.toList();
return new Table(Stream.concat(bike.columns().stream(), added.stream()).toList(), rows);
}
天気の表に無い天気 ID の行は filter で落とすので、SQL の内部結合と同じ結果になります(テスト「天気の表に無い天気 ID の行は残さない」)。Collectors.toMap はキーが重複すると例外を投げるので、天気の表の天気 ID が一意でなければ結合の前に気付けます。filter(c -> !KEY.equals(c)) と定数を左に置いているのは、PMD の LiteralsFirstInComparisons の指摘に従ったためです。
天気ごとの平均利用者数は、Collectors.groupingBy と averagingDouble で求め、多い順に並べ替えて LinkedHashMap に集めます。
/** 天気ごとの平均利用者数を、多い順に並べて返す。 */
public static Map<String, Double> meanCountByWeather(Table joined) {
Map<String, Double> means =
joined.rows().stream()
.collect(
Collectors.groupingBy(
row -> row.text("weather"),
Collectors.averagingDouble(row -> row.number("cnt").orElseThrow())));
return means.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.collect(
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
}
groupingBy が返す HashMap は順序を持たないので、並べ替えたあとに LinkedHashMap::new を渡して順序を保ちます。Map.Entry.<String, Double>comparingByValue() の型引数は、reversed() を続けると型推論が効かなくなるため明示しています。
9.9 特徴量の効果を測る¶
Boston データの前処理¶
Boston.prepare は、CRIME をダミー変数にしてから、第 2 章の関数で「特徴量と正解に分ける → シード付きで分割する → 訓練データの平均値で両方の欠損値を補完する」を行います。
Kotlin 版では、欠損のある整数の列(RAD)が Int? として読み込まれ、平均値の Double を入れられないという型の問題が実データで見つかりました。Java 版の Row はセルを文字列で持ち、number で読むときに double にするので、この問題は起きません。列の型を読み込み時に推論しない代わりに、読むたびに解釈する設計の違いです。
線形回帰と決定係数¶
決定係数を測るために、Kotlin 版と同じく、この章に正規方程式を解く最小の LinearModel を置きました。Tribuo の DenseMatrix のコレスキー分解で XᵀX β = Xᵀt を解きます。
/** 先頭に 1 の列を加えた計画行列 X で、正規方程式 XᵀX β = Xᵀt を解く。 */
public static LinearModel fit(double[][] rows, List<Double> t) {
double[][] design = new double[rows.length][];
for (int i = 0; i < rows.length; i++) {
design[i] = new double[rows[i].length + 1];
design[i][0] = 1;
System.arraycopy(rows[i], 0, design[i], 1, rows[i].length);
}
DenseMatrix x = DenseMatrix.createDenseMatrix(design);
DenseMatrix transposed = x.transpose();
DenseMatrix.CholeskyFactorization cholesky =
transposed
.matrixMultiply(x)
.choleskyFactorization()
.orElseThrow(() -> new IllegalArgumentException("特徴量の列が互いに独立でないため、正規方程式を解けません"));
DenseVector target = DenseVector.createDenseVector(t.stream().mapToDouble(v -> v).toArray());
double[] beta = cholesky.solve(transposed.leftMultiply(target)).toArray();
return new LinearModel(beta[0], Arrays.stream(beta).skip(1).boxed().toList());
}
choleskyFactorization() は Optional を返すので、Kotlin 版の orElseThrow { ... } と同じく orElseThrow で「解けない」ことを例外にします。
特徴量の組ごとの評価は Boston.scoreFeatureSet にまとめました。訓練データとテストデータのそれぞれで多項式特徴量を作って項を選び、訓練データで Standardizer を fit し、両方を transform してから学習します。
public static Scores scoreFeatureSet(
TrainTestSplit<Features, Double> split, List<String> columns, List<String> terms) {
List<Features> train =
PolynomialFeatures.select(PolynomialFeatures.expand(split.xTrain(), columns), terms);
List<Features> test =
PolynomialFeatures.select(PolynomialFeatures.expand(split.xTest(), columns), terms);
Standardizer standardizer = Standardizer.fit(train);
double[][] xTrain = toRows(standardizer.transform(train));
double[][] xTest = toRows(standardizer.transform(test));
LinearModel model = LinearModel.fit(xTrain, split.tTrain());
return new Scores(
LinearModel.rSquared(split.tTrain(), model.predict(xTrain)),
LinearModel.rSquared(split.tTest(), model.predict(xTest)));
}
Kotlin 版は訓練とテストの決定係数を Pair<Double, Double> で返しましたが、Java 版は Scores(double train, double test) という record にしました。first・second ではなく train()・test() と読めます。
価格を 3 × RM² + 1 にした架空のデータで、「2 乗の項が無いと当てきれない」「2 乗の項を加えると訓練データもテストデータも決定係数が 1 になる」ことを確かめてから、実データに進みました。
実データで測る¶
./gradlew runChapter -Pchapter=09 の出力です。
訓練データ: 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 に下がりました。テストデータには外れ値が残っているので、外れ値を学ばなかったモデルはそれを当てられません
- 天気ごとの平均利用者数は、Kotlin 版と同じ値になりました
決定係数の値は Kotlin 版(2 乗の項でテスト 0.6457 → 0.7975)と違います。第 2 章と同じく、Java 版は java.util.Random(0) で Collections.shuffle し、Kotlin 版は kotlin.random.Random(0) で shuffled するので、同じシードでも訓練データとテストデータに入る行が違うためです。「2 乗の項で上がる」という結論は同じですが、交互作用の項の効果は、Java 版では Python 版と同じく「下がる」、Kotlin 版では「ほとんど変わらない」になりました。100 件のデータでは、分け方によって結論が揺れる判断もあることが分かります。
この出力は FeatureEngineeringDataTest で固定しています。学習データが無い環境では、assumeTrue で実データのテストをスキップします。
9.10 Notebook による探索と可視化¶
Java 版では Notebook と可視化の節を設けません。標準化の前後の分布、特徴量と価格の関係、外れ値、天気ごとの利用者数のグラフは、Python 版の第 9 章 と Kotlin 版の第 9 章 の「Notebook による探索と可視化」の節を参照してください。
9.11 リファクタリング¶
TODO リストをすべて終えてから、./gradlew spotlessApply check で整形と静的解析をかけました。
- PMD の
LiteralsFirstInComparisons—c.equals(KEY)をKEY.equals(c)に直しました。定数を左に置けば、左辺がnullでもNullPointerExceptionになりません。Dummies.encodeのc.equals(column)は、比べる相手が引数で定数ではないので指摘されません - 表示の桁数 —
Mainの表示の桁数(4・2・1)をSCORE_DIGITS・MEAN_DIGITS・COUNT_DIGITSの定数にしました - 標準偏差の確認 — 最初は
Mainの中で平均と標準偏差を計算し直していましたが、Standardizer.fitをもう一度当てれば同じことが分かるので、計算の重複を消しました
Kotlin 版が detekt の指摘で技法ごとにファイルを分けたのに対し、Java 版は「1 ファイルに 1 つのトップレベルのクラス」なので、最初から技法ごとのクラスに分かれています。
第 14 章から使う API¶
第 14 章(K-means)でも標準化を使うので、Standardizer は次の形で公開しています。
| API | 内容 |
|---|---|
Standardizer.fit(List<Features> x) |
すべての列の平均と標準偏差(件数で割る)を求める |
standardizer.transform(List<Features> x) |
リストを標準化する |
standardizer.transform(Features features) |
1 件を標準化する。平均を持たない列はそのまま残す |
standardizer.means()・stds() |
列名の順を保った、変更できない Map |
9.12 まとめ¶
この章では、特徴量エンジニアリングの 5 つの技法を Java の TDD で自作し、標準化を Tribuo と突き合わせました。
| 技法 | 自作したクラス・メソッド | 突き合わせたライブラリ | 落とし穴 |
|---|---|---|---|
| ダミー変数 | Dummies.categories・encode |
— | 訓練データとテストデータで列をそろえる |
| 標準化 | Standardizer |
Tribuo の MeanStdDevTransformation |
標準偏差の定義がライブラリで違う、分散 0 の列、テストデータの平均を使わない |
| 多項式特徴量 | PolynomialFeatures.expand・pairsWithReplacement |
— | 列数が急に増えて過学習しやすい |
| 外れ値の検出 | Outliers.quantile・iqrOutliers |
— | 検出はできても、除くかどうかはデータの意味で決める |
| 表の結合 | DelimitedFiles.load・BikeWeather.joinWeather |
— | 区切り文字と文字コード、内部結合で消える行 |
実データでは、2 乗の項を加えるとテストデータの決定係数が 0.6950 から 0.8628 に上がり、交互作用の項を加えると 0.8213 に、外れ値を除くと 0.7947 に下がりました。
Java 版ならではの学びもありました。
- データフレームが無くても書ける —
Mapで結合し、Collectors.groupingByで集計した。順序を保つにはLinkedHashMapを明示する - 文字コードの誤りは例外になる —
Files.readAllLinesはMalformedInputExceptionを投げ、Kotlin DataFrame のように黙って文字化けしない - 既定の引数の代わりにオーバーロード —
iqrOutliers(values)とiqrOutliers(values, k)を用意した - 組には名前を付ける —
Pairの代わりにScoresやPolynomialFeatures.Pairの record を使い、項の名前もメソッドにした
次の章では、分類のモデルを増やし、ロジスティック回帰と、第 3 章の決定木を組み合わせたランダムフォレストを実装します。