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가 어떻게 구성되어 있는지 보겠습니다.