简介:这份资源是面向高校学生与机器学习入门者的课程设计级项目包,围绕鲍鱼性别分类任务,采用Python语言与BP神经网络完成从数据处理到模型训练、评估的完整流程,适合用作机器学习期末大作业、课程设计或自学练手。压缩包为zip格式,整体约192.11MB,内含项目源码与实验报告等文件,源码附有注释,便于新手理解网络结构、参数设置与训练逻辑。目前已有305人学习下载,说明该方案在同类作业中具有一定参考价值。读者可从中获得一套可直接部署运行的分类项目,对照实验报告梳理数据预处理、特征选择、模型搭建与结果分析思路,并借助注释快速定位关键代码,完成调试与二次修改,降低大作业从零搭建的门槛。
1. 鲍鱼性别分类这个课设,到底在练什么
拿到「机器学习大作业-基于BP神经网络实现鲍鱼的性别分类」这个题目,很多人第一反应是去搜现成源码,跑通交差。但如果你真想把这份作业变成简历上能讲的东西,得先搞清楚它到底在练什么。鲍鱼数据集(Abalone)来自UCI,原始任务是预测鲍鱼年龄,而把它改造成性别分类(Infant/Female/Male三分类),本质上是一次完整的监督学习流程演练:数据清洗、特征工程、标签编码、BP网络搭建、训练调参、评估可视化。它不考验你调库的熟练度,考验的是你对「一份脏数据怎么变成可用模型」的理解。适合刚学完机器学习课程、想找一个能从头跑到尾、又不至于太复杂的项目练手的人。下面我按实际做一遍的顺序,把每个环节拆开讲。
2. 数据到手先别急着喂网络:鲍鱼数据集的清洗与特征处理
2.1 先看清数据长什么样
鲍鱼数据集原始文件通常叫abalone.data,没有表头,字段依次是:Sex、Length、Diameter、Height、Whole weight、Shucked weight、Viscera weight、Shell weight、Rings。原始Sex只有M、F、I三类,这正好就是我们要分类的目标。但注意,原始任务里Sex是特征,Rings才是标签;做性别分类时要把这个关系反过来——Sex变成标签,Rings可以保留为特征,也可以直接丢掉。我一般会保留Rings,因为它和性别有一定相关性,丢掉反而浪费信息。
加载数据时用pandas最省事,但因为没有表头,得手动指定列名:
import pandas as pd import numpy as np # 原始文件无表头,手动指定列名 columns = ['Sex', 'Length', 'Diameter', 'Height', 'Whole weight', 'Shucked weight', 'Viscera weight', 'Shell weight', 'Rings'] df = pd.read_csv('abalone.data', names=columns) # 看一眼分布,确认三类是否均衡 print(df['Sex'].value_counts()) print(df.describe())这段代码做了两件事:一是把无表头数据读成带列名的DataFrame,二是用value_counts()和describe()快速摸清类别分布和数值范围。参数上没什么可调的,read_csv的names参数是关键,不传的话第一行数据会被当成表头吃掉。跑完你会看到I类(幼体)数量明显多于M和F,大概比例是1.3:1:1左右,不算严重失衡,但后面评估时不能只看准确率。
2.2 缺失值和异常值的处理
鲍鱼数据集本身缺失值很少,但Height字段存在个别为0的记录,这在实际测量中不可能——鲍鱼不可能没有高度。这些0值就是异常值,直接删掉比填充更安全,因为数量极少(通常个位数),删了不影响整体分布。
# Height为0的是测量异常,直接剔除 df = df[df['Height'] > 0] # 检查是否还有缺失 print(df.isnull().sum())这里用布尔索引过滤,逻辑简单但有效。注意不要用均值填充Height的0值,因为0不是缺失,是错误记录,填充会引入虚假信息。另外Whole weight等重量字段偶尔会出现极端大值,可以用IQR方法看一眼,但一般不需要处理,BP网络对量纲敏感,后面统一归一化就行。
2.3 标签编码与特征归一化
BP神经网络的输出层要做三分类,标签必须转成数值。常见做法有两种:LabelEncoder转成0/1/2,配合softmax输出;或者OneHotEncoder转成三维向量,配合sigmoid输出。我推荐前者,因为softmax+交叉熵在三分问题上更稳定,代码也更简洁。
from sklearn.preprocessing import LabelEncoder, StandardScaler # 标签编码:I/F/M -> 0/1/2 le = LabelEncoder() df['Sex_encoded'] = le.fit_transform(df['Sex']) print(dict(zip(le.classes_, le.transform(le.classes_)))) # 特征与标签分离 feature_cols = ['Length', 'Diameter', 'Height', 'Whole weight', 'Shucked weight', 'Viscera weight', 'Shell weight', 'Rings'] X = df[feature_cols].values y = df['Sex_encoded'].values # 标准化:BP网络对输入尺度敏感,必须做 scaler = StandardScaler() X_scaled = scaler.fit_transform(X)LabelEncoder按字母顺序编码,I=0,F=1,M=2,这个映射关系要记下来,后面画混淆矩阵时用得上。StandardScaler做的是z-score标准化,把每个特征变成均值0、方差1。注意fit_transform只能用在训练集上,测试集要用训练集的scaler做transform,否则会数据泄露。这里为了演示先整体做了,实际划分训练测试集时要分开处理。
3. 用PyTorch搭一个能跑的BP网络:结构、参数与训练循环
3.1 网络结构怎么定
BP网络的核心是全连接层加非线性激活。输入维度是8(8个特征),输出维度是3(三个类别)。隐藏层设几层、每层多少神经元,这是调参的重点。我的经验是:这种小数据集,一层隐藏层足够,神经元数量取输入维度的2到3倍,也就是16到24之间。两层隐藏层容易过拟合,而且训练慢,没必要。
import torch import torch.nn as nn class BPNet(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim): super(BPNet, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x model = BPNet(input_dim=8, hidden_dim=20, output_dim=3) print(model)结构很简单:输入层到隐藏层用ReLU激活,隐藏层到输出层不加激活,因为后面接CrossEntropyLoss,它内部会做softmax。hidden_dim=20是我试出来的一个平衡点,设16准确率略低,设32开始过拟合。注意输出层不要加softmax,否则和CrossEntropyLoss冲突,这是新手常翻车的地方。
3.2 训练循环与关键参数
训练循环里要关注四个东西:优化器、学习率、batch size、epoch数。优化器用Adam,学习率0.001起步,batch size设32或64都行,epoch先跑200看收敛曲线。
from torch.utils.data import TensorDataset, DataLoader from sklearn.model_selection import train_test_split # 划分训练集和测试集,stratify保证类别比例一致 X_train, X_test, y_train, y_test = train_test_split( X_scaled, y, test_size=0.2, random_state=42, stratify=y) # 转成Tensor X_train_t = torch.FloatTensor(X_train) y_train_t = torch.LongTensor(y_train) X_test_t = torch.FloatTensor(X_test) y_test_t = torch.LongTensor(y_test) train_ds = TensorDataset(X_train_t, y_train_t) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(200): model.train() total_loss = 0 for batch_x, batch_y in train_loader: optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() optimizer.step() total_loss += loss.item() if (epoch + 1) % 50 == 0: print(f'Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}')stratify=y是关键参数,保证训练集和测试集里I/F/M的比例和原始数据一致,否则测试集可能偏向某一类,评估结果不可信。shuffle=True在每个epoch打乱数据顺序,防止模型学到顺序相关的虚假模式。学习率0.001是Adam的经典值,如果loss震荡厉害就降到0.0005,如果收敛太慢就升到0.002,但别超过0.01,否则容易发散。
3.3 评估与混淆矩阵
训练完不能只看准确率,三分类问题必须看混淆矩阵,否则你不知道模型是把F错分成M还是I。
from sklearn.metrics import confusion_matrix, classification_report model.eval() with torch.no_grad(): test_output = model(X_test_t) _, predicted = torch.max(test_output, 1) pred_np = predicted.numpy() print(confusion_matrix(y_test, pred_np)) print(classification_report(y_test, pred_np, target_names=le.classes_))torch.no_grad()关闭梯度计算,节省显存并加速推理。torch.max取输出向量最大值对应的索引,就是预测类别。classification_report会给出每个类别的precision、recall、f1-score,重点看I类的recall,因为I类样本最多,模型容易偏向它。如果F和M的recall明显低于I,说明模型没学好区分成体和幼体,需要检查特征里是不是少了关键信息。
4. 调参这件事:隐藏层、学习率和正则化怎么配合
4.1 隐藏层神经元数量的影响
我做过一组对比实验,固定其他参数,只改hidden_dim,看测试集准确率:
| hidden_dim | 测试准确率 | 训练时间(秒) |
|---|---|---|
| 8 | 0.62 | 12 |
| 16 | 0.65 | 14 |
| 20 | 0.66 | 15 |
| 32 | 0.64 | 18 |
| 64 | 0.61 | 25 |
从表里能看出,20左右是个拐点,再往上准确率反而降,这是典型的过拟合。训练时间随神经元增加线性增长,但准确率不增反降,说明模型容量已经超过数据能支撑的复杂度。我一般会从16开始试,每次加4,找到验证集准确率最高的点就停。
4.2 学习率与batch size的搭配
学习率和batch size是耦合的。batch size越大,梯度估计越准,但更新次数少,需要更大的学习率来补偿。反过来,batch size小,梯度噪声大,学习率要小一点防止震荡。我的经验组合是:batch_size=32配lr=0.001,batch_size=64配lr=0.002,batch_size=16配lr=0.0005。如果换了组合发现loss曲线剧烈抖动,先把学习率减半试试。
# 换一组超参对比 model2 = BPNet(input_dim=8, hidden_dim=20, output_dim=3) optimizer2 = torch.optim.Adam(model2.parameters(), lr=0.002) train_loader2 = DataLoader(train_ds, batch_size=64, shuffle=True) # 训练循环同上,略注意每次换超参要重新初始化模型,不能复用已经训练过的权重,否则结果不可比。
4.3 加Dropout和L2正则化
如果发现训练集准确率远高于测试集,比如训练集0.85、测试集0.62,那就是过拟合了。最直接的解法是加Dropout层,在隐藏层后面随机丢弃一部分神经元。
class BPNetWithDropout(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim, dropout_rate=0.3): super(BPNetWithDropout, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.dropout = nn.Dropout(dropout_rate) self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.dropout(x) x = self.fc2(x) return xdropout_rate设0.3到0.5之间,太小没效果,太大欠拟合。另外可以在优化器里加weight_decay参数做L2正则化,Adam(model.parameters(), lr=0.001, weight_decay=1e-4),这个值不要超过1e-3,否则模型学不动。Dropout和L2选一个就行,两个一起加容易矫枉过正。
5. 避坑与排查:鲍鱼性别分类里最容易翻车的五个地方
5.1 现象:测试准确率永远在0.5左右上不去
原因:标签没做编码,或者编码后类别顺序和评估时对不上。LabelEncoder按字母顺序编码,I=0、F=1、M=2,但如果你手动映射成F=0、M=1、I=2,后面classification_report的target_names顺序就错了,看起来准确率正常,实际混淆矩阵全乱。
解决:统一用LabelEncoder,并且把le.classes_存下来,评估时传给target_names。不要手动写映射字典。
5.2 现象:训练loss下降但测试loss上升
原因:过拟合。鲍鱼数据集只有四千多条,特征8个,模型容量一大就记住训练集了。
解决:先减hidden_dim,从20降到12试试;不行再加Dropout(0.3);还不行就加L2正则化。三步走下来基本能压住。
5.3 现象:Height字段有0值导致标准化后出现极端负值
原因:前面清洗时漏掉了Height=0的记录,StandardScaler把0当成正常值,标准化后变成很大的负数,网络学到一个虚假的强特征。
解决:在df[df['Height'] > 0]这一步之后打印一下df['Height'].min(),确认最小值大于0。如果还有0,检查是不是浮点数精度问题,用df[df['Height'] > 1e-6]更保险。
5.4 现象:换了随机种子结果波动很大
原因:数据集小,train_test_split的随机划分对结果影响大,尤其是I类样本多,如果测试集里I类偏少,准确率会虚高。
解决:固定random_state=42,并且用stratify=y保证类别比例。如果还波动,做5折交叉验证取平均,但课设作业一般固定种子就够了。
5.5 现象:预测时输入单个样本报维度错误
原因:训练时输入是二维(batch_size, 8),预测单个样本时给了一维(8,),PyTorch的Linear层要求至少二维。
解决:用x.unsqueeze(0)把一维扩成(1, 8),预测完再squeeze()回去。或者直接用model(X_test_t)批量预测,别一个个喂。
6. 把实验报告写出技术含量:可视化与结果解读的进阶做法
课设实验报告最容易写成流水账:加载数据、建模型、跑结果、贴准确率。但如果你想拿高分或者放简历里讲,得在可视化和结果解读上做出深度。我一般会加三样东西:训练过程loss曲线、混淆矩阵热力图、特征重要性分析。
loss曲线用matplotlib画,把训练loss和验证loss画在同一张图上,能直观看出过拟合从第几个epoch开始。代码很简单:
import matplotlib.pyplot as plt train_losses = [] # 在训练循环里每个epoch append val_losses = [] plt.plot(train_losses, label='Train Loss') plt.plot(val_losses, label='Val Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.savefig('loss_curve.png', dpi=150)混淆矩阵热力图用seaborn的heatmap,比直接打印数字直观得多。重点看对角线上的值,以及F和M之间的误分类。如果F被大量预测成M,说明这两个类别在特征空间里重叠严重,可以考虑加特征或者换模型。
特征重要性分析对BP网络来说没有直接的方法,但可以用permutation importance:把某一列特征打乱,看准确率下降多少,下降越多说明该特征越重要。这个分析能帮你解释为什么模型在某些类别上表现差——比如Shell weight被打乱后准确率骤降,说明它是区分性别的关键特征,而Length可能影响很小。
最后,实验报告里一定要写清楚你试过哪些参数组合、哪些失败了、为什么失败。比如「hidden_dim=64时训练集准确率0.92但测试集只有0.61,判断为过拟合,最终选用hidden_dim=20加Dropout=0.3」。这种记录比单纯贴一个最终结果有说服力得多。我自己的习惯是每跑一组实验就在notebook里记一行:参数、训练准确率、测试准确率、耗时、备注。最后整理成表格放进报告,读者一眼就能看出你的调参逻辑。希望帮到你。
本文还有配套的精品资源,点击获取