SubDraft 📌📝: Автоматическое выделение самостоятельных услуг и генерация черновиков объявлений для Авито
Исходя из текста объявления в категории «Ремонт и отделка» определить, какие микрокатегории представлены как самостоятельные услуги, и при необходимости, сгенерировать черновики дополнительных объявлений исходя из вычленения определенной услуги. Задача решения — по тексту объявления понять:
- какие микрокатегории вообще присутствуют в тексте;
- какие из них представлены как самостоятельные услуги;
- нужно ли создавать дополнительные черновики объявлений;
- для каких микрокатегорий создавать эти черновики;
- по возможности — сгенерировать текст черновика.
├── datasets
├── rnc_dataset
├── rnc_dataset_markup.csv # основной датасет
├── rnc_dataset_markup.json # основной датасет в json
├── rnc_dataset_README.txt # описание основного датасета
├── rnc_mic_key_phrases.csv # словарь микрокатегорий
├── rnc_test.csv # исходный тестовый набор данных
├── notebooks
├── itpurple-avito-eda.ipynb # eda
├── itpurple-avito-subdraft.ipynb # пайплайн
├── subdraft_artifacts # артефакты
├── arbiter.pkl # сериализованный объект арбитража
├── bank_data.pkl # промежуточные данные пайплайна
├── config.json # глобальная конфигурация пайплайна
├── retriever_model # модель для поиска кандидатов
├── config.json # параметры архитектуры ретривера
├── model.safetensors # веса модели ретривера
├── retriever_tokenizer # токенизатор для ретривера
├── tokenizer.json # словарь и правила токенизации
├── tokenizer_config.json # конфигурация токенизатора
├── verifier_model # модель-верификатор (классификация)
├── config.json # параметры архитектуры верификатора
├── model.safetensors # веса модели верификатора
├── verifier_tokenizer # токенизатор для верификатора
├── tokenizer.json # словарь и правила токенизации
├── tokenizer_config.json # конфигурация токенизатора
├── rnc_test_predictions.csv # предсказания на тестовой выборке rnc_test.csv
├── README.md
На вход подаются:
- словарь микрокатегорий с полями
mcId,mcTitle,keyPhrases; - объявление с полями:
itemIdmcId/sourceMcIdmcTitle/sourceMcTitledescription
Модель должна вернуть:
detectedMcIds— все найденные в тексте микрокатегории;shouldSplit— нужен ли хотя бы один дополнительный черновик;drafts— список черновиков:mcIdmcTitletext
Основные метрики:
- Precision (micro) по дополнительным микрокатегориям;
- Recall (micro) по дополнительным микрокатегориям;
- F1-score (micro) — основная метрика качества;
- Accuracy по
shouldSplit— корректность бинарного решения о необходимости сплита.
Ключевая сложность задачи — отличить упоминание услуги от предложения её как самостоятельной. Простой поиск по ключевым словам даёт ложноположительные срабатывания на комплексных описаниях («ремонт под ключ, включая электрику и сантехнику»). Решение использует каскадную архитектуру из трёх стадий, где каждый следующий компонент уточняет решение предыдущего:
Текст объявления
│
▼
Hybrid Retriever (E5 + TF-IDF) <- какие микрокатегории вообще упомянуты?
│
▼
Cross-Encoder Verifier (rubert-tiny2) <- упомянуты ли они как самостоятельные?
│
▼
Late-Fusion Arbiter (HGBC) <- финальное решение по каждому кандидату.
│
▼
Draft Generator <- генерация текста черновика.
EDA продемонстрировал, что целевая переменная shouldSplit описывает не факт наличия услуги, а степень её автономности в тексте. Это двухуровневая задача: в первую очередь необходимо найти релевантные микрокатегории (retrieval), а затем для каждой из них, оценить, является ли она самостоятельной (verification & arbitration). Единая модель не способна эффективно решать обе подзадачи одновременно, тогда как каскад позволяет использовать оптимальные подходы на каждом этапе.
Проведенный исследовательский анализ данных (itpurple-avito-eda.ipynb) позволил выявить ключевые закономерности, которые легли в основу архитектуры и пространства признаков.
Распределение целевой переменной shouldSplit сильно несбалансировано: 2009 объявлений с False против 471 с True (соотношение ~4:1). Это потребовало стратифицированного разбиения при валидации и взвешенной функции потерь при обучении верификатора.
Анализ показал, что объявления с shouldSplit = True отличаются не столько содержанием, сколько способом организации текста:
- Медиана длины для
Trueзначительно выше, однако длина сама по себе — слабый предиктор (длинные «маркетинговые» тексты часто описывают одну комплексную услугу). - Плотность запятых и переносов строк выше в
True— это маркеры перечисления. - Плотность точек и восклицательных знаков выше в
False— маркеры повествовательного стиля. - Буллеты (
•,●) — сильный сигнал перечисления самостоятельных услуг.
На основе данных наблюдений был сконструирован композитный признак list_score:
list_score = n_comma × 0.5 + n_newline × 2.0 + n_bullet × 5.0 − n_excl × 2.0
Распределение частей речи различается между классами:
shouldSplit = True: преобладают существительные (NOUN) и прилагательные (ADJF) — маркеры перечисления сущностей.shouldSplit = False: выше доля глаголов (VERB,INFN) и связующих конструкций — маркеры повествовательного описания процесса.
Это привело к созданию признаков noun_verb_ratio и logic_density, которые отражают жанр текста и являются устойчивыми разделителями классов.
EDA выявил важные граничные случаи:
- Объявления с 10+ обнаруженными микрокатегориями, но
shouldSplit = False— длинные маркетинговые описания комплексного ремонта, где все услуги упомянуты как часть пакета. - Короткие тексты с минимумом слов, но
shouldSplit = True— краткие перечисления отдельных услуг.
Вышеупомянутые кейсы подтвердили необходимость верификации на уровне каждого кандидата, а не простого подсчёта совпадений.
| Параметр | Значение | Назначение |
|---|---|---|
SEED |
64911002 |
Фиксация воспроизводимости |
RETRIEVER_MODEL |
intfloat/multilingual-e5-base |
Backbone bi-encoder |
VERIFIER_MODEL |
cointegrated/rubert-tiny2 |
Backbone cross-encoder |
RETR_TOP_K |
6 |
Максимум кандидатов на retriever |
RETR_THRESHOLD |
0.44 |
Порог отсечения retriever |
RETR_W_DENSE |
0.75 |
Вес dense-скора в гибридном ранжировании |
RETR_W_LEX |
0.30 |
Вес лексического скора |
MAX_LEN |
256 |
Максимальная длина токенизации для верификатора |
EPOCHS |
6 |
Эпохи обучения верификатора |
LR |
1e-5 |
Learning rate |
ARBITER_ITER |
300 |
Число итераций HGBC |
DEFAULT_THR |
0.50 |
Начальный порог арбитра (автоподбирается) |
Retriever — intfloat/multilingual-e5-base: мультиязычная модель семейства E5 с поддержкой префиксов query: / passage:. Выбрана за сильное качество русскоязычного семантического поиска при умеренном размере (~278M параметров), что позволяет кодировать все фразы таксономии и юниты объявлений в едином пространстве.
Verifier — cointegrated/rubert-tiny2: компактная русскоязычная BERT-модель (~29M параметров). Дообучается как cross-encoder для бинарной классификации пар «утверждение о самостоятельности услуги — контекст объявления». Компактность модели критична: верификатор вызывается для каждой пары (кандидат × промпт-шаблон), и скорость инференса напрямую влияет на общее время работы пайплайна.
Arbiter — HistGradientBoostingClassifier: градиентный бустинг из sklearn. Принимает финальное решение на основе ~30 признаков, объединяя сигналы от retriever, verifier и EDA-фичей. HGBC выбран за нативную поддержку пропусков, быструю скорость обучения и хорошее качество на табличных данных малого объёма.
Загружает словарь из 11 микрокатегорий и 497 ключевых фраз. Для каждой фразы строит:
- Нормализованную форму (lowercase, удаление спецсимволов).
- Лемматизированную форму (через
pymorphy3). - TF-IDF представления: word-level (1–3 граммы) и char-level (3–5 граммы).
TF-IDF матрицы используются для fuzzy-matching на стадии retriever: даже если точного совпадения фразы нет, высокий косинус между TF-IDF векторами юнита и фразы сигнализирует о релевантности.
Для каждого объявления определяет набор кандидатов — микрокатегорий, которые потенциально представлены в тексте. Работает в два этапа:
1. Сегментация текста: объявление разбивается на «юниты» — отдельные семантические фрагменты. Разбиение выполняется по буллетам, переносам строк, точкам и другим пунктуационным маркерам (split_units). Каждый юнит — это потенциальное описание отдельной услуги.
2. Гибридное ранжирование: для каждого юнита вычисляются два типа скоров относительно каждой микрокатегории:
- Dense score: косинусное сходство E5-эмбеддингов юнита и ключевых фраз микрокатегории. Юнит кодируется с префиксом
query:, фразы — сpassage:. - Lexical score: комбинация точного совпадения нормализованных фраз (вес 1.0), лемматизированного совпадения (0.8), word TF-IDF косинуса (0.7) и char TF-IDF косинуса (0.5).
Итоговый скор юнита для микрокатегории:
score = RETR_W_DENSE × dense_score + RETR_W_LEX × lexical_score
Микрокатегория становится кандидатом, если хотя бы один юнит набирает скор выше RETR_THRESHOLD. Для каждого кандидата сохраняются top-юниты и агрегированные статистики (max, mean скоров, число точных/лемма совпадений).
Верификатор решает ключевую задачу: отличить самостоятельную услугу от упоминания в составе комплексной работы.
Формирование пар. Для каждого кандидата из retriever строится пара для cross-encoder:
-
Левая часть (premise) — промпт-утверждение о самостоятельности услуги. Используются 3 шаблона для аугментации:
«Утверждение: услуга «{mc_title}» предлагается как самостоятельная»«Верно ли, что исполнитель оказывает «{mc_title}» отдельно?»«Можно ли создать отдельный черновик для «{mc_title}»?»
-
Правая часть (hypothesis) — конкатенация ключевых фраз микрокатегории, релевантных фрагментов из retriever и укороченного описания объявления.
Обучение. rubert-tiny2 дообучается на train-выборке с взвешенной CrossEntropyLoss (компенсация дисбаланса классов). Early stopping по F1 на валидации с patience = 2.
Инференс. Для каждого кандидата верификатор возвращает вероятности по всем 3 промпт-шаблонам. Из них извлекаются статистики: p_mean, p_max, p_min, p_std, p_range — они становятся признаками для арбитра.
Собирает финальный вектор признаков для каждого кандидата (~30 фичей), объединяя сигналы из всех предыдущих стадий:
| Группа признаков | Примеры | Источник |
|---|---|---|
| Verifier probabilities | p_mean, p_max, p_min, p_std, p_range |
Cross-Encoder |
| Retriever scores | dense_max, dense_mean, lexical_max, lexical_mean, retr_score_max |
Hybrid Retriever |
| Evidence stats | n_units, exact_hits, lemma_hits |
Retriever |
| Marker features | standalone_desc, bundled_desc, standalone_ev, bundled_ev |
Regex по маркерам |
| Clause analysis | clause_mentions, clause_standalone |
Синтаксическое разбиение |
| EDA features | list_score, noun_verb_ratio, logic_density, comma_density, emoji_density, ... |
Текстовая статистика |
| Category proximity | is_source |
Совпадение с исходной микрокатегорией |
Маркеры самостоятельности (STANDALONE_MARKERS): «отдельно», «самостоятельно», «выполняем отдельно», и т.д.
Маркеры комплексности (BUNDLED_MARKERS): «под ключ», «включая», «в составе», «входит в комплекс», и т.д.
Финальный классификатор, принимающий бинарное решение по каждому кандидату: создавать черновик или нет.
Реализован как Pipeline(SimpleImputer → HistGradientBoostingClassifier):
SimpleImputer(strategy="median")— обработка пропусков.HGBC(max_iter=300, max_depth=5, lr=0.05)— градиентный бустинг.
Выход — вероятность P(split) для каждого кандидата. Кандидаты с P > threshold попадают в финальный список для генерации черновиков.
Порог принятия решения подбирается автоматически на валидационной выборке:
for thr in np.arange(0.30, 0.81, 0.05):
pipe = SubdraftPipeline(..., threshold=thr)
m = evaluate(pipe, val_df)Выбирается порог, максимизирующий F1-micro по микрокатегориям.
Для каждого одобренного кандидата генерируется текст черновика объявления:
- Из сохранённых юнитов-evidence удаляются маркеры комплексности («ремонт под ключ», «включая», «в составе»).
- Очищенные юниты склеиваются в связный текст.
- Добавляется заголовок с названием микрокатегории.
Генерация чисто эвристическая (без LLM), что обеспечивает детерминированность и скорость.
Оркестратор, объединяющий все компоненты в единый интерфейс:
def predict(description, source_mc_id) -> PredictionOutput:
candidates = retriever.generate(description, source_mc_id)
for mc_id, cand in candidates.items():
probs = verifier.predict_proba(...)
features = fb.build(description, ..., probs)
arbiter_prob = arbiter.predict_proba(features)
if arbiter_prob > threshold:
drafts.append(draft_text(mc, cand.units))
return PredictionOutput(detected, should_split, drafts)Возвращает структуру PredictionOutput:
detected_mc_ids— все микрокатегории, найденные retriever'ом.should_split— нужно ли создавать дополнительные черновики.drafts— список черновиков сmcId,mcTitle,text.
Решение работает с двумя файлами:
rnc_mic_key_phrases.csv— словарь из 11 микрокатегорий с 497 ключевыми фразами.rnc_dataset_markup.csv— 2480 размеченных объявлений с эталоннымиtargetDetectedMcIds,targetSplitMcIdsиshouldSplit.
Разбиение: 70% train / 15% val / 15% test, стратифицированное по shouldSplit.
| Метрика | Описание |
|---|---|
| F1-micro | Основная метрика по дополнительным микрокатегориям |
| Precision (micro) | Доля корректно предложенных микрокатегорий |
| Recall (micro) | Доля найденных эталонных микрокатегорий |
| Accuracy (shouldSplit) | Доля верных бинарных решений о необходимости сплита |
Исходная микрокатегория объявления в расчёт не включается.
После обучения сохраняются в subdraft_artifacts/:
| Файл | Содержание |
|---|---|
retriever_model/, retriever_tokenizer/ |
Веса и токенизатор E5 |
verifier_model/, verifier_tokenizer/ |
Дообученный rubert-tiny2 |
arbiter.pkl |
Обученный HGBC пайплайн |
bank_data.pkl |
Таксономия, TF-IDF матрицы |
config.json |
Гиперпараметры и пороги |
rnc_test_predictions.csv |
Предсказания на тестовом наборе |
torch==2.4.0
transformers
pymorphy3
scikit-learn
pandas
numpy
tqdm
spacy (ru_core_news_sm) # требуется исключительно для EDA
pip install torch==2.4.0 transformers pymorphy3 scikit-learn pandas numpy tqdm
# Запуск полного пайплайна (обучение + инференс)
# Выполнить все ячейки в itpurple-avito-subdraft.ipynb
# Только инференс по сохранённым артефактам:
# Загрузить артефакты из subdraft_artifacts/ и вызвать pipeline.predict(description, source_mc_id)rnc_mic_key_phrases.csv + rnc_dataset_markup.csv
│
▼
TaxonomyBank (11 mc, 497 phrases, TF-IDF)
│
▼
HybridRetriever (E5 dense + TF-IDF lexical)
│
├── split_units() → сегментация текста
├── dense scoring (cosine E5 embeddings)
└── lexical scoring (exact + lemma + TF-IDF)
│
▼
build_pairs() → PromptedPairDataset (3 промпта × кандидат)
│
▼
PromptVerifier (rubert-tiny2, fine-tuned cross-encoder)
│
▼
FeatureBuilder (~30 признаков: verifier + retriever + EDA + markers)
│
▼
LateFusionArbiter (HGBC) → P(split) per candidate
│
▼
Threshold autotuning (val F1-micro grid search)
│
▼
SubdraftPipeline.predict() → PredictionOutput
│
├── detectedMcIds
├── shouldSplit
└── drafts [{mcId, mcTitle, text}]
- NLP - EDA, Bag of Words, TF IDF, GloVe, BERT
- Deep Learning For NLP: Zero To Transformers & BERT
- Is text preprocessing still worth the time? A comparative survey on the influence of popular preprocessing methods on Transformers and traditional classifiers
- A Survey of Model Architectures in Information Retrieval
- Exploiting cloze-questions for few-shot text classification and natural language inference
- Hard Negatives, Hard Lessons: Revisiting Training Data Quality for Robust Information Retrieval with LLMs
- Taxonomy-based Negative Sampling In Personalized Semantic Search for E-commerce
- Comparing Neighbors Together Makes it Easy: Jointly Comparing Multiple Candidates for Efficient and Effective Retrieval
- Uncertainty-aware Generative Recommendation