ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手写线性回归:Python代码逐行拆解与深度学习入门

从零手写线性回归:Python代码逐行拆解与深度学习入门 很多刚开始接触深度学习的朋友第一道坎往往不是那些听着高大上的卷积神经网络反而是看起来最简单的线性回归。我刷“考研李哥深度学习”系列时也有同样的感受原理一听就懂公式一推就会但真到了打开编辑器写代码手却停在键盘上不知道从哪一行开始。网上教程要么是 sklearn 三行调包要么是密密麻麻的数学推导中间断层严重特别不友好。这篇文章就是我个人的“小白线性回归代码心得”不搞长篇大论的理论也不玩花活核心就是带着你用 Python 把线性回归从零手写一遍弄清楚每一行代码在干什么、为什么要这么写然后再过渡到框架用法。适合刚看完原理视频、准备动手敲第一行深度学习代码的同学也适合那些调包调得飞起但对底层实现始终没底的人。读完你至少能回答三个问题梯度下降到底怎么落到代码上的多维特征该怎么处理线性回归跟深度学习的连接点在哪1. 先别急着敲代码线性回归在深度学习中到底处于什么位置很多教程一上来就让你写代码但我觉得动手之前必须把“这东西在整个体系里算什么”这个问题搞清楚。否则代码写得再顺你也只是一个在敲背板而不是在学功夫的人。1.1 从数学直觉到代码逻辑线性回归要解决的问题非常朴素给定一堆数据点找到一条线或者一个超平面让这条线尽可能贴住这些点。数学上就是 y wx b多维度时写作 y XW b。这里的 w 是权重b 是偏置训练的过程就是不断调整 w 和 b让预测值和真实值的差距越来越小。关键在于“差距”怎么衡量。最常用的就是均方误差 MSE把所有样本的预测误差平方后取平均。为什么要平方两点考虑一是消除正负误差抵消的问题二是放大那些偏差大的样本让模型更关注“错得离谱”的点。这个损失函数选得好后续梯度下降才有明确方向。理解了这个逻辑代码里写loss np.mean((y_pred - y)**2)就不再是背公式而是顺着思路自然写出来的事。1.2 线性回归为什么是深度学习的必修第一课我见过不少同学嫌线性回归简单直接跳到 CNN 和 Transformer结果连反向传播的链式法则都捋不顺。线性回归本质上是“没有隐藏层的神经网络”它的前向传播、损失计算、梯度回传、参数更新这一整套流程和复杂神经网络是完全一致的。你现在把线性回归的代码写明白后面看到model.fit()里藏着的东西才不会觉得黑盒。说得再直白一点线性回归就是深度学习世界的“hello world”。它的数据集是自己就能造的模型结构简单到不可能出错但涵盖了机器学习最核心的训练闭环。这套闭环你现在不打扎实后面调 LSTM、调扩散模型出了问题根本定位不到是数据、梯度、学习率还是网络结构哪一环出了错。很多人训模型一遇 loss 不降就慌了神本质就是没在简单模型上建立过“排障直觉”。2. 做好这些准备工作之后的代码不会再让人抓狂工欲善其事必先利其器。这个环节主要解决三件事用什么语言、用什么工具、去哪里搞数据。很多人忽略这步直接复制网上的代码结果跑出一堆环境报错还以为是自己数学不行其实只是没做好环境隔离。2.1 环境配置与工具选型深度学习再怎么吹语言层面的答案基本是 Python这是生态决定的。你不会 Python 也能入门但每前进一步都要跟“调用接口”死磕体验极差。起码要会 numpy 的基础用法比如数组创建、矩阵运算、广播机制这些是手写模型的地基。环境用 Anaconda 最省心创建一个独立环境是基本操作conda create -n dl-basic python3.9 conda activate dl-basic pip install numpy matplotlib scikit-learn为什么不直接把包装进 base 环境我踩过太多次坑。不同项目的依赖版本经常打架今天装了个新包把 numpy 版本顶了明天另一个项目就莫名其妙跑不了。独立环境是隔离成本最低的手段这个习惯越早养成越好。另外推荐装一个代码诊断插件比如 VS Code 的 Pylance。新手写代码最常见的低级错误是括号不匹配、变量名拼写不一致、缩进混乱插件能在你按下运行键之前就标红提示。这不是偷懒而是把精力留给真正需要思考的算法逻辑。2.2 三种实现方式的取舍线性回归的代码实现大体有三条路手写 numpy 版、sklearn 调包版、PyTorch 版。我的建议是三条路都走一遍顺序不能乱。numpy 手写版逐行实现前向传播、损失函数、梯度下降建立对算法的直觉。sklearn 调包版几行代码出结果但你得知道它内部帮你干了什么。PyTorch 版从手写版平滑迁移到自动求导框架理解框架帮我们解决了什么问题。有些同学一上来就 PyTorch用了torch.nn.Linear和loss.backward()两个接口跑通了就觉得“我会了”。真要问他梯度是怎么传回来的偏置有没有跟着更新经常答不上来。我这篇就重点讲第一种和第三种因为这两条路能把脑子里的弯弯绕绕彻底走通。2.3 数据从哪来自己造数据才是最优解看别人的示例代码总有隔靴搔痒的感觉因为数据是别人的你不知道它的分布。自己造数据才能完全掌控全局。线性回归可以简单到只有一个特征这正好适合调试import numpy as np # 设置随机种子让每次运行结果可复现 np.random.seed(42) # 生成100个样本特征x在0到10之间均匀分布 X np.linspace(0, 10, 100).reshape(-1, 1) # 真实关系y 2x 1加上高斯噪声模拟真实场景 y 2 * X 1 np.random.randn(100, 1) * 1.5这里的reshape(-1, 1)很容易被忽略但极其重要。sklearn 和大部分矩阵运算要求输入是二维的形状是(样本数, 特征数)。如果你写成np.linspace(0, 10, 100)它是一维数组形状只有(100,)后续矩阵乘法直接维度报错。新手阶段有 80% 的报错都是因为形状不对学会随时打印X.shape能救你半条命。为什么加噪声因为现实世界就没有完全符合 y 2x 1 这种规则的数据总有一些无法解释的波动。噪声让模型学不到完美的 2 和 1但正好用来看训练过程的损失曲线是不是平滑下降到稳定值。如果你造的数据过于完美损失变成 0反而掩盖了模型的学习过程。3. 从零手写线性回归核心代码逐行拆解这章是全篇的重头戏。我先把完整代码贴出来然后一行一行讲清楚顺便把新手最容易掉进去的坑标出来。3.1 初始化参数起点决定了你会不会踩坑# 初始化权重为0偏置为0 w np.zeros((1, 1)) b np.zeros((1, 1))这里初始化为 0 在线性回归里没问题因为损失函数是凸函数从任何点出发最终都能收敛到全局最优。但如果你后面学了多层神经网络权重全 0 初始化会导致所有神经元输出相同、梯度相同模型退化成单神经元所以神经网络里一般用随机初始化。你现在养成“看一眼初始化方式”的习惯后面学深度学习会省很多事。另外这里的 w 和 b 我都保持成二维矩阵而不是一维数组就是为了让它在矩阵运算中不出现形状不匹配的情况。小白时期少点形状报错就多点学习耐心。3.2 前向传播与损失计算每一行的含义# 前向传播计算预测值 y_pred np.dot(X, w) b # 计算均方误差损失 loss np.mean((y_pred - y) ** 2)np.dot(X, w)就是矩阵乘法X 形状是 (100, 1)w 形状是 (1, 1)结果是 (100, 1)每一行代表一个样本的预测值。加上 b 就是 y Xw b 的向量化实现一次算完所有样本。有人会问为什么不用 for 循环一个个算因为向量化运算能把计算时间压缩几个数量级。你可以做个对比实验100 个样本 for 循环可能感觉不到差别但 100 万条数据差距就非常明显了。深度学习模型动辄千万级参数向量化是唯一的可行性方案。损失计算用np.mean而不是np.sum好处是梯度值不会随样本量增大而变大不同 batch size 之间的损失也具有可比性。很多框架的默认损失函数都是平均而不是求和正是在这一点上的长期实践结论。3.3 梯度计算整个深度学习体系的地基梯度就是损失函数对每个参数的偏导数。这一步是新手最懵的地方因为涉及到一些数学但落到代码上其实非常简洁# 计算梯度 dw np.dot(X.T, (y_pred - y)) / X.shape[0] db np.mean(y_pred - y)先说 dw 为什么会同时出现 X 的转置和差值。均值损失函数中 MSE 关于 w 的导数是 2 * (1/m) * X^T * (Xw b - y)前面的常数 2 通常被并入学习率所以代码里省略。X.T 的作用是把每个样本的特征和它的预测误差相乘然后对样本维度求和平均。这是矩阵求导的结论你现在不懂推导没问题但要记住这个形态特征转置点乘误差再除以样本量。从梯度公式可以读出很重要的信息梯度大小跟预测误差成正比误差大的样本对参数更新的贡献大。这就是模型“学习”的本质——它会被那些错得离谱的样本重点教育。理解了这一点你就能明白为什么数据里的离群点对线性回归影响这么大。3.4 参数更新学习率该怎么选# 设置学习率 lr 0.01 # 梯度下降更新参数 w - lr * dw b - lr * db“参数减去梯度乘以学习率”这个操作就是梯度下降的全部。为什么是减因为梯度指向的是损失增长最快的方向我们要往反方向走才能让损失变小。学习率是每一步迈多大。学习率的选择是手感活儿也是新手最先遇到的玄学问题。我自己的经验是学习率过大比如 0.5损失会震荡甚至飞掉打印出来是 inf 或 nan。学习率过小比如 0.0001损失下降非常慢训练半天感觉模型没动。先用 0.01 起步观察损失曲线的下降速度再按 10 倍步长调整。如果数据分布差异较大比如某个特征取值范围是 0~1另一个是 1000~100000不同方向的梯度尺度差异巨大用同一个学习率很难收敛。这就引出特征缩放的重要性后面专门讲。3.5 完整训练循环把上面所有代码串起来就是这样import numpy as np # 1. 造数据 np.random.seed(42) X np.linspace(0, 10, 100).reshape(-1, 1) y 2 * X 1 np.random.randn(100, 1) * 1.5 # 2. 初始化 w np.zeros((1, 1)) b np.zeros((1, 1)) lr 0.01 # 3. 训练 for epoch in range(1000): # 前向传播 y_pred np.dot(X, w) b # 损失计算 loss np.mean((y_pred - y) ** 2) # 梯度计算 dw np.dot(X.T, (y_pred - y)) / X.shape[0] db np.mean(y_pred - y) # 参数更新 w - lr * dw b - lr * db if epoch % 100 0: print(fEpoch {epoch}, Loss: {loss:.4f}, w: {w.item():.4f}, b: {b.item():.4f})跑完 1000 轮之后你会发现 w 逼近 2b 逼近 1损失稳定在噪声方差附近不会降到 0。这就是一个正常模型该有的样子能学到数据的整体趋势但无法复现每一个点的噪声波动。如果你看到 w 几乎精确等于 2、b 几乎精确等于 1反而要怀疑数据是不是造得太假了。真实场景中参数基本不会和理论值完全一致。4. 可视化与结果解读这些指标真的能说明模型好吗训练完代码之后不能直接收工要会看结果。很多人的模型训练完打印一个 loss 就以为万事大吉这其实错过了整个调试阶段最重要的信息来源。4.1 画两条曲线一眼看出学没学会第一个必画的图是数据点和拟合线的叠加图import matplotlib.pyplot as plt # 画出所有数据点的散点图 plt.scatter(X, y, alpha0.6, label真实数据) # 用训练好的参数画拟合线 plt.plot(X, np.dot(X, w) b, colorred, linewidth2, label拟合结果) plt.xlabel(X) plt.ylabel(y) plt.legend() plt.title(线性回归拟合效果) plt.show()如果红色的拟合线能穿在数据点的“中间带”上说明模型学到了主要趋势。如果拟合线明显偏离比如斜率太低说明欠拟合可能是学习率太小或者训练轮数不够。如果拟合线穿过了每一个点画出了一条剧烈弯曲的曲线说明过拟合但在线性回归这种简单模型里不太容易发生。第二个必画的是损失曲线也就是每个 epoch 的 loss 值loss_history [] for epoch in range(1000): # ...训练代码... loss_history.append(loss) plt.plot(range(len(loss_history)), loss_history) plt.xlabel(Epoch) plt.ylabel(MSE Loss) plt.title(训练损失下降曲线) plt.yscale(log) # 对数坐标更容易观察收敛趋势 plt.show()损失曲线本质上是模型学习过程的“心电图”。正常的曲线应该是指数式下降后趋于平缓。我常用的判断方法前 100 轮损失快速下降说明模型在抓住主要规律后面增速放缓说明进入精细调整阶段最后变成一条水平线说明模型已经收敛。如果你看到损失震荡、跳跃、甚至变大那就是学习率太大或者数据里有异常值。4.2 R² 到底是什么含义除了 MSE另一个绕不开的指标是 R²决定系数。它的公式是ss_res np.sum((y - y_pred) ** 2) ss_tot np.sum((y - np.mean(y)) ** 2) r2 1 - ss_res / ss_totR² 的含义是相比“直接用均值做预测”这个最粗糙的基准线我们的模型把误差减少了多少。R² 越接近 1说明模型解释掉了大部分方差R² 接近 0说明模型和直接猜均值差不多R² 为负说明模型比猜均值还烂基本是废了。这里有一个新手常犯的理解偏差R² 高不等于模型好。如果数据本身就一条直线R² 很容易到 0.99但这不代表你的模型聪明只是任务简单。反过来如果数据噪声很大R² 0.5 可能已经是很理想的结果。看指标一定要结合具体任务的“噪声底噪”。5. 多维特征、特征缩放与踩坑记录现实中绝大多数问题不会只靠一个特征预测。房价不只是面积还跟位置、楼层、房龄、朝向有关。特征升到多维之后代码层面几乎不用改但有两个新问题会突然冒出来。5.1 从单特征到多特征的代码升级假设现在有两个特征生成数据和训练的代码改动极少# 两个特征面积、房龄 X np.random.randn(100, 2) # 真实关系y 3*x1 - 2*x2 5 噪声 y 3 * X[:, 0:1] - 2 * X[:, 1:2] 5 np.random.randn(100, 1) # 初始化权重为 (特征数, 1) 的形状 w np.zeros((2, 1)) b np.zeros((1, 1)) for epoch in range(2000): y_pred np.dot(X, w) b loss np.mean((y_pred - y) ** 2) dw np.dot(X.T, (y_pred - y)) / X.shape[0] db np.mean(y_pred - y) w - lr * dw b - lr * db你仔细观察这跟单特征版本有什么不同——几乎没有。真正的核心逻辑全部被矩阵运算统一了。这就是向量化表示的魅力一套代码从 1 个特征到 1000 个特征结构都不变。w 的形状从(1, 1)变成(2, 1)仅此而已。5.2 特征缩放让梯度下降快如闪电多维特征带来的第一个真正的大问题是量纲不一致。比如面积范围是 50~200房龄范围是 1~50朝向可能是 0 或 1。这种情况下不同特征的梯度尺度差别巨大梯度下降会在山谷里来回震荡收敛极慢。解决办法是标准化Standardization把每个特征变成均值为 0、标准差为 1mean X.mean(axis0) std X.std(axis0) X_norm (X - mean) / std这个操作背后的几何意义是将损失函数的等高线从拉长的椭球变成近似的圆形。在圆的表面上做梯度下降每一步都指向圆心收敛路径几乎是直线。不做缩放路径会是锯齿状的“Z”字形可能要几千轮才收敛做了缩放几百轮就到了。为什么深度学习中这个操作更关键因为深层网络的参数互相作用更复杂输入尺度不一致的问题会被逐层放大。很多 “loss 不降” 的现场最终都是特征没归一化导致的。5.3 我的踩坑记录从 loss 涨到 10 亿开始的排查有次我在练手时随手把学习率从 0.01 改成了 0.5跑出来的 loss 直接变成 inf。当时第一反应是“代码写错了”结果一行一行查了很久也没发现问题。回头看问题就是学习率太大参数更新迈的步子太大直接越过了最优点误差变大后梯度也变大进一步跨得更远形成正反馈发散。第二个经典案例是数据没归一化时loss 一直缓慢下降但 5000 轮还没收敛到理想值。我也曾以为需要加更多训练轮数其实是因为两个特征的量纲差了 100 倍梯度下降一直在走锯齿路径。后来加了标准化几百轮就收敛了。所以说遇到 loss 问题不要急着怀疑代码逻辑。按这个顺序排查先打印每轮的 loss 看它是否单调下降再看学习率是否合理再看数据是否需要归一化最后才回头看梯度公式有没有写错。顺序反了你会白白浪费好几个小时。5.4 不得不提的正规方程除了梯度下降还有一个解析解法叫正规方程直接一步算出最优解# 在X前面拼接一列1对应偏置b X_b np.c_[np.ones((X.shape[0], 1)), X] # 正规方程(X^T X)^(-1) X^T y w_best np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y)它的原理是基于最小二乘的解析推导把损失函数对参数求导并令导数为零直接解线性方程组。优点是不用调学习率、不用迭代大样本时也不怕局部最优。缺点是当特征数很大时矩阵求逆的计算量呈立方增长而且如果特征之间存在高度相关性矩阵不可逆会导致计算失败。我建议小白先不要用正规方程当主力因为梯度下降的迭代过程才是深度学习的灵魂。正规方程可以作为验证手段——如果梯度和正规方程算出了一致的 w 和 b说明你的手写代码没有问题。6. 从线性回归迈向深度学习的过渡到此为止你已经完整实现了线性回归的全部核心流程。最后这部分我想告诉你这套东西和真正的深度学习之间其实只隔着一层窗户纸。6.1 线性回归和神经网络之间的关系拿 PyTorch 来说手写版和框架版的对应关系非常明显import torch import torch.nn as nn import torch.optim as optim # 定义模型一个没有激活函数的线性层 model nn.Linear(2, 1) # 定义损失函数和优化器 criterion nn.MSELoss() optimizer optim.SGD(model.parameters(), lr0.01) # 转成Tensor X_t torch.tensor(X, dtypetorch.float32) y_t torch.tensor(y, dtypetorch.float32) # 训练循环 for epoch in range(1000): optimizer.zero_grad() y_pred model(X_t) loss criterion(y_pred, y_t) loss.backward() optimizer.step()nn.Linear(2, 1)对应的就是手写代码里的 w 和 b。loss.backward()自动帮你算好了 dw 和 db。optimizer.step()对应的是参数更新。这里面最值得品味的是zero_grad()它相当于“清空上一次的梯度”。PyTorch 默认梯度是累积的不清零就会把多次反向传播的梯度叠加导致参数更新完全混乱。手写版没有这个问题是因为我们每次都在内存里直接重算 dw 和 db而框架为了效率默认累积历史梯度。这就是框架带来的“隐藏状态”不了解它你会被loss.backward()背刺得很惨。6.2 加一个激活函数世界豁然开朗如果在线性层后面加一个非线性激活函数比如 Sigmoid 或 ReLU模型就从一个纯线性函数升级成了能逼近任意非线性函数的万能逼近器。这就是深度学习的核心魔法——多层非线性变换的组合。理解这条演进路线的关键在于线性回归本身是一个“没有隐藏层的神经网络”它的表达能力受限于线性关系。你训练的模型在二维空间是一条直线在三维空间是一个平面但现实世界的关系往往不是一条直线能描述的。当你在线性层之间插入激活函数后模型有了弯曲数据的能力这就是从浅层模型到深层模型的质变。所以我的建议是踏踏实实把线性回归代码吃透然后朝这个方向扩展——线性层接激活函数再接线性层你就会发现自己已经能够理解并手写一个最简单的神经网络了。到那时候再回头看这篇心得你会有完全不同的体会。7. 写在最后的经验分享如果让我只留一条核心经验给刚开始学代码的朋友那就是手写一遍胜过看十遍。视频里老师讲得再清楚代码在你手里跑起来、报错、你修好、看到损失下降的那一刻知识才是真正属于你的。我见过太多人收藏了无数教程却没有一个跑通一直停在“看懂了”的错觉里。另一个小技巧是把这段手写代码保存好后面每学一个新模型就回到这份代码旁边对照一下。你会发现不管模型变得多复杂万变不离其宗——前向传播、算损失、回传梯度、更新参数这四步永远是骨架。从线性回归到逻辑回归从单层网络到 ResNet它们共享同一套发动机改的只是内部的零件而已。想清楚这层关系你后面学深度学习框架、读开源代码、自己搭模型都会觉得顺了很多。
返回列表