Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7.2. Model Persistence in Depth

RustyML gives you 2 ways to persist a model. Both write the same wire format, but they diverge in one important way. A classical estimator serializes the entire model: its hyperparameters, its learned parameters, and its training metadata. Loading it back gives you a ready-to-predict object, with no extra work. A neural network serializes weights only. You rebuild the architecture in code, then load the arrays back into it.

Both paths write postcard, a compact, non-self-describing binary format. This one choice explains why the files are small and why only Rust can read them. It also explains why loading a file across a version boundary is a hazard.

The 2 paths handle that hazard differently. A neural-network file starts with a magic tag and a format version. The loader checks both first, so an incompatible release fails loudly. A classical file has no header at all, so you manage the version boundary yourself.

This page is the low-level companion to 3.9. Saving and Loading Weights. Section 3.9 teaches the neural-network workflow. This page takes both paths down to the byte level. It covers the failure surface for both paths. It also covers operational patterns such as versioning, atomic writes, and interop. The 2 convenience methods do not give you these for free.

7.2.1. Two APIs, one format

The 2 subsystems expose different method signatures on purpose. Treating them as the same method causes problems.

// Classical ML models. The `model_save_and_load_methods!` macro in lib.rs generates these:
impl LinearRegression {
    pub fn save_to_path(&self, path: &str) -> Result<(), rustyml::error::Error>;
    pub fn load_from_path(path: &str) -> Result<Self, rustyml::error::Error>;
}

// Sequential neural network:
impl Sequential {
    pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> rustyml::error::RustymlResult<()>;
    pub fn load_from_path(&mut self, path: impl AsRef<std::path::Path>) -> rustyml::error::RustymlResult<()>;
}

Three differences matter here. First, the classical save_to_path and load_from_path take &str only. They do not take impl AsRef<Path>. A PathBuf argument needs an explicit .to_str().unwrap() call, or the code fails to compile. The neural-network methods accept any AsRef<Path>.

Second, the classical load_from_path is an associated function. It returns an owned Self. There is nothing to load data into, because the whole model comes from the file. Sequential::load_from_path works differently. It takes &mut self and changes a model you already built.

Third, RustymlResult<()> is an alias for Result<(), Error>. So the error type is the same on both paths. Every failure is a variant of the crate’s unified Error.

The macro that generates the classical pair lives in lib.rs as model_save_and_load_methods!. It applies, unchanged, to 13 types in the machine_learning module: LinearRegression, LogisticRegression, KNN, DecisionTree, SVC, LinearSVC, LDA, KMeans, DBSCAN, MeanShift, PCA, KernelPCA, and IsolationForest. It also applies to 5 scalers in the utils module: MaxAbsScaler, MinMaxScaler, Normalizer, RobustScaler, and StandardScaler. Every one of these 18 types gets the same 2 methods, with the same behavior.

7.2.2. Classical ML: the whole model on disk

The macro body is short. save_to_path calls postcard::to_allocvec(self), then writes the bytes through a buffered writer. load_from_path reads the file, then calls postcard::from_bytes::<Model>(&bytes).

The model struct derives Serialize and Deserialize, so every field travels with it. For LinearRegression, that includes coefficients, intercept, fit_intercept, the post-fit n_iter, regularization_type, and the solver field. The solver field is a LeastSquaresSolver. When you select gradient descent, that same enum also carries the learning_rate, max_iter, and tol settings.

Classical persistence has no separate “config” and “weights” split. That split matters a great deal on the neural-network side, but it does not exist here. This is why a loaded model is ready to use right away.

load_from_path gives you back an object you cannot tell apart from the one you trained. You do not need to call fit again. You do not need to set any parameter again. There is no compile step.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let x = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 2.0, 1.0, 3.0, 5.0, 4.0, 3.0]).unwrap();
    let y = Array1::from_vec(vec![5.0, 5.0, 13.0, 11.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let path = "lr_whole_model.bin";
    model.save_to_path(path).unwrap();

    // Load returns a model ready to use, with no re-fit, no rebuild, and no compile step.
    let restored = LinearRegression::load_from_path(path).unwrap();

    // The round trip preserves both the learned parameters and the hyperparameters.
    assert_eq!(restored.get_solver(), model.get_solver());
    assert_eq!(restored.get_coefficients().unwrap().len(), 2);

    let probe = Array2::from_shape_vec((1, 2), vec![2.0, 4.0]).unwrap();
    let a = model.predict(&probe).unwrap();
    let b = restored.predict(&probe).unwrap();
    println!("live vs restored prediction gap: {:e}", (a[0] - b[0]).abs());

    std::fs::remove_file(path).unwrap();
}

If you know scikit-learn’s pickle or joblib, the mental model matches. The whole estimator makes a round trip. Two real differences remain.

First, postcard is not pickle. It runs no code when it loads a file. A hostile file cannot trigger arbitrary code execution. The only risk is malformed or mismatched bytes.

Second, postcard carries no class identity. It has no __module__ field and no version stamp. The next sections cover what that costs you.

7.2.3. Neural networks: weights, and the enum that carries them

Section 3.9 covers the neural-network path from end to end. This section covers the mechanism underneath it.

Sequential::save_to_path builds a SerializableSequential { magic, format_version, layers: Vec<SerializableLayer> }. Each SerializableLayer pairs a LayerInfo { layer_type, output_shape } metadata tag with a LayerWeight<'a> value, taken from layer.get_weights().

The 2 leading u32 values form the file header: MODEL_MAGIC ("RMLM") and MODEL_FORMAT_VERSION. They come first on purpose. A file written before this header existed starts with its layer count instead. The loader reads that small integer where the tag belongs, and rejects the file. This happens before the loader parses far enough to apply even 1 weight.

The enum after the header holds the on-disk weight format:

pub enum LayerWeight<'a> {
    Dense(DenseLayerWeight<'a>),
    SimpleRNN(SimpleRNNLayerWeight<'a>),
    LSTM(LSTMLayerWeight<'a>),
    GRU(GRULayerWeight<'a>),
    Conv1D(Conv1DLayerWeight<'a>),
    Conv2D(Conv2DLayerWeight<'a>),
    Conv3D(Conv3DLayerWeight<'a>),
    SeparableConv2D(SeparableConv2DLayerWeight<'a>),
    DepthwiseConv2D(DepthwiseConv2DLayerWeight<'a>),
    BatchNormalization(BatchNormalizationLayerWeight<'a>),
    LayerNormalization(LayerNormalizationLayerWeight<'a>),
    InstanceNormalization(InstanceNormalizationLayerWeight<'a>),
    GroupNormalization(GroupNormalizationLayerWeight<'a>),
    Empty, // no trainable parameters: Dropout, pooling, flatten, pure activation layers
}

Two design choices matter here. First, each per-layer struct stores its arrays as Cow. One type serves both directions. get_weights borrows the live arrays with Cow::Borrowed, so saving clones nothing. Loading fills Cow::Owned arrays instead, used as LayerWeight<'static>.

Second, the enum uses serde’s default representation, called externally tagged. It writes an explicit variant tag before the payload. Postcard is non-self-describing, so without this tag it could not tell a Dense payload from a Conv2D one.

The file deliberately leaves out any constructor information. It has no activation function, no epsilon, no momentum, no stride, no kernel size, no dilation, and no group count. Those settings live in your source code, not in the file. The layer_type and output_shape strings in LayerInfo are validation tags, not a build recipe. This is exactly why loading needs a pre-built model, ready to receive weights.

A pre-built model lets the neural-network loader do something the classical loader cannot do: validate structure. load_from_path checks the layer count. It checks each layer’s layer_type string against the model you built. Inside apply_weights_to_layer, it downcasts each layer to its concrete type, then calls set_weights. This step also catches a shape disagreement. Any of these checks that fails raises Error::Io(IoError::ModelStructureMismatch), with a message that names the problem.

The classical macro has no such guard. It deserializes bytes straight into the target struct, with no check at all. Loading a LinearRegression file into KMeans::load_from_path is not rejected by any type check. Postcard reads the bytes by position only.

You get a Serialization error in the common case, where the 2 layouts disagree enough. In the unlucky case, the bytes happen to fit the other layout, and you get a model that looks valid but means nothing. For a classical model, the file name or an outside label is the only thing that stops you from making this mistake.

7.2.4. The postcard wire format

Postcard is a minimal binary format. One property governs everything about it: postcard is non-self-describing.

The bytes hold values in field order, and nothing else. There are no field names, no type names, no schema, and no length-prefixed section you could skip over. Serializing a LinearRegression does not write the string "learning_rate" anywhere. It writes the 8 bytes of the f64 value, at the exact position where learning_rate sits inside the serialized LeastSquaresSolver.

This is the whole reason the files stay small. It is also the reason they are fragile. Reading the bytes back correctly depends on the reading side having the exact same type layout as the writing side.

The per-type encoding matters when you reason about file size, or when you debug a broken file:

Rust typepostcard encodingbytes
boolsingle byte, 0 or 11
f32 (neural-network weights)fixed width, little endian4
f64 (classical parameters)fixed width, little endian8
usize / u64 (for example max_iter, or a length)LEB128 varint1 to 10
enum variant (Solver, LayerWeight)varint discriminant1 (plus the payload)
Option<T>1 tag byte (0 = None, 1 = Some)1 (plus T if Some)
String (a layer type tag)varint length, then UTF-8 bytesvaries
ndarray Array (through serde)small version and shape header, then a varint element count, then the elementsvaries

Floats use a fixed width. An array of N values costs N x 4 bytes for f32, or N x 8 bytes for f64. Add a small shape header and a length prefix on top. The round trip stays lossless, not lossy.

Integers and lengths use varints, so a small count costs 1 byte. Postcard applies no compression and no alignment padding.

The format’s small size is also its fragility. Nothing in the file carries a label. Suppose a struct’s layout drifts between the version that wrote the file and the version that reads it. A field might get added, removed, reordered, or given a new type.

Deserializing that file has no name left to check against. The likely result is an “unexpected end of input” error, or a bad-tag Serialization error. The dangerous result is a silent misparse, where the drifted layout still happens to consume the same number of bytes.

This is not a bug in postcard. It is the price you pay for a small file. It is also why versioning, covered later on this page, is your job, not the format’s job.

Several classical estimators changed their on-disk layout in this release. LinearRegression folded 3 separate iteration settings into the solver payload. KMeans gained a new n_init field. LDA gained a new field for the overall training mean. A file saved by an older version of any of these 3 types fails to load. Re-fit the model, then save it again.

MeanShift changed its layout too, in a way that also changes meaning. Its labels field changed type, from usize to isize. An unassigned point’s label changed meaning as well: it used to equal the cluster count, and now it is -1. On top of that, the cluster centers themselves now come from a different algorithm. An old MeanShift file, if it still loads at all, holds centers computed under the old Gaussian kernel, not the current flat kernel.

IsolationForest changed only in meaning, not in layout. Its offset field keeps the same type and the same position, but the stored number now carries the opposite sign. Re-fit and re-save MeanShift and IsolationForest models too, even where the shapes still line up.

7.2.5. File size: a back-of-envelope you can trust

Postcard applies no compression and no framing, so you can predict a file’s size to within a few bytes.

For a classical model, the learned arrays dominate the payload. A LinearRegression with p features costs about p x 8 bytes for its coefficients, plus a few dozen bytes for scalar hyperparameters and headers.

For a neural network, add up (weight_elements + bias_elements) x 4 bytes across every layer, since neural-network weights use f32. Add a small amount for each layer’s type and shape strings.

A Dense(784 -> 128) layer has 784 x 128 + 128 = 100,480 parameters. That is about 392 KB. The same layer in a classical, f64-based world would cost twice as much. Measure the size directly instead of estimating it:

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let n_features = 8usize;
    let n_samples = 20usize;
    let x = Array2::from_shape_fn((n_samples, n_features), |(i, j)| (i + j) as f64 * 0.1);
    let y = Array1::from_shape_fn(n_samples, |i| i as f64);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    let path = "lr_size_probe.bin";
    model.save_to_path(path).unwrap();

    let on_disk = std::fs::metadata(path).unwrap().len();
    // One f64 per coefficient is the dominant term. Everything else is scalars and small headers.
    let coefficient_bytes = (n_features * std::mem::size_of::<f64>()) as u64;
    println!("file: {on_disk} bytes, coefficient payload ~= {coefficient_bytes} bytes");

    std::fs::remove_file(path).unwrap();
}

In practice, this makes postcard checkpoints cheap to keep in bulk. A loop that saves every epoch and keeps the best model costs only kilobytes per checkpoint, for a small model. The limit on how often you save a checkpoint becomes disk-write speed, not file size.

For a large convolutional stack, the f32 weights dominate the file, and the file size tracks the parameter count closely. This makes capacity planning simple: count the parameters, then multiply by 4.

7.2.6. What loading actually rejects

Every persistence failure surfaces as Error::Io(...). Exactly 4 shapes exist underneath it.

IoError::Std wraps a std::io::Error. This covers a missing path, a permissions problem, or a read or write that failed.

IoError::UnsupportedModelFormat applies to neural networks only. It comes from the header check: the magic tag or the format version does not match this build.

IoError::Serialization wraps a postcard::Error. This means the bytes are not valid postcard for the layout the code expects, because of corruption, truncation, or a schema that no longer matches.

IoError::ModelStructureMismatch also applies to neural networks only, as section 7.2.3 covers. Classical models have no counterpart to it.

Two From implementations in error.rs let the ? operator lift a raw std::io::Error into IoError::Std, and a raw postcard::Error into IoError::Serialization. This mapping works the same way in both subsystems. Both Error and IoError carry #[non_exhaustive]. Always add a trailing catch-all arm when you match them.

use rustyml::machine_learning::*;
use rustyml::error::{Error, IoError};

fn main() {
    // This file holds bytes that are not valid postcard for a LinearRegression.
    let junk = "corrupt_lr.bin";
    std::fs::write(junk, b"\xff\xff\xff not a model").unwrap();

    match LinearRegression::load_from_path(junk) {
        Ok(_) => println!("unexpected success"),
        Err(Error::Io(IoError::Serialization(e))) => println!("bad bytes -> Serialization: {e}"),
        Err(Error::Io(IoError::Std(e))) => println!("io failure: {e}"),
        Err(e) => println!("other: {e}"),
    }
    std::fs::remove_file(junk).unwrap();

    // A missing path surfaces as IoError::Std, not Serialization.
    match LinearRegression::load_from_path("no_such_model_9f3a.bin") {
        Err(Error::Io(IoError::Std(e))) => println!("missing file -> Std: {e}"),
        other => println!("unexpected: {other:?}"),
    }
}

The neural-network test suite checks these mappings against the real API. A path that does not exist yields IoError::Std. A wrong magic tag or format version yields IoError::UnsupportedModelFormat. Corrupt bytes behind a well-formed header yield IoError::Serialization. A layer-count, layer-type, or weight-shape disagreement yields IoError::ModelStructureMismatch.

Classical models have a gap in that list: no structural error exists to catch. Serialization becomes the only signal that a file is wrong, and it is not a reliable one. If a corrupt classical file happens to deserialize anyway, load_from_path returns Ok. Build your own integrity check to guard against that. The next 2 sections build one.

7.2.7. Versioning across RustyML releases

Postcard files carry no version stamp. RustyML makes no promise that a model struct’s field layout stays stable across releases.

Add a hyperparameter to LinearRegression. Reorder a field. Change a field’s type. Any of these turns a file from an older version into bad input for the newer one. The usual result is a Serialization failure. Occasionally, the result is a silent misparse instead.

RustyML ships no built-in migration path. Adopt these 2 habits so this never surprises you in production.

First, pin the exact rustyml version that writes your long-lived checkpoints. This stops a routine cargo update from silently changing the on-disk layout under a directory of saved models:

[dependencies]
rustyml = { version = "=0.14.0", features = ["full"] }

Second, write a version sidecar. This is a small companion file, saved next to the model, that records the format identity. Refuse to load the model when the sidecar does not match what your binary expects. This turns a silent misparse into a loud, early error.

Note who needs the sidecar most. A Sequential file carries its own magic tag and format version, so the neural-network path already fails loudly across an incompatible release. There, the sidecar only adds your own schema identity on top. The classical macro path has no header at all. For those models, the sidecar is your whole defense.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

// Bump this value every time you upgrade the rustyml dependency that writes your checkpoints.
const CHECKPOINT_FORMAT: &str = "rustyml-0.14";

fn main() {
    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();

    let model_path = "sidecar_model.bin";
    let version_path = "sidecar_model.bin.version";
    model.save_to_path(model_path).unwrap();
    std::fs::write(version_path, CHECKPOINT_FORMAT).unwrap();

    // On load, check the recorded format string before deserializing.
    let recorded = std::fs::read_to_string(version_path).unwrap();
    if recorded != CHECKPOINT_FORMAT {
        panic!("checkpoint written by {recorded}, this binary expects {CHECKPOINT_FORMAT}");
    }
    let restored = LinearRegression::load_from_path(model_path).unwrap();
    println!("loaded {} coefficients", restored.get_coefficients().unwrap().len());

    std::fs::remove_file(model_path).unwrap();
    std::fs::remove_file(version_path).unwrap();
}

Make the version string specific enough to matter. Use the rustyml version at minimum. Add your own schema counter too, if you wrap models inside a larger record. The sidecar costs almost nothing to write. It turns the format’s worst failure mode, silent wrong numbers, into a panic you catch during testing.

7.2.8. Atomic checkpoints

Both save_to_path methods call File::create, which truncates the target file right away. Suppose the process crashes, the disk fills up, or something kills the training loop partway through the write. You are left with a truncated file, at the exact path your restart logic tries to load. Truncation causes a Serialization error at best. At worst, it gives you a silently short array.

The standard defense is write-then-rename. Serialize the model to a temporary path on the same file system, then call std::fs::rename to move it over the final path. Rename works atomically on POSIX file systems. A reader sees either the complete old file or the complete new file, and never a half-written one.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

/// Saves through a temporary file, then an atomic rename. A crash during the
/// write never leaves a half-written checkpoint at `final_path`.
fn save_atomically(model: &LinearRegression, final_path: &str) -> std::io::Result<()> {
    let tmp_path = format!("{final_path}.tmp");
    model
        .save_to_path(&tmp_path)
        .expect("serialize to temp file");
    std::fs::rename(&tmp_path, final_path)
}

fn main() {
    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();

    let path = "atomic_model.bin";
    save_atomically(&model, path).unwrap();

    let restored = LinearRegression::load_from_path(path).unwrap();
    println!("intercept present after atomic save: {}", restored.get_intercept().is_some());

    std::fs::remove_file(path).unwrap();
}

Keep the temporary file on the same file system as the destination. A rename across file systems is not atomic, and it falls back to a copy plus a delete.

Pair this with the sidecar from section 7.2.7. Rename the model file first, then rename the version file second. A torn write then leaves the version sidecar pointing at the previous, complete model, instead of at a broken new one.

For a loop that keeps only the best checkpoint, an atomic replace also means your best-so-far file is never missing, even for a moment.

7.2.9. Crossing the language boundary

Postcard belongs to the Rust ecosystem. No maintained Python or R reader exists for it. Even if one did, the non-self-describing bytes would need a hand-written schema that mirrors RustyML’s exact struct layout, and that layout changes between versions.

Treat a .bin checkpoint as a RustyML-to-RustyML artifact, and nothing else. When another tool needs to read a trained model, do not try to parse the postcard file. Instead, export the parameters through the getters, into a portable format the other tool already reads. Classical models expose everything you need for this: LinearRegression::get_coefficients and get_intercept, KMeans::get_centroids, and matching accessors on the other estimators.

use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

fn main() {
    let x = Array2::from_shape_vec(
        (3, 3),
        vec![1.0, 0.0, 2.0, 0.0, 1.0, 1.0, 2.0, 2.0, 0.0],
    )
    .unwrap();
    let y = Array1::from_vec(vec![4.0, 3.0, 6.0]);

    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();

    // Pull the learned parameters out through the getters, then write portable CSV.
    // A Python, R, or spreadsheet consumer can read this directly. Postcard plays no part.
    let coefficients = model.get_coefficients().expect("model is fitted");
    let intercept = model.get_intercept().unwrap_or(0.0);

    let mut csv = String::from("term,value\n");
    for (i, c) in coefficients.iter().enumerate() {
        csv.push_str(&format!("x{i},{c}\n"));
    }
    csv.push_str(&format!("intercept,{intercept}\n"));

    print!("{csv}");
}

The same pattern works elsewhere. Dump the getter output as CSV, for a spreadsheet or for pandas read_csv. Or assemble it into a JSON object that your service already reads.

For a neural network, loop over Sequential::get_weights(), then write each LayerWeight variant’s arrays out, layer by layer. The arrays are ndarrays, so .iter() plus your own formatter does the job.

This costs you 2 things: the compactness of postcard, and the exactness guarantee of a same-format round trip. Keep the postcard file as your canonical checkpoint, for reloading back into RustyML. Treat the exported CSV or JSON as a one-way view for other tools only.

Remember what the getters do and do not include when you export. A classical export captures the fitted parameters, but unlike the postcard file, it drops the hyperparameters and the metadata. A neural-network export holds weights only, for the same reasons section 3.9 explains.

For 2 related operational topics, see the neighboring pages. 7.1. Reproducibility and Random Seeds covers reseeding a model after a load, so a resumed shuffle stays reproducible. 7.3. Performance Tuning and Parallelism covers the throughput of writing many checkpoints under parallelism.