这篇是 JavaScript 深度学习的第二篇。上一篇我从整体聊了聊为什么在前端做深度学习、有哪些现成库可以用、浏览器里跑模型的可行边界在哪很多人看完觉得不过瘾说光聊生态和选型不够能不能真的从零写一个能训练、能推理的神经网络出来而不是只会用别人封装好的框架。这篇就来填这个坑不用任何深度学习库纯手写一个微型神经网络在 JavaScript 里完成完整的训练和预测流程。会涉及矩阵运算、梯度下降、反向传播的推导和实现全程代码都能在浏览器控制台或者 Node 里直接跑。适合已经懂一点 JS、想真正理解深度学习底层逻辑的读者也适合想给前端项目塞一点智能推理能力的朋友。我先把话放前面手写神经网络并不是为了证明自己牛而是为了让你真正理解框架背后发生了什么。哪一层是全连接、激活函数做了什么、梯度是怎么流动的、学习率为什么有时候调不动这些东西只要你亲手写过一遍之后再去看任何框架的 API思路都会完全不同。所以这篇会很硬核但我会尽量用大白话把每一个数学符号和代码对应起来。1. 为什么在 JavaScript 里造深度学习轮子1.1 真实场景浏览器端的智能推理正在变成刚需很多人听到 JavaScript 和深度学习组合在一起第一反应是“这在生产环境能用吗”。我的答案是分场景。如果你要做的是大规模离线训练那别折腾直接用 Python 那套生态完全没有必要在浏览器里硬扛。但如果你遇到的是下面这些需求JavaScript 反而是最顺手的选项第一模型推理要部署在网页端又不想引入动辄几 MB 的服务端依赖或者压根没有后端资源。比如给一个静态页面加一个手势识别、情感分析、文本分类功能用户上传数据到本地推理不上传服务器隐私和成本都解决了。第二需要用到浏览器特有的能力比如摄像头实时画面、麦克风音频流、WebGL 加速这些设备能力只有浏览器能直接触达。第三你想做一个交互式可视化教学工具在页面上实时展示神经网络的训练过程那前端就是唯一合理的载体。我见过不少团队在 Python 里训好模型再转成 ONNX 或者 TensorFlow.js 的格式推到前端做推理这条路本身没问题但一旦模型需要微调、需要根据用户数据在线学习传统方案就很笨重。在 JavaScript 里实现从训练到推理的完整链路才能处理这种动态更新场景。1.2 纯 JS 实现和调用现成库的边界我知道你现在一定在想直接用 TensorFlow.js 不好吗何必要从零写我的看法是TensorFlow.js 当然好它替你解决了大量工程问题比如 GPU 加速、自动求导、模型格式兼容这些我手写代码完全比不了也不推荐你在正式项目里用今天写的这套代码替代它。但是自动求导恰恰是最大的问题。你只需要写前向传播梯度的事情框架全包了这当然省事可省事的结果是你对反向传播的理解永远是黑盒。一旦遇到梯度爆炸、梯度消失、学习率不匹配这类问题你会发现只知道“调一下参数”其实是很虚的。那什么时候值得手推当你做研究原型验证、做教学演示、做依赖极小的工具插件时手写实现反而有巨大优势——代码完全透明、无黑盒、可以随时打印每一层的中间梯度值来调试。而且这篇文章实现的是一个两层全连接网络代码量不超过两百行搞清楚它之后你再看那些复杂结构就顺理成章了。1.3 这篇的路线从矩阵到完整的训练闭环这篇我不打算一口气实现一个能做图像识别的卷积神经网络那步子迈太大了。我会带你把一个完整的最小闭环跑通矩阵运算是最底层的地基然后是前向传播把小批量数据从输入层送到输出层算出预测值和损失再是反向传播用链式法则把误差梯度一层一层传回去最后是参数更新让网络真正开始“学习”。为了不让整个过程太空对理论我选了一个非常经典的小型分类任务鸢尾花二分类问题。数据集是公开领域里最常见的入门级数据包含四个数值特征花萼长度、花萼宽度、花瓣长度、花瓣宽度类别就取两种。虽然这个任务简单到用一个逻辑回归都能得到不错效果但正因为简单数据量小、特征维度低每一层梯度变化都能被精确观察到。用它做教学案例再适合不过。2. 核心数学基础矩阵、损失函数和梯度下降2.1 把神经网络的计算变成矩阵运算理解神经网络的第一步是忘掉那些画得很花哨的结构图把每一层都看成一次矩阵乘法外加一个偏置向量。假设输入是一个包含 4 个特征的样本那么输入向量就是一个 1×4 的矩阵。第一层全连接层包含若干个神经元每个神经元都和输入的每个特征相连。这种“全连接”用矩阵表达特别简洁乘以一个形状为 4×N 的权重矩阵得到一个 1×N 的中间向量再加上一个形状为 1×N 的偏置向量最后过一个激活函数就得到这一层的输出。整个过程写成公式就是Z X·W bA activation(Z)为什么全部用矩阵表达因为矩阵乘法天然适合批量处理数据。如果有 100 个样本X 的形状就是 100×4一次矩阵乘法就能同时算出这 100 个样本在每个神经元上的加权求和值这不仅让代码更简洁也更符合 GPU 或者底层数学库的优化逻辑。JavaScript 里虽然没有内置的高性能矩阵库除非你用 TensorFlow.js但理解这种维度关系才是真正的核心。很多新手在写矩阵乘法的时候最常犯的错就是维度不匹配。我给你一个非常好用的判断方法两个矩阵 A 和 B 能相乘的条件是 A 的第二维必须等于 B 的第一维结果矩阵的维度是 A 的第一维乘以 B 的第二维。比如 100×4 乘以 4×5结果是 100×5。你在写代码的时候多写几个维度断言可以避免一大堆诡异 bug。2.2 损失函数定一个目标才能谈优化神经网络学习的本质是调整权重让网络输出逼近真实标签。怎么衡量“逼近”呢这就需要损失函数。对于二分类问题最常用的是二元交叉熵损失。假设网络输出的预测值是 a在 0 到 1 之间真实标签是 y0 或者 1单个样本的损失是Loss -[ y·log(a) (1-y)·log(1-a) ]你可以这样理解当真实标签为 1 时损失函数变成了 -log(a)预测值越接近 1损失越小当真实标签为 0 时损失是 -log(1-a)预测值越接近 0损失越小。这比用均方误差更适合分类问题因为它在预测信心不足时给出更大的梯度信号模型学起来更快。为什么不用均方误差均方误差对输出饱和区不敏感。比如 sigmoid 函数在两端的导数值几乎为 0如果预测值已经接近 0.98 但真实值是 1这时用 MSE 算出来的梯度会非常小模型更新很慢而交叉熵能自动放大这种“错误自信”的惩罚让梯度保持在一个合适的量级上。这就是为什么分类任务里交叉熵几乎成了默认选择。2.3 梯度下降沿着最陡的方向下山有了损失函数问题就变成了求一组权重让损失最小。最经典的方法是梯度下降计算损失函数对每个权重的偏导数也就是梯度然后沿着梯度的反方向更新权重因为梯度的方向是函数上升最快的方向反过来就是下降最快的方向。参数更新的公式就是经典的W W - η·dWb b - η·db这里的 η 是学习率控制每步迈多大。学习率太大会导致震荡甚至发散太小则训练慢到让人崩溃。在后面的实操部分我还会专门聊这个参数怎么选。但到这里有一个关键问题损失函数是经过多层复合出来的结果怎么求它对第一层权重的偏导数答案是链式法则。误差从输出层开始逆向穿过每一层逐个求出中间变量的梯度这个逆向过程就是反向传播。下一节我会用具体的代码把它完整实现一遍你会发现它没有想象中那么神秘。3. 详解网络结构设计与代码骨架3.1 网络架构两层的全连接网络我会构造一个 4 输入 → 4 隐藏层神经元 → 1 输出的两层网络激活函数用 tanh 和 sigmoid 的组合。选 4 个隐藏神经元是基于数据规模和任务复杂度折中的结果你完全可以改成 8 个或 16 个试试效果。整体结构是这样输入层4 个特征形状 [1, 4]第一层权重 W1 形状 [4, 4]偏置 b1 形状 [1, 4]激活函数用 tanh第二层权重 W2 形状 [4, 1]偏置 b2 形状 [1, 1]激活函数用 sigmoid输出一个 0~1 之间的概率值损失二元交叉熵用 tanh 做隐藏层激活是因为它的输出范围是 -1 到 1而且相比 sigmoid 来说均值更接近 0这有助于削弱梯度消失问题训练会更稳定。输出层必须用 sigmoid因为我们要得到的是一个概率值。3.2 前向传播与反向传播的推导细节前向传播的每一步其实都不复杂你可以把它拆成四个小步骤。第一从输入到隐藏层z1 X·W1 b1a1 tanh(z1)第二从隐藏层到输出z2 a1·W2 b2a2 sigmoid(z2)第三计算损失。而反向传播就是从 loss 出发反过来求每个参数的梯度。我先给出最关键的几个推导结果这样你在看代码的时候能对上号。输出层的误差项也就是损失对 z2 的偏导在二分类交叉熵 sigmoid 的情况下有个极其简洁的形式dz2 a2 - y这是整个反向传播里最漂亮的结果它意味着输出层的“错误信号”就是预测值和真实值的差。这个差值越大说明错得越离谱参数调整的幅度就应该越大。然后求第二层权重的梯度dW2 a1^T · dz2db2 dz2 按样本求和注意这里有一个转置因为矩阵乘法不等于数乘尺寸必须对齐。接着误差继续往第一层传。首先计算损失对 a1 的梯度da1 dz2 · W2^T再通过 tanh 的导数把误差映射到 z1 上。tanh 的导数公式是 1 - tanh²(x)恰好 a1 就是 tanh(z1) 的结果所以直接用 a1 就能算dz1 da1 · (1 - a1²)最后是 W1 和 b1 的梯度dW1 X^T · dz1db1 dz1 按样本求和有了这些梯度更新参数就只是简单的减法了。整个过程你可以理解为误差像一个信号从输出端出发每经过一层就乘以这一层的“灵敏度”然后传给更前一层。每一层只需要关心自己收到的误差信号和自己局部的导数完全不需要知道前面长什么样。3.3 核心代码一个两百行的微型神经网络下面这段代码就是本篇的核心我建议你不要直接复制粘贴跑完就关掉而是一行一行看注释跑完后再自己默写一遍这样理解深度完全不一样。class TinyNeuralNet { constructor(inputSize, hiddenSize, outputSize, learningRate 0.1) { this.inputSize inputSize; this.hiddenSize hiddenSize; this.outputSize outputSize; this.lr learningRate; // 初始化权重使用 He 初始化的简化版本 this.W1 this.randomMatrix(hiddenSize, inputSize, Math.sqrt(2 / inputSize)); this.b1 this.zeros(1, hiddenSize); this.W2 this.randomMatrix(outputSize, hiddenSize, Math.sqrt(2 / hiddenSize)); this.b2 this.zeros(1, outputSize); } // 生成一个 [rows, cols] 的随机矩阵值服从均值为0、标准差为scale的正态分布。 // 这里用近似方法生成高斯分布用 6 个均匀分布叠加。 randomMatrix(rows, cols, scale 1) { const m []; for (let i 0; i rows; i) { m.push([]); for (let j 0; j cols; j) { let sum 0; for (let k 0; k 6; k) sum Math.random(); m[i].push(((sum - 3) / 3) * scale); } } return m; } zeros(rows, cols) { const m []; for (let i 0; i rows; i) { m.push(new Array(cols).fill(0)); } return m; } tanh(x) { return Math.tanh(x); } sigmoid(x) { return 1 / (1 Math.exp(-x)); } // 矩阵乘法A 是 [m, n]B 是 [n, p]结果是 [m, p] matMul(A, B) { const m A.length; const n B.length; const p B[0].length; const result []; for (let i 0; i m; i) { result.push(new Array(p).fill(0)); for (let j 0; j p; j) { let sum 0; for (let k 0; k n; k) { sum A[i][k] * B[k][j]; } result[i][j] sum; } } return result; } // 矩阵逐元素相加B 加到 A 上返回新矩阵 matAdd(A, B) { const m A.length; const n A[0].length; const result []; for (let i 0; i m; i) { result.push([]); for (let j 0; j n; j) { result[i].push(A[i][j] B[i][j]); } } return result; } // 矩阵转置[m, n] - [n, m] transpose(A) { const m A.length; const n A[0].length; const result []; for (let j 0; j n; j) { result.push([]); for (let i 0; i m; i) { result[j].push(A[i][j]); } } return result; } // 按行求和结果是 [1, cols]用于把梯度汇总到偏置上 sumRows(A) { const m A.length; const n A[0].length; const result new Array(n).fill(0); for (let i 0; i m; i) { for (let j 0; j n; j) { result[j] A[i][j]; } } return [result]; } // 前向传播把中间值保存下来供反向传播使用 forward(X) { const Z1 this.matAdd(this.matMul(X, this.transpose(this.W1)), this.b1); const A1 Z1.map(row row.map(v this.tanh(v))); const Z2 this.matAdd(this.matMul(A1, this.transpose(this.W2)), this.b2); const A2 Z2.map(row row.map(v this.sigmoid(v))); return { X, Z1, A1, Z2, A2 }; } // 计算损失二元交叉熵加一个极小量防止 log(0) computeLoss(Y, A2) { const n Y.length; let loss 0; for (let i 0; i n; i) { const y Y[i][0]; const a A2[i][0]; loss -(y * Math.log(a 1e-9) (1 - y) * Math.log(1 - a 1e-9)); } return loss / n; } // 反向传播 backward(cache) { const { X, Z1, A1, Z2, A2 } cache; const Y this.YCache; const n X.length; // 输出层误差 const DZ2 A2.map((row, i) [row[0] - Y[i][0]]); // 第二层权重梯度 const A1T this.transpose(A1); const DW2 this.matMul(DZ2, A1); // 严格来说是 [1, hidden]不需要调整 // 实际上应该用 DZ2^T * A1因为 DZ2 是 [n,1]A1 是 [n,hidden] // 这里修正一下用转置之后相乘再来计算 const DZ2T this.transpose(DZ2); const DW2Correct this.matMul(DZ2T, A1); // 结果是 [1, hidden] const DB2 this.sumRows(DZ2); // 反向传播到第一层 const W2T this.transpose(this.W2); const DA1 this.matMul(DZ2, W2T); // [n, hidden] const DZ1 DA1.map((row, i) row.map((v, j) v * (1 - A1[i][j] * A1[i][j]))); const XT this.transpose(X); const DW1 this.matMul(DZ1, X); // 需要用 DZ1^T * X不对, 需要整理 const DZ1T this.transpose(DZ1); const DW1Correct this.matMul(DZ1T, X); // 结果是 [hidden, input] const DB1 this.sumRows(DZ1); return { DW1: DW1Correct, DB1: DB1[0], DW2: DW2Correct, DB2: DB2[0] }; } // 参数更新 update(grads) { const scale this.lr; for (let i 0; i this.W1.length; i) { for (let j 0; j this.W1[0].length; j) { this.W1[i][j] - scale * grads.DW1[i][j]; } } for (let i 0; i this.b1.length; i) { for (let j 0; j this.b1[0].length; j) { this.b1[i][j] - scale * grads.DB1[j]; } } for (let i 0; i this.W2.length; i) { for (let j 0; j this.W2[0].length; j) { this.W2[i][j] - scale * grads.DW2[i][j]; } } for (let i 0; i this.b2.length; i) { for (let j 0; j this.b2[0].length; j) { this.b2[i][j] - scale * grads.DB2[j]; } } } // 训练一个批次 trainBatch(X, Y) { this.YCache Y; const cache this.forward(X); const grads this.backward(cache); this.update(grads); const loss this.computeLoss(Y, cache.A2); return loss; } // 预测返回 0 或 1 predict(X) { const cache this.forward(X); return cache.A2.map(row (row[0] 0.5 ? 1 : 0)); } }你看两个类方法就把前向和反向全包含了。整个代码没有依赖任何外部库你把这个类定义复制到任何一个 JS 运行环境里立刻就能把网络实例化出来。不过上面的 backward 里有一处容易写错的地方我需要单独强调一下。很多人写梯度的时候会被矩阵维度绕晕我用个土办法帮你理清楚判断梯度算得对不对不用看公式推导过程直接看结果矩阵的维度是否和原参数一致。DW2 的维度必须和 W2 一样因为你要做 W2 W2 - lr·DW2维度天然要对齐DW1 同理。我写代码的时候会在关键步骤打印每个矩阵的 shape养成这个习惯你在调试任何神经网络代码时都能少掉一半头发。3.4 梯度检查如何确认反向传播没写错这里我强烈建议你做一件事梯度检查。反向传播公式一旦记错一个符号整个训练就会原地踏步或者直接炸掉。你在正式训练之前可以用有限差分法来验证梯度是否正确。原理很粗暴对某个权重 w分别在 wε 和 w-ε 处计算损失用 (loss(wε) - loss(w-ε)) / (2ε) 近似梯度。如果这个值和你的反向传播算出来的梯度非常接近那基本可以确定反向传播是对的。这个方法在正式训练中太慢不可能逐参数做但在调试阶段是金标准。我每次写新的网络结构第一件事就是跑一遍梯度检查确认梯度计算没问题才开始训练。建议你也把它写成一个小函数毕竟反向传播的一堆链式法则脑算一遍根本不够。4. 实操训练跑通一个完整分类任务4.1 数据准备与归一化数据我用的是一个公开的花卉分类数据集里提取的二分类子集只保留两类样本每类 50 条共 100 条每条样本有 4 个数值特征。它非常适合做迷你神经网络的入门实战。有一个关键步骤不能漏特征归一化。我见过太多新手直接拿原始数据丢进网络里训练结果损失怎么调都不降。原因在于如果一个特征的量级是几十另一个特征只有零点几那么权重更新时会严重偏向大量级的特征模型训练很不稳定。我这里用最简单的 Min-Max 归一化把每个特征线性缩放到 0 到 1 之间。公式是xScaled (x - min) / (max - min)实际操作时我先扫描一遍全部训练数据记下每个特征的最小值和最大值然后统一做变换。测试数据也要用训练集上算出的 min 和 max 来归一化这是非常重要的细节如果用测试集自己的 min 和 max相当于把测试集的信息泄漏给了模型最后得到的准确率会虚高。归一化之后我把数据随机打乱按照 80 比 20 切分训练集和测试集。打乱数据是为了避免类别顺序对训练产生干扰。4.2 训练循环与超参数调整训练过程其实就是一个不断重复的循环取一批数据、前向传播、计算损失、反向传播、更新参数。由于当前最大批量也就 80 条我直接在全批量梯度下降模式下训练也就是每次迭代都用全部训练数据。初始把学习率设为 0.1隐藏层神经元 4 个迭代 300 轮。你猜会发生什么如果运气好损失会稳定下降如果运气不好你可能会看到损失在某个区间内来回震荡这说明学习率偏大或者网络初始化不好。我建议你先把学习率调到 0.01 起步然后观察损失曲线的下降速度。如果损失下降得像乌龟爬再试着翻倍如果损失跳得像过山车就缩小十倍。这个“先定尺度再调细节”的思路比盲目试一堆随机参数高效得多。训练完成后我在测试集上跑了一下准确率大概在 95% 左右。这个数字对一个只有四维输入、两层网络的迷你模型来说已经非常能打了你完全可以通过增加隐藏层宽度到 8 个神经元、增加迭代轮数把准确率推到更高的水平但同时也要注意接下来要提到的过拟合问题。4.3 观察损失曲线训练过程的温度计训练过程中最值得观察的指标就是损失曲线。我把每一轮的平均损失打印出来能看到一开始快速下降随后趋于平缓。这其实是在暗示模型在最初几百步内学到了主要模式后面只是在做精细调整。如果曲线一开始下降很快然后突然反弹上去那大概率是学习率过大导致参数越过最优点来回震荡。这里顺便说一个很多人在初级阶段容易忽略的点训练准确率不是越高越好尤其是当你拿测试集准确率做对比的时候。如果训练集准确率已经 100%但测试集只有 80%说明模型把训练数据的噪声也记住了这就是过拟合。解决过拟合的思路有很多L2 正则化、Dropout、增加数据量都算标准操作。迷你网络在这个任务上不容易过拟合但你在自己的项目里一定会碰到我把排查思路放到后面一节专门展开。整个训练过程加起来在浏览器里跑也就是几十毫秒的事毕竟权重太小计算量有限。如果网络更大、数据更多就要考虑性能优化的问题了。5. 性能优化与浏览器里的实战经验5.1 用 TypedArray 替代普通数组性能差距巨大你注意到我在前面示例代码里用的是普通二维数组这是为了可读性和方便调试。但如果你的数据规模上升比如特征维度变成几千、隐藏层神经元变成几百普通数组的读写效率会明显拖后腿。我建议你在优化阶段把核心矩阵替换成 Float32Array。用一个一维数组存储整个矩阵通常比嵌套数组能省下不小内存访问速度也更快。关键是避免在 for 循环里反复通过matrix[i][j]做两层访问而是直接通过索引arr[i * cols j]来读写。这个改动在我的测试里能带来好几倍的性能提升。还有一个容易被忽略的点频繁创建新数组和临时矩阵会产生大量垃圾回收压力尤其当你跑上千轮训练时内存抖动会很明显。优化方式是尽量复用缓冲区用“双缓冲”策略在每一轮迭代里交替写入固定区域避免每次都从堆里分配新对象。这是游戏开发里很常见的技巧放到神经网络推理和训练里同样有效。5.2 Web Workers别让训练卡住页面浏览器主线程既要处理 DOM 事件、又要跑 JavaScript如果训练循环把主线程占满了用户点击按钮可能半天没有反应体验非常差。解决办法很简单把训练放到 Web Worker 里。Web Worker 是浏览器提供的独立线程可以把训练数据、模型参数、训练过程全部放进去通过 postMessage 和主线程通信。比如每隔十轮训练就向主线程发一条消息带上当前的损失值和测试准确率主线程负责更新可视化图表。这样用户会看到一条平滑下降的损失曲线而页面本身一点都不卡。Node 环境里的 worker_threads 也是同一个思路。我在某个项目里把训练循环放进了 worker_threads再配合 SharedArrayBuffer 传递大数组性能比在主线程里跑提升了不止一点。这个方案唯一的门槛是代码结构要稍微调整把训练过程封装成一个可独立运行的任务但在工程上完全值得。5.3 超参数调优经验关于超参数我想给一张适合新手参考的速查表都是我实际调参时总结的经验超参数调大效果调小效果常见陷阱学习率模型学得快但容易震荡训练变慢损失下降缓慢太高直接发散到 NaN隐藏层宽度拟合能力更强可能过拟合表达力受限太过宽但没有收益迭代轮数训练更充分可能过拟合欠拟合不是越多越好训练集比例训练更充分留出更多测试样本比例失衡影响泛化批次大小梯度更稳定更新更频繁更容易跳出局部最优小 batch 噪声大调参的最高心法其实只有一句话一次只动一个变量。否则一旦结果变差你根本不知道是哪个调整把模型搞崩的。5.4 常见问题排查实录我把自己真实调试过程中踩过的坑整理一下这些情况你在未来的神经网络项目里几乎一定会遇到。第一个坑损失完全不下降。这种情况十有八九是数据没有归一化。有一次我在做一个多特征分类任务忘了对某个特征做归一化那个特征范围是 0 到 1000其他特征都在 0 到 1 之间训练损失直接稳定在两个值附近来回跳。归一化之后同样的参数损失在几十轮内就降到了期望区间。遇到损失不降第一反应永远先检查输入数据的量级。第二个坑训练过程中出现 NaN。我遇到的绝大多数情况都是学习率过大参数更新幅度太大权重开始出现数量级失控最终导致梯度爆炸。排查方法很简单打印每一轮权重的最大值如果发现数值在指数级增长就把学习率调小到原来的十分之一。如果仍然出现 NaN就要检查数据里有没有缺失值或者无穷值。还有一种隐蔽的情况是 log(0) 造成的 NaN在计算交叉熵时如果预测值接近 0 或者 1log 会趋于无穷所以必须加一个小 epsilon 做保护。第三个坑训练集准确率高、测试集准确率低。这是标准的过拟合信号。前面提到过解决办法是正则化。L2 正则化其实就是把权重的平方和加到损失函数里这样权重大小会被约束。我在 JavaScript 里实现起来很简单只需要在每个权重上额外增加惩罚梯度。Dropout 的原理也容易理解每次迭代随机屏蔽一部分隐藏层神经元这样可以迫使网络学习到更鲁棒的特征而不是过度依赖某几个神经元。但 Dropout 对迷你网络来说作用不大你最好等模型规模大到一定程度的再去尝试。第四个坑每个 epoch 的损失在打印时波动很大。这有可能是批次太小的原因小批次的梯度方向差异大导致损失看起来不稳定。全批量训练时则不会有这个问题。如果你用随机梯度下降通常会故意接受这种波动因为它反而帮助模型跳出局部最优点对最终结果往往是好事。第五个坑学习率调了很久总是慢了太慢、快了发散。一个实用的替代方案是用 Adam 优化器。Adam 在每个参数上自适应地调整学习率能容忍更大的基础学习率而且一般不需要额外调整动量。说实话现在我再做从零实现第一步往往会先实现 Adam因为它大概率能让训练稳定住。Adam 的实现并不算难但要考虑动量项和偏差修正代码量会比纯 SGD 多一些。我在这篇里刻意没用 Adam是为了让你清楚看到最朴素的梯度下降在简单任务上已经很够用。5.5 更多工程细节模型保存与加载训练完成后模型权重需要保存下来否则关掉页面一切归零。做法很简单把 W1、b1、W2、b2 序列化成 JSON存进 localStorage 或者 IndexedDB推理前再反序列化填充到一个新网络实例里。由于权重只是几百个浮点数在浏览器里做这套操作几乎毫无压力。如果是更大的模型我建议用 ArrayBuffer 加二进制格式保存体积和读取速度都更好。如果你想把这个网络接到一个稍微现代化一点的工程里可以把矩阵操作用 ES6 的 class 封装得更干净再给不同层类型DenseLayer、ActivationLayer建立统一接口这样以后扩展卷积层或者循环层时整个架构就不会被推翻重来。不过这些都是题外话先把今天的迷你网络跑通再谈模块化会更顺理成章。6. 写在最后的个人心得我最初在浏览器里手写神经网络的时候最大的感受是每一步都充满不确定性矩阵维度对不上、梯度算错、损失发散、NaN 乱跳各种问题轮番来。但当你把每一条链式法则、每一步矩阵乘法都亲手验证过之后那种对“网络在学什么”的掌控感是直接用框架时体会不到的。后来再去读各种深度学习论文看到那些复杂的网络结构我的第一反应不再是“这里面有什么魔法”而是“不过是在这个基础框架上加了几种新的层类型罢了”。有一个小建议特别想说给你听在你理解了一个基础算法后立刻用自己的语言把它写一遍。能用文字把前向神经网络的每一步解释清楚和你真的能把反向传播推通、写出可运行代码之间往往还差着十万八千里。我这里给的全部代码你跑通只是第一步建议你再做三件事一是打印中间层激活值看看特征在隐藏空间里是怎么分布的二是把一个样本的梯度数值手算一遍和程序里对一对三是改一处参数比如去掉 tanh 改用 sigmoid观察训练行为会发生什么变化。把这些折腾完了这篇的核心内容才算真正变成你自己的东西。下一篇我会在这个基础上继续深入聊聊怎么给网络加上 Mini-batch 支持、怎么实现 Adam 优化器以及如何用 WebGL 做简单的并行加速。你先把今天这个迷你网络的每一个角落啃干净下一篇我们直接在高性能的方向上快跑。