Нейромережа вагою 2 кілобайти: TinyML на Raspberry Pi Pico

Про те, як Модель машинного навчання на чипі за $4 розпізнає рухи руки

Замість вступу — трохи ностальгії

Привіт! Перш ніж ми почнемо, дозвольте трохи ностальгії — без неї ця історія не має сенсу.

(Невелика примітка: у моєму блозі паралельно йде цикл статей про моє шестимісячне входження в embedded — день за днем, плата за платою, від голих регістрів STM32 до Linux на крихітному комп’ютері Luckfox. Ця стаття стоїть осібно і читається сама собою, але якщо вам близька тема — там продовження.)

Багато років тому, коли слово «IoT» ще не було так популярно, я будував свій маленький інтернет речей. Тільки він був на дротах. Arduino Pro Mini, Ethernet-модуль W5500, кручена пара. Мої пристрої спілкувалися один з одним по сокетах через простенький сигнальний сервер. Я наглядав за насосною станцією, датчиком кортролював тиск води, наглядав за електрокотлом температурними датчиками. SMS-розсилка, дрібна автоматика, термінал знижок. Wi-Fi я тоді не довіряв і, чесно кажучи, не дуже довіряю зараз: ефір — це втрачені пакети, ризик, що школота заглушить сигнал, ну і хакери бо припускаю і такий ризик, особливо після перегляду серіалу Селіконова долина, хто бачив зрозуміє.

Мої пристрої вміли передавати дані, але не розуміли. Датчик тиску відправляв число. Десь на сервері це число порівнювалося з пороговим значенням, записаним рукою: «якщо більше — тривога». Уся «кмітливість» системи була набором рукописних if-ів.

Довгі роки мріяв навчитися робити так, щоб залізо саме розуміло, що означають отримані ним числа прям на мікроконтролері. Сьогодні вже очевидно що майбутнє IoT саме у маленьких пристроях, які думають локально.

◆ Розділ 1. Чому Pi Pico і Ардуїно і що таке TinyML

TinyML — машинне навчання на мікроконтролерах — як і багато чого іншого вивчав по книзі. «TinyML Cookbook»  від технічного лідера у Machine Learning Group компанії Arm Джан Марко Іодіче. Класна книга, практична, з наскрізними проєктами. Приклади розглядались в ній на Arduino Nano 33 BLE Sense і в десять разів дешевшій Raspberry Pi Pico. Ось і вся таємниця «чому Pico»: не тому що вона технічно краща, а тому що дешево.

Тепер — що таке TinyML.

Машинне навчання має два зовсім різні етапи: навчання і застосування.

Навчання (training) — це важко. Береться багато прикладів, модель тисячі разів їх переглядає, помиляється, підкручує себе. Це вимагає потужного заліза — звичайно ПК з хорошою відеокартою. Жоден мікроконтролер цього не робить і робити не буде.

Застосування (inference) — це легко. Модель уже навчена, її параметри сталі. Лишається чиста арифметика: взяти вхідні числа, прогнати через готову модель, отримати відповідь.

TinyML — це спосіб взяти модель, навчену на «великому» комп’ютері, стиснути її так, щоб вона влізла в кілька кілобайт, і запустити її inference на крихітному чипі. Pico у цій історії нічого не вчить. Він проганяє вже готову, «сталу» модель.

Весь шлях виглядає так:

Keras і TensorFlow живуть тільки на кроці 1, тільки на комп’ютері. На Pico приїжджає лише результат — стала модель у вигляді масиву байтів. Між ними конвертер, який стискає модель.

Ми будемо розпізнавати жести. До Pico по шині I2C підключений акселерометр-гіроскоп MPU6050. Модель має навчитися розрізняти чотири стани руху. Жодного рядка if про «якщо прискорення більше за X». Замість рукописних правил — модель, яка вивела ці правила сама, подивившись на приклади.

Чесно про основу. Усе, що нижче — мій перший власний TinyML-проєкт, зібраний за мотивами «TinyML Cookbook». Книга показує жести через сервіс Edge Impulse; я ж пішов іншим шляхом — зібрав ланцюг напряму через Keras і TFLite Micro, щоб бачити кожен крок руками. Тому це не переказ книги, а мій власний шлях — з усіма набитими ґулями, які чесно опишу далі.

◆ Розділ 2. Залізо: MPU6050 і шина I2C

MPU6050 — це акселерометр і гіроскоп в одному корпусі. Нам у цьому проєкті потрібен акселерометр: він вимірює прискорення по трьох осях X, Y, Z. У стані спокою він орієгтується на земне тяжіння — це наш постійний орієнтир: вісь, спрямована вгору, показує приблизно 1g.

Підключення до Pico — чотири дроти: живлення, земля і дві лінії шини I2C — SDA (дані) і SCL (тактування). У коді вони висять на пінах GP16 і GP17.

I2C це типу коротка розмова за адресами. У MPU6050 адреса 0x68  та цього не достатньо, треба знати ще  два регістри:

Регістр 0x6B — керування живленням. Після подачі струму MPU6050 спить, будимо записуючи нуль в 0x6B.

Регістр 0x3B — звідси починаються дані акселерометра. Це наче поштова скринька: кажемо «хочемо читати з 0x3B», і чип віддає шість байтів поспіль — по два на кожну вісь X, Y, Z.

Код ініціалізації простий:

Зчитання трьох осей:

Кожна вісь визначена двома байтами — старший і молодший. Зсуваємо старший на 8 біт, склеюємо з молодшим — отримуємо ціле число зі знаком від —32768 до 32767.

Але це «сирі» числа і вони нам ні про що не говорять. MPU6050 за замовчуванням налаштований на діапазон ±2g. Ділимо сире число на 16384, дістаємо нормальні одиниці тяжіння:

Тепер az ≈ 1.0, коли датчик лежить рівно. Це і є числа, які побачить нейромережа.

Звідки 16384. Це проста арифметика. Діапазон ±2g розкладений на 16 біт зі знаком. Половина діапазону (2g) відповідає максимуму 32768. 32768 / 2 = 16384 одиниць на кожен g. Зміните діапазон датчика на ±4g — дільник стане 8192. Це класичні граблі: взяв чужий код з іншим діапазоном, забув про дільник — і модель отримує числа не того масштабу.

◆ Розділ 3. Навчаємо модель

Тепер найцікавіше — звідки беруться «мізки». Відкриваємо Python. Це робиться один раз, на комп’ютері, у Google Colab або локально — будь-де, де є TensorFlow.

Модель навчається на прикладах: набір вимірювань акселерометра, і мітки, який це жест. Чотири класи: спокій, рух вгору, рух вниз, струс. Мітки — числа 0, 1, 2, 3.

Важливо. У моєму першому проєкті дані — синтетичні. Я не записував реальні рухи, а згенерував числа програмно: в «спокої» — близькі до нуля з невеликим шумом, для «струсу» — великі стрибки, і так далі. Модель, навчена на синтетиці, навіть працює, але «грубо». Це свідоме спрощення для першого кроку, усе, що нижче — навчальний макет.

Сама модель:

Три рядки — і це повноцінна нейромережа.

Sequential — модель як стопка шарів: дані входять зверху, проходять шар за шаром, виходять знизу.

Dense — «повнозв’язний» шар. Кожен його нейрон з’єднаний з усіма входами. Один шар Dense робить просту річ: бере вхідні числа, множить їх на свою таблицю ваг, додає зсув. Множення таблиці чисел це і є вся «математика» нейромережі. Те, що на серйозному залізі робить NPU, тут робить процесор Pico.

input_shape=(3,) — на вході три числа: ax, ay, az. Один знімок акселерометра.

Dense(16) — перший шар має 16 нейронів. Це його «місткість»: замало — не вистачить пам’яті вивчити жести, забагато — модель завчить приклади напам’ять і провалиться на нових.  Для чотирьох простих жестів 16 це норм.

activation='relu' — після множення кожен нейрон пропускає результат через ReLU. ReLU: від’ємне обнуляє, додатне лишає. Без цього кілька шарів множення математично згорнулися б в одне, і глибина  б нічого не давала. ReLU «ламає» цю лінійність — саме завдяки їй мережа здатна вчити складні, нелінійні правила.

Dense(4, activation='softmax') — останній шар має 4 нейрони, по одному на клас. softmax перетворює їхні сирі числа на чотири ймовірності, що в сумі дають одиницю. Якщо вихід [0.02, 0.05, 0.91, 0.02] — модель каже: «це клас 2, струс, упевненість 91%».

Тренування:

loss — міра помилки: наскільки сильно модель зараз бреше. optimizer='adam' — механізм, який після кожної порції прикладів підкручує ваги, щоб помилка меншала. epochs=50 — пройти весь набір даних 50 разів. validation_split=0.2 — відкласти п’яту частину прикладів і перевіряти точність на них; це чесна оцінка, бо ці приклади модель при навчанні не бачила.

Запускаємо — і спостерігаємо, як accuracy повзе вгору. Модель навчається.

Тепер найважливіше перетворення — з Python у щось, що потягне мікроконтролер:

Optimize.DEFAULT — це і є квантизація.

Натренована модель зберігає ваги як float32 — числа з комою, кожне по 4 байти, точні. Мікроконтролер таке тягне погано бо і пам’яті мало, і float-арифметика дорога. Квантизація переводить усі ваги у int8 — цілі числа від —128 до 127, по 1 байту. Модель меншає вчетверо, рахується швидше. Ціна — мікроскопічна втрата точності, для нашого завдання непомітна.

Останній крок — модель .tflite це файл, а у мікроконтролера немає файлової системи. Тому файл перетворюємо на масив байтів просто в коді C:

На виході — файл model_data.h. Усередині масив model[] і його довжина. У моєму проєкті модель вийшла 2284 байти. Два з гаком кілобайти — ось це і вся «розумна» частина пристрою.

Цей файл ми кладемо поряд зі скетчем — і переходимо на Pico.

◆ Розділ 4. Inference на Pico: як модель оживає в C++

На комп’ютері ми модель створили й заморозили. Тепер запустимо її на чипі за чотири долари.

Спершу — про бібліотеку, бо тут є нюанс. Inference на Pico виконує TFLite Micro — урізана версія TensorFlow для мікроконтролерів. Я брав її як Arduino-бібліотеку Chirale_TensorFlowLite з офіційного каталогу Arduino.

Про вибір бібліотеки. Chirale_TensorFlowLite офіційно протестована на платах Arduino Nano 33 BLE, Nicla, Portenta, Giga, Nano ESP32. Raspberry Pi Pico у списку підтверджених немає — автори прямо пишуть: сумісність з іншими платами не гарантується, перевіряйте самі. Я перевірив: під ядром arduino-pico воно збирається і працює. Якщо хочете шлях саме для Pico — є pico-tflmicro, офіційний порт TFLite Micro під Pico SDK. Я лишився на Arduino-варіанті свідомо: весь проєкт у мене в Arduino IDE, і для навчання простіше тримати все в одному середовищі. Також є простіша обгортка ArduTFLite з API в «ардуїнівському» стилі — але я взяв нижчий рівень навмисно, щоб бачити інтерпретатор і тензори напряму.

У TFLite Micro чотири дійові особи. Знайомимось:

Model — той самий масив із model_data.h.

Tensor Arena — шматок оперативної пам’яті, який ми вручну виділяємо під робочі потреби нейромережі. Усі проміжні обчислення відбуваються тут. Розмір підбирається під модель; для нашої вистачає кількох кілобайт.

Interpreter — двигун. Бере вхідні числа, проганяє їх крізь шари моделі, кладе результат на вихід.

Resolver — економіст. Нейромережі бувають з різних математичних операцій, і TFLite Micro знає їх усі. Resolver підключає лише ті, що потрібні саме нашій моделі: множення повнозв’язного шару, ReLU, softmax.

Налаштування — у setup(), один раз:

AllocateTensors() — момент, коли інтерпретатор розкладає по арені всі вхідні, вихідні й проміжні тензори. input і output — це наші «вікна»: у перше ми записуємо дані датчика, з другого читаємо відповідь.

І головний цикл loop() — чотири кроки на кожному оберті:

Invoke() — це і є inference. Один рядок, за яким ховається весь прохід крізь шари: множення на ваги, ReLU, softmax. Те, що на комп’ютері описувалося в Keras, тут просто виконується.

Квантизація б’є саме тут. Код вище подає у модель float і читає float — це працює, поки модель з float-входом. Якщо ж ви квантизували модель повністю (вхід і вихід теж у int8), числа треба перетворювати вручну: int8 = float / scale + zero_point на вході, і навпаки на виході. scale і zero_point — два числа, які конвертер прикладає до кожного тензора. Забули перетворення — модель отримає сміття замість даних. Це найпідступніші граблі TinyML: код компілюється, пристрій працює, відповіді — випадкові. Тому тестуйте, перевірте, чи збігаються типи даних на вході.

◆ Розділ 5. Запуск

Заливаємо скетч, відкриваємо Serial Monitor. Pico читає акселерометр, проганяє кожен знімок крізь модель, друкує жест.

Кладемо датчик на стіл — «Спокій». Трусимо — «Струс». Працює. Чип за чотири долари справді розрізняє рух — без жодного рукописного if, лише силою 2284 байтів натренованих вагів.

А тепер про межу, і її важливо розуміти.

«Спокій» і «Струс» модель розрізняє впевнено. А от «Вгору» і «Вниз» — плутає. Чому?

Тому що наша модель дивиться на один миттєвий знімок — три числа в один момент часу. А рух «вгору» — це не момент, це процес: розгін, пік швидкості, гальмування. У різні миті цього процесу акселерометр показує ну дуже різне. Гірше того: один кадр посеред руху вгору і один кадр посеред руху вниз можуть виглядати майже однаково. Модель просто фізично не має звідки взяти відповідь — їй показують одну точку там, де відповідь захована в послідовності точок.

«Спокій» і «Струс» по одному кадру видно: спокій — числа майже не рухаються, струс — великі стрибки. А напрямок руху по одному кадру не видно нікому, навіть ідеальній моделі.

Ось чому я від самого початку чесно попереджав про синтетичні дані й «навчальний макет». Справжній жест-розпізнавач будується інакше — і це рівно те, що відрізняє цей перший крок від наступного.

◆ Розділ 6. Що далі

Цей проєкт — простий перший крок. Його завдання лише показати ланцюг TinyML від Keras до чипа. Що можна зробити.

Перше — справжні дані замість синтетики. Замість згенерованих чисел треба записати реальні рухи: робите жест, Pico шле дані акселерометра в Serial, ви збираєте їх у файл, ставите мітку. По кілька сотень прикладів на жест. Модель, навчена на справжніх рухах, знає шум саме вашого датчика і саме вашу манеру руху — і впевненість стрибає з «якось працює» до 95+ відсотків.

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

Про що можна написати далі, якщо на цей опус буде відгук.

Є плата Luckfox Pico Pro — крихітний комп’ютер за $15, на якому працює повноцінний Linux. І в нього на борту є те, чого немає ні в Pico, ні в ESP32: NPU — окремий апаратний прискорювач нейромереж. Те, що тут робив процесор Pico в поту, NPU робить без напряга і майже миттєво.

Можна узяти ту саму ідею — розпізнавання за даними датчика — і перенести її на NPU Luckfox: інший рантайм, інший формат моделі, апаратне прискорення. А далі — щоб рішення нейромережі йшло по UART на мікроконтролер, і той замикав реле. Один пристрій думає, інший діє. Але це вже окрема історія.

Дякую, що дочитали.

 


Довідники:

  • tinyml_pico_guide.md — покрокове налаштування: ядро arduino-pico, бібліотека Chirale_TensorFlowLite, заливка скетча, типові помилки збірки.
  • mpu6050_i2c_cheatsheet.md — карта регістрів MPU6050, режими діапазону (±2g/±4g/±8g/±16g) і відповідні дільники, формули переводу.
  • uart_wiring_cheatsheet.md — окреме питання, яке часто плутає: два, три чи чотири проводи у UART, і чому. Я й сам роками користувався двома, доки не зрозумів усю картину.

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

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