← 返回主题列表
Q
QianXun
@QianXun · 2026年06月13日 09:25 · 0浏览

《GoMLX从入门到精通》第12章:实战:CIFAR-10 图像分类全流程

Chapter 12

---

12.1 项目全景

项目要素详情
任务32×32 彩色图像,10 类(飞机/汽车/鸟/猫/鹿/狗/蛙/马/船/卡车)
数据量50,000 训练 + 10,000 测试
模型简化 ResNet-18(去掉瓶颈层,适配小图)
目标准确率训练集 >95%,测试集 >88%
代码量约 300 行 Go 代码
训练时间CPU 约 1-2 小时(SimpleGo),GPU 约 10-20 分钟(XLA)
---

12.2 数据准备

import "github.com/gomlx/gomlx/pkg/ml/dataset"

// CIFAR-10 二进制格式解析(简化——实际项目可能从文件或预加载张量读)
func loadCIFAR10() (trainDS, testDS train.Dataset) {
    // 加载训练集:50000 × [32, 32, 3] 的 uint8 图像
    trainImages := loadBinaryImages("cifar-10-batches-bin/data_batch_*.bin")
    trainLabels := loadBinaryLabels("cifar-10-batches-bin/data_batch_*.bin")

    // 数据增强(Data Augmentation)
    augmentFn := func(images, labels *tensors.Tensor) (*tensors.Tensor, *tensors.Tensor) {
        // 1. 随机水平翻转(50% 概率)
        // 2. 随机裁剪 + padding(32×32 → 40×40 → 随机裁回 32×32)
        // 3. 归一化到 [0, 1],再标准化(减均值/除方差)
        return augmentedImages, labels
    }

    trainDS = dataset.Custom(
        "CIFAR-10 Train",
        func() (chan *train.Batch, chan error) {
            // 每批 128 张图,shuffle,应用增强
            return batchedIterator(trainImages, trainLabels, 128, true, augmentFn)
        })

    // 测试集:不增强,只归一化
    testDS = dataset.Custom(
        "CIFAR-10 Test",
        func() (chan *train.Batch, chan error) {
            return batchedIterator(testImages, testLabels, 128, false, normalizeOnly)
        })

    return
}

数据增强的意义:每张原始图片在每次训练 epoch 中都被随机变换——模型永远不会看到"完全相同的"输入两次。这极大提升了泛化能力——等价于无限扩增了训练集。

---

12.3 模型:简化 ResNet-18

ResNet 的核心创新是残差连接(Skip Connection)。一个残差块长这样:

import (
    "github.com/gomlx/gomlx/pkg/ml/context"
    "github.com/gomlx/gomlx/pkg/core/graph"
    "github.com/gomlx/gomlx/pkg/ml/layers"
    "github.com/gomlx/gomlx/pkg/ml/layers/activations"
    "github.com/gomlx/gomlx/pkg/ml/layers/batchnorm"
)

func resnetBlock(ctx *context.Context, x *graph.Node, filters int, stride int) *graph.Node {
    residual := x  // 保存短路连接
    inputChannels := x.Shape().Dimensions[len(x.Shape().Dimensions)-1]

    // 主路径:Conv → BN → ReLU → Conv → BN
    x = layers.Convolution(ctx.In("conv_a"), x).Channels(filters).
        KernelSizePerAxis(3, 3).Strides(stride).PadSame().Done()
    x = batchnorm.New(ctx.In("bn_a"), x, -1).Done()
    x = activations.Relu(x)

    x = layers.Convolution(ctx.In("conv_b"), x).Channels(filters).
        KernelSizePerAxis(3, 3).Strides(1).PadSame().Done()
    x = batchnorm.New(ctx.In("bn_b"), x, -1).Done()

    // 如果 stride > 1 或通道数变了,短路也需要投影
    if stride > 1 || inputChannels != filters {
        residual = layers.Convolution(ctx.In("proj"), residual).Channels(filters).
            KernelSize(1).Strides(stride).Done()
    }

    // 残差连接:主路径 + 短路
    x = graph.Add(x, residual)
    return activations.Relu(x)
}

残差连接为什么有效:传统深层网络中,梯度在反向传播时一层一层衰减(梯度消失)。残差连接给梯度提供了一条"高速公路"——梯度可以直接从输出跳到输入,绕过中间层。这使得 152 层甚至更深的网络也能有效训练。

完整的 ResNet-18 结构:

输入 [batch, 32, 32, 3]
  → Conv 3×3, 64, stride=1
  → ResBlock × 2, 64 通道
  → ResBlock × 2, 128 通道 (stride=2, 尺寸 16×16)
  → ResBlock × 2, 256 通道 (stride=2, 尺寸 8×8)
  → ResBlock × 2, 512 通道 (stride=2, 尺寸 4×4)
  → Global Average Pooling
  → Dense(512 → 10)
  → 输出 [batch, 10]

---

12.4 训练策略

import (
    "github.com/gomlx/gomlx/backends"
    "github.com/gomlx/gomlx/pkg/ml/context"
    "github.com/gomlx/gomlx/pkg/ml/context/checkpoints"
    "github.com/gomlx/gomlx/pkg/ml/layers/regularizers"
    "github.com/gomlx/gomlx/pkg/ml/train"
    "github.com/gomlx/gomlx/pkg/ml/train/losses"
    "github.com/gomlx/gomlx/pkg/ml/train/metrics"
    "github.com/gomlx/gomlx/pkg/ml/train/optimizers"
    "github.com/gomlx/gomlx/pkg/ml/train/commandline"
)

func main() {
    backend, _ := backends.New()

    // 1. 加载数据
    trainDS, testDS := loadCIFAR10()

    // 2. 创建 Context 和超参数
    ctx := context.New()
    ctx.SetParam(optimizers.ParamLearningRate, 0.1) // 初始学习率
    ctx.SetParam(regularizers.ParamL2, 1e-4)       // L2 正则化

    // 3. 创建 Checkpoint Handler
    checkpoint := checkpoints.Build(ctx).Dir("cifar10-checkpoint").MustDone()

    // 4. 创建 Trainer
    // 注意:SGD 默认带 1/sqrt(step) 学习率衰减。如需 Cosine Annealing,
    // 需在模型图内调用 cosineschedule.New(ctx, g, dtype).FromContext().Done()
    trainer := train.NewTrainer(backend, ctx,
        ResNet18,                         // 模型函数
        losses.SparseCategoricalCrossEntropyLogits, // 损失
        optimizers.StochasticGradientDescent(), // SGD
        []metrics.Interface{              // 训练评估指标
            metrics.NewSparseCategoricalAccuracy("accuracy", "acc"),
        },
        nil)

    // 6. 创建 Loop + 钩子
    loop := train.NewLoop(trainer)
    commandline.AttachProgressBar(loop)

    // 每 1000 步评估测试集
    evalHook := train.EveryNSteps(loop, 1000, "test_eval", 0,
        func(loop *train.Loop, metrics []*tensors.Tensor) error {
            return loop.Trainer.Eval(testDS)
        })
    loop.AttachHook(evalHook)

    // 每 5000 步保存 Checkpoint
    saveHook := train.EveryNSteps(loop, 5000, "save", 0,
        func(loop *train.Loop, _ []*tensors.Tensor) error {
            return checkpoint.Save()
        })
    loop.AttachHook(saveHook)

    // 7. 跑训练
    trainMetrics, err := loop.RunSteps(trainDS, 64000)
    if err != nil {
        log.Fatal(err)
    }
    _ = trainMetrics
    fmt.Println("Training completed.")
}

为什么 Cosine Decay? 训练开始时大步子(lr=0.1),快速靠近最优区。接近结束时小步精细调整。Cosine 曲线比手动阶梯式衰减更平滑,在实践中往往给出更好的最终结果。

---

12.5 评估与分析

训练完成后,不只看一个数字:

// 1. 在测试集上的最终准确率
testMetrics, _ := trainer.Eval(testDS)
fmt.Printf("Test accuracy: %.2f%%\n", testMetrics[0].Value())

// 2. 混淆矩阵——哪些类别容易搞混?
// (需要你在 testDS 上逐批推理,对比预测和真实标签)
// 典型发现:猫 vs 狗是 CIFAR-10 最难区分的两个类

// 3. 错误案例分析
// 做完混淆矩阵后,抽出"模型判错"的几张图片肉眼观察。
// 常见原因:
//   - 图像太模糊(32×32 本身分辨率低)
//   - 物体被遮挡或只在角落
//   - 标注本身有歧义(那张图里的到底是"鹿"还是"马")

---

12.6 模型导出

训练好的模型,不只是"在训练代码里能用"。要把它变成一个独立的推理服务。

import (
    "github.com/gomlx/gomlx/backends"
    "github.com/gomlx/gomlx/pkg/ml/context"
    "github.com/gomlx/gomlx/pkg/ml/context/checkpoints"
)

// 1. 保存 Checkpoint
checkpoint := checkpoints.Build(ctx).Dir("cifar10-resnet18").MustDone()
checkpoint.Save()

// 2. 在推理程序中加载
func loadModel(backend backends.Backend, ckptPath string) *context.Context {
    ctx := context.New()
    checkpoints.Load(ctx).Dir(ckptPath).MustDone() // 加载所有权重
    return ctx
}

// 3. 创建纯推理 Exec
inferenceExec, err := context.NewExec(backend, ctx, ResNet18)
if err != nil {
    log.Fatal(err)
}

// 4. 推理——只需要输入图像,不需要训练数据
image := loadAndPreprocessImage("cat.jpg")
logits := inferenceExec.MustExec1(image)
predictedClass := argmax(logits.Value())

---

12.7 收束:从知道到做到

第 8 章你手写了训练循环,知道了里面每一根水管怎么接。

第 9 章你拿到了标准件——Dataset、Trainer、Loop。

第 10 章你学会了搭积木,从全连接层到 Dropout、BatchNorm。

第 11 章你掌握了 CNN 和 Attention 两种最重要的架构。

第 12 章你把这些全部组装在一起,跑完了一个完整的图像分类项目。Code 是模板,知识是骨架。从这一章出发,你可以修改 ResNet-18 的深度、换用不同的数据增强策略、接入你自己的数据集。

第 13 章讲模型导出和 ONNX 互通——让你的模型从"项目内的函数"变成"可以分发、部署、商用的资产"。

---

> 作者:步子哥,资深AI研究员 | 连载中:《GoMLX从入门到精通》

[上一章:第11章 卷积与注意力——两大基础架构] | [下一章:第13章 模型保存、导出与 ONNX 互操作]

暂无表态
💬 讨论回复 (0)
推荐

🌟 智谱 GLM-5 已上线

我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。

🎁 领取 2000万 Tokens