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

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.

Overfitting và chia train/val/test không rò rỉ: đọc đường loss, tìm ảnh trùng

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
  1. 1. Underfitting, vừa khớp, overfitting
  2. 2. Thí nghiệm: một mô hình nhỏ quá khớp
  3. 3. Ba tập dữ liệu và vai trò của từng tập
  4. 4. Rò rỉ dữ liệu: chia theo nhóm, không chia theo ảnh
  5. 5. Tìm ảnh trùng và gần trùng bằng hash ảnh
  6. 6. Các cách chống quá khớp: đo bằng số
  7. 7. Sai lầm thường gặp
  8. 8. Bài tập
  9. Tóm tắ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".

Mục tiêu cuối bàiNhận ra underfitting, overfitting qua bảng loss train/val; hiểu vai trò của từng tập train, validation, test; chia dữ liệu theo nhóm (lô, mã chi tiết, ngày) bằng scikit-learn và đo được mức "lạc quan giả" khi chia ngẫu nhiên; viết script tìm ảnh trùng và gần trùng giữa các tập bằng MD5 và dHash; so sánh bằng số weight decay, dropout, augmentation, thêm dữ liệu và early stopping. Code dùng PyTorch 2.14, scikit-learn 1.9, OpenCV.

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áiLoss trainLoss valNguyên nhân thường gặpHướng xử lý
Underfitting (chưa khớp)CaoCao, gần bằng trainMô 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ỗiMô hình lớn hơn, huấn luyện lâu hơn, sửa dữ liệu
Vừa khớpThấpThấp, gần trainGiữ checkpoint này
Overfitting (quá khớp)Rất thấp, tiến về 0Giả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âuThê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.

Đọc nhanh các dạng đường lossLoss val dao động mạnh từ epoch này sang epoch khác: tập val quá nhỏ hoặc learning rate lớn. Loss val thấp hơn loss train: thường do dropout/augmentation chỉ bật lúc train (bình thường), nhưng nếu chênh nhiều hãy nghi tập val dễ hơn hoặc bị rò rỉ. Cả hai đứng yên ở ln 2 ≈ 0,693 với hai lớp cân bằng: mô hình đang đoán mò (xem lại chuẩn hóa, learning rate, nhãn).

3. Ba tập dữ liệu và vai trò của từng tập

TậpDùng đểTỉ lệ gợi ýKhông được
TrainCậ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 stopping10–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 thu10–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ếtChụp 4 mặt, chụp lại khi kiểm tra lại, khung hình liên tiếp của videoMã chi tiết (số seri)
Cùng lô vật liệu, cùng khuônVân bề mặt, màu sắc đặc trưng của lô; lỗi đặc trưng của một khuônMã 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 laiNgày: train là dữ liệu cũ, test là dữ liệu mới nhất
Augmentation lưu ra đĩa trước khi chiaBiến thể của cùng ảnh nằm ở cả hai tậpChia trước, augment sau (chỉ train)
Thống kê tính trên toàn bộ dữ liệuMean/std chuẩn hóa, ngưỡng chọn trên cả testChỉ 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).

Ảnh gần trùng trong cùng tập train cũng có hạiChúng không làm sai số đo, nhưng làm tập train "nhỏ hơn bạn tưởng" và khuyến khích học thuộc. Khi thu thập từ video hoặc camera chụp liên tục, hãy lọc các khung hình gần trùng trước khi gán nhãn: vừa đỡ công gán nhãn, vừa tránh rò rỉ.

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ápCơ chếTrong thí nghiệmGhi chú thực tế
Thêm dữ liệu đa dạngKhó học thuộc nhiều ảnh; nhãn nhầm bị "pha loãng"Loss val thấp, độ chính xác caoHiệ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ànMỗi epoch mô hình thấy biến thể khác, không học thuộc được từng pixelGần bằng thêm dữ liệu; loss train không về 0Chỉ 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")
DropoutTắt ngẫu nhiên neuron khi train, mạng không dựa vào một vài đặc trưngCải thiện rõ ở bài nàyHay đặt trước lớp fully-connected cuối; nhớ model.eval() khi đánh giá
Weight decayPhạt trọng số lớn, mô hình "trơn" hơnTác dụng nhỏ với mạng nhỏ nàyGiá trị phổ biến với AdamW: 0,01–0,1; thử trên val
Early stoppingDừng và giữ checkpoint tại loss val thấp nhấtLấ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 learningBắ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ánViệc làm tiếp
AKhông học gì: 0,69 ≈ ln 2, đoán mò hai lớpKiể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
BHọc tốt, chưa quá khớp, vẫn đang giảmHuấn luyện thêm epoch với early stopping
COverfitting rõ từ khoảng epoch 5–20Lấy checkpoint tốt nhất (early stopping), thêm dữ liệu, augmentation an toàn, dropout; kiểm tra nhãn train
DLoss 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

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

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í.