迄今为止,已经讨论过线性层和Relu层,包括Softmax和交叉熵损失函数,现在可以用这些模块组建一个简单的网络了:
永远在你身后:Softmax与交叉熵损失的实现及求导zhuanlan.zhihu.comMINST数据库是由Yann提供的手写数字数据库文件,这个数据库主要包含了60000张的训练样本(Sample)和10000张的测试样本
每个样本包括一个28X28的训练数据(X)和对应的真值(Y),例如
上面是一个样本的栗子,其实就是一个28x28的灰度图像,其对应的真值是9(这应该是9吧)
所以,这是一个多分类任务,接受784(=28x28)的输入,输出是10(数字0-9)的向量,然后计算损失,反向传播,更新参数,再进行下一次训练……
那么这个网络应该是个什么样子呢?
layers = [
{'type': 'linear', 'shape': (784, 200)},
{'type': 'relu'},
{'type': 'linear', 'shape': (200, 100)},
{'type': 'relu'},
{'type': 'linear', 'shape': (100, 10)}
]
如上,这是网络的层的配置,可以看到,这里有五个层(Layer)
第一个Linear接受的输入大小为784,输出大小为200,然后经过Relu激活
第二个Linear的输入大小为200,输出100,再经过Relu激活
第三个Linear的输入为100,输出大小为10
这里没有加Softmax,因为这部分已经并入交叉熵损失函数里面了
当然,也可以不按这个方式来,不过只要输入是784,输出是10,至少包含2个Linear(即至少有一个隐藏层)就行
现在,可以根据上面的配置开始实例化各种Layer,然后组成网络了,不过有时候可能会希望换一种配置,所以不能把它给写死了,最好是能够根据配置自动生成网络,以后只需要改配置就行了
所以,这里再定义一个Net类,用来实现这个需求:
class Net(Layer):
def __init__(self, layer_configures):
self.layers = []
for config in layer_configures:
self.layers.append(self.createLayer(config))
def createLayer(self, config):
'''
继承的子类添加自定义层可重写此方法
'''
return self.getDefaultLayer(config)
def getDefaultLayer(self, config):
t = config['type']
if t == 'linear':
layer = Linear(**config)
elif t == 'relu':
layer = Relu()
elif t == 'softmax':
layer = Softmax()
else:
raise TypeError
return layer
其中包含一个layers属性,通过给定的配置创建相应的Layer并附加到其中,另外,该类也是从Layer的基类继承的,所以还要实现forward和backward方法:
def forward(self, x):
for layer in self.layers:
x = layer.forward(x)
return x
def backward(self, eta):
for layer in self.layers[::-1]:
eta = layer.backward(eta)
return eta
很简单,forward就是正序遍历layers的forward,前一层的输出就是后一层的输出,backward也一样,不过是逆序遍历而已
接下来,就是读取训练数据
在本文开头的GitHub链接有我已经转换成npz文件的训练集和测试集,读取函数就很简单了
def load_MNIST(file, transform=False):
file = np.load(file)
X = file['X']
Y = file['Y']
if transform:
X = X.reshape(len(X), -1)
return X, Y
网络组好了,训练数据有了,损失函数也有了
现在,就差一样就可以进行训练了:优化器(Optimizer)
前面讨论了各层的正向计算,损失函数以及反向传播和参数的梯度计算,但是一直没有说到参数更新这一部分,虽然提到过一个公式:
但是一直没有提起相关代码,因为我并不打算把参数更新直接写在backward里面,而是使用优化器来进行集中更新
回顾在(一)中关于Linear的初始化函数中有个Parameter类:
class Linear(Layer):
def __init__(self, shape, require_grad=True, bias=True, **kwargs):
'''
shape = (in_size, out_size)
'''
self.W = Parameter(shape, require_grad)
self.b = Parameter(shape[-1], require_grad) if bias else None
self.require_grad = require_grad
这个Parameter主要就是包装了一个numpy数组,做下初始化,其他并没什么功能:
class Parameter(object):
def __init__(self, shape, requires_grad):
if isinstance(shape, int):
self.data = np.zeros(shape)
elif len(shape) == 2:
self.data = np.random.randn(*shape) * 2 / shape[0]
self.grad = None
self.requires_grad = requires_grad
这里之所以要对参数增加一层封装而不是直接使用numpy数组,一个是方便使用接下来要介绍的优化器(以后会介绍不同的优化算法)进行参数更新,另一个就是方便对参数的保存和读取(总不能每次训练都重新开始吧)
可以看到,上面的初始化做了下一下区分,因为如果这个参数是偏置的话,传进来的shape就是一个整数,可以直接初始化为0,
如果传进来的shape是元组的话,意味着该参数是权重,就使用正太分布的随机方法进行初始化,并且再乘上一个修正值:
n是输入数据的规模,例如上面的配置中第一个Linear的shape为(784, 200),那么对于该层的权重初始化而言,n就是784
如果你要问为什么是
然后,还需要Net类里面也要改一下,初始化方法中添加一个属性,用来保存所有Layer的参数(如果有的话)
class Net(Layer):
def __init__(self, layer_configures):
self.parameters = []
因为迄今为止介绍过的Layer中只有线性层有参数,所以创建线性层时就要把它的参数加进去
def getDefaultLayer(self, config):
t = config['type']
if t == 'linear':
layer = Linear(**config)
self.parameters.append(layer.W)
if layer.b is not None: self.parameters.append(layer.b)
现在,是时候正式介绍优化器了
class SGD(object):
def __init__(self, parameters, lr):
self.parameters = parameters
self.lr = lr
初始化参数非常简单,parameters就是上面Net中的那个,lr是学习率(learning rate)的缩写(即公式中的
现在要用到的也只有一个方法:
def update(self):
for p in self.parameters:
if not p.requires_grad: continue
p.data -= self.lr * p.grad
每次调用时遍历paramters,如果该参数不需要更新则跳过,否则就进行更新
p.data即
p.grad即
贴一下关键部分代码,显示创建各个类的实例
if __name__ == "__main__":
layers = [
{'type': 'linear', 'shape': (784, 200)},
{'type': 'relu'},
{'type': 'linear', 'shape': (200, 100)},
{'type': 'relu'},
{'type': 'linear', 'shape': (100, 10)}
]
loss_fn = package.CrossEntropyLoss()
net = package.Net(layers)
optimizer = optim.SGD(net.parameters, 0.01)
batch_size = 128
然后是训练部分
output = net.forward(x)
batch_acc, batch_loss = loss_fn(output, y)
eta = loss_fn.gradient()
net.backward(eta)
optimizer.update()
x,y分别是训练样本的数据和真值,batch_acc和batch_loss是对应的正确率和损失值
最后,我们之前所做的推导都是建立在一次训练一个样本的基础上的,然而一次训练一个样本不仅速度慢,而且梯度下降的方向充满太多不确定性——简单的说就是单个训练样本偶然性太大了,针对该样本的损失进行梯度更新对于整个训练集而言不一定是正确的
所以,可以一次使用多个样本进行训练,然后使用它们总体损失的平均值来对参数进行更新,这样就大大降低了单个样本所带来的不确定性,使得梯度下降能够快的朝正确的方向进行
而且,即使是使用多个样本同时训练,但是代码并不需要做什么改动,之前做的推导依然是有效的,在下一篇会进行论证
完整实现github.com