跳至主要內容

    GAN:生成器與判別器怎麼互相學習?

    從真假標籤與兩個 loss,走到 detach、凍結參數及真正的交替更新

    sunny 桑尼· · 36 MIN READ · 295 次瀏覽

    GAN:生成器與判別器怎麼互相學習?
    本文目錄

    如果手上有一批真實資料,想學會產生相似的新樣本,要怎麼告訴模型「這次做得像不像」?GAN 的做法是讓另一個可學習的模型提供訊號:生成器負責產生樣本,判別器負責分辨樣本來自資料還是生成器,兩者交替改變參數。

    但「互相競爭」還沒有說清楚訓練。更新判別器時,生成器要不要收到梯度?更新生成器時,判別器若不更新,還需要參與反向傳播嗎?同一張假樣本為什麼在不同階段會用到不同的 target?

    本篇先用原圖拆開資料流與目標,再以四個可學習標量,實際執行一次 D 更新與一次 G 更新。這確實有梯度與 SGD 參數更新,並非只有畫出流程;範圍則限於固定小例,不是完整影像 GAN 訓練、生成品質或收斂實驗。前一篇 AE 與 VAE 從 likelihood 與重建談生成;這篇改由學得的判別訊號來引導生成器。

    一個產生樣本,一個判斷來源

    基本 GAN 有兩個模型。Generator(G,生成器)把 noise/latent z 映射成資料空間的樣本 G(z);Discriminator(D,判別器)讀取一筆樣本,輸出「來自真實資料」的分數。在原始 GAN 的機率記法裡,D(x) 介於 0 和 1。

    這裡的 real 指來自指定資料來源,並不等於內容絕對正確、沒有偏差或不含雜訊;fake 指由 G 產生,也不自動等於對人類沒有價值。這是用來定義訓練問題的來源標籤。

    圖 1|real images 取樣與 random input→generator→sample 兩條路都進同一個 discriminator,再列出 D loss 與 G loss。這張原圖畫的是整體輸入與 loss 關係,沒有完整 backward 箭頭、凍結參數或 optimizer 更新順序,後文另拆解。來源:Google Developers〈Overview of GAN Structure〉,Google Developers,CC BY 4.0。完整原 SVG 轉白底 PNG;手機先全圖,再將兩個原 loss 框的原像素逆時針旋轉 90 度,依 D loss、G loss 順序放大讀字,不構成新流程,未重畫文字或箭頭。封面將完整原圖等比 fit 於白底 1600×900,無裁切、標題或新增圖元;鄰近的鈔票照片不在本文使用範圍。

    與基本 deterministic AE 不同,這條生成路徑不必先拿某筆 x 編成它自己的 latent code,再要求逐筆還原。G 從指定 noise 分布取得輸入;訓練時希望整體生成分布接近資料分布,而不是要求每個 z 與某張真實圖片有固定的一對一重建標籤。

    D 提供的訊號會隨訓練而變。當 G 改變,D 遇到的假樣本也改變;當 D 改變,G 接下來收到的梯度也改變。這是它與對固定目標函式做一般參數最佳化的重要差別,不能只把兩個各自下降的 loss 當成完成任務。

    真假 target 屬於哪個階段?

    先固定 G,讓 D 學會判別來源。最常見的二元約定是 real target=1、fake target=0;同一個 D 會處理兩種輸入,並非分成「看真圖的 D」與「看假圖的另一個 D」。

    圖 2|上下兩個 discriminator 圖示代表同一模型。fake→0、real→1 是 D 階段的訓練 targets,右側數字不是已學出的預測。G 階段另外希望 fake 被判為 real,不能從上列的 0 推論 G 也要朝 0 最佳化。來源:Mtanti,Generative Adversarial Network illustration,原英文圖作者 Mtanti;目前 SVG 修訂另含 Ksarasola 加入的巴斯克語翻譯層,本次呈現原英文層,CC BY-SA 4.0。完整原圖轉白底 PNG;手機先全圖,再放大原 noise/G/fake-input 區與 fake/real→D→targets 區,fake input 會重複。頂端標題完整保留在全圖,細區不重複標題;局部是讀圖輔助,不是新接起的網路,沒有新增字或箭頭,改作維持同授權。

    原始 GAN 的 value function 可以寫成:

    min_G max_D V(D,G)
    
    V(D,G) = E_real[log D(x)] + E_noise[log(1−D(G(z)))]
    

    D 最大化 V:把 real 的 D 值往 1 推,把 fake 的 D 值往 0 推。如果實作統一採最小化 loss,D 就最小化負號版本:

    L_D = −mean_real log D(x)
          −mean_noise log(1−D(G(z)))
    

    本篇原型取兩個 batch 各自的 mean 再相加。若 real 與 fake 筆數相等,把它們拼接後只做一次 mean,數值會少一半;那是另一種 reduction,不是這裡同一個 loss。比例也會影響固定 learning rate 下的更新量。

    程式讓 D 回傳未經 sigmoid 的 logit s,透過 binary_cross_entropy_with_logits 計算穩定的 sigmoid 加 BCE,而不是先 sigmoid 再把結果當 logits 傳進去。沒有額外權重時,單項公式為:

    BCEWithLogits(s, y) = softplus(s) − y·s
    softplus(s) = log(1+exp(s))
    
    ∂BCE/∂s = sigmoid(s) − y
    

    正文使用自然對數。這條簡單導數,接下來同時用來核對 D 與 G 的梯度。原論文 §3 與 Algorithm 1 定義了原始對抗目標與交替更新;PyTorch 2.3.1 的 BCEWithLogitsLoss 原始碼 則交代本篇 API 的 logits 與 reduction 約定。

    G 的 non-saturating loss,為什麼另選 target=1?

    在原始 minimax 形式中,固定 D 後,G 最小化 log(1−D(G(z)))。這會鼓勵 D 把生成樣本判得更像 real;但當 G 很差、D 對 fake 的判斷接近 0 時,這個 G 目標可能給出很弱的梯度。

    原始 GAN 論文 §3 就提出替代做法:改為最大化 log D(G(z)),也就是最小化 non-saturating loss:

    L_G_nonSat = −mean_noise log D(G(z))
    

    這相當於在 G 這個目標裡,對 D(G(z)) 使用 target=1。意思是 G 想讓生成物被 D 當真;它沒有把 D 訓練資料裡 fake 的來源標籤改成 real,也沒有要求在這一步順便把 D 訓練成接受所有假樣本。

    令 fake logit 為 s、D=sigmoid(s),兩種最小化形式的 logit 導數是:

    原 minimax G: log(1−D)    → 導數 −D
    non-saturating:−log D    → 導數 D−1
    

    固定 logit兩種 G loss 對 logit 的導數

    −8,D≈0.00033535
    minimax≈−0.00033535;non-saturating≈−0.99966465
    0,D=0.5
    兩者都是 −0.5
    8,D≈0.99966465
    minimax≈−0.99966465;non-saturating≈−0.00033535

    當 D 非常確定 fake 是假,第一列直接顯示 non-saturating 的優點:相同 logit 下,梯度幅度大得多。第三列也提醒它並非在每個 logit 都比較大。這只是對 logit 的局部導數;真正 G 參數梯度還要乘上穿過 D 與 G 的 Jacobian,不能從單個導數幅度保證整個模型訓練穩定。

    兩個 G 目標不是數值相同的 loss。原論文討論它們具有相同固定點,但這不代表可以混用 loss 數值,或把原 minimax 的所有理論公式直接當作 non-saturating loss 的恆等式。

    兩個更新階段,梯度路徑怎麼分?

    更新 D 時,當前 G 只是提供 fake 樣本。原型計算 fake=G(z),再把 fake.detach() 傳給 D,讓這個 D loss 對 G 的路徑在 fake 處中斷。接著只呼叫 D optimizer 的 step。

    更新 G 時剛好不同:需要保留 G(z)→D→G loss 的可微路徑,才能知道生成樣本往哪裡改能讓 D 更接受它。此時原型將 D 參數的 requires_grad 設為 False,固定 D 參數;但 D 的 forward 仍參與可微計算,梯度可以相對於它的輸入傳回 G。

    階段本篇原型保留與切斷的路徑

    更新 D
    fake.detach() 切斷 G;D 參數收到梯度並 step
    更新 G
    D 參數凍結;保留 D 對 fake 輸入的梯度,G 參數 step

    若把 G 階段的整個 D forward 放在 torch.no_grad() 裡,這個例子所需的路徑便被關閉,不能再靠此 G loss 把梯度傳回 G。requires_grad_(False)、detach() 和 no_grad() 各控制不同範圍,不能互相當成「反正都不要更新 D」的同義操作。PyTorch 2.3.1 no_grad 原始碼 說明了此 context 對梯度記錄的作用。

    還要分清「沒有 step」與「沒有算參數梯度」。只對 G optimizer step,即使算出 D 的參數梯度也不會由該 optimizer 更新 D;本例進一步凍結 D 參數並先清空舊 grad,讓檢查更明確。requires_grad 也不等於 .eval():若換成含 BatchNorm、dropout 的模型,其模式與 buffers 還需另行安排。本篇標量模型沒有那些層,不把這兩步擴張成所有 GAN 的完整狀態管理範本。

    真正走一次 SGD:D 先更新,G 再使用更新後的 D

    這次只固定兩個 noise 值與兩個 real 標量,沒有從資料分布抽樣:

    z = [−1, 1]
    real = [0.8, 1.2]
    
    G(z) = a·z+c,起始 a=0.4、c=−0.1
    D logit(x) = w·x+b,起始 w=1.2、b=−0.3
    D probability(x) = sigmoid(w·x+b)
    

    因此起始 fake 是 [-0.5, 0.3];real logits 為 [0.66, 1.14],fake logits 為 [-0.9, 0.06]。程式的 D forward 回傳的是 logit,不要把例如 1.14 當成超出範圍的機率。

    以單項導數 sigmoid(s)−y 接上 logit 的鏈式法則:

    ∂L_D/∂w = mean_real[(D(x)−1)x] + mean_fake[D(fake)·fake]
    ∂L_D/∂b = mean_real[D(x)−1]    + mean_fake[D(fake)]
    

    保存的數值是 real mean BCE 約 0.34706565920109705,fake mean BCE 約 0.5323754939041144,兩者相加 L_D≈0.8794411531052115。梯度與 learning rate 0.05 給出的實際 D 更新為:

    D 參數梯度與更新後數值

    w,原為 1.2
    梯度≈−0.27670136031806036;更新後≈1.213835068015903
    b,原為 −0.3
    梯度≈0.11049301322474825;更新後≈−0.3055246506612374

    這一步 G 的兩個參數仍是 0.4、−0.1,grad 都是 None;是因為 fake detach 後不再把 D loss 傳回 G。

    接著固定更新後的 w₁、b₁,重算 D(G(z)),不能假裝 D 還是原來的數值。對 G 的 non-saturating mean BCE,令每筆誤差信號 e=sigmoid(w₁(az+c)+b₁)−1:

    ∂L_G/∂a = mean[e·w₁·z]
    ∂L_G/∂c = mean[e·w₁]
    

    這時 L_G≈0.9571396633237046。D loss 是兩個 mean 相加,G loss 是一個 mean,且這個 G loss 使用更新後的 D;不能由 0.957 大於 0.879 就判定 G 比 D 學得差。以 learning rate 0.1 執行真正的 G optimizer step:

    G 參數梯度與更新後數值

    a,原為 0.4
    梯度≈0.13846940080558645;更新後≈0.3861530599194414
    c,原為 −0.1
    梯度≈−0.7276014944104432;更新後≈−0.027239850558955683

    此時 D 的參數仍保持上表更新後的值,grad 為 None,但 G 明確得到非零梯度。這便是「凍結 D 參數,仍保留對輸入梯度」的實際證據。

    程式用 autograd、直接以 Python math 展開的鏈式法則,以及固定輸入的中心差分核對四個梯度,再對照 parameter−learning_rate×gradient 與實際 SGD 結果。這四個標量確實更新了;它們沒有因此證明完整資料分布已學會,更沒有影像、多樣性或長期收斂的證據。

    D 都輸出 0.5,就表示 G 成功了嗎?

    不一定。假設一個完全沒學會判別的 D 對任何輸入都輸出 0.5,無論 G 產生什麼,它也都會輸出 0.5。只看 D 猜不出來,不能排除 D 欠擬合、沒更新成功,或評估資料本身出了問題。

    原始理論的條件更精確。固定 G、允許 D 在理想分布與足夠容量下達到最優時,在資料與生成密度總和非零的區域:

    D*(x) = p_data(x) / (p_data(x)+p_g(x))
    

    若兩個分布相同,這個最優 D 才是 1/2。把最優 D 代回原 minimax value,得到:

    C(G) = max_D V(D,G)
         = −log(4) + 2·JSD(p_data || p_g)
    

    JSD 是 Jensen–Shannon divergence,非負,在兩個分布相同時為零;於是這個理想 criterion 的全域最小值對應 p_g=p_data。這不是說只要某次 D loss 接近某個數字,就已量出 JSD,更不是 non-saturating G loss 的數值公式。

    原論文 §4–4.2 在非參數、理想容量的設定討論這些結果。收斂論述還要求每一步 D 到達對當前 G 的最優,並直接在分布空間以足夠小的改善步驟更新 p_g。真實訓練改的是有限 MLP 的參數,可能有多個 critical points;論文本身就提醒,這些理論保證不會直接跟著搬過去。

    因此,圖中兩模型交替更新是一個可實作機制;「普通 alternating SGD 必然到達理想均衡」則是另外一個沒有由此得到的保證。也不能把有限樣本上的 D accuracy 接近 50%,直接當作兩分布相同的證明。

    讀來源訓練圖:先看 real 是哪來的,再看 loss 的定義

    下面兩張是 D2L 原頁保存的 PyTorch 教學輸出。它們用二維高斯資料讓生成與判別可視化,沒有影像、照片或本站重跑。先看作為 real reference 的資料:

    圖 3|這是 1000 個標準常態二維點經 X@A+b 轉換後的前 100 個 reference points,並非 G 輸出。橫軸是資料第 0 維,縱軸是第 1 維,沒有額外物理單位;A=[[1,2],[−0.1,0.5]]、b=[1,2]。母體 mean 為 b,covariance 為 AᵀA,不代表這 100 點的樣本統計量剛好相等。來源:D2L〈Generative Adversarial Networks〉,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。保留原頁 PyTorch backend 的完整 SVG 輸出,轉白底 PNG、等比縮放,未更動點或坐標;改作維持同授權。

    來源先從 1000 個 2D normal rows 產生訓練資料,data=X@A+b,母體 covariance 是 [[1.01,1.95],[1.95,4.25]]。G 是 Linear(2,2),D 則是 2→5→3→1 的 MLP,中間兩層 Tanh。這樣的線性 G 配 Gaussian noise,有機會描述此例的 Gaussian 目標;不能直接把結果外推到複雜影像分布。

    圖 4|上方是來源 20 epochs 的 D/G loss,下方藍點為前 100 個 real reference、橘點為另抽 100 個 noise 經 G 產生的點;不是 100 個保留測試樣本,也不是影像品質分數。來源 PyTorch run 使用 batch=8、latent_dim=2、lr_D=0.05、lr_G=0.005、上述線性 G/Tanh MLP D,Adam 與 BCEWithLogitsLoss(sum),每 batch 先 D 再 G;loss 的正規化方式見下文。來源:D2L GAN 原章,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。原頁 PyTorch backend 完整 SVG 轉白底 PNG、等比縮放,未改曲線、圖例或資料點;本次未重跑來源訓練,改作維持同授權。

    來源把 G/D 的 weights 與 biases 初始化為 normal_(0,0.02),Adam 只明設 learning rate,其他使用來源當時 framework defaults;此段 code 沒有設定隨機 seed,圖是其保存的特定結果,不是本文保證逐點重現的輸出。

    它的 D loss 先對 real、fake 各求 BCE sum,再把兩項相加除以 2;每個 epoch 累計後除以處理的 examples 數。G loss 是 target=1 的 BCE sum,epoch 累計同樣除以 examples 數。這與本文標量原型的「real mean 加 fake mean,沒有再除以 2」不同。

    若 D 對所有輸入都為 0.5,單項 BCE 都是 log 2≈0.693147。來源這種 D 正規化會得到約 0.693147;本篇原型的兩個 mean 相加則會得到約 1.386294,G 的 non-saturating mean loss 為約 0.693147。可見不先對齊定義,直接比較兩張圖的 loss 高低沒有意義。來源兩條曲線接近 0.693,連同散點可以幫助理解這個例子,仍不足以單獨證明一般 GAN 成功、穩定或完整覆蓋資料分布。

    執行完整程式,檢查更新範圍與 before/after

    將下方完整程式存為 gan_minimax_updates.py,在已有 Python 與 PyTorch 的環境執行:

    python3 gan_minimax_updates.py > stdout.json
    

    保存的實際執行環境是 Python 3.10.12、PyTorch 2.3.1+cu118,所有張量在 CPU、float64 計算,沒有亂數或模型下載。套件 wheel 名稱含 cu118,不表示這次用過 GPU。這裡提供的是已裝環境的執行與算術核對,不宣稱全新 pip 安裝、其他平台或 GPU 已測。

    原型只執行固定輸入的一次 D step 與一次 G step。輸出的 checks 會檢查 autograd/手算/差分與 SGD 是否一致,但閱讀時也要對照 discriminator_step、generator_step 的參數前後值,以及另一模型的 grad 是否為 None;只有看到一排 True,還不等於理解更新路徑。

    """A deterministic scalar GAN update example; not a neural-network training run."""
    import json
    import math
    import platform
    
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    DTYPE = torch.float64
    DEVICE = torch.device("cpu")
    
    
    class ScalarGenerator(nn.Module):
        def __init__(self):
            super().__init__()
            self.a = nn.Parameter(torch.tensor(0.4, dtype=DTYPE, device=DEVICE))
            self.c = nn.Parameter(torch.tensor(-0.1, dtype=DTYPE, device=DEVICE))
    
        def forward(self, z):
            return self.a * z + self.c
    
    
    class ScalarDiscriminator(nn.Module):
        def __init__(self):
            super().__init__()
            self.w = nn.Parameter(torch.tensor(1.2, dtype=DTYPE, device=DEVICE))
            self.b = nn.Parameter(torch.tensor(-0.3, dtype=DTYPE, device=DEVICE))
    
        def forward(self, x):
            # Return logits. BCEWithLogitsLoss applies a numerically stable sigmoid+BCE.
            return self.w * x + self.b
    
    
    def f(x):
        return float(x.detach().cpu()) if isinstance(x, torch.Tensor) else float(x)
    
    
    def sigmoid_scalar(x):
        # Stable scalar sigmoid without autograd.
        if x >= 0:
            e = math.exp(-x)
            return 1.0 / (1.0 + e)
        e = math.exp(x)
        return e / (1.0 + e)
    
    
    def softplus_scalar(x):
        # Stable log(1 + exp(x)); used for independent loss/finite-difference checks.
        return max(x, 0.0) + math.log1p(math.exp(-abs(x)))
    
    
    def bce_logit_scalar(logit, target):
        return softplus_scalar(logit) - target * logit
    
    
    def mean(xs):
        return sum(xs) / len(xs)
    
    
    def d_loss_scalar(w, b, z_values, real_values, a_value, c_value):
        fake_values = [a_value * z + c_value for z in z_values]
        real_loss = mean([bce_logit_scalar(w * x + b, 1.0) for x in real_values])
        fake_loss = mean([bce_logit_scalar(w * x + b, 0.0) for x in fake_values])
        # The two batch means are summed; this is not one mean over a 2B concatenation.
        return real_loss + fake_loss
    
    
    def central_difference(fn, value, h=1e-6):
        return (fn(value + h) - fn(value - h)) / (2.0 * h)
    
    
    z = torch.tensor([-1.0, 1.0], dtype=DTYPE, device=DEVICE)
    real = torch.tensor([0.8, 1.2], dtype=DTYPE, device=DEVICE)
    G = ScalarGenerator()
    D = ScalarDiscriminator()
    
    # --- Discriminator step: hold G fixed and detach fake samples. ---
    a0, c0 = f(G.a), f(G.c)
    w0, b0 = f(D.w), f(D.b)
    fake0 = G(z)
    real_logits0 = D(real)
    fake_logits0 = D(fake0.detach())
    d_real_loss = F.binary_cross_entropy_with_logits(
        real_logits0, torch.ones_like(real_logits0), reduction="mean"
    )
    d_fake_loss = F.binary_cross_entropy_with_logits(
        fake_logits0, torch.zeros_like(fake_logits0), reduction="mean"
    )
    d_loss = d_real_loss + d_fake_loss
    D.zero_grad(set_to_none=True)
    d_loss.backward()
    d_grads_torch = {"w": f(D.w.grad), "b": f(D.b.grad)}
    
    # Independent scalar chain-rule derivatives, using only Python math.
    real_x = [0.8, 1.2]
    z_values = [-1.0, 1.0]
    fake_x0 = [a0 * zi + c0 for zi in z_values]
    d_dw_manual = mean([
        (sigmoid_scalar(w0 * x + b0) - 1.0) * x for x in real_x
    ]) + mean([
        sigmoid_scalar(w0 * x + b0) * x for x in fake_x0
    ])
    d_db_manual = mean([
        sigmoid_scalar(w0 * x + b0) - 1.0 for x in real_x
    ]) + mean([
        sigmoid_scalar(w0 * x + b0) for x in fake_x0
    ])
    d_w_fd = central_difference(lambda w: d_loss_scalar(w, b0, z_values, real_x, a0, c0), w0)
    d_b_fd = central_difference(lambda b: d_loss_scalar(w0, b, z_values, real_x, a0, c0), b0)
    
    D_LR = 0.05
    d_optimizer = torch.optim.SGD(D.parameters(), lr=D_LR)
    d_before = {"w": f(D.w), "b": f(D.b)}
    d_optimizer.step()
    d_after = {"w": f(D.w), "b": f(D.b)}
    d_update_expected = {
        "w": d_before["w"] - D_LR * d_grads_torch["w"],
        "b": d_before["b"] - D_LR * d_grads_torch["b"],
    }
    g_grads_none_after_d = G.a.grad is None and G.c.grad is None
    g_params_after_d = {"a": f(G.a), "c": f(G.c)}
    assert g_grads_none_after_d and g_params_after_d == {"a": a0, "c": c0}  # fake detached; G unchanged
    
    # --- Non-saturating generator step: freeze D parameters, but keep its input path. ---
    D.zero_grad(set_to_none=True)
    for parameter in D.parameters():
        parameter.requires_grad_(False)
    
    G.zero_grad(set_to_none=True)
    fake_for_g = G(z)
    g_logits = D(fake_for_g)
    g_loss = F.binary_cross_entropy_with_logits(
        g_logits, torch.ones_like(g_logits), reduction="mean"
    )
    g_loss.backward()
    g_grads_torch = {"a": f(G.a.grad), "c": f(G.c.grad)}
    
    w1, b1 = f(D.w), f(D.b)
    # Independent scalar derivatives with D held fixed at its post-D-step values.
    def g_loss_scalar(a, c):
        return mean([
            bce_logit_scalar(w1 * (a * zi + c) + b1, 1.0) for zi in z_values
        ])
    
    sigmoid_errors = [sigmoid_scalar(w1 * (a0 * zi + c0) + b1) - 1.0 for zi in z_values]
    g_da_manual = mean([error * w1 * zi for error, zi in zip(sigmoid_errors, z_values)])
    g_dc_manual = mean([error * w1 for error in sigmoid_errors])
    g_a_fd = central_difference(lambda a: g_loss_scalar(a, c0), a0)
    g_c_fd = central_difference(lambda c: g_loss_scalar(a0, c), c0)
    
    G_LR = 0.1
    g_optimizer = torch.optim.SGD(G.parameters(), lr=G_LR)
    g_before = {"a": f(G.a), "c": f(G.c)}
    g_optimizer.step()
    g_after = {"a": f(G.a), "c": f(G.c)}
    g_update_expected = {
        "a": g_before["a"] - G_LR * g_grads_torch["a"],
        "c": g_before["c"] - G_LR * g_grads_torch["c"],
    }
    assert D.w.grad is None and D.b.grad is None  # D is frozen, but D(fake) was not no_grad.
    
    # Compare original minimax G objective to the non-saturating objective in logit space.
    # Stable forms: log(1 - sigmoid(s)) = -softplus(s); -log(sigmoid(s)) = softplus(-s).
    logit_comparison = []
    for s in (-8.0, 0.0, 8.0):
        p = sigmoid_scalar(s)
        logit_comparison.append({
            "fake_logit": s,
            "D_probability": p,
            "original_minimax_G_loss_log1_minus_D": -softplus_scalar(s),
            "original_minimax_dloss_dlogit": -p,
            "non_saturating_G_loss_minus_log_D": softplus_scalar(-s),
            "non_saturating_dloss_dlogit": p - 1.0,
        })
    
    checks = {
        "D_manual_chainrule_matches_autograd": all(abs(d_grads_torch[k] - v) < 1e-12 for k, v in {"w": d_dw_manual, "b": d_db_manual}.items()),
        "D_central_difference_matches_manual": abs(d_w_fd - d_dw_manual) < 1e-7 and abs(d_b_fd - d_db_manual) < 1e-7,
        "D_scalar_SGD_updates_match": all(abs(d_after[k] - d_update_expected[k]) < 1e-14 for k in d_after),
        "G_manual_chainrule_matches_autograd": abs(g_grads_torch["a"] - g_da_manual) < 1e-12 and abs(g_grads_torch["c"] - g_dc_manual) < 1e-12,
        "G_central_difference_matches_manual": abs(g_a_fd - g_da_manual) < 1e-7 and abs(g_c_fd - g_dc_manual) < 1e-7,
        "G_scalar_SGD_updates_match": all(abs(g_after[k] - g_update_expected[k]) < 1e-14 for k in g_after),
        "G_gradients_absent_and_params_unchanged_during_D_step": g_grads_none_after_d and g_params_after_d == {"a": a0, "c": c0},
        "D_grad_none_and_unchanged_by_G_step": D.w.grad is None and D.b.grad is None and d_after == {"w": f(D.w), "b": f(D.b)},
    }
    assert all(checks.values()), checks
    
    result = {
        "scope": "Fixed scalar GAN gradient/update mechanics only; no data training, neural-network image generation, convergence, quality, or benchmark claims.",
        "environment": {"python": platform.python_version(), "torch": torch.__version__, "device": "cpu", "dtype": "float64", "randomness": "none"},
        "inputs": {"z": z.tolist(), "real": real.tolist(), "G_initial": {"a": a0, "c": c0}, "D_initial": {"w": w0, "b": b0}},
        "discriminator_step": {
            "loss_definition": "mean BCEWithLogits(real, 1) + mean BCEWithLogits(fake.detach(), 0)",
            "separate_real_mean": f(d_real_loss), "separate_fake_mean": f(d_fake_loss), "summed_loss": f(d_loss),
            "before": d_before, "grad_autograd": d_grads_torch,
            "grad_manual_chainrule": {"w": d_dw_manual, "b": d_db_manual},
            "grad_central_difference": {"w": d_w_fd, "b": d_b_fd},
            "learning_rate": D_LR, "expected_after": d_update_expected, "actual_after": d_after,
            "G_grads_after_D_step": {"a": None, "c": None},
            "G_parameters_after_D_step": g_params_after_d,
        },
        "generator_step": {
            "loss_definition": "mean BCEWithLogits(D(G(z)), 1), the non-saturating objective; D parameters frozen but forward path differentiable",
            "loss": f(g_loss), "D_fixed_at": {"w": w1, "b": b1},
            "before": g_before, "grad_autograd": g_grads_torch,
            "grad_manual_chainrule": {"a": g_da_manual, "c": g_dc_manual},
            "grad_central_difference": {"a": g_a_fd, "c": g_c_fd},
            "learning_rate": G_LR, "expected_after": g_update_expected, "actual_after": g_after,
            "D_grads_after_G_step": {"w": None, "b": None},
            "D_parameters_after_G_step": {"w": f(D.w), "b": f(D.b)},
        },
        "logit_gradient_comparison": logit_comparison,
        "checks": checks,
    }
    print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False))
    

    跑完後,可以先回答:為什麼 D 階段的 fake 要 detach,而 G 階段不能把整個 D forward 包進 no_grad?為什麼 G 使用的是 D 更新後的 w、b?為什麼 source plot 的 D loss 在 0.693 附近,與本篇兩個 mean 相加的數值不能直接對照?能用上述算式和輸出回答,才把「兩個模型互相學習」落到可檢查的梯度與更新。

    在真正專案裡,還要分開看樣本品質、多樣性、與資料的分布差異和評估程序;本篇固定標量、D2L 二維散點與理論最優 D,各自只提供其中一段理解。它們不構成影像 GAN 的訓練完成證明,也不能以一條漂亮 loss 曲線代替對生成結果的檢查。

    來源與延伸閱讀

    • 原始方法:Goodfellow 等,Generative Adversarial Nets,2014 v1 PDF;arXiv 版本頁。本篇依 §3 的 minimax/non-saturating 目標、Algorithm 1、§4–4.2 的理想化分析;原論文圖像保持連結,未複製論文像素。原 Algorithm 1 可做 k 次 D 更新再做一次 G,本文固定為各一次的標量示範,亦沒有重現原論文的抽樣與 optimizer 設定。
    • 圖 1、手機版與封面:Google Developers〈Overview of GAN Structure〉;原 SVG;Google Developers、CC BY 4.0。不採該頁鈔票照片,本次改作為 white render/resize、手機原 loss 框 rotate/resize、封面完整 fit。
    • 圖 2、手機版:Mtanti 原作品頁,原英文作者 Mtanti;目前修訂含 Ksarasola 的巴斯克語譯層,本次渲染英文層,CC BY-SA 4.0。手機保留 whole-first、重用原圖細區,變更已列於圖說,不暗示作者背書。
    • 圖 3–4:D2L GAN 原章與 PyTorch 教學;固定教材原始碼,四作者 Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。固定 code 用來核方法,保存的圖則另對應 live PyTorch backend,不把不同 framework 畫面算成不同實驗,也不把書籍 grant 擴到資料集角色圖像或外部照片。本文圖 2–4 的改作均維持 SA4;Google BY4 分開適用。
    • API:PyTorch 2.3.1 loss.py 與 grad_mode.py。本機安裝版本對應的 BCEWithLogitsLoss、no_grad 原始碼也已核讀,未把較新 API 行為當成本次實跑。
    • 本文原創教學是從共享 D、targets、兩種 G 目標與兩階段梯度重組流程,手算固定標量 before/after,並分開解讀教材結果與理論假設。標量原型有真正參數更新;沒有語料或影像資料訓練、完整分布學習、收斂或生成品質 benchmark。讀者可用完整程式核對此有限機制。

    留言0

    登入後就能留言。

    登入
    sunny 桑尼
    sunny 桑尼

    AI 資料科學 · 產品經理 · 蒙奇 D 桑尼

    專注於 AI 工具應用與資料科學教育,致力於讓每個人都能用 AI 提升工作效率。曾任職於科技業,現為全職教育創作者。

    讀更多文章 →想自己動手試?領免費 Starter Kit →