"""HCCR 模型定义。 baseline:plain CNN(5 个 conv block + GAP + Linear)。 - 输入: [B, 1, 64, 64] - 输出: [B, num_classes] (logits) - 参数量:约 1.1M(num_classes=3755) - 不用 dropout(BN 已经有正则效果);如需可在 classifier 前加 约束: - 只用标准算子(Conv2d / BN / ReLU / MaxPool / Linear)便于后续导出 - 固定输入 shape(避免动态控制流) """ from __future__ import annotations import torch import torch.nn as nn def _conv_bn_relu(in_ch: int, out_ch: int, kernel_size: int = 3, padding: int = 1) -> nn.Sequential: return nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size, padding=padding, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) class GWAP(nn.Module): """Global Weighted Average Pooling(Melnyk et al. 2020)。 学一个空间权重图代替朴素 GAP: out[c] = sum_{i,j} W[c, i, j] * x[c, i, j] 初始化为 1/(H*W) 即等价 GAP,有梯度后偏离学到"哪些位置更重要"。 可学参数:C × H × W(对 448 ch / 4×4 spatial 是 7168,可忽略)。 """ def __init__(self, channels: int, spatial_h: int, spatial_w: int) -> None: super().__init__() init_val = 1.0 / (spatial_h * spatial_w) self.weight = nn.Parameter( torch.full((channels, spatial_h, spatial_w), init_val, dtype=torch.float32) ) def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [B, C, H, W] → [B, C] return (x * self.weight).sum(dim=(2, 3)) class PlainCNN(nn.Module): """5 stage CNN,每 stage 后 MaxPool 下采样一次。 [1,64,64] → 32 → 32(/2)=32 → 64(/2)=16 → 96(/2)=8 → 128(/2)=4 → 192(/2)=2 → GAP → Linear """ def __init__(self, num_classes: int, in_channels: int = 1) -> None: super().__init__() self.features = nn.Sequential( _conv_bn_relu(in_channels, 32), nn.MaxPool2d(2), # 64→32 _conv_bn_relu(32, 64), nn.MaxPool2d(2), # 32→16 _conv_bn_relu(64, 96), nn.MaxPool2d(2), # 16→8 _conv_bn_relu(96, 128), nn.MaxPool2d(2), # 8→4 _conv_bn_relu(128, 192), nn.MaxPool2d(2), # 4→2 ) self.gap = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Linear(192, num_classes) self._init_weights() def _init_weights(self) -> None: for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") elif isinstance(m, nn.BatchNorm2d): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.zeros_(m.bias) def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.features(x) x = self.gap(x).flatten(1) return self.classifier(x) class MelnykNet(nn.Module): """Melnyk-Net(2020 Soft Computing,Melnyk/You/Li)。 原版 96×96 输入,这里改造为 64×64(我们的部署口径)。 14 conv(3×3 + BN + ReLU)分 5 stage,4 次 avg pool,GWAP,FC。 spatial 流动:64 → 32 → 16 → 8 → 4 通道流动(注意"瓶颈"模式 high-low-high): stage 1: 64, 64 @ 64×64 stage 2: 96, 64, 96 @ 32×32 stage 3: 128, 96, 128 @ 16×16 stage 4: 256, 192, 256 @ 8×8 stage 5: 448, 256, 448 @ 4×4 总参数 ~6.5M(同原版),FP32 ~25 MB,INT8 ~6.5 MB(超 plan 5MB,需要后续考虑)。 """ def __init__(self, num_classes: int, in_channels: int = 1) -> None: super().__init__() def block(in_ch: int, out_ch: int) -> nn.Sequential: return _conv_bn_relu(in_ch, out_ch) self.stage1 = nn.Sequential(block(in_channels, 64), block(64, 64)) self.stage2 = nn.Sequential(block(64, 96), block(96, 64), block(64, 96)) self.stage3 = nn.Sequential(block(96, 128), block(128, 96), block(96, 128)) self.stage4 = nn.Sequential(block(128, 256), block(256, 192), block(192, 256)) self.stage5 = nn.Sequential(block(256, 448), block(448, 256), block(256, 448)) self.pool = nn.AvgPool2d(2) self.gwap = GWAP(448, 4, 4) self.classifier = nn.Linear(448, num_classes) self._init_weights() def _init_weights(self) -> None: for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu") elif isinstance(m, nn.BatchNorm2d): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.zeros_(m.bias) def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.stage1(x); x = self.pool(x) x = self.stage2(x); x = self.pool(x) x = self.stage3(x); x = self.pool(x) x = self.stage4(x); x = self.pool(x) x = self.stage5(x) # 4×4 x = self.gwap(x) return self.classifier(x) def _build_mobilenet_v3_small_hccr(num_classes: int, in_channels: int = 1) -> nn.Module: """torchvision MobileNetV3-Small 改造: - 第一层 conv 改成单通道输入(3 → 1) - 分类头去掉 576 → 1024 隐层,直接 576 → num_classes(省 ~600K params,3755 类下分类头本身就是大头) - 保留 HSwish / SE 块 / depthwise conv,PNNX/NCNN 都原生支持 输入: [B, 1, 64, 64] spatial 走 stride 2×5 → 64 → 32 → 16 → 8 → 4 → 2 输出: [B, num_classes] """ from torchvision.models import mobilenet_v3_small model = mobilenet_v3_small(num_classes=num_classes) model.features[0][0] = nn.Conv2d( in_channels, 16, kernel_size=3, stride=2, padding=1, bias=False ) model.classifier = nn.Sequential( nn.Dropout(p=0.2, inplace=False), nn.Linear(576, num_classes), ) # torchvision 在 mobilenet_v3_small() 构造时跑过 init loop(Conv kaiming_normal, # Linear normal(0, 0.01))。我们之后才替换层,新层是 PyTorch 默认 init, # stddev 偏大(分类头 2.3x),早期梯度信号偏强 —— 重新走一遍同样的 init。 for layer in (model.features[0][0], model.classifier[1]): if isinstance(layer, nn.Conv2d): nn.init.kaiming_normal_(layer.weight, mode="fan_out") if layer.bias is not None: nn.init.zeros_(layer.bias) elif isinstance(layer, nn.Linear): nn.init.normal_(layer.weight, 0, 0.01) nn.init.zeros_(layer.bias) return model class MobileNetV2WithGWAP(nn.Module): """V2 改造 + 借 Melnyk-Net 的 GWAP 替换 GAP。 流程:V2 features → GWAP(C × 2 × 2 学习权重)→ Dropout → Linear。 输入 [B,1,64,64]:V2 5 次 stride 2 下采样 → 2×2 spatial。 """ def __init__( self, num_classes: int, in_channels: int = 1, last_channel: int = 576 ) -> None: super().__init__() from torchvision.models import mobilenet_v2 base = mobilenet_v2() base.features[0][0] = nn.Conv2d( in_channels, 32, kernel_size=3, stride=2, padding=1, bias=False ) base.features[18] = nn.Sequential( nn.Conv2d(320, last_channel, kernel_size=1, bias=False), nn.BatchNorm2d(last_channel), nn.ReLU6(inplace=True), ) self.features = base.features # 64 input → 5 次 stride 2 → 2×2 spatial 在末端 self.gwap = GWAP(last_channel, 2, 2) self.classifier = nn.Sequential( nn.Dropout(p=0.2, inplace=False), nn.Linear(last_channel, num_classes), ) for layer in ( self.features[0][0], self.features[18][0], self.features[18][1], self.classifier[1], ): if isinstance(layer, nn.Conv2d): nn.init.kaiming_normal_(layer.weight, mode="fan_out") if layer.bias is not None: nn.init.zeros_(layer.bias) elif isinstance(layer, nn.BatchNorm2d): nn.init.ones_(layer.weight) nn.init.zeros_(layer.bias) elif isinstance(layer, nn.Linear): nn.init.normal_(layer.weight, 0, 0.01) nn.init.zeros_(layer.bias) def forward(self, x: torch.Tensor) -> torch.Tensor: x = self.features(x) # [B, last_ch, 2, 2] x = self.gwap(x) # [B, last_ch] return self.classifier(x) def _replace_relu6_with_relu(module: nn.Module) -> None: """递归把 ReLU6 替换为 ReLU。 用途:MobileNetV2 默认 ReLU6 在 INT8 量化时(activation 被 clip 到 [0, 6] + 1×1 expand 高动态范围 + DW 各通道差异大)精度损失大。换成 ReLU 后 激活分布更线性,PTQ 友好,文献和实测都验证过。 FP32 精度通常基本不变(< 0.3 点),换来 INT8 PTQ 损失从 2-3 点 → < 1 点。 """ for name, child in module.named_children(): if isinstance(child, nn.ReLU6): setattr(module, name, nn.ReLU(inplace=child.inplace)) else: _replace_relu6_with_relu(child) def _build_mobilenet_v2_relu_hccr( num_classes: int, in_channels: int = 1, last_channel: int = 576 ) -> nn.Module: """V2-HCCR + ReLU(替换全部 ReLU6),量化友好版。""" model = _build_mobilenet_v2_hccr(num_classes, in_channels, last_channel) _replace_relu6_with_relu(model) return model def _build_mobilenet_v2_hccr( num_classes: int, in_channels: int = 1, last_channel: int = 576 ) -> nn.Module: """torchvision MobileNetV2 改造 for HCCR: - 第一层 conv 改单通道输入(3 → 1) - 最后 1×1 expand 从 320→1280 改为 320→last_channel(默认 576), 避免分类头 Linear(1280, 3755) 单层就 4.8M params - 分类头 Linear(last_channel, num_classes) 保留 ReLU6 + 标准 inverted residual(无 SE,无 HSwish,无 NAS 不规则宽度), 量化最稳。skip connection(ResNet 思想)在 inverted residual 块内自动包含。 输入: [B, 1, 64, 64] 输出: [B, num_classes] """ from torchvision.models import mobilenet_v2 model = mobilenet_v2(num_classes=num_classes) # 1. 第一层 conv:3 → 1 model.features[0][0] = nn.Conv2d( in_channels, 32, kernel_size=3, stride=2, padding=1, bias=False ) # 2. 最后 1×1 expand:320 → last_channel model.features[18] = nn.Sequential( nn.Conv2d(320, last_channel, kernel_size=1, bias=False), nn.BatchNorm2d(last_channel), nn.ReLU6(inplace=True), ) # 3. 分类头:Dropout + Linear model.classifier = nn.Sequential( nn.Dropout(p=0.2, inplace=False), nn.Linear(last_channel, num_classes), ) # 4. 替换层后重新跑 torchvision 的 init 约定(同 V3 处理) for layer in ( model.features[0][0], model.features[18][0], model.features[18][1], model.classifier[1], ): if isinstance(layer, nn.Conv2d): nn.init.kaiming_normal_(layer.weight, mode="fan_out") if layer.bias is not None: nn.init.zeros_(layer.bias) elif isinstance(layer, nn.BatchNorm2d): nn.init.ones_(layer.weight) nn.init.zeros_(layer.bias) elif isinstance(layer, nn.Linear): nn.init.normal_(layer.weight, 0, 0.01) nn.init.zeros_(layer.bias) return model def build_model(name: str, num_classes: int) -> nn.Module: if name == "plain_cnn": return PlainCNN(num_classes) if name == "mobilenet_v3_small": return _build_mobilenet_v3_small_hccr(num_classes) if name == "mobilenet_v2": return _build_mobilenet_v2_hccr(num_classes) if name == "mobilenet_v2_relu": return _build_mobilenet_v2_relu_hccr(num_classes) if name == "mobilenet_v2_gwap": return MobileNetV2WithGWAP(num_classes) if name == "melnyk_net": return MelnykNet(num_classes) raise ValueError(f"未知模型: {name}") def count_params(model: nn.Module) -> int: return sum(p.numel() for p in model.parameters() if p.requires_grad) if __name__ == "__main__": # smoke:确认能 forward,参数量符合预期 x = torch.randn(2, 1, 64, 64) for name in ("plain_cnn", "mobilenet_v3_small", "mobilenet_v2", "mobilenet_v2_relu", "mobilenet_v2_gwap", "melnyk_net"): model = build_model(name, num_classes=3755) n = count_params(model) bytes_ = sum(p.numel() * p.element_size() for p in model.parameters()) y = model(x) print(f"{name}: {n:,} params ({n/1e6:.2f}M) fp32={bytes_/1e6:.2f}MB " f"int8≈{bytes_/4/1e6:.2f}MB out={tuple(y.shape)}")