Как решать задачу NER на практике

от автора

Всем привет! Меня зовут Максим. Я NLP‑инженер в red_mad_robot и автор Telegram‑канала Максим Максимов // IT, AI. Сегодня я расскажу о том, как решать задачу NER на практике. Теории будет по минимуму — вместо неё разберёмся, как решать задачу руками: подходы, ресурсы, код на Python.

Сегодня в меню:

Давайте начинать!

Что такое NER

Начнем с определения. 

Named Entity Recognition (NER) — задача распознавания именованных сущностей. Это задача из области обработки естественного языка (NLP), суть которой — находить и классифицировать именованные сущности в тексте. Примерами сущностей могут быть: локации, ФИО человека, даты, различные персональные данные (ИНН, паспорт,…). 

Задача NER

Задача NER

Эта NLP‑задача имеет множество практических применений. Например:

  • Обнаружение персональных данных в тексте (автоматическое скрытие имён и адресов в документах перед их публикацией (обезличивание по 152-ФЗ));

  • Поиск ключевых навыков в резюме, по которым система поймёт, подходите ли вы под вакансию;

  • Извлечение номера заказа и города доставки в чат‑ботах поддержки для быстрой обработки обращений;

Отлично, мы с вами кратко разобрались, что такое NER и с чем его едят. Давайте теперь на реальном примере пройдём основные этапы решения этой задачи.

Понимание целей и задач

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

Представим: к нам пришёл клиент с просьбой разработать модуль для HR‑системы, который поможет рекрутерам находить наиболее релевантных кандидатов по входящим резюме и добавлять их в базу.

Пообщавшись с рекрутерами, мы поняли, что наиболее релевантными считаются резюме, в которых указаны нужные навыки и ожидаемая зарплата.

После обсуждений в команде решили пробовать решать задачу через NER.

Пообщавшись с рекрутерами, мы выяснили, что на вход будут поступать резюме кандидатов в форматах PDF и DOC.

Пример входящего резюме (*все данные вымышлены, совпадения случайны)

Пример входящего резюме (*все данные вымышлены, совпадения случайны)

Далее необходимо задать нашим заказчикам несколько вопросов, которые позволят нам лучше решить задачу.

Вопрос про домен. Из начального описания это понятно — это будут текстовые описания резюме, которые содержат информацию о профессиональном опыте кандидата. В них будет содержаться информация о человеке (ФИО, место проживания), о том, где человек работал, какие задачи выполнял, с какими технологиями работал, ожидаемая должность, ожидаемая ЗП.

Вопрос про язык. Пообщавшись с клиентом, мы узнали, что резюме будут приходить только на русском языке.

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

Итак, у нас достаточно информации, чтобы зафиксировать, с чем мы работаем:

Параметр

Описание

Домен

Резюме кандидатов (профессиональный опыт, навыки, контакты)

Язык

Русский

Формат входных данных

PDF, DOC

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

Имя, Фамилия, Email, Телефон, Навыки, Ожидаемая ЗП

Вопросов по задаче еще можно задать много: объем обрабатываемых данных, какие интеграции должны быть у модуля и так далее. Здесь мы сосредоточились на тех вещах, которые могут потребоваться для решения задачи NER. 

Формализуем задачу. На вход поступает файл резюме в формате PDF или DOC. Из него мы извлекаем текст, а затем с помощью NER‑модели находим в нём нужные сущности: имя, фамилию, email, телефон, навыки и ожидаемую заработную плату. В конечном итоге модуль отдает найденные сущности на дальнейшую обработку.

Работа с данными

Далее мы переходим к работе с данными. Это самый важный этап при решении задачи NER (да и в целом любой NLP-задачи). Всегда стоит помнить фразу: «Garbage in — garbage out».

На прошлом шаге мы рассмотрели задачу и поняли, что нам требуется. Обратим внимание на те сущности, которые нам необходимо извлечь: имя, фамилия, email, телефон, навыки, ожидаемая ЗП. Именно эти сущности и будет находить модель в тексте.

Важно зафиксировать точные определения для каждой сущности, чтобы в дальнейшем однозначно размечать их в тексте.

Тег

Сущность

Описание

NAME

Имя

Личное имя кандидата

SURNAME

Фамилия

Фамилия кандидата

EMAIL

Email

Адрес электронной почты

PHONE

Телефон

Номер телефона (в любом формате)

SKILL

Навыки

Профессиональные навыки, технологии, инструменты

SALARY

Ожидаемая ЗП

Желаемый уровень заработной платы (включая сумму и валюту)

Как вы могли заметить, мы добавили столбец «Тег». Эти теги мы зададим для NER‑модели, чтобы она возвращала их для найденных сущностей в тексте.

Переходим к конкретному формату данных, с которыми будет работать модель. Существуют различные форматы разметки:

Возможные форматы NER разметки

Возможные форматы NER разметки

Мы будем использовать схему BIO, так как в резюме навыки часто перечисляются подряд без разделителей — например, «Python SQL Docker» — и тег B‑SKILL на каждом слове позволяет модели понять, что это три отдельных навыка, а не один (как с IO форматом).

Поиск готового датасета

Первым шагом попробуем найти подходящий датасет для нашей задачи в открытых источниках. Для этого можно использовать несколько платформ.

Начнём с Hugging Face. Для тех, кто не знает: Hugging Face — это популярная платформа в области ИИ, которая содержит большое количество open‑source‑моделей и датасетов для различных задач.

Hugging Face

Hugging Face

Попробуем найти здесь датасет для нашей задачи.

Все датасеты можно увидеть во вкладке Datasets. Настроим немного фильтры для нашей задачи. Мы решаем через NER — значит, в фильтре Task следует выбрать «Token Classification». Далее, так как наши резюме будут на русском — выставим русский язык во вкладке «Languages». Таким образом, мы получим датасеты на русском языке для задачи NER. Далее я попытался через поиск найти датасеты по резюме, но таких не оказалось.

Поиск датасетов в Hugging Face

Поиск датасетов в Hugging Face

Если же убрать фильтр на язык, всего останется 16 датасетов по запросу resume

Далее я попробовал сделать DeepResearch через Claude, чтобы он также попробовал поискать датасеты на Hugging Face. Ничего на русском языке он не нашёл. Но всё же я заприметил один интересный датасет.

Это датасет, в котором содержатся 5000 резюме на английском, а также размеченные навыки (SKILLS) для каждого из них. Решил приберечь его, чтобы попробовать перевести часть на русский для своей задачи.

Далее я провёл те же поиски на:

Есть ещё много различных площадок с данными. Это одни из самых популярных. В итоге конкретно для своей задачи я так и не нашёл датасета — только схожий на английском, который упоминал выше. В целом, так чаще всего и происходит. Чем специфичнее задача, тем меньше вероятность, что будут найдены данные. Но всегда есть шанс найти схожий датасет, который можно будет модернизировать или переиспользовать.

Далее рассмотрим вариант подготовки данных, когда мы их размечаем вручную.

Разметка в Label Studio

В качестве инструмента разметки можно использовать Label Studio.

Это популярная open‑source‑платформа для разметки данных. Она позволяет размечать данные для большого количества задач:

Виды аннотаций в Label Studio

Виды аннотаций в Label Studio

Она же включает средства для разметки NER‑датасетов — что нам и подходит.

Давайте рассмотрим процесс разметки.

В связи с некоторыми нюансами по работе с реальными резюме (персональные данные) я решил продемонстрировать сам подход разметки. Эти резюме в примерах я сгенерировал или взял из открытых источников (писал выше). Главная цель — показать, как вы можете работать с разметкой, когда у вас будут реальные данные.

Давайте я покажу, как настроить работу Label Studio для NER.

Первым делом необходимо её установить. Сделать это можно через pip. Продемонстрирую этот процесс на Windows. У вас должен быть установлен Python.

Для этого создайте новую виртуальную среду. Откройте командную строку и введите команду

python -m venv venv

Активируйте среду

./venv/Scripts/activate

Далее установим Label Studio командой

pip install -U label-studio

После установки запустить Label Studio можно командой

label-studio

Запустится сервис Label Studio. Доступ к нему можно получить перейдя по ссылке http://localhost:8080/. Откроется страница авторизации:

Страница авторизации Label Studio

Страница авторизации Label Studio

Нажмите на Sign Up, создайте аккаунт, а после авторизуйтесь. 

Вы попадете на главную страницу Label Studio. 

Далее создадим проект для нашей задачи. Нажмите на «Create Project»

Создание проекта в Label Studio

Создание проекта в Label Studio

После этого вы можете ввести имя проекта и описание (опционально)

Название проекта

Название проекта

Далее необходимо загрузить данные. Я столкнулся со следующими проблемами:

  • Label Studio не предоставляет встроенного парсера текста из PDF — поэтому необходимо самим вытащить текст оттуда.

  • Если загружать в Label Studio.txt‑файлы, то он не может их прочитать, а показывает просто ссылку.

Решил я эти проблемы тем, что написал простенький парсер текста из PDF, а после перевёл их в определённый формат JSON, который принимает Label Studio и хорошо считывает.

Скидываю вам скрипт для этого:

Python код для перевода pdf файлов в json для Label Studio
# pip install pdfplumberimport pdfplumberimport jsonimport osimport sysdef extract_text_from_pdf(pdf_path: str) -> str:    """Извлекает текст из PDF файла."""    pages = []    with pdfplumber.open(pdf_path) as pdf:        for page in pdf.pages:            text = page.extract_text()            if text:                pages.append(text)    return "\n\n".join(pages)def pdfs_to_label_studio_json(pdf_dir: str, output_path: str = "label_studio_import.json"):    """Считывает все PDF из папки и формирует JSON для Label Studio."""    tasks = []    for filename in sorted(os.listdir(pdf_dir)):        if not filename.lower().endswith(".pdf"):            continue        pdf_path = os.path.join(pdf_dir, filename)        text = extract_text_from_pdf(pdf_path)        if not text.strip():            print(f"[!] Пустой текст: {filename}")            continue        tasks.append({"text": text})        print(f"[+] {filename} — {len(text)} символов")    with open(output_path, "w", encoding="utf-8") as f:        json.dump(tasks, f, ensure_ascii=False, indent=2)    print(f"\nГотово: {len(tasks)} задач → {output_path}")if __name__ == "__main__":    pdf_dir = sys.argv[1] if len(sys.argv) > 1 else "pdfs"    output = sys.argv[2] if len(sys.argv) > 2 else "label_studio_import.json"    pdfs_to_label_studio_json(pdf_dir, output)

Перед запуском, поместите все ваши pdf файлы в папку, а после запустите его командой

python ./script.py path/to/pdfs

После этого создастся JSON, который загружается в Label Studio на странице «Data Import»

Загрузка данных в Label Studio

Загрузка данных в Label Studio

Далее определим формат данных. Для этого перейдите в «Labeling Setup». В ней выберете область «Natural Language Processing», а после задачу «Named Entity Recognition».

Выбор NER разметки в Label Studio

Выбор NER разметки в Label Studio

Далее необходимо добавить теги для разметки. Наши теги: NAME, SURNAME, EMAIL, PHONE, SKILL, SALARY. Чтобы теги добавились в проект — введите их в поле «Add label names» каждый с новой строки и нажмите «Add».

Добавление NER тегов в Label Studio

Добавление NER тегов в Label Studio

После добавления для удобства можно добавить цвета для каждого тега. Обычно я выставляю различные цвета, чтобы они не смешивались при разметке и их можно было быстро отличить, и вам советую.

Настройка разметки

Настройка разметки

После этого начинается «самое интересное» — разметка. Чтобы разметить в тексте нужные теги — необходимо выбрать нужные тег (кликнуть на него), а после выделить нужный фрагмент в тексте под этот тег. Для ускорения я использую клавиатуру — нажимая «1», «2»,… — можно переключаться между тегами.

NER разметка в Label Studio

NER разметка в Label Studio

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

После каждой итерации разметки (а лучше раз в минуту) не забывайте нажимать «Submit», чтобы сохранить разметку.

Далее. После разметки датасета необходимо его выгрузить.

Как обсуждали выше, нам необходимы данные в формате BIO. В Label Studio нет встроенного функционала для выгрузки данных в таком формате.

Мы бы могли заранее выбрать теги в таком формате и размечать:

Мне показалось это неудобным и замедляющим разметку. Поэтому я пришёл к следующему решению — оставить теги в Label Studio как есть и выгружать датасет в доступном формате, скриптом переведя его в формат BIO.

Чтобы выгрузить данные, нужно на странице проекта нажать кнопку «Export», а после выбрать нужный формат. Я выберу JSON.

Экспорт данных из Label Studio

Экспорт данных из Label Studio

После этого датасет можно форматировать в BIO при помощи скрипта:

Python скрипт для перевода JSON из Label Studio в BIO формат
# pip install openpyxlimport jsonimport argparsefrom openpyxl import Workbookfrom openpyxl.styles import Font, Alignment, PatternFilldef tokenize_simple(text: str):    tokens = []    i = 0    while i < len(text):        if text[i].isspace():            i += 1            continue        j = i        while j < len(text) and not text[j].isspace():            j += 1        tokens.append((text[i:j], i, j))        i = j    return tokensdef spans_to_bio(text: str, spans: list[dict]) -> list[str]:    tokens = tokenize_simple(text)    spans = sorted(spans, key=lambda s: s["start"])    tags = []    for tok_text, tok_start, tok_end in tokens:        tag = "O"        for span in spans:            s, e = span["start"], span["end"]            label = span["labels"][0]            if tok_start >= s and tok_end <= e:                tag = f"B-{label}" if tok_start == s else f"I-{label}"                break            elif tok_start < e and tok_end > s:                tag = f"B-{label}" if tok_start <= s else f"I-{label}"                break        tags.append(tag)    return tagsdef convert(input_path: str, output_path: str):    with open(input_path, "r", encoding="utf-8") as f:        data = json.load(f)    wb = Workbook()    ws = wb.active    ws.title = "BIO Tags"    # Заголовки    ws["A1"] = "text"    ws["B1"] = "bio_tags"    for cell in [ws["A1"], ws["B1"]]:        cell.font = Font(bold=True, name="Arial", size=11)        cell.fill = PatternFill("solid", fgColor="D9E2F3")        cell.alignment = Alignment(horizontal="center")    row = 2    for task in data:        text = task.get("data", {}).get("text", "")        if not text:            for key in task.get("data", {}):                val = task["data"][key]                if isinstance(val, str) and len(val) > 10:                    text = val                    break        if not text:            continue        spans = []        annotations = task.get("annotations", []) or task.get("completions", [])        for ann in annotations:            for result in ann.get("result", []):                if result.get("type") == "labels":                    v = result["value"]                    spans.append({"start": v["start"], "end": v["end"], "labels": v["labels"]})        tags = spans_to_bio(text, spans)        ws.cell(row=row, column=1, value=text).font = Font(name="Arial", size=10)        ws.cell(row=row, column=2, value=str(tags)).font = Font(name="Arial", size=10)        row += 1    ws.column_dimensions["A"].width = 60    ws.column_dimensions["B"].width = 80    ws.auto_filter.ref = ws.dimensions    wb.save(output_path)    print(f"Готово! Строк: {row - 2}. Сохранено: {output_path}")if __name__ == "__main__":    parser = argparse.ArgumentParser(description="Label Studio JSON в Excel BIO")    parser.add_argument("input", help="JSON-экспорт из Label Studio")    parser.add_argument("output", help="Выходной .xlsx файл")    args = parser.parse_args()    convert(args.input, args.output)

Это можно сделать командой

json2bio.py ./annotation.json output.xlsx

Таким образом мы получим Excel табличку с разметкой в формате:

  • text: «Алексей Смирнов знает Python зп 150000»

  • bio_tags: [‘B‑NAME’, ‘B‑SURNAME’, ‘O’, ‘B‑SKILL’, ‘O’, ‘B‑SALARY’]

Давайте сделаем небольшой промежуточный итог — этот подход с ручной разметкой является самым ресурсозатратным, но в то же время самым качественным подходом к подготовке данных. Выше я продемонстрировал, как это сделать при помощи Label Studio. Далее перейдём к подходу создания синтетических данных.

Генерация данных

В этом разделе мы рассмотрим подходы к генерации данных для нашей задачи.

Первый способ, который я использовал — это перевод найденных ранее датасетов с английского на русский язык и последующий прогон этих данных через LLM для формирования разметки.

Генерация NER разметки из схожего датасета на другом языке.

Генерация NER разметки из схожего датасета на другом языке.

Я взял небольшое количество резюме из датасета на английском языке, перевёл их на русский язык. Так как мы решали задачу для русского языка, помимо перевода самого текста заменяли иностранные имена и фамилии на русские. При переводе возникали и другие потери — email и телефоны иногда коверкались, название некоторых технологий тоже переводились (питон). Поэтому после перевода требовалась дополнительная валидация (тоже LLM). Далее из переведённых резюме я извлёк нужные мне сущности (NAME, SURNAME, EMAIL, PHONE, SKILL, SALARY). После этого на основе найденных сущностей я формировал BIO‑разметку в формате: [«word», «word», …] — [«O», «B‑…», …]

Для перевода и извлечения сущностей я использовал gpt-4o‑mini по API. В целом, модель показала хорошее качество извлечения и не допускала много ошибок. Кстати, насчёт ошибок — возникали моменты, когда LLM не размечала какие‑то теги или размечала лишнего. Например, организации добавлялись в SKILL (Oracle как работодатель, а не как продукт), человеческие языки размечались как навыки (Английский, Хинди), в токены попадали markdown‑артефакты. Также была проблема — одна и та же технология (например Java) в одном документе помечалась как SKILL, а в другом оставалась без разметки. Здесь я решал проблему частичным ручным просмотром данных и их исправлением, а также просил Claude проанализировать датасет и исправить ошибки.

В целом генерация NER‑датасета — непростая задача для LLM. На моей практике при создании датасета такого рода модель часто ошибается, пропускает какие‑то метки или делает неверную разметку. Решал я это ручным просмотром и исправлением разметки с помощью той же LLM. Помимо этого, использовал regex для доразметки пропущенных EMAIL и PHONE — LLM их иногда просто не замечала, а регулярное выражение ловит 100% (если не сильно коверкаются эти значения). Также важным шагом было размечать навыки (SKILL) везде в тексте (и в описании опыта, и в секции навыков), а не только в секции «Навыки» — иначе модель учится не «что такое скилл», а «что стоит после слова Навыки».

Вторую часть данных я подготовил используя другой подход — просил LLM сгенерировать текст для резюме с нужными мне тегами (NAME, SURNAME, EMAIL, PHONE, SKILL, SALARY), которые я сформировал для неё заранее.

Генерация NER разметки из заранее подготовленных тегов

Генерация NER разметки из заранее подготовленных тегов

А именно — были сформированы различные роли (DevOps, QA, Data Scientist…) и под каждую роль были сформированы свои SKILLS. Для остальных тегов я использовал библиотеку Faker — библиотека которая упрощает создание фиктивных персональных данных (почта, номер, имя..). Для структурного разнообразия я подготовил 6 разных шаблонов резюме (классическое, минималистичное, навыки в центре внимания, профиль‑ориентированное и др.) — чтобы модель не переобучалась на одну структуру документа.

В конечном итоге я сформировал 114 документов и разметку для каждого. Для production‑обучения этого объёма недостаточно — на практике речь идёт о тысячах размеченных примеров, — но для демонстрации этого хватит. Распределение тегов было следующее:

Распределение тегов

Распределение тегов

Видим сильный дисбаланс — тег SKILL сильно преобладает над другими. Но в целом, для резюме это нормально, потому что резюме в основном состоит из описания навыков человека.

Закончу эту часть напоминанием, что я лишь демонстрирую подходы, которые можно использовать для создания такого рода датасетов. Здесь я хотел бы выделить то, что если всё же вы генерируете данные, то уделяйте достаточно времени проверке их качества. Например, для этого я делал себе небольшой сервис, в котором просматривал часть разметки после генерации и подправлял в местах, где были ошибки. Таким образом, датасет становился качественнее.

Моделирование

Далее перейдём к обучению модели. Для решения задачи NER наиболее популярной архитектурой является BERT. Я решил в качестве эксперимента обучить различные BERT‑модели и сравнить их точность.

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

Для обучения можно использовать бесплатные сервисы вроде Kaggle или Google Colab, либо арендовать GPU в облаке. Для нашего эксперимента был выбран второй вариант. В качестве GPU взял RTX 3090 на 24 Гб VRAM.

Я решил сравнить следующие модели для решения нашей задачи:

  • google‑bert/bert‑base‑multilingual‑cased

  • xlm‑roberta‑base

  • xlm‑roberta‑large

  • ai‑forever/ruBert‑base

  • ai‑forever/ruRoberta‑large

  • distilbert/distilbert‑base‑multilingual‑cased

Давайте шаг за шагом пройдемся по коду обучения.

Сначала импортируем необходимые библиотеки

Импорт библиотек
import jsonimport timeimport gcimport numpy as npimport pandas as pdimport openpyxlfrom collections import Counterfrom datasets import Dataset, DatasetDictfrom transformers import (    AutoTokenizer,    AutoModelForTokenClassification,    TrainingArguments,    Trainer,    DataCollatorForTokenClassification,)from seqeval.metrics import classification_report, f1_scorefrom seqeval.metrics import precision_score, recall_scoreimport torchimport matplotlib.pyplot as plt# устанавливаем device для дальнейшей работыdevice = "cuda" if torch.cuda.is_available() else "cpu"

Из основного — обучение я буду проводить при помощи библиотеки transformers, метрики будут браться из seqeval.

Далее выгрузим наши данные из excel в удобный формат

Выгрузка датасета
DATASET_PATH = "ner_bio_dataset.xlsx"wb = openpyxl.load_workbook(DATASET_PATH)ws = wb.activedocuments = []for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True):    fname, tokens_str, tags_str = row    if tokens_str and tags_str:        documents.append({            "file": fname,            "tokens": json.loads(tokens_str),            "tags": json.loads(tags_str),        })

После этого производим маппинг тегов.

Маппинг тегов
all_tags = sorted(set(tag for doc in documents for tag in doc["tags"]))tag2id = {tag: i for i, tag in enumerate(all_tags)}id2tag = {i: tag for tag, i in tag2id.items()}

Это необходимо для того, чтобы перевести наши теги в числовой вид — который понимает нейросеть (tag2id), а также для того, чтобы перевести предсказания модели в понятные нам теги (id2tag).

После этого производится чанкование. Это нужно потому, что иногда резюме может содержать большое количество текста, которое просто не поместится в контекстное окно BERT.

Чанкование
# ЧанкованиеMAX_WORDS = 380OVERLAP = 40def chunk_document(tokens, tags):    if len(tokens) <= MAX_WORDS:        return [(tokens, tags)]    chunks = []    start = 0    while start < len(tokens):        end = min(start + MAX_WORDS, len(tokens))        chunks.append((tokens[start:end], tags[start:end]))        if end >= len(tokens):            break        start += MAX_WORDS - OVERLAP    return chunksall_chunks = []for doc in documents:    all_chunks.extend(chunk_document(doc["tokens"], doc["tags"]))print(f"Чанков: {len(all_chunks)}")

Далее разбиваем датасет на обучающую и валидационную выборки.

train_test_split
train_chunks, val_chunks = train_test_split(    all_chunks, test_size=0.15, random_state=42)

Переводим наши данные в Dataset формат

Перевод данных в Dataset Transformers
def chunks_to_dataset(chunks):    return Dataset.from_dict({        "tokens": [c[0] for c in chunks],        "ner_tags": [[tag2id[t] for t in c[1]] for c in chunks],    })ds_train = chunks_to_dataset(train_chunks)ds_val = chunks_to_dataset(val_chunks)print(f"Train: {len(ds_train)}, Val: {len(ds_val)}")

Определяем модели, которые будем обучать

Модели для эксперимента
MODELS = {    "mBERT": "google-bert/bert-base-multilingual-cased",    "XLM-RoBERTa-base": "xlm-roberta-base",    "XLM-RoBERTa-large": "xlm-roberta-large",    "ruBERT": "ai-forever/ruBert-base",    "ruRoBERTa-large": "ai-forever/ruRoberta-large",    "DistilBERT-multi": "distilbert/distilbert-base-multilingual-cased",}

После определю вспомогательные функции:

Выравнивание
def tokenize_and_align(examples, tokenizer):    tokenized = tokenizer(        examples["tokens"],        truncation=True,        is_split_into_words=True,        max_length=512,        padding=False,    )    all_labels = []    for i, labels in enumerate(examples["ner_tags"]):        word_ids = tokenized.word_ids(batch_index=i)        label_ids = []        prev_word_id = None        for word_id in word_ids:            if word_id is None:                label_ids.append(-100)            elif word_id != prev_word_id:                label_ids.append(labels[word_id])            else:                label_ids.append(-100)            prev_word_id = word_id        all_labels.append(label_ids)    tokenized["labels"] = all_labels    return tokenized

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

Вычисление метрик
def compute_metrics(eval_pred):    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 p, l in zip(pred_seq, label_seq):            if l != -100:                pred_tags.append(id2tag[int(p)])                label_tags.append(id2tag[int(l)])        true_preds.append(pred_tags)        true_labels.append(label_tags)    return {"f1": f1_score(true_labels, true_preds)}

Функция для подсчёта метрик. В качестве метрики мы будем отслеживать F1. Для этого используем функцию f1_score из библиотеки seqeval. Если вы хотите подробнее узнать о метриках NER — загляните в мою предыдущую статью.

classification_report
def evaluate_detailed(trainer, tokenized_val):    """Возвращает подробные метрики по каждой сущности."""    predictions, labels, _ = trainer.predict(tokenized_val)    predictions = np.argmax(predictions, axis=-1)    true_labels, true_preds = [], []    for pred_seq, label_seq in zip(predictions, labels):        pred_tags, label_tags = [], []        for p, l in zip(pred_seq, label_seq):            if l != -100:                pred_tags.append(id2tag[int(p)])                label_tags.append(id2tag[int(l)])        true_preds.append(pred_tags)        true_labels.append(label_tags)    report = classification_report(true_labels, true_preds, output_dict=True)    f1_overall = f1_score(true_labels, true_preds)    p_overall = precision_score(true_labels, true_preds)    r_overall = recall_score(true_labels, true_preds)    return {        "report": report,        "f1": f1_overall,        "precision": p_overall,        "recall": r_overall,        "report_text": classification_report(true_labels, true_preds),    }

Функция, которая возвращает более подробную информацию по метрикам (classification report).

Далее код цикла обучения

Код обучения моделей
results = {}for model_name, model_id in MODELS.items():    print(f"\n{'='*70}")    print(f"  {model_name} ({model_id})")    print(f"{'='*70}")    try:        # Токенизатор        tokenizer = AutoTokenizer.from_pretrained(model_id)        # Токенизация данных        tok_fn = lambda examples: tokenize_and_align(examples, tokenizer)        tokenized_train = ds_train.map(tok_fn, batched=True, remove_columns=ds_train.column_names)        tokenized_val = ds_val.map(tok_fn, batched=True, remove_columns=ds_val.column_names)        # Модель        model = AutoModelForTokenClassification.from_pretrained(            model_id,            num_labels=len(tag2id),            id2label=id2tag,            label2id=tag2id,        )        n_params = model.num_parameters() / 1e6        print(f"  Parameters: {n_params:.0f}M")        # Batch size — меньше для large моделей        batch_size = 8 if "large" in model_id else 16        training_args = TrainingArguments(            output_dir=f"./ner-{model_name}",            num_train_epochs=10,            per_device_train_batch_size=batch_size,            per_device_eval_batch_size=batch_size * 2,            learning_rate=3e-5,            weight_decay=0.01,            warmup_ratio=0.1,            eval_strategy="epoch",            save_strategy="epoch",            logging_steps=20,            load_best_model_at_end=True,            metric_for_best_model="f1",            greater_is_better=True,            save_total_limit=2,            fp16=torch.cuda.is_available(),            report_to="none",            seed=42,        )        data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)        trainer = Trainer(            model=model,            args=training_args,            train_dataset=tokenized_train,            eval_dataset=tokenized_val,            processing_class=tokenizer,            data_collator=data_collator,            compute_metrics=compute_metrics,        )        # Обучение        start_time = time.time()        trainer.train()        train_time = time.time() - start_time        # Оценка        metrics = evaluate_detailed(trainer, tokenized_val)        metrics["train_time"] = train_time        metrics["params_m"] = n_params        metrics["model_id"] = model_id        results[model_name] = metrics        print(f"\n  F1: {metrics['f1']:.4f}  |  Time: {train_time:.0f}s  |  Params: {n_params:.0f}M")        print(metrics["report_text"])    except Exception as e:        print(f"  ОШИБКА: {e}")        results[model_name] = {"f1": 0, "error": str(e)}    finally:        # Освобождаем GPU память        del model, trainer, tokenizer        del tokenized_train, tokenized_val        gc.collect()        if torch.cuda.is_available():            torch.cuda.empty_cache()print("\n" + "="*70)print("  ВСЕ МОДЕЛИ ОБУЧЕНЫ")print("="*70)

Основные этапы обучения:

  1. В цикле перебираем каждую выбранную предобученную модель.

  2. Для каждой модели токенизируем данные и производим выравнивание.

  3. Задаём настройки обучения: размер батча, количество эпох, скорость обучения, валидация после каждой эпохи, папка для сохранения результатов,…

  4. Обучаем модель через стандартный Trainer из Hugging Face.

  5. После обучения считаем F1-меру.

  6. Освобождаем память GPU, чтобы следующая модель не упала.

После обучения я получил следующий результат.

Результат обучение NER моделей

Результат обучение NER моделей

Таким образом, на моём датасете модель ruRoBERTa‑large показала наилучший результат. На втором месте — bert‑base‑multilingual‑cased. Я решил выбрать bert‑base‑multilingual‑cased в качестве модели для извлечения сущностей из резюме. Почему, спросите вы? Есть же модель, которая показала себя лучше.

Дело в том, что mBERT (bert‑base‑multilingual‑cased) содержит 178M параметров — это вдвое меньше, чем у ruRoBERTa‑large (355M). При этом разница в F1 составила всего ~0.01. Меньшее количество параметров означает более быстрый инференс и меньшие требования к GPU‑памяти (а можно даже на CPU). В нашем случае такой trade‑off между качеством и скоростью оправдан.

Создание сервиса на основе модели

И последний шаг — это создание сервиса поверх нашей модели. Для этого я решил обернуть её в FastAPI, что позволит предоставить API модели и использовать её на различных платформах клиента. Для инференса будет использоваться библиотека transformers.

Сервис FastAPI для NER

Сервис FastAPI для NER

Давайте пошагово разберем код FastAPI сервера с нашей моделью.

Импортируем нужные библиотеки

Импорт библиотек
import jsonimport torchfrom fastapi import FastAPIfrom pydantic import BaseModelfrom transformers import AutoTokenizer, AutoModelForTokenClassification

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

Пре-токенизация текста
PUNCT = set(".,;:!?()[]{}\"'«»—–/\\|")def tokenize_text(text):    raw_tokens = []    i = 0    while i < len(text):        if text[i].isspace():            i += 1            continue        j = i        while j < len(text) and not text[j].isspace():            j += 1        raw_tokens.append((text[i:j], i, j))        i = j    tokens = []    for word, start, end in raw_tokens:        while word and word[0] in PUNCT:            if word[0] == '.' and len(word) > 1 and word[1].isalpha():                break            tokens.append(word[0])            start += 1            word = word[1:]        tail = []        while word and word[-1] in PUNCT:            end -= 1            tail.append(word[-1])            word = word[:-1]        if word:            tokens.append(word)        tokens.extend(reversed(tail))    return tokens

Далее загружаем и инициализируем нашу обученную модель.

Инициализация NER модели
MODEL_PATH = "./ner-mbert-resume-final"tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)model = AutoModelForTokenClassification.from_pretrained(MODEL_PATH)model.eval()with open(f"{MODEL_PATH}/tag_mapping.json", "r") as f:    mapping = json.load(f)id2tag = {int(k): v for k, v in mapping["id2tag"].items()}device = "cuda" if torch.cuda.is_available() else "cpu"model.to(device)print(f"Model loaded on {device}")

Код инференса модели. На вход подается текст, он токенизируется. После происходит чанкинг текста (это нужно потому что BERT имеет размер контекста 512 токенов). Далее происходит сам инференс по фрагментам и сбор найденных тегов в выходные данные. 

Инференс
def predict(text: str):    words = tokenize_text(text)    if not words:        return []    MAX_WORDS = 400    all_results = []    for chunk_start in range(0, len(words), MAX_WORDS):        chunk_words = words[chunk_start:chunk_start + MAX_WORDS]        inputs = tokenizer(            chunk_words,            is_split_into_words=True,            return_tensors="pt",            truncation=True,            max_length=512,        ).to(device)        with torch.no_grad():            outputs = model(**inputs)        preds = torch.argmax(outputs.logits, dim=-1)[0].cpu().numpy()        word_ids = inputs.word_ids(batch_index=0)        prev_word_id = None        for idx, word_id in enumerate(word_ids):            if word_id is not None and word_id != prev_word_id:                tag = id2tag[int(preds[idx])]                global_idx = chunk_start + word_id                all_results.append((global_idx, words[global_idx], tag))            prev_word_id = word_id    # Группируем B-/I- в сущности    entities = []    current = None    for idx, word, tag in all_results:        if tag == "O":            if current:                entities.append(current)                current = None            continue        prefix, label = tag.split("-", 1)        if prefix == "B":            if current:                entities.append(current)            current = {"entity": label, "text": word}        elif prefix == "I" and current and current["entity"] == label:            current["text"] += " " + word        else:            if current:                entities.append(current)            current = {"entity": label, "text": word}    if current:        entities.append(current)    return entities

Здесь мы определяем логику сервиса. Имеет схему входных данных, а также выходных данных для ответа сервера на запрос.

FastAPI сервер
app = FastAPI(title="NER Resume Parser")class NERRequest(BaseModel):    text: strclass Entity(BaseModel):    entity: str    text: strclass NERResponse(BaseModel):    entities: list[Entity]@app.post("/predict", response_model=NERResponse)def ner_predict(req: NERRequest):    entities = predict(req.text)    return NERResponse(entities=entities)

Таким образом, запустив этот сервер командой

uvicorn api:app --port 8000

Можно обращаться на этот сервер POST запросом с текстом резюме и в ответ получать нужные теги из резюме.

Заключение

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

Спасибо за внимание!

Подписывайтесь на мой Telegram‑канал, в котором я также рассказываю интересные вещи об IT и AI технологиях.

ссылка на оригинал статьи https://habr.com/ru/articles/1023552/