Телевизор я смотрю редко. Когда всё-таки включаю, хочется попасть на хороший фильм о путешествиях, науке или космосе. Нравятся каналы вроде «Моей Планеты», но смотреть всё подряд даже там не хочется. В одной сетке соседствуют экспедиции, гастротуры, обзоры достопримечательностей и передачи, которым достаточно идти фоном. Кулинария меня вообще не интересует.
Решил сделать персональную разметку телепрограммы: читать названия и описания, находить подходящие передачи и добавлять звёздочку в начало названия. Получился конвейер от XMLTV до небольшой BERT-модели в домашнем Kubernetes. На синтетически расширенной выборке модель показала macro-F1 = 1,000, а в выходном расписании нашлись 66 записей со звёздочкой.
При проверке материала для статьи выяснилось, что оба результата требуют серьёзных оговорок. У 119 из 120 тестовых записей описание уже встречалось в обучении с другим номером части. В обработчике XML обнаружилась ошибка, из-за которой тысячи передач записались пустыми. Ниже разберу устройство системы, реальные ответы модели и то, что мы преждевременно приняли за успешное внедрение.
Сначала разобраться, что вообще есть в подписке
На телевизионной приставке работает OSMC с Kodi и IPTV Simple. Адрес плейлиста уже лежит в настройках дополнения. Источник расписания отдаёт XMLTV: каналы, время начала и окончания, название, иногда описание передачи.
Мы взяли именно настроенную подписку. В её плейлисте оказалось 1 332 канала. В разделах «Образование» и «Природа» нашлось 105, включая «Мою Планету», «Науку 2.0», Galaxy, «Космический», Discovery Science и Travel+Adventure. Путешествия у провайдера входили в образовательный раздел.
В доступном снимке расписания на 3, 4 и 5 октября 2026 года было 10 670 записей эфира этих разделов. На последний день данные были неполными. Повторные показы и разные выпуски одного цикла учитывались отдельно.
Уже на этом этапе проявилась проблема названий каналов. На «Космическом» рядом с реальными космическими миссиями идут «Материалы о пришельцах». На любимом тревел-канале встречается «Магия вкуса». Подписка и жанровая группа определяют область поиска, но не гарантируют, что конкретный выпуск мне подходит.
Проба с Jev: тема и содержательность оказались разными признаками
Для первой выборки использовали Jev от TypeSafe. В соседнем проекте уже был клиент его API с типизированными ответами. Передавали публичные названия и синопсисы, без адресов потоков и реквизитов подписки.
Модели задавали два вопроса:
-
Подходит ли тема взрослому зрителю, которому интересны путешествия, природа, наука и космос, но не кулинария, бытовые реалити и паранормальные истории?
-
Есть ли в описании конкретные признаки содержательного разбора: исследователь, экспедиция, объяснение механизма, изучение экосистемы или культуры?
Для подробного пилота выбрали 16 каналов. После объединения повторов по каналу, названию и описанию, исключения пустых синопсисов и слотов короче 20 минут осталось 782 записи. Ограничение по длительности было способом сузить эксперимент: короткая передача вполне может быть хорошей.
Успешно обработали 114 записей. Ещё 100 запросов завершились сетевой ошибкой URLError, оставшиеся 568 отменили до отправки. Это существенно меньше подготовленной выборки; полного автоматического обзора подписки на этом этапе не было.
Вот несколько фактических ответов Jev:
|
Передача |
Соответствие теме |
Признаки содержательности |
|---|---|---|
|
«Аттенборо и кладбище мамонтов» |
0,92 |
0,76 |
|
«Арал: погасшее море» |
0,93 |
0,75 |
|
«Вопрос науки. Альтермагнетики: новый вид магнетизма или хайп» |
0,92 |
0,68 |
|
«Сокровище Купера. Космическая карта сокровищ» |
0,85 |
0,55 |
Последняя строка особенно полезна. История с космической картой и поиском сокровищ получила достаточно высокий балл, хотя такой формат я не хотел бы видеть в строгой подборке научпопа. Эти числа нельзя читать как вероятность того, что фильм понравится, или как подтверждение его научной аккуратности.
После ручного просмотра описаний в список кандидатов вошли «Арал», «Путешествие Кюриосити», «Монголия» Леонида Пашковского, «Патагония. Край неизведанного», фильм Аттенборо о раскопках мамонтов. Для части кандидатов проверили карточки передач и дистрибьютора. Фильмы целиком при этом не смотрели. Первичная подборка была редакторским отбором, её нельзя приписывать обученной позже модели.
Переносим подход из классификатора сообщений
В проекте italent уже был опыт классификации сообщений: предлагают работу, ищут работу, прочее. В сервисе iinference работал классификатор сообщений на базе cointegrated/rubert-tiny2 с инференсом через ONNX Runtime.
Для телепрограммы взяли ту же базовую модель, примерно 29 миллионов параметров, добавили классификационную голову и дообучили всю модель на новых метках. Готовые веса классификатора вакансий для этой задачи не использовали.
Задали четыре класса:
|
Метка |
Как мы её определили |
|---|---|
|
|
Содержательная наука, космос, природа, география, экспедиции и авторский тревел |
|
|
Кулинария, рецепты, гастротуры, передачи преимущественно о еде |
|
|
Поверхностные топы, некоторые развлекательные форматы, псевдонаучные и паранормальные истории |
|
|
Остальное вне текущих интересов: дача, рыбалка, уход за домашними животными и подобные темы |
Это персональная схема. Хорошая программа о рыбалке всё равно окажется в other. Метка filler тоже получилась слишком широкой: она объединяет и безобидный обзор, и передачу с сомнительными научными утверждениями. В дальнейшем эти причины отказа стоит разделить.
На вход модели поступает строка из названия и описания. Название канала в этот текст не включаем. Он участвует только в выборе разделов, которые нужно обработать.
В iinference/training/ появились отдельные скрипты:
training/ prepare_tv_dataset.py # подготовка именно телевизионного корпуса train_classifier.py # обучение на JSONL или CSV export_onnx.py # отдельный экспорт существующей HF-модели eval_classifier.py # оценка ONNX-модели и времени инференса package_model.ts # SHA-256 файлов для реестра моделей
Общий тренер принимает text и label, либо title, desc и метку. Имена классов задаются данными или параметром --labels. Телевизионные примеры не зашиты в цикл обучения.
При этом полную миграцию старого обучения из italent мы не делали. В iinference появился отдельный общий модуль; старый скрипт в соседнем проекте остался. Подготовка TV-корпуса также пока привязана к локальному файлу пилотной разметки. Публиковать это как готовый переносимый ML-пакет было бы преждевременно.
Откуда взялись 600 примеров
В корпус попала часть реальных записей пилота. Скрипт назначал им метки по оценкам Jev и ключевым словам. Например, для interesting требовались fit >= 0.85 и substance >= 0.5. Вручную подтверждённым эталоном весь этот набор не являлся.
Затем добавили подготовленные нами примеры с характерными темами и синтетическими описаниями. Число строк довели до 150 на класс. Способ расширения оказался очень простым: к названию добавляли «Обзор», «Спецвыпуск» или «Документальный проект», а к тому же описанию приписывали «Часть N».
Это были вариации нескольких десятков исходных текстов. Нельзя считать их 600 независимо размеченными телепередачами. Синтетические описания также не подтверждают содержание реальных одноимённых фильмов.
После генерации перемешали строки и разделили их в отношении 80/20:
|
Класс |
Обучение |
Проверка |
|---|---|---|
|
|
121 |
29 |
|
|
110 |
40 |
|
|
126 |
24 |
|
|
123 |
27 |
|
Всего |
480 |
120 |
Порядок этих действий стал главной ошибкой эксперимента. Почти одинаковые варианты одного текста разошлись по обучению и проверке.
Обучение и экспорт
Модель обучали на CPU: пять эпох, батч 16, AdamW, learning rate 3e-5, weight decay 0.01. Максимальная длина входа составила 256 токенов.
Команда из корня iinference, в окружении с установленными зависимостями:
python training/train_classifier.py \ --train training/data/tv_train.jsonl \ --test training/data/tv_test.jsonl \ --out data/models/tv-classifier \ --epochs 5 \ --batch-size 16 \ --lr 3e-5
Сохраняли checkpoint при улучшении macro-F1. Уже на второй эпохе получили 1,000. На следующих эпохах значение не менялось, поэтому в экспорт пошли веса второй эпохи.
С экспортом тоже пришлось разобраться. Современный экспортёр PyTorch создал маленький model.onnx и отдельный файл весов model.onnx.data. Первый отчёт посчитал размер только графа и напечатал «0.0 МБ». Затем проверка паритета упала: код использовал объект NodeArg как ключ вместо имени входа.
Исправили обращение к входам ONNX Runtime и переключили экспорт на dynamo=False, opset 17. Получился единый ONNX-файл размером около 111,4 МиБ. В десяти проверочных текстах расхождение логитов PyTorch и ONNX выводилось как 0.000000 при шести знаках после запятой. Это результат ограниченной численной проверки, а не обещание точного совпадения для всех входов.
Словарь WordPiece, конфигурацию и labels.json положили рядом. Скрипт упаковки вычисляет SHA-256 артефактов; сервис проверяет эти суммы при загрузке.
Отдельный запуск оценки сохранённой ONNX-модели дал:
|
Измерение |
Результат |
|---|---|
|
Accuracy на 120 строках |
1,000 |
|
Macro-F1 на них же |
1,000 |
|
Медиана времени |
1,67 мс |
|
95-й процентиль времени |
2,03 мс |
Время измеряли локально на CPU, без токенизации, HTTP и очереди сервиса. Полный запрос с приставки этим числам не равен.
Почему идеальная метрика ничего не доказала
При подготовке статьи проверили сохранённые файлы корпуса. Для каждой строки взяли desc, удалили конечное «Часть N.» и сравнили описания между выборками.
Результат: 119 из 120 проверочных строк имели такое же нормализованное описание в обучающей части. Во всём корпусе из 600 строк оказалось 68 различных описаний после этой нормализации.
Модель могла узнавать знакомую формулировку. Для этого не требуется научиться выбирать хороший документальный фильм среди незнакомых передач.
Дополнительная проблема: набор с именем test использовался для выбора checkpoint на каждой эпохе. Фактически он выполнял роль validation. Отдельного независимого теста у нас не было.
Поэтому корректная запись результата звучит так: «ONNX-модель без ошибок классифицировала этот набор из 120 сильно пересекающихся с обучением строк». Утверждение «мы научились отличать хорошие передачи со стопроцентной точностью» из него не следует.
Разделять такой корпус нужно до аугментации, сохраняя все варианты исходного текста в одной группе. Для телепередач разумно дополнительно группировать выпуски по циклам: две серии с одним шаблонным синопсисом тоже создают утечку. Незнакомые передачи и описания из других дней стоит оставить для финальной проверки.
Как модель попала на приставку
На самой OSMC модель не запускали. В домашнем Kubernetes на узле moon уже работал сервис iinference на Bun с onnxruntime-node. В его реестр добавили tv-classifier, веса скопировали на persistent volume и выкатили новую версию через werf.
После выкладки /health вернул новую модель в списке доступных. Проверка развёртывания наблюдала сервис 90 секунд без рестартов. Запрос классификации с OSMC также прошёл.
Запрос к существующему API имеет такой вид:
{ "model": "tv-classifier", "input": [ "Арал: погасшее море. Исследование причин экологического кризиса", "Магия вкуса. Вкуснейшие рецепты шашлыка и десертов", "Материалы о пришельцах. Тайная база НЛО" ]}
Сервис возвращает метку и softmax-оценки всех классов. Для этих строк фактические ответы с приставки были следующими:
|
Вход |
Выбранный класс |
Score победившего класса |
|---|---|---|
|
«Арал… Исследование причин экологического кризиса» |
|
0,451 |
|
«Магия вкуса… рецепты шашлыка и десертов» |
|
0,601 |
|
«Материалы о пришельцах. Тайная база НЛО» |
|
0,391 |
Все три решения соответствуют нашей схеме, но формулировки близки к обучающим примерам. Это проверка работающего API и понятных случаев, не независимый замер качества.
У «Арала» весь вектор выглядел так:
culinary 0.1344filler 0.2631interesting 0.4508other 0.1517
В текущей интеграции достаточно победы класса interesting. Порога уверенности нет. Поэтому звёздочка может появиться и при score меньше 0,5. Softmax здесь не калибровали на пользовательских оценках.
Разметка XMLTV и реальные находки
До этого в itv уже был потоковый обработчик XMLTV. Он оставляет каналы подписки и временное окно от предыдущих 24 часов до следующих 48. Параллельный запуск блокируется файловым lock, результат записывается через временный файл.
Добавленная часть собирает программы образовательных и природных каналов в HTTP-пакеты до 64 элементов. Повторяющиеся тексты объединяются, результаты кешируются в памяти на время запуска. Остальные каналы не отправляются классификатору. Если возвращается interesting, обработчик добавляет ⭐ к <title>.
Полный путь данных выглядит так:
Плейлист подписки + XMLTV | vPython на OSMC: каналы, даты, название и описание | | HTTPS, POST /v1/classify vBun + ONNX Runtime в локальном Kubernetes | | label + scores vИзменение <title> в выходном XMLTV | vФайл телепрограммы для IPTV Simple / Kodi
Проверочный прогон использовал уже скачанный XMLTV на OSMC. Лог сообщил о 1 297 каналах и 108 937 программах, время выполнения составило около 117 секунд. Эти числа относятся ко всему обработанному расписанию, а не только к научно-познавательным каналам.
В выходном файле нашли 66 записей с префиксом. Среди них:
|
Название со звёздочкой |
Как это выглядит относительно запроса |
|---|---|
|
«Секреты квантовой физики», серия 2 |
Подходящая научная тема |
|
«Чудеса Вселенной» |
Подходящая космическая тема |
|
«Научный туризм. Музей криптографии», часть 1 |
Перспективное сочетание науки и посещения музея |
|
«На поезде вокруг света. Мексика» |
Подходящий тревел-кандидат |
|
«Наносфера» |
Возможный научпоп; одного названия мало для оценки глубины |
|
«Девочка из Ватикана: исчезновение Эмануэлы Орланди» |
Криминальная история попала в подборку вне выбранных интересов |
|
«Тени прошлого. Смертельная загадка», часть 1 |
Сомнительное попадание; требуется проверка содержания |
|
«Профотбор», серия 2 |
По названию невозможно объяснить рекомендацию |
Эта таблица содержит фактически помеченные названия. Комментарий справа выражает нашу оценку соответствия запросу. Содержательность самих фильмов просмотром не проверяли.
Ошибка в XML сделала результат хуже, чем показал лог
При подготовке статьи повторно прочитали итоговый файл на OSMC. В нём оказалось 11 257 пустых элементов <programme/> при общем числе 108 937. Синтаксически XML оставался корректным, поэтому обычный парсинг не сообщал об ошибке.
Причина видна в обработчике:
pending.append((element, text, title_el))if len(pending) >= args.batch_size: flush_pending(pending, out)root.remove(element)element.clear()
В очередь помещается ссылка на Element, а затем этот же объект очищается. У большинства элементов в момент очистки очередь ещё не достигла размера батча. Позже сериализатор получает пустой объект вместо передачи с каналом, временем и названием.
Отдельная ссылка на title_el не спасает ситуацию: после element.clear() дочерний узел уже не входит в родительский элемент. Изменение его текста не возвращает заголовок в сериализуемую передачу.
Поэтому 66 звёздочек нельзя интерпретировать как «модель тщательно выбрала 66 лучших передач из всего расписания». Это число сохранившихся помеченных записей в повреждённом результате. В первоначальной проверке мы посчитали звёздочки и не проверили сохранность остальных записей.
Исправление должно изменить время жизни XML-элементов: удалить их из корня, но очищать только после записи отложенного батча, либо буферизовать независимые данные. После этого нужны проверки количества полноценных записей, обязательных атрибутов channel и start, сохранности заголовков. Файловая атомарность сама по себе от логически неверного содержимого не защищает.
В этой статье описано найденное состояние; исправление обработчика и новый прогон не выполнены. Наличие префикса мы проверяли в XML, отображение звёздочки в интерфейсе Kodi отдельно не подтверждали.
Что ещё требуется до повседневного использования
Сам HTTP-путь работает: приставка обращается к локальному сервису, ONNX-модель возвращает классы. Но для ежедневной разметки пока недостаточно ни качества корпуса, ни сохранности результата.
Для самой модели следующий полезный замер должен отвечать на вопрос: сколько из помеченных передач действительно хочется смотреть? Для редкого просмотра precision рекомендаций важнее количества звёздочек. Нужны реальные новые программы, человеческая разметка и возможность воздержаться от решения, когда описания недостаточно. Порог стоит выбирать на отдельной проверочной выборке, а не назначать по впечатлению от нескольких score.
Содержимое эфира остаётся отдельным ограничением. Рекламный синопсис может обещать исследование там, где фильм заполнен повторами и драматической музыкой. Классификатор текста этого не увидит. Накопленные оценки после просмотра здесь полезнее ещё сотни вариаций «Спецвыпуск: та же передача».
Что осталось от эксперимента
Мы получили общий тренер текстового классификатора в iinference, экспорт в ONNX и работающий инференс на CPU с миллисекундным временем исполнения графа. На реальных запросах сервис отличил кулинарные и уфологические примеры от экологической темы. В расписании среди пометок нашлись передачи о квантовой физике, Вселенной и путешествии по Мексике, но туда же попала криминальная история.
Первый отчёт переоценил результат: идеальная F1 объяснялась пересечением данных, а успешное завершение скрипта скрывало потерю содержимого XML. Сейчас осмысленный критерий следующего запуска вполне конкретен: все передачи сохраняются, тестовые циклы отсутствуют в обучении, а выбранную подборку оценивает зритель.
ссылка на оригинал статьи https://habr.com/ru/articles/1090216/