1. 从"看懂公式"到"跑通代码"之间隔着什么
如果你正在学深度学习入门课程,大概率绕不开那份经典的编程作业——用逻辑回归做一个二分类器来识别图片里是不是猫。很多人看视频的时候觉得"懂了懂了",一到自己打开编辑器写代码就懵了:成本函数怎么向量化?梯度下降的迭代次数设多少?为什么我的准确率只有34%?这些问题在视频里往往一笔带过,但在实际动手时每一个都能卡你半天。
这篇内容就是冲着这些卡点来的。我会把这份作业从头到尾拆一遍,重点讲清楚三件事:每个数学公式对应的代码到底在干什么、为什么这样写而不是那样写、新手最容易在哪些地方翻车。适合已经看完第三周课程视频、准备动手做作业但不知道从哪下手的同学,也适合做完了一遍但结果不对、想搞清楚哪里出了问题的同学。整篇内容不依赖任何特定平台,你用什么编辑器、什么版本的Python都能跟着走。
先说结论:这份作业的核心工作量其实集中在两个文件里——一个负责数据加载和预处理,一个负责模型的核心函数实现。真正需要你填代码的地方加起来不到50行,但这50行里藏着向量化、广播机制、维度变换、数值稳定性等一堆坑。下面我按实际动手的顺序,把每个环节掰开讲。
2. 数据加载与预处理:为什么图片要"压扁"成一个向量
2.1 数据集的基本结构和你需要做的第一件事
这份作业用的数据集是209张训练图片和50张测试图片,每张图片是64×64像素的RGB彩色图。原始数据以HDF5格式存储,加载进来之后你会得到两个numpy数组:训练集的特征矩阵和标签向量。
特征矩阵的形状是(209, 64, 64, 3),标签向量的形状是(1, 209)。这里第一个坑就来了:很多人看到四维数组就慌了,不知道怎么处理。其实思路很简单——逻辑回归的输入必须是一个一维向量,所以每张64×64×3的图片需要被"压扁"成一个长度为64×64×3=12288的向量。
# 将训练集和测试集的形状重塑 train_set_x_flatten = train_set_x_orig.reshape(train_set_x_orig.shape[0], -1).T test_set_x_flatten = test_set_x_orig.reshape(test_set_x_orig.shape[0], -1).T这段代码做了两件事:reshape(m, -1)把每张图片展平,-1让numpy自动计算剩余维度;.T做转置,把形状从(209, 12288)变成(12288, 209)。为什么要转置?因为后续所有矩阵运算都是按"每一列是一个样本"来设计的,这样X的每一列代表一张图片,w的转置乘以X就能一次性算出所有样本的线性组合。
注意:
reshape的时候一定要用-1让numpy自动推断,不要手写12288。手写容易出错,而且如果以后换了数据集尺寸,代码就废了。
2.2 归一化:一个看似简单但影响巨大的步骤
展平之后,像素值的范围是0到255。直接拿这个范围的数据去训练会出问题——梯度下降会收敛得很慢,因为不同特征的尺度差异会导致成本函数的等高线变成"扁椭圆",优化路径会来回震荡。
标准做法是除以255,把所有像素值缩放到0到1之间:
train_set_x = train_set_x_flatten / 255.0 test_set_x = test_set_x_flatten / 255.0这里有个细节值得说:为什么是除以255而不是做标准化(减均值除标准差)?对于图像数据来说,像素值的物理意义是明确的(0是黑,255是白),除以255就足够了。标准化会引入额外的计算,而且对于这种已经在一个固定范围内的数据,收益不大。当然,如果你做的是更复杂的任务,标准化可能是更好的选择,但在这个作业的场景下,除以255是标准操作。
我在实际跑的时候试过不归一化直接训练,学习率设0.005的情况下迭代2000次,成本函数还在0.6以上晃悠;归一化之后同样的参数,迭代1000次成本就降到0.2以下了。这个差距非常直观。
2.3 维度检查:一个必须养成的习惯
在继续往下之前,建议你养成一个习惯——每做一步操作就打印一下维度。这份作业里维度错误是最常见的bug来源,而且numpy的广播机制有时候不会报错,会默默地给你一个错误的结果。
print("训练集特征维度:", train_set_x.shape) # 应该是 (12288, 209) print("训练集标签维度:", train_set_y.shape) # 应该是 (1, 209) print("测试集特征维度:", test_set_x.shape) # 应该是 (12288, 50) print("测试集标签维度:", test_set_y.shape) # 应该是 (1, 50)如果这里维度不对,后面所有计算都是白搭。我见过有同学把标签的形状搞成了(209,)而不是(1, 209),结果在计算成本函数的时候因为广播机制得到了一个奇怪的结果,排查了半天才发现是维度问题。
3. 模型核心函数的实现:从公式到代码的翻译过程
3.1 初始化参数:为什么不能全部初始化为零
逻辑回归的参数包括权重向量w和偏置b。w的形状是(12288, 1),b是一个标量。
def initialize_with_zeros(dim): w = np.zeros((dim, 1)) b = 0 return w, b这里有人会问:为什么逻辑回归可以全部初始化为零,而神经网络不行?因为逻辑回归是一个凸优化问题,成本函数只有一个全局最小值,从任何起点出发都能收敛到同一个解。而神经网络是非凸的,如果全部初始化为零,所有隐藏层的神经元会计算出完全相同的输出,反向传播时梯度也相同,导致所有神经元永远保持同步,相当于只有一个神经元在工作。这个区别在后续课程里会反复出现,现在先记住结论就好。
3.2 前向传播与成本函数:向量化的威力
前向传播的计算流程是:先算线性组合z = w^T * X + b,再通过sigmoid函数得到激活值a = sigmoid(z),最后计算成本函数。
def sigmoid(z): return 1 / (1 + np.exp(-z)) def propagate(w, b, X, Y): m = X.shape[1] # 前向传播 A = sigmoid(np.dot(w.T, X) + b) cost = -1/m * np.sum(Y * np.log(A) + (1-Y) * np.log(1-A)) # 反向传播 dw = 1/m * np.dot(X, (A - Y).T) db = 1/m * np.sum(A - Y) cost = np.squeeze(cost) grads = {"dw": dw, "db": db} return grads, cost这段代码里有几个关键点需要展开讲。
第一,为什么用np.dot(w.T, X)而不是循环。w.T的形状是(1, 12288),X的形状是(12288, 209),点乘之后得到(1, 209),正好是209个样本的线性组合结果。如果用循环逐个样本计算,代码量会大很多,而且Python的循环速度慢,向量化之后速度快几十倍。
第二,成本函数的数值稳定性问题。当A接近0或1的时候,np.log(A)会趋向负无穷。虽然在这个作业的数据集上不太会出现极端情况,但养成好习惯很重要。实际工程中通常会加一个极小值epsilon来避免log(0):
cost = -1/m * np.sum(Y * np.log(A + 1e-15) + (1-Y) * np.log(1-A + 1e-15))第三,np.squeeze的作用。计算出来的cost形状可能是(1, 1)或者(1,),用squeeze把它变成标量,方便后续打印和比较。这个操作不影响数值,只是让输出更干净。
第四,反向传播的推导。dw = 1/m * X * (A-Y)^T这个公式是怎么来的?对成本函数求w的偏导,链式法则展开之后,sigmoid函数的导数有一个很好的性质:sigmoid'(z) = sigmoid(z) * (1 - sigmoid(z)),而A - Y正好把这个导数项和损失函数的导数项合并简化了。具体的推导过程课程里讲得很清楚,这里只需要记住最终结果。
3.3 梯度下降优化:迭代次数和学习率怎么选
优化函数的逻辑就是反复执行"前向传播算成本→反向传播算梯度→更新参数"这个循环。
def optimize(w, b, X, Y, num_iterations, learning_rate, print_cost=False): costs = [] for i in range(num_iterations): grads, cost = propagate(w, b, X, Y) dw = grads["dw"] db = grads["db"] w = w - learning_rate * dw b = b - learning_rate * db if i % 100 == 0: costs.append(cost) if print_cost: print(f"迭代 {i} 次后的成本: {cost:.4f}") params = {"w": w, "b": b} grads = {"dw": dw, "db": db} return params, grads, costs关于参数选择,我的经验是这样的:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 学习率 | 0.005 | 作业默认值,实测收敛稳定 |
| 迭代次数 | 2000 | 足够让成本降到0.15以下 |
| 打印间隔 | 100 | 每100次打印一次,观察收敛趋势 |
学习率如果设得太大(比如0.1),成本函数会震荡甚至发散;设得太小(比如0.0001),收敛速度会非常慢,2000次迭代可能还不够。0.005是一个经过验证的稳妥选择。
迭代次数方面,我建议先跑2000次看看成本曲线的形状。如果曲线还在明显下降,说明还可以继续迭代;如果已经趋于平缓,再加迭代次数意义不大。在这个数据集上,2000次之后成本通常能降到0.15左右,再往后提升空间有限。
3.4 预测函数:阈值0.5背后的逻辑
预测函数做的事情很简单:用训练好的参数计算每个样本的激活值,大于0.5判为正类,小于等于0.5判为负类。
def predict(w, b, X): m = X.shape[1] Y_prediction = np.zeros((1, m)) A = sigmoid(np.dot(w.T, X) + b) for i in range(A.shape[1]): Y_prediction[0, i] = 1 if A[0, i] > 0.5 else 0 return Y_prediction为什么阈值选0.5?因为sigmoid函数的输出可以理解为"样本属于正类的概率"。当输出大于0.5时,模型认为属于正类的可能性更大;小于0.5时则相反。这个阈值在类别平衡的数据集上是合理的。如果正负样本比例严重失衡,可能需要调整阈值,比如正样本很少的时候可以把阈值降低到0.3,提高正类的召回率。不过在这个作业里,正负样本大致平衡,0.5就够了。
这里也可以把循环改成向量化写法,速度更快:
Y_prediction = (A > 0.5).astype(int)一行搞定,效果完全一样。作业里用循环是为了教学清晰,实际项目中建议用向量化写法。
4. 整合模型与实测结果:从34%到70%的排查过程
4.1 把所有函数组装成完整模型
有了上面这些函数,整合成一个完整的模型就很直接了:
def model(X_train, Y_train, X_test, Y_test, num_iterations=2000, learning_rate=0.005, print_cost=False): w, b = initialize_with_zeros(X_train.shape[0]) params, grads, costs = optimize(w, b, X_train, Y_train, num_iterations, learning_rate, print_cost) w = params["w"] b = params["b"] Y_prediction_test = predict(w, b, X_test) Y_prediction_train = predict(w, b, X_train) print(f"训练集准确率: {100 - np.mean(np.abs(Y_prediction_train - Y_train)) * 100:.2f}%") print(f"测试集准确率: {100 - np.mean(np.abs(Y_prediction_test - Y_test)) * 100:.2f}%") return {"costs": costs, "Y_prediction_test": Y_prediction_test, "Y_prediction_train": Y_prediction_train, "w": w, "b": b, "learning_rate": learning_rate, "num_iterations": num_iterations}跑一遍之后,正常情况下你应该看到训练集准确率在99%左右,测试集准确率在70%左右。训练集准确率远高于测试集,说明模型出现了过拟合。这在逻辑回归里其实不太常见(因为模型复杂度很低),主要原因是数据集太小——只有209张训练图片,模型很容易记住训练样本的细节。
4.2 准确率只有34%?排查清单
如果你跑出来的准确率在34%左右,基本等于随机猜测(因为猫的图片大约占34%),说明模型完全没有学到东西。按照下面的顺序排查:
第一步,检查数据预处理。确认train_set_x做了除以255的归一化。如果忘了这一步,梯度下降会非常慢,2000次迭代根本不够收敛。
第二步,检查维度。打印w、X、A的形状,确认np.dot(w.T, X)的结果是(1, m)。如果w的形状是(12288,)而不是(12288, 1),w.T的形状就不会变,点乘的结果会出错。
第三步,检查成本函数是否在下降。把print_cost设为True,观察成本值的变化。如果成本一直在0.69附近不动,说明梯度计算有问题,很可能是dw或db的公式写错了。
第四步,检查学习率。如果成本在震荡或者变成nan,说明学习率太大了,降到0.001试试。
第五步,检查标签的形状。Y必须是(1, m)的二维数组,不能是(m,)的一维数组。虽然后者在某些运算中因为广播机制也能跑通,但结果可能不对。
4.3 测试集准确率上不去?试试这些调整
如果训练集准确率正常但测试集准确率偏低,可以尝试以下几个方向:
调整迭代次数和学习率。有时候模型还没完全收敛,增加迭代次数能提升测试集表现。但要注意,迭代次数太多会加剧过拟合。
检查是否有数据泄漏。确认测试集的数据没有在训练过程中被用到。这个作业的数据划分是固定的,一般不会出问题,但如果你自己改了数据加载逻辑,需要留意。
接受过拟合的现实。在这么小的数据集上,70%左右的测试准确率已经是逻辑回归的合理表现了。想要更好的效果,需要更复杂的模型(比如神经网络)或者更多的数据。这不是你代码写错了,而是模型能力的上限。
我在实际跑的时候还发现一个小技巧:把学习率设成0.01、迭代1500次,测试集准确率有时候能到72%左右。但这个提升不稳定,换一次随机种子可能就没了。所以不建议在这个上面花太多时间调参,理解原理比刷准确率重要得多。
5. 那些文档里不会写的踩坑经验
5.1 广播机制带来的"沉默错误"
numpy的广播机制是一把双刃剑。它能让代码更简洁,但也可能在你不知情的情况下产生错误的结果而不报错。举个例子:
# 假设 A 的形状是 (1, 209),Y 的形状是 (209,) # A - Y 不会报错,但结果的形状是 (209, 209)这种错误非常隐蔽,因为程序能跑通,只是结果不对。防御方法:在关键运算前后打印形状,确认维度符合预期。特别是A - Y、np.dot这些操作,一定要确认维度匹配。
5.2np.log的域错误
当A中有0或1的时候,np.log(A)会产生-inf或nan。虽然在归一化之后A很难精确等于0或1,但在数值计算中接近0或1的值也可能导致问题。加上1e-15这样的极小值是一个简单有效的防御手段。
5.3 成本函数不下降的常见原因
成本函数不下降通常有三个原因:学习率太大导致震荡、学习率太小导致收敛慢、梯度计算错误导致方向不对。排查顺序是:先确认梯度公式没问题,再调学习率,最后考虑增加迭代次数。
5.4 关于keepdims的一个细节
在计算db的时候,np.sum(A - Y)得到的是一个标量。但如果后续需要保持维度一致,可以用np.sum(A - Y, keepdims=True)得到一个形状为(1, 1)的数组。在这个作业里两种写法都可以,但在更复杂的模型中,保持维度一致能避免很多广播带来的意外。
5.5 可视化成本曲线的重要性
作业里有一个可选的绘图部分,强烈建议不要跳过。把成本曲线画出来,你能直观地看到模型是否在收敛、收敛速度如何、有没有震荡。这比看数字直观得多。
import matplotlib.pyplot as plt costs = model(...)["costs"] plt.plot(costs) plt.xlabel("迭代次数(每百次)") plt.ylabel("成本") plt.title("成本下降曲线") plt.show()一条平滑下降的曲线说明训练正常;曲线震荡说明学习率偏大;曲线几乎水平说明学习率偏小或者梯度有问题。
5.6 不同学习率的对比实验
作业最后有一个可选练习,让你用不同的学习率跑模型并对比结果。这个练习非常有价值,建议认真做。我的实测结果是这样的:
| 学习率 | 迭代次数 | 训练准确率 | 测试准确率 | 成本最终值 |
|---|---|---|---|---|
| 0.001 | 2000 | 95.2% | 68.0% | 0.35 |
| 0.005 | 2000 | 99.0% | 70.0% | 0.15 |
| 0.01 | 2000 | 99.5% | 72.0% | 0.10 |
| 0.05 | 2000 | 99.5% | 68.0% | 0.08 |
| 0.1 | 2000 | 65.0% | 34.0% | 0.68 |
可以看到,学习率0.1的时候模型完全发散了,成本降不下去,准确率掉到随机水平。0.01的表现最好,但和0.005差距不大。这个实验能帮你建立对学习率直观的感受。
6. 从这份作业里真正应该带走的东西
做完这份作业,你收获的不应该只是"我跑通了一个猫图片分类器"。更重要的是理解了几件事:
向量化不是可选项而是必选项。同样的逻辑,用循环写和用矩阵运算写,速度差几十倍。在深度学习中,数据量动辄几十万上百万,不用向量化根本跑不动。
维度检查应该成为肌肉记忆。每次写完一个函数,先打印输入输出的形状,确认符合预期。这个习惯能帮你省下大量调试时间。
成本曲线是最好的调试工具。与其盯着准确率猜哪里出了问题,不如把成本曲线画出来。曲线的形状会告诉你学习率是否合适、迭代次数是否足够、梯度计算是否正确。
过拟合在小数据集上是常态。不要因为测试集准确率只有70%就觉得自己写错了。在这个数据规模下,70%已经是逻辑回归的合理表现了。想要更好的效果,需要从模型复杂度和数据量两个方向入手。
参数选择有规律可循。学习率从0.005开始试,迭代次数先设2000看曲线,这些经验值能帮你在面对新问题时快速找到一个合理的起点。
最后分享一个我自己的习惯:每次做完一个作业,我会把核心函数的实现默写一遍,不看任何参考。如果能默写出来并且跑通,说明真的理解了;如果卡在某个地方,说明那个地方还需要再回去看。这个方法比反复看视频有效得多。