Danh sách bài viết

Bài 33: Broadcasting — phép tính giữa array khác shape

Broadcasting trong NumPy: quy tắc align shape từ phải qua trái, stretch dim=1, các ví dụ FAIL vs OK, keepdims=True, memory savings vs np.tile, pattern AI/ML như standardize, normalize embedding, pairwise distance, bias trong neural network.

24/05/2026
14 phút đọc
0 lượt xem
1

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 together và 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.
2

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.

3

Quy tắc broadcasting

Quy tắc chính thức (NumPy docs), áp dụng từ phải qua trái:

  1. Align 2 shape từ chiều rightmost (chiều cuối cùng).
  2. 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.
  3. 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).

4

Ví dụ FAIL vs OK

Áp quy tắc trên cho vài cặp shape quen gặp:

Shape AShape BKế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,)FAILAlign phải: 4 vs 3 — đều > 1, không khớp.
(2, 3)(4, 3)FAILDim 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)
5

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.

6

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.

7

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().

8

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 \).

9

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]]
10

Bài tập

Bài 1. Cho A.shape = (5, 3)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)b = np.array([100, 200, 300]).

Bài 2. Cho A.shape = (5, 3)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)(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 = 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.

11

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)(4, 3) sẽ FAIL.
  • keepdims=True giữ 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ấy np.tile trước phép cộng là dấu hiệu nên rewrite.
  • Pattern AI/ML: standardize (X - mean) / std, normalize embedding emb / norm, bias X @ W + b, pairwise distance X[:, 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 .shape khi nghi ngờ.
  • Bài này khép lại Module 4 (NumPy). Module kế tiếp chuyển sang Pandas — Series và DataFrame.