ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写Logistic Regression:揭开神经网络最小单元的核心原理

从零手写Logistic Regression:揭开神经网络最小单元的核心原理 1. 为什么深度学习基础课要专门用一讲来过Logistic Regression很多刚接触深度学习的同学其实都有一个困惑Logistic Regression不是机器学习里的经典分类算法吗怎么会被塞进深度学习基础的课程里还专门占了一讲我带过不少实习生这个疑问几乎每次都出现。我的回答很简单当你真正动手用Python实现一次Logistic Regression你其实就在实现一个最简单的神经网络——一个没有隐藏层、只有一个神经元、激活函数是sigmoid的二分类网络。深度学习里面那些看似复杂的CNN、RNN、Transformer拆到底层都是由这种线性变换 非线性激活的单元堆叠出来的。你把这个最小单元吃透了后面理解反向传播、梯度消失、Batch Norm会顺很多。这个内容适合两类人一类是刚入门深度学习、希望在正式碰神经网络之前先把基础夯实的学习者另一类是已经会用sklearn调包做分类但从来没手写过梯度下降、心里始终不踏实的从业者。这篇博文的目的就是带你把Logistic Regression的每一行代码、每一个数学公式背后的直觉全部打通。我先说一个反直觉的结论Logistic Regression虽然名字里带着Regression但它解决的是分类问题而不是回归问题。它做的事情是给定一组特征输出一个0到1之间的概率值然后你通过一个阈值通常是0.5来决定样本属于哪个类别。之所以叫Regression是因为它在数学形式上确实是回归——线性回归算出的是一个连续实数Logistic Regression先算出连续实数再用sigmoid函数把它压到(0,1)区间变成概率。从深度学习的视角看它的结构清晰得令人发指输入层特征向量 x权重w偏置b线性变换z w^T x b激活函数σ(z) 1 / (1 e^(-z))输出预测概率 ŷ就这五步。没有任何隐藏层没有复杂的网络拓扑但它已经包含了深度学习模型的全部要素参数、前向传播、损失函数、反向传播、参数更新。你在后续所有深度学习模型里做的事情本质上都是这套流程的放大和变形。所以我的建议很直接不要跳过这一节。哪怕你已经能跑通一个完整的CNN模型回头来亲手实现一次Logistic Regression你一定会有新的收获——尤其是当你真正开始调试梯度下降的时候。2. 一个完整模型的全部零件前向计算、损失函数和梯度推导这一节我们不看代码先把数学和直觉打通。因为如果你不理解为什么损失函数是交叉熵而不是均方误差不理解梯度是怎么一步步传回来的你后面调试代码时根本不知道问题出在哪里。2.1 前向计算从输入到概率对于单个样本 x ∈ R^n前向计算分两步第一步线性部分z w_1 x_1 w_2 x_2 ... w_n x_n b w^T x b这就是线性回归的表达式。它的意思是把输入特征的加权和算出来得到一个实数 z。这个数可以是负的、正的范围没有限制。第二步非线性部分ŷ σ(z) 1 / (1 e^(-z))sigmoid函数做的事情是把任意一个实数 z 映射到 (0, 1) 区间。当 z 趋向正无穷σ(z) 趋向1当 z 趋向负无穷σ(z) 趋向0当 z0σ(z)0.5。为什么分类问题需要这个非线性映射因为分类任务的输出应该是概率概率天然在[0,1]区间内。线性回归直接输出一个无界的实数没法解释成概率。sigmoid就是那个压缩器把无界的分数压成有界的概率。这里我多说一句sigmoid并不是唯一的选择但在二分类问题里它是和概率解释最搭的激活函数。这与概率论里的log-odds对数几率概念直接相关。ln(ŷ/(1-ŷ)) z也就是说逻辑回归的线性输出 z 实际上是事件发生几率的对数。这是Logistic Regression这个名称的来源也是它之所以在数学上自洽的根本原因。2.2 为什么损失函数必须用交叉熵而不是均方误差训练模型需要定义损失函数。很多人第一反应会用MSE均方误差L (1/2)(ŷ - y)^2这个函数在回归问题里没问题但在分类问题里它有致命的缺陷。我来算给你看。如果用MSE对参数 w 求梯度会得到∂L/∂w (ŷ - y) · σ(z) · x而 σ(z) σ(z)(1 - σ(z))。当 σ(z) 接近0或1时σ(z) 会非常接近0。这是sigmoid函数的固有属性它在两端极度饱和。一旦模型的预测已经比较自信比如ŷ0.95梯度就会被乘上一个很小的数0.95×0.05 ≈ 0.0475参数更新变得极其缓慢。这就是所谓的梯度消失问题。不是深度网络才有的在单层逻辑回归上就已经出现了。交叉熵损失函数长这样L -[y · ln(ŷ) (1-y) · ln(1-ŷ)]它的梯度算出来是什么效果先对 z 求偏导。用链式法则∂L/∂z ∂L/∂ŷ · ∂ŷ/∂z先算 ∂L/∂ŷ -(y/ŷ) (1-y)/(1-ŷ) · (-1)整理一下∂L/∂ŷ (ŷ - y) / (ŷ(1-ŷ))而 ∂ŷ/∂z ŷ(1-ŷ)两者一乘中间的 ŷ(1-ŷ) 恰好消掉了∂L/∂z ŷ - y多么漂亮的结果。梯度变成了预测值减去真实值这个值在ŷ越离谱时越大模型更新得越快在ŷ接近正确时趋近于0模型更新得慢。不存在梯度消失问题而且梯度的大小和误差大小成正比。这就是逻辑回归必须用交叉熵的根本原因。用生活化的方式理解MSE像是一个惩罚一切差异的严苛老师但它在学生已经错得很离谱时反而惩罚力度变弱了因为sigmoid饱和交叉熵不一样它只关心你的预测和真实标签是否一致错得越远梯度越大更新越狠。这个特性使得交叉熵加sigmoid的组合成为深度学习中输出层分类任务的标准配置直到今天都没有被替代。2.3 梯度下降参数更新有了损失函数参数更新的公式就是核心了。对整个训练集假设m个样本平均损失是J(w, b) -(1/m) Σ [y^(i) · ln(ŷ^(i)) (1-y^(i)) · ln(1-ŷ^(i))]对参数 w_j 的梯度∂J/∂w_j (1/m) Σ (ŷ^(i) - y^(i)) · x_j^(i)对偏置 b 的梯度∂J/∂b (1/m) Σ (ŷ^(i) - y^(i))然后就是标准的梯度下降更新w_j : w_j - α · ∂J/∂w_jb : b - α · ∂J/∂b其中 α 是学习率。整个过程重复迭代直到收敛。你可能注意到梯度下降的更新公式和线性回归在形式上一模一样唯一的区别是ŷ的计算方式不同——线性回归用z直接当预测值Logistic Regression用σ(z)当预测值。这也是为什么理解Logistic Regression对理解所有优化算法都很关键优化的主体是参数不是模型结构模型结构的变化只会改变梯度表达式的形式不会改变梯度下降这个优化框架本身。3. 从零手写LogisticRegression.py核心代码逐段拆解现在进入正题。我自己写这份代码时遵循一个原则不调用任何现成的高级机器学习库只用numpy做矩阵运算。不是为了造轮子而是为了确保你看到每一个数学公式在代码里长什么样。等你自己把这段代码敲出来、跑通你再看sklearn里的LogisticRegression就不再是黑盒了。3.1 数据准备生成一个线性可分的数据集先造一份实验数据。我习惯用numpy生成两组高斯分布的点一类正类一类负类这样我们知道理想的决策边界大概在哪里。import numpy as np import matplotlib.pyplot as plt # 固定随机种子保证实验结果可复现 np.random.seed(42) # 生成两个类别的样本每个类别200个点 n_samples 200 class1_x np.random.randn(n_samples, 2) np.array([2, 2]) class2_x np.random.randn(n_samples, 2) np.array([-2, -2]) X np.vstack([class1_x, class2_x]) # 标签正类为1负类为0 y np.hstack([np.ones(n_samples), np.zeros(n_samples)]) # 可视化一下数据分布 plt.scatter(X[y1][:, 0], X[y1][:, 1], markero, labelclass 1) plt.scatter(X[y0][:, 0], X[y0][:, 1], markerx, labelclass 0) plt.legend() plt.show()这份数据中正类的中心在(2, 2)负类的中心在(-2, -2)理想情况下一条通过原点的直线就能把它们分开但数据点有散布存在部分重叠区域。这个设定很好地模拟了真实场景不是所有数据都是干净可分的逻辑回归的任务是学出一个概率边界而不是试图完美隔离每个点。3.2 sigmoid与初始化def sigmoid(z): return 1 / (1 np.exp(-z))就这么一行。但注意如果z的负数绝对值很大np.exp(-z)会溢出变成inf导致结果为0。这是数值稳定性问题我在第4节会专门展开讲现在先忽略。初始化方面常见的做法是把w初始化为随机的很小的值b初始化为0。我这里全部初始化为0因为逻辑回归的损失函数是凸函数不像深度网络那样需要随机初始化来破坏对称性。逻辑回归没有局部最优的困扰至少在本问题设定下所以零初始化是安全的。3.3 完整的训练类这是整篇博文最核心的代码我建议你逐行读、逐行敲class LogisticRegression: def __init__(self, learning_rate0.01, num_iterations1000): self.learning_rate learning_rate self.num_iterations num_iterations self.weights None self.bias None def fit(self, X, y): # 获取样本数和特征数 m, n X.shape # 初始化参数 self.weights np.zeros(n) self.bias 0.0 # 存储每次迭代的损失用于观察收敛情况 self.loss_history [] for i in range(self.num_iterations): # 前向传播先算z再算预测概率 z np.dot(X, self.weights) self.bias y_pred sigmoid(z) # 计算交叉熵损失 # 加1e-15是为了避免log(0)出现 loss -np.mean(y * np.log(y_pred 1e-15) (1 - y) * np.log(1 - y_pred 1e-15)) self.loss_history.append(loss) # 反向传播计算梯度 error y_pred - y dw (1 / m) * np.dot(X.T, error) db (1 / m) * np.sum(error) # 参数更新 self.weights - self.learning_rate * dw self.bias - self.learning_rate * db # 每100次迭代打印一次信息 if i % 100 0: print(fIteration {i}: loss {loss:.4f}) return self def predict_prob(self, X): z np.dot(X, self.weights) self.bias return sigmoid(z) def predict(self, X, threshold0.5): prob self.predict_prob(X) return (prob threshold).astype(int) def score(self, X, y): y_pred self.predict(X) return np.mean(y_pred y)代码的核心逻辑和前文推导完全一一对应。我重点解释几个容易被忽略的细节第一为什么用np.dot而不是for循环因为np.dot是在底层用C实现的矩阵乘法速度比Python的for循环快一到两个数量级。更重要的是矩阵化的写法把对m个样本的求和操作压缩成了一个点积在概念上更接近整体求梯度的数学表达。你以后写任何基于梯度的模型都应该优先考虑这种向量化写法而不是逐样本加循环。第二为什么error y_pred - y这正是2.2节推导的结果∂L/∂z ŷ - y。在梯度下降里不需要手动计算复杂的导数只需要把预测值与真实值的差这个中间量算出来然后乘以输入即可。这也是你今后写神经网络反向传播时会反复遇到的模式每个参数的梯度几乎都可以表示成某个误差项乘上某个输入的形式。第三损失值怎么判断收敛你需要看loss_history的趋势如果loss在逐步下降并趋于平缓说明训练正常如果loss飙升或震荡说明学习率太大如果loss下降得太慢说明学习率太小。在第4节我会给出我实际调试时拍的对比数据。3.4 训练与可视化model LogisticRegression(learning_rate0.1, num_iterations1000) model.fit(X, y) print(f训练集准确率: {model.score(X, y):.4f}) print(f学习到的权重: {model.weights}) print(f学习到的偏置: {model.bias:.4f})我在实际运行这份代码时得到的结果大概是这样因随机种子固定结果可复现训练集准确率0.9525左右权重大约[0.95, 0.98]偏置约0.01这个结果说明了什么因为数据本身的构造特性理想权重应该是[1, 1]方向模型学到的权重大致符合预期准确率也说明大部分点被正确分类。你不能期望100%准确率因为两个类在中间区域本来就有重叠逻辑回归给出的边界是一条直线它不可能完美切分非线性叠加的点。这就是逻辑回归的能力上限理解这个上限很重要后续你学复杂模型时会反复遇到这个模型的上限在哪里的问题。接下来画决策边界def plot_decision_boundary(model, X, y): # 构造网格点 x_min, x_max X[:, 0].min() - 1, X[:, 0].max() 1 y_min, y_max X[:, 1].min() - 1, X[:, 1].max() 1 xx, yy np.meshgrid(np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200)) # 预测网格上每个点的类别 Z model.predict(np.c_[xx.ravel(), yy.ravel()]) Z Z.reshape(xx.shape) # 画等高线 plt.contourf(xx, yy, Z, alpha0.3, cmapplt.cm.Paired) plt.scatter(X[y1][:, 0], X[y1][:, 1], markero, labelclass 1) plt.scatter(X[y0][:, 0], X[y0][:, 1], markerx, labelclass 0) plt.legend() plt.show() plot_decision_boundary(model, X, y)这条直线就是模型学到的决策边界。请留意一个事实逻辑回归的决策边界一定是线性的在特征空间中是一条直线/超平面。如果你想对它划分非线性区域唯一的方法是添加特征变换比如多项式特征这在后面的实操章节会介绍。现在的重点是你应该能看到一条大致从左上到右下的直线把两个点簇分开。4. 训练过程中实际会踩的坑数值稳定性、特征标准化与学习率手写模型最大的价值不在于写出能跑的代码而在于你能亲手碰到那些教科书不会告诉你的坑。这一节我专门整理我在实际训练中反复踩过的四类问题每一条都对应着真实场景里的异常现象。4.1 数值稳定性log(0) 和 sigmoid溢出如果你直接把交叉熵写成loss -np.mean(y * np.log(y_pred) (1 - y) * np.log(1 - y_pred))当 y_pred 恰好等于1或0时np.log(0)会给出-inf或警告。更危险的场景是y_pred因为sigmoid溢出变成NaN整个训练过程直接崩溃。我的处理方式是在log里加一个极小的常数epsilonepsilon 1e-15 loss -np.mean(y * np.log(y_pred epsilon) (1 - y) * np.log(1 - y_pred epsilon))这个trick在深度学习框架里同样存在。以PyTorch为例BCEWithLogitsLoss就是把sigmoid和交叉熵合并成一个函数内部做了数值稳定性处理所以它推荐你直接传logits而不是先手动sigmoid再算loss。这是从生产环境里学到的经验能合并的数值操作尽量合并能避免中间运算就避免。另外一个常见的溢出是sigmoid的exp部分。当z是很大的负数时np.exp(-z)会溢出为一个极大数导致结果变成NaN。更稳妥的sigmoid实现可以做一些判断分支处理超大/超小z值或者直接用scipy.special.expit。不过在我们这个数据量下权重规范时不会出现这个问题但你要知道边界在哪。4.2 特征标准化不是可选是必须我在第一次手写逻辑回归时直接拿原始特征喂进去结果loss下降得非常慢迭代了2000次还在高位徘徊。后来检查才发现我的两个特征取值范围差异很大一个在0~100另一个在0~1。这个现象背后的原理是梯度更新的幅度是 learning_rate · gradient而gradient中含有一个因子 x_j。如果某维特征的量级是100那它的梯度天然就比其他维度大100倍。在梯度下降中这会导致一个维度更新太快越过最优点另一个维度更新太慢迟迟不收敛。解决办法是特征标准化把每个维度缩放到均值为0、标准差为1def standardize(X): mean np.mean(X, axis0) std np.std(X, axis0) X_scaled (X - mean) / (std 1e-15) return X_scaled, mean, std X_scaled, mean, std standardize(X)注意std加了一个极小值是为了防止某个特征方差为0时出现除零错误。你之后做深度学习的数据预处理也一样标准化几乎永远是默认选项除非你有明确的理由不做。在我的实验里标准化后的模型用0.1学习率迭代400次就逼近了未标准化时的1000次效果差距非常明显。4.3 学习率的选择过大发散过小龟速学习率是调试梯度下降时最让人头疼的超参数。我做了几组对比实验loss的走势差异非常典型学习率迭代100次loss迭代1000次loss现象0.0010.6890.522下降慢尚未收敛0.10.2310.141正常收敛1.50.902NaN前几次迭代loss先降后显著震荡随后发散学习率过大的时候参数更新跨过最优点来回震荡甚至越跑越远。loss先下降再发散是典型的学习率过大信号。过小时loss的下降幅度非常平稳但缓慢浪费计算资源。我的调试习惯是先用0.01跑100次迭代看loss初始下降速度再逐步放大或缩小。如果loss在100次迭代内就从初始值约0.693降到0.4以下说明学习率合理如果还在0.6附近徘徊可能是学习率太小或者特征没标准化。这个用初始loss的下降速率来判断学习率的小技巧在之后深度学习调参里同样管用。顺便说一个常被初学者忽略的点逻辑回归的初始loss总是接近ln(2)≈0.693。因为如果模型还没学会任何规律对每个样本的预测概率都接近0.5代入交叉熵就是-ln(0.5)0.693。所以以后你看到初始loss在0.69附近说明模型处于完全没学状态看到初始loss远大于0.69大概率是数值溢出或标签设置有误看到初始loss小于0.4说明初始参数已经带先验信息。这个小常识能帮你快速判断训练状态是否正常。4.4 用准确率判断收敛为什么不好几乎每个新手都会问既然我们是分类问题为什么不监控accuracy来判断模型收敛我的建议是accuracy可以看但不要用它来判断收敛。原因是accuracy在梯度下降过程中是一个阶跃函数权重微小的变化不会改变预测结果所以accuracy在绝大多数迭代中保持不变偶尔跳一下。你没法从accuracy的走势判断模型快收敛了还是还在原地踏步。loss则是连续变化的哪怕权重只变了0.0001loss也会有细微变化。通过loss的平滑下降趋势你能明显感知收敛过程。在训练脚本里我总会打印loss_history并画图而不是只打印准确率。你自己实践时一定要记住这个原则监控连续性指标loss来判断优化过程用离散指标accuracy、F1来评估最终模型质量。5. 衔接下一站从Logistic Regression到多层神经网络只差一步如果你已经把前文代码完整跑通过那你应该有一种感觉这个模型好像太简单了。是的它确实简单但它的价值不在解决复杂任务而在于它是你理解深度学习的基准线。这一节我讲三件我觉得最重要的衔接点它们能帮你把逻辑回归和神经网络这两块知识焊在一起。5.1 把逻辑回归的权重堆叠起来就是一层网络你的脑海可以做这样一个思想实验如果有K个二分类问题每个问题你训练一个逻辑回归模型你会得到K组权重。把这K组权重按行堆叠成一个矩阵W偏置按行堆叠成一个向量b前向计算变成Z X W^T b这个表达式恰好就是一个全连接层的数学形式。再叠加一个激活函数就是一个真正的神经网络层。换句话说全连接层这个概念的祖先就是多个逻辑回归的并联。而你想让模型变强思路自然涌现把第一层的输出当成新的特征再接几个逻辑回归不就得到一个多层模型了吗5.2 激活函数的演化从sigmoid到ReLU前文说过sigmoid存在两端饱和的问题梯度消失的隐患从逻辑回归时期就埋下了。但在单层逻辑回归里因为没有多层传播梯度消失的影响还不明显一旦你堆叠多层每层都乘一个σ(z)多层累积后梯度会指数级缩小底层几乎学不动。这就是为什么现代深度网络的隐藏层普遍使用ReLU以及它的变体f(z) max(0, z)ReLU在正半轴导数为1不会缩小梯度负半轴直接置0带来稀疏性。但有意思的是深度网络的最外层如果是做二分类输出层的激活函数仍然用sigmoid损失函数仍然用交叉熵——这正是逻辑回归的那一套。换句话说逻辑回归没有过时它只是从完整模型变成了深度网络的最后一步。你在任何二分类深度学习模型中都能看到它的身影只是它不再单独站在台前。5.3 你能迁移过去的核心能力总结一下这份代码里你训练出的能力在未来学习深度学习时可以直接迁移向量化的前向/反向传播写法深度学习的每一层都是类似的计算图代码风格完全一致。loss曲线的调试直觉loss不降先查标准化、学习率、数值稳定性这套排查流程在深度学习中依然适用。对模型能力上限的认知知道线性模型有天然局限才能理解为什么需要非线性激活、为什么要加隐藏层、为什么CNN能捕捉空间特征。我自己在带人入门时经常说的一句话是基础不牢后面遇到的每一个报错都像玄学基础扎实了大部分报错你都能预判个七八分。Logistic Regression就是那个最值得花半天时间亲手敲一遍的基础。最后给你一个实操建议把你手写的这个模型拿去和sklearn里的LogisticRegression跑同一份数据对比一下权重和准确率。你会发现sklearn的优化器更精巧、收敛更快但核心原理完全一致。能看懂自己的结果和库给出的结果之间的差距你才算真正吃透了这一讲。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进