PyTorch LSTM 예제

이 예제는 파이토치를 활용해서 LSTM을 구현하는 예제입니다.
LSTM은 RNN(Recurrent Neural Network)의 하나로 좋은 성능을 발휘하는 모델입니다. 파이토치는 LSTM를 직관적으로 구현할 수 있도록 하는 좋은 인공지능 프레임워크입니다.

본 예제는 현재 문장을 주고 다음 문장을 예측하는 알고리즘입니다. 이러한 예제들을 활용하면 다양한 시계열 데이터를 다룰 수 있습니다. 시계열 데이터라 함은 데이터가 어떤 시간의 순서를 가진다는 것을 의미합니다.

예를 들어 한 문장의 다양한 단어들은 비록 같은 단어일지라도 앞에 오느냐 뒤에 오느냐에 따라서 그 의미가 달라지는 경우가 있습니다. 그렇기 때문에 현재 문장을 유추하기 위해서는 앞에 어떤 단어가 있는지를 알아내는 것이 중요합니다.
RNN은 이러한 예측을 가능하게 해줍니다.

RNN은 두개의 Linear 모델이 합쳐진 하나의 Activation Function입니다. 아래 그림이 이를 잘 설명하고 있습니다.

https://pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np

입력 문장은 “In the beginning God created the heavens and the earth”라는 문장입니다. x 데이터는 입력 문장이고 x 데이터를 통해 예측한 결과를 비교하기 위해서 정답 데이터셋 y를 만듭니다. y 데이터는 x 데이터의 첫번째 입력 ‘I’의 경우 ‘n’를 예측하고 ‘n’가 입력된 경우 ‘ ‘(공백) 를 시스템이 예측할 수 있도록 하기 위함입니다.

sentence = 'In the beginning God created the heavens and the earth'
x = sentence[:-1]
y = sentence[1:]

char_set = list(set(sentence))
input_size = len(char_set)
hidden_size = len(char_set)

index2char = {i:c for i, c in enumerate(char_set)}
char2index = {c:i for i, c in enumerate(char_set)}

index2char과 char2index는 각각 문자를 문자 자체로 입력하지 않고 one-hot의 형태로 입력하기 위해서 만들어준 python dict입니다.
char2index를 출력하면 아래와 같은 형태가 됩니다.

{‘s’: 0, ‘ ‘: 1, ‘t’: 2, ‘I’: 3, ‘o’: 4, ‘h’: 5, ‘e’: 6, ‘g’: 7, ‘d’: 8, ‘c’: 9, ‘b’: 10, ‘i’: 11, ‘n’: 12, ‘G’: 13, ‘v’: 14, ‘a’: 15, ‘r’: 16}

one_hot = []
for i, tkn in enumerate(x):
    one_hot.append(np.eye(len(char_set), dtype='int')[char2index[tkn]])

x_train = torch.Tensor(one_hot)
x_train = x_train.view(1,len(x),-1)

입력된 sentence는 그대로 입력값으로 사용하지 않고 one-hot 형태로 변경해서 최종 x_train 형태의 데이터를 만듭니다. 문장을 one-hot 형태로 만들기 위해서 numpy의 eye함수를 사용합니다.

print(x_train)

tensor([[[0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0.],
         [0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.] ...

x_train 데이터를 출력하면 위와 같은 형태가 만들어집니다. 위의 데이터셋은 [1, 10, 8] 형태의 3차원 데이터셋입니다. NLP 데이터를 3차원 형태의 입력 데이터셋을 가집니다. 첫번째 차원은 문장의 갯수, 두번째는 단어의 갯수, 세번째는 단어의 입력 차원입니다.

참고로 CNN의 경우에는 4차원 형태의 데이터를 가집니다.
다음으로 아래와 같이 y_data를 만들어줍니다.

# y label
y_data = [char2index[c] for c in y]
y_data = torch.Tensor(y_data)

이제 모델을 만들 차례입니다.
파이토치는 nn.Module을 사용해서 Module을 만들 수 있습니다.

class RNN(nn.Module):
    
    # (batch_size, n, ) torch already know, you don't need to let torch know
    def __init__(self,input_size, hidden_size):
        super().__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size
        
        self.rnn = nn.LSTM(
            input_size = input_size, 
            hidden_size = hidden_size, 
            num_layers = 4, 
            batch_first = True,
            bidirectional = True
        )
        
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(input_size*2, hidden_size),
        )
        
    def forward(self, x):
        y,_ = self.rnn(x)
        y = self.layers(y)
        return y
    
model = RNN(input_size, hidden_size)
model

RNN 클래스는 init, forward 함수로 구성됩니다. init함수는 LSTM 모델을 선언하는 부분과 softamx 함수를 선언하는 두부분이 있습니다. LSTM 함수는 두개의 인자값을 기본으로 받습니다. input_size, hidden_size입니다. input_size는 입력 벡터의 크기이며 hidden_size는 출력 벡터의 크기입니다. 본 예제에서는 입력 벡터와 출력 벡터가 크기가 같습니다. 배치 사이즈나 시퀀스 사이즈는 파이토치에서 자동으로 계산하기 때문에 입력할 필요가 없습니다.
num_layers는 RNN의 층을 의미합니다. 본 예제는 4개의 층으로 구성했기 때문에 num_layers를 4로 설정했습니다. 그리고 bidirectional을 True로 했기 때문에 마지막 output의 형태는 input_size*2의 형태가 됩니다.
Linear 레이어는 input_size의 차원을 줄이기 위해서 선언합니다.

또 모델을 만들면서 중요한 것은 batch_first를 True로 해줘야 한다는 것입니다. 그렇지 않으면 time-step(=sequence_length), batch_size, input_vector 의 형태가 됩니다.

선언한 모델의 정보를 출력해보면 다음과 같습니다.
RNN(
(rnn): LSTM(8, 8, num_layers=4, bidirectional=True)
(layers): Sequential(
(0): ReLU()
(1): Linear(in_features=16, out_features=8, bias=True)
)
)

# loss & optimizer setting
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())

# start training
for i in range(5000):
    model.train()
    outputs = model(x_train)
    loss = criterion(outputs.view(-1, input_size), y_data.view(-1).long())
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    if i%500 == 0:
        result = outputs.data.numpy().argmax(axis=2)
        result_str = ''.join([char_set[c] for c in np.squeeze(result)])
        print(i, "loss: ", loss.item(), "\nprediction: ", result, "\ntrue Y: ", y_data, "\nprediction str: ", result_str,"\n")

위의 코드와 같이 학습을 수행합니다.
x_train 데이터를 입력 받아 나온 결과를 y_data와 비교해서 loss를 계산하고 이 loss 값을 Back-propagation을 수행하고 Gradient를 초기화하는 과정을 반복합니다.

5000 회 학습할 경우 다음과 같이 loss가 내려가는 것을 확인 할 수 있습니다.

실제로 데이터를 돌려보면 약 1500번 정도 학습을 완료하면 입력 단어를 통해서 정확히 다음 단어를 예측하는 것을 확인할 수 있습니다.

1500 loss: 0.31987816095352173
prediction: [[12 1 2 5 6 1 10 6 7 11 12 12 11 12 7 1 13 4 8 1 9 16 6 15
2 6 8 1 2 5 6 1 5 6 15 14 6 12 0 1 15 12 8 1 2 5 6 1
6 15 16 2 5]]
true Y: tensor([12., 1., 2., 5., 6., 1., 10., 6., 7., 11., 12., 12., 11., 12.,
7., 1., 13., 4., 8., 1., 9., 16., 6., 15., 2., 6., 8., 1.,
2., 5., 6., 1., 5., 6., 15., 14., 6., 12., 0., 1., 15., 12.,
8., 1., 2., 5., 6., 1., 6., 15., 16., 2., 5.])
prediction str: n the beginning God created the heavens and the earth

fast text

fastText는 Facebook의 AI Research lab에서 만든 단어 임베딩 및 텍스트 분류 학습을위한 라이브러리입니다. 이 모델을 사용하면 단어에 대한 벡터 표현을 얻기 위해 비지도 학습 또는지도 학습 알고리즘을 만들 수 있습니다.

fastText에 대한 위키에 있는 간단한 정의입니다.

fastText is a library for learning of word embeddings and text classification created by Facebook‘s AI Research (FAIR) lab[3][4][5][6]. The model allows to create an unsupervised learning or supervised learning algorithm for obtaining vector representations for words. Facebook makes available pretrained models for 294 languages.[7] fastText uses a neural network for word embedding.

Algorithm of fasttext is based on these two papers:[8]

gensim 패키지를 활용하면 간단히 활용할 수 있습니다.
word2vec을 사용하는 것보다 더 좋은 성능을 얻을 수 있고 또 입력 시에 나온 오타도 어느 정도 해결할 수 있다고 합니다. 그 이유는 char-ngram 방식을 사용하기 때문입니다.

fastText attempts to solve this by treating each word as the aggregation of its subwords. For the sake of simplicity and language-independence, subwords are taken to be the character ngrams of the word. The vector for a word is simply taken to be the sum of all vectors of its component char-ngrams.

https://radimrehurek.com/gensim/auto_examples/tutorials/run_fasttext.html

다만 계산량이 word2vec을 사용할 때보다 많아서 시간이 더 걸립니다. 아래의 공식 문서를 참고하시기 바랍니다.

Training time for fastText is significantly higher than the Gensim version of Word2Vec (15min 42s vs 6min 42s on text8, 17 mil tokens, 5 epochs, and a vector size of 100).

https://radimrehurek.com/gensim/auto_examples/tutorials/run_fasttext.html

Test Code

테스트를 위해 인터넷에 공개 되어 있는 2018년도 데이터를 다운 받았습니다. 파일 포맷은 csv형태로 되어 있습니다.

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:]   # header 제외 #    
    return data 
    
train_data = read_data('./data/2018_simpan_newgroup.csv') 

테스트를 위해 받은 텍스트 파일의 형태는 다음과 같습니다.

[['0|이 건 심판청구는 처분청의 직권경정으로 인하여 심리일 현재 청구의 대상이 되는 처분이 존재하지 아니하므로 부적법한 청구로 판단됨|0'],
 ['1|처분청의 2016년 제2기 부가가치세 경정결정 후 청구인이 심판청구를 제기하여 2017.10.24. 이미 기각결정을 받았으므로 이 건 심판청구는 동일한 처분에 대하여 중복하여 제기된 점, 청구인은 당초 심판청구와 동일한 내용의 경정청구를 하였고, 그에 대한 처분청의 거부통지는 민원회신에 불과한 것이어서 심판청구의 대상이 되는 처분으로 볼 수 없는 점 등에 비추어 이 건 심판청구는 부적법한 청구로 판단됨|0'],
 ['2|처분청이 청구주장을 받아들여 이 건 과세처분을 직권으로 감액경정하였으므로 이 건 심판청구는 심리일 현재 불복 대상이 되는 처분이 존재하지 아니하여 부적법한 청구에 해당하는 것으로 판단됨|0'],
 ['3|쟁점건물은 종교인과 일반인을 상대로 종교서적 등을 판매하는 매장으로 사용되는 것으로 나타나고, 달리 종교용도로 직접 사용되었다고 인정할 만한 사실이 확인되지 아니하므로 처분청이 종교목적으로 직접 사용하지 아니한 것으로 보아 이 건 재산세 등을 부과한 처분은 잘못이 없다고 판단됨.|1']...

리스트 형태로 데이터가 들어오고 2372문장을 테스트로 사용합니다.

해당 문장에는 여러가지 특수기호가 있기 때문에 적절히 전처리를 해줍니다.

전처리 후에 konlpy.Okt()를 활용하여 각 문장을 형태소 단위로 나눠줍니다.

def tokenize(doc):
    s = doc[0].split('|')
    # 이부분에 특수문자 제거 등의 전처리를 해주시면 됩니다. 
    return ['/'.join(t) for t in pos_tagger.pos(s, norm=True, stem=True)]

tokens = [tokenize(row) for row in train_data]
[['이/Noun','건/Noun','심판/Noun','청구/Noun','는/Josa','처분/Noun','청/Noun','의/Josa','직권/Noun','경정/Noun','으로/Josa','인하다/Adjective','심리/Noun','일/Noun','현재/Noun','청구/Noun','의/Josa','대상/Noun','이/Josa','되다/Verb','처분/Noun','이/Josa','존재/Noun','하다/Verb','아니다/Adjective','부/Noun','적법하다/Adjective','청구/Noun','로/Josa','판단/Noun','되다/Verb']]

위와 같은 형태로 분리됩니다.

model = gensim.models.fasttext.FastText(size=100)
model.build_vocab(tokens)
model = gensim.models.fasttext.FastText(size=100)
model.build_vocab(tokens)

model.train(tokens, window=5, epochs=model.epochs, total_examples=model.corpus_count)
model.alpha -= 0.002
model.min_alpha = model.alpha

다음과 같이 수행합니다. 필요에 따라서 다양한 옵션을 사용하여 훈련을 진행하시면 됩니다. gensim 사이트에 가시면 이에 대한 내용이 설명되어 있습니다.

부동산을 입력했더니 아래와 같은 결과를 얻었습니다.

model.wv.similar_by_word('부동산/Noun')
[('취득/Noun', 0.9003169536590576),
 ('가액/Noun', 0.8994468450546265),
 ('정산/Noun', 0.8911253213882446),
 ('환산/Noun', 0.8888809084892273),
 ('연말정산/Noun', 0.8887563943862915),
 ('자산/Noun', 0.8879978656768799),
 ('재산/Noun', 0.8878995180130005),
 ('전액/Noun', 0.8871012330055237),
 ('분산/Noun', 0.8870201110839844),
 ('거액/Noun', 0.8869702816009521)]

해당 모델은 100차원으로 되어 있는데 그것은 위에 모델을 선언할 때에 size=100으로 설정했기 때문입니다.

model.wv['부동산/Noun']
array([ 0.23454253, -0.7865744 , -0.46801254, -0.11220518,  0.49738216,
        0.51051146,  0.28836748,  0.24520665, -0.2823485 ,  0.12481502,
        0.31313908,  0.09823137,  0.9331261 , -0.63185096,  0.79251087,
        0.07525934,  0.5575937 ,  0.6052933 , -0.36211282,  0.43174762,
        0.0608188 ,  0.18941545,  0.35179955, -0.43175125, -0.48578402,
        0.7635253 ,  0.19132383,  0.83176637, -0.4213232 ,  0.2916827 ,
        0.06576332,  0.03166943, -0.5215866 , -0.9714561 , -0.43011758,
        0.14605877,  0.77329254,  0.18222107,  0.5664433 ,  0.971345  ,
        0.65927994,  0.3893743 , -0.09935822,  0.2923206 ,  0.12915374,
       -0.14681472,  0.05491441, -0.27698728,  0.01709399,  0.26082256,
        0.07673132, -0.227397  , -0.15840591, -0.10292988, -0.6830837 ,
       -0.23510128,  0.6165825 ,  0.11153345, -0.4144705 ,  0.09626016,
       -0.11291514,  0.8256664 , -0.49922696,  0.26332954, -0.35839406,
        0.6881266 ,  0.6718516 ,  0.0867641 ,  0.24843903,  0.6920707 ,
        0.37919027, -0.27192804,  0.5573388 , -1.0683383 , -0.45235977,
       -0.5060888 , -0.693835  , -0.33676928,  0.5679421 , -0.4563976 ,
        0.4198934 , -0.06000128,  0.6072741 , -1.1808567 ,  0.09339973,
       -0.4496738 ,  0.02826241, -0.01418105,  0.01322413, -0.16594794,
       -0.8327613 , -0.02719802,  0.5258091 , -0.6739192 , -0.7354652 ,
       -0.6937513 , -0.28029326, -0.36118436, -0.41617483,  0.8403618 ],
      dtype=float32)

심판 판결 결과 예측 해보기

Step. 1 데이터 준비

심판문 데이터는 2018년도 심판문 중 결정요지 데이터를 활용했습니다. 해당 데이터는 조세심판원 홈페이지에 공개 되어 있어 다운로드 받을 수 있습니다.

def read_data(filename):
    with io.open(filename, 'r',encoding='utf-8') as f:
        data = [line for line in f.read().splitlines()]
        data = data[1:]
    return data 

train_data = read_data('./data/2018_simpan_newgroup.csv')

해당 파일은 “번호|심판결정요지|유형”으로 구분되어 있습니다. train_data를 읽어보면 아래와 같습니다.

train_data[0:3]
['0|이 건 심판청구는 처분청의 직권경정으로 인하여 심리일 현재 청구의 대상이 되는 처분이 존재하지 아니하므로 부적법한 청구로 판단됨|0',
 '1|처분청의 2016년 제2기 부가가치세 경정결정 후 청구인이 심판청구를 제기하여 2017.10.24. 이미 기각결정을 받았으므로 이 건 심판청구는 동일한 처분에 대하여 중복하여 제기된 점, 청구인은 당초 심판청구와 동일한 내용의 경정청구를 하였고, 그에 대한 처분청의 거부통지는 민원회신에 불과한 것이어서 심판청구의 대상이 되는 처분으로 볼 수 없는 점 등에 비추어 이 건 심판청구는 부적법한 청구로 판단됨|0',
 '2|처분청이 청구주장을 받아들여 이 건 과세처분을 직권으로 감액경정하였으므로 이 건 심판청구는 심리일 현재 불복 대상이 되는 처분이 존재하지 아니하여 부적법한 청구에 해당하는 것으로 판단됨|0']

다음으로 받은 데이터를 konlpy.Okt()를 사용하여 형태소를 분리합니다. 분리한 데이터를 토큰화 하여 데이터 셋을 만듭니다. 데이터 셋을 만드는 과정에서 심판문의 개인정보를 익명처리하기 위해서 사용했던 특수기호들을 제거합니다.

train_tokens[0:3]
array([[list(['이', '건', '심판', '청구', '는', '처분', '청', '의', '직권', '경정', '으로', '인하다', '심리', '일', '현재', '청구', '의', '대상', '이', '되다', '처분', '이', '존재', '하다', '아니다', '부', '적법하다', '청구', '로', '판단', '되다']),
        0],
       [list(['처분', '청', '의', '2016년', '제', '2', '기', '부가가치세', '경정', '결정', '후', '청구인', '이', '심판', '청구', '를', '제기', '하다', '2017', '10', '24', '이미', '기', '각', '결정', '을', '받다', '이', '건', '심판', '청구', '는', '동일하다', '처분', '에', '대하', '여', '중복', '하다', '제기', '되다', '점', ',', '청구인', '은', '당초', '심판', '청구', '와', '동일하다', '내용', '의', '경정', '청구', '를', '하다', ',', '그', '에', '대한', '처분', '청', '의', '거부', '통지', '는', '민원', '회신', '에', '불과하다', '것', '이어서', '심판', '청구', '의', '대상', '이', '되다', '처분', '으로', '볼', '수', '없다', '점', '등', '에', '비추다', '이', '건', '심판', '청구', '는', '부', '적법하다', '청구', '로', '판단', '되다']),
        0],
       [list(['처분', '청', '이', '청구', '주장', '을', '받아들이다', '이', '건', '과세', '처분', '을', '직권', '으로', '감액', '경정', '하다', '이', '건', '심판', '청구', '는', '심리', '일', '현재', '불복', '대상', '이', '되다', '처분', '이', '존재', '하다', '아니다', '부', '적법하다', '청구', '에', '해당', '하다', '것', '으로', '판단', '되다']),
        0]], dtype=object)

해당 작업을 거치면 위와 같은 형태로 변경됩니다.

# supervised learning을 위한 text, label 생성
train_X = train_tokens[:,0]
train_Y = train_tokens[:,1]

판결은 각하, 기각, 취소,경정,재조사로 나눌 수 있습니다. 이중 취소, 경정, 재조사는 인용으로 다시 분류할 수 있어 최종 데이터는 각하(0), 기각(1), 인용(2)의 형태로 label을 만들 수 있습니다.

train_Y[0:10]
array([0, 0, 0, 1, 1, 0, 1, 1, 0, 1], dtype=object)
W2V = Word2Vec.Word2Vec()
train_Y_ = W2V.One_hot(train_Y)  ## Convert to One-hot
train_X_ = W2V.Convert2Vec("./model/FastText.model",train_X)

train_x 데이터는 word-embedding 형태로 만들어줍니다. 이는 one-hot-encoding 형태의 데이터보다 word- embedding이 학습에 더 유리하기 때문입니다.
자세한 설명는 word-embedding 자료를 보시길 권해드립니다.

W2V.Convert2Vec은 train_x의 값을 사전에 훈련한 FastText 모델의 벡터로 변환해주는 함수입니다. FastText는 본 블로그에 간단히 기술노트에 간단한 Test Code를 올려놨습니다.

Step. 2 모델 준비

다음과 같은 모델을 준비합니다.
예측을 위해 BiLSTM 모델을 사용했습니다. BiLSTM은 RNN 모델의 하나로 높은 성능을 발휘하는 모델로 알려져있습니다.

RNN 모델의 특성상 [batch_size, sequence_length, output_size]의 형태의 입력이 필요합니다. batch_size는 문장의 크기, sequence_length는 문장의 길이, output_size는 문장의 vector size입니다.

Batch_size = 32
Total_size = len(train_X)
Vector_size = 300
train_seq_length = [len(x) for x in train_X] # flexible input lenght
Maxseq_length = max(train_seq_length) ## 95
learning_rate = 0.001
lstm_units = 128
num_class = 3
training_epochs = 100

아래와 같이 모델을 선언합니다. BiLSTM은 LSTM 모델을 두개를 사용하여 forward, backward 방향으로 학습함으로 구현할 수 있습니다.

keras나 pytorch등을 사용하면 더 간단히 구현할 수 있습니다.

# ?, cell count, input dimension(one-hot)
X = tf.placeholder(tf.float32, shape = [None, Maxseq_length, Vector_size], name = 'X')

# ?, output(true,false)
Y = tf.placeholder(tf.float32, shape = [None, num_class], name = 'Y')

seq_len = tf.placeholder(tf.int32, shape = [None])
keep_prob = tf.placeholder(tf.float32, shape = None)

with tf.compat.v1.variable_scope('forward', reuse = tf.compat.v1.AUTO_REUSE):
    # hidden_size : 128
    lstm_fw_cell = tf.nn.rnn_cell.LSTMCell(lstm_units, forget_bias=1.0, state_is_tuple=True)
    lstm_fw_cell = tf.contrib.rnn.DropoutWrapper(lstm_fw_cell, output_keep_prob = keep_prob)

with tf.compat.v1.variable_scope('backward', reuse = tf.compat.v1.AUTO_REUSE):
    lstm_bw_cell = tf.nn.rnn_cell.LSTMCell(lstm_units, forget_bias=1.0, state_is_tuple=True)
    lstm_bw_cell = tf.contrib.rnn.DropoutWrapper(lstm_bw_cell, output_keep_prob = keep_prob)

with tf.compat.v1.variable_scope('Weights', reuse = tf.compat.v1.AUTO_REUSE):
    W = tf.get_variable(name="W", shape=[2 * lstm_units, num_class], dtype=tf.float32, initializer = tf.contrib.layers.xavier_initializer())
    b = tf.get_variable(name="b", shape=[num_class], dtype=tf.float32, initializer=tf.zeros_initializer())

두 모델을 합하여 하나의 학습 모델을 완성합니다.

with tf.variable_scope("loss", reuse = tf.AUTO_REUSE):
    (output_fw, output_bw), states = tf.nn.bidirectional_dynamic_rnn(lstm_fw_cell, lstm_bw_cell, dtype=tf.float32, inputs = X, sequence_length = seq_len)
    ## concat fw, bw final states
    outputs = tf.concat([states[0][1], states[1][1]], axis=1) #bi-lstm fully connected layer
    logits = tf.matmul(outputs, W) + b # hypothesis
    
    with tf.compat.v1.variable_scope("loss"):
        loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(logits = logits , labels = Y)) # Softmax loss
        optimizer = tf.train.AdamOptimizer(learning_rate=0.001).minimize(loss) # Adam Optimizer
prediction = tf.nn.softmax(logits)
correct_pred = tf.equal(tf.argmax(prediction, 1), tf.argmax(Y, 1))
accuracy = tf.reduce_mean(tf.cast(correct_pred, tf.float32))

total_batch = int(len(train_X) / Batch_size)

train_acc = []
train_loss = []
history_loss = []

print("Start training!")
with tf.Session(config = config) as sess:
    start_time = time.time()
    sess.run(tf.global_variables_initializer())
    
    for epoch in range(training_epochs):

        avg_acc, avg_loss = 0. , 0.
        mask = np.random.permutation(len(train_X_)) #shuffle all row
        train_X_ = train_X_[mask]
        train_Y_ = train_Y_[mask]
        
        for step in range(total_batch):
            train_batch_X = train_X_[step*Batch_size : step*Batch_size+Batch_size] # 32 batch size
            train_batch_Y = train_Y_[step*Batch_size : step*Batch_size+Batch_size]
            batch_seq_length = train_seq_length[step*Batch_size : step*Batch_size+Batch_size]
            
            train_batch_X = W2V.Zero_padding(train_batch_X, Batch_size, Maxseq_length, Vector_size) # 32, 255, 300 -> fill zero with empty rows words. max row words 255
            
            sess.run(optimizer, feed_dict={X: train_batch_X, Y: train_batch_Y, seq_len: batch_seq_length})
            
            # Compute average loss
            loss_ = sess.run(loss, feed_dict={X: train_batch_X, Y: train_batch_Y, seq_len: batch_seq_length, keep_prob : 0.75})
            avg_loss += loss_ / total_batch
            
            acc_ = sess.run(accuracy , feed_dict={X: train_batch_X, Y: train_batch_Y, seq_len: batch_seq_length, keep_prob : 0.75})
            avg_acc += acc_ / total_batch
            
            history_loss.append(loss_)
            print("epoch :{}-{}  {:02d} step : {:04d} loss = {:.6f} accuracy= {:.6f}".format(step*Batch_size, step*Batch_size+Batch_size, epoch+1, step+1, loss_, acc_))
   
        print("<Train> Loss = {:.6f} Accuracy = {:.6f}".format(avg_loss, avg_acc))
      
        train_loss.append(avg_loss)
        train_acc.append(avg_acc)


    save_path = saver.save(sess, modelName)
    
    print ('save_path',save_path)

학습이 종료가 되고 아래와 같은 코드를 입력하여 학습이 잘 됐는지 확인해봅니다.

import matplotlib.pyplot as plt
plt.figure(figsize=(10,5))
plt.plot(history_loss)

Naive-FAQ-Chatbot-3

간단한 FAQ 챗봇을 만들어보겠습니다.

이 챗봇은 간단한 형태로 챗봇을 처음 접하시는 분들을 위해 작성한 코드정도로 생각하시면 될듯합니다.

아래의 파일은 predict.py입니다.
해당 파일은 입력 받은 텍스트를 통해서 해당 텍스트가 어떤 질문인지를 예측하는 기능을 수행합니다.

예를 들어 사용자가 “회원정보를 수정하고 싶어요”라고 질문을 하면 해당 클래스는 입력 받은 질문을 형태소 분석하고 이 정보를 학습한 모델에 입력하여 해당 질문이 어떤 내용의 질문인지 찾아내 적절한 답변을 표시해주는 기능입니다.

아래의 블록은 predict.py 클래스 실행시 외부에서 입력 받는 parameter 값입니다.
parameter는 총 3가지로 질문 내용( q_message), 모델명(model_fn), 워드 벡터를 만들기 위해 입력한 파일(word_data)입니다.

def define_argparser():
    p = argparse.ArgumentParser()
    p.add_argument('--q_message', required=True)
    p.add_argument('--model_fn', required=True)
    p.add_argument('--word_data', required=True)
    config = p.parse_args()

    return config

입력 받은 질문은 미리 학습된 모델에 넣어서 적절한 값을 예측해냅니다.

def main(config):
    device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')

    # model load
    load = torch.load(config.model_fn, map_location=device)
    labels = load['labels']
    print(labels)

    IPT = 196
    H = 100
    OPT = 6

    model = FaqCategoryClassifier(IPT, H, OPT)
    model.load_state_dict(load['model'])
    
    okt = Okt()

    predict = PredictCategory(okt, model)
    
    words = fileRead()
    morphs = okt.morphs(config.q_message)
    x_data = myutils.bag_of_words(morphs,words)
    
    p = predict.getCategory(torch.FloatTensor(x_data))
    idx = torch.argmax(p)
    print('{}\n{}\n'.format(p,idx))
    print(labels[idx])

이것으로 3번에 나눠서 간단한 FAQ 챗봇에 대한 설명을 마무리하겠습니다.

해당 코드에서 입력 데이터를 만드는 부분을 자세히 설명하지 않았는데 그 이유는 입력 데이터는 각각 다양한 방법으로 만들 수 있기 때문입니다.

그에 따라서 모델의 모양도 변하기 때문입니다.
먼저는 어떤 데이터를 어떻게 만들지에 대해서 설계해보는 것이 중요합니다.

Naive-FAQ-Chatbot-2

간단한 FAQ 챗봇을 만들어보겠습니다.

이 챗봇은 간단한 형태로 챗봇을 처음 접하시는 분들을 위해 작성한 코드정도로 생각하시면 될듯합니다.

아래의 파일은 trainer.py 입니다.

해당 파일의 기능은 모델을 훈련하고 검증하는 역할을 수행합니다.

class Trainer():
def __init__(self, model, optimizer, crit):
    self.model = model
    self.optimizer = optimizer
    self.loss = loss

    super().__init__()

위의 부분은 Trainer 클래스의 선언부로 model, optimizer, loss 값을 전달 받습니다.

def train(self, train_data, valid_data, config):
        lowest_loss = np.inf
        best_model = None

        for epoch_index in range(config.n_epochs):
            train_loss = self._train(train_data[0], train_data[1], config)
            valid_loss = self._validate(valid_data[0], valid_data[1], config)

            # You must use deep copy to take a snapshot of current best weights.
            if valid_loss <= lowest_loss:
                lowest_loss = valid_loss
                best_model = deepcopy(self.model.state_dict())

            print("Epoch(%d/%d): train_loss=%.4e  valid_loss=%.4e  lowest_loss=%.4e" % (
                epoch_index + 1,
                config.n_epochs,
                train_loss,
                valid_loss,
                lowest_loss,
            ))

        # Restore to best model.
        self.model.load_state_dict(best_model)

train() 함수는 입력 받은 데이터를 epoch 만큼 학습을 시작합니다.
이때 _train()과 _valid()함수를 호출하는데 _train()은 학습을 _valid()는 검증을 수행합니다.

입력 데이터는 단어의 one-hot 데이터를 사용합니다. 더 좋은 결과를 얻기 위해서는 one-hot 보다는 embedding된 데이터를 사용하는 것이 좋습니다. 그 이유는 one-hot의 특징상 단어간의 관계를 표현 할 수 없기 때문이며 one-hot 데이터가 sparse하기 때문입니다.

word를 embedding하는 가장 대표적인 방법인 word2vec을 사용하기를 추천합니다. 다만 여기서는 naive한 형태의 챗봇이기 때문에 one-hot을 사용하여 테스트했습니다.

    def _train(self, x, y, config):
        self.model.train()

        # Shuffle before begin.
        indices = torch.randperm(x.size(0), device=x.device)
        x = torch.index_select(x, dim=0, index=indices).split(config.batch_size, dim=0)
        y = torch.index_select(y, dim=0, index=indices).split(config.batch_size, dim=0)

        total_loss = 0

        for i, (x_i, y_i) in enumerate(zip(x, y)):
            y_hat_i = self.model(x_i)
            loss_i = self.crit(y_hat_i, y_i.squeeze())

            # Initialize the gradients of the model.
            self.optimizer.zero_grad()
            loss_i.backward()

            self.optimizer.step()

            if config.verbose >= 2:
                print("Train Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

            # Don't forget to detach to prevent memory leak.
            total_loss += float(loss_i)

        return total_loss / len(x)

위의 코드와 같이 학습을 시작합니다.
학습 데이터는 사전에 정의한 배치 사이즈에 맞춰 분할 학습을 수행합니다.
이때 현재 모델이 학습 중이라는 것을 알려주기 위해 model.train()을 선언합니다.

def _validate(self, x, y, config):
        # Turn evaluation mode on.
        self.model.eval()

        # Turn on the no_grad mode to make more efficintly.
        with torch.no_grad():
            # Shuffle before begin.
            indices = torch.randperm(x.size(0), device=x.device)
            x = torch.index_select(x, dim=0, index=indices).split(config.batch_size, dim=0)
            y = torch.index_select(y, dim=0, index=indices).split(config.batch_size, dim=0)

            total_loss = 0

            for i, (x_i, y_i) in enumerate(zip(x, y)):
                y_hat_i = self.model(x_i)
                loss_i = self.crit(y_hat_i, y_i.squeeze())

                if config.verbose >= 2:
                    print("Valid Iteration(%d/%d): loss=%.4e" % (i + 1, len(x), float(loss_i)))

                total_loss += float(loss_i)

            return total_loss / len(x)

validate 코드도 train 코드와 거의 동일합니다.
다른 점은 train에서 학습에 관련된 부분이 validate에서는 빠져있다는 부분입니다. 단순히 검증만 하는 데이터이기 때문에 학습이 일어나지 않습니다.
특히 잊지 말아야 할 것은 model.eval()을 실행시켜줘야 한다는 것입니다.

validation은 과적합을 방지하기 위해서 실행하는 것으로 대부분 데이터셋을 8:2, 7:3 정도로 분리하여 학습과 검증을 수행합니다.

이제 남은 부분은 이렇게 만들어진 모델을 통해 예측을 수행하는 코드가 남아 있습니다.

해당 코드는 Naive-FAQ-Chatbot-3에서 설명하겠습니다.

Naive-FAQ-Chatbot-1

간단한 FAQ 챗봇을 만들어보겠습니다.

이 챗봇은 간단한 형태로 챗봇을 처음 접하시는 분들을 위해 작성한 코드정도로 생각하시면 될듯합니다.

csv 파일은 질문과 그 질문이 속해 있는 카테고리의 집합입니다.
예를 들어서 질문의 내용이 “사용 중인 아이디 또는 이름을 변경하고 싶어요” 이라면 이것은 “회원” 카테고리에 등록된 질문이라고 인식하여 그 중에서 하나의 답변을 찾아 리턴하는 방법입니다.

본 테스트 데이터 셋에는 [“회원”,”교재”,”웹사이트”…] 총 6개의 카테고리가 있습니다.

즉, 어떠한 질문을 입력을 받고 입력 받은 데이터를 통해서 해당 질문이 어떤 카테고리에 속하는 질문인지 찾아 내는 분류(Classification)의 문제로 접근하면 됩니다.

일단 사용할 라이브러리를 import 합니다.
추가한 라이브러리를 보시면 아시겠지만 pytorch로 구현되어 있는 코드입니다.
나중에 Tensorflow나 keras로 작성된 코드도 정리해서 올려드리겠습니다.

코드의 구성은 단위 기능을 수행하는 몇개의 파일로 분리되어 있습니다.

  • train.py
  • trainer.py
  • model.py
  • dataloader.py
  • predict.py

아래의 파일은 train.py 입니다.

해당 파일의 기능은 데이터 준비, 모델 셋팅, 훈련,  모델 저장의 역할을 수행합니다.

import argparse
import numpy as np
from konlpy.tag import Okt

import torch
import torch.nn as nn
import torch.optim as optim

from model import FaqCategoryClassifier
from dataloader import DataLoader
from trainer import Trainer

아래 부분은 csv파일을 통해서 데이터를 읽어 오는 부분입니다.
데이터를 읽은 후에 x_train, y_train, labels로 정보를 리턴합니다.
해당 파일의 type은 numpy 형태로 들어오고 학습을 위해  데이터 타입을 변환해줍니다.
pytorch는 tensorflow와 달리 Define-by-Run 형태이기 때문에 코드를 이해하기가 편리합니다.

* 이미지를 포함한 간단한 설명이 있으니 참고하시면 이해하시기 좋을듯합니다.
https://medium.com/@zzemb6/define-and-run-vs-define-by-run-b527d127e13a

학습용 데이터는 적당히 섞어줍니다. 이때 각 feature 데이터와 label 데이터가 섞이지 않도록 반드시 주의해야 합니다. 그리고 마지막에 해당 데이터의  shape을 표시해보고 데이터가 잘 들어왔는지 확인해봅니다.

참고로 읽어온 데이터에서 훈련용 세트와 검증용 세트를 분리합니다. 본 실험에서는 7:3정도로 분리하여 사용합니다.

 ## Data Read
 dataloader = DataLoader('./data/faq.categories.extend.csv', okt)
 x_train, y_train, labels = dataloader.prepareDataset()

 x_train = torch.FloatTensor(x_train)
 y_train = torch.LongTensor(y_train)

 train_cnt = int(x_train.size(0) * 0.7)
 valid_cnt = x_train.size(0) - train_cnt

 indices = torch.randperm(x_train.size(0))
 x = torch.index_select(x_train, dim=0, index=indices).split([train_cnt, valid_cnt], dim=0) # x[0] x_train, x[1] x_train valid data
 y = torch.index_select(y_train, dim=0, index=indices).split([train_cnt, valid_cnt], dim=0) # y[0] y_train, y[1] y_train valid label

 print('Train', x[0].shape, x[1].shape)
 print('Valid', y[0].shape, y[1].shape)

데이터가 준비되었으면 이제 모델을 생성합니다.
모델은 입력(IPT)과 출력(Hidden), 그리고 최종 출력(OPT)의 형태로 나타낼 수 있습니다. 이때 최종 출력은 FAQ의 카테고리 즉 6개 중 하나인 one-hot의 형태로 출력합니다.
그리고 GD 알고리즘 중에 하나인 Adam을 사용하고 분류모델의 손실함수로 cross-entropy를 사용합니다.

## Model Setting
IPT = 196
H = 100
OPT = len(labels)
model = FaqCategoryClassifier(IPT, H, OPT)

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

모델은 다음과 같이 생성합니다.
모델은 nn.Module을 상속 받아서 클래스 파일 형태로 작성합니다.
위의 모델은 간단한 형태의 정보입니다. 더 높은 학습 결과를 얻기 위해서는 모델의 레이어를 잘구성해야 합니다. 맨 마지막에 최종 출력의 shape을 넣어야 한다는 것을 기억하시 바랍니다. 만일 이 정보가 맞지 않을 경우 에러 코드를 표시합니다.

class FaqCategoryClassifier(nn.Module):
    def __init__(self, IPT, H, OPT):
        print('FaqCategoryClassifier Load!')
        super().__init__()

        self.layers = nn.Sequential(
            nn.Linear(IPT, H),
            nn.Linear(H, 50),
            nn.Linear(50, 20),
            nn.Linear(20, OPT)
        )
        

    def forward(self, x):
        return self.layers(x)

이제는 아래와 같은 방법으로 모델을 훈련시킵니다.
Trainer 클래스는 다음 편에서 내용을 설명해드리겠습니다.
일단 Trainer에서 활용하는 데이터는 입력값과 검증값 데이터들과 각각의 레이블 정보입니다.

## Trainer
trainer = Trainer(model, optimizer, loss)
trainer.train((x[0], y[0]), (x[1], y[1]), config)

훈련이 완료되면 해당 모델을 저장합니다.
이때 저장할 데이터는 모델 데이터 외에도 다양한 데이터를 함께 저장할 수 있습니다.
아래의 코드는 환경정보(config)와 레이블 정보를 같이 저장합니다.
이 외에도 필요한 정보가 있다면 같이 저장합니다.

## Save Model
torch.save({'model':trainer.model.state_dict(), 'config':config, 'labels':labels}, config.model_fn)

이렇게 train.py 파일에는 데이터 준비-모델 셋팅-훈련-모델 저장의 단계를 거치게됩니다.

다음 코드에서는 trainer.py가 어떻게 구성되어 있는지 보겠습니다.