⭐ Vision Engineer · Chặng 10Bài 2/12Bài học~75 phút

Gradient descent từ đầu với NumPy: loss, gradient và learning rate

Tự đạo hàm MSE, cài gradient descent bằng NumPy cho bài hiệu chỉnh pixel–mm, thấy learning rate nhỏ, vừa, phân kỳ bằng số và vì sao phải chuẩn hóa.

Gradient descent từ đầu với NumPy: loss, gradient và learning rate

Học xong bài này, bạn sẽ

  • Hiểu hàm mất mát và gradient
  • Tự cài gradient descent
  • Ảnh hưởng của learning rate

Nên học trước: Machine Learning là gì?

Nội dung bài viết
  1. 1. Bài toán: hiệu chỉnh pixel sang milimét
  2. 2. Hàm mất mát MSE
  3. 3. Gradient: đạo hàm của loss theo từng tham số
  4. 4. Gradient descent bằng NumPy
  5. 5. Learning rate: quá nhỏ, vừa phải, quá lớn
  6. 6. Chuẩn hóa đặc trưng: vì sao bắt buộc
  7. 7. So sánh với nghiệm bình phương tối thiểu
  8. 8. Từ gradient descent đến mini-batch SGD
  9. 9. Sai lầm thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Ở bài "Machine Learning là gì?", bạn gọi fit() và mô hình "tự học". Bài này mở hộp đen đó ra. Mọi mạng nơ-ron hiện đại, từ CNN phân loại OK/NG đến YOLO, đều học theo cùng một nguyên lý: định nghĩa một hàm mất mát (loss) đo mô hình sai bao nhiêu, tính gradient cho biết nên chỉnh tham số theo hướng nào, rồi đi từng bước nhỏ theo hướng ngược gradient. Cách tốt nhất để hiểu nguyên lý này là tự cài nó bằng NumPy cho bài toán đơn giản nhất mà kỹ sư vision gặp hằng ngày: hiệu chỉnh tuyến tính từ pixel sang milimét. Bạn sẽ tự đạo hàm, tự viết vòng lặp, thấy tận mắt learning rate quá nhỏ, vừa phải và quá lớn (phân kỳ) bằng con số, hiểu vì sao phải chuẩn hóa dữ liệu, và kiểm tra kết quả với nghiệm bình phương tối thiểu. Bài sau, "Mạng nơ-ron và CNN hoạt động thế nào", dùng đúng vòng lặp này cho mô hình có hàng nghìn tham số.

Mục tiêu cuối bàiViết được hàm mất mát MSE và tự đạo hàm gradient theo w, b; kiểm tra gradient bằng sai phân hữu hạn; cài gradient descent bằng NumPy; giải thích bằng số ảnh hưởng của learning rate (0,001; 0,01; 0,5; 1,1) và vẽ đường loss; hiểu vì sao chuẩn hóa đặc trưng quyết định learning rate dùng được; so sánh với nghiệm đóng (least squares) và với mini-batch SGD. Chỉ cần NumPy và OpenCV.

1. Bài toán: hiệu chỉnh pixel sang milimét

Trạm đo chiều dài chi tiết bằng camera (chặng 7). Bạn đặt 40 mẫu chuẩn có chiều dài đã biết (đo bằng máy CMM hoặc thước cặp) dưới camera, đo chiều dài mỗi mẫu bằng pixel. Ta tìm quan hệ tuyến tính

y_mm ≈ w · x_px + b

trong đó w là kích thước một pixel (mm/px) và b là độ lệch cố định (do ngưỡng cạnh, viền chi tiết). Dữ liệu mô phỏng: kích thước pixel thật 0,0482 mm/px, lệch 0,35 mm, nhiễu đo ±0,03 mm.

import numpy as np

rng = np.random.default_rng(0)
x = np.sort(rng.uniform(150, 750, 40))                 # chiều dài đo bằng camera (px)
y = 0.0482 * x + 0.35 + rng.normal(0, 0.03, 40)        # chiều dài thật (mm) từ CMM
np.savez("hieu_chinh.npz", x=x, y=y)                   # các đoạn sau đọc lại file này
print("5 mẫu đầu (px):", np.round(x[:5], 1))
print("5 mẫu đầu (mm):", np.round(y[:5], 3))
print(f"x: trung bình {x.mean():.1f} px, độ lệch chuẩn {x.std():.1f} px | y trung bình {y.mean():.2f} mm")
5 mẫu đầu (px): [151.6 159.9 167.  170.2 174.6]
5 mẫu đầu (mm): [7.621 8.103 8.439 8.575 8.773]
x: trung bình 472.0 px, độ lệch chuẩn 180.5 px | y trung bình 23.11 mm

Bài toán này có lời giải trực tiếp (mục 7), nhưng ta cố ý giải bằng gradient descent: đây là phương pháp duy nhất còn dùng được khi mô hình có hàng triệu tham số và không có công thức nghiệm.

2. Hàm mất mát MSE

Với một cặp (w, b), mô hình dự đoán ŷ = w·x + b. Sai số của mẫu thứ i là e_i = ŷ_i − y_i. Hàm mất mát MSE (mean squared error, trung bình bình phương sai số):

L(w, b) = (1/n) · Σ (w·x_i + b − y_i)²

Vì sao bình phương? Sai âm và sai dương không triệt tiêu nhau; sai lớn bị phạt nặng hơn sai nhỏ; và hàm trơn, đạo hàm được ở mọi điểm. Căn bậc hai của MSE (RMSE) có cùng đơn vị với y (mm), dễ đọc hơn. Thử vài cặp tham số đoán tay:

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]


def mse(w, b):
    return np.mean((w * x + b - y) ** 2)


for w, b in [(0.0, 0.0), (0.05, 0.0), (0.048, 0.0), (0.048, 0.35), (0.0482, 0.35)]:
    print(f"w = {w:.4f} mm/px, b = {b:.2f} mm → MSE = {mse(w, b):10.5f} mm², RMSE = {np.sqrt(mse(w, b)):.4f} mm")
w = 0.0000 mm/px, b = 0.00 mm → MSE =  609.69594 mm², RMSE = 24.6920 mm
w = 0.0500 mm/px, b = 0.00 mm → MSE =    0.34968 mm², RMSE = 0.5913 mm
w = 0.0480 mm/px, b = 0.00 mm → MSE =    0.20770 mm², RMSE = 0.4557 mm
w = 0.0480 mm/px, b = 0.35 mm → MSE =    0.01279 mm², RMSE = 0.1131 mm
w = 0.0482 mm/px, b = 0.35 mm → MSE =    0.00114 mm², RMSE = 0.0337 mm

Loss càng nhỏ, đường thẳng càng khớp dữ liệu. Với tham số đúng (0,0482; 0,35), RMSE xấp xỉ mức nhiễu đo 0,03 mm: không mô hình tuyến tính nào làm tốt hơn đáng kể. "Huấn luyện" chính là tìm (w, b) làm L nhỏ nhất, một cách tự động.

3. Gradient: đạo hàm của loss theo từng tham số

Gradient là vector các đạo hàm riêng (∂L/∂w, ∂L/∂b). Nó chỉ hướng làm loss tăng nhanh nhất; đi ngược hướng đó thì loss giảm. Đạo hàm bằng quy tắc dây chuyền: với e_i = w·x_i + b − y_i, ta có ∂(e_i²)/∂w = 2·e_i·x_i và ∂(e_i²)/∂b = 2·e_i. Lấy trung bình:

∂L/∂w = (2/n) · Σ e_i · x_i  = 2 · mean(e · x)
∂L/∂b = (2/n) · Σ e_i        = 2 · mean(e)

Đọc công thức: nếu mô hình dự đoán dư (e > 0) ở các mẫu có x lớn, ∂L/∂w dương, nên phải giảm w. Công thức tự viết rất dễ sai dấu hoặc thiếu hệ số 2, nên luôn kiểm tra bằng sai phân hữu hạn: tăng tham số một lượng rất nhỏ h và xem loss đổi bao nhiêu.

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]


def mse(w, b):
    return np.mean((w * x + b - y) ** 2)


def gradient(w, b):
    e = w * x + b - y
    return 2 * np.mean(e * x), 2 * np.mean(e)


w, b, h = 0.03, 0.1, 1e-6
gw, gb = gradient(w, b)
gw_so = (mse(w + h, b) - mse(w - h, b)) / (2 * h)      # sai phân trung tâm
gb_so = (mse(w, b + h) - mse(w, b - h)) / (2 * h)
print(f"∂L/∂w: công thức {gw:.4f} | sai phân {gw_so:.4f}")
print(f"∂L/∂b: công thức {gb:.5f} | sai phân {gb_so:.5f}")
∂L/∂w: công thức -9538.7913 | sai phân -9538.7913
∂L/∂b: công thức -17.69920 | sai phân -17.69920

Hai cách khớp nhau, công thức đúng. Chú ý độ lớn: ∂L/∂w lớn gấp hàng trăm lần ∂L/∂b, vì nó được nhân với x (vài trăm pixel). Chi tiết này sẽ gây rắc rối ở mục 6.

Autograd làm thay việc nàyỞ bài "PyTorch cơ bản: tensor, autograd, vòng lặp huấn luyện", PyTorch tự tính gradient cho mọi phép toán bạn viết (backpropagation). Nhưng kỹ thuật kiểm tra bằng sai phân vẫn hữu ích khi bạn viết hàm loss tùy biến.

4. Gradient descent bằng NumPy

Thuật toán chỉ có một dòng cốt lõi, lặp lại nhiều lần:

tham_số ← tham_số − learning_rate · gradient

Learning rate (tốc độ học, ký hiệu lr hay η) quyết định mỗi bước đi xa bao nhiêu. Trước khi chạy, ta chuẩn hóa x về trung bình 0, độ lệch chuẩn 1 (lý do ở mục 6), rồi đổi tham số về đơn vị gốc sau khi học xong:

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]
mu, sigma = x.mean(), x.std()
xs = (x - mu) / sigma                       # x chuẩn hóa: trung bình 0, độ lệch chuẩn 1

w, b, lr = 0.0, 0.0, 0.1
for buoc in range(101):
    e = w * xs + b - y                      # 1. dự đoán và sai số
    loss = np.mean(e ** 2)                  # 2. loss
    dw, db = 2 * np.mean(e * xs), 2 * np.mean(e)   # 3. gradient
    if buoc % 20 == 0:
        print(f"bước {buoc:3d}: loss {loss:12.6f} | w {w:7.4f} | b {b:7.4f}")
    w -= lr * dw                            # 4. cập nhật ngược hướng gradient
    b -= lr * db

w_goc = w / sigma                           # y = w·(x − mu)/sigma + b = (w/sigma)·x + (b − w·mu/sigma)
b_goc = b - w * mu / sigma
print(f"kết quả: y ≈ {w_goc:.5f}·x + {b_goc:.4f}  (giá trị thật 0.0482·x + 0.35)")
print(f"kích thước pixel ≈ {w_goc * 1000:.2f} µm")
bước   0: loss   609.695943 | w  0.0000 | b  0.0000
bước  20: loss     0.082084 | w  8.5962 | b 22.8434
bước  40: loss     0.001052 | w  8.6953 | b 23.1068
bước  60: loss     0.001041 | w  8.6965 | b 23.1098
bước  80: loss     0.001041 | w  8.6965 | b 23.1099
bước 100: loss     0.001041 | w  8.6965 | b 23.1099
kết quả: y ≈ 0.04818·x + 0.3686  (giá trị thật 0.0482·x + 0.35)
kích thước pixel ≈ 48.18 µm

Loss giảm từ hàng trăm xuống khoảng 0,001 mm² (RMSE ≈ 0,03 mm, đúng mức nhiễu) và dừng ở đó: mô hình đã hội tụ. Tham số tìm được khác giá trị thật một chút vì 40 mẫu có nhiễu; đó cũng là kết quả tốt nhất mà dữ liệu cho phép (mục 7 sẽ xác nhận). Đây là toàn bộ "bí mật" của việc huấn luyện. Bốn bước trong vòng lặp (dự đoán, tính loss, tính gradient, cập nhật) chính là bốn bước bạn sẽ viết lại bằng PyTorch.

5. Learning rate: quá nhỏ, vừa phải, quá lớn

Chạy cùng thuật toán với nhiều learning rate, ghi lại loss sau mỗi bước:

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]
xs = (x - x.mean()) / x.std()


def chay_gd(lr, so_buoc=200):
    w, b, lich_su = 0.0, 0.0, []
    for _ in range(so_buoc + 1):
        e = w * xs + b - y
        lich_su.append(np.mean(e ** 2))
        w, b = w - lr * 2 * np.mean(e * xs), b - lr * 2 * np.mean(e)
    return np.array(lich_su)


cac_lr = [0.001, 0.01, 0.1, 0.5, 1.0, 1.1]
duong_loss = {lr: chay_gd(lr) for lr in cac_lr}
np.savez("duong_loss.npz", **{str(lr): v for lr, v in duong_loss.items()})
cot = (0, 1, 10, 50, 200)
print("lr     | " + "".join(f"bước {i}".ljust(10) for i in cot).rstrip())
for lr, L in duong_loss.items():
    print(f"{lr:<6} | " + "".join(f"{L[i]:.3g}".ljust(10) for i in cot).rstrip())
lr     | bước 0    bước 1    bước 10   bước 50   bước 200
0.001  | 610       607       586       499       274
0.01   | 610       586       407       80.9      0.19
0.1    | 610       390       7.03      0.00104   0.00104
0.5    | 610       0.00104   0.00104   0.00104   0.00104
1.0    | 610       610       610       610       610
1.1    | 610       878       2.34e+04  5.05e+10  2.87e+34
lrHiện tượngÝ nghĩa
0,001Loss giảm rất chậm, sau 200 bước vẫn còn rất xa đáyQuá nhỏ: tốn thời gian huấn luyện, dễ tưởng nhầm là mô hình "không học được"
0,01Giảm đều nhưng 200 bước chưa tới đáyHơi nhỏ: cần nhiều bước hơn
0,1Tới đáy sau khoảng 50 bướcTốt
0,5Tới đáy ngay sau 1 bướcTối ưu cho bài này (giải thích bên dưới)
1,0Loss đứng yên ở giá trị ban đầuTham số nhảy qua lại hai bên đáy mãi mãi
1,1Loss tăng như hàm mũPhân kỳ (diverge): mỗi bước vượt quá đáy xa hơn bước trước

Vì sao có những con số "đặc biệt" 0,5 và 1? Với x đã chuẩn hóa (trung bình 0, phương sai 1), hai tham số không ảnh hưởng nhau và mỗi bước cập nhật tương đương với

(w − w*) ← (1 − 2·lr) · (w − w*)       (tương tự với b)

trong đó w* là giá trị tối ưu. Khoảng cách tới đáy được nhân với hệ số 1 − 2·lr sau mỗi bước: lr = 0,001 cho hệ số 0,998 (sau 200 bước còn 0,998200 ≈ 0,67 khoảng cách ban đầu), lr = 0,1 cho 0,8, lr = 0,5 cho 0 (tới đáy ngay), lr = 1 cho −1 (dao động mãi), lr = 1,1 cho −1,2 (mỗi bước xa thêm 20%, loss tăng 1,44 lần). Điều kiện hội tụ là |1 − 2·lr| < 1, tức 0 < lr < 1. Với mạng nơ-ron thật không có công thức gọn như vậy, nhưng hiện tượng giống hệt: có một ngưỡng learning rate mà vượt qua thì huấn luyện nổ tung, loss thành nan.

Vẽ đường loss theo thang log là cách nhanh nhất để "đọc" một lần huấn luyện. Ta vẽ bằng OpenCV (không cần thư viện vẽ đồ thị):

import cv2
import numpy as np

d = np.load("duong_loss.npz")
W, H, LE = 640, 400, 60                          # kích thước ảnh, lề
y_min, y_max = -4, 6                             # trục tung: log10(loss) từ 1e-4 đến 1e6
anh = np.full((H, W, 3), 255, np.uint8)


def diem(buoc, loss):
    ly = np.clip(np.log10(max(loss, 1e-12)), y_min, y_max)
    px = LE + buoc / 200 * (W - LE - 20)
    py = H - LE - (ly - y_min) / (y_max - y_min) * (H - LE - 20)
    return int(round(px)), int(round(py))


for k in range(y_min, y_max + 1, 2):             # lưới ngang và nhãn trục tung
    _, py = diem(0, 10.0 ** k)
    cv2.line(anh, (LE, py), (W - 20, py), (225, 225, 225), 1)
    cv2.putText(anh, f"1e{k}", (5, py + 5), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (80, 80, 80), 1)
cv2.putText(anh, "buoc (0 - 200)", (W // 2 - 50, H - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 80), 1)

mau = {"0.001": (200, 120, 0), "0.01": (0, 160, 0), "0.5": (0, 0, 220), "1.1": (160, 0, 160)}
for i, (ten, c) in enumerate(mau.items()):
    L = d[ten]
    pts = np.array([diem(k, L[k]) for k in range(len(L))], np.int32)
    cv2.polylines(anh, [pts], False, c, 2, cv2.LINE_AA)
    cv2.putText(anh, f"lr = {ten}", (W - 150, 40 + 22 * i), cv2.FONT_HERSHEY_SIMPLEX, 0.55, c, 2)
cv2.imwrite("duong_loss.png", anh)
print("đã lưu duong_loss.png", anh.shape)
đã lưu duong_loss.png (400, 640, 3)

Trên hình: đường lr = 0,001 gần như nằm ngang ở phía trên; lr = 0,01 dốc đều xuống; lr = 0,5 rơi thẳng xuống đáy ở bước 1 rồi nằm ngang; lr = 1,1 vọt lên khỏi khung hình (bị cắt ở 1e6). Nếu bạn đã cài matplotlib, đoạn tương đương ngắn hơn:

# (minh họa) cần: pip install matplotlib
import matplotlib.pyplot as plt
import numpy as np

d = np.load("duong_loss.npz")
for ten in ["0.001", "0.01", "0.5", "1.1"]:
    plt.semilogy(d[ten], label=f"lr = {ten}")
plt.ylim(1e-4, 1e6)
plt.xlabel("bước")
plt.ylabel("loss (MSE, mm²)")
plt.legend()
plt.savefig("duong_loss_mpl.png", dpi=120)

6. Chuẩn hóa đặc trưng: vì sao bắt buộc

Thử bỏ bước chuẩn hóa, chạy thẳng trên x tính bằng pixel với learning rate "nhỏ" 0,001:

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]


def gd_tren_x_goc(lr, so_buoc):
    w, b = 0.0, 0.0
    for buoc in range(so_buoc + 1):
        e = w * x + b - y
        loss = np.mean(e ** 2)
        if loss > 1e12:
            return f"phân kỳ ở bước {buoc} (loss {loss:.2e})"
        w, b = w - lr * 2 * np.mean(e * x), b - lr * 2 * np.mean(e)
    return f"sau {so_buoc} bước: loss {loss:.4f}, w {w:.5f}, b {b:.4f}"


print("lr = 0.001:", gd_tren_x_goc(0.001, 1000))
print("lr = 1e-6 :", gd_tren_x_goc(1e-6, 1000))
print("lr = 1e-6 :", gd_tren_x_goc(1e-6, 100000))

for ten, dac_trung in [("x gốc (px)", x), ("x chuẩn hóa", (x - x.mean()) / x.std())]:
    H = 2 * np.array([[np.mean(dac_trung ** 2), dac_trung.mean()], [dac_trung.mean(), 1.0]])
    lam = np.linalg.eigvalsh(H)
    print(f"{ten:12s}: độ cong lớn nhất {lam.max():.4g}, nhỏ nhất {lam.min():.4g}, "
          f"lr tối đa {2 / lam.max():.3g}, tỉ số cong {lam.max() / lam.min():.3g}")
lr = 0.001: phân kỳ ở bước 2 (loss 4.12e+13)
lr = 1e-6 : sau 1000 bước: loss 0.0184, w 0.04886, b 0.0002
lr = 1e-6 : sau 100000 bước: loss 0.0175, w 0.04884, b 0.0094
x gốc (px)  : độ cong lớn nhất 5.107e+05, nhỏ nhất 0.2552, lr tối đa 3.92e-06, tỉ số cong 2e+06
x chuẩn hóa : độ cong lớn nhất 2, nhỏ nhất 2, lr tối đa 1, tỉ số cong 1

Learning rate 0,001, "quá nhỏ" với dữ liệu chuẩn hóa, lại làm thuật toán phân kỳ trên dữ liệu gốc. Giảm xuống 10−6 thì không nổ nữa, nhưng sau 100.000 bước b mới nhích từ 0 lên 0,0094, còn rất xa 0,37; loss 0,0175 vẫn lớn gấp 17 lần loss tối ưu 0,00104 (mục 7). w phải "gánh" luôn phần của b nên lệch sang 0,0488. Nguyên nhân nằm ở độ cong của mặt loss theo từng hướng (các trị riêng của ma trận đạo hàm bậc hai):

  • Theo hướng w, loss cong rất gắt vì x cỡ vài trăm: learning rate phải nhỏ hơn 2/độ cong lớn nhất, khoảng 4·10−6, nếu không sẽ vượt đáy và phân kỳ.
  • Theo hướng còn lại, loss gần như phẳng: với learning rate nhỏ như vậy, mỗi bước chỉ nhích một chút xíu.
  • Tỉ số hai độ cong cỡ 2 triệu: không có learning rate nào vừa đủ lớn cho hướng phẳng vừa đủ nhỏ cho hướng dốc. Mặt loss là một "khe hẹp" dài.

Sau chuẩn hóa, hai độ cong bằng nhau (bằng 2), mặt loss tròn đều, và một learning rate trong khoảng (0, 1) đi thẳng về đáy. Đây là lý do mọi đường ống Deep Learning đều chuẩn hóa đầu vào: chia ảnh cho 255 và trừ mean, chia std (bài "Dataset, DataLoader và augmentation" sẽ làm việc này cho ảnh).

Tham số chuẩn hóa là một phần của mô hìnhmu và sigma tính trên dữ liệu huấn luyện phải được lưu lại cùng w, b và dùng y hệt khi chạy trên trạm. Tính lại mu, sigma trên dữ liệu mới, hoặc quên chuẩn hóa ở phía trạm, cho kết quả sai mà không báo lỗi.

7. So sánh với nghiệm bình phương tối thiểu

Hồi quy tuyến tính với MSE có nghiệm đóng (closed-form): giải hệ phương trình chuẩn (AᵀA)·θ = Aᵀy với A = [x, 1]. NumPy làm việc này ổn định bằng np.linalg.lstsq (hoặc np.polyfit bậc 1):

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]

A = np.column_stack([x, np.ones_like(x)])
(w_ls, b_ls), *_ = np.linalg.lstsq(A, y, rcond=None)
w_pf, b_pf = np.polyfit(x, y, 1)

xs = (x - x.mean()) / x.std()                       # gradient descent như mục 4, lr = 0.5, 20 bước
w, b = 0.0, 0.0
for _ in range(20):
    e = w * xs + b - y
    w, b = w - 0.5 * 2 * np.mean(e * xs), b - 0.5 * 2 * np.mean(e)
w_gd, b_gd = w / x.std(), b - w * x.mean() / x.std()

for ten, ww, bb in [("lstsq", w_ls, b_ls), ("polyfit", w_pf, b_pf), ("gradient descent", w_gd, b_gd)]:
    print(f"{ten:17s}: w = {ww:.6f} mm/px, b = {bb:.5f} mm, MSE = {np.mean((ww * x + bb - y) ** 2):.7f}")
lstsq            : w = 0.048180 mm/px, b = 0.36864 mm, MSE = 0.0010415
polyfit          : w = 0.048180 mm/px, b = 0.36864 mm, MSE = 0.0010415
gradient descent : w = 0.048180 mm/px, b = 0.36864 mm, MSE = 0.0010415

Ba cách cho cùng một nghiệm. Vậy tại sao không luôn dùng công thức đóng? Vì nó chỉ tồn tại cho mô hình tuyến tính với loss bình phương. Mạng nơ-ron có hàm kích hoạt phi tuyến, hàm loss cross-entropy, hàng triệu tham số: không có công thức nào, chỉ còn gradient descent. Với bài hiệu chỉnh pixel–mm thật trên trạm, hãy dùng lstsq; gradient descent ở đây là để hiểu cơ chế.

8. Từ gradient descent đến mini-batch SGD

Ở trên, mỗi bước dùng toàn bộ 40 mẫu để tính gradient. Với 50.000 ảnh, mỗi bước phải chạy qua cả 50.000 ảnh mới cập nhật được một lần, quá chậm và không vừa bộ nhớ GPU. Mini-batch SGD (stochastic gradient descent) tính gradient trên một nhóm nhỏ ngẫu nhiên (batch) và cập nhật ngay. Mỗi lần duyệt hết dữ liệu gọi là một epoch.

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]
mu, sigma = x.mean(), x.std()
xs = (x - mu) / sigma

rng = np.random.default_rng(0)
w, b, lr, batch = 0.0, 0.0, 0.1, 8
for epoch in range(30):
    thu_tu = rng.permutation(len(xs))              # xáo trộn mỗi epoch
    for i in range(0, len(xs), batch):
        idx = thu_tu[i:i + batch]                   # 5 batch × 8 mẫu = 1 epoch
        e = w * xs[idx] + b - y[idx]
        w -= lr * 2 * np.mean(e * xs[idx])
        b -= lr * 2 * np.mean(e)
    if epoch % 10 == 9:
        loss = np.mean((w * xs + b - y) ** 2)
        print(f"epoch {epoch + 1:2d}: loss toàn bộ {loss:.6f} | w_gốc {w / sigma:.6f} | b_gốc {b - w * mu / sigma:.4f}")
epoch 10: loss toàn bộ 0.001043 | w_gốc 0.048186 | b_gốc 0.3659
epoch 20: loss toàn bộ 0.001042 | w_gốc 0.048182 | b_gốc 0.3665
epoch 30: loss toàn bộ 0.001042 | w_gốc 0.048176 | b_gốc 0.3704

Mỗi batch chỉ là ước lượng "có nhiễu" của gradient thật, nên tham số dao động nhẹ quanh đáy thay vì dừng hẳn; loss vẫn xuống gần mức tối ưu. Trong Deep Learning, nhiễu này còn có ích (giúp thoát vùng phẳng), và người ta giảm dần learning rate về cuối để tham số ổn định.

Trong bài nàyTrong mạng nơ-ron
2 tham số w, bHàng nghìn đến hàng trăm triệu tham số
Tự đạo hàm bằng tay, kiểm tra bằng sai phânAutograd tính tự động (backpropagation)
Loss MSE (hồi quy)Cross-entropy cho phân loại, MSE/L1 cho hồi quy, loss riêng cho detection
Mặt loss "cái bát" một đáy duy nhấtMặt loss gồ ghề, nhiều vùng phẳng; không đảm bảo tới điểm thấp nhất toàn cục
Gradient descent thuần, lr cố địnhSGD momentum, Adam; lr thay đổi theo lịch (scheduler)
Chuẩn hóa x bằng mean/stdChia ảnh cho 255, chuẩn hóa mean/std; BatchNorm bên trong mạng

9. Sai lầm thường gặp

  • Sai dấu khi cập nhật (w += lr * dw): loss tăng ngay từ bước đầu. Gradient chỉ hướng loss tăng, phải đi ngược.
  • Không chuẩn hóa đầu vào, rồi kết luận "thuật toán không hội tụ" hoặc giảm lr đến mức không học được gì.
  • Chọn learning rate một lần cho mọi bài. Learning rate phụ thuộc thang đo dữ liệu, kiến trúc, optimizer, kích thước batch. Luôn nhìn đường loss vài chục bước đầu.
  • Thấy loss thành nan hoặc inf mà không nghĩ tới learning rate quá lớn. Đó là nguyên nhân phổ biến nhất.
  • Quên đổi tham số về thang đo gốc (hoặc quên lưu mu, sigma), dùng w học trên x chuẩn hóa để nhân với x pixel.
  • Tính gradient bằng tay mà không kiểm tra: lỗi thiếu hệ số 2 vẫn hội tụ (chỉ như lr nhỏ đi một nửa), nên rất khó phát hiện bằng mắt.

10. Bài tập

Bài 1. Thử learning rate 0,001; 0,01; 0,5 và vẽ đường loss của từng trường hợp, trên dữ liệu đã chuẩn hóa (mục 5). Sau đó lặp lại với x chưa chuẩn hóa: với mỗi lr, loss sau 200 bước là bao nhiêu, hay phân kỳ ở bước nào? Giải thích sự khác biệt.

Xem đáp án

Phần dữ liệu chuẩn hóa: chạy đoạn code mục 5 và mục vẽ. Kết quả: 0,001 giảm rất chậm (bước 200 vẫn còn vài trăm mm²), 0,01 giảm đều (bước 200 còn khoảng 0,19), 0,5 xuống đáy sau 1 bước. Phần chưa chuẩn hóa:

import numpy as np

d = np.load("hieu_chinh.npz")
x, y = d["x"], d["y"]
for lr in [0.001, 0.01, 0.5]:
    w, b, ket_qua = 0.0, 0.0, None
    for buoc in range(201):
        e = w * x + b - y
        loss = np.mean(e ** 2)
        if not np.isfinite(loss) or loss > 1e12:
            ket_qua = f"phân kỳ ở bước {buoc}"
            break
        w, b = w - lr * 2 * np.mean(e * x), b - lr * 2 * np.mean(e)
    print(f"lr = {lr}: {ket_qua or f'loss sau 200 bước {loss:.4f}'}")
lr = 0.001: phân kỳ ở bước 2
lr = 0.01: phân kỳ ở bước 2
lr = 0.5: phân kỳ ở bước 1

Trên x gốc, cả ba learning rate đều lớn hơn ngưỡng ổn định khoảng 4·10−6 (mục 6), nên đều phân kỳ, lr càng lớn càng nổ sớm. Cùng một con số learning rate có thể "quá nhỏ" hay "quá lớn" tùy thang đo dữ liệu.

Bài 2. Camera nhìn hơi nghiêng nên kích thước pixel thay đổi theo hàng ảnh: y_mm = w1·x_px + w2·hang + b, với hang là tọa độ hàng (0–1000) của tâm chi tiết. Viết gradient descent dạng vector cho mô hình nhiều đặc trưng ŷ = X·w + b (gradient ∂L/∂w = (2/n)·Xᵀe), chuẩn hóa từng cột, rồi so với lstsq. Dùng dữ liệu mô phỏng: 60 mẫu, y = 0,0482·x + 0,0006·hang + 0,35 cộng nhiễu 0,03 mm (đoạn sinh dữ liệu ở đầu đáp án).

Xem đáp án
import numpy as np

rng = np.random.default_rng(5)
n = 60
X = np.column_stack([rng.uniform(150, 750, n), rng.uniform(0, 1000, n)])   # [x_px, hàng]
y = 0.0482 * X[:, 0] + 0.0006 * X[:, 1] + 0.35 + rng.normal(0, 0.03, n)

mu, sigma = X.mean(axis=0), X.std(axis=0)
Xs = (X - mu) / sigma                         # chuẩn hóa TỪNG cột
w, b, lr = np.zeros(2), 0.0, 0.3
for _ in range(300):
    e = Xs @ w + b - y
    w -= lr * 2 / n * (Xs.T @ e)              # vector gradient cho mọi trọng số cùng lúc
    b -= lr * 2 * np.mean(e)
w_goc = w / sigma
b_goc = b - np.sum(w * mu / sigma)

A = np.column_stack([X, np.ones(n)])
theta, *_ = np.linalg.lstsq(A, y, rcond=None)
print("gradient descent:", np.round(w_goc, 6), round(b_goc, 4))
print("lstsq           :", np.round(theta[:2], 6), round(theta[2], 4))
gradient descent: [0.048217 0.000607] 0.3398
lstsq           : [0.048217 0.000607] 0.3398

Hai nghiệm khớp nhau. Dạng vector Xᵀe là đúng công thức mà một lớp nn.Linear trong PyTorch dùng khi lan truyền ngược. Với hai cột có thang đo khác nhau (vài trăm và vài nghìn), chuẩn hóa từng cột là bắt buộc.

Bài 3. Không chạy code: với dữ liệu đã chuẩn hóa ở mục 5, (a) learning rate lớn nhất còn hội tụ là bao nhiêu? (b) với lr = 0,2, sau bao nhiêu bước khoảng cách tới đáy còn dưới 1% ban đầu? (c) với lr = 0,9 thì sao?

Xem đáp án

Hệ số co sau mỗi bước là q = 1 − 2·lr. (a) Hội tụ khi |q| < 1, tức lr < 1 (lr = 1 dao động mãi). (b) q = 0,6: cần 0,6^k < 0,01, k > ln(0,01)/ln(0,6) ≈ 9,0, tức 10 bước. (c) q = −0,8: tham số nhảy qua lại hai bên đáy nhưng biên độ giảm 0,8 lần mỗi bước, cần k > ln(0,01)/ln(0,8) ≈ 20,6, tức 21 bước. lr = 0,9 vẫn hội tụ nhưng chậm hơn lr = 0,2, dù lớn hơn gấp 4,5 lần: learning rate lớn hơn không phải lúc nào cũng nhanh hơn.

Tóm tắt

  • Huấn luyện = tìm tham số làm hàm mất mát nhỏ nhất. MSE mean((w·x + b − y)²) là loss chuẩn cho hồi quy.
  • Gradient ∂L/∂w = 2·mean(e·x), ∂L/∂b = 2·mean(e) chỉ hướng loss tăng; gradient descent cập nhật θ ← θ − lr·∇L. Kiểm tra gradient tự viết bằng sai phân hữu hạn.
  • Learning rate quá nhỏ thì chậm, quá lớn thì dao động hoặc phân kỳ (loss tăng vọt, nan). Luôn vẽ đường loss theo thang log.
  • Chuẩn hóa đầu vào làm mặt loss tròn đều và cho phép learning rate lớn; trên dữ liệu chưa chuẩn hóa, cùng lr có thể nổ. Lưu tham số chuẩn hóa cùng mô hình.
  • Với hồi quy tuyến tính, nghiệm đóng lstsq cho cùng kết quả; gradient descent là phương pháp chung cho mọi mô hình, kể cả mạng nơ-ron, thường dưới dạng mini-batch SGD.

Bài tiếp theo: Mạng nơ-ron và CNN hoạt động thế nào, nơi w·x + b trở thành một neuron và hàng nghìn neuron ghép thành mạng phân loại ảnh.

Bài tập thực hành

  1. Thử learning rate 0,001; 0,01; 0,5 và vẽ đường loss của từng trường hợp.

Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.