NumPy: 广播机制
广播机制
1. 你将学到
❶ 广播 3 条规则 ❷ 标量→1D→2D 广播 ❸ 兼容性判断 ❹ 常见陷阱 ❺ 广播 vs 循环性能
2. 一个开发者的真实故事
(1) 痛点
Bob 想把 1×3 行向量加到 4×3 矩阵每行上,老老实实写了 4 行 for 循环——代码丑、速度慢、还容易写错索引。
(2) 解法
Alice 一句话点醒:"不用循环——NumPy 自动广播。行向量'广播'成 4×3 再加。"Bob 看 Mermaid 图,瞬间理解维度如何"虚扩展"。
(3) 收益
删掉循环后,代码从 6 行缩到 1 行,速度提升 50 倍以上——广播是 NumPy 向量化运算的基石。
3. 广播规则详解
(1) 广播 3 条规则
NumPy 广播(broadcasting)让不同形状的数组在算术运算中自动"对齐"维度,无需手动复制数据。核心规则如下:
| 规则 | 说明 | 示例 |
|---|---|---|
| 规则 1:维度对齐 | 从最右侧维度开始,向左对齐,不足的维度在左侧补 1 | (3,) + (2,3) → 补成 (1,3) + (2,3) |
| 规则 2:尺寸为 1 可扩展 | 某维度尺寸为 1 时,沿该维度复制到与另一数组相同 | (1,3) + (2,3) → (2,3) + (2,3) |
| 规则 3:其余必须相同 | 非尺寸-1 的维度必须完全一致,否则报错 | (2,3) + (4,3) → ❌ 2≠4 |
广播规则速查表
| 情况 | 形状 A | 形状 B | 结果形状 | 是否兼容 |
|---|---|---|---|---|
| 标量 + 数组 | () |
(3,4) |
(3,4) |
✅ |
| 1D + 2D | (3,) |
(4,3) |
(4,3) |
✅ |
| 列向量 + 行向量 | (4,1) |
(1,3) |
(4,3) |
✅ |
| 两个 2D | (3,1) |
(1,5) |
(3,5) |
✅ |
| 维度不匹配 | (2,3) |
(4,3) |
— | ❌ |
| 无尺寸-1 可扩展 | (3,) |
(4,) |
— | ❌ |
(2) 广播步骤可视化
以 (4,3) 矩阵 + (3,) 行向量为例,广播的完整过程:
graph TB
A["Matrix A<br/>shape=(4,3)"] --> D{"Rule 1:<br/>Align dimensions"}
B["Vector B<br/>shape=(3,)"] --> D
D --> E["Pad left dim<br/>B: (3,) → (1,3)"]
E --> F{"Rule 2:<br/>Expand dim=1"}
F --> G["Expand B along axis 0<br/>(1,3) → (4,3)"]
G --> H["A + B_expanded<br/>shape=(4,3)"]
style A fill:#4CAF50,color:#fff
style B fill:#2196F3,color:#fff
style D fill:#FF9800,color:#fff
style F fill:#FF9800,color:#fff
style G fill:#9C27B0,color:#fff
style H fill:#E91E63,color:#fff
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
关键理解:广播不复制数据——它只在计算时"假装"复制,实际通过 stride 技巧实现零拷贝。
(3) 标量 + 数组广播
标量被视为 0 维数组,广播时沿所有维度扩展。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:标量加数组(难度⭐)
import numpy as np
a = np.array([[1, 2, 3],
[4, 5, 6]])
# Scalar broadcasts to (2,3)
result = a + 10
print(result)
# [[11 12 13]
# [14 15 16]]
# Equivalent manual loop (slow!)
result_loop = np.empty_like(a)
for i in range(a.shape[0]):
for j in range(a.shape[1]):
result_loop[i, j] = a[i, j] + 10
print(np.array_equal(result, result_loop)) # True
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
# Scalar works with any shape
b = np.arange(24).reshape(2, 3, 4)
print((b * 2).shape) # (2, 3, 4) — scalar broadcasts to all dims
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(4) 1D + 2D 广播
一维数组与二维数组运算时,1D 数组沿行方向广播。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:行向量加矩阵(难度⭐)
import numpy as np
matrix = np.array([[10, 20, 30],
[40, 50, 60],
[70, 80, 90],
[100, 110, 120]]) # shape (4,3)
row = np.array([1, 2, 3]) # shape (3,)
# row: (3,) → pad → (1,3) → expand → (4,3)
result = matrix + row
print(result)
# [[ 11 22 33]
# [ 41 52 63]
# [ 71 82 93]
# [101 112 123]]
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:列向量加矩阵(难度⭐⭐)
列向量广播需要先正确塑造形状——2D 列向量沿列方向扩展。
import numpy as np
matrix = np.array([[10, 20, 30],
[40, 50, 60],
[70, 80, 90],
[100, 110, 120]]) # shape (4,3)
col = np.array([[1],
[2],
[3],
[4]]) # shape (4,1)
# col: (4,1) → expand axis 1 → (4,3)
result = matrix + col
print(result)
# [[ 11 21 31]
# [ 42 52 62]
# [ 73 83 93]
# [104 114 124]]
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
# Common trick: use newaxis to create column vector from 1D
col2 = np.array([1, 2, 3, 4])[:, np.newaxis] # (4,) → (4,1)
print(col2.shape) # (4, 1)
print(np.array_equal(matrix + col, matrix + col2)) # True
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(5) 2D + 2D 广播
两个 2D 数组可以在不同维度各自广播,产生"外积"效果。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:两个 2D 广播(难度⭐⭐)
import numpy as np
a = np.array([[1],
[2],
[3]]) # shape (3,1)
b = np.array([[10, 20, 30, 40]]) # shape (1,4)
# a expands along axis 1: (3,1) → (3,4)
# b expands along axis 0: (1,4) → (3,4)
result = a + b
print(result)
# [[11 21 31 41]
# [12 22 32 42]
# [13 23 33 43]]
# This is essentially an outer operation
print(result.shape) # (3, 4)
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(6) broadcast_to 与 broadcast_arrays
NumPy 提供了显式广播工具,用于调试或需要中间结果的场景。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:broadcast_to 和 broadcast_arrays(难度⭐⭐)
import numpy as np
# np.broadcast_to: explicitly broadcast to target shape
row = np.array([1, 2, 3]) # shape (3,)
expanded = np.broadcast_to(row, (4, 3))
print(expanded)
# [[1 2 3]
# [1 2 3]
# [1 2 3]
# [1 2 3]]
print(expanded.shape) # (4, 3)
# Note: broadcast_to returns a read-only view
try:
expanded[0, 0] = 999 # ValueError — read-only!
except ValueError as e:
print(f"Error: {e}")
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
# np.broadcast_arrays: broadcast multiple arrays together
a = np.array([[1], [2], [3]]) # (3,1)
b = np.array([[10, 20, 30, 40]]) # (1,4)
ba, bb = np.broadcast_arrays(a, b)
print(ba.shape) # (3, 4)
print(bb.shape) # (3, 4)
print(ba)
# [[1 1 1 1]
# [2 2 2 2]
# [3 3 3 3]]
print(bb)
# [[10 20 30 40]
# [10 20 30 40]
# [10 20 30 40]]
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(7) 不兼容报错
当两个形状无法通过广播规则对齐时,NumPy 抛出 ValueError。
▶ 示例
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
:不兼容形状报错(难度⭐)
import numpy as np
a = np.ones((3, 4)) # shape (3,4)
b = np.ones((2, 4)) # shape (2,4)
# axis 0: 3 vs 2, neither is 1 → incompatible!
try:
result = a + b
except ValueError as e:
print(f"Error: {e}")
# operands could not be broadcast together with shapes (3,4) (2,4)
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
# Fix: reshape or slice to make dimensions compatible
a = np.ones((3, 4))
c = np.ones((1, 4)) # (1,4) — axis 0 is 1, can expand
print((a + c).shape) # (3, 4) — works!
# Another incompatible case
d = np.array([1, 2, 3]) # (3,)
e = np.array([1, 2, 3, 4]) # (4,)
try:
print(d + e)
except ValueError as e2:
print(f"Error: {e2}")
# operands could not be broadcast together with shapes (3,) (4,)
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
兼容判断流程
graph TB
S["Input shapes A, B"] --> P["Pad shorter shape<br/>with 1s on the left"]
P --> C{"For each axis:<br/>size_A == size_B<br/>or size_A == 1<br/>or size_B == 1?"}
C -->|Yes, all axes| OK["Compatible!<br/>Result shape = max per axis"]
C -->|No, any axis| FAIL["Incompatible!<br/>ValueError"]
style S fill:#4CAF50,color:#fff
style OK fill:#2196F3,color:#fff
style FAIL fill:#F44336,color:#fff
style C fill:#FF9800,color:#fff
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(8) 常见场景与结果形状
| 场景 | 形状 A | 形状 B | 广播过程 | 结果形状 |
|---|---|---|---|---|
| 标量 + 任意 | (3,4) |
() |
标量→(3,4) |
(3,4) |
| 行向量 + 矩阵 | (4,3) |
(3,) |
(3,)→(1,3)→(4,3) |
(4,3) |
| 列向量 + 矩阵 | (4,3) |
(4,1) |
(4,1)→(4,3) |
(4,3) |
| 列 + 行向量 | (3,1) |
(1,4) |
各自扩展→(3,4) |
(3,4) |
| 3D + 1D | (2,3,4) |
(4,) |
(4,)→(1,1,4)→(2,3,4) |
(2,3,4) |
| 3D + 2D | (2,3,4) |
(3,4) |
(3,4)→(1,3,4)→(2,3,4) |
(2,3,4) |
| 3D + 列向量 | (2,3,4) |
(3,1) |
(3,1)→(1,3,1)→(2,3,4) |
(2,3,4) |
(9) 广播 vs 循环性能
| 方法 | 10,000 元素 | 100,000 元素 | 1,000,000 元素 |
|---|---|---|---|
| Python 循环 | ~5 ms | ~50 ms | ~500 ms |
| NumPy 广播 | ~0.05 ms | ~0.2 ms | ~2 ms |
| 加速倍数 | ~100× | ~250× | ~250× |
import numpy as np
import time
size = 100_000
a = np.random.rand(size, 3)
b = np.array([1.0, 2.0, 3.0])
# Method 1: Broadcasting
start = time.perf_counter()
result1 = a + b
t_broadcast = time.perf_counter() - start
# Method 2: Python loop
start = time.perf_counter()
result2 = np.empty_like(a)
for i in range(a.shape[0]):
result2[i] = a[i] + b
t_loop = time.perf_counter() - start
print(f"Broadcast: {t_broadcast*1000:.2f} ms")
print(f"Loop: {t_loop*1000:.2f} ms")
print(f"Speedup: {t_loop/t_broadcast:.0f}x")
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
(10) 综合示例:数据中心化与标准化
场景:Carol 有一份 5 名学生、3 门课的成绩数据,要用广播实现数据中心化(减均值)和标准化(减均值除标准差)。
import numpy as np
# Carol's student scores: 5 students, 3 subjects
scores = np.array([
[80, 75, 90],
[65, 88, 72],
[90, 92, 85],
[70, 60, 78],
[85, 80, 88]
])
print(f"Scores shape: {scores.shape}") # (5, 3)
# Step 1: Center — subtract mean per subject (along axis 0)
mean = scores.mean(axis=0) # shape (3,)
print(f"Mean per subject: {mean}") # e.g. [78. 79. 82.6]
# Broadcasting: (5,3) - (3,) → (5,3) - (1,3) → (5,3)
centered = scores - mean
print(f"Centered:\n{centered}")
# Verify: each column mean should be ~0
print(f"Column means after centering: {centered.mean(axis=0)}")
# [~0 ~0 ~0]
# Step 2: Standardize — divide by std per subject
std = scores.std(axis=0) # shape (3,)
print(f"Std per subject: {std}")
# Broadcasting: (5,3) / (3,) → (5,3)
standardized = (scores - mean) / std
print(f"Standardized:\n{standardized}")
# Verify: each column mean≈0, std≈1
print(f"Means: {standardized.mean(axis=0)}") # ≈ [0, 0, 0]
print(f"Stds: {standardized.std(axis=0)}") # ≈ [1, 1, 1]
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
# Broadcasting makes per-row operations equally easy
# Add a different offset to each row
row_offsets = np.array([5, 10, 15, 20, 25])[:, np.newaxis] # (5,1)
adjusted = scores + row_offsets # (5,3) + (5,1) → (5,3)
print(f"Adjusted with row offsets:\n{adjusted}")
# Per-subject scaling (different multiplier per subject)
subject_scale = np.array([1.0, 0.5, 2.0]) # (3,)
scaled = scores * subject_scale # (5,3) * (3,) → (5,3)
print(f"Scaled by subject:\n{scaled}")
> **输出:** 在本地 Python 环境运行 NumPy 2.x,输出 ndarray 数组内容。Piston 服务器未预装 NumPy,请在本机安装(`pip install numpy`)后实操对照。实际数值可能因 NumPy 版本、随机种子略有差异。
❓ 常见问题
broadcast_to 返回的是只读视图,不能原地修改。(3,4) + (2,4) 在轴 0 上 3≠2 且都不为 1,报 ValueError。(2,3,4) + (3,1) → (3,1) 补成 (1,3,1) → 扩展成 (2,3,4)。从最右轴往左逐轴检查即可。broadcast_to 是显式函数,可指定目标形状,但返回只读视图。需要可写结果时用 .copy() 复制一份。(1,n) 或 (n,)(1D),沿行方向广播。列向量 shape 是 (n,1),沿列方向广播。用 [:, np.newaxis] 可把 1D 转为列向量。📖 小节
- 广播 3 规则:① 左侧补 1 对齐维度 ② 尺寸 1 可扩展 ③ 其余必须相同
- 标量 → 1D → 2D → 3D,广播规则一致
- 列向量
(n,1)沿列广播,行向量(1,m)沿行广播 broadcast_to显式广播(只读),broadcast_arrays同时广播多个数组- 不兼容维度会抛
ValueError,从最右轴逐轴检查 - 广播比 Python 循环快 100~250 倍,零内存开销
📝 作业
(1) 判断形状兼容性
判断以下形状对是否兼容,若兼容写出结果形状:
(5,3)+(3,)→ ?(4,1)+(1,6)→ ?(2,3,4)+(2,1,4)→ ?(3,)+(4,)→ ?(6,)+(2,3,6)→ ?
(2) 每行加不同偏移量
- 创建
a = np.arange(12).reshape(4,3) - 创建偏移量
offsets = np.array([100, 200, 300, 400]) - 用广播让每行加上对应的偏移量(提示:
offsets[:, np.newaxis]) - 打印结果,验证第 0 行各元素是否都加了 100
(3) 广播计算距离矩阵
- 创建点集
points = np.array([[0,0], [1,0], [0,1], [1,1]])(4 个 2D 点) - 用广播计算 4×4 距离矩阵
D[i,j] = ||points[i] - points[j]|| - 提示:
points[:, np.newaxis, :]shape(4,1,2),points[np.newaxis, :, :]shape(1,4,2) - 广播后差值 shape
(4,4,2),再沿 axis=2 求 norm - 打印距离矩阵,验证
D[0,0]= 0,D[0,3]= √2 ≈ 1.414