본문 바로가기

Programming Project/Pytorch Tutorials

Pytorch 머신러닝 튜토리얼 강의 12 (RNN 1 - Basics)


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

2018/07/02 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 1 (Overview)

2018/07/02 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 2 (Linear Model)

2018/07/03 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 3 (Gradient Descent)

2018/07/03 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 4 (Back-propagation and Autograd)

2018/07/07 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 5 (Linear Regression in the PyTorch way)

2018/07/09 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 6 (Logistic Regression)

2018/07/10 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 7 (Wide and Deep)

2018/07/11 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 8 (PyTorch DataLoader)

2018/07/11 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 9 (Softmax Classifier)

2018/07/13 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 10 (Basic CNN)

2018/07/14 - [Programming Project/Pytorch Tutorials] - Pytorch 머신러닝 튜토리얼 강의 11 (Advanced CNN)


이번 강의에서는 RNN에 대해서 알아보도록 하겠습니다.




우리는 Deep Neural Network 에 대해서 배웠습니다.



DNN은 입력이 주어지면, 그것을 네트워크 레이어를 통해서 앞으로 쭉쭉 곱하면서 숫자들을 보내는 과정을 통해 이루어졌습니다.


또한 Convolutional neural network에 대해서도 배웠는데, 



cnn은 이전 layer의 전체에서 weight를 곱하지 않고, 인접한 부분끼리 추출하여 새로운 feature을 만들어내는 과정을 거치므로, 이미지 분석 등에서 굉장히 효율적인 결과를 가져왔습니다.





RNN


Recurrent Neural Network 는 여전히 x를 input으로 받아서 우리의 예측 h를 output으로 내보냅니다.


하지만 RNN은 조금 다르게 설계된 부분이 있습니다.


CNN은 지역적으로 인접한 input이 의미를 가지는 것에 대해 처리하기 위한 모델이었다면, RNN은 연속적인 input에 대한 것을 처리하기 위한 모델이라고 할 수 있습니다.,


예를 들어, 글을 분석하는 행위는 단어 자체만 읽어서는 충분하지 않고, 이전 단어들을 읽어낸 '맥락'을 필요로 합니다.


또한 음악을 만드는 행위는 이전 음악이 어떤 느낌으로 흘러들어왔는지 알아야 그 이후의 음악을 써나갈 수 있습니다.







그런 이유로 RNN은 모델에서 이전의 input 상태에서 다음의 input 상태에 어떤 값을 전달합니다.


그림에서와 같이 연속적인 x1 , x2 , x3.... 으로 들어오는 어떤 input에 대해서, 모델은 x1 x2만 입력으로 받는것이 아니라, 모델이 만들어낸 이전시간에 대한 상태(state) vector또한 받게 됩니다.



그리고, RNN이란 말은 이전 state에서 나온 vector을 다음 state에 전달하는 모양인 모델을 의미하지만, 디테일한 부분은 얼마든지 다양하게 구성할 수 있습니다.




RNN모델은 입력과 출력 형태로 구분할 수 있습니다.


그림과 함께 예시를 통해 살펴보겠습니다.




one to many -> 하나의 input(예를 들어 이미지)를 받아서 여러 output(예를 들어 이미지에 대한 설명? 문장?)을 내놓는 모델이라던가


many to one -> 문장 같은것을 입력받아, 그 문장이 부정적인지 긍정적인지 등에 대한 것을 내놓는 모델이라던가


many to many -> 한글 입력을 받아 영어로 번역을 해준다던가


등의 모델들로 구분할 수 있을 것입니다.








RNN Inside


기본적인 RNN을 조금 더 들어가서 살펴보도록 합시다.


그림은 기본적인 RNN의 모양을 보여주고 있는데요,


Xt는 시간 t에 대한 input x라고 생각하시면 됩니다.

Ht는 시간 t에 대한 output이라고 생각하시면 되구요.


중요한것은, RNN은 이전에 모델이 output으로 넘겨준 'status output'과 시간 t의 'input'을 모두 받아서, tanh 등의 함수를 거치게 하고, 그 결과로 새로운 output을 만들어냄과 동시에 다음 state로 새로 넘겨줄 status output을 만들어냅니다.


입력 : 시간 t-1의 (status output) , 시간 t의 (x)

출력 : 시간 t의 (status output) , 시간 t+1의 (x)


저 그림에서의 A라고 네모 쳐져 있는 부분을 RNN Cell이라고 보통 부르고, 저 Cell을 어떤 식으로 만들었냐에 따라서 RNN의 종류가 달라지게 됩니다.

위 RNN Cell 그림에서 보면 두개의 입력을 합쳐 tanh 라는 함수를 거쳐서 다시 output을 만들어 내고 있습니다.


(RNN, GRU, LSTM 등등의 Cell 종류가 있습니다.)




지금까지 RNN에 대한 것을 개괄적으로 설명했지만, 더욱 디테일한 설명은 당장 우리가 코딩함에 있어서 크게 중요하지 않습니다.


바로 코드로 들어가 봅시다.






RNN in PtTorch


파이토치에서는 그 내부적인 RNN Cell이 어떻게 이루어져 있는지 알 필요 없이 이미 구현된 다양한 RNN Cell들을 제공하고 있습니다.


다음 예시 코드를 봅시다.



RNN GRU LSTM 다 각각 Cell의 한 종류고, 


input size는 몇개의 input을 한번에 받을 것인지, hidden size는 몇개의 output을 내보낼 것인지를 의미합니다.


batch_first 옵션은 우리의 input data shape가 어떻게 주어지느냐를 설명해주는 옵션인데, 나중에 입력을 할 때 필요한 옵션이므로 궁금하시다면 아래의 설명을 참고하시기 바랍니다. (batch를 먼저 순서로 줄 것이다 라는 뜻이어요)


If your input data is of shape (seq_len, batch_size, features) then you don’t need batch_first=Trueand your LSTM will give output of shape (seq_len, batch_size, hidden_size).


If your input data is of shape (batch_size, seq_len, features) then you need batch_first=True and your LSTM will give output of shape (batch_size, seq_len, hidden_size).

(참고 : https://discuss.pytorch.org/t/could-someone-explain-batch-first-true-in-lstm/15402)




자 그렇다면


cell = nn.RNN(input_size = 4 , hidden_size = 2 , batch_first = True)


인 상태에서 cell에 어떻게 input을 건네줘야 output을 뱉을까요?


우리가 batch_first option을 True로 주었으므로


inputs = (batch_size * seq_len * input_size ) 인 행렬

hidden = (num_layers * batch_size * hidden_size ) 인 행렬


으로 만들어놓고 


out, hidden = cell(inputs , hidden) 으로 놓으면 out변수와 hidden 변수에 cell에서 연산을 수행한 결과를 내놓게 됩니다.


out은 우리가 정답으로 예측한 output이 될 것이고, hidden은 다음 cell에 넘겨줄 hidden 이 될 것입니다.


굳이 풀어 쓰자면 이런 식?


out1, hidden1 = cell(inputs , hidden0)

out2, hidden2 = cell(inputs , hidden1)

out3, hidden3 = cell(inputs , hidden2)



조금 더 풀어서 설명하자면 


inputs의 각 차원은



batch_size만큼, 연속된 input 숫자만큼, RNN에 들어갈 vector size만큼 이 되고


hidden의 각 차원은


layer의 개수 만큼(나중에 더 설명합니다 안 배운 개념이고 일단은 1이라고 해두죠), batch size만큼, hidden state의 차원만큼인데


애매한 부분은 차차 더 알아보도록 하죠






그럼 한번 이번에는 RNN으로 h,e,l,l,o 한 글자씩 넣었을 때 결국 h,e,l,l,o 라는 단어를 예상할 수 있도록 학습해보도록 합시다.


(그러니까 h를 넣으면 e를 예측하고 h , e 다음엔 l이 올걸 예측하고 h e l 다음에는 l 이 올걸 예측하고 h e l l 다음앤 o가 올걸 예측하게 만들어봅시다! ) 


일단 이걸 처리하기 위해서 글자에 대해서 one hot encoding을 합시다.


그 결과는 이렇게 되겠죠


h = [1,0,0,0]

e = [0,1,0,0]

l = [0,0,1,0]

o = [0,0,0,1]




일단 input-dimension (input size)가 4라고 해야 하겠습니다. 왜냐하면 helo라는 네개의 one hot 데이터가 한 input으로 들어오기 때문입니다.


그리고 hidden size가 2라고 해놨기 떄문에 output은 2개의 숫자를 뱉어낼 것입니다.


이 두개의 숫자는 아직 어떤 단어를 의미하지는 않지만, 곧 그것도 처리해볼 것이구요


여튼 일단 두 개의 숫자를 (hidden size와 같은) output으로 뱉어낼 것입니다.




그리고 아래에는 전체적인 input / output flow와 더불어서 sequence length (연숙된 input개수) 에 대해서 조금더 잘 알아볼 수 있는 그림이 있는데요




여기에 있는 이 그림이 조금 더 자세하게 설명해주고 있습니다.


일단 맨 아랫줄의 (1,5,4) shape의 matrix를 input으로 줍니다.


1개의 데이터, 5개의 sequence length, 4개의 알파벳 종류라는 뜻이 됩니다.


그러면 hidden_size = 2이기 때문에 1,5,2가 output으로 나오겠죠


이 때 중요한것은, 파이토치는 미리 seq_len이라는 파라미터로 입력할 값이 'hello'라면 다섯글자를 연속적으로 이어붙일 것이라는 것을 알려주는 식으로 한번에 총 몇 번의 cell 연산이 반복되는지 먼저 알려주도록 설계하게 되어있습니다.




또한 한번에 한개의 input만 넣으면 batch 단위로 output을 받아올 수가 없죠.




그래서 다음과 같이 batch_size또한 지정해 주고 쭉 프로그래밍 하게 되면 한번에 batch_size만큼의 입력 데이터를 넣고, 한번에 batch_size 만큼의 output을 얻을 수 있게 됩니다.






그렇다면 실제로 동작하는 모델을 학습시켜 볼까요?



단어 한글자씩 넣어주는 과정에서 hihello라는 문장을 예측하는 모델을 만들어 보도록 할 것인데요.


조금 더 정확히 말하자면 hihell에서 ihello가 나와야 되겠네요! 아래 그림을 조금 더 참고하시죠.



저희가 여기서 주의깊게 봐야할 부분은


h,i,e,l,o 를 각각 one hot vector로 인코딩 했다는 점입니다.

[10000] = h

[01000] = i

[00100] = e

[00010] = l

[00001] = o

이렇게 되는 것이에요.


그리고 그런 상태에서 우리 원하는 input dimension 은 5가 되겠고

output dimension또한 5가 되겠죠? (input output각각 단어 하나씩 예측해야 하므로)



 



In Code


그럼 한번 코드로 여기까지의 내용을 간략하게 정리 해 봅시다.


우선 우리는 Loss를 어떻게 정의 할 것인지 디자인 해야 합니다.


Loss는 몇차원인지, 또 어떤 수를 넘겨줄 것인지 등을 정해야 합니다.




한번 맞춰봅시다 저 위 그림에서의 output Y는 어떤 shape의 행렬일까요? N x ????







바로 N x 5 행렬입니다. 5개의 알파벳에 대한 one hot vector을 사용할 것이기 때문입니다.


loss는 그냥 멀티 레이블 classification이기 때문에 CrossEntropyLoss를 사용하도록 하겠습니다.


일단 당연히, 먼저 선언 부분입니다.

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



그리고 device 설정을 해 준 후, 전체적인 설정들을 정의해줄 변수들을 선언합니다.


(class는 5개이고, input size는 몇개이고 하는 것들을요.)


이것을 위에서 변수로 선언해주면 나중에 전체적으로 수정할 때 하나의 변수만 바꾸면 나머지 모든 것들이 한번에 바뀌게 되므로 비교적 이렇게 먼저 정의해주는게 편리하다고 할 수 있습니다.

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

num_classes = 5
input_size = 5 # one-hot size
hidden_size = 5 #output from the cell. 바로 결과 예측을 위해서 5로 설정
batch_size = 1 # sentence의 개수 (단어개수)
sequence_length = 1 # 이번에는 한번에 하나씩 해본다
num_layers = 1 # one layer rnn이다. (아직 안 다룬 개념)






그리고 input x와 output y를 정의 해 줍니다.

x를 일일히 배열로 써주는 방법과,

주석으로 조금 더 섹시하다고 써놨는데, 아래의 list comprehension 방법으로 조금 더 편하게 입력하는 방법이 있습니다. 그냥 기호의 차이입니다. 결과는 같습니다.

idx2char = ['h','i','e','l','o']

x_data = [0,1,0,2,3,3] #hihell

x_one_hot = [[[1,0,0,0,0], #h
             [0,1,0,0,0], #i
             [1,0,0,0,0], #h
             [0,0,1,0,0], #e
             [0,0,0,1,0], #l
             [0,0,0,1,0]]] #l

#이라고 해도 되지만 아래 방법이 조금 더 섹시

one_hot_lookup = [[1,0,0,0,0], #h
                     [0,1,0,0,0],
                     [0,0,1,0,0],
                     [0,0,0,1,0],
                     [0,0,0,0,1]]

x_one_hot = [one_hot_lookup[x] for x in x_data]

y_data = [1,0,2,3,3,4] #ihello


inputs = torch.Tensor(x_one_hot)
labels = torch.LongTensor(y_data)





그리고 우리의 Model을 정의하는 부분입니다.

대략적인 설명은 주석에 달아놓았는데요, 일단 init에서는 rnn을 초기화 해주고 cell 설정을 미리 위에서 선언 해 놓은 변수들의 값으로 대입 해 줍니다.

forward에서는 x와 hidden state를 입력으로 받아서 rnn cell에 넘겨주고 그 결과를 다시 return해주게 되구요.

init_hidden에서는 모델을 처음 시작할 때 (아직 한번도 실행하지 않았을 때), hidden으로 넣을 수 있는 zero 배열을 리턴 해 줍니다.
class Model(nn.Module):
    def __init__(self):
        super(Model,self).__init__()
        self.rnn=nn.RNN(input_size = input_size,hidden_size=hidden_size,batch_first=True)
        
    def forward(self,x,hidden):
        #input x 를 (batch_size,sequence_length,input_size)로 reshape함
        #just for make sure이라고 하심.
        x=x.view(batch_size,sequence_length,input_size)
        
        #Propagate input through RNN
        #Input:(batch,seq_len,input_size)
        out,hidden = self.rnn(x,hidden)
        
        #for make sure, output이 N * 5 shape을 따르게 하기 위해서
        out = out.view(-1,num_classes)
        return hidden,out
    
    def init_hidden(self):
        #initialize hidden and cell states
        return torch.zeros(num_layers,batch_size,hidden_size).to(device)



그리고 그 모델을 돌리게 되는데요, 

#1 : CELoss를 쓰고, optimizer로는 Adam을 쓰는데 그냥 옵티마이저의 한 종류라고 생각하시면 됩니다.

#2 : hidden에 아까 선언해준 0으로 이루어진 배열을 넘겨줍니다.

#3 : model에 data와 hidden을 넘겨 준 후 loss를 구합니다. output.max(1)은 우리가 무엇을 예측했는지 로그로 찍어보기 위해서 쓴 건데요, 딱히 중요한건 아니니 자세한건 한번 구글링해보시기 바랍니다.

model = Model().to(device)


criterion = nn.CrossEntropyLoss() #1
optimizer = optim.Adam(model.parameters(),lr = 0.1)


for epoch in range(100):
    optimizer.zero_grad()
    loss = 0
    hidden = model.init_hidden() #2

    print("predicted string : ",end="")

    for input,label in zip(inputs,labels): #3
        inputs = inputs.to(device)
        labels = labels.to(device)
        labels = labels.view(-1,1)
        hidden,output = model(input,hidden)
        val,idx = output.max(1)
        print(idx2char[idx.data[0]],end="")
        loss += criterion(output,label)

    print(", epoch: %d, loss: %1.3f" % (epoch+1,loss.data[0]))
    loss.backward()
    optimizer.step()






그러면 output이 


predicted string : iloioi, epoch: 1, loss: 9.909
predicted string : ileoll, epoch: 2, loss: 8.719
predicted string : ilelll, epoch: 3, loss: 8.043
predicted string : ilelll, epoch: 4, loss: 7.418
predicted string : ilelll, epoch: 5, loss: 6.854
predicted string : ililll, epoch: 6, loss: 6.364
predicted string : ihilll, epoch: 7, loss: 5.936
predicted string : ihilll, epoch: 8, loss: 5.573
predicted string : ihillo, epoch: 9, loss: 5.228
predicted string : ihillo, epoch: 10, loss: 4.896
predicted string : ihillo, epoch: 11, loss: 4.624
predicted string : ihillo, epoch: 12, loss: 4.417
predicted string : ihillo, epoch: 13, loss: 4.269
predicted string : ihillo, epoch: 14, loss: 4.170
predicted string : ihillo, epoch: 15, loss: 4.094
predicted string : ihillo, epoch: 16, loss: 4.026
predicted string : ihillo, epoch: 17, loss: 3.961


이런식으로 주르르 나오게 됩니다.


결국 ihillo를 학습하는 것을 볼 수 있습니다.




일단 이정도로도 충분히 좋습니다


그렇지만 for loop을 쓰지 않고 한번에 모든 input을 넣어서 output을 결과로 받아본다면 더욱 빠르게 연산이 가능할 것 같습니다.




그럼 일단 sequence length가 hihell -> ihello 로 6개가 될 것이므로 6으로 바꿔줍시다.

sequence_length = 6 

그리고 model의 forward부분도 더이상 hidden을 return 할 필요가 없게 되었습니다.


    def forward(self,x,hidden):
        #input x 를 (batch_size,sequence_length,input_size)로 reshape함
        #just for make sure이라고 하심.
        x=x.view(batch_size,sequence_length,input_size)
        
        #Propagate input through RNN
        #Input:(batch,seq_len,input_size)
        out,hidden = self.rnn(x,hidden)
        
        #for make sure, output이 N * 5 shape을 따르게 하기 위해서
        out = out.view(-1,num_classes)
        return out

그렇게 조금 바꾸고 나서, outputs = model(inputs,hidden)으로 몽땅 넣어주기만 하면 됩니다. 그럼 6개를 몽땅 output으로 뱉어내게 되죠.


그럼 그걸 또 몽땅 CELoss로 넘겨 계산해주면 되는데요, 한번 코드로 자세히 보시기 바랍니다.


model = Model().to(device)


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


for epoch in range(100):
    optimizer.zero_grad()
    loss = 0
    hidden = model.init_hidden()

#     print("predicted string : ",end="")

    inputs = inputs.to(device)
    labels = labels.to(device)
    
    outputs = model(inputs,hidden)
    

    loss = criterion(outputs,labels)

    _, idx = outputs.max(1)

    result_str = [idx2char[c] for c in idx.squeeze()]
    
    print("epoch: %d, loss: %1.3f" % (epoch+1,loss.data[0]))
    print("Predicted string: ", ''.join(result_str))
    loss.backward()
    optimizer.step()


epoch: 1, loss: 1.619
Predicted string:  eiehee
epoch: 2, loss: 1.378
Predicted string:  elelel
epoch: 3, loss: 1.248
Predicted string:  ilelel
epoch: 4, loss: 1.121
Predicted string:  ilelll
epoch: 5, loss: 1.007
Predicted string:  ilelll
epoch: 6, loss: 0.907
Predicted string:  ilello
epoch: 7, loss: 0.828
Predicted string:  ilello
epoch: 8, loss: 0.782
Predicted string:  ihello
epoch: 9, loss: 0.751
Predicted string:  ihello
epoch: 10, loss: 0.722
Predicted string:  ihello
epoch: 11, loss: 0.692
Predicted string:  ihello
epoch: 12, loss: 0.672
Predicted string:  iheloo
epoch: 13, loss: 0.658
Predicted string:  iheloo
epoch: 14, loss: 0.640
Predicted string:  iheloo
epoch: 15, loss: 0.621
Predicted string:  iheloo
epoch: 16, loss: 0.606
Predicted string:  ihello
epoch: 17, loss: 0.594
Predicted string:  ihello
epoch: 18, loss: 0.581
Predicted string:  ihello
epoch: 19, loss: 0.569
Predicted string:  ihello
epoch: 20, loss: 0.557
Predicted string:  ihello
epoch: 21, loss: 0.546
Predicted string:  ihello
epoch: 22, loss: 0.537
Predicted string:  ihello
epoch: 23, loss: 0.529
Predicted string:  ihello
epoch: 24, loss: 0.526
Predicted string:  ihello
epoch: 25, loss: 0.520
Predicted string:  ihello


이런 식으로 잘 되네요!




만약 그냥 주어진 하나의 input에 대해 그 다음 output을 내뱉는 일반적인 softmax classifier을 프로그래밍 했다면 h에 대해서 i를 output으로 줄지, e를 output으로 줄지 잘 몰랐을 것입니다.


또, l에 대해서는 l을 output으로 줄 지, o를 output으로 줄 지 몰랐겠죠.


RNN이니까 잘 해결했던 것입니다.




한번 과제로서, RNN의 맨 위 부분에 linear model을 추가해서 더해 봅시다.


많이 쓰이는 방식이고, 모델의 수렴 또한 빨리 되는것을 알 수 있습니다.



RNN의 output에 linear model을 더하고 마지막에 Softmax를 걸어주는 것이죠.











-


Embedding



자 여기까지 오셨다면 조금 더 고오오급진 방법을 사용해봅시다.



지금까지는 one hot 인코딩만을 사용했습니다.


하지만 one hot보다 조금 더 나을 수 있는 방법에 대해서 소개하려고 합니다.






one hot은 일종의, 어떻게 주어진 자료를 인코딩할지 정해진 상태라고 볼 수 있습니다.


자료 종류(class) 만큼의 길이의 벡터를 가지고 각각 한 자리씩 나눠주는 것이죠.


하지만 임베딩은 자료 각각을 우리가 정한 n차원의 벡터공간의 적당한 위치에 대응시킵니다.


이 대응되는 위치 (숫자) 는 학습해서 얻어지게 되는 숫자이고, 결국 비슷한 class끼리는 가까운 벡터공간에, 서로 다른 class끼리는 먼 벡터공간에 위치하게 됩니다.





이것을 구현하는것은 정말 쉬운데 nn.Embedding 이라는 것을 써서 하면 됩니다.


함수 안에는 (vocab_size , output_size)를 넣어 주시면 되는데, 입력되는 class의 개수와 그것을 몇차원 벡터공간에 대응시킬 것인지에 대한 것입니다.


그리고 x에 대한 그 임베딩된 결과는 self.embeddings(x)로 받아볼 수 있습니다.




이걸 이용하게 되면 다음과 같이 모델을 조금 더 발전시켜서 구현할 수 있게 됩니다.


x를 넣기 전에 임베딩 시켜서 넣게 되는 것이죠.









마지막으로 RNN / LSTM에 대한 구체적인 설명이 조금 더 보고싶으신 분은 아래의 링크를 참조하시기 바랍니다.


https://ratsgo.github.io/natural%20language%20processing/2017/03/09/rnnlstm/




GRU는 LSTM과 비슷..하다고 보시면 될것...입니다.