Yeongmin Baek
8 min read

Categories

Tags

이번 포스트에서는 tensorflow-text, tf_sentencepiece 모듈을 이용하여 학습 코드 상에서 토크나이징을 진행하고, 결과 모델을 export하는 과정까지의 경험을 기록한다. tensorflow-text는 텍스트 전처리 과정의 연산들을 Tensorflow 그래프 상에포함할 수 있도록 해주고, tf-sentencepiece는 자연어 처리에서 자주 이용되는 Sentencepiece 토크나이저를 tensorflow-text 토크나이저 형식에 맞춰 쉽게 이용할 수 있도록 해준다.

학습 측면에서 살펴보면, tensorflow를 이용하여 학습을 진행하는 과정은 1) 데이터 전처리 및 TFRecord 파일 생성 2) 1에서 생성된 파일들을 이용하여 학습의 두 단계로 구성된다. 이전에는 TFRecord를 만들 때, 모든 데이터들을 토크나이징하고 인덱싱하여 저장했지만, tensorflow-text를 이용하면 TFRecord를 String으로 저장하고 이를 읽어서 바로 토크나이징하여 학습에 이용할 수 있다.

서빙 측면에서 보면, 일반적으로 Tensorflow로 학습된 모델을 SavedModel 형태로 저장하고 이를 tf-serving, TFLite 등 을 이용하여 서빙에 이용한다. 많은 NLP 모델들은 Sentencepiece, WordPiece 등의 토크나이저를 이용하는데, 이를 서빙하기 위해 모델 서버에 요청을 보내기 전에 별도의 토크나이징 + 인덱싱 과정이 필요했다. tensorflow-text를 이용하면 모델 서버에 바로 텍스트로 요청을 보내고, 이를 처리할 수 있다.


1. Tensorflow-text

Tensorflow-text 공식문서 설명을 읽어보면, Tensorflow 코어에서는 지원하지 않는 텍스트 피쳐를 다루기 위한 유용한 기능들을 제공하는 라이브러리이다.(Tensorflow2.x 버전부터 이용이 가능한 것 같다.) 설치는 pip을 이용해 간단히 할 수 있으므로 생략한다.(단 tensorflow와 tensorflow-text의 마이너 버전은 맞춰줘야 한다.) 위에서 잠깐 언급했지만, 이 모듈의 가장 큰 장점은 텍스트 전처리 과정을 Tensorflow graph 상에 포함시킬 수 있다는 점이다. 이를 통해 전처리 스크립트를 관리하기 쉽고, 학습과 추론시에 완전히 동일한 전처리 과정을 보장할 수 있다. Unicode, Normalization 등 다양한 기능들을 제공하지만, 이번 글에서는 Tokenization에 집중한다.

Tokenization

토크나이저는 입력 텍스트를 토큰의 단위로 잘라준다. 가장 간단하게는 띄어쓰기나 캐릭터 단위로 분리할 수 있다. 최근에는 Sentencepiece나 WordPiece 등의 토크나이저가 주로 이용된다. Tensorflow-text에서는 기본적으로 WhitespaceTokenizerUnicodeScriptTokenizer를 제공한다. WhitespaceTokenizer는 띄어쓰기 단위로 토크나이징을 하고, UnicodeScriptTokenizer는 띄어쓰기와 유사하지만 띄어쓰기 이외에 몇몇 Unicode를 기준으로 토크나이징을 한다. 아래 예제와 같이, 선언을 하고 .tokenize() method를 이용하면 손쉽게 텍스트를 자를 수 있다.

import tensorflow_text as tf_text

tokenizer = tf_text.UnicodeScriptTokenizer()
tokens = tokenizer.tokenize(['everything not saved will be lost.',
                             u'Sad☹'.encode('UTF-8')])
print(tokens.to_list())
[['everything', 'not', 'saved', 'will', 'be', 'lost', '.'],
 ['Sad', '\xe2\x98\xb9']]


2. tf-sentencepiece

이 모듈은 구글의 Sentencepiece 공식 구현체에 구현되어 있으며, pip으로 쉽게 설치할 수 있다. Sentencepiece의 설명은 이 논문을 참고할 수 있고, 위 레포에는 BPE, Unigram-LM등 다양한 알고리즘들이 구현되어 있다. 학습을 진행할 텍스트 파일만 있으면 몇 줄 안되는 코드로 쉽게 토크나이저를 학습하고, 불러올 수 있다. 자세한 과정은 생략하고, 학습된 sentencepiece 모델 파일(.model)이 있다고 가정하고 이후 단계를 진행한다.

Sentencepiece 모델 불러오기(기존 방법)

일반적으로 python에서 학습된 Sentencepiece 모델을 불러오고, tokenize하는 과정은 다음과 같다. tokenize는 .encode() method로 할 수 있으며, out_type 인자가 str인 경우 잘려진 토큰들의 리스트가 int인 경우 잘려진 토큰들의 사전 index의 리스트가 반환된다.

import sentencepiece as spm

sp = spm.SentencePieceProcessor(model_file=MODEL_FILE_PATH)
print(sp.encode("토크나이저 테스트", out_type=str))
print(sp.encode("토크나이저 테스트", out_type=int))
['▁', '토크', '나', '이', '저', '▁테스트']
[3, 14338, 30, 7, 512, 13167]

Sentencepiece 모델 불러오기(tensorflow-text)

tensorflow-text 에서 학습된 Sentencepiece 모델을 불러오고, tokenize하는 과정은 다음과 같다. 위 과정과 유사하고, SentencepieceTokenizer를 초기화 할 때, out_type 인자를 조정하여 출력 값의 타입을 설정할 수 있다. 출력 타입은 tf.RaggedTensor인데, 토크나이저에서 처음 접했고 공식문서를 참조하면 하나 이상의 차원에서 각각의 원소가 다른 길이를 갖는 텐서이다. 토크나이징 결과의 길이는 입력 문장에 따라 달라지기 때문에 위 텐서타입을 반환한다. 결과는 기존 방법과 동일함을 확인할 수 있다.

import tensorflow as tf
import tensorflow_text as tf_text

model = open(f"{MODEL_PREFIX}.model", "rb").read()
tensorflow_sp_out_int = tf_text.SentencepieceTokenizer(model=model)
tensorflow_sp_out_str = tf_text.SentencepieceTokenizer(model=model, out_type=tf.string)
print(tensorflow_sp_out_str.tokenize(["토크나이저 테스트"]))
print(tensorflow_sp_out_int.tokenize(["토크나이저 테스트"]))
<tf.RaggedTensor [[b'\xe2\x96\x81', b'\xed\x86\xa0\xed\x81\xac', b'\xeb\x82\x98', b'\xec\x9d\xb4', b'\xec\xa0\x80', b'\xe2\x96\x81\xed\x85\x8c\xec\x8a\xa4\xed\x8a\xb8']]>
<tf.RaggedTensor [[3, 14338, 30, 7, 512, 13167]]>


3. NSMC 데이터를 이용한 실습

NSMC(Naver Sentiment Movie Corpus) 를 이용해 Tokenizing 과정이 포함된 간단한 모델을 학습하고, 학습된 모델을 Export하는 과정 까지 진행한다. 모든 스크립트는 이 저장소의 코드를 이용한다.

3.1. 데이터 전처리

데이터는 위 링크의 ratings_train.txt, ratings_test.txt 두 개의 파일을 이용하고, 각 파일은 id ,document,label로 구성된다. label은 해당 영화 리뷰가 긍정적인지(1), 부정적인지(0)로 저장되어 있다. 일반적인 tensorflow 학습은 데이터를 TFRecord 형식으로 저장하고, 이를 이용한다. Tensorflow 공식 튜토리얼 을 참고하여 간단하게 NSMC 데이터를 읽어서 TFRecord 형태로 저장하는 코드는 다음과 같다.

def _bytes_feature(value):
    if isinstance(value, type(tf.constant(0))):
        value = value.numpy() # BytesList won't unpack a string from an EagerTensor.
    return tf.train.Feature(bytes_list=tf.train.BytesList(value=[value]))

def _int64_feature(value):
    return tf.train.Feature(int64_list=tf.train.Int64List(value=[value]))

def serialize_example(text, label):
    feature = {
        'text': _bytes_feature(text),
        'label': _int64_feature(label),
    }

    example_proto = tf.train.Example(features=tf.train.Features(feature=feature))
    return example_proto.SerializeToString()

with open(TRAIN_FILE_PATH) as f:
    with tf.io.TFRecordWriter(TRAIN_TF_RECORD_PATH) as writer:
        for line in f.readlines()[1:]:
            text, label = line.strip("\n").split("\t")[1:]
            example = serialize_example(text.encode("utf-8"), int(label))
            writer.write(example)

3.2. 토크나이징 과정을 포함한 모델

tf-text를 이용하면 토크나이징 과정을 Tensorflow Graph 연산에 포함시킬 수 있다. 이번 포스트에서는 간단한 구현을 위해 tensorflow.keras.Model.call() 메소드 내부에 토크나이징을 포함한다. 명시적으로 전처리 과정을 구분하고 싶다면, 토크나이징 부분을 밖으로 빼고, @tf.function의 형태로 구현하여 그래프에 포함시킬 수도 있다.

모델은 Tokenizing → Embedding → BiLSTM → Dense 레이어 순으로 구성되며, 최종적으로 두 개의 logit을 출력한다. 위에서 잠깐 언급했듯이, tf_text.Tokenizer.tokenize() 메소드는 tf.RaggedTensor를 반환하는데, 이 텐서 타입의 to_tensor() 메소드를 이용하면 [batch_size, sequnece_length] 형태의 Dense 텐서를 얻을 수 있다. (Dense 텐서로 변환하면 이후 연산을 진행할 수 있다.)

class SimpleTextClassifier(tf.keras.Model):
    def __init__(self, 
                 tokenizer_path,
                 vocab_size, 
                 hidden_size, 
                 output_size, 
                 default_value=0, 
                 max_sequence_length=128,
                 *args, 
                 **kwargs):
        super(SimpleTextClassifier, self).__init__(*args, **kwargs)
        
        self.default_value = default_value
        self.max_sequence_length = max_sequence_length
        
        self.tokenizer = tf_text.SentencepieceTokenizer(model=open(tokenizer_path, "rb").read())
        self.embedding = tf.keras.layers.Embedding(vocab_size, hidden_size)
        self.lstm_layer = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(hidden_size))
        self.output_layer = tf.keras.layers.Dense(output_size)
    
    def call(self, inputs, training=False):
        # Tokenizing
        tokenized_inputs = self.tokenizer.tokenize(inputs).to_tensor(
            default_value=self.default_value, 
            shape=[None, self.max_sequence_length]
        )

        sequence_mask = tokenized_inputs != self.default_value
        embedding = self.embedding(tokenized_inputs, training=training)
        encoded_output = self.lstm_layer(embedding, mask=sequence_mask, training=training)
        output = self.output_layer(encoded_output, training=training)
        return output

3.3. 학습

Tensorflow Customized Training Loop 튜토리얼을 참고하여 아래와 같은 학습코드를 구성할 수 있다.

def forward_step(batch, model, loss_fn, metrics, training=False):
    output = model(batch["text"], training=training)
    label = tf.one_hot(batch["label"], 2)
    loss = loss_fn(label, output)
    argmax_output = tf.argmax(output, -1)
    
    metrics["accuracy"].update_state(batch["label"], argmax_output)
    metrics["precision"].update_state(batch["label"], argmax_output)
    metrics["recall"].update_state(batch["label"], argmax_output)
    metrics["loss"].update_state(loss)
    
    return loss

@tf.function
def train_step(batch, model, optimizer, loss_fn, metrics):
    with tf.GradientTape() as tape:
        loss = forward_step(batch, model, loss_fn, metrics, True)
        
    gradients = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(gradients, model.trainable_variables))

@tf.function
def valid_step(batch, model, loss_fn, metrics):
    forward_step(batch, model, loss_fn, metrics, False)
  
model = SimpleTextClassifier(SPM_MODEL_PATH, 16000, 128, 2)
optimizer = tf.keras.optimizers.Adam(1e-3)
loss_fn = tf.keras.losses.BinaryCrossentropy(from_logits=True)
checkpoint = tf.train.Checkpoint(model=model)
ckpt_manager = tf.train.CheckpointManager(checkpoint, MODEL_SAVE_PATH, max_to_keep=5)

for idx, batch in enumerate(train_dataset):
    train_step(batch, model, optimizer, loss_fn, train_metrics)
    
    if (idx + 1) % logging_interval == 0:
        logging_metric(idx + 1, train_metrics)
    
    if (idx + 1) % valid_interval == 0:
        for batch in valid_dataset:
            valid_step(batch, model, loss_fn, valid_metrics)
        print("====Validation====")
        logging_metric(idx + 1, valid_metrics)
        print("==================")
        ckpt_manager.save()

위 코드를 이용하면 아래와 같이 학습이 진행된다. loss는 정상적으로 잘 떨어지고, 3가지 메트릭(Accuracy, Precision, Recall)도 함께 향상되는 모습을 볼 수 있다.

Step: 10|accuracy: 0.5148|precision: 0.5000|recall: 0.0628|loss: 0.6915
Step: 20|accuracy: 0.5781|precision: 0.6710|recall: 0.3854|loss: 0.6857
Step: 30|accuracy: 0.6961|precision: 0.6760|recall: 0.7078|loss: 0.6433
...
Step: 1080|accuracy: 0.8742|precision: 0.8844|recall: 0.8483|loss: 0.3087
Step: 1090|accuracy: 0.8492|precision: 0.8455|recall: 0.8358|loss: 0.3351
Step: 1100|accuracy: 0.8578|precision: 0.8795|recall: 0.8202|loss: 0.3345
====Validation====
Step: 1100|accuracy: 0.8564|precision: 0.8745|recall: 0.8344|loss: 0.3334
==================

3.4. 학습된 모델 Export

서빙을 위해 Tensorflow-Serving, TFLite 등에서 이용할 수 있도록 학습된 모델을 SavedModel형식으로 Export 한다. Tensorflow SavedModel 튜토리얼를 참고하여, 예측용 함수의 input_signature를 지정하고 이를 Export할 수 있다. 모델 자체에 토크나이징이 포함되어 있기 때문에, 입력으로 tf.string을 받는다. 또한 0,1 각각 클래스에 대한 확률 값을 출력으로 얻기 위해 모델 출력 값에 softmax연산을 진행한다.

@tf.function(input_signature=[tf.TensorSpec(shape=[None], dtype=tf.string)])
def predict_fn(inputs):
    model_output = model(inputs)
    return tf.nn.softmax(model_output, -1)
print(predict_fn(tf.constant(["진짜 제 인생영화 ㅠㅠ"])))

signatures = {
    'serving_default': predict_fn.get_concrete_function(),
}
tf.saved_model.save(model, "./output/saved_model/00/", signatures)

위 실행의 결과로 아래 값을 얻을 수 있으며, 99%의 확률로 긍정(1)을 예측하는 것을 볼 수 있다. (모델이 잘 학습되었다!) 또한 string을 입력으로 하여 바로 모델 결과를 얻을 수 있다!

<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[0.00181852, 0.99818146]], dtype=float32)>

위에서 Export한 모델과 tensorflow-servining을 이용하여 간단하게 모델 서버를 띄울 수 있다.

docker run -p 8501:8501 \
    -v /Users/baek-yeongmin/Documents/GitHub/tf-text-practice/output/saved_model/:/models/saved_model \
    -e MODEL_NAME=saved_model tensorflow/serving

위와 같은 입력을 보내고 결과 값을 확인해보면 같은 값을 반환하고, 띄워진 서버는 String 입력을 잘 처리함을 볼 수 있다.

curl -i -d '{"instances": ["진짜 제 인생영화 ㅠㅠ"]}' \
    -X POST http://localhost:8501/v1/models/saved_model:predict
HTTP/1.1 200 OK
Content-Type: application/json
Date: Sun, 11 Oct 2020 06:20:26 GMT
Content-Length: 58

{
    "predictions": [[0.00181851524, 0.998181462]]
}


4. 후기

모델 코드 혹은 예측 함수에 토크나이징 과정을 포함하여 Tensorflow Graph를 구성한 후 이를 Export하면 텍스트를 입력으로 하는 모델 서버를 구성할 수 있다. 이를 이용하면 NLP 모델을 서빙하는 과정이 별도의 토크나이징 서버를 구성했던 이전에 비해 훨씬 간단해진다! tensorflow-text를 써보면서 앞으로 서빙에 있어서 tensorflow는 부동의 첫번째 선택지가 될 것 같다는 생각이 들었다. tensorflow2로 버전이 올라오면서 모델/학습 코드의 작성도 간편해졌는데, 연구 목적이 아니라 서빙까지 고려한다면 torch보다는 tensorflow를 선택하지 않을까..?