深度学习实战 第5章深度学习优化笔记

第5章 深度学习优化

5.1 神经网络优化困难

1.深度学习所面临的优化挑战,包括了局部最优鞍点梯度悬崖梯度消失问题。

5.1.1 局部最优

1.凸函数最显著的特征是能够找到一个局部最优解,并且此解是全局的最优解。但有时我们也并不执着于寻找最优的某一点,在某些凸函数的底部可能是一个貌如盆地的平坦区域,在该平坦区域取得的任何值也是可以接受的。

2.在实践中我们发现,最优解附近的解其泛化性能通常要比最优解要好

3.但专家们现在开始怀疑,对于大规模的神经网络而言,大多数局部最优都有一个比较低的损失值,并且寻找真实的全局最优也不是一个很重要的问题,重要的是在参数空间中找到一个相对较低的局部最优值。

5.1.2 鞍点

1.对于高维数据来说,局部最优可能已不是非常严重的问题,因为存在着一个更突出的问题——鞍点(saddle point)。

2.鞍点处的梯度几乎为零

3.维度越高,那么出现局部最优的概率也就越低,但鞍点的数目,却是成指数增长。

5.1.3 梯度悬崖

1.高度非线性的深度神经网络或者循环神经网络,在参数空间中常常含有尖锐的非线性,这导致了某些区域可能会产生非常高的梯度,当参数靠近这一悬崖区域,高梯度会将参数弹射到很远的地方,很可能导致原本的优化工作半途而废。

2.需要注意的是,由于循环神经网络涉及在多个时间段内相乘,因此梯度悬崖在递归神经网络中十分频繁,特别是处理较长的时序序列时,该问题会变得异常令人头疼。

5.1.4 梯度消失或梯度爆炸

1.深度学习最大的特点是其网络非常深(层数非常多),深度提升了模型的复杂度与能力,但也导致了深度学习中的一大难题,那就是梯度消失问题(Vanishing Gradient Problem)。这个问题也是曾经导致深度学习只能被称为“(浅层)神经网络”的最主要原因。该问题随着ReLU单元的使用而得到了极大的缓解,但在一些特定的神经网络中,如循环神经网络(RNN),该问题依然严重。

2.梯度消失的原因是因为使用链式求导法则。使用BP算法对多层神经网络进行求导,最关键的问题就出现在激活函数的导数中。每一层权重的梯度都需要乘以以后各层激活函数的导数。因此,在深层网络中,即使网络预测产生了很大的误差,但底层的神经元依然没有得到足够的误差修正

3.那什么又是**梯度爆炸问题(Exploding Gradient Problem)**呢?本层权重的梯度可以简化为上层各神经元的梯度与其权重的乘积,如果上层的权重过大,当经过传递后,本层的梯度就会变得异常巨大,造成梯度非常不稳定。但相比于梯度消失,梯度爆炸问题比较容易解决,并且发生的情况也不频繁。

5.1.5 梯度不精确

1.**大多数优化算法最原始的动机都是试图获取代价函数对应的精确梯度,从而优化学习器。但在实践中,我们经常使用含有噪声的梯度进行优化。**比如梯度下降法需要遍历所有训练数据后计算出平均梯度,然后才修改网络,但这种方法在数据较大时训练速度太慢,因此深度学习通常会进行数据采样,使用最小批量梯度下降学习方法进行网络训练,甚至在极端情况下还会使用随机梯度下降(一次采样一条数据)进行网络训练。

2.**这种不精确的梯度,也就导致了训练的稳定性较差,但梯度不精确有时也可以看作是防止过拟合以及逃离局部最优或鞍点的方法。**机器学习终究不是一个最优化问题,但其却要依靠优化手段来完成机器学习任务。具体问题,还需要根据实际需求进行思考。

5.1.6 优化理论的局限性

1.一些理论结果显示,很多针对神经网络而设计的优化算法有着局限性,但在实践中,这些理论结果却很少对神经网络产生影响。这也是相比于其他机器学习算法而言,神经网络更像是一个黑盒。神经网络中存在着大量的训练技巧,这也使得训练神经网络更像是艺术而非科学。

2.深度学习是实践的产物,还缺乏强有力的理论支持,很多科研人员仍然对其保持着怀疑态度,如何理智地评估深度学习算法性能边界仍然是机器学习中一个重要的目标。

5.2 随机梯度下降

1.标准的随机梯度下降训练过程:选择一条数据,就训练一条数据,然后修改一次权重。SGD算法在训练过程中很有可能选择被错误标记的数据,或者与正常数据差异很大的数据进行训练,那么使用此数据求得的梯度就会有很大的偏差,因此SGD在训练过程中会出现很强的随机现象

2.随机梯度下降算法

给定数据集X={x(1),x(2),,x(n)}X = \{x^{(1)},x^{(2)},\cdots,x^{(n)}\},数据集标记Y={y(1),y(2),,y(n)}Y = \{y^{(1)},y^{(2)},…,y^{(n)}\}

学习器f(x;w)f(x;w),学习率(步长)αα

For 迭代足够多次

{

1.随机选择数据:{x(j),y(j)}\{ x^{(j)},y^{(j)} \}

2.计算损失梯度:\nabla w=\frac{\part L(y^{(j)},f(x^{(j)};w))}{\part w}

3.修改权重:wi=wiαww_i = w_i-\alpha \nabla w

}

3.为了防止这种随机性带来的危害,我们就多选几条数据,然后计算一下多条数据的平均错误。即使有某条数据存在严重缺陷,也会因为多条数据的中和而降低其错误的程度。

4.在上述的算法中,学习率α\alpha 是固定的值。但在实践中,我们往往需要通过训练次数的增长而逐渐地降低学习率。SGD算法引入了源噪声,而这种噪声是个体数据的特殊性所造成的,因此,即使我们的算法到达了最优解附近,其噪声也不会消失,这也就形成在最优解附近振荡的现象。

5.我们将原本固定的学习率α\alpha 设计为时间衰减的形式。初始时学习率较高,随着训练轮数的增加,学习率不断地减少。但我们也不希望学习率一直衰减,因此也会设置一个学习率的最低值。

αi=(1ik)α0+ikb\alpha_i = (1-\frac{i}{k})\alpha_0+\frac{i}{k}b

**学习率的取值需要反复的实验,通常最好的方式是通过代价函数的变化情况来监控学习率的变化曲线。**但这与其说是科学,还不如说是一项艺术,许多针对学习率的调整方法都太过技巧性。

6.现在的主要问题就是**如何设置初始学习率,如果太大,学习曲线就会剧烈震荡;如果初始值太小,那学习过程就会非常缓慢,并且还会陷入一个比较高的代价函数区域。在实际测试中,学习率通常要作为超参数进行选择,并没有一个固定的取值方法。**综上所述,就为改进的随机梯度下降后的学习率衰减最小批量梯度下降训练法

7.学习率衰减最小批量梯度下降训练法

初始化:

给定数据集X={x(1),x(2),,x(n)}X = \{x^{(1)},x^{(2)},\cdots,x^{(n)}\},数据集标记Y={y(1),y(2),,y(n)}Y = \{y^{(1)},y^{(2)},…,y^{(n)}\}

随机采样mm 条数据,训练周期kk, 学习率衰减最低值bb,学习器f(x;w)f(x;w),初始学习率(步长)αα

训练:

For i <= k

{

1.随机选择m条数据:{(x(1),y(1))(x(m),y(m))}\{ (x^{(1)},y^{(1)})\cdots(x^{(m)},y^{(m)}) \}

2.计算采样数据平均损失值梯度:\nabla w=\frac{1}{m} \sum_{j=1}^{m}\frac{\part L(y^{(j)},f(x^{(j)};w))}{\part w}

3.计算衰减学习率:ai=(1ik)a0+ikba_i=(1-\frac{i}{k})a_0+\frac{i}{k}b

4.修改网络权重:wi=wiαww_i = w_i-\alpha \nabla w

}

5.3 动量学习法

1.动量(Momentum)学习算法。首先类似于物理学,我们用变量v表示速度,表明参数在参数空间移动的方向及速率,而代价函数的负梯度表示参数在参数空间移动的力。根据牛顿运动定律,动量等于质量乘以速度,而在动量学习算法中,我们假设质量为单位1,因此速度v就可以直接当作动量。我们同时也引入超参数B,其取值在[0,1]范围之间,用于调节先前梯度(力)的衰减效果。

v=βvαwv=\beta v-\alpha \nabla w

w=w+vw=w+v

2.动量随机梯度下降算法

初始化:

给定数据集X={x(1),x(2),,x(n)}X = \{x^{(1)},x^{(2)},\cdots,x^{(n)}\},数据集标记Y={y(1),y(2),,y(n)}Y = \{y^{(1)},y^{(2)},…,y^{(n)}\}

初始速度vv,随机采样数据大小mm ,训练周期kk,学习器f(x;w)f(x;w),初始学习率αα,初始动量参数β\beta

训练:

For i <= k

{

1.随机选择m条数据:{(x(1),y(1))(x(m),y(m))}\{ (x^{(1)},y^{(1)})\cdots(x^{(m)},y^{(m)}) \}

2.计算采样数据平均损失值梯度:\nabla w=\frac{1}{m} \sum_{j=1}^{m}\frac{\part L(y^{(j)},f(x^{(j)};w))}{\part w}

3.更新速度:v=βvαwv=\beta v-\alpha \nabla w

4.更新参数:w=w+vw = w+v

}

3.**在随机梯度下降中,每一步走多远是简单的梯度乘以学习率,而在动量学习算法中,每一步走多远依赖的是过去的速度以及当前的力(梯度)。速度v用于累加各轮训练的参数梯度,β\beta越大先前梯度对于本轮训练梯度的影响就越大。**假设每轮训练的梯度方向都是相同的,就如同小球从斜坡一直往下滚落,但由于衰减因子β\beta的存在,小球并不会一直加速往下,而是达到速度的最大值后就匀速前行。我们假设每轮获得的梯度都是相同的,那该速度的最大值就如下式所示。

vmax=αw1βv_{max}=\frac{\alpha||\nabla w||}{1-\beta}

4.在实践中,常用的β\beta取值可以是0.5,0.9或者是0.99,当然也可以像学习率a的调整一样来根据训练轮数的增加自适应地衰减,但对于B的调整,并没有a的调整重要,因此,不太需要作为超参数进行选择,正常情况下,取值适当即可。

5.4 AdaGrad和RMSProp

1.**AdaGrad算法其实很简单,就是将每一维各自的历史梯度的平方叠加起来,然后在更新的时候除以该历史梯度值即可。**例如,针对第i参数,算法如下。

首先,如下式所示,我们将当前梯度的平方累加在cache中,使用平方的原因是去除梯度的符号,我们只对梯度的量进行累加。

cachei=cachei+(wi)2cache_i=cache_i+(\nabla w_i)^{2}

其次,如下式所示,在更新参数时,学习率需要除以根号cache,其中δ=107\delta=10^{-7},防止数值溢出。

wi=wiαcachei+δwiw_i=w_i-\frac{\alpha}{\sqrt{cache_i}+\delta}\nabla w_i

从上式中可以看出,**AdaGrad使得参数在累积的梯度量较小时(<1),放大学习率,使网络的训练更加快速。在梯度的累积量较大时(>1),缩小学习率,延缓网络训练。**这就好比网络训练开始时要勇往直前,当走完一段距离后要小心翼翼。

2.那么AdaGrad有什么问题吗?细心的读者可能早已观察出来了,AdaGrad很容易受到“过去”的影响,因为梯度很容易就会累积到比较大的值,此时学习率就会被降低得非常厉害。因此AdaGrad很容易过分降低学习率。那么要如何改进该算法呢?那就很简单了,做一只快乐的小金鱼,忘记“过去”
就好。

3.RMSProp算法就在AdaGrad基础上引入衰减因子,如下式所示,RMSProp算法在进行梯度累积的时候,会对“过去”与“现在”做一个权衡。通过超参数B来调节衰减量,常用的取值有0.9或0.5。

cachei=βcachei+(1β)(wi)2cache_i=\beta\cdot cache_i+(1-\beta)(\nabla w_i)^{2}

在参数更新阶段,和AdaGrad相同,如下式所示,学习率除以历史总体度即可。

wi=wiαcachei+δwiw_i=w_i-\frac{\alpha}{\sqrt{cache_i}+\delta}\nabla w_i

5.5 Adam

1.Adam的名称来源于"adaptive moments",可以将其看作为Momentum + RMSProp的微调版本。该方法是目前深度学习中最流行的优化方法,在默认情况下,我们都推荐使用Adam作为参数更新方式。

首先,如下式所示,计算当前最小批量数据梯度gg

g = \frac{1}{m} \sum_{j=1}^{m}{\frac{\part L(y^{(j)},f(x^{(j)};w))}{\part w}}

然后,如下式所示,类似于动量学习法,计算衰减梯度v

v=\beta_1\dot\quad v+(1-\beta_1)g

然后,如下式所示,类似于RMSProp学习法,计算衰减学习率r

r=\beta_2 \dot\quad r+(1-\beta_2)g^{2}

最后,如式(5.14)所示,更新参数。

w=wαr+δvw=w-\frac{\alpha}{\sqrt{r}+\delta}v

以上就是Adam算法,是不是很简单。但还有一点小问题,那就是在开始时梯度会非常小,rrvv 经常会接近于0,因此我们还需要做一步“热身”工作。如下式所示,我们将rrvv 分别除以1减去各自衰减率的tt 次方之差。

vb=v1β1t,rb=r1β2tvb=\frac{v}{1-\beta_1^{t}},\quad rb=\frac{r}{1-\beta_2^{t}}

其中tt 表示训练的次数,因此仅仅在训练的前几轮中根据衰减因子来放大各自值,很快vbvbrbrb 就会退化为vvrr

2.Adam学习算法

初始化:

给定数据集X={x(1),x(2),,x(n)}X = \{x^{(1)},x^{(2)},\cdots,x^{(n)}\},数据集标记Y={y(1),y(2),,y(n)}Y = \{y^{(1)},y^{(2)},…,y^{(n)}\}

初始速度vv,随机采样数据大小mm ,训练周期kk,学习器f(x;w)f(x;w),初始学习率αα,动量衰减参数β1\beta_1,学习率衰减参数β2\beta_2δ=107\delta=10^{-7}

训练:

For t <= k

{

1.随机选择m条数据:{(x(1),y(1))(x(m),y(m))}\{ (x^{(1)},y^{(1)})\cdots(x^{(m)},y^{(m)}) \}

2.计算当前采样数据梯度:g=\frac{1}{m} \sum_{j=1}^{m}\frac{\part L(y^{(j)},f(x^{(j)};w))}{\part w}

3.更新当前速度:v=\beta_1 \dot\quad v-(1-\beta_1)g

4.更新当前学习率:r=\beta_2 \dot\quad r+(1-\beta_2)g^{2}

5.更新训练次数:t=t+1t=t+1

vb=v1β1t,rb=r1β2tvb=\frac{v}{1-\beta_1^{t}},\quad rb=\frac{r}{1-\beta_2^{t}}

6.更新参数:w=wαrb+δvbw = w-\frac{\alpha}{\sqrt{rb}+\delta}vb

}

5.6 参数初始化策略

1.深度学习的优化过程可以看作是下山的过程,山路崎岖,有鞍点,有局部最优点,也有悬崖点。之前我们学习了很多下山的方法,如SGD,Momentum,RMSProp和Adam都是不错的方法。但无论使用何种学习方式,都避免不了同一个问题,那就是下山的起始点问题,也就是参数的初始化问题

2.现代的参数初始化策略通常是简单并且带有试探性的,就目前而言,神经网络的优化策略仍然没有充分的完善,想要设计优秀的初始化策略是一件非常困难的工作。大多数初始化策略都是基于已实现网络的某些优良属性,但我们并不知道这些属性应该应用在何种环境中。

目前,我们能够确定的一种性质是在不同的神经元中,应该尽量避免神经元参数出现对称情况。如果两个隐藏单元使用相同的激活函数,并且连接到相同的输入,那么每个神经元必须要有不同参数。因为参数相同,在确定的学习算法下,其参数的更新就会相同,这就会出现神经元冗余的情况。

3.在初始化权重参数时,我们几乎都采用均匀分布或高斯分布的随机初始化方式。选择均匀分布或者高斯分布没有好坏之分,但**分布函数的取值范围(标准差)**对于优化过程以及泛化能力却有着巨大的影响。

较大的初始化权重范围可以有效地避免参数对称线性,减少神经元冗余现象,也可以帮助减轻训练网络时的梯度消失问题。但太大的初始化权重也会导致梯度爆炸的问题,同时在一些使用诸如Sigmoid激活函数那样的神经元中,也容易出现过饱和现象,致使修改神经元梯度几乎为零。

4.从正则化以及最优化的角度出发,我们可能会得出完全不同的参数初始化策略。以最优化的观点来看,权重应该足够大,这样才能较好地传递信息;但从正则化的角度出发,我们却希望参数越小越好。由于神经网络中存在局部最优和鞍点等困难,最终训练后的神经网络权重会非常靠近初始化时的权重。综合正则化要求以及训练权重接近初始化权重的事实,我们实际上是在权重中加入了一条参数均值为0的高斯分布先验知识。而参数尽可能小这一先验知识,其实也就是所谓的稀疏性原则,即神经元不应该连接到全部神经元中,而是应该连接到需要连接的神经元中。

5.随机采样初始化或者标准采样初始化。

这两种初始化的一个缺点在于所有初始化权重都会具有相同的标准差,当每层的神经元数目太多时,神经元的权重就会变得极其的小,解决的方式是一种被称为**稀疏初始化(Sparse Intialization)**的策略,该方法强制每个神经元中要有k个非零的权重。稀疏初始化可以在神经元之间实现更强的多样性,但也施加了一个非常强的先验在权重中。假设权重较大的神经元恰好是需要修正的神经元,那可能需要花费很长的时间来缩小误差值。如果计算资源允许,将每层的权重的标准差都设置成一个超参数是不错的选择。

6.上述的初始化策略,其实都是随机的初始化参数,需要我们仔细地选择参数。人工智能的一大目标就是尽可能地减少人为参与,那么将参数的初始化当作是机器学习任务去学习又如何呢?其中非常著名的策略就是**使用非监督学习方法逐层地学习深度模型的参数,**将其作为监督学习模型的参数初始化过程,这是深度学习中非常重要的方法,也是深度学习研究的主要方向之一。

5.7 批量归一化

1.批量归一化:首先,归一化就是将数据的输入值减去其均值然后除以数据的标准差,几乎所有数据预处理都会使用这一步骤。而深度学习也可以认为是逐层特征提取的过程,那每一层的输出其实都可以理解为经过特征提取后的数据。因此,批量归一化方法的**“归一化”所做的其实就是在网络的每一层都进行数据归一化处理**,但每一层对所有数据都进行归一化处理的计算开销太大,因此就和使用最小批量梯度下降一样,批量归一化中的**“批量”其实是采样一小批数据**,然后对该批数据在网络各层的输出进行归一化处理。

2.假设我们一次采样 m 条数据训练,用Hi,j(k)H_{i,j}^{(k)} 表示训练第 k 条数据时,第 j 层的第 i 神经元的输出值; μi,j\mu_{i,j} 表示这批数据在第 j 层的第 i 神经元处的平均输出值:σi,j\sigma_{i,j} 表示这批数据在第 j 层的第 i 神经元处输出值的标准差。批量归一化后的输出值就如下式所示。

Hi,j(k)=Hi,j(k)μi,jσi,jH_{i,j}^{’(k)}=\frac{H_{i,j}^{(k)}-\mu_{i,j}}{\sigma_{i,j}}

其中神经元输出的均值μi,j\mu_{i,j} 从如下式所示。

μi,j=1mk=1mHi,j(k)\mu_{i,j}=\frac{1}{m}\sum_{k=1}^{m}H_{i,j}^{(k)}

神经元输出值的标准差σi,j\sigma_{i,j} ,如下式所示。

σi,j=δ+1mk=1m(Hi,j(k)μi,j)2\sigma_{i,j}=\sqrt{\delta+\frac{1}{m}\sum_{k=1}^{m} (H_{i,j}^{(k)}-\mu_{i,j})^{2}}

其中δ\delta 是一个很小的常数,目的是为了防止0\sqrt{0} 的产生。批量归一化的目的其实很简单,就是把神经网络每一层的输入数据都调整到均值为零,方差为1的标准正态分布

3.批量正则化使每一层输入数据都调整到均值为0,方差为1的标准正态分布的目的就是为了避免梯度消失

4.多层线性神经网络其实可以用一层线性网络来表示。那我们使用BN算法将输入值投射到激活函数的线性区城,会使得深度网络能力下降。如果仅仅是归一化各层输入值到一个近似的线性区域,我们的深层网络能力将大大降低。

因此,BN算法其实还有另一个步骤,那就是再将归一化的数据放大,平移回非线性区域。如下式所示,我们引入γ\gammaβ\beta 两个可学习参数,调整归一化的输出值。

Xi,j(k)=γi,jHi,j(k)+βi,jX_{i,j}^{(k)} = \gamma_{i,j}H_{i,j}^{’(k)} + \beta_{i,j}

变量γ\gammaβ\beta 是允许新变量有任意均值和标准差的学习参数,这似乎不合理,为什么我们将均值设为0,然后又引入参数允许它被重设为β\beta 呢?这是因为**新的参数不但可以表示旧参数的非线性能力,而且新参数还可以消除层与层之间的关联,具有相对独立的学习方式。**在旧参数中,HH 的均值和方差取决于H下层中参数的复杂关联。在新参数中,γH+β\gamma H^{’} + \beta 的均值与方差仅仅由本层决定,新参数很容易通过梯度下降来学习。

5.运行时平均(running averages)

由于训练时我们仅仅对批量采样数据进行归一化处理,该批数据的均值和方差不能代表全体数据的均值和方差。因此,我们需要在每批训练数据归一化后,累积其均值和方差。当训练完成后再求出总体数据的均值和方差,然后再在测试时使用,但累积每一次数据的均值和方差太过麻烦,在实践中,我们经常使用运行时的均值和方差代替全体数据的均值和方差。如下式所示,和动量学习法类似,我们引入衰减因子对均值和方差进行衰减累积

\mu = decay \dot\quad \mu+(1-decay)\mu_{sample}

\sigma = decay \dot\quad \sigma+(1-decay)\sigma_{sample}

6.BN算法

BN前向传播

1,计算采样数据均值:mu=1mi=1mximu= \frac{1}{m} \sum_{i=1}^{m}x_i

2,将数据平移为零均值xmui=ximuxmu_i=x_i-mu

3,计算平方项:carrei=xmui2carre_i = xmu_i^{2}

4,计算数据方差:var=1mi=1mcarreivar=\frac{1}{m}\sum_{i=1}^{m} carre_i

5,计算数据标准差:sqrtvar=var+δsqrtvar=\sqrt{var+\delta}

6,计算数据标准差倒数:invvar=1sqrtvarinvvar=\frac{1}{sqrtvar}

7,采样数据归一化va2i=xmui×invvarva2_i=xmu_i \times invvar

8,采样数据缩放:va3i=gamma×va2iva3_i=gamma \times va2_i

9,采样数据平移:outi=va3i+betaout_i= va3_i+beta

BN反向传播

9,计算beta梯度:dbeta=i=1mdoutidbeta=\sum_{i=1}^{m} dout_i

计算va3梯度:dva3i=doutidva3_i =dout_i

8,计算va2梯度:dva2i=gamma×dva3idva2_i=gamma \times dva3_i

计算gamma梯度:dgamma=i=1mva2i×dva3idgamma=\sum_{i=1}^{m}va2_i \times dva3_i

7,计算xmu1梯度:dxmu1i=invvar×dva2idxmu1_i=invvar \times dva2_i

计算invvar梯度:dinvvar=i=1mxmui×dva2idinvvar=\sum_{i=1}^{m}xmu_i \times dva2_i

6,计算sqrtvar梯度:dsqrtvar=1sqrtvar2×dinvvardsqrtvar = \frac{-1}{sqrtvar^{2} } \times dinvvar

5,计算var梯度:dvar=0.5×(var+δ)0.5×dsqrtvardvar=0.5 \times(var+\delta)^{-0.5} \times dsqrtvar

4,计算carre梯度:dcarrei=1mdvardcarre_i=\frac{1}{m}dvar

3,计算xmu2梯度:dxmu2i=2×xmui×dcarreidxmu2_i=2 \times xmu_i \times dcarre_i

计算xmu梯度:dxmui=dxmu1i+dxmu2idxmu_i=dxmu1_i+dxmu2_i

2,计算x1梯度:dx1i=dxmuidx1_i=dxmu_i

计算mu梯度:dmu=i=1mdxmuidmu =-\sum_{i=1}^{m}dxmu_i

1,计算x梯度:dxi=dmuim+dx1idx_i=\frac{dmu_i}{m}+dx1_i


深度学习实战 第5章深度学习优化笔记
https://fulequn.github.io/2020/12/Article202012031/
作者
Fulequn
发布于
2020年12月3日
许可协议