本系列为 斯坦福CS231n《深度学习与计算机视觉(Deep Learning for Computer Vision)》的全套学习笔记,对应的课程视频可以在 这里 查看。更多资料获取方式见文末。
在上一篇 深度学习与计算机视觉教程(2) - 图像分类与机器学习基础 内容中,我们对线性分类器做了一些介绍,我们希望线性分类器能够准确地对图像进行分类,要有一套优化其权重参数的方法,这就是本篇ShowMeAI要给大家介绍到的损失函数与最优化相关的知识。
回到之前讲解过的小猫分类示例,这个例子中权重值 W 非常差,因为猫类别的得分非常低(-96.8),而狗(437.9)和船(61.95)比较高。
我们定义损失函数(Loss Function)(有时也叫代价函数 Cost Function 或目标函数 Objective) L 来衡量对预估结果的「不满意程度」。当评分函数输出结果与真实结果之间差异越大,损失函数越大,反之越小。
对于有 N 个训练样本对应 N 个标签的训练集数据 (x_{i},y_{i})),损失函数定义为:
损失函数有很多种,下面介绍最常见的一些。
SVM 的知识可以参考ShowMeAI的图解机器学习教程中的文章支持向量机模型详解,多类 SVM 可以看作二分类 SVM 的一个推广,它可以把样本数据分为多个类别。
SVM 的损失函数想要 SVM 在正确分类上的得分始终比不正确分类上的得分高出一个边界值 \Delta。
我们先看一条数据样本(一张图片)上的损失函数 L_i 如何定义,根据之前的描述,第 i 个数据 (x_{i},y_{i}) )中包含图像 x_i 的像素和代表正确类别的标签 y_i。给评分函数输入像素数据,然后通过公式 f(x_i, W) )来计算不同分类类别的分值。
这里我们将所有分值存放到 s 中,第 j 个类别的得分就是 s 的第 j 个元素: s_j = f(x_i, W_j)。针对第 i 条数据样本的多类 SVM 的损失函数定义如下:
直观来看,就是如果评分函数给真实标签的分数比其他某个标签的分数高出 \Delta,则对该其他标签的损失为 0;否则损失就是 s_j - s_{y_i}+ \Delta。要对所有不正确的分类循环一遍。
下面用一个示例来解释一下:
简化计算起见,我们只使用3个训练样本,对应3个类别的分类,y_i =0,1,2 对于第1张图片 「小猫」 来说,评分 s=[3.2, 5.1, -1.7] 其中 s_{y_i}=3.2 如果把 \Delta 设为 1,则针对小猫的损失函数:
同理可得 L_2 =0,L_3 =12.9,所以对整个训练集的损失:L= (2.9 + 0 + 12.9)/3 =5.27。
上面可以看到 SVM 的损失函数不仅想要正确分类类别 y_i 的分数比不正确类别分数高,而且至少要高 \Delta。如果不满足这点,就开始计算损失值。
展开一点解释如下:之所以会加入一个 \Delta,是为了真实标签的分数比错误标签的分数高出一定的距离,如上图所示,如果其他分类分数进入了红色的区域,甚至更高,那么就开始计算损失;如果没有这些情况,损失值为 0:
在训练最开始的时候,往往会给 W 一个比较小的初值,结果就是 s 中所有值都很小接近于 0,此时的损失 L 应该等于分类类别数 K-1,这里是 $2$
。可根据这个判断代码是否有问题;
非向量化和向量化多类 SVM 损失代码实现如下:
def L_i(x, y, W):
"""
非向量化版本。
计算单个例子(x,y)的多类 SVM 损失
- x 是表示图像的列向量(例如,CIFAR-10中的3073 x 1),附加偏置维度
- y 是一个给出正确类索引的整数(例如,CIFAR-10中的0到9之间)
- W 是权重矩阵(例如,CIFAR-10中的10 x 3073) """
delta = 1.0 # 间隔 delta
scores = W.dot(x) # 得分数组,10 x 1
correct_class_score = scores[y]
D = W.shape[0] # 分类的总数,即为10
loss_i = 0.0
for j in range(D): # 迭代所有错误分类
if j == y:
# 跳过正确分类的
continue
# 第 i 个样本累加损失
loss_i += max(0, scores[j] - correct_class_score + delta)
return loss_i
def L_i_vectorized(x, y, W):
'''
更快的半向量化实现。
half-vectorized指的是这样一个事实:对于单个样本,实现不包含for循环,
但是在样本外仍然有一个循环(在此函数之外)
'''
delta = 1.0
scores = W.dot(x)
# 用一个向量操作计算和所有类别的间隔
margins = np.maximum(0, scores - scores[y] + delta)
# y处的值应该为0
margins[y] = 0
loss_i = np.sum(margins)
return loss_i
这里的评分函数 f(x_i; W) = W x_i,所以损失函数可以写为:
max(0,-) 函数,常被称为合页损失(hinge loss)。比如平方合页损失 SVM (即 L2 - SVM ),它使用的是 max(0,-)^2 ),将更强烈(平方地而不是线性地)地惩罚过界的边界值。不使用平方是更标准的版本,但是在某些数据集中,平方合页损失会工作得更好。可以通过交叉验证来决定到底使用哪个。
总结:我们对于预测训练集数据分类标签的结果,有一些不满意的地方,而损失函数就能将这些不满意的程度量化。
假设有1个数据集和1组权重 W 能够正确地分类每个数据,即所有 L_i 都为 0,这样的 W 是否唯一?其实只要是任意 \lambda >1,\lambda W 都可以满足 L_i = 0,因为把差值放大 \lambda 倍后,仍然会大于 \Delta。
所以,我们希望对某些 W 添加一些偏好,让我们的W更趋向于希望的形式,一个常见的做法是向损失函数增加一个正则化惩罚(regularization penalty) R(W) ,它同时也能让模型更加泛化。
结合上述思路我们得到完整的多类 SVM 损失函数,它由两个部分组成:数据损失(data loss),即所有样例的平均损失,以及正则化损失**(regularization loss**)。完整公式如下:
引入 L2 范数正则化损失最好的性质就是对大数值权重进行惩罚,可以提升其泛化能力,因为这就意味着没有哪个维度能够独自对于整体分值有过大的影响。
举个例子,假设输入向量 x = [1,1,1,1],两个权重向量 w_1 = [1,0,0,0],w_2 = [0.25,0.25,0.25,0.25]。那么 w_1^Tx = w_2^Tx = 1。两个权重向量都得到同样的内积,但是 w_1 的 L2 惩罚是1.0,而 w_2 的 L2 惩罚是 0.25。因此,根据 L2 惩罚来看,w_2 更好,因为它的正则化损失更小。从直观上来看,这是因为 w_2 的权重值更小且更分散,这就会鼓励分类器最终将所有维度上的特征都用起来,而不是强烈依赖其中少数几个维度。这一效果将会提升分类器的泛化能力,并避免过拟合。
注意,和权重不同,偏置项没有这样的效果,因为它们并不控制输入维度上的影响强度。因此通常只对权重 W 正则化,而不正则化偏置项 b。
同时,因为正则化惩罚的存在,不可能在所有的例子中得到 0 的损失值,这是因为只有当 W=0 的特殊情况下,才能得到损失值为 0。
但是从 L1 惩罚来看,w_1 可能会更好一些,当然这里 L1 惩罚相同,但是一般来说,L1 惩罚更希望 W 比较稀疏,最好是有很多为 0 的元素,这一特性可以用来在不改变模型的基础上防止过拟合。
比如下面的例子中:
假设我们的训练数据得到的模型是蓝色的曲线,可以看出应该是一个多项式函数,比如 f=w_1x_1+w_2x_2^2+w_3x_3^3+w_4x_4^4。但是当新的绿色数据输入时,显然模型是错误的,更准确的应该是绿色的线。
如果我们使用 L1 惩罚,由于 L1 惩罚的特性,会希望 W 变得稀疏,可让 w_2,w_3,w_4 变成接近 0 的数,这样就可以在不改变模型的情况下,让模型变得简单泛化。
思考:超参数 \Delta 和 \lambda 应该被设置成什么值?需要通过交叉验证来求得吗?
二元 SVM 对于第 i 个数据的损失计算公式是:
其中,C 是一个超参数,并且 y_i \in \{ -1,1 \},这个公式是多类 SVM 公式只有两个分类类别的特例,C 和 \lambda 的倒数正相关。比如对真实标签为 y_i=1 的数据得分是 50,则 L_i=0。这里只用到了 y_i=1 标签的得分,因为二元 SVM 的W只有一行,只有一个得分并且是自身分类的得分,只要这个得分和 y_i 的乘积大于 1 就是预测正确的了。
最终,我们得到了多类 SVM 损失的完整表达式:
接下来要做的,就是找到能够使损失值最小化的权重了。
SVM 是最常用的分类器之一,另一个常用的是Softmax分类器。Softmax 分类器可以理解为逻辑回归分类器面对多个分类的一般化归纳,又称为多项式逻辑回归((Multinomial Logistic Regression)。
还是以之前小猫的图片为例:
图片上的公式初一看可能感觉有点复杂,下面逐个解释:
根据上面的分析,可以计算出小猫的 Softmax 损失为 0.89。损失为 0 的时候最好,无穷大的时候最差。
其中:
该技巧简单地说,就是应该将向量 s 中的数值进行平移,使得最大值为 0。参考python实现代码如下:
s = np.array([123, 456, 789]) # 例子中有3个分类,每个评分的数值都很大
p = np.exp(s) / np.sum(np.exp(s)) # 不好:数值问题,可能导致数值爆炸
# 那么将f中的值平移到最大值为0:
s -= np.max(s) # s变成 [-666, -333, 0]
p = np.exp(s) / np.sum(np.exp(s)) # 现在可以了,将给出正确结果
Softmax 和 SVM 这两类损失的对比如下图所示:
SVM 和 Softmax 分类器对于数据有不同的处理方式。两个分类器都计算了同样的分值向量 s(本节中是通过矩阵乘来实现)。不同之处在于对 s 中分值的解释:
SVM 的最终的损失值是 1.58,Softmax 的最终的损失值是 0.452,注意这两个数值大小没有可比性。只在给定同样数据,在同样的分类器的损失值计算中,损失之间比较才有意义。
SVM 的计算是无标定的,而且难以针对所有分类的评分值给出直观解释。Softmax 分类器则不同,它允许我们计算出对于所有分类标签的 「概率」。
但这里要注意,「不同类别概率」 分布的集中或离散程度是由正则化参数 \lambda 直接决定的。随着正则化参数 \lambda 不断增强,权重数值会越来越小,最后输出的概率会接近于均匀分布。
也就是说,Softmax 分类器算出来的概率可以某种程度上视作一种对于分类正确性的自信。和 SVM 一样,数字间相互比较得出的大小顺序是可以解释的,但其绝对值则难以直观解释。
两种分类器的表现差别很小。
截止目前,我们已知以下内容:
它们之间的关系:
下一步我们希望寻找最优的 W 让损失loss最小化。
损失函数一般都是定义在高维度的空间中(比如,在 CIFAR-10 中一个线性分类器的权重矩阵大小是 [10 \times 3073],就有 30730 个参数),这样要将其可视化就很困难。
解决办法是在1维或2维方向上对高维空间进行切片,就能得到一些直观感受。
下图是一个无正则化的多类 SVM 的损失函数的图示。左边和中间只有一个样本数据,右边是 CIFAR-10 中的 100 个数据,蓝色部分是低损失值区域,红色部分是高损失值区域:
上图中注意损失函数的分段线性结构。多个样本的损失值是总体的平均值,所以右边的碗状结构是很多的分段线性结构的平均。可以通过数学公式来解释损失函数的分段线性结构。
对于1条单独的数据样本,有损失函数的计算公式如下:
每个样本的数据损失值是以 W 为参数的线性函数的总和。W 的每一行( w_j ),有时候它前面是一个正号(比如当它对应非真实标签分类的时候),有时候它前面是一个负号(比如当它是正确分类的时候)。
比如,假设有一个简单的数据集,其中包含有3个只有1个维度的点,数据集数据点有3个类别。那么完整的无正则化 SVM 的损失值计算如下:
这些例子都是一维的,所以数据 x_i 和权重 w_j 都是数字。单看 w_0,可以看到最上面的三个式子每一个都含 w_0 的线性函数,且每一项都会与 0 比较,取两者的最大值。第一个式子线性函数斜率是负的,后面两个斜率是正的,可作图如下:
上图中,横轴是 w_0,纵轴是损失,三条线对应三个线性函数,加起来即为右图。
补充解释:
优化策略的目标是:找到能够最小化损失函数值的权重 W。
随机尝试很多不同的权重,然后看其中哪个最好。这是一个差劲的初始方案。代码如下:
# 假设X_train的每一列都是一个数据样本(比如3073 x 50000)
# 假设Y_train是数据样本的类别标签(比如一个长50000的一维数组)
# 假设函数L对损失函数进行评价
bestloss = float("inf") # 初始指定一个最高的损失
for num in range(1000):
W = np.random.randn(10, 3073) * 0.0001 # 随机生成一个10x3073的W矩阵
# 都接近为0
loss = L(X_train, Y_train, W) # 得到整个训练集的损失
if loss < bestloss: # 保持最好的解决方式
bestloss = loss
bestW = W
print 'in attempt %d the loss was %f, best %f' % (num, loss, bestloss)
# 输出:
# in attempt 0 the loss was 9.401632, best 9.401632
# in attempt 1 the loss was 8.959668, best 8.959668
# in attempt 2 the loss was 9.044034, best 8.959668
# in attempt 3 the loss was 9.278948, best 8.959668
# in attempt 4 the loss was 8.857370, best 8.857370
# in attempt 5 the loss was 8.943151, best 8.857370
# in attempt 6 the loss was 8.605604, best 8.605604
# ... (trunctated: continues for 1000 lines)
在上面的代码中,我们尝试了若干随机生成的权重矩阵 W,其中某些的损失值较小,而另一些的损失值大些。我们可以把这次随机搜索中找到的最好的权重 W 取出,然后去跑测试集:
# 假设X_test尺寸是[3073 x 10000], Y_test尺寸是[10000 x 1]
scores = Wbest.dot(Xte_cols) # 10 x 10000, 每个样本对应10个类得分,共10000
# 找到在每列中评分值最大的索引(即预测的分类)
Yte_predict = np.argmax(scores, axis = 0)
# 以及计算准确率
np.mean(Yte_predict == Yte)
# 返回 0.1555
验证集上表现最好的权重W跑测试集的准确率是 15.5\%,而完全随机猜的准确率是 10\%,效果不好!
思路调整:新的策略是从随机权重W开始,然后迭代取优,每次都让它的损失值变得更小一点,从而获得更低的损失值。想象自己是一个蒙着眼睛的徒步者,正走在山地地形上,目标是要慢慢走到山底。在 CIFAR-10 的例子中,这山是 30730 维的(因为 W 是 3073 \times 10)。我们在山上踩的每一点都对应一个的损失值,该损失值可以看做该点的海拔高度。
第一个策略可以看做是每走一步都尝试几个随机方向,如果是上山方向就停在原地,如果是下山方向,就向该方向走一步。这次我们从一个随机 W 开始,然后生成一个随机的扰动 aW,只有当 W+aW 的损失值变低,我们才会更新。
这个过程的参考实现代码如下:
W = np.random.randn(10, 3073) * 0.001 # 生成随机初始W
bestloss = float("inf")
for i in xrange(1000):
step_size = 0.0001
Wtry = W + np.random.randn(10, 3073) * step_size
loss = L(Xtr_cols, Ytr, Wtry)
if loss < bestloss:
W = Wtry
bestloss = loss
print 'iter %d loss is %f' % (i, bestloss)
用上述方式迭代 1000 次,这个方法可以得到 21.4\% 的分类准确率。
前两个策略关键点都是在权重空间中找到合适的方向,使得沿其调整能降低损失函数的损失值。其实不需要随机寻找方向,我们可以直接计算出最好的方向,这个方向就是损失函数的梯度(gradient)。这个方法就好比是感受我们脚下山体的倾斜程度,然后向着最陡峭的下降方向下山。
在一维函数中,斜率是函数在某一点的瞬时变化率。梯度是函数斜率的一般化表达,它是一个向量。
在输入空间中,梯度是各个维度的斜率组成的向量(或者称为导数 derivatives)。对一维函数的求导公式如下:
当函数有多个自变量的时候,我们称导数为偏导数,而梯度就是在每个维度上偏导数所形成的向量。设三元函数 f(x,y,z) 在空间区域 G 内具有一阶连续偏导数,点 P(x,y,z)\in G,称向量
为函数 f(x,y,z) )在点 P 的梯度
记为:
关于梯度计算与检查的详细知识也可以参考ShowMeAI的深度学习教程 | 吴恩达专项课程 · 全套笔记解读中的文章深度学习的实用层面里对于「梯度检验 (Gradient checking)」部分的讲解
计算梯度有两种方法:
下面我们展开介绍这两种方法
数值梯度法是借助于梯度的定义对其进行逼近计算。
下面代码中:
输入为函数 f 和矩阵 x,计算 f 的梯度的通用函数,它返回函数 f 在点 x 处的梯度,利用公式 \frac{df(x)}{dx} = \lim_{h\ \to 0} \frac{f(x + h) - f(x)}{h},代码对 x 矩阵所有元素进行迭代,在每个元素上产生一个很小的变化 h,通过观察函数值变化,计算函数在该元素上的偏导数。最后,所有的梯度存储在变量 grad 中:
参考实现代码如下:
def eval_numerical_gradient(f, x):
"""
我们是求L关于w的梯度,f就是损失L,x就是权重矩阵w
一个 f 在 x 处的数值梯度法的简单实现
- f 是参数 x 的函数,x 是矩阵,比如之前的 w 是10x3073
- x 是计算梯度的点
"""
fx = f(x) # 计算x点处的函数值
grad = np.zeros(x.shape) # 梯度矩阵也是10x3073
h = 0.00001 # 近似为0的变化量
# 对x中所有的索引进行迭代,比如从(0,0)到(9,3072)
it = np.nditer(x, flags=['multi_index'], op_flags=['readwrite'])
# np.nditer是np自带的迭代器
# flags=['multi_index']表示对 x 进行多重索引 比如(0,0)
# op_flags=['readwrite']表示不仅可以对x进行read(读取),还可以write(写入)
while not it.finished:
# 计算x+h处的函数值
ix = it.multi_index #索引从(0,0)开始,即从x矩阵第一行第一列的元素开始
old_value = x[ix] # 先将x(0,0)处原值保存
x[ix] = old_value + h # 增加h
fxh = f(x) # 计算新的f(x + h)
x[ix] = old_value # 将x(0,0)处改回原值
# 计算偏导数
grad[ix] = (fxh - fx) / h # x(0,0)处的偏导数
it.iternext() # 到下个维度x(0,1)
return grad # 最终是计算好的10x3073的梯度矩阵
实际中用中心差值公式(centered difference formula)[f(x+h) - f(x-h)] / 2 h 效果会更好。下面计算权重空间中的某些随机点上,CIFAR-10 损失函数的梯度:
# 为了使用上面的代码,需要一个只有一个参数的函数
# (在这里参数就是权重W)所以封装了X_train和Y_train
def CIFAR10_loss_fun(W):
return L(X_train, Y_train, W)
W = np.random.rand(10, 3073) * 0.001 # 随机权重矩阵
df = eval_numerical_gradient(CIFAR10_loss_fun, W) # 得到梯度矩阵
梯度告诉我们损失函数在每个元素上的斜率,以此来进行更新:
loss_original = CIFAR10_loss_fun(W) # 初始损失值
print 'original loss: %f' % (loss_original, )
# 查看不同步长的效果
for step_size_log in [-10, -9, -8, -7, -6, -5,-4,-3,-2,-1]:
step_size = 10 ** step_size_log
W_new = W - step_size * df # 权重空间中的新位置,使用负梯度
loss_new = CIFAR10_loss_fun(W_new)
print 'for step size %f new loss: %f' % (step_size, loss_new)
# 输出:
# original loss: 2.200718
# for step size 1.000000e-10 new loss: 2.200652
# for step size 1.000000e-09 new loss: 2.200057
# for step size 1.000000e-08 new loss: 2.194116
# for step size 1.000000e-07 new loss: 2.135493
# for step size 1.000000e-06 new loss: 1.647802
# for step size 1.000000e-05 new loss: 2.844355
# for step size 1.000000e-04 new loss: 25.558142
# for step size 1.000000e-03 new loss: 254.086573
# for step size 1.000000e-02 new loss: 2539.370888
# for step size 1.000000e-01 new loss: 25392.214036
W_new
,要注意我们是向着梯度 df 的负方向去更新,这是因为我们希望损失函数值是降低而不是升高。(偏导大于0,损失递增,W需要减小;偏导小于0,损失递减,W需要增大。)数值梯度的计算比较简单,但缺点在于只是近似不够精确,且耗费计算资源太多。
得益于牛顿-莱布尼茨的微积分,我们可以利用微分来分析,得到计算梯度的公式(不是近似),用公式计算梯度速度很快,但在实现的时候容易出错。
为了解决这个问题,在实际操作时常常将分析梯度法的结果和数值梯度法的结果作比较,以此来检查其实现的正确性,这个步骤叫做梯度检查。
比如我们已知多类 SVM 的数据损失 L_i:
可以对函数进行微分。比如对 w_{y_i} 微分:
虽然上述公式看起来复杂,但在代码实现的时候比较简单:只需要计算没有满足边界值的即对损失函数产生贡献的分类的数量,然后乘以 x_i 就是梯度了。
一旦将梯度的公式微分出来,代码实现公式并用于梯度更新就比较顺畅了。
关于Batch Gradient Descent、Mini-batch gradient descent、Stochastic Gradient Descent的详细知识也可以参考ShowMeAI的的深度学习教程 | 吴恩达专项课程 · 全套笔记解读中的文章神经网络优化算法
现在可以利用微分公式计算损失函数梯度了,程序重复地计算梯度然后对参数进行更新,这一过程称为梯度下降。
# 普通的梯度下降
while True:
weights_grad = evaluate_gradient(loss_fun, data, weights)
weights += - step_size * weights_grad # 进行梯度更新
这个简单的循环在所有的神经网络核心库中都有。虽然也有其他实现最优化的方法(比如LBFGS),但是到目前为止,梯度下降是对神经网络的损失函数最优化中最常用的方法。
后面大家见到的新的优化算法也是在其基础上增加一些新的东西(比如更新的具体公式),但是核心思想不变,那就是我们一直跟着梯度走,直到结果不再变化。
在大规模的应用中(比如 ILSVRC 挑战赛),训练数据量 N 可以达到百万级量级。如果像这样计算整个训练集,来获得仅仅一个参数的更新就太浪费计算资源了。一个常用的方法通过训练集中的小批量(batches)数据来计算。
例如,在目前最高水平的卷积神经网络中,一个典型的小批量包含 256 个样本,而整个训练集是一百二十万个样本。(CIFAR-10,就有 50000 个训练样本。)比如这个小批量数据就用来实现一个参数更新:
# 普通的小批量数据梯度下降
while True:
data_batch = sample_training_data(data, 256) # 从大规模训练样本中提取256个样本
weights_grad = evaluate_gradient(loss_fun, data_batch, weights)
weights += - step_size * weights_grad # 参数更新
这个方法之所以效果不错,是因为训练集中的数据都是相关的。
要理解这一点,可以想象一个极端情况:在ILSVRC中的 120万个图像是1000张不同图片的复制(每个类别1张图片,每张图片复制 1200 次)。那么显然计算这1200张复制图像的梯度就应该是一样的。对比120万张图片的数据损失的均值与只计算1000张的子集的数据损失均值时,结果应该是一样的。
实际情况中,数据集肯定不会包含重复图像,那么小批量数据的梯度就是对整个数据集梯度的一个近似。因此,在实践中通过计算小批量数据的梯度可以实现更快速地收敛,并以此来进行更频繁的参数更新。
小批量数据策略有个极端情况:每批数据的样本量为1,这种策略被称为随机梯度下降(Stochastic Gradient Descent 简称SGD),有时候也被称为在线梯度下降。SGD在技术上是指每次使用1个样本来计算梯度,你还是会听到人们使用SGD来指代小批量数据梯度下降(或者用MGD来指代小批量数据梯度下降)。
小批量数据的大小是一个超参数,但是一般并不需要通过交叉验证来调参。它一般设置为同样大小,比如32、64、128等。之所以使用2的指数,是因为在实际中许多向量化操作实现的时候,如果输入数据量是2的指数,那么运算更快。
直接输入原始像素,效果不好,可以将图像的特征计算出来,便于分类。
常用的特征计算方式:颜色直方图、词袋、计算边缘等,神经网络中是特征是训练过程中得到的。
线性分类器各种细节,可在斯坦福大学开发的一个在线程序观看演示:点击这里
可以点击 B站 查看视频的【双语字幕】版本
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。