14.测试推理与可视化
第 14 章 — 测试推理与可视化(predict + torchmetrics)
上一章(第 13 章)我们让训练跑上了多卡,工程越来越"正规"。但有一个关键问题被搁置了:模型训练完,躺在
checkpoints/best.pth里,我们除了 TensorBoard 上一根 accuracy 曲线,既不知道怎么用它去预测一张新图,也不知道它到底错在哪里。本章给训练画上句号:新增predict.py做真正的推理(单图/目录 + top-5 置信度),新增analyze.py把测试集"解剖"开来看(混淆矩阵 + 每类 precision/recall/F1 + 预测样例图),并引入社区标准指标库torchmetrics——准确率、混淆矩阵这类指标的算法规格,从此不用各写各的。
一、本章要解决的问题
- 之前:模型只在验证/测试集上产出一个整体 accuracy,不知道具体哪几类在互相混淆、每一类错得有多严重。
- 之前:想拿模型去预测一张新图,只能临时拼几行脚本,还要自己记得住预处理配方。
- 之前:指标全是手写公式 + 打印,换个团队/换个项目就得重写一遍,数字口径还不统一。
- 现在:需要一条正式的推理入口(
python predict.py --image x.jpg直接输出 top-5 与置信度),以及一套标准化的评估工具(整体准确率、混淆矩阵、每类指标、可视化图)。 - 判断标准:
predict.py能对单张图或整个目录输出 top-5 类别与置信度;analyze.py能打印每类指标表并保存confusion_matrix.png、predictions.png两张图;所有指标用 torchmetrics 计算且与手算结果一致。
二、核心概念速览
下面 8 个概念是本章代码的"生词"。先花 5 分钟读完,再看代码会轻松很多。
1. 推理 vs 训练(考试 vs 学习)
训练是"学习":反复看题、对答案、改错,不断更新参数;推理是"考试":知识(参数)已经定型,拿着它回答没见过的新题,绝不能再"看答案改错"。所以推理只有一次前向传播,而且必须 model.eval()(让 BatchNorm 用训练时累计的全局统计)加 torch.no_grad()(不计算梯度,省显存、更快)。
2. softmax 与置信度(把分数变成概率)
网络最后一层输出 10 个原始分数(logits),"7.3"这种数字本身没有直观含义。softmax 把它们压成 10 个正数、加起来恰好等于 1,等价于"每个类别的把握度"。这个概率就是置信度:0.95 代表相当确定,0.31 基本是在蒙。训练时 CrossEntropyLoss 内部其实也做了 softmax,只是你不直接看到。
3. top-k 输出
预测不只看第一名。把概率从大到小排,取前 k 个(如 top-5),一口气告诉你"模型认为最可能的 5 个候选"。现实里第一名猜错、正确答案落在前五名的情况很常见;诊断、推荐这类"宁可多给候选"的场景尤其需要 top-k。
4. torchmetrics(标准指标库)
PyTorch 官方社区维护的指标库,装完 import 即用,内置 Accuracy、ConfusionMatrix、F1Score 等上百个指标。所有指标统一"三段式"接口:update() 分批喂数据 → compute() 汇总出最终值。好处:不用手写公式、不会算错、团队之间数字口径一致。
5. 混淆矩阵(行=真实,列=预测,怎么读)
10 类的分类结果可以画成一张 10×10 计数表:行 = 真实类别,列 = 预测类别。对角线上的格子是"猜对的";对角线之外是"猜错的",格子越亮错得越多。比如"真实=鸟"这一行里第 7 列(青蛙)特别亮,就说明模型总把鸟认成青蛙——一眼就能定位"哪两类长得像、互相混淆"。
6. precision / recall / F1(抓错 vs 漏掉)
整体 accuracy 一个数字会骗人:模型把什么都预测成"狗"也能刷出很高的准确率。Precision(精确率)问"预测成狗的图里真狗占多少"——惩罚抓错;Recall(召回率)问"真狗里被找到多少"——惩罚漏掉。F1 是两者的调和平均,两个都高 F1 才高。逐类看这三个数字,才知道模型对哪类"抓得准"、对哪类"漏得多"。
7. 推理预处理必须与训练一致
模型学到的参数是针对某个特定数据分布学的:32×32 尺寸、像素归一化到均值 0.49 附近。推理时如果漏掉 Normalize,或者用了不同均值/方差,喂进去的分布跟训练时完全不同,输出基本不可信。预处理是模型的"出厂接口",推理必须逐字复刻训练时的 transform——这就是 build_predict_transform 要单独写、还特意注释均值方差的原因。
8. 拒识阈值的思想
softmax 给的置信度还能干一件事:设一个阈值,低于它就回答"我不确定,请人工处理",而不是硬猜一个。好比考试时没把握的题先空着,不瞎蒙。真实业务里"承认不会"往往比"给一个错答案"更有价值,这个思想叫拒识(rejection),本章先讲清楚原理,落地留作练习。
三、解决思路
- 新增
utils/eval_tools.py:把"评估 + 画图"收进一个模块。混淆矩阵用torchmetrics.ConfusionMatrix算、整体准确率用torchmetrics.Accuracy算;每类 precision/recall/F1 仍从混淆矩阵手推公式(教学目的,让你看清定义);再附两个 matplotlib 画图函数(混淆矩阵热力图、预测样例网格图)。 - 新增
predict.py:面向"单张/整目录新图"的推理入口。加载best.pth,复用第 13 章的build_model、load_checkpoint和 config 结构,对每张图输出 top-5 类别与置信度。推理预处理单独写一个build_predict_transform,确保和训练完全一致。 - 新增
analyze.py:跑完整测试集,依次输出整体准确率、每类指标表、混淆矩阵热力图、16 张预测样例图。它天然就是predict.py的"批量 + 带标签"版本,训练和评估由此闭环。 - 修改
requirements.txt:新增一行torchmetrics>=1.2。 - 不做什么:不改训练/验证逻辑,
train.py、engine/trainer.py等一律不动;不重新发明指标轮子(torchmetrics 是标准库,per_class 手推只是教学演示)。
trade-off:引入 torchmetrics 多了一个依赖,但换来统一接口、免手写 bug、口径一致;per_class 手推在 CIFAR-10 上毫无性能压力,恰好把公式讲透,生产环境直接换
MulticlassPrecision等封装即可。
四、代码变更
相对第 13 章的改动:
config/config.yaml # 不变:沿用第 13 章
models/classifier.py # 不变
dataset/datasets.py # 不变
engine/trainer.py # 不变
utils/checkpoint.py # 不变
utils/logger.py # 不变
utils/metrics.py # 不变(本章不再直接使用,仍保留)
utils/early_stopping.py # 不变
utils/seed.py # 不变
utils/dist.py # 不变
train.py # 不变:训练入口与第 13 章完全一致
+ utils/eval_tools.py # 新增:torchmetrics 指标 + matplotlib 可视化
+ predict.py # 新增:单图/目录推理(top-5 + 置信度)
+ analyze.py # 新增:测试集评估与可视化
requirements.txt # 修改:新增一行依赖
+ torchmetrics>=1.2 # 新增依赖:标准指标库五、完整代码
本章完整代码 = 第 13 章工程 + 以下新增/修改文件。未列出的文件(train.py、config/config.yaml、models/classifier.py、dataset/datasets.py、engine/trainer.py、utils/checkpoint.py、utils/logger.py、utils/metrics.py、utils/early_stopping.py、utils/seed.py、utils/dist.py)与第 13 章完全相同,直接沿用即可。
关于 eval_tools.py 的设计说明:混淆矩阵由 torchmetrics.ConfusionMatrix 计算、整体准确率由 torchmetrics.Accuracy 计算;而 per_class_metrics 故意从混淆矩阵手推公式——教学目的,让你亲眼看到 precision/recall/F1 是怎么从这张表里算出来的。生产项目可直接换成 torchmetrics 的 MulticlassPrecision 等封装,数字完全一致(见动手练习 4)。
utils/eval_tools.py(新增):
"""评估工具:基于 torchmetrics 的混淆矩阵/准确率,加 matplotlib 可视化。"""
import torch
import numpy as np
import matplotlib
matplotlib.use("Agg") # Agg 后端:不弹窗口、直接存图(服务器/无显示环境必需)
import matplotlib.pyplot as plt
from torchmetrics import Accuracy, ConfusionMatrix
# CIFAR-10 官方类别名(英文,避免 matplotlib 中文字体问题)
CIFAR10_CLASSES = ["airplane", "automobile", "bird", "cat", "deer",
"dog", "frog", "horse", "ship", "truck"]
@torch.no_grad() # 评估不更新参数,全程关闭梯度计算
def compute_confusion_matrix(model, loader, device, num_classes=10):
"""用 torchmetrics.ConfusionMatrix 跑完整测试集,返回混淆矩阵 ndarray。"""
metric = ConfusionMatrix(task="multiclass", num_classes=num_classes).to(device) # 指标也要搬到同一设备
model.eval() # 推理模式:BN 用训练累计的全局统计
for images, labels in loader:
preds = model(images.to(device)).argmax(dim=1) # logits -> 预测类别(概率最大者)
metric.update(preds, labels.to(device)) # 喂一组 (预测, 真实)
return metric.compute().cpu().numpy() # 汇总成 (10, 10) 的 numpy 矩阵
@torch.no_grad()
def compute_accuracy(model, loader, device):
"""用 torchmetrics.Accuracy 计算整体准确率。"""
metric = Accuracy(task="multiclass").to(device)
model.eval()
for images, labels in loader:
preds = model(images.to(device)).argmax(dim=1)
metric.update(preds, labels.to(device))
return metric.compute().item() # .item() 把 0 维张量变成 Python float
def per_class_metrics(cm, class_names):
"""从混淆矩阵计算每类的 precision/recall/F1(行=真实,列=预测)。"""
rows = []
for i, name in enumerate(class_names):
tp = cm[i, i] # 命中:真实=i 且预测=i
fp = cm[:, i].sum() - tp # 列 i 中除 tp 外都是"误抓"(预测成 i 但实际不是)
fn = cm[i, :].sum() - tp # 行 i 中除 tp 外都是"漏掉"(实际是 i 但没预测成)
prec = tp / (tp + fp) if tp + fp > 0 else 0.0 # 预测为 i 的里面,真 i 占多少
rec = tp / (tp + fn) if tp + fn > 0 else 0.0 # 真 i 的里面,被找回来多少
f1 = 2 * prec * rec / (prec + rec) if prec + rec > 0 else 0.0 # 调和平均
rows.append((name, prec, rec, f1))
return rows
def plot_confusion_matrix(cm, class_names, save_path):
"""保存混淆矩阵热力图。"""
fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(cm, cmap="Blues") # Blues 色带:颜色越深数字越大
ax.set_xticks(range(len(class_names)), class_names, rotation=45, ha="right")
ax.set_yticks(range(len(class_names)), class_names)
ax.set_xlabel("预测类别") # 列 = 预测
ax.set_ylabel("真实类别") # 行 = 真实
ax.set_title("Confusion Matrix (CIFAR-10)")
for i in range(len(class_names)): # 在每个格子里写上具体数字
for j in range(len(class_names)):
ax.text(j, i, str(cm[i, j]), ha="center", va="center",
color="white" if cm[i, j] > cm.max() / 2 else "black")
plt.colorbar(im, ax=ax)
fig.tight_layout()
fig.savefig(save_path, dpi=150, bbox_inches="tight")
plt.close(fig) # 画完即关,避免内存攒一堆 Figure
def plot_predictions(images, labels, preds, class_names, save_path, num=16):
"""保存预测结果网格图:绿框=正确,红框=错误。"""
images = images[:num].cpu() # 只取前 num 张并挪回 CPU 才好转 numpy
ncols = 4
nrows = (num + ncols - 1) // ncols # 向上取整算行数
fig, axes = plt.subplots(nrows, ncols, figsize=(3 * ncols, 3 * nrows))
for idx, ax in enumerate(axes.flat):
if idx >= len(images):
ax.axis("off")
continue
img = images[idx].permute(1, 2, 0).numpy() # (C,H,W) -> (H,W,C)
img = np.clip(img * 0.5 + 0.5, 0, 1) # 反归一化:乘 0.5 加 0.5 回到 0~1
ax.imshow(img)
ok = (labels[idx] == preds[idx])
title = f"T:{class_names[labels[idx]]}\nP:{class_names[preds[idx]]}" # T=真实 P=预测
ax.set_title(title, fontsize=9, color="green" if ok else "red")
ax.set_xticks([])
ax.set_yticks([])
fig.suptitle("预测结果(绿=正确,红=错误)")
fig.tight_layout()
fig.savefig(save_path, dpi=150, bbox_inches="tight")
plt.close(fig)predict.py(新增):
"""单图/目录推理:加载 best.pth,输出 top-5 类别与置信度。"""
import argparse
import os
from pathlib import Path
import torch
from torchvision import transforms
from PIL import Image
from models.classifier import build_model
from utils.checkpoint import load_checkpoint
from utils.eval_tools import CIFAR10_CLASSES
import yaml
class Config(dict): # 让 cfg.data.root 这种"点号取属性"写法成立
def __getattr__(self, key):
try:
return self[key]
except KeyError as e:
raise AttributeError(key) from e
def _to_config(obj): # 把 yaml 读出来的嵌套 dict 递归包成 Config
if isinstance(obj, dict):
return Config({k: _to_config(v) for k, v in obj.items()})
if isinstance(obj, list):
return [_to_config(v) for v in obj]
return obj
def load_config(path):
with open(path, "r", encoding="utf-8") as f:
return _to_config(yaml.safe_load(f))
def build_predict_transform():
"""推理预处理:任意尺寸先缩放到 32x32,再走验证集同款归一化。"""
return transforms.Compose([
transforms.Resize((32, 32)),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)),
])
@torch.no_grad() # 推理不需要梯度,省显存、更快
def predict_one(model, image_path, transform, device):
img = Image.open(image_path).convert("RGB") # 统一转 3 通道,兼容灰度图
tensor = transform(img).unsqueeze(0).to(device) # 单图 -> (1,3,32,32)
logits = model(tensor) # (1,10) 原始分数
probs = torch.softmax(logits, dim=1).squeeze(0) # 分数 -> 10 个类别的概率
topk = probs.topk(5) # 概率最高的 5 个(索引+数值)
return img, topk.indices.tolist(), topk.values.tolist()
def print_result(image_path, indices, values):
print(f"\n{image_path}")
for rank, (idx, val) in enumerate(zip(indices, values), 1):
print(f" Top{rank}: {CIFAR10_CLASSES[idx]:10s} 置信度 {val:.4f}")
def main():
parser = argparse.ArgumentParser(description="CIFAR-10 单图推理")
parser.add_argument("--config", type=str, default="config/config.yaml")
parser.add_argument("--ckpt", type=str, default="checkpoints/best.pth",
help="要加载的 checkpoint(默认验证集最优模型)")
parser.add_argument("--image", type=str, default=None, help="单张图片路径")
parser.add_argument("--image-dir", type=str, default=None, help="图片目录")
args = parser.parse_args()
cfg = load_config(args.config)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = build_model(cfg).to(device) # 用训练时的配置重建模型结构
ckpt = load_checkpoint(args.ckpt, device)
model.load_state_dict(ckpt["model_state"]) # 把训练好的权重灌进去
model.eval() # 千万别漏:进入推理模式
transform = build_predict_transform()
if args.image:
targets = [args.image]
elif args.image_dir:
targets = [str(p) for p in Path(args.image_dir).glob("*.jpg")]
targets += [str(p) for p in Path(args.image_dir).glob("*.png")]
else:
raise SystemExit("必须指定 --image 或 --image-dir")
for path in targets:
if not os.path.exists(path):
print(f"跳过不存在的文件: {path}")
continue
img, indices, values = predict_one(model, path, transform, device)
print_result(path, indices, values)
if __name__ == "__main__":
main()analyze.py(新增):
"""测试集评估与可视化:混淆矩阵、每类指标、预测样例图。"""
import argparse
import os
import torch
from torch.utils.data import DataLoader
from torchvision import datasets
from models.classifier import build_model
from utils.checkpoint import load_checkpoint
from utils.eval_tools import (CIFAR10_CLASSES, compute_confusion_matrix,
compute_accuracy, per_class_metrics,
plot_confusion_matrix, plot_predictions)
from dataset.datasets import build_transforms
import yaml
class Config(dict): # 与 predict.py 相同的 config 工具
def __getattr__(self, key):
try:
return self[key]
except KeyError as e:
raise AttributeError(key) from e
def _to_config(obj):
if isinstance(obj, dict):
return Config({k: _to_config(v) for k, v in obj.items()})
if isinstance(obj, list):
return [_to_config(v) for v in obj]
return obj
def load_config(path):
with open(path, "r", encoding="utf-8") as f:
return _to_config(yaml.safe_load(f))
def main():
parser = argparse.ArgumentParser(description="CIFAR-10 测试集评估")
parser.add_argument("--config", type=str, default="config/config.yaml")
parser.add_argument("--ckpt", type=str, default="checkpoints/best.pth")
parser.add_argument("--output-dir", type=str, default="./analysis")
args = parser.parse_args()
cfg = load_config(args.config)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
os.makedirs(args.output_dir, exist_ok=True) # 输出目录不存在就自动创建
# 测试集必须与训练同款预处理(build_transforms(cfg, train=False)),否则指标失真
test_dataset = datasets.CIFAR10(
root=cfg.data.root, train=False, download=True,
transform=build_transforms(cfg, train=False))
test_loader = DataLoader(test_dataset, batch_size=cfg.data.batch_size,
shuffle=False, num_workers=cfg.data.num_workers)
model = build_model(cfg).to(device)
ckpt = load_checkpoint(args.ckpt, device)
model.load_state_dict(ckpt["model_state"])
model.eval()
# 1) 整体准确率(torchmetrics)
total_acc = compute_accuracy(model, test_loader, device)
print(f"整体准确率: {total_acc:.4f}")
# 2) 混淆矩阵 + 每类指标
cm = compute_confusion_matrix(model, test_loader, device)
print(f"\n{'类别':12s} {'Precision':>10s} {'Recall':>10s} {'F1':>10s}")
for name, prec, rec, f1 in per_class_metrics(cm, CIFAR10_CLASSES):
print(f"{name:12s} {prec:10.4f} {rec:10.4f} {f1:10.4f}")
# 3) 混淆矩阵热力图
cm_path = os.path.join(args.output_dir, "confusion_matrix.png")
plot_confusion_matrix(cm, CIFAR10_CLASSES, cm_path)
print(f"\n混淆矩阵已保存: {cm_path}")
# 4) 抽样 16 张预测结果图(红/绿标题一眼看出对错)
seen_images, seen_labels, seen_preds = [], [], []
with torch.no_grad():
for images, labels in test_loader:
preds = model(images.to(device)).argmax(dim=1).cpu().tolist()
for img, t, p in zip(images, labels.tolist(), preds):
seen_images.append(img)
seen_labels.append(t)
seen_preds.append(p)
if len(seen_images) >= 16: # 集满 16 张就停
break
if len(seen_images) >= 16:
break
samples_path = os.path.join(args.output_dir, "predictions.png")
plot_predictions(torch.stack(seen_images), seen_labels, seen_preds,
CIFAR10_CLASSES, samples_path)
print(f"预测样例已保存: {samples_path}")
if __name__ == "__main__":
main()requirements.txt(修改,新增最后一行):
torch>=2.0
torchvision>=0.15
numpy>=1.24
pyyaml>=6.0
tensorboard>=2.13
matplotlib>=3.7
tqdm>=4.66
rich>=13.0
torchsummary>=1.5
torchmetrics>=1.2注意:
matplotlib.use("Agg")必须写在import matplotlib.pyplot as plt之前,否则在无显示环境(服务器/CI)下会尝试弹窗报错。torchmetrics的指标对象也要.to(device),且update(preds, target)里预测和标签必须同设备。
六、运行示例
先确保安装了新依赖(旧环境请先升级):
pip install -r requirements.txtpredict.py:单图推理
python predict.py --image ./sample/cat.jpg输出示例(top-5,按置信度从高到低):
./sample/cat.jpg
Top1: cat 置信度 0.8712
Top2: dog 置信度 0.0641
Top3: deer 置信度 0.0303
Top4: frog 置信度 0.0129
Top5: horse 置信度 0.0110换一个 checkpoint(比如训练最后一步保存的 last.pth):
python predict.py --image ./sample/cat.jpg --ckpt checkpoints/last.pthpredict.py:整个目录推理(自动扫描 .jpg 和 .png)
python predict.py --image-dir ./sampleanalyze.py:三条命令
# 命令 1:默认配置,评估 best.pth,结果输出到 ./analysis
python analyze.py
# 命令 2:评估另一个 checkpoint,输出到独立目录,便于对比
python analyze.py --ckpt checkpoints/last.pth --output-dir ./analysis_last
# 命令 3:换一套配置(例如换 batch_size 或模型超参数后的工程)
python analyze.py --config config/another.yaml --ckpt checkpoints/best.pthanalyze.py 终端输出示例:
整体准确率: 0.8142
类别 Precision Recall F1
airplane 0.8534 0.8256 0.8392
automobile 0.8861 0.9023 0.8941
bird 0.7023 0.6458 0.6729
...
混淆矩阵已保存: ./analysis/confusion_matrix.png
预测样例已保存: ./analysis/predictions.png运行完打开两张图:confusion_matrix.png 看对角线外哪几格最亮(最易混淆的类别对),predictions.png 看红绿标题(哪些图被认错了、错成了什么)。
七、本章小结
- 学到了什么
- 推理 = 在训练好的固定参数上跑一次前向:
model.eval()+torch.no_grad(),预处理必须与训练完全一致。 softmax把 logits 变成概率(置信度),probs.topk(5)一次拿到前 5 名候选。torchmetrics统一update()/compute()三段式接口,Accuracy、ConfusionMatrix两行搞定;混淆矩阵"行=真实、列=预测",对角线越亮说明该类别分得越清楚。- precision 惩罚"误抓"、recall 惩罚"漏掉"、F1 平衡两者;只看整体 accuracy 会被"全预测成多数类"骗到,必须逐类看指标。
- 评估可视化闭环:整体准确率 → 每类指标表 → 混淆矩阵热力图 → 预测样例网格图,从"一个数字"进化到"看清模型"。
- 推理 = 在训练好的固定参数上跑一次前向:
- 常见坑
- 推理时忘了
model.eval():BatchNorm 用了当前 batch 统计,单张图结果忽高忽低。 - 推理图预处理与训练不一致(漏 Normalize、尺寸不对):模型输出完全不可信,且不容易察觉。
torchmetrics指标忘.to(device),或update时预测和标签设备不一致:直接报 device mismatch。matplotlib.use("Agg")写在了import pyplot之后:无显示环境直接崩。- softmax 概率是"相对分数"而非校准过的绝对把握度;用它做业务阈值前建议先做 temperature scaling(超出本章范围,见练习 5)。
- 推理时忘了
- 下一章预告:模型在 PyTorch 里已经"会考试"了,但它还是 Python 世界的私有资产——怎么把它导出成
TorchScript/ONNX,让 C++、TensorRT、移动端也能跑?第 15 章讲模型导出与部署。
八、动手练习
- 拒识阈值:给
predict.py加一个--threshold 0.8参数,top-1 置信度低于阈值时输出"不确定(confidence x.xxxx)"。再统计测试集里被拒识的比例,画出"阈值 vs 准确率/拒识率"的关系——想想为什么拒掉低置信样本能提升整体准确率。 - 找最易混淆的一对:从
analyze.py的指标表 + 混淆矩阵热力图中找出对角线外最大的格子,去 CIFAR-10 数据目录里翻几张真实样例,说说为什么这两类(如 bird 与 airplane、cat 与 dog)会被搞混。 - 带标签的目录评估:把图片按类别放进子文件夹(
./data_demo/cat/*.jpg、./data_demo/dog/*.jpg),仿照analyze.py写一个脚本批量预测并算出该目录的整体准确率——这次不依赖 torchmetrics,直接用argmax == label手算,验证结果与 torchmetrics 一致。 - 换成 torchmetrics 全家桶:把
per_class_metrics替换成torchmetrics.MulticlassPrecision、MulticlassRecall、MulticlassF1Score(同样update/compute),对比每个类别的三个数字是否与手推完全一致。 - 置信度校准曲线:收集测试集所有样本的 top-1 置信度与是否预测正确,按置信度分成 10 个分箱,每箱画"平均置信度 vs 实际准确率";如果点全部落在对角线下,说明模型过度自信——想想温度缩放(
softmax(logits / T))能把曲线拉回对角线吗?
