📑 本页目录(点开跳转)
08 · 整数 dtype 的真相:回绕与截断
⏱ 64 分钟 | ⭐ 同一个溢出,写成数组一声不吭给你 44,写成标量会报警告 —— 你踩的坑正是因为数组不吭声
🎯 一句话
整数越界不会报错,它会回绕;astype 不是四舍五入,它是向零截断;而 np.errstate 对这两件事一个都管不了。
浮点那一侧有 inf 和 nan 给你信号,整数这一侧什么信号都没有——结果照样是个合法的整数,只是不是你要的那个。
🧩 一、先把边界划清楚
⚠️ 「dtype」这个词在站内已经被占用了,而且占用它的是另一件事。
| 说的是 | 在哪讲 | 关心什么 |
|---|---|---|
| FP16 / BF16 / FP8 怎么选、loss scaling 怎么配 | AI 基础设施 · 06 · 混合精度 | 训练该用哪个精度(工程选型) |
| INT8 量化、per-channel 还是 per-tensor | AI 基础设施 · 18 · 量化 | 模型压到多小还能用(模型压缩) |
exp 会不会炸、log(1+x) 丢几位 |
09 · log 域运算 | 浮点表示本身的数值行为 |
⭐ int16 装不下 70000 会变成什么 |
本章 | 整数的回绕、截断、类型提升 |
本章只做整数,一行浮点精度的选型建议都不给。反过来,上面三处也都不讲整数回绕——站内 102 处 dtype 全是深度学习数值格式,整数这一侧是空的。
🧩 二、招牌:数组静默,标量报警告
import numpy as np
import warnings
with warnings.catch_warnings(record=True) as w: # 把警告抓下来数一数
warnings.simplefilter("always")
r = np.array([200], dtype=np.uint8) + np.uint8(100)
print("数组 [200]uint8 + uint8(100) =", r, "| 警告条数", len(w))
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
s = np.uint8(200) + np.uint8(100)
print("标量 uint8(200) + uint8(100) =", s, "| 警告条数", len(w))
for x in w:
print(" ->", x.category.__name__ + ":", x.message)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
r2 = np.array([200], np.uint8) + np.array([100], np.uint8)
r3 = np.array([2147483647], np.int32) + np.int32(1)
print("数组+数组 =", r2, "| int32 最大值 +1 =", r3, "| 警告条数", len(w))
关键信息
⭐ 这就是全章的招牌:三个式子算的是同一件事,答案都是 44,只有写成标量的那一个会告诉你出事了。
为什么是 44:uint8 只有 8 位,能表示 0–255。300 超了,于是取模——300 − 256 = 44。有符号那一侧更吓人:int32 的最大值加 1,直接从 21 亿多跳到负 21 亿多。
💀 而你在真实代码里遇到的,永远是数组那一版。你不会写 np.uint8(200) + np.uint8(100),你会写 img + delta——然后什么都没发生,只是几个像素从 250 变成了 4。
🧩 三、每种整数能装多少
import numpy as np
for t in (np.int8, np.uint8, np.int16, np.uint16,
np.int32, np.uint32, np.int64, np.uint64):
i = np.iinfo(t)
print(f"{np.dtype(t).name:<7} {i.bits:>2} bit {i.min:>21} ~ {i.max:>20}")
print("\nnp.array([1,2,3]).dtype =", np.array([1, 2, 3]).dtype)
print("np.arange(3).dtype =", np.arange(3).dtype)
print("np.zeros(3, dtype=int).dtype =", np.zeros(3, dtype=int).dtype)
print("np.array([2**40]).dtype =", np.array([2**40]).dtype)
| 整数类型 | 位宽 | 范围 |
|---|---|---|
| int8 | 8 bit | -128 ~ 127 |
| uint8 | 8 bit | 0 ~ 255 |
| int16 | 16 bit | -32768 ~ 32767 |
| uint16 | 16 bit | 0 ~ 65535 |
| int32 | 32 bit | -2147483648 ~ 2147483647 |
| uint32 | 32 bit | 0 ~ 4294967295 |
| int64 | 64 bit | -9223372036854775808 ~ 9223372036854775807 |
| uint64 | 64 bit | 0 ~ 18446744073709551615 |
这台环境上的默认 dtype
- np.array([1,2,3]).dtype = int64
- np.arange(3).dtype = int64
- np.zeros(3, dtype=int).dtype = int64
- np.array([2**40]).dtype = int64
⭐ 中间那四个数字,请记住它们的样子:127 · 255 · 32767 · 65535 · 2147483647 · 4294967295。它们是整数溢出留在数据里的指纹——见第八节。
⚠️ 默认整数 dtype 是平台相关的。上面这台机器(Windows + NumPy 2.4.6)给的是 int64;⚠️ NumPy 1.x 时代的 Windows 默认是 int32。所以「同一份代码在同事的 Linux 上没问题,在我的 Windows 上算错了」这种事,历史上真实发生过很多次。⭐ 依赖范围的地方永远显式写 dtype,别指望默认值。
🧩 四、astype 是截断,不是四舍五入
import numpy as np
f = np.array([0.9999, -0.9999, 2.5, -2.5, 3.5, 1e9])
print("直接 astype ", f.astype(np.int32))
print("round 再 astype", np.round(f).astype(np.int32))
print("floor 再 astype", np.floor(f).astype(np.int32))
big = np.array([32768, 70000, -1], dtype=np.int64)
print("int64 -> int16", big.astype(np.int16))
print("int64 -> uint8", big.astype(np.uint8))
info = np.iinfo(np.int16)
print("int16 范围", info.min, "~", info.max,
"| 全部装得下吗:", bool(((big >= info.min) & (big <= info.max)).all()))
关键信息
三件事同时发生了:
| 现象 | 实证 | 说明 |
|---|---|---|
| 向零截断 | 0.9999 -> 0,-0.9999 -> 0 |
不是四舍五入,也不是 floor。⚠️ 负数那一侧和 floor 结果不同(-0.9999 截断是 0,floor 是 −1) |
⭐ np.round 是「银行家舍入」 |
2.5 -> 2 而 3.5 -> 4 |
五往偶数那边凑,不是永远进位。批量取整时会让「统计上的偏差」更小,但会让你对不上手算的结果 |
| 💀 越界静默回绕 | 70000 -> 4464,-1 -> 255(uint8) |
和上一节同一个机制。-1 变成 255 是最经典的那个:用无符号类型存可能为负的量,负号会变成一个巨大的正数 |
⚠️ 32768 -> -32768 这一条最值得盯着看:只超了 1,符号就翻了。如果这是个传感器读数,你的下游会看到一个「突然反向的信号」而不是「一个偏大的值」——异常检测抓不住它,因为它落在合法范围内。
🛑 读到这里可以停 —— 前半章讲完了(约 21 分钟)。 后半章还有:类型提升:NEP 50 之后的规则 · 💀
np.errstate抓不到整数回绕 · 整数这一侧只能自己兜 · ⭐ 站里已经在诊断这个 bug,只是没讲成因 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、类型提升:NEP 50 之后的规则
import numpy as np
a8 = np.array([1, 2, 3], dtype=np.int8)
print("int8 数组 + Python int 1 ->", (a8 + 1).dtype)
print("int8 数组 + np.int64(1) ->", (a8 + np.int64(1)).dtype)
print("int8 数组 + int64 数组 ->", (a8 + np.array([1], np.int64)).dtype)
u8 = np.array([1], np.uint8)
i8 = np.array([1], np.int8)
print("uint8 + int8 ->", (u8 + i8).dtype)
print("uint8 + int16 ->", (u8 + np.array([1], np.int16)).dtype)
print("uint64 + int64 ->", (np.array([1], np.uint64) + np.array([1], np.int64)).dtype)
print("int64 + float32 ->", (np.array([1], np.int64) + np.array([1], np.float32)).dtype)
print("int32 + float32 ->", (np.array([1], np.int32) + np.array([1], np.float32)).dtype)
print("int8 数组 + 1000 ->", end=" ")
try:
print(a8 + 1000)
except Exception as e:
print(type(e).__name__ + ":", e)
关键信息
规则拆开是四条:
- ⭐ Python 的裸整数是「弱标量」,不会拉高数组的 dtype(NEP 50)。
int8 数组 + 1还是int8。 ⭐ 但它越界时会报OverflowError——a8 + 1000直接抛异常。这是 NumPy 2 少数几个主动帮你挡了一下的地方,请珍惜,因为下一条里它就不管了。 - 有 dtype 的标量(
np.int64(1))和数组一样参与提升。int8 + np.int64(1)→int64。 💀 所以a8 + 1和a8 + np.int64(1)结果的 dtype 不一样,而它们看起来只差一个包装。 - 有符号 + 无符号 = 找一个能同时装下两边的。
uint8(0–255)+int8(−128–127)→int16,因为没有 8 位类型能覆盖 −128–255。 ⚠️uint64 + int64找不到这样的整数类型,于是掉到float64——超过 2^53 之后就丢精度了,而且不报错。 - 整数 + 浮点,提升到「能装下这个整数精度的最小浮点」。
int16 + float32→float32,但int32 + float32→float64(float32只有 24 位尾数,装不下 32 位整数)。
🧩 六、💀 np.errstate 抓不到整数回绕
下一章会用 np.errstate 把静默的 inf 变成异常。很自然会以为它也能管整数。它不能。
import numpy as np
try:
with np.errstate(over="raise"):
print(np.exp(np.array([800.0])))
except FloatingPointError as e:
print("浮点溢出被抓到:", e)
with np.errstate(over="raise", invalid="raise", all="raise"):
r = np.array([2147483647], np.int32) + np.int32(1)
print("整数回绕没被抓到:", r)
def add_checked(a, b):
"""先升到 int64 算,再确认结果装得回原来的 dtype"""
out = a.astype(np.int64) + np.int64(b)
info = np.iinfo(a.dtype)
if (out < info.min).any() or (out > info.max).any():
raise OverflowError(f"结果超出 {a.dtype} 的范围 {info.min}~{info.max}")
return out.astype(a.dtype)
try:
add_checked(np.array([2147483647], np.int32), 1)
except OverflowError as e:
print("自己查出来了:", e)
print("np.can_cast(int32 -> int16, 'safe') =", np.can_cast(np.int32, np.int16, "safe"))
print("np.can_cast(int16 -> int32, 'safe') =", np.can_cast(np.int16, np.int32, "safe"))
关键信息
⭐ 原因:errstate 控制的是 IEEE 754 浮点异常标志(overflow / underflow / invalid / divide),那是 FPU 硬件提供的一套旗标。整数加法根本不走那条路,CPU 的整数溢出标志 NumPy 没有暴露出来。
⚠️ 所以 with np.errstate(all='raise') 给的安全感是假的——它只盖住了浮点那一半。
🧩 七、整数这一侧只能自己兜
| 手段 | 怎么用 | 什么时候用 |
|---|---|---|
⭐ 先升到 int64 再算,算完检查范围 |
上面那个 add_checked |
通用兜底,代价是一份 int64 的临时内存 |
np.can_cast(src, dst, "safe") |
转换前先问一句 | 写库函数、要给调用方一个明确报错时 |
np.iinfo(dtype).min / .max 自查 |
((x >= lo) & (x <= hi)).all() |
⭐ 从外部读进来的数据落库前,最值钱的一道闸 |
| 干脆别省那点内存 | 默认用 int64 |
数据量没到几十 GB 的时候,这就是正确答案 |
⚠️ 归约函数会自己升档,所以「求和不炸、逐元素炸」:
import numpy as np
raw = np.array([12000, 25000, 31000], dtype=np.int16)
print("原始 int16 ", raw)
print("直接 *2 (int16)", raw * np.int16(2)) # 静默回绕
print("先升 int32 再乘", raw.astype(np.int32) * 2) # 正确
big = np.full(100, 30000, dtype=np.int16)
print("sum 的 dtype ", big.sum().dtype, "值", big.sum())
print("逐元素累加 int16", np.add.reduce(big, dtype=np.int16))
print("mean 的 dtype ", big.mean().dtype, "值", big.mean())
print("int16 + float32 ->", (np.array([1], np.int16) + np.array([1], np.float32)).dtype)
print("bool + bool ->", (np.array([True]) + np.array([True])).dtype,
np.array([True]) + np.array([True]))
关键信息
⭐ sum() 和 mean() 会自动升档(int16 的和给 int64,均值给 float64),所以它们通常是安全的。危险的是逐元素运算:raw * 2 里的 25000 和 31000 当场变成负数。
⭐ 最后一行是个独立的坑:True + True 不是 2,是 True ——布尔数组相加是逻辑或。想数「有多少个 True」必须先 .astype(int) 或者用 .sum()(.sum() 会升到 int64,是对的)。
🧩 八、⭐ 站里已经在诊断这个 bug,只是没讲成因
数据这一关 · 07 · 异常值是错还是真 把「某一列的值正好是 32767 / 65535 / 2147483647」列为脏数据的判别信号——那就是本章第三节那张表里的三个数。
⭐ 两章合起来才是完整的一件事:
| 那一章 | 本章 |
|---|---|
| 教你认出症状:一列数据里堆着一撮 32767 | 教你认出成因:某处一个 astype(np.int16),或者一个 int16 数组做了乘法 |
| 视角是「拿到别人的数据,怎么发现它坏了」 | 视角是「我自己的代码,正在制造这种数据」 |
💀 最难查的一种:回绕之后的值仍然落在合法范围内。32768 -> -32768 不会被「值域检查」拦下来,因为 −32768 是一个完全合法的 int16。你只能靠「它和前后样本的关系不对劲」发现它,而那已经是异常检测的活了。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 09 · log 域运算 | ⭐ 同一个 np.errstate,在那边真的管用。那一章讲怎么用它把静默的 inf/nan 变成异常——去看一眼「有信号」的那一半长什么样,就知道整数这一侧亏在哪 |
| 数据这一关 · 07 · 异常值是错还是真 | 那里把 32767 / 65535 / 2147483647 列为脏数据的判别信号。⭐ 读完本章你会知道那三个数是怎么被制造出来的,以及你自己的 astype 正在制造它 |
| AI 基础设施 · 06 · 混合精度 | 浮点低精度那一整侧在那里:FP16 和 BF16 怎么选、loss scaling 为什么必须有。本章一个字都不讲浮点选型,要选精度就去那边 |
| AI 基础设施 · 18 · 量化 | INT8 量化天天在做「浮点压进 8 位整数」,⭐ 本章第四节那个「越界回绕」正是量化里 clip 那一步在防的东西——去那边看这件事被认真对待时是什么样子 |
| 07 · 把循环改写成数组运算 | 那一章的套路 ② 用整数组号做 bincount。⚠️ 组号很多时用 int16 存看着省内存,但只要参与一次乘法就会回绕 |
| ML 基础 · 16 · 特征工程基础 | 特征工程里到处是「把浮点分桶成整数 ID」。⭐ 桶数超过 32767 而 ID 存成 int16 时,本章第四节那个 -32768 就会出现在你的特征里 |
| 代码题拆解 07 · 位运算与模拟 | ⭐ 反面对照:纯 Python 的整数是无限位的,所以「不用加减乘除做加法」那道题处理负数时得自己补一个 32 位掩码;进了 NumPy 则相反,位宽是定死的、越界直接回绕 |
✅ 检查点
np.array([200], np.uint8) + np.uint8(100)等于多少?为什么是这个数?它会报警告吗?- 同样的运算写成
np.uint8(200) + np.uint8(100)有什么不同? np.array([0.9999, -0.9999]).astype(np.int32)得到什么?和np.floor的结果一样吗?np.round(np.array([2.5, 3.5]))得到什么?为什么不是 3 和 4?np.array([1,2,3], np.int8) + 1的 dtype 是什么?+ np.int64(1)呢?+ 1000呢?uint8 + int8为什么提升成int16?uint64 + int64又提升成什么?with np.errstate(all="raise")能不能抓住int32最大值 +1?为什么?np.full(100, 30000, np.int16).sum()是多少?dtype 是什么?为什么它没有溢出,而raw * 2溢出了?np.array([True]) + np.array([True])等于什么?- 数据里出现一撮值正好等于 32767,最可能的成因是什么?为什么值域检查抓不住
32768 -> -32768这种回绕?
👀 答案
[44]。uint8只能表示 0–255,300 超出后取模:300 − 256 = 44。⚠️ 不报警告,实测警告条数 0。- 值同样是 44,但会报
RuntimeWarning: overflow encountered in scalar add(实测警告条数 1)。这就是全章招牌:只有标量吭声,而真实代码里遇到的永远是数组那一版。 [0, 0]——向零截断。⚠️ 和np.floor不一样:floor给[0, -1]。负数那一侧两者才分得开。[2., 4.]。np.round是银行家舍入:五往偶数那边凑,所以 2.5 → 2 而 3.5 → 4。+ 1→int8(NEP 50 的弱标量不拉高数组 dtype);+ np.int64(1)→int64(有 dtype 的标量参与提升);+ 1000→ 抛OverflowError: Python integer 1000 out of bounds for int8。- 因为
uint8是 0–255、int8是 −128–127,没有 8 位类型能覆盖 −128–255,所以升到int16。⚠️uint64 + int64找不到能装下两边的整数类型,掉到float64——超过 2^53 就丢精度且不报错。 - 抓不住,实测照样静默给出
-2147483648。因为errstate控制的是 IEEE 754 的浮点异常标志(FPU 硬件旗标),整数加法根本不走那条路。⚠️ 所以all="raise"给的安全感只盖住了一半。 - 3000000,dtype 是
int64。sum()/mean()会自动升档(均值给float64),所以归约通常安全;危险的是逐元素运算——np.add.reduce(big, dtype=np.int16)强行按 int16 累加就给出 −14656,而raw * np.int16(2)让 25000、31000 变成 −15536、−3536。 True(dtype 还是bool)。布尔数组相加是逻辑或,不是 2。数 True 的个数要.astype(int)或直接.sum()。- 最可能是某处
astype(np.int16)(或int16数组做了乘法)把超范围的值截断了 —— 32767 是int16的上界。💀 值域检查抓不住32768 -> -32768,因为 −32768 是一个完全合法的int16;只能靠「它和前后样本的关系不对劲」发现。
🛑 可以停在这里
⚡ 走神救援
⭐ 这一章只讲整数——浮点精度选型和量化在别的板块,站内上百处
dtype全是深度学习数值格式,整数这一侧一处没有。⭐ 招牌实验:
uint8的 200 加 100 得到的是 44(也就是减掉 256),⭐⭐ 而警告条数是 0。同一件事写成标量倒会报溢出警告——💀 但真实代码里你遇到的永远是数组那一版:你不会写标量加法,你会写img + delta,然后几个像素从 250 悄悄变成 4。有符号更狠:最大值加 1 直接翻成最小的负数。⭐ 记住这几个数的样子:127、255、32767、65535、21 亿多、42 亿多——它们是溢出留在数据里的指纹。⚠️ 默认整数 dtype 平台和版本相关,依赖范围的地方一律显式写 dtype。
astype三件事同时发生:⚠️ 向零截断(和floor对负数结果不同)、np.round是银行家舍入(2.5 进到 2 而 3.5 进到 4)、⭐ 越界静默回绕。💀 最阴的一例是只超出上界 1,符号就翻了,而结果仍是一个合法值,值域检查抓不住。类型提升四条里最容易被绊的:Python 裸整数是弱标量、不会把数组 dtype 拉高;⚠️ 而有符号加无符号会取一个能装下两边的类型,极端情况下会一路提升到浮点。
💀💀 最该记住的一条:
np.errstate对整数回绕完全无效。 实测在all="raise"里整数照样静默回绕——因为 errstate 管的是 IEEE 754 的浮点异常旗标,整数加法根本不走那条路。整数这一侧只能自己兜:先升到大类型算完再查范围、用can_cast的安全模式、用iinfo自查。⭐ 好消息是求和、求均值这类会自己升档,危险的是逐元素运算。⭐ 附带一个独立的坑:布尔相加是逻辑或,
True + True还是True而不是 2。
下一节 👉 09-log域运算.md