딥러닝 감성분석(텍스트 분류)

본 예제는 감성분석 혹은 텍스트 분류라고 할 수 있습니다. 감성분석이란 쉽게 말해서 어떤 글이 찬성/반대, 좋음/보통/싫음, 긍정/중립/부정 등 어떠한 polarity를 나타내는지에 대한 상태를 분석하는 것입니다.

소비자의 감성과 관련된 텍스트 정보를 자동으로 추출하는 텍스트 마이닝(Text Mining) 기술의 한 영역. 문서를 작성한 사람의 감정을 추출해 내는 기술로 문서의 주제보다 어떠한 감정을 가지고 있는가를 판단하여 분석한다. 주로 온라인 쇼핑몰에서 사용자의 상품평에 대한 분석이 대표적 사례로 하나의 상품에 대해 사용자의 좋고 나쁨에 대한 감정을 표현한 결과이다.
[네이버 지식백과] 감성분석
[Sentimental Analysis , 感性 分析]

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

import random
import numpy as np

감성분석에 사용한 데이터는 네이버에서 공개한 영화 평점 정보입니다. 해당 데이터는 아래 링크에서 받을 수 있습니다.
https://github.com/e9t/nsmc

본 예제는 평점 데이터의 전체를 사용하지 않고 RNN에서 many-to-one 형태의 감성분석 모델의 개념을 위해 일부 데이터만 사용했습니다. 또 그중에서 문장의 길이가 30 미만인 데이터만 사용했습니다.

sentence = []
file = open("./data/ratings_test.txt", "r")
for i in range(1000):
    line = file.readline()
    arr = line.split('\t')
    if len(arr[1]) < 30:
        sentence.append(arr[1]+'|'+arr[2].replace('\n',''))

file.close()

sentences = sentence[1:]
len(sentences) #560

단어셋 생성을 위해서 Vocab 클래스를 생성합니다. <unk>는 데이터의 Sequence Length를 맞춰주기 위해서 빈 데이터를 채우기 위해 생성한 코드입니다.

데이터의 형식은 아래와 같습니다. 분석에 필요한 데이터는 텍스트 부분과 뒤 이어 나오는 0,1의 데이터입니다. 0은 부정적인 평가이며 1은 긍정적인 평가입니다.

2541728	아찔한 사랑 줄다리기???	0
9648521	재미있었다! 또봐야징ㅎ	1
9911421	중간에 화면이 좀 끊기는것 빼곤 넘 좋았어요~	1
3608055	이 영화를 말하는데 긴 단어는 필요없다. 재수없는 졸작 이거면 충분하다.	0
class Vocab:
    def __init__(self):
        self.vocab2index = {'<unk>':0}
        self.index2vocab = {0:'<unk>'}
        self.vocab_count = {}
        self.n_vocab = len(self.vocab2index)

    def add_vocab(self, sentence):
        for word in sentence:
            if word not in self.vocab2index:
                self.vocab2index[word] = self.n_vocab
                self.vocab_count[word] = 1
                self.index2vocab[self.n_vocab] = word
                self.n_vocab += 1
            else:
                self.vocab_count[word] += 1

단어는 형태소 분석을 사용합니다. 형태소 분석기로 konlpy를 사용합니다.

vo = Vocab()

from konlpy.tag import Okt
okt = Okt()

for sentence in sentences:
    vo.add_vocab(okt.morphs(sentence.split('|')[0]))

Model

input_size = vo.n_vocab
hidden_size = 2

class SentimentModel(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.embedding = nn.Embedding(self.input_size, 250)
        
        self.rnn = nn.LSTM(
            input_size = 250, 
            hidden_size = 100, 
            num_layers = 4, 
            batch_first = True,
            bidirectional = False
        )
        
        self.layers = nn.Sequential(
            nn.ReLU(),
            nn.Linear(100,50),
            nn.Linear(50,25),
            nn.Linear(25,self.hidden_size),
            #nn.Sigmoid()
        )
        
        self.softmax = nn.LogSoftmax(dim=0)
         
        
    def forward(self, x):
        x = self.embedding(x) 
        y,_ = self.rnn(x)
        y = self.layers(y)
        return self.softmax(y[:,-1,:])
    
model = SentimentModel(input_size, hidden_size)

생성한 모델 정보를 출력해보면 아래와 같습니다.
간략히 살펴보면 입력 데이터 1751을 250 차원으로 Embedding 합니다. 그리고 Embedding의 마지막 값을 LSTM의 입력값으로 사용합니다. LSTM은 250을 입력 받아서 100개의 정보를 출력하는 4개층의 레이어 구조로 되어 있습니다.

입력 받은 데이터는 Linear 모델을 통과하며 차원 정보를 낮춰주고 마지막에는 이 문장의 값이 “긍정” 혹은 “부정”을 나타내는 2개의 값을 최종적으로 출력합니다.

최종 output 데이터는 모두 사용하지 않고 각 배치 사이즈의 마지막 Sequence(or Time-Step) 데이터만 사용합니다. 해당 정보는 (batch_size * hidden_vector)로 표시할 수 있습니다. 이렇게 만들어진 정보를 LogSoftmax를 통과 시키고 예측값을 구해냅니다.

이렇게 구해진 예측값과 정답의 차이 즉, Loss를 계산하고 이 값을 줄이는 과정을 수행하는 학습을 수행합니다.

SentimentModel(
  (embedding): Embedding(1751, 250)
  (rnn): LSTM(250, 100, num_layers=4, batch_first=True)
  (layers): Sequential(
    (0): ReLU()
    (1): Linear(in_features=100, out_features=50, bias=True)
    (2): Linear(in_features=50, out_features=25, bias=True)
    (3): Linear(in_features=25, out_features=2, bias=True)
  )
  (softmax): LogSoftmax()
)

입력 문장의 최대 길이는 30으로 정하고 길이가 30이 안되는 문장에는 <unk> 값을 채워줍니다.

def tensorize(vocab, sentence):
    idx = [vocab.vocab2index[word] for word in okt.morphs(sentence)]
    #return torch.Tensor(idx).long().item()
    return idx

ten = []
y_data = []
for sentence in sentences:
    tmp = tensorize(vo,sentence.split('|')[0])
    tmp_zero = np.zeros(30)
    
    for i,val in enumerate(tmp):
        tmp_zero[i] = val
    
    ten.append(tmp_zero)
    y_data.append(float(sentence.split('|')[1]))
    
x_data = torch.Tensor(ten).long()
y_data = torch.Tensor(y_data).long()

Training

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

hist = []

# start training
for epoch in range(201):
    model.train()
    outputs = model(x_data)
    
    loss = criterion(outputs, y_data)
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    hist.append(loss.item())
    
    if epoch%20 == 0:
        print(epoch, loss.item())
        #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")
    
    

Evaluate

with torch.no_grad():

    prediction = model(x_data)
    correct_prediction = torch.argmax(prediction, 1) == y_data
    
    accuracy = correct_prediction.float().mean()
    print('Accuracy:', accuracy.item()) # 정확도 표시

학습의 Loss 값을 표시해보면 아래와 같습니다.

import matplotlib.pyplot as plt

plt.plot(hist)
plt.show()

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

PyTorch DataLoader Example

sklearn의 붓꽃 데이터를 활용하여 pytorch와 dataloader를 활용하여 분류 문제를 풀어 보겠습니다.

iris 데이터셋을 받아서 pandas로 데이터를 변환합니다. 변환 과정이 반드시 필요한 것은 아니지만 데이터셋을 변경하거나 학습용 컬럼 정보를 수정할 때에 도움이 됩니다.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import load_iris
iris = load_iris()

df = pd.DataFrame(iris.data)
df.columns = iris.feature_names
df['class'] = iris.target

다음으로 PyTorch로 데이터를 import하여 학습용 데이터를 생성합니다. 학습용 데이터는 train_data와 valid_data로 분리하되 8:2 비율로 분리합니다.

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

## Prepare Dataset
data = torch.from_numpy(df.values).float()
#data.shape = torch.Size([150, 5])

# 데이터셋에서 feature 정보와 label 데이터를 분리하여 x,y 데이터를 생성
x = data[:,:4]
y = data[:,[-1]]

# train, valid 데이터셋 분리, 데이터는 8:2 or 7:3 생성
ratio = [.8, .2]

train_cnt = int(data.size(0) * ratio[0])
valid_cnt = data.size(0) - train_cnt
print(train_cnt, valid_cnt) #120, 30

# torch.randperm을 사용해서 랜덤한 int 순열을 생성, train/valid 데이터로 분리
indices = torch.randperm(data.size(0))
x = torch.index_select(x, dim=0, index=indice).split([train_cnt, valid_cnt], dim=0)
y = torch.index_select(y, dim=0, index=indice).split([train_cnt, valid_cnt], dim=0)

pytorch에서 제공하는 Dataset과 DataLoader를 import합니다.

Dataset 클래스를 상속하여 IrisDataset 클래스를 생성하고 data, label을 입력합니다.
IrisDataset을 DataLoader에 입력하여 데이터를 batch_size 만큼 데이터를 분리하여 train_loader에 넣어줍니다.

iris 데이터셋은 총 150개 데이터입니다. 이것을 train/valid 형태로 8:2로 분리했기 때문에 train 120, valid 30개의 데이터로 각각 생성됐습니다. 이렇게 생성된 데이터를 한번에 훈련하지 않고 일정 갯수로 데이터를 묶어 줍니다. 사실 소규모의 데이터 셋에서는 이러한 batch 작업이 불필요합니다. 그러나 많은 수의 데이터를 훈련하기 위해서는 이러한 작업이 필수입니다. 이번 예제에서는 30개 단위로 묶음을 만들어보겠습니다.

파이토치에서는 이러한 묶음 작업을 할 수 있는 DataLoader라는 편리한 패키지를 제공합니다. 이러한 과정을 통해서 120개의 데이터가 30개식 4묶음으로 train_loader에 저장되게 됩니다.

from torch.utils.data import Dataset, DataLoader

# Dataset 상속
class IrisDataset(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]

# DataLoader
train_loader = DataLoader(dataset=IrisDataset(x[0],y[0]), batch_size=config['batch_size'], shuffle=True)
valid_loader = DataLoader(dataset=IrisDataset(x[1],y[1]), batch_size=config['batch_size'], shuffle=False)

참고로 data, train, validate, bacth_size, epoch을 이해하기 위해 예를 들어보면…
선생님이 학생들의 학력 수준을 알아보기 위해서 100문제를 만들었습니다. 선생님은 학생들에게 100문제 중에서 80 문제를 풀어보면서 수학적 원리를 설명합니다. 그러나 한번에 80문제를 풀기 어려우니 20문제씩 1~4교시 동안 풀어보게 합니다. 한번만 문제를 풀어보는 것보다는 같은 문제를 반복해서 풀어보는 것이 효과적이기 때문에 5~8교시 다시 문제를 풀어봅니다.

이제 학생들은 80문제를 20문제씩 나눠서 2번에 걸쳐 풀어본것이 됩니다. 만약 시간적 여유가 있다면 2번이 아니라 3번, 4번 풀어본다면 아마도 더 학습이 잘되겠죠.

이제 학생들이 수학원리를 잘 이해했는지 테스트해보기 위해서 남겨둔 20문제를 풀어보게 합니다. 그리고 20개의 문제를 얼마나 많은 학생이 맞췄는지를 계산해봅니다.

이러한 과정은 보통의 학습에서 매우 일반적인 방법입니다. 이제 생각해보면 100문제가 data, 80문제가 train_data, 20문제가 valid_data, 80문제를 20문제씩 나눠서 4묶음을 만드는 과정 batch, 같은 문제를 총 2회 풀어봄 epoch 이것이 지금까지의 과정에서 사용했던 용어를 정의한 것입니다.

즉, train_loader는 120개의 데이터가 30개씩 4묶음으로 되어 있는 것이 됩니다. valid_loader는 30개의 데이터가 30개씩 1묶음이 되겠네요.

자, 이제 모델을 간단히 구성합니다. 학습을 위한 모델이라기 보다는 간단히 테스트하기 위한 것임으로 간단한 모델을 만들어보겠습니다.

예측 데이터는 붓꽃의 꽃받침의 길이와 너비, 꽃잎의 길이와 너비에 따라 3종류 중 하나로 예측하는 것임으로 최종 아웃풋의 형태는 3입니다. 그리고 해당 데이터를 확률 값으로 나타내기 위하여 softmax_classification을 활용합니다.

# model 생성
model = nn.Sequential(
    nn.Linear(4,3)
)

optimizer = optim.Adam(model.parameters())

from copy import deepcopy
lowest_loss = np.inf
best_model = None
lowest_epoch = np.inf

copy 패키지로부터 deepcopy를 import합니다. 이것은 이번에 데이터를 만드는 과정과 직접적인 관련이 없기 때문에 간단히만 설명하면 객체의 모든 내용을 복사해서 새로운 하나의 객체를 만드는 것을 deep copy라고 합니다. 반대의 개념은 shallow copy 입니다.

이제 학습을 시작합니다. 이 모델은 2개의 for loop으로 되어 있습니다. 가장 먼저 나오는 for loop은 epoch에 대한 정의로 train data를 총 몇번 학습하는가에 대한 정의입니다. 다음에 나오는 또 하나의 for loop은 학습 데이터를 몇개로 나눠서 학습할 것인가 즉, batch에 대한 문제입니다.

1번 학습이 끝나면 학습의 loss를 계산해봅니다. loss는 정답과의 차이를 의미하는 것으로 작으면 작을 수록 학습이 잘됐다는 의미입니다. 한번 학습이 끝나면 valid data를 실행해봅니다. 그리고 valid에서 나온 loss와 train에서 나온 loss를 비교해보고 valid의 loss가 더 좋을 때에 해당 학습에 사용한 모델을 deepcopy해서 저장합니다.

그 이유는 무조건 학습을 오래 한다고 해서 좋은 결과가 나오는 것이 아니고 어느 순간에 학습이 정체되거나 과적합 되는 일이 있기 때문에 가장 좋은 모델을 저장하는 것입니다.

train_history, valid_history = [], []

for i in range(config['n_epochs']+1):
    model.train()
    
    train_loss, valid_loss = 0, 0
    y_hat = []
    
    # train_batch start
    for x_i, y_i in train_loader:
        y_hat_i = model(x_i)
        loss = F.cross_entropy(y_hat_i, y_i.long().squeeze())
        
        optimizer.zero_grad()
        loss.backward()

        optimizer.step()        
        train_loss += float(loss) # This is very important to prevent memory leak.

    train_loss = train_loss / len(train_loader)
    
    model.eval()
    with torch.no_grad():
        valid_loss = 0
        
        for x_i, y_i in valid_loader:
            y_hat_i = model(x_i)
            loss = F.cross_entropy(y_hat_i, y_i.long().squeeze())
            
            valid_loss += float(loss)
            
            y_hat += [y_hat_i]
            
    valid_loss = valid_loss / len(valid_loader)
    
    train_history.append(train_loss)
    valid_history.append(valid_loss)
    
    if i % config['print_interval'] == 0:
        print('Epoch %d: train loss=%.4e  valid_loss=%.4e  lowest_loss=%.4e' % (i, train_loss, valid_loss, lowest_loss))
        
    if valid_loss <= lowest_loss:
        lowest_loss = valid_loss
        lowest_epoch = i
        best_model = deepcopy(model.state_dict())
        
    model.load_state_dict(best_model)

이제 학습이 잘됐는지 아래와 같은 방법으로 train_loss와 valid_loss를 표시해봅니다.

import matplotlib.pyplot as plt

fig, loss_ax = plt.subplots()

loss_ax.plot(train_history, 'y', label='train loss')
loss_ax.plot(valid_history, 'r', label='val loss')

loss_ax.set_xlabel('epoch')
loss_ax.set_ylabel('loss')

loss_ax.legend(loc='upper left')

plt.show()

사실 이 예제는 torch의 Dataset과 DataLoader를 사용하는 방법에 대한 예제였는데 이것저것 설명하다 보니 글이 길어졌습니다.

여기서 중요한 것은 Dataset을 만들고 DataLoader를 통해서 학습에 사용하는 방법에 대한 내용이 중요하니 예제 코드를 활용해서 직접 테스트해보시기 바랍니다.

PyTorch’s Embedding()

단어 임베딩(Word Embedding)이란 말뭉치의 각 단어에 일대일로 대응하는 실수 벡터의 집합이나 혹은 이런 집합을 구하는 행위를 Word Embedding이라고 합니다. Word2Vec도 이런 워드 임베딩의 한 방법입니다.

그렇다면 왜 이런 워드 임베딩 방법이 필요할까요? 그 이유는 컴퓨터가 자연어를 이해하지 못하기 때문입니다. 그렇다면 컴퓨터가 이해할 수 있는 형태, 즉 숫자로 단어를 바꿔서 입력해줘야 합니다. 그렇다면 어떻게 해야 효과적으로 단어를 숫자의 형태로 바꿀 수 있을까요? 이러한 고민에서 나온 것이 워드 임베딩입니다.

먼저 워드를 숫자로 바꾸는 가장 간단한 방법은 원-핫-인코딩(One-Hot-Encoding)입니다. 예를 들어 “나는 학교에 갑니다” 이 문장을 3개의 단어로 구분하고 각 단어의 위치를 표시하는 것이죠. 이렇게 하면 일단 문자를 숫자로 바꾸는데는 성공했습니다.

1나는1,0,0
2학교에0,1,0
3갑니다0,0,1

그러나 이러한 방법에는 단점이 있습니다. 가장 큰 단점은 벡터의 사이즈가 너무 커진다는 것과 벡터의 내용이 하나의 1을 제외한 나머지 내용이 모두 0으로 채워진다는 것입니다. 예를 들어 “나는 학교에 갑니다” 3개의 단어이지만 책과 같은 대규모의 말뭉치에 등장하는 단어는 수만개가 된다는 것이죠. 그렇게 되면 벡터의 크기는 수만개가 넘는 사이즈에 대부분 0인 벡터가 만들어지기 때문에 이를 처리하는데 큰 문제가 생깁니다. 이것은 희소 벡터(Sparse Vector)라고 합니다. 또 하나의 문제는 각 단어의 값들은 모두 동일한 거리(Distance)를 가진다는 것입니다. 의미론적인 구분이 불가능하다는 것이죠.

그렇기 때문에 필요한 것은 단어의 크기와 상관 없는 차원의 벡터와 0과 1이 아닌 실수값을 가지는 새로운 벡터가 필요합니다. 이것을 밀집 벡터(Dense Vector)라고 합니다. 또 각 단어가 가지는 벡터에 방향성이나 유사도에 따라서 거리가 가깝거나 멀거나 하는 특징을 가지도록 표현할 필요가 있습니다.

워드 임베딩은 이러한 밀집된 형태의 벡터를 만드는 과정이라고 할 수 있습니다.

PyTorch는 입력 텍스트를 받아서 임베딩 벡터를 생성하는 nn.Eembedding()을 제공하고 있습니다. index 값이 부여되어 있는 단어를 입력 받습니다. 여기서 index는 고유한 값이 됩니다. 이 Index를 참조 테이블(look-up table)에서 사용할 것입니다. 즉, |?|×? 크기의 행렬에 단어 임베딩을 저장하는데, D 차원의 임베딩 벡터가 행렬의 ?i 번째 행에 저장되어있어 ?i 를 인덱스로 활용해 임베딩 벡터를 참조하는 것입니다. 여기서 |?|는 vocabulary의 수이고 D는 차원정보입니다.

https://wikidocs.net/64779

위의 그림은 단어 great이 정수 인코딩 된 후 테이블로부터 해당 인덱스에 위치한 임베딩 벡터를 꺼내오는 모습을 보여줍니다. 위의 그림에서는 임베딩 벡터의 차원이 4로 설정되어져 있습니다. 그리고 단어 great은 정수 인코딩 과정에서 1,918의 정수로 인코딩이 되었고 그에 따라 단어 집합의 크기만큼의 행을 가지는 테이블에서 인덱스 1,918번에 위치한 행을 단어 great의 임베딩 벡터로 사용합니다. 이 임베딩 벡터는 모델의 입력이 되고, 역전파 과정에서 단어 great의 임베딩 벡터값이 학습됩니다.

룩업 테이블의 개념을 이론적으로 우선 접하고, 처음 파이토치를 배울 때 어떤 분들은 임베딩 층의 입력이 원-핫 벡터가 아니어도 동작한다는 점에 헷갈려 합니다. 파이토치는 단어를 정수 인덱스로 바꾸고 원-핫 벡터로 한번 더 바꾸고나서 임베딩 층의 입력으로 사용하는 것이 아니라, 단어를 정수 인덱스로만 바꾼채로 임베딩 층의 입력으로 사용해도 룩업 테이블 된 결과인 임베딩 벡터를 리턴합니다.[1]

참고 : Word Embeddings in Pytorch

https://pytorch.org/tutorials/beginner/nlp/word_embeddings_tutorial.html

파이토치 공식홈에 있는 내용을 사용해서 간단한 테스트 코드를 만들어보겠습니다.

import torch
import torch.nn as nn

train_data = '태초에 하나님이 천지를 창조하시니라 창세기 1장 1절'.split(' ')
word_set = list(set(train_data))
word_to_ix = {tkn:i for i, tkn in enumerate(word_set)}
word_to_ix

입력 데이터를 글자 단위로 분리하여 dict에 저장합니다. 저장된 내용을 출력해보면 아래의 내용과 같습니다. 중복을 제거하기 위해 set() 자료형을 사용해서 단어의 순서는 무시되었습니다.

{'천지를': 0, '하나님이': 1, '창조하시니라': 2, '창세기': 3, '태초에': 4, '1절': 5, '1장': 6}

이제 이렇게 분리한 단어를 아래와 같은 방법으로 one-hot 형태로 나타내면 다음과 같은 형태로 표시됩니다.

one_hot = []
for i, tkn in enumerate(word_to_ix):
    one_hot.append(np.eye(len(vocab), dtype='int')[word_to_ix[tkn]])
[array([1, 0, 0, 0, 0, 0, 0]), #천지를
 array([0, 1, 0, 0, 0, 0, 0]), #하나님이
 array([0, 0, 1, 0, 0, 0, 0]), #창조하시니라
 array([0, 0, 0, 1, 0, 0, 0]), #창세기
 array([0, 0, 0, 0, 1, 0, 0]), #태초에
 array([0, 0, 0, 0, 0, 1, 0]), #1절
 array([0, 0, 0, 0, 0, 0, 1])] #1장

그러나 서두에 언급했듯이 이렇게 표현된 벡터 데이터를 직접 학습에 사용하기는 적절하지 않습니다. 그래서 차원을 입력 데이터의 차원을 낮춰주고 Sparse한 데이터를 Dense한 형태로 변경할 필요가 있습니다. 그때 사용하는 것이 Embedding이라고 할 수 있습니다.

torch.nn.Embedding()은 이러한 작업을 쉽게 할 수 있도록 도와줍니다.

embeds = nn.Embedding(len(vocab), 3)
lookup_tensor = torch.tensor([word_to_ix["태초에"]], dtype=torch.long)
w = embeds(lookup_tensor)
print(w)

위와 같은 방법을 사용하면 [1, 0, 0, 0, 0, 0, 0] 형태의 one-hot 벡터를 [-1.0998, -1.0605, -0.5849] 형태의 데이터로 변경할 수 있습니다.

#생성된 weight 벡터
Parameter containing:
tensor([[ 0.5765,  0.2391, -0.1834],
        [-0.1860, -0.0754,  0.4587],
        [-0.9538, -0.6950,  0.5682],
        [-2.1076, -0.4070,  0.2598],
        [-1.0998, -1.0605, -0.5849],
        [ 1.1632, -0.8139,  0.1154],
        [ 0.9705,  0.3963,  0.8804]], requires_grad=True)

아래의 링크는 Word2Vec을 실제로 어떻게 사용하는지에 대한 예제입니다. 궁금하신 분들은 참고하시기 바랍니다.

Reference

[1] https://wikidocs.net/64779

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 -&gt; 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("&lt;Train&gt; 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 챗봇에 대한 설명을 마무리하겠습니다.

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

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