fef2's picture
Update README.md
52b5b07 verified
|
Raw
History Blame Contribute Delete
16.9 kB
---
language:
- ru
- en
license: apache-2.0
base_model: DeepPavlov/rubert-base-cased
library_name: transformers
pipeline_tag: token-classification
tags:
- ner
- token-classification
- pii
- secret-detection
- credentials
- masking
- russian
- bert
---
# ner_rus_bert-secret_detection
Дообученная голова `BertForTokenClassification` поверх `DeepPavlov/rubert-base-cased`,
которая размечает в русскоязычном тексте **учётные данные** (логины, пароли,
токены, ключи), **персональные данные** (ФИО, организации, локации) и **номера
договоров**. Основной сценарий, под который она обучалась - маскирование
пользовательских сообщений и фрагментов конфигов перед отправкой во внешнюю LLM.
> **Модель — половина системы.** В проде она работает **в объединении с
> детерминированным regex-слоем**, и все опубликованные ниже цифры покрытия
> учётных данных относятся к объединению `NER ∪ regex`. Одна NER заметно слабее
> по recall — так и задумано: её задача добирать то, что не ловится регулярками
> (естественная формулировка, кириллица, контекст), не переусердствуя с ложными
> срабатываниями. Обе половины лежат в этом репозитории: `core/scrubber.py` +
> `gitleaks.toml`
> См. [Детерминированный слой](#детерминированный-слой).
## Метки
17 BIO-тегов, 8 типов сущностей:
| Группа | Метки |
|---|---|
| Учётные данные | `LOGIN`, `PASSWORD`, `AUTH_TOKEN`, `SECRET_KEY` |
| ПДн | `PERSON`, `LOCATION`, `ORGANIZATION` |
| Прочее | `CONTRACT_NUMBER` |
Схема — `B-`/`I-`/`O`, полный список в `config.json` (`id2label`).
## Быстрый старт
```python
from transformers import pipeline
ner = pipeline(
"token-classification",
model="fef2/ner_rus_bert-secret_detection",
aggregation_strategy="simple",
)
text = 'user = svc_billing, password = "Xt7#pQm2Zr", api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
for e in ner(text):
print(f"{e['entity_group']:<12} {e['score']:.3f} {text[e['start']:e['end']]!r}")
```
```
LOGIN 0.936 'svc_billing'
PASSWORD 0.993 'Xt7#pQm2Zr'
SECRET_KEY 0.996 'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
```
Обратите внимание: режьте исходную строку по `start`/`end`, а **не** берите поле
`word` — pipeline отдаёт его склеенным из wordpiece, с лишними пробелами
(`'svc _ billing'`).
**У `pipeline()` три ограничения, из-за которых он годится только для проб.**
Он не режет длинный вход на окна — текст длиннее 512 wordpiece падает с
`RuntimeError: The size of tensor a (1218) must match the size of tensor b (512)`;
не нормализует юникод; и это **только NER**, без детерминированного слоя. Для
настоящей работы возьмите `predict.py` из этого репозитория.
## Правильный путь: `predict.py`
`predict.py` — перенос инференс-логики развёрнутого сервиса целиком:
NFC-нормализация, нарезка на окна по 384 реальных wordpiece по границам
предложений, BIO-декод, детерминированный слой, смещения в символах, склейка
пересекающихся спанов. Нужны только `torch` и `transformers` — regex-половина
на чистом stdlib.
```bash
python predict.py --text 'password = "Xt7#pQm2Zr"' # замаскированный текст
python predict.py --file документ.txt --spans # спаны построчно
cat лог.txt | python predict.py --json # спаны в JSON
cat лог.txt | python predict.py --json --no-rules # только NER, без regex
```
```python
from predict import Detector
det = Detector("fef2/ner_rus_bert-secret_detection", device="cpu") # cuda / mps
text = ('Коллеги, доступ к стенду: user = svc_billing, password = "Xt7#pQm2Zr", '
'api_key: sk-live-4f9a2b7c1e6d8f3a0b5c9d2e. Ответственный — Петров Сергей '
'Иванович (ПАО «Сбербанк», Санкт-Петербург), договор № 77-АБ/2025-4412.')
for s in det.spans(text):
print(f"{s.start:>4} {s.end:>4} {s.label:<16} {s.source:<5} {s.text!r}")
print(det.mask(text))
```
```
33 44 LOGIN ner 'svc_billing'
58 68 PASSWORD both 'Xt7#pQm2Zr'
80 112 SECRET_KEY both 'sk-live-4f9a2b7c1e6d8f3a0b5c9d2e'
130 152 PERSON ner 'Петров Сергей Иванович'
154 167 ORGANIZATION ner 'ПАО «Сбербанк'
170 185 LOCATION ner 'Санкт-Петербург'
198 213 CONTRACT_NUMBER ner '77-АБ/2025-4412'
```
`source` говорит, кто нашёл спан: `ner`, `regex` или `both` (нашли оба —
самый спокойный случай). У чисто regex-спанов `score` равен `None`: у
детерминированного правила нет вероятности, и подставлять туда единицу было бы
враньём.
Документ на 6800 символов режется на 4 окна и размечается целиком — граница окна
не теряет спаны, потому что нарезка идёт по концам предложений и абзацев.
### Четыре контракта, которые легко нарушить
**Окно — 384 *реальных* wordpiece.** Модель обучалась на окнах такого размера,
и `plan_windows` закладывает 384 токена **без** учёта `[CLS]`/`[SEP]`, то есть
`max_length=386` при токенизации. Если считать по-хагингфейсовски — 384 вместе со
служебными, — в окно попадёт 382 реальных токена, и предсказания на длинных
текстах разойдутся: два лишних wordpiece меняют каждый контекстный эмбеддинг
последовательности. На эталонном сплите в этот зазор попадают 280 строк из 6312,
и 5 из них предсказываются иначе.
**Смещения — по NFC-нормализованному тексту.** `Detector.spans()` сам приводит
вход к NFC, и `start`/`end` — индексы кодовых точек **нормализованной** строки.
Для латиницы и обычной кириллицы это то же самое, но текст с составными символами
(диакритика, вставки из PDF) после нормализации меняет длину, и спаны «поедут».
Режьте по спанам нормализованную строку:
```python
import unicodedata
normalized = unicodedata.normalize("NFC", text) # ровно то, что видела модель
fragment = normalized[span.start:span.end]
```
**Батчи, а не по одному.** Пропускная способность здесь берётся из батчей;
`Detector(batch_size=...)` задаёт размер микро-батча окон. На CPU замеренная
скорость NER-половины — около **9.5k токенов/с**; на GPU в FP16 (`fp16=True`,
включается автоматически при `device="cuda"`) она перестаёт быть узким местом
задолго до regex-слоя.
**Модель не решает, что делать со спаном.** `mask()` подставляет
`[REDACTED:LABEL]` — это пример, а не политика. Обратимое маскирование
(плейсхолдеры с обратной подстановкой), пороги по `score`, отдельная политика на
`PERSON` против `SECRET_KEY` — всё это ваш слой поверх спанов.
## Детерминированный слой
Рядом с моделью по тому же тексту работает второй, полностью детерминированный
детектор — `core.scrubber.credential_sites`. Итоговый набор спанов есть
**объединение** двух источников с последующей склейкой. Он даёт постоянный, не
зависящий от чекпоинта пол по типовым форматам, а NER добирает то, что регуляркой
не описывается. Внутри — три детектора:
| Детектор | Что ищет | Метки |
|---|---|---|
| `kv` | `ключ = значение` с онтологией ключей (`password`, `пароль`, `api_key`, `токен`, `login`…) | `PASSWORD` `SECRET_KEY` `AUTH_TOKEN` `LOGIN` |
| `cli` | значения у известных флагов известных команд (`--password=…`, `curl -u user:pass`) | `PASSWORD` `AUTH_TOKEN` |
| `opaque` | непрозрачные высокоэнтропийные значения + правила gitleaks | `AUTH_TOKEN` `SECRET_KEY` `SECRET` |
Правила gitleaks отбираются по идентификатору (`…token…``AUTH_TOKEN`,
`…key…`/`…secret…``SECRET_KEY`), из 222 правил конфига до рантайма доходят
**168** — остальные отсеиваются фильтром по имени либо не компилируются под
`re` без предупреждений. `load_gitleaks_rules` возвращает вторым значением
именно счётчик отброшенных.
`cli` бьёт по таблице «команда → флаг» и отдаёт только само значение пароля или
токена; логин из `-u user:pass` в объединение не попадает, а голая строка
подключения (`postgres://user:pass@host`) не покрывается вовсе — это осознанная
граница релиза, см. [Ограничения](#ограничения).
Слой умеет отличать живое значение от инертного: `${DB_PASSWORD}`,
`os.environ["TOKEN"]`, `<your-key-here>`, `changeme` и уже проставленные маркеры
`[REDACTED:…]` он не трогает (`core._shim.is_inert_value`). Спан с меткой
`SECRET` — это опознанное непрозрачное значение, тип которого установить не
удалось; в модельный набор из 17 меток он не входит и приходит только отсюда.
Замеренная цена этой половины на тестовом сплите: 2891 срабатывание, из них 452
не задевают ни одного gold-спана; вычёркивается 3.03 % всех символов, из которых
0.37 % корпуса лежит вне gold-спанов. Слой чисто питоновский и держит GIL —
около 0.7 мс на текст (~460k токенов/с на ядро), так что в проде именно он, а не
GPU, упирается в потолок пропускной способности.
Отключается через `Detector(rules=None)` или `--no-rules` — но тогда цифры ниже
к вам не относятся.
## Обучение
| | |
|---|---|
| Базовая модель | `DeepPavlov/rubert-base-cased` (vocab 119547, 12 слоёв, 768) |
| Шаги / batch / lr | 8000 · 32 · 3e-5, warmup 800, weight decay 0.1, grad clip 1.0, AMP |
| Окно | 384 wordpiece |
| Выбор чекпоинта | по `credential_any_f1` на валидации (максимизируется вклад NER в детекцию учётных данных: пол от регулярок постоянен) |
| Данные | синтетические семейства учётных данных в русскоязычном контексте (kv-pair в сообщениях, конфигах и коде) + replay-доля 0.35 на общий русский NER, чтобы не потерять ПДн |
## Ограничения
* **CLI и DSN — вне зоны ответственности.** Строки подключения
(`postgres://user:pass@host`) и пароли в аргументах команд сознательно не входят
в целевую поверхность релиза и на ней не измерялись. Модель на них ведёт себя
неровно: `psql -U reporter -W 'Qw!7823ml' -h db.corp.ru` → спан `PASSWORD`
захватывает открывающую кавычку, а хвост имени хоста уезжает в `LOGIN`.
Детерминированный слой подстраховывает только известные флаги известных команд.
Целевая поверхность — **kv-pair**: `ключ = значение` в сообщениях, конфигах и
коде.
* **Границы спанов не всегда аккуратны.** Модель может отрезать закрывающую
кавычку (`'ПАО «Сбербанк'`) или разделить фразу на два спана. Для маскирования
это безопасно (спан всё равно вычёркивается), для извлечения сущностей —
учитывайте.
* **Не детектор «всех секретов».** Формат, не похожий ни на одно из обученных
семейств и не покрытый регуляркой, будет пропущен. Recall 0.967 — это про
объединение на распределении сплита, а не гарантия на вашем корпусе.
Измерьте на своих данных.
* **Один seed.** Веса выпущены под пилот, из одного прогона; трёхсидовая
сертификация релиза не проводилась.
* **Происхождение данных.** Общая NER-часть (`PERSON`/`ORGANIZATION`/`LOCATION`)
опирается на публичные русские NER-корпуса; учётные данные — синтетика.
Настоящих секретов в обучающем корпусе нет.
## Файлы
```
config.json · model.safetensors веса (BertForTokenClassification, fp32, 709 МБ)
tokenizer.json · vocab.txt · … токенизатор (fast, BertTokenizer, cased)
predict.py инференс: окна · NFC · BIO-декод · объединение
gitleaks.toml правила детерминированного слоя
core/scrubber.py детерминированный слой: kv · cli · opaque
core/_shim.py инертные значения, NFC, вспомогательное
core/slot_ontology.yaml онтология ключей для kv-детектора
```
`core/` скопирован без изменений из `masking-service/core` бандла
`r3-2026-07-22` — того же кода, что крутится в проде; `predict.py` вызывает
`load_gitleaks_rules` и `credential_sites` из него, а не переписывает их. У
`scrubber.py` есть ещё и CLI датасетного пайплайна (`python -m core.scrubber`) - к инференсу он отношения не имеет, его можно игнорировать.