训练好的神经网络在你看来是什么对大多数开发者来说它只是一个 API、一组权重文件或者一堆浮点数组。模型调通了能跑出结果了但它内部到底是怎么工作的大多数人并没有真正“看见”过。神经网络的可解释性长期是工业界的痛点不是模型不 work而是当模型 fail 的时候你很难快速判断是哪一层、哪个特征通道出了问题。这个项目的出发点就是围绕“可视化”做一次完整实践构建一个 3D Neural Decode 网站用交互式 3D 场景把神经网络的前向传播过程画出来。你可以拖拽旋转、滚轮缩放、悬停查看每一个神经元节点的激活值把一次抽象的张量计算变成可以被眼睛和鼠标感知的物理空间。这篇文章会完整还原这个项目的技术选型、核心流程、关键代码和踩坑点目标读者是正在做模型可视化、AI 教育产品、可解释性研究或者单纯对 Three.js 感兴趣的开发者。我给你的判断是这种 3D 可视化网站不是玩具它是一个很实用的“认知脚手架”。它不能取代专业的可解释性算法但能显著降低模型理解门槛和教育成本。如果你正在做模型展示、课程 Demo、AI 科普或者内部工具这套方案值得参考。文章后面的代码示例都很短跟着跑一遍你就能得到一个可以继续扩展的 3D 网络可视化基础框架。1. 为什么需要把神经网络“画”出来先看一个真实场景。你训练了一个图像分类模型测试集上准确率不错但某个特定类别的召回率始终不达标。这时你会做什么多数人第一反应是看测试样本、改数据增强、换网络结构但很少有人会去思考模型内部把这一类样本编码成了什么如果能看到模型每一层的激活响应很多排查就不需要靠猜。神经网络本质上是一个分层的特征提取系统原始输入进入第一层经过线性变换和非线性激活进入下一层层层加工后输出预测。每一层的神经元数量、连接权重、偏置参数共同决定了模型的表达能力和行为模式。但这些东西在代码里只是浮点数组在模型文件里只是二进制权重不具备可读性。可视化的第一个价值是建立直觉。当你看到输入图片、隐层特征、最终输出在三维空间中如何流动你会突然理解“模型把高维特征压缩到低维空间”这句话到底在说什么。第二个价值是定位问题。把激活值映射成节点颜色后如果某一层的节点长期不激活说明这一层可能已经失效对应的梯度更新也会很微弱。这种信号在数值日志里也有但视觉上更容易被注意到。传统 2D 神经网络结构图的问题在于节点多了以后整张图变成密密麻麻的线团无法表达激活强度、梯度流动、权重分布这些动态信息。3D 场景恰恰擅长处理这个问题。相机可以自由旋转从不同角度观察网络内部颜色、透明度、粒子动画可以叠加在结构之上形成信息层次用户悬停点击能对单个节点做深度查询。这就是 3D 相比 2D 最本质的差异不是把图画得更炫而是让你能在同一时间看到结构和状态两层信息。2. 核心概念Neural Decode、3D 可视化与可解释性2.1 Neural Decode 到底是什么意思这个项目标题里的 Decode 并不是指某种具体的解码器结构而是把 AI 模型内部的隐藏状态“解码”成人可以理解的形式。换句话说我们要做的是把权重、偏置、激活值这些不直观的数字翻译成三维空间中的节点、连线、颜色和动画。真实世界里解码器在自编码器、扩散模型、生成模型中都存在它是从隐空间还原数据的那部分网络而这里的 Neural Decode 概念更偏向“面向人的可理解性解码”即把模型内部表示转化为人类认知可接收的信息。2.2 在 3D 场景里到底看什么一个完整的神经网络可视化通常包含四层信息网络结构、激活状态、权重强度、梯度流动。这个项目优先实现的是前两层因为结构和激活最容易用 3D 表达也最能帮助用户建立直觉。网络结构指的是层数、每层神经元数量、层与层之间的连接关系。在 3D 场景中每个神经元用一个球体节点表示同一层的节点沿纵轴排列不同层沿横轴展开层间用直线表示连接关系。激活状态指的是输入样本经过前向传播后每个节点的输出值。激活值越大节点颜色越偏向暖色激活值越小节点颜色越偏向冷色。这样的表达方式让一次前向传播变成一个肉眼可见的“脉冲”过程。2.3 2D 与 3D 可视化的对比维度传统 2D 图3D 交互可视化信息密度节点一多就混乱通过旋转分层观察可承载更多节点动态状态很难表达激活、梯度变化颜色、透明度、粒子动画可叠加交互查询通常只支持悬停悬停、点击、拖拽、缩放、动画触发空间理解平面布局层间关系不直观三维空间天然适合分层架构展示开发成本较低较高需要理解三维场景和相机控制适用场景论文插图、快速结构说明教学、展示、模型调试、AI 科普2.4 与可解释性研究的边界行业内讲可解释性通常涉及 Saliency Map、Grad-CAM、SHAP、LIME 等方法。3D 可视化并不和这些方法冲突它更像是把分析结果呈现出来的前端载体。你完全可以在后面的扩展中把 Grad-CAM 生成的注意力区域映射到节点颜色上也可以在训练过程中实时渲染梯度范数。这个项目先解决从 0 到 1 的脚手架问题后续接入什么算法取决于你想解释什么样的模型行为。3. 技术选型与整体架构设计3.1 为什么选 Three.jsThree.js 是 WebGL 领域的事实标准封装了场景、相机、灯光、材质、几何体、渲染循环等核心概念。相比直接写原生 WebGLThree.js 能让开发者把注意力集中在“要画什么”而不是“怎么调用 GPU 绘制”。神经网络可视化需要大量球体、线段、粒子和颜色变化Three.js 的 MeshStandardMaterial、LineBasicMaterial、Raycaster 都能直接满足需求。3.2 为什么用 Vite 做构建工具Vite 启动速度快开发体验好对 ES Module 的支持非常干净。Three.js 从 r160 开始逐步推荐使用模块化引入方式Vite 恰好可以完全承接这种生态无需复杂的 Webpack 配置。如果团队里已经使用 Vue 或 React把渲染器封装成组件后Vite 都能无缝集成。如果你之前用过 three.js 加 Vue 做 3D 场景编辑器这次的思路是同构的只是场景内容从模型文件变成了神经网络结构。3.3 整体架构整个项目按职责拆成四层数据层定义网络结构提供前向传播计算能力。场景层创建 Three.js 场景、相机、灯光、节点和连线。交互层监听鼠标事件处理点击、悬停、缩放。表现层把激活值映射为颜色触发动画显示 Tooltip。数据层和场景层严格分离这样网络结构和渲染方式可以独立演进。你可以在不修改渲染代码的前提下把随机初始化网络替换成训练好的真实模型权重。这也是后期维护中最舒服的结构任何时候想换一种可视化风格只需要操作场景层想接入新的模型结构只需要改数据层。3.4 数据流设计用户点击页面后主线程读取网络结构描述在数据层执行一次前向传播得到每层的激活值数组然后把激活值传递给场景层场景层根据激活值修改节点材质颜色并通过 requestAnimationFrame 渲染。整个过程是单向数据流调试时很容易追踪问题出在计算环节还是渲染环节。4. 环境准备与项目初始化4.1 环境要求项目运行需要 Node.js 环境建议使用 LTS 版本。本文不会写死具体版本号因为不同机器上的环境存在差异核心思路是保证 npm 命令可以正常使用。检查方式node -v npm -v如果命令行能打印出版本号说明环境基础没问题。4.2 初始化项目打开终端创建一个新的项目目录并初始化 npmmkdir my-3d-neural-vis cd my-3d-neural-vis npm init -y npm install three npm install -D vite第一条 npm install 安装 three 核心库第二条安装 Vite 作为开发和构建工具。安装完成后项目根目录会出现 node_modules 文件夹和 package.json 文件。4.3 目录规划为了保持代码清晰建议按下面的目录组织my-3d-neural-vis/ ├── index.html ├── package.json └── src/ ├── main.js ├── network.js └── scene.jsmain.js 负责初始化网络、创建场景、绑定交互network.js 负责网络结构和前向传播计算scene.js 负责 3D 场景渲染。这样的划分即使以后项目膨胀到多个可视化组件基础逻辑依然能复用。5. 核心流程拆解5.1 设计网络结构数据神经网络可视化的第一步不是写渲染代码而是明确网络结构。在这个项目中网络结构用一组层尺寸数组表示const layerSizes [8, 16, 16, 4];它表示输入层 8 个节点两个隐层各 16 个节点输出层 4 个节点。你完全可以换成其他尺寸比如 [784, 256, 128, 10] 就对应 MNIST 分类网络。只要数据描述统一渲染层不需要改代码。5.2 构建 3D 场景与节点连线创建 Three.js 场景后需要把每一层的节点放在合适的位置。常见做法是层与层之间按 x 轴等距排列同一层内部按 y 轴居中排列。这样从侧面看网络结构就是几列纵向排列的球体层间连线清晰可见。每一条连线代表上一层某个节点到下一层某个节点的权重连接。全连接网络中相邻两层节点两两相连所以 8 到 16 层之间会有 128 条线。节点总数不多时用 Line 对象逐条绘制没有问题节点规模扩大后需要考虑合并几何体优化性能这一点在后面的最佳实践部分展开。5.3 前向传播与激活值映射为了让可视化“动起来”需要一个能执行前向传播的最小网络。这个网络不需要具备训练能力只需要给定输入后能够算出每一层的激活值。激活值经过归一化后映射到颜色就能形成视觉上的传播效果。颜色映射规则建议保持统一激活值接近 0 时使用冷色蓝色激活值接近 1 时使用暖色橙红色。这样用户看到蓝色节点可以快速知道它不活跃看到橙色节点就知道它被明显激活。5.4 交互设计3D 可视化网站的交互核心是三件事自由观察、信息查询、触发动画。OrbitControls 负责实现旋转和缩放Raycaster 负责把鼠标位置转换成“点击了哪个节点”的判断点击页面触发一次前向传播让用户主动控制可视化节奏。5.5 对接真实模型数据这个项目本身使用随机权重模拟前向传播但它留给真实模型的数据接口是简单的数组和数组嵌套。如果想展示一个训练好的模型只需要从模型文件中提取每层权重和偏置填充到 network 模块的权重数组里。从 TensorFlow.js 等框架导出模型后权重数据通常是 JSON 或二进制格式你可以写一个数据转换脚本把模型结构映射成 layerSizes 数组把权重映射成嵌套数组。可视化渲染层不需要感知来源它只消费统一的数据结构。6. 完整代码实现下面给出一个可以完整运行的最小实现。所有代码都放在 src 和 index.html 中跑通之后可以自由扩展。6.1 index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title3D Neural Decode - 交互式神经网络探索/title style body { margin: 0; overflow: hidden; font-family: system-ui, sans-serif; } #info { position: absolute; top: 16px; left: 16px; color: #fff; background: rgba(0, 0, 0, 0.6); padding: 8px 14px; border-radius: 6px; pointer-events: none; font-size: 14px; } #tooltip { position: absolute; display: none; color: #fff; background: rgba(20, 20, 40, 0.85); padding: 6px 10px; border-radius: 6px; pointer-events: none; font-size: 13px; z-index: 10; } /style /head body div idinfo3D Neural Decode滚轮缩放 / 拖拽旋转 / 点击页面触发前向传播/div div idtooltip/div script typemodule src/src/main.js/script /body /html这个页面只做两件事提供视觉容器和显示提示信息。真正的地图和场景全部交给 src 下面的模块处理。6.2 src/network.js// 文件路径src/network.js // 一个极简的全连接前向传播模拟器不负责训练只负责生成可视化所需的激活值 export function createSimpleNetwork(layerSizes) { const weights []; const biases []; for (let i 0; i layerSizes.length - 1; i) { const currentLayerWeights []; const currentLayerBiases []; for (let j 0; j layerSizes[i 1]; j) { const neuronWeights []; for (let k 0; k layerSizes[i]; k) { neuronWeights.push((Math.random() - 0.5) * 0.8); } currentLayerWeights.push(neuronWeights); currentLayerBiases.push((Math.random() - 0.5) * 0.2); } weights.push(currentLayerWeights); biases.push(currentLayerBiases); } function sigmoid(x) { return 1 / (1 Math.exp(-x)); } function forward(input) { const activations [input.slice()]; let current input.slice(); for (let i 0; i weights.length; i) { const next []; for (let j 0; j weights[i].length; j) { let sum biases[i][j]; for (let k 0; k weights[i][j].length; k) { sum weights[i][j][k] * current[k]; } next.push(sigmoid(sum)); } current next; activations.push(current.slice()); } return activations; } return { forward, weights, biases }; }这里的重点是 forward 函数。输入是一个长度等于输入层节点数的数组输出是一个二维数组表示每一层的激活值。sigmoid 函数把网络输出压缩到 0 到 1 之间刚好方便做颜色映射。实际项目中可以换 ReLU、tanh 等激活函数不影响渲染逻辑。6.3 src/scene.js// 文件路径src/scene.js import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; export function createNeuralScene(container, layerSizes, layerNames []) { const scene new THREE.Scene(); scene.background new THREE.Color(0x0d1117); const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(14, 8, 18); camera.lookAt(0, 0, 0); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); container.appendChild(renderer.domElement); const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.08; scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const dirLight new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 10, 7); scene.add(dirLight); const grid new THREE.GridHelper(20, 20, 0x2a2f3a, 0x1c2029); scene.add(grid); const layerCount layerSizes.length; const layerSpacing 4; const nodeMeshes []; const nodePositions []; const group new THREE.Group(); layerSizes.forEach((neuronCount, layerIndex) { const x (layerIndex - (layerCount - 1) / 2) * layerSpacing; const ySpacing neuronCount 1 ? 8 / (neuronCount - 1) : 0; const layerMeshes []; const layerPositions []; for (let i 0; i neuronCount; i) { const y neuronCount 1 ? (i - (neuronCount - 1) / 2) * ySpacing : 0; const sphere new THREE.Mesh( new THREE.SphereGeometry(0.35, 24, 24), new THREE.MeshStandardMaterial({ color: 0x4a9eff, emissive: 0x0a1628, roughness: 0.35, metalness: 0.1 }) ); sphere.position.set(x, y, 0); sphere.userData { layer: layerIndex, index: i, layerName: layerNames[layerIndex] || Layer ${layerIndex} }; layerMeshes.push(sphere); layerPositions.push(new THREE.Vector3(x, y, 0)); group.add(sphere); } nodeMeshes.push(layerMeshes); nodePositions.push(layerPositions); }); const lineGroups []; for (let l 0; l layerCount - 1; l) { const lineGroup new THREE.Group(); lineGroups.push(lineGroup); group.add(lineGroup); for (const fromPos of nodePositions[l]) { for (const toPos of nodePositions[l 1]) { const geometry new THREE.BufferGeometry().setFromPoints([ fromPos, toPos ]); const material new THREE.LineBasicMaterial({ color: 0x4a9eff, transparent: true, opacity: 0.18 }); lineGroup.add(new THREE.Line(geometry, material)); } } } scene.add(group); function onResize() { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } window.addEventListener(resize, onResize); function render() { controls.update(); renderer.render(scene, camera); } return { scene, camera, renderer, controls, group, nodeMeshes, lineGroups, render, dispose() { window.removeEventListener(resize, onResize); controls.dispose(); renderer.dispose(); } }; }这段代码是 3D 场景的核心。它创建了相机、灯光、网格辅助线、球体节点和层间连线并把这些对象挂到同一个 group 中。节点位置的计算方式是层与层之间拉开 4 个单位的距离同一层节点在纵向居中排列最大跨度控制在 8 个单位内。这样即使某层有几百个节点画面也不会溢出视野。6.4 src/main.js// 文件路径src/main.js import * as THREE from three; import { createNeuralScene } from ./scene.js; import { createSimpleNetwork } from ./network.js; const layerSizes [8, 16, 16, 4]; const layerNames [输入, 隐层1, 隐层2, 输出]; const network createSimpleNetwork(layerSizes); const view createNeuralScene(document.body, layerSizes, layerNames); const tooltip document.getElementById(tooltip); const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); function activationColor(value) { const c new THREE.Color(); c.lerpColors( new THREE.Color(0x4a9eff), new THREE.Color(0xff6a3d), Math.max(0, Math.min(1, value)) ); return c; } function resetColors() { view.nodeMeshes.forEach((layerMeshes) { layerMeshes.forEach((mesh) { mesh.material.color.set(0x4a9eff); mesh.material.emissive.set(0x0a1628); }); }); } function randomInput(size) { return Array.from({ length: size }, () Math.random()); } function runForward() { const activations network.forward(randomInput(layerSizes[0])); resetColors(); activations.forEach((layerValues, layerIndex) { setTimeout(() { layerValues.forEach((value, neuronIndex) { const mesh view.nodeMeshes[layerIndex][neuronIndex]; if (!mesh) return; const color activationColor(value); mesh.material.color.set(color); mesh.material.emissive.set(color.clone().multiplyScalar(0.25)); }); }, layerIndex * 400); }); } function onPointerMove(event) { pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(pointer, view.camera); const meshes view.nodeMeshes.flat(); const hits raycaster.intersectObjects(meshes, false); if (hits.length 0) { tooltip.style.display none; return; } const hit hits[0].object; const { layer, index, layerName } hit.userData; tooltip.style.display block; tooltip.style.left event.clientX 14 px; tooltip.style.top event.clientY 14 px; tooltip.textContent ${layerName} / 第 ${index 1} 个节点; } window.addEventListener(pointermove, onPointerMove); window.addEventListener(click, runForward); function animate() { view.render(); requestAnimationFrame(animate); } animate(); // 进入页面后先跑一次让用户立刻看到前向传播效果 runForward();main.js 把所有模块串起来。activationColor 函数通过 lerpColors 实现蓝色到橙红色的连续渐变runForward 函数按层依次触发颜色变化每层间隔 400ms形成从输入层流向输出层的动画感。鼠标移动时Raycaster 会检测当前悬停的节点并在附近显示层名和节点序号。6.5 启动项目代码都准备好后在项目根目录执行npm run devVite 启动后终端会打印一个本地地址通常是 http://localhost:5173如果端口被占用Vite 会自动切换端口以终端输出为准。浏览器打开地址就能看到 3D 场景。7. 运行效果与验证启动成功后你应该能看到以下效果页面出现深色背景的 3D 场景中间有四列球体节点从左到右分别是 8 个输入节点、16 个隐层节点、16 个隐层节点、4 个输出节点。节点之间被半透明直线连接网格辅助线铺在底部。页面加载后约 1.6 秒内可以观察到蓝色到橙红色的颜色变化从最左侧开始逐层向右传播到输出层。鼠标拖拽可以旋转视角滚轮可以缩放画面。鼠标悬停在某个球体上会出现一个 Tooltip显示该节点属于哪一层、是第几个节点。鼠标点击页面任意位置前向传播动画会重新执行一次。如果这些现象都出现了说明整个链路已经跑通。判断项目是否真正成功的标准不是“页面显示了什么”而是“用户能不能通过交互理解一次前向传播的流程”。如果点击后颜色只在某一层变化没有逐层传递问题大概率出在 runForward 的 setTimeout 逻辑上如果 Tooltip 不出现优先检查 Raycaster 的 intersectObjects 是否拿空了数组。如果页面白屏第一步不是改代码而是打开浏览器开发者工具切换到 Console 面板看有没有红色的报错。十次白屏有九次是依赖缺失或者路径写错控制台会直接告诉你哪一行出了问题。8. 常见问题与排查方法问题现象可能原因排查方式解决方案页面白屏控制台报 Failed to resolve import依赖没有安装完整检查 node_modules 是否存在 three重新执行 npm install页面加载后没有节点显示JS 模块路径错误检查 src 目录文件是否存在index.html 引用的路径是否正确统一使用 /src/main.js 这样的绝对路径点击页面没有前向传播动画click 监听未绑定或 runForward 报错在 runForward 开头打 console.log 调试确认 activations 数组结构检查 nodeMeshes 是否为空Tooltip 不出现Raycaster 没有命中节点打印 hits.length 看是否大于 0确认 meshes 数组不为空检查相机位置是否靠近节点节点缩放后画面模糊像素比设置不合理检查 setPixelRatio 参数使用 Math.min(window.devicePixelRatio, 2) 限制像素比网络节点很多时页面卡顿球体和线段数量过多打开 Performance 面板观察帧率使用 InstancedMesh 和 LineSegments 合并几何体端口被占用导致启动失败本机已有进程占用默认端口查看终端提示的新端口号使用终端提示的 URL 访问或配置服务器端口旋转视角时画面抖动OrbitControls 阻尼关闭或帧率低检查 controls.enableDamping 是否开启开启阻尼并确保 requestAnimationFrame 持续执行遇到问题不要着急改大段代码先用 console.log 把数据流的每一步打出来网络结构数据、激活值数组、命中节点列表。这些信息一旦明确问题就缩小到了具体模块。9. 最佳实践与工程建议9.1 数据结构与渲染严格解耦这个项目最值得保留的设计是 network.js 和 scene.js 的完全分离。network.js 不知道 Three.js 的存在scene.js 也不关心输入数据是怎么算出来的。后续接真实模型、换激活函数、改网络结构都不会互相影响。在团队项目里这种边界能避免两个人同时改一个文件导致的冲突。9.2 性能优化要提前做预案当前示例只有几十个节点性能没有问题。但如果要展示大模型比如 [784, 256, 128, 10]连线数量会达到几万条普通 Line 对象会拖垮渲染性能。建议在节点规模变大时采用三个手段节点改用 InstancedMesh 一次性绘制连线改用 BufferGeometry 合并后用 LineSegments 渲染控制帧率不需要每帧都重绘。9.3 颜色映射规则要统一激活值到颜色的映射是用户理解可视化的核心语言。建议整个项目只使用一种映射规则并把映射函数独立出来。比如蓝色代表低激活橙红色代表高激活一旦定了就不要在不同页面使用