CNN을 활용한 비음성 구간 검출

합성곱신경망(CNN, Convolutional Neural Network)은 처음에는 이미지 분류에 많이 사용되었지만 현재는 이미지 분류 외에도 아주 다양한 분류 문제 해결에 사용되고 있습니다.

이번에는 CNN을 활용하여 음성신호를 분류하는 문제를 다뤄보겠습니다.

일단 음성신호를 몇개 만들어봅니다. 사용하는 음성신호는 wav(waveform audio file format)입니다.

위키피디아 정의에 의하면 WAV 또는 WAVE는 개인용 컴퓨터에서 오디오를 재생하는 마이크로소프트와 IBM 오디오 파일 표준 포맷이다. 가공되지 않은 오디오를 위한 윈도우 시스템의 기본 포맷이다. WAV는 비압축 오디오 포맷으로 프로그램 구동음이나 일반 수준의 녹으로 사용되지만 전문 녹음용으로는 사용되지 않는다.

구조적으로 보면 wav 파일은 PCM 파일에 Header를 붙인 것이라고 할 수 있습니다. 다시 말하면 PCM 정보를 이용하면 wav 파일을 만들수 있다는 의미가 됩니다. Header에는 Sampling Rate와 같은 정보, 채널 같은 정보가 필요합니다. wav 파일을 읽을 때에 별도의 조치가 필요하지 않은 이유는 바로 Header에 이런 정보가 포함되어 있기 때문입니다.

Sampling Rate는 아나로그인 음성 파일을 초당 몇번의 샘플링을 하는가에 대한 숫자입니다. 높으면 높을 수록 손실되는 정보가 적어지니 음질이 좋아지겠지만 저장을 위해서 더 많은 공간을 필요로 하기 때문에 목적에 따른 적절한 수준의 정의가 필요합니다. 이번 예제에서는 22,050 Sampling Rate를 사용합니다.

이 외에도 Mono, Stereo 를 표시하는 Channel과 8-bit, 16-bit, 32-bit를 정의하는 Resolution 정보가 헤더에 포함되어 있습니다. 이 헤더 정보를 자세히 살펴보면 아래와 같은 구조로 되어 있습니다.

보면 파일 구조 중에서 0-44 byte까지가 헤더 부분이고 실제 데이터는 그 이후에 추가됩니다.

wav 파일에 대한 자세한 정보는 인터넷에 공개된 다른 정보를 참고해 보시기 바랍니다.

테스트를 위한 개략적인 개요는 아래의 그림과 같습니다.

먼저 음성신호를 입력 받아서 각 신호를 1초 단위로 slicing 합니다. 그렇게 되면 60초의 음성 wav 파일이 있다면 각 파일은 1초 단위의 wav 파일로 나눠지게됩니다. 이제 각 분리한 음성파일을 들어보고 음성이면 0, 비음성이면 1로 레이블링합니다.

이 과정이 시간이 오래걸립니다. 두말할 것도 없이 이러한 데이터가 많으면 많을 수록 비음성을 찾을 확률이 커집니다. 비음성은 녹음 환경이 어디야에 따라서 많은 차이가 있습니다. 차안, 방안, 야외, 놀이터, 사무실 등에서 발생하는 비음성 데이터를 샘플링하면 더 좋은 예측 모델을 만들 수 있습니다.

1초 단위로 샘플링하면 이 데이터는 [0-22049]의 벡터로 변환할 수 있습니다. 만약 음성 길이가 60초라면 [60×22050]의 데이터가 됩니다. 이제 이 데이터를 CNN을 통해서 학습할 수 있도록 데이터의 형태를 바꿔주면 해당 데이터는 [60×1×n×m] 형태의 데이터가 됩니다. n과 m은 각각 원하시는 사이즈로 만드실 수 있습니다.

이제 해당 음성 데이터를 Google Drive에 업로드하고 Google Colab(Pro)를 사용해서 모델을 학습해보겠습니다. 아래의 그림은 테스트에 사용되는 Colab GPU 정보입니다.

gpu_info = !nvidia-smi
gpu_info = '\n'.join(gpu_info)
if gpu_info.find('failed') >= 0:
  print('Select the Runtime > "Change runtime type" menu to enable a GPU accelerator, ')
  print('and then re-execute this cell.')
else:
  print(gpu_info)
def load_wav2():
    file_list = sorted(glob.glob("gdrive/.../*sound/*.wav"))
    file_class = []

    for f in file_list:
      if f[40:47]=='av/nois':
          file_class.append([1,0])
      else:
          file_class.append([0,1])
  
    return file_list, file_class

import time
def time_per_epoch(st, et):
  elt = et - st
  elaps_min = int(elt/60)
  elaps_sec = int(elt - (elaps_min*60))
  
  return elaps_min, elaps_sec

# noise 0, norm 1
wav_list, wav_class = load_wav2()

음성 파일이 있는 위치에서 학습 파일 경로를 읽어옵니다. 학습 파일을 읽은 후에 노이즈(비음성)일 경우 0, 음성일 경우 1로 표시합니다. 즉 class는 2가됩니다. 이 말은 예측의 결과 값도 결국 0 or 1의 형태를 가진다는 말이됩니다. 그런 다음 1 epoch에 걸리는 시간을 기록하기 위해서 time_per_epoch() 이라는 함수를 선언해줍니다.

파일 리스트와 레이블 정보를 출력해보면 아래의 정보와 같습니다.

for w,c in zip(wav_list[0:10], wav_class[0:10]):
  print(w,c)

#gdrive/.../noise_sound/basic_10_0.wav [1, 0]
#gdrive/.../noise_sound/basic_10_0_cp.wav [1, 0]
#gdrive/.../noise_sound/basic_10_1.wav [1, 0]
#gdrive/.../noise_sound/basic_10_1_cp.wav [1, 0]
#gdrive/.../noise_sound/basic_10_21.wav [1, 0]
...

이제 음성 파일을 읽어서 벡터로 변환합니다.
각 음성 파일은 librosa 패키지 안에 있는 load() 함수를 사용해서 아나로그 신호를 벡터 정보로 변환합니다. Sampling Rate가 22050로 설정했기 때문에 벡터의 길이는 [0-22049]가 됩니다. 전체 데이터를 읽어서 메모리에 저장하는데 많은 시간이 필요합니다.

예제에서는 전체 파일이 아닌 일부 데이터만 읽어 오기 때문에 한번에 읽을 수 있지만 파일이 큰 경우는 메모리가 제한되어 있기 때문에 한번에 데이터를 읽을 수 없습니다. 그럴 경우는 batch_size를 사전에 정의하고 파이토치의 데이터로더(Dataloader)와 같은 도구를 사용해서 파일을 부분적으로 읽어서 학습을 수행할 수 있습니다. 데이터로더 예제는 블로그의 다른 글에 있으니 참고하시기 바랍니다.

wav_data = []
start_time = time.time()

for idx, wav in enumerate(wav_list):
  y, sr = librosa.load(wav)
  wav_data.append(y)

end_time = time.time()
lap_mins, lap_secs = time_per_epoch(start_time, end_time)
print(f"Lap Time: {lap_mins} mins, {lap_secs} secs")

데이터를 읽은 후에 학습용 데이터셋과 테스트용 데이터셋으로 분리[8:2]로 분리합니다.

xx = torch.tensor(x_data, dtype=torch.float, device=device)
yy = torch.tensor(y_data, dtype=torch.long, device=device)

train_cnt = int(xx.size(0) * config.train_rate)
valid_cnt = xx.size(0) - train_cnt

# Shuffle dataset to split into train/valid set.
indices = torch.randperm(xx.size(0)).to(device) # random 순열 리턴
x = torch.index_select(xx, dim=0, index=indices).to(device).split([train_cnt, valid_cnt], dim=0)
y = torch.index_select(yy, dim=0, index=indices).to(device).split([train_cnt, valid_cnt], dim=0)

이제 분석을 위한 CNN 학습 모듈을 생성합니다. 학습 모듈은 Conv2d() → ReLU() → BatchNorm2d() → Linear() → Softmax()로 구성되어 있습니다. 최종 출력값은 0 or 1의 형태를 가지게 됩니다.

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        
        self.convs = nn.Sequential(
            nn.Conv2d(1, 10, kernel_size=3), # input_channel, output_channel, kernel_size
            nn.ReLU(),
            nn.BatchNorm2d(10),
            nn.Conv2d(10, 20, kernel_size=3, stride=2),
            nn.ReLU(),
            nn.BatchNorm2d(20),
            nn.Conv2d(20, 40, kernel_size=3, stride=2),
            nn.ReLU(),
            nn.BatchNorm2d(40),
            nn.Conv2d(40, 80, kernel_size=3, stride=2)
        )
        
        self.layers = nn.Sequential(
            nn.Linear(80*17*17, 500),
            nn.ReLU(),
            nn.BatchNorm1d(500),
            nn.Linear(500,250),
            nn.Linear(250,100),
            nn.ReLU(),
            nn.BatchNorm1d(100),
            nn.Linear(100,50),
            nn.Linear(50, 10),
            nn.ReLU(),
            nn.BatchNorm1d(10),
            nn.Linear(10, 2),
            nn.Softmax(dim=-1)
        )

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, 80*17*17)
        return self.layers(x)
    
model = Net()
model.to(device)

이제 모델을 아래와 같이 학습합니다.

optimizer = optim.Adam(model.parameters(), lr=config.lr)
criterion = nn.CrossEntropyLoss()

hist_loss = []
hist_accr = []

model.train()
for epoch in range(config.number_of_epochs):
    output = model(x[0]) # Train 
    loss = criterion(output, torch.argmax(y[0], dim=1)) # Train
    
    predict = torch.argmax(output, dim=-1) == torch.argmax(y[0], dim=-1)
    accuracy = predict.sum().item()/x[0].size(0)
    
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    hist_loss.append(loss)
    hist_accr.append(accuracy)
    
    if epoch % 5 == 0:
        print('epoch{}, loss : {:.5f}, accuracy : {:.5f}'.format(epoch, loss.item(), accuracy))

plt.plot(hist_loss)
plt.plot(hist_accr)
plt.legend(['Loss','Accuracy'])
plt.title('Loss/Legend')
plt.xlabel('Epoch')
plt.show()

학습이 완료된 후 아래와 같이 Loss/Accuracy를 표시해봅니다.

테스트 데이터를 통해서 해당 모델의 정확률을 테스트해보니 0.94 값을 얻었습니다.

with torch.no_grad():
  output = model(x[1])

  predict = torch.argmax(output, dim=1)
  accur = (predict == torch.argmax(y[1], dim=1)).to('cpu').numpy()
  print(accur.sum()/len(accur)) # 0.94

CNN을 활용한 비속어 필터링

CNN을 활용해서 텍스트를 분류하는 예제는 이전에서 살펴봤습니다.
해당 예제는 영화평점 텍스트를 학습하고 그 평가가 긍정인지 부정인지를 판단하는 문제를 CNN을 통해서 예측하는 내용이었습니다.
내용이 궁금하신 분은 아래의 예제를 살펴보시기 바랍니다.

이번에도 CNN을 활용한 예제로 비속어를 필터링하는 내용입니다. 본 예제에서는 학습을 통해서 생성된 모델을 python에서 제공하는 웹 어플리케이션 제작 프레임워크인 django를 통해서 간단한 웹서버를 구현해보겠습니다.

먼저 아래의 그림은 이미 많은 연구 문서나 블로그에서 보셨을듯한 이미지로 CNN을 활용한 텍스트 분류의 대표적인 이미지입니다. 해당 이미지를 간단히 설면하면 아래의 문장 “wait for the video and don’t rent it” 문장을 어절단위로 분리하여 Embedding(n, 5) 레이어를 통과시키면 아래의 문장은 “문장어절 × 5” 형태의 값을 가지게 됩니다. 이것이 첫번째 이미지입니다. 이것을 CNN에서 처리하는 벡터 shape으로 만들기 위해서는 앞에 Channel 값을 입력하게 됩니다.

이렇게 되면 예시 문장은 하나의 이미지 데이터의 모양(Shape)을 가지게 됩니다. 그러나 이런 문장이 하나만 존재하지는 않고 여러개 존재합니다. 그렇게 되면 맨 앞에는 n_batch 정보를 입력 할 수 있습니다.

예를 들어서 100개의 문장이라면 [100 × 1 × 9 × 5] 형태의 shape이 되는 것이죠. 이는 CNN을 통해서 이미지를 분류한다고 생각할 때에 9×5의 1채널 이미지 100장에 해당됩니다.

이제 이것을 Conv2d 레이어를 통과시키고 나온 output 데이터를 Fully Connected 한 후에 Linear 레이어를 통과시키고 이 값을 Softmax로 시켜 최종 출력값을 얻습니다.

이 과정은 이미지를 분류하는 과정과 굉장히 유사합니다. 다만 앞부분에서 “어떻게 텍스트를 처리해서 매트릭스를 만드는가?” 하는 과정만 차이가 있습니다. 위의 논문에서는 어절 단위로 분리해서 처리했고 또 다른 논문에서는 한글 자소단위로 분리하여 필터를 적용하는 연구도 있습니다.

본 예제에서는 문장을 단어 단위로 분리하되 각 단어를 n-gram 하여 2글자씩 분리해서 워드 벡터를 생성하였습니다. 학습에 사용할 비속어 리스트는 아래와 같습니다.

txt 컬럼은 비속어 정보를 label 정보는 1의 경우는 비속어, 0은 일반 단어로 표시합니다. 각 텍스트는 2글자씩 분리하여 워드 벡터를 만든다고 했는데 해당 과정을 수행하면 “야해요야동” 문장의 경우”야해, 해요, 요야, 야동, 야해요야동” 이런 방법으로 구성됩니다. 비속어가 1글자 인경우도 상당히 많기 때문에 1글자의 경우는 한글자만 사용하는 것으로 했습니다. 2글자씩 분리한 다음 마지막에는 원문도 포함해서 훈련용 데이터셋을 생성합니다.

# n-gram
def textgram(text):
    tmp = []
    if len(text) > 1:
        for i in range(len(text)-1):
            tmp.append(text[i]+text[i+1])
        tmp.append(text)
    return tmp

textgram('야해요야동')
# output ['야해', '해요', '요야', '야동', '야해요야동']

아래 데이터는 테스트에 사용했던 도메인의 일반 텍스트입니다. 당연한 말이지만 도메인이 넓은 경우 보다는 한정된 범위로 축소하는 것이 더 좋은 예측 결과를 보입니다. 본 예제의 경우는 챗봇에 사용하는 일상 대화들로부터 데이터를 수집하였습니다. 아래의 데이터셋 역시 2글자로 분리합니다. label이 0인 것은 정상 단어라는 의미입니다.

생성된 단어의 리스트들을 통해서 vocab을 만듭니다. 이때 중복 제거는 필수입니다.
만들어진 vocab에 padding, unk 값을 추가합니다. 그 이유는 각 단어가 기준 크기 보다 작은 경우 빈 값을 패딩값으로 채우기 위함입니다. unk의 경우는 vocab에 존재하지 않는 단어가 나올 경우 해당 위치를 채워주는 코드입니다.

vocab = list(set([w for word in words for w in word]))
vocab = np.insert(vocab,0,'!') # padding
vocab = np.insert(vocab,0,'#') # unk

x_data = [[word2index[w] for w in word] for word in words]

이제 각 워드를 인덱스로 바꾸는 과정을 완료하면 아래와 같은 데이터셋을 얻을 수 있습니다. 아래의 데이터셋은 보시는 것처럼 그 크기가 각각 달라서 일정한 값으로 Shape을 맞출 필요가 있습니다.

print(x_data[0:10])

[[2179, 2693, 4402, 2776, 3215],
 [964, 1927, 2027, 1767, 6721, 3171],
 [964, 1927, 1525, 5679, 3310],
 [964, 1927, 4133, 257, 2462, 1061, 554, 1941, 1753, 1666],
 [964, 1927, 5247, 1177],
 [964, 1927, 3795, 6693, 191, 5585, 3299, 5066],
 [964, 1927, 601, 4397, 2938],
 [1298, 5558, 2423, 5374, 877, 4260],
 [3911, 229, 5374, 877, 4103],
 [3241, 5603, 3173]]

고정 크기를 정해주고 해당 길이보다 작은 데이터들은 아래와 같이 사전에 정의한 padding 값으로 채워줍니다. 그렇게 되면 아래와 같은 형태의 데이터 데이터 값을 얻을 수 있습니다. 여기서 vocab에 존재하지 않는 새로운 단어가 입력되면 해당 단어는 0으로 채우게 됩니다.

[array([2179, 2693, 4402, 2776, 3215,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 2027, 1767, 6721, 3171,    1,    1,    1,    1]),
 array([ 964, 1927, 1525, 5679, 3310,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 4133,  257, 2462, 1061,  554, 1941, 1753, 1666]),
 array([ 964, 1927, 5247, 1177,    1,    1,    1,    1,    1,    1]),
 array([ 964, 1927, 3795, 6693,  191, 5585, 3299, 5066,    1,    1]),
 array([ 964, 1927,  601, 4397, 2938,    1,    1,    1,    1,    1]),
 array([1298, 5558, 2423, 5374,  877, 4260,    1,    1,    1,    1]),
 array([3911,  229, 5374,  877, 4103,    1,    1,    1,    1,    1]),
 array([3241, 5603, 3173,    1,    1,    1,    1,    1,    1,    1])]

입력 데이터에 대한 준비가 마무리되면 파이토치의 nn.Module 모듈을 상속 받아서 훈련용 모듈을 생성합니다. 이전에 활용한 CNN을 활용한 텍스트 분류 글에서 사용했던 CNN 모듈을 그대로 사용하기 때문에 해당 부분은 생략합니다. 다만 생성된 모듈을 출력해보면 아래와 같은 정보를 얻을 수 있습니다.

CNN(
  (embedding): Embedding(7037, 100)
  (convs): ModuleList(
    (0): Conv2d(1, 100, kernel_size=(2, 100), stride=(1, 1))
    (1): Conv2d(1, 100, kernel_size=(3, 100), stride=(1, 1))
    (2): Conv2d(1, 100, kernel_size=(4, 100), stride=(1, 1))
    (3): Conv2d(1, 100, kernel_size=(5, 100), stride=(1, 1))
  )
  (fc): Linear(in_features=400, out_features=2, bias=True)
)

해당 모듈은 Embedding 레이어, 4개의 Conv2d 레이어, Linear 레이어로 구성되어 있습니다. Linear 레이어의 최종 값은 2이고 이는 (0,1) 둘 중에 하나의 값을 출력하게됩니다.

훈련용 데이터는 DataLoader를 통해서 데이터셋을 만들고 batch_size = 1000, epoch = 1000으로 학습을 수행합니다. 테스트 환경은 구글 코랩 프로(Colab Pro) 버전을 사용합니다. 일반 Colab 버전을 사용해서 테스트 하셔도 무방합니다. 속도의 차이가 있지만 그리 큰 차이는 아닌듯 합니다.

최종 학습이 수행하고 나온 model과 word2index 파일을 저장합니다.

이제 저장된 모델을 django를 통해서 간단한 웹서버를 만들어봅니다. 참고로 해당 부분에 대한 설명은 이번 글에서는 하지 않고 Django를 통해서 웹서버를 개발하는 예제는 이후에 다른 글에서 다뤄보겠습니다.

위와 같은 형태로 간단한 입력과 출력 결과를 표시합니다. 결과에 보면 “영화관”은 비속어가 아닌데 비속어로 처리된 부분이 있습니다. 이 부분은 영화관이라는 단어가 비속어 데이터에 추가 되어 있기 때문에 표시된 부분입니다. 학습용 데이터의 중요성이 다시 한번 확인되네요.

CNN을 통해서 필터링 하면 철자에 오타가 있거나 단어의 조합인 경우 앞뒤 순서가 바뀌는 경우에도 비교적 잘 탐지 하는 것을 확인했습니다.

CNN을 활용한 텍스트 분류

CNN(Convolutional Neural Networks)은 이미지 분류에 높은 성능을 발휘하는 알고리즘이나 이 외에도 여러 분야에서도 활용되고 있습니다. 그중에 하나가 텍스트를 분류하는 문제입니다.

본 예제는 아래의 논문을 참조하고 있습니다.

Convolutional Neural Networks for Sentence Classification

We report on a series of experiments with convolutional neural networks (CNN) trained on top of pre-trained word vectors for sentence-level classification tasks. We show that a simple CNN with little hyperparameter tuning and static vectors achieves excellent results on multiple benchmarks. Learning task-specific vectors through fine-tuning offers further gains in performance. We additionally propose a simple modification to the architecture to allow for the use of both task-specific and static vectors. The CNN models discussed herein improve upon the state of the art on 4 out of 7 tasks, which include sentiment analysis and question classification.

https://arxiv.org/abs/1408.5882

합성곱신경망이라고도 불리는 CNN 알고리즘은 여러 좋은 강의가 있으니 참고하시기 바랍니다. 또 관련해서 좋은 예제들도 많이 있으니 아래 예제를 수행하시기 전에 살펴보시면 도움이 되시리라 생각합니다.
아래의 예제는 가장 유명한 예제 중에 하나인 MNIST 분류 예제입니다.

먼저 config를 정의합니다. config에는 학습에 필요한 여러가지 변수들을 미리 정의하는 부분입니다. model을 저장할 때에 함께 저장하면 학습 모델을 이해하는데 도움이 됩니다.

학습을 완료하고 저장된 모델 파일을 업로드해서 사용할 때에 해당 모델이 어떻게 학습됐는지에 대한 정보가 없을 경우나 모델을 재학습 한다거나 할 때에 config 정보가 유용하게 사용됩니다. 본 예제는 해당 알고리즘을 이해하는 정도로 활용할 예정이기 때문에 학습은 100번 정도로 제한합니다.

나머지 정의된 변수들은 예제에서 사용할 때에 설명하도록 하겠습니다.

from argparse import Namespace
config = Namespace(
    number_of_epochs=100, lr=0.001, batch_size=50, sentence_lg=30, train_ratio=0.2, embedding_dim=100, n_filters=100, n_filter_size=[2,3,4], output_dim=2
)

본 예제는 영화의 평점 데이터를 활용합니다. 해당 데이터는 네이버 영화 평점과 이에 대한 긍정,부정의 반응이 저장된 데이터입니다. 컬럼은 [id, document, label]의 구조로 되어 있습니다. 영화 평이 부정적인 경우는 label=0, 그렇지 않은 경우는 label=1으로 되어 있어 비교적 간단하게 활용할 수 있는 데이터입니다.

아래의 코드를 실행하면 데이터를 읽어 올 수 있습니다. 해당 데이터에 검색해보면 쉽게 찾을 수 있습니다. 파일인 train 데이터와 test 데이터로 되어 있습니다. 본 예제에서는 train 데이터만 사용합니다. 많은 데이터를 통해서 결과를 확인하고자 하시는 분은 train, test 모두 사용해보시길 추천합니다.

def read_data(filename):
    with open(filename, 'r',encoding='utf-8') as f:
        data = [line.split('\t') for line in f.read().splitlines()]
        data = data[1:]
    return data  
train_data = read_data("../Movie_rating_data/ratings_train.txt")

읽어온 데이터를 몇개 살펴보면 아래와 같습니다. 아래 샘플에는 Label 데이터를 표시하지 않았습니다. 하지만 읽어 보면 대충 이 리뷰를 작성한 사람이 영화를 추천하고 싶은지 그렇지 않은지를 이해할 수 있습니다. 사람의 경우에는 이러한 글을 읽고 판단 할 수 있지만 컴퓨터의 경우에는 이런 텍스트(자연어)를 바로 읽어서 긍정이나 부정을 파악하는 것은 어렵습니다. 그렇기 때문에 각 단어들을 숫자 형태의 벡터로 변환하는 작업을 수행합니다.

['많은 사람들이 이 다큐를 보고 우리나라 슬픈 현대사의 한 단면에 대해 깊이 생각하고 사죄하고 바로 잡기 위해 노력했으면 합니다. 말로만 듣던 보도연맹, 그 민간인 학살이 이정도 일 줄이야. 이건 명백한 살인입니다. 살인자들은 다 어디있나요?',
 '이틀만에 다 봤어요 재밌어요 근데 차 안에 물건 넣어 조작하려고 하면 차 안이 열려있다던지 집 안이 활짝 열려서 아무나 들어간다던가 문자를 조작하려고하면 비번이 안 걸려있고 ㅋㅋㅋ 그런 건 억지스러웠는데 그래도 내용 자체는 좋았어요',
 '이 영화를 이제서야 보다니.. 감히 내 인생 최고의 영화중 하나로 꼽을 수 있을만한 작품. 어떻게 살아야할지 나를 위한 고민을 한번 더 하게 되는 시간. 그리고 모건 프리먼은 나이가 들어도 여전히 섹시하다.',
 '아~ 진짜 조금만 더 손 좀 보면 왠만한 상업 영화 못지 않게 퀄리티 쩔게 만들어 질 수 있었는데 아쉽네요 그래도 충분히 재미있었습니다 개인적으로 조금만 더 잔인하게 더 자극적으로 노출씬도 화끈하게 했더라면 어땠을까 하는 국산영화라 많이 아낀 듯 보임',
 '평점이 너무 높다. 전혀 재미있지 않았다. 쓸데없이 말만 많음. 이런 류의 영화는 조연들의 뒷받침이 중요한데 조연들의 내용자체가 전혀 없음. 또한 여배우도 별로 매력 없었다. 이틀전에 저스트고위드잇의 애니스톤을 보고 이 영화를 봐서 그런가. 실망했음',
 '왜 극을 끌어가는 중심있는 캐릭터가 있어야 하는지 알게 된영화 살인마와 대적하는 그리고 사건을 해결하는 인물이 없고 그리고 왜 마지막에 다 탈출 해놓고 나서 잡히고 죽임을 당하는지 이해할수가 없다. 대체 조달환 정유미는 왜 나옴?',
 '초딩 때 친척형이 비디오로 빌려와서 봤던 기억이 난다...너무 재미 없었다 근데 나중에 우연히 다시보니 재밌더라 그 땐 왜 그렇게 재미가 없었을까?? 98년이면 내가 초등학교 2학년 때니까...사촌형이 당시 나름 최신 비디오를 빌려온거 같다',
 '창업을 꿈꾸는가! 좋은 아이템이 있어 사업을 하려하는가!! 그렇다면 기를 쓰고 이 영활 보기바란다!! 그 멀고 험한 여정에 스승이 될것이요 지침서가 될것이다... 혹은 단념에 도움이 될지도... 참 오랜만에 박장대소하며 본 독립영활세~~~ ★',
 "영화'산업'이라고 하잖는가? 이딴식으로 홍보 해놓고 속여서 팔았다는 게 소비자 입장에서는 짜증난다. 그나마 다행은 아주 싸구려를 상급품으로 속여판 게 아니라는 점. 그래서 1점. 차라리 연상호 감독 작품 처럼 홍보가 됐다면, 그 비슷하게 만이라도 하지",
 '도입부를 제외하고는 따분.헬기에서 민간인을 마구 쏴 죽이는 미군, 베트공 여성 스나이퍼 등,현실감 없는 극단적인 설정.라이언 일병에서의 업햄 그리고 이 영화 주인공인 조커, 두 넘 모두 내가 싫어하는 캐릭터, 착한척 하면서 주위에 피해를 주는 넘들.']

각 리뷰를 읽은 후에 문장을 어절 단위로 분리합니다. 분리한 어절을 형태소까지 분리해서 활용하면 좋겠지만 본 예제에서는 간단히 어절 단위로만 분리합니다. 형태소로 분리하는 예제는 본 블로그에 다른 예제에서도 내용이 있으니 참고하시기 바랍니다. 어절 단위로 분리한 텍스트에서 중복을 제거해보면 32,435개 어절을 얻을 수 있습니다.

이렇게 얻은 32,435개의 어절을 어떻게 벡터로 나타내는가에 대해서는 pytorch의 Embedding을 사용하여 표한합니다. 해당 내용도 본 블로그의 다른 예제에서 많이 다뤘기 때문에 여기서는 생략하고 넘어가도록 하겠습니다.

words = []
for s in sentences:
    words.append(s.split(' '))
    
words = [j for i in words for j in i]
words = set(list(words))

print('vocab size:{}'.format(len(words)))
vocab_size = len(words) #vocab size:32435

리뷰의 길이를 보면 길은 것은 70 어절이 넘고 짧은 것은 1 어절도 있기 때문에 어절의 편차가 크다는 것을 확인 할 수 있습니다. 그렇기 때문에 본 예제에서는 30 어절 이상 되는 리뷰들만 사용하겠습니다. 이를 위해서 config 파일에 sentence_lg=30와 같은 값을 설정했습니다.

x_data = [[word2index[i] for i in sentence.split(' ')] for sentence in sentences]
sentence_length = np.array([len(x) for x in x_data])
max_length = np.array([len(x) for x in x_data]).max()

위의 그래프는 샘플 어절의 분포를 나타냅니다. 본 예제에서는 약 30~40 사이의 어절 정도만 사용하도록 하겠습니다. 만약 어절의 편차가 너무 크면 상당 부분을 의미 없는 데이터로 채워야 합니다. 아래의 예제는 빈 어절을 패딩값(0)으로 채우는 부분입니다.

for ndx,d in enumerate(x_data):
    x_data[ndx] = np.pad(d, (0, max_length), 'constant', constant_values=0)[:max_length]

아래와 같이 각 어절을 숫자 형태의 값으로 변환하면 리뷰의 내용은 숫자로 구성된 리스트 형태가 됩니다. 이때 0은 패딩 값으로 max_length 보다 작을 경우 남은 값을 0으로 채우게 됩니다. 0 데이터가 많을 수록 예측의 정확도가 떨어지게 됩니다.

[array([18196, 16747,  1952, 27879,  4206, 29579,  3641, 14582,  8661,
        16754,   964, 10240,  6070, 25011,  3902, 16410, 30182, 22634,
         5531, 24456,  6360,  6482, 26016,  9239, 25466, 31032,  6505,
        30782, 30861, 30494,  6876, 12237, 27035, 14997,     0,     0,
            0,     0,     0,     0,     0]),
 array([30000, 27035, 15316,  4633, 26703,  7875,  5042, 16695, 25520,
        14681, 20133,  7875,    71,  8983,   363,    71,  5149,  2391,
        27910, 28746, 23902, 32136, 12475, 24439, 15973, 20236,  4726,
         6190, 17515, 20610, 29270, 13967, 28490,     0,     0,     0,
            0,     0,     0,     0,     0]),
 array([ 1952, 12632, 10665, 27623, 25106,  1978,   184,  1537, 29451,
         4705, 22537, 21866, 14473, 26012,  6744, 15690, 27119, 15822,
        12491, 31747, 11202, 14268, 31494,  3202, 10936, 21619, 29214,
        15185,  5496, 12854, 27679,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0])]

학습을 위한 데이터를 train, test 형태로 분리하게 됩니다. 분리하면서 학습용 데이터와 테스트용 데이터의 비율을 8:2로 설정합니다. test 데이터는 학습에 사용하지 않는 데이터로 모델의 정확도 평가에만 사용됩니다.

from sklearn.model_selection import train_test_split

y_data = np.array(label).astype(np.long)
x_train, x_test, y_train, y_test = train_test_split(np.array(x_data), y_data, test_size = config.train_ratio, random_state=0) # 8:2
print(x_train.shape, y_train.shape, x_test.shape, y_test.shape)

학습용 데이터는 데이터로더에 입력하여 일정 크기(config.batch_size)로 묶어 줍니다. 예를 들어 100건의 데이터를 20개로 묶는다면 5개의 묶음으로 나타낼 수 있습니다. 지금 수행하는 예제는 비교적 적은 양의 데이터이기 때문에 이런 과정이 불필요할 수도 있지만 많은 데이터를 통해서 학습하시는 분을 위해서 해당 로직을 구현했습니다. 그리고 학습 데이터를 shuffle 해줍니다. 이 과정도 훈련의 정확도를 높이기 위해서 필요한 부분이니 True로 설정하시기 바랍니다.

from torch.utils.data import Dataset, DataLoader
class TxtDataSet(Dataset):

    def __init__(self, data, labels):
        super().__init__()
        self.data = data
        self.labels = labels

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx], self.labels[idx]
train_loader = DataLoader(dataset=TxtDataSet(x_train, y_train), batch_size=config.batch_size, shuffle=True)

파이토치를 활용해서 수행하기 때문에 필요한 모듈을 임포트합니다.

import numpy as np

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

CNN 모델을 아래와 같이 생성합니다. 이미 설명한 내용도 있기 때문에 자세한 내용은 넘어가겠습니다. 가장 중요한 부분은 텍스트 데이터를 [number_of_batch, channel, n, m] 형태의 데이터로 만드는 과정이 중요합니다. 이렇게 데이터가 만들어지면 해당 데이터를 통해서 학습을 수행합니다.

https://halfundecided.medium.com/%EB%94%A5%EB%9F%AC%EB%8B%9D-%EB%A8%B8%EC%8B%A0%EB%9F%AC%EB%8B%9D-cnn-convolutional-neural-networks-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-836869f88375

CNN 알고리즘을 잘 설명하고 있는 블로그의 링크를 올립니다. 자세한 내용은 이곳 블로그도 참고해 보시기 바랍니다.

class CNN(nn.Module):

    def __init__(self, vocab_size, embedding_dim, n_filters, filter_size, output_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.convs = nn.ModuleList([
            nn.Conv2d(in_channels=1, out_channels=n_filters, kernel_size=(fs, embedding_dim)) for fs in filter_size
            ])
        self.fc = nn.Linear(len(filter_size)*n_filters, output_dim)

    def forward(self, text):
        embedded = self.embedding(text)
        embedded = embedded.unsqueeze(1)
        conved = [F.relu(conv(embedded)).squeeze(3) for conv in self.convs]
        pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]
        
        return self.fc(torch.cat(pooled, dim=1)) # make fully-connected
    
embedding_dim = config.embedding_dim
n_filters = config.n_filters
n_filter_size = config.n_filter_size
output_dim = config.output_dim # 0 or 1

model = CNN(vocab_size, embedding_dim, n_filters, n_filter_size, output_dim)
print(model)
CNN(
  (embedding): Embedding(32435, 100)
  (convs): ModuleList(
    (0): Conv2d(1, 100, kernel_size=(2, 100), stride=(1, 1))
    (1): Conv2d(1, 100, kernel_size=(3, 100), stride=(1, 1))
    (2): Conv2d(1, 100, kernel_size=(4, 100), stride=(1, 1))
  )
  (fc): Linear(in_features=300, out_features=2, bias=True)
)

아래와 같이 학습을 수행합니다. 간단히 100번 정도만 반복했습니다.

optimizer = optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()

model.train()
for epoch in range(config.number_of_epochs):
    train_loss, valid_loss = 0, 0
    
    # train_batch start
    for x_i, y_i in train_loader:
        optimizer.zero_grad()
        
        output = model(x_i)
        loss = criterion(output, y_i)
        
        loss.backward()
        optimizer.step()
        
        train_loss += float(loss)
    if epoch % 5 == 0:
        print('Epoch : {}, Loss : {:.5f}'.format(epoch, train_loss/len(train_loader)))
Epoch : 0, Loss : 0.69145
Epoch : 5, Loss : 0.04431
Epoch : 10, Loss : 0.00848
Epoch : 15, Loss : 0.00352
Epoch : 20, Loss : 0.00192
Epoch : 25, Loss : 0.00120
Epoch : 30, Loss : 0.00082
Epoch : 35, Loss : 0.00059
Epoch : 40, Loss : 0.00044
Epoch : 45, Loss : 0.00034
Epoch : 50, Loss : 0.00027
Epoch : 55, Loss : 0.00022
Epoch : 60, Loss : 0.00018
Epoch : 65, Loss : 0.00015
Epoch : 70, Loss : 0.00012
Epoch : 75, Loss : 0.00010
Epoch : 80, Loss : 0.00009
Epoch : 85, Loss : 0.00008
Epoch : 90, Loss : 0.00007
Epoch : 95, Loss : 0.00006

학습을 완료하고 테스트 데이터를 통해서 모델을 평가해본 결과 68.39%의 정확도를 얻었습니다.
높은 정확도는 아니지만 많은 부분 간소화한 학습이었음을 감안하면 나름대로 유의미한 결과를 얻었다고 생각됩니다.

with torch.no_grad():
    output = model(torch.tensor(x_test, dtype=torch.long))
    predict = torch.argmax(output, dim=-1)
    predict = (predict==torch.tensor(y_test, dtype=torch.long))
    print('Accuracy!',predict.sum().item()/len(x_test)*100)
    #Accuracy! 68.39622641509435

CNN Filter 변환 결과

입력 이미지가 CNN(Convolutional Neural Network) 필터를 통과하면서 이미지의 변화가 어떻게 되는지 알아보고자 합니다. CNN 필터는 기존의 머신러닝 기반의 Feature 추출 방식과는 달리 자동으로 Feature를 학습합니다.

https://www.researchgate.net/figure/Architecture-of-our-unsupervised-CNN-Network-contains-three-stages-each-of-which_283433254

CNN 네트워크는 위와 같은 네트워크를 통과 하며 스스로 이미지에 대한 feature 정보를 습득하게 됩니다. 본 코드는 하나의 각 필터를 통과하며 이미지가 어떻게 변화되는지에 대한 정보를 보여주는 코드입니다.

테스트를 위해 필요한 라이브러리를 import합니다.

import cv2
import torch
import numpy as np
import matplotlib.pyplot as plt

위의 이미지는 cv2.imread로 읽으면 (225, 400, 3) 형태의 shape 정보를 가집니다. 우선 데이터의 자료형을 바꾸고 정규화를 수행합니다.

또 하나 작업해야 할 것은 pytorch에서 데이터를 처리하기 편하도록 dimension을 변경하는 작업을 수행합니다. pytorch cnn에서 데이터를 읽기 위한 데이터 타입은 아래와 같습니다.

conv2d input, output data shape

conv2d의 입력 형태는 (N, C, H, W) 형태이기 때문에 이에 맞게 shape 정보를 변경하고 plt.imshow() 함수를 통해서 이미지를 표시해봅니다.

_image = cv2.imread('./lena.jpg')
_image = _image.astype(np.float32)
_image = np.multiply(_image, 1.0 / 255.0)

image = torch.from_numpy(_image)
image = image[np.newaxis, :] 
image = image.permute(0, 3, 1, 2)

plt.imshow(image[0,2,:], cmap='gray')

이미지를 출력하기 위해서 간단한 출력용 함수를 정의합니다.

def imagegrid(args, output):
    width=args[0]
    height=args[1]
    rows = args[2]
    cols = args[3]
    axes=[]
    fig=plt.figure()
    fig.set_size_inches((20,14))
    for i in range(rows*cols):
        img = output[0,i,:]
        axes.append( fig.add_subplot(rows, cols, i+1) )
        plt.imshow(img)
    fig.tight_layout()    
    plt.show()

첫번째 conv2d는 입력 데이터를 받아서 12 Channel로 출력값을 나타내는 함수입니다. 이때 kernel 사이즈를 5로 정의했습니다. conv2d를 통해서 출력되는 이미지 정보는 아래와 같습니다. 해당 이미지들은 CNN에서 생성한 필터를 통과한 이미지 들입니다. 이러한 방식을 계속하며 곡선, 직선, 대각선 등 이미지의 특징 정보를 추출하게 됩니다.

conv1 = torch.nn.Conv2d(3,12,5)
output = conv1(image)
conv1_output = output.detach().numpy()
imagegrid([100,100,3,4], conv1_output)

위와 같은 방법으로 conv2d 네트워크를 한번 더 통과해봅니다. 이번에는 24개의 이미지를 출력하게 됩니다. conv2d를 통과하면서 점점 이미지의 크기는 작아지고 원본 이미지 형태는 필터를 지나며 점점 Feature의 특징 정보가 드러나게 됩니다.

conv2 = torch.nn.Conv2d(12,24,5)
output = conv2(output)
conv2_output = output.detach().numpy()
imagegrid([100,100,6,4], conv2_output)

한번 더 conv2d를 수행해서 48개의 이미지 데이터를 만들어 냅니다.

conv3 = torch.nn.Conv2d(24,48,5)
output = conv3(output)
conv3_output = output.detach().numpy()
imagegrid([100,100,6,8], conv3_output)

이런 방식으로 여러 차례 conv2d를 통과 하면서 이미지의 특징들일 추출해 내는 과정을 반복하게 됩니다. CNN 은 마지막에 이렇게 만들어진 정보들을 Linear 모듈을 통과하면서 최종 적으로 Classification 하게 됩니다.

예를 들어 현재의 이미지가 (60,97) 사이즈라면 Linear 모듈의 입력은 48*60*97을 입력으로 받고 임의의 크기의 출력 값을 얻을 수 있습니다. 이러한 Linear 과정을 통과하면서 ReLU, BatchNorm과 같은 함수를 사용하면 학습이 더 효율적일 수 있습니다.

CNN Fashion-MNIST 테스트 (PyTorch)

Fashion-MNIST에 대한 설명은 아래 링크로 대신하겠습니다.

Fashion-MNIST is a dataset of Zalando‘s article images—consisting of a training set of 60,000 examples and a test set of 10,000 examples. Each example is a 28×28 grayscale image, associated with a label from 10 classes. We intend Fashion-MNIST to serve as a direct drop-in replacement for the original MNIST dataset for benchmarking machine learning algorithms. It shares the same image size and structure of training and testing splits.

본 예제 코드는 데이터셋을 학습해서 입력되는 이미지가 어떤 분류에 속하는지를 예측해보는 것입니다. Fashion-MNIST 데이터셋을 벡터 공간에 표시하면 위와 같은 이미지로 분류할 수 있습니다.

이제 학습을 위해 해당 데이터셋을 다운로드합니다.

# Define a transform to normalize the data
transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.5,), (0.5,))])

# Download and load the training data
train_loader = torch.utils.data.DataLoader(datasets.FashionMNIST('../F_MNIST_data/', download=True, train=True, transform=transform), batch_size=128, shuffle=True)

# Download and load the test data
test_loader = torch.utils.data.DataLoader(datasets.FashionMNIST('../F_MNIST_data/', download=True, train=False, transform=transform), batch_size=128, shuffle=True)

다운로드한 데이터가 어떤 이미지가 있는지 살펴보기 위해서 랜덤하게 몇개의 샘플을 추출해서 표시해보겠습니다. 해당 이미지들은 10개 [‘t-shirt’, ‘trouser’, ‘pullover’, ‘press’, ‘coat’, ‘sandal’, ‘shirt’, ‘sneaker’, ‘bag’, ‘ankleboot’]로 분류할 수 있는 패션 아이템들입니다.

x_train, y_train = next(iter(train_loader))
x_valid, y_valid = next(iter(test_loader))

fig, ax = plt.subplots(5,5)
fig.set_size_inches((20,14))
for i in range(5):
    for j in range(5):
        idx = numpy.random.randint(128)
        ax[i][j].imshow(x_train[idx,0,:])
        ax[i][j].set_xlabel(label[y_train[idx].item()])
        ax[i][j].set_xticklabels([])
        ax[i][j].set_yticklabels([])

학습을 위한 모델을 선언합니다. 이전 MNIST 데이터셋을 테스트했을 때와 같은 모델을 재활용했습니다.

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        
        self.convs = nn.Sequential(
            nn.Conv2d(1, 10, kernel_size=3), # input_channel, output_channel, kernel_size
            nn.ReLU(),
            nn.BatchNorm2d(10),
            nn.Conv2d(10, 20, kernel_size=3, stride=2),
            nn.ReLU(),
            nn.BatchNorm2d(20),
            nn.Conv2d(20, 40, kernel_size=3, stride=2)
        )
        
        self.layers = nn.Sequential(
            nn.Linear(40*5*5, 500),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.BatchNorm1d(500),
            nn.Linear(500,250),
            nn.Linear(250,100),
            nn.Dropout(p=0.2),
            nn.ReLU(),
            nn.BatchNorm1d(100),
            nn.Linear(100,50),
            nn.Linear(50, 10),
            nn.Softmax(dim=-1)
        )

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, 40*5*5)
        return self.layers(x)
    
cnn = Net().to(DEVICE)

이전 MNIST 코드는 하나의 mini batch 데이터만 학습했다면 이번에는 전체 데이터를 대상으로 학습을 진행합니다. 많은 학습을 거친다면 모델의 정확도가 높아지겠지만 성능을 높이는 테스트가 아니기 때문에 최소한의 학습 epcohs만 수행합니다.

optimizer = optim.Adam(cnn.parameters())
criterion = nn.CrossEntropyLoss()

hist_loss = []
hist_accr = []

epochs = 30

for epoch in range(epochs):

    for idx, (data, label) in enumerate(train_loader):
        data, label = data.to(DEVICE), label.to(DEVICE)
        output = cnn(data)
        loss = criterion(output, label)
        
        predict = torch.argmax(output, dim=-1) == label
        accuracy = predict.float().mean().item()

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        hist_loss.append(loss.item())
        hist_accr.append(accuracy)

        if idx % 100 == 0:
            print('Epoch {}, idx {}, Loss : {:.5f}, Accuracy : {:.5f}'.format(epoch, idx, loss.item(), accuracy))

학습이 완료되고 학습의 진행이 어떻게 되었는지 알기 위해서 사전에 정의한 hist_loss와 hist_accr을 사용해서 시각화 해보겠습니다.

fig, ax = plt.subplots(2,1)
fig.set_size_inches((12,8))

ax[0].set_title('Loss')
ax[0].plot(hist_loss, color='red')
ax[0].set_ylabel('Loss')
ax[1].set_title('Accuracy')
ax[1].plot(hist_accr, color='blue')
ax[1].set_ylabel('Accuracy')
ax[1].set_xlabel('Epochs')

학습이 완료된 후에 테스트 데이터를 사용해서 모델의 정확도를 확인해보았고 결과 값으로 Accuracy : 0.93750를 얻었습니다.

cnn.eval()

with torch.no_grad():
    for idx, (data, label) in enumerate(test_loader):
        data, label = data.to(DEVICE), label.to(DEVICE)
        output = cnn(data)
        loss = criterion(output, label)
    
        predict = torch.argmax(output, dim=-1) == label
        accuracy =  predict.float().mean().item()
    
        print('Accuracy : {:.5f}'.format(accuracy))

CNN MNIST 테스트 (PyTorch)

CNN 알고리즘을 MNIST 데이터셋을 활용해서 테스트해봅니다.

CNN 알고리즘에 대한 다양한 많은 설명이 있으니 자세한 내용은 아래의 강의를 참고하시기 바랍니다. 비록 작은 부분의 차이들은 있을 수 있지만 본 예제 역시 인터넷에 많은 소스 코드와 다르지 않습니다.
단, 아래의 영상은 텐서플로우로 설명하는 영상이지만 본 예제는 파이토치로 구현되어 있으며 학습도 전체 데이터를 대상으로 하지 않고 첫번째 미니배치만 학습하는 것으로 작성했습니다.

필요한 라이브러리를 임포트하고 GPU 사용 설정하는 부분과 MNIST 데이터를 로드하는 부분에 대해서는 자세한 설명을 하지 않고 지나가겠습니다. GPU 설정이 필요 없는 CPU 상에서 예제를 구동하는 경우는 device 설정을 하지 않고 넘어가셔도 무방합니다.

GPU 서버가 없는 경우는 무료로 Colab을 이용하시는 것도 추천합니다.

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import transforms, datasets

import matplotlib.pyplot as plt

USE_CUDA = torch.cuda.is_available()
DEVICE = torch.device("cuda" if USE_CUDA else "cpu")

BATCH_SIZE = 128

train_loader = torch.utils.data.DataLoader(
    datasets.MNIST('../data',
                   train=True,
                   download=True,
                   transform=transforms.Compose([
                       transforms.ToTensor(),
                       transforms.Normalize((0.1307,), (0.3081,))
                   ])),
    batch_size=BATCH_SIZE, shuffle=True)
test_loader = torch.utils.data.DataLoader(
    datasets.MNIST('../data',
                   train=False, 
                   transform=transforms.Compose([
                       transforms.ToTensor(),
                       transforms.Normalize((0.1307,), (0.3081,))
                   ])),
    batch_size=BATCH_SIZE, shuffle=True)

MNIST 데이터는 DataLoader로 불러왔습니다. 데이터는 (128,1,28,28) 형태로 149개로 분리되어 있습니다. 테스트 데이터 역시 마찬가지입니다. 128은 배치사이즈, 1은 채널 사이즈, (28*28)은 이미지의 크기입니다.

본 테스트에서는 전체 149개 데이터를 모두 학습하지 않고 1세트만 301회 학습을 수행합니다. 더 높은 정확도 얻고자 하시는 분은 전체 데이터를 통해 더 많은 학습을 해보시기 바랍니다.

dataset = next(iter(train_loader)) # 학습 데이터
x_data = dataset[0] # x 데이터
y_data = dataset[1] # label 데이터

dataset = next(iter(test_loader)) # 검증 데이터
x_test = dataset[0].to(DEVICE) # x 데이터
y_test = dataset[1].to(DEVICE) # label 데이터

각 데이터 셋에 어떤 이미지가 있는지 확인해보기 위해서 아래와 같은 코드를 수행합니다. MNIST 데이터 셋은 동일한 크기의 손글씨 이미지가 들어있기 때문에 각각의 이미지를 표시해보면 0-9까지의 손글씨 이미지가 저장되어 있는 것을 확인 할 수 있습니다.
아래와 같이 0번째 배열에 숫자 5가 있는 것을 확인 할 수 있습니다.

plt.imshow(x_data[0,0,:])

참고로 CNN의 입력 데이터의 Shape은 아래의 그림과 같습니다. 입력 데이터는 4개의 차원으로 구성되어 있습니다. 가장 먼저는 Batch_Size로 해당 이미지의 갯수를 의미합니다. 다음에 나오는 것은 이미지의 Channel입니다. 3인경우는 RGB 값을 가지고 1인 경우는 대부분 단일 색상으로 표현하는 값입니다. 그리고 나오는 값은 Height, Width 값입니다.

이런 상태에서 [0,0,:]의 의미는 0번째 이미지에서 첫번째 채널의 이미지 데이터를 가지고 온다는 의미가 됩니다.

이제 학습을 위한 모델을 구성합니다. 본 모델을 크게 두부분으로 되어 있습니다. self.convs는 convolution을 수행하는 부분으로 원본 이미지에서 특징정보를 추출하는 부분입니다. 이때 중요한 것은 각 Conv2d를 수행하며 어떤 형태의 아웃풋이 나오는지 확인하는 것이 중요합니다.

예를 들어 28*28 이미지를 kernel_size 3으로 계산하면 출력되는 이미지 사이즈는 (26*26)입니다. 어떻게 이렇게 나오는지는 아래 식에서 확인하실 수 있습니다.

https://pytorch.org/docs/stable/generated/torch.nn.Conv2d.html

하지만 매번 위의 식을 통해서 계산하는 것은 좀 귀찮고 힘든 입니다. 위의 공식을 간단한 함수로 구현한 내용을 공유해드립니다.

def conv_output_shape(h_w, kernel_size=1, stride=1, pad=0, dilation=1):
    from math import floor
    if type(kernel_size) is not tuple:
        kernel_size = (kernel_size, kernel_size)
    h = floor( ((h_w[0] + (2 * pad) - ( dilation * (kernel_size[0] - 1) ) - 1 )/ stride) + 1)
    w = floor( ((h_w[1] + (2 * pad) - ( dilation * (kernel_size[1] - 1) ) - 1 )/ stride) + 1)
    return h, w

위와 같은 식을 거쳐서 self.convs 레이어의 최종 output_shape은 총 5*5 이미지 사이즈를 가진 40장의 이미지 데이터를 얻을 수 있습니다.

이렇게 얻은 데이터는 self.layers를 거치다 보면 최종 0~9까지의 숫자 정보를 얻을 수 있습니다.

PyTorch의 Conv2d 패키지는 프로그래머가 간단히 Convolution Layer를 구성할 수 있도록 해줍니다. 프로그래머는 간단히 입력 채널의 수와 출력 채널의 수 그리고 커널 사이즈와 스트라이드 정보만 맞춰주면 자동으로 이미지를 구성해줍니다.

아래의 경우는 최초 28×28 이미지를 입력하고 커널을 3으로 맞춰서 앞선 함수를 통해서 출력 shape을 보면 26×26의 이미지를 출력한다는 것을 확인 할 수 있습니다. 또 다음 레이어는 커널을 3, 스트라이드를 2로 정의하고 이전에 입력된 이미지의 크기를 입력하면 출력 이미지는 12×12로 표시되는 것을 확인 할 수 있습니다. 이런 방법으로 마지막 이미지가 출력되는 크기는 5×5의 이미지가 됩니다.

그렇게 되면 마지막의 fully-connected layer에 들어가는 값은 40×5×5의 입력 값이 됩니다. 그리고 맨 마지막까지 Linear Layer를 거치게 되면 10개의 값으로 출력되고 이에 Softmax를 취하면 0-9 중에 하나의 값을 예측하게 됩니다.

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        
        self.convs = nn.Sequential(
            nn.Conv2d(1, 10, kernel_size=3), # input_channel, output_channel, kernel_size
            nn.ReLU(),
            nn.BatchNorm2d(10),
            nn.Conv2d(10, 20, kernel_size=3, stride=2),
            nn.ReLU(),
            nn.BatchNorm2d(20),
            nn.Conv2d(20, 40, kernel_size=3, stride=2)
        )
        
        self.layers = nn.Sequential(
            nn.Linear(40*5*5, 500),
            nn.ReLU(),
            nn.BatchNorm1d(500),
            nn.Linear(500,250),
            nn.Linear(250,100),
            nn.ReLU(),
            nn.BatchNorm1d(100),
            nn.Linear(100,50),
            nn.Linear(50, 10),
            nn.Softmax(dim=-1)
        )

    def forward(self, x):
        x = self.convs(x)
        x = x.view(-1, 40*5*5)
        return self.layers(x)
    
cnn = Net().to(DEVICE)

이제 학습을 위한 모든 준비가 완료되었고 아래와 같이 학습을 수행합니다. 예제에서는 간단히 301회 학습을 수행했습니다. 학습이 진행되면서 loss와 accuracy 정보의 변화를 기록하기 위해서 list 변수를 각각 선언해줍니다.

또 100회 학습이 완료될 때마다 변화되는 loss와 accuracy 값을 화면에 출력해줍니다.

optimizer = optim.Adam(cnn.parameters())
criterion = nn.CrossEntropyLoss()

hist_loss = []
hist_accr = []

epochs = 301
for epoch in range(epochs):
    cnn.train()
    output = cnn(x_data)
    loss = criterion(output, y_data)
    
    predict = torch.argmax(output, dim=-1) == y_data
    accuracy = predict.float().mean().item()
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    hist_loss.append(loss)
    hist_accr.append(accuracy)
    
    if epoch % 100 == 0:
        print('epoch{}, {:.5f}, {:.5f}'.format(epoch, loss.item(), accuracy))

학습이 완료되고 loss와 accuracy 값을 그래프로 그려줍니다.
그래프를 보니 학습의 곡선이 완만하게 내려가고 정확도는 1에 가까운 값을 나타내어 학습이 잘이뤄지는 것을 확인할 수 있습니다.

그러나 training 데이터를 통한 학습정확도이기 때문에 검증용 데이터를 통해서 정확도 계산을 다시 할 필요가 있습니다.

fig, ax = plt.subplots(2,1)
fig.set_size_inches((12,8))

ax[0].set_title('Loss')
ax[0].plot(hist_loss, color='red')
ax[0].set_ylabel('Loss')
ax[1].set_title('Accuracy')
ax[1].plot(hist_accr, color='blue')
ax[1].set_ylabel('Accuracy')
ax[1].set_xlabel('Epochs')

검증용 데이터셋(test_dataloader)를 통해서 한 배치 정보를 얻어서 방금 수행한 모델의 정확도를 테스트해봅니다. 테스트 결과 0.7109375 값을 얻을 수 있었습니다. 높은 값은 아니지만 전체 469개 미니배치 중에서 1개 데이터셋만 테스트 했기 때문에 전체 데이터를 대상으로 테스트하면 보다 높은 정확도를 얻을 수 있을 것입니다.

cnn.eval()

with torch.no_grad():
    output = cnn(x_test)
    loss = criterion(output, y_test)
    
    predict = torch.argmax(output, dim=-1) == y_test
    accuracy =  predict.float().mean().item()
    
    print(accuracy)