ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于PyTorch的多层感知机假钞识别项目实战

基于PyTorch的多层感知机假钞识别项目实战 开工前先说清楚一句话这个项目不是让你去跟印钞机过不去而是用机器学习里最基础的多层感知机MLP在公开数据集上搭建一个能区分真钞和假钞的分类系统。它解决的是“给定一组钞票的物理特征判断这张钞票是真是假”的二分类问题适合刚学完PyTorch基础、准备上手第一个完整项目的同学也适合想复习数据预处理、模型训练、评估全流程的人。我在这篇文章里会把从数据怎么来、模型怎么搭到训练参数怎么调、踩过哪些坑完整走一遍。所有代码都跑过可以直接抄。1. 项目整体设计为什么用多层感知机做假钞识别1.1 假钞识别到底在解什么题先明确一下任务边界。假钞识别在工业上有两条路线一种是图像路线直接对着钞票拍照用卷积神经网络做图像分类或目标检测另一种是特征路线已经通过专用设备提取了钞票的物理特征比如小波变换后的统计量模型只需要根据这些特征做判断。本项目的场景天然适合特征路线。数据集选的是UCI Machine Learning Repository上的Banknote Authentication数据集它包含从真实和伪造钞票图像上提取的4个数值特征小波变换后的方差variance、偏度skewness、峰度kurtosis和图像熵entropy标签是0和1分别表示真钞和假钞共1372条样本。这个数据集的好处有两个维度低只有4个特征不需要GPUCPU跑起来毫无压力样本量不大但足够训练出一个像样的MLP。更重要的是它把“深度学习用于金融安全”这个听上去很高大上的问题压缩到了一个能在半小时内跑完实验的规模特别适合做完整项目练手。1.2 为什么选多层感知机而不是更复杂的模型多层感知机的定位是“深度学习的敲门砖”。面对只有4个特征的数据CNN和Transformer属于杀鸡用牛刀还会因为数据量太少而严重过拟合。MLP具有足够的非线性拟合能力又因为结构简单而易于调试当模型效果不好时你能清楚地知道问题出在数据上、结构上还是训练参数上。我强调过很多次不要为了秀技术堆模型。真实工作里能用线性模型解决的问题绝不用树模型能用MLP解决的绝不动CNN。这不是保守而是工程上的成本意识——模型越简单训练成本越低部署越容易排查问题越快。假钞识别这种结构化数据任务MLP是最优解之一。另外从学习角度说MLP涵盖了深度学习训练的全部核心环节数据归一化、网络结构设计、激活函数选择、损失函数定义、优化器配置、训练循环写法、结果评估。把这些基本功练扎实后面再学CNN、RNN、Transformer都是顺水推船的事。1.3 项目技术栈选型技术栈方面核心是PyTorch配合NumPy做数据处理、Scikit-learn做数据切分和评估指标计算、Matplotlib画训练曲线。PyTorch版本我实测过2.x系列都没问题CPU版本就足够。选PyTorch而不是TensorFlow主要是因为它的动态图机制对初学者极其友好。你可以print每一步的中间张量形状也可以随时打断训练去检查梯度这种“所见即所得”的调试体验在写模型时能省下大量时间。加上PyTorch社区生态成熟遇到问题搜一下基本都能找到答案。GPU方面这个项目完全不需要。用CPU训练整个训练过程也就几十秒。我用的是Apple Silicon的M系列芯片跑100轮epoch也只是一眨眼的功夫Intel芯片的普通笔记本同样毫无压力。2. 数据准备拿到数据先别急着喂模型2.1 数据集下载与文件格式解析UCI的Banknote Authentication数据集是一个data.txt文件每行5列前4列是特征最后一列是标签。下载后用pandas读进来同时给列命名让数据可读性更强。import pandas as pd import numpy as np df pd.read_csv(data_banknote_authentication.txt, headerNone, names[variance, skewness, kurtosis, entropy, class]) print(df.head()) print(df.info()) print(df[class].value_counts())我实测的样本分布是标签0真钞762条标签1假钞610条比例约为55:45属于比较均衡的分布不用做特别复杂的类别不平衡处理。这一点在后面会详细展开。先别急着建模先把数据的基本情况摸底一遍有没有缺失值、每个特征的分布范围、特征和标签之间的相关性。这一环节很多人跳过但它能帮你提前发现很多问题。print(df.isnull().sum()) print(df.describe())从describe的结果能看到variance这个特征的取值范围在-7到7左右而entropy的取值在-8到-0.5左右每个特征的量纲和分布都不一样。如果你直接把原始值喂进模型训练过程会非常不稳定甚至不收敛。这就是下面要说的归一化问题。2.2 数据归一化这一步不做模型大概率废掉特征归一化是结构化数据任务中最关键、最容易被新手忽略的步骤。为什么必须做原因在梯度下降的机制上。神经网络训练靠梯度更新权重而梯度的尺度跟特征的尺度直接相关。假设特征A取值范围是0到1000特征B取值范围是0到1那么特征A对应的权重梯度的量级可能是特征B的几百上千倍。后果是要么学习率设大了导致震荡不收敛要么学习率设小了导致训练慢如蜗牛。直观地类比你想在一个东西长度分别是1米和1000米的两个维度上找最小值如果尺度不统一等高线图会变成极扁的椭圆梯度下降在里面走“之”字形效率极低。把特征缩放到相近范围后等高线接近圆形梯度直接指向圆心收敛快而稳定。常用的归一化方式有两种Standardization标准化和MinMaxScaler归一化。Standardization让特征满足均值为0、方差为1的正态分布适合特征本身服从近似正态分布的场景MinMaxScaler把特征缩放到0到1之间适合特征分布不均匀但有明确边界的场景。我这次选的是Standardization因为从describe看出各特征分布近似正态且数据里有少量负值标准化是更稳妥的选择。这里有个关键细节拟合scaler只能用训练集然后拿着训练集学到的均值和标准差去转换验证集和测试集。这能防止测试集信息泄露到训练过程中是数据预处理里必须养成的习惯。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X df[[variance, skewness, kurtosis, entropy]].values y df[class].values X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) X_train, X_val, y_train, y_val train_test_split( X_train, y_train, test_size0.25, random_state42, stratifyy_train ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_val scaler.transform(X_val) X_test scaler.transform(X_test) print(X_train.shape, X_val.shape, X_test.shape)数据切分比例上是8:2切出测试集再从训练集中切出20%做验证集最终训练集823条、验证集274条、测试集275条。stratify参数保证切分后正负样本比例和原始数据一致防止随机切分把某一类样本全切到测试集里。2.3 从NumPy数组到PyTorch数据集数据准备好之后要封装成PyTorch的Dataset和DataLoader。Dataset负责定义“怎么取一条样本”DataLoader负责在训练时按批次打乱顺序、组织batch。封装成Dataset而不是直接用Tensor是因为它更通用后续改造成图像数据集时不用改训练代码。import torch from torch.utils.data import TensorDataset, DataLoader X_train_t torch.tensor(X_train, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32) X_val_t torch.tensor(X_val, dtypetorch.float32) y_val_t torch.tensor(y_val, dtypetorch.float32) X_test_t torch.tensor(X_test, dtypetorch.float32) y_test_t torch.tensor(y_test, dtypetorch.float32) train_dataset TensorDataset(X_train_t, y_train_t) val_dataset TensorDataset(X_val_t, y_val_t) test_dataset TensorDataset(X_test_t, y_test_t) batch_size 32 train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size) test_loader DataLoader(test_dataset, batch_sizebatch_size) print(训练集batch数:, len(train_loader)) print(验证集batch数:, len(val_loader)) print(测试集batch数:, len(test_loader))注意batch_size取32这是经验值。太大比如256会导致收敛变慢太小比如1会让梯度更新方向过于随机。32和64是绝大多数任务的首选可以先从这两个值开始调。3. 模型构建搭建一个干净的多层感知机3.1 网络结构设计四层结构拆解参数量模型结构沿用的是经典的多层感知机范式输入层4个神经元中间两个隐藏层输出层1个神经元做二分类概率输出。层与层之间的参数数量计算如下第一层输入4 → 输出16参数量 4×16 16偏置 80第二层输入16 → 输出8参数量 16×8 8 136第三层输入8 → 输出1参数量 8×1 1 9总参数量225个。这是一个非常轻量的模型甚至小于一张灰度图的像素数。为什么用16和8这两个数字而不是随便选的输入是4个特征第一层先扩到16维相当于把特征空间升维让模型有机会做更丰富的非线性组合第二层缩到8维逐渐降维提取高级特征最后压到1维输出。这是典型的“升维-降维-输出”结构。如果用更少的隐藏神经元比如输入直接到4再到1模型表达能力不足容易欠拟合但如果用更多的神经元比如128对这种规模的数据明显浪费还会加剧过拟合。激活函数选ReLU而非Sigmoid。ReLU的梯度在正区间恒为1能有效缓解深层网络中的梯度消失问题而Sigmoid的梯度在正向和反向传播时连乘多次后衰减极快在隐藏层使用会导致训练缓慢。Sigmoid只保留在输出层因为二分类问题需要将输出压缩到0到1之间的概率区间。每个隐藏层后都接一个BatchNorm1d层。这是容易被忽视但极其有效的设计。BatchNorm在每层输入时做标准化把数据拉到均值0方差1的分布然后再送入激活函数能让训练更稳定也能缓解梯度消失问题。我在不加BatchNorm的对照组里看到过训练曲线明显更震荡收敛也更慢。3.2 模型代码实现用nn.Module搭建网络import torch.nn as nn class MLPBanknote(nn.Module): def __init__(self): super(MLPBanknote, self).__init__() self.net nn.Sequential( nn.Linear(4, 16), nn.BatchNorm1d(16), nn.ReLU(), nn.Linear(16, 8), nn.BatchNorm1d(8), nn.ReLU(), nn.Linear(8, 1) ) def forward(self, x): return self.net(x)这个实现有几个细节值得注意。一是用了nn.Sequential把所有层串起来代码干净forward里不用手动逐层调用。二是最后一层没有接Sigmoid这是有意为之原因和损失函数有关下面马上讲。打印模型看一下结构是否和预期一致model MLPBanknote() print(model)输出结果里每一层的名称、输出维度一目了然。强烈建议每写完一个模型都print出来看一眼确认每层的输出维度和下一层的输入维度是对上的这比报错后再查要高效得多。3.3 损失函数和优化器为什么用BCEWithLogitsLoss二分类任务最常用的损失函数是二值交叉熵Binary Cross Entropy。PyTorch里提供了两个相关实现BCELoss和BCEWithLogitsLoss。两者的区别在于BCELoss要求模型的输出已经经过Sigmoid即输入是0到1之间的概率值而BCEWithLogitsLoss把Sigmoid和BCE的计算合并到一起输入直接是原始logits。官方推荐使用BCEWithLogitsLoss因为它在内部做了数值稳定处理能避免Sigmoid计算时出现的数值上溢或下溢问题。这就是前面模型最后一层不接Sigmoid的原因——把Sigmoid留给损失函数去处理逻辑上更清晰数值上更稳定。优化器选的是Adam。它根据每个参数的历史梯度自适应调整学习率对学习率的敏感性远低于SGD是深度学习中的默认选择。学习率设为0.001是Adam最常用的默认值通常能跑出不错的结果。import torch.optim as optim criterion nn.BCEWithLogitsLoss() optimizer optim.Adam(model.parameters(), lr0.001)4. 训练与评估从手写训练循环到完整评估4.1 训练循环三个嵌套层次要理清训练代码有一个固定的层次结构最外层是epoch循环整个数据集跑几轮中间层是batch循环每个batch做一次前向和反向内层是具体的训练步骤前向计算、损失计算、梯度清零、反向传播、参数更新。在训练时注意设置model.train()这会启用BatchNorm的训练模式。BatchNorm在训练和推理评估时的行为是不同的训练时使用当前batch的均值和方差做标准化推理时使用历史累积的全局统计量。如果不切换模式可能出现训练效果很好但验证集效果很差的现象或者更隐蔽的——训练和验证效果都好但部署到生产环境时结果对不上。训练循环代码import matplotlib.pyplot as plt train_losses [] val_losses [] train_accs [] val_accs [] num_epochs 100 for epoch in range(num_epochs): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in train_loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels.view(-1, 1)) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) preds (torch.sigmoid(outputs) 0.5).float() correct (preds.view(-1) labels).sum().item() total labels.size(0) train_loss running_loss / len(train_dataset) train_acc correct / total model.eval() val_loss 0.0 val_correct 0 val_total 0 with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) loss criterion(outputs, labels.view(-1, 1)) val_loss loss.item() * inputs.size(0) preds (torch.sigmoid(outputs) 0.5).float() val_correct (preds.view(-1) labels).sum().item() val_total labels.size(0) val_loss val_loss / len(val_dataset) val_acc val_correct / val_total train_losses.append(train_loss) val_losses.append(val_loss) train_accs.append(train_acc) val_accs.append(val_acc) if (epoch 1) % 10 0: print(fEpoch {epoch1}/{num_epochs} | fTrain Loss: {train_loss:.4f} Acc: {train_acc:.4f} | fVal Loss: {val_loss:.4f} Acc: {val_acc:.4f})这段代码里有几个容易出错的地方。loss criterion(outputs, labels.view(-1, 1))这行很关键。模型的输出形状是[batch_size, 1]而标签的形状是[batch_size]两者形状不匹配必须用labels.view(-1, 1)把标签从一维变成列向量。这个错误在刚开始接触PyTorch时几乎人人都会踩熟悉以后就知道凡是报“size mismatch”先检查形状。preds (torch.sigmoid(outputs) 0.5).float()这行表示在计算准确率前先把logits经过Sigmoid转成概率再以0.5为阈值转成0/1预测。注意模型输出是原始logits不能直接和0.5比较。另一个必须养成的习惯是验证/测试阶段用with torch.no_grad()包裹并调用model.eval()。这能避免PyTorch在前向传播时计算和保存梯度显著降低内存占用、加快推理速度。4.2 训练过程观察从曲线看模型是否健康训练结束后把损失曲线和准确率曲线画出来这是判断模型训练是否健康的最直观方式。fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].plot(range(1, num_epochs1), train_losses, labelTrain Loss) axes[0].plot(range(1, num_epochs1), val_losses, labelVal Loss) axes[0].set_xlabel(Epoch) axes[0].set_ylabel(Loss) axes[0].set_title(Loss Curve) axes[0].legend() axes[1].plot(range(1, num_epochs1), train_accs, labelTrain Acc) axes[1].plot(range(1, num_epochs1), val_accs, labelVal Acc) axes[1].set_xlabel(Epoch) axes[1].set_ylabel(Accuracy) axes[1].set_title(Accuracy Curve) axes[1].legend() plt.tight_layout() plt.show()一个健康的训练过程训练损失和验证损失都应该平稳下降并最终趋近于一个稳定的低位值。如果训练损失下降而验证损失先降后升说明模型在后期过拟合了如果两者都居高不下说明模型欠拟合需要增加模型的表达能力。我测下来的结果大概在30轮左右损失已经降到很低的水平100轮时训练准确率约99.8%验证准确率约99.3%是一个非常理想的学习曲线。这也说明MLP在这个任务上并没有遇到拟合困难。4.3 测试集评估准确率不是唯一的指标训练完成后在从未参与过训练的测试集上做最终评估。独立测试集的评估结果代表模型真正泛化到新数据的能力。model.eval() y_true [] y_pred [] with torch.no_grad(): for inputs, labels in test_loader: outputs model(inputs) preds (torch.sigmoid(outputs) 0.5).float() y_true.extend(labels.numpy()) y_pred.extend(preds.view(-1).numpy()) from sklearn.metrics import (accuracy_score, precision_score, recall_score, f1_score, confusion_matrix, classification_report) print(Accuracy:, accuracy_score(y_true, y_pred)) print(Precision:, precision_score(y_true, y_pred)) print(Recall:, recall_score(y_true, y_pred)) print(F1 Score:, f1_score(y_true, y_pred)) print(\nConfusion Matrix:) print(confusion_matrix(y_true, y_pred)) print(\nClassification Report:) print(classification_report(y_true, y_pred, target_names[Genuine, Fake]))在我本地的运行结果中测试集准确率稳定在99%以上。混淆矩阵大致是这样预测为真钞预测为假钞实际为真钞1530实际为假钞1121理解准确率、精确率、召回率三者的区别在这个场景至关重要。准确率是“所有样本里判断正确的比例”精确率是“模型判断为假钞的样本里有多少真的是假钞”召回率是“真正的假钞里有多少被模型找出来了”。在假钞识别场景中更关注的是召回率。为什么因为把一张假钞放过去假阳性比把一张真钞误判为假钞假阳性后果严重得多——前者代表假币流入市场后者顶多是重新审核一遍。这个业务背景直接决定了模型评估的侧重点。如果需要业务上更高的召回率可以把判断阈值从0.5降低到0.3甚至0.2模型会输出更多“疑似假钞”的样本召回率提高精确率下降。这是应用层面的取舍和模型训练本身无关但生产环境里非常常见。4.4 保存和加载模型项目完整收尾的必备环节训练完成后把模型参数保存到磁盘。真实项目中模型还要走部署、测试流程不可能每次推理都重新训练一遍。torch.save(model.state_dict(), banknote_mlp.pt)重载模型的完整流程是先实例化模型结构再加载参数。这一步和保存一样重要不实例化就直接加载会报错。model_loaded MLPBanknote() model_loaded.load_state_dict(torch.load(banknote_mlp.pt)) model_loaded.eval()保存state_dict模型参数而不是整个模型对象是推荐的方式。state_dict是Python字典只包含参数的张量数据不包含网络结构定义。这样做的好处是结构变化时比如新增隐藏层旧参数也能部分加载文件体积小代码可读性更好。加载参数后必须调用model_loaded.eval()切换到推理模式否则BatchNorm在推理时仍按训练模式运行输出结果不对。5. 项目实践中的踩坑记录与优化建议5.1 踩坑一DataLoader标签维度不匹配这是新手在训练中碰到最多的报错之一报错信息长这样ValueError: Using a target size (torch.Size([32])) that is different to the input size (torch.Size([32, 1]))。原因就是模型的输出经过Linear层后是[batch_size, 1]的形状而标签从TensorDataset里取出来是[batch_size]的形状两者在计算BCEWithLogitsLoss时不匹配。解决办法就是前面代码里的labels.view(-1, 1)。记住这个操作后面做图像分类多分类时会用CrossEntropyLoss标签保持一维整数形状不用改但二分类任务里这个坑几乎必踩一次。5.2 踩坑二忘记切换train/eval模式如果你在训练循环里验证时没有调用model.eval()BatchNorm层会继续使用当前batch的统计量做归一化而不是使用全局统计量。这会导致验证集指标波动极大甚至出现验证准确率忽高忽低的现象。排查方法很简单在训练每轮开始加model.train()验证和测试前加model.eval()。写成习惯后不会再犯这个问题。5.3 踩坑三学习率设太大导致loss变成NaN如果学习率设置得过大梯度更新一步踩过头权重变成无穷大或NaN之后所有计算都会变成NaN且这个过程不可恢复只能重启训练。我在做对照实验时试过learning_rate0.1大概在第10轮loss就爆了。训练曲线会显示loss值跳变到几百上千甚至显示nan。遇到loss变成nan时第一时间应该检查学习率其次是检查数据里是否包含空值或无穷值。5.4 过拟合的几个防御手段虽然这个项目过拟合程度不明显数据量相对充足特征维度低但我在调参过程中做了几组对照实验。如果在数据规模更小或特征维度更高的任务中遇到训练准确率远高于验证准确率的情况可以依次尝试几个手段早停Early Stopping每轮记录验证损失如果连续多个epoch没有下降就终止训练保留最佳模型。增大Batch Size更大的batch意味着每次梯度更新利用了更多样本信息梯度方向更稳定有一定正则化效果。权重衰减Weight Decay在Adam优化器里设置weight_decay1e-4相当于L2正则化抑制权重过大。Dropout层在隐藏层后随机丢弃一部分神经元强迫网络学到更鲁棒的特征。Dropout只加在隐藏层输出层不加。降低模型复杂度减少隐藏层数量或神经元的数量降低参数规模。5.5 阈值调整的实际操作如前所说在假钞识别场景里如果更看重召回率不要把假钞当漏网之鱼可以修改阈值。在sklearn里可以很方便地评估不同阈值下的F1分数、精确率、召回率组合from sklearn.metrics import precision_recall_curve y_probs [] model.eval() with torch.no_grad(): for inputs, _ in test_loader: outputs torch.sigmoid(model(inputs)) y_probs.extend(outputs.view(-1).numpy()) precisions, recalls, thresholds precision_recall_curve(y_true, y_probs) for thr in [0.3, 0.4, 0.5, 0.6, 0.7]: y_pred_thr (np.array(y_probs) thr).astype(int) print(fthr{thr}, precision{(y_true np.where(y_pred_thr, 1, 0)).sum() / len(y_true):.4f})完整做法是用precision_recall_curve得到不同阈值下的P/R值然后根据业务对假阳性和假阴性的容忍度选一个平衡点。这个技巧在真实风控项目里几乎天天用。5.6 项目还可以怎么延伸跑通这个项目后可以往几个方向做延伸。数据层面换成更复杂的公开数据集或者自己构造带噪声的数据看模型的鲁棒性。模型层面用同样的数据试试决策树、随机森林、支持向量机横向比较MLP和传统机器学习的差异这比单纯只跑一个模型收获大得多。框架层面把它改写成PyTorch Lightning的标准写法减少样板代码了解现代PyTorch工程化的写法。如果想把这个项目包装成有实际意义的成果可以再加一个简单的Web服务用FastAPI封装模型推理接口前端做一个简单的上传数值、返回预测结果的页面。这样一个“从模型到服务”的完整闭环就形成了无论放作品集还是做课程设计都很有说服力。6. 我的几点实际体会这个项目我前前后后跑过不止一遍每次跑都有新收获。第一个体会是做项目不要贪复杂把数据理解透、把训练循环吃透比盲目堆模型结构有用得多。这个只有4个特征的数据集你用不上任何花哨的技术反而更能看清深度学习训练的本质是数据、结构、损失函数、优化器这四者的配合。第二个体会是一定要把所有可视化都做出来。损失曲线、准确率曲线、混淆矩阵每一张图都在告诉你模型当前的状态盯着数字看不如画图来得直观。第三个体会是模型保存、阈值调整这些“非核心”环节才是从练手走向实际应用的分水岭。训练代码网上到处都是但能把模型存下来、加载起来、根据业务调阈值的人才是真正理解了模型生命周期的人。最后分享一个小技巧做任何深度学习项目先跑一个小模型、小数据量把整个流程跑通再去加数据和增加模型复杂度。这个习惯能帮你省掉大量调试时间我至今仍在用这个策略。这个假钞识别项目麻雀虽小但五脏俱全跑通它你就具备了自己动手从零做一个深度学习项目的能力。
返回列表