目录
2.2 Probabilistic Interpretation
2.3 cross-entropy loss function
2.4 Practical issues: Numeric stability
1. 前言
本文是李飞飞cs231n-2022的第一次作业的第3个问题(Implement a Softmax classifier)。 前两个问题分别参见:
cs231n-2022-assignment1#Q1:kNN图像分类器实验
cs231n-2022-assignment1#Q2:训练一个支持向量机(SVM)
本次作业相关的课程内容参见:CS231n Convolutional Neural Networks for Visual Recognition
建议有兴趣的伙伴读原文,过于精彩,不敢搬运。本文可以作为补充阅读材料,主要介绍作业完成过程所涉及一些要点以及关键代码解读。作业的原始starter code可以从该课程网站下载。本文仅涉及完成作业所需要修改的代码,修改的文件涉及以下几个文件:
- cs231n/classifiers/softmax.py
- root\softmax.ipynb
2. SoftMax分类器
SoftMax分类器与SVM分类器的主要区别在于对数据样本经过(其中D表示数据维度数,或者说所包含元素个数;C表示分类数)映射的结果数据的(物理意义的)解释,以及由此而衍生的损失函数、梯度计算等。
2.1 softmax函数
softmax函数定义如下:
softmax函数是sigmoid函数从2维向量向多维向量的推广。
由以上定义可知,,因此
可以看作是一种概率度量。
2.2 Probabilistic Interpretation
借助softmax函数可以将的各分量变换为针对数据样本
在系数W的条件下被分类为类
的概率:
换句话说,Softmax分类器将原始变换 的结果解释为样本被识别为各类别的非归一化的对数概率(unnormalized log probabilities)。
2.3 cross-entropy loss function
在信息论中,用交叉熵来衡量两个概率分布时间的距离,如下所示:
在SoftMax分类器中,则基于交叉熵来计算损失函数。
一方面是通过对进行softmax映射得到样本归类为各类的概率,这个构成一个概率分布。另一方面是根据标签得到的真实概率分布
,其中,显而易见的是,如果样本
的真实类别为
,则有
,因此可以得到交叉熵损失函数如下:
以上,为了描述的简化,用表示
.
对所有样本的损失函数求和然后再加上正则损失(与SVM分类相同),就得到SoftMax分类器的完整的损失函数:
2.4 Practical issues: Numeric stability
由于以上计算涉及指数计算,因此很容易出现由overflow导致数值稳定性问题。
通常的做法是在进入指数运算处理之前,先对进行“归一化”处理,如下所示:
这样做有效地压缩了数据范围,但是并不影响softmax变换输出结果。
2.5 向量微积分
向量微积分(vector calculus)也称矩阵微积分(matrix calculus),是标量微积分的扩展。这里只介绍接下来梯度计算所需要的东西。更全面的相关只是请参考相关正式文献。由矩阵微积分法则:
其中x和w表示两个向量(1阶张量),具体过程如下:
其中W表示一个矩阵(2阶张量)。
注意,这里采用的是所谓的numerator layout notation。与之相对的还有另一种标记法叫denominator layout notation。两者之间的差别,在于微分后所得的张量的shape如何安排(layout)。理论上来说,两者都是可以的,只要前后连贯一致即可。但是实际上可能numerator layout notation更为常见一些。在numerator layout notation中,对列向量的求导得到的是行向量,对矩阵的求导得到的张量的layout是按该矩阵的转置方式来进行安排的。
2.5 梯度计算
Softmax损失函数相比SVM损失函数的一个优势在于softmax函数本身是可微的,因此其梯度计算相对来说更简洁易懂。
以下来看看如何计算梯度(为简洁起见,暂时只考虑单个样本的损失函数的梯度,且不考虑正则项)。由上一节我们得到:
这里
根据链式法则:
For ,
For ,
推导过程如下(for 。
的推到大抵相同,略):
3. 代码实现
3.1 softmax_loss_naive
def softmax_loss_naive(W, X, y, reg):
"""
Softmax loss function, naive implementation (with loops)
Inputs have dimension D, there are C classes, and we operate on minibatches
of N examples.
Inputs:
- W: A numpy array of shape (D, C) containing weights.
- X: A numpy array of shape (N, D) containing a minibatch of data.
- y: A numpy array of shape (N,) containing training labels; y[i] = c means
that X[i] has label c, where 0 <= c < C.
- reg: (float) regularization strength
Returns a tuple of:
- loss as single float
- gradient with respect to weights W; an array of same shape as W
"""
# Initialize the loss and gradient to zero.
loss = 0.0
dW = np.zeros_like(W)
#############################################################################
# TODO: Compute the softmax loss and its gradient using explicit loops. #
# Store the loss in loss and the gradient in dW. If you are not careful #
# here, it is easy to run into numeric instability. Don't forget the #
# regularization! #
#############################################################################
# *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
num_train = X.shape[0]
num_class = W.shape[1]
for i in range(num_train):
score = X[i].dot(W) # (1,D) x (D,C) --> (1,C)
score = score - np.max(score) # Normalization to avoid numeric stability problem
exp_score = np.exp(score)
prob = exp_score / np.sum(exp_score)
loss -= np.log(prob[y[i]])
prob[y[i]] = prob[y[i]] - 1
dW += np.expand_dims(X[i].T,axis=1) * np.expand_dims(prob,axis=0)
loss = loss / num_train + 0.5 * reg * np.sum(W*W)
dW = dW / num_train + reg * W
# *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
return loss, dW
几个要点如下:
score = score - np.max(score)
这条语句对应于2.4所描述的防止由于指数运算带来的numeric stability问题。
exp_score = np.exp(score)
prob = exp_score / np.sum(exp_score)
这两条语句对应于softmax()映射。其结果就是每个样本对应于每个分类的概率,即,也即2.5节中的
。而当前样本的loss就是该概率的对数的负值。
prob[y[i]] = prob[y[i]] - 1
这条语句对应于2.5中所述的针对对应正确标签那一项的调整。
dW += np.expand_dims(X[i].T,axis=1) * np.expand_dims(prob,axis=0)
如前所述,在numerator layout notation中,对列向量的求导得到的是行向量,对矩阵的求导得到的张量的layout是按该矩阵的转置方式来进行安排的。所以的形状本应是(C,D),但是,一方面这个只是一个convention而已;另一方面,
是要用于W的更新的,需要转置变换回到和W相同的形状。所以以下是直接计算得到
的转置。
np.expand_dims(X[i].T,axis=1)的形状是(D,1),np.expand_dims(prob,axis=0)的形状是(1,C),两者直接相乘由于numpy的broadcasting作用会得到形状(D,C)的张量。注意,要用np.expand_dims()进行维度扩充才能进行正确的broadcasting,相关细节可以查阅numpy文档关于broadcasting的描述。
其余关于正则化的处理上一篇SVM中的相同,此处不再赘述。
3.2 softmax_loss_vectorized
softmax_loss_naive中的关于样本的显式循环消除掉,实现完全向量化的处理得到如下代码:
def softmax_loss_vectorized(W, X, y, reg):
"""
Softmax loss function, vectorized version.
Inputs and outputs are the same as softmax_loss_naive.
"""
# Initialize the loss and gradient to zero.
loss = 0.0
dW = np.zeros_like(W)
#############################################################################
# TODO: Compute the softmax loss and its gradient using no explicit loops. #
# Store the loss in loss and the gradient in dW. If you are not careful #
# here, it is easy to run into numeric instability. Don't forget the #
# regularization! #
#############################################################################
# *****START OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
num_train = X.shape[0]
num_class = W.shape[1]
scores = X @ W # (N,D) x (D,C) --> (N,C)
scores = scores - np.max(scores,axis=1,keepdims=True)
exp_scores = np.exp(scores)
probs = exp_scores / np.sum(exp_scores,axis=1,keepdims=True)
loss = -np.sum(np.log(probs[range(num_train),y]))
probs[range(num_train),y] -= 1
dW = X.T.dot(probs)
loss = loss / num_train + 0.5 * reg * np.sum(W*W)
dW = dW / num_train + reg * W
# *****END OF YOUR CODE (DO NOT DELETE/MODIFY THIS LINE)*****
return loss, dW
一些要点分别说明如下。
scores的向量化非常直观:从单个样本的“score = X[i].dot(W)” 到批量处理的“scores = X @ W”。虽然运算符号变了,但是其实用"X[i] @ W"和"X.dot(W)"应该也是可以?需要实验确认一下。
scores = scores - np.max(scores,axis=1,keepdims=True)
exp_scores = np.exp(scores)
probs = exp_scores / np.sum(exp_scores,axis=1,keepdims=True)
以上语句完成了完全向量化的score归一化、softmax映射以及概率的计算。需要注意的一点,为了能够进行正确的broadcasting处理,在np.max()和np.sum()处理中使用了keepdims选项用于保持被运算消除了的一个维度。 对于shape=(N,C)的scores,调用"np.max(scores,axis=1)"是做按行累加会得到shape=(N,),但是这样的话再执行与scores的相减运算无法进行正确的broadcasting。因此用keepdims=True将由于求和运算被压缩掉的维度仍然保留下来,“np.max(scores,axis=1,keepdims=True)”生成的结果为shape=(N,1)。后面np.sum()的调用也同理。
loss = -np.sum(np.log(probs[range(num_train),y]))
loss的计算只取probs的每一行中对应样本的对应于类别真值的概率再取对数。 np.log执行的是element-wise运算,不需要考虑shape-matching的问题。
“probs[range(num_train),y]”的索引方式很优雅,相当于是取:
[ p[0,y[0]], p[1,y[1]], ..., p[num_train-1,y[num_train-1]] ]
"probs[range(num_train),y] -= 1" 的处理方式同理。
# Both are equivalent
dW = X.T.dot(probs)
# dW = X.T @ probs
dW的向量化是这里最不直观的,可能需要好好咀嚼一下才能理解。但是在向量化处理中有一个非常nice的事情就是,当shape匹配上了,基本上就对了。这就跟两个接口对接时一样,由于接口形状匹配的要求,使得你想出错都不是那么容易。X.T.shape=(D,N), probs.shape=(N,C),所以两者相乘(矩阵乘法)得到(D,C),perfect,恰是所想要的W的形状。
4. softmax分类的实验性能
对softmax.ipnyb做一些修改(其实与上一题svm.ipnyb中的修改基本相同),然后运行代码检查结果是否正确。。。此处过程不再赘述。最终softmax分类器的结果如下所示:
lr 1.000000e-07 reg 2.500000e+04 train accuracy: 0.349612 val accuracy: 0.364000 lr 1.000000e-07 reg 5.000000e+04 train accuracy: 0.330020 val accuracy: 0.347000 lr 5.000000e-07 reg 2.500000e+04 train accuracy: 0.340796 val accuracy: 0.345000 lr 5.000000e-07 reg 5.000000e+04 train accuracy: 0.332857 val accuracy: 0.349000
这个结果居然比svm分类器要差一些。。。有点出乎我的意料。