
文章目录
-
- [一、seterr 救不了你:它只管标量](#一、seterr 救不了你:它只管标量)
- [二、三个安静的角落:abs、prod 和累加器](#二、三个安静的角落:abs、prod 和累加器)
- 三、无符号的下溢,和混合类型的暗改
- [四、pandas 的 nullable 类型不防这个](#四、pandas 的 nullable 类型不防这个)
- 五、三条出路,和它们的代价
- 六、这条家族坑,每一层都静默
先看一行输出,不需要任何背景知识:
python
import numpy as np
np.array([9223372036854775807]) + 1
# array([-9223372036854775808])
9223372036854775807 是 int64 能表示的最大整数。给它加 1,结果是 int64 能表示的最小整数。没有警告,没有异常,连一条日志都没有。
同一行逻辑用纯 Python 写,9223372036854775807 + 1 得到 9223372036854775808,精确无误。
差别不在代码,在类型。Python 的 int 是任意精度整数,numpy 的 int64 是固定 64 位。位宽用完之后,值会绕回。问题是:它绕回的时候不打招呼。
这篇文章把 numpy 的整数溢出处境摸了个遍,全部本地实测(numpy 2.4.6,pandas 3.0.3)。结论是它家有一套自己的「取舍逻辑」:该报的地方报,不该报的地方一声不出。而踩坑的人,往往正好站在不该报的那一边。

一、seterr 救不了你:它只管标量
numpy 有 np.seterr 和 np.errstate,可以把运算错误设成 raise。很多教程说「打开 all=raise 就不怕溢出了」。我实测了一遍,这句话只对一半。
python
import numpy as np
with np.errstate(all="raise"):
# 标量路径:抛 FloatingPointError
np.int64(9223372036854775807) + np.int64(1)
with np.errstate(all="raise"):
# 数组路径:安安静静返回绕回值
np.array([9223372036854775807]) + 1
第一段抛 FloatingPointError: overflow encountered in scalar add。第二段什么都不发生,直接给出 array([-9223372036854775808])。换成小类型也一样:np.uint8(255) + np.uint8(1) 会发 RuntimeWarning,np.array([255], dtype=np.uint8) + np.array([1], dtype=np.uint8) 一声不出,两边结果都是 0。

这不是疏忽,是写进设计里的取舍。核心开发者 Robert Kern 在很多年前的回答里把原因说得很直白:硬件浮点单元会在溢出时置一个标志位,整数运算没有这个便利;要检测整数溢出,得在每一个原子操作前后逐元素检查,太慢,所以只在标量路径上做了,数组路径没做。
理解这句话,比记住十个 API 都有用。它意味着:
- 写单行调试的时候你可能看到警告,批量跑数组的时候看不到;
- 把 seterr 设成 all 加 raise 给你的安全感,在向量化代码里不存在;
- 以后看到「开了 raise 就万事大吉」的教程,可以直接关掉。
二、三个安静的角落:abs、prod 和累加器
除了加减法,还有几个地方同样不打招呼。
abs 能返回负数。 对 int64 最小值取绝对值:
python
np.abs(np.array([-9223372036854775808], dtype=np.int64))
# array([-9223372036854775808])
数学上没有 -(-2^63) 的 int64 表示,绕回之后还是最小值。这个行为在 numpy 的 issue 列表里挂了十几年(#5657),至今没改。写 abs() 之后默认「结果非负」的代码,在这一格上会失守。
np.prod 吃 Python list 也能静默出错。 这是 issue #21219 记录的行为:
python
np.prod([2] * 66) # 0
2 的 66 次方远超 int64,结果绕成一串 0。注意这里是直接传 Python list,没有任何 dtype 参数。list 里的每个 2 都是精确的 Python int,进了 prod 就被套进固定宽度,然后绕回。
累加器的宽度是隐形的。 同一个 np.prod,给不给 dtype 是两种命运:
python
np.prod(np.full(3, 300, dtype=np.int16)) # 27000000,正确
np.prod(np.full(3, 300, dtype=np.int16), dtype=np.int16) # -832,绕回
默认情况下 sum、prod、cumsum 会用更宽的累加器,所以 300×300×300 这种小类型连乘反而没事。但你一旦显式指定窄 dtype(有时是为了省内存),绕回立刻出现。同理 np.sum(np.full(10, 100, dtype=np.int8), dtype=np.int8) 得到 -24,而 1000 本该是正数。
规律:不指定 dtype 时它悄悄帮你兜着,指定了它就按你说的办,办错了也不吭声。
三、无符号的下溢,和混合类型的暗改
uint64 的下溢方向相反,同样安静:
python
a = np.zeros(1, dtype=np.uint64)
a[0] -= 1
# array([18446744073709551615])
0 减 1 变成 2^64-1。计数器从零开始递减、哈希桶回绕、缓冲区指针前移。任何「应该停在 0」的逻辑,在这里会直接冲到天花板。
更隐蔽的是混合类型运算。numpy 2.x 的提升规则(NEP 50)下:
python
(np.array([1], dtype=np.uint64) + np.array([1], dtype=np.int64)).dtype # float64
(np.array([1], dtype=np.int64) + 1.0).dtype # float64
(np.array([1], dtype=np.int64) / np.array([1], dtype=np.int64)).dtype # float64
uint64 和 int64 相加、int64 做除法、int64 碰 Python float,结果类型统统变成 float64。整数运算悄悄变成浮点运算 ,精确度上限从 2^63 一路掉到 2^53。这正是上一篇讲过的雷区换了一身衣服:数组里躺着一个看起来是整数的 float64,9007199254740992 之后的奇数它一个都表示不了。
小知识:在 2^63 附近,float64 每 1024 才有一个可表示的值。两个相邻大整数求平均,真值是 ...806.5,numpy 只能给到 2^63 这个格点,偏差 1.5。这还只是两个数,样本一多偏差就更没谱。
四、pandas 的 nullable 类型不防这个
有人会想:pandas 的 Int64(可空整数)是不是更安全?我测了:
python
import pandas as pd
pd.array([9223372036854775807], dtype="Int64") + 1
# <IntegerArray>[-9223372036854775808]
一模一样地绕回。nullable 解决的是「用 NaN 表示缺失」,不是「防止溢出」。混入缺失值的行为倒是符合预期:[max, None] 加 1 得到 [min, <NA>],缺失传播不受影响。
顺带一提,mean() 这类聚合在 Int64 上走的是 float64,于是回到上一段的格点问题。整数类型的安全边界到 int64 为止,再往上 pandas 和 numpy 一样没有原生办法。
五、三条出路,和它们的代价
那超过 int64 的整数怎么办?我测了三条路线。
路线一:dtype=object 逃生舱。 构造时让 numpy 别选固定宽度:
python
np.array([264, 264 + 1], dtype=object).sum()
# 36893488147419103233,精确
有意思的是,这个逃生舱有时候是自动的:np.array([18446744073709551616])(超过 uint64 上界)会自己选 object dtype,值保持精确。代价是性能。百万级元素求和实测:int64 路径亚毫秒级,object 路径几十毫秒,相差几十倍(多次运行在 26 到 58 倍之间)。object 数组里每个元素都是 Python 对象,运算走解释器,向量化的好处一点不剩。

路线二:不进数组。 超过 2^53 的 ID、订单号、雪花 ID,从头到尾用 Python int 或字符串传递。需要向量化运算的列(金额、计数)通常到不了 int64 边界,需要 int64 边界的场景(唯一 ID、大数运算)通常不需要向量化。这个分工大部分时候成立。
路线三:显式 dtype + 边界自查。 必须用 int64 的地方,把 dtype 写清楚,并且在关键路径加断言:
python
assert arr.max() < np.iinfo(np.int64).max - 1000
难看的断言也是保险。它不会让代码变慢,但会让绕回发生在开发阶段而不是生产环境。
六、这条家族坑,每一层都静默
把最近踩过的坑排在一起看,模式惊人地一致:
- CSV 进 pandas:一个大整数列里混进一个小数值,整列被推断成 float64,精度蒸发(
12345678901234567890变成1.2345678901234567e+19); - JSON 过 JavaScript:
9007199254740993被 Number 类型四舍五入成9007199254740992,两个相邻 ID 撞成同一个人; - numpy 数组加 1:从最大值绕到最小值,无警告。
三篇文章,三个工具,三种「读取即改写」。共同点:没有一层愿意为超出自己表示能力的数字报警,因为报警意味着承认自己处理不了,而它们的默认姿态是全都要。
所以真正的防御不在工具里,在习惯里:超过 2^53 的整数,从进系统的第一秒起就是字符串;跨语言、跨管道、跨存储的字段,先问「这一层会不会按数字处理我」;看到 + 1、sum、prod、mean 这些人畜无害的小动作,先想想输入离边界有多远。
