Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`no_std` MLP inference for learned policies.** `Layer` runs one dense layer of a multi-layer
perceptron — `activation(weights · input + biases)` — over a `MatrixView` of weights and a
`VectorView` of biases, so a policy exported as one flat buffer is read where it sits instead of
being copied onto the stack. `Activation` picks the scalar nonlinearity: `Relu`, `Tanh`, or
`Identity`. Layer widths are const parameters, so a mismatched chain fails to compile.
@Thiago316316 (#83)

- **Zero-copy matrix and vector views.** `Matrix::view` / `view_mut` and `Vector::view` /
`view_mut` hand out `MatrixView` / `VectorView` and their `Mut` counterparts: a flat slice, an
offset, and a stride per axis. Transpose, submatrix, row, column, diagonal, segment, and the
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ https://github.com/user-attachments/assets/ed45ccb5-ca95-4e4b-8399-27d09284b220
- [Collision checking](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/kinematics.md#collision-checking): `CollisionQuery` for sphere/capsule proximity — primitives on tree frames against each other and against world-fixed obstacles, with pair exclusions and fixed capacities.
- [Motion](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/motion.md): `PolylinePath` for waypoint paths with arc-length, closest-point, and lookahead queries, `MinimumSnapPlanner` for the smoothest trajectory through them, and `MotionProfilePlanner` for jerk-limited point-to-point moves with multi-axis synchronization.
- [Mapping](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mapping.md): 2D `OccupancyGrid` and `ScanGeometry`
- [MLP inference](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mlp-inference.md): `Layer` and `Activation` run a trained multi-layer-perceptron policy forward on the robot — the weights and biases stay in flash behind a `MatrixView` / `VectorView` and are never copied onto the stack, and layer widths are const parameters, so a mismatched chain is a build error.

### Core math

Expand Down
1 change: 1 addition & 0 deletions crates/multicalc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- [Collision checking](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/kinematics.md#collision-checking): `CollisionQuery` for sphere/capsule proximity — primitives on tree frames against each other and against world-fixed obstacles, with pair exclusions and fixed capacities.
- [Motion](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/motion.md): `PolylinePath` for waypoint paths with arc-length, closest-point, and lookahead queries, `MinimumSnapPlanner` for the smoothest trajectory through them, and `MotionProfilePlanner` for jerk-limited point-to-point moves with multi-axis synchronization.
- [Mapping](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/mapping.md): 2D `OccupancyGrid` and `ScanGeometry`
- [MLP inference](tutorials/mlp-inference.md): `Layer` and `Activation` run a trained multi-layer-perceptron policy forward on the robot — the weights and biases stay in flash behind a `MatrixView` / `VectorView` and are never copied onto the stack, and layer widths are const parameters, so a mismatched chain is a build error.
### Core math

- [Automatic differentiation](https://github.com/kmolan/multicalc-rust/blob/main/crates/multicalc/tutorials/scalars-and-automatic-differentiation.md): Exact autodiff of any order (total and partial), plus Jacobian and Hessian matrices.
Expand Down
1 change: 1 addition & 0 deletions crates/multicalc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ pub mod gaussian_tables;
pub mod kinematics;
pub mod linear_algebra;
pub mod mapping;
pub mod mlp_inference;
pub mod motion;
pub mod numerical_derivative;
pub mod numerical_integration;
Expand Down
217 changes: 217 additions & 0 deletions crates/multicalc/src/mlp_inference/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Forward-pass inference for a multi-layer perceptron, over borrowed parameters.
//!
//! A learned policy is a stack of dense layers. Each forms one weighted sum per output —
//! `weights · input + biases` — and passes every sum through a scalar [`Activation`]. One layer's
//! output is the next layer's input, and the last one's is the action the policy was trained to
//! produce. Only inference lives here; training belongs on a machine with room for it.
//!
//! The parameters are borrowed rather than owned, because a policy is large next to the board
//! running it: two 64-wide hidden layers over a 22-component observation is some 23 KB as `f32`,
//! against a small Cortex-M's 64 KB of RAM. A [`Layer`] holds a [`MatrixView`] of its weights and
//! a [`VectorView`] of its biases, so nothing is copied and only the activations are written.
//!
//! Widths are const parameters, so a mismatched chain is a build error. Nothing allocates and
//! nothing panics, so this runs under `no_std`.
//!
//! ```
//! use multicalc::linear_algebra::Vector;
//! use multicalc::mlp_inference::{Activation, Layer};
//!
//! // One flat block, the way a trained policy arrives: a 2 -> 3 -> 1 network.
//! let parameters = [
//! 0.5, -0.5, 1.0, 0.0, -1.0, 2.0, // 3x2 hidden weights, row-major
//! 0.0, 1.0, -1.0, // 3 hidden biases
//! 1.0, 1.0, 1.0, // 1x3 output weights
//! 0.5, // 1 output bias
//! ];
//! let (hidden_weights, rest) = parameters.split_at(6);
//! let (hidden_biases, rest) = rest.split_at(3);
//! let (output_weights, output_biases) = rest.split_at(3);
//!
//! let hidden = Layer::<3, 2>::try_from_slices(hidden_weights, hidden_biases, Activation::Relu)?;
//! let output =
//! Layer::<1, 3>::try_from_slices(output_weights, output_biases, Activation::Identity)?;
//!
//! let observation = Vector::new([2.0, 1.0]);
//! let activations = hidden.forward(observation.view())?;
//!
//! // The third hidden unit sums to -1.0, so the rectifier switches it off.
//! assert_eq!(activations.into_array(), [0.5, 3.0, 0.0]);
//! assert_eq!(output.forward(activations.view())?.into_array(), [4.0]);
//! # Ok::<(), multicalc::error::LinalgError>(())
//! ```

use crate::Numeric;
use crate::error::LinalgError;
use crate::linear_algebra::{MatrixView, Vector, VectorView};

/// The scalar function a layer applies to each of its outputs, shaping the raw weighted sum
/// into the range the next layer expects.
///
/// It is also the layer's only nonlinear step: without one, a chain of layers collapses into a
/// single layer no matter how deep it is.
///
/// ```
/// use multicalc::mlp_inference::Activation;
/// assert_eq!(Activation::Relu.apply(-2.0), 0.0);
/// assert_eq!(Activation::Relu.apply(3.0), 3.0);
/// assert_eq!(Activation::Identity.apply(-2.0), -2.0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Activation {
/// Clamps a negative sum to zero and passes a positive one through. The usual hidden-layer
/// choice: one comparison, no `libm` call.
Relu,
/// Squashes into `(-1, 1)`. Bounded, which matters when the value drives an actuator, at the
/// cost of a `libm` call per component.
Tanh,
/// Passes the value through unchanged. The usual output-layer choice, where the value is a
/// physical quantity to report rather than squash.
Identity,
}

impl Activation {
/// Applies the activation to one value.
///
/// ```
/// use multicalc::mlp_inference::Activation;
/// assert_eq!(Activation::Tanh.apply(0.0), 0.0);
/// assert_eq!(Activation::Identity.apply(1.5), 1.5);
/// ```
#[inline]
#[must_use]
pub fn apply<T: Numeric>(self, value: T) -> T {
match self {
Activation::Relu => {
if value > T::ZERO {
value
} else {
T::ZERO
}
}
Activation::Tanh => value.tanh(),
Activation::Identity => value,
}
}
}

/// One dense layer of a multi-layer perceptron: `activation(weights · input + biases)`.
///
/// The parameters are borrowed, not owned, so a policy exported as one flat buffer is read where
/// it sits. Only the intermediate activations are materialized, and those are `OUTPUT` values
/// rather than `OUTPUT`×`INPUT`.
///
/// ```
/// use multicalc::linear_algebra::Vector;
/// use multicalc::mlp_inference::{Activation, Layer};
/// let weights = [0.5, -0.5, 1.0, 0.0, -1.0, 2.0];
/// let biases = [0.0, 1.0, -1.0];
/// let hidden = Layer::<3, 2>::try_from_slices(&weights, &biases, Activation::Relu).unwrap();
/// let input = Vector::new([2.0, 1.0]);
/// assert_eq!(hidden.forward(input.view()).unwrap().into_array(), [0.5, 3.0, 0.0]);
/// ```
#[derive(Debug)]
#[must_use]
pub struct Layer<'data, const OUTPUT: usize, const INPUT: usize, T = f64> {
weights: MatrixView<'data, OUTPUT, INPUT, T>,
biases: VectorView<'data, OUTPUT, T>,
activation: Activation,
}

// Written out rather than derived: a derive would demand `T: Copy`, but what is copied is the pair
// of handles, not the parameters they point at.
impl<'data, const OUTPUT: usize, const INPUT: usize, T> Clone for Layer<'data, OUTPUT, INPUT, T> {
#[inline]
fn clone(&self) -> Self {
*self
}
}
impl<'data, const OUTPUT: usize, const INPUT: usize, T> Copy for Layer<'data, OUTPUT, INPUT, T> {}

impl<'data, const OUTPUT: usize, const INPUT: usize, T> Layer<'data, OUTPUT, INPUT, T> {
/// A layer over parameters that are already viewed.
///
/// ```
/// use multicalc::linear_algebra::{MatrixView, Vector, VectorView};
/// use multicalc::mlp_inference::{Activation, Layer};
/// let weights = [1.0, 0.0, 0.0, 1.0];
/// let biases = [0.0, 0.0];
/// let layer = Layer::new(
/// MatrixView::<2, 2>::try_from_row_major_slice(&weights).unwrap(),
/// VectorView::<2>::try_from_slice(&biases).unwrap(),
/// Activation::Identity,
/// );
/// // Identity weights, no bias, and no squashing, so the input comes back unchanged.
/// let input = Vector::new([2.0, -3.0]);
/// assert_eq!(layer.forward(input.view()).unwrap(), input);
/// ```
#[inline]
pub const fn new(
weights: MatrixView<'data, OUTPUT, INPUT, T>,
biases: VectorView<'data, OUTPUT, T>,
activation: Activation,
) -> Self {
Layer {
weights,
biases,
activation,
}
}

/// A layer over two runs of a parameter buffer: `weights` read row-major as
/// `OUTPUT`×`INPUT`, `biases` as `OUTPUT` components. `OutOfBounds` if either is too short;
/// trailing elements in either slice are ignored.
///
/// ```
/// use multicalc::mlp_inference::{Activation, Layer};
/// let weights = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
/// let biases = [0.0, 0.0];
/// assert!(Layer::<2, 3>::try_from_slices(&weights, &biases, Activation::Relu).is_ok());
/// assert!(Layer::<3, 3>::try_from_slices(&weights, &biases, Activation::Relu).is_err());
/// ```
#[inline]
pub fn try_from_slices(
weights: &'data [T],
biases: &'data [T],
activation: Activation,
) -> Result<Self, LinalgError> {
Ok(Layer::new(
MatrixView::try_from_row_major_slice(weights)?,
VectorView::try_from_slice(biases)?,
activation,
))
}
}

impl<'data, const OUTPUT: usize, const INPUT: usize, T: Numeric> Layer<'data, OUTPUT, INPUT, T> {
/// `activation(weights · input + biases)`, one output component at a time.
///
/// Each output reads the whole input and nothing else, so the components are independent and
/// the order they are computed in does not matter. The activation is applied to the finished
/// sum, never to the individual products.
///
/// ```
/// use multicalc::linear_algebra::Vector;
/// use multicalc::mlp_inference::{Activation, Layer};
/// let weights = [1.0, 1.0, 1.0];
/// let biases = [0.5];
/// let output = Layer::<1, 3>::try_from_slices(&weights, &biases, Activation::Identity).unwrap();
/// let hidden = Vector::new([0.5, 3.0, 0.0]);
/// assert_eq!(output.forward(hidden.view()).unwrap().into_array(), [4.0]);
/// ```
#[inline]
pub fn forward(
&self,
input: VectorView<'_, INPUT, T>,
) -> Result<Vector<OUTPUT, T>, LinalgError> {
let mut result = Vector::<OUTPUT, T>::zeros();
for row_index in 0..OUTPUT {
let weighted_sum = self.weights.try_row(row_index)?.dot(input);
let biased = weighted_sum + *self.biases.try_get(row_index)?;
let slot = result.get_mut(row_index).ok_or(LinalgError::OutOfBounds)?;
*slot = self.activation.apply(biased);
}
Ok(result)
}
}
2 changes: 2 additions & 0 deletions crates/multicalc/src/tutorial_examples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ pub struct Control;
pub struct Motion;
#[doc = include_str!("../tutorials/mapping.md")]
pub struct Mapping;
#[doc = include_str!("../tutorials/mlp-inference.md")]
pub struct MlpInference;
#[doc = include_str!("../tutorials/estimation.md")]
pub struct Estimation;
#[doc = include_str!("../tutorials/random.md")]
Expand Down
1 change: 1 addition & 0 deletions crates/multicalc/tests/suite/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod gaussian_tables;
mod kinematics;
mod linear_algebra;
mod mapping;
mod mlp_inference;
mod motion;
mod numerical_derivative;
mod numerical_integration;
Expand Down
87 changes: 87 additions & 0 deletions crates/multicalc/tests/suite/mlp_inference.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use multicalc::linear_algebra::Vector;
use multicalc::mlp_inference::{Activation, Layer};

#[test]
fn relu_clamps_a_negative_sum_and_leaves_a_positive_one() {
assert_eq!(Activation::Relu.apply(-1.0), 0.0);
assert_eq!(Activation::Relu.apply(0.0), 0.0);
assert_eq!(Activation::Relu.apply(2.5), 2.5);
}

#[test]
fn two_layers_chain_hidden_output_into_the_next_input() {
let hidden_weights = [0.5, -0.5, 1.0, 0.0, -1.0, 2.0]; // 3x2
let hidden_biases = [0.0, 1.0, -1.0];
let output_weights = [1.0, 1.0, 1.0]; // 1x3
let output_biases = [0.5];

let hidden =
Layer::<3, 2>::try_from_slices(&hidden_weights, &hidden_biases, Activation::Relu).unwrap();
let output =
Layer::<1, 3>::try_from_slices(&output_weights, &output_biases, Activation::Identity)
.unwrap();

let input = Vector::new([2.0, 1.0]);
let activations = hidden.forward(input.view()).unwrap();

// The third unit's sum is -1.0, so ReLU switches it off.
assert_eq!(activations.into_array(), [0.5, 3.0, 0.0]);
assert_eq!(
output.forward(activations.view()).unwrap().into_array(),
[4.0]
);
}

#[test]
fn zero_weights_leave_only_the_biases() {
let weights = [0.0; 6];
let biases = [1.0, -2.0, 3.0];
let layer = Layer::<3, 2>::try_from_slices(&weights, &biases, Activation::Identity).unwrap();

let output = layer.forward(Vector::new([7.0, -9.0]).view()).unwrap();

assert_eq!(output.into_array(), biases);
}

#[test]
fn identity_weights_and_zero_biases_pass_the_input_through() {
let weights = [1.0, 0.0, 0.0, 1.0]; // 2x2 identity
let biases = [0.0, 0.0];
let layer = Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Identity).unwrap();

let input = Vector::new([2.5, -4.0]);

assert_eq!(layer.forward(input.view()).unwrap(), input);
}

#[test]
fn a_row_reads_the_whole_input_rather_than_summing_its_own_weights() {
// Rows sum to 6 and 15, which the dot products must not be mistaken for.
let weights = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2x3
let biases = [0.0, 0.0];
let layer = Layer::<2, 3>::try_from_slices(&weights, &biases, Activation::Identity).unwrap();

let output = layer
.forward(Vector::new([10.0, 20.0, 30.0]).view())
.unwrap();

assert_eq!(output.into_array(), [140.0, 320.0]);
}

#[test]
fn relu_never_returns_a_negative_activation() {
let weights = [-1.0, -1.0, -1.0, -1.0];
let biases = [-5.0, -5.0];
let layer = Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Relu).unwrap();

let output = layer.forward(Vector::new([3.0, 4.0]).view()).unwrap();

assert!(output.as_slice().iter().all(|value| *value >= 0.0));
}

#[test]
fn a_slice_too_short_for_the_declared_shape_is_rejected() {
let weights = [1.0, 2.0, 3.0];
let biases = [0.0, 0.0];
assert!(Layer::<2, 2>::try_from_slices(&weights, &biases, Activation::Relu).is_err());
}
1 change: 1 addition & 0 deletions crates/multicalc/tutorials/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Every fallible call returns a `Result`, and the error is the module family's own
- [Control](control.md)
- [Motion](motion.md)
- [Mapping](mapping.md)
- [MLP inference](mlp-inference.md)
- [Estimation](estimation.md)
- [Random](random.md)
- [Error handling](error-handling.md)
Expand Down
Loading
Loading