💡 先搞懂問題
虛構的「青松補習班」把 4 位學生、3 科的模擬考成績放進陣列 X,shape 是 (4, 3)。班主任想把每一科都換算成「比這一科的平均高或低多少」,先算出每科平均 mu = X.mean(axis=0),shape 是 (3,)。一位助教習慣寫迴圈,用兩層 for 一格一格減;另一位直接寫 X - mu,一行算完,結果一模一樣。接著班主任又想看「每位學生比自己的平均高或低多少」,助教照樣寫 X - X.mean(axis=1),這次卻跳出 ValueError: operands could not be broadcast together with shapes (4,3) (4,)。兩次都是「一張表減一排數字」,為什麼一次可以、一次不行?
新手在這裡常有三個直覺,偏偏都不對:以為那排數字的長度只要「對得上表格的任何一軸」就能算;以為 NumPy 會從左邊開始對齊;以為 X - mu 是先把 mu 複製成 4 列再相減。資料前處理最常見的標準化公式 (X − X.mean(axis=0)) / X.std(axis=0),背後就連用了兩次同一條規則,看懂它,大半的前處理程式都能讀得懂。
這條規則叫廣播(broadcasting)。兩個陣列做逐元素運算(element-wise,例如加減乘除、比較大小、np.maximum)時,如果 shape 不同,NumPy 會從最右邊的軸開始逐軸比對:兩邊長度相等,或其中一邊是 1,就相容,長度 1 的那邊會被「拉伸」去配合;維度數比較少的一邊,先在左邊補 1。只要有一軸兩邊都不是 1 又不相等,就丟出 ValueError。拉伸只是概念上的,NumPy 並不會真的複製資料。像這樣把整張表一次交給 NumPy、不寫 Python 迴圈的寫法,叫做向量化(vectorization)。
生活比喻:調分小紙條
期末時,老師要替全班 40 位學生的國文、英文、數學都加上「每科的調分」:國文加 2、英文加 5、數學加 3。她不必影印 40 份調分表,只要把一張寫著三格的小紙條對準成績單第一列,加完往下挪一列,一路挪到最後一位。小紙條的三格剛好對著三個科目,這就是它能一路往下套用的原因。
後來教務處又給了一份「每位學生的出席加分」,40 個數字寫成一長條橫的紙。老師把它橫著對成績單,40 格對 3 個科目,怎麼對都不對;把紙條轉成直的、貼在成績單左邊,再一科一科往右套用,才是正確做法。
(40, 3) 的陣列,調分紙條是 (3,)。NumPy 從最右邊比對:3 對 3 相等;紙條少了第 0 軸,就在左邊補 1 變成 (1, 3),再沿著第 0 軸拉伸成 40 列,這就是 scores + bonus。出席加分是 (40,),最右邊 40 對 3 對不上,所以報錯;「轉成直的」就是變成 (40, 1),寫成 attend[:, None] 或 attend.reshape(-1, 1),最右邊 1 對 3 可以拉伸,結果是 (40, 3)。開頭那個報錯的 X - X.mean(axis=1),也是同樣的修法。
比喻有三個地方和實際不同。第一,老師挪紙條是一列一列做,NumPy 也確實逐格計算,但迴圈跑在編譯過的 C 程式裡,Python 只下一次指令,所以快得多。第二,老師手上至少有一張實體紙條,廣播則連「影本」都不做:拉伸出來的那些列,是用步長(stride)0 一再讀取同一塊記憶體,實驗室二會畫給你看。第三,廣播只用在逐元素運算,矩陣乘法 @ 有自己的 shape 規則(內側維度要相等),不能拿廣播的規則去套。
🎮 互動實驗室一:廣播判定器
替 a 與 b 各挑一個 shape(純量,或 1 到 3 維、每軸長度 1~5)。判定器會把兩個 shape 靠右對齊,從最右邊那一軸開始一軸一軸比對:綠色是長度相等,黃色是其中一邊是 1、會被拉伸,虛線格是維度不足時在左邊補上的 1,紅色是不相容。最下面一列是結果的 shape;只要有一軸不相容,就會顯示 NumPy 實際丟出的錯誤訊息。也可以直接按下方的常見組合。
a.shape
b.shape
🎮 互動實驗室二:虛擬複製動畫
選一個情境,按「下一步」或「自動播放」。動畫分四步:先看兩個陣列真正存在記憶體裡的樣子;再把 shape 靠右對齊;接著畫出被「虛擬複製」的格子(虛線、半透明),它們其實全都指回原本那幾格;最後逐格運算,得到結果。下方的 Python 與輸出都和實際執行的結果一致。
🎮 互動實驗室三:逐欄標準化計算器
表格是青松補習班 4 位學生的三科成績(示意),可以直接改數字。按「下一步」走一遍 (X − X.mean(axis=0)) / X.std(axis=0):每一步會顯示那一行 Python、結果的 shape 與數值,黃色的列或欄是正在被廣播的向量。上方可以切換 axis,看看改成「每位學生」或「整張表」會怎樣;也可以切換 ddof,比較 NumPy 預設的 ddof=0 與 pandas 預設的 ddof=1。
📘 原理補完
1. 三條規則與結果的 shape
NumPy 官方文件對廣播的描述是:兩個陣列運算時,從最後一軸(最右邊)開始逐一比較 shape,往左進行;兩個維度在「相等」或「其中一個是 1」時相容。整理成三條規則:一、維度數不同時,在較短的 shape 左邊補 1,直到兩邊一樣長;二、逐軸比對,相等或其中一邊是 1 就相容,否則丟出 ValueError: operands could not be broadcast together with shapes …;三、結果的每一軸取兩邊較大的長度,長度 1 的那一邊被拉伸。官方文件舉的例子是 (8, 1, 6, 1) 與 (7, 1, 5),結果是 (8, 7, 6, 5),可以用 np.broadcast_shapes 直接驗算。
2. 只有長度 1 能拉伸
拉伸只會發生在長度剛好是 1 的軸上。(2,) 不會自動重複兩次去配合 (4,),(2, 3) 和 (3, 2) 也不會自動轉置;這些情況一律報錯。真的需要重複,要自己用 np.tile 或 np.repeat,它們會真的配置新的記憶體。維度不足時補的 1 一定加在左邊,所以一維的 (3,) 永遠被當成「一列」(1, 3),不會被當成直行;想要直行,必須自己寫 [:, None]。
3. 兩邊都被拉伸:結果比兩個輸入都大
當一邊是 (3, 1)、另一邊是 (4,),補 1 之後是 (3, 1) 對 (1, 4),兩邊各有一軸是 1,各自被拉伸,結果是 (3, 4),等於把每一列的值和每一欄的值兩兩組合。刻意這樣寫時很好用,例如一次算出所有點兩兩之間的差;不小心發生時卻是最難抓的錯誤,因為它不會報錯。典型情況是迴歸題的 y_true 是 (n, 1)、y_pred 是 (n,),y_true - y_pred 默默變成 (n, n),算出來的 MSE 完全不對,n 很大時還會吃光記憶體。
4. 不會真的複製:步長 0
ndarray 用步長(strides)記錄「沿著每一軸走一格,要在記憶體裡跳多少 bytes」。np.broadcast_to(mu, (4, 3)) 可以把廣播的虛擬陣列拿出來看:它的 strides 是 (0, 8),往右一格跳 8 bytes(一個 float64),往下一列跳 0 bytes,也就是一直讀同一列。這個陣列是唯讀的(flags.writeable 為 False),因為好幾個位置共用同一格記憶體,寫進去會互相影響。所以廣播本身幾乎不花額外記憶體;真正會配置新記憶體的是運算的結果,例如 X - mu 會產生一個 (4, 3) 的新陣列,意外的 (n, n) 也會真的佔滿 n × n 格。
還有一個相關的陷阱:就地運算 a += b 要把結果寫回 a,所以廣播後的 shape 必須等於 a 原本的 shape。a 是 (3,)、b 是 (3, 1) 時,a + b 可以算出 (3, 3),a += b 卻會丟出 non-broadcastable output operand with shape (3,) doesn't match the broadcast shape (3,3)。
5. 標準化背後在做什麼
標準化(standardization,又稱 z-score)把每一欄換算成「離平均幾個標準差」,讓量尺不同的特徵可以放在一起比較,KNN、SVM、K-means、PCA 與神經網路都需要它。(X − X.mean(axis=0)) / X.std(axis=0) 這一行其實有四個動作、兩次廣播:先沿 axis=0 壓出每欄平均 (3,),用廣播讓每一列都減掉它;再壓出每欄標準差 (3,),用廣播讓每一列都除以它。結果每一欄的平均是 0、標準差是 1。注意運算子的優先順序:少了括號寫成 X - X.mean(axis=0) / X.std(axis=0),會變成先除再減,shape 一樣是 (4, 3),數值卻完全不對。
標準差有兩種分母,這是最容易對不上答案的地方。母體標準差除以 n,樣本標準差除以 n − 1,函式用 ddof(delta degrees of freedom,自由度修正)參數控制:分母是 n − ddof。各工具的預設值不一樣,同一份資料算出來就不同:
| 工具 | 預設 ddof | 分母 | 國文欄(72、88、64、80)的標準差 | 備註 |
|---|---|---|---|---|
np.std、X.std() | 0 | n | 8.944 | 母體標準差;要樣本版寫 ddof=1 |
DataFrame.std、Series.std | 1 | n − 1 | 10.328 | pandas 預設樣本標準差;要母體版寫 ddof=0 |
StandardScaler | 0(固定) | n | 8.944 | 官方文件說明採用有偏估計,等同 numpy.std(x, ddof=0) |
statistics.pstdev/stdev | — | n/n − 1 | 8.944/10.328 | Python 標準函式庫,分成兩個函式 |
資料筆數很多時兩者差異很小,但程式題常故意用 4、5 筆小資料,答案就會差一截。另一個邊界情況是某一欄全部相同、標準差為 0:自己用 NumPy 除會得到 nan 並出現 RuntimeWarning: invalid value encountered in divide;StandardScaler 則會把這一欄的縮放係數設為 1,結果是全 0,不會產生 nan。實務上標準化的平均與標準差只能從訓練集計算,再套用到測試集,這屬於資料洩漏的議題,延伸閱讀有完整的逐行解說。
6. 每一列各自運算:keepdims 或 [:, None]
想讓每位學生減掉自己的平均,X.mean(axis=1) 是 (4,),補 1 會補在左邊變成 (1, 4),最右軸 3 對 4 對不上。解法是讓它變成直的 (4, 1):計算時加 keepdims=True,或事後寫 [:, None]、.reshape(-1, 1)。pandas 的規則又不一樣:DataFrame 和 Series 運算時是依標籤對齊,不是依位置廣播。df - df.mean() 依欄名對齊,剛好是每科減自己的平均;df - df.mean(axis=1) 卻會拿學生的列索引 0~3 去對欄名,對不到就全部變成 NaN,結果還多出 4 個欄位(實測 shape 從 (4, 3) 變成 (4, 7))。pandas 要每列各自運算,寫 df.sub(df.mean(axis=1), axis=0)。
X - X.mean(axis=1)[:, None]。7. 向量化取代 for 迴圈
不用廣播時,「每格減掉該欄平均」要寫兩層 for 迴圈,每一格都要經過一次 Python 直譯器:取值、查型別、相減、存回去。向量化的 X - mu 只讓 Python 下一次指令,真正一格一格算的是 NumPy 內部編譯過的 C 迴圈,速度常差到數十倍以上(依電腦與資料大小而定),程式也短得多、不容易寫錯索引。限制是:向量化會產生完整的中間陣列,資料非常大時要留意記憶體;前一步結果會影響下一步的計算(例如遞迴式的累加)不一定能直接改寫成一行。
8. 一張表與判斷步驟
常見的 shape 組合整理如下,每一列都用 np.broadcast_shapes 與實際相加驗證過。
| a.shape | b.shape | 靠右對齊後 | 結果 | 說明 |
|---|---|---|---|---|
(4, 3) | (3,) | (4, 3) 對 (1, 3) | (4, 3) | 每一列套用同一組數,例如減每欄平均 |
(4, 3) | (4,) | (4, 3) 對 (1, 4) | ValueError | 最右軸 3 對 4;要改成 (4, 1) |
(4, 3) | (4, 1) | (4, 3) 對 (4, 1) | (4, 3) | 每一列套用自己的值,例如減每列平均 |
(4, 3) | (1, 3) | (4, 3) 對 (1, 3) | (4, 3) | 和 (3,) 的效果相同 |
(4, 3) | () 純量 | (4, 3) 對 (1, 1) | (4, 3) | 純量可以和任何 shape 運算 |
(3, 1) | (4,) | (3, 1) 對 (1, 4) | (3, 4) | 兩邊都拉伸,結果比兩邊都大 |
(2, 3, 4) | (3, 1) | (2, 3, 4) 對 (1, 3, 1) | (2, 3, 4) | 三維也一樣從右邊比 |
(2, 1, 4) | (3, 4) | (2, 1, 4) 對 (1, 3, 4) | (2, 3, 4) | 兩邊各拉伸一軸 |
(2, 3) | (3, 2) | (2, 3) 對 (3, 2) | ValueError | 不會自動轉置 |
(8, 1, 6, 1) | (7, 1, 5) | (8, 1, 6, 1) 對 (1, 7, 1, 5) | (8, 7, 6, 5) | 官方文件的例子 |
- 寫出兩個 shape,靠右對齊,維度數少的一邊在左邊補 1。
- 從最右軸往左逐軸比對:相等保留、有一邊是 1 就拉伸、都不是就報錯。
- 結果的每一軸取兩邊較大的長度。
- 檢查合理性:結果是不是比兩個輸入都大?是就停下來確認;想讓「每一列」各自運算時,向量要先變成
(n, 1)。
9. 完整範例
第一段驗證廣播規則與步長 0;第二段是標準化與 ddof 的對照,順便看 pandas 的標籤對齊;第三段是 (n, 1) 與 (n,) 的陷阱、迴圈與向量化的對照,以及就地運算的限制。註解裡的輸出都是 NumPy 2.4、pandas 3.0、scikit-learn 1.8 實際執行的結果。
import numpy as np
X = np.array([[72., 85., 60.],
[88., 70., 95.],
[64., 90., 75.],
[80., 75., 90.]]) # 4 位學生 × 3 科(示意)
mu = X.mean(axis=0) # (3,):每科平均 [76. 80. 80.]
print((X - mu).shape) # (4, 3) 與 (3,):最右邊 3 對 3 → (4, 3)
print(np.broadcast_shapes((4, 3), (3,)), np.broadcast_shapes((3, 1), (4,))) # (4, 3) (3, 4)
try:
X - X.mean(axis=1) # (4, 3) 與 (4,):最右邊 3 對 4 → 不相容
except ValueError as e:
print(e) # operands could not be broadcast together with shapes (4,3) (4,)
row_c = X - X.mean(axis=1, keepdims=True) # (4, 1) 可以沿著欄的方向拉伸
print(row_c.shape, np.allclose(row_c, X - X.mean(axis=1)[:, None])) # (4, 3) True
v = np.broadcast_to(mu, (4, 3)) # 看看「虛擬複製」出來的陣列
print(v.strides, v.flags.writeable) # (0, 8) False:往下一列跳 0 bytes,沒有真的複製
import numpy as np, pandas as pd
from sklearn.preprocessing import StandardScaler
X = np.array([[72., 85., 60.],
[88., 70., 95.],
[64., 90., 75.],
[80., 75., 90.]])
Z = (X - X.mean(axis=0)) / X.std(axis=0) # 逐欄標準化:用了兩次廣播
print(Z[0].round(3)) # 第 1 位學生:[-0.447 0.632 -1.461]
print(Z.mean(axis=0).round(6), Z.std(axis=0)) # 每欄平均 0、標準差 1
print(X.std(axis=0).round(3)) # NumPy 預設 ddof=0:[ 8.944 7.906 13.693]
df = pd.DataFrame(X, columns=['國文', '英文', '數學'])
print(df.std().round(3).tolist()) # pandas 預設 ddof=1:[10.328, 9.129, 15.811]
print(np.allclose(StandardScaler().fit_transform(X), Z)) # True:StandardScaler 用 ddof=0
print((df - df.mean()).shape) # 依欄名對齊,每科減自己的平均 → (4, 3)
print(df.sub(df.mean(axis=1), axis=0).shape) # 每位學生減自己的平均要指定 axis=0 → (4, 3)
import numpy as np
y_true = np.array([[3.], [5.], [2.]]) # (3, 1):不小心多了一軸
y_pred = np.array([2.5, 5., 4.]) # (3,)
diff = y_true - y_pred # (3, 1) 與 (3,):兩邊都拉伸 → (3, 3)
print(diff.shape, np.mean(diff ** 2)) # (3, 3) 2.861…:不會報錯,但不是 MSE
print(np.mean((y_true.ravel() - y_pred) ** 2)) # 先攤平成 (3,):正確的 MSE ≈ 1.4167
X = np.random.default_rng(0).random((1000, 3))
out = np.empty_like(X)
for i in range(X.shape[0]): # 迴圈版:一格一格減
for j in range(X.shape[1]):
out[i, j] = X[i, j] - X[:, j].mean() # 每一格都重算一次平均
print(np.allclose(out, X - X.mean(axis=0))) # True:向量化一行得到同樣結果
a = np.zeros(3)
try:
a += np.ones((3, 1)) # 就地運算:結果 (3, 3) 放不回 (3,) 的 a
except ValueError as e:
print(e) # non-broadcastable output operand with shape (3,) ...
容易寫錯或考錯的地方
從右邊對齊:判斷 (4, 3) 與 (4,) 時,很多人看到「4 對 4」就以為可以。NumPy 先比的是最右軸 3 對 4,所以報錯;選項寫「會自動對到第 0 軸」的是陷阱。
(n, 1) 減 (n,):不報錯、結果變成 (n, n)。題目若問 shape,答案比兩個輸入都大;若問 MSE,數值會不對。
標準化的括號:X - X.mean(axis=0) / X.std(axis=0) 先除後減,shape 一樣但數值錯。干擾選項常只差一組括號。
ddof:NumPy 與 StandardScaler 是 ddof=0,pandas 是 ddof=1。用 pandas 算標準差再拿去和 StandardScaler 的結果比,會對不上。
pandas 不是位置廣播:DataFrame 減 Series 依標籤對齊,df - df.mean(axis=1) 會得到一片 NaN;每列運算用 df.sub(s, axis=0)。
廣播 vs 矩陣乘法:* 是逐元素相乘、會廣播;@ 是矩陣乘法,要求 (a, b) @ (b, c) 的內側維度相等,兩者的 shape 規則不能混用。
就地運算:a += b 的結果必須放得回 a 的 shape,否則報 non-broadcastable output operand。
✅ 自我檢測
6 題原創的程式閱讀題,每一題的輸出都用 python3 實際執行確認過。選完立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6
🎯 重點整理
- 廣播只用在逐元素運算:從最右軸開始比,相等或其中一邊是 1 才相容,否則 ValueError。
- 維度不足時在左邊補 1,所以 (3,) 一律被當成一列;要直行得寫 [:, None] 或 keepdims=True。
- 結果每一軸取較大者;兩邊都被拉伸時結果比輸入都大,(n, 1) 減 (n,) 會默默變成 (n, n)。
- 拉伸是虛擬的:步長 0、唯讀、不複製;真正佔記憶體的是運算結果。
- 標準化 (X − X.mean(axis=0)) / X.std(axis=0) 是兩次「彙總成 (3,),再廣播回 (4, 3)」。
- np.std 與 StandardScaler 用 ddof=0,pandas std 用 ddof=1;pandas 依標籤對齊,不是位置廣播。
- 向量化把迴圈交給 C,結果相同、程式更短、速度常快數十倍。