在做算法落地和 AI 应用开发时,我们经常遇到这种情况:框架调用得很熟练,loss 曲线画得也很漂亮,但模型一旦出现梯度爆炸、训练不收敛、线上推理结果异常,就不知道该从哪一行代码开始排查。说到底,是因为平时只接触“框架 API”这一层,对模型内部的梯度从哪来、参数往哪更新、数值为什么会不稳定,缺少足够的“手感”。
本文围绕AI by Hand这个主题展开,尝试把 AI 中最核心的线性回归、梯度下降、神经网络反向传播,用 Python + NumPy 从零实现一遍。不调用 PyTorch、TensorFlow、sklearn 等现成训练库,让每个数学符号都对应到具体的数组操作上。适合刚学完机器学习基础、想深入理解算法原理的开发者,也适合正在做 AI 工程实践、希望补齐底层认知的读者。学完后,你能用自己的代码训练出一个可运行的线性回归模型和一个能解决 XOR 问题的两层神经网络,并掌握一套通用的梯度排查方法。
1. 什么是“AI by Hand”,为什么要手写一次 AI
1.1 概念理解:不是调 API,而是手动构建算法
“AI by Hand”可以直译为“手工实现 AI”。这里的 AI 并不是指从零训练大模型,也不是自己实现分布式训练框架,而是指通过手写代码、手工推导公式的方式,把机器学习里最基础的模型实现出来。
举个例子。在常规开发流程中,使用 sklearn 训练逻辑回归只需要三行代码:
from sklearn.linear_model import LogisticRegression model = LogisticRegression() model.fit(X_train, y_train)这三行代码能帮我们完成任务,却很难帮助我们回答下面这些问题:
- 为什么训练时 loss 不下降?
- 为什么把学习率从 0.01 改成 0.1 后,loss 反而变成 NaN?
- 神经网络内部每一层到底在做哪些矩阵运算?
- 反向传播里的“链式法则”是如何体现的?
AI by Hand 的练习,就是要通过亲自实现这些细节,把“黑盒”变成“白盒”。当你亲手写过一次梯度下降、手推过一次反向传播后,再去阅读 PyTorch 的loss.backward(),看到的东西会完全不一样。
1.2 在模型部署和智能体时代,底层理解为什么依然重要
现在 AI 领域的热点已经从“怎么训练模型”扩展到“怎么部署模型”“怎么构建 AI Agent”“怎么做 RAG 应用”。搜索和讨论中大量出现 spring ai、ai 工程实践、ai 模型部署、ai coding 等关键词,这容易造成一种错觉:基础算法没那么重要了,会调接口就行。
实际工程里的反馈恰恰相反。
- 当 loss 曲线出现异常时,你需要判断是数据问题、学习率问题,还是梯度计算问题。
- 当模型部署到线上,推理性能不达标时,你需要理解模型计算图、批处理大小和数值精度对结果的影响。
- 当你构建一个 AI Agent 时,模型输出的置信度、上下文窗口内的有效信息占比、每一步调用的耗时与费用,本质上都来自底层模型的推理机制。
如果你对最基本的矩阵运算、梯度下降和损失函数没有直观理解,这些问题很难定位。手写一次“迷你 AI”,是理解上层复杂系统成本最低的方式。
1.3 手写 AI 不等于重复造轮子
这里需要先说明边界:手写 AI 不是为了证明“不用框架也能训练模型”,更不是鼓励生产环境抛弃成熟框架。
生产中我们仍然应该优先使用 sklearn、PyTorch、TensorFlow,以及各类大模型推理框架。手写实现的价值发生在“学习阶段”和“排查阶段”:
- 学习阶段:通过手写理解原理,避免只会调参。
- 排查阶段:当框架结果不符合预期时,能快速定位问题可能出在数据、特征、超参还是模型结构。
所以,这是一项“花一次时间、长期受益”的投资。接下来,我们进入实操环节。
2. 环境准备与实验约定
2.1 运行环境说明
手写 AI 核心只需要 NumPy,不需要 GPU,也不需要安装大型深度学习框架。本文示例以常见环境为例,你本机的具体版本不必完全一致,只要满足以下条件即可:
| 依赖项 | 建议版本 |
|---|---|
| 操作系统 | Windows 10/11、macOS、Linux 均可 |
| Python | 3.9 及以上 |
| NumPy | 1.21 及以上 |
| 开发工具 | VS Code 或 PyCharm |
如果你不确定本机 Python 环境,可以先在终端执行以下命令检查:
python --version如果 Python 版本过低,建议先安装新版 Python,避免某些语法和随机数 API 不兼容。
2.2 创建项目目录与虚拟环境
为了让实验环境干净,建议先创建一个独立目录,并使用虚拟环境。
mkdir ai-by-hand cd ai-by-hand python -m venv venv激活虚拟环境:
- Windows:
venv\Scripts\activate- macOS / Linux:
source venv/bin/activate安装 NumPy:
pip install numpy安装完成后,可以执行一个快速验证:
python -c "import numpy as np; print(np.__version__)"能正常输出版本号,说明环境就绪。
2.3 项目结构规划
本文会实现两类模型:一元线性回归和两层神经网络。建议按功能拆成独立文件,方便后续修改和运行。
ai-by-hand/ ├── linear_regression.py # 手写线性回归 ├── mlp_xor.py # 手写两层神经网络,解决 XOR 问题 └── grad_check.py # 梯度检查工具(可选)本文的重点文件是linear_regression.py和mlp_xor.py。搭建过程不需要刻意追求复杂工程结构,能跑通、能看懂、能改进即可。
3. 手写线性回归:从数学公式到可运行代码
3.1 问题定义与损失函数
线性回归是理解机器学习参数更新流程的最佳入门模型。假设输入特征为x,输出为y,模型表达式是:
y_pred = w * x + b其中:
w是权重,也可以叫斜率。b是偏置,也可以叫截距。y_pred是模型预测值。
为了衡量预测值与真实值之间的差距,我们使用均方误差(Mean Squared Error, MSE):
loss = (1 / m) * sum((y_pred - y)^2)其中m是样本数量。loss 越小,说明模型预测越接近真实值。
3.2 梯度公式推导
训练的目标是找到一组w和b,使得 loss 最小。最常用的方法是梯度下降:沿着损失函数下降最快的方向更新参数。
对loss分别求w和b的偏导,可以得到:
dloss / dw = 2 * mean((y_pred - y) * x) dloss / db = 2 * mean(y_pred - y)这里的mean表示对全部样本取平均。每次迭代,参数按如下方式更新:
w = w - lr * (dloss / dw) b = b - lr * (dloss / db)lr是学习率,控制每一步更新的步长。学习率太大,参数可能来回震荡甚至发散;学习率太小,训练速度会很慢。
3.3 生成实验数据
为了方便验证,我们先构造一组带噪声的线性数据。这里固定随机种子,保证多次运行结果一致。
import numpy as np def make_linear_data(n=100, true_w=2.0, true_b=1.0, noise=0.3, seed=0): rng = np.random.default_rng(seed) X = rng.uniform(-3.0, 3.0, size=(n, 1)) y = true_w * X + true_b + rng.normal(0.0, noise, size=(n, 1)) return X, y X, y = make_linear_data() print(X.shape, y.shape)预期输出显示数据是 100 行 1 列的特征和 100 行 1 列的标签。真实关系是y = 2 * x + 1,只是额外叠加了一些高斯噪声。训练得到的参数如果能接近w=2、b=1,说明模型已经学到数据规律。
3.4 手写梯度下降训练函数
下面这段代码不依赖任何机器学习框架,只使用 NumPy 的数组运算:
import numpy as np def train_linear_regression(X, y, lr=0.05, epochs=300): m = X.shape[0] # 初始化参数 w = 0.0 b = 0.0 for epoch in range(epochs): # 前向计算:预测值 y_pred = w * X + b # 误差与损失 error = y_pred - y loss = np.mean(error ** 2) # 梯度计算 grad_w = 2.0 * np.mean(error * X) grad_b = 2.0 * np.mean(error) # 参数更新 w -= lr * grad_w b -= lr * grad_b if epoch % 50 == 0: print(f"epoch {epoch:3d}, loss {loss:.4f}, w {w:.4f}, b {b:.4f}") return w, b if __name__ == "__main__": X, y = make_linear_data() w, b = train_linear_regression(X, y) print(f"final w={w:.4f}, b={b:.4f}")代码里每一步都很直观:
y_pred = w * X + b:计算当前参数下所有样本的预测值。loss = np.mean(error ** 2):计算均方误差。grad_w和grad_b:对应前面推导出的梯度公式。w -= lr * grad_w:完成参数更新。
3.5 运行结果与验证
保持默认参数运行,可以看到 loss 从最初的高位逐渐下降,w和b逐步逼近真实值。不同 NumPy 版本下结果会有微小差异,但整体趋势一致,最终参数大约在以下范围:
w ≈ 1.96 ~ 2.05 b ≈ 0.95 ~ 1.05当噪声方差较小时,loss 会接近噪声本身的方差,约0.09左右。如果 loss 已经降到几乎不再变化,说明模型已经收敛。
可以继续验证模型预测能力:
X_test = np.array([[0.0], [1.0], [-1.0]]) y_test = w * X_test + b print(y_test)x=0时预测值应接近b,x=1时预测值应接近w+b。
到这里,我们完成了第一个“AI by Hand”模型。你会发现,整个训练过程本质上就是“前向计算、计算损失、反向求梯度、更新参数”四步循环。下一节,我们将这套流程推广到含隐藏层的神经网络。
4. 手写神经网络:前向传播与反向传播
4.1 为什么线性模型不够:经典 XOR 问题
很多人在学神经网络时会遇到一个经典案例:XOR(异或)问题。它的样本如下:
| 输入 x1 | 输入 x2 | 输出 y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
这四个点在二维平面上是无法用一条直线划分的。无论线性回归还是逻辑回归,都无法直接解决这个非线性分类问题。要解决它,模型必须具备“非线性表达能力”,也就是在输入和输出之间加入隐藏层,并通过激活函数引入非线性。
接下来,我们要手写一个两层神经网络:
- 输入层:2 个节点,对应 x1、x2。
- 隐藏层:4 个节点,激活函数使用 tanh。
- 输出层:1 个节点,激活函数使用 sigmoid,输出二分类概率。
4.2 网络结构与符号约定
先约定网络中的符号:
X:输入矩阵,形状为 (m, 2),m 是样本数 W1:第一层权重,形状为 (2, 4) b1:第一层偏置,形状为 (1, 4) W2:第二层权重,形状为 (4, 1) b2:第二层偏置,形状为 (1, 1)前向传播过程如下:
Z1 = X @ W1 + b1 A1 = tanh(Z1) Z2 = A1 @ W2 + b2 A2 = sigmoid(Z2)其中:
@是矩阵乘法。tanh是双曲正切激活函数,输出范围在 -1 到 1 之间。sigmoid将输出压缩到 0 到 1 之间,可以解释为概率。
对于二分类问题,损失函数使用二元交叉熵(Binary Cross Entropy, BCE):
loss = -mean(y * log(A2) + (1 - y) * log(1 - A2))4.3 参数初始化
参数初始化会影响网络能否收敛。这里使用较小的随机数来打破对称性,偏置初始化为 0。
import numpy as np def initialize_parameters(n_input=2, n_hidden=4, n_output=1, seed=42): rng = np.random.default_rng(seed) W1 = rng.uniform(-0.5, 0.5, size=(n_input, n_hidden)) b1 = np.zeros((1, n_hidden)) W2 = rng.uniform(-0.5, 0.5, size=(n_hidden, n_output)) b2 = np.zeros((1, n_output)) return W1, b1, W2, b2为什么初始化要随机?
如果所有权重初始化为相同值,那么同一层内每个神经元的输入和梯度完全相同,更新后依然相同,隐藏层就退化成一个神经元,表达能力大打折扣。随机初始化是打破这种对称性的必要手段。
4.4 前向传播与损失函数实现
def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def forward(X, W1, b1, W2, b2): Z1 = X @ W1 + b1 A1 = np.tanh(Z1) Z2 = A1 @ W2 + b2 A2 = sigmoid(Z2) cache = (Z1, A1, Z2, A2) return A2, cache def compute_bce_loss(A2, y): eps = 1e-12 A2 = np.clip(A2, eps, 1.0 - eps) loss = -np.mean(y * np.log(A2) + (1.0 - y) * np.log(1.0 - A2)) return loss这里对A2做了clip,是因为log(0)会得到负无穷。加上一个极小值eps,可以避免数值问题。
4.5 反向传播:梯度公式与代码实现
反向传播的核心是链式法则。我们从输出层开始,逐层往回求出每个参数的梯度。
对于二元交叉熵损失 + sigmoid 输出层,有一个非常简洁的结果:
dZ2 = (A2 - y) / m其中m是样本数。这个式子的含义是:输出层的误差信号等于预测值与真实值的差,再除以样本数。
然后往隐藏层传播:
dW2 = A1^T @ dZ2 db2 = sum(dZ2, axis=0, keepdims=True) dA1 = dZ2 @ W2^T dZ1 = dA1 * (1 - A1^2) # tanh 的导数是 1 - tanh^2 dW1 = X^T @ dZ1 db1 = sum(dZ1, axis=0, keepdims=True)这里(1 - A1^2)就是 tanh 激活函数在A1处的导数。代码实现如下:
def backward(X, y, W2, cache): Z1, A1, Z2, A2 = cache m = X.shape[0] dZ2 = (A2 - y) / m grad_W2 = A1.T @ dZ2 grad_b2 = np.sum(dZ2, axis=0, keepdims=True) dA1 = dZ2 @ W2.T dZ1 = dA1 * (1 - A1 ** 2) grad_W1 = X.T @ dZ1 grad_b1 = np.sum(dZ1, axis=0, keepdims=True) return grad_W1, grad_b1, grad_W2, grad_b2这里有一个容易混淆的点:为什么dZ2除以了m?
因为损失函数是对每个样本的交叉熵取平均,反向传播得到的梯度必须对应“平均损失”的梯度。如果不除以m,相当于在放大梯度,学习率需要相应调小。两种写法都能训练,但需要保持前向损失和反向梯度的一致性。
4.6 参数更新与训练循环
参数更新的规则与线性回归完全一致:用梯度乘以学习率,然后从原参数中减去。
def train_mlp(X, y, hidden_size=4, lr=0.5, epochs=2000, print_every=200): W1, b1, W2, b2 = initialize_parameters(n_input=2, n_hidden=hidden_size, n_output=1) for epoch in range(epochs): A2, cache = forward(X, W1, b1, W2, b2) loss = compute_bce_loss(A2, y) grad_W1, grad_b1, grad_W2, grad_b2 = backward(X, y, W2, cache) W1 -= lr * grad_W1 b1 -= lr * grad_b1 W2 -= lr * grad_W2 b2 -= lr * grad_b2 if epoch % print_every == 0: print(f"epoch {epoch:4d}, loss {loss:.6f}") return W1, b1, W2, b2这里使用了固定学习率,没有做学习率衰减,也没有引入动量。对于这个小规模问题已经足够。
5. 完整实战:用两层神经网络解决 XOR 分类
5.1 构造数据
将四个样本整理成 NumPy 数组:
def make_xor_data(): X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float) y = np.array([[0], [1], [1], [0]], dtype=float) return X, y需要注意,这里只有 4 个样本。在真实项目中,4 个样本远远不够,但 XOR 问题作为“最小非线性分类演示”非常适合,因为我们可以人工核对每一个样本的预测结果。
5.2 完整代码:mlp_xor.py
为了方便复现,把前面所有函数整合成一个完整文件。
# 文件路径:ai-by-hand/mlp_xor.py import numpy as np def make_xor_data(): X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float) y = np.array([[0], [1], [1], [0]], dtype=float) return X, y def initialize_parameters(n_input=2, n_hidden=4, n_output=1, seed=42): rng = np.random.default_rng(seed) W1 = rng.uniform(-0.5, 0.5, size=(n_input, n_hidden)) b1 = np.zeros((1, n_hidden)) W2 = rng.uniform(-0.5, 0.5, size=(n_hidden, n_output)) b2 = np.zeros((1, n_output)) return W1, b1, W2, b2 def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def forward(X, W1, b1, W2, b2): Z1 = X @ W1 + b1 A1 = np.tanh(Z1) Z2 = A1 @ W2 + b2 A2 = sigmoid(Z2) cache = (Z1, A1, Z2, A2) return A2, cache def compute_bce_loss(A2, y): eps = 1e-12 A2 = np.clip(A2, eps, 1.0 - eps) loss = -np.mean(y * np.log(A2) + (1.0 - y) * np.log(1.0 - A2)) return loss def backward(X, y, W2, cache): Z1, A1, Z2, A2 = cache m = X.shape[0] dZ2 = (A2 - y) / m grad_W2 = A1.T @ dZ2 grad_b2 = np.sum(dZ2, axis=0, keepdims=True) dA1 = dZ2 @ W2.T dZ1 = dA1 * (1 - A1 ** 2) grad_W1 = X.T @ dZ1 grad_b1 = np.sum(dZ1, axis=0, keepdims=True) return grad_W1, grad_b1, grad_W2, grad_b2 def train_mlp(X, y, hidden_size=4, lr=0.5, epochs=3000, print_every=500): W1, b1, W2, b2 = initialize_parameters(n_input=2, n_hidden=hidden_size, n_output=1) for epoch in range(epochs): A2, cache = forward(X, W1, b1, W2, b2) loss = compute_bce_loss(A2, y) grad_W1, grad_b1, grad_W2, grad_b2 = backward(X, y, W2, cache) W1 -= lr * grad_W1 b1 -= lr * grad_b1 W2 -= lr * grad_W2 b2 -= lr * grad_b2 if epoch % print_every == 0: print(f"epoch {epoch:4d}, loss {loss:.6f}") return W1, b1, W2, b2 def predict(X, W1, b1, W2, b2, threshold=0.5): A2, _ = forward(X, W1, b1, W2, b2) return (A2 >= threshold).astype(int), A2 if __name__ == "__main__": X, y = make_xor_data() W1, b1, W2, b2 = train_mlp(X, y) pred, prob = predict(X, W1, b1, W2, b2) print("\n预测结果:") for i in range(X.shape[0]): print(f"输入 {X[i]},真实 {y[i][0]},预测 {pred[i][0]},概率 {prob[i][0]:.4f}") accuracy = np.mean(pred.flatten() == y.flatten()) print(f"\n准确率:{accuracy * 100:.1f}%")5.3 运行结果与说明
在终端运行:
python mlp_xor.py预期输出如下趋势:
epoch 0, loss 0.705648 epoch 500, loss 0.045136 epoch 1000, loss 0.011537 ...最终打印四个样本的预测结果,每个样本预测值与真实值一致时,准确率为 100%。由于初始化时固定了随机种子,同一环境下的多次运行结果一致;不同 NumPy 版本可能有小数点后的细微差异,关键看 loss 是否持续下降、最终准确率是否接近 100%。
这个例子验证了一个重要结论:仅增加一个隐藏层,网络就能通过 tanh 激活函数获得非线性分类能力,从而解决线性模型无法处理的 XOR 问题。
6. 常见问题与排查思路
手写神经网络最容易出的问题不在“写代码”,而在“对不上号”。下面整理几个高频问题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| loss 下降非常慢 | 学习率太小,或特征没有归一化 | 调大学习率;对输入做标准化处理 |
| loss 变成 NaN | 学习率过大,参数更新步长过大 | 调小学习率,减小参数初始化范围 |
| 网络无法拟合 XOR | 隐藏层节点过少,或卡在局部最优 | 增加隐藏层节点数,调整随机种子 |
| 手推梯度与代码不一致 | 某个中间变量形状或公式写错 | 使用数值梯度做差分验证 |
| 训练集准确率高但泛化差 | 模型过拟合,样本量太少 | 增加数据、加入正则化或早停 |
6.1 loss 不下降或下降慢
先检查学习率。以线性回归为例,学习率从 0.05 调小到 0.001 后,300 轮迭代可能还远远没有收敛;调大到 0.5 后,又可能震荡。可以按 10 倍为单位搜索,先粗后细。
其次是数据尺度。如果输入特征取值范围差异很大,例如有的在 0~1,有的在 1000~10000,梯度会被大数值特征主导。建议对每个特征做标准化,使均值为 0、方差为 1。
6.2 loss 变成 NaN
NaN 通常意味着数值溢出。最常见的原因是学习率过大,导致参数在某一步更新后,前向传播的中间值变得非常大,exp或tanh运算产生溢出。
排查顺序:
- 先把学习率降到原来的十分之一,看 loss 是否恢复。
- 检查参数初始化范围是否过大。
- 检查数据中是否存在无穷大或缺失值。
在真实训练中,梯度裁剪是常用手段。例如把梯度的 L2 范数限制在一个阈值内:
grad_norm = np.linalg.norm(grad_W1) max_norm = 1.0 if grad_norm > max_norm: grad_W1 = grad_W1 * max_norm / grad_norm这里的原理是:当梯度异常大时,不直接使用原始梯度,而是先按比例缩小到安全范围,避免参数一步更新过远。
6.3 梯度检查:用数值梯度验证反向传播
手写反向传播极易出错。一个非常有效的验证方法是“数值梯度”:用中心差分近似计算梯度,然后与反向传播得到的解析梯度对比。如果两者相差非常小,说明反向传播公式和实现大概率正确。
# 文件路径:ai-by-hand/grad_check.py import numpy as np from mlp_xor import make_xor_data, compute_bce_loss, forward, backward def numerical_grad_W2(X, y, W1, b1, W2, b2, epsilon=1e-6): grad = np.zeros_like(W2) it = np.nditer(W2, flags=["multi_index"]) while not it.finished: idx = it.multi_index old_val = W2[idx] W2[idx] = old_val + epsilon A2_plus, _ = forward(X, W1, b1, W2, b2) loss_plus = compute_bce_loss(A2_plus, y) W2[idx] = old_val - epsilon A2_minus, _ = forward(X, W1, b1, W2, b2) loss_minus = compute_bce_loss(A2_minus, y) grad[idx] = (loss_plus - loss_minus) / (2 * epsilon) W2[idx] = old_val it.iternext() return grad if __name__ == "__main__": X, y = make_xor_data() W1 = np.random.default_rng(0).uniform(-0.5, 0.5, size=(2, 4)) b1 = np.zeros((1, 4)) W2 = np.random.default_rng(1).uniform(-0.5, 0.5, size=(4, 1)) b2 = np.zeros((1, 1)) A2, cache = forward(X, W1, b1, W2, b2) grad_analytical = backward(X, y, W2, cache)[2] grad_numerical = numerical_grad_W2(X, y, W1, b1, W2, b2) diff = np.max(np.abs(grad_analytical - grad_numerical)) print(f"解析梯度与数值梯度最大差异:{diff:.10f}")如果输出差异在1e-6到1e-8之间,说明梯度实现正确。如果差异达到0.1甚至更大,基本可以断定反向传播公式或代码有误。
6.4 防止“看起来收敛但实际没学会”
如果 network 在训练集上准确率很高,但在新样本上表现差,不要急着调整网络结构,先确认是不是数据量太少或分布偏差。XOR 实验中只有 4 个点,模型很容易“记住”这 4 个点。真实项目中,建议划分训练集、验证集和测试集,并观察验证集上的 loss 变化。
6.5 手写实验的统一排查清单
按照以下顺序检查,能覆盖大多数新手问题:
- 前向传播的输出形状是否符合预期。
- 损失函数是否在合理范围。
- 初始参数的随机种子是否固定,便于复现。
- 梯度方向是否正确:可以固定一个参数做小范围扰动,观察 loss 变化方向。
- 学习率和参数初始化范围是否匹配。
- 使用数值梯度验证每个参数的梯度。
7. 从手写模型回到 AI 工程实践
7.1 手写模型在生产中的真实定位
很多人会问:既然工作中都用现成框架,手写模型到底有什么用?
一个很实际的作用是“做基线”。在接到一个新的分类或回归任务时,与其立刻训练大模型,不如先用简单模型跑通流程,得到一组可解释的基线指标。手写过线性回归后,你会清楚地知道哪些因素影响结果:特征是否标准化、学习率是否合适、数据噪声有多大。
另一个作用是“排查线上异常”。当线上服务返回的结果突然变差,如果业务逻辑里包含规则模型、线性模型和神经网络模型,先分析哪一层输出异常会快得多。手写实现时积累的“逐层打印中间变量”习惯,在复杂系统排错中同样适用。
7.2 模型部署与 AI Agent 场景中的底层概念
在当前的 AI 工程实践里,模型部署和 AI Agent 被讨论得越来越多。这类系统的复杂度不在于“训练”本身,而在于把模型输出接入业务逻辑后的稳定性。
举个例子:当你调用一个模型服务时,如果输入没有做标准化,推理结果的分布可能偏移。如果服务端的批处理大小设置不合理,耗时和内存占用都会异常。如果在一个 Agent 工作流里连续多次调用模型,单次调用的延迟会被叠加放大。这些问题的排查,最终都会回到对模型输入、输出、推理过程的基本理解上。
手写一次神经网络,并不能让你直接掌握部署工具链,但能让你在排查时知道应该看哪些中间量。
7.3 给自己的三步练习路线
如果你刚开始接触 AI by Hand,建议按下面的顺序推进:
第一步:复现线性回归和逻辑回归。用 NumPy 手写梯度下降,先在合成数据上训练,再换到真实小数据集上实验。
第二步:手写两层神经网络并做梯度检查。一定要运行数值梯度对比脚本,确认反向传播正确。
第三步:迁移到框架视角。使用 PyTorch 或 sklearn 实现同一个任务,把框架输出的梯度与手写梯度做对比。当你发现两者一致时,对“框架只是自动求导工具”这句话会有更深的感受。
更进一步,可以尝试加入不同的激活函数、优化器(如 Momentum、Adam)、正则化手段,观察它们对训练曲线的影响。这些实验在框架里只需要改一行参数,但如果理解底层原理,调参就不再是“试运气”。
对手写 AI 的开发者来说,比较重要的不是把每一行代码都背下来,而是建立“从数学公式到代码实现”的映射能力。当你看到loss.backward()时,能意识到它内部做了链式法则的逐层计算;当你看到optimizer.step()时,能意识到它是按学习率更新参数。这种理解,会在你真正排查模型问题时提供巨大帮助。
留一个可以继续动手的小任务:把本文神经网络的 tanh 激活函数改成 ReLU,观察训练曲线如何变化。注意 ReLU 在负数区域的导数为 0,你需要单独处理导数分支,并思考“神经元死亡”现象与梯度传播的关系。改完代码之后,你对手写神经网络的理解会比单纯读完这篇文章更深一层。