【RustyML入门】7.2. 深入模型持久化

7.2. 深入模型持久化

RustyML 提供两种持久化模型的方式。两者写出的是同一种 wire format,但有一处关键分歧。经典 estimator 序列化整个 模型:超参数、学到的参数,以及训练时的元数据。加载回来之后,你直接得到一个能预测的对象,不需要做任何额外工作。神经网络只序列化权重:架构要你在代码里重新搭出来,再把数组加载回去。

两条路径写出的都是 postcard------一种紧凑、非自描述的二进制格式。这一个选择就解释了为什么文件这么小、为什么只有 Rust 能读,也解释了为什么跨版本加载会是个隐患。两条路径应对这个隐患的方式并不一样。神经网络的文件以一个魔数和一个格式版本开头,加载器最先校验这两样,所以不兼容的版本会响亮地失败。经典模型的文件完全没有头部,跨版本这道坎全得你自己兜。

本页是 3.9. 权重保存与加载 的底层篇:3.9 讲的是神经网络的工作流,本页把两条路径都下沉到字节层面。它讲清两条路径各自的失败面,也讲版本管理、原子写入、跨语言互操作这些运维套路------那两个便捷方法并不会替你把这些都做好。

7.2.1. 两套 API,同一种格式

这两个子系统的方法签名故意就长得不一样。要是把它们当成同一个方法用,麻烦就来了。

rust,ignore 复制代码
// 经典 ML 模型:由 lib.rs 中的 `model_save_and_load_methods!` 宏生成
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 神经网络:
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<()>;
}

这里有三处差异要记住。第一,经典的 save_to_pathload_from_path 只收 &str,不收 impl AsRef<Path>。传一个 PathBuf 得显式调用 .to_str().unwrap(),不然编译不过。神经网络那两个方法则接受任意 AsRef<Path>

第二,经典的 load_from_path 是一个关联函数,返回一个拥有所有权的 Self------没有什么可以加载进去的对象,因为整个模型都是从文件里取回来的。Sequential::load_from_path 则不同:它收 &mut self,改写的是你已经搭好的一个模型。

第三,RustymlResult<()> 就是 Result<(), Error> 的别名,所以两条路径的错误类型完全一样:每一次失败都是本 crate 统一 Error 的某个变体。

生成经典这一对方法的宏,就是 lib.rs 里的 model_save_and_load_methods!。它原样套用在 machine_learning 模块的 13 个类型上:LinearRegressionLogisticRegressionKNNDecisionTreeSVCLinearSVCLDAKMeansDBSCANMeanShiftPCAKernelPCAIsolationForest。它同样套用在 utils 模块的 5 个 scaler 上:MaxAbsScalerMinMaxScalerNormalizerRobustScalerStandardScaler。这 18 个类型,每一个都得到同样的 2 个方法,行为也一样。

7.2.2. 经典 ML:整个模型落盘

宏体很短:save_to_path 调用 postcard::to_allocvec(self),再把字节通过一个带缓冲的 writer 写出去;load_from_path 读入文件,再调用 postcard::from_bytes::<Model>(&bytes)

模型结构体 derive 了 SerializeDeserialize,所以每一个字段都会随之传输。对 LinearRegression 而言,这包括 coefficientsinterceptfit_intercept、拟合后的 n_iterregularization_type,以及 solver 字段。solver 字段是一个 LeastSquaresSolver:选中梯度下降时,这个枚举本身还会带上 learning_ratemax_itertol 这几项设置。

经典持久化没有"配置"和"权重"这条分界线------这条线在神经网络那一侧很重要,但在这里根本不存在。这正是加载出来的模型可以立刻使用的原因:load_from_path 交还给你的对象,和你训练出的那个别无二致。不用再调用 fit,不用重新设置任何参数,也没有编译步骤。

rust 复制代码
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();

    // 加载得到一个可以直接用的模型:无需重新拟合、无需重建、无需编译步骤。
    let restored = LinearRegression::load_from_path(path).unwrap();

    // 这一趟往返保留了学到的参数和超参数。
    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();
}

如果你熟悉 scikit-learn 的 picklejoblib,这套心智模型是一样的:整个 estimator 走一趟往返。但有两点实打实的差异。

第一,postcard 不是 pickle:它加载文件时不执行任何代码,恶意文件触发不了任意代码执行,唯一的风险是字节畸形或对不上号。

第二,postcard 不带类身份,没有 __module__ 字段,也没有版本戳------这恰恰是接下来几节要讲的代价。

7.2.3. 神经网络:权重,以及承载它们的枚举

3.9 节从头到尾讲了神经网络这条路径,本节则往下再挖一层机制。

Sequential::save_to_path 会构建一个 SerializableSequential { magic, format_version, layers: Vec<SerializableLayer> }。每个 SerializableLayer 把一个 LayerInfo { layer_type, output_shape } 元数据标签,和一个从 layer.get_weights() 取得的 LayerWeight<'a> 值配成一对。

开头两个 u32 就是文件头:MODEL_MAGIC"RMLM")和 MODEL_FORMAT_VERSION,它们排在最前面是刻意的。在这个头部存在之前写出的文件,是以层数开头的,于是加载器会在本该是魔数的位置读到那个小整数,从而拒掉这个文件------这发生在加载器解析到足以应用哪怕 1 个权重之前。

排在头部之后的枚举,就是落盘时的权重格式:

rust 复制代码
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, // 没有可训练参数的层:Dropout、pooling、flatten、纯激活层
}

这里有两处设计选择值得记住。第一,每个 per-layer 结构体都用 Cow 存放数组,于是同一个类型能服务于两个方向:get_weightsCow::Borrowed 借用活着的数组,保存时什么都不克隆;加载则填入 Cow::Owned 数组,以 LayerWeight<'static> 的身份使用。

第二,这个枚举用的是 serde 默认的表示法,叫外部标签(externally tagged):在负载之前写入一个显式的变体标签。postcard 非自描述,没有这个标签就分不清一段 Dense 负载和一段 Conv2D 负载。

文件里刻意不带任何构造器信息:没有激活函数,没有 epsilonmomentum、stride、kernel size、dilation,也没有 group count。这些设置活在你的源代码里,不在文件里。LayerInfo 里的 layer_typeoutput_shape 字符串是校验标签,不是搭建配方------这正是为什么加载需要一个预先搭好、随时准备接收权重的模型。

正是这个预先搭好的模型,让神经网络加载器能做到经典加载器做不到的事:校验结构。load_from_path 会检查层数,逐层拿 layer_type 字符串和你搭的模型比对;在 apply_weights_to_layer 内部,它把每一层向下转型成具体类型,再调用 set_weights,这一步也顺带抓住了形状不一致。这些检查中任何一个失败,都会抛出 Error::Io(IoError::ModelStructureMismatch),并带上一条指明问题所在的消息。

经典那套宏没有这样的防护:它把字节直接反序列化进目标结构体,一点检查都没有。把一个 LinearRegression 文件喂给 KMeans::load_from_path,不会被任何类型检查拒绝。postcard 只按位置读字节:多数情况下,两边布局差得够远,你会拿到一个 Serialization 错误;倒霉的情况下,字节碰巧凑得上另一套布局,你就得到一个看起来合法、实则毫无意义的模型。对经典模型而言,文件名或者某个外部标签,是唯一能拦住你犯这个错误的东西。

7.2.4. postcard 的 wire format

postcard 是一种极简的二进制格式,有一个性质左右着它的一切:postcard 是非自描述的。

字节里按字段顺序装的是值,别无其他:没有字段名、没有类型名、没有 schema,也没有一段可以跳过的、带长度前缀的区块。序列化一个 LinearRegression,不会在任何地方写下字符串 "learning_rate",它写的是那个 f64 值的 8 个字节,就摆在 learning_rate 在序列化后的 LeastSquaresSolver 里所处的确切位置上。这既是文件保持小巧的全部原因,也是它脆弱的原因:能否正确读回字节,完全取决于读的一方是否拥有和写的一方完全相同的类型布局。

当你要推算文件大小、或者调试一个损坏的文件时,各类型的编码方式就很重要:

Rust 类型 postcard 编码 字节数
bool 单字节,01 1
f32(神经网络权重) 定宽,小端 4
f64(经典参数) 定宽,小端 8
usize / u64(例如 max_iter,或某个长度) LEB128 varint 1 到 10
枚举变体(SolverLayerWeight varint 判别值 1(外加负载)
Option<T> 1 个标签字节(0 = None,1 = Some) 1(Some 时外加 T
String(层类型标签) varint 长度,再加 UTF-8 字节 不定
ndarray Array(经由 serde) 小的版本与形状头,再加一个 varint 元素数量,再加元素本身 不定

浮点数是定宽的:N 个值的数组,f32 就是 N x 4 字节,f64 就是 N x 8 字节,再加上一个小的形状头和长度前缀。这趟往返是无损的,不是有损的。

整数和长度用的是 varint,所以小的计数只花 1 个字节。postcard 不做压缩,也没有对齐填充。

这个格式的小巧,同时也是它的脆弱:文件里什么都没有标注。设想一个结构体的布局,在写文件和读文件的两个版本之间发生了漂移:一个字段被增加、删除、重排,或者换了类型。反序列化这样一个文件,没有任何名字可供核对。大概率的结果是一个 "unexpected end of input" 错误,或者一个坏标签的 Serialization 错误;危险的结果,是一次悄无声息的错误解析------漂移后的布局恰好消费掉同样数量的字节。

这不是 postcard 的 bug,而是你为小文件付出的代价------也正因如此,版本管理(本页后面会讲)是你的责任,不是格式的责任。

本次发布里,好几个经典 estimator 改了自己的落盘布局。LinearRegression 把 3 个散落的迭代设置折进了求解器负载里;KMeans 多了一个新的 n_init 字段;LDA 多了一个记录训练集总体均值的新字段。这 3 个类型中,任何一个用旧版本保存的文件都加载不了------请重新拟合,再重新保存。

MeanShift 的布局也变了,而且同时改了含义。它的 labels 字段换了类型,从 usize 变成了 isize;一个未被分配的点,它的标签含义也变了------以前等于簇的数量,现在是 -1。除此之外,簇中心本身现在也是由不同的算法算出来的:一个旧的 MeanShift 文件,就算侥幸还能加载进来,装的也是旧高斯核算出的中心,而不是现在的 flat kernel(平顶核)算出的。

IsolationForest 只改了含义,没有改布局:它的 offset 字段类型和位置都没变,但存的那个数现在符号相反了。就算形状还能对得上,MeanShiftIsolationForest 这两个模型也请一并重新拟合、重新保存。

7.2.5. 文件大小:一份可信的粗略估算

postcard 不压缩,也不分帧,所以你能把文件大小预测到几个字节以内。

对经典模型而言,负载由学到的数组主导:一个有 p 个特征的 LinearRegression,系数大约花费 p x 8 字节,再加上二三十字节的标量超参数和头部。

对神经网络而言,把每一层的 (weight_elements + bias_elements) x 4 字节加起来即可,因为神经网络权重用的是 f32;再加上每层那点类型和形状字符串的开销。

一个 Dense(784 -> 128) 层有 784 x 128 + 128 = 100,480 个参数,约 392 KB;同样的层放在经典的、基于 f64 的世界里则要翻一倍。与其猜,不如直接量:

rust 复制代码
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();
    // 主导项是每个系数一个 f64。其余都是标量和小的头部。
    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();
}

实际用起来,这让 postcard checkpoint 便宜到可以大量留存:对一个小模型,跑"每个 epoch 存一次、只留最好那份"的循环,每个 checkpoint 只花几个 KB。多久存一次快照,受限的是磁盘写入速度,而不是文件大小。

对大型卷积堆叠,f32 权重占了文件的大头,文件大小几乎精确跟着参数量走。这让容量规划变得简单:数一数参数,再乘以 4。

7.2.6. 加载到底会拒绝什么

每一次持久化失败都以 Error::Io(...) 的形式浮现,底下正好有 4 种形态。

IoError::Std 包裹一个 std::io::Error:路径不存在、权限问题,或者一次读写失败。

IoError::UnsupportedModelFormat 只用于神经网络,来自头部校验:魔数或格式版本和当前构建对不上。

IoError::Serialization 包裹一个 postcard::Error:字节对代码期望的布局来说不是有效的 postcard,原因可能是损坏、截断,或者 schema 不再匹配。

IoError::ModelStructureMismatch 同样只用于神经网络,如 7.2.3 节所讲。经典模型没有与之对应的东西。

error.rs 里的两个 From 实现,让 ? 操作符能把一个裸的 std::io::Error 提升成 IoError::Std,把一个裸的 postcard::Error 提升成 IoError::Serialization------这个映射在两个子系统里都是一样的。ErrorIoError 都带着 #[non_exhaustive],匹配它们时永远要加一个兜底分支。

rust 复制代码
use rustyml::machine_learning::*;
use rustyml::error::{Error, IoError};

fn main() {
    // 这个文件里装的字节,对 LinearRegression 来说不是有效的 postcard。
    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();

    // 一个不存在的路径会以 IoError::Std 浮现,而不是 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:?}"),
    }
}

神经网络的测试套件对照真实 API 核对了这些映射:不存在的路径产生 IoError::Std;错误的魔数或格式版本产生 IoError::UnsupportedModelFormat;藏在合法头部之后的坏字节产生 IoError::Serialization;层数、层类型或权重形状的不一致,产生的都是 IoError::ModelStructureMismatch

经典模型在这份清单里有个缺口:没有结构性错误可抓,Serialization 就成了文件出错的唯一信号,而它并不可靠。要是一个损坏的经典文件碰巧还是反序列化成功了,load_from_path 就会返回 Ok。用你自己的完整性检查来防这一手------接下来两节要搭的正是这个。

7.2.7. 跨 RustyML 版本的版本管理

postcard 文件不带版本戳,RustyML 也不保证模型结构体的字段布局在各版本间保持稳定。给 LinearRegression 加一个超参数,重排一个字段,或者改一个字段的类型------这些都会让旧版本写出的文件,对新版本来说变成糟糕的输入:通常表现为一次 Serialization 失败,偶尔则是一次悄无声息的错误解析。

RustyML 没有内建的迁移路径。养成下面这两个习惯,能让这件事不至于在生产环境里给你惊吓。

第一,把写出你那些长命 checkpoint 的 rustyml 版本精确锁定,这样一次例行的 cargo update 就没法在一整目录已保存的模型底下,悄悄换掉磁盘布局:

toml 复制代码
[dependencies]
rustyml = { version = "=0.14.0", features = ["full"] }

第二,写一个版本旁挂文件(sidecar):一个保存在模型旁边的小伴生文件,记录格式身份。当它和你的二进制期望的版本对不上时,就拒绝加载模型------这把一次悄无声息的错误解析,变成了一个响亮、及早的错误。

注意谁最需要这个 sidecar。Sequential 的文件自带魔数和格式版本,跨不兼容版本时神经网络这条路本来就会响亮地失败,sidecar 在这里只是再叠上你自己的 schema 身份。经典那条宏路径完全没有头部,对那些模型来说,sidecar 就是你的全部防线。

rust 复制代码
use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

// 每次升级写出 checkpoint 的 rustyml 依赖时,就把这个值往上抬一档。
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();

    // 加载时,先核对记录下来的格式字符串,再反序列化。
    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();
}

让版本字符串足够具体、具体到有意义:至少用 rustyml 的版本号;如果你把模型包进了更大的记录里,再加上你自己的 schema 计数器。这个 sidecar 写起来几乎不花成本,却把这个格式最糟的失败模式------悄悄给出错误数字------换成了一个你在测试阶段就能抓到的 panic

7.2.8. 原子 checkpoint

两个 save_to_path 方法都调用 File::create,它会立刻截断目标文件。设想进程崩溃、磁盘写满,或者训练循环写到一半被杀掉------你就会在重启逻辑要加载的那个确切路径上,留下一个被截断的文件。截断往好了说是一个 Serialization 错误,往坏了说,会悄悄给你一个偏短的数组。

标准的防御是先写后改名:把模型序列化到同一个文件系统上的一个临时路径,再调用 std::fs::rename 把它移到最终路径上。在 POSIX 文件系统上,rename 是原子的,所以读者要么看到完整的旧文件,要么看到完整的新文件,绝不会看到写到一半的那个。

rust 复制代码
use rustyml::machine_learning::*;
use ndarray::{Array1, Array2};

/// 经由一个临时文件,再做一次原子 rename 来保存。写入过程中的崩溃,
/// 绝不会在 `final_path` 留下一个只写了一半的 checkpoint。
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();
}

把临时文件放在和目标同一个文件系统上:跨文件系统的 rename 不是原子的,会退化成先复制再删除。

把这个做法和 7.2.7 节的 sidecar 配合起来:先给模型文件改名,再给版本文件改名。这样一次撕裂的写入,留下的版本 sidecar 指向的是上一个完整的模型,而不是一个损坏的新模型。

对一个只保留最优 checkpoint 的循环,原子替换还意味着你那份目前最好的文件,永远不会有哪怕一瞬间的缺席。

7.2.9. 跨越语言边界

postcard 属于 Rust 生态。没有一个有人维护的 Python 或 R 读取器能读它;就算有,那些非自描述的字节也需要一份手写的、镜像 RustyML 确切结构体布局的 schema------而这个布局在版本之间还会变。

把一个 .bin checkpoint 当成 RustyML 到 RustyML 的产物,别的什么都不是。当另一个工具需要读一个训练好的模型时,不要去解析那个 postcard 文件,而是通过 getter 把参数导出成对方本就能读的可移植格式。经典模型把你需要的一切都暴露了出来:LinearRegression::get_coefficientsget_interceptKMeans::get_centroids,以及其他 estimator 上对应的访问器。

rust 复制代码
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();

    // 通过 getter 把学到的参数取出来,再写出可移植的 CSV。
    // Python、R 或电子表格的消费方能直接读这个文件,postcard 完全不参与。
    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}");
}

同样的套路可以用在别处:把 getter 的输出转储成 CSV,给电子表格或者 pandas 的 read_csv 用;或者把它拼装成一个你的服务本就会读的 JSON 对象。

对神经网络,遍历 Sequential::get_weights(),把每个 LayerWeight 变体的数组逐层写出来。这些数组是 ndarray,所以 .iter() 加上你自己的格式化方式就够了。

这么做要付出两个代价:postcard 的紧凑,以及同格式往返的精确性保证。把 postcard 文件留作你的规范 checkpoint,用来加载回 RustyML;把导出的 CSV 或 JSON 只当成给其他工具用的单向视图。

导出时,记住 getter 包含什么、不包含什么:经典导出捕获的是拟合出的参数,但和 postcard 文件不同,它丢掉了超参数和元数据;神经网络导出则只有权重,原因和 3.9 节讲的一样。

另外两个相邻的运维话题,参见邻近的两页:7.1. 可复现性与随机种子 讲加载后如何给模型重新设种子,好让恢复的洗牌仍然可复现;7.3. 性能调优与并行 讲并行写出大量 checkpoint 时的吞吐量。

相关推荐
知几蜗牛1 小时前
0 后端 · 0 数据库 · 0 备案:用 AI 两天搓出的股票管理系统,开源了
前端·后端·llm
yaoxin5211231 小时前
502. Java 反射 - 编写 MessageInterceptor 类
java·开发语言
wuyk5551 小时前
Python零基础入门第五章:元组Tuple(不可变容器详解、列表与元组区别)
开发语言·python
风流 少年1 小时前
Spring AI 2.0:阿里云百炼平台(工作流应用)
java·后端·spring
zhanghaha13141 小时前
Python进阶教程:13_math 模块 —— 新手完全指南
数据库·python·机器学习
2601_965798471 小时前
Build a Fast, High-Ranking Restaurant Website with Rolanda Theme
开发语言·ios·swift
__zRainy__1 小时前
Node系列 · 数据库:单表查询
数据库·后端·mysql·node.js
caimouse1 小时前
ReactOS 窗口系统分析(25):标题栏显示与系统按钮 — nonclient.c 标题栏专题
c语言·开发语言
诺伦1 小时前
Rust 错误处理实战:从 unwrap 到优雅 Result 的进阶之路
开发语言·后端·rust