引言
在前序文章中,已经完成矿物数据集预处理、缺失值填充,并训练了逻辑回归、随机森林、SVM、AdaBoost、高斯朴素贝叶斯、XGBoost六种传统机器学习模型。本文在此基础上引入深度学习,基于PyTorch搭建全连接神经网络(Net)与一维卷积神经网络(ConvNet),尝试提升矿物分类效果。文章重点讲解全连接神经网络实现,一维卷积网络侧重说明与前者的差异。
一、数据准备(简要回顾)
1.1 代码
import pandas as pd
import warnings
warnings.filterwarnings("ignore")
datas1 = pd.read_csv("data/训练数据集_均值填充.csv")
datas2 = pd.read_csv("data/测试数据集_均值填充.csv")
x_train = datas1.iloc[:, :-1] # 特征(共13个)
y_train = datas1.iloc[:, -1] # 标签(0,1,2,3)
x_test = datas2.iloc[:, :-1]
y_test = datas2.iloc[:, -1]
1.2 逐行讲解
pd.read_csv:读取CSV文件为DataFrame。训练集和测试集是预先划分好 的两个独立文件,避免了在代码中再做train_test_split。iloc[:, :-1]:iloc是按位置索引。第一个:表示取所有行;:-1表示取除最后一列外的所有列(即特征列)。iloc[:, -1]:取最后一列,即标签列。标签值为0、1、2、3,对应4种矿物类别。warnings.filterwarnings("ignore"):屏蔽运行时警告(如版本弃用提示),让输出更干净,不影响实际计算。
1.3 为什么数据已经标准化?
神经网络对输入尺度非常敏感。如果某个特征范围是01000,另一个是01,梯度更新时大尺度特征会主导权重更新,导致小尺度特征学不到东西。所以在前序预处理中已经做了标准化(Z-Score) :x = (x - mean) / std,使每个特征均值为0、方差为1,保证各特征对模型的贡献处于同一量级。
二、传统机器学习模型(回顾)
| 模型 | 核心思想 | 适用场景 |
|---|---|---|
| 逻辑回归 | 线性组合 + Sigmoid,输出类别概率 | 线性可分、特征少的基线 |
| 随机森林 | 多棵决策树投票,Bagging集成 | 抗过拟合、特征重要性可解释 |
| SVM(多项式核) | 找最大间隔超平面,核函数映射高维 | 小样本、非线性边界 |
| AdaBoost | 串行提升,每轮聚焦错分样本 | 弱分类器集成 |
| 高斯朴素贝叶斯 | 特征条件独立假设 + 贝叶斯公式 | 文本/小样本、速度快 |
| XGBoost | 梯度提升树,正则化 + 二阶泰勒展开 | 表格数据竞赛常胜模型 |
这些模型在中小规模表格数据上通常已经很强,尤其是随机森林和XGBoost这类集成方法。引入神经网络的目的是探索更复杂的特征交叉组合,但并不保证一定超越。
三、全连接神经网络(Net)
3.1 什么是全连接层?
全连接层(Fully Connected Layer,也称线性层)的数学公式:
y = xW\^T + b
其中:
x:输入向量,形状(batch, in_features)W:权重矩阵,形状(out_features, in_features),是可学习参数b:偏置向量,形状(out_features,),是可学习参数y:输出向量,形状(batch, out_features)
nn.Linear(13, 32) 就是创建一个权重矩阵 W 形状为 (32, 13)、偏置 b 形状为 (32,) 的线性变换。每个输出神经元都和所有输入神经元相连,所以叫"全连接"。
3.2 模型定义详解
import torch
from torch import nn
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.fc1 = nn.Linear(13, 32) # 输入13特征 → 32神经元
self.fc2 = nn.Linear(32, 64) # 32 → 64
self.fc3 = nn.Linear(64, 4) # 64 → 4,输出4分类logits
def forward(self, x):
x = torch.relu(self.fc1.forward(x))
x = torch.relu(self.fc2.forward(x))
x = self.fc3.forward(x) # 输出原始得分,不做激活
return x
逐行解析:
class Net(nn.Module):所有PyTorch模型都继承nn.Module,这是PyTorch的基类,提供参数管理、.to(device)、.train()/.eval()等能力。super(Net, self).__init__():调用父类构造函数,必须写,否则参数注册机制失效。self.fc1/fc2/fc3:在__init__中定义的nn.Module子模块会被自动注册,其参数会被model.parameters()收集,供优化器更新。forward(self, x):定义前向传播逻辑。调用model(x)时实际执行的就是forward。torch.relu(x):ReLU(x) = max(0, x),负数置零、正数保留。
网络结构数据流:
输入(13) → fc1线性变换 → ReLU → (32)
→ fc2线性变换 → ReLU → (64)
→ fc3线性变换 → (4) 输出logits
3.3 为什么用ReLU而不是Sigmoid?
| 激活函数 | 公式 | 问题 |
|---|---|---|
| Sigmoid | 1/(1+e^-x) |
输出范围(0,1),深层网络梯度消失(两端导数趋近0);输出非零均值,影响梯度方向 |
| ReLU | max(0,x) |
正区间梯度恒为1,缓解梯度消失;计算极快(只需比较);但负区间"死亡ReLU"问题 |
原代码注释了Sigmoid版本,采用ReLU是因为:训练更快、收敛更稳定、深层网络不易梯度消失。
3.4 为什么最后一层不加激活函数?
因为损失函数用的是nn.CrossEntropyLoss(),它的内部实现是:
CrossEntropyLoss = LogSoftmax + NLLLoss
具体来说:
- 先对logits做
Softmax:p_i = e^(z_i) / Σe^(z_j),转成概率分布 - 再取
log:log(p_i) - 最后用负对数似然
NLLLoss:loss = -log(p_true_class)
如果模型最后一层再加Softmax,等于做了两次Softmax,数值会出问题。所以模型输出原始logits即可。
3.5 数据转为Tensor
X_train = torch.tensor(x_train.values, dtype=torch.float32)
Y_train = torch.tensor(y_train.values)
X_test = torch.tensor(x_test.values, dtype=torch.float32)
Y_test = torch.tensor(y_test.values)
x_train.values:DataFrame转NumPy数组dtype=torch.float32:特征用32位浮点数,这是深度学习的标准精度(比float64省显存、计算快)- 标签
Y_train默认推断为int64(即long类型),这是CrossEntropyLoss的硬性要求------标签必须是torch.long,否则报错。
3.6 损失函数与优化器
model = Net()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
CrossEntropyLoss多分类交叉熵:
- 单个样本损失:
L = -log(p_y),其中p_y是真实类别的预测概率 - 真实类别概率越接近1,损失越小;越接近0,损失越大
- 对整个batch取平均
Adam优化器:
- 结合了Momentum(动量,积累历史梯度方向)和RMSprop(自适应学习率,每个参数单独调整步长)
lr=0.01:初始学习率。全连接网络用0.01,CNN用0.001(CNN更敏感,需要更小步长)model.parameters():收集模型中所有可学习参数(W和b),交给优化器更新
3.7 评估函数详解
def evaluate_model(model, X_data, Y_data, train_or_test):
size = len(X_data)
with torch.no_grad():
preds = model(X_data)
correct = (preds.argmax(1) == Y_data).type(torch.float).sum().item()
correct /= size
print(f"{train_or_test}:\tAccuracy:{(100*correct)}%")
return correct
逐行拆解:
with torch.no_grad()::关闭梯度计算。评估时不需要反向传播,关闭梯度可以省显存、加速。这是评估/推理阶段的标准写法。preds = model(X_data):前向传播,输出形状(batch, 4),每行是4个类别的logits。preds.argmax(1):dim=1表示按列方向取最大值索引,即每行取分数最高的类别编号,输出形状(batch,)。(preds.argmax(1) == Y_data):布尔张量,预测对为True,错为False。.type(torch.float):布尔转浮点(True→1.0,False→0.0),方便求和。.sum().item():求和得到正确样本数,.item()把单元素张量转成Python标量。correct /= size:除以总样本数,得到0~1的准确率。
3.8 训练循环------深度学习的核心四步
epochs = 6000
accs = []
for epoch in range(epochs):
outputs = model.forward(X_train) # ① 前向传播
loss = criterion(outputs, Y_train) # ② 计算损失
optimizer.zero_grad() # ③ 梯度清零
loss.backward() # ④ 反向传播
optimizer.step() # ⑤ 参数更新
if (epoch + 1) % 100 == 0:
print(f"Epoch {epoch+1}/{epochs}, Loss {loss.item():.4f}")
train_acc = evaluate_model(model, X_train, Y_train, 'train')
test_acc = evaluate_model(model, X_test, Y_test, 'test')
accs.append(test_acc * 100)
五步训练法是所有PyTorch训练的标准模板:
| 步骤 | 代码 | 作用 | 不写会怎样 |
|---|---|---|---|
| ① 前向 | model(X) |
计算预测值 | 没有预测就没法算损失 |
| ② 损失 | criterion(out, y) |
量化预测和真实的差距 | 没有优化目标 |
| ③ 清零 | optimizer.zero_grad() |
清空上一轮累积的梯度 | 梯度会累加,更新方向错误 |
| ④ 反向 | loss.backward() |
自动求导,计算每个参数的梯度 | 参数不知道该往哪个方向更新 |
| ⑤ 更新 | optimizer.step() |
按梯度和学习率更新参数 | 学了等于没学,参数不变 |
为什么要zero_grad()? PyTorch的.backward()默认是累加梯度而不是覆盖。这是为了支持RNN等需要多次反向传播的场景。如果不清零,第2轮的梯度 = 第1轮梯度 + 第2轮梯度,越积越大,学习率等效越来越大,训练直接崩掉。
训练6000轮、每100轮评估一次:
- 小数据集(矿物数据通常几百~几千条)不需要mini-batch,直接全量训练(Full Batch)
- 每100轮记录一次测试集准确率,最后取
max(accs)作为最佳结果 - 注意:取max可能有"挑选最佳epoch"的乐观偏差,严格来说应该用验证集选epoch再在测试集测一次
四、卷积神经网络(ConvNet)
4.1 一维卷积是什么?
图像用的是二维卷积(Conv2d),在高和宽两个维度滑动卷积核。对于表格数据,我们把13个特征排成一个长度为13的一维序列,用一维卷积(Conv1d)在这个序列上滑动,提取相邻特征间的局部模式。
一维卷积的数学操作:卷积核在序列上滑动,每个位置做对应元素相乘再求和,输出一个值。
4.2 Conv1d参数详解
self.conv1 = nn.Conv1d(in_channels=1, out_channels=16, kernel_size=3, padding=1)
in_channels=1:输入通道数。表格数据每个样本只有1条特征序列,所以是1。(图像是3通道RGB,对应in_channels=3)out_channels=16:输出通道数,也是卷积核的个数。每个卷积核学一种局部模式,16个卷积核能学16种不同模式。kernel_size=3:卷积核大小,每次看相邻的3个特征。padding=1:在序列两端各补1个0。输入长度13,卷积核3,padding=1时输出长度 = (13 + 2*1 - 3)/1 + 1 = 13,长度不变。
三层卷积的通道变化:
(batch, 1, 13) → conv1 → (batch, 16, 13)
→ conv2 → (batch, 32, 13)
→ conv3 → (batch, 64, 13)
4.3 模型定义详解
class ConvNet(nn.Module):
def __init__(self, num_features, hidden_size, num_classes):
super(ConvNet, self).__init__()
self.conv1 = nn.Conv1d(1, 16, 3, padding=1)
self.conv2 = nn.Conv1d(16, 32, 3, padding=1)
self.conv3 = nn.Conv1d(32, 64, 3, padding=1)
self.relu = nn.ReLU()
self.fc = nn.Linear(64, num_classes)
def forward(self, x):
x = x.unsqueeze(1) # 关键:增加通道维度
x = torch.relu(self.conv1(x))
x = torch.relu(self.conv2(x))
x = torch.relu(self.conv3(x))
x = x.mean(dim=2) # 全局平均池化
x = self.fc(x)
return x
关键操作逐行讲:
x.unsqueeze(1):- 输入x形状是
(batch, 13) - Conv1d要求输入形状是
(batch, channels, length) unsqueeze(1)在第1维(0-based)插入一个维度,变成(batch, 1, 13)- 不加这一步会直接报错:维度不匹配
- 输入x形状是
- 三层卷积 + ReLU :
- 每层卷积后接ReLU引入非线性
- 通道数从1→16→32→64逐步增加,学更抽象的特征
- 因为padding=1,序列长度始终保持13不变
x.mean(dim=2)------全局平均池化(GAP) :- 此时x形状是
(batch, 64, 13) dim=2表示在第2维(长度维)上求平均- 每个通道的13个值取平均,得到
(batch, 64) - 作用:把变长/固定长度的序列压缩成固定长度的特征向量,同时减少参数量、防止过拟合
- 替代方案:也可以用
Flatten展平,但参数量更大(64*13=832 vs 64)
- 此时x形状是
- 全连接分类 :
(batch, 64)→fc→(batch, 4),输出4类logits。
4.4 ConvNet vs Net 核心区别总结
| 对比项 | 全连接Net | 卷积ConvNet |
|---|---|---|
| 核心操作 | 矩阵乘法,每个输出连接所有输入 | 卷积核滑动,参数共享,只看局部 |
| 参数量 | 大(13×32+32×64+64×4=2528) | 小(卷积核参数共享,三层仅约2300) |
| 特征视角 | 全局特征组合 | 局部相邻特征模式 |
| 输入形状 | (batch, 13) |
(batch, 1, 13)需加通道维 |
| 归纳偏置 | 无(暴力学所有组合) | 局部性、平移不变性 |
| 适合数据 | 表格、低维特征 | 序列、信号、图像 |
注意:
__init__中的hidden_size参数在forward中完全没用到,是冗余参数,可以删除。这里保留是为了和原代码一致。
4.5 训练流程差异
model = ConvNet(13, hidden_size=10, num_classes=4)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 学习率更小
num_epochs = 7000 # 训练轮数更多
- 学习率0.001:CNN比全连接网络更敏感,大学习率容易震荡不收敛,所以调小10倍
- 训练7000轮:CNN收敛更慢,需要更多轮次
- 其余训练五步(前向→损失→清零→反向→更新)和评估逻辑完全复用
五、性能比较汇总
all_models = [
("逻辑回归", float(LR_result['lr测试集正确率'])),
("随机森林", float(RF_result['rf测试集正确率'])),
("SVM", float(SVM_result['svm测试集正确率'])),
("AdaBoost", float(ABF_result['abf测试集正确率'])),
("高斯朴素贝叶斯", float(GNB_result['gnb测试集正确率'])),
("XGBoost", float(XGBoost_result['xgb测试集正确率'])),
("全连接神经网络", float(net_result['acc'])),
("卷积神经网络", float(cnn_result['acc']))
]
best_name, best_score = max(all_models, key=lambda t: t[1])
print("正确率最高模型\t", best_name, best_score)
all_models是一个元组列表,每个元组是(模型名, 准确率)max(..., key=lambda t: t[1]):按元组第2个元素(准确率)取最大值- 返回
(best_name, best_score),即最优模型名称和分数 float(...):确保所有值都是Python float类型,避免numpy/tensor类型混用导致比较异常
六、总结与延伸思考
6.1 本文核心结论
- 全连接网络:结构简单,通过多层线性变换+ReLU学习非线性特征组合,适合表格数据
- 一维CNN:把特征当序列,用卷积核捕捉相邻特征的局部模式,参数共享更高效
- 中小规模表格数据:传统集成方法(随机森林、XGBoost)往往已经很强,深度学习不一定显著超越,但值得尝试
6.2 可以进一步优化的方向
- Dropout正则化 :在全连接层之间加
nn.Dropout(0.5),随机失活神经元,防止过拟合 - Batch Normalization :加
nn.BatchNorm1d,稳定训练、允许更大学习率 - 学习率调度 :用
torch.optim.lr_scheduler动态衰减学习率,后期更精细 - Mini-Batch训练 :数据量大时用
DataLoader分batch,而不是全量一次喂入 - 交叉验证:用K-Fold验证模型稳定性,而不是单次划分
- 早停(Early Stopping):验证集损失连续N轮不下降就停止,避免过拟合
- 取max的问题:本文取测试集准确率最大值作为结果,存在"挑选最佳epoch"的乐观偏差。严格做法是用验证集选最优epoch,再在测试集上测一次
6.3 关键知识点速查表
| 知识点 | 核心一句话 |
|---|---|
nn.Linear |
y = xW^T + b,全连接线性变换 |
| ReLU | max(0,x),缓解梯度消失、计算快 |
| CrossEntropyLoss | 内部=LogSoftmax+NLLLoss,模型输出logits即可 |
torch.no_grad() |
评估时关闭梯度,省显存加速 |
argmax(1) |
按行取最大类别索引 |
| 训练五步 | 前向→损失→清零→反向→更新 |
zero_grad() |
必须清零,否则梯度累加 |
unsqueeze(1) |
给Conv1d加通道维度 |
Conv1d |
一维序列上滑动卷积核,参数共享 |
mean(dim=2) |
全局平均池化,压缩序列维 |
完整代码参考文中片段,数据集与缺失值处理见前文,欢迎交流。