一、端侧训练的现实与挑战

说到端侧训练,很多朋友第一反应是:“手机那点算力,能训练模型?” 嗯,我几年前也是这么想的。直到有一次,我在做一个实时手势识别项目,用户数据分布跟训练集差太远,模型在云端更新又延迟太高…… 那时候我才意识到,端侧训练不是“能不能”的问题,而是“怎么做得巧”的问题。

说白了,端侧训练的核心价值就两条:隐私保护实时自适应。数据不出设备,模型越用越懂你。但代价也很明显——算力、内存、功耗,样样都得精打细算。

核心矛盾: 端侧训练不是要把云端那套搬过来,而是要在资源受限的环境下,找到“够用就好”的更新策略。

二、NNAPI 对训练的支持现状

NNAPI 从 Android 8.1 开始引入,最初只支持推理。到了 Android 10,开始加入一些训练相关的扩展,但说实话,直到 Android 12 之后,才真正有了可用的训练原语。

2.1 当前支持的操作

NNAPI 目前支持的训练相关操作,主要集中在前向传播梯度计算上。我整理了一下,大概包括这些:

操作类型 具体算子 说明
前向传播 ADD, MUL, CONV_2D, AVERAGE_POOL_2D, SOFTMAX, RELU 等 与推理一致,直接复用
损失函数 MEAN_SQUARED_ERROR, SOFTMAX_CROSS_ENTROPY Android 12 新增
梯度计算 GRADIENT_OP(包装器),支持自动微分 需配合训练图使用
参数更新 不直接提供优化器,需手动实现 后面会细说

我的经验: 别指望 NNAPI 能像 PyTorch 那样自动求导。它提供的梯度计算是“显式”的——你得自己构建反向传播图。说白了,就是手动把链式法则写出来。

2.2 训练图的构建方式

NNAPI 的训练图与推理图不同。推理图是 DAG(有向无环图),而训练图需要包含前向反向两条路径。我习惯的做法是:

  1. 先构建前向图,得到预测值和损失值。
  2. 再调用 ANeuralNetworksCompilation_setTrainingMode 开启训练模式。
  3. 然后添加梯度计算节点,最后添加参数更新节点。
// 伪代码示意
ANeuralNetworksModel* model;
ANeuralNetworksModel_create(&model);

// 1. 添加前向操作(输入 -> 卷积 -> 全连接 -> 输出)
// ... 省略具体操作添加 ...

// 2. 添加损失函数
ANeuralNetworksModel_addOperation(model, ANEURALNETWORKS_MEAN_SQUARED_ERROR, ...);

// 3. 开启训练模式
ANeuralNetworksCompilation* compilation;
ANeuralNetworksCompilation_create(model, &compilation);
ANeuralNetworksCompilation_setTrainingMode(compilation, true);

// 4. 添加梯度计算(NNAPI 内部自动处理)
// 5. 手动添加参数更新操作(如 SGD 更新)

注意: 目前 NNAPI 的训练模式仍处于实验阶段。不同厂商的驱动实现差异很大。我在某款骁龙芯片上测试时,发现梯度计算精度与 CPU 参考实现有 1% 左右的偏差。所以,务必在真机上验证

三、在线学习思路

在线学习,说白了就是“边用边学”。模型部署到设备后,每来一条新数据,就做一次微调。这跟传统的离线训练完全不同——你没法做 epoch,也没法 shuffle。

3.1 增量更新 vs 全量重训

我见过不少团队,一开始想全量重训,结果手机发热、内存爆掉。后来都老老实实改成增量更新。核心思路是:

  • 只更新最后几层:冻结底层特征提取器,只微调分类头。这样计算量能减少 80% 以上。
  • 使用小批量:每次只拿 1-4 个样本做更新。别贪多,端侧内存扛不住。
  • 限制更新步数:每轮在线学习只做 1-5 步更新。多了反而过拟合。

一个实用的策略: 我在项目中用的是“滑动窗口 + 优先级采样”。维护一个 100 条样本的缓存,每次从中选 4 条“最难”的样本(损失最大的)来更新。效果比随机采样好很多。

3.2 何时触发在线学习?

不是每条数据都值得学。我总结了几种触发条件:

  1. 预测置信度低:比如 softmax 输出小于 0.6,说明模型不确定,需要学习。
  2. 用户显式反馈:用户点了“纠正”或“标记错误”,立即触发更新。
  3. 周期性微调:每天凌晨充电时,用当天积累的数据做一次批量更新。

你想想看,如果每帧都更新,手机电量撑不过半天。所以,触发策略比更新算法更重要

四、参数更新策略

参数更新是端侧训练的核心。NNAPI 不提供现成的优化器,你得自己实现。这里我分享几种常用的策略。

4.1 随机梯度下降(SGD)

最简单的方案。每次更新:

// 参数更新公式:θ = θ - lr * gradient
// 在 NNAPI 中,你需要手动添加一个 SUB 操作
ANeuralNetworksModel_addOperation(model, ANEURALNETWORKS_SUB, ...);
// 输入:当前参数,梯度乘以学习率
// 输出:更新后的参数

但 SGD 在端侧有个问题:梯度噪声大。因为每次只用一个样本,梯度方向可能很偏。我建议加上 momentum:

// 带 momentum 的 SGD
// v = 0.9 * v + lr * gradient
// θ = θ - v
// 需要维护一个动量缓冲区 v

我的习惯: 学习率设成 0.001 起步,然后根据损失变化动态调整。如果损失连续 3 步上升,就把学习率减半。这个“自适应学习率”的小技巧,在端侧很管用。

4.2 参数冻结与分层更新

前面提到,只更新最后几层。具体怎么做?

  • 在构建训练图时,只对需要更新的参数添加梯度计算节点
  • 其他参数的梯度直接设为 0,或者干脆不连接反向路径。
  • 我通常冻结前 80% 的层,只更新最后 20%。
// 示例:只更新全连接层的权重
// 卷积层参数不参与梯度计算
ANeuralNetworksModel_addOperation(model, ANEURALNETWORKS_GRADIENT_OP, 
    /* 只对 fc_weight 和 fc_bias 计算梯度 */);

4.3 联邦学习风格的聚合

如果你有多个设备,还可以做简单的“端侧联邦”。每个设备本地更新几步,然后把参数差值上传到云端聚合。不过,这已经超出 NNAPI 的范围了,属于上层策略。

避坑指南: 我曾经在实现参数更新时,忘记把学习率乘到梯度上,结果模型越学越差。调试了一整天才发现是标量乘法没加。所以,一定要在 CPU 上先跑通参考实现,再移植到 NNAPI。

五、知识体系总览

下面这张图,是我对本章内容的总结。你可以看到,端侧训练从底层 NNAPI 支持,到上层更新策略,是一条完整的链路。

端侧训练知识体系 NNAPI 训练支持 前向 + 梯度 + 损失 在线学习思路 增量更新 · 触发策略 参数更新策略 SGD · 分层 · 联邦 前向传播算子 CONV_2D, RELU, ADD 损失函数 MSE, 交叉熵 梯度计算 GRADIENT_OP 增量更新 只更新最后几层 触发策略 低置信度 · 用户反馈 小批量采样 滑动窗口 · 优先级 SGD + Momentum 学习率动态调整 分层更新 冻结底层,微调顶层 联邦聚合 多设备参数平均 端侧训练 = 轻量 · 实时 · 自适应

从图中你可以看到,NNAPI 提供了底层计算能力,在线学习决定了“什么时候学”,参数更新策略决定了“怎么学”。三者缺一不可。

最后说一句: 端侧训练目前还不是银弹。如果你的模型很大(比如超过 10MB),或者需要频繁更新,还是建议走云端。但如果你做的是轻量级、实时性要求高的场景——比如关键词唤醒、手势识别——那端侧训练绝对值得投入。

公众号:蓝海资料掘金营,微信deep3321