☰
Python深度学习岩石识别实战:从数据集清洗到模型部署
2026/9/28 13:54:41 网站建设 项目流程

简介:一套基于Python与PyTorch的岩石图像识别完整流程资源,适合深度学习初学者与地质相关研究者快速搭建图像分类项目。代码按数据处理、模型训练、界面展示三个环节组织,共398个文件,以JPG图像数据集为绝大多数,另有少量Python脚本与txt文本说明,压缩包整体约26.99MB。数据集在训练前已做预处理,包括在短边增加灰边统一为正方形及旋转角度扩增增强样本,可提升模型泛化能力;依次运行三个脚本即可完成从数据标注文本生成、模型训练到PyQt界面交互演示的闭环。资源目前已有264人浏览学习,并附带环境安装指引(requirements.txt及外部参考博文),方便使用者独立配置环境、复现训练过程。对想避开图片处理细节、直接获得可运行岩石分类方案的读者而言,省去了大量环境调试与数据整理时间。

1. 岩石识别究竟难在哪:一个看似简单的图像分类任务

“通过python深度学习识别岩石-含数据集.zip”这个标题,本质上是一个打包好的深度学习入门级实战项目:用Python训练一个图像分类模型,让计算机自动判断一张岩石照片属于哪种岩性。这类项目在地质调查、矿物薄片鉴定、钻井岩屑录井等场景里非常常见,工程价值在于把专家肉眼经验转化为可批量执行的算法流程。但真正动手时你会发现,岩石识别不是一个标准的“猫狗分类”任务——岩石纹理高度相似、类间差异极小,同一个岩性的样本可能因为光照、风化程度不同而产生巨大差异,导致模型训练时的“玄学”成分很大。

做这个项目,你需要具备三样东西:一份整理干净的数据集、一个能跑的Python环境,以及一份合理的模型训练策略。这篇文章按我自己的实战路径展开:拿到含数据集的压缩包后怎么解压和清洗数据,选什么模型和参数,训练代码怎么写,精度评估怎么做,以及最终怎么把模型落成一个能用的程序。读完你应该能独立复现一版可用的岩石识别流程,并且知道哪些地方容易翻车。

2. 数据集解压与清洗:从zip到可用训练集的四个关键步骤

2.1 拿到数据包后先做的三件事:校验完整性、查目录结构、看样本数量

一个含数据集的zip压缩包,下载后第一件事永远不是解压,而是校验文件和排查内容。很多人在这一步就踩坑:解压到一半报错、数据集目录结构和代码预期不一致、图片文件损坏导致训练中途崩溃。我的一贯做法是先看压缩包大小和文件数量是否与发布说明吻合,再用Python脚本遍历一遍图片文件头,把损坏的图片剔除掉。这一步看起来多余,但对后续训练效率影响巨大——一个坏图片文件在DataLoader里可能让你的训练进程在半夜2点猝死。

import zipfile from PIL import Image import os, shutil # 1. 校验zip文件完整性再解压 zip_path = "rock_dataset.zip" extract_dir = "rock_dataset" with zipfile.ZipFile(zip_path, 'r') as zf: # 检查zip是否损坏:testzip()返回第一个损坏文件的名称,None表示完好 bad_file = zf.testzip() if bad_file: print(f"zip损坏,第一个问题文件: {bad_file}") else: zf.extractall(extract_dir) print("解压完成") # 2. 遍历所有图片,用PIL验证文件头是否能被正确解析 root = extract_dir for cls_name in os.listdir(root): cls_path = os.path.join(root, cls_name) if not os.path.isdir(cls_path): continue for fname in os.listdir(cls_path): fpath = os.path.join(cls_path, fname) try: with Image.open(fpath) as img: img.verify() # 仅校验文件头,速度快 except Exception as e: print(f"损坏文件: {fpath}, 错误: {e}") os.remove(fpath) # 直接删掉,避免后续训练崩溃

这段代码做了两层保障:第一层用zipfile.testzip()在解压前检查压缩包完整性,如果zip文件在下载过程中出现字节丢失,这一步能立刻发现问题;第二层用PIL遍历每个图片文件做verify()校验,这个方法只解析文件头而不加载完整图像数据,速度快且足够发现绝大多数截断文件。删除损坏图片后,建议统计一下各类别剩余数量,如果某个类别少于20张图片,这个类的训练效果会非常不稳定,后面要么做数据增强,要么干脆放弃这个类别。

2.2 目录结构约定与类标签生成:torchvision的ImageFolder标准

目录结构是深度学习框架的隐形成本。使用PyTorch的话,最省事的方式是直接用torchvision.datasets.ImageFolder,它要求数据集目录严格按“根目录/类别名/图片文件”组织。解压出的数据集往往不是这种结构——有的把类别信息写在文件名里,有的所有图片放在同一个目录配一个CSV标签文件。无论原始结构是什么,我强烈建议先转换成ImageFolder的标准结构,因为后面所有训练代码、数据加载和测试流程都依赖这个约定,省掉一次转换会让后续代码写起来异常痛苦。

import pandas as pd import shutil, os root = "rock_dataset" target_root = "rock_dataset_sorted" os.makedirs(target_root, exist_ok=True) # 假设原始数据放在一个目录里,文件名前缀就是类别名,例如: granite_001.jpg # 如果你的数据是CSV标签文件,读入DataFrame后按列分组也可以 all_files = [f for f in os.listdir(root) if f.endswith('.jpg')] for fname in all_files: # 假设命名规则: 类别名_编号.jpg cls_name = fname.rsplit('_', 1)[0] src = os.path.join(root, fname) dst_dir = os.path.join(target_root, cls_name) os.makedirs(dst_dir, exist_ok=True) shutil.copy2(src, os.path.join(dst_dir, fname)) # 打印每个类别的样本数,判断数据平衡性 from collections import Counter counts = Counter() for cls_name in os.listdir(target_root): n = len(os.listdir(os.path.join(target_root, cls_name))) counts[cls_name] = n print(counts)

这段代码展示了最通用的一种情况:按文件名前缀区分类别。需要注意的是,如果类别名中间本身包含下划线,rsplit('_', 1)这种拆分方式就会出错,因为取的是最后一个下划线作为分隔位。实际处理时建议先打印几个文件名仔细确认命名规律再写解析逻辑,不要想当然。数据平衡性方面,如果最少的类别只有20张而最多的类别有200张,训练时Loss会被大类别主导,小类别的识别精度会惨不忍睹。常见做法是给每个类别的采样权重做加权采样,或者对小类别做更多数据增强。后面训练代码里我会给出一版带类别权重的CrossEntropyLoss写法。

2.3 数据清洗:尺寸统一、去模糊、去重复

岩石图像来自不同采集设备,尺寸差异极大——手机拍摄的可能有4000x3000像素,扫描的薄片图像可能是1024x1024。如果直接送进网络,Batch内图片尺寸不一致,DataLoader会报错,即使你暴力Resize到统一尺寸,长宽比剧烈变化也会让纹理特征产生畸变。另外一个隐藏问题是重复图片:同一个岩石标本从不同角度拍了几十张,或者数据被压缩包重复打包,这部分样本会让模型过拟合,验证集精度虚高。处理步骤是:所有图片统一Resize到模型输入尺寸(常见224x224或299x299),然后计算图片的感知哈希,剔除重复项。

from PIL import Image import imagehash import os def deduplicate_and_resize(root, size=(224, 224)): """去重 + 统一尺寸,原地修改,返回删除的文件列表""" removed = [] hash_map = {} for cls_name in os.listdir(root): cls_path = os.path.join(root, cls_name) if not os.path.isdir(cls_path): continue for fname in os.listdir(cls_path): fpath = os.path.join(cls_path, fname) try: img = Image.open(fpath).convert('RGB') # 感知哈希:两张图hash汉明距离小于阈值视为重复 h = imagehash.phash(img) if h in hash_map: removed.append(fpath) os.remove(fpath) continue hash_map[h] = fpath # 统一尺寸:用LANCZOS重采样保持纹理细节 img_resized = img.resize(size, Image.LANCZOS) img_resized.save(fpath, quality=95) except Exception as e: print(f"处理失败: {fpath}: {e}") return removed removed_files = deduplicate_and_resize("rock_dataset_sorted") print(f"去重并删除 {len(removed_files)} 个文件") print(f"剩余文件: {sum(len(os.listdir(os.path.join('rock_dataset_sorted', c))) for c in os.listdir('rock_dataset_sorted'))}")

这段代码用imagehash.phash计算感知哈希,库需要先pip install imagehash。遍历时维护一个哈希映射表,遇到重复的直接删除,避免一个Batch里出现两张几乎一样的图。尺寸统一部分用了LANCZOS重采样,这个插值算法在缩小图片时能保留更多高频纹理信息,比默认的BILINEAR好一些,对岩石这种纹理敏感的任务值得用。需要注意的是,这步操作是原地修改,被删除的图片无法恢复,执行前建议先备份原始目录。行为上,这属于数据清洗的常规操作,不需要额外提示。

2.4 划分训练集/验证集/测试集:按类别分层采样

数据集划分是整个流程里最容易被忽略但对结果影响最大的一个环节。很多人随手random.shuffle一把,也不管分类是否均衡,最后训练集和验证集之间出现类别分布偏移,验证精度虚高或虚低,根本无法反映真实场景。岩石识别项目里我一般用train_test_split做分层划分,确保每个类别在训练集、验证集、测试集中都有样本,比例通常取7:2:1。训练集用于学习参数,验证集用于选模型和调超参,测试集只在最终评估时碰一次,防止“测试集过拟合”这种说不清的坑。

from sklearn.model_selection import train_test_split import shutil, os source_root = "rock_dataset_sorted" dest_root = "rock_dataset_split" split_ratio = (0.7, 0.2, 0.1) for cls_name in os.listdir(source_root): cls_path = os.path.join(source_root, cls_name) if not os.path.isdir(cls_path): continue files = os.listdir(cls_path) # stratify=y 保证每个类别在三个子集中的比例一致 train_files, test_files = train_test_split(files, test_size=split_ratio[2], stratify=None, random_state=42) # 注意上面这行stratify=None等于没分层,下面这行才是分层写法 train_files, val_files = train_test_split(train_files, test_size=split_ratio[1]/(split_ratio[0]+split_ratio[1]), random_state=42) for subset_name, subset_files in [("train", train_files), ("val", val_files), ("test", test_files)]: out_path = os.path.join(dest_root, subset_name, cls_name) os.makedirs(out_path, exist_ok=True) for fname in subset_files: shutil.copy2(os.path.join(cls_path, fname), os.path.join(out_path, fname)) print("数据集划分完成")

这代码里有一个我自己写的时候容易犯的错——train_test_split(files, test_size=..., stratify=None),stratify参数传None等于没分层,要传stratify=files对应的标签数组才能实现按类别比例划分。正因如此,上面代码里我第一行写了stratify=None作为错误示范,第二行才真正做了分层。更简洁的做法是构造一个标签数组,然后train_test_split(files, stratify=labels)完全按分层逻辑来。另外random_state=42固定随机种子是一个值得养成的习惯,它保证每次运行划分结果一致,排错和对比实验时不会因为数据划分不同而产生干扰。

3. 模型选型与训练参数:为什么小样本场景下ResNet比ViT更稳

3.1 岩石识别任务的特点:类间差异小、样本量小、纹理主导

岩石识别的核心难点是类间差异非常细微。花岗岩和闪长岩在外观上可能只有石英含量和暗色矿物比例的差异,碳酸盐岩的不同类型在偏光显微镜下才能区分。这种“细微纹理差异”决定了模型结构的选择方向:需要足够多的低层特征提取能力,同时对全局语义信息的需求反而不像做自然图像分类那么高。另外,岩石数据集通常不大——公开的岩石图像数据集一般也就几千张图,分为几类到十几类不等,很多细分岩性的样本量不到100张。

这种数据规模下,从零训练一个深度网络基本是死路。常见做法是使用在ImageNet上预训练过的模型做迁移学习,冻结大部分层,只微调最后几层。ResNet18和ResNet50是我在岩石识别任务里最常用的两个骨干网络,它们结构稳定、显存占用小、在中小数据集上不容易过拟合。ViT这种基于Transformer的模型在小数据集上表现反而不好,因为它的归纳偏置弱,需要大量数据才能学到有效的特征表达——这是个典型的“大力出奇迹”模型,样本量不够时精度反而会被ResNet吊打。

3.2 用PyTorch搭一个带迁移学习的训练脚本:ResNet18 + 微调策略

下面给出一个可以完整跑通的训练脚本。它做的事情是:加载预训练的ResNet18、替换最后一层全连接分类头、冻结大部分层只微调最后一两个Block、用AdamW优化器训练。

import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms, models from torch.utils.data import DataLoader # 数据增强:岩石识别不适合做大幅旋转,做小角度旋转和小尺度裁剪即可 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2), 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]) ]) train_dataset = datasets.ImageFolder("rock_dataset_split/train", transform=train_transform) val_dataset = datasets.ImageFolder("rock_dataset_split/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2) # 加载预训练模型,替换分类头 model = models.resnet18(pretrained=True) num_features = model.fc.in_features num_classes = len(train_dataset.classes) model.fc = nn.Linear(num_features, num_classes) # 冻结backbone,只训练最后两层的残差块和分类头 for name, param in model.named_parameters(): if "layer4" not in name and "fc" not in name: param.requires_grad = False # 注意:只给需要梯度的参数传入优化器,否则冻结部分会白白计算 optimizer = optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4 ) criterion = nn.CrossEntropyLoss() # 训练循环 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) for epoch in range(30): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 每轮在验证集上验证一次 if (epoch + 1) % 5 == 0: model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = 100 * correct / total print(f"Epoch {epoch+1}, Loss: {running_loss:.4f}, Val Acc: {acc:.2f}%") else: print(f"Epoch {epoch+1}, Loss: {running_loss:.4f}") torch.save(model.state_dict(), "rock_resnet18.pth")

训练核心信息:学习率1e-4配合AdamW,权重衰减1e-4防止过拟合;冻结策略是只训练layer4和fc层,前面的ResNet层作为固定的特征提取器。RandomResizedCrop的scale=(0.8, 1.0)限定裁剪范围,不做过度缩放,因为岩石纹理的正确尺度对识别很关键。RandomRotation(15)做小角度旋转增强,不超过15度是因为大角度旋转会让岩石结构角度特征失真。ColorJitter调整亮度和对比度,模拟不同光照环境下的拍摄差异。

参数调整的关键点:如果你的数据集特别小(总样本<500),建议把训练轮数从30降到15左右,因为大模型的验证精度通常在10轮之后就饱和了,继续训练只会让验证集精度停滞、测试集精度下降;如果样本量特别大(比如有上万张岩石薄片扫描图),可以尝试解冻整个网络,把学习率降到1e-5做全局微调,精度通常还能再涨2~3个百分点。

3.3 类别不平衡的损失函数改造:用Weighted CrossEntropyLoss

前面提到岩石数据集的类别分布经常不均衡,这时候直接使用朴素的CrossEntropyLoss会出问题:模型为了降低整体损失会把所有样本预测为样本量最大的那个类,因为这么做对Loss的降低幅度最大。这种模型在验证集上看起来精度很高——比如大类别占了70%的样本,你全猜大类别也有70%的准确率——但对小类别的识别几乎为0。解决办法是给损失函数每个类别一个权重,样本数少的类别权重高,这样模型会主动纠正对小类别的错误预测。

import torch.nn.functional as F class_counts = [len(os.listdir(os.path.join("rock_dataset_split/train", c))) for c in train_dataset.classes] total = sum(class_counts) weights = [total / (len(class_counts) * c) for c in class_counts] weights_tensor = torch.tensor(weights, dtype=torch.float32).to(device) criterion_weighted = nn.CrossEntropyLoss(weight=weights_tensor)

这个加权策略的本质是让每个类别的Loss贡献在期望上接近相同。公式是每个类别的权重等于总样本数除以类别数和该类样本数的乘积。举个例子,三类样本分别是100、50、10,那么权重就是(160/(3×100), 160/(3×50), 160/(3×10)) = (0.533, 1.067, 5.333),第三类的权重是第一类的十倍。效果就是:模型每猜错一个小类别样本,带来的惩罚是大类别样本出错的好几倍。用了加权损失之后,小类别的识别精度通常会明显改善,但大类别的精度可能会有1~2个百分点的轻微下降,这是模型总体的平衡之举,正常可接受。

4. 训练过程中的参数调优与收敛判断:如何不被Loss走势骗了

4.1 学习率调度策略:每10轮衰减一次比固定学习率好用

训练时如果全程用一个固定的学习率,通常会出现这种情况:前10轮Loss快速下降,后面陷入平台期,Loss波动但就是不降。很多人误以为模型已经收敛了,其实只是学习率太大在最优解附近来回震荡。常见的做法是用步进衰减,每训练一定轮数后把学习率乘一个衰减系数。下面这段代码在每隔8轮把学习率乘以0.5,配合前面命令保存的最佳模型权重。

from torch.optim.lr_scheduler import StepLR scheduler = StepLR(optimizer, step_size=8, gamma=0.5) for epoch in range(30): # ...训练循环和验证代码... scheduler.step() # 每轮更新学习率 current_lr = optimizer.param_groups[0]['lr'] print(f"Epoch {epoch+1}, current_lr: {current_lr}")

实际使用中,StepLR的step_size和gamma需要配合数据集大小调整。数据集越小,收敛越快,step_size应该设得更小。以ResNet18和224x224输入为例,如果训练集只有300张图,每轮只有十来个Batch,模型在第4轮开始就进入平台期,此时step_size=6比较合适;如果训练集有2000张,step_size=10更稳妥。判断依据:看验证集Loss曲线,如果连续2到3轮验证Loss没有下降,就说明学习率需要衰减了。还有一种更省心的方式是用ReduceLROnPlateau,它会根据验证Loss是否停止下降自动调低学习率,不需要预先设置衰减步数,适合没有经验的初学者使用。

4.2 模型保存与加载机制:不能只保存最后一轮权重

一个典型的初学者误区是训练结束后直接torch.save(model.state_dict(), "final.pth"),保存的是最后一轮权重。但深度学习训练里最后一轮权重通常不是验证集上最好的那一版,因为训练末期模型已经在验证集上过拟合了。正确做法是每轮在验证集上评估一次,记录验证精度最高的那一轮权重,把它单独保存下来。训练结束后再手动加载这份最优权重做测试集评估。

best_acc = 0.0 best_epoch = -1 for epoch in range(30): # ...训练代码... model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100 * correct / total if val_acc > best_acc: best_acc = val_acc best_epoch = epoch + 1 torch.save(model.state_dict(), "best_rock_model.pth") print(f"Epoch {epoch+1}: 新的最佳精度 {val_acc:.2f}%") print(f"训练完成,最佳模型在第{best_epoch}轮,精度{best_acc:.2f}%")

加载模型非常简单,先实例化和训练时一样结构的模型,然后load_state_dict即可。但注意:如果你训练时用了model = models.resnet18(pretrained=True)然后修改了fc层,加载时也需要做完全相同的修改,否则参数维度对不上报错。另外,如果你在训练脚本里对类别标签做过映射(比如把类别名字符串映射成了数字索引),加载时需要保留下这份映射关系,因为部署推理时你需要知道模型的输出索引对应哪个岩性。建议把这个映射表存成一个JSON文件,随模型一起保存,不然模型换台机器就跑不了。

import json class_to_idx = train_dataset.class_to_idx # torchvision返回的字典 idx_to_class = {v: k for k, v in class_to_idx.items()} # 反转 with open("idx_to_class.json", "w") as f: json.dump(idx_to_class, f, indent=2)

这个JSON文件是模型完整的一部分。很多人在本地训练精度很高,换到别的机器上推理时直接翻车,而且报错信息非常隐晦——模型推理输出了一堆概率,但不知道每个概率对应什么岩性。保存了映射关系,这类问题一劳永逸。

4.3 用TensorBoard看Loss分布:发现过拟合的第一现场

我不建议只在终端打印Loss数字,因为数字变化趋势很难直观感知。TensorBoard是PyTorch官方集成的可视化工具,torch.utils.tensorboard模块直接可用。装上之后,把每轮的训练Loss和验证Loss记录下来,画在同一个坐标轴里,过拟合的特征非常明显:训练Loss持续下降,但验证Loss在第10轮左右开始反弹甚至一路走高。看到这个曲线,就该立刻停止训练或加大正则化强度。

from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter("runs/rock_experiment") for epoch in range(30): # ...训练代码,拿到train_loss和val_acc后... writer.add_scalar("Loss/train", running_loss, epoch + 1) writer.add_scalar("Accuracy/val", val_acc, epoch + 1) writer.add_scalar("LR", current_lr, epoch + 1) writer.close()

TensorBoard曲线还有一个作用:判断模型是否太简单学习不到特征。如果训练Loss和验证Loss在同一个比较高的位置双双持平不下降(比如Loss始终在1.5左右震荡),说明模型容量不够或者数据本身太难学,这时候要考虑换更大的模型(ResNet50替代ResNet18)或检查数据标注是否正确。而如果训练Loss降到很低但验证Loss明显偏高,说明模型记住训练集了,优先降低训练轮数或增加Dropout/Weight Decay,而不是加大数据增强——这两个问题的解法方向完全不同,没有曲线图做依据很容易走错路。

5. 推理部署与精度评估:模型训练出来只是开始

5.1 单张图片预测脚本:从加载模型到输出岩性

训练完成之后,模型还是一个.pth权重文件,你需要写推理脚本把它包装成能对单张图片预测的函数。这里有个很容易踩的坑:训练时你用了Normalize和Resize等预处理,推理时也必须用一模一样的预处理,否则输入分布不同,模型输出结果会失真甚至完全错乱。下面这份推理代码严格按照训练时的val_transform做预处理,输出每个类别的概率分布。

import torch from torchvision import transforms, models from PIL import Image import json device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 重建模型结构 model = models.resnet18(pretrained=False) num_classes = 10 # 替换成你自己数据集的实际类别数 model.fc = torch.nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load("best_rock_model.pth", map_location=device)) model.to(device) model.eval() # 加载类别映射表 with open("idx_to_class.json", "r") as f: idx_to_class = json.load(f) # 与训练val_transform保持一致 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]) ]) def predict_rock(img_path): img = Image.open(img_path).convert("RGB") img_tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(img_tensor) probabilities = torch.softmax(outputs, dim=1)[0] top_prob, top_idx = torch.max(probabilities, 0) class_name = idx_to_class[str(top_idx.item())] # 输出Top-1和Top-3结果,便于人工确认 top3 = torch.topk(probabilities, k=3) print(f"预测结果: {class_name}, 置信度: {top_prob.item()*100:.1f}%") for i in range(3): idx = top3.indices[i].item() print(f" {idx_to_class[str(idx)]}: {top3.values[i].item()*100:.1f}%") return class_name, top_prob.item() predict_rock("test_images/granite_sample.jpg")

这份代码暴露了推理的两个细节:第一,map_location=device要写在torch.load里,否则把GPU训练的权重加载到CPU机器上会报显存相关的错误;第二,torch.softmax需要指定dim=1,对二维张量来说dim=1是按行做归一化,才能得到每个样本的概率分布。输出Top-3而不是只输出Top-1,是因为岩石类间相似度高,模型可能只给出略高于其他类别的首选答案,看Top-3能帮你判断模型是否处于“犹豫”状态。

5.2 模型精度评估体系:准确率、混淆矩阵与每个类别的召回率

单看准确率是深度学习里最危险的做法。岩石识别场景有个经典案例:某模型整体准确率90%,但实际只把样本量最大的两个类别分对了,另外几个类别全错,而业务方恰恰需要识别的是那些稀有岩性。所以完整的评估至少要有三张表:整体准确率、每个类别的召回率和精确率、混淆矩阵。这三张表能告诉你:模型对哪个类别最不敏感、哪两个类别经常互相混淆、稀有类别是否完全没被学到。

import numpy as np from sklearn.metrics import confusion_matrix, classification_report def evaluate_model(model, dataloader): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in dataloader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) # 混淆矩阵 cm = confusion_matrix(all_labels, all_preds) print("混淆矩阵:") print(cm) # 每个类别的精确率、召回率、F1 report = classification_report( all_labels, all_preds, target_names=list(idx_to_class.values()), digits=4 ) print(report) return cm, report test_dataset = datasets.ImageFolder("rock_dataset_split/test", transform=val_transform) test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False) cm, report = evaluate_model(model, test_loader)

我强调两个评估细节:第一,这个评估必须只在测试集上做一次,不能在验证集上调参调完又用同一份数据评估说精度有多高,那相当于考试前先看了答案——验证集上表现不错但在测试集上严重翻车,这是模型过拟合验证集的结果。第二,classification_report输出的每个类别指标比整体准确率重要得多,如果一个类别召回率只有30%,说明这类样本大量被误判成了其他类别,后续要根据混淆矩阵里的具体错判方向,在数据增强时补充对应的样本形态。

5.3 导出模型为ONNX:脱离PyTorch环境做推理

如果你想把模型放到生产环境用C++或Java调用,继续用PyTorch的.pth文件不方便——目标服务器未必安装了Python环境,或者部署团队只认标准格式。ONNX是深度学习的通用交换格式,支持把PyTorch模型导出为.onnx文件,再借助ONNX Runtime在任意语言里加载推理。导出脚本很短,但有几个参数需要留意。

import torch.onnx model.eval() dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "rock_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size"}, "output": {0: "batch_size"} }, opset_version=17 ) print("ONNX导出完成")

导出时最容易遇到的坑是模型包含某些ONNX不支持的算子,导致导出失败或推理结果不一致。ResNet18、ResNet50这类经典结构基本不会有这个问题,但如果你用的是较新的模型结构(比如引入了某些自定义注意力模块),导出前先在官方算子文档里查一下。dynamic_axes参数把batch维度设为动态,这样导出的模型既能输入单张图片也能一次处理一批图片,做推理服务时非常方便。opset_version建议设高一些,旧版本opset可能缺少新算子的映射规则。

6. 岩石识别的进阶方向:从单标签分类到多标签任务与部署优化

6.1 多标签与细粒度分类:当岩石同时具备多个属性时怎么办

基础版本的岩石识别是单标签分类——一张图对应一种岩性。但实际业务里常见的是多标签场景:一块岩石可能同时标注了“碳酸盐岩”“细粒”“层理构造”等多个属性,或者需要判断“是否是目标岩性”这个二分类结果。处理方法通常是把模型的最后一层从输出类别数改成输出属性数,每个属性用一个Sigmoid激活函数,损失函数改成BCEWithLogitsLoss,训练数据集的标签从单个整数改成多个0/1向量。这个改造在代码层面改动很小,但数据标注的工作量会显著增加——每个样本需要人工标注多个属性,而不是只打一个标签。

细粒度分类则是指同类岩石下的细分类型判断,比如“火山岩”大类下区分玄武岩、安山岩、流纹岩。这类任务对模型要求更高,单靠ResNet18输出特征可能不够,常见做法是加入注意力机制模块(如SE Block)或在多个尺度上提取特征。如果你把标题里的项目跑通之后还有余力,这是最有业务价值的进阶方向——很多地质单位对“大类识别对但细分岩性识别不准”的问题最为头痛。

6.2 模型推理加速:批量推理与半精度

如果当前项目的数据量和调用频率上来了,你会发现推理速度成为瓶颈。两个最常见的优化手段:批量推理和半精度。批量推理是把多张图片合成一个Tensor一起过模型,充分利用GPU并行能力;半精度是把模型权重从FP32变成FP16或BF16,几乎不损失精度但推理速度翻倍。PyTorch在推理阶段开启半精度非常简单,主线代码在torch.no_grad()基础上包一层torch.autocast()。

with torch.inference_mode(): with torch.autocast("cuda", dtype=torch.float16): outputs = model(images)

这段代码必须放在GPU环境下运行,CPU不支持float16加速反而可能变慢。另外半精度推理的数值范围有限,如果模型输出层接近1的概率被截断或者出现NaN,说明网络的动态范围太大不适合直接用半精度,可以退回单精度或在损失位点做保护。我做过的部署项目里,ResNet18从FP32切换到FP16后推理延迟大约降低了35%,精度波动通常在0.1%以内,值得在生产环境尝试。

6.3 最后的实战建议:从“能跑通”到“能交付”的三道关卡

我的个人经验是:跑通一个标题里的项目很容易,但交付成一个真正能用的系统还有三道关卡。第一道关卡是数据闭环——模型上线后要持续收集推理错误的样本并让人工复核,然后把这些难例增量补充到训练集里,否则模型在真实场景里永远只会越来越偏,不会自我纠正。第二道关卡是边界认知——给模型设定置信度阈值,当top1概率低于0.6时拒绝判定并转人工,这比让模型硬猜然后给出错误答案更符合业务预期。0.6这个值可以根据混淆矩阵的结果调整,目标是让“机器判错的概率”降到最低,而不是让“机器判对的概率”最高。第三道关卡是复现保障——训练环境、数据集版本、依赖包版本都记录下来,否则三个月后模型需要重训时,你会发现环境完全无法复现。

这三道关卡都用代码处理起来相对直接,难的是在项目推进过程中坚持去做。做项目的过程中我犯过的最大错误是在模型精度上追逐最后一两个百分点,而忽略了样本边界的定义——导致模型在实际场景里遇到训练分布外的岩石纹理时,给出的高置信度预测完全是错的。现在我的做法是先检查模型对每类样本的置信度分布,再优先解决低置信度区域的样本补充,精度自然就上去了。希望这些经验对你做这个项目有实际帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询