🏠 总目录📚 本教程 04 · 视图还是拷贝 ← →
📑 本页目录(点开跳转)

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

实跑输出:

实验结果:逐项核对,不是执行步骤
观察项原样结果与说明
切片后改 cc = b[::2]; c += 1 -> b = [1 1 3 3 5 5]
花式索引后改 cc = 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
直接视图的 basec.base is b : True
reshape 的 basea.base is base : True
转置的上一层a.T.base is a : False <- 直觉会以为 True
转置的持有者a.T.base is base : True
转置共享内存np.shares_memory(a, a.T) : True
花式索引的 basea[[0, 2]].base : None
copy 的 basea.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 对象")

实跑输出:

实验结果:逐项核对,不是执行步骤
观察项原样结果与说明
显式 copyv.copy() 还和 a 共享吗 -> False
np.copynp.copy(v) 还和 a 共享吗 -> False
np.arraynp.array(v) 还和 a 共享吗 -> False
astypev.astype(v.dtype) 还和 a 共享吗 -> False
新算术结果v + 0 还和 a 共享吗 -> False
asarraynp.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

✅ 检查点

  1. c = b[::2]; c += 1 和 c = b[[0,2]]; c += 1,实跑之后 b 分别变成什么?差别的根源是什么?
  2. 判断两个数组共不共享内存,该写哪一行?为什么 .base 不行 —— 说出两个方向的失败。
  3. ⭐ a.T.base is a 是 True 还是 False?a.T.base is base 呢?
  4. 判据表里哪一行是「同一行代码、两种行为」?想要「压平且保证不共享」该写 ravel() 还是 flatten()?
  5. ⭐ 「花式索引给的是拷贝」,那为什么 b[[0,2]] += 1 还是把 b 改了?判据是什么?
  6. 💀 a[[0,1]][0] = 99 会发生什么?为什么它比报错还糟?正确写法是什么?
  7. K-means 那场事故里,X 被改掉了几行?两版的 inertia 分别是多少?180 个样本里有多少个标签是一样的 —— 这个数字为什么才是重点?
  8. ⭐ 数值梯度检查少写两个 .copy(),算出来的梯度是多少?为什么事后打印 W 也看不出问题?
  9. a.copy() / np.array(a) / np.asarray(a) / a[:],哪些切断了共享?NumPy 2.x 里 np.array(x, copy=False) 是什么意思?
  10. 一个函数收到别人的数组、要就地改它,第八节的三条约定分别怎么落地?
👀 答案
  1. 切片版 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 —— 切片给视图(同一块内存的另一种读法),花式索引给拷贝。

    ⚠️ 两行都不报错、都返回一个正常数组,差别只在背后是不是同一块内存。

  2. 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 这个写法不能用。

  3. a.T.base is a 是 False(直觉会以为 True),a.T.base is base 是 True。只有当源数组自己就是内存持有者(b.base is None)时,c.base is b 那种写法才成立 —— 完整机制在 05 章第三节。

  4. reshape(还有 ravel):a.reshape(4,3) 共享内存,a.T.reshape(12) 不共享 —— 是否能给视图取决于目标形状能否由当前 strides 表达;不连续不等于必然拷贝。想保证不共享写 flatten(),它一定拷贝;ravel() 会尽量给视图。

  5. 因为判据是「那个下标在不在等号左边」,和视图/拷贝是两件独立的事。

    c = b[[0,2]] 走 __getitem__,抄一份给你;b[[0,2]] += 1 走 __setitem__,按下标直接往 b 里写:先读取、计算,再写回;读取过程中仍可能产生临时数组。

    实跑 b[[0,2]] += 1 之后 b = [1 1 3 3 4 5]。一句话:只有紧挨着等号左边的那个下标才是写,它左边的每一步都是读。

  6. 💀 既不报错、也不生效: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] 恰好是视图 —— 语法一样,结果取决于读那一步给的是什么。

  7. X 被改掉 3 行(180 行里的前 3 行,被覆盖成了聚类中心;第 1 行从 [0.32, 0.052] 被搬到 [4.86, 0.014],跨了一个簇)。错版 inertia 133.080,对版 89.828。

    ⚠️ 重点是「178/180 个标签一样」:结果看起来只像「这次收敛得不太好」,没有报错、没有 nan、没有形状异常,所以没人会去怀疑那一行 X[:3] —— 而被污染的是数据本身,后面每个用到 X 的模型都在用它。

  8. 恒等于 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(),那是承重的。

  9. 切断的: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)。

  10. ① 把「会改参数」写进函数名或第一行文档(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

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