6.1 RNN의 문제점
6.1.1 RNN 복습
6.1.2 기울기 소실 또는 기울기 폭발
- 기울기 소실 : 시간을 거슬러 올라갈수록 기울기가 작아짐
- 기울기 폭발 : 시간을 거슬러 올라갈수록 기울기가 커짐
6.1.3 기울기 소실과 기울기 폭발의 원인
역전파에서 차례로 tanh -> + -> MatMul 연산 통과
- tanh
- 미분값 그래프 : 1.0이하, x가 0으로부터 멀어질수록 작아짐
=> 역전파에서 기울기가 tanh 노드를 지날 때마다 값이 계속 작아짐
- MatMul
- 시계열 데이터의 시간 크기만큼 매번 똑같은 Wh가중치가 행렬 곱에 사용됨
N = 2 #미니배치 크기
H = 3 #은닉 상태 벡터의 차원 수
T = 20 #시계열 데이터의 길이
dh = np.ones((N, H))
np.random.seed(3) #재현할 수 있도록 난수의 시드 고정
Wh = np.random.randn(H, H)
norm_list = []
for t in range(T): #dh 갱신
dh = np.matmul(dh, Wh.T)
norm = np.sqrt(np.sum(dh**2)) / N #미니배치의 평균 L2노름
norm_list.append(norm) #각 단계에서 dh크기(노름) 추가
print(norm_list)
# 그래프 그리기
plt.plot(np.arange(len(norm_list)), norm_list)
plt.xticks([0, 4, 9, 14, 19], [1, 5, 10, 15, 20])
plt.xlabel('time step')
plt.ylabel('norm')
plt.show()
-> 기울기의 크기는 시간에 비례해 지수적으로 증가 => 기울기 폭발 (exploding gradients)
+) 난수 고정 - seed() : https://antilibrary.org/2481
#Wh = np.random.randn(H, H) 변경 전
Wh = np.random.randn(H, H) * 0.5 #변경 후
- Wh 초깃값 변경
-> 기울기 소실 (vanishing gradients)
=> 기울기가 일정 수준 이하로 작아지면 가중치 매개변수가 더 이상 갱신되지 않음, 장기 의존 관계 학습 불가능
- 이유
- Wh를 T번 반복해서 곱하기 때문
- Wh가 행렬이라면 행렬의 특잇값이 척도가 되어 1보다 크면 증가, 작으면 감소
- 특잇값 : 데이터가 얼마나 퍼져 있는지 나타냄
- 특잇값 중 최댓값이 1보다 큰지 여부
6.1.4 기울기 폭발 대책
- 기울기 클리핑 (gradients clipping)
- g^ : 모든 매개변수의 기울기를 하나로 모은 것
- threshold : 문턱값
- 기울기의 L2노름 (||g^||)이 문턱값을 초과하면 기울기 수정
##기울기 클리핑
dW1 = np.random.rand(3,3)*10
dW2 = np.random.rand(3,3)*10
grads = [dW1, dW2] #기울기의 리스트
max_norm = 5.0 #문턱값
def clip_grads(grads, max_norm):
total_norm = 0
for grad in grads:
total_norm += np.sum(grad**2)
total_norm = np.sqrt(total_norm)
rate = max_norm /(total_norm +1e-6)
if rate <1:
for grad in grads:
grad *= rate
6.2 기울기 소실과 LSTM
6.2.1 LSTM의 인터페이스
-> 계산 그래프 단순화
- c 경로가 있다는 차이
- c :기억 셀 (memory cell) , LSTM 전용의 기억 메너키즘
- 데이터를 자기자신으로만 주고받음
- LSTM 계층 내에서만 완결되고, 다른 계층으로는 출력하지 않음
- 반면 은닉 상태 h는 RNN계층과 마찬가지로 다른 계층 (위쪽)으로 출력됨
6.2.2 LSTM 계층 조립하기
- 기억 셀 ct : 시각 t에서의 LSTM의 기억이 저장, 과거로부터 시각 t까지에 필요한 모든 정보가 저장
- ct를 바탕으로 은닉 상태 ht를 출력
- ht는 기억셀 ct를 tanh함수로 변환한 값
- ht = tanh(ct)
- ct의 각 요소에 tanh함수를 적용
- ct와 ht의 원소 수는 같음
- 게이트 : 데이터의 흐름 제어, 열기/닫기 뿐 아니라 어느 정도 열지도 조절
- 게이트의 열림 상태 - 0.0~1.0 사이 실수로 나타냄 -> 시그모이드 함수 이용
- 게이트를 얼마나 열지도 데이터로부터 자동으로 학습함
6.2.3 output 게이트
: tanh(ct), 즉 ht의 각 원소에 대해 그것이 다음 시각의 은닉 상태에 얼마나 중요한가를 조정
다음 은닉 상태 ht의 출력을 담당하는 게이트
- 입력 xt와 이전상태 ht-1로부터 구함
- 가중치 매개변수와 편향에 output의 첫 글자인 o를 첨자로 추가
- 시그모이드 함수 σ
- 출력 게이트의 출력 o
- ht는 o와 tanh(ct)의 아다마르 곱으로 계산됨
- 아다마르 곱 ⊙ : 원소별 곱 Hadamard product
- tanh의 출력 : -1.0~1.0의 실수 -> 인코딩된 정보의 강약을 표시
- 게이트에서는 시그모이드 함수, 실질적인 정보를 지니는 데이터에는 tanh함수를 활성화 함수로 사용
6.2.4 forget 게이트
: 기억 셀에 무엇을 잊을지 명확하게 지시하는 게이트
ct-1의 기억 중에 불필요한 기억을 잊게 해줌
- forget 게이트 전용 가중치 매개변수 W(f)
- forget게이트의 출력 f
- Ct = f⊙Ct-1
6.2.5 새로운 기억 셀
: 새로 기억해야 할 정보를 기억셀에 추가 -> tanh 노드 추가
- tanh노드가 계산한 결과 g가 이전 시각의 기억 셀 Ct-1에 더해짐
- 기억 셀에 새로운 정보가 추가된 것
- tanh노드는 게이트가 아니며 새로운 정보를 기억 셀에 추가하는 것이 목적이기 때문에 활성화 함수로 tanh 사용
6.2.6 input 게이트
: g의 각 원소가 새로 추가되는 정보로써 가치가 얼마나 큰지 판단
새 정보를 모두 수용하는 것이 아니라, 적절히 취사선택하는 것이 역할
input 게이트에 의해 가중된 정보가 새로 추가되는 것
- input게이트의 출력 i와 g의 원소별 곱 결과를 기억 셀에 추가
6.2.7 LSTM의 기울기 흐름
- 기억 셀의 역전파는 +와 x노드를 지남
- x노드는 행렬 곱이 아닌 원소별 곱(아마다르 곱)을 계산
- 똑같은 가중치 행렬을 사용하여 행렬 곱을 반복했던 RNN의 역전파와 달리 원소별 곱이 이뤄짐
- 매 시각 다른 게이트 값을 이용해 원소별 곱을 계산하기 때문에 곱셈의 효과 누적 x
-> 기울기 소실 일어나기 어려움 - x노드 계산은 forget 게이트가 제어
- 잊어야한다고 판단한 기억 셀의 원소에 대해서는 기울기가 작아짐
- 잊어서 안되는 기억 셀 원소에 대해서는 기울기가 약화되지 않은 채로 과거 방향으로 전해짐
- LSTM : Long Short -Term Memory
6.3 LSTM 구현
- LSTM에서 수행하는 계산 수식들
- 아핀 변환 (affine transformation) : 행렬 변환과 평행 이동(편향)을 결합한 형태
- xW +hW +b
- 하나의 식으로 정리해 계산
- 4개의 가중치와 편향을 하나로 모음
- 4번을 수행하던 아핀 변환을 단 1회의 계산으로 -> 계산 속도 빨라짐
(행렬 라이브러리는 큰 행렬을 한꺼번에 계산할 때가 각각을 따로 계산할 때보다 빠르기 때문)
- 처음 4개분의 아핀 변환을 한꺼번에 수행
- slice노드를 통해 4개의 결과 꺼냄
- slice 노드 : 아핀 변환의 결과(행렬)을 균등하게 네조각으로 나눠서 꺼내주는 노드
- RNN과 같이 Wx, Wh, b 세개의 매개변수만 관리하면 되지만 그 형상은 다름 (D*4H, H*4H)
- np.hstach() 메서드 : 인수로 주어진 배열들을 가로로 연결
- vstach() : 세로로 연결
- dA = np.hstack((df, dg, di, do))
class LSTM:
def __init__(self, Wx, Wh, b):
self.params = [Wx, Wh, b] #4개분의 가중치
self.grads = [np.zeros_like(Wx), np.zeros_like(Wh), np.zeros_like(b)]
self.cache = None #순전파 결과 보관했다가 역전파 계산에 사용하려는 용도
def forward(self, x, h_prev, c_prev):
Wx, Wh, b = self.params
N, H = h_prev.shape
A = np.matmul(x, Wx) +np.matmul(h_prev, Wh) +b #아핀변환
#slice
f = A[:, :H]
g = A[:, H:2*H]
i = A[:, 2*H:3*H]
o = A[:, 3*H:]
f = sigmoid(f)
g = np.tanh(g)
i = sigmoid(i)
o = sigmoid(o)
c_next = f*c_prev +g*i
h_next = o*np.tanh(c_next)
self.cache = (x, h_prev, c_prev, i, f, g, o, c_next)
return h_next, c_next
def backward(self, dh_next, dc_next):
Wx, Wh, b = self.params
x, h_prev, c_prev, i, f, g, o, c_next = self.cache
tanh_c_next = np.tanh(c_next)
ds = dc_next + (dh_next * o) * (1 - tanh_c_next ** 2)
dc_prev = ds * f
di = ds * g
df = ds * c_prev
do = dh_next * tanh_c_next
dg = ds * i
di *= i * (1 - i)
df *= f * (1 - f)
do *= o * (1 - o)
dg *= (1 - g ** 2)
dA = np.hstack((df, dg, di, do))
dWh = np.dot(h_prev.T, dA)
dWx = np.dot(x.T, dA)
db = dA.sum(axis=0)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
dx = np.dot(dA, Wx.T)
dh_prev = np.dot(dA, Wh.T)
return dx, dh_prev, dc_prev
6.3.1 Time LSTM 구현
: T개분의 시계열 데이터를 한꺼번에 처리하는 계층
- Truncated BPTT 수행 - 은닉 상태와 기억 셀 인스턴스 변수로 유지
-> 다음번에 forward()가 불렸을 때 이전 시각의 은닉상태와 기억 셀에서부터 시작 가능
class TimeLSTM:
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.layer = None
self.h, self.c = None, None
self.dh = None
self.stateful = stateful
def forward(self,xs):
Wx, Wh, b = self.params
N, T, D = xs.shape
H = Wh.shape[0]
self.layers = []
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')
if not self.stateful or self.c is None:
self.c = np.zeros((N, H), dtype = 'f')
for t in range(T):
layer = LSTM(*self.params)
self.h, self.c = layer.forward(xs[:, t, :], self.h, self.c)
hs[:,t, :] = self.h
self.layers.append(layer)
return hs
def backward(self,dhs):
Wx, Wh, b = self.params
N, T, H = dhs.shape
D = Wx.shape[0]
dxs = np.empty((N, T, D), dtype='f')
dh, dc = 0,0
grads = [0,0,0]
for t in reversed(range(T)):
layer = self.layers[t]
dx, dh, dc = layer.backward(dhs[:, t, :]+dh, dc)
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
def set_state(self, h, c = None):
self.h, self.c = h, c
def reset_state(self):
self.h, self.c = None, None
-> TimeRNN과 흡사
6.4 LSTM을 사용한 언어 모델
- 5장에서 구현한 언어모델에서 Time RNN-> Time LSTM으로 변경
class Rnnlm:
def __init__(self, vocab_size=10000, wordvec_size=100, hidden_size=100):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
#가중치 초기화
embed_W = (rn(V, D)/100).astype('f')
lstm_Wx = (rn(D, 4*H)/ np.sqrt(D)).astype('f') #H4개, Xavier초깃값 이용
lstm_Wh = (rn(H, 4*H)/ np.sqrt(H)).astype('f')
lstm_b = np.zeros(4*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),
TimeLSTM(lstm_Wx, lstm_Wh, lstm_b, stateful=True),
TimeAffine(affine_W, affine_b)
]
self.loss_layer = TimeSoftmaxWithLoss()
self.lstm_layer = self.layers[1]
#모든 가중치와 기울기를 리스트에 모은다.
self.params, self.grads = [], []
for layer in self.layers:
self.params +=layer.params
self.grads += layer.grads
def predict(self, xs): #문장생성에 사용하기 위해 추가한 메서드
for layer in self.layers:
xs = layer.forward(xs)
return xs
def forward (self, xs, ts):
score = self.predict(xs)
loss = self.loss_layer.forward(score, 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.lstm_layer.reset_state()
def save_params(self, file_name = 'Rnnlm.pkl'): #매개변수 쓰기 처리
with open(file_name, 'wb') as f:
pickle.dump(self.params, f)
def load_params(self, file_name='Rnnlm.pkl'): #매개변수 읽기 처리
with open(file_name, 'rb') as f:
self.params = pickle.load(f)
- PTB 데이터셋 학습
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity
from dataset import ptb
#하이퍼파라미터 설정
batch_size = 20
wordvec_size = 100
hidden_size = 100 #RNN의 은닉 상태 벡터의 원소 수
time_size = 35 #RNN을 펼치는 크기
lr = 20.0
max_epoch = 4
max_grad = 0.25
#학습데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_test, _, _ = ptb.load_data('test')
vocab_size = len(word_to_id)
xs = corpus[:-1] #입력
ts = corpus[1:] #출력 (정답 레이블)
#모델 생성
model = Rnnlm(vocab_size, wordvec_size, hidden_size)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)
#1. 기울기 클리핑을 적용하여 학습
trainer.fit(xs, ts, max_epoch, batch_size, time_size, max_grad, eval_interval =20)
#max_grad : 기울기 클리핑 벅용, eval_interval : 20번째 반복마다 퍼플렉서티 평가
trainer.plot(ylim=(0,500))
#2. 테스트 데이터로 평가
model.reset_state() #모델 상태를 재설정하여 평가 수행
ppl_test = eval_perplexity(model, corpus_test)
print('테스트 퍼플렉서티: ', ppl_test)
#3. 매개변수 저장
model.save_params() #학습이 완료된 매개변수들을 파일로 저장
- 퍼플렉서티 값이 9998.63에서 100정도로 낮아짐
책 (10000.84 -> 100정도) - 최종 평가의 퍼플렉서티 값 :135.33750373270138
책 (136.07) - 다음에 나올 단어의 후보를 136개 정도로 줄일 때까지 개선된 것
-> 그렇게 좋은 결과는 아님, 다음 절에서 개선
6.5 RNNLM 추가 개선
6.5.1 LSTM 계층 다층화
- LSTM 계층을 깊게 쌓아 정확도 개선
-> 더 복잡한 패턴 학습
- 몇 층 쌓을지는 하이퍼파라미터
- 처리할 문제의 복잡도나 준비된 학습 데이터의 양에 따라 적절하게 결정
- PTB 데이터셋 - 2~4층일 때 좋은 결과
6.5.2 드롭아웃에 의한 과적합 억제
- RNN은 일반적인 피드포워드 신경망보다 쉽게 과적합
- 과적합 : 훈련 데이터에만 너무 치중해 학습된 상태
-> 훈련 데이터 양 늘리기
-> 모델의 복잡도 줄이기 (정규화)
- 정규화 방법 중 하나 : 드롭아웃 dropout - 훈련 시 계층 내의 뉴런 몇 개를 무작위로 무시하고 학습
- 무작위로 뉴런을 선택하여 앞 계층으로부터의 신호 전달을 막음
- 이러한 제약으로 신경망의 일반화 성능 개선
- 시간 방향(좌우 방향)으로 드롭아웃을 넣으면 시간의 흐름에 따라 정보가 사라질 수 있음
-> 드롭아웃이 시간축과는 독립적으로 깊이 방향 (상하 방향)에만 영향
- 최근 연구에서는 다양한 방법 제안되고 있음
- 변형 드롭아웃 : 시간방향으로 드롭아웃 적용
- 언어 모델의 정확도 한층 더 향상
- 같은 계층에 속한 드롭아웃들은 같은 마스크 mask 공유
- 마스크 : 데이터의 통과/차단을 결정하는 이진 형태의 무작위 패턴
- 같은 계층 마스크 고정 -> 정보 잃는 방법도 고정 -> 정보가 지수적으로 손실되는 사태 피할 수 있음
6.5.3 가중치 공유
- Embedding계층의 가중치 : V x H
(어휘수 *은닉상태의 차원 수) - Affine 계층의 가중치 : H x V
- Embedding 계층의 가중치를 전치하여 Affine 계층의 가중치로 설정
- 매개변수 수 줄일 수 있음
-> 학습하기 더 쉬워짐
-> 과적합 억제
6.5.4 개선된 RNNLN 구현
세가지 개선점
- LSTM 계층의 다층화 (2층)
- 드롭아웃 사용 (깊이 방향으로만 적용)
- 가중치 공유(Embedding, Affine)
from common.time_layers import *
from common.np import *
from common.base_model import BaseModel
class BetterRnnlm(BaseModel):
def __init__(self, vocab_size=10000, wordvec_size=650, hidden_size=650, dropout_ratio = 0.5):
V, D, H = vocab_size, wordvec_size, hidden_size
rn = np.random.randn
#가중치 초기화
embed_W = (rn(V, D)/100).astype('f')
lstm_Wx1 = (rn(D, 4*H)/ np.sqrt(D)).astype('f')
lstm_Wh1 = (rn(H, 4*H)/ np.sqrt(H)).astype('f')
lstm_b1 = np.zeros(4*H).astype('f')
lstm_Wx2 = (rn(H, 4*H)/ np.sqrt(H)).astype('f') #lstm계층 2개
lstm_Wh2 = (rn(H, 4*H)/ np.sqrt(H)).astype('f')
lstm_b2 = np.zeros(4*H).astype('f')
affine_b = np.zeros(V).astype('f')
#계층 생성 #세 가지 개선
self.layers = [
TimeEmbedding(embed_W),
TimeDropout(dropout_ratio),
TimeLSTM(lstm_Wx1, lstm_Wh1, lstm_b1, stateful=True),
TimeDropout(dropout_ratio),
TimeLSTM(lstm_Wx2, lstm_Wh2, lstm_b2, stateful=True),
TimeDropout(dropout_ratio),
TimeAffine(embed_W.T, affine_b) #가중치 공유
]
self.loss_layer = TimeSoftmaxWithLoss()
self.lstm_layers = [self.layers[2], self.layers[4]]
self.drop_layers = [self.layers[1], self.layers[3], self.layers[5]]
#모든 가중치와 기울기를 리스트에 모은다.
self.params, self.grads = [], []
for layer in self.layers:
self.params +=layer.params
self.grads += layer.grads
def predict(self, xs, train_flg=False): #문장생성에 사용하기 위해 추가한 메서드
for layer in self.drop_layers:
layer.train_flg = train_flg
for layer in self.layers:
xs = layer.forward(xs)
return xs
def forward (self, xs, ts, train_flg=True):
score = self.predict(xs, train_flg)
loss = self.loss_layer.forward(score, 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):
for layer in self.lstm_layers:
layer.reset_state()
- 개선된 모델로 학습 진행
from common.optimizer import SGD
from common.trainer import RnnlmTrainer
from common.util import eval_perplexity
from dataset import ptb
#하이퍼파라미터 설정
batch_size = 20
wordvec_size = 650
hidden_size = 650 #RNN의 은닉 상태 벡터의 원소 수
time_size = 35 #RNN을 펼치는 크기
lr = 20.0
max_epoch = 4
max_grad = 0.25
dropout = 0.5
#학습데이터 읽기
corpus, word_to_id, id_to_word = ptb.load_data('train')
corpus_val, _, _ = ptb.load_data('val')
corpus_test, _, _ = ptb.load_data('test')
vocab_size = len(word_to_id)
xs = corpus[:-1] #입력
ts = corpus[1:] #출력 (정답 레이블)
#모델 생성
model = BetterRnnlm(vocab_size, wordvec_size, hidden_size, dropout)
optimizer = SGD(lr)
trainer = RnnlmTrainer(model, optimizer)
best_ppl = float('inf')
for epoch in range(max_epoch):
trainer.fit(xs, ts, max_epoch=1, batch_size=batch_size, time_size=time_size, max_grad=max_grad)
model.reset_state() #모델 상태를 재설정하여 평가 수행
ppl = eval_perplexity(model, corpus_val)
print('검증 퍼플렉서티: ', ppl) #매 에폭마다 검증 데이터로 퍼플렉서티 평가
if best_ppl > ppl:
best_ppl = ppl
model.save_params()
else: #기존퍼플렉서티보다 나빠졌을 경우에만 학습률 낮춤
lr /=4.0 #학습률 1/4로 줄임
optimizer.lr = lr
model.reset_state()
print('-'*50)
# 테스트 데이터로 평가
model.reset_state()
ppl_test = eval_perplexity(model, corpus_test)
print('테스트 퍼플렉서티: ', ppl_test)
- 책) 최종 퍼플렉서티 75.76 -epoch 40번
- 개선전 136에 비하면 상당히 개선
내 결과) 109.48257447859318 - epoch 4번만 진행
135.33 -> 109.48 (개선 후)
6.5.5 첨단 연구로
- PTB 데이터셋에 대한 각 모델의 퍼플렉서티 결과
- 최저 52.8
- 구현한 모델과 공통점 많음
- 다층 LSTM
- 드롭아웃 기반의 정규화
- 가중치 공유
- 한층 더 세련된 최적화나 정규화 기법 적용
- 하이퍼파라미터 튜닝 정밀하게
6.6 정리
- 단순한 RNN의 학습에서는 기울기 소실과 기울기 폭발이 문제가 된다.
- 기울기 폭발에는 기울기 클리핑, 기울기 소실에는 게이트가 추가된 RNN이 효과적이다.
- LSTM에는 input게이트, forget게이트, output게이트 등 3개의 게이트가 있다.
- 게이트에는 전용 가중치가 있으며, 시그모이드 함수를 사용하여 0.0~1.0 사이의 실수를 출력한다.
- 언어 모델 개선에는 LSTM계층 다층화, 드롭아웃, 가중치 공유 등의 기법이 효과적이다.
- RNN의 정규화는 중요한 주제이며, 드롭아웃 기반의 다양한 기법이 제안되고 있다.
책 참고 : 밑바닥부터 시작하는 딥러닝 (한빛미디어)
'딥러닝 > 밑바닥부터 시작하는 딥러닝 2' 카테고리의 다른 글
8장 : 어텐션 (1) | 2023.09.28 |
---|---|
7장 : RNN을 사용한 문장 생성 (2) | 2023.09.27 |
5장 : 순환 신경망(RNN) (0) | 2023.09.26 |
4장 : word2vec 속도 개선 (0) | 2023.09.23 |
3장 : word2vec (0) | 2023.09.21 |