Як я навчив нейромережу чути своє ім’я: wake word з нуля на TensorFlow

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Продовження серії про 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 крок оновлення ваг. Зазвичай тренують 50-100 епох.

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   ← фон

Але з’явилась нова проблема: стукіт клавіатури дає 0.72-0.83. Ритмічний стукіт створює на спектрограмі короткі вертикальні сплески — точно така ж форма як приголосна «К» на початку «Каті».

Рішення: записати стукіт клавіатури і музику як негативні, і «Катю» з музикою на фоні як позитивні.

Ітерація 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   ← клавіатура

«Катя» впевнено — 0.58-0.95 залежно від відстані до мікрофону. Фон і клавіатура — 0.02-0.05. Зазор достатній для будь-якого порогу від 0.4 до 0.5.

Підсумок трьох ітерацій:

RunVal AUCVal AccuracyЩо вирішили
GlobalAvgPool0.5582%нічого, модель не вчилась
GlobalMaxPool0.8685%архітектурний баг
+ hard negatives, 2 мікрофони0.9895.7%Саша/Маша/shortcut
+ клавіатура, музика0.9895.7%false positive на стукіт

10. Що далі

Модель (~50KB INT8) готова до деплою. Але це вже інша стаття.

Що цікавого дізнався з цього проекту:

Два тижні я думав що проблема в даних — додавав, прибирав, балансував. Виявилось що проблема в одному архітектурному рішенні яке я взяв «за замовчуванням» навіть не думаючи.

GlobalAveragePooling — стандартний вибір для більшості задач класифікації. Але wake word — це не «яке це зображення загалом», а «чи є в цьому зображенні конкретний локальний патерн». Для другої задачі потрібен GlobalMaxPooling.

Деталь яка не очевидна поки не зіткнешся.

Попередня стаття: Як я будував AI Gateway: Катя, компаньйонка 18+

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

то гіпотетично можна зробити детонатор до бімби, який буде активуватися на певні нюанси вимови (іф ю ноу вот ай мін) та/або на к-ть різних голосів поряд за одиницю часу?

Ага і начинимо вилками і ложками, щоб було потужніше

Дякую за статтю!
А чи не надто ви агресивно використовуєте в архітектурі Dropout, враховуючи що вона і так має небагато параметрів?
Також було б корисно побачити confusion matrix, щоб оцінити кількість false positive і false negative спрацювань та краще зрозуміти поведінку моделі.
Це допомогло б визначити, чи потрібно балансувати датасет, додавати hard negatives або змінювати архітектуру

Дякую за слушні зауваження

Гарна стаття! З нетерпінням чекаю на продовження.

завтра чи сьогодні з’явиться інструмент, який автоматично робитиме 80% описаного в статті

Цікаво. Але після прочитання виникло питання: чи не витрачаємо тут більше часу на «правильну архітектуру», ніж на вирішення самої задачі?

Підписатись на коментарі