📑 本页目录(点开跳转)
06 · 索引的四种形态
⏱ 80 分钟 | ⭐ 方括号里放什么,决定你拿到的是原数组的一扇窗,还是一份复印件 —— 而这件事有一条唯一的判据
🎯 一句话
能用「一组新的 shape + strides + 起点」把同一块内存重新读出来的,就给你视图;读不出来的,只能拷一份给你。
上一章讲了「形状只是 shape + strides 的说明书」。 这一章是那条规则的直接推论 —— 索引的四种形态之所以有的免费有的花钱, 理由全部来自「这次选出来的东西,还能不能写成一份说明书」。
🧩 一、四种形态,一次看全
| # | 形态 | 长什么样 | 拿到什么 | 共享内存 |
|---|---|---|---|---|
| ① | 基础切片 | a[1:3, ::2] |
视图 | ✅ |
| ② | 整数索引 | a[2, 1](每根轴都给整数) |
0 维标量 | — |
| ③ | 花式索引(整数数组) | a[[0, 2]] |
新数组 | ❌ |
| ④ | 布尔掩码 | a[a > 5] |
新数组 | ❌ |
import numpy as np
a = np.arange(12).reshape(3, 4)
s = a[1:3, ::2] # ① 基础切片
i = a[2, 1] # ② 整数索引
f = a[[0, 2]] # ③ 花式索引(整数数组)
m = a[a > 5] # ④ 布尔掩码
for name, r in [("① 基础切片", s), ("③ 花式索引", f), ("④ 布尔掩码", m)]:
print(f"{name} shape={str(r.shape):8s} 共享内存={np.shares_memory(a, r)}")
print(f"② 整数索引 {i} {type(i).__name__} ndim={np.ndim(i)}")
实跑输出:
操作步骤
- 基础切片 shape=(2, 2) 共享内存=True
- 花式索引 shape=(2, 4) 共享内存=False
- 布尔掩码 shape=(6,) 共享内存=False
- 整数索引 9 int64 ndim=0
⚠️ 判断共享内存请一律用 np.shares_memory,不要用 .base is a ——
.base 指向的是最终持有内存的那个数组,不是上一层,视图套视图时它会骗你。原因见 05 章第三节。
⭐ 这张表不用背。 后面五节讲的是同一件事:为什么只有 ① 能是视图。
📐 二、① 基础切片:它天生就是一份说明书
a[1:3, ::2] 里每一维给的都是 起点 : 终点 : 步长。这三个数直接就能翻译成 strides:
import numpy as np
a = np.arange(12).reshape(3, 4)
s = a[1:3, ::2]
print("a", a.shape, a.strides)
print("s", s.shape, s.strides)
print(s)
print("共享内存:", np.shares_memory(a, s))
print("s 的第一个元素是 a 的第几个:", s[0, 0])
实跑输出:
要点
a (3, 4) (32, 8)
s (2, 2) (32, 16)
[[ 4 6]
[ 8 10]]
共享内存: True
s 的第一个元素是 a 的第几个: 4
一行一行对照:
| 说明书那一栏 | 怎么来的 |
|---|---|
| 起点 挪到第 4 个元素 | 行切片从 1 开始 → 1 × 32 = 32 字节 |
shape (2, 2) |
行取 2 个(1、2),列步长 2 取 2 个(0、2) |
strides (32, 16) |
行步长没变还是 32;列步长 8 × 2 = 16(隔一个取) |
⭐ 切片的每一维都是「等差数列」 —— 起点、公差、个数。 而 strides 能表达的恰好就是等差数列。两边严丝合缝,所以一个字节都不用搬。
⭐ 推论:切片写得多花哨都不要紧,照样是视图(连负步长都行,它只是让 strides 变成负数):
import numpy as np
a = np.arange(100).reshape(10, 10)
for expr in ("a[5:100:7, ::-1]", "a[::-1]", "a[2]", "a[:, 3]", "a[..., 2]"):
r = eval(expr)
print(f"{expr:18s} shape={str(r.shape):10s} strides={str(r.strides):11s} "
f"视图={np.shares_memory(a, r)}")
实跑输出:
对照
a[5:100:7, ::-1] shape=(1, 10) strides=(560, -8) 视图=True
a[::-1] shape=(10, 10) strides=(-80, 8) 视图=True
a[2] shape=(10,) strides=(8,) 视图=True
a[:, 3] shape=(10,) strides=(80,) 视图=True
a[ · , 2] shape=(10,) strides=(80,) 视图=True
⚠️ 注意 a[2] 和 a[:, 3] 也在这张表里。 形态 ② 说的是「每根轴都给整数」才得到标量;
只给一部分轴整数,剩下的轴还在,那还是基础索引 —— 依然是视图。
a[:, 3] 的 strides 是 (80,):沿这一列往下走一格要跳 80 字节,它选中的元素在内存里根本不挨着,
但因为间隔是等差的,一份说明书就够了。
🎭 三、③ 花式索引:它写不出这份说明书
换成一个整数数组,规矩当场就破了:
import numpy as np
a = np.arange(12).reshape(3, 4)
f = a[[2, 0, 2]]
print(f)
print("shape:", f.shape)
print("共享内存:", np.shares_memory(a, f))
for r in (2, 0, 2):
print(f"第 {r} 行 -> 偏移 {r * a.strides[0]} 字节")
实跑输出:
关键信息
⭐ 看那三个偏移:64、0、64。
- 不是等差的(先往后 64,再倒回去 −64,再往前 64)
- 而且重复了(第 2 行出现了两次)
「行号 +1 就挪 N 字节」这句话在这里根本没法填 —— N 该等于多少? strides 只有一个数字的位置,而这里需要任意一张顺序表。
⭐ 于是 NumPy 只剩一条路:真的把这些行抄进一块新内存。 这就是花式索引必然是拷贝的全部理由 —— 不是设计者偷懒,是说明书这个数据结构表达不了。
后果很实在:
import numpy as np
# 切片是视图:改它就是改原数组
a = np.arange(12).reshape(3, 4)
v = a[0:2]
v[:] = -1
print("切片改完,原数组:")
print(a)
# 花式索引是拷贝:改它,原数组毫无反应
b = np.arange(12).reshape(3, 4)
f = b[[0, 1]]
f[:] = -1
print("花式索引改完,原数组:")
print(b)
实跑输出:
切片改完,原数组:
| 行 / 列 | 列 0 | 列 1 | 列 2 | 列 3 |
|---|---|---|---|---|
| 0 | -1 | -1 | -1 | -1 |
| 1 | -1 | -1 | -1 | -1 |
| 2 | 8 | 9 | 10 | 11 |
花式索引改完,原数组:
| 行 / 列 | 列 0 | 列 1 | 列 2 | 列 3 |
|---|---|---|---|---|
| 0 | 0 | 1 | 2 | 3 |
| 1 | 4 | 5 | 6 | 7 |
| 2 | 8 | 9 | 10 | 11 |
⭐ 同样是「取前两行再改掉」,一个改到了原数组、一个没有。 两段代码长得几乎一样,差别只在方括号里是
0:2还是[0, 1]。
🚦 四、⚠️ 「花式索引是拷贝」这句话,只对【读】成立
上面那段容易让人得出一个错误的推论:「既然是拷贝,那我就没法用花式索引改原数组了」。 不对:
import numpy as np
a = np.arange(12).reshape(3, 4)
a[[0, 2]] = -1 # 直接赋值:写得进去
print(a)
b = np.arange(12).reshape(3, 4)
b[b > 7] = 0 # 布尔掩码同理
print(b)
实跑输出:
[[-1 -1 -1 -1]
[ 4 5 6 7]
[-1 -1 -1 -1]]
[[0 1 2 3]
[4 5 6 7]
[0 0 0 0]]
⭐ 原因是 Python 把这两句编译成了完全不同的调用:
| 你写的 | Python 调的 | NumPy 干什么 |
|---|---|---|
x = a[[0, 2]] |
a.__getitem__([0, 2]) |
挑出来,抄一份给你 |
a[[0, 2]] = -1 |
a.__setitem__([0, 2], -1) |
按下标直接往原数组里写,压根没有中间数组 |
💀 这条区别一旦踩错就是「代码不报错、也不生效」:
import numpy as np
a = np.arange(12).reshape(3, 4)
print("a[1, 2] =", a[1, 2])
a[a > 5][0] = 999 # ⚠️ 写进了一个临时拷贝
print("a[a>5][0] = 999 之后, a[1,2] =", a[1, 2])
a[a > 5] = 999 # ✅ 一次到位
print("a[a>5] = 999 之后:")
print(a)
b = np.arange(12).reshape(3, 4)
b[0][1] = 777 # 这个反而生效:b[0] 是视图
print("b[0][1] = 777 之后, b[0,1] =", b[0, 1])
实跑输出:
a[1, 2] = 6
a[a>5][0] = 999 之后, a[1,2] = 6
a[a>5] = 999 之后:
| 行 / 列 | 列 0 | 列 1 | 列 2 | 列 3 |
|---|---|---|---|---|
| 0 | 0 | 1 | 2 | 3 |
| 1 | 4 | 5 | 999 | 999 |
| 2 | 999 | 999 | 999 | 999 |
b[0][1] = 777 之后, b[0,1] = 777
a[a > 5][0] = 999 是两个方括号,于是分成两步:
先 __getitem__ 拿到一份临时拷贝,再往那份拷贝的第 0 位写 999 —— 写完那个临时对象就被回收了。
没有任何报错,a 一个数都没变。
⚠️ 而 b[0][1] = 777 竟然生效了,因为 b[0] 是基础索引给的视图,往视图上写就是往原数组写。
⭐ 所以链式索引「有时灵有时不灵」,取决于中间那一步是视图还是拷贝。 别去记哪种灵 —— 判据是:想写进去,方括号就只能出现一次。
💀 五、重复下标:+= 只加了一次
这是本章最贵的一个坑,因为它结果偏小、不报错、而且只在有重复下标时才出现:
import numpy as np
idx = np.array([1, 1, 1, 3])
b = np.zeros(5, dtype=int)
b[idx] += 1
print("b[idx] += 1 ->", b, " ⚠️ 1 号位只加了一次")
c = np.zeros(5, dtype=int)
np.add.at(c, idx, 1)
print("np.add.at ->", c, " ✅ 加了三次")
print("np.bincount ->", np.bincount(idx, minlength=5), " ✅ 同一件事的专用通道")
# 为什么:赋值那一步是「最后一个写的赢」
d = np.zeros(5, dtype=int)
d[[1, 1, 1]] = [10, 20, 30]
print("d[[1,1,1]] = [10,20,30] ->", d)
实跑输出:
结果对照
⭐ 为什么是 1 不是 3:b[idx] += 1 不是原子操作,它被拆成三步 ——
tmp = b[idx]→ 取出[0, 0, 0, 0](一份拷贝,就是第三节那件事)tmp += 1→ 变成[1, 1, 1, 1]b[idx] = tmp→ 按下标写回去
第 3 步里 1 号位被写了三次,每次写的都是 1,而且后写的盖掉先写的。
最后那行 d[[1,1,1]] = [10,20,30] 得到 30 就是这个机制的裸照 —— 最后一个写的赢。
⭐ 要「累加」而不是「覆盖」,用 np.add.at(它对每个下标逐个执行,不走「取出—改—写回」)。
np.bincount 是同一件事的专用快速通道,07 章的套路 ② 实测它比循环快 120–137 倍。
💀 这个 bug 的典型现场:直方图、词频、稀疏梯度累加 —— 全是「同一个桶会被撞好几次」的场景。 症状是总数比预期小,而且小多少取决于数据里重复得有多厉害,看起来毫无规律。
🛑 读到这里可以停 —— 前半章讲完了(约 20 分钟)。 你已经拿到两条判据:能不能写成一份 strides 说明书(视图还是拷贝), 以及数方括号(写不写得进去),外加「重复下标
+=只加一次」这个最贵的坑。 后半章还有:多个索引数组是配对不是交叉 · 布尔掩码的三个翻车现场 · 花式索引被切片隔开时结果轴会跑到最前面 · 视图和拷贝的实测账单 · 一页速记。 回来的时候不用重读,直接从下一节接着看就行。
🧮 六、两个整数数组是「配对」,不是「交叉」
给两根轴各一个整数数组时,NumPy 不做笛卡尔积,而是把它们逐位配对:
import numpy as np
a = np.arange(12).reshape(3, 4)
rows = np.array([0, 2])
cols = np.array([1, 3])
print("配对 a[rows, cols] =", a[rows, cols], " shape", a[rows, cols].shape)
cross = a[rows[:, None], cols]
print("交叉 a[rows[:,None], cols] shape", cross.shape)
print(cross)
print("np.ix_ 同样效果:")
print(a[np.ix_(rows, cols)])
实跑输出:
对照
配对 a[rows, cols] = [ 1 11] shape (2,)
交叉 a[rows[:,None], cols] shape (2, 2)
[[ 1 3]
[ 9 11]]
np.ix_ 同样效果:
[[ 1 3]
[ 9 11]]
⭐ 规则只有一条:把几个索引数组按广播规则对齐,对齐后的 shape 就是结果的 shape。
rows是(2,)、cols是(2,)→ 广播成(2,)→ 结果 2 个元素,取的是a[0,1]和a[2,3]rows[:, None]是(2,1)、cols是(2,)→ 广播成(2,2)→ 才拿到那张 2×2 的交叉表
⚠️ 想要交叉就写 np.ix_(rows, cols),它替你插好那些长度 1 的轴,比手数 None 可靠。
⭐ 「配对」这个语义本身极其有用 —— 它是「每行各取一个」的标准写法:
import numpy as np
# 每行取出「属于本行标签」的那一个分数
scores = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0],
[10., 11., 12.]])
y = np.array([2, 0, 1, 2]) # 每行的正确类别
n = len(y)
print("每行的正确类分数:", scores[np.arange(n), y])
scores[np.arange(n), y] -= 1.0 # 就地改回去
print(scores)
实跑输出:
每行的正确类分数: [ 3. 4. 8. 12.]
| 行 / 列 | 列 0 | 列 1 | 列 2 |
|---|---|---|---|
| 0 | 1. | 2. | 2. |
| 1 | 3. | 5. | 6. |
| 2 | 7. | 7. | 9. |
| 3 | 10. | 11. | 11. |
np.arange(n) 和 y 都是 (n,),配对之后正好是 (0,y₀), (1,y₁), … —— 每行一个。
这就是 ML 基础 附录 C 第 1 题(Softmax + 交叉熵)里
grad[np.arange(n), y] -= 1.0 那行的机制。⚠️ 那里是顺手写的、没有一个字解释过它为什么这么写。
⚠️ 顺带注意上面那行 -= 能生效,正是因为它只有一个方括号(第四节)。
而如果 y 里有重复……不会有问题,因为 np.arange(n) 保证了每对下标都不同 —— 重复下标的坑在这里恰好躲开了。
🔍 七、④ 布尔掩码:长度要到运行时才知道
import numpy as np
a = np.arange(12).reshape(3, 4)
m = a > 5
print("掩码本身:", m.dtype, m.shape)
print(m)
print("a[m] =", a[m], " shape", a[m].shape)
print("共享内存:", np.shares_memory(a, a[m]))
print("等价写法 a[np.nonzero(m)] =", a[np.nonzero(m)])
print("nonzero 给的是下标:", np.nonzero(m))
rowmask = np.array([True, False, True])
print("按行选:", a[rowmask].shape)
实跑输出:
掩码本身: bool (3, 4)
| 行 / 列 | 列 0 | 列 1 | 列 2 | 列 3 |
|---|---|---|---|---|
| 0 | False | False | False | False |
| 1 | False | False | True | True |
| 2 | True | True | True | True |
a[m] = [ 6 7 8 9 10 11] shape (6,)
共享内存: False
等价写法 a[np.nonzero(m)] = [ 6 7 8 9 10 11]
nonzero 给的是下标: (array([1, 1, 2, 2, 2, 2]), array([2, 3, 0, 1, 2, 3]))
按行选: (2, 4)
⭐ 布尔掩码为什么必然是拷贝,理由比花式索引还硬:
结果的长度是 mask.sum() —— 要把整个掩码扫一遍才知道。
而说明书要求你在读数据之前就把 shape 填好。这里根本填不出来。
⭐ a[m] 和 a[np.nonzero(m)] 是同一件事(上面实跑输出完全相同)。
所以布尔掩码本质上是花式索引的一个便捷入口 —— 先把掩码翻译成整数下标,再走 ③ 那条路。
拷贝的理由一脉相承。
⚠️ 两条形状规矩:
- 二维掩码 → 一维结果(选中的元素按 C 序摊平),因为「每行选中几个」不一定相等,摊平是唯一能保证矩形的做法
- 一维掩码可以只作用在一根轴上:
a[rowmask]得到(2, 4),整行整行地选
三个高频翻车现场:
import numpy as np
a = np.arange(12).reshape(3, 4)
print("✅ 正确 a[(a > 3) & (a < 8)] =", a[(a > 3) & (a < 8)])
try:
a[a > 3 and a < 8]
except ValueError as e:
print("❌ 用 and ->", e)
bad = (a > 3) & a < 8 # ⚠️ 少一对括号,不报错
print("⚠️ 少括号 a[(a > 3) & a < 8] =", a[bad])
print(" 它算出来的掩码:")
print(bad)
try:
a[np.array([True, False])]
except IndexError as e:
print("❌ 掩码长度不对 ->", e)
实跑输出:
结果对照
| 现场 | 症状 |
|---|---|
用 and / or / not |
当场报错:The truth value of an array with more than one element is ambiguous |
| 少一对括号 | ⚠️⚠️ 不报错,静默给出全错的答案 |
| 掩码长度对不上 | 报错,而且报得很清楚(size of axis is 3 but ... is 2) |
💀 中间那一条最危险。 (a > 3) & a < 8 里 & 的优先级高于 <,
于是它算的是 ((a > 3) & a) < 8 —— 先把布尔和整数按位与,再比 8。
上面实跑的结果是全 True,a[bad] 把 12 个元素一个不落全返回了。
⭐ 规矩:比较表达式一律加括号,(x > 3) & (x < 8),一个不漏。
🧷 八、混着用:花式索引被切片隔开时,结果轴会跑到最前面
这是 NumPy 索引里唯一一条真正反直觉的规则:
import numpy as np
a = np.arange(60).reshape(3, 4, 5)
print("① 花式索引挨着切片,位置不变:", a[:, [0, 2], :].shape)
print("② 花式索引被切片隔开: ", a[[0, 1], :, [0, 2]].shape)
print("③ 两个花式索引挨在一起: ", a[[0, 1], [0, 2], :].shape)
实跑输出:
操作步骤
- 花式索引挨着切片,位置不变: (3, 2, 5)
- 花式索引被切片隔开: (2, 4)
- 两个花式索引挨在一起: (2, 5)
| 写法 | 花式索引在哪 | 结果 shape | 那根「被选出来的」轴在第几位 |
|---|---|---|---|
a[:, [0,2], :] |
只有一处 | (3, 2, 5) |
原位(第 1 位) |
a[[0,1], [0,2], :] |
两处挨着 | (2, 5) |
原位(第 0 位) |
a[[0,1], :, [0,2]] |
两处,被 : 隔开 |
(2, 4) |
⚠️ 被提到最前面 |
第三行发生了两件事:0 轴和 2 轴的两个索引数组配对成 2 个坐标(第六节那条规则),
然后因为它们不连续,NumPy 不知道该把这根新轴放回哪一位,索性放最前面,剩下的 : 那根轴(长度 4)跟在后面。
⭐ 不用背这条规则,用它的推论就够了:a[[0,1], :, [0,2]] 这种写法本身就是个坏味道。
两个办法都比记规则可靠:
- 拆成两步:
a[[0, 1]][:, :, [0, 2]](⚠️ 代价是两次拷贝) - 补齐维度自己控制形状:
a[np.array([0,1])[:, None], :, np.array([0,2])[:, None]]之类,让广播明确
⚖️ 九、账单:视图 0.14 微秒,拷贝 6 毫秒
前面八节讲的都是语义。这一节给的是价格:
import numpy as np, timeit
n = 2000
a = np.ones((n, n))
idx = np.arange(0, n, 2) # 和 ::2 选出完全一样的行
print("选出的东西一模一样:", np.array_equal(a[::2], a[idx]))
print(f"结果占 {a[idx].nbytes / 2**20:.0f} MB")
t_sl = min(timeit.repeat(lambda: a[::2], number=20, repeat=7)) / 20
t_cp = min(timeit.repeat(lambda: a[::2].copy(), number=20, repeat=7)) / 20
t_fa = min(timeit.repeat(lambda: a[idx], number=20, repeat=7)) / 20
print(f"切片(视图) {t_sl*1e6:8.2f} us")
print(f"切片 + .copy() {t_cp*1e6:8.2f} us")
print(f"花式索引 {t_fa*1e6:8.2f} us")
本机(numpy 2.4.6)实测,三次重跑:
对照
选出的东西一模一样: True
结果占 15 MB
切片(视图) 0.14 us
切片 + .copy() 5595.84 us
花式索引 5850.59 us
| 写法 | 三次实测 | 在干什么 |
|---|---|---|
a[::2] |
0.13 – 0.14 微秒 | 填一份新说明书,和数组多大完全无关 |
a[::2].copy() |
5.6 – 6.6 毫秒 | 真的搬 15 MB |
a[idx] |
5.9 – 6.9 毫秒 | 同样搬 15 MB |
⭐ 后两行几乎一样,这才是重点:花式索引的开销就是拷贝的开销,一分不多。 它不慢在「花式」,慢在「它必须拷」。而切片快了大约五个数量级,因为它一个字节都没碰。
⚠️ 但别因此就认定「布尔掩码总是最慢的那个」。 命中很稀疏时,反过来了:
import numpy as np, timeit
rng = np.random.default_rng(0)
x = rng.random(5_000_000)
mask = x < 0.001 # 只有千分之一命中
idx = np.flatnonzero(mask) # 同一批元素的整数下标
print("命中", idx.size, "个 /", x.size)
print("结果一样:", np.array_equal(x[mask], x[idx]))
t_mask = min(timeit.repeat(lambda: x[mask], number=20, repeat=5)) / 20
t_idx = min(timeit.repeat(lambda: x[idx], number=20, repeat=5)) / 20
print(f"布尔掩码 {t_mask*1e6:8.1f} us 整数下标 {t_idx*1e6:8.1f} us {t_mask/t_idx:.0f}x")
实跑(三次重跑,贴的是第一次):
要点
命中 5031 个 / 5000000
结果一样: True
布尔掩码 809.6 us 整数下标 9.8 us 83x
另两次是 863.6 vs 19.7(44x)、1053.8 vs 22.2(48x)。
⚠️ 你的绝对数值会不同(这台机器有热降频,同一个脚本重跑倍数就在 44–83x 之间晃),
但方向是结构性的:
⭐ 布尔掩码每次都要把 500 万个 bool 从头扫一遍(结果多少个只有扫完才知道), 而整数下标只碰那 5031 个。所以:
⭐ 同一个掩码要反复用很多次时,先
idx = np.flatnonzero(mask)存下来,之后一律用x[idx]。 只用一次就别折腾 ——flatnonzero自己也要扫一遍。
(np.flatnonzero 在 07 章的套路 ⑥ 里还有另一个用法:配合 np.diff 找相邻变化。)
🛑 读到这里可以停 —— 已经读了约 58 分钟。 最后一段还有(约 21 分钟):一页速记 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
📋 十、一页速记
| 想干的事 | 写法 | 视图还是拷贝 |
|---|---|---|
| 取连续/等距的一块 | a[1:3, ::2] |
视图 |
| 取一整行 / 一整列 | a[2] / a[:, 3] |
视图 |
| 倒序 | a[::-1] |
视图(strides 变负) |
| 取一个标量 | a[2, 1] |
0 维标量 |
| 按任意顺序挑几行 | a[[2, 0, 2]] |
拷贝 |
| 按条件挑元素 | a[a > 5] |
拷贝 |
| 每行各取一个 ⭐ | a[np.arange(n), y] |
拷贝(配对,不是交叉) |
| 交叉取(行 × 列的小方阵) | a[np.ix_(rows, cols)] |
拷贝 |
| 把切片变成独立的一份 | a[1:3].copy() |
拷贝(明码标价) |
| 重复下标累加 💀 | np.add.at(b, idx, 1) 或 np.bincount(idx) |
就地写 |
| 掩码 → 整数下标 | np.nonzero(mask)(每根轴一个数组)/ np.flatnonzero(mask)(摊平后的下标) |
— |
| 判断两个数组共不共享内存 | np.shares_memory(a, b) |
— |
⭐ 两条判据,记住这两条就够了:
- 视图还是拷贝 → 问「这次选出来的东西,能不能用一组 shape + strides + 起点描述」。 能(等差)→ 视图;不能(任意顺序、重复、长度待定)→ 拷贝。
- 写得进去还是写不进去 → 数方括号。只有一个方括号才落到原数组上。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 05 · 内存布局与 stride | ⭐ 这一章的全部理由都在那里。本章说「能不能写成一份说明书」,那一章讲说明书里到底有什么、strides 怎么算 |
| 04 · 视图还是拷贝 | 那一章告诉你「改了会不会串」这件事有多要命,本章告诉你哪种语法会串 |
| 03 · 广播的三条规则 | 第六节「多个索引数组按广播规则对齐」用的就是那三条规则,rows[:, None] 也是那一章的模式 |
| 07 · 把循环改写成数组运算 | ⭐ 套路 ② 的 np.bincount 是本章第五节那个「重复下标」问题的专用快速通道(实测比循环快 120–137 倍);套路 ⑥ 的 np.flatnonzero 是本章第九节那个「掩码转下标」的另一个用法 |
| 02 · shape 和 axis 到底怎么数 | a[:, None] 用的是索引语法,本章第六节拿它做「配对 vs 交叉」的开关 |
| ML 基础 附录 C · 手撕代码速查 | ⭐ 第 1 题(Softmax + 交叉熵)那行 grad[np.arange(n), y] -= 1.0 是本章第六节的正主。那是一道题的写法,这一章是它成立的理由 |
✅ 检查点
- 用一句话说清「什么时候给视图、什么时候给拷贝」的判据。
a = np.arange(12).reshape(3,4),a[1:3, ::2]的 shape 和 strides 分别是多少?第二个 16 是怎么来的?a[[2, 0, 2]]为什么没法是视图?拿那三行的字节偏移说明。a[:, 3]选出的元素在内存里并不挨着,它还是视图吗?为什么?f = a[[0, 1]]; f[:] = -1之后a变了吗?a[[0, 1]] = -1呢?两者差在哪?a[a > 5][0] = 999会发生什么?为什么b[0][1] = 777反而生效?该记住哪条判据?- 💀
b = np.zeros(5, int); b[[1,1,1]] += 1之后b[1]是几?为什么?该怎么写才对? a[rows, cols]和a[rows[:, None], cols]分别得到什么形状?规则是什么?- 布尔掩码为什么必然是拷贝?这个理由和花式索引的理由有什么不同?
a[(a > 3) & a < 8]少了一对括号又会怎样? a是(3,4,5),a[[0,1], :, [0,2]]的 shape 是什么?为什么不是(2,4,2)?同一个掩码要用 100 次,又该怎么写?
👀 答案
- 能不能用「一组新的 shape + strides + 起点」把同一块内存重新读出来 —— 能就给视图(基础切片),不能就只好拷一份(花式索引、布尔掩码)。
- shape
(2, 2),strides(32, 16)。行步长照抄原来的 32(一整行 4 个 int64);列步长是8 × 2 = 16,因为::2隔一个取。起点还挪了1 × 32 = 32字节,所以第一个元素是4。 - 那三行的偏移是 64、0、64 —— 既不是等差(先 +64 再 −64 再 +64),又有重复(第 2 行出现两次)。strides 只有「一格挪 N 字节」这一个位置,填不出这张任意顺序表,所以只能真抄一份。
- 还是视图。它的 strides 是
(80,):元素之间隔 80 字节,但间隔是等差的,一份说明书就描述得了。形态 ② 说的「整数索引给标量」是指每根轴都给整数;只给一部分,剩下的轴还在,那仍是基础索引。 f[:] = -1之后a没变(f是拷贝);a[[0, 1]] = -1之后a变了。差别是 Python 编译成了不同的调用:前者__getitem__先抄一份再改那份拷贝,后者__setitem__直接按下标往原数组写、压根没有中间数组。a[a > 5][0] = 999什么都不会发生(a[1,2]还是6),而且不报错 —— 两个方括号分两步,写进了一个用完就被回收的临时拷贝。b[0][1] = 777生效是因为b[0]是基础索引给的视图,往视图上写就是往原数组写。判据:想写进去,方括号就只能出现一次。b[1]是 1,不是 3。 因为+=被拆成「取出拷贝 → 加 1 → 按下标写回」,第三步里 1 号位被写了三次、每次写的都是1,后写的盖掉先写的(d[[1,1,1]] = [10,20,30]得到30就是这个机制的裸照)。正确写法:np.add.at(b, idx, 1),或者用专用通道np.bincount(idx, minlength=5)。a[rows, cols]得(2,)(两个索引数组都是(2,),广播还是(2,),逐位配对取a[0,1]和a[2,3]);a[rows[:, None], cols]得(2,2)((2,1)和(2,)广播成(2,2),才是交叉表)。规则:把几个索引数组按广播规则对齐,对齐后的 shape 就是结果的 shape。 想要交叉写np.ix_(rows, cols)更可靠。- 因为结果的长度是
mask.sum(),要把整个掩码扫完才知道,而 shape 必须在读数据之前就填好 —— 这里根本填不出来。和花式索引的区别:花式索引是「顺序表达不了」(偏移不等差),布尔掩码是「长度都还不知道」,比前者更硬。(顺带:a[m]和a[np.nonzero(m)]输出完全相同,布尔掩码本质是花式索引的便捷入口。)少一对括号 ⚠️⚠️ 不报错,静默给出全错的答案 ——&的优先级高于<,实际算的是((a > 3) & a) < 8,实跑得到全 True 的掩码,12 个元素一个不落全返回了。规矩:比较表达式一律加括号。(用and反而好,它会当场报The truth value of an array with more than one element is ambiguous。) (2, 4)。两个索引数组先配对成 2 个坐标(不是交叉,所以没有那个2×2),又因为它们被中间的:隔开,NumPy 不知道该把这根新轴放回哪一位,索性放最前面,剩下那根长度 4 的轴跟在后面。不用背 —— 这种写法本身就是坏味道,拆成两步a[[0,1]][:, :, [0,2]]更清楚。掩码要反复用就先idx = np.flatnonzero(mask)存下来,之后一律x[idx]:布尔掩码每次都要把 500 万个 bool 从头扫一遍,而整数下标只碰命中的那 5031 个 —— 三次实测 809.6 / 9.8(83x)、863.6 / 19.7(44x)、1053.8 / 22.2(48x),单位都是微秒。⚠️ 只用一次就别折腾,flatnonzero自己也要扫一遍。
🛑 可以停在这里
⚡ 走神救援
⭐ 能用「一组新的 shape + strides + 起点」把同一块内存重新读出来的,就给你视图;读不出来的,只能拷一份。 索引的四种形态全部由这一条决定。
基础切片是视图,因为每一维都是「起点 + 公差 + 个数」的等差数列,而 strides 能表达的恰好就是等差数列——⚠️ 所以哪怕选出的元素在内存里根本不挨着(比如取一整列),只要间隔是等差的就仍然是视图。
花式索引和布尔掩码是拷贝:前者的偏移既不等差又可能重复,「一格挪 N 字节」根本填不出来;后者更硬——结果长度要扫完才知道,而 shape 必须在读数据之前填好。
⚠️⚠️ 但「拷贝」只对读成立:
a[[0,1]] = -1是写得进去的。区别在于前者是__getitem__(先抄一份),后者是__setitem__(直接按下标写)。💀 踩错的形态是
a[a>5][0] = 999:两个方括号 = 两步,写进了一个用完就回收的临时拷贝,原数组一个数没变而且不报错。⭐ 别记哪种灵——想写进去,方括号就只能出现一次。💀💀 本章最贵的坑:
b[[1,1,1]] += 1之后只加了 1 不是 3——因为+=被拆成「取出拷贝 → 加 → 按下标写回」,同一位置被写三次、后写的盖掉先写的。要累加用np.add.at,专用快速通道是np.bincount。⚠️ 典型现场是直方图、词频、稀疏梯度累加,症状是总数比预期小,小多少取决于数据重复得多厉害,看起来毫无规律。⭐ 多个整数数组是「配对」不是「交叉」:要交叉表得先把一边升维,或者用
np.ix_。而「配对」这个语义正是「每行各取一个」的标准写法。
下一节 👉 07-把循环改写成数组运算.md