본문 바로가기
R

10강. CNN 모델을 사용한 다중 분류 모델 구현하기

by 슬통이 2021. 11. 22.
반응형

이전 포스트에서는 로지스틱 회귀모형을 사용하여 분류를 하는 문제를 다루었다. 이번 시간에는 좀 더 복잡한 분류 문제를 CNN(Convolutioinal Neural Network) 모델을 사용하여 해결하는 방법에 대하여 학습해보자.

예제 데이터

딥러닝에서 가장 유명한 데이터는 바로 MNIST 데이터이다. 딥러닝을 시작할 때 가장 많이 등장하는 signiture 느낌의 데이터 셋이므로 꼭 알고 있어야하는 데이터셋 중 하나이다. 캐글에서도 이 데이터를 사용하여 튜토리얼 대회를 열어놓았다. 본 포스팅은 캐글 튜토리얼 대회의 자료를 그대로 사용한다. MNIST 데이터는 손글씨 숫자 이미지 데이터라고 생각하면 된다. 데이터 셋에는 무작위로 0에서부터 9까지의 숫자가 손글씨 이미지 형태로 저장되어있다.

 

대회에서는 이미지와 레이블 정보가 같이 들어있는 학습용 데이터 train.csv와 이미지 정보만이 들어있는 실험용 데이터 test.csv를 같이 제공한다. 우리의 목표는 테스트 데이터의 각 이미지 id별 레이블을 추측하여 sample_submission.csv에 적어 제출하는 것이다.

library(torch)
library(tidyverse)
library(knitr)

file_path <- "../kaggle/input/digit-recognizer"
files <- list.files(file_path)
files
## [1] "sample_submission.csv" "test.csv"              "train.csv"

데이터 불러오기 & 구조 살펴보기

read_csv() 함수를 사용하여 데이터를 불러오고, 각 칼럼의 이름을 janitor 패키지를 사용하여 정리한다.

train <- read_csv(file.path(file_path, "train.csv")) %>% 
  janitor::clean_names()
test <- read_csv(file.path(file_path, "test.csv")) %>% 
  janitor::clean_names()
train %>% dim()
## [1] 42000   785
test %>% dim()
## [1] 28000   784

위와 같이 train 데이터 셋에는 42000개의 표본이 들어있으며, label 정보가 들어있는 열 1개와 나머지 그림 정보를 포함하고 있는 열들로 이루어져있다. 반대로 test 데이터 셋에는 28000개의 표본이 들어있고, 784개의 열들로 이루어져있는 tabular (사각형 모양의) 데이터 셋이라는 것을 알 수 있다. 여기서 숫자 784에 대한 의미를 알고 넘어가도록 하자.

 

784라는 숫자는 28 by 28 정사각형의 이미지를 일자로 쭈욱 펼쳐놓은 형태의 벡터를 의미한다. 즉, 28 by 28의 행렬 형태로 데이터를 저장해놓기 불편하므로 (3차원 형태의 데이터셋이 되어버리므로) csv 파일의 한줄이 이미지 1장의 정보를 담고 있도록 펼쳐서 저장해놓았다고 생각하자.

# 원래 이미지 1장의 구조
matrix(1:9, nrow = 3)
##      [,1] [,2] [,3]
## [1,]    1    4    7
## [2,]    2    5    8
## [3,]    3    6    9
# MNIST 데이터
1:9
## [1] 1 2 3 4 5 6 7 8 9

데이터 레이블의 분포 확인

train 데이터의 레이블을 확인해보자.

train$label %>% unique() 
##  [1] 1 0 4 7 3 5 8 9 2 6

0에서부터 9까지의 숫자가 빠짐없이 다 들어있는 것을 알 수 있다. 또한, 각 레이블 별로 표본의 갯수도 큰 차이가 없음을 확인 할 수 있다.

train %>% 
  group_by(label) %>% 
  tally() %>% t() %>% 
  kable()
label 0 1 2 3 4 5 6 7 8 9
n 4132 4684 4177 4351 4072 3795 4137 4401 4063 4188

표본 데이터

다음의 코드는 train 데이터 셋의 두 번째 표본을 시각화하는 코드이다. 코드에서 다음과 같은 변환 과정을 거쳐 이미지를 표현하게 된다.

  • 레이블 정보가 들어있는 첫번째 열을 제거한 데이터 프레임을 선택
  • 데이터 프레임을 벡터로 변환 후 가로 28, 세로 28인 행렬로 변환
  • 행렬로 변환된 자료를 image() 사용하여 이미지로 나타낸다.
  • 이미지 제목을 레이블 정보를 가져와 위쪽에 같이 표시한다.
sample_image <- matrix(unlist(train[2,-1]), 28, 28)
image(sample_image[,28:1], xaxt='n', yaxt='n', 
      main = paste("Number: ", train[2, 1]))

train dataset의 두 번째 표본

데이터 셋 클래스 등록하기

신경망 학습을 위하여 데이터셋 클래스를 정의하고 데이터 로더를 통하여 학습에 사용할 데이터 셋 만들기를 시작해보자.

  • digit_dataset 클래스 정의 코드 전체
digit_dataset <- dataset(
    name = "digit_dataset",
    initialize = function(data, dset) {
        temp_data <- self$prepare_digit_data(data, dset)
        self$x <- temp_data$x$view(c(-1, 1, 28, 28))
        self$y <- temp_data$y
    },
    
    .getitem = function(index) {
        x <- self$x[index, ..]
        y <- self$y[index]
        list(x, y)
    },

    .length = function() {
        self$x$size()[1]
    },

    prepare_digit_data = function(input_data, dset) {
      if (dset == "train") {
        img_data <- torch_tensor(as.matrix(input_data[,-1]))
        y_data <- torch_tensor(input_data$label + 1,
                               dtype = torch_long())
        list(x = img_data/255, y = y_data)        
      } else if (dset == "test") {
        n <- dim(train)[1]
        img_data <- torch_tensor(as.matrix(input_data))
        y_data <- torch_tensor(rep(0, n), dtype = torch_long())
        list(x = img_data/255, y = y_data)
      } else {
        warning("dset should be either train or test")
        return(FALSE)
      }
    }
)

digit_dataset 데이터셋 클래스 생성자는 다음과 같이 정의한다. 먼저 데이터 셋을 객체화 할 때, 데이터셋 자체와 학습을 위한 데이터인지 테스트를 위한 데이터인지를 나타내주는 dset 옵션을 지정하여 생성한다.

digit_dataset(mydata, dset = "train")

즉, 위와 같이 mydata를 학습 데이터로 등록하기 위해서는 dset을 “train”으로 지정해 주어야 한다. 이 경우, prepare_digit_data 함수 과정에서 y 변수의 내용을 입력된 데이터에서 뽑아내고, 그렇지 않은 경우 (dset = "test")는 들어온 데이터 전체를 x로 사용하는 것에 주의하자. 또한, 데이터 클래스안에 self$x에는 이미지 정보가 더 이상 한 줄의 벡터 형태가 아닌 4차원의 텐서 형태로 저장이 되어 있음을 꼭 확인하고 넘어가자.

 

레이블 정보의 경우 실제 레이블 정보 + 1을 사용하여 저장이 되어있는데, 이유는 추후 loss 함수를 계산할 때 계산 에러가 나는 것을 확인해서 1을 더해주었다. (torch 패키지의 인덱스 문제로 보인다.) 추후 좀 더 자세한 이유를 알게 될 경우 업데이트 하도록 하겠다.

 

데이터셋 나누기

데이터 셋 클래스를 정의하였으므로, 가지고있는 데이터를 나눠서 데이터 셋을 준비하도록 하자. 다만, 이번에는 학습할 때 사용되는 train 데이터셋을 다시 두 개의 데이터 셋 로더 train_dlvalid_dl로 나누었다. 이러한 이유는 신경망의 구조가 복잡해지면서 데이터의 분포를 더 잘 잡아낼 수 있지만, 그와 동시에 발생할 수 있는 overfitting 문제가 발생하기 때문이다. train 데이터 셋에서의 모델 성능이 좋아지더라도, valid 데이터 셋 (학습에 사용되지 않은 데이터 셋)에서 모델의 성능이 떨어진다면, 모델이 overfitting 되고 있다는 신호라고 생각하고, 학습을 멈추도록 한다.

# 80% of train data will be used as the actual trainning set
n <- nrow(train)
batch_size <- 16
train_indices <- sample(1:n, size = floor(0.8 * nrow(train)))
valid_indices <- setdiff(1:n, train_indices)

# train data set loader register
train_ds <- digit_dataset(train[train_indices, ], dset = "train")
train_dl <- train_ds %>% dataloader(batch_size = batch_size, 
                                    shuffle = TRUE)

# valid data set loader register
valid_ds <- digit_dataset(train[valid_indices, ], dset = "train")
valid_dl <- train_ds %>% dataloader(batch_size = batch_size, 
                                    shuffle = TRUE)

# test data set loader register
test_ds <- digit_dataset(test, dset = "test")
test_dl <- test_ds %>% dataloader(batch_size = batch_size, 
                                  shuffle = FALSE)

배치 데이터 확인

데이터 셋 로더에서 하나의 배치를 꺼내어 구조를 확인해보면, 신경망 학습 시 어떠한 데이터가 모델로 들어가게 되는지 아이디어를 얻을 수 있다. 데이터 로더에서 하나의 배치를 꺼내오는 코드는 다음과 같다.

train_dl$.iter()$.next()

상당히 유용하게 자주 사용되는 코드이므로, 코드를 통째로 외워두기 바란다.

b <- train_dl$.iter()$.next()
b[[1]]$size()
## [1] 16  1 28 28
b[[2]]$size()
## [1] 16
classes <- b[[2]]
head(classes)
## torch_tensor
##   2
##  10
##   9
##   5
##   8
##   3
## [ CPULongType{6} ]

위에서 볼 수 있는 바와같이, 하나의 배치는 16개의 표본으로 구성되어 있다. 또한 train_dl의 경우 리스트를 반환하며, 리스트의 1번째 정보에는 이미지 정보가 들어있고, 2번째 정보에는 레이블이 정수 형태로 담겨 있음을 확인 할 수 있다.

handdigit_net 신경망 구조 정의하기

손글씨 분류 신경망 구조는 2차원 convolutional 레이어 2개를 사용하여 구현하였다. convolutional 레이어에 대한 자세한 설명은 추후 업데이트 하도록 하겠다. 현재는 다음의 자료를 참고하자.

전체적인 구조는 conv 레이어 \(\rightarrow\) relu 활성화 함수 \(\rightarrow\) Max pool레이어의 과정을 2번 반복하는 구조이다.

# Note: img H and W auto. measured
handdigit_net <- nn_module(
    "HandDigitNet",
    initialize = function() {
        self$conv2d1 <- nn_conv2d(
                              in_channels = 1,
                              out_channels = 16, 
                              kernel_size = 5,
                              stride = 1, 
                              padding = 0)
        self$conv2d2 <- nn_conv2d(
                              in_channels = 16,
                              out_channels = 32, 
                              kernel_size = 5,
                              stride = 1, 
                              padding = 0)
        self$relu <- nn_relu()
        self$maxpool <- nn_max_pool2d(kernel_size = 2)
        self$fc = nn_linear(32 * 4 * 4, 10) 
    },
    
    forward = function(x) {
      n <- x$size()[1]
      x %<>% 
        self$conv2d1() %>%
        self$relu() %>% 
        self$maxpool() %>% 
        self$conv2d2() %>%
        self$relu() %>% 
        self$maxpool()
      x <- self$fc(x$view(c(n, -1)))
      x
    }
)
  • handdigit_net의 객체화
my_handdigit_net <- handdigit_net()
# device <- if (cuda_is_available()) torch_device("cuda:0") else "cpu"
device <- "cpu"
my_handdigit_net <- my_handdigit_net$to(device = device)

my_handdigit_net에는 총 18378개의 패라미터가 존재한다.

신경망 학습하기

학습률 결정하기

신경망은 학습률을 얼마로 설정하느냐에 따라서 학습 성능이 많이 차이가 난다. 학습률 결정하는 방법은 다음 포스트에서 다루도록 하겠다. 현재는 학습률을 작은 숫자부터 시작해서 0.02까지 올려가는 스케쥴러를 사용했다는 것만 이해하고 넘어가도록 한다. 또한 다중 분류 문제이므로 loss 함수를 nn_cross_entropy_loss()를 사용하여 정의해준다.

num_epochs <- 5
optimizer <- optim_adam(my_handdigit_net$parameters)
scheduler <- optimizer %>% 
  lr_one_cycle(max_lr = 0.02, 
               epochs = num_epochs, 
               steps_per_epoch = train_dl$.length())
criterion <- nn_cross_entropy_loss()

학습 과정 구현

학습 과정은 이전 포스팅에서 다룬 내용과 유사하다. 하지만 몇가지 바뀐 점을 살펴보면 다음과 같다.

  • my_handdigit_net을 train loss와 valid loss를 계산할 때 train() 모드와 eval() 모드로 옯겨주면서 계산량을 줄여준다.
  • coro::loop()를 사용하여 데이터 로더에서 배치들을 사용한 루핑을 가능하게 해주었다. 이전 enumerate() 함수를 사용한 루핑 방법은 이제는 depreciate 되었음에 주의하자.
loss_record <- tibble(
  epoch = 1:num_epochs,
  train = rep(0, num_epochs),
  valid = rep(0, num_epochs)
)

for (epoch in 1:num_epochs) {
  my_handdigit_net$train()
  train_losses <- c()

  coro::loop(for (b in train_dl) {
    optimizer$zero_grad()
    output <- my_handdigit_net(b[[1]]$to(device = device))
    loss <- criterion(output, 
                      b[[2]]$to(device = device))
    loss$backward()
    optimizer$step()
    train_losses <- c(train_losses, loss$item())
  })

  my_handdigit_net$eval()
  valid_losses <- c()

  coro::loop(for (b in valid_dl) {
    output <- my_handdigit_net(b[[1]]$to(device = device))
    loss <- criterion(output, 
                      b[[2]]$to(device = device))
    valid_losses <- c(valid_losses, loss$item())
  })

  cat(sprintf("Loss at epoch %d: training: %3f, validation: %3f\n",
              epoch, mean(train_losses), mean(valid_losses)))
  loss_record$train[epoch] <- mean(train_losses)
  loss_record$valid[epoch] <- mean(valid_losses)
}
## Loss at epoch 1: training: 0.206273, validation: 0.130114
## Loss at epoch 2: training: 0.059134, validation: 0.041074
## Loss at epoch 3: training: 0.043816, validation: 0.032327
## Loss at epoch 4: training: 0.032853, validation: 0.024270
## Loss at epoch 5: training: 0.027613, validation: 0.020689

학습셋과 평가셋에서의 모델 성능 시각화

loss_record에 담겨있는 정보를 시각화 해 보도록 하자. train 데이터와 valid 데이터에서도 학습이 잘 일어나고 있는 것을 알 수 있다.

loss_record %>% 
  pivot_longer(!epoch, 
               names_to = "data_set", 
               values_to = "loss") %>% 
  qplot(x = epoch, y = loss, 
        color = data_set, 
        geom = "path", data = .)

valid set의 loss가 계속 낮아지는 것으로 보아 계속 학습을 해도 될 것이다.

예측하기

학습을 마친 모델에서 예측값을 가져오기 온다. 예측값을 만들때 label을 앞에서 +1을 해줬으므로 원래의 위치로 바꿔주기 위해서 -1을 해준다.

my_handdigit_net$eval()

# test dataset register
test_ds <- digit_dataset(test, dset = "test")
test_dl <- test_ds %>% 
  dataloader(batch_size = batch_size, 
             shuffle = FALSE)

pred <- c()
coro::loop(for (b in test_dl) {
    result <- my_handdigit_net(b[[1]]$to(device = device))
    result <- torch_max(result, dim = 2)[[2]]$to(device = "cpu") %>% as.integer()
    pred <- c(pred, result - 1)    
})

몇 개의 test 셋에 대하여 결과를 확인해보도록 하자.

draw_test <- function(test_num){
    sample_image <- matrix(unlist(test[test_num,]), 28, 28)
    image(sample_image[,28:1], xaxt='n', yaxt='n', 
          main = paste("Number: ", pred[test_num]))  
}

par(mfrow=c(3, 3))
sapply(sample(1:length(test), 9), draw_test)

test dataset의 무작위 9개 추출 데이터에 대한 레이블 확인

캐글 정답 제출

캐글 제출을 위해서 sample_submission.csv을 불러와 base_model_pred.csv 파일로 예측 결과를 저장해서 제출을 한다. 아래의 그림과 같이 학습한 모델의 결과는 97%의 정확도를 갖는 것을 확인할 수 있다.

submission <- read_csv(file.path(file_path, "sample_submission.csv"))
submission$Label <- pred
submission %>% head()
## # A tibble: 6 × 2
##   ImageId Label
##     <dbl> <dbl>
## 1       1     2
## 2       2     0
## 3       3     9
## 4       4     9
## 5       5     3
## 6       6     7
write.csv(submission,
          row.names = FALSE,
          "base_model_pred.csv")

캐글 제출 결과 확인

반응형

댓글