텍스트를 위한 인공 신경망

신민제·2023년 8월 10일
0

머신러닝, 딥러닝

목록 보기
6/6
post-thumbnail

순차 데이터와 순환 신경망

  • 순차 데이터

순차 데이터(sequential data)는 텍스트나 시계열 데이터(time series data)와 같이 순서에 의미가 있는 데이터를 말한다.
ex) I am a boy. -> boy am a I
순서가 다르면 의미가 사라진다.

지금까지 다루었던 데이터는 순서와는 상관이 없었다. 오히려 데이터를 적절히 섞는 것이 결과가 더 좋게 나오기도 했다. 이번 시간에 사용하려는 댓글, 즉 텍스트 데이터는 단어의 순서가 중요한 데이터이다. 순차 데이터를 다룰 때는 이전에 입력한 데이터를 기억하는 기능이 필요하다.

그러기 위해서는 입력 데이터의 흐름이 앞으로만 전달되는 신경망이 필요한데, 이러한 특징을 가진 신경망이 '피드포워드 신경망'이다. 이전 장에서 배웠던 완전 연결 신경망과 합성곱 신경망이 모두 피드포워드 신경망에 속한다.

  • 순환 신경망

    신경망이 이전에 처리했던 샘플을 다음 샘플을 처리하는데 재사용하기 위해서는 데이터 흐름이 앞으로만 전달되어서는 안된다. 다음 샘플을 위해서 이전 데이터가 신경망 층에 순환될 필요가 있다. 이것이 '순환 신경망'이다.

RNN은 Recurrent 단어 그대로 반복되는 신경망이다. 즉, 스스로를 반복하면서 이전 단계에서 얻은 정보가 지속되도록 한다. RNN은 기존 Neural Network와 구조가 상당히 비슷하다. CNN과 같은 신경망들은 전부 hidden layer에서 activation function을 지난 값은 오직 출력층으로만 향했다.(이러한 신경망을 Feed Forward Neural Network라고 한다.) 그러나 RNN은 hidden node에서 activation function을 통해 나온 출력을 출력층으로도 내보내고, hidden node의 다음 연산의 입력으로도 내보내는 특징을 가지고 있다.

  • 순환 신경망의 구조

순환 신경망의 계층은 다음과 같다.

위의 왼쪽 그림에서 RNN은 입력값() 을 받아 출력값() 을 만들고, 이 출력을 다시 입력으로 받는 형태를 보인다. 오른쪽 그림은 이를 각 타임 스텝(Time step)마다 펼쳐서 Time step 별 입력, 출력, 가중치를 나타낸다.


위는 RNN 계층을 수행하는 계산의 수식이다. 위 식에서의 입력() 을 출력() 으로 변환하기 위한 가중치와, RNN 출력을 다음 시각(t)의 출력으로 변환하기 위한 가중치, 편향으로 이루어져 있다. 먼저 행렬 곱을 계산한 후, 그 합을 tanh 함수(tanh; Hyperbolic tangent, 쌍곡 탄젠트 함수)를 이용해 변환하여 시간이 출력된다. 이 는 다른 계층을 향해 위쪽으로 출력되는 동시에, 다음 시각 의 RNN 계층으로도 출력된다.

순환 신경망으로 IMDB 리뷰 분류하기

다음은 순환 신경망으로 IMDB 리뷰를 분류해보겠다.

앞 절에서는 순환 신경망의 작동 원리에 대해서 알아보았다면, 이번에는 대표적인 순환 신경망 문제인 IMDB 리뷰 데이터셋을 사용해 가장 간단한 순환 신경망 모델을 훈련해 보겠다. IMDB 리뷰 데이터셋은 유명한 인터넷 영화 데이터베이스에서 수집한 리뷰를 감상평에 따라 긍정과 부정으로 분류해 놓은 데이터셋이다. 총 50,000개의 샘플로 이루어져 있고, 훈련 데이터와 테스트 데이터에 각각 25,000개씩 나누어져 있다.

실제 IMDB 리뷰 데이터셋은 영어로 된 문장이지만, 텐서플로에는 이미 정수로 바꾼 데이터가 포함되어 있다. 여기에서는 가장 자주 등장하는 단어 500개만 사용한다.

from tensorflow.keras.datasets import imdb
(train_input, train_target), (test_input, test_target) = imdb.load_data(num_words=500)

먼저 훈련 세트와 테스트 세트의 크기를 확인해 보겠다.

print(train_input.shape, test_input.shape)

(25000,) (25000,)

그럼 이제 첫 번째 리뷰의 길이를 출력해 보겠다.

print(len(train_input[0]))

218

첫 번째 리뷰의 길이는 218개의 토큰으로 이루어져 있다. 여기서 토큰이란, '분리된 단어' 를 말한다. 이제 첫 번째 리뷰에 담긴 내용을 출력해보겠다.

print(train_input[0])

[1, 14, 22, 16, 43, 2, 2, 2, 2, 65, 458, 2, 66, 2, 4, 173, 36, 256, 5, 25, 100, 43, 2, 112, 50, 2, 2, 9, 35, 480, 284, 5, 150, 4, 172, 112, 167, 2, 336, 385, 39, 4, 172, 2, 2, 17, 2, 38, 13, 447, 4, 192, 50, 16, 6, 147, 2, 19, 14, 22, 4, 2, 2, 469, 4, 22, 71, 87, 12, 16, 43, 2, 38, 76, 15, 13, 2, 4, 22, 17, 2, 17, 12, 16, 2, 18, 2, 5, 62, 386, 12, 8, 316, 8, 106, 5, 4, 2, 2, 16, 480, 66, 2, 33, 4, 130, 12, 16, 38, 2, 5, 25, 124, 51, 36, 135, 48, 25, 2, 33, 6, 22, 12, 215, 28, 77, 52, 5, 14, 407, 16, 82, 2, 8, 4, 107, 117, 2, 15, 256, 4, 2, 7, 2, 5, 2, 36, 71, 43, 2, 476, 26, 400, 317, 46, 7, 4, 2, 2, 13, 104, 88, 4, 381, 15, 297, 98, 32, 2, 56, 26, 141, 6, 194, 2, 18, 4, 226, 22, 21, 134, 476, 26, 480, 5, 144, 30, 2, 18, 51, 36, 28, 224, 92, 25, 104, 4, 226, 65, 16, 38, 2, 88, 12, 16, 283, 5, 16, 2, 113, 103, 32, 15, 16, 2, 19, 178, 32]

텐서플로에 있는 IMDB 리뷰 데이터는 이미 정수로 변환되어 있다. 앞서 num_words = 500으로 지정했기 때문에 어휘 사전에는 500개의 단어들만 들어가 있다. 따라서 어휘 사전에 없는 단어는 모두 2로 표시되어 나타난다.

이번에는 타깃 데이터를 출력해 보겠다.

print(train_target[:20])

[1 0 0 1 0 0 1 0 1 0 1 0 0 0 0 0 1 1 0 1]

우리가 해결할 문제는 리뷰가 긍정인지 부정인지를 판단하는 것이다. 긍정, 부정 두개의 분류 문제이기에 이진 분류 문제로 볼 수 있으므로 타깃값이 0(부정)과 1(긍정) 으로 나뉜다.

데이터를 더 살펴보기 전에 훈련 세트에서 검증 세트를 떼어 놓고, 훈련 세트에 대해 몇 가지 조사를 해본 후, 길이를 재보도록 하겠다.

from sklearn.model_selection import train_test_split
train_input, val_input, train_target, val_target = train_test_split(train_input, train_target, test_size=0.2, random_state=42)

import numpy as np
lengths = np.array([len(x) for x in train_input])

print(np.mean(lengths), np.median(lengths))

239.00925 178.0

리뷰의 평균 단어 개수는 239개이고 중간값이 178인 것으로 보아 이 리뷰 길이 데이터는 한족에 치우친 분포를 보일 것 같다.

import numpy as np
import matplotlib.pyplot as plt
lengths = np.array([len(x) for x in train_input])

plt.hist(lengths)
plt.xlabel('length')
plt.ylabel('frequency')

plt.show()

예상대로 한쪽으로 치우친 것을 볼 수 있다. 대부분의 리뷰 길이는 300 미만이고, 어떤 리뷰는 1,000개의 단어가 넘기도 한다. 리뷰는 대부분 짧아서 이번 문제에서는 중간값보다 훨신 짧은 100개의 단어만 사용해보겠다. 여기서 한가지 문제는 100개의 단어보다 작은 리뷰가 있다는 것이다. 이를 해결하기 위해서는 리뷰의 길이를 100에 맞춰야 하는데 이때 사용하는 것이 패딩이다.

from tensorflow.keras.preprocessing.sequence import pad_sequences

train_seq = pad_sequences(train_input, maxlen=100)
val_seq = pad_sequences(val_input, maxlen=100)

print(train_seq.shape)

(20000, 100)

train_input은 파이썬 리스트의 배열이었지만, 길이를 100으로 맞춘 train_seq는 이제 (20000,100) 크기의 2차원 배열로 바뀌었다. train_seq에 있는 첫 번째 샘플을 출력해 보겠다.

print(train_seq[0])

[ 10 4 20 9 2 364 352 5 45 6 2 2 33 269 8 2 142 2
5 2 17 73 17 204 5 2 19 55 2 2 92 66 104 14 20 93
76 2 151 33 4 58 12 188 2 151 12 215 69 224 142 73 237 6
2 7 2 2 188 2 103 14 31 10 10 451 7 2 5 2 80 91
2 30 2 34 14 20 151 50 26 131 49 2 84 46 50 37 80 79
6 2 46 7 14 20 10 10 470 158]

이 샘플의 앞뒤에 패딩값 0이 없는 것으로 보아 100보다는 길이가 길었음을 알 수 있다. 그럼 원래 샘플의 앞 부분이 잘린 것일까? 뒷 부분이 잘린 것일까? train_input에 있는 원본 샘플의 끝을 확인해 보자.

print(train_input[0][-10:])

[6, 2, 46, 7, 14, 20, 10, 10, 470, 158]

train_input[0] 에 있는 마지막 10개의 토큰을 출력해봤는데 train_seq[0]의 값과 같은 것으로 보아 샘플의 앞부분이 잘린 것을 알 수 있다. 이번에는 train_seq에 있는 여섯 번째 샘플을 출력해 보겠다.

print(train_seq[5])

[ 0 0 0 0 1 2 195 19 49 2 2 190 4 2 352 2 183 10
10 13 82 79 4 2 36 71 269 8 2 25 19 49 7 4 2 2
2 2 2 10 10 48 25 40 2 11 2 2 40 2 2 5 4 2
2 95 14 238 56 129 2 10 10 21 2 94 364 352 2 2 11 190
24 484 2 7 94 205 405 10 10 87 2 34 49 2 7 2 2 2
2 2 290 2 46 48 64 18 4 2]

앞부분에 0이 있는 것으로 보아 이 샘플의 길이는 100보다 작은 것을 알 수 있다. 역시 같은 이유로 패딩 토큰은 시퀀스의 뒷부분이 아닌 앞부분에 추가된다. 시퀀스의 마지막에 있는 단어가 셀의 은닉 상태에 가장 큰 영향을 미치게 되므로 마지막에 패딩을 추가하는 것은 일반적으로 선호하지 않는다. 이런 방식대로 검증 세트의 길이도 100으로 맞추고 순환 신경망을 만들어 보겠다.

원 - 핫 인코딩으로 데이터 바꾸기

#검증 세트의 길이 100으로 맞추기
val_seq = pad_sequences(val_input, maxlen = 100)

from tensorflow import keras
model = keras.Sequential()
model.add(keras.layers.SimpleRNN(8, input_shape=(100, 500)))
model.add(keras.layers.Dense(1, activation='sigmoid')) 

뉴런 갯수를 8개로 지정하고, 샘플의 길이가 100이고 500개의 단어만 사용하도록 설정했기 때문에 input_shape를 (100,500)으로 둔다.

순환층도 활성화 함수를 사용하는데 기본 매개변수 acivation의 기본값은 tanh로, 하이퍼볼릭 탄젠트 함수를 사용한다.

그러나 토큰을 정수로 변환한 데이터를 신경망에 주입하면, 큰 정수가 큰 활성화 출력을 만들게 된다.
이 정수들 사이에는 어떤 관련이 없기 때문에 정수값에 있는 크기 속성을 없애고 각 정수를 고유하게 표현하기 위해 원-핫 인코딩을 사용한다.

keras.utils 패키지의 to_categorical() 함수를 사용하여 훈련세트와 검증 세트를 원-핫 인코딩으로 바꾸어준다.

train_oh = keras.utils.to_categorical(train_seq)
val_oh = keras.utils.to_categorical(val_seq)
print(train_oh.shape)

(20000, 100, 500)

정수 하나마다 500차원의 배열로 변경되었다.

print(train_oh[0][0][:12])
print(np.sum(train_oh[0][0]))

[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0.]
1.0

첫 리뷰의 첫 단어를 원-핫 인코딩시킨 결과이다.
모든 원소의 값을 더하면 1임을 알 수 있다.

순환 신경망 훈련하기

RMSprop의 기본 학습률 0.001을 사용하지 않기 위해 별도의 RMSprop 객체를 만들어 학습률을 0.0001로 지정한다. 에포크 횟수는 100으로 늘리고, 배치 크기는 64개로 지정한다.
체크포인트와 조기 종료를 구성하고, 신경망을 훈련한다.

rmsprop = keras.optimizers.RMSprop(learning_rate = 1e-4)
model.compile(optimizer=rmsprop, loss='binary_crossentropy', metrics=['accuracy'])

checkpoint_cb = keras.callbacks.ModelCheckpoint('best-simplernn-model.h5')
early_stopping_cb = keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True)

history = model.fit(train_oh, train_target, epochs=100, batch_size=64, validation_data = (val_oh, val_target), callbacks=[checkpoint_cb, early_stopping_cb])
Epoch 1/100
313/313 [==============================] - 14s 41ms/step - loss: 0.6968 - accuracy: 0.5038 - val_loss: 0.6970 - val_accuracy: 0.5008
Epoch 2/100
313/313 [==============================] - 12s 40ms/step - loss: 0.6898 - accuracy: 0.5366 - val_loss: 0.6878 - val_accuracy: 0.5494
Epoch 3/100
313/313 [==============================] - 12s 40ms/step - loss: 0.6800 - accuracy: 0.5793 - val_loss: 0.6774 - val_accuracy: 0.5900
...
Epoch 42/100
313/313 [==============================] - 12s 38ms/step - loss: 0.3983 - accuracy: 0.8228 - val_loss: 0.4540 - val_accuracy: 0.7922

이 훈련은 35번째 에포크에서 조기종료 되었다. 검증 세트에 대한 정확도는 약 80% 정도이다. 그럼 훈련 손실과 검증 손실을 그래프로 그려서 훈련 과정을 살펴보겠다.

plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.xlabel('epoch')
plt.ylabel('loss')
plt.legend(['train', 'val'])
plt.show()

훈련 손실은 꾸준히 감소하고 있지만 검증 손실은 대략 20번째 에포크에서 감소가 둔해지고 있다. 적절한 에포크에서 훈련을 잘 멈춘 것 같다.

단어 임베딩 사용하기

원-핫 인코딩의 단점은 입력 데이터가 매우 커진다는 것이다. 이를 해결하기 위해 순환 신경망에서 단어 임베딩을 사용할 수 있다.

단어 임베딩은 각 단어를 고정된 크기의 실수 벡터로 바꾸어 준다. 이렇게 단어 임베딩으로 만들어진 벡터는 원-핫 인코딩으로 된 벡터보다 훨씬 의미있는 값으로 채워져 있기 때문에 자연어 처리에서 좋은 성능을 내는 경우가 많다.

model2 = keras.Sequential()
model2.add(keras.layers.Embedding(500, 16, input_length=100))
model2.add(keras.layers.SimpleRNN(8))
model2.add(keras.layers.Dense(1, activation='sigmoid'))

Embedding 클래스의 첫 매개변수 500은 어휘 사전의 크기이며, 두 번째 16은 임베딩 벡터의 크기이다. 세 번째 input_length는 입력 시퀸스의 길이이다.

LSTM과 GRU 셀

LSTM(Long Short-Term Memory)는 단기 기억을 오래 기억하기 위해 고안되었다. 입력과 가중치를 곱하고 절편을 더해 활성화 함수를 통과시키는 구조를 여러 개 가지고 있고, 이런 계산 결과는 다음 타임스텝에 재사용된다.


위는 LSTM의 구조인데, LSTM은 RNN의 히든 state에 cell-state를 추가한 구조이다.

cell state는 일종의 컨베이어 벨트 역할을 한다. 덕분에 state가 꽤 오래 경과하더라도 그래디언트가 비교적 전파가 잘 되게 된다.

LSTM 셀의 수식을 하나씩 작성한 사진이며,

forget gate ft는 '과거정보를 잊기'를 위한 게이트이다. ht-1과 xt를 받아서 시그모이드 취해준 값이 바로 forget gate가 내보내는 값이 된다.

input gate it⊙gt 는 '현재정보를 기억하기'위한 게이트이다. ht-1과 xt를 받아서 시그모이드를 취하고 또 같은 입력으로 tanh를 취해준 다음 hadamard product연산을 한 값이 바로 input gate가 내보내는 값이 된다. (hadamard product: 같은 크기의 두 행렬의 각 성분을 곱하는 연산)

LSTM 신경망 훈련하기

#IMDB 리뷰 데이터를 로드하고 훈련 세트와 검증 세트로 나누기
from tensorflow.keras.datasets import imdb
from sklearn.model_selection import train_test_split
(train_input, train_target), (test_input, test_target) = imdb.load_data(num_words=500)
train_input, val_input, train_target, val_target = train_test_split(train_input, train_target, test_size=0.2, random_state=42)

다음은 케라스의 pad_sequences() 함수로 각 샘플의 길이를 100에 맞추고 부족할 때는 패딩을 추가한다.

from tensorflow.keras.preprocessing.sequence import pad_sequences
train_seq = pad_sequences(train_input, maxlen=100)
val_seq = pad_sequences(val_input, maxlen=100)

이제 LSTM 셀을 사용한 순환층을 만들어보겠다.

from tensorflow import keras
model = keras.Sequential()
model.add(keras.layers.Embedding(500,16,input_length=100))
model.add(keras.layers.LSTM(8))
model.add(keras.layers.Dense(1,activation='sigmoid'))

사실 복잡한것은 없고, simpleRNN 클래스를 LSTM 클래스로 바꾸기만 하면 된다.

그럼 이제 모델을 컴파일하고 훈련해보겠다. 이전과 마찬가지로 배치 크기는 64개, 에포크 횟수는 100으로 지정한다.

from tensorflow.python.ops.gen_batch_ops import batch
rmsprop = keras.optimizers.RMSprop(learning_rate=1e-4)
model.compile(optimizer = rmsprop, loss='binary_crossentropy',metrics=['accuracy'])
checkpoint_cb = keras.callbacks.ModelCheckpoint('best-list-model.h5', save_best_only=True)
early_stopping_cb = keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True)
history = model.fit(train_seq, train_target, epochs=100, batch_size=64, validation_data=(val_seq, val_target), callbacks=[checkpoint_cb, early_stopping_cb])

훈련 손실과 검증 손실 그래프를 그려보자.

plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.xlabel('epoch')
plt.ylabel('loss')
plt.legend(['train', 'val'])
plt.show()

GRU 구조


GRU의 구조는 위의 그림과 같다.

GRU 신경망 훈련하기

model4 = keras.Sequential()
model4.add(keras.layers.Embedding(500, 16, input_length=100))
model4.add(keras.layers.GRU(8))
model4.add(keras.layers.Dense(1, activation='sigmoid'))

LSTM 클래스를 GRU 클래스로 바꾼 것 외에는 이전 모델과 동일하다.

rmsprop = keras.optimizers.RMSprop(learning_rate = 1e-4)
model4.compile(optimizer=rmsprop, loss='binary_crossentropy', metrics=['accuracy'])

checkpoint_cb = keras.callbacks.ModelCheckpoint('best-gru-model.h5')
early_stopping_cb = keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True)

history = model4.fit(train_seq, train_target, epochs=100, batch_size=64, validation_data=[val_seq, val_target], callbacks=[checkpoint_cb, early_stopping_cb])

다음은 손실 그래프를 출력해보자.

plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.xlabel('epoch')
plt.ylabel('loss')
plt.legend(['train', 'val'])
plt.show()

드롭아웃을 사용하지 않았기 때문에 이전보다 훈련 손실과 검증 손실 사이에 차이가 있다. 하지만 훈련 과정이 잘 수렴되고 있는 것을 확인할 수 있다. 오늘은 여기서 마치겠다.

0개의 댓글