📑 本页目录(点开跳转)
NumPy与向量化思维 · 按需阅读
只改了一个数组,另一个为什么也变了?
先看两段几乎一样的代码跑出什么,再追查它们是否共享内存。
从本章实验看差别
c = b[::2] 后改 c,b 变了;c = b[[0, 2]] 后改 c,b 没变。注意:直接给 b 的下标赋值是另一件事。
04 · 视图还是拷贝
⏱ 106 分钟 | ⭐ 两行只差一对方括号的代码,一行把原数组改了,一行没有 —— 而 NumPy 两次都没吭声
🎯 一句话
NumPy 的很多操作返回的不是新数据,是「同一块内存的另一种读法」。改了它,原数组跟着变 —— 不报错、不警告,唯一可靠的判据是 np.shares_memory(a, b)。
上一章那类错误至少还留了点痕迹:要么 ValueError,要么结果形状变成一张莫名其妙的方阵。
这一章是完全没有信号的那一类:程序跑通了、形状对、数字看着也合理,
只是你的输入数据在中途被人改掉了。
先观察:两种读取,两种结果
先预测 b 会不会变,再运行第一段代码核对。
🧩 一、两行只差一对方括号
import numpy as np
b = np.arange(6)
c = b[::2] # 基础切片
c += 1
print("c = b[::2]; c += 1 -> b =", b)
b = np.arange(6)
c = b[[0, 2]] # 花式索引(整数数组)
c += 1
print("c = b[[0,2]]; c += 1 -> b =", b)
b = np.arange(6)
print()
print("np.shares_memory(b, b[::2]) =", np.shares_memory(b, b[::2]))
print("np.shares_memory(b, b[[0,2]]) =", np.shares_memory(b, b[[0, 2]]))
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 切片后改 c | c = b[::2]; c += 1 -> b = [1 1 3 3 5 5] |
| 花式索引后改 c | c = b[[0,2]]; c += 1 -> b = [0 1 2 3 4 5] |
| 切片是否共享 | np.shares_memory(b, b[::2]) = True |
| 花式索引是否共享 | np.shares_memory(b, b[[0,2]]) = False |
⭐ 两行代码长得几乎一样,一行动了 b,一行没动。
b[::2]给的是视图 ——c和b是两个不同的 Python 对象,但指着同一块内存。c += 1就地改那块内存,b当然跟着变。b[[0, 2]]给的是拷贝 —— 数据被抄了一份出来,改c和b没关系。
⚠️ 两行都不报错,都返回一个正常的数组。 差别不在语法、不在形状、不在类型, 只在「返回的东西背后是不是同一块内存」。
⚠️⚠️ 先别急着总结成「花式索引不会改原数组」 —— 第四节会给你一个反例,
那是本章最容易记反的一条。这里成立的只有一句话:b[[0,2]] 这次【读】回来的东西是拷贝。
怎么判断是否共享内存
判据说明和完整写法表放在一起;.base 回答的不是同一个问题。
🔍 二、判据:np.shares_memory,不是 .base
结论先放这儿,这一行你会用一辈子:⭐ np.shares_memory(a, b) —— 唯一在所有情况下都对的判据。
.base 看起来更直观 —— 视图有 base、拷贝没有 —— 而且在最简单的情形下它确实对:
import numpy as np
# 情形一:源数组自己就是内存的持有者
b = np.arange(6)
c = b[::2]
print("b 自己持有内存吗 b.base is None :", b.base is None)
print("c.base is b :", c.base is b)
# 情形二:源数组本身也是个视图
base = np.arange(12)
a = base.reshape(3, 4)
print()
print("a.base is base :", a.base is base)
print("a.T.base is a :", a.T.base is a, " <- 直觉会以为 True")
print("a.T.base is base :", a.T.base is base)
print("np.shares_memory(a, a.T) :", np.shares_memory(a, a.T))
# 拷贝的 .base
print()
print("a[[0, 2]].base :", a[[0, 2]].base)
print("a.copy().base :", a.copy().base)
print("np.shares_memory(a, a.copy()) :", np.shares_memory(a, a.copy()))
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 源数组持有内存 | b 自己持有内存吗 b.base is None : True |
| 直接视图的 base | c.base is b : True |
| reshape 的 base | a.base is base : True |
| 转置的上一层 | a.T.base is a : False <- 直觉会以为 True |
| 转置的持有者 | a.T.base is base : True |
| 转置共享内存 | np.shares_memory(a, a.T) : True |
| 花式索引的 base | a[[0, 2]].base : None |
| copy 的 base | a.copy().base : None |
| copy 不共享 | np.shares_memory(a, a.copy()) : False |
⭐ c.base is b 那一行是对的,但它有前提:b = np.arange(6) 自己就是那块内存的持有者
(b.base is None)。一旦源数组本身也是个视图,这个写法就开始骗你。
⚠️ 两个方向都会错,所以 .base 不能当判据:
| 错法 | 实测 | 为什么 |
|---|---|---|
| 共享内存,却判成「不是视图」 | a.T.base is a → False |
.base 不指向上一层,直接指向最终持有那块内存的数组(a.T.base is base 才是 True) |
| 不共享内存,却判成「是视图」 | 见下 | 有些操作会先偷偷拷一份,返回的东西 .base 指向那份临时拷贝,而不是你手里的 a |
第二条在第三节的表里有两个现成的例子(a.T.reshape(12) 和 a[1:3, [0, 2]]):
它们的 .base 不是 None,但 np.shares_memory(a, ...) 是 False。
所以 if x.base is not None: # 那它是个视图 这个写法会在两个方向上都出错。
⭐ 第一条的完整机制在 05 章第三节,那里把「视图的视图」这一层讲透了。 这一章只要你记住结论:判共享内存,一律写
np.shares_memory。 ⚠️ 它在超大数组上可能偏慢,替代品和取舍也在 05 章第三节。
📋 三、一张判据表
把常见写法一次跑完,别猜:
展开完整代码(34 行,原样保留)
import numpy as np
a = np.arange(12).reshape(3, 4).copy() # ⭐ 让 a 自己持有内存,a.base is None
print("a.base is None =", a.base is None, "\n")
cases = [
("a[1:3] 基础切片", lambda: a[1:3]),
("a[::2] 带步长切片", lambda: a[::2]),
("a[1] 整数下标", lambda: a[1]),
("a[:, None] 加一根轴", lambda: a[:, None]),
("a.T 转置", lambda: a.T),
("a.reshape(4, 3) reshape(连续)", lambda: a.reshape(4, 3)),
("a.T.reshape(12) reshape(不连续)", lambda: a.T.reshape(12)),
("a.ravel() ravel", lambda: a.ravel()),
("a.T.ravel() ravel(不连续)", lambda: a.T.ravel()),
("a.flatten() flatten", lambda: a.flatten()),
("a.view(np.int32) 改 dtype 解释", lambda: a.view(np.int32)),
("a.astype(a.dtype) astype(同类型)", lambda: a.astype(a.dtype)),
("a[[0, 2]] 花式索引", lambda: a[[0, 2]]),
("a[a > 5] 布尔索引", lambda: a[a > 5]),
("a[1:3, [0, 2]] 切片 + 花式", lambda: a[1:3, [0, 2]]),
("np.broadcast_to(a, (2,3,4))", lambda: np.broadcast_to(a, (2, 3, 4))),
("a + 0 任何算术", lambda: a + 0),
("np.sort(a) 非原地排序", lambda: np.sort(a)),
("a.copy() copy", lambda: a.copy()),
("np.asarray(a) asarray", lambda: np.asarray(a)),
("np.array(a) array", lambda: np.array(a)),
]
print(f"{'写法':<36} {'shares_memory':<14} {'.base is None'}")
print("-" * 66)
for name, f in cases:
r = f()
print(f"{name:<36} {str(np.shares_memory(a, r)):<14} {r.base is None}")
实跑输出(numpy 2.4.6):
本次输入的 a.base is None 为 True。下面两列分别检查「是否与 a 共享内存」和「结果的 base 是否为空」,不要把它们当成同一问题。
| 写法 | shares_memory | .base is None |
|---|---|---|
a[1:3] 基础切片 |
True | False |
a[::2] 带步长切片 |
True | False |
a[1] 整数下标 |
True | False |
a[:, None] 加一根轴 |
True | False |
a.T 转置 |
True | False |
a.reshape(4, 3) reshape(连续) |
True | False |
a.T.reshape(12) reshape(不连续) |
False | False |
a.ravel() ravel |
True | False |
a.T.ravel() ravel(不连续) |
False | True |
a.flatten() flatten |
False | True |
a.view(np.int32) 改 dtype 解释 |
True | False |
a.astype(a.dtype) astype(同类型) |
False | True |
a[[0, 2]] 花式索引 |
False | True |
a[a > 5] 布尔索引 |
False | True |
a[1:3, [0, 2]] 切片 + 花式 |
False | False |
np.broadcast_to(a, (2,3,4)) |
True | False |
a + 0 任何算术 |
False | True |
np.sort(a) 非原地排序 |
False | True |
a.copy() copy |
False | True |
np.asarray(a) asarray |
True | True |
np.array(a) array |
False | True |
⭐ 一条能概括全表的话: 只改「怎么读这块内存」的,是视图;需要重新挑元素、或者算出新数值的,是拷贝。
| 类别 | 谁属于这里 | 会串吗 |
|---|---|---|
| 纯换读法 | 基础切片 · 整数下标 · None 加轴 · .T · .view() · broadcast_to |
⭐ 会串 |
| 算出新数值 | a + 0 和一切算术 · np.sort · .astype() · .copy() |
不会 |
| 按任意顺序挑元素 | 花式索引 · 布尔索引 · 切片与花式混用 | 不会 |
| ⚠️ 看情况 | reshape · ravel |
同一行代码,换个输入就换个行为 |
⚠️ 最后一行是这张表里唯一需要盯着的。a.reshape(4,3) 是视图、a.T.reshape(12) 是拷贝 ——
写代码的时候你未必知道传进来的数组是不是连续的,也就未必知道这一行会不会串。
⭐ ravel() 和 flatten() 的分工正是为这件事准备的:
ravel() 尽量给视图,flatten() 一定给拷贝。想要「保证不共享」就写 flatten(),别写 ravel()。
为什么这条线画在这里(哪些操作能用一份 shape + strides 说明书表达)是 05 章的正题; 索引那四种形态各自为什么落在这一边在 06 章。 这一章只回答「改了会不会串」。
读取结果是拷贝,不等于不能写回原数组
把“先取出来再改”和“直接给下标赋值”分清。
💀 四、最容易记反的一条:b[[0,2]] += 1 会改原数组
第一节说「花式索引给的是拷贝」。很多人由此推出「所以花式索引改不了原数组」—— 这是错的。
import numpy as np
# ① 先取出来,再改那个取出来的东西 —— 花式索引给的是拷贝,原数组不动
b = np.arange(6)
c = b[[0, 2]]
c += 1
print("① c = b[[0,2]]; c += 1 -> b =", b, " c =", c)
# ② 直接对下标做增量赋值 —— 走的是 __setitem__,写回去了
b = np.arange(6)
b[[0, 2]] += 1
print("② b[[0,2]] += 1 -> b =", b)
# ③ 显式赋值,当然也写回去
b = np.arange(6)
b[[0, 2]] = 99
print("③ b[[0,2]] = 99 -> b =", b)
# ④ 布尔索引一模一样
b = np.arange(6)
d = b[b % 2 == 0]
d += 100
print("\n④ d = b[b%2==0]; d += 100 -> b =", b, " d =", d)
b = np.arange(6)
b[b % 2 == 0] += 100
print("⑤ b[b%2==0] += 100 -> b =", b)
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 先读拷贝再改 | ① c = b[[0,2]]; c += 1 -> b = [0 1 2 3 4 5] c = [1 3] |
| 直接增量写回 | ② b[[0,2]] += 1 -> b = [1 1 3 3 4 5] |
| 直接赋值 | ③ b[[0,2]] = 99 -> b = [99 1 99 3 4 5] |
| 先读布尔结果再改 | ④ d = b[b%2==0]; d += 100 -> b = [0 1 2 3 4 5] d = [100 102 104] |
| 直接布尔下标写回 | ⑤ b[b%2==0] += 100 -> b = [100 1 102 3 104 5] |
⭐ 判据是「那个下标在不在等号左边」,和视图还是拷贝是两件独立的事:
| 你写的 | Python 实际调用的 | 会不会改 b |
|---|---|---|
c = b[[0,2]],之后改 c |
b.__getitem__([0,2]) —— 抄一份给你 |
❌ 不会 |
b[[0,2]] += 1 |
b.__setitem__(...) —— 按下标直接往 b 里写 |
⭐ 会 |
b[[0,2]] = 99 |
同上 | ⭐ 会 |
⚠️ 还有第三种,最阴:下标不在等号左边、但看起来像在:
import numpy as np
# ① 链式下标 + 基础切片:中间那步是视图,写得进去
a = np.arange(12).reshape(3, 4)
a[0][1] = 99
print("① a[0][1] = 99 -> a[0] =", a[0], " ✅ 写进去了")
# ② 链式下标 + 花式索引:中间那步是【拷贝】,写完就被丢掉
a = np.arange(12).reshape(3, 4)
a[[0, 1]][0] = 99
print("② a[[0,1]][0] = 99 -> a[0] =", a[0], " ❌ 一声不吭,什么也没发生")
# ③ 布尔索引同理
a = np.arange(12).reshape(3, 4)
a[a > 5][0] = 99
print("③ a[a>5][0] = 99 -> a.ravel()[6] =", a.ravel()[6], " ❌ 同样没发生")
# ④ 正确写法:一次写完,别分两步
a = np.arange(12).reshape(3, 4)
a[[0, 1], 0] = 99
print("④ a[[0,1], 0] = 99 -> a[:, 0] =", a[:, 0], " ✅")
a = np.arange(12).reshape(3, 4)
a[a > 5] = 99
print("⑤ a[a>5] = 99 -> a.ravel()[6] =", a.ravel()[6], " ✅")
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 链式基础切片 | ① a[0][1] = 99 -> a[0] = [ 0 99 2 3] ✅ 写进去了 |
| 链式花式索引 | ② a[[0,1]][0] = 99 -> a[0] = [0 1 2 3] ❌ 一声不吭,什么也没发生 |
| 链式布尔索引 | ③ a[a>5][0] = 99 -> a.ravel()[6] = 6 ❌ 同样没发生 |
| 合并整数下标 | ④ a[[0,1], 0] = 99 -> a[:, 0] = [99 99 8] ✅ |
| 合并布尔下标 | ⑤ a[a>5] = 99 -> a.ravel()[6] = 99 ✅ |
💀 a[[0,1]][0] = 99 是本章最坏的一种失败:它既不报错、也不生效。
a[[0,1]] 先当作 __getitem__ 跑完,造出一份临时拷贝,[0] = 99 写进那份拷贝,
然后临时拷贝当场被回收。你写的那一行,效果等于什么都没写。
⭐ 一句话记法:只有【紧挨着等号左边】的那个下标才是写;它左边的每一步都是读。
a[0][1] = 99 之所以生效,是因为读那一步 a[0] 恰好给的是视图。语法一样,结果取决于读的那一步。
⚠️ 同一个机制还有一个后果:b[[0,0,0]] += 1 只加一次,不是三次。
那一条以及 np.add.at 的用法在 06 章第五节。
🛑 读到这里可以停 —— 前半章讲完了,那是「怎么判断」的一半(大约一半篇幅)。 你已经拿到三样东西:判据是
np.shares_memory不是.base· 哪些操作给视图哪些给拷贝 ·b[[0,2]] += 1为什么反而会改原数组。 后半章还有:两场实测事故(K-means 的初始中心 · 数值梯度检查恒等于 0)·a += b和a = a + b的完整版 · 想切断时该写哪一个 · 函数边界的三条约定 · 一页速记。 回来的时候不用重读,直接从下一节接着看就行。
两场事故:结果正常,却改坏了输入
K-means 看原数据变化;梯度检查先看两个扰动是否独立。
💀 五、事故:拿切片当 K-means 的初始中心
判据讲完了,看它在真代码里长什么样。下面这段 K-means 除了初始中心的取法之外,两版一个字符都不差:
先看这个关系:X[:3] 与原数组共享内存,X[[0, 1, 2]] 得到独立拷贝。展开实验后,重点找初始化与 C[j] = … 的就地更新;结果与事故解释保留在代码下方。
查看 K-means 完整实验 · 数据生成、两种初始化与结果比较
import numpy as np
np.set_printoptions(precision=3, suppress=True)
def kmeans(X, C, iters=10):
"""标准 K-means。C 是调用者传进来的初始中心,函数会就地更新它"""
for _ in range(iters):
d = ((X[:, None, :] - C[None, :, :]) ** 2).sum(-1) # (n, k),第 03 章那个模式
lab = d.argmin(1)
for j in range(len(C)):
if (lab == j).any():
C[j] = X[lab == j].mean(0) # ⭐ 就地写进 C
return C, lab
rng = np.random.default_rng(0)
X0 = np.vstack([rng.normal([0, 0], .5, (60, 2)),
rng.normal([5, 0], .5, (60, 2)),
rng.normal([0, 5], .5, (60, 2))]) # 三坨,各 60 个点
# ❌ 用基础切片挑初始中心 —— C 是 X 的视图
X = X0.copy()
init = X[:3]
print("X[:3] 和 X 共享内存吗:", np.shares_memory(X, init))
C_bad, lab_bad = kmeans(X, init)
print("跑完之后 X 被改掉了", (X != X0).any(axis=1).sum(), "行 / 共", len(X), "行")
print(" 第 1 行 原本", X0[1], " 现在", X[1], " <- 从第一簇被搬到第二簇的位置上了")
# ✅ 用花式索引挑 —— 天然就是拷贝
X = X0.copy()
init = X[[0, 1, 2]]
print("\nX[[0,1,2]] 和 X 共享内存吗:", np.shares_memory(X, init))
C_ok, lab_ok = kmeans(X, init)
print("跑完之后 X 被改掉了", (X != X0).any(axis=1).sum(), "行")
def inertia(X, C, lab):
return ((X - C[lab]) ** 2).sum()
print("\n拿【原始数据】评两版的簇内平方和:")
print(" 错版 inertia = %.3f" % inertia(X0, C_bad, lab_bad))
print(" 对版 inertia = %.3f" % inertia(X0, C_ok, lab_ok))
print(" 两版标签一致的样本 =", (lab_bad == lab_ok).sum(), "/", len(X0))
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 错误初始化的共享 | X[:3] 和 X 共享内存吗: True |
| 原数据被改的行数 | 跑完之后 X 被改掉了 3 行 / 共 180 行 |
| 第 1 行前后对比 | 第 1 行 原本 [0.32 0.052] 现在 [4.86 0.014] <- 从第一簇被搬到第二簇的位置上了 |
| 正确初始化的共享 | X[[0,1,2]] 和 X 共享内存吗: False |
| 正确版改动行数 | 跑完之后 X 被改掉了 0 行 |
| 评估使用哪份数据 | 拿【原始数据】评两版的簇内平方和: |
| 错误版误差 | 错版 inertia = 133.080 |
| 正确版误差 | 对版 inertia = 89.828 |
| 相同标签的数量 | 两版标签一致的样本 = 178 / 180 |
💀 事故报告:
| 发生了什么 | X[:3] 是视图,C[j] = ... 写进 C 就是写进 X 的前三行。跑完 10 轮,3 行原始数据被覆盖成了聚类中心 —— 第 1 行从 [0.32, 0.052](第一簇)被搬到 [4.86, 0.014](第二簇的位置) |
| 为什么没被发现 | ⚠️ 180 个样本里有 178 个标签和正确版一样;inertia 从 89.828 涨到 133.080,读起来只像「这次初始化不太走运、收敛得一般」。没有报错、没有 nan、没有形状异常 |
| 代价 | 被改的是 X 本身。如果 X 是从上游传进来的特征矩阵,后面每一个用到它的模型都在用被污染过的数据,而污染只有 3 行、藏在 180 行里 |
| 该补什么 | 函数入口一行 C = np.array(C, dtype=float)(np.array 默认拷贝,见第七节),或者调用处写 X[:3].copy() |
⭐ 站内 ML 基础 · 附录 C 第 396 行的 K-means 写的是
C = X[rng.choice(len(X), k, replace=False)].copy() —— 花式索引本来就给拷贝,那个 .copy() 其实是多余的。
⭐ 但多余的 .copy() 只花钱,漏掉的 .copy() 出上面这种事。 在这个位置上,多写一个是划算的。
🧯 六、a += b 和 a = a + b(兑现上一章的预告)
03 章第六节末尾留了一句「这两个是两件不同的事」,这里是完整版:
展开完整代码(48 行,原样保留)
import numpy as np
# ---- 同一个数组,两种写法 ----
a = np.arange(3)
v = a[:] # 一个视图,代表「别人手里的引用」
a += 1
print("a += 1 之后 v =", v, " <- 视图跟着变了")
a = np.arange(3)
v = a[:]
a = a + 1
print("a = a + 1 之后 v =", v, " <- 视图没变,a 换了个新数组")
print(" 现在 np.shares_memory(a, v) =", np.shares_memory(a, v))
# ---- 同一件事发生在函数参数上 ----
def center_inplace(x):
x -= x.mean() # ⚠️ 原地:调用者的数组被改了
return x
def center_pure(x):
x = x - x.mean() # ✅ 新数组:调用者不受影响
return x
d = np.array([1., 2., 3.])
center_inplace(d)
print("\ncenter_inplace 之后 调用者的 d =", d, " <- 被改了")
d = np.array([1., 2., 3.])
center_pure(d)
print("center_pure 之后 调用者的 d =", d, " <- 没被改")
# ---- 数值梯度检查:站内那两行 .copy() 是承重的 ----
def loss(w):
return (w ** 2).sum()
W = np.array([3.0, 4.0])
eps = 1e-5
Wp = W.copy(); Wp[0] += eps # ✅ 站内 ml_md/08:207-208 的写法
Wm = W.copy(); Wm[0] -= eps
print("\n带 .copy() 数值梯度 =", (loss(Wp) - loss(Wm)) / (2 * eps), " 解析值 = 6.0")
W = np.array([3.0, 4.0])
Wp = W; Wp[0] += eps # ❌ 少写了 .copy()
Wm = W; Wm[0] -= eps
print("少 .copy() 数值梯度 =", (loss(Wp) - loss(Wm)) / (2 * eps), " <- 恒等于 0,不报错")
print(" Wp is W:", Wp is W, " Wm is W:", Wm is W, " 三个名字同一个数组")
print(" 事后 W[0] == 3.0 ?", W[0] == 3.0, " <- 加完又减完,连现场都没留下")
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 原地加法 | a += 1 之后 v = [1 2 3] <- 视图跟着变了 |
| 新数组加法 | a = a + 1 之后 v = [0 1 2] <- 视图没变,a 换了个新数组 |
| 新数组与旧视图 | 现在 np.shares_memory(a, v) = False |
| 原地中心化 | center_inplace 之后 调用者的 d = [-1. 0. 1.] <- 被改了 |
| 独立中心化 | center_pure 之后 调用者的 d = [1. 2. 3.] <- 没被改 |
| 独立扰动的梯度 | 带 .copy() 数值梯度 = 6.000000000128124 解析值 = 6.0 |
| 共享扰动的梯度 | 少 .copy() 数值梯度 = 0.0 <- 恒等于 0,不报错 |
| 三个名字的关系 | Wp is W: True Wm is W: True 三个名字同一个数组 |
| 实验后的原值 | 事后 W[0] == 3.0 ? True <- 加完又减完,连现场都没留下 |
⭐ 区别只有一句话:
| 写法 | 干了什么 | 别人手里的视图看得见吗 |
|---|---|---|
a += b |
就地改 a 指着的那块内存 |
⭐ 看得见 |
a = a + b |
造一个新数组,然后把名字 a 挪过去指它 |
看不见(老的那块没动) |
💀 最后那段是本章第二贵的坑:手写反向传播时的数值梯度检查,
少写两个 .copy(),Wp、Wm、W 就是同一个数组 ——
+eps 之后紧接着 -eps,两次前向算的是同一个点,数值梯度恒等于 0.0。
⚠️ 它比 K-means 那个还难查,因为:
- 不报错,返回一个规规矩矩的浮点数
- 你会以为是「解析梯度写错了」,于是去逐行核对反向传播的公式 —— 而那边根本没问题
- ⭐ 事后连现场都没有:+eps 和 -eps 正好抵消,实测 W[0] == 3.0 是 True,
你就算事后打印 W 也看不出它被动过
⭐ 站内两处数值梯度检查(ML 基础 08 第 216–217 行、
附录 C 第 71–72 行)都老老实实写了
zp = z.copy(); zp[i, j] += eps。那两个 .copy() 是承重的,不是风格。
⚠️ 划一条界:
a = a + b之后a换了个对象但名字还叫a—— 「名字重新绑到另一个对象」是 Python 语言层的事,归 《Python 会咬你的地方》01。 本章只管内存缓冲区这一层:b = a之后a is b为真是那边的题,y = x[:2]之后是两个对象指同一块内存才是这边的题。
🛑 第二个休息点 —— 中段讲完了(约 22 分钟)。 最后一段还有:想切断的时候,写哪一个 · 函数边界上的三条约定 · 一页速记 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
需要独立数据时,在哪一处切断
写法、成本和函数边界约定一起保留。
✂️ 七、想切断的时候,写哪一个
展开完整代码(31 行,原样保留)
import numpy as np
a = np.arange(6)
v = a[::2] # 一个视图
for name, r in [
("v.copy()", v.copy()),
("np.copy(v)", np.copy(v)),
("np.array(v)", np.array(v)),
("v.astype(v.dtype)", v.astype(v.dtype)),
("v + 0", v + 0),
("np.asarray(v)", np.asarray(v)),
("v[:]", v[:]),
]:
print(f"{name:<20} 还和 a 共享吗 -> {np.shares_memory(a, r)}")
# ⚠️ NumPy 2.x 改了 np.array(..., copy=False) 的语义:
# 不是「能不拷就不拷」,是「必须不拷,做不到就报错」
print("\nnp.array(v, copy=False) 共享吗 ->", np.shares_memory(v, np.array(v, copy=False)))
try:
np.array(v, dtype=np.float64, copy=False) # 换 dtype 必然要拷
except ValueError as e:
print("np.array(v, dtype=float64, copy=False) -> ValueError:", str(e).splitlines()[0])
# ⚠️ .copy() 拷的是那块【数字缓冲区】。dtype=object 的数组里装的是 Python 对象,它拷不动
o = np.empty(2, dtype=object)
o[0], o[1] = [1, 2], [3, 4]
p = o.copy()
print("\no 和 p 共享内存吗:", np.shares_memory(o, p))
p[0].append(999)
print("往 p[0] 那个 list 里 append 之后 o[0] =", o[0], " <- 是同一个 list 对象")
实跑输出:
| 观察项 | 原样结果与说明 |
|---|---|
| 显式 copy | v.copy() 还和 a 共享吗 -> False |
| np.copy | np.copy(v) 还和 a 共享吗 -> False |
| np.array | np.array(v) 还和 a 共享吗 -> False |
| astype | v.astype(v.dtype) 还和 a 共享吗 -> False |
| 新算术结果 | v + 0 还和 a 共享吗 -> False |
| asarray | np.asarray(v) 还和 a 共享吗 -> True |
| 基础切片 | v[:] 还和 a 共享吗 -> True |
| 禁止复制 | np.array(v, copy=False) 共享吗 -> True |
| 禁止复制但需要换类型 | np.array(v, dtype=float64, copy=False) -> ValueError: Unable to avoid copy while creating an array as requested. |
| 对象数组的缓冲区 | o 和 p 共享内存吗: False |
| 对象数组中的同一列表 | 往 p[0] 那个 list 里 append 之后 o[0] = [1, 2, 999] <- 是同一个 list 对象 |
| 写法 | 切断吗 | 什么时候用它 |
|---|---|---|
⭐ a.copy() |
✅ | 默认选它 —— 意图写在脸上,读代码的人一眼看得懂 |
np.copy(a) / np.array(a) |
✅ | np.array 的 copy 默认就是 True。函数入口收外部数据时常用 |
a.astype(dt) |
✅ | 顺手要改 dtype 的时候。⚠️ 同类型也照样拷 |
⚠️ np.asarray(a) |
❌ | 它的职责是「保证这玩意儿是个 ndarray」,不保证独立。别拿它当拷贝用 |
💀 a[:] |
❌ | 这是 Python list 的习惯(lst[:] 确实拷贝)。对 ndarray 完全无效,它给的是视图 |
⚠️ np.array(x, copy=False) 在 NumPy 2.x 变了意思。
1.x 里它是「能不拷就不拷」,2.x 里它是「不许拷,做不到就抛 ValueError」
(实测那句话就叫 Unable to avoid copy while creating an array as requested.)。
⭐ 想要老的「尽量别拷」语义,写 np.asarray(x) —— 报错信息自己也是这么说的。
⚠️ .copy() 对 dtype=object 的数组是浅的。 它拷的是那块存指针的缓冲区,
里面装的 Python 对象还是同一批:实测往 p[0] 那个 list 里 append,o[0] 跟着变成 [1, 2, 999]。
⭐ 那一层要用 copy.deepcopy,机制归
《Python 会咬你的地方》01 —— 那已经不是内存缓冲区的问题了。
⭐ 这一刀多少钱:一个 122 MB 的 (4000, 4000) 数组,big[::2, ::2] 取视图实测 0.0001 ms,
后面加个 .copy() 是 10.1 ms(重跑两次 10.109 / 10.182),差了约 9 万倍。
⚠️ 倍数看量级不看单点,但结论很硬:防御性的 .copy() 撒在函数入口撒一次就够,别在循环里每行撒一个。
🛑 读到这里可以停 —— 已经读了约 71 分钟。 最后一段还有(约 31 分钟):函数边界上的三条约定 · 一页速记 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
🚦 八、函数边界上的三条约定
上面两场事故都发生在函数边界上 —— 一个函数改了调用者的数组,而调用者不知道。 三条约定能挡住绝大部分:
| # | 约定 | 怎么落地 |
|---|---|---|
| ① | 会改参数的函数,要把这件事写进名字或第一行文档 | def center_inplace(x) / """就地修改 x"""。⭐ NumPy 自己就是这么做的:np.sort(a) 返回新的,a.sort() 改自己 |
| ② | 要改就先在入口拷一份,别在半路拷 | x = np.array(x, dtype=float) 放函数第一行。⚠️ 别写 np.asarray,它不拷 |
| ③ | ⭐ 不要把内部数组的视图当返回值交出去 | return self._buf[1:] 会让调用者拿到一把能改你内部状态的钥匙。要么 .copy(),要么 r.flags.writeable = False 把它设成只读 |
⚠️ 约定 ③ 那个只读的招数你已经见过:03 章第三节里
np.broadcast_to 返回的东西 writeable 是 False,就是 NumPy 自己在用这一手。
带着眼前的问题回来查
速记、相关章节、检查点与答案都在这里。
📋 九、一页速记
| 遇到的事 / 想干的事 | 怎么办 |
|---|---|
| 想知道两个数组会不会互相影响 | ⭐ np.shares_memory(a, b),别用 .base |
「我明明只改了 b,a 怎么也变了」 |
b 是 a 的视图。切片 / .T / reshape / ravel / None 加轴,全都可能是 |
| 「我改了却没生效,还不报错」 | 💀 链式索引:a[[0,1]][0] = 9 写进了临时拷贝。合并成一次下标:a[[0,1], 0] = 9 |
| 想按任意顺序 / 按条件读 | 花式索引、布尔索引,天然是拷贝,随便改 |
| 想按任意顺序 / 按条件写 | 照写 a[idx] = v、a[mask] += 1,⭐ __setitem__ 直接写进原数组 |
| 想要一份保证独立的 | ⭐ a.copy()。⚠️ np.asarray(a) 和 a[:] 都不拷 |
| 想要「压平,且保证不共享」 | a.flatten()(ravel() 会尽量给视图) |
| 想要「不能零拷贝就报错给我看」 | 换 dtype 时 np.array(x, dtype=..., copy=False);改形状的写法在 05 章 |
| 传给别人的函数,怕被改 | 入口 x = np.array(x);或者交出去之前 r.flags.writeable = False |
dtype=object 的数组要真拷贝 |
copy.deepcopy —— .copy() 在那一层是浅的 |
| 想知道为什么某个操作能白拿视图 | 05 章 |
| 想知道哪种索引语法给视图 | 06 章 |
⚠️ 一条容易漏的:reshape 是这张表里唯一「同一行代码、两种行为」的 ——
是否能给视图取决于目标形状能否由当前 strides 表达;不连续不等于必然拷贝。写库的时候别依赖它的返回值会不会串。
上面的具体实验结果照常成立;不要把它概括成“不连续必然拷贝”。机制可对照 NumPy 的拷贝与视图说明。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 03 · 广播的三条规则 | ⭐ 那一章第六节留了一句「a += b 和 a = a + b 是两件事」,本章第六节是它的完整版;那里 broadcast_to 的结果只读,也是本章第八节约定 ③ 的现成范例 |
| 05 · 内存布局与 stride | ⭐ 本章给判据,那一章给机制:为什么转置能白拿视图、reshape 什么时候掏钱、.contiguous() 在修什么。⚠️ 本章第二节 .base 那个坑的完整版在它第三节 |
| 06 · 索引的四种形态 | ⭐ 本章说「哪些操作会串」,那一章说索引这一族里哪种语法会串、以及 b[[0,0,0]] += 1 为什么只加一次 |
| 07 · 把循环改写成数组运算 | 那一章的 sliding_window_view 展开成 (199951, 50) 却和原数组共享内存 —— ⭐ 视图不是麻烦,是那一章省下 76 MiB 的全部原因 |
| 11 · 从 NumPy 到 PyTorch | ⭐ torch.from_numpy 让两个库共享同一块内存,判据一模一样,只是「另一边」换了个库 |
| ML 基础 08 · 反向传播 | ⭐ 第 216–217 行 W1p = W1.copy(); W1p[0,0] += eps —— 那两个 .copy() 是承重的,本章第六节实测了漏掉之后数值梯度会恒等于 0.0 |
| ML 基础 · 附录 C 手撕代码速查 | 第 396 行 K-means 的初始中心用花式索引 + .copy(),第 71–72 行梯度校验也是 z.copy()。⭐ 本章第五节演示了把那一行换成基础切片会发生什么 |
| 模型上线之后 09 · 特征重要性的三种谎言 | 置换重要性每一轮先写 Xp = X.copy() 再打乱那几列(那边是 DataFrame,道理一模一样)—— 同一类承重的拷贝 |
| 数据这一关 05 · 缺失不是一种东西 | 四种填补方式各写一句 x1.copy() 再往缺失位置写,⭐ 少一个就会让后一种填补建在前一种的结果上 |
| PyTorch 这个框架本身 01 · 张量到底是什么 | ⭐ torch 的规则和这一章一致:view() 一定不拷贝(做不到就报错),reshape() 给不了视图就悄悄拷 |
| PyTorch 03 · 三种就地操作报错 | ⭐ 同一件事在 autograd 底下的后果:x[0] = 1.0 底层是「先取视图、再往视图里写」,所以报的是视图那句错 |
| 框架底下是 C++ 02 · 值语义与所有权 | ⭐ 三方分工的第二块就是本章:「缓冲区被共享」。第一块(名字绑到对象)归 Python 那边,第三块(谁负责释放)归它 |
| 《Python 会咬你的地方》01 · 名字、对象和绑定 | ⚠️ 同名不同物:那边的「共享」是两个名字指同一个对象,这边是两个对象指同一块内存。dtype=object 数组要深拷贝时才需要那边的 copy.deepcopy |
✅ 检查点
c = b[::2]; c += 1和c = b[[0,2]]; c += 1,实跑之后b分别变成什么?差别的根源是什么?- 判断两个数组共不共享内存,该写哪一行?为什么
.base不行 —— 说出两个方向的失败。 - ⭐
a.T.base is a是True还是False?a.T.base is base呢? - 判据表里哪一行是「同一行代码、两种行为」?想要「压平且保证不共享」该写
ravel()还是flatten()? - ⭐ 「花式索引给的是拷贝」,那为什么
b[[0,2]] += 1还是把b改了?判据是什么? - 💀
a[[0,1]][0] = 99会发生什么?为什么它比报错还糟?正确写法是什么? - K-means 那场事故里,
X被改掉了几行?两版的 inertia 分别是多少?180 个样本里有多少个标签是一样的 —— 这个数字为什么才是重点? - ⭐ 数值梯度检查少写两个
.copy(),算出来的梯度是多少?为什么事后打印W也看不出问题? a.copy()/np.array(a)/np.asarray(a)/a[:],哪些切断了共享?NumPy 2.x 里np.array(x, copy=False)是什么意思?- 一个函数收到别人的数组、要就地改它,第八节的三条约定分别怎么落地?
👀 答案
-
切片版
b = [1 1 3 3 5 5](原数组变了),花式索引版b = [0 1 2 3 4 5](没变)。根源是
np.shares_memory(b, b[::2])是True、np.shares_memory(b, b[[0,2]])是False—— 切片给视图(同一块内存的另一种读法),花式索引给拷贝。⚠️ 两行都不报错、都返回一个正常数组,差别只在背后是不是同一块内存。
-
np.shares_memory(a, b)。.base两个方向都会错:① 共享却判成不共享 ——
.base不指向上一层而是最终持有内存的那个,所以a.T.base is a是False;② 不共享却判成共享 ——
a.T.reshape(12)和a[1:3, [0,2]]的.base都不是None(指向过程中造出的临时拷贝),而shares_memory是False。所以
if x.base is not None这个写法不能用。 -
a.T.base is a是False(直觉会以为True),a.T.base is base是True。只有当源数组自己就是内存持有者(b.base is None)时,c.base is b那种写法才成立 —— 完整机制在 05 章第三节。 -
reshape(还有ravel):a.reshape(4,3)共享内存,a.T.reshape(12)不共享 —— 是否能给视图取决于目标形状能否由当前 strides 表达;不连续不等于必然拷贝。想保证不共享写flatten(),它一定拷贝;ravel()会尽量给视图。 -
因为判据是「那个下标在不在等号左边」,和视图/拷贝是两件独立的事。
c = b[[0,2]]走__getitem__,抄一份给你;b[[0,2]] += 1走__setitem__,按下标直接往b里写:先读取、计算,再写回;读取过程中仍可能产生临时数组。实跑
b[[0,2]] += 1之后b = [1 1 3 3 4 5]。一句话:只有紧挨着等号左边的那个下标才是写,它左边的每一步都是读。 -
💀 既不报错、也不生效:
a[[0,1]]先当__getitem__跑完造出一份临时拷贝,[0] = 99写进那份拷贝,然后拷贝被回收 —— 实跑a[0]还是[0 1 2 3]。比报错糟,是因为它连一行错误信息都不给你。正确写法是合并成一次下标:
a[[0,1], 0] = 99(实跑a[:, 0] = [99 99 8])。⚠️
a[0][1] = 99反而生效,因为a[0]恰好是视图 —— 语法一样,结果取决于读那一步给的是什么。 -
X被改掉 3 行(180 行里的前 3 行,被覆盖成了聚类中心;第 1 行从[0.32, 0.052]被搬到[4.86, 0.014],跨了一个簇)。错版 inertia 133.080,对版 89.828。⚠️ 重点是「178/180 个标签一样」:结果看起来只像「这次收敛得不太好」,没有报错、没有
nan、没有形状异常,所以没人会去怀疑那一行X[:3]—— 而被污染的是数据本身,后面每个用到X的模型都在用它。 -
恒等于
0.0。因为Wp is W和Wm is W都是True,三个名字是同一个数组:+eps之后紧接着-eps,两次前向算的是同一个点。事后看不出来是因为加完又减完正好抵消,实测
W[0] == 3.0是True—— 连现场都没留下。⚠️ 典型的排查路径是去逐行核对反向传播公式,而那边根本没错。站内
ml_md/08:207-208和附录C:71-72都老实写了.copy(),那是承重的。 -
切断的:
a.copy()(默认选它,意图最清楚)、np.copy(a)、np.array(a)(copy默认True)、a.astype(dt)。不切断的:⚠️
np.asarray(a)(职责是「保证是 ndarray」,不保证独立)、💀a[:](那是 Python list 的习惯,对 ndarray 给的是视图)。NumPy 2.x 里
np.array(x, copy=False)从「能不拷就不拷」变成了「不许拷,做不到就抛ValueError: Unable to avoid copy while creating an array as requested.」;想要老语义写np.asarray(x)。 -
① 把「会改参数」写进函数名或第一行文档(NumPy 自己就是:
np.sort(a)返回新的、a.sort()改自己);② 要改就在函数第一行拷一份
x = np.array(x, dtype=float)(⚠️ 别写np.asarray,它不拷);③ 别把内部数组的视图当返回值交出去 —— 要么
.copy(),要么r.flags.writeable = False设成只读(np.broadcast_to就是这么干的)。拷贝不免费:122 MB 的数组取视图 0.0001 ms、
.copy()10.1 ms,约 9 万倍,所以入口撒一次就够,别在循环里每行撒。
🛑 可以停在这里
⚡ 走神救援
- 判断两个 ndarray 的数据缓冲区是否共享,用 np.shares_memory(a, b),不要用 .base 代替。
- 花式索引读出的结果是拷贝;直接向原数组下标赋值仍会写回。链式索引可能只改到临时拷贝。
- K-means 实验只污染 3 行,却有 178/180 个标签相同;梯度检查少了独立拷贝,差分会变成 0。
- 需要独立数值缓冲区时用 .copy();dtype=object 里的对象另需深拷贝。函数边界上的约定见第八节。
下一节 👉 05-内存布局与stride.md