Naver Ai Boostcamp

[DAY 17] RNN & LSTM & GRU

잡담연구소 2021. 2. 17. 02:52

오늘 학습 내용은 14일 학습내용과 겹치는 게 많아 아주 조금 14일 정리에 추가했다.

실습시간에 아주 간단하게 토픽만 얘기하고 넘어간 lstm을 통한 감정분석을 따로 구현해보았다.

 

실습에서는 너무 겉핥기 식?으로 다루는 거 같아 RNN을 통한 classfication 예제를 찾아 구현해보았다.

3분 파이토치 June님의 블로그 를 참고했다. 

 

LSTM을 이용해 IMDB 데이터셋을 감정분석하는 프로젝트라고 말하기엔 부끄러운 소소한 작업이다.

영화리뷰 텍스트를 LSTM을 통해 압축시키고, 압축된 리뷰가 긍정인지 부정인지 판단한다. 

 

0. 준비

우선 필요한 패키지들을 import 해온다.

cnn에서 torchvision을 통해 이미지를 처리하는 dataloader등을 불러온 것과 같이 torchtext를 통해 data, datasets라는 중요한 모듈을 불러온다.

from tqdm import tqdm
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchtext import data, datasets
import random
import timeit

random seed를 고정하고 gpu사용을 대비해 device를 정의해준다. 

#SEED 고정 및 DEVICE 설정 
Seed = 77
random.seed(Seed)
torch.manual_seed(Seed)

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

 

1. 데이터 준비하기 

Field란 텐서로 표현 될 수 있는 텍스트 데이터 타입을 처리한다.

IMDB데이터는 문장과 긍/부정을 나타내는 감정으로 이루어져있기 때문에 TEXT, LABEL이란 Field를 만들어준다.

Field에는 타입 지정 , 길이제한 등 여러가지 기능이 있다. 

나는 연속적인 데이터라는 것을 의미하는 sequential과 batch가 맨 앞인 상태로 output 해주는 batch_first와 모두 소문자로 만들어주는 lower을 적용했다. 이렇게 하지 않으면 "word"와 "Word"를 서로 다른 단어로 인식하게 된다. 

그 후 datasets.MNIST와 똑같이 datasets.IMDB를 통해 IMDB데이터를 다운받고 클래스 내 splits이라는 함수를 통해 

trainset , testset으로 나눠준다. 

TEXT = data.Field(sequential=True, batch_first=True, lower=True)
LABEL = data.Field(sequential=False, batch_first=True)

trainset , testset = datasets.IMDB.splits(TEXT,LABEL)

 

vars에 대한 설명은 여기에서 확인할 수 있다. 파이썬 내장함수로 dict로 이루어진 객체를 볼 수 있게 해주는...? 거 같다. 

label - text 쌍으로 이루어져있다. 

vars(trainset.examples[0])
{'label': 'pos', 'text': ['the', 'film', 'is', 'exceptional', 'in', 'its', 'gay', 'iconography', 'and', 'extends', 'this', 'beyond', 'the', 'asthetics', 'to', 'the', 'music', 'and', 'cast', 'throughout', 'the', 'whole', 'film', 'exists', 'a', 'childlike', 'wonder', 'as', 'seen', 'through', 'the', 'eyes', 'of', 'the', 'main', 'character', 'her', 'lighthearted', 'take', 'on', 'the', 'world', 'around', 'us', 'is', 'comical', 'and', 'beautiful', 'in', 'a', 'way', 'its', 'a', 'slacker', 'movie', 'for', 'girls', 'watch', 'this', 'is', 'you', 'fancy', 'a', 'relaxing', 'entertaining', 'midnight', 'movie', 'buy', 'this', 'if', 'you', 'like', 'diferent', 'takes', 'on', 'the', 'world', 'of', 'media', 'and', 'love', 'combined', '']}

 

잘보면 필요없는 괄호라든가, 개행문자 br 등이 포함되어 있는 걸 확인할 수 있다.

큰 차이는 없을 거 같지만 전처리를 통해 제거해준다. 

trainset, testset 모두 br을 제거해주고, 특수문자(string.puctuation)에 해당하는 문자는 삭제해준다.

# 직접 padding 해주기 
import string 

# trainset
for example in trainset.examples:
  text = [c.replace("<br", "") for c in vars(example)['text']]
  text = [''.join(c for c in s if c not in string.punctuation) for s in vars(example)['text']]
  vars(example)['text'] = text

#testset
for example in testset.examples:
  text = [c.replace("<br", "") for c in vars(example)['text']]
  text = [''.join(c for c in s if c not in string.punctuation) for s in vars(example)['text']]
  vars(example)['text'] = text

 

단어 집합을 만들어 줄 차례다. 

FIELD에는 build_vocab으로 자동으로 단어 사전을 만들어주는 함수가 있다. 이걸 사용하도록 하자.

min_freq = 5로 설정해 너무 자주 나오는 단어는 생각하지 않았다.

불용어를 생략해 전처리할 수 있지만 그런 경우는 생각하지 않았다.

TEXT.build_vocab(trainset , min_freq = 5)
LABEL.build_vocab(trainset)

vocab_size = len(TEXT.vocab)
n_classes = 2
print('단어 집합의 크기 : {}'.format(vocab_size))
print('클래스의 개수 : {}'.format(n_classes))


# 단어 집합의 크기 : 32066
# 클래스의 개수 : 2

 

stoi를 통해 접근하면 너무 길이서 다 쓰진 못하겠지만 defaultdict에 해당 단어와 인덱스 번호가 저장되어있는 걸 확인할 수 있다. 

print(TEXT.vocab.stoi)
defaultdict(<function _default_unk_index at 0x7f9c76f26488>, {'<unk>': 0, '<pad>': 1, 'the': 2, 'and': 3, 'a': 4, 'of': 5, 'to': 6, 'is': 7, 'in': 8, 'it': 9, 'i': 10, 'this': 11, 'that': 12, 'br': 13, 'was': 14, 'as': 15, 'for': 16, 'with': 17, 'movie': 18, 'but': 19, 

 

이제 이 많은 데이터를 iteration을 통해 하나하나씩 꺼내다 쓰자. 

여기서 Bucketiterator라는 개념을 사용하게 된다! 

Bucketing은 주어진 문장의 길이에 따라 데이터를 그룹화하여 padding을 적용하는 기법이다.

길이가 천차만별인 데이터들을 하나의 batch내에 넣는다면 가장 큰 데이터의 길이만큼 padding이 되어야하므로 쓸데없이 0으로 차있게 돼 학습에 시간이 오래걸린다. 

하지만 Bucketing은 길이가 비슷한 데이터들끼리 하나의 batch로 만들어 padding을 최소화시킨다. 

이 기법은 모델의 학습 시간을 단축하기 위해 고안되었다.

train_iter,  test_iter = data.BucketIterator.splits(
        (trainset,  testset), batch_size=64,
        shuffle=True, repeat=False)

 

2. 모델 생성

 

이제 모델을 만들어보자.

LSTM을 사용해 모델을 만들었다. 여기서 꽤나 애를 먹었는데 잘 기억은 안나지만 공유해보면 

 

1. h_0 = torch.zeros((self.n_layers)) 와 같이 쓰게 되면 아래와 같은 오류가 뜨게 된다. 

RuntimeError: Input and hidden tensors are not at the same device, found

hidden tensor인 h와 c도 to(device)로 gpu에서 실행 될 수 있게 ? 만들어주어야한다.

 

2. parameter의 size 맞추기는 항상 힘들다,,,

layer=1일 때는 h_n.squeeze(0)을 self.out에 넣어주면 아무 문제 없지만, layer가 2 이상인 경우부터 문제가 생긴다.

h_n은 [n_layer * dir , batch_size , hidden_dim] 이런 사이즈를 가진다. 

이를 2차원 벡터 [batch_size , n_layer * dir * hidden_dim]로 만들어주어야 Fully connected layer에 넣을 수 있다.

항상 모델을 짤 때는 파라미터의 사이즈, 인풋, 아웃풋의 사이즈를 고려하도록 하자. 

한줄 한줄 출력해보거나 파라미터의 개수를 출력해보는 게 좋은 방법인 거 같다. 

 

class customLSTM(nn.Module):
  def __init__(self,n_layers,hidden_size ,n_vocab,embedding_size,n_classes,num_dirs=1,dropout=0.5):
    super(customLSTM,self).__init__()

    self.n_layers = n_layers
    self.num_dirs = num_dirs
    self.hidden_size = hidden_size
    self.embedding_size = embedding_size
    self.embed = nn.Embedding(vocab_size, embedding_size)
    self.lstm = nn.LSTM(
    input_size=embedding_size,
    hidden_size=hidden_size,
    num_layers=n_layers,
    batch_first= True,
    bidirectional=True if num_dirs > 1 else False
)
    self.out = nn.Linear(hidden_size*n_layers , n_classes)
    self.dropout = nn.Dropout(dropout)
    
  def forward(self, x):
    # I : 48 -> 512차원 벡터로 임베딩해주기
    x = self.embed(x)  # [batch size,sent_length] -> [batch size, sent_len, emb dim]
    h_0 = torch.zeros((self.n_layers * self.num_dirs, x.shape[0], self.hidden_size)).to(device)
    c_0 = torch.zeros((self.n_layers * self.num_dirs, x.shape[0], self.hidden_size)).to(device)

    hidden_states, (h_n , c_n) = self.lstm(x, (h_0, c_0))
    self.dropout(h_n)
    h_n = h_n.view(h_n.shape[1] ,-1)
    # [n_layer * dir , batch_size , hidden_dim] -> [batch_size , n_layer * dim * hidden_dim]
    logit = self.out(h_n)

    return logit

 

n_layer = 5

hidden_dim = 512

embedding_size = 256

n_classes = 2 (pos, neg)

num_dirs = 1 (단방향)

으로 하이퍼파라미터를 설정해 Model을 생성한 후 , optimizer는 Adam , loss는 CE를 사용했다.

Model = customLSTM(5, 512, vocab_size, 256, n_classes, num_dirs=1).to(device)

optm = torch.optim.Adam(Model.parameters(), lr=1e-3)
loss = nn.CrossEntropyLoss().to(device)

 

보너스로 파라미터를 출력하는 코드이다. 

import numpy as np

np.set_printoptions(precision=3) #3자리수까지 출력 
n_params = 0
for p_idx , (param_name , param) in enumerate(Model.named_parameters()):
    #gpu -> cpu , torch -> numpy , detach : 연산 기록x 
    param_numpy = param.detach().cpu().numpy()
    n_params += len(param_numpy.reshape(-1))
    print ("[%d] name:[%s] shape:[%s]."%(p_idx,param_name,param_numpy.shape))
    print ("    val:%s"%(param_numpy.reshape(-1)[:5]))
print ("Total number of parameters:[%s]."%(format(n_params,',d')))

3. 훈련시키기

훈련시키기 전에 평가 함수 먼저 작성했다.

이미지 데이터로더와는 다르게 텍스트는 리스트 내 딕셔너리형태,,,? 로 나온다. 

그래서 data_iter를 통해 한 batch를 불러내고 그 batch 내 text , label에 접근해야된다. 

 

그리고 또 pos / neg 가 1,2로 변경되어있으므로 0,1과 비교하기 위해 중간에 data.sub_를 통해 1씩 빼주어야한다. 

def func_eval(model, data_iter , device , train = True):
    model.eval()
    with torch.no_grad():
      n_total , n_correct = 0,0
      for batch in data_iter :
          text = batch.text
          # mini_batch 내 label = target label
          y_target = batch.label.to(device)
          # 1,2로 이루어져있음 
          y_target.data.sub_(1)
          # mini_batch 내 image를 model에 넣은 결과물 = 예측
          model_pred =  model(text.to(device))
          # dimension 1 기준 , 가장 큰 인덱스와 값 return 
          _, y_pred = torch.max(model_pred.data , 1)
          #tensor(n) 형식으로 뜨기 때문에 .items()
          n_correct += (y_pred == y_target).sum().item()
          n_total += len(data_iter)
      val_accr = (n_correct / n_total )

    return val_accr

train_accr = func_eval(Model,train_iter,device)
test_accr = func_eval(Model,test_iter,device)
print ("train_accr:[%.3f] test_accr:[%.3f]."%(train_accr,test_accr))

 

이제 훈련을 시켜보자. 따로 체크포인트나 가중치를 저장하지는 않았다. 

4번째 줄의 Model.train() 을 안썼더니 아래와 같은 오류가 떴다. 

"Pytorch cudnn RNN backward can only be called in training mode"

func_eval 코드에 의해 train.eval()모드가 된 후,  다시 model.train()모드가 되어야 하는데 안 쓰면 이런 오류가 난다. 

# 파라미터 초기화 후 train

start_time = timeit.default_timer()
epochs = 20
for epoch in range(epochs):
  Model.train()
  loss_val_sum = 0
  for batch in train_iter : 
      #forward path 
      y_pred = Model.forward(batch.text.to(device))
      batch.label.sub_(1)
      loss_out = loss(y_pred , batch.label.to(device))
      #update & backward
      # 미분을 통해 얻은 grad가 누적되는 것을 방지하기 위해 zero_grad 매번 실행 
      optm.zero_grad()
      #backpropagation
      loss_out.backward()
      # weight upgrade
      optm.step()
      #loss cal
      loss_val_sum += loss_out
  loss_val_avg = loss_val_sum / len(train_iter)

  #확인을 위해 중간중간 출력 
  train_accr = func_eval(Model , train_iter , device )
  test_accr = func_eval(Model , val_iter , device )
  print(f"epochs:{epoch} , loss : {loss_val_avg:.3f} , train_acc:{train_accr:.3f},test_acc:{test_accr:.3f}")

terminate_time = timeit.default_timer()
print("%f초 걸렸습니다." % (terminate_time - start_time))

 

결과는 이렇게,,,, train_acc를 구하는 곳에서 코드를 잘못 쓴 건지, 아니면 underfitting이 되는건지 

train_acc는 낮지만 , test_acc는 높은 상황이 생겨버렸다;; 

다시 고쳐보고싶지만 코랩 gpu를 다 써서 지금은 안된다ㅜㅜ 

다시 고쳐봐야지 

 

 

이거 말고도 더 해보고 싶은 task들이 있었는데 얼마 안걸릴 줄 알았는데 디버깅 하느라 시간이 너무 오래걸려서 오늘은 이만 끝내야겠다. 

'Naver Ai Boostcamp' 카테고리의 다른 글

[DAY 19] Transformer  (0) 2021.02.18
[DAY 18] Seq2seq , beam search , BLEU  (1) 2021.02.17
[DAY 16] Bag-of-Words & Word2Vec, GloVe  (0) 2021.02.15
[DAY 15] Generative model  (0) 2021.02.05
[DAY 14] Recurrent Neural Networks  (0) 2021.02.05