06 · 模型与推理¶
本篇拆开 4 个
AgeClassifier类,讲清 EfficientNet-B3 backbone、分类头差异、predict 接口分歧、动态加载器。 前置:05 · 检测模块 下一篇:07 · 打包与部署
6.1 总览:5 个权重 + 4 个 .py + 1 个加载器¶
models/
├── __init__.py 空文件(仅标记为包)
├── model1.py AgeClassifier (无参构造),加载 best_model1.pth
├── model2.py AgeClassifier (无参构造),加载 best_model2.pth
├── model3.py AgeClassifier (无参构造),加载 best_model3.pth
├── model4.py AgeClassifier (需 model_path 参数),加载 best_model4.pth
├── best_model1.pth 43 MB
├── best_model2.pth 43 MB
├── best_model3.pth 43 MB
├── best_model4.pth 43 MB
├── efficientnet_b3_pytorch.pth 48 MB ← 实际未被加载,留存疑似为备份
├── tempCodeRunnerFile.py ⚠️ IDE 临时文件,应清理
├── .idea/ ⚠️ PyCharm 配置,应清理
└── __pycache__/ ⚠️ 字节码缓存,应清理
utils/
└── model_loader.py importlib 动态加载器,仅 watercamera 使用
6.2 EfficientNet-B3 Backbone¶
所有 4 个模型都共享同一个 backbone:
# 来自 model1.py:29 / model2.py:28 / model3.py:29 / model4.py:35
model = EfficientNet.from_name('efficientnet-b3', num_classes=1)
几个关键事实¶
from_name不下载预训练:与from_pretrained不同,from_name只构造结构,权重是随机初始化的。所有有意义的参数都来自best_modelN.pth这个完整微调权重(不是只存分类头)num_classes=1:原始 EfficientNet-B3 的_fc是Linear(1536, 1000)(ImageNet 1000 类),这里改为Linear(1536, 1)输出单一 logit → 经 sigmoid 得二分类概率- 特征维度 1536:
model._fc.in_features是 1536(EfficientNet-B3 的默认) - 输入 300×300:
各模型 img_size = 300,与 EfficientNet-B3 的原生输入尺寸一致
为什么不用预训练?¶
model4.py:34 的注释 说:"使用 from_name 而不是 from_pretrained 避免下载预训练权重"。这是个部署考虑——打包成 exe 后没有网络,无法 from_pretrained 触发的下载。完全合理。
但代价是:efficientnet_b3_pytorch.pth 这个 48MB 文件实际上没有被加载——它就静静躺在 models/ 目录里,可能是 gxl 训练时为了"知道有这么个东西"留下的备份。
6.3 分类头结构对比¶
四个模型的 model._fc 各自不同:
model1 / model2 / model3 的分类头(含水量)¶
# 例如 model1.py:31-38
model._fc = nn.Sequential(
nn.Identity(), # _fc.0 占位
nn.Linear(num_features, 256), # _fc.1
nn.Identity(), # _fc.2 占位
nn.BatchNorm1d(256), # _fc.3
nn.Identity(), # _fc.4 占位
nn.Linear(256, 1) # _fc.5
)
为什么放 3 个 Identity():训练时的分类头可能是这样的(典型设计):
nn.Sequential(
nn.Dropout(p1),
nn.Linear(1536, 256),
nn.ReLU(),
nn.BatchNorm1d(256),
nn.Dropout(p2),
nn.Linear(256, 1)
)
推理时不需要 Dropout 和 ReLU(ReLU 推理时虽然该保留,但这里被替换了),所以用 Identity() 占位保持 state_dict 的索引位置不变——这样训好的权重才能正确 load 到 _fc.1 和 _fc.5 上。
疑似问题:把
ReLU()替换成Identity()会让推理结果与训练时不同(少了一次非线性)。这可能是 gxl 的失误,但因为 ReLU 在 0~∞ 范围影响有限(且 BatchNorm 之后大部分激活值是 0 附近),实际指标偏差不一定显著。
model4 的分类头(膨胀)¶
# model4.py:39-46
model._fc = nn.Sequential(
nn.Dropout(0.45),
nn.Linear(num_features, 256),
nn.ReLU(),
nn.BatchNorm1d(256),
nn.Dropout(0.55),
nn.Linear(256, 1)
)
model4 保留了 Dropout 和 ReLU。Dropout 在 model.eval() 后会自动跳过(不引入随机性),所以推理时等价于无 Dropout。但激活函数 ReLU 是真实生效的——这是 model4 与 model1/2/3 唯一的实质差异。
对照表¶
| 模型 | _fc.0 | _fc.1 | _fc.2 | _fc.3 | _fc.4 | _fc.5 |
|---|---|---|---|---|---|---|
| model1/2/3 | Identity | Linear(1536→256) | Identity | BN1d(256) | Identity | Linear(256→1) |
| model4 | Dropout(0.45) | Linear(1536→256) | ReLU | BN1d(256) | Dropout(0.55) | Linear(256→1) |
6.4 输入预处理¶
所有 4 个模型的 transforms 都是一样的(model1.py:59-63 等):
transforms.Compose([
transforms.Resize((300, 300)),
transforms.ToTensor(), # → [0,1] float32
transforms.Normalize([0.485, 0.456, 0.406], # ImageNet 均值
[0.229, 0.224, 0.225]) # ImageNet 标准差
])
ImageNet 归一化:尽管 EfficientNet-B3 权重不是 ImageNet 预训练的,gxl 仍然沿用了 ImageNet 的均值/方差归一化——这是惯例,训练时多半也用了同样的 transforms,所以推理时必须一致。
输入路径:Image.open(path).convert('RGB') → .unsqueeze(0) → 形状 [1, 3, 300, 300]。
6.5 predict() 接口分歧¶
⚠️ 这是 P1-2 bug:四个模型的 predict 接口不统一。
model1 / model2 / model3(无参构造,返回 tuple)¶
# 例 model1.py:45-55
def predict(self, image_path):
img_tensor = self._preprocess_image(image_path)
return self._predict(img_tensor)
def _predict(self, img_tensor):
with torch.no_grad():
img_tensor = img_tensor.to(self.device)
output = self.model(img_tensor)
probability = torch.sigmoid(output).item()
pred_class = 0 if probability > 0.5 else 1
pred_label = self.class_names[pred_class]
class_probs = {
self.class_names[1]: 1 - probability,
self.class_names[0]: probability
}
return pred_label, class_probs # ← tuple
实例化:AgeClassifier() — 无参,模型路径在内部硬编码。
返回:(pred_label, class_probs) — 元组。
model4(需 model_path,返回 dict)¶
# model4.py:65-103
def __init__(self, model_path):
self.model_path = model_path # ← 需要参数
...
def predict(self, image_path):
img_tensor = self._preprocess_image(image_path)
return self._predict(img_tensor)
def _predict(self, img_tensor):
with torch.no_grad():
img_tensor = img_tensor.to(self.device)
output = self.model(img_tensor)
probability = torch.sigmoid(output).item()
pred_class = 1 if probability > 0.5 else 0
confidence = abs(probability - 0.5) * 2
return { # ← dict
'class': self.class_names[pred_class],
'probabilities': {
self.class_names[0]: 1 - probability,
self.class_names[1]: probability
},
'confidence': round(confidence, 4)
}
实例化:AgeClassifier(model_path="models/best_model4.pth") — 必传 path。
返回:{'class': ..., 'probabilities': ..., 'confidence': ...} — 字典。
对照表¶
| 维度 | model1/2/3 | model4 |
|---|---|---|
| 构造参数 | 无(path 内部硬编码) | model_path 必填 |
| 返回类型 | tuple |
dict |
| 返回字段 | (label, probs_dict) |
{class, probabilities, confidence} |
| 阈值方向 | prob > 0.5 → class_names[0] |
prob > 0.5 → class_names[1] |
| 额外信息 | 无 | confidence = |
调用方需要分情况处理:
- 在 watercamera.py:41 中用:pred_label, probs = classifier.predict(image_path)
- 在 swellcamera.py:84 中用:result = self.classifier.predict(temp_path),然后访问 result['class']
这种不一致让代码不可互换,新加模型时不知道该跟哪一种。
6.6 阈值方向的细节¶
注意 model1/2/3 与 model4 的 pred_class 取法不一样:
# model1/2/3
pred_class = 0 if probability > 0.5 else 1
# model4
pred_class = 1 if probability > 0.5 else 0
这是因为 class_names 的顺序约定不同——model1/2/3 把"高概率对应的类"放在 index 0,model4 放在 index 1。两种写法的最终语义其实都对(结合 class_names 的顺序),但混在一起读非常容易看错。
实际例子(model4)¶
class_names = ["膨胀", "不膨胀"]probability = 0.8(sigmoid 输出)pred_class = 1 if 0.8 > 0.5 else 0 → 1class_names[1] = "不膨胀"→ 实际输出"不膨胀"
但别忘了 swellcamera 还要叠加 label_mapping 把它转成"膨胀"!见 05 · 检测模块 5.3 节。
实际例子(model1)¶
class_names = ["35-40", "30-35"]probability = 0.8pred_class = 0 if 0.8 > 0.5 else 1 → 0class_names[0] = "35-40"→ 输出"35-40"- 概率字典 =
{"30-35": 0.2, "35-40": 0.8}✓
6.7 model1/3 的概率字典构造异常¶
model1.py:80-83:
class_probs = {
self.class_names[1]: 1 - probability, # 反转前是class_names[0]
self.class_names[0]: probability # 反转前是class_names[1]
}
注释里说"反转前是 class_names[0]",意思是 gxl 在某次改动里反转过这两个键。
对比 model2(没反转)¶
model2.py:86-89:
两者的实际效果¶
让我们仔细演算 model1(class_names = ["35-40", "30-35"]):
| probability | pred_class | pred_label | class_probs |
|---|---|---|---|
| 0.8 | 0 | "35-40" | {"30-35": 0.2, "35-40": 0.8} ✓ |
| 0.3 | 1 | "30-35" | {"30-35": 0.7, "35-40": 0.3} ✓ |
→ 结论:尽管字典构造看起来"反转"了,实际语义是正确的——probability 这个变量代表的是 class_names[0](即"35-40")的概率,所以 class_names[0]: probability 是正确的。注释里写的"反转前"指的是历史代码可能搞错过,gxl 修正了。
→ model1/2/3 这三个文件在功能上都是正确的,只是 model1/3 的字典构造写法绕(先 class_names[1] 再 class_names[0]),看起来反,实际不反。
6.8 utils/model_loader.py — 动态加载器¶
完整代码(utils/model_loader.py):
import importlib
from pathlib import Path
MODEL_MAP = {
'1': 'model1',
'2': 'model2',
'3': 'model3'
}
def load_model(model_id):
"""动态加载指定模型"""
if model_id not in MODEL_MAP:
raise ValueError(f"无效模型ID,可选: {list(MODEL_MAP.keys())}")
module_name = f"models.{MODEL_MAP[model_id]}"
try:
module = importlib.import_module(module_name)
return module
except ImportError as e:
raise ImportError(f"无法加载模型{model_id}: {str(e)}")
使用方式¶
watercamera.py:39-40:
注意 load_model 返回的是模块对象,不是类实例,调用方需要再取 .AgeClassifier()。
这个加载器解决了什么问题¶
- 让
watercamera可以在循环里跑for model_id in ['1', '2', '3'],不用写三个from models.modelN import AgeClassifier - 缺点:不包含 model4(
MODEL_MAP只有 1/2/3)。这导致 swellcamera 必须直接 import model4,加载器没起到统一作用
一个隐藏特性¶
importlib.import_module 会触发模块的首次完整执行,包括 if __name__ == '__main__': 之外的所有顶层代码。但 model1/2/3 没有有副作用的顶层代码,所以安全。
6.9 推理性能粗估(CPU)¶
基于 EfficientNet-B3 的特征:
| 阶段 | 时间(i7 CPU) | 时间(GPU) |
|---|---|---|
| 模型加载(一次性) | ~3 秒 | ~3 秒 |
| 单张图推理(含预处理) | ~500-800 ms | ~30-50 ms |
| 单张图推理 ×3(含水量投票) | ~1.5-2.5 秒 | ~150 ms |
→ 这就是 watercamera 选择 5 秒采样间隔的原因:三模型投票一次要 1.5-2.5 秒(CPU),间隔太短会丢帧。
→ 膨胀检测每帧推理(~500ms)+ 临时文件 I/O 实际 fps 大概是 1-2 fps,根本不可能达到摄像头的 30 fps(swellcamera.py:170 设的 720p 摄像头本身就是浪费)。
6.10 训练复现需要什么¶
仓库不包含训练相关的任何东西:
| 缺失 | 备注 |
|---|---|
| 训练脚本 | 没有 train.py |
| 数据集 | 没有 data/ 目录 |
| 训练超参 | epoch、lr、batch_size、optimizer 未知 |
| 损失函数 | 推测是 BCEWithLogitsLoss(二分类) |
| 训练日志 | TensorBoard / .log 都没有 |
| 数据增强 | 推测训练用了 Dropout + 翻转 + 颜色抖动 |
要重训需要向 gxl 拿这些。若未来要在不重训前提下替换模型,只要保持以下结构:
- EfficientNet-B3 + 1536→256→1 的分类头
- 输入 300×300 + ImageNet 归一化
- 输出单一 logit(sigmoid 后是二分类概率)
那么直接替换 .pth 文件即可,无需改 .py 代码。
6.11 测试代码(model1.py 等的 __main__ 块)¶
每个 model*.py 文件底部都有一段测试代码,用于独立验证:
# model1.py:87-92
if __name__ == '__main__':
classifier = AgeClassifier()
test_image = r"E:\kingtask\waterdata\30-35\30-35 (31).png" # ⚠️ 硬编码 Windows 路径
pred_label, probs = classifier.predict(test_image)
print(f"预测类别: {pred_label}")
print("各类别概率:", probs)
# model4.py:106-118
if __name__ == '__main__':
classifier = AgeClassifier(
model_path=r"C:\Users\lenovo\Desktop\ovo策略尝试\models\best_model4.pth" # ⚠️
)
result = classifier.predict(r"E:\圆盘干燥机智能化运行图片\膨胀组\膨胀结束\00c5e64a-...jpg")
print(...)
两个问题:
1. 测试路径是 gxl 本机的绝对路径(E:\kingtask\... 和 C:\Users\lenovo\...),任何人在自己机器上跑都会 FileNotFoundError
2. 泄漏了 gxl 的本地路径——C:\Users\lenovo 暗示她用户名叫 lenovo(应该是联想电脑默认用户名),E:\圆盘干燥机智能化运行图片 透露了原始数据集的命名
→ 应该删除或改成 argparse 输入路径。详见 08 · 问题与改进 P2-4。
6.12 推理调用速查表¶
| 你想 | 调用方式 |
|---|---|
| 加载 model4(膨胀) | from models.model4 import AgeClassifier; m = AgeClassifier('models/best_model4.pth') |
| 加载 model1(含水量 35-40 vs 30-35) | from models.model1 import AgeClassifier; m = AgeClassifier() |
| 推理一张图(model4) | result = m.predict('path/to.jpg') → dict |
| 推理一张图(model1/2/3) | label, probs = m.predict('path/to.jpg') → tuple |
| 在 watercamera 风格批量加载 | from utils.model_loader import load_model; mod = load_model('1'); m = mod.AgeClassifier() |
| 强制 CPU | m.device = torch.device('cpu') 后重新 m.model.to('cpu') |
| 切换批处理(一次多张) | 当前代码不支持,需改 _preprocess_image 让其接受 batch |