6.2 广播规则的形状对齐 本节摘要:广播是 NumPy 对形状不同的数组做的自动对齐:右对齐比较各维,某维相等或其一为 1 即兼容,为 1 的一侧沿该维虚拟复制。结果形状取逐维最大值。物理上没有任何复制发生——为 1 的维 strides 记 0,读的是同一块数据。本节给出四步对齐法与完整推演练习。 从标量说起 最熟悉的广播其实是标量: 数组与数组的广播是同一套规则的推广。四步对齐法: 把两个形状右对齐,左侧缺的维补 1; 逐维比较:相等,或其一为 1,即兼容; 任何一维两边都非 1 且不相等 → 报错; 结果形状 = 逐维取较大值。
本节摘要:广播是 NumPy 对形状不同的数组做的自动对齐:右对齐比较各维,某维相等或其一为 1 即兼容,为 1 的一侧沿该维虚拟复制。结果形状取逐维最大值。物理上没有任何复制发生——为 1 的维 strides 记 0,读的是同一块数据。本节给出四步对齐法与完整推演练习。
最熟悉的广播其实是标量:
import numpy as np a = np.array([1, 2, 3]) print(a * 2) # [2 4 6] # 标量形状视为 (),与任何形状兼容,"虚拟复制"到每个位置
数组与数组的广播是同一套规则的推广。四步对齐法:
import numpy as np m = np.arange(12).reshape(3, 4) # (3, 4) v = np.array([10, 20, 30, 40]) # (4,) print(m + v) # 右对齐:(3,4) 对 (4,) # 第2维:4 对 4,相等 # 第1维:3 对补的1,其一为1,兼容 # 结果 (3, 4):v 每行"复用" # [[10 21 32 43] # [10 22 34 46] # [10 23 36 49]] col = np.array([[1], [2], [3]]) # (3, 1) print(m + col) # 第2维:4 对 1,col 沿列向扩展 # 第1维:3 对 3 # [[ 1 2 3 4] # [ 6 7 8 9] # [11 12 13 14]]

广播不搬数据,用广播视图可以亲眼看到:
import numpy as np v = np.array([10, 20, 30, 40]) bv = np.broadcast_to(v, (3, 4)) # 显式广播成 (3,4) print(bv) # [[10 20 30 40] # [10 20 30 40] # [10 20 30 40]] print(bv.strides) # (0, 8)!第一维步长为0,走多少行都停在同一字节 try: bv[0, 0] = 99 # 广播视图只读 except ValueError as e: print("只读:", e) # assignment destination is read-only
broadcast_to 是"想要广播又不想写表达式"时的工具;广播视图只读,因为多行共享一块内存,写入语义无法定义。这也解释了 6.3 节 out 参数为什么不能随便拿广播视图当输出。
import numpy as np # 图像批处理:10张 224x224 RGB 图,减去每通道均值 batch = np.random.rand(10, 224, 224, 3) mean = np.array([0.485, 0.456, 0.406]) # (3,) centered = batch - mean # 右对齐:(10,224,224,3) 对 (3,) # 前三维:10/224/224 vs 补1,全兼容 # 第四维:3 vs 3,相等 print(centered.shape) # (10, 224, 224, 3) # 反例:均值存成了 (1, 3),形状意图不明 m2 = mean.reshape(1, 3) try: batch - m2 except ValueError as e: print("报错:", str(e)[:60]) # operands could not be broadcast together # (1,3) 右对齐后:维1 3 vs 224、维2 1 vs 224?逐维全乱
排错套路:拿到报错先打印两个 .shape,按四步法手工走一遍,冲突的那一维立刻现形。九成广播错误是"该在末维对齐的数据被放在了开头维"(3, 1) 与 (1, 3) 之差),解法用 newaxis 或 reshape 把轴挪对位置。
完整过程。背景:12 个月、5 个产品的销售矩阵,算每个月各产品占比与全年同比指数。
操作:
import numpy as np rng = np.random.default_rng(9) sales = rng.integers(100, 1000, size=(12, 5)).astype(float) # 行归一化:每月总量 (12, 1),广播除法 month_total = sales.sum(axis=1, keepdims=True) # (12, 1) share = sales / month_total print(share[0].round(3)) # [0.171 0.241 0.198 0.213 0.177],每月合计恒为1 # keepdims=True 的作用:保住 (12,1) 而不是 (12,),省掉 newaxis # 若忘了 keepdims,(12,) 对 (12,5) 右对齐会在维2冲突,报错 # 修复:sales / month_total[:, np.newaxis] # 各产品相对全年均值的倍数:(1, 5) 广播 product_ratio = sales / sales.mean(axis=0, keepdims=True) # (1,5) 对 (12,5) print(product_ratio[0].round(2)) # [1.23 0.86 1.05 0.94 1.11]
结果与解读:keepdims 是广播最好的朋友——聚合时保留长度 1 的轴,形状天然对齐,免去手动插轴。share 每行合计为 1 验证了归一正确;product_ratio 显示首月产品一卖出全年均值的 1.23 倍。变式:三维 (门店, 月份, 产品) 数据同理,按哪一维归一就让哪一维 keepdims。
⚠️ 常见坑:keepdims 不是装饰参数。忘了它,(12,) 会与 (12,5) 在错误维度上尝试对齐——有时报错(好事),碰巧维数相同则静默算错(灾难)。聚合接广播的代码,永远显式想清楚输出形状。
下一节把账本模型与广播合流:ufunc 与 out 参数,让"新分配写"那一笔也能省下来。