经过万能近似定理的铺垫,我们知道理论上如何实现万能近似函数了,接下来我们常识用 TS 实现它,不用任何库函数,只用最原生的代码编写所有逻辑。
再次强调,就像看再多全栈入门到精通也不如自己从零写一套博客系统一样,虽然现在机器学习的 python 库非常多,但自己动手从零开始实现程序对学习新知识来说非常重要。理论与实践要相互结合。
首先,我们考虑 API 如何设计。
API 设计
API 设计是开发任何接口的第一步,我们要实现的万能近似函数包括两个过程,第一是创建神经网络,第二是训练(可以训练任意多次,每次训练得到此时的 loss)。
先定义训练。为了训练,首先要定义 Training data 的结构,它由神经网络的输入输出决定。假定我们要实现的万能近似函数的输入输出都是数字,即可以表示一个高维函数,那么 Training data 的类型可以这么定义:
type TrainingData = TrainingItem[]
type TrainingItem = [number[], number[]]
也就是说,输入与输出可以是任意数量的数字,并且长度可以不等。
比如对于一个一元方程的 Training data 可以这么表示:
const trainingData: TrainingData = [
[[1], [3]],
[[2], [6]],
[[3], [9]],
];
每组 Training data input 和 output 的数量越多,表示的函数图像维度就越高,这里为了方便理解,我们用了单输入与单输出举例。
再定义创建神经网络。神经网络的架构包括该神经网络有几层,每层有几个神经元,每个神经元的启动函数是怎样的。为了简化,我们假设神经网络是 full connected(全连接) 的,即每个神经元的输入来自上一层所有神经元的输入。
每个输入层的定义如下:
interface Layer {
count: number;
activation: "sigmoid" | "relu"; // 支持 sigmoid 和 relu 两种启动函数
inputCount?: number; // 输入长度,仅第一层需要
}
一个最简单的神经网络创建函数设计如下:
const neuralNetwork = new NeuralNetwork({
trainingData: trainingData,
layers: [
{ count: 4, activation: "sigmoid", inputCount: 1 },
{ count: 1, activation: "sigmoid" },
],
});
上面的 layers
描述了两层神经网络,输入层有 1 个节点,中间层有 4 个节点,输出层有 1 个节点(只有第一个层需要定义 inputCount
,因为输入长度是未知的)。
一个最小化神经网络,至少要定义 training data 与神经网络每一层的结构,其中每一层结构的 count
代表神经元的数量,activation
代表启动函数类型。当然这样设计有一个小瑕疵,即每个 layer 的神经元启动函数都相同。
接着,就可以调用 fit()
进行训练:
neuralNetwork.fit(); // { loss: .. }
调用几次就训练几次,每次都返回当前的 loss 值,以判断训练是否有效收敛。
神经网络对象实体的设计
接下我们讨论 NeuralNetwork
类的实现。首先这个函数显然包含以下几个要素:
定义神经网络对象实体,并存储各节点参数,以便训练时可以不断调整该网络的参数。
定义 model function。
定义 loss function。
定义 optimization。
其中 optimization 环节是最难的,因为我们要根据输出的结果,对每一个参数求导,这个过程被称为 “反向传播”,我们后续单开一篇文章来说明。
这次我们先采用一种模拟的方式绕过反向传播的具体实现,让代码能跑起来,方便理解全流程。
回到神经网络对象实体的设计,我们再来看一次神经网络的结构图:
可以看到从第二层网络开始,每一层网络的值都是上一层所有节点分别乘以不同的 w 系数,再加上 b 系数,最后乘以 c 系数,因此我们可以把参数存在第二层节点上,分别是:
w:
number[]
,表示上一层每个节点连接到该节点乘以的系数 w。b:
number
,表示该节点的常数系数 b。c:
number
,表示该节点的常数系数 c。
我们可以定义神经网络数据结构如下:
type NetworkStructor = Array<{
// 启动函数类型
activation: "sigmoid" | "relu";
// 节点
neurals: Array<{
/** 当前该节点的值 */
value: number | undefined;
/** 上一层每个节点连接到该节点乘以的系数 w */
w: Array<number>;
/** 该节点的常数系数 b */
b: number;
/** 该节点的常数系数 c */
c: number;
}>;
}>;
则我们根据用户传入的 layers
来初始化神经网络对象,并对每个参数赋予一个初始值:
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) => ({
activation,
neurals: Array.from({ length: count }).map(() => ({
value: undefined,
w: Array.from({
length: index === 0 ? this.inputCount : layers[index - 1].count,
}).map(() => getRandomNumber()),
b: getRandomNumber(),
c: getRandomNumber(),
})),
}));
}
}
如上代码所示,将参数中输入长度、神经网络结构、训练数据都分别记录了下来,其中给每个神经节点 w、b、c 从 [-3, 3] 的随机初始值。
这样,我们就拥有了包含完整信息的神经网络结构,接下来只要分别实现 model function、loss function、optimization 就行了。
model function 的设计
梳理一下 model function 的逻辑:Training data 每一条输入的长度为 inputCount
,它们 full connect 到 networkStructor
的第一层,然后经过后续所有层的处理最后达到输出层,输出层是一个数组。
modelFunction
的输入就是一个训练数据,输出是一个字符串数组,TraningItem
的结构上面已经提过:
type TrainingItem = [number[], number[]]
所以拿 traningItem
的第 0 项就是输入,modelFunction
就是根据输入得到预测的输出。
class NeuralNetwork {
/** 获取上一层神经网络各节点的值 */
private getPreviousLayerValues(layerIndex: number, trainingItem: TraningItem) {
if (layerIndex >= 0) {
return this.networkStructor[layerIndex].neurals.map((neural) => neural.value);
}
return trainingItem[0];
}
private modelFunction(trainingItem: TraningItem) {
this.networkStructor.forEach((layer, layerIndex) => {
layer.neurals.forEach((neural) => {
// 前置节点的值 * w 的总和
let weightCount = 0;
this.getPreviousLayerValues(layerIndex - 1, trainingItem).forEach(
(value, index) => {
weightCount += value * neural.w[index];
},
);
const activateResult = activate(layer.activation)(weightCount + neural.b);
neural.value = neural.c * activateResult;
});
});
// 输出最后一层网络的值
return this.networkStructor[this.networkStructor.length - 1].neurals.map(
(neural) => neural.value
);
}
}
modelFunction
函数的流程很简单,首先遍历类神经网络的每一层,计算其中每个神经元的值。
而计算这个值,取决于该网络上一层的每一个值,乘以权重 w,加上系数 b,并通过启动函数(activate
实现我们待会说)后再乘以系数 c。
第一层类神经网络的上一层来自于输入,从第二层开始,输入就来自于上一层神经网络,所以为了方便统一处理,定义了 visitPreviousLayerInputs
函数拿到上一层输入,兼容了第一层要从输入(Training data)拿的情况。
接下来是 activate
函数,它可以根据启动函数名生成对应的启动函数实现,代码如下:
const functionByType = (functionType: ActivationType) => (x: number) => {
switch (functionType) {
case "sigmoid":
return sigmoid(x);
// ...
}
};
const sigmoid = (z: number) => {
return 1 / (1 + Math.pow(Math.E, -z));
};
loss function 的设计
loss function 的输入也是 Training item,输出也是一个数字,这个数字就是 loss,越小越好。
计算 loss 有很多种选择,我们选择一种最简单的均方差:
class NeuralNetwork {
private lossFunction(trainingItem: TraningItem) {
// 预测值
const y = this.modelFunction(trainingItem);
// 实际值
const t = trainingItem[1];
// loss 最终值
let loss = 0;
for (let i = 0; i < y.length; i++) {
// l(t,y) = (t-y)²
loss += Math.pow(t[i] - y[i]!, 2);
}
return loss / y.length;
}
}
首先执行 modelFunction
拿到预测值,再把与实际值的平方差加总,除以 Training item 的长度,就得到了均方差。
optimization 的设计(模拟实现)
本来 optimazation 应该使用反向传播来实现,但因为实现比较复杂,我们在下一篇文章再说明。
但为了让故事完整,我们也可以采用迂回手段模拟 optimization 实现的:模拟求导。
把要求导的参数增加一个极小的值,其他参数不变,此时得到函数的输出减去上一次的函数值,就可以作为近似的偏导值,它反而最符合求导的本质:
optimization 的入参是 traningData
,返回值是此时的 loss。入参和出参与 lossFunction
一样,但这个函数是真的有在做优化,而 lossFunction
仅仅计算 loss。
模拟求导的实现有两个问题:
性能差,需要反复执行函数。
结果不精确,训练效果不好。
我们下一篇就来介绍反向传播。
总结
我们从零到一构建一个神经网络,包括以下几个部分:
神经网络 API 的设计。
神经网络类的框架实现。
model function、loss function 的实现。
我们把 Training data 的每一项命名为 Training item,其中 model function、loss function 的入参是 Training item, optimization 的入参是 Training data,它们的含义分别如下:
modelFunction(traningItem)
: 输入一项训练数据,输出预测结果。lossFunction(traningItem)
: 输入一项训练数据,输出当前神经网络的 loss。optimization(traningData)
: 输入一组训练数据,让神经网络对该组训练数据进行一次 fit。
可以看到,神经网络训练好后,只需要调用 modelFunction
即可,不再需要求导,也不需要大量训练,调用成本相比训练时下降了好几个数量级。
因为篇幅问题,我们没有详细介绍 optimization
阶段,下一篇文章我们进入反向传播知识点。