자연어와 기계어
컴퓨터 과학에서 한국어나 영어 같은 사람의 언어를 기계를 위해 고안된 언어와 구별하기 위해 자연어(natural language)라고 부른다. 모든 기계어는 규칙이 먼저이고 규칙이 완성된 이후에야 이 언어를 사용한다. 하지만 사람의 언어는 먼저 사용되고 나중에 규칙이 생긴 경우이다. 자연어에서 규칙은 나중에 체계화 되며 사용자들에 의해 규칙이 무시되거나 변화하기도 한다. 즉 자연어는 복잡하고, 모호하고, 혼란스럽고, 불규칙하며, 끊임없이 변화한다.
미분 가능한 함수인 딥러닝 모델은 수치 텐서만 처리가능하다. 즉 원시 텍스트를 입력으로 사용할 수 없다. 텍스트 벡터화
는 텍스트를 수치 텐서로 바꾸는 과정이다. 다양한 방법이 있지만 모두 동일한 템플릿을 따른다.
텍스트 분할(토큰화)는 세 가지 방법으로 수행 가능하다.
일반적으로 단어 수준 토큰화 혹은 N-그램 토큰화를 항상 사용한다. 텍스트 처리 모델은 두가지로, 단어의 순서를 고려하는 시퀀스모델과 입력 단어의 순서를 무시하고 집합으로 다루는 BoW모델입니다. 시퀀스 모델에서는 단어 수준 토큰화를, BoW모델에서는 N-그램 토큰화를 사용한다.
훈련 데이터에 있는 모든 토큰의 인덱스를 만들어 어휘 사전의 각 항목에 고유한 정수를 할당하는 방법을 사용한다.
그 다음 정수를 신경망이 처리할 수 있도록 원-핫 벡터같은 벡터 인코딩으로 바꿀 수 있다. 이 단계에서는 훈련데이터에서 가장 많이 등장하는 2만 개~ 3만개 단어로 어휘사전을 제한하는 것이 보통적이다.
어휘 사전 인덱스에서 새로운 토큰을 찾을 때 이 토큰이 항상 존재하지 않을 수 있다. 이런 상황을 다루기 위해 예외 어휘 인덱스(OOV)
를 이용한다. 이 인덱스는 어휘 사전에 없는 모든 토큰에 대응되며 이 인덱스는 일반적으로 1이다. 0인 경우는 마스킹 토큰으로, 시퀀스 데이터를 패딩하기 위해 사용된다.
지금까지 소개한 모든 단계는 파이썬으로 쉽게 구현가능하다.
import string
class Vectorizer:
def standardize(self, text):
text = text.lower() # 소문자화
# string.punctuation : 구두점 꾸러미로 구두점이 아닌 것들만 합치기
return "".join(char for char in text if char not in string.punctuation) #
def tokenize(self, text):
return text.split()
def make_vocabulary(self, dataset):
self.vocabulary = {"": 0, "[UNK]": 1}
for text in dataset:
text = self.standardize(text)
tokens = self.tokenize(text)
for token in tokens:
if token not in self.vocabulary:
self.vocabulary[token] = len(self.vocabulary)
self.inverse_vocabulary = dict(
(v, k) for k, v in self.vocabulary.items())
def encode(self, text):
text = self.standardize(text)
tokens = self.tokenize(text)
return [self.vocabulary.get(token, 1) for token in tokens]
def decode(self, int_sequence):
return " ".join(
self.inverse_vocabulary.get(i, "[UNK]") for i in int_sequence)
vectorizer = Vectorizer()
dataset = [
"I write, erase, rewrite",
"Erase again, and then",
"A poppy blooms.",
]
vectorizer.make_vocabulary(dataset)
실전에서는 빠르고 효율적인 케라스 Text Vectorization 층을 사용한다. 이 층은 tf.data 파이프라인이나 케라스 모델에서 사용가능하다. 기본적으로 TextVectorization 층은 텍스트 표준화를 위해 소문자로 바꾸고 구두점을 제거하며 토큰화를 위해 공백으로 나눈다.
get_vocabulary() 메서드를 사용하여 계산된 어휘 사전을 추출할 수 있다. 어휘사전의 처음 두 항목은 마스킹 토큰과 OOV토큰이며 어휘사전의 항목은 빈도순으로 정렬되어있다.
dataset = [
"I write, erase, rewrite",
"Erase again, and then",
"A poppy blooms.",
]
text_vectorization.adapt(dataset)
text_vectorization.get_vocabulary()
예시문장을 인코딩하고 디코딩해보기.
단어는 범주형 특성이고 이를 처리하는 방법은 정해져 있다. 단어를 특성공간의 차원으로 인코딩하거나 범주 벡터로 인코딩한다. 더 중요한 것은 단어를 문장으로 구성하는 방식인 단어 순서를 인코딩하는 방법이다.
자연어에서 순서 문제는 흥미로운 문제이다. 시계열의 타임스텝과 달리 문장에 있는 단어는 표준이 되는 순서가 없다. 예로 영어와 일본어의 문장 구조는 매우 다르다. 어떻게 단어의 순서를 표현하는지는 여러 종류의 NLP 아키텍처를 발생시키는 핵심 질문이다. 텍스트를 단어의 집합으로 처리하는 것이 BoW모델
이다. 단어의 순서를 고려하는 RNN과 트랜스포머 모두 시퀀스 모델
이라고 한다.
우선 이진 인코딩을 사용한 유니그램으로 "the cat sat on the mat"이라는 문장은 {"cat", "mat", "on", "sat", "the"}와 같이 유니그램으로 하나의 벡터로 표현될 수 있다. 멀티-핫 이진 인코딩을 사용하면 하나의 텍스트를 어휘 사전에 있는 단어 개수만큼의 차원을 가진 벡터로 인코딩한다.
실제로 유니그램으로 하여금 모델을 훈련하고 테스트하면 테스트 정확도 89.2%를 얻을 수 있다. 균형 잡힌 이진 분류 데이터셋이기 때문에 실제 모델을 훈련하지 않고 얻을 수 있는 단순한 기준점은 50%이다.
"United States"라는 표현은 united와 states로 잘려 순서가 무시될 경우 문제가 될 수 있다. 이 경우 바이그램을 사용하면 앞의 예시 문장은 다음과 같이 표현된다.
이진 인코딩된 바이그램에서 훈련한 모델의 경우 테스트 정확도는 90.4%가 나온다. 이는 국부적인 순서가 매우 중요함을 나타낸다.
TF-IDF
TF(단어 빈도, term frequency)는 특정한 단어가 문서 내에 얼마나 자주 등장하는지를 나타내는 값으로, 이 값이 높을수록 문서에서 중요하다고 생각할 수 있다. 하지만 단어 자체가 문서군 내에서 자주 사용되는 경우, 이것은 그 단어가 흔하게 등장한다는 것을 의미한다. 이것을 DF(문서 빈도, document frequency)라고 하며, 이 값의 역수를 IDF(역문서 빈도, inverse document frequency)라고 한다. TF-IDF는 TF와 IDF를 곱한 값이다.
- the나 a같은 경우의 단어는 모든 문서에 걸쳐 단어가 자주 등장하게 된다. TF값은 즉 그 단어가 한 문서내에 얼마나 많이 등장하느냐를 나타내며, IDF는 그 단어가 모든 문서들에서 얼마나 많이 등장하느냐를 고려한다. TF-IDF값이 높아지기 위해서는 TF/DF 가 높아져야한다. 그러므로 한 문서에서는 자주 등장하는 단어이면서 모든 문서에서는 등장빈도가 높지 않다면 TFIDF가 높을 것이다.
- TextVectorization객체에서 output_mode를 "tf_idf"로 바꾸어주면 사용 가능하다.
정확도는 이전보다 높아지지 않는 89.8%를 달성하게 된다. 순서를 고려하게 하였던 바이그램화했을 때의 향상도보다 덜하였다. 이유를 그냥 추론하자면, 빈도확인의 방법으로는 한계에 도달한 것으로 판단된다.
시퀀스 모델을 구현하기 위해서는 먼저 입력 샘플을 정수 인덱스의 시퀀스로 표현해야한다. (하나의 단어 => 하나의 정수) 그 다음 모델을 만든다. 정수 시퀀스를 벡터 시퀀스로 바꾸는 가장 간단한 방법은 정수를 원-핫 인코딩하고 원-핫 벡터위에 간단한 양방향 LSTM층을 추가한다.
import tensorflow as tf
inputs = keras.Input(shape=(None,), dtype="int64")
# 인풋 데이터 원핫인코딩
embedded = tf.one_hot(inputs, depth=max_tokens)
# 양방향 LSTM층
x = layers.Bidirectional(layers.LSTM(32))(embedded)
# 드롭아웃
x = layers.Dropout(0.5)(x)
# 이진분류
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
model.summary()
이 모델의 테스트 정확도는 87%로 이진 유니그램 모델만큼 성능이 좋지 않다. 가장 손쉽게 할 수 있지만 원-핫 인코딩은 좋은 방법이 아니다. 더 나은 방법으로 단어 임베딩
이 있다.
원-핫 인코딩으로 무언가를 인코딩 했다면, 그것은 특성공학을 수행한 것이다. 하지만 이 특성공학의 방법은 옳은 것일까? 생각하면 그렇지 않다. 모든 단어는 연관성을 가지고 있다. 즉 movie와 film은 대부분의 문장에서 동일한 의미로 사용된다. 즉 movie와 film은 특성공학의 측면에서 독립적인 관계이지 않다. 하지만 원핫인코딩은 이것들을 모두 독립적으로 처리해버린다.
단어 임베딩
합리적인 단어 벡터 공간에서는 동의어가 비슷한 단어 벡터로 임베딩 될 것이라고 기대할 수 있다. 즉 두 단어 벡터 사이의 기하학적 거리가 가까워야 한다.(비슷한 의미의 단어일 경우) 단어 임베딩은 더 많은 정보를 더 적은 차원으로 압축이 가능하다.
입력 시퀀스가 0으로 가득 차 있으면 모델의 성능에 나쁜 영향을 미친다. 이는 TextVectorization 층에 output_sequence_length=max_length 옵션을 사용했기 때문이다.
두 RNN층이 병렬로 실행되는 양방향 RNN을 사용한다. 한 층은 원래 순서대로 토큰을 처리하고 다른 층은 동일한 토큰을 거꾸로 처리한다. 원래 순서대로 토큰을 바라보는 RNN층은 마지막에 패딩이 인코딩된 벡터만 처리하게 된다. 즉 이러한 부분이 많다면 의미 없는 입력을 처리하면서 점차 내부 상태의 정보가 사라지게 될 것이다. RNN층이 이런 패딩을 건너뛰게 할 방법이 MASKING이다. Embedding 층은 입력 데이터에 상응하는 마스킹을 생성할 수 있고 이 마스킹은 1과 0으로 이루어진 텐서이다.
실전에서는 수동으로 마스킹을 관리할 필요가 없다.(케라스가 마스킹을 처리할 수 있는 모든 층에 자동으로 전달, RNN층은 마스킹된 스텝을 건너뜀)
사전 훈련된 단어 임베딩 사용하기
훈련데이터가 부족할 경우 작업에 맞는 단어 임베딩을 학습할 수 없다. 대신 미리 계산된 임베딩 공간의 임베딩 벡터를 로드할 수 있다. 이전에 학습한 이미지 분류 문제에서 사전 훈련된 컨브넷을 사용하는 이유와 거의 동일하다.
- 책에서는 GloVe 임베딩 데이터베이스 사용
- 사전 훈련된 임베딩 층은 동결시켜 trainable하지 않게 처리
2017년부터 새로운 모델 아키텍처인 트랜스포머(Transformer)
가 대부분의 자연어 처리 작업에서 순환 신경망을 앞지르기 시작했다. 순환 층이나 합성곱 층을 사용하지 않고 neural attention(뉴럴 어텐선)
이라고 부르는 간단한 메커니즘을 사용하여 강력한 시퀀스 모델을 만들 수 있다.
트랜스포머 아키텍처의 기본 구성 요소 중 하나인 셀프 어텐션(self-attention)을 사용해서 트랜스포머 인코더(encoder)를 만들어 IMDB 분류에 적용해본다.
- 컨브넷에서 최대 풀링은 어떤 가설공간의 특성 집합에서 하나의 특성을 선택한다. 가장 중요한 특성을 유지하고 나머지는 버린다.(max weight선택)
- TF-IDF 정규화는 토큰이 전달하는 정보량에 따라 중요도를 할당하며 강조의 역할을 가진다.
이러한 논리로 모델은 어떤 특성에 '조금 더 주의'를 기울이고 다른 특성에 '조금 덜 주의'를 기울인다. 즉 이것이
어텐션
의 개념이다.
임베딩 공간에서 단어 하나는 고정된 위치를 가진다. 즉 이 공간에 있는 모든 다른 단어와의 관계가 고정된다. 하지만 단어는 일반적으로 문맥에 따라 다르다. 스마트한 임베딩 공간이라면 주변 단어에 따라 단어의 벡터 표현이 달라져야한다. 이것을 고려하게 해주는 것이셀프 어텐션
이다.
실전에서는 MultiHeadAttention 층을 제공한다. 아래 코드에서 다음과 같은 질문이 생길 수 있다.
num_head = 4
embed_dim = 256
mha_layer = MultiHeadAttention(num_heads=num_heads, key_dim=embed_dim)
outputs = mha_layer(inputs, inputs, inputs)
시퀀스-투-시퀀스 모델
outputs = sum(inputs * pairwisescores(inputs, inputs))
inputs(A)에 있는 모든 토큰이 Inputs(B)에 있는 모든 토큰에 얼마나 관련되어 있는지 계산하고 이 점수를 사용하여 inputs(C)에 있는 모든 토큰의 가중치 합을 계산한다를 의미한다.
- outpiuts = sum(values * pairwise_scores(query, keys)
query, keys, values = 쿼리, 키, 값
EXAMPLE
쿼리로 "dogs on the beach"를 전달한다고 했을 때 데이터베이스에는 사진이 존재한다. 사진들은 값이 될 것이고 값에 해당하는 키들이 존재한다. 키는 단어토큰들로 존재하며 쿼리와 매치될 것이다. 쿼리와의 매칭점수를 통해 가장 높은 점수의 값을 반환받는 것이다.
- 실제로 키와 값은 같은 시퀀스인 경우가 많다. 기계 번역에서는 쿼리가 타깃 시퀀스이며 소스 시퀀스는 키와 값의 역할을 한다.
이것이 MultiHeadAttention층에 inputs를 세 번 전달해야 하는 이유이다. 멀티 헤드 어텐션은 셀프 어텐션 메커니즘의 변형으로 셀프 어텐션의 출력 공간이 독립적으로 학습되는 부분 공간으로 이는 대체로 깊이별 분리 합성곱의 작동 방식과 유사하다. 즉 한 그룹내의 특성은 다른 특성과 연관되어 있지만 다른 그룹에 있는 특성과는 거의 독립적이다.
# Layer 층을 상속하여 구현한 트랜스포머 인코더
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
class TransformerEncoder(layers.Layer):
def __init__(self, embed_dim, dense_dim, num_heads, **kwargs):
super().__init__(**kwargs)
self.embed_dim = embed_dim
self.dense_dim = dense_dim
self.num_heads = num_heads
self.attention = layers.MultiHeadAttention(
num_heads=num_heads, key_dim=embed_dim)
self.dense_proj = keras.Sequential(
[layers.Dense(dense_dim, activation="relu"),
layers.Dense(embed_dim),]
)
self.layernorm_1 = layers.LayerNormalization()
self.layernorm_2 = layers.LayerNormalization()
def call(self, inputs, mask=None):
if mask is not None:
mask = mask[:, tf.newaxis, :]
attention_output = self.attention(
inputs, inputs, attention_mask=mask)
proj_input = self.layernorm_1(inputs + attention_output)
proj_output = self.dense_proj(proj_input)
return self.layernorm_2(proj_input + proj_output)
# 모델 저장을 위한 직렬화 구현
def get_config(self):
config = super().get_config()
config.update({
"embed_dim": self.embed_dim,
"num_heads": self.num_heads,
"dense_dim": self.dense_dim,
})
return config
언제 BoW모델 대신 시퀀스 모델을 사용하나요?
실제로 IMDB로 여러 모델을 만들어 실험한 결과 바이그램 위에 몇 개의 Dense층을 쌓은 모델만큼의 성능이 나온 경우는 없었다. 트랜스포머 기반 시퀀스 모델만이 최상위 성능을 모든 경우에서 보여주는 것은 아니다.
- 새로운 텍스트 분류 작업을 시도할 때 훈련 데이터에 있는 샘플 개수와 샘플에 있는 평균 단어 개수 사이의 비율에 주의를 기울여야한다. 이 비율이 1,500 BoW 모델의 성능이 더 나을 것이다!(http://mng.bz/AOzK)
- 시퀀스 모델은 훈련 데이터가 많고 샘플의 길이가 짧은 경우에 잘 동작한다.
EXAMPLE
1000개의 단어 길이를 가진 10만 개의 문서분류는 바이그램 모델을, 40개의 단어로 이루어진 50만 개의 트윗을 분류해야한다면 트랜스포머 인코더를 사용해야한다. 즉 우선적으로 단어의 개수와 문서의 수의 비율을 우선적으로 고려해볼 필요가 있다.
시퀀스 투 시퀀스 모델은 입력으로 시퀀스를 받아 이를 다른 시퀀스로 바꾼다. 기계번역, 텍스트 요약, 질문 답변, 챗봇, 텍스트 생성등에서 사용된다.
시퀀스 투 시퀀스 모델의 일반적인 구조로는 인코더와 디코더로 구성되며, 인코더가 소스 시퀀스를 인코딩하고 디코더가 초기 시드 토큰을 사용하여 시퀀스의 첫 번째 토큰을 예측한다. 지금까지 예측된 시퀀스를 디코더에 다시 주입하고 다음 토큰을 생성하는 식으로 종료 토큰이 생성될 때까지 반복한다.