Skip to content

Data augmentation trong CV

This content is not available in your language yet.

Sau bài này, bạn sẽ:

  • Hiểu tăng cường dữ liệu (data augmentation) là dạy mô hình điều gì không được làm đổi nhãn, và vì sao nó là một dạng regularization.
  • Chọn được phép biến đổi hình học (lật, cắt ngẫu nhiên, xoay, co giãn) và quang học (độ sáng, tương phản, màu, nhiễu, làm mờ) hợp với bài toán, và nhận ra khi nào một phép làm sai nhãn.
  • Tự viết được cutout, mixup và CutMix, kể cả công thức trộn nhãn của mixup, và biết RandAugment rút việc chọn chính sách augmentation về hai số NN, MM.
  • Cài đặt được một pipeline torchvision.transforms.v2 biến đổi ảnh, mask và hộp bao cùng lúc, và chỉ augment tập train.

Bạn huấn luyện một mạng nhỏ nhận chữ số viết tay trên 300 ảnh 8×88 \times 8 của load_digits. Trên ảnh test, nó đúng khoảng 94%. Rồi bạn dịch mỗi ảnh test đi tối đa 1 pixel, ngang hoặc dọc. Với mắt người, chữ số không đổi gì. Với mô hình, độ chính xác rơi xuống khoảng 40% (ô scikit-learn ở mục Ví dụ code cho đúng các số này).

Chuyện gì đã xảy ra? Mô hình chưa bao giờ thấy một chữ số 6 nằm lệch sang phải. Nó học “pixel ở cột 3 đậm thì là 6”, không học “hình có vòng tròn ở dưới thì là 6”. Với 300 ảnh, cả hai quy tắc đều giải thích dữ liệu train như nhau, và mô hình chọn quy tắc dễ hơn.

Cách sửa đơn giản nhất: cho mô hình thấy chữ số ở nhiều vị trí. Bạn không cần thu thêm ảnh. Bạn tự dịch các ảnh train đã có, giữ nguyên nhãn, rồi đưa vào huấn luyện. Mỗi ảnh dịch là một câu nói với mô hình: “dịch 1 pixel không đổi chữ số này”.

Đó là toàn bộ ý tưởng của tăng cường dữ liệu. Bạn biết trước một số phép biến đổi không làm đổi đáp án: chó lật ngang vẫn là chó, ảnh tối hơn một chút vẫn là ảnh đó. Bạn biến kiến thức này thành dữ liệu, vì mô hình chỉ học được từ dữ liệu.

Nhưng không phải phép nào cũng an toàn. Xoay số 6 đi 180 độ được số 9. Lật ngang chữ “b” được chữ “d”. Lật ngang một phim X-quang ngực đưa tim sang bên phải, thành một ca bệnh hiếm. Phép biến đổi nào an toàn tùy vào bài toán, không tùy vào ảnh.

Câu hỏi của bài: chọn phép biến đổi nào, áp vào đâu (ảnh, nhãn, mask, hộp bao), và vì sao việc “chế thêm dữ liệu” này lại làm mô hình tổng quát hóa tốt hơn chứ không chỉ học thuộc nhiều hơn?

Ảnh X∈RH×W×C\mathbf{X} \in \mathbb{R}^{H \times W \times C} (cao HH, rộng WW, CC kênh màu), nhãn yy, mô hình ff. Mẫu thứ ii của tập train là (X(i),y(i))(\mathbf{X}^{(i)}, y^{(i)}), có mm mẫu. Một phép biến đổi (transformation) TT nhận một ảnh và trả về một ảnh cùng cỡ hoặc khác cỡ, ví dụ “lật ngang” hay “dịch phải 1 pixel”. Bài toán segmentation có thêm mask M\mathbf{M}: mỗi pixel một số lớp.

Với phân loại, ta muốn mô hình bất biến (invariant) với TT: biến đổi ảnh không làm đổi dự đoán.

f(T(X))=f(X)f(T(\mathbf{X})) = f(\mathbf{X})

Với segmentation thì khác. Lật ảnh thì đáp án đúng cũng phải lật theo: con mèo sang bên trái thì vùng “mèo” trong mask cũng sang bên trái. Ta muốn mô hình đồng biến (equivariant) với TT: biến đổi đầu vào thì đầu ra đổi theo đúng phép đó.

f(T(X))=T(f(X))f(T(\mathbf{X})) = T(f(\mathbf{X}))

Phát hiện vật thể (object detection) cũng vậy: hộp bao (bounding box) phải dời theo vật. Đây là lý do mục “Ví dụ code” đưa ảnh, mask và hộp qua cùng một phép biến đổi.

CNN có sẵn một phần tính chất này: tích chập trượt cùng một kernel khắp ảnh nên đồng biến với phép dịch (xem bài Phân loại ảnh với CNN). Nhưng nó không tự bất biến với phép xoay, co giãn hay đổi độ sáng. Mạng kết nối đầy đủ như MLP thì không có sẵn tính chất nào: với nó, pixel ở cột 3 và pixel ở cột 4 là hai đặc trưng chẳng liên quan gì nhau. Augmentation là cách đưa các tính chất còn thiếu vào bằng dữ liệu.

Huấn luyện bình thường làm nhỏ trung bình mất mát ℓ\ell trên tập train:

L(θ)=1m∑i=1mℓ(f(X(i)),y(i))\mathcal{L}(\boldsymbol{\theta}) = \frac{1}{m}\sum_{i=1}^{m} \ell\big(f(\mathbf{X}^{(i)}), y^{(i)}\big)

Có augmentation, mỗi lần mô hình gặp ảnh ii, ta bốc thăm một phép TT từ một tập phép biến đổi (ví dụ “dịch ngẫu nhiên tối đa 1 pixel”). Huấn luyện qua nhiều epoch, mỗi epoch một lần bốc thăm, nên điều được làm nhỏ thực chất là mất mát trung bình trên mọi phép TT:

Laug(θ)=1m∑i=1mET[ℓ(f(T(X(i))),y(i))]\mathcal{L}_{\text{aug}}(\boldsymbol{\theta}) = \frac{1}{m}\sum_{i=1}^{m} \mathbb{E}_{T}\Big[\ell\big(f(T(\mathbf{X}^{(i)})), y^{(i)}\big)\Big]

Kỳ vọng ET\mathbb{E}_T ở đây là trung bình theo cách bốc thăm TT. Đọc công thức này theo hai cách:

  • Thêm dữ liệu. Mỗi ảnh gốc trở thành cả một “đám” ảnh, cùng nhãn. Tập train hiệu dụng lớn hơn nhiều lần.
  • Một khoản phạt. Mô hình nào cho dự đoán khác nhau trên X\mathbf{X} và T(X)T(\mathbf{X}) thì chắc chắn sai ở một trong hai, nên bị phạt. Hàm mất mát mới loại bớt những mô hình “học thuộc vị trí pixel”, giống regularization ở bài Ridge, Lasso, Elastic Net loại bớt những mô hình có hệ số khổng lồ.

Vì thế augmentation là một cách chống overfitting: nó không cho mô hình bám vào chi tiết chỉ đúng với vài trăm ảnh train. Và cũng như hệ số phạt λ\lambda, có thể phạt quá tay: biến đổi mạnh tới mức ảnh mất nghĩa thì mô hình underfit.

Phép hình học (geometric) dời chỗ các pixel, không đổi giá trị:

  • Lật ngang (horizontal flip): ảnh tự nhiên gần như luôn an toàn. Lật dọc chỉ hợp với ảnh không có “phía trên”, như ảnh vệ tinh hay tiêu bản kính hiển vi.
  • Cắt ngẫu nhiên (random crop) và cắt co giãn ngẫu nhiên (random resized crop): lấy một vùng ngẫu nhiên rồi co về cỡ cố định. Mô hình thấy vật ở nhiều vị trí và nhiều cỡ. AlexNet (Krizhevsky và cộng sự, 2012) đã cắt vùng 224×224224 \times 224 ngẫu nhiên từ ảnh 256×256256 \times 256 cộng lật ngang; đây là một trong hai cách chống overfitting chính của paper, cách kia là dropout.
  • Xoay (rotation) vài độ, co giãn (scaling), xô nghiêng (shear). Khi xoay, các góc ảnh lộ ra chỗ trống phải điền (thường bằng 0), và pixel mới phải nội suy từ pixel cũ.

Phép quang học (photometric) đổi giá trị pixel, giữ nguyên chỗ:

  • Độ sáng, tương phản (brightness, contrast): mô phỏng ảnh chụp lúc sáng, lúc tối, máy ảnh khác nhau.
  • Màu (color jitter: độ bão hòa, sắc độ): mô phỏng ánh sáng vàng, trắng.
  • Nhiễu (noise) và làm mờ (blur): mô phỏng cảm biến kém, ảnh rung.

Phép quang học không dời pixel nào, nên mask và hộp bao giữ nguyên. Chỉ phép hình học phải áp lên cả mask và hộp.

Phép TT chỉ an toàn khi nhãn thật sự bất biến với nó. Vài ví dụ nhãn không bất biến:

  • Chữ số và chữ viết: xoay 180 độ đổi 6 thành 9; lật ngang biến chữ thành chữ ngược, thứ không có trong dữ liệu thật.
  • Hướng có nghĩa: biển “rẽ trái” lật ngang thành “rẽ phải”. Ảnh y tế có trái phải giải phẫu: tim nằm bên trái lồng ngực, nên lật phim X-quang tạo ra một ca bệnh khác.
  • Màu có nghĩa: phân loại quả chín hay xanh, đèn giao thông đỏ hay xanh: đổi sắc độ là đổi nhãn.
  • Cắt quá tay: cắt ngẫu nhiên một vùng nhỏ có thể bỏ mất cả vật cần nhận.

Kiểm tra đơn giản nhất: in ra vài chục ảnh đã augment và tự hỏi “mình có còn gán đúng nhãn cũ cho ảnh này không?”.

Ba kỹ thuật dưới đây không mô phỏng thứ gì có thật. Chúng tạo ảnh “lạ” có chủ đích để mô hình khỏi dựa vào một chi tiết duy nhất.

Cutout (DeVries và Taylor, 2017): xóa một ô vuông cỡ cố định ở vị trí ngẫu nhiên, điền 0. Mô hình phải nhận ra chó cả khi phần đầu bị che, nên không dựa hết vào một vùng.

Mixup (Zhang và cộng sự, 2018): trộn hai ảnh và hai nhãn với cùng một hệ số. Nhãn viết dạng vectơ one-hot y\mathbf{y} (một số 1 ở vị trí lớp đúng, còn lại 0):

X~=λ X(i)+(1−λ) X(j)y~=λ y(i)+(1−λ) y(j)\begin{aligned} \tilde{\mathbf{X}} &= \lambda\,\mathbf{X}^{(i)} + (1 - \lambda)\,\mathbf{X}^{(j)} \\ \tilde{\mathbf{y}} &= \lambda\,\mathbf{y}^{(i)} + (1 - \lambda)\,\mathbf{y}^{(j)} \end{aligned}

Hệ số λ∈[0,1]\lambda \in [0, 1] được bốc thăm từ phân phối Beta(α,α)(\alpha, \alpha); với α\alpha nhỏ, λ\lambda thường gần 0 hoặc gần 1, tức đa số ảnh chỉ bị trộn nhẹ. Paper thấy α\alpha từ 0.1 tới 0.4 cải thiện kết quả trên ImageNet, còn α\alpha lớn làm mô hình underfit. Hai ảnh được trộn thường lấy trong cùng một mini-batch sau khi xáo thứ tự.

Nhãn y~\tilde{\mathbf{y}} là nhãn mềm (soft label): ví dụ 0.70.7 cho lớp 6, 0.30.3 cho lớp 1. Cross-entropy với nhãn mềm vẫn là −∑ky~kln⁡pk-\sum_k \tilde{y}_k \ln p_k, với pkp_k là xác suất mô hình gán cho lớp kk. Thay y~k\tilde{y}_k vào:

−∑ky~kln⁡pk=−∑k(λyk(i)+(1−λ)yk(j))ln⁡pk=λ(−∑kyk(i)ln⁡pk)+(1−λ)(−∑kyk(j)ln⁡pk)\begin{aligned} -\sum_k \tilde{y}_k \ln p_k &= -\sum_k \big(\lambda y^{(i)}_k + (1-\lambda) y^{(j)}_k\big) \ln p_k \\ &= \lambda\Big(-\sum_k y^{(i)}_k \ln p_k\Big) + (1-\lambda)\Big(-\sum_k y^{(j)}_k \ln p_k\Big) \end{aligned}

Dòng thứ hai chỉ tách tổng thành hai phần. Mỗi ngoặc là cross-entropy thường với một nhãn cứng. Vậy mất mát của mixup là trung bình có trọng số của hai mất mát: “ảnh này là lớp ii” với trọng số λ\lambda, “là lớp jj” với trọng số 1−λ1 - \lambda. Nhiều code cài mixup đúng theo dòng thứ hai, không cần tạo vectơ nhãn mềm.

Vì sao trộn hai ảnh vô nghĩa lại giúp? Mixup đòi mô hình đi “thẳng” giữa hai mẫu: ảnh lưng chừng giữa 6 và 1 thì dự đoán cũng lưng chừng, không nhảy đột ngột. Đó là một ràng buộc kiểu regularization, làm mô hình bớt tự tin thái quá.

CutMix (Yun và cộng sự, 2019) gộp hai ý trên: thay vì xóa một ô (cutout) hay trộn mờ cả ảnh (mixup), nó cắt một hình chữ nhật từ ảnh jj dán đè lên ảnh ii. Gọi B\mathbf{B} là ma trận cùng cỡ ảnh, bằng 1 ở chỗ giữ ảnh ii và 0 trong hình chữ nhật, ⊙\odot là nhân từng phần tử:

X~=B⊙X(i)+(1−B)⊙X(j)\tilde{\mathbf{X}} = \mathbf{B} \odot \mathbf{X}^{(i)} + (\mathbf{1} - \mathbf{B}) \odot \mathbf{X}^{(j)}

Nhãn trộn như mixup, với λ\lambda bằng tỉ lệ diện tích còn của ảnh ii. Để hình chữ nhật chiếm đúng phần 1−λ1 - \lambda diện tích, hai cạnh của nó là W1−λW\sqrt{1-\lambda} và H1−λH\sqrt{1-\lambda}: tích hai cạnh là (1−λ)WH(1 - \lambda)WH. Hình chữ nhật bị cắt bớt khi chạm mép ảnh, nên code tính lại λ\lambda từ diện tích thật. So với cutout, không pixel nào bị phí; so với mixup, mỗi vùng ảnh vẫn là ảnh thật, mô hình học được “vật ở đâu”.

Chọn phép biến đổi nào, mạnh cỡ nào, với xác suất bao nhiêu? Có hàng chục phép, mỗi phép một độ mạnh, nên số tổ hợp rất lớn.

AutoAugment (Cubuk và cộng sự, 2019) để máy tìm. Một chính sách (policy) gồm nhiều chính sách con; mỗi chính sách con có hai phép, mỗi phép kèm xác suất áp dụng và độ mạnh. Mỗi ảnh trong mỗi mini-batch được áp một chính sách con chọn ngẫu nhiên. Thuật toán tìm (học tăng cường) thử rất nhiều chính sách, mỗi lần huấn luyện một mô hình nhỏ trên một phần dữ liệu và chấm trên tập kiểm định. Kết quả tốt, nhưng tìm rất tốn: theo paper RandAugment, riêng bản AutoAugment cho phát hiện vật thể tốn 15 nghìn giờ GPU.

RandAugment (Cubuk và cộng sự, 2020) bỏ hẳn bước tìm. Có một danh sách cố định 14 phép (xoay, xô nghiêng, dịch, tương phản, độ sáng, độ nét…). Với mỗi ảnh:

  1. Chọn ngẫu nhiên NN phép trong danh sách, mỗi phép xác suất như nhau.
  2. Áp lần lượt từng phép với cùng một độ mạnh MM, trên thang số nguyên 0 tới 10 (10 là mạnh nhất của từng phép).

Chỉ còn hai số, và cả hai dễ hiểu: NN, MM càng lớn thì regularization càng mạnh. Paper ước không gian tìm kiếm giảm từ khoảng 103210^{32} (AutoAugment) xuống khoảng 10210^{2}, nhỏ tới mức thử lưới vài giá trị trên chính bài toán là đủ. Paper còn thấy độ mạnh tốt nhất tăng theo cỡ mô hình và cỡ tập train. Đây là lý do không nên tìm chính sách trên một bài toán nhỏ rồi đem sang bài toán lớn.

Chỉ augment tập train, và test-time augmentation

Phần tiêu đề “Chỉ augment tập train, và test-time augmentation”

Augmentation ngẫu nhiên chỉ dùng khi huấn luyện. Tập kiểm định và tập test chỉ đi qua các bước tất định: co về cỡ chuẩn, cắt giữa, chuẩn hóa pixel. Nếu tập test cũng bị biến đổi ngẫu nhiên, điểm test đổi theo mỗi lần chạy và không so được với ai.

Ngoại lệ có chủ đích là test-time augmentation (TTA): lúc dự đoán, đưa vài bản biến đổi cố định của cùng một ảnh test (ví dụ ảnh gốc và ảnh lật ngang) qua mô hình rồi lấy trung bình xác suất. AlexNet làm vậy với 10 vùng cắt (bốn góc, giữa, cùng bản lật của chúng). TTA thường tăng độ chính xác một chút, đổi lại chậm hơn nhiều lần khi suy luận.

  • Không tạo ra thông tin mới. Augmentation chỉ nhân các biến thể mà bạn đã biết trước. Mô hình chưa từng thấy ảnh ban đêm thì tăng độ tối không thay được dữ liệu ban đêm thật.
  • Có thể phạt quá tay. Biến đổi mạnh làm mô hình underfit, và độ chính xác trên ảnh sạch có thể giảm nhẹ. Mục Ví dụ code cho thấy chuyện này khi thay MLP bằng mô hình tuyến tính.
  • Học tự giám sát dùng augmentation làm tín hiệu chính: hai bản biến đổi của cùng ảnh phải cho biểu diễn giống nhau. Bài Self-supervised learning với ảnh đi tiếp ý này.

Ô dưới đây là phần minh họa chính của bài: cùng một chữ số 6 (chữ số 6 thứ nhất của load_digits, giá trị pixel chia 16 để về đoạn 0 tới 1) qua chín phép biến đổi viết bằng NumPy và scipy.ndimage. Mixup dùng λ=0.7\lambda = 0.7 cố định cho dễ đọc, thay vì bốc thăm từ Beta(α,α)(\alpha, \alpha). Để biết mỗi ảnh “trông giống” chữ số nào, ô dùng một bộ phân loại rất thô: tính ảnh trung bình của từng chữ số trên cả 1 797 ảnh, rồi chọn chữ số có ảnh trung bình gần nhất. Bấm Chạy, rồi nhìn vào số trong ngoặc ở tiêu đề mỗi ô. Lần đầu chạy lâu hơn, vì trình duyệt phải tải thêm SciPy, scikit-learn và matplotlib.

Lật, dịch, xoay, nhiễu, cutout, mixup

import matplotlib.pyplot as plt
import numpy as np
from scipy import ndimage
from sklearn.datasets import load_digits
rng = np.random.default_rng(0)
digits = load_digits()
X = digits.images / 16 # 1 797 ảnh 8 x 8, giá trị 0..1
x6, x1 = X[6], X[1] # một chữ số 6 và một chữ số 1
# Phép hình học: chỉ dời chỗ các pixel
flip = x6[:, ::-1] # lật ngang
shift = ndimage.shift(x6, (0, 1), order=0, cval=0) # dịch phải 1 px
rot = ndimage.rotate(x6, 15, reshape=False, order=1) # xoay 15 độ
rot180 = np.rot90(x6, 2) # xoay 180 độ
# Phép quang học: đổi giá trị pixel, giữ nguyên chỗ
bright = np.clip(1.4 * x6 + 0.1, 0, 1) # tăng tương phản và độ sáng
noise = np.clip(x6 + rng.normal(0, 0.15, x6.shape), 0, 1)
# Cutout: xóa một ô vuông 3 x 3 ở vị trí ngẫu nhiên
cut = x6.copy()
r, c = rng.integers(0, 6, size=2)
cut[r:r + 3, c:c + 3] = 0
# Mixup: trộn hai ảnh, trộn cả nhãn với cùng hệ số lam
lam = 0.7
mix = lam * x6 + (1 - lam) * x1
y_mix = np.zeros(10)
y_mix[6], y_mix[1] = lam, 1 - lam
print("mixup label:", np.round(y_mix, 2))
# Bộ phân loại thô: ảnh trung bình của mỗi chữ số, chọn cái gần nhất
means = np.stack([X[digits.target == k].mean(0) for k in range(10)])
panels = [(x6, "original"), (flip, "flip"), (shift, "shift 1px"),
(rot, "rotate 15"), (rot180, "rotate 180"), (bright, "contrast"),
(noise, "noise"), (cut, "cutout"), (mix, "mixup")]
fig, axes = plt.subplots(3, 3, figsize=(6, 6.4))
for ax, (img, name) in zip(axes.flat, panels):
near = np.argmin(((means - img) ** 2).sum(axis=(1, 2)))
print(f"{name:10s} -> looks like {near}")
ax.imshow(img, cmap="gray_r", vmin=0, vmax=1)
ax.set_title(f"{name} ({near})")
ax.axis("off")
plt.tight_layout()
plt.show()

The first run downloads Python, about 10 MB.

Dòng mixup label in vectơ 10 số: 0.30.3 ở vị trí 1 và 0.70.7 ở vị trí 6, đúng công thức y~=λy(i)+(1−λ)y(j)\tilde{\mathbf{y}} = \lambda\mathbf{y}^{(i)} + (1-\lambda)\mathbf{y}^{(j)}. Dịch dùng order=0 (nội suy gần nhất) và cval=0 (ô trống điền nền trắng của ảnh, giá trị 0); xoay dùng order=1 (song tuyến), nên ảnh xoay hơi nhòe.

Ba điều đáng để ý:

  • Xoay 180 độ cho một hình mà chính bạn cũng đọc là 9: phép này làm sai nhãn, không được dùng cho chữ số.
  • Dịch 1 pixel làm bộ phân loại thô đoán sai, dù với mắt người ảnh gần như không đổi. Đó chính là kiểu mô hình mà augmentation dịch chuyển giúp được.
  • Mixup cho một hình không phải chữ số nào. Nó không cần trông thật; nhãn của nó là 0.70.7 cho 6, 0.30.3 cho 1.

Bảng dưới tóm tắt khi nào dùng và khi nào tránh từng phép.

Phép biến đổiHợp vớiTránh khiMask, hộp bao
Lật ngangẢnh tự nhiên: động vật, đồ vật, cảnhChữ viết, biển chỉ hướng, ảnh y tế có trái phảiLật theo
Lật dọc, xoay 90 độẢnh vệ tinh, kính hiển vi (không có “phía trên”)Ảnh có trọng lực: người, cảnh đường phốLật, xoay theo
Cắt co giãn ngẫu nhiênPhân loại ảnh nói chungVật nhỏ, dễ bị cắt mất; đếm vậtCắt theo, bỏ hộp ra ngoài khung
Xoay nhỏ, xô nghiêngChữ số, ảnh chụp cầm tayGóc có nghĩa (đồng hồ, kim la bàn)Xoay theo, nội suy gần nhất cho mask
Độ sáng, tương phảnẢnh chụp ngoài trời, nhiều máy ảnhĐộ sáng có nghĩa (ảnh y tế đo mật độ)Giữ nguyên
Đổi màu, sắc độMàu không quyết định lớpMàu là nhãn: quả chín, đèn giao thôngGiữ nguyên
Nhiễu, làm mờẢnh cảm biến rẻ, ảnh rungChi tiết nhỏ là nhãn: vết nứt, chữ nhỏGiữ nguyên
CutoutMô hình dựa vào một vùng duy nhấtVật nhỏ có thể bị che hếtPhải xử lý riêng (vật bị che hết)
Mixup, CutMixPhân loại, nhất là dữ liệu ít, nhiều lớpBài toán cần nhãn cứng; segmentation (cần cách trộn mask riêng)Trộn theo diện tích (CutMix)

Code trong bài chạy thử lần cuối tháng 11/2026: các ô chạy trên trang với Python 3.14, NumPy 2.4, SciPy 1.18 và scikit-learn 1.8 (bản có trong Pyodide), ô PyTorch với Python 3.14, PyTorch 2.15 và torchvision 0.29 trên CPU. Ô scikit-learn dưới đây (và ô ở mục Minh họa) chạy ngay trên trang, ô PyTorch chạy trên máy bạn. Các ô độc lập, không dùng biến của nhau.

Ô này làm lại thí nghiệm ở mục Trực giác. Nó chia 300 ảnh train (đủ cả 10 chữ số theo đúng tỉ lệ, nhờ stratify) và 1 497 ảnh test. Tập test có hai bản: ảnh gốc, và cùng các ảnh đó bị dịch ngẫu nhiên tối đa 1 pixel theo mỗi chiều. Mô hình là MLPClassifier, một mạng một lớp ẩn 64 nơ-ron, huấn luyện hai lần cùng seed: một lần trên 300 ảnh gốc, một lần trên 300 ảnh gốc cộng 4 bản dịch ngẫu nhiên của mỗi ảnh (1 500 ảnh). Augmentation chỉ chạm vào tập train. Ô cần khoảng chục giây trở lên, vì phải huấn luyện mạng hai lần.

Huấn luyện có và không có dịch ngẫu nhiên

import numpy as np
from scipy import ndimage
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
rng = np.random.default_rng(0)
digits = load_digits()
X = digits.images / 16
X_tr, X_te, y_tr, y_te = train_test_split(
X, digits.target, train_size=300, stratify=digits.target,
random_state=0)
def random_shift(imgs):
# Mỗi ảnh dịch ngẫu nhiên tối đa 1 px theo mỗi chiều, ô trống điền 0
moves = rng.integers(-1, 2, size=(len(imgs), 2))
return np.stack([ndimage.shift(im, mv, order=0, cval=0)
for im, mv in zip(imgs, moves)])
# Tập test thứ hai: cùng các ảnh test, mỗi ảnh bị dịch ngẫu nhiên
X_te_shift = random_shift(X_te)
# Augmentation chỉ cho tập train: thêm 4 bản dịch ngẫu nhiên của mỗi ảnh
X_aug = np.concatenate([X_tr] + [random_shift(X_tr) for _ in range(4)])
y_aug = np.tile(y_tr, 5)
for name, Xa, ya in [("no aug", X_tr, y_tr), ("shift aug", X_aug, y_aug)]:
# MLP nhỏ: một lớp ẩn 64 nơ-ron, cùng seed cho cả hai lần
clf = MLPClassifier((64,), max_iter=1000, random_state=0)
clf.fit(Xa.reshape(len(Xa), -1), ya)
acc = clf.score(X_te.reshape(len(X_te), -1), y_te)
acc_s = clf.score(X_te_shift.reshape(len(X_te), -1), y_te)
print(f"{name:9s} train {len(Xa):4d}: test {acc:.3f}, "
f"shifted test {acc_s:.3f}")

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • Không augmentation: đúng 0.9380.938 trên ảnh test gốc, chỉ 0.4010.401 trên ảnh test bị dịch. Mô hình đã học vị trí pixel, như ở mục Trực giác.
  • Có augmentation: 0.9370.937 trên ảnh gốc, gần như không đổi, và 0.8820.882 trên ảnh bị dịch. Mô hình đã học được “dịch 1 pixel không đổi chữ số”, dù không thấy thêm ảnh thật nào.
  • Mạng không có tính chất dịch chuyển sẵn như CNN, nên mọi bất biến nó có đều đến từ dữ liệu.

Thử đổi mô hình thành LogisticRegression(max_iter=2000) (nhớ sửa dòng import). Kết quả: không augmentation 0.9350.935 và 0.3720.372; có augmentation 0.8830.883 và 0.5950.595. Mô hình tuyến tính quá đơn giản để vừa nhận đúng ảnh gốc vừa chịu được phép dịch, nên augmentation kéo độ chính xác trên ảnh gốc xuống: đây là chuyện “phạt quá tay” ở mục Giới hạn. Phép biến đổi phải đi cùng một mô hình đủ sức học tính bất biến.

Pipeline torchvision v2: ảnh, mask và hộp bao cùng lúc

Phần tiêu đề “Pipeline torchvision v2: ảnh, mask và hộp bao cùng lúc”

Trong dự án thật, bạn không tự viết từng phép mà dùng torchvision.transforms.v2. Từ torchvision 0.15 (3/2023), các phép v2 nhận được cả ảnh, mask và hộp bao trong một lần gọi, nếu chúng được gói trong tv_tensors. File dưới đây tự vẽ một ảnh 128×128128 \times 128 bằng PIL (hình vuông đỏ là lớp 1, hình tròn xanh là lớp 2), tạo mask và hộp bao của hình vuông, rồi đưa qua pipeline: cắt co giãn ngẫu nhiên về 96×9696 \times 96, lật ngang, xoay tối đa 15 độ, đổi độ sáng, tương phản, độ bão hòa, đổi kiểu sang số thực. Để kiểm ảnh và mask còn khớp nhau, file đoán lớp của từng pixel từ màu (đỏ trội là 1, xanh trội là 2) rồi so với mask.

Run on your machine

Biến đổi đồng bộ ảnh, mask và hộp bao

Install
pip install torch torchvision pillow
Hardware
CPU là đủ. Phần tính chỉ mất chưa tới một giây; phần lớn thời gian là nạp PyTorch
import torch
from PIL import Image, ImageDraw
from torchvision import tv_tensors
from torchvision.transforms import v2
torch.manual_seed(0)
# Ảnh 128 x 128 tự vẽ: nền xám, hình vuông đỏ (lớp 1), hình tròn xanh (lớp 2)
pil = Image.new("RGB", (128, 128), (128, 128, 128))
draw = ImageDraw.Draw(pil)
draw.rectangle([16, 20, 60, 64], fill=(220, 40, 40))
draw.ellipse([70, 60, 116, 106], fill=(40, 40, 220))
img = v2.functional.pil_to_tensor(pil) # (3, 128, 128), uint8
mask = torch.zeros(128, 128, dtype=torch.uint8)
mask[20:65, 16:61] = 1
mask[img[2] > 200] = 2 # pixel xanh dương là lớp 2
img = tv_tensors.Image(img)
mask = tv_tensors.Mask(mask)
box = tv_tensors.BoundingBoxes([[16, 20, 61, 65]], format="XYXY",
canvas_size=(128, 128))
train_tf = v2.Compose([
v2.RandomResizedCrop(96, scale=(0.5, 1.0)), # cắt ngẫu nhiên rồi co về 96
v2.RandomHorizontalFlip(p=0.5),
v2.RandomRotation(15),
v2.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4),
v2.ToDtype(torch.float32, scale=True), # uint8 0..255 -> 0..1
])
# Tập kiểm định, test: chỉ các bước tất định, không ngẫu nhiên
eval_tf = v2.Compose([v2.Resize(110), v2.CenterCrop(96),
v2.ToDtype(torch.float32, scale=True)])
def color_class(x):
# Đoán lớp của từng pixel từ màu: đỏ trội -> 1, xanh trội -> 2
r, b = x[0], x[2]
return (r - b > 0.2).long() + 2 * (b - r > 0.2).long()
def agree(x, m):
return (color_class(x) == m.long()).float().mean().item()
# Đúng: gọi một lần, ảnh, mask và hộp đi qua cùng một phép biến đổi
out_img, out_mask, out_box = train_tf(img, mask, box)
print("image:", tuple(out_img.shape), out_img.dtype)
print("mask:", tuple(out_mask.shape), out_mask.dtype,
"values", out_mask.unique().tolist())
print("box before:", box.tolist(), "after:", out_box.round().tolist())
print(f"agreement, one call: {agree(out_img, out_mask):.3f}")
# Sai: gọi hai lần riêng, mỗi lần bốc thăm một phép cắt và xoay khác nhau
img2 = train_tf(img)
mask2 = train_tf(mask)
print(f"agreement, two calls: {agree(img2, mask2):.3f}")
print("eval image:", tuple(eval_tf(img).shape))

Sample output

From one real run.

image: (3, 96, 96) torch.float32
mask: (96, 96) torch.uint8 values [0, 1, 2]
box before: [[16, 20, 61, 65]] after: [[48, 0, 87, 41]]
agreement, one call: 0.993
agreement, two calls: 0.400
eval image: (3, 96, 96)

Đọc kết quả:

  • image: cắt co giãn đưa ảnh về 96×9696 \times 96, ToDtype(..., scale=True) đổi 0–255 thành 0–1.
  • mask ... values [0, 1, 2]: mask vẫn chỉ có ba số lớp, không có giá trị lưng chừng. torchvision biết tv_tensors.Mask là nhãn nên dùng nội suy gần nhất, và các phép đổi màu bỏ qua mask.
  • box: hộp của hình vuông dời và co theo ảnh. Sau khi xoay, hộp mới là hộp nhỏ nhất bao được hình đã xoay, nên có thể rộng hơn vật một chút.
  • agreement, one call: 0.993: màu của ảnh và mask khớp ở 99.3% pixel; phần lệch chỉ là các pixel ở viền hình, nơi màu của ảnh đã bị nội suy song tuyến thành màu lưng chừng còn mask thì không.
  • agreement, two calls: 0.400: gọi pipeline hai lần riêng cho ảnh và mask thì mỗi lần bốc thăm một phép cắt, lật, xoay khác nhau. Chỉ 40% pixel còn khớp: mô hình sẽ học từ một mask lệch với ảnh.
  • eval image: pipeline cho tập kiểm định và tập test là một Compose khác, chỉ có các bước tất định, nên cùng một ảnh luôn cho cùng một kết quả:
eval_tf = v2.Compose([v2.Resize(110), v2.CenterCrop(96),
v2.ToDtype(torch.float32, scale=True)])

Muốn thêm RandAugment vào pipeline train, dùng v2.RandAugment(num_ops=2, magnitude=9): N=2N = 2, M=9M = 9, cũng là giá trị mặc định của torchvision. Lưu ý thang độ mạnh của torchvision mặc định có 31 mức (num_magnitude_bins=31), khác thang 0 tới 10 của paper. Nếu mô hình cần, thêm v2.Normalize(mean, std) ở cuối cả hai pipeline.

Question 1 of 4 Bạn huấn luyện mô hình nhận biển báo giao thông, trong đó có hai lớp "cấm rẽ trái" và "cấm rẽ phải". Bạn nên làm gì với lật ngang (horizontal flip)?
Question 2 of 4 Một ảnh mixup trộn ảnh mèo và ảnh chó với λ=0.25\lambda = 0.25 cho mèo, nên nhãn là [0.25,0.75][0.25, 0.75] (mèo, chó). Mô hình dự đoán xác suất mèo 0.80.8, chó 0.20.2. Cross-entropy của mẫu này xấp xỉ bao nhiêu?
Question 3 of 4 Bạn tự viết hàm xoay cho bài segmentation, và xoay mask (giá trị 0, 1, 2 là nền, đường, xe) bằng nội suy song tuyến (bilinear), giống ảnh. Ở biên giữa đường và xe xuất hiện các giá trị như 1.51.5. Vấn đề là gì?
Question 4 of 4 Có 500 ảnh. Bạn tạo 10 bản dịch và xoay ngẫu nhiên cho mỗi ảnh, được 5 500 ảnh, rồi mới chia ngẫu nhiên 80% train, 20% test. Độ chính xác test rất cao. Điều nào đúng?

Ý chính:

  • Augmentation đưa vào dữ liệu kiến thức “phép biến đổi này không đổi đáp án” (bất biến, cho phân loại) hoặc “đáp án đổi theo đúng phép này” (đồng biến, cho segmentation, detection). Nó làm nhỏ mất mát trung bình trên các phép biến đổi, nên vừa là thêm dữ liệu vừa là một dạng regularization.
  • Phép hình học (lật, cắt, xoay) dời pixel và phải áp cả lên mask, hộp bao; phép quang học (sáng, tương phản, màu, nhiễu, mờ) chỉ đổi giá trị pixel. Phép nào an toàn tùy bài toán: xoay 180 độ biến 6 thành 9.
  • Cutout xóa một ô, mixup trộn hai ảnh và hai nhãn y~=λy(i)+(1−λ)y(j)\tilde{\mathbf{y}} = \lambda\mathbf{y}^{(i)} + (1-\lambda)\mathbf{y}^{(j)}, CutMix dán một vùng ảnh này lên ảnh kia và trộn nhãn theo diện tích. RandAugment thay việc tìm chính sách tốn kém của AutoAugment bằng hai số NN, MM.
  • Chỉ augment tập train, sau khi đã chia dữ liệu. Tập kiểm định, test chỉ qua các bước tất định; test-time augmentation là ngoại lệ có chủ đích.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About