1. 从一次"训练到一半就NaN"说起:ModelArts到底适合谁
如果你正在看这篇笔记,大概率是三种人之一:第一种,手头有数据、有想法,但本地机器只有一张消费级显卡,跑个ResNet预训练模型微调都要等一晚上;第二种,公司或比赛要求用云平台交付,华为云ModelArts是明确指定的工具链;第三种,纯粹想搞清楚"模型训练"和"模型部署"这两件事在云上到底是怎么串起来的。不管你是哪一种,这篇笔记都按"我自己踩过一遍"的顺序来讲,不绕弯子。
先说结论性的判断:ModelArts不是那种"点两下就出模型"的魔法平台,它更像一套把数据标注、训练作业、超参调优、模型管理、在线服务这几件事打包好的工程化流水线。它的价值不在于帮你写模型代码,而在于帮你解决"代码写完之后那一堆脏活"——环境依赖、GPU排队、断点续训、版本管理、上线接口。你要是只跑一个MNIST练手,用不用它差别不大;但一旦涉及多轮实验、多人协作、需要对外提供推理接口,它的优势就出来了。
我这次的任务很典型:拿一个ResNet预训练模型做迁移学习,数据是自己整理的小规模图像分类集,最终要部署成一个能通过HTTP调用的在线服务。中间遇到的第一个大坑就是标题里那个热搜词——模型训练报NaN。这个问题在本地跑的时候偶尔出现,到了云上因为换了GPU型号、换了batch size,出现得更频繁。所以这篇笔记的结构不是"先讲概念再讲操作",而是按真实推进顺序:先把数据喂进去,再解决训练不收敛,再谈部署,最后聊成本。
提示:ModelArts的界面和API在不同区域(Region)会有细微差异,本文以主流区域的通用流程为准,具体按钮位置以你实际控制台为准。
2. 数据准备:从"本地一堆文件夹"到ModelArts能认的格式
2.1 为什么不能直接把本地目录拖上去
很多人第一次用ModelArts,习惯性地把本地dataset/train/cat/xxx.jpg这种ImageFolder结构整个压缩上传,然后在训练脚本里用torchvision.datasets.ImageFolder去读。这个做法在本地没问题,但在云上会踩两个坑:第一,训练作业的输入路径是只读挂载的,你没法在里面做数据增强后的写回;第二,大文件上传如果走网页端,超过一定体积就会断,而且没有断点续传。
正确的思路是:数据先进入OBS(对象存储),再通过训练作业的输入配置挂载到容器里。OBS相当于云上的一个超大网盘,ModelArts的训练作业、Notebook、在线服务都能直接读它。你可以把它理解成"云上的硬盘",只不过访问方式是S3风格的API。
2.2 上传数据的三种方式与选择逻辑
| 方式 | 适用场景 | 优点 | 坑点 |
|---|---|---|---|
| 控制台网页上传 | 小数据集(<1GB) | 零配置 | 大文件易断,不支持文件夹拖拽 |
| OBS Browser+客户端 | 中等数据集(1-50GB) | 图形化,支持断点续传 | 需要单独下载客户端 |
| obsutil命令行 | 大数据集、脚本化 | 可写进自动化流程 | 需要配置AK/SK,权限要小心 |
我这次数据大概8GB,用的是obsutil。配置好AK/SK之后,一条命令搞定:
obsutil cp -r -f ./local_dataset obs://my-bucket/dataset/ --parallel=8 --threshold=100MB这里--parallel=8是并发数,--threshold=100MB表示超过100MB的文件才启用分片上传。实测下来,8GB数据在普通宽带上大概十几分钟传完。注意AK/SK是敏感凭证,不要写进代码仓库,用环境变量或者配置文件单独管理。
2.3 数据格式的"隐性要求"
ModelArts对数据格式本身没有强制要求,但你的训练脚本对格式有要求。这里有个经验:在OBS里保持和本地一致的目录结构,比如:
obs://my-bucket/dataset/ ├── train/ │ ├── class_a/ │ └── class_b/ └── val/ ├── class_a/ └── class_b/然后在训练脚本里,输入路径参数直接指向挂载后的/home/ma-user/work/dataset或者/cache/dataset。为什么强调这个?因为一旦你在OBS里把结构打乱,后面做增量训练或者换模型时,数据加载代码就得重写,非常痛苦。
注意:训练作业的输入路径挂载点在不同镜像里可能不同,常见的是
/home/ma-user/work/和/cache/。/cache/通常性能更好但容量有限,适合放解压后的数据;/home/ma-user/work/容量大但IO可能慢一些。大数据集建议先解压到/cache/。
3. 训练作业配置:那些文档里不会写的参数细节
3.1 镜像选择:为什么我不推荐"自动选择"
创建训练作业时,ModelArts会让你选镜像。它有一个"自动选择"的选项,会根据你的框架代码猜一个。我试过两次,一次猜对了,一次给我选了个CPU版本的PyTorch,结果训练跑了半小时才发现没用上GPU。所以镜像一定要手动选,而且要看清三个信息:框架名、版本号、CPU/GPU标识。
我这次用的是PyTorch 1.8 + CUDA 10.2 + GPU版本。为什么不用更新的版本?因为我的代码里有些旧的API,新版本PyTorch会有warning甚至报错。版本选择的第一原则是"和本地调试环境一致",而不是"越新越好"。本地跑通了,云上换版本就是给自己找麻烦。
3.2 计算资源配置:GPU型号与batch size的联动
这是最容易出问题的地方。ModelArts提供的GPU型号有V100、P100、T4、A100等,不同型号的显存不一样。我本地用的是某款消费级显卡(显存8GB),云上选了V100(显存32GB)。看起来是升级了,但问题来了:我本地batch size设的是16,云上显存大了,我顺手改成64,结果训练到第200个step就NaN了。
原因不复杂:batch size变大,等效学习率也应该相应调整。我本地用的学习率是1e-3,batch size从16变到64,学习率没变,梯度更新的方差变大,很容易发散。后来改成4e-3(按线性缩放原则)并加了warmup,才稳定下来。
| 参数 | 本地值 | 云上初值 | 云上修正值 |
|---|---|---|---|
| batch size | 16 | 64 | 64 |
| 初始学习率 | 1e-3 | 1e-3 | 4e-3 |
| warmup步数 | 0 | 0 | 500 |
| 梯度裁剪 | 无 | 无 | max_norm=1.0 |
这个表建议你直接抄。batch size翻4倍,学习率大致翻4倍,再加warmup和梯度裁剪兜底,这是处理NaN问题的通用套路。
3.3 超参配置与断点续训
ModelArts的训练作业支持超参传入,格式是键值对。我一般会把epochs、batch_size、lr、data_path、output_path都做成超参,这样不用改代码就能调。输出路径一定要指向OBS,否则训练结束容器销毁,模型文件就没了。
断点续训是个救命功能。训练作业如果因为超时或者手动停止中断,只要输出路径里有checkpoint,重新提交时指定resume=True就能接着跑。但有个坑:checkpoint的保存频率别设太高,我一开始每个epoch存一次,结果8GB的数据集,checkpoint文件累积了几十GB,OBS存储费用蹭蹭涨。后来改成每5个epoch存一次,只保留最近3个。
# 训练脚本里保存checkpoint的典型写法 torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, }, os.path.join(args.output_path, f'checkpoint_epoch{epoch}.pth'))提示:OBS的存储是按量计费的,checkpoint文件多且大时,记得定期清理旧版本。可以在训练脚本里加一段逻辑,只保留最新的N个checkpoint。
4. NaN问题的完整排查链路:从数据到梯度
4.1 第一步:确认是不是数据的问题
训练报NaN,第一反应通常是"数据里有脏东西"。我的排查顺序是:
- 检查输入数据是否有NaN或Inf。图像数据一般不会有,但如果你做了归一化,除数为0就会产生Inf。我写了个小脚本,在数据加载后打印每个batch的min/max/mean,发现有一个batch的max是Inf。
- 定位到具体文件。用二分法把数据集切一半,分别跑几个step,看哪一半会触发NaN。最后定位到几张损坏的JPEG文件,解码后像素值异常。
- 修复方式:在
__getitem__里加try-except,遇到损坏文件就跳过或者用随机噪声替代。
这一步花了大概两个小时,但值得。因为如果数据本身有问题,你在模型和超参上怎么调都是白费。
4.2 第二步:检查损失函数与标签范围
数据没问题之后,看损失函数。我用的交叉熵,标签是0到N-1的整数。如果标签越界(比如有-1或者大于类别数的值),交叉熵会返回NaN。这个用一行代码就能查:
assert labels.min() >= 0 and labels.max() < num_classes, "标签越界"另外,如果用了BCEWithLogitsLoss,输入不能先过sigmoid,否则会数值不稳定。这个坑我在另一个项目里踩过,这里提一句。
4.3 第三步:梯度爆炸与混合精度
排除了数据和损失函数,基本就是梯度问题了。V100支持混合精度训练(AMP),能省显存、加速,但混合精度下梯度更容易溢出。我一开始开了AMP,NaN出现频率明显变高。后来加了梯度裁剪和loss scaling,才稳住。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in dataloader: optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update()这段代码的关键是scaler.unscale_之后再裁剪,顺序反了裁剪就不生效。梯度裁剪的max_norm一般设1.0到5.0之间,我设1.0比较保守,训练稍慢但稳。
4.4 第四步:学习率与warmup的配合
如果以上都做了还NaN,那大概率是学习率太大。特别是用预训练模型微调时,主干网络的学习率应该比新加的分类头小一个数量级。我后来的做法是分组设置学习率:
params = [ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.classifier.parameters(), 'lr': 1e-3}, ] optimizer = torch.optim.Adam(params)再加上500步的warmup,NaN基本绝迹。这套组合拳打下来,我从"每跑必NaN"变成了"连续跑20个epoch稳定收敛"。
5. 模型部署:从"训练完的pth"到"能调用的API"
5.1 模型包的目录结构有讲究
训练完成后,OBS里会有一个model.pth。但ModelArts部署在线服务时,不是直接传一个pth文件就行,它要求一个模型包,里面有固定的目录结构和配置文件。典型结构:
model/ ├── model.pth ├── config.json └── custom_service.py (可选)config.json里要写清楚推理入口、依赖包、Python版本等。我一开始漏了custom_service.py,结果平台用默认的推理逻辑,输入输出格式对不上,调接口一直报400。后来补上自定义推理脚本才通。
5.2 推理脚本的输入输出设计
自定义推理脚本的核心是实现一个predict函数,入参是请求体,出参是响应体。这里有个经验:输入输出尽量用JSON,且字段名要语义化。我见过有人用纯数组传图像base64,结果前端对接时各种编码问题。我的做法是:
import json import base64 from PIL import Image import io import torch def predict(data): # data 是解析后的JSON img_b64 = data['image'] img_bytes = base64.b64decode(img_b64) img = Image.open(io.BytesIO(img_bytes)).convert('RGB') # 预处理... with torch.no_grad(): output = model(preprocessed) pred = torch.argmax(output, dim=1).item() return {'class_id': pred, 'confidence': float(torch.softmax(output, dim=1).max())}这样前端传{"image": "base64字符串"},拿到{"class_id": 3, "confidence": 0.92},清晰明了。
5.3 在线服务的规格选择与冷启动
部署时会让选规格,比如modelarts.vcpu、modelarts.gpu等。如果模型不大(ResNet50级别),CPU规格其实够用,推理延迟在几十毫秒。GPU规格贵不少,除非QPS很高或者模型很大,否则没必要。
另一个坑是冷启动。在线服务如果一段时间没请求,会自动缩容到零,下次请求来了要重新加载模型,第一次调用可能等好几秒。如果对延迟敏感,可以设置最小实例数为1,但这样一直计费。我的做法是比赛或演示期间设最小1,平时设0省钱。
注意:在线服务的计费是按实例运行时长算的,不是按调用次数。部署完不用了记得停掉,不然会一直扣费。
6. 成本控制与几个容易忽略的细节
6.1 训练作业的计费逻辑
ModelArts训练作业按"使用的资源规格 × 运行时长"计费。这里有个细节:作业排队等待的时间不计费,但一旦开始运行就计费。所以如果你选了稀缺的GPU型号,排队排了两小时,这两小时不花钱;但跑起来之后,哪怕你在调试代码,也是按秒计费。
我的省钱策略是:先用小规格CPU跑通流程,确认代码没问题,再换GPU跑正式训练。调试阶段用CPU,虽然慢,但便宜。正式训练用GPU,一次跑完。
6.2 自动停止与超时设置
训练作业可以设最大运行时长,超时自动停止。这个一定要设,我见过有人忘了设,脚本里有个死循环,跑了一整夜,第二天发现账单多了好几百。一般按预估时间的1.5倍设,比如预计跑2小时,设3小时。
6.3 模型版本管理
ModelArts的模型管理支持多版本,每次部署可以指定版本号。这个功能在迭代时很有用:v1上线了,v2在测试,v3在训练,互不干扰。但要注意模型文件本身存在OBS里,模型管理只是存了元数据和路径。如果你把OBS里的文件删了,模型管理里的版本也就失效了。
6.4 日志查看的姿势
训练作业的日志在控制台能看,但刷新有延迟。我习惯在代码里把关键信息同时打到stdout和OBS的一个日志文件里,这样即使作业结束了,日志还在。排查NaN问题时,我就是靠OBS里的日志文件回溯到具体是哪个step、哪个batch出的问题。
import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(os.path.join(args.output_path, 'train.log')), logging.StreamHandler() ] )这段配置让日志同时输出到文件和控制台,文件存在OBS里,作业结束后还能下载回来看。
7. 关于"免费GPU"和"本地部署"的一点个人看法
热搜词里有"免费gpu训练模型"和"本地部署ai模型",我顺带说两句。ModelArts新用户通常有一些免费额度,比如一定时长的CPU或GPU体验券,用来跑通流程足够了。但免费额度一般有有效期,且规格有限,正式项目还是得算成本。
至于本地部署,如果你的模型是ResNet这种中等规模,一张消费级显卡完全能跑推理。但本地部署的麻烦在于环境依赖和对外服务——你得自己搞定端口映射、并发处理、进程守护。ModelArts的在线服务把这些都封装好了,代价是付费。我的建议是:训练阶段用云,推理阶段看情况。如果只是内部用,本地部署省钱;如果要对外提供服务且要求稳定性,云上部署省心。
最后分享一个我在这次项目里养成的习惯:每次训练作业提交前,先在Notebook里用1个epoch、小批量数据跑一遍完整流程。Notebook是按需计费的,调试成本低,而且能实时看到输出。确认无误再提交正式训练作业。这个习惯帮我省了不少因为低级错误导致的GPU浪费。
另外,关于那个"模型训练报NaN",我再补一句:NaN不是单一原因造成的,它是一个症状。数据、损失函数、学习率、混合精度、梯度裁剪,任何一个环节出问题都可能触发。排查时不要跳步,按"数据→损失→梯度→学习率"的顺序逐个排除,比盲目调参高效得多。我这次从开始排查到彻底解决,前后改了四版代码,每一版都记录了改动点和对应的训练曲线,这些记录后来成了团队里的排查模板。