by museonghwang

장단기 메모리(LSTM) 개념 이해하기

|

  1. Vanilla RNN의 한계
  2. Vanilla RNN 내부 열어보기
  3. LSTM(Long Short‐Term Memory)
    • (1) 입력 게이트 : 현재 정보를 기억하기 위한 게이트
    • (2) 삭제 게이트 : 기억을 삭제하기 위한 게이트
    • (3) 셀 상태
    • (4) 출력 게이트와 은닉 상태 : 현재 시점 $x_t$ 의 은닉 상태를 결정
  4. 케라스 SimpleRNN 이해하기
  5. 케라스 LSTM 이해하기
  6. Bidirectional(LSTM) 이해하기


1. Vanilla RNN의 한계

image


Vanilla RNN출력 결과가 이전의 계산 결과에 의존 하는데, 비교적 짧은 시퀀스(sequence)에 대해서만 효과를 보이는 단점 이 있습니다. 즉, Vanilla RNN의 시점(time step)이 길어질 수록 앞의 정보가 뒤로 충분히 전달되지 못하는 현상이 발생 합니다. 위의 그림은 첫번째 입력값인 $x_1$ 의 정보량을 짙은 남색으로 표현했을 때, 색이 점차 얕아지는 것으로 시점이 지날수록 $x_1$ 의 정보량이 손실되어가는 과정을 표현하였습니다. 뒤로 갈수록 $x_1$ 의 정보량은 손실되고, 시점이 충분히 긴 상황에서는 $x_1$ 의 전체 정보에 대한 영향력은 거의 의미가 없을수도 있습니다.

어쩌면 가장 중요한 정보가 시점의 앞쪽에 위치할 수도 있습니다. RNN으로 만든 언어 모델이 다음 단어를 예측하는 과정을 생각해봅시다. 예를 들어 다음 문장이 있습니다.

모스크바에 여행을 왔는데 건물도 예쁘고 먹을 것도 맛있었어.
그런데 글쎄 직장 상사한테 전화가 왔어. 어디냐고 묻더라구.
그래서 나는 말했지. 저 ___ 여행왔는데요.


다음 단어를 예측하기 위해서는 장소 정보가 필요합니다. 그런데 장소 정보에 해당되는 단어인 ‘모스크바’ 는 앞에 위치하고 있고, RNN이 충분한 기억력을 가지고 있지 못한다면 다음 단어를 엉뚱하게 예측합니다. 이를 장기 의존성 문제(the problem of Long‐Term Dependencies) 라고 합니다.


2. Vanilla RNN 내부 열어보기

image


위 그림은 Vanilla RNN 의 내부 구조를 보여줍니다. 위 그림에 그림에 편향 $b$ 를 그린다면 $x_t$ 옆에 $tanh$ 로 향하는 또 하나의 입력선을 그리면 됩니다.

[h_t = tanh(W_x x_t + W_h h_{t-1} + b)]


Vanilla RNN 은 $x_t$ 와 $h_{t-1}$ 이라는 두 개의 입력이 각각의 가중치와 곱해져서 메모리 셀의 입력이 됩니다. 그리고 이를 하이퍼볼릭탄젠트 함수의 입력으로 사용하고 이 값은 은닉층의 출력인 은닉 상태가 됩니다.


3. LSTM(Long Short‐Term Memory)

image


위 그림은 RNN의 단점을 보완한 단점을 보완한 RNN의 일종을 장단기 메모리(Long Short‐Term Memory, LSTM) 라고 하며, LSTM 의 전체적인 내부의 모습을 보여줍니다. LSTM 은 은닉층의 메모리 셀에 입력 게이트, 망각 게이트, 출력 게이트 를 추가하여 불필요한 기억을 지우고, 기억 해야할 것들을 정합니다.

요약하면 LSTM 은 은닉 상태(hidden state)를 계산하는 식이 전통적인 RNN보다 조금 더 복잡해졌으며 셀 상태(cell state) 라는 값을 추가하였습니다. 위의 그림에서는 $t$ 시점의 셀 상태를 $C_t$ 로 표현 하고 있습니다. LSTM 은 RNN과 비교하여 긴 시퀀스의 입력을 처리하는데 탁월한 성능 을 보입니다.


image


셀 상태(cell state) 는 위 그림에서 왼쪽에서 오른쪽으로 가는 굵은선 입니다. 셀 상태 또한 은닉 상태 처럼 이전 시점의 셀 상태가 다음 시점의 셀 상태를 구하기 위한 입력으로서 사용 됩니다.

은닉 상태의 값과 셀 상태의 값을 구하기 위해새로 추가 된 3개의 게이트를 사용 합니다. 각 게이트는 삭제 게이트, 입력 게이트, 출력 게이트 라고 부르며 이 3개의 게이트에는 공통적으로 시그모이드 함수가 존재합니다. 시그모이드 함수를 지나면 0과 1사이의 값이 나오게 되는데 이 값들을 가지고 게이트를 조절합니다. 아래의 내용을 참고로 각 게이트에 대해 알아보겠습니다.

  • $σ$ : 시그모이드 함수
  • $tanh$ : 하이퍼볼릭탄젠트 함수
  • $W_{xi}$, $W_{xg}$, $W_{xf}$, $W_{xo}$ : $x_t$ 와 함께 각 게이트에서 사용되는 4개의 가중치
  • $W_{hi}$, $W_{hg}$, $W_{hf}$, $W_{ho}$ : $h_{t-1}$ 와 함께 각 게이트에서 사용되는 4개의 가중치
  • $b_i$, $b_g$, $b_f$, $b_o$ : 각 게이트에서 사용되는 4개의 편향


(1) 입력 게이트 : 현재 정보를 기억하기 위한 게이트

image


[i_t = \sigma(W_{xi} x_t + W_{hi} h_{t-1} + b_i)]

[g_t = tanh(W_{xg} x_t + W_{hg} h_{t-1} + b_g)]


  • $i_t$
    • $\sigma(W_{xi} x_t + W_{hi} h_{t-1} + b_i)$
    • 현재시점 $t$ 의 $x_t$ 값과 입력 게이트로 이어지는 가중치 $W_{xi}$ 를 곱한 값과, 이전 시점 $t‐1$ 의 은닉 상태 $h_{t-1}$ 가 입력 게이트로 이어지는 가중치 $W_{hi}$ 를 곱한 값을 더하여 시그모이드 함수 를 지납니다.
    • 시그모이드 함수를 지나 0과 1사이의 값을 가짐.
  • $g_t$
    • $tanh(W_{xg} x_t + W_{hg} h_{t-1} + b_g)$
    • 현재시점 $t$ 의 $x_t$ 값과 입력 게이트로 이어지는 가중치 $W_{xg}$ 를 곱한 값과, 이전 시점 $t‐1$ 의 은닉 상태 $h_{t-1}$ 가 입력 게이트로 이어지는 가중치 $W_{hg}$ 를 곱한 값을 더하여 하이퍼볼릭탄젠트 함수 를 지납니다.
    • 하이퍼볼릭탄젠트 함수를 지나 -1과 1사이의 값을 가짐.


0과 1사이의 값을 가지는 $i_t$ 와 -1과 1사이의 값을 가지는 $g_t$, 이 두 개의 값을 가지고 이번에 선택된 기억할 정보의 양을 정합니다.


(2) 삭제 게이트 : 기억을 삭제하기 위한 게이트

image


[f_t = \sigma(W_{xf} x_t + W_{hf} h_{t-1} + b_f)]


  • $f_t$
    • $\sigma(W_{xf} x_t + W_{hf} h_{t-1} + b_f)$
    • 현재시점 $t$ 의 $x_t$ 값과 삭제 게이트로 이어지는 가중치 $W_{xf}$ 를 곱한 값과, 이전 시점 $t‐1$ 의 은닉 상태 $h_{t-1}$ 가 삭제 게이트로 이어지는 가중치 $W_{hf}$ 를 곱한 값을 더하여 시그모이드 함수 를 지나게 됩니다.
    • 시그모이드 함수를 지나 0과 1사이의 값을 가짐.


0과 1사이의 값을 가지는 $f_t$, 이 값이 곧 삭제 과정을 거친 정보의 양 입니다. 0에 가까울수록 정보가 많이 삭제된 것이고 1에 가까울수록 정보를 온전히 기억한 것 입니다. 이를 가지고 셀 상태를 구하게 됩니다.


(3) 셀 상태

image


[C_t = f_t ∘ C_{t−1} + i_t ∘ g_t]


  • $C_t$
    • 현재 삭제 게이트에서 일부 기억을 잃은 상태
    • 입력 게이트에서 구한 $i_t$, $g_t$ 이 두 개의 값에 대해서 원소별 곱(entrywise product)을 진행하여 같은 위치의 성분끼리 곱합니다. 이것이 이번에 선택된 기억할 값입니다.
    • 입력 게이트에서 선택된 기억을 삭제 게이트의 결과값과 더합니다. 이 값을 현재 시점 $t$ 의 셀 상태 라고 하며, 이 값은 다음 $t+1$ 시점의 LSTM 셀로 넘겨집니다.


삭제 게이트와 입력 게이트의 영향력을 이해해봅시다.

만약 삭제 게이트의 출력값인 $f_t$ 가 0이 된다면, 이전 시점의 셀 상태의 값인 $C_{t-1}$ 은 현재 시점의 셀 상태의 값을 결정하기 위한 영향력이 0이 되면서, 오직 입력 게이트의 결과만이 현재 시점의 셀 상태의 값 $C_t$ 을 결정할 수 있습니다. 이는 삭제 게이트가 완전히 닫히고 입력 게이트를 연 상태를 의미 합니다. 반대로 입력 게이트의 $i_t$ 값을 0이라고 한다면, 현재 시점의 셀 상태의 값 $C_t$ 는 오직 이전 시점의 셀 상태의 값 $C_{t-1}$ 의 값에만 의존합니다. 이는 입력 게이트를 완전히 닫고 삭제 게이트만을 연 상태를 의미합니다.

결과적으로 삭제 게이트는 이전 시점의 입력을 얼마나 반영할지를 의미 하고, 입력 게이트는 현재 시점의 입력을 얼마나 반영할지를 결정 합니다.


(4) 출력 게이트와 은닉 상태 : 현재 시점 $x_t$ 의 은닉 상태를 결정

image


[o_t = \sigma(W_{xo} x_t + W_{ho} h_{t-1} + b_0)]

[h_t = o_t ∘ tanh(c_t)]


  • $o_t$
    • $\sigma(W_{xo} x_t + W_{ho} h_{t-1} + b_0)$
    • 출력 게이트는 현재 시점 $t$ 의 $x_t$ 값과 이전 시점 $t‐1$ 의 은닉 상태가 시그모이드 함수를 지난 값입니다.
    • 해당 값은 현재 시점 $x_t$ 의 은닉 상태를 결정하는 일에 쓰이게 됩니다.


셀 상태의 값 $c_t$ 가 하이퍼볼릭탄젠트 함수를 지나 -1과 1사이의 값이 되고, 해당 값은 출력 게이트의 값과 연산되면서, 값이 걸러지는 효과가 발생 하여 은닉 상태 가 됩니다. 은닉 상태의 값은 또한 출력층으로도 향합니다.


4. 케라스 SimpleRNN 이해하기

우선 RNNLSTM 을 테스트하기 위한 임의의 입력을 만듭니다.

import numpy as np
import tensorflow as tf
from tensorflow.keras.layers import SimpleRNN, GRU, LSTM, Bidirectional

train_X = [[0.1, 4.2, 1.5, 1.1, 2.8],
           [1.0, 3.1, 2.5, 0.7, 1.1],
           [0.3, 2.1, 1.5, 2.1, 0.1],
           [2.2, 1.4, 0.5, 0.9, 1.1]]

print(np.shape(train_X))
[output]
(4, 5)


위 입력은 단어 벡터의 차원은 5 이고, 문장의 길이가 4 인 경우를 가정한 입력입니다. 다시 말해 4번의 시점(timesteps)이 존재하고, 각 시점마다 5차원의 단어 벡터가 입력으로 사용 됩니다. 그런데 RNN은 2D 텐서가 아니라 3D 텐서를 입력을 받습니다. 즉, 위에서 만든 2D 텐서를 3D 텐서 로 변경하겠습니다. 이는 배치 크기 1을 추가해줌으로서 해결합니다.

train_X = [[[0.1, 4.2, 1.5, 1.1, 2.8],
            [1.0, 3.1, 2.5, 0.7, 1.1],
            [0.3, 2.1, 1.5, 2.1, 0.1],
            [2.2, 1.4, 0.5, 0.9, 1.1]]]

train_X = np.array(train_X, dtype=np.float32)
print(train_X.shape)
[output]
(1, 4, 5)


(batch_size, timesteps, input_dim) 에 해당되는 (1, 4, 5)의 크기를 가지는 3D 텐서가 생성되었습니다. batch_size 는 한 번에 RNN 이 학습하는 데이터의 양을 의미하지만, 여기서는 샘플이 1개 밖에 없으므로 batch_size 는 1입니다.


위에서 생성한 데이터를 SimpleRNN 의 입력으로 사용하여 SimpleRNN 의 출력값을 이해해보겠습니다. SimpleRNN에는 여러 인자가 있으며 대표적인 인자로 return_sequencesreturn_state 가 있으며, 기본값으로는 둘 다 False 로 지정되어져 있으므로 별도 지정을 하지 않을 경우에는 False 로 처리됩니다. 우선, 은닉 상태의 크기를 3으로 지정하고, 두 인자 값이 모두 False 일 때의 출력값을 보겠습니다. 출력값 자체보다는 해당 값의 크기(shape)에 주목 해야합니다.

rnn = SimpleRNN(3)
# rnn = SimpleRNN(3, return_sequences=False, return_state=False)와 동일.
hidden_state = rnn(train_X)

print('hidden state : {} shape: {}'.format(hidden_state, hidden_state.shape))
[output]
hidden state : [[-0.9577472  -0.33443117 -0.16784662]] shape: (1, 3)


(1, 3) 크기의 텐서가 출력되는데, 이는 마지막 시점의 은닉 상태입니다. 은닉 상태의 크기를 3으로 지정했음을 주목합시다. 기본적으로 return_sequencesFalse 인 경우에는 SimpleRNN 은 마지막 시점의 은닉 상태만 출력합니다. 이번에는 return_sequencesTrue 로 지정하여 모든 시점의 은닉 상태를 출력해봅시다.

rnn = SimpleRNN(3, return_sequences=True)
hidden_states = rnn(train_X)

print('hidden states : \n{} shape: {}'.format(hidden_states, hidden_states.shape))
[output]
hidden states : 
[[[-0.07684275 -0.9996449  -0.99920934]
  [-0.6308924  -0.9999172  -0.9968455 ]
  [ 0.6306296  -0.99580896 -0.7375661 ]
  [-0.7447482  -0.9749265  -0.9728286 ]]] shape: (1, 4, 3)


(1, 4, 3) 크기의 텐서가 출력됩니다. 앞서 입력 데이터는 (1, 4, 5) 의 크기를 가지는 3D 텐서였고, 그 중 4가 시점(timesteps)에 해당하는 값이므로 모든 시점에 대해서 은닉 상태의 값을 출력하여 (1, 4, 3) 크기의 텐서를 출력하는 것입니다.

return_stateTrue 일 경우에는 return_sequencesTrue/False 여부와 상관없이 마지막 시점의 은닉 상태를 출력합니다. 가령, return_sequencesTrue 이면서, return_stateTrue 로 할 경우 SimpleRNN 은 두 개의 출력을 리턴합니다.

rnn = SimpleRNN(3, return_sequences=True, return_state=True)
hidden_states, last_state = rnn(train_X)

print('hidden states : \n{} shape: {}'.format(hidden_states, hidden_states.shape))
print('last hidden state : {} shape: {}'.format(last_state, last_state.shape))
[output]
hidden states : 
[[[ 0.05431597  0.9997256   0.07848608]
  [-0.5503005   0.96039253  0.61889   ]
  [ 0.7662684   0.48871595  0.8754748 ]
  [ 0.949467    0.854687   -0.16799167]]] shape: (1, 4, 3)
last hidden state : [[ 0.949467    0.854687   -0.16799167]] shape: (1, 3)


첫번째 출력은 return_sequences=True 로 인한 출력으로 모든 시점의 은닉 상태입니다. 두번째 출력은 return_state=True 로 인한 출력으로 마지막 시점의 은닉 상태입니다. 실제로 출력을 보면 모든 시점의 은닉 상태인 (1, 4, 3) 텐서의 마지막 벡터값이 return_state=True 로 인해 출력된 벡터값과 일치하는 것을 볼 수 있습니다. (둘 다 [-0.63698626 -0.6929572 -0.9387183 ])

그렇다면 return_sequencesFalse 인데, retun_stateTrue 인 경우를 살펴보겠습니다.

rnn = SimpleRNN(3, return_sequences=False, return_state=True)
hidden_state, last_state = rnn(train_X)

print('hidden state : {} shape: {}'.format(hidden_state, hidden_state.shape))
print('last hidden state : {} shape: {}'.format(last_state, last_state.shape))
[output]
hidden state : [[ 0.9969874   0.922215   -0.44041932]] shape: (1, 3)
last hidden state : [[ 0.9969874   0.922215   -0.44041932]] shape: (1, 3)


두 개의 출력 모두 마지막 시점의 은닉 상태를 출력하게 됩니다.


5. 케라스 LSTM 이해하기

이번에는 임의의 입력에 대해서 LSTM 을 사용할 경우를 보겠습니다. 우선 return_sequencesFalse 로 두고, return_stateTrue 인 경우를 보겠습니다.

lstm = LSTM(3, return_sequences=False, return_state=True)
hidden_state, last_state, last_cell_state = lstm(train_X)

print('hidden state : {} shape: {}'.format(hidden_state, hidden_state.shape))
print('last hidden state : {} shape: {}'.format(last_state, last_state.shape))
print('last cell state : {} shape: {}'.format(last_cell_state, last_cell_state.shape))
[output]
hidden state : [[-0.23502806 -0.45216066  0.06345625]] shape: (1, 3)
last hidden state : [[-0.23502806 -0.45216066  0.06345625]] shape: (1, 3)
last cell state : [[-0.38973868 -0.6697613   0.10231213]] shape: (1, 3)


SimpleRNN 때와는 달리, 세 개의 결과를 반환합니다. return_sequencesFalse 이므로 우선 첫번째 결과는 마지막 시점의 은닉 상태입니다. 그런데 LSTMSimpleRNN 과 다른 점은 return_stateTrue 로 둔 경우에는 마지막 시점의 은닉 상태뿐만 아니라 셀 상태까지 반환한다는 점입니다. 이번에는 return_sequencesTrue로 바꿔보겠습니다.

lstm = LSTM(3, return_sequences=True, return_state=True)
hidden_states, last_hidden_state, last_cell_state = lstm(train_X)

print('hidden states : \n{} shape: {}'.format(hidden_states, hidden_states.shape))
print('last hidden state : {} shape: {}'.format(last_hidden_state, last_hidden_state.shape))
print('last cell state : {} shape: {}'.format(last_cell_state, last_cell_state.shape))
[output]
hidden states : 
[[[0.06367525 0.42689556 0.25716597]
  [0.10355692 0.32117185 0.3720547 ]
  [0.03180264 0.4889893  0.34424222]
  [0.10580046 0.3162607  0.3505974 ]]] shape: (1, 4, 3)
last hidden state : [[0.10580046 0.3162607  0.3505974 ]] shape: (1, 3)
last cell state : [[0.23588046 0.63385934 1.5717858 ]] shape: (1, 3)


return_stateTrue 이므로 두번째 출력값이 마지막 은닉 상태, 세번째 출력값이 마지막 셀 상태인 것은 변함없지만 return_sequencesTrue 이므로 첫번째 출력값은 모든 시점의 은닉 상태가 출력됩니다.


6. Bidirectional(LSTM) 이해하기

양방향 LSTM 의 출력값을 확인해보겠습니다. return_sequencesTrue 인 경우와 False 인 경우에 대해서 은닉 상태의 값이 어떻게 바뀌는지 직접 비교하기 위해서 이번에는 출력되는 은닉 상태의 값을 고정시켜주겠습니다.

k_init = tf.keras.initializers.Constant(value=0.1)
b_init = tf.keras.initializers.Constant(value=0)
r_init = tf.keras.initializers.Constant(value=0.1)


우선 return_sequencesFalse 이고, return_stateTrue 인 경우입니다.

bilstm = Bidirectional(LSTM(3, return_sequences=False, return_state=True, \
                            kernel_initializer=k_init, bias_initializer=b_init, recurrent_initializer=r_init))
hidden_states, forward_h, forward_c, backward_h, backward_c = bilstm(train_X)

print('hidden states : {}, shape: {}'.format(hidden_states, hidden_states.shape))
print('forward state : {}, shape: {}'.format(forward_h, forward_h.shape))
print('backward state : {}, shape: {}'.format(backward_h, backward_h.shape))
[output]
hidden states : [[0.6301636 0.6301636 0.6301636 0.7037439 0.7037439 0.7037439]], shape: (1, 6)
forward state : [[0.6301636 0.6301636 0.6301636]], shape: (1, 3)
backward state : [[0.7037439 0.7037439 0.7037439]], shape: (1, 3)


이번에는 무려 5개의 값을 반환합니다. return_stateTrue 인 경우에는 정방향 LSTM의 은닉 상태와 셀 상태, 역방향 LSTM의 은닉 상태와 셀 상태 4가지를 반환하기 때문입니다. 다만, 셀 상태는 각각 forward_c와 backward_c에 저장만 하고 출력하지 않았습니다.

첫번째 출력값의 크기가 (1, 6) 인 것에 주목합시다. 이는 return_sequencesFalse 인 경우 정방향 LSTM의 마지막 시점의 은닉 상태와 역방향 LSTM의 첫번째 시점의 은닉 상태가 연결된 채 반환 되기 때문입니다. 그림으로 표현하면 아래와 같이 연결되어 다음층에서 사용됩니다.

image


마찬가지로 return_stateTrue 인 경우에 반환한 은닉 상태의 값인 forward_h와 backward_h는 각각 정방향 LSTM의 마지막 시점의 은닉 상태와 역방향 LSTM의 첫번째 시점의 은닉 상태값 입니다. 그리고 이 두 값을 연결한 값이 hidden_states에 출력되는 값 입니다.

정방향 LSTM의 마지막 시점의 은닉 상태값과 역방향 LSTM의 첫번째 은닉 상태값을 기억해둡시다.

  • 정방향 LSTM의 마지막 시점의 은닉 상태값 : [0.6303139 0.6303139 0.6303139]
  • 역방향 LSTM의 첫번째 시점의 은닉 상태값 : [0.70387346 0.70387346 0.70387346]


현재 은닉 상태의 값을 고정시켜두었기 때문에 return_sequencesTrue 로 할 경우, 출력이 어떻게 바뀌는지 비교가 가능합니다.

bilstm = Bidirectional(LSTM(3, return_sequences=True, return_state=True, \
                            kernel_initializer=k_init, bias_initializer=b_init, recurrent_initializer=r_init))
hidden_states, forward_h, forward_c, backward_h, backward_c = bilstm(train_X)

print('hidden states : \n{} shape: {}'.format(hidden_states, hidden_states.shape))
print('forward state : {} shape: {}'.format(forward_h, forward_h.shape))
print('backward state : {} shape: {}'.format(backward_h, backward_h.shape))
[output]
hidden states : 
[[[0.35896602 0.35896602 0.35896602 0.7037439  0.7037439  0.7037439 ]
  [0.5509713  0.5509713  0.5509713  0.5884772  0.5884772  0.5884772 ]
  [0.5910032  0.5910032  0.5910032  0.39501813 0.39501813 0.39501813]
  [0.6301636  0.6301636  0.6301636  0.21932526 0.21932526 0.21932526]]] shape: (1, 4, 6)
forward state : [[0.6301636 0.6301636 0.6301636]] shape: (1, 3)
backward state : [[0.7037439 0.7037439 0.7037439]] shape: (1, 3)


hidden states 의 출력값에서는 이제 모든 시점의 은닉 상태가 출력됩니다. 역방향 LSTM의 첫번째 시점의 은닉 상태는 더 이상 정방향 LSTM의 마지막 시점의 은닉 상태와 연결되는 것이 아니라 정방향 LSTM의 첫번째 시점의 은닉 상태와 연결됩니다.

그림으로 표현하면 다음과 같이 연결되어 다음층의 입력으로 사용됩니다.

image

Read more

순환 신경망(RNN) 개념 이해하기

|

  1. 순환 신경망(Recurrent Neural Network, RNN)
  2. RNN에 대한 수식 정의
  3. 케라스(Keras)로 RNN 구현하기
  4. 깊은 순환 신경망(Deep Recurrent Neural Network)
  5. 양방향 순환 신경망(Bidirectional Recurrent Neural Network)


1. 순환 신경망(Recurrent Neural Network, RNN)

RNN(Recurrent Neural Network)입력과 출력을 시퀀스 단위로 처리 하는 시퀀스(Sequence) 모델 입니다. 시퀀스들을 처리하기 위해 고안된 모델들을 시퀀스 모델이라고 하며, RNN은 가장 기본적인 인공신경망 시퀀스 모델입니다.

피드 포워드 신경망(Feed Forward Neural Network) 은 전부 은닉층에서 활성화 함수를 지난 값을 오직 출력층 방향으로만 향했습니다. 하지만 RNN(Recurrent Neural Network)은닉층의 노드에서 활성화 함수를 통해 나온 결과값을 출력층 방향으로도 보내면서, 다시 은닉층 노드의 다음 계산의 입력으로 보내는 특징 을 갖고있습니다.

image


  • $t$ : 현재 시점
  • $x$ : 입력층의 입력 벡터
  • $y$ : 출력층의 출력 벡터
  • 셀(cell)(메모리 셀, RNN 셀)
    • RNN에서, 은닉층에서 활성화 함수를 통해 결과를 내보내는 역할을 하는 노드
    • 이전의 값을 기억하려고 하는, 일종의 메모리 역할을 수행


은닉층의 메모리 셀 은 각각의 시점(time step) 에서 입력층의 입력 벡터바로 이전 시점에서의 은닉층의 메모리 셀에서 나온 값을 자신의 입력으로 사용 하는 재귀적 활동 을 하고 있습니다. 이는 현재 시점 $t$ 에서의 메모리셀이 갖고있는 값은 과거의 메모리 셀들의 값에 영향을 받은 것임을 의미 합니다.

이때 메모리 셀이 출력층 방향 또는 다음 시점인 $t+1$ 의 자신에게 보내는 값은닉 상태(hidden state) 라고 합니다. 다시말해 $t$ 시점의 메모리 셀은 $t‐1$ 시점의 메모리 셀이 보낸 은닉 상태값을 $t$ 시점의 은닉 상태 계산을 위한 입력값으로 사용합니다.

image


위 그림은 동일한 그림으로 단지 사이클을 그리는 화살표를 사용하여 표현하였느냐, 시점의 흐름에 따라서 표현하였느냐의 차이일 뿐 둘 다 동일한 RNN 을 표현하고 있습니다.

RNN 에서는 뉴런이라는 단위보다는 입력층과 출력층에서는 각각 입력 벡터출력 벡터, 은닉층에서는 은닉 상태 라는 표현을 주로 사용합니다. 피드 포워드 신경망과의 차이를 비교하기 위해서 RNN 을 뉴런 단위로 시각화해보겠습니다.

image


RNN(순환 신경망)은 은닉층에 사이클이 존재하는 신경망이라는 점이 FFNN(순방향 신경망)과 다릅니다.

image


위의 그림은 입력 벡터의 차원이 4, 은닉 상태의 크기가 2, 출력층의 출력 벡터의 차원이 2인 RNN의 시점이 2 일 때의 모습을 보여줍니다. 다시 말해 뉴런 단위로 해석하면 입력층의 뉴런수는 4, 은닉층의 뉴런 수는 2, 출력층의 뉴런 수는 2입니다.

image


RNN은 입력과 출력의 길이를 다르게 설계 할 수 있으므로 다양한 용도로 사용할 수 있습니다. 위 그림은 입력과 출력의 길이에 따라서 달라지는 RNN의 다양한 형태 를 보여줍니다. RNN 셀의 각 시점의 가장 보편적인 입출력의 단위‘단어 벡터’ 입니다. 위 구조가 자연어 처리에서 어떻게 사용될 수 있는지 예를 들어보겠습니다.

  • 일 대 다(one‐to‐many)
    • 하나의 입력에 대해서 여러개의 출력을 의미
    • 이미지 캡셔닝(Image Captioning) : 하나의 이미지 입력에 대해서 사진의 제목을 출력. 사진의 제목은 단어들의 나열이므로 시퀀스 출력입니다.
  • 다 대 일(many‐to‐one)
    • 단어 시퀀스에 대해서 하나의 출력을 의미
    • 감성 분류(sentiment classification) : 입력 문서가 긍정적인지 부정적인지를 판별
    • 스팸 메일 분류(spam detection) : 메일이 정상 메일인지 스팸 메일인지 판별
  • 다 대 다(many‐to‐many)
    • 챗봇 : 사용자가 문장을 입력하면 대답 문장을 출력을 의미
    • 번역기 : 입력 문장으로부터 번역된 문장을 출력
    • 태깅 작업 : 개체명 인식이나 품사 태깅과 같은 작업


2. RNN에 대한 수식 정의

image


현재 시점 $t$ 에서의 은닉 상태값을 $h_t$ 라고 정의하겠습니다. 은닉층의 메모리 셀은 $h_t$ 를 계산하기 위해서 총 두 개의 가중치 를 가집니다. 하나는 입력층을 위한 가중치 $W_x$ 이고, 하나는 이전 시점 $t‐1$ 의 은닉 상태값인 $h_{t−1}$ 을 위한 가중치 $W_h$ 입니다. 이를 식으로 표현하면 다음과 같습니다.

  • 은닉층 : $h_t = tanh(W_x x_t + W_h h_{t−1} + b)$
  • 출력층 : $y_t = f(W_y h_t + b)$
  • $f$ : 비선형 활성화 함수


자연어 처리에서 RNN의 입력 $x_t$ 는 대부분의 경우 단어 벡터로 간주 할 수 있는데, 단어 벡터의 차원$d$ 라고 하고, 은닉 상태의 크기$D_h$ 라고 하였을 때 각 벡터와 행렬의 크기는 다음과 같습니다.

  • $x_t$
    • RNN의 입력, 대부분의 경우 단어 벡터로 간주
    • $d × 1$
  • $W_x$
    • 입력층을 위한 가중치
    • $D_h × d$
  • $W_h$
    • 이전 시점 $t‐1$ 의 은닉 상태값인 $h_{t−1}$ 을 위한 가중치
    • $D_h × D_h$
  • $h_{t−1}$
    • 이전 시점 $t-1$ 에서의 은닉 상태값
    • $D_h × 1$
  • $b$
    • $D_h × 1$
  • $h_t$
    • 현재 시점 $t$ 에서의 은닉 상태값
    • $D_h × 1$


배치 크기가 1이고, $d$ 와 $D_h$ 두 값 모두를 4로 가정하였을때, RNN의 은닉층 연산을 그림으로 표현하면 다음과 같습니다.

image


이때 $h_t$ 를 계산하기 위한 활성화 함수로는 주로 tanh 가 사용됩니다. 위의 식에서 각각의 가중치 $W_x$, $W_h$, $W_y$ 의 값은 하나의 층에서는 모든 시점에서 값을 동일하게 공유 합니다. 하지만 은닉층이 2개 이상일 경우에는 각 은닉층에서의 가중치는 서로 다릅니다.

출력층은 결과값인 $y_t$ 를 계산하기 위한 활성화 함수로는 푸는 문제에 따라서 달라지는데, 예를 들어 이진 분류시 출력층에 시그모이드 함수, 다중 클래스 분류시 출력층에 소프트맥스 함수를 사용할 수 있습니다.


3. 케라스(Keras)로 RNN 구현하기

from tensorflow.keras.layers import SimpleRNN

# RNN층 추가
model.add(SimpleRNN(hidden_units))

# 추가 인자를 사용할때
model.add(SimpleRNN(hidden_units, input_shape=(timesteps, input_dim)))

# 다른 표기
model.add(SimpleRNN(hidden_units, input_length=M, input_dim=N))


image


RNN 층은 (batch_size, timesteps, input_dim) 크기의 3D 텐서를 입력으로 받습니다.

  • hidden_units : $D_h$
    • 은닉 상태의 크기를 정의. 메모리 셀의 용량.
    • 메모리 셀이 다음 시점의 메모리 셀과 출력층으로 보내는 값의 크기(output_dim)와 동일.
    • RNN의 용량(capacity)을 늘린다고 보면 되며, 중소형 모델의 경우 보통 128, 256, 512, 1024 등의 값을 가짐.
  • timesteps
    • 입력 시퀀스의 길이(input_length) 라고 표현.
    • 시점의 수. NLP에서는 보통 문장의 길이가 된다.
  • input_dim : $d$
    • NLP에서는 보통 단어 벡터의 차원이 된다.
    • 입력의 크기.


위 코드는 주로 은닉층으로 간주할 수 있는 하나의 RNN 층에 대한 코드로, 해당 코드가 리턴하는 결과값은 하나의 은닉 상태 또는 정의하기에 따라 여러 개의 시점의 은닉 상태 입니다. 아래의 그림은 전결합층(Fully‐connected layer)을 출력층으로 사용하였을 경우의 인공 신경망 그림과 은닉층까지만 표현한 그림의 차이를 보여줍니다.

image


RNN 층은 3D 텐서를 입력받아 사용자의 설정에 따라 두 가지 종류의 출력을 내보냅니다. 메모리 셀의 최종 시점의 은닉 상태만을 리턴 하고자 한다면 (batch_size, output_dim) 크기의 2D 텐서를 리턴 합니다. output_dim 은 앞서 코드에서 정의한 hidden_units 의 값으로 설정 됩니다.

하지만, 메모리 셀의 각 시점(time step) 의 은닉 상태값들을 모아서 전체 시퀀스를 리턴 하고자 한다면 (batch_size, timesteps, output_dim) 크기의 3D 텐서를 리턴 합니다. 이는 RNN 층의 return_sequences 매개 변수에 True 를 설정 하여 설정이 가능합니다.

image


위의 그림은 time step=3 일 때, return_sequences=True 를 설정했을 때와 그렇지 않았을 때 어떤 차이가 있는지를 보여줍니다. return_sequences=True 를 선택하면 메모리 셀이 모든 시점(time step)에 대해서 은닉 상태값을 출력하며, return_sequences=False 로 선택할 경우에는 메모리 셀은 하나의 은닉 상태값만을 출력합니다. 그리고 이 하나의 값은 마지막 시점(time step)의 메모리 셀의 은닉 상태값입니다.

실습을 통해 모델 내부적으로 출력 결과를 어떻게 정의하는지 이해해봅시다.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import SimpleRNN

model = Sequential()
model.add(SimpleRNN(3, input_shape=(2, 10)))
# model.add(SimpleRNN(3, input_length=2, input_dim=10))와 동일
model.summary()
[output]
Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 simple_rnn (SimpleRNN)      (None, 3)                 42        
                                                                 
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
_________________________________________________________________


출력값이 (batch_size, output_dim) 크기의 2D 텐서일 때, output_dimhidden_units 의 값인 3입니다. 이 경우 batch_size 를 현 단계에서는 알 수 없으므로 (None, 3) 이 됩니다.

위의 예제의 경우 $D_h = 3$, $t = 2$(RNN의 특성상 모든 시점에 히든 스테이트를 공유하므로, time은 변수의 개수에 관계없음), $d = 10$ 이므로, 아래 계산과정으로 파라미터의 수를 카운팅할 수 있습니다. params = (Dh * Dh) + (Dh * d) + (Dh) = (3 * 3) + (3 * 10) + (3) = 42.


이번에는 batch_size 를 미리 정의해보겠습니다.

model = Sequential()
model.add(SimpleRNN(3, batch_input_shape=(8, 2, 10)))
model.summary()
[output]
Model: "sequential_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 simple_rnn_1 (SimpleRNN)    (8, 3)                    42        
                                                                 
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
_________________________________________________________________


batch_size 를 8로 기재하면 출력의 크기가 (8, 3) 이 됩니다. return_sequences 매개 변수에 True 를 기재 하여 출력값으로 (batch_size, timesteps, output_dim) 크기의 3D 텐서를 리턴하도록 모델을 만들어 보겠습니다.

model = Sequential()
model.add(SimpleRNN(3, batch_input_shape=(8, 2, 10), return_sequences=True))
model.summary()
[output]
Model: "sequential_2"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 simple_rnn_2 (SimpleRNN)    (8, 2, 3)                 42        
                                                                 
=================================================================
Total params: 42
Trainable params: 42
Non-trainable params: 0
_________________________________________________________________


출력의 크기가 (8, 2, 3) 이 됩니다.


4. 깊은 순환 신경망(Deep Recurrent Neural Network)

image


RNN도 다수의 은닉층을 가질수 있으며, 위 그림은 순환 신경망에서 은닉층이 1개 더 추가되어 은닉층이 2개인 깊은(deep) 순환 신경망 의 모습을 보여줍니다. 은닉층을 2개 추가하는 경우 코드는 아래와 같습니다.

hidden_size = 8

model = Sequential()
model.add(SimpleRNN(hidden_size, input_length=10, input_dim=5, return_sequences=True))
model.add(SimpleRNN(hidden_size, return_sequences=True))
model.summary()
[output]
Model: "sequential_3"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 simple_rnn_3 (SimpleRNN)    (None, 10, 8)             112       
                                                                 
 simple_rnn_4 (SimpleRNN)    (None, 10, 8)             136       
                                                                 
=================================================================
Total params: 248
Trainable params: 248
Non-trainable params: 0
_________________________________________________________________


첫번째 은닉층은 다음 은닉층이 존재하므로 return_sequences=True 를 설정하여 모든 시점에 대해서 은닉 상태 값을 다음 은닉층으로 보내주고 있습니다.


5. 양방향 순환 신경망(Bidirectional Recurrent Neural Network)

양방향 순환 신경망시점 $t$ 에서의 출력값을 예측할때 이전 시점의 입력뿐만 아니라, 이후 시점의 입력 또한 예측에 기여할 수 있다는 아이디어에 기반 합니다. 빈칸 채우기 문제에 비유하여 보겠습니다.

운동을 열심히 하는 것은[ ]을 늘리는데 효과적이다.

1) 근 육
2) 지 방
3) 스 트 레 스


‘운동을 열심히 하는 것은 [ ] 을 늘리는데 효과적이다.’ 라는 문장에서 문맥 상으로 정답은 ‘근육’ 입니다. 위의 빈칸 채우기 문제를 풀 때 이전에 나온 단어들만으로 빈칸을 채우려고 시도해보면 정보가 부족합니다.

RNN이 풀고자 하는 문제 중에서는 과거 시점의 입력 뿐만 아니라 미래 시점의 입력에 힌트가 있는 경우도 많습니다. 그래서 이전과 이후의 시점 모두를 고려해서 현재 시점의 예측을 더욱 정확하게 할 수 있도록 고안된 것양방향 RNN 입니다.

image


양방향 RNN은 하나의 출력값을 예측하기 위해 기본적으로 두 개의 메모리 셀을 사용 합니다. 첫번째 메모리 셀앞 시점의 은닉 상태(Forward States) 를 전달받아 현재의 은닉 상태를 계산 합니다. 위의 그림에서는 주황색 메모리 셀에 해당됩니다. 두번째 메모리 셀 은 앞 시점의 은닉 상태가 아니라 뒤 시점의 은닉 상태(Backward States) 를 전달받아 현재의 은닉 상태를 계산 합니다. 입력 시퀀스를 반대 방향으로 읽는 것입니다. 위의 그림에서는 초록색 메모리 셀에 해 당됩니다. 그리고 이 두 개의 값 모두가 현재 시점의 출력층에서 출력값을 예측하기 위해 사용됩니다.

from tensorflow.keras.layers import Bidirectional

hidden_size = 8
timesteps = 10
input_dim = 5

model = Sequential()
model.add(Bidirectional(SimpleRNN(hidden_size, return_sequences=True), input_shape=(timesteps, input_dim)))
model.summary()
[output]
Model: "sequential_4"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 bidirectional (Bidirectiona  (None, 10, 16)           224       
 l)                                                              
                                                                 
=================================================================
Total params: 224
Trainable params: 224
Non-trainable params: 0
_________________________________________________________________


양방향 RNN도 다수의 은닉층을 가질 수 있습니다. 아래의 그림은 양방향 순환 신경망에서 은닉층이 1개 더 추가되어 은닉층이 2개인 깊은(deep) 양방향 순환 신경망 의 모습을 보여줍니다.

image


다른 인공 신경망 모델들도 마찬가지이지만, 은닉층을 무조건 추가한다고 해서 모델의 성능이 좋아지는 것은 아닙니다. 은닉층을 추가하면 학습할 수 있는 양이 많아지지만 반대로 훈련데이터 또한 많은 양이 필요합니다.

Read more

영화리뷰 텍스트 감성분석(sentiment analysis)하기

|

자연어 처리에 사용되는 기본 아키텍처인 RNN(Recurrent Neural Network) 과 컴퓨터 비전에서 주로 사용하는 CNN(Convolutional Neural Network) 구조를 학습하고 이를 활용하여 IMDB 영화 리뷰 평점 데이터를 토대로 영화리뷰에 대한 감성분석(sentiment analysis) 를 진행해 보도록 하겠습니다.

  1. 텍스트 데이터의 특징
  2. 텍스트 데이터의 특징 (1) 텍스트를 숫자로 표현하는 방법
  3. 텍스트 데이터의 특징 (2) Embedding 레이어의 등장
  4. 시퀀스 데이터를 다루는 RNN
  5. 1-D Convolution Neural Network
  6. IMDB 영화리뷰 감성분석 (1) IMDB 데이터셋 분석
  7. IMDB 영화리뷰 감성분석 (2) 딥러닝 모델 설계와 훈련
  8. IMDB 영화리뷰 감성분석 (3) Word2Vec의 적용



1. 텍스트 데이터의 특징

인공지능 모델을 입력과 출력이 정해진 함수라고 생각해 봅시다. 예를 들어 MNIST 숫자 분류기 모델이라면 이미지 파일을 읽어 들인 매트릭스가 입력이 되고, 이미지 파일에 쓰여 있는 실제 숫자 값이 출력이 되는 함수가 될 것입니다.

이제 텍스트 문장을 입력으로 받아서 그 의미가 긍정이면 1, 부정이면 0을 출력하는 인공지능 모델을 만든다고 생각해 봅시다. 이 모델을 만들기 위해서는 숫자 분류기를 만들 때는 생각할 필요가 없었던 2가지 문제가 생깁니다.

  • 텍스트를 어떻게 숫자 행렬로 표현할 수 있을까?
  • 텍스트에는 순서가 중요한데, 입력 데이터의 순서를 인공지능 모델에 어떻게 반영해야 하는가?


인공지능 모델의 입력이 될 수 있는 것은 0과 1의 비트로 표현 가능한 숫자만으로 이루어진 매트릭스일 뿐입니다. 아주 단순히, A=0, B=1, …, Z=25 라고 숫자를 임의로 부여한다고 해봅시다. 그러면 의미적으로 A와 B는 1만큼 멀고, A와 Z는 25만큼 멀까요? 그렇지 않습니다. 텍스트의 중요한 특징은 그 자체로는 기호일 뿐이며, 텍스트가 내포하는 의미를 기호가 직접 내포하지 않는다는 점입니다.


2. 텍스트 데이터의 특징 (1) 텍스트를 숫자로 표현하는 방법

우선 단어 사전을 만들어 볼 수는 있습니다. 우리가 사용하는 국어, 영어 사전에는 단어와 그 의미 설명이 짝지어져 있습니다. 우리가 하려는 것은 단어와 그 단어의 의미를 나타내는 벡터 를 짝지어 보려고 하는 것 입니다. 그런데 그 벡터는 어디서 가져올까요? 그렇습니다. 우리는 딥러닝을 통해 그 벡터를 만들어 낼 수 있습니다.

아래와 같이 단 3개의 짧은 문장으로 이루어진 텍스트 데이터를 처리하는 간단한 예제를 생각해 보겠습니다.

i feel hungry
i eat lunch
now i feel happy


# 처리해야 할 문장을 파이썬 리스트에 옮겨 담았습니다.
sentences = ['i feel hungry', 'i eat lunch', 'now i feel happy']

# 파이썬 split() 메소드를 이용해 단어 단위로 문장을 쪼개 봅니다.
word_list = 'i feel hungry'.split()
print(word_list)
[output]
['i', 'feel', 'hungry']


텍스트 데이터로부터 사전을 만들기 위해 모든 문장을 단어 단위로 쪼갠 후에 파이썬 딕셔너리(dict) 자료구조 로 표현해 보겠습니다.

index_to_word = {}  # 빈 딕셔너리를 만들어서

# 단어들을 하나씩 채워 봅니다. 채우는 순서는 일단 임의로 하였습니다. 그러나 사실 순서는 중요하지 않습니다. 
# <BOS>, <PAD>, <UNK>는 관례적으로 딕셔너리 맨 앞에 넣어줍니다. 
index_to_word[0] = '<PAD>'  # 패딩용 단어
index_to_word[1] = '<BOS>'  # 문장의 시작지점
index_to_word[2] = '<UNK>'  # 사전에 없는(Unknown) 단어
index_to_word[3] = 'i'
index_to_word[4] = 'feel'
index_to_word[5] = 'hungry'
index_to_word[6] = 'eat'
index_to_word[7] = 'lunch'
index_to_word[8] = 'now'
index_to_word[9] = 'happy'

print(index_to_word)
[output]
{0: '<PAD>', 1: '<BOS>', 2: '<UNK>', 3: 'i', 4: 'feel', 5: 'hungry', 6: 'eat', 7: 'lunch', 8: 'now', 9: 'happy'}


단어 10개짜리 작은 딕셔너리가 만들어졌습니다. 하지만 우리가 가진 텍스트 데이터를 숫자로 바꿔 보려고 하는데, 텍스트를 숫자로 바꾸려면 위의 딕셔너리가 {텍스트:인덱스} 구조여야 합니다.

word_to_index = {word:index for index, word in index_to_word.items()}
print(word_to_index)
[output]
{'<PAD>': 0, '<BOS>': 1, '<UNK>': 2, 'i': 3, 'feel': 4, 'hungry': 5, 'eat': 6, 'lunch': 7, 'now': 8, 'happy': 9}


이 딕셔너리는 단어를 주면 그 단어의 인덱스를 반환하는 방식으로 사용할 수 있습니다.

print(word_to_index['feel'])  # 단어 'feel'은 숫자 인덱스 4로 바뀝니다.
[output]
4


이제 우리가 가진 텍스트 데이터를 숫자로 바꿔 표현, 즉 encode 해 봅시다.

# 문장 1개를 활용할 딕셔너리와 함께 주면, 단어 인덱스 리스트로 변환해 주는 함수를 만들어 봅시다.
# 단, 모든 문장은 <BOS>로 시작하는 것으로 합니다. 
def get_encoded_sentence(sentence, word_to_index):
    return [word_to_index['<BOS>']] + [word_to_index[word] if word in word_to_index else word_to_index['<UNK>'] for word in sentence.split()]

print(get_encoded_sentence('i eat lunch', word_to_index))
[output]
[1, 3, 6, 7]


get_encoded_sentence 함수를 통해 아래와 같이 매핑된 것이 확인할 수 있습니다.

  • <BOS> -> 1
  • i -> 3
  • eat -> 6
  • lunch -> 7


# 여러 개의 문장 리스트를 한꺼번에 숫자 텐서로 encode해 주는 함수입니다. 
def get_encoded_sentences(sentences, word_to_index):
    return [get_encoded_sentence(sentence, word_to_index) for sentence in sentences]

# sentences=['i feel hungry', 'i eat lunch', 'now i feel happy'] 가 아래와 같이 변환됩니다. 
encoded_sentences = get_encoded_sentences(sentences, word_to_index)
print(encoded_sentences)
[output]
[[1, 3, 4, 5], [1, 3, 6, 7], [1, 8, 3, 4, 9]]


반대로, encode 된 벡터를 decode 하여 다시 원래 텍스트 데이터로 복구할 수도 있습니다.

# 숫자 벡터로 encode된 문장을 원래대로 decode하는 함수입니다. 
def get_decoded_sentence(encoded_sentence, index_to_word):
    # [1:]를 통해 <BOS>를 제외
    return ' '.join(index_to_word[index] if index in index_to_word else '<UNK>' for index in encoded_sentence[1:])

print(get_decoded_sentence([1, 3, 4, 5], index_to_word))
[output]
i feel hungry


# 여러 개의 숫자 벡터로 encode된 문장을 한꺼번에 원래대로 decode하는 함수입니다. 
def get_decoded_sentences(encoded_sentences, index_to_word):
    return [get_decoded_sentence(encoded_sentence, index_to_word) for encoded_sentence in encoded_sentences]

# encoded_sentences=[[1, 3, 4, 5], [1, 3, 6, 7], [1, 8, 3, 4, 9]] 가 아래와 같이 변환됩니다.
print(get_decoded_sentences(encoded_sentences, index_to_word))
[output]
['i feel hungry', 'i eat lunch', 'now i feel happy']


여기서 정의된 함수들은 이후 스텝들에서 반복해서 활용됩니다.


3. 텍스트 데이터의 특징 (2) Embedding 레이어의 등장

텍스트가 숫자로 변환되어 인공지능 모델의 입력으로 사용될 수 있게 되었지만, 이것으로 충분하지는 않습니다. ‘i feel hungry’1, 3, 4, 5 로 변환되었지만 이 벡터는 텍스트에 담긴 언어의 의미와 대응되는 벡터가 아니라 임의로 부여된 단어의 순서에 불과 합니다. 우리가 하려는 것은 단어와 그 단어의 의미를 나타내는 벡터를 짝짓는 것 이었습니다. 그래서 단어의 의미를 나타내는 벡터를 훈련 가능한 파라미터로 놓고 이를 딥러닝을 통해 학습해서 최적화하게 됩니다. Tensorflow, Pytorch 등의 딥러닝 프레임워크들은 이러한 의미 벡터 파라미터를 구현한 Embedding 레이어 를 제공합니다.


자연어 처리(Natural Language Processing)분야에서 임베딩(Embedding) 은 사람이 쓰는 자연어를 기계가 이해할 수 있는 숫자형태인 vector로 바꾼 결과 혹은 그 일련의 과정 전체를 의미하며, 가장 간단한 형태의 임베딩은 단어의 빈도를 그대로 벡터로 사용하는 것입니다. 임베딩은 다른 딥러닝 모델의 입력값으로 자주 쓰이고, 품질 좋은 임베딩을 쓸수록 모델의 성능이 좋아집니다.


image


위 그림에서 word_to_index(‘great’)1918 입니다. 그러면 ‘great’ 라는 단어의 의미 공간상의 워드 벡터(word vector)는 Lookup Table 형태로 구성된 Embedding 레이어의 1919번째 벡터가 됩니다. 위 그림에서는 1.2, 0.7, 1.9, 1.5 가 됩니다.

Embedding 레이어를 활용하여 이전 스텝의 텍스트 데이터를 워드 벡터 텐서 형태로 다시 표현 해 보겠습니다.

# 아래 코드는 그대로 실행하시면 에러가 발생할 것입니다. 

import numpy as np
import tensorflow as tf
import os

vocab_size = len(word_to_index)  # 위 예시에서 딕셔너리에 포함된 단어 개수는 10
word_vector_dim = 4    # 위 그림과 같이 4차원의 워드 벡터를 가정합니다. 

embedding = tf.keras.layers.Embedding(
    input_dim=vocab_size,
    output_dim=word_vector_dim,
    mask_zero=True
)

# 숫자로 변환된 텍스트 데이터 [[1, 3, 4, 5], [1, 3, 6, 7], [1, 8, 3, 4, 9]] 에 Embedding 레이어를 적용합니다. 
raw_inputs = np.array(get_encoded_sentences(sentences, word_to_index), dtype='object')
output = embedding(raw_inputs)
print(output)
[output]
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/root/DeepLearningProject/Exploration-01/1. 영화리뷰 텍스트 감성분석하기.ipynb 셀 34 in 1
     12 # 숫자로 변환된 텍스트 데이터 [[1, 3, 4, 5], [1, 3, 6, 7], [1, 8, 3, 4, 9]] 에 Embedding 레이어를 적용합니다. 
     13 raw_inputs = np.array(get_encoded_sentences(sentences, word_to_index), dtype='object')
---> 14 output = embedding(raw_inputs)
     15 print(output)

.
.
.

ValueError: Failed to convert a NumPy array to a Tensor (Unsupported object type list).


실행해 보니 에러가 발생합니다. 왜 그럴까요? 주의해야 할 점이 있는데, Embedding 레이어의 input이 되는 문장 벡터는 그 길이가 일정 해야 합니다. raw_inputs3개 벡터의 길이 는 각각 4, 4, 5 입니다. Tensorflow에서는 tf.keras.preprocessing.sequence.pad_sequences 라는 편리한 함수를 통해 문장 벡터 뒤에 패딩(<PAD>) 을 추가하여 길이를 일정하게 맞춰주는 기능을 제공 합니다.

raw_inputs = tf.keras.preprocessing.sequence.pad_sequences(
    raw_inputs,
    value=word_to_index['<PAD>'],
    padding='post',
    maxlen=5
)
print(raw_inputs)
[output]
[[1 3 4 5 0]
 [1 3 6 7 0]
 [1 8 3 4 9]]


짧은 문장 뒤쪽이 0으로 채워지는 것을 확인할 수 있습니다. **** 가 0에 매핑되어 있다는 걸 기억하세요. 그러면 위에 시도했던 **output = embedding(raw_inputs)** 을 다시 시도해보겠습니다.

import numpy as np
import tensorflow as tf
import os

vocab_size = len(word_to_index)  # 위 예시에서 딕셔너리에 포함된 단어 개수는 10
word_vector_dim = 4    # 그림과 같이 4차원의 워드 벡터를 가정합니다.

# tf.keras.preprocessing.sequence.pad_sequences를 통해 word vector를 모두 일정 길이로 맞춰주어야 
# embedding 레이어의 input이 될 수 있음에 주의해 주세요. 
raw_inputs = np.array(get_encoded_sentences(sentences, word_to_index), dtype=object)
raw_inputs = tf.keras.preprocessing.sequence.pad_sequences(
    raw_inputs,
    value=word_to_index['<PAD>'],
    padding='post',
    maxlen=5
)

embedding = tf.keras.layers.Embedding(
    input_dim=vocab_size,
    output_dim=word_vector_dim,
    mask_zero=True
)

output = embedding(raw_inputs)
print(output)
[output]
tf.Tensor(
[[[ 0.01395203  0.01275567 -0.02401054 -0.02090124]
  [-0.04068121 -0.01742718 -0.00815885 -0.02921033]
  [ 0.01333598 -0.02777994 -0.03375378 -0.03576127]
  [ 0.03633847  0.01739961 -0.00358608 -0.04881281]
  [ 0.01803286 -0.0018087  -0.00501338 -0.00896306]]

 [[ 0.01395203  0.01275567 -0.02401054 -0.02090124]
  [-0.04068121 -0.01742718 -0.00815885 -0.02921033]
  [-0.03603647  0.04998456 -0.00863974  0.02029398]
  [-0.02421701 -0.00824345 -0.0313128  -0.03255729]
  [ 0.01803286 -0.0018087  -0.00501338 -0.00896306]]

 [[ 0.01395203  0.01275567 -0.02401054 -0.02090124]
  [-0.01857213  0.01194413  0.02710113  0.04482098]
  [-0.04068121 -0.01742718 -0.00815885 -0.02921033]
  [ 0.01333598 -0.02777994 -0.03375378 -0.03576127]
  [-0.048308    0.00933223  0.04508836 -0.04411023]]], shape=(3, 5, 4), dtype=float32)


여기서 outputshape=(3, 5, 4) 에서 3, 5, 4의 의미는 다음과 같습니다.

  • 3 : 입력문장 개수
  • 5 : 입력문장의 최대 길이
  • 4 : 워드 벡터의 차원 수


4. 시퀀스 데이터를 다루는 RNN

텍스트 데이터를 다루는 데 주로 사용되는 딥러닝 모델은 바로 Recurrent Neural Network(RNN) 입니다. RNN은 시퀀스(Sequence) 형태의 데이터를 처리하기에 최적인 모델 로 알려져 있습니다.

텍스트 데이터도 시퀀스 데이터라는 관점으로 해석할 수 있습니다만, 시퀀스 데이터의 정의에 가장 잘 어울리는 것은 음성 데이터가 아닐까 합니다. 시퀀스 데이터란 바로 입력이 시간 축을 따라 발생하는 데이터 입니다. 예를 들어 이전 스텝의 ‘i feel hungry’라는 문장을 누군가가 초당 한 단어씩, 3초에 걸쳐 이 문장을 발음했다고 합시다.

at time=0s : 듣는이의 귀에 들어온 input='i'
at time=1s : 듣는이의 귀에 들어온 input='feel'
at time=2s : 듣는이의 귀에 들어온 input='hungry'


time=1s 인 시점에서 입력으로 받은 문장은 ‘i feel’ 까지입니다. 그다음에 ‘hungry’ 가 올지, ‘happy’ 가 올지 알 수 없는 상황입니다. RNN은 그런 상황을 묘사하기에 가장 적당한 모델 구조를 가지고 있습니다. 왜냐하면 RNN 은 시간의 흐름에 따라 새롭게 들어오는 입력에 따라 변하는 현재 상태를 묘사하는 state machine 으로 설계되었기 때문입니다.

vocab_size = 10  # 어휘 사전의 크기입니다(10개의 단어)
word_vector_dim = 4  # 단어 하나를 표현하는 임베딩 벡터의 차원수입니다. 

model = tf.keras.Sequential()
model.add(tf.keras.layers.Embedding(vocab_size, word_vector_dim, input_shape=(None,)))
model.add(tf.keras.layers.LSTM(8))   # 가장 널리 쓰이는 RNN인 LSTM 레이어를 사용하였습니다. 이때 LSTM state 벡터의 차원수는 8로 하였습니다. (변경 가능)
model.add(tf.keras.layers.Dense(8, activation='relu'))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))  # 최종 출력은 긍정/부정을 나타내는 1dim 입니다.

model.summary()
[output]
Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_5 (Embedding)     (None, None, 4)           40        
                                                                 
 lstm (LSTM)                 (None, 8)                 416       
                                                                 
 dense (Dense)               (None, 8)                 72        
                                                                 
 dense_1 (Dense)             (None, 1)                 9         
                                                                 
=================================================================
Total params: 537
Trainable params: 537
Non-trainable params: 0
_________________________________________________________________


5. 1-D Convolution Neural Network

텍스트를 처리하기 위해 RNN 이 아니라 1-D Convolution Neural Network(1-D CNN) 를 사용할 수도 있습니다. 1-D CNN문장 전체를 한꺼번에 한 방향으로 길이 n짜리 필터로 스캐닝 하면서 n단어 이내에서 발견되는 특징을 추출하여 그것으로 문장을 분류하는 방식으로 사용 됩니다. 이 방식도 텍스트를 처리하는 데 RNN 못지않은 효율을 보여줍니다. 그리고 CNN 계열은 RNN 계열보다 병렬처리가 효율적이기 때문에 학습 속도도 훨씬 빠르게 진행된다는 장점이 있습니다.

vocab_size = 10  # 어휘 사전의 크기입니다(10개의 단어)
word_vector_dim = 4   # 단어 하나를 표현하는 임베딩 벡터의 차원 수입니다. 

model = tf.keras.Sequential()
model.add(tf.keras.layers.Embedding(vocab_size, word_vector_dim, input_shape=(None,)))
model.add(tf.keras.layers.Conv1D(16, 7, activation='relu'))
model.add(tf.keras.layers.MaxPooling1D(5))
model.add(tf.keras.layers.Conv1D(16, 7, activation='relu'))
model.add(tf.keras.layers.GlobalMaxPooling1D())
model.add(tf.keras.layers.Dense(8, activation='relu'))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))  # 최종 출력은 긍정/부정을 나타내는 1dim 입니다.

model.summary()
[output]
Model: "sequential_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_6 (Embedding)     (None, None, 4)           40        
                                                                 
 conv1d (Conv1D)             (None, None, 16)          464       
                                                                 
 max_pooling1d (MaxPooling1D  (None, None, 16)         0         
 )                                                               
                                                                 
 conv1d_1 (Conv1D)           (None, None, 16)          1808      
                                                                 
 global_max_pooling1d (Globa  (None, 16)               0         
 lMaxPooling1D)                                                  
                                                                 
 dense_2 (Dense)             (None, 8)                 136       
                                                                 
 dense_3 (Dense)             (None, 1)                 9         
                                                                 
=================================================================
Total params: 2,457
Trainable params: 2,457
Non-trainable params: 0
_________________________________________________________________


아주 간단히는 GlobalMaxPooling1D() 레이어 하나만 사용하는 방법도 생각해 볼 수 있습니다. 이 방식은 전체 문장 중에서 단 하나의 가장 중요한 단어만 피처로 추출하여 그것으로 문장의 긍정/부정을 평가하는 방식이라고 생각할 수 있는데, 의외로 성능이 잘 나올 수도 있습니다.

vocab_size = 10  # 어휘 사전의 크기입니다(10개의 단어)
word_vector_dim = 4   # 단어 하나를 표현하는 임베딩 벡터의 차원 수입니다. 

model = tf.keras.Sequential()
model.add(tf.keras.layers.Embedding(vocab_size, word_vector_dim, input_shape=(None,)))
model.add(tf.keras.layers.GlobalMaxPooling1D())
model.add(tf.keras.layers.Dense(8, activation='relu'))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))  # 최종 출력은 긍정/부정을 나타내는 1dim 입니다.

model.summary()
[output]
Model: "sequential_2"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_7 (Embedding)     (None, None, 4)           40        
                                                                 
 global_max_pooling1d_1 (Glo  (None, 4)                0         
 balMaxPooling1D)                                                
                                                                 
 dense_4 (Dense)             (None, 8)                 40        
                                                                 
 dense_5 (Dense)             (None, 1)                 9         
                                                                 
=================================================================
Total params: 89
Trainable params: 89
Non-trainable params: 0
_________________________________________________________________


6. IMDB 영화리뷰 감성분석 (1) IMDB 데이터셋 분석

이제 본격적으로 IMDB 영화리뷰 감성분석 태스크에 도전해 보겠습니다. IMDb Large Movie Dataset은 50000개의 영어로 작성된 영화 리뷰 텍스트로 구성되어 있으며, 긍정은 1, 부정은 0의 라벨이 달려 있습니다. 2011년 Learning Word Vectors for Sentiment Analysis 논문에서 이 데이터셋을 소개하였습니다.

50000개의 리뷰 중 절반인 25000개가 훈련용 데이터, 나머지 25000개를 테스트용 데이터로 사용하도록 지정되어 있습니다. 이 데이터셋은 tensorflow Keras 데이터셋 안에 포함되어 있어서 손쉽게 다운로드하여 사용할 수 있습니다.

imdb = tf.keras.datasets.imdb

# IMDb 데이터셋 다운로드 
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=10000)
print(f"훈련 샘플 개수: {len(x_train)}, 테스트 개수: {len(x_test)}")
[output]
훈련 샘플 개수: 25000, 테스트 개수: 25000


imdb.load_data() 호출 시 단어사전에 등재할 단어의 개수(num_words)를 10000으로 지정하면, 그 개수만큼의 word_to_index 딕셔너리까지 생성된 형태로 데이터셋이 생성됩니다. 다운로드한 데이터 실제 예시를 확인해 보겠습니다.

print(x_train[0])  # 1번째 리뷰데이터
print('라벨: ', y_train[0])  # 1번째 리뷰데이터의 라벨
print('1번째 리뷰 문장 길이: ', len(x_train[0]))
print('2번째 리뷰 문장 길이: ', len(x_train[1]))
[output]
[1, 14, 22, 16, 43, 530, 973, 1622, 1385, 65, 458, 4468, 66, 3941, 4, 173, 36, 256, 5, 25, 100, 43, 838, 112, 50, 670, 2, 9, 35, 480, 284, 5, 150, 4, 172, 112, 167, 2, 336, 385, 39, 4, 172, 4536, 1111, 17, 546, 38, 13, 447, 4, 192, 50, 16, 6, 147, 2025, 19, 14, 22, 4, 1920, 4613, 469, 4, 22, 71, 87, 12, 16, 43, 530, 38, 76, 15, 13, 1247, 4, 22, 17, 515, 17, 12, 16, 626, 18, 2, 5, 62, 386, 12, 8, 316, 8, 106, 5, 4, 2223, 5244, 16, 480, 66, 3785, 33, 4, 130, 12, 16, 38, 619, 5, 25, 124, 51, 36, 135, 48, 25, 1415, 33, 6, 22, 12, 215, 28, 77, 52, 5, 14, 407, 16, 82, 2, 8, 4, 107, 117, 5952, 15, 256, 4, 2, 7, 3766, 5, 723, 36, 71, 43, 530, 476, 26, 400, 317, 46, 7, 4, 2, 1029, 13, 104, 88, 4, 381, 15, 297, 98, 32, 2071, 56, 26, 141, 6, 194, 7486, 18, 4, 226, 22, 21, 134, 476, 26, 480, 5, 144, 30, 5535, 18, 51, 36, 28, 224, 92, 25, 104, 4, 226, 65, 16, 38, 1334, 88, 12, 16, 283, 5, 16, 4472, 113, 103, 32, 15, 16, 5345, 19, 178, 32]
라벨:  1
1번째 리뷰 문장 길이:  218
2번째 리뷰 문장 길이:  189


텍스트 데이터가 아니라 이미 숫자로 encode 된 텍스트 데이터를 다운로드했음을 확인할 수 있습니다. 이미 텍스트가 encode 되었으므로 IMDb 데이터셋에는 encode 에 사용한 딕셔너리까지 함께 제공합니다.

word_to_index = imdb.get_word_index()
index_to_word = {index:word for word, index in word_to_index.items()}
print(index_to_word[1])     # 'the' 가 출력됩니다. 
print(word_to_index['the']) # 1 이 출력됩니다.
[output]
the
1


여기서 주의할 점이 있습니다. IMDb 데이터셋의 텍스트 인코딩을 위한 word_to_index, index_to_word 는 보정이 필요합니다. 예를 들어 다음 코드를 실행시켜보면 보정이 되지 않은 상태라 문장이 이상함을 확인하실 겁니다.

# 보정 전 x_train[0] 데이터
print(get_decoded_sentence(x_train[0], index_to_word))
[output]
as you with out themselves powerful lets loves their becomes reaching had journalist of lot from anyone to have after out atmosphere never more room and it so heart shows to years of every never going and help moments or of every chest visual movie except her was several of enough more with is now current film as you of mine potentially unfortunately of you than him that with out themselves her get for was camp of you movie sometimes movie that with scary but and to story wonderful that in seeing in character to of 70s musicians with heart had shadows they of here that with her serious to have does when from why what have critics they is you that isn't one will very to as itself with other and in of seen over landed for anyone of and br show's to whether from than out themselves history he name half some br of and odd was two most of mean for 1 any an boat she he should is thought frog but of script you not while history he heart to real at barrel but when from one bit then have two of script their with her nobody most that with wasn't to with armed acting watch an for with heartfelt film want an


그럼 매핑 보정 작업을 해보겠습니다. word_to_index 는 IMDb 텍스트 데이터셋의 단어 출현 빈도 기준으로 내림차수 정렬되어 있습니다.

#실제 인코딩 인덱스는 제공된 word_to_index에서 index 기준으로 3씩 뒤로 밀려 있습니다.  
word_to_index = {k:(v+3) for k,v in word_to_index.items()}

# 처음 몇 개 인덱스는 사전에 정의되어 있습니다.
word_to_index["<PAD>"] = 0
word_to_index["<BOS>"] = 1
word_to_index["<UNK>"] = 2  # unknown
word_to_index["<UNUSED>"] = 3

index_to_word = {index:word for word, index in word_to_index.items()}

print(index_to_word[1])     # '<BOS>' 가 출력됩니다. 
print(word_to_index['the'])  # 4 이 출력됩니다. 
print(index_to_word[4])     # 'the' 가 출력됩니다.

# 보정 후 x_train[0] 데이터
print(get_decoded_sentence(x_train[0], index_to_word))
[output]
<BOS>
4
the
this film was just brilliant casting location scenery story direction everyone's really suited the part they played and you could just imagine being there robert <UNK> is an amazing actor and now the same being director <UNK> father came from the same scottish island as myself so i loved the fact there was a real connection with this film the witty remarks throughout the film were great it was just brilliant so much that i bought the film as soon as it was released for <UNK> and would recommend it to everyone to watch and the fly fishing was amazing really cried at the end it was so sad and you know what they say if you cry at a film it must have been good and this definitely was also <UNK> to the two little boy's that played the <UNK> of norman and paul they were just brilliant children are often left out of the <UNK> list i think because the stars that play them all grown up are such a big profile for the whole film but these children are amazing and should be praised for what they have done don't you think the whole story was so lovely because it was true and was someone's life after all that was shared with us all


다운로드한 데이터셋이 확인되었습니다. 보정 후 x_train[0] 데이터도 자연스러운 문장으로 바뀌었습니다. 마지막으로, encode 된 텍스트가 정상적으로 decode 되는지 확인해 보겠습니다.

print(get_decoded_sentence(x_train[0], index_to_word))
print('라벨: ', y_train[0])  # 1번째 리뷰데이터의 라벨
[output]
this film was just brilliant casting location scenery story direction everyone's really suited the part they played and you could just imagine being there robert <UNK> is an amazing actor and now the same being director <UNK> father came from the same scottish island as myself so i loved the fact there was a real connection with this film the witty remarks throughout the film were great it was just brilliant so much that i bought the film as soon as it was released for <UNK> and would recommend it to everyone to watch and the fly fishing was amazing really cried at the end it was so sad and you know what they say if you cry at a film it must have been good and this definitely was also <UNK> to the two little boy's that played the <UNK> of norman and paul they were just brilliant children are often left out of the <UNK> list i think because the stars that play them all grown up are such a big profile for the whole film but these children are amazing and should be praised for what they have done don't you think the whole story was so lovely because it was true and was someone's life after all that was shared with us all
라벨:  1


pad_sequences 를 통해 데이터셋 상의 문장의 길이를 통일하는 것을 잊어서는 안됩니다. 문장 최대 길이 maxlen 의 값 설정도 전체 모델 성능에 영향을 미치게 됩니다. 이 길이도 적절한 값을 찾기 위해서는 전체 데이터셋의 분포를 확인해 보는 것이 좋습니다.

total_data_text = list(x_train) + list(x_test)

# 텍스트데이터 문장길이의 리스트를 생성한 후
num_tokens = [len(tokens) for tokens in total_data_text]
num_tokens = np.array(num_tokens)

# 문장길이의 평균값, 최대값, 표준편차를 계산해 본다. 
print('문장길이 평균 : ', np.mean(num_tokens))
print('문장길이 최대 : ', np.max(num_tokens))
print('문장길이 표준편차 : ', np.std(num_tokens))

# 예를들어, 최대 길이를 (평균 + 2*표준편차)로 한다면,  
max_tokens = np.mean(num_tokens) + 2 * np.std(num_tokens)
maxlen = int(max_tokens)
print('pad_sequences maxlen : ', maxlen)
print(f'전체 문장의 {np.sum(num_tokens < max_tokens) / len(num_tokens)}%가 maxlen 설정값 이내에 포함됩니다. ')
[output]
문장길이 평균 :  234.75892
문장길이 최대 :  2494
문장길이 표준편차 :  172.91149458735703
pad_sequences maxlen :  580
전체 문장의 0.94536%가 maxlen 설정값 이내에 포함됩니다. 


위의 경우에는 maxlen=580 이 됩니다. 또 한 가지 유의해야 하는 것은 padding 방식을 문장 뒤쪽('post')과 앞쪽('pre') 중 어느 쪽으로 하느냐에 따라 RNN을 이용한 딥러닝 적용 시 성능 차이가 발생한다는 점 입니다.

RNN 활용 시 pad_sequencespadding 방식은 ‘post’‘pre’ 중 어느 것이 유리할까요? RNN 은 입력데이터가 순차적으로 처리되어, 가장 마지막 입력이 최종 state 값에 가장 영향을 많이 미치게 됩니다. 그러므로 마지막 입력이 무의미한 padding으로 채워지는 것은 비효율적 입니다. 따라서 ‘pre’가 훨씬 유리 하며, 10% 이상의 테스트 성능 차이를 보이게 됩니다.

x_train = tf.keras.preprocessing.sequence.pad_sequences(
    x_train,
    value=word_to_index["<PAD>"],
    padding='post', # 혹은 'pre'
    maxlen=maxlen
)

x_test = tf.keras.preprocessing.sequence.pad_sequences(
    x_test,
    value=word_to_index["<PAD>"],
    padding='post', # 혹은 'pre'
    maxlen=maxlen
)

print(x_train.shape)
[output]
(25000, 580)


7. IMDB 영화리뷰 감성분석 (2) 딥러닝 모델 설계와 훈련

model 훈련 전에, 훈련용 데이터셋 25000건 중 10000건을 분리하여 검증셋(validation set) 으로 사용하도록 합니다. 적절한 validation 데이터는 몇 개가 좋을지 고민해 봅시다.

# validation set 10000건 분리
x_val = x_train[:10000]   
y_val = y_train[:10000]

# validation set을 제외한 나머지 15000건
partial_x_train = x_train[10000:]  
partial_y_train = y_train[10000:]

print(x_val.shape)
print(y_val.shape)
print(partial_x_train.shape)
print(partial_y_train.shape)
[output]
(10000, 580)
(10000,)
(15000, 580)
(15000,)


RNN 모델을 직접 설계해 보겠습니다. 참고로 여러가지 모델을 사용할 수 있습니다.

vocab_size = 10000    # 어휘 사전의 크기입니다(10,000개의 단어)
word_vector_dim = 16  # 워드 벡터의 차원 수 (변경 가능한 하이퍼파라미터)

# model 설계 - 딥러닝 모델 코드를 직접 작성해 주세요.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Embedding(vocab_size, word_vector_dim, input_shape=(None,)))
model.add(tf.keras.layers.LSTM(8))   # 가장 널리 쓰이는 RNN인 LSTM 레이어를 사용하였습니다. 이때 LSTM state 벡터의 차원수는 8로 하였습니다. (변경 가능)
model.add(tf.keras.layers.Dense(8, activation='relu'))
model.add(tf.keras.layers.Dense(1, activation='sigmoid'))  # 최종 출력은 긍정/부정을 나타내는 1dim 입니다.

model.summary()
[output]
Model: "sequential_3"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_4 (Embedding)     (None, None, 16)          160000    
                                                                 
 lstm_1 (LSTM)               (None, 8)                 800       
                                                                 
 dense_6 (Dense)             (None, 8)                 72        
                                                                 
 dense_7 (Dense)             (None, 1)                 9         
                                                                 
=================================================================
Total params: 160,881
Trainable params: 160,881
Non-trainable params: 0
_________________________________________________________________


model 학습을 시작해 봅시다.

model.compile(
    optimizer='adam',
    loss='binary_crossentropy',
    metrics=['accuracy']
)
              
epochs=20  # 몇 epoch를 훈련하면 좋을지 결과를 보면서 바꾸어 봅시다. 
history = model.fit(
    partial_x_train,
    partial_y_train,
    epochs=epochs,
    batch_size=512,
    validation_data=(x_val, y_val),
    verbose=1
)
[output]
Epoch 1/20
30/30 [==============================] - 3s 34ms/step - loss: 0.6931 - accuracy: 0.5082 - val_loss: 0.6932 - val_accuracy: 0.5019
Epoch 2/20
30/30 [==============================] - 1s 28ms/step - loss: 0.6926 - accuracy: 0.5107 - val_loss: 0.6928 - val_accuracy: 0.5010
Epoch 3/20
30/30 [==============================] - 1s 30ms/step - loss: 0.6918 - accuracy: 0.5015 - val_loss: 0.6923 - val_accuracy: 0.5026
Epoch 4/20
30/30 [==============================] - 1s 29ms/step - loss: 0.6875 - accuracy: 0.5169 - val_loss: 0.6954 - val_accuracy: 0.5043
Epoch 5/20
30/30 [==============================] - 1s 28ms/step - loss: 0.6793 - accuracy: 0.5285 - val_loss: 0.6885 - val_accuracy: 0.5084
Epoch 6/20
30/30 [==============================] - 1s 26ms/step - loss: 0.6741 - accuracy: 0.5335 - val_loss: 0.6910 - val_accuracy: 0.5074
Epoch 7/20
30/30 [==============================] - 1s 32ms/step - loss: 0.6734 - accuracy: 0.5317 - val_loss: 0.6915 - val_accuracy: 0.5067
Epoch 8/20
30/30 [==============================] - 1s 30ms/step - loss: 0.6707 - accuracy: 0.5365 - val_loss: 0.6957 - val_accuracy: 0.5068
Epoch 9/20
30/30 [==============================] - 1s 29ms/step - loss: 0.6687 - accuracy: 0.5377 - val_loss: 0.6981 - val_accuracy: 0.5070
Epoch 10/20
30/30 [==============================] - 1s 28ms/step - loss: 0.6669 - accuracy: 0.5381 - val_loss: 0.6977 - val_accuracy: 0.5084
Epoch 11/20
30/30 [==============================] - 1s 26ms/step - loss: 0.6649 - accuracy: 0.5388 - val_loss: 0.7020 - val_accuracy: 0.5076
Epoch 12/20
30/30 [==============================] - 1s 28ms/step - loss: 0.6624 - accuracy: 0.5389 - val_loss: 0.7012 - val_accuracy: 0.5093
Epoch 13/20
30/30 [==============================] - 1s 31ms/step - loss: 0.6597 - accuracy: 0.5389 - val_loss: 0.6996 - val_accuracy: 0.5106
Epoch 14/20
30/30 [==============================] - 1s 31ms/step - loss: 0.6573 - accuracy: 0.5391 - val_loss: 0.7016 - val_accuracy: 0.5117
Epoch 15/20
30/30 [==============================] - 1s 33ms/step - loss: 0.6558 - accuracy: 0.5388 - val_loss: 0.7139 - val_accuracy: 0.5102
Epoch 16/20
30/30 [==============================] - 1s 27ms/step - loss: 0.6540 - accuracy: 0.5390 - val_loss: 0.7021 - val_accuracy: 0.5127
Epoch 17/20
30/30 [==============================] - 1s 26ms/step - loss: 0.6527 - accuracy: 0.5395 - val_loss: 0.6975 - val_accuracy: 0.5131
Epoch 18/20
30/30 [==============================] - 1s 33ms/step - loss: 0.6519 - accuracy: 0.5392 - val_loss: 0.7030 - val_accuracy: 0.5134
Epoch 19/20
30/30 [==============================] - 1s 27ms/step - loss: 0.6516 - accuracy: 0.5317 - val_loss: 0.7096 - val_accuracy: 0.5130
Epoch 20/20
30/30 [==============================] - 1s 30ms/step - loss: 0.6507 - accuracy: 0.5402 - val_loss: 0.7019 - val_accuracy: 0.5136


학습이 끝난 모델을 테스트셋으로 평가해 봅니다.

results = model.evaluate(x_test, y_test, verbose=2)

print(results)
[output]
782/782 - 8s - loss: 0.6969 - accuracy: 0.5182 - 8s/epoch - 10ms/step
[0.6969093084335327, 0.5182399749755859]


model.fit() 과정 중의 train/validation loss, accuracy 등이 매 epoch 마다 history 변수에 저장되어 있습니다.

이 데이터를 그래프로 그려 보면, 수행했던 딥러닝 학습이 잘 진행되었는지, 오버피팅 혹은 언더피팅하지 않았는지, 성능을 개선할 수 있는 다양한 아이디어를 얻을 수 있는 좋은 자료가 됩니다.

history_dict = history.history
print(history_dict.keys()) # epoch에 따른 그래프를 그려볼 수 있는 항목들
[output]
dict_keys(['loss', 'accuracy', 'val_loss', 'val_accuracy'])


import matplotlib.pyplot as plt

acc = history_dict['accuracy']
val_acc = history_dict['val_accuracy']
loss = history_dict['loss']
val_loss = history_dict['val_loss']

epochs = range(1, len(acc) + 1)

# "bo"는 "파란색 점"입니다
plt.plot(epochs, loss, 'bo', label='Training loss')
# b는 "파란 실선"입니다
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()

plt.show()

image


Training and validation loss 를 그려 보면, 몇 epoch 까지의 트레이닝이 적절한지 최적점을 추정해 볼 수 있습니다. validation loss 의 그래프가 train loss 와의 이격이 발생하게 되면 더 이상의 트레이닝은 무의미해지게 마련입니다.

plt.clf()   # 그림을 초기화합니다

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()

plt.show()

image


마찬가지로 Training and validation accuracy 를 그려 보아도 유사한 인사이트를 얻을 수 있습니다.


8. IMDB 영화리뷰 감성분석 (3) Word2Vec의 적용

이전 스텝에서 라벨링 비용이 많이 드는 머신러닝 기반 감성분석의 비용을 절감하면서 정확도를 크게 향상시킬 수 있는 자연어처리 기법으로 단어의 특성을 저차원 벡터값으로 표현할 수 있는 워드 임베딩(word embedding) 기법이 있다는 언급을 한 바 있습니다.

우리는 이미 이전 스텝에서 워드 임베딩을 사용했습니다. 사용했던 model 의 첫 번째 레이어는 바로 Embedding 레이어였습니다. 이 레이어는 우리가 가진 사전의 단어 개수 × 워드 벡터 사이즈 만큼의 크기를 가진 학습 파라미터였습니다. 만약 우리의 감성 분류 모델이 학습이 잘 되었다면, Embedding 레이어에 학습된 우리의 워드 벡터들도 의미 공간상에 유의미한 형태로 학습되었을 것입니다. 한번 확인해 봅시다.

embedding_layer = model.layers[0]
weights = embedding_layer.get_weights()[0]
print(weights.shape)    # shape: (vocab_size, embedding_dim)
[output]
(10000, 16)


# 학습한 Embedding 파라미터를 파일에 써서 저장합니다. 
word2vec_file_path = '/data/word2vec.txt'
f = open(word2vec_file_path, 'w')
f.write('{} {}\n'.format(vocab_size-4, word_vector_dim))  # 몇개의 벡터를 얼마 사이즈로 기재할지 타이틀을 씁니다.

# 단어 개수(에서 특수문자 4개는 제외하고)만큼의 워드 벡터를 파일에 기록합니다. 
vectors = model.get_weights()[0]
for i in range(4,vocab_size):
    f.write('{} {}\n'.format(index_to_word[i], ' '.join(map(str, list(vectors[i, :])))))
f.close()
[output]


워드 벡터를 다루는데 유용한 gensim 에서 제공하는 패키지를 이용해, 위에 남긴 임베딩 파라미터를 읽어서 word vector 로 활용할 수 있습니다.

from gensim.models.keyedvectors import Word2VecKeyedVectors

word_vectors = Word2VecKeyedVectors.load_word2vec_format(word2vec_file_path, binary=False)
vector = word_vectors['computer']
vector
[output]
array([ 0.03693542,  0.09759595,  0.02630373,  0.00367015, -0.03850541,
        0.03551435,  0.00019023, -0.00949474, -0.07800166, -0.06625549,
        0.01207815, -0.0637126 ,  0.06162278, -0.05298196, -0.01577774,
       -0.04987772], dtype=float32)


위와 같이 얻은 워드 벡터를 가지고 재미있는 실험을 해볼 수 있습니다. 워드 벡터가 의미 벡터 공간상에 유의미하게 학습되었는지 확인하는 방법 중에, 단어를 하나 주고 그와 가장 유사한 단어와 그 유사도를 확인하는 방법 이 있습니다. gensim 을 사용하면 아래와 같이 해볼 수 있습니다.

word_vectors.similar_by_word("love")
[output]
[('idiocy', 0.8063594102859497),
 ('sense', 0.7779642939567566),
 ('grasp', 0.7166379690170288),
 ('pie', 0.708033561706543),
 ('weather', 0.7012685537338257),
 ('work', 0.6996005177497864),
 ('cheaper', 0.698716402053833),
 ('ranger', 0.6964582800865173),
 ('encounters', 0.6961168050765991),
 ('incidentally', 0.6926853656768799)]


어떻습니까? love 라는 단어와 유사한 다른 단어를 그리 잘 찾았다고 느껴지지는 않습니다. 감성 분류 태스크를 잠깐 학습한 것만으로 워드 벡터가 유의미하게 학습되기는 어려운 것 같습니다. 우리가 다룬 정도의 훈련 데이터로는 워드 벡터를 정교하게 학습시키기 어렵습니다.

그래서 이번에는 구글에서 제공하는 Word2Vec 이라는 사전학습된(Pretrained) 워드 임베딩 모델을 가져다 활용 해 보겠습니다. Word2Vec 은 무려 1억 개의 단어로 구성된 Google News dataset을 바탕으로 학습되었습니다. 총 300만 개의 단어를 각각 300차원의 벡터로 표현한 것입니다. Word2Vec 이 학습되는 원리에 대해서는 차후 깊이 있게 다루게 될 것입니다. 하지만 그렇게 해서 학습된 Word2Vec 이라는 것도 실은 방금 우리가 파일에 써본 Embedding Layer 와 원리는 동일합니다.


그러면 본격적으로 Google의 Word2Vec 모델을 가져와 적용해 봅시다.

from gensim.models import KeyedVectors

word2vec_path = '/data/GoogleNews-vectors-negative300.bin.gz'
word2vec = KeyedVectors.load_word2vec_format(word2vec_path, binary=True, limit=1000000)
vector = word2vec['computer']
vector     # 무려 300dim의 워드 벡터입니다.
[output]
array([ 1.07421875e-01, -2.01171875e-01,  1.23046875e-01,  2.11914062e-01,
       -9.13085938e-02,  2.16796875e-01, -1.31835938e-01,  8.30078125e-02,
        2.02148438e-01,  4.78515625e-02,  3.66210938e-02, -2.45361328e-02,
        2.39257812e-02, -1.60156250e-01, -2.61230469e-02,  9.71679688e-02,
       -6.34765625e-02,  1.84570312e-01,  1.70898438e-01, -1.63085938e-01,
       -1.09375000e-01,  1.49414062e-01, -4.65393066e-04,  9.61914062e-02,
        1.68945312e-01,  2.60925293e-03,  8.93554688e-02,  6.49414062e-02,
        3.56445312e-02, -6.93359375e-02, -1.46484375e-01, -1.21093750e-01,
       -2.27539062e-01,  2.45361328e-02, -1.24511719e-01, -3.18359375e-01,
       -2.20703125e-01,  1.30859375e-01,  3.66210938e-02, -3.63769531e-02,
       -1.13281250e-01,  1.95312500e-01,  9.76562500e-02,  1.26953125e-01,
        6.59179688e-02,  6.93359375e-02,  1.02539062e-02,  1.75781250e-01,
       -1.68945312e-01,  1.21307373e-03, -2.98828125e-01, -1.15234375e-01,
        5.66406250e-02, -1.77734375e-01, -2.08984375e-01,  1.76757812e-01,
        2.38037109e-02, -2.57812500e-01, -4.46777344e-02,  1.88476562e-01,
        5.51757812e-02,  5.02929688e-02, -1.06933594e-01,  1.89453125e-01,
       -1.16210938e-01,  8.49609375e-02, -1.71875000e-01,  2.45117188e-01,
       -1.73828125e-01, -8.30078125e-03,  4.56542969e-02, -1.61132812e-02,
        1.86523438e-01, -6.05468750e-02, -4.17480469e-02,  1.82617188e-01,
        2.20703125e-01, -1.22558594e-01, -2.55126953e-02, -3.08593750e-01,
        9.13085938e-02,  1.60156250e-01,  1.70898438e-01,  1.19628906e-01,
        7.08007812e-02, -2.64892578e-02, -3.08837891e-02,  4.06250000e-01,
       -1.01562500e-01,  5.71289062e-02, -7.26318359e-03, -9.17968750e-02,
       -1.50390625e-01, -2.55859375e-01,  2.16796875e-01, -3.63769531e-02,
        2.24609375e-01,  8.00781250e-02,  1.56250000e-01,  5.27343750e-02,
        1.50390625e-01, -1.14746094e-01, -8.64257812e-02,  1.19140625e-01,
       -7.17773438e-02,  2.73437500e-01, -1.64062500e-01,  7.29370117e-03,
        4.21875000e-01, -1.12792969e-01, -1.35742188e-01, -1.31835938e-01,
       -1.37695312e-01, -7.66601562e-02,  6.25000000e-02,  4.98046875e-02,
       -1.91406250e-01, -6.03027344e-02,  2.27539062e-01,  5.88378906e-02,
       -3.24218750e-01,  5.41992188e-02, -1.35742188e-01,  8.17871094e-03,
       -5.24902344e-02, -1.74713135e-03, -9.81445312e-02, -2.86865234e-02,
        3.61328125e-02,  2.15820312e-01,  5.98144531e-02, -3.08593750e-01,
       -2.27539062e-01,  2.61718750e-01,  9.86328125e-02, -5.07812500e-02,
        1.78222656e-02,  1.31835938e-01, -5.35156250e-01, -1.81640625e-01,
        1.38671875e-01, -3.10546875e-01, -9.71679688e-02,  1.31835938e-01,
       -1.16210938e-01,  7.03125000e-02,  2.85156250e-01,  3.51562500e-02,
       -1.01562500e-01, -3.75976562e-02,  1.41601562e-01,  1.42578125e-01,
       -5.68847656e-02,  2.65625000e-01, -2.09960938e-01,  9.64355469e-03,
       -6.68945312e-02, -4.83398438e-02, -6.10351562e-02,  2.45117188e-01,
       -9.66796875e-02,  1.78222656e-02, -1.27929688e-01, -4.78515625e-02,
       -7.26318359e-03,  1.79687500e-01,  2.78320312e-02, -2.10937500e-01,
       -1.43554688e-01, -1.27929688e-01,  1.73339844e-02, -3.60107422e-03,
       -2.04101562e-01,  3.63159180e-03, -1.19628906e-01, -6.15234375e-02,
        5.93261719e-02, -3.23486328e-03, -1.70898438e-01, -3.14941406e-02,
       -8.88671875e-02, -2.89062500e-01,  3.44238281e-02, -1.87500000e-01,
        2.94921875e-01,  1.58203125e-01, -1.19628906e-01,  7.61718750e-02,
        6.39648438e-02, -4.68750000e-02, -6.83593750e-02,  1.21459961e-02,
       -1.44531250e-01,  4.54101562e-02,  3.68652344e-02,  3.88671875e-01,
        1.45507812e-01, -2.55859375e-01, -4.46777344e-02, -1.33789062e-01,
       -1.38671875e-01,  6.59179688e-02,  1.37695312e-01,  1.14746094e-01,
        2.03125000e-01, -4.78515625e-02,  1.80664062e-02, -8.54492188e-02,
       -2.48046875e-01, -3.39843750e-01, -2.83203125e-02,  1.05468750e-01,
       -2.14843750e-01, -8.74023438e-02,  7.12890625e-02,  1.87500000e-01,
       -1.12304688e-01,  2.73437500e-01, -3.26171875e-01, -1.77734375e-01,
       -4.24804688e-02, -2.69531250e-01,  6.64062500e-02, -6.88476562e-02,
       -1.99218750e-01, -7.03125000e-02, -2.43164062e-01, -3.66210938e-02,
       -7.37304688e-02, -1.77734375e-01,  9.17968750e-02, -1.25000000e-01,
       -1.65039062e-01, -3.57421875e-01, -2.85156250e-01, -1.66992188e-01,
        1.97265625e-01, -1.53320312e-01,  2.31933594e-02,  2.06054688e-01,
        1.80664062e-01, -2.74658203e-02, -1.92382812e-01, -9.61914062e-02,
       -1.06811523e-02, -4.73632812e-02,  6.54296875e-02, -1.25732422e-02,
        1.78222656e-02, -8.00781250e-02, -2.59765625e-01,  9.37500000e-02,
       -7.81250000e-02,  4.68750000e-02, -2.22167969e-02,  1.86767578e-02,
        3.11279297e-02,  1.04980469e-02, -1.69921875e-01,  2.58789062e-02,
       -3.41796875e-02, -1.44042969e-02, -5.46875000e-02, -8.78906250e-02,
        1.96838379e-03,  2.23632812e-01, -1.36718750e-01,  1.75781250e-01,
       -1.63085938e-01,  1.87500000e-01,  3.44238281e-02, -5.63964844e-02,
       -2.27689743e-05,  4.27246094e-02,  5.81054688e-02, -1.07910156e-01,
       -3.88183594e-02, -2.69531250e-01,  3.34472656e-02,  9.81445312e-02,
        5.63964844e-02,  2.23632812e-01, -5.49316406e-02,  1.46484375e-01,
        5.93261719e-02, -2.19726562e-01,  6.39648438e-02,  1.66015625e-02,
        4.56542969e-02,  3.26171875e-01, -3.80859375e-01,  1.70898438e-01,
        5.66406250e-02, -1.04492188e-01,  1.38671875e-01, -1.57226562e-01,
        3.23486328e-03, -4.80957031e-02, -2.48046875e-01, -6.20117188e-02],
      dtype=float32)


300dim의 벡터로 이루어진 300만 개의 단어입니다. 이 단어 사전을 메모리에 모두 로딩하면 아주 높은 확률로 여러분의 실습환경에 메모리 에러가 날 것입니다. 그래서 KeyedVectors.load_word2vec_format 메서드로 워드 벡터를 로딩할 때 가장 많이 사용되는 상위 100만 개만 limt으로 조건을 주어 로딩했습니다.

메모리가 충분하다면 limt=None 으로 하시면 300만 개를 모두 로딩합니다.

# 메모리를 다소 많이 소비하는 작업이니 유의해 주세요.
word2vec.similar_by_word("love")
[output]
[('loved', 0.6907791495323181),
 ('adore', 0.6816873550415039),
 ('loves', 0.661863386631012),
 ('passion', 0.6100708842277527),
 ('hate', 0.600395679473877),
 ('loving', 0.5886635780334473),
 ('Ilove', 0.5702950954437256),
 ('affection', 0.5664337873458862),
 ('undying_love', 0.5547304749488831),
 ('absolutely_adore', 0.5536840558052063)]


어떻습니까? Word2Vec에서 제공하는 워드 임베딩 벡터들끼리는 의미적 유사도가 가까운 것이 서로 가깝게 제대로 학습된 것을 확인 할 수 있습니다. 이제 우리는 이전 스텝에서 학습했던 모델의 임베딩 레이어를 Word2Vec 의 것으로 교체하여 다시 학습시켜 볼 것입니다.

vocab_size = 10000    # 어휘 사전의 크기입니다(10,000개의 단어)
word_vector_dim = 300  # 워드 벡터의 차원수
embedding_matrix = np.random.rand(vocab_size, word_vector_dim)
print(embedding_matrix.shape)

# embedding_matrix에 Word2Vec 워드 벡터를 단어 하나씩마다 차례차례 카피한다.
for i in range(4, vocab_size):
    if index_to_word[i] in word2vec:
        embedding_matrix[i] = word2vec[index_to_word[i]]
[output]
(10000, 300)


from tensorflow.keras.initializers import Constant

vocab_size = 10000    # 어휘 사전의 크기입니다(10,000개의 단어)
word_vector_dim = 300  # 워드 벡터의 차원 수 

# 모델 구성
model = tf.keras.Sequential()
model.add(
    tf.keras.layers.Embedding(
        vocab_size,
        word_vector_dim,
        embeddings_initializer=Constant(embedding_matrix),  # 카피한 임베딩을 여기서 활용
        input_length=maxlen,
        trainable=True
    )
)   # trainable을 True로 주면 Fine-tuning
model.add(tf.keras.layers.Conv1D(16, 7, activation='relu'))
model.add(tf.keras.layers.MaxPooling1D(5))
model.add(tf.keras.layers.Conv1D(16, 7, activation='relu'))
model.add(tf.keras.layers.GlobalMaxPooling1D())
model.add(tf.keras.layers.Dense(8, activation='relu'))
model.add(tf.keras.layers.Dense(1, activation='sigmoid')) 

model.summary()
[output]
Model: "sequential_4"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 embedding_5 (Embedding)     (None, 580, 300)          3000000   
                                                                 
 conv1d_2 (Conv1D)           (None, 574, 16)           33616     
                                                                 
 max_pooling1d_1 (MaxPooling  (None, 114, 16)          0         
 1D)                                                             
                                                                 
 conv1d_3 (Conv1D)           (None, 108, 16)           1808      
                                                                 
 global_max_pooling1d_2 (Glo  (None, 16)               0         
 balMaxPooling1D)                                                
                                                                 
 dense_8 (Dense)             (None, 8)                 136       
                                                                 
 dense_9 (Dense)             (None, 1)                 9         
                                                                 
=================================================================
Total params: 3,035,569
Trainable params: 3,035,569
Non-trainable params: 0
_________________________________________________________________


# 학습의 진행
model.compile(
    optimizer='adam',
    loss='binary_crossentropy',
    metrics=['accuracy']
)
              
epochs=20  # 몇 epoch를 훈련하면 좋을지 결과를 보면서 바꾸어 봅시다. 
history = model.fit(
    partial_x_train,
    partial_y_train,
    epochs=epochs,
    batch_size=512,
    validation_data=(x_val, y_val),
    verbose=1
)
[output]


# 테스트셋을 통한 모델 평가
results = model.evaluate(x_test,  y_test, verbose=2)
print(results)
[output]
782/782 - 3s - loss: 0.5146 - accuracy: 0.8620 - 3s/epoch - 4ms/step
[0.5145856142044067, 0.8619999885559082]


import matplotlib.pyplot as plt

history_dict = history.history
acc = history_dict['accuracy']
val_acc = history_dict['val_accuracy']
loss = history_dict['loss']
val_loss = history_dict['val_loss']

epochs = range(1, len(acc) + 1)

# "bo"는 "파란색 점"입니다
plt.plot(epochs, loss, 'bo', label='Training loss')
# b는 "파란 실선"입니다
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()

plt.show()

image


plt.clf()   # 그림을 초기화합니다

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()

plt.show()

image


Word2Vec 을 정상적으로 잘 활용하면 그렇지 않은 경우보다 약 30% 이상의 성능 향상이 발생합니다.

Read more

IDMB 리뷰 데이터를 이용한 전처리(토큰화, 정수 인코딩, 패딩)

|

IDMB 리뷰 데이터를 이용하여 전처리 및 간단한 EDA와 정수 인코딩, 그리고 패딩 과정까지 진행해보겠습니다. IDMB 리뷰 데이터는 리뷰에 대한 텍스트와 해당 리뷰가 긍정인 경우 1을 부정인 경우 0으로 표시한 레이블로 구성된 데이터입니다.


실습을 진행하기 전에 간단하게 자연어 전처리 과정을 확인하고나서 실습을 진행하겠습니다.


  1. Text Preprocessing
  2. IDMB 리뷰 데이터 다운로드
  3. 데이터 개수, 중복값, Null 확인
  4. 단어 토큰화
  5. 토큰화기반 통계적 전처리
  6. 정수 인코딩
  7. 패딩



1. Text Preprocessing

기계에게는 단어와 문장의 경계를 알려주어야 하는데 이를 위해서 특정 단위로 토큰화 또는 토크나이징을 해줍니다.

image


기계가 알고있는 단어들의 집합을 단어 집합(Vocabulary)이라고 합니다. 단어 집합이란 훈련 데이터에 있는 단어들의 중복을 제거한 집합을 의미합니다.

image


단어 집합에 있는 각 단어에는 고유한 정수가 부여됩니다. 단어 집합을 기반으로 하므로 중복은 허용되지 않습니다. 이는 앞으로 입력된 모든 텍스트를 정수 시퀀스로 변환하기 위함입니다.

image


정수 인코딩을 진행하면 다음과 같습니다.

image

image


만약 단어 집합에 없는 단어로 인해 생기는 문제를 OOV 문제라고 합니다. 이렇게 생긴 단어들을 일괄적으로 하나의 토큰으로 맵핑해주기도 합니다.

image


image


단어 집합에 있는 각 단어에는 고유한 정수가 부여됩니다. 이는 앞으로 입력된 모든 텍스트를 정수 시퀀스로 변환하기 위함입니다.

image


여러 문장을 병렬적으로 처리하고 싶은 경우, 이를 하나의 행렬로 인식시켜줄 필요가 있습니다. 이때, 서로 다른 문장의 길이를 패딩을 통해 동일하게 만들어줄 수 있습니다.

image


image


image


2. 네이버 영화 리뷰 데이터 다운로드

필요한 라이브러리와 네이버 영화 리뷰 데이터를 다운로드 하겠습니다.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import nltk
nltk.download('punkt')

import torch
import urllib.request
from tqdm import tqdm
from collections import Counter
from nltk.tokenize import word_tokenize
from sklearn.model_selection import train_test_split

urllib.request.urlretrieve("https://raw.githubusercontent.com/ukairia777/pytorch-nlp-tutorial/main/10.%20RNN%20Text%20Classification/dataset/IMDB%20Dataset.csv", filename="IMDB Dataset.csv")


IMDB Dataset.csv 가 다운로드 된 것을 볼 수 있습니다.


3. 데이터 개수, 중복값, Null 확인

데이터를 읽고 간단한 EDA 를 진행하겠습니다.

df = pd.read_csv('IMDB Dataset.csv')
df

image


결측값과 label의 분포와 개수를 확인해보겠습니다.

df.info()
[output]
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 50000 entries, 0 to 49999
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   review     50000 non-null  object
 1   sentiment  50000 non-null  object
dtypes: object(2)
memory usage: 781.4+ KB


print('결측값 여부 :',df.isnull().values.any())
[output]
결측값 여부 : False


df['sentiment'].value_counts().plot(kind='bar')

image


print('레이블 개수')
print(df.groupby('sentiment').size().reset_index(name='count'))
[output]
레이블 개수
  sentiment  count
0  negative  25000
1  positive  25000


label의 분포는 같으며, label은 각각 25000개를 가지고 있습니다. label 값을 정수로 바꾸겠습니다. 즉 positive를 1로, negative를 0으로 변경하겠습니다.

df['sentiment'] = df['sentiment'].replace(['positive','negative'],[1, 0])
df.head()

image


이제 영화 리뷰의 개수, 레이블의 개수 각각을 확인해보고 훈련 데이터와 테스트 데이터로 분리하겠습니다.

X_data = df['review']
y_data = df['sentiment']
print('영화 리뷰의 개수: {}'.format(len(X_data)))
print('레이블의 개수: {}'.format(len(y_data)))
[output]
영화 리뷰의 개수: 50000
레이블의 개수: 50000


X_train, X_test, y_train, y_test = train_test_split(
    X_data,
    y_data,
    test_size=0.5,
    random_state=0,
    stratify=y_data
)

print('--------훈련 데이터의 비율-----------')
print(f'긍정 리뷰 = {round(y_train.value_counts()[0]/len(y_train) * 100,3)}%')
print(f'부정 리뷰 = {round(y_train.value_counts()[1]/len(y_train) * 100,3)}%')
print('--------테스트 데이터의 비율-----------')
print(f'긍정 리뷰 = {round(y_test.value_counts()[0]/len(y_test) * 100,3)}%')
print(f'부정 리뷰 = {round(y_test.value_counts()[1]/len(y_test) * 100,3)}%')
[output]
--------훈련 데이터의 비율-----------
긍정 리뷰 = 50.0%
부정 리뷰 = 50.0%
--------테스트 데이터의 비율-----------
긍정 리뷰 = 50.0%
부정 리뷰 = 50.0%


4. 단어 토큰화

전처리된 데이터를 바탕으로 nltkword_tokenize 를 이용해서 단어 토큰화 를 진행하겠습니다. 우선 샘플 하나를 가지고 진행하겠습니다.

print(X_train[0])
[output]
One of the other reviewers has mentioned that after watching just 1 Oz episode you'll be hooked. They are right, as this is exactly what happened with me.<br /><br />The first thing that struck me about Oz was its brutality and unflinching scenes of violence, which set in right from the word GO. 

... (중략) ...

Not just violence, but injustice (crooked guards who'll be sold out for a nickel, inmates who'll kill on order and get away with it, well mannered, middle class inmates being turned into prison bitches due to their lack of street skills or prison experience) Watching Oz, you may become comfortable with what is uncomfortable viewing....thats if you can get in touch with your darker side.


sample = word_tokenize(X_train[0])
print(sample)
[output]
['One', 'of', 'the', 'other', 'reviewers', 'has', 'mentioned', 'that', 'after', 'watching', 'just', '1', 'Oz', 'episode', 'you', "'ll", 'be', 'hooked', '.', 'They', 'are', 'right', ',', 'as', 'this', 'is', 'exactly', 'what', 'happened', 'with', 'me.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'The', 'first', 'thing', 'that', 'struck', 'me', 'about', 'Oz', 'was', 'its', 'brutality', 'and', 'unflinching', 'scenes', 'of', 'violence', ',', 'which', 'set', 'in', 'right', 'from', 'the', 'word', 'GO', '.',

... (중략) ... ,

'Not', 'just', 'violence', ',', 'but', 'injustice', '(', 'crooked', 'guards', 'who', "'ll", 'be', 'sold', 'out', 'for', 'a', 'nickel', ',', 'inmates', 'who', "'ll", 'kill', 'on', 'order', 'and', 'get', 'away', 'with', 'it', ',', 'well', 'mannered', ',', 'middle', 'class', 'inmates', 'being', 'turned', 'into', 'prison', 'bitches', 'due', 'to', 'their', 'lack', 'of', 'street', 'skills', 'or', 'prison', 'experience', ')', 'Watching', 'Oz', ',', 'you', 'may', 'become', 'comfortable', 'with', 'what', 'is', 'uncomfortable', 'viewing', '....', 'thats', 'if', 'you', 'can', 'get', 'in', 'touch', 'with', 'your', 'darker', 'side', '.']


단어 토큰화가 된 데이터에 대해 소문자화 시키겠습니다.

lower_sample = [word.lower() for word in sample]
print(lower_sample)
[output]
['one', 'of', 'the', 'other', 'reviewers', 'has', 'mentioned', 'that', 'after', 'watching', 'just', '1', 'oz', 'episode', 'you', "'ll", 'be', 'hooked', '.', 'they', 'are', 'right', ',', 'as', 'this', 'is', 'exactly', 'what', 'happened', 'with', 'me.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'the', 'first', 'thing', 'that', 'struck', 'me', 'about', 'oz', 'was', 'its', 'brutality', 'and', 'unflinching', 'scenes', 'of', 'violence', ',', 'which', 'set', 'in', 'right', 'from', 'the', 'word', 'go', '.',

... (중략) ... ,

'not', 'just', 'violence', ',', 'but', 'injustice', '(', 'crooked', 'guards', 'who', "'ll", 'be', 'sold', 'out', 'for', 'a', 'nickel', ',', 'inmates', 'who', "'ll", 'kill', 'on', 'order', 'and', 'get', 'away', 'with', 'it', ',', 'well', 'mannered', ',', 'middle', 'class', 'inmates', 'being', 'turned', 'into', 'prison', 'bitches', 'due', 'to', 'their', 'lack', 'of', 'street', 'skills', 'or', 'prison', 'experience', ')', 'watching', 'oz', ',', 'you', 'may', 'become', 'comfortable', 'with', 'what', 'is', 'uncomfortable', 'viewing', '....', 'thats', 'if', 'you', 'can', 'get', 'in', 'touch', 'with', 'your', 'darker', 'side', '.']


지금부터 train 및 test 데이터에 대한 단어 토큰화를 진행하겠습니다.

def tokenize(sentences):
    tokenized_sentences = []
    for sent in tqdm(sentences):
        tokenized_sent = word_tokenize(sent)
        tokenized_sent = [word.lower() for word in tokenized_sent]
        tokenized_sentences.append(tokenized_sent)
    return tokenized_sentences

tokenized_X_train = tokenize(X_train)
tokenized_X_test = tokenize(X_test)
[output]
100%|██████████| 25000/25000 [00:35<00:00, 713.44it/s]
100%|██████████| 25000/25000 [00:34<00:00, 722.73it/s]


# 상위 샘플 2개 출력
for sent in tokenized_X_train[:2]:
    print(sent)
[output]
['life', 'is', 'too', 'short', 'to', 'waste', 'on', 'two', 'hours', 'of', 'hollywood', 'nonsense', 'like', 'this', ',', 'unless', 'you', "'re", 'a', 'clueless', 'naiive', '16', 'year', 'old', 'girl', 'with', 'no', 'sense', 'of', 'reality', 'and', 'nothing', 'better', 'to', 'do', '.', 'dull', 'characters', ',', 'poor', 'acting', '(', 'artificial', 'emotion', ')', ',', 'weak', 'story', ',', 'slow', 'pace', ',', 'and', 'most', 'important', 'to', 'this', 'films', 'flawed', 'existence-no', 'one', 'cares', 'about', 'the', 'overly', 'dramatic', 'relationship', '.']
['for', 'those', 'who', 'expect', 'documentaries', 'to', 'be', 'objective', 'creatures', ',', 'let', 'me', 'give', 'you', 'a', 'little', 'lesson', 'in', 'american', 'film-making.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'documentaries', 'rely', 'heavily', 'on', 'casting', '.', 'you', 'pick', 'and', 'choose', 'characters', 'you', 'think', 'will', 'enhance', 'the', 'drama', 'and', 'entertainment', 'value', 'of', 'your', 'film.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'after', 'you', 'have', 'shot', 'a', 'ton', 'of', 'footage', ',', 'you', 'splice', 'it', 'together', 'to', 'make', 'a', 'film', 'with', 'ups', 'and', 'downs', ',', 'turning', 'points', ',', 'climaxes', ',', 'etc', '.', 'if', 'you', 'have', 'trouble', 'with', 'existing', 'footage', ',', 'you', 'either', 'shoot', 'some', 'more', 'that', 'makes', 'sense', ',', 'find', 'some', 'stock', 'footage', ',', 'or', 'be', 'clever', 'with', 'your', 'narration.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'the', 'allegation', 'that', 'the', 'filmmakers', 'used', 'footage', 'of', 'locales', 'not', 'part', 'of', 'the', 'movie', '(', 'favelas', 'next', 'to', 'beautiful', 'beaches', ')', 'does', 'not', 'detract', 'from', 'the', 'value', 'of', 'the', 'film', 'as', 'a', 'dramatic', 'piece', 'and', 'the', 'particular', 'image', 'is', 'one', 'that', 'resonates', 'enough', 'to', 'justify', 'its', 'not-quite-truthful', 'inclusion', '.', 'at', 'any', 'rate', ',', 'you', 'use', 'the', 'footage', 'you', 'can', '.', 'so', 'they', 'did', "n't", 'happen', 'to', 'have', 'police', 'violence', 'footage', 'for', 'that', 'particular', 'neighborhood', '.', 'does', 'this', 'mean', 'not', 'include', 'it', 'and', 'just', 'talk', 'about', 'it', 'or', 'maybe', 'put', 'in', 'some', 'cartoon', 'animation', 'so', 'the', 'audience', 'is', "n't", '``', 'duped', "''", '?', 'um', ',', 'no.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'as', 'for', 'the', 'hopeful', 'ending', ',', 'why', 'not', '?', 'yes', ',', 'americans', 'made', 'it', '.', 'yes', ',', 'americans', 'are', 'optimistic', 'bastards', '.', 'but', 'why', 'end', 'on', 'a', 'down', 'note', '?', 'just', 'because', 'it', "'s", 'set', 'in', 'a', 'foreign', 'country', 'and', 'foreign', 'films', 'by', 'and', 'large', 'end', 'on', 'a', 'down', 'note', '?', 'let', 'foreigners', 'portray', 'the', 'dismal', 'outlook', 'of', 'life.', '<', 'br', '/', '>', '<', 'br', '/', '>', 'let', 'us', 'americans', 'think', 'there', 'may', 'be', 'a', 'happy', 'ending', 'looming', 'in', 'the', 'future', '.', 'there', 'just', 'may', 'be', 'one', '.']


train 및 test 데이터에 대한 단어 토큰화가 완료되었습니다. 총 단어의 개수 및 각 단어의 등장 횟수를 확인해보겠습니다.

word_list = []
for sent in tokenized_X_train:
    for word in sent:
        word_list.append(word)

word_counts = Counter(word_list)
print('총 단어수 :', len(word_counts))
[output]
총 단어수 : 112946


print(word_counts)
[output]
Counter({'the': 332140, ',': 271720, '.': 234036, 'and': 161143, 'a': 161005, 'of': 144426, 'to': 133327, 'is': 107917, ... (중략) ..., 'pseudo-film': 1, "'harris": 1, 'bridesmaid': 1, 'infatuations': 1, 'a.p': 1})


print('훈련 데이터에서의 단어 the의 등장 횟수 :', word_counts['the'])
print('훈련 데이터에서의 단어 love의 등장 횟수 :', word_counts['love'])
[output]
훈련 데이터에서의 단어 the의 등장 횟수 : 332140
훈련 데이터에서의 단어 love의 등장 횟수 : 6260


5. 토큰화기반 통계적 전처리

단어사전에 대해 등장 빈도수 상위 10개 단어를 추출해보고 통계적 수치 를 계산하겠습니다.

vocab = sorted(word_counts, key=word_counts.get, reverse=True)
print('등장 빈도수 상위 10개 단어')
print(vocab[:10])
[output]
등장 빈도수 상위 10개 단어
['the', ',', '.', 'and', 'a', 'of', 'to', 'is', '/', '>']


threshold = 3
total_cnt = len(word_counts) # 단어의 수
rare_cnt = 0 # 등장 빈도수가 threshold보다 작은 단어의 개수를 카운트
total_freq = 0 # 훈련 데이터의 전체 단어 빈도수 총 합
rare_freq = 0 # 등장 빈도수가 threshold보다 작은 단어의 등장 빈도수의 총 합

# 단어와 빈도수의 쌍(pair)을 key와 value로 받는다.
for key, value in word_counts.items():
    total_freq = total_freq + value

    # 단어의 등장 빈도수가 threshold보다 작으면
    if(value < threshold):
        rare_cnt = rare_cnt + 1
        rare_freq = rare_freq + value

print('단어 집합(vocabulary)의 크기 :',total_cnt)
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt))
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100)
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100)
[output]
단어 집합(vocabulary)의 크기 : 112946
등장 빈도가 2번 이하인 희귀 단어의 수: 69670
단어 집합에서 희귀 단어의 비율: 61.68434473111044
전체 등장 빈도에서 희귀 단어 등장 빈도 비율: 1.1946121064938062


등장 빈도가 threshold 값인 3회 미만. 즉, 2회 이하인 단어들은 단어 집합에서 무려 69% 이상을 차지합니다. 하지만, 실제로 훈련 데이터에서 등장 빈도로 차지하는 비중은 상대적으로 매우 적은 수치인 1.19%밖에 되지 않습니다. 아무래도 등장 빈도가 2회 이하인 단어들은 자연어 처리에서 별로 중요하지 않을 듯 합니다. 그래서 이 단어들은 정수 인코딩 과정에서 배제시키겠습니다.

등장 빈도수가 2이하인 단어들의 수를 제외한 단어의 개수를 단어 집합의 최대 크기로 제한 하겠습니다.

# 전체 단어 개수 중 빈도수 1이하인 단어는 제거.
vocab_size = total_cnt - rare_cnt
vocab = vocab[:vocab_size]
print('단어 집합의 크기 :', len(vocab))
[output]
단어 집합의 크기 : 43276


print(vocab)
[output]
['the', ',', '.', 'and', 'a', 'of', 'to', 'is', ... (중략) ..., '-atlantis-', 'middlemarch', 'lollo']


단어 집합의 크기는 112946개에서 등장 빈도수가 2이하인 단어들의 수를 제외한 뒤 확인해보니 43276개 였습니다. 정제된 단어 집합에 특수 토큰인 PAD, UNK 를 삽입하고 최종 단어 집합을 구성하겠습니다.

word_to_index = {}
word_to_index['<PAD>'] = 0
word_to_index['<UNK>'] = 1

for index, word in enumerate(vocab):
    word_to_index[word] = index + 2

print(word_to_index)
[output]
{'<PAD>': 0, '<UNK>': 1, 'the': 2, ',': 3, '.': 4, 'and': 5, 'a': 6, 'of': 7, 'to': 8, 'is': 9, ... (중략) ..., '-atlantis-': 43277, 'middlemarch': 43278, 'lollo': 43279}


vocab_size = len(word_to_index)
print('패딩 토큰과 UNK 토큰을 고려한 단어 집합의 크기 :', vocab_size)
[output]
패딩 토큰과 UNK 토큰을 고려한 단어 집합의 크기 : 43278


최종적으로 전처리된 단어 집합의 크기는 특수 토큰인 PAD, UNK를 포함한 개수인 43278개 입니다. 단어에 대한 index를 조회해보겠습니다.

print('단어 <PAD>와 맵핑되는 정수 :', word_to_index['<PAD>'])
print('단어 <UNK>와 맵핑되는 정수 :', word_to_index['<UNK>'])
print('단어 the와 맵핑되는 정수 :', word_to_index['the'])
[output]
단어 <PAD>와 맵핑되는 정수 : 0
단어 <UNK>와 맵핑되는 정수 : 1
단어 the와 맵핑되는 정수 : 2


word_to_index['bridesmaid']
[output]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-108-a8fb05ad2c9a> in <module>
----> 1 word_to_index['bridesmaid']

KeyError: 'bridesmaid'


단어 사전에 없는 단어인 ‘bridesmaid’를 조회하면 오류가 납니다.


6. 정수 인코딩

지금부터 완성된 단어 사전에 대해 정수 인코딩 을 진행하겠습니다.

def texts_to_sequences(tokenized_X_data, word_to_index):
    encoded_X_data = []
    for sent in tokenized_X_data:
        index_sequences = []
        for word in sent:
            try:
                index_sequences.append(word_to_index[word])
            except KeyError:
                index_sequences.append(word_to_index['<UNK>'])
        encoded_X_data.append(index_sequences)
        
    return encoded_X_data

encoded_X_train = texts_to_sequences(X_train, word_to_index)
encoded_X_test = texts_to_sequences(X_test, word_to_index)

print('토큰화 전 원본 문장 :', X_train[42043])
print('정수 인코딩 전 토큰화 :', tokenized_X_train[0])
print('정수 인코딩 결과 :', encoded_X_train[0])
[output]
토큰화 전 원본 문장 : Life is too short to waste on two hours of Hollywood nonsense like this, unless you're a clueless naiive 16 year old girl with no sense of reality and nothing better to do. Dull characters, poor acting (artificial emotion), weak story, slow pace, and most important to this films flawed existence-no one cares about the overly dramatic relationship.
정수 인코딩 전 토큰화 : ['life', 'is', 'too', 'short', 'to', 'waste', 'on', 'two', 'hours', 'of', 'hollywood', 'nonsense', 'like', 'this', ',', 'unless', 'you', "'re", 'a', 'clueless', 'naiive', '16', 'year', 'old', 'girl', 'with', 'no', 'sense', 'of', 'reality', 'and', 'nothing', 'better', 'to', 'do', '.', 'dull', 'characters', ',', 'poor', 'acting', '(', 'artificial', 'emotion', ')', ',', 'weak', 'story', ',', 'slow', 'pace', ',', 'and', 'most', 'important', 'to', 'this', 'films', 'flawed', 'existence-no', 'one', 'cares', 'about', 'the', 'overly', 'dramatic', 'relationship', '.']
정수 인코딩 결과 : [139, 9, 117, 353, 8, 459, 30, 129, 635, 7, 360, 1934, 50, 17, 3, 898, 29, 192, 6, 5485, 1, 4041, 346, 188, 261, 22, 72, 307, 7, 605, 5, 176, 143, 8, 54, 4, 772, 119, 3, 351, 132, 28, 4786, 1386, 27, 3, 838, 81, 3, 617, 1057, 3, 5, 104, 681, 8, 17, 123, 2958, 1, 40, 1994, 57, 2, 2346, 950, 632, 4]


정수 인코딩이 완료되었습니다. 정수 디코딩을 하여 기존 첫번째 샘플을 복원해보겠습니다.

index_to_word = {}
for key, value in word_to_index.items():
    index_to_word[value] = key

decoded_sample = [index_to_word[word] for word in encoded_X_train[0]]
print('기존의 첫번째 샘플 :', tokenized_X_train[0])
print('복원된 첫번째 샘플 :', decoded_sample)
[output]
기존의 첫번째 샘플 : ['life', 'is', 'too', 'short', 'to', 'waste', 'on', 'two', 'hours', 'of', 'hollywood', 'nonsense', 'like', 'this', ',', 'unless', 'you', "'re", 'a', 'clueless', 'naiive', '16', 'year', 'old', 'girl', 'with', 'no', 'sense', 'of', 'reality', 'and', 'nothing', 'better', 'to', 'do', '.', 'dull', 'characters', ',', 'poor', 'acting', '(', 'artificial', 'emotion', ')', ',', 'weak', 'story', ',', 'slow', 'pace', ',', 'and', 'most', 'important', 'to', 'this', 'films', 'flawed', 'existence-no', 'one', 'cares', 'about', 'the', 'overly', 'dramatic', 'relationship', '.']
복원된 첫번째 샘플 : ['life', 'is', 'too', 'short', 'to', 'waste', 'on', 'two', 'hours', 'of', 'hollywood', 'nonsense', 'like', 'this', ',', 'unless', 'you', "'re", 'a', 'clueless', '<UNK>', '16', 'year', 'old', 'girl', 'with', 'no', 'sense', 'of', 'reality', 'and', 'nothing', 'better', 'to', 'do', '.', 'dull', 'characters', ',', 'poor', 'acting', '(', 'artificial', 'emotion', ')', ',', 'weak', 'story', ',', 'slow', 'pace', ',', 'and', 'most', 'important', 'to', 'this', 'films', 'flawed', '<UNK>', 'one', 'cares', 'about', 'the', 'overly', 'dramatic', 'relationship', '.']


실제로 인코딩을 의미하는 word_to_index 와 디코딩을 의미하는 index_to_word 를 살펴보면 다음과 같습니다.

print(word_to_index)
print(index_to_word)
[output]
{'<PAD>': 0, '<UNK>': 1, 'the': 2, ',': 3, '.': 4, 'and': 5, 'a': 6, 'of': 7, 'to': 8, 'is': 9, ... (중략) ..., middlemarch: 43276', lollo: '43277'}
{0: '<PAD>', 1: '<UNK>', 2: 'the', 3: ',', 4: '.', 5: 'and', 6: 'a', 7: 'of', 8: 'to', 9: 'is', ... (중략) ..., 43276: middlemarch', 43277: 'lollo'}


7. 패딩

정수 인코딩이 완료된 리뷰 데이터의 최대 길이와 평균 길이를 구해보겠습니다.

print('리뷰의 최대 길이 :', max(len(review) for review in encoded_X_train))
print('리뷰의 평균 길이 :', sum(map(len, encoded_X_train))/len(encoded_X_train))
plt.hist([len(review) for review in encoded_X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
[output]
리뷰의 최대 길이 : 2818
리뷰의 평균 길이 : 279.0998

image


전체 샘플 중 길이가 maxlen 이하인 샘플의 비율을 확인하고 패딩 을 진행하겠습니다.

def below_threshold_len(max_len, nested_list):
    count = 0
    for sentence in nested_list:
        if(len(sentence) <= max_len):
            count = count + 1

    print('전체 샘플 중 길이가 %s 이하인 샘플의 비율: %s'%(max_len, (count / len(nested_list))*100))

max_len = 500
below_threshold_len(max_len, encoded_X_train)
[output]
전체 샘플 중 길이가 500 이하인 샘플의 비율: 87.836


전체 샘플 중 길이가 500 이하인 샘플의 비율은 87.836% 입니다. 패딩을 진행하겠습니다.

def pad_sequences(sentences, max_len):
    features = np.zeros((len(sentences), max_len), dtype=int)
    for index, sentence in enumerate(sentences):
        if len(sentence) != 0:
            features[index, :len(sentence)] = np.array(sentence)[:max_len]

    return features

padded_X_train = pad_sequences(encoded_X_train, max_len=max_len)
padded_X_test = pad_sequences(encoded_X_test, max_len=max_len)

print('훈련 데이터의 크기 :', padded_X_train.shape)
print('테스트 데이터의 크기 :', padded_X_test.shape)
[output]
훈련 데이터의 크기 : (25000, 500)
테스트 데이터의 크기 : (25000, 500)


정상적으로 패딩이 완료되었는지 확인하겠습니다.

padded_X_train[:5]
[output]
array([[ 139,    9,  117, ...,    0,    0,    0],
       [  23,  162,   47, ...,    0,    0,    0],
       [  23,   40,  347, ...,    0,    0,    0],
       [  17,   24,   20, ...,    0,    0,    0],
       [1827,   92,  134, ...,    0,    0,    0]])


구체적으로 1개의 데이터에 대한 패딩확인을 진행하겠습니다.

len(padded_X_train[0])
[output]
500


print(padded_X_train[0])
[output]
[ 139    9  117  353    8  459   30  129  635    7  360 1934   50   17
    3  898   29  192    6 5485    1 4041  346  188  261   22   72  307
    7  605    5  176  143    8   54    4  772  119    3  351  132   28
 4786 1386   27    3  838   81    3  617 1057    3    5  104  681    8
   17  123 2958    1   40 1994   57    2 2346  950  632    4    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0]


정상적으로 모든 전처리가 완료되었습니다.

Read more

네이버 영화 리뷰 데이터를 이용한 전처리(정규식, 토큰화, 정수 인코딩, 패딩)

|

네이버 영화 리뷰 데이터를 이용하여 전처리 및 간단한 EDA와 정수 인코딩, 그리고 패딩 과정까지 진행해보겠습니다. 네이버 영화 리뷰 데이터는 총 200,000개 리뷰로 구성된 데이터로 영화 리뷰에 대한 텍스트와 해당 리뷰가 긍정인 경우 1, 부정인 경우 0을 표시한 레이블로 구성되어져 있습니다.


실습을 진행하기 전에 간단하게 자연어 전처리 과정을 확인하고나서 실습을 진행하겠습니다.


  1. Text Preprocessing
  2. 네이버 영화 리뷰 데이터 다운로드
  3. 데이터 개수, 중복값, Null 확인
  4. 정규표현식을 이용한 전처리
  5. 단어 토큰화
  6. 토큰화기반 통계적 전처리
  7. 정수 인코딩
  8. 패딩



1. Text Preprocessing

기계에게는 단어와 문장의 경계를 알려주어야 하는데 이를 위해서 특정 단위로 토큰화 또는 토크나이징을 해줍니다.

image


기계가 알고있는 단어들의 집합을 단어 집합(Vocabulary)이라고 합니다. 단어 집합이란 훈련 데이터에 있는 단어들의 중복을 제거한 집합을 의미합니다.

image


단어 집합에 있는 각 단어에는 고유한 정수가 부여됩니다. 단어 집합을 기반으로 하므로 중복은 허용되지 않습니다. 이는 앞으로 입력된 모든 텍스트를 정수 시퀀스로 변환하기 위함입니다.

image


정수 인코딩을 진행하면 다음과 같습니다.

image

image


만약 단어 집합에 없는 단어로 인해 생기는 문제를 OOV 문제라고 합니다. 이렇게 생긴 단어들을 일괄적으로 하나의 토큰으로 맵핑해주기도 합니다.

image


image


단어 집합에 있는 각 단어에는 고유한 정수가 부여됩니다. 이는 앞으로 입력된 모든 텍스트를 정수 시퀀스로 변환하기 위함입니다.

image


여러 문장을 병렬적으로 처리하고 싶은 경우, 이를 하나의 행렬로 인식시켜줄 필요가 있습니다. 이때, 서로 다른 문장의 길이를 패딩을 통해 동일하게 만들어줄 수 있습니다.

image


image


image


2. 네이버 영화 리뷰 데이터 다운로드

필요한 라이브러리와 네이버 영화 리뷰 데이터를 다운로드 하겠습니다.

import pickle
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import re
import urllib.request
from konlpy.tag import Okt
from tqdm import tqdm
from collections import Counter

urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings_train.txt", filename="ratings_train.txt")
urllib.request.urlretrieve("https://raw.githubusercontent.com/e9t/nsmc/master/ratings_test.txt", filename="ratings_test.txt")


ratings_train.txtratings_test.txt 가 다운로드 된 것을 볼 수 있습니다.


3. 데이터 개수, 중복값, Null 확인

데이터를 읽고 간단한 EDA 를 진행하겠습니다. 이때 주의해서 볼 것은 전처리 과정 입니다.

train_data = pd.read_table('ratings_train.txt')
test_data = pd.read_table('ratings_test.txt')

train_data.head()

image


데이터를 간략하게 파악할 수 있습니다. 훈련용 리뷰 데이터 개수를 출력하고, document 열 기준으로 중복값을 제거 해보겠습니다.

print('훈련용 리뷰 개수 :', len(train_data)) # 훈련용 리뷰 개수 출력

# document 열의 중복 제거
train_data.drop_duplicates(subset=['document'], inplace=True)

print('총 샘플의 수 :', len(train_data))
[output]
훈련용 리뷰 개수 : 150000
총 샘플의 수 : 146183


중복값 제거가 완료되었습니다. 이제 label의 분포와 개수를 확인해보겠습니다.

train_data['label'].value_counts().plot(kind='bar')

image


print(train_data.groupby('label').size().reset_index(name='count'))
[output]
   label  count
0      0  73342
1      1  72841


label의 분포는 비슷하며, label 0은 73342개, label 1은 72841개를 가지고 있습니다. 이제 document 열 기준으로 Null값이 있는지 확인해보겠습니다.

print(train_data.loc[train_data.document.isnull()])
[output]
            id document  label
25857  2172111      NaN      1


document 열 기준으로 NULL값을 제거하고 개수를 재확인 하겠습니다.

train_data = train_data.dropna(how='any') # Null 값이 존재하는 행 제거
print(train_data.isnull().values.any()) # Null 값이 존재하는지 확인
[output]
False


len(train_data)
[output]
146182


ratings_train.txt 데이터의 총 리뷰 개수는 150000개였으며, document 열 기준으로 중복을 제거했을때 146183개이며, Null값을 제거했을때 146182개가 되었습니다.


4. 정규표현식을 이용한 전처리

정규표현식을 사용하여 문자열에서 한글과 공백을 제외한 모든 문자를 제거하겠습니다.

# 한글과 공백을 제외하고 모두 제거
train_data['document'] = train_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","")
train_data[:5]

image


위 코드는 정규표현식을 사용하여 문자열에서 한글과 공백을 제외한 모든 문자를 제거하는 역할을 합니다.

  • ”^”: 이 문자는 대괄호 내에 사용되었을 때 부정(negation)을 의미합니다. 즉, 대괄호 안의 문자들을 제외한 문자를 선택하도록 합니다.
  • “ㄱ-ㅎ”: 자음을 의미합니다. 한글 자모 중 초성 자음을 나타내는 범위입니다.
  • “ㅏ-ㅣ”: 모음을 의미합니다. 한글 자모 중 중성 모음을 나타내는 범위입니다.
  • “가-힣”: 한글의 전체 범위를 나타냅니다.
  • ” “: 공백 문자입니다.


따라서, ’[^ㄱ-ㅎㅏ-ㅣ가-힣 ]’ 는 한글 자모, 한글 문자, 그리고 공백을 제외한 모든 문자를 선택합니다. ‘replace(“[^ㄱ-ㅎㅏ-ㅣ가-힣 ]”,””)’ 는 선택된 문자들을 빈 문자열로 대체하여 제거하는 역할을 합니다.

예를 들어 다음과 같이 입력하면, 원본 문자열에서 영문, 숫자, 특수문자 등은 모두 제거되고, 한글과 공백만 남게 됩니다.

import re

text = "Hello, 안녕하세요! 123ABC"

cleaned_text = re.sub("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]", "", text)

print(cleaned_text)
[output]
안녕하세요 


또한 정규표현식을 사용하여 문자열에서 시작 부분에 있는 하나 이상의 공백을 제거하겠습니다.

train_data['document'] = train_data['document'].str.replace('^ +', "") # white space 데이터를 empty value로 변경


위 코드는 정규표현식을 사용하여 문자열에서 시작 부분에 있는 하나 이상의 공백을 제거하는 역할을 합니다.

  • ”^”: 이 문자는 정규표현식에서 문자열의 시작 부분을 나타냅니다.
  • ”+”: 이 문자는 바로 앞의 패턴이 하나 이상의 반복을 의미합니다.
  • ” “: 공백 문자입니다.


따라서, ’^ +’ 는 문자열의 시작 부분에 있는 하나 이상의 공백을 선택합니다. ‘replace(“^ +”, “”)’ 는 선택된 공백을 빈 문자열로 대체하여 제거하는 역할을 합니다.

예를 들어 다음과 같이 입력하면, 원본 문자열에서 시작 부분에 있는 공백이 제거되어 “Hello, 안녕하세요!”가 남게 됩니다.

import re

text = "    Hello, 안녕하세요!"

cleaned_text = re.sub("^ +", "", text)

print(cleaned_text)
[output]
Hello, 안녕하세요!


이후 빈 문자열을 numpy 라이브러리의 NaN 값으로 대체하겠습니다.


train_data['document'].replace('', np.nan, inplace=True)
print(train_data.isnull().sum())
[output]
id            0
document    789
label         0
dtype: int64


NaN값을 한번 확인해보겠습니다.

print(train_data.loc[train_data.document.isnull()][:5])
[output]
           id document  label
404   4221289      NaN      0
412   9509970      NaN      1
470  10147571      NaN      1
584   7117896      NaN      0
593   6478189      NaN      0


NaN값을 제거한 뒤, 데이터의 개수를 확인하겠습니다.

train_data = train_data.dropna(how='any')
print(len(train_data))
[output]
145393


최종적으로 ratings_train.txt 데이터의 총 리뷰 개수는 150000개였으며, document 열 기준으로 중복을 제거했을때 146183개이며, Null값을 제거했을때 146182개가 되었으며, 정규표현식 전처리를 통해 145393개가 되었습니다. 이제 ratings_test.txt 데이터에 그대로 적용하겠습니다.

print('테스트용 샘플의 개수 :', len(test_data)) # 훈련용 리뷰 개수 출력
test_data.drop_duplicates(subset = ['document'], inplace=True) # document 열에서 중복인 내용이 있다면 중복 제거
test_data['document'] = test_data['document'].str.replace("[^ㄱ-ㅎㅏ-ㅣ가-힣 ]","") # 정규 표현식 수행
test_data['document'] = test_data['document'].str.replace('^ +', "") # 공백은 empty 값으로 변경
test_data['document'].replace('', np.nan, inplace=True) # 공백은 Null 값으로 변경
test_data = test_data.dropna(how='any') # Null 값 제거
print('전처리 후 테스트용 샘플의 개수 :', len(test_data))
[output]
테스트용 샘플의 개수 : 50000
전처리 후 테스트용 샘플의 개수 : 48852


5. 단어 토큰화

전처리된 데이터를 바탕으로 Okt 를 이용해서 단어 토큰화 를 진행하겠습니다. 앞서 불용어를 정의하고 Okt를 불러오겠습니다.

stopwords = ['의','가','이','은','들','는','좀','잘','걍','과','도','를','으로','자','에','와','한','하다']


okt = Okt()
okt.morphs('와 이런 것도 영화라고 차라리 뮤직비디오를 만드는 게 나을 뻔')
[output]
['와', '이런', '것', '도', '영화', '라고', '차라리', '뮤직비디오', '를', '만드는', '게', '나을', '뻔']


지금부터 train 및 test 데이터에 대한 단어 토큰화를 진행하겠습니다.

X_train = []
for sentence in tqdm(train_data['document']):
    tokenized_sentence = okt.morphs(sentence) # 토큰화
    stopwords_removed_sentence = [word for word in tokenized_sentence if not word in stopwords] # 불용어 제거
    X_train.append(stopwords_removed_sentence)
[output]
100%|██████████| 145393/145393 [09:19<00:00, 259.68it/s]


X_test = []
for sentence in tqdm(test_data['document']):
    tokenized_sentence = okt.morphs(sentence) # 토큰화
    stopwords_removed_sentence = [word for word in tokenized_sentence if not word in stopwords] # 불용어 제거
    X_test.append(stopwords_removed_sentence)
[output]
100%|██████████| 48852/48852 [03:19<00:00, 244.43it/s]


train 및 test 데이터에 대한 단어 토큰화가 완료되었습니다. 총 단어의 개수 및 각 단어의 등장 횟수를 확인해보겠습니다.

word_list = []
for sent in X_train:
    for word in sent:
        word_list.append(word)

word_counts = Counter(word_list)
print('총 단어수 :', len(word_counts))
[output]
총 단어수 : 100004


print(word_counts)
[output]
Counter({'영화': 50367, '을': 23208, '너무': 11124, ... (중략) ..., '들어나': 1, '찎었': 1, '디케이드': 1, '수간': 1})


print('훈련 데이터에서의 단어 영화의 등장 횟수 :', word_counts['영화'])
print('훈련 데이터에서의 단어 송강호의 등장 횟수 :', word_counts['송강호'])
print('훈련 데이터에서의 단어 열외의 등장 횟수 :', word_counts['열외'])
[output]
훈련 데이터에서의 단어 영화의 등장 횟수 : 50367
훈련 데이터에서의 단어 송강호의 등장 횟수 : 74
훈련 데이터에서의 단어 열외의 등장 횟수 : 0


6. 토큰화기반 통계적 전처리

단어사전에 대해 등장 빈도수 상위 10개 단어를 추출해보고 통계적 수치 를 계산하겠습니다.

vocab = sorted(word_counts, key=word_counts.get, reverse=True)
print('등장 빈도수 상위 10개 단어')
print(vocab[:10])
[output]
등장 빈도수 상위 10개 단어
['영화', '을', '너무', '다', '정말', '적', '만', '진짜', '로', '점']


threshold = 3
total_cnt = len(word_counts) # 단어의 수
rare_cnt = 0 # 등장 빈도수가 threshold보다 작은 단어의 개수를 카운트
total_freq = 0 # 훈련 데이터의 전체 단어 빈도수 총 합
rare_freq = 0 # 등장 빈도수가 threshold보다 작은 단어의 등장 빈도수의 총 합

# 단어와 빈도수의 쌍(pair)을 key와 value로 받는다.
for key, value in word_counts.items():
    total_freq = total_freq + value

    # 단어의 등장 빈도수가 threshold보다 작으면
    if(value < threshold):
        rare_cnt = rare_cnt + 1
        rare_freq = rare_freq + value

print('단어 집합(vocabulary)의 크기 :', total_cnt)
print('등장 빈도가 %s번 이하인 희귀 단어의 수: %s'%(threshold - 1, rare_cnt))
print("단어 집합에서 희귀 단어의 비율:", (rare_cnt / total_cnt)*100)
print("전체 등장 빈도에서 희귀 단어 등장 빈도 비율:", (rare_freq / total_freq)*100)
[output]
단어 집합(vocabulary)의 크기 : 100004
등장 빈도가 2번 이하인 희귀 단어의 수: 67691
단어 집합에서 희귀 단어의 비율: 67.68829246830127
전체 등장 빈도에서 희귀 단어 등장 빈도 비율: 4.949305773454659


등장 빈도가 threshold 값인 3회 미만. 즉, 2회 이하인 단어들은 단어 집합에서 무려 67% 이상을 차지합니다. 하지만, 실제로 훈련 데이터에서 등장 빈도로 차지하는 비중은 상대적으로 매우 적은 수치인 4.94%밖에 되지 않습니다. 아무래도 등장 빈도가 2회 이하인 단어들은 자연어 처리에서 별로 중요하지 않을 듯 합니다. 그래서 이 단어들은 정수 인코딩 과정에서 배제시키겠습니다.

등장 빈도수가 2이하인 단어들의 수를 제외한 단어의 개수를 단어 집합의 최대 크기로 제한 하겠습니다.

# 전체 단어 개수 중 빈도수 2이하인 단어는 제거.
vocab_size = total_cnt - rare_cnt
vocab = vocab[:vocab_size]
print('단어 집합의 크기 :', len(vocab))
[output]
단어 집합의 크기 : 32313


print(vocab)
[output]
['영화', '을', '너무', '다', ... (중략) ..., '황홀하게', '쥬다이', '라쿠']


단어 집합의 크기는 100004개에서 등장 빈도수가 2이하인 단어들의 수를 제외한 뒤 확인해보니 32313개 였습니다. 정제된 단어 집합에 특수 토큰인 PAD, UNK 를 삽입하고 최종 단어 집합을 구성하겠습니다.

word_to_index = {}
word_to_index['<PAD>'] = 0
word_to_index['<UNK>'] = 1

for index, word in enumerate(vocab):
    word_to_index[word] = index + 2

print(word_to_index)
[output]
{'<PAD>': 0, '<UNK>': 1, '영화': 2, '을': 3, '너무': 4, '다': 5, ... (중략) ..., '황홀하게': 32312, '쥬다이': 32313, '라쿠': 32314}


vocab_size = len(word_to_index)
print('패딩 토큰과 UNK 토큰을 고려한 단어 집합의 크기 :', vocab_size)
[output]
패딩 토큰과 UNK 토큰을 고려한 단어 집합의 크기 : 32315


최종적으로 전처리된 단어 집합의 크기는 특수 토큰인 PAD, UNK를 포함한 개수인 32315개 입니다. 단어에 대한 index를 조회해보겠습니다.

word_to_index['영화']
[output]
2


word_to_index['송강호']
[output]
2314


word_to_index['열외']
[output]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-50-a70a2828dec6> in <module>
----> 1 word_to_index['열외']

KeyError: '열외'


단어 사전에 없는 단어인 ‘열외’를 조회하면 오류가 납니다.


7. 정수 인코딩

지금부터 완성된 단어 사전에 대해 정수 인코딩 을 진행하겠습니다.

def texts_to_sequences(tokenized_X_data, word_to_index):
    encoded_X_data = []
    for sent in tokenized_X_data:
        index_sequences = []
        for word in sent:
            try:
                index_sequences.append(word_to_index[word])
            except KeyError:
                index_sequences.append(word_to_index['<UNK>'])
        encoded_X_data.append(index_sequences)
    
    return encoded_X_data

encoded_X_train = texts_to_sequences(X_train, word_to_index)
encoded_X_test = texts_to_sequences(X_test, word_to_index)

print('토큰화 전 원본 문장 :', train_data['document'][0])
print('정수 인코딩 전 토큰화 :', X_train[0])
print('정수 인코딩 결과 :', encoded_X_train[0])
[output]
토큰화 전 원본 문장 : 아 더빙 진짜 짜증나네요 목소리
정수 인코딩 전 토큰화 : ['아', '더빙', '진짜', '짜증나네요', '목소리']
정수 인코딩 결과 : [41, 418, 9, 6599, 625]


정수 인코딩이 완료되었습니다. 정수 디코딩을 하여 기존 첫번째 샘플을 복원해보겠습니다.

index_to_word = {}
for key, value in word_to_index.items():
    index_to_word[value] = key

decoded_sample = [index_to_word[word] for word in encoded_X_train[0]]
print('기존의 첫번째 샘플 :', X_train[0])
print('복원된 첫번째 샘플 :', decoded_sample)
[output]
기존의 첫번째 샘플 : ['아', '더빙', '진짜', '짜증나네요', '목소리']
복원된 첫번째 샘플 : ['아', '더빙', '진짜', '짜증나네요', '목소리']


8. 패딩

정수 인코딩이 완료된 리뷰 데이터의 최대 길이와 평균 길이를 구해보겠습니다.

print('리뷰의 최대 길이 :', max(len(review) for review in encoded_X_train))
print('리뷰의 평균 길이 :', sum(map(len, encoded_X_train))/len(encoded_X_train))
plt.hist([len(review) for review in encoded_X_train], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
[output]
리뷰의 최대 길이 : 72
리뷰의 평균 길이 : 11.222988727105156

image


전체 샘플 중 길이가 maxlen 이하인 샘플의 비율을 확인하고 패딩 을 진행하겠습니다.

def below_threshold_len(max_len, nested_list):
    count = 0
    for sentence in nested_list:
        if(len(sentence) <= max_len):
            count = count + 1

    print('전체 샘플 중 길이가 %s 이하인 샘플의 비율: %s'%(max_len, (count / len(nested_list))*100))

max_len = 70
below_threshold_len(max_len, encoded_X_train)
[output]
전체 샘플 중 길이가 70 이하인 샘플의 비율: 99.99931220897842


전체 샘플 중 길이가 70 이하인 샘플의 비율은 99.999% 입니다. 패딩을 진행하겠습니다.

def pad_sequences(sentences, max_len):
    features = np.zeros((len(sentences), max_len), dtype=int)
    for index, sentence in enumerate(sentences):
        if len(sentence) != 0:
            features[index, :len(sentence)] = np.array(sentence)[:max_len]
    
    return features

padded_X_train = pad_sequences(encoded_X_train, max_len=max_len)
padded_X_test = pad_sequences(encoded_X_test, max_len=max_len)

print('훈련 데이터의 크기 :', padded_X_train.shape)
print('테스트 데이터의 크기 :', padded_X_test.shape)
[output]
훈련 데이터의 크기 : (145393, 70)
테스트 데이터의 크기 : (48852, 70)


정상적으로 패딩이 완료되었는지 확인하겠습니다.

padded_X_train[:5]
[output]
array([[   41,   418,     9,  6599,   625,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0],
       [  906,   420,    32,   568,     2,   183,  1522,    13,   940,
         6037, 25785,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0],
       [  358,  2814,     1,  2647,  7327, 12029,   190,     5,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0],
       [ 8582,    90, 11211,   206,    47,    65,    15,  4338,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0],
       [ 1006,     1,    18,     1,    13,  6410,     2,  2971,    12,
         5281,     1,   441, 21848,     1,  1071,  3610,  4527,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0,     0,     0,
            0,     0,     0,     0,     0,     0,     0]])


정상적으로 모든 전처리가 완료되었습니다.

Read more