⭐ Vision Engineer · Chặng 11Bài 5/7Bài học~75 phút

GAN – Mạng đối sinh: cho máy tính tự “vẽ” ảnh

Generator và Discriminator đấu trí ra sao, hàm minimax, huấn luyện DCGAN bằng PyTorch, mode collapse và các biến thể cGAN, pix2pix, CycleGAN.

GAN – Mạng đối sinh: cho máy tính tự “vẽ” ảnh

Học xong bài này, bạn sẽ

  • Hiểu Generator, Discriminator và hàm minimax
  • Huấn luyện DCGAN bằng PyTorch
  • Biết giới hạn của GAN khi tạo ảnh lỗi tổng hợp

Nên học trước: Khi thiếu ảnh lỗi: augmentation và dữ liệu tổng hợp

Nội dung bài viết
  1. Mô hình phân biệt và mô hình sinh
  2. Ý tưởng: kẻ làm tiền giả và cảnh sát
  3. Hàm mục tiêu minimax
  4. Thuật toán huấn luyện
  5. DCGAN: GAN dùng mạng tích chập
  6. Những khó khăn kinh điển của GAN
  7. Các biến thể GAN nổi bật
  8. GAN có ích gì cho một Vision Engineer?
  9. Tóm tắt
  10. Tài liệu tham khảo

Hầu hết các bài trong lộ trình dạy máy tính hiểu ảnh: phân loại, phát hiện, phân vùng. Generative Adversarial Network (GAN, mạng đối sinh) làm điều ngược lại: tạo ra những bức ảnh chưa từng tồn tại, từ khuôn mặt người, phong cảnh đến ảnh sản phẩm lỗi tổng hợp. Yann LeCun từng gọi GAN là "ý tưởng thú vị nhất trong machine learning của 10 năm qua". Bài trước, "Khi thiếu ảnh lỗi: augmentation và dữ liệu tổng hợp", tạo ảnh lỗi bằng cắt dán và vẽ thủ tục; GAN là cách để mạng nơ-ron tự học sinh ảnh. Bài này tương ứng phần "Other topics" (module 06) của bài giảng gốc.

Mô hình phân biệt và mô hình sinh

Mô hình phân biệt (discriminative)Mô hình sinh (generative)
Học gì\(p(y \mid x)\): cho ảnh, đoán nhãn\(p(x)\): bản thân phân phối của dữ liệu ảnh
Ví dụResNet, YOLO, U-NetGAN, VAE, diffusion model
Làm được"Ảnh này là mèo""Hãy vẽ một con mèo mới"

Học \(p(x)\) trực tiếp cho ảnh 64×64×3 (12.288 chiều) là cực kỳ khó. GAN né bài toán này một cách thông minh: thay vì viết ra công thức xác suất, nó huấn luyện một mạng biến nhiễu ngẫu nhiên thành ảnh, và dùng một mạng khác làm "giám khảo".

Ý tưởng: kẻ làm tiền giả và cảnh sát

Goodfellow et al. (2014) mô tả GAN bằng một trò chơi hai người:

  • Generator G (kẻ làm tiền giả): nhận vector nhiễu \(z \sim \mathcal N(0, I)\) (ví dụ 100 chiều) và sinh ra ảnh giả \(G(z)\).
  • Discriminator D (cảnh sát): nhận một ảnh, trả về xác suất \(D(x)\) ảnh đó là thật (lấy từ dữ liệu) thay vì giả.

D cố phân biệt thật/giả càng giỏi càng tốt; G cố đánh lừa D. Hai bên cùng tiến bộ: tiền giả ngày càng tinh vi, cảnh sát ngày càng tinh mắt. Khi cân bằng lý tưởng, ảnh giả không thể phân biệt với ảnh thật và D chỉ đoán được 50/50.

Nhiễu z Generator GConvTranspose Ảnh giả G(z) Ảnh thật x Discriminator DConv → P("thật") gradient từ D "dạy" G cách làm giả tốt hơn
G không bao giờ nhìn thấy ảnh thật; nó chỉ học qua tín hiệu gradient từ D

Hàm mục tiêu minimax

\[ \min_G \max_D\ V(D, G) = \mathbb E_{x\sim p_{data}}\big[\log D(x)\big] + \mathbb E_{z\sim p_z}\big[\log\big(1 - D(G(z))\big)\big] \]
  • D muốn tối đa \(V\): \(D(x) \to 1\) với ảnh thật, \(D(G(z)) \to 0\) với ảnh giả. Đây chính là binary cross-entropy quen thuộc.
  • G muốn tối thiểu \(V\): làm \(D(G(z)) \to 1\).

Goodfellow chứng minh: với G cố định, D tối ưu là \(D^*(x) = \dfrac{p_{data}(x)}{p_{data}(x) + p_g(x)}\); khi đó bài toán của G tương đương tối thiểu hoá khoảng cách Jensen–Shannon giữa phân phối ảnh thật \(p_{data}\) và phân phối ảnh sinh \(p_g\). Cực tiểu toàn cục đạt được khi \(p_g = p_{data}\), lúc đó \(D^* = 1/2\) ở mọi nơi và \(V = -\log 4\).

Non-saturating loss: mẹo quan trọngLúc đầu G còn kém, D dễ dàng nhận ra ảnh giả, \(D(G(z)) \approx 0\) nên \(\log(1 - D(G(z)))\) gần như phẳng: G hầu như không nhận được gradient. Trong thực tế G được huấn luyện để tối đa \(\log D(G(z))\) thay thế: cùng điểm cân bằng nhưng gradient mạnh khi G còn yếu. Viết bằng BCE: loss của G = BCE(D(G(z)), nhãn "thật").

Thuật toán huấn luyện

Lặp lại cho mỗi mini-batch:

  1. Cập nhật D: lấy batch ảnh thật (nhãn 1) và batch ảnh giả \(G(z)\) (nhãn 0); loss \(= \text{BCE}(D(x), 1) + \text{BCE}(D(G(z)), 0)\). Ảnh giả được detach() để bước này không cập nhật G.
  2. Cập nhật G: loss \(= \text{BCE}(D(G(z)), 1)\); gradient đi xuyên qua D (D không được cập nhật ở bước này) về G.

DCGAN: GAN dùng mạng tích chập

GAN gốc dùng mạng fully connected và rất khó huấn luyện. Radford et al. (2016) đưa ra DCGAN với bộ nguyên tắc kiến trúc giúp GAN ổn định trên ảnh, đến nay vẫn là điểm khởi đầu tốt:

  • Bỏ pooling: D dùng conv stride 2 để thu nhỏ, G dùng transposed conv stride 2 để phóng to.
  • BatchNorm ở cả G và D (trừ lớp ra của G và lớp vào của D).
  • G dùng ReLU, lớp ra dùng Tanh (nên ảnh thật phải chuẩn hoá về [−1, 1]); D dùng LeakyReLU(0,2).
  • Bỏ các lớp fully connected ẩn.
  • Adam với \(\eta = 0{,}0002\), \(\beta_1 = 0{,}5\); khởi tạo trọng số \(\mathcal N(0;\ 0{,}02^2)\).
import torch
import torch.nn as nn

nz, ngf, ndf = 100, 64, 64        # kích thước vector nhiễu, số kênh cơ sở của G và D


class Generator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            # z: (N, 100, 1, 1) -> (N, 512, 4, 4)
            nn.ConvTranspose2d(nz, ngf * 8, 4, 1, 0, bias=False), nn.BatchNorm2d(ngf * 8), nn.ReLU(True),
            nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 4), nn.ReLU(True),  # 8x8
            nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf * 2), nn.ReLU(True),  # 16x16
            nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False), nn.BatchNorm2d(ngf), nn.ReLU(True),              # 32x32
            nn.ConvTranspose2d(ngf, 3, 4, 2, 1, bias=False), nn.Tanh(),                                             # 64x64, [-1, 1]
        )

    def forward(self, z):
        return self.net(z)


class Discriminator(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(3, ndf, 4, 2, 1, bias=False), nn.LeakyReLU(0.2, True),                                    # 32x32
            nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 2), nn.LeakyReLU(0.2, True),      # 16x16
            nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 4), nn.LeakyReLU(0.2, True),  # 8x8
            nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False), nn.BatchNorm2d(ndf * 8), nn.LeakyReLU(0.2, True),  # 4x4
            nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),                                                           # 1x1: logit
        )

    def forward(self, x):
        return self.net(x).view(-1)


def weights_init(m):                     # khởi tạo theo bài báo DCGAN: N(0, 0.02)
    if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
        nn.init.normal_(m.weight, 0.0, 0.02)
    elif isinstance(m, nn.BatchNorm2d):
        nn.init.normal_(m.weight, 1.0, 0.02)
        nn.init.zeros_(m.bias)


device = "cuda" if torch.cuda.is_available() else "cpu"
G, D = Generator().to(device), Discriminator().to(device)
G.apply(weights_init); D.apply(weights_init)
criterion = nn.BCEWithLogitsLoss()     # D trả logit, BCEWithLogits ổn định số học hơn Sigmoid + BCELoss
opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
fixed_z = torch.randn(64, nz, 1, 1, device=device)    # để theo dõi ảnh sinh qua các epoch

for epoch in range(num_epochs):
    for real, _ in loader:                 # real: ảnh 64x64 đã chuẩn hoá về [-1, 1]
        real = real.to(device)
        b = real.size(0)
        ones, zeros = torch.ones(b, device=device), torch.zeros(b, device=device)

        # (1) Cập nhật D: maximize log D(x) + log(1 - D(G(z)))
        z = torch.randn(b, nz, 1, 1, device=device)
        fake = G(z)
        loss_d = criterion(D(real), ones) + criterion(D(fake.detach()), zeros)   # detach: không cập nhật G ở bước này
        opt_d.zero_grad()
        loss_d.backward()
        opt_d.step()

        # (2) Cập nhật G: maximize log D(G(z))  (non-saturating loss)
        loss_g = criterion(D(fake), ones)      # G muốn D tin ảnh giả là thật
        opt_g.zero_grad()
        loss_g.backward()
        opt_g.step()
    print(f"epoch {epoch}: loss_D {loss_d.item():.3f}  loss_G {loss_g.item():.3f}")

Dữ liệu chuẩn bị với transforms.Compose([Resize(64), CenterCrop(64), ToTensor(), Normalize((0.5,)*3, (0.5,)*3)]) để đưa ảnh về [−1, 1]. Generator có khoảng 3,58 triệu tham số, Discriminator khoảng 2,77 triệu. Huấn luyện trên bộ khuôn mặt CelebA vài epoch trên GPU đã cho ra những khuôn mặt nhận ra được.

Đọc loss của GAN thế nào?Khác với phân loại, loss của GAN không giảm đều: G và D giằng co nên loss dao động là bình thường. Đừng dựa vào loss để dừng huấn luyện; hãy lưu ảnh sinh từ fixed_z sau mỗi epoch và nhìn bằng mắt, kèm chỉ số FID. Dấu hiệu xấu: loss_D về 0 rất nhanh (D thắng tuyệt đối, G hết gradient), hoặc ảnh sinh ra giống hệt nhau.

Những khó khăn kinh điển của GAN

  • Mode collapse: G phát hiện vài mẫu ảnh đánh lừa được D và chỉ sinh đi sinh lại những mẫu đó (ví dụ chỉ sinh chữ số "1"), bỏ qua sự đa dạng của dữ liệu.
  • Không hội tụ / dao động: bài toán minimax không có "đáy" rõ ràng như tối thiểu hoá thông thường; hai mạng có thể đuổi nhau mãi.
  • Vanishing gradient cho G khi D quá mạnh.
  • Nhạy siêu tham số: learning rate, kiến trúc, tỉ lệ cập nhật G/D.
  • Khó đánh giá: không có loss nào phản ánh trực tiếp chất lượng ảnh.

Các cải tiến nổi bật:

  • WGAN, WGAN-GP (2017): thay JS divergence bằng khoảng cách Wasserstein, D thành "critic" cho điểm không giới hạn, kèm gradient penalty; huấn luyện ổn định hơn nhiều và loss có ý nghĩa hơn.
  • Spectral Normalization (2018): giới hạn hằng số Lipschitz của D.
  • Các mẹo nhỏ: one-sided label smoothing (nhãn thật = 0,9), learning rate khác nhau cho G và D (TTUR).

Đánh giá: FID (Fréchet Inception Distance) so sánh phân phối đặc trưng Inception-v3 của ảnh thật và ảnh sinh; càng thấp càng tốt, đo cả chất lượng lẫn độ đa dạng. Inception Score (IS) cũ hơn, ít dùng hơn.

Các biến thể GAN nổi bật

Mô hìnhNămÝ tưởngỨng dụng
Conditional GAN2014Đưa thêm nhãn \(y\) vào cả G và D: \(G(z, y)\)Sinh ảnh theo lớp chỉ định ("sinh số 7")
pix2pix2017cGAN cho dữ liệu có cặp; G là U-Net, D là PatchGAN (chấm thật/giả từng mảng nhỏ); loss GAN + L1Bản phác thảo → ảnh, bản đồ → ảnh vệ tinh, ảnh đen trắng → màu
CycleGAN2017Dữ liệu không cần cặp; hai cặp G/D và cycle-consistency loss: A → B → A phải về lại ảnh gốcNgựa ↔ ngựa vằn, ảnh ↔ tranh Monet, ngày ↔ đêm
ProGAN2018Huấn luyện tăng dần độ phân giải 4×4 → 1024×1024Ảnh độ phân giải cao
StyleGAN 1/2/32019–2021Mạng ánh xạ z sang không gian "style", điều khiển từng tầng chi tiếtKhuôn mặt siêu thực (thispersondoesnotexist)
SRGAN / ESRGAN2017–2018GAN cho siêu phân giải, kết hợp perceptual lossPhóng to ảnh, phục hồi ảnh cũ

GAN có ích gì cho một Vision Engineer?

  • Tăng cường dữ liệu tổng hợp: sinh thêm ảnh cho lớp hiếm (lỗi sản phẩm hiếm gặp). Cần kiểm chứng kỹ: ảnh tổng hợp kém chất lượng có thể làm mô hình học sai.
  • Phát hiện bất thường (anomaly detection): huấn luyện GAN chỉ trên ảnh bình thường; ảnh lỗi sẽ khó được tái tạo, sai số tái tạo cao (AnoGAN, GANomaly). Hữu ích khi gần như không có mẫu lỗi.
  • Chuyển đổi miền ảnh: biến ảnh mô phỏng thành ảnh trông như thật (sim-to-real), ngày sang đêm để tăng cường dữ liệu cho xe tự lái.
  • Siêu phân giải, khử nhiễu, phục hồi ảnh.
Bối cảnh hiện nayTừ khoảng 2021, diffusion model (DDPM, Stable Diffusion, và các mô hình tạo ảnh từ văn bản) đã vượt GAN về chất lượng và độ đa dạng của ảnh sinh, đồng thời huấn luyện ổn định hơn. GAN vẫn có chỗ đứng nhờ tốc độ (sinh ảnh chỉ bằng một lần forward, trong khi diffusion cần nhiều bước khử nhiễu), và ý tưởng huấn luyện đối kháng vẫn được dùng làm thành phần trong nhiều hệ thống hiện đại (ví dụ discriminator trong các bộ mã hoá ảnh, chưng cất diffusion thành mô hình ít bước).

Tóm tắt

  • GAN = Generator sinh ảnh từ nhiễu + Discriminator phân biệt thật/giả, huấn luyện đối kháng.
  • Mục tiêu minimax; cân bằng lý tưởng khi \(p_g = p_{data}\) và \(D = 1/2\); dùng non-saturating loss cho G.
  • DCGAN: conv stride 2, transposed conv, BatchNorm, ReLU/LeakyReLU, Tanh, Adam(2e-4, β₁ = 0,5).
  • Khó khăn: mode collapse, dao động, khó đánh giá; WGAN-GP, spectral norm giúp ổn định; đánh giá bằng FID.
  • Biến thể: cGAN, pix2pix, CycleGAN, StyleGAN; ứng dụng: dữ liệu tổng hợp, phát hiện bất thường, chuyển miền ảnh.

GAN là một trong các cách tạo dữ liệu tổng hợp; xem cách làm thực tế, rẻ hơn trong bài Khi thiếu ảnh lỗi: augmentation và dữ liệu tổng hợp, và so sánh với GAN trên chính dữ liệu của bạn. Bài tiếp theo trong chặng: Chọn ngưỡng và đánh giá theo escape, overkill, cycle time.

Tài liệu tham khảo

  • Goodfellow et al. (2014). Generative Adversarial Nets.
  • Radford, Metz, Chintala (2016). Unsupervised Representation Learning with Deep Convolutional GANs (DCGAN).
  • Arjovsky et al. (2017). Wasserstein GAN; Gulrajani et al. (2017). Improved Training of Wasserstein GANs.
  • Isola et al. (2017). pix2pix; Zhu et al. (2017). CycleGAN; Karras et al. (2019). StyleGAN.
  • Heusel et al. (2017). GANs Trained by a Two Time-Scale Update Rule Converge to a Local Nash Equilibrium (FID).
  • PyTorch Tutorials: DCGAN Tutorial.

Bài tập thực hành

  1. Huấn luyện DCGAN trên ảnh OK của bạn (hoặc một lớp của MVTec AD) vài epoch, lưu lưới ảnh sinh từ cùng một vector nhiễu cố định sau mỗi epoch và nhận xét.

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