Что получилось в итоге: короткий ответ по кейсу
Кейс опубликован на Хабре 7 ноября 2026 года и отвечает на конкретный вопрос: можно ли предсказать просмотры статьи по её структуре. Автор собрал 81 запись: 80 чужих статей хаба «Фриланс», половина из свежей ленты и половина из лучших публикаций за всё время, плюс одна своя статья с меткой is_my_article = 1. Модель Random Forest на Python училась на длине заголовка, количестве слов, картинок, ссылок и блоков кода.
Первая версия предиктора дала завышенный прогноз. Причина - утечка данных: собственная статья автора про биржу Kwork с известными 30 000 просмотров осталась в обучающей выборке. Модель видела ответ и переносила эту связь на остальные записи.
После исключения своей статьи из обучения и ограничения глубины деревьев прогноз стал скромнее: 26% вероятности набрать больше 25 000 просмотров и около 14 795 просмотров для исходной структуры. Для доработанной структуры те же расчёты дали 41% и 34 919. Второй урок кейса касается локальных LLM: Qwen3 4B в LM Studio скопировала числа из примера в промпте и выставила одинаковые оценки всем 81 статье. После этого автор отказался от LLM-скоринга в пользу признаков, извлекаемых регулярными выражениями.
Зачем вообще предсказывать просмотры и что именно мы предсказываем
Целевая метрика в кейсе - просмотры статьи на Хабре, приведённые к целым числам: значение вида 30K превращалось в 30 000. Модель предсказывает именно это число, а не «успех» материала. Разница принципиальная: у публикации может быть мало просмотров и много сохранений в закладки, и наоборот.
Практическая польза такого предиктора ограничена сравнением структур. Если вы меняете заголовок, добавляете картинки или сокращаете текст, модель показывает, как изменится ожидаемое число просмотров при прочих равных. Смысл текста, тема, день публикации и активность аудитории в признаки не входят, поэтому вывод всегда условный.
Выборка тоже задаёт рамки. Хаб «Фриланс» - узкая ниша, а половина датасета набрана из лучших статей за всё время. Такое смешение сдвигает распределение просмотров вверх: модель учится на выборке, которая не похожа на случайный поток публикаций, и переносить её оценки на обычную ленту нельзя без оговорок.
Сбор датасета: 81 статья хаба «Фриланс» и файл habr_dataset.csv
Данные сложены в файл habr_dataset.csv: 81 строка, где 80 записей относятся к чужим статьям, а одна помечена is_my_article = 1. Поля таблицы:
- title_length - длина заголовка в символах;
- word_count - общее число слов в тексте;
- images_count - количество изображений;
- links_count - количество ссылок внутри материала;
- code_blocks_count - количество блоков с кодом;
- views - просмотры, переведённые из сокращений вроде 30K в обычные целые числа;
- text_preview - первые 3000 символов текста, сохранённые для передачи в LLM.
text_preview появился под LLM-скоринг: модель должна была читать начало статьи и ставить оценки. Для структурных признаков это поле не нужно, и именно поэтому оно осталось невостребованным после провала нейросетевого этапа.
81 строка для Random Forest - очень мало. Одно наблюдение с 30 000 просмотров на таком объёме тянет на себя вес целой подвыборки, и деревья начинают опираться на него. Тот же эффект даёт любой выброс: залетевшая в топ статья или, наоборот, публикация с 50 просмотрами меняет картину сильнее, чем хотелось бы. Когда данных становится больше и появляется потребность в разметке людьми, схема усложняется: разбор Argilla 2.4 показывает, как устроен такой workflow без кода.
Признаки: что реально извлекается из статьи регулярными выражениями
Все пять структурных признаков считаются парсингом текста и регулярными выражениями. Нейросеть для них не нужна, и это главное достоинство: результат одинаков при повторном запуске, не зависит от формулировки промпта и не ломается при смене модели.
Схематичный пример на Python, иллюстрирующий подход:
import re
def structural_features(raw: str, title: str) -> dict:
plain = re.sub(r"https?://\S+", " ", raw)
return {
"title_length": len(title),
"word_count": len(plain.split()),
"images_count": len(re.findall(r"!\[[^\]]*\]", raw)),
"links_count": len(re.findall(r"https?://\S+", raw)),
"code_blocks_count": raw.count("```"),
}
title_length ловит длину заголовка, word_count задаёт объём, images_count и code_blocks_count показывают плотность визуального и технического контента, links_count считает внешние отсылки. Набор скромный, зато считается за секунды и хранится рядом с датасетом, так что любой прогон модели можно повторить и получить те же числа.
Ограничение простое: признаки описывают форму и ничего не говорят о смысле. Две статьи с одинаковой длиной заголовка и одинаковым числом картинок могут отличаться по читательскому интересу в десятки раз. Зато как стабильный baseline такой набор работает лучше, чем нестабильные оценки маленькой модели.
Попытка прогнать датасет через LLM: OpenRouter и ошибка 429
Скрипт ai_scorer.py отправлял в LLM заголовок и text_preview, а на выходе ждал четыре оценки по шкале 1-10: clickbait_score, hook_score, pain_score и readability_score. Логика понятная: кликбейтность, цепляющее начало, попадание в боль читателя и читабельность в структурные признаки не входят, а через модель их можно получить.
Первый прогон шёл через OpenRouter на бесплатной модели google/gemma-4-26b-a4b-it:free. Уже на первой статье сервер вернул ошибку 429 (temporarily rate-limited upstream). Для пакетной обработки 81 записи бесплатный доступ без ретраев и контроля лимитов не годится: даже если часть запросов проходит, воспроизвести прогон через день сложно.
Ответ напрашивался сам: поднять модель локально. Локальный запуск снимает лимиты и стоимость запроса, зато добавляет свою проблему - качество маленькой модели и её поведение на однотипном промпте.
Qwen3 4B в LM Studio: как локальная модель скопировала ответ 81 раз
Автор поднял Qwen3 4B в LM Studio и прогнал ai_scorer.py по всем 81 статье. Результат оказался одинаковым для всех записей: модель скопировала числа из примера в промпте и воспроизвела их как ответ.
Механизм типичный для небольших моделей. Если в few-shot примере стоят конкретные значения, скажем 7, 8, 6 и 9, модель воспринимает их как образец формата и часто как готовое содержание. Ей проще вернуть знакомый набор, чем разбирать текст. Чем меньше модель и чем жёстче шаблон ответа, тем выше шанс такого поведения.
Выбирая модель для пакетного прогона, полезно заранее посмотреть, как она ведёт себя на чужих тестах: locallm.top собирает результаты моделей с открытыми весами и оценки экспертов по доменам.
Как проверить, что локальная LLM не копирует ответ
Проверки дешёвые и занимают минуты:
- Посчитать число уникальных значений по каждому критерию. Одно уникальное значение на 81 строку означает, что модель не различает тексты.
- Прогнать два заведомо разных текста: сухой технический отчёт и эмоциональный разбор. Совпавшие оценки - повод не доверять всему прогону.
- Убрать конкретные числа из примера в промпте и заменить их описанием формата ответа.
- Отправить один и тот же текст дважды. Расхождение покажет вклад случайности, совпадение ещё не доказывает качество.
- Сверить оценки с простым подсчётом корреляции с длиной текста или заголовка. Нулевая связь там, где она обязана быть, говорит о шаблонном ответе.
Логика универсальная: если метрика меняется без реальных изменений объекта, проблема в инструменте, а не в объекте. Разбор ошибки в тесте «машинности» текста показывает, как это выглядит на практике, когда замер падает без правок.
Один прогон локальной модели без проверки разброса оценок даёт мусорные признаки, которые выглядят как данные. В кейсе это заметили и вернулись к объективным признакам, извлекаемым регулярками.
Утечка данных: почему первая модель выдала аномальный прогноз
Утечка в этом кейсе выглядит буднично. В датасете была своя статья с меткой is_my_article = 1 и известным результатом: 30 000 просмотров. Она осталась в обучающей выборке, а значит, модель училась на строке, для которой целевое значение ей известно, и переносила эту связь на остальные записи.
Симптомы утечки: точность на валидации выглядит подозрительно высокой, прогноз для новой структуры нереалистично большой, а после удаления одной строки метрики падают в разы. В кейсе аномальный прогноз заметили именно так: модель выдала результат, который не сходился со здравым смыслом.
Общее правило: целевая метрика не должна быть известна модели на этапе обучения, а тестовые примеры отделяются до любых преобразований данных. Одна строка с «правильным ответом» внутри train ломает оценку качества сильнее, чем кажется.
Как исключить свою статью из обучения и не потерять данные
Свою статью убирают из обучающей выборки и оставляют отдельным тест-кейсом: структура известна, фактические просмотры тоже, значит есть с чем сравнить прогноз. На 81 записи потеря одной строки не критична, при меньших выборках важнее аккуратность: деление на train и test делается до масштабирования, отбора признаков и любых операций, которые смотрят на всю таблицу.
Второй элемент исправления - ограничение глубины деревьев. На восьмидесяти строках дерево без ограничения растёт до почти идеального попадания в обучающие точки и запоминает шум. Параметры max_depth и минимальный размер листа возвращают модель к обобщению. Конкретные значения подбираются по валидации, ориентир для малых данных выглядит так:
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(
n_estimators=500,
max_depth=4,
min_samples_leaf=3,
random_state=42,
)
Результаты после исправлений: 26% против 41% и 14 795 против 34 919
После двух исправлений модель выдала пары чисел, которые легко перепутать. По данным автора кейса итог такой:
- 26% вероятности набрать больше 25 000 просмотров - оценка шанса превысить порог для исходной структуры;
- около 14 795 просмотров - точечный прогноз для той же структуры;
- 41% и 34 919 - те же две величины для доработанной структуры.
То есть правки в структуре текста, по оценке модели, повышают и ожидаемое число просмотров, и шанс перешагнуть порог в 25 000. Разница между вариантами почти в 2,3 раза по точечному прогнозу; на выборке из 81 строки такая амплитуда означает скорее направление, чем точную величину.
Читать цифры нужно раздельно. Вероятность отвечает на вопрос «как часто похожие статьи переходят порог», точечный прогноз - «сколько ожидается в среднем». Распределение просмотров имеет длинный правый хвост, поэтому среднее обычно выше медианы, и расхождение между двумя метриками нормально.
Ограничения подхода: что структурная модель не видит
Ограничения стоит держать в голове до того, как показывать предиктор коллегам:
- Малая выборка. 81 статья, Random Forest на таких данных легко переобучается, а качество сильно зависит от нескольких выбросов.
- Признаки описывают форму. Смысл текста, тема, качество аргументации и актуальность в модели не участвуют.
- Узкая ниша. Хаб «Фриланс» не равен Хабре целиком, перенос выводов на другие хабы или на другой сайт ничем не обоснован.
- Нет времени и контекста. Дата публикации, новостная повестка, активность аудитории и попадание в дайджесты не учтены.
- Порог выбран вручную. 25 000 просмотров - удобная граница для конкретного кейса, а не универсальная метрика.
Отсюда рабочее применение: предиктор сравнивает структуры между собой. Решение о том, менять ли заголовок или добавлять картинки, остаётся за автором, который видит смысл текста и понимает, о чём он пишет.
Чек-лист: как повторить кейс на своём датасете
- Соберите данные в CSV с явными полями: заголовок, текст, просмотры, метка своей статьи.
- Отделите тестовые примеры и свою статью до обучения, а не после первого прогона.
- Извлекайте структурные признаки регулярными выражениями и храните скрипт расчёта рядом с данными.
- Если используете LLM для оценок, уберите конкретные числа из примеров в промпте и проверьте разброс значений по всем записям.
- Ограничьте глубину деревьев и минимальный размер листа, сверяйтесь с валидацией после каждого изменения.
- Смотрите на вероятность и точечный прогноз отдельно, фиксируйте размер выборки рядом с любым числом.
- При смене промпта, модели или набора признаков повторяйте весь прогон целиком, иначе сравнение теряет смысл.
Инструменты кейса простые: Python и Random Forest для модели, LM Studio с Qwen3 4B для неудачной попытки LLM-скоринга, OpenRouter как первый вариант облачного прогона. Ценность кейса не в точности чисел, а в двух проверках, которые стоит встроить в любой похожий эксперимент: контроль утечки данных и контроль того, что модель действительно различает входы.