NER: Извлечение именованных сущностей с LLM

nlpnerinformation-extractionllmopensource
← Back to Blog

Введение

NER (Named Entity Recognition) — это задача извлечения именованных сущностей из текста: имён людей, организаций, мест, дат, количеств и других. С LLM NER стал значительно точнее и проще в настройке.


Что такое NER?

Текст:
  "Илон Маск основал Tesla в 2003 году в Калифорнии."

Сущности:
  Илон Маск → PERSON
  Tesla → ORGANIZATION
  2003 → DATE
  Калифорнии → LOCATION

Типичные типы сущностей:

  • PER — Person (имя человека)
  • ORG — Organization (организация)
  • LOC — Location (место)
  • DATE — Date (дата)
  • MONEY — Деньги
  • QUANTITY — Количество
  • PRODUCT — Продукт
  • GPE — Geo-Political Entity (страна, город)

Классические подходы

CRF (Conditional Random Fields)

from sklearn_crfsuite import CRF
from sklearn_crfsuite import metrics

# Извлечение признаков
def word2features(sent, i):
    word = sent[i]
    return {'word': word, 'is_upper': word.isupper()}

# Обучение
crf = CRF(algorithm='lbfgs', c1=0.1, c2=0.1)
crf.fit(X_train, y_train)

# Предсказание
predictions = crf.predict(X_test)

Плюсы: ✅ Быстрое обучение ✅ Мало данных нужно ✅ Интерпретируемо

Минусы: ❌ Ручной feature engineering ❌ Плохо обобщается ❌ Не понимает контекст

BiLSTM-CRF

import torch
import torch.nn as nn

class BiLSTM_CRF(nn.Module):
    def __init__(self, vocab_size, tag_set, embed_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(embed_dim, hidden_dim,
                           bidirectional=True, batch_first=True)
        self.fc = nn.Linear(hidden_dim * 2, tag_set)
        self.transitions = nn.Parameter(
            torch.randn(tag_set, tag_set)
        )
    
    def forward(self, x, tags=None):
        embeds = self.embedding(x)
        lstm_out, _ = self.lstm(embeds)
        emissions = self.fc(lstm_out)
        # CRF layer для последовательности
        return self.crf_forward(emissions, tags)

Плюсы: ✅ Ловит контекст ✅ Учитывает последовательность тегов ✅ Лучше CRF

Минусы: ❌ Нужно много данных ❌ Долгое обучение ❌ Трудно адаптировать


NER с LLM

Zero-shot NER

from openai import OpenAI

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {
            "role": "system",
            "content": """Извлеки именованные сущности из текста.
Ответь в формате JSON:
{
  "entities": [
    {"text": "...", "type": "PERSON|ORG|LOC|DATE|..."}
  ]
}"""
        },
        {"role": "user", "content": "Apple Inc. купила стартап в Берлине в 2024 году."}
    ],
    response_format={"type": "json_object"}
)

# Результат:
# {"entities": [
#   {"text": "Apple Inc.", "type": "ORG"},
#   {"text": "Берлине", "type": "LOC"},
#   {"text": "2024", "type": "DATE"}
# ]}

Плюсы: ✅ Не нужно обучать модель ✅ Работает сразу ✅ Поддержка многих языков

Минусы: ❌ Дорого ❌ Медленно ❌ Может галлюцинировать

Few-shot NER

messages = [
    {
        "role": "system",
        "content": "Извлекай сущности в формате JSON."
    },
    {"role": "user", "content": "Москва — столица России."},
    {"role": "assistant", "content": '{"entities": [{"text": "Москва", "type": "LOC"}, {"text": "России", "type": "GPE"}]}'},
    {"role": "user", "content": "Париж — столица Франции."},
    {"role": "assistant", "content": '{"entities": [{"text": "Париж", "type": "LOC"}, {"text": "Франции", "type": "GPE"}]}'},
    {"role": "user", "content": "Google основал Ларри Пейдж."}
]

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=messages
)

In-context learning с локальной моделью

# Используем llama.cpp для локального NER
import requests

prompt = """Извлеки именованные сущности из текста.

Пример 1:
Текст: "Обаму посетил Макрон в Париже."
Результат: {"entities": [{"text": "Обаму", "type": "PERSON"}, {"text": "Макрон", "type": "PERSON"}, {"text": "Париже", "type": "LOC"}]}

Текст: "Microsoft купила GitHub за 7.5 миллиардов долларов."
Результат: """

response = requests.post(
    "http://localhost:8080/completion",
    json={"prompt": prompt, "max_tokens": 200}
)

Fine-tuning для NER

Подготовка данных

{"text": "Илон Маск родился в Претории.", "entities": [{"text": "Илон Маск", "type": "PERSON", "start": 0, "end": 10}, {"text": "Претории", "type": "LOC", "start": 22, "end": 30}]}
{"text": "OpenAI находится в Сан-Франциско.", "entities": [{"text": "OpenAI", "type": "ORG", "start": 0, "end": 6}, {"text": "Сан-Франциско", "type": "LOC", "start": 19, "end": 32}]}

Fine-tuning через OpenAI

# Загрузка файла с примерами
file = client.files.create(
    file=open("ner_training.jsonl", "rb"),
    purpose="fine-tune"
)

# Создание fine-tune job
job = client.fine_tuning.jobs.create(
    model="gpt-4o-mini",
    training_file=file.id,
    hyperparameters={
        "n_epochs": 3,
        "learning_rate_multiplier": 0.1
    }
)

# Результат: ft:gpt-4o-mini:custom:ner-model-abc123

Fine-tuning open-source моделей

from transformers import AutoModelForTokenClassification, AutoTokenizer, TrainingArguments, Trainer

model = AutoModelForTokenClassification.from_pretrained(
    "microsoft/deberta-v3-base",
    num_labels=9,  # BIO tagging: B-PER, I-PER, B-ORG, ...
    id2label={
        0: "O", 1: "B-PER", 2: "I-PER",
        3: "B-ORG", 4: "I-ORG",
        5: "B-LOC", 6: "I-LOC",
        7: "B-DATE", 8: "I-DATE"
    }
)

tokenizer = AutoTokenizer.from_pretrained("microsoft/deberta-v3-base")

training_args = TrainingArguments(
    output_dir="./ner-model",
    num_train_epochs=3,
    per_device_train_batch_size=16,
    learning_rate=2e-5,
    evaluation_strategy="epoch"
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    eval_dataset=eval_dataset
)

trainer.train()

BIO тегирование

B-XXX — Begin of entity type XXX
I-XXX — Inside of entity type XXX
O — Outside any entity

Пример:
"Apple Inc. купила Google"

Apple  → B-ORG
Inc.   → I-ORG
купил  → O
Google → B-ORG

Почему BIO?

Без BIO:
  Apple → ORG, Inc. → ORG
  Модель не знает, что это одна сущность

С BIO:
  Apple → B-ORG, Inc. → I-ORG
  Модель понимает последовательность

NER с spaCy + LLM

import spacy
from spacy.tokens import Doc

nlp = spacy.load("ru_core_news_md")

# Нейроспаcy для базового NER
text = "Вчерa Путин встретился с Макроном в Москве."
doc = nlp(text)

for ent in doc.ents:
    print(ent.text, ent.label_)
# вчерa → DATE
# Путин → PERSON
# Макроном → PERSON
# Москве → LOC

# LLM для уточнения
from openai import OpenAI
client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{
        "role": "system",
        "content": f"Уточни сущности: {[(ent.text, ent.label_) for ent in doc.ents]}"
    }]
)

NER с LlamaIndex

from llama_index.core import Document
from llama_index.core.extractors import (
    EntityExtractor,
    MetadataExtractor
)

documents = [
    Document(text="OpenAI выпустила GPT-4 в марте 2023.")
]

extractors = [
    EntityExtractor(
        model="gpt-4o",
        description="извлеки организации, людей, даты, места"
    )
]

metadata = MetadataExtractor(
    extractors=extractors
).extract_from_documents(documents)

# Результат:
# entities: [OpenAI, GPT-4, март 2023]

Оценка NER

Метрики

Precision: из всех предсказанных сущностей, сколько правильных
Recall: из всех правильных сущностей, сколько нашли
F1: гармоническое среднее

Пример:
True:    [Москва, Россия]
Predicted: [Москва, Берлин]

Precision: 1/2 = 50%
Recall: 1/2 = 50%
F1: 50%

Код оценки

from sklearn.metrics import classification_report

# y_true — реальные метки
# y_pred — предсказанные
print(classification_report(y_true, y_pred, target_names=[
    "O", "B-PER", "I-PER", "B-ORG", "I-ORG",
    "B-LOC", "I-LOC", "B-DATE", "I-DATE"
]))

#           precision    recall  f1-score
# O            0.98      0.99      0.98
# B-PER        0.92      0.90      0.91
# I-PER        0.90      0.88      0.89
# B-ORG        0.94      0.93      0.93

Практические применения

1. Анализ новостей

# Извлечение сущностей из новостных статей
articles = load_news_articles()

for article in articles:
    entities = ner_extract(article["text"])
    print(f"Организации: {[e for e in entities if e['type'] == 'ORG']}")
    print(f"Люди: {[e for e in entities if e['type'] == 'PERSON']}")

2. Автоматическая категоризация

# Категоризация документов по сущностям
doc = "Соглашение между Россией и Китаем о торговле"

ents = ner_extract(doc)
if any(e['type'] == 'GPE' for e in ents):
    category = "geopolitics"
if any(e['type'] == 'MONEY' for e in ents):
    category = "finance"

3. Построение knowledge graph

# Извлечение сущностей и связей
text = "Маск основал Tesla в Калифорнии."

ents = ner_extract(text)
# [Musk→PERSON, Tesla→ORG, California→LOC]

# Затем relation extraction для связей:
# (Musk, founded, Tesla)
# (Tesla, located_in, California)

4. Автоматическое аннотирование

# Аннотирование медицинских записей
medical_text = "Пациенту назначен Амоксициллин 500мг."

ents = ner_extract(medical_text, types=["DRUG", "DOSAGE", "DISEASE"])
# [Амоксициллин → DRUG, 500мг → DOSAGE]

Open Source модели для NER

Модели:
  ✅ spaCy (en/ru/de/fr)
  ✅ Stanza (Stanford NLP)
  ✅ DeBERTa-v3 (HuggingFace)
  ✅ mBERT (multilingual BERT)
  ✅ XLM-RoBERTa
  ✅ LLM-based (GPT, Llama)

API:
  ✅ Google NLP API
  ✅ AWS Comprehend
  ✅ Azure AI Language
  ✅ GCP Cloud NLP

Итоги

NER с LLM стал значительно доступнее. Zero-shot подход работает для большинства задач, а fine-tuning даёт дополнительную точность.

Ключевые выводы:

  • Zero-shot NER с GPT-4o работает отлично
  • Fine-tuning DeBERTa даёт хороший баланс цена/качество
  • BIO тегирование — стандарт для sequence labeling
  • LLM упрощают кастомные сущности