RNN
1Recurrent Neural Network¶
Recurrent Neural Network (RNN)은 순차적 데이터를 처리하기 위해 설계된 신경망입니다. RNN은 이전 시점의 은닉 상태를 현재 시점의 입력과 결합하여 다음 은닉 상태를 계산합니다. 이 구조는 시간에 따른 의존성을 모델링할 수 있게 해줍니다.
1.1상태: 순서를 기억하는 방식¶
다층 퍼셉트론은 입력을 각각 독립적으로 처리합니다. 지금 들어온 정보만으로 판단하며 이전에 무엇이 들어왔는지는 고려하지 않으므로, 순서가 의미를 결정하는 데이터에는 적합하지 않습니다. 문장에서 단어의 순서가 바뀌면 뜻이 달라지고, 음성이나 시계열에서도 순서가 흐름을 결정합니다.
RNN은 입력을 처리하면서 그에 대한 반응을 은닉 상태 로 남깁니다. 이 상태는 입력을 그대로 저장하거나 기억하는 것이 아니라, 입력을 바탕으로 형성된 일시적인 해석의 흔적에 가깝습니다. 어떤 설명을 들을 때 문장과 단어를 하나하나 기억하지는 못해도 전체적인 의미는 이해하게 되고, 이후 관련된 내용을 다시 접하면 그 이해를 바탕으로 더 쉽게 받아들이는 것과 같습니다.
이 상태는 시간의 흐름에 따라 매 시점 새롭게 갱신됩니다. 이전 입력이 반영된 상태가 현재 입력과 함께 처리되어 새로운 상태를 만들고, 그 상태는 다시 다음 입력을 해석하는 데 쓰입니다. 이렇게 상태가 시간을 따라 이어지므로 과거와 현재의 관계가 자연스럽게 모델링됩니다. 입력과 상태로부터 나온 출력은 문제에 따라 매 시점 즉시 사용할 수도 있고, 여러 입력이 처리된 뒤 한 번에 사용할 수도 있습니다.
상태를 갱신할 때 와 같은 비선형 활성화 함수를 쓰는 것은 본질적인 요구입니다. RNN의 순환 연산은 은닉층의 역할을 수행하는데, 선형 변환만 반복해서는 아무리 단계를 거듭해도 복잡한 패턴을 표현할 수 없습니다. 는 입력의 크기를 부드럽게 조절하여 출력을 일정한 범위로 유지하므로, 순환 과정에서 신호가 왜곡되거나 폭주하는 것을 막는 역할도 합니다.
import numpy as np
class RNN:
def __init__(self, batch_size, n_steps, n_features, n_outputs):
self.batch_size = batch_size
self.n_steps = n_steps
self.n_features = n_features
self.n_outputs = n_outputs
# 상태 초기화
self.state = np.zeros((batch_size, n_steps, n_outputs))
# 입력에 대한 매개변수
self.W = np.random.randn(n_features, n_outputs)
self.b = np.random.randn(n_outputs)
# 이전 상태를 반영하는 가중치
self.U = np.random.rand(n_outputs, n_outputs)
def __call__(self, X, **kwargs):
return self.forward(X, **kwargs)
def forward(self, X, return_sequence=False):
for t in range(1, self.n_steps):
Z = np.dot(X[:, t], self.W) + self.b
self.state[:, t, :] = np.tanh(Z + np.dot(self.state[:, t-1, :], self.U))
if return_sequence:
return self.state
else:
return self.state[:, -1, :]
batch_size = 3
n_steps = 4
n_features = 5
n_outputs = 6
rnn = RNN(batch_size, n_steps, n_features, n_outputs)
input_sequence = np.random.randn(batch_size, n_steps, n_features)
output = rnn(input_sequence, return_sequence=False)
assert output.shape == (batch_size, n_outputs,)
output = rnn(input_sequence, return_sequence=True)
print(output.shape)
assert output.shape == (batch_size, n_steps, n_outputs)1.2시간 역전파¶
Backpropagation Through Time (BPTT), 또는 시간 역전파는 RNN에서 순차적인 데이터에 대한 손실의 기울기를 계산하는 역전파 알고리즘입니다. 일반적인 신경망은 단일 입력에 대한 역전파를 수행하지만, RNN은 시간축을 따라 여러 시점의 은닉 상태가 연결되어 있기 때문에 시간을 따라 펼친 구조(unrolled network) 에 대해 역전파가 적용됩니다.
역전파를 위해 필요한 국소 미분
이를 활용하여 상태 에 대한 손실 의 기울기를 구할 수 있습니다.
시퀀스 길이를 라고 할 때,
BPTT는 RNN을 시퀀스 축으로 펼쳐(unroll) 일반적인 역전파를 수행하는 것과 동일합니다.
은닉 상태 간의 의존성 때문에 모든 시퀀스 단계()의 기울기를 누적해야 합니다.
RNN의 구조적 특성으로 인해 시간 의존성 있는 시퀀스 데이터를 효과적으로 학습할 수 있습니다.
2PyTorch API¶
import torch
시퀀스길이, 특성수, 출력수 = 4, 5, 6
rnn = torch.nn.RNN(input_size=특성수, hidden_size=출력수, batch_first=True)
input_sequence = torch.randn(3, 시퀀스길이, 특성수)
output, hidden = rnn(input_sequence)
assert output.shape == (3, 시퀀스길이, 출력수)
assert hidden.shape == (1, 3, 출력수) # RNN은 기본적으로 단일 레이어를 사용하므로 hidden의 첫 번째 차원은 1입니다.
# 매개변수 확인
for name, param in rnn.named_parameters():
print(f"{name}: {param.shape}")
Wx = rnn.weight_ih_l0 # 입력에 대한 가중치
Wh = rnn.weight_hh_l0 # 이전 상태에 대한 가중치
b = rnn.bias_ih_l0 + rnn.bias_hh_l0 # 바이어스3Keras API¶
import keras
from keras import layers
시퀀스길이, 특성수, 출력수 = 4, 5, 6
inputs = keras.Input(shape=(시퀀스길이, 특성수))
rnn = layers.SimpleRNN(출력수)
x = rnn(inputs)
print(x.shape)
weights = rnn.get_weights()
W, U, b = weights
print(W.shape, U.shape, b.shape)
rnn = layers.SimpleRNN(출력수, return_sequences=True)
x = rnn(inputs)
print(x.shape)
weights = rnn.get_weights()
W, U, b = weights
print(W.shape, U.shape, b.shape)
outputs = layers.Dense(1)(x)
print(outputs.shape)4예시 데이터¶
import numpy as np
import matplotlib.pyplot as plt
def generate_spiral_data(n_points, noise=0.5):
"""
MLP와 RNN의 차이를 보여주기 위한 2차원 나선형 데이터를 생성합니다.
Args:
n_points (int): 각 나선(클래스)당 생성할 데이터 포인트의 수
noise (float): 데이터에 추가할 노이즈의 강도
Returns:
tuple: (데이터, 레이블) 튜플.
데이터는 (2 * n_points, 2) 형태의 NumPy 배열.
레이블은 (2 * n_points,) 형태의 NumPy 배열.
"""
# 클래스 0: 바깥으로 퍼져나가는 나선
theta_out = np.sqrt(np.linspace(0, 4 * np.pi, n_points)) * 2
r_out = theta_out + np.pi
x_out = r_out * np.cos(theta_out) + np.random.randn(n_points) * noise
y_out = r_out * np.sin(theta_out) + np.random.randn(n_points) * noise
class_0 = np.vstack([x_out, y_out]).T
# 클래스 1: 안으로 수렴하는 나선
theta_in = np.sqrt(np.linspace(0, 4 * np.pi, n_points)) * 2
r_in = -theta_in - np.pi
x_in = r_in * np.cos(theta_in) + np.random.randn(n_points) * noise
y_in = r_in * np.sin(theta_in) + np.random.randn(n_points) * noise
class_1 = np.vstack([x_in, y_in]).T
# 데이터와 레이블 합치기
# 순서를 유지하기 위해 각 클래스의 데이터를 번갈아 가며 합칩니다.
# 이는 시각화의 편의를 위함이며, 실제 RNN에 입력할 때는
# (n_points, sequence_length, 2) 형태로 데이터를 재구성해야 합니다.
X = np.concatenate([class_0, class_1], axis=0)
labels = np.array([0] * n_points + [1] * n_points)
return X, labels
# --- 데이터 생성 및 시각화 ---
# 1. 데이터 생성
N_POINTS_PER_CLASS = 200
NOISE = 0.8
X, y = generate_spiral_data(N_POINTS_PER_CLASS, NOISE)
# 2. 데이터 시각화
plt.figure(figsize=(8, 8))
plt.scatter(X[y==0, 0], X[y==0, 1], c='blue', label='Class 0 (Outward)', s=15, alpha=0.7)
plt.scatter(X[y==1, 0], X[y==1, 1], c='red', label='Class 1 (Inward)', s=15, alpha=0.7)
# 각 나선의 시작점과 끝점을 표시하여 방향성을 명확히 보여줌
# 클래스 0 (파란색) 시작점
plt.plot(X[0, 0], X[0, 1], 'o', c='lime', markersize=10, label='Start of Class 0')
# 클래스 0 (파란색) 끝점
plt.plot(X[N_POINTS_PER_CLASS-1, 0], X[N_POINTS_PER_CLASS-1, 1], 'X', c='lime', markersize=12, label='End of Class 0')
# 클래스 1 (빨간색) 시작점
plt.plot(X[N_POINTS_PER_CLASS, 0], X[N_POINTS_PER_CLASS, 1], 'o', c='yellow', markersize=10, label='Start of Class 1')
# 클래스 1 (빨간색) 끝점
plt.plot(X[-1, 0], X[-1, 1], 'X', c='yellow', markersize=12, label='End of Class 1')
plt.title('2D Spiral Data for Comparing MLP and RNN')
plt.xlabel('X coordinate')
plt.ylabel('Y coordinate')
plt.legend()
plt.grid(True)
plt.axis('equal')
plt.show()4.1시퀀스 데이터¶
시퀀스배치생성 = lambda data, 시퀀스길이: np.array([data[i:i + 시퀀스길이] for i in range(0, len(data) - 시퀀스길이 + 1)])
print(X.shape, y.shape)
# 목표값에 따른 시퀀스 데이터 생성
시퀀스길이 = 10
X0 = 시퀀스배치생성(X[y==0], 시퀀스길이)
X1 = 시퀀스배치생성(X[y==1], 시퀀스길이)
X_seq = np.concatenate([X0, X1], axis=0)
print(X0.shape, '+', X1.shape, '=', X_seq.shape)
# 레이블 생성
y0 = np.zeros(len(X0), dtype=int)
y1 = np.ones(len(X1), dtype=int)
y_seq = np.concatenate([y0, y1], axis=0)
print(y0.shape, '+', y1.shape, '=', y_seq.shape)4.2MLP 모델¶
keras.backend.clear_session()
mlp_model = keras.Sequential([
keras.Input(shape=(시퀀스길이, 2)),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(1, activation='sigmoid')
])
mlp_model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
history = mlp_model.fit(X_seq, y_seq, epochs=10, batch_size=8, validation_split=0.2)4.3RNN 모델¶
keras.backend.clear_session()
rnn_model = keras.Sequential([
keras.Input(shape=(시퀀스길이, 2)),
layers.SimpleRNN(64, activation='tanh'),
layers.Dense(1, activation='sigmoid')
])
rnn_model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
history = rnn_model.fit(X_seq, y_seq, epochs=10, batch_size=8, validation_split=0.2)시퀀스길이, 특성수, 출력수 = 4, 5, 6
keras.backend.clear_session()
rnn_model = keras.Sequential([
keras.Input(shape=(시퀀스길이, 특성수)),
layers.SimpleRNN(출력수, activation='tanh', name='rnn'),
])
rnn_model.summary()
params = rnn_model.get_layer('rnn').get_weights()
print("RNN Layer Weights:")
for i, param in enumerate(params):
print(f"Param {i}: shape {param.shape}")
Wx, Wh, b = params
X = np.random.randn(1, 시퀀스길이, 특성수)
outputs = rnn_model.predict(X)
print("RNN Output Shape:", outputs.shape)5경사 소실/폭발 문제¶
손실 함수 에 대해, 시퀀스 길이 일 때 에 대한 기울기는 다음과 같은 연쇄법칙(chain rule)로 전개됩니다:
RNN의 은닉 상태는 다음과 같이 정의됩니다:
이때 , , 입니다.
손실 함수 에 대해 에 대한 기울기를 구하려면 다음과 같은 야코비안(Jacobian)을 계산해야 합니다:
연쇄법칙에 따라, 다음과 같이 표현할 수 있습니다:
각 항은 다음과 같습니다:
비선형 함수 의 도함수는 요소별로 적용되므로, 다음과 같은 대각 행렬이 됩니다:
의 에 대한 미분은 선형이므로:
결과적으로, 다음과 같이 표현됩니다:
이는 RNN에서 시간 축을 따라 기울기가 어떻게 전달되는지를 나타냅니다.
따라서 전체 기울기는 다음과 같이 표현됩니다:
이때, 다음이 성립합니다:
그러므로 시간 차 가 커질수록:
이라면, 다음과 같이 기울기가 지수적으로 감소합니다:
이러한 현상을 기울기 소실 (Vanishing Gradient) 라고 합니다.
이고 각 항이 충분히 크다면:
따라서 시간 간격 가 커질수록 기울기의 크기가 지수적으로 커지게 되며, 이를 폭발하는 경사 (Exploding Gradient) 라고 합니다.
5.1직관과 이후의 구조¶
역전파는 마지막 시점의 손실에서 시작하여 이전 시점들로 신호를 차례차례 거슬러 전달합니다. 이 과정에서 각 시점의 기울기에는 활성화 함수의 도함수가 반복해서 곱해지는데, 는 대부분 0과 1 사이의 작은 값입니다. 작은 수를 계속 곱하면 전체 값은 빠르게 0에 가까워집니다. 0.5를 여러 번 곱하면 금세 0에 수렴하듯, 기울기도 점점 줄어 결국 앞 시점의 입력이 손실에 어떤 영향을 주었는지 학습할 수 없게 됩니다. 이것이 기울기 소실입니다.
반대로 곱해지는 항이 1보다 크면 결과는 기하급수적으로 커집니다. 기울기가 지나치게 커지면 매개변수 갱신이 과도해져 학습이 불안정해지고, 심하면 발산하거나 수치적 오류가 발생합니다. 이것이 기울기 폭발입니다.
두 문제는 기본 RNN이 구조만으로는 긴 시퀀스를 안정적으로 학습하기 어렵다는 것을 보여줍니다. 실제로 몇 단계 이전까지만 정보를 유지하고 더 오래된 과거는 거의 반영하지 못하는 경우가 많습니다. 이러한 한계를 극복하기 위해 이후 LSTM, GRU와 같은 구조가 등장하게 됩니다.
6RNN 인코더-디코더¶
2014년에 제안된 RNN 인코더-디코더는 기계 번역에 새로운 돌파구를 마련했습니다. 기본 구상은 두 개의 RNN을 사용하는 것입니다. 먼저 인코더 RNN이 입력 문장의 형태소를 순서대로 처리합니다. 문장에서 형태소의 순서가 중요하므로 이는 곧 시퀀스이며, 인코더의 마지막 은닉 상태는 전체 문장에 대한 요약으로 볼 수 있습니다.
두 번째 RNN은 출력 문장을 생성하는 디코더입니다. 디코더도 RNN이므로 이전 은닉 상태를 입력으로 받는 점은 인코더와 같지만, 출력 형태소를 순차적으로 예측하면서 앞서 예측한 형태소가 다시 현재 시점의 입력이 된다는 점이 다릅니다. 또한 입력 문장의 요약인 인코더의 최종 은닉 상태가 디코더에 더해져, 입력 문장과 출력 문장의 관계를 매개합니다. 입력 문장의 길이 와 출력 문장의 길이 는 서로 다를 수 있습니다.
번역은 단어를 일대일로 변환하는 작업이 아닙니다. 표현된 문장은 전하려는 개념을 대표하며, 그 개념에 대응하는 적합한 목표 언어의 표현을 찾는 것이 번역입니다. 예를 들어 "안녕하세요"는 상황에 맞는 인사말이라는 개념으로 요약되며, 그에 대응하는 표현은 "hi"나 “how are you” 등 여러 가지가 될 수 있습니다. 단어나 문장 수준에서 일대일로 대응하기는 어렵고, 개념을 기준으로 대응을 찾는 것이 바람직합니다. RNN 인코더-디코더는 이러한 통찰을 구조에 반영한 모델입니다.
6.1RNN 언어 모델¶
문장의 형태소는 순차적이므로, 이전 단어들의 맥락이 주어졌을 때 다음 단어가 나타날 조건부 확률로 언어 모델을 정의할 수 있고, 이를 RNN으로 훈련할 수 있습니다. 다음 단어의 예측은 RNN의 은닉 상태에 단어 임베딩 가중치를 곱한 뒤, 전체 어휘 에 대한 확률 분포가 되도록 소프트맥스로 처리하여 출력합니다.
6.2인코더-디코더의 훈련¶
RNN 인코더-디코더는 훈련 데이터에서 입력 문장 가 주어졌을 때 목표 문장 가 나타날 확률을 극대화하는 방식으로 학습합니다. 이는 최대우도추정(maximum likelihood estimation)에 기반합니다. 목표 문장의 각 형태소는 전체 어휘에 대한 확률 분포로부터 추론되는데, 이 확률은 0과 1 사이의 작은 값이므로 전체 표본에 대한 우도의 곱은 매우 작아집니다. 우도에 로그를 취하면 곱이 합으로 바뀌어, 최대우도를 수치적으로 더 안정적이고 효과적으로 측정할 수 있습니다.