minhui study

마르코프 체인과 LSTM으로 문자 생성하기 본문

Python/챗봇

마르코프 체인과 LSTM으로 문자 생성하기

minhui 2020. 8. 12. 14:22

이번에는 문장 자동 생성을 해보자. 마르코프 체인과 LSTM/RNN은 서로 다른 방식의 문장 생성 방법으로 마르코프 체인은 확률을 기반으로 문장을 이어 붙여 나가는 방법이며, LSTM/RNN은 머신러닝으로 다음에 위치할 문장을 예측해서 문장을 생성하는 방법이다. 

 

마르코프 체인이란?

마르코프 체인은 마르코프 성질을 지닌 이산 확률 과정을 의미하며 이를 이용하면 기존 문장을 기반으로 문장을 자동으로 생성할 수 있다. 마르코프 성질(Markov property)이란 과거의 상태를 무시하고, 현재의 상태만을 기반으로 다음 상태를 선택하는 것을 의미한다. 예를 들어 현재 상태를 q라고 표현했을 때 다음 상태 p로 이동할 확률은 현재 상태와 다음 상태만을 기준으로 결정되므로 P( q | p )이다. 또한 마르코프 체인은 문장을 요약하는 기능도 있다. 기계적으로 문장을 생성하고, Okt에 자동으로 등록하는 트위터봇에서도 사용된다. 문장을 만드는 과정은 다음과 같다.

 

[1] 문장을 단어로 분할(형태소 분석)한다.

 

[2] 단어의 전후 연결을 딕셔너리에 등록한다.

 

[3] 사전을 사용해 임의의 문장을 생성한다. 

 

"그녀는 강아지를 좋아합니다"라는 문장이 있다고 하자. 이를 대상으로 형태소 분석을 하면 "그녀|는|강아지|를|좋아|합니다"라고 분할된다. 각 단어의 전후 관계를 알 수 있게 3개의 요소씩 묶어 다음과 같이 사전으로 등록한다.

그녀|는|강아지
는|강아지|를
강아지|를|좋아

를|좋아|합니다

N-gram과 다르게 문자 단위가 아니라 단어 단위로 처리하는 것이다. 이를 기반으로 단어들을 연결해서 문장을 만든다.

하지만 마르코프 체인은 단어의 실질적인 의미 연관성을 생각하지 않고 문장을 조합하므로 조금 이상한 문장이 만들어지는 경우가 있다.

 

마르코프 체인 구현하기

일단 형태소 분석을 하고, 이를 사전으로 만든 뒤, 사전을 기반으로 문장을 만드는 순서이다. 이전에 사용했던 "토지"의 텍스트 파일을 사용하자. 토지를 읽고, 형태소 분석을 하고, 마르코프 체인으로 문장을 생성한다. 형태소 분석에는 koNLPy를 사용할 것이다.

마르코프 체인을 사용해 문장을 만들 대 영어 계열의 언어는 모든 단어를 띄어버리면 되고, 일본어와 중국어의 경우는 모든 단어를 붙이면 된다. 한국어는 띄어쓰기 규칙이 매우 복잡하므로 만들어진 문장을 네이버 맞춤법 검사기에 넣어 띄어쓰기하자.

 

import os
import codecs
from bs4 import BeautifulSoup
from konlpy.tag import Twitter
import urllib.request
import os, re, json, random

#네이버 맞춤법 검사 요청에 user-agent 헤더 추가
import requests


# 마르코프 체인 딕셔너리 만들기 --- (※1)
def make_dic(words):
    tmp = ["@"]
    dic = {}
    for word in words:
        tmp.append(word)
        if len(tmp) < 3: continue
        if len(tmp) > 3: tmp = tmp[1:]
        set_word3(dic, tmp)
        if word == ".":
            tmp = ["@"]
            continue
    return dic
# 딕셔너리에 데이터 등록하기 --- (※2)
def set_word3(dic, s3):
    w1, w2, w3 = s3
    if not w1 in dic: dic[w1] = {}
    if not w2 in dic[w1]: dic[w1][w2] = {}
    if not w3 in dic[w1][w2]: dic[w1][w2][w3] = 0
    dic[w1][w2][w3] += 1
# 문장 만들기 --- (※3)
def make_sentence(dic):
    ret = []
    if not "@" in dic: return "no dic" 
    top = dic["@"]
    w1 = word_choice(top)
    w2 = word_choice(top[w1])
    ret.append(w1)
    ret.append(w2)
    while True:
        w3 = word_choice(dic[w1][w2])
        ret.append(w3)
        if w3 == ".": break
        w1, w2 = w2, w3
    ret = "".join(ret)
    # 띄어쓰기
    params = urllib.parse.urlencode({
        "_callback": "",
        "q": ret
    })
    # 네이버 맞춤법 검사기를 사용합니다.
    # data = urllib.request.urlopen("https://m.search.naver.com/p/csearch/ocontent/util/SpellerProxy?" + params)
    data = urllib.request.urlopen("https://m.search.naver.com/p/csearch/ocontent/spellchecker.nhn?" + params)
    data = data.read().decode("utf-8")[1:-2]
    data = json.loads(data)
    data = data["message"]["result"]["html"]
    data = soup = BeautifulSoup(data, "html.parser").getText()
    # 리턴
    return data

def word_choice(sel):
    keys = sel.keys()
    return random.choice(list(keys))
# 문장 읽어 들이기 --- (※4)
toji_file = "toji.txt"
dict_file = "markov-toji.json"
if not os.path.exists(dict_file):
    # 토지 텍스트 파일 읽어 들이기
    fp = codecs.open("BEXX0003.txt", "r", encoding="utf-16")
    soup = BeautifulSoup(fp, "html.parser")
    body = soup.select_one("body > text")
    text = body.getText()
    text = text.replace("…", "") # 현재 koNLPy가 …을 구두점으로 잡지 못하는 문제 임시 해결
    # 형태소 분석
   # twitter = Twitter()
    twitter=Twitter()
    malist = twitter.pos(text, norm=True)
    words = []
    for word in malist:
        # 구두점 등은 대상에서 제외(단 마침표는 포함)
        if not word[1] in ["Punctuation"]:
            words.append(word[0])
        if word[0] == ".":
            words.append(word[0])
    # 딕셔너리 생성
    dic = make_dic(words)
    json.dump(dic, open(dict_file,"w", encoding="utf-8"))
else:
    dic = json.load(open(dict_file,"r"))
# 문장 만들기 --- (※6)
for i in range(3):
    s = make_sentence(dic)
    print(s)
    print("---")

마르코프 체인 사전을 생성하면 JSON 형식으로 "morkov-toji.json"으로 저장한다. 그리고 이 파일을 곧바로 사용한다.

[ 1 ] : 마르고프 체인 전용 사전을 만든다. 이 사전은 파이썬 딕셔너리 자료형이며 이전에 설명했던 것처럼 세 단어가 한 세트이다.

[ 2 ] : 딕셔너리에 데이터를 등록한다. 이때 문장의 시작을 나타내는 부분을 "@'로 나타내었다.

[ 3 ] : 여기서는 문장을 만든다. 마르코프 체인의 사전에는 이어서 사용할 수 있는 후보들이 저장되어 있으므로 무작위 후보들을 하나씩 꺼내서 연결하면 나름 그럴듯한 문장이 만들어진다.

[ 4 ] : "토지"를 읽고, 간단하게 가공한다. 

[ 5 ] : 형태소 분석을 하고 마르코프 체인을 위한 딕셔너리를 생성한 뒤 JSON 파일로 저장한다.

[ 6 ] : 딕셔너리를 기반으로 문장을 생성해서 출력한다.

LSTM/RNN

문장을 생성하는 알고리즘으로 유명한 것으로 재귀 신경망(Recurrent Neural Network/RNN)과 LSTM(Long Short Term-Memory)이 있다.

RNN은 신경망을 재귀적으로 사용해 시간 순서를 가진 데이터를 다룰 수 있게 한 것이다. 그리고 LSTM은 RNN을 개량한 것이다. RNN은 바로 전의 데이터밖에 기억하지 못하지만 LSTM은 장기적으로 기억할 수 있게 여러가지 기능을 추가한 것이다. 

시간 순서를 기반으로 데이터를 다룰 수 있게 되면 문장을 쉽게 생성할 수 있다. 예를 들어 "오늘"이라고 입력했을 때 이후에 "아침"이나 "날씨" 등의 글자가 이어질 것이라고 예사하고 조합할 수 있다. 그리고 "오늘 날씨"를 입력했을 때 "맑은 날씨입니다" 등을 차례대로 조합하면 문장이 생성된다. 

 

LSTM으로 문장 생성하기

Keras를 이용해 문장을 생성해보자. 참고로 프로그램을 배치한 폴더에 국립국어원에서 받은 토지의 텍스트 파일을 함께 넣어줘야 한다.

 

import codecs
from bs4 import BeautifulSoup
from keras.models import Sequential
from keras.layers import Dense, Activation, Dropout
from keras.layers import LSTM
from keras.optimizers import RMSprop
from keras.utils.data_utils import get_file
import numpy as np
import random, sys
fp = codecs.open("./BEXX0003.txt", "r", encoding="utf-16")
soup = BeautifulSoup(fp, "html.parser")
body = soup.select_one("body")
text = body.getText() + " "
print('코퍼스의 길이: ', len(text))
# 문자를 하나하나 읽어 들이고 ID 붙이기
chars = sorted(list(set(text)))
print('사용되고 있는 문자의 수:', len(chars))
char_indices = dict((c, i) for i, c in enumerate(chars)) # 문자 → ID
indices_char = dict((i, c) for i, c in enumerate(chars)) # ID → 문자
# 텍스트를 maxlen개의 문자로 자르고 다음에 오는 문자 등록하기
maxlen = 20
step = 3
sentences = []
next_chars = []
for i in range(0, len(text) - maxlen, step):
    sentences.append(text[i: i + maxlen])
    next_chars.append(text[i + maxlen])
print('학습할 구문의 수:', len(sentences))
print('텍스트를 ID 벡터로 변환합니다...')
X = np.zeros((len(sentences), maxlen, len(chars)), dtype=np.bool)
y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t, char in enumerate(sentence):
        X[i, t, char_indices[char]] = 1
    y[i, char_indices[next_chars[i]]] = 1
# 모델 구축하기(LSTM)
print('모델을 구축합니다...')
model = Sequential()
model.add(LSTM(128, input_shape=(maxlen, len(chars))))
model.add(Dense(len(chars)))
model.add(Activation('softmax'))
optimizer = RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)
# 후보를 배열에서 꺼내기
def sample(preds, temperature=1.0):
    preds = np.asarray(preds).astype('float64')
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probas = np.random.multinomial(1, preds, 1)
    return np.argmax(probas)
# 학습시키고 텍스트 생성하기 반복
for iteration in range(1, 60):
    print()
    print('-' * 50)
    print('반복 =', iteration)
    model.fit(X, y, batch_size=128, nb_epoch=1) # 
    # 임의의 시작 텍스트 선택하기
    start_index = random.randint(0, len(text) - maxlen - 1)
    # 다양한 다양성의 문장 생성
    for diversity in [0.2, 0.5, 1.0, 1.2]:
        print()
        print('--- 다양성 = ', diversity)
        generated = ''
        sentence = text[start_index: start_index + maxlen]
        generated += sentence
        print('--- 시드 = "' + sentence + '"')
        sys.stdout.write(generated)
        # 시드를 기반으로 텍스트 자동 생성
        for i in range(400):
            x = np.zeros((1, maxlen, len(chars)))
            for t, char in enumerate(sentence):
                x[0, t, char_indices[char]] = 1.
            # 다음에 올 문자를 예측하기
            preds = model.predict(x, verbose=0)[0]
            next_index = sample(preds, diversity)
            next_char = indices_char[next_index]
            # 출력하기
            generated += next_char
            sentence = sentence[1:] + next_char
            sys.stdout.write(next_char)
            sys.stdout.flush()
        print()

실행하면 "토지"를 기반으로 글을 학습해서 텍스트를 생성한다.

파이썬을 이용한 머신러닝, 딥러닝 실전개발 입문

결과적으로 자연스러운 문장이 되지는 않았다. 토지에 사용되는 어투 때문이기도 하기도 한다. 현대적인 느낌의 한국어가 되려면 현대 소설들을 더 많이 넣어 학습해야 한다.



<참고 자료>

파이썬을 이용한 머신러닝, 딥러닝 실전 개발 입문(개정판)

Comments