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

经过万能近似定理的铺垫,我们知道理论上如何实现万能近似函数了,接下来我们常识用 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 类的实现。首先这个函数显然包含以下几个要素:

  1. 定义神经网络对象实体,并存储各节点参数,以便训练时可以不断调整该网络的参数。
  2. 定义 model function。
  3. 定义 loss function。
  4. 定义 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。

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

  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 阶段,下一篇文章我们进入反向传播知识点。

原文链接:https://juejin.cn/post/7347229062164840457 作者:黄子毅

(0)
上一篇 2024年3月18日 上午10:18
下一篇 2024年3月18日 上午10:29

相关推荐

发表回复

登录后才能评论