华为云ModelArts实战:ResNet迁移学习从训练NaN排查到在线服务部署 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/ --parallel8 --threshold100MB这里--parallel8是并发数--threshold100MB表示超过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-3batch size从16变到64学习率没变梯度更新的方差变大很容易发散。后来改成4e-3按线性缩放原则并加了warmup才稳定下来。参数本地值云上初值云上修正值batch size166464初始学习率1e-31e-34e-3warmup步数00500梯度裁剪无无max_norm1.0这个表建议你直接抄。batch size翻4倍学习率大致翻4倍再加warmup和梯度裁剪兜底这是处理NaN问题的通用套路。3.3 超参配置与断点续训ModelArts的训练作业支持超参传入格式是键值对。我一般会把epochs、batch_size、lr、data_path、output_path都做成超参这样不用改代码就能调。输出路径一定要指向OBS否则训练结束容器销毁模型文件就没了。断点续训是个救命功能。训练作业如果因为超时或者手动停止中断只要输出路径里有checkpoint重新提交时指定resumeTrue就能接着跑。但有个坑checkpoint的保存频率别设太高我一开始每个epoch存一次结果8GB的数据集checkpoint文件累积了几十GBOBS存储费用蹭蹭涨。后来改成每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, fcheckpoint_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能省显存、加速但混合精度下梯度更容易溢出。我一开始开了AMPNaN出现频率明显变高。后来加了梯度裁剪和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_norm1.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步的warmupNaN基本绝迹。这套组合拳打下来我从每跑必NaN变成了连续跑20个epoch稳定收敛。5. 模型部署从训练完的pth到能调用的API5.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, dim1).item() return {class_id: pred, confidence: float(torch.softmax(output, dim1).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( levellogging.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不是单一原因造成的它是一个症状。数据、损失函数、学习率、混合精度、梯度裁剪任何一个环节出问题都可能触发。排查时不要跳步按数据→损失→梯度→学习率的顺序逐个排除比盲目调参高效得多。我这次从开始排查到彻底解决前后改了四版代码每一版都记录了改动点和对应的训练曲线这些记录后来成了团队里的排查模板。