
简介这份资源面向医疗AI方向的学习者与研究者提供一套基于视觉变换网络ViT实现自闭症谱系障碍ASD儿童脸部分析检测的完整项目实战代码可用于理解如何用深度学习识别与自闭症相关的面部特征如表情、注视模式与头部姿态适合具备一定PyTorch与Transformer基础、希望切入医疗影像分类场景的开发者。压缩包共39个文件约3.42MB以17个Python脚本为核心涵盖模型定义、训练与评估流程辅以12个YAML配置文件管理不同规模实验参数另有PNG可视化图、编译缓存及说明文档目录按models、configs、datasets、tools等模块组织结构清晰便于二次开发。目前已有158人学习。项目包含ViTASD多尺度模型实现、注意力可视化脚本与数据集加载逻辑读者可据此复现训练流程、调整配置并迁移到其他面部相关神经发育疾病的分析任务中。1. 从一张儿童正脸照说起ViT 做自闭症谱系障碍筛查到底靠不靠谱自闭症谱系障碍ASD的早期筛查临床上长期依赖 ADOS-2、M-CHAT 这类量表加行为观察一个孩子评估下来动辄四十分钟起步还得靠有经验的儿科医生或心理师。问题在于基层和家庭场景里根本没有这么多专业资源很多孩子排到号已经三四岁错过了两到六岁这个干预黄金窗口。于是「能不能用一张正脸照片做初筛」这个念头就自然冒出来了——脸部分析本身不侵入、不需要孩子配合做任务、手机就能拍天然适合做低成本前置筛查。这个项目标题里的技术路线就是用 Vision TransformerViT对儿童正脸图像做特征提取输出一个 ASD 相关的分类倾向。ViT 这几年在主流技术路线里已经是视觉任务的默认选项之一它把图像切成 patch 序列后走自注意力对全局面部构型眼距、内眦褶皱、面中比例、嘴部形态这类弱纹理但强结构的信号比纯卷积更敏感。这篇笔记不吹「AI 诊断自闭症」而是把「基于 ViT 的儿童脸部分析检测」当成一个可复现的深度学习实战项目案例来讲数据怎么组织、ViT 怎么改、训练怎么不翻车、指标怎么读。适合已经会 PyTorch、想找一个真实医学影像方向练手的人也适合做儿童发育筛查产品、想评估这条路可行性的工程师。2. 数据与任务定义把「脸部分析」翻译成模型能学的标签2.1 先想清楚标签从哪来别急着写 Dataset这类项目最容易翻车的地方不是模型是标签。ASD 是行为诊断不是影像诊断所以「脸」和「ASD」之间没有直接的因果链模型学到的更可能是面部形态学特征与 ASD 的统计相关性而不是诊断依据。常见做法是标签来自临床量表ADOS-2 或 CARS 评分或家长填写的 M-CHAT-R把「确诊 ASD」和「典型发育TD」两类儿童的正脸照配对。你要在项目一开始就把这件事写进 README否则后面指标再漂亮也站不住。数据组织上我一般按下面这个结构放方便后续做分层划分dataset/ ├── train/ │ ├── asd/ # 确诊 ASD 儿童正脸图 │ └── td/ # 典型发育儿童正脸图 ├── val/ │ ├── asd/ │ └── td/ └── test/ ├── asd/ └── td/关键约束有三条同一儿童不能同时出现在 train 和 test否则就是数据泄漏指标虚高到 0.99 你会以为模型神了按儿童 ID 分层划分而不是按图片随机划分性别、年龄段要配平因为 ASD 男女比例约 4:1不配平模型会直接学「性别」这个捷径。下面这段代码就是按儿童 ID 做分组划分避免同人跨集import pandas as pd from sklearn.model_selection import GroupShuffleSplit # meta.csv 至少包含: image_path, child_id, label, age_month, gender meta pd.read_csv(meta.csv) gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, test_idx next(gss.split(meta, groupsmeta[child_id])) train_df meta.iloc[train_idx] test_df meta.iloc[test_idx] # 再在 train 内部切一份 val同样按 child_id 分组 gss2 GroupShuffleSplit(n_splits1, test_size0.15, random_state42) tr_idx, val_idx next(gss2.split(train_df, groupstrain_df[child_id])) train_df.iloc[tr_idx].to_csv(train.csv, indexFalse) train_df.iloc[val_idx].to_csv(val.csv, indexFalse) test_df.to_csv(test.csv, indexFalse)GroupShuffleSplit的groups参数就是儿童 ID它保证同一个 ID 的所有图片只会落在一个集合里。test_size0.2是测试集比例random_state固定住方便复现。这一步做完你后面看到的准确率才是可信的。2.2 人脸对齐与裁剪比换模型更能涨点的预处理ViT 对输入的空间布局很敏感脸歪 15 度、下巴被裁掉一截注意力就会跑到背景上去。所以预处理里人脸检测和对齐是必做项常见做法是用 RetinaFace 或 MTCNN 检出人脸框和五个关键点双眼、鼻尖、双嘴角再做仿射变换把眼睛摆到水平位置最后按固定比例裁剪成正方形。import cv2 import numpy as np from insightface.app import FaceAnalysis app FaceAnalysis(allowed_modules[detection]) app.prepare(ctx_id0, det_size(640, 640)) def align_face(img_bgr, out_size224): faces app.get(img_bgr) if len(faces) 0: return None face max(faces, keylambda f: (f.bbox[2]-f.bbox[0])*(f.bbox[3]-f.bbox[1])) # kps: 左眼, 右眼, 鼻, 左嘴角, 右嘴角 kps face.kps.astype(np.float32) left_eye, right_eye kps[0], kps[1] dy right_eye[1] - left_eye[1] dx right_eye[0] - left_eye[0] angle np.degrees(np.arctan2(dy, dx)) eyes_center ((left_eye[0]right_eye[0])/2, (left_eye[1]right_eye[1])/2) M cv2.getRotationMatrix2D(eyes_center, angle, 1.0) rotated cv2.warpAffine(img_bgr, M, (img_bgr.shape[1], img_bgr.shape[0]), flagscv2.INTER_CUBIC) # 以眼睛中心为基准裁剪正方形 x, y eyes_center half out_size // 2 x1, y1 int(x-half), int(y-half) crop rotated[max(0,y1):y1out_size, max(0,x1):x1out_size] return cv2.resize(crop, (out_size, out_size)) # 批量处理 import os for split in [train, val, test]: for cls in [asd, td]: src_dir fdataset/{split}/{cls} dst_dir faligned/{split}/{cls} os.makedirs(dst_dir, exist_okTrue) for name in os.listdir(src_dir): img cv2.imread(os.path.join(src_dir, name)) out align_face(img) if out is not None: cv2.imwrite(os.path.join(dst_dir, name), out)det_size(640,640)是检测输入分辨率儿童脸小的时候可以调到 1024。out_size224是为了对齐 ViT-Base 的默认输入。注意align_face返回None时要记录日志检出失败率超过 5% 说明你的数据里侧脸、遮挡太多得回头筛数据而不是硬训。2.3 类别不平衡与年龄混淆因子ASD 和 TD 样本量往往不对等而且两组孩子的年龄分布经常错开ASD 组偏大因为确诊晚。年龄本身会改变面部比例模型很容易把「年龄」当成「ASD」来学。处理办法有两个一是训练时用WeightedRandomSampler按类别加权采样二是在划分数据时对年龄做匹配让两组年龄分布尽量重叠。前者是代码层面后者是数据层面我一般两个都做。from torch.utils.data import WeightedRandomSampler import numpy as np labels train_df[label].values # 0td, 1asd class_count np.bincount(labels) class_weight 1.0 / class_count sample_weight class_weight[labels] sampler WeightedRandomSampler(sample_weight, num_sampleslen(sample_weight), replacementTrue)class_weight取类别频次的倒数稀有类权重高replacementTrue表示有放回采样保证每个 epoch 抽到的样本数一致。这个 sampler 直接塞进DataLoader(sampler...)就行注意此时不能再设shuffleTrue两者互斥。3. ViT 模型改造从 ImageNet 预训练到儿童脸部分类3.1 为什么选 ViT 而不是 ResNet以及它的代价在儿童脸部分析这个任务上ViT 的优势是自注意力能建模左右脸、眉眼嘴之间的长程关系比如内眦距离和嘴宽的联合模式卷积要靠堆层数才能拿到类似感受野。但代价也很实在ViT 没有卷积的归纳偏置小数据集上从头训基本必翻车必须靠 ImageNet 或更大规模预训练权重迁移。所以选型结论是数据量低于一万张时用vit_base_patch16_224的预训练权重做微调数据量再小退到vit_small或干脆用 CNN 打底。另一个现实约束是显存。ViT-Base 在 224 分辨率下单张前向约 1.5G 显存batch size 32 训练要 12G 以上。显存不够就降 batch 到 16 并开梯度累积或者用混合精度。下面这段是模型构建和微调策略import torch import torch.nn as nn import timm def build_model(num_classes2, drop_rate0.1, freeze_blocks8): # 加载 ImageNet 预训练的 ViT-Base model timm.create_model( vit_base_patch16_224, pretrainedTrue, num_classesnum_classes, drop_ratedrop_rate, ) # 冻结前 freeze_blocks 个 Transformer block只微调后段和分类头 for i, block in enumerate(model.blocks): if i freeze_blocks: for p in block.parameters(): p.requires_grad False return model model build_model().cuda() criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr2e-5, weight_decay0.05 )freeze_blocks8表示冻结前 8 个 blockViT-Base 一共 12 个只训后 4 个加分类头。这样做的好处是小数据集上不容易过拟合坏处是收敛慢需要更多 epoch。lr2e-5是微调的典型量级比从头训的 1e-3 小两个数量级weight_decay0.05配合 AdamW 是 ViT 微调的标准组合。label_smoothing0.1能缓解过拟合和标签噪声医学数据标签噪声通常不小这个参数别省。3.2 数据增强哪些能用哪些会毁掉面部信号通用图像增强里水平翻转要慎用。面部有轻微不对称性而 ASD 相关研究里恰恰关注面部对称性指标翻转会把这个信号抹掉。颜色抖动、随机裁剪、旋转小角度±10 度是安全的。MixUp 和 CutMix 在医学小数据上有时能涨点但会破坏面部结构我一般只在数据量过万时才开。from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1), transforms.RandomAffine(degrees0, translate(0.05, 0.05), scale(0.95, 1.05)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])Normalize的均值方差是 ImageNet 统计值因为用了 ImageNet 预训练权重必须对齐。RandomAffine里degrees0是因为旋转已经单独做了这里只做平移和缩放。注意别加RandomHorizontalFlip理由上面说了。3.3 训练循环与早停指标怎么选才不骗自己医学筛查场景下准确率是最没用的指标。ASD 检出率低、假阴性代价高漏掉一个真 ASD 孩子所以主指标应该看召回率Recall / Sensitivity和 AUC同时盯住特异度Specificity别太低否则健康孩子被误判一堆。下面训练循环里我按验证集 AUC 做模型选择from sklearn.metrics import roc_auc_score, recall_score import numpy as np def evaluate(model, loader): model.eval() all_probs, all_labels [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.cuda() logits model(imgs) probs torch.softmax(logits, dim1)[:, 1] all_probs.extend(probs.cpu().numpy()) all_labels.extend(labels.numpy()) auc roc_auc_score(all_labels, all_probs) preds (np.array(all_probs) 0.5).astype(int) rec recall_score(all_labels, preds) return auc, rec best_auc, patience, wait 0.0, 5, 0 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels imgs.cuda(), labels.cuda() optimizer.zero_grad() loss criterion(model(imgs), labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() auc, rec evaluate(model, val_loader) print(fepoch {epoch} auc{auc:.4f} recall{rec:.4f}) if auc best_auc: best_auc, wait auc, 0 torch.save(model.state_dict(), best_vit_asd.pth) else: wait 1 if wait patience: print(early stop) breakclip_grad_norm_的max_norm1.0是防梯度爆炸的后悔药ViT 微调时偶尔会碰到 loss 突然飙到 nan加上它稳很多。patience5表示验证 AUC 连续 5 个 epoch 不涨就停。阈值 0.5 只是默认实际部署时应该按验证集画 ROC 曲线选一个召回优先的工作点比如把阈值降到 0.35 换更高召回。4. 避坑与排查这类项目最容易踩的五个坑4.1 指标高得离谱先查数据泄漏现象测试集准确率 0.98AUC 0.99兴奋到以为发了顶会。原因同一儿童的多张照片被随机分到了 train 和 test模型记住了这张脸而不是学到了 ASD 特征。解决回到 2.1 节用GroupShuffleSplit按child_id分组划分重新跑一遍指标大概率掉到 0.7 附近那才是真实水平。4.2 模型只学性别和年龄不看脸现象混淆矩阵里 ASD 组几乎全判对但一看样本ASD 组男孩占绝大多数模型其实在学「男孩ASD」。原因性别、年龄分布不配平模型走了捷径。解决划分数据时对性别、年龄段做分层匹配训练时把年龄作为辅助回归任务或多任务头逼模型别只依赖年龄。也可以做消融把性别标签从数据里去掉再训一次对比。4.3 人脸检测失败率高训练集里混进背景图现象训练 loss 降不下去验证集波动大。原因预处理时align_face返回None的样本被静默丢弃或塞了原图导致输入里混入侧脸、遮挡、纯背景。解决统计检出失败率超过 5% 就回头筛数据失败样本单独存一个failed/目录人工看别让它们污染训练集。4.4 显存爆了batch size 只能开到 4现象CUDA out of memory只能把 batch 降到 4训练极慢且 BN/统计不稳。原因ViT-Base 在 224 分辨率下显存占用高加上没开混合精度。解决开torch.cuda.amp混合精度显存能省 40% 左右再用梯度累积把等效 batch 补回来scaler torch.cuda.amp.GradScaler() accum_steps 4 for i, (imgs, labels) in enumerate(train_loader): imgs, labels imgs.cuda(), labels.cuda() with torch.cuda.amp.autocast(): loss criterion(model(imgs), labels) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()accum_steps4表示每 4 个 mini-batch 更新一次参数等效 batch 变成原来的 4 倍。autocast自动把部分算子降到 fp16GradScaler负责防梯度下溢。4.5 换台机器推理结果就变了现象本地跑出来 ASD 概率 0.8部署到服务器变成 0.5。原因预处理不一致比如训练用了对齐裁剪推理时直接 resize 原图或者归一化参数写错。解决把预处理封装成一个函数训练和推理共用同一份代码别在两处各写一遍。推理前打印输入张量的均值和方差和训练时对比对不上就是预处理出问题了。5. 进阶玩法用注意力图验证模型到底在看哪以及一个可复现的评估习惯模型训完只是开始真正决定这个方向值不值得投入的是你能不能解释它看了什么。ViT 的好处是注意力权重可以直接可视化把最后一层[CLS]token 对各 patch 的注意力拿出来叠回原图就能看到模型关注的是眼睛、鼻子还是背景。如果注意力全在头发或背景上说明模型没学到面部信号指标再高也是玄学。import matplotlib.pyplot as plt def visualize_attention(model, img_tensor, patch_size16): model.eval() with torch.no_grad(): # timm 的 ViT 支持 forward_features 拿 token tokens model.forward_features(img_tensor.unsqueeze(0).cuda()) # tokens: [1, 1num_patches, dim]第 0 个是 cls token num_patches tokens.shape[1] - 1 grid int(num_patches ** 0.5) # 取最后一层注意力的近似用 cls token 与 patch token 的相似度代替 cls_token tokens[:, 0, :] patch_tokens tokens[:, 1:, :] attn torch.softmax(cls_token patch_tokens.transpose(1, 2) / (cls_token.shape[-1] ** 0.5), dim-1) attn_map attn[0].reshape(grid, grid).cpu().numpy() plt.imshow(attn_map, cmapjet) plt.colorbar() plt.title(CLS-Patch attention) plt.savefig(attn.png, dpi150)这段用[CLS]与 patch token 的点积相似度做近似注意力图grid是 patch 网格边长224/1614。真正的注意力权重需要 hook 每个 block 的attn模块但近似图已经够判断模型有没有跑偏。跑几十张测试图如果注意力稳定落在眼周和面中说明模型学到了合理的面部区域如果散在背景就得回去查预处理和数据质量。评估习惯上我踩过最大的坑是「只看一个阈值下的指标」。正确做法是画 ROC 和 PR 曲线把召回 0.85、0.90、0.95 三个工作点对应的阈值和特异度都列出来交给临床或产品方去选。下面这张表是我一般会输出的评估摘要格式工作点阈值召回(Sensitivity)特异度(Specificity)准确率高召回0.350.920.610.76平衡0.500.840.780.81高特异0.650.710.900.80筛查场景通常选高召回那一行宁可多转诊也别漏。这张表比一个孤零零的「准确率 0.81」有用得多也是你判断这个方向能不能落地的最直接依据。最后说个我自己的习惯每做完一个医学相关的分类项目我都会拿测试集里预测错得最离谱的十张图单独看一遍逐张问「是数据问题、标签问题还是模型问题」。十次里有七八次能揪出预处理或标签的毛病比调参有用得多。这个方向值不值得做取决于你愿不愿意把数据质量当第一优先级而不是把希望全押在换更大的 ViT 上。希望帮到你。本文还有配套的精品资源点击获取