实验三的任务是logistic回归预测二分类。说实话刚开始我有点看不上这个实验毕竟周围同学已经开始讨论各种“网络结构”了而我还在跟一条S曲线打交道。但真正把代码跑起来、把loss曲线拉出来看之后我的想法完全变了。这个实验表面上只是解决一个二分类问题实际上它是整个深度学习的微缩版——前向传播、损失计算、反向更新全部都能在一页纸里写完没有任何黑盒。这篇复盘我会从原理层、代码层和踩坑层各走一遍最后聊聊怎么从这个最简单的模型往多层网络上迈。目标是让正在做类似实验、或者想把这节课老老实实吃透的人看完之后能自己把代码敲出来而不是只会对着框架文档复制粘贴。1. 实验定位看似送分题其实是深度学习的微缩版1.1 从线性回归到分类输出端只改了一点点先说个容易绕晕的问题logistic回归名字里带“回归”但它干的是分类的活。很多系列课程会把logistic回归安排在第三个实验前面大概率刚做完线性回归。线性回归输出连续值适合预测房价、温度这种无限延伸的数值但分类任务要的是“这个样本属于哪一类”输出不能是正负无穷的实数而是一个落在0到1之间的概率。从公式上看变化其实很小。线性回归是y wᵀx blogistic回归只是在外面套了一个sigmoid函数p σ(wᵀx b)就凭这个“一套”输出从(−∞, ∞)被压到了(0, 1)刚好可以解释成概率。所以从结构上讲logistic回归就是一个“线性层 非线性激活函数”这正是神经网络里神经元的标准结构。换句话说以后你在CNN、Transformer里看到的各种线性变换和激活函数组合本质上都是从这个最简单的模型长出来的。平时很多人会漏掉一个细节为什么名字叫“回归”这是统计学里遗留的历史叫法当年提出这个模型的统计学家沿用了“回归”这个词但它在机器学习里的定位从来都是分类器。实验报告里如果把这个点写清楚老师会觉得你是真的理解了这个模型而不是只会调包。1.2 一个实验吃透整个训练流程我做完这个实验最大的感受是它值得你认真做三遍。第一遍照着流程跑通第二遍手推一遍梯度第三遍尝试用深度学习框架重新实现。每一遍得到的理解完全不同。原因很简单logistic回归是一个“全流程的最小完备系统”数据预处理、模型定义、损失函数、梯度计算、参数更新、评估指标、可视化每个环节都会在后面所有实验里重复出现。如果这里靠抄代码糊弄过去后面遇到模型不收敛、loss异常、准确率虚高这类问题基本只能靠猜。更进一步说logistic回归就是“单神经元网络”。只有一个神经元没有隐藏层但它已经具备了一个完整模型的全部要素可训练参数、激活函数、基于梯度的优化。理解了这个最小单位后面理解多层感知机、卷积神经网络就只是“在原来的骨架上做加法”。2. 动手前必须吃透的三个数学细节2.1 sigmoid为什么偏偏是这条S曲线sigmoid的公式写出来很简单σ(z) 1 / (1 e⁻ᶻ)但很多人只记住了它的形状没想过为什么选它。我当时是从三个角度来理解的第一它需要一个能把实数压缩到(0, 1)区间的函数。分类要概率概率必须落在0和1之间sigmoid正好满足。第二它有一个特别方便求导的性质。σ′(z) σ(z) · (1 − σ(z))。这个性质在反向传播里太关键了算梯度的时候不需要额外记忆复杂的导数公式拿着前向传播的结果就能直接算。第三它和“logit”是一对反函数。所谓logit就是几率比的对数ln(p / (1 − p))。如果把logit看成线性打分z那sigmoid就是把打分还原成概率的那个反函数。这也是为什么很多教材会说logistic回归是在用线性函数拟合logit。当然sigmoid在深层网络里会有梯度消失的毛病这是后话。但在单神经元模型里它作为激活函数完全够用而且是最容易理解的一个。2.2 交叉熵分类任务的最优损失函数这个点我建议不要直接背结论而是亲手对比一下。如果拿均方误差MSE来做二分类会出现什么情况假设真实标签是y模型输出是aσ(z)MSE写成L 1/m · Σ(aᵢ − yᵢ)²对权重w求梯度会得到这样的形式∂L/∂w 1/m · Σ(aᵢ − yᵢ) · σ′(zᵢ) · xᵢ注意这里多乘了一个σ′(z)。当z很大或很小时sigmoid进入饱和区σ′(z)趋近于0梯度也跟着趋近于0。这会导致什么模型预测错得离谱的时候梯度反而很小更新不动训练卡死。交叉熵BCE就没有这个问题。它的形式是L −1/m · Σ[yᵢ·ln(aᵢ) (1−yᵢ)·ln(1−aᵢ)]同样对w求梯度化简之后会得到一个非常干净的结果∂L/∂w 1/m · Σ(aᵢ − yᵢ) · xᵢ预测偏了多少梯度就按多少更新误差越大更新越用力。下面这个表格可以直观对比一下对比项均方误差交叉熵损失形式1/m Σ(aᵢ − yᵢ)²−1/m Σ[yᵢln(aᵢ) (1−yᵢ)ln(1−aᵢ)]对w的梯度1/m Σ(aᵢ − yᵢ)σ′(zᵢ)xᵢ1/m Σ(aᵢ − yᵢ)xᵢ接近收敛时的行为梯度被σ′压制可能消失梯度与误差成正比自然变小分类任务推荐度不推荐推荐从最大似然估计的角度看交叉熵也天然适合分类问题。每个样本的标签y服从伯努利分布模型输出的概率就是分布参数p。最大化对数似然化简之后恰好就是最小化交叉熵。所以它不是拍脑袋选的损失函数而是概率框架下的自然结论。2.3 梯度下降一次完整的链式法则推导这一步别跳。手推一次梯度你对后面框架里backward()做了什么会非常有底。设z wᵀx ba σ(z)损失L −[y·ln(a) (1−y)·ln(1−a)]。先算∂L/∂a∂L/∂a −y/a (1−y)/(1−a)再算∂a/∂z∂a/∂z a(1−a)两者相乘得到∂L/∂z∂L/∂z [−y/a (1−y)/(1−a)] · a(1−a) a − y这个等式漂亮得有点不真实损失对z的梯度居然就是模型输出与真实标签的差。再往上一层∂L/∂w (a − y) · x ∂L/∂b a − y参数更新就是w : w − lr · 1/m · Σ(aᵢ − yᵢ)·xᵢ b : b − lr · 1/m · Σ(aᵢ − yᵢ)整个公式简单到可以用一句话概括预测偏了就按偏差方向调整参数。理解到这里后面的代码实现就水到渠成了。3. 实验环境与数据处理先把数据喂干净再谈模型3.1 数据生成用可控数据验证模型行为做理论验证型的实验我建议先用人工生成的数据不要一上来就抓一个真实数据集。人工数据的优势在于你知道真实分布可以精确判断模型学得好不好。以二分类为例我用NumPy直接生成两类二维高斯点或者用scikit-learn的工具生成簇状分布。这里的关键参数是样本量500特征数2中心点两个这样生成的数据在二维平面上可以直接可视化后续画决策边界也方便。import numpy as np from sklearn.datasets import make_blobs X, y make_blobs( n_samples500, centers2, n_features2, random_state42, cluster_std1.2, ) print(X.shape, y.shape) print(y.mean()) # 看类别是否平衡期望接近0.5为什么建议用这类数据因为logistic回归的决策边界是一条直线用线性可分或近似线性可分的数据来验证模型的表现是符合直觉的。如果你用了一个高度非线性分布的数据logistic回归表现不好你还得花时间分析是数据问题还是代码问题徒增干扰。3.2 标准化先有尺度对齐才有梯度下降标准化这一步我第一遍实验的时候偷懒跳过了结果loss下降得特别慢后来才发现问题出在特征尺度上。看梯度更新公式∂L/∂w (a − y)·x。如果某个特征的取值动辄成百上千该维度的梯度也会被整体放大而另一个特征取值在0到1之间梯度就小。这会造成特征之间的更新步调严重不一致参数来回震荡收敛极慢。解决办法很简单把每个特征减去均值、除以标准差让它变成均值为0、方差为1的分布。from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) mean X_train.mean(axis0) std X_train.std(axis0) X_train_scaled (X_train - mean) / std X_test_scaled (X_test - mean) / std注意一个很容易踩的坑测试集要用训练集的均值和标准差而不是自己重新算。原因在于训练集的标准差和均值承载了训练阶段学到的数据分布信息测试集在做的是“模拟未来新数据”不能用测试集自身的统计量去“作弊”。严格来说如果先把整个数据集标准化再划分也算一种轻微的信息泄漏虽然logistic回归对这种泄漏不太敏感但规范起见流程上还是要先划分再标准化。3.3 划分训练集与测试集隔离必须从头开始数据划分放在预处理之前这是个原则问题。先划分再标准化可以保证测试集在模型训练的整个过程中完全不可见。我见过有的同学把数据读进来标准化可视化观察了半天最后才想起来划分。这样做的风险是你在观察和调整的过程中可能已经通过测试集的信息做了某些决定比如发现测试集上的分布不太一样反过来调整了预处理参数。这同样属于信息泄漏。正确流程是读数据 → 划分训练集测试集 → 只对训练集做标准化拟合 → 用训练集的统计量变换测试集。顺序一定不能反。4. 从零手写NumPy实现logistic回归全流程4.1 前向传播与损失函数实现这个实验我强烈建议先不用框架用NumPy手写一遍。框架把太多细节封装起来了直接调API你永远不知道梯度长什么样。先写前向传播和损失函数def sigmoid(z): return 1 / (1 np.exp(-z)) def forward(X, w, b): z np.dot(X, w) b return sigmoid(z) def compute_loss(y_pred, y_true): m len(y_true) eps 1e-8 loss -1/m * np.sum( y_true * np.log(y_pred eps) (1 - y_true) * np.log(1 - y_pred eps) ) return loss这里有个自检技巧在数据平衡、参数w初始化为0、b初始化为0的情况下模型的初始输出对所有样本都是0.5此时的交叉熵正好是ln(2)≈0.6931。如果你打印第一次迭代的loss发现它非常接近0.693说明前向传播和损失函数写得没问题。这个小小的验证点能帮你快速排除一大半低级错误比如公式抄错、标签方向弄反等。4.2 梯度计算与参数更新根据前面推导的公式梯度实现起来非常简洁def compute_gradients(X, y_true, y_pred): m X.shape[0] error y_pred - y_true dw 1/m * np.dot(X.T, error) db 1/m * np.sum(error) return dw, db def update_parameters(w, b, dw, db, learning_rate): w - learning_rate * dw b - learning_rate * db return w, b有没有发现整个反向传播的核心就三行算误差、算梯度、更新参数。框架里那一堆封装最后也是在算这几行东西。手动实现一遍之后你再看深度学习框架的反向传播机制会有一种“原来如此”的通透感。4.3 完整训练循环与决策边界训练过程就是重复“前向 → 算损失 → 算梯度 → 更新”这个循环def train(X, y, learning_rate0.1, epochs1000): n_features X.shape[1] w np.zeros(n_features) b 0.0 for epoch in range(1, epochs 1): y_pred forward(X, w, b) loss compute_loss(y_pred, y) dw, db compute_gradients(X, y, y_pred) w, b update_parameters(w, b, dw, db, learning_rate) if epoch % 100 0 or epoch 1: print(fEpoch {epoch:4d}, loss {loss:.6f}) return w, b w, b train(X_train_scaled, y_train, learning_rate0.1, epochs1000)训练结束后用测试集做一次预测看准确率def predict(X, w, b, threshold0.5): y_pred forward(X, w, b) return (y_pred threshold).astype(int) y_pred_test predict(X_test_scaled, w, b) accuracy np.mean(y_pred_test y_test) print(fTest accuracy {accuracy:.4f})这一步执行完你手上已经有了一个完整可用的二分类模型。但实验还没完——建议把决策边界画出来这一步对理解线性分类器特别有帮助。logistic回归的决策边界是线性方程 wᵀx b 0。在二维特征空间里w的方向垂直于决策边界。用网格评价模型在整个平面上的输出能直观看到边界把两类点切成了两半import matplotlib.pyplot as plt def plot_decision_boundary(X, y, w, b): x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 xx, yy np.meshgrid( np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200) ) grid np.c_[xx.ravel(), yy.ravel()] zz forward(grid, w, b).reshape(xx.shape) plt.contourf(xx, yy, zz, levels[0, 0.5, 1], alpha0.6, colors[#ffaaaa, #aaaaff]) plt.scatter(X[y 0, 0], X[y 0, 1], cred, edgecolorsk, labelclass 0) plt.scatter(X[y 1, 0], X[y 1, 1], cblue, edgecolorsk, labelclass 1) plt.xlabel(feature 1) plt.ylabel(feature 2) plt.legend() plt.show() plot_decision_boundary(X_test_scaled, y_test, w, b)看到这条直线把两类样本分开的时候我对“线性分类器”这个概念才有了真正的体感——它就是在高维空间里找一个超平面把所有样本分成两个区域。5. 用PyTorch重新实现框架下的等价写法5.1 框架版模型定义三行代码等NumPy手写版跑通之后再用PyTorch写一遍对比一下抽象层次。模型定义非常简洁import torch import torch.nn as nn import torch.optim as optim X_train_t torch.tensor(X_train_scaled, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32).reshape(-1, 1) X_test_t torch.tensor(X_test_scaled, dtypetorch.float32) y_test_t torch.tensor(y_test, dtypetorch.float32).reshape(-1, 1) model nn.Sequential( nn.Linear(2, 1), nn.Sigmoid() ) criterion nn.BCELoss() optimizer optim.SGD(model.parameters(), lr0.1)这里的nn.Linear(2, 1)对应手写版里的w和bnn.Sigmoid()对应sigmoid函数BCELoss对应交叉熵损失SGD对应手动的参数更新。5.2 训练循环与结果对照PyTorch的训练循环比手写版多了一步梯度清零epochs 1000 for epoch in range(1, epochs 1): y_pred model(X_train_t) loss criterion(y_pred, y_train_t) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 100 0 or epoch 1: print(fEpoch {epoch:4d}, loss {loss.item():.6f}) with torch.no_grad(): y_pred_test model(X_test_t) pred_labels (y_pred_test 0.5).float() accuracy (pred_labels.view(-1) y_test_t.view(-1)).float().mean().item() print(fTest accuracy {accuracy:.4f})如果设置相同的学习率和迭代次数你会发现PyTorch版和NumPy手写版的loss曲线几乎完全重合。这说明框架并没有做什么魔法它只是把数学公式封装成了高效的计算图核心逻辑跟手写版是一致的。5.3 框架替我们做了什么对比两版代码可以清楚看到框架解决了两件事自动微分和GPU加速。手写版需要自己推导梯度公式PyTorch的backward()通过计算图自动完成链式法则手写版在CPU上跑矩阵运算PyTorch里同样的运算能在GPU上并行执行。但前提是你得知道梯度是什么、为什么这样算否则框架只是把错误变得更难发现而已。这里还有一个实践经验如果直接用BCELoss模型的输出层应该包含sigmoid因为BCELoss要求输入是概率。而如果你想用更稳定的写法可以用nn.BCEWithLogitsLoss它把sigmoid和交叉熵合并在一起通过log-sum-exp的变换来避免数值精度问题。实验里可以两种都试一下观察最终loss的位数是否一致这也能加深对数值稳定性的理解。model_logits nn.Linear(2, 1) criterion_stable nn.BCEWithLogitsLoss() # 预测时 # y_pred torch.sigmoid(model_logits(X_test_t))6. 训练中遇到的坑与排查经验6.1 学习率最常见的震荡源学习率是logistic回归实验里第一个需要调的参数。lr设太小loss下降得像蜗牛1000次迭代还没走到收敛lr设太大loss不降反升打印出来能看到前几步从0.7直接跳到2以上。我之前有一次把学习率调到1.5模型直接发散loss越滚越大。原因也好理解学习率乘以梯度之后参数一步跨过了最低点跑到误差更大的区域再下一步又跨回来来回震荡震荡幅度还不收敛。经验值对于标准化后的数据logistic回归的学习率可以从0.1开始试loss下降平稳就保持下降太快且震荡就降到0.03、0.01如果下降非常慢也可以试试0.3、0.5。关键是观察前100次迭代的loss曲线形态而不要光看最终结果。6.2 标准化顺序与测试集泄漏这个坑我在第3章里提过但值得再展开一次因为它太不容易察觉了。错误做法是# 错误示范先在全体数据上标准化再划分 scaler StandardScaler() X_scaled scaler.fit_transform(X) X_train, X_test train_test_split(X_scaled, y)为什么有问题因为测试集参与了scaler的拟合训练阶段就已经间接知道了测试集的均值和标准差。在模型比较简单的时候这种泄漏可能只造成测试集准确率略微虚高但如果后面实验用深层模型、大量调参这种泄漏会在不知不觉中放大测试集上的表现最终导致模型上线后崩盘。# 正确做法先划分再适配 X_train, X_test train_test_split(X, y) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)第二次做实验我就吃了这个亏测试集准确率0.97感觉特别好结果同学生成一组新数据让我预测直接掉到0.89。排查了半天发现就是把测试集提前放进了scaler的统计范围。6.3 类别不平衡时的阈值调整准确率并不是二分类唯一的评估指标尤其当数据不平衡时。假设99%的样本是类0、1%是类1模型把所有样本都预测成类0准确率照样有99%看起来漂亮实际上完全没有分类能力。我刚开始只盯着准确率后来打印了一次混淆矩阵才意识到问题。对于logistic回归输出的p是模型认为样本属于类1的概率阈值默认取0.5。数据不平衡时模型输出往往整体偏低0.5这个阈值并不合适。一个简单的排查方式预测前先看看测试集输出的概率分布。如果模型在类1样本上的概率都集中到0.3、0.4附近说明模型的“内部分数”其实是能区分两类的只是0.5这个阈值定高了。这时可以把阈值下调到0.3左右准确率反而会上升。threshold 0.3 pred_labels y_pred_test threshold阈值的选择属于决策层面的判断不改变模型本身的参数但能让模型适应不同的代价偏好。比如医疗筛查场景更怕漏诊就降低阈值让模型更倾向于报阳性垃圾邮件过滤更怕误伤正常邮件就提高阈值。排查项可能原因解决思路loss在0.7左右不下降数据没标准化/特征尺度差异大对特征做标准化并检查训练流程loss下降后又反弹学习率过大降低学习率观察前100轮曲线训练集准确率高、测试集忽高忽低测试集参与了预处理先划分再用训练集统计量做标准化准确率高但预测结果没意义类别不平衡用混淆矩阵、F1、AUC等指标替代7. 往后一步从logistic回归自然过渡到多层网络7.1 线性边界的极限logistic回归做不到的事把实验再往下推一层可以试试人工生成一个非线性分布的数据比如两个环形嵌套的类。这时logistic回归会怎样决策边界仍然是一条直线无论怎么调参它都无法画出一个圆形的边界把内外两圈分开测试集准确率会稳定在50%附近——跟猜差不多。这个现象不是bug而是模型的表达能力上限。logistic回归本质上还是线性模型它在特征空间里只能学到一个超平面。现实中的分类问题特征和类别之间往往是非线性关系所以必须引入更复杂的模型。7.2 再加一层的魔力从单神经元到MLP把多个神经元堆起来加上非线性激活函数模型就能拟合非线性边界。最简单的做法是加一个隐藏层比如两个输入特征 → 四个隐藏神经元 → 一个输出神经元中间用ReLU作为激活函数。为什么加一层就能表达非线性可以这样理解隐藏层里的每个神经元各自学会了一个线性边界ReLU把负方向“压死”相当于每个神经元只对某个局部区域有响应输出层再把这几个局部响应拼接起来最终决策边界就成了由多段直线拼接出的折线。隐藏神经元越多折线段越多能逼近的形状就越复杂。实验课上把logistic回归改成两层MLP通常只需改模型定义那几行代码训练循环几乎不用动。这一步做完基本就完成从“逻辑回归”到“深度学习”的概念跃迁了。model_mlp nn.Sequential( nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 1), nn.Sigmoid() # 如果用BCELoss )7.3 实验报告可以怎么扩展如果你的实验报告想拿高分除了把基础流程跑通还可以加几个小实验这部分我建议至少做三个第一个把二分类改成三分类这时输出层神经元变成3个激活函数从sigmoid换成softmax损失函数换成交叉熵对应的多分类版本体验从“一个概率值”升级为“一个概率分布”的变化第二个给模型加上L2正则化对比不同正则强度的训练集、测试集准确率理解正则化对过拟合的抑制第三个把学习率从0.5、0.1、0.01分别跑一组画出loss曲线对比把观察到的现象和梯度下降原理结合起来写在报告里。这些扩展并不难但每一样都指向深度学习里更核心的问题非线性表达、概率建模、正则化、优化策略。做完之后你会发现实验三的后劲比你想象中大得多后续所有深度学习实验的基本功几乎都在这里打下了。
阅读完成 · 觉得有帮助?