이번 시간에는 텐서플로우(TensorFlow)를 이용해서 글자를 생성(Text Generation)하는 예제를 살펴보자.
딥러닝을 이용해서 Text Generation을 수행하는 대표적인 구조는 Char-RNN이다.
그림 1 – Char-RNN 구조 [3]
Char-RNN에 대한 자세한 설명은 이전 포스팅[R1]을 참조하라.
본 포스팅에서는 텐서플로우(TensorFlow)를 이용한 Char-RNN 코드 구현 중에 가장 유명한 Sherjil Ozair의 아래 GitHub 저장소 코드를 간결하게 정리한 코드를 소개한다.
https://github.com/sherjilozair/char-rnn-tensorflow
위 GitHub 저장소는 Char-RNN을 안정적인 성능으로 구현했지만 초심자가 한눈에 보기엔 쉽지 않은 구성이다. 따라서 Sherjil Ozair의 코드를 간결하게 정리하고 한글 주석을 추가해서 초심자분들도 쉽게 코드를 따라올 수 있도록 아래 GitHub 저장소에 올려놓았다.
https://github.com/solaris33/char-rnn-tensorflow/
위 저장소의 train.py 코드를 살펴보면 아래와 같다.
| # -*- coding: utf-8 -*- | |
| # Char-RNN 예제 | |
| # Author : solaris33 | |
| # Project URL : http://solarisailab.com/archives/2487 | |
| # GitHub Repository : https://github.com/solaris33/char-rnn-tensorflow/ | |
| # Reference : https://github.com/sherjilozair/char-rnn-tensorflow | |
| import tensorflow as tf | |
| import numpy as np | |
| from utils import TextLoader | |
| # 학습에 필요한 설정값들을 지정합니다. | |
| data_dir = 'data/tinyshakespeare' | |
| #data_dir = 'data/linux' | |
| batch_size = 50 # Training : 50, Sampling : 1 | |
| seq_length = 50 # Training : 50, Sampling : 1 | |
| hidden_size = 128 # 히든 레이어의 노드 개수 | |
| learning_rate = 0.002 | |
| num_epochs = 2 | |
| num_hidden_layers = 2 | |
| grad_clip = 5 # Gradient Clipping에 사용할 임계값 | |
| # TextLoader를 이용해서 데이터를 불러옵니다. | |
| data_loader = TextLoader(data_dir, batch_size, seq_length) | |
| # 학습데이터에 포함된 모든 단어들을 나타내는 변수인 chars와 chars에 id를 부여해 dict 형태로 만든 vocab을 선언합니다. | |
| chars = data_loader.chars | |
| vocab = data_loader.vocab | |
| vocab_size = data_loader.vocab_size # 전체 단어개수 | |
| # 인풋데이터와 타겟데이터, 배치 사이즈를 입력받기 위한 플레이스홀더를 설정합니다. | |
| input_data = tf.placeholder(tf.int32, shape=[None, None]) # input_data : [batch_size, seq_length]) | |
| target_data = tf.placeholder(tf.int32, shape=[None, None]) # target_data : [batch_size, seq_length]) | |
| state_batch_size = tf.placeholder(tf.int32, shape=[]) # Training : 50, Sampling : 1 | |
| # RNN의 마지막 히든레이어의 출력을 소프트맥스 출력값으로 변환해주기 위한 변수들을 선언합니다. | |
| # hidden_size -> vocab_size | |
| softmax_w = tf.Variable(tf.random_normal(shape=[hidden_size, vocab_size]), dtype=tf.float32) | |
| softmax_b = tf.Variable(tf.random_normal(shape=[vocab_size]), dtype=tf.float32) | |
| # num_hidden_layers만큼 LSTM cell(히든레이어)를 선언합니다. | |
| cells = [] | |
| for _ in range(0, num_hidden_layers): | |
| cell = tf.nn.rnn_cell.BasicLSTMCell(hidden_size) | |
| cells.append(cell) | |
| # cell을 종합해서 RNN을 정의합니다. | |
| cell = tf.contrib.rnn.MultiRNNCell(cells, state_is_tuple=True) | |
| # 인풋데이터를 변환하기 위한 Embedding Matrix를 선언합니다. | |
| # hidden_size -> vocab_size | |
| embedding = tf.Variable(tf.random_normal(shape=[vocab_size, hidden_size]), dtype=tf.float32) | |
| inputs = tf.nn.embedding_lookup(embedding, input_data) | |
| # 초기 state 값을 0으로 초기화합니다. | |
| initial_state = cell.zero_state(state_batch_size, tf.float32) | |
| # 학습을 위한 tf.nn.dynamic_rnn을 선언합니다. | |
| # outputs : [batch_size, seq_length, hidden_size] | |
| outputs, final_state = tf.nn.dynamic_rnn(cell, inputs, initial_state=initial_state, dtype=tf.float32) | |
| # ouputs을 [batch_size * seq_length, hidden_size]] 형태로 바꿉니다. | |
| output = tf.reshape(outputs, [-1, hidden_size]) | |
| # 최종 출력값을 설정합니다. | |
| # logits : [batch_size * seq_length, vocab_size] | |
| logits = tf.matmul(output, softmax_w) + softmax_b | |
| probs = tf.nn.softmax(logits) | |
| # Cross Entropy 손실 함수를 정의합니다. | |
| loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=target_data)) | |
| # 옵티마이저를 선언하고 옵티마이저에 Gradient Clipping을 적용합니다. | |
| # grad_clip(=5)보다 큰 Gradient를 5로 Clippin합니다. | |
| tvars = tf.trainable_variables() | |
| grads, _ = tf.clip_by_global_norm(tf.gradients(loss, tvars), grad_clip) | |
| optimizer = tf.train.AdamOptimizer(learning_rate) | |
| train_step = optimizer.apply_gradients(zip(grads, tvars)) | |
| # 세션을 열고 학습을 진행합니다. | |
| with tf.Session() as sess: | |
| # 변수들에 초기값을 할당합니다. | |
| sess.run(tf.global_variables_initializer()) | |
| for e in range(num_epochs): | |
| data_loader.reset_batch_pointer() | |
| # 초기 상태값을 지정합니다. | |
| state = sess.run(initial_state, feed_dict={state_batch_size : batch_size}) | |
| for b in range(data_loader.num_batches): | |
| # x, y 데이터를 불러옵니다. | |
| x, y = data_loader.next_batch() | |
| # y에 one_hot 인코딩을 적용합니다. | |
| y = tf.one_hot(y, vocab_size) # y : [batch_size, seq_length, vocab_size] | |
| y = tf.reshape(y, [-1, vocab_size]) # y : [batch_size * seq_length, vocab_size] | |
| y = y.eval() | |
| # feed-dict에 사용할 값들과 LSTM 초기 cell state(feed_dict[c])값과 hidden layer 출력값(feed_dict[h])을 지정합니다. | |
| feed_dict = {input_data : x, target_data: y, state_batch_size : batch_size} | |
| for i, (c, h) in enumerate(initial_state): | |
| feed_dict[c] = state[i].c | |
| feed_dict[h] = state[i].h | |
| # 한스텝 학습을 진행합니다. | |
| _, loss_print, state = sess.run([train_step, loss, final_state], feed_dict=feed_dict) | |
| print("{}(학습한 배치개수)/{}(학습할 배치개수), 반복(epoch): {}, 손실함수(loss): {:.3f}".format( | |
| e * data_loader.num_batches + b, | |
| num_epochs * data_loader.num_batches, | |
| (e+1), | |
| loss_print)) | |
| print("트레이닝이 끝났습니다!") | |
| # 샘플링 시작 | |
| print("샘플링을 시작합니다!") | |
| num_sampling = 4000 # 생성할 글자(Character)의 개수를 지정합니다. | |
| prime = u' ' # 시작 글자를 ' '(공백)으로 지정합니다. | |
| sampling_type = 1 # 샘플링 타입을 설정합니다. | |
| state = sess.run(cell.zero_state(1, tf.float32)) # RNN의 최초 state값을 0으로 초기화합니다. | |
| # Random Sampling을 위한 weighted_pick 함수를 정의합니다. | |
| def weighted_pick(weights): | |
| t = np.cumsum(weights) | |
| s = np.sum(weights) | |
| return(int(np.searchsorted(t, np.random.rand(1)*s))) | |
| ret = prime # 샘플링 결과를 리턴받을 ret 변수에 첫번째 글자를 할당합니다. | |
| char = prime # Char-RNN의 첫번쨰 인풋을 지정합니다. | |
| for n in range(num_sampling): | |
| x = np.zeros((1, 1)) | |
| x[0, 0] = vocab[char] | |
| # RNN을 한스텝 실행하고 Softmax 행렬을 리턴으로 받습니다. | |
| feed_dict = {input_data: x, state_batch_size : 1, initial_state: state} | |
| [probs_result, state] = sess.run([probs, final_state], feed_dict=feed_dict) | |
| # 불필요한 차원을 제거합니다. | |
| # probs_result : (1,65) -> p : (65) | |
| p = np.squeeze(probs_result) | |
| # 샘플링 타입에 따라 3가지 종류로 샘플링 합니다. | |
| # sampling_type : 0 -> 다음 글자를 예측할때 항상 argmax를 사용 | |
| # sampling_type : 1(defualt) -> 다음 글자를 예측할때 항상 random sampling을 사용 | |
| # sampling_type : 2 -> 다음 글자를 예측할때 이전 글자가 ' '(공백)이면 random sampling, 그렇지 않을 경우 argmax를 사용 | |
| if sampling_type == 0: | |
| sample = np.argmax(p) | |
| elif sampling_type == 2: | |
| if char == ' ': | |
| sample = weighted_pick(p) | |
| else: | |
| sample = np.argmax(p) | |
| else: | |
| sample = weighted_pick(p) | |
| pred = chars[sample] | |
| ret += pred # 샘플링 결과에 현재 스텝에서 예측한 글자를 추가합니다. (예를들어 pred=L일 경우, ret = HEL -> HELL) | |
| char = pred # 예측한 글자를 다음 RNN의 인풋으로 사용합니다. | |
| print("샘플링 결과:") | |
| print(ret) |
코드를 실행하면 Char-RNN이 셰익스피어의 희곡 <리처드 3세>로 학습을 진행한뒤, 샘플링하는 모습을 볼 수 있다. 실험 결과 중 일부를 발췌하면 아래와 같다.
First Lord: Or Ray Cluicians? you appeace, advain. Shepherd: Then? Who do valiantage, the trail swoed's Englong Offords in you, why this paliancac's. KING RICHARD III: O, and his in plawful your joyters. First MuntaSTsa: All If I sir? I'll none may to see Zhose than saught; Beven play'st scalk in Rich, I dolicides are when I mays good friend; Hath no sunbs her side to lip him; for you, And she offendeed: I dear, alome no; Have their leave his briefer's proceasing in these his sain whice captas wind supposent. And such I showners of ever my lease be sing A fall before the base grown for Edward these comprace. BENVOLIO: Have action, Ant confeigh'd. ANGELO: That you say go may truon a have lender mother suffe: How shord, as mengef, King side Lancast, may. First Duky you, my raz?
그림 2 – Char-RNN으로 셰익스피어 희곡 <리처드 3세>를 학습하고 샘플링으로 새로운 데이터를 생성한 결과
한 눈에 봐도 그럴듯한 텍스트를 생성해낸 모습을 볼 수 있다. 희곡은 Char-RNN의 학습 데이터로 널리 사용되는데 RNN이 학습하는 것은 글자배열의 패턴이기 때문이다. 희곡 같은 경우 문장의 첫 부분에 “등장인물:” 형태의 고정된 패턴이 반복해서 등장하기 때문에 Char-RNN의 학습에 적합하다.
그림 2의 <리처드 3세> 샘플링 결과를 보면 <리처드 3세>의 실제 등장인물인 (First Lord, Shepherd, KING RICHARD III, BENVOLIO, ANGELO) 등의 이름을 정확히 생성해낸 모습을 볼 수 있다. 하지만 (First MuntaSTsa)와 이상한 이름을 생성한 경우도 있다. 또한 생성한 영어 대사의 경우, 의미론적으로는 말이 안되지만 구조적으로는 그럴듯한 모습을 띄는 것을 알 수 있다.
이제 다른 학습데이터로 재밌는 실험을 하나 더 해보자. 프로그래밍 언어 또한 언어의 문법에 따라 일정한 패턴으로 코드가 작성되기 때문에 Char-RNN의 학습에 적합하다. 그림 3은 C언어로 작성된 리눅스 소스코드를 이용해서 Char-RNN을 학습시킨 뒤 샘플링한 결과 중 일부를 발췌한 것이다.
void graph_index(struct hrtime_hardetr, bolaction, struct hectx *tsk) { struct plog_batch; get_rrgbum_shifock_remove_elaper(L)) { dr.name_graph(return, tsk); if (index = &bay->count - (smap, hardid_usigned && tr->sched_name && ~SUPTION_BT(032)) return 1; raw_spin_unlock(dstat_val))) return -EINVAL; i+++) return -EINVA; } static inline bufferpoid(tp, char *timer, struct rq *rq) { } #else return rq->ret_module_aps(p, i) gore, &path) & " factive it rcu\bled tsk0. * * Arce a dis: return error = 1; retuct task_next; if (hadd_piocup.ext, sizeof(queued); result = &context->trace_hung_twueued entry->graph_find_rmatf("Tode, infags or */ return prink; timekeeping_write(, p2)) return; }
그림 3 – Char-RNN으로 <Linux 소스코드>를 학습하고 샘플링으로 새로운 데이터를 생성한 결과
희곡의 경우와 마찬가지로 의미론적으로는 말이 안되지만 구조적으로는 그럴듯한 모습을 띄는 것을 알 수 있다.
연관 포스팅
References
[1] https://github.com/sherjilozair/char-rnn-tensorflow
[2] https://github.com/solaris33/char-rnn-tensorflow/
[3] http://karpathy.github.io/2015/05/21/rnn-effectiveness/


