Як я навчив нейромережу чути своє ім’я: wake word з нуля на TensorFlow
Продовження серії про AI Gateway і Катю. Частина про те, як звичайний звук перетворюється на картинку, чому модель два тижні була «глухою» і який один рядок коду все виправив.
1. Чому не готове рішення
У попередній статті про Катю я згадав wake word як «наступний крок» і навіть написав назви готових інструментів — openWakeWord, Picovoice Porcupine. Логічно було б просто взяти одне з них і рухатись далі.
Але є нюанс.
Porcupine від Picovoice — платний для комерційного використання, і кастомне слово коштує грошей. openWakeWord — безкоштовний і непоганий, але він базується на великій pre-trained моделі (~20MB), а моє захоплення це щось корисне на слабкому залізі, тому тре щось більш компактне
Тому я вирішив: зроблю свою модель. Маленьку, просту, зрозумілу. Таку яка влізе в 50KB і запуститься на будь-якому залізі.
Спойлер: це зайняло значно більше часу ніж я думав. І головна проблема виявилась не в даних і не в архітектурі — а в одному рядку коду який я написав неправильно з самого початку.
2. Що таке wake word
Перш ніж писати код — розберемось що ми взагалі хочемо зробити.
Wake word («слово пробудження») — це те слово після якого пристрій починає вас слухати. «Окей Гугл», «Алекса», «Сірі» — всі вони працюють однаково: мікрофон слухає постійно, але пристрій «прокидається» тільки коли чує своє слово.
Технічно це задача бінарної класифікації. Є два класи:
- positive — прозвучало слово «Катя»
- negative — все інше: тиша, фонові звуки, інші слова
Модель повинна слухати потік аудіо і для кожного короткого вікна (1 секунда) відповідати: «Катя» чи «не Катя».
Звучить просто. Але є одна проблема: нейромережі не вміють працювати зі звуком напряму. Вони вміють працювати з числами і — особливо добре — з картинками. Тому перший крок — перетворити звук на картинку.
3. Звук — це числа. Багато чисел
Коли ви говорите в мікрофон, він вимірює коливання повітря тисячі разів на секунду і записує їх як числа. Стандарт для аудіо в ML — 16,000 вимірювань на секунду (16kHz).
Тобто одна секунда вашого голосу — це масив з 16,000 чисел від —1.0 до 1.0.
[0.001, -0.003, 0.012, -0.008, 0.024, 0.031, -0.015, ...] # 16,000 таких чисел для кожної секунди
Можна спробувати подати ці 16,000 чисел напряму в нейромережу. Але є проблема: однакове слово сказане тихо і голосно дасть зовсім різні числа, хоча звучить однаково. Сказане повільно і швидко — теж різні числа. Модель буде навчатись не на слові, а на гучності і темпі конкретного запису.
Потрібне краще представлення, таке де «Катя» вимовлене тихо і голосно виглядає схоже.
4. Мел-спектрограма: перетворюємо звук на картинку
Рішення підказує те, як чує людське вухо.
Наше вухо не аналізує кожне коливання окремо. Воно розкладає звук на частоти як призма розкладає світло на кольори. Низький бас — одна частота, високий голос — інша. І ми чуємо не окремі коливання, а цей «спектр».
Спектрограма — це саме така картинка. По горизонталі — час, по вертикалі — частота, яскравість — гучність на цій частоті в цей момент.
Мел-спектрограма — це спектрограма де вісь частот стиснута так само як її сприймає людське вухо. Ми краще чуємо різницю між 100Hz і 200Hz ніж між 5000Hz і 5100Hz — хоча різниця та сама 100Hz. Мел-шкала враховує це.
В результаті одна секунда аудіо перетворюється на картинку 64×64 пікселі:
import librosa import numpy as np def wav_to_spectrogram(wav_path): # Завантажуємо аудіо audio, sr = librosa.load(wav_path, sr=16000, duration=1.0) # Рахуємо мел-спектрограму mel = librosa.feature.melspectrogram( y=audio, sr=16000, n_mels=64, # 64 частотних смуги по вертикалі n_fft=512, # вікно аналізу ~32ms hop_length=160 # крок 10ms → ~100 кадрів по горизонталі ) # Переводимо в децибели (логарифмічна шкала — як чує вухо) mel_db = librosa.power_to_db(mel, ref=np.max) # Стискаємо до 64×64 mel_resized = tf.image.resize(mel_db[..., np.newaxis], [64, 64]).numpy()[..., 0] # Нормалізуємо до [0, 1] mel_norm = (mel_resized - mel_resized.min()) / (mel_resized.max() - mel_resized.min()) return mel_norm # картинка 64×64 float32
Ось як виглядає «Катя» як картинка:
Тиша зліва → різкий сплеск енергії → затухання [░░░░░░░▓▓▓▓▓▓░░░░░░] ← висока частота (приголосна "К") [░░░░░▓▓▓▓▓▓▓▓▓░░░░░] ← середня частота (голосна "а") [░░░░▓▓▓▓▓▓▓▓▓▓░░░░░] ← низька частота (резонанс)
5. Нейромережа, епохи, батчі — пояснення без формул
Якщо ви ніколи не тренували нейромережу — ось найпростіше пояснення.
Нейромережа — це функція з мільйонами параметрів (вагами). На вході — картинка 64×64, на виході — одне число від 0 до 1 («наскільки це схоже на Катю»). Спочатку всі ваги випадкові і модель відповідає навмання.
Тренування — це процес підбору правильних ваг. Ми показуємо моделі картинку і кажемо «правильна відповідь — 1 (це Катя)» або «правильна відповідь — 0 (це не Катя)». Модель дивиться наскільки її відповідь відрізняється від правильної (це називається loss) і трохи підкручує ваги щоб наступного разу відповісти краще.
Батч — скільки картинок показуємо моделі за один раз перш ніж підкрутити ваги. Якщо батч = 32, то модель дивиться на 32 картинки, рахує середню помилку і робить один крок оновлення. Більший батч — стабільніше навчання, але більше потреба в пам’яті.
Епоха — один повний прохід по всьому датасету. Якщо у вас 1000 картинок і батч 32, то одна епоха = ~31 крок оновлення ваг. Зазвичай тренують
Adam — алгоритм який вирішує наскільки сильно підкручувати ваги на кожному кроці. Він «розумний»: якщо вага весь час рухається в одному напрямку — прискорює, якщо стрибає туди-сюди — гальмує.
AUC (Area Under Curve) — метрика якості моделі від 0.5 до 1.0. 0.5 — модель відповідає навмання (як монетка). 1.0 — ідеальна модель. 0.85+ — добре для wake word.
model.compile(
optimizer=tf.keras.optimizers.Adam(3e-4), # learning rate — розмір кроку
loss="binary_crossentropy", # функція помилки для 0/1 задач
metrics=["accuracy", tf.keras.metrics.AUC(name="auc")]
)
history = model.fit(
train_dataset,
validation_data=val_dataset,
epochs=60, # 60 проходів по датасету
batch_size=32, # 32 картинки за раз
class_weight={0: 0.65, 1: 2.1} # компенсація дисбалансу класів
)
6. Збираємо датасет: мікрофон і терпіння
Для навчання потрібні приклади. Два класи:
- Positive: записи слова «Катя» — ціль 300+
- Negative: все інше — ціль 600+
Баланс 1:2 або 1:3 — нормально. Якщо негативних буде 10:1 — модель навчиться просто завжди казати «не Катя» і матиме 90% accuracy нічого не навчившись.
Найважливіше при записі — різноманіття:
python record.py --mode positive
# Говори "Катя" по-різному:
# - тихо і голосно
# - питально ("Катя?") і стверджувально ("Катя!")
# - здалеку і близько до мікрофону
# - повільно і швидко
# - вранці і ввечері (голос змінюється)
# - різні голоси дитячий, чоловічий, жіночій
Для негативних — не тільки тиша і фоновий шум. Модель повинна знати слова які звучать схоже:
python record.py --mode negative # Обов'язково: Саша, Маша, Даша — фонетично найближчі # Також: один, два, три, книга, стіл, вода — звичайні слова
Чому це важливо? Якщо всі ваші негативні — це тиша і шум вулиці, а позитивні — живий голос, модель навчиться не «Катя» розпізнавати, а «живий голос = Катя». Це називається shortcut learning — модель знайшла обхідний шлях замість реального патерну.
Перевірка датасету перед тренуванням:
python check_dataset.py # Покаже: # - кількість тихих/порожніх записів (їх треба видалити) # - баланс класів # - проблеми з sample rate
7. Треную модель, а вона не вчиться
Ось де починається найцікавіше.
Я зібрав датасет, написав модель, запустив тренування. І отримав таке:
Epoch 1: train_auc=0.487 val_auc=0.529 val_accuracy=0.840 Epoch 2: train_auc=0.464 val_auc=0.538 val_accuracy=0.840 Epoch 3: train_auc=0.517 val_auc=0.538 val_accuracy=0.840 ... Epoch 10: train_auc=0.481 val_auc=0.552 val_accuracy=0.840
val_accuracy=0.840 — константа на кожній епосі. train_auc=0.487 — це майже як монетка (0.5 = повна випадковість).
Що це означає: модель навчилась казати «не Катя» на все. У датасеті 84% негативних прикладів — от вона і видає 84% accuracy нічому не навчившись. Це класична пастка незбалансованого датасету.
Довго я шукав причину. Перевірив все:
- ✓ Дані валідні, спектрограми різні на вигляд
- ✓
class_weightпередається правильно ({0: 0.65, 1: 2.1}) - ✓ Shuffle buffer виправлений (
buffer_size=2000) - ✓ Learning rate підняв (
Adam(1e-3)) - ✓ Баланс датасету перевірений
Нічого не допомагало. train_auc залишався ~0.50.
Тоді я зробив тест: запустив найпростішу лінійну модель (Dense без згорток) на тих самих даних:
Epoch 1: AUC=0.524 Epoch 2: AUC=0.540 Epoch 3: AUC=0.600 ← покращується!
Лінійна модель вчиться. CNN — ні. Проблема в архітектурі CNN, не в даних.
8. Один рядок який все виправив
Ось архітектура моделі:
def build_model(): inputs = tf.keras.Input(shape=(64, 64, 1)) x = Conv2D(16, 3, padding="same", activation="relu")(inputs) x = MaxPooling2D(2)(x) # 64→32 x = Dropout(0.2)(x) x = Conv2D(32, 3, padding="same", activation="relu")(x) x = MaxPooling2D(2)(x) # 32→16 x = Dropout(0.2)(x) x = Conv2D(64, 3, padding="same", activation="relu")(x) x = MaxPooling2D(2)(x) # 16→8 x = Dropout(0.3)(x) x = GlobalAveragePooling2D()(x) # ← ОСЬ ПРОБЛЕМА x = Dense(32, activation="relu")(x) x = Dropout(0.4)(x) output = Dense(1, activation="sigmoid")(x)
Бачите GlobalAveragePooling2D? Це шар який бере feature map після згорток і усереднює всі позиції. Тобто якщо в картинці є яскравий сплеск в одному куті і тиша скрізь — він поверне середнє значення по всій картинці.
А «Катя» на спектрограмі — це саме такий локальний сплеск. Характерний патерн в конкретному місці по часу і частоті. GAP цей сплеск «розмазував» по всьому feature map і градієнт зникав.
Заміна одного слова:
# Було: x = GlobalAveragePooling2D()(x) # Стало: x = GlobalMaxPooling2D()(x) # бере МАКСИМУМ, а не середнє
GlobalMaxPooling2D питає: «де найсильніша активація?» — і знаходить саме той сплеск.
Результат після заміни:
Epoch 1: train_auc=0.600 ← одразу рушило! Epoch 10: train_auc=0.770 Epoch 76: val_auc=0.862 ← фінальний результат
З 0.55 до 0.86 — зміна одного слова в коді.
9. Живий результат
Це був не один запуск — а три ітерації, кожна з яких вирішувала конкретну проблему.
Ітерація 1: після GlobalMaxPooling
python inference.py --model models/wake_word_*/best.keras --threshold 0.32 [23:39:01] КАТЯ [██████░░░░░░░░░░░░░░] 0.33 ✓ [23:39:05] КАТЯ [██████░░░░░░░░░░░░░░] 0.33 ✓ ─ [█████░░░░░░░░░░░░░░░] 0.28 ← "один" — не спрацьовує ─ [████░░░░░░░░░░░░░░░░] 0.22 ← "стіл" — не спрацьовує
Один, два, три, стіл, підлога, кавун — не тригерять. Але «Саша», «Маша», «Плаття», «Шмаття» дають ~0.33. Фонетично схожі — закінчення -аша/-атя/-ття має подібну структуру на спектрограмі.
Рішення: записати їх як hard negatives і перетренувати.
Ітерація 2: після hard negatives + два мікрофони
Записав позитивні і негативні на два різні мікрофони.
val_AUC: 0.9815 val_Accuracy: 95.7% ← було 85.1%
Живий тест на першому мікрофоні:
[07:15:35] КАТЯ [█████████████░░░░░░░] 0.66 ✓ [07:16:16] КАТЯ [████████████████░░░░] 0.84 ✓ [07:17:23] КАТЯ [██████████████████░░] 0.91 ✓ ─ [░░░░░░░░░░░░░░░░░░░░] 0.03 ← фон ─ [░░░░░░░░░░░░░░░░░░░░] 0.02 ← фон
Але з’явилась нова проблема: стукіт клавіатури дає
Рішення: записати стукіт клавіатури і музику як негативні, і «Катю» з музикою на фоні як позитивні.
Ітерація 3: фінальна
val_AUC: 0.9869 val_Accuracy: 95.7% val_Loss: 0.1611 Best epoch: 74/80 python inference.py --threshold 0.45 [07:53:16] КАТЯ [██████████████████░░] 0.92 ✓ [07:53:19] КАТЯ [██████████████░░░░░░] 0.75 ✓ [07:53:22] КАТЯ [██████████████████░░] 0.90 ✓ [07:53:30] КАТЯ [██████████████████░░] 0.95 ✓ ─ [░░░░░░░░░░░░░░░░░░░░] 0.03 ← фон ─ [░░░░░░░░░░░░░░░░░░░░] 0.04 ← клавіатура
«Катя» впевнено —
Підсумок трьох ітерацій:
| Run | Val AUC | Val Accuracy | Що вирішили |
|---|---|---|---|
| GlobalAvgPool | 0.55 | 82% | нічого, модель не вчилась |
| GlobalMaxPool | 0.86 | 85% | архітектурний баг |
| + hard negatives, 2 мікрофони | 0.98 | 95.7% | Саша/Маша/shortcut |
| + клавіатура, музика | 0.98 | 95.7% | false positive на стукіт |
10. Що далі
Модель (~50KB INT8) готова до деплою. Але це вже інша стаття.

Що цікавого дізнався з цього проекту:
Два тижні я думав що проблема в даних — додавав, прибирав, балансував. Виявилось що проблема в одному архітектурному рішенні яке я взяв «за замовчуванням» навіть не думаючи.
GlobalAveragePooling — стандартний вибір для більшості задач класифікації. Але wake word — це не «яке це зображення загалом», а «чи є в цьому зображенні конкретний локальний патерн». Для другої задачі потрібен GlobalMaxPooling.
Деталь яка не очевидна поки не зіткнешся.
Попередня стаття: Як я будував AI Gateway: Катя, компаньйонка 18+
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівто гіпотетично можна зробити детонатор до бімби, який буде активуватися на певні нюанси вимови (іф ю ноу вот ай мін) та/або на к-ть різних голосів поряд за одиницю часу?
Ага і начинимо вилками і ложками, щоб було потужніше
Дякую за статтю!
А чи не надто ви агресивно використовуєте в архітектурі Dropout, враховуючи що вона і так має небагато параметрів?
Також було б корисно побачити confusion matrix, щоб оцінити кількість false positive і false negative спрацювань та краще зрозуміти поведінку моделі.
Це допомогло б визначити, чи потрібно балансувати датасет, додавати hard negatives або змінювати архітектуру
Дякую за слушні зауваження
Гарна стаття! З нетерпінням чекаю на продовження.
Дякую!
завтра чи сьогодні з’явиться інструмент, який автоматично робитиме 80% описаного в статті
Цікаво. Але після прочитання виникло питання: чи не витрачаємо тут більше часу на «правильну архітектуру», ніж на вирішення самої задачі?
а де тут про архітектуру?
Клікбейт звичайний, аби що