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

Продовження серії про AI Gateway і Катю. Частина про те, як звичайний звук перетворюється на картинку, чому модель два тижні була “сліпою” і який один рядок коду все виправив.

Зміст

  1. Чому не готове рішення
  2. Що таке wake word і як це працює в голові
  3. Звук — це числа. Багато чисел
  4. Мел-спектрограма: перетворюємо звук на картинку
  5. Нейромережа, епохи, батчі — пояснення без формул
  6. Збираємо датасет: мікрофон і терпіння
  7. Тренуємо модель — і вона не вчиться
  8. Один рядок який все виправив
  9. Живий результат
  10. Що далі

1. Чому не готове рішення

У попередній статті про Катю я згадав wake word як “наступний крок” і навіть написав назви готових інструментів — openWakeWord, Picovoice Porcupine. Логічно було б просто взяти одне з них і рухатись далі.

Але є нюанс.

Porcupine від Picovoice — платний для комерційного використання, і кастомне слово коштує грошей. openWakeWord — безкоштовний і непоганий, але він базується на великій pre-trained моделі (~20MB) яку потрібно запустити на Luckfox Pico з RV1106. А там лише 256MB RAM і специфічний NPU який “їсть” тільки RKNN-формат. Портувати туди чужу архітектуру — окремий квест.

Тому я вирішив: зроблю свою модель. Маленьку, просту, зрозумілу. Таку яка влізе в 50KB і запуститься на будь-якому залізі.

Спойлер: це зайняло значно більше часу ніж я думав. І головна проблема виявилась не в даних і не в архітектурі — а в одному рядку коду який я написав неправильно з самого початку.

2. Що таке wake word і як це працює в голові

Перш ніж писати код — розберемось що ми взагалі хочемо зробити.

Wake word (“слово пробудження”) — це те слово після якого пристрій починає вас слухати. “Окей Гугл”, “Алекса”, “Сірі” — всі вони працюють однаково: мікрофон слухає постійно, але пристрій “прокидається” тільки коли чує своє слово.

Технічно це задача бінарної класифікації. Є два класи:

  • positive — прозвучало слово “Катя”
  • negative — все інше: тиша, фонові звуки, інші слова

Модель повинна слухати потік аудіо і для кожного короткого вікна (1 секунда) відповідати: “Катя” чи “не Катя”.

Звучить просто. Але є одна проблема: нейромережі не вміють працювати зі звуком напряму. Вони вміють працювати з числами і — особливо добре — з картинками. Тому перший крок — перетворити звук на картинку.

3. Звук — це числа. Багато чисел

Коли ви говорите в мікрофон, він вимірює коливання повітря тисячі разів на секунду і записує їх як числа. Стандарт для аудіо в ML — 16,000 вимірювань на секунду (16kHz).

Тобто одна секунда вашого голосу — це масив з 16,000 чисел від -1.0 до 1.0.

Можна спробувати подати ці 16,000 чисел напряму в нейромережу. Але є проблема: однакове слово сказане тихо і голосно дасть зовсім різні числа, хоча звучить однаково. Сказане повільно і швидко — теж різні числа. Модель буде навчатись не на слові, а на гучності і темпі конкретного запису.

Потрібне краще представлення. Таке де “Катя” тихим і голосним голосом виглядає схоже.

4. Мел-спектрограма: перетворюємо звук на картинку

Рішення підказує те, як чує людське вухо.

Наше вухо не аналізує кожне коливання окремо. Воно розкладає звук на частоти — як призма розкладає світло на кольори. Низький бас — одна частота, високий голос — інша. І ми чуємо не окремі коливання, а цей “спектр”.

Спектрограма — це саме така картинка. По горизонталі — час, по вертикалі — частота, яскравість — гучність на цій частоті в цей момент.

Мел-спектрограма — це спектрограма де вісь частот стиснута так само як її сприймає людське вухо. Ми краще чуємо різницю між 100Hz і 200Hz ніж між 5000Hz і 5100Hz — хоча різниця та сама 100Hz. Мел-шкала враховує це.

В результаті одна секунда аудіо перетворюється на картинку 64×64 пікселі:

Ось як виглядає “Катя” як картинка:

А ось “один”:

Тепер у нас є картинки. А з картинками нейромережі вміють працювати дуже добре.

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.

6. Збираємо датасет: мікрофон і терпіння

Для навчання потрібні приклади. Два класи:

  • Positive: записи слова “Катя” — ціль 300+
  • Negative: все інше — ціль 600+

Баланс 1:2 або 1:3 — нормально. Якщо негативних буде 10:1 — модель навчиться просто завжди казати “не Катя” і матиме 90% accuracy нічого не навчившись.

Найважливіше при записі — різноманіття:

Для негативних — не тільки тиша і фоновий шум. Модель повинна знати слова які звучать схоже:

Чому це важливо? Якщо всі ваші негативні — це тиша і шум вулиці, а позитивні — живий голос, модель навчиться не “Катя” розпізнавати, а “живий голос = Катя”. Це називається shortcut learning — модель знайшла обхідний шлях замість реального патерну.

Перевірка датасету перед тренуванням:

7. Тренуємо модель, а вона не вчиться

Ось де починається найцікавіше.

Я зібрав датасет, написав модель, запустив тренування. І отримав таке:

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 без згорток) на тих самих даних:

Лінійна модель вчиться. CNN — ні. Проблема в архітектурі CNN, не в даних.

8. Один рядок який все виправив

Ось архітектура моделі:

Бачите GlobalAveragePooling2D? Це шар який бере feature map після згорток і усереднює всі позиції. Тобто якщо в картинці є яскравий сплеск в одному куті і тиша скрізь — він поверне середнє значення по всій картинці.

А “Катя” на спектрограмі — це саме такий локальний сплеск. Характерний патерн в конкретному місці по часу і частоті. GAP цей сплеск “розмазував” по всьому feature map і градієнт зникав.

Заміна одного слова:

GlobalMaxPooling2D питає: “де найсильніша активація?” — і знаходить саме той сплеск.

Результат після заміни:

З 0.55 до 0.86 — зміна одного слова в коді.

9. Живий результат

Це був не один запуск — а три ітерації, кожна з яких вирішувала конкретну проблему.

Ітерація 1: після GlobalMaxPooling

Один, два, три, стіл, підлога, кавун — не тригерять. Але “Саша”, “Маша”, “Плаття” дають ~0.33. Фонетично схожі — закінчення -аша/-атя/-ття має подібну структуру на спектрограмі.

Рішення: записати їх як hard negatives і перетренувати.

Ітерація 2: після hard negatives + два мікрофони

Записав позитивні і негативні на два різні мікрофони. Модель більше не може вивчити “цей мікрофон = Катя”.

Живий тест на першому мікрофоні:

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

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

Ітерація 3: фінальна

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

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

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) готова до деплою на Luckfox Pico через RKNN-конвертацію. Але це вже інша стаття.

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

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

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

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

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

Теги: TensorFlow, wake word, mel spectrogram, CNN, TFLite, embedded ML, Luckfox Pico

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *