一. 前言:錯的通常不是數值,而是 Shape #
NumPy 最讓人困惑的 bug,往往長這樣:
ValueError: operands could not be broadcast together with shapes (4,3) (4,)
或更危險一點:程式完全沒報錯,結果的 shape 也「看起來很合理」,但數值其實沿著錯的維度算了。 拍拍君先講結論:
學 NumPy,不要只盯著值;每一步都要先預測 shape。 這篇會用一份小型銷售資料,串起 shape、axis、索引、boolean mask、broadcasting 與除錯方法。理解之後,Pandas、PyTorch、MLX 的多維資料也會突然順眼很多。
二. 安裝與準備資料 #
用 uv 建一個最小專案:
uv init numpy-shape-lab
cd numpy-shape-lab
uv add numpy
如果你使用一般虛擬環境:
python -m pip install numpy
先建立一個 (4, 3) 的陣列:四列代表四間分店,三欄代表三種商品。
import numpy as np
sales = np.array(
[
[120, 80, 50],
[100, 90, 60],
[140, 70, 40],
[110, 95, 55],
],
dtype=np.float64,
)
print(sales)
print(sales.shape) # (4, 3)
print(sales.ndim) # 2
print(sales.size) # 12
print(sales.dtype) # float64
這個 shape 本身就在描述資料契約:
axis 0:分店,共 4 個
axis 1:商品,共 3 個
ndim == 2 表示它有兩條軸;size == 12 才是元素總數。
三. Shape 與 Axis:先建立座標感 #
3.1 Axis 不是「橫的或直的」口訣 #
很多教學把 axis=0 背成直向、axis=1 背成橫向。
二維時勉強可用,進入三維就會崩掉。更穩的想法是:
指定
axis=k,就是把第 k 條軸收掉或沿著它操作。
print(sales.sum(axis=0))
# [470. 335. 205.]:4 間分店被加總,保留 3 種商品
print(sales.sum(axis=1))
# [250. 250. 250. 260.]:3 種商品被加總,保留 4 間分店
用 shape 預測一次:
原本 (4, 3)
sum(axis=0) 收掉 4 (3,)
sum(axis=1) 收掉 3 (4,)
3.2 一維陣列不是列向量 #
product_price = np.array([30.0, 45.0, 60.0])
print(product_price.shape) # (3,)
(3,) 只有一條長度為 3 的軸。
它不是 (1, 3),也不是 (3, 1)。這個逗號不是裝飾,而是在提醒你它是一維。
row = product_price.reshape(1, 3)
column = product_price.reshape(3, 1)
print(row.shape) # (1, 3)
print(column.shape) # (3, 1)
3.3 reshape() 不能憑空生出元素
#
numbers = np.arange(12)
matrix = numbers.reshape(3, 4)
cube = numbers.reshape(2, 2, 3)
print(matrix.shape) # (3, 4)
print(cube.shape) # (2, 2, 3)
只要元素總數仍是 12,通常就能重排。
-1 可以請 NumPy 自己推算其中一個維度:
batch = np.arange(24).reshape(2, -1, 4)
print(batch.shape) # (2, 3, 4)
一個 reshape() 最多只能放一個 -1,不然 NumPy 也不知道該猜哪一條軸。
四. 基本索引與切片:選到的 Shape 是什麼? #
4.1 單一位置 #
print(sales[0, 1]) # 80.0
兩條軸都指定單一整數,因此結果是 scalar。 負索引同樣從尾端開始:
print(sales[-1, -1]) # 55.0
4.2 選一列與保留二維 #
first_store = sales[0]
first_store_2d = sales[0:1]
print(first_store.shape) # (3,)
print(first_store_2d.shape) # (1, 3)
整數索引會消掉一條軸;slice 則會保留它。 這個差異在組合矩陣、串接 batch 或餵給模型時非常重要。
4.3 選欄與區塊 #
second_product = sales[:, 1]
second_product_2d = sales[:, 1:2]
top_left = sales[:2, :2]
print(second_product.shape) # (4,)
print(second_product_2d.shape) # (4, 1)
print(top_left.shape) # (2, 2)
4.4 Slice 通常是 View #
基本切片通常共享原陣列的資料:
demo = np.arange(6).reshape(2, 3)
view = demo[:, :2]
view[0, 0] = 999
print(demo[0, 0]) # 999
不想讓修改回頭影響原陣列,就明確複製:
safe_copy = demo[:, :2].copy()
safe_copy[0, 0] = -1
print(demo[0, 0]) # 999,沒有再被改成 -1
不要靠「看起來像新變數」猜是否複製;需要隔離就寫 .copy()。
五. Boolean Mask:把條件直接變成索引 #
5.1 建立同 Shape 的 Mask #
high_sales = sales >= 100
print(high_sales.shape) # (4, 3)
print(high_sales.dtype) # bool
用 mask 取值時,符合條件的元素會被攤平成一維結果:
values = sales[high_sales]
print(values)
# [120. 100. 140. 110.]
print(values.shape) # (4,)
5.2 組合條件要用 &、|、~
#
mid_range = (sales >= 70) & (sales < 110)
either_end = (sales < 60) | (sales >= 130)
not_high = ~(sales >= 100)
每個比較式都要加括號,因為 Python 運算子優先順序不是你直覺想的那樣。 也不要寫:
# 錯誤示範
# (sales >= 70) and (sales < 110)
and 想把整個陣列壓成單一 True/False,NumPy 不會替你猜。
5.3 用列條件選整列 #
假設想找總銷量大於 250 的分店:
store_totals = sales.sum(axis=1)
busy_store_mask = store_totals > 250
busy_stores = sales[busy_store_mask]
print(store_totals) # [250. 250. 250. 260.]
print(busy_store_mask) # [False False False True]
print(busy_stores.shape) # (1, 3)
mask 長度 4,正好對應 sales 的 axis 0。
選欄則放在第二個索引位置:
premium_product_mask = product_price >= 45
premium_sales = sales[:, premium_product_mask]
print(premium_sales.shape) # (4, 2)
5.4 Mask 也能直接修改 #
cleaned = sales.copy()
cleaned[cleaned < 60] = 0
print(cleaned)
若想保留原陣列,還可以用 np.where() 產生新陣列:
cleaned = np.where(sales < 60, 0, sales)
np.where(condition, x, y) 的三個輸入也會遵守 broadcasting 規則。
六. Broadcasting:從最右邊開始對齊 #
Broadcasting 讓不同 shape 的陣列做逐元素運算,不必手動複製資料。 規則其實只有兩條。從最右邊的維度逐一比較,每一對維度必須:
- 數字相等;或
- 其中一邊是
1。 缺少的左側維度,可視為1。
6.1 Scalar 對所有元素 #
taxed = sales * 1.05
print(taxed.shape) # (4, 3)
scalar 的 shape 是 (),可套用到所有位置。
6.2 對每一欄套用不同係數 #
product_factor = np.array([1.0, 0.9, 1.2])
adjusted = sales * product_factor
print(sales.shape) # (4, 3)
print(product_factor.shape) # (3,)
print(adjusted.shape) # (4, 3)
從右邊排好看:
sales 4 × 3
product_factor 3
result 4 × 3
尾端都是 3,因此 factor 會套用到每一列。
6.3 為什麼 (4,) 不能直接套到四列?
#
store_factor = np.array([1.0, 0.95, 1.1, 1.05])
# sales * store_factor
# ValueError: operands could not be broadcast together with shapes (4,3) (4,)
NumPy 從最右邊比較,先看到的是 3 和 4,當然對不上。
你的語意是「每間分店一個係數」,就要把它變成 (4, 1):
adjusted_by_store = sales * store_factor[:, np.newaxis]
print(store_factor[:, np.newaxis].shape) # (4, 1)
print(adjusted_by_store.shape) # (4, 3)
現在形狀是:
sales 4 × 3
store_factor 4 × 1
result 4 × 3
第一條軸 4 對 4;第二條軸 3 對 1,合法。
七. newaxis、expand_dims() 與 keepdims
#
7.1 明確插入 Singleton Axis #
np.newaxis 就是 None,用途是插入長度為 1 的新軸:
x = np.array([10, 20, 30])
row = x[np.newaxis, :]
column = x[:, np.newaxis]
print(row.shape) # (1, 3)
print(column.shape) # (3, 1)
如果覺得索引語法太隱晦,可以用名稱更直接的 expand_dims():
row = np.expand_dims(x, axis=0)
column = np.expand_dims(x, axis=1)
7.2 外積式運算 #
兩個一維陣列都先補軸,就能產生所有配對:
stores = np.array([1, 2, 3, 4])
products = np.array([10, 20, 30])
grid = stores[:, None] + products[None, :]
print(grid.shape) # (4, 3)
print(grid)
(4, 1) 加 (1, 3),結果是 (4, 3)。
7.3 聚合後用 keepdims=True
#
想把每間分店的銷量轉成商品占比:
store_totals = sales.sum(axis=1, keepdims=True)
share = sales / store_totals
print(store_totals.shape) # (4, 1)
print(share.shape) # (4, 3)
print(share.sum(axis=1)) # [1. 1. 1. 1.]
如果不加 keepdims=True,總和會是 (4,),無法和 (4, 3) 對齊。
keepdims 的價值不是「讓括號比較多」,而是保留資料語意所在的軸。
八. 常見陷阱與除錯方法 #
8.1 * 不是矩陣乘法
#
a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])
print(a * b) # element-wise
print(a @ b) # matrix multiplication
8.2 意外產生超大中間陣列 #
Broadcasting 不會先複製小陣列,但輸出本身仍需要記憶體。
# 100_000 × 100_000 個 float64 約需 80 GB
# huge = np.ones((100_000, 1)) + np.ones((1, 100_000))
8.3 Boolean Mask Shape 不吻合 #
mask = np.array([True, False, True])
print(sales[:, mask].shape) # (4, 2),mask 對應商品軸
# sales[mask] # 錯:axis 0 長度是 4,不是 3
問自己:這個 mask 描述的是「列」還是「欄」?然後把它放進正確索引位置。
8.4 用 squeeze() 把語意一起擠掉
#
batch = np.ones((1, 4, 1, 3))
print(np.squeeze(batch).shape) # (4, 3)
print(np.squeeze(batch, axis=2).shape) # (1, 4, 3)
不指定 axis 的 squeeze() 會移除所有長度為 1 的軸。
若 batch axis 很重要,請指定要移除哪一條。
8.5 一套可重複的 Shape Debug 流程 #
遇到錯誤時,按這個順序查:
def describe(name: str, array: np.ndarray) -> None:
print(
f"{name}: shape={array.shape}, "
f"ndim={array.ndim}, dtype={array.dtype}"
)
describe("sales", sales)
describe("store_factor", store_factor)
接著:
- 先寫出每條軸的語意;
- 把兩個 shape 靠右排列;
- 從最右邊逐一檢查「相等或其中之一為 1」;
- 確認預期輸出 shape 與記憶體大小;
- 必要時用
None、expand_dims()或keepdims=True補軸; - 最後才執行真正的大資料運算。 也能讓 NumPy 先幫你驗證:
print(np.broadcast_shapes((4, 3), (3,))) # (4, 3)
print(np.broadcast_shapes((4, 3), (4, 1))) # (4, 3)
try:
np.broadcast_shapes((4, 3), (4,))
except ValueError as error:
print(error)
九. 小型實戰:標準化每個商品欄位 #
最後把 shape、axis 與 broadcasting 合在一起。 我們要讓每個商品欄位都做 z-score 標準化:
column_mean = sales.mean(axis=0, keepdims=True)
column_std = sales.std(axis=0, keepdims=True)
standardized = (sales - column_mean) / column_std
print(column_mean.shape) # (1, 3)
print(column_std.shape) # (1, 3)
print(standardized.shape) # (4, 3)
驗證結果,而不是只相信程式沒報錯:
np.testing.assert_allclose(
standardized.mean(axis=0),
np.zeros(3),
atol=1e-12,
)
np.testing.assert_allclose(
standardized.std(axis=0),
np.ones(3),
atol=1e-12,
)
safe_std = np.where(column_std == 0, 1.0, column_std)
standardized = (sales - column_mean) / safe_std
這裡每個 shape 都值得先說出口:
sales (4, 3)
column_mean (1, 3)
column_std (1, 3)
standardized (4, 3)
只要 shape 契約清楚,程式就不再像靠運氣湊出來的魔法。
結語 #
NumPy broadcasting 的重點,不是背更多函式,而是培養「維度對齊」的直覺。 拍拍君幫你濃縮成五句:
- 每次運算前先預測輸入與輸出 shape;
- broadcasting 永遠從最右邊開始比;
- 維度必須相等,或其中一邊是 1;
- 整數索引會消軸,slice 通常保留軸;
- 聚合後還要繼續逐元素運算時,優先考慮
keepdims=True。 下次看到(4, 3)和(4,)打架,不要先亂塞reshape()。 先問清楚每條軸代表什麼,再讓 shape 表達你的意圖。這才是 NumPy 真正可靠的寫法。