분석하고싶은코코

개체명 인식(NER)(2)_Bi_LSTM을 통한 개체명 인식 본문

머신러닝&딥러닝/NLP

개체명 인식(NER)(2)_Bi_LSTM을 통한 개체명 인식

코코로코코 2023. 9. 20. 16:46
반응형

이번에는 지난번에 작업한 품사 태깅 데이터를 바탕으로 개체명 인식하는 모델을 LSTM으로 구현하는 작업을 진행해보겠습니다.

 

 

1) NLTK(ENG) - BiLSTM 개체명 인식

우선 개체명 인식 모델을 구현하기 위해서는 품사태깅 작업이 된 데이터가 필요합니다. 그래서 우선은 영어의 개체명 인식 작업을 먼저 진행해보도록 하겠습니다. 데이터는 NLTK에서 제공해주는 treebank 데이터를 사용하였습니다.

 

import nltk
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.utils import to_categorical
from sklearn.model_selection import train_test_split

# treebank에 대한 데이터가 없다면 주석 해제하고 실행
# nltk.download('treebank')

# 토큰화에 품사 태깅이 된 데이터 받아오기
tagged_sentences = nltk.corpus.treebank.tagged_sents()

print("품사 태깅이 된 문장 개수: ", len(tagged_sentences))
print(tagged_sentences[0])
품사 태깅이 된 문장 개수:  3914
[('Pierre', 'NNP'), ('Vinken', 'NNP'), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD'), ('.', '.')]

 

사용할 데이터는 품사 태깅 된 문장은 총 3,914개이고 어떻게 태깅되어 있는지 확인해보기 위해 첫 번째 문장의 태깅정보를 확인해보았습니다.

 

지금의 데이터 형태로는 컴퓨터가 이해하지 못하므로 Keras의 토크나이저를 통해 정수 인코딩까지 진행해보겠습니다. 우선 (단어, 품사) 형태로 되어 있는 데이터를 단어와 품사를 분리하여 단어의 정보만 모인 리스트, 품사 정보만 모인 리스트로 분리하는 작업을 진행합니다. 이 작업은 토크나이저를 각각 초기화 할것이기 때문에 이와같이 진행합니다.

# 문장(단어)와 품사 태깅 정보를 분리
sentences, pos_tags = [], [] 
for tagged_sentence in tagged_sentences: 
    sentence, tag_info = zip(*tagged_sentence)
    sentences.append(list(sentence)) # 단어 정보
    pos_tags.append(list(tag_info)) # 품사 태깅 정보

# 토크나이저 생성함수
def tokenize(samples):
  tokenizer = Tokenizer()
  tokenizer.fit_on_texts(samples)
  return tokenizer

# 문장, 태깅 토크나이저 초기화
src_tokenizer = tokenize(sentences)
tar_tokenizer = tokenize(pos_tags)

# 토크나이징 결과 확인
vocab_size = len(src_tokenizer.word_index) + 1
tag_size = len(tar_tokenizer.word_index) + 1


print('샘플의 최대 길이 : %d' % max(len(l) for l in sentences))
print('샘플의 평균 길이 : %f' % (sum(map(len, sentences))/len(sentences)))
print('단어 집합의 크기 : {}'.format(vocab_size))
print('태깅 정보 집합의 크기 : {}'.format(tag_size))
plt.hist([len(s) for s in sentences], bins=50)
plt.xlabel('length of samples')
plt.ylabel('number of samples')
plt.show()
샘플의 최대 길이 : 271
샘플의 평균 길이 : 25.722024
단어 집합의 크기 : 11388
태깅 정보 집합의 크기 : 47

 

하나의 문장이 대부분 100개 이하의 단어들로 구성되어 있는게 보이고 평균적으로 약 25개 단어, 최대 271개 단어로 구성되어 있음을 확인했습니다. 토크나이징 후 단어는 총 11,388개 정보가 있고 태깅정보는 47개 있음을 확인할 수 있습니다.

 

모델에 해당 데이터를 사용하기 위해서는 각 문장에 대한 정보의 길이를 맞춰줄 필요가 있습니다. 그렇기 때문에 각 문장이 몇개의 단어들로 이뤄졌는지 확인하는 작업을 거쳤습니다. 그래프에서 보면 130~140근처까지는 어느정도 데이터가 있찌만 그 이후로는 잘 없는 것응로 보입니다. 따라서 140을 최대 길이로 잡고 패딩 작업을 진행해보겠습니다.

# 각 토크나이저를 사용해 정수 인코딩
X_train = src_tokenizer.texts_to_sequences(sentences)
y_train = tar_tokenizer.texts_to_sequences(pos_tags)

# 길이가 140으로 패딩 작업 진행
max_len = 140
X_train = pad_sequences(X_train, padding='post', maxlen=max_len)
y_train = pad_sequences(y_train, padding='post', maxlen=max_len)

# 모델링을 위한 데이터 셋 만들기
X_train, X_test, y_train, y_test = train_test_split(X_train, y_train, test_size=.2, random_state=777)

 

이제 LSTM에 만들어둔 데이터를 통해 학습시키기만 하면 양방향 LSTM을 통한 개체명 인식 모델 구현이 끝이 납니다.

임베딩 백터와 은닉 상태의 차원을 128차원으로 지정하였습니다. 양방향 LSTM을 구현할 것이기 때문에 Bidirectional로 LSTM을 감싸주고 개체명 인식은 many-to-one이 아닌 many-to-many 방식을 사용하므로 return_sequences를 True값으로 줍니다.

 

모델의 손실함수를 'sparse_categorical_crossentropy'를 적용하였는데 이는 레이블에 원-핫 인코딩 작업을 해주지 않았기 때문에 해당 손실함수를 사용하였습니다. 만약 레이블에 원-핫 인코딩을 진행하였다면 'categorical_crossentropy'로 손실함수를 지정해주면 됩니다.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, LSTM, InputLayer, Bidirectional, TimeDistributed, Embedding
from tensorflow.keras.optimizers import Adam

embedding_dim = 128
hidden_units = 128

model = Sequential()
model.add(Embedding(vocab_size, embedding_dim, mask_zero=True))
model.add(Bidirectional(LSTM(hidden_units, return_sequences=True)))
model.add(TimeDistributed(Dense(tag_size, activation=('softmax'))))

model.compile(loss='sparse_categorical_crossentropy', optimizer=Adam(0.001), metrics=['accuracy'])
model.fit(X_train, y_train, batch_size=128, epochs=7, validation_data=(X_test, y_test))

 

이렇게 훈련시킨 모델이 어떻게 작동하는지 확인해보겠습니다.

index_to_word = src_tokenizer.index_word #정수 -> 단어
index_to_tag = tar_tokenizer.index_word #정수 -> 태깅정보

i = 10 # 확인하고 싶은 테스트용 샘플의 인덱스.
y_predicted = model.predict(np.array([X_test[i]]))
y_predicted = np.argmax(y_predicted, axis=-1) # 확률 벡터를 정수 레이블로 변환.

print("{:15}|{:5}|{}".format("단어", "실제값", "예측값"))
print(35 * "-")

# 선택한 문장에 구성된 단어들의 태깅정보와 예측값 출력
for word, tag, pred in zip(X_test[i], y_test[i], y_predicted[0]):
    if word != 0: # PAD값은 제외함.
        print("{:17}: {:7} {}".format(index_to_word[word], index_to_tag[tag].upper(), index_to_tag[pred].upper()))
단어             |실제값  |예측값
-----------------------------------
in               : IN      IN
addition         : NN      NN
,                : ,       ,
buick            : NNP     NNP
is               : VBZ     VBZ
a                : DT      DT
relatively       : RB      RB
respected        : VBN     VBN
nameplate        : NN      NN
among            : IN      IN
american         : NNP     NNP
express          : NNP     NNP
card             : NN      NN
holders          : NNS     NNS
,                : ,       ,
says             : VBZ     VBZ
0                : -NONE-  -NONE-
*t*-1            : -NONE-  -NONE-
an               : DT      DT
american         : NNP     NNP
express          : NNP     NNP
spokeswoman      : NN      NN
.                : .       .

 

2)  BiLSTM(KOR) 개체명 인식

 

진행항 방식은 영어의 개체명 인식과 크게 다른게 없습니다. 다른점은 태깅된 데이터를 불러오는 점인데 

'ukairia777'님의 깃허브에서 배포하고 있는 tensorflow 한국어 ner태깅 작업을 위한 예시 데이터들을 사용하였습니다.

또한, 마지막 모델링 작업에서 이전에는 라벨에 대한 원-핫 인코딩을 진행하지 않아서 손실함수로 'sparse_categorical_crossentropy'를 사용하였지만 이번에는 정수 인코딩된 라벨에 원-핫 인코딩을 진행하여 손실함수를 'categorical_crossentropy'를 사용하였습니다. 데이터량이 영어때와 달리 10배 이상 크기를 갖고 있어 한 번 학습에 오랜시간이 걸려 에포크는 3으로 수정하고 진행하였습니다. 이외에는 진행방식은 영어 개체명인식 부분과 다른점은 없습니다.

 

import pandas as pd
import urllib.request

# 데이터 다운로드
urllib.request.urlretrieve("https://raw.githubusercontent.com/ukairia777/tensorflow-nlp-tutorial/main/18.%20Fine-tuning%20BERT%20(Cls%2C%20NER%2C%20NLI)/dataset/ner_train_data.csv", filename="ner_train_data.csv")
urllib.request.urlretrieve("https://raw.githubusercontent.com/ukairia777/tensorflow-nlp-tutorial/main/18.%20Fine-tuning%20BERT%20(Cls%2C%20NER%2C%20NLI)/dataset/ner_test_data.csv", filename="ner_test_data.csv")
urllib.request.urlretrieve("https://raw.githubusercontent.com/ukairia777/tensorflow-nlp-tutorial/main/18.%20Fine-tuning%20BERT%20(Cls%2C%20NER%2C%20NLI)/dataset/ner_label.txt", filename="ner_label.txt")

# 훈련, 테스트, 라벨 정보
train_ner_df = pd.read_csv("ner_train_data.csv")
test_ner_df = pd.read_csv("ner_test_data.csv")
labels = [label.strip() for label in open('ner_label.txt', 'r', encoding='utf-8 ')]

# 문장(단어)와 품사 태깅 정보를 분리
def div_split(data):
    result = [texts.split() for texts in data]
    return result

sentences = div_split(train_ner_df['Sentence'])
pos_tags = div_split(train_ner_df['Tag'])


def tokenize(samples):
  tokenizer = Tokenizer()
  tokenizer.fit_on_texts(samples)
  return tokenizer

# 토크나징
src_tokenizer = tokenize(sentences)
tar_tokenizer = tokenize(pos_tags)

vocab_size = len(src_tokenizer.word_index) + 1
tag_size = len(tar_tokenizer.word_index) + 1


X_train = src_tokenizer.texts_to_sequences(sentences)
y_train = tar_tokenizer.texts_to_sequences(pos_tags)

# 최대길이가 영어와 다름
max_len = 70
X_train = pad_sequences(X_train, padding='post', maxlen=max_len)
y_train = pad_sequences(y_train, padding='post', maxlen=max_len)

X_train, X_test, y_train_int, y_test_int = train_test_split(X_train, y_train, test_size=.2, random_state=777)

# 원-핫 인코딩
y_train = to_categorical(y_train_int, num_classes=tag_size)
y_test = to_categorical(y_test_int, num_classes=tag_size)

# BiLSTM모델링
# 해당 부분은 영어 부분과달리 데이터가 많아 실행하는 PC의 성능에 따라 시간차이가 클 수 있습니다. 

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, LSTM, InputLayer, Bidirectional, TimeDistributed, Embedding
from tensorflow.keras.optimizers import Adam

embedding_dim = 128
hidden_units = 128

model = Sequential()
model.add(Embedding(vocab_size, embedding_dim, mask_zero=True))
model.add(Bidirectional(LSTM(hidden_units, return_sequences=True)))
model.add(TimeDistributed(Dense(tag_size, activation=('softmax'))))

model.compile(loss='categorical_crossentropy', optimizer=Adam(0.001), metrics=['accuracy'])
model.fit(X_train, y_train, batch_size=128, epochs=3, validation_data=(X_test, y_test))
index_to_word = src_tokenizer.index_word
index_to_tag = tar_tokenizer.index_word

i = 20 # 확인하고 싶은 테스트용 샘플의 인덱스.

test_data = X_test[i]
true_data = y_test[i]
y_predicted = model.predict(np.array([test_data])) # 입력한 테스트용 샘플에 대해서 예측값 y를 리턴
y_predicted = np.argmax(y_predicted, axis=-1) # 확률 벡터를 정수 레이블로 변환.
labels = np.argmax(true_data, -1) # 원-핫 인코딩을 다시 정수 인코딩으로 변경함.

# 결과 출력
print("{:15}|{:5}|{}".format("단어", "실제값", "예측값"))
print(35 * "-")

for word, tag, pred in zip(test_data, labels, y_predicted[0]):
    if word != 0: # PAD값은 제외함.
        print("{:17}: {:7} {}".format(index_to_word[word], index_to_tag[tag].upper(), index_to_tag[pred].upper()))

 

단어             |실제값  |예측값
-----------------------------------
4년               : DAT-B   DAT-B
전                : DAT-I   DAT-I
한탄바이러스           : TRM-B   TRM-B
판정을              : O       O
받았을              : O       O
때                : O       O
여린뼈가             : O       O
심하게              : O       O
마모된              : O       O
정서였던             : O       O
궐녀               : O       O
.                : O       O

 

예시에서는 각 단어에 대한 태깅 정보를 정확하게 예측하기는 했지만 훈련이 끝났을떄 훈련 데이터 98% 테스트 데이터에 80% 정확성을 보여주었습니다. 숫자로 보면 높다고 볼 수 있지만 대부분의 데이터가 의미가 없는 데이터로 'O'로 태깅되었었기 때문에 보다 개체명 인식을 위한 개체 단어에 대한 태깅 예측률이 높다고 보기는 어렵습니다. 그렇다면 어떻게 확인해야할까요?? 그 방법을 위해서 정확도(accuracy)가 아닌 f1스코어를 통해 확인해볼 수 있습니다.

 

seqeval 패키지를 사용한 f1스코어 확인방법은 아래 방법을 참고하시면 됩니다. 

from seqeval.metrics import f1_score, classification_report

def sequences_to_tag(sequences):
    result = []
    # 전체 시퀀스로부터 시퀀스를 하나씩 꺼낸다.
    for sequence in sequences:
        word_sequence = []
        # 시퀀스로부터 확률 벡터 또는 원-핫 벡터를 하나씩 꺼낸다.
        for pred in sequence:
            # 정수로 변환. 예를 들어 pred가 [0, 0, 1, 0 ,0]라면 1의 인덱스인 2를 리턴한다.
            pred_index = np.argmax(pred)            
            # index_to_ner을 사용하여 정수를 태깅 정보로 변환. 'PAD'는 'O'로 변경.
            word_sequence.append(index_to_ner[pred_index].replace("PAD", "O"))
        result.append(word_sequence)
    return result

y_predicted = model.predict([X_test])
pred_tags = sequences_to_tag(y_predicted)
test_tags = sequences_to_tag(y_test)

print("F1-score: {:.1%}".format(f1_score(test_tags, pred_tags)))
print(classification_report(test_tags, pred_tags))

 

 

+ 추가적으로 개체명 인식에 대한 모델의 성능을 향상시키고 싶다면 CRF(Conditional Random Field)층을 활성화 함수의 값들을 받는 층으로 마지막에 추가시켜 주면 됩니다.

 

CRF(Conditional Random Field)란?

CRF는 softmax regression의 일종이다. softmax + softmax?? 이상하지 않은가요?? 그런데 다른점은 CRF는 각 벡터에 softmax를 취하는게 아니라 하나의 시퀀스를 벡터로봅니다. 즉, CRF층을 올리기전에 마지막에 softmax 활성화 함수를 지나서 값들이 나왔고 이게 하나의 벡터인데 단어가 4개라면 각 단어들이 마지막 활성화함수를 지나 벡터들이 나와 4개의 벡터들이 존재할것입니다.(LSTM에서 many-to-many로 return_sequences 옵션을 True로 설정해주었었음) 이 4개의 벡터는 하나의 시퀀스이고 이 하나의 시퀀스를 가지고 softmax regression을 한 번 더 진행하게 되는 것입니다.  그림으로 보면 확실하게 이해하기 쉬우니 참고하시면 될 것 같습니다.

밑에 링크는 CRF에 대한 자세한 설명을 작성해둔 블로그가 있어 링크 남깁니다.

https://lovit.github.io/nlp/machine%20learning/2018/04/24/crf/

반응형