지금까지 살펴본 신경망은 피드포워드(feed forward)라는 신경망이다. 흐름이 단방향인 신경망을 말한다.
다시 말해, 입력 신호가 다음 층(중간 층)으로 전달되고, 그 신호를 받은 층은 그 다음 층으로 전달하고, 다시 다음 층으로... 식으로 한 방향으로만 신호가 전달된다.
피드포워드 신경망은 구성이 단순하여 구조를 이해하기 쉽고, 그래서 많은 문제에 으용할 수 있다.
그러나 커다란 단점이 하나 있는데, 바로 시계열 데이터를 잘 다루지 못한다는 것이다.
정확하게 말하면, 단순한 피드포워드 신경망에서는 시계열 데이터의 성질(패턴)을 충분히 학습할 수 없다.
그래서 순환 신경망(Recurrent Neural Network, RNN)이 등장하게 된다.
확률과 언어 모델
RNN을 시작하기 위한 준비 과정으로, 앞장의 word2vec을 복습해보자.
그 다음 자연어에 관한 현상을 확률을 사용해 기술하고, 마지막에는 언어를 확률로 다루는 언어 모델에 대해 설명한다.
word2vec을 확률 관점에서 바라보다
word2vec의 CBOW 모델을 다시 보자. w1,w2,...wr이라는 단어열료 표현되는 말뭉치를 생각해보자.
t번째 단어를 target으로, 그 전후 단어를 contexts라고 취급해보자.
이때 CBOW 모델은 다음 그림처럼 contexts로부터 target을 추측하는 일을 수행한다.
contexts가 주어졌을 때 target이 wt가 될 확률을 수식화하면 다음 식이 된다.
CBOW 모델은 위 식의 사후 확률을 모델링한다.
사후 확률은 contexts가 주어졌을 때 target(wt)가 일어날 확률을 뜻한다. 이것이 윈도우 크기가 1일때 CBOW 모델이다.
지금까지는 contexts를 항상 좌우 대칭으로 생각해왔다. 이번에는 contexts를 왼쪽 윈도우만 한정해보자. 그림은 다음과 같이 된다.
마찬가지로 contexts로부터 target이 wt가 될 확률 수식은(사후 확률) 다음과 같다.
위 식의 표기를 사용하면 CBOW모델이 다루는 손실 함수를 아래 식처럼 쓸 수 있다. 교차 엔트로피 오차에 의해 유도한 결과이다. (자세한 내용은 chap1. 손실함수 참고)
CBOW 모델의 학습으로 수행하는 일은 위 식의 손실 함수(정확히는 말뭉치 전체의 손실 함수의 총합)를 최소화하는 가중치 매개변수를 찾는 것이다.
이러한 가중치 매개변수가 발견되면 CBOW 모델은 contexts로부터 target을 더 정확하게 추측할 수 있게 된다.
이처럼 CBOW 모델을 학습시키는 본래 목적은 contexts로부터 target을 정확하게 추측하는 것이다.
이 목적을 위해 학습을 진행하면, 그 부산물로 단어의 의미가 인코딩된 단어의 분산 표현을 얻을 수 있다.
그럼 CBOW의 본래 목적은 어디에 이용할 수 있을까? 확률식은 실용적인 쓰임이 있을까?
여기서 언어 모델이 등장한다.
언어 모델
언어 모델(Language Model)은 단어 나열에 확률을 부여한다.
특정한 단어 시퀀스에 대해, 그 시퀀스가 일어날 가능성이 어느 정도인지(얼마나 자연스러운 단어 순서인지)를 확률로 평가하는 것이다.
예를 들어 you say goodbye 단어 시퀀스는 높은 확률(0.092)를 출력하고, you say good die에는 낮은 확률(0.000000032)을 출력하는 것이 일종의 언어 모델이다.
이 언어 모델은 다양하게 응용할 수 있다. 기계 번역과 음성 인식이 대표적인 예이다.
음석 인식 시스템의 경우, 사람이 음성으로부터 몇 개의 문장을 후보로 생성할 것이다.
그 다음 언어 모델을 사용하여 후보 문장이 '문장으로써 자연스러운지'를 기준으로 순서를 매길 수 있다.
또한 언어 모델은 새로운 문장을 생성하는 용도로도 이용할 수 있다.
왜냐하면 언어 모델은 단어 순서의 자연스러움을 확률적으로 평가할 수 있으므로, 그 확률분포에 따라 다음으로 적합한 단어를 샘플링 할 수 있기 때문이다. 참고로 언어 모델을 사용한 문장 생성은 chap7에서 설명한다.
언어 모델의 수식을 보자. w1,...,wm이라는 m개의 단어로 된 문장이 있다. 이때 단어가 w1,...,wm 순서로 출현할 확률을
P(w1,...,wm)으로 나타낸다.
이 확률은 여러 사건이 동시에 일어날 확률이므로 동시 확률이다.
이 동시 확률 P는 사후 확률을 사용하여 다음과 같이 분해하여 쓸 수 있다.
위 식에서 알 수 있듯, 동시 확률은 사후 확률의 총곱으로 나타낼 수 있다.
위 식의 결과는 확률의 곱셈정리로부터 유도할 수있다. 곱셈정리 식을 참고하자.
곱셈정리를 사용하면 m개 단어의 동시 확률을 사후 확률로 나타낼 수 있고, 이때 식 변형을 알기 쉽게 나타내면 다음과 같다.
위 식을 A에 대해 식 변형을 이어나갈 수 있다.
여기에서 주목할 것은 사후 확률은 target단어보다 왼쪽에 있는 모든 단어를 contexts로 했을 때의 확률이다.
아래 그림을 참고하자.
정리하면, 우리의 목표는 사후 확률을 얻는 것이다. 이 확률을 통해 언어 모델의 통시확률을 구할 수 있다.
CBOW 모델을 언어 모델로?
worb2vec의 CBOW 모델을 언어 모델에 적용하려면 맥락의 크기를 특정 값으로 한정하여 근사적으로 나타낼 수 있다.
여기서는 contexts를 왼쪽 2개의 단어로 한정한다.
그러면 CBOW 모델에 따라(사후 확률에 따라) 근사적으로 나타낼 수 있다.
머신러닝이나 통계학에서는 마르코프 연쇄 또는 모델(Markov Chain,Model)이라는 말을자주 듣는다.
마르코프 연쇄란 미래의 상태가 현재 상태에만 의존해 결정되는 것을 말한다.
또한 이 사상의 확률이 그 직전 N개의 사건에만 의존할 때, 이를 N층 마르코프 연쇄라고 한다.
위의 예에서는 직전 2개의 단어에만 의존해 다음 단어가 정해지는 모델이므로 2층 마르코프 연쇄라고 볼 수 있다.
contexts의 크기는 임의로 설정할 수 있다. 그러나 임의로 설정해도 결국은 특정 길이로 고정된다.
예를 들어 왼쪽 10개의 단어를 contexts로 CBOW 모델을 만든다고 하면, 그 contexts보다 왼쪽에 있는 단어의 정보는 무시된다. 이것이 문제가 되는 상황이 있는데, 다음 예를 보자.
Tom이 방에서 TV를 보고 있었고, Mary가 방에 들어왔다. 이 contexts를 고려하면 Mary가 Tom에게 인사를 건넸다가 정답이 될 것이다.
이 문제에서 정답을 구하려면 예문의 target으로부터 18번째나 앞에 위치한 Tom을 기억해야 할 것이다.
CBOW 모델의 contexts가 10개까지였다면 이 문제에 제대로 답할 수 없을 것이다.
그러면 contexts 크기를 20,30 혹은 그 이상까지 키우면 문제가 되지 않을까?
CBOW모델에서는 contexts 안에 있는 단어 순서가 무시된다는 한계가 있다.
CBOW란 continuous bag-of-words의 약어이다.
가방 안의 단어는 '순서'도 무시된다는 뜻을 내포한다.(순서 대신 분포를 이용한다)
다음 예를 보자. contexts로 2개의 단어를 다루는 경우 CBOW 모델에서는 2개 단어 벡터의 합이 은닉층에 도착한다.
왼쪽 그림과 같이 CBOW모델의 은닉층에서는 단어 벡터들이 더해지므로 contexts의 단어 순서는 무시된다.
이상적으로는 contexts의 단어 순서도 고려하는 모델이 바람직할 것이다.
이를 위해 오른쪽 그림처럼 contexts의 단어 벡터를 은닉층에서 연결(concatenate)하는 방식을 생각할 수 있다.
실제 확률론적 언어 모델(NPL, Neural Probabilistic Language Model)에서 제안한 모델은 이 방식을 취한다.
연결하는 방식을 취하면 맥락의 크기에 비례해 가중치 매개변수도 늘어나게 된다. 매개변수가 증가한다는 건 환영할 만한 현상이 아니다.
위 문제는 RNN, 순환 신경망을 통해 해결할 수 있다. RNN은 contexts가 아무리 길더라도 그 맥락의 정보를 기억하는 매커니즘을 갖추고 있다.
그래서 RNN을 사용하면 아무리 긴 시계열 데이터라도 대응할 수 있다.
word2vec은 단어의 분산 표현을 얻을 목적으로 고안된 기법이다. 그래서 이를 언어 모델로 사용하는 경우는 잘 없다.
RNN이란
Recurrent Neural Network의 Recurrent는 라틴어로 '몇번이고 반복해서 일어나는 일'을 뜻한다.
직역하자면 순환하는 신경망이 된다는 것이다.
유사어로 Recursive Neural Network(재귀 신경망)이 있다. 이는 주로 트리 구조의 데이터를 처리하기 위한 신경망으로, 순환 신경망과는 다른 것이다.
순환하는 신경망
순환하다에는 '반복해서 되돌아감'의 의미가 담겨 있다.
어느 한 지점에서 시작한 것이 시간을 지나 다시 원래 장소로 돌아오는 것, 그리고 이 과정을 반복하는 것이다.
여기서 중요한 것은, 순환하기 위해 닫힌 경로가 필요하다는 것이다.
닫힌 경로, 혹은 순환하는 경로가 존재해야 데이터가 같은 장소를 반복해 왕래할 수있다.
그리고 데이터가 순환하면서 정보가 끊임없이 갱신되게 된다.
RNN의 특징은 순환하는 경로(닫힌 경로)가 있다는 것이다.
구체적으로 살펴보자. RNN 계층은 다음과 같이 그릴 수 있다.
그림처럼 RNN 계층은 순환하는 경로를 포함한다. 이 경로를 따라 데이터를 계층 안에서 순환시킬 수 있다.
그림에서는 xt를 입력받는데, t는 time(시각)을 의미한다.
x를 벡터라고 가정한다. 문장 단어 순서를 다루는 경우를 예로 든다면 각 단어의 분산 표현(단어 벡터)이 xt가 되며, 이 분산표현이 순서대로 하나씩 RNN 계층에 입력되는 것이다.
위 그림을 보면 출력이 2개로 '분기'하고 있음을 알 수 있다. 같은것이 복제되어 분기된 출력 중 하나가 순환하는 것이다.
순환 구조 펼치기
RNN 계층의 순환 구조는 지금까지의 신경망에는 존재하지 않던 구조이다.
피드포워드 신경망과 유사해 보이지만 RNN은 계층 모두가 실제로는 같은 계층인 것이 지금까지의 신경망과 다르다.
시계열 데이터는 시간 방향으로 데이터가 나열된다. 따라서 시계열 데이터의 인덱스를 가리킬 때는 '시각'이라는 용어를 사용한다.
위 그림에서 보듯, 각 시각의 RNN계층은 계층으로의 입력과 1개 전의 RNN 계층으로부터의 출력을 받는다.
그리고 이 정보를 바탕으로 현 시각의 출력을 계산한다. 계산 수식은 다음과 같다.
RNN에는 가중치가 2개 있다. 입력 x를 출력 h로 변환하기 위한 Wh(가중치) 하나와, 1개의 RNN 출력을 다음 시각의 출력으로 변환하기 위한 가중치 Wh이다. 편향(b)도 있다. ht-1과 xt는 행벡터이다.
위 식에서는 행렬 곱을 계산하고, 그 합을 tanh함수(쌍곡탄젠트 함수)를 이용해 변환한다.
그 결과가 시각 t의 출력 ht가 된다. ht는 다른 계층을 향해 위쪽으로 출력되는 동시에, 다음 시각의 RNN 계층(자기 자신)을 향해 오른쪽으로도 출력된다.
식을 보면, 현재의 출력 ht는 한시각 이전 출력 ht-1에 기초해 계산됨을 알 수 있다.
다른 관점으로 보면, RNN은 h라는 상태를 가지고 있으며, 식의 형태로 갱신된다고 해석할 수 있다.
그래서 RNN 계층을 상태를 가지는 계층 혹은 메모리(기억력) 있는 계층이라고도 한다.
RNN의 h는 상태를 기억해 시각이 1step 진행될 때 마다 위 식의 형태로 갱신된다.
많은 문헌에서 RNN의 출력 ht를 은닉 상태(hidden state) 혹은 은닉 상태 벡터(hidden state vector)라고 한다.
BPTT
앞서 봤던 것처럼, RNN 계층은 가로로 펼친 신경망으로 간주할 수 있다.
따라서 RNN의 학습도 보통의 신경망과 같은 순서로 진행할 수 있다. 다음 그림과 같은 모습이 되는 것이다.
여기서의 오차역전파법은 시간 방향으로 펼친 오차역전파법이란 뜻으로 BPTT(Backpropagation Trough Time)라고 한다.
그럼 긴 시계열 데이터를 어떻게 학습시킬 것인가?
시계열 데이터의 시간 크기가 커지는 것에 비례하여 BPTT가 소비하는 컴퓨팅 자원도 증가할 뿐더러, 역전파 시의 기울기가 불안정해진다.
Truncated BPTT
큰 시계열 데이터를 취급할 때는 흔히 신경망 연결을 적당한 길이로 끊는다.
시간축 방향으로 너무 길어진 신경망을 적당한 지점에서 잘라내어 작은 신경망 여러개로 만든다는 아이디어다.
잘라낸 작은 신경망에서 오차역전파법을 수행한다. 이것이 Truncated BPTT(잘린 BPTT)라는 기법이다.
Truncated BPTT는 신경망의 연결을 끊긴 하지만, 제대로 구현하려면 역전파의 연결만 끊어야 한다.
순전파의 연결은 반드시 유지해야 한다. 구체적인 예를 보자.
길이가 1000인 시계열 데이터가 있다고 해보자. 자연어 문제에서라면 단어 1000개짜리 말뭉치에 해당한다.
덧붙여서, 우리는 지금까지 다룬 PTB데이터셋에서는 여러 문장을 연결할 것을 하나의 큰 시계열 데이터로 취급했다.
여기에서도 마찬가지로 여러 문장을 연결한 것을 하나의 시계열 데이터로 취급하겠다.
RNN계층을 펼치면 계층이 가로로 1000개가 늘어선 신경망이 된다. 물론 아무리 늘어나더라도 오차역전파법으로 기울기를 계산할 수는 있다. 하지만 너무 길면 계산량과 메모리 사용량 등이 문제가 된다.
또한 계층이 길어지면 기울기 값이 신경망을 통과할때마다 작아져서, 이전 시각 t까지 역전파되기 전에 0이 되어 소멸할 수도 있다.
지금까지 본 신경망에서는 미니배치 학습을 수행할 때 데이터를 무작위로 선택해 입력했다.
RNN에서 Truncated BPTT를 수행할 때는 데이터를 순서대로 입력해야 한다.
그럼 Truncated BPTT 방식으로 RNN을 학습시켜보자. 가장 먼저 할 일은 첫번째 입력 데이터(x0,...,x9)을 RNN 계층에 제공하는 것이다. 그러면 다음 그림과 같아진다.
그림에서 보듯 먼저 순전파를 수행하고, 그다음 역전파를 수행한다. 다음 블록의 입력 데이터(x10에서 x19)를 입력해 오차역전파법을 수행한다.
두번째 블록도 첫번째 블록과 마찬가지로 순전파를 수행한 다음 역전파를 수행한다.
중요한 점은, 이번 순전파 계싼에는 앞 블록의 마지막 은닉 상태인 h9이 필요하다. 이것으로 순전파는 계속 연결될 수 있다.
이러한 계산 과정을 전체적으로 보면 다음 그림과 같다.
Truncated BPTT에서는 위 그림처럼 데이터를 순서대로 입력해 학습한다. 이런 식으로 순전파의 연결을 유지하면서 블록 단위로 오차역전파법을 적용할 수 있다.
Truncated BPTT의 미니배치 학습
미니배치 학습을 수행할 땐, 원래대로라면 구체적인 배치 방식을 고려해 데이터를 순서대로 입력해야 한다.
그렇게 하려면 데이터를 주는 시작 위치를 각 미니배치의 시작 위치로 옮겨줘야 한다.
옮긴다는 뜻을 설명하기 위해 길이가 1000인 시계열 데이터에서 시각을 10개 단위로 잘라 Truncated BPTT로 학습하는 경우를 예로 들어보자.
그러면 이때 미니배치의 수를 두개로 구성해 학습하려면 어떻게 해야 될까?
RNN 계층의 입력 데이터로, 첫번째 미니배치(샘플 데이터) 때는 처음부터 순서대로 데이터를 제공한다.
두번째 미니배치 때는 500번째의 데이터를 시작 위치로 정하고, 그 위치부터 다시 순서대로 데이터를 제공한다. 즉 시작 위치를 500만큼 옮겨주는 것이다.
이처럼 미니배치 학습을 수행할 때는 각 미니배치의 시작 위치를 오프셋으로 옮겨준 후 순서대로 제공하면 된다.
데이터를 순서대로 입력하다가 끝에 도달하면 다시 처음부터 입력하도록 한다.
Truncated BPTT의 원리는 단순하지만 데이터 제공 방법 면에서는 몇가지를 주의해야 한다.
구체적으로는 '데이터를 순서대로 제공하기'와 '미니배치별로 데이터를 제공하는 시작 위치를 옮기기'이다.
RNN 구현
Truncated BPTT 방식의 학습을 따른다면, RNN은 가로 크기가 일정한 일련의 신경망을 만들면 된다.
다음 그림처럼 말이다.
위에서 보듯, 우리가 다룰 신경망은 길이가 임의의 값 T인 시계열 데이터를 받는다.
각 시간의 은닉 상태를 T개 출력한다.
그리고 모듈화를 생각해, 옆으로 성장한 신경망을 하나의 계층으로 구현한다. 다음 그림을 참고하자.
옆으로 늘어선 일련의 계층을 하나의 계층으로 간주할 수 있다.
이때 Time RNN 계층 내에서 한 단계의 작업을 수행하는 계층을 RNN 계층이라고 한다.
T개 단계분의 작업을 한꺼번에 처리하는 계층은 Time RNN 계층이라고 한다.
RNN 계층 구현
RNN처리를 한단계만 수행하는 RNN 클래스부터 구현해보자.
RNN의 순전파 수식을 복습해보면 다음과 같다.
우리는 데이터를 미니배치로 모아 처리한다. 행렬을 계산할 때는 형상 확인이 중요하다.
미니배치 크기 N, 입력 벡터 차원 D, 은닉 상태 차원 H라고 하면 계산에서 형상 확인은 다음과 같이 해볼 수 있다.
이를 바탕으로 RNN 클래스의 초기화와 순전파 메서드를 구현해보자.
# -*- coding: utf-8 -*-
"""
Created on Thu Dec 26 14:57:36 2024
Created by SeungKeon Lee
"""
import numpy as np
class RNN:
def __init__(self,Wx,Wh,b):
self.params = [Wx,Wh,b]
self.grads = [np.zeros_like(Wx),np.zeros_like(Wh),np.zeros_like(b)]
self.cache = None
def forward(self,x,h_prev):
Wx,Wh,b = self.params
t = np.matmul(h_prev, Wh) + np.matmul(x,Wx) + b
h_next = np.tanh(t)
self.cache = (x,h_prev,h_next)
return h_next
초기화 메서드는 가중치 2개와 편향 1개를 인수로 받는다. 받은 인수를 params에 리스트로 저장한다.
각 매개변수에 대응하는 형태로 기울기를 초기화한 후 grads에 저장한다.
마지막으로 역전파 계산시 사용하는 중간 데이터를 담을 cache를 None으로 초기화한다.
순전파 메서드에서는 입력 x와 왼쪽으로부터의 입력 h_prev를 받는다.
현시각 RNN계층으로붜의 출력은 h_next이다.
RNN의 역전파를 구현하기 전에, RNN의 순전파를 계산 그래프로 다시 확인해보자.
bias의 덧셈에서는 브로드캐스트가 일어나기 때문에 정확하게는 Repeat노드를 이용하지만, 간략하게 보여주기 위해 생략한다.
역전파는 순전파 떄와는 반대 방향으로 각 연산자의 역전파를 수행하기만 하면 된다.
RNN계층의 backward 구현 코드를 보자.
def backward(self,dh_next):
Wx,Wh,b = self.params
x,h_prev,h_next = self.cache
dt = dh_next * (1-h_next **2)
db = np.sum(dt, axis=0)
dWh = np.matmul(h_prev.T,dt)
dh_prev = np.matmul(dt,Wh.T)
dWx = np.matmul(x.T,dt)
dx = np.matmul(dt,Wx.T)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
이상이 RNN 계층의 구현이다.
이어서 Time RNN 계층을 구현할 차례이다.
Time RNN 계층 구현
Time RNN계층은 T개의 RNN 계층으로 구성된다. T는 임의의 수로 설정할 수 있다.
여기에서는 RNN계층의 은닉 상태 h를 인스턴스 변수로 유지한다.
이 변수를 아래 그림처럼 은닉 상태를 인계받는 용도로 이용한다.
이렇게 하면 Time RNN 사용자는 RNN계층 사이에서 은닉 상태를 인계하는 작업을 하지 않아도 되는 장점이 생긴다.
이 책에서는 이 기능(은닉 상태를 인계받을지)을 stateful 인수로 조정할 수 있도록 했다.
Time RNN계층의 코드를 보자. 초기화 메서드와 또 다른 메서드 2개가 있다.
class TimeRNN:
def __init__(self,Wx,Wh,b,stateful = False):
self.params = [Wx,Wh,b]
self.grads = [np.zeros_like(Wx),np.zeros_like(Wh),np.zeros_like(b)]
self.layers = None
self.h, self.dh = None, None
self.stateful = stateful
def set_state(self,h):
self.h = h
def reset_state(self):
self.h = None
변수 layers는 다수의 RNN 계층을 리스트로 저장하는 용도이다.
h는 forward메서드를 불렀을 때 마지막 RNN계층의 은닉 상태를 저장하고, dh는 backward메서드에서 하나 앞 블록의 은닉 상태의 기울기를 저장한다.
긴 시계열 데이터를 처리할 때는 RNN의 은닉 상태를 유지해야 한다. 이를 흔히 stateful이라고 표현한다.
순전파의 구현을 보자.
def forward(self,xs):
Wx,W,b = self.params
N,T,D = xs.shape
D,H = Wx.shape
self.layes = []
hs = np.empty((N,T,H),dtype='f')
if not self.stateful or self.h is None:
self.h = np.zeros((N,H),dtype='f')
for t in range(T):
layer = RNN(*self.params)
self.h = layer.forward(xs[:,t,:],self.h)
hs[:,t,:] = self.h
self.layers.append(layer)
return hs
forward는 아래로부터 입력 xs를 받는다. xs는 T개 분량의 시계열 데이터를 하나로 모은 것이다.
따라서 미니배치 크기를 N, 입력 벡터의 차원 수를 D라고 하면, xs의 형상은 (N,T,D)가 된다.
RNN계층의 은닉 상태 h는 처음 호출시 원소가 모두 0인 행렬로 초기화된다.
stateful이 False일때도 항상 영행렬로 초기화한다.
기본구현에서는 hs에 출력값을 담을 그릇을 준비한다.
이어서 총 T회 반복되는 for문 안에서 RNN 계층을 생성하여 layers에 추가한다. RNN계층이 각 시각 t의 은닉상태 h를 계산하고, 이를 hs에 해당 인덱스(시각)의 값으로 설정한다.
이어서 TimeRNN 계층의 역전파 구현이다. 계산 그래프는 다음과 같다.
상류(출력쪽 층)에서부터 전해지는 기울기를 dhs라고 쓰고, 하류로 내보내는 기울기를 dxs로 쓴다.
Truncated BPTT를 수행하기 때문에 이 블록의 이전 시각 역전파는 필요하지 않다.
단, 이전 시각의 은닉 상태 기울기는 dh에 저장한다. chap7.에서 다루는 seq2seq에 필요하다.
t번째의 RNN 계층에 주목하면, 그 역전파는 다음 그림처럼 그릴 수 있다.
t번째 RNN계층에서는 위로부터 기울기 dht와 한 시각 뒤 미래 계층으로부터 기울기 dhnext가 전해진다.
여기서 주의점은 RNN계층의 순전파에서는 출력이 2개로 분기된다는 것이다.
순전파시 분기했을 경우, 역전파에서는 각 기울기가 합산되어 전해진다.
따라서 역전파시 RNN계층에서는 합산된 기울기 (dht + dhnext)가 입력된다.
위 사항을 주의하여 코드로 구현한 역전파는 다음과 같다.
def backward(self,dhs):
Wx,Wh,b = self.params
N,T,H = dhs.shape
D,H = Wx.shape
dxs = np.empty((N,T,D),dtype='f')
dh = 0
grads = [0,0,0]
for t in reversed(range(T)):
layer = self.layers[t]
dx, dh = layer.backward(dhs[:,t,:] + dh) #합산된 기울기
dxs[:,t,:] = dx
for i, grad in enumerate(layer.grads):
grads[i] += grad
for i,grad in enumerate(grads):
self.grads[i][...] = grad
self.dh = dh
return dxs
여기에서도 가장 먼저 하류로 흘려보낼 기울기를 담을 그릇인 dxs를 만든다.
그리고 나서 순전파때와는 반대 순서로 RNN계층의 backward메서드를 호출하여 각 시각의 기울기 dx를 구해 dxs의 해당 인덱스(시각)에 저장한다.
가중치 매개변수에 대해서도 각 RNN계층의 가중치 기울기를 합산하여 최종 멤버 변수 self.grads에 덮어쓴다.(이때 ...문법을 사용한다.)
Time RNN 계층 안에는 RNN 계층이 여러개있다. 그리고 그 RNN 계층들에서 똑같은 가중치를 사용하고 있다.
따라서 Time RNN 계층의 최종 가중치의 기울기는 각 RNN 계층의 가중치 기울기를 모두 더한 게 된다.
시계열 데이터 처리 계층 구현
RNN을 사용해 언어 모델을 구현해보자.
지금까지 RNN계층을 구현했는데, 이번 절에서는 시계열 데이터를 처리하는 계층을 몇개 더 만들어보자.
RNNLM(RNN Language Model)완성을 목표로 해보자.
RNNLM의 전체 그림
아래 그림은 RNNLM의 계층 구성이고, 오른쪽에는 이를 시간축으로 펼친 신경망이다.
첫번째(가장 아래)층은 Embedding 계층이다. 이 계층은 단어 ID를 단어의 분산 표현(단어 벡터)로 변환한다.
그리고 분산 표현이 RNN계층으로 입력된다.
RNN계층은 은닉 상태를 다음 층(위쪽)으로 출력함과 동시에, 다음 시각의 RNN계층(오른쪽)으로 출력한다.
RNN계층이 위로 출력한 은닉 상태는 Affine계층을 거쳐 Softmax계층으로 전해진다.
위 그림의 신경망에서 순전파로 한정해, 구체적인 데이터를 흘려보내면서 출력 결과를 관찰해보자.
입력 데이터로는 친숙한 문장인 You say goodbye and I say hello. 를 사용해보자.
이때 RNNLM에서 이뤄지는 처리는 다음 그림과 같이 된다.
두번째 say단어에 주목해보자. 이때 Softmax계층 출력은 goodbye와 hello 두곳에서 높게 나왔다.
you say goodbye와 you say hello는 확실히 모두 자연스러운 문장이다.
여기서 주목할 점은 RNN계층은 you say라는 contexts를 기억하고 있다는 사실이다.
더 정확하게 말하면, RNN은 you say라는 과거의 정보를 응집된 은닉 상태 벡터로 저장해두고 있다.
그러한 정보를 더 위의 Affine 계층에, 그리고 다음 시각의 RNN계층에 전달하는 것이 RNN계층이 하는 일이다.
이처럼 RNNLM은 지금까지 입력된 단어를 기억하고, 그것을 바탕으로 다음에 출현할 단어를 예측한다.
이 일을 가능하게 하는 비결이 RNN계층의 존재이다.
RNN계층이 과거에서 현재로 데이터를 계속 흘려줌으로써 과거의 정보를 인코딩해 저장(기억)할 수 있는 것이다.
Time계층 구현
시계열 데이터를 한꺼번에 처리하는 계층을 Time RNN이라는 이름의 계층으로 구현했다.
마찬가지로, 시계열 데이터를 한번에 처리하는 계층을 Time Embedding, Time Affine형태의 이름으로 구현하겠다.
다음 그림을 참고하자.
Time계층은 간단하게 구현할 수 있다. Affine계층을 T개 준비해서, 각 시각의 데이터를 개별적으로 처리하면 된다.
Time Affine과 Time Embedding계층은 특별히 어려운 부분이 없으니 설명은 생략한다.
참고로 Time Affine계층은 단순히 Affine 계층 T개를 이용하는 방식 대신 행렬 계산으로 한꺼번에 처리하는 효율 좋은 방식으로 구현되었다. common/time_layers.py의 TimeAffine 클래스를 참고하자.
Softmax계층을 구현할 때는 손실 오차를 구하는 Cross Entropy Error계층도 함께 구현한다.
x0,x1등의 데이터는 아래층으로부터 전해진 점수를 나타낸다.(확률로 정규화되기 전의 값)
t0,t1등의 데이터는 정답 레이블이다.
그림에서 보듯, T개의 Softmax with Loss계층 각각이 손실을 산출한다.
손실들을 합산해 평균을 구한 값이 최종 손실이 된다.
이때 수행하는 계산의 수식은 다음과 같다.
그런데 이 책의 Softmax with Loss계층은 미니배치에 해당되는 손실의 평균을 구했다.
데이터 N개짜리 미니배치라면 그 N개의 손실을 더해 다시 N으로 나눠 데이터 1개당 평균 손실을 구했다.
이와 마찬가지로 Time Softmax with Loss계층도 시계열에 대한 평균을 구하는 것으로, 데이터 1개당 평균 손실을 구해 최종 출력으로 내보낸다.
RNNLM 학습과 평가
RNNLM 구현
SimpleRnnlm 클래스의 계층 구성은 다음과 같다.
초기화 코드를 보자.
# -*- coding: utf-8 -*-
"""
Created on Fri Dec 27 14:55:14 2024
Created by SeungKeon Lee
"""
import sys
sys.path.append('..')
import numpy as np
from common.time_layers import *
class SimpleRnnlm:
def __init__(self,vocab_size,wordvec_size,hidden_size):
V,D,H = vocab_size,wordvec_size,hidden_size
rn = np.randn
#가중치 초기화
embed_W = (rn(v,D)/100).astype('f')
rnn_Wx = (rn(D,H)/np.sqrt(D)).astype('f')
rnn.Wh = (rn(H,H)/np.sqrt(H)).astype('f')
rnn_b = np.zeros(H).astype('f')
affine_W = (rn(H,V)/np.sqrt(H)).astype('f')
affine_b = np.zeros(V).astype('f')
#계층 생성
self.layers = [
TimeEmbedding(embed_W),
TimeRNN(rnn_Wx,rnn_Wh,rnn_b,stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.rnn_layer = self.layers[1]
#모든 가중치와 기울기를 리스트에 모은다.
self.params, self.grads = [],[]
for layer in self.layers:
self.params += layer.params
self.grads += layer.grads
각 계층에서 사용하는 매개변수(가중치와 편향)을 초기화하고 필요한 계층을 생성한다.
Truncated BPTT로 학습한다고 가정하여 TimeRNN계층의 stateful을 True로 설정한다. TimeRNN계층은 이전 시각의 은닉 상태를 계승할 수 있다.
이 초기화 코드는 RNN계층와 Affine계층에서 Xavier초깃값을 이용했다.
이전 계층의 노드가 n개라면 표준편차가 1/(n^1/2)인 분포로 값들을 초기화한다. 표준편차는 데이터의 차이를 직관적으로 나타내는 척도로 해석할 수 있다.
계속해서 forward, backward, reset_size 메서드 구현 코드를 보자.
def forward(self,xs,ts):
for layer in self.layers:
xs = layer.forward(xs)
loss = self.loss_layer.forward(xs,ts)
return loss
def backward(self,dout = 1):
dout = self.loss_layer.backward(dout)
for layer in reversed(self.layers):
dout = layer.backward(dout)
return dout
def reset_state(self):
self.rnn_layer.reset_state()
각각의 계층에서 순전파와 역전파를 적절히 구현해뒀으므로, 여기서는 해당 계층의 forward,backward 메서드를 적절한 순서로 호출해줄 뿐이다.
reset_state는 신경망의 상태를 초기화하는 편의 메서드이다.
언어 모델의 평가
언어 모델은 주어진 과거 단어(정보)로부터 다음에 출현할 단어의 확률분포를 출력한다.
이때 언어 모델의 예측 성능을 평가하는 척도로 퍼플렉서티(perplexity,혼란도)를 자주 이용한다.
퍼플렉서티는 간단히 말하면 확률의 역수이다.
you say goodbye and I say hello. 말뭉치를 예르 보자.
모델 1의 언어 모델에 you를 주입하면 왼쪽과 같은 확률분포를 출력했다고 가정하자.
그러면 정답이 say라면, 그 확률은 0.8이다. 이때 퍼플렉서티는 역수, 즉 1/0.8 = 1.25로 계산할 수 있다.
한펀 오른쪽 모델은 정답인 say의 확률이 0.2라고 예측했다. 이때의 퍼플렉서티는 1/0.2 = 5이다.
이를 통해 퍼플렉서티는 작을수록 좋다는 것을 알 수 있다.
그렇다면 1.25나 5라는 값은 직관적으로는 어떻게 해석할 수 있을까?
이 값은 분기수(number of branches)로 해석할 수 있다.
분기 수란 다음에 취할 수 있는 선택사항의 수(구체적으로 말하면, 다음에 출현할 수 있는 단어의 후보 수)를 말한다.
앞의 예에서, 좋은 모델이 예측한 분기수가 1.25라는 것은 다음에 출현할 수 있는 단어의 후보가 1개 정도로 좁혀졌다는 말인데, 나쁜 모델에서는 후보가 5개나 된다는 의미이다.
지금까지는 입력 데이터가 하나일 때의 퍼플렉서티를 이야기했다. 그렇다면 입력 데이터가 여러개일때는 어떻게 될까?
이럴 때는 다음 공식에 따라 계산한다.
N은 데이터의 총 개수이다. tn은 원핫 벡터로 표현된 정답 레이블이며, tnk는 t개째 데이터의 k번째 값을 의미한다.
ynk는 확률 분포를 의미한다.(신경망에서는 Softmax의 출력)
L은 신경망의 손실을 뜻하며, 교차 엔트로피 오차 식과 완전히 같은 식이다.
이 식을 이용해 자연로그로 표현한 값이 곧 퍼플렉서티이다.
다소 복잡해 보이지만, 데이터가 하나일 때 설명한 확률의 역수, 분기 수, 선택사항의 수 같은 개념이 그대로 적용된다.
정보이론 분야에서는 퍼플렉서티를 기하평균 분기 수 라고도 한다. 이는 데이터가 1개일 때 설명한 분기 수를 데이터가 N개인 경우에서 평균한 것이라고 해석할 수 있다.
RNNLM의 학습 코드
PTB 데이터셋을 이용해 학습을 수행해보자.
단, 이번에 구현할 RNNLM은 PTB 데이터셋(훈련 데이터) 전부를 대상으로 학습하면 전혀 좋은 결과를 낼 수 없기 때문에, 처음 1000개 단어만 이용하겠다. 이 문제는 다음 장에서 개선한다.
# -*- coding: utf-8 -*-
"""
Created on Fri Dec 27 15:19:12 2024
Created by SeungKeon Lee
"""
import sys
sys.path.append('..')
import matplotlib.pyplot as plt
import numpy as np
from common.optimizer import SGD
from dataset import ptb
from simple_rnnlm import SimpleRnnlm
# 하이퍼파라미터 설정
batch_size = 10
wordvec_size = 100
hidden_size = 100 # RNN의 은닉 상태 벡터의 원소 수
time_size = 5 # Truncated BPTT가 한 번에 펼치는 시간 크기
lr = 0.1
max_epoch = 100
# 학습 데이터 읽기(전체 중 1000개만)
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_size = 1000
corpus = corpus[:corpus_size]
vocab_size = int(max(corpus) + 1)
xs = corpus[:-1] # 입력
ts = corpus[1:] # 출력(정답 레이블)
data_size = len(xs)
print('말뭉치 크기: %d, 어휘 수: %d' % (corpus_size, vocab_size))
# 학습 시 사용하는 변수
max_iters = data_size // (batch_size * time_size)
time_idx = 0
total_loss = 0
loss_count = 0
ppl_list = []
# 모델 생성
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
# 미니배치의 각 샘플의 읽기 시작 위치를 계산
jump = (corpus_size - 1) // batch_size
offsets = [i * jump for i in range(batch_size)]
for epoch in range(max_epoch):
for iter in range(max_iters):
# 미니배치 취득
batch_x = np.empty((batch_size, time_size), dtype='i')
batch_t = np.empty((batch_size, time_size), dtype='i')
for t in range(time_size):
for i, offset in enumerate(offsets):
batch_x[i, t] = xs[(offset + time_idx) % data_size]
batch_t[i, t] = ts[(offset + time_idx) % data_size]
time_idx += 1
# 기울기를 구하여 매개변수 갱신
loss = model.forward(batch_x, batch_t)
model.backward()
optimizer.update(model.params, model.grads)
total_loss += loss
loss_count += 1
# 에폭마다 퍼플렉서티 평가
ppl = np.exp(total_loss / loss_count)
print('| 에폭 %d | 퍼플렉서티 %.2f'
% (epoch+1, ppl))
ppl_list.append(float(ppl))
total_loss, loss_count = 0, 0
# 그래프 그리기
x = np.arange(len(ppl_list))
plt.plot(x, ppl_list, label='train')
plt.xlabel('epochs')
plt.ylabel('perplexity')
plt.show()
기본적으로는 지금까지 본 신경망 학습과 거의 같은데, 큰 관점에서 두가지가 지금까지 학습 코드와 다르다.
1)데이터 제공 방법과 2)퍼플렉서티 계산 부분이다.
1)데이터 제공 방법은 Truncated BPTT 방식으로 학습을 수행하기 때문에, offsets의 각 원소에 데이터를 읽는 시작 위치(offset)이 담기게 된다.
2)퍼플렉서티 계산은 에폭마다 손실의 평균을 구하고, 그 값을 사용해 퍼플렉서티를 구한다.
실행은 퍼플렉서티가 순조롭게 줄어드는 것을 확인할 수 있다. 300을 넘던 값이 1까지 근접한 것을 볼 수 있다.
다만 이번에는 크기가 작은 말뭉치로 실험한 것이고, 현재의 모델로는 큰 말뭉치에는 전혀 대응할 수 없다.
RNNLM의 Trainer 클래스
이 책에서는 RNNLM 학습을 수행해주는 RnnlmTrainer 클래스를 제공한다. ch05/train.py를 참고하자.
...
from common.trainer import RnnlmTrainer
...
# 모델 생성
model = SimpleRnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)
trainer.fit(xs, ts, max_epoch, batch_size, time_size)
trainer.plot()
코드 일부분을 발췌했다.
RnnlmTrainer 클래스에 model과 optimizer을 주어 초기화한다.
그 다음 fit 메서드를 호출해 학습을 수생한다. 이때 내부에서는 앞 절에서 수행한 과정이 진행된다.
- 미니배치를 순차적으로 만들어
- 모델의 순전파와 역전파를 호출하고
- 옵티마이저로 가중치를 갱신하고
- 퍼플렉서티를 구한다.
정리
- RNN은 순환하는 경로가 있고, 이를 통해 내부의 은닉 상태를 기억할 수 있다.
- RNN의 순환 경로를 펼침으로써 다수의 RNN 계층이 연결된 신경망으로 해석할 수 있으며, 보통의 오차역전파법으로 학습할 수 있다.(=BPTT)
- 긴 시계열 데이터를 학습할 때는 데이터를 적당한 길이씩 모으고(블록), 블록 단위로 BPTT에 의한 학습을 수행한다(=Truncated BPTT).
- Truncated BPTT에서는 역전파의 연결만 끊는다.
- Truncated BPTT에서는 순전파의 연결을 유지하기 위해 데이터를 순차적으로 입력해야 한다.
- 언어 모델은 단어 시퀀스를 확률로 해석한다.
- RNN 계층을 이용한 조건부 언어 모델은 이론적으로는 그때까지 등장한 모든 단어의 정보를 기억할 수 있다.
※dataset 및 코드 제공 - 밑바닥부터 시작하는 딥러닝 GitHub:https://github.com/WegraLee/deep-learning-from-scratch-2scratch/tree/master
GitHub - kchcoo/WegraLee-deep-learning-from-scratch
Contribute to kchcoo/WegraLee-deep-learning-from-scratch development by creating an account on GitHub.
github.com
'공부 > 밑바닥부터 시작하는 딥러닝' 카테고리의 다른 글
밑바닥부터 시작하는 딥러닝2 - Chap7. RNN을 사용한 문장 생성 (2) | 2025.01.03 |
---|---|
밑바닥부터 시작하는 딥러닝2 - Chap6. 게이트가 추가된 RNN (2) | 2025.01.01 |
밑바닥부터 시작하는 딥러닝2 - Chap4. word2vec 속도 개선 (1) | 2024.12.26 |
밑바닥부터 시작하는 딥러닝2 - Chap3. word2vec (3) | 2024.12.23 |
밑바닥부터 시작하는 딥러닝2 - Chap2. 자연어와 단어의 분산 표현 (4) | 2024.12.16 |