🔬 Bài 30: Dự án — Khớp một đường thẳng từ số 0
Chặng 7 · Toán & Tư duy ML (Bài 26 → 30) · đây là bài 5/5, bài đóng chặng.
🎯 Mục tiêu: dùng cả bốn thứ vừa học để tự tay dựng một mô hình học máy hoàn chỉnh — không dùng thư viện máy học nào, chỉ NumPy và vòng lặp.
👥 Đối tượng: học sinh cấp 2–3, sinh viên. Cần biết trước: cả bốn bài 26, 27, 28, 29.
📖 Cách đọc: bài này là dự án, không phải bài lý thuyết. Chỉ có đúng một khái niệm mới (tham số thứ hai), phần còn lại là ráp những mảnh bạn đã có.
Sau bài này bạn sẽ:
- Huấn luyện được mô hình hai tham số bằng gradient descent, viết từ con số không.
- Hiểu vì sao gradient trở thành một vector khi có nhiều tham số — đúng thứ Bài 26 đã chuẩn bị.
- Biết vì sao Loss dừng lại ở một con số dương chứ không về 0, và vì sao thế mới là đúng.
- Nhận ra tại sao thang đo của dữ liệu lại quyết định tốc độ học.
Bốn bài vừa rồi mỗi bài cho một mảnh: vector để biểu diễn, đạo hàm để định hướng, hàm mất mát để chấm điểm, gradient descent để bước đi. Hôm nay ta dùng cả bốn cùng lúc, trên một bài toán có thật.
Bài toán: bạn có số liệu của 6 học sinh — mỗi tuần tự học bao nhiêu giờ và thi được bao nhiêu điểm. Câu hỏi: một bạn học 7 giờ mỗi tuần thì được khoảng bao nhiêu điểm?
Không ai cho ta công thức. Máy phải tự tìm ra nó từ dữ liệu. Đó chính là định nghĩa của học máy.
📊 Dữ liệu của cả bài — chỉ 6 dòng, cố ý nhỏ để bạn kiểm tay được:
Giờ tự học mỗi tuần (x) | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
Điểm thi (y) | 4,5 | 6,2 | 7,4 | 9,1 | 10,4 | 12,2 |
Chấm 6 điểm này lên giấy sẽ thấy chúng gần như thẳng hàng, nhưng không thẳng hoàn hảo. Đời thực luôn vậy: có bạn hôm đó ốm, có bạn gặp đúng đề tủ. Sự lệch lạc ấy gọi là nhiễu, và nó sẽ quyết định câu chuyện ở phần 3.
Hình 1 — Sáu chấm xanh là dữ liệu thật. Đường đỏ là thứ máy phải tự tìm ra. Lần này có hai núm vặn chứ không phải một: độ dốc w và điểm cắt b.
1. Hai núm vặn thay vì một
Ở Bài 29, mô hình là y = w × x. Đường thẳng đó luôn đi qua gốc tọa độ — nghĩa là ta ngầm giả định “học 0 giờ thì được 0 điểm”. Nhìn Hình 1 sẽ thấy giả định đó sai: đường thẳng cắt trục dọc ở khoảng 3 điểm.
Nên ta thêm một núm vặn nữa: y = w × x + b.
wđiều khiển độ nghiêng — vặnwthì đường thẳng xoay quanh trục.bđiều khiển độ cao — vặnbthì cả đường thẳng trượt lên hoặc xuống.
Giờ chiếc bát của Bài 28 không còn là một đường cong trên giấy phẳng nữa. Nó thành một cái chảo trong không gian ba chiều: hai chiều đáy là w và b, chiều cao là Loss. Nhưng cách đi xuống thì y hệt: xem dốc, bước một bước, lặp lại.
Điều đáng mừng: bạn không cần hình dung nổi cái chảo ba chiều đó. Máy cũng không hình dung. Nó chỉ cần biết ở chỗ đang đứng thì dốc theo w bao nhiêu, dốc theo b bao nhiêu.
Có hai tham số thì phải tính hai đạo hàm — mỗi cái đo độ dốc theo một núm, trong khi núm kia đứng yên. Đặt r = w×x + b − y là phần dư của từng điểm:
dL/dw = trung bình của ( 2 * x * r ) # co nhan x: diem xa goc anh huong manh hon dL/db = trung bình của ( 2 * r ) # khong nhan x: moi diem anh huong nhu nhau
Khác biệt duy nhất giữa hai dòng là chữ x. Lý do rất trực quan: vặn w làm đường thẳng xoay, nên điểm càng xa gốc càng bị dịch nhiều — vì thế đóng góp của nó phải nhân với x. Còn vặn b làm cả đường trượt song song, mọi điểm dịch như nhau.
Trong Python, viết gọn thành một hàm trả về cả hai:
def gradient(w, b): r = w * x + b - y_that return np.mean(2 * x * r), np.mean(2 * r)
Và cập nhật cả hai cùng lúc, dùng chung một tốc độ học:
gw, gb = gradient(w, b) w = w - lr * gw b = b - lr * gb
Đây chính là chỗ Bài 26 phát huy tác dụng. Hai đạo hàm ở trên gom lại thành một vector hai phần tử — đó là gradient theo đúng nghĩa toán học. Và công thức cập nhật viết được thành một dòng vector:
theta = theta - lr * gradient(theta) # theta = [w, b]
Với 2 tham số, vector có 2 phần tử. Với 10 triệu tham số, vector có 10 triệu phần tử. Dòng lệnh không đổi một chữ nào. Đó là lý do bài đầu chặng phải học vector trước khi học bất cứ thứ gì khác.
Đi xa hơn một chút: nếu gom b thành một cột toàn số 1 ghép vào dữ liệu, cả mô hình rút gọn thành một phép nhân ma trận y = X · theta — đúng phép nhân ở Hình 2 của Bài 26. Mọi thư viện máy học đều làm đúng cách này, vì phép nhân ma trận là thứ GPU chạy nhanh nhất.
2. Vòng lặp huấn luyện, lần này với hai tham số
Bắt đầu từ w = 0 và b = 0 — tức là một đường thẳng nằm bẹp trên trục ngang, dự đoán mọi học sinh đều được 0 điểm. Sai bét, nhưng không sao: máy sẽ tự sửa.
Bảng dưới là ba bước đầu tiên, tính với lr = 0,01. Mọi con số đều kiểm được bằng máy tính bỏ túi.
| Bước | w | b | Loss | dL/dw | dL/db |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 75,54 | −66,90 | −16,60 |
| 1 | 0,6690 | 0,1660 | 35,62 | −45,45 | −11,59 |
| 2 | 1,1235 | 0,2818 | 17,14 | −30,85 | −8,17 |
| 3 | 1,4319 | 0,3636 | 8,59 | −20,92 | −5,85 |
Muốn kiểm dòng đầu: dL/dw tại gốc chính là −(1/3) × tổng của x×y. Tổng đó bằng 200,7 nên đạo hàm bằng −66,9. Tương tự dL/db = −(1/3) × tổng y = −49,8/3 = −16,6.
Chú ý hai đạo hàm có độ lớn rất khác nhau: −66,9 so với −16,6, chênh nhau bốn lần. Nghĩa là ở bước đầu, núm w bị vặn mạnh hơn núm b rất nhiều. Điều này sẽ gây ra một chuyện thú vị ở phần sau.
Chạy đủ 2000 bước, đây là những cột mốc:
| Sau bao nhiêu bước | w | b | Loss |
|---|---|---|---|
| 10 | 2,046 | 0,583 | 1,167 |
| 150 | 1,849 | 1,564 | 0,424 |
| 400 | 1,645 | 2,436 | 0,081 |
| 1000 | 1,524 | 2,955 | 0,0164 |
| 2000 | 1,509 | 3,018 | 0,0156 |
Có hai điều đáng nhìn kỹ ở bảng này.
Thứ nhất, w đi quá đà rồi mới quay lại. Nó vọt lên tới đỉnh 2,061 ở đúng bước 16, rồi mới từ từ bò ngược xuống 1,51. Vì sao? Vì đạo hàm theo w lớn hơn hẳn nên núm đó bị vặn nhanh, trong khi b lê từng chút. Máy phải bù lại bằng cách hạ w xuống trong lúc chờ b leo lên.
Thứ hai, Loss giảm rất nhanh lúc đầu rồi bò lê. Từ 75,5 xuống 1,17 chỉ mất 10 bước; nhưng từ 1,17 xuống 0,0156 thì tốn gần 2000 bước nữa. Đây là chuyện hoàn toàn bình thường và bạn sẽ gặp lại suốt: phần lớn tiến bộ đến trong một nhúm bước đầu tiên.
Hiện tượng “một núm chạy nhanh, một núm bò chậm” có tên: bài toán bị điều kiện xấu (ill-conditioned). Cái chảo Loss không tròn đều mà bị kéo dài thành hình lòng máng hẹp. Viên bi lăn rất nhanh theo chiều ngang máng nhưng trượt cực chậm dọc theo máng.
Nguyên nhân ở đây rất cụ thể: x nhận giá trị từ 1 tới 6, toàn số dương và khá lớn so với hằng số 1 đi kèm b. Cách chữa kinh điển là chuẩn hóa dữ liệu — trừ đi trung bình rồi chia cho độ lệch chuẩn, đưa x về quanh 0. Làm vậy cái chảo tròn lại và thuật toán hội tụ nhanh hơn hàng chục lần. Chặng 8 sẽ dạy kỹ thuật này.
Còn một lối khác: bài toán khớp đường thẳng có công thức nghiệm chính xác, giải một lần ra ngay, không cần lặp. Vậy sao phải học gradient descent? Vì công thức đó chỉ tồn tại cho vài mô hình đơn giản. Mạng nơ-ron thì không có công thức nào cả — chỉ còn cách lặp. Ta học cách lặp trên bài toán dễ, để dùng được cho bài toán khó.
Hình 2 — Bên trái: đường thẳng tự xoay và trượt dần về đúng vị trí. Bên phải: Loss lao xuống trong chục bước đầu rồi bò lê hàng nghìn bước, và dừng lại ở một mức sàn dương chứ không về 0.
3. Vì sao Loss không bao giờ về 0 — và thế mới đúng
Ở Bài 29, dữ liệu thẳng hàng hoàn hảo nên Loss xuống tới 0. Ở đây thì không: chạy bao lâu Loss cũng chỉ tới 0,0156 rồi đứng.
Lý do đơn giản: sáu điểm này không nằm trên cùng một đường thẳng. Không có đường thẳng nào đi qua cả sáu điểm cả. Loss 0,0156 chính là mức sai ít nhất mà một đường thẳng có thể đạt được — và máy đã tìm ra đúng nó.
Điều này quan trọng hơn vẻ ngoài của nó. Nếu một ngày bạn thấy Loss xuống đúng 0 trên dữ liệu thật, đó thường không phải tin vui. Nó có nghĩa mô hình đã học thuộc lòng từng điểm dữ liệu, kể cả phần nhiễu — và sẽ đoán rất tệ với dữ liệu mới. Hiện tượng đó gọi là overfitting, và là chủ đề trung tâm của Chặng 8.
Nói cách khác: mục tiêu không phải là Loss bằng 0. Mục tiêu là mô hình nắm được quy luật chung và bỏ qua phần nhiễu lặt vặt.
Kết quả máy học được:
y = 1.509 * x + 3.018
Đọc ra tiếng Việt: học thêm 1 giờ mỗi tuần thì điểm tăng khoảng 1,5; còn nếu không tự học giờ nào thì được khoảng 3 điểm. Một mô hình có ý nghĩa, không phải con số vô hồn.
Và giờ trả lời được câu hỏi đặt ra ở đầu bài:
học 7 giờ -> 1.509 * 7 + 3.018 = 13.58 điểm
Lưu ý: x = 7 nằm ngoài khoảng dữ liệu ta có (chỉ từ 1 tới 6). Dự đoán ngoài vùng đã biết luôn rủi ro — ngoài đời điểm số có trần là 10, mà mô hình đường thẳng thì cứ tăng mãi. Máy không biết điều đó, chỉ có bạn biết.
Con số 0,0156 có tên riêng: sai số không thể giảm được (irreducible error). Nó do bản thân dữ liệu sinh ra, không phải do mô hình dở. Đổi sang mô hình phức tạp hơn có thể ép Loss xuống thấp hơn trên chính bộ dữ liệu này, nhưng thường sẽ tệ hơn trên dữ liệu mới.
Chuyện này dẫn tới một trong những khái niệm quan trọng nhất của ML: đánh đổi giữa thiên lệch và phương sai (bias–variance tradeoff). Mô hình quá đơn giản thì bỏ sót quy luật; quá phức tạp thì học luôn cả nhiễu. Chỗ ngọt nằm ở giữa, và tìm chỗ ngọt đó là nghề của người làm ML.
Một điểm nữa cho ai muốn đi xa: cách đánh giá “mô hình tốt hay dở” bằng chính dữ liệu đã dùng để huấn luyện là sai phương pháp. Phải giữ lại một phần dữ liệu mà mô hình chưa từng thấy để chấm điểm. Đó là nội dung Bài 33 — và là bài quan trọng nhất của Chặng 8.
⚠️ Thang đo của dữ liệu quyết định tốc độ học
Thử tưởng tượng bạn ghi giờ tự học bằng phút thay vì giờ: x thành 60, 120, 180... Dữ liệu không đổi bản chất, chỉ đổi đơn vị. Nhưng khi đó lr = 0,01 sẽ làm thuật toán phân kỳ ngay lập tức.
Tôi đã thử: với x tính bằng phút, phải hạ tốc độ học xuống khoảng 0,00001 mới chạy được — nhỏ hơn một nghìn lần. Lý do: đạo hàm theo w có nhân với x, nên x lớn gấp 60 thì gradient phình lên cỡ 60² = 3600 lần.
Bài học: nếu đổi đơn vị dữ liệu mà mô hình hỏng, đừng nghi ngờ thuật toán — hãy nghi tốc độ học trước. Và đây chính là lý do người ta gần như luôn chuẩn hóa dữ liệu trước khi huấn luyện.
📝 Bảng ghi nhớ
| Việc | Cách làm |
|---|---|
| Mô hình đường thẳng | y = w * x + b |
| Đạo hàm theo độ dốc | np.mean(2 * x * r) với r = w*x + b - y |
| Đạo hàm theo điểm cắt | np.mean(2 * r) — giống hệt, chỉ bỏ chữ x |
| Cập nhật | Tính xong cả hai đạo hàm rồi mới sửa cả hai tham số |
| Loss dừng ở số dương | Bình thường — dữ liệu có nhiễu, không đường thẳng nào qua hết mọi điểm |
| Loss xuống đúng 0 | Đáng ngờ — nhiều khả năng mô hình đang học thuộc lòng |
| Đổi đơn vị dữ liệu | Phải chỉnh lại tốc độ học, nếu không sẽ phân kỳ |
| Dự đoán ngoài vùng dữ liệu | Làm được nhưng rủi ro cao, máy không biết giới hạn thực tế |
🏆 Nhìn lại cả Chặng 7
Năm bài vừa qua, mỗi bài thêm đúng một mảnh, và giờ thì đủ bộ:
| Bài | Mảnh ghép | Vai trò trong cỗ máy |
|---|---|---|
| 26 | Vector và ma trận | Cách biểu diễn dữ liệu và tham số |
| 27 | Đạo hàm | La bàn — chỉ hướng dốc |
| 28 | Hàm mất mát | Địa hình — đo mức sai |
| 29 | Gradient descent | Đôi chân — cách bước đi |
| 30 | Dự án khớp đường thẳng | Ráp cả bốn lại thành mô hình chạy được |
Nếu có một câu mang theo khỏi chặng này, thì là câu sau: “học” của máy chỉ là đo mình sai bao nhiêu, xem nên chỉnh về phía nào, chỉnh một chút, rồi lặp lại. Mọi mô hình từ hồi quy tuyến tính tới mô hình ngôn ngữ khổng lồ đều chạy trên đúng vòng lặp đó.
✎ Kiểm tra nhanh
Câu 1. Vì sao mô hình bài này cần thêm tham số b chứ không dùng y = w × x như Bài 29?
- Vì hai tham số thì luôn chính xác hơn một tham số
- Vì gradient descent yêu cầu tối thiểu hai tham số
- Vì đường thẳng qua gốc tọa độ ngầm giả định học 0 giờ được 0 điểm, mà dữ liệu cho thấy không phải vậy
- Vì NumPy không xử lý được mô hình một tham số
b sinh ra để mô tả điều đó. Thêm tham số không tự động tốt hơn — chỉ tốt hơn khi dữ liệu đòi hỏi.Câu 2. Hai công thức đạo hàm chỉ khác nhau ở chữ x. Vì sao đạo hàm theo w lại có nhân với x còn theo b thì không?
- Vì
wluôn lớn hơnb - Vì vặn
wlàm đường thẳng xoay nên điểm càng xa gốc dịch càng nhiều, còn vặnblàm cả đường trượt song song nên mọi điểm dịch như nhau - Vì đó chỉ là quy ước cho gọn công thức
- Vì
bkhông ảnh hưởng tới Loss
w phụ thuộc vào vị trí điểm: điểm ở xa bị ảnh hưởng mạnh hơn khi đường xoay, nên đóng góp của nó nhân với x. Còn dịch b thì nâng cả đường lên đều nhau.Câu 3. Sau 2000 bước, Loss dừng ở 0,0156 và không giảm nữa. Điều đó có nghĩa gì?
- Thuật toán bị lỗi, cần tăng số bước
- Tốc độ học đang quá lớn
- Dữ liệu bị hỏng, cần thu thập lại
- Đã tới mức sai nhỏ nhất mà một đường thẳng đạt được — dữ liệu có nhiễu nên không thể về 0
Câu 4. Nếu một ngày bạn huấn luyện trên dữ liệu thật và thấy Loss xuống đúng bằng 0, phản ứng hợp lý nhất là gì?
- Nghi ngờ — nhiều khả năng mô hình đang học thuộc lòng cả phần nhiễu
- Vui mừng vì mô hình đã hoàn hảo
- Tăng tốc độ học để về 0 nhanh hơn nữa
- Bỏ hàm mất mát vì không còn cần nữa
Câu 5. Đổi đơn vị của x từ giờ sang phút (nhân 60) mà giữ nguyên lr = 0,01. Chuyện gì xảy ra?
- Không có gì thay đổi, chỉ là đổi đơn vị
- Mô hình hội tụ nhanh hơn 60 lần
- Thuật toán phân kỳ, vì gradient phình lên khoảng 3600 lần
- Kết quả
wvàbvẫn y nguyên
w có nhân với x, nên x lớn gấp 60 làm gradient phình cỡ 60². Với cùng tốc độ học, bước chân trở nên khổng lồ và Loss nổ tung. Muốn chạy được phải hạ lr xuống khoảng một phần nghìn.Câu 6. Trong bảng cột mốc, w vọt lên đỉnh 2,061 ở bước 16 rồi mới bò ngược về 1,51. Vì sao?
- Vì có lỗi trong công thức cập nhật
- Vì đạo hàm theo
wlớn hơn hẳn theob, nênwbị vặn nhanh trong khiblê chậm, và sau đó phải điều chỉnh bù lại - Vì tốc độ học tự động thay đổi giữa chừng
- Vì dữ liệu được xáo trộn qua mỗi bước
Câu 7. Mô hình dự đoán học 7 giờ được 13,58 điểm. Vì sao nên thận trọng với con số này?
- Vì 7 nằm ngoài khoảng dữ liệu đã có (1 tới 6), và đường thẳng cứ tăng mãi trong khi điểm thi thì có trần
- Vì mô hình chỉ dự đoán được số nguyên
- Vì cần chạy thêm ít nhất 10000 bước mới đáng tin
- Vì Loss chưa về 0 nên mọi dự đoán đều vô nghĩa
💻 Thực hành: dự án hoàn chỉnh
▶ Cách dùng: bấm Khởi động Python một lần (lần đầu mất khoảng 10–20 giây), đợi báo sẵn sàng rồi bấm Chạy code. Phần 1 in ra đúng bảng tính tay ở trên — hãy đối chiếu để yên tâm rằng bạn và máy đang làm cùng một việc.
💡 Thử thách (bốn câu này chính là bản lề sang Chặng 8):
- Đổi
lr = 0.08rồi chạy lại. Loss diễn biến ra sao? Con số nào xuất hiện báo cho bạn biết thuật toán đã hỏng? - Thêm một học sinh cá biệt vào cuối dữ liệu:
xthêm7,y_thatthêm3.0(học nhiều mà điểm thấp). Đường thẳng bị kéo lệch bao nhiêu? Điều này minh họa nhận xét nào ở hộp cảnh báo của Bài 28? - Đổi
xsang đơn vị phút bằngx = np.array([60., 120, 180, 240, 300, 360]). Giữ nguyênlrrồi chạy — chuyện gì xảy ra? Sau đó hạlrdần cho tới khi chạy được. Bạn phải hạ bao nhiêu lần? - Thử chuẩn hóa: thay
xbằng(x - x.mean()) / x.std()rồi huấn luyện lại vớilr = 0.1. Cần bao nhiêu bước để Loss xuống dưới 0,02? So với 2000 bước ban đầu thì nhanh hơn mấy lần?
Chặng 7 khép lại ở đây. Bạn vừa tự tay dựng một mô hình học máy hoàn chỉnh từ con số không — không thư viện máy học, không phép màu, chỉ NumPy và một vòng lặp.
Chặng 8 bắt đầu bằng một cú sốc dễ chịu: toàn bộ những gì bạn vừa làm, scikit-learn gói lại thành ba dòng. Nhưng khác biệt là bây giờ bạn biết ba dòng ấy đang làm gì bên trong — và đó chính là ranh giới giữa người dùng công cụ và người hiểu công cụ. Chặng 8 cũng sẽ trả lời những câu hỏi mà chặng này cố tình để ngỏ: làm sao biết mô hình thật sự tốt, vì sao không được chấm điểm bằng chính dữ liệu đã học, và overfitting nguy hiểm tới mức nào.