В последние годы системы детекции и очистки персональных данных стали неотъемлемой частью NLP-пайплайнов, особенно в сценариях, где тексты передаются во внешние LLM-провайдеры и используются в LLM-агентах.
На практике такие системы решают задачу детекции и маскирования персональных данных, среди них можно выделить: Presidio, LLM Guard, NvidiaNeMo Guardrails и другие.
Хотя на уровне API результат выглядит достаточно простым.
Например, когда вы используете presidio и получаете подобный ответ:
from presidio_analyzer import AnalyzerEngine text="My phone number is 212-555-5555" analyzer = AnalyzerEngine() # Call analyzer to get results results = analyzer.analyze(text=text, entities=["PHONE_NUMBER"], language='en') print(results) # type: PHONE_NUMBER, start: 19, end: 31, score: 0.75]
Или обращаетесь в какой-то сервис и получаете аналогичный ответ:
{ "text": "My phone number is 212-555-5555", "entities": [ { "type": "PHONE_NUMBER", "text": "212-555-5555", "start": 19, "end": 31, "score": 0.75 } ] }
Внутри NER-пайплайна скрывается ряд архитектурных решений.
Одно из ключевых — формат и схема аннотации данных, на которых обучается и валидируется не только одна модель, но и весь пайплайн.
Разметка в NER — это не вспомогательный этап, а основа, которая напрямую влияет на: выбор подхода (regex, ML, эвристики), архитектуру модели, качество, скорость и сложность пост-обработки.
Задача NER заключается в том, чтобы найти некоторые фрагменты (spans) текста, которые считаются именованными сущностями, например:
[PER] Петр Петров [/PER] работает в [LOC] Москве [/LOC]
PER span: Петр Петров
LOC span: Москве
Спаны в NER датасетах как правило представлены следующим образом:
{ "text": "Письмо от Ивана Петрова Сергею Сидорову", "entities": [ { "start": 10, "end": 23, "type": "PERSON" }, { "start": 24, "end": 39, "type": "PERSON" } ] }
Некоторые датасеты могут содержать в себе вложенные сущности:
{ "text": "Санкт-Петербург, Невский проспект, дом 45", "entities": [ { "start": 0, "end": 42, "label": "ADDRESS" }, // весь адрес { "start": 0, "end": 15, "label": "CITY" }, // город { "start": 17, "end": 33, "label": "STREET" }, // улица { "start": 39, "end": 42, "label": "BUILDING" } // номер дома ] }
Но далеко не все схемы кодирования могут представить такую структуру.
Данные с разметкой на уровне спанов можно найти у Nvidia:
https://huggingface.co/datasets/nvidia/Nemotron-PII
Также есть небольшой набор архитектур моделей, которые работают именно со спанами, например: Span marker и Gliner.
Span-level разметка удобна тем, что она универсальна, поскольку любая модель или эвристика в NER-пайплайне после некоторой пост-обработки будет выдавать свой ответ именно в этом формате, что дает возможность держать данные в едином формате для бенчмаркинга пайплайнов.
Однако большинство моделей работают иначе - они классифицируют токены, а спаны восстанавливаются из меток. Что ведет нас к следующей части данной статьи.
Исторически и в большинстве индустриальных NER пайплайнов формулируется как задача классификации токенов, из которых затем восстанавливаются спаны сущностей.
Каждый раз когда вы делаете:
from transformers import AutoModelForTokenClassification model = AutoModelForTokenClassification.from_pretrained( "distilbert/distilbert-base-uncased", num_labels=13, id2label=id2label, label2id=label2id )
Модель оптимизируется под задачу классификации каждого входного токена в один из классов.
На уровне модели не существует понятия сущности как цельного объекта — она появляется только после декодирования последовательности токенов в спаны.
Аналогично, если у вас есть некоторый набор данных в span-level разметке, вам нужно будет конвертировать их в token-level схему.
Давайте посмотрим какие они бывают.
К распространенным token-level схемам относятся:
IO, IOB, IOE, IOBES, BI, IE и BIES
Рассмотрим их более подробно.
Итак, мы решаем задачу классификации и наша задача классифицировать токены, самое элементарное что нам придет в голову - это сказать, что каждый токен это какой-то конкретный класс, если классов много - значит много или их просто нет (I-CLASS или O).
Формально:
Каждый токен в датасете принимает значение I-CLASS или O
I-inside tag - означает что токен отмечен как именованная сущность
O- outside tag - означает что у токена сущностей нет
Примеры
Вот так может выглядеть IO схема:
["Петр", "Иванов", "живет", "на", "ленина", "13"] ["I-PERSON", "I-PERSON", "O", "O", "I-ADDRESS", "I-ADDRESS"]
А еще вот так:
["Петр Иванов", "живет", "на", "ленина", "13"] ["I-PERSON", "O", "O", "I-ADDRESS", "I-ADDRESS"]
Или вот так:
["Петр Иванов", "живет", "на", "ленина 13"] ["I-PERSON", "O", "O", "I-ADDRESS"]
Все 3 варианта формально допустимы и зависят от контекста постановки задачи.
Просто помните что если модель выучит что любое число это адрес - скорее всего у вас будет высокий FP.
С IO возникает проблема, заключающаяся в том, что мы не совсем понимаем когда закончилась конкретная сущность:
["Письмо", "от", "Ивана", "Петрова", "Сергею", "Сидорову"] ["O", "O", "I-PERSON", "I-PERSON", "I-PERSON", "I-PERSON"]
Как видите ["Ивана", "Петрова", "Сергею", "Сидорову"] - все это 4 I-сущности без начала и конца. Если вам надо понимать кого из них выделять и не терять контекст при этом, у вас возникнут сложности.
Например вы решили замаскировать их:
["PERSON_1", "PERSON_2", "PERSON_3", "PERSON_4"]
Получается так что 2 человека внезапно превратились в 4 разных, что может быть критичным в определенных юзкейсах, где важно не просто замаскировать, а сохранить контекст.
Технически пример выше можно разметить еще и вот так:
["Письмо", "от", "Ивана Петрова Сергею Сидорову"] ["O", "O", "I-PERSON"]
Поздравляю, теперь у вас не 2 а одна PERSON сущность.
С точки зрения семантики это выглядит полным абсурдом, ведь письмо от Ивана Сергею, это два разных человека.
С другой стороны если ваша задача просто маскировать сущности и не важно их явно разделять, проблем такая разметка у вас скорее всего не вызовет, сохраняя при этом адекватный баланс классов.
В качестве решения проблемы этой была представлена BIO схема.
Как мы уже поняли нам нужен некоторый разделитель, который
скажет нам когда сущность начинается (или заканчивается но это уже IOE)
Формально
I- inside
O- outside
B- begin тег - означающий начало сущности
Примеры
Тогда данные можно разметить так:
["Письмо", "от", "Ивана", "Петрова", "Сергею", "Сидорову"] ["O", "O", "B-PERSON", "I-PERSON", "B-PERSON", "I-PERSON"]
Данный вид разметки также известен как CONLL формат.
Тут начинаются потенциальные проблемы с дисбалансом классов, поскольку "O" будет доминирующим тегом, а частота B/I зависит от средней длины сущностей
Также возникает проблема вложенных сушностей, а точнее невозможность их как то разметить:
ADDRESS: "Санкт-Петербург"_CITY, "Невский проспект"_STREET, "дом 45"_HOUSE_NUMBER, "квартира 23"_APARTMENT
Данная проблема характерна для всех видов token-level аннотаций.
Попробуйте адаптировать эту сущность к BIO схеме, а к IO?
Вот примеры для BIO:
["Санкт-Петербург", ",", "Невский", "проспект", ",", "дом", "45", ",", "корпус", "2", ",", "квартира", "23"] ["B-ADDRESS", "O", "B-ADDRESS", "I-ADDRESS", "O", "B-ADDRESS", "I-ADDRESS", "O", "B-ADDRESS", "I-ADDRESS", "O", "B-ADDRESS", "I-ADDRESS"]
тут мы теряем все вложенные сущности (CITY, STREET, HOUSE_NUMBER, APARTMENT)
или:
["Санкт-Петербург", ",", "Невский", "проспект", ",", "дом", "45", ",", "корпус", "2", ",", "квартира", "23"] ["B-CITY", "O", "B-STREET", "I-STREET","O", "B-NUM", "I-NUM", "O", "B-NUM", "I-NUM", "O", "B-APART" "I-APART",]
Тут мы теряем сущность которая придает какой-то смысл происходящему здесь - ADDRESS.
Аналогичен IOB, просто вместо Begin-тега - у нас идет End-тег.
Формально
I- inside
O- outside
E- end tag - означающий конец сущности
Пример
["Письмо", "от", "Ивана", "Петрова", "Сергею", "Сидорову"] ["O", "O", "I-PERSON", "E-PERSON", "I-PERSON", "E-PERSON"]
Не судите по названию что тут всего лишь 2 вида тэгов, хотя тэгов действительно 2, но они применяются теперь не только к токенам сущностей (entities) но к "не сущностям" (non-entities), то есть токенам которые не являются сущностями.
Формально
B - begin of entity/non-entity
I - inside entity/non-entity
Пример
["Письмо", "от", "Ивана", "Петрова", "Сергею", "Сидорову"] ["B-O", "I-O", "B-PERSON", "I-PERSON", "B-PERSON", "I-PERSON"]
Как следствие классов у нас уже не 2 как в IO, а 4, что повышает потенциальный дисбаланс классов.
Помните IOB и IOE?
Так вот кейс такой же, просто "переворачиваем" и берем что? Правильно End tag.
Формально
I - inside entity/non-entity
E - end of entity/non-entity
Пример
["Письмо", "от", "Ивана", "Петрова", "Сергею", "Сидорову"] ["I-O", "E-O", "I-PERSON", "E-PERSON", "I-PERSON", "E-PERSON"]
Продолжает идею IOB и IOE, сделав merge и добавив в них S-тег
Ее также называют BILOU и она является достаточно популярной, наравне с BIO.
Формально
I- inside - где-то внутри сущности (между началом и концом)
O- outside
B- begin - начало сущности
E- end - конец сущности
S- single - одиночная сущность
Пример
["Петр", "Сергеевич", "Иванов", "живет", "в", "Москве", "на", "Тверской", "15"] ["B-PERSON", "I-PERSON", "E-PERSON", "O", "O", "S-GPE", "O","B-ADDRESS", "E-ADDRESS"]
BIES берет идею IOBES(BILOU), но еще делаем concat с BI и IE
Звучит как апофеоз всего зоопарка, поскольку проблема вложенных сущностей все еще не решена, а классов становится все больше и больше...
Формально
I- inside entity/non-entity
O- outside of entity/non-entity
B- begin - начало сущности
E- end - конец сущности
S- single - одиночная сущность
Пример
["Петр", "Сергеевич", "Иванов", "живет", "в", "Москве", "на", "Тверской", "15"] ["B-PERSON", "I-PERSON", "E-PERSON", "B-O", "E-O", "S-GPE", "S-O","B-ADDRESS", "E-ADDRESS"]
Каждая схема позволяет решить определенный класс задач с некоторыми трейд-оффами. Тем не менее, если вы не совсем понимаете какая именно разметка вам нужна, просто берите span-level, а из него конвертируйте в BIO, BILOU или что-то менее популярное по необходимости.
В этой статье мы рассмотрели два уровня разметки, которые решают разные задачи.
Span-level разметка является универсальным форматом представления сущностей.
Не все датасеты и бенчмарки используют её напрямую, однако именно к этому формату в итоге приводятся результаты работы NER-пайплайнов. Span-level разметка удобна для сравнения разных подходов, анализа ошибок и оценки качества на уровне целых сущностей. Также она дает возможность решать проблему вложенных сущностей, однако для этого требуется отдельный класс моделей.
Token-level разметка используется в первую очередь для обучения и тюнинга моделей.
Большинство архитектур работают с последовательностями токенов, поэтому при обучении span-level аннотации (если они есть) преобразуются в token-level схемы. Разные схемы (IO, BIO, IOBES и другие) отличаются способом кодирования границ сущностей.
Главное ограничение token-level подхода заключается в том, что он не поддерживает вложенные сущности и не оперирует спанами как цельными объектами.
На практике это приводит к следующему пайплайну: данные хранятся в span-level формате, затем конвертируются в token-level схемы для обучения модели, а предсказания модели декодируются обратно в спаны на этапе инференса.
Источник


