《Machine Learning in Action》—— 浅谈线性回归的那些事

10 篇文章 0 订阅
9 篇文章 0 订阅

手撕机器学习系列文章就暂时更新到此吧,目前已经完成了支持向量机SVM、决策树、KNN、贝叶斯、线性回归、Logistic回归,其他算法还请允许Taoye在这里先赊个账,后期有机会有时间再给大家补上。

更新至此,也是收到了部分读者的好评。虽然不多,但还是非常感谢大家的支持,希望每一位阅读过的读者都能够有所收获。

该系列文章的全部内容都是Taoye纯手打,也是参考了不少书籍以及公开资源,系列总字数在15W左右(含源码),后期会再慢慢填补,更多的技术文章可以来访Taoye的公众号:玩世不恭的Coder。文档可以随意传播,但注意不可修改其中的内容。

如果文章中有任何不懂的问题,都可以直接提出,Taoye看见后会第一时间回复,同时欢迎大家来此私密地向Taoye催更:玩世不恭的Coder,公众号也有Taoye的私人联系方式。有些话,Taoye只能在那里和你偷偷地说 (#`O′)

为了提高大家的阅读体验,手撕机器学习系列文章Taoye已经整理成PDF和和HTML,阅读效果都很不错,在公众号【玩世不恭的Coder】下回复【666】即可免费获取。

手撕机器学习算法系列文章已经肝了不少,自我感觉质量都挺不错的。目前已经更新了支持向量机SVM、决策树、K-近邻(KNN)、贝叶斯分类,读者可根据以下内容自行“充电”(持续更新中):

阅读过上述那些文章的读者应该都知道,上述内容都属于分类算法,分类的目标变量都是标称型数据(关于标称型和数据型数据的意思的可参考上文)。而本文主要讲述的内容是线性回归,它是一种回归拟合问题,会对连续性数据做出预测,而非判别某个样本属于哪一类。

本文主要包括的内容有如下几部分:

  • 线性回归,我们来谈谈小姐姐如何相亲
  • 揭开梯度下降算法的神秘面纱
  • 基于梯度下降算法实现线性回归拟合

一、线性回归,我们来谈谈小姐姐如何相亲

回归预测,回归预测,说到底就包括两个部分。

一个是回归(拟合),另一个是预测。回归是为预测做准备或者说是铺垫,只有基于已有的数据集我们才能构建一个的回归模型,然后根据这个回归模型来处理新样本数据的过程就是预测。

而线性回归就是我们的回归模型属于线性的,也就是说我们样本的每个属性特征都最多是一次的(进来的读者 应该都知道吧)

为了让读者对线性回归有个基本的了解,我们来聊聊小姐姐的相亲故事。

故事是这样的。

在很久很久以前,有位小姐姐打算去相亲,她比较在意对象的薪资情况,但这种事情也不太好意思直入主题,你说是吧?所以呢,她就想着通过相亲对象本身的属性特征来达到一个预测薪资的目的。假如说这位小姐姐认为对象的薪资主要有两个部分的数据的组成,分别是对象的年龄和头发量。对此,小姐姐想要构建出这么一个关于薪资的线性模型:

s a l a r y = w 1 ∗ h a i r _ n u m b e r + w 2 ∗ a g e salary = w_1 * hair\_number + w_2 * age salary=w1hair_number+w2age

中文形式的描述就是:

小 姐 姐 对 象 的 薪 资 = w 1 ∗ 小 姐 姐 对 象 的 发 量 + w 2 ∗ 小 姐 姐 对 象 的 年 龄 小姐姐对象的薪资 = w_1 * 小姐姐对象的发量 + w_2 * 小姐姐对象的年龄 =w1+w2

所以呢,小姐姐现在的目的就是想要得到这么一个 w 1 和 w 2 w_1和w_2 w1w2的值,然后观察和询问相亲对象的发量以及年龄,就可以根据这个线性模型计算得出其相亲对象的薪资情况。

那么,如何得到 w 1 , w 2 w_1,w_2 w1w2的值呢???就在小姐姐脑阔疼的厉害之时,Taoye是这么手握手教小姐姐的:“小姐姐,你可以先相亲1000个对象,观察并询问对象的发量和年龄之后,然后通过社会工程学来得到他的薪资情况。有了这1000组对象数据之后,你就能训练出 w 1 和 w 2 w_1和w_2 w1w2的值,从而得到误差达到最小时候的这个线性模型”

小姐姐听完Taoye的讲述之后,真的是一语惊醒梦中人啊,心想:妙啊,就这么干!!!

以上例子中的内容纯属Taoye胡扯,只为描述线性回归的过程,不代表任何观点。

二、揭开梯度下降算法的神秘面纱

通过上述小姐姐的相亲故事,相信各位看官都已经对线性回归的过程有了一个基本的认识,而要具体操作线性回归,我们还需明白一个在机器学习领域中比较重要的算法,也就是梯度下降算法。

要理解梯度下降算法,我们可以将其类比成一个人下山的过程,这也是我们理解梯度下降算法时候最常用的一个例子,也就是这么一个场景:

有个人被困在山上,他现在要做的就是下山,也就是到达山的最低点。但是呢,现在整座山烟雾缭绕,可见度非常的低,所以下山的路径暂时无法确定,他必须通过自己此刻所在地的一些信息来一步步找到下山的路径。此时,就是梯度下降算法大显身手的时候了。具体怎么做呢?

是这样的,首先会以他当前所在地为基准,寻找此刻所处位置的最陡峭的地方,然后朝着下降的方向按照自己的设定走一步。走一步之后,就来到了一个新的位置,然后将这个新的位置作为基准,再找到最陡峭的地方,沿着这个方向再走一步,如此循环往复,知道走到最低点。这就是梯度下降算法的类别过程,上山同理也是一样,只不过变成了梯度上升算法。

梯度下降算法的基本过程就类似于上述下山的场景。

首先,我们会有一个可微分的函数。这个函数就类似于上述的一座山。我们的目标就是找到这个函数的最小值,也就是上述中山的最低点。根据之前的场景假设,最快的下山的方式就是找到当前位置最陡峭的方向,然后沿着此方向向下走,在这个可微分函数中,梯度反方向就代表这此山最陡峭的方向,也就是函数下降最快的方向。因为梯度的方向就是函数变化最快的方向(在后面会详细解释)

所以,我们重复利用这个方法,在达到一个新的位置之后,反复求取梯度,最后就能到达局部的最小值,这就类似于我们下山的过程。而求取梯度就确定了最陡峭的方向,也就是场景中测量方向的手段。那么为什么梯度的方向就是最陡峭的方向呢?接下来,我们从微分开始讲起:

  • 单变量

d ( x 2 + 2 x + 1 ) d x = 2 x + 2 d ( 2 x + 1 ) 2 d x = 8 x + 4 \begin{aligned} & \frac{d(x^2+2x+1)}{dx}=2x+2 \\ & \frac{d(2x+1)^2}{dx}=8x+4 \end{aligned} dxd(x2+2x+1)=2x+2dxd(2x+1)2=8x+4

对于单变量微分来讲,由于函数只有一个变量,所以此时的梯度就是函数的微分,所代表的意义就是在该点所对应的斜率。

  • 多变量(以三个变量为例)

J ( x ) = 3 x 1 − 2.7 x 2 + 5.1 x 3 + 7.9 ▽ J ( x ) = ( d J d x 1 , d J d x 2 , d J d x 3 ) = ( 3 , − 2.7 , 5.1 ) \begin{aligned} & J(x) = 3x_1-2.7x_2+5.1x_3 + 7.9 \\ & \bigtriangledown J(x)=(\frac{dJ}{dx_1},\frac{dJ}{dx_2},\frac{dJ}{dx_3})=(3, -2.7, 5.1) \end{aligned} J(x)=3x12.7x2+5.1x3+7.9J(x)=(dx1dJ,dx2dJ,dx3dJ)=(3,2.7,5.1)

对于多变量函数来讲,此时的梯度就不再是一个具体的值,而是一个向量。我们都知道,向量是有方向的,而该梯度的方向就代表着函数在指定点中上升最快的方向。

这也就说明了为什么我们需要千方百计的求取梯度!我们需要到达山底,就需要在每一步观测到此时最陡峭的地方,梯度就恰巧告诉了我们这个方向。梯度的方向是函数在给定点上升最快的方向,那么梯度的反方向就是函数在给定点下降最快的方向,这正是我们所需要的。所以我们只要沿着梯度的方向一直走,就能走到局部的最低点!

现在,我们不妨通过代码来模拟实现这个过程。假如说,我们现在的目标函数是:

y = x 2 + 2 x + 1 y = x^2+2x+1 y=x2+2x+1

则其对对应的梯度为:

d y d x = 2 x + 2 \frac{dy}{dx}=2x+2 dxdy=2x+2

对此,我们可以通过如下代码来模拟梯度下降的过程,以寻找出到达最低点时候的 x x x值:

通过上述代码,我们可以发现,当函数值达到最低点的时候,此时我们的 x = − 0.9999517797740893 x=-0.9999517797740893 x=0.9999517797740893,与我们手动计算的 x = − 1 x=-1 x=1基本可以划等号,这就是梯度下降所解决的问题。

针对上述代码,这里我们主要说两个点:

①:x_new = x_old - learning_rate * gradient(x_old)

在前进过程中更新x_new的时候,是通过x_old来进行的,之所以在梯度前加一个负号,主要是为了朝着梯度相反的方向前进。在前文我们也要提到,梯度的反方向就是函数在此点下降最快的方向。那么如果是上坡,也就是梯度上升算法,此时x_new的更新过程也就不需要加上负号了。

至于什么时候是梯度上升,什么时候是梯度下降,这个是根据我们实际情况是求最小值,还是最大值来决定的。

②:learning_rate

learning_rate在梯度下降算法中被称作为学习率或者说是步长,意味着我们可以通过learning_rate来控制每一步走的距离,其实就是不要走太快,从而错过了最低点。同时也要保证不要走的太慢,导致我们打到最低点需要花费大量的时间,也就是效率太低了,需要迭代很多次才能满足我们的需求。所以learning_rate的选择在梯度下降法中往往是很重要的!

需要合理的选择learning_rate值,一般来讲可取0.01,具体问题还需具体分析。

总而言之,梯度下降算法主要是根据函数的梯度来对x的值进行不断的更新迭代,以求得函数到达最小值时候的x值。

当然了,以上是该算法的一般形式,同时各位研究者也是提出了一些梯度下降算法的变种形式,主要有以下三种

  1. 随机梯度下降算法(SGD,根据时间复杂度)
  2. 批量梯度下降算法(BGD,根据数据量的大小)
  3. 小批量梯度下降算法(MBGD,算法准确性)

关于上述三种梯度下降算法的变种形式,我们在这里挖个坑,后面有机会再来慢慢把这个坑个填上。

三、基于梯度下降算法实现线性回归拟合

这里代码实战的话,其实是牵涉到对神经网络的理解,不过我们在这里不着重讲解神经网络的内容,只简单的提一下,待手撕机器学习系列文章完成之后再来详细看看。

参考资料:《TensorFlow深度学习》——龙龙老师

我们都知道,人的大脑中包含了大量的神经元细胞,每个神经元都通过树突来获取输入的信号,然后通过轴突传递并输出信号,而神经元与神经元之间相互连接从而构成了巨大的神经网络。生物神经元的结构如下图所示:

图片来源:维基百科

1943年,心理学家沃伦·麦卡洛克(Warren McCulloch)和数理逻辑学家沃尔特·皮茨(Walter Pitts)通过对生物神经元的研究,提出了模拟生物神经元机制的人工神经网络的数学模型,这一成果被美国神经学家弗兰克·罗森布拉特(Frank Rosenblatt)进一步发展成感知机(Perceptron)模型,这也是现代深度学习的基石。

我们从神经元的结构出发,来模拟这个神经元的信号处理过程。

如下图a所示,神经元输入向量 x = [ x 1 , x 2 , . . . , x n ] T x=[x_1,x_2,...,x_n]^T x=[x1,x2,...,xn]T,经过函数映射: f θ : x − > y f_\theta:x->y fθ:x>y后得到y,其中 θ \theta θ为函数f的自身参数。在这里,我们考虑一种简化的情况,也就是线性变换: f ( x ) = w T x + b f(x)=w^Tx+b f(x)=wTx+b,因为其中的w和x都是向量,所以我们将其展开为标量形式可表示为:

f ( x ) = w 1 x 1 + w 2 x 2 + . . . + w n x n + b f(x)=w_1x_1 + w_2x_2+...+w_nx_n+b f(x)=w1x1+w2x2+...+wnxn+b

上述计算的逻辑过程可通过下图b直观展现

以上神经元含有多个输入,为了方便理解,我们不妨进一步简化模型,此时的n=1,即我们假设该线性模型为:

y = w x + b y = wx+b y=wx+b

其中 w w w体现的是模型的斜率,而b体现的是截距,或者在这里我们说是偏置。

我们知道,对于一条直线来讲,我们只需要已知两个点,求解二元一次方程组,就能得到该直线的具体表达式,也就是求解出 w , b w,b w,b的值。

理想状态下的确是这样的,但是现实总是残酷的,我们所获取到的数据可能存在一定的误差,此时我们就根本无法构建出这么一条完美的直线来切合这些数据点。我们不妨用 ε \varepsilon ε来表示该观测误差,假设该误差满足 N ( μ , σ 2 ) N(\mu,\sigma^2) N(μ,σ2)的高斯分布,则我们的模型可转换为:

y = w x + b + ε , ε ∈ N ( μ , σ 2 ) y = wx+b+\varepsilon,\varepsilon \in N(\mu,\sigma^2) y=wx+b+ε,εN(μ,σ2)

虽然我们不可能通过一条直线来完美的通过这些数据点,所以我们现在的需求就是尽可能找到这么一条直线,使得所有数据点到这条直线的“距离”最小,那么得到的这条直线就是我们比较满意的模型。

那么如何衡量所有数据点到达这条直线的“距离”最小?如何衡量这个模型的“好”与“不好”呢?这个时候就需要引出我们的损失函数了,一个很自然的想法就是求出当前模型的所有采样点上的预测值与真实值之间差的平方和的均值来作为这个模型的损失函数,也就是我们常常所提到的均方误差,损失函数 L L L表达如下:

L = 1 n ∑ i = 1 n ( w x ( i ) + b − y ( i ) ) 2 L=\frac{1}{n}\sum_{i=1}^n(wx^{(i)}+b-y^{(i)})^2 L=n1i=1n(wx(i)+by(i))2

当我们的损失函数计算的值比较大的时候,此时说明该直线的拟合效果并不好,当我们的损失函数计算的值比较小的时候,说明此时的拟合效果达到了一个不错的程度。所以,我们不妨令损失函数值达到最小时,此时的模型参数为 w ∗ , b ∗ w^*,b^* w,b,则为:

读到这里,各位看官是不是知道下文如何走笔的了。没错,接下来就是通过梯度下降算法来求解该损失函数的最小值,

对此,我们需要求解出损失函数分别对 w , b w,b wb的偏导,求解过程如下:

即:

∂ L ∂ w = 2 n ∑ i = 1 n ( w x ( i ) + b − y ( i ) ) x ( i ) ∂ L ∂ b = 2 n ∑ i = 1 n ( w x ( i ) + b − y ( i ) ) \begin{aligned} & \frac{\partial L}{\partial w}=\frac{2}{n}\sum_{i=1}^n(wx^{(i)}+b-y^{(i)})x^{(i)} \\ & \frac{\partial L}{\partial b}=\frac{2}{n}\sum_{i=1}^n(wx^{(i)}+b-y^{(i)}) \end{aligned} wL=n2i=1n(wx(i)+by(i))x(i)bL=n2i=1n(wx(i)+by(i))

得到偏导之后,我们就可以根据旧的 w , b w,b w,b更新得到新的 w , b w,b w,b,这就是一次更新迭代过程。更新之后,我们再次重新计算偏导并更新参数,如此不断循环往复,知道我们计算的损失函数的值得到一个我们可接受的范围,即达到了我们的目的。

下面,我们通过代码来模拟实现这个过程。

  • NumPy随机生成数据集,并通过Matplotlib初步观察数据的分布
"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 用于生成样本数据
    Return:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
"""
def establish_data(data_number):
    x_data = np.random.uniform(-10, 10, data_number)
    eps = np.random.normal(0, 1, data_number)
    y_data = x_data * 1.474 + 0.86 + eps
    return x_data, y_data
    
if __name__ == "__main__":
    x_data, y_data = establish_data(100)
    from matplotlib import pyplot as plt
    plt.scatter(x_data, y_data)
    plt.show()

运行结果如下,可以看出数据分布大致可通过一条直线来进行拟合:

  • 根据数据和当前的w、b值计算均方误差
"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 计算均方误差
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w_now: 当前的w参数
        b_now: 当前的b参数
    Return:
        mse_value: 均方误差值
"""
def calc_mse(x_data, y_data, w_now, b_now):
    x_data, y_data = np.mat(x_data), np.mat(y_data)
    _, data_number = x_data.shape
    return np.power(w_now * x_data + b_now - y_data, 2).sum() / float(data_number)
  • 单次对w、b参数进行更新迭代
"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 更新迭代一次w、b
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w_now: 当前的w参数
        b_now: 当前的b参数
        learning_rate: 学习率
    Return:
        w_new: 更新迭代之后的w
        b_new: 更新迭代之后的b
"""
def step_gradient(x_data, y_data, w_now, b_now, learning_rate):
    x_data, y_data = np.mat(x_data), np.mat(y_data)
    w = (w_now * x_data + b_now - y_data) * x_data.T * 2 / x_data.shape[1]
    b = (w_now * x_data + b_now - y_data).sum() * 2 / x_data.shape[1]
    return w_now - w * learning_rate, b_now - b * learning_rate
  • 多次迭代更新w、b(外循环)
"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 多次迭代更新w、b(外循环)
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        starting_w: 初始的w参数
        starting_b: 初试的b参数
        learning_rate: 学习率
        max_iter:最大迭代次数
    Return:
        w:得到的最终w
        b: 得到的最终b
        loss_list: 每次迭代计算的损失值
"""
def gradient_descent(x_data, y_data, starting_b, starting_w, learning_rate, max_iter):
    b, w = starting_b, starting_w
    loss_list = list()
    for step in range(max_iter):
        w, b = step_gradient(x_data, y_data, w, b, learning_rate)
        loss = calc_mse(x_data, y_data, w, b)
        loss_list.append(loss)
    return w, b, np.array(loss_list)
  • 拟合结果的可视化
"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 拟合结果的可视化
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w: 拟合得到的模型w参数
        b: 拟合得到的模型b参数
        loss_list: 每次更新迭代得到的损失函数的值
"""
def plot_result(x_data, y_data, w, b, loss_list):
    from matplotlib import pyplot as plt
    %matplotlib inline
    
    plt.subplot(2, 1, 1)
    plt.scatter(x_data, y_data)
    x_line_data = np.linspace(-10, 10, 1000)
    y_line_data = x_line_data * w + b
    plt.plot(x_line_data, y_line_data, "--", color = "red")
    
    plt.subplot(2, 1, 2)
    plt.plot(np.arange(loss_list.shape[0]), loss_list)
    plt.show()

程序运行结果如下:

从上方的运行结果来看,我们可以分析得到线性回归模型的拟合效果还不错,完全能够体现出数据的分布规律。另外,通过损失函数的变化图以及具体数值,我们可以观察到,前期损失值的变化非常的大,到了后期基本居于平缓,看比如说第一次到后面计算的损失值分别为14.215、4.0139、1.941188…,这就是梯度下降法所体现出来的效果,也就是说我们的损失函数值越大,我们梯度下降法优化的效果也就越明显。

完整代码:

import numpy as np

"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 用于生成样本数据
    Return:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
"""
def establish_data(data_number):
    x_data = np.random.uniform(-10, 10, data_number)
    eps = np.random.normal(0, 1, data_number)
    y_data = x_data * 1.474 + 0.86 + eps
    return x_data, y_data

"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 计算均方误差
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w_now: 当前的w参数
        b_now: 当前的b参数
    Return:
        mse_value: 均方误差值
"""
def calc_mse(x_data, y_data, w_now, b_now):
    x_data, y_data = np.mat(x_data), np.mat(y_data)
    _, data_number = x_data.shape
    return np.power(w_now * x_data + b_now - y_data, 2).sum() / float(data_number)

"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 更新迭代一次w、b
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w_now: 当前的w参数
        b_now: 当前的b参数
        learning_rate: 学习率
    Return:
        w_new: 更新迭代之后的w
        b_new: 更新迭代之后的b
"""
def step_gradient(x_data, y_data, w_now, b_now, learning_rate):
    x_data, y_data = np.mat(x_data), np.mat(y_data)
    w = (w_now * x_data + b_now - y_data) * x_data.T * 2 / x_data.shape[1]
    b = (w_now * x_data + b_now - y_data).sum() * 2 / x_data.shape[1]
    return w_now - w * learning_rate, b_now - b * learning_rate

"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 多次迭代更新w、b(外循环)
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        starting_w: 初始的w参数
        starting_b: 初试的b参数
        learning_rate: 学习率
        max_iter:最大迭代次数
    Return:
        w:得到的最终w
        b: 得到的最终b
        loss_list: 每次迭代计算的损失值
"""
def gradient_descent(x_data, y_data, starting_b, starting_w, learning_rate, max_iter):
    b, w = starting_b, starting_w
    loss_list = list()
    for step in range(max_iter):
        w, b = step_gradient(x_data, y_data, w, b, learning_rate)
        loss = calc_mse(x_data, y_data, w, b)
        loss_list.append(loss)
    return w, b, np.array(loss_list)

"""
    Author: Taoye
    微信公众号: 玩世不恭的Coder
    Explain: 拟合结果的可视化
    Parameters:
        x_data: 数据样本的一个属性
        y_data: 数据样本的另一个属性
        w: 拟合得到的模型w参数
        b: 拟合得到的模型b参数
        loss_list: 每次更新迭代得到的损失函数的值
"""
def plot_result(x_data, y_data, w, b, loss_list):
    from matplotlib import pyplot as plt
    %matplotlib inline
    
    plt.subplot(2, 1, 1)
    plt.scatter(x_data, y_data)
    x_line_data = np.linspace(-10, 10, 1000)
    y_line_data = x_line_data * w + b
    plt.plot(x_line_data, y_line_data, "--", color = "red")
    
    plt.subplot(2, 1, 2)
    plt.plot(np.arange(loss_list.shape[0]), loss_list)
    plt.show()
    
if __name__ == "__main__":
    x_data, y_data = establish_data(100)
    w, b, loss_list = gradient_descent(x_data, y_data, 0, 0, 0.01, 1000)
    plot_result(x_data, y_data, w[0, 0], b, loss_list)

以上就是本文线性回归的全部内容了,总体上来讲还是挺简单的,难度系数也没那么大,更多关于线性回归的内容,我们后面再来讲解。

这里我们对线性回归做一个简单的总结:

优点:结果比较容易理解,计算上并不复杂,没有太多复杂的公式和花里胡哨的内容
缺点:对非线性的数据拟合不好,时间复杂度还有一定的优化空间
适用数据类型:数值型和标称型数据

我是Taoye,爱专研,爱分享,热衷于各种技术,学习之余喜欢下象棋、听音乐、聊动漫,希望借此一亩三分地记录自己的成长过程以及生活点滴,也希望能结实更多志同道合的圈内朋友,更多内容欢迎来访微信公主号:玩世不恭的Coder。

我们下期再见,拜拜~~~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值