Wbudowane sieci neuronowe w STM32 (1)

Wbudowane sieci neuronowe w STM32 (1)

Sieci neuronowe są obecnie najbardziej rozpowszechnionym algorytmem sztucznej inteligencji. W ostatnim czasie powstały także wersje przeznaczone dla mikrokontrolerów, gdzie znajdują zastosowanie w wykrywaniu wzorców w zbieranych danych. W artykule zaprezentuję, jak można wytrenować sieć w bibliotece TensorFlow, a następnie, jak ją uruchomić na mikrokontrolerze używając dostępnych bibliotek. Eksperymenty zostały wykonane na płytce Nucleo-L476RG, ale powinny zadziałać, także na innych układach firmy ST dysponującymi odpowiednią ilością pamięci.

Pierwszym przykładem będzie mało przydatna w praktyce, ale idealna, jako przykład startowy sieć realizująca działanie funkcji XOR. Na początku przygotujemy dane szkoleniowe i utworzymy model sieci oraz wytrenujemy jej wagi w pakiecie TensorFlow. Następnie wygenerujemy model TensorFlow lite i na jego podstawie otrzymamy kod dla mikrokontrolera STM32.

Uczymy sieć

Do trenowania sieci zastosujemy środowisko Google Colab [1]. Możemy tam tworzyć notatniki i uruchamiać je w chmurze. Dzięki temu mamy już zainstalowane potrzebne biblioteki. Cały kod użyty w projekcie znajduje się w [2]. Na listingu 1 pokazano kod odpowiedzialny za importowanie bibliotek, z których będziemy korzystać. Są to moduły:

  • TensorFlow, który dostarcza funkcji dla sieci neuronowych,
  • NumPy odpowiadający za efektywne obliczenia numeryczne.
Listing 1. Dołączanie bibliotek

import tensorflow as tf
import numpy as np
print(“TensorFlow version:”, tf.__version__)

Dla sprawdzenia wyświetlamy wersję TensorFlow. Ja używałem wersji 2.8.2.
Kolejnym krokiem jest przygotowanie treningowego zbioru danych (listing 2). Ponieważ w naszym przypadku mamy tylko 4 punkty, dla których będziemy używać naszej sieci, to będą one zarówno zbiorem treningowym jak i użytym do oceny modelu. Zarówno wejścia jak i wyjścia naszej sieci będą liczbami zmiennoprzecinkowymi typu float32. Przyjmiemy więc, że stan 0 zakodujemy jako –127, a 1 jako +127. Natomiast stan wyjścia będziemy oceniać na podstawie znaku. Zero i więcej będą odpowiadać logicznej 1, natomiast liczby ujemne będą interpretowane jako 0.

Listing 2. Zbiór uczący

data_x = np.array([
[-127.0, -127.0],
[-127.0, 127.0],
[127.0, -127.0],
[127.0, 127.0]
], dtype=np.float32)

data_y = np.array([
-127.0,
127.0,
127.0,
-127.0
], dtype=np.float32)

Dla naszego zadania wystarczyłaby mniejsza sieć, ale wtedy algorytm uczenia miałby problem ze znalezieniem satysfakcjonujących nas wag. Moglibyśmy je dobrać ręcznie (co dla naszego małego przypadku byłoby proste). Ale my po prostu zwiększymy rozmiar modelu. Został on pokazany na rysunku 1 oraz listingu 3.

Rysunek 1. Model sieci
Listing 3. Opis kolejnych warstw sieci

model = tf.keras.models.Sequential([
tf.keras.layers.Dense(4, activation=’relu’),
tf.keras.layers.Dense(4, activation=’relu’),
tf.keras.layers.Dense(1, activation=’linear’),
])

Stosujemy trzy warstwy. W dwóch pierwszych są po cztery neurony z nieliniowością typu ReLU (Rectifed linear unit linear – poprawiona jednostka liniowa). Kształt tej funkcji zaprezentowano na rysunku 2. Można ją zapisać jako:

y = max(0, x)

Rysunek 2. Poprawiona jednostka liniowa – ReLU

Na końcu mamy pojedynczą warstwę z wyjściem liniowym. Nie możemy wykorzystać tu nieliniowości ReLU, ponieważ na wyjściu chcemy otrzymywać, także wartości ujemne symbolizujące logiczne 0.

Na początku parametry sieci są wypełniane wartościami losowymi. Możemy sprawdzić jaki uzyskamy wynik dla naszych 4 punktów uruchamiając następujące działanie:

predictions = model(data_x).numpy()

Ja uzyskałem wynik:

array([[-206.96419 ],
[-132.3428 ],
[ -82.97228 ],
[ -21.927877]], dtype=float32)

Odpowiada on zwróceniu logicznego 0 dla każdej pary wejść. Jest on dość daleki od oczekiwanych przez nas wartości. Aby to poprawić musimy przeprowadzić trening sieci. Konfigurujemy więc sposób obliczania błędu pomiędzy wartością oczekiwaną, a zwracaną oraz wybieramy metodę optymalizacji – wpisujemy:

model.compile(optimizer=’rmsprop’, loss=’mae’, metrics=[‘mae’])

Następnie uruchamiamy szkolenie poleceniem:

model.fit(data_x, data_y, epochs=1000)

W moim przypadku wystarczyło 1000 iteracji (parametr epochs) dla uzyskania satysfakcjonującego wyniku. Aby ocenić, czy efekt szkolenia jest dla nas akceptowalny obliczamy wynik dla potrzebnych nam 4 punktów i sprawdzamy, czy znak odpowiada oczekiwanej przez nas wartości. Ja otrzymałem:

array([[-127.08677 ],
[ 0.72788846],
[ 0.72788846],
[-127.08033 ]], dtype=float32)

Jeżeli wynik jest satysfakcjonujący możemy wygenerować model TensorFlow lite i zapisać go do pliku. W tym celu uruchamiamy kod:

converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()
open(‘xor.tflite’, ‘wb’).write(tflite_model)

Teraz pobieramy wygenerowany plik. Klikamy ikonę folderu w lewej części okna, co powoduje rozwinięcie panelu Pliki pokazanego na rysunku 3 i pobieramy plik xor.tflite.

Rysunek 3. Pobieramy wygenerowany plik z wytrenowanym modelem sieci

Jeżeli nie wiemy, w którym miejscu w drzewie katalogów został on zapisany, możemy wywołać w nowym polu typu Kod polecenie pwd. Zawartość pobranej sieci możemy podejrzeć na przykład za pomocą programu Neutron [3], albo bezpośrednio w środowisku STM32CubeIDE.

Mikrokontroler

Sieć jest już zaprojektowana. Musimy teraz przygotować program dla mikrokontrolera. Utworzymy go w środowisku STM32CubeIDE, które można pobrać z [4]. Jest ono zbudowane na bazie edytora Eclipse z dołączonym generatorem kodu Stm32Cube oraz kompilatorem.
Zaczniemy od pobrania biblioteki do sztucznej inteligencji. Z menu wybieramy Help i klikamy Manage embedded software packages. W oknie wybieramy zakładkę STMicroelectronics, a z listy wybieramy X-CUBE-AI w najnowszej wersji i naciskamy Install Now.
Następnie tworzymy nowy projekt, co pokazano na rysunku 4.

Rysunek 4. Tworzymy nowy projekt

W kolejnym oknie (rysunek 5) przechodzimy do zakładki Boards Select (wybór płytek) i znajdujemy płytkę Nucleo, której chcemy użyć. W moim przypadku jest to L476RG.

Rysunek 5. Wybór zestawu startowego

W następnym oknie (rysunek 6) wybieramy nazwę projektu i klikamy Finish, aby zakończyć.

Rysunek 6. Wybór nazwy projektu

Następnie pojawi się pytanie o zainicjalizowanie peryferiów dostępnych na płytce (rysunek 7).

Rysunek 7. Pytanie o inicjalizację peryferiów

Potwierdzamy klikając OK. Jeżeli nie mamy wymaganych bibliotek na dysku, to zostaną one automatycznie pobrane o czym poinformuje nas ekran podobny do pokazanego na rysunku 8.

Rysunek 8. Pobieranie brakujących bibliotek

Gdy projekt zostanie utworzony, zostaniemy zapytani, czy otworzyć ekran konfiguracji (rysunek 9) – klikamy Yes.

Rysunek 9. Pytanie o otwarcie widoku konfiguracji

Zobaczymy otwarte okno pozwalające na konfigurację projektu. Zaczniemy od skonfigurowania sieci neuronowej. W tym celu na górnym pasku klikamy Software package i z rozwiniętego menu wybieramy Select Components (rysunek 10).

Rysunek 10. Dodajemy bibliotekę CubeAI

Ukaże się lista dostępnych paczek (rysunek 11), rozwijamy STMicroelectronics X-CUBE-AI. Zaznaczamy tic w polu X-CUBE-AI Core. Natomiast w zakładce Application wybieramy z listy Application Template (szablon aplikacji). Po zatwierdzeniu możemy dostać pytanie, czy chcemy zmienić ustawienia zegara, w celu zwiększenia wydajności. Zgadzamy się klikając Yes.

Rysunek 11. Lista bibliotek

Wracamy do głównego okna konfiguratora. W prawym panelu rozwijamy Software Package i wybieramy STMicroelectronic.X-CUBE-AI. Pojawi się ekran jak na rysunku 12.

Rysunek 12. Widok konfiguracji biblioteki CubeAI

Widzimy, że w polu Mode zaznaczone są obie opcje – zarówno biblioteka jak i przykładowa aplikacja. W polu configure klikamy przycisk Add network. Zobaczymy okno jak na rysunku 13, w polu tekstowym Model Inputs wpisujemy nazwę naszej sieci. Ja wybrałem xor. Wybieramy rodzaj sieci na TFLite oraz STM32Cube.AI runtime.

Rysunek 13. Konfiguracja nowej sieci

Klikamy Browser i wybieramy pobrany wcześniej plik xor.tflite. Znajdziemy go, także w repozytorium [5] i naciskamy przycisk Analyze. Otworzy się nowe okno, w którym będą pojawiały się informacje o postępach. Gdy się zakończy klikamy OK. Po chwili, w głównej aplikacji zobaczymy parametry naszej sieci oraz ilość potrzebnej pamięci RAM i Flash (rysunek 14).

Rysunek 14. Złożoność sieci oraz ilość wymaganej pamięci

Klikając Show Graph możemy wyświetlić schemat załadowanej sieci (rysunek 15). Ciekawą funkcją jest Validate on Target. Pozwala on na wygenerowanie testowej aplikacji, która zostanie uruchomiona bezpośrednio na naszej płytce. Najpierw jednak musimy wygenerować kod. W tym celu po prostu zapisujemy nasz projekt. Wtedy powinno się pojawić okno dialogowe z pytaniem, czy wygenerować pliki. Potwierdzamy.

Rysunek 15. Graf przedstawiający budowę załadowanej sieci

Jeżeli zostaniemy przeniesieni do widoku edycji kodu musimy z powrotem wrócić do widoku konfiguracji. W tym celu w znajdujący się w lewej części okna panelu z listą plików wybieramy i otwieramy plik o rozszerzeniu .ino. Teraz możemy już nacisnąć przycisk Validate on target. Po kliknięciu zobaczymy okno konfiguracji (rysunek 16).

Rysunek 16. Konfiguracja testu w sprzęcie

Przy ustawieniach portu szeregowego wybieramy Manual i zaznaczamy port szeregowy pod którym system operacyjny widzi naszą płytkę. W drugiej części zaznaczamy Enabled przy automatycznym tworzeniu i wgrywaniu projektu. Pozostałe opcje pozostawiamy bez zmian. Zatwierdzamy klikając OK. Otworzy się okno, w którym zobaczymy informację o postępach oraz wyniki. Możemy dowiedzieć się o ile różnią się wartości uzyskane na komputerze oraz na mikrokontrolerze. Znajdziemy także czas wykonania się poszczególnych warstw (listing 4). Widzimy, że pojedyncze wywołanie naszej sieci zajmuje 36 μs, czyli 2892 cykle zegara. Znajdziemy także rozpiskę jaki procent czasu zajmuje która warstwa. Gdy zapoznamy się z wynikami zamykamy okno naciskając OK.

Listing 4. Podsumowanie wygenerowanej aplikacji

Results for 10 inference(s) – average per inference
device : 0x415 – STM32L4x6xx @80/80MHz fpu,art_lat=4,art_prefetch,art_icache,art_dcache
duration : 0.036ms
CPU cycles : 2892
cycles/MACC : 64.28
c_nodes : 5
c_id m_id desc output ms  %
------------------------------------------------------------------------
0 0 Dense (0x104) (1,1,1,4)/float32/16B 0.009 25.4%
1 0 NL (0x107) (1,1,1,4)/float32/16B 0.004 12.3%
2 1 Dense (0x104) (1,1,1,4)/float32/16B 0.010 28.1%
3 1 NL (0x107) (1,1,1,4)/float32/16B 0.004 12.1%
4 2 Dense (0x104) (1,1,1,1)/float32/4B 0.008 22.1%
------------------------------------------------------------------------
0.036 ms

Wróćmy jednak do naszego projektu. Zostało nam jeszcze skonfigurowanie portów mikrokontrolera. Przechodzimy do głównego ekranu konfiguratora, tego z rysunkiem układu scalonego (rysunek 17).

Rysunek 17. Konfiguracja portów mikrokontrolera

Widzimy, że PA5, do którego jest podłączona dioda LED został już skonfigurowany. Pozostaje nam jeszcze ustawienie wejść dla przycisków. Podłączymy je na płytce stykowej zgodnie z rysunkiem 18.

Rysunek 18. Podłączenie przycisków

Klikamy więc lewym przyciskiem myszy na PortA.8, a następnie PortA.9 i dla każdego z nich wybierzmy GPIO_Input. Następnie w prawym panelu rozwijamy wybieramy System Core i klikamy GPIO (rysunek 19).

Rysunek 19. Konfiguracja wejść/wyjść

W środkowym panelu wybieramy najpierw PortA.8, a następnie PortA.9 i dla każdego włączamy podciąganie do plusa (pull-up). W tym samym panelu widzimy, że PortA.5, do którego podłączona jest dioda LED znajdująca się w zestawie, został już skonfigurowany jako wyjście. Możemy także sprawdzić, że konfiguracja portu szeregowego, podłączonego do USB, także jest gotowa. Gdy zapiszemy projekt, zostaniemy zapytani, czy wygenerować kod, potwierdzamy.

Kolejne okienko pyta, czy przełączyć w tryb edycji kodu. Tu także potwierdzamy.

Printf

Aby uprościć debbugowanie przekierujemy standardową funkcję z biblioteki C: printf na port szeregowy. W tym celu w pliku Core/Src/main.c w bloku /* USER CODE BEGIN PV */ dodajemy definicję dwóch funkcji (listing 5).

Listing 5. Obsługa funkcji printf

int __io_putchar(int ch){
HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}

int _write(int file, char *ptr, int len){
int DataIdx;
for (DataIdx = 0; DataIdx < len; DataIdx++){
__io_putchar(*ptr++);
}
return len;
}

Funkcja _write służy do wysłania ciągu znaków na standardowe wyjściu. W niej każdy znak jest przekazany do funkcji __io_putchar. Druga z nich wywołuje funkcję z biblioteki HAL, która odpowiada za obsługę portu szeregowego. Sam UART został automatycznie skonfigurowany przy wyborze płytki na prędkość 115200.

Drugą częścią jest włączenie wypisywania liczb zmiennoprzecinkowych. Aby ograniczyć rozmiar kodu wynikowego ta opcja jest domyślnie wyłączona. Na prawym pasku z listą plików znajdujemy nasz projekt i klikamy go prawym przyciskiem myszy. Z listy wybieramy Properties. W nowym oknie (rysunek 20) przechodzimy do zakładki C/C++ Build —> Settings —> MCU Settings i zaznaczamy tic przy opcji Use float with printf.

Rysunek 20. Włączamy obsługę zmiennych float w funkcji printf

Uruchamiamy sieć

Czytając dalej plik main.c znajdziemy, że przy konfiguracji wywoływana jest funkcja MX_X_CUBE_AI_Init(), a w głównej pętli programu MX_X_CUBE_AI_Process(). Ich implementację znajdziemy w X-CUBE-AI/App/app_x-cube-ai.c. Musimy zmodyfikować jedynie drugą z nich. Jej zawartość po zmianach pokazuje listing 6. Na początku definiujemy zmienne: nn_input jest tablicą z wejściami dla sieci, a do nn_output zostaną wpisane wyniki. Rozmiar wejścia i wyjścia jest zdefiniowany przez stałe i wynosi odpowiednio 2 i 1, co jest zgodne z tym co skonfigurowaliśmy w TensorFLow. Wejścia sieci są odczytywane z przycisków. Jeśli przycisk jest naciśnięty wpisujemy 127, a w przeciwnym razie –127.

Listing. 6. Funkcja MX_X_CUBE_AI_Process

void MX_X_CUBE_AI_Process(void){
/* USER CODE BEGIN 6 */

ai_i32 batch;

float nn_input[AI_XOR_IN_1_SIZE];
float nn_output[AI_XOR_OUT_1_SIZE];

nn_input[0] = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_8) ? 127.0 : -127.0;
nn_input[1] = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_9) ? 127.0 : -127.0;

ai_input->data = nn_input;
ai_output->data = nn_output;

batch = ai_xor_run(xor, ai_input, ai_output);

HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, nn_output[0]>=0 ? 1 : 0);
printf("%f\r\n", nn_output[0]);

if (batch != 1) {
ai_log_err(ai_xor_get_error(xor), "ai_xr_run");
}

/* USER CODE END 6 */
}

Następnie przypisujemy wskaźniki do tablic z danymi do pól data struktur z wejściem i wyjściem sieci. Wywołanie obliczeń następuje w funkcji ai_xor_run. Jeżeli otrzymany wynik jest dodatni, dioda led jest zaświecana, a gdy ujemny gaszona. Na końcu wypisujemy wartość zwrócona przez sieć na port szeregowy. Cały program można pobrać z repozytorium [5]. Podłączamy przyciski do pinów D7 i D8 zgodnie ze schematem z rysunku 18. Kompilujemy projekt i programujemy płytkę za pomocą przycisku Run(). Gotowy model został pokazany na fotografii tytułowej. Stan diody LD2 powinien być funkcją xor przycisków. Gdy otworzymy monitor portu szeregowego zobaczymy dokładny wynik uzyskany z sieci neuronowej.

Uruchomiliśmy pierwszą sieć neuronowa na mikrokontrolerze. Jednak obliczanie funkcji xor nie jest zbyt intrygującym zadaniem. Dlatego w drugiej części uruchomimy rozpoznawanie kształtów.

Rafał Kozik
rafkozik@gmail.com

Bibliografia:
[1] https://bit.ly/3Q9v7b5
[2] https://bit.ly/3IgeYyR
[3] https://bit.ly/3Grh2Ct
[4] https://bit.ly/2XsPWlH
[5] https://bit.ly/3Q3weJx

Artykuł ukazał się w
Elektronika Praktyczna
styczeń 2023
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik grudzień 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio listopad - grudzień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje listopad - grudzień 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna grudzień 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich grudzień 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów