快轉到主要內容
  1. 教學文章/

NumPy Broadcasting 實戰:Shape、索引、Mask 與維度對齊

·6 分鐘· loading · loading · ·
Python Numpy Broadcasting Indexing Boolean Mask Data-Analysis
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 113: 本文

featured

一. 前言:錯的通常不是數值,而是 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. 數字相等;或
  2. 其中一邊是 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 從最右邊比較,先看到的是 34,當然對不上。 你的語意是「每間分店一個係數」,就要把它變成 (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,合法。

七. newaxisexpand_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)

接著:

  1. 先寫出每條軸的語意;
  2. 把兩個 shape 靠右排列;
  3. 從最右邊逐一檢查「相等或其中之一為 1」;
  4. 確認預期輸出 shape 與記憶體大小;
  5. 必要時用 Noneexpand_dims()keepdims=True 補軸;
  6. 最後才執行真正的大資料運算。 也能讓 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 的重點,不是背更多函式,而是培養「維度對齊」的直覺。 拍拍君幫你濃縮成五句:

  1. 每次運算前先預測輸入與輸出 shape;
  2. broadcasting 永遠從最右邊開始比;
  3. 維度必須相等,或其中一邊是 1;
  4. 整數索引會消軸,slice 通常保留軸;
  5. 聚合後還要繼續逐元素運算時,優先考慮 keepdims=True。 下次看到 (4, 3)(4,) 打架,不要先亂塞 reshape()。 先問清楚每條軸代表什麼,再讓 shape 表達你的意圖。這才是 NumPy 真正可靠的寫法。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 113: 本文

相關文章

Pandas GroupBy 實戰:聚合、Pivot Table 與報表整理
·8 分鐘· loading · loading
Python Pandas GroupBy Pivot Table Data-Analysis Reporting
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
uv pip-compile migration:從 requirements.txt 到可重現部署流程
·7 分鐘· loading · loading
Python Uv Requirements.txt Pip-Compile Dependency-Management Deployment
Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出
·8 分鐘· loading · loading
Python Streamlit Data-Editor CSV Validation Developer-Tools
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools