从零实现逻辑回归:理解神经网络基础与梯度下降实战

发布时间:2026/8/28 15:57:48
从零实现逻辑回归:理解神经网络基础与梯度下降实战 1. 从零开始为什么逻辑回归是深度学习的“第一块砖”如果你刚开始接触吴恩达老师的深度学习课程学完第一周的理论面对第二周的编程作业“实现简单逻辑回归”时心里可能会犯嘀咕这听起来像是机器学习入门的内容和“深度”有什么关系我当年也有同样的困惑但真正动手实现一遍后才恍然大悟逻辑回归恰恰是理解神经网络一切奥秘的起点。你可以把它想象成乐高积木中最基础的那块2x4的砖。单看它功能简单就是实现一个二分类是/否猫/非猫。但它的内部结构——输入、权重、偏置、激活函数、损失计算、梯度下降——构成了神经网络单个神经元或者说感知机的完整工作流程。后续无论多么复杂的网络无论是10层、100层的全连接网络还是卷积神经网络CNN、循环神经网络RNN其最基本的计算单元其前向传播和反向传播的核心思想都脱胎于这个简单的逻辑回归模型。所以这个作业的目的绝不是让你“复习”机器学习。它的核心价值在于强迫你脱离高级框架如TensorFlow、PyTorch的“黑箱”用最原始的NumPy从数学公式到代码亲手搭建并训练一个“神经元”。这个过程会让你对“权重如何更新”、“损失如何计算并反向传播”、“梯度下降究竟在做什么”产生肌肉记忆般的理解。很多人在后续学习中出现“梯度消失”、“爆炸”等问题时感到抽象根源往往就在于跳过了这个亲手搭建的环节对数据流动和梯度计算缺乏直观感受。接下来我将带你一步步拆解这个作业不仅给出能跑通的代码更重要的是解释每一个变量为什么这样定义每一步计算背后的数学原理是什么以及我在初次实现时踩过的那些“坑”。我们会用识别猫的图片这个经典二分类任务作为贯穿始终的例子。你需要准备的是基本的Python编程知识、对NumPy数组操作有初步了解以及一份渴望理解底层原理的好奇心。2. 环境搭建与数据初探别在第一步就卡住在动手写模型之前一个干净、可复现的环境和清晰的数据认知是成功的基石。很多新手在这里就会遇到版本冲突、路径错误等问题导致兴致勃勃开始灰头土脸结束。2.1 构建专属的Python环境我强烈建议你使用conda或venv创建一个独立的虚拟环境。这能确保你使用的库版本与课程要求一致避免和系统中其他项目冲突。# 使用 conda 创建环境假设环境名为 dl_lr conda create -n dl_lr python3.7 -y conda activate dl_lr # 或者使用 venv python -m venv dl_lr_env # Windows 激活 dl_lr_env\Scripts\activate # Linux/Mac 激活 source dl_lr_env/bin/activate接下来安装核心依赖。吴恩达的课程作业通常基于 Jupyter Notebook并使用h5py来读取课程提供的.h5格式数据集。pip install numpy matplotlib jupyter h5py注意NumPy版本不宜过高。一些较新的NumPy版本在广播机制或函数接口上有细微变化可能导致与课程示例代码不兼容。使用pip install numpy1.19.5是一个比较稳妥的选择。2.2 理解你的数据猫 vs 非猫课程提供的数据集通常是一个名为train_catvnoncat.h5和test_catvnoncat.h5的文件。我们用h5py加载它并看看里面到底有什么。import h5py import numpy as np # 加载训练集 train_dataset h5py.File(datasets/train_catvnoncat.h5, r) train_set_x_orig np.array(train_dataset[train_set_x][:]) # 训练集特征 train_set_y_orig np.array(train_dataset[train_set_y][:]) # 训练集标签 # 加载测试集 test_dataset h5py.File(datasets/test_catvnoncat.h5, r) test_set_x_orig np.array(test_dataset[test_set_x][:]) # 测试集特征 test_set_y_orig np.array(test_dataset[test_set_y][:]) # 测试集标签 # 查看数据形状 print (训练集特征形状: str(train_set_x_orig.shape)) print (训练集标签形状: str(train_set_y_orig.shape)) print (测试集特征形状: str(test_set_x_orig.shape)) print (测试集标签形状: str(test_set_y_orig.shape))你可能会看到类似这样的输出训练集特征形状: (209, 64, 64, 3) 训练集标签形状: (209,) 测试集特征形状: (50, 64, 64, 3) 测试集标签形状: (50,)数据解读与第一个“坑”train_set_x_orig形状是(209, 64, 64, 3)。这代表有209张训练图片每张图片是64像素高、64像素宽、3个颜色通道RGB。train_set_y_orig形状是(209,)这是一个一维数组包含209个标签1代表“是猫”0代表“非猫”。这里的关键点我们的逻辑回归模型期望的输入是一个二维矩阵形状为(特征数量, 样本数量)。每个样本应该是一个特征向量而不是一个三维的图片块。所以我们需要把每张64x64x3的图片“展平”成一个长度为 64 * 64 * 3 12288 的向量。这就是所谓的“预处理”。2.3 数据预处理标准化与维度变换预处理包含两步展平和标准化。# 1. 展平数据 # 训练集样本数 m_train train_set_x_orig.shape[0] m_test test_set_x_orig.shape[0] # 每张图片展平后的特征数 num_px train_set_x_orig.shape[1] # 64 num_py train_set_x_orig.shape[2] # 64 num_channels train_set_x_orig.shape[3] # 3 train_set_x_flatten train_set_x_orig.reshape(m_train, -1).T test_set_x_flatten test_set_x_orig.reshape(m_test, -1).T print (训练集展平后形状: str(train_set_x_flatten.shape)) print (测试集展平后形状: str(test_set_x_flatten.shape)) # 输出应为: (12288, 209) 和 (12288, 50)为什么是.T(转置)这是本作业乃至后续神经网络实现中一个至关重要的约定也是容易混淆的点。展平后train_set_x_flatten的形状最初是(209, 12288)即每一行是一个样本的所有特征。但在后续的向量化计算中为了计算效率我们通常将权重w设计为(12288, 1)的列向量。那么w.T * x其中x是一个样本就需要x是列向量。更通用的做法是让数据矩阵X的每一列代表一个样本。所以我们需要转置得到(12288, 209)这样第i列就是第i个样本的12288维特征向量。这个约定在后续的Z w.T X b计算中会非常自然。# 2. 标准化数据 # 图片RGB像素值范围是0-255将其标准化到0-1区间有助于梯度下降更快更稳定地收敛。 train_set_x train_set_x_flatten / 255. test_set_x test_set_x_flatten / 255.至此我们的数据train_set_x和test_set_x已经准备好了形状分别是(12288, 209)和(12288, 50)。标签train_set_y和test_set_y保持为(1, 209)和(1, 50)的行向量形式后续需要调整。3. 逻辑回归模型的核心架构前向传播的数学到代码模型的核心就是两个函数前向传播计算预测值和损失和反向传播计算梯度并更新参数。我们先从前向传播开始把每一步的数学公式和Python代码对应起来。3.1 模型参数初始化小随机数的智慧逻辑回归有两个参数权重w(形状:(n_x, 1)) 和偏置b(一个标量)。n_x是特征数量这里是12288。为什么用随机初始化而不是全零对于逻辑回归其实初始化成全零是可以的因为只有一个神经元对称性破坏的问题不严重。但在神经网络中如果所有权重初始化为相同值包括全零那么每个神经元在反向传播时会获得完全相同的梯度导致所有神经元学习到相同的特征失去了网络的意义。因此养成随机初始化的习惯至关重要。我们使用很小的随机数如0.01是为了避免在激活函数如sigmoid的饱和区梯度接近0开始训练导致学习缓慢。def initialize_with_zeros(dim): 此函数创建一个维度为(dim, 1)的权重向量w并将b初始化为0。 参数: dim -- 权重向量w的大小本例中为特征数量 返回: w -- 初始化的权重向量 (dim, 1) b -- 初始化的偏置标量 w np.random.randn(dim, 1) * 0.01 # 使用小随机数 b 0.0 # 为了确保w和b的数据类型正确我们使用assert来检查 assert(w.shape (dim, 1)) assert(isinstance(b, float) or isinstance(b, int)) return w, b3.2 Sigmoid函数将线性输出映射为概率线性计算z w.T * x b的结果是一个实数范围是负无穷到正无穷。我们需要一个函数将其映射到(0, 1)区间代表“是猫”的概率。这个函数就是Sigmoid。公式σ(z) 1 / (1 e^(-z))它的输出特性完美符合概率要求当z很大时σ(z)接近1当z很小时σ(z)接近0当z0时σ(z)0.5。def sigmoid(z): 计算z的sigmoid值。 参数: z -- 一个标量或numpy数组。 返回: s -- z的sigmoid结果。 s 1 / (1 np.exp(-z)) return s实操心得np.exp(-z)在z是一个非常小的负数时比如 -1000可能会因为数值下溢而得到0但这通常不会导致问题因为1/(10)1。反之如果z是一个非常大的正数比如1000np.exp(-z)会下溢为0计算1/(10)1也是正确的。NumPy能很好地处理这些极端情况。但在自己实现时意识到这些数值稳定性问题是有益的。3.3 前向传播计算预测值与损失前向传播包含两步计算线性输出和激活值预测值Z w.T X b向量化后X形状为(n_x, m) 所以Z形状为(1, m)A σ(Z) A就是我们对每个样本的预测概率ŷ形状也是(1, m)。计算损失代价我们使用交叉熵损失函数。对于单个样本L(a, y) -[y*log(a) (1-y)*log(1-a)]。对所有样本取平均得到代价函数J。为什么用交叉熵损失而不是均方误差MSE这是一个关键选择。对于分类问题特别是输出经过Sigmoid激活后MSE损失函数会是非凸的存在很多局部最小值不利于梯度下降优化。而交叉熵损失是凸函数能保证梯度下降找到全局最优解对于逻辑回归。从信息论角度看它衡量了预测分布与真实分布的“距离”。def propagate(w, b, X, Y): 实现前向传播和反向传播计算代价和梯度。 参数: w -- 权重形状为 (n_x, 1) b -- 偏置一个标量 X -- 输入数据形状为 (n_x, m) Y -- 真实标签形状为 (1, m) 返回: cost -- 逻辑回归的交叉熵代价 dw -- w的损失梯度形状与w相同 db -- b的损失梯度形状与b相同 m X.shape[1] # 样本数量 # 前向传播 Z np.dot(w.T, X) b # Z形状: (1, m) A sigmoid(Z) # A形状: (1, m) 即预测值 ŷ # 计算代价交叉熵损失 # 这里使用了向量化操作避免for循环。注意np.log可能对0或1输入产生警告但我们的A理论上不会精确等于0或1。 cost - (1/m) * np.sum(Y * np.log(A) (1 - Y) * np.log(1 - A)) # 反向传播计算梯度 dZ A - Y # 这是Sigmoid激活函数和交叉熵损失结合后推导出的优美结果形状: (1, m) dw (1/m) * np.dot(X, dZ.T) # 形状: (n_x, 1) db (1/m) * np.sum(dZ) # 标量 # 使用断言确保梯度形状正确 assert(dw.shape w.shape) assert(db.dtype float) # 将代价从数组转换为标量如果它是数组的话 cost np.squeeze(cost) assert(cost.shape ()) # 将梯度和代价存入字典 grads {dw: dw, db: db} return grads, cost反向传播公式推导的直观理解dZ A - Y这个公式是核心。它表示预测概率A与真实标签Y之间的误差。对于第i个样本如果y1而a很小预测错误那么dz就是一个很大的负数意味着需要大幅调整参数。dw和db就是这个误差dZ对参数w和b的“贡献”的平均值。np.dot(X, dZ.T)实现了对所有样本梯度的向量化求和效率远高于for循环。4. 优化器梯度下降的迭代艺术有了计算代价和梯度的函数我们就可以用梯度下降法来迭代优化参数w和b使代价J最小化。4.1 梯度下降的更新规则公式w w - α * dwb b - α * db其中α是学习率控制每次参数更新的步长。4.2 实现优化循环我们将前向传播、反向传播和参数更新封装到一个优化函数中。def optimize(w, b, X, Y, num_iterations, learning_rate, print_costFalse): 此函数通过梯度下降算法优化w和b 参数: w -- 权重形状为 (n_x, 1) b -- 偏置一个标量 X -- 输入数据形状为 (n_x, m) Y -- 真实标签形状为 (1, m) num_iterations -- 优化迭代次数 learning_rate -- 学习率梯度下降的步长 print_cost -- 每100次迭代打印一次损失值 返回: params -- 包含优化后的w和b的字典 grads -- 包含最后一次迭代的梯度的字典 costs -- 记录每次迭代损失值的列表用于绘图 costs [] for i in range(num_iterations): # 计算当前参数下的梯度和代价 grads, cost propagate(w, b, X, Y) # 从grads字典中取出梯度 dw grads[dw] db grads[db] # 梯度下降更新规则 w w - learning_rate * dw b b - learning_rate * db # 每100次迭代记录一次代价 if i % 100 0: costs.append(cost) if print_cost: print (第 %i 次迭代后的损失值: %f %(i, cost)) # 将最终参数和梯度存入字典 params {w: w, b: b} grads {dw: dw, db: db} return params, grads, costs学习率的选择与第二个“坑”学习率α是梯度下降中最重要的超参数之一。太大如1.0代价函数可能会震荡甚至发散太小如0.000001收敛速度会极慢。对于这个猫分类数据集经过实践0.005或0.01是一个不错的起点。在optimize函数中我们记录了每100次迭代的损失并可以打印出来。务必观察损失曲线的变化一个健康的训练过程损失值应该随着迭代平稳下降最终趋于平缓。如果损失值上下跳动或上升很可能意味着学习率太大了。4.3 预测与模型评估训练完成后我们需要用学到的参数w和b对新的数据进行预测并评估模型在训练集和测试集上的准确率。def predict(w, b, X): 使用学习到的逻辑回归参数w, b预测X的标签 参数: w -- 权重形状为 (n_x, 1) b -- 偏置一个标量 X -- 输入数据形状为 (n_x, m) 返回: Y_prediction -- 包含X中所有样本预测结果0/1的numpy数组形状为(1, m) m X.shape[1] Y_prediction np.zeros((1, m)) w w.reshape(X.shape[0], 1) # 确保w形状正确 # 计算预测概率A A sigmoid(np.dot(w.T, X) b) # 将概率转换为0/1预测 # 方法1: 使用for循环直观但慢 # for i in range(A.shape[1]): # if A[0, i] 0.5: # Y_prediction[0, i] 1 # else: # Y_prediction[0, i] 0 # 方法2: 向量化操作推荐速度快 Y_prediction (A 0.5).astype(int) return Y_prediction评估准确率很简单就是比较预测值Y_prediction和真实标签Y相等的比例。def model(X_train, Y_train, X_test, Y_test, num_iterations2000, learning_rate0.005, print_costFalse): 构建完整的逻辑回归模型 参数: X_train -- 训练集形状为 (n_x, m_train) Y_train -- 训练标签形状为 (1, m_train) X_test -- 测试集形状为 (n_x, m_test) Y_test -- 测试标签形状为 (1, m_test) num_iterations -- 迭代次数 learning_rate -- 学习率 print_cost -- 是否打印训练过程中的损失 返回: d -- 包含模型信息的字典 # 1. 初始化参数 w, b initialize_with_zeros(X_train.shape[0]) # 2. 梯度下降优化 params, grads, costs optimize(w, b, X_train, Y_train, num_iterations, learning_rate, print_cost) # 3. 获取训练好的参数 w params[w] b params[b] # 4. 在训练集和测试集上进行预测 Y_prediction_train predict(w, b, X_train) Y_prediction_test predict(w, b, X_test) # 5. 计算准确率 train_accuracy 100 - np.mean(np.abs(Y_prediction_train - Y_train)) * 100 test_accuracy 100 - np.mean(np.abs(Y_prediction_test - Y_test)) * 100 # 打印结果 print(训练集准确率: {:.2f} %.format(train_accuracy)) print(测试集准确率: {:.2f} %.format(test_accuracy)) # 将结果存入字典 d {costs: costs, Y_prediction_train: Y_prediction_train, Y_prediction_test: Y_prediction_test, w: w, b: b, learning_rate: learning_rate, num_iterations: num_iterations} return d5. 模型训练、分析与可视化读懂你的模型现在让我们把所有部分组合起来训练模型并分析结果。# 调用模型函数 d model(train_set_x, train_set_y, test_set_x, test_set_y, num_iterations2000, learning_rate0.005, print_costTrue)运行后你可能会看到类似下面的输出第 0 次迭代后的损失值: 0.693147 第 100 次迭代后的损失值: 0.584508 第 200 次迭代后的损失值: 0.466949 ... 第 1800 次迭代后的损失值: 0.058389 第 1900 次迭代后的损失值: 0.053441 训练集准确率: 99.04 % 测试集准确率: 70.00 %5.1 解读训练结果过拟合与欠拟合这是一个非常典型的结果训练集准确率很高99%说明模型已经很好地记住了训练数据。测试集准确率较低70%说明模型的泛化能力一般。模型从训练数据中学到的规律不能完美地推广到没见过的测试数据上。这指向了机器学习中的一个核心问题过拟合。我们的模型一个简单的逻辑回归可能过于复杂相对于这个小数据集或者更常见的是我们使用的特征12288个像素值对于区分猫和非猫来说并不是最有效的。原始像素包含大量冗余和噪声信息。在深度学习中我们会使用卷积神经网络CNN来自动学习更高级、更鲁棒的特征从而大幅提升性能。但在这个入门作业中70%左右的测试准确率是符合预期的它清晰地展示了使用原始像素的线性分类器的局限性。5.2 可视化学习过程绘制损失曲线是诊断训练过程是否健康的重要手段。import matplotlib.pyplot as plt # 绘制损失曲线 costs np.squeeze(d[costs]) plt.plot(costs) plt.ylabel(cost) plt.xlabel(iterations (per hundreds)) plt.title(Learning rate str(d[learning_rate])) plt.show()你应该看到一条随着迭代次数增加而单调下降的曲线最终趋于平缓。如果曲线震荡说明学习率太大如果下降极其缓慢说明学习率太小。5.3 学习率的影响实验为了深刻理解学习率的作用我们可以进行一个简单的实验用不同的学习率训练模型并观察损失曲线的变化。learning_rates [0.01, 0.005, 0.001, 0.0005] models {} for lr in learning_rates: print (使用学习率: str(lr)) models[str(lr)] model(train_set_x, train_set_y, test_set_x, test_set_y, num_iterations1500, learning_ratelr, print_costFalse) print (\n ------------------------------------------------------- \n) # 绘制不同学习率下的损失曲线 for lr in learning_rates: plt.plot(np.squeeze(models[str(lr)][costs]), labelstr(lr)) plt.ylabel(cost) plt.xlabel(iterations (per hundreds)) plt.legend(locupper right, shadowTrue) plt.title(不同学习率下的损失曲线) plt.show()通过这个对比图你可以直观地看到学习率过大如0.01损失曲线可能震荡甚至无法收敛代价上升。学习率过小如0.0005损失下降非常缓慢需要更多迭代才能达到相同的效果。学习率适中如0.005损失平稳快速下降。这个实验虽然简单但它体现了深度学习调参的基本方法论通过控制变量观察模型性能损失、准确率的变化从而找到合适的超参数。6. 调试与常见问题排查当你代码不工作时第一次实现时几乎一定会遇到各种错误。下面是我总结的几个最常见的“坑”及其解决方法。6.1 维度不匹配错误这是NumPy向量化操作中最常见的错误。错误信息通常是ValueError: shapes (a,b) and (c,d) not aligned。排查清单检查X,Y,w,b的初始形状。X_train应该是(n_x, m_train)例如(12288, 209)。检查你是否做了转置.T。Y_train应该是(1, m_train)例如(1, 209)。如果原始标签是(209,)需要用Y Y.reshape(1, -1)进行重塑。w应该是(n_x, 1)。b应该是一个标量float。在propagate函数中逐行检查矩阵运算的维度。Z np.dot(w.T, X) bw.T是(1, n_x)X是(n_x, m)点积结果是(1, m)。b通过广播机制加到每个元素上。dZ A - YA和Y都必须是(1, m)。dw (1/m) * np.dot(X, dZ.T)X是(n_x, m)dZ.T是(m, 1)点积结果是(n_x, 1)与w形状一致。db (1/m) * np.sum(dZ)对(1, m)的dZ求和得到一个标量。调试技巧在函数的关键步骤后使用print(X.shape)打印数组形状确保与你的预期一致。6.2 损失值为 NaN 或无限大这通常是由于数值计算问题引起的。检查 Sigmoid 函数的输入Z。如果Z的值非常大正或负np.exp(-z)可能会溢出得到inf或下溢得到0。虽然NumPy的sigmoid实现通常能处理但如果你自己写的sigmoid没有做数值稳定处理就可能出问题。确保使用1 / (1 np.exp(-z))。检查交叉熵损失计算中的log。当预测概率A非常接近0或1时np.log(A)或np.log(1-A)可能得到负无穷-inf。一个常见的稳定化技巧是“裁剪”# 在计算损失前将A限制在一个很小的范围内如[1e-15, 1-1e-15] A np.clip(A, 1e-15, 1 - 1e-15) cost - (1/m) * np.sum(Y * np.log(A) (1 - Y) * np.log(1 - A))检查学习率。过大的学习率可能导致参数更新步伐太大使得损失“爆炸”成NaN。尝试将学习率降低一个数量级例如从0.1降到0.01。6.3 模型性能不佳准确率接近50%如果训练集和测试集准确率都只在50%左右徘徊说明模型没有学到任何东西相当于随机猜测。检查数据预处理。确保标签Y是正确的0或1。确保数据标准化/255.已经完成。检查参数初始化。确保权重w是用小随机数初始化的如np.random.randn(dim,1)*0.01。如果初始化为全零对于逻辑回归虽然可以工作但养成好习惯很重要。如果初始值太大可能导致Sigmoid饱和梯度消失学习停滞。检查梯度计算是否正确。这是最可能的原因。实现一个梯度检查函数是深度学习入门阶段的必备技能。梯度检查通过数值方法使用极限定义近似计算梯度并与你反向传播计算的解析梯度进行比较。如果两者差异很大说明你的反向传播实现有bug。由于篇幅所限梯度检查的实现细节较多但其核心思想是对于每个参数θ计算J(θε) - J(θ-ε) / (2ε)作为数值梯度并与你的dθ比较。相对误差应在1e-7量级。检查学习率和迭代次数。学习率可能太小或者迭代次数不够。尝试增加num_iterations到5000或10000观察损失是否还在下降。7. 从逻辑回归到神经网络思维的跃迁完成这个简单的逻辑回归实现其意义远不止于完成一次作业。它为你搭建了理解神经网络的完整思维框架。单个神经元就是逻辑回归你现在实现的就是一个没有隐藏层的神经网络。输入层12288个神经元直接连接到输出层1个神经元使用Sigmoid激活。前向传播的通用模式无论网络多复杂前向传播都是Z[l] W[l]A[l-1] b[l],A[l] g[l](Z[l])的重复。你刚刚实现了l1的这一层。反向传播的基石你推导并实现了dZ A - Y以及dw和db的计算。在多层网络中反向传播无非是从输出层开始将这个误差一层层往回传递利用链式法则计算每一层参数的梯度。你现在的理解是解开反向传播黑盒的第一把钥匙。向量化的重要性你体验了用np.dot代替for循环带来的效率提升。在真正的深度网络中面对百万级的数据向量化是唯一可行的道路。当你后续学习真正的神经网络时你会看到初始化从initialize_with_zeros扩展到He或Xavier初始化。激活函数从Sigmoid扩展到ReLU,Tanh。损失函数从二分类交叉熵扩展到多分类交叉熵、均方误差等。优化器从基础的梯度下降扩展到Momentum,RMSprop,Adam。但万变不离其宗核心的数据流动前向/反向、梯度计算、参数更新的逻辑你已经在这次“简单”的逻辑回归实现中亲手搭建并运行过了。这份从零开始构建的体验是直接调用model.fit()所无法替代的。它赋予你一种“手感”和“直觉”让你在后续面对更复杂的模型和更诡异的问题时能有章法地进行分析和调试而不是停留在盲目调参的层面。这就是这个作业这块深度学习的“第一块砖”真正想交给你的东西。

相关新闻