library(rpart)
library(rpart.plot)
library(caret)
library(randomForest)
library(ggplot2)
library(reshape2)

1. Data Preprocessing

1-A. Training set 불러오기 및 범주 분포 확인

training set의 크기가 매우 크기 때문에 첫 2,000개 데이터만 사용한다. 이 2,000개 안에 10개의 범주가 균일하게 포함되어 있는지 확인해본다.

# 데이터 불러오기
setwd("C:/Users/kyeeu/OneDrive/Desktop")
train_full <- read.csv("fashion-mnist_train.csv")
test_data  <- read.csv("fashion-mnist_test.csv")

# 첫 2,000개만 사용
train_data <- train_full[1:2000, ]

# label을 factor로 변환
train_data$label <- factor(train_data$label)
test_data$label  <- factor(test_data$label)

# target 범주 분포 확인
label_names <- c("T-shirt", "Trouser", "Pullover", "Dress", "Coat",
                 "Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot")

table_result <- table(train_data$label)
names(table_result) <- label_names

print(table_result)
##    T-shirt    Trouser   Pullover      Dress       Coat     Sandal      Shirt 
##        214        194        210        211        218        178        207 
##    Sneaker        Bag Ankle boot 
##        199        184        185
barplot(table_result,
        main = "Training Set (2,000개) Target 범주 분포",
        ylab = "개수",
        las = 2,
        col = "steelblue",
        cex.names = 0.8)

barplot을 보면 10개 범주가 각각 약 175~220개로 비교적 균등하게 분포하고 있음을 확인할 수 있다. Coat와 T-shirt가 약 220개로 가장 많고, Sandal이 약 178개로 가장 적지만, 범주 간 차이가 크지 않아 class imbalance로 인한 편향 문제는 없을 것으로 판단된다.


1-B. 의류 이미지 출력 함수 작성

dataframe의 행 번호를 입력받아 해당 이미지를 출력하는 함수를 작성하고, 각 범주별로 이미지를 하나씩 출력해보자.

# 이미지 출력 함수
plot_image <- function(df, row_idx) {
  pixel_vals <- as.numeric(df[row_idx, 2:785])
  img_matrix <- matrix(pixel_vals, nrow = 28, ncol = 28, byrow = TRUE)
  img_matrix <- img_matrix[28:1, ]  # 상하 반전 (R의 image() 특성상)
  
  label_val  <- as.numeric(as.character(df$label[row_idx]))
  title_name <- label_names[label_val + 1]
  
  image(t(img_matrix),
        col = gray(seq(0, 1, length.out = 256)),
        axes = FALSE,
        main = paste0("label: ", label_val, " (", title_name, ")"))
}

# 10개 범주 각 1개씩 출력
# 각 범주의 첫 번째 등장 행 번호 찾기
sample_rows <- sapply(0:9, function(x) which(train_data$label == x)[1])

par(mfrow = c(2, 5), mar = c(1, 1, 2, 1))
for (i in sample_rows) {
  plot_image(train_data, i)
}

par(mfrow = c(1, 1))

10개 범주의 이미지를 출력한 결과, 범주별로 명확하게 형태의 차이가 있음을 확인할 수 있다.

Trouser, Bag, Ankle boot는 비교적 확실한 형태를 가지지만, 그에 반면 T-shirt, Shirt, Pullover, Coat는 전체적인 형태가 유사하여 육안으로도 구분이 어렵다.

이러한 시각적 유사성은 이후 분류 모델 성능에 직접적인 영향을 미칠 것으로 예상된다.


1-C. 분산 기반 픽셀 선택 (200개)

배경 부분처럼 대부분의 이미지에서 같은 값을 가지는 픽셀은 분류에 크게 영향을 미치지 않는다. 따라서 각 픽셀에 대해 training set 2,000개 값의 분산을 계산하고, 분산이 큰 상위 200개 픽셀만 선택한다.

# pixel 열만 추출 (label 제외)
pixel_cols <- grep("^pixel", names(train_data), value = TRUE)

# 각 픽셀의 분산 계산
pixel_var <- apply(train_data[, pixel_cols], 2, var)

# 분산 상위 200개 픽셀 선택
top200_pixels <- names(sort(pixel_var, decreasing = TRUE))[1:200]

# 어떤 위치의 픽셀인지 이미지로 시각화
selected_idx <- as.numeric(sub("pixel", "", top200_pixels))

pixel_map <- rep(0, 784)
pixel_map[selected_idx] <- 1
img_map <- matrix(pixel_map, nrow = 28, ncol = 28, byrow = TRUE)
img_map <- img_map[28:1, ]

image(t(img_map),
      col = c("white", "steelblue"),
      axes = FALSE,
      main = "선택된 200개 픽셀 위치")

선택된 200개 픽셀의 위치를 시각화한 결과, 이미지의 테두리(외곽) 부분에 집중적으로 분포하고 있음을 확인할 수 있다. 이는 의류 이미지에서 배경(검정)과 사물의 경계 부분에서 범주 간 픽셀 값 차이가 크게 나타나기 때문이다.

반면 이미지 중앙부는 대부분의 범주에서 밝은 픽셀 값을 공유하여 분산이 낮고, 외곽은 배경과 의류 실루엣의 경계가 범주마다 달라 분산이 높게 나타나는 것으로 보인다.


1-D. Test set에도 동일한 200개 픽셀 적용

# training set: 선택된 200개 픽셀 + label
train_sel <- train_data[, c("label", top200_pixels)]

# test set: 동일한 200개 픽셀 + label
test_sel  <- test_data[, c("label", top200_pixels)]

dim(train_sel)
## [1] 2000  201
dim(test_sel)
## [1] 10000   201

train_sel은 2,000행 × 201열(label 1개 + 선택된 픽셀 200개), test_sel은 10,000행 × 201열로 구성되었음을 확인할 수 있다.

train과 test 모두 동일한 200개 픽셀 열을 사용하므로 이후 모델 학습 및 예측에 일관성이 보장된다고 할 수 있다.


2. Decision Tree 분류 모델

2-A. rpart() default 옵션으로 Tree 생성 및 Pruning

rpart() 함수의 default 옵션으로 tree를 만든 후, cross validation 결과를 바탕으로 pruning을 수행한다.

# default 옵션으로 tree 생성 (cp=0으로 충분히 큰 tree 생성 후 pruning)
set.seed(123)
ct <- rpart(label ~ ., data = train_sel, method = "class", control = list(cp = 0))

# CV 결과 확인
printcp(ct)
## 
## Classification tree:
## rpart(formula = label ~ ., data = train_sel, method = "class", 
##     control = list(cp = 0))
## 
## Variables actually used in tree construction:
##  [1] pixel101 pixel124 pixel125 pixel153 pixel183 pixel190 pixel207 pixel245
##  [9] pixel247 pixel274 pixel275 pixel287 pixel289 pixel301 pixel304 pixel317
## [17] pixel330 pixel344 pixel360 pixel371 pixel372 pixel373 pixel388 pixel399
## [25] pixel413 pixel416 pixel42  pixel427 pixel43  pixel442 pixel444 pixel46 
## [33] pixel47  pixel471 pixel482 pixel484 pixel499 pixel500 pixel539 pixel582
## [41] pixel595 pixel622 pixel631 pixel660 pixel661 pixel683 pixel686 pixel690
## [49] pixel70  pixel713 pixel715 pixel717 pixel719 pixel738 pixel742 pixel744
## [57] pixel746 pixel747 pixel96 
## 
## Root node error: 1782/2000 = 0.891
## 
## n= 2000 
## 
##            CP nsplit rel error  xerror      xstd
## 1  0.10998878      0   1.00000 1.02245 0.0071460
## 2  0.10101010      1   0.89001 0.92256 0.0095996
## 3  0.08641975      2   0.78900 0.83221 0.0109874
## 4  0.08249158      3   0.70258 0.72391 0.0120088
## 5  0.07968575      4   0.62009 0.64029 0.0124227
## 6  0.04152637      5   0.54040 0.54545 0.0125432
## 7  0.03423120      6   0.49888 0.52357 0.0125199
## 8  0.02413019      8   0.43042 0.44669 0.0122842
## 9  0.01907969      9   0.40629 0.42929 0.0121967
## 10 0.01487093     10   0.38721 0.42536 0.0121751
## 11 0.00841751     12   0.35746 0.39955 0.0120164
## 12 0.00785634     13   0.34905 0.39787 0.0120050
## 13 0.00617284     16   0.32548 0.38664 0.0119258
## 14 0.00505051     18   0.31313 0.37318 0.0118230
## 15 0.00448934     20   0.30303 0.37093 0.0118051
## 16 0.00392817     22   0.29405 0.36644 0.0117684
## 17 0.00374111     26   0.27834 0.36364 0.0117450
## 18 0.00364759     29   0.26712 0.36195 0.0117308
## 19 0.00336700     31   0.25982 0.36195 0.0117308
## 20 0.00291807     32   0.25645 0.36308 0.0117403
## 21 0.00280584     40   0.22840 0.36027 0.0117164
## 22 0.00252525     43   0.21998 0.35746 0.0116922
## 23 0.00224467     47   0.20988 0.34848 0.0116120
## 24 0.00196409     50   0.20314 0.34905 0.0116171
## 25 0.00168350     52   0.19921 0.34456 0.0115756
## 26 0.00140292     58   0.18911 0.34624 0.0115913
## 27 0.00112233     60   0.18631 0.34624 0.0115913
## 28 0.00056117     68   0.17733 0.35578 0.0116774
## 29 0.00000000     76   0.17284 0.36476 0.0117544
plotcp(ct)

  • printcp() 결과, cp = 0으로 생성된 full tree는 총 76번의 split을 수행하였으며 57개의 픽셀 변수가 실제로 사용되었다. Root node error는 0.891로, 아무것도 예측하지 않을 때의 오류율이다.

  • plotcp() 그래프를 보면 xerror(CV error)는 split 수가 증가함에 따라 빠르게 감소하다가 약 20~30번째 split 이후 점선(1-SE 기준) 아래로 내려가며 수렴하는 양상을 보인다. xerror가 최소인 지점은 nsplit = 68 (cp ≈ 0.000561)이며, 이 cp 값으로 pruning을 수행한다.

best_cp <- ct$cptable[which.min(ct$cptable[, "xerror"]), "CP"]
best_ct <- prune(ct, cp = best_cp)

rpart.plot(best_ct,
           type        = 2,
           extra       = 104,
           box.palette = "RdYlGn",
           legend.x    = 0,
           legend.y    = 1,
           legend.cex  = 2,
           main        = "Pruned Decision Tree")

pred_ct <- predict(best_ct, newdata = test_sel, type = "class")
acc_ct  <- mean(pred_ct == test_sel$label)
cat("Pruned Tree Test Accuracy:", round(acc_ct, 4), "\n")
## Pruned Tree Test Accuracy: 0.6951

rpart()의 default 옵션으로 생성한 full tree에 cost complexity pruning을 적용하였다.

CV error가 최소인 cp = 0.000561 (nsplit = 68)을 기준으로 pruning한 결과, 68번의 split을 가진 subtree가 최종 모델로 선택되었다.

시각화에서 각 노드의 색상은 해당 노드의 예측 범주를 나타내며, 빨강 계열(0~2)에서 초록 계열(7~9)로 갈수록 label 값이 높은 범주에 대응된다. 색상의 진하기는 해당 node의 purity를 반영하여, 특정 범주가 지배적일수록 더 진한 색으로 표현되도록 하였다.

Pruned tree의 test set 정확도는 69.51%이다. Tree 기반 모델은 splitting rule을 시각적으로 확인할 수 있어 해석이 직관적이라는 장점이 있으나, 단일 tree는 variance가 크고 예측 정확도가 상대적으로 낮다는 한계가 있다.


2-B. Bagging 모델

randomForest() 함수에서 mtry = p (전체 feature 수)로 설정하면 bagging과 같다. mtry를 제외한 나머지 옵션은 모두 default 값을 사용한다.

p <- ncol(train_sel) - 1  # feature 수 (label 제외)

set.seed(123)
bag_model <- randomForest(label ~ ., data = train_sel, mtry = p)

# test set 예측 및 정확도
pred_bag <- predict(bag_model, newdata = test_sel, type = "class")
acc_bag  <- mean(pred_bag == test_sel$label)
cat("Bagging Test Accuracy:", round(acc_bag, 4), "\n")
## Bagging Test Accuracy: 0.7861
cat("Decision Tree 대비 향상:", round((acc_bag - acc_ct) * 100, 2), "%p\n")
## Decision Tree 대비 향상: 9.1 %p

Bagging의 test set 정확도는 78.61%로, 단일 decision tree(69.51%)에 비해 9.1%p 향상되었다.

이는 bagging이 bootstrap sampling을 통해 생성한 다수의 tree를 앙상블함으로써, 단일 tree의 높은 variance 문제를 효과적으로 줄였기 때문이라고 볼 수 있다.


2-C. mtry에 대한 Cross Validation (Random Forest)

caret 패키지의 train() 함수를 사용하여 mtry 값에 대한 CV를 진행한다. 계산 시간을 줄이기 위해 ntree = 100, fold 수는 5로 설정하였다.

set.seed(123)
rf_cv <- train(
  label ~ .,
  data       = train_sel,
  method     = "rf",
  trControl  = trainControl(method = "cv", number = 5),
  tuneLength = 5,
  ntree      = 100
)
print(rf_cv)
## Random Forest 
## 
## 2000 samples
##  200 predictor
##   10 classes: '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' 
## 
## No pre-processing
## Resampling: Cross-Validated (5 fold) 
## Summary of sample sizes: 1600, 1599, 1600, 1601, 1600 
## Resampling results across tuning parameters:
## 
##   mtry  Accuracy   Kappa    
##     2   0.7565069  0.7292190
##    51   0.7760057  0.7509625
##   101   0.7770182  0.7521161
##   150   0.7805120  0.7560017
##   200   0.7690194  0.7432283
## 
## Accuracy was used to select the optimal model using the largest value.
## The final value used for the model was mtry = 150.
best_mtry <- rf_cv$bestTune$mtry
cat("Best mtry:", best_mtry, "\n")
## Best mtry: 150

5-fold CV 결과, mtry 후보 {2, 51, 101, 150, 200} 중 mtry = 150일 때 CV Accuracy가 0.7805로 가장 높아 최적값으로 선택되었다. mtry = 2처럼 매우 작은 값에서는 개별 tree의 성능이 낮아 accuracy가 떨어지고, mtry = 200(= p, bagging)에서도 tree 간 상관관계 증가로 mtry = 150보다 낮은 성능을 보였다.

mtry에 따른 CV Accuracy 변화를 시각화하면 아래와 같다.

ggplot(rf_cv) +
  labs(title = "mtry에 따른 CV Accuracy", x = "mtry", y = "Accuracy (CV)") +
  theme_bw()

그래프를 보면 mtry가 증가함에 따라 CV Accuracy가 빠르게 상승하다가 mtry = 150에서 최고점(0.7805)을 기록한 후 mtry = 200(bagging)에서 다시 감소하는 역U자 형태를 보인다.

이는 mtry가 너무 작으면 각 tree가 제한된 feature만 고려하여 예측력이 낮아지고, mtry가 너무 크면 tree 간 상관관계가 높아져 앙상블 효과가 감소하기 때문이다.

수업에서 classification의 경우 일반적으로 sqrt(p) ≈ 14를 권장하지만, 본 데이터에서는 mtry = 150이 최적으로 선택되어 데이터에 따라 최적 mtry가 달라질 수 있음을 확인하였다.


2-D. Bagging vs Random Forest - OOB Error Rate 비교

Bagging model과 Random Forest model에서 tree 수 증가에 따른 OOB classification error rate 변화를 하나의 그래프에 그려 비교한다.

set.seed(123)
bag_model2 <- randomForest(label ~ ., data = train_sel, mtry = p, ntree = 300)

set.seed(123)
rf_model   <- randomForest(label ~ ., data = train_sel, mtry = best_mtry, ntree = 300)

# OOB error rate 추출
oob_bag <- bag_model2$err.rate[, "OOB"]
oob_rf  <- rf_model$err.rate[, "OOB"]

df_oob <- data.frame(
  Trees = 1:300,
  Bagging       = oob_bag,
  RandomForest  = oob_rf
)

df_oob_long <- reshape2::melt(df_oob, id.vars = "Trees",
                               variable.name = "Model",
                               value.name    = "OOB_Error")

ggplot(df_oob_long, aes(x = Trees, y = OOB_Error, color = Model)) +
  geom_line(size = 0.8) +
  scale_color_manual(values = c("Bagging" = "darkred", "RandomForest" = "darkblue")) +
  labs(title = "Tree 수에 따른 OOB Classification Error Rate 비교",
       x = "# Trees", y = "OOB Error Rate") +
  theme_bw()

두 모델 모두 tree 수가 증가함에 따라 OOB error rate가 빠르게 감소하다가 약 100개 전후부터 수렴하는 양상을 보인다. 초반에는 Random Forest의 error rate가 Bagging보다 높게 나타나는데, 이는 mtry = 150으로 설정된 이번 실험에서 두 모델의 mtry 차이가 크지 않아 초반 변동성이 비슷하게 나타났기 때문이다.

300개의 tree 기준으로 수렴한 최종 OOB error rate는 두 모델이 거의 유사한 수준으로, mtry = 150이 bagging(mtry = 200)에 근접한 값이기 때문으로 해석할 수 있다.


2-E. Random Forest 예측 정확도

pred_rf <- predict(rf_model, newdata = test_sel, type = "class")
acc_rf  <- mean(pred_rf == test_sel$label)

cat("Random Forest Test Accuracy:", round(acc_rf, 4), "\n")
## Random Forest Test Accuracy: 0.7865
cat("Bagging 대비 향상:", round((acc_rf - acc_bag) * 100, 2), "%p\n")
## Bagging 대비 향상: 0.04 %p

Random Forest의 test set 정확도는 78.65%로, Bagging(78.61%)에 비해 0.04%p 향상에 그쳤다. 이는 CV에서 선택된 최적 mtry = 150이 전체 feature 수 p = 200에 근접한 값으로, 두 모델 간 실질적인 차이가 크지 않았기 때문으로 보인다.


2-F. Confusion Matrix 분석 및 잘못 분류된 이미지 출력

# Confusion Matrix
cm <- confusionMatrix(pred_rf, test_sel$label)
print(cm)
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction   0   1   2   3   4   5   6   7   8   9
##          0 717   1   7  54   6   1 162   0   0   0
##          1  11 935   3  28   6   1  11   0   4   0
##          2  34  18 756  18 176   0 156   0  26   0
##          3  59  34   8 835  46   6  42   0   4   0
##          4  10   6 132  29 691   0 122   0   7   0
##          5   3   1   0   0   0 843   0 109   5  46
##          6 135   4  76  32  63   9 473   0  21   7
##          7   0   0   0   0   0  79   0 783   0  39
##          8  31   1  18   4  12  20  34   1 932   8
##          9   0   0   0   0   0  41   0 107   1 900
## 
## Overall Statistics
##                                           
##                Accuracy : 0.7865          
##                  95% CI : (0.7783, 0.7945)
##     No Information Rate : 0.1             
##     P-Value [Acc > NIR] : < 2.2e-16       
##                                           
##                   Kappa : 0.7628          
##                                           
##  Mcnemar's Test P-Value : NA              
## 
## Statistics by Class:
## 
##                      Class: 0 Class: 1 Class: 2 Class: 3 Class: 4 Class: 5
## Sensitivity            0.7170   0.9350   0.7560   0.8350   0.6910   0.8430
## Specificity            0.9743   0.9929   0.9524   0.9779   0.9660   0.9818
## Pos Pred Value         0.7563   0.9359   0.6385   0.8075   0.6931   0.8371
## Neg Pred Value         0.9687   0.9928   0.9723   0.9816   0.9657   0.9825
## Prevalence             0.1000   0.1000   0.1000   0.1000   0.1000   0.1000
## Detection Rate         0.0717   0.0935   0.0756   0.0835   0.0691   0.0843
## Detection Prevalence   0.0948   0.0999   0.1184   0.1034   0.0997   0.1007
## Balanced Accuracy      0.8457   0.9639   0.8542   0.9064   0.8285   0.9124
##                      Class: 6 Class: 7 Class: 8 Class: 9
## Sensitivity            0.4730   0.7830   0.9320   0.9000
## Specificity            0.9614   0.9869   0.9857   0.9834
## Pos Pred Value         0.5768   0.8690   0.8784   0.8580
## Neg Pred Value         0.9426   0.9762   0.9924   0.9888
## Prevalence             0.1000   0.1000   0.1000   0.1000
## Detection Rate         0.0473   0.0783   0.0932   0.0900
## Detection Prevalence   0.0820   0.0901   0.1061   0.1049
## Balanced Accuracy      0.7172   0.8849   0.9588   0.9417

전체 test set 정확도는 78.65%로 나타났다.

  • 범주별 Sensitivity를 살펴보면 Trouser(0.935), Bag(0.932), Ankle boot(0.900)은 비교적 잘 분류되었다.
  • 반면 Shirt(0.473)가 가장 낮은 sensitivity를 기록하여 분류가 가장 어려운 범주임을 알 수 있었다.

Confusion matrix를 보면 Shirt(6)가 T-shirt(0)로 135개, Pullover(2)로 76개, Coat(4)로 63개 잘못 분류된 것을 확인할 수 있었는데, 앞서 1-B에서 이미지를 출력해봤을 때도 느꼈듯이 상의류끼리는 픽셀 패턴이 비슷해서 모델도 구분하기 어려웠던 것 같다.

반면 Trouser나 Bag처럼 형태가 뚜렷한 범주들은 혼동 없이 잘 분류된 것으로 보인다.

# Confusion matrix 시각화
cm_table <- as.data.frame(cm$table)
colnames(cm_table) <- c("Prediction", "Reference", "Freq")
cm_table$Prediction <- factor(cm_table$Prediction, labels = label_names)
cm_table$Reference  <- factor(cm_table$Reference,  labels = label_names)

ggplot(cm_table, aes(x = Reference, y = Prediction, fill = Freq)) +
  geom_tile(color = "white") +
  geom_text(aes(label = Freq), size = 3) +
  scale_fill_gradient(low = "white", high = "steelblue") +
  labs(title = "Confusion Matrix (Random Forest)",
       x = "실제 범주", y = "예측 범주") +
  theme_bw() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Confusion matrix heatmap에서 대각선 셀이 진할수록 해당 범주가 정확하게 분류된 것이다.

Trouser, Bag, Sandal, Ankle boot는 대각선이 뚜렷하게 진하고 off-diagonal 값이 작아 분류가 잘 된 것을 시각적으로 확인할 수 있었다.

반면 Shirt 행을 보면 대각선(473)보다 T-shirt(135), Pullover(76), Coat(63) 쪽 셀도 상당히 밝게 나타나, 실제 Shirt를 다른 상의류로 잘못 분류하는 경우가 많았음을 알 수 있다.

전반적으로 하의류·신발·가방류는 분류가 쉬운 반면 상의류 간 혼동이 주요 오분류 패턴임을 한눈에 파악할 수 있었다.


그럼 이제, Confusion matrix에서 범주별 Sensitivity를 비교하여 분류가 가장 어려운 범주를 찾고, 해당 범주에서 잘못 분류된 이미지를 직접 출력하여 오분류 원인을 살펴보겠다.

# 각 범주별 sensitivity (정분류율)
sensitivity_by_class <- cm$byClass[, "Sensitivity"]
worst_class_idx <- which.min(sensitivity_by_class)
worst_class_name <- label_names[worst_class_idx]

cat("분류가 가장 어려운 범주:", worst_class_name, 
    "(Sensitivity:", round(sensitivity_by_class[worst_class_idx], 4), ")\n")
## 분류가 가장 어려운 범주: Shirt (Sensitivity: 0.473 )
# 해당 범주가 어떤 범주로 혼동되는지 확인
worst_label <- worst_class_idx - 1  # label은 0-indexed
cm_matrix   <- cm$table
confused_with_idx <- which.max(cm_matrix[, worst_class_idx][-worst_class_idx])
# 잘못 분류된 이미지 출력 (worst class 기준)
# test_sel의 인덱스와 test_data의 인덱스가 일치함을 이용
misclassified_idx <- which(pred_rf != test_sel$label & test_sel$label == worst_label)

# 최대 6개 출력
n_show <- min(6, length(misclassified_idx))
cat("잘못 분류된", worst_class_name, "이미지 (최대 6개):\n")
## 잘못 분류된 Shirt 이미지 (최대 6개):
par(mfrow = c(2, 3), mar = c(1, 1, 3, 1))
for (i in 1:n_show) {
  idx <- misclassified_idx[i]
  pixel_vals <- as.numeric(test_data[idx, 2:785])
  img_matrix <- matrix(pixel_vals, nrow = 28, ncol = 28, byrow = TRUE)
  img_matrix <- img_matrix[28:1, ]
  pred_name  <- label_names[as.numeric(as.character(pred_rf[idx])) + 1]
  image(t(img_matrix),
        col  = gray(seq(0, 1, length.out = 256)),
        axes = FALSE,
        main = paste0("실제: ", worst_class_name, "\n예측: ", pred_name))
}

par(mfrow = c(1, 1))

분류 성능이 가장 낮은 범주는 Shirt(Sensitivity: 0.473)로 확인되었다.

잘못 분류된 Shirt 이미지 6개를 출력한 결과, Pullover(3개), Coat(2개), Bag(1개)으로 오분류된 사례들을 확인할 수 있었다.

이미지를 직접 보면 사람이 봐도 헷갈릴 만한 것들이 꽤 있었던 것 같은데, 특히 체크무늬 셔츠나 긴팔 셔츠는 Coat나 Pullover와 외형이 매우 유사하기 때문에 28×28의 저해상도 픽셀에서는 모델이 구분하기 어려웠던 것으로 보인다. Bag으로 오분류된 사례는 민소매 형태의 셔츠로, 어깨 부분의 픽셀 패턴이 가방 손잡이와 유사하게 나타났기 때문으로 해석할 수 있다.


❖ 자유 분석: mtry 값에 따른 OOB Error Rate 변화 분석

분석 질문 정의

“Bagging과 Random Forest의 OOB error가 결국 비슷하게 수렴했는데, 그러면 굳이 Random Forest를 쓰는 의미가 있는 건가?”

이번 실험에서 CV로 선택된 최적 mtry = 150은 전체 feature 수 p = 200에 근접한 값이었다. 그 결과 2-D에서 확인했듯이 두 모델의 OOB error rate가 거의 유사하게 수렴하였고, test set 정확도 차이도 0.04%p에 불과했다.

이에 mtry 값이 두 모델의 성능 차이에 실질적인 영향을 미치는지 확인하기 위해, mtry를 1부터 200까지 변화시키면서 OOB error rate가 어떻게 달라지는지 직접 분석해보고자 한다.

분석 방법

mtry를 1, 10, 20, …, 200까지 변화시키면서 각각 Random Forest 모델을 생성하고, 300개의 tree 기준 OOB error rate를 기록하여 시각화한다.

계산 시간을 고려해 mtry 값을 10 간격으로 설정하였으며, sqrt(p)와 bagging(mtry = p) 지점도 함께 표시하여 비교한다.

sqrt_p <- floor(sqrt(p))
cat("p =", p, "/ sqrt(p) =", sqrt_p, "\n")
## p = 200 / sqrt(p) = 14
mtry_candidates <- sort(unique(c(seq(10, p, by = 10), sqrt_p, 1)))

oob_results <- data.frame(mtry = mtry_candidates, OOB_Error = NA)

for (i in seq_along(mtry_candidates)) {
  set.seed(123)
  rf_tmp <- randomForest(label ~ ., data = train_sel,
                         mtry  = mtry_candidates[i],
                         ntree = 300)
  oob_results$OOB_Error[i] <- rf_tmp$err.rate[300, "OOB"]
}

best_idx      <- which.min(oob_results$OOB_Error)
best_mtry_val <- oob_results$mtry[best_idx]
best_oob      <- oob_results$OOB_Error[best_idx]

cat("최적 mtry:", best_mtry_val, "/ OOB Error:", round(best_oob, 4), "\n")
## 최적 mtry: 110 / OOB Error: 0.2035

분석 결과

ggplot(oob_results, aes(x = mtry, y = OOB_Error)) +
  geom_line(color = "steelblue", size = 0.9) +
  geom_point(color = "steelblue", size = 2) +
  geom_vline(xintercept = sqrt_p,
             linetype = "dashed", color = "darkgreen", size = 0.8) +
  geom_vline(xintercept = best_mtry_val,
             linetype = "dashed", color = "darkred", size = 0.8) +
  annotate("text", x = sqrt_p + 3, y = max(oob_results$OOB_Error) * 0.98,
           label = paste0("sqrt(p) = ", sqrt_p),
           color = "darkgreen", size = 3.5, hjust = 0) +
  annotate("text", x = best_mtry_val + 3, y = max(oob_results$OOB_Error) * 0.95,
           label = paste0("best mtry = ", best_mtry_val),
           color = "darkred", size = 3.5, hjust = 0) +
  labs(title = "mtry 값에 따른 OOB Classification Error Rate 변화",
       subtitle = paste0("p = ", p, " (전체 feature 수)"),
       x = "mtry", y = "OOB Error Rate (300 trees)") +
  theme_bw()

결과 해석

그래프를 보면 mtry = 1일 때 OOB error rate가 약 0.275로 가장 높고, mtry가 증가하면서 급격히 감소하다가 sqrt(p) = 14 근처에서 한 번 안정되는 모습을 보인다. 이후 완만하게 감소하여 mtry = 110에서 최솟값(0.2035)을 기록한 뒤 다시 완만하게 증가하는 패턴을 보였다.

수업에서 배운 권장값 sqrt(p) = 14에서도 OOB error rate가 낮게 나타나긴 했지만, 이 데이터에서는 훨씬 큰 값인 mtry = 110이 최적으로 나타났다. sqrt(p) 권장값은 일반적인 가이드라인일 뿐, 실제 최적 mtry는 데이터의 특성에 따라 크게 달라질 수 있음을 확인할 수 있었다. 또한 mtry = 200(bagging)에서도 error rate가 크게 높아지지 않아, 처음의 질문으로 돌아가면 이 데이터에서는 Random Forest와 Bagging의 성능 차이가 크지 않았던 것은 최적 mtry 자체가 p에 가까운 값이었기 때문임을 알 수 있었다.