সরাসরি পর্বে যান
পর্ব ৭৭ · ৯১-এর মধ্যে · মডিউল ১২

GAN — generator, discriminator আর প্রশিক্ষণের অস্থিরতা

Generative Adversarial Networks — The Minimax Game, the Optimal Discriminator, the Non-Saturating Loss and Why GAN Training Is Unstable

Taught in Bangla, this lesson explains the GAN game between generator and discriminator, works one training step by hand, proves the optimal discriminator and its link to JS divergence, and contrasts saturating and non-saturating generator loss gradients. CPU runs on a 1-D toy, an eight-Gaussian ring and an MLP GAN on MNIST measure mode collapse with a separate classifier and show how learning rate and seed change the outcome, with in-page Python and NumPy.

এই পর্বে যা শিখবেন

  • Generator, discriminator আর minimax objective; একটি পূর্ণ training ধাপ হাতে হিসাব
  • Optimal discriminator-এর সূত্র প্রমাণ, আর JS divergence-এর সঙ্গে তার যোগসূত্র
  • Saturating আর non-saturating generator loss-এর gradient-এর পার্থক্য
  • Mode collapse, মরে যাওয়া gradient আর oscillation চেনা ও মাপা
  • আলাদা classifier দিয়ে mode coverage মাপা, আর learning rate ও seed-এর প্রভাব সৎভাবে লেখা

পর্ব ৭৪ (Generative modeling-এর মানচিত্র), ৭৫ (Autoencoder) আর ৭৬ (Variational autoencoder)-এর model-গুলো কোনো না কোনোভাবে likelihood বা তার একটি সীমা বাড়ায়। GAN পুরো অন্য পথে হাঁটে: সে কখনো p(x) লিখে না, শুধু নমুনা বানায়, আর একটি দ্বিতীয় network তাকে বলে নমুনাগুলো আসলের মতো লাগছে কি না। এই পর্বে সেই খেলার গণিত (minimax, optimal discriminator, JS divergence), generator-এর loss কেন বদলাতে হয়, আর training কেন এত অস্থির, সবই হাতে হিসাব আর সত্যিকারের run দিয়ে দেখবেন। Binary cross-entropy আর BCEWithLogitsLoss এই কোর্সের পর্ব ১৩-এ, Adam পর্ব ১৫-এ, আর KL divergence AI-এর ভিত্তি কোর্সের পর্ব ৩৮-এ; এখানে শুধু এক লাইনে মনে করিয়ে দেওয়া হবে। PyTorch পাতায় চলে না, তাই PyTorch-এর সব আউটপুট ডেস্কটপের সত্যিকারের run থেকে; পাতায় চালানো যায় এমন চারটি ব্লক NumPy, pandas আর matplotlib দিয়ে (প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে)।

১ · জাল নোটের কারবারি আর ব্যাংকের যাচাইকারী — GAN-এর মূল ধারণা

ঈদের আগে বাজারে জাল নোট বাড়ে। এক কারবারি নোট বানায়, আর ব্যাংকের এক যাচাইকারী আসল আর জাল আলাদা করে। প্রথমে কারবারির নোট কাঁচা, যাচাইকারী সহজেই ধরে ফেলেন। কিন্তু প্রতিবার ধরা পড়ার পর কারবারি জানতে পারে কোন দিক দেখে ধরা হলো (জলছাপ, নিরাপত্তা সুতা), আর সেটা ঠিক করে। যাচাইকারীও নতুন কৌশল শেখেন। দুজনই যদি সমানতালে উন্নতি করে, শেষে কারবারির নোট এত নিখুঁত হয় যে যাচাইকারী আর আন্দাজের চেয়ে ভালো করতে পারেন না। GAN-এর দুই network ঠিক এই দুই চরিত্র। (বাস্তবের জাল নোট অপরাধ; এখানে শুধু খেলার কাঠামোটুকু নিচ্ছি।)

GAN (generative adversarial network) দুটি network নিয়ে গঠিত:

  • Generator G: একটি এলোমেলো latent vector z (যেমন ৬৪টি সংখ্যা, প্রতিটি N(0, 1) থেকে) নিয়ে একটি নমুনা x̃ = G(z) বানায়, যেমন একটি ২৮ × ২৮ ছবি। z বদলালে ছবি বদলায়, তাই G আসলে একটি সহজ বণ্টনকে (Gaussian) জটিল বণ্টনে (হাতে লেখা অঙ্ক) রূপান্তর করে। এই রূপান্তরিত বণ্টনের নাম pg.
  • Discriminator D: একটি ছবি x নিয়ে একটি সংখ্যা D(x) ∈ (০, ১) দেয়: “এটি আসল ডেটা থেকে এসেছে” তার সম্ভাবনা। এটি সাধারণ একটি binary classifier, শেষে sigmoid.

GAN-এর গঠন — দুই network, একটি খেলা

G নমুনা বানায়, D আসল আর নকল আলাদা করে, D-এর gradient G-তে ফেরে

GAN architecture: generator, discriminator and the gradient path noise z z ~ N(0, I) Generator G নকল ছবি x̃ = G(z) আসল ছবি x ডেটাসেট থেকে Discriminator D D(·) ∈ (0, 1) আসল হওয়ার সম্ভাবনা gradient ফেরত যায়
ছবি ৭৭.১ — D দুই দিক থেকে ছবি পায় আর আসল-নকল আলাদা করতে শেখে। G কখনো আসল ছবি সরাসরি দেখে না; সে শুধু D-এর মতামতের gradient থেকে শেখে, যা D-এর ভেতর দিয়ে নকল ছবি হয়ে G-এর weight পর্যন্ত backpropagate হয়।

এখানে একটি সূক্ষ্ম কিন্তু জরুরি বিষয় আছে। G দিয়ে আমরা নমুনা বানাতে পারি, কিন্তু কোনো একটি ছবি x-এর pg(x) কত, তা বের করার উপায় নেই। এ ধরনের model-কে বলে implicit generative model: বণ্টন আছে, কিন্তু তার সূত্র নেই। তাই GAN-কে likelihood দিয়ে train করা যায় না, আর likelihood দিয়ে মাপাও যায় না। এর বদলে একটি classifier-কে বিচারক বানানো হয়। এটাই GAN-এর সবচেয়ে বড় চালাকি, আর পরে দেখবেন এটাই তার অনেক সমস্যার উৎস।

ইতিহাস: GAN প্রস্তাব করেন Ian Goodfellow ও সহকর্মীরা, “Generative Adversarial Nets” প্রবন্ধে (NeurIPS, তখনকার নাম NIPS, ২০১৪)। জাল নোটের কারবারি আর পুলিশের উপমাটি সেই প্রবন্ধেই আছে। দুটি network-কে প্রতিযোগী বানিয়ে শেখানোর ধারণার আগের রূপও আছে, যেমন Jürgen Schmidhuber-এর ১৯৯০-এর দশকের শুরুর “predictability minimization” আর artificial curiosity-র কাজ; কার কৃতিত্ব কতটা, তা নিয়ে গবেষকদের মধ্যে প্রকাশ্য বিতর্ক হয়েছে, তাই এখানে কোনো পক্ষ নিচ্ছি না। ২০১৪ থেকে প্রায় ২০২০ পর্যন্ত ছবি তৈরির সবচেয়ে চোখে-পড়া ফল এসেছিল GAN-এর বিভিন্ন রূপ থেকে; পরে diffusion model (পর্ব ৭৯) অনেক জায়গায় তার জায়গা নেয়।

২ · Minimax objective আর একটি training ধাপ

দুই খেলোয়াড়ের লক্ষ্য একটি সংখ্যা দিয়ে লেখা যায়। Goodfellow ও সহকর্মীদের মূল সূত্র:

minG maxD V(D, G) = Ex∼pdata[ln D(x)] + Ez∼pz[ln(1 − D(G(z)))]

এখানে E মানে গড় (expectation), x∼pdata মানে আসল ডেটা থেকে একটি নমুনা, z∼pz মানে noise থেকে একটি নমুনা। প্রথম পদ বড় হয় যদি D আসল ছবিতে ১-এর কাছে বলে; দ্বিতীয় পদ বড় হয় যদি D নকলে ০-এর কাছে বলে। D চায় V বড় করতে (max), G চায় V ছোট করতে (min), অর্থাৎ নকলে D(G(z))-কে ১-এর দিকে ঠেলতে। একজনের লাভ ঠিক অন্যজনের ক্ষতি, তাই এটি একটি zero-sum game, আর লক্ষ্যটি একটি minimax objective.

D-এর দিক থেকে দেখলে, −V হলো সাধারণ binary cross-entropy: আসল ছবির লেবেল ১, নকলের লেবেল ০. তাই PyTorch-এ D-এর loss হুবহু পর্ব ১৩-এর BCEWithLogitsLoss. Training একটি খেলা, তাই একটি সাধারণ loop-এ দুই খেলোয়াড় পালা করে চাল দেয়:

প্রতিটি training ধাপে:
  ১. আসল ছবির একটি batch x, আর noise z থেকে নকল x̃ = G(z)
  ২. D-এর ধাপ:  L_D = BCE(D(x), 1) + BCE(D(x̃), 0)   ← x̃ detach, G স্থির
  ৩. G-এর ধাপ:  L_G = BCE(D(x̃), 1)   ← "non-saturating" রূপ, অংশ ৪
                 gradient D-এর ভেতর দিয়ে যায়, কিন্তু শুধু G-এর weight বদলায়

নিচের stepper-এ সবচেয়ে ছোট একটি GAN-এর একটি পূর্ণ ধাপ হাতে করুন। ডেটা এক-মাত্রার: একটি আসল সংখ্যা x = ৪.০. Generator G(z) = μ + σz, যেখানে μ আর σ তার দুটি parameter. Discriminator D(x) = sigmoid(wx + c). Learning rate η = ০.১, সাধারণ gradient descent.

x = 4.0, z = 0.5, η = 0.1 · D(x) = sigmoid(wx + c)
ধাপ
হিসাব
  1. শুরু. Generator μ = ০, σ = ১; noise z = ০.৫ থেকে নকল x̃ = ০ + ১ × ০.৫ = ০.৫. আসল সংখ্যা ৪.০, অর্থাৎ নকল এখনো অনেক দূরে।
  2. D-এর অনুমান. Logit s = wx + c: আসলে ১.০, নকলে −০.৭৫. Sigmoid দিয়ে D(x) = ০.৭৩১১ আর D(x̃) = ০.৩২০৮. D ইতিমধ্যে কিছুটা ঠিক বলছে।
  3. D-এর loss. আসলের BCE −ln ০.৭৩১১ = ০.৩১৩৩, নকলের −ln(১ − ০.৩২০৮) = ০.৩৮৬৯; মোট ০.৭০০১.
  4. D-এর gradient. BCE-র logit-gradient “অনুমান − লেবেল”: আসলে ০.৭৩১১ − ১, নকলে ০.৩২০৮ − ০. w-এর gradient-এ প্রতিটি x দিয়ে গুণ: −০.২৬৮৯ × ৪ + ০.৩২০৮ × ০.৫ = −০.৯১৫৪.
  5. D হালনাগাদ. w ← w − η × gradient = ০.৫৯১৫, c ← −১.০০৫২. এখন D আসলে ০.৭৯৫৯ আর নকলে ০.৩২৯৭ দেয়: আসলের দিকে বেশি নিশ্চিত হয়েছে।
  6. G-এর loss. নতুন D নিয়ে LG = −ln D(x̃) = ১.১০৯৫. Chain rule: ∂L/∂s = D − ১ = −০.৬৭০৩, আর ∂s/∂x̃ = w, তাই ∂L/∂x̃ = −০.৩৯৬৫. ঋণাত্মক মানে x̃ বাড়ালে loss কমে।
  7. G হালনাগাদ. ∂x̃/∂μ = ১ আর ∂x̃/∂σ = z = ০.৫, তাই μ ০.০৩৯৬ বাড়ল, σ ০.০১৯৮. নতুন নকল ০.৫৪৯৬, D তাকে ০.৩৩৬২ দেয়, আগের ০.৩২৯৭-এর চেয়ে বেশি। G আসলের দিকে এক পা এগোল।

লক্ষ্য করুন G-এর gradient-এ w আছে: G কোন দিকে যাবে, তা পুরোপুরি D-এর ঢালের ওপর নির্ভর করে। D যদি এমন জায়গায় চ্যাপ্টা হয় যেখানে নকলগুলো আছে, তবে G কোনো দিক পায় না। অংশ ৪ আর ৫-এর সমস্যাগুলো এখান থেকেই জন্মায়। PyTorch-এ দুটি ব্যবহারিক কথা: D-এর ধাপে নকল ছবি xf.detach() করে দিন, যাতে D-এর loss G-এর weight-এ gradient না পাঠায়; আর G-এর ধাপে শুধু G-এর optimizer step() করে, যদিও gradient D-এর ভেতর দিয়ে আসে (দুটি আলাদা optimizer, প্রতিটির আগে আলাদা zero_grad ডাকা)।

৩ · Optimal discriminator আর JS divergence

খেলাটি শেষে কোথায় থামা উচিত? প্রথমে G স্থির রেখে প্রশ্ন করুন: সেরা D কী? V-কে একটি integral হিসেবে লিখুন (z-এর গড়কে x̃-এর বণ্টন pg-এর গড়ে বদলে):

V(D, G) = ∫ [ pdata(x) ln D(x) + pg(x) ln(1 − D(x)) ] dx

প্রতিটি x-এ আলাদা করে D(x) বাছা যায়। a = pdata(x), b = pg(x) আর y = D(x) ধরলে প্রতিটি x-এ সর্বোচ্চ করতে হবে a ln y + b ln(১ − y). Derivative শূন্য করুন: a/y − b/(১ − y) = ০, অর্থাৎ a(১ − y) = by, তাই y = a/(a + b). এটিই optimal discriminator:

D*(x) = pdata(x) / ( pdata(x) + pg(x) )

Second derivative −a/y² − b/(১ − y)² ঋণাত্মক, তাই এটি সত্যিই সর্বোচ্চ বিন্দু। এবার D* বসিয়ে দিন। একটু সাজালে (প্রতিটি ln-এর ভেতরে ২ গুণ আর ভাগ করে) আসে:

V(D*, G) = −ln 4 + KL(pdata ‖ m) + KL(pg ‖ m) = −ln 4 + 2·JS(pdata ‖ pg)

যেখানে m = (pdata + pg)/২ দুই বণ্টনের গড়, KL হলো KL divergence, আর JS divergence (Jensen–Shannon) = ½KL(p ‖ m) + ½KL(q ‖ m). JS সবসময় ০ থেকে ln ২-এর মধ্যে, আর শুধু দুই বণ্টন হুবহু এক হলে ০. তাই সেরা D-এর বিপরীতে G-এর কাজ হলো JS কমানো, আর সর্বনিম্ন বিন্দু pg = pdata, যেখানে D* = ½ সব জায়গায় আর V = −ln ৪ ≈ −১.৩৮৬৩. এটিই মূল প্রবন্ধের প্রধান তাত্ত্বিক ফল। নিচের stepper-এ দুটি Gaussian দিয়ে সংখ্যায় দেখুন।

D*(x) = p_data(x) / (p_data(x) + p_g(x))
ধাপ
p_data(x)
p_g(x)
ফল
  1. সূত্র. আসল ডেটা N(৪, ১), এখনকার generator N(২, ১), দুটি একই আকারের Gaussian, দুই একক দূরে।
  2. x = ১. এখানে প্রায় সব ঘনত্ব generator-এর (০.২৪২০ বনাম ০.০০৪৪), তাই সেরা D বলে “আসল হওয়ার সম্ভাবনা মাত্র ০.০১৮০”.
  3. x = ৩. দুই বণ্টনের ঠিক মাঝখানে ঘনত্ব সমান, তাই D* = ০.৫: এখানে সেরা বিচারকও আন্দাজের বেশি পারে না।
  4. x = ৫. উল্টো ছবি: প্রায় সব ঘনত্ব আসল ডেটার, D* = ০.৯৮২০.
  5. সরল রূপ. সমান variance-এর দুই Gaussian-এ log অনুপাত x-এ রৈখিক: −(x − ৪)²/২ + (x − ২)²/২ = ২x − ৬. তাই D* = sigmoid(২x − ৬), একটি logistic regression! আসলে সবসময় D* = sigmoid(ln pdata − ln pg): সেরা D-এর logit হলো দুই density-র log অনুপাত।
  6. খেলার মান. সংখ্যায় integral করে JS = ০.৩৩৬৮, তাই V(D*, G) = −ln ৪ + ২ × ০.৩৩৬৮ = −০.৭১২৬.
  7. G জিতলে. pg = pdata হলে প্রতিটি x-এ D* = ০.৫, JS = ০, আর V = −ln ৪ = −১.৩৮৬৩, খেলার সর্বনিম্ন মান।

এবার একই কথা পাতায় সংখ্যায় যাচাই করুন: D* আর sigmoid(২x − ৬) মেলে কি না, V(D*) আর −ln ৪ + ২·JS মেলে কি না, আর অন্য কোনো D কি V-কে D*-এর চেয়ে বড় করতে পারে।

import numpy as np

def pdf(x, m, s=1.0):                             # Gaussian density N(m, s²)
    return np.exp(-(x - m) ** 2 / (2 * s * s)) / (s * np.sqrt(2 * np.pi))

p_data = lambda x: pdf(x, 4.0)                    # আসল ডেটা N(4, 1)
p_g = lambda x: pdf(x, 2.0)                       # এখনকার generator N(2, 1)
D_star = lambda x: p_data(x) / (p_data(x) + p_g(x))

for x in (1.0, 3.0, 5.0):
    print(f"x={x}: D*={D_star(x):.4f} | sigmoid(2x−6)={1 / (1 + np.exp(-(2 * x - 6))):.4f}")

x = np.linspace(-6, 12, 200_001); dx = x[1] - x[0]
P, Q = p_data(x), p_g(x)

def V(D):                                         # V(D, G) = E_data[ln D] + E_g[ln(1 − D)]
    return (P * np.log(D)).sum() * dx + (Q * np.log(1 - D)).sum() * dx

M = (P + Q) / 2
JS = 0.5 * (P * np.log(P / M)).sum() * dx + 0.5 * (Q * np.log(Q / M)).sum() * dx
print(f"V(D*) = {V(D_star(x)):.4f} | −ln4 + 2·JS = {-np.log(4) + 2 * JS:.4f} | JS = {JS:.4f}")

for name, D in [("D* থেকে একটু সরানো", np.clip(D_star(x) + 0.05, 1e-9, 1 - 1e-9)),
                ("ঢাল কম sigmoid(x−3)", 1 / (1 + np.exp(-(x - 3)))),
                ("সব জায়গায় 0.5", np.full_like(x, 0.5))]:
    print(f"{name:20s}: V = {V(D):.4f}")

আউটপুট (পাতার Python-এ চালানো):

x=1.0: D*=0.0180 | sigmoid(2x−6)=0.0180
x=3.0: D*=0.5000 | sigmoid(2x−6)=0.5000
x=5.0: D*=0.9820 | sigmoid(2x−6)=0.9820
V(D*) = -0.7126 | −ln4 + 2·JS = -0.7126 | JS = 0.3368
D* থেকে একটু সরানো  : V = -0.8480
ঢাল কম sigmoid(x−3) : V = -0.8137
সব জায়গায় 0.5     : V = -1.3863

প্রথম তিন সারিতে D* আর sigmoid(২x − ৬) হুবহু এক, যেমন stepper-এর ৫ নম্বর ধাপ বলেছিল। চতুর্থ সারিতে দুটি পথে একই সংখ্যা, −০.৭১২৬: সংখ্যায় integral করা V(D*) আর −ln ৪ + ২·JS. শেষ তিন সারিতে অন্য তিনটি D: D*-কে সামান্য সরালে, কম ঢালের sigmoid নিলে, বা সব জায়গায় ০.৫ বললে V কমে যায় (−০.৮৪৮০, −০.৮১৩৭, −১.৩৮৬৩)। D* সত্যিই V-এর সর্বোচ্চ বিন্দু। সব জায়গায় ০.৫ বলা D-এর মান ঠিক −ln ৪, কারণ তখন ln ০.৫ দুই পদেই।

তত্ত্ব আর বাস্তবের ফাঁক। উপরের প্রমাণ ধরে নেয় তিনটি জিনিস: D যেকোনো function হতে পারে, প্রতিটি G-ধাপের আগে D পুরো সেরা অবস্থায় পৌঁছায়, আর আমরা সরাসরি pg-কে বদলাই। বাস্তবে D একটি সীমিত network, প্রতি G-ধাপে D মাত্র এক বা কয়েক ধাপ শেখে, আর আমরা বদলাই G-এর weight, যার সঙ্গে pg-এর সম্পর্ক জটিল ও non-convex. মূল প্রবন্ধও এই সীমাবদ্ধতা স্বীকার করেছিল। তাই “GAN JS কমায়” কথাটি একটি আদর্শ অবস্থার গল্প; training-এর আসল আচরণ বুঝতে অংশ ৪ আর ৫ দরকার।

৪ · Non-saturating loss — generator-এর gradient কেন মরে যায়

Minimax সূত্রে G কমায় ln(১ − D(G(z))). সমস্যা training-এর শুরুতে: G-এর ছবি তখন হিজিবিজি, আর D সহজেই শেখে যে এগুলো নকল, তাই D(G(z)) ≈ ০. কিন্তু ln(১ − D) ফাংশনটি D ≈ ০-এর কাছে প্রায় সমতল। D-এর logit s ধরে (D = sigmoid(s)) chain rule:

  • Saturating (মূল minimax): LG = ln(১ − D), ∂LG/∂s = −D. D ≈ ০ হলে gradient ≈ ০.
  • Non-saturating: LG = −ln D, ∂LG/∂s = −(১ − D). D ≈ ০ হলে gradient ≈ −১, পূর্ণ জোর।

দুটির লক্ষ্য একই দিকে: D(G(z)) বাড়ানো। পার্থক্য শুধু gradient-এর আকারে, ঠিক সেই সময়ে যখন G সবচেয়ে খারাপ আর সংশোধন সবচেয়ে দরকার। মূল প্রবন্ধই এই সমস্যার কথা বলে আর −ln D বাড়ানোর পরামর্শ দেয়। PyTorch-এ এটি খুব সরল: নকল ছবিকে “আসল” লেবেল দিয়ে BCE, BCEWithLogitsLoss(D(G(z)), 1). নিচের stepper-এ D যত নিশ্চিত হয়, দুই gradient-এর ফারাক কত দ্রুত বাড়ে দেখুন।

D = D(G(z)) = sigmoid(s); gradient = ∂L_G/∂s
D(G(z))
saturating
non-saturating
অনুপাত
  1. D দ্বিধায় (০.৫). দুই loss-এর gradient সমান, −০.৫. এখানে কোনো পার্থক্য নেই।
  2. D মোটামুটি নিশ্চিত (০.১). Saturating-এর gradient −০.১, non-saturating-এর −০.৯: ৯ গুণ।
  3. D বেশ নিশ্চিত (০.০১). Saturating loss প্রায় শূন্য (−০.০১০১) আর প্রায় সমতল, gradient −০.০১; non-saturating −০.৯৯, ৯৯ গুণ জোরালো।
  4. D প্রায় নিখুঁত (০.০০১). অনুপাত ৯৯৯. Training-এর শুরুতে D প্রায়ই এই অবস্থায় থাকে, তাই saturating loss-এ G প্রায় কিছুই শেখে না।
  5. সারকথা. অনুপাত (১ − D)/D. Saturating loss ঠিক তখনই চুপ করে যখন G সবচেয়ে পিছিয়ে; non-saturating তখনই সবচেয়ে জোরে ধাক্কা দেয়।

এবার অংশ ২-এর খেলনা আরেকটু বড় করে দুই loss পাশাপাশি চালান। আসল ডেটা N(৪, ১.২৫²), G(z) = μ + σz শুরু করে μ = −২ থেকে, অর্থাৎ ভুল জায়গায়। D এবার একটু শক্তিশালী: logit s = a·u² + b·u + c, যেখানে u = x/৪, যাতে দুটি Gaussian-এর log অনুপাত (একটি quadratic) পুরোপুরি ধরতে পারে। আগে ৩০০ ধাপ শুধু D শেখে, যাতে শুরুতে D নিশ্চিত থাকে; তারপর প্রতিটি G-ধাপে D-এর ৫ ধাপ।

import numpy as np

sig = lambda s: 1 / (1 + np.exp(-s))
feat = lambda x: np.stack([(x / 4) ** 2, x / 4, np.ones_like(x)])   # D: s = a·u² + b·u + c, u = x/4

def d_step(th, mu, sd, rng, lr=0.5):             # একটি discriminator ধাপ (batch 256)
    xr = rng.normal(4.0, 1.25, 256)               # আসল ডেটা N(4, 1.25²)
    xf = mu + sd * rng.normal(size=256)           # নকল: x = μ + σ·z
    gr = sig(th @ feat(xr)) - 1                   # ∂L_D/∂s, আসল
    gf = sig(th @ feat(xf))                       # ∂L_D/∂s, নকল
    return th - lr * (feat(xr) @ gr + feat(xf) @ gf) / 256

def train(kind, steps=600, lr_g=0.05, k=5):
    rng = np.random.default_rng(42)
    th, mu, sd = np.zeros(3), -2.0, 1.0           # generator শুরু করে ভুল জায়গায়
    for _ in range(300):                          # আগে D-কে দক্ষ করে তোলা
        th = d_step(th, mu, sd, rng)
    for t in range(steps + 1):
        z = rng.normal(size=256); x = mu + sd * z
        D = sig(th @ feat(x))
        if t % 100 == 0:
            print(f"{kind:3s} ধাপ {t:3d}: μ={mu:5.2f} σ={sd:4.2f} গড় D(G(z))={D.mean():.3f}")
        ds = (D - 1) if kind == "ns" else -D      # ∂L_G/∂s: −ln D বনাম ln(1 − D)
        dx = ds * (2 * th[0] * x / 4 + th[1]) / 4  # chain rule: ∂s/∂x
        mu -= lr_g * dx.mean()
        sd = abs(sd - lr_g * (dx * z).mean())
        for _ in range(k):                        # প্রতি G ধাপে D-এর k ধাপ
            th = d_step(th, mu, sd, rng)

train("sat"); train("ns")

আউটপুট (পাতার Python-এ চালানো):

sat ধাপ   0: μ=-2.00 σ=1.00 গড় D(G(z))=0.023
sat ধাপ 100: μ=-1.72 σ=1.52 গড় D(G(z))=0.047
sat ধাপ 200: μ=-0.87 σ=2.95 গড় D(G(z))=0.183
sat ধাপ 300: μ=-0.11 σ=3.48 গড় D(G(z))=0.220
sat ধাপ 400: μ= 0.52 σ=3.60 গড় D(G(z))=0.277
sat ধাপ 500: μ= 1.09 σ=3.53 গড় D(G(z))=0.298
sat ধাপ 600: μ= 1.63 σ=3.32 গড় D(G(z))=0.327
ns  ধাপ   0: μ=-2.00 σ=1.00 গড় D(G(z))=0.023
ns  ধাপ 100: μ= 3.95 σ=0.39 গড় D(G(z))=0.499
ns  ধাপ 200: μ= 4.19 σ=0.29 গড় D(G(z))=0.496
ns  ধাপ 300: μ= 4.18 σ=0.37 গড় D(G(z))=0.479
ns  ধাপ 400: μ= 4.14 σ=0.73 গড় D(G(z))=0.477
ns  ধাপ 500: μ= 4.01 σ=1.46 গড় D(G(z))=0.507
ns  ধাপ 600: μ= 3.84 σ=1.82 গড় D(G(z))=0.502

শুরুতে দুই run-এই D(G(z)) মাত্র ০.০২৩: D নিশ্চিত। Saturating loss-এ ১০০ ধাপে μ এগিয়েছে মাত্র −২.০০ থেকে −১.৭২; ৬০০ ধাপ পরেও ১.৬৩-এ, লক্ষ্য ৪ থেকে অনেক দূরে, আর σ ফুলে ৩-এর বেশি। Non-saturating-এ ১০০ ধাপেই μ = ৩.৯৫ আর D(G(z)) ≈ ০.৫. কিন্তু খেয়াল করুন σ: ১০০ ধাপে ০.৩৯, যেখানে আসল ১.২৫, তারপর ০.২৯ → ০.৩৭ → ০.৭৩ → ১.৪৬ → ১.৮২. G একটি স্থির উত্তরে থামেনি, আসল মানের চারপাশে দোল খাচ্ছে, আর এক সময় প্রায় একটি বিন্দুতে গুটিয়ে গিয়েছিল। ছোট একটি খেলনাতেই পরের অংশের দুই অসুখ, collapse আর oscillation, দেখা দিল।

একটি সতর্কতা: non-saturating loss আর minimax-এর “সঠিক” খেলা নয়। সেরা D-এর বিপরীতে −ln D কমানো একটি ভিন্ন divergence-এর মিশ্রণ কমায় (Arjovsky আর Bottou, ICLR ২০১৭-এর বিশ্লেষণ অনুযায়ী KL(pg ‖ pdata) − ২·JS); সেই KL-এর দিক এমন যে আসল ডেটার কোনো অংশ বাদ পড়লে (mode হারালে) শাস্তি কম, কিন্তু অবাস্তব নমুনায় শাস্তি বেশি। তাই non-saturating loss-কে mode collapse-এর একটি সম্ভাব্য কারণ হিসেবেও দেখা হয়, যদিও এই ব্যাখ্যা নিয়ে গবেষকদের মতভেদ আছে (প্র ০২)। এটি সবচেয়ে প্রচলিত ব্যবহারিক পছন্দ, কিন্তু কোনো জাদু নয়।

৫ · অস্থিরতার তিন রূপ — mode collapse, মরে যাওয়া gradient আর oscillation

সাধারণ classifier-এর training একটি পাহাড় বেয়ে নামা: loss কমলেই ভালো। GAN-এ দুটি network একে অপরের পাহাড় বদলে দেয়। তাই loss কমা-বাড়া দেখে অগ্রগতি বোঝা যায় না, আর তিনটি পরিচিত সমস্যা দেখা দেয়:

Mode collapse

আসল ডেটায় অনেক “mode” (দল) আছে, যেমন ১০টি অঙ্ক। G দেখে যে কয়েকটি ধরন বানালেই D-কে ফাঁকি দেওয়া যায়, তাই বাকিগুলো বানানো ছেড়ে দেয়। পুরো collapse-এ সব z থেকে প্রায় একই ছবি আসে; আংশিক collapse-এ কয়েকটি শ্রেণি বাদ পড়ে, যাকে mode dropping বলা হয়। প্রতিটি ছবি দেখতে ভালো হলেও বৈচিত্র্য নেই।

মরে যাওয়া gradient

D খুব তাড়াতাড়ি নিখুঁত হলে D(G(z)) ≈ ০, আর saturating loss-এ G-এর gradient প্রায় শূন্য (অংশ ৪)। আরও গভীর কারণ: ছবির মতো ডেটা একটি নিম্ন-মাত্রার “পাতলা” অংশে থাকে, তাই pg আর pdata প্রায়ই একেবারে আলাদা জায়গায় থাকে। তখন একটি নিখুঁত D প্রায় সবসময় সম্ভব আর JS আটকে থাকে ln ২-এ, যা G-কে কোনো দিক দেখায় না।

Oscillation

খেলার লক্ষ্য একটি Nash equilibrium: এমন অবস্থা যেখানে কোনো খেলোয়াড় একা চাল বদলে লাভ পায় না। কিন্তু gradient descent একটি সর্বনিম্ন বিন্দু খোঁজার পদ্ধতি, সাম্যাবস্থা খোঁজার নয়। পালা করে চাল দিলে দুই খেলোয়াড় একে অপরকে তাড়া করে ঘুরতে পারে, যেমন অংশ ৪-এর σ.

Oscillation-এর সবচেয়ে সরল উদাহরণ একটি খেলা: f(x, y) = x·y, যেখানে x কমাতে চায় আর y বাড়াতে চায়। সাম্যাবস্থা (০, ০). একসঙ্গে gradient ধাপ নিলে x ← x − η·y আর y ← y + η·x; প্রতি ধাপে দূরত্বের বর্গ (১ + η²) গুণ বাড়ে, তাই বিন্দুটি (০, ০)-এর চারপাশে সর্পিল পথে বাইরে চলে যায়, কখনো থামে না। GAN-এর অনেক stabilization কৌশল (দুই আলাদা learning rate, gradient penalty) এই ঘূর্ণন থামানোর চেষ্টা।

এবার একটি পরিচিত পরীক্ষা নিজে চালাই: ২-মাত্রার সমতলে একটি বৃত্তে (ব্যাসার্ধ ২) ৮টি ছোট Gaussian (প্রতিটির standard deviation ০.০৫)। প্রতিটি Gaussian একটি mode. এই খেলনাটি mode collapse দেখাতে Metz ও সহকর্মীদের “Unrolled GAN” প্রবন্ধ (ICLR ২০১৭) থেকে জনপ্রিয় হয়েছে। G: ১৬-মাত্রার z → 128 → 128 → 2, D: 2 → 128 → 128 → 1, ReLU, Adam (lr ১০−৩, β₁ = ০.৫), batch ২৫৬, ৫,০০০ ধাপ। প্রতি ১,০০০ ধাপে ২,০০০টি নমুনা নিয়ে গোনা হলো কতগুলো mode “ধরা পড়েছে” (কোনো কেন্দ্রের ৩σ = ০.১৫-এর মধ্যে অন্তত ১% নমুনা), আর কত ভাগ নমুনা কোনো কেন্দ্রের কাছে (hq, অর্থাৎ high quality নমুনার ভাগ)। দুই loss, প্রতিটি পাঁচটি seed.

সত্যিকারের run (ডেস্কটপ, CPU-তে, ৩টি thread):

~/bazarghar-dl$ uv run python ring_gan.py
torch 2.15.0 | CPU, 3 threads | 8-Gaussian ring r=2, std=0.05
G: 16 -> 128 -> 128 -> 2, D: 2 -> 128 -> 128 -> 1, Adam lr 1e-3 betas (0.5, 0.999), batch 256
loss=ns  seed=0 modes@1k..5k: 0 8 8 8 8 | final hq 0.89 D(G(z)) 0.492 counts [168, 275, 250, 222, 202, 155, 253, 254]
loss=ns  seed=1 modes@1k..5k: 7 6 6 6 6 | final hq 0.94 D(G(z)) 0.430 counts [273, 355, 0, 312, 334, 323, 285, 0]
loss=ns  seed=2 modes@1k..5k: 3 6 3 3 3 | final hq 0.83 D(G(z)) 0.305 counts [6, 798, 0, 797, 10, 18, 9, 23]
loss=ns  seed=3 modes@1k..5k: 8 8 8 8 8 | final hq 0.93 D(G(z)) 0.527 counts [230, 170, 119, 355, 285, 230, 246, 229]
loss=ns  seed=4 modes@1k..5k: 5 7 7 7 7 | final hq 0.93 D(G(z)) 0.423 counts [310, 0, 143, 323, 478, 295, 154, 160]
loss=sat seed=0 modes@1k..5k: 8 8 8 8 8 | final hq 0.93 D(G(z)) 0.485 counts [201, 252, 215, 238, 218, 243, 244, 243]
loss=sat seed=1 modes@1k..5k: 8 8 8 8 8 | final hq 0.91 D(G(z)) 0.473 counts [226, 222, 203, 261, 252, 261, 191, 208]
loss=sat seed=2 modes@1k..5k: 8 8 8 8 8 | final hq 0.94 D(G(z)) 0.517 counts [208, 252, 219, 237, 228, 246, 236, 250]
loss=sat seed=3 modes@1k..5k: 6 8 8 8 8 | final hq 0.94 D(G(z)) 0.530 counts [269, 225, 190, 247, 253, 256, 223, 207]
loss=sat seed=4 modes@1k..5k: 8 8 8 8 8 | final hq 0.93 D(G(z)) 0.472 counts [211, 230, 251, 231, 224, 204, 252, 249]
time 117 s (CPU)

এই টেবিলটি একটু সাবধানে পড়ুন। Non-saturating loss-এ seed ০ আর ৩ আটটি mode-ই ধরেছে। Seed ১ শেষে ৬টি (দুটি কেন্দ্রে ০টি নমুনা), seed ৪ সাতটি। Seed ২ সবচেয়ে শিক্ষণীয়: mode-এর সংখ্যা ৩ → ৬ → ৩ → ৩ → ৩; শেষে প্রায় সব নমুনা মাত্র দুটি কেন্দ্রে (৭৯৮ আর ৭৯৭)। প্রতিটি নমুনা দেখতে নিখুঁত (hq ০.৮৩), কিন্তু বৈচিত্র্য নেই: এটিই mode collapse, আর মাঝে ৬-এ উঠে আবার ৩-এ নামা হলো oscillation-এর চিহ্ন। আশ্চর্যের কথা, এই খেলনায় saturating loss-এর পাঁচটি seed-ই ৮টি mode ধরেছে। কারণ এখানে G শুরু করে কেন্দ্রের কাছে, বৃত্তের ভেতরে, তাই শুরুতে D খুব নিশ্চিত হয় না (অংশ ৪-এর সমস্যাটি দেখা দেয় না)। অর্থাৎ “non-saturating সবসময় ভালো” বলা যায় না; কোন loss কখন ভালো, তা পরিস্থিতির ওপর নির্ভর করে। এটি ছোট একটি খেলনায় একটি স্থানীয় run, সাধারণ নিয়ম নয়।

৬ · MNIST-এ MLP GAN — loss, নমুনা আর classifier দিয়ে mode coverage

এবার আসল ছবি। ডেটা MNIST: ২৮ × ২৮ পিক্সেলের হাতে লেখা অঙ্ক, ৬০,০০০ train ছবি (LeCun, Cortes আর Burges; লাইসেন্স সাধারণত CC BY-SA 3.0 বলে উল্লেখ করা হয়, তবু ব্যবহারের আগে নিজে যাচাই করুন)। এখানে বাজারঘরের পণ্যের ছবি নয়, অঙ্ক নিচ্ছি, কারণ অঙ্কের ১০টি শ্রেণি mode গোনার জন্য পরিষ্কার মাপকাঠি দেয়। পিক্সেল [−১, ১]-এ আনা হয়েছে, যাতে G-এর শেষের tanh-এর পরিসরের সঙ্গে মেলে।

  • G: z (৬৪-মাত্রা) → 256 → 512 → 1024 → 784, মাঝে LeakyReLU (০.২), শেষে tanh; ১৪,৭৭,১৩৬টি parameter.
  • D: 784 → 512 → 256 → 1 (logit), LeakyReLU (০.২); ৫,৩৩,৫০৫টি parameter.
  • Training: দুটি Adam optimizer, lr ২ × ১০−৪, β = (০.৫, ০.৯৯৯), batch ১২৮, non-saturating loss, ৩০ epoch, seed ০.
  • বিচারক: আলাদাভাবে train করা একটি ছোট CNN digit classifier (MNIST test accuracy ০.৯৮৩৬, CPU-তে ২৭ সেকেন্ড)। প্রতি ৫ epoch-এ একটি স্থির z-এর সেট থেকে ১,০০০টি নমুনা বানিয়ে classifier দিয়ে শ্রেণি গোনা হয়: কয়টি শ্রেণি অন্তত ২% নমুনা পেল, শ্রেণি-হিস্টোগ্রামের entropy (bit-এ; ১০টি সমান শ্রেণিতে সর্বোচ্চ log2 ১০ ≈ ৩.৩২), আর classifier-এর গড় confidence.

সত্যিকারের run (ডেস্কটপ, CPU-তে, ৩টি thread; torch 2.15.0, Python 3.14.4-এ)। প্রথম সারি তুলনার জন্য আসল test set-এর ১,০০০টি ছবি; বাকি সারি প্রতি epoch-এর JSON log থেকে একটি ছোট script দিয়ে টেবিলে সাজানো, কয়েকটি epoch বাদ দিয়ে সংক্ষেপিত:

~/bazarghar-dl$ uv run python mnist_gan.py main
torch 2.15.0 | CPU | real test 1000: hist [86, 125, 111, 110, 109, 84, 86, 98, 97, 94] classes>=2% 10 entropy 3.310 bits conf 0.977
ep  lossD  lossG  D(x)   D(G(z)) | classes  entropy  conf  histogram 0..9
1   1.194  0.933  0.666  0.408
2   1.007  1.555  0.684  0.233
5   0.934  2.033  0.712  0.189   | 7        1.436    0.773 [0, 24, 44, 35, 30, 4, 36, 0, 754, 73]
9   0.835  2.027  0.739  0.184
10  0.851  2.033  0.760  0.189   | 8        2.880    0.794 [8, 15, 132, 129, 46, 36, 65, 139, 264, 166]
13  1.043  1.321  0.648  0.318
15  1.113  1.399  0.663  0.297   | 10       3.100    0.828 [41, 110, 79, 165, 94, 62, 51, 127, 230, 41]
20  1.173  1.126  0.600  0.362   | 10       3.178    0.878 [33, 116, 73, 168, 76, 55, 73, 99, 167, 140]
25  1.158  1.066  0.598  0.380   | 10       3.261    0.890 [37, 130, 75, 117, 83, 102, 99, 126, 123, 108]
30  1.210  1.072  0.590  0.376   | 10       3.251    0.901 [50, 126, 55, 152, 92, 97, 90, 130, 117, 91]
time 240.7 s

তিনটি জিনিস লক্ষ্য করুন। এক, শুরুতে collapse. ৫ম epoch-এ ১,০০০টি নমুনার ৭৫৪টিকে classifier “৮” বলেছে, আর ৭ ও ০-এর একটিও নেই। প্রথম দিকের ঝাপসা গোল আকৃতিগুলো classifier-এর চোখে ৮-এর মতো, আর G কিছু সময় সেখানেই আটকে ছিল। দুই, loss দিয়ে অগ্রগতি বোঝা যায় না. D-এর loss ১.১৯ থেকে নেমে ৯ম epoch-এ ০.৮৪, তারপর আবার উঠে ১.২১; D(G(z)) ০.৪১ থেকে ০.১৮-এ নেমে আবার ০.৩৮. Classifier-এর মাপে কিন্তু নমুনা প্রায় ধারাবাহিকভাবে ভালো হয়েছে: শ্রেণি ৭ → ৮ → ১০, entropy ১.৪৪ → ৩.২৫, confidence ০.৭৭ → ০.৯০. তিন, পুরো কভারেজ মানে সমান কভারেজ নয়. ৩০তম epoch-এ দশটি শ্রেণিই আছে, কিন্তু “৩” ১৫২টি আর “০” মাত্র ৫০টি, “২” ৫৫টি; আসল test-এ প্রতিটি ৮৪ থেকে ১২৫. Entropy ৩.২৫১, আসলের ৩.৩১০-এর একটু নিচে। Confidence ০.৯০১, আসল ছবির ০.৯৭৭-এর নিচে: কিছু নমুনা classifier-এর কাছে দ্ব্যর্থক।

নিচের ব্লক একই run-এর আসল নমুনা আঁকে: একটি স্থির z-এর সেট থেকে ১ম আর ৫ম epoch-এর ৮টি করে, আর ৩০তম epoch-এর ১৬টি। ছবিগুলো ছোট রাখতে প্রতিটি পিক্সেল ১৬টি ধূসর স্তরে (৪ bit) রাখা হয়েছে, আর base64 করে পাতায় বসানো (প্রায় ১৭ KB); শেষ লাইনে ৩০তম epoch-এর ছবিগুলোর ওপর classifier-এর অনুমান, desktop run থেকে নেওয়া।

import base64
import numpy as np, matplotlib.pyplot as plt

raw = np.frombuffer(base64.b64decode(open("gan_samples.b64").read()), np.uint8)
px = np.stack([raw >> 4, raw & 15], axis=1).reshape(-1, 28, 28).astype(int) * 17   # 4 bit → 0..255
print("ছবি:", px.shape, "| পিক্সেলের পরিসর:", px.min(), "থেকে", px.max())

groups = {"epoch 1": px[:8], "epoch 5": px[8:16], "epoch 30": px[16:]}
for name, g in groups.items():
    ink = (g > 128).mean()                                # উজ্জ্বল পিক্সেলের ভাগ
    diff = g.reshape(len(g), -1).std(axis=0).mean()       # নমুনাগুলো পরস্পর কতটা আলাদা
    print(f"{name:8s}: কালির ভাগ {ink:.3f} | নমুনার মধ্যে গড় পার্থক্য (std) {diff:5.1f}")

pred30 = [4, 1, 3, 3, 1, 7, 9, 2, 6, 0, 3, 7, 7, 0, 7, 7]   # desktop classifier, epoch 30
print("epoch 30-এর ১৬টি ছবিতে classifier-এর অনুমান:", pred30)

fig, axes = plt.subplots(4, 8, figsize=(8, 4.6))
for i, ax in enumerate(axes.flat):
    ax.imshow(px[i], cmap="gray", vmin=0, vmax=255)
    ax.set_xticks([]); ax.set_yticks([])
for r, t in enumerate(["epoch 1", "epoch 5", "epoch 30", "epoch 30"]):
    axes[r, 0].set_ylabel(t, fontsize=9)
fig.suptitle("Real samples from one MLP-GAN run on MNIST (seed 0, 4-bit)")
plt.show()

আউটপুট (পাতার Python-এ চালানো; নিচে নমুনার একটি চিত্র আঁকা হয়):

ছবি: (32, 28, 28) | পিক্সেলের পরিসর: 0 থেকে 255
epoch 1 : কালির ভাগ 0.204 | নমুনার মধ্যে গড় পার্থক্য (std)  13.7
epoch 5 : কালির ভাগ 0.138 | নমুনার মধ্যে গড় পার্থক্য (std)  30.8
epoch 30: কালির ভাগ 0.112 | নমুনার মধ্যে গড় পার্থক্য (std)  40.8
epoch 30-এর ১৬টি ছবিতে classifier-এর অনুমান: [4, 1, 3, 3, 1, 7, 9, 2, 6, 0, 3, 7, 7, 0, 7, 7]

চিত্রের প্রথম সারিতে ১ম epoch-এর ৮টি ছবি প্রায় একই রকম ঝাপসা দাগ, দ্বিতীয় সারিতে ৫ম epoch-এ আকৃতি আসছে, আর শেষ দুই সারিতে ৩০তম epoch-এর অঙ্কগুলো চেনা যায়। সংখ্যাগুলোও একই কথা বলে। “নমুনার মধ্যে গড় পার্থক্য” মানে প্রতিটি পিক্সেলে নমুনাগুলোর standard deviation-এর গড়: ১ম epoch-এ মাত্র ১৩.৭, অর্থাৎ ভিন্ন ভিন্ন z থেকেও প্রায় একই ছবি, শুরুর collapse-এর সরাসরি চিহ্ন; ৫ম epoch-এ ৩০.৮, ৩০তম-এ ৪০.৮. কালির ভাগ ০.২০৪ থেকে ০.১১২-এ নেমেছে: শুরুর মোটা দাগ সরু কলমের রেখা হয়েছে। ১৬টি ছবিতে classifier দেখেছে ৪, ১, ৩, ৩, ১, ৭, ৯, ২, ৬, ০, ৩, ৭, ৭, ০, ৭, ৭: ছোট নমুনাতেই “৭” পাঁচবার আর “৫”, “৮” একবারও নেই, যদিও ১,০০০টি নমুনায় দশটি শ্রেণিই ছিল। তাই ১৬টি ছবি দেখে বৈচিত্র্যের সিদ্ধান্ত নেবেন না; গোনার জন্য বড় নমুনা আর classifier লাগে। ৪ bit-এ রাখার পরেও classifier-এর ১৬টি অনুমান মূল ছবির অনুমানের সঙ্গে হুবহু মিলেছে (desktop-এ যাচাই করা)।

৭ · Learning rate, seed আর loss — একই কোড, ভিন্ন ভাগ্য

একটি run দেখে কিছু বলা যায় না, অংশ ৫-এর বৃত্তেই তা দেখেছেন। তাই একই কোড আরও ১২ বার চালানো হলো, প্রতিটি ১৫ epoch: non-saturating loss-এ তিনটি learning rate (৫ × ১০−৫, ২ × ১০−৪, ১০−৩), প্রতিটি seed ১, ২, ৩; আর saturating loss-এ lr ২ × ১০−৪, seed ১, ২, ৩. অংশ ৬-এর seed ০-এর ১৫তম epoch-ও তুলনায় রাখা হয়েছে। সব run CPU-তে, প্রতিটি প্রায় দেড় থেকে আড়াই মিনিট।

সবচেয়ে চমকপ্রদ ফলটি সবচেয়ে ছোট learning rate-এর। তার একটি run-এর সত্যিকারের log (সংক্ষেপিত, কয়েকটি epoch বাদ):

~/bazarghar-dl$ uv run python mnist_gan.py sweep2
torch 2.15.0 | CPU | real test 1000: hist [86, 125, 111, 110, 109, 84, 86, 98, 97, 94] classes>=2% 10 entropy 3.310 bits conf 0.977
ep  lossD  lossG  D(x)   D(G(z)) | classes  entropy  conf  histogram 0..9
RUN {"lr": 5e-05, "seed": 1, "epochs": 15, "eval_every": 15}
1   1.143  0.731  0.693  0.484
3   0.800  1.428  0.760  0.245
5   0.582  2.257  0.800  0.119
8   0.175  3.461  0.937  0.037
10  0.102  4.170  0.958  0.019
12  0.004  6.519  0.998  0.002
15  0.000  9.188  1.000  0.000   | 1        -0.000    0.968 [0, 0, 0, 0, 0, 0, 0, 0, 1000, 0]
time 100.8 s
…

এখানে D ধীরে ধীরে খেলা জিতে নিয়েছে: D(x) ০.৬৯ থেকে ১.০০০, D(G(z)) ০.৪৮ থেকে ০.০০০, D-এর loss শূন্যে, G-এর loss ৯.১৯-এ। ১,০০০টি নমুনার সবকটিকে classifier “৮” বলেছে: সম্পূর্ণ mode collapse. বাকি দুটি seed-এও একই (১৫তম epoch-এ D(G(z)) ০.০১৬ আর ০.০০২)। একটি সম্ভাব্য ব্যাখ্যা: আসল আর নকল আলাদা করা সহজ কাজ, ছবি বানানো কঠিন; ছোট learning rate-এ G প্রথম দিকের ঝাপসা “৮”-এর পর্যায় থেকে বেরোনোর আগেই D নিখুঁত হয়ে যায়। Non-saturating loss-ও এখানে বাঁচায়নি, কারণ নিখুঁত D-এর চারপাশে নকলের জায়গায় D-এর ঢাল নিজেই প্রায় সমতল হয়ে যেতে পারে। ৩০ epoch চালালে G বেরিয়ে আসত কি না, তা এই পরীক্ষায় মাপা হয়নি।

পুরো sweep-এর ১৫তম epoch-এর শ্রেণি-পরিসংখ্যান নিচে একটি CSV হিসেবে বসানো (desktop run-এর log থেকে, সংখ্যা অপরিবর্তিত)। ব্লকটি প্রতিটি run দেখায়, তারপর প্রতিটি setting-এর গড় আর seed-এর বিস্তার, আর saturating loss-এ epoch অনুযায়ী ধরা পড়া শ্রেণি।

import numpy as np, pandas as pd, matplotlib.pyplot as plt

pd.set_option("display.width", 100)
df = pd.read_csv("gan_coverage.csv")                     # সত্যিকারের desktop run-এর log
e15 = df[df.epoch == 15].copy()                          # সব run-এর তুলনা ১৫তম epoch-এ
e15["run"] = e15["loss"] + " lr=" + e15["lr"].map("{:g}".format)
cols = ["run", "seed", "classes", "entropy", "conf", "top_share"]
print(e15[cols].to_string(index=False))

summ = e15.groupby("run").agg(seeds=("seed", "size"), classes=("classes", "mean"),
                              H_mean=("entropy", "mean"), H_sd=("entropy", "std"),
                              conf=("conf", "mean"))
print(summ.round(3).to_string())

sat = df[df["loss"] == "sat"].pivot(index="epoch", columns="seed", values="classes")
print("saturating loss, ধরা পড়া শ্রেণি (সারি epoch, কলাম seed):")
print(sat.to_string())

fig, ax = plt.subplots(figsize=(7, 3.4))
for i, (name, g) in enumerate(e15.groupby("run")):
    ax.scatter(np.full(len(g), i), g["entropy"], s=40)
ax.axhline(3.310, ls="--", c="gray"); ax.text(-0.4, 3.33, "real test images", fontsize=8)
ax.set_xticks(range(e15["run"].nunique()), sorted(e15["run"].unique()), fontsize=8)
ax.set_ylabel("class entropy (bits)"); ax.set_title("Mode coverage at epoch 15, one dot per seed")
plt.show()

আউটপুট (পাতার Python-এ চালানো; নিচে একটি চিত্রও আঁকা হয়):

run  seed  classes  entropy  conf  top_share
 ns lr=0.0002     0       10    3.100 0.828      0.230
 ns lr=0.0002     1       10    3.126 0.841      0.186
 ns lr=0.0002     2       10    3.174 0.842      0.210
 ns lr=0.0002     3       10    3.180 0.838      0.177
  ns lr=0.001     1       10    3.291 0.899      0.149
  ns lr=0.001     2       10    3.292 0.889      0.147
  ns lr=0.001     3       10    3.268 0.887      0.166
  ns lr=5e-05     1        1    0.000 0.968      1.000
  ns lr=5e-05     2        1    0.000 0.991      1.000
  ns lr=5e-05     3        1    0.000 0.953      1.000
sat lr=0.0002     1       10    3.131 0.823      0.195
sat lr=0.0002     2       10    3.144 0.842      0.197
sat lr=0.0002     3       10    3.146 0.801      0.195
               seeds  classes  H_mean   H_sd   conf
run                                                
ns lr=0.0002       4     10.0   3.145  0.039  0.837
ns lr=0.001        3     10.0   3.284  0.014  0.892
ns lr=5e-05        3      1.0   0.000  0.000  0.971
sat lr=0.0002      3     10.0   3.140  0.008  0.822
saturating loss, ধরা পড়া শ্রেণি (সারি epoch, কলাম seed):
seed    1   2   3
epoch            
5       4   5   6
10      8   7   8
15     10  10  10

প্রথম টেবিলে প্রতিটি run, দ্বিতীয়টিতে setting অনুযায়ী গড়। lr ৫ × ১০−৫: তিনটি seed-ই একটিমাত্র শ্রেণি (top_share ১.০০০, entropy ০)। Classifier-এর confidence (০.৯৭১) এখানে আসল ছবির কাছাকাছি, অর্থাৎ “নিশ্চিত শ্রেণি” দেখে মান বিচার করলে ভুল হতো; বৈচিত্র্য আলাদা করে মাপতে হয়। lr ২ × ১০−৪: চারটি seed-এ গড় entropy ৩.১৪৫, বিস্তার ০.০৩৯; প্রতিটিতে দশটি শ্রেণি, কিন্তু সবচেয়ে বড় শ্রেণি ১৭.৭% থেকে ২৩.০%, যেখানে সমান ভাগে প্রতিটি শ্রেণি পেত ১০% করে। lr ১০−৩: গড় entropy ৩.২৮৪, আসল ছবির ৩.৩১০-এর সবচেয়ে কাছে, confidence-ও বেশি (০.৮৯২)। অর্থাৎ এই ছোট MLP-তে ১৫ epoch-এর বাজেটে বড় learning rate-ই ভালো করেছে; বেশি epoch-এ ফল বদলাতে পারে। Saturating loss: ৫ম epoch-এ মাত্র ৪, ৫ আর ৬টি শ্রেণি (non-saturating-এর seed ০-এ ৭টি), কিন্তু ১৫তম epoch-এ তিনটিতেই দশটি আর entropy ৩.১৪০, non-saturating-এর প্রায় সমান। এখানে ৫ম epoch-এও D(G(z)) ০.১০ থেকে ০.১৭ ছিল (১৫তম epoch-এ ০.৩৫ থেকে ০.৩৮), D খুব নিশ্চিত ছিল না, তাই অংশ ৪-এর gradient-সমস্যা বড় হয়ে দেখা দেয়নি। চিত্রে প্রতিটি বিন্দু একটি seed; ধূসর রেখা আসল test ছবির entropy.

সৎভাবে লেখার নিয়ম

GAN-এর কোনো ফল লিখলে seed-এর সংখ্যা, learning rate আর epoch সঙ্গে লিখুন, আর গড়ের পাশে বিস্তার দিন। এখানে learning rate-এর প্রভাব (একটি শ্রেণি থেকে দশটি) seed-এর প্রভাবের (entropy-তে কয়েক শতাংশ) চেয়ে অনেক বড়, আর saturating বনাম non-saturating-এর পার্থক্য এই setting-এ ১৫তম epoch-এ প্রায় অদৃশ্য। এগুলো একটি ছোট MLP আর MNIST-এ একটি স্থানীয় পরীক্ষা: অন্য network বা ডেটায় সেরা learning rate আলাদা হতে পারে, তাই নিজের task-এ নিজে মাপুন।

৮ · GAN-এর রোগনির্ণয় — লক্ষণ দেখে কারণ খোঁজা

ডাক্তার যেমন জ্বর দেখে সরাসরি ওষুধ দেন না, আগে লক্ষণ মিলিয়ে কারণ খোঁজেন, GAN-এর training-ও তেমনভাবে পড়তে হয়। নিচের টেবিল এই পর্বের পরীক্ষাগুলো থেকে পাওয়া লক্ষণগুলো একসঙ্গে রাখে। প্রথম কলামের সংখ্যাগুলো ছাপতে খরচ সামান্য, তাই প্রতি epoch-এ ছাপুন।

লক্ষণসম্ভাব্য কারণপ্রথমে কী চেষ্টা করবেন
D-এর loss → ০, D(G(z)) → ০, সব নমুনা একই রকম (অংশ ৭-এর lr ৫ × ১০−৫)D জিতে গেছে; saturating loss হলে G-এর gradient প্রায় মরে যায়Non-saturating loss; learning rate আর দুই network-এর ক্ষমতার ভারসাম্য বদলে কয়েকটি seed-এ মাপা
নমুনা ধারালো, কিন্তু শ্রেণি-হিস্টোগ্রামে কয়েকটি শ্রেণিMode collapse বা mode droppingকয়েকটি seed; learning rate কমানো; পর্ব ৭৮-এর conditional GAN বা WGAN-GP
ধরা পড়া mode-এর সংখ্যা ওঠানামা করে (বৃত্তের পরীক্ষায় ৩ → ৬ → ৩)Oscillation: G এক mode থেকে আরেকটিতে লাফায়ছোট learning rate, Adam-এর β₁ = ০.৫, নির্দিষ্ট সময় পরপর checkpoint রেখে সেরাটি বাছা
Loss হঠাৎ nanখুব বড় learning rate, বা আলাদা sigmoid আর logBCEWithLogitsLoss; learning rate ছোট
D(x) আর D(G(z)) দুটোই প্রায় ০.৫, কিন্তু নমুনা খারাপD খুব দুর্বল, কিছুই আলাদা করতে পারছে নাD-এর ক্ষমতা বা learning rate বাড়ানো; ডেটার scale ([−১, ১]) আর G-এর tanh মিলেছে কি না

একটি জিনিস টেবিলে নেই কিন্তু সবচেয়ে জরুরি: প্রতিটি পরিবর্তনের পর বাইরের মাপকাঠি দেখুন। GAN-এর নিজের loss দুই খেলোয়াড়ের আপেক্ষিক শক্তি দেখায়, নমুনার মান নয়। এই পর্বে সেই মাপকাঠি ছিল একটি আলাদা classifier, যা MNIST-এর জন্য সহজ আর সস্তা। ছবির সাধারণ মূল্যায়নের পরিচিত metric (Inception Score, FID, precision/recall) পর্ব ৮১ (Generative model-এর মূল্যায়ন আর নিরাপত্তা)-এর বিষয়।

আরও গভীরে: মূল তত্ত্ব আর non-saturating loss-এর প্রস্তাব Goodfellow ও সহকর্মীদের “Generative Adversarial Nets” (NIPS ২০১৪)-এ; সহজপাঠ্য সারাংশ Goodfellow-এর “NIPS 2016 Tutorial: Generative Adversarial Networks” (arXiv ২০১৬)। আলাদা support, JS-এর স্থির থাকা আর non-saturating gradient-এর অস্থিরতার বিশ্লেষণ Arjovsky আর Bottou-এর “Towards Principled Methods for Training Generative Adversarial Networks” (ICLR ২০১৭)-এ, যা পরে WGAN-এর পথ খোলে। JS-কে যেকোনো f-divergence-এ সাধারণীকরণ Nowozin ও সহকর্মীদের f-GAN (NeurIPS ২০১৬)-এ। আর খেলার স্থানীয় convergence নিয়ে Mescheder ও সহকর্মীদের ICML ২০১৮-এর প্রবন্ধ দেখুন।

পরীক্ষা ও ইন্টারভিউতে:

  • Optimal discriminator কী? D*(x) = pdata(x)/(pdata(x) + pg(x)); প্রমাণ: প্রতিটি x-এ a ln y + b ln(১ − y)-এর derivative শূন্য করে y = a/(a + b).
  • D* বসালে V কত? −ln ৪ + ২·JS(pdata ‖ pg); সর্বনিম্ন −ln ৪ ≈ −১.৩৮৬, যখন pg = pdata.
  • Non-saturating loss কেন? শুরুতে D(G(z)) ≈ ০; ln(১ − D)-এর logit-gradient −D ≈ ০, কিন্তু −ln D-এর −(১ − D) ≈ −১.
  • Mode collapse কীভাবে মাপবেন? আলাদা classifier দিয়ে নমুনার শ্রেণি-হিস্টোগ্রাম, ধরা পড়া শ্রেণির সংখ্যা আর entropy, কয়েকটি seed-এ।
  • GAN-এর loss কমলেই কি model ভালো? না; দুই খেলোয়াড়ের loss আপেক্ষিক, নমুনা আর বাইরের metric দেখতে হয়।

৯ · AI-এর যুগে — AI-এর লেখা GAN কোড যাচাই

“PyTorch-এ একটি GAN লিখে দাও” বললে AI assistant কয়েক সেকেন্ডে চলনসই কোড দেয়, কারণ এমন কোড ইন্টারনেটে প্রচুর। সমস্যা হলো GAN-এর ভুলগুলো প্রায় সবই চুপচাপ: কোড চলে, loss-এর সংখ্যা ছাপে, কিন্তু G কিছু শেখে না বা একটিই ছবি বানায়। নিচের জায়গাগুলো আলাদা করে দেখুন।

AI প্রায়ই যা করেফলকীভাবে ধরবেন
D-এর ধাপে নকল ছবি detach() করে নাD-এর loss G-এর weight-এ gradient পাঠায়, বা দ্বিতীয় backward-এ errorD-এর ধাপের পরে G-এর parameter-এর .grad None কি না দেখুন
Generator-এর loss হিসেবে log(1 - D(G(z)))শুরুতে gradient প্রায় শূন্য (অংশ ৪)নকলকে লেবেল ১ দিয়ে BCEWithLogitsLoss
D-এর শেষে Sigmoid আর সঙ্গে BCEWithLogitsLossদুবার sigmoid, ভুল lossD logit দেবে; sigmoid শুধু loss-এর ভেতরে
একটি zero_grad দুই optimizer-এর জন্য, বা ভুল optimizer-এর stepGradient জমে যায়, বা G-এর ধাপে D বদলায়দুটি optimizer, প্রতিটির আগে নিজের zero_grad
Loss কমছে দেখে “training ভালো চলছে”Mode collapse ধরা পড়ে নানির্দিষ্ট z-এর নমুনা আর একটি আলাদা classifier-এর শ্রেণি-হিস্টোগ্রাম
একটি seed-এর সুন্দর ছবি দেখিয়ে সিদ্ধান্তভাগ্যবান run-কে নিয়ম ভাবাকয়েকটি seed, গড় আর বিস্তার

AI-কে কোডের সঙ্গে যাচাইয়ের ব্যবস্থাও চাইতে বলুন:

আমি PyTorch 2.x-এ MNIST-এর জন্য একটি ছোট MLP GAN বানাচ্ছি (z 64-মাত্রা, ছবি [-1, 1]-এ)। ১. G আর D-এর কোড দাও; D শেষে logit দেবে, loss BCEWithLogitsLoss; generator-এর loss non-saturating রূপে। ২. D-এর ধাপে নকল ছবি কেন detach করতে হয়, এক লাইনে বলো, আর দুটি আলাদা optimizer ব্যবহার করো। ৩. প্রতি epoch-এ গড় D(x), D(G(z)) আর একটি স্থির z থেকে ১৬টি নমুনা সেভ করার কোড দাও। ৪. আলাদা train করা একটি MNIST classifier দিয়ে ১০০০টি নমুনার শ্রেণি-হিস্টোগ্রাম মাপার কোড দাও। ৫. যে argument বা function তোমার নিশ্চিত জানা নেই, তা চিহ্নিত করো; আমি নিজের version-এর docs-এ মেলাব।

AI-এর দেওয়া GAN কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১GAN কোনো likelihood হিসাব করে না। এটি কি দুর্বলতা, না শক্তি?

দুটিই। শক্তির দিকটি আগে দেখি। Likelihood-ভিত্তিক model-কে প্রতিটি ছবির p(x) লিখতে হয়, তাই তার গঠনে বাধা থাকে: autoregressive model পিক্সেল একটি একটি করে বানায় (ধীর), normalizing flow-কে উল্টানো যায় এমন layer লাগে, আর VAE (পর্ব ৭৬) একটি সীমা বাড়ায়, যেখানে Gaussian reconstruction loss প্রায়ই ঝাপসা ছবি দেয়। GAN-এর generator যেকোনো network হতে পারে, আর একবার চালালেই একটি নমুনা আসে। তার “loss” একটি শেখা বিচারক, যা ঝাপসা ছবিকে সহজেই নকল বলে ধরে, তাই GAN ঐতিহাসিকভাবে ধারালো ছবির জন্য পরিচিত হয়েছিল।

দুর্বলতার দিকটিও সমান বাস্তব। কোনো ছবির সম্ভাবনা জানা যায় না, তাই anomaly detection বা compression-এর মতো কাজে GAN সরাসরি চলে না। Training-এর অগ্রগতি মাপার কোনো একক সংখ্যা নেই: D আর G-এর loss একে অপরের ওপর নির্ভর করে, তাই কমা-বাড়া দিয়ে মান বোঝা যায় না। মূল্যায়নের জন্য বাইরের মাপকাঠি লাগে, যেমন এই পর্বের classifier-ভিত্তিক শ্রেণি-হিস্টোগ্রাম, বা Inception Score আর FID (পর্ব ৮১)। Theis, van den Oord আর Bethge-এর “A note on the evaluation of generative models” (ICLR ২০১৬) দেখিয়েছিল যে likelihood আর নমুনার চোখে-দেখা মান অনেকটাই আলাদা জিনিস: একটি দিয়ে অন্যটি অনুমান করা যায় না।

আরেকটি সূক্ষ্ম কথা: likelihood train-এ model প্রতিটি আসল ছবিকে কিছু সম্ভাবনা দিতে বাধ্য, তাই সে কোনো mode পুরো বাদ দিতে পারে না (তার শাস্তি অসীম)। GAN-এর ওপর এমন কোনো বাধ্যবাধকতা নেই, তাই mode বাদ দেওয়া তার কাছে প্রায় বিনা খরচের। একই বৈশিষ্ট্য তাকে ধারালো করে আবার বৈচিত্র্যহীনও করতে পারে। গবেষকরা এ কারণে দুই পথ মেলানোর চেষ্টাও করেছেন, যেমন VAE-র সঙ্গে adversarial loss জুড়ে দেওয়া।

মূল উপলব্ধি: Likelihood না থাকায় GAN-এর generator স্বাধীন আর নমুনা ধারালো হতে পারে, কিন্তু একই কারণে training-এর অগ্রগতি মাপা কঠিন, mode বাদ পড়ার শাস্তি কম, আর মূল্যায়নে বাইরের মাপকাঠি লাগে।

প্র ০২Mode collapse কেন হয়, আর এর ওষুধ হিসেবে গবেষকরা কী কী চেষ্টা করেছেন?

মূল GAN প্রবন্ধ (২০১৪) আর Goodfellow-এর NIPS ২০১৬-এর tutorial এই সমস্যাকে মজা করে “Helvetica scenario” নামেও ডেকেছে; tutorial-টি mode collapse-কে তখনকার সম্ভবত সবচেয়ে গুরুত্বপূর্ণ অমীমাংসিত সমস্যা বলে। সহজ ব্যাখ্যাটি খেলার ক্রম থেকে আসে। Minimax-এ ধরা হয় D আগে সেরা অবস্থায় পৌঁছায়, তারপর G চাল দেয়। কিন্তু বাস্তবে দুজন পালা করে ছোট ধাপ নেয়, তাই খেলাটি কখনো কখনো max-min-এর মতো আচরণ করে: G দেখে এখন D কোন একটি ধরনকে সবচেয়ে “আসল” ভাবছে, আর সব z-কে সেখানে পাঠায়। D তখন সেই ধরনকে নকল বলে শেখে, আর G আরেকটি ধরনে লাফ দেয়। এক mode থেকে আরেক mode-এ এই লাফ ২-মাত্রার বৃত্তের পরীক্ষায় seed ২-এর ৩ → ৬ → ৩-এর মধ্যে দেখেছেন।

দ্বিতীয় ব্যাখ্যা loss থেকে। অংশ ৪-এর নোটে দেখেছেন, non-saturating loss-এর gradient KL(pg ‖ pdata)-এর মতো একটি পদ ধারণ করে, যা আসল ডেটার কোনো অংশ বাদ দিলে কম শাস্তি দেয়। তবে সাবধান: Goodfellow-এর tutorial যুক্তি দেয় যে divergence-এর দিককে দায়ী করা সম্ভবত ঠিক নয়, কারণ KL-ভিত্তিক অন্য রূপেও একই সমস্যা দেখা যায়; তাই এটি একটি বিতর্কিত ব্যাখ্যা। তৃতীয় কারণ গঠনগত: D প্রতিটি ছবি আলাদা করে বিচার করে, তাই একটি batch-এর সব ছবি একই হলেও D তা জানে না।

ওষুধগুলো এই কারণগুলোকেই লক্ষ্য করে। Salimans ও সহকর্মীদের “Improved Techniques for Training GANs” (২০১৬) প্রস্তাব করে minibatch discrimination (D পুরো batch-এর বৈচিত্র্য দেখে) আর feature matching. Metz ও সহকর্মীদের Unrolled GAN (ICLR ২০১৭) G-এর ধাপে D-এর কয়েক ধাপ ভবিষ্যৎ হিসাবে ধরে, যাতে G লাফ দেওয়ার পরিণাম দেখতে পায়; ৮টি Gaussian-এর বৃত্ত সেই প্রবন্ধের পরিচিত পরীক্ষা। পরে Wasserstein distance-ভিত্তিক loss, gradient penalty আর spectral normalization এসেছে, যা পর্ব ৭৮ (GAN-এর রূপ — DCGAN, WGAN, StyleGAN আর conditional GAN)-এর বিষয়। Conditional GAN, যেখানে শ্রেণির লেবেলও দেওয়া হয়, প্রতিটি শ্রেণি বানাতে বাধ্য করে বলে বাস্তবে collapse অনেক কমায়। কোনো ওষুধই সব ক্ষেত্রে নিশ্চিত নয়; তাই collapse মাপা (শ্রেণি-হিস্টোগ্রাম, বৈচিত্র্যের metric) ওষুধের চেয়েও জরুরি।

মূল উপলব্ধি: Mode collapse আসে পালা-করা খেলার গতিবিদ্যা, loss-এর KL-এর দিক আর প্রতিটি ছবি আলাদা বিচারের মিলিত ফল থেকে; ওষুধ অনেক, কিন্তু নির্ভরযোগ্য পথ হলো collapse নিয়মিত মাপা।

প্র ০৩Gradient descent একটি loss কমাতে দক্ষ। তবে দুই খেলোয়াড়ের খেলায় এটি সাম্যাবস্থা খুঁজে পেতে কেন হিমশিম খায়?

সাধারণ training-এ একটি function আছে আর আমরা তার সর্বনিম্ন বিন্দু খুঁজি; ঢাল সবসময় নিচের দিকে দেখায়। খেলায় দুটি লক্ষ্য, একজনের নিচে নামা মানে অন্যজনের ওপরে ওঠা। সাম্যাবস্থা একটি saddle point-এর মতো: G-এর দিক থেকে সর্বনিম্ন, D-এর দিক থেকে সর্বোচ্চ। দুজনের gradient মিলে যে “vector field” তৈরি হয়, তার মধ্যে ঘূর্ণন থাকতে পারে, যা কোনো একক loss-এর gradient-এ থাকে না। অংশ ৫-এর f(x, y) = x·y খেলাটি এর সবচেয়ে সরল রূপ: একসঙ্গে ধাপ নিলে প্রতি ধাপে দূরত্বের বর্গ (১ + η²) গুণ বাড়ে, অর্থাৎ বিন্দুটি বাইরে সর্পিল হয়ে যায়।

Mescheder, Geiger আর Nowozin-এর “Which Training Methods for GANs do actually Converge?” (ICML ২০১৮) ঠিক এমন একটি “Dirac-GAN” দিয়ে দেখান যে সাধারণ GAN training সাম্যাবস্থার কাছেও স্থানীয়ভাবে converge না করতে পারে, আর D-এর gradient-এর ওপর একটি শাস্তি (R1 regularization) দিলে তা স্থির হয়। Heusel ও সহকর্মীরা (NeurIPS ২০১৭) দুই খেলোয়াড়ের জন্য আলাদা learning rate (two time-scale update rule, TTUR) প্রস্তাব করেন আর কিছু শর্তে স্থানীয় Nash equilibrium-এ পৌঁছানোর প্রমাণ দেন; একই প্রবন্ধে FID-ও আসে। খেলা-তত্ত্বের আরও পদ্ধতি আছে, যেমন extragradient বা optimistic gradient, যা “এক পা আগে দেখে” ঘূর্ণন কমায়।

ব্যবহারিক শিক্ষা হলো, GAN-এর loss curve-কে classifier-এর loss curve-এর মতো পড়া যায় না। D-এর loss ln ৪ ≈ ১.৩৯-এর কাছে থাকলে বোঝা যায় খেলা চলছে; D-এর loss শূন্যে নামলে D জিতে গেছে আর G-এর gradient বিপদে। এই পর্বের MNIST run-এও loss স্থির হয়নি, দোল খেয়েছে, অথচ নমুনা ভালো হয়েছে। অগ্রগতি দেখতে হয় নমুনা আর বাইরের metric দিয়ে।

মূল উপলব্ধি: Gradient descent সর্বনিম্ন বিন্দু খোঁজে, সাম্যাবস্থা নয়; খেলায় ঘূর্ণন থাকে বলে GAN দোল খায়, আর TTUR, gradient penalty বা উন্নত optimizer সেই ঘূর্ণন কমানোর চেষ্টা।

প্র ০৪নতুন একটি GAN কৌশল “সেরা” ফল দিল, এমন দাবি কীভাবে সৎভাবে যাচাই করবেন?

২০১৬ থেকে ২০১৮ সালের মধ্যে প্রায় প্রতি মাসে নতুন GAN loss বা কৌশল প্রকাশিত হতো, আর প্রায় প্রতিটিই আগেরগুলোর চেয়ে ভালো বলে দাবি করত। Lucic, Kurach, Michalski, Gelly আর Bousquet-এর “Are GANs Created Equal? A Large-Scale Study” (NeurIPS ২০১৮) একটি বড় পরীক্ষা চালায়: একই network, একই ডেটা, প্রতিটি পদ্ধতিতে বহু hyperparameter আর random seed. তাদের প্রধান পর্যবেক্ষণ ছিল, পর্যাপ্ত hyperparameter খোঁজা আর random restart দিলে বেশিরভাগ পদ্ধতি প্রায় একই FID-তে পৌঁছায়, আর একই পদ্ধতির ভেতরে seed-এর বৈচিত্র্য প্রায়ই পদ্ধতিগুলোর পার্থক্যের সমান বা বেশি। অর্থাৎ অনেক “উন্নতি” আসলে বেশি টিউনিং বা ভাগ্যবান seed থেকে এসেছিল, অন্তত তাদের পরীক্ষার পরিসরে।

এই পর্বের ছোট sweep-এও একই ছবি দেখেছেন: একই learning rate-এ তিনটি seed-এর ফল আলাদা, আর learning rate বদলালে ফল আরও বেশি বদলায় (অংশ ৭)। তাই একটি দাবি যাচাই করতে কয়েকটি নিয়ম মানুন। এক, প্রতিটি পদ্ধতিকে সমান টিউনিং বাজেট দিন। দুই, কয়েকটি seed চালান আর গড়ের সঙ্গে বিস্তারও লিখুন, সেরা run শুধু নয়। তিন, metric আগেই ঠিক করুন আর তার সীমা জানুন (FID-এর নমুনা সংখ্যার ওপর নির্ভরতা পর্ব ৮১-এ)। চার, একই compute-এ তুলনা করুন, কারণ বেশি সময় train করলে প্রায় সব পদ্ধতিই ভালো হয়।

Machine learning-এ এই সমস্যা শুধু GAN-এর নয়। Henderson ও সহকর্মীদের “Deep Reinforcement Learning that Matters” (AAAI ২০১৮) reinforcement learning-এ একই রকম seed-নির্ভরতা দেখিয়েছিল। এই কোর্সের পর্ব ১৮-এর “একটি run কোনো প্রমাণ নয়” নিয়ম GAN-এ আরও কড়াভাবে খাটে, কারণ এখানে training নিজেই অস্থির।

মূল উপলব্ধি: GAN-এ seed আর hyperparameter-এর প্রভাব প্রায়ই পদ্ধতির প্রভাবের সমান; সমান টিউনিং বাজেট, কয়েকটি seed আর বিস্তারসহ ফল ছাড়া “সেরা” দাবি বিশ্বাসযোগ্য নয়।

প্র ০৫ছবি তৈরিতে এখন diffusion model বেশি দেখা যায়। তাহলে GAN শেখার দরকার কী?

Dhariwal আর Nichol-এর “Diffusion Models Beat GANs on Image Synthesis” (NeurIPS ২০২১) শিরোনামেই সময়ের পরিবর্তনটি বলে দেয়: ImageNet-এর মতো বৈচিত্র্যময় ডেটায় diffusion model নমুনার মান আর বৈচিত্র্য দুই দিকেই তখনকার সেরা GAN-কে ছাড়িয়ে যায় বলে তারা রিপোর্ট করেন। Diffusion-এর training একটি সাধারণ regression loss, খেলা নয়, তাই অনেক বেশি স্থির; mode collapse-ও কম। পর্ব ৭৯ (Diffusion model — DDPM, noise schedule আর denoising) সেটি দেখাবে।

কিন্তু GAN-এর ধারণাগুলো হারিয়ে যায়নি, অন্য জায়গায় ঢুকে গেছে। প্রথম, গতি: GAN একবার network চালিয়েই নমুনা দেয়, diffusion-এর লাগে অনেক ধাপ। তাই দ্রুততার দরকার যেখানে (রিয়েল-টাইম super-resolution, কিছু audio বা speech synthesis-এর vocoder), সেখানে adversarial training এখনো দেখা যায়। দ্বিতীয়, “শেখা loss” হিসেবে: Latent diffusion-এর প্রবন্ধ (Rombach ও সহকর্মীরা, CVPR ২০২২) তার autoencoder-কে একটি perceptual loss আর একটি patch-ভিত্তিক adversarial loss দিয়ে train করেছিল বলে লেখে, যাতে ছবি ঝাপসা না হয় (পর্ব ৮০)। তৃতীয়, diffusion-কে দ্রুত করতে: কিছু কাজ, যেমন adversarial diffusion distillation (Sauer ও সহকর্মীরা, ২০২৩), একটি discriminator দিয়ে diffusion model-কে খুব কম ধাপে ছবি বানাতে শেখায় বলে রিপোর্ট করে।

আর একটি সামাজিক কারণও আছে। মুখের ছবি বা ভিডিওর অনেক deepfake প্রযুক্তির ইতিহাসে GAN-এর ভূমিকা ছিল, তাই নকল ছবি শনাক্তকরণ বা provenance (পর্ব ৮১) বুঝতে GAN-এর ধারণা কাজে লাগে। সবশেষে, দুই network-এর খেলার গণিত (optimal discriminator, divergence, সাম্যাবস্থা) density ratio estimation, domain adaptation আর robust training-এর মতো আরও অনেক জায়গায় ব্যবহৃত হয়।

মূল উপলব্ধি: নমুনার মানে diffusion অনেক জায়গায় এগিয়ে, কিন্তু adversarial loss এখন autoencoder, দ্রুত sampling আর distillation-এর একটি অংশ; GAN-এর খেলা বোঝা আধুনিক generative AI পড়ার একটি ভিত্তি।

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান।

  1. পূর্ণ numerical: একটি বিন্দু x-এ pdata(x) = ০.৩ আর pg(x) = ০.১. (ক) সেখানে D*(x) কত, আর তার logit কত? (খ) কোনো G যদি pg = pdata করে, তবে D* আর V(D*, G) কত? (গ) একটি নকল নমুনায় D(G(z)) = ০.০৫. Logit-এর সাপেক্ষে saturating আর non-saturating loss-এর gradient কত, আর অনুপাত কত? (ঘ) D(x) = sigmoid(wx + c), w = ১, c = ০; আসল x = ২, নকল x̃ = −১. D-এর BCE loss আর ∂L/∂w কত?

    (ক) D* = ০.৩/(০.৩ + ০.১) = ০.৭৫; logit = ln(০.৭৫/০.২৫) = ln ৩ ≈ ১.০৯৮৬, যা ln pdata − ln pg = ln(০.৩/০.১)-এর সমান। (খ) সব জায়গায় D* = ০.৫, আর V = ln ০.৫ + ln ০.৫ = −ln ৪ ≈ −১.৩৮৬৩. (গ) Saturating: −D = −০.০৫; non-saturating: −(১ − D) = −০.৯৫; অনুপাত (১ − D)/D = ১৯. (ঘ) আসলের logit ২ → D = ০.৮৮০৮, loss −ln ০.৮৮০৮ = ০.১২৬৯; নকলের logit −১ → D = ০.২৬৮৯, loss −ln(১ − ০.২৬৮৯) = ০.৩১৩৩; মোট ০.৪৪০২. ∂L/∂w = (০.৮৮০৮ − ১) × ২ + (০.২৬৮৯ − ০) × (−১) = −০.২৩৮৪ − ০.২৬৮৯ = −০.৫০৭৩; অর্থাৎ w বাড়ালে D আরও ভালো আলাদা করবে।

  2. সমালোচনা ও নকশা: একজন সহকর্মী MNIST-এ একটি GAN চালিয়ে এই log দেখালেন (নমুনা log, এই পর্বের কোনো run নয়) আর বললেন “D-এর loss প্রায় শূন্য, মানে model দারুণ শিখেছে”: ep 3: lossD 0.012, lossG 6.21, D(x) 0.995, D(G(z)) 0.002, আর ২০ epoch পরেও একই রকম। তাঁর কোডে generator-এর loss torch.log(1 - D(G(z)).sigmoid()).mean(), আর D-এর learning rate G-এর ১০ গুণ। কী ভুল বুঝছেন, সমস্যা কোথায়, আর কী কী বদলাবেন?

    ভুল ব্যাখ্যা: GAN-এ D-এর loss শূন্য মানে D জিতে গেছে, G নয়। D(G(z)) = ০.০০২ মানে প্রায় সব নকল সহজেই ধরা পড়ছে। সমস্যা: generator-এর loss saturating রূপে, ln(১ − D); D = ০.০০২-এ তার logit-gradient মাত্র −০.০০২, তাই G প্রায় কিছুই শেখে না (অংশ ৪)। তার ওপর D-এর learning rate অনেক বেশি, তাই D আরও দ্রুত নিখুঁত হয়। Log-এর lossG ৬.২১ ≈ −ln ০.০০২, অর্থাৎ ছাপা হচ্ছে −ln D, অথচ optimize হচ্ছে ln(১ − D); ছাপা loss আর optimize করা loss আলাদা হলে বিভ্রান্তি বাড়ে। বদল: (১) non-saturating loss: নকলকে লেবেল ১ দিয়ে BCEWithLogitsLoss; (২) দুই learning rate সমান থেকে শুরু (যেমন ২ × ১০−৪, β₁ = ০.৫), তারপর মেপে বদল; (৩) প্রতি epoch-এ স্থির z-এর নমুনা আর classifier-এর শ্রেণি-হিস্টোগ্রাম; (৪) তিনটি seed. প্রয়োজনে D-কে দুর্বল করা (ছোট network, dropout) বা পর্ব ৭৮-এর WGAN-GP ধরনের loss বিবেচনা করুন।

  3. আউটপুট অনুমান করুন: চালানোর আগে লিখে ফেলুন নিচের কোড কী ছাপবে।
    import numpy as np
    D = np.array([0.5, 0.2, 0.02])          # D(G(z))
    print(np.round(-D, 3))                  # saturating
    print(np.round(-(1 - D), 3))            # non-saturating
    print(np.round((1 - D) / D, 1))
    print(round(-np.log(4), 4))
    [-0.5  -0.2  -0.02]
    [-0.5  -0.8  -0.98]
    [ 1.  4. 49.]
    -1.3863

    (সত্যিকারের run, NumPy 2.5.4.) প্রথম সারি saturating gradient −D, দ্বিতীয় non-saturating −(১ − D); D = ০.৫-এ দুটি সমান। তৃতীয় সারি অনুপাত (১ − D)/D: ১, ৪ আর ৪৯. NumPy সংখ্যার পরে অপ্রয়োজনীয় শূন্য ছাপে না, তাই 1. আর 4.. শেষ লাইন −ln ৪, minimax খেলার সর্বনিম্ন মান।

  4. কোড লিখুন: PyTorch-এ একটি function gan_step(G, D, opt_g, opt_d, x_real, z_dim) লিখুন, যা একটি পূর্ণ GAN ধাপ নেয়: আগে D (আসল লেবেল ১, নকল ০, BCE logit থেকে), তারপর G (non-saturating), আর দুই loss, গড় D(x) আর গড় D(G(z)) ফেরত দেয়। D শেষে logit দেয়।
    import torch, torch.nn.functional as F
    
    def gan_step(G, D, opt_g, opt_d, x_real, z_dim):
        B = x_real.size(0)
        z = torch.randn(B, z_dim, device=x_real.device)
        x_fake = G(z)
        # ১. D-এর ধাপ: নকল detach, যাতে G-এ gradient না যায়
        lr, lf = D(x_real), D(x_fake.detach())
        loss_d = F.binary_cross_entropy_with_logits(lr, torch.ones_like(lr)) + \
                 F.binary_cross_entropy_with_logits(lf, torch.zeros_like(lf))
        opt_d.zero_grad(set_to_none=True); loss_d.backward(); opt_d.step()
        # ২. G-এর ধাপ: হালনাগাদ D, নকলকে লেবেল ১ (non-saturating)
        lf = D(x_fake)
        loss_g = F.binary_cross_entropy_with_logits(lf, torch.ones_like(lf))
        opt_g.zero_grad(set_to_none=True); loss_g.backward(); opt_g.step()
        return loss_d.item(), loss_g.item(), lr.sigmoid().mean().item(), lf.sigmoid().mean().item()

    যাচাই (সত্যিকারের run, torch 2.15.0, CPU): একটি খুব ছোট G (2 → 16 → 1) আর D (1 → 16 → 1) দিয়ে N(৪, ১.২৫²) শেখাতে ২,০০০ ধাপ চালালে নকলের গড় ০.০৬ থেকে ৫০০ ধাপেই ৪.০৭, আর ২,০০০ ধাপে ৪.০৪; D-এর loss ১.৩৮৭ ≈ ln ৪ আর D(G(z)) ০.৪৯৯. তবে নকলের standard deviation ধাপে ধাপে ১.১৪ → ২.৪৫ → ০.৩৪ → ০.৯৯: আসল ১.২৫-এর চারপাশে দোল, ঠিক অংশ ৪-এর খেলনার মতো।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমPyTorch-এ একটি GAN training ধাপের কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. আসল ছবির একটি batch আর noise z নেওয়া
  2. Generator দিয়ে নকল ছবি x̃ = G(z) বানানো
  3. D-এর loss: আসলে লেবেল ১, detach করা নকলে লেবেল ০
  4. D-এর optimizer দিয়ে শুধু D হালনাগাদ
  5. হালনাগাদ D দিয়ে নকলের logit নিয়ে লেবেল ১ দিয়ে G-এর loss
  6. G-এর optimizer দিয়ে শুধু G হালনাগাদ

একটি GAN-এ কোনো বিন্দু x-এ আসল ডেটার density ০.৩ আর generator-এর density ০.১. Generator স্থির থাকলে সেরা discriminator ওই বিন্দুতে কী মান দেবে?

  • ০.৩
  • ০.৫
  • ০.৭৫
  • ০.২৫

মূল GAN-এর minimax খেলায় generator যদি আসল বণ্টন হুবহু শিখে ফেলে (pg = pdata), তবে সেরা discriminator-এর বিপরীতে V(D, G)-এর মান কত?

  • ০
  • −ln ৪ ≈ −১.৩৮৬
  • ln ২ ≈ ০.৬৯৩
  • −∞

Discriminator একটি নকল নমুনাকে D(G(z)) = ০.০১ দিল। D-এর logit-এর সাপেক্ষে generator-এর gradient-এর মান non-saturating loss (−ln D)-এ saturating loss (ln(1 − D))-এর তুলনায় কত গুণ?

  • সমান
  • প্রায় ১০ গুণ
  • Non-saturating-এর gradient শূন্য, saturating-এর বড়
  • ৯৯ গুণ

MNIST-এ train করা একটি GAN-এর ১,০০০টি নমুনা একটি আলাদা digit classifier-কে দেখানো হলো। প্রায় সব নমুনা ধারালো আর classifier খুব নিশ্চিত, কিন্তু শ্রেণি-হিস্টোগ্রামে শুধু ১, ৭ আর ৯ দেখা যায়। এটি কোন সমস্যার লক্ষণ?

  • Mode collapse
  • Vanishing gradient-এ G কিছুই শেখেনি
  • G train set মুখস্থ করেছে
  • Classifier-এর accuracy কম

PyTorch-এ GAN-এর discriminator ধাপে নকল ছবিকে x_fake.detach() করে D-তে দেওয়া হয় কেন?

  • GPU মেমরি বাঁচাতে, ফলাফলে কোনো পার্থক্য নেই
  • নকল ছবির মান [−১, ১]-এ সীমিত রাখতে
  • D-এর loss যেন G-এর weight-এ gradient না পাঠায়
  • যাতে D নকল ছবি থেকে কিছু না শেখে

দুটি বণ্টনের support একেবারে আলাদা (কোথাও মিল নেই)। তাদের Jensen–Shannon divergence কত, আর এতে GAN-এর generator-এর কী সমস্যা হয়?

  • ০; generator ইতিমধ্যে নিখুঁত
  • ln ২; generator কাছে এলেও মান বদলায় না, তাই কোনো দিক পায় না
  • অসীম; gradient বিস্ফোরিত হয়
  • সংজ্ঞায়িত নয়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. খেলা: minG maxD E[ln D(x)] + E[ln(1 − D(G(z)))]; D একটি binary classifier (BCE), G কোনো density লেখে না, শুধু নমুনা বানায়।
  2. Optimal discriminator: D* = pdata/(pdata + pg), logit = ln(pdata/pg); তখন V = −ln ৪ + ২·JS, সর্বনিম্ন −ln ৪ যখন pg = pdata.
  3. Non-saturating loss: G-এর জন্য −ln D(G(z)); logit-gradient −(1 − D), যেখানে saturating-এর −D. অনুপাত (1 − D)/D.
  4. অস্থিরতা: mode collapse, মরে যাওয়া gradient (আলাদা support-এ JS = ln ২), আর খেলার ঘূর্ণনে oscillation; loss দেখে অগ্রগতি বোঝা যায় না।
  5. সৎ মূল্যায়ন: স্থির z-এর নমুনা, আলাদা classifier-এর শ্রেণি-হিস্টোগ্রাম, কয়েকটি seed আর learning rate; একটি সুন্দর run কোনো প্রমাণ নয়।

পরের পর্বে: এই পর্বের সমস্যাগুলোর বিখ্যাত সমাধানগুলো আসবে পর্ব ৭৮ (GAN-এর রূপ — DCGAN, WGAN, StyleGAN আর conditional GAN)-এ: convolution দিয়ে GAN, JS-এর বদলে Wasserstein distance, gradient penalty, আর লেবেল দিয়ে নির্দিষ্ট শ্রেণির ছবি বানানো।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান