🏠 总目录📚 本教程 06 · 索引的四种形态 ← →
📑 本页目录(点开跳转)

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

实跑输出:

操作步骤

  1. 基础切片 shape=(2, 2) 共享内存=True
  2. 花式索引 shape=(2, 4) 共享内存=False
  3. 布尔掩码 shape=(6,) 共享内存=False
  4. 整数索引 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]} 字节")

实跑输出:

关键信息

[[ 8 9 10 11]
[ 0 1 2 3]
[ 8 9 10 11]]
shape: (3, 4)
共享内存: False
第 2 行 -> 偏移 64 字节
第 0 行 -> 偏移 0 字节
第 2 行 -> 偏移 64 字节

⭐ 看那三个偏移:64、0、64。

「行号 +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
2891011

花式索引改完,原数组:

按行、列对齐查看数组
行 / 列列 0列 1列 2列 3
00123
14567
2891011

⭐ 同样是「取前两行再改掉」,一个改到了原数组、一个没有。 两段代码长得几乎一样,差别只在方括号里是 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
00123
145999999
2999999999999

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)

实跑输出:

结果对照

b[idx] += 1 -> [0 1 0 1 0] ⚠️ 1 号位只加了一次
np.add.at -> [0 3 0 1 0] ✅ 加了三次
np.bincount -> [0 3 0 1 0] ✅ 同一件事的专用通道
d[[1,1,1]] = [10,20,30] -> [ 0 30 0 0 0]

⭐ 为什么是 1 不是 3:b[idx] += 1 不是原子操作,它被拆成三步 ——

  1. tmp = b[idx] → 取出 [0, 0, 0, 0](一份拷贝,就是第三节那件事)
  2. tmp += 1 → 变成 [1, 1, 1, 1]
  3. 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。

⚠️ 想要交叉就写 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
01.2.2.
13.5.6.
27.7.9.
310.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
0FalseFalseFalseFalse
1FalseFalseTrueTrue
2TrueTrueTrueTrue

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)] 是同一件事(上面实跑输出完全相同)。 所以布尔掩码本质上是花式索引的一个便捷入口 —— 先把掩码翻译成整数下标,再走 ③ 那条路。 拷贝的理由一脉相承。

⚠️ 两条形状规矩:

三个高频翻车现场:

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)

实跑输出:

结果对照

✅ 正确 a[(a > 3) & (a < 8)] = [4 5 6 7]
❌ 用 and -> The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
⚠️ 少括号 a[(a > 3) & a < 8] = [ 0 1 2 3 4 5 6 7 8 9 10 11]
它算出来的掩码:
[[ True True True True]
[ True True True True]
[ True True True True]]
❌ 掩码长度不对 -> boolean index did not match indexed array along axis 0; size of axis is 3 but size of corresponding boolean axis is 2
现场 症状
用 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)

实跑输出:

操作步骤

  1. 花式索引挨着切片,位置不变: (3, 2, 5)
  2. 花式索引被切片隔开: (2, 4)
  3. 两个花式索引挨在一起: (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]] 这种写法本身就是个坏味道。 两个办法都比记规则可靠:


⚖️ 九、账单:视图 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) —

⭐ 两条判据,记住这两条就够了:

  1. 视图还是拷贝 → 问「这次选出来的东西,能不能用一组 shape + strides + 起点描述」。 能(等差)→ 视图;不能(任意顺序、重复、长度待定)→ 拷贝。
  2. 写得进去还是写不进去 → 数方括号。只有一个方括号才落到原数组上。

🔗 这一章连到哪里

相关的地方 为什么
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 是本章第六节的正主。那是一道题的写法,这一章是它成立的理由

✅ 检查点

  1. 用一句话说清「什么时候给视图、什么时候给拷贝」的判据。
  2. a = np.arange(12).reshape(3,4),a[1:3, ::2] 的 shape 和 strides 分别是多少?第二个 16 是怎么来的?
  3. a[[2, 0, 2]] 为什么没法是视图?拿那三行的字节偏移说明。
  4. a[:, 3] 选出的元素在内存里并不挨着,它还是视图吗?为什么?
  5. f = a[[0, 1]]; f[:] = -1 之后 a 变了吗?a[[0, 1]] = -1 呢?两者差在哪?
  6. a[a > 5][0] = 999 会发生什么?为什么 b[0][1] = 777 反而生效?该记住哪条判据?
  7. 💀 b = np.zeros(5, int); b[[1,1,1]] += 1 之后 b[1] 是几?为什么?该怎么写才对?
  8. a[rows, cols] 和 a[rows[:, None], cols] 分别得到什么形状?规则是什么?
  9. 布尔掩码为什么必然是拷贝?这个理由和花式索引的理由有什么不同?a[(a > 3) & a < 8] 少了一对括号又会怎样?
  10. a 是 (3,4,5),a[[0,1], :, [0,2]] 的 shape 是什么?为什么不是 (2,4,2)?同一个掩码要用 100 次,又该怎么写?
👀 答案
  1. 能不能用「一组新的 shape + strides + 起点」把同一块内存重新读出来 —— 能就给视图(基础切片),不能就只好拷一份(花式索引、布尔掩码)。
  2. shape (2, 2),strides (32, 16)。行步长照抄原来的 32(一整行 4 个 int64);列步长是 8 × 2 = 16,因为 ::2 隔一个取。起点还挪了 1 × 32 = 32 字节,所以第一个元素是 4。
  3. 那三行的偏移是 64、0、64 —— 既不是等差(先 +64 再 −64 再 +64),又有重复(第 2 行出现两次)。strides 只有「一格挪 N 字节」这一个位置,填不出这张任意顺序表,所以只能真抄一份。
  4. 还是视图。它的 strides 是 (80,):元素之间隔 80 字节,但间隔是等差的,一份说明书就描述得了。形态 ② 说的「整数索引给标量」是指每根轴都给整数;只给一部分,剩下的轴还在,那仍是基础索引。
  5. f[:] = -1 之后 a 没变(f 是拷贝);a[[0, 1]] = -1 之后 a 变了。差别是 Python 编译成了不同的调用:前者 __getitem__ 先抄一份再改那份拷贝,后者 __setitem__ 直接按下标往原数组写、压根没有中间数组。
  6. a[a > 5][0] = 999 什么都不会发生(a[1,2] 还是 6),而且不报错 —— 两个方括号分两步,写进了一个用完就被回收的临时拷贝。b[0][1] = 777 生效是因为 b[0] 是基础索引给的视图,往视图上写就是往原数组写。判据:想写进去,方括号就只能出现一次。
  7. 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)。
  8. 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) 更可靠。
  9. 因为结果的长度是 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。)
  10. (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

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