카카오톡 대화 생성기

카카오톡 대화 생성기

2017, Apr 05    

들어가며

올초부터 Udacity에서 Deep Learning 과정을 듣고 있다. 이래저래 벌려놓은 일이 많아 신청할까 망설였었는데, 돌아보니 잘한 듯 하다. 예전에 아무것도 모른 채 강필성 교수님이 하셨던 패캠 머신러닝 수업을 들었던 것처럼, 딥러닝에 대해 세세히 이해하지는 못하더라도, 대략적인 개념을 파악하고 새로운 아이디어를 얻는 것만으로도 즐거운 자극이 되었다.

아직 코스가 진행중이긴 한데, Recurrent Neural Network라는 재밌는 도구를 배우게 되어 간단히 작은 프로젝트를 기록으로 남겨둔다.


Recurrent Neural Network

RNN은 Recurrent Neural Network의 약자로, 딥러닝의 한 방법론이다. 기본적인 형태의 DNN과 CNN이 고정된 크기의 입력/출력값을 가지는데 반해, 시퀀스를 처리할 수 있는 RNN은 그럴 필요가 없다. 그래서 길이가 다양한 텍스트나 비디오 데이터를 처리하는데 자주 쓰인다.

RNN에 대한 보다 자세한 설명은 안드레 카파시 블로그에서 확인할 수 있다.

RNN으로 할 수 있는 재밌는 것 중 하나는 텍스트를 학습해서 그 패턴에 기반해 새로운 텍스트를 생성하는 것이다. Udacity 튜토리얼에서는 톨스토이의 ‘안나 카레리나’ 소설을 사용해서 새 텍스트를 만들었다. 이 튜토리얼에서 사용한 tensorflow 라이브러리와 로직을 사용해서 카카오톡 대화를 생성해본다.


Data Preprocessing

카카오톡 데이터는 대화방에서 텍스트 대화만을 내려받아 얻는다. 압축을 풀면 txt 파일이 여러개 나오는데, 다음과 같이 처리한다. 본 테스트를 위해 대학친구 8명이 있는 카카오톡 대화방 2016.10 ~ 2017.03 데이터를 내려받았다.

import glob
import pickle
import pandas as pd

files = glob.glob("./data/*.txt")

## 영문으로만 된 문장은 제외한다
def isEnglish(s):
    try:
        s.encode('ascii')
    except UnicodeEncodeError:
        return False
    else:
        return True

## 파일을 열어
## 대화명 : 대화를 파싱하고
## 순수 영문 문장이나,
## 'ㅋㅋㅋㅋ'인 문장은 걸러낸다
def read_das_file(a_file):
    sentence_list = []
    f = open(a_file, 'r')
    for line in f:
        if (" : " in line) & ("M, " in line):
            person = line.split(" : ")[0].split("M, ")[1]
            line = line.split(" : ")[1]
            if isEnglish(line) == False:
                if set(line) != {"ㅋ", "\n"}:
                    a_dict = {'person':person, 'line':line}

                    sentence_list.append(a_dict)
    return sentence_list

sentence_lists = [read_das_file(file) for file in files]

## 리스트를 평평하게 만든다.
sentence_list = [sentence for sublist in sentence_lists for sentence in sublist]

이렇게만 처리해도 대화 데이터를 얻을 수 있으나, 소설과 달리 메신저에서는 엔터를 더 자주 친다. 따라서 화자가 연속된 경우, 잘려진 문장은 하나의 문장으로 처리한다.

df = pd.DataFrame(sentence_list)
df['counter'] = 0

counter = 0

## 현재 화자와 다음 화자가 다른 경우, counter를 증가시킨다.
for i in range(1, len(df)):
    current_person = df.ix[i]['person']
    next_person = df.ix[i-1]['person']
    if current_person != next_person:
        counter = counter + 1
    df.set_value(i, 'counter', counter)

## 화자와 counter를 더해 message_idx를 만든다.
df['message_idx'] = df.person.astype(str) + "-" + df.counter.astype(str)

## counter와 message_idx로 그룹바이하여 문장을 리스트로 묶는다.
parsed = pd.DataFrame(df.groupby(['counter', 'message_idx'])['line'].apply(list))

## 묶은 라인을 하나의 스트링으로 합치고 줄바꿈을 마지막에 붙인다.
parsed['line2'] = parsed['line'].map(lambda x: " ".join(x).replace("\n", "") + "\n")

## 문장을 pickle로 저장한다.
final_text = parsed['line2'].values
with open("das_data_parsed.pickle", "wb") as f:
    pickle.dump(final_text, f)

카카오톡 대화 생성기

1. 데이터 준비

import time
from collections import namedtuple
import numpy as np
import tensorflow as tf
import pickle

data = pickle.load(open("das_data_parsed.pickle", "rb"))
text = "".join(data)

text[:100]
## 사진이 왜케 많냐 저게 다 기본 8년전 7년전 이렇다\n나랑 자주놀앗구먼ㅋㅋ\n싸이월드 사진첩 다운로드 받는중인데 이쁜게 많네\n일시: 5/3(화) 2시 304호  휴. 비도 오


## 모든 문자셋을 만든다.
vocab = set(text)

## 문자셋의 문자에 고유한 숫자를 매긴다. 
vocab_to_int = {c: i for i, c in enumerate(vocab)}

## 그 반대로 숫자에 문자를 대응시킨다. 
## 이렇게 함으로써 신경망을 학습시키고, 
## 신경망에서 생성한 숫자 시퀀스를 다시 문자열로 변환할 수 있다.
int_to_vocab = dict(enumerate(vocab))
chars = np.array([vocab_to_int[c] for c in text], dtype=np.int32)

chars[:100]
## array([841,  509, 1077,  455, ... 1408,  785,  150, 341], dtype=int32)
## 이렇게 문자가 숫자로 변환된다.

num_of_all_chars = np.max(chars) + 1
print("글자 가짓수: %d" %num_of_all_chars)
## 글자 가짓수: 1862

2. 학습 & 검증 batch 준비

데이터셋을 준비했다면, 다음은 학습할 트레이닝 데이터셋과 학습 결과를 측정할 검증 데이터셋을 분리해야 한다. 그리고, 긴 텍스트를 다 집어넣는 것이 아니라, 적절한 크기로 잘라 배치(batch)로 만들어 모델에 넣어주어야 한다.

데이터를 자르는 split_data, 배치를 생성하는 get_batch 함수를 만든다. 여기서 만든 함수는 뒤에 모델을 학습시킬 때 tensorflow Session 안에서 실행된다.

def split_data(chars, batch_size, num_steps, split_frac=0.9):
    """
    문자 데이터를 학습 & 검증 데이터로 분리
    
    파라미터
    ------
    chars: 문자열 배열
    batch_size: 각 배치 크기
    num_steps: 입력 데이터에 넣을 시퀀스 스텝 수
    split_frac: 트레이닝셋에 넣을 데이터 비율
    
    returns train_x, train_y, val_x, val_y
    """
    
    slice_size = batch_size * num_steps
    n_batches = int(len(chars) / slice_size)
    
    # 배치로 나누고 난 후 남은 잔챙이는 버리자
    # y는 x에 1을 더한다. 왜냐하면 캐릭터 단위로 하나씩 미는 RNN이므로.
    x = chars[: n_batches * slice_size]
    y = chars[1: n_batches * slice_size + 1]
    
    # 배치 크기에 따라 데이터를 자르고, 2차원 매트릭스로 쌓는다.
    x = np.stack(np.split(x, batch_size))
    y = np.stack(np.split(y, batch_size))
    
    # 이제 x와 y는 batch_size x n_batches*num_steps로 된 2차원 배열임
    
    # 이를 학습과 검증 셋으로 나눈다.
    split_idx = int(n_batches * split_frac)
    train_x, train_y = x[:, :split_idx*num_steps], y[:, :split_idx*num_steps]
    val_x, val_y = x[:, split_idx*num_steps:], y[:, split_idx*num_steps:]
    
    return train_x, train_y, val_x, val_y

## 이런식으로 자를 수 있다.
## train_x, train_y, val_x, val_y = split_data(chars, 10, 50)

## 배치를 생성하는 함수를 만든다.
def get_batch(arrs, num_steps):
    """
    함수가 호출될때마다 배치를 리턴한다.
    
    파라미터
    ------
    arrs: 전체 배열
    num_steps: 입력 데이터에 넣을 시퀀스 스텝 수
    """
    batch_size, slice_size = arrs[0].shape
    
    n_batches = int(slice_size / num_steps)
    for b in range(n_batches):
        yield [x[:, b*num_steps: (b+1)*num_steps] for x in arrs]

3. RNN 모델 만들기

sklearn으로 jupyter notebook에서 해보는 간단한 머신러닝과는 달리, tensorflow를 쓸 때는 먼저 모델을 정의하고, 그 다음 실제 오퍼레이션이 이루어진다. 함수를 실행하면 바로 결과가 나오지 않기 때문에 개인적으로는 쉽게 받아들일 수 있는 부분은 아니었다.

def build_rnn(num_classes, batch_size=50, num_steps=50, lstm_size=128, num_layers=2,
             learning_rate=0.001, grad_clip=5, sampling=False):
    """
    RNN 모델을 만든다.
    
    파라미터
    ------
    num_classes: 문자 가짓수
    batch_size: 배치 크기
    num_steps: 입력 데이터로 쓸 시퀀스 스텝 수
    lstm_size: LSTM 셀의 유닛 수
    num_layers: LSTM 레이어 갯수
    learning_rate: 학습 속도 알파
    grad_clip: gradient가 폭증하거나 사라지는 것을 막아주는 임계값
    sampling: True인 경우, batch_size와 num_steps를 1로 지정
    """
    
    if sampling == True:
        batch_size, num_steps = 1, 1
        
    # 그래프를 초기화
    tf.reset_default_graph()
    
    # 입력 & 타겟 변수 선언
    inputs = tf.placeholder(tf.int32, [batch_size, num_steps], name='inputs')
    targets = tf.placeholder(tf.int32, [batch_size, num_steps], name='targets')
    
    # dropout 레이어를 위한 값 설정
    keep_prob = tf.placeholder(tf.float32, name='keep_prob')
    
    # 입력 및 타겟 변수를 원핫인코딩
    x_one_hot = tf.one_hot(inputs, num_classes)
    y_one_hot = tf.one_hot(targets, num_classes)
    
    ### RNN 레이어를 만든다
    # 기본 LSTM cell을 사용함
    lstm = tf.contrib.rnn.BasicLSTMCell(lstm_size)
    
    # dropout 레이어 추가
    drop = tf.contrib.rnn.DropoutWrapper(lstm, output_keep_prob=keep_prob)
    
    # LSTM 레이어를 여러개 쌓아서 딥러닝 모델 구축
    cell = tf.contrib.rnn.MultiRNNCell([drop] * num_layers)
    initial_state = cell.zero_state(batch_size, tf.float32)
    
    ### RNN 레이어에 데이터를 넣는다
    rnn_inputs = [tf.squeeze(i, axis=[1]) for i in tf.split(x_one_hot, num_steps, 1)]
    
    # RNN에 각 시퀀스를 넣고 결과를 출력
    outputs, state = tf.contrib.rnn.static_rnn(cell, rnn_inputs, initial_state=initial_state)
    final_state = state
    
    # output의 형태를 바꿈
    seq_output = tf.concat(outputs, axis=1)
    output = tf.reshape(seq_output, [-1, lstm_size])
    
    # RNN 결과를 softmax 레이어에 연결
    with tf.variable_scope('softmax'):
        softmax_w = tf.Variable(tf.truncated_normal((lstm_size, num_classes), stddev=0.1))
        softmax_b = tf.Variable(tf.zeros(num_classes))
    
    # 행렬 계산
    logits = tf.matmul(output, softmax_w) + softmax_b
    
    # 소프트맥스에 넣어 다음 문자의 확률을 계산 (총합은 1)
    preds = tf.nn.softmax(logits, name='prediction')
    
    # target의 형태를 변형해서 logits에 맞춤
    y_reshaped = tf.reshape(y_one_hot, [-1, num_classes])
    loss = tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=y_reshaped)
    cost = tf.reduce_mean(loss)
    
    # AdamOptimizer와 gradient clipping으로 최적화
    tvars = tf.trainable_variables()
    grads, _ = tf.clip_by_global_norm(tf.gradients(cost, tvars), grad_clip)
    train_op = tf.train.AdamOptimizer(learning_rate)
    optimizer = train_op.apply_gradients(zip(grads, tvars))
    
    # 노드 출력
    export_nodes = ['inputs', 'targets', 'initial_state', 'final_state',
                   'keep_prob', 'cost', 'preds', 'optimizer']
    
    Graph = namedtuple('Graph', export_nodes)
    local_dict = locals()
    graph = Graph(*[local_dict[each] for each in export_nodes])
    
    return graph

코드가 길지만 기본적으로 여기서 사용한 RNN 구조는 간단하다. 여기서 사용한 RNN은 character-wise RNN으로, 문자열이 있을 때, 바로 다음 ‘문자’가 무엇이 올지 예측한다. 즉, ‘나는 학교에 간다’는 입력이 주어지면, 그에 대응되는 출력은 ‘는 학교에 간다 ‘가 된다. 이렇게 하면 형태소 처리나 TF-IDF 같은 NLP에서 해줘야 하는 많은 어려운 문제들을 우회할 수 있다. 단, 그 수많은 패턴 학습을 위해서는 충분히 많은 데이터와 파라미터 튜닝이 필요한 것 같다.

모델에 들어가는 파라미터를 정의해준다.

batch_size = 100
num_steps = 100
lstm_size = 512
num_layers = 2
learning_rate = 0.001
keep_prob = 0.5

딥러닝이 상당히 많은 문제를 해결해주기는 하지만, 파라미터를 여전히 사람이 컨트롤 해줘야 하는 부분이다. training과 validation loss 를 지켜보면서 overfitting인지 underfitting인지 판단하고, 그에 따라 데이터 양을 늘릴지 줄일지, dropout prob을 조정할지, 레이어를 추가할지 등의 결정을 내려야 한다.

카카오톡 데이터를 처리해보면서 레이어를 3개를 만들어보기도, LSTM 셀 수, learning_rate 등 여러개를 조정해봤는데, 위의 세팅이 가장 적절한 학습 결과를 출력했었다. 특히 레이어 3개를 넣는 순간 오버피팅이 심하게 일어나면서, 전혀 이해할 수 없는 괴텍스트가 생성되기도 했다. 딥러닝에서 무조건 딥하게 간다고 문제가 해결되지 않는다.

LSTM 레이어를 3개를 쌓은 경우


4. 학습

실제 데이터를 모델에 넣어 학습시킨다.

epochs = 20

# N 이터레이션마다 저장
save_every_n = 100

# 데이터 준비
train_x, train_y, val_x, val_y = split_data(chars, batch_size, num_steps)

model = build_rnn(len(vocab),
                 batch_size=batch_size,
                 num_steps=num_steps,
                 learning_rate=learning_rate,
                 lstm_size=lstm_size,
                 num_layers=num_layers)

saver = tf.train.Saver(max_to_keep=100)

with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    
    n_batches = int(train_x.shape[1]/num_steps)
    iterations = n_batches * epochs
    
    # 에폭을 돌면서
    for e in range(epochs):
        
        # 네트워크를 학습시킨다
        new_state = sess.run(model.initial_state)
        loss = 0

        # get_batch 함수로 train 데이터셋을 생성하여 모델에 입력한다.
        for b, (x, y) in enumerate(get_batch([train_x, train_y], num_steps), 1):
            iteration = e*n_batches + b
            start = time.time()
            feed = {model.inputs: x,
                    model.targets: y,
                    model.keep_prob: keep_prob,
                    model.initial_state: new_state}
            batch_loss, new_state, _ = sess.run([model.cost, model.final_state, model.optimizer],
                                               feed_dict=feed)
            loss += batch_loss
            end = time.time()
            print('Epoch {}/{}'.format(e+1, epochs),
                 'Iteration {}/{}'.format(iteration, iterations),
                 'Traning loss: {:.4f}'.format(loss/b),
                 '{:.4f} sec/batch'.format((end-start)))
            
            # n 이터레이션 마다, 혹은 마지막 이터레이션에 도달하면
            if (iteration%save_every_n == 0) or (iteration == iterations):
                # 성능을 체크한다.
                val_loss = []
                new_state = sess.run(model.initial_state)
                for x, y in get_batch([val_x, val_y], num_steps):
                    feed = {model.inputs: x,
                            model.targets: y,
                            model.keep_prob: 1.,
                            model.initial_state: new_state}
                    batch_loss, new_state = sess.run([model.cost, model.final_state], feed_dict=feed)
                    val_loss.append(batch_loss)
                    
                print('Validation loss:', np.mean(val_loss), '체크포인트 저장!')
                saver.save(sess, "checkpoints_parsed/i{}_l{}_v{:3f}.ckpt".format(iteration, lstm_size, np.mean(val_loss)))

## Epoch 1/20 Iteration 1/660 Traning loss: 7.5287 8.4289 sec/batch
## Epoch 1/20 Iteration 2/660 Traning loss: 7.4979 7.2635 sec/batch
## Epoch 1/20 Iteration 3/660 Traning loss: 7.3909 7.2701 sec/batch
## ...
## Epoch 3/20 Iteration 99/660 Traning loss: 5.0322 7.1951 sec/batch
## Epoch 4/20 Iteration 100/660 Traning loss: 5.1010 7.1860 sec/batch
## Validation loss: 4.94154 체크포인트 저장!

모델이 깊어지고, num_step(시퀀스의 길이)가 커질수록 학습이 오래 걸린다. num_step을 200으로 올려본 결과, Iteration당 배치 학습에 걸린 시간이 7초에서 15초로 2배 증가하였다. 전체 트레이닝 데이터셋(약 4MB)을 학습시키면 현재 맥북프로 Mid 2015 머신으로 2시간 정도가 소요되었다.


5. 결과 확인하기

모델이 학습되고 나면, 체크포인트에 저장한 모델을 불러와서 새로운 텍스트를 생성할 수 있다.

def pick_top_n(preds, vocab_size, top_n=5):
    p = np.squeeze(preds)
    p[np.argsort(p)[:-top_n]] = 0
    p = p / np.sum(p)
    c = np.random.choice(vocab_size, 1, p=p)[0]
    return c

## 체크포인트에 저장된 모델을 불러와서 새로운 텍스트를 생성한다. 
def sample(checkpoint, n_samples, lstm_size, vocab_size, prime="안녕 "):
    samples = [c for c in prime]
    model = build_rnn(vocab_size, lstm_size=lstm_size, sampling=True)
    saver = tf.train.Saver()
    with tf.Session() as sess:
        saver.restore(sess, checkpoint)
        new_state = sess.run(model.initial_state)
        for c in prime:
            x = np.zeros((1, 1))
            x[0, 0] = vocab_to_int[c]
            feed = {model.inputs: x,
                   model.keep_prob: 1.,
                   model.initial_state: new_state}
            preds, new_state = sess.run([model.preds, model.final_state],
                                       feed_dict=feed)
            
        c = pick_top_n(preds, len(vocab))
        samples.append(int_to_vocab[c])
        
        for i in range(n_samples):
            x[0, 0] = c
            feed = {model.inputs:x,
                   model.keep_prob: 1.,
                   model.initial_state: new_state}
            
            preds, new_state = sess.run([model.preds, model.final_state],
                                       feed_dict=feed)
            
            c = pick_top_n(preds, len(vocab))
            samples.append(int_to_vocab[c])
            
        return ''.join(samples)

## 이런식으로 모델을 불러와서 실행한다.
checkpoint = "checkpoints_parsed/i660_l512_v3.607831.ckpt"
samp = sample(checkpoint, 100, lstm_size, len(vocab), prime="친구")
print(samp)

위에서 학습시킨 결과는 총 660번 Iteration을 돌렸는데, 매 100번마다 모델을 저장하게 해두었다. 다른 머신러닝 알고리즘이 그러하듯, RNN 역시 이터레이션이 반복될수록 모델을 최적화한다. 100번, 400번, 660번 돌린 결과를 보자.

100번, 400번, 660번 학습 결과

학습이 진행됨에 따라 문장의 형태는 좀 더 말이 되는 방향으로 진화해가고 있다. 하지만 바로 다음 문자를 기계적으로 예측하는 것이므로, 특정 주제에 대해 이야기하거나, 주고받는 식으로 대화가 진행되지는 않는다. 그래도 이 결과는 ㅋㅋㅋ를 제외하지 않은 학습결과보다는 더 괜찮아 보인다.

'ㅋㅋㅋ'를 포함했을 때 학습 결과

다음은 몇가지 키워드로 생성한 텍스트들.

몇가지 샘플 텍스트

위 모델에는 화자 구분 없이 모든 대화 시퀀스를 넣어보았다. 화자별로 텍스트를 나눠 학습해보면, ‘황준식’이라는 개인의 대화 패턴으로 텍스트를 생성할 수 있을 것이고, 다른 친구로 학습한 모델과 서로 대화하는 프로그램을 짜볼 수 있지 않을까 싶었는데, 문제가 몇개 있었다.

먼저 전체 데이터셋 자체가 크지 않은 상황에서, 특정 개인의 말수가 더욱 줄어들어 학습이 원하는 만큼 잘 되지 않았다. 그러다보니 문장이 짧고 분절된 메신저 대화의 특성상 시퀀스 길이나 배치 크기 등의 파라미터 조정이 더 어려워졌다. 데이터셋이 더 많이 쌓이고, 고도화된 RNN 모델을 만들 수준이 되면 다시 시도해봐야겠다.


앞으로 더 해볼 수 있는 것들

여기서 테스트해본 모델은 가장 기본적인 형태의 RNN으로, 대화를 흉내낼 뿐, 의미있는 대화를 만든다고 보기는 어렵다. ‘주제’를 입력받아 그에 연관된 텍스트를 생성하는 RNN 모델을 만들어보면 재밌을 것 같다. 또 RNN에 넣기 전에 문자나 단어를 embedding하거나, CNN을 넣어 처리하는 방법 등 여러 다른 variation을 테스트해보면 어떨까 싶다.