머신 러닝 교과서/ 순환 신경망으로 시퀀스 데이터 모델링

시퀀스 데이터 소개

시퀀스 데이터 모델링: 순서를 고려한다

  • 다른 데이터 타입과 다르게 시퀀스는 특별하다. 시퀀스 원소들은 특정 순서를 가지므로 상호 독립적이지 않기 때문이다.
  • 일반적으로 지도 학습의 머신 러닝 알고리즘은 입력 데이터가 독립 동일 분포(Independent and Identically Distributed, IID)라고 가정한다.
    • 예컨대 n 개의 데이터 샘플 x^{(1)}, x^{(2)}, ... , x^{(n)} 이 있을 떄 머신 러닝 알고리즘을 훈련하기 위해 데이터를 사용하는 순서는 상관없다.
    • 시쿠너스 데이터를 다룰 때는 이런 가정이 유효하지 않다. 시퀀스는 정의 자체가 순서를 고려한 데이터이기 때문이다.

시퀀스 표현

  • 입력 데이터에서 의미 있는 순서를 가지도록 시퀀스를 구성한다. 그 다음 머신 러닝 모델이 이런 유용한 정보를 사용하도록 만들어야 한다.
  • 이 장에서는 시퀀스를 x^{(1)}, x^{(2)}, ... , x^{(T)} 처럼 나타내겠다. 위 첨자는 샘플 순서이고 T 는 시쿠너스 길이이다.
    • 시퀀스의 좋은 예시는 시계열 데이터이다. 여기서 각 샘플 포인트 x^{(t)} 는 특정 시간 t 에 속한다.
  • 아래 그림은 시계열 데이터 예를 보여준다. x y 는 시간축을 따라 순서대로 나열되어 있다. 따라서 x y 는 시퀀스 데이터이다.

  • MLP와 CNN 같이 지금까지 다룬 기본적인 신경망 모델은 입력 샘플의 순서를 다루지 못한다. 쉽게 생각해서 이런 모델은 이전에 본 샘플을 기억하지 못한다.
    • 샘플이 정방향과 역방향 단계를 통과하고 가중치는 샘플이 처리되는 순서와 상관없이 업데이트 된다.
  • 반면 RNN은 시쿠너스 모델링을 위해 고안되었다. 지난 정보를 기억하고 이를 기반으로 새로운 이벤트를 처리할 수 있다.

시퀀스 모델링의 종류

  • 시퀀스 모델링은 언어 번역, 이미지 캡셔닝, 텍스트 생성처럼 흥미로운 애플리케이션이 많이 있다.
  • 적절한 모델을 개발하기 위해 시퀀스 모델링의 종류를 이해할 필요가 있다. 아래 그림은 각기 다른 종류의 입력과 출력 데이터에 대한 관계를 보여준다.

  • 어떤 입력 데이터와 출력 데이터가 있다고 가정하자. 입력과 출력 데이터가 시퀀스로 표현되지 않는다면 일반 데이터로 처리한다.
    • 이런 데이터를 모델링하려면 MLP나 CNN 같은 방법 중 하나를 사용할 수 있다.
  • 만일 입력이나 출력이 시퀀스라면 데이터는 다음 세 가지 중 하나로 구성된다.
    • 다대일(many-to-one): 입력 데이터가 시퀀스이다. 출력은 시퀀스가 아니라 고정 크기의 벡터이다. 예컨대 감성 분석에서 입력은 텍스트고 출력은 클래스 레이블이다.
    • 일대다(one-to-many): 입력 데이터가 시퀀스가 아니라 일반적인 형태이다. 출력은 시퀀스이다. 이런 종류의 예로는 이미지 캡셔닝이 있다. 입력이 이미지고 출력은 영어 문장이다.
    • 다대다(many-to-many): 입력과 출력 배열이 모두 시퀀스이다. 이런 종류는 입력과 출력이 동기적인지 아닌지에 따라 더 나눌 수 있다. 동기적인 다대다 모델링의 작업의 에는 각 프레임이 레이블되어 있는 비디오 분류이다. 그렇지 않은 다대다 모델의 예는 한 언어에서 다른 언어로 번역하는 작업이다.

시퀀스 모델링을 위한 RNN

RNN 구조와 데이터 흐름 이해

  • RNN 구조를 소개하겠다. 아래 그림에 비교를 위해 기본 피드포워드 신경망과 RNN을 나란히 놓았다.

  • 두 네트워크 모두 하나의 은닉층만 있다. 위 그림에서는 유닛을 표시하지 않았다.
    • 입력층(x ), 은닉층(h ), 출력층(y ) 모두 벡터고 여러 개의 유닛이 있다고 가정한다.
  • 기본 피드포워드 네트워크에서 정보는 입력에서 은닉층으로 흐른 후 은닉층에서 출력층으로 전달된다.
    • 반면 순환 네트워크에서는 은닉층이 입력층과 이전 타임 스텝(time step)의 은닉층으로부터 정보를 받는다.
    • 인접합 타임 스텝의 정보가 은닉층에 흐르기 때문에 네트워크가 이전 이벤트를 기억할 수 있다.
    • 이런 정보 흐름을 보통 루프(loop)로 표시한다. 그래프 표기법에서는 순환 에지(recurrent edge)라고도 하기 때문에 이 구조 이름이 여기서 유래되었다.
    • 아래 그림은 하나의 은닉층을 가진 순환 네트워크와 다층 순환 네트워크를 비교하여 보여준다.

  • RNN 구조와 정보 흐름을 설명하게 위해 순환 에지를 위 그림과 같이 펼쳐서 나타낼 수 있다.
  • 표준 신경망의 은닉 유닛은 입력층에 연결된 최종 입력 하나만 받는다. 반면 RNN의 은닉 유닛은 두 개의 다른 입력을 받는다.
    • 입력층으로부터 받은 입력과 같은 은닉층에서 t - 1 타임 스텝의 활성화 출력을 받는다.
    • 맨 처음 t = 0 에서는 은닉 유닛이 0 또는 작은 난수로 초기화 된다.
    • t > 0 인 타임 스텝에서는 은닉 유닛이 현재 타입 스텝의 데이터 포인트 x^{(t)} 와 이전 타입 스텝 t-1 의 은닉 유닛 값 h^{(t-1)} 을 입력으로 받는다.
  • 비슷하게 다층 RNN의 정보 흐름을 다음과 같이 요약할 수 있다.
    • layer=1: 은닉층의 출력을 h_{1}^{(t)} 로 표현한다. 데이터 포인트 x^{(t)} 와 이 은닉층의 이전 타입 스텝 출력 h_{1}^{(t-1)} 을 입력으로 받는다.
    • layer=2: 두 번째 은닉층의 h_{2}^{(t)} 는 이전 층의 현재 타임 스텝 출력 h_{1}^{(t)} 와 이 은닉층의이전 타임 스텝 출력 h_{2}^{(t-1)} 을 입력으로 받는다.

RNN의 활성화 출력 계산

  • RNN의 구조와 일반적인 정보 흐름을 이해했으므로 구체적으로 운닉층과 출력층의 실제 활성화 출력을 계산해 보겠다.
    • 간소하게 나타내기 위해 하나의 은닉층만 고려하지만 다층 RNN에도 동일한 개념이 적용된다.
  • 그림 16-4에서 유향 에지(directed edge)는 가중치 행렬과 연관된다. 이 가중치는 특정 시간 t 에 종속적이지 않고 전체 시간 축에 공유된다.
  • 단일층 RNN의 각 가중치는 다음과 같다.
    • W_{xh} : 입력 x^{(t)} 와 은닉층 h 사이의 가중치 행렬
    • W_{hh} : 순환 에지에 연관된 가중치 행렬
    • W_{hy} : 은닉층과 출력층 사이의 가중치 행렬
  • 아래 그림에 이 가중치를 나타냈다.

  • 구현에 따라 가중치 행렬 W_{xh} W_{hh} 를 합쳐 연결된 행렬 W_{h} = [W_{xh};W_{hh}] 를 사용한다. 나중에 이런 방식을 사용해 보겠다.
  • 활성화 출력의 계산은 기본적인 다층 퍼셉트론이나 다른 피드포워드 신경망과 매우 비슷하다.
    • 은닉층의 최종 입력 z_{h} (활성화 함수를 통과하기 전의 값)는 선형 조합으로 계산한다.
    • 즉, 가중치 행렬과 대응되는 벡터를 곱해서 더한 후 절편 유닛을 더한다. (z_{h}^{(t)} = W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h} )
    • 그 다음 타입 스텝 t 에서 은닉층의 활성화를 계산한다.

h^{(t)} = \phi_{h} (z_{h}^{(t)}) = \phi_{h} (W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h})

  • 여기서 b_{h} 은 은닉 유닛의 절편 벡터이고 \phi_{h}(\cdot) 는 은닉층의 활성화 함수이다.
  • 가중치 행렬을 W_{h} = [W_{xh};W_{hh}] 처럼 연결하면 은닉 유닛의 계산 공식은 다음과 같이 바뀐다.

h^{(t)} = \phi_{h}( [W_{xh};W_{hh}] \ \left[ \begin{array}{rr} x^{(t)} \\ h^{(t-1)} \end{array} \right] + b_{h} )

  • 현재 타임 스텝에서 은닉 유닛의 활성화 출력을 계산한 후 출력 유닛의 활성화를 다음과 같이 계산한다.

y^{(t)} = \phi_{y} (W_{hy} h^{(t)} + b_{y})

  • 이해를 돕기 위해 아래 그림에 두 공식으로 활성화 출력을 계산하는 과정을 나타냈다.

긴 시퀀스 학습의 어려움

  • 앞서 노트에서 간략히 소개한 BPTT(BackPropagation Through Time)는 새로운 도전 과제가 되었다.
  • 손실 함수의 그래디언트를 계산할 때 곱셈 항인 {\partial h^{(t)} \over \partial h^{(k)}} 때문에 소위 그래디언트 폭주(exploding gradient) 또는 그래디언트 소실(vanishing gradient) 문제가 발생한다.
    • 이 문제를 아래 그림에서 하나의 은닉 유닛이 있는 예를 들어서 설명하겠다.

  • {\partial h^{(t)} \over \partial h^{(k)}} t - k 개의 곱셈으로 이루어진다. 즉, 가중치 w t-k 번 곱해져 w_{t-k} 가 된다.
    • 결국 |w| < 1 이면 t-k 가 클 때 이 항이 매우 작아진다.
    • 반면 순환 에지의 가중치 값이 |w|>1 이면 t-k 가 클 때 w_{t-k} 가 매우 커진다.
    • t-k 값이 크다는 것은 긴 시간 의존성을 가진다는 의미이다.
  • 그래디언트 소실이나 폭주를 피하는 간단한 해결책은 |w| = 1 이 되도록 만드는 것이다. 자세한 정보는 관련 논문을 참고하고 실전에서 이 문제의 해결책은 다음과 같다.
    • T-BPTT(Truncated BackPropagation Through Time)
    • LSTM(Long Short-Term Memory)
  • T-BPTT는 주어진 타임 스텝 너머의 그래디언트를 버린다. T-BPTT가 그래디언트 폭주 문제를 해결할 수 있지만 그래디언트가 시간을 거슬러 적절하게 가중치가 업데이트 될 수 있는 타임 스텝을 제한한다.
  • 다른 방법으로 1997년 호크라이더(Hochreiter)와 슈미트후버(Schmidhuber)가 고안한 LSTM은 그래디언트 소실 문제를 극복하여 긴 시퀀스를 성공적으로 모델링할 수 있게 되었다.

LSTM 유닛

  • LSTM은 그래디언트 소실 문제를 극복하기 위해 처음 소개되었다. LSTM의 기본 구성요소는 은닉층을 의미하는 메모리 셀(memory cell)이다.
  • 이전에 언급 했듯이 그래디언트 소실과 폭주 문제를 극복하기 위해 메모리 셀에 적절한 가중치 w = 1 를 유지하는 순환 에지가 있다. 이 순환 에지의 출력을 셀 상태(cell state)라고 한다.
    • 자세한 LSTM 구조가 아래 그림에 나타나 있다.

  • 이전 타임 스텝의 셀 상태 C^{(t-1)} 은 어떤 가중치와도 직접 곱해지지 않고 변경되어 현재 타임 스텝의 셀 상태 C^{(t)} 를 얻는다.
  • 메모리 셀의 정보 흐름은 다음에 기술된 몇 개의 연산으로 제어된다.
    • 위 그림에서 \odot 는 원소별 곱셈(element-wise multiplication), \oplus 는 원소별 덧셈(element-wise addition)을 나타낸다.
    • x^{(t)} 는 타임 스텝 t 에서 입력 데이터고 h^{(t-1)} 는 타임 스텝 t-1 에서 은닉 유닛의 출력이다.
    • 네 개의 상자는 시그모이드 함수(\sigma )나 하이퍼볼릭 탄젠트(tanh) 활성화 함수와 일련의 가중치로 표시된다.
    • 이 상자는 입력에 대해 행렬-벡터 곱셈을 수행한 후 선형 조합된다.
    • 시그모이드 함수로 계산하는 유닛을 게이트(gate)라고 하며 \odot 을 통해 출력된다.
  • LSTM 셀에는 세 종류의 게이트가 있다. 삭제 게이트(forget gate), 입력 게이트(input gate), 출력 게이트(output gate)이다.
  • 삭제 게이트(f_{t} )는 메모리 셀이 무한정 성장하지 않도록 셀 상태를 다시 설정한다.
    • 사실 삭제 게이트가 통과할 정보와 억제할 정보를 결정한다.
    • f_{t} 는 다음과 같이 계산된다.

f_{t} = \sigma (W_{xf} x^{(t)} + W_{hf} h^{(t-1)} + b_{f})

  • 삭제 게이트는 원본 LSTM 셀에 포함되어 있지 않았다. 초기 모델을 향상시키기 위해 몇 년 후에 추가되었다.
  • 입력 게이트(i_{t} )와 입력 노드(g_{t} )는 셀 상태를 업데이트하는 역할을 담당하며 다음과 같이 계산한다.

i_{t} = \sigma (W_{xi} x^{(t)} + W_{hi} h^{(t-1)} + b_{i})

g_{t} = tanh(W_{xg} x^{(t)} + W_{hg} h^{(t-1)} + b_{g})

  • 타임 스텝 t 에서 셀 상태는 다음과 같이 계산한다.

C^{(t)} = (C^{(t-1)} \odot f_{t}) \oplus (i_{t} \odot g_{t})

  • 출력 게이트 (o_{t} )는 은닉 유닛의 출력 값을 업데이트 한다.

o_{t} = \sigma (W_{xo} x^{(t)} + W_{ho} h^{(t-1)} + b_{o})

  • 이를 가지고 현재 타임 스텝에서 은닉 유닛의 출력을 다음과 같이 계산한다.

h^{(t)} = o_{t} \odot tanh(C^{(t)})

  • LSTM 셀의 구조와 연산이 매우 복잡해 보일 수 있다. 다행히 텐서플로의 tf.keras API에 래퍼 함수로 이미 모두 구현되어 있어서 간단하게 LSTM 셀을 정의할 수 있다.

텐서플로의 tf.keras API로 시퀀스 모델링을 위한 다층 RNN 구현

  • RNN 이론을 소개했으므로 tf.keras API를 사용하여 RNN을 구현하는 구체적인 단계로 넘어가 보겠다. 이 장 나머지에서 두 개의 문제에 RNN을 적용하겠다.
    • 감성 분석
    • 언어 모델링

첫 번째 프로젝트: 다층 RNN으로 IMDb 영화 리뷰의 감성 분석 수행

데이터 준비

  • 8장의 전처리 단계에서 만든 정제된 데이터셋인 movie_data.csv 파일을 다시 사용하겠다.
import pyprind
import pandas as pd
from string import punctuation
import re
import numpy as np

df = pd.read_csv('movie_data.csv', encoding='utf-8')
  • 데이터프레임 df에는 ‘review’와 ‘sentiment’ 두 개의 컬럼이 있다.
    • ‘review’ 에는 영화 리뷰 텍스트가 담겨 있고 ‘sentiment’에는 0 또는 1 레이블이 들어 있다. 영화 리뷰 텍스트는 단어의 시퀀스이다.
    • RNN 모델을 만들어서 시퀀스 단어를 처리하고 마지막에 전체 시퀀스를 0 또는 1 클래스로 분류해보자.
  • 신경망에 주입할 입력 데이터를 준비하기 위해 텍스트를 정수 값으로 인코딩해야 한다. 이를 위해 전체 데이터셋에서 고유한 단어를 먼저 찾아야 한다.
    • 파이썬의 set를 사용할 수 있지만, 대규모 데이터셋에서 고유한 단어를 찾는데 집합을 사용하는 것은 효율적이지 않으므로 collection 패키지에 있는 Counter를 사용하자.
  • 아래 코드에서 Counter 클래스의 counts 객체를 정의하고 텍스트에 있는 모든 고유한 단어의 등장 횟수를 수집한다.
    • 특히 이 애플리케이션은 (BoW(Bag-ofWord) 모델과 달리) 고유한 단어의 집합만 고나심 대상이고 부수적으로 생성된 단어 카운트는 필요하지 않다.
    • 그 다음 데이터셋의 고유한 단어를 정수 숫자로 매핑한 딕셔너리를 만든다. 이 word_to_int 딕셔너리를 이용하여 전체 텍스트를 정수 리스트로 변환하겠다.
    • 고유한 단어가 카운트 순으로 정렬되어 있지만 순서는 최종 결과에 영향을 미치지 않는다.
from collections import Counter

counts = Counter()
pbar = pyprind.ProgBar(len(df['review']), title='단어의 등장 횟수를 카운트 한다')

for i, review in enumerate(df['review']):
    text = ''.join([c if c not in punctuation else ' ' + c + ' ' for c in review]).lower()
   df.loc[i, 'review'] = text
    pbar.update()
    counts.update(text.split())

word_counts = sorted(counts, key=counts.get, reverse=True)
print(word_counts[:5])

word_to_int = {word: ii for ii, word in enumerate(word_counts, 1)}

mapped_reviews = []
pbar = pyprind.ProgBar(len(df['review']), title='리뷰를 정수로 매핑합니다')

for review in df['review']:
    mapped_reviews.append([word_to_int[word] for word in review.split()])
    pbar.update()
  • 단어 시퀀스를 정수 시퀀스로 변환했지만 한 가지 풀어야 할 문제가 있다. 이 시퀀스들은 길이가 서로 다르다. RNN 구조에 맞게 입력 데이터를 생성하려면 모든 시퀀스가 동일한 길이를 가져야 한다.
    • 이를 위해 sequence_length 파라미터를 정의하고 200으로 값을 설정한다.
    • 200개의 단어보다 적은 시퀀스는 왼쪽에 0으로 패딩된다. 반대로 200개의 단어보다 긴 시퀀스는 마지막 200개의 단어만 사용하도록 잘라낸다.
    • 두 단계로 전처리 과정을 구현하면 다음과 같다.
      1. 행 길이가 시퀀스 크기 200에 해당하는 행렬을 만들고 0으로 채운다.
      2. 행렬 오른쪽부터 시퀀스의 단어 인덱스를 채운다. 시퀀스 길이가 150이면 이 행의 처음 50개 원소는 0으로 남는다.
    • 이 두 단계를 그림으로 나타내면 아래와 같다.

  • 사실 sequence_length는 하이퍼파라미터이므로 최적의 성능을 위해 튜닝해야 한다.
    • 여기서는 지면 관계상 생략했지만 sequence_length를 바꾸어보며 시도해 볼 것.
  • 코드는 아래와 같다.
sequence_length = 200
sequences = np.zeros((len(mapped_reviews), sequence_length), dtype=int)

for i, row in enumerate(mapped_reviews):
    review_arr = np.array(row)
    sequences[i, -len(row):] = review_arr[-sequence_length:]
  • 데이터셋을 전처리한 후 데이터를 훈련 세트와 테스트 세트로 나눈다. 이미 무작위로 섞여 있기 때문에 75%를 훈련 세트로, 25%를 테스트 세트로 사용한다.
    • 훈련 세트 중 일부를 모델의 fit 메서드를 호출할 때 검증 세트로 지정하겠다.
X_train = sequences[:37500, :]
y_train = df.loc[:37499, 'sentiment'].values

X_test = sequences[37500:, :]
y_test = df.loc[37500:, 'sentiment'].values

임베딩

  • 이전의 데이터 준비 단계에서 동일한 길이의 시퀀스를 생성했다. 이 시퀀스의 원소는 고유한 단어의 인덱스에 해당하는 정수 숫자이다.
    • 이런 단어 인덱스를 입력 특성을 변환하는 몇 가지 방법이 있다. 간단하게 원-핫 인코딩을 적용하여 인덱스를 0 또는 1로 이루어진 벡터로 변환할 수 있다.
    • 각 단어는 전체 데이터셋의 고유한 단어의 수에 해당하는 크기를 가진 벡터로 변환된다. 고유한 단어의 수가 2만 개라면 입력 특성 개수는 2만개가 된다.
    • 이렇게 많은 특성에서 훈련된 모델은 차원의 저주(curse of dimensionality)로 인한 영향을 받는다.
    • 또 하나를 제외하고 모든 원소가 0이므로 특성 벡터가 매우 희소해진다.
  • 좀 더 고급스러운 방법은 각 단어를 실수 값을 가진 고정된 길이의 벡터로 변환하는 것이다. 원-핫 인코딩과 달리 고정된 길이의 벡터를 사용하여 무한히 많은 실수를 표현할 수 있다.
  • 임베딩(embedding)이라고 하는 특성 학습 기법을 사용하여 데이터셋에 있는 단어를 표현하는데 중요한 특성을 자동으로 학습할 수 있다.
    • 고유한 단어의 수를 unique_words 라고 하면 고유 단어의 수보다 훨씬 작게(embedding_size << unique_words) 임베딩 벡터 크기를 선택하여 전체 어휘를 입력 특성으로 나타낸다.
  • 원-핫 인코딩에 비해 임베딩의 장점은 다음과 같다.
    1. 특성 공간의 차원이 축소되므로 차원의 저주로 인한 영향을 감소 시킨다.
    2. 신경망에서 임베딩 층이 훈련되기 때문에 중요한 특성이 추출된다.
  • 아래 그림은 임베딩이 어휘 사전의 인덱스를 어떻게 훈련 가능한 임베딩 행렬로 매핑하는지 보여준다.

  • 텐서플로에는 고유한 단어에 해당하는 정수 인덱스를 훈련 가능한 임베딩 행렬의 행으로 매핑해 주는 tf.keras.layers.Embedding 클래스가 구현되어 있다.
    • 예컨대 정수 1이 첫 번째 행으로 매핑되고 정수 2는 두 번째 행에 매핑되는 식이다.
    • <0, 5, 3, 4, 19, 2, … > 처럼 정수 시퀀스가 주어지면 시퀀스의 각 원소에 해당하는 행을 찾는다.
  • 실제로 임베딩 층을 어떻게 만드는지 알아보자. Sequential 모델을 만들고 [n_words x embedding_size] 크기의 Embedding 층을 추가하면 된다.
from tensorflow.keras import models, layers

model = models.Sequential()
model.add(layers.Embedding(n_words, 200, embeddings_regularizer='l2'))
  • Embedding 클래스의 첫 번째 매개변수는 입력 차원으로 어휘 사전의 크기가 된다. 앞서 word_to_int 크기에 1을 더해 n_words를 구했다.
    • 두 번째 매개변수는 출력 차원이다. 여기서는 200차원의 벡터로 단어를 임베딩한다.
    • 다른 층과 마찬가지로 임베딩 층도 가중치를 규제할 수 있는 매개변수를 지원한다. 이 예제에서는 L2 규제를 추가했다. 가중치 초기화는 기본적으로 균등 분포를 사용한다.
    • embeddings_initializer 매개변수에서 다른 초기화 방법을 지정할 수 있다.
  • 임베딩 층을 추가한 후에 summary 메서드로 모델 구조를 출력해 보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# =================================================================
# Total params: 20,593,400
# Trainable params: 20,593,400
# Non-trainable params: 0
  • 임베딩 층의 출력은 3차원 텐서이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 타임 스텝이다. 마지막 차원이 임베딩 벡터의 차원이다.
    • 앞서 n_words 크기가 102,967이었으므로 200차원을 곱하면 전체 모델 파라미터 개수는 20,593,400이 된다.

RNN 모델 만들기

  • 이제 본격적으로 RNN 층을 추가할 차례다. 여기서는 긴 시퀀스를 학습하는데 유리한 tf.keras.layers.LSTM 층을 사용하겠다. 이 LSTM 층은 16개의 순환 유닛을 사용한다.
model.add(layers.LSTM(16))
  • LSTM 층의 첫 번째 매개변수는 유닛 개수이다. 나머지 매개변수는 모두 기본값을 사용한다.
    • 몇 가지 언급할 만한 매개변수가 있는데, activation 매개변수는 히든 상태(층의 출력)에 사용할 활성화 함수를 지정한다. 기본 값은 ‘tanh’이다.
    • recurrent_activation은 셀 상태에 사용할 활성화 함수를 지정한다. 기본값은 ‘hard_sigmoid’이다.
  • 순환층에도 드롭아웃을 추가할 수 있다. dropout 매개변수는 히든 상태를 위한 드롭아웃 비율을 지정하며, recurrent_dropout은 셀 상태를 위한 드롭아웃 비율을 지정한다.
    • 기본값은 0이다.
  • 기본적으로 순환층은 마지막 타임 스텝의 히든 상태만 출력한다. 이는 마지막 출력 값을 사용하여 모델을 평가하는데 사용하기 때문이다.
    • 만약 두 개 이상의 순환층을 쌓는다면 아래층에서 만든 모든 스텝의 출력이 위층 입력으로 전달되어야 한다. 이렇게 하려면 return_sequences 매개변수를 True로 지정해야 한다.
  • 순환층을 추가한 후에는 출력층에 연결하기 위해 펼쳐야 한다. 앞서 합성곱 신경망에서 보았던 것과 유사하다.
    • 감성 분석은 긍정 또는 부정 리뷰를 판단하는 것이므로 출력층의 유닛은 하나이고, 활성화 함수는 시그모이드 함수를 사용한다.
model.add(layers.Flatten())
model.add(layers.Dense(1, activation='sigmoid'))
  • 순환 신경망 모델이 만들어졌다. 완전 연결 신경망이나 합성곱 신경망 보다 어렵지 않다. 전체 모델 구조를 살펴보자.
model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# embedding (Embedding) (None, None, 200) 20593400
# _________________________________________________________________
# lstm (LSTM) (None, 16) 13888
# _________________________________________________________________
# flatten (Flatten) (None, 16) 0
# _________________________________________________________________
# dense (Dense) (None, 1) 17
# =================================================================
# Total params: 20,607,305
# Trainable params: 20,607,305
# Non-trainable params: 0
  • LSTM 층의 출력 크기는 (None, 16)이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 셀의 출력(유닛 개수) 차원이다. LSTM 층이 가지는 모델 파라미터 개수는 13,888개 이다.
  • 좀 더 자세히 분석해 보자. 먼저 삭제 게이트(f_{t} )에 필요한 모델 파라미터를 계산해 보자.
    • 임베딩된 입력 벡터와 곱해지는 W_{xf} 는 (16, 200) 차원이고 이전 셀의 히든 상태와 곱해지는 W_{hf} 는 (16, 16) 차원이다. 마지막으로 절편은 유닛마다 하나씩 있으므로 b_{f} 는 (16,) 차원이다. 이를 모두 더하면 16 x 200 + 16 x 16 + 16 = 2,472 개이다.
    • LSTM 층에는 삭제 게이트와 같은 계산이 세 개 더 있다. i_{t}, g_{t}, o_{t} 이다. 이들 모두 동일한 차원의 가중치 두 개와 절편을 가진다. 따라서 LSTM 층에 있는 전체 모델 파라미터는 3,472 x 4 = 13,888개가 된다.
    • 마지막 Dense 층은 16개의 입력을 처리하기 위한 가중치와 절편을 합쳐서 17개의 모델 파라미터가 있다.

감성 분석 RNN 모델 훈련

  • 모델 구성을 완료 했으므로 Adam 옵티마이저를 사용하여 모델을 컴파일해 보자. 감성 분석 문제는 이진 분류 문제이므로 손실 함수는 binary_crossentropy로 지정한다.
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])
  • 합성곱 신경망에서 했던 ㄱ서처럼 가장 좋은 검증 점수의 모델 파라미터를 체크포인트로 저장하고 텐서보드를 취한 출력을 지정하겠다.
import time
from tensorflow.keras.callbacks import ModelCheckpoint, TensorBoard

callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard(log_dir="sentiment_rnn_logs/{}".format(time.asctime()))]

# 역시나 log_dir 폴더가 안 만들어져서 그냥 TensorBoard()만 사용
# callback_list = [ModelCheckpoint(filepath='sentiment_rnn_checkpoint.h5', monitor='val_loss', save_best_only=True), TensorBoard()]
  • 이제 모델을 훈련할 단계이다. 배치 크기는 64로 지정하고 열 번 에포크 동안 훈련하겠다.
    • validation_split을 0.3으로 지정하여 전체 훈련 세트의 30%를 검증 세트로 사용한다.
history = model.fit(X_train, y_train, batch_size=64, epochs=10, validation_split=0.3, callbacks=callback_list)
  • fit 메서드에서 반환된 history 객체에서 손실과 정확도를 추출하여 그래프로 그려보자. 먼저 손실 점수에 대한 그래프이다.
import matplotlib.pyplot as plt

epochs = np.arange(1, 11)

plt.plot(epochs, history.history['loss'])
plt.plot(epochs, history.history['val_loss'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 정확도 그래프를 그려보자.
epochs = np.arange(1, 11)

plt.plot(epochs, history.history['acc'])
plt.plot(epochs, history.history['val_acc'])
plt.xlabel('epochs')
plt.ylabel('loss')
plt.show()

  • 출력 결과를 보면 2번째 에포크만에 손실이 크게 감소하고 정확도가 상승한 것을 볼 수 있다.
    • 그 이후에는 훈련 정확도와 간격을 두며 검증 정확도가 조금씩 상승하고 있다. 이런 효과는 임베딩 층에 L2 규제를 추가했기 때문이다.
    • 임베딩 층에 규제가 없다면 훈련 세트에 금방 과대적합될 것이다.

감성 분석 RNN 모델 평가

  • 훈련 과정에서 만들어진 최상의 체크포인트 파일을 복원하여 테스트 세트에서 성능을 평가해보자.
    • 체크 포인트를 복원 하려면 모델의 load_weights 메서드를 사용하면 된다.
model.load_weights('sentiment_rnn_checkpoint.h5')
model.evaluate(X_test, y_test)

### 결과
# ...======] - 27s 2ms/sample - loss: 0.4732 - acc: 0.8777
  • evaluate 메서드는 기본적으로 손실 점수를 반환한다. 만약 compile 메서드의 metrics 매개변수에 측정 지표를 추가했다면 반환되는 값이 늘어난다.
    • 앞서 반환된 결과의 첫 번째 원소는 손실 점수고, 두 번째는 정확도 이다.
    • LSTM 층 하나로 테스트 세트에서 87% 정도의 정확도를 달성했는데, 8장에서 얻은 테스트 정확도와 비교할 만하다.
  • 샘플의 감성 분석 결과를 출력하려면 predict 메서드를 사용한다.
    • 이전 장에서 보았듯이 predict 메서드는 확률 값을 반환한다. 감성 분석 예제는 이진 분류 문제이므로 양성 클래스, 즉 긍정 리뷰일 확률을 반환한다.
    • Sequential 클래스는 predict 메서드와 동일하게 확률을 반환하는 predict_proba 메서드를 제공한다.
    • 의도를 분명하게 하기 위해 predict_proba 메서드로 테스트 샘플 열 개의 확률을 출력해 보자.
print(model.predict_proba(X_test[:10]))

### 결과
# [[0.00631011]
# [0.00777742]
# [0.00151676]
# [0.95133054]
# [0.99530613]
# [0.9786582 ]
# [0.00557807]
# [0.8497387 ]
# [0.00201363]
# [0.5879719 ]]
  • 다중 분류에서는 가장 큰 확률의 레이블이 예측 클래스가 되고 이진 분류 문제에서는 0.5보다 크면 양성 클래스가 된다.
    • 간단하게 0.5보다 큰 값을 구분할 수 있지만 Sequential 클래스는 친절하게 이를 위한 predict_classes 메서드도 제공한다.
print(model.predict_classes(X_test[:10]))

### 결과
# [[0]
# [0]
# [0]
# [1]
# [1]
# [1]
# [0]
# [1]
# [0]
# [1]]
  • 최적화를 위해 LSTM 층의 유닛 개수, 타임 스텝의 길이, 임베딩 크기 같은 모델의 하이퍼파라미터를 튜닝하면 더 높은 일반화 성능을 얻을 수 있다.
    • 6장에서 설명한 것처럼 테스트 데이터를 사용하여 편향되지 않은 성능을 얻으려면 평가를 위해 테스트 세트를 반복적으로 사용하면 안된다는 점에 주의하라.

두 번째 프로젝트: 텐서플로로 글자 단위 언어 모델 구현

  • 언어 모델링(language modeling)은 영어 문장 생성처럼 기계가 사람의 언어와 관련된 작업을 수행하도록 만드는 흥미로운 애플리케이션이다.
    • 이 분야에서 관심을 끄는 결과물 중 하나는 서스키버(Sutskever), 마틴(Martens), 힌튼(Hinton)의 작업이다.
  • 앞으로 만들 모델의 입력은 텍스트 문장이다. 목표는 입력 문서와 비슷한 새로운 텍스트를 생성하는 모델을 개발하는 것이다.
    • 입력 데이터는 책이나 특정 프로그래밍 언어로 쓰여진 컴퓨터 프로그램일 수 있다.
  • 글자 단위 언어 모델링에서 입력은 글자의 시퀀스로 나뉘어 한 번에 글자 하나씩 네트워크에 주입된다.
    • 이 네트워크는 지금까지 본 글자와 함께 새로운 글자를 처리하여 다음 글자를 예측한다.
    • 아래 그림은 글자 단위 언어 모델링의 예이다.

  • 데이터 전처리, RNN 모델 구성, 다음 글자를 예측하고 새로운 텍스트를 생성하는 세 개의 단계로 나누어 구현하겠다.
  • 이 장의 서두에서 그래디언트 폭주 문제를 언급했는데, 이 애플리케이션에서 그래디언트 폭주 문제를 피하기 위해 그래디언트 클리핑 기법을 사용해 보겠다.

데이터 전처리

  • 글자 수준의 언어 모델링을 위한 데이터를 준비하자.
    • 수천 권의 무료 전자책을 제공하는 구텐베르크 프로젝트 웹사이트에서 입력 데이터를 구하겠다. 이 예에서는 셰익스피어의 햄릭 텍스트를 사용하겠다. (http://www.gutenberg.org/cache/epub/2265/pg2265.txt)
  • 데이터가 준비되면 파이썬에서 일반 텍스트로 읽는다.
    • 다음 코드에서 파이썬 매개변수 chars는 이 텍스트에 있는 고유한 글자 집합이다.
    • 그 다음 각 글자와 정수를 매핑한 딕셔너리 char2int와 거꾸로 정수와 글자를 매핑한 int2char 딕셔너리를 만든다.
    • char2int 딕셔너리를 사용하여 텍스트를 넘파이 정수 배열로 변환한다.
    • 아래 그림은 변환 예시이다.

import numpy as np

with open('pg2265.txt', 'r', encoding='utf-8') as f:
    text = f.read()

text = text[15858:]
chars = set(text)
char2int = {ch:i for i, ch in enumerate(chars)}
int2char = dict(enumerate(chars))
text_ints = np.array([char2int[ch] for ch in text], dtype=np.int32)

print(len(text))
print(len(chars))

### 결과
# 163237
# 68
  • 이 텍스트를 char2int 딕셔너리를 사용하여 모두 정수로 바꾸어 text_ints 배열에 저장했다.
  • 데이터 전처리에서 가장 중요한 단계는 이 데이터를 시퀀스의 배치로 바꾸는 작업이다. 지금까지 본 글자 시퀀스를 기반으로 새로운 글자를 예측하는 것이 목적이다.
    • 따라서 신경망의 입력(x )과 출력(y )을 한 글자씩 이동한다.
    • 텍스트 데이터셋에서 데이터 배열 x y 를 생성하는 것부터 시작해서 아래 그림에 이 전처리 단계를 나타냈다.

  • 그림에서 볼 수 있듯이 훈련 배열 x y 는 동일한 크기 또는 차원을 가진다. 행 개수는 배치 크기와 같고, 열 개수는 배치 횟수 x 스텝 횟수이다.
  • 텍스트 데이터의 글자를 표현한 정수 입력 배열 data가 주어지면 다음 함수는 위의 그림과 동일한 구조의 x y 를 만든다.
def reshape_data(sequence, batch_size, num_steps):
    mini_batch_length = batch_size * num_steps
    num_batches = int(len(sequence) / mini_batch_length)

   if num_batches * mini_batch_length + 1 > len(sequence):
       num_batches = num_batches - 1   

   x = sequence[0: num_batches * mini_batch_length]
    y = sequence[1: num_batches * mini_batch_length + 1]

   x_batch_splits = np.split(x, batch_size)
    y_batch_splits = np.split(y, batch_size)

    x = np.stack(x_batch_splits)
    y = np.stack(y_batch_splits)

    return x, y
  • 시퀀스 길이를 10으로 가정하고 reshape_data 함수를 사용하여 배치 크기 64에 맞게 데이터를 바꾸어 보자.
train_x, train_y = reshape_data(text_ints, 64, 10)

print(train_x.shape)
print(train_x[0, :10])
print(train_y[0, :10])
print(''.join(int2char[i] for i in train_x[0, :10]))
print(''.join(int2char[i] for i in train_y[0, :10]))

### 결과
# (64, 2550)
# [49 48 2 63 48 14 2 38 49 48]
# [48 2 63 48 14 2 38 49 48 40]
# e of more
# of more t
  • text_ints를 64개의 행을 가진 2차원 배열 train_x와 train_y로 바꾸었다. train_x 크기를 출력해 보면 배치 크기의 행이 만들어진 것을 확인할 수 있다.
    • train_x와 train_y를 정수 크기대로 출력하고 int2char 딕셔너리를 사용하여 문자로도 출력했다.
    • 출력 결과를 보면 train_y가 한 글자씩 밀려 있다는 것을 확인할 수 있다.
  • 다음 단계에서 배열 x y 를 나누어 열 길이가 스텝 횟수와 동일한 미니 배치를 만든다. 데이터 배열 x 를 나누는 과정이 아래 그림에 나와있다.

  • 다음 코드에서 위 그림에 나온 데이터 배열 x y 를 나누어 배치를 출력하는 create_batch_generator를 정의한다.
    • 차후에 이  제너레이터를 사용하여 네트워크를 훈련하는 동안 미니 배치를 반복하겠다.
def create_batch_generator(data_x, data_y, num_steps):
    batch_size, tot_batch_length = data_x.shape[0:2]
    num_batches = int(tot_batch_length/num_steps)

    for b in range(num_batches):
        yield (data_x[:, b * num_steps:(b+1) * num_steps], data_y[:, b * num_steps: (b+1) * num_steps])
  • 이 코드에서 정의한 제너레이터는 메모리 부족을 해결할 수 있는 좋은 기법이다.
    • 신경망을 훈련하는 동안 모든 데이터를 미리 나누어 메모리에 저장하지 않고 데이터셋을 미니 배치로 나누는 방식이 바람직하다.
  • train_x와 train_y 배열에서 길이 100까지만 사용하여 배치 데이터를 테스트로 만들어보겠다.
    • 시퀀스 길이는 15로 설정하고, 길이가 100이므로 제너레이터 함수는 길이 15인 시퀀스의 배치를 여섯 번 반환한다.
bgen = create_batch_generator(train_x[:, :100], train_y[:, :100], 15)

for x, y in bgen:
   print(x.shape, y.shape, end='  ')
    print(''.join(int2char[i] for i in x[0, :]).replace('\n', '*'), '  ', ''.join(int2char[i] for i in y[0, :]).replace('\n', '*'))

### 결과
# (64, 15) (64, 15) e of more than of more than 3
# (64, 15) (64, 15) 30 different*Fi 0 different*Fir
# (64, 15) (64, 15) rst Folio editi st Folio editio
# (64, 15) (64, 15) ons' best pages ns' best pages.
# (64, 15) (64, 15) .**If you find **If you find a
# (64, 15) (64, 15) any scanning er ny scanning err
  • 64개 배치 중 첫 번째 배치만 문자로 바꾸어 출력했다. 훈련 데이터와 타깃 데이터가 올바르게 추출되었다.
  • 실제 모델에 사용할 데이터를 만들기 위한 준비를 거의 마쳤다. 먼저 reshape 메서드를 사용하여 text_ints 배열을 64개의 배치 행을 가진 형태로 바꾼다.
batch_size = 64
num_steps = 100
train_x, train_y = reshape_data(text_ints, batch_size, num_steps)

print(train_x.shape, train_y.shape)

### 결과
# (64, 2500) (64, 2500)
  • 배치 크기를 64, 타임 스텝 길이를 100으로 설정했으므로 text_ints에서 자투리 부분은 제외하고 (64, 2500) 크기의 배열이 되었다.
  • 데이터 전처리의 마지막 단계는 이 데이터를 원-핫 인코딩으로 바꾸는 작업이다.
    • 이전 감성 분석 예제에서는 임베딩 층을 사용하여 단어를 길이가 200인 벡터로 인코딩했었다. 이때 타깃 데이터는 긍정 또는 부정 리뷰를 나타내는 1차원 배열이었다. 일련의 시퀀스를 처리한 후 손실 함수로부터 그래디언트를 계산했다.
    • 글자 단위 RNN 모델에서는 조금 다른 방식을 사용하는데, 모델에서 처리하는 글자마다 그래디언트를 모두 계산하여 사용한다. 이렇게 하려면 타깃 데이터도 전체 타임 스텝에 걸쳐 원-핫 인코딩 되어야 한다.
    • 텐서플로에서 제공하는 to_categorical 함수를 사용하여 원-핫 인코딩을 간단하게 만들어보자.
from tensorflow.keras.utils import to_categorical

train_encoded_x = to_categorical(train_x)
train_encoded_y = to_categorical(train_y)

print(train_encoded_x.shape, train_encoded_y.shape)

### 결과
# (64, 2500, 68) (64, 2500, 68)
  • to_categorical 함수는 입력된 데이터에서 가장 큰 값에 맞추어 자동으로 원-핫 인코딩된 벡터로 변환시킨다.
    • 정수 값이 0부터 시작한다고 가정하므로 원-핫 인코딩 벡터의 길이는 최댓값에 1을 더해야 한다.
    • 만약 train_x와 train_y에 있는 최댓값이 다르면 원-핫 인코딩 크기가 달라진다. 여기서는 train_y가 train_x에서 한 글자만 이동했기 때문에 최댓값이 같지만 문제에 따라 다를 수 있으므로 주의하라

글자 단위 RNN 모델 만들기

  • Sequential 클래스를 사용하여 글자 단위 RNN 모델을 만들어보겠다. 먼저 Sequential 클래스 객체를 생성한다.
from tensorflow.keras import models, layers

char_model = models.Sequential()
  • 훈련 데이터를 원-핫 인코딩 했으므로 임베딩 층 대신 LSTM 층을 바로 추가하겠다. 이때 두 가지를 고려해야 한다.
  • 첫째, 이 모델은 훈련할 때 길이가 100인 시퀀스를 주입한다. 즉, 타임 스텝 길이가 100이다. 하지만 새로운 글자를 생성할 때는 이전 글자를 주입하여 한 글자씩 생성한다. 다시 말해 샘플링 시에는 배치 크기가 1이 된다. 따라서 훈련과 샘플링 시에 배치 크기와 타임 스텝 크기가 다음과 같이 정의된다.

sampling mode = \begin{cases} batch size = 1 \\ num steps = 1 \end{cases}

training mode = \begin{cases} batch size = 64 \\ num steps = 100 \end{cases}

  • 훈련과 샘플링 모드에서 사용하는 시퀀스 길이가 다르다. 흔히 이런 RNN 네트워크의 구조를 ‘시간에 따라 동적으로 펼친다’라고도 한다.
    • 텐서플로의 케라스 API를 사용하면 가변 길이 시퀀스를 다루는 작업도 간단하게 처리할 수 있다.
  • 이전 장의 합성곱 모델에서 보았듯이 모델에 추가하는 첫 번째 층에는 input_shape 매개변수로 배치 차원을 제외한 입력 크기를 지정해야 한다.
    • LSTM 층에서 가변 길이 시퀀스를 처리하려면 타임 스텝 길이에 해당하는 input_shape의 첫 번째 차원을 None으로 지정하면 된다.
    • 두 번째 차원은 원-핫 인코딩 벡터의 크기가 된다.
  • 둘째, 모든 타임 스텝에 대해 그래디언트를 계산하여 모델을 업데이트할 것이다. 따라서 LSTM 층이 시퀀스의 마지막 타임 스텝의 출력만 반환하지 않고 전체 시퀀스에 대해 출력을 만들어야 한다.
    • 이렇게 하려면 앞서 언급한 대로 LSTM 층의 return_sequences 매개변수를 True로 지정해야 한다.
  • 이런 점을 고려하여 다음과 같이 128개의 순환 유닛을 가진 LSTM 층을 모델에 추가한다.
num_classes = len(chars)
char_model.add(layers.LSTM(128, input_shape=(None, num_classes), return_sequences=True))
  • 이 모델에 입력할 데이터는 num_classes 크기로 원-핫 인코딩 되었다는 것을 기억하라. num_classes는 텍스트에 있는 모든 글자 수이다.
  • 그 다음은 각 글자에 대한 확률을 출력하는 완전 연결 층을 추가한다. 이 출력층의 유닛 개수는 num_classes가 된다. 다중 출력이므로 활성화 함수는 소프트맥스 함수를 사용한다.
  • 지금까지는 Dense 층을 추가하기 전에 Flatten 층을 추가했다. 이 층은 배치 차원을 제외하고 입력 텐서의 나머지 차원을 일렬로 펼친다. Dense 층은 이렇게 전형적으로 2차원 텐서를 다룬다.
    • 하지만 이 예제에서는 모든 타임 스텝에 대한 손실을 계산해야 하기 때문에 LSTM 층에서 출력되는 3차원 텐서를 그대로 다루어야 한다.
    • LSTM 층에서 출력되는 텐서 크기는 (배치 개수, 타임 스텝 개수, 순환 유닛 개수)이다. Dense 층을 통과할 때 이 텐서츼 엇 번째와 두 번째 차원이 유지되어야 한다.
    • 이 작업을 처리하기 위해 Flatten 층을 추가하지 않고 LSTM 층의 출력을 타임 스텝 순으로 Dense 층에 주입하고 결과를 받아 다시 타임 스텝 순서대로 쌓아야 한다.
    • 이런 작업을 위한 클래스도 텐서플로에 이미 준비되어 있다. tf.keras.layers.TimeDistribute 클래스를 사용하면 Dense 층을 감싸서 타임 스텝을 가진 입력을 다룰 수 있다.
char_model.add(layers.TimeDistributed(layers.Dense(num_classes, activation='softmax')))
  • 전체 모델 구성이 끝났다. tf.keras API를 사용하면 간단하게 RNN 모델을 만들 수 있다.
    • summary 메서드로 구성된 네트워크를 출력해 보자.
char_model.summary()

### 결과
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# lstm (LSTM) (None, None, 128) 100864
# _________________________________________________________________
# time_distributed (TimeDistri (None, None, 68) 8772
# =================================================================
# Total params: 109,636
# Trainable params: 109,636
# Non-trainable params: 0
  • 가변 길이 시퀀스를 다루기 위해 LSTM 층과 TimeDistributed 층의 출력에서 두 번째 차원이 None으로 된 것을 볼 수 있다. 또 최종 출력에 타임 스텝 차원이 포함되었다.
  • 모델 구성을 마치면서 각 츠으이 모델 파라미터의 크기를 계산해 보자.
    • 순환 유닛이 128개이고 원-핫 인코딩의 크기가 65이므로 W_{xf} 는 (128, 65) 차원이다. 셀의 히든 상태와 곱해지는 W_{hf} 는 (128, 128)이다. 여기에 절편을 더하면 삭제 게이트에 필요한 모델 파라미터 개수는 128 x 65 + 128 x 128 + 128 = 24,832 개가 된다.
    • LSTM 층에는 이런 가중치가 네 벌 더 있으므로 전체 모델 파라미터 개수는 99,328개가 된다.
  • TimeDistributed 층은 모델 파라미터를 가지고 있지 않다. summary 메서드에서 출력한 값은 Dense 층의 모델 파라미터 개수이다.
    • Dense 층의 입력 차원은 128이고 65개의 유닛이 있으므로 절편을 고려한 전체 모델 파라미터 개수는 65 x 128 + 65 = 8,386개이다.

글자 단위 RNN 모델 훈련

  • 이전 예제에서는 옵티마이저의 기본값을 사용했다. 이 예제에서는 그래디언트 폭주를 피하기 위한 대표적인 방법인 그래디언트 클리핑을 적용해 보겠다.
    • 그래디언트 클리핑을 하려면 옵티마이저 클래스의 객체를 직접 만들어 모델의 compile 메서드에 전달해야 한다.
    • 앞선 예제와 같이 Adam 옵티마이저를 사용한다.
from tensorflow.keras.optimizers import Adam

adam = Adam(clipnorm=5.0)
  • tf.keras.optimizers에 있는 옵티마이저들은 그래디언트 클리핑을 위한 두 개의 매개변수를 제공한다. 하나는 L2 노름 임계 값을 지정하는 clipnorm이고 다른 하나는 절댓값으로 임계 값을 지정하는 clipvalue이다.
    • clipnorm 매개변수가 설정되면 그래디언트의 L2 노름이 clipnorm 보다 클 경우 다음과 같이 클리핑 된 그래디언트를 계산한다.
      • 클리핑된 그래디언트 = 그래디언트 * clipnorm / 그래디언트의 L2 노름
    • clipvalue 매개변수가 설정되면 -clipvalue 보다 작은 그래디언트는 -clipvalue가 되고 clipvalue 보다 큰 그래디언트는 clipvalue로 만든다.
  • 이 두 클리핑 방식을 동시에 사용할 수도 있다. 여기서는 clipnorm 매개변수만 사용했다.
    • 65개의 글자에 대한 확률을 출력하는 다중 클래스 모델이므로 손실 함수는 categorical_crossentropy를 사용한다.
    • 그 다음 옵티마이저 객체와 함께 char_model을 컴파일 한다.
char_model.compile(loss='categorical_crossentropy', optimizer=adam)
  • 훈련된 모델을 저장하여 나중에 학습을 이어 가거나 텍스트를 생성할 수 있도록 체크포인트 콜백을 준비한다.
from tensorflow.keras.callbacks import ModelCheckpoint

callback_list = [ModelCheckpoint(filepath='char_rnn_chckpoint.h5')]
  • 이제 500번의 에포크 동안 모델을 훈련하겠다. Sequential 모델은 입력과 타깃 배치를 반환하는 제너레이터와 함께 쓸 수 있는 fit_generator 메서드를 제공한다.
    • 앞서 만든 create_batch_generator 함수로부터 제너레이터 객체를 만들어 fit_generator 메서드에 전달하겠다.
    • file_generator 메서드는 파이썬 제너레이터에서 배치를 끝없이 반환할 것으로 기대한다. 데이터가 끝없이 생성되므로 하나의 에포크를 정의하기 위해 제너레이터로부터 몇 번이나 배치를 뽑을 것인지 알려주어야 한다.
    • fit_generator 메서드의 steps_per_epoch 매개변수에서 이를 설정한다.
  • 이 예제에서는 시퀀스 길이가 100이므로 전부 25번의 배치가 생성된다.
    • 사실 create_batch_generator 함수는 배치를 순환하지 않기 때문에 25번째 배치 이후에는 더는 추출하지 못하고 에러가 발생된다.
    • 이를 해결하기 위해 for 반복문에서 fit_generator 메서드를 호출할 때 epochs를 1로 설정한다.
    • 전체 훈련 횟수는 500번이고 훈련할 때마다 제너레이터를 다시 초기화 해야 한다.
for i in range(500):
    bgen = create_batch_generator(train_encoded_x, train_encoded_y, num_steps)
   char_model.fit_generator(bgen, steps_per_epoch=25, epochs=1, callbacks=callback_list, verbose=0)

### 결과
# 윈도우 설정 문제인지, 책 코드가 있는 git의 소스를 그대로 써도 에러가 나서 이 예제는 이후 결과 없이 종료
  • 반복 횟수가 많기 때문에 verbose 매개변수를 0으로 설정하여 훈련 과정을 출력하지 않았다.

글자 단위 RNN 모델로 텍스트 생성

  • 텍스트를 만들기 위해 앞서 설명한 것처럼 배치 크기 1, 타임 스텝 길이 1을 만들어 모델에 주입한다. 그 다음 예측된 문자를 다음번 예측을 하기 위해 다시 모델에 주입하는 과정을 반복한다.
  • 먼저 모델에서 출력된 65개의 확률 값에서 하나를 랜덤하게 선택할 get_top_char 함수를 정의하자. 이 함수는 전달된 확률을 정렬하고 numpy.random.choice 함수로 상위 다섯 개의 확률 중 하나를 랜덤하게 선택한다.
np.random.seed(42)

def get_top_char(probas, char_size, top_n=5):
    p = np.squeeze(probas)
   p[np.argsort(p)[:-top_n]] = 0.0
    p = p / np.sum(p)
    ch_id = np.random.choice(char_size, 1, p=p)[0]

    return ch_id
  • “The ” 란 초기 문자열을 사용하여 이어지는 텍스트를 생성하겠다. 텍스트를 생성하는 방법은 다음과 같다.
    • 먼저 모델에 문자열 “The “를 한 글자씩 주입하고 마지막 글자에서 다음 글자를 예측한다.
    • 그 다음 이글자를 사용하여 계속 다음 글자를 예측하는 식이다.
  • 모델에 주입할 데이터를 만드는 과정은 앞서 훈련 데이터에서 했던 것과 유사하다. 한 가지 주의할 점은 한 글자씩 인코딩 하기 때문에 to_categorical 함수를 호출할 때 num_classes 매개변수로 원-핫 인코딩될 벡터 크기를 지정해야 한다.
    • 배치 차원을 만들기 위해 넘파이 expand_dims 함수로 첫 번째 차원을 추가했다.
    • 만들어진 onehot 배열의 차원은 (1, 1, 65)이다.
seed_text = "The "

for ch in seed_text:
   num = [char2int[ch]]
   onehot = to_categorical(num, num_classes=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)

num = get_top_char(probas, len(chars))
seed_text += int2char[num]
  • 초기 문자열 “The “를 모델에 차례대로 주입한 후 마지막에 얻은 probas 출력을 사용하여 다음 글자를 예측한다.
    • 이 값은 정수이기 때문에 int2char를 사용하여 문자로 바꾼 후 seed_text 문자열 끝에 추가한다.
  • 이제 예측한 문자열을 비슷한 과정으로 인코딩하여 다시 모델에 주입한다.
    • 반환된 클래스 확률 값을 사용하여 다시 다음 글자를 선택한다.
    • 이런 과정을 for 반복문을 사용하여 500번 되풀이 하여 긴 텍스트를 만들어 보자.
for i in range(500):
   onehot = to_categorical([num], num_classe=65)
    onehot = np.expand_dims(onehot, axis=0)
    probas = char_model.predict(onehot)
   num = get_top_char(probas, len(chars))
    seed_text += int2char[num]

print(seed_text)
  • 결과에서 볼 수 있듯이 일부 영어 단어는 거의 그대로 유지되었다. 이 예제는 오래된 영어 텍스트를 사용했으므로 원본 텍스트에는 낯선 단어가 일부 포함되어 있다.
  • 더 나은 결과를 얻으려면 에포크 수를 늘려서 모델을 훈련하거나 훨씬 더 큰 문서를 사용해도 좋다. np.random.choice 대신 확률 값의 크기에 따라 글자 선택 가능성을 높일 수도 있다.
[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.