순환 신경망과 시퀀스
시퀀스 데이터는 입력의 순서에 따라 의미가 달라지는 데이터입니다. 문장에서 단어의 순서가 바뀌면 뜻이 달라지고, 음악이나 시계열에서도 순서가 흐름을 결정합니다. 이런 데이터를 다루려면 순서와 흐름을 함께 고려할 수 있는 구조가 필요합니다. Part 1에서 다룬 다층 퍼셉트론이나 합성곱 신경망은 입력을 각각 독립적으로 처리합니다. 지금 들어온 정보만 보고 판단하며, 이전에 무엇이 들어왔는지는 고려하지 않습니다. 따라서 순서가 중요한 데이터에는 적절하지 않습니다.
이 장에서 다루는 순환 신경망(Recurrent Neural Network, RNN) 은 입력이 시간적으로 이어지는 상황에서 과거의 영향을 현재 처리에 반영할 수 있도록 설계된 신경망입니다. 텍스트 분류에서 출발해 단어 임베딩을 들여다보고, 함수형 API로 비순차 계산 그래프를 구성하는 법을 거쳐, 이 장의 핵심인 RNN 인코더-디코더 기반 기계 번역까지 쌓아 올립니다.
1순환 구조와 상태¶
RNN은 입력을 처리할 때 그에 대한 반응을 내부의 상태(state) 라는 형태로 남겨 둡니다. 이 상태는 입력을 그대로 저장하거나 기억하는 것이 아닙니다. 오히려 그 입력을 바탕으로 형성된 일시적인 이해, 또는 해석의 흔적에 가깝습니다. 어떤 설명을 들을 때 우리는 등장한 문장과 단어를 하나하나 정확히 기억하지는 못하지만 전체적인 의미나 취지는 이해하게 됩니다. 이후 관련된 내용을 다시 접하면 그때 형성된 이해를 바탕으로 더 쉽게 받아들입니다. RNN에서 말하는 상태란 바로 이런 이해에 해당합니다.
이 상태는 시간의 흐름에 따라 매 순간 새롭게 갱신됩니다. 이전 입력이 반영된 상태는 현재 입력과 함께 처리되어 새로운 상태를 만들고, 이 새로운 상태는 다시 다음 입력을 해석하는 데 활용됩니다.
상태가 시간에 따라 이어지며 구성되기 때문에 RNN은 과거와 현재 사이의 관계를 자연스럽게 모델링할 수 있습니다. 입력과 상태를 통해 나온 출력은 문제의 종류에 따라 각 시점마다 즉시 사용할 수도 있고, 여러 입력이 처리된 후 한 번에 사용할 수도 있습니다. 예를 들어 문장을 번역할 때는 전체 문장을 다 읽은 후에 결과를 생성하기도 합니다.
여기서 중요한 부분 중 하나가 비선형 활성화 함수의 적용입니다. RNN의 순환 단위는 입력과 상태를 조합해 새로운 출력을 만들어 내는 은닉층의 역할을 합니다. 은닉층이 의미 있는 역할을 하려면 단순한 선형 계산만으로는 부족합니다. 아무리 반복해도 선형 변환만 존재한다면 복잡한 패턴을 표현하는 데 한계가 생깁니다. 그래서 RNN에서는 주로 하이퍼볼릭 탄젠트() 같은 비선형 활성화 함수를 사용합니다. 이 함수는 입력의 크기를 부드럽게 조절하며 출력 값을 일정한 범위 안에 유지해 신호의 왜곡이나 폭주를 막습니다.
순환 단위는 현재 입력과 이전 상태를 함께 받아 비선형 변환으로 새 상태를 만들고, 이 상태가 다음 시점으로 이어집니다.
2RNN의 한계: 기울기 소실과 폭발¶
과거 정보를 현재에 반영할 수 있다는 점에서 RNN은 시퀀스 데이터에 매우 유용하지만, 기본 RNN에는 중요한 구조적 한계가 있습니다. 바로 기울기 문제입니다. 이 문제는 역전파, 즉 학습을 위한 신호가 시점을 거슬러 올라가며 전달되는 과정에서 발생합니다.
RNN의 역전파는 마지막 시점의 손실에서 시작해 이전 시점들로 신호를 차례차례 전달합니다. 이때 각 시점의 기울기는 활성화 함수의 도함수와 반복해서 곱해집니다.
| 도함수의 크기 | 시점을 거슬러 곱한 결과 | 증상 |
|---|---|---|
| 기하급수적으로 0에 수렴 | 기울기 소실 — 앞 시점 입력의 영향을 학습하지 못함 | |
| 기하급수적으로 발산 | 기울기 폭발 — 파라미터 갱신이 과도해져 학습이 불안정·발산 |
작은 수를 계속 곱하면 전체 값은 점점 작아집니다. 0.5를 여러 번 곱하면 금세 0에 가까워지듯, 기울기도 점점 줄어 결국 거의 0이 됩니다. 그러면 모델은 앞 시점의 입력이 손실에 어떤 영향을 주었는지 학습할 수 없게 됩니다. 반대로 도함수가 1보다 크면 큰 수를 계속 곱한 결과가 기하급수적으로 커져 파라미터 갱신이 지나치게 커지고, 심하면 모델이 발산하거나 수치 오류가 발생합니다.
두 문제는 기본 구조만으로 긴 시퀀스를 안정적으로 학습하기 어렵다는 것을 보여줍니다. 실제로는 몇 단계 이전까지만 정보를 유지하고 그보다 오래된 과거는 거의 반영하지 못하는 경우가 많습니다. 이 한계를 극복하기 위해 등장한 것이 LSTM과 GRU 같은 게이트 구조입니다.
역전파 신호가 시점을 거슬러 전달될 때 활성화 함수의 도함수가 반복해서 곱해지며, 도함수 크기에 따라 기울기가 소실되거나 폭발합니다.
3텍스트 분류기로 보는 RNN¶
가변 길이의 문장을 입력받아 이진 분류를 수행하는 텍스트 분류기로 RNN 계열의 동작을 확인합니다. 입력은 정수 토큰 시퀀스이고, Embedding 층이 각 토큰을 밀집 벡터로 바꾼 뒤 LSTM 층이 시퀀스를 따라 상태를 갱신합니다. 마지막 상태가 문장 전체의 요약이 되어 시그모이드 출력층으로 전달됩니다. 입력 형태 (None,) 은 길이가 고정되지 않은 시퀀스를 허용한다는 의미입니다.
import keras
from keras import layers
%run load_text_class_example.py
어휘수 = tokenizer.get_piece_size()
print(f'어휘 수: {어휘수:,}')
keras.backend.clear_session()
model = keras.Sequential([
keras.Input(shape=(None,)), # 가변 길이 시퀀스 입력
layers.Embedding(input_dim=어휘수, output_dim=128), # 임베딩
# 은닉층
layers.LSTM(32), # RNN 계열의 LSTM 층
# 출력층
layers.Dense(1, activation='sigmoid') # 이진 분류
])
model.summary()
model.compile(
loss=keras.losses.BinaryCrossentropy(),
optimizer=keras.optimizers.Adam(),
metrics=['acc'],
)
history = model.fit(
dataloaders['train'], epochs=10,
validation_data=dataloaders['validation'],
callbacks=[
keras.callbacks.ModelCheckpoint(
'text_classifier-RNN.keras', save_best_only=True)
]
)학습이 끝나면 검증 성능이 가장 좋았던 체크포인트를 다시 불러와 구조를 확인합니다.
model = keras.models.load_model('text_classifier-RNN.keras')
model.summary()3.1학습된 임베딩 들여다보기¶
Embedding 층의 가중치는 어휘마다 하나씩 대응하는 벡터들의 행렬입니다. 분류 과제를 학습하는 과정에서 이 벡터들은 과제에 유용한 방향으로 정렬됩니다. 특정 단어의 정수 인덱스를 구해 해당 임베딩 벡터를 직접 꺼내 볼 수 있습니다.
임베딩행렬 = model.layers[0].get_weights()[0]
print(임베딩행렬.shape)
단어 = '사랑'
정수인덱스 = tokenizer.piece_to_id(단어)
print(f'{단어} -> {정수인덱스}')
임베딩벡터 = 임베딩행렬[정수인덱스]
print(f'임베딩 벡터: {임베딩벡터[:5].round(3)}...')임베딩 공간에서 두 단어의 의미적 근접성은 벡터 사이의 코사인 유사도로 가늠할 수 있습니다. 전체 어휘에 대해 유사도 행렬을 계산하면 어떤 단어가 어떤 단어와 가깝게 배치되었는지 살펴볼 수 있습니다.
from sklearn.metrics.pairwise import cosine_similarity
유사도행렬 = cosine_similarity(임베딩행렬)print(유사도행렬.shape)
어휘목록 = tokenizer.id_to_piece(list(range(어휘수)))
유사도프레임 = pd.DataFrame(유사도행렬, index=어휘목록, columns=어휘목록)
유사도프레임.round(3)한 단어를 기준으로 유사도가 높은 상위 단어들을 추려 보면, 분류 과제를 통해 학습된 임베딩이 의미적으로 관련된 단어들을 가깝게 모아 두었음을 확인할 수 있습니다.
단어 = '대통령'
# 거리 (유사도) 순 상위 10개 단어
정수인덱스 = tokenizer.piece_to_id(단어)
유사도프레임.iloc[정수인덱스].sort_values(ascending=False).head(5).round(3).to_frame()4함수형 API와 비순차 계산 그래프¶
앞서 본 Sequential 모델은 층을 일렬로 쌓는 단순한 흐름만 표현합니다. 그러나 곧 다룰 인코더-디코더처럼 여러 입력을 받거나 여러 출력을 내거나, 같은 층을 여러 곳에서 공유하는 모델은 일렬 구조로 표현할 수 없습니다. 함수형 API 는 층을 함수처럼 호출해 입력 텐서로부터 출력 텐서를 만들고, 그 연결 관계로 계산 그래프를 구성합니다. 먼저 입력 텐서를 정의하고, 층 객체를 호출해 변환을 이어 붙인 뒤, 입력과 출력을 묶어 모델을 만듭니다.
# 정의된 계산 (예: 계층) 객체 생성
fc = layers.Dense(64, activation='relu') # XW + b
# 계산 그래프 형성
inputs = keras.Input(shape=(784,)) # 입력 형식
x = inputs / 255
x = fc(x)
# 모델 형성
model = keras.Model(inputs=inputs, outputs=x)
model.summary()이 방식이면 다중 입력·다중 출력 모델도 자연스럽게 구성됩니다. 예컨대 제목·본문·태그를 각각 입력받아 하나의 공유 임베딩으로 인코딩한 뒤 결합하고, 중요도(이진 분류)와 담당 부서(다중 분류)라는 두 출력을 동시에 내보내는 모델을 만들 수 있습니다. 같은 임베딩계층 객체를 세 입력에 재사용한다는 점에 주목합니다.
어휘수, 벡터차원 = 10000, 256
임베딩계층 = layers.Embedding(어휘수, 벡터차원)
제목 = keras.Input(shape=(100,), dtype='int')
본문 = keras.Input(shape=(1000,), dtype='int')
태그 = keras.Input(shape=(100,), dtype='int')
x1 = 임베딩계층(제목)
x2 = 임베딩계층(본문)
x3 = 임베딩계층(태그)
x1 = layers.Flatten()(x1)
x2 = layers.Flatten()(x2)
x3 = layers.Flatten()(x3)
x = layers.Concatenate()([x1, x2, x3])
x = layers.Dense(4096)(x)
중요도 = layers.Dense(1, activation='sigmoid')(x)
담당부서 = layers.Dense(4, activation='softmax')(x)
model = keras.Model(inputs=[제목, 본문, 태그], outputs=[중요도, 담당부서])
model.summary()
keras.utils.plot_model(model, show_shapes=True)5번역 모형과 마스킹¶
번역처럼 길이가 제각각인 문장을 배치로 묶으려면 짧은 문장을 0으로 채워(padding) 길이를 맞춥니다. 이때 채움 토큰이 실제 단어처럼 계산에 끼어들면 안 됩니다. Embedding 층에 mask_zero=True 를 주면 정수 0을 채움으로 간주해 마스크를 생성하고, 이후 RNN 층이 그 위치를 건너뛰도록 합니다.
어휘수 = tokenizer.vocab_size()
벡터차원 = 256
원문 = keras.Input(shape=(None,), dtype='int', name='korean')
x1 = layers.Embedding(어휘수, 벡터차원, mask_zero=True)(원문)
model = keras.Model(inputs=[원문], outputs=x1)
model.summary()6시퀀스 생성: RNN 인코더-디코더 (2014)¶
2014년에 제안된 RNN 인코더-디코더 는 기계 번역의 새로운 돌파구를 열었습니다. 기본 구상은 두 개의 RNN을 잇는 것입니다.
먼저 인코더 는 길이가 서로 다를 수 있는 입력 문장의 형태소들을 RNN으로 처리합니다. 문장에서 형태소들은 순서가 중요하므로 그 자체로 하나의 시퀀스입니다. 인코더 RNN의 마지막 은닉 상태는 전체 문장에 대한 요약으로 볼 수 있습니다.
두 번째 RNN인 디코더 는 출력 문장을 생성합니다. 디코더 역시 RNN이므로 이전 은닉 상태를 받는다는 점은 인코더와 같습니다. 다른 점은 출력 형태소를 순차적으로 예측하면서 예측된 이전 형태소가 현재 은닉 상태의 입력이 된다는 것입니다. 또한 인코더의 최종 은닉 상태가 디코더의 초기 상태로 더해져 입력 문장과 출력 문장의 관계를 매개합니다. 입력 문장의 길이 와 출력 문장의 길이 는 서로 다를 수 있습니다.
이 구조의 통찰은 번역의 본질에 닿아 있습니다. 두 언어를 오갈 때 번역은 단어를 일대일로 변환하는 작업이 아닙니다. 표현된 문장은 표현하고자 하는 개념을 대표합니다. 예컨대 "안녕하세요"는 인사말이라는 개념으로 요약되며, 그 번역은 "hi"일 수도 "how are you"일 수도 있습니다. 단어 수준의 일대일 대응은 매우 제한적이거나 불가능합니다. 따라서 입력 문장을 하나의 요약 벡터(개념)로 압축하고, 그 개념에 대응하는 목표 언어의 표현을 생성하는 인코더-디코더 방식이 번역에 적합합니다.
다음은 이 구조를 함수형 API로 구성한 모델입니다. 인코더는 양방향 GRU로 문장을 하나의 벡터로 압축하고, 디코더 GRU는 그 벡터를 초기 상태로 받아 출력 시퀀스를 생성합니다. 인코더 부분의 주석은 RNN의 역사적 변천(1980년대 SimpleRNN, 1990년대 LSTM, 2014년 양방향 GRU)을 보여줍니다.
인코더 RNN이 입력 문장(길이 S)을 하나의 요약 벡터로 압축하고, 그 벡터가 디코더 RNN의 초기 상태가 되어 출력 문장(길이 T)을 생성합니다.
keras.backend.clear_session() # 계산 그래프 정리
시퀀스길이 = 128
어휘수, 벡터차원 = 10000, 256
임베딩 = layers.Embedding(어휘수, 벡터차원, mask_zero=True)
원문 = keras.Input(shape=(시퀀스길이,), dtype='int64', name='korean')
x1 = 임베딩(원문)
# RNN 인코더
# 인코더출력 = layers.SimpleRNN(1024)(x1) # 1980년대
# 인코더출력 = layers.LSTM(1024)(x1) # 1990년대
# 원문의 의도를 반영한 하나의 벡터를 형성
인코더출력 = layers.Bidirectional(layers.GRU(1024), merge_mode='sum', name='encoder_rnn')(x1) # 2014년
# Decoder
번역 = keras.Input(shape=(시퀀스길이,), dtype='int64', name='english')
x2 = 임베딩(번역)
x2 = layers.GRU(1024, return_sequences=True, name='decoder_rnn')(x2, initial_state=인코더출력)
outputs = layers.Dense(어휘수)(x2)
model = keras.Model(inputs=[원문, 번역], outputs=outputs, name='rnn_encdec')
model.summary()
keras.utils.plot_model(model, show_shapes=True, show_layer_names=True)6.1GRU: 게이트 순환 단위¶
RNN 인코더-디코더에서는 순환 단위로 기존의 LSTM 대신 GRU(Gated Recurrent Unit) 를 사용합니다. GRU는 LSTM에 비해 연산과 구현이 훨씬 간단한 것이 장점입니다. GRU의 핵심은 은닉 상태를 조건부로 갱신한다는 데 있습니다. 두 개의 게이트가 이 갱신을 제어합니다.
| 게이트 | 기호 | 역할 |
|---|---|---|
| 갱신 게이트(update) | 입력으로부터 만들어진 새 은닉 상태를 얼마나 반영할지 결정 | |
| 리셋 게이트(reset) | 이전 은닉 상태를 얼마나 반영할지 결정 |
이 게이트 구조 덕분에 GRU는 필요한 정보를 더 오래 유지하면서 기본 RNN의 기울기 문제를 완화합니다.
6.2RNN 언어 모델과 학습¶
디코더가 다음 형태소를 어떻게 고르는지는 언어 모델의 관점으로 이해할 수 있습니다. 문장의 형태소들은 순차적이므로, 이전 단어들의 맥락에 대한 조건부 확률로 다음 단어의 확률을 정의할 수 있습니다. 다음 단어 예측은 RNN 은닉 상태에 단어 임베딩을 가중치로 곱하고, 전체 어휘 에 대해 소프트맥스로 처리해 확률적 자신감으로 출력합니다.
학습은 입력 문장 가 주어졌을 때 목표 문장 가 나타날 확률을 극대화하는 방식, 즉 최대우도추정(maximum likelihood estimation) 으로 수행합니다. 목표 문장의 각 형태소는 전체 어휘에 대한 확률적 자신감으로 추론됩니다. 그런데 각 확률은 0과 1 사이의 작은 값이라 전체 표본에 대한 우도의 곱은 매우 작은 값이 되어 수치적으로 다루기 어렵습니다. 로그를 취하면 곱이 합으로 바뀌어 안정적으로 최적화할 수 있습니다.
실제 번역 모델의 학습은 다음 스크립트로 수행합니다.
디코더는 요약 벡터를 초기 상태로 받아 형태소를 순차적으로 예측하며, 예측된 이전 형태소가 다음 시점의 입력이 됩니다. 목표 시퀀스에 대한 우도를 최대화하도록 학습합니다.
기계번역 모델 학습 예시
python train_rnn_translator.py