Український TTS-нормалізатор (Gemma 3 270M)

Перетворює письмовий український текст на те, як його вимовляють: числа, дати, час, гроші, одиниці, скорочення, коди, телефони, IBAN, домени, пошту, римські цифри, латинські вкраплення. Це передостанній крок у конвеєрі синтезу мовлення — те, що подають у TTS замість сирого тексту.

Рахунок на 1111 386,40 грн треба сплатити до 25.07.2026.
→ Рахунок на один мільйон сто одинадцять тисяч триста вісімдесят шість гривень
  сорок копійок треба сплатити до двадцять п'ятого липня дві тисячі двадцять
  шостого року.

Пиши на support@shop.com.ua або дзвони 067 123 45 67.
→ Пиши на супорт ет шоп крапка ком крапка юа або дзвони нуль шістдесят сім
  сто двадцять три сорок п'ять шістдесят сім.

Борг сягнув 2625414042726 гривень.
→ Борг сягнув двох трильйонів шестисот двадцяти п'яти мільярдів чотирьохсот
  чотирнадцяти мільйонів сорока двох тисяч семисот двадцяти шести гривень.

Модель відмінює числівники за контекстом («при двох тисячах восьмистах тридцяти відвідувачах»), узгоджує їх з іменником і розрізняє конвенції читання за рамкою: вітамін D → «вітамін де», але роз'єм типу C → «роз'єм типу сі».

Що лежить у репозиторії

формат шлях розмір для чого
safetensors (bf16) корінь 250 МБ transformers, дотренування
GGUF (F16) gguf/model-f16.gguf 250 МБ llama.cpp — найшвидший шлях на GPU
ONNX (з KV-кешем) onnx/ ~600 МБ onnxruntime, CPU-інференс, edge
пруноний SPM tokenizer.model 725 КБ без нього GGUF-конвертація розсипається
код normalize.py, digit_router.py, bignum_spacing.py повний конвеєр
скрипти scripts/ прунінг і обидва експорти, відтворювано

Використання

transformers

from normalize import Normalizer          # normalize.py лежить у цьому репозиторії
n = Normalizer("skypro1111/gemma-3-270m-uk-verbalizer")
print(n("Зустріч о 08:05 у кабінеті 12."))
# Зустріч о восьмій нуль п'ять у кабінеті дванадцять.

Без обгортки — але тоді самі поставте кроки препроцесингу (див. нижче):

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

tok = AutoTokenizer.from_pretrained("skypro1111/gemma-3-270m-uk-verbalizer")
model = AutoModelForCausalLM.from_pretrained("skypro1111/gemma-3-270m-uk-verbalizer", dtype=torch.bfloat16).cuda().eval()

bad = [[i] for t, i in tok.get_vocab().items() if any(c.isdigit() for c in t)]
ids = tok.apply_chat_template([{"role": "user", "content": "Ціна 1250 грн."}],
                              add_generation_prompt=True, return_tensors="pt").cuda()
out = model.generate(ids, max_new_tokens=192, do_sample=False, bad_words_ids=bad)
print(tok.decode(out[0, ids.shape[-1]:], skip_special_tokens=True))

llama.cpp

У репозиторії лежить gguf/model-f16.gguf і tokenizer.model — пруноний SentencePiece, потрібний, якщо конвертуватимете самі. Без нього convert_hf_to_gguf.py іде BPE-гілкою і токенізація розсипається.

llama-server -m gguf/model-f16.gguf --host 127.0.0.1 --port 8901 \
             -c 8192 --parallel 4 -ngl 99 -fa on

Рекомендований декод — greedy плюс заборона цифрових токенів:

{"messages": [{"role": "user", "content": "<текст>"}],
 "temperature": 0, "top_k": 1, "n_predict": 192,
 "logit_bias": [[<кожен токен із цифрою>, -100]]}

Це найшвидший шлях із трьох, і з великим відривом — див. таблицю нижче.

onnxruntime

Граф експортовано з KV-кешем, тому декод лінійний, а не квадратичний.

from optimum.onnxruntime import ORTModelForCausalLM
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("skypro1111/gemma-3-270m-uk-verbalizer")
model = ORTModelForCausalLM.from_pretrained("skypro1111/gemma-3-270m-uk-verbalizer", subfolder="onnx")

ids = tok.apply_chat_template([{"role": "user", "content": "Ціна 1250 грн."}],
                              add_generation_prompt=True, return_tensors="pt")
out = model.generate(ids, max_new_tokens=192, do_sample=False)
print(tok.decode(out[0, ids.shape[-1]:], skip_special_tokens=True))

На CUDA обов'язково вимикайте io-binding — інакше граф падає з cudaErrorIllegalAddress на embed_tokens/Mul:

model = ORTModelForCausalLM.from_pretrained(
    "skypro1111/gemma-3-270m-uk-verbalizer", subfolder="onnx",
    provider="CUDAExecutionProvider", use_io_binding=False)

І майте на увазі: без явного provider optimum мовчки бере CPU, навіть коли CUDA доступна.

Скільки це коштує за часом

Заміряно на RTX 3090 Ti: один рядок за раз (саме так модель і працює — речення за реченням), greedy, до 192 нових токенів, медіана з п'яти прогонів.

шлях токенів/с
transformers, bf16, GPU 27
onnxruntime, fp32, CPU 31
onnxruntime, fp32, GPU 69
llama.cpp, GGUF F16, GPU 532

llama.cpp швидший за transformers у 20 разів — і це не про якість реалізації матричних множень. На моделі такого розміру крок декоду впирається не в обчислення, а в накладні витрати на запуск CUDA-ядер: сама робота мізерна, а ядер на крок запускається стільки ж, скільки у великої моделі. llama.cpp тримає CUDA-графи й тому цей оверхед майже прибирає.

Звідси практичний висновок: якщо потрібна пропускна здатність — беріть GGUF. ONNX має сенс там, де llama.cpp незручний, і особливо на CPU: 31 проти 69 ток/с на GPU означає, що на процесорі модель втрачає лише вдвічі, а не в десятки разів.

Препроцесинг обов'язковий

Два детерміновані кроки перед моделлю. Це не косметика — без них модель провалює конкретні класи входу, і кожен фікс заміряний.

digit_router перехоплює голий рядок цифр і читає його поцифрово, повз модель. Причина: рахувати однакові цифри поспіль модель не вміє — 000000 перетворюється на чотири «нуль».

bignum_spacing вставляє роздільники розрядів у довгі числа. Парний зонд на тому самому наборі чисел: суцільним рядком 4/14, з пробілами 11/14. Провалюються саме числа з довгими серіями нулів (7000000000 → «сімсот мільярдів»). На пакеті великих чисел цей крок дав 13/20 → 20/20.

Обидва модулі (digit_router.py, bignum_spacing.py) лежать у корені репозиторію поруч із normalize.py і не мають залежностей, крім стандартної бібліотеки. У кожного всередині є самотести: python bignum_spacing.py.

Якість

Тестові набори вирізані з тренувальних даних і виключені з тренування; еталони в них канонічні за побудовою. Числа нижче — чиста модель, greedy, без препроцесингу.

зріз результат
139 доменів, збалансовано (2564 рядки) 91.7%
якір, змішані складні речення (557) 98.2% толерантно · 91.2% суворо
транслітерація (380: бачені й небачені слова) 75.5%
узгодження числівник+іменник (63) 100%

Розкладка по 139 доменах є в датасеті. Найсильніше: час, дати, узгодження, складені числівники у відмінках, гроші — 95-100%. Найслабше — див. обмеження.

Для орієнтира, як це росло за шість ітерацій даних на тих самих тестсетах: домени 87.6% → 89.8% → 91.0% → 91.7%; транслітерація 31.8% → 55.0% → 61.8% → 75.5%. Майже весь приріст дали зміни в даних, а не в архітектурі чи гіперпараметрах.

Останню ітерацію робили не заради агрегату, а за живими баг-репортами, і там ефект зовсім іншого масштабу. Зріз із 22 повідомлених поломок (одиниці вимірювання, дефіс у ролі тире, латинізовані українські імена): 22.7% → 95.5%. Класи, яких у даних не було зовсім, модель не просто плутала з сусідніми — вона вигадувала неіснуючі слова: 85 дБ читалось як «дбайів», 200 кПа як «кіпатих». Коли покриття з'явилось, це зникло.

Обмеження

Транслітерація — відкритий клас. Модель вивчила список із ~12.7 тис. слів, а не правило. На небачених англійських словах точність близько 50%, і щодня з'являються нові назви продуктів. Це стеля підходу, а не дефект ваг.

Одне речення за раз. Навчена на окремих реченнях; n_predict=192 обрізає довгий абзац. Ріжте текст на речення перед подачею.

Найслабші домени: римські цифри в неканонічному записі, списки з кількох англійських слів поспіль (композиція: сім слів по 85% дають 0.85⁷ ≈ 32%, окремої вади немає), голий фрагмент-email, наука й техніка.

Діапазон без пробілів. 5-7 млн модель зараз читає як «п'ять — сім мільйонів», хоча усталене — «від п'яти до семи». Це наслідок того, що в останню ітерацію додали читання дефіса як тире й не звірили його з наявною конвенцією діапазонів. Виправляється наступним колом даних.

Стеля метрики нижча за 100%. Шість незалежних аудиторів розсудили 425 спірних випадків: 360 на користь моделі проти еталона, 26 на користь еталона, 39 «обидва прийнятні». Тобто близько 15% «помилок» — дефекти самих еталонів, неминучі там, де норма допускає два читання.

Як це зроблено

Прунінг словника

Базова gemma-3-270m-it має словник на 262 144 токени. Для української це абсурдно багато: матриця ембедингів 262144 × 640 — це 168M параметрів із 270M, тобто 62% моделі витрачено на словник, більшість якого ніколи не трапиться в українському тексті.

Словник урізано до 38 651 токена. Прунінг тут — чиста хірургія, без жодного дотренування:

  1. Зібрати keep-set — токени, які реально трапляються в цільових текстах, плюс усі службові.
  2. index_select потрібних рядків із embed_tokens і lm_head, resize_token_embeddings(len(keep)), копіювання рядків на місце.
  3. Ремап bos/eos/pad у конфігу за таблицею old → new.

Ключова властивість: збережені рядки не змінюються, тож логіти для збережених токенів ідентичні (max |diff| = 0). Якість не падає ні на йоту, і дотренування не потрібне. Токени, що випали, покриває byte_fallback — заміряно: інфляція довжини послідовності +0.10%, поломок 0.

Результат: 270M → ~125M параметрів, −53% ваг за нуль GPU-годин.

Дві пастки, на які варто зважати, якщо повторюватимете:

Merges треба замикати за фікспоінтом. Викинувши токен, ви можете зламати BPE-злиття, яке відтворювало написання захищеного слова. Тому keep-set ітеративно розширюється (до 10 проходів): токенізуємо список захищених рядків старим і новим токенізатором і повертаємо в keep усе, чого бракує для точка-в-точку однакової токенізації. Захищали римські цифри (IIXL) і абревіатури, що читаються як слово (НАТО, ООН, США, NASA, IKEA).

generation_config.json ремап НЕ зачіпає. Хірургія править config.json, а generation_config.json лишається від бази — з eos_token_id: [1, 106]. Після прунінгу <end_of_turn> має id 7, а 106 — це вже <sup>. Наслідок: generate() не має на чому зупинитись, видає правильну відповідь і продовжує молоти до ліміту токенів. У цьому репозиторії виправлено (eos_token_id: [1, 7]), але якщо прунитимете самі — перелічуйте EOS заново через tok.convert_tokens_to_ids("<end_of_turn>"), а не копіюйте з бази.

Скрипт: scripts/apply_surgery.py разом із scripts/keepset.json. Він застосовується до будь-якого чекпоінта з оригінальним словником Gemma 3 — хоч до бази, хоч до вже навченої моделі:

python scripts/apply_surgery.py --in google/gemma-3-270m-it --out ./pruned

Експорт у GGUF

Робиться штатним convert_hf_to_gguf.py з llama.cpp, але з однією умовою, без якої результат мовчки зіпсований.

llama.cpp читає цей словник правильно лише в SPM-режимі, а той вмикається тільки за наявності tokenizer.model (Gemma3Model.set_vocab()). Без нього конвертер іде BPE-гілкою з GPT-2 byte-level декодером — і токенізація розсипається: 1 збіг із 52, декод дає сміття («ĸ14» замість «Зустріч»). Помилки при цьому немає, файл конвертується «успішно».

Готового SPM на 38 651 після прунінгу не лишається, тож його треба зібрати: взяти оригінальний Gemma-3 proto з усіма 262k pieces та їхніми score і лишити тільки наші, строго в порядку наших ID. Score копіюються як є — у SPM саме вони задають порядок злиття, а прунінг відносного порядку не змінював.

python scripts/build_pruned_spm.py <оригінальний_tokenizer.model> tokenizer.model
cp tokenizer.model <тека_моделі>/
python convert_hf_to_gguf.py <тека_моделі> --outfile model-f16.gguf --outtype f16

Перевіряйте токенізацію після конвертації — HF і SPM мають давати ідентичні ID один-в-один. Без збігу 52/52 результат вважати недійсним. Готовий tokenizer.model лежить у корені цього репозиторію.

Навіщо взагалі GGUF: це найшвидший шлях із трьох, 532 токени/с проти 27 у transformers — при побайтово тій самій якості (перевірено, відповіді збігаються слово в слово).


Експорт в ONNX

Через optimum, задача text-generation-with-past (тобто з KV-кешем — без нього декод квадратичний):

python scripts/export_onnx.py --model <тека_моделі> --out ./onnx

Готовий граф лежить у onnx/. Він fp32, тому вдвічі більший за safetensors — це очікувано, а не помилка експорту.

Дві речі, на які пішов час і які варто знати наперед:

  • без явного provider optimum бере CPU, навіть коли CUDA доступна — мовчки;
  • на CUDA треба use_io_binding=False, інакше граф падає з cudaErrorIllegalAddress на embed_tokens/Mul.

Чесна засторога щодо швидкості: на GPU ONNX програє llama.cpp у 7-8 разів (69 проти 532 ток/с), і причина не в якості реалізації, а в тому, що на такому розмірі все впирається в оверхед запуску ядер, який CUDA-графи llama.cpp прибирають, а onnxruntime для цього графа — ні. Натомість ONNX добре тримається на CPU: 31 ток/с проти 69 на GPU, тобто втрата лише вдвічі. Саме тут його ніша, а не в перегонах на відеокарті.

Тренування

Повний fine-tune (не LoRA), три сіди, model soup із трьох найкращих чекпоінтів. Дані — 176 705 пар, синтетичні й детерміновано згенеровані: числівники відмінює власний рушій ukr_num, а не мовна модель. Датасет: skypro1111/uk-text-normalization.

Ліцензія

Похідна від Gemma 3, тож поширюється на умовах Gemma Terms of Use. Використовуючи модель, ви приймаєте ці умови й Gemma Prohibited Use Policy.

Downloads last month
1,543
Safetensors
Model size
0.1B params
Tensor type
BF16
·
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Model tree for skypro1111/gemma-3-270m-uk-verbalizer

Quantized
(345)
this model

Dataset used to train skypro1111/gemma-3-270m-uk-verbalizer