1.3. Working with ndarray
Every matrix type in RustyML comes from ndarray. If you want the more advanced parts of ndarray — broadcasting rules, the various iterators, linear-algebra helpers — read the official ndarray documentation.
RustyML 0.14 depends on ndarray 0.17. Your own Cargo.toml needs to use the same ndarray version:
[dependencies]
ndarray = "0.17"
rustyml = { version = "0.14", features = ["full"] }
1.3.1. f64 versus f32
In RustyML the elements of a classical machine-learning matrix are f64, while the neural-network stack’s tensor elements are f32.
The classical estimators (see machine_learning) take a two-dimensional Array2<f64> feature matrix laid out as samples by features (one row per sample), plus a one-dimensional Array1<_> target vector. The target’s element type varies by model:
| Model | Target (y) | predict output |
|---|---|---|
LinearRegression | Array1<f64> | Array1<f64> |
LogisticRegression | Array1<f64> (0.0 / 1.0) | Array1<f64> (0.0 / 1.0) |
DecisionTree | Array1<f64> | Array1<f64> |
SVC, LinearSVC | Array1<f64> | Array1<f64> |
LDA | Array1<i32> | Array1<i32> |
KNN<T> | Array1<T> (T: Clone + Hash + Eq) | Array1<T> |
KMeans, MeanShift, DBSCAN | (unsupervised) | Array1<isize> (cluster ids, -1 is noise) |
IsolationForest | (unsupervised) | Array1<i32> (-1 outlier / +1 inlier; scores come from score_samples) |
PCA, KernelPCA | (unsupervised) | Array2<f64> (via transform) |
The neural-network stack uses Tensor, which is a type alias:
pub type Tensor = ArrayD<f32>;
Tensor differs from the classical machine-learning matrices in two ways:
- The element type is
f32: half the precision, half the memory, and the width every deep-learning framework standardizes on, because neural networks are compute-bound and the extra throughput is worth a few bits of precision. - The dimensionality is dynamic:
ArrayD(equivalentlyArray<f32, IxDyn>) carries its rank at run time rather than encoding it in the type. A wrong rank surfaces at run time as anErr; see 1.3.7.
1.3.2. Building arrays
Here are the common ways to build the ndarray matrix types:
use ndarray::{array, Array, Array1, Array2};
fn main() {
// `array!`: the shape is inferred from the nesting — this is a 3x2 `Array2<f64>`
let m = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
assert_eq!(m.shape(), &[3, 2]);
// `from_shape_vec`: (shape, flat row-major Vec)
// The length must equal the product of the shape, or you get an Err
let x = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
assert_eq!(x.shape(), &[2, 3]);
// `from_shape_fn`: give it a shape, plus a closure that produces each element
let g = Array2::from_shape_fn((3, 2), |(i, j)| (i * 2 + j) as f64);
assert_eq!(g[[2, 1]], 5.0);
// `from_vec`: turn a `Vec<T>` into an `Array1<T>`
let y = Array1::from_vec(vec![2.0, 4.0, 6.0]);
// `from_iter`: fill an `Array1<T>` from an iterator
let labels = Array1::from_iter(0..3i32);
// Constant / all-zero / all-one fills, with the element type spelled out
let zeros = Array2::<f64>::zeros((2, 2));
let ones = Array2::<f64>::ones((2, 2));
let filled = Array::from_elem((2, 2), 7.0f64);
println!("{} {} {} {}", y.len(), labels.len(), zeros.sum(), ones.sum());
println!("{}", filled.sum());
}
Most data arrives as nested Vecs, and that is when you reach for from_shape_vec. It reads the Vec in row-major order. Its return value is wrapped in a Result, and if you get an Err, the shape is almost certainly wrong.
Often you want to initialize a matrix randomly. The ndarray-rand crate provides Array::random for that, and you add it to your Cargo.toml:
[dependencies]
ndarray-rand = "0.16"
ndarray = "0.17"
Example:
use ndarray::Array;
use ndarray_rand::RandomExt; // the extension trait that gives ndarray's array types
// random construction — bring it into scope for `Array::random`
use ndarray_rand::rand_distr::Uniform; // samples uniformly between the given bounds
fn main() {
let w = Array::random((4, 3), Uniform::new(-1.0, 1.0).unwrap());
println!("{:?}", w.shape());
}
When you use RustyML, do not seed ndarray-rand’s generator by hand. RustyML has its own randomness management (set_global_seed, a per-model with_random_state, and Sequential::new_with_seed), so control randomness through RustyML’s API instead; see 7.1 Reproducibility and Random Seeds.
1.3.3. Inputs to the classical estimators
Here is the shape a typical supervised classical fit and predict signature takes:
pub fn fit<S1, S2>(&mut self, x: &ArrayBase<S1, Ix2>, y: &ArrayBase<S2, Ix1>) -> Result<&mut Self, Error>
where
S1: Data<Elem = f64>,
S2: Data<Elem = f64>,
Two things depart from it, both visible in the table in 1.3.1: LDA’s y is Data<Elem = i32> rather than f64, and the unsupervised estimators (KMeans, DBSCAN, MeanShift, PCA, KernelPCA) take the feature matrix alone, with no y argument at all.
ndarray has two kinds of borrow:
- a reference to an owned array (
&Array) - a view (
ArrayView), which is what.view()and.slice(..)produce - Both are zero-copy, and RustyML accepts either. The parameter is a reference, so write
&arrayor&array.view(); a barearray.view()does not type-check.
You also have to keep the feature matrix two-dimensional (Ix2) and the target vector one-dimensional (Ix1).
use rustyml::machine_learning::LinearRegression;
use ndarray::{s, Array1, Array2};
fn main() {
let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
let y = Array1::from_vec(vec![2.0, 4.0, 6.0, 8.0]);
let mut model = LinearRegression::new(true);
// A reference to the owned array
model.fit(&x, &y).unwrap();
// A reference to a view
model.fit(&x.view(), &y.view()).unwrap();
// A reference to a slice: train on the first three rows only
// This subview is a borrow into `x`
let x_head = x.slice(s![0..3, ..]);
let y_head = y.slice(s![0..3]);
model.fit(&x_head, &y_head).unwrap();
}
What .view() and .slice(..) really buy you is the ability to feed a subset of an array — a range of rows, a strided window, a single column reshaped — straight into fit or predict without first materializing that subset as a new owned array. To train on rows 0..800 of a 1000-row matrix, x.slice(s![0..800, ..]) is all it takes.
1.3.4. Inputs to the neural-network stack
The neural layers want ArrayD<f32>, but you almost never construct one directly. The usual path is to build a fixed-dimension array and then convert it to the dynamic dimension IxDyn. That conversion is a thin wrapper; it does not touch the data:
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::MeanSquaredError;
use ndarray::Array2;
fn main() {
// 4 samples, 2 features: build an `Array2<f32>` first, then convert to `ArrayD`
let x = Array2::from_shape_vec(
(4, 2),
vec![0.0f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0],
)
.unwrap()
.into_dyn(); // convert to a dynamic-dimension tensor
let y = Array2::from_shape_vec((4, 1), vec![0.0f32, 1.0, 1.0, 0.0])
.unwrap()
.into_dyn(); // convert to a dynamic-dimension tensor
let mut model = Sequential::new();
model
.add(Dense::new(2, 4, Activation::ReLU).unwrap())
.add(Dense::new(4, 1, Activation::Linear).unwrap());
model.compile(Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap(), MeanSquaredError::new());
model.fit(&x, &y, 5).unwrap();
let preds = model.predict(&x).unwrap();
println!("prediction tensor shape: {:?}", preds.shape());
}
Output:
prediction tensor shape: [4, 1]
1.3.5. Input shapes
RustyML’s input format is one row per sample, one column per feature.
The tensor shapes for neural-network layers match Keras’s default (batch, height, width, channels). The channel axis is always the trailing one and the batch axis is always the leading one, with the spatial axes in between — so a Keras user should feel at home. Here are the exact layouts:
| Layer | Input tensor | Weight tensor | Bias | Output tensor |
|---|---|---|---|---|
Conv1D | [batch, length, Cin] | [k, Cin, filters] | [filters] | [batch, out_len, filters] |
Conv2D | [batch, height, width, Cin] | [kh, kw, Cin, filters] | [filters] | [batch, out_h, out_w, filters] |
Conv3D | [batch, depth, height, width, Cin] | [kd, kh, kw, Cin, filters] | [filters] | [batch, out_d, out_h, out_w, filters] |
DepthwiseConv2D | [batch, height, width, C] | [kh, kw, C, dm] | [C·dm] | [batch, out_h, out_w, C·dm] |
SeparableConv2D | [batch, height, width, Cin] | depthwise [kh, kw, Cin, dm], pointwise [1, 1, Cin·dm, filters] | [filters] | [batch, out_h, out_w, filters] |
MaxPooling{1,2,3}D, AveragePooling{1,2,3}D | [batch, spatial…, C] | — | — | [batch, pooled…, C] |
GlobalMaxPooling{1,2,3}D, GlobalAveragePooling{1,2,3}D | [batch, spatial…, C] | — | — | [batch, C] |
SpatialDropout{1,2,3}D | [batch, spatial…, C] | — | — | same shape as the input |
BatchNormalization, GroupNormalization, InstanceNormalization | [batch, spatial…, C] | gamma/beta [C] | — | same shape as the input |
For example:
- A batch of 8 single-channel 28×28 images is
[8, 28, 28, 1] - A bank of 3×3 filters producing 16 feature maps has weight shape
[3, 3, 1, 16]
Here is a code example:
use rustyml::neural_network::layers::{Activation, Conv1D};
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// filters=2, kernel=3, input_shape=[batch, length, channels], stride=1
let mut conv = Conv1D::new(2, 3, vec![1, 6, 1], 1, Activation::Linear).unwrap();
// Channels last: [batch=1, length=6, channels=1]
let input = Array::from_shape_vec((1, 6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0])
.unwrap()
.into_dyn();
let output = conv.forward(&input).unwrap();
println!("conv output shape: {:?}", output.shape()); // [1, 4, 2]
}
The constructor’s input_shape argument records the [batch, length, channels] the layer is configured for. Passing a tensor whose rank or channel count disagrees is rejected at forward time. For the per-layer detail, see 3.5 Convolutional Layers and 3.6 Pooling Layers.
1.3.6. Practical advice
Real data rarely arrives as an Array2. The most common shape is a Vec<Vec<f64>>, one inner Vec per parsed CSV row. The conversion is to flatten it into a single row-major Vec<T> and hand it to from_shape_vec along with the (rows, cols) you counted:
use ndarray::Array2;
fn main() {
// As if parsed from CSV: 3 rows, 2 columns
let rows: Vec<Vec<f64>> = vec![
vec![1.0, 2.0],
vec![3.0, 4.0],
vec![5.0, 6.0],
];
let n_rows = rows.len();
let n_cols = rows[0].len();
// `flatten()` flattens the nested vectors in row-major order
let flat: Vec<f64> = rows.into_iter().flatten().collect();
let x = Array2::from_shape_vec((n_rows, n_cols), flat).unwrap();
assert_eq!(x.shape(), &[3, 2]);
println!("{:?}", x.row(0)); // [1.0, 2.0]
}
If your rows are ragged, flatten still flattens the nested vectors, but the length will not equal n_rows * n_cols and from_shape_vec returns an Err.
Other tools you will use:
- The
s!macro expresses ranges and strides concatenatejoins arrays along an existing axisouter_iter(equivalentlyaxis_iter(Axis(0))) walks each row as a one-dimensional view, which is how you iterate sample by sample
Code example:
use ndarray::{array, concatenate, s, Axis};
fn main() {
let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
// Slice: all rows, first two columns (drop a feature)
let first_two_cols = x.slice(s![.., 0..2]);
assert_eq!(first_two_cols.shape(), &[3, 2]);
// Slice: a single row as a submatrix
let middle = x.slice(s![1..2, ..]);
assert_eq!(middle.shape(), &[1, 3]);
// Concatenate two matrices along axis 0 (append rows)
let more = array![[10.0, 11.0, 12.0]];
let stacked = concatenate(Axis(0), &[x.view(), more.view()]).unwrap();
assert_eq!(stacked.shape(), &[4, 3]);
// Iterate each row as an `ArrayView1`
// The sample-by-sample idiom
for (i, row) in x.outer_iter().enumerate() {
println!("row {i} sums to {}", row.sum());
}
}
Turning raw labels into the Array1 a model wants is usually a map over your source data collected into an array. When a model or a loss expects one-hot targets rather than integer class ids, use RustyML’s to_categorical function, which turns an Array1<i32> of class ids into an Array2<f64> one-hot matrix:
use rustyml::utils::to_categorical;
use ndarray::Array1;
fn main() {
// Map string labels to integer class ids, then into an `Array1<i32>`
let raw = ["cat", "dog", "cat", "bird"];
let ids: Array1<i32> = raw
.iter()
.map(|s| match *s {
"cat" => 0,
"dog" => 1,
_ => 2,
})
.collect::<Vec<_>>()
.into();
// One-hot encode: 4 samples, 3 classes -> a (4, 3) `Array2<f64>`
let onehot = to_categorical(&ids, None).unwrap();
assert_eq!(onehot.shape(), &[4, 3]);
println!("{:?}", onehot.row(1)); // [0.0, 1.0, 0.0]
}
Label encoding, its inverse, and the mapping-preserving variants are covered in full in 4.3 Label Encoding. Preprocessing your feature matrix with standardize and normalize is in 4.2 Standardization and Normalization.
1.3.7. Shape-mismatch errors
Two different error systems catch shape problems. Errors that happen while you are building an array come from ndarray — from_shape_vec returns ndarray’s ShapeError when the buffer length disagrees with the shape. Errors that happen while RustyML runs come back as RustyML’s own Error enum, which subdivides them further:
Error::DimensionMismatch { expected, found }: two scalar counts disagree, such as the feature count atpredicttime versus atfittime, orx.nrows()versusy.len()Error::ShapeMismatch { expected, found }: two whole tensor shapes disagree, such as a gradient in the neural-network stack not matching the activation it flows intoError::InvalidInput(_): the input’s rank is wrong, such as handing a 2-D tensor to aConv1Dthat expects 3-D
The one you will hit most often is a feature-count mismatch: you train on p features and then call predict with a matrix that has a different number of columns. RustyML validates this and returns DimensionMismatch:
use rustyml::machine_learning::LinearRegression;
use rustyml::error::Error;
use ndarray::{Array1, Array2};
fn main() {
// Train on 2 features
let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 4.0]).unwrap();
let y = Array1::from_vec(vec![6.0, 9.0, 12.0]);
let mut model = LinearRegression::new(true);
model.fit(&x, &y).unwrap();
// Predict with 3 features
// The model returns an error
let bad = Array2::from_shape_vec((1, 3), vec![4.0, 5.0, 6.0]).unwrap();
match model.predict(&bad) {
Err(Error::DimensionMismatch { expected, found }) => {
println!("expected {expected} features, got {found}");
}
other => println!("unexpected: {other:?}"),
}
}
Because Error is marked #[non_exhaustive], a match over it always needs a trailing _ => arm, which keeps your code compiling when new error variants are added. For the full error-handling story, see 1.6 Error Handling.