基于numpy实现合成梯度

电子说

1.2w人已加入

描述

DeepMind提出用合成梯度取代反向传播,让网络层可以独立学习,加快训练速度。让我们和DeepMind数据科学家、Udacity深度学习导师Andrew Trask一起,基于numpy实现合成梯度。

TLDR 本文将通过从头实现DeepMind的Decoupled Neural Interfaces Using Synthetic Gradients论文中的技术,学习这一技术背后的直觉。

一、合成梯度概述

通常,神经网络比较预测和数据集,以决定如何更新权重。它接着使用反向传播找出每个权重移动的方向,使得预测更精确。然而,在合成梯度(Synthetic Gradient)的情况下,每层各自做出数据的“最佳猜测”,然后根据猜测更新其权重。“最佳猜测”称为合成梯度。数据用来帮助更新每层的“猜测器”(合成梯度生成器)。在大多数情况下,这让网络层可以独立学习,以加快训练的速度。

生成器

上图(来自论文)提供了一个直观的表示(自左向右)。圆角方块为网络层,菱形为合成梯度生成器。

二、使用合成梯度

让我们暂时忽略合成梯度是如何生成的,直接看看它们是如何使用的。上图最左展示了如何更新神经网络的第一层。第一层前向传播至合成梯度生成器(Mi+1),合成梯度生成器返回一个梯度。网络使用这个合成梯度代替真实的梯度(计算真实梯度需要一次完整的前向传播和反向传播)。接着照常更新权重,假装合成梯度是真实梯度。如果你需要温习下权重是如何根据梯度更新的,请参考我之前写的基于Numpy实现神经网络:反向传播和梯度下降。

所以,简单来说,合成梯度和平常的梯度一样,而且出于一些神奇的原因,它们看起来很精确(在没有查看数据的情况下)!看起来像魔法?让我们看看它们是如何生成的。

三、生成合成梯度

好吧,这部分非常巧妙,坦白地说,它可以起效真令人惊讶。如何为一个神经网络生成合成梯度?好吧,你当然需要另一个网络!合成梯度生成器不过是一个神经网络,该网络经训练可以接受一个网络层的输出,然后预测该网络层的梯度。

边注:Geoffrey Hinton的相关工作

事实上这让我回想起几年前Geoffrey Hinton的工作,随机合成权重支持的深度学习网络(arXiv:1411.0247)。基本上,你可以通过随机生成矩阵进行反向传播,仍然能够完成学习。此外,他展示了这具有某种正则化效应。这肯定是一项有趣的工作。

好,回到合成梯度。论文同时提到其他相关信息可以用作合成梯度生成网络的输入,不过论文本身看起来在普通前馈网络上只使用了网络层的输出作为生成器的输入。此外,论文甚至声称单线性层可以用作合成梯度生成器。令人惊奇!我们将尝试一下这个。

网络如何学习生成梯度?

这提出了一个问题,生成合成梯度的网络如何学习?当我们进行完整的前向传播和反向传播时,我们实际得到了“正确”的梯度。我们可以将其与“合成”梯度进行比较,就像我们通常比较神经网络输出和数据集一样。因此,我们可以假装“真梯度”来自某个神秘的数据集,以此训练合成梯度网络……所以我们像训练平常的网络一样训练。酷!

等一下……如果合成梯度网络需要反向传播……这还有什么意义?

很好的问题!这一技术的全部价值在于允许独立训练网络层,无需等待所有网络层完成前向传播和反向传播。如果合成梯度网络需要等待完整的前向/反向传播步骤,我们岂不是又回到了原点,而且需要进行的计算更多了(比原先还糟)。为了找到答案,让我们重新看下论文中对网络架构的可视化。

生成器

让我们聚焦左边的第二块区域。看到了没有?梯度(Mi+2)经fi+1反向传播至Mi+2。如你所见,每个合成梯度生成器实际上仅仅使用下一层生成的合成梯度进行训练。因此,只有最后一层实际在数据上训练。其他层,包括合成梯度生成网络,基于合成梯度训练。因此,训练每层的合成梯度生成网络时,只需等待下一层的合成梯度(没有其他依赖)。太酷了!

四、基线神经网络

到了写代码的时间了!我将首先实现一个通过反向传播进行训练的原味神经网络,风格与基于Numpy实现神经网络:反向传播中的类似。(所以,如果你有不明白的地方,可以先去阅读我之前写的文章,然后再回过头来阅读本文)。然而,我将额外增加一层,不过这不会造成理解问题。我只是觉得,既然我们在讨论减少依赖,更多的网络层可能有助于形成更好的解释。

至于我们训练的数据集,我们将使用二进制加法生成一个合成数据集(哈哈!)。所以,网络将接受两个随机的二进制数作为输入,并预测两者之和(也是一个二进制数)。这使我们可以方便地根据需要增加维度(大致相当于难度)。下面是生成数据集的代码。

import numpy as np

import sys

def generate_dataset(output_dim = 8,num_examples=1000):

def int2vec(x,dim=output_dim):

out = np.zeros(dim)

binrep = np.array(list(np.binary_repr(x))).astype('int')

out[-len(binrep):] = binrep

return out

x_left_int = (np.random.rand(num_examples) * 2**(output_dim - 1)).astype('int')

x_right_int = (np.random.rand(num_examples) * 2**(output_dim - 1)).astype('int')

y_int = x_left_int + x_right_int

x = list()

for i in range(len(x_left_int)):

x.append(np.concatenate((int2vec(x_left_int[i]),int2vec(x_right_int[i]))))

y = list()

for i in range(len(y_int)):

y.append(int2vec(y_int[i]))

x = np.array(x)

y = np.array(y)

return (x,y)

num_examples = 1000

output_dim = 12

iterations = 1000

x,y = generate_dataset(num_examples=num_examples, output_dim = output_dim)

print("Input: two concatenated binary values:")

print(x[0])

print("\nOutput: binary value of their sum:")

print(y[0])

下面则是相应的神经网络代码:

batch_size = 10

alpha = 0.1

input_dim = len(x[0])

layer_1_dim = 128

layer_2_dim = 64

output_dim = len(y[0])

weights_0_1 = (np.random.randn(input_dim,layer_1_dim) * 0.2) - 0.1

weights_1_2 = (np.random.randn(layer_1_dim,layer_2_dim) * 0.2) - 0.1

weights_2_3 = (np.random.randn(layer_2_dim,output_dim) * 0.2) - 0.1

for iter in range(iterations):

error = 0

for batch_i in range(int(len(x) / batch_size)):

batch_x = x[(batch_i * batch_size):(batch_i+1)*batch_size]

batch_y = y[(batch_i * batch_size):(batch_i+1)*batch_size]    

layer_0 = batch_x

layer_1 = sigmoid(layer_0.dot(weights_0_1))

layer_2 = sigmoid(layer_1.dot(weights_1_2))

layer_3 = sigmoid(layer_2.dot(weights_2_3))

layer_3_delta = (layer_3 - batch_y) * layer_3  * (1 - layer_3)

layer_2_delta = layer_3_delta.dot(weights_2_3.T) * layer_2 * (1 - layer_2)

layer_1_delta = layer_2_delta.dot(weights_1_2.T) * layer_1 * (1 - layer_1)

weights_0_1 -= layer_0.T.dot(layer_1_delta) * alpha

weights_1_2 -= layer_1.T.dot(layer_2_delta) * alpha

weights_2_3 -= layer_2.T.dot(layer_3_delta) * alpha

error += (np.sum(np.abs(layer_3_delta)))

sys.stdout.write("\rIter:" + str(iter) + " Loss:" + str(error))

if(iter % 100 == 99):

print("")

现在,我真心觉得有必要做些我几乎从不在学习时做的事,加上一点面向对象结构。通常,这会略微混淆网络,更难看清代码做了什么。然而,由于本文的主题是“解耦网络接口”(Decoupled Neural Interfaces)及其优势,如果不解耦这些接口的话,解释起来会相当困难。因此,我将把上面的网络转换为一个Layer类,之后将进一步转换为一个DNI(解耦网络接口)。

classLayer(object):

def __init__(self,input_dim, output_dim,nonlin,nonlin_deriv):

self.weights = (np.random.randn(input_dim, output_dim) * 0.2) - 0.1

self.nonlin = nonlin

self.nonlin_deriv = nonlin_deriv

def forward(self,input):

self.input = input

self.output = self.nonlin(self.input.dot(self.weights))

return self.output

def backward(self,output_delta):

self.weight_output_delta = output_delta * self.nonlin_deriv(self.output)

return self.weight_output_delta.dot(self.weights.T)

def update(self,alpha=0.1):

self.weights -= self.input.T.dot(self.weight_output_delta) * alpha

在这个Layer类中,我们有一些变量。weights是我们从输入到输出进行线性变换的矩阵(就像平常的线性层)。我们同时引入了一个输出nonlin函数,给我们的网络输出加上了非线性。如果我们不想要非线性,我们可以直接将其值设为lambda x:x。在我们的情形中,我们将传入sigmoid函数。

我们传入的第二个函数是nonlin_deriv,这是一个导数。该函数将接受我们的非线性输出,并将其转换为导数。就sigmoid而言,它的值为(out * (1 - out)),其中out为sigmoid的输出。

现在,让我们看下类中的几个方法。forward,顾名思义,前向传播,首先通过一个线性转换,接着通过一个非线性函数。backward接受一个output_delta参数,该参数表示从下一层经反向传播返回的真实梯度(非合成梯度)。我们接着使用这个参数来计算self.weight_output_delta,也就是权重输出的导数。最后,反向传播发送给前一层的误差,并返回误差。

update也许是其中最简单的函数。它直接接受权重输出的导数,并使用它更新权重。如果有任何步骤不明白,请再次参考基于Numpy实现神经网络:反向传播。

接着,让我们看看layer对象是如何用于训练的。

layer_1 = Layer(input_dim,layer_1_dim,sigmoid,sigmoid_out2deriv)

layer_2 = Layer(layer_1_dim,layer_2_dim,sigmoid,sigmoid_out2deriv)

layer_3 = Layer(layer_2_dim, output_dim,sigmoid, sigmoid_out2deriv)

for iter in range(iterations):

error = 0

for batch_i in range(int(len(x) / batch_size)):

batch_x = x[(batch_i * batch_size):(batch_i+1)*batch_size]

batch_y = y[(batch_i * batch_size):(batch_i+1)*batch_size]  

layer_1_out = layer_1.forward(batch_x)

layer_2_out = layer_2.forward(layer_1_out)

layer_3_out = layer_3.forward(layer_2_out)

layer_3_delta = layer_3_out - batch_y

layer_2_delta = layer_3.backward(layer_3_delta)

layer_1_delta = layer_2.backward(layer_2_delta)

layer_1.backward(layer_1_delta)

layer_1.update()

layer_2.update()

layer_3.update()

如果你将上面的代码和之前的脚本对比,基本上所有事情发生在基本相同的地方。我只是用方法调用替换了脚本中的相应操作。

所以,我们实际上做的是从之前的脚本中提取步骤,将其切分为类中不同的函数。

如果你搞不明白这个新版本的网络,不要继续下去。确保你在继续阅读下文之前习惯这种抽象的方式,因为下面会变得更复杂。

五、基于层输出的合成梯度

现在,我们将基于了解的合成梯度的知识改写Layer类,将其重新命名为DNI。

class DNI(object):

def __init__(self,input_dim, output_dim,nonlin,nonlin_deriv,alpha = 0.1):

# 和之前一样

self.weights = (np.random.randn(input_dim, output_dim) * 0.2) - 0.1

self.nonlin = nonlin

self.nonlin_deriv = nonlin_deriv

# 新东西

self.weights_synthetic_grads = (np.random.randn(output_dim,output_dim) * 0.2) - 0.1

self.alpha = alpha

# 之前仅仅是`forward`,现在我们在前向传播中基于合成梯度更新权重

def forward_and_synthetic_update(self,input):

# 缓存输入

self.input = input

# 前向传播

self.output = self.nonlin(self.input.dot(self.weights))

# 基于简单的线性变换生成合成梯度

self.synthetic_gradient = self.output.dot(self.weights_synthetic_grads)

# 使用合成梯度更新权重

self.weight_synthetic_gradient = self.synthetic_gradient * self.nonlin_deriv(self.output)

self.weights += self.input.T.dot(self.weight_synthetic_gradient) * self.alpha

# 返回反向传播的合成梯度(这类似Layer类的backprop方法的输出)

# 同时返回前向传播的输出(我知道这有点怪……)

return self.weight_synthetic_gradient.dot(self.weights.T), self.output

# 和之前的`update`方法类似……除了基于合成权重之外

def update_synthetic_weights(self,true_gradient):

self.synthetic_gradient_delta = self.synthetic_gradient - true_gradient

self.weights_synthetic_grads += self.output.T.dot(self.synthetic_gradient_delta) * self.alpha

我们有了一些新的变量。唯一关键的是self.weights_synthetic_grads,这是我们的合成梯度生成器神经网络(只是一个线性层……也就是……一个矩阵)。

前向传播和合成更新:forward方法变为forward_and_synthetic_update。还记得我们不需要网络的其他部分来更新权重吗?这就是魔法发生之处。首先,照常进行前向传播。接着,我们通过将输出传给一个非线性生成合成梯度。这一部分本可以是一个更复杂的神经网络,不过我们没有这么做,而是决定保持简单性,直接使用一个简单的线性层生成我们的合成梯度。得到我们的梯度之后,我们继续更新权重。最后,我们反向传播合成梯度,以便发送给之前的层。

更新合成梯度: 下一层的update_synthetic_gradient方法将接受上一层的forward_and_synthetic_update方法返回的梯度。所以,如果我们位于第二层,那么第三层的forward_and_synthetic_update方法返回的梯度将作为第二层的update_synthetic_weights的输入。接着,我们直接更新合成权重,就像在普通的神经网络中做的那样。这和通常的神经网络的学习没什么两样,只不过我们使用了一些特别的输入和输出而已。

基于合成梯度方法训练网络,我发现它不像我预料的那样收敛。我的意思是,它在收敛,但是收敛得非常慢。我仔细调查了一下,发现隐藏的表示(也就是梯度生成器的输入)在开始时比较扁平和随机。换句话说,两个不同的训练样本在不同网络层结果会有几乎一样的输出表示。这大大增加了梯度生成器工作的难度。在论文中,作者使用的解决方案是批归一化,批归一化将所有网络层输出缩放至0均值和单位方差。此外,论文还提到你可以使用其他形式的梯度生成器输入。对于我们的简单玩具神经网络而言,批归一化会加入大量复杂度。因此,我尝试了使用输出数据集。这并没有破坏解耦状态(秉持了DNI的精神),但在开始阶段给网络提供了非常强力的信息。

进行了这一改动后,训练起来快多了!思考哪些可以充当梯度生成器的优良输入真是一项迷人的活动。也许输入数据、输出数据、批归一化层输出的某种组合会是最佳的(欢迎尝试!)希望你喜欢这篇教程。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分