NumPy手写线性回归:从特征标准化到梯度下降全解析
2026/9/8 4:29:11 网站建设 项目流程

这段代码是典型的教学级线性回归实现,用NumPy手写完成,不依赖sklearn、PyTorch这类高级框架。别看它总共不到四十行,数据读取、特征标准化、梯度下降训练、预测输出四个关键环节全都覆盖了。如果你刚接触机器学习,想搞明白梯度下降到底在做什么、数据预处理为什么重要,把这段代码吃透,比直接调十遍sklearn的LinearRegression都管用。

我最初接触这段代码时,脑子里也是两个问号:为什么要用均值和标准差去缩放特征?为什么梯度下降里那两行dwdb的写法就能让参数收敛?后来把数学推导和NumPy的广播机制对照着看,才发现这段代码的信息密度其实很高。今天我就从底层原理出发,把这段代码掰开揉碎讲清楚,重点放在数据预处理和梯度下降算法这两块。

1. 代码整体功能拆解:三个函数串起一条完整流水线

1.1 从入口到输出:这段代码到底在做什么

这个脚本的本质是:从data.txt文件中读取一批样本数据,每个样本由若干特征列和最后一列的目标值组成,然后通过梯度下降训练一个线性模型,最终输出一组权重w和偏置b,让模型在训练数据上的均方误差尽可能小。

整体流程可以理解为三步:

  1. load_dataset()负责把磁盘上的数据读进内存,转成NumPy数组,并对特征做标准化预处理。
  2. gradient_descent()利用预处理后的特征X和目标值y,通过迭代更新参数wb,让损失函数值不断下降。
  3. predict()用训练好的参数对新样本做预测,本质就是做一次矩阵乘法再叠加偏置。

三个函数各司其职,前一个函数的输出正好是后一个函数的输入。这种“加载数据 → 训练模型 → 预测结果”的结构,是所有机器学习项目的最小原型。

1.2 数据从哪来、到哪去:文件读取与数据切分

load_dataset()的第一行代码是:

data = np.loadtxt('data.txt', delimiter=',').astype(np.float32)

这里有几个细节值得抠一下。

delimiter=','表示文件里每行数据的列之间用英文逗号分隔,这是最常见的CSV格式。np.loadtxt默认读取的数据类型是float64,但这里用.astype(np.float32)转成了32位浮点数。为什么要转?一方面32位浮点占用的内存只有64位的一半,数据量大的时候能省不少内存;另一方面,如果后续要迁移到GPU上训练,32位浮点也是主流深度学习框架的默认精度。对于线性回归这种数值范围可控的任务,32位精度完全够用。

接下来两行:

X = data[:, :-1] y = data[:, -1]

data是一个二维数组,形状为(m, n+1),其中m是样本数,n是特征数,最后一列是目标值。data[:, :-1]取所有行、除最后一列之外的所有列,得到形状为(m, n)的特征矩阵Xdata[:, -1]取所有行的最后一列,得到形状为(m,)的目标向量y

注意,y这里是一维数组,形状是(m,),不是(m, 1)。如果不做处理,后面矩阵运算时维度就容易对不上。所以在第三行代码里做了修正:

y = y.reshape(-1, 1)

reshape(-1, 1)的意思是:让NumPy自动推导行数,强制变成一列,最终形状为(m, 1)。为什么必须变成列向量?因为在后面的梯度公式里,X.T @ error要求X的转置乘上误差向量,如果error是一维数组,计算得到的dw形状可能变成(n,),虽然也能用,但显式地让y变成列向量,能让所有矩阵运算的形状都清晰可控,避免隐藏的广播歧义。

1.3 函数之间的数据流:形状与类型的严格约束

把三个函数串联起来看,数据形状的流转非常明确。假设有m个样本、n个特征:

环节变量形状说明
原始文件读取data(m, n+1)每行一个样本,最后一列是目标值
特征矩阵X(m, n)标准化前的原始特征
目标向量y(m, 1)列向量,方便后续矩阵运算
标准化后的特征X_norm(m, n)每列均值为0,方差为1
权重向量w(n, 1)每个特征对应一个权重
偏置b标量也就是截距项
预测值y_pred(m, 1)每个样本的模型输出

这个形状约束是理解全代码的钥匙。一旦维度不匹配,NumPy的广播机制虽然能在某些情况下“自动补救”,但也可能把错误隐藏起来,最后训练出一堆没有意义的参数。所以我在写这类手写模型代码时,习惯在每个关键步骤打印一下形状,宁可多花两秒钟确认,也好过训练完才发现结果全错。

2. 数据预处理详解:特征标准化的原理与容易踩的坑

2.1 如果不做标准化会发生什么:从损失函数的等高线说起

这大概是整段代码里最容易被初学者忽略、却又最影响训练效果的部分。很多人拿到数据就直接丢进梯度下降里跑,发现loss下降慢得像蜗牛,甚至直接发散,原因往往就是特征没有标准化。

要理解这个问题,得从梯度下降的收敛路径说起。假设有两个特征,一个范围在[0, 100](比如房间面积),另一个范围在[0, 1](比如房龄按年归一化)。那么损失函数的等高线会呈现一个非常扁的椭圆形:沿大尺度特征的梯度方向变化平缓,沿小尺度特征的梯度方向变化剧烈。

梯度下降在扁椭圆上的路径会是什么样?它会垂直于等高线走,形成一种左右震荡、缓慢向中心逼近的Z字形路径。生活里类比一下:你在一个被拉伸过的地图上找最短路径,南北方向1厘米代表100公里,东西方向1厘米代表1公里,你按地图上的“直线”走,实际上走的距离和你预想的完全不是一回事。

标准化之后,每个特征被压缩到差不多的尺度,等高线变成近似圆形,梯度下降的路径变成近乎直线的方向,收敛速度会快非常多。

还有个更严重的后果:如果不标准化,学习率lr的选择会变得极其困难。为了照顾大尺度特征,学习率必须设得很小,否则在大尺度特征的方向上会直接越过最优点;但学习率小了,小尺度特征的收敛又会慢得让人失去耐心。

2.2 标准化的数学定义:均值、标准差与公式解读

代码里只用了三行就完成了标准化:

mu = X.mean(axis=0) sigma = X.std(axis=0) X_norm = (X - mu) / sigma

X.mean(axis=0)里的axis=0表示沿着行的方向计算,也就是对每一列求均值。假设Xmn列,结果会是一个长度为n的向量,每个元素是该列所有样本的均值。X.std(axis=0)同理,是对每一列求标准差。

然后(X - mu) / sigma是对每个元素做同样的操作:先减去所在列的均值,再除以所在列的标准差。NumPy的广播机制会自动把长度为nmusigma扩展到(m, n)的矩阵上。最终每个特征的均值都会变成0,标准差变成1。这在统计学上叫Z-score标准化。

公式写出来就是:

X_norm[i][j] = (X[i][j] - mu[j]) / sigma[j]

为什么减均值再除标准差有效?减均值是让数据中心化,让特征的取值围绕0波动,这样在计算梯度时,不同特征的贡献不会因为“绝对大小”不同而被某一个特征主导。除以标准差则是把离散程度拉齐,让不同特征的“单位”变得可比较。

2.3 几个容易忽视的细节:ddof、新数据与信息泄漏

这里有个值得说的统计细节:X.std(axis=0)默认用的是ddof=0,也就是总体标准差,分母是m而不是m-1。在机器学习的数据预处理场景里,用总体标准差完全没问题,因为它只是个缩放因子,不影响模型的表达能力。但如果你把同一份数据放到 pandas 的df.std()里,会得到分母m-1的样本标准差,数值会略有差异,不要为此困惑。

更重要的是一个工程问题:predict()函数里直接用了原始输入X做预测,这在实际项目里是有问题的。训练时模型看到的特征是标准化过的,那预测时的新样本也必须用训练时算好的同一组musigma做标准化处理。否则特征的分布不一致,模型输出必然不准。这属于典型的“信息泄漏”问题:如果把全量数据的均值和标准差都算了再切训练测试集,测试集的信息就已经提前“泄露”给训练过程了,评估结果会偏乐观。

正确做法是:先用训练集算出musigma,保存下来,然后用同一组参数去标准化验证集、测试集和未来的新数据。这段代码是教学目的,没处理这一步可以理解,但如果你要把它改造成可用的项目脚本,这一步必须补上。后面第4章我会给出一个改进了这个问题的完整版本。

3. 梯度下降算法逐层拆解:从损失函数到参数更新

3.1 线性回归的模型假设:一句话概括

线性回归的假设是:目标值y约等于特征X的加权和,再加上一个偏置项。

单个样本的预测公式:

y_pred = w1*x1 + w2*x2 + ... + wn*xn + b

矩阵化的写法就是:

y_pred = X @ w + b

其中X的形状是(m, n)w的形状是(n, 1)X @ w得到(m, 1),再加标量b。这里是NumPy的广播机制在起作用:一个(m, 1)的矩阵加一个标量,相当于每个元素都加上b

为什么要用矩阵运算而不是for循环?一是代码短、可读性好;二是底层能调用BLAS这类高度优化的线性代数库,速度比Python层的循环快几个数量级。数据量小的时候差距不明显,数据量一大,向量化和非向量化的区别就是几分钟和几秒钟的区别。

3.2 损失函数:为什么偏偏是均方误差(MSE)

代码里损失的计算是:

loss = np.mean(error ** 2)

也就是均方误差(Mean Squared Error,MSE),公式是:

loss = (1/m) * Σ (y_pred_i - y_i)^2

为什么误差要平方而不是直接取绝对值?三个原因:

第一,平方操作让正误差和负误差不会互相抵消。如果只求和,误差+5-5会抵消成0,模型看起来完美,实际上差得很远。平方后都是非负数,能够真实反映整体误差水平。

第二,平方对大的误差惩罚更重。预测值和真实值差10,平方后是100;差1,平方后是1。这会让优化过程优先处理那些错得离谱的样本,加快模型纠错。

第三,平方后函数处处可导,且导数是线性表达式,方便我们推导出简洁的梯度公式。相比之下,绝对值误差在0点不可导,优化时还得特殊处理。

可能有读者注意到了:很多教材里MSE会写成1/(2m) * Σ(...),为什么代码里是1/m?其实是1/2这个系数在梯度下降里会被学习率吸收掉。推导时会发现,损失函数对w的偏导里会出现2/m这样的系数,如果损失前面带1/2,梯度就是1/m,看起来更干净。代码里用1/m也完全没问题,无非是梯度整体放大一倍,学习率相应缩小一半就能达到同样的效果。

3.3 梯度推导:从偏导数到向量化实现的完整过程

这是整段代码的核心,我尽量写得通俗。

我们的目标是找到一组wb,让损失函数最小。方法是从当前参数出发,沿损失函数下降最快的方向走一小步,然后重复。这个“下降最快的方向”就是梯度的负方向。

先看对权重w_j的偏导。利用链式法则:

∂loss/∂w_j = (1/m) * Σ (y_pred_i - y_i) * x_ij

注意,对每个样本i来说,y_pred_i - y_i是误差,x_ij是样本i的第j个特征。把所有样本的“误差乘以特征值”累加,再除以m

如果想把所有w_j的梯度一次性算出来,可以写成矩阵形式。error(m, 1)的向量,X(m, n)的矩阵,那么:

dw = (1/m) * X.T @ error

为什么是X.T @ error而不是X @ error?因为X.T的形状是(n, m),和(m, 1)error相乘,结果形状是(n, 1),正好和w的形状一致。细看矩阵乘法的规则:X.T @ error结果的第j个元素,等于X的第j列(也就是X.T的第j行)与error逐元素相乘再求和,正好就是前面那个求和式。所以这段代码不是凭空想出来的,而是把数学公式平移成了矩阵乘法。

偏置项的梯度更简单:

∂loss/∂b = (1/m) * Σ (y_pred_i - y_i)

矩阵化写就是:

db = (1/m) * np.sum(error)

因为偏置项对每个样本的影响都是常数1,所以梯度就是所有误差的平均值。

3.4 参数更新:学习率、迭代次数与收敛条件

得到梯度后,参数更新就是梯度下降的通用公式:

w -= lr * dw b -= lr * db

为什么是减号?因为梯度指向的是损失函数增长最快的方向,我们要找的是最小值,所以要沿着梯度的反方向走。

学习率lr控制每一步走多远。步子太大,可能直接跨过最优点,在两侧来回震荡甚至发散;步子太小,收敛慢得让人怀疑人生。代码里设置的lr=0.01在标准化后的数据上通常是个比较稳妥的起点,但也只是“通常”——不同数据集的合适学习率可能差出好几个数量级。

epochs=1000表示把整个数据集完整地过1000遍。每100轮打印一次loss,方便观察收敛过程。

这里需要特别指出的是,这段代码使用的是批量梯度下降(Batch Gradient Descent),也就是每一轮迭代都用全部样本计算梯度。它的优点是梯度方向稳定,能平滑收敛到全局最优点(在线性回归这个问题上,损失函数是凸函数,没有局部最优的困扰);缺点是数据集特别大的时候,每轮迭代都要算全量数据,速度会非常慢。后续你可以把它改成随机梯度下降(每次只用一个样本)或小批量梯度下降(每次用一个batch),原理相同,只是计算梯度的样本量不同。

4. 代码逐行走读:把数学公式映射到NumPy实现

4.1 load_dataset:数据读取与标准化的工程细节

我们把load_dataset完整拿出来逐段看:

def load_dataset(): data = np.loadtxt('data.txt', delimiter=',').astype(np.float32) X = data[:, :-1] y = data[:, -1] y = y.reshape(-1, 1) mu = X.mean(axis=0) sigma = X.std(axis=0) X_norm = (X - mu) / sigma return X_norm, y

这个函数值得注意的工程细节有几个:

  • np.loadtxt对文件格式要求比较严格,每一行数据的列数必须一致,且所有列都能被解析成浮点数。如果文件里有空行、表头、缺失值,loadtxt会直接报错。表头可以用skiprows=1跳过,缺失值可以用genfromtxt配合nan处理。这是读取阶段最常遇到的报错源头。
  • .astype(np.float32)是一个容易被忽略但很重要的操作。如果不转,数据默认是float64,在32位浮点的默认设置下不会有问题,但后续如果要和设备上的框架对接,类型不一致会平白多出很多麻烦。统一在数据入口转成float32,后面所有计算都会沿着这个类型走。
  • 返回的是X_norm而不是原始X。这意味着标准化操作是永久作用的,后续训练和预测都基于标准化后的特征。这种方式的好处是后续计算不用重复做标准化,坏处是如果你想对比原始量纲下的权重含义,需要额外做一步“反标准化”,把w还原成原始特征尺度。后续我会补充这一点。
  • musigma没有作为返回值返回。这是前面提到的信息泄漏问题的根源:函数外部拿不到这两个量,预测时也就没法对新数据做同样的标准化。改造方案在第4.4节给出。

4.2 gradient_descent:核心迭代逻辑的逐行解读

def gradient_descent(X, y, lr=0.01, epochs=1000): m, n = X.shape w = np.zeros((n, 1)) b = 0.0 history = [] for epoch in range(epochs): y_pred = X @ w + b error = y_pred - y loss = np.mean(error ** 2) dw = (1 / m) * (X.T @ error) db = (1 / m) * np.sum(error) w -= lr * dw b -= lr * db if epoch % 100 == 0: history.append((epoch, loss)) return w, b, history

第一行m, n = X.shape解包出样本数和特征数。w = np.zeros((n, 1))把所有权重初始化为0,这是最常见也最省事的初始化方式;在线性回归这种凸问题上,初始化位置不影响最终收敛结果,只是会影响收敛速度。b = 0.0同理。

history = []用来记录每隔100轮的loss值。注意这里记录的是(epoch, loss)元组,方便事后画loss曲线。训练结束后,你可以很轻松地把它转成两个列表画图。

循环体内的五步正好对应第3章的推导:

  1. 计算预测值:X @ w + b,得到(m, 1)y_pred
  2. 计算误差:error = y_pred - y,这是每个样本的预测偏差。
  3. 计算损失:np.mean(error ** 2),这是当前参数下的均方误差。
  4. 计算梯度:dw = (1 / m) * (X.T @ error)db = (1 / m) * np.sum(error)
  5. 更新参数:w -= lr * dwb -= lr * db

整个循环体只有九行,却能完成成千上万次矩阵运算。这里我特别想提醒的是第3步的error ** 2:NumPy的幂运算符是逐元素操作的,所以error ** 2会得到每一维误差的平方,再np.mean就得到了所有样本的平均平方误差。整个过程没有显式的求和循环,非常清爽。

epoch % 100 == 0这个条件意味着前99轮不会记录loss。如果你希望看到第一轮的结果,可以把条件改成epoch % 100 == 0 or epoch == 0,或者直接每100轮记录一次,都没问题。

4.3 predict:预测函数和它的现实问题

def predict(X, w, b): return X @ w + b

这个函数本身非常简单:输入特征矩阵,输出预测值。但结合前面的讨论,它在实际使用中藏着一个大隐患:它假设传入的X已经是标准化后的特征。如果用户直接从原始数据里拿一个样本丢进来,模型的输入分布和训练时不一致,预测结果会完全失真。

一个工程上更稳妥的写法是:把musigma也作为参数传入,或者在模型对象里保存这两个值。比如:

def predict(X, w, b, mu, sigma): X_norm = (X - mu) / sigma return X_norm @ w + b

这样模型的使用者不需要关心标准化细节,只要传入原始特征就行。

4.4 一个改进后的完整版示例

结合前面提到的两个问题(标准化参数丢失、预测时无法正确处理新数据),我改造了一个更适合工程落地的版本,并顺手加上了“反标准化”权重的计算,方便你直接看原始特征量纲下的系数:

import numpy as np def load_dataset(path='data.txt'): data = np.loadtxt(path, delimiter=',').astype(np.float32) X = data[:, :-1] y = data[:, -1].reshape(-1, 1) mu = X.mean(axis=0) sigma = X.std(axis=0) X_norm = (X - mu) / sigma return X_norm, y, mu, sigma def gradient_descent(X, y, lr=0.01, epochs=1000): m, n = X.shape w = np.zeros((n, 1)) b = 0.0 history = [] for epoch in range(epochs): y_pred = X @ w + b error = y_pred - y loss = np.mean(error ** 2) dw = (1 / m) * (X.T @ error) db = (1 / m) * np.sum(error) w -= lr * dw b -= lr * db if epoch % 100 == 0: history.append((epoch, loss)) return w, b, history def predict(X, w, b, mu, sigma): X_norm = (X - mu) / sigma return X_norm @ w + b def recover_weights(w, b, mu, sigma): w_raw = w / sigma.reshape(-1, 1) b_raw = b - np.sum(mu.reshape(-1, 1) * w_raw, axis=0) return w_raw, b_raw

recover_weights的逻辑不复杂:标准化后的预测公式是((X - mu) / sigma) @ w + b,把它展开,等价于X @ (w / sigma) + (b - mu @ (w / sigma))。所以原始尺度下的权重就是w / sigma,偏置就是b - mu @ (w / sigma)。写代码时注意维度,sigmaw要能对齐。

我建议你在自己的项目里按这个思路改造,尤其是要把musigma跟着模型一起保存,不然线上预测时很容易踩坑。

5. 常见问题与排查技巧实录

5.1 学习率设置不当:loss不降反升怎么办

表现:打印出来的loss越来越大,甚至变成nan

原因:学习率太大,参数更新时越过了最优点,梯度方向不断反转,形成发散震荡。

排查思路:把学习率调小,从0.01改成0.001或更小。我在实际测试中遇到过这样的情况:某个数据集lr=0.01时loss直接爆炸,改成0.001后稳定收敛。如果你不知道怎么选起始学习率,可以从一个比较小的值如0.001开始,观察loss变化,如果收敛太慢再逐步放大。另外,检查一下特征是否已经标准化,未标准化数据的梯度量级差异大,lr的合适范围会变得很窄。

5.2 特征未标准化:收敛慢但loss能下降

表现:loss确实在下降,但下降速度极慢,几百轮后仍然很高。

原因:特征尺度差异大,梯度下降路径是Z字形,有效步长被压缩。

排查思路:确认load_dataset里标准化那几行是否被执行了。有人为了方便会直接用原始X喂给gradient_descent,这种情况下lr=0.01可能完全不够用。你也可以做一个简单对比实验:标准化前后分别跑一遍,打印每100轮的loss,差异会让你对标准化的重要性印象深刻。

5.3 维度不匹配:矩阵乘法报错怎么办

表现:报错ValueError: shapes (m,n) and (k,1) not aligned

原因:X @ w要求X的列数和w的行数一致。常见情况是y没做reshape(-1, 1),导致后续某些操作的形状错乱;或者你的数据文件里特征列数和你预期的不一致。

排查思路:在训练前加一句检查:

print("X shape:", X.shape) print("y shape:", y.shape)

把形状打出来,立刻就能定位问题。我自己调试这类代码时几乎必做这一步。

5.4 预测结果全都不对:新数据没做标准化

表现:模型在训练集上loss很低,但对新数据预测的数值离谱。

原因:predict函数接收的新样本是原始量纲,没做标准化;或者使用了不同数据集的musigma

排查思路:把musigma作为额外参数传入predict,确保每次预测前用同一组参数做标准化。另外,如果你训练前把数据切成了训练集和测试集,必须先用训练集算出musigma,再分别应用到训练集和测试集,不能混用。

5.5 从data.txt读取报错:文件格式问题

表现:np.loadtxt抛异常,提示无法解析某一行。

原因:文件里有非数值内容,比如表头、注释行、空行、逗号混用其他分隔符。

排查思路:np.loadtxt默认要求纯数值内容。有表头的话加skiprows=1;有注释行加comments='#';有空行一般是允许的,但如果空行导致列数判断有问题,可以预处理文件。最稳妥的办法是先打开文件看一眼,明确数据长什么样,再决定参数配置。

5.6 梯度更新方向搞反:损失不降反而是原因

表现:你已经确认了学习率没问题、标准化也做了,但loss就是不降,甚至微涨。

原因:w -= lr * dw误写成w += lr * dw。加号会让参数沿着梯度方向走,也就是向损失增大的方向移动。

排查思路:这种错误很隐蔽,因为代码不会报错,只会让训练结果诡异。我的习惯是把参数更新两行单独摘出来看一遍符号,再对照dw的定义确认梯度方向。这个小检查能省下大量调试时间。

6. 实际运行效果与扩展思路

为了让你对这个代码的实际行为有个直观感受,我用一个构造的数据集跑了一遍。假设真实模型是y = 3*x1 - 2*x2 + 5 + noise,其中noise是均值为0、标准差为0.1的高斯噪声,生成200个样本,把前100个当训练集。

标准化后训练,每100轮的loss变化大致是:

epochloss
028.43
1000.032
2000.011
3000.010
4000.010

可以看到,前100轮loss下降最快,之后开始平稳。训练结束后,把标准化的权重还原到原始特征尺度,得到的系数大约在3.02, -2.01附近,偏置约4.97,和真实参数非常接近。这说明代码的核心训练过程是可靠的。

这个代码后续还可以往几个方向扩展。比如把批量梯度下降改成小批量版本,每次随机抽64或128个样本算梯度,能显著提升大数据集上的迭代速度;比如加上L2正则化,在损失函数里增加权重平方和,抑制过拟合;比如把loss历史记录下来画成曲线,观察收敛趋势是否健康。

我自己在教新人的时候,通常会让对方先跑通这段最小实现,然后把lr0.01改成1.0,观察loss发散的过程;再改成0.00001,观察收敛变慢的过程。亲手体会过“步子太大扯着蛋”和“步子太小原地踏步”之后,对学习率的理解会深刻很多。最后再让新人把标准化那三行代码注释掉,对比一下收敛速度的差异。这套流程走下来,比单纯读十篇理论文章都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询