做视觉或者深度学习这块时间久了,多少都会遇到一种情况:模型跑通了,精度看着还行,但出问题时你根本不知道它到底哪一步出了问题。之前调一个图像分类任务,训练集里某个类别大量出现带特定纹理的背景,结果模型学到的不是物体本身,而是那个纹理。最后怎么定位到这个问题的?靠的就是把模型中间层打开,看一眼它到底在“看”什么。这其实就是可视化卷积神经网络的特征和过滤器做的事情——把黑盒打开,看特征图、看卷积核、看注意力热力图,用这些信息反向理解模型的行为。
这篇文章我会先拆解可视化的核心思路,把特征图、卷积核、类激活图这三条路线的原理和适用场景讲明白,再给出可以直接跑的PyTorch实操代码,最后把我在实际项目中踩过的坑和排查经验整理出来。无论你是刚入门CNN想理解它内部长什么样,还是已经在做模型调试但总靠猜,这篇文章应该都能给你一些可落地的参考。
1. 为什么要“看见”CNN内部:可视化能解决哪些实际问题
1.1 黑盒困境:从一次模型调优失败说起
先说一个真实场景。某次我接手一个图片分类项目,二分类任务,指标一切正常,验证集准确率也不错。但把模型部署到新场景时,准确率断崖式下跌。一开始我怀疑数据分布问题,重新采样、加增广,折腾了两周都没起色。后来做了一次可视化,把最后一个卷积层的特征图导出来,我直接在图上看到,模型对背景区域的激活强度远高于物体区域。这说明它学到的区分依据根本不是物体的视觉特征,而是背景颜色和纹理。
如果没有可视化,这个问题可能还要很久才能定位。准确率只能告诉你“错了”,但没办法告诉你“错在哪”。这也是可视化第一个也是最核心的价值:把模型内部的决策依据用人类能理解的方式呈现出来,让诊断从猜测变成观察。
后来我把那次经验总结成一个工作流:先看浅层卷积核是否收敛到基础纹理,再看深层特征图是否专注于语义区域,最后用热力图定位讲解模型分类时依赖的图像区域。这套流程后来在多个项目里帮助快速定位数据问题、模型容量问题以及训练策略问题。
1.2 可视化的四个应用方向:从调试到教学
可视化能做的事情,远不止“看个热闹”。我总结下来,至少有四个方向是实际项目中高频用到的:
模型调试验证:训练完成后,验证浅层卷积核是否学到了边缘、纹理特征,深层是否学到了零件、语义特征。如果浅层卷积核看起来是随机噪点,说明训练策略有问题,比如学习率太大或收敛不充分。
样本级分析:单张图片输入模型,观察不同层的特征图,搞清楚它关注的是目标区域还是背景干扰,定位错误分类的具体原因。
可解释性需求:在一些需要向非技术同事或下游客户解释模型行为的场景,热力图类的可视化是最直观的沟通工具,比口头讲“注意力机制”有效得多。
教学与认知理解:如果你在带新人或者自己学习CNN,可视化几乎是不可替代的教具。光看公式很难直观理解“卷积核提取特征”这件事,但把特征图画出来,一眼就能明白浅层提取边缘、深层提取语义是怎么回事。
这里提醒一句,可视化不是万能的。它更多是一个诊断工具,帮你定位问题方向,具体的修复动作(加数据、调结构、改 Loss)还是要靠对任务本身的理解。但它能把“摸着石头过河”变成“看着地图走路”,这个价值已经很大了。
2. 三条可视化路线拆解:特征图、卷积核、注意力热力图
2.1 特征图可视化:看网络“眼前”的画面
特征图可视化,是最直观的一种做法。你把一张图片输入网络,跑到某一层的时候,把中间输出矩阵拿出来,转成图片显示。这里有个关键点:每一层的输出都是多个通道的矩阵,每个通道你可以理解成“在图像上做了一次特定滤波后的响应图”。浅层(比如第一个卷积层)往往响应的是边缘方向、颜色梯度、局部纹理;深层(比如倒数第二个卷积层)响应的是更抽象的模式,比如某个零件的存在、某种结构的组合。
实际操作中,我不会把全部通道都显示,一般每个层随机挑8到16个通道,放在一个格栅图里看。如果某个通道在训练前后响应模式发生明显变化,通常说明模型学到了一些新的特征。很多人第一次跑特征图可视化的时候会得到一个全黑或全白的图,绝大多数情况是归一化方式不对,这个我后面在踩坑部分会详细讲。
2.2 卷积核可视化:直接看“眼睛”本身
特征图反映的是“模型看到了什么”,卷积核可视化则是观察这些“眼睛”本身——也就是卷积层里的权重矩阵。以一个简单的残差网络为例,第一层卷积的权重形状一般是[64, 3, 7, 7],意思是有64个卷积核,每个核是3通道(对应RGB)、7x7大小。
因为输入通道是3,你可以直接把这个权重张量当成64张小图展示,每张是一个7x7的RGB小图。训练好的模型,这64个小图通常能看到明显的边缘、颜色块、条纹等基础模式。如果模型训练得不好或者从随机初始化开始,这些小图就是一片噪声。
但如果卷积层的输入通道很多,比如中间层的卷积核形态可能是[512, 256, 3, 3],没法直接当RGB图显示。这时常见做法有三种:对输入通道维度做平均,得到单通道图;用PCA把256个通道压缩到3个主成分,映射成彩色图;或者用“最大化激活输入”的方法,通过梯度上升合成一个能让该卷积核响应最大的虚拟图片。第一种最简单,第三种最容易出好看又有解释性的结果,不过计算成本高一些,适合对单个卷积核做深入分析。
2.3 类激活图(Grad-CAM):定位“决策依据”在哪里
特征图和卷积核更多是从微观层面理解网络,类激活图则是从宏观定位的角度回答一个实际问题:“模型做出这个分类决定时,到底看了图像的哪些区域”。
Grad-CAM原理可以概括为:对目标类别得分,求最后一个卷积层特征图的梯度,用梯度的全局平均作为每个通道的权重,再把这些通道加权求和,经过ReLU后得到与类别相关的显著区热力图。套个生活类比,就是把每个通道想象成一个“专家”,梯度的幅度就是这位专家对分类结果的“发言权重”,加权求和相当于把专家的意见汇总成一张“关注地图”。
类激活图最大的价值是可以叠加到原图上。你放一张狗的照片进模型,热力图高亮区域如果集中在狗的脸上,说明模型确实根据狗的视觉特征做判断;如果高亮区域散落在背景,说明它可能在走捷径,拿背景信息当成分类依据。这种信息在调试、审核、可解释性汇报场景里太有用了。
2.4 三条路线的定位差异与搭配思路
| 可视化类型 | 观察对象 | 回答的问题 | 典型使用场景 |
|---|---|---|---|
| 特征图可视化 | 中间层输出 | 网络提取到了哪些特征 | 训练状态检查、层级定位 |
| 卷积核可视化 | 卷积层权重 | 模型学到的是什么模式 | 早期训练诊断、结构对比 |
| 类激活图 | 梯度加权特征图 | 分类依据在图像哪个区域 | 错误样本分析、可解释性报告 |
做项目的时候,我一般习惯三条路线组合着用。训练刚结束,先看卷积核确认底层特征是否合理;再挑几个验证集样本做特征图和Grad-CAM;如果某个类别准确率偏低,专门挑这类错误的样本做热力图分析。这样一套组合拳下来,绝大多数模型行为异常都能有个大概方向。
3. PyTorch实操:从零搭建特征可视化流水线
3.1 环境准备与模型选择:为什么首选预训练基准模型
实操部分我用PyTorch来实现,这是目前最方便做hook机制和动态图修改的框架。环境方面,PyTorch 1.8以上即可,依赖的第三方库包括torchvision、matplotlib、numpy、opencv-python、scikit-learn。
模型选择上,我推荐先用带残差结构的经典模型,比如ResNet18。原因很简单:ResNet的层结构清晰,有明确的block划分,方便选取不同层级的特征;而且预训练权重好找,可视化出来的结果有效信息多。拿随机初始化的模型做可视化,很多时候看到的是噪点,新读者容易被劝退。第一次跑通,选一个参数比较少、速度快的模型,等流程成熟了再换大模型。
代码里,先加载预训练模型,设置成eval模式。eval模式这一步务必要做,不然BatchNorm和Dropout的行为不稳定,特征图输出会抖来抖去,你很难判断看到的变化是模型行为还是随机噪声。
3.2 用Forward Hook提取任意层特征图
PyTorch里提取中间层特征,最主流的方式是注册forward hook。理解这个机制,你可以把它想成给神经网络的某一层装了一个“流量检测器”,每次数据流过这一层,检测器就自动记录一份副本,而网络本身不需要做任何改动。
直接上一段核心代码:
import torch import torchvision.models as models import torchvision.transforms as T from PIL import Image import matplotlib.pyplot as plt model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.eval() # 用字典保存捕获到的特征图 feature_maps = {} def make_hook(name): def forward_hook(module, input, output): feature_maps[name] = output.detach() return forward_hook # 给感兴趣的层挂hook model_layer_map = { 'conv1': model.conv1, 'layer1': model.layer1, 'layer2': model.layer2, 'layer3': model.layer3, 'layer4': model.layer4, } for name, module in model_layer_map.items(): module.register_forward_hook(make_hook(name))预处理部分,这里要注意一个高频错误:一定要用加载的预训练权重对应的标准化参数,不加或者加错会导致特征图异常。
transform = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) image = Image.open('sample.jpg').convert('RGB') input_tensor = transform(image).unsqueeze(0) with torch.no_grad(): _ = model(input_tensor)跑完forward之后,feature_maps里就是每一层捕获到的特征图。显示特征图有一个细节:直接做min-max归一化很容易出现黑图,因为大部分像素值集中在很小的区间,某几个极端值会把整体对比度吃掉。更好的做法是使用百分位裁剪,比如取1%和99%分位数作为显示上下限,这样能保留更多有效信息。
def show_feature_maps(features, num_cols=8): features = features.squeeze(0) total = features.size(0) num_rows = (total + num_cols - 1) // num_cols flat = features.view(total, -1) vmin = torch.quantile(flat, 0.01, dim=1, keepdim=True).unsqueeze(-1) vmax = torch.quantile(flat, 0.99, dim=1, keepdim=True).unsqueeze(-1) normed = torch.clamp((features - vmin) / (vmax - vmin + 1e-8), 0, 1) fig, axes = plt.subplots(num_rows, num_cols, figsize=(num_cols * 2, num_rows * 2)) for i in range(total): ax = axes[i // num_cols, i % num_cols] ax.imshow(normed[i].cpu().numpy(), cmap='viridis') ax.axis('off') for i in range(total, num_rows * num_cols): axes[i // num_cols, i % num_cols].axis('off') plt.tight_layout() plt.show()调用方式很简单,传入hook保存的feature_map即可:
for name in model_layer_map.keys(): print(f"Layer: {name}, shape: {feature_maps[name].shape}") show_feature_maps(feature_maps[name][:64])你会看到conv1的特征图里有一些明显的边缘、纹理敏感区域,layer3、layer4的通道会渐渐变得“抽象”起来,单个通道只在很局部的小区域有强烈响应。从这些特征图的形态差异中,你能直观感受到CNN逐层抽象的过程。我习惯挑两个通道做“单通道激活图叠加”,直接看这个通道在原图上激活的位置,这对理解通道语义很有帮助。
3.3 卷积核可视化:直接读取权重比想象中简单
卷积核可视化不需要hook,直接把模型权重取出来就行。代码比特征图还短:
def show_filters(weights, num_cols=8): out_ch = weights.size(0) k = weights.size(-1) if weights.size(1) == 3: # 输入为3通道,直接做RGB图 w_min = weights.min(dim=(1, 2, 3), keepdim=True)[0] w_max = weights.max(dim=(1, 2, 3), keepdim=True)[0] vis = (weights - w_min) / (w_max - w_min + 1e-8) vis = vis.permute(0, 2, 3, 1).cpu().numpy() else: # 多通道取均值投影到灰度 vis = weights.mean(dim=1, keepdim=True) vis = (vis - vis.min()) / (vis.max() - vis.min() + 1e-8) vis = vis.squeeze(1).cpu().numpy() num_rows = (out_ch + num_cols - 1) // num_cols fig, axes = plt.subplots(num_rows, num_cols, figsize=(num_cols * 2, num_rows * 2)) for i in range(out_ch): ax = axes[i // num_cols, i % num_cols] ax.imshow(vis[i], cmap='gray' if vis[i].ndim == 2 else None) ax.axis('off') for i in range(out_ch, num_rows * num_cols): axes[i // num_cols, i % num_cols].axis('off') plt.tight_layout() plt.show()调用:
conv1_weights = model.conv1.weight.data show_filters(conv1_weights)训练良好的模型,密集区域里能看到水平/垂直边缘、边界、不同频率的条纹图样。如果所有卷积核看起来都像随机噪声,先不要怀疑代码,多半是模型根本没训练好,或者训练未收敛时直接调用了随机保存的权重。有一种情况需要单独说明:BatchNorm层参数如果和卷积层没有融合,显示卷积核时值域会被缩放扭曲,建议显示前用归一化逻辑处理,上面代码里的min-max步骤就是为了规避这个坑。
3.4 Grad-CAM实现:梯度加权得到类别热力图
Grad-CAM是三类方法里最复杂的一个,但核心代码也就几十行。关键点在于:它需要拿到目标类别分数的梯度,所以不能用torch.no_grad()包裹forward过程,而且需要额外注册一个backward hook。
import cv2 import numpy as np from torch.nn import functional as F def grad_cam(model, input_tensor, target_class): # 选择最后一个卷积块 target_layer = model.layer4[-1] activations = {} gradients = {} def forward_hook(module, input, output): activations['value'] = output.detach() def backward_hook(module, grad_input, grad_output): gradients['value'] = grad_output[0].detach() target_layer.register_forward_hook(forward_hook) target_layer.register_full_backward_hook(backward_hook) output = model(input_tensor) model.zero_grad() score = output[0, target_class] score.backward() # 权重的计算:对梯度做全局平均池化 alpha = gradients['value'].mean(dim=(2, 3), keepdim=True) cam = torch.relu((alpha * activations['value']).sum(dim=1, keepdim=True)) cam = F.interpolate(cam, size=input_tensor.shape[-2:], mode='bilinear', align_corners=False) cam = cam.squeeze().cpu().numpy() # 归一化到0~1 cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return cam拿到cam之后,叠加到原图上面。这里有一个透明度选择的问题,我测试下来alpha取0.5左右效果比较均衡,热力图太透明看不清区域,太不透明又看不清原图结构。
def blend_cam(image_path, cam): image = cv2.imread(image_path) image = cv2.resize(image, (224, 224)) heatmap = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET) heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) blended = cv2.addWeighted(image, 0.5, heatmap, 0.5, 0) return blended使用的时候,target_class怎么定?两种常见策略:一是直接使用模型的预测类别,这样能看到“模型认为自己依据什么分类”;二是手动指定一个错误类别,做对比分析看模型的混淆原因。后一种在错误样本诊断中非常有用,比如某张真实类别是猫的图被分成了狗,我把target_class设为狗对应的索引,看模型是依据哪些区域做出的错误判断。
register_full_backward_hook在PyTorch 1.8以上的版本稳定可用,旧版本用register_backward_hook会有警告和兼容问题,建议直接用新版。
3.5 t-SNE降维:把特征空间长什么样画出来
如果想把“模型对一批样本的理解”整体画出来,可以用t-SNE把高维特征压迫到二维平面。做法是:取倒数第二层或最后一个卷积块的特征图,做全局平均池化得到每个样本的特征向量,然后对一批样本做t-SNE降维,按类别着色。
全局平均池化的思路可以这样理解:把一张224x224的特征图压缩成一个数值,相当于统计“这个通道在这张图上平均有多强的响应”,所有通道拼起来就是一个“特征向量签名”。不同类别的样本如果在这个向量空间里聚成团,说明模型的类别区分能力是健康的;如果多个类别混在一起,说明特征空间没有对齐到任务语义上。
from sklearn.manifold import TSNE def extract_features(model, data_loader, target_module): vectors, labels = [], [] for images, label in data_loader: with torch.no_grad(): feat = target_module(images) feat = feat.mean(dim=(2, 3)) vectors.append(feat.cpu().numpy()) labels.extend(label.numpy()) return np.concatenate(vectors), np.array(labels) # 假设已经准备好测试集的DataLoader # target_module = model.layer4 # features, labels = extract_features(model, test_loader, target_module) # tsne = TSNE(n_components=2, perplexity=30, random_state=42) # emb = tsne.fit_transform(features)画散点图的时候,不同类别用不同颜色,形状可以统一用圆形。如果你发现某个类别的样本在二维空间里和另一个类别重叠严重,说明这两个类别在特征空间的边界本来就不清晰,下一步需要看是数据问题(标注噪声、样本分布不均)还是模型能力瓶颈。t-SNE不是唯一的降维手段,UMAP速度更快,但t-SNE有一个优势是随机性带来的“簇结构更明显”,在调试时比较直观。
4. 踩坑记录:可视化过程中最容易翻车的5个问题
4.1 特征图全是黑的或者白的
这个我几乎每次带人入门都会遇到。大多数情况不是你代码错了,而是显示时的归一化问题。CNN中间输出的值分布往往不是均匀的,大量值聚集在零附近,少量极端值拉高了整体范围,直接min-max归一化后,主体信息全部被压缩到接近0的暗区,看起来就是一张黑图。
解决方法是改用分位数裁剪:取1%和99%分位数作为显示区间,把中间像素值对应的色阶铺满整个显示范围,细节信息立刻就能看见。再有一个隐藏原因:输入图片没有用预训练权重对应的mean/std做标准化,因为输入的值域不对,中间层特征分布会出现整体偏移。
4.2 Hook没有触发
Hook没触发最常见的原因是module路径写错了。很多模型不是平铺的,模块嵌套很深,你用model.layer1但是实际路径是model.features.layer1,自然捕不到结果。排查办法很简单:注册完hook后跑一次forward,然后检查字典里有没有内容;如果为空,就遍历model.named_modules(),把每一层的名字打印出来,对着名字再改注册路径。
另一个容易忽略的点:PyTorch的hook在module是Sequential对象时,注册的是整个容器的输出,不是子模块各自的输出。如果你想看ResNet里layer2内部每个残差块的输出,要逐个block注册,注册在整个layer2上只能拿到最后一级输出。
4.3 卷积核可视化看着全是噪点
出现这种情况,先不要怀疑可视化代码逻辑。大概率是你加载的模型权重是随机初始化状态,或者训练过程压根没保存到最优权重。拿一个刚刚随机初始化的模型去看卷积核,那当然是噪声。解决办法是换用预训练权重,或者确保训练收敛以后再做可视化。
还有一种情况:卷积核前接了BatchNorm,直接看原始权重会发现值域很小,因为BN层本身参与的是另一套缩放逻辑。你可以做权重归一化显示,或者先执行BN层参数合并到卷积层的操作,这样看到的权重才是真正“发挥作用”的那个版本。
4.4 Grad-CAM热力图太散或太集中
Grad-CAM效果不理想,多半是选层不对。选太浅的层(比如conv1),特征图分辨率很高但语义信息弱,热力图会呈现一片弥散的碎块,看不出明显聚焦区域;选太深的层,空间分辨率过低,上采样到原图后边界糊成一片。实践里最稳妥的选择是最后一个卷积块的最后一级,语义能力和空间分辨率平衡得最好。
如果热力图仍然很散,可以检查对alpha权重的处理。有些实现里会因为通道间梯度正负抵消,权重值趋近于零。这种情况下,你可以只保留正梯度部分再做全局平均,效果通常会更集中。另外,score.backward()算完之后模型梯度已经写回,如果你对多张图片做循环,记得每张图处理前调用model.zero_grad()清空旧梯度,不然热力图会被上一张图的梯度污染。
4.5 结果复现对不齐
这个坑比较隐蔽,往往出现在你已经成功可视化过一次、换了个环境再跑的时候。模型分层设置了dropout或者BatchNorm没有切到eval模式,两次forward的随机行为不一样;又或者CPU和GPU对浮点数的计算顺序不同,特定层输出会有毫厘级的误差,但可视化对极小的数值差异很敏感,显示结果会产生轻微噪点。
不要慌,这是正常现象。固定好随机种子,统一model.eval(),尽量在同一个设备类型上做对比实验。真正的对比分析,最好在同一批特征图上操作,而不是跨设备跨环境直接对比两张热力图。
| 问题现象 | 主要原因 | 解决步骤 |
|---|---|---|
| 特征图全黑/全白 | 归一化区间不当 | 用1%/99%分位数裁剪再做min-max |
| Hook没触发 | 模块路径错误 | 遍历named_modules核对名称 |
| 卷积核显示噪点 | 权重是随机初始化 | 加载预训练或收敛权重 |
| 热力图分散 | 选层太浅 | 改用最后一个卷积块的最后一层 |
| 结果无法复现 | 训练模式与随机种子不同 | eval模式+固定随机种子 |
5. 可视化结果怎么解读:从直觉判断到量化分析
5.1 看懂特征图的“语言”:从边缘纹理到语义部件
可视化做完,最难的反而是“看懂”。我看过很多新人跑到这一层就卡住了,图出来了但不知道这意味着什么。这里给一个比较稳定的解读框架。
浅层特征图的特征:每张特征图里被激活的区域是一块块连着的,能看到明显的方向性边缘、颜色对比、点状纹理,很像传统图像处理里Sobel边缘检测或者Gabor滤波器做出来的结果。这代表模型已经学到图像的低层结构信息,是训练正常的标志。
深层特征图的特征:单张特征图里激活区域面积变小,但位置往往对应着有语义的部分,比如一个轮子、一只眼睛、一个窗户。这说明该通道已经和某个语义部件对上了。不同通道之间呈现“分工”状态,有的通道负责响应轮子,有的负责响应前挡风玻璃。如果所有深层通道都响应在整个图像的大范围区域里,说明模型没有形成清晰的局部特征,泛化能力很可能存在问题。
5.2 用激活统计量做量化对比
光靠肉眼看图容易陷入“看起来好像合理”的主观陷阱。我建议在直觉判断之外,加几个量化指标辅助判断。
激活均值与方差:在验证集上统计某一层特征图的均值、方差。训练不充分的模型,均值往往集中在零点附近,方差很小。过度拟合的模型,深层均值会出现大的漂移。这两个简单指标能给你一个全局基准。
稀疏度:计算特征图中小于某个阈值(比如最大值的10%)的像素占比。正常情况下深层特征应当比较稀疏,因为模型只对特定语义部件响应。如果深层特征图几乎全是高响应激活,说明模型处于特征混淆状态。
类别可分性:统计同一通道在不同类别样本上的平均响应差异。某个通道在“狗”类别激活很强、在“猫”类别几乎不激活,说明它是“狗部件”检测通道。如果某个通道所有类别响应都差不多,它在分类决策上的贡献就很可疑。
5.3 可视化不是终点:闭环调优的思路
可视化真正发挥价值的地方,是它驱动了一套闭环调优流程。我个人的使用范式大致如下:先做一批样本的Grad-CAM热力图,如果发现大量样本关注背景区域,就去检查数据采集和预处理,看是否有背景泄漏式的可区分特征;如果是特定类别的热力图明显碎片化,考虑增加该类别的增广策略或者调整损失函数,必要时切成更细粒度的子类训练;持续观察中间层卷积核的收敛质量,如果训练若干轮之后卷积核变化不明显,考虑调整学习率或更换优化器。
这套流程看起来朴素,但它有核心价值:把“凭经验试”变成“基于观察做决策”。最近一次项目中,我用Grad-CAM批量分析了某个商店实拍数据集的预测结果,发现模型总会把高亮区域放在货架间隙的灯光反射上,而不是商品本身。后来在训练集里加入了对光照鲁棒的数据增广策略,验证集上的错误率直接降了一半。那次经历让我确定,可视化是整个深度学习工作流里被低估的工具之一。
最后分享一点个人经验:做可视化一定要养成“多次对比”的习惯。单看一张华为的单个热力图会被偶然性误导,同一层多挑几个样本,同一个样本多试几个类别的热力图,结论才稳。还有一个实用小技巧:在我需要快速验证解析效果的场景里,通常会同时保存特征图、卷积核、热力图到本地目录,方便后续翻看和对比建模。给每一版实验记录留下可视化的证据链,长期积累下来,你会对自己模型的“脾气”越来越熟悉。