选深度学习框架,最有用的问题不是“谁更强”,而是:同一个模型、同一组参数、同一种损失,在框架里究竟计算了什么?如果连输入维度、权重方向和梯度归约都没有对齐,两份代码即使都能训练,也不是公平比较。
2026-09-08 修订:本文加入实际运行的 PyTorch / TensorFlow CPU 对照,纠正旧版关于 GPU 必然加速、梯度必须逐批清零、文件扩展名代表固定内容等过度概括。这里没有测 GPU 性能或模型排名,也不把教学实验写成生产经历。
一、框架替你做什么,不能替你决定什么
框架提供张量运算、自动微分、层、优化器和设备执行接口。自动微分沿实际执行的计算求导,不会替你判断标签是否对齐、损失分母是否正确、数据是否泄漏。错误的目标函数同样可以得到正确的导数。
下面用一个三样本、两输入、三输出的线性层建立可检查的约定。样本按行排列,使用 float64 和 CPU;目标是九个输出误差平方的平均。它与矩阵微积分教程中的按列排样本、半平方误差按样本平均不同,迁移代码时必须显式转换。
X = [[1, 2], [-1, 0.5], [0.25, -2]] # (B=3, D=2)
W = [[0.2,-0.4], [0.7,0.3], [-0.3,0.5]] # (M=3, D=2)
b = [0.05, -0.1, 0.1]
Y = [[0.4,-0.2,0.1], [-0.5,0.5,-0.3], [0.2,0,0.7]]
prediction = X @ W.T + b # (3, 3)
loss = np.mean((prediction - Y)**2)
第一行预测为 [-0.55, 1.20, 0.80]。三行平方误差和分别为 3.3525、2.2475、3.57125,所以损失精确值是 7337/7200,约 1.019027778。固定这些数字之后,才有条件比较框架行为。
二、Linear 与 Dense:名字相似,权重方向不同
| 检查项 | 本例的对应关系 |
|---|---|
| 输入和输出 | 两边输入均为 3×2,输出均为 3×3;不让两边各自随机初始化后直接比结果。 |
| PyTorch | nn.Linear(2, 3) 的 weight 为 3×2,计算 X @ weight.T + bias。 |
| TensorFlow | Dense(3) 的 kernel 为 2×3,计算 X @ kernel + bias。因此本例赋值为 kernel = W.T;先 build,再 set_weights。 |
| 梯度对照 | 比较参数梯度前,把 TensorFlow 的 kernel 梯度转置回 W 的方向。偏置梯度为长度 3 的向量,输入梯度为 3×2。 |
接口约定见 PyTorch Linear 和 TensorFlow Dense。本例故意不用方形权重:3×2 与 2×3 的混用容易被形状检查发现;方阵形状相同,错误转置反而可能悄悄通过。
三、一份能独立运行的双框架对照
下载框架行为实验包后,在解压目录执行下面的命令。本次验证环境是 arm64 macOS、Python 3.13.9、NumPy 2.3.5、PyTorch 2.14.0、TensorFlow 2.21.0、Keras 3.15.1。安装包较大,不需要另行下载训练数据。
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python compare_layers.py
.venv/bin/python audit_frameworks.py --output reproduced
两个成功标记分别为 FRAMEWORK_COMPARE_OK 和 FRAMEWORK_AUDIT_OK。其他系统先确认所选 Python、系统架构和框架版本有兼容安装包;这些固定版本不是所有设备的通用安装方案。
展开完整 compare_layers.py 程序
"""Compare one fixed CPU linear layer, not framework speed or general accuracy."""
import json
import numpy as np
import tensorflow as tf
import torch
def fixture():
return tuple(np.array(a, dtype=np.float64) for a in (
[[1.0, 2.0], [-1.0, 0.5], [0.25, -2.0]],
[[0.2, -0.4], [0.7, 0.3], [-0.3, 0.5]],
[0.05, -0.1, 0.1],
[[0.4, -0.2, 0.1], [-0.5, 0.5, -0.3], [0.2, 0.0, 0.7]]))
def torch_layer(W, b):
layer = torch.nn.Linear(2, 3, dtype=torch.float64, device='cpu')
with torch.no_grad():
layer.weight.copy_(torch.from_numpy(W))
layer.bias.copy_(torch.from_numpy(b))
return layer
def tensorflow_layer(W, b):
layer = tf.keras.layers.Dense(3, dtype='float64',
kernel_initializer='zeros', bias_initializer='zeros')
layer.build((None, 2))
layer.set_weights([W.T, b])
return layer
def run_torch(X, W, b, Y):
layer = torch_layer(W, b)
x = torch.tensor(X, dtype=torch.float64, requires_grad=True)
prediction = layer(x)
loss = ((prediction - torch.from_numpy(Y)) ** 2).mean()
loss.backward()
return {'prediction': prediction.detach().numpy(), 'loss': loss.item(),
'dW': layer.weight.grad.numpy(), 'db': layer.bias.grad.numpy(),
'dX': x.grad.numpy()}
def run_tensorflow(X, W, b, Y):
with tf.device('/CPU:0'):
layer = tensorflow_layer(W, b)
x = tf.Variable(X, dtype=tf.float64)
with tf.GradientTape() as tape:
prediction = layer(x)
loss = tf.reduce_mean((prediction - Y) ** 2)
dK, db, dX = tape.gradient(loss, [layer.kernel, layer.bias, x])
return {'prediction': prediction.numpy(), 'loss': float(loss.numpy()),
'dW': dK.numpy().T, 'db': db.numpy(), 'dX': dX.numpy()}
def main():
X, W, b, Y = fixture()
prediction = X @ W.T + b
error = prediction - Y
G = 2 * error / error.size
reference = {'prediction': prediction, 'loss': float(np.mean(error ** 2)),
'dW': G.T @ X, 'db': G.sum(axis=0), 'dX': G @ W}
report = {'loss_definition': 'mean over all 9 squared output errors',
'numpy_loss': reference['loss'], 'frameworks': {}}
for name, run in (('pytorch', run_torch), ('tensorflow', run_tensorflow)):
result = run(X, W, b, Y)
errors = {}
for key, expected in reference.items():
np.testing.assert_allclose(result[key], expected, atol=1e-12, rtol=1e-12)
errors[key] = float(np.max(np.abs(result[key] - expected)))
report['frameworks'][name] = {'loss': result['loss'], 'max_abs_errors': errors}
print(json.dumps(report, indent=2))
print('FRAMEWORK_COMPARE_OK')
if __name__ == '__main__':
main()
程序显式覆盖两边全部参数。PyTorch 使用 backward() 将梯度写入叶子张量的 .grad;TensorFlow 在 GradientTape 内记录前向,再用 gradient() 返回所求导数。输入在这里也被跟踪,所以不只验证 W 和 b。
本文按元素平均平方误差,因而 G = 2E/(BM),dW = G.T @ X,db = G.sum(axis=0),dX = G @ W。自动求导结果先与这组 NumPy 公式比,再由独立的分数标量循环复核,避免只让两个框架相互“作证”。
四、实际结果和检查边界
| 实验 | 本次观察 |
|---|---|
| 独立参考 | 每个框架检查 9 个预测、1 个损失、6 个权重梯度、3 个偏置梯度和 6 个输入梯度,共 50 项数值对照。相对精确分数参考,最大绝对差约为 4.44e-16;断言容差为绝对、相对各 1e-12。 |
| 20 次更新 | 两边使用普通 SGD、学习率 0.125。训练 MSE 从 1.019027778,经第 1 次更新后的 0.712134108,降至第 20 次的 0.041723010。记录的 21 组预测最大跨框架差为 0。 |
| 保存再加载 | PyTorch state_dict 加载到重新创建的同结构层;Keras 内置层模型保存为 .keras 再加载。两边对固定输入的重载前后最大预测差均为 0。 |
这是同一组教学输入上的结果,不证明所有模型、设备和精度都逐位一致;训练损失下降也不等于测试集效果改善。本次没有分类数据、独立测试集、GPU 计时、导出运行时对照或优化器断点续训实验。

查看原尺寸实验图;逐项对照 CSV;21 行训练记录;环境、源码哈希和检查结果 JSON。
五、比记住 API 更重要的三个反例
反例 1:不清梯度不一定错,但必须知道在累积什么
在参数不更新的情况下,对同一批数据重新前向并反传两次,PyTorch 参数梯度确实成为一次梯度的两倍。本例九个参数坐标的实测差为 0。清零应放在你定义的“有效批次”边界,不能仅凭某个小批次里没有 zero_grad() 就判定训练错误。
将三行数据拆成大小为 2 和 1 的两个小批次时,正确目标为 (2/3)L_two + (1/3)L_one。各自 mean 后再简单除以 2,会过度加权最后一个样本。本次错误梯度与整批梯度的最大绝对差为 0.277777778;按样本数加权后的最大差为 0。这个等价关系针对本例的可分解均方损失,不能直接推广到含跨样本统计、不同随机状态或其他批次耦合行为的模型。
反例 2:标签广播能让错误结果看起来更好
把标签从 3×3 错写为 Y[:, :1] 的 3×1,本文两种框架表达式仍然执行,损失变成约 0.607361111,比正确值还小。它比较的是另一组重复标签,并不是模型突然变好了。先断言预测与标签形状完全一致,再考虑是否允许业务上有意设计的广播。
反例 3:输入梯度为 None,不等于数值为零
在 TensorFlow 中,未显式 watch 的常量输入不会自动成为求导源。本次 tf.constant(X) 的输入梯度返回 None;加入 tape.watch(x) 后恢复,并与 PyTorch 输入梯度一致。None 表示这次请求没有得到相应的导数,不应直接当零使用。更多边界见TensorFlow 自动微分指南。
六、怎样选框架,而不是选阵营
若目标是复现某个公开模型,先采用该项目明确支持的框架和锁定版本,跑通其最小测试;若要接入已有服务,先核对目标运行时的算子、动态形状和设备支持。不要用“研究一定选 A、工业一定选 B”代替项目约束。
学习时可以先写一轮“取数据、前向、损失、反传、更新”,看清梯度和状态如何改变。但 fit() 不是无法调试的黑盒:Keras 既支持手写训练循环,也支持自定义层与模型。本实验用的 Keras 是 TensorFlow 后端,不代表 Keras 只支持一个后端。
同样,不能把 TensorFlow 等同于静态图、把 PyTorch 等同于只能即时执行。TensorFlow 2 默认 eager,可用 tf.function 构造图;PyTorch 的编译与导出也有各自约束。官方已标记 TorchScript 弃用,新项目应先核对 torch.export 和目标运行时文档,而不是机械沿用旧教程的 trace 命令。本轮没有运行导出或编译实验。
七、保存权重、恢复训练、部署不是同一个验收
.pt 只是扩展名,不能据此判断文件里有没有结构。本文明确调用 torch.save(layer.state_dict(), ...),保存的是状态字典;加载时重新构造兼容层。其他保存方法有不同的内容和依赖,不能一概写成“.pt 不能独立分发”。参见PyTorch 序列化说明。
本次 Keras .keras 重载使用内置层,未覆盖自定义对象注册等情形,具体约定见Keras 保存与序列化。两种重载检查都只读取本实验刚生成的本地文件;不要把加载参数中的安全选项理解成任意外来模型都可信。
如果需要断点续训,还要验证优化器、调度器、随机数和数据迭代状态;如果需要部署,则要验证实际运行时的输入输出、算子和数值误差。Python 服务可以直接使用框架,不是所有部署都必须先转 ONNX。ONNX、GGUF 或硬件专用产物也不是所有网络都能互换的通用容器。
可继续读浏览器 ONNX 部署检查和量化验证的取样偏差。这些是后续问题的入口,不是本次实验已完成相应验证的证据。
八、没有 CUDA 也能先完成正确性检查
CPU 足够运行本例。GPU 是否更快取决于数据规模、算子、传输和同步开销,需要在真实任务上测量,不能承诺改一行就快几十倍。torch.cuda.is_available() 为 False 只说明当前进程无法使用 CUDA,不说明 CPU 不可用,也不说明所有 GPU 后端都不可用;Apple 设备另有 MPS 后端。
展开不会在无 CUDA 时查询设备 0 的环境检查
"""Report availability without querying a CUDA device when none is available."""
import json
import tensorflow as tf
import torch
def report():
cuda = torch.cuda.is_available()
mps = getattr(torch.backends, 'mps', None)
return {
'torch_version': torch.__version__,
'torch_cuda_build': torch.version.cuda,
'cuda_available': cuda,
'cuda_device': torch.cuda.get_device_name(0) if cuda else None,
'mps_built': bool(mps and mps.is_built()),
'mps_available': bool(mps and mps.is_available()),
'tensorflow_version': tf.__version__,
'tensorflow_gpus': [d.name for d in tf.config.list_physical_devices('GPU')],
'tutorial_execution_device': 'CPU',
}
if __name__ == '__main__':
print(json.dumps(report(), indent=2))
本次运行记录为 CUDA 不可用、CUDA 构建版本为空、MPS 已构建但当前进程不可用、TensorFlow GPU 列表为空;两个框架的 CPU 实验仍全部通过。它只描述这次环境,不代表所有同型号 Mac 的状态。安装或驱动不匹配需要结合完整错误信息排查,不要只凭一个布尔值重装全部环境。
实验包 README 提供文件说明、可选绘图步骤和完整的本次依赖记录。完成对照后,可以修改一项约定再观察检查在哪一步失败:交换 kernel 方向、改变损失分母,或调整小批次大小。先解释差异,再决定换工具;这是从“会调用层”走向“能定位训练问题”的关键。