Что же такое, этот ваш NER?

Named Entity Recognition (NER) — это задача в области NLP (Natural Language Processing), направленная на выделение фрагментов в тексте, относящихся к классам, таким как имена людей, названия организаций, даты, местоположения, суммы денег и любые другие классы, на определение которых можно обучить модель

Как работает NER

Теперь рассмотрим шаги, которые выполняет система распознавания именованных объектов (NER):

  1. Токенизация текста: Текст разбивается на отдельные слова или токены.

  2. Выделение признаков: Каждому токену назначаются признаки, описывающие его окружение и контекст, такие как:

    • Предыдущие и следующие слова

    • Части речи

    • Другие лингвистические характеристики

  3. Применение модели: Модель анализирует признаки каждого токена и определяет, является ли он именованной сущностью.

  4. Объединение результатов: Результаты анализа токенов объединяются для формирования именованных сущностей, которым назначаются соответствующие метки классов, например такие как:

    • «PER» (персона)

    • «ORG» (организация)

  5. Постобработка: Происходит дополнительная обработка для уточнения результатов и исправления ошибок.

Применение

NER применяется если нужный элемент модели невозможно определить через синонимы, regex и т.д., а создание правил поиска по сложности превосходит программирование логики.

NER является важным компонентом многих NLP-приложений, таких как извлечение информации, анализ тональности, вопросно-ответные системы и многие другие (голосовые ассистенты, смс, телефонные разговоры).

Python библиотеки для NER

Из всего многообразия существующих библиотек я выделяю эти:

  • spaCy — В ней есть функции NER, POS-тегирования, разбора зависимостей, векторов слов и многое другое. Примечательно, что в предобученных моделях имеются модели, поддерживающие русский язык

  • StanfordCoreNLP — Он предоставляет простой API для таких задач обработки текста, как токенизация, тегирование частей речи, реконфигурация именованных сущностей, синтаксический разбор, синтаксический разбор зависимостей и другие.

SpaCy

Об этой библиотеке мы поговорим ввиду банальной простоты ее использования, а также моделях на русском языке со стока

Установка SpaCy

GPU версия (рекомендую для большей производительности)

Здесь представлен пример для карт Nvidia. Перед установкой библиотек вам потребуется установить CUDA, прямая ссылка

PyPi:

pip install 'spacy[cuda12x]'
pip install cupy-cuda12x

Anaconda

conda create -n NER python=3.10.9 spacy spacy-transformers cupy -c conda-forge

CPU версия (только если нет видеокарты)

PyPi:

pip install spacy

Anaconda

conda create -n NER python=3.10.9 spacy -c conda-forge

Пример кода

В примере кода мы подгрузим предобученную русскую модель

Установка модели:

python -m spacy download ru_core_news_sm

Тестовый код:

import spacy

nlp = spacy.load("ru_core_news_sm")
doc = nlp("Apple рассматривает возможность покупки британского стартапа за 1 миллиард долларов")
for token in doc:
    print(token.text, token.pos_, token.dep_)

Вывод:

Apple PROPN nsub

рассматривает VERB ROOT

возможность NOUN obj

покупки NOUN nmod

британского ADJ amod

стартапа NOUN nmod

за ADP case

1 NUM nummod

миллиард NOUN nummod:gov

долларов NOUN obl

Подготовка к обучению

С предобученной моделью мы ознакомились, пора бы научиться их обучать.

Понятие обучения

Обучение — это итерационный процесс, при котором прогнозы модели сравниваются с эталонными значениями для оценки лосса. Затем лосс используется для вычисления градиента весов с помощью метода обратного распространения. Градиенты показывают, как необходимо скорректировать веса, чтобы улучшить точность прогнозов модели.

Примечательно, что SpaCy защищает модели от переобучения. Как только начнется переобучение, тренировка модели будет прервана.

Конфиг для обучения

Файл config.cfg для обучения модели SpaCy содержит различные параметры и настройки, которые позволяют адаптировать модель к конкретной задаче. Давайте рассмотрим подробно, что может включать данный файл:

1. Данные:

Этот раздел содержит информацию о датасетах, которые будут использоваться для обучения, проверки и тестирования модели. Например:

  • Пути к файлам тренировочного и тестового наборов данных.

  • Формат данных (JSON, CSV и др.)

2. Компоненты модели:

Этот раздел описывает компоненты NLP, которые используются в модели, такие как:

  • Токенизатор (Tokenizer)

  • Лемматизатор (Lemmatizer)

  • Часть речи (Tagger)

  • Распознавание именованных сущностей (NER)

  • Анализ зависимостей (Parser)

3. Оптимизаторы:

Здесь описываются методы оптимизации и параметры обучения, такие как:

  • Тип оптимизатора (Adam, SGD)

  • Параметры оптимизатора (коэффициент обучения, коэффициенты регуляризации и др.)

4. Гиперпараметры:

Этот раздел включает параметры, которые управляют процессом обучения:

  • Размер батча

  • Количество эпох

  • Частота сохранения модели на диск

5. Векторные представления:

Этот раздел определяет векторные представления слов (вектора Word2Vec, GloVe и др.), которые будут использоваться для инициализации эмбеддингов.

6. Настройки среды:

Здесь находятся настройки для среды выполнения, такие как:

  • Использование GPU или CPU

  • Пути к исходным файлам и директориям

7. Настройки логирования:

Этот раздел может включать параметры для логирования процесса обучения:

  • Уровень логирования (DEBUG, INFO, WARNING)

  • Формат логов и пути к ним

8. Метрики:

Здесь описываются метрики, которые будут использоваться для оценки модели:

  • Точность (Accuracy)

  • Полнота (Recall)

  • F1-меры и др.

Пример части файла config.cfg:

[paths]
train = "path/to/train_data"
dev = "path/to/dev_data"
vectors = "path/to/vectors"

[nlp]
lang = "ru"
pipeline = ["tok2vec","ner"]
batch_size = 1000

[components.tok2vec]
factory = "tok2vec"

[training.optimizer]
@optimizers = "Adam.v1"

[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2

[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001

Создание конфигурации обучения (CPU/GPU)

Вообще, в официальной документации SpaCy имеется генератор конфигураций, но если взять его конфиг, то за редким случаем у вас выйдет точность выше 64%

Поэтому я дам вам свои конфиги для CPU и GPU, показавшие среднюю точность 99% обученных на них моделей

GPU

[paths]
train = null
dev = null
vectors = null
init_tok2vec = null

[system]
gpu_allocator = null
seed = 0

[nlp]
lang = "ru"
pipeline = ["tok2vec","ner"]
batch_size = 1000
disabled = []
before_creation = null
after_creation = null
after_pipeline_creation = null
tokenizer = {"@tokenizers":"spacy.Tokenizer.v1"}
vectors = {"@vectors":"spacy.Vectors.v1"}

[components]

[components.ner]
factory = "ner"
incorrect_spans_key = null
moves = null
scorer = {"@scorers":"spacy.ner_scorer.v1"}
update_with_oracle_cut_size = 100

[components.ner.model]
@architectures = "spacy.TransitionBasedParser.v2"
state_type = "ner"
extra_state_tokens = false
hidden_width = 64
maxout_pieces = 2
use_upper = true
nO = null

[components.ner.model.tok2vec]
@architectures = "spacy.Tok2VecListener.v1"
width = ${components.tok2vec.model.encode.width}
upstream = "*"

[components.tok2vec]
factory = "tok2vec"

[components.tok2vec.model]
@architectures = "spacy.Tok2Vec.v2"

[components.tok2vec.model.embed]
@architectures = "spacy.MultiHashEmbed.v2"
width = ${components.tok2vec.model.encode.width}
attrs = ["NORM","PREFIX","SUFFIX","SHAPE"]
rows = [5000,1000,2500,2500]
include_static_vectors = true

[components.tok2vec.model.encode]
@architectures = "spacy.MaxoutWindowEncoder.v2"
width = 256
depth = 8
window_size = 1
maxout_pieces = 3

[corpora]

[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0
gold_preproc = true
limit = 0
augmenter = null

[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0
gold_preproc = true
limit = 0
augmenter = null

[training]
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"
seed = ${system.seed}
gpu_allocator = ${system.gpu_allocator}
dropout = 0.1
accumulate_gradient = 1
patience = 1600
max_epochs = 0
max_steps = 20000
eval_frequency = 200
frozen_components = []
annotating_components = []
before_to_disk = null
before_update = null

[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2
get_length = null

[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001
t = 0.0

[training.logger]
@loggers = "spacy.ConsoleLogger.v1"
progress_bar = true

[training.optimizer]
@optimizers = "Adam.v1"
beta1 = 0.9
beta2 = 0.999
L2_is_weight_decay = true
L2 = 0.01
grad_clip = 1.0
use_averages = false
eps = 0.00000001
learn_rate = 0.001

[training.score_weights]
ents_f = 1.0
ents_p = 0.0
ents_r = 0.0
ents_per_type = null

[pretraining]

[initialize]
vectors = ${paths.vectors}
init_tok2vec = ${paths.init_tok2vec}
vocab_data = null
lookups = null
before_init = null
after_init = null

[initialize.components]

[initialize.tokenizer]

CPU

[paths]
train = null
dev = null
vectors = null
[system]
gpu_allocator = null

[nlp]
lang = "ru"
pipeline = ["tok2vec","ner"]
batch_size = 1000

[components]

[components.tok2vec]
factory = "tok2vec"

[components.tok2vec.model]
@architectures = "spacy.Tok2Vec.v2"

[components.tok2vec.model.embed]
@architectures = "spacy.MultiHashEmbed.v2"
width = ${components.tok2vec.model.encode.width}
attrs = ["NORM", "PREFIX", "SUFFIX", "SHAPE"]
rows = [5000, 1000, 2500, 2500]
include_static_vectors = true

[components.tok2vec.model.encode]
@architectures = "spacy.MaxoutWindowEncoder.v2"
width = 256
depth = 8
window_size = 1
maxout_pieces = 3

[components.ner]
factory = "ner"

[components.ner.model]
@architectures = "spacy.TransitionBasedParser.v2"
state_type = "ner"
extra_state_tokens = false
hidden_width = 64
maxout_pieces = 2
use_upper = true
nO = null

[components.ner.model.tok2vec]
@architectures = "spacy.Tok2VecListener.v1"
width = ${components.tok2vec.model.encode.width}

[corpora]

[corpora.train]
@readers = "spacy.Corpus.v1"
path = ${paths.train}
max_length = 0

[corpora.dev]
@readers = "spacy.Corpus.v1"
path = ${paths.dev}
max_length = 0

[training]
dev_corpus = "corpora.dev"
train_corpus = "corpora.train"

[training.optimizer]
@optimizers = "Adam.v1"

[training.batcher]
@batchers = "spacy.batch_by_words.v1"
discard_oversize = false
tolerance = 0.2

[training.batcher.size]
@schedules = "compounding.v1"
start = 100
stop = 1000
compound = 1.001

[initialize]
vectors = ${paths.vectors}

Отличия от стоковой конфигурации

Отличия имеются только для GPU версии, ввиду того что там включен gold preprocessing. Если ваши датасеты тщательно аннотированы и вы уверены в их качестве, включение gold preprocessing рекомендуется для повышения точности и надежности модели.

Создание датасетов

Для обучения модели необходимо разметить данные и создать датасеты, чем мы сейчас и займемся. Для начала, нам следует определиться с классами, которые будет определять наша модель, а также найти данные, которые будут размечены в датасеты. Количество строк в каждом классе должно быть не менее 1000, иначе возможны проблемы с точностью распознавания из-за недостаточного количества данных.

Поиск данных на разметку

Я выделяю три основных места, в которых мы можем найти данные на разметку:

  • HuggingFace — огромное количество данных на любой случай жизни и пусть датасетов под NER всего ничего, но взяв условный csv можно достать из него данные и разметить

  • Kaggle — аналогично первому пункту

  • Google Dorking — поиск информации, проиндексированной поисковой системой. Можно извлечь файлы с нужной информацией, я, к примеру, таким образом набирал названия компаний на разметку

Разметка данных

Теперь приступим, непосредственно, к разметке данных.

Перед разметкой собранные ранее данные следует собрать в txt файл

  1. Заходим на сайт

  2. Открываем файл и настраиваем классы

  3. Размечаем данные до конца, одна строка может иметь как один, так и несколько классов, все зависит от формата данных и их наполнения

  4. Загружаем размеченные данные через Annotations -> Export

После выполнения вышеуказанных действий произойдет загрузка файла annotations.json

Конвертация данных

Как мы видим. загруженный файл имеет формат JSON, который нам не подходит. Необходимо преобразовать его в поддерживаемый формат SpaCy для последующего создания датасетов. В этом нам может помочь следующий Python код:

import json

# Исходный JSON
data = open(input('Введите путь до JSON файла: ')).read()

# Загружаем данные из JSON
json_data = json.loads(data)

# Конвертируем данные в необходимый формат
converted_data = []
for item in json_data["annotations"]:
    text, annotation = item
    entities = annotation["entities"]
    converted_data.append((text, entities))

# Сохраняем результат в training_data.txt
with open("training_data.txt", "w", encoding="utf-8") as f:
    for entry in converted_data:
        f.write(f"{entry}\n")

Создание датасетов

Вот мы и подобрались к заключительному этапу подготовки данных для обучения модели. Для конвертации training_data.txt в датасеты, нам потребуется запустить еще один код, вот он:

import spacy
from spacy.tokens import DocBin
from collections import defaultdict
import math
import random

# Загружаем модель SpaCy
nlp = spacy.blank("ru")

# Функция для конвертации данных в формат spacy
def convert_to_spacy_format(data, nlp):
    doc_bin = DocBin()
    for item in data:
        text, annotations = item
        doc = nlp.make_doc(text)
        ents = []
        for start, end, label in annotations:
            span = doc.char_span(start, end, label=label)
            if span:
                ents.append(span)
        doc.ents = ents
        doc_bin.add(doc)
    return doc_bin

# Чтение данных на конвертацию из файла
data = [
    eval(line.strip().rstrip(","))
    for line in open("training_data.txt", encoding="utf-8")
]

# Группируем данные по классам
class_data = defaultdict(list)
for item in data:
    if isinstance(item, tuple) and len(item) == 2:
        _, annotations = item
        for _, _, label in annotations:
            class_data[label].append(item)
            break  # Предполагаем, что каждый текст относится к одному классу

# Устанавливаем соотношение для train, dev и test наборов
train_ratio = 0.7
dev_ratio = 0.2
test_ratio = 0.1

train_data = []
dev_data = []
test_data = []

# Вычисляем количество элементов для каждого класса
for label, items in class_data.items():
    n_items = len(items)
    n_train = math.ceil(train_ratio * n_items)
    n_dev = math.ceil(dev_ratio * n_items)
    n_test = n_items - n_train - n_dev  # Оставшееся количество идёт в тестовый набор

    # Добавляем данные в наборы
    train_data.extend(items[:n_train])
    dev_data.extend(items[n_train:n_train + n_dev])
    test_data.extend(items[n_train + n_dev:])

# Перемешиваем данные внутри каждого набора для случайного распределения
random.shuffle(train_data)
random.shuffle(dev_data)
random.shuffle(test_data)

# Конвертируем данные в формат spacy и сохраняем
train_doc_bin = convert_to_spacy_format(train_data, nlp)
train_doc_bin.to_disk("train.spacy")

dev_doc_bin = convert_to_spacy_format(dev_data, nlp)
dev_doc_bin.to_disk("dev.spacy")

test_doc_bin = convert_to_spacy_format(test_data, nlp)
test_doc_bin.to_disk("test.spacy")

По окончанию работы у нас появится 3 файла: train.spacy (данные на которых будет обучаться модель, 70% всех данных), dev.spacy (данные, которыми будут проверяться предсказания модели, 20% всех данных), test.spacy (данные для бенчмарка, 10% всех данных)

Обучение модели

Итак, наконец у нас есть три файла с датасетами и конфиг, можно начать обучение

GPU:

python -m spacy train config.cfg --output ./output --paths.train ./train.spacy --paths.dev ./dev.spacy -g 0

CPU:

python -m spacy train config.cfg --output ./output --paths.train ./train.spacy --paths.dev ./dev.spacy
Процесс обучения модели
Процесс обучения модели

Отлично, обучение запущено. Теперь давайте разберемся со значением каждой колонки:

  1. # - Это порядковый номер итерации в процессе обучения модели.

  2. LOSS TOK2VEC - Показывает потери (ошибки) во время обучения компонента tok2vec. Этот компонент используется для векторизации токенов (слов) в модели.

  3. LOSS NER - Показывает потери (ошибки) во время обучения компонента NER (Named Entity Recognition). Этот компонент отвечает за распознавание именованных сущностей в тексте.

  4. ENTS_F - Это метрика F1-score для распознавания именованных сущностей. Она представляет собой гармоническое среднее между точностью и полнотой.

  5. ENTS_P - Показывает точность (precision) распознавания именованных сущностей. Она отражает долю правильно классифицированных именованных сущностей среди всех предсказанных.

  6. ENTS_R - Показывает полноту (recall) распознавания именованных сущностей. Это отношение правильно классифицированных именованных сущностей к общему количеству настоящих именованных сущностей.

  7. SCORE - Это общая оценка или показатель успеха модели на данной итерации обучения. Этот показатель может учитывать различные метрики и параметры модели для оценки её качества. Умножение на 100 этого значения даст процент успеха модели.

По окончанию процесса лучшая модель сохранится по пути ./output/model-best

Использование готовой модели

Использовать модель достаточно просто. Для этого создайте файл for_model.txt и построчно внесите в него значения, которые хотите обработать моделью. Далее запустите следующий код:

import spacy
import re
import openpyxl

# Загрузите модель для русского языка, например, 'ru_core_news_sm'
nlp_ru = spacy.load("./output/model-best")

f = open("for_model.txt", encoding="utf-8")
output_xlsx_file = "output_data.xlsx"


# Функция для извлечения сущностей заданного типа из документа
def extract_entities(doc, entity_type):
    return [ent.text for ent in doc.ents if ent.label_ == entity_type]


# Получаем список уникальных типов сущностей из модели
entity_types = nlp_ru.pipe_labels["ner"]

# Создаем workbook и worksheet для xlsx файла
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "NER Output"

# Записываем заголовок колонок
ws.append(["Input Text"] + entity_types)

for input in f:
    line = input.strip()
    doc_ru = nlp_ru(line)
    row_data = [line]

    # Извлекаем сущности для каждого типа и удаляем недопустимые компании
    for entity_type in entity_types:
        entities = extract_entities(doc_ru, entity_type)

        # Добавляем сущности в соответствующую колонку по их классу
        row_data.append(", ".join(list(set(entities))))

    # Записываем строку в xlsx файл
    ws.append(row_data)

# Закрываем вводной файл
f.close()

# Сохраняем и закрываем рабочую книгу
wb.save(output_xlsx_file)
wb.close()

print(f"Данные успешно сохранены в файл {output_xlsx_file}")

По окончанию работы кода будет создан файл output_data.xlsx.

На этом все!

Комментарии (3)


  1. ValeriyPushkarev
    05.07.2024 03:39

    Ой как сложно.

    Может сделать так:

    1) Разобрать грамматически

    2) Найти все типы грамматического дерева, соответствующие именованным сущностям (adj+noun, noun, etc)

    3) Используя словарь синонимов (Word2Vec, например), проверить по интересующим типам (по близости векторов)

    В чем магия - с вероятностью 99% именованные сущности это подлежащее, определение (существительное+прилагательное), и т.д.

    Плюсы - машинное обучение нужно только на этапе 2. Сама модель - просто образец Explainable Machine Learning.

    И еще небольшой плюс:

    Количество строк в каждом классе должно быть не менее 1000, иначе возможны проблемы с точностью распознавания из-за недостаточного количества данных.

    Нет, при таком подходе хватит пары абзацев.


    1. whynothacked Автор
      05.07.2024 03:39

      Неплохой вариант развития событий, просто я действовал отталкиваясь от того что у нас только NER (в случаях когда не нужно определять что существительное, что прилагательное и тп, а просто определять по парочке классов данные текста, будь тоназвания компаний или что-то еще)


      1. ValeriyPushkarev
        05.07.2024 03:39

        Еще у вас появилась необходимость в 1000 примерах (чтобы найти контекстные синонимы Четверг-дата).

        А для контекстных синонимов уже есть добрый десяток решений.

        Ну и бонус - вы получаете сколько угодно классов именованных сущностей (без необходимости разметки).

        Т.е. в общем пайплайн - находим все, что попадает под определение именованных сущностей, определяем класс.