챗봇 슬롯 채우기(Slot-Filling)

대화는 어떤 목적을 이루기 위한 대화(TOD, Task Oriented Dialog)가 있고 특별한 목적 없이 자신의 생각을 자유롭게 이야기 하는 소통을 목적으로 하는 대화(Chit-Chat Dialog)가 있습니다.

목적이 있는 대화의 경우에는 대화의 특징에 따라서 n개의 대화턴(Turn)으로 구성된 대화의 묶음으로 나눌 수 있는데 그것을 태스크(Task)라고 할 수 있습니다. 그리고 각 태스크는 또 n개의 액티비티(Activity)로 나눌 수 있습니다. 그리고 각각의 액티비티에는 태스크의 원활한 진행을 위해서 반드시 입력되어야 할 필요한 정보들이 있습니다.

이처럼 특정한 태스크의 발화(Utterance)에서 반드시 필요한 의미 있는 정보들을 슬롯(Slot)이라고 하고 이 슬롯을 채우는 것을 슬롯 필링(Slot Filling)이라고 합니다.

https://d2.naver.com/helloworld/2110494

그리고 이 슬롯을 채우기 위해서 거듭해서 질문을 하게되는데 이러한 것을 Follow-Up Question이라고 합니다. 아래의 그림과 같이 좌석을 예약할 때에 기본적으로 필요한 정보들이 있고 이러한 정보들이 입력되지 않으면 시스템은 이용자에게 질문을 통해 해당 정보를 얻게 됩니다.

https://d2.naver.com/helloworld/2110494

이런 과정을 수행하기 위해서는 개체명인식(NEG, Named Entity Recognition) 작업이 필요합니다.
해당 작업은 사용자가 입력한 텍스트를 사전에 정의된 몇가지 단어들… 예를 들어서 메뉴명, 지역명, 호텔명 등에 태깅하는 작업을 의미하고 이것은 비슷한 말로 엔티티 태깅(Entity Tagging)이라고도 합니다.

여기서 중요한 것은 기존에 정의된 태그 목록이라는 것입니다. 태그 목록은 기존에 범용적으로 사용되는 것도 있고 대화가 진행되는 특정한 도메인에서 활용되는 것도 있습니다.

이처럼 사용자가 입력한 단어들이 기존에 정의된 개체명에 포함되어 있는지를 살펴보고 없다면 필요한 정보를 다시 요구하는 방식으로 요청 정보를 채우게됩니다.

다음에 구현한 예제는 이러한 과정을 간단히 작성한 내용입니다. 주문상황을 가정하고 챗봇을 통해서 주문을 받아보는 방식을 생각해보겠습니다.
먼저 아래와 같이 슬롯을 정의합니다.

#Intent Slot 구성
slot_entity = { "주문": {"메뉴":None, "장소":None, "날짜":None},
                "예약": {"장소":None, "날짜":None},
                "날씨": {"장소":None, "시간":None}
              }

사용자가 입력된 텍스트가 “주문 부탁해”입니다. 챗봇 시스템이 먼저 파악해야 할 것이 있다면 텍스트를 입력한 사람이 어떤 의도로 이런 텍스트를 입력했는지를 알아내는 것입니다.

간단히는 ‘주문’이라는 키워드를 확인해서 대화의 의도를 파악할 수 있고 몇가지 ‘주문’ 상황에서 쓸 수 있는 문장들을 입력하고 유사도 검사를 통해서도 이용자의 의도를 파악할 수 있습니다. 최근에는 딥러닝을 활용해서 해당 문장이 어떤 의도인지 알아내기도 합니다. 딥러닝(LSTM)을 활용한 문장 유사도를 찾는 것은 예제로 구현되어 있으니 참고하시기 바랍니다.

입력한 문장 ‘햄버거 주문할께요’라는 단어가 ‘주문’이라는 의도를 가지고 있다는 것을 파악한 다음에 ‘햄버거 주문할께요’라는 입력 문장을 분석해서 예약에 필요한 정보들을 담고 있는지 확인해야합니다. 그러기 위해서 형태소 분석(Morphology Analysis) 과정이 필요합니다.

input_txt = '햄버거 주문할께요'
intent_code = '주문'

from konlpy.tag import Kkma
kkma = Kkma()
morpheme = kkma.pos(input_txt)
print(input_txt,'\n',morpheme)
# [('햄버거', 'NNG'), ('주문', 'NNG'), ('하', 'XSV'), ('ㄹ게요', 'EFN')]

입력된 문장의 형태소 분석을 통해서 ‘햄버거’,’주문’이라는 개체명(명사)을 찾아내었습니다. 이제 다음으로는 이런 개체명이 사전에 정의해둔 개체명과 일치하는 내용을 찾는 과정을 수행합니다. 입력된 단어 중에서 ‘햄버거’란 단어는 이미 메뉴 아이템에 사전으로 등록했기 때문에 ‘햄버거’는 menu_item이라고 인식합니다.

# 개체명
menu_item = ['피자','햄버거','치킨','떡볶이']
loc_item = ['세종','대전','공주']
date_item = ['지금','내일','모래']

# 개체명 태깅
for pos_tag in morpheme:
    if (pos_tag[1] in ['NNG', 'NNP']): #명사, 영어만 사용
        if pos_tag[0] in menu_item: #메뉴 item 검색
            slot_value["메뉴"] = pos_tag[0] 
        elif pos_tag[0] in loc_item: #장소 item 검색
            slot_value["장소"] = pos_tag[0] 
        elif pos_tag[0] in date_item: #날짜 item 검색
            slot_value["날짜"] = pos_tag[0] 
print (slot_entity.get(intent_code))

입력된 문장을 분석해본 결과 주문에 필요한 나머지 정보들 즉, ‘주소’ 정보와 ‘시간’ 정보는 입력되지 않았습니다. 챗봇 시스템은 빠진 두개의 정보를 입력할 것을 요청합니다. 이것을 Follow-Up Question이라고 합니다.

if(None in slot_value.values()): #빈 Slot 출력
    key_values = ""
    for key in slot_value.keys():
        if(slot_value[key] is None):
            key_values = key_values + key + ","
    output_data = key_values[:-1] + '를 입력해주세요.'
else:
    output_data = "주문이 완료 되었습니다."
            
print (output_data)
#메뉴,장소,날짜를 입력해주세요.

이러한 방법으로 주문에 필요한 모든 정보가 입력되면 챗봇 시스템은 주문 정보를 기반으로 실제로 요청한 내용들을 주문하게됩니다.

참고로 본 글을 작성하기 위해서 잘 정리된 아래의 두개 글을 참고했습니다. 관심이 있으신 분은 아래의 글을 검색해보시기 바랍니다.

REF
[Naver] 챗봇을 위한 대화는 어떻게 디자인할까
[KAKAO] 카카오 미니의 슬롯 태깅

챗봇(Chatbot) 이란

채팅은 보통 일상의 소소한 대화를 주고 받는 경우와 어떤 목적을 위해 당사자간 정보를 주고 받는 경우 혹은 정보의 이동이 단방향인 경우 세가지가 있습니다. 이중에서 어떤 목적을 위해 당사자간에 정보를 주고 받는 경우는 대화하라고 하고 정보의 요구 주체가 있어서 상대방은 정보를 주기만 하는 것을 QnA라고 할 수 있습니다.

그렇다면 이러한 챗봇은 어떤 방법으로 사람의 말을 이해하고 또 어떻게 구현할 수 있을까? (사실 챗봇과 음성인식은 거의 비슷한 기술을 사용합니다. 단지 앞 단에 음성을 텍스트로 변환해주거나 텍스트를 음성으로 변환해주는 기능을 수행하는 STT/TTS와 같은 기술이 적용될 뿐입니다.)
먼저 어떻게 이해하는지에 대해서 간단히 그림으로 살펴보면 아래와 같습니다.

사람은 머리속에 생각이나 감정 등을 언어의 형태로 상대방에게 전달합니다. 그 언어를 자연어(Natural Language)라고 합니다. 이 자연어는 인간의 언어이기 때문에 당연히 컴퓨터가 이해할 수 없습니다. 그렇기 때문에 인간의 언어를 컴퓨터에게 이해시키기 위해서 NLU(자연어이해, Natural Language Understanding)라는 분석과정이 필요합니다. 컴퓨터는 이러한 특별한 처리 단계를 통해서 인간의 이 말이 어떤 의미가 있는가를 이해하게 됩니다.

NLU의 과정은 축적된 데이터가 큰 역활을 합니다. 마치 아이들이 언어를 배울 때 좋은 환경에서 말을 배우는 것과 때로 거친 환경에서 말을 배우는 것이 사용하는 어휘의 차이가 있듯이 컴퓨터도 인간의 언어를 학습하는데 데이터가 절대적인 영향을 미치게됩니다.

어떤 데이터를 어떻게 축적 했느냐에 따라서 인간의 말을 더 잘 이해할 수 있습니다. 실제로 이러한 일을 수행하기에는 굉장히 큰 사전 데이터를 필요로합니다. 대화처리기는 학습된 데이터를 통해서 인간의 말에 어떤 응답을 해야할지를 선택하게 되고 그것을 NLG(자연어생성, Natural Language Generator)에 전달하게됩니다. NLG는 인간의 언어를 이해한 내용을 바탕으로 다시 인간이 이해할 수 있는 음성과 글의 형태로 출력해줍니다.

사실 이러한 과정은 사람에게서도 비슷하게 일어나고 있습니다. 인간 역시 화자의 관념화된 사상을 말이나 글로 표현하게 되고 상대방은 이러한 글을 다시 해석해서 관념화 하는 과정이 일어나고 있습니다.

위의 그림은 서정연교수님의 <대화 인터페이스, 챗봇, 그리고 자연어처리>라는 강의자료에 첨부되어 있는 그림입니다. 입력 데이터를 문장으로 가정할 때에 문장이 입력되고 그 문장이 어떤 과정에 의해서 어떻게 이해되는지에 대한 그림이 도식화되어 있습니다. 이후에 사용하는 자료도 해당 슬라이드에서 인용했습니다.

가장 기본이 되는 것은 보라색으로 표시되어 있는 부분입니다.

형태소분석기(Morphology) – 구문분석기(Syntax) – 의미분석기(Semantics) – 담화분석기(Discourse)

형태소분석(Morphology) : 명사, 조사 따위로 분리하는 단계, 의미를 가지는 가장 작은 단위로 분리
구문분석(Syntax) : 형태소들이 결합하여 문장이나 구절을 만드는 구문 규칙에 따라서 문장 내에서 각 형태소들이 가지는 역할을 분석
의미분석(Semantics) : 문장의 각 품사들이 어떤 역할을 하는지 보고 분석하는 단계, 각 어휘간 같은 단어라도 문장에서 어떤 의미로 사용되는가를 분석
담화분석기(Discourse) : 담화는 글의 흐름 및 연속체란 의미로 한 문장이 아닌 전체 문장간의 관계를 연구하여 글의 결합력과 통일성을 보는 연구, 언어가 사용되는 상황을 고려한 문맥의 이해

이러한 방식으로 자연어를 이해하고 처리합니다. 이런 과정을 통합하여 자연어처리(NLP, Natural Language Processing)라고 합니다.

챗봇을 만드는 방법은 여러가지가 있습니다. 우선 말꼬리를 이어서 대화를 이어가는 방식이 있습니다. 말을 계속해서 이어가기 때문에 적절한 응답을 할 수는 있지만 문맥의 흐름이 맞지 않을 수 있다는 단점이 있습니다.

또 하나는 미리 만들어진 대화쌍을 DB에 저장하고 사용자의 발화와 가장 유사한 대화쌍을 찾아 대화를 이어가는 방식이 있습니다. 그리고 이러한 방법을 확장을 확장하여 아래와 같이 표현합니다.

어디 사는지 물어봐도 되요? 라는 질문은 아래와 같이 6개의 질문으로 확장할 수 있습니다. 이에 따른 답변도 오른쪽과 같이 7개로 제공합니다. 이러한 대화쌍이 풍성해지면 채팅 이용자의 다양한 질문에 재밌는 방식으로 대응 할 수 있게 됩니다.

대화형 챗봇에서 가장 많이 사용되는 것은 시나리오 기반의 챗봇입니다.

물론 현재 딥러닝 기술의 발전으로 인해서 인공지능이 번역, 대화인식 등 많은 일을 해내고 있으나 아직 입력된 문장을 통해서 자연스러운 대화를 만들어 내는 것은 더 많은 연구와 노력이 필요한 단계입니다.

앞서 이야기한 시나리오 기반의 챗봇은 미리 입력한 시나리오를 통해서 대화가 진행되도록 합니다. 가장 유명한 것은 Google의 DialogFlow입니다. 이 외에도 국내에 많은 회사들이 이러한 시나리오 기반의 챗봇을 활용해서 다양한 서비스를 제공하고 있습니다.

최근 세종학당에서 개발한 <인공지능 기반의 한국어 교육용 서비스>도 이러한 방식으로 구현되었습니다. 사전에 전문가와 함께 다수의 상황별 교육용 시나리오를 제작하고 이를 챗봇 엔진에 탑재해서 이를 통해서 한글 학습을 할 수 있도록 개발되었습니다.

시나리오 기반의 챗봇은 주어진 흐름에 따른 대화만 인식한다는 단점이 있기 때문에 이를 극복하기 위한 자연스러운 예외처리가 필요합니다. 또 주제를 이탈했을 경우 어떻게 다시 주제로 복귀하는지에 대한 기술도 필요합니다.

이외에도 또 중요한 것은 대화의 의도를 파악하는 일입니다. 이것을 의도(Intention)라고 합니다.

https://d2.naver.com/helloworld/2110494

예를 들어서 대화의 순서가 “인사-주문-결제-감사”의 순으로 진행된다면 입력된 사용자의 대화가 어떤 의도로 말한 것인지 정확하게 판단해야 합니다. 의도를 파악하지 못하면 챗봇은 사용자의 질문에 엉뚱한 대답을 하게됩니다.

이런 의도를 파악하는데 다양한 인공지능 기법이 사용됩니다. 인공지능은 사용자의 글을 통해서 이것이 어떤 내용인지 분류하고 해당 분류에 있는 대답중에서 하나를 출력합니다. 예제로 구현한 테스트 코드에서는 BiLSTM을 사용합니다. 해당 알고리즘은 RNN 기법 중 하나로 분류에서 RNN에서 좋은 성능을 나타내는 알고리즘입니다.

이 외에도 슬롯-필링(Slot Filling)이라는 기법이 있습니다. 이것은 말 그대로 빈 칸을 채우는 기법입니다.

예를 들어서 날씨를 묻는 사용자의 질문에 기본적으로 시스템이 알아야 할 정보를 사전에 정의하고 부족한 정보를 다시 사용자에게 요청하는 것입니다. 만약 사용자가 “날씨를 알려줘”라고 질문하게 되면 시스템은 시간, 장소 등을 다시 물어보게 됩니다.

아래의 그림과 같이 예약을 원하는 사용자에게는 메뉴, 가격, 사이드 메뉴, 결제 방법 등을 추가로 물어볼 수 있고 이것을 Follow-Up Questions 이라고 할 수 있습니다.

https://d2.naver.com/helloworld/2110494

시나리오 기반 챗봇(Naive Scenario Chatbot)

이번 예제에서는 간단한 시나리오 기반 챗봇을 구현해보겠습니다.

해당 예제를 실행한 결과는 아래의 영상과 같습니다.

이 대화는 아래와 같이 4개의 턴(Turn)으로 이루어져있습니다. 대화의 흐름은 “인사-간단한 일상 대화-주문-끝인사”로 이뤄져있습니다. 각 턴을 수행하면 자연스럽게 다음 턴으로 연결됩니다. 대화가 예상된 흐름으로 넘어가지 않을 때는 사전에 정의된 간단한 대화를 출력하고 다시 이전 질문을 다시 수행합니다.

Dialog Flow : Greeting – Where – Order – Bye

테스트에 사용할 간단한 시나리오는 아래와 같습니다. 아래에 order – bye가 화면상에는 표시되어 있지 않지만 내용은 위와 다르지 않습니다.

category에 NaN으로 되어 있는 부분은 시스템의 발화 부분입니다. 그 외의 부분은 시스템에 입력되는 기대값들입니다.

예를 들어 greeting 카테고리를 살펴보면 시스템이 “안녕하세요”라고 발화 했을 때에 해당 발화에 답변으로는 기대되는 값들을 greeting 카테고리에 등록합니다. 현재 시스템 발화에는 하나만 등록했지만 만약 시스템 발화 부분을 다양하게 하고자 한다면 여러개의 답변을 넣고 그중에서 하나의 답을 랜덤하게 표시해주는 방법으로 해도 됩니다. 실제로 많은 채팅 시나리오가 같은 방법으로 제작되고 있습니다. 여기서는 간단하게 시스템에서는 하나의 답변만 낼 수 있도록 합니다.

시스템 메세지에 대한 사용자의 기대되는 “안녕하세요”, “안녕”, “헬로”, “네 반갑습니다”, “hi hello” 5가지 중에 하나로 입력된다고 가정합니다.

이와 같은 방법으로 “어디서 오셨나요?”라는 시스템의 질문에도 사용자는 몇가지 대답을 할 수 있습니다. 그에 대한 답변을 미리 등록해봅니다.

동일한 방법으로 나머지 시나리오도 입력해봅니다.

그렇지만 안타깝게도 위와 같이 정의된 답변만 사용자가 입력하지는 않습니다. 사용자는 여러가지 답변을 입력할 수 있습니다. 기본적으로는 챗봇에게 많은 내용을 학습시킬 수 있다면 좋겠지만 실제로 그렇게 하기는 쉽지 않습니다. 또 하나의 문제는 시스템은 사용자가 어떤 순서로 답변을 낼지 알지 못한다는 것입니다.

그렇기 때문에 챗봇 시스템은 사용자의 입력한 답변이 입력한 시나리오에 있는지 그렇다면 어떤 질문인지 만약에 아니라면 어떻게 예외적인 사항을 처리해야 하는지 알아야합니다. 즉, NLU(자연어이해, Natural Language Understanding)가 필요합니다.

이부분에 형태소 분석과 구문분석 등의 과정이 필요하고 더 높은 이해를 위해서 사전 구축등의 작업이 필요합니다. 하지만 비슷한 예제를 이미 구현했기 때문에 여기서는 간단히 해당 문장이 어떤 카테고리에 속하는지 분류하는 분류기 정도로만 구현해 보겠습니다.

딥러닝을 활용하여 텍스트 분류를 수행할 수 있습니다.

위와 같은 텍스트 분류 예제를 참고하시기 바랍니다.

간단히 작성한 시나리오를 통해서 학습을 위해 아래와 같이 각 카테고리(Category 혹은 Intent)에 코드값을 부여해줍니다. 이때 시스템의 발화는 제외하고 사용자의 발화만 코드값을 부여합니다.

index2category = {0:'greeting',1:'where',2:'ask',3:'bye'}
def category_define(x):
    code = ''
    if x=='greeting': code=0
    elif x=='where': code=1
    elif x=='ask': code=2
    elif x=='bye': code=3
    else: code='NaN'
    return code

# category code!
df['code'] = df['category'].apply(category_define)

# only answer
df=df[df['category'].notnull()]

사용자가 입력할 예상 문장들을 아래와 같이 추출할 수 있습니다.

sentence = df['text'].values
print(sentence)
array(['안녕하세요', '안녕', '헬로', '네 반갑습니다', 'hi hello', '세종에서 왔습니다',
       '세종에서 살아요', '대전 살아요', '세종이요', '세종요', '세종시에서 왔지', '서울요', '부산요',
       '빵을 사고 싶어요', '음료수 사고 싶어요', '커피 주세요', '빵 주세요', '케이크 주세요',
       '아이스 아메리카노 주세요', '베이글 주세요', '감사합니다', '고맙습니다', '잘먹을께요', 'Thank you'],
      dtype=object)

각 문장을 형태소분석이나 구분분석 등의 과정을 생략하고 단순히 문장을 공백으로 분리하여 각 단어의 집합을 생성합니다. 집합 생성시에 입력되는 문장에 단어가 없는 경우를 위해서 unk 코드와 자리수를 맞추기 위한 padding 값을 부여합니다.

sentence = df['text'].values

words = list(set([w for word in sentence for w in word.split(' ')]))
words = np.insert(words,0,'!') # padding 1
words = np.insert(words,0,'#') # unk 0

이제 생성한 문장을 단어 단위로 분리하고 각각 Index 값을 부여했기 때문에 각 문장을 숫자로 표현할 수 있습니다. word2index의 경우는 입력되는 단어들을 Index 값으로 바꿔주는 python dictionary이고 index2word는 그 반대의 경우입니다.

해당 과정을 거치면 문장은 숫자의 형태로 변경됩니다. 이렇게 하는 이유는 컴퓨터가 인간의 문장을 이해하지 못하기 때문입니다. 이제 학습을 위해 각 단어의 입력 Sequence Length를 맞춰주는 작업이 필요합니다. 이때 지나치게 패딩을 많이 입력하면 훈련 데이터에 노이즈가 많이 들어가기 때문에 예측 결과가 좋지 않을 수 있습니다. 그리고 마지막에 각 문장이 어떤 카테고리 혹은 의도(Intent)에 속하는지 Label 데이터를 입력해줍니다.

word2index = {w:i for i,w in enumerate(words)}
index2word = {i:w for i,w in enumerate(words)}

def xgenerator(x):
    return [ word2index['#'] if x_ not in word2index else word2index[x_] for x_ in x]

x_data = [xgenerator(words.split(' ')) for words in sentence]

for ndx,d in enumerate(x_data):
    x_data[ndx] = np.pad(d, (0, config.max_length), 'constant', constant_values=0)[:config.max_length]

y_data = df['code'].values

아래와 같이 학습에 필요한 파이토치 라이브러리를 import하고 DataLoader를 통해서 학습용 데이터셋을 만들어줍니다. 배치 사이즈는 데이터 셋이 크지 않기 때문에 한번에 학습을 하는 것으로 설정하시면 됩니다.

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

config.vocab_size = len(word2index)
config.input_size = 30
config.hidden_size = len(df['code'].unique())

from torch.utils.data import Dataset, DataLoader

class TxtDataSet(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]

train_loader = DataLoader(dataset=TxtDataSet(x_data, y_data), batch_size=config.batch_size, shuffle=True)

학습용 모델을 생성합니다. 학습은 아래의 3개의 레이어를 통과하고 나온 결과 값을 사용합니다. 제가 작성한 여러 예제에 해당 모델에 대한 설명이 있기 때문에 자세한 설명은 하지 않고 넘어가겠습니다.

Embedding Layer – LSTM Layer – Linear Layer

class RNN(nn.Module):
    def __init__(self, vocab_size, input_size, hidden_size):
        super().__init__()
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.vocab_size = vocab_size
        
        self.embedding = nn.Embedding(self.vocab_size, self.input_size)
        self.rnn = nn.LSTM(
            input_size = self.input_size, 
            hidden_size = self.hidden_size, 
            num_layers=4, 
            batch_first=True, 
            bidirectional=True
        )
        
        self.layers = nn.Sequential(
            nn.ReLU(), 
            nn.Linear(hidden_size*2, hidden_size),
            
        )
        
    def forward(self,x):
        x = self.embedding(x)
        y, _ = self.rnn(x)
        y = self.layers(y[:,-1]) # last output dim...

        return F.softmax(y, dim=-1)
    
model = RNN(config.vocab_size, config.input_size, config.hidden_size)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())
print(model)

생성한 모델을 출력하면 아래와 같이 표시됩니다.

RNN(
  (embedding): Embedding(35, 30)
  (rnn): LSTM(30, 4, num_layers=4, batch_first=True, bidirectional=True)
  (layers): Sequential(
    (0): ReLU()
    (1): Linear(in_features=8, out_features=4, bias=True)
  )
)
model.train()

hist_loss = []
hist_accr = []

for epoch in range(config.number_of_epochs):
    epoch_loss = 0
    for x_i, y_i in train_loader:
        y_hat = model(x_i) 
        loss = criterion(y_hat, y_i)
        
        accr = torch.argmax(y_hat, axis=1)== y_i
        accr = accr.data.numpy()
        accr = accr.sum()/len(y_i)
        
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        epoch_loss += float(loss)
    
    if epoch % 100 == 0:
        print('epoch:{}, loss:{:.5f}, accr:{:.5f}'.format(epoch, epoch_loss/config.number_of_epochs, accr))
epoch:0, loss:0.00070, accr:0.20833
epoch:100, loss:0.00068, accr:0.29167
epoch:200, loss:0.00065, accr:0.50000
epoch:300, loss:0.00062, accr:0.62500
epoch:400, loss:0.00056, accr:0.62500
epoch:500, loss:0.00054, accr:0.62500
epoch:600, loss:0.00052, accr:0.62500
epoch:700, loss:0.00051, accr:0.83333
epoch:800, loss:0.00050, accr:0.83333
epoch:900, loss:0.00049, accr:0.83333
epoch:1000, loss:0.00048, accr:0.83333
epoch:1100, loss:0.00048, accr:0.83333
epoch:1200, loss:0.00047, accr:1.00000
epoch:1300, loss:0.00046, accr:1.00000
epoch:1400, loss:0.00046, accr:1.00000
epoch:1500, loss:0.00045, accr:1.00000
epoch:1600, loss:0.00044, accr:1.00000
epoch:1700, loss:0.00044, accr:1.00000
epoch:1800, loss:0.00043, accr:1.00000
epoch:1900, loss:0.00043, accr:1.00000

학습한 모델을 통해서 입력한 어떤 내용으로 발화한 것인지를 예측해봅니다.

test_sentence = ['잘먹을께요']
x_test = [xgenerator(words.split(' ')) for words in test_sentence]
for ndx,d in enumerate(x_test):
    x_test[ndx] = np.pad(d, (0, config.max_length), 'constant', constant_values=0)[:config.max_length]
    
with torch.no_grad():
    x_test = torch.tensor(x_test, dtype=torch.long)
    predict = model(x_test)
    print(predict)
    result = torch.argmax(predict,dim=-1).data.numpy()
    print([index2category[p] for p in result])

학습을 완료한 후 모델을 아래와 같이 저장합니다.

torch.save({
  'model': model.state_dict(), 'config':config
}, './model/model.scenario')

import pickle
def save_obj(obj, name):
    with open('./pkl/'+ name + '.pkl', 'wb') as f:
        pickle.dump(obj, f, pickle.HIGHEST_PROTOCOL)
    
save_obj(index2category,'index2category')
save_obj(word2index,'word2index')
save_obj({'vocab_size':config.vocab_size,'input_size':config.input_size,'hidden_size':config.hidden_size,'max_length':config.max_length},'config')

django를 통해서 간단한 웹서버를 만들고 해당 모델을 활용해서 간단한 챗봇을 만들어봅니다.

웹서버의 사용자의 입력을 받아서 시나리오에서 어떤 흐름에 속하는 대화인지를 찾아내고 그 흐름에 맞으면 다음 대화를 진행하고 맞지 않을 경우 다시 발화를 할 수 있도록 유도합니다.

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