都说深度学习必须用 Python,JavaScript 没有发言权——这个说法我听过太多次。可事实是,浏览器里跑神经网络早已不是新鲜事,而且不靠任何框架、只靠原生 JavaScript 手写一个能完成手写数字识别的三层网络,也就是一两百行代码的事。这个系列的第二篇,我打算换个跟主流教程完全不同的切入方式:先把手伸进神经网络的内部,把前向传播、反向传播、梯度下降这些术语,一个一个用 JavaScript 代码亲手实现出来。
这里的“黑客入门”不是网络攻击的意思,而是 Hacker 精神——不满足于调 API,而是拆开引擎看里面的齿轮。JavaScript 开发者接触深度学习时,最容易遇到两类困境:一类是直接上手各种 JS 深度学习框架,API 会用了,但出了 NaN 不知道去哪找原因;另一类是看了大量基于 Python 的深度学习教育资料,数学推导看懂了,回到浏览器里却不知道怎么落地。这篇文章就是写给这两类人的。
我会从矩阵运算说起,手写一个三层神经网络,用 Canvas 把训练过程画出来,最后落到一个浏览器里的手写数字识别 Demo。整个过程中不依赖任何第三方库,就用原生 JavaScript,顺便把那些框架文档里不会写的坑一个一个踩给你看。
1. 先搞清楚:为什么 JavaScript 要碰深度学习
1.1 这个系列为什么叫“黑客入门”
如果你去搜深度学习入门,搜出来的资料十有八九是 Python 写的,框架、安装环境、CUDA、GPU 显存——光环境配置就能劝退一波人。但 JavaScript 开发者其实有个天然优势:浏览器本身就是个巨大的运行平台,打开页面就能跑,不需要配置环境,不需要买显卡,人人都能复现。
“黑客入门”这个词在我这里有三层含义。第一层,是快速切入,用最少的概念直接跑通一个智能系统;第二层,是深入内部,不看封装好的层,而是自己写层;第三层,是把神经网络当成一个可以拆解的代码工程,而不是一个只会调参的黑魔法。所以这个系列不会一上来就介绍那些定义良好的开源框架,而是先用原生 JS 把神经网络的骨架搭出来。
当你亲手写完前向传播和反向传播之后,再看任何框架的 API,都会觉得它们是透明的——你知道每个 Layer 内部在算什么,每个损失函数背后在优化什么。这种感觉,是直接调库永远得不到的。
1.2 别被“深度学习必须 Python”的观念锁死
深度学习需要大量矩阵运算和数值计算,Python 生态在这方面确实成熟,NumPy、PyTorch、TensorFlow 都是很强大的工具。但要说浏览器里只能做玩具,那就是误解了。现代浏览器里已经有非常快的数值计算能力,Float32Array、TypedArray、WebAssembly、WebGPU 这些技术都在补足 JavaScript 的短板。
更重要的是,JavaScript 做深度学习有一个 Python 做不到的场景:部署和交互。你用 JS 写的神经网络可以直接运行在用户浏览器里,数据不用上传服务器,隐私安全有保障;模型可以跟 DOM、Canvas、WebGL 无缝交互,做出来的东西是活的、可视化的;不需要后端服务,一个静态页面就是一个完整的 AI 应用。
我做这个系列,不是要说服你抛弃 Python,而是想补充一块很多人忽视的版图:JavaScript 在深度学习的“理解”和“交互”这两个维度上,有着独特的价值。
1.3 本篇目标:搞定一个能训练、能识别、能可视化的小系统
这一篇的核心目标是构建一个最小但完整的神经网络训练系统。它需要满足四个标准:
- 纯 JavaScript,零依赖,一个 HTML 文件搞定;
- 能完成标准的前向传播和反向传播,真正训练出有效模型;
- 训练过程实时可视化,能看到损失下降、准确率上升;
- 最终做出一个可交互的手写数字识别 Demo。
四个标准全部达成,你对神经网络的“内功”就算是入门了。后面的系列文章再讲更复杂的卷积神经网络、循环神经网络时,你也具备了扎实的基础。
2. 神经网络的本质:矩阵运算的层层传递
2.1 一个神经元到底在算什么
很多学过一点神经网络概念的人,对“神经元”的理解还停留在生物学的类比上——树突接收信号、轴突传递信号之类的。这个类比有用,但在工程实现里,我更喜欢把神经元看成一个极简的计算单元。
每个神经元做的事情可以拆成两步:
- 把输入信号做加权求和,加上一个偏置(bias);
- 把求和结果塞进一个非线性函数(激活函数),得到输出。
用 JavaScript 写出来就是:
function neuron(inputs, weights, bias) { let sum = bias; for (let i = 0; i < inputs.length; i++) { sum += inputs[i] * weights[i]; } return activation(sum); }一个神经元就是一次“点乘加激活”。一个神经元如此,一层神经元就是把多个这样的单元并排放在一起,每个单元有自己的一组权重。而深度学习之所以“深”,就是把这些层叠起来——上一层的输出,是下一层的输入。
2.2 图像是怎么变成高维向量的
这个系列的热搜词里有一个很经典的提问:人脸识别图像进入神经网络到输出高维度向量的过程是什么。这个问题正好能说明矩阵运算的本质。
一张 28x28 像素的手写数字图片,在进网络之前会被“拉平”成 784 个数字的数组。每个数字代表一个像素的灰度值,0 表示纯黑,1 表示纯白,中间是灰。这样一个一维数组,就是网络的输入向量。
第一层隐藏层做的事,是拿输入向量和权重矩阵做矩阵乘法,得到一个新的向量。这个向量的维度等于隐藏层神经元的个数,比如 64。这个 64 维的向量已经不再是“像素”了,而是网络从图像中提取出的 64 种特征强度。第二层再做一次映射,把 64 维的特征映射到 10 维的输出向量,每个维度对应一个数字类别(0 到 9)的置信度。
整个过程用一句话概括:图像像素矩阵,经过一次次线性变换加非线性挤压,变成越来越抽象、维度越来越高的特征向量,最后映射成类别得分。
所谓“深度学习”,本质上就是在学习一组合适的矩阵参数,让这个映射关系对训练数据成立,并且能泛化到新数据上。
2.3 矩阵乘法:神经网络的地基运算
既然神经网络的核心是矩阵运算,那就得先解决一个问题:JavaScript 原生没有矩阵类型,怎么办?
最简单可靠的办法是用二维数组模拟矩阵,然后手动实现乘法。矩阵乘法的规则不复杂,只要 A 的列数等于 B 的行数,结果矩阵的行数等于 A 的行数,列数等于 B 的列数。结果矩阵的第 i 行第 j 列,等于 A 的第 i 行和 B 的第 j 列逐项相乘再求和。
function matrixMultiply(A, B) { const rowsA = A.length; const colsA = A[0].length; const rowsB = B.length; const colsB = B[0].length; if (colsA !== rowsB) { throw new Error('矩阵维度不匹配:A 的列数必须等于 B 的行数'); } const result = []; for (let i = 0; i < rowsA; i++) { result[i] = []; for (let j = 0; j < colsB; j++) { let sum = 0; for (let k = 0; k < colsA; k++) { sum += A[i][k] * B[k][j]; } result[i][j] = sum; } } return result; }这个三层循环的时间复杂度是 O(n³),后面我们会用 Float32Array 和优化循环来加速,但现在不着急。重要的是理解矩阵乘法的几何意义——它是在对向量做“旋转 + 缩放”的线性变换,神经网络之所以需要激活函数,就是因为如果没有非线性层,多少层线性变换叠加起来都等价于一层,那就永远学不出复杂模式。
3. 手写前向传播:三层网络核心代码
3.1 网络结构定义:784-64-10
我们定义一个经典的三层结构:输入层 784 个节点、隐藏层 64 个节点、输出层 10 个节点。输入层对应 28x28 的灰度图,隐藏层负责提取特征,输出层负责分类。
为什么隐藏层选 64?没有绝对答案,这是一个基于经验和数据量的选型。节点太少,网络表达能力不足,学不出复杂特征;节点太多,训练数据量不够时容易过拟合,把训练样本背下来,对新数据反而预测不准。MNIST 这类手写数字任务比较简单,64 个隐藏节点是一个不错的平衡点。如果你在搞定这篇代码之后,也可以试着改成 128、256,感受一下不同容量对训练结果的影响。
权重矩阵的维度这样确定:
- W1:784 行 × 64 列,把 784 维输入映射到 64 维隐藏空间;
- b1:64 维向量,每个隐藏神经元一个偏置;
- W2:64 行 × 10 列,把 64 维特征映射到 10 维输出空间;
- b2:10 维向量,输出层每个类别一个偏置。
用代码初始化这些参数:
function initializeNetwork(hiddenSize = 64) { const inputSize = 784; const outputSize = 10; // 均匀分布随机初始化,范围控制在正负0.5之间 function randomMatrix(rows, cols) { const mat = []; for (let i = 0; i < rows; i++) { mat[i] = []; for (let j = 0; j < cols; j++) { mat[i][j] = Math.random() - 0.5; } } return mat; } return { w1: randomMatrix(inputSize, hiddenSize), b1: new Array(hiddenSize).fill(0), w2: randomMatrix(hiddenSize, outputSize), b2: new Array(outputSize).fill(0) }; }第一次接触神经网络的人可能会问:权重为什么不能全部初始化为 0?如果所有权重都一样,每个神经元就会学到完全相同的特征,整个网络的表达能力就废了。随机初始化就是为了打破对称性。这个细节后面我们还会在调参章节里再深挖。
3.2 激活函数:非线性的来源
激活函数的选择对网络训练影响很大。这个网络里,隐藏层我选用 Sigmoid,输出层选用 Softmax。
Sigmoid 函数的公式和实现:
$$ \sigma(x) = \frac{1}{1 + e^{-x}} $$
function sigmoid(x) { return 1 / (1 + Math.exp(-x)); }Sigmoid 把任意实数压缩到 0 到 1 之间,像一个平滑的开关。它的优点是输出有界、便于理解,缺点是在两端梯度趋近于零,容易导致梯度消失。但我们的网络只有一层隐藏层,深度不大,Sigmoid 的缺点还不至于致命,反而能让我们更直观地理解非线性变换。
输出层的 Softmax 稍微复杂一点。它的作用是把一组任意实数映射成概率分布:每个输出都大于 0,且总和为 1。
function softmax(logits) { const max = Math.max(...logits); const exps = logits.map((x) => Math.exp(x - max)); const sum = exps.reduce((acc, x) => acc + x, 0); return exps.map((x) => x / sum); }注意代码里有个关键细节:我先把 logits 减掉了最大值再求指数。如果不这么做,当 logits 中存在很大数值时,Math.exp 会直接溢出为 Infinity,导致计算结果全是 NaN。这是一个极其常见的坑,后面还会说到。减掉最大值不会改变概率结果,因为 Softmax 的分子分母同时除以了一个相同的常数。
3.3 完整的前向传播代码
有了激活函数和矩阵乘法,前向传播就水到渠成了。输入 x 是长度为 784 的数组,依次过隐藏层、输出层,得到最终的概率分布。
function forward(network, x) { // 输入层 -> 隐藏层 const z1 = new Array(network.b1.length).fill(0); for (let j = 0; j < network.b1.length; j++) { let sum = network.b1[j]; for (let i = 0; i < x.length; i++) { sum += x[i] * network.w1[i][j]; } z1[j] = sum; } const a1 = z1.map(sigmoid); // 隐藏层 -> 输出层 const z2 = new Array(network.b2.length).fill(0); for (let j = 0; j < network.b2.length; j++) { let sum = network.b2[j]; for (let i = 0; i < a1.length; i++) { sum += a1[i] * network.w2[i][j]; } z2[j] = sum; } const output = softmax(z2); return { z1, a1, z2, output }; }这段代码没有用矩阵乘法函数,而是直接把矩阵乘法的逻辑手写在了循环里。这样做的用意是:避免为单个输入创建临时二维矩阵,直接对一维向量做加权求和更高效,也更容易看懂维度变化。
到这里,一个神经网络的前向传播已经跑通了。输入一张图片的像素数组,输出是一个长度 10 的数组,最大值所在的下标就是网络预测的数字。但当前这个网络是随机初始化的,预测结果等于瞎猜。要让网络学会识别,必须进入下一环节:反向传播。
4. 反向传播与梯度下降:拆开看每一个导数
4.1 损失函数:衡量预测和真相差距的标尺
神经网络的学习前提是有一个“标尺”来度量当前模型有多差。经典的选择是交叉熵损失。对于分类问题来说,交叉熵比均方误差更合适,因为它在模型自信却预测错误的时候给出更大惩罚,梯度也更稳定。
对于单条训练样本,真实标签向量 y 是一个 one-hot 数组,比如数字 5 对应[0, 0, 0, 0, 0, 1, 0, 0, 0, 0]。模型输出是概率分布output。交叉熵定义为:
$$ L = -\sum_{i} y_i \log(\hat{y}_i) $$
代码实现:
function crossEntropyLoss(y, output) { let loss = 0; for (let i = 0; i < y.length; i++) { loss -= y[i] * Math.log(output[i] + 1e-8); } return loss; }1e-8是防下溢的,避免 output 为 0 时取对数变成负无穷。
4.2 链式法则:误差是怎么一层层传回去的
反向传播的核心思想,是链式法则。如果你不想看数学公式,可以这么理解:网络预测错了,这个错误有大有小,每一层、每一个权重对最终错误的“贡献”不同。我们要做的就是根据贡献大小,调整每个权重——贡献大就多调整,贡献小就少调整。
数学上,我们需要损失函数对每个权重的偏导数。以输出层的权重 W2 为例,损失 L 对第 i 个输入到第 j 个输出的权重 w2[i][j] 的偏导,可以写成三个因子的乘积:
- 第一个因子:softmax + 交叉熵的联合梯度,经过公式推导,会得到一个非常简洁的结果:
delta2[j] = output[j] - y[j]; - 第二个因子:
a1[i],即隐藏层第 i 个节点的输出; - 两者相乘,就是梯度:
dL/dw2[i][j] = a1[i] * (output[j] - y[j])。
对隐藏层的权重,需要把误差继续往上传。定义delta1[j] = (sum_k delta2[k] * w2[j][k]) * sigmoidDerivative(z1[j]),其中sigmoidDerivative(z1[j]) = a1[j] * (1 - a1[j])。然后dL/dw1[i][j] = x[i] * delta1[j]。
如果你觉得这一小段像天书,没关系,看代码就清楚了:
function backward(network, x, y, forwardResults) { const { z1, a1, output } = forwardResults; // 输出层误差 const delta2 = new Array(10); for (let j = 0; j < 10; j++) { delta2[j] = output[j] - y[j]; } // 隐藏层误差 const delta1 = new Array(64); for (let j = 0; j < 64; j++) { let sum = 0; for (let k = 0; k < 10; k++) { sum += delta2[k] * network.w2[j][k]; } delta1[j] = sum * a1[j] * (1 - a1[j]); } // 记录梯度 const gradW2 = matrixFromFunction(64, 10, (i, j) => a1[i] * delta2[j]); const gradB2 = delta2.slice(); const gradW1 = matrixFromFunction(784, 64, (i, j) => x[i] * delta1[j]); const gradB1 = delta1.slice(); return { gradW1, gradB1, gradW2, gradB2 }; }你可能注意到,这里对 softmax 求导的细节完全消失在了这行简洁公式里——这是推导后的结果。训练过程代码如下:
function train(network, data, epochs, learningRate) { for (let epoch = 0; epoch < epochs; epoch++) { let totalLoss = 0; for (const sample of data) { const { x, y } = sample; const forwardResults = forward(network, x); const grads = backward(network, x, y, forwardResults); updateWeights(network, grads, learningRate); totalLoss += crossEntropyLoss(y, forwardResults.output); } console.log(`Epoch ${epoch + 1}, loss: ${totalLoss / data.length}`); } } function updateWeights(network, grads, learningRate) { for (let i = 0; i < 784; i++) { for (let j = 0; j < 64; j++) { network.w1[i][j] -= learningRate * grads.gradW1[i][j]; } } for (let j = 0; j < 64; j++) { network.b1[j] -= learningRate * grads.gradB1[j]; } for (let i = 0; i < 64; i++) { for (let j = 0; j < 10; j++) { network.w2[i][j] -= learningRate * grads.gradW2[i][j]; } } network.b2 = network.b2.map((b, j) => b - learningRate * grads.gradB2[j]); }4.3 一个现实问题:训练一个 epoch 要多久
MNIST 有 60000 条训练样本,我们的手写 Demo 不用那么多,但为了效果,肯定比 1000 条要多。每一轮参数更新,要对每个样本做一次前向加一次反向传播,整体循环里还有大量乘法,纯 JavaScript 跑起来会有些卡顿。
我在实测中的经验是,纯二维数组做乘法,6000 条样本、50 个 epoch,在普通浏览器里要几分钟到十几分钟不等。这不是 bug,是 JavaScript 数值计算的现实。所以后面专门有一节讲性能优化。
5. 训练可视化与调参:Canvas 把黑盒变成显示器
5.1 为什么必须画损失曲线
神经网络训练时最容易出现的三个问题,只看数字很难快速判断:训练发散、学习过慢、出现 NaN。而一张损失曲线图,能让你一眼看出训练状态是否正常。
正常训练的损失曲线应该是一条平滑下降、渐近收敛的曲线。如果损失突然冲到巨大值,说明学习率太高导致震荡;如果损失下降非常缓慢,可能学习率太低,或者数据归一化没做好;如果损失直接变成 NaN,问题往往出在数值溢出。
Canvas 画一条损失曲线非常简单。关键不是画图的技术,而在于采样时机——训练过程是百万次级别的更新,不可能每更新一次就重绘。我一般每 50 到 100 次参数更新记录一次损失,然后绘制到 Canvas 上。
function drawLossCurve(ctx, lossHistory, width, height) { ctx.clearRect(0, 0, width, height); ctx.strokeStyle = '#4caf50'; ctx.lineWidth = 2; ctx.beginPath(); const maxLoss = Math.max(...lossHistory); const step = width / lossHistory.length; lossHistory.forEach((loss, i) => { const x = i * step; const y = height - (loss / maxLoss) * height; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }我习惯在训练页面同时画两条曲线:训练集损失和验证集损失。如果训练损失持续下降但验证损失在某个点开始回升,说明模型开始过拟合了,这时就该考虑增大正则化系数或者减少训练轮次。
5.2 学习率:一个稍微大一点就会全盘崩溃的数字
学习率是神经网络里最重要的超参数,没有之一。学习率太大,参数会越过最优点,损失函数像乒乓球一样在波谷两端反复弹跳,越训练越糟糕;学习率太小,训练会顺畅但进展极慢,可能跑几百轮还没有明显变化。
我在手写这个网络时做过一次实验:相同数据、相同初始化,学习率从 0.1 调到 0.5,训练 100 轮。0.1 的最后准确率约 92%,0.3 的在训练到第 30 轮时就出现了明显的损失震荡,0.5 的直接在第二轮就输出了一串 NaN。
这里给个经验值范围:Sigmoid 隐藏层的网络,用交叉熵损失,学习率建议从 0.05 开始试。如果你的网络用了 ReLU 激活函数,学习率可以试着调大一点到 0.1,因为 ReLU 的梯度不像 Sigmoid 那样容易饱和。如果用了 Batch Normalization,学习率甚至能调到 0.3 以上,但我们手写版不会加这些花哨的操作,稳住比炫技重要。
训练时我建议用一个带衰减的学习率计划:前期大步快跑,后期小步微调。最简单的是每隔若干个 epoch,把学习率乘一个小于 1 的系数,比如每 20 轮减半。这会让训练更加稳定。
5.3 权重初始化与 L2 正则化
前面说过,权重不能初始化为全 0。但Math.random() - 0.5这种方式生成的权重分布是均匀分布,方差偏大。更好的初始化方法叫 Xavier 初始化(也叫 Glorot 初始化),它的思想是让每一层输出和输入的方差尽量保持一致,避免信号在前向传播和反向传播过程中逐层放大或缩小。
Xavier 初始化的实现方式是让权重从均值为 0、标准差为 2 / (fanIn + fanOut) 的正态分布中采样,其中 fanIn 是输入节点数,fanOut 是输出节点数。在实际工程里,用均匀分布近似也能达到类似效果:
function xavierInit(fanIn, fanOut) { const limit = Math.sqrt(6 / (fanIn + fanOut)); const mat = []; for (let i = 0; i < fanIn; i++) { mat[i] = []; for (let j = 0; j < fanOut; j++) { mat[i][j] = (Math.random() * 2 - 1) * limit; } } return mat; }这个初始化方式在我后面的实践里成了默认选择,替换之后明显感觉到训练收敛更顺滑了。
L2 正则化也是必不可少的正则化技术。它在我们原来的损失函数上追加一个惩罚项:所有权重平方和乘以一个系数 lambda,再除以样本数。代码里加正则化很简单,只需在每次更新权重时给权重“缩小”一点,等价于权重整体乘以一个略微小于 1 的系数:
// 每次更新后,对权重做 L2 衰减,weightDecay 例如 0.001 network.w1[i][j] -= learningRate * weightDecay * network.w1[i][j];这一行的作用是除了梯度下降的更新量之外,把权重本身往零方向拉一点点。如果模型过拟合,权重通常会有很多很大的绝对值;正则化通过惩罚大权重,迫使模型用更小的函数值去拟合数据,从而提升泛化能力。
5.4 训练中的数值稳定性:不小心就会变成 NaN
数值稳定性是手写神经网络最容易翻车的地方。我这里总结几个最常见的 NaN 来源:
第一,是 log 里传入了 0。交叉熵损失里Math.log(output[i] + 1e-8)这个 1e-8 就是干这个用的,但不小心漏掉它,一旦 output 出现 0,损失直接变成 Infinity,反向传播的所有梯度跟着变成 Infinity。
第二,是 softmax 溢出。前面已经说过,先减去最大值再算指数,这个小技巧能避免 99% 的溢出。
第三,是梯度更新后的权重出现 Infinity。如果训练数据里存在极端值而不做归一化,某些权重的梯度会非常大,一步更新后权重本身变成 Infinity,后续所有计算直接崩塌。解决办法是做好输入归一化,让每个像素值在 0 到 1 之间,而不是 0 到 255。
还有一个小细节,就是在每轮训练前把数据打乱(shuffle)。不做打乱的话,模型会先学会训练集前面一小部分数据,然后灾难性遗忘之前学过的内容,训练曲线会呈现锯齿状波动。我用 Fisher-Yates 洗牌算法解决这个问题,这就是解决问题的一般手段。
6. 浏览器推理落地:手写数字 Demo 与性能陷阱
6.1 画布采集手写数字:从像素到输入向量
一个完整的手写数字识别 Demo,需要用户用手指或鼠标在 Canvas 上写字,然后程序把画布内容转换为 784 维向量并传入神经网络。
采集的方式是遍历 Canvas 的像素数据。这里有一个不容易注意到的细节:Canvas 渲染出的像素颜色是 RGBA 四通道,而网络训练的输入是灰度单通道,因此必须将 RGB 转换成灰度值,再用 255 归一化到 0 到 1 之间。我用的灰度公式是加权平均法:
$$ gray = 0.299 \times R + 0.587 \times G + 0.114 \times B $$
这个公式是从人类的视觉感知特性来的:人眼对绿色最敏感,对蓝色最不敏感,所以绿色权重大,蓝色权重小。如果你直接用三个通道的平均值,灰度图会显得比较“平淡”,识别准确率也会略微下降。
转换代码:
function canvasToInput(canvas) { const ctx = canvas.getContext('2d', { willReadFrequently: true }); const imageData = ctx.getImageData(0, 0, 28, 28); const input = []; // 先把画布缩放到 28x28,保证输入尺寸与训练一致 for (let i = 0; i < 28; i++) { for (let j = 0; j < 28; j++) { const idx = (i * 28 + j) * 4; const r = imageData.data[idx]; const g = imageData.data[idx + 1]; const b = imageData.data[idx + 2]; const gray = (0.299 * r + 0.587 * g + 0.114 * b) / 255; input.push(gray); } } return input; }willReadFrequently这个配置也值得提一句。频繁调用 getImageData 会触发浏览器底层优化路径,读取速度会有明显提升。这种选项一般在文档的小字里,不实际踩一次很难注意到。
预处理还有一个关键点:训练和推理的数据处理方式必须完全一致。如果训练时用除以 255 归一化,推理时却忘了这一步,输入分布跟训练分布不一致,再好的模型也得翻车。
6.2 性能优化:从普通数组换成 Float32Array
前面提到用普通二维数组实现矩阵乘法和训练,正确性没问题,但性能很吃紧。JavaScript 里的普通数组可以存放任意类型,引擎需要做很多额外的类型检查;而Float32Array是专门为 32 位浮点数设计的类型化数组,底层是一块连续的内存,性能远高于普通数组。
实测下来,把权重从普通二维数组改成 Float32Array,训练速度能有 2 到 4 倍的提升,改法也很简单。定义一个 Float32Array 支持的一维数组来模拟矩阵,同时显式记录维度信息:
class Matrix { constructor(rows, cols, data = null) { this.rows = rows; this.cols = cols; this.data = data || new Float32Array(rows * cols); } get(row, col) { return this.data[row * this.cols + col]; } set(row, col, value) { this.data[row * this.cols + col] = value; } }矩阵乘法也同步改成基于this.data的线性索引循环。这样改动看起来只是数据结构变了,但底层内存布局完全不同,CPU 的缓存命中率大幅提升。一定要注意,不能为了图方便在get方法里做行列转换的时候每次都算一遍index = row * cols + col,很多次调用会积少成多,适得其反。
除了存储布局,另一个影响训练速度的瓶颈是频繁创建临时对象。JavaScript 的垃圾回收机制会在临时对象过多时频繁触发 STW(全停顿),导致训练过程像打嗝一样卡顿。解决办法是复用中间结果的缓冲区:把z1、a1、delta1、delta2这些变量声明成模块级的固定数组,每次训练时往里面写值而不是新建数组。
6.3 推理阶段的优化:预测本身其实很快
很多人以为“神经网络推理很慢”,其实在推理阶段只有一个前向传播,没有反向传播,计算量比训练小一个数量级。用 Float32Array 来实现推理,单张 28x28 图片的识别时间在 1 毫秒以内,完全感受不到延迟。
推理阶段的优化重点不是乘法,而是把权重数据加载到本地。我们通常会在训练完成后,把网络的权重序列化成 JSON 或者二进制格式保存下来。推理页面启动时直接加载权重,不需要重新训练,这样的应用才是真正可用的。
这里我给一个建议:训练完成后,先验证模型在测试集上的准确率,再考虑把它部署成 Demo。很多初学者跳过测试这一步,直接用训练集准确率自我感动,结果部署后一到手写输入就翻车,因为用户的手写风格和训练集差异太大。测试集就像是“考前模拟”,只有模拟过关了,才敢上真战场。
6.4 我在实际使用中发现的两个手写识别细节
第一个细节,是手写数字的“白底黑字 vs 黑底白字”问题。训练集里数字是白色、背景是黑色,而你 Demo 里用户是用黑笔在白底上写字,两种输入在像素上的“极性”完全相反。如果不做反转处理,模型大概率识别不准。解决办法有两个:要么在采集时做一次像素反转,白色区域处理成接近 0,黑色笔迹处理成接近 1;要么训练时就同时包含两种极性的数据增强。我推荐前者,简单直接,运行成本为零。
第二个细节,是用户写的字往往不在画布的正中央。训练集里的数字都是居中的,如果用户写的字偏左,模型表现就会很差。廉价的解决办法是在采集时计算笔迹的质心,然后把图像平移,让质心位于画布中央。数学原理是找所有有墨迹的像素坐标的平均值,然后把画布整体平移。这个预处理步骤看起来不起眼,却能明显提高实际使用中的识别率。
这些细节都不是神经网络本身的难度,而是工程落地的关键。深度学习项目里,真正花时间的往往不只是调模型,还有这些数据层面的“最后一公里”。
写在最后的实操建议
整个项目从零做到能识别手写数字,我完整跑过不下十遍。我可以负责任地说,最难的部分不是实现前向传播或者反向传播,而是当你遇到一个 NaN、一个训练不收敛、一个推理效果奇差时,如何有条理地定位问题。
我的排查顺序非常简单:先检查输入数据是否正确(打印前几个样本的像素值,确认归一化没写错),再检查前向传播输出是否符合基本逻辑(softmax 输出总和是否接近 1),然后检查第一个 batch 更新之前和之后的 loss 对比(如果第一个 batch 之后 loss 不降反升,基本是学习率太大),最后才去检查梯度公式是否有笔误。这套顺序帮我快速解决了大部分问题。
如果这篇帮助你完成了第一个“能跑、能看、能识别”的 JavaScript 神经网络,我建议你立刻做一个小扩展:把隐藏层的神经元输出(a1 数组)可视化,看看不同神经元对什么形状敏感。这会启发你对特征提取的理解,比反复刷教程有用得多。下一篇我打算沿着这个方向,把卷积神经网络在浏览器里的实现和可视化也拆开讲讲。