feat: 新增缺陷分类训练与评估链路

在聚类 + 人工归类得到的数据集(data/<集>/<类别名>/*.png)上训练分类模型。

- datasets/classification.py:按目录组织的分类数据集,**逐类分层**切分 train/val
  (others 只有 65 张,整体随机切很容易让它在验证集里挂零),固定种子可复现
- models/classifier.py:卷积骨架 + 分类头,与 DefectNet 共用 backbone 注册表,
  因此自监督产出的 simclr_backbone.pth 可用 init_from 直接初始化;
  frozen_backbone 可做线性探针
- engine/classification.py:训练循环 + 指标。主指标用 macro-F1 而非准确率——
  数据从 normal 2679 张到 others 65 张差 41 倍,全预测成 normal 也有 24% 准确率;
  损失按类别频率加权,避免小类被淹没;输出逐类 P/R/F1 与混淆矩阵
- classify.py:训练/评估入口,--eval-only 可单独评估并导出 eval_report.json
- configs/classify/film.yaml:预处理沿用聚类阶段的结论(拉伸 + 逐图标准化)。
  抹掉亮度在这里尤其重要:有几类几乎只来自单一原始数据集,不抹掉的话模型可能
  靠"认出这是哪个数据集"作弊
- 续训语义与检测/自监督一致:检查点只提供进度,超参以配置为准

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-10 14:58:41 +08:00
co-authored by Claude Opus 5
parent 33d28dcc7c
commit 33fdf396ee
10 changed files with 1085 additions and 1 deletions
+288
View File
@@ -0,0 +1,288 @@
"""分类任务的训练、评估与指标。
数据极不均衡(normal 2679 张 vs others 65 张,差 41 倍),所以:
- 主指标用 **macro-F1** 而不是准确率——全预测成 normal 也能拿到 24% 准确率,但 macro-F1 会很低;
- 损失支持按类别频率加权,避免小类被淹没。
"""
from __future__ import annotations
import time
import warnings
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import DataLoader
from engine.checkpoint import load_checkpoint, save_checkpoint
from engine.metrics import AverageMeter
from engine.optim import build_optimizer, build_scheduler
from engine.visualizer import SwanLabLogger
from utils.device import PeakMemory, resolve_amp
from utils.logging import get_logger
logger = get_logger("engine.classification")
class ClassificationMetric:
"""混淆矩阵 + 由它导出的各项指标。
准确率在不均衡数据上会骗人,所以同时给 macro-F1(各类等权)与
weighted-F1(按样本数加权),并保留逐类明细供人工判断哪些类没学会。
"""
def __init__(self, classes: list[str]) -> None:
self.classes = list(classes)
self.reset()
def reset(self) -> None:
n = len(self.classes)
self.confusion = torch.zeros(n, n, dtype=torch.long) # 行=真实,列=预测
def update(self, logits: torch.Tensor, targets: torch.Tensor) -> None:
preds = logits.argmax(dim=1).cpu()
for target, pred in zip(targets.cpu().tolist(), preds.tolist(), strict=True):
self.confusion[target, pred] += 1
def per_class(self) -> dict[str, dict[str, float]]:
report = {}
for index, name in enumerate(self.classes):
tp = int(self.confusion[index, index])
support = int(self.confusion[index].sum())
predicted = int(self.confusion[:, index].sum())
precision = tp / predicted if predicted else 0.0
recall = tp / support if support else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
report[name] = {
"precision": precision,
"recall": recall,
"f1": f1,
"support": support,
}
return report
def compute(self) -> dict[str, float]:
total = int(self.confusion.sum())
if not total:
return {"accuracy": 0.0, "macro_f1": 0.0, "weighted_f1": 0.0, "balanced_accuracy": 0.0}
report = self.per_class()
f1s = [r["f1"] for r in report.values()]
recalls = [r["recall"] for r in report.values() if r["support"]]
supports = [r["support"] for r in report.values()]
weighted = sum(r["f1"] * r["support"] for r in report.values()) / total
return {
"accuracy": float(self.confusion.diagonal().sum()) / total,
"macro_f1": sum(f1s) / len(f1s),
"weighted_f1": weighted,
# 各类召回的均值:小类分错会明显拉低它,比准确率更能反映"每类都学会了吗"
"balanced_accuracy": sum(recalls) / len(recalls) if recalls else 0.0,
"num_classes_with_support": sum(1 for s in supports if s),
}
def class_weights(counts: list[int], mode: str | None = "balanced") -> torch.Tensor | None:
"""按类别频率算损失权重。
`balanced` 用逆频率并归一化到均值 1:小类的每个样本被放大,
否则 normal(占 24%)会主导梯度,小类学不动。
"""
if not mode or str(mode).lower() in ("none", "false"):
return None
if str(mode).lower() != "balanced":
raise ValueError(f"未知 class_weight: {mode},可选 balanced / none")
counts_tensor = torch.tensor(counts, dtype=torch.float32).clamp(min=1)
weights = counts_tensor.sum() / (len(counts) * counts_tensor)
return weights / weights.mean()
class ClassificationTrainer:
"""Args:
model: 已 `.to(device)` 的分类模型。
train_loader / val_loader: 数据加载器。
cfg: `train` 段配置(epochs / optimizer / scheduler / amp / class_weight / monitor 等)。
classes: 类别名,顺序必须与标签下标一致。
"""
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader | None,
cfg: Mapping[str, Any],
device: torch.device,
work_dir: str | Path,
classes: list[str],
train_counts: list[int] | None = None,
full_config: dict | None = None,
) -> None:
self.model = model
self.train_loader = train_loader
self.val_loader = val_loader
self.cfg = dict(cfg)
self.device = device
self.classes = list(classes)
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.full_config = full_config
self.epochs = int(self.cfg.get("epochs", 30))
self.log_interval = int(self.cfg.get("log_interval", 20))
self.clip_grad = float(self.cfg.get("clip_grad", 0.0))
self.monitor = str(self.cfg.get("monitor", "macro_f1"))
self.label_smoothing = float(self.cfg.get("label_smoothing", 0.0))
weights = class_weights(train_counts or [], self.cfg.get("class_weight", "balanced"))
self.class_weight = weights.to(device) if weights is not None else None
if self.class_weight is not None:
extremes = sorted(zip(self.classes, weights.tolist(), strict=True), key=lambda kv: -kv[1])
logger.info(
"类别权重(逆频率):最大 %s=%.2f,最小 %s=%.2f",
extremes[0][0], extremes[0][1], extremes[-1][0], extremes[-1][1],
)
self.optimizer = build_optimizer(model, self.cfg.get("optimizer", {"name": "adamw", "lr": 1e-3}))
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
self.amp = resolve_amp(self.cfg.get("amp", "auto"), device)
self.scaler = torch.amp.GradScaler(device.type, enabled=self.amp)
self.peak_memory = PeakMemory(device)
self.metric = ClassificationMetric(self.classes)
self.best_metric = float("-inf")
self.start_epoch = 0
self.global_step = 0
self.visualizer = SwanLabLogger(
work_dir=self.work_dir, config=full_config, **dict(self.cfg.get("swanlab", {}))
)
def _loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
return F.cross_entropy(
logits, targets, weight=self.class_weight, label_smoothing=self.label_smoothing
)
def train_one_epoch(self, epoch: int) -> dict[str, float]:
self.model.train()
loss_meter, acc_meter = AverageMeter(), AverageMeter()
start = time.time()
for step, batch in enumerate(self.train_loader):
images = batch["images"].to(self.device, non_blocking=True)
targets = batch["labels"].to(self.device, non_blocking=True)
with torch.amp.autocast(self.device.type, enabled=self.amp):
logits = self.model(images)
loss = self._loss(logits, targets)
self.optimizer.zero_grad(set_to_none=True)
self.scaler.scale(loss).backward()
if self.clip_grad > 0:
self.scaler.unscale_(self.optimizer)
nn.utils.clip_grad_norm_(self.model.parameters(), self.clip_grad)
self.scaler.step(self.optimizer)
self.scaler.update()
self.peak_memory.update()
batch_size = images.shape[0]
loss_meter.update(float(loss.detach()), batch_size)
acc_meter.update(float((logits.argmax(1) == targets).float().mean()), batch_size)
self.global_step += 1
if step % self.log_interval == 0:
logger.info(
"epoch %d [%d/%d] loss=%.4f acc=%.4f lr=%.2e",
epoch, step, len(self.train_loader), loss_meter.avg, acc_meter.avg,
self.optimizer.param_groups[0]["lr"],
)
self.visualizer.log(
{"loss": loss_meter.avg, "acc": acc_meter.avg,
"lr": self.optimizer.param_groups[0]["lr"]},
step=self.global_step, prefix="train/",
)
logger.info("epoch %d 训练耗时 %.1fs", epoch, time.time() - start)
return {"loss": loss_meter.avg, "acc": acc_meter.avg}
@torch.no_grad()
def evaluate(self, loader: DataLoader | None = None) -> dict[str, float]:
loader = loader or self.val_loader
if loader is None:
return {}
self.model.eval()
self.metric.reset()
loss_meter = AverageMeter()
for batch in loader:
images = batch["images"].to(self.device, non_blocking=True)
targets = batch["labels"].to(self.device, non_blocking=True)
logits = self.model(images)
loss_meter.update(float(self._loss(logits, targets)), images.shape[0])
self.metric.update(logits, targets)
metrics = self.metric.compute()
metrics["loss"] = loss_meter.avg
return metrics
def fit(self) -> float:
logger.info("开始训练:共 %d 个 epoch,%d 类,输出目录 %s",
self.epochs, len(self.classes), self.work_dir)
for epoch in range(self.start_epoch, self.epochs):
self.train_one_epoch(epoch)
if self.scheduler is not None:
self.scheduler.step()
metrics = self.evaluate()
if metrics:
logger.info(
"epoch %d 验证: %s",
epoch,
" ".join(f"{k}={v:.4f}" for k, v in metrics.items() if isinstance(v, float)),
)
self.visualizer.log(metrics, step=self.global_step, prefix="val/")
self._save("last.pth", epoch)
score = metrics.get(self.monitor)
if score is not None and score > self.best_metric:
self.best_metric = score
self._save("best.pth", epoch)
logger.info("epoch %d 刷新最佳 %s=%.4f,已保存 best.pth", epoch, self.monitor, score)
self.visualizer.finish()
return self.best_metric
def _save(self, name: str, epoch: int) -> None:
save_checkpoint(
self.work_dir / name, self.model, self.optimizer, epoch=epoch,
best_metric=self.best_metric, config=self.full_config,
scheduler=self.scheduler, global_step=self.global_step, train_cfg=dict(self.cfg),
)
def resume(self, path: str | Path) -> dict:
"""续训。与检测训练同样的语义:检查点只提供进度,超参以配置为准。"""
state = load_checkpoint(path, self.model, map_location=self.device)
if state.get("optimizer"):
intended = [
{k: v for k, v in group.items() if k != "params"}
for group in self.optimizer.param_groups
]
self.optimizer.load_state_dict(state["optimizer"])
for group, wanted in zip(self.optimizer.param_groups, intended, strict=True):
group.update(wanted)
self.start_epoch = int(state.get("epoch", -1)) + 1
self.global_step = int(state.get("global_step", 0))
best = state.get("best_metric")
self.best_metric = float(best) if best is not None else float("-inf")
if self.scheduler is not None:
for group in self.optimizer.param_groups:
group["lr"] = group.get("initial_lr", group["lr"])
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
for _ in range(self.start_epoch):
self.scheduler.step()
logger.info("已从 %s 恢复:起始 epoch=%d,best_%s=%.4f",
path, self.start_epoch, self.monitor, self.best_metric)
return state