
逻辑回归这玩意儿别看名字里带个回归它干的其实是分类的活儿。我刚开始学深度学习那会儿也被这个命名坑过以为它跟线性回归是亲兄弟结果发现它是个披着回归外衣的分类器。这次我打算用PyTorch从头到尾把逻辑回归撸一遍不是那种调个sklearn就完事的玩法而是真正用张量、自动求导、优化器这些底层组件搭起来让你彻底搞明白神经网络框架到底在背后干了什么。这篇文章适合谁看如果你已经会一点Python听说过PyTorch但还没真正动过手或者你用过sklearn的LogisticRegression但不知道里面发生了什么那这篇就是写给你的。我会从最基础的数据构造开始一步步搭出模型、定义损失、写训练循环最后在测试集上验证效果。整个过程不依赖任何高级封装全部用PyTorch的原生API实现代码可以直接复制到你的环境里跑。1. 逻辑回归到底在做什么1.1 从线性回归到分类的跨越线性回归的输出是一个连续的实数值比如预测房价、温度、销量。但分类问题不一样我们要的是这个样本属于哪一类的答案。二分类问题最理想的情况是输出0或1但直接让模型输出离散值有个致命问题函数不连续没法求导梯度下降就玩不转了。逻辑回归的解法很巧妙它先算一个线性组合 $z w^Tx b$然后把这个实数通过Sigmoid函数压缩到(0,1)区间。Sigmoid的形式是 $\sigma(z) \frac{1}{1e^{-z}}$它的形状是个S形曲线当$z$趋近正无穷时输出趋近1趋近负无穷时输出趋近0$z0$时输出恰好0.5。这样一来输出值就可以解释为属于正类的概率。我个人的理解是Sigmoid在这里扮演了一个软开关的角色。线性部分负责画一条分界线Sigmoid负责把离分界线多远转换成有多大概率属于某一类。离分界线越远且方向正确概率就越接近1反之越接近0。1.2 为什么损失函数不能用均方误差很多人第一反应是用均方误差MSE来衡量预测概率和真实标签的差距但这样做会出问题。把Sigmoid和MSE组合在一起损失函数关于参数的曲线不是凸的存在多个局部极小值梯度下降容易陷进去出不来。逻辑回归用的是交叉熵损失Cross-Entropy Loss二分类下的形式是$$L -\frac{1}{N}\sum_{i1}^{N}[y_i \log(\hat{y}_i) (1-y_i)\log(1-\hat{y}_i)]$$这个公式看起来有点吓人但拆开看很直观。当真实标签$y_i1$时第二项消失损失变成$-\log(\hat{y}_i)$预测概率越接近1损失越小当$y_i0$时第一项消失损失变成$-\log(1-\hat{y}_i)$预测概率越接近0损失越小。交叉熵配合Sigmoid的导数形式非常简洁梯度就是$(\hat{y}_i - y_i)$乘以输入特征计算效率极高。注意PyTorch的BCELoss要求输入已经过Sigmoid而BCEWithLogitsLoss把Sigmoid和交叉熵合在了一起数值稳定性更好。实际项目中我强烈建议用后者原因在后面会详细说。1.3 PyTorch实现逻辑回归的完整思路用PyTorch实现逻辑回归核心就是把上面说的数学过程翻译成代码。整体架构分四块数据准备、模型定义、损失与优化器选择、训练循环。数据准备阶段要构造特征矩阵和标签向量转成张量模型定义可以用nn.Linear加nn.Sigmoid也可以自己写一个继承nn.Module的类损失函数用BCEWithLogitsLoss优化器选SGD或Adam都行学习率需要调。我选择自己写一个nn.Module子类而不是直接用nn.Sequential原因是这样能更清楚地看到前向传播的每一步对理解整个流程更有帮助。等你熟练了用nn.Sequential(nn.Linear(2,1), nn.Sigmoid())一行搞定也没问题。2. 环境准备与数据构造2.1 依赖安装与版本确认动手之前先把环境理清楚。PyTorch的安装命令根据操作系统和是否用GPU有所不同我这里以CPU版本为例pip install torch numpy matplotlib装完之后建议在Python里确认一下版本import torch print(torch.__version__)我写这篇内容时用的是2.x版本1.x版本的核心API也完全兼容。如果你有NVIDIA显卡并且配好了CUDA可以把CPU版本换成对应的GPU版本训练速度会快很多但对于逻辑回归这种小模型CPU和GPU的差距几乎感觉不到。除了PyTorch本身还需要numpy做数据生成matplotlib做可视化。这两个库在数据科学环境里基本是标配没有的话装一下就行。2.2 构造一个可复现的二分类数据集为了把注意力集中在模型本身而不是数据清洗上我用手工构造的二维数据集。二维的好处是可以直接画在平面上决策边界一目了然。数据生成用sklearn.datasets.make_classification或者自己用正态分布采样都行我倾向于自己写因为可控性更强import torch import numpy as np import matplotlib.pyplot as plt # 固定随机种子保证每次运行结果一致 torch.manual_seed(42) np.random.seed(42) # 生成两类数据每类500个样本 n_samples 500 # 正类均值在(2, 2)标准差0.8 class1 np.random.randn(n_samples, 2) * 0.8 np.array([2.0, 2.0]) # 负类均值在(-2, -2)标准差0.8 class2 np.random.randn(n_samples, 2) * 0.8 np.array([-2.0, -2.0]) # 拼接特征和标签 X np.vstack([class1, class2]).astype(np.float32) y np.hstack([np.ones(n_samples), np.zeros(n_samples)]).astype(np.float32) # 转成PyTorch张量 X_tensor torch.from_numpy(X) y_tensor torch.from_numpy(y).unsqueeze(1) # 形状从(1000,)变成(1000,1) print(f特征形状: {X_tensor.shape}) print(f标签形状: {y_tensor.shape})这里有几个细节值得说。第一torch.manual_seed和np.random.seed都要设因为数据生成用的是numpy模型初始化用的是torch两边都得固定住才能完全复现。第二标签用unsqueeze(1)从一维变成二维是因为BCEWithLogitsLoss要求预测值和真实值的形状一致模型输出是(N,1)标签也得是(N,1)。第三数据类型统一用float32PyTorch默认的浮点类型就是它混用float64会报类型不匹配的错。2.3 数据可视化与训练集划分数据生成完先画出来看看确认两类确实分得开plt.figure(figsize(8, 6)) plt.scatter(class1[:, 0], class1[:, 1], cblue, alpha0.5, labelClass 1) plt.scatter(class2[:, 0], class2[:, 1], cred, alpha0.5, labelClass 2) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.legend() plt.title(Binary Classification Dataset) plt.show()图上应该能看到两团颜色分明的点云中间有重叠区域。这个重叠区域就是逻辑回归的难点所在完美线性可分的数据集在现实中几乎不存在有重叠才真实。接下来划分训练集和测试集比例8:2# 打乱索引 indices torch.randperm(len(X_tensor)) # 前80%做训练后20%做测试 split int(0.8 * len(X_tensor)) train_idx, test_idx indices[:split], indices[split:] X_train, y_train X_tensor[train_idx], y_tensor[train_idx] X_test, y_test X_tensor[test_idx], y_tensor[test_idx] print(f训练集: {X_train.shape[0]} 样本) print(f测试集: {X_test.shape[0]} 样本)实操心得划分之前一定要打乱。如果数据是按类别顺序排列的不打乱直接切会导致训练集和测试集的类别分布严重偏斜模型评估结果完全不可信。我见过有人在这上面栽跟头训练集准确率99%测试集只有50%排查半天才发现是数据没打乱。3. 模型定义与前向传播3.1 用nn.Module搭建逻辑回归模型PyTorch定义模型的标准做法是继承nn.Module在__init__里声明层在forward里定义计算流程import torch.nn as nn class LogisticRegression(nn.Module): def __init__(self, input_dim): super().__init__() # 线性层输入维度 - 输出维度1 self.linear nn.Linear(input_dim, 1) def forward(self, x): # 线性组合后接Sigmoid return torch.sigmoid(self.linear(x))这个类只有两层结构但麻雀虽小五脏俱全。nn.Linear(input_dim, 1)内部维护了权重矩阵$W$形状(1, input_dim)和偏置$b$形状(1,)前向传播时自动执行$xW^T b$。torch.sigmoid把结果压缩到(0,1)。实例化模型并查看参数model LogisticRegression(input_dim2) print(model) # 查看初始化的权重和偏置 for name, param in model.named_parameters(): print(f{name}: {param.data})输出大概是这样的LogisticRegression( (linear): Linear(in_features2, out_features1, biasTrue) ) linear.weight: tensor([[-0.3542, 0.2871]]) linear.bias: tensor([0.1234])权重和偏置是随机初始化的每次运行数值不同除非固定了种子。这些参数就是模型要学习的东西训练的过程就是不断调整它们让预测结果越来越接近真实标签。3.2 前向传播的数值验证模型搭好了先手动跑一次前向传播确认输出形状和数值范围都正常# 取前5个训练样本测试 sample X_train[:5] with torch.no_grad(): output model(sample) print(f输入形状: {sample.shape}) print(f输出形状: {output.shape}) print(f输出值: {output.squeeze()})输出形状应该是(5, 1)数值都在0到1之间。如果输出全是0.5左右说明权重初始化得太小模型还没开始学习如果输出全是0或1说明权重太大Sigmoid饱和了。这两种情况在训练初期都正常随着训练推进会逐渐分化。注意torch.no_grad()在推理时一定要加。它会关闭自动求导减少内存占用和计算量。训练时不能加因为需要计算梯度推理时加上是标准操作。3.3 为什么不用nn.Sigmoid作为独立层有人可能会想为什么不在__init__里写self.sigmoid nn.Sigmoid()然后在forward里调用技术上完全可以但我不推荐。原因是后面用BCEWithLogitsLoss时损失函数内部已经包含了Sigmoid如果模型输出再过一次Sigmoid相当于做了两次概率会被压缩到极端值附近梯度消失严重。正确的做法是模型只输出线性部分的$z$Sigmoid交给损失函数处理。这样数值稳定性最好PyTorch官方文档也是这么建议的。如果你非要用BCELoss那模型输出必须过Sigmoid但BCELoss在极端值附近容易出现log(0)的问题需要加一个极小值裁剪比较麻烦。我个人的习惯是模型只做线性变换Sigmoid和交叉熵全部交给BCEWithLogitsLoss。这样代码最简洁数值也最稳定。4. 损失函数与优化器选择4.1 BCEWithLogitsLoss的内部机制BCEWithLogitsLoss把Sigmoid和二元交叉熵合成了一个操作数学上等价于先算Sigmoid再算BCE但数值上更稳定。它内部用的是log-sum-exp技巧避免了直接计算$\log(\sigma(z))$时可能出现的数值下溢。用代码验证一下它的输出import torch.nn.functional as F # 模拟模型输出未过Sigmoid的logits logits torch.tensor([[2.0], [-1.0], [0.5], [-2.5]]) targets torch.tensor([[1.0], [0.0], [1.0], [0.0]]) # 用BCEWithLogitsLoss loss_fn nn.BCEWithLogitsLoss() loss1 loss_fn(logits, targets) # 手动先Sigmoid再算BCE probs torch.sigmoid(logits) loss2 F.binary_cross_entropy(probs, targets) print(fBCEWithLogitsLoss: {loss1.item():.6f}) print(f手动SigmoidBCE: {loss2.item():.6f})两个结果应该非常接近差异在小数点后五六位。但如果你把logits的值调到很大比如100手动版本可能会返回inf或nan而BCEWithLogitsLoss依然稳定。这就是数值稳定性的价值。4.2 优化器的选择与学习率设定优化器我选SGD虽然Adam收敛更快但SGD配合逻辑回归这种凸优化问题已经足够而且SGD的行为更可预测方便观察损失下降的过程。学习率设0.1这个值对于标准化后的数据比较合适optimizer torch.optim.SGD(model.parameters(), lr0.1)学习率的选择有个经验法则太大容易震荡甚至发散太小收敛太慢。0.1对于SGD来说属于中等偏大配合我们的数据尺度特征标准差0.8均值±2刚好合适。如果你换了数据集特征尺度变化很大学习率也要相应调整。一个实用的技巧是先用0.01试如果损失下降太慢就乘10如果震荡就除以10。实操心得逻辑回归的损失曲面是凸的没有局部极小值所以SGD一定能收敛到全局最优。但收敛速度受学习率影响很大。我试过lr1.0损失直接飞到nanlr0.001跑1000轮损失还在0.5以上。0.1是我实测下来最平衡的值。4.3 权重衰减与正则化逻辑回归容易过拟合尤其是特征维度高、样本少的时候。PyTorch的优化器支持L2正则化通过weight_decay参数控制optimizer torch.optim.SGD(model.parameters(), lr0.1, weight_decay1e-4)weight_decay1e-4表示在损失函数里加上$\frac{\lambda}{2}|w|^2$$\lambda10^{-4}$。这个值很小对当前这个简单数据集影响不大但养成加正则化的习惯是好事。如果发现训练集准确率远高于测试集可以适当增大weight_decay比如调到1e-3或1e-2。L1正则化PyTorch的优化器不直接支持需要手动在损失里加torch.norm(model.linear.weight, 1)。L1会让权重变得稀疏适合做特征选择但逻辑回归通常用L2就够了。5. 训练循环的完整实现5.1 训练循环的骨架训练循环是整个过程的核心逻辑不复杂但细节多。基本流程是前向传播算预测值算损失清空梯度反向传播算梯度更新参数。重复这个过程若干轮num_epochs 200 loss_history [] for epoch in range(num_epochs): # 1. 前向传播 logits model.linear(X_train) # 注意这里取线性输出不过Sigmoid loss loss_fn(logits, y_train) # 2. 清空梯度 optimizer.zero_grad() # 3. 反向传播 loss.backward() # 4. 更新参数 optimizer.step() # 记录损失 loss_history.append(loss.item()) if (epoch 1) % 20 0: print(fEpoch [{epoch1}/{num_epochs}], Loss: {loss.item():.4f})这里有个关键点训练时我调用的是model.linear(X_train)而不是model(X_train)。因为model(X_train)会过Sigmoid而BCEWithLogitsLoss期望的是未过Sigmoid的logits。如果你不小心传了Sigmoid后的值损失会偏大梯度也会有问题。这个坑我踩过当时损失死活降不下去排查了半天才发现是这里传错了。5.2 损失曲线的绘制与分析训练完把损失曲线画出来直观判断收敛情况plt.figure(figsize(10, 5)) plt.plot(loss_history, linewidth2) plt.xlabel(Epoch) plt.ylabel(Loss) plt.title(Training Loss Curve) plt.grid(True, alpha0.3) plt.show()正常的损失曲线应该是一条快速下降然后逐渐平缓的曲线。前几十轮下降最快后面越来越慢最后趋于稳定。如果曲线震荡剧烈说明学习率太大如果曲线几乎不下降说明学习率太小或者模型有问题如果曲线先降后升说明过拟合了需要加正则化或提前停止。我这次跑的结果是第1轮损失约0.65第20轮降到0.15左右第100轮降到0.08第200轮稳定在0.07附近。这个下降模式很健康说明模型正常学习到了数据的分布。5.3 训练过程中的常见异常训练时可能遇到几种异常情况我列个表方便对照排查异常现象可能原因排查方法损失为nan学习率太大、数据未标准化降低学习率、检查数据范围损失不下降学习率太小、梯度被截断增大学习率、检查backward调用损失震荡学习率偏大、batch太小降低学习率、增大batch损失先降后升过拟合加正则化、早停、增数据准确率始终0.5标签和预测不对齐检查标签形状和模型输出形状注意损失为nan是最常见的问题九成以上是学习率太大导致的。我建议第一次跑的时候把学习率设小一点比如0.01确认能正常下降后再逐步调大。另外数据标准化也能显著降低nan的概率因为特征尺度差异太大会导致梯度爆炸。6. 模型评估与决策边界可视化6.1 准确率计算与混淆矩阵训练完了得评估效果。逻辑回归的输出是概率需要设一个阈值通常0.5转成类别def predict(model, X, threshold0.5): model.eval() with torch.no_grad(): probs model(X) return (probs threshold).float() # 训练集准确率 train_pred predict(model, X_train) train_acc (train_pred y_train).float().mean().item() # 测试集准确率 test_pred predict(model, X_test) test_acc (test_pred y_test).float().mean().item() print(f训练集准确率: {train_acc:.4f}) print(f测试集准确率: {test_acc:.4f})我这次跑出来训练集准确率约0.97测试集约0.96差距很小说明没有明显过拟合。如果两者差距超过5个百分点就要警惕了。混淆矩阵能提供更细粒度的信息from sklearn.metrics import confusion_matrix test_pred_np test_pred.numpy().flatten() y_test_np y_test.numpy().flatten() cm confusion_matrix(y_test_np, test_pred_np) print(cm)输出是一个2x2的矩阵四个格子分别是真负例、假正例、假负例、真正例。通过这个矩阵可以算出精确率、召回率、F1分数等指标。对于类别不平衡的数据集准确率会骗人必须看这些细分指标。6.2 决策边界的可视化二维数据的最大好处是可以把决策边界画出来。逻辑回归的决策边界是$w^Tx b 0$这条直线在二维平面上就是一条线# 提取权重和偏置 w model.linear.weight.data.numpy().flatten() b model.linear.bias.data.numpy().flatten()[0] # 决策边界w1*x1 w2*x2 b 0 x2 -(w1*x1 b) / w2 x1_range np.linspace(-5, 5, 100) x2_boundary -(w[0] * x1_range b) / w[1] plt.figure(figsize(10, 8)) plt.scatter(class1[:, 0], class1[:, 1], cblue, alpha0.4, labelClass 1) plt.scatter(class2[:, 0], class2[:, 1], cred, alpha0.4, labelClass 2) plt.plot(x1_range, x2_boundary, g-, linewidth3, labelDecision Boundary) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.legend() plt.title(Logistic Regression Decision Boundary) plt.xlim(-5, 5) plt.ylim(-5, 5) plt.grid(True, alpha0.3) plt.show()图上那条绿线就是模型学到的分界线。理想情况下它应该穿过两类数据的中间把重叠区域尽量平分。如果线明显偏向某一类说明数据不平衡或者模型没训好。6.3 概率输出的解读逻辑回归的输出是概率不是硬分类。这个概率值本身包含信息量。比如一个样本输出0.51另一个输出0.99虽然都判为正类但后者置信度高得多。实际应用中如果对误判成本敏感可以调整阈值。比如把阈值从0.5调到0.7只有非常确信的样本才判为正类能降低假正例但会增加假负例。# 查看测试集前10个样本的预测概率 with torch.no_grad(): probs model(X_test[:10]) print(预测概率:, probs.squeeze().numpy()) print(真实标签:, y_test[:10].squeeze().numpy())概率值应该和真实标签高度吻合标签为1的样本概率接近1标签为0的接近0。如果某个标签为1的样本概率只有0.3说明模型在这个样本上犯了错可以单独拿出来分析原因。7. 常见问题与排查技巧实录7.1 形状不匹配报错这是新手最容易遇到的问题。BCEWithLogitsLoss要求输入和目标的形状完全一致都是(N, 1)或都是(N,)。如果模型输出是(N, 1)而标签是(N,)会报广播错误或者静默计算出错误结果。排查方法很简单在算损失之前打印两个张量的形状print(flogits shape: {logits.shape}) print(ftarget shape: {y_train.shape})确保两者一致。我习惯在数据准备阶段就用unsqueeze(1)把标签变成二维这样和模型输出天然对齐。7.2 梯度消失与Sigmoid饱和Sigmoid在输入绝对值很大时导数趋近于0导致梯度消失。如果模型输出的logits绝对值经常超过10说明权重太大Sigmoid已经饱和了。这时候梯度几乎为零参数更新不动损失卡住不降。解决办法有几个一是减小学习率防止权重更新过猛二是加L2正则化抑制权重增长三是用BCEWithLogitsLoss而不是手动SigmoidBCE因为前者在数值上更稳定。我实测下来只要学习率不超过0.5配合标准化数据基本不会遇到严重的饱和问题。7.3 训练集和测试集分布不一致如果训练集准确率很高但测试集很低除了过拟合还有一种可能是两个集合的分布不一致。比如数据划分时没有打乱训练集全是某一类测试集全是另一类。这种情况在真实项目中很常见尤其是按时间顺序排列的数据。排查方法是分别统计训练集和测试集的标签分布print(f训练集正类比例: {y_train.mean().item():.4f}) print(f测试集正类比例: {y_test.mean().item():.4f})两个比例应该接近。如果差距大说明划分有问题需要重新打乱再切分。7.4 损失函数选择速查表场景推荐损失函数模型输出备注二分类追求数值稳定BCEWithLogitsLoss线性logits首选方案二分类需要概率输出BCELossSigmoid后概率需防log(0)多分类CrossEntropyLoss线性logits内部含Softmax多标签分类BCEWithLogitsLoss线性logits每个标签独立实操心得我几乎所有二分类项目都用BCEWithLogitsLoss唯一例外是需要单独获取Sigmoid输出做可视化的时候。即便如此我也会在推理阶段单独调torch.sigmoid而不是把它塞进模型里。8. 从逻辑回归到多层神经网络的扩展8.1 加一层隐藏层会怎样逻辑回归本质上是一个没有隐藏层的神经网络。如果在它基础上加一个隐藏层就变成了多层感知机MLP。代码改动很小class MLP(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() self.fc1 nn.Linear(input_dim, hidden_dim) self.relu nn.ReLU() self.fc2 nn.Linear(hidden_dim, 1) def forward(self, x): x self.relu(self.fc1(x)) return self.fc2(x) # 输出logits不过Sigmoid隐藏层的作用是学习非线性特征。逻辑回归只能画直线决策边界MLP可以画曲线。对于线性可分的数据两者效果差不多对于线性不可分的数据比如同心圆、异或问题MLP优势明显。8.2 非线性数据集的对比实验构造一个线性不可分的数据集来验证这个结论# 同心圆数据集 n 500 theta np.random.uniform(0, 2*np.pi, n) r_inner np.random.normal(1.0, 0.2, n//2) r_outer np.random.normal(3.0, 0.2, n//2) inner_x np.stack([r_inner*np.cos(theta[:n//2]), r_inner*np.sin(theta[:n//2])], axis1) outer_x np.stack([r_outer*np.cos(theta[n//2:]), r_outer*np.sin(theta[n//2:])], axis1) X_circle np.vstack([inner_x, outer_x]).astype(np.float32) y_circle np.hstack([np.ones(n//2), np.zeros(n//2)]).astype(np.float32)用逻辑回归跑这个数据集准确率大概只有0.6左右因为一条直线没法把内圆和外环分开。换成带隐藏层的MLP准确率能到0.95以上。这个对比实验很能说明问题模型的表达能力决定了它能拟合多复杂的决策边界。8.3 什么时候该用逻辑回归虽然神经网络更强大但逻辑回归依然有它的用武之地。特征维度高但样本少的时候逻辑回归不容易过拟合需要模型可解释性的时候逻辑回归的权重直接反映了每个特征的重要性线上推理延迟敏感的场景逻辑回归的计算量远小于深度网络。我个人的经验是先上逻辑回归做baseline如果效果不够再考虑复杂模型。很多时候逻辑回归的效果已经够用了没必要为了几个百分点的提升引入复杂的深度网络。而且逻辑回归训练快调参简单迭代周期短适合快速验证想法。9. 完整代码整合与运行指南9.1 端到端脚本把前面所有片段整合成一个可运行的脚本import torch import torch.nn as nn import numpy as np import matplotlib.pyplot as plt # 1. 数据准备 torch.manual_seed(42) np.random.seed(42) n_samples 500 class1 np.random.randn(n_samples, 2) * 0.8 np.array([2.0, 2.0]) class2 np.random.randn(n_samples, 2) * 0.8 np.array([-2.0, -2.0]) X np.vstack([class1, class2]).astype(np.float32) y np.hstack([np.ones(n_samples), np.zeros(n_samples)]).astype(np.float32) X_tensor torch.from_numpy(X) y_tensor torch.from_numpy(y).unsqueeze(1) indices torch.randperm(len(X_tensor)) split int(0.8 * len(X_tensor)) train_idx, test_idx indices[:split], indices[split:] X_train, y_train X_tensor[train_idx], y_tensor[train_idx] X_test, y_test X_tensor[test_idx], y_tensor[test_idx] # 2. 模型定义 class LogisticRegression(nn.Module): def __init__(self, input_dim): super().__init__() self.linear nn.Linear(input_dim, 1) def forward(self, x): return torch.sigmoid(self.linear(x)) model LogisticRegression(input_dim2) # 3. 损失与优化器 loss_fn nn.BCEWithLogitsLoss() optimizer torch.optim.SGD(model.parameters(), lr0.1, weight_decay1e-4) # 4. 训练循环 num_epochs 200 loss_history [] for epoch in range(num_epochs): logits model.linear(X_train) loss loss_fn(logits, y_train) optimizer.zero_grad() loss.backward() optimizer.step() loss_history.append(loss.item()) if (epoch 1) % 40 0: print(fEpoch [{epoch1}/{num_epochs}], Loss: {loss.item():.4f}) # 5. 评估 def predict(model, X, threshold0.5): model.eval() with torch.no_grad(): probs model(X) return (probs threshold).float() train_acc (predict(model, X_train) y_train).float().mean().item() test_acc (predict(model, X_test) y_test).float().mean().item() print(f\n训练集准确率: {train_acc:.4f}) print(f测试集准确率: {test_acc:.4f}) # 6. 可视化 fig, axes plt.subplots(1, 2, figsize(14, 6)) # 损失曲线 axes[0].plot(loss_history, linewidth2) axes[0].set_xlabel(Epoch) axes[0].set_ylabel(Loss) axes[0].set_title(Training Loss) axes[0].grid(True, alpha0.3) # 决策边界 w model.linear.weight.data.numpy().flatten() b model.linear.bias.data.numpy().flatten()[0] x1_range np.linspace(-5, 5, 100) x2_boundary -(w[0] * x1_range b) / w[1] axes[1].scatter(class1[:, 0], class1[:, 1], cblue, alpha0.4, labelClass 1) axes[1].scatter(class2[:, 0], class2[:, 1], cred, alpha0.4, labelClass 2) axes[1].plot(x1_range, x2_boundary, g-, linewidth3, labelBoundary) axes[1].set_xlabel(Feature 1) axes[1].set_ylabel(Feature 2) axes[1].set_title(Decision Boundary) axes[1].legend() axes[1].grid(True, alpha0.3) plt.tight_layout() plt.show()这个脚本从头到尾跑一遍大概两三秒输出包括训练过程的损失、最终准确率和两张图。你可以直接复制到Jupyter Notebook或者Python文件里运行。9.2 超参数调优建议虽然逻辑回归的超参数不多但每个都值得调一调。学习率是最关键的我建议从0.01开始试按10倍递增或递减。weight_decay从0开始如果过拟合再加到1e-4、1e-3。训练轮数看损失曲线稳定了就可以停一般100到500轮足够。批量大小batch size在逻辑回归里通常设成全量即每轮用所有数据因为数据量小全量梯度下降更稳定。如果数据量很大比如几十万条可以设成256或512用mini-batch训练。9.3 从CPU迁移到GPU如果数据量很大或者想体验GPU加速迁移很简单device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) X_train X_train.to(device) y_train y_train.to(device) X_test X_test.to(device) y_test y_test.to(device)然后在训练循环里所有张量运算自动在GPU上执行。注意loss.item()会触发CPU和GPU之间的同步频繁调用会拖慢速度建议每几十轮记录一次而不是每轮都记。注意GPU上的随机种子设置和CPU不同torch.manual_seed只控制CPUGPU需要用torch.cuda.manual_seed。如果追求完全可复现两个都要设。10. 我踩过的坑与实战建议第一个坑是标签形状。我一开始把标签搞成(N,)模型输出是(N,1)BCEWithLogitsLoss没报错但算出来的损失不对训练了半天准确率上不去。后来打印形状才发现问题。现在我的习惯是数据准备阶段就统一成二维省得后面出幺蛾子。第二个坑是训练时误传了Sigmoid后的输出。有次我图省事直接调model(X_train)结果损失函数收到的是概率值而不是logits梯度计算完全错了。损失曲线看着在降但降到一个比较高的值就卡住了。排查方法很简单打印模型输出的最小值和最大值如果都在0到1之间说明过了Sigmoid如果范围超出0到1说明是logits。第三个坑是学习率设得太大。有次我设了1.0第一轮损失直接nan后面全是nan。当时不知道怎么回事后来查了文档才明白是梯度爆炸。现在我的习惯是先用小学习率跑通确认没问题再逐步调大。第四个坑是忘了optimizer.zero_grad()。PyTorch的梯度是累加的不清零的话梯度会越积越大参数更新幅度失控。这个错误很隐蔽因为损失曲线可能看着正常但模型效果很差。我现在写训练循环时zero_grad永远是第一步形成肌肉记忆了。关于阈值的选择0.5是默认值但不一定最优。如果正类样本很少比如欺诈检测可以把阈值调低到0.3提高召回率如果误报成本很高可以调到0.7提高精确率。阈值调优可以用验证集上的F1分数作为指标遍历0.1到0.9找到最佳值。最后分享一个调试技巧如果模型效果不理想先别急着调参把决策边界画出来看看。如果边界明显偏离数据分布的中心说明模型没训好如果边界穿过数据重叠区域但准确率还是低说明数据本身线性不可分该换模型了。可视化是最直观的排查手段比盯着损失数值有效得多。