第 18 章 · 数组的创建与形状
这一章要解决什么:熟练造出各种形状的数组,并能在不改变数据的前提下变换形状。 打掉一个高频错误:形状参数必须是元组 ——
np.zeros(2, 3)会报错,np.zeros((2, 3))才对。
本章你需要完成的代码:demos/ch18-array-create.py
18.1 arange:起点、终点(不含)、步长
import numpy as np
print(np.arange(5)) # [0 1 2 3 4]
print(np.arange(1, 6)) # [1 2 3 4 5]
print(np.arange(0, 10, 2)) # [0 2 4 6 8]
print(np.arange(5, 0, -1)) # [5 4 3 2 1]和 range() 的规则完全一样,也是左闭右开 —— 终点取不到。 这是本课程第三次强调"左闭右开"了(列表切片、range、现在是 arange), 因为它们背后是同一套索引规则。
print(np.arange(0, 1, 0.25)) # [0. 0.25 0.5 0.75] ← 注意没有 1.0点开:为什么小数步长有风险?
浮点数不能精确表示所有小数,所以 arange 用小数步长时可能出现 "多一个"或"少一个"元素的情况:
print(len(np.arange(0, 1, 0.1))) # 10
print(np.arange(0, 1, 0.1)[-1]) # 0.9999999999999999 ← 不是 1.0按"每 0.1 取一个、到 1 之前停"的直觉,应该是 10 个(0, 0.1, ..., 0.9), 结果确实对。但换成 arange(0, 1.2, 0.1) 就可能出现尾部误差累积, 导致元素个数与你的心算不符。
结论:凡是要"几个点"而不是"每隔多少取一个",都用 linspace。
print(np.linspace(0, 1, 11)) # 明确 11 个点,一定含两端18.2 linspace:要"几个点"而不是"步长"
axis = np.linspace(0.80, 1.00, 21)
print(len(axis)) # 21
print(axis[0], axis[-1]) # 0.8 1.0 ← 含两端
print(axis[:5]) # [0.8 0.81 0.82 0.83 0.84]签名是 linspace(起点, 终点, 点的个数) —— 第三个参数是个数,不是步长。
"lin"是 linear(线性)的意思:在起点和终点之间等距插入。
含两端是它和 arange 最大的行为差异:
arange(0.8, 1.0, 0.05) | linspace(0.8, 1.0, 5) | |
|---|---|---|
| 第三个参数 | 步长 | 点的个数 |
| 含终点吗 | ❌ 不含 | ✅ 含 |
| 会受浮点误差影响吗 | 会 | 不会(按个数精确划分) |
| 结果 | [0.8 0.85 0.9 0.95] | [0.8 0.85 0.9 0.95 1.0] |
典型用途:
# 画图的坐标轴刻度
np.linspace(0, 1500, 6).astype(int) # [0 300 600 900 1200 1500]
# 分箱边界(第 33 章 IQR 分箱)
np.linspace(38, 90, 11) # 内阻从 38 到 90 分 10 段
# 阈值扫描(第 32 章找最佳异常阈值)
np.linspace(2.0, 4.0, 21) # 试 21 个 z 阈值18.3 批量填充:zeros / ones / full / eye / empty
print(np.zeros(3)) # [0. 0. 0.]
print(np.ones((2, 2))) # 2×2 全 1
print(np.full((2, 3), 2.5)) # 2×3 全是 2.5(额定容量)
print(np.eye(3)) # 3×3 单位阵| 函数 | 作用 | 常见用途 |
|---|---|---|
zeros(shape) | 全 0 | 占位、累加器初值、掩码 |
ones(shape) | 全 1 | 权重初值、计数基准 |
full(shape, value) | 全是某个值 | 填额定容量、填默认阈值 |
eye(n) | n×n 单位阵 | 线性代数、one-hot 的雏形 |
empty(shape) | 不初始化(内容是内存残留) | 马上要覆盖全部元素时(快一点) |
点开:empty 为什么危险又为什么有用?
print(np.empty(5)) # [1.23e-312 6.95e-310 ...] ← 完全随机的垃圾值np.empty 不写任何数据,只是"要一块内存"。你看到的是那块内存里残留的旧数据。
为什么会有这种函数:写数据到内存需要时间。如果你马上就要覆盖全部元素, 先清零是白费功夫:
# 慢:先清零,再全部覆写
buf = np.zeros(1_000_000)
buf[:] = np.arange(1_000_000)
# 快:跳过清零
buf = np.empty(1_000_000)
buf[:] = np.arange(1_000_000)危险之处:如果你忘了覆盖某一部分,那里就是随机数,而且不报错。
实践建议:
- 除非性能敏感且你确信会全覆盖,否则一律用
zeros; - 看到陌生的
empty出现在别人代码里,去检查它有没有被完整填充。
18.4 ⚠ 形状参数必须是元组
这是本章的第一个高频错误:
np.zeros(2, 3)
# TypeError: Cannot interpret '3' as a data type为什么:np.zeros 的签名是 zeros(shape, dtype=float, order='C')。 你写的 3 被当成了第二个位置参数 dtype,而"3"不是一个合法的数据类型。
np.zeros((2, 3)) # ✅ 多一层括号,把形状包成元组同类错误(记住"形状是元组"这一条):
np.ones(5, 5) # ❌ 应该 np.ones((5, 5))
arr.reshape(3, 4) # ✅ reshape 例外!它接受分开的参数
np.full(2, 3, 0) # ❌ 应该 np.full((2, 3), 0)为什么 reshape 例外:它的签名是 reshape(shape) 或 reshape(*shape), 两种写法都支持。但为了统一,本书建议一律写元组形式:arr.reshape((3, 4)) 或 arr.reshape(3, 4) 都行。
18.5 reshape:换一种看法,不改数据
flat = np.arange(12)
print(flat.shape) # (12,)
m = flat.reshape(3, 4)
print(m.shape) # (3, 4)
print(m)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]核心认知:reshape 不复制数据。
它返回的是一个视图 —— "同一块内存的另一种看法"。 12 个数字还在原地,只是现在按 3 行 4 列来解读它们。
这意味着:
flat = np.arange(12)
m = flat.reshape(3, 4)
m[0, 0] = 99
print(flat[0]) # 99 ← 原数组也变了!为什么这样设计:重新排布一亿个元素要花时间和内存。 如果只是"换个解读方式",就没必要动数据。这是第 19 章"视图 vs 拷贝"的第一次出现。
-1:让 NumPy 帮你算
print(flat.reshape(2, -1).shape) # (2, 6) 12/2 = 6
print(flat.reshape(-1, 6).shape) # (2, 6) 12/6 = 2-1 是通配符:"这一维你自己算,从总数推出来。"
实用场景:处理一批图像时,你知道是 (N, 28, 28),想展平成 (N, 784):
images = np.zeros((100, 28, 28))
flat_images = images.reshape(len(images), -1) # (100, 784)
# ↑ 用 len() 而不是硬编码 100这样换一批数据(比如 500 张)时,代码不用改。
规则:-1 只能出现一次。reshape(-1, -1) 会报错,因为信息不足。
元素总数必须守恒
flat.reshape(5, 5)
# ValueError: cannot reshape array of size 12 into shape (5,5)12 个元素摆不成 5×5(需要 25 个)。报错信息里会告诉你实际大小和目标形状 —— 看到 cannot reshape array of size 12 into shape (5,5) 就去检查:5 × 5 = 25 ≠ 12。
18.6 转置、展平、增减维度
X = np.arange(1, 7).reshape(2, 3)
print(X.shape) # (2, 3)
print(X.T.shape) # (3, 2) ← .T 是转置(行列互换)转置的用途:把"样本 × 特征"转成"特征 × 样本"。 在矩阵乘法、求协方差、某些库的接口对齐时会用到。
ravel() vs flatten():一个视图一个拷贝
print(X.ravel()) # [1 2 3 4 5 6] ← 视图(改了会影响 X)
print(X.flatten()) # [1 2 3 4 5 6] ← 深拷贝(改了不影响 X)ravel() | flatten() | |
|---|---|---|
| 返回 | 视图 | 拷贝 |
| 内存 | 不额外占 | 额外占一份 |
| 改它会影响原数组 | 会 | 不会 |
| 该用哪个 | 只读遍历时 | 需要独立副本时 |
记忆法:flatten 比 ravel 长 —— 它多做了"复制"这件事。
newaxis:给数组加一个维度
row = np.arange(3)
print(row.shape) # (3,)
print(row[np.newaxis, :].shape) # (1, 3) 行向量
print(row[:, np.newaxis].shape) # (3, 1) 列向量为什么需要这个:这是第 21 章广播的关键工具。
col = np.arange(3)[:, np.newaxis] # (3, 1)
r = np.arange(3) # (3,)
print((col + r).shape) # (3, 3) ← 一张 3×3 的加法表心智模型:(3,) 是一个"没有方向"的一维数组。 用 [:, np.newaxis] 把它变成"竖着的" (3, 1),用 [np.newaxis, :] 变成"横着的" (1, 3)。 方向不同,广播出来的结果就不同。
点开:还有更可读的写法 —— np.expand_dims
print(np.expand_dims(row, axis=0).shape) # (1, 3)
print(np.expand_dims(row, axis=1).shape) # (3, 1)完全等价,但 np.expand_dims(row, axis=1) 比 row[:, np.newaxis] 更直白 ("在第 1 维插入一个长度为 1 的轴")。
选哪个:
- 写短表达式、熟悉 NumPy 的人 →
np.newaxis(更紧凑) - 追求可读性、团队协作 →
np.expand_dims - 本书两个都会用,看到不认识的知道它们是同一件事就行。
在 PyTorch 里对应的是 x.unsqueeze(dim) 和 x.squeeze(dim)(去掉长度为 1 的维度)。
18.7 随机数:可复现是底线
rng = np.random.default_rng(42) # 固定种子
r1 = rng.normal(2.5, 0.01, 5)
rng2 = np.random.default_rng(42) # 同样的种子
r2 = rng2.normal(2.5, 0.01, 5)
print(np.allclose(r1, r2)) # True ← 两次完全一致为什么必须固定种子:
如果每次运行得到不同的随机数据,别人就无法复现你的结果, 你自己也无法判断"这次结果变好是因为改了方法,还是因为随机数不同"。
这就是本书数据用 seed=42 生成的原因 —— 你在自己电脑上跑出来的行数、 每支电芯的 β 值,必须和书上完全一致。不一致就说明哪里错了。
API 说明:
| 写法 | 评价 |
|---|---|
np.random.default_rng(seed) | ✅ 推荐(新式,返回一个独立的 Generator 对象) |
np.random.seed(seed) + np.random.normal(...) | 能用(老式,改全局状态) |
为什么推荐新式:default_rng 返回的对象是独立的。 如果你在项目里同时用了两个库、它们都想用随机数,老式的全局种子会互相干扰; 新式的各管各的。
常用分布:
rng = np.random.default_rng(42)
rng.normal(2.5, 0.01, 5) # 正态分布(均值 2.5、标准差 0.01)
rng.uniform(0.8, 1.6, 5) # 均匀分布
rng.integers(800, 1501, 5) # 整数 [800, 1501) —— 注意右端不含
rng.choice(["A", "B", "C"], 5) # 从列表里随机选
rng.permutation(5) # 打乱顺序(洗牌)注意 integers:右端不含,所以想取到 1500 要写 1501。
本书数据里每支电芯的寿命就有个这样的区间(最短 859 圈、最长 1423 圈)—— integers(800, 1501) 就是构造这类"随机寿命"的典型写法。
18.8 完整示例与输出
cd code
python demos/ch18-array-create.py真实输出(节选):
=== ② linspace:要「几个点」而不是「步长」 ===
np.linspace(0.80, 1.00, 21) → 21 个点
首 0.80 末 1.00 含两端 ✅
=== ④ 形状参数必须是元组(高频错误) ===
np.zeros(2, 3) → TypeError: Cannot interpret '3' as a data type
正确写法:np.zeros((2, 3)) ← 多一层括号
=== ⑤ reshape ===
reshape(2, -1) → shape=(2, 6) ← -1 表示「你帮我算」
reshape(5, 5) → ValueError: cannot reshape array of size 12 into shape (5,5)
reshape 不复制数据,只是换了个「看待同一批数字」的视角(视图)。
=== ⑥ 转置 / 展平 / 增减维度 ===
展平 ravel() = [1 2 3 4 5 6] ← 视图,改了会影响 X
展平 flatten()= [1 2 3 4 5 6] ← 深拷贝,改了不影响 X
列向量在广播里非常有用(第 21 章):(n,1) 与 (m,) 能算出 (n,m) 的表。
=== ⑧ 随机数组要固定种子 ===
第一次: [2.503 2.4896 2.5075 2.5094 2.4805]
再来一次: [2.503 2.4896 2.5075 2.5094 2.4805] 两次一致吗? True本章练习
练习 1:用创建函数与 reshape 造数组
依次完成,每步打印 .shape 验证:
- 造一个
(12, 1)的全零列向量; - reshape 成
(3, 4),打印 shape 和 size; - 用
-1把它 reshape 回 1 维; - 用
np.linspace造 0.80 → 1.00 的 21 个点,打印首尾并验证"含两端"; - 用
np.eye(4)造单位阵。
参考答案
import numpy as np
# ① 全零列向量:形状必须是元组
z = np.zeros((12, 1))
print(z.shape) # (12, 1)
# ② reshape 成 3×4
m = z.reshape(3, 4)
print(m.shape, m.size) # (3, 4) 12
print(m.size == z.size) # True ← 元素总数守恒
# ③ 用 -1 回到 1 维
flat = m.reshape(-1)
print(flat.shape) # (12,)
print(m.ravel().shape) # (12,) ← ravel 也行,而且是视图
# ④ linspace 含两端
axis = np.linspace(0.80, 1.00, 21)
print(len(axis)) # 21
print(axis[0], axis[-1]) # 0.8 1.0
print(axis[0] == 0.80 and axis[-1] == 1.00) # True
print(axis[:5]) # [0.8 0.81 0.82 0.83 0.84]
print(np.allclose(np.diff(axis), axis[1] - axis[0])) # True 间距均匀
# ⑤ 单位阵
print(np.eye(4))要确认的三件事:
np.zeros((12, 1))的双层括号不能少 —— 少了就报TypeError;reshape前后元素总数不变:3 × 4 = 12;linspace(0.8, 1.0, 21)含两端,而arange(0.8, 1.0, 0.01)不含 1.0。
完整练习:exercises/ex18-01-array-create
练习 2:为什么 reshape 改了数据,原数组也变了?
a = np.arange(12)
b = a.reshape(3, 4)
b[0, 0] = 99
print(a[0])要求:先预测输出,再运行验证,然后解释原因。
参考答案
输出是 99,不是 0。
原因:reshape 返回的是视图(view),不是拷贝。
内存里的一串数字: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
↑
a 的看法:一维,12 个元素
b 的看法:3 行 4 列,同一块内存
b[0,0] 就是内存里的第 0 个元素 —— 和 a[0] 是同一个位置所以 b[0, 0] = 99 改的就是 a[0]。
怎么确认是视图:看 .base 属性。
print(b.base is a) # True ← 视图的 base 指向原数组
c = a.reshape(3, 4).copy()
print(c.base) # None ← 拷贝没有 base哪些操作返回视图,哪些返回拷贝:
| 返回视图 | 返回拷贝 |
|---|---|
切片 a[1:5] | 花式索引 a[[0, 2]] |
reshape / ravel | 布尔索引 a[a > 5] |
.T 转置 | flatten() |
np.newaxis 增维 | astype() |
记忆法:"只改变解读方式"的操作 → 视图;"需要挑挑拣拣或重新排版"的操作 → 拷贝。
为什么要这样设计:一亿个元素的切片如果都拷贝,内存直接翻倍。 视图让"切一片"变成零成本的"加个框"。
什么时候是坑:你以为自己在改一个副本,其实改到了原数据。
def normalize(arr):
arr = arr.ravel() # 视图!
arr /= arr.max() # 原地修改 → 调用方的数组被改了
return arr
data = np.array([1.0, 2.0, 4.0])
normalize(data)
print(data) # [0.25 0.5 1. ] ← 调用方的 data 被改了!修复:arr = arr.ravel().copy(),或者在函数内用 arr = arr / arr.max()(产生新数组而不是原地改)。
练习 3:随机数不可复现会带来什么问题
import numpy as np
# 版本 A
a1 = np.random.default_rng().normal(2.5, 0.01, 3)
a2 = np.random.default_rng().normal(2.5, 0.01, 3)
# 版本 B
rng = np.random.default_rng(42)
b1 = rng.normal(2.5, 0.01, 3)
b2 = rng.normal(2.5, 0.01, 3)要求:说出 a1 和 a2、b1 和 b2 分别相不相等,以及各自适合什么场景。
参考答案
a1 和 a2:不相等(每次 default_rng() 不给种子,用的是系统熵)
b1 和 b2:也不相等!注意 B 版本的反直觉之处:rng 是一个有状态的生成器。 b1 消耗了前 3 个随机数,b2 拿到的是接下来的 3 个 —— 所以两个数组内容不同!
但 B 版本是可复现的:重新运行整个脚本,b1 和 b2 的值会和上一次完全一样。
# 验证可复现性
rng = np.random.default_rng(42)
b1 = rng.normal(2.5, 0.01, 3) # 每次运行都是同一组值对比表:
| 写法 | 每次运行一样吗 | 适合 |
|---|---|---|
np.random.default_rng()(无种子) | ❌ 不一样 | 真实随机需求(抽签、加密不适用) |
np.random.default_rng(42) | ✅ 一样 | 科研复现、教学、单元测试 |
同一个 rng 对象连续调用 | ❌ 每次调用不同 | 正常的多次采样 |
如果想让两批随机数完全相同,必须重建生成器:
r1 = np.random.default_rng(42).normal(2.5, 0.01, 3)
r2 = np.random.default_rng(42).normal(2.5, 0.01, 3)
print(np.allclose(r1, r2)) # True为什么这件事对本书很重要:本书的数据用固定种子生成, 所以你在自己电脑上跑出来的 cycle_raw.csv 是 13,967 行、每支电芯的 β 值也和书上一致。 如果哪天你跑出来行数不对,第一件要检查的就是种子改没改。
在 AI 项目里的三处应用:
# ① 划分训练/测试集 —— 必须固定种子,否则每次划分不同、评估结果不可比
train = df.sample(frac=0.8, random_state=42)
# ② 模型初始化
torch.manual_seed(42)
# ③ 数据打乱 —— 要让实验可复现
DataLoader(dataset, shuffle=True, generator=torch.Generator().manual_seed(42))面向 AI 开发:形状是 AI 里 90% 报错的根源
如果说第 17 章教会你"张量就是数组",那这一章教的是每个 AI 工程师每天都要做的事:对齐形状。
一个真实的报错:
RuntimeError: Expected 4-dimensional input for 4-dimensional weight [64, 3, 7, 7],
but got 3-dimensional input of size [3, 224, 224] instead翻译:卷积层要 (batch, channel, height, width) 四维, 你给的是 (channel, height, width) 三维 —— 少了一个 batch 维度。
修复就是用本章学的 newaxis:
img = np.zeros((3, 224, 224)) # 单张图:(C, H, W)
batch = img[np.newaxis, :, :, :] # (1, C, H, W)
print(batch.shape) # (1, 3, 224, 224)AI 里最常遇到的四个形状错误:
| 错误 | 原因 | 修复 |
|---|---|---|
| 少 batch 维 | 单样本没打包 | x[None] 或 x.unsqueeze(0) |
| 多了 batch 维 | 单样本当成 batch | x.squeeze(0) |
| 通道顺序不对 | (C,H,W) vs (H,W,C) | .permute() / .transpose() |
| 展平前忘了 batch 维 | x.reshape(-1, 784) 把 batch 也展平了 | x.reshape(x.shape[0], -1) |
最后一条最容易犯:
x = torch.zeros(32, 3, 224, 224) # batch=32
bad = x.reshape(-1, 784) # (32*3*224*224/784, 784) —— 全乱了!
good = x.reshape(x.shape[0], -1) # (32, 150528) ← 保住 batch 维所以"用 x.shape[0] 而不是硬编码 batch 大小"是好习惯 —— 第 18 章讲 -1 通配符时提过这一点,在 AI 里它是硬性要求。
三个实用的调试习惯:
① 每个操作后 print(x.shape)。 这不是"新手做法",这是从业者的标准动作。
② 报错提到形状时,先看两个东西:
- 报错里说"expected"什么形状、"got"什么形状;
- 你喂进去的那个张量的
.shape到底是什么。
③ 用 -1 和 x.shape[0] 表达"我不关心这一维",但永远对 batch 维显式。 这样换 batch size 时代码不用改。