入れ子交差検証(Nested Cross-Validation)は、モデルの性能評価とλの選定におけるバイアスを排除するための検証手法である。この方法は、外側と内側という比喩を用いて説明される。外側のクロスバリデーションと内側のクロスバリデーションの二重構造から成り立っている。具体的には、データセットを複数のサブセットに分割し、外側の反復でモデルの性能を評価し、内側の反復ループでλの選定を行う。
この手順を踏む理由は、一般的なk-foldクロスバリデーションの問題を克服するためである。k-foldクロスバリデーションでは、データセットをk個のフォールドに分割し、それぞれのフォールドを一度ずつテストデータとして使用し、残りのフォールドを訓練データとして使用する。この方法では、λの選定とモデルの性能評価が同じデータセットで行われるため、バイアスが生じる可能性がある。
λはモデルの性能評価によって選定され、モデルの性能評価にはRMSEやMAEといった統計量が使用される。入れ子交差検証では、この選定と評価が同じデータセットで行われることによるバイアスを防ぐことが目的である。
入れ子交差検証では、各フォールドで得られた性能指標を平均し、異なるλの値に対してこのプロセスを繰り返す。最も良い性能を示したλの値が最適なλとして選ばれる。λを選定した後は、k-foldクロスバリデーションと同じ手順でLassoの推定が行われるため、仮に入れ子交差検証とk-foldクロスバリデーションで選定されたλが同じであれば、Lassoの推定値も同じになる。
要するに、入れ子交差検証はλの選定過程をより厳密にすることで、より良いλの選定を行うための技法であると考えられる。
入れ子交差検証の流れ
外側ループ(反復):
フォールド1 (テストセット), フォールド2-5 (訓練セット) -> 内側ループでハイパーパラメータチューニング -> 最適モデルでテストセット評価 フォールド2 (テストセット), フォールド1,3-5 (訓練セット) -> 内側ループでハイパーパラメータチューニング -> 最適モデルでテストセット評価 ... k. フォールドk (テストセット), フォールド1-(k-1) (訓練セット) -> 内側ループでハイパーパラメータチューニング -> 最適モデルでテストセット評価
外側ループでは、データセットをk個のフォールドに分割し、それぞれのフォールドを一度ずつテストデータとして使用し、残りのフォールドを訓練データとして使用する。例えば、5フォールドのクロスバリデーションを行う場合、以下のようになる。
最初のステップでは、フォールド1がテストセットとなり、フォールド2-5が訓練セットとなる。この訓練セットに対して、内側ループでハイパーパラメータのチューニングを行い、最適なモデルを見つける。その後、この最適モデルを用いてフォールド1のテストセットを評価する。
次に、フォールド2がテストセットとなり、フォールド1と3-5が訓練セットとなる。同様に、内側ループでハイパーパラメータのチューニングを行い、最適なモデルを見つけた後に、フォールド2のテストセットを評価する。
これをk回繰り返し、各フォールドをテストセットとして評価することで、全体のモデル評価を行う。
内側ループ(反復):
内側ループでは、外側ループで選ばれた訓練セットをさらにm個のフォールドに分割し、ハイパーパラメータのチューニングを行う。このプロセスは以下の通りである。
訓練セットをm個のフォールドに分割し、各フォールドを一度ずつバリデーションデータとして使用し、残りのフォールドを訓練データとして使用する。
各ハイパーパラメータ(λ)の組み合わせについて、m回のクロスバリデーションを行い、それぞれのバリデーションセットでのモデルの性能を評価する。
すべてのハイパーパラメータ(λ)の組み合わせの中から、バリデーションセットで最も良い性能を示したハイパーパラメータ(λ)の組み合わせを選ぶ。
この内側ループで選ばれた最適なハイパーパラメータ(λ)を用いて、外側ループの訓練セット全体で最終モデルを訓練し、そのモデルを外側ループのテストセットで評価する。
Rでの実装
# データの読み込みと前処理
library(haven)
library(tidyr)
library(dplyr)
auto <- haven::read_dta("http://www.stata-press.com/data/r9/auto.dta")
auto <- auto %>% drop_na() # 全カラムに対してNAがない行を抽出
auto$foreign <- as.integer(auto$foreign)
n <- nrow(auto)
price <- auto$price
X <- auto[, c("mpg", "rep78", "headroom", "trunk", "weight", "length",
"turn", "displacement", "gear_ratio", "foreign")]
X <- as.matrix(X)
並列処理の設定
library(doParallel) num_cores <- detectCores() - 2 # システム全体のコア数から2つを引いた数を使用 cl <- makeCluster(num_cores) registerDoParallel(cl)
入れ子交差検証の実行
library(glmnet) library(nestedcv) set.seed(123) # 再現性のためのシード設定 result <- nestcv.glmnet( y = price, x = X, family = "gaussian", alphaSet = 1, n_outer_folds = 5, # 外側ループのフォールド数 n_inner_folds = 5, # 内側ループのフォールド数 parallel = TRUE # 並列処理を有効にする ) # 並列処理の停止 stopCluster(cl)
結果の表示
print(result) print(result[["final_coef"]])
Nested cross-validation with glmnet
No filter
Final parameters:
lambda alpha
128.5 1.0
Final coefficients:
(Intercept) foreign headroom rep78 displacement weight
-1831.698 2825.830 -394.122 123.420 13.211 1.736
Result:
RMSE Rsquared MAE
2149.8921 0.4552 1666.7084
安定性のシミュレーション
# 必要なライブラリの読み込み
library(haven)
library(tidyr)
library(glmnet)
library(nestedcv)
library(dplyr)
library(doParallel)
# データの読み込みと前処理
auto <- haven::read_dta("http://www.stata-press.com/data/r9/auto.dta")
auto <- auto %>% drop_na() # 全カラムに対してNAがない行を抽出
auto$foreign <- as.integer(auto$foreign)
price <- auto$price
X <- auto[, c("mpg", "rep78", "headroom", "trunk", "weight", "length",
"turn", "displacement", "gear_ratio", "foreign")]
X <- as.matrix(X)
# 並列処理の設定
num_cores <- detectCores() - 2 # システム全体のコア数から2つを引いた数を使用
cl <- makeCluster(num_cores)
registerDoParallel(cl)
# 結果を格納するリストを初期化
selected_variables <- list()
coefficients <- list()
jaccard_indices <- numeric()
# 50回のシミュレーション
for (i in 1:50) {
set.seed(i) # 異なる乱数シードを設定
# ネステッドクロスバリデーションの実行
result <- nestcv.glmnet(
y = price,
x = X,
family = "gaussian",
alphaSet = 1,
n_outer_folds = 5, # 外側ループのフォールド数
n_inner_folds = 5, # 内側ループのフォールド数
parallel = TRUE # 並列処理を有効にする
)
# 最終モデルの係数を取得
final_coef <- result$final_fit$glmnet.fit$beta[, result$final_fit$glmnet.fit$lambda == result$final_fit$lambda.min]
non_zero_coefs <- final_coef[final_coef != 0]
# 選ばれた変数とその係数を保存
selected_variables[[i]] <- names(non_zero_coefs)
coefficients[[i]] <- non_zero_coefs
}
# 並行処理の停止
stopCluster(cl)
# 選ばれた変数の回数をカウント
variable_counts <- table(unlist(selected_variables))
# 選ばれた変数の推定値の平均と標準偏差を計算
variable_stats <- lapply(names(variable_counts), function(var) {
coefs <- unlist(lapply(coefficients, function(coef) if(var %in% names(coef)) coef[var] else NA))
coefs <- coefs[!is.na(coefs)]
mean_coef <- mean(coefs, na.rm = TRUE)
sd_coef <- sd(coefs, na.rm = TRUE)
data.frame(variable = var, mean = mean_coef, sd = sd_coef)
})
variable_stats <- do.call(rbind, variable_stats)
# Jaccard係数の計算
for (i in 1:(length(selected_variables) - 1)) {
for (j in (i + 1):length(selected_variables)) {
set_i <- selected_variables[[i]]
set_j <- selected_variables[[j]]
if (length(set_i) > 0 && length(set_j) > 0) {
jaccard_index <- length(intersect(set_i, set_j)) / length(union(set_i, set_j))
jaccard_indices <- c(jaccard_indices, jaccard_index)
}
}
}
average_jaccard <- mean(jaccard_indices, na.rm = TRUE)
変数が選ばれた回数
variable_counts_df <- as.data.frame(variable_counts)
colnames(variable_counts_df) <- c("Variable", "Count")
print(variable_counts_df)
Variable Count 1 displacement 50 2 foreign 50 3 gear_ratio 28 4 headroom 50 5 length 17 6 mpg 12 7 rep78 50 8 trunk 12 9 turn 40 10 weight 50
各変数の推定値の平均と標準偏
variable_stats_df <- as.data.frame(variable_stats) print(variable_stats_df)
variable mean sd 1 displacement 13.167347 0.6169471 2 foreign 2983.161668 193.8486265 3 gear_ratio -164.675853 101.6676337 4 headroom -462.243290 78.4175351 5 length -38.720636 18.9774730 6 mpg -9.721489 5.2209098 7 rep78 132.597007 22.3809795 8 trunk 35.476713 15.3503600 9 turn -61.485420 31.5922149 10 weight 2.397636 0.8185932
平均Jaccard係数
cat("Average Jaccard Index: ", average_jaccard)
Average Jaccard Index: 0.7713382
変数選択のパターンは、選ばれている変数はそのまま、選ばれにくかった変数も選ばれる回数が増加している。 そのため、Jaccard係数も上がっているのだろう。