🔬 Bài 36: Kiểm định chéo — chia một lần vẫn chưa đủ
Chặng 8 · Học máy thực chiến (Bài 31 → 42) · đây là bài 6/12 của chặng.
🎯 Mục tiêu: làm cho con số chấm điểm bớt phụ thuộc may rủi, bằng cách chia nhiều lần rồi lấy trung bình.
👥 Cần biết trước: Bài 34 (train_test_split) và Bài 35 (bậc đa thức, overfitting).
📖 Cách đọc 3 lớp: mỗi phần có ba khối màu song song — 🟢 Lớp A trực giác, 🔵 Lớp B cơ chế, 🟣 Lớp C nghiên cứu.
Sau bài này bạn sẽ:
- Thấy được rằng chọn mô hình dựa trên một lần chia là việc khá may rủi.
- Dùng được kiểm định chéo (cross-validation): chia K phần, mỗi phần lần lượt làm đề thi, rồi lấy trung bình.
- Phân biệt được ba vai trò khác nhau của dữ liệu: phần để học, phần để chọn, và phần để chấm lần cuối.
- Hiểu vì sao con số bạn dùng để chọn không còn là con số trung thực để báo cáo.
Hãy nhớ lại cuối Bài 35, ta có một vết gợn. Đường “đề mới” đáng lẽ phải là chữ U trơn tru, nhưng bậc 6 lại tụt xuống thấp hơn bậc 4 và bậc 5. Lý do: đề thi chỉ có 5 điểm, ít tới mức may rủi còn xen được vào.
Hôm nay ta đo xem sự may rủi ấy lớn tới đâu — và câu trả lời khá đáng lo. Rồi ta sẽ chữa nó bằng một ý tưởng đơn giản đến bất ngờ.
1. Đo thử xem chia một lần bấp bênh tới đâu
Vẫn bộ nhiệt độ 14 điểm của Bài 35. Ta làm đúng việc đã làm hôm trước — chia dữ liệu, thử các bậc từ 1 tới 6, chọn bậc có MSE thấp nhất trên đề mới — nhưng làm 12 lần, mỗi lần chia theo một cách khác.
Nếu cách làm này đáng tin, cả 12 lần phải cho cùng một câu trả lời. Kết quả thật:
Cach chia so: 1 2 3 4 5 6 7 8 9 10 11 12 Bac duoc chon: 2 5 2 2 2 3 2 2 3 2 3 2
Ba câu trả lời khác nhau. Bậc 2 thắng 8 lần, bậc 3 thắng 3 lần, bậc 5 thắng 1 lần. Nói cách khác: cứ ba lần thì có một lần bạn chọn nhầm — chỉ vì rơi vào cách chia không may.
Chuyện này không phải do ta làm sai bước nào. Nó là hệ quả tất yếu của việc chấm điểm chỉ trên 5 điểm dữ liệu. Năm điểm ấy có thể tình cờ toàn điểm dễ, hoặc tình cờ toàn điểm khó.
Không chỉ bậc được chọn nhảy múa, mà cả con số chấm điểm cũng vậy. Lấy riêng bậc 2 và chấm nó bằng 12 cách chia khác nhau:
MSE thap nhat : 0.1311 MSE cao nhat : 0.6363 <- chenh nhau 4,9 lan
Cùng một mô hình, cùng một bộ dữ liệu. Chỉ khác ở chuyện ai rơi vào đề thi, mà con số báo cáo chênh nhau gần năm lần.
Nếu bạn viết trong báo cáo “mô hình đạt MSE 0,13” thì cũng không sai — bạn đã chạy ra đúng con số ấy. Nhưng nó không trung thực, vì hôm sau chạy lại với cách chia khác có thể ra 0,64.
Gốc rễ của vấn đề là đánh đổi mà Bài 34 đã nhắc tới. Có 14 điểm, dùng bao nhiêu cho đề thi cũng dở:
- Đề thi ít điểm → điểm chấm bấp bênh, đúng như ta vừa thấy.
- Đề thi nhiều điểm → phần ôn còn quá ít, mô hình học kém đi, nên ta lại đang chấm một mô hình yếu hơn mô hình thật sự sẽ dùng.
Với dữ liệu hàng chục nghìn dòng, mâu thuẫn này gần như biến mất — 20% của 50.000 dòng vẫn là 10.000 dòng, thừa sức chấm chính xác. Nhưng dữ liệu nhỏ là chuyện rất thường gặp trong đời thực: một nghiên cứu y khoa có 80 bệnh nhân, một lớp có 35 học sinh.
Cách chữa ở phần 2 chính là dành cho những trường hợp đó, và nó khéo ở chỗ không phải chọn bên nào trong hai cái dở trên.
2. Kiểm định chéo: cho mọi điểm đều được đi thi
Ý tưởng gọn đến mức nghe xong thấy hiển nhiên: thay vì chia một lần, hãy chia nhiều lần và lấy trung bình.
Cụ thể, cách làm phổ biến nhất tên là K-fold — chia K phần:
- Chia dữ liệu thành K phần bằng nhau (thường K = 5 hoặc 10).
- Lấy phần 1 làm đề thi, 4 phần còn lại làm đề ôn. Chấm được một điểm.
- Lặp lại với phần 2 làm đề thi, rồi phần 3… cho tới phần K.
- Lấy trung bình K điểm đó.
Cái hay nằm ở chỗ: mỗi điểm dữ liệu đều được đem đi thi đúng một lần, và được dùng để học ở K−1 lần còn lại. Không điểm nào bị bỏ phí, và không có điểm nào vừa học vừa thi trong cùng một lượt.
Ta không còn phải chọn giữa “đề thi ít” và “đề ôn ít” nữa — ta được cả hai, chỉ phải trả giá bằng thời gian chạy nhiều hơn K lần.
Hình 1 — Kiểm định chéo 5 phần trên bộ 14 điểm, cho mô hình bậc 2. Năm con số bên phải chính là năm lần chấm riêng lẻ. Chú ý chúng chênh nhau tới gần 6 lần — đó đúng là sự bấp bênh ở phần 1. Lấy trung bình là cách làm dịu nó đi.
Công cụ là KFold, cũng nằm ở ngăn model_selection như train_test_split:
from sklearn.model_selection import KFold kf = KFold(n_splits=5, shuffle=True, random_state=1) diem = [] for i_on, i_thi in kf.split(X): # chay dung 5 vong m = LinearRegression().fit(X[i_on], y[i_on]) diem.append( ((m.predict(X[i_thi]) - y[i_thi]) ** 2).mean() ) print(np.mean(diem)) # 0.4102
kf.split(X) không trả về dữ liệu mà trả về chỉ số dòng — dòng nào đi học, dòng nào đi thi. Ta dùng chỉ số đó để cắt X và y. Ba tham số:
n_splits=5— chia làm 5 phần. Với 14 điểm thì được 4 phần 3 điểm và 1 phần 2 điểm; không chia hết thì scikit-learn tự cân đối.shuffle=True— xáo trộn trước khi chia. Rất quan trọng: thiếu nó thì phần 1 sẽ toàn là buổi sáng sớm, phần cuối toàn buổi chiều tối.random_state=1— cố định cách xáo, để chạy lại ra đúng kết quả cũ. Chỉ có tác dụng khishuffle=True.
Đem cách chấm mới này áp cho cả sáu bậc:
| Bậc | Chia một lần (Bài 35) | Kiểm định chéo 5 phần | |
|---|---|---|---|
| 1 | 15,2719 | 9,2788 | |
| 2 | 0,6363 | 0,4102 | ⭐ thấp nhất ở cả hai |
| 3 | 1,3412 | 0,7261 | |
| 4 | 4,2181 | 0,9795 | |
| 5 | 7,8290 | 16,0919 | |
| 6 | 1,3334 | 3,3974 | vết gợn đã hết |
Để ý hàng cuối: ở Bài 35, bậc 6 tụt xuống 1,3334 — thấp hơn cả bậc 4 và 5, làm hỏng hình chữ U. Với kiểm định chéo, bậc 6 lên 3,3974, cao hơn bậc 4 đúng như đáng lẽ phải thế.
Hình 2 — Lặp lại thí nghiệm 12 lần với 12 cách xáo trộn khác nhau. Chia một lần chọn đúng 8/12; kiểm định chéo chọn đúng 11/12. Tốt hơn hẳn, nhưng không hoàn hảo — vẫn có một lần nó chọn nhầm bậc 4.
Chọn K bằng bao nhiêu? K = 5 và K = 10 là hai lựa chọn phổ biến. K lớn thì mỗi lần đề ôn nhiều hơn (sát với mô hình cuối cùng hơn) nhưng phải chạy nhiều lần hơn. Trường hợp cực đoan K = số dòng có tên riêng là leave-one-out: mỗi lần chỉ để đúng một dòng đi thi.
Có đường tắt không? Có. scikit-learn gói cả vòng lặp trên vào một dòng:
from sklearn.model_selection import cross_val_score d = cross_val_score(LinearRegression(), X, y, cv=kf, scoring="neg_mean_squared_error") print(-d.mean()) # 0.4102 - dung bang vong lap tren
Chữ neg_ trong tên gây bối rối cho hầu hết người mới. Lý do: scikit-learn quy ước điểm càng cao càng tốt cho mọi thước đo, mà MSE thì càng thấp càng tốt. Nên nó đổi dấu MSE thành số âm để giữ quy ước — và ta phải đổi dấu lại khi đọc. Không có ý nghĩa toán học nào, chỉ là quy ước.
Kiểm định chéo đắt tới đâu? Chấm một mô hình bằng K-fold tốn gấp K lần so với chia một lần. Với sáu bậc và K = 5, ta huấn luyện 30 mô hình thay vì 6. Ở quy mô này thì không sao; với mô hình lớn thì đó là lý do người ta vẫn dùng cách chia một lần khi dữ liệu đủ nhiều.
3. Nhưng ai chấm điểm cuối cùng?
Ta vừa dùng điểm kiểm định chéo để chọn bậc 2. Vậy có được viết trong báo cáo “mô hình của tôi đạt MSE 0,4102” không?
Không nên. Và lý do đáng suy nghĩ.
Hãy tưởng tượng bạn làm sáu đề thi thử, được 6 — 9 — 7 — 5 — 8 — 6. Bạn chọn ra con số đẹp nhất, 9 điểm, rồi bảo “trình độ của tôi là 9”. Nghe đã thấy có gì đó không ổn: con số 9 ấy có phần là thực lực, nhưng cũng có phần là đề hôm đó tình cờ hợp với bạn.
Ta vừa làm đúng như vậy: thử sáu bậc rồi lấy con số thấp nhất. Con số thấp nhất trong sáu lần thử bao giờ cũng đẹp hơn thực lực một chút — vì nó được chọn ra chính vì nó đẹp.
Nguyên tắc rút ra: một con số đã được dùng để đưa ra quyết định thì không còn làm giám khảo công tâm cho quyết định đó nữa.
Hình 3 — Ba vai trò tách bạch. Kiểm định chéo diễn ra hoàn toàn bên trong phần màu xanh; phần màu cam chỉ được mở ra một lần, sau khi mọi quyết định đã chốt xong.
Quy trình đầy đủ, đúng thứ tự:
# 1) Cat rieng phan cuoi cung NGAY TU DAU X_lam, X_cuoi, y_lam, y_cuoi = train_test_split( X, y, test_size=0.2, random_state=0) # 2) Chon bac bang kiem dinh cheo, CHI tren X_lam for bac in range(1, 7): ... KFold tren X_lam ... bac_tot = bac co CV-MSE thap nhat # 3) Huan luyen lai tren TOAN BO X_lam voi bac da chon mo_hinh = LinearRegression().fit(tao_cot(X_lam, bac_tot), y_lam) # 4) Cham dung MOT lan tren phan cat rieng mse_cuoi = ((mo_hinh.predict(tao_cot(X_cuoi, bac_tot)) - y_cuoi) ** 2).mean()
Bước 3 hay bị bỏ sót. Sau khi đã chọn được bậc, ta huấn luyện lại trên toàn bộ phần làm việc — vì trong kiểm định chéo, mỗi mô hình chỉ được học trên 4/5 dữ liệu. Mô hình cuối cùng nên được học trên càng nhiều càng tốt.
Hiện tượng “chọn cái tốt nhất trong nhiều lựa chọn rồi báo cáo con số đó” có tên trong thống kê: lời nguyền của người thắng cuộc (winner’s curse). Nó càng nặng khi bạn thử càng nhiều phương án và mỗi phép đo càng nhiễu.
Vậy vì sao vẫn phải giữ phần cắt riêng? Vì lập luận đúng ngay cả khi phép đo chưa thấy, và vì trong công việc thật bạn sẽ không thử 6 phương án mà thử hàng trăm: sáu loại mô hình, chục bộ tham số mỗi loại, vài cách xử lý dữ liệu. Thử càng nhiều thì con số “tốt nhất” càng lạc quan giả tạo. Lúc đó lời nguyền không còn nhỏ nữa.
Bài 37 sẽ cho bạn thấy một dạng nhìn trộm khác, tinh vi hơn nhiều và có sức tàn phá lớn hơn hẳn — nó xảy ra ngay cả khi bạn đã chia dữ liệu đúng bài bản.
⚠️ Ba sai lầm hay gặp khi mới dùng kiểm định chéo:
- Quên
shuffle=True. Mặc địnhKFoldkhông xáo trộn. Nếu dữ liệu đang xếp theo thứ tự nào đó — theo thời gian, theo tên, theo nhãn — thì các phần sẽ lệch hẳn nhau và kết quả vô nghĩa. - Chạy kiểm định chéo trên toàn bộ dữ liệu rồi mới cắt phần cuối. Sai thứ tự. Phải cắt trước, rồi mới làm mọi thứ khác trên phần còn lại.
- Chấm lại nhiều lần trên phần cắt riêng. Nếu bạn xem kết quả rồi quay lại chỉnh mô hình rồi chấm lại, phần cắt riêng đã trở thành một phần của quá trình chọn — và mất giá trị. Nó chỉ được mở ra một lần.
📝 Bảng ghi nhớ
| Việc | Câu lệnh | Ghi chú |
|---|---|---|
| Nhập K-fold | from sklearn.model_selection import KFold | Cùng ngăn với train_test_split |
| Tạo bộ chia | KFold(n_splits=5, shuffle=True, random_state=1) | Thiếu shuffle là hỏng |
| Lặp qua các lượt | for i_on, i_thi in kf.split(X): | Trả về chỉ số dòng, không phải dữ liệu |
| Điểm cuối cùng | np.mean(diem) | Trung bình K lần chấm |
| Đường tắt | cross_val_score(..., scoring="neg_mean_squared_error") | Nhớ đổi dấu lại khi đọc |
| Phần làm việc | ~80% — học và chọn | Kiểm định chéo chạy trong này |
| Phần cắt riêng | ~20% — chỉ để báo cáo | Mở ra đúng một lần |
| Sau khi chọn xong | Huấn luyện lại trên toàn bộ phần làm việc | Bước hay bị quên |
✎ Kiểm tra nhanh
Câu 1. Vì sao chọn bậc dựa trên một lần chia lại bấp bênh?
- Vì
train_test_splitcó lỗi - Vì mô hình bậc cao luôn thắng
- Vì đề thi chỉ có vài điểm, nên có thể tình cờ toàn điểm dễ hoặc toàn điểm khó
- Vì chưa đặt
random_state
Câu 2. Trong K-fold với K = 5, mỗi điểm dữ liệu được đem đi thi mấy lần?
- 5 lần
- Đúng 1 lần
- 4 lần
- Tùy vào
random_state
Câu 3. KFold(n_splits=5) mà quên shuffle=True thì sao?
- Chương trình báo lỗi
- Kết quả không đổi
- Chạy chậm hơn 5 lần
- Các phần bị chia theo đúng thứ tự dữ liệu đang có, nên có thể lệch hẳn nhau
KFold không xáo trộn. Với bộ nhiệt độ xếp theo giờ, phần đầu sẽ toàn buổi sáng và phần cuối toàn buổi tối — mô hình học buổi sáng rồi bị bắt đoán buổi tối. Chương trình vẫn chạy êm, chỉ có kết quả là vô nghĩa.Câu 4. Vì sao không nên báo cáo chính con số kiểm định chéo đã dùng để chọn bậc?
- Vì nó được chọn ra chính vì nó thấp nhất, nên đẹp hơn thực lực một chút
- Vì kiểm định chéo luôn tính sai
- Vì nó được đo trên dữ liệu huấn luyện
- Vì nó là số âm
Câu 5. Sau khi kiểm định chéo chọn được bậc 2, bước tiếp theo là:
- Báo cáo luôn điểm kiểm định chéo
- Chạy lại kiểm định chéo với
random_statekhác cho chắc - Huấn luyện lại bậc 2 trên toàn bộ phần làm việc, rồi chấm một lần trên phần cắt riêng
- Thử thêm bậc 7, 8, 9
Câu 6. Kiểm định chéo chọn đúng bậc 2 ở 11 trên 12 lần thử. Cách hiểu đúng là:
- Kiểm định chéo luôn cho kết quả đúng
- Nó ổn định hơn hẳn cách chia một lần (8/12), nhưng vẫn không phải là bảo đảm
- Cách chia một lần đáng tin hơn vì đơn giản hơn
- Cần tăng K lên bằng số dòng thì mới đúng được
💻 Thực hành: tự tay so hai cách chấm
▶ Cách dùng: bấm Khởi động Python một lần (khoảng 15 giây), đợi báo sẵn sàng rồi bấm Chạy code. Dữ liệu giống hệt Bài 35 nên bạn có thể đối chiếu cả hai bài.
💡 Thử thách (làm được cả ba là bạn đã nắm chắc bài):
- Bỏ xáo trộn. Đổi
shuffle=Truethànhshuffle=Falsetrongkiem_dinh_cheorồi chạy lại. Bậc nào được chọn? Nhìn lại bảng dữ liệu và giải thích: khi không xáo, phần thứ nhất gồm những giờ nào, phần cuối gồm những giờ nào? - Đổi số phần. Thử
K = 3,K = 7, rồiK = 14. VớiK = 14thì mỗi lượt có mấy điểm đi thi — và cách đó có tên riêng là gì (gợi ý: Lớp C phần 2)? Số lần chọn đúng bậc 2 thay đổi ra sao? - Làm cho đủ quy trình. Thêm bước cắt riêng 20% ngay từ đầu bằng
train_test_split, chỉ chạy kiểm định chéo trên 80% còn lại, rồi chấm một lần trên phần cắt riêng. Con số cuối cùng cao hơn hay thấp hơn điểm kiểm định chéo? Chạy với vàirandom_statekhác nhau — kết luận của bạn có giữ nguyên không?
Bài tiếp theo: ba bài vừa rồi ta đã học cách giấu dữ liệu cho kỹ. Nhưng có một kiểu “nhìn trộm” xảy ra ngay cả khi bạn chia dữ liệu hoàn toàn đúng bài bản — nó len vào từ những chỗ không ai ngờ, và có thể khiến mô hình đạt điểm gần như hoàn hảo rồi thất bại thảm hại ngoài đời thực. Bài 37 nói về rò rỉ dữ liệu, cái bẫy tinh vi nhất của cả chặng.