A model from Python on the ESP32
The neural network from module 3 has thousands of weights and is hard to move to a small chip. For the ESP32 we choose a model that is small and that a person can read: a decision tree. Python will train it from the table classroom.csv and then write a C++ function by itself, which we insert into the program for the ESP32. This is how we get a smart classroom whose rules were learned instead of written by us.
Decision tree
A decision tree is a series of yes/no questions, like in the game “Guess who”: Is the temperature lower than 19.6 °C? If not – is it lower than 26 °C? If yes – is the humidity higher than 64.5%?… At the end of each branch there is an answer (a state).
The program chooses the questions so that each one separates the examples of different states as well as possible. The parameter max_depth=3 says there can be at most three questions in a row – the model stays small and readable.
Step 1: training the tree
# The decision tree learns when the classroom is cold, pleasant, warm or stuffy
import csv
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree
X, y = [], []
with open("classroom.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
X.append([float(row["temperature"]), float(row["humidity"])]) # features
y.append(row["state"]) # label
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
# max_depth=3: the tree may ask at most 3 questions in a row – a small model for a small chip
tree = DecisionTreeClassifier(max_depth=3, random_state=0)
tree.fit(X_train, y_train)
print(f"Accuracy on the test data: {tree.score(X_test, y_test) * 100:.0f} %")
print("Learned rules:")
print(export_text(tree, feature_names=["temperature", "humidity"]))
plot_tree(tree, feature_names=["temperature", "humidity"], class_names=list(tree.classes_),
filled=True, rounded=True, impurity=False, fontsize=8)
plt.show()
Program output:
Accuracy on the test data: 100 %
Learned rules:
|--- temperature <= 19.55
| |--- class: cold
|--- temperature > 19.55
| |--- temperature <= 26.05
| | |--- humidity <= 64.50
| | | |--- class: pleasant
| | |--- humidity > 64.50
| | | |--- class: stuffy
| |--- temperature > 26.05
| | |--- class: warm

The tree found the limits by itself: 19.55 °C (cold), 26.05 °C (warm) and 64.5% humidity (stuffy). These are rules we might have guessed ourselves – but the program extracted them from the data. With data from your classroom, the limits will be different.
Step 2: Python writes C++ code
The tree is a series of if/else statements, so we can write it in C++. This program “walks” through the tree and writes one if for each node – a small example of a program that writes a program:
# Converting the learned tree into a C++ function for the ESP32
import csv
from sklearn.tree import DecisionTreeClassifier
X, y = [], []
with open("classroom.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
X.append([float(row["temperature"]), float(row["humidity"])])
y.append(row["state"])
tree = DecisionTreeClassifier(max_depth=3, random_state=0).fit(X, y) # we train on all the data
t = tree.tree_
names = ["t", "h"] # names of the C++ function parameters
classes = list(tree.classes_) # e.g. ['cold', 'pleasant', 'stuffy', 'warm']
def node(i, indent):
"""Recursively converts a tree node into if/else statements."""
p = " " * indent
if t.children_left[i] == -1: # a leaf of the tree: we return the class number
k = t.value[i][0].argmax()
return f"{p}return {k}; // {classes[k]}\n"
condition = f"{names[t.feature[i]]} <= {t.threshold[i]:.2f}"
return (f"{p}if ({condition}) {{\n" + node(t.children_left[i], indent + 1) +
f"{p}}} else {{\n" + node(t.children_right[i], indent + 1) + f"{p}}}\n")
code = "// This part was made by the program p21_to_cpp.py from the learned decision tree\n"
code += "const char* STATES[] = {" + ", ".join(f'"{k}"' for k in classes) + "};\n\n"
code += "int estimate(float t, float h) {\n" + node(0, 1) + "}\n"
print(code)
with open("model_classroom.h", "w", encoding="utf-8") as f:
f.write(code)
print("Saved to model_classroom.h")
Program output:
// This part was made by the program p21_to_cpp.py from the learned decision tree
const char* STATES[] = {"cold", "pleasant", "stuffy", "warm"};
int estimate(float t, float h) {
if (t <= 19.55) {
return 0; // cold
} else {
if (t <= 26.05) {
if (h <= 64.50) {
return 1; // pleasant
} else {
return 2; // stuffy
}
} else {
return 3; // warm
}
}
}
Saved to model_classroom.h
We checked that the C++ function gives the same answer as the model in Python for more than 500 different measurements. The function returns the number of the state (0–3), and the name is read from the array STATES.
Step 3: the program for the ESP32
The wiring is the same as in the smart classroom: DHT11 on GPIO4, the light ring on GPIO27, the servo on GPIO13. Replace the part between the markers “start/end of the part from Python” with what your program printed.
// A smart classroom that has “learned” when it is cold, pleasant, warm or stuffy
// DHT11 -> GPIO4, light ring (DIN) -> GPIO27, servo (“window”) -> GPIO13
#include <DHT.h>
#include <Adafruit_NeoPixel.h>
#include <ESP32Servo.h>
const int DHT_PIN = 4;
const int RING_PIN = 27;
const int SERVO_PIN = 13;
const int NUM_LEDS = 12;
DHT dht(DHT_PIN, DHT11);
Adafruit_NeoPixel ring(NUM_LEDS, RING_PIN, NEO_GRB + NEO_KHZ800);
Servo window;
// ===== start of the part made by Python (model_classroom.h) =====
// This part was made by the program p21_to_cpp.py from the learned decision tree
const char* STATES[] = {"cold", "pleasant", "stuffy", "warm"};
int estimate(float t, float h) {
if (t <= 19.55) {
return 0; // cold
} else {
if (t <= 26.05) {
if (h <= 64.50) {
return 1; // pleasant
} else {
return 2; // stuffy
}
} else {
return 3; // warm
}
}
}
// ===== end of the part from Python =====
// ring color for each state (same order as in the array STATES)
const uint8_t COLORS[4][3] = {
{0, 0, 255}, // cold – blue
{0, 255, 0}, // pleasant – green
{160, 0, 255}, // stuffy – purple
{255, 0, 0} // warm – red
};
void setup() {
Serial.begin(115200);
dht.begin();
ring.begin();
ring.setBrightness(40);
window.setPeriodHertz(50);
window.attach(SERVO_PIN, 500, 2400);
window.write(0);
}
void loop() {
delay(2000);
float t = dht.readTemperature();
float h = dht.readHumidity();
if (isnan(t) || isnan(h)) {
return;
}
int s = estimate(t, h); // the model decides
Serial.print(t, 1);
Serial.print(" C, ");
Serial.print(h, 0);
Serial.print(" % -> ");
Serial.println(STATES[s]);
ring.fill(ring.Color(COLORS[s][0], COLORS[s][1], COLORS[s][2]));
ring.show();
bool openWindow = (s == 2 || s == 3); // warm or stuffy -> airing
window.write(openWindow ? 90 : 0);
}
| State | Ring | Window |
|---|---|---|
| cold | blue | closed |
| pleasant | green | closed |
| warm | red | open |
| stuffy | purple | open |
- The model on the ESP32 is just a few comparisons – it takes less than a kilobyte and answers in a fraction of a millisecond.
- When you collect new data, you run Python again, replace the function and upload the program – the ESP32 has “learned” something new.
- Unlike the smart classroom from the Arduino course, here there is also the state stuffy – the classroom is aired even when it isn't warm, but the air is humid.
First lesson: training the tree on the class's data and comparing the rules with the data of other classes. Second lesson: transferring to the ESP32 and testing – warm the sensor with your palm, blow on it.
Common misconceptions
- “The model on the ESP32 keeps learning by itself” – here the ESP32 only uses the learned tree; learning happens on the computer, and the new tree is sent to the board again.
- “A deeper tree is always better” – a deeper tree can “memorize” the data (overfitting) and becomes unreadable.
Practice tasks
Try it yourself first, and only then open the solution.
- Compare trees of depth 1, 2, 3, 5 and without a limit: how many leaves (answers) do they have and how accurate are they?
- Make the window stay open for another 60 seconds after airing is no longer needed (without
delayfor waiting). - Why did we choose a decision tree for the ESP32 and not the neural network from module 3?
Solution 1: tree depth
# Task: how the depth of the tree affects accuracy and the number of rules
import csv
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
X, y = [], []
with open("classroom.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
X.append([float(row["temperature"]), float(row["humidity"])])
y.append(row["state"])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=0)
for depth in [1, 2, 3, 5, None]: # None = no limit
tree = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(X_train, y_train)
print(f"depth {str(depth):4}: leaves {tree.get_n_leaves():2}, "
f"accuracy training {tree.score(X_train, y_train) * 100:3.0f} %, test {tree.score(X_test, y_test) * 100:3.0f} %")
Program output:
depth 1 : leaves 2, accuracy training 60 %, test 59 %
depth 2 : leaves 3, accuracy training 81 %, test 82 %
depth 3 : leaves 4, accuracy training 99 %, test 100 %
depth 5 : leaves 6, accuracy training 100 %, test 100 %
depth None: leaves 6, accuracy training 100 %, test 100 %
Depth 3 is enough: bigger trees don't give a better test result, and they have more rules. Depth 1 can ask only one question, so it can't tell four states apart.
Solution 2: window open for another 60 s
// Task: the window stays open for another 60 s after airing is no longer needed
// (replace loop() in the program above with this part; the rest of the program stays the same)
unsigned long lastNeeded = 0; // when airing was last needed
const unsigned long EXTRA_TIME = 60000; // 60 s
void updateWindow(int s) {
if (s == 2 || s == 3) {
lastNeeded = millis(); // warm or stuffy – we remember the moment
}
bool isOpen = lastNeeded != 0 && millis() - lastNeeded < EXTRA_TIME;
window.write(isOpen ? 90 : 0);
}
void loop() {
delay(2000);
float t = dht.readTemperature();
float h = dht.readHumidity();
if (isnan(t) || isnan(h)) {
return;
}
int s = estimate(t, h);
ring.fill(ring.Color(COLORS[s][0], COLORS[s][1], COLORS[s][2]));
ring.show();
updateWindow(s);
}
We remember the moment when it was last “warm” or “stuffy” and keep the window open until 60 s have passed since that moment.
Solution 3: explanation
The tree is very small (a few comparisons), it is easy to convert to C++, and a person can read the rules and check whether they make sense. For two features (temperature and humidity) a network would not be more accurate, but it would be bigger and harder to move to the board.
Check your knowledge
Open the quiz
Quiz: a model on the ESP32
1. What is a decision tree?
2. What does max_depth=3 mean?
3. How does the tree get onto the ESP32?
4. What should you do when you collect new data?
