機械学習の性能を正しく評価するための検証手法

  • 110
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

概要

多くの機械学習関連のライブラリのチュートリアルは解決するべき問題に対してある程度適切なモデルを初めから提示してくれますが、実際に機械学習を応用するのにあたって一番難しくて手間のかかるのはモデルのチューニングである気がします。特に最近流行ってる(ディープ)ニューラルネットワークを使いこなすのにはチューニングの要素が強いことが指摘されています。

モデルのチューニングで必要不可欠となるのがその検証です。この記事では機械学習全般で用いられる幾つかの検証方法を実装して比較します。

データの分割と誤り率 (error rate)

モデルの学習に使用するデータを学習データ (Train Data)、検証に使用するデータをテストデータ (Test Data)といいます。時々学習データと同じテストデータを用いた検証を見かけますが、その場合機械学習の学習能力を示したことにはなっても、汎化能力を示したことにはなりません。

次に分類器の誤り率について考えます(1)。先述した通り、学習データを用いて誤り率を評価(これを再代入誤り率といいます)しても、分類器の汎化能力は評価できません。学習に使用していないテストデータを用いて誤り率を評価する必要があります。
しかし、データが有限個しか与えられていないほとんどの現実問題では、データをどのように学習データとテストデータに分割するかが問題となります。幾つかある検証手法は、「いかにして有限個のデータを学習データとテストデータに分割するか」に尽きます。

各検証方法の概要

ホールドアウト法

|ooooooo|xxxxxx|
| train | test |

もっともナイーブな方法で、学習データとテストデータを単純に1方法で分割します。
すぐに、分割の方法が一意でない問題に気付きます。

交差確認法 / 一つ抜き法

test1: |xx|oo|oo|oo|
test2: |oo|xx|oo|oo|
test3: |oo|oo|xx|oo|
test4: |oo|oo|oo|xx|

o : train data
x : test data

上図のように、データ全体を$m$個のデータセットに分割し、$m$回の検証でそれぞれ$m$個目のデータセットをテストデータとして使用します。分割の方法によってバイアスが生じるので、幾つかの分割で評価した後に平均値を用います。
すべてのデータをテストデータとして使用できる利点があります。

$m$=データ数とする場合を一つ抜き法といいます。
この場合、分割方法が一意なので$m$回の評価で十分となります。

ブートストラップ法

raw data: |abcd|
sample1:  |bacc|
sample2:  |dabc|
...

上図のように、データ全体$N$から重複を許すサンプリング$N^*$を何度か行い評価します。
学習データ$A$とテストデータ$B$を用いた誤り率を$\varepsilon(A, B)$と表すと、以下の式によってバイアスを補正することができます(3)。

\mathrm{bias} = \varepsilon(N^*, N) - \varepsilon(N^*, N^*) \\
\varepsilon = \varepsilon(N, N) + \overline{\mathrm{bias}}
\tag{1}

ブートストラップ法を直感的に理解するためには、「再代入誤り率のバイアス」を考えます。
$\varepsilon (N^*, N) - \varepsilon (N^*, N^*)$ が(現実的に計算することができる)再代入誤り率のバイアスの予測値で、その予測値を$\varepsilon(N, N)$に加えることで(現実的に計算することができない)真の誤り率を求めようというものです。

bootstrap_error.png

実装と実行結果

各手法に共通する処理

irisをkNNで分類する例で、各手法を比較してみます。

library(dplyr)
library(ggplot2)

extract_input <- function(data) {
  return(data %>% select(c(Sepal.Length, Sepal.Width, Petal.Length, Petal.Width)))
}

extract_labels <- function(data) {
  return(data %$% Species %>% factor)
}

split_columns <- function(data) {
  return(list(
    "input" = data %>% extract_input,
    "labels" = data %>% extract_labels
  ))
}

error_rate <- function(test_data_labels, classified_labels) {
  num_data <- test_data_labels %>% length
  error_count <- (as.character(test_data_labels) != as.character(classified_labels)) %>% sum
  return(error_count / num_data)
}

sample_all <- function(data) {
  return(data %>% .[sample(1:nrow(.)),])
}

classifier_error <- function(train, test) {
  knn_result <- FNN::knn(train$input, test$input, train$labels, k=32)
  return(error_rate(test$label, knn_result))
}

ホールドアウト法

holdout <- function(data, num_train) {
  train_data <- data %>% head(num_train) %>% split_columns
  test_data <- data %>% tail(nrow(.) - num_train) %>% split_columns
  return(classifier_error(train_data, test_data))
}

num_train_array <- seq(from=10, to=140, by=10)
num_samples_per_train <- 64

error_rate_df <- plyr::ldply(num_train_array, function(num_train) {
  plyr::ldply(seq(num_samples_per_train), function(sample_index) {
    sampled_iris <- iris %>% sample_all
    c(num_train=num_train, error_rate=holdout(sampled_iris, num_train))
  })
})

p <- ggplot(error_rate_df, aes(x=factor(num_train), y=error_rate))
p + geom_boxplot()

holdout.png

横軸が学習データサイズ、縦軸が算出された誤り率です。
学習データを増やすほど誤り率が減少していきます。誤り率を下げるために学習データをできるだけ多くとれば良いかというとそうではなく、学習データが多い場合はテストデータが小さいために不当に誤り率が低く算出されてると言えます。
また、分割方法次第で誤り率が大きくばらついて算出されることも確認できます。

交差確認法 / 一つ抜き法

cross_validation <- function(data, subset_size) {
  num_splits <- nrow(iris) / subset_size
  error_array <- sapply(seq(num_splits), function(split_index) {
    test_data_flags <- c(
      rep(FALSE, subset_size * (split_index - 1)),
      rep(TRUE, subset_size),
      rep(FALSE, subset_size * (num_splits - split_index))
    )
    test_data <- data[test_data_flags,] %>% split_columns
    train_data <- data[!test_data_flags,] %>% split_columns
    classifier_error(train_data, test_data)
  })
  return(error_array %>% mean)
}
subset_size_array = c(1, 5, 10, 15, 25, 50, 75)
error_rate_df <- plyr::ldply(subset_size_array, function(subset_size) {
  plyr::ldply(seq(num_samples_per_train), function(subset_size_index) {
    sampled_iris <- iris %>% sample_all
    c(subset_size=subset_size, error_rate=cross_validation(sampled_iris, subset_size))
  })
})

p <- ggplot(error_rate_df, aes(x=factor(subset_size), y=error_rate))
p + geom_boxplot()

crossvalidation.png

横軸が分割サイズ(150 / 分割サイズ = 分割数)、縦軸が算出された誤り率です。
一つ抜き法でもっともばらつきが少なくなりましたが、分割サイズが小さいとその分評価をする回数も多くなってしまいます。

ブートストラップ法

bootstrap <- function(data) {
  num_bootstrap_samples <- 1024
  base_sample <- data %>%
    split_columns

  bias_array <- sapply(seq(num_bootstrap_samples), function(bootstrap_index_) {
    bootstrap_sample <- data %>%
      .[sample(1:nrow(.), replace = TRUE),] %>%
      split_columns
    classifier_error(bootstrap_sample, bootstrap_sample) - classifier_error(bootstrap_sample, base_sample)
  })
  return(classifier_error(base_sample, base_sample) - mean(bias_array))
}

sampled_iris <- iris %>% sample_all
bootstrap_result <- bootstrap(sampled_iris) 
p <- ggplot(bootstrap_result, aes(x=factor(class), y=error_rate))
p + geom_boxplot()

bootstrap_memo.png

グラフの横軸

表記
N_star, N_star $\varepsilon(N^*, N^*)$
N_star, N $\varepsilon(N^*, N)$
N, N $\varepsilon(N, N)$

式$(1)$より、N, Nの値にbiasを加えた値が求める誤り率となります。
予測される誤り率は、一つ抜き法とほぼ同じ約5%となります。

計算コスト

下表のように厳密に(もしくは、一意的に)求める手法ほど計算コストが高くなるというトレードオフがあります。

手法 パラメータ 実行回数
ホールドアウト法 分割数 1回
交差確認法 分割数 分割数回
ブートストラップ法 サンプル数 サンプル数 + 1

ニューラルネットワークのハイパーパラメータ

ニューラルネットワークのモデルを設計する際、以下のようなハイパーパラメータを試行錯誤しながら決定する必要があります。

  • 過適合を防ぐための学習の早期終了をいつするのか
  • 隠れ層のユニット数
  • 重み・バイアスの初期化方法
  • ネットワークの層の数

Hugo Larochelle先生のNeural Networkの動画では、Test DataTrain Data とは別に、上記で挙げたようなハイパーパラメータを決定するためにValidation Dataを用意するべきと述べています (4)。

まとめ

目的に応じて適切な検証方法を使いましょう。
モデルのパラメータをチューニングする用途ならホールドアウト法や最大入誤り率そのものでも十分かもしれないですが、その結果をもって「このモデルの識別率は〜〜です」と結論付けるのは危険でしょう。

脚注

  1. この記事では誤り率を、分類器がデータを誤って分類した割合として取り扱いますが、回帰問題の最小二乗誤差等の誤差を誤り率とみなしても同様の概念が当てはまります。
  2. 機械学習でよく問題となる過適合は、学習データに対しての誤り率を低く、テストデータに対する誤り率が高い状態と言い換えられます。
  3. はじパタ(参考文献[2])に表記されているブートストラップ法のbiasは値が負になってわかりにくいので符号を反転していますが、何らかの意図があって負になっているかもしれないのでここで触れておきます。
  4. 以前書いた記事でハイパーパラメータをチューニングするためにテストデータを使ってしまったのでその反省としてここに書いておきます(具体的には、テストデータを使っていつ学習を終了するかを判断していました)。モデルをより厳密に評価するためには、バリデーションデータで学習の打ち切り回数を決定した後に、その打ち切り回数を使用してテストデータをモデルに適用した結果を見るべきでしょう。

参考文献