Mục lục
Mục tiêu bài học
- Hiểu broadcasting là phép tính element-wise giữa 2 array khác shape, không copy dữ liệu thật.
- Nắm quy tắc align shape từ phải qua trái và điều kiện stretch dim=1.
- Đọc được lỗi
ValueError: operands could not be broadcast togethervà biết cách fix. - Dùng
keepdims=Trueđể giữ shape khi reduce theo axis. - Áp dụng broadcasting cho standardize, normalize embedding, pairwise distance, bias.
Broadcasting là gì
NumPy cho phép thực hiện phép tính element-wise (+, -, *, /, các ufunc) giữa 2 array có shape khác nhau, miễn là shape "compatible". Cơ chế này gọi là broadcasting.
Điểm quan trọng: broadcasting KHÔNG copy dữ liệu thật ra một array lớn hơn — nó chỉ "stretch" về mặt logic. Buffer dữ liệu của array nhỏ được tái sử dụng bằng cách điều chỉnh strides (xem Bài 32 về stride). Nhờ vậy broadcasting nhanh và tốn ít RAM hơn việc tile thủ công.
import numpy as np
# Scalar + array — broadcast scalar lên shape (3,)
print(5 + np.array([1, 2, 3])) # [6 7 8]
# Vector + matrix — broadcast (3,) lên (2, 3)
v = np.array([10, 20, 30])
A = np.array([[1, 2, 3],
[4, 5, 6]])
print(A + v)
# [[11 22 33]
# [14 25 36]]
Ở ví dụ trên, v shape (3,) được "stretch" thành shape (2, 3) bằng cách lặp logic theo axis 0. Không có array (2, 3) mới nào được cấp phát.
Quy tắc broadcasting
Quy tắc chính thức (NumPy docs), áp dụng từ phải qua trái:
- Align 2 shape từ chiều rightmost (chiều cuối cùng).
- Với mỗi cặp dim đã align:
- Nếu 2 dim bằng nhau → OK.
- Nếu 1 trong 2 dim bằng 1 → stretch dim=1 lên size còn lại.
- Nếu 1 dim missing (shape ngắn hơn) → coi như size 1, rồi stretch.
- Nếu có cặp dim nào không thoả → raise
ValueError.
Output shape của phép broadcast = lấy max của từng cặp dim sau khi align.
Ví dụ minh hoạ quy tắc với shape \((3, 4)\) và \((4,)\):
(3, 4)
(4,) ← align từ phải
↓
(3, 4)
(1, 4) ← missing dim coi như 1
↓
(3, 4)
(3, 4) ← stretch dim=1 lên 3
Kết quả: shape output là (3, 4).
Ví dụ FAIL vs OK
Áp quy tắc trên cho vài cặp shape quen gặp:
| Shape A | Shape B | Kết quả | Giải thích |
|---|---|---|---|
() (scalar) | (3,) | OK → (3,) | Scalar stretch lên mọi shape. |
(3, 4) | (4,) | OK → (3, 4) | Align phải, (4,) → (1, 4) → (3, 4). |
(3, 4) | (3, 1) | OK → (3, 4) | Dim cuối: 4 vs 1 → stretch. |
(3, 4) | (3,) | FAIL | Align phải: 4 vs 3 — đều > 1, không khớp. |
(2, 3) | (4, 3) | FAIL | Dim 0: 2 vs 4 — đều > 1. |
(3, 1) | (1, 4) | OK → (3, 4) | Cả 2 cùng stretch dim=1. |
(5, 1, 4) | (3, 4) | OK → (5, 3, 4) | Pad (3, 4) thành (1, 3, 4) rồi stretch. |
Verify bằng NumPy:
A = np.zeros((3, 4))
A + np.zeros((4,)) # OK → shape (3, 4)
A + np.zeros((3, 1)) # OK → shape (3, 4)
A + np.zeros((3,)) # ValueError: shapes (3,4) (3,) not aligned
Cách fix case FAIL cuối: reshape (3,) thành (3, 1):
b = np.array([10, 20, 30]) # shape (3,)
A + b[:, None] # b[:, None] shape (3, 1) → OK
# hoặc: A + b.reshape(3, 1)
keepdims=True — chìa khoá broadcast theo axis
Các hàm reduce như .mean(), .sum(), .std(), .max() khi gọi với axis= sẽ thu lại 1 chiều — output ít hơn 1 dim. Nếu muốn dùng kết quả để broadcast ngược lại array gốc, hãy bật keepdims=True để giữ chiều đã thu thành size 1.
X = np.array([[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]]) # shape (2, 3)
X.mean(axis=1).shape # (2,) — đã thu axis 1
X.mean(axis=1, keepdims=True).shape # (2, 1) — giữ axis 1 size 1
# Subtract row mean
X - X.mean(axis=1, keepdims=True)
# [[-1. 0. 1.]
# [-1. 0. 1.]]
Không có keepdims=True thì X - X.mean(axis=1) sẽ raise ValueError hoặc tệ hơn — chạy nhưng broadcast nhầm axis. Cụ thể X shape (2, 3) trừ (2,): align phải cho 3 với 2 → không khớp, fail.
Pattern standardize (mean 0, std 1) theo từng cột:
mu = X.mean(axis=0) # shape (3,)
sig = X.std(axis=0) # shape (3,)
X_std = (X - mu) / sig # OK: (2,3) op (3,) → (2,3)
Ở đây (3,) tự align với cột (axis 1) nên KHÔNG cần keepdims. Quy tắc: reduce theo axis nào thì axis đó cần keepdims=True nếu nó KHÔNG phải axis rightmost.
Memory: broadcasting vs np.tile
Broadcasting không cấp phát array trung gian. Cùng kết quả, viết bằng np.tile sẽ tốn RAM thật.
v = np.array([10, 20, 30]) # shape (3,), 24 bytes
A = np.zeros((1000, 3)) # shape (1000, 3)
# Cách 1: broadcasting — không cấp phát thêm cho v
B1 = A + v # v "stretch" logic
# Cách 2: tile thủ công — cấp phát array (1000, 3) cho v_tiled
v_tiled = np.tile(v, (1000, 1)) # shape (1000, 3), ~24000 bytes
B2 = A + v_tiled
np.array_equal(B1, B2) # True — kết quả như nhau
Với shape lớn, khác biệt RAM là hàng trăm MB. Quy tắc thực hành: nếu thấy mình viết np.tile để khớp shape cho phép cộng, dừng lại, kiểm tra xem broadcasting có làm được không.
Pitfall — broadcast ngoài ý muốn
Broadcasting chỉ stretch dim=1; KHÔNG stretch dim khác. Cặp (2, 3) với (4, 3) sẽ FAIL — không có dim=1 nào để stretch ở axis 0.
Pitfall hay gặp: hai vector cùng dài, một được vô tình promote thành column, một thành row → ra outer-product-style thay vì element-wise.
u = np.array([1, 2, 3]) # shape (3,)
v = np.array([10, 20, 30]) # shape (3,)
# Ý định: element-wise sum, kỳ vọng shape (3,)
u + v # [11 22 33] — đúng
# Vô tình: u column, v row
u[:, None] + v[None, :]
# [[11 21 31]
# [12 22 32]
# [13 23 33]]
# → shape (3, 3): outer-sum, KHÔNG phải element-wise
Lỗi tương tự với np.dot vs outer product: np.dot(u, v) trả scalar 140, trong khi u[:, None] * v[None, :] trả ma trận (3, 3). Khi viết code, luôn print .shape ở các bước trung gian nếu nghi ngờ.
Một pitfall khác: NaN broadcast. Nếu trong array có NaN, mọi phép broadcast với NaN đều ra NaN — kiểm tra bằng np.isnan(result).any().
Pattern AI/ML
1. Standardize features (z-score). Mỗi cột trừ mean, chia std.
X = np.random.randn(100, 5) # (n_samples=100, n_features=5)
X_std = (X - X.mean(axis=0)) / X.std(axis=0)
# (100,5) - (5,) → (100,5); rồi / (5,) → (100,5)
2. Normalize embedding theo L2 norm. Mỗi vector chia chuẩn của chính nó để có độ dài 1.
emb = np.random.randn(64, 768) # 64 vector chiều 768
norms = np.linalg.norm(emb, axis=1, keepdims=True) # (64, 1)
emb_norm = emb / norms # (64, 768)
# Verify: mỗi hàng có norm = 1
np.linalg.norm(emb_norm, axis=1) # ~ [1. 1. ... 1.]
keepdims=True bắt buộc ở đây vì cần shape (64, 1) để broadcast với (64, 768) theo axis 1.
3. Bias trong neural network layer. Linear layer: \( Y = XW + b \) với \( X \) shape (batch, in_dim), \( W \) shape (in_dim, out_dim), \( b \) shape (out_dim,).
batch, in_dim, out_dim = 32, 128, 10
X = np.random.randn(batch, in_dim) # (32, 128)
W = np.random.randn(in_dim, out_dim) # (128, 10)
b = np.random.randn(out_dim) # (10,)
Y = X @ W + b # (32,10) + (10,) → (32,10)
Bias b shape (10,) được broadcast lên (32, 10): cùng vector cộng vào mỗi sample trong batch.
4. Pairwise distance giữa 2 set of points. \( X \) shape (N, D), \( Y \) shape (M, D), muốn ma trận \( D \) shape (N, M) với \( D_{ij} = \lVert X_i - Y_j \rVert \).
X = np.random.randn(50, 3) # 50 điểm 3D
Y = np.random.randn(30, 3) # 30 điểm 3D
# diff[i, j, k] = X[i, k] - Y[j, k]
diff = X[:, None, :] - Y[None, :, :]
# (50, 1, 3) - (1, 30, 3) → (50, 30, 3)
dist = np.sqrt((diff ** 2).sum(axis=2)) # (50, 30)
Đây là kỹ thuật tiêu chuẩn cho k-NN, k-means, attention QK dot-product. Lưu ý array trung gian diff shape (N, M, D) — nếu \(N, M\) lớn (vd \(10^4\)) thì có thể bùng RAM, khi đó cần block hoặc dùng công thức khai triển \( \lVert x - y \rVert^2 = \lVert x \rVert^2 + \lVert y \rVert^2 - 2 x^\top y \).
Code Python tổng hợp
import numpy as np
# ----- 1. Subtract row mean: ma trận điểm thi (3 sinh viên × 4 môn) -----
scores = np.array([[ 8, 7, 9, 6],
[ 5, 6, 4, 5],
[10, 9, 10, 9]], dtype=float)
row_mean = scores.mean(axis=1, keepdims=True) # (3, 1)
deviation = scores - row_mean # (3, 4)
print(deviation)
# [[ 0.5 -0.5 1.5 -1.5]
# [ 0. 1. -1. 0. ]
# [ 0.5 -0.5 0.5 -0.5]]
# ----- 2. Normalize 1 batch embedding theo L2 norm -----
emb = np.array([[3.0, 4.0],
[1.0, 0.0],
[0.0, 5.0]]) # (3, 2)
norms = np.linalg.norm(emb, axis=1, keepdims=True) # (3, 1)
emb_norm = emb / norms
print(emb_norm)
# [[0.6 0.8]
# [1. 0. ]
# [0. 1. ]]
print(np.linalg.norm(emb_norm, axis=1)) # [1. 1. 1.]
# ----- 3. Pairwise Euclidean distance bằng broadcasting -----
X = np.array([[0.0, 0.0],
[1.0, 0.0],
[0.0, 1.0]]) # (3, 2)
Y = np.array([[0.0, 0.0],
[2.0, 2.0]]) # (2, 2)
diff = X[:, None, :] - Y[None, :, :] # (3, 2, 2)
dist = np.sqrt((diff ** 2).sum(axis=2)) # (3, 2)
print(dist)
# [[0. 2.82842712]
# [1. 2.23606798]
# [1. 2.23606798]]
Bài tập
Bài 1. Cho A.shape = (5, 3) và b.shape = (3,). Phép A + b có chạy không? Nếu có, output shape là gì? Verify bằng code với A = np.arange(15).reshape(5, 3) và b = np.array([100, 200, 300]).
Bài 2. Cho A.shape = (5, 3) và b.shape = (5,). Phép A + b có chạy không? Nếu không, viết ra lỗi và cách fix sao cho mỗi giá trị b[i] được cộng vào hàng i của A. Gợi ý: dùng b[:, None] hoặc b.reshape(5, 1).
Bài 3. Cho X = np.arange(12).reshape(4, 3).astype(float) (4 sinh viên × 3 môn). Standardize theo từng cột (mỗi cột mean 0, std 1) chỉ dùng 1 dòng broadcasting. Verify bằng X_std.mean(axis=0) ~ 0 và X_std.std(axis=0) ~ 1.
Bài 4. Implement pairwise Euclidean distance giữa 2 ma trận shape (N, D) và (M, D), output shape (N, M). Test với N=4, M=3, D=5. Cross-check với scipy.spatial.distance.cdist nếu có (kết quả trùng đến mức epsilon).
Bài 5 (pitfall). Cho u = np.arange(4) và v = np.arange(4). Tính 3 thứ: (a) u + v, (b) u[:, None] + v[None, :], (c) u[None, :] + v[:, None]. Shape và giá trị của (b), (c) khác nhau thế nào? Khi nào ta thực sự cần dạng outer-sum này?
Bài 6. Cho emb.shape = (1000, 256) là embedding của 1000 câu. Tính cosine similarity ma trận (1000, 1000) bằng broadcasting: trước hết normalize từng hàng về L2 norm = 1, sau đó emb_norm @ emb_norm.T. Giải thích vì sao bước normalize biến dot product thành cosine.
Tóm tắt
- Broadcasting là phép tính element-wise giữa array khác shape, không copy dữ liệu thật.
- Align shape từ phải qua trái; mỗi cặp dim phải bằng nhau, hoặc 1 trong 2 bằng 1 (kể cả missing dim coi như 1).
- Chỉ dim=1 mới được stretch;
(2, 3)và(4, 3)sẽ FAIL. keepdims=Truegiữ axis đã reduce thành size 1 — bắt buộc khi axis đó không phải rightmost.- Broadcasting tiết kiệm RAM so với
np.tile; thấynp.tiletrước phép cộng là dấu hiệu nên rewrite. - Pattern AI/ML: standardize
(X - mean) / std, normalize embeddingemb / norm, biasX @ W + b, pairwise distanceX[:, None, :] - Y[None, :, :]. - Pitfall: vô tình promote 1 vector thành column và 1 thành row → outer-sum/outer-product thay vì element-wise. Luôn print
.shapekhi nghi ngờ. - Bài này khép lại Module 4 (NumPy). Module kế tiếp chuyển sang Pandas — Series và DataFrame.
