Overfitting và chia train/val/test không rò rỉ: đọc đường loss, tìm ảnh trùng
Nhận biết quá khớp qua loss train/val, chia dữ liệu theo lô và ngày, tìm ảnh trùng bằng dHash, đo hiệu quả dropout, augmentation, early stopping.
Học xong bài này, bạn sẽ
- Nhận biết overfitting qua đường loss
- Chia dữ liệu không rò rỉ
- Các cách chống quá khớp
Nên học trước: Dataset, DataLoader và augmentation
Nội dung bài viết
Mô hình đạt 99% trên máy của bạn, mang xuống nhà máy chỉ còn 80%. Thủ phạm gần như luôn là một trong hai thứ: overfitting (mô hình học thuộc dữ liệu huấn luyện thay vì học quy luật) hoặc chia dữ liệu sai (tập kiểm tra chứa ảnh "họ hàng" của ảnh huấn luyện, nên con số đẹp giả tạo). Ở bài "Dataset, DataLoader và augmentation", bạn đã có đường ống nạp ảnh và augmentation an toàn. Bài này dạy cách đọc đường loss để nhận ra overfitting, cách chia train/validation/test theo lô, theo chi tiết, theo ngày để không rò rỉ, cách tìm ảnh trùng và gần trùng bằng hash ảnh, và đo bằng số hiệu quả của các biện pháp chống quá khớp. Đây là bước chuẩn bị trực tiếp cho "Dự án chặng 10: phân loại ảnh OK/NG bằng transfer learning".
1. Underfitting, vừa khớp, overfitting
Mục tiêu của huấn luyện không phải là đúng trên dữ liệu huấn luyện, mà là tổng quát hóa: đúng trên ảnh mô hình chưa từng thấy, chụp ở ca khác, lô khác. Ba trạng thái:
| Trạng thái | Loss train | Loss val | Nguyên nhân thường gặp | Hướng xử lý |
|---|---|---|---|---|
| Underfitting (chưa khớp) | Cao | Cao, gần bằng train | Mô hình quá nhỏ, học quá ít epoch, learning rate sai, đầu vào chưa chuẩn hóa, ROI không chứa lỗi | Mô hình lớn hơn, huấn luyện lâu hơn, sửa dữ liệu |
| Vừa khớp | Thấp | Thấp, gần train | Giữ checkpoint này | |
| Overfitting (quá khớp) | Rất thấp, tiến về 0 | Giảm rồi tăng trở lại; khoảng cách với train ngày càng lớn | Ít dữ liệu so với độ phức tạp mô hình, nhãn nhiễu, học quá lâu | Thêm dữ liệu, augmentation, regularization, early stopping |
Overfitting đặc biệt dễ xảy ra ở nhà máy vì hai lý do: ảnh NG ít (vài chục ảnh mỗi loại lỗi), và nhãn có nhiễu (hai QC đánh giá khác nhau với vết "lưng chừng"). Mạng nơ-ron có đủ tham số để học thuộc cả hai.
2. Thí nghiệm: một mô hình nhỏ quá khớp
Dùng lại bộ ảnh xám 32×32 của bài "PyTorch cơ bản: tensor, autograd, vòng lặp huấn luyện": bề mặt kim loại có vân, ảnh NG có một vết xước tối. Lần này mô phỏng điều kiện nhà máy thật: chỉ có 60 ảnh train (thêm 180 ảnh dự phòng để thử nghiệm "thêm dữ liệu" ở mục 6), và khoảng 10% nhãn train bị gán nhầm (tính trên cả 240 ảnh; riêng 60 ảnh đầu có 2 ảnh nhầm). Tập val 300 ảnh có nhãn đúng.
import cv2
import numpy as np
import torch
rng = np.random.default_rng(0)
def tao_anh(ng, rng):
van = rng.normal(0, 0.04, (32, 1)) + rng.normal(0, 0.03, (32, 32)) # vân ngang + nhiễu
anh = (0.55 + rng.normal(0, 0.03) + van).astype(np.float32)
if ng: # vết xước tối ngẫu nhiên
x1, y1 = (int(v) for v in rng.integers(4, 28, 2))
goc, dai = rng.uniform(0, np.pi), rng.integers(6, 14)
x2, y2 = int(x1 + dai * np.cos(goc)), int(y1 + dai * np.sin(goc))
cv2.line(anh, (x1, y1), (x2, y2), float(anh.mean() - 0.18), 1)
return np.clip(anh, 0, 1)
def tao_bo(n):
nhan = rng.permutation(np.arange(n) % 2)
anh = np.stack([tao_anh(bool(k), rng) for k in nhan])
return torch.from_numpy(anh).unsqueeze(1), torch.from_numpy(nhan).long()
X_tr, y_tr = tao_bo(240) # 60 ảnh đầu dùng làm train, 180 ảnh sau là "dữ liệu thu thêm"
X_val, y_val = tao_bo(300)
nham = torch.from_numpy(rng.random(240) < 0.1)
y_tr = torch.where(nham, 1 - y_tr, y_tr) # ~10% nhãn train bị gán nhầm
mean, std = X_tr.mean(), X_tr.std() # chuẩn hóa bằng thống kê của train
torch.save({"X_tr": (X_tr - mean) / std, "y_tr": y_tr, "X_val": (X_val - mean) / std, "y_val": y_val},
"okng_nho.pt")
print("train:", tuple(X_tr[:60].shape), "| nhãn nhầm trong 60 ảnh đầu:", int(nham[:60].sum()))
print("val :", tuple(X_val.shape))
train: (60, 1, 32, 32) | nhãn nhầm trong 60 ảnh đầu: 2
val : (300, 1, 32, 32)
Hàm huấn luyện dưới đây ghi loss train, loss val và độ chính xác val sau mỗi epoch; các tham số cho phép bật từng biện pháp chống quá khớp ở mục 6. Với 60 ảnh, mỗi epoch là một batch duy nhất.
import numpy as np
import torch
from torch import nn
torch.set_num_threads(2)
class CNNNho(nn.Module):
def __init__(self, dropout=0.0):
super().__init__()
self.dac_trung = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.AdaptiveMaxPool2d(1), nn.Flatten())
self.dropout = nn.Dropout(dropout)
self.dau_ra = nn.Linear(32, 2)
def forward(self, x):
return self.dau_ra(self.dropout(self.dac_trung(x)))
def huan_luyen(so_anh=60, weight_decay=0.0, dropout=0.0, augment=False, so_epoch=120):
d = torch.load("okng_nho.pt", weights_only=True)
X, y = d["X_tr"][:so_anh], d["y_tr"][:so_anh]
torch.manual_seed(0)
g = torch.Generator().manual_seed(0)
model = CNNNho(dropout)
toi_uu = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=weight_decay)
ham_loss = nn.CrossEntropyLoss()
lich_su, tot_nhat = [], (float("inf"), None, 0)
for epoch in range(1, so_epoch + 1):
model.train()
xb = X
if augment: # dịch ±4 px và lật ngang ngẫu nhiên
dx, dy = torch.randint(-4, 5, (2,), generator=g).tolist()
xb = torch.roll(X, (dy, dx), dims=(2, 3))
if torch.rand(1, generator=g).item() < 0.5:
xb = xb.flip(3)
loss = ham_loss(model(xb), y)
toi_uu.zero_grad()
loss.backward()
toi_uu.step()
model.eval()
with torch.no_grad():
loss_tr = ham_loss(model(X), y).item()
logit = model(d["X_val"])
loss_val = ham_loss(logit, d["y_val"]).item()
acc_val = (logit.argmax(1) == d["y_val"]).float().mean().item()
lich_su.append((epoch, loss_tr, loss_val, acc_val))
if loss_val < tot_nhat[0]: # nhớ checkpoint tốt nhất theo loss val
tot_nhat = (loss_val, {k: v.clone() for k, v in model.state_dict().items()}, epoch)
return np.array(lich_su), tot_nhat
ls, (best_loss, best_state, best_epoch) = huan_luyen()
print("epoch | loss train | loss val | val đúng")
for e, ltr, lv, av in ls[[0, 9, 19, 29, 39, 49, 59, 79, 99, 119]]:
print(f"{int(e):5d} | {ltr:10.4f} | {lv:8.3f} | {av:7.1%}")
print(f"loss val thấp nhất {best_loss:.3f} ở epoch {best_epoch}")
epoch | loss train | loss val | val đúng
1 | 0.6819 | 0.693 | 50.0%
10 | 0.5884 | 0.642 | 84.7%
20 | 0.3076 | 0.461 | 89.7%
30 | 0.0963 | 0.274 | 91.3%
40 | 0.0249 | 0.211 | 92.3%
50 | 0.0068 | 0.236 | 90.7%
60 | 0.0029 | 0.224 | 92.0%
80 | 0.0012 | 0.246 | 91.3%
100 | 0.0008 | 0.258 | 91.0%
120 | 0.0006 | 0.266 | 90.3%
loss val thấp nhất 0.206 ở epoch 46
Đọc bảng như đọc một biểu đồ:
- Epoch 1–30: cả hai loss cùng giảm, độ chính xác val tăng nhanh. Mô hình đang học đặc trưng thật (vết xước).
- Quanh epoch 40–50: loss val chạm đáy. Đây là checkpoint tốt nhất.
- Sau đó: loss train tiếp tục lao về gần 0, tức là mô hình đã đúng cả 2 ảnh bị gán nhãn nhầm (trái với vết xước thật trên ảnh), nghĩa là nó đã học thuộc chúng cùng với nhiễu của từng ảnh. Loss val tăng trở lại và độ chính xác val giảm dần. Đó là chữ ký của overfitting.
Lưu ý loss val tăng mạnh hơn độ chính xác val giảm: mô hình quá khớp trở nên quá tự tin, khi sai thì sai với xác suất 0,99. Với nhà máy, đây là điều tệ: điểm số kém tin cậy làm việc chọn ngưỡng OK/NG khó hơn. Vì vậy hãy theo dõi loss val, không chỉ độ chính xác.
3. Ba tập dữ liệu và vai trò của từng tập
| Tập | Dùng để | Tỉ lệ gợi ý | Không được |
|---|---|---|---|
| Train | Cập nhật trọng số (gradient chỉ tính trên tập này) | 60–80% | |
| Validation (val) | Chọn checkpoint, so sánh kiến trúc, chỉnh learning rate, augmentation, chọn ngưỡng OK/NG, early stopping | 10–20% | Dùng để báo cáo kết quả cuối cùng: bạn đã "tối ưu theo" nó nên nó lạc quan |
| Test | Đánh giá cuối cùng, một lần, trước khi nghiệm thu | 10–20% | Nhìn kết quả rồi quay lại chỉnh mô hình. Làm thế thì tập test trở thành tập val thứ hai |
Tỉ lệ phần trăm ít quan trọng hơn số ảnh NG tuyệt đối trong val và test. Với 20 ảnh NG trong test, mỗi ảnh lọt làm recall NG giảm 5 điểm phần trăm: con số 95% hay 90% chỉ cách nhau một ảnh. Nếu NG hiếm, hãy ưu tiên dồn đủ NG cho val/test (ví dụ tối thiểu 30–50 ảnh mỗi loại lỗi quan trọng) và bù cho train bằng augmentation. Bài "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time" ở chặng 11 tính khoảng tin cậy cho những con số này.
Ngoài ba tập trên, hãy giữ một tập "ngoài phòng thí nghiệm": ảnh chụp vào ngày khác, ca khác, lô vật liệu khác so với mọi ảnh huấn luyện. Đó là thước đo gần nhất với hiệu năng thật trên dây chuyền, và về sau trở thành bộ ảnh vàng (golden set) để nghiệm thu mọi phiên bản mô hình.
4. Rò rỉ dữ liệu: chia theo nhóm, không chia theo ảnh
Rò rỉ dữ liệu (data leakage) là khi tập val/test chứa thông tin mà mô hình sẽ không có khi chạy thật. Dạng phổ biến nhất trong vision: một chi tiết được chụp nhiều lần (nhiều góc, chụp lại khi kiểm tra lại, nhiều khung hình liên tiếp từ video), rồi các ảnh được chia ngẫu nhiên từng ảnh. Ảnh của cùng một chi tiết rơi vào cả train lẫn test; mô hình chỉ cần "nhớ mặt" chi tiết là đoán đúng.
Thí nghiệm sau đo mức lạc quan giả đó. 80 chi tiết, mỗi chi tiết chụp 5 lần (lệch 1 px, độ sáng và nhiễu khác nhau). Mỗi chi tiết có vân bề mặt riêng (như dấu vân tay), còn lỗi là một chấm mờ khó thấy. Bộ phân loại 1-NN (láng giềng gần nhất) là mô hình "học thuộc" cực đoan: nó trả lời nhãn của ảnh train giống nhất.
import cv2
import numpy as np
from sklearn.model_selection import GroupShuffleSplit, ShuffleSplit
from sklearn.neighbors import KNeighborsClassifier
rng = np.random.default_rng(1)
anh, nhan, ma_ct = [], [], []
for ct in range(80): # 80 chi tiết
van = cv2.GaussianBlur(rng.normal(0, 1, (36, 36)).astype(np.float32), (0, 0), 2)
van = van / van.std() * 20 # vân bề mặt riêng của chi tiết
ng = ct % 2
for lan in range(5): # 5 lần chụp mỗi chi tiết
dx, dy = rng.integers(0, 3, 2)
a = 120 + van[dy:dy + 32, dx:dx + 32] + rng.normal(0, 4) + rng.normal(0, 2, (32, 32))
if ng:
cv2.circle(a, (16 + int(dx), 16 + int(dy)), 3, float(a.mean() - 6), -1) # lỗi mờ
anh.append(a.ravel())
nhan.append(ng)
ma_ct.append(ct)
X, y, nhom = np.array(anh), np.array(nhan), np.array(ma_ct)
def thu(bo_chia, ten):
kq = []
nhom_arg = {"groups": nhom} if "Group" in type(bo_chia).__name__ else {}
for tr, te in bo_chia.split(X, y, **nhom_arg):
knn = KNeighborsClassifier(n_neighbors=1).fit(X[tr], y[tr])
kq.append(knn.score(X[te], y[te]))
chung = len(set(nhom[tr]) & set(nhom[te]))
print(f"{ten:24s}: đúng {np.mean(kq):.1%} | số chi tiết có ảnh ở cả train và test (lần chia cuối): {chung}")
thu(ShuffleSplit(n_splits=5, test_size=0.3, random_state=0), "Chia ngẫu nhiên theo ảnh")
thu(GroupShuffleSplit(n_splits=5, test_size=0.3, random_state=0), "Chia theo mã chi tiết")
Chia ngẫu nhiên theo ảnh: đúng 100.0% | số chi tiết có ảnh ở cả train và test (lần chia cuối): 69
Chia theo mã chi tiết : đúng 53.8% | số chi tiết có ảnh ở cả train và test (lần chia cuối): 0
Cùng dữ liệu, cùng mô hình: chia ngẫu nhiên báo một con số rất đẹp, chia theo chi tiết cho thấy sự thật là mô hình gần như đoán mò với loại lỗi mờ này. Mạng nơ-ron sâu không phải 1-NN, nhưng với ít dữ liệu nó cũng học thuộc rất giỏi, và hiện tượng giống hệt. Quy tắc: mọi ảnh có chung nguồn gốc phải nằm cùng một tập.
| Nguồn rò rỉ | Ví dụ | Chia theo |
|---|---|---|
| Nhiều ảnh cùng một chi tiết | Chụp 4 mặt, chụp lại khi kiểm tra lại, khung hình liên tiếp của video | Mã chi tiết (số seri) |
| Cùng lô vật liệu, cùng khuôn | Vân bề mặt, màu sắc đặc trưng của lô; lỗi đặc trưng của một khuôn | Mã lô, mã khuôn |
| Thời gian | Đèn lão hóa, camera được chỉnh lại; mô hình phải chạy trên dữ liệu tương lai | Ngày: train là dữ liệu cũ, test là dữ liệu mới nhất |
| Augmentation lưu ra đĩa trước khi chia | Biến thể của cùng ảnh nằm ở cả hai tập | Chia trước, augment sau (chỉ train) |
| Thống kê tính trên toàn bộ dữ liệu | Mean/std chuẩn hóa, ngưỡng chọn trên cả test | Chỉ tính trên train (ngưỡng: trên val) |
Trong code, giữ một bảng metadata cho từng ảnh (đường dẫn, nhãn, mã lô, ngày) và chia theo nhóm. StratifiedGroupKFold vừa giữ nhóm nguyên vẹn vừa cố giữ tỉ lệ OK/NG giữa các phần; chia theo thời gian thì chỉ cần sắp theo ngày:
import numpy as np
from sklearn.model_selection import StratifiedGroupKFold
rng = np.random.default_rng(2)
n = 600
lo = rng.integers(0, 20, n) # 20 lô sản xuất
ngay = np.sort(rng.integers(0, 60, n)) # ngày chụp 0..59
nhan = (rng.random(n) < 0.15 + 0.02 * (lo % 5)).astype(int) # tỉ lệ NG khác nhau giữa các lô
# Cách 1: chia theo lô, giữ tỉ lệ NG gần nhau. 5 phần: 1 phần test, 1 phần val, 3 phần train
skf = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=0)
phan = [te for _, te in skf.split(np.zeros(n), nhan, groups=lo)]
test, val = phan[0], phan[1]
train = np.concatenate(phan[2:])
for ten, idx in (("train", train), ("val", val), ("test", test)):
print(f"{ten:5s}: {len(idx):3d} ảnh, NG {nhan[idx].mean():.1%}, lô {sorted(set(lo[idx].tolist()))}")
assert not set(lo[train]) & set(lo[test]) and not set(lo[val]) & set(lo[test])
# Cách 2: chia theo thời gian: 40 ngày đầu train, 10 ngày tiếp val, 10 ngày cuối test
tr_t, va_t, te_t = ngay < 40, (ngay >= 40) & (ngay < 50), ngay >= 50
print("theo ngày:", int(tr_t.sum()), int(va_t.sum()), int(te_t.sum()), "ảnh")
train: 360 ảnh, NG 17.2%, lô [0, 1, 2, 3, 7, 8, 10, 12, 13, 15, 16, 18]
val : 118 ảnh, NG 16.9%, lô [6, 9, 14, 19]
test : 122 ảnh, NG 15.6%, lô [4, 5, 11, 17]
theo ngày: 386 108 106 ảnh
Khi có cả thông tin lô và thời gian, cách an toàn nhất cho tập test là "lô mới, ngày mới": đúng tình huống mô hình sẽ gặp sau khi triển khai.
5. Tìm ảnh trùng và gần trùng bằng hash ảnh
Dù đã chia theo nhóm, bộ dữ liệu thật vẫn hay có ảnh trùng: cùng một file được chép vào hai thư mục, ảnh được lưu lại dưới tên khác, ảnh chụp lại cùng chi tiết nhưng mất thông tin mã. Hai công cụ:
- MD5 (hoặc SHA-256) của file: phát hiện file giống hệt từng byte. Nhanh, chắc chắn, nhưng chỉ cần lưu lại JPEG hoặc đổi một pixel là khác hash.
- Hash cảm nhận (perceptual hash) như dHash: thu nhỏ ảnh xám về 9×8 pixel, so sánh từng pixel với pixel bên phải, được 64 bit. Ảnh nhìn giống nhau cho hash gần nhau; khoảng cách Hamming (số bit khác nhau) nhỏ nghĩa là gần trùng.
Tạo bộ dữ liệu thử: 20 ảnh train; 10 ảnh test gồm 2 bản sao y hệt, 3 ảnh gần trùng (dịch 1 px, sáng hơn, lưu lại JPEG) và 5 ảnh mới.
from pathlib import Path
import cv2
import numpy as np
rng = np.random.default_rng(3)
def anh_chi_tiet(rng):
nen = cv2.GaussianBlur(rng.normal(0, 1, (96, 128)).astype(np.float32), (0, 0), 12)
a = 60 + nen / nen.std() * 12 + rng.normal(0, 4, (96, 128)) # nền chiếu sáng không đều + nhiễu
c = (int(rng.integers(30, 98)), int(rng.integers(30, 66)))
cv2.ellipse(a, c, (int(rng.integers(15, 30)), int(rng.integers(10, 25))), float(rng.uniform(0, 180)),
0, 360, 170, -1) # chi tiết sáng trên nền tối
cv2.circle(a, (int(rng.integers(10, 118)), int(rng.integers(10, 86))), 6, 110, -1)
return np.clip(a, 0, 255).astype(np.uint8)
for p in ("trung/train", "trung/test"):
Path(p).mkdir(parents=True, exist_ok=True)
goc = [anh_chi_tiet(rng) for _ in range(20)]
for i, a in enumerate(goc):
cv2.imwrite(f"trung/train/tr_{i:02d}.png", a)
cv2.imwrite("trung/test/te_00.png", goc[3]) # bản sao y hệt
cv2.imwrite("trung/test/te_01.png", goc[7])
cv2.imwrite("trung/test/te_02.png", np.roll(goc[11], 1, axis=1)) # dịch 1 px
cv2.imwrite("trung/test/te_03.png", cv2.add(goc[15], 12)) # sáng hơn
cv2.imwrite("trung/test/te_04.jpg", goc[18], [cv2.IMWRITE_JPEG_QUALITY, 80]) # lưu lại JPEG
for i in range(5, 10):
cv2.imwrite(f"trung/test/te_{i:02d}.png", anh_chi_tiet(rng)) # ảnh mới
print(len(list(Path("trung/train").iterdir())), "ảnh train,", len(list(Path("trung/test").iterdir())), "ảnh test")
20 ảnh train, 10 ảnh test
Hai hàm hash và phép so sánh mọi cặp train–test:
import hashlib
from pathlib import Path
import cv2
import numpy as np
def md5_file(p):
return hashlib.md5(Path(p).read_bytes()).hexdigest()
def dhash(p, kich_thuoc=8):
xam = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
nho = cv2.resize(xam, (kich_thuoc + 1, kich_thuoc), interpolation=cv2.INTER_AREA)
return (nho[:, 1:] > nho[:, :-1]).ravel() # 64 giá trị True/False
train = sorted(Path("trung/train").iterdir())
test = sorted(Path("trung/test").iterdir())
md5_train = {md5_file(p): p.name for p in train}
H_train = np.array([dhash(p) for p in train]) # (20, 64)
for p in test:
kc = np.count_nonzero(H_train != dhash(p), axis=1) # khoảng cách Hamming tới mọi ảnh train
j = int(kc.argmin())
if md5_file(p) in md5_train:
ket_luan = f"TRÙNG Y HỆT {md5_train[md5_file(p)]}"
elif kc[j] <= 10:
ket_luan = f"gần trùng {train[j].name}"
else:
ket_luan = "mới"
print(f"{p.name}: Hamming nhỏ nhất {kc[j]:2d} ({train[j].name}) → {ket_luan}")
te_00.png: Hamming nhỏ nhất 0 (tr_03.png) → TRÙNG Y HỆT tr_03.png
te_01.png: Hamming nhỏ nhất 0 (tr_07.png) → TRÙNG Y HỆT tr_07.png
te_02.png: Hamming nhỏ nhất 0 (tr_11.png) → gần trùng tr_11.png
te_03.png: Hamming nhỏ nhất 0 (tr_15.png) → gần trùng tr_15.png
te_04.jpg: Hamming nhỏ nhất 1 (tr_18.png) → gần trùng tr_18.png
te_05.png: Hamming nhỏ nhất 22 (tr_18.png) → mới
te_06.png: Hamming nhỏ nhất 19 (tr_09.png) → mới
te_07.png: Hamming nhỏ nhất 18 (tr_00.png) → mới
te_08.png: Hamming nhỏ nhất 22 (tr_18.png) → mới
te_09.png: Hamming nhỏ nhất 16 (tr_16.png) → mới
MD5 chỉ bắt được 2 bản sao y hệt; dHash bắt thêm 3 ảnh gần trùng, trong khi các ảnh mới cách xa (khoảng cách Hamming lớn). Ngưỡng khoảng cách phải chọn theo dữ liệu của bạn: in phân bố khoảng cách của các cặp chắc chắn khác nhau, chọn ngưỡng nằm dưới hẳn phần lớn các cặp đó, rồi mở ảnh ra xem các cặp bị đánh dấu. Ảnh công nghiệp rất giống nhau (cùng chi tiết, cùng nền), nên có thể hai chi tiết khác nhau cũng có hash gần nhau; khi đó hãy tính hash trên ROI vùng lỗi, hoặc dùng kích thước hash lớn hơn (16 cho 256 bit).
6. Các cách chống quá khớp: đo bằng số
Quay lại thí nghiệm mục 2. Bật lần lượt từng biện pháp, giữ nguyên mọi thứ khác, ghi loss val tốt nhất, epoch đạt được, và kết quả ở epoch cuối (nếu không có early stopping):
cau_hinh = {
"Gốc (60 ảnh)": {},
"Weight decay 0.5": {"weight_decay": 0.5},
"Dropout 0.5": {"dropout": 0.5},
"Augmentation dịch + lật": {"augment": True},
"Thêm dữ liệu (240 ảnh)": {"so_anh": 240},
}
print(f"{'cấu hình':26s}| loss val tốt nhất (epoch) | đúng tại đó | loss val cuối | đúng cuối")
for ten, tham_so in cau_hinh.items():
ls, (best_loss, _, best_ep) = huan_luyen(**tham_so)
print(f"{ten:26s}| {best_loss:6.3f} ({best_ep:3d}) | {ls[best_ep - 1, 3]:10.1%} "
f"| {ls[-1, 2]:13.3f} | {ls[-1, 3]:8.1%}")
cấu hình | loss val tốt nhất (epoch) | đúng tại đó | loss val cuối | đúng cuối
Gốc (60 ảnh) | 0.206 ( 46) | 92.7% | 0.266 | 90.3%
Weight decay 0.5 | 0.205 ( 44) | 93.3% | 0.238 | 90.7%
Dropout 0.5 | 0.107 (119) | 96.7% | 0.118 | 95.0%
Augmentation dịch + lật | 0.119 ( 92) | 95.7% | 0.120 | 95.7%
Thêm dữ liệu (240 ảnh) | 0.146 ( 80) | 96.0% | 0.153 | 95.7%
Kết quả cụ thể có thể lệch đôi chút trên máy khác, và mỗi cấu hình chỉ chạy một lần với một seed, nên chênh lệch một hai điểm phần trăm chưa có ý nghĩa. Xu hướng rõ ràng:
| Biện pháp | Cơ chế | Trong thí nghiệm | Ghi chú thực tế |
|---|---|---|---|
| Thêm dữ liệu đa dạng | Khó học thuộc nhiều ảnh; nhãn nhầm bị "pha loãng" | Loss val thấp, độ chính xác cao | Hiệu quả nhất và bền nhất. Thu thêm ảnh ở các lô, ca còn thiếu, đặc biệt ảnh NG |
| Augmentation an toàn | Mỗi epoch mô hình thấy biến thể khác, không học thuộc được từng pixel | Gần bằng thêm dữ liệu; loss train không về 0 | Chỉ biến đổi có thể xảy ra thật. Ở đây vân ngang không có hướng trái–phải nên lật ngang an toàn; với chi tiết có hướng thì không (bài "Dataset, DataLoader và augmentation") |
| Dropout | Tắt ngẫu nhiên neuron khi train, mạng không dựa vào một vài đặc trưng | Cải thiện rõ ở bài này | Hay đặt trước lớp fully-connected cuối; nhớ model.eval() khi đánh giá |
| Weight decay | Phạt trọng số lớn, mô hình "trơn" hơn | Tác dụng nhỏ với mạng nhỏ này | Giá trị phổ biến với AdamW: 0,01–0,1; thử trên val |
| Early stopping | Dừng và giữ checkpoint tại loss val thấp nhất | Lấy được cột "tốt nhất" thay vì cột "cuối" | Gần như miễn phí; luôn dùng |
| Transfer learning | Bắt đầu từ đặc trưng đã học trên hàng triệu ảnh | (dự án cuối chặng) | Cách chuẩn khi chỉ có vài trăm ảnh |
Không có biện pháp nào sửa được nhãn sai. Nếu nghi ngờ nhãn, lọc ra những ảnh train mà mô hình (huấn luyện có early stopping) tự tin trả lời ngược nhãn, đưa QC xem lại.
Early stopping trong vòng lặp huấn luyện
Hàm huan_luyen ở mục 2 đã lưu checkpoint tốt nhất nhưng vẫn chạy đủ 120 epoch. Trong dự án thật, dừng sớm khi loss val không cải thiện sau kien_nhan epoch (patience) để tiết kiệm thời gian, rồi nạp lại checkpoint tốt nhất:
import copy
import torch
from torch import nn
d = torch.load("okng_nho.pt", weights_only=True)
torch.manual_seed(0)
model = CNNNho(dropout=0.5)
toi_uu = torch.optim.AdamW(model.parameters(), lr=3e-3)
ham_loss = nn.CrossEntropyLoss()
X, y = d["X_tr"][:60], d["y_tr"][:60]
KIEN_NHAN = 20
best, best_state, best_epoch, cho = float("inf"), None, 0, 0
for epoch in range(1, 301):
model.train()
loss = ham_loss(model(X), y)
toi_uu.zero_grad()
loss.backward()
toi_uu.step()
model.eval()
with torch.no_grad():
loss_val = ham_loss(model(d["X_val"]), d["y_val"]).item()
if loss_val < best - 1e-4: # cải thiện đáng kể
best, best_epoch, cho = loss_val, epoch, 0
best_state = copy.deepcopy(model.state_dict())
else:
cho += 1
if cho >= KIEN_NHAN:
print(f"dừng ở epoch {epoch}: {KIEN_NHAN} epoch không cải thiện")
break
model.load_state_dict(best_state) # quay về checkpoint tốt nhất
torch.save(best_state, "tot_nhat.pt")
with torch.no_grad():
acc = (model(d["X_val"]).argmax(1) == d["y_val"]).float().mean().item()
print(f"checkpoint tốt nhất: epoch {best_epoch}, loss val {best:.3f}, đúng {acc:.1%}")
dừng ở epoch 139: 20 epoch không cải thiện
checkpoint tốt nhất: epoch 119, loss val 0.107, đúng 96.3%
Early stopping dùng tập val để quyết định, nên tập val đã bị "dùng". Đây chính là lý do cần tập test riêng để báo cáo kết quả cuối.
7. Sai lầm thường gặp
- Chia ngẫu nhiên theo ảnh khi một chi tiết, một lô có nhiều ảnh. Kết quả val/test lạc quan giả, có thể chênh hàng chục điểm phần trăm.
- Chọn ngưỡng, chọn epoch, chọn augmentation trên tập test, rồi báo cáo kết quả test như kết quả độc lập.
- Chỉ nhìn độ chính xác val, bỏ qua loss val đang tăng: mô hình đang mất độ tin cậy của điểm số.
- Lưu checkpoint epoch cuối thay vì checkpoint tốt nhất theo val.
- Tính mean/std, chọn ngưỡng hoặc lọc dữ liệu trên toàn bộ dữ liệu trước khi chia.
- Không kiểm tra trùng lặp khi gộp dữ liệu từ nhiều nguồn (nhiều đợt thu thập, nhiều người gán nhãn).
- Tập test cùng ngày, cùng lô với train: con số không nói gì về tuần sau.
8. Bài tập
Bài 1. Kiểm tra bộ dữ liệu của bạn: có ảnh trùng hoặc gần trùng giữa train và test không? Viết script nhận hai thư mục (duyệt cả thư mục con, mọi đuôi ảnh phổ biến), tính MD5 và dHash, in ra mọi cặp có khoảng cách Hamming ≤ ngưỡng, ghi kết quả ra CSV để mở ảnh kiểm tra, và in phân bố khoảng cách để chọn ngưỡng.
Xem đáp án
import csv
import hashlib
from pathlib import Path
import cv2
import numpy as np
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
def liet_ke(thu_muc):
return sorted(p for p in Path(thu_muc).rglob("*") if p.suffix.lower() in DUOI_ANH)
def dhash(p, kich_thuoc=8):
du_lieu = np.fromfile(str(p), np.uint8) # đọc được cả đường dẫn có dấu tiếng Việt trên Windows
xam = cv2.imdecode(du_lieu, cv2.IMREAD_GRAYSCALE)
if xam is None:
return None
nho = cv2.resize(xam, (kich_thuoc + 1, kich_thuoc), interpolation=cv2.INTER_AREA)
return (nho[:, 1:] > nho[:, :-1]).ravel()
def kiem_tra_trung(thu_muc_a, thu_muc_b, nguong=10, file_csv="cap_trung.csv"):
A, B = liet_ke(thu_muc_a), liet_ke(thu_muc_b)
md5 = {p: hashlib.md5(p.read_bytes()).hexdigest() for p in A + B}
ha = {p: dhash(p) for p in A + B}
hong = [p for p, h in ha.items() if h is None]
A = [p for p in A if ha[p] is not None]
B = [p for p in B if ha[p] is not None]
HA = np.array([ha[p] for p in A])
HB = np.array([ha[p] for p in B])
kc = np.count_nonzero(HA[:, None, :] != HB[None, :, :], axis=2) # ma trận (len A, len B)
cap = []
for i, j in zip(*np.nonzero(kc <= nguong)):
loai = "y hệt" if md5[A[i]] == md5[B[j]] else "gần trùng"
cap.append((str(A[i]), str(B[j]), int(kc[i, j]), loai))
cap.sort(key=lambda c: c[2])
with open(file_csv, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["anh_a", "anh_b", "hamming", "loai"])
w.writerows(cap)
print(f"{len(A)} ảnh × {len(B)} ảnh, {len(hong)} file không đọc được, {len(cap)} cặp ≤ {nguong} → {file_csv}")
print("phân bố khoảng cách nhỏ nhất của mỗi ảnh B:", np.bincount(kc.min(axis=0) // 8, minlength=9).tolist(),
"(nhóm 0-7, 8-15, ...)")
for c in cap:
print(f" {Path(c[0]).name} ↔ {Path(c[1]).name}: {c[2]:2d} ({c[3]})")
return cap
cap = kiem_tra_trung("trung/train", "trung/test")
20 ảnh × 10 ảnh, 0 file không đọc được, 5 cặp ≤ 10 → cap_trung.csv
phân bố khoảng cách nhỏ nhất của mỗi ảnh B: [5, 0, 5, 0, 0, 0, 0, 0, 0] (nhóm 0-7, 8-15, ...)
tr_03.png ↔ te_00.png: 0 (y hệt)
tr_07.png ↔ te_01.png: 0 (y hệt)
tr_11.png ↔ te_02.png: 0 (gần trùng)
tr_15.png ↔ te_03.png: 0 (gần trùng)
tr_18.png ↔ te_04.jpg: 1 (gần trùng)
Với hàng chục nghìn ảnh, ma trận khoảng cách vẫn nhỏ (10.000 × 2.000 × 64 bit ≈ 1,3 GB dạng bool nên hãy chia theo khối, hoặc đóng gói bit bằng np.packbits). Cách dùng thực tế: chạy cho train–val, train–test và val–test; với mỗi cặp bị đánh dấu, quyết định xóa ảnh khỏi val/test hoặc chuyển cả nhóm (chi tiết, lô) về một tập. Chạy thêm với thu_muc_a = thu_muc_b = train để tìm trùng lặp bên trong train (bỏ qua cặp một ảnh với chính nó).
Bài 2. Bốn lần huấn luyện cho các bảng sau (loss train / loss val ở epoch 5, 20, 50). Chẩn đoán mỗi trường hợp và đề xuất việc làm tiếp: (A) 0,69/0,69 → 0,69/0,69 → 0,69/0,69; (B) 0,40/0,42 → 0,15/0,18 → 0,08/0,11; (C) 0,30/0,35 → 0,05/0,40 → 0,001/0,90; (D) 0,30/0,05 → 0,20/0,03 → 0,15/0,02.
Xem đáp án
| Chẩn đoán | Việc làm tiếp | |
|---|---|---|
| A | Không học gì: 0,69 ≈ ln 2, đoán mò hai lớp | Kiểm tra chuẩn hóa đầu vào, learning rate, nhãn có khớp ảnh không, ROI có chứa lỗi không; thử overfit một batch nhỏ để chắc vòng lặp đúng |
| B | Học tốt, chưa quá khớp, vẫn đang giảm | Huấn luyện thêm epoch với early stopping |
| C | Overfitting rõ từ khoảng epoch 5–20 | Lấy checkpoint tốt nhất (early stopping), thêm dữ liệu, augmentation an toàn, dropout; kiểm tra nhãn train |
| D | Loss val thấp hơn hẳn train và gần 0: đáng ngờ | Kiểm tra rò rỉ (ảnh trùng, chia theo ảnh), tập val có quá dễ hay quá nhỏ không; augmentation quá mạnh cũng làm loss train cao hơn val |
Bài 3. Nhà máy có 3 dây chuyền, dữ liệu 4 tháng, 25 lô. Tháng thứ 3, camera dây chuyền 2 được thay. Mô hình sẽ chạy trên cả 3 dây chuyền từ tháng thứ 5. Đề xuất cách chia train/val/test.
Xem đáp án
Test phải mô phỏng "tương lai": lấy các lô của tháng 4 trên cả 3 dây chuyền (chia theo lô, không lấy ảnh lẻ). Val: một số lô của tháng 3–4 không nằm trong test, có đủ cả 3 dây chuyền. Train: phần còn lại. Bắt buộc có ảnh dây chuyền 2 sau khi thay camera trong cả train, val và test, vì đó là điều kiện mô hình sẽ gặp; ảnh trước khi thay camera vẫn giữ trong train nhưng báo cáo kết quả val/test tách theo dây chuyền để thấy dây chuyền nào yếu. Chạy script tìm trùng ở Bài 1 giữa các tập trước khi huấn luyện.
Tóm tắt
- Overfitting: loss train tiến về 0 trong khi loss val chạm đáy rồi tăng lại; mô hình học thuộc, kể cả nhãn nhầm, và trở nên quá tự tin. Theo dõi loss val, không chỉ độ chính xác.
- Train để học, val để chọn (checkpoint, siêu tham số, ngưỡng), test chỉ để báo cáo cuối một lần. Số ảnh NG tuyệt đối trong val/test quyết định độ tin cậy của con số.
- Chia theo nhóm (chi tiết, lô, khuôn) và theo thời gian, không chia ngẫu nhiên từng ảnh; tính chuẩn hóa chỉ trên train. Chia sai có thể làm con số đẹp giả tạo hàng chục điểm phần trăm.
- MD5 tìm file trùng y hệt, dHash (64 bit, khoảng cách Hamming) tìm ảnh gần trùng; luôn mở ảnh kiểm tra các cặp bị đánh dấu.
- Chống quá khớp: thêm dữ liệu, augmentation an toàn, dropout, weight decay, early stopping (giữ checkpoint tốt nhất), transfer learning. Không biện pháp nào sửa được nhãn sai.
Bài tiếp theo: Dự án chặng 10: phân loại ảnh OK/NG bằng transfer learning, nơi bạn ghép mọi thứ của chặng thành một mô hình hoàn chỉnh.
Bài tập thực hành
- Kiểm tra bộ dữ liệu của bạn: có ảnh trùng hoặc gần trùng giữa train và test không? Viết script tìm bằng hash ảnh.
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.


