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 1 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 1 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. 2 APIs, 1 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<()>;
    pub fn load_partial_from_path(&mut self, path: impl AsRef<std::path::Path>) -> rustyml::error::RustymlResult<LoadReport>;
}

4 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.

Fourth, the neural-network side has a third method. load_partial_from_path applies what the file and the model agree on, and returns a report of the rest. The classical path has no counterpart, because a classical file either deserializes into the whole struct or fails.

Graph, the model whose layers form a directed graph, carries the same 3 methods with the same signatures. Everything this page says about a Sequential file holds for a graph file too. See 3.10. Graph Models and Merge Layers.

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. 2 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, addressed by name

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 ModelCheckpoint { magic, format_version, layers }. Each LayerCheckpoint holds the string that LayerBase::layer_type returned, 1 build shape per input of the layer, and 1 WeightRecord per array the layer holds. Each record holds the name of the array, its WeightKind, its shape, and its elements in logical C order. The whole structure borrows the live model. A record takes the name and the elements of the array it describes, so nothing is copied before postcard reads it. An array that a layer does not hold in 1 contiguous run is the 1 exception, and its record owns a C-order copy.

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.

MODEL_FORMAT_VERSION is 3 in this release. Version 3 widened the build record, which held 1 shape in version 2 and holds 1 shape per input of the layer now. Almost every layer takes 1 input and records 1 shape. A merge layer takes several inputs, and it records the shape of every one of them. The Concatenate that joins 2 branches of a graph model is such a layer. A version 2 file therefore stops loading, and the message names both version numbers.

The layout is small enough to build by hand, which is the clearest way to read it:

use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::checkpoint::{
    BuildConfig, LayerCheckpoint, MODEL_FORMAT_VERSION, MODEL_MAGIC, ModelCheckpoint, WeightRecord,
};
use rustyml::neural_network::traits::WeightKind;
use std::borrow::Cow;

fn main() {
    // 2 layers: 1 that holds an array and takes 1 input, and 1 merge layer that
    // holds no array and takes 2. A saved file has exactly this shape.
    let file = ModelCheckpoint {
        magic: MODEL_MAGIC,
        format_version: MODEL_FORMAT_VERSION,
        layers: vec![
            LayerCheckpoint {
                layer_type: Cow::Borrowed("Dense"),
                // 1 shape per input. `BuildConfig::unary` frees the batch axis.
                build: Some(BuildConfig::unary(&Shape::known(&[8, 4]))),
                weights: vec![WeightRecord {
                    name: Cow::Borrowed("kernel"),
                    kind: WeightKind::Trainable,
                    shape: vec![4, 3],
                    data: Cow::Owned(vec![0.0f32; 12]),
                }],
            },
            LayerCheckpoint {
                layer_type: Cow::Borrowed("Concatenate"),
                // A merge layer records the shape of every input it joins.
                build: Some(BuildConfig::new(&[
                    Shape::known(&[8, 3]),
                    Shape::known(&[8, 5]),
                ])),
                weights: Vec::new(),
            },
        ],
    };

    println!("format version {}", file.format_version);
    for (scope, layer) in file.layers.iter().enumerate() {
        let built: Vec<String> = match &layer.build {
            Some(build) => build.input_shapes.iter().map(Shape::to_string).collect(),
            None => Vec::new(),
        };
        let held: Vec<String> = layer
            .weights
            .iter()
            .map(|record| format!("{scope}.{} {:?} {:?}", record.name, record.kind, record.shape))
            .collect();
        println!(
            "layer {scope} `{}` built for {built:?}, holding {held:?}",
            layer.layer_type
        );
    }
}
format version 3
layer 0 `Dense` built for ["(None, 4)"], holding ["0.kernel Trainable [4, 3]"]
layer 1 `Concatenate` built for ["(None, 3)", "(None, 5)"], holding []

3 design choices matter here. First, every array is addressed by <scope>.<name>, where scope is the position of the layer and name is what the layer calls the array. That is the same pair the optimizer keys its state on, so 1 address serves the training loop and the file alike.

Nothing in the format is positional inside a layer. An optional array such as a bias can therefore leave a layer without moving any other path. In a graph model the scope is the position of the layer in the arena, and not the position of a node. A layer that several nodes call therefore holds 1 set of paths and takes 1 set of values.

Second, the Cow fields let 1 type serve both directions. Saving borrows the live name and the live elements. Loading owns both, as a ModelCheckpoint<'static>.

Third, build carries the shapes the layer was built for, 1 per input. A layer allocates every array it owns in Layer::build_many, from the shapes of its inputs. The build shape is therefore the 1 thing a fresh layer does not have, and it decides every extent the layer allocates. The record frees the batch axis of every shape, because 1 layer serves every batch size. A model built for 32 samples therefore takes the checkpoint of a model built for 1.

A load compares the field when the file and the layer both carry one, and skips the comparison in every other case. A layer that owns no array and reads no extent of its input, such as an activation, carries none.

A convolution is what earns the field its place. A Conv2D kernel is [kh, kw, Cin, filters] for every spatial extent. The per-array shape check therefore cannot see a file that came from a model built for another image size. The build shape is the only thing that can.

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 string, the array names, and the kinds 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 runs 2 passes, and the first one writes nothing. It checks the layer count, and then the layer_type of each position. Next come the build shapes of each layer, where both sides carry them. Then come the number of arrays of a layer, and the name of each one in order. Last come the kind of each array, and the shape of each array against the element count of the record.

Only then does the second pass write, through the layer’s own weights_mut views. Any check that fails raises Error::Io(IoError::ModelStructureMismatch), with a message that names the checkpoint path, or the layer position for a whole-layer disagreement. A refusal therefore leaves the model exactly as it was. A model never holds the arrays of 1 file next to the arrays of another.

That strictness is the default on purpose. A name and a shape together are a weaker key than the closed set of per-layer containers this format replaced. InstanceNormalization, GroupNormalization, and a rank-2 LayerNormalization all expose gamma [C] and beta [C], identically, and nothing but the per-layer type name separates them. A lenient default would load a file that is wrong for the model and leave the difference to surface later as a wrong prediction.

load_partial_from_path is the lenient path, and a caller asks for it by name. It applies what matches and returns a LoadReport { applied, missing, unused } of checkpoint paths. Section 3.9.9 works through it.

The lenient path does not work around the build shape. It skips a layer built for another shape whole, rather than writing weights into it. Every path of that layer then goes into missing and into unused at the same time. The model got no value there, and the file value went nowhere. The program below runs both loads against 1 file:

use rustyml::neural_network::Shape;
use rustyml::neural_network::layers::{Activation, Conv2D, Dense, GlobalAveragePooling2D};
use rustyml::neural_network::sequential::{Sequential, SequentialBuilder};

fn stack(height: usize, width: usize) -> Sequential {
    SequentialBuilder::new()
        .add(Conv2D::new(2, (3, 3), (1, 1), Activation::ReLU).unwrap())
        .add(GlobalAveragePooling2D::new())
        .add(Dense::new(4, Activation::Linear).unwrap())
        .build(&Shape::known(&[1, height, width, 1]))
        .unwrap()
}

fn main() {
    // Saved from a model built for 8-by-8 images.
    let source = stack(8, 8);
    let path = "build_shape_demo.bin";
    source.save_to_path(path).unwrap();

    // The same stack, built for 6-by-6 images. The convolution kernel is
    // [3, 3, 1, 2] in both, so only the recorded build shape tells them apart.
    let mut target = stack(6, 6);

    // Strict: the load refuses, and the message names both shapes.
    println!("strict: {}", target.load_from_path(path).unwrap_err());

    // Lenient: the convolution is skipped whole, and its paths land in
    // `missing` and in `unused` at the same time. The Dense layer was built
    // for (None, 2) on both sides, so it takes its values.
    let report = target.load_partial_from_path(path).unwrap();
    println!("applied: {:?}", report.applied);
    println!("missing: {:?}", report.missing);
    println!("unused:  {:?}", report.unused);

    std::fs::remove_file(path).unwrap();
}
strict: model structure mismatch: layer 0 (`Conv2D`) was built for input shape (None, 6, 6, 1), and the file records (None, 8, 8, 1)
applied: ["2.kernel", "2.bias"]
missing: ["0.kernel", "0.bias"]
unused:  ["0.kernel", "0.bias"]

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. 1 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, WeightKind)varint discriminant1 (plus the payload)
Option<T>1 tag byte (0 = None, 1 = Some)1 (plus T if Some)
String (a layer type tag, an array name)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.

The neural-network format moved from version 2 to version 3 in the same release, because the build record now holds 1 shape per input. Every version 2 file stops loading, and the message names both version numbers. Train such a model again under this release, and save it again.

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.

2 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 disagreement over the layer count, a layer type, an array name, an array kind, or an array shape 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.15.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.15";

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::weight_paths(), then read each address with Sequential::weight(path) and write the array out. The path is already a stable column name for the export, and the views 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.