跳转至

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)

几个关键事实

  1. from_name 不下载预训练:与 from_pretrained 不同,from_name 只构造结构,权重是随机初始化的。所有有意义的参数都来自 best_modelN.pth 这个完整微调权重(不是只存分类头)
  2. num_classes=1:原始 EfficientNet-B3 的 _fcLinear(1536, 1000)(ImageNet 1000 类),这里改为 Linear(1536, 1) 输出单一 logit → 经 sigmoid 得二分类概率
  3. 特征维度 1536model._fc.in_features 是 1536(EfficientNet-B3 的默认)
  4. 输入 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 → 1
  • class_names[1] = "不膨胀" → 实际输出"不膨胀"

别忘了 swellcamera 还要叠加 label_mapping 把它转成"膨胀"!见 05 · 检测模块 5.3 节。

实际例子(model1)

  • class_names = ["35-40", "30-35"]
  • probability = 0.8
  • pred_class = 0 if 0.8 > 0.5 else 1 → 0
  • class_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

class_probs = {
    self.class_names[0]: probability,
    self.class_names[1]: 1 - probability
}

两者的实际效果

让我们仔细演算 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

model_module = load_model(model_id)
classifier = model_module.AgeClassifier()

注意 load_model 返回的是模块对象,不是类实例,调用方需要再取 .AgeClassifier()

这个加载器解决了什么问题

  • watercamera 可以在循环里跑 for model_id in ['1', '2', '3'],不用写三个 from models.modelN import AgeClassifier
  • 缺点:不包含 model4MODEL_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 拿这些。若未来要在不重训前提下替换模型,只要保持以下结构:

  1. EfficientNet-B3 + 1536→256→1 的分类头
  2. 输入 300×300 + ImageNet 归一化
  3. 输出单一 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