当前位置:   article > 正文

实现万能近似函数: 神经网络的架构设计

实现万能近似函数: 神经网络的架构设计

经过万能近似定理的铺垫,我们知道理论上如何实现万能近似函数了,接下来我们常识用 TS 实现它,不用任何库函数,只用最原生的代码编写所有逻辑。

再次强调,就像看再多全栈入门到精通也不如自己从零写一套博客系统一样,虽然现在机器学习的 python 库非常多,但自己动手从零开始实现程序对学习新知识来说非常重要。理论与实践要相互结合。

首先,我们考虑 API 如何设计。

API 设计

API 设计是开发任何接口的第一步,我们要实现的万能近似函数包括两个过程,第一是创建神经网络,第二是训练(可以训练任意多次,每次训练得到此时的 loss)。

先定义训练。为了训练,首先要定义 Training data 的结构,它由神经网络的输入输出决定。假定我们要实现的万能近似函数的输入输出都是数字,即可以表示一个高维函数,那么 Training data 的类型可以这么定义:

  1. type TrainingData = TrainingItem[]
  2. type TrainingItem = [number[], number[]]

也就是说,输入与输出可以是任意数量的数字,并且长度可以不等。

比如对于一个一元方程的 Training data 可以这么表示:

  1. const trainingData: TrainingData = [
  2.   [[1], [3]],
  3.   [[2], [6]],
  4.   [[3], [9]],
  5. ];

每组 Training data input 和 output 的数量越多,表示的函数图像维度就越高,这里为了方便理解,我们用了单输入与单输出举例。

再定义创建神经网络。神经网络的架构包括该神经网络有几层,每层有几个神经元,每个神经元的启动函数是怎样的。为了简化,我们假设神经网络是 full connected(全连接) 的,即每个神经元的输入来自上一层所有神经元的输入。

每个输入层的定义如下:

  1. interface Layer {
  2.   count: number;
  3.   activation: "sigmoid" | "relu"// 支持 sigmoid 和 relu 两种启动函数
  4.   inputCount?: number; // 输入长度,仅第一层需要
  5. }

一个最简单的神经网络创建函数设计如下:

  1. const neuralNetwork = new NeuralNetwork({
  2.   trainingData: trainingData,
  3.   layers: [
  4.     { count: 4, activation: "sigmoid", inputCount: 1 },
  5.     { count: 1, activation: "sigmoid" },
  6.   ],
  7. });

上面的 layers 描述了两层神经网络,输入层有 1 个节点,中间层有 4 个节点,输出层有 1 个节点(只有第一个层需要定义 inputCount,因为输入长度是未知的)。

一个最小化神经网络,至少要定义 training data 与神经网络每一层的结构,其中每一层结构的 count 代表神经元的数量,activation 代表启动函数类型。当然这样设计有一个小瑕疵,即每个 layer 的神经元启动函数都相同。

接着,就可以调用 fit() 进行训练:

neuralNetwork.fit(); // { loss: .. }

调用几次就训练几次,每次都返回当前的 loss 值,以判断训练是否有效收敛。

神经网络对象实体的设计

接下我们讨论 NeuralNetwork 类的实现。首先这个函数显然包含以下几个要素:

  1. 定义神经网络对象实体,并存储各节点参数,以便训练时可以不断调整该网络的参数。

  2. 定义 model function。

  3. 定义 loss function。

  4. 定义 optimization。

其中 optimization 环节是最难的,因为我们要根据输出的结果,对每一个参数求导,这个过程被称为 “反向传播”,我们后续单开一篇文章来说明。

这次我们先采用一种模拟的方式绕过反向传播的具体实现,让代码能跑起来,方便理解全流程。

回到神经网络对象实体的设计,我们再来看一次神经网络的结构图:

0b33ebfaf77f56b6c6f6c4573ceaad14.png

可以看到从第二层网络开始,每一层网络的值都是上一层所有节点分别乘以不同的 w 系数,再加上 b 系数,最后乘以 c 系数,因此我们可以把参数存在第二层节点上,分别是:

  • w: number[],表示上一层每个节点连接到该节点乘以的系数 w。

  • b: number,表示该节点的常数系数 b。

  • c: number,表示该节点的常数系数 c。

我们可以定义神经网络数据结构如下:

  1. type NetworkStructor = Array<{
  2.   // 启动函数类型
  3.   activation: "sigmoid" | "relu";
  4.   // 节点
  5.   neurals: Array<{
  6.     /** 当前该节点的值 */
  7.     value: number | undefined;
  8.     /** 上一层每个节点连接到该节点乘以的系数 w */
  9.     w: Array<number>;
  10.     /** 该节点的常数系数 b */
  11.     b: number;
  12.     /** 该节点的常数系数 c */
  13.     c: number;
  14.   }>;
  15. }>;

则我们根据用户传入的 layers 来初始化神经网络对象,并对每个参数赋予一个初始值:

  1. class NeuralNetwork {
  2.   // 输入长度
  3.   private inputCount = 0;
  4.   // 网络结构
  5.   private networkStructor: NetworkStructor;
  6.   // 训练数据
  7.   private trainingData: TraningData;
  8.   constructor({
  9.     trainingData,
  10.     layers,
  11.   }: {
  12.     trainingData: TraningData;
  13.     layers: Layer[];
  14.   }) {
  15.     this.trainingData = trainingData;
  16.     this.inputCount = layers[0].inputCount!;
  17.     this.networkStructor = layers.map(({ activation, count }, index) => ({
  18.       activation,
  19.       neurals: Array.from({ length: count }).map(() => ({
  20.         value: undefined,
  21.         w: Array.from({
  22.           length: index === 0 ? this.inputCount : layers[index - 1].count,
  23.         }).map(() => getRandomNumber()),
  24.         b: getRandomNumber(),
  25.         c: getRandomNumber(),
  26.       })),
  27.     }));
  28.   }
  29. }

如上代码所示,将参数中输入长度、神经网络结构、训练数据都分别记录了下来,其中给每个神经节点 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 就是根据输入得到预测的输出。

  1. class NeuralNetwork {
  2.   /** 获取上一层神经网络各节点的值 */
  3.   private getPreviousLayerValues(layerIndex: number, trainingItem: TraningItem) {
  4.     if (layerIndex >= 0) {
  5.       return this.networkStructor[layerIndex].neurals.map((neural) => neural.value);
  6.     }
  7.     return trainingItem[0];
  8.   }
  9.   
  10.   private modelFunction(trainingItem: TraningItem) {
  11.     this.networkStructor.forEach((layer, layerIndex) => {
  12.       layer.neurals.forEach((neural) => {
  13.         // 前置节点的值 * w 的总和
  14.         let weightCount = 0;
  15.         this.getPreviousLayerValues(layerIndex - 1, trainingItem).forEach(
  16.           (value, index) => {
  17.             weightCount += value * neural.w[index];
  18.           },
  19.         );
  20.         const activateResult = activate(layer.activation)(weightCount + neural.b);
  21.         neural.value = neural.c * activateResult;
  22.       });
  23.     });
  24.     // 输出最后一层网络的值
  25.     return this.networkStructor[this.networkStructor.length - 1].neurals.map(
  26.       (neural) => neural.value
  27.     );
  28.   }
  29. }

modelFunction 函数的流程很简单,首先遍历类神经网络的每一层,计算其中每个神经元的值。

而计算这个值,取决于该网络上一层的每一个值,乘以权重 w,加上系数 b,并通过启动函数(activate 实现我们待会说)后再乘以系数 c。

第一层类神经网络的上一层来自于输入,从第二层开始,输入就来自于上一层神经网络,所以为了方便统一处理,定义了 visitPreviousLayerInputs 函数拿到上一层输入,兼容了第一层要从输入(Training data)拿的情况。

接下来是 activate 函数,它可以根据启动函数名生成对应的启动函数实现,代码如下:

  1. const functionByType = (functionType: ActivationType) => (x: number) => {
  2.   switch (functionType) {
  3.     case "sigmoid":
  4.       return sigmoid(x);
  5.     // ...
  6.   }
  7. };
  8. const sigmoid = (z: number) => {
  9.   return 1 / (1 + Math.pow(Math.E, -z));
  10. };

loss function 的设计

loss function 的输入也是 Training item,输出也是一个数字,这个数字就是 loss,越小越好。

计算 loss 有很多种选择,我们选择一种最简单的均方差:

  1. class NeuralNetwork {
  2.   private lossFunction(trainingItem: TraningItem) {
  3.     // 预测值
  4.     const y = this.modelFunction(trainingItem);
  5.     // 实际值
  6.     const t = trainingItem[1];
  7.     // loss 最终值
  8.     let loss = 0;
  9.     for (let i = 0; i < y.length; i++) {
  10.       // l(t,y) = (t-y)²
  11.       loss += Math.pow(t[i] - y[i]!, 2);
  12.     }
  13.     return loss / y.length;
  14.   }
  15. }

首先执行 modelFunction 拿到预测值,再把与实际值的平方差加总,除以 Training item 的长度,就得到了均方差。

optimization 的设计(模拟实现)

本来 optimazation 应该使用反向传播来实现,但因为实现比较复杂,我们在下一篇文章再说明。

但为了让故事完整,我们也可以采用迂回手段模拟 optimization 实现的:模拟求导。

把要求导的参数增加一个极小的值,其他参数不变,此时得到函数的输出减去上一次的函数值,就可以作为近似的偏导值,它反而最符合求导的本质:

c47abac9b6f5826cca03283d2ec9f496.png

optimization 的入参是 traningData,返回值是此时的 loss。入参和出参与 lossFunction 一样,但这个函数是真的有在做优化,而 lossFunction 仅仅计算 loss。

模拟求导的实现有两个问题:

  1. 性能差,需要反复执行函数。

  2. 结果不精确,训练效果不好。

我们下一篇就来介绍反向传播。

总结

我们从零到一构建一个神经网络,包括以下几个部分:

  1. 神经网络 API 的设计。

  2. 神经网络类的框架实现。

  3. model function、loss function 的实现。

我们把 Training data 的每一项命名为 Training item,其中 model function、loss function 的入参是 Training item, optimization 的入参是 Training data,它们的含义分别如下:

  1. modelFunction(traningItem): 输入一项训练数据,输出预测结果。

  2. lossFunction(traningItem): 输入一项训练数据,输出当前神经网络的 loss。

  3. optimization(traningData): 输入一组训练数据,让神经网络对该组训练数据进行一次 fit。

可以看到,神经网络训练好后,只需要调用 modelFunction 即可,不再需要求导,也不需要大量训练,调用成本相比训练时下降了好几个数量级。

因为篇幅问题,我们没有详细介绍 optimization 阶段,下一篇文章我们进入反向传播知识点。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/303359
推荐阅读
相关标签
  

闽ICP备14008679号