mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2751 字
7 分钟
2026-04-11学习
2026-04-11

梯度下降的Python代码实现#

注:以下内容使用的是python3.10版本,jumpyter,使用了d2l(动手深度学习)的包,但该包对推导公式并无影响,对数据集生成以及测试会有一定影响。

%matplotlib inline
import random
import torch
from d2l import torch as d2l

以下是数据集的生成 w是我们自己定义的一个权重 b同样是我们自己定义的一个偏置

然后用定义的函数生成了num_examples个样本,其中: X是均值为0,标准差为1的正态分布,len(w)可以看作权重的个数,一共有num_example个,组成了一个矩阵,作为特征数据集 matmul则是矩阵乘法,将X乘以权重并加上偏差,得到标签数据集 之后则是添加噪声

def synthetic_data(w,b,num_examples):
X=torch.normal(0,1,(num_examples,len(w)))
y=torch.matmul(X,w)+b
y+=torch.normal(0,0.01,y.shape)
return X,y.reshape(-1,1)
true_w=torch.tensor([2,-3.4])
true_b=4.2
features,labels=synthetic_data(true_w,true_b,1000)

可以画图得到数据的分布情况

d2l.set_figsize()
d2l.plt.scatter(features[:,1].detach().numpy(),labels.detach().numpy(),1)

这里是随机打乱下标,然后以10个为一组,返回对应的X(特征)以及y(标签)

def data_iter(batch_size,features,labels):
num_examples=len(features)
indices=list(range(num_examples))
random.shuffle(indices)
for i in range(0,num_examples,batch_size):
batch_indices=torch.tensor(indices[i:min(i+batch_size,num_examples)])
yield features[batch_indices],labels[indices]
batch_size=10
for X,y in data_iter(batch_size,features,labels):
print(X,'\_n',y)
break

下面到了我真正想讲的东西,也就是数学推导 首先是随机一个w和b,这里的w和b是什么并不重要,至于为什么往下面看。

w=torch.normal(0,0.01,size=(2,1),requires_grad=True)
b=torch.zeros(1,requires_grad=True)

在数学中我们可以这样表示:

wN(0,0.012)w \sim \mathcal{N}(0, 0.01^2)b=0b = 0

定义线性回归公式:

def linreg(X,w,b):
return torch.matmul(X,w)+b

看着很复杂,但是在数学中就会清晰明了:

y^=Xw+b\hat{y} = Xw + b

一元一次方程嘛,线性回归,为什么是线性的呢?就是因为成比例。

然后是平方损失函数:

def square_loss(y_hat,y):
return (y_hat-y.reshape(y_hat.shape))**2/2

同样的,在数学上:

l(y^,y)=12(y^y)2l(\hat{y}, y) = \frac{1}{2} (\hat{y} - y)^2

下面到了难点:

首先no_grad()是停止记录计算图。避免梯度的累积,因为梯度下降是多次的,上一次的梯度下降是不应该影响下一次的梯度下降。

举个很简单的例子,在山顶想要到山谷的梯度是很陡的,但山间到山谷的梯度就会比较平缓一些了,第一次梯度下降可能是从山顶到了山谷,如果累计梯度的话,那么下一次下降的方向就是山顶的最速下降方向+山间的最速下降方向合起来的向量了。

那么接下来讲一个让我比较困惑的点,也就是优化算法:

def sgd(params,lr,batch_size):
with torch.no_grad():
for param in params:
param-=lr*param.grad/batch_size
param.grad.zero_()

让我们首先确定这里代码所代表的数学含义:

θθηBθl\theta \leftarrow \theta - \frac{\eta}{|B|} \nabla_{\theta} l

θθ\theta \leftarrow \theta - \dots 对应代码中的 param -= …。

η\eta 代表学习率(代码中的 lr),控制每次参数更新的步长。

θl\nabla_{\theta} l 对应代码中的 param.grad,即损失函数对当前参数计算出的梯度(求导结果)。

B|B| 代表小批量样本的数量(代码中的 batch_size)。在这里除以 batch_size 是因为前面在计算损失函数时没有求均值(只是简单求和了当前批次所有样本的损失),所以在这里对梯度求均值,确保更新步长不会因为批量大小的变化而剧烈波动。

param.grad.zero_() 是每次更新完参数后,必须将现有的梯度清零,否则 PyTorch 默认会把下一次计算的梯度和这次的累加起来,导致计算错误。

现在我们都在推导公式了,自然要深入一点思考,首当其中的就是w到底是怎么算出来的,这也是这个公式的核心,对吧?

在机器学习中我们训练的时候,数据是有训练集和测试集的,训练集包含了特征和标签,也就是说,我们是知道真实的y值的,对吧?

模型预测(线性方程):y^=wx+b\hat{y} = wx + b

损失函数(平方损失):L=12(y^y)2L = \frac{1}{2}(\hat{y} - y)^2

那么,损失函数自然是算得出的,对吧?

看着毫无关联,我们该怎么算ww呢?

注意到y^\hat{y}是和ww相关的,L又和y^\hat{y}相关,那么想对w求偏导,我们根据链式法则,可以转换为:

Ly^=212(y^y)211=y^y\frac{\partial L}{\partial \hat{y}} = 2 \cdot \frac{1}{2}(\hat{y} - y)^{2-1} \cdot 1 = \hat{y} - y

ww 求导:y^w=x\frac{\partial \hat{y}}{\partial w} = x

bb 求导:y^b=1\frac{\partial \hat{y}}{\partial b} = 1

现在,把外层和内层乘起来,我们就得到了最终的梯度:

关于权重 ww 的梯度:

Lw=Ly^y^w=(y^y)x\frac{\partial L}{\partial w} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial w} = (\hat{y} - y) \cdot x

关于偏置 bb 的梯度:Lb=Ly^y^b=(y^y)1=y^y\frac{\partial L}{\partial b} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial b} = (\hat{y} - y) \cdot 1 = \hat{y} - y

算这个有什么用呢?

我们回归基础,偏导是什么?

不动其他参数,对某个参数微调,对函数的改变量,对吧?

那么,Lw\frac{\partial L}{\partial w},就是改变ww,对y^\hat{y}的改变,对LL的改变。

需要注意的是,ww并不是只代表了一个数,而是一个向量,并且这个向量是LL的参数(带入可知),也就是说LLww求偏导,其实是求梯度。

梯度又是什么呢?代表着在当前点的最快上升路径,反之则是下降最快的路径。

我们可以来一点几何上的直观想象,不是说某个点指向山顶,其上升速度一定是最快的,只需要顺着下降即可。

山的例子其实在我看来是一个很有问题的例子,会让用这个模型想象的人,不自觉的认为切线方向一定是上升速率最快的方向。

因为你如果观看过曲线最速下降的图的指向,就会发现绝大部分情况下,这个方向不是指向山顶的,而是指向一个莫名其妙的位置,虽然有向下的趋势,但很明显不符合人的常识。

顺着山爬或者顺着山下才应该最快的,为什么不是这样?

首先需要注意的是,如果要用山的模型,至少应该把二维转换为三维。

绝大部分给你举的例子,都是一个用y=x^2的函数伪装的山。

我更推荐你用一个三维的图像去看,这是我给出的一个会造成梯度震荡的例子:

f(x,y)=x2+10y2f(x, y) = x^2 + 10y^2

然后,我们不应该用一个全局的眼光去看。

梯度从来指向的不是全局最高的位置,而是一个极小范围内,距离下一个等高线的最速位置。

我们可以设想一个二维映一维的图像,二维中有无数个圆圈(不一定是标准圆),每个圆圈都代表了一个高度,如果圆圈是标准的,那么梯度下降确实沿着最速方向(垂直于切线的方向),但如果圆是椭圆…

椭圆的垂直于切线的方向会指向圆心吗?

你可以现在去画一个图,或者在脑内设想一下,甚至是问ai,答案都是很明显的不会。

至于为什么垂直切线的方向是梯度的方向,这涉及到了一个数学推导:

先想象一下,沿着等高线走,高度的变化是怎么样的?

显然等高线之所以是等高线,就是因为高度相同

假设这座山的高度由函数 f(x,y)f(x, y) 决定。你在山上的位置是 (x,y)(x, y)

朝着某个极其微小的方向迈出一步,这一步的位移向量我们叫它 dr=(dx,dy)d\vec{r} = (dx, dy)

在迈出这一步后,高度的变化量(数学上叫全微分 dfdf)是由两部分叠加而成的:X方向的坡度乘以X方向的步长,加上Y方向的坡度乘以Y方向的步长。用公式写出来就是:

df=fxdx+fydydf = \frac{\partial f}{\partial x}dx + \frac{\partial f}{\partial y}dy

我们可以看作两个向量的点乘

第一个向量是 (fx,fy)(\frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}) —— 这正是梯度的定义 f\nabla f

第二个向量是 (dx,dy)(dx, dy) —— 这是迈出那一步的方向 drd\vec{r}

高度变化便可以写作:

df=fdrdf = \nabla f \cdot d\vec{r}

如果这迈出的这一步 drd\vec{r},是刚好顺着等高线的切线方向走的,那么高度是绝对不会变的,也就是高度变化量 df=0df = 0。把 df=0df = 0 代入上面的公式:fdr=0\nabla f \cdot d\vec{r} = 0

这可以证明,梯度垂直于等高线切线,显然又是数学的小巧思…

不过我们知道了这个后,加上前面的举例,就可以得到一个比较直观的结论了,那就是梯度是指向局部最高点的。

这也就是为什么很多时候你看的梯度明明显然存在一个最短的路径,但就是反反复复跳的原因。

(扯的有点偏了,但确实对理解这个会有很大的帮助)

理解了意义后,ww又是怎么改变的呢?

梯度下降,下降的是谁的梯度呢?

让我们拉回来,再看一遍公式:

θθηBθl\theta \leftarrow \theta - \frac{\eta}{|B|} \nabla_{\theta} l

理解上面的内容后,我们就不难发现梯度下降其实很类似于贪心算法,一直在寻找局部最优解,这里的θ\theta显然就是w,学习率其实就是沿着梯度走了多少,每个点的梯度显然不一样,这就是更新的梯度,而我们的核心目的就是找到一个ww,使得L的损失最小。

我们这里在重新整理一下思路:

ww的梯度:

Lw=Ly^y^w=(y^y)x\frac{\partial L}{\partial w} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial w} = (\hat{y} - y) \cdot x

梯度代表上升最快的方向,反之则是下降最快的方向,LL是关于ww的函数,在一个固定的点,梯度上升代表误差变大,梯度下降代表误差减小。

ww我们可以看作坐标,梯度则是L(损失)的上升方向,为了使损失下降,我们要往反向去走一个位移,然后更新ww的位置,这就是这个公式的意义。

然后w变小,那么梯度模型预测的y^\hat{y}会更精准,导致梯度变小,也就是越走需要调整的位移越小.

如果是在几何图像上,你可以画n个椭圆,然后看看沿着切线方向,走一走试试看。

ww不再更新的时候,就代表走到最低点。

还有一个值得注意的点是有人应该会疑惑,梯度是求函数的偏导,难道不会存在一个值越小,梯度越大的函数吗?例如梯度是-lnx这样的

这个就不过多解释了,平方损失嘛…自己想想应该就明白了,看公式推导其实也看得出来,除非预测值越来越偏离原值,不然梯度都是逐渐减小的。

lr=0.03
num_epochs=3
net=linreg
loss=squared_loss
for epoch in range(num_epochs):
for X,y in data_iter(batch_size,features,labels):
l=loss(net(X,w,b),y)
l.sum().backward()
sgd([w,b],lr,batch_size)
with torch.no_grad():
train_1=loss(net(features,w,b),labels)
print(f'epoch{epoch+1},loss {train_1.mean():f}')

这就是最后的实现啦。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

2026-04-11学习
https://suifengstudy.tech/posts/2026-04-11学习/
作者
随风
发布于
2026-04-11
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00