Spellman


Зачем детектировать язык

Мы любим текстовые представления данных: они легче медиа-файлов, по ним намного дешевле и проще искать и, как правило, эффективность работы LLM с ними намного выше. Но просто получить текст недостаточно, чтобы выжать из него максимум, мы применяем различные обработчики: разбиваем текст на слова для полнотекстового поиска; проводим морфологический анализ, чтобы находить слово во всех формах; выделяем и нормализуем именованные сущности для работы в нашем поисковом синтаксисе.

Все эти обработчики представляют из себя комбинацию из специализированных моделей и кода на Rust; мы не используем LLM для решения этих задач, т.к. это требует очень дорогого оборудования, ухудшает скорость обработки и демонстрирует худшие метрики на «диких» текстах, чем специализированные решения.

Но как понять, какой набор обработчиков применить к документу? По его языку: у каждого языка свой токенизатор, своя морфология и свои модели именованных сущностей, поэтому обработка документа начинается с определения языка. Ошибка здесь дорогая — документ уходит не в тот конвейер, и поиск его потом не находит. Раньше мы использовали открытые решения, вроде Google CLD3 и Whichlang, но качество детекции этих моделей было таково, что заказчик терял до восьми процентов документов.

spellmanпост«@kek_lord ну и жара 🔥»email«Добрый день! Во вложении…»сообщение«щас буду, жди»транскрипция«да короче завтра не смогу»DLP«fwd: договор, see attached»русскийтокенизация · pymorphyPOSNER · нейронный + алгоритмическийпоисковая индексацияукраинскийтокенизация · ua-dictPOSNER · алгоритмическийпоисковая индексацияказахскийтокенизация · стеммингPOS — не нуженNER — нет моделипоисковая индексация

Зачем это нужно за пределами нашего конвейера? Задача та же: по языку текста решить, что с ним делать дальше. Интерфейс отвечает пользователю на языке его запроса, поиск выбирает словарь и фильтрует документы по языку, переводчик узнаёт, с какого языка переводить.

Детектор, о котором этот пост, — spellman — различает 30 языков, и 21 из них пишется кириллицей: русский, украинский и белорусский; болгарский, македонский и сербский; казахский, киргизский, узбекский, татарский, башкирский, чувашский, якутский и тувинский; таджикский, монгольский, осетинский, чеченский, удмуртский, марийский и коми. К ним добавлены пять крупных языков на латинице — английский, испанский, французский, португальский и немецкий — и четыре языка со своей письменностью: китайский, японский, хинди и арабский.

Какие решения уже есть

Вне экосистемы Rust детекция языка давно стандартизировалась на моделях в духе fastText — линейный классификатор над усреднёнными хешированными n-граммными эмбеддингами (Bag of Tricks, Joulin et al. 2016). Два опорных решения здесь — ветеран, который всё ещё остаётся стандартом де-факто, и текущий SOTAState of the art — лучший известный на сегодня результат. среди открытых моделей.

fastText lid.176

lid.176 — модель Meta 2016 года на 176 языков: ~130 МБ на диске и микросекунды на CPU. Почти десять лет спустя это по-прежнему самый частый ответ на вопрос «как детектировать язык» — дешёвый, быстрый, перенесённый на все платформы. Для кириллических текстов у него два системных изъяна. Первый — дыры в списке языков: удмуртского и коми в модели просто нет, а класс uz обучен на латинице — узбекский текст в кириллице (а нужен именно он) модель не распознаёт в принципе. Второй — качество на близких парах: модель обучена на Википедии и её окрестностях, то есть на длинном аккуратном тексте, и на коротких фрагментах из соцсетей уверенно путает именно то, что различать труднее всего: русский с украинским и белорусским, болгарский с македонским.

GlotLID

GlotLID — текущий SOTA среди открытых моделей: та же fastText-архитектура, но 2 102 класса (язык + письменность: татарский в латинице и кириллице — разные классы) и обучение на собственном корпусе GlotLID-C, который вырос из Glot500. Покрытие — сильнейшая сторона модели: среди её классов есть и коми, и удмуртский, и якутский, и чеченский. На чистых предложениях вне обучающей выборки GlotLID ближе всех к потолку точности. Обратная сторона свойственна любому классификатору на две тысячи классов: на коротком тексте энтропия распределения классов съедает уверенность, и отдельные слова угадываются заметно хуже, чем моделями с компактным набором языков. Плюс цена: модель весит полтора гигабайта, а предсказание на CPU стоит сотни микросекунд — единицы тысяч документов в секунду на ядро против сотен тысяч у компактных CPU-детекторов.

Что есть в Rust

Поддерживаемых решений в экосистеме Rust немного: три активных крейта плюс несколько биндингов к детекторам Google, которые давно не развиваются. Прежде чем писать свой, я оценил каждый — и у каждого нашлась причина, по которой он не решает мою задачу.

Whatlang

Whatlang — самый популярный вариант: если Rust-разработчику нужно определить язык, он скорее всего поставит whatlang. Крейт живёт с 2016 года, его используют Meilisearch и Sonic, около 50 тысяч загрузок в месяц. Внутри — классические триграммные профили по Cavnar & Trenkle ’94 (N-Gram-Based Text Categorization): для каждого языка зашит отсортированный список из 300 самых частых триграмм, текст ранжируется по совпадению с профилем.

Ограничение метода в том, что профили строятся из частот, а не обучаются различать близкие языки (классическое различие генеративных и дискриминативных моделей): модель хорошо описывает, как выглядит текст на языке, но не учится отделять его от соседей. На близкородственных кириллических языках это раскрывается в полный рост: по замерам lingua (тестовые данные — производные корпусов Wortschatz) у whatlang болгарский 69%, сербский 66%, македонский 67%, русский 67% против 87/88/84/90 у lingua. Каждая близкая пара отдаёт проценты соседям, потому что триграммные распределения ru/uk/bg/mk/sr сильнее всего пересекаются как раз на коротком тексте. Вторая проблема — покрытие: из интересующих меня кириллических языков whatlang знает шесть (rus, ukr, bel, bul, srp, mkd) — ни казахского, ни киргизского, ни таджикского, ни одного из малых языков Поволжья и Сибири. Третья — отсутствие калибровки: is_reliable — эвристика над количеством уникальных триграмм и отрывом первого места, а не вероятность, на которую можно опереться при выборе порога.

Whichlang

Whichlang написали в Quickwit — это поисковый движок для логов и трейсов, и детектор языка ему был нужен прежде всего быстрый. Устроен он просто: признаки текста хешируются (murmur2) в таблицу из 4 096 ячеек, а поверх неё обучена логистическая регрессия. Чтобы определить язык, достаточно один раз пройти по тексту и сложить веса из нужных строк таблицы. Языков 16, на документ уходят единицы микросекунд — быстрее в экосистеме ничего нет, поэтому по скорости я ориентируюсь именно на whichlang.

Слабое место whichlang — не скорость и не число языков, а признаки: они подогнаны под его шестнадцать языков. Символьные n-граммы длиной от 2 до 4 строятся только по ASCII. Любой другой символ даёт всего два признака: в каком 128-символьном блоке Unicode он лежит и к какому классу относится по таблице, составленной вручную, — а в этой таблице только латиница с диакритикой и японские диапазоны. Арабскому, хинди и китайскому этого хватает: у каждого своя письменность. Кириллице — нет. Вся она укладывается в два блока и один общий класс; і, ї, є, ў, ђ, љ попадают в тот же блок, что и а, б, в, а пробелы между кириллическими словами вообще не дают признаков. В итоге русское, украинское, белорусское, болгарское и сербское предложения выглядят для модели почти одинаково.

Отсюда и единственный кириллический язык в списке — русский: остальные при таких признаках просто не отличить. И 99,92% точности на русском он набирает потому, что рядом нет украинского, белорусского и казахского, с которыми русский по-настоящему путается. Код для обучения в репозитории есть, но двадцать кириллических языков одним переобучением не добавишь: сначала придётся переписать извлечение признаков, чтобы n-граммы строились по любым символам Unicode, а не только по ASCII.

Lingua

Lingua — самое точное из существующих решений: фильтрация на основе правил по алфавиту плюс наивный Байес над n-граммами длины от 1 до 5, 75 языков, модели хранятся как конечные преобразователи. Заявленное преимущество lingua — именно короткие тексты, и на его бенчмарках он заметно опережает whatlang и whichlang.

Цена — скорость, и дело не в наивном Байесе, а в доступе к памяти. Для каждого языка lingua держит отдельный конечный преобразователь с n-граммами — от 3 до 5,5 МБ на язык — и каждую n-грамму текста длиной от 1 до 5 ищет в модели каждого языка-кандидата, а при промахе повторяет поиск для n-граммы покороче. Поиск в таком преобразователе — цепочка зависимых чтений по непредсказуемым адресам: следующий узел не прочитать, пока не прочитан предыдущий. Даже после фильтра по алфавиту для кириллического текста остаются восемь языков — около 36 МБ моделей, в десятки раз больше L2Кэши процессора. L2 — небольшой и быстрый, свой у каждого ядра; L3 — больше и медленнее, общий для ядер. Ещё медленнее — обычная память. одного ядра, так что в лучшем случае каждый шаг поиска — обращение к L3, которое в разы медленнее, а распараллелить зависимые чтения процессор не может. И даже там, где L3 хватает, lingua занимает его почти целиком, вытесняя данные всего, что работает на соседних ядрах.

По замерам самого автора, на классификацию 2 000 предложений lingua в high-accuracy режиме тратит ~640 мс против ~4,5 мс у whichlang — два порядка разницы. Поэтому lingua плохо подходит не только для запросов в реальном времени, но и для пакетной обработки на общих серверах: медленным становится не он один. Из 30 языков spellman lingua знает 17, из них кириллических восемь — больше, чем whatlang, включая казахский и монгольский, — но малых языков нет: ни чувашского, ни удмуртского, ни якутского, ни чеченского. И главное — lingua тоже проигрывает на близких парах в «диких» текстах: модели обучены на чистых корпусах, и русский в твитах с @упоминаниями и латинскими вставками размечается заметно хуже, чем аккуратные предложения.

Биндинги к CLD2/CLD3

CLD2 и CLD3 — детекторы Google из Chromium — существуют в Rust как FFI-обёртки, но upstream не развивается (CLD3 заброшен самим Google), биндинги не обновлялись годами, а cld3 требует системный libprotobuf. Упомяну их только для полноты картины: кандидатами они не являются.

Итог: whatlang — популярный, но неточный на близких парах и знающий лишь шесть кириллических языков; whichlang — самый быстрый, но его признаки не различают кириллические языки между собой, поэтому из них он знает только русский; lingua — самое точное из существующих, но на два порядка медленнее whichlang и без малых языков. Близкородственные кириллические языки на коротком и «настоящем» тексте из соцсетей не закрывает никто — эту нишу я и займу.

Детекция языка — задача про данные

История детекции языка длиннее, чем история одной архитектуры. Сначала были словари — «в тексте встретилось слово привет, значит это русский» — и эвристики: таблицы письменностей, уникальные буквы, списки стоп-слов. Потом пришла статистика: Cavnar и Trenkle в 1994 году сравнивали частотные профили символьных n-грамм, за ними были наивный Байес, логистическая регрессия, fastText. СкорерыСкорер — часть детектора, которая по признакам текста считает оценку каждого языка; побеждает язык с наибольшей оценкой. сменяли друг друга, а признаки остались прежними: символьные n-граммы работают в детекторах языка уже тридцать лет.

Современный ответ выглядит почти издевательски просто: хешированные символьные n-граммы и линейный классификатор поверх них. В экосистеме Rust эту схему показал whichlang, и я взял её за основу. Обучается spellman чуть иначе — как fastText, через усреднённые эмбеддингиЭмбеддинг — выученный вектор чисел для каждого признака; здесь 128 чисел на n-грамму. и линейный слой, — но после обучения модель сворачивается в одну таблицу (как именно — в разделе «Как устроен детектор»), и на инференсе это тот же линейный скорер: сложить строки таблицы для n-грамм текста. Никакого глубокого обучения, никаких лингвистических знаний внутри модели — и микросекунды на CPU. Всё в духе bitter lesson Саттона: выученная статистика обыгрывает правила, которые человек вписал вручную.

Отсюда главный тезис: скорер — решённая задача, его можно взять готовым. Точность решают признаки и данные. Признаки мне пришлось переделывать: например, одна только замена ссылок, @упоминаний и чисел служебными токенами подняла точность на настоящих русских твитах с 72,5% до 92,4%. Но и хорошие признаки не спасут модель, обученную на плохих данных, а с данными есть ровно две беды, и обе отравляют детектор тихо, без очевидных следов на метриках:

  1. Недостаточное разнообразие. Корпус большой, но однородный: один стиль, одна тематика, аккуратные предложения из энциклопедий или, хуже, машинный перевод. Детектор блестяще сдаёт экзамен и падает на реальном трафике — твитах, комментариях, поисковых запросах.
  2. Мусор — то есть неверные метки. Чужой текст внутри корпуса языка, перепутанная разметка, близкие пары, перетекающие друг в друга. Это не «шум», который модель как-нибудь усреднит: это систематически неверные сигналы, из которых модель уверенно выучивает неправильные границы.

Поэтому скорер — лёгкие десять процентов работы, а остальные девяносто уходят на данные: источники и лицензии, чистку и защиту пограничных примеров, разнообразие стилей и длин, честные контрольные выборки.

Коммерческое применение

У датасетов есть свойство, о котором вспоминают слишком поздно: значительная их часть создаётся университетами и живёт на грантах, а грантовые условия по умолчанию приводят к лицензиям вида CC BY-NC — «некоммерческое использование». Для исследования это неважно, для продукта — фундамент, который отберут в суде. Детектор, обученный на NC-данных, нельзя продавать вместе с сервисом; «переучим потом» не работает, когда пересборка датасета означает пересборку всего, что вокруг него, — источников, проверок, метрик.

Строить на таком фундаменте нельзя, поэтому лицензия — не юридическая сноска, а полноценное проектное ограничение, наравне с качеством и покрытием: каждый источник проверяется на коммерческую совместимость до того, как попадёт в микс. В открытом мире это отдельная работа: у популярного корпуса NC-лицензия бывает не только явной, но и наследственной — он агрегирует чужие подкорпусы, и отравлен весь агрегат целиком. Таблицу того, из чего в итоге можно строить, я приведу в разделе о данных.

Качество данных

Под качеством данных я понимаю три вещи.

Разнообразие текста. Обучающая выборка должна покрывать те типы текста, с которыми детектору предстоит работать: соцсети и комментарии, новости, литература, пользовательские запросы. Корпус из одних новостных предложений порождает детектор, который не видел ни опечаток, ни сленга, ни обрывков фраз — и который на первом же реальном твите ведёт себя как отличник, впервые попавший на стройку.

Естественное загрязнение. Настоящий текст не стерилен. В русском твите встречаются «iPhone» и «Wildberries» латиницей, @упоминания, ссылки и эмодзи — и он всё равно остаётся русским. Это не мусор из списка выше: мусор — это неверная метка, когда текст помечен не тем языком, а здесь метка верная, просто текст живой. Вычищать такие примеры нельзя — наоборот, они обязаны быть в обучении. Детектор, который видел только чистые предложения, ошибётся на первом же твите со ссылкой в конце: n-грамм у ссылки больше, чем у двух настоящих слов рядом, и она перевесит.

Разные длины. На длинном тексте детекция тривиальна — различий хватает с избытком; вся сложность сосредоточена в коротких фрагментах: одно слово, реплика из чата, заголовок. Если обучающая выборка состоит из полноценных предложений, модель просто не готова к режиму, где ошибки дороже всего. Длины в миксе должны быть распределены так, чтобы короткие фрагменты были представлены честно, а не случайными обрезками длинных.

Leipzig — это асбест

Wortschatz / Leipzig — самый популярный источник языковых корпусов в открытом доступе: сотни языков, скачивается в один клик, десятилетиями был первым ответом на вопрос «где взять данные». Именно поэтому он опасен. Leipzig оказался внутри огромного числа открытых датасетов — как прямой ингредиент, а чаще по наследству: агрегатор скачал корпуса Leipzig, пересобрал и опубликовал под своей вывеской, следующий агрегатор включил его в себя — и NC-лицензии с ошибками оригинала тиражируются по цепочке, невидимые снаружи. Сознательно это сделано или по небрежности — уже не выяснить, но результат один: проверка «нет ли внутри Leipzig» стала отдельным этапом чистки почти каждого крупного открытого корпуса, которым я пользовался.

Сам по себе Leipzig тоже не образец чистоты: в community-краулах смешаны языки и остатки HTML-разметки, и немалая часть корпусов состоит из них. Для близких пар это опасно вдвойне: таджикский корпус с примесью русского текста — это не «чуть грязнее», это систематически смещённая граница между классами.

Отсюда и сравнение: Leipzig — это асбест. Дешёвый, удобный, десятилетиями шёл в дело повсюду; вред проявляется не сразу, а когда проявился, он уже вмурован в чужие стены — и вынимать его приходится из каждой постройки отдельно. Я вычистил Leipzig-следы из всех датасетов, пошедших в микс, — в таблице источников ниже его нет.

Подготовка данных

Ниже — как это выглядело на практике. Датасет прошёл шестнадцать версий: каждая следующая подгоняла его под лучшие метрики и под настоящий текст, и почти каждая начиналась не с идеи об архитектуре, а с проблемы, найденной в данных.

Рецепт — это код

Рецептом я называю описание обучающего набора: какие источники взять, сколько строк из каждого, какие фильтры и какие ограничения применить. По рецепту программа собирает микс — сам набор строк с метками языков. Микс собирается из сотни источников: FineWeb-2, датасеты с Hugging Face, Tatoeba, параллельные корпуса, твиты, форумы. Каждый источник — отдельный адаптер с настройками, а результат его работы кэшируется. Повторы выбрасываются (выигрывает источник, указанный раньше), а строка попадает в обучающую, валидационную или тестовую выборку по хешу своего текста — значит, при любой пересборке одна и та же строка окажется в той же выборке. Синтетический шум — @упоминания, эмодзи, хаотичные заглавные буквы, обрывки фраз — добавляется только в обучающие данные: тест остаётся настоящим. Вся сборка записывается в манифест и воспроизводится одной командой. Без этого ни одно утверждение о данных не проверить: «стало лучше» нужно уметь пересобрать.

Сито в несколько слоёв

Один фильтр не справляется: каждый следующий ловит то, что пропустил предыдущий.

Модель-судья. Строка отбрасывается, если текущая модель с уверенностью от 99,5% относит её к другому языку. Так нашлись 503 английские страницы внутри кириллических разделов FineWeb-2 — из-за них модель принимала английский текст за болгарский. Таджикский корпус HPLT оказался на треть узбекским.

Латиница — к fastText. Бывает, что строка помечена кириллическим языком, а написана латиницей, — например, английская страница, попавшая в болгарский раздел веб-корпуса. Свою модель о такой строке спрашивать бесполезно: она училась на этих же данных и привыкла считать такой английский болгарским. Поэтому строки, написанные в основном латиницей, проверяет посторонний детектор — fastText lid.176, который хорошо знает крупные языки, пишущиеся латиницей.

Близнецов модель не судит. Близнецами я называю группы близких языков: русский, украинский и белорусский; болгарский, македонский и сербский; тюркские; пермские. Внутри такой группы уверенному предсказанию верить нельзя. Пример: в башкирском корпусе модель уверенно называет татарскими две строки. В одной есть буква җ, которую пишут только татары, — это действительно чужой текст. В другой есть ҡ, которой в татарском нет, — это настоящий башкирский, и модель ошиблась. Судья выбросил бы обе, а вместе со второй — именно те пограничные примеры, на которых модель учится различать близнецов. Поэтому между близнецами решают не модели, а буквы и слова.

Буквы. Близнецов различает правописание. В надёжных таджикских текстах буква «ы» встречается в 0,08% случаев, а в таджикском разделе MADLAD — в 3,7%: это русский текст под таджикской меткой. В киргизском HPLT нашлись якутские ҕ и һ. Так проверяются целые корпусы, но у такой проверки есть предел: узбекский и чеченский по алфавиту от русского не отделить. Поэтому буквы проверяются ещё и в каждой строке при сборке микса: по буквам, которые пишет только один из близнецов, фильтр оценивает, какая доля строки написана на каждом языке, и выбрасывает строку, только если она в основном чужая. Поэтому русская статья с цитатой на украинском остаётся, а украинский твит с меткой «русский» уходит. Ссылки и @упоминания не считаются — детектор их тоже не читает.

Слова. Короткая реплика часто не содержит ни одной различающей буквы: «Хочу жити», «дякую, отримала» — украинский без «і», «ї», «є» и «ґ». Тут решают слова: словарь из 314 тысяч слов хранит для каждого, насколько оно вероятнее в одном языке, чем в другом, и посчитан он только на корпусах с надёжной разметкой. Строка уходит, если сумма по её словам говорит о чужом языке с перевесом примерно 55 к 1.

Короткие строки. Для реплик до 19 символов модели-судьи слишком ненадёжны, поэтому такая строка остаётся, только если сходятся три независимых детектора: spellman, GlotLID и lid.176.

Один канал, три захода

Самый поучительный случай — русские твиты. Корпус украинских твитов содержит и сообщения, которые Twitter сам пометил как русские, — 41 тысяча коротких строк. Выглядело как готовый источник живого короткого русского. Буквенный фильтр выбросил из него 7 тысяч строк, словарный — ещё 11 тысяч, и в оставшемся тоже большинство строк нельзя уверенно назвать русскими: детектор Twitter массово путал короткий украинский с русским.

Но чистка ударила по другому месту. Канал давал заметную часть коротких русских реплик, и после фильтров их стало меньше на треть — точность на коротком русском упала. Фильтр убрал не только мусор, но и целый тип текста — короткие разговорные реплики. Помогла замена, а не удаление: канал ушёл из рецепта целиком, от него остались 3 тысячи строк, которые словарь уверенно считает русскими, а на освободившееся место пришли по 12 тысяч коротких реплик из открытых диалогов, чата и форума.

до фильтров близнецов после фильтров и замены
общий тест (715 тыс. строк) 98,74% 98,79%
строки до 20 символов 94,79% 95,37%
украинский до 20 символов 92,40% 97,63%
короткий русский из комментариев ok.ru (ни в одном миксе) 88,10% 93,20%
другие языки, ошибочно названные русскими (до 20 символов) 0,85% 0,25%

Обе модели здесь проверены на одном и том же файле — тестовой выборке без строк, разметку которых нельзя проверить. В итоговых метриках ниже — полная тестовая выборка, поэтому цифры там чуть другие.

Отсюда правило: фильтр — это хирургия, после которой нужно закрыть рану. Если чистка вырезает целый тип текста, его нужно вернуть из чистого источника.

Состав важнее объёма

Больше данных — не значит лучше; важно, какие это данные.

Покрытие словаря. Для русского и других крупных языков часть строк отбирается по новым леммам: строка принимается, если приносит слова, которых в выборке ещё мало. Попытка максимизировать покрытие провалилась: алгоритм собрал самые длинные и богатые словами предложения — ни одной строки короче 40 символов, — и все контрольные выборки просели. Покрытие работает как фильтр приёма, а не как цель оптимизации.

Чистый текст вредит малым языкам. 12 тысяч аккуратных энциклопедических строк для маленького класса — бомба: точность на коротком «диком» тексте падала на величину до 2,4 п. п.Процентный пункт — разница между процентами: было 95%, стало 93% — минус 2 п. п. Добавка веб-текста FineWeb-2 впятеро сверх базового объёма тоже ухудшила результат. Важен тип текста, а не доля языка в наборе.

Литература. «Война и мир» на 6,1% определялась как тувинский: слово «князь» встречалось в пяти русских обучающих строках из 16 тысяч, и малые языки побеждали на обрывках n-грамм. Отдельный литературный канал из Викитеки снизил долю до 0,2%.

Предел числа строк на язык. Подъём с 32 до 120 тысяч строк улучшил все контрольные выборки разом. А вот отдельная прибавка русского помогает самому русскому примерно до 230 тысяч строк, но общая точность от неё не растёт, а другие языки всё чаще ошибочно уходят в русский.

Контрольные выборки — тоже данные

Контрольные выборки собираются из тех же открытых источников, а значит, могут утечь в обучение. Так и вышло: 2 172 из 2 808 строк русского COSMUS сидели в обучающей выборке, потому что сам COSMUS был одним из источников микса. Словарный фильтр вдобавок нашёл в его «русских» строках 95 украинских. Из 49 русских строк контрольной выборки коротких реплик 42 пришли из того самого канала Twitter — в основном шутки транслитом вроде «ви нид ту гоу зэр».

Теперь каждая строка контрольных выборок исключается из всех частей микса при сборке, а COSMUS очищен от украинских строк. Есть и тонкая утечка: почти-дубликаты между обучением и тестом составляют 2,83%, поэтому разница в десятую долю процента на собственном тесте ничего не значит. Решения принимаются по замороженным внешним выборкам.

Что получилось

Всё открыто:

Данные

Микс собран только из данных, которые можно использовать коммерчески: каждый источник с некоммерческой (NC) лицензией — своей или унаследованной — заменён или отфильтрован построчно. Полный список источников:

Источник Языки Лицензия
FineWeb-2 (*_Cyrl) + FineWeb sample-10BT 21 кириллический + tat (Latn) + eng, spa, fra, por, deu ODC-By-1.0
HPLT 2.0 (cleaned) kir, tgk CC0
Tatoeba (train-остаток) 26 языков CC-BY 2.0 FR (атрибуция в каждой строке)
Glot500 (срезы tat/tgk/sah/udm/tyv; uzn = Earthlings) tat, tgk, sah, udm, tyv, uzn у каждого подкорпуса своя; NC и другие ограниченные срезы вырезаны построчно
Wikipedia dumps (tt, ce, ky, tg) tat, che, kir, tgk CC-BY-SA 3.0 + GFDL
MADLAD-400 (clean/noisy) tyv, sah, che, kir, tgk ODC-By-1.0
alifbank/Tajik tgk MIT
tahrirchi/uz-crawl (Telegram) + uz-books-v2 uzn Apache-2.0 / MIT
AigizK/bashkir-russian-parallel bak CC-BY 4.0
AigizK/tatar-russian-parallel tat не указана*
Agisight/tyv-rus-200k tyv CC-BY 4.0
ai-forever/udmurt-corpora + udmurtNLP/zerpal udm MIT / не указана*
d0rj/ru-mhr-parallel mhr CC-BY 4.0
NM-development ce-ru 171k + OPUS translatewiki (ce-en) che MIT / CC-BY-SA 3.0+
alexantonov/chuvash_mono + parallel chv CC0
ailabykt/sakha-corpus-mono + averoo/sakha-oscar sah CC-BY 4.0 / не указана*
vpermilp/lid-* (мои краулы: новости, библиотеки, Telegram, VK) sah, tyv, kpv, mhr, oss, udm MIT (компиляция; тексты из указанных публичных источников)
OPUS GoURMET (ky-ru) kir CC0
YShynkarov/COSMUS (Telegram/отзывы) ukr MIT
maaxap/BelarusianGLUE bel Apache-2.0
DGurgurov macedonian_sa + bulgarian_sa mkd, bul MIT
rusentitweet (train) rus не указана* (research-корпус)
saganoren/ukr-twi-corpus ukr, rus (только строки, которые словарь уверенно считает русскими) не указана*
Den4ikAI/russian_dialogues + nyuuzyou/ruforum + hausmer/dvach_chat (короткие реплики) rus MIT / CC0-1.0 / другая (метка загрузившего)
ganaxy/diploma (mn social) mon не указана*
IS2AI/KazSAnDRA kaz не указана*
UMSAB (cardiffnlp multilingual tweets) eng, fra, deu, por, spa не указана*
sentiment140 eng не указана*
FrancophonIA/french_tweets fra не указана*
fpaulino/portuguese-tweets + mteb/told-br por не указана* / CC-BY-SA 4.0
NLP-UniBW (немецкие полит-твиты) deu не указана*
pysentimiento/spanish-tweets spa не указана*
Викитека (русская литература) rus CC-BY-SA 4.0
FineWeb-2 _removed (отбракованная часть — трудные примеры) 12 языков наследует ODC-By-1.0

* «не указана» — у датасета нет машиночитаемой лицензии на карточке. NC-лицензий в миксе нет: вместо mteb-македонских твитов (CC-BY-NC-SA) и корпусов Leipzig — DGurgurov/MADLAD/HPLT.

Метрики

Каждая контрольная выборка — отдельный режим работы, и я привожу их рядом, потому что средняя точность по всем сразу маскирует главное: где именно модель выигрывает и проигрывает. fastText lid.176 оценён на подмножестве языков, которые есть в его списке классов (24 из 30); GlotLID — на полном покрытии, с его классами вида «язык + письменность», приведёнными к классам spellman.

выборка режим spellman GlotLID v3 fastText lid.176
отложенный микс (718 647, тест после фильтров) текст 98,74% 92,92% 81,97%
Tatoeba (37 051 отложенное предложение) слово / пара / тройка 72,04 / 89,78 / 95,37 43,9 / 79,3 / 91,9 59,0 / 79,0 / 87,9
Tatoeba (37 051 отложенное предложение) текст 99,08% 99,25% 94,90%
русские твиты (2 606, «дикий» текст, проверенная разметка) текст 96,55% 82,73% 90,41%
COSMUS (2 713, дикий русский: Telegram/отзывы, ручная разметка) текст 99,15% 98,82% 99,37%
короткие реплики (574, ≤19 символов, сертифицированы орфографией) текст 95,82% 71,25% 84,32%
литературный русский (2 000 предложений из отложенного романа) текст 98,40% — —

Как читать таблицу. Tatoeba — открытая база коротких аккуратных предложений с переводами на сотни языков. 37 051 её предложение на языках spellman я отложил как контрольную выборку: в обучение они не попадают, хотя остальные предложения Tatoeba в обучающем наборе есть. Режим «слово / пара / тройка» — те же предложения, нарезанные на фрагменты из одного, двух и трёх слов: так видно, сколько текста нужно детектору. Одно слово — самый трудный режим, и 72% там — лучший результат из трёх. Здесь GlotLID выше всех за счёт объёма своих данных, и его 99.25% — честный потолок этого режима. Но «дикие» ряды — русские твиты с @упоминаниями, ссылками и латинскими вставками, Telegram и отзывы с ручной разметкой — это тексты, которых в чистых корпусах нет вовсе, и там расклад обратный: на твитах spellman опережает GlotLID почти на 14 пунктов, lid.176 — на шесть. На COSMUS все трое упираются в потолок около 99%, и lid.176 здесь чуть впереди. Короткие реплики — отдельная боль: их невозможно сертифицировать моделями (мягкий консенсус судей сам путает короткий украинский с русским), поэтому достоверность каждой строки гарантирует орфография — буквы і ї є ґ не встречаются в русском, ы ъ ё э — в украинском.

Точность по длине текста — та же картина по-другому: вся сложность — в коротких фрагментах, и на отложенном миксе spellman ведёт в каждом диапазоне длины, включая самый трудный (≤20 символов; у контрольной выборки коротких реплик граница — 19):

У этой картинки есть оговорка в пользу соперников: whichlang и lingua здесь оцениваются только на строках языков из их списков, а spellman — на всех 30. Если считать по всем строкам рабочей нагрузки, у whichlang выходит 27%, у lingua — 33%: язык, которого детектор не знает, всегда ошибка.

Как устроен детектор

хост, Rustплан svod (JIT)хост, RustСловассылка, @ник, число→ служебный токенn-граммысимволы 1–5 + слово+ пара словХешfmix32 → 2¹⁸ строкзнак → половина ±qСбор строкint8-строки [q; −q]32 столбцаСумматочно, в i32без округленийОтвет× масштаб, ÷ n, + biassoftmax → языккана · ханьцзы · деванагари · арабица → язык сразу, без модели
Путь текста через spellman: один проход по словам, векторное хеширование, сбор и сумма int8-строк в плане svod, финальная арифметика на хосте.

Детектор проходит по тексту один раз. Сначала каждое слово получает класс: ссылка, email, @упоминание, число или обычное слово. Всё, кроме обычных слов, превращается в один служебный токен: ссылка на сто символов даёт горстку одинаковых признаков вместо сотни с лишним своих n-грамм и больше не перекрикивает два настоящих слова рядом. Именно этот шаг поднял точность на русских твитах с 72,5% до 92,4%.

В том же проходе детектор считает, какими письменностями написаны буквы. Японский, китайский, хинди и арабский — единственные языки из 30, у которых своя письменность: кану, иероглифы, деванагари и арабицу больше никто из списка не использует. Поэтому письменность здесь сама даёт ответ: кана означает японский, преобладание иероглифов — китайский. Такие тексты получают язык сразу и до модели не доходят. Строить для них n-граммы было бы не только лишним, но и вредным: в китайском и японском тысячи иероглифов и нет пробелов между словами, и их n-граммы заняли бы в таблице место, нужное близким кириллическим языкам, ради ответа, который и так известен.

Обычное слово раскладывается на символьные n-граммы длиной от одного до пяти символов, с маркерами начала и конца слова. Строки для них не собираются: символы по одному вдвигаются в 64-битное число. Каждый новый символ сдвигает число на 21 бит (столько нужно любому символу Unicode) и встаёт в его конец, так что в числе всегда лежат три последних символа. Униграмма, биграмма и триграмма — это младшие 21, 42 и 63 бита, их достаточно вырезать маской; для 4- и 5-грамм берётся число целиком. Получившееся число и есть ключ n-граммы. Никаких строк и выделений памяти — только сдвиг и маска. К n-граммам добавляются слово целиком и пара соседних слов: у близких языков общий алфавит, но разная лексика, и словам есть что сказать там, где n-граммы совпадают.

каждый символ сдвигает число влево на 21 битм21 бити21 битр21 битот началаслова ‹остался 1 бит›следующий:конец словар — униграммаир — биграммамир — триграмма‹мир — 4- и 5-граммы, ‹ обрезан
Ключи n-грамм — маски одного 64-битного числа: строки не собираются, память не выделяется.

Ключи хешируются в 2¹⁸ (262 144) строк таблицы, а ещё один бит хеша даёт знак, с которым ключ войдёт в сумму: так коллизии в среднем гасят друг друга, а не копятся. Хеширование идёт отдельным проходом по массиву ключей — простым циклом, который компилятор сам превращает в векторные инструкции (NEON на ARM, AVX2 на x86 при сборке под современный процессор).

Свёртка. Обучается модель как fastText. Каждой n-грамме текста соответствует строка таблицы эмбеддингов — 128 чисел. Строки всех n-грамм текста усредняются, а линейный слой — умножение на матрицу W размером 128 × 30 — превращает среднее в 30 оценок, по одной на язык. Но усреднить и потом умножить на W — то же самое, что сначала умножить на W каждую строку, а потом усреднить. Значит, после обучения можно один раз умножить на W все строки таблицы и хранить готовый результат: для каждой n-граммы сразу 30 чисел — её вклад в оценку каждого языка.

обучениеинференсn-граммы текстапо строке на каждуюстрока эмбеддингов128 чиселсреднеепо всему тексту× W128 → 3030 оценокпо одной на языкn-граммы текстапо строке на каждуюготовая строка30 чиселсреднеепо всему тексту30 оценокпо одной на языкумножать на W не нужно× W, один разпосле обучения
Свёртка: умножение на W переезжает из каждого запроса в однократный шаг после обучения.

Инференс превращается в сложение готовых строк: никаких эмбеддингов, никакого умножения матриц, 30 чисел на n-грамму вместо 128.

int8. Каждое число таблицы хранится как целое от −127 до 127 (int8), а настоящее значение получается умножением на масштаб — один на язык. Раз масштаб общий для всего столбца, его можно вынести за скобки суммы: строки складываются как целые числа, точно, в 32-битном целом, а на масштаб сумма умножается один раз на документ. Знак хеша тоже ничего не стоит: в памяти таблица лежит дважды — со знаком плюс и со знаком минус, — и знак просто выбирает копию. В итоге файл модели весит 7,9 МБ (удвоенная таблица собирается уже в памяти), вдвое меньше f16, с той же точностью (±0,02 п. п.) и примерно в 1,4 раза быстрее.

План svod. Сбор строк и сумму выполняет не рукописный цикл, а скомпилированный план svod — моего тензорного компилятора. План собирается под фиксированные размеры: K токеновТокен — ключ одного признака: n-граммы, слова или пары слов. В среднем документе их около 700. на документ и пакет из 4 096 документов, — и компилятор специализирует под них каждый цикл. Чтобы короткие тексты не платили за K = 1 024, планов три — K = 64, 256 и 1 024, — и пакет идёт на самый маленький, в который помещается его самый длинный документ; документ длиннее K режется на куски, суммы которых складываются, — без обрезки. Пара мелочей дала заметный выигрыш. Явное ограничение индексов размером таблицы позволило LLVMКомпиляторная инфраструктура, на которой построены rustc и svod: она превращает промежуточный код в машинные инструкции. убрать проверку границ и слить расширение int8 со сложением в одну инструкцию (saddw на NEON): 79 → 50 инструкций на токен, сбор и сумма стали быстрее на 20–27%. Дополнение строки с 30 до 32 байт — ещё 10–16%: такая строка никогда не пересекает границу кэш-линииКэш-линия — порция в 64 байта, которой процессор читает память. Строка через границу двух линий стоит двух чтений вместо одного..

Зачем здесь svod, а не обычный Rust? K выбирается при загрузке модели, а не при сборке программы. Для компилятора Rust это просто переменная: цикл длины K он не может подогнать под конкретное значение, а векторные инструкции выбирает под самый старый процессор, для которого собран бинарник. Чтобы получить ту же скорость на чистом Rust, пришлось бы вручную писать отдельную реализацию вычислительной функции под каждое значение K и под каждый набор инструкций — NEON, AVX2, AVX-512 — и самому выбирать нужную реализацию во время работы. Или смириться с более медленным кодом. svod делает это за меня: план компилируется при загрузке под выбранные K и под то устройство, на котором запущен, — процессор, Metal, CUDA или AMD, — а перебор вариантов плана выполнения (BEAMBEAM — поиск по вариантам: svod пробует разные раскладки циклов, замеряет каждую на этом железе и оставляет самую быструю.) подбирает раскладку циклов под конкретное железо. Поменять K или платформу — значит просто загрузить модель заново, а не переписывать код.

Все ядра. Многопоточная версия плана svod платит при каждом запуске фиксированную цену — около 85–90 мкс на 14 потоках. Выгоднее другая схема: у каждого потока своя однопоточная копия плана. Копии делят веса, а однопоточный запуск этой цены не платит.

Где в итоге уходит время: в один поток около 60% — подготовка признаков (классы слов, n-граммы, письменности, хеширование), около 40% — план svod. В среднем документе около 700 токенов, так что сбор строки таблицы и её сложение обходятся примерно в две наносекунды на токен. Самой модели на документ почти ничего не нужно — дороже всего прочитать текст.

Производительность

Целевой ориентир задавал whichlang — единицы микросекунд на документ. Все детекторы мерились на одном и том же файле (отложенный микс одной из ранних версий датасета, 368 507 документов), на Apple M4 Max и в один поток — так, как работают whichlang и lingua:

детектор классов задержка документов в секунду на ядро
whichlang 16 1,2 мкс ~850 000
spellman (пакетный режим) 30 3,3 мкс ~300 000
spellman (по одному) 30 6,7 мкс ~150 000
lingua (high-accuracy) 17 ~200 мкс ~5 000

whichlang остаётся самым быстрым — примерно в 2,8 раза быстрее моего пакетного режима. Во многом это обратная сторона того, о чём шла речь выше: кириллический символ даёт whichlang два грубых признака, а spellman — до пяти n-грамм плюс признаки слов, около 700 признаков на документ. Заодно это ответ на вопрос «а зачем было писать свой»: lingua медленнее примерно в 60 раз и при этом заметно хуже на коротком тексте (80,8% против 95,1% на строках до 20 символов), а whichlang не умеет 20 из 30 языков.

На всех ядрах (14 потоков, у каждого своя копия плана над общими весами) пакетный режим проходит тот же файл за ~0,47 мкс на документ — около 2,1 миллиона документов в секунду; whichlang в той же схеме — ~0,14 мкс. Всё, что описано в предыдущем разделе, вместе ускорило детектор вдвое: пакетный режим в один поток — с 6,6 до 3,3 мкс на документ, на всех ядрах — с 0,92 до 0,47 мкс.