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