如何使用JavaScript构建一个神经网络

642 阅读15分钟
  1. 引言

近年来,AI 模型的数学理论基础已经非常成熟,伴随而来的开发框架也非常完备;包括主流的 AI 开发语言 Python,AI 框架 TensorFlow (Google)、Pytorch (Facebook)、MXNet (Amazon) 等。

而随着前端应用的日趋丰富,JavaScript 语言也在不断地更迭,基于 GPU 的 JS API 给了前端开发者更大的创作空间,也为 JS 开发 AI 模型提供了可能;在前端直接运行 AI 模型也能提高响应速度并保护隐私。基于 JS 的 TensorFlow.JS 框架就重构了 Tensorflow,使得 JS 开发者能够在 Node 环境或浏览器环境中开发 AI 模型。

本文首先讨论了使用JS开发神经网络的主要场景和必要性,然后抛出一个药物剂量-药效的拟合问题,通过构建一种极简的神经网络,在网络运算的过程中逐步介绍神经网络中的各种概念,给出数学推理的同时介绍一些简单的神经网络训练方法;然后,通过 TensorFlow.JS 框架实现这个神经网络,并在训练过程中动态地绘制图表来展示训练过程和结果;最后,简单叙述了几种在我司业务中可能应用的场景,起到抛砖引玉的作用。

  1. 为什么要用JS构建神经网络?

在我们的印象中,AI模型都是通过服务端开发&部署、前端调用服务的形式去实现的;算法的实现过程和服务的处理过程对前端来说是一个黑盒。但其实,目前的AI开发框架的发展已经远超我们的想象了:不仅支持node.js环境、web浏览器环境、甚至支持微信小程序环境;我们可以轻松地在各种JS环境中开发、部署神经网络。

相比于基于服务端,基于JS的神经网络可以很好地提高计算的实时性,例如跟随摄像头的视频流处理、实时语音处理等。这种场景下,视频、音频本身较大,而且如果切片发送给后端计算再返回结果,用户能感受到交互阻塞、明显延迟、甚至直接不可用;如果我们能直接基于前端计算,实时返回结果,则可以在用户体验上有极大的提升。

另外,如果前端同学掌握了基于JS的神经网络开发方法,也可以避免任何小模型都需要和后端沟通、提需、联调的长战线;我们可以发挥想象,在业务中的寻找任何可能的与AI结合的优化点,自己构建小、中、大型神经网络、或直接下载开源网络直接部署在前端工程中,自己就可以快速去做功能迭代。(直白地说,再也不用请求后端给我们提供服务了,拜拜吧您嘞)(我们甚至可以让后端请求我们给他们计算后的数据)

虽然TensorFlow.js框架已经非常完备了,模型的开发过程也是流水线式的,在编程上比较简单;但是还是无法避免基本的概念和数学原理。接下来,我们通过解决一个实际的小问题来逐渐展开地介绍各种概念。

思考:前端部署神经网络,在业务赋能上的优势是什么?在团队合作中的优势是什么?

  1. 药能不能停?

首先,我们思考一个【药能不能停】的问题。

一位医学家研发了一种新型药物,他为了测试这种药物的剂量和药效之间的关系,邀请了三组志愿者。为了简化计算量,我们让“剂量”、“药效”均在0~1之间取值。其中,A组志愿者服用的药物剂量为0、B组为0.5、C组为1;对应的服药后药效是A组为0、B组是1、C组是0。也就是说,不服药和服药剂量接近1时,药效几乎没有效果,而在服药剂量接近0.5时效果最佳。

在得到了真实的“剂量-药效”统计数据后,医学家希望能够用一种运算模型来描述某种剂量所对应的药效情况。然而医学家发现,虽然直线是最简单的数学模型,但是直线无法很好地模拟剂量和药效的关系:直线只能准确描述三类关系中的两类,因此他可能需要一种曲线模型。

医学家的朋友,一位神经网络学家看到了这个问题之后表示:可以用两条曲线的组合来表示这种关系。一条曲线上升、一条曲线下降,把两条曲线相加即可,而我们刚好可以用一个包含两个神经元的神经网络来描述它。

  1. 药怎么停?

我们先观察这个神经网络是如何工作的。在观察的过程中,我们会对其中的概念进行展开。

首先,神经网络学家给出一种运算的方法:输入一个剂量值,两条曲线分别做一次乘法,然后分别做一次加法,然后经过softplus函数f(x)=log(1+ex)f(x) = \log(1 + e^x),再经过一次乘法,最后求和,即得到了药效的值。

softplus函数并不复杂,不必纠结于它的数学表达式,它只是对输入数据的一种放大或缩小。它的仿生学依据是模拟大脑神经元的激活状态,即达到一定阈值,信号才会被放大传递。也正是因此,这种网络才被称为“神经”网络。

医学家不信,他要亲自验证这种运算是否准确。

蓝色曲线
输入✖️ -34.4➕ 2.14softplus✖️ -1.30
002.142.25-2.93
0.5-17.2-15.0600
134.4-32.2600
橙色曲线
输入✖️ -2.52➕ 1.29softplus✖️ 2.28
001.291.533.50
0.5-1.260.030.711.61
1-2.52-1.230.260.58
蓝橙结果求和➖ 0.58
0.57-0.01
1.611.03
0.580

可以看到,这一套运算规则很好地匹配了真实的数据关系,也就是说,“这个神经网络拟合得很好”。

术语

输入输入数据的集合。在本例中,即为 [0, 0.5, 1]
标签数据对应的目标值。在本例中,即为 [ 0, 1, 0 ]
权重每个节点跟随的乘法运算值的集合
偏置每个权重后跟随的加法运算值的集合
激活函数神经元节点的处理函数。本例中为softplus
输入层
隐藏层
输出层

思考:神经网络为什么叫「神经」网络?

  1. 药可以停了!

警告:前方数学出没

医学家惊呆了,这套看起来乱七八糟的运算规则竟然很好地匹配了真实数据。医学家向神经网络学家请教神经网络中的那些“权重”和“偏置”是如何计算出来的?咋就这么准?

  1. 损失函数-评价模型表现

神经网络学家表示,不管是什么样的模型,我们都希望它的预测值和真实值更接近。在医学家的问题中,我们可以直接用预测值和真实值的差作为依据,两者差值越小则说明模型效果越好。

然而,直接求差可能有正数和负数,又不太方便计算,我们干脆利用平方的非负性,定义模型好坏为:

L=(yy^)2L=\sum{(y-\hat{y})^2}

其中yy代表真实值,y^\hat{y}代表预测值。如果L的值越小,说明预测值和真实值的差距越小,我们称这个评价模型好坏的函数为损失函数。由此一来,我们只要不断调整“权重”和“偏置”,使得损失函数达到最小值,我们就可以认为模型表现良好了。(这个损失函数就是大名鼎鼎的方差损失函数)

模型训练的终极目的:找到能使损失函数值最小的参数值。也就是说,在模型训练过程中,自变量是模型参数,因变量是损失函数值。

  1. 梯度下降-求解模型最优参数的方法

      上文定义的损失函数看起来是一个二次函数,而二次函数的关系曲线是一条抛物线,因此我们求损失函数的最小值,就是在函数图像上找到抛物线的最低点。然而,我们如果遍历大量的xx来比较抛物线的最小的yy,会非常盲目。幸运的是,抛物线是一个可导凸函数,我们可以利用凸函数导数=0的点快速寻找最小值。(说明导数的概念)

    把自己想象成函数中的一个点,你所能行走的轨迹就是函数的图像;你身边被浓浓迷雾包围,只能看到自己身前身后的一点点路径。要怎么走才能走到最低点呢?

    (即计算机内部无法知道全局的函数轨迹,计算机只知道一个计算规则;我们只能对这个计算规则做文章)

    (针对模型训练,其实就是通过调整参数值,求得最小损失函数值的过程)

    实际上,医学家问题中的损失函数图像是一个8维的曲面,自变量是4个权重和3个偏置

      梯度下降法为我们提供了一种快速接近导数=0点的求解方法。我们以一个二维函数举例:由图4.3可见,在自变量取值越接近导数=0处,抛物线的斜率越平缓,即抛物线导数越接近我们的所求0。因此,我们考虑在斜率较大时,采用较大步长改变自变量;而在导数较小时,采用较小步长改变自变量。(描述随机取点-斜率*学学习率的过程;导函数描述了函数每一处的变化率)

      经过多次梯度下降计算后,由于导数越来越接近0,步长越来越小,自变量会开始在距离最优解很近的地方徘徊,我们就可以根据两个终止条件:1. 到达了下降的最大次数;2. 步长几乎无法变化了,停止计算并认为模型达到了最优。

    术语

    损失函数评价模型预测误差的函数;损失函数值越小,模型效果越好
    导数、斜率函数的瞬时变化率,评价函数在当前位置走向的陡峭程度(变化趋势)
    凸函数有f(x),xRx\in\mathbb{R}使得f'(x)=0的x值只有一个
    非凸函数有f(x),xRx\in\mathbb{R}使得f'(x)=0的x值不止有一个
  1. 链式法则-梯度下降的数学理论依据

如果忘记了请联系高中数学老师,他肯定讲过

医学家还有一些疑问:我们已经知道了损失函数定义,和梯度下降计算损失函数最小值的方法;但是梯度下降方法比较抽象,具体的运算过程是怎样的呢?神经网络学家表示:先别急,在具体观察梯度下降之前,还需要了解一个规则-链式法则。

神经网络学家用了一个最最最简单的例子:

有:

y=2xy=2x

z=y2z=y^2

那么,dzdx\frac{dz}{dx}是多少?其实就等于:

dzdx=dzdy×dydx=2y×2=8x\frac{dz}{dx}=\frac{dz}{dy}\times{\frac{dy}{dx}}=2y\times{2}=8x

与直接计算 dzdx=d(4x2)dx=8x\frac{dz}{dx}=\frac{d(4x^2)}{dx}=8x,结果一致。

思考:我们训练模型时,其实是在做什么? (描述:目标是损失函数最小,过程是找到损失函数和每个参数的导函数关系,方法是梯度下降)

  1. 反向传播-真实的求解过程

我们把网络中的各个权重和偏置分别记为w1、w2、w3、w4、b1、b2、b3。假设我们来到了网络训练过程中的一个步骤,这个步骤正在求解b3的最优值;而且前置的权重和偏置都已经求得了最优解。让我们详细地观察在本次训练过程中,b3的最优解是如何得到的。

首先,根据经验,偏置项的初始值一般设置为0,此时绿色曲线和实际值还有较大偏差。我们试图调整b3的值并计算损失函数L=(yy^)2L=\sum{(y-\hat{y})^2},发现如下规律:

b3真实值预测值方差损失函数值
点100-2.6(02.6)2(0--2.6)^220.4
点201-1.61(11.61)2(1--1.61)^2
点300-2.61(02.61)2(0--2.61)^2
点110-1.6(01.6)2(0--1.6)^27.8
点211-1.61(10.61)2(1--0.61)^2
点310-1.61(01.61)2(0--1.61)^2
21.11
30.43

医学家恍然大悟:这里可以用梯度下降法来得到b3的某个值使得损失函数最小!那么损失函数Loss=(yy^)2Loss=\sum{(y-\hat{y})^2}关于b3的导数dLossdb3\frac{dLoss}{db_3}是什么呢?

我们把损失函数完全展开:(yy =真实值,y^\hat{y}=预测值)

网络损失(Loss) =(真实值1 - 预测值1)2^2 + (真实值2 - 预测值2)2^2 + (真实值3 - 预测值3)2^2

其中:

预测值(Predicted) = 绿色曲线 = 蓝色曲线 + 橙色曲线 + b3

根据链式法则,有:

dLossdb3=dLossdPredicted×dPredicteddb3\frac{dLoss}{db_3}=\frac{dLoss}{dPredicted}\times\frac{dPredicted}{db_3}

其中:

dLossdPredicted=2×\frac{dLoss}{dPredicted}=-2\times\sum(真实值-预测值)

dPredicteddb3=\frac{dPredicted}{db_3}=d(蓝色+橙色+b3) / d(b3) = 0+0+1 = 1

有:(我们得到了损失函数关于b3的导函数!)(导函数描述了函数每一处的导数值)

dLossdb3=2×\frac{dLoss}{db_3}=-2\times\sum(真实值-预测值)* 1

b3真实值,预测值-2*(真实值-预测值)dLossdb3\frac{dLoss}{db_3}新的b3值
点100,-2.6-5.2-15.70 - (15.7 * 0.1) = 1.57
点21,-1.6-5.2
点30,-2.61-5.222
1.57-6.261.57 - (-6.26 * 0.1) = 2.19
2.19... ...

最终,我们解得b3=2.61时,损失函数达到最小值(非常接近0),模型的拟合程度最好;医学家和神经网络学家弹冠相庆:我们竟然用手动计算的方式训练了一个AI模型!

其他的权重、偏置参数,也都可以通过链式法则和反向传播继续求解,虽然求导计算相对复杂些,不过原理是一模一样的。

  1. 神经网络训练过程概述

  1. (拓展阅读)神经网络中其他的概念

激活函数梯度消失、爆炸
神经网络结构设计局部最优困境
超参数过拟合、欠拟合
  1. 药效预测模型的JS代码实现

仓库地址

(描述计算图、全连接层,展示模型如何存储)

import * as tf from "@tensorflow/tfjs";

// 定义神经网络
function createSimpleNN() {
  const model = tf.sequential();
  // 第一层,输入维度为 1,输出维度为 2,激活函数为 softplus
  model.add(
    tf.layers.dense({ units: 2, inputShape: [1], activation: "softplus" })
  );
  // 第二层,输出维度为 1
  model.add(tf.layers.dense({ units: 1 }));
  return model;
}

export const train = (epochCb, onFinish) => {
  // 准备数据(示例数据)
  const xs = tf.tensor2d([[0], [0.5], [1]], [3, 1]);
  const ys = tf.tensor2d([[0], [1], [0]], [3, 1]);
  // 准备训练数据
  const testXs = tf.tensor2d(
    [...],
    [21, 1]
  );

  // 实例化模型
  const model = createSimpleNN();
  // 编译模型,定义损失函数和优化器
  model.compile({ loss: "meanSquaredError", optimizer: tf.train.sgd(0.1) });
  // 训练模型
  model
    .fit(xs, ys, {
      // 训练轮次 10000
      epochs: 10000,
      callbacks: {
        onEpochEnd: (epoch, logs) => {
          // 使用训练好的模型进行预测
          const predictions = model.predict(testXs);
          epochCb(predictions, epoch, logs);
        },
      },
    })
    .then((history) => {
      console.log("Training completed!");
      onFinish();
    })
};

思考:在编程实现中,训练可以哪些条件下结束?

模型保存

  1. 应用

  1. 谷歌官方示例

其实,前端部署的网络模型已经广泛应用在各个领域,并真正有效地提高了前端应用的丰富度。

  1. 基于JS的AI模型在业务中可能的应用场景

  1. 实时语音识别

    1.   比如我们前一段时间在做的智能问诊,医患之间的对话需要经过【开始录音】-【对话】-【结束录音】-【上传录音】-【算法返回文字】的流程;而如果我们直接基于前端部署本地的语音识别模型,则流程可以优化为【对话】-【文字】,👍。(这个非常可以做,相关同学研究下;语音识别的开源模型很成熟了,可以直接拿过来部署到前端的)
  2. 视频电话识别病因

    1.   由于实时性是前端部署AI模型的一大优势,那么同样地,在视频问诊的场景下:比如患者患有皮肤病,但是无法确定是感染导致还是过敏导致,通过视频电话寻求诊断;如果在传统的服务端演算架构下,前端把视频传递给后端,结果的实时性较差。如果基于前端部署的实时图像识别,患者可以不断调整拍摄位置,确保快速得到诊断结果。(假设机器人诊断率较好,描述患者使用场景)
  3. 前端自研小型模型,快速迭代,不依赖后端、算法团队;「打破技术垄断,解放前端生产力,实现前端民主共和(误)」

    1.   以宙斯平台举例:
    2.   宙斯平台包括SQL语句输入场景,但是目前没有智能补齐功能,产品和后端暂时又不会排期;我们非常可以自研或下载一个seq2seq模型,直接在前端部署智能补齐服务。
    3.   同样地,宙斯平台的数据开发功能中,需要先输入数据操作流的【标题】,然后输入好多个【标签】,比较费时费力;我们可以直接搞一个小型编解码器上去,直接在前端根据标题补齐标签。
  1. What's next?

由于时间仓促,本次的代码只构建并训练一个超级简单的神经网络;接下来准备实现两个纯前端部署的AI应用,更好地展示前端的模型能力。

  1. 即时患处图片识别(病因分类)
  2. 即时语音转文字
  1. 扩展

  1. 其他典型的神经网络结构

  1. 全连接

  1. CNN

  1. RNN

  1. LSTM

  1. 编解码器(ChatGPT的实现原理)

  1. 2024年的诺贝尔物理学奖和化学奖

  1. 物理学奖(“利用物理学工具,构建了有助于为当今强大的机器学习奠定基础的方法”

  1. 化学奖( “AI不再只是一个辅助工具”