Два года назад я потратил неделю, пытаясь заставить DistilBERT отличать тексты Пелевина от Сорокина. Результат — 72% accuracy на тесте и полное ощущение, что я просто подогнал шум. Параллельно коллега за час накрутил стек из TF-IDF + Vowpal Wabbit и получил 89%. С тех пор я заново пересмотрел своё отношение к «старому» NLP. В этом гайде — без соплей про нейросети, только железобетонная инженерия: как собрать систему идентификации автора, которая будет работать в продакшне, и почему иногда лучше забыть про LLM.
Проблема: LLM-детекторы вроде GPTZero всё ещё пасуют перед имитацией стиля (см. ИИ-детекторы терпят крах). Но для задачи «кто написал этот текст — Пушкин или Гоголь?» классические фичи часто работают эффективнее, чем fine-tuning целого трансформера. Почему? Потому что стиль — это не контекст, а паттерн: любимые предлоги, длина предложений, частота союзов. LLM это учат, но переплачиваете вы за умение рассуждать, которое здесь не нужно.
Анатомия атаки: что мы будем строить
Представьте: у вас есть корпус текстов от 10 авторов (по 100–200 документов на каждого). Нужно написать API, который по новому тексту (300–500 слов) скажет: «с вероятностью 85% это написал Иванов». Подходов два:
- Классика жанра: Bag-of-Words / TF-IDF / FastText → классификатор (логистическая регрессия, Vowpal Wabbit, стекинг).
- Тяжёлая артиллерия: Fine-tuning предобученного LLM (BERT, RoBERTa).
Мы сравним оба на равных данных. Спойлер: классика выигрывает по скорости обучения, потреблению памяти и часто по качеству — особенно на маленьких датасетах. Но не всё так однозначно, и я покажу, где LLM действительно нужны.
Шаг 1: готовим данные и окружение
Беру GutenbergPy для загрузки текстов проектов Gutenberg. Выкачал по 150 книг от 5 авторов (Диккенс, Остин, Твен, По, Уэллс). Разбил на фрагменты по 500 слов — получилось ~6000 документов. Лейблы — авторы.
Важно: Не используйте современные тексты, которые могли быть сгенерированы LLM, иначе вы будете обучать детектор на аномалиях. Берите тексты до 2010 года — гарантия, что стиль не разбавлен нейросетками. Подробнее о влиянии LLM на авторский стиль — в статье LLM-редактура: почему авторы скрывают использование нейросетей.
import nltk
import spacy
from sklearn.model_selection import train_test_split
# nltk.tokenize.word_tokenize для разбивки
# сплит 70/15/15 (train/val/test)
# стратификация по авторам
Шаг 2: классический пайплайн (то, за что меня называли динозавром)
2.1 Признаки: от Bag-of-Words до FastText
Первый грабли — не использовать стоп-слова. В авторской идентификации стоп-слова — золото. Именно служебные части речи (предлоги, союзы, частицы) образуют стилистический отпечаток. Я обычно беру TF-IDF с n-граммами символов (3–5 грамм) и словами (1–3 граммы). Плюс добавляю FastText эмбеддинги как средние векторы слов — это ловит семантические сдвиги, которые не видны на уровне униграмм.
Кстати, о векторных представлениях стиля — в статье Стиль письма — это вектор объясняется, почему эмбеддинги могут быть лучшей альтернативой токенам.
from sklearn.feature_extraction.text import TfidfVectorizer
import fasttext.util
# char n-grams 3-5
char_vectorizer = TfidfVectorizer(analyzer='char', ngram_range=(3,5), max_features=30000)
X_char = char_vectorizer.fit_transform(corpus)
# word n-grams 1-3
word_vectorizer = TfidfVectorizer(analyzer='word', ngram_range=(1,3), max_features=50000)
X_word = word_vectorizer.fit_transform(corpus)
# FastText (загружаем модель, усредняем)
ft = fasttext.load_model('cc.en.300.bin')
def doc_vector(doc):
words = nltk.word_tokenize(doc.lower())
vecs = [ft.get_word_vector(w) for w in words if w in ft]
return np.mean(vecs, axis=0) if vecs else np.zeros(300)
X_ft = np.array([doc_vector(d) for d in corpus])
# объединяем scipy.sparse.hstack
from scipy.sparse import hstack
X_combined = hstack([X_char, X_word, csr_matrix(X_ft)])
2.2 Модели: Vowpal Wabbit и стекинг
Vowpal Wabbit — зверь для разреженных данных. Он обучается на порядок быстрее любой sklearn-модели благодаря онлайн-градиенту и хэшированию признаков. Но с ним нужно уметь обращаться: без правильного learning rate и опции сброса весов модель будет переобучаться.
Я использую стекинг: первый уровень — логистическая регрессия (на TF-IDF), второй — градиентный бустинг (на предсказаниях + FastText). Стекинг даёт +5–7% точности на моём датасете по сравнению с отдельными моделями. Пример правильного стекинга (не делайте ту же ошибку, что я — не используйте мета-фичи из тестовых данных без кросс-валидации):
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold
import numpy as np
# базовая модель
base = LogisticRegression(max_iter=1000, multi_class='multinomial')
# мета-признаки на основе out-of-fold предсказаний
skf = StratifiedKFold(n_splits=5)
meta_features = np.zeros((len(corpus), len(authors)))
for train_idx, val_idx in skf.split(X_combined, y):
base.fit(X_combined[train_idx], y[train_idx])
meta_features[val_idx] = base.predict_proba(X_combined[val_idx])
# стекер — градиентный бустинг
stacker = GradientBoostingClassifier()
stacker.fit(meta_features, y)
Типичная ошибка: Использовать все данные для генерации мета-признаков, а не только train. Утечка данных даст красивое 99% на валидации и 60% на реальных данных. Всегда используйте StratifiedKFold или Hold-out.
Шаг 3: LLM-подход (BERT, который я так и не подружил)
Взял bert-base-uncased (transformers 4.48.0, PyTorch 2.5). Токенизировал тексты, обрезал до 512 токенов, добавил [CLS] для классификации. Fine-tuning 3 эпохи, lr=2e-5, батч 16.
from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSequenceClassification.from_pretrained('bert-base-uncased', num_labels=5)
def tokenize(batch):
return tokenizer(batch['text'], padding='max_length', truncation=True, max_length=512)
# dataset, trainer...
Результат: 82% accuracy на тесте. Против 91% у стекинга. BERT переобучился на конкретные фразы, а не на стиль. Плюс время обучения — 4 часа на V100 против 10 минут на 8 ядрах CPU. Неоправданно.
Если хотите поглубже понять, когда LLM в продакшне — зло, смотрите чек-лист Delegation Filter: когда НЕ использовать LLM в продакшн-пайплайнах. Там наглядно: если задача — классификация без контекста длиннее 512 токенов, классика почти всегда выигрывает.
Бенчмарк: цифры, которые не дадут вам спать
| Метод | Accuracy | F1 (weighted) | Время обучения (CPU) | RAM |
|---|---|---|---|---|
| TF-IDF + LogisticRegression | 0.87 | 0.86 | 2 мин | 1.2 ГБ |
| FastText + Vowpal Wabbit | 0.89 | 0.88 | 1 мин | 0.8 ГБ |
| Стекинг (TF-IDF + FastText + GB) | 0.91 | 0.91 | 10 мин | 2.5 ГБ |
| BERT fine-tuned (3 эпохи, GPU) | 0.82 | 0.81 | 4 часа (GPU) | 8 ГБ+ |
Вывод: классический пайплайн в 9 раз быстрее и на 10% точнее. Но есть оговорка — BERT лучше справляется с очень короткими текстами (менее 100 слов), где статистические признаки зашумлены. Если ваш продакшн оперирует микропостами из соцсетей — LLM может быть оправдан.
Подводные камни, о которых молчат туториалы
- Дисбаланс классов. У одного автора 500 документов, у другого — 50. Без взвешивания или oversampling классификатор проигнорирует редкого автора. Используйте
class_weight='balanced'или SMOTE. - Стилистические дрейфы. Один автор меняет стиль с годами (например, ранний Твен vs поздний). Если смешивать все тексты в одну кучу — модель будет путать. Решение: добавлять год как признак или делать временные срезы. Об этой проблеме подробнее в Антиплагиат 2026.
- Пересечение тем. Если один автор пишет только про космос, а второй — про кулинарию, модель выучит темы, а не стиль. Нужно или балансировать по темам, или использовать де-темизацию (выкидывать content words).
Грабли, на которые я наступил: Я решил, что FastText-эмбеддинги можно не нормировать вместе с TF-IDF. Получил градиентный взрыв на стекинге. Нормируйте каждый блок признаков отдельно, а потом объединяйте. Иначе один признак перевесит другие на порядок.
Когда всё-таки LLM? (и почему я иногда её использую)
Если вам нужно идентифицировать автора по тексту, который содержит много цитат, диалогов, или автор намеренно меняет стиль (как в феномене Elias Thorne), классические фичи пасуют. LLM видят контекст. Но это уже не «гайд по идентификации», а детектив с элементами шизофрении. Я бы не советовал идти этим путём без серьёзных вычислительных ресурсов.
Есть ещё трюк: использовать эмбеддинги из LLM как плотные признаки, а не fine-tuning всю сеть. Например, взять sentence-transformers, получить вектор текста и скормить его в LogisticRegression. Это даёт ~85% accuracy — компромисс между скоростью и качеством. Получается гибрид. Кстати, похожий подход описан в RAG за 15 минут, только там для поиска, а здесь — для классификации.
Финальный совет (спойлер: он не про код)
Многие инженеры гонятся за новым железом, забывая, что часто простейшие решения работают лучше. Если вы пилите MVP для идентификации авторов, начните с TF-IDF + Vowpal Wabbit. За день получите работающий сервис. А если заказчик настаивает на «нейросетях», покажите ему этот бенчмарк. Пусть заплатит за GPU — вы будете только рады.
И последнее: не верьте ни одной модели, которая даёт точность выше 95% на этой задаче, если в данных нет явных лексических уникальностей (например, автор-поэт использует специфические рифмы). Стиль — это шумная сигнатура. Идеального детектора не существует. Но приблизиться к нему можно, используя стекинг и нормальную инженерию признаков.
Полный код проекта — на моём GitHub. Ссылка в профиле (без рефералок, честно).
P.S. Если после прочтения вы всё ещё хотите прикрутить сюда LLM — перечитайте раздел про невидимые Unicode-символы. Возможно, потом передумаете.