news 2026/10/22 22:02:30

从零手写线性回归:梯度下降、特征标准化与模型评估全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写线性回归:梯度下降、特征标准化与模型评估全流程解析

李哥项目班mylinear代码解析

项目班前段时间留了个叫 mylinear 的练习代码,要求很简单:不允许调用现成的机器学习库,从零实现一个线性回归模型,能训练、能预测、能评估。刚拿到题目时,不少同学觉得这有什么难的,sklearn 一行就能搞定。但真把代码一行一行写下来才发现,模型、损失、梯度、学习率、标准化、收敛判断,每一步都有坑。这篇解析是我对这份练习代码的完整复盘,适合正在学机器学习基础、想搞清楚线性回归底层原理、或者被各种框架封装搞晕了的人。读完你不仅能看懂 mylinear,还能自己改造成逻辑回归、mini-batch 版本。

1. 项目班布置这个练习,到底想让你练什么

1.1 为什么是"手写"而不是直接用现成模型

先说结论:这个练习的核心目的不是让你造一个比sklearn更好的线性回归,而是让你理解训练过程到底发生了什么。

很多初学者用 sklearn 的 LinearRegression 时,流程永远是 fit 一下、 predict 一下、 score 一下,三行代码结束。模型内部怎么求解参数的、损失函数长什么样、梯度从哪里来,全被封装成了黑盒。遇到稍微复杂一点的问题,比如数据没标准化导致不收敛、学习率设置不合理导致 loss 爆炸,就只能靠猜,没有任何排查思路。

mylinear 这个项目名的含义就在"my"上——我自己的线性回归。它不依赖机器学习高级框架,只允许用 numpy 这样的基础库(有些同学甚至用了纯 Python 列表实现)。老师的意思是:把线性回归从数学公式到代码实现的所有链路亲手走一遍,把参数更新、损失计算、梯度下降这些概念变成自己能写出来的代码。

做完之后回头看,这个练习的价值不在代码量,而在思维模型的建立。以后再接触任何机器学习的包,你都会习惯性地想:它内部大概率也是这套流程,只是加了工程优化和自动求导。

1.2 mylinear 的整体工程结构

先说代码的大框架。一个完整的 mylinear 代码不是只有一个训练函数,它应该至少拆成几个模块,哪怕是在一个文件里用函数或类来组织。项目班的标准版本大致是这样划分的:

模块职责核心函数/类
数据准备生成或加载数据、切分训练集和测试集load_data / make_data / train_test_split
数据预处理特征标准化,让不同量纲的特征处于同一尺度StandardScaler / normalize
模型定义线性模型参数与预测逻辑LinearRegression 类
训练流程损失计算、梯度计算、参数更新循环fit / loss / gradient_descent
评估用测试集计算指标,判断模型好坏predict / score / r2_score

这个拆法看起来很常规,但它对应的是一个标准机器学习项目的完整生命周期。很多人在写 mylinear 时只写模型类,把数据准备和评估全跳过,这会导致后面调试时无法判断是数据问题还是模型问题。我建议就算是最简版本,也要保证数据生成、训练、评估三个环节齐全。

1.3 这个练习隐藏的知识点地图

mylinear 虽然代码量不大,但知识点密度很高。梳理一下你需要提前掌握的基础:

  • 线性代数部分:特征矩阵 X 的形状是 (n_samples, n_features),预测值本质上是矩阵乘法 X @ weights 加上偏置 bias。理解 numpy 点积和广播机制是必须的。
  • 微积分部分:对 MSE 损失函数求关于权重 w 和偏置 b 的偏导数,这是梯度下降的基础。好在线性回归的导数推导比较直观,不需要复杂的链式法则。
  • 数值优化部分:梯度下降的迭代公式、学习率 lr 的物理含义、收敛条件。
  • Python 工程部分:面向对象封装、numpy 向量化、训练过程的日志记录。

这些知识点如果单独学,每一块都很抽象。但通过 mylinear 把它们串起来之后,你会发现它们其实是同一条链路上的环节:用线性代数算预测值,用微积分算梯度,用优化方法更新参数,用工程手段把流程自动化。

2. 数据准备与预处理:训练代码跑偏的源头往往在这里

2.1 没有真实数据,就自己造一份带噪声的线性数据

项目班练习的常见做法是先用合成数据验证代码正确性,再用真实数据做扩展。合成数据的好处是,我们预先知道真实的权重和偏置,可以对比模型学出来的参数与真实参数是否接近,从而判断代码有没有 bug。

一般的造数据逻辑是这样:

import numpy as np def make_data(n_samples=200, n_features=2, noise=0.5, random_seed=42): rng = np.random.default_rng(random_seed) X = rng.uniform(low=-10, high=10, size=(n_samples, n_features)) true_w = np.array([2.5, -1.8]) true_b = 0.6 y = X @ true_w + true_b + rng.normal(0, noise, size=n_samples) return X, y, true_w, true_b

这里的核心点是 noise 参数。它模拟真实场景中不可避免的观测误差,让 y 不完全是 X 的线性函数。噪声越小,训练出来的权重越接近 true_w;噪声越大,拟合难度越高,loss 的最终值也不会降到 0。

初次写 mylinear 时,建议把 noise 设小一点,比如 0.2 左右。先把全链路跑通,确认权重都能学出来,再加噪声观察模型容忍度,这个过程能帮你理解"模型能解释到什么程度"这个问题。

2.2 标准化:容易被忽略但极其关键的一步

很多初学者写 mylinear 时,数据生成完直接丢给训练函数跑。如果特征的取值范围都差不多,比如都在 -10 到 10 之间,那问题不大。但一旦特征量纲差异很大,比如一列是 0 到 1,另一列是 1000 到 10000,梯度下降就会表现得非常奇怪。

原因可以用等高线图来理解。未经标准化时,损失函数的等高线是狭长的椭圆形,梯度方向会沿着陡峭的轴来回振荡,导致收敛极慢;标准化之后,等高线接近正圆形,梯度方向能基本对准中心,参数可以更快收敛。这在数学上叫特征缩放,代码实现并不复杂:

class StandardScaler: def __init__(self): self.mean = None self.std = None def fit(self, X): self.mean = X.mean(axis=0) self.std = X.std(axis=0) def transform(self, X): return (X - self.mean) / self.std def fit_transform(self, X): self.fit(X) return self.transform(X)

提示:标准化时必须只用训练集的数据来 fit,得到 mean 和 std,再用同一组 mean 和 std 去 transform 测试集。如果拿全量数据一起 fit,会造成数据泄露,测试集的评估结果会偏乐观。

这个细节在 mylinear 的评分里往往是扣分点,但在实际项目中却是非常重要的习惯。

2.3 训练集和测试集的切分逻辑

代码里切分数据时,除了比例,还需要注意随机性。如果不随机打乱,数据本身又按某种规律排列(比如全部小值在前、大值在后),训练集和测试集的分布就会不一致,评估结果失真。

def train_test_split(X, y, test_size=0.2, random_seed=42): n = X.shape[0] indices = np.arange(n) rng = np.random.default_rng(random_seed) rng.shuffle(indices) n_test = int(n * test_size) test_idx, train_idx = indices[:n_test], indices[n_test:] return X[train_idx], X[test_idx], y[train_idx], y[test_idx]

固定 random_seed 是为了让结果可复现。训练模型的时候如果每次跑出来的结果都不一样,你会分不清是代码问题还是随机性问题。把 seed 固定下来,才能稳定地观察调整参数带来的效果变化。

3. 核心训练代码逐行拆解:损失、梯度、更新循环

3.1 预测函数:线性模型最简单的部分

线性回归的预测公式是 y_pred = X @ w + b。在代码里,当 X 是多条样本组成的矩阵、w 是权重向量时,这正好是矩阵乘法。加上偏置 b 时要注意 numpy 的广播机制:b 是标量,会自动加到每一行的结果上。

class MyLinearRegression: def __init__(self, lr=0.01, n_iter=1000): self.lr = lr self.n_iter = n_iter self.weights = None self.bias = None self.loss_history = [] def predict(self, X): return np.dot(X, self.weights) + self.bias

这里为什么用 np.dot 而不是运算符 * ?因为 * 在 numpy 里是对应元素相乘,而 np.dot 是矩阵乘法。对于形状为 (n_samples, n_features) 的 X 和 (n_features,) 的 w,np.dot(X, w) 得到 (n_samples,) 的预测结果,完全符合我们的需求。如果写 X * w,要么报维度错误的异常,要么出现完全错误的广播结果。

3.2 损失函数:为什么选 MSE 而不是 MAE

模型训练需要衡量预测值和真实值的差距,mylinear 里通常用均方误差 MSE,公式是先算每条样本的误差平方,再取平均。为什么选平方误差?两个原因。

第一,MSE 处处可导,导数形式简单。MAE 在误差为 0 处不可导,梯度下降处理起来更麻烦。第二,MSE 对大误差的惩罚更强,因为误差被平方了,这会迫使模型优先修正那些偏差很大的预测。

代码实现:

def _compute_loss(self, X, y): y_pred = self.predict(X) error = y - y_pred return np.mean(error ** 2)

补充一个细节。有些教科书上会写 loss = 1/(2m) * sum((y - y_pred)**2),前面多一个 1/2。这个系数纯粹是为了求导时把平方项的 2 抵消掉,让梯度表达式更简洁。mylinear 如果不用 1/2,梯度里就会多一个 2 倍系数,这不会影响模型收敛,只会影响学习率的最佳数值范围。关键是代码里必须保持一致,否则梯度和损失函数不配套,训练过程会混乱。

3.3 梯度推导与代码实现:手动循环和向量化对比

这是 mylinear 的灵魂部分。对权重 w_j 求偏导,结果可以写成 (2/m) * sum((y_pred - y) * X[:, j])。对所有 w 一起算,就是 (2/m) * X.T @ error,其中 error = y_pred - y。

刚开始接触时,很多人不理解 X.T 这个转置是从哪来的。我提供一个直观理解方式:X.T 的形状是 (n_features, n_samples),error 的形状是 (n_samples,),两者做矩阵乘法后,会为每个特征累加所有样本上该特征与误差的乘积。本质上就是在用嵌套循环推导导数的向量化写法。

下面展示两种实现方式。第一种是初学者常见的手写循环,方便对照公式:

def _gradient_manual(self, X, error): n_samples, n_features = X.shape dw = np.zeros(n_features) for j in range(n_features): total = 0 for i in range(n_samples): total += X[i, j] * error[i] dw[j] = 2 * total / n_samples db = 2 * np.sum(error) / n_samples return dw, db

第二种是用 numpy 向量化,代码更简洁、运行更快:

def _gradient_vectorized(self, X, error): n_samples = X.shape[0] dw = (2 / n_samples) * np.dot(X.T, error) db = (2 / n_samples) * np.sum(error) return dw, db

两个版本结果完全一致。在数据量小的时候看不出差别,但数据量到几十万条时,向量化版本会比循环版本快几十倍。这一点也是 mylinear 想要传递的工程意识:能用矩阵运算表达的,不要用 Python 循环。

3.4 参数同步更新:一个容易写错的细节

梯度下降的更新公式是 w = w - lr * dw,b = b - lr * db。看似简单,但初学者很容易犯一个错误:先更新 w,然后立刻用更新后的 w 重新计算预测值,再去算 b 的梯度。这样做会让两次梯度分别基于不同的模型参数,严格来说已经不是标准的梯度下降,训练轨迹会变得非常奇怪。

正确的做法是先把 dw、db 都计算出来,再做更新:

def fit(self, X, y): n_samples, n_features = X.shape self.weights = np.zeros(n_features) self.bias = 0.0 for _ in range(self.n_iter): y_pred = self.predict(X) error = y_pred - y dw = (2 / n_samples) * np.dot(X.T, error) db = (2 / n_samples) * np.sum(error) self.weights -= self.lr * dw self.bias -= self.lr * db loss = np.mean(error ** 2) self.loss_history.append(loss) return self

注意 loss 记录用的是更新前的误差平方均值。这个细节不用太纠结,只要确保画 loss 曲线时趋势是下降的就行。

3.5 训练完成后如何评估模型

mylinear 的评估通常不止看 loss,还要看 R² 分数,也就是 sklearn 里 score 函数默认返回的指标。R² 的计算逻辑是:1 - 误差平方和 / 真实值的方差,它的含义是模型比"直接用均值预测"好多少。

def score(self, X, y): y_pred = self.predict(X) ss_res = np.sum((y - y_pred) ** 2) ss_tot = np.sum((y - np.mean(y)) ** 2) return 1 - ss_res / ss_tot

R² 的最大值是 1,表示完美拟合。如果 R² 接近 0,说明模型跟"直接用 y 的均值预测"效果差不多。如果 R² 是负数,说明模型比用均值预测还差,这时候要去查数据和训练流程,基本可以确定代码有问题,或者是数据本身不是线性关系。

4. 这段代码为什么容易跑偏:学习率、标准化与收敛判断

4.1 特征不标准化时的典型症状:loss 震荡甚至发散

我在调试 mylinear 时,见过最多的现象就是 loss 一开始很大,然后迅速变成 nan。大部分情况下,罪魁祸首是特征没有标准化,加上学习率设置过大。

想象一下,如果特征 A 的取值范围是 0 到 1,特征 B 的取值范围是 1 到 10000,那么同样的学习率对于特征 B 的权重来说步长就太大了。参数在陡峭方向上反复横跳,loss 不仅不下降,反而不停上升,最终数值溢出变成 nan。

解决办法有两种。第一种是像我前面写的,在数据进入模型之前做标准化。第二种是调小学习率,比如从 0.01 改成 0.0001。但只调学习率而不做标准化,往往是治标不治本,多个特征量纲差异大时,找到合适的学习率非常困难。

所以我的建议是:数据标准化应该作为 mylinear 默认流程的一部分,而不是可选项。这也符合真实项目里特征工程的思想。

4.2 学习率:从 0.1 开始按 10 倍尝试

学习率 lr 是梯度下降里最敏感的超参数。它控制每一步参数更新的幅度。深度学习框架里那些自适应学习率的优化器,本质上都是在解决"怎么自动调整步长"的问题,而 mylinear 里没有这些机制,只能手动调。

最直接的调参策略是先把迭代次数固定在一个足够大的值,比如 2000,然后按 10 倍从大到小试学习率。举个例子:

  • lr = 0.1:如果 loss 迅速下降且稳定,说明可以接受,但后续可能在高位震荡,难以收敛到最小点。
  • lr = 0.01:常见的安全档位,多数标准化后的数据都能正常收敛,只是速度会慢一些。
  • lr = 0.001:更保守,但收敛可能需要更多迭代次数。
  • lr = 1 或以上:如果出现了 nan,基本就是学习率过大,可以直接判断跑偏原因。

提示:判断学习率是否合适的标准不是训练轮的绝对速度,而是 loss 曲线的形态。理想的曲线是平滑下降后趋于水平,没有明显上升段,也没有剧烈的锯齿状震荡。

4.3 用学习率衰减改善后期收敛

在 mylinear 的迭代次数固定版本里,一个常见改进是让学习率随迭代次数逐步衰减。这样前期用大学习率快速逼近最优区域,后期用小学习率精细调整,避免在最优参数附近来回震荡。

实现方式非常简单,在 fit 循环里加一行学习率缩放逻辑:

for step in range(self.n_iter): current_lr = self.lr / (1 + 0.01 * step) y_pred = self.predict(X) error = y_pred - y dw = (2 / n_samples) * np.dot(X.T, error) db = (2 / n_samples) * np.sum(error) self.weights -= current_lr * dw self.bias -= current_lr * db

这个例子用的是一个简单的时间衰减策略。按经验,当 n_iter 设得比较大时,固定学习率的曲线往往在最后几百轮基本不动,而衰减学习率的曲线每次还能往下走一点点,最终 loss 更低。

4.4 训练集 loss 很低但测试集效果差:过拟合的初步诊断

mylinear 毕竟是个线性模型,在低维合成数据上过拟合概率不大。但当你把项目扩展到更高维特征或者用真实数据时,就会遇到这个问题。判断方法很简单:比较训练集和测试集的 R²。如果训练集 R² 很高,比如 0.95,但测试集只有 0.6,说明模型过度拟合了训练集中的噪声,没有学到可泛化的规律。

线性模型的过拟合补救手段主要有两个方向。一是收集更多训练数据,把特征覆盖得更全面;二是引入正则化,在损失函数中加入对参数大小的惩罚,比如 L2 正则项。

def _compute_loss_with_reg(self, X, y, lambda_reg=0.01): y_pred = self.predict(X) error = y - y_pred m = X.shape[0] reg_term = lambda_reg * np.sum(self.weights ** 2) / m return np.mean(error ** 2) + reg_term

加了 L2 正则之后,梯度里也要对应的加一项 2 * lambda_reg * w / m。这个改动在代码上很简单,但在理解上是一个跨越:从"最小化训练误差"到"在训练误差和模型复杂度之间做平衡"。

5. 跑通 mylinear 之后,可以这样继续改造

5.1 改造成逻辑回归:只动三层代码

mylinear 的本质是一个通用的梯度下降框架,线性回归只是它的一个实例。把它改造成逻辑回归,只需要做三处替换:预测层、损失函数、梯度表达式。

线性回归的预测是 X @ w + b,直接输出连续值;逻辑回归需要先做同样的线性计算,再过一层 sigmoid 函数,把输出压缩到 0 到 1 之间,代表概率。损失函数从 MSE 换成二元交叉熵。梯度表达式也会从线性的误差变成 error = y_pred - y,形式上反而和线性回归一致,只是这里的 y_pred 是经过 sigmoid 的概率值。

def sigmoid(self, z): return 1 / (1 + np.exp(-z)) def predict_proba(self, X): linear = np.dot(X, self.weights) + self.bias return self.sigmoid(linear) def fit(self, X, y): # 逻辑回归的梯度计算,形式上与线性回归几乎一致 y_pred = self.predict_proba(X) error = y_pred - y dw = (1 / n_samples) * np.dot(X.T, error) db = (1 / n_samples) * np.sum(error) self.weights -= current_lr * dw self.bias -= current_lr * db

做这个改造的价值在于,你会突然发现不同模型之间的底层结构是相通的。学一个框架,等于学会了一批模型。

5.2 加入 mini-batch 训练:处理更大规模数据的关键

全量梯度下降每轮要用所有样本计算梯度,数据量一大就非常慢。mylinear 扩展版可以改成 mini-batch 梯度下降:每次随机抽一小批样本,比如 32 条,用这批样本的梯度近似全量梯度。

核心代码思路如下:

def fit_mini_batch(self, X, y, batch_size=32): n_samples = X.shape[0] for step in range(self.n_iter): indices = np.random.permutation(n_samples) X_shuffled = X[indices] y_shuffled = y[indices] for start in range(0, n_samples, batch_size): end = start + batch_size X_batch = X_shuffled[start:end] y_batch = y_shuffled[start:end] y_pred = self.predict(X_batch) error = y_pred - y_batch dw = (2 / X_batch.shape[0]) * np.dot(X_batch.T, error) db = (2 / X_batch.shape[0]) * np.sum(error) self.weights -= self.lr * dw self.bias -= self.lr * db

mini-batch 的优点是每次参数更新更频繁,收敛速度更快;缺点是梯度方向带有随机噪声,loss 曲线不会那么平滑,会有小幅震荡。这个震荡不一定是坏事,有时候反而能帮助跳出局部最优。

5.3 把 mylinear 的思路迁移到真实项目

写完这个项目之后,我再看任何框架里的线性模型,基本都能在大脑中想象出它内部的运行过程。有了这个基础,再学 sklearn、PyTorch 时心态完全不同——你面对的不再是黑盒,而是封装好的工具。

真实项目里我一般不会自己手写线性回归,但不代表这个练习没用。遇到模型不收敛时,我能迅速判断是数据标准化的问题、学习率的问题还是特征表达的问题。有一次在处理一个多特征业务数据时,模型训练 loss 一直不降,我按照 mylinear 的排查链路去查:先检查特征分布,发现有一列数据跨度极大;标准化后训练立刻正常。如果只会在框架里调参,这个问题可能翻来覆去找不到根因。

最后分享一个我自己的小习惯:给 mylinear 加上每次迭代的 loss 打印或者画 loss 曲线,比任何调试工具都直接。只要曲线呈现平滑下降趋势,参数基本没问题;一旦出现上升或 nan,马上停下检查数据和学习率。这个习惯现在也一直保留着,算是这份项目代码留下的最大收获。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 22:02:16

零基础转行AI产品经理:7阶段实战学习路线图

这两年问我“怎么转行AI产品经理”的人,加起来比过去五年问“怎么做产品”的人还要多。大家的心态我特别能理解:眼看着大模型一夜之间改变了软件形态,又看着招聘网站上AI产品经理的薪资水涨船高,谁都怕自己上不了这趟车。但真正动…

作者头像 李华
网站建设 2026/10/22 22:02:05

AI功能设计三原则:用模板管住大模型的不确定性

很多产品评审会上,只要有人说出“我们接入一个大模型,做智能摘要、智能客服、智能写作”,气氛就会瞬间热起来。但真正开始画原型的时候,气氛又会很快冷下去:模型答错了怎么办?用户问的问题超纲了怎么处理&a…

作者头像 李华
网站建设 2026/10/22 21:58:31

C++手写倒排索引:从数据结构到查询优化的完整指南

简介:这份资源围绕C实现倒排索引与搜索功能展开,面向正在学习数据结构、搜索引擎原理或准备相关课程设计的学习者。包内共13个文件,以10个txt文档作为待索引语料,配合2个cpp源文件与1个头文件构成完整工程,压缩包约4KB…

作者头像 李华
网站建设 2026/10/22 21:54:20

VLA模型π05速过指南:从数据准备到部署的完整实践

1. VLA模型是什么,为什么值得“速过”一遍先交代一下背景。VLA模型,全称是Vision-Language-Action Model,也就是视觉-语言-动作模型。严格说,它不是一个单独的算法,而是一整套技术路线:把视觉输入&#xff…

作者头像 李华