🏠 总目录📚 本教程 08 · 整数 dtype 的真相 ← →
📑 本页目录(点开跳转)

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))

关键信息

数组 [200]uint8 + uint8(100) = [44] | 警告条数 0
标量 uint8(200) + uint8(100) = 44 | 警告条数 1
-> RuntimeWarning: overflow encountered in scalar add
数组+数组 = [44] | 警告条数 0
int32 最大值 +1 = [-2147483648] | 警告条数 0

⭐ 这就是全章的招牌:三个式子算的是同一件事,答案都是 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)
整数类型的表示范围
整数类型位宽范围
int88 bit-128 ~ 127
uint88 bit0 ~ 255
int1616 bit-32768 ~ 32767
uint1616 bit0 ~ 65535
int3232 bit-2147483648 ~ 2147483647
uint3232 bit0 ~ 4294967295
int6464 bit-9223372036854775808 ~ 9223372036854775807
uint6464 bit0 ~ 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()))

关键信息

直接 astype [ 0 0 2 -2 3 1000000000]
round 再 astype [ 1 -1 2 -2 4 1000000000]
floor 再 astype [ 0 -1 2 -3 3 1000000000]
int64 -> int16 [-32768 4464 -1]
int64 -> uint8 [ 0 112 255]
int16 范围 -32768 ~ 32767 | 全部装得下吗: False

三件事同时发生了:

现象 实证 说明
向零截断 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)

关键信息

int8 数组 + Python int 1 -> int8
int8 数组 + np.int64(1) -> int64
int8 数组 + int64 数组 -> int64
uint8 + int8 -> int16
uint8 + int16 -> int16
uint64 + int64 -> float64
int64 + float32 -> float64
int32 + float32 -> float64
int8 数组 + 1000 -> OverflowError: Python integer 1000 out of bounds for int8

规则拆开是四条:

  1. ⭐ Python 的裸整数是「弱标量」,不会拉高数组的 dtype(NEP 50)。int8 数组 + 1 还是 int8。 ⭐ 但它越界时会报 OverflowError ——a8 + 1000 直接抛异常。这是 NumPy 2 少数几个主动帮你挡了一下的地方,请珍惜,因为下一条里它就不管了。
  2. 有 dtype 的标量(np.int64(1))和数组一样参与提升。int8 + np.int64(1) → int64。 💀 所以 a8 + 1 和 a8 + np.int64(1) 结果的 dtype 不一样,而它们看起来只差一个包装。
  3. 有符号 + 无符号 = 找一个能同时装下两边的。uint8(0–255)+ int8(−128–127)→ int16,因为没有 8 位类型能覆盖 −128–255。 ⚠️ uint64 + int64 找不到这样的整数类型,于是掉到 float64 ——超过 2^53 之后就丢精度了,而且不报错。
  4. 整数 + 浮点,提升到「能装下这个整数精度的最小浮点」。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"))

关键信息

浮点溢出被抓到: overflow encountered in exp
整数回绕没被抓到: [-2147483648]
自己查出来了: 结果超出 int32 的范围 -2147483648~2147483647
np.can_cast(int32 -> int16, 'safe') = False
np.can_cast(int16 -> int32, 'safe') = True

⭐ 原因: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]))

关键信息

原始 int16 [12000 25000 31000]
直接 *2 (int16) [ 24000 -15536 -3536]
先升 int32 再乘 [24000 50000 62000]
sum 的 dtype int64 值 3000000
逐元素累加 int16 -14656
mean 的 dtype float64 值 30000.0
int16 + float32 -> float32
bool + bool -> bool [ 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 则相反,位宽是定死的、越界直接回绕

✅ 检查点

  1. np.array([200], np.uint8) + np.uint8(100) 等于多少?为什么是这个数?它会报警告吗?
  2. 同样的运算写成 np.uint8(200) + np.uint8(100) 有什么不同?
  3. np.array([0.9999, -0.9999]).astype(np.int32) 得到什么?和 np.floor 的结果一样吗?
  4. np.round(np.array([2.5, 3.5])) 得到什么?为什么不是 3 和 4?
  5. np.array([1,2,3], np.int8) + 1 的 dtype 是什么?+ np.int64(1) 呢?+ 1000 呢?
  6. uint8 + int8 为什么提升成 int16?uint64 + int64 又提升成什么?
  7. with np.errstate(all="raise") 能不能抓住 int32 最大值 +1?为什么?
  8. np.full(100, 30000, np.int16).sum() 是多少?dtype 是什么?为什么它没有溢出,而 raw * 2 溢出了?
  9. np.array([True]) + np.array([True]) 等于什么?
  10. 数据里出现一撮值正好等于 32767,最可能的成因是什么?为什么值域检查抓不住 32768 -> -32768 这种回绕?
👀 答案
  1. [44]。uint8 只能表示 0–255,300 超出后取模:300 − 256 = 44。⚠️ 不报警告,实测警告条数 0。
  2. 值同样是 44,但会报 RuntimeWarning: overflow encountered in scalar add(实测警告条数 1)。这就是全章招牌:只有标量吭声,而真实代码里遇到的永远是数组那一版。
  3. [0, 0] ——向零截断。⚠️ 和 np.floor 不一样:floor 给 [0, -1]。负数那一侧两者才分得开。
  4. [2., 4.]。np.round 是银行家舍入:五往偶数那边凑,所以 2.5 → 2 而 3.5 → 4。
  5. + 1 → int8(NEP 50 的弱标量不拉高数组 dtype);+ np.int64(1) → int64(有 dtype 的标量参与提升);+ 1000 → 抛 OverflowError: Python integer 1000 out of bounds for int8。
  6. 因为 uint8 是 0–255、int8 是 −128–127,没有 8 位类型能覆盖 −128–255,所以升到 int16。⚠️ uint64 + int64 找不到能装下两边的整数类型,掉到 float64 ——超过 2^53 就丢精度且不报错。
  7. 抓不住,实测照样静默给出 -2147483648。因为 errstate 控制的是 IEEE 754 的浮点异常标志(FPU 硬件旗标),整数加法根本不走那条路。⚠️ 所以 all="raise" 给的安全感只盖住了一半。
  8. 3000000,dtype 是 int64。sum() / mean() 会自动升档(均值给 float64),所以归约通常安全;危险的是逐元素运算——np.add.reduce(big, dtype=np.int16) 强行按 int16 累加就给出 −14656,而 raw * np.int16(2) 让 25000、31000 变成 −15536、−3536。
  9. True(dtype 还是 bool)。布尔数组相加是逻辑或,不是 2。数 True 的个数要 .astype(int) 或直接 .sum()。
  10. 最可能是某处 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

打卡记录保存在你的浏览器里,首页能看到总进度