Apprendimento automatico con TinyML

In un precedente articolo avevamo realizzato un modello per l’apprendimento automatico. Grazie a Tensorflow Lite avevamo poi  convertito il modello per essere caricato ed eseguito su un microcontrollore.  In questa seconda parte, costruiremo un’applicazione embedded che utilizza il modello sinusoidale per creare un effetto luminoso. Creeremo un ciclo continuo che inserisce un valore x nel modello, esegue l’inferenza e utilizza il risultato per accendere, spegnere e affievolire un LED.

Introduzione

Il modello è solo una parte di un’applicazione per l’apprendimento automatico. Da solo non può fare molto. Per usare il modello, occorre racchiuderlo nel codice che imposta l’ambiente necessario per la sua esecuzione, fornirgli input e utilizzare l’output per generare un comportamento. Questa applicazione è già stata scritta. Si tratta di un programma C++ il cui codice è progettato per mostrare la più piccola implementazione possibile di un’applicazione TinyML completa. Dopo aver capito la struttura generale per la specifica applicazione sarà possibile riutilizzare la stessa struttura in altri progetti. La Figura 1 mostra come il modello ML si inserisce in un’applicazione TinyML di base.

modello ML
Figura 1: architettura base di un’applicazione TinyML.

Analizziamo i singoli blocchi dell’architettura base:

  • Pre elaborazione – trasformata i dati di input in modo da essere compatibili con il modello.
  • Interprete TFLite – esegue il modello all’interno del codice dell’applicazione.
  • Post elaborazione – interpreta l’uscita del modello e prende decisioni.
  • Attuazione – usa le capacità del dispositivo embedded per rispondere alle previsioni.

Struttura file di progetto

Il programma (“hello_world”) che stiamo costruendo consiste in un ciclo continuo che inserisce un valore x nel modello, esegue l’inferenza e utilizza il risultato y per produrne una sorta di uscita visibile (LED lampeggiante). Poiché l’applicazione è complessa e si estende su più file, diamo un’occhiata alla sua struttura e a come tutto si tiene insieme. Tutti i file sono disponibili al seguente LINK.

La root dell’applicazione si trova in tensorflow/lite/micro/esempi/hello_world, e contiene i seguenti file:

  • Build – elenca le varie cose che possono essere costruite usando il codice sorgente dell’applicazione, incluso il file binario dell’applicazione principale e i test.
  • Makefile.inc –  definisce quali file sorgente fanno parte del file binario dell’applicazione principale hello_world  e del test hello_world_test.
  • README.md – Un file contenente le istruzioni per la creazione e l’esecuzione dell’applicazione.
  • constants.h, constants.cc – una coppia di file contenenti varie costanti che sono importanti per definire il comportamento del programma.
  • create_sine_model.ipynb – lo Jupyter notebook del modello.
  • hello_world_test.cc – un test che esegue l’inferenza usando il modello.
  • main.cc – il punto di ingresso del programma, che viene eseguito per primo quando l’applicazione viene distribuita su un dispositivo.
  • main_functions.h, main_functions.cc – una coppia di file che definiscono una funzione setup()  e una funzione loop().
  • output_handler.h, output_handler.cc – una coppia di file che definiscono una funzione utile per visualizzare un output ad ogni inferenza.
  • output_handler_test.cc – Un test che dimostra che il codice in output_handler.h e output_handler.cc funziona correttamente.
  • sine_model_data.h, sine_model_data.cc – una coppia di file che definiscono una matrice di dati che rappresentano il modello, esportati usando xxd.

Oltre a questi file, la directory contiene anche la sottodirectory arduino/. Poiché diverse piattaforme di microcontrollori hanno funzionalità e API diverse, la directory arduino contiene versioni personalizzate di main.cc, constants.cc e output_handler.cc che adatta l’applicazione per Arduino.

Passiamo ad analizzare il codice. Il codice può essere letto in parallelo all’analisi, al seguente LINK.

Main_functions.cc

Iniziamo con main_functions.cc, il fulcro su cui ruota l’intera applicazione. Dopo aver dichiarato le dipendenze tramite #include, vengono impostate le variabili globali utilizzate all’interno di main_functions.cc.  La variabile int inference_count, tiene traccia del numero di inferenze eseguite.

Quindi vediamo la funzione setup(). Nella prima parte di setup() avviene l’impostazione della registrazione, il caricamento del modello, l’impostazione dell’interprete e l’allocazione della memoria. Si giunge quindi ai puntatori ai tensori di input e output. Infine, la variabile inference_count viene posta a 0. A questo punto, tutta l’infrastruttura di apprendimento automatico è configurata e pronta per l’uso.

La funzione loop() verrà eseguita ripetutamente. Il valore (x_val) da passare al modello viene determinato da due costanti: kXrange (valore x massimo) e kInferencesPerCycle (numero di inferenze da eseguire mentre passiamo da 0 a 2π).

Le due costanti, kInferencesPerCycle e kXrange, sono definite nei file constants.h e constants.cc. Può essere utile definire le costanti in un file separato in modo che possano essere incluse e utilizzate in qualsiasi altro file.

La parte successiva del codice prende il valore x dal tensore di input del modello, esegue l’inferenza, e quindi prende il risultato y dal tensore di uscita. Ora abbiamo un valore sinusoidale. Genereremo una sequenza di valori sinusoidali nel tempo per controllare un LED.

Non ci resta che portare in qualche modo all’esterno il risultato per mezzo della funzione HandleOutput (), definita in output_handler.cc.

Gestione dell’output con output_handler.cc

Il file output_handler.cc definisce la funzione HandleOutput(). Tutto ciò che fa questa funzione è utilizzare l’istanza ErrorReporter per registrare i valori x e y. Questa è solo un’implementazione minima che possiamo usare per testare la funzionalità di base della nostra applicazione. Il nostro obiettivo, tuttavia, è distribuire questa applicazione sui microcontrollori, utilizzando l’hardware specializzato di ciascuna piattaforma per visualizzare l’output (per esempio, accendendo un LED). Per ciascuna piattaforma, esiste una soluzione specifica di output_handler.cc che utilizza le API della piattaforma per controllare l’output. Come accennato in precedenza, questi file sostitutivi si trovano in sottodirectory con il nome di ciascuna piattaforma.

Conclusione main_functions.cc

L’ultima cosa che facciamo nella nostra funzione loop() è incrementare il contatore inference_count. Se ha raggiunto il numero massimo di inferenze per ciclo definito in kInferencesPerCycle, lo ripristiniamo a 0.

Ora abbiamo raggiunto la fine della nostra funzione loop(). Ogni volta che viene eseguita, un nuovo valore x viene calcolato, viene eseguita l’inferenza e il risultato viene generato da HandleOutput(). Se loop() viene chiamata continuamente, eseguirà l’inferenza per una progressione di valori x nell’intervallo da 0 a 2π e quindi a ripetere. Ciò che fa funzionare loop() in modo continuato è da ricercare nel file main.cc.

Capire main.cc

Il file main.cc contiene una funzione globale denominata main(), che verrà eseguito all’avvio del programma. L’esistenza di questa funzione main() è la ragione per cui main.cc rappresenta il punto di ingresso del programma. Il codice in main() verrà eseguito ad ogni avvio del microcontrollore.

Il file main.cc è molto breve. Inizia con un’istruzione #include per main_functions.h, che importerà le funzioni setup() e loop() definite in esso. Successivamente, avviene la dichiarazione della funzione main().

Quando main() viene eseguita, chiamerà prima la funzione setup() e lo farà solo una volta. Dopo, entrerà in un ciclo while che chiamerà  continuamente la funzione loop(). Questo ciclo continuerà a funzionare indefinitamente.

Finchè il microcontrollore sarà collegato all’alimentazione continuerà a fare inferenze e a fornire dati. Con questo abbiamo esaminato l’intera applicazione per microcontrollore.

Distribuire l’applicazione su Arduino

Esiste una grande varietà di schede Arduino, tutte con capacità diverse. Non tutte però sono compatibili con TensorFlow Lite per microcontrollori. La scheda che raccomandiamo è Arduino Nano 33 BLE Sense. Ogni dispositivo ha le proprie capacità di output uniche. La maggior parte delle schede Arduino sono dotate di un LED integrato (figura 2), e questo è ciò che useremo visivamente per emettere i valori sinusoidali.

Figura 2: la scheda Arduino Nano 33 BLE Sense con indicazione del led integrato.

Dato che i valori variano da -1 a 1, potremmo rappresentare 0 con un LED completamente spento, -1 e 1 con un LED completamente acceso e tutti i valori intermedi con un LED parzialmente oscurato. Mentre il programma esegue le inferenze in un ciclo, il LED lampeggierà ripetutamente. Per attenuare il nostro LED integrato, possiamo usare la tecnica di modulazione della larghezza degli impulsi (PWM).

Modificando la costante kInferencesPerCycle, definita in constants.cc, si regolerà la velocità di attenuazione del LED. Esiste una versione specifica per Arduino di questo file in hello_world/arduino/constants.cc e verrà utilizzata invece dell’implementazione originale.

Il nostro progetto, insieme ad altri esempi, sono disponibili come codice esempio nella libreria TensorFlow Lite per Arduino, installabile facilmente tramite l’IDE Arduino. Per farlo basta selezionare “Gestisci librerie” dal menu “Strumenti”. Nella finestra che appare, cercare e installare il file libreria denominato “Arduino_TensorFlowLite”. Dopo aver installato la libreria, l’esempio “hello_world” verrà visualizzato in “File” → ” Esempi” → “_Arduino_TensorFlowLite_”.

Dopo aver caricato l’esempio “hello_world”, apparirà una scheda per ciascuno dei file sorgente. Il file nella prima scheda, hello_world, è equivalente al file main_functions.cc. Per eseguire l’esempio, basta collegare il proprio dispositivo Arduino tramite USB e fare clic sul pulsante di caricamento per compilare e caricare il codice sul dispositivo. Una volta completato il caricamento, il LED sulla scheda dovrebbe iniziare ad illuminarsi e attenuarsi alternativamente.

A questo punto il modello ML è in esecuzione sul dispositivo embedded!

È inoltre possibile visualizzare il valore di luminosità tracciando un grafico. Per fare ciò, basta aprire il “Plotter Seriale” dell’IDE selezionandolo nel menu “Strumenti”.

Conclusione

Siamo arrivati quindi alla conclusione del percorso, sviluppato in due articoli, per la realizzazione di un’applicazione ML per dispositivi embedded. Iniziato con la realizzazione end-to-end di un modello ML mediante framework  TensorFlow, convertito quindi per TensorFlow Lite, e terminato con la scrittura di un’applicazione attorno ad esso in modo da poterlo distribuire su un piccolo dispositivo come Arduino Nano 33 BLE Sense.

Riferimenti

TinyML: Machine Learning with TensorFlow Lite on Arduino and Ultra-Low-Power Microcontrollers” , Pete Warden & Daniel Situnayake, O’REILLY

Redazione Fare Elettronica