简介:一份基于深度学习的鱼类分类算法研究论文PDF,面向计算机视觉、机器学习研究者及水产信息化开发人员,针对传统鱼类分类依赖人工设计特征、跨数据集可迁移性差的问题,给出基于卷积神经网络与迁移学习的自动特征提取方案。资源共1个PDF文件,整体约1.56MB,为论文全文,涵盖摘要、引言、相关技术、模型设计、实验与结论等完整内容。论文以CNN卷积神经网络为基础,提出PreCNN+SVM混合分类模型,利用预训练网络提取高级特征后交由支持向量机分类,并在Fish4Knowledge数据集上借助TensorFlow完成训练与评估,最终获得98.6%的分类准确率,明显优于传统方法。读者可直接参考其网络结构、训练参数和实验对比,用于学术论文写作、深度学习课程项目或鱼类识别算法复现。目前已有105人学习/下载。
1. 鱼类分类为什么难:从“分类算法研究”到可落地的技术选型
做鱼类分类和做猫狗分类完全是两回事。水下图像的色偏、浑浊、鱼身姿态扭曲,再加上很多鱼种之间只有鳞片纹路和鳍形差异,通用的深度学习分类算法直接套上去,常常在论文指标上好看,一到现场就翻车。这篇笔记从实践角度拆解基于深度学习的鱼类分类算法:数据怎么整理、模型怎么选、训练参数怎么调、部署有哪些坑,以及最后怎么验证它真的能用。适合做水产养殖监测、渔业资源调查、水下机器人视觉的工程师,也适合准备用图像分类算法解决类似细粒度识别问题的朋友。
2. 数据准备与预处理:把原始图像整理成能训练的样子
鱼类分类算法训练之前,数据整理占掉一半工作量。很多人一上来就写模型,结果训练到一半发现标签错乱、类别不均衡、验证集分布和真实场景对不上,只能返工。这一章按常见做法把数据链路打通:分类体系、目录结构、预处理、数据增强。
2.1 先定分类体系,再选数据集
在开始训练之前,第一件事不是下载模型,而是确定分类粒度。你要分的是“鲤鱼、草鱼、鲢鱼”这样的鱼种,还是“鱼 vs 非鱼”这样的二分类,难度完全不一样。鱼种级别的分类属于细粒度图像识别,类间差异有时只在鳞片排列、鳍条数量和体色斑纹上,这比猫狗分类要难不少。
公开数据集方面,常见的有 Fish4Knowledge、DeepFish 等。前者是从水下视频里抽帧得到的淡水鱼图像,类别数在 20 种上下;后者更偏向海洋鱼类的检测与分类任务。用这类公开数据集做算法验证是够的,但如果你最终要部署到某个具体水域,最好自采一批覆盖目标场景的视频帧,否则后面会遇到严重的域差异问题。
数据规模上,深度学习图像分类模型在 ImageNet 上需要百万级数据,但鱼类分类任务受益于迁移学习,每类有 500 到 1000 张图像可以看作起步配置。如果某类图像实在少于 200 张,优先想两个办法:一是去补充数据,二是把它合并到相近的类别里,而不是硬着头皮让模型去学。
2.2 按类别目录组织数据:用 ImageFolder 避免标签错乱
PyTorch 的 torchvision.datasets.ImageFolder 要求数据按“类别目录”组织,目录名就是标签名。这个约定简单可靠,很多人却在这一步踩坑:把图片全部丢到一个文件夹,再用一个 CSV 记录标签,一旦图片和 CSV 对不上,整个训练集就废了。
我常用的目录结构是这样:
data/ ├── train/ │ ├── class_a/ │ │ ├── 0001.jpg │ │ └── 0002.jpg │ └── class_b/ │ └── 0001.jpg └── val/ ├── class_a/ │ └── 0003.jpg └── class_b/ └── 0002.jpg先用一个划分脚本把原始图片按类别切到 train 和 val。脚本逻辑很简单:遍历每个类别目录,按比例随机切分,保持类别目录名不变。
import argparse import random import shutil from pathlib import Path def split_dataset(src: Path, out: Path, ratio: float, seed: int): random.seed(seed) class_dirs = [p for p in src.iterdir() if p.is_dir()] for cls in class_dirs: images = list(cls.rglob("*.jpg")) + list(cls.rglob("*.png")) if len(images) == 0: continue random.shuffle(images) split_idx = int(len(images) * ratio) for img in images[:split_idx]: dest = out / "train" / cls.name / img.name dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy(img, dest) for img in images[split_idx:]: dest = out / "val" / cls.name / img.name dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy(img, dest) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--src", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--ratio", type=float, default=0.8) parser.add_argument("--seed", type=int, default=42) args = parser.parse_args() split_dataset(args.src, args.out, args.ratio, args.seed)这段脚本的关键是“按类别目录分别切分”。如果对整个文件列表统一打乱再切分,某类图片数少时可能全部跑进训练集,验证集里一个样本都没有,后面训练时验证准确率会出现诡异的大坑。参数上,ratio 控制训练集比例,80% 是经验值;seed 固定为 42 让每次切分结果可复现。我习惯用 copy 而不是 move,原始数据保留一份,后面清洗时还能找回来。
2.3 用 PyTorch 写 Dataset:预处理顺序影响最终精度
ImageFolder 虽然方便,但有时需要自定义预处理或标签映射,我会直接写一个轻量 Dataset 类:
from pathlib import Path from PIL import Image from torch.utils.data import Dataset class FishDataset(Dataset): def __init__(self, root, transform=None): self.paths = [] self.labels = [] self.classes = sorted([p.name for p in Path(root).iterdir() if p.is_dir()]) class_to_idx = {c: i for i, c in enumerate(self.classes)} for cls in self.classes: cls_dir = Path(root) / cls for img_path in cls_dir.rglob("*.jpg"): self.paths.append(img_path) self.labels.append(class_to_idx[cls]) self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") if self.transform: img = self.transform(img) return img, self.labels[idx]这段代码把标签映射成从 0 开始的连续整数,顺序由目录名排序决定。注意rglob("*.jpg")只匹配 jpg,如果你的数据里有 png、bmp,记得补上。当数据集有几十万张图时,每次遍历目录会比较慢,建议第一次运行后把(path, label)列表缓存成 json 或 txt,下次直接读取。
预处理环节,训练集和验证集要分开写。训练集用 RandomResizedCrop、随机翻转和颜色抖动,验证集只做 Resize 和 CenterCrop:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])归一化用的 ImageNet 统计量,这对鱼类图像依然有效。水下图像普遍偏蓝绿,ColorJitter 的 brightness 和 contrast 参数我一般控制在 0.2 到 0.3 之间,太强会把鱼的体色特征洗掉,太弱又模拟不了不同水深的光照差异。输入分辨率 224 是起步值,如果后续混淆矩阵显示相似鱼种分不开,可以升到 256 或 384,但显存占用会明显上涨。
类别不均衡是鱼类数据的常见问题,某类鱼常见、某类稀有。除了直接对 loss 做加权,更稳的做法是使用 WeightedRandomSampler:
from torch.utils.data.sampler import WeightedRandomSampler def make_weighted_sampler(dataset): counts = [0] * len(dataset.classes) for label in dataset.labels: counts[label] += 1 weights = [1.0 / c for c in counts] sample_weights = [weights[label] for label in dataset.labels] return WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)这里 sample weight 是类别样本数的倒数,让稀有类别被采到的概率更高。加了这个采样器后,模型会对少数类过拟合得更快,所以一定要配合数据增强和早停。如果某类图片只有几十张,先别急着调采样器,补数据才是根治办法。
3. 模型选型与训练:从 CNN 到迁移学习的关键参数
模型部分的核心不是把网络搭得多花哨,而是怎么让预训练权重在鱼类数据上稳定收敛。这一章覆盖模型怎么选、训练参数怎么定、检查点怎么存,以及常见的训练环境配置问题。
3.1 为什么用迁移学习而不是从零训练
鱼类分类项目的数据量通常只有几千到几万张,远不够从零训练一个深度 CNN。从零初始化 ResNet50 在这么少的数据上几乎必然过拟合。常见做法是加载 ImageNet 预训练权重做迁移学习,把最后的全连接层换成自己的类别数,然后微调。
常用模型有三类:
- ResNet50:经典结构,PyTorch 官方权重稳定,显存占用适中,适合做 baseline。
- EfficientNet-B0/B1:相同精度下参数更少,但对数据增强和优化器更敏感,初期容易踩坑。
- MobileNetV3-Large:面向移动端和嵌入式设备部署,精度略低,适合最终要上手机或边缘盒子。
做深度学习常用的编程语言是 Python,PyTorch 生态里这些模型都有现成实现。如果项目对推理延迟不敏感,我一般先用 ResNet50 跑通流程,拿到一个可靠的 baseline,再根据部署目标换轻量模型。
3.2 训练脚本:模型替换、损失函数和优化器参数
用 PyTorch 加载预训练 ResNet50,替换最后的分类层:
import torch import torch.nn as nn from torchvision import models def create_model(num_classes, model_name="resnet50"): if model_name == "resnet50": model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif model_name == "efficientnet_b0": model = models.efficientnet_b0( weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1 ) in_features = model.classifier[1].in_features model.classifier[1] = nn.Linear(in_features, num_classes) return modelweights 参数会下载预训练权重,下载路径可以通过环境变量指定,避免每次换机器都重新下。替换分类头后,如果数据量少,可以先把 backbone 冻结,只训练分类头几个 epoch,等 loss 稳定后再解冻 backbone 做全量微调。如果每类图片超过 500 张,可以直接全部微调,收敛更快。
损失函数和优化器的设置,我推荐这样:
epochs = 30 optimizer = torch.optim.SGD( model.parameters(), lr=1e-3, momentum=0.9, weight_decay=1e-4, ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs, eta_min=1e-5, ) criterion = nn.CrossEntropyLoss(label_smoothing=0.1)几个容易被忽视的参数:
- 学习率:全量微调时 SGD 的 lr 从 1e-3 开始。如果只训练分类头,lr 可以提到 1e-2。解冻 backbone 后,backbone 部分的学习率建议设为分类头的十分之一,否则前期预训练特征容易被破坏。
- batch size:默认从 32 开始。ResNet50 在 224 输入下,batch size 32 大约需要 8GB 显存。显存不足时把 batch size 减半,同时学习率也减半,这是最常见的补偿做法。
- 损失函数:CrossEntropyLoss 加 label_smoothing=0.1,对标注噪声有一定免疫力。鱼类相似种标注本来就容易出错,太自信的模型反而不好。
- 混合精度:用 torch.cuda.amp 能省 40% 左右显存,训练速度也更快。但老显卡不支持,跑之前先检查算力。
提示:如果显存不足,优先减 batch size 并把学习率减半,而不是直接换更小的模型。先跑通 baseline,再考虑效率优化。
3.3 训练循环、早停和检查点
训练循环本身不复杂,但检查点和早停的逻辑值得好好设计。我习惯用验证集 loss 作为保存模型的标尺,而不是准确率,因为 loss 对概率分布的细微变化更敏感。
best_val_loss = float("inf") for epoch in range(epochs): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc = evaluate(model, val_loader, criterion) scheduler.step() if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_fish_model.pth")早停可以手写一个小类:
class EarlyStopping: def __init__(self, patience=10, min_delta=0.001): self.patience = patience self.min_delta = min_delta self.counter = 0 self.best_loss = None def step(self, val_loss): if self.best_loss is None or val_loss < self.best_loss - self.min_delta: self.best_loss = val_loss self.counter = 0 else: self.counter += 1 return self.counter >= self.patiencepatience 设为 10 意味着连续 10 个 epoch 验证 loss 没有明显下降就停止。min_delta 设为 0.001,防止 loss 微小抖动就误判为过拟合。训练时把每个 epoch 的 lr、train_loss、val_loss、val_acc 记录到 CSV 或 TensorBoard,后面排查问题全靠这份日志。很多训练“翻车”现场,回看日志会发现 lr 根本没按计划下降,或者验证 loss 在第 5 个 epoch 就开始回升,只是之前没记录。
4. 鱼类分类模型训练与部署的 5 个常见坑:现象、原因、解决
这个领域踩过的坑不少,下面按“现象 → 原因 → 解决”的方式记录五条,基本覆盖从训练到部署最常见的故障点。
4.1 训练集准确率接近满分,验证集却忽高忽低
现象:训练 loss 一路下降,准确率到 95% 以上,但验证集准确率在 70% 到 90% 之间大幅跳动,验证 loss 稳定不下来。
原因:一是模型过拟合,二是验证集样本太少。鱼类数据类别不均衡时,验证集里某些类可能只有十几张图,随机抽到哪一批对整体准确率影响很大,甚至会出现同一套权重两次评估结果差 5 个点的情况。
解决:先看验证集每个类别的样本数,每类至少 30 到 50 张,少了就回去重新切分。样本量够还震荡,就把 weight_decay 从 1e-4 调到 5e-4,学习率减半,同时加强数据增强。另外确认验证集没有开 shuffle,虽然顺序不影响结果,但如果验证集每次都被打乱,你很难判断指标变化来自模型还是来自抽样。
4.2 相似鱼种严重混淆,混淆矩阵一片红
现象:整体准确率看起来还行,但展开混淆矩阵发现某两个鱼种互相错分,比如鲤鱼和草鱼被混成一片。
原因:224 输入分辨率下,鳞片纹理、鳍条数量这类细粒度特征不明显。另一个原因是数据里这两个类别的姿态和角度分布差异大,模型学到的是姿态特征而不是鱼种特征。
解决:先把输入分辨率提升到 320 或 384,让卷积核能看到更多细节。训练时 RandomResizedCrop 的 scale 下限调到 0.5,强制模型关注局部纹理。如果还分不开,把这两个难分类别单独拉出来训练一个二分类模型,或者引入注意力模块。在鱼类分类项目里,我会在 backbone 后加一个轻量 SE 模块,涨点不一定多,但能显著降低相似种混淆。
4.3 本地测试集表现很好,部署到边缘设备上精度掉一截
现象:服务器上验证准确率 90%,换到手机、Jetson 等设备上只剩 80%,有时差距更大。
原因:预处理不一致是最常见原因。训练时用 PyTorch 的 transforms 做 Resize 和 CenterCrop,部署代码用 OpenCV 的 resize,插值算法不同,图像像素分布就有差异。另外推理时的输入尺寸、归一化均值方差写错,或者模型量化时精度损失太大,都会导致掉点。
解决:在部署代码里严格复刻训练时的预处理顺序。训练时如果用 Resize(256) 再 CenterCrop(224),部署时就按同样顺序处理,不要图省事直接 resize 到 224。量化方面,先用校准集计算每层的动态范围,不要直接动态量化。如果部署目标是嵌入式设备,建议一开始就选 MobileNetV3 这类轻量模型,而不是训练完 ResNet50 再想办法压缩。
4.4 用实验室鱼缸数据训练,拿到野外水域就失灵
现象:训练集里鱼在干净鱼缸或白色背景中,部署到浑浊水体、复杂水草环境时频繁误判。
原因:域差异太大。模型在训练时把背景特征也当成了判别依据,一旦背景变成水草、泥沙和光线变化,分类就失效。这不是过拟合,而是“学到了错误的东西”。
解决:数据层面,采集目标场景的视频帧加入训练。增强层面,用 RandomErasing 或者遮挡一部分图像,让模型不能只依赖背景。算法层面,如果鱼在画面中占比很小,先跑一个目标检测模型把鱼裁剪出来再分类,而不是直接用整图分类。这一步在鱼类分类项目里几乎决定成败,比调任何训练参数都有效。
4.5 标注错了几百张图,模型怎么训都不对
现象:训练 loss 降不下去,或者验证集里某些类别的召回率极低,怎么调参数都没用。
原因:标注噪声,尤其是相似鱼种被标错。分类问题用交叉熵时,错误标签会让模型学到矛盾的梯度,最终表现为某些类别不管怎么调都拉不起来。
解决:先做数据清洗。用当前模型对训练集做预测,把预测置信度高但标签不一致的样本挑出来人工复核。也可以用 T-SNE 对特征做可视化,离群的样本往往是错标。标注流程上,每张图至少两个人独立标注,不一致的交给第三人仲裁。这套流程很慢,但血泪经验告诉我,宁可在脏数据上多花三天清洗,也不要在脏数据上训练一周,因为后者往往白训。对清洗不干净的数据,label_smoothing 可以降低错误标签的负面影响,但治标不治本。
5. 验证与部署:把分类算法变成真正能用的服务
模型训练完不算结束,还要做指标验证和部署。这一章讲两个最实用的动作:用混淆矩阵做验收,以及导出 ONNX 写最小推理服务。
5.1 用混淆矩阵和单类指标做最终验收
只看整体准确率不够,鱼类分类更关心稀有鱼种会不会被漏掉。验证阶段我用 sklearn 直接计算:
from sklearn.metrics import confusion_matrix, classification_report cm = confusion_matrix(y_true, y_pred) report = classification_report( y_true, y_pred, target_names=dataset.classes, )y_true 是验证集真实标签,y_pred 是模型对验证集预测后取 argmax 的结果。classification_report 会给出每个类别的 precision、recall、f1,重点看召回率低于 0.8 的类别。如果某些类别的误判会带来更高的业务成本,可以调整分类阈值:在 softmax 输出上乘一个业务权重再取 argmax,而不是永远用默认的 0.5 概率。
5.2 导出 ONNX 并写一个最小推理服务
训练结束后把 PyTorch 模型导出为 ONNX,部署时就不依赖训练框架了:
model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "fish_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )dynamic_axes 允许推理时 batch 维可变,如果你的部署平台固定 batch 为 1,这部分可以去掉。导出后用 onnxruntime 做推理:
import numpy as np import onnxruntime as ort from PIL import Image ort_session = ort.InferenceSession("fish_model.onnx") input_name = ort_session.get_inputs()[0].name def preprocess(img_path): img = Image.open(img_path).convert("RGB") img = img.resize((256, 256)) img = img.crop((16, 16, 240, 240)) arr = np.array(img).astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) arr = (arr - mean) / std arr = arr.transpose(2, 0, 1)[None, ...] return arr logits = ort_session.run(None, {input_name: preprocess("test_fish.jpg")})[0] pred = int(np.argmax(logits[0]))这段代码里的 resize 和 crop 逻辑必须和训练时的 val_transform 完全一致,包括插值方式。很多人部署时掉精度,查到最后都是这里不一致。另一个建议是给输出加一个“不确定”分支:当 softmax 最大概率低于阈值时输出 unknown,交给人工确认。这个阈值根据验证集上的 precision-recall 曲线来定,不要拍脑袋。
我自己的一个习惯是每次迭代记录数据版本、模型版本和预处理代码版本。如果三个月后精度回退了,你能快速定位是哪一环变了。我的教训是:不要因为某次训练涨了两三个点就把增强、优化器、学习率一起改掉,一次只改一个变量,否则这个黑匣子谁也救不了。希望帮到你。
本文还有配套的精品资源,点击获取