根据万能近似定理、反向传播的理论铺垫,我们终于进入了实战阶段,让我们用 JS 写一个跑在浏览器的神经网络吧!
在《实现万能近似函数: 神经网络的架构设计》这一篇文章我们讲过了对 Model Function、Loss Function 的定义,但还没有实现 Optimization 函数。在实现 Optimization 函数之前,我们需要回顾一下神经网络设计的代码,并加上一些关键参数缓存数据以提升运行时性能。
首先我们定义了神经网络的类 NeuralNetwork
,分别实现了几个关键函数:
constructor
: 在构造函数里我们初始化神经网络对象的层级结构。modelFunction(trainingItem: TraningItem)
: 实现了模型函数。lossFunction(trainingItem: TraningItem)
: 实现了损失函数。
其中 modelFunction
是按照万能近似定理实现的多层神经网络架构,每层都有多个神经元节点,每一层的每个节点都全连接到下一层的每个节点;lossFunction
使用均方误差衡量损失。
现在我们根据《反向传播: 揭秘神经网络的学习机制》,利用反向传播实现 optimization 函数吧!
首先我们来看该函数的实现全览,之后我们再拆解它的逻辑:
export class NeuralNetwork {
// 优化
private optimization(trainingData: TraningData) {
// 每个训练数据单独计算
trainingData.forEach((trainingItem) => {
const { dlossByDxList, lossList } = this.lossFunction(trainingItem);
// 反向传播求每个参数的导数
for (let i = this.networkStructor.length - 1; i >= 0; i--) {
const layer = this.networkStructor[i];
layer.neurals.forEach((neural, neuralIndex) => {
// 求出该节点的 dloss/dx 并保存
if (i === this.networkStructor.length - 1) {
// 输出层,就是 dlossByDx
neural.dlossByDx = dlossByDxList[neuralIndex];
} else {
// 非输出层,是下一层的神经元 (q规则) 加和
// q规则 是该层 dloss/dx * dx/dx' * w
neural.dlossByDx = 0;
const nextLayer = this.networkStructor[i + 1];
nextLayer.neurals.forEach((nextNeural) => {
neural.dlossByDx +=
nextNeural.dlossByDx *
dFunctionByType(
nextLayer.activation,
nextNeural.value,
)(nextNeural.value) *
nextNeural.w[neuralIndex];
});
}
// 求 dloss/db = dloss/dx * dx/db
// 其中 dx/db = 1
neural.dlossByDb += neural.dlossByDx;
// 求每个 dloss/dwi = dloss/dx * dx/dwi
// 其中 dx/dwi = 前一个对应神经元的输出 x
neural.w.forEach((w, wi) => {
neural.dlossByDw[wi] +=
neural.dlossByDx * this.getPreviousLayerValues(i - 1, trainingItem)[wi];
});
});
}
});
// 根据计算结果,更新每层节点的参数
for (let i = this.networkStructor.length - 1; i >= 0; i--) {
const layer = this.networkStructor[i];
layer.neurals.forEach((neural) => {
// 更新参数 b
const dbMean = neural.dlossByDb / trainingData.length;
neural.b += -dbMean;
neural.dlossByDb = 0;
// 更新参数 w
neural.w.forEach((w, wi) => {
const dwMean = neural.dlossByDw[wi] / trainingData.length;
neural.w[wi] += -dwMean;
neural.dlossByDw[wi] = 0;
});
});
}
}
}
好了,如果是第一次看到该函数,可以跟着我一步一步来解读:
首先 optimization
函数接收的参数是 trainingData: TraningData
,也就是它拿到的是一批训练资料,所以需要对训练资料进行 for 循环,对每一个资料单独处理。
在处理每一层之前,我们先调用 lossFunction
得到 dlossByDxList
,即对于 输出节点,其输出值对 loss 的偏导值。因为该偏导值取决于 Loss Function(均方误差)的定义,因此根据公式我们已经将偏导提前在 lossFunction
中算好了:
const { dlossByDxList, lossList } = this.lossFunction(trainingItem);
/** next */
接下来我们要根据反向传播计算最终 loss 针对每一个节点、每一个参数的偏导数。由于我们已经知道反向传播需要从最后一层反向推导,因此采用从后向前遍历,并遍历每一个神经元:
// 反向传播求每个参数的导数
for (let i = this.networkStructor.length - 1; i >= 0; i--) {
const layer = this.networkStructor[i];
layer.neurals.forEach((neural, neuralIndex) => {
/** next */
})
}
我们要计算最终 loss 对每一个神经元各参数的偏导。由反向传播章节介绍的公式:
对于任意参数 z
(实际上是针对每个神经元的参数 b
与任意数量的参数 w
),loss 对该参数的偏导可以拆解为 loss 对该节点输出值 x
的偏导乘以参数对该节点本身的偏导,因此我们先计算 dloss/dx
。
首先对于输出节点,dloss/dx
的值已经在 dlossByDxList
变量中了:
if (i === this.networkStructor.length - 1) {
// 输出层,就是 dlossByDx
neural.dlossByDx = dlossByDxList[neuralIndex];
} else {
/** next */
}
对于非输出层,我们结合根据链式法则得到的推导公式:
可以发现,想要计算 dloss/dx
就必须计算出该节点 下一层所有节点的 dloss/dx
,所以我们要在每个节点挂一个临时变量 dlossByDx
,这个缓存下来的值在反向传播计算前一个节点的 dloss/dx
需要被用到。
其实计算当前节点的 dloss/dx
也是为再计算前一个节点的 dloss/dx
做准备嘛,而对于输出层来说,dloss/dx
已经得到值了,所以我们可以总是认为下一层的每个神经元的 dloss/dx
已经准备好了。
再根据万能近似定理的实现公式,发现 dx'/dx
其实就是 dx'/dx= 启动函数偏导(x') * w'
(其中 x'
表示下一层的神经元)。
最后再根据全连接的特性,把该节点对下一层每个神经元的计算值都进行累加,就得到了下面的代码:
else {
// 非输出层,是下一层的神经元 (q规则) 加和
// q规则 是该层 dloss/dx * dx/dx' * w
neural.dlossByDx = 0;
const nextLayer = this.networkStructor[i + 1];
nextLayer.neurals.forEach((nextNeural) => {
neural.dlossByDx +=
nextNeural.dlossByDx *
dFunctionByType(
nextLayer.activation,
nextNeural.value,
)(nextNeural.value) *
nextNeural.w[neuralIndex];
});
}
有了 loss 对当前节点输出 x 的偏导,我们就非常容易求 loss 针对当前节点各参数的偏导了。
首先是针对参数 b 的偏导:
// 求 dloss/db = dloss/dx * dx/db
// 其中 dx/db = 1
neural.dlossByDb += neural.dlossByDx * 1;
为什么 dx/db=1
呢?因为 b 是常数项,所以偏导是 1.
再针对 w 求偏导:
// 求每个 dloss/dwi = dloss/dx * dx/dwi
// 其中 dx/dwi = 前一个对应神经元的输出 x
neural.w.forEach((w, wi) => {
neural.dlossByDw[wi] +=
neural.dlossByDx * this.getPreviousLayerValues(i - 1, trainingItem)[wi];
});
为什么 dx/dwi=x'i
(x' 表示下一个层的对应节点的输出值) 呢?因为该节点的的函数表达式为 f(x) = wn*xn + b
,而 wi
对于 f(x)
的偏导值就是 xi
,所以我们利用 getPreviousLayerValues
找到前一层的第 i
节点的输出值就行了。
得到了参数 b 与 w 的偏导值后,我们再次遍历一下神经网络,累加偏导值即可,唯一注意的一点是,由于前面我们基于所有训练资料把偏导值加总了,这里累加的时候除以训练资的总数:
// 根据计算结果,更新每层节点的参数
for (let i = this.networkStructor.length - 1; i >= 0; i--) {
const layer = this.networkStructor[i];
layer.neurals.forEach((neural) => {
// 更新参数 b
const dbMean = neural.dlossByDb / trainingData.length;
neural.b += -dbMean;
neural.dlossByDb = 0;
// 更新参数 w
neural.w.forEach((w, wi) => {
const dwMean = neural.dlossByDw[wi] / trainingData.length;
neural.w[wi] += -dwMean;
neural.dlossByDw[wi] = 0;
});
});
}
到这里,我们就完成了一轮基于反向传播原理的参数优化。
根据 optimization 的实现,我们在计算过程中,为每个神经节点增加了 dlossByDx
、dlossByDbAll
、dlossByDwAll
这三个参数,所以对应神经元定义,与神经网络构造函数也要更新一下。
首先是神经元的定义,这次我们增加了三个参数:
interface Neural {
/** 当前该节点的值 */
value: number;
/** 上一层每个节点连接到该节点乘以的系数 w */
w: Array<number>;
/** 该节点的常数系数 b */
b: number;
// dloss/dx - 仅针对当前训练资料
dlossByDx: number;
// dloss/db - 对所有训练资料累加值
dlossByDbAll: number;
// dloss/dw - 对所有训练资料累加值
dlossByDwAll: Array<number>;
}
因为要更新的参数是 b
与 w
,为了在批量训练资料完成后更新这些参数,我们会记录在所有训练资料下参数对 loss 偏导的累加值,而 dlossByDx
只用于每一个训练资料的临时计算,不会用于参数更新,所以不用累加。
接下来更新是神经元的 constructor 构造函数:
export class NeuralNetwork {
// 输入长度
private inputCount = 0;
// 网络结构
private networkStructor: NetworkStructor;
// 训练数据
private trainingData: TraningData;
constructor({
trainingData,
layers,
}: {
trainingData: TraningData;
layers: Layer[];
}) {
this.trainingData = trainingData;
this.inputCount = layers[0].inputCount!;
this.networkStructor = layers.map(({ activation, count }, index) => {
const previousNeuralCount = index === 0 ? this.inputCount : layers[index - 1].count;
return {
activation,
neurals: Array.from({ length: count }).map(() => ({
value: 0,
w: Array.from({
length: previousNeuralCount,
}).map(() => getRandomNumber()),
b: getRandomNumber(),
dlossByDx: 0,
dlossByDbAll: 0,
dlossByDwAll: Array.from({
length: previousNeuralCount,
}).map(() => 0),
})),
};
});
}
}
为了验证该万能近似函数可以完全包含我们最早实现的一元一次函数,我们将神经网络设定为一层,且该层只有一个神经元,那么理论上应该退化为一元一次函数 y = b + wx
的效果,我们验证一下:
const trainingData = [
[1, 3],
[2, 6],
[3, 9],
[4, 12],
[5, 15],
];
new NeuralNetwork({
trainingData,
layers: [{ count: 1, activation: 'none', inputCount: 1 }],
})
其中 activation='none'
表示不使用启动函数。
效果如下:
从上图看,可以证明万能函数在一层一个神经元的时候,已经完全降级为一元一次函数的效果,从另一方面也证明我们的反向传播逻辑应该没有问题。
我们试试一元一次函数无法解决的非线性函数拟合:
const trainingData = [
[1, 3.2],
[2, 7],
[3, 8],
[4, 11.2],
[5, 15.3],
];
new NeuralNetwork({
trainingData: commonTrainingData,
layers: [
{ count: 5, activation: 'leakyRelu', inputCount: 1 },
{ count: 1, activation: 'leakyRelu' },
],
})
这个例子中,我们使用 leakyRelu
作为启动函数,并且设计了一个两层的神经网络,其中第一层网络有 5 个节点,第二层输出层有 1 个节点,所以输出了一个 y 值。
这个网络训练起来难度就陡增了,训练了 10 万次的效果如下图:
可以发现,该训练过程通过组合第一层的 5 个神经节点,把 leakyRelu
的拐点用到了两处关键位置,使得整体曲线更加贴合每一个红点。
通过手写神经网络可以发现,一个两层的神经网络训练的难度比一层的网络大了非常多:一层神经网络非常好调,但两层的神经网络笔者就不得不进行一些学习速率、神经网络节点、启动函数类型的不断试错,训练了10万次才找到了相对较好的结果,而一层神经网络解决一元线性问题可能训练100次就达到了最优解。
所以当神经网络深度增加时,调参的难度会几何倍数增加,大部分机器学习专家的精力也是花在如何让庞大的神经网络可以 “学起来” 的事情上,在接下来的文章,我们会介绍一些神经网络训练的技巧。