pytorch-5-6 基本优化思想、动态计算图与梯度下降

Lesson 5.基本优化思想

  在正式开始进行神经网络建模之前,我们还需要掌握一些基本数学工具,在PyTorch中,最核心的基础数学工具就是梯度计算工具,也就是PyTorch的AutoGrad(自动微分)模块。AutoGrad模块,就是PyTorch提供的最核心的数学工具模块,我们可以利用其编写一系列的最优化方法,当然,要使用好微分工具,就首先需要了解广泛应用于机器学习建模的优化思想。

  所谓优化思想,指的是利用数学工具求解复杂问题的基本思想,同时也是近现代机器学习算法在实际建模过程中经常使用的基础理论。在实际建模过程中,我们往往会先给出待解决问题的数值评估指标,并在此基础之上构建方程、采用数学工具、不断优化评估指标结果,以期达到可以达到的最优结果。本节,我们将先从简单线性回归入手,探讨如何将机器学习建模问题转化为最优化问题,然后考虑使用数学方法对其进行求解。

一、简单线性回归的机器学习建模思路

  在《Lesson 4.张量的线性代数运算》结尾,我们曾简单提及线性方程建模问题,彼时,我们将线性回归问题转化成,求解以线性方程系数为核心目标的线性方程组求解问题,并且将方程组转化为矩阵表示形式,最终使用逆矩阵的方法解出线性方程系数。线性回归是较为基础且通用的模型,但使用矩阵方法求解却不是一般方法。
  接下来,我们将更进一步,将简单线性回归的求解参数问题转化为最优化问题求解问题,这也是机器学习建模过程中的最通用的思想。

1.回顾简单线性回归建模问题(确定模型是线性回归模型 y=ax+b)

import matplotlib as mpl
import matplotlib.pyplot as plt
A = torch.arange(1, 5).reshape(2, 2).float()
A
# 绘制点图查看两个点的位置
plt.plot(A[:,0], A[:, 1], 'o')

如果更进一步,我们希望在二维空间中找到一条直线,来拟合这两个点,也就是所谓的构建一个线性回归模型,我们可以设置线性回归方程如下:

$ y = ax + b $

2.转化为优化问题(得到一个目标函数SSE)

  上述问题除了可以使用矩阵方法求解以外,还可以将其转化为最优化问题,然后通过求解最优化问题的方法对其进行求解。总的来说,最优化问题的转化分为两步,其一是确定优化数值指标,其二则是确定优化目标函数。在大多数问题中,这二者是相辅相成的,确定了优化的数值指标,也就确定了优化的目标函数。

  如果我们希望通过一条直线拟合二维平面空间上分布的点,最核心的目标,毫无疑问,就是希望方程的预测值和真实值相差较小。假设真实的y值用y表示,预测值用ŷ表示,带入样本点(1,2), (3,4)到 y = ax +b中,

$ŷ_1 = 1*a + b = a + b$
$ŷ_2 = 3*a + b = 3a + b$
  而这两个预测值和真实值相差:
$y_1 = 2, ŷ_1 = a + b, y_1 - ŷ_1 = 2 - a - b$
$y_2 = 4, ŷ_2 = 3a + b, y_2 - ŷ_1=2 = 4 - 3a - b$
  我们希望ŷ和y尽可能接近,因此我们可以考虑计算上述误差总和,但为了避免正负相消(一部分为正、另一部分为负),在衡量上述两个点的误差总和时,我们使用平方和来进行衡量:
SSE = $(y_1 - ŷ_1)^2 + (y_2 - ŷ_2)^2 $
$= (2 - a - b)^2 + (4 - 3a - b)^2$
  上式也就是两个点的预测值和真实值的差值的平方和,也就是所谓的,**误差平方和——SSE**(Sum of the Squared Errors)。

当然,此处我们只带入了(1, 2)和(3, 4)两个点来计算SSE,也就是带入了两条数据来训练y = ax + b这个模型。

  至此,我们已经将原问题转化为了一个最优化问题,接下来我们的问题就是,当a、b取何值时,SSE取值最小?值得注意的是,SSE方程就是我们优化的目标方程(求最小值),因此上述方程也被称为目标函数,同时,SSE代表着真实值和预测值之间的差值(误差平方和),因此也被称为损失函数(预测值距真实值的损失)。

换而言之,就是当SSE取值最小时,a、b的取值,就是最终线性回归方程的系数取值。

值得注意的是,目标函数和损失函数并不完全等价,但大多数目标函数都由损失函数构成。

3.最优化问题的求解方法(求解目标函数SSE最小值:最小二乘法)

  在机器学习领域,大多数优化问题都是围绕目标函数(或者损失函数)进行求解。在上述问题中,我们需要围绕SSE求最小值。
  SSE是一个关于a和b的二元函数,要求其最小值,需要借助数学工具,也就是所谓的最优化方法。选择优化方法并执行相应计算,可以说是整个建模过程中最核心也是相对较难的部分,很多时候这个过程会直接决定模型的性能。

  • 图形展示目标函数

  为了更好的讨论目标函数(SSE)求最小值的过程,对于上述二元函数来说,我们可以将其展示在三维空间内。此处我们可以使用Python中matplotlib包和Axes3D函数进行三维图像绘制

from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
x = np.arange(-1,3,0.05)
y = np.arange(-1,3,0.05)
a, b = np.meshgrid(x, y)
SSE = (2 - a - b) ** 2 + (4 - 3 * a - b) ** 2  # 目标函数
ax = plt.axes(projection='3d')

ax.plot_surface(a, b, SSE, cmap='rainbow')
ax.contour(a, b, SSE, zdir='z', offset=0, cmap="rainbow")  #生成z方向投影,投到x-y平面
plt.show()

在这里插入图片描述

  • 函数的凹凸性
      初步探索函数图像,不难看出,目标函数SSE是个整体看起来“向下凸”的函数,而对于一个凸函数来说,全域最小值明显存在,我们可以进一步给出求解上述SSE凸函数最小值的一般方法,也就是著名的最小二乘法

  • 凸函数的最小值
      凸函数求解最小值的一般方法:a).对于一元函数,如果存在导数为0的点,则该点就是最小值点; b).对于多元函数,如果存在某一点,使得函数的各个自变量的偏导数都为0,则该点就是最小值点。
      因此,对于凸函数的最小值求解,最基本的出发点就是寻找导数为0的点。而最小二乘法也是基于偏导函数取值为0联立的方程组进行的求解。

  函数的凹凸性是函数的重要性质,而在使用优化方法围绕目标函数进行求解时,函数本身性质是决定使用哪种优化方法的重要因素,例如函数是否可导、是否连续、函数凹凸性等等。而要判断函数的各种属性,其实都需要经过非常严格的数学证明,也就是说,一般的采用数学优化方法求解的过程,都是先确定函数形式、然后探索函数性质、之后寻找优化方法、最终执行优化算法求解。
  当然,机器学习建模中的目标函数,大多数时候都是可导的函数,而凹凸性则是影响使用哪种最优化方法的最核心因素。正因如此,凸函数的最优化问题是优化方法的一类重要应用,甚至围绕凸函数,还衍生出了凸优化相关的一大类优化问题分支学课,在机器学习中,我们经常听说的最小二乘法就是求解凸优化问题的重要工具。当然,很多本身不是针对凸函数设计的最优化方法,但鉴于凸函数在求最小值时的优异特性,我们也会在凸优化过程中应用的、例如梯度下降、拟牛顿法等等等等,都是本次课程中重点学习的内容。

  • SSE最小值
    而对于SSE来说,此处虽然不做证明,但对于简单线性回归的损失函数,SSE是凸函数,因此,对于 SSE(a,b) 而言,最小值点就是a、b两个参数求偏导等于0的点
    在这里插入图片描述
    利用偏导等于0得出的方程组求解线性回归方程参数,就是最小二乘法求解过程。此处我们求得a=1,b=1时,SSE(a,b)取得最小值,也就是(1,1)是目标函数的最小值点,此时方程为 y = x + 1。

4.机器学习建模一般流程

  至此,我们就完成了一个基本的简单线性回归建模过程。此处通过一个简单的例子,来介绍机器学习、包括深度学习的利用优化方法建模的一般思想,我们可以将其总结如下:

  • Step 1:提出基本模型(线性回归 y=ax+b)
      如本节中,我们试图利用一条直线(y=ax+b)去拟合二维平面空间中的点,这里我们所使用的这条直线,就是我们提出的基本模型。而在后续的深度学习的学习过程中,我们还将看到更为强大、同时也更加通用的神经网络模型。当然,不同的模型能够适用不同的场景,在提出模型时,我们往往会预设一些影响模型结构或者实际判别性能的参数,如简单线性回归中的a和b;
  • Step 2:确定损失函数和目标函数(SSE)
      接下来,围绕建模的目标,我们需要合理设置损失函数,并在此基础之上设置目标函数,当然,在很多情况下,这二者是相同的。例如,在上述简单线性回归中,我们的建模目标就是希望y=ax+b这条直线能够尽可能的拟合(1,2)、(3,4)这两个点,或者说尽可能“穿过”这两个点,因此我们设置了SSE作为损失函数,也就是预测值和真实值的差值平方和。当然,在计算过程中不难发现,SSE是一个包含了a和b这两个变量的方程,因此SSE本身也是一个函数(a和b的二元函数),并且在线性回归中,SSE既是损失函数(用于衡量真实值和预测值差值的函数),同时也是我们的目标函数(接下来需要优化、或者说要求最小值的函数)。这里尤其需要注意的是,损失函数不是模型,而是模型参数所组成的一个函数。
  • Step 3:根据目标函数特性,选择优化方法,求解目标函数 (求解SSE最小值:最小二乘法)
      之前提到,目标函数既承载了我们优化的目标(让预测值和真实值尽可能接近),同时也是包含了模型参数的函数,因此完成建模需要确定参数、优化结果需要预测值尽可能接近真实值这两方面需求就统一到了求解目标函数最小值的过程中了,也就是说,当我们围绕目标函数求解最小值时,也就完成了模型参数的求解。当然,这个过程本质上就是一个数学的最优化过程,求解目标函数最小值本质上也就是一个最优化问题,而要解决这个问题,我们就需要灵活适用一些最优化方法。当然,在具体的最优化方法的选择上,函数本身的性质是重要影响因素,也就是说,不同类型、不同性质的函数会影响优化方法的选择。在简单线性回归中,由于目标函数是凸函数,我们根据凸函数性质,判断偏导函数取值为0的点就是最小值点,进而完成a、b的计算(也就是最小二乘法),其实就是通过函数本身的性质进行最优化方法的选取。

Lesson 6.动态计算图与梯度下降入门

  PyTorch中的AutoGrad(自动微分)模块中的autograd.grad进行函数的微分运算,可以灵活进行函数某一点的导数或偏导数的运算,但微分计算其实也只是AutoGrad模块中的一小部分功能。本节课,我们将继续讲解AutoGrad模块中的其他常用功能,并在此基础上介绍另一个常用优化算法:梯度下降算法。

import numpy as np
import torch

一、AutoGrad的回溯机制与动态计算图

1.可微分性相关属性

  在上一节中我们提到,新版PyTorch中的张量已经不仅仅是一个纯计算的载体,张量本身也可支持微分运算。这种可微分性其实不仅体现在我们可以使用grad函数对其进行求导,更重要的是这种可微分性会体现在可微分张量参与的所有运算中。

  • requires_grad属性:可微分性
# 构建可微分张量
x = torch.tensor(1.,requires_grad = True)
x
# 构建函数关系
y = x ** 2
  • grad_fn属性:存储Tensor微分函数
y

我们发现,此时张量y具有了一个grad_fn属性,并且取值为<PowBackward0>,我们可以查看该属性

y.grad_fn

grad_fn其实是存储了Tensor的微分函数,或者说grad_fn存储了可微分张量在进行计算的过程中函数关系,此处x到y其实就是进行了幂运算。

# 但x作为初始张量,并没有grad_fn属性
x.grad_fn

这里值得注意的是,y不仅和x存在幂运算关系(y = x**2),更重要的是,y本身还是一个有x张量计算得出的一个张量

y

而对于一个可微分张量生成的张量,也是可微分的

y.requires_grad

也就是相比于x,y不仅同样拥有张量的取值,并且同样可微,还额外存储了x到y的函数计算信息。我们再尝试围绕y创建新的函数关系,z = y + 1

z = y + 1
z
z.requires_grad
z.grad_fn

不难发现,z也同时存储了张量计算数值、z是可微的,并且z还存储了和y的计算关系(add)。据此我们可以知道,在PyTorch的张量计算过程中,如果我们设置初始张量是可微的,则在计算过程中,每一个由原张量计算得出的新张量都是可微的,并且还会保存此前一步的函数关系,这也就是所谓的回溯机制。而根据这个回溯机制,我们就能非常清楚掌握张量的每一步计算,并据此绘制张量计算图。

2.张量计算图

  借助回溯机制,我们就能将张量的复杂计算过程抽象为一张图(Graph),例如此前我们定义的x、y、z三个张量,三者的计算关系就可以由下图进行表示。
在这里插入图片描述

  • 计算图的定义
      上图就是用于记录可微分张量计算关系的张量计算图,图由节点和有向边构成,其中节点表示张量,边表示函数计算关系,方向则表示实际运算方向,张量计算图本质是有向无环图。
  • 节点类型
      在张量计算图中,虽然每个节点都表示可微分张量,但节点和节点之间却略有不同。就像在前例中,y和z保存了函数计算关系,但x没有,而在实际计算关系中,我们不难发现z是所有计算的终点,因此,虽然x、y、z都是节点,但每个节点却并不一样。此处我们可以将节点分为三类,分别是:
    • a):叶节点,也就是初始输入的可微分张量,前例中x就是叶节点;
    • b):输出节点,也就是最后计算得出的张量,前例中z就是输出节点;
    • c):中间节点,在一张计算图中,除了叶节点和输出节点,其他都是中间节点,前例中y就是中间节点。

当然,在一张计算图中,可以有多个叶节点和中间节点,但大多数情况下,只有一个输出节点,若存在多个输出结果,我们也往往会将其保存在一个张量中。

3.计算图的动态性

  值得一提的是,PyTorch的计算图是动态计算图,会根据可微分张量的计算过程自动生成,并且伴随着新张量或运算的加入不断更新,这使得PyTorch的计算图更加灵活高效,并且更加易于构建,相比于先构件图后执行计算的部分框架(如老版本的TensorFlow),动态图也更加适用于面向对象编程。

二、反向传播与梯度计算

1.反向传播的基本过程

  除了autograd.grad进行函数某一点的导数值得计算,我们还有另一种方法,也能进行导数运算:反向传播。当然,此时导数运算结果我们也可以有另一种解读:计算梯度结果。

注:此处我们暂时不区分微分运算结果、导数值、梯度值三者区别,目前位置三个概念相同,后续讲解梯度下降时再进行区分。

首先,对于某一个可微分张量的导数值(梯度值),存储在grad属性中。

x.grad

在最初,x.grad属性是空值,不会返回任何结果,我们虽然已经构建了x、y、z三者之间的函数关系,x也有具体取值,但要计算x点导数,还需要进行具体的求导运算,也就是执行所谓的反向传播。所谓反向传播,我们可以简单理解为,在此前记录的函数关系基础上,反向传播函数关系,进而求得叶节点的导数值。在必要时求导,这也是节省计算资源和存储空间的必要规定。

z
z.grad_fn
# 执行反向传播
z.backward()
# 反向传播结束后,即可查看叶节点的导数值
x
# 在z=y+1=x**2+1函数关系基础上,x取值为1时的导数值
x.grad

注意,在默认情况下,在一张计算图上执行反向传播,只能计算一次,再次调用backward方法将报错

z.backward() # 报错

当然,在y上也能执行反向传播

x = torch.tensor(1.,requires_grad = True)
y = x ** 2
z = y + 1
y.backward()
x.grad

第二次执行时也会报错

y.backward() 	# 报错
z.backward() 	# 报错

无论何时,我们只能计算叶节点的导数值

y.grad

至此,我们就了解了反向传播的基本概念和使用方法:

  • 反向传播的本质:函数关系的反向传播(不是反函数);
  • 反向传播的执行条件:拥有函数关系的可微分张量(计算图中除了叶节点的其他节点);
  • 反向传播的函数作用:计算叶节点的导数/微分/梯度运算结果;

2.反向传播运算注意事项

  • 中间节点反向传播和输出节点反向传播区别
      尽管中间节点也可进行反向传播,但很多时候由于存在复合函数关系,中间节点反向传播的计算结果和输出节点反向传播输出结果并不相同。
x = torch.tensor(1.,requires_grad = True)
y = x ** 2
z = y ** 2
z.backward()		# 输出节点反向传播

x.grad
x = torch.tensor(1.,requires_grad = True)
y = x ** 2
z = y ** 2
y.backward()		# 中间节点反向传播
x.grad
  • 中间节点的梯度保存
      默认情况下,在反向传播过程中,中间节点并不会保存梯度
x = torch.tensor(1.,requires_grad = True)
y = x ** 2
z = y ** 2
z.backward()
y.grad
x.grad

若想保存中间节点的梯度,我们可以使用retain_grad()方法

x = torch.tensor(1.,requires_grad = True)
y = x ** 2
y.retain_grad()		# 保存中间节点的梯度
z = y ** 2
z.backward()

y
y.grad
x.grad

3.阻止计算图追踪

  在默认情况下,只要初始张量是可微分张量,系统就会自动追踪其相关运算,并保存在计算图关系中,我们也可通过grad_fn来查看记录的函数关系,但在特殊的情况下,我们并不希望可微张量从创建到运算结果输出都被记录,此时就可以使用一些方法来阻止部分运算被记录。

  • with torch.no_grad():阻止计算图记录
      例如,我们希望x、y的函数关系被记录,而y的后续其他运算不被记录,可以使用with torch.no_grad()来组织部分y的运算不被记录。
x = torch.tensor(1.,requires_grad = True)
y = x ** 2
with torch.no_grad():   	#运算不被记录
    z = y ** 2

with相当于是一个上下文管理器,with torch.no_grad()内部代码都“屏蔽”了计算图的追踪记录

z
z.requires_grad 	# 不可导
y
  • .detach()方法:创建一个不可导的相同张量
    在某些情况下,我们也可以创建一个不可导的相同张量参与后续运算,从而阻断计算图的追踪
x = torch.tensor(1.,requires_grad = True)
y = x ** 2
y1 = y.detach()		# 不可导
z = y1 ** 2

y			# 可导
y1			# 不可导
z			# 不可导

4.识别叶节点

  由于叶节点较为特殊,如果需要识别在一个计算图中某张量是否是叶节点,可以使用is_leaf属性查看对应张量是否是叶节点。

x.is_leaf
y.is_leaf

但is_leaf方法也有容易混淆的地方,对于任何一个新创建的张量,无论是否可导、是否加入计算图,都是可以是叶节点,这些节点距离真正的叶节点,只差一个requires_grad属性调整。

torch.tensor([1]).is_leaf
# 经过detach的张量,也可以是叶节点
y1
y1.is_leaf

三、梯度下降基本思想

  有了AutoGrad模块中各函数方法的支持,接下来,我们就能尝试手动构建另一个优化算法:梯度下降算法。

1.最小二乘法的局限与优化

  在《Lesson 5.》中,我们尝试使用最小二乘法求解简单线性回归的目标函数,并顺利的求得了全域最优解。但正如上节所说,在所有的优化算法中最小二乘法虽然高效并且结果精确,但也有不完美的地方,核心就在于最小二乘法的使用条件较为苛刻,要求特征张量的交叉乘积结果必须是满秩矩阵,才能进行求解。而在实际情况中,很多数据的特征张量并不能满足条件,此时就无法使用最小二乘法进行求解。
最小二乘法结果:
w ^ T = ( X T X ) − 1 X T y \hat w ^T = (X^TX)^{-1}X^Ty w^T=(XTX)1XTy  当最小二乘法失效的情况时,其实往往也就代表原目标函数没有最优解或最优解不唯一。针对这样的情况,有很多种解决方案,例如,岭回归算法。

  岭回归算法在原矩阵方程中加入一个扰动项 λ I \lambda I λI,修改后表达式如下:
w ^ T ∗ = ( X T X + λ I ) − 1 X T y \hat w ^{T*} = (X^TX + \lambda I)^{-1}X^Ty w^T=(XTX+λI)1XTy
其中, λ \lambda λ是扰动项系数, I I I是单元矩阵。由矩阵性质可知,加入单位矩阵后, ( X T X + λ I ) (X^TX + \lambda I) (XTX+λI)部分一定可逆,而后即可直接求解 w ^ T ∗ \hat w^{T*} w^T。上式修改后求得的结果就不再是全域最小值,而是一个接近最小值的点。鉴于许多目标函数本身也并不存在最小值或者唯一最小值,在优化的过程中略有偏差也是可以接受的。
  伴随着深度学习的逐渐深入,我们会发现,最小值并不唯一存在才是目标函数的常态。基于此情况,很多根据等式形变得到的精确的求解析解的优化方法(如最小二乘)就无法适用,此时我们需要寻找一种更加通用的,能够高效、快速逼近目标函数优化目标的最优化方法。在机器学习领域,最通用的求解目标函数的最优化方法就是著名的梯度下降算法
  值得一提的是,我们通常指的梯度下降算法,并不是某一个算法,而是某一类依照梯度下降基本理论基础展开的算法簇,包括梯度下降算法、随机梯度下降算法、小批量梯度下降算法等等。接下来,我们就从最简单的梯度下降入手,讲解梯度下降的核心思想和一般使用方法。

2.梯度下降核心思想

  梯度下降的基本思想其实并不复杂,其核心就是希望能够通过数学意义上的迭代运算,从一个随机点出发,一步步逼近最优解,而其基本过程则:

  • 在目标函数上随机找到一个初始点;
  • 通过迭代运算,一步步逼近最小值点;
    11

数学意义上的迭代运算,指的是上一次计算的结果作为下一次运算的初始条件带入运算

3.梯度下降的方向与步长

  当然,梯度下降的基本思想好理解,但实现起来却并不容易(这也是大多数机器学习算法的常态)。在实际沿着目标函数下降的过程中,我们核心需要解决两个问题,其一是往哪个方向走,其二是每一步走多远。以上述简单线性回归的目标函数为例,在三维空间中,目标函数上的每个点理论上都有无数个移动的方向,每次移动多远的物理距离也没有明显的约束,而这些就是梯度下降算法核心需要解决的问题,也就是所谓的方向和步长
首先,是关于方向的讨论。

关于方向的讨论,其实梯度下降是采用了一种局部最优推导全域最优的思路,我们首先是希望能够找到让目标函数变化最快的方向作为移动的方向,而这个方向,就是梯度。

3.1 导数与梯度

  我们都知道,函数上某一点的导数值的几何含义就是函数在该点上切线的斜率,代表含义是给该点一个无穷小的增量,该点只能沿着切向方向移动(但仍然在曲线上)。当然,该点导数值的另外一个解释就是该点的梯度,梯度的值(grad)和导数相同,而梯度的概念可以视为导数概念的延申,只不过梯度更侧重方向的概念,也就是从梯度的角度解读导数值,就代表着当前这个点的可以使得y值增加最快的移动方向

梯度:梯度本身是一个代表方向的矢量,代表某一函数在该点处沿着梯度方向变化时,变化率最大。当然,梯度的正方向代表函数值增长最快的方向,梯度的负方向表示函数减少最快的方向。

  不过此时由于自变量存在一维空间,只能沿着x轴变化(左右移动,只有两个方向),梯度给出的方向只能解读为朝着斜率方向,也就是正方向变化时,y的增加最快(确实如此,同时也显而易见)。

3.2 梯度与方向

**Point:**这里有关于方向的两点讨论

  • 方向没有大小,虽然这是个显而易见的观点,但我们当我们说朝着方向移动,只是说沿着直线移动,并非一步移动到(-28,-12)上;
  • 方向跟随梯度,随时在发生变化。值得注意的是,一旦点发生移动,梯度就会随之发生变化,也就是说,哪怕是沿着让sse变化最快的方向移动,一旦“沿着方向”移动了一小步,这个方向就不再是最优方向了。如果每个点的梯度提供了移动方向的最优解,那移动多长,其实并没有统一的规定。这里,我们将上述0.01称作学习率,而学习率乘以梯度,则是原点移动的“长度”。在移动若干次之后,就将得到非常接近于(1,1)的结果。

2.再次理解步长

对于步长的设置,我们有如下初步结论:

  • 步长太短:会极大的影响迭代收敛的时间,整体计算效率会非常低;
    14
  • 步长太长:容易跳过最优解,导致结果震荡。
    13
    关于步长的设置,其实更多的会和实际使用情况相关,和实际损失函数特性相关。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白白白飘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值