Search
Duplicate

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

시퀀스 데이터 소개

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

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

시퀀스 표현

입력 데이터에서 의미 있는 순서를 가지도록 시퀀스를 구성한다. 그 다음 머신 러닝 모델이 이런 유용한 정보를 사용하도록 만들어야 한다.
이 장에서는 시퀀스를 x(1),x(2),...,x(T)x^{(1)}, x^{(2)}, ... , x^{(T)} 처럼 나타내겠다. 위 첨자는 샘플 순서이고 TT는 시퀀스 길이이다.
시퀀스의 좋은 예시는 시계열 데이터이다. 여기서 각 샘플 포인트 x(t)x^{(t)}는 특정 시간 tt에 속한다.
아래 그림은 시계열 데이터 예를 보여준다. xx와 yy는 시간축을 따라 순서대로 나열되어 있다. 따라서 xx와 yy는 시퀀스 데이터이다.
MLP와 CNN 같이 지금까지 다룬 기본적인 신경망 모델은 입력 샘플의 순서를 다루지 못한다. 쉽게 생각해서 이런 모델은 이전에 본 샘플을 기억하지 못한다.
샘플이 정방향과 역방향 단계를 통과하고 가중치는 샘플이 처리되는 순서와 상관없이 업데이트 된다.
반면 RNN은 시퀀스 모델링을 위해 고안되었다. 지난 정보를 기억하고 이를 기반으로 새로운 이벤트를 처리할 수 있다.

시퀀스 모델링의 종류

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

시퀀스 모델링을 위한 RNN

RNN 구조와 데이터 흐름 이해

RNN 구조를 소개하겠다. 아래 그림에 비교를 위해 기본 피드포워드 신경망과 RNN을 나란히 놓았다.
두 네트워크 모두 하나의 은닉층만 있다. 위 그림에서는 유닛을 표시하지 않았다.
입력층(xx), 은닉층(hh), 출력층(yy) 모두 벡터고 여러 개의 유닛이 있다고 가정한다.
기본 피드포워드 네트워크에서 정보는 입력에서 은닉층으로 흐른 후 은닉층에서 출력층으로 전달된다.
반면 순환 네트워크에서는 은닉층이 입력층과 이전 타임 스텝(time step)의 은닉층으로부터 정보를 받는다.
인접합 타임 스텝의 정보가 은닉층에 흐르기 때문에 네트워크가 이전 이벤트를 기억할 수 있다.
이런 정보 흐름을 보통 루프(loop)로 표시한다. 그래프 표기법에서는 순환 에지(recurrent edge)라고도 하기 때문에 이 구조 이름이 여기서 유래되었다.
아래 그림은 하나의 은닉층을 가진 순환 네트워크와 다층 순환 네트워크를 비교하여 보여준다.
RNN 구조와 정보 흐름을 설명하게 위해 순환 에지를 위 그림과 같이 펼쳐서 나타낼 수 있다.
표준 신경망의 은닉 유닛은 입력층에 연결된 최종 입력 하나만 받는다. 반면 RNN의 은닉 유닛은 두 개의 다른 입력을 받는다.
입력층으로부터 받은 입력과 같은 은닉층에서 t1t - 1 타임 스텝의 활성화 출력을 받는다.
맨 처음 t=0t = 0에서는 은닉 유닛이 0 또는 작은 난수로 초기화 된다.
t>0t > 0 인 타임 스텝에서는 은닉 유닛이 현재 타입 스텝의 데이터 포인트 x(t)x^{(t)}와 이전 타입 스텝 t1t-1의 은닉 유닛 값 h(t1)h^{(t-1)}을 입력으로 받는다.
비슷하게 다층 RNN의 정보 흐름을 다음과 같이 요약할 수 있다.
layer=1: 은닉층의 출력을 h1(t)h_{1}^{(t)}로 표현한다. 데이터 포인트 x(t)x^{(t)}와 이 은닉층의 이전 타입 스텝 출력 h1(t1)h_{1}^{(t-1)}을 입력으로 받는다.
layer=2: 두 번째 은닉층의 h2(t)h_{2}^{(t)}는 이전 층의 현재 타임 스텝 출력 h1(t)h_{1}^{(t)}와 이 은닉층의 이전 타임 스텝 출력 h2(t1)h_{2}^{(t-1)}을 입력으로 받는다.

RNN의 활성화 출력 계산

RNN의 구조와 일반적인 정보 흐름을 이해했으므로 구체적으로 운닉층과 출력층의 실제 활성화 출력을 계산해 보겠다.
간소하게 나타내기 위해 하나의 은닉층만 고려하지만 다층 RNN에도 동일한 개념이 적용된다.
그림 16-4에서 유향 에지(directed edge)는 가중치 행렬과 연관된다. 이 가중치는 특정 시간 tt에 종속적이지 않고 전체 시간 축에 공유된다.
단일층 RNN의 각 가중치는 다음과 같다.
WxhW_{xh}: 입력 x(t)x^{(t)}와 은닉층 hh 사이의 가중치 행렬
WhhW_{hh}: 순환 에지에 연관된 가중치 행렬
WhyW_{hy}: 은닉층과 출력층 사이의 가중치 행렬
아래 그림에 이 가중치를 나타냈다.
구현에 따라 가중치 행렬 WxhW_{xh}와 WhhW_{hh}를 합쳐 연결된 행렬 Wh=[Wxh;Whh]W_{h} = [W_{xh};W_{hh}]를 사용한다. 나중에 이런 방식을 사용해 보겠다.
활성화 출력의 계산은 기본적인 다층 퍼셉트론이나 다른 피드포워드 신경망과 매우 비슷하다.
은닉층의 최종 입력 zhz_{h} (활성화 함수를 통과하기 전의 값)는 선형 조합으로 계산한다.
즉, 가중치 행렬과 대응되는 벡터를 곱해서 더한 후 절편 유닛을 더한다. (zh(t)=Wxhx(t)+Whhh(t1)+bh)(z_{h}^{(t)} = W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h})
그 다음 타입 스텝 tt에서 은닉층의 활성화를 계산한다.
h(t)=ϕh(zh(t))=ϕh(Wxhx(t)+Whhh(t1)+bh)h^{(t)} = \phi_{h} (z_{h}^{(t)}) = \phi_{h} (W_{xh} x^{(t)} + W_{hh} h^{(t-1)} + b_{h})
여기서 bhb_{h}은 은닉 유닛의 절편 벡터이고 ϕh()\phi_{h}(\cdot)는 은닉층의 활성화 함수이다.
가중치 행렬을 Wh=[Wxh;Whh]W_{h} = [W_{xh};W_{hh}]처럼 연결하면 은닉 유닛의 계산 공식은 다음과 같이 바뀐다.
h(t)=ϕh([Wxh;Whh] [x(t)h(t1)]+bh)h^{(t)} = \phi_{h}( [W_{xh};W_{hh}] \ \left[ \begin{array}{rr} x^{(t)} \\ h^{(t-1)} \end{array} \right] + b_{h} )
현재 타임 스텝에서 은닉 유닛의 활성화 출력을 계산한 후 출력 유닛의 활성화를 다음과 같이 계산한다.
y(t)=ϕy(Whyh(t)+by)y^{(t)} = \phi_{y} (W_{hy} h^{(t)} + b_{y})
이해를 돕기 위해 아래 그림에 두 공식으로 활성화 출력을 계산하는 과정을 나타냈다.

긴 시퀀스 학습의 어려움

앞서 노트에서 간략히 소개한 BPTT(BackPropagation Through Time)는 새로운 도전 과제가 되었다.
손실 함수의 그래디언트를 계산할 때 곱셈 항인 h(t)h(k){\partial h^{(t)} \over \partial h^{(k)}} 때문에 소위 그래디언트 폭주(exploding gradient) 또는 그래디언트 소실(vanishing gradient) 문제가 발생한다.
이 문제를 아래 그림에서 하나의 은닉 유닛이 있는 예를 들어서 설명하겠다.
h(t)h(k){\partial h^{(t)} \over \partial h^{(k)}}는 tkt - k개의 곱셈으로 이루어진다. 즉, 가중치 ww가 tkt-k번 곱해져 wtkw_{t-k}가 된다.
결국 w<1|w| < 1이면 tkt - k가 클 때 이 항이 매우 작아진다.
반면 순환 에지의 가중치 값이 w>1|w| > 1 이면 tkt - k가 클 때 wtkw_{t - k}가 매우 커진다.
tkt - k 값이 크다는 것은 긴 시간 의존성을 가진다는 의미이다.
그래디언트 소실이나 폭주를 피하는 간단한 해결책은 w=1|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=1w = 1 를 유지하는 순환 에지가 있다. 이 순환 에지의 출력을 셀 상태(cell state)라고 한다.
자세한 LSTM 구조가 아래 그림에 나타나 있다.
이전 타임 스텝의 셀 상태 C(t1)C^{(t-1)}은 어떤 가중치와도 직접 곱해지지 않고 변경되어 현재 타임 스텝의 셀 상태 C(t)C^{(t)}를 얻는다.
메모리 셀의 정보 흐름은 다음에 기술된 몇 개의 연산으로 제어된다.
위 그림에서 \odot는 원소별 곱셈(element-wise multiplication), \oplus는 원소별 덧셈(element-wise addition)을 나타낸다.
x(t)x^{(t)}는 타임 스텝 tt에서 입력 데이터고 h(t1)h^{(t-1)}는 타임 스텝 t1t-1에서 은닉 유닛의 출력이다.
네 개의 상자는 시그모이드 함수(σ\sigma)나 하이퍼볼릭 탄젠트(tanh) 활성화 함수와 일련의 가중치로 표시된다.
이 상자는 입력에 대해 행렬-벡터 곱셈을 수행한 후 선형 조합된다.
시그모이드 함수로 계산하는 유닛을 게이트(gate)라고 하며 \odot을 통해 출력된다.
LSTM 셀에는 세 종류의 게이트가 있다. 삭제 게이트(forget gate), 입력 게이트(input gate), 출력 게이트(output gate)이다.
삭제 게이트(ftf_{t})는 메모리 셀이 무한정 성장하지 않도록 셀 상태를 다시 설정한다.
사실 삭제 게이트가 통과할 정보와 억제할 정보를 결정한다.
ftf_{t}는 다음과 같이 계산된다.
ft=σ(Wxfx(t)+Whfh(t1)+bf)f_{t} = \sigma (W_{xf} x^{(t)} + W_{hf} h^{(t-1)} + b_{f})
삭제 게이트는 원본 LSTM 셀에 포함되어 있지 않았다. 초기 모델을 향상 시키기 위해 몇 년 후에 추가되었다.
입력 게이트(iti_{t})와 입력 노드(gtg_{t})는 셀 상태를 업데이트하는 역할을 담당하며 다음과 같이 계산한다.
it=σ(Wxix(t)+Whih(t1)+bi)i_{t} = \sigma (W_{xi} x^{(t)} + W_{hi} h^{(t-1)} + b_{i})
gt=tanh(Wxgx(t)+Whgh(t1)+bg)g_{t} = tanh(W_{xg} x^{(t)} + W_{hg} h^{(t-1)} + b_{g})
타임 스텝 tt에서 셀 상태는 다음과 같이 계산한다.
C(t)=(C(t1)ft)(itgt)C^{(t)} = (C^{(t-1)} \odot f_{t}) \oplus (i_{t} \odot g_{t})
출력 게이트 (oto_{t})는 은닉 유닛의 출력 값을 업데이트 한다.
ot=σ(Wxox(t)+Whoh(t1)+bo)o_{t} = \sigma (W_{xo} x^{(t)} + W_{ho} h^{(t-1)} + b_{o})
이를 가지고 현재 타임 스텝에서 은닉 유닛의 출력을 다음과 같이 계산한다.
h(t)=ottanh(C(t))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')
Python
데이터프레임 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()
Python
단어 시퀀스를 정수 시퀀스로 변환했지만 한 가지 풀어야 할 문제가 있다. 이 시퀀스들은 길이가 서로 다르다. 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:]
Python
데이터셋을 전처리한 후 데이터를 훈련 세트와 테스트 세트로 나눈다. 이미 무작위로 섞여 있기 때문에 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
Python

임베딩

이전의 데이터 준비 단계에서 동일한 길이의 시퀀스를 생성했다. 이 시퀀스의 원소는 고유한 단어의 인덱스에 해당하는 정수 숫자이다.
이런 단어 인덱스를 입력 특성을 변환하는 몇 가지 방법이 있다. 간단하게 원-핫 인코딩을 적용하여 인덱스를 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'))
Python
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
Python
임베딩 층의 출력은 3차원 텐서이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 타임 스텝이다. 마지막 차원이 임베딩 벡터의 차원이다.
앞서 n_words 크기가 102,967이었으므로 200차원을 곱하면 전체 모델 파라미터 개수는 20,593,400이 된다.

RNN 모델 만들기

이제 본격적으로 RNN 층을 추가할 차례다. 여기서는 긴 시퀀스를 학습하는데 유리한 tf.keras.layers.LSTM 층을 사용하겠다. 이 LSTM 층은 16개의 순환 유닛을 사용한다.
model.add(layers.LSTM(16))
Python
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'))
Python
순환 신경망 모델이 만들어졌다. 완전 연결 신경망이나 합성곱 신경망 보다 어렵지 않다. 전체 모델 구조를 살펴보자.
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
Python
LSTM 층의 출력 크기는 (None, 16)이다. 첫 번째 차원은 배치 차원이고, 두 번째 차원은 셀의 출력(유닛 개수) 차원이다. LSTM 층이 가지는 모델 파라미터 개수는 13,888개 이다.
좀 더 자세히 분석해 보자. 먼저 삭제 게이트(ftf_{t})에 필요한 모델 파라미터를 계산해 보자.
임베딩된 입력 벡터와 곱해지는 WxfW_{xf}는 (16, 200) 차원이고 이전 셀의 히든 상태와 곱해지는 WhfW_{hf}는 (16, 16) 차원이다. 마지막으로 절편은 유닛마다 하나씩 있으므로 bfb_{f}는 (16,) 차원이다. 이를 모두 더하면 16 x 200 + 16 x 16 + 16 = 2,472 개이다.
LSTM 층에는 삭제 게이트와 같은 계산이 세 개 더 있다. it,gt,oti_{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'])
Python
합성곱 신경망에서 했던 ㄱ서처럼 가장 좋은 검증 점수의 모델 파라미터를 체크포인트로 저장하고 텐서보드를 취한 출력을 지정하겠다.
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()]
Python
이제 모델을 훈련할 단계이다. 배치 크기는 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)
Python
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()
Python
정확도 그래프를 그려보자.
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()
Python
출력 결과를 보면 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
Python
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 ]]
Python
다중 분류에서는 가장 큰 확률의 레이블이 예측 클래스가 되고 이진 분류 문제에서는 0.5보다 크면 양성 클래스가 된다.
간단하게 0.5보다 큰 값을 구분할 수 있지만 Sequential 클래스는 친절하게 이를 위한 predict_classes 메서드도 제공한다.
print(model.predict_classes(X_test[:10])) ### 결과 # [[0] # [0] # [0] # [1] # [1] # [1] # [0] # [1] # [0] # [1]]
Python
최적화를 위해 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
Python
이 텍스트를 char2int 딕셔너리를 사용하여 모두 정수로 바꾸어 text_ints 배열에 저장했다.
데이터 전처리에서 가장 중요한 단계는 이 데이터를 시퀀스의 배치로 바꾸는 작업이다. 지금까지 본 글자 시퀀스를 기반으로 새로운 글자를 예측하는 것이 목적이다.
따라서 신경망의 입력(xx)과 출력(yy)을 한 글자씩 이동한다.
텍스트 데이터셋에서 데이터 배열 xx와 yy를 생성하는 것부터 시작해서 아래 그림에 이 전처리 단계를 나타냈다.
그림에서 볼 수 있듯이 훈련 배열 xx 와 yy 는 동일한 크기 또는 차원을 가진다. 행 개수는 배치 크기와 같고, 열 개수는 배치 횟수 x 스텝 횟수이다.
텍스트 데이터의 글자를 표현한 정수 입력 배열 data가 주어지면 다음 함수는 위의 그림과 동일한 구조의 xx 와 yy를 만든다.
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
Python
시퀀스 길이를 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
Python
text_ints를 64개의 행을 가진 2차원 배열 train_x와 train_y로 바꾸었다. train_x 크기를 출력해 보면 배치 크기의 행이 만들어진 것을 확인할 수 있다.
train_x와 train_y를 정수 크기대로 출력하고 int2char 딕셔너리를 사용하여 문자로도 출력했다.
출력 결과를 보면 train_y가 한 글자씩 밀려 있다는 것을 확인할 수 있다.
다음 단계에서 배열 xx 와 yy를 나누어 열 길이가 스텝 횟수와 동일한 미니 배치를 만든다. 데이터 배열 xx를 나누는 과정이 아래 그림에 나와있다.
다음 코드에서 위 그림에 나온 데이터 배열 xx 와 yy를 나누어 배치를 출력하는 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])
Python
이 코드에서 정의한 제너레이터는 메모리 부족을 해결할 수 있는 좋은 기법이다.
신경망을 훈련하는 동안 모든 데이터를 미리 나누어 메모리에 저장하지 않고 데이터셋을 미니 배치로 나누는 방식이 바람직하다.
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
Python
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)
Python
배치 크기를 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)
Python
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()
Python
훈련 데이터를 원-핫 인코딩 했으므로 임베딩 층 대신 LSTM 층을 바로 추가하겠다. 이때 두 가지를 고려해야 한다.
첫째, 이 모델은 훈련할 때 길이가 100인 시퀀스를 주입한다. 즉, 타임 스텝 길이가 100이다. 하지만 새로운 글자를 생성할 때는 이전 글자를 주입하여 한 글자씩 생성한다. 다시 말해 샘플링 시에는 배치 크기가 1이 된다. 따라서 훈련과 샘플링 시에 배치 크기와 타임 스텝 크기가 다음과 같이 정의된다.
sampling mode={batch size=1num steps=1\text{sampling mode} = \begin{cases} \text{batch size} = 1 \\ \text{num steps} = 1 \end{cases}
training mode={batch size=64num steps=100\text{training mode} = \begin{cases} \text{batch size} = 64 \\ \text{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))
Python
이 모델에 입력할 데이터는 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')))
Python
전체 모델 구성이 끝났다. 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
Python
가변 길이 시퀀스를 다루기 위해 LSTM 층과 TimeDistributed 층의 출력에서 두 번째 차원이 None으로 된 것을 볼 수 있다. 또 최종 출력에 타임 스텝 차원이 포함되었다.
모델 구성을 마치면서 각 츠으이 모델 파라미터의 크기를 계산해 보자.
순환 유닛이 128개이고 원-핫 인코딩의 크기가 65이므로 WxfW_{xf}는 (128, 65) 차원이다. 셀의 히든 상태와 곱해지는 WhfW_{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)
Python
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)
Python
훈련된 모델을 저장하여 나중에 학습을 이어 가거나 텍스트를 생성할 수 있도록 체크포인트 콜백을 준비한다.
from tensorflow.keras.callbacks import ModelCheckpoint callback_list = [ModelCheckpoint(filepath='char_rnn_chckpoint.h5')]
Python
이제 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의 소스를 그대로 써도 에러가 나서 이 예제는 이후 결과 없이 종료
Python
반복 횟수가 많기 때문에 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
Python
“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]
Python
초기 문자열 “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)
Python
결과에서 볼 수 있듯이 일부 영어 단어는 거의 그대로 유지되었다. 이 예제는 오래된 영어 텍스트를 사용했으므로 원본 텍스트에는 낯선 단어가 일부 포함되어 있다.
더 나은 결과를 얻으려면 에포크 수를 늘려서 모델을 훈련하거나 훨씬 더 큰 문서를 사용해도 좋다. np.random.choice 대신 확률 값의 크기에 따라 글자 선택 가능성을 높일 수도 있다.