Nowadays, it is straightforward to get the weather forecast with our smartphones, laptops, and tablets, thanks to internet connectivity. However, have you ever thought of what you would do if you had to track the weather in a remote region with no internet access?
This chapter will teach us how to implement a weather station with machine learning (ML) using the temperature and humidity of the last three hours.
In this chapter, we will focus on dataset preparation and show how to acquire historical weather data from WorldWeatherOnline. After that, we will explain how to train and test a model with TensorFlow (TF). In the last part, we will deploy the model on an Arduino Nano and a Raspberry Pi Pico with TensorFlow Lite for Microcontrollers (TFLu) and build an application to predict whether it will snow.
The goal of this chapter is to guide you through all the development stages of a TF-based application for microcontrollers and explain how to acquire temperature and humidity sensor data.
In this chapter, we're going to implement the following recipes:
To complete all the practical recipes of this chapter, we will need the following:
The source code and additional material are available in the Chapter03 folder of the repository for this book (https://github.com/PacktPublishing/TinyML-Cookbook/tree/main/Chapter03).
The effectiveness of ML algorithms depends heavily on the data used for training. Hence, as we commonly say, the ML model is only good as the dataset. The essential requirement for a good dataset is that the input data must represent the problem we want to solve. Considering our context, we know from physics that temperature and humidity affect snow formation.
Hence, in this recipe, we will show how to gather historical hourly temperature, humidity, and snowfall data to build a dataset for forecasting snow.
The following Colab file (see the Importing weather data from WorldWeatherOnline section in the following repository) contains the code referred to in this recipe:
On the internet, there are various sources from which we can gather hourly weather data, but most of them are not free or have limited usage.
For this recipe, WorldWeatherOnline (https://www.worldweatheronline.com/developer/) has been our choice, which has a free trial period for 30 days and provides the following:
Important Note
The limit on the weather data requests per day has no impact on this recipe.
You only need to sign up on the website to start fetching the data.
WorldWeatherOnline has an API called the Past Historical Weather API (https://www.worldweatheronline.com/developer/premium-api-explorer.aspx) that allows us to gather historical weather conditions from July 1, 2008.
However, we will not directly deal with its native API but use the Python package wwo-hist (https://github.com/ekapope/WorldWeatherOnline) to export the data directly to a pandas DataFrame.
Open Colab and create a new notebook. In the coding area, do the following:
!pip install wwo-hist
from wwo_hist import retrieve_hist_data
retrieve_hist_data is the only function required to acquire data from WorldWeatherOnline and can export to either pandas DataFrames or CSV files.
frequency=1
api_key = 'YOUR_API_KEY'
location_list = [canazei]
df_weather = retrieve_hist_data(api_key,
location_list,
'01-JAN-2011',
'31-DEC-2020',
frequency,
location_label = False,
export_csv = False,
store_df = True)
www-hist will export the data to df_weather, a list of pandas DataFrames.
In this step, we set the input arguments for retrieve_hist_data. Let's unpack all of them:
Once the weather data is retrieved, the console output will report export to canazei completed!.
t_list = df_weather[0].tempC.astype(float).to_list()
h_list = df_weather[0].humidity.astype(float).to_list()
s_list = df_weather[0].totalSnow_cm.astype(float).to_ list()
The generated df_weather[] dataset includes several weather conditions for each requested date and time. For example, we can find the pressure in millibars, cloud coverage in percentage, visibility in kilometers, and, of course, the physical quantities that we're interested in:
In this final step, we export the hourly temperature, humidity, and snowfall in cm to three lists using the to_list() method.
Now, we have all we need to prepare the dataset for forecasting the snow.
Preparing a dataset is a crucial phase in any ML project because it has implications for the effectiveness of the trained model.
In this recipe, we will put into action two techniques to make the dataset more suitable to get a more accurate model. These two techniques will balance the dataset with standardization and bring the input features into the same numerical range.
The following Colab file (see the Preparing the dataset section in the following repository) contains the code referred to in this recipe:
The temperature and humidity of the last three hours are our input features. If you wonder why we use the last three hours' weather conditions, it is just so we have more input features and Increase the chance of higher classification accuracy.
To get ready for the dataset preparation, we need to know why the dataset needs to be balanced and why the raw input features should not be used for training. These two aspects will be examined in the following subsections.
An unbalanced dataset is a dataset where one of the classes has considerably more samples than the others. Training with an unbalanced dataset could produce a model with high accuracy but that's incapable of solving our problem. For example, consider a dataset where one of the two classes has 99% of the samples. If the network miss-classified the minority class, we would still have 99% accuracy, but the model would be ineffective.
Therefore, we require a balanced dataset with roughly the same input samples for each output category.
Balancing a dataset can be done with the following techniques:
As we can see, despite the variety of techniques, there is not an overall best solution to fix an unbalanced dataset. The method or methods to adopt will depend on the problem to solve.
Our input features exist in different numerical ranges. For example, humidity is always between 0 and 100, while the temperature on the Celsius scale can be negative and has a smaller positive numerical range than humidity.
This is a typical scenario when dealing with various physical quantities and could impact the effectiveness of the training.
Generally, if the input features have different numerical ranges, the ML model may not generalize properly because it will be influenced more by the features with more significant values. Therefore, the input features need to be rescaled to ensure that each input feature contributes equally during training. Furthermore, another benefit of feature scaling in neural networks is that it helps converge the gradient descent faster toward the minima.
Z-score is a common scaling technique adopted in neural networks, and it is defined with the following formula:
Let's break down this formula:
Z-score can bring the input features to a similar numerical range, but not necessarily between zero and one.
Continue working on the Colab file and follow the following steps to discover how to balance the dataset and rescale the input features with Z-score:
def binarize(snow, threshold):
if snow > threshold:
return 1
else:
return 0
s_bin_list = [binarize(snow, 0.5) for snow in s_list]
cm = plt.cm.get_cmap('gray_r')
sc = plt.scatter(t_list, h_list, c=s_bin_list, cmap=cm, label="Snow")
plt.figure(dpi=150)
plt.colorbar(sc)
plt.legend()
plt.grid(True)
plt.title("Snow(T, H)")
plt.xlabel("Temperature - °C")
plt.ylabel("Humidity - %")
plt.show()
The preceding code generates the following scatter plot:
In the preceding chart, the x-axis is the temperature, the y-axis is the humidity, and the black dot is the snow formation.
As you can observe from the distribution of the black dots, there are cases where the snow formation is reported for temperatures well above 0°C.
To simplify the recipe, we can ignore these cases and consider 2° C as the maximum temperature for the snow formation.
def gen_label(snow, temperature):
if snow > 0.5 and temperature < 2:
return "Yes"
else:
return "No"
snow_labels = [gen_label(snow, temp) for snow, temp in zip(s_list, t_list)]
Since we are only forecasting snow, only two classes are needed: Yes, it snows, or No, it does not snow. At this scope, we convert totalSnow_cm to the corresponding class (Yes or No) through the gen_label() function. The mapping function assigns Yes when totalSnow_cm exceeds 0.5 cm and the temperature is below 2° C.
csv_header = ["Temp0", "Temp1", "Temp2", "Humi0", "Humi1", "Humi2", "Snow"]
df_dataset = pd.DataFrame(list(zip(t_list[:-2], t_list[1:-1], t_list[2:], h_list[:-2], h_list[1:-1], h_list[2:], snow_labels[2:])), columns = csv_header)
If t0 is the current time, the values stored in the dataset are as follows:
Therefore, we just need a zip and a few indices calculations to build the dataset.
df0 = df_dataset[df_dataset['Snow'] == "No"]
df1 = df_dataset[df_dataset['Snow'] == "Yes"]
if len(df1.index) < len(df0.index):
df0_sub = df0.sample(len(df1.index))
df_dataset = pd.concat([df0_sub, df1])
else:
df1_sub = df1.sample(len(df0.index))
df_dataset = pd.concat([df1_sub, df0])
The original dataset is unbalanced because, in the selected location, it typically snows during the winter season, which lasts from December to March. The following bar chart shows that the No class represents 87% of all cases, so we need to apply one of the techniques shown in the Getting ready section to balance the dataset.
Since the minority class has many samples (~5000), we can randomly undersample the majority class so the two categories have the same number of observations.
t_list = df_dataset['Temp0'].tolist()
h_list = df_dataset['Humi0'].tolist()
t_list = t_list + df_dataset['Temp2'].tail(2).tolist()
h_list = h_list + df_dataset['Humi2'].tail(2).tolist()
You can get all the temperature (or humidity) values from the Temp0 (or Humi0) column and the last two records of the Temp2 (or Humi2) column.
Next, calculate the mean and standard deviation of the temperature and humidity input features:
t_avg = mean(t_list)
h_avg = mean(h_list)
t_std = std(t_list)
h_std = std(h_list)
print("COPY ME!")
print("Temperature - [MEAN, STD] ", round(t_avg, 5), round(t_std, 5))
print("Humidity - [MEAN, STD] ", round(h_avg, 5), round(h_std, 5))
The expected output is as follows:
Copy the mean and standard deviation values printed in the output log because they will be required when deploying the application on the Arduino Nano and Raspberry Pi Pico.
Finally, scale the input features with Z-score:
def scaling(val, avg, std):
return (val - avg) / (std)
df_dataset['Temp0']=df_dataset['Temp0'].apply(lambda x: scaling(x, t_avg, t_std))
df_dataset['Temp1']=df_dataset['Temp1'].apply(lambda x: scaling(x, t_avg, t_std))
df_dataset['Temp2']=df_dataset['Temp2'].apply(lambda x: scaling(x, t_avg, t_std))
df_dataset['Humi0']=df_dataset['Humi0'].apply(lambda x: scaling(x, h_avg, h_std))
df_dataset['Humi1']=df_dataset['Humi1'].apply(lambda x: scaling(x, h_avg, h_std))
df_dataset['Humi2']=df_dataset['Humi2'].apply(lambda x: scaling(x, h_avg, h_std))
The following charts compare the raw and scaled input feature distributions:
As you can observe from the charts, Z-score provides roughly the same value range (the x axis) for both features.
Now, the dataset is ready to be used for training our snow forecast model!
The model designed for forecasting the snow is a binary classifier, and it is illustrated in the following diagram:
The network consists of the following layers:
In this recipe, we will train the preceding model with TF.
The following Colab file (see the Training the ML model with TF section in the following repository) contains the code referred to in this recipe:
The model designed in this recipe has one input and output node. The input node provides the six input features to the network: the temperature and humidity for each of the last three hours.
The model consumes the input features and returns the probability of the class in the output node. Since the sigmoid function produces the output, the result is between zero and one and considered No when it is below 0.5; otherwise, it's Yes.
In general, we consider the following four sequential steps when training a neural network:
In this recipe, we will use TF and scikit-learn to implement them.
Scikit-Learn (https://scikit-learn.org/stable/) is a higher-level Python library for implementing generic ML algorithms, such as SVMs, random forests, and logistic regression. It is not a DNN-specific framework but rather a software library for a wide range of ML algorithms.
The following steps show how to train the model presented in the Getting ready section with TF:
f_names = df_dataset.columns.values[0:6]
l_name = df_dataset.columns.values[6:7]
x = df_dataset[f_names]
y = df_dataset[l_name]
labelencoder = LabelEncoder()
labelencoder.fit(y.Snow)
y_encoded = labelencoder.transform(y.Snow)
This step converts the output labels (Yes and No) to numerical values since neural networks can only deal with numbers. We use scikit-learn to transform the target labels to integer values (zero and one). The conversion requires calling the following three functions:
After transform(), the encoded labels are available in y_encoded.
# Split 1 (85% vs 15%)
x_train, x_validate_test, y_train, y_validate_test = train_test_split(x, y_encoded, test_size=0.15, random_state = 1)
# Split 2 (50% vs 50%)
x_test, x_validate, y_test, y_validate = train_test_split(x_validate_test, y_validate_test, test_size=0.50, random_state = 3)
The following diagram shows how we split the train, validation, and test datasets:
These three datasets are as follows:
From the original dataset, we assign 85% to the training dataset, 7.5% to the validation dataset, and 7.5% to the test dataset. With this split, the validation and test dataset will have roughly 1,000 samples each, enough to see if the model works properly.
The dataset splitting is done with the train_test_split() function from scikit-learn which splits the dataset into training and test datasets. The split proportion is defined with the test_size (or train_size) input argument, representing the input dataset's percentage to include in the test (or train) split.
We call this function twice to generate the three different datasets. The first split generates the 85% training dataset by providing test_size=0.15. The second split produces the validation and test datasets by halving the 15% dataset from the first split.
model = tf.keras.Sequential()
model.add(layers.Dense(12, activation='relu', input_shape=(len(f_names),)))
model.add(layers.Dropout(0.2))
model.add(layers.Dense(1, activation='sigmoid'))
model.summary()
The preceding code generates the following output:
The summary reports useful architecture information about the neural network model, such as the layer types, the output shapes, and the number of trainable weights required.
Important Note
In TinyML, it is important to keep an eye on the number of weights because it is related to the program's memory utilization.
In this step, we initialize the training parameters, such as the following:
Once we have initialized the training parameters, we can train the model.
NUM_EPOCHS=20
BATCH_SIZE=64
history = model.fit(x_train, y_train, epochs=NUM_EPOCHS, batch_size=BATCH_SIZE, validation_data=(x_validate, y_validate))
During training, TF reports the loss and accuracy after each epoch on both the train and validation datasets, as shown in the following screenshot:
accuracy and loss are the accuracy and loss on the train data, while val_accuracy and val_loss are the accuracy and loss on the validation data.
It is best to rely on the accuracy and loss of the validation data to prevent overfitting and to see how the model behaves on unseen data.
loss_train = history.history['loss']
loss_val = history.history['val_loss']
acc_train = history.history['accuracy']
acc_val = history.history['val_accuracy']
epochs = range(1, NUM_EPOCHS + 1)
def plot_train_val_history(x, y_train, y_val, type_txt):
plt.figure(figsize = (10,7))
plt.plot(x, y_train, 'g', label='Training'+type_txt)
plt.plot(x, y_val, 'b', label='Validation'+type_txt)
plt.title('Training and Validation'+type_txt)
plt.xlabel('Epochs')
plt.ylabel(type_txt)
plt.legend()
plt.show()
plot_train_val_history(epochs, loss_train, loss_val, "Loss")
plot_train_val_history(epochs, acc_train, acc_val, "Accuracy")
The preceding code plots the following two charts:
From the plots of the accuracy and loss during training, we can see the trend of the model's performance. The trend tells us whether we should train less to avoid overfitting or more to prevent underfitting. The validation accuracy and loss are at their best around ten epochs in our case. Therefore, we should consider terminating the training earlier to prevent overfitting. To do so, you can either re-train the network for ten epochs or use the EarlyStopping Keras function to stop training when a monitored performance metric has stopped improving. You can discover more about EarlyStopping at the following link: https://www.tensorflow.org/api_docs/python/tf/keras/callbacks/EarlyStopping.
model.save("snow_forecast")
SavedModel is a directory containing the following:
Therefore, the preceding command creates the snow_forecast folder, which you can explore using the file explorer pane on the left of Colab.
We have finally in our hands a model to forecast the snow!
Accuracy and loss are not enough to judge the model's effectiveness. In general, accuracy is a good performance indicator if the dataset is balanced, but it does not tell us the strengths and weaknesses of our model. For instance, what classes do we recognize with high confidence? What frequent mistakes does the model make?
This recipe will judge the model's effectiveness by visualizing the confusion matrix and evaluating the recall, precision, and F1-score performance metrics.
The following Colab file (see the Evaluating the model's effectiveness section in the following repository) contains the code referred to in this recipe:
To complete this recipe, we need to know what a confusion matrix is and which performance metrics we can use to understand whether the model works fine.
The following subsections will examine these performance indicators.
A confusion matrix is an NxN matrix reporting the number of correct and incorrect predictions on the test dataset.
For our binary classification model, we have a 2x2 matrix like the one in the following diagram:
The four values reported in the previous confusion matrix are as follows:
Ideally, we would like to have 100% accuracy, therefore, zero in the gray cells (FN and FP) of the confusion matrix reported in Figure 3.10. In fact, from the confusion matrix, we can calculate the accuracy using the following formula:
However, as previously mentioned, we are more interested in alternative performance metrics. These performance indicators are described in the following subsection.
The first performance metric evaluated is recall, defined as follows:
This metric tells us how many of all positive ("Yes") samples we predicted correctly. Recall should be as high as possible.
However, this metric does not consider the misclassification of negative samples. In short, the model could be excellent at classifying positive samples but incapable of classifying negative ones.
For this reason, there is another metric that takes into consideration FPs. It is precision, defined as follows:
This metric tells us how many predicted positive classes ("yes") were actually positive. Precision should be as high as possible.
Another key performance metric combines recall and precision with a single formula. It is F-score, defined as follows:
This formula helps us to evaluate the recall and precision metrics at the same time. Also, a high F-score implies a good model performance.
The following steps will teach us how to visualize the confusion matrix and calculate the recall, precision, and F-score metrics:
y_test_pred = model.predict(x_test)
y_test_pred = (y_test_pred > 0.5).astype("int32")
cm = sklearn.metrics.confusion_matrix(y_test, y_test_pred)
index_names = ["Actual No Snow", "Actual Snow"]
column_names = ["Predicted No Snow", "Predicted Snow"]
df_cm = pd.DataFrame(cm, index = index_names, columns = column_names)
plt.figure(figsize = (10,7))
sns.heatmap(df_cm, annot=True, fmt='d', cmap="Blues")
plt.figure(figsize = (10,7))
The previous code produces the following output:
The confusion matrix is obtained with the following two steps:
From Figure 3.11, we can see that the samples are mainly distributed in the leading diagonal, and there are more FPs than FNs. Therefore, although the network is suitable for detecting snow, we should expect some false detections.
TN = cm[0][0]
TP = cm[1][1]
FN = cm[1][0]
FP = cm[0][1]
precision = TP / (TP + FP)
recall = TP / (TP + FN)
f_score = (2 * recall * precision) / (recall + precision)
print("Recall: ", round(recall, 3))
print("Precision: ", round(precision, 3))
print("F-score: ", round(f_score, 3))
The preceding code prints the following information on the output console:
As we can see from the expected results, Recall equals 0.983, so our model can forecast the snow with high confidence. However, the Precision is lower, 0.808. This metric shows that we should expect some false alarms from our model. Finally, the value of 0.887 obtained for the F-score tells us that Recall and Precision are balanced. Therefore, we have a good ML model in our hands capable of forecasting the snow with the input features provided.
The model is now trained and validated. Hence, it is time to make it suitable for microcontroller deployment.
Exporting the trained network as SavedModel saves the training graphs such as the network architecture, weights, training variables, and checkpoints. Therefore, the generated TF model is perfect for sharing or resuming a training session but not suitable for microcontroller deployment for the following reasons:
Since our target device has computational and memory constraints, it is crucial to transform the trained model into something compact.
This recipe will teach how to quantize and convert the trained model into a lightweight, memory-efficient, and easy-to-parse exporting format with TensorFlow Lite (TFLite). The generated model will then be converted to a C-byte array, suitable for microcontroller deployments.
The following Colab file (see the Quantizing the model with TFLite converter section in the following recipe) contains the code referred to in this recipe:
The main ingredients used in this recipe are the TFLite converter and quantization.
TFLite (https://www.tensorflow.org/lite) is a deep learning framework specifically for inference on edge devices such as smartphones or embedded platforms.
As reported in the following diagram, TFLite provides a set of tools for the following:
The lightweight model representation used by TFLite is identified with the .tflite extension, and it is internally represented as FlatBuffers (https://google.github.io/flatbuffers/). The FlatBuffers format offers a flexible, easy-to-parse, and memory-efficient structure. The TFLite converter is responsible for converting the TF model to FlatBuffers and applying optimizations based on 8-bit integer quantization to reduce the model size and improve latency.
An indispensable technique to make the model suitable for microcontrollers is quantization.
Model quantization, or simply quantization, has three significant advantages:
This widely adopted technique applies the quantization after training and converts the 32-bit floating-point weights to 8-bit integer values. To understand how quantization works, consider the following C-like function that approximates a 32-bit floating-point value using an 8-bit value:
float dequantize(int8 x, float zero_point, float scale) {
return ((float)x - zero_point) * scale;
}
In the proceeding code, x is the quantized value represented as an 8-bit signed integer value, while scale and zero_point are the quantization parameters. The scale parameter is used to map our quantized value to the floating-point domain and vice versa. zero_point is the offset to consider for the quantized range.
To understand why the zero_point could not be zero, consider the following floating-point input distribution that we want to scale to the 8-bit range:
The proceeding figure shows that the input floating-point distribution is not zero-centered but shifted toward the negative range. Therefore, if we simply scaled the floating-point values to 8-bit, we could have the following:
Therefore, it would be inefficient to assign zero to zero_point since we could dedicate a larger range to the negative values to reduce their quantization error, defined as follows:
When zero_point is not zero, we commonly call the quantization asymmetric because we assign a different range of values for the positive and negative sides, as shown in the following diagram:
When zero_point is zero, we commonly call the quantization symmetric because it is symmetric about zero, as we can see in the following diagram:
Commonly, we apply symmetric quantization to the model's weights and asymmetric quantization to the input and output of the layers.
The scale and zero_point values are the only parameters required for quantization and are commonly provided in the following ways:
The following diagram visually describes per-tensor and per-channel quantization:
Commonly, we adopt the per-tensor approach except for the weights and biases of the convolution and depth-wise convolution layers.
The following steps show how to use the TFLite converter to quantize and produce a suitable model for microcontrollers:
def representative_data_gen():
for i_value in tf.data.Dataset.from_tensor_slices(x_test).batch(1).take(100):
i_value_f32 = tf.dtypes.cast(i_value, tf.float32)
yield [i_value_f32]
This step is commonly called generating a representative dataset, and it is essential to reduce the risk of an accuracy drop in the quantization. In fact, the converter uses this set of samples to find out the range of the input values and then estimate the quantization parameters. Typically, a hundred samples is enough and can be taken from the test or training dataset. In our case, we used the test dataset.
converter = tf.lite.TFLiteConverter.from_saved_model("snow_forecast")
# Representative dataset
converter.representative_dataset = tf.lite.RepresentativeDataset(representative_data_gen)
# Optimizations
converter.optimizations = [tf.lite.Optimize.DEFAULT]
# Supported ops
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
# Inference input/output type
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
In this step, we configure the TFLite converter to apply the 8-bit quantization. The input arguments passed to the tool are as follows:
Once we have initialized the TFLite converter, we can execute the conversion:
tflite_model_quant = converter.convert()
open("snow_forecast_model.tflite", "wb").write(tflite_model_quant)
!apt-get update && apt-get -qq install xxd
!xxd -i snow_forecast_model.tflite > model.h
The previous command outputs a C header file (the -i option) containing the TFLite model as an unsigned char array with many hexadecimal numbers. However, in the Getting ready section, we mentioned that the model is a file with a .tflite extension. Therefore, why do we need this extra conversion? The conversion to a C-byte array is crucial for deploying the model on microcontrollers because the .tflite format requires an additional software library into our application to load the file from memory. We need to remember that most microcontrollers do not have OS and native filesystem support. Therefore, the C-byte array format allows us to integrate the model directly into the application. The other important reason for this conversion is that the .tflite file does not allow keeping the weights in program memory. Since every byte matters and the SRAM has a limited capacity, keeping the model in program memory is generally more memory efficient when the weights are constant.
Now, you can download the generated model.h file from Colab's left pane. The TFLite model is stored in the snow_forecast_model_tflite array.
As we know, the Arduino Nano and Raspberry Pi Pico have unique hardware features that make them ideal for tackling different development scenarios. For example, the Arduino Nano have a built-in temperature and humidity sensor so that we do not need external components for our project with this board.
In this recipe, we will show how to read the temperature and humidity sensor data on an Arduino Nano.
The following Arduino sketch contains the code referred to in this recipe:
There are no particular new things to know to accomplish this task. Therefore, this Getting ready section will give just an overview of the main characteristics of the built-in temperature and humidity sensor on the Arduino Nano.
The Arduino Nano board integrates the HTS221 (https://www.st.com/resource/en/datasheet/HTS221.pdf) sensor from ST (https://www.st.com/content/st_com/en.html) for relative humidity and temperature measurements.
The sensor is ultra-compact (2x2mm) and provides the measurements through two digital serial interfaces. The following table reports the main characteristics of this sensing element:
As we can see from the table, the sensor is extremely low-power since it has a current power consumption in the range of µA.
Create a new sketch on the Arduino IDE and follow the following steps to initialize and test the temperature and humidity sensor on an Arduino Nano:
#include <Arduino_HTS221.h>
#define READ_TEMPERATURE() HTS.readTemperature()
#define READ_HUMIDITY() HTS.readHumidity()
The reason for defining the preceding two C macros is because the Raspberry Pi Pico will use different functions to read the temperature and humidity from the sensor. Therefore, it is more practical to have a common interface so that our Arduino Nano and Raspberry Pi Pico applications can share most of their code.
void setup() {
Serial.begin(9600);
while (!Serial);
if (!HTS.begin()) {
Serial.println("Failed initialization of HTS221!");
while (1);
}
}
The serial peripheral will be used to return the classification result.
Important Note
As reported in the FAQ of the Arduino Nano 33 BLE Sense Board, due to self-heating, when the board is powered by USB, the HTS221 becomes unreliable and shows an offset in each reading that changes with the external temperature.
We recommend disconnecting the USB cable and powering the board with batteries through the VIN pin to obtain reliable measurements. Refer to Chapter 2, Prototyping with Microcontrollers, to discover how to power an Arduino Nano with batteries.
In contrast to the Arduino Nano, the Raspberry Pi Pico requires an external sensor module and an additional software library to measure the temperature and humidity.
In this recipe, we will show how to use the DHT22 sensor with a Raspberry Pico to get temperature and humidity measurements.
The following Arduino sketch contains the code referred to in this recipe:
The temperature and humidity sensor module considered for the Raspberry Pi Pic is the low-cost AM2302 that you can get either from Adafruit (https://www.adafruit.com/product/393) or Amazon.
As shown in the following diagram, the AM2302 module is a through-hole component with three pins that integrates the DHT22 temperature and humidity sensor:
The following table summarizes the key characteristics of the DHT22 sensor:
Note
DHT11 is another popular temperature and humidity sensor from the DHT family. However, we cannot use it in our recipe because it has a good temperature accuracy only between 0 °C and 50 °C.
In contrast to the HTS221 sensor on the Arduino Nano, the DHT22 has a digital protocol to read the temperature and humidity values. The protocol must be implemented through the GPIO peripheral and requires precise timing to read the data. Luckily, Adafruit developed a software library (https://github.com/adafruit/DHT-sensor-library) for the DHT sensors, so we do not have to worry about it. The library will deal with the low-level software details and provide an API to read the temperature and humidity.
Create a new sketch on the Arduino IDE and follow these steps to use the DHT22 sensor with a Raspberry Pi Pico:
A pop-up window will tell us that the library has been successfully imported.
#include <DHT.h>
const int gpio_pin_dht_pin = 10;
DHT dht(gpio_pin_dht_pin, DHT22);
The DHT object is initialized with the GPIO pin used by the DHT22 data terminal (G10) and the type of DHT sensor (DHT22).
#define READ_TEMPERATURE() dht.readTemperature()
#define READ_HUMIDITY() dht.readHumidity()
The function's name must be the same as the ones of the previous recipe. This step ensures a common function interface to measure the temperature and humidity on an Arduino Nano and a Raspberry Pi Pico.
void setup() {
Serial.begin(9600);
while(!Serial);
dht.begin();
delay(2000);
}
The DHT22 can only return new data after two seconds. For this reason, we use delay(2000) to wait for the peripheral to be ready.
Now, the Raspberry Pi Pico can read temperature and humidity sensor data.
As we know, the model's input features are the scaled and quantized temperature and humidity of the last three hours. Using this data, the ML model can forecast whether it will snow.
In this recipe, we will see how to prepare the input data to feed into our ML model. In particular, this recipe will teach us how to acquire, scale, and quantize the sensor measurements and keep them in temporal order using a circular buffer.
The following Arduino sketch contains the code referred to in this recipe:
Our application will acquire the temperature and humidity every hour to get the necessary input features for the model. However, how can we keep the last three measurements in temporal order to feed the network the correct input?
In this recipe, we will use a circular buffer, a fixed-sized data structure that implements a First-In-First-Out (FIFO) buffer.
This data structure is well-suited to buffering data streams and can be implemented with an array and a pointer that tells where to store the element in memory. The following diagram shows how a circular buffer with three elements works:
As you can see from the preceding diagram, this data structure simulates a ring since the pointer (Ptr) is incremented after each data insertion and wraps around when it reaches the end.
The instructions provided in this section apply to both the Arduino Nano and the Raspberry Pi Pico. Follow these steps to see how to create a circular buffer and prepare the input for the model inference:
#define NUM_HOURS 3
int8_t t_vals [NUM_HOURS] = {0};
int8_t h_vals [NUM_HOURS] = {0};
int cur_idx = 0;
These two arrays will be used to keep the scaled and quantized temperature and humidity measurements in temporal order.
float tflu_i_scale = 0.0f;
int32_t tflu_i_zero_point = 0;
The following recipe will extract these quantization parameters from the TF model. Please note that scale (tflu_i_scale) is a floating-point number, while zero point (tflu_i_zero_point) is a 32-bit integer.
constexpr int num_reads = 3;
void loop() {
float t = 0.0f;
float h = 0.0f;
for(int i = 0; i < num_reads; ++i) {
t += READ_TEMPERATURE();
h += READ_HUMIDITY();
delay(3000);
}
t /= (float)num_reads;
h /= (float)num_reads;
Capturing more than one sample is, in general, a good way to have a robust measurement.
constexpr float t_mean = 2.05179f;
constexpr float h_mean = 82.30551f;
constexpr float t_std = 7.33084f;
constexpr float h_std = 14.55707f;
t = (t – t_mean) / t_std;
h = (h – h_mean) / h_std;
Z-score requires the mean and standard deviation, which we calculated in the second recipe of this chapter.
t_vals[cur_idx] = (t / tflu_i_scale) + tflu_i_zero_point;
h_vals[cur_idx] = (h / tflu_i_scale) + tflu_i_zero_point;
The samples are quantized using the tflu_i_scale and tflu_i_zero_point input quantization parameters. Remember that the model's input uses the per-tensor quantization schema, so all input features need to be quantized with the same scale and zero-point.
t_vals[cur_idx] = t;
h_vals[cur_idx] = h;
cur_idx = (cur_idx + 1) % NUM_HOURS;
delay(2000);
The pointer of the circular buffer (cur_index) is updated after each data insertion with the following formula:
In the preceding formula, is the size of the circular buffer, while and are the pointer's values before and after the data insertion.
Important Note
At the end of the code, we have a delay of two seconds, but it should be one hour in the actual application. The pause of two seconds is used to avoid waiting too long in our experiments.
Here we are, with our first ML application on microcontrollers.
In this recipe, we will finally discover how to use TensorFlow Lite for Microcontrollers (TFLu) to run the TFLite model on an Arduino Nano and a Raspberry Pi Pico.
The following Arduino sketch contains the code referred to in this recipe:
To get ready with this last recipe, we need to know how inference with TFLu works.
TFLu was introduced in Chapter 1, Getting Started with TinyML, and is the software component that runs TFLite models on microcontrollers.
Inference with TFLu typically consists of the following:
When dealing with microcontrollers, it is necessary to optimize every line of our code to keep the memory footprint at the minimum and maximize performance.
For this reason, TFLu also integrates software libraries to get the best performance from various target processors. For example, TFLu supports CMSIS-NN (https://www.keil.com/pack/doc/CMSIS/NN/html/index.html), a free and open source software library developed by Arm for optimized DNN operators on Arm Cortex-M architectures. These optimizations are relevant to the critical DNN primitives such as convolution, depth-wise convolution, and the fully connected layer, and are compatible with the Arm processors in the Arduino Nano and Raspberry Pi Pico.
At this point, you might have one question in mind: How can we use TFLu with CMSIS-NN?
We do not need to install additional libraries because TFLu for Arduino comes with CMSIS-NN. Therefore, Arduino will automatically include CMSIS-NN to run the inference faster when using TFLu.
The instructions in this section are applicable to both the Arduino Nano and the Raspberry Pi Pico. The following steps will show how to use TFLu to run the snow forecast TFLite model on our boards:
A folder window will appear from which you can drag and drop the TFLu model's file.
Once the file has been imported, include the C header in the sketch:
#include "model.h"
#include <TensorFlowLite.h>
#include <tensorflow/lite/micro/all_ops_resolver.h>
#include <tensorflow/lite/micro/micro_error_reporter.h>
#include <tensorflow/lite/micro/micro_interpreter.h>
#include <tensorflow/lite/schema/schema_generated.h>
#include <tensorflow/lite/version.h>
The main header files are as follows:
For more information about the header files, we recommend reading the Get started with microcontroller guide in the TF documentation (https://www.tensorflow.org/lite/microcontrollers/get_started_low_level).
const tflite::Model* tflu_model = nullptr;
tflite::MicroInterpreter* tflu_interpreter = nullptr;
TfLiteTensor* tflu_i_tensor = nullptr;
TfLiteTensor* tflu_o_tensor = nullptr;
tflite::MicroErrorReporter tflu_error;
constexpr int tensor_arena_size = 4 * 1024;
byte tensor_arena[tensor_arena_size] __attribute__((aligned(16)));
The global variables declared in this step are as follows:
The preceding variables are generally required in all TFLu-based applications.
tflu_model = tflite::GetModel(snow_forecast_model_tflite);
tflite::AllOpsResolver tflu_ops_resolver;
The TFLu interpreter will use this interface to find the function pointers for each DNN operator.
tflu_interpreter = new tflite::MicroInterpreter(tflu_model, tflu_ops_resolver, tensor_arena, tensor_arena_size, &tflu_error);
tflu_interpreter->AllocateTensors();
tflu_i_tensor = tflu_interpreter->input(0);
tflu_o_tensor = tflu_interpreter->output(0);
const auto* i_quantization = reinterpret_cast<TfLiteAffineQuantization*>(tflu_i_tensor->quantization.params);
onst auto* o_quantization = reinterpret_cast<TfLiteAffineQuantization*>(tflu_o_tensor->quantization.params);
tflu_i_scale = i_quantization->scale->data[0];
tflu_i_zero_point = i_quantization->zero_point->data[0];
tflu_o_scale = o_quantization->scale->data[0];
tflu_o_zero_point = o_quantization->zero_point->data[0];
The quantization parameters are returned in the TfLiteAffineQuantization object, containing two arrays for the scale and zero point parameters. Since both input and output tensors adopt a per-tensor quantization, each array stores a single value.
const int idx0 = cur_idx;
const int idx1 = (cur_idx - 1 + NUM_HOURS) % NUM_HOURS;
const int idx2 = (cur_idx - 2 + NUM_HOURS) % NUM_HOURS;
tflu_i_tensor->data.int8[0] = t_vals[idx2];
tflu_i_tensor->data.int8[1] = t_vals[idx1];
tflu_i_tensor->data.int8[2] = t_vals[idx0];
tflu_i_tensor->data.int8[3] = h_vals[idx2];
tflu_i_tensor->data.int8[4] = h_vals[idx1];
tflu_i_tensor->data.int8[5] = h_vals[idx0];
Since we need the last three samples, we use the following formula to read the elements from the circular buffer:
In the preceding formula, N is the sampling instant and is the corresponding circular buffer's pointer. For example, if t0 is the current instant, N = 0 means the sample at time t = t0, N = 1 the sample at time t = t0 – 1, and N = 2 the sample at time t = t0 – 2.
tflu_interpreter->Invoke();
int8_t out_int8 = tflu_o_tensor->data.int8[0];
float out_f = (out_int8 - tflu_o_zero_point) * tflu_o_scale;
if (out_f > 0.5) {
Serial.println("Yes, it snows");
}
else {
Serial.println("No, it does not snow");
}
The dequantization of the output is done with the tflu_o_scale and tflu_o_zero_point quantization parameters retrieved in the setup() function. Once we have the floating-point representation, the output is considered No when it is below 0.5; otherwise, it's Yes.
Now, compile and upload the program on the microcontroller board. The serial terminal in the Arduino IDE will report Yes, it snows or No, it does not snow, depending on whether snow is forecast.
To check if the application can forecast snow, you can simply force the temperature to -10 and the humidity to 100. The model should return Yes, it snows on the serial terminal.
44.200.94.150