Skip to main content

개체명 인식 (Named Entity Recognition)

개체명 인식(NER)은 텍스트에서 사람(PER), 장소(LOC), 조직(ORG), 날짜(DAT) 등의 고유한 엔티티(Entity)를 식별하고 분류하는 태스크입니다. 텍스트 분류가 문장 전체에 하나의 레이블을 부여하는 것과 달리, NER은 각 토큰마다 레이블을 부여하는 Token Classification 태스크입니다.

학습 목표

이 문서를 완료하면 다음을 수행할 수 있습니다.
  • BIO / BIOES 태깅 체계의 차이와 의미를 설명할 수 있습니다
  • NER 데이터셋의 구조를 이해하고 전처리할 수 있습니다
  • BERT 기반 Token Classification 모델을 Fine-tuning할 수 있습니다
  • seqeval 라이브러리를 사용하여 엔티티 단위 평가를 수행할 수 있습니다

NER 태깅 체계

NER에서는 엔티티의 시작, 중간, 외부를 구분하기 위한 태깅 체계(Tagging Scheme)를 사용합니다.

BIO 태깅

가장 널리 사용되는 태깅 체계입니다. 3개의 접두어로 엔티티 범위를 표시합니다.
접두어의미설명
B-Beginning엔티티의 시작 토큰
I-Inside엔티티의 내부 토큰 (2번째 이후)
OOutside엔티티가 아닌 토큰
삼성전자가     서울     본사에서    AI    컨퍼런스를    개최했다
B-ORG        B-LOC     O        B-EVT   I-EVT        O

BIOES 태깅

BIO에 **E(End)**와 **S(Single)**를 추가하여 엔티티 경계를 더 명확히 합니다.
접두어의미설명
B-Beginning다중 토큰 엔티티의 시작
I-Inside다중 토큰 엔티티의 중간
OOutside엔티티 아님
E-End다중 토큰 엔티티의 마지막
S-Single단일 토큰 엔티티
삼성전자가     서울     본사에서    AI    컨퍼런스를    개최했다
S-ORG        S-LOC     O        B-EVT   E-EVT        O
BIO vs BIOES: 대부분의 실무 환경에서 BIO 태깅이면 충분합니다. BIOES는 연속된 동일 유형 엔티티를 구분할 때 장점이 있지만, 레이블 수가 많아져 학습 데이터가 부족한 경우 오히려 성능이 낮아질 수 있습니다.

실습: BERT 기반 한국어 NER

1

데이터셋 로딩 및 탐색

KLUE-NER 데이터셋을 로딩합니다. KLUE는 한국어 NLP 벤치마크로, NER 태스크에 6개 엔티티 유형을 포함합니다.
from datasets import load_dataset

# KLUE NER 데이터셋 로딩
dataset = load_dataset("klue", "ner")
print(dataset)
# DatasetDict({
#     train:      Dataset({num_rows: 21008})
#     validation: Dataset({num_rows: 5000})
# })

# 데이터 구조 확인
sample = dataset["train"][0]
print("토큰:", sample["tokens"])
print("태그:", sample["ner_tags"])
KLUE-NER의 엔티티 유형은 다음과 같습니다.
태그엔티티 유형예시
PS사람 (Person)이순신, 세종대왕
LC장소 (Location)서울, 한강
OG조직 (Organization)삼성전자, 국회
DT날짜 (Date)2024년, 3월 15일
TI시간 (Time)오후 3시, 정오
QT수량 (Quantity)100명, 3킬로미터
# 레이블 목록 확인
label_names = dataset["train"].features["ner_tags"].feature.names
print(label_names)
# ['O', 'B-PS', 'I-PS', 'B-LC', 'I-LC', 'B-OG', 'I-OG',
#  'B-DT', 'I-DT', 'B-TI', 'I-TI', 'B-QT', 'I-QT']

num_labels = len(label_names)
print(f"레이블 수: {num_labels}")  # 13
2

토크나이저 적용과 레이블 정렬

BERT 토크나이저는 단어를 서브워드로 분할하므로, 원본 토큰과 서브워드 토큰 사이의 레이블 정렬이 필요합니다.
from transformers import AutoTokenizer

model_name = "klue/bert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)

def tokenize_and_align_labels(examples):
    """서브워드 토큰에 NER 레이블을 정렬하는 함수"""
    tokenized = tokenizer(
        examples["tokens"],
        truncation=True,
        is_split_into_words=True,  # 이미 토큰화된 입력
        max_length=128,
        padding="max_length",
    )

    all_labels = []
    for i, labels in enumerate(examples["ner_tags"]):
        word_ids = tokenized.word_ids(batch_index=i)
        label_ids = []
        previous_word_id = None

        for word_id in word_ids:
            if word_id is None:
                # [CLS], [SEP], [PAD] 토큰 → -100 (손실 계산에서 무시)
                label_ids.append(-100)
            elif word_id != previous_word_id:
                # 새 단어의 첫 번째 서브워드 → 원본 레이블 사용
                label_ids.append(labels[word_id])
            else:
                # 같은 단어의 후속 서브워드
                # B- → I- 로 변환 (선택 사항)
                label = labels[word_id]
                if label % 2 == 1:  # B- 태그인 경우
                    label_ids.append(label + 1)  # I- 태그로 변환
                else:
                    label_ids.append(label)

            previous_word_id = word_id

        all_labels.append(label_ids)

    tokenized["labels"] = all_labels
    return tokenized

# 전체 데이터셋에 적용
tokenized_dataset = dataset.map(
    tokenize_and_align_labels,
    batched=True,
    remove_columns=dataset["train"].column_names,
)
레이블 정렬이 NER Fine-tuning의 핵심입니다. 서브워드 토큰에 잘못된 레이블이 할당되면 모델 성능이 크게 저하됩니다. 특수 토큰([CLS], [SEP], [PAD])에는 반드시 -100을 할당하여 손실 계산에서 제외해야 합니다.
3

모델 학습

Token Classification 모델을 로딩하고 학습합니다.
from transformers import (
    AutoModelForTokenClassification,
    TrainingArguments,
    Trainer,
    DataCollatorForTokenClassification,
)

# 모델 로딩 (각 토큰마다 13개 클래스 분류)
model = AutoModelForTokenClassification.from_pretrained(
    model_name,
    num_labels=num_labels,
)

# Data Collator (동적 패딩)
data_collator = DataCollatorForTokenClassification(
    tokenizer=tokenizer,
)

# 학습 설정
training_args = TrainingArguments(
    output_dir="./ner-results",
    eval_strategy="epoch",
    save_strategy="epoch",
    learning_rate=3e-5,
    per_device_train_batch_size=32,
    per_device_eval_batch_size=64,
    num_train_epochs=5,
    weight_decay=0.01,
    load_best_model_at_end=True,
    metric_for_best_model="overall_f1",
    fp16=True,
)
4

seqeval 기반 평가

NER은 엔티티 단위로 평가합니다. 토큰 단위 정확도가 아니라, 엔티티의 시작~끝 범위와 유형이 모두 일치해야 정답으로 인정됩니다.
import numpy as np
import evaluate

seqeval = evaluate.load("seqeval")

def compute_metrics(eval_pred):
    """seqeval 기반 NER 평가 함수"""
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)

    # 정수 레이블을 문자열 태그로 변환
    true_labels = []
    true_preds = []

    for pred_seq, label_seq in zip(predictions, labels):
        pred_tags = []
        label_tags = []

        for pred, label in zip(pred_seq, label_seq):
            if label != -100:  # 특수 토큰 제외
                pred_tags.append(label_names[pred])
                label_tags.append(label_names[label])

        true_preds.append(pred_tags)
        true_labels.append(label_tags)

    results = seqeval.compute(
        predictions=true_preds,
        references=true_labels,
    )

    return {
        "precision": results["overall_precision"],
        "recall": results["overall_recall"],
        "overall_f1": results["overall_f1"],
        "accuracy": results["overall_accuracy"],
    }

# Trainer 생성 및 학습
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset["train"],
    eval_dataset=tokenized_dataset["validation"],
    data_collator=data_collator,
    compute_metrics=compute_metrics,
)

trainer.train()
5

추론 및 결과 시각화

학습된 모델로 새로운 텍스트에서 엔티티를 추출합니다.
from transformers import pipeline

# NER 파이프라인 생성
ner_pipeline = pipeline(
    "ner",
    model=trainer.model,
    tokenizer=tokenizer,
    aggregation_strategy="simple",  # 서브워드 토큰을 단어 단위로 병합
    device=0,
)

# 추론
text = "삼성전자의 이재용 회장이 2024년 3월 서울에서 AI 전략 회의를 주재했다."
entities = ner_pipeline(text)

for entity in entities:
    print(f"[{entity['entity_group']}] {entity['word']} "
          f"(score: {entity['score']:.3f})")
# [OG] 삼성전자 (score: 0.987)
# [PS] 이재용 (score: 0.995)
# [DT] 2024년 3월 (score: 0.972)
# [LC] 서울 (score: 0.991)

NER 평가 지표 상세

seqeval은 엔티티를 하나의 단위로 평가합니다. 아래 예시를 통해 토큰 단위 평가와의 차이를 이해할 수 있습니다.
정답:   B-PER  I-PER  O    B-LOC  O
예측:   B-PER  I-PER  O    B-ORG  O

토큰 단위 정확도: 4/5 = 80%
엔티티 단위 (seqeval):
  - "PER" 엔티티: TP (시작~끝, 유형 모두 일치)
  - "LOC" 엔티티: FN (놓침)
  - "ORG" 엔티티: FP (오탐)
  → Precision: 1/2, Recall: 1/2, F1: 0.50
is_split_into_words=True 파라미터를 사용하고 있는지 확인하세요. 이미 토큰화된 입력(단어 리스트)을 전달할 때 필수입니다. word_ids() 메서드로 각 서브워드가 어떤 원본 단어에 매핑되는지 추적하세요.
none: 서브워드 단위로 결과 반환. simple: 동일 엔티티 서브워드를 병합하고 점수 평균. first: 첫 서브워드의 점수 사용. max: 최고 점수 사용. 일반적으로 simple이 가장 직관적입니다.
CRF(Conditional Random Field)는 태그 전이 확률을 학습하여 “B-PER 다음에 I-LOC가 오는” 비합리적 예측을 방지합니다. 데이터셋이 작을 때 효과적이지만, BERT처럼 강력한 인코더를 사용하면 CRF 없이도 충분한 성능을 달성하는 경우가 많습니다.

다음 문서