资讯动态

用JavaScript手写神经网络:从异或问题到浏览器端深度学习实战

发布时间:2026/10/14 4:51:26 来源:尧图企业网站定制
别急着划走——如果你平时写前端偶尔会想“这个页面要是能自己学会点什么就好了”那么这篇东西就是给你准备的。标题里的“JavaScript 深度学习”和“神经网络”不是噱头而是确实能用你手里的JS技能做出来的东西至于“黑客”我这里说的不是找漏洞、黑服务器而是Hacker精神里最核心的那股劲亲手拆开黑盒子弄明白它到底是怎么工作的然后自己动手造一个。这篇文章适合已经写过一些JavaScript、对机器学习有兴趣但始终觉得Python门槛太高、迟迟没动手的同学。不需要你懂微积分不需要你先读三本AI教材只需要你跟着我一步步把代码敲进去看到数字真的在变看到网络真的“学会”了东西。我大概一年前遇到一个需求用户上传的图片要在浏览器本地完成自动分类不能把数据发到服务器。当时第一反应是老老实实去学Python搞一套训练环境。但转念一想所有数据处理本来就在前端为什么训练和推理就不能也在前端做于是我开始认真研究JavaScript生态里的深度学习方案结果发现这条路的成熟度远超我的预期。这篇是系列的第一篇我先把最核心的东西讲透手写一个神经网络让它在浏览器里学会解决异或问题然后告诉你什么时候该手写、什么时候该用库。1. 在动手之前JavaScript做深度学习到底靠不靠谱先破除一个最常见的误解深度学习不是Python的专利。你真正需要的数学底层是矩阵运算、导数和优化这些和语言没有必然关系。JavaScript的生态这些年发展得非常快TensorFlow.js、Brain.js这些库让浏览器里跑神经网络成了日常操作。很多人的顾虑其实是“别人都用Python我用JS是不是走歪了”——我的回答是你要是已经会JS那么从JS入门深度学习的成本远比从零学Python低因为你只需要理解机器学习本身不需要同时学一门新语言、新工具链、新环境配置。在我的实际体验里JavaScript深度学习有几个非常实在的落地场景。第一是浏览器端的实时推理比如摄像头画面里检测手势、识别物体数据不出本地省掉了服务器开销和隐私争议。第二是Node.js服务端推理如果你本来就是个JS全栈团队引入一个模型推理模块不需要额外部署Python服务架构上会简单不少。第三是最容易被忽略的教学和原型验证。JavaScript写出来的网络结构非常直观没有框架包装得那么深特别适合把前向传播、反向传播这些概念一层层剥开看清楚。做深度学习需要多少数学底子这个问题我认真回答你如果你今天只想跑通一个简单网络会四则运算就够了。神经网络里所有的计算无非就是“加权求和”和“函数映射”反向传播求梯度也就用到链式法则你只要能理解“一个数怎么跟着另一个数连锁变化”后面的公式都是纸老虎。唯一的硬性要求是能静下心调试JavaScript代码。因为训练网络时你会经常遇到loss不下降、输出全是NaN这类问题这时候就看你对代码细节的敏感度了。2. 神经元的本质从单个节点开始拆解神经网络2.1 神经元在数学上到底是什么先忘掉生物课本里的神经元图画。机器学习里的“神经元”是一个非常数学化的东西它接收一组输入每个输入乘上对应的权重全部加起来再加上一个偏置最后扔进一个非线性函数里得到一个输出。整个过程用公式写出来就是这样output activation(Σ(w_i * x_i) b)这行公式里的四个要素是理解全部后续内容的关键输入x就是你要喂给网络的数据比如一张图片的像素值或者一个用户的年龄、消费金额。权重w它决定了“每个输入到底有多重要”权重越大这个输入对最终结果的影响就越大。偏置b你可以把它理解为“神经元的启动阈值”偏置越高这个神经元越不容易被激活。激活函数activation它给整个系统引入非线性让神经网络能拟合曲线、曲面和各种复杂边界。如果你觉得“权重”的概念太抽象我通常用评分来类比。想象你在为一家餐厅打分输入x是菜品的口味、环境、服务态度权重w是你对这三项的重视程度比如口味权重0.6、环境0.2、服务0.2最后算出来的总分就是加权求和。偏置就是你心里的那根及格线分数超过这根线你就给出“推荐”这个结论。2.2 激活函数没有它神经网络就是一张纸上一节里我特意强调了非线性激活函数这一点极其重要。如果你只做加权求和、不加激活函数那么多层网络无论堆多深最终都能化简成一个线性方程表达能力等于一个单层网络这叫“线性不可分”异或问题就是最典型的例子。最常见的激活函数有三个激活函数公式输出范围特点Sigmoid1 / (1 e^-x)(0, 1)平滑适合输出概率但有梯度消失问题tanh(e^x - e^-x) / (e^x e^-x)(-1, 1)零中心训练比Sigmoid稳一点ReLUmax(0, x)[0, ∞)计算极快深度网络首选但可能“死掉”这篇入门文章我统一用Sigmoid原因只有一个它的导数形式极其简单。Sigmoid的导数是σ(x) * (1 - σ(x))这个特性让反向传播的代码实现简单很多特别适合理解原理。2.3 用JS写出我们的第一个神经元光说不练等于白看。把上面那行公式翻译成JavaScript就是下面这个函数// Sigmoid 激活函数 function sigmoid(x) { return 1 / (1 Math.exp(-x)); } // 单神经元接收一个输入向量和对应的权重、偏置 function neuron(inputs, weights, bias) { let sum bias; for (let i 0; i inputs.length; i) { sum inputs[i] * weights[i]; } return sigmoid(sum); } // 示例一个“餐厅评分”神经元权重表示对口味、环境、服务的重视程度 const input [8, 7, 9]; // 口味8分、环境7分、服务9分 const weights [0.6, 0.2, 0.2]; // 分别的重视程度 const bias -5; // 及格线偏移 const result neuron(input, weights, bias); console.log(result); // 输出一个介于0和1之间的分数这段代码就是神经网络的最基本单元。你把它跑起来得到的只是一个靠固定的权重来做判断的“死”神经元它不会学习。真正的学习发生在你不断调整这些权重和偏置让输出越来越接近你期望值的过程中。下一节我们就来写这个“学习”的部分。3. 让网络真正“学”起来前向传播与反向传播3.1 为什么必须用多层网络异或问题的教训如果你想把多个神经元连成一个网络第一道入门考题就是异或XOR。异或的逻辑表非常简单输入相同时输出0输入不同时输出1。但就是这么一个简单的逻辑在数学上有一个著名结论线性模型无法解决异或问题。你可以试着画一下坐标系。输入只有两个数就是二维平面上的点(0,0)和(1,1)是一类要输出0(0,1)和(1,0)是另一类要输出1。这四个点没办法用一条直线把两类分开你必须用一条曲线或者折线。线性模型做不出这种曲线所以必须引入隐藏层让中间神经元先对输入做一次非线性组合再进行第二次判断。异或问题因此成了验证“神经网络是不是真的在工作”的经典基准。3.2 前向传播数据怎么在网络里流动下面这张结构图你可以直接刻在脑子里输入层有2个节点隐藏层有2个节点输出层有1个节点。数据从输入层进经过隐藏层处理最后从输出层出来。整个流程叫“前向传播”Forward Propagation。用代码描述前向传播其实就是在第2节单神经元的基础上套一层循环// 三层神经网络的前向传播 function feedForward(inputs, weightsInputHidden, weightsHiddenOutput, biasHidden, biasOutput) { // 计算隐藏层输出 const hiddenOutputs []; for (let j 0; j weightsInputHidden[0].length; j) { let sum biasHidden[j]; for (let i 0; i inputs.length; i) { sum inputs[i] * weightsInputHidden[i][j]; } hiddenOutputs[j] sigmoid(sum); } // 计算输出层结果 const finalOutputs []; for (let k 0; k weightsHiddenOutput[0].length; k) { let sum biasOutput[k]; for (let j 0; j hiddenOutputs.length; j) { sum hiddenOutputs[j] * weightsHiddenOutput[j][k]; } finalOutputs[k] sigmoid(sum); } return { hiddenOutputs, finalOutputs }; }先别急着考虑代码结构是否优雅你现在需要理解的是数据路径每一个神经元的输出都成了下一层神经元的输入。从输入到输出的整个过程是一次前向传播它读起来就像流水线作业——原材料的价值被层层加码、判断、筛选最后形成结论。3.3 损失函数与反向传播错误是怎么回流去修正权重的网络输出了一个数字怎么判断这个数字对不对你需要一把“尺子”也就是损失函数。最简单的尺子是均方误差MSE把真实值和预测值的差平方再取平均。误差越大说明网络预测得越离谱。function mseLoss(predictions, targets) { let sum 0; for (let i 0; i predictions.length; i) { const diff targets[i] - predictions[i]; sum diff * diff; } return sum / predictions.length; }知道错了接下来怎么改这就是反向传播Backpropagation要做的事。我把这里的关键逻辑压缩成一句话加权求和是线性的Sigmoid导数能告诉我们“输入的小变化会带来输出的多大变化”所以从输出层开始把误差层层往回传每经过一层就按链式法则算出梯度最后按梯度去修正权重。我自己第一次写反向传播代码时公式看得头晕。后来我用“下山”来做类比想象你站在山腰看不见山脚只能靠脚底的坡度感知方向。你要下山就得每走一步感受哪个方向是向下的然后迈出一步。梯度就是那个坡度学习率就是你迈步的大小。梯度数值大说明坡陡、可以大跨步梯度接近0说明快到底了步子要变小。学习率设得太大容易一脚踩空、来回震荡设得太小则走一万步还在原地。把上面这个想法翻译成JavaScript代码就是下面这个完整的神经网络类。我建议你现在打开一个空白JS文件把这整段代码敲进去再敲入训练数据和训练循环亲眼看它跑起来。class NeuralNetwork { constructor(inputNodes, hiddenNodes, outputNodes, learningRate 0.5) { this.inputNodes inputNodes; this.hiddenNodes hiddenNodes; this.outputNodes outputNodes; this.learningRate learningRate; // 初始化权重随机生成 -1 到 1 之间的数 this.weightsInputHidden []; for (let i 0; i inputNodes; i) { this.weightsInputHidden[i] []; for (let j 0; j hiddenNodes; j) { this.weightsInputHidden[i][j] Math.random() * 2 - 1; } } this.weightsHiddenOutput []; for (let j 0; j hiddenNodes; j) { this.weightsHiddenOutput[j] []; for (let k 0; k outputNodes; k) { this.weightsHiddenOutput[j][k] Math.random() * 2 - 1; } } this.biasHidden []; for (let j 0; j hiddenNodes; j) { this.biasHidden[j] Math.random() * 2 - 1; } this.biasOutput []; for (let k 0; k outputNodes; k) { this.biasOutput[k] Math.random() * 2 - 1; } } // 前向传播 feedForward(inputs) { const hiddenOutputs []; for (let j 0; j this.hiddenNodes; j) { let sum this.biasHidden[j]; for (let i 0; i this.inputNodes; i) { sum inputs[i] * this.weightsInputHidden[i][j]; } hiddenOutputs[j] sigmoid(sum); } const finalOutputs []; for (let k 0; k this.outputNodes; k) { let sum this.biasOutput[k]; for (let j 0; j this.hiddenNodes; j) { sum hiddenOutputs[j] * this.weightsHiddenOutput[j][k]; } finalOutputs[k] sigmoid(sum); } return { hiddenOutputs, finalOutputs }; } // 反向传播 权重更新 train(inputs, targets) { const { hiddenOutputs, finalOutputs } this.feedForward(inputs); // 计算输出层误差 const outputErrors []; for (let k 0; k this.outputNodes; k) { const error targets[k] - finalOutputs[k]; outputErrors[k] error * (finalOutputs[k] * (1 - finalOutputs[k])); } // 计算隐藏层误差 const hiddenErrors []; for (let j 0; j this.hiddenNodes; j) { let error 0; for (let k 0; k this.outputNodes; k) { error outputErrors[k] * this.weightsHiddenOutput[j][k]; } hiddenErrors[j] error * (hiddenOutputs[j] * (1 - hiddenOutputs[j])); } // 更新 输入层 - 隐藏层 权重 for (let i 0; i this.inputNodes; i) { for (let j 0; j this.hiddenNodes; j) { this.weightsInputHidden[i][j] this.learningRate * hiddenErrors[j] * inputs[i]; } } for (let j 0; j this.hiddenNodes; j) { this.biasHidden[j] this.learningRate * hiddenErrors[j]; } // 更新 隐藏层 - 输出层 权重 for (let j 0; j this.hiddenNodes; j) { for (let k 0; k this.outputNodes; k) { this.weightsHiddenOutput[j][k] this.learningRate * outputErrors[k] * hiddenOutputs[j]; } } for (let k 0; k this.outputNodes; k) { this.biasOutput[k] this.learningRate * outputErrors[k]; } } }这段代码我建议你复制到本地逐行读一遍。你在train方法里能看到三个关键段的思路完全一致先算误差再把误差乘以Sigmoid导数也就是梯度然后用梯度乘以对应层的输入得到权重修正量最后用学习率缩放后加到旧权重上。这就是“链式法则”在代码里的具体长相。3.4 训练一个异或网络跑起来看数字变化网络类写好了下面这组代码是用来训练异或真值表并验证结果的// 训练数据异或真值表 const trainingData [ { inputs: [0, 0], targets: [0] }, { inputs: [0, 1], targets: [1] }, { inputs: [1, 0], targets: [1] }, { inputs: [1, 1], targets: [0] } ]; const nn new NeuralNetwork(2, 2, 1, 0.5); // 训练 10000 轮 for (let epoch 0; epoch 10000; epoch) { for (const data of trainingData) { nn.train(data.inputs, data.targets); } } // 测试训练效果 for (const data of trainingData) { const { finalOutputs } nn.feedForward(data.inputs); console.log( 输入: [${data.inputs}] 期望: ${data.targets[0]} 实际: ${finalOutputs[0].toFixed(4)} ); }我在本地Node环境里跑这段代码一次典型的结果是这样的输入: [0,0] 期望: 0 实际: 0.0512 输入: [0,1] 期望: 1 实际: 0.9523 输入: [1,0] 期望: 1 实际: 0.9478 输入: [1,1] 期望: 0 实际: 0.0487输出不是干净利落的0或1而是接近0和接近1的小数这是Sigmoid激活函数的正常表现。你可以把阈值0.5当成分界线小于0.5记为0大于0.5记为1。训练成功的关键指标是看输出值是否在朝正确的方向靠近只要四个样例的输出都大幅偏离初始状态并逼近目标就说明网络真的学会了异或规则而不是记住答案。这里有个我踩过的坑要提醒你这份代码不是每次跑都能成功。因为权重是随机初始化的如果初始化时所有权重都落在让Sigmoid饱和的区域输出贴近0或1导数趋近于0网络会陷入“几乎不学习”的状态。如果你的训练结果里实际输出始终停在0.5附近可以重新初始化权重、调大学习率、增加隐藏层节点或者加长训练轮数都会显著改善。4. 库的选择什么时候该手写什么时候该上框架4.1 三大主流JavaScript深度学习库的定位对比手写网络让我们看懂了原理但工程实践中没人会再用裸代码去造轮子。我常年在项目里用的库有三个定位差异很大先上对比表库核心特点上手难度适合场景Brain.jsAPI极简几行代码构建DNN内置训练逻辑很低快速原型、表格型数据的分类回归TensorFlow.js功能全面支持CNN/RNN、迁移学习、WebGL加速中等图像识别、语音、生产级应用Synaptic网络拓扑自由偏教学演示低学习与研究、非标准连接网络实验如果你是刚开始接触JavaScript深度学习我特别推荐先用Brain.js搞定第一版业务需求。它的学习成本低到出乎意料训练一个异或网络只需要几十行代码。下面是Brain.js版的写法你可以对照着手写版感受一下框架带来的简化const brain require(brain.js); const net new brain.NeuralNetwork({ hiddenLayers: [2], learningRate: 0.5 }); net.train([ { input: [0, 0], output: [0] }, { input: [0, 1], output: [1] }, { input: [1, 0], output: [1] }, { input: [1, 1], output: [0] } ], { iterations: 10000, errorThresh: 0.005 }); for (const data of [ { input: [0, 0] }, { input: [0, 1] }, { input: [1, 0] }, { input: [1, 1] } ]) { console.log(net.run(data.input)); }看到区别了吗手写版里我们需要自己写前向传播、反向传播、权重更新的全部细节而在Brain.js里这些都被封装好了。但反过来说如果你没有先写过手写版你根本不会知道hiddenLayers: [2]这个参数是什么意思也不会知道训练结果出了问题该从哪个环节排查。我的建议非常明确先用原生JS写一遍再上框架。4.2 什么时候必须上TensorFlow.jsBrain.js解决不了的事情基本就是TensorFlow.js的战场了。我归纳出三个你该上TensorFlow.js的信号。第一你需要卷积神经网络CNN做图像识别。浏览器里实时识别图片里的物体这是TensorFlow.js的看家本领它利用WebGL做GPU加速速度比纯CPU计算快几个数量级。第二条你需要用到预训练模型做迁移学习。比如有一个已经在大量图片上训练好的MobileNet你只需要在浏览器里加载它再用你自己收集的一两百张图片做微调就能获得一个定制化的分类器。这一整套流程TensorFlow.js都有现成的API。第三你要把模型部署到Node.js服务端与后面的业务系统共用进程。我已经能听到有人问“那手写网络还有意义吗”有意义而且意义很大。当用库训练出来的模型效果不好时你如果只把库当黑盒就真的只能束手无策。你自己写过一遍反向传播就会知道loss不下降可能是因为学习率太大或权重初始化问题知道输出全变NaN可能是前面有溢出或除以零。库是你的交通工具但不是你的方向盘。4.3 浏览器端的三道性能坎与绕法跑在浏览器里的深度学习项目最终都会撞上性能问题。我列出我遇到最多的三道坎以及对应的解法。第一道坎是计算量太大把主线程卡死页面直接失去响应。解法是把训练丢给Web Worker让神经网络计算在后台线程执行UI线程保持流畅训练完再通过postMessage把结果传回来。第二道坎是模型文件太大加载慢一个几MB的模型文件会让首屏体验崩塌。解法是先用TensorFlow.js的转换工具把模型量化一遍把32位浮点数转成16位甚至8位体积能压缩一半以上精度损失在你的业务场景里往往可以接受。第三道坎是运行时延迟高手指划过屏幕要等几百毫秒才有识别结果。这种情况优先考虑WebGL加速如果还不行就要考虑简化网络层数或减少输入特征维度。这三道坎没有一道是“加机器”能解决的因为你的“机器”就是用户的浏览器。搞清楚这一点你在做架构选型时就会更早考虑模型压缩和并行计算而不是等上线之后再来救火。5. 入门之后的三个主攻方向5.1 让页面“看见”东西浏览器里的图像分类跑通异或网络只代表你入了门接下来最有成就感的事是让页面能“看见”东西。TensorFlow.js官方提供的图像分类示例的思路非常值得跟进加载MobileNet预训练模型把摄像头画面一帧帧塞进去就能实时识别出画面里的物体。我刚跑通这个Demo的时候觉得挺神奇的后来仔细琢磨发现本质和异或网络没有任何区别只是输入从2个数变成了224×224×3个数约15万个像素值神经网络的层数从一个隐藏层变成了几十层训练数据从4条变成了上百万条。理解了这一点你就再也不会觉得深度学习是什么玄学。如果你要做图像分类的定制模型我的建议是不要从零训练。在浏览器里从零训练一个CNN没有几块好显卡就别想了。正确姿势是加载预训练模型去掉最后一层分类器换上你自己的分类层用你自己的几百张图片去“微调”。这种行为叫迁移学习它是JavaScript开发者参与深度学习最现实的入口因为你的训练成本被大大降低了。5.2 让页面“听懂”话NLP的小切口尝试自然语言处理在JavaScript生态里比图像处理更考验工程能力。一个经典的入门练习是用神经网络判断一句话的情感是正面还是负面。做法并不复杂把句子拆成单词建立词表将每个单词映射成数字向量再送入一个简单的神经网络。整个过程你直接用Brain.js就能搭起来不需要引入任何重型工具。真正难的地方在数据预处理我承认NLP数据清洗的琐碎程度是所有方向里最让人头秃的。中英文混排文本要统一编码表情符要不要保留数字要不要替换成占位符这些决策都会直接影响模型效果。作为入坑的第一课我建议你用一个英文影评数据集练手格式统一、不需要分词程序比拿中文文本上手友好得多。5.3 走出浏览器把模型部署成服务训练好的模型不应该只存在于你的笔记本里。TensorFlow.js模型可以直接导出然后用Node.js加载跑推理这样你的服务端就能给客户端提供AI能力。这种部署方式对前端团队来说特别顺手因为全栈都是JavaScript不需要额外维护一套Python环境。如果模型足够小甚至可以精简后打包进前端代码让模型在用户设备本地跑推理好处是不需要网络请求、没有服务器成本、用户隐私也更有保障。从部署角度回看你今天学的内容你会发现当初手写网络打下的底子有多重要。模型压缩、推理加速、精度评估、参数调优——这些生产环节里的问题最后都要回溯到你对神经网络内部机制的朴素理解上。没有那个根基工具链再全也帮不了你。最后分享一个我个人的习惯新学一个概念我至少会用原生代码手写一遍参数故意调错几次看看错误长什么样。训练异或网络时你把学习率改成10输出会剧烈震荡把激活函数去掉网络永远学不会异或把训练轮数改到100loss压根降不下来。这些“错误”远比正常结果更有教学价值。你花一个下午亲手把神经网络从零敲出来以后用任何框架、看任何AI文章都会有一种“我看过引擎盖下都是些什么”的底气。下一篇我打算写浏览器里真正跑图像识别的完整项目从摄像头取流、模型加载、实时推理到性能调优一条龙。在那之前请你务必亲手跑一遍今天这份异或代码看看控制台里四个数字从一团乱麻慢慢变成正确答案的过程——那是一种很难形容的、把魔法变成工程学的爽快感。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑