Modeling for Arduino / ESP32

This page covers strategies for training and configuring machine learning models optimized for deployment on resource-constrained microcontrollers like Arduino and ESP32. The pyruleanalyzer library exports trained scikit-learn models directly to self-contained C++ sketches (.ino) that run entirely on-device with zero external dependencies.

When deploying to Arduino/ESP32, memory is the primary constraint: every tree node consumes Flash ROM for constants and SRAM at runtime. This guide explains how to choose model hyperparameters, reduce footprint, and validate compatibility before uploading your sketch.

Why pyruleanalyzer for Arduino?

The full_pipeline function bridges Python ML training and embedded deployment in a single step:

  1. Train in Python — Use scikit-learn’s full API (Decision Tree, Random Forest, GBDT).

  2. Compile to C arrays — Trees are serialized as static const arrays (feature indices, thresholds, children pointers, leaf values).

  3. Generate self-contained sketch — A single .ino file includes the model data, inference engine, and Arduino skeleton code.

  4. Memory validation — Built-in Flash/SRAM estimation with board-specific thresholds alerts you before upload fails.

Resource Constraints by Board

Different boards have dramatically different memory budgets. The table below summarizes the key limits:

Board

Flash (program storage)

SRAM (data)

Arduino Uno / Nano

~30 KB usable (~140 KB total, bootloader takes space)

2 KB

Arduino Mega 2560

~256 KB usable (~1 MB total)

8 KB

Arduino Leonardo

~28 KB usable (32 KB total, 2 KB boot loader)

2.5 KB

ESP32 (DevKit)

~4 MB Flash

512 KB SRAM

Decision Tree Strategies

A single Decision Tree is the most Arduino-friendly model type because it has no aggregation overhead.

Recommended settings for Uno/Nano:

from pyruleanalyzer import full_pipeline

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Decision Tree',
    max_depth=8,          # Keep depth <= 10 for Uno/Nano
    generate_arduino_sketch=True,
    board_model='uno',
)

Node count formula: A binary tree with depth d has at most 2^d - 1 nodes. Each node stores:

  • feature_idx: int32 (4 bytes)

  • threshold: double (8 bytes)

  • children_left: int32 (4 bytes)

  • children_right: int32 (4 bytes)

  • value: int32 × n_classes (4n bytes)

Total per node ≈ 20 + 4n bytes. For d=8 with 3 classes: 255 nodes × 32 bytes = ~8 KB Flash — well within Uno limits.

For larger trees, use pruning or reduce max_depth until the memory check passes.

Random Forest Strategies

Random Forests aggregate predictions from many trees via majority voting. Each tree adds its own node arrays to Flash, so footprint grows linearly with n_estimators.

Rule of thumb: On Uno/Nano, aim for total nodes across all trees < 2000 (roughly ~64 KB Flash).

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Random Forest',
    n_estimators=10,      # Start small: 5-15 trees
    max_depth=6,          # Shallower trees to compensate for ensemble size
    generate_arduino_sketch=True,
    board_model='uno',
)

Optimization tips:

  • Use max_depth <= 8 — deeper trees multiply memory by 2× per extra level.

  • Start with n_estimators=5 and incrementally add trees while monitoring the Flash percentage in results['memory_check'].

  • Consider min_samples_leaf=5 or higher to prune leaf-level noise without adding depth.

Gradient Boosting Decision Trees (GBDT) Strategies

GBDT models are the least Arduino-friendly because predictions require summing outputs from all trees rather than simple voting. Each tree’s value arrays must remain in SRAM simultaneously during inference, doubling the baseline footprint compared to Random Forest with the same number of estimators.

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Gradient Boosting Decision Trees',
    n_estimators=20,          # Keep low: 10-30 trees
    max_depth=3,              # GBDT works well with shallow "stumps"
    learning_rate=0.1,        # Lower learning rate compensates for fewer estimators
    generate_arduino_sketch=True,
    board_model='mega',       # Recommend Mega or ESP32 for GBDT
)

When to use GBDT on Arduino:

  • Board has >= 8 KB SRAM (Mega) or >= 512 KB SRAM (ESP32).

  • You need higher accuracy than Random Forest provides at the same depth.

  • Tree depth is limited to <= 4 and total nodes < 3000.

For Uno/Nano, prefer Decision Trees or small Random Forests over GBDT.

Feature Selection Strategies

Each feature adds a column to the sensor-reading code (the read_features() function) and increases the size of the features[] array passed to the inference engine. While the feature arrays themselves are small, more features mean:

  1. More sensor hardware — Each feature typically maps to one analog/digital pin or I²C sensor reading.

  2. Slower inference — Feature reads add latency in every loop iteration.

  3. Overfitting risk — Extra irrelevant features can degrade model generalization.

Recommendations:

  • Use max_features in Random Forest to limit per-tree feature usage:

    results = full_pipeline(
        train_csv='dataset.csv',
        target_feature='Target',
        model_type='Random Forest',
        max_features=4,      # Only use 4 features per tree
        generate_arduino_sketch=True,
        board_model='uno',
    )
    
  • Apply feature importance from the trained Decision Tree to select only top-K features before training:

    model = PyRuleAnalyzer.new_model(model='Decision Tree')
    model.fit(X_train, y_train)
    
    importances = model.feature_importance_
    top_features = [f for _, f in sorted(zip(importances, feature_names), reverse=True)[:5]]
    
    X_reduced = X_train[top_features]  # Train on reduced set
    

Memory Validation

The full_pipeline function automatically estimates Flash and SRAM usage after training. The memory_check result contains these key fields:

Field

Description

flash_bytes

Estimated Flash (program storage) used by tree arrays

flash_percent

Percentage of board’s Flash capacity consumed

ram_bytes

Estimated SRAM (data) needed for inference buffers

ram_percent

Percentage of board’s SRAM capacity consumed

board_model

Target board name (e.g., “uno”, “mega”, “esp32”)

fits_flash

True if flash_bytes <= max_flash_bytes

fits_ram

True if ram_bytes <= max_ram_bytes

flash_max_bytes

Maximum Flash for the target board (bytes)

ram_max_bytes

Maximum SRAM for the target board (bytes)

Checking memory compatibility manually:

mc = results['memory_check']

if not mc['fits_flash']:
    print(f"WARNING: Model exceeds Flash by {mc['flash_percent']:.1f}%")

if not mc['fits_ram']:
    print(f"WARNING: Model exceeds SRAM by {mc['ram_percent']:.1f}%")

Depth vs. Accuracy Trade-off

Tree depth is the single biggest lever for controlling Arduino footprint. Here’s a practical guide:

Max Depth

Max Nodes (per tree)

Flash per tree (~3 classes)

Recommended for

4

15

~480 bytes

Uno, Nano (large ensembles OK)

6

63

~2 KB

Uno, Nano (small ensembles)

8

255

~8 KB

Uno (single tree), Mega

10

1023

~32 KB

Mega only

14+

16K+

>500 KB

ESP32 only

Choosing a Board

Use the board_model parameter to target your board. Set it to 'auto' (default) to let pyruleanalyzer auto-detect and pick the best-fit board:

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Decision Tree',
    generate_arduino_sketch=True,
    board_model='auto',  # Auto-select best board
)

Board presets used internally:

Board Model

Flash Max (bytes)

SRAM Max (bytes)

Default Baud

uno

143360

2048

115200

nano

143360

2048

115200

mega

1048576

8192

115200

leonardo

32768

2560

115200

esp32

4194304

524288

115200

Example Workflows

Workflow 1: Small model for Arduino Uno (Iris dataset)

from pyruleanalyzer import full_pipeline

results = full_pipeline(
    train_csv='files/dataset_iris.csv',
    target_feature='Target',
    model_type='Decision Tree',
    max_depth=8,
    generate_arduino_sketch=True,
    board_model='uno',
)

mc = results['memory_check']
print(f"Flash: {mc['flash_percent']:.1f}% | SRAM: {mc['ram_percent']:.1f}%")
# Output example: Flash: 5.2% | SRAM: 8.1%

Workflow 2: Medium model for Arduino Mega (Wine dataset)

results = full_pipeline(
    train_csv='files/dataset_wine.csv',
    target_feature='Target',
    model_type='Random Forest',
    n_estimators=10,
    max_depth=8,
    generate_arduino_sketch=True,
    board_model='mega',
)

Workflow 3: Large model for ESP32 (Breast Cancer dataset)

results = full_pipeline(
    train_csv='files/dataset_heart.csv',
    target_feature='Target',
    model_type='Gradient Boosting Decision Trees',
    n_estimators=30,
    max_depth=4,
    learning_rate=0.1,
    generate_arduino_sketch=True,
    board_model='esp32',
)

Workflow 4: Auto-detect best board

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Random Forest',
    n_estimators=15,
    max_depth=6,
    generate_arduino_sketch=True,
    board_model='auto',  # Auto-select
)

print(f"Auto-selected: {results['memory_check']['board_model'].upper()}")

Optimization Checklist

Before uploading your sketch to a physical board, verify the following:

  • [ ] fits_flash is True in memory_check.

  • [ ] fits_ram is True in memory_check.

  • [ ] Total nodes across all trees < 2000 for Uno/Nano (< 10K for Mega).

  • [ ] max_depth is minimized while maintaining acceptable accuracy.

  • [ ] n_estimators is the minimum needed for your target accuracy.

  • [ ] read_features() placeholders are filled with real sensor code.

  • [ ] Serial baud rate matches your monitor configuration (default 115200).

Troubleshooting

Sketch exceeds Flash on Uno/Nano

Reduce max_depth by 2 and retrain. If still too large, reduce n_estimators or switch to a single Decision Tree.

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Decision Tree',
    max_depth=6,              # Reduced from 8
    generate_arduino_sketch=True,
    board_model='uno',
)

SRAM exceeded on Mega

GBDT models with many estimators consume significant SRAM for intermediate values. Reduce n_estimators or switch to Random Forest:

results = full_pipeline(
    train_csv='dataset.csv',
    target_feature='Target',
    model_type='Random Forest',     # Switch from GBDT
    n_estimators=15,                # Reduced from 30
    max_depth=4,
    generate_arduino_sketch=True,
    board_model='mega',
)

API Reference

full_pipeline() parameters relevant to Arduino export:

Parameter

Description

Default

generate_arduino_sketch

Generate self-contained .ino sketch

False

board_model

Target board: auto, uno, nano, mega, leonardo, esp32

"auto"

serial_baud

Serial port baud rate

115200

include_sensor_placeholders

Add TODO comments in read_features()

True

max_flash_percent

Max Flash usage for compatibility check (%)

90.0

max_ram_percent

Max SRAM usage for compatibility check (%)

85.0

RuleClassifier.export_to_arduino_ino() method:

result = model.classifier.export_to_arduino_ino(
    filepath='model.ino',      # Output file path
    board_model='uno',         # Target board
    serial_baud=115200,        # Serial baud rate
    include_sensor_placeholders=True,  # TODO placeholders
)

result = {
    'ino_path': '/path/to/model.ino',
    'memory_check': {...},     # Flash/SRAM estimation dict
    'metadata': {...},          # Model metadata embedded in sketch
}

See Also

  • arduino — Full Arduino/ESP32 Sketch Export tutorial (setup, upload, sensor integration)

  • Usage — General RuleClassifier usage and model training

  • Arduino CLI — Command-line upload reference

  • PlatformIO — Cross-platform embedded build system