恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
深度学习实验三:从零实现 Logistic 回归二分类全流程解析
首页
资讯中心
/
深度学习实验三:从零实现 Logistic 回归二分类全流程解析
深度学习实验三:从零实现 Logistic 回归二分类全流程解析
发布时间:2026/10/16 13:38:19
1. 实验背景与设计思路1.1 为什么偏偏是 logistic 回归很多人看到“深度学习实验三”这个标题第一反应是都做到实验三了还在折腾 logistic 回归这种“古早”模型说实话我当年也有过同样的疑问。但等我真正把代码写了一遍、把梯度推导走了一遍、又把训练曲线画出来之后才明白这个实验放在深度学习课程里到底想干什么。逻辑回归虽然名字里带个“回归”但它本质上做的是分类。它解决的是“给定一组特征判断样本属于哪个类别”的问题典型场景就是二分类——比如判断一封邮件是不是垃圾邮件、一个用户会不会续费、一张图片里有没有猫。这不只是一个入门练习它是理解神经网络一切复杂机制的地基。在深度学习框架里看logistic 回归就是一个“单神经元 sigmoid 激活 交叉熵损失”的网络。你把神经元数量从 1 个加到 128 个、再加一个隐藏层就变成了多层感知机。再把隐藏层换成卷积核就变成了 CNN。所有后续那些花里胡哨的结构本质上都是在 logistic 回归这个最小闭环上做加法。所以这个实验的真正目的不是让你学会调一行model.fit()而是让你亲手拆开“前向传播—损失计算—反向传播—参数更新”这个闭环理解每一个环节到底发生了什么。从课程设计角度看实验三放在这里也很合理。实验一通常是环境搭建和 Tensor/PyTorch 基础实验二一般是线性回归或多层感知机实验三开始接触分类问题正好把“概率输出”“损失函数选择”“评估指标”这些分类问题的核心概念一次性带出来。这时候你需要区分回归问题用均方误差分类问题用交叉熵回归看 R²、MAE分类看准确率、精确率、召回率、AUC。这个弯转过来后面学 softmax 多分类、学 CNN 图像分类都会顺很多。1.2 这个实验到底能解决什么问题实验三的输入输出形式一般是这样的给一个数据集每一条样本有若干维特征和一个二值标签0 或 1要求你训练一个模型输入特征后输出这个样本属于类别 1 的概率。一个很经典的例子是肿瘤良性/恶性预测——根据肿瘤大小、厚度、细胞形态等特征判断它是良性还是恶性。“良/恶”就是二分类“概率”就是模型输出的核心信息。再比如信用卡风控里判断一笔交易是否欺诈特征可能是交易金额、时间、地点、设备信息等标签是“正常/欺诈”。这些场景有一个共同特点边界不是一条直线能完美切开的特征是带噪声的类别之间存在重叠区域。这正是 logistic 回归可以发挥优势的地方——它不追求“硬性判定”而是输出一个概率让决策者根据概率阈值做最终判断。适合参考这个实验的人我总结有三类正在上机器学习或深度学习课程的学生需要完成类似的实验作业刚入行做数据分析或算法工程师需要用简单模型快速验证一个二分类问题是否可行自己做 Side Project 的人想从零手写一个分类器而不依赖高级框架搞清楚底层的数学原理。1.3 实验的预期产出与验收标准按照我在某高校、某实验室看到的多次实验要求实验三的验收通常包含四个部分完整的代码实现包括数据预处理、模型定义、训练循环、评估环节训练过程中的 loss 曲线和准确率曲线或验证集指标变化对测试集的最终预测结果计算准确率、精确率、召回率、F1、AUC 等指标一份实验报告说明数据情况、模型选择依据、参数设置、结果分析和可能的改进方向。这里有一个很多人忽略的点实验报告里必须写清楚“你尝试过什么、失败了什么、如何调整的”。老师真正想看的不是“最后跑出来 0.98 准确率”这个结果而是你有没有理解为什么调学习率、为什么加正则、为什么用交叉熵。后面我会专门讲到我在这个过程中踩过的坑和排查思路这部分在评分里往往比代码本身拉开的差距更大。2. 核心原理拆解从线性回归到概率输出2.1 线性回归到 logistic 回归的“进化”要理解 logistic 回归最好的方式是从线性回归说起。线性回归做的事情是学习一个线性函数 f(x) w·x b输出一个连续值。比如预测房价输出 200 万、350 万这种实数。但二分类问题的标签是 0 和 1你不可能让模型直接输出 0 或 1 这两个离散值——万一输出 1.7 怎么解释就算输出落在 0 和 1 之间1.7 和 -0.3 代表什么完全没有概率含义。logistic 回归的进化在于它把线性函数的输出结果塞进一个 sigmoid 函数把任意实数值压缩到 (0, 1) 区间。sigmoid 函数的公式是σ(z) 1 / (1 e^(-z))其中 z w·x b。当 z 趋近正无穷时σ(z) 趋近 1当 z 趋近负无穷时σ(z) 趋近 0z 0 时σ(z) 0.5。这个“压缩”操作让模型的输出可以被解释为“样本属于正类的概率”。概率大于 0.5 判为正类小于 0.5 判为负类0.5 就是决策边界。用生活化的类比来解释线性回归像是一杆秤直接告诉你重量logistic 回归像是在秤上装了一个“概率转换器”不告诉你具体重量了而是告诉你“这东西超重的概率有多大”。秤本身还是那把秤但输出的解释方式完全不同。2.2 损失函数如何选交叉熵的直觉理解二分类问题最常用的损失函数是交叉熵Binary Cross Entropy简称 BCEL -[y·log(ŷ) (1-y)·log(1-ŷ)]其中 y 是真实标签0 或 1ŷ 是模型输出的概率。直觉上如果真实标签是 1那么模型输出的 ŷ 越接近 1损失越小如果真实标签是 0那么 ŷ 越接近 0损失越小。当 ŷ 在错误方向上特别自信时比如标签是 1 但模型输出 0.99 的概率给 0损失会极其巨大。我自己试过用均方误差MSE替换交叉熵去训练 logistic 回归效果怎么样呢收敛明显变慢而且训练初期梯度很小loss 下降得很挣扎。原因在于 MSE 对概率输出的梯度是 (ŷ - y)·σ(z)而 sigmoid 的导数在两端趋于 0导致“梯度饱和”。交叉熵配合 sigmoid 之后梯度形式简化为 (ŷ - y)当模型预测错得离谱时梯度大错得少时梯度小非常符合直觉。这是“损失函数设计配合激活函数”的一个经典例子值得展开讲讲。为什么交叉熵和 sigmoid 搭配就这么合适因为两者组合后数学形式出现了奇迹般的简化。设输出概率 ŷ σ(z)那么损失对权重 w 的偏导结果是 (ŷ - y)·x。这意味着梯度大小由“预测与真实的差值”直接决定而不再是“差值 × sigmoid 导数衰减”。模型错得越厉害修正力度越大越接近收敛修正越小天然适合梯度下降。这也是我在实验报告里重点分析的一个点。2.3 手写梯度推导反向传播的最小范例实验要求里通常要求手动推导梯度或者至少理解梯度怎么来的。以单个样本为例令损失为 L -[y·log(ŷ) (1-y)·log(1-ŷ)]。根据链式法则∂L/∂z ∂L/∂ŷ · ∂ŷ/∂z [-(y/ŷ) (1-y)/(1-ŷ)] · ŷ·(1-ŷ)化简后得到∂L/∂z ŷ - y然后∂L/∂w (ŷ - y)·x ∂L/∂b ŷ - y有了这两个梯度就能更新参数w w - α·(∂L/∂w) b b - α·(∂L/∂b)α 是学习率。注意这里的推导过程虽然只针对单个神经元但它与“反向传播”的核心思想完全一致——从损失出发逐层回传梯度。在神经网络里每一层做的都是“用链式法则求局部梯度再乘以上一层的回传梯度”。把这个看懂了实验四的反向传播手写实现对你来说就会简单很多。提示这里有一个初学者特别容易搞混的地方——sigmoid 会用在输出层做概率转换但神经网络中隐藏层的激活函数通常不使用 sigmoid更多用 ReLU原因后面会展开。3. 实验环境与工具选型3.1 两种路线纯手写 vs. PyTorch/TensorFlow做 logistic 回归实验有两种主流路线各有利弊。我强烈建议你两条都走一遍先手写 numpy 版本再用深度学习框架实现。这不是浪费时间而是“从数学到工程”的完整映射过程。手写 numpy 版本自己实现 sigmoid、损失函数、梯度下降循环全部计算基于矩阵乘法权重 W 是 (n_features, 1) 的矩阵偏置 b 是一个标量数据迭代手写 for 循环或者用整个矩阵算批量梯度下降优点完全不依赖框架所有细节暴露在眼前梯度推导的正确性可以得到验证缺点没有自动求导没有 GPU 加速也不方便扩展到复杂网络。PyTorch 版本自由定义 nn.Module 子类在 forward 里定义计算过程用torch.nn.Functional.binary_cross_entropy_with_logits或torch.nn.BCELoss计算损失自动求导、优化器、学习率调度一把梭优点和后续实验的写法无缝衔接代码量少一半缺点自动求导掩盖了底层梯度计算细节容易“只会调包”。我的建议是手写版本作为“验证数学理解”的手段PyTorch 版本作为“正式实验交付”的形式。如果实验时间有限只做 PyTorch 版本也够但务必要在报告里把梯度公式的推导写清楚否则老师问到梯度怎么来的容易答不上来。我这里实际用的是 numpy 手写 PyTorch 对比两边结果做了交叉验证验证逻辑手写梯度检查和数值梯度比对偏差小于 1e-6基本可以认为写法正确。3.2 版本选择与安装实践考虑到实验课环境我推荐 PyTorch 作为首选框架版本选择注意两点不要追最新版本选相对稳定的 1.13 或 2.x 稳定版即可CPU 版完全够用这个实验的数据量根本不需要 GPU 加速。安装命令以 pip 为例pip install torch pip install numpy scikit-learn matplotlib如果是在某高校的机房电脑上做实验没有管理员权限可以用pip install --user torch numpy scikit-learn matplotlib或者优先推荐用 Anaconda 环境直接创建一个干净的虚拟环境conda create -n dl_lab3 python3.9 conda activate dl_lab3 conda install pytorch cpuonly -c pytorch conda install numpy pandas scikit-learn matplotlib关于“配环境”这件事我多说一句——这通常是实验课里第一个大坑。我见过太多人花了两小时装环境最后发现是 Python 版本和 PyTorch 版本不匹配。我的建议是第一次装好环境后立刻跑一个最小验证import torch import numpy as np x torch.rand(5, 3) print(x.shape) print(torch.__version__)能输出就没问题。这一步十分钟内解决很多人跳过了等到跑实验的时候才发现环境有问题那时候处理成本高得多。3.3 放弃复杂框架的理由这里我也想给一个反直觉的建议如果你只是想做这个实验、验证思想其实没必要上任何深度学习框架。numpy 手写 120 行代码就能完成全部功能。框架的优势在大型网络、复杂优化器和 GPU 并行时才体现出来。实验三的真正教学价值恰恰在于“不依赖框架也能跑通”所以别一上来就抄 PyTorch 官方教程先用 numpy 把逻辑理顺。4. 数据准备与预处理细节4.1 数据集选型与加载二分类实验常用的数据集有这么几个某经典数据集某大学统计了某领域若干样本包含几十个特征和标签适合做二分类教学。数据量只有几百条、维度适中、不存在缺失值。这个数据集好跑、好画图、好分析我后面所有演示都在它上面做。某诊断数据集根据体检指标判断某类疾病风险特征含义直观便于在报告中解释模型学到的权重。制造业或金融领域的某数据集特征更多带缺失值预处理环节更复杂一些。如果你是自选数据请注意三点类别不能太极端不平衡比如 99% 都是负样本否则准确率会失真特征维度不要太高否则 logistic 回归理论上能跑但可解释性会下降数据量几百到几千条最合适训练快、迭代周期短、调试体验好。用 scikit-learn 可以直接加载该数据集from sklearn.datasets import load_breast_cancer data load_breast_cancer() X, y data.data, data.target print(X.shape, y.shape) print(类别分布, {0: (y 0).sum(), 1: (y 1).sum()}) print(特征名, data.feature_names)输出类别分布时如果发现 0 和 1 的比例差距过大比如超过 3:1后续评估指标就要更关注召回率和 AUC而不是只看 accuracy。4.2 训练集/验证集/测试集的切分原则切分数据集是实验中最容易“犯浑”的环节。有几个原则必须死守测试集只能最后用一次。你可以在训练集和验证集上反复尝试调参但是测试集是“模拟真实未来数据”的角色多次看测试集结果、再根据结果去调参本质上是在对测试集过拟合。用分层抽样保证训练集和测试集中类别比例一致。sklearn 里直接设置stratifyy即可。from sklearn.model_selection import train_test_split X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.4, random_state42, stratifyy ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, random_state42, stratifyy_temp )这里的切分比例是 6:2:2即 60% 训练、20% 验证、20% 测试。这个比例在中小数据量时比较常规。随机种子固定成 42 是为了结果可复现写实验报告时也要注明随机种子。注意不要用全量数据训练完再切出来测试——那是“透过答案答题”。先切分再只基于训练集做特征标准化和训练这是纪律问题实验报告里一般都会专门看这一点。4.3 标准化让梯度下降跑得稳logistic 回归的输入特征如果数值范围差异很大训练会变得很不稳定。比如肿瘤大小可能是 0.5 到 10而某些指标可能是几百这时候梯度更新会被大数值特征所主导收敛极慢。解法是标准化——把每个特征变成均值 0、方差 1。公式z (x - μ) / σ关键一点μ 和 σ 必须只从训练集计算然后用同一组参数去变换验证集和测试集。如果你用全数据的均值和标准差相当于信息泄露——测试集的信息提前参与了训练预处理评估结果就不真实了。这个细节在实验报告里很加分。from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_val_scaled scaler.transform(X_val) X_test_scaled scaler.transform(X_test)4.4 特征相关性检查与共线性问题在拟合模型之前我习惯先做一个快速相关性检查。画一个特征相关性的矩阵热力图或者计算一下方差膨胀因子VIF。logistic 回归对特征共线性比线性回归还要敏感——共线性会导致权重估计不稳定标准误大解释起来容易出错。我在做某诊断数据时发现其中两个“均值、标准误、最坏值”三组特征内部相关性非常高很多超过 0.9模型预测没问题但单个系数的解释会很难。这时候在实验报告里明确写出“存在共线性但不影响预测后续可以考虑做 PCA 或 L1 正则化”这是有深度的分析。5. 从零搭建与训练完整代码实现5.1 NumPy 手写版完整可运行我先把 numpy 手写版完整代码贴出来注释标得很细方便直接参考import numpy as np from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, precision_score, recall_score # ---------- 数据加载与预处理 ---------- data load_breast_cancer() X, y data.data, data.target # 0/1 标签保持不变 X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.4, random_state42, stratifyy ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, random_state42, stratifyy_temp ) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_val scaler.transform(X_val) X_test scaler.transform(X_test) # 为了方便矩阵运算给训练数据加一列常数项 1 # 这样就把偏置 b 也吸收进权重的第一维不需要单独更新 X_train_b np.hstack([np.ones((X_train.shape[0], 1)), X_train]) X_val_b np.hstack([np.ones((X_val.shape[0], 1)), X_val]) X_test_b np.hstack([np.ones((X_test.shape[0], 1)), X_test]) # 初始化权重维度 特征数 1偏置 n_features X_train_b.shape[1] w np.zeros((n_features, 1)) y_train y_train.reshape(-1, 1) y_val y_val.reshape(-1, 1) y_test y_test.reshape(-1, 1) # ---------- 核心函数 ---------- def sigmoid(z): # 数值稳定的写法防止 exp 溢出 return 1 / (1 np.exp(-np.clip(z, -250, 250))) def compute_loss(y_true, y_pred): eps 1e-12 y_pred np.clip(y_pred, eps, 1 - eps) loss -np.mean(y_true * np.log(y_pred) (1 - y_true) * np.log(1 - y_pred)) return loss def compute_gradient(X_batch, y_batch, y_pred): error y_pred - y_batch # 批量梯度误差向量和特征矩阵做矩阵乘法后取平均 dw (X_batch.T error) / len(y_batch) return dw def predict(X_batch, w): return sigmoid(X_batch w) def predict_label(X_batch, w, threshold0.5): prob predict(X_batch, w) return (prob threshold).astype(int) # ---------- 训练循环 ---------- learning_rate 0.1 n_epochs 2000 train_losses [] val_losses [] val_accs [] for epoch in range(n_epochs): # 全量批量梯度下降数据量小一次性算全部 y_pred predict(X_train_b, w) loss compute_loss(y_train, y_pred) dw compute_gradient(X_train_b, y_train, y_pred) w - learning_rate * dw # 每隔 100 轮记录一次验证集指标 if epoch % 100 0: val_pred predict(X_val_b, w) val_loss compute_loss(y_val, val_pred) val_acc accuracy_score(y_val, predict_label(X_val_b, w)) train_losses.append(loss) val_losses.append(val_loss) val_accs.append(val_acc) print(fepoch {epoch:4d} | train loss {loss:.6f} | val loss {val_loss:.6f} | val acc {val_acc:.4f}) # ---------- 最终测试 ---------- test_pred_label predict_label(X_test_b, w) print(\n----- 测试集最终结果 -----) print(准确率:, accuracy_score(y_test, test_pred_label)) print(精确率:, precision_score(y_test, test_pred_label)) print(召回率:, recall_score(y_test, test_pred_label))这个版本跑出来的结果在标准数据集上通常能到 95% 以上的测试准确率虽然代码简单但所有核心机制都在了。几个细节值得琢磨np.clip(z, -250, 250)是为了防止exp数值溢出尤其当特征标准化后权重快速增大时容易出现eps 1e-12防止log(0)把偏置 b 拼到特征矩阵里让代码只维护一个参数矩阵 w实现上更简洁全量批量梯度下降适合这种小数据数据量大了要改成小批量。5.2 PyTorch 版正式实验的写法PyTorch 版我尽量写得“像正式实验”方便直接改成你自己的数据处理逻辑import torch import torch.nn as nn class LogisticRegression(nn.Module): def __init__(self, n_features): super().__init__() self.linear nn.Linear(n_features, 1) def forward(self, x): # 这里不显式做 sigmoid直接输出 logits return self.linear(x) model LogisticRegression(X_train.shape[1]) # 用 with_logits 版本的损失函数内部完成 sigmoid 交叉熵 criterion nn.BCEWithLogitsLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01) X_train_t torch.tensor(X_train, dtypetorch.float32) y_train_t torch.tensor(y_train, dtypetorch.float32).view(-1, 1) X_val_t torch.tensor(X_val, dtypetorch.float32) y_val_t torch.tensor(y_val, dtypetorch.float32).view(-1, 1) X_test_t torch.tensor(X_test, dtypetorch.float32) y_test_t torch.tensor(y_test, dtypetorch.float32).view(-1, 1) n_epochs 500 batch_size 32 n_samples len(X_train_t) for epoch in range(n_epochs): # 小批量训练 permutation torch.randperm(n_samples) epoch_loss 0.0 for i in range(0, n_samples, batch_size): indices permutation[i:i batch_size] batch_x X_train_t[indices] batch_y y_train_t[indices] optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() optimizer.step() epoch_loss loss.item() * len(batch_x) epoch_loss / n_samples if epoch % 50 0: with torch.no_grad(): val_logits model(X_val_t) val_loss criterion(val_logits, y_val_t) val_probs torch.sigmoid(val_logits) val_pred (val_probs 0.5).int() val_acc (val_pred.view(-1) y_val.view(-1)).float().mean().item() print(fepoch {epoch:4d} | train loss {epoch_loss:.6f} | val loss {val_loss:.6f} | val acc {val_acc:.4f}) # 测试集评估 with torch.no_grad(): test_logits model(X_test_t) test_probs torch.sigmoid(test_logits) test_pred (test_probs 0.5).int().view(-1).numpy() print(测试集准确率:, accuracy_score(y_test, test_pred))这里有一个容易被忽略的细节nn.BCEWithLogitsLoss和nn.BCELoss的区别。前者期望模型输出的是未经过 sigmoid 的 logits内部会自动做 sigmoid 再算交叉熵后者需要你手动对模型输出做 sigmoid。用BCEWithLogitsLoss的好处是数值稳定性更好——组合后的计算在数学上等价但不会出现“sigmoid 之后取 log”导致的数值误差。实验报告里可以提一句这个选择体现你对数值计算的理解。5.3 学习率与迭代次数的调整逻辑为什么 numpy 版学习率用 0.1、PyTorch 版用 0.01这不是随便拍的。数据标准化后特征范围在 -3 到 3 之间梯度量级相对可控。手写版的权重初始化是 0批量梯度下降的梯度比较稳定0.1 能快速收敛PyTorch 版用随机初始化 小批量batch_size32梯度带噪声学习率设太大会震荡。这个“不同算法不同学习率”的选择过程本身就是实验报告里很好的分析素材。如果 loss 曲线发散——比如训练 loss 越来越大或者出现 NaN——第一件事不是调网络结构而是先调小学习率一般降低 10 倍再试。如果 loss 下降非常慢可以适当增大学习率或增加迭代轮数。这些经验在实验中都会用到。6. 训练结果分析与评估6.1 损失曲线的解读方法训练完成后先把 loss 曲线画出来。代码import matplotlib.pyplot as plt plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.plot(train_losses, labeltrain loss) plt.plot(val_losses, labelval loss) plt.xlabel(epoch (every 100)) plt.ylabel(loss) plt.legend() plt.title(Loss curves) plt.subplot(1, 2, 2) plt.plot(val_accs, labelval acc) plt.xlabel(epoch (every 100)) plt.ylabel(accuracy) plt.legend() plt.title(Validation accuracy) plt.tight_layout() plt.show()三条线怎么读是实验报告里的重点内容训练 loss 和验证 loss 同时下降然后趋于平稳——正常收敛模型学好了训练 loss 不断下降但验证 loss 中途开始上升——过拟合的经典信号。需要加正则、增大数据量、降低模型复杂度或早停训练 loss 和验证 loss 都处于高位几乎不下降——欠拟合或数据预处理有问题。检查标准化是否做对、学习率是否过小、特征是否失效。我第一次跑这个实验时训练 loss 一直在 0.55 附近纹丝不动怎么调都下不去。排查了大半天最后发现是标准化代码里我用fit_transform同时处理了训练集和测试集导致测试集信息混入模型学到的模式完全被带偏。把标准化改成“训练集 fit、其他集 transform”之后loss 立刻掉到 0.08 附近。这种坑只有亲手踩过才会印象深。6.2 混淆矩阵与评估指标体系对于二分类问题光看准确率远远不够必须看混淆矩阵。混淆矩阵的四格含义TP真实为正预测为正TN真实为负预测为负FP真实为负预测为正误报FN真实为正预测为负漏报比如在某数据集里我们关心的是“能不能把恶性样本全部找出来”。万一漏掉一个恶性样本后果很严重。这时候召回率TP / (TP FN)比准确率更重要。另一个场景垃圾邮件过滤把正常邮件误判为垃圾邮件的代价也很大那就要重点看精确率TP / (TP FP)。这就是为什么评估指标不能只看准确率——类别不平衡时准确率可能很虚。from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score # 在测试集上计算完整指标 test_probs predict(X_test_b, w).ravel() test_pred_label predict_label(X_test_b, w).ravel() cm confusion_matrix(y_test, test_pred_label) print(混淆矩阵) print(cm) print(F1 Score:, f1_score(y_test, test_pred_label)) print(AUC Score:, roc_auc_score(y_test, test_probs))AUC 这个指标特别值得写进报告。它的直观含义是“随机抽一个正样本和一个负样本模型给正样本打分高于负样本的概率”。AUC 不受分类阈值影响评估的是模型的排序能力对类别不平衡比较鲁棒。在这个数据集上AUC 通常能到 0.99 以上说明模型对正负样本的区分能力非常强。6.3 ROC 曲线绘图与阈值选择ROC 曲线的横轴是 FPR假正率纵轴是 TPR真正率即召回率。曲线越靠近左上角模型性能越好。AUC 就是曲线下方的面积。绘图from sklearn.metrics import roc_curve fpr, tpr, thresholds roc_curve(y_test, test_probs) plt.figure(figsize(6, 5)) plt.plot(fpr, tpr, linewidth2, labelfAUC {roc_auc_score(y_test, test_probs):.3f}) plt.plot([0, 1], [0, 1], r--, labelRandom guess) plt.xlabel(False Positive Rate) plt.ylabel(True Positive Rate) plt.title(ROC Curve) plt.legend() plt.grid(True) plt.show()阈值选择是 ROC 曲线的一个直接应用。默认用 0.5 作为分类阈值但实际项目里不一定最优。比如某些场景需要“宁可错杀不可放过”——漏诊代价很高那可以把阈值调低到 0.3提高召回率反之如果误判代价高阈值可以调到 0.7。怎么选阈值一种做法是根据 ROC 曲线上离左上角最近的点的阈值或者直接看约登指数J TPR - FPR最大的点。我在实验里通常把不同阈值下的精确率和召回率画出来然后根据业务需求做选择。这个部分如果能在报告里写清楚分数会明显高出一个档次。7. 常见问题与排查技巧实录7.1 梯度爆炸与数值不稳定这个实验最常见的“数值事故”是 loss 出现 NaN 或者突然变成 inf。原因基本逃不开几个学习率过大梯度更新步长太大权重瞬间变成极大值下一轮 sigmoid 输入溢出。遇到这种情况学习率直接除以 10 再试未做特征标准化某个特征的量级达到几千导致对应权重梯度巨大更新后失去平衡。先做标准化再用log(0) 问题模型输出概率恰好为 0 或 1 时交叉熵里的 log 会算出无穷大。需要在损失计算里加微小 epsilon或者使用数值稳定的损失函数版本。手写版里用np.clip(z, -250, 250)的方式拦住 sigmoid 输入范围就是这个用途。PyTorch 的BCEWithLogitsLoss内部也做了数值稳定处理正常情况下不会出现 NaN——如果出现先怀疑学习率。7.2 准确率很高但 AUC 反而不高的解释这是一个非常有意思的现象。某次实验里模型在测试集上准确率 97%但 AUC 只有 0.85。很多人觉得矛盾准确率这么高AUC 怎么不爆表原因通常是分类阈值选得太“偏”。比如模型输出的大多数概率都在 0.49 以下、0.51 以上占比很小的区间里样本排序是混乱的。默认阈值 0.5 恰好把它们切开了所以准确率高。但 AUC 是遍历所有阈值的综合排序能力只要排序有混乱AUC 就会掉。这个案例说明准确率高不一定代表模型好AUC 能更全面地反映模型区分能力。实验报告里写出这个分析老师会觉得你是真在做实验而不是只会跑通。7.3 训练 loss 下降不明显怎么排查顺序排查检查数据预处理是否做了标准化y 标签是 0/1 还是 -1/1如果是 -1/1损失函数要相应调整检查梯度实现手动验证梯度。用数值梯度对比解析梯度偏差在 1e-5 以内基本没问题。验证方法很简单eps 1e-6 for i in range(w.shape[0]): w1 w.copy(); w1[i] eps w2 w.copy(); w2[i] - eps numerical_grad (compute_loss(X_train_b, y_train, predict(X_train_b, w1)) - compute_loss(X_train_b, y_train, predict(X_train_b, w2))) / (2 * eps)尝试不同学习率把学习率取 [0.5, 0.1, 0.05, 0.01, 0.001] 分别跑 100 轮观察 loss 变化确认特征数量如果特征维度非常高但样本量很小模型可能欠拟合或过拟合。先用少量特征跑通再逐步加。7.4 常见问题速查表现象最常见原因排查顺序loss 为 NaN学习率过大 / 特征未标准化先降学习率 10 倍再做标准化loss 一直高位不降梯度实现错误 / 数据未归一化数值梯度对比解析梯度训练 loss 降但验证 loss 升过拟合加正则 / 增大数据 / 早停测试结果远低于验证切分泄露 / 测试集预处理不一致检查是否用训练集 fit 的 scaler准确率很高但 AUC 一般阈值恰好切得好排序能力弱看概率分布尝试换模型结构权重大小差异巨大特征量级差异大导致未标准化必须标准化后再训练以上表格可以直接放进实验报告的“问题分析”章节比纯文字描述更有说服力。8. 实验进阶从 logistic 回归走向神经网络8.1 正则化控制过拟合的第一招在训练 loss 和验证 loss 出现明显差距时第一个该尝试的就是正则化。L2 正则化在梯度更新时额外增加一项权重平方和数学上等价于在损失函数后面加 λ/2n · ||w||²。它的效果是惩罚过大的权重强迫模型学到的特征权重不要无限膨胀。numpy 手写版加 L2 正则很简单只需在梯度计算里多一步lambda_reg 0.01 dw (X_batch.T error) / len(y_batch) lambda_reg * w要注意偏置项 b 那一维通常不做正则化所以代码里用 w[1:] 参与正则项w[0]偏置跳过。PyTorch 里更简单在优化器里加参数optimizer torch.optim.SGD(model.parameters(), lr0.01, weight_decay0.01)weight_decay就是 L2 正则的 λ。这个实验里由于数据量和特征数量都不大正则化带来的提升可能有限但把原理写清楚、把实验对比做出来报告立刻加分。8.2 特征交互与多项式扩展logistic 回归是线性模型遇到非线性边界时能力会不够。一个经典补救方案是“特征工程”——把原始特征做多项式扩展比如把年龄 × 收入作为一个新特征。sklearn 里用PolynomialFeatures即可。但我必须提醒logistic 回归的特征扩展要谨慎。扩展到二次项后特征数量会爆炸n 个特征变成 (n²3n)/2 个在小数据集上极易过拟合而且可解释性大打折扣。建议从这个角度出发做实验对比——原始特征 vs 二次多项式扩展 vs 带正则的多项式扩展AUC 和可解释性的权衡是很好的报告素材。8.3 从单神经元到多层网络无缝切换如果实验要求做扩展最简单的“升级”方式是加一个隐藏层class TwoLayerNet(nn.Module): def __init__(self, n_features, hidden_size32): super().__init__() self.hidden nn.Linear(n_features, hidden_size) self.output nn.Linear(hidden_size, 1) def forward(self, x): h torch.relu(self.hidden(x)) return self.output(h)注意两点隐藏层激活函数不要用 sigmoid用 ReLU。因为 sigmoid 在深层网络中会梯度消失ReLU 的梯度是 0 或 1反向传播更顺畅输出层不加激活函数配合BCEWithLogitsLoss使用。这样一个小网络在大多数标准二分类数据集上通常比纯 logistic 回归有更高的 AUC但可解释性会下降。实验结果报告里对比两者优劣就是极好的讨论素材。9. 实验复盘与个人体会9.1 我踩过最深的三个坑第一个坑是标准化时机错位。我一开始用StandardScaler对全量数据fit_transform然后才切分数据集。结果测试集上结果虚高模型一上“真实没见过”的数据就露馅。后来养成习惯任何预处理都必须在切分之后做fit只有训练集能碰验证集和测试集只能transform。第二个坑是“准确率幻觉”。有一版实验测试准确率做到了 96%我当时还挺高兴后来看了 AUC 才发现只有 0.79。细查后发现是数据集中正负样本比例偏斜默认阈值 0.5 的切分刚好“占便宜”了。从那以后我每跑一个二分类实验都会把混淆矩阵和概率分布一起画出来不再只看准确率。第三个坑是批量梯度下降 vs 小批量的行为差异。手写版跑批量梯度下降学习率 0.1 很稳换成 PyTorch 的 mini-batch同样的学习率直接 loss 震荡到飞起。一开始我还以为代码写错了后来才意识到小批量的梯度是带噪声的需要更低的学习率。不同优化器SGD vs Adam对学习率的敏感度也不同Adam 通常容忍更高的学习率而 SGD 需要更仔细地调。这个对比实验做出来能让你对优化算法有真正的体感。9.2 这个实验在深度学习全局中的位置学习深度学习的过程很像搭积木。logistic 回归是第一块也是最关键的一块——它教会你模型 架构 损失函数 优化算法训练循环 前向传播 损失计算 反向传播 参数更新评估 不是看 loss 到 0而是看泛化能力。这套框架你搞明白了后面学 CNN、RNN、Transformer本质上是换架构、换损失、换优化器。很多人到了中期学不动回头看不一定是模型难而是“训练循环”的基本功没打牢。实验三的价值就是帮你把这块地基夯实。9.3 给后来者的操作建议如果你正在准备做这个实验我的建议总结如下先手写 numpy 版本把每一步的变量形状shape打印出来确保矩阵维度对得上写代码时从简单开始先用全批量梯度下降跑通再尝试小批量每次改动只改一个变量记录实验过程和结果不要同时调三个超参数固定随机种子保持可复现画图比数字更能说明问题loss 曲线、ROC 曲线、概率分布直方图都尽量画出来最后不要抄代码——你可以参考任何开源实现但每一行都要能解释它在做什么因为实验报告和答辩里老师一定会问“为什么”。最后分享一个小技巧在训练结束后把模型的权重和特征名称对应打印出来看看哪些特征的权重绝对值最大。这些权重绝对值大的特征就是模型认为最重要的特征。在标准数据集里几个和“胞核形态”相关的特征权重通常很突出这能和医学常识对上。这种“模型解释”层面的分析比单纯调参有意思得多也会让你的实验报告跳出大多数人的水平。