09_DL(Deep_Learning)
29_개체명 인식(양방향LSTM)
chuuvelop
2025. 5. 8. 23:12
728x90
양방향 LSTM
- RNN이나 LSTM은 시퀀스 또는 시계열 데이터 처리에 특화되어 은닉층에서 과거의 정보를 기억할 수 있음
- 그러나 순환 구조의 특성상 데이터가 입력 순으로 처리되기 때문에 이전 시점의 정보만 활용할 수 밖에 없다는 단점이 존재
- 문장이 길어질수록 성능이 저하
- 예) ios앱 (개발)은 맥북이 필요합니다
- 위의 경우에 ios와 앱이라는 단어만으로는 개발 이라는 단어를 유추하기 힘듦
- 문장의 앞부분보다 뒷부분에 중요한 정보가 있음
- 양방향 LSTM(Bidirectional LSTM)
- 기존 LSTM 계층에 역방향으로 처리하는 LSTM 계층을 추가해 양방향에서 문장의 패턴을 분석할 수 있도록 구성
- 입력 문장을 양방향에서 처리하기 때문에 시퀀스 길이가 길어져도 정보 손실 없이 처리가 가능
개체명 인식(Named Entity Recognition)
- 각 개체의 유형을 인식
- 문장 내에 포함된 어떤 단어가 인물, 장소, 날짜 등을 의미하는 단어인지 인식하는 것
- 개체명 인식은 문장을 정확하게 해석하기 위해 반드시 해야 하는 전처리 과정임
- 예) 날짜와 지역에 대해 개체 인식을 할 수 있는 모델이 있다고 가정할 경우
- 입력 문장 : 내일 부산 날씨 알려줘
- 문장 의도 : 날씨 요청
- 개체명 인식 결과 : 내일 - 날짜, 부산 -지역
- 단순한 질문 형태라면 개체명 사전을 구축해 해당 단어들과 매핑되는 개체명을 찾을 수도 있음
- 문장 구조가 복잡하거나 문맥에 따라 단어의 의미가 바뀐다면 딥러닝 모델을 활용해야 함
- 개체명 사전 구축 방식은 신조어나 사전에 포함되지 않은 단어는 처리 불가능하며 사람이 직접 사전 데이터를 관리해야 하기 때문에 관리비용이 많이 필요함
BIO 표기법
- Beginning, Inside, Outside의 약자
- 각 토큰마다 태그를 붙이기 위해 사용
- Beginning : 개체명이 시작되는 단어에 "B-개체명"으로 태그
- Inside : "B-개체명"과 연결되는 단어일 때 "I-개체명"으로 태그
- Outside : 개체명 이외의 모든 토큰에 태그
- 예) 오늘부터 홍길동은 삼성전자에 근무합니다
- 오늘 -> B-date
- 부터 -> O
- 홍길동 -> B-Person
- 은 -> O
- 삼성 -> B-Company
- 전자 -> I-Company
- 에 -> O
- 근무 -> O
- 합니다 -> O
- 두개 이상의 토큰이 하나의 개체를 구성하는 경우가 많기 때문에 BIO표기법을 사용
국립국어원 언어정보나눔터 개체명 인식 모델을 위한 말뭉치
- ;으로 시작하는 문장 : 원본 문장
- $로 시작하는 문장 : 해당 문장에서 NER 처리된 결과
- 개체명 인식 모델은 단어 토큰을 입력했을 때 출력되는 NER 태그값을 예측
- 예) "삼성전자"를 입력한다면 B_OG(단체) 태그가 출력되도록 학습
import matplotlib.pyplot as plt
import tensorflow as tf
from tensorflow.keras import preprocessing
from sklearn.model_selection import train_test_split
import numpy as np
from tensorflow import keras
from seqeval.metrics import f1_score, classification_report
# 학습 파일 불러오기
def read_file(file_name):
sents = []
with open(file_name, "r", encoding = "utf-8") as f:
lines = f.readlines()
for idx, l in enumerate(lines):
if l[0] == ";" and lines[idx + 1][0] == "$":
this_sent = []
elif l[0] == "$" and lines[idx - 1][0] == ";":
continue
elif l[0] == "\n":
sents.append(this_sent)
else:
this_sent.append(tuple(l.split()))
return sents
# 학습용 말뭉치 데이터를 불러옴
corpus = read_file("./data/ner/train.txt")
corpus[0]
[('1', '한편', 'NNG', 'O'),
('1', ',', 'SP', 'O'),
('2', 'AFC', 'SL', 'O'),
('2', '챔피언스', 'NNG', 'O'),
('2', '리그', 'NNG', 'O'),
('3', 'E', 'SL', 'B_OG'),
('3', '조', 'NNG', 'I'),
('3', '에', 'JKB', 'O'),
('4', '속하', 'VV', 'O'),
('4', 'ㄴ', 'ETM', 'O'),
('5', '포항', 'NNP', 'O'),
('6', '역시', 'MAJ', 'O'),
('7', '대회', 'NNG', 'O'),
('8', '8강', 'NNG', 'O'),
('9', '진출', 'NNG', 'O'),
('9', '이', 'JKS', 'O'),
('10', '불투명', 'NNG', 'O'),
('10', '하', 'VV', 'O'),
('10', '다', 'EC', 'O'),
('11', '.', 'SF', 'O')]
# 말뭉치 데이터에서 단어와 BIO 태그만 불러와 학습용 데이터셋 생성
sentences = []
tags = []
for t in corpus:
tagged_sentence = []
sentence = []
bio_tag = []
for w in t:
tagged_sentence.append((w[1], w[3]))
sentence.append(w[1])
bio_tag.append(w[3])
sentences.append(sentence)
tags.append(bio_tag)
- 단어와 BIO 태그만 이용해 학습용 데이터셋을 생성
print("샘플 크기 :", len(sentences))
print("0번째 심플 문장 시퀀스 :")
print(sentences[0])
print("0번째 샘플 bio 태그:")
print(tags[0])
print("샘플 문장 시퀀스 최대 길이 : ", max(len(l) for l in sentences))
print("샘플 문장 시퀀스 평균 길이 : ", (sum(map(len, sentences)) / len(sentences)))
샘플 크기 : 3555
0번째 심플 문장 시퀀스 :
['한편', ',', 'AFC', '챔피언스', '리그', 'E', '조', '에', '속하', 'ㄴ', '포항', '역시', '대회', '8강', '진출', '이', '불투명', '하', '다', '.']
0번째 샘플 bio 태그:
['O', 'O', 'O', 'O', 'O', 'B_OG', 'I', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O']
샘플 문장 시퀀스 최대 길이 : 168
샘플 문장 시퀀스 평균 길이 : 34.03909985935302
# 토크나이저 정의
sent_tokenizer = preprocessing.text.Tokenizer(oov_token = "OOV")
sent_tokenizer.fit_on_texts(sentences)
tag_tokenizer = preprocessing.text.Tokenizer(lower = False)
tag_tokenizer.fit_on_texts(tags)
# 단어 사전 및 태그 사전 크기
vocab_size = len(sent_tokenizer.word_index) + 1
tag_size = len(tag_tokenizer.word_index) + 1
print("BIO 태그 사전 크기 : ", tag_size)
print("단어 사전 크기: ", vocab_size)
BIO 태그 사전 크기 : 8
단어 사전 크기: 13834
# 단어 -> 숫자
tag_tokenizer.word_index
{'O': 1, 'I': 2, 'B_OG': 3, 'B_PS': 4, 'B_DT': 5, 'B_LC': 6, 'B_TI': 7}
# 학습용 단어 시퀀스 생성
x_train = sent_tokenizer.texts_to_sequences(sentences)
y_train = tag_tokenizer.texts_to_sequences(tags)
print(x_train[0])
print(y_train[0])
[183, 11, 4276, 884, 162, 931, 402, 10, 2608, 7, 1516, 608, 145, 1361, 414, 4, 6347, 2, 8, 3]
[1, 1, 1, 1, 1, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
# index to word / index to NER 정의
index_to_word = sent_tokenizer.index_word # 시퀀스 인덱스를 단어로 변환하기 위해 사용
index_to_ner = tag_tokenizer.index_word # 시퀀스 인덱스를 NER로 변환하기 위해 사용
index_to_ner[0] = "PAD"
# 숫자 -> 단어
index_to_ner
{1: 'O',
2: 'I',
3: 'B_OG',
4: 'B_PS',
5: 'B_DT',
6: 'B_LC',
7: 'B_TI',
0: 'PAD'}
# 시퀀스 패딩 처리
max_len = 40
x_train = preprocessing.sequence.pad_sequences(x_train, padding = "post", maxlen = max_len)
y_train = preprocessing.sequence.pad_sequences(y_train, padding = "post", maxlen = max_len)
- 벡터의 크기는 시퀀스의 평균 길이보다 넉넉하게 40으로 정의
# 학습 데이터와 테스트 데이터를 8 : 2 비율로 분리
x_train, x_test, y_train, y_test = train_test_split(x_train, y_train, test_size = 0.2,
random_state = 26)
x_train.shape, x_test.shape, y_train.shape, y_test.shape
((2844, 40), (711, 40), (2844, 40), (711, 40))
y_train[0]
array([3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 5, 2, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
# 출력데이터를 원-핫 인코딩
y_train = tf.keras.utils.to_categorical(y_train, num_classes = tag_size)
y_test = tf.keras.utils.to_categorical(y_test, num_classes = tag_size)
x_train.shape, x_test.shape, y_train.shape, y_test.shape
((2844, 40), (711, 40), (2844, 40, 8), (711, 40, 8))
x_train[0]
array([ 877, 19, 2000, 6246, 224, 9, 12977, 477, 12,
552, 2, 6, 92, 2000, 6246, 224, 5245, 12978,
96, 306, 12, 296, 2, 106, 228, 24, 56,
18, 8, 3, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0])
y_train[0]
array([[0., 0., 0., 1., 0., 0., 0., 0.],
[0., 1., 0., 0., 0., 0., 0., 0.],
[0., 1., 0., 0., 0., 0., 0., 0.],
[0., 1., 0., 0., 0., 0., 0., 0.],
[0., 1., 0., 0., 0., 0., 0., 0.],
[0., 1., 0., 0., 0., 0., 0., 0.],
...
# 모델 정의(Bi-LSTM)
model = keras.Sequential()
model.add(keras.Input(shape = (max_len,)))
model.add(keras.layers.Embedding(vocab_size, 64, mask_zero = True)) # 64 -> 높여주는게 좋지만 시간단축을 위해
model.add(keras.layers.Bidirectional(keras.layers.LSTM(256, return_sequences = True, dropout = 0.5,
recurrent_dropout = 0.25)))
model.add(keras.layers.TimeDistributed(keras.layers.Dense(tag_size, activation = "softmax")))
# model.add(keras.layers.Dense(tag_size, activation = "softmax"))
- mask_zero = True : 0으로 패딩된 값을 마스킹하여 네트워크의 뒤로 전달되지 않게 만듦
- TimeDistributed : many-to-many 로 동작. 각 타임스텝마다 출력이 있어야 함
- TimeDistributed
- 주로 시퀀스 데이터나 시계열 데이터를 다루는 신경망 모델에서 사용
- 각 타임스텝에 동일한 레이어를 적용할 때 사용
- 주로 RNN, CNN과 결합
- 입력 데이터의 각 타임스텝에 동일한 레이어(Dense, Conv 등)를 독립적으로 적용할 수 있게 해주는 래퍼(wrapper)
- 시퀀스 데이터의 각 요소에 동일한 처리를 반복적으로 수행할 수 있음
- 특징
- 타임스텝별 독립 처리 : 각 타임 스텝에 동일한 레이어를 독립적으로 적용
- 레이어 재사용 : 레이어의 가중치가 모든 타임스텝에 공유되어 효율적인 학습이 가능
- 유연성 : 다양한 레이어를 TimeDistributed 로 감싸서 시퀀스 데이터에 적용할 수 있음
model.summary()
model.compile(loss = "categorical_crossentropy", optimizer = "Adam", metrics = ["accuracy"])
es_cb = keras.callbacks.EarlyStopping(patience = 4, restore_best_weights = True)
model.fit(x_train, y_train, batch_size = 64, epochs = 100, validation_split = 0.2,
callbacks = [es_cb])
Epoch 1/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 18s 299ms/step - accuracy: 0.6008 - loss: 1.2902 - val_accuracy: 0.6153 - val_loss: 0.5740
Epoch 2/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 232ms/step - accuracy: 0.6213 - loss: 0.5548 - val_accuracy: 0.6159 - val_loss: 0.4925
Epoch 3/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 230ms/step - accuracy: 0.6245 - loss: 0.4560 - val_accuracy: 0.6274 - val_loss: 0.3982
Epoch 4/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 226ms/step - accuracy: 0.6416 - loss: 0.3500 - val_accuracy: 0.6305 - val_loss: 0.3491
Epoch 5/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6409 - loss: 0.2863 - val_accuracy: 0.6370 - val_loss: 0.3271
Epoch 6/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 229ms/step - accuracy: 0.6522 - loss: 0.2505 - val_accuracy: 0.6406 - val_loss: 0.3171
Epoch 7/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 226ms/step - accuracy: 0.6692 - loss: 0.2215 - val_accuracy: 0.6438 - val_loss: 0.3066
Epoch 8/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6701 - loss: 0.1999 - val_accuracy: 0.6438 - val_loss: 0.3011
Epoch 9/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6810 - loss: 0.1769 - val_accuracy: 0.6462 - val_loss: 0.3011
Epoch 10/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6767 - loss: 0.1614 - val_accuracy: 0.6476 - val_loss: 0.3082
Epoch 11/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6807 - loss: 0.1518 - val_accuracy: 0.6484 - val_loss: 0.3013
Epoch 12/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6824 - loss: 0.1443 - val_accuracy: 0.6496 - val_loss: 0.2941
Epoch 13/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6870 - loss: 0.1195 - val_accuracy: 0.6504 - val_loss: 0.3039
Epoch 14/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.6952 - loss: 0.1065 - val_accuracy: 0.6527 - val_loss: 0.3114
Epoch 15/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 228ms/step - accuracy: 0.7014 - loss: 0.1003 - val_accuracy: 0.6519 - val_loss: 0.3179
Epoch 16/100
36/36 ━━━━━━━━━━━━━━━━━━━━ 8s 225ms/step - accuracy: 0.7018 - loss: 0.0894 - val_accuracy: 0.6529 - val_loss: 0.3121
<keras.src.callbacks.history.History at 0x2258960ffb0>
print("평가 결과 : ", model.evaluate(x_test, y_test))
23/23 ━━━━━━━━━━━━━━━━━━━━ 1s 31ms/step - accuracy: 0.6367 - loss: 0.3225
평가 결과 : [0.31454142928123474, 0.6407173871994019]
- BIO 태그는 실제 의미 있는 태그보다 의미 없는 O 태그가 대부분을 차지하고 있기 때문에 실제 성능과 무관한게 높은 점수가 나올 수 있음
- 따라서 개체명 인식에서는 F1스코어가 주로 사용됨
- F1스코어 : 정밀도와 재현율의 조화 평균
# 시퀀스를 NER 태그로 변환
def sequences_to_tag(sequences):
result = []
for sequence in sequences:
temp = []
for pred in sequence:
pred_index = np.argmax(pred)
temp.append(index_to_ner[pred_index].replace("PAD", "O"))
result.append(temp)
return result
x_test.shape
(711, 40)
# 테스트 데이터셋의 NER 예측
y_predicted = model.predict(x_test) # (711, 40) => (711, 40, 8)
23/23 ━━━━━━━━━━━━━━━━━━━━ 2s 68ms/step
y_predicted.shape
(711, 40, 8)
pred_tags = sequences_to_tag(y_predicted) # 예측된 NER
test_tags = sequences_to_tag(y_test) # 실제 NER
pred_tags[0]
['B_OG',
'O',
'O',
'O',
'O',
'O',
'O',
...
test_tags[0]
['B_PS',
'O',
'B_PS',
'O',
'O',
'O',
...
print(classification_report(test_tags, pred_tags))
print(f"F1-score: {f1_score(test_tags, pred_tags)}")
precision recall f1-score support
_ 0.41 0.42 0.42 671
_DT 0.73 0.81 0.77 333
_LC 0.45 0.22 0.29 345
_OG 0.46 0.42 0.44 506
_PS 0.48 0.17 0.25 364
_TI 0.88 0.13 0.23 53
micro avg 0.50 0.40 0.44 2272
macro avg 0.57 0.36 0.40 2272
weighted avg 0.50 0.40 0.42 2272
F1-score: 0.44319294809010773
# 새로운 유형의 문장 NER 예측
word_to_index = sent_tokenizer.word_index
new_sentences = "삼성전자 출시 스마트폰 오늘 애플 도전장 내밀다".split()
new_x = []
for w in new_sentences:
try:
new_x.append(word_to_index.get(w, 1))
except KeyError:
# 모르는 단어의 경우 OOV
new_x.append(word_to_index["OOV"])
word_to_index["삼성전자"]
531
print("새로운 유형의 시퀀스 :", new_x)
new_padded_seqs = preprocessing.sequence.pad_sequences([new_x], padding = "post", maxlen = max_len)
새로운 유형의 시퀀스 : [531, 307, 1476, 286, 1507, 6766, 1]
p
array([[3, 1, 1, 5, 6, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
dtype=int64)
# NER 예측
p = model.predict(np.array([new_padded_seqs[0]]))
p = np.argmax(p, axis = -1) # 예측된 NER 인덱스값 추출
print("{:10} {:5}".format("단어", "예측된 NER"))
print("-" * 50)
for w, pred in zip(new_sentences, p[0]): # p가 예측값
print("{:10} {:5}".format(w, index_to_ner[pred])) #10칸차지, 5칸차지
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 62ms/step
단어 예측된 NER
--------------------------------------------------
삼성전자 B_OG
출시 O
스마트폰 O
오늘 B_DT
애플 B_LC
도전장 I
내밀다 I
728x90