diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 82e84e5..9532a9d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -301,3 +301,18 @@ jobs: READTHEDOCS: 'True' run: | make html + + check-types: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install Python dependencies + run: | + pip install -r requirements.txt -r requirements.dev.txt + - name: Run type checks + run: make check_types diff --git a/Makefile b/Makefile index 637bc43..d595b8f 100644 --- a/Makefile +++ b/Makefile @@ -196,7 +196,7 @@ QEMU_ARCH ?= armv7emdp QEMU_FIRMWARE = $(QEMU_PORT_DIR)/build-$(QEMU_BOARD)/firmware.elf # Build firmware for QEMU -.PHONY: qemu_build +.PHONY: qemu_build check_types qemu_build: $(MAKE) -C $(QEMU_PORT_DIR) BOARD=$(QEMU_BOARD) MICROPY_HEAP_SIZE=1024000 @@ -207,3 +207,13 @@ check_qemu: $(QEMU_FIRMWARE) python3 $(abspath tools/run_qemu_tests.py) --board $(QEMU_BOARD) --arch $(QEMU_ARCH) --abi-version $(MPY_ABI_VERSION) --mount $(abspath .) +# Type-check examples/ and tests/ against stubs/ with mypy. +# Uses --follow-imports=skip to skip imports of third-party packages not +# installed. +# Excludes subdirs that have duplicate module names or 3rd-party deps. +check_types: + MYPYPATH=./stubs python3 -m mypy \ + --follow-imports=skip \ + --exclude tests/tools/ \ + tests/ + diff --git a/docs/api_reference.rst b/docs/api_reference.rst index c3e1240..f1b446e 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -18,7 +18,7 @@ API reference .. _emlearn_trees: -emlearn_trees - Decision tree ensembles +emlearn_trees - Decision tree ensemble inference ---------------------------------------------------- .. autoapimodule:: emlearn_trees @@ -31,8 +31,27 @@ emlearn_linreg - Linear regression .. autoapimodule:: emlearn_linreg :members: +.. _emlearn_linreg: +emlearn_logreg - Logistic regression classification +---------------------------------------------------- + +.. autoapimodule:: emlearn_logreg + :members: + +emlearn_extratrees - Learning decision tree ensembles +---------------------------------------------------- + +.. autoapimodule:: emlearn_extratrees + :members: + +emlearn_plsr - Partial Least Squares Regression (PLSR) +---------------------------------------------------- + +.. autoapimodule:: emlearn_plsr + :members: + .. _emlearn_cnn: -emlearn_cnn - Convolutional Neural Networks +emlearn_cnn - Convolutional Neural Networks inference ---------------------------------------------------------- .. autoapimodule:: emlearn_cnn diff --git a/requirements.dev.txt b/requirements.dev.txt index e36e389..f11ec80 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -5,3 +5,4 @@ sphinx_rtd_theme>=0.5.2 sphinx-gallery>=0.10.1 sphinx-autodoc-typehints>=3.0.1 myst_parser>=4.0.0 +mypy>=2.0.0 diff --git a/stubs/emlearn_arrayutils.pyi b/stubs/emlearn_arrayutils.pyi index 4e78692..32f1121 100644 --- a/stubs/emlearn_arrayutils.pyi +++ b/stubs/emlearn_arrayutils.pyi @@ -1,6 +1,4 @@ - # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Array utility functions diff --git a/stubs/emlearn_cnn.pyi b/stubs/emlearn_cnn.pyi index fe39c2c..391f694 100644 --- a/stubs/emlearn_cnn.pyi +++ b/stubs/emlearn_cnn.pyi @@ -1,5 +1,4 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Convolutional Neural Network module. diff --git a/stubs/emlearn_cnn_fp32.pyi b/stubs/emlearn_cnn_fp32.pyi new file mode 120000 index 0000000..c33a7a8 --- /dev/null +++ b/stubs/emlearn_cnn_fp32.pyi @@ -0,0 +1 @@ +emlearn_cnn.pyi \ No newline at end of file diff --git a/stubs/emlearn_cnn_int8.pyi b/stubs/emlearn_cnn_int8.pyi new file mode 120000 index 0000000..c33a7a8 --- /dev/null +++ b/stubs/emlearn_cnn_int8.pyi @@ -0,0 +1 @@ +emlearn_cnn.pyi \ No newline at end of file diff --git a/stubs/emlearn_extratrees.pyi b/stubs/emlearn_extratrees.pyi new file mode 100644 index 0000000..49475f5 --- /dev/null +++ b/stubs/emlearn_extratrees.pyi @@ -0,0 +1,126 @@ +# Stub file (PEP 484) with API definitions and documentation for native module + +""" +Extra Trees (Extremely Randomized Trees) classification. + +Tree-based ensemble classifier with randomized splits. +""" + +import array +import typing +from typing import Iterator + + +class Model(): + """An ExtraTrees ensemble model + + Note: Use emlearn_extratrees.new to construct an instance + """ + def train(self, X : array.array, y : array.array) -> None: + """ + Train the model on the given data. + + :param X: Training features, int16 array of n_samples * n_features + :param y: Training labels, int16 array of n_samples + """ + pass + + def train_init(self, X : array.array, y : array.array) -> None: + """ + Initialize step-by-step training. + + :param X: Training features, int16 array of n_samples * n_features + :param y: Training labels, int16 array of n_samples + """ + pass + + def train_step(self) -> int: + """ + Process one node of step-by-step training. + + :return: 1 if training is complete, 0 if more steps needed + """ + pass + + def predict(self, features : array.array, probabilities : array.array) -> int: + """ + Make a prediction and fill the probabilities buffer. + + :param features: Input features, int16 array of n_features + :param probabilities: Output buffer, float32 array of n_classes (modified in-place) + :return: Predicted class index + """ + pass + + def get_n_features(self) -> int: + """ + Get the number of features. + """ + pass + + def get_n_classes(self) -> int: + """ + Get the number of classes. + """ + pass + + def get_n_trees(self) -> int: + """ + Get the number of trees in the ensemble. + """ + pass + + def get_n_nodes_used(self) -> int: + """ + Get the number of nodes currently used in the model. + """ + pass + + def get_n_trees_trained(self) -> int: + """ + Get the number of trees trained so far. + """ + pass + + def __del__(self) -> None: + pass + + +def new(n_features : int, n_classes : int, + *, n_trees : int = ..., max_depth : int = ..., + min_samples_leaf : int = ..., n_thresholds : int = ..., + subsample_ratio : float = ..., feature_subsample_ratio : float = ..., + max_nodes : int = ..., max_samples : int = ..., + rng_seed : int = ..., use_global_feature_range : bool = ...) -> Model: + """ + Construct a new ExtraTrees model. + + :param n_features: Number of input features + :param n_classes: Number of output classes + :param n_trees: Number of trees in the ensemble + :param max_depth: Maximum tree depth + :param min_samples_leaf: Minimum samples at a leaf node + :param n_thresholds: Random thresholds drawn per feature split + :param subsample_ratio: Fraction of samples used per tree (0.0-1.0) + :param feature_subsample_ratio: Fraction of features considered per split + :param max_nodes: Maximum pre-allocated nodes + :param max_samples: Maximum pre-allocated samples + :param rng_seed: Random number generator seed + :param use_global_feature_range: Use global feature range + """ + pass + + +def train_steps(model : Model, X : array.array, y : array.array) -> Iterator[int]: + """ + Generator for step-by-step training. + + Yields the number of trees trained so far after each step. + Returns when training is complete. + + :param model: ExtraTrees model instance + :param X: Training features, int16 array of n_samples * n_features + :param y: Training labels, int16 array of n_samples + :return: Iterator yielding trees_trained count + """ + pass diff --git a/stubs/emlearn_fft.pyi b/stubs/emlearn_fft.pyi index 4c07762..8f601e0 100644 --- a/stubs/emlearn_fft.pyi +++ b/stubs/emlearn_fft.pyi @@ -1,6 +1,5 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Fast Fourier Transform (FFT) diff --git a/stubs/emlearn_iir.pyi b/stubs/emlearn_iir.pyi index ebe8843..590ca11 100644 --- a/stubs/emlearn_iir.pyi +++ b/stubs/emlearn_iir.pyi @@ -1,6 +1,5 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Infinite Impulse Response (IIR) filters diff --git a/stubs/emlearn_iir_q15.pyi b/stubs/emlearn_iir_q15.pyi new file mode 120000 index 0000000..a80335a --- /dev/null +++ b/stubs/emlearn_iir_q15.pyi @@ -0,0 +1 @@ +emlearn_iir.pyi \ No newline at end of file diff --git a/stubs/emlearn_kmeans.pyi b/stubs/emlearn_kmeans.pyi new file mode 100644 index 0000000..2a694fb --- /dev/null +++ b/stubs/emlearn_kmeans.pyi @@ -0,0 +1,51 @@ +# Stub file (PEP 484) with API definitions and documentation for native module + +""" +K-Means clustering with C-accelerated distance computation. +""" + +import array +import typing +from typing import Iterator + + +def euclidean_argmin(vectors : array.array, point : array.array) -> typing.Tuple[int, int]: + """ + Find the closest centroid/vector to a given point. + + :param vectors: All vectors concatenated, uint8 array of n_vectors * n_channels + :param point: Query point, uint8 array of n_channels + :return: Tuple of (closest_vector_index, squared_distance) + """ + pass + + +def cluster_iter(values : array.array, centroids : array.array, + assignments : array.array, features : int, + max_iter : int = ..., stop_changes : int = ...) -> Iterator[int]: + """ + Perform K-Means clustering with iteration yielding. + + :param values: Input data, uint8 array of n_samples * features + :param centroids: Initial centroids, uint8 array of n_clusters * features (modified in-place) + :param assignments: Output assignments, uint8 array of n_samples (modified in-place) + :param features: Number of features + :param max_iter: Maximum number of iterations + :param stop_changes: Stop if changes in assignments drop below this count + :return: Iterator yielding number of assignment changes per iteration + """ + pass + + +def cluster(values : array.array, centroids : array.array, + features : int, **kwargs) -> array.array: + """ + Run K-Means clustering and return sample assignments. + + :param values: Input data, uint8 array of n_samples * features + :param centroids: Initial centroids, uint8 array of n_clusters * features + :param features: Number of features + :param kwargs: Additional arguments (max_iter, stop_changes) + :return: Array of cluster assignments for each sample + """ + pass diff --git a/stubs/emlearn_linreg.pyi b/stubs/emlearn_linreg.pyi index b23fead..9667a47 100644 --- a/stubs/emlearn_linreg.pyi +++ b/stubs/emlearn_linreg.pyi @@ -1,5 +1,4 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Linear Regression with support for training/learning/fitting as well as inference/predictions. diff --git a/stubs/emlearn_logreg.pyi b/stubs/emlearn_logreg.pyi new file mode 100644 index 0000000..c86bc3b --- /dev/null +++ b/stubs/emlearn_logreg.pyi @@ -0,0 +1,165 @@ +# Stub file (PEP 484) with API definitions and documentation for native module + +""" +Logistic Regression (binary and multiclass). + +For more complicated training needs, use the `train` or `train_batches` helper functions. +""" + +import array +import typing + + +class Model(): + """A logistic regression model + + Note: Use emlearn_logreg.new to construct an instance + """ + def predict(self, features : array.array, probs : array.array, logits : array.array) -> int: + """ + Run prediction and return the predicted class index. + + :param features: Input features, float32 array of n_features + :param probs: Output buffer for probabilities, float32 array of n_classes (modified in-place) + :param logits: Output buffer for logits, float32 array of n_classes (modified in-place) + :return: Index of the predicted class + """ + pass + + def step(self, X : array.array, y : array.array, + logits : array.array, probs : array.array, + bias_grads : array.array) -> None: + """ + Perform a single training iteration. + + :param X: Batch features, float32 array of n_samples * n_features + :param y: Batch targets (one-hot), float32 array of n_samples * n_classes + :param logits: Workspace buffer, float32 array of n_classes + :param probs: Workspace buffer, float32 array of n_classes + :param bias_grads: Workspace buffer, float32 array of n_classes + """ + pass + + def get_weights(self, out : array.array) -> None: + """ + Copy the model weights into an output buffer. + + :param out: Float32 buffer of n_features * n_classes (modified in-place) + """ + pass + + def set_weights(self, weights : array.array) -> None: + """ + Set the model weights from a buffer. + + :param weights: Float32 array of n_features * n_classes + """ + pass + + def get_bias(self, out : array.array) -> None: + """ + Copy the model biases into an output buffer. + + :param out: Float32 buffer of n_classes (modified in-place) + """ + pass + + def set_bias(self, bias : array.array) -> None: + """ + Set the model biases. + + :param bias: Float32 array of n_classes + """ + pass + + def get_n_features(self) -> int: + """ + Get the number of features. + """ + pass + + def get_n_classes(self) -> int: + """ + Get the number of classes. + """ + pass + + def score_logloss(self, X : array.array, y : array.array, + logits : array.array, probs : array.array) -> float: + """ + Compute the log-loss on a dataset. + + :param X: Features, float32 array of n_samples * n_features + :param y: Targets (one-hot), float32 array of n_samples * n_classes + :param logits: Workspace buffer, float32 array of n_classes + :param probs: Workspace buffer, float32 array of n_classes + :return: The log-loss value + """ + pass + + def __del__(self) -> None: + pass + + +def new(n_features : int, n_classes : int, + learning_rate : float, lambda_l2 : float, lambda_l1 : float) -> Model: + """ + Construct a new logistic regression model. + + :param n_features: Number of input features + :param n_classes: Number of output classes + :param learning_rate: Learning rate for gradient descent + :param lambda_l2: L2 regularization strength + :param lambda_l1: L1 regularization strength + """ + pass + + +def train(model : Model, X_train : array.array, y_train : array.array, + max_iterations : int = ..., + tolerance : float = ..., + check_interval : int = ..., + divergence_factor : float = ..., + score_limit : typing.Optional[float] = ..., + verbose : int = ...) -> typing.Tuple[int, float]: + """ + Full-dataset training loop for logistic regression. + + :param model: Logistic regression model instance + :param X_train: Training features, float32 array of n_samples * n_features + :param y_train: Training targets (one-hot), float32 array of n_samples * n_classes + :param max_iterations: Maximum number of training steps + :param tolerance: Convergence tolerance + :param check_interval: Check convergence every N iterations + :param divergence_factor: Divergence detection factor + :param score_limit: Stop training if score reaches this value + :param verbose: Verbosity level + :return: Tuple of (iterations_completed, final_loss) + """ + pass + + +def train_batches(model : Model, + batch_iter_factory : typing.Callable[[], typing.Iterator[typing.Tuple[array.array, array.array]]], + max_iterations : int = ..., + tolerance : float = ..., + check_interval : int = ..., + divergence_factor : float = ..., + score_limit : typing.Optional[float] = ..., + verbose : int = ..., + score_batches : typing.Optional[typing.Callable[[Model], float]] = ...) -> typing.Tuple[int, float]: + """ + Train logistic regression model using externally provided batches. + + :param model: Logistic regression model instance + :param batch_iter_factory: Callable returning a fresh iterator over (X_batch, y_batch) tuples + :param max_iterations: Maximum number of training epochs + :param tolerance: Convergence tolerance + :param check_interval: Check convergence every N epochs + :param divergence_factor: Divergence detection factor + :param score_limit: Stop training if score reaches this value + :param verbose: Verbosity level + :param score_batches: Optional callable computing score from the model + :return: Tuple of (iterations_completed, final_loss) + """ + pass diff --git a/stubs/emlearn_neighbors.pyi b/stubs/emlearn_neighbors.pyi index b817858..7225d8f 100644 --- a/stubs/emlearn_neighbors.pyi +++ b/stubs/emlearn_neighbors.pyi @@ -1,6 +1,5 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ K-nearest neighbors diff --git a/stubs/emlearn_plsr.pyi b/stubs/emlearn_plsr.pyi new file mode 100644 index 0000000..241770e --- /dev/null +++ b/stubs/emlearn_plsr.pyi @@ -0,0 +1,114 @@ +# Stub file (PEP 484) with API definitions and documentation for native module + +""" +Partial Least Squares Regression (PLSR) + +Implemented using *eml_plsr* from the emlearn C library (https://github.com/emlearn/emlearn). +""" + +import array +import typing + + +class Model(): + """A PLSR model + + Note: Use emlearn_plsr.new to construct an instance + """ + def fit_start(self, X : array.array, y : array.array) -> None: + """ + Start iterative fitting of the model. + + :param X: Training input data, float32 array of n_samples * n_features + :param y: Training target data, float32 array of n_samples + """ + pass + + def step(self, tolerance : float = ...) -> None: + """ + Perform one NIPALS iteration step for the current component. + + :param tolerance: Convergence tolerance (optional) + """ + pass + + def finalize_component(self) -> None: + """ + Finalize the current component and prepare for the next one. + """ + pass + + def is_converged(self) -> bool: + """ + Check if the current component has converged. + """ + pass + + def is_complete(self) -> bool: + """ + Check if all components have been trained. + """ + pass + + def predict(self, x : array.array) -> float: + """ + Predict the target value for a single sample. + + :param x: Input features, float32 array of n_features + :return: Predicted target value + """ + pass + + def get_convergence_metric(self) -> float: + """ + Get the convergence metric for the current component. + """ + pass + + def set_auto_center(self, value : bool) -> None: + """ + Enable or disable automatic centering of data during fitting. + + :param value: Whether to auto-center + """ + pass + + def get_auto_center(self) -> bool: + """ + Get the auto-centering flag. + """ + pass + + def __del__(self) -> None: + pass + + +def new(n_samples : int, n_features : int, n_components : int) -> Model: + """ + Construct a new PLSR model. + + :param n_samples: Number of training samples + :param n_features: Number of input features + :param n_components: Number of PLS components + """ + pass + + +def fit(model : Model, X_train : array.array, y_train : array.array, + max_iterations : int = ..., + tolerance : float = ..., + check_interval : int = ..., + verbose : int = ...) -> typing.Tuple[int, float]: + """ + Train the PLSR model using a NIPALS iterative fitting loop. + + :param model: PLSR model instance + :param X_train: Training input data, float32 array of n_samples * n_features + :param y_train: Training target data, float32 array of n_samples + :param max_iterations: Maximum iterations per component + :param tolerance: Convergence tolerance + :param check_interval: Check convergence every N iterations + :param verbose: Verbosity level + :return: Tuple of (total_iterations, final_convergence_metric) + """ + pass diff --git a/stubs/emlearn_trees.pyi b/stubs/emlearn_trees.pyi index a914277..bf29eb0 100644 --- a/stubs/emlearn_trees.pyi +++ b/stubs/emlearn_trees.pyi @@ -1,5 +1,4 @@ # Stub file (PEP 484) with API definitions and documentation for native module -# Is called .py because Sphinx autodoc currently does not support .pyi files """ Tree-based models (Random Forest et.c.) diff --git a/tests/test_extratrees.py b/tests/test_extratrees.py index 14e089d..a994f29 100644 --- a/tests/test_extratrees.py +++ b/tests/test_extratrees.py @@ -287,4 +287,4 @@ def test_train_step_same_as_train(): except Exception as e: print("Error during debugging: {}".format(e)) import sys - sys.print_exception(e) + sys.print_exception(e) # type: ignore[attr-defined] diff --git a/tests/test_extratrees_xor.py b/tests/test_extratrees_xor.py index 94f14f2..d868f0a 100644 --- a/tests/test_extratrees_xor.py +++ b/tests/test_extratrees_xor.py @@ -226,6 +226,6 @@ def test_xor_different_values(): except Exception as e: print(f"❌ Error: {e}") import sys - sys.print_exception(e) + sys.print_exception(e) # type: ignore[attr-defined] print("\n" + "="*60)