数学教材Python源码NumPy/Matplotlib报错修复指南
简介:《程序员数学》配套 Python 源码与练习包,面向希望用编程学透线性代数、微积分的学习者与开发者,对应书中向量矩阵、行列式、特征值、线性变换、极限导数与积分等章节,并对原书示例中的错误和 bug 作了修正,便于边读边跑、对照验证。压缩包共 104 个文件,约 13.55MB,以 74 个 py 脚本和 17 个 ipynb 笔记为主,辅以少量 jpg 图示、off 文件及 md 说明,覆盖逐章演练与可视化实验。已有 1653 人学习下载。读者可直接获得可运行的章节代码、交互式笔记本和修正后的排错参考,在 numpy、scipy、sympy、matplotlib 环境中复现矩阵运算、符号求导与函数绘图,减少踩坑并加深对抽象数学概念的理解。
1. 从一行 AttributeError 说起:数学书的配套代码为什么一跑就崩
跑过程序员数学配套源码的人基本都遇到过这种场面:照着书上第几页找到对应文件,双击运行,屏幕上甩出一行红字AttributeError: module 'numpy' has no attribute 'float'。线性代数的推导明明看懂了,微积分公式也抄对了,代码却连一个矩阵都构造不出来。这类故障几乎全部集中在三块:NumPy 接口随版本变动、矩阵形状和书上推导对不上、绘图与符号计算依赖本地环境。这份按章节与书本一一对应、并把原示例里的 bug 逐条修过的 Python 源码,价值就在这里——它把「书上是正确的、代码是过时的」这种最磨人的情况提前处理掉了,改动行还留了注释,方便你对照原书公式理解为什么这么改。下面按报错类型逐个拆。
2. NumPy 弃用别名引发的连环报错:np.float、np.int 改成什么才不改变数值
2.1 报错现场:AttributeError: module 'numpy' has no attribute 'float'
典型现场是这样的,跑到讲向量与矩阵运算的那一章,构造标量或者给数组指定类型时直接挂掉:
Traceback (most recent call last): File "ch02/vector_basic.py", line 14, in <module> scale = np.float(3.14) AttributeError: module 'numpy' has no attribute 'float'同一类错误会以四种面貌出现,只是后半截名字不同:np.int、np.bool、np.object、np.str。还有一种更隐蔽的变体,报错发生在dtype参数上,比如np.zeros(3, dtype=np.int),看起来只是建了个零向量,实际上在解释器解析这一行时就已经炸了,你甚至走不到打印那一步。
新手最容易误判的地方是:错误信息说module 'numpy' has no attribute 'float',于是在文件头加一行import numpy as np就以为能解决,或者怀疑自己装了个假 NumPy。其实 NumPy 装得好好的,只是这些名字在新版本里被官方拿掉了。判断依据很简单,先确认环境:
# 同时打印解释器路径和 NumPy 版本,避免改了半天发现改的是另一个 Python python -c "import sys, numpy; print(sys.executable); print(numpy.__version__)"如果你看到版本在 1.24 及以上,而源码里还在写np.float,那基本就是这个坑,不需要继续往下猜。
2.2 根因:别名被下线了,而且它和内置类型不是一回事
np.float、np.int、np.bool这几个名字,本质上是 Python 内置float、int、bool的别名。当初保留它们只是为了写法短一点,但带来一个严重问题:宽度不明确。np.int到底是 32 位还是 64 位,取决于平台和 C 编译器的long,同一份代码在两种机器上算出来的 dtype 可能不同。NumPy 从 1.20 开始把这些别名标记为弃用,跑起来会给你一条DeprecationWarning,1.24 之后直接移除,于是老代码全线报错。
这里有个高频误解必须点掉:很多人以为「np.float就是 Python 的float,那我直接换成内置float总没错」。换成内置float在多数场景确实能用,因为 NumPy 会把dtype=float解释成float64;但换成内置int就有风险了,dtype=int在 64 位平台上映射到int64,在一些老环境里却可能落到int32,而数组元素个数一多、索引一超范围,结果就悄悄变了。数学书里大量示例依赖固定的浮点精度和整数宽度,这种「能跑但数字对不上」的坑,比直接报错难查十倍。
我一般按语义对照着改,而不是一律替换:
| 旧写法 | 推荐新写法 | 说明 |
|---|---|---|
np.float | np.float64 | 老别名实际就是双精度,显式写出来最稳 |
np.int | np.int64 | 原宽度依赖平台,统一到 64 位避免跨机器不一致 |
np.bool | np.bool_ | 注意尾部下划线,np.bool已不存在 |
np.object | np.object_或直接object | 对象数组场景多用于存放不规整数据 |
np.float_ | np.float64 | 这个带下划线的变体在更新的版本里也下线了 |
提示:
dtype=float、dtype=int这种直接写内置类型的写法在新版本仍然可用,报错的只有np.float这类带np.前缀的别名,别把两者混为一谈,也别顺手把前者一起批量改掉。
2.3 改法:先定位、再按语义替换、最后跑断言
我不建议上来就全局替换,因为注释和字符串里的np.float会被一起改掉,更麻烦的是np.float128、np.int0这类长名字可能被截断。正确顺序是先扫一遍,看清楚有多少处、分别是什么语境:
# 在当前源码目录下扫描所有旧别名用法,只列行号和内容,不动文件 # -r 递归子目录,-n 带行号,--include 限定只扫 .py,-E 启用扩展正则,\b 保证不误伤 np.float64 grep -rn --include="*.py" -E "np\.(float|int|bool|object|str)\b" .扫出来的结果里,如果某一章的文件几乎全是旧别名,说明那部分代码的写作年代较早,需要整章核对;如果只有零星几处,逐个改更快。改完之后,用一段小脚本验证 dtype 语义没有跑偏:
import numpy as np # 反例:下面这行在当前 NumPy 上会抛 AttributeError,保留在注释里作为对照 # x = np.float(3.14) # 正例一:需要固定双精度时显式指定,数值行为与老代码保持一致 x = np.float64(3.14) # 正例二:构造数组时用 dtype 参数,比在元素上做类型转换更清晰 a = np.zeros(3, dtype="int64") # 正例三:布尔数组的标量类型是 np.bool_,判断 dtype 时不要写成 np.bool mask = np.array([True, False], dtype=np.bool_) # 断言的作用:改完之后立刻验证,避免只改了语法、语义没对上 assert x.dtype == np.float64 assert a.dtype == np.int64 assert mask.dtype == np.bool_ print(x, a.dtype, mask.dtype)这段代码里,np.float64(3.14)得到的是一个 NumPy 标量,可以正常参与后续矩阵运算,dtype属性是实打实的float64;dtype="int64"用字符串更直观,也方便批量搜索;三条assert是关键,很多人改完直接看程序不报错就往下走,结果在梯度下降那一章拿到一堆精度异常的结果,回头再找就来不及了。
还有一个常见分支:有人图省事把 NumPy 降级到老版本,想着「反正书上的代码能跑」。短期可行,但绘图层、符号计算库通常会跟着要求新版本 NumPy,降级之后另一个模块又开始报ImportError,来回折腾的时间远超改这几行代码。我更倾向改代码而不是改环境,环境是共享的,代码是自己的。
3. 矩阵形状对不上:ValueError: shapes not aligned 的三步定位顺序
3.1 先分清两类形状报错,它们根因完全不同
同样叫「形状问题」,报错文本却指向两种完全不同的机制,先分桶再动手,能省一半时间。
| 报错文本 | 触发算子 | 含义 | 第一反应 |
|---|---|---|---|
shapes (3,2) and (3,2) not aligned | @、np.dot、np.matmul | 矩阵乘法要求左矩阵列数等于右矩阵行数,这里 2 不等于 3 | 检查是不是有一个矩阵该转置 |
operands could not be broadcast together with shapes (3,1) (3,) | +、*、-等逐元素运算 | 广播规则要求两个数组从末维对齐,(3,1)和(3,)对不上 | 检查是不是把行向量和列向量搞混了 |
第一种是线代本身的问题:书上写的是 $A_{m\times n}B_{n\times p}$,代码里却把 $B$ 也建成了 $m\times n$。第二种是 NumPy 广播的问题:数学上「向量乘标量」没问题,但两个形状不同的数组做逐元素运算时,NumPy 需要从后往前逐维匹配,任何一维既不相等也不为 1,就报广播失败。
这两类错误在第 3、4 章的示例里出现频率最高,因为它们正好处在「手推公式」到「代码实现」的转换点上。书上画的是竖排的列向量,敲代码时np.array([1,2,3])得到的却是一维的(3,),形状概念在纸面上根本不存在。
3.2 shape 断点法:在乘法前打印,而不是反复读 traceback
ValueError的 traceback 只给你行号,不给中间变量。而数学代码往往一行里套三四个运算,比如w = X.T @ y + b,报错说形状不对,你根本不知道是X建错了还是y拿错了。我的做法是在可疑行之前插一个调试函数,把参与运算的每个数组形状打出来:
import numpy as np A = np.array([[1., 2.], [3., 4.], [5., 6.]]) # 3x2 B = np.array([[1., 2.], [3., 4.], [5., 6.]]) # 书上要求 2x3,这里建错了 def matmul_debug(x, y): # 打印形状和关键维度,比冻在 traceback 上猜要快很多 print(f"x.shape={x.shape}, y.shape={y.shape}") print(f"内维校验: x.shape[-1]={x.shape[-1]} vs y.shape[0]={y.shape[0]}") # 断言把错误提前到这一行,堆栈信息直接指向真实位置 assert x.shape[-1] == y.shape[0], "内维不一致:左矩阵列数必须等于右矩阵行数" return x @ y print(matmul_debug(A, B))这里x.shape[-1]取的是左矩阵的最后一维,也就是列数;y.shape[0]是右矩阵的第一维,也就是行数。这两个数相等才允许相乘,写出来一目了然。assert的价值在于把错误拦在真正出问题的那一行,而不是让它流到后续十几行之外才炸。打印出来的(3, 2)和(3, 2)一眼就能看出右边那个应该是(2, 3),这时候回到构造B的地方核对数据来源,通常能发现是把转置矩阵和原矩阵抄反了。
参数上还有个小细节:多维数组时用x.ndim一起打出来更保险,因为(3,)和(3,1)的shape明显不同,但ndim分别为 1 和 2,能立刻判断出是维度缺失还是维度不匹配。
3.3 转置、reshape、补维:三种修法各管一段,别混用
定位到问题之后,修法有三种,选错会造成「不报错但结果错」这种更麻烦的局面。
| 修法 | 写法 | 适用场景 | 风险 |
|---|---|---|---|
| 转置 | B.T | 书里公式没错,只是数据按行列颠倒存了 | 转置是视图,改它会改到原数组 |
| 重排 | B.reshape(2, 3) | 数据本身是一维序列,只是按顺序切成矩阵 | 元素顺序错了不会报错,结果静默变化 |
| 补维 | v[:, np.newaxis] | 一维向量需要显式变成列向量参与广播 | 补错方向会得到外积而不是内积 |
对应的代码长这样:
import numpy as np v = np.array([1., 2., 3.]) # 同样是三个元素的向量,行向量和列向量在广播里的表现完全不同 row = v[np.newaxis, :] # 形状 (1, 3) col = v[:, np.newaxis] # 形状 (3, 1) print(row.shape, col.shape) print((row * col).shape) # (3, 3):逐元素相乘被广播成外积矩阵 print(np.dot(row, col).shape) # (1, 1):这才是内积,结果是标量包裹在矩阵里v[np.newaxis, :]相当于在第一个维度前插一个长度为 1 的轴,v[:, np.newaxis]则是在第二个维度前插。这两个写法线代教材里几乎不会讲,但书本示例一旦涉及二次型、协方差矩阵、梯度向量的转置,就离不开它们。
注意:形状对不上会报错,形状对上了但语义错却不会报错。把
row * col当成内积写进损失函数,程序一路跑到底,最后画出条水平线,这种翻车我见过不止一次。所以在数学代码里,我宁可多写一行
顺带说一句*和@的区别,这是线代代码里最常见的静默错误来源:A * B是逐元素相乘,要求两个矩阵形状相同或可广播;A @ B才是矩阵乘法。书本上的矩阵乘法公式对应的是@,很多示例为了兼容老版本会写np.dot,二维情况下两者等价,一旦升到三维以上,np.dot和@的批量语义就不同了。修函数里如果混用了两种写法,最好统一成@,读起来也更贴近数学符号。
4. 绘图与可视化翻车:后端、中文字体和「图不出来」的三类现场
4.1 no display name:没有图形界面时 plt.show() 必然失败
先看报错原文,这是服务器和容器里跑书上示例时最典型的一条:
_tkinter.TclError: no display name and no $DISPLAY environment variable触发条件很明确:代码走到plt.show(),matplotlib 想调起图形窗口,但当前环境没有 X 显示、没有桌面会话,于是抛出异常。注意这个报错有时不是一上来就出现,而是在你跑完前面所有计算、眼巴巴等着看图的时候才崩,前面几十秒的计算全白费。
根因在 matplotlib 的后端机制。后端分两类:交互式的(TkAgg、QtAgg 这类,能弹窗)和非交互式的(Agg,只负责渲染成像素,不负责显示)。默认后端是根据环境探测出来的,探测到有显示就选交互式,探测不到就报错。所以解决办法不是给系统装桌面,而是显式指定非交互后端,并且把结果写进文件:
import matplotlib # 必须在 import pyplot 之前设置后端,顺序反了不生效 matplotlib.use("Agg") import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(4, 3)) ax.plot([0, 1, 2, 3], [0, 1, 4, 9]) ax.set_title("square") fig.savefig("square.png", dpi=120, bbox_inches="tight") # 无显示环境下直接落盘 print("saved")matplotlib.use("Agg")的作用是告诉渲染层只走内存绘制,不碰任何窗口系统;dpi=120决定输出清晰度,教学图 120 已经够用,想嵌进讲义可以提到 200;bbox_inches="tight"会裁掉图周围多余的空白边距,避免坐标标签被切掉。这段配置我一般放在脚本最顶上,比在每张图前面判断环境省事。
4.2 中文变方块、负号消失:字体与 rcParams 的两行配置
图形能出来了,第二个经典问题随之而来:标题里的中文全变成方块,坐标轴负号变成一个小方框。这不报错,但图上信息全废。
原因是 matplotlib 默认字体是英文字体族,找不到中文字形就用占位方块替代;负号的问题则是默认用 Unicode 减号,同样依赖字体支持。修法是显式指定系统里真实存在的中文字体,并关掉 Unicode 负号:
import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # 按优先级列出候选字体,系统里装了哪个用哪个,全部缺失时仍会退化成方块 plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "WenQuanYi Zen Hei", "SimHei"] plt.rcParams["axes.unicode_minus"] = False # 用 ASCII 减号,避开负号变方块的经典开关 fig, ax = plt.subplots(figsize=(5, 3)) ax.plot([-2, -1, 0, 1, 2], [4, 1, 0, 1, 4]) ax.set_title("函数图像") ax.set_xlabel("x") fig.savefig("curve.png", dpi=120, bbox_inches="tight")font.sans-serif接收的是一个列表,matplotlib 从左往右找,找到第一个可用的就用。这里一定不要硬编码一个你没装过的字体名,否则它不报错,只是默默退回方块,你还以为配置生效了。验证方法很直接,把系统里实际的字体名列出来:
# 列出 matplotlib 能识别的字体名,取前 20 个看有没有中文字体 python -c "from matplotlib import font_manager as fm; print([f.name for f in fm.fontManager.ttflist][:20])"axes.unicode_minus = False这一行单独说明:它不是把负号变成别的符号,而是让 matplotlib 改用 ASCII 的-来渲染负号,牺牲一点排版美观,换取在所有字体下都能正常显示。凡是数学图里带负值坐标的,这行几乎必加。
4.3 图对了但线多了:全局状态污染与采样点数
第三类现场最容易被忽略:图能出来,中文也正常,但曲线上多了几条你没画的线,或者函数图像跟书上明显不是一个形状。
前者是状态污染。plt.plot()这种全局接口把所有东西画进当前 figure 的当前 axes,如果你的脚本里连续调用多次、中间又没有plt.close(),第二次运行时上一次的线还留在画布上。修复方式是把绘图改成面向对象写法,每次新建画布:
import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np # 面向对象写法:画布和坐标轴都是显式对象,不会互相污染 fig, ax = plt.subplots(figsize=(5, 3)) x = np.linspace(-np.pi, np.pi, 400) # 采样点数决定曲线光滑度 ax.plot(x, np.sin(x), label="sin") ax.plot(x, np.cos(x), label="cos") ax.legend() ax.grid(True) fig.savefig("trig.png", dpi=120, bbox_inches="tight") plt.close(fig) # 显式关闭,批量跑多张图时防止内存堆积 print(x.shape, x[:3])np.linspace(-np.pi, np.pi, 400)里的 400 是采样点数,微积分章节里画切线、画黎曼和时会大量用到。采样点太少,曲线看着像折线;采样点太多,批量出图时又慢。教学场景 200 到 400 之间比较合适。plt.close(fig)在批量生成几十张图的脚本里很重要,不关的话内存会一直涨,跑到后面几张时明显变慢。
曲线形状和书上对不上,还有一种可能:书上用的是解析式直接采样,代码里用了数值近似,两者在端点附近的差异肉眼可见。这种情况不是 bug,但要能分辨出来——把采样点从 100 提到 400,如果差异明显收敛,那就是采样密度问题;如果形状依然对不上,再回头查公式实现。
5. 避坑:源码跑不通时最容易踩的 5 个坑
5.1 改了却没生效:字节码缓存和解释器错位
现象:明明已经把np.float改成np.float64,保存后重跑,报错行号还是原来那一行,甚至报错内容一字不差。改到第三遍开始怀疑人生。
原因通常有两个。一是__pycache__里的.pyc字节码和源文件时间戳对不上,模块被直接复用了旧缓存;二是 IDE 的运行配置指向另一个解释器,或者指向了另一份拷贝的源码目录,你改的是 A 目录,跑的是 B 目录。第二种在「书配套源码解压了两份」的情况下特别常见。
解决分两步,先清缓存再确认解释器:
# 递归清掉当前目录下所有字节码缓存目录 find . -name "__pycache__" -type d -prune -exec rm -rf {} + # 确认当前 python 指向哪个可执行文件,和你 IDE 里配的是不是同一个 python -c "import sys; print(sys.executable)"-type d限定只找目录,-prune阻止 find 继续往缓存目录里钻,-exec rm -rf {} +批量删除。清完缓存后如果报错内容变了,说明确实是缓存问题;如果一模一样,重点查解释器路径和文件路径,用python -c "import 模块名; print(模块名.__file__)"直接把实际加载的文件路径打出来,一比就知道跑的是哪份。
5.2 FileNotFoundError:相对路径按工作目录解析,不是按脚本位置
现象:从命令行在项目根目录跑脚本一切正常,换到 IDE 里点运行,立刻FileNotFoundError: [Errno 2] No such file or directory: 'data/ch02/points.csv'。
原因是相对路径的基准是进程的当前工作目录,而不是脚本文件所在目录。命令行里你cd到了项目根,所以data/ch02/...能找到;IDE 默认的工作目录往往是脚本所在的那一层,路径就短了一截。
解决方式是以脚本自身位置为锚点拼路径:
from pathlib import Path # 以脚本所在目录为基准,无论从哪个工作目录启动都能定位到数据文件 BASE = Path(__file__).resolve().parent csv_path = BASE / "data" / "ch02" / "points.csv" print(csv_path) assert csv_path.exists(), f"数据文件不存在,检查目录结构: {csv_path}"Path(__file__).resolve()会把符号链接和..都解开,得到绝对路径;.parent取所在目录;/运算符在 pathlib 里就是拼接路径,比手写字符串加斜杠更不容易出错。assert那行是有意加的,把「文件不存在」这个错误提前到脚本入口处,而不是等到读了半本书的数据之后才崩。
注意:Jupyter Notebook 里没有
__file__这个变量,这段写法只适用于.py脚本。在 Notebook 里跑书上示例时,改用Path.cwd()并显式确认当前目录,或者干脆用绝对路径。
5.3 结果变 nan 或 inf:溢出被静默吞掉,警告不是噪音
现象:梯度下降那一章的示例跑完,损失值打印出来是nan,或者参数一路涨到inf;控制台只飘过一行RuntimeWarning: overflow encountered in exp。
原因是数值计算里的溢出和无效运算默认只发警告不中断,程序继续往下跑,错的值一路传染。常见触发点是学习率设得过大、迭代步数过多、exp的指数项太大、阶乘或幂次直接炸掉浮点范围。
处理方式分两层。先让警告变成异常,把现场冻住;再去调参数:
import numpy as np # 把溢出和无效运算升级为异常,出错时直接停在真实位置,而不是最后打印出 nan np.seterr(over="raise", invalid="raise") def gradient_step(w, grad, lr=1e-3): # 学习率是首要怀疑对象,先降一个数量级再观察 return w - lr * grad w = np.array([1.0, 2.0, 3.0]) grad = np.array([10.0, -4.0, 0.5]) print(gradient_step(w, grad))np.seterr的over管上溢、invalid管0/0、inf - inf这类无效运算。设成"raise"之后,程序会在第一次出问题的那一行抛FloatingPointError,堆栈直指源头,比在几百行之后看到nan强太多。注意这行配置是全局的,调试完记得改回"warn",否则正常的边界情况也会被打断。学习率这一项,我一般的顺序是先缩到原来的十分之一,如果还炸再检查输入数据有没有未归一化。
5.4 SympifyError:符号计算和浮点数混着用
现象:微积分章节里用符号库求极限或导数,报SympifyError: Sympify of expression 'could not parse ...' failed,或者干脆抛一个类型错误。
原因是把 NumPy 的数组、np.float64标量直接塞进了符号表达式。符号计算库要的是它自己的符号对象和精确常量,NumPy 的浮点标量对它来说是个不认识的对象,转换失败就报这个错。
解决原则是:符号阶段全程用精确值,最后一步再转浮点。
import sympy as sp x = sp.symbols("x") # 全程用符号常量和对分数,避免中途引入浮点误差 expr = sp.sin(x) / x limit_val = sp.limit(expr, x, 0) deriv = sp.diff(sp.exp(-x) * sp.cos(x), x) print(limit_val, float(limit_val)) # 只有最后展示时才转成浮点数 print(deriv)sp.symbols声明符号变量,sp.limit和sp.diff都返回符号表达式而不是数值,所以能保留精确形式。float(limit_val)放在打印语句里,是为了让你看到 1.0 这样的直观结果,但计算链条里不该出现它。同理,需要写 $\pi$ 的时候用sp.pi而不是np.pi,需要写二分之一的时候用sp.Rational(1, 2)而不是0.5,否则一开始就丢了精度,后面求出来的极限可能带着微小残差。
5.5 输出和书上对不上:随机性、精度和已修过的 bug 三件事
现象:代码跑通了,图也出来了,可数值和书上的印刷结果差一点点,或者散点分布明显不一样。
原因通常有三种。第一是随机性,书上示例用了随机初始化,但没有强调种子,你每次跑结果都不同;第二是精度差异,书里可能只印了三位小数,你的结果是0.3333333333333333;第三,也是最容易被忽略的一种——源码里已经修过原示例的 bug,修完之后结果本来就该和书上不同。
第一种用显式随机数生成器控制,第二种对齐打印格式即可,第三种要去读源码里的改动注释:
import numpy as np # 显式种子保证可复现,default_rng 比老的 np.random.seed 更隔离,不会污染全局状态 rng = np.random.default_rng(42) sample = rng.normal(size=5) # 和书上对比时统一保留位数,避免把精度差异误判成逻辑错误 print(np.round(sample, 3)) print(f"{1/3:.3f}")default_rng(42)创建的是一个独立的生成器对象,好处是不受其他地方调用全局随机函数的影响;老写法np.random.seed(42)改的是全局状态,只要中间插了任何一次随机调用,结果就全变。如果发现改了种子还是对不上,就把源码里那一行改动前后的表达式都手算一遍——很多情况下,书上印的是错误版本的结果,源码修好了,数字当然不一样,这时候以源码为准。
6. 快速定位这类数学代码故障的顺序
故障排查最耗时间的不是修,而是判断该往哪个方向查。我的习惯是三步走,尽量在三分钟内把范围缩到一类。
第一步,只看 traceback 的最后一行异常类型,把它丢进三个桶里。AttributeError、ImportError、ModuleNotFoundError归到环境和接口桶,优先查版本和依赖;ValueError、IndexError、KeyError归到数据和形状桶,优先查数组维度、索引边界、文件路径;FloatingPointError、RuntimeWarning、结果为nan归到数值桶,优先查步长、精度和溢出。这个分桶动作看起来粗糙,但能立刻排掉三分之二的错误方向,避免在版本问题上找形状错误。
第二步,打一份环境快照存下来。同类问题第二次出现时直接比对快照,能立刻看出是不是换了机器或换了环境:
# 一次性记录关键库版本,粘到排查笔记里,比事后回忆靠谱 python - <<'PY' import sys import numpy import matplotlib print("python", sys.version.split()[0]) print("numpy", numpy.__version__) print("matplotlib", matplotlib.__version__) print("executable", sys.executable) PY这段脚本用 heredoc 方式直接执行,不落地文件。重点看两处:Python 大版本决定语法特性是否可用,NumPy 版本决定用哪套接口,这两个对上了,再去看具体代码。
第三步,做最小复现。把出问题的那一段从书本示例里摘出来,删掉所有绘图、打印、文件读写,只留下构造数据和调用出错函数的那几行。绝大多数「跑不通」在这一步就暴露出真实原因——要么是某个数组形状在删减过程中露了出来,要么是删到最后发现根本不是这段代码的问题,而是上游传进来的参数类型不对。最小复现还有个额外好处:它是可以直接贴给别人看的,沟通成本比描述现场低得多。
最后给一张对照表,把前面几章的信号和查法压缩成速查:
| 报错信号 | 最可能的根因 | 先查什么 |
|---|---|---|
has no attribute 'float' | NumPy 别名下线 | 改成np.float64等显式类型 |
shapes not aligned | 矩阵内维不匹配 | 乘法前打印两个 shape |
could not be broadcast | 行向量列向量混淆 | 检查(3,)与(3,1) |
no display name | 无图形界面环境 | 提前matplotlib.use("Agg") |
| 中文方块、负号丢失 | 字体与 Unicode 负号 | 设font.sans-serif与unicode_minus |
结果nan/inf | 步长过大或精度溢出 | np.seterr升级为异常 |
| 输出与书上不一致 | 随机种子、精度、已修 bug | 固定种子并对照改动注释 |
血泪经验是:这类数学代码的故障九成不在数学本身,而在版本、形状、环境这三层。数学推导写错反而最容易发现,因为它通常会在第一组数据上就给出明显离谱的结果;真正难查的是那些能跑完、图也画出来、唯独某个中间量差了 0.001 的问题。
从那以后我每次拿到一份配套源码,都强制走一遍「环境快照 → 扫旧接口 → 跑最小示例 → 对一遍输出」这四步,再开始看正文。看起来多花了十分钟,实际上省掉的是后面几个小时的回溯。希望帮到你。
本文还有配套的精品资源,点击获取