Study/MachineLearning

[Deep Learning with Python] 4-1. 텍스트 데이터 다루기

soohwan_justin 2021. 9. 12. 11:46

이번 포스트는 케라스 창시자에게 배우는 딥러닝(Deep Learning with Python)의 ch.6을 참고하였습니다.

 

이번 포스트를 읽기 전에, [Do it!] 시리즈를 먼저 읽는것을 권장합니다. 이 포스트에서는 [Do it!] 시리즈에 중복해서 나온 내용들을 많이 생략하였습니다.

 

이 책은 딥 러닝의 입문서로서는 적절하지 않다고 생각됩니다. 딥 러닝에 대한 어느정도 지식이 있고, 케라스의 사용방법에 대해 좀 더 자세히 알고 싶은 분께 권장합니다.

 

 

 

 

 

 

이번에는 텍스트, 시계열 또는 일반적인 시퀀스 데이터를 처리할 수 있는 딥러닝 모델에 대해 알아봅니다. 시퀀스 데이터를 처리하는 기본적인 딥러닝 모델은 순환 신경망과 1D 합성곱 신경망 이렇게 2가지입니다.

 

 

1. 텍스트 데이터 다루기

 

텍스트는 가장 흔한 시퀀스 형태의 데이터입니다. 텍스트는 단어의 시퀀스 또는 문자의 시퀀스로 이해할 수 있는데, 일반적으로 단어 수준의 시퀀스를 많이 사용합니다.

 

가장 먼저 알아볼 시퀀스 처리용 딥러닝 모델은 텍스트를 사용하여 기초적인 자연어 이해 문제를 처리할 수 있는데, 이런 모델은 문서 분류, 감성 분석, 저자 식별 등의 어플리케이션에 적합합니다. 참고로, 이 모델들은 사람처럼 텍스트를 "이해"하는 것이 아니라, 문자 언어에 대한 통계적 구조를 만들어 문제를 해결합니다. 컴퓨터 비전에서 사용했던 패턴 인식처럼, 자연어 처리를 위한 딥러닝도 단어, 문장, 문단에 적용한 패턴을 인식하는 것입니다.

 

이 모델 또한 텍스트 원본을 입력으로 사용할 수 없습니다. 딥러닝 모델은 수치형 텐서만 다룰 수 있습니다. 따라서 텍스트도 수치형 텐서로 변환해야 하는데, 이를 텍스트 벡터화(vectorizing text)라고 합니다. 텍스트 벡터화의 방법에는 몇 가지가 있습니다.

 

1. 텍스트를 단어로 나누고, 각 단어를 하나의 벡터로 변환하기

2. 텍스트를 문자로 나누고, 각 문자를 하나의 벡터로 변환하기

3. 텍스트에서 단어나  문자의 n-gram을 추출하여 각각의 n-gram을 하나의 벡터로 변환하기. n-gram은 연속된 단어나 문자의 그룹이며, 텍스트에서 단어나 문자를 하나씩 이동하면서 추출합니다.

 

텍스트를 나누는 이런 단위(단어, 문자, n-gram)을 토큰이라고 합니다. 그리고 이렇게 텍스트를 토큰으로 나누는 작업을 토큰화(tokenization)라고 합니다. 모든 텍스트 벡터화 과정은 특정 종류의 토큰화를 적용하고, 이렇게 생성된 토큰에 수치형 벡터를 매핑하는 것으로 이루어집니다. 토큰과 벡터를 매핑하는 방법에는 원-핫 인코딩과 토큰 임베딩(또는 단어 임베딩)이 있습니다.

 

케라스를 사용한 단어 수준의 원-핫 인코딩을 하는 코드는 다음과 같습니다.

from keras.preprocessing.text import Tokenizer

samples = ['The cat sat on the mat.',
           'The dog ate my homework.']
# 가장 빈도수가 높은 10개의 단어 선택
tokenizer = Tokenizer(num_words=10)
 
# tokenizer 객체 생성
tokenizer.fit_on_texts(samples)
# 문자열을 정수 인덱스 리스트로 변환
sequences = tokenizer.texts_to_sequences(samples)
print(sequences)
# 이진 벡터로 나타낸 원-핫 인코딩 벡터
one_hot_results = tokenizer.texts_to_matrix(samples, mode='binary')
print(one_hot_results)
 
word_index = tokenizer.word_index
 
print('토큰의 개수 : %s' % len(word_index))

 

원-핫 해싱(one-hot hashing)은 원-핫 인코딩의 변종 중 하나입니다. 이 방식은 어휘 사전에 있는 고유한 토큰의 수가 너무 커서 모두 다루기 어려울 때 사용합니다. 각 단어에 인덱스를 할당하고 이를 딕셔너리에 저장하는 대신, 각 단어를 해싱하여 고정된 크기의 벡터로 변환합니다.

 

이 방식의 장점은 단어 인덱스가 필요 없기 때문에 메모리를 절약하고 온라인으로 데이터를 인코딩 할 수 있지만, 해시 충돌이 문제가 됩니다. 따라서 이 방법을 사용하기 위해서는 해싱 공간의 차원이 고유 토큰의 전체 개수보다 훨씬 커야합니다.

 

 

단어 임베딩

 

단어와 벡터를 매핑하는 또다른 방법은 단어 임베딩 입니다. 원-핫 인코딩으로 만든 벡터는 희소(sparse)하고, 차원이 매우 높습니다(사용할 단어의 수와 같습니다). 반면, 단어 임베딩은 저차원의 실수 벡터입니다(희소 벡터의 반대인 밀집 벡터입니다). 단어 임베딩은 데이터로부터 학습되며, 보통 256, 512 또는 큰 어휘 사전을 다룰때는 1024차원의 단어 임베딩을 사용합니다.

 

단어 임베딩을 만드는 방법에는 두 가지가 있습니다.

 

- 관심 대상인 문제와 함께 단어 임베딩도 같이 학습합니다. 랜덤한 단어 벡터로 시작하며, 신경망의 가중치를 학습하는 것처럼 단어 벡터도 학습합니다.

- 다른 머신 러닝에서 사용했던 단어 임베딩을 불러와서 사용합니다. 이를 사전 훈련된 단어 임베딩(pretrained word embedding)이라고 합니다.

 

 

임베딩 층을 사용하여 단어 임베딩 학습하기

 

단어와 밀집 벡터를 매핑하는 가장 간단한 방법은, 벡터를 랜덤하게 고르는 것입니다. 하지만 이 방법의 문제는 임베딩 공간이 구조적이지 않다는 것입니다. 예를 들어, "적절한"과 "적당한"은 비슷한 의미로 사용되지만 완전히 다른 임베딩을 갖습니다. 심층 신경망 이렇게 구조적이지 않은 임의의 임베딩 공간을 이해하는 것은 어렵습니다.

 

단어 벡터 사이에 추상적이고 기하학적인 관계를 얻기 위해서는 그 단어들 사이의 의미 관계를 반영해야 합니다. 단어 임베딩은 언어를 기하학적 공간에 매핑하는 것이므로, 비슷한 단어 끼리는 비슷한 벡터로 임베딩 되어야 합니다. 일반적으로 두 벡터 사이의 L2 거리는 단어 사이의 의미 거리와 관계되어 있습니다. 즉, 비슷한 단어는 가까운 위치에 임베딩되어야 합니다.

 

다음의 예시를 보겠습니다.

 

 

위 그림의 네 단어들은 2D 공간에 매핑되어있습니다. 이 벡터 표현을 사용하면 각 단어 간의 의미 관계를 기하학적으로 나타낼 수 있습니다. 여기서 "개"와 "고양이"는 애완동물입니다. "늑대"는 "개"의 야생동물이라고 볼 수 있고, "호랑이"는 "고양이"의 야생동물이라고 볼 수 있습니다. 여기서 "개"에서 "늑대"로 이동하는 벡터를 똑같이 "고양이"에 적용하면, 고양이는 "호랑이"로 바뀌게 됩니다.

 

"늑대"에서 "호랑이"로 가는 것은 종류가 바뀌는 변환입니다. "늑대"에서 "호랑이"로 가는 변환을 똑같이 "개"에다 적용하면 "개"는 "고양이"로 바뀌게 됩니다. 

 

 

실제 단어 임베딩 공간에서 기하학적 변환의 일반적인 예시는 "성별"과 "복수(plural)" 벡터입니다. 예를 들어, "kid"벡터에 "female"벡터를 더하면 "daughter"이 될 것이고, "male" 벡터를 더하면 "son"이 될 것입니다. "plural" 벡터를 더하면 "kids"가 되겠네요. 단어 임베딩 공간은 이렇게 해석 가능하고 잠재적으로 유용한 수천 개의 벡터를 특성으로 갖습니다.

 

하지만 사람의 언어를 완벽하게 매핑해서 어떤 자연어 처리 작업에도 사용할 수 있는 이상적인 단어 임베딩 공간은, 아직까지는 없습니다. 이는 사람의 언어에도 없습니다. 좋은 단어 임베딩 공간을 만드는 것은 문제에 따라 크게 달라집니다. 예를 들어, 영화 리뷰에 쓰는 단어 임베딩 공간과 법률 문서를 분류하기 위한 단어 임베딩 공간은 분명히 다를 것입니다. 왜냐하면 같은 단어도 어떤 상황에서 어떤 목적으로 쓰이느냐에 따라 뜻이 너무 달라지고, 이에 따라 단어 사이의 관계도 달라지기 때문입니다.

 

예를 들어, 영화 리뷰 감성 분석을 목적으로 한 단어 임베딩 공간이 있고, 농작물을 키우는 방법에 대한 단어 임베딩 공간이 있다고 해보겠습니다. 한국어를 기준으로, "사과"라는 단어를 임베딩 할 때는, 영화 리뷰 감성 분석 시에는 감정에 관련된 다른 단어들과 비슷한 벡터로 임베딩 되어야 할 것입니다. 그러나 농작물을 키우는 방법에서는 다른 과일들과 비슷한 벡터로 임베딩 되어야 할 것입니다.

 

따라서 새로운 작업에는 새로운 임베딩을 학습해야 하며, 이는 역전파를 사용하면 쉽게 만들 수 있고, 케라스로 편리하게 구현할 수 있습니다. 임베딩 층의 가중치를 학습하면 됩니다.

 

임베딩 층의 객체를 생성하는 코드는 다음과 같습니다.

 

from keras.layers import Embedding
 
# (가능한 토큰의 개수, 임베딩 차원)
embedding_layer = Embedding(1000, 64)

 

임베딩 층은 (samples, sequence_length) 크기의 2차원 정수  텐서를 입력으로 받으며, 각 샘플은 정수의 시퀀스입니다. 배치에 있는 모든 시퀀스는 길이가 같아야 하므로, 작은 길이의 시퀀스는 모자란 자리를 0으로 채우고, 긴 길이의 시퀀스는 잘라버립니다.

 

임베딩 층은 (samples, sequence_length, embedding_dimensionality)크기의 3D 실수형 텐서를 리턴합니다. 이렇게 리턴된 3D 텐서는 RNN층이나 1D 합성곱 층에서 처리됩니다.

 

임베딩 층의 객체를 생성할 때, 가중치는 다른 층처럼 랜덤하게 초기화되며, 훈련을 하는 동안 이 단어 벡터는 역전파를 통해 점점 조정되어 임베딩 공간을 구성합니다. 훈련이 끝나면 이 공간은 특정 문제에 특화된 구조를 갖게 됩니다.

 

이제 이 방법을 IMDB 영화 리뷰 감성 예측 문제에 적용해보겠습니다. 영화 리뷰에서 가장 많이 나오는 단어 1만개를 추출하고, 각 리뷰당 20개의 단어만 사용합니다. 이번에 만들어볼 네트워크는 1만개의 단어에 대해 8차원의 임베딩을 학습합니다. 2D의 정수 시퀀스 입력은 이 임베딩을 통해 3D 실수형 텐서로 출력될 것이고, 이 출력을 2D로 펼쳐서 분류를 위한 Dense 층을 훈련할 것입니다.

 

from keras.datasets import imdb
from keras import preprocessing
 
max_features = 10000
maxlen = 20
 
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)
x_train = preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)

 

 

다음으로, 모델을 만들고 훈련 해봅니다.

 

from keras.models import Sequential
from keras.layers import Flatten, Dense, Embedding
 
model = Sequential()
# 임베딩 층의 출력 크기는 (samples, maxlen, 8) 입니다
model.add(Embedding(10000, 8, input_length=maxlen))
model.add(Flatten())
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
model.summary()

 

history = model.fit(x_train, y_train,
                    epochs=10, batch_size=32,
                    validation_split=0.2)

 

검증 정확도는 약 75%입니다. 20개의 단어만을 갖고 분류한 것 치고는 꽤 좋은 결과입니다. 위 모델은 임베딩 시퀀스를 펼치고 하나의 Dense 층을 훈련했으므로, 시퀀스의 각 단어를 독립적으로 다루었습니다. 단어 사이의 관계나 문장 구조를 고려하기 위해서는 순환층이나 1D 합성곱층이 필요합니다.

 

 

사전 훈련된 단어 임베딩 사용하기

 

훈련 데이터가 부족하면 작업에 맞는 적절한 단어 임베딩을 학습할 수 없습니다. 이럴 때는 이미지 처리를 위한 합성곱 신경망에서 기존의 모델을 불러온 것 처럼 단어 임베딩도 이미 훈련된 모델을 불러올 수 있습니다.

 

 

원본 텍스트에서 단어 임베딩까지 구현해보기

 

먼저, 앞서 만들었던 것과 비슷한 모델을 사용할 것입니다. 문장을 벡터의 시퀀스로 임베딩하고 펼친 후, Dense층을 훈련합니다.

 

- 원본 데이터 다운받기

 

먼저, 링크에서 데이터를 다운받고, 압축을 해제합니다.

 

그 후, 압축을 해제한 폴더의 리뷰 파일들을 읽고, 해당 내용들을 저장하고, 이를 부정, 긍정으로 레이블링 합니다.

 

import os
 
imdb_dir = './datasets/aclImdb'
train_dir = os.path.join(imdb_dir, 'train')
 
labels = []
texts = []
 
for label_type in ['neg', 'pos']:
    dir_name = os.path.join(train_dir, label_type)
    for fname in os.listdir(dir_name):
        if fname[-4:] == '.txt':
            f = open(os.path.join(dir_name, fname), encoding="utf8")
            texts.append(f.read())
            f.close()
            if label_type == 'neg':
                labels.append(0)
            else:
                labels.append(1)

 

 

그 후, 다음과 같이 데이터를 토큰화하고, 훈련 세트와 검증 세트를 나눕니다. 이번에는 200개의 샘플만 사용할 것입니다.

from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np
 
maxlen = 100
training_samples = 200
validation_samples = 10000
 
max_words = 10000
 
tokenier = Tokenizer(num_words=max_words)
tokenizer.fit_on_texts(texts)
sequences = tokenizer.texts_to_sequences(texts)
 
word_index = tokenizer.word_index
print('토큰의 수 : %s' % len(word_index))
 
data = pad_sequences(sequences, maxlen=maxlen)
 
labels = np.asarray(labels)
print('데이터 텐서의 크기 : ', data.shape)
print('레이블 텐서의 크기 : ', labels.shape)
 
indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]
 
x_train = data[:training_samples]
y_train = labels[:training_samples]
x_val = data[training_samples: training_samples + validation_samples]
y_val = labels[training_samples: training_samples + validation_samples]

 

GloVe 단어 임베딩 내려받기

 

링크에서 사전에 계산된 임베딩을 다운받습니다. glove.6B.zip 파일을 다운받으세요. 이 임베딩은 40만개의 단어에 대한 100차원의 임베딩 벡터를 포함하고있습니다.

 

 

다운받은  파일의 압축을 해제하고, 파일을 확인해보면 다음과 같은 형태로 되어있습니다.

 

 

이제 이 데이터를 파싱할 것입니다. 이 파일을 읽어서 줄 단위로 나누고, 딕셔너리에 각 값을 추가합니다.

glove_dir = './datasets/glove.6B'
 
embeddings_index={}
f = open(os.path.join(glove_dir, 'glove.6B.100d.txt'), encoding="utf8")
for line in f:
    values = line.split()
    word = values[0]
    coefs = np.asarray(values[1:], dtype='float32')
    embeddings_index[word] = coefs
f.close()
 
print('단어 벡터의 수 : %s' %len(embeddings_index))

 

다음으로, 임베딩 층에 주입할 수 있도록 임베딩 행렬을 만듭니다. 이 행렬의 크기는 (max_words, embedding_dim)입니다. 이 행렬의 i번째 원소는 i번째 인덱스에 해당하는 단어 벡터이며, 이 벡터의 차원은 embedding_dim입니다.  인덱스가 0인 경우는 max_words의 범위 밖에 있는 단어들입니다.

embedding_dim = 100
 
embedding_matrix = np.zeros((max_words, embedding_dim))
for word, i in word_index.items():
    if i < max_words:
        embedding_vector = embeddings_index.get(word)
        if embedding_vector is not None:
            embedding_matrix[i] = embedding_vector

 

모델 정의하기

 

이전과 동일한 구조의 모델을 사용합니다.

from keras.models import Sequential
from keras.layers import Embedding, Flatten, Dense
 
model = Sequential()
model.add(Embedding(max_words, embedding_dim, input_length=maxlen))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))
model.summary()

 

 

사전 훈련된 임베딩 로드하기

 

임베딩 층은 하나의 가중치 행렬을 가집니다. 2D 부동 소수 행렬이며, 각 i번째 원소는 i번째 인덱스의 단어 벡터입니다. 다음 코드는 위에서 정의한 모델에 사전에 훈련된 임베딩 행렬을 적용하고, 이를 동결시킵니다.

model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False

 

모델 훈련하고 평가하기

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
 
history = model.fit(x_train, y_train,
                    epochs=10, batch_size=32,
                    validation_data=(x_val, y_val))
 
model.save_weights('pre_trained_glove_model.h5')

%matplotlib inline
 
import matplotlib.pyplot as plt
 
acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']
 
epochs = range(1, len(acc) + 1)
 
plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
 
plt.figure()
 
plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
 
plt.show()

 

정확도는 약 50%로, 이진 분류이므로 성능이 별로 좋지 않은 것을 알 수 있습니다. 훈련 샘플이 긍정과 부정 비율이 반반이므로, 무작위로 예측하는 분류 모델을 만든다고 해도 50%는 나올 것입니다. 또한 과대적합이 빠르게 시작되는데, 이 문제들은 모두 샘플 수가 너무 적기 때문입니다.

 

사전 훈련된 단어 임베딩을 사용하지 않거나 임베딩 층을 동결하지 않고 훈련을 할 수도 있습니다. 이 방법은 해당 작업에 특화된 입력 토큰의 임베딩을 학습할 것이며, 데이터가 풍부하다면 일반적으로 사전 훈련된 단어 임베딩보다 성능이 훨씬 좋습니다. 200개의 훈련 샘플만을 사용할 것이지만, 그래도 한번 구현해보겠습니다.

 

from keras.models import Sequential
from keras.layers import Embedding, Flatten, Dense
 
model = Sequential()
model.add(Embedding(max_words, embedding_dim, input_length=maxlen))
model.add(Flatten())
model.add(Dense(32, activation='relu'))
model.add(Dense(1, activation='sigmoid'))
model.summary()
 
model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
 
history = model.fit(x_train, y_train,
                    epochs=10,
                    batch_size=32,
                    validation_data=(x_val, y_val))

이 예시에서는 사전 훈련된 임베딩을 사용하는 것이 더 나은것 같아보입니다.

 

 

마지막으로, 테스트 데이터에서 모델을 평가해볼 것입니다. 먼저, 데이터를 토큰화해야 합니다.

test_dir = os.path.join(imdb_dir, 'test')
 
labels = []
texts = []
 
for label_type in ['neg', 'pos']:
    dir_name = os.path.join(test_dir, label_type)
    for fname in sorted(os.listdir(dir_name)):
        if fname[-4:] == '.txt':
            f = open(os.path.join(dir_name, fname), encoding="utf8")
            texts.append(f.read())
            f.close()
            if label_type == 'neg':
                labels.append(0)
            else:
                labels.append(1)
 
sequences = tokenizer.texts_to_sequences(texts)
x_test = pad_sequences(sequences, maxlen=maxlen)
y_test = np.asarray(labels)

 

다음 코드로 첫 번째 모델을 불러오고, 평가해봅니다.

model.load_weights('pre_trained_glove_model.h5')
model.evaluate(x_test, y_test)

 

51%정도의 정확도를 보여줍니다.