はじめに
第9回では、画像分類、セグメンテーション、物体検出を例に、モデルの出力をどう読むかを整理しました。分類ならlogits、セグメンテーションならmask、検出ならbox、label、scoreというように、タスクが変わると出力の意味も変わります。
今回扱うのは、生成モデルの代表例であるGANと、系列データを扱うRNNです。一見すると別々の話に見えますが、どちらも「計算グラフをどう流し、どこで勾配を止め、どのTensorをどのshapeで渡すか」が重要になります。
GANでは、GeneratorとDiscriminatorを交互に訓練します。RNNでは、時間方向に同じパラメータを繰り返し使い、hidden stateでそれまでの情報を受け渡します。どちらもコードは短く書けますが、detach()、logits、値域、hiddenのshape、paddingの扱いを曖昧にすると、動いているように見えて学習が崩れます。
目的は、GANやRNNの名前を覚えることではありません。fake.detach() がなぜ必要なのか、BCEWithLogitsLoss をどこで使うのか、DCGANのGeneratorとDiscriminatorがどのように画像のshapeを変えるのか、batch_first=True でもhidden stateの次元順は変わらないこと、packingでpadding計算を避けることを、PyTorchのコードに結びつけて理解することです。
目次
- この記事で扱うこと
- 第9回から第10回へのつながり
- GANとRNNの全体像
- 生成モデルとGAN
- GeneratorとDiscriminator
- GANの目的関数
- Discriminatorの訓練
- Generatorの訓練
- BCEWithLogitsLossでGANの損失を書く
- 小さなGAN訓練stepでdetachを確認する
- DCGANの構造
- DCGANの値域、初期化、訓練の注意
- CelebAでDCGANを訓練する骨組み
- checkpointから画像を生成する
- GANの失敗パターンと評価
- 条件付きGANと画像変換タスク
- RNNの役割と基本構造
- RNNの数式とTensor形状
- 時間展開とBPTT
- PyTorchのRNN API
- LSTM
- GRU
- PaddingとPacking
- 文字、one-hot、Embedding
- 人名分類の流れ
- 手作りRNNで人名分類を行う例
- batch化したRNN分類モデル
- RNNの訓練でよく見る問題
- RNN、CNN、Transformerの違い
- 図として整理する
- よくあるつまずきどころ
- 参考リンク
- おわりに
この記事で扱うこと
この記事では、次の内容を扱います。
- 生成モデルとGANの基本的な考え方
- GeneratorとDiscriminatorの役割
- GANのminimax目的とnon-saturating loss
-
BCEWithLogitsLossを使う理由 - Discriminator更新時の
fake.detach() - Generator更新時にreal labelをtargetにする理由
- 小さなGAN訓練stepの実装
- DCGANのGenerator、Discriminator、値域、初期化
- GANの失敗パターンと評価指標
- 条件付きGAN、Pix2Pix、CycleGANなどの位置づけ
- RNNのhidden stateと時間展開
-
nn.RNN、nn.LSTM、nn.GRUの入出力shape - BPTTとtruncated BPTT
- padding、packing、
lengths.cpu()の注意 - 文字one-hotとEmbeddingの違い
- 人名分類を例にした文字系列分類の流れ
- batch化したRNN分類モデルの骨組み
- RNN、CNN、Transformerの違い
- よくあるエラーと確認ポイント
コードは、小さなTensorや軽いモデルで確認できるものを中心にします。実画像の生成や大きなデータセットを使う箇所では、どのようなデータを用意すればよいかを説明します。
第9回から第10回へのつながり
第9回では、入力画像から分類logits、segmentation mask、検出boxを取り出す流れを見ました。今回も、出発点は同じです。モデルは文字列や画像そのものの意味を返すのではなく、Tensorを返します。
第9回:画像タスクの出力を読む
-> logits、mask、box、score
-> argmax、NMS、IoU
-> train/evalで戻り値が変わるモデル
第10回:生成と系列の計算グラフを読む
-> fake.detach で勾配の流れを止める
-> GeneratorとDiscriminatorを別々に更新する
-> hidden stateを時間方向に受け渡す
-> paddingとpackingで系列長の違いを扱う
GANでは、同じfake画像でもDiscriminatorを訓練するときとGeneratorを訓練するときで、勾配を流す先が変わります。RNNでは、同じRNN cellを時間方向に繰り返し使うため、系列長、hidden state、paddingの扱いが結果に直結します。
GANとRNNの全体像
まず、今回扱う2つの大きなテーマを並べます。
| テーマ | 主な問い | 重要なTensor |
|---|---|---|
| GAN | 本物らしいデータを生成できるか | latent z、fake sample、real/fake logits |
| DCGAN | 画像生成を畳み込みで行えるか |
(N, nz, 1, 1)、(N, 3, 64, 64)
|
| RNN | 系列の履歴情報をどう持つか |
x_t、h_t、output、h_n
|
| LSTM/GRU | 長期依存をどう扱いやすくするか | gate、cell state、hidden state |
| Padding/Packing | 長さの違う系列をbatch化できるか | padded tensor、lengths、PackedSequence |
| 文字分類 | 文字列をどう数値化するか | one-hot、token ID、Embedding |
GANは画像生成の文脈でよく紹介されますが、考え方の中心は「2つのネットワークを別々の目的で更新する」ことです。RNNは自然言語処理でよく紹介されますが、考え方の中心は「時間方向に共有パラメータとhidden stateを使う」ことです。
生成モデルとGAN
生成モデルは、訓練データの分布を学び、そこから新しいサンプルを作るモデルです。GAN、Generative Adversarial Networkは、GeneratorとDiscriminatorを競わせることで生成分布を近づけようとします。
z ~ p_z(z)
x_fake = G(z)
D(x) -> real/fake logit
z はlatent vector、潜在変数です。多くの実装では、標準正規分布からランダムにサンプルします。Generatorは z を画像や音声などのデータ空間へ写します。Discriminatorは、入力が訓練データ由来らしいか、Generatorが作ったfakeらしいかを判断します。
重要なのは、GANが通常の分類問題のように「正解ラベル付きの入力を1つのモデルで学習する」だけではない点です。Discriminatorは本物とfakeを区別する方向に学習し、GeneratorはDiscriminatorをだます方向に学習します。
GeneratorとDiscriminator
Generatorの役割は、latent vectorからfake sampleを作ることです。
latent z
-> Linear / ConvTranspose / Upsample blocks
-> generated sample G(z)
Discriminatorの役割は、入力sampleからreal/fakeのスコアを出すことです。
real x or fake G(z)
-> feature extractor
-> scalar logit
実装では、Discriminatorの最後をSigmoidにして確率として扱う書き方もあります。ただしPyTorchでは、Discriminatorに生のlogitsを返させ、損失に BCEWithLogitsLoss を使うほうが整理しやすいです。SigmoidとBCEを別々に書くより、数値的に安定した計算になります。
GeneratorとDiscriminatorの役割が決まったところで、次はこの2つを同時にどう訓練するかを式で表します。
GANの目的関数
GANの基本的な目的は、次のようなminimaxの形で説明されます。
min_G max_D V(D, G)
= E_x~p_data [log D(x)]
+ E_z~p_z [log(1 - D(G(z)))]
固定したGeneratorに対して、理想的なDiscriminatorは、あるsampleがデータ分布から来たものか、生成分布から来たものかを比率で判断します。
D*(x) = p_data(x) / (p_data(x) + p_g(x))
理論上の均衡では、生成分布とデータ分布が一致し、Discriminatorはreal/fakeを区別できなくなります。
p_g = p_data
D(x) = 1/2
ただし、訓練中にDiscriminatorの平均出力が0.5付近になったからといって、それだけで生成が成功したとは言えません。Discriminatorが弱すぎる場合、Generatorが少数のパターンだけを出している場合、訓練が振動している場合でも、似た数値が出ることがあります。
Discriminatorの訓練
Discriminatorを更新するときは、本物データとfakeデータの両方を使います。
real x -> D -> target 1
noise z -> G(z).detach() -> D -> target 0
loss_D = loss_real + loss_fake
ここで fake.detach() が重要です。Discriminatorを更新するstepでは、Generatorの重みを更新したくありません。fake画像の数値は使いますが、そのfake画像を作ったGenerator側へ勾配を戻さないようにします。次のコードは、訓練ループの中心部分だけを抜き出した断片です。
optimizer_d.zero_grad(set_to_none=True)
real_logits = discriminator(real_images)
real_loss = criterion(real_logits, torch.ones_like(real_logits))
noise = torch.randn(batch_size, latent_dim, device=device)
fake_samples = generator(noise)
fake_logits = discriminator(fake_samples.detach()) # D更新ではGへ勾配を戻さない
fake_loss = criterion(fake_logits, torch.zeros_like(fake_logits))
loss_d = real_loss + fake_loss
loss_d.backward()
optimizer_d.step()
detach() はTensorの値をコピーする操作ではありません。計算グラフ上で、そこから前へ勾配を流さないようにする操作です。
Generatorの訓練
Generatorを更新するときは、Discriminatorにfake sampleを本物だと思わせたいので、targetには1を使います。
noise z -> G(z) -> D -> target 1
loss_G = BCEWithLogitsLoss(D(G(z)), ones)
このstepでは fake_samples.detach() を使ってはいけません。detachしてしまうと、Generatorへ勾配が戻らず、Generatorの重みが更新されません。次のコードも、Generator更新部分だけを抜き出した断片です。
optimizer_g.zero_grad(set_to_none=True)
fake_logits_for_g = discriminator(fake_samples)
loss_g = criterion(fake_logits_for_g, torch.ones_like(fake_logits_for_g))
loss_g.backward()
optimizer_g.step()
最初は直感に反するかもしれませんが、Generatorのlossでtargetを1にするのは自然です。Generatorは「自分が作ったsampleを、Discriminatorがrealと判断する」方向に重みを変えたいからです。
BCEWithLogitsLossでGANの損失を書く
BCEWithLogitsLoss は、Sigmoidとbinary cross entropyをまとめた損失です。入力には確率ではなくlogitsを渡します。
import torch
from torch import nn
logits = torch.tensor([[2.0], [-1.0], [0.0]])
targets = torch.tensor([[1.0], [0.0], [1.0]])
criterion = nn.BCEWithLogitsLoss()
loss = criterion(logits, targets)
print("loss:", round(loss.item(), 4))
# loss: 0.3778
Discriminatorの最後にSigmoidを入れてから BCELoss に渡す書き方もありますが、logitsのまま BCEWithLogitsLoss に渡すほうが、極端な値での数値計算が安定します。
推奨しやすい形:
discriminator -> logits
BCEWithLogitsLoss(logits, target)
避けたい形:
discriminator -> sigmoid probability
BCELoss(probability, target)
ここまでは損失関数の書き方を見てきました。次は、DiscriminatorとGeneratorを更新する際に、実際にどこで勾配の流れが切り替わるのかを、小さなコードで確認します。
小さなGAN訓練stepでdetachを確認する
実画像を使わなくても、detach() の意味は小さな線形モデルで確認できます。次の例では、Discriminatorを更新した直後にはGeneratorの勾配がまだ作られていないこと、Generator更新では勾配が作られることを見ます。
import torch
from torch import nn
torch.manual_seed(0)
latent_dim = 3
data_dim = 4
batch_size = 5
generator = nn.Sequential(nn.Linear(latent_dim, data_dim))
discriminator = nn.Sequential(nn.Linear(data_dim, 1))
criterion = nn.BCEWithLogitsLoss()
optimizer_g = torch.optim.SGD(generator.parameters(), lr=0.1)
optimizer_d = torch.optim.SGD(discriminator.parameters(), lr=0.1)
real_samples = torch.randn(batch_size, data_dim)
noise = torch.randn(batch_size, latent_dim)
fake_samples = generator(noise)
# 1. Discriminatorを更新する
optimizer_d.zero_grad(set_to_none=True)
real_logits = discriminator(real_samples)
fake_logits = discriminator(fake_samples.detach()) # このstepではGへ勾配を流さない
loss_d = criterion(real_logits, torch.ones_like(real_logits)) + criterion(
fake_logits,
torch.zeros_like(fake_logits),
)
loss_d.backward()
optimizer_d.step()
print("G grad after D step:", generator[0].weight.grad is None)
# G grad after D step: True
# 2. Generatorを更新する
optimizer_g.zero_grad(set_to_none=True)
fake_logits_for_g = discriminator(fake_samples) # ここではdetachしない
loss_g = criterion(fake_logits_for_g, torch.ones_like(fake_logits_for_g))
loss_g.backward()
optimizer_g.step()
print("G grad after G step:", generator[0].weight.grad.abs().sum().item() > 0)
# G grad after G step: True
この例ではfake sampleの中身に意味はありません。確認したいのは、Discriminator更新ではGeneratorへ勾配を流さず、Generator更新ではGeneratorへ勾配を流す、という計算グラフの切り替えです。
実際のGAN訓練では、loss_d.item()、loss_g.item()、D(x)、D(G(z)) などをログに残します。ただし、GANのlossは通常の教師あり学習のlossのように単調に下がるとは限りません。
DCGANの構造
DCGAN、Deep Convolutional GANは、画像生成向けに畳み込み構造を使うGANです。Generatorはlatent vectorから画像へ空間サイズを広げ、Discriminatorは画像からscalar logitへ空間サイズを縮めます。
Generatorの典型的な流れは次の通りです。
z: (N, nz, 1, 1)
-> ConvTranspose2d + BatchNorm + ReLU
-> ConvTranspose2d + BatchNorm + ReLU
-> ConvTranspose2d + BatchNorm + ReLU
-> ConvTranspose2d + Tanh
-> image: (N, nc, 64, 64)
Discriminatorの典型的な流れは次の通りです。
image: (N, nc, 64, 64)
-> strided Conv2d + LeakyReLU
-> strided Conv2d + BatchNorm + LeakyReLU
-> strided Conv2d + BatchNorm + LeakyReLU
-> Conv2d
-> scalar logit
小さなDCGAN風モデルでshapeだけ確認します。
import torch
from torch import nn
class MiniGenerator(nn.Module):
def __init__(self, nz=16, ngf=8, nc=3):
super().__init__()
self.main = nn.Sequential(
nn.ConvTranspose2d(nz, ngf * 8, kernel_size=4, stride=1, padding=0, bias=False), # (N, nz, 1, 1) -> (N, ngf*8, 4, 4)
nn.BatchNorm2d(ngf * 8),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(ngf * 8, ngf * 4, kernel_size=4, stride=2, padding=1, bias=False), # 4x4 -> 8x8
nn.BatchNorm2d(ngf * 4),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(ngf * 4, ngf * 2, kernel_size=4, stride=2, padding=1, bias=False), # 8x8 -> 16x16
nn.BatchNorm2d(ngf * 2),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(ngf * 2, ngf, kernel_size=4, stride=2, padding=1, bias=False), # 16x16 -> 32x32
nn.BatchNorm2d(ngf),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(ngf, nc, kernel_size=4, stride=2, padding=1, bias=False), # 32x32 -> 64x64
nn.Tanh(),
)
def forward(self, z):
return self.main(z)
class MiniDiscriminator(nn.Module):
def __init__(self, nc=3, ndf=8):
super().__init__()
self.main = nn.Sequential(
nn.Conv2d(nc, ndf, kernel_size=4, stride=2, padding=1, bias=False), # 64x64 -> 32x32
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf, ndf * 2, kernel_size=4, stride=2, padding=1, bias=False), # 32x32 -> 16x16
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 2, ndf * 4, kernel_size=4, stride=2, padding=1, bias=False), # 16x16 -> 8x8
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 4, ndf * 8, kernel_size=4, stride=2, padding=1, bias=False), # 8x8 -> 4x4
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(ndf * 8, 1, kernel_size=4, stride=1, padding=0, bias=False), # 4x4 -> 1x1
)
def forward(self, image):
return self.main(image)
z = torch.randn(2, 16, 1, 1)
generator = MiniGenerator()
discriminator = MiniDiscriminator()
fake_images = generator(z)
logits = discriminator(fake_images)
print("fake images:", tuple(fake_images.shape), fake_images.min().item() >= -1.0, fake_images.max().item() <= 1.0)
# fake images: (2, 3, 64, 64) True True
print("logits:", tuple(logits.shape))
# logits: (2, 1, 1, 1)
Generatorの最後が Tanh() なので、出力値は [-1, 1] に入ります。そのため訓練画像も同じ値域へそろえる必要があります。
DCGANの値域、初期化、訓練の注意
画像を ToTensor() で読み込むと、多くの場合、値域は [0, 1] になります。Generatorの最後に Tanh() を使うなら、実画像も Normalize((0.5,), (0.5,)) の考え方で [-1, 1] へ写します。
import torch
values = torch.tensor([0.0, 0.5, 1.0])
scaled = (values - 0.5) / 0.5
print(scaled.tolist())
# [-1.0, 0.0, 1.0]
RGB画像なら、各チャンネルに対して同じ変換を使うことが多いです。
uint8 0..255
-> float 0..1
-> Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5))
-> float -1..1
DCGANの古典的な設定では、畳み込みと転置畳み込みの重みを平均0、標準偏差0.02の正規分布で初期化し、BatchNormのweightを平均1、標準偏差0.02、biasを0に初期化することがよく使われます。
from torch import nn
def weights_init(module):
classname = module.__class__.__name__
if "Conv" in classname:
nn.init.normal_(module.weight, mean=0.0, std=0.02)
elif "BatchNorm" in classname:
nn.init.normal_(module.weight, mean=1.0, std=0.02)
nn.init.constant_(module.bias, 0.0)
OptimizerにはAdamを使うことが多く、DCGANでは lr=0.0002、betas=(0.5, 0.999) がよく知られた出発点です。ただしGANのハイパーパラメータは互いに強く関係します。Discriminatorを強くすれば必ず良くなる、という単純なものではありません。
実画像でDCGANを試す場合、PyTorchのDCGANチュートリアルではCelebA Faces datasetを使っています。これは著名人の顔画像を集めたデータセットで、公式ページ(http://mmlab.ie.cuhk.edu.hk/projects/CelebA.html、Google Driveミラーからも取得可能)から img_align_celeba.zip を取得し、次のように ImageFolder が読める構造へ展開します。
/path/to/celeba
-> img_align_celeba
-> 188242.jpg
-> 173822.jpg
-> ...
顔画像データは、利用条件、再配布可否、プライバシーに注意が必要です。自分で用意した画像や、利用条件が明確な小さな画像フォルダで構造を確認してから、大きなデータで訓練するほうが安全です。
CelebAでDCGANを訓練する骨組み
ここまでの部品をつなげると、DCGANの訓練ループは次のような形になります。CelebADataset、Generator、Discriminator はプロジェクト側で定義済みだとします。次の例は、DiscriminatorがSigmoid後の確率を返す設計に合わせて BCELoss を使っています。Discriminatorからlogitsを返す設計にする場合は、最後のSigmoidを外し、損失を BCEWithLogitsLoss に置き換えます。
from pathlib import Path
import imageio.v2 as imageio
import matplotlib.pyplot as plt
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import transforms
from torchvision.utils import make_grid
from tools.dcgan import Discriminator, Generator
from tools.my_dataset import CelebADataset
torch.manual_seed(1)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
data_dir = Path("data/celeba/img_align_celeba") # 画像ファイルを置いたディレクトリに合わせる
out_dir = Path("log_gan")
out_dir.mkdir(parents=True, exist_ok=True)
image_size = 64
nc = 3
nz = 100
ngf = 128
ndf = 128
num_epochs = 20
batch_size = 64
lr = 0.0002
beta1 = 0.5
checkpoint_interval = 10
fixed_noise = torch.randn(64, nz, 1, 1, device=device) # 各epochで同じzを使い、生成の変化を比べる
transform = transforms.Compose([
transforms.Resize(image_size),
transforms.CenterCrop(image_size),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), # 画像値域を[-1, 1]へそろえる
])
train_set = CelebADataset(data_dir=data_dir, transforms=transform)
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True, num_workers=2)
net_g = Generator(nz=nz, ngf=ngf, nc=nc).to(device)
net_d = Discriminator(nc=nc, ndf=ndf).to(device)
net_g.initialize_weights()
net_d.initialize_weights()
criterion = nn.BCELoss()
optimizer_g = torch.optim.Adam(net_g.parameters(), lr=lr, betas=(beta1, 0.999))
optimizer_d = torch.optim.Adam(net_d.parameters(), lr=lr, betas=(beta1, 0.999))
scheduler_g = torch.optim.lr_scheduler.StepLR(optimizer_g, step_size=8, gamma=0.1)
scheduler_d = torch.optim.lr_scheduler.StepLR(optimizer_d, step_size=8, gamma=0.1)
g_losses = []
d_losses = []
for epoch in range(num_epochs):
net_g.train()
net_d.train()
for batch_index, real_images in enumerate(train_loader):
real_images = real_images.to(device)
batch_current = real_images.size(0)
real_labels = torch.ones(batch_current, device=device)
fake_labels = torch.zeros(batch_current, device=device)
# 1. Discriminatorを更新する: realは1、fakeは0へ近づける
optimizer_d.zero_grad(set_to_none=True)
noise = torch.randn(batch_current, nz, 1, 1, device=device)
fake_images = net_g(noise)
real_probs = net_d(real_images).view(-1)
fake_probs = net_d(fake_images.detach()).view(-1) # D更新ではGへ勾配を戻さない
loss_d_real = criterion(real_probs, real_labels)
loss_d_fake = criterion(fake_probs, fake_labels)
loss_d = loss_d_real + loss_d_fake
loss_d.backward()
optimizer_d.step()
d_x = real_probs.mean().item()
d_g_z_before = fake_probs.mean().item()
# 2. Generatorを更新する: fakeをrealと判定させる方向へGを動かす
optimizer_g.zero_grad(set_to_none=True)
fake_probs_for_g = net_d(fake_images).view(-1) # G更新ではdetachしない
loss_g = criterion(fake_probs_for_g, real_labels)
loss_g.backward()
optimizer_g.step()
d_g_z_after = fake_probs_for_g.mean().item()
if batch_index % 10 == 0:
print(
f"[{epoch}/{num_epochs}][{batch_index}/{len(train_loader)}]\t"
f"Loss_D: {loss_d.item():.4f}\tLoss_G: {loss_g.item():.4f}\t"
f"D(x): {d_x:.4f}\tD(G(z)): {d_g_z_before:.4f} / {d_g_z_after:.4f}"
)
g_losses.append(loss_g.item())
d_losses.append(loss_d.item())
scheduler_d.step()
scheduler_g.step()
# 固定noiseから生成した画像を保存し、epochごとの変化を見る
net_g.eval()
with torch.inference_mode():
fake = net_g(fixed_noise).detach().cpu()
image_grid = make_grid(fake, padding=2, normalize=True).numpy()
image_grid = np.transpose(image_grid, (1, 2, 0))
plt.imshow(image_grid)
plt.axis("off")
plt.title(f"Epoch: {epoch}")
plt.savefig(out_dir / f"{epoch}_epoch.png")
plt.close()
if (epoch + 1) % checkpoint_interval == 0:
checkpoint = {
"g_model_state_dict": net_g.state_dict(),
"d_model_state_dict": net_d.state_dict(),
"epoch": epoch,
}
torch.save(checkpoint, out_dir / f"checkpoint_{epoch}_epoch.pkl")
plt.figure(figsize=(10, 5))
plt.title("Generator and Discriminator Loss During Training")
plt.plot(g_losses, label="G")
plt.plot(d_losses, label="D")
plt.xlabel("iterations")
plt.ylabel("Loss")
plt.legend()
plt.savefig(out_dir / "loss.png")
plt.close()
epoch_images = sorted(out_dir.glob("*_epoch.png"), key=lambda path: int(path.stem.split("_")[0]))
frames = [imageio.imread(path) for path in epoch_images]
imageio.mimsave(out_dir / "generation_animation.gif", frames, fps=2)
このコードは、各epochの生成画像を log_gan/0_epoch.png、log_gan/1_epoch.png のように保存し、最後に generation_animation.gif も作ります。画像出力はデータ、初期値、GPU実装、訓練epoch数によって変わるため、固定の数値として書くより、同じ fixed_noise からの変化を見比べるのが大切です。
ログには、たとえば次のような値が表示されます。数値そのものは訓練データと乱数で変わります。
[0/20][0/32] Loss_D: 1.3862 Loss_G: 0.7421 D(x): 0.5010 D(G(z)): 0.5004 / 0.4762
D(x) はreal画像に対するDiscriminatorの平均出力、D(G(z)) の左側はDiscriminator更新前のfake判定、右側はGenerator更新時のfake判定です。これらは訓練の様子を見る手がかりですが、単独で生成品質を保証する指標ではありません。
訓練が終わったら、次は保存済みのcheckpointから画像だけを生成する場面を見ます。
checkpointから画像を生成する
訓練済みGeneratorだけを読み込んで画像を生成する場合は、checkpointから g_model_state_dict を取り出します。DataParallel で保存した場合は、keyの先頭に module. が付くことがあるため、読み込み前に外します。
from collections import OrderedDict
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import torch
from torchvision.utils import make_grid
from tools.dcgan import Generator
def remove_module_prefix(state_dict):
cleaned_state = OrderedDict()
for key, value in state_dict.items():
cleaned_key = key.removeprefix("module.")
cleaned_state[cleaned_key] = value
return cleaned_state
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
path_checkpoint = Path("log_gan/checkpoint_19_epoch.pkl") # 実際に保存したcheckpoint名に合わせる
num_images = 64
nz = 100
ngf = 128
nc = 3
net_g = Generator(nz=nz, ngf=ngf, nc=nc)
checkpoint = torch.load(path_checkpoint, map_location="cpu", weights_only=True)
state_dict_g = remove_module_prefix(checkpoint["g_model_state_dict"])
net_g.load_state_dict(state_dict_g)
net_g.to(device).eval()
fixed_noise = torch.randn(num_images, nz, 1, 1, device=device)
with torch.inference_mode():
fake_images = net_g(fixed_noise).detach().cpu()
image_grid = make_grid(fake_images, padding=2, normalize=True).numpy()
image_grid = np.transpose(image_grid, (1, 2, 0))
plt.imshow(image_grid)
plt.axis("off")
plt.show()
この推論コードは、64枚の生成画像を8x8のgridとして表示します。生成結果はcheckpointとnoiseに依存します。同じcheckpointで比較したい場合は、torch.manual_seed(...) を設定するか、保存済みの fixed_noise を使います。
GANの失敗パターンと評価
GANの訓練では、lossだけを見ても品質を判断しにくいです。よくある失敗パターンを整理します。
Mode Collapse
Generatorが少数のパターンだけを出す状態です。
many z -> nearly identical output
見た目はそれらしくても、多様性がありません。固定noiseだけでなく、複数のnoise、訓練データとの近さ、クラスや属性の偏りを確認します。
Training Oscillation
DiscriminatorとGeneratorが互いに相手を追いかけるため、lossがきれいに下がらず、上下に振れることがあります。教師あり分類のように、lossが単調に下がることを期待しすぎないようにします。
Vanishing Gradient
Discriminatorがfakeを簡単に見破れるほど強くなると、Generatorが有効な勾配を受け取りにくくなります。GANの元々の定式化ではGeneratorは log(1 - D(G(z))) を最小化しますが、Discriminatorが強いとこの値はほぼ0で変化に乏しく、勾配が小さくなります。そのため実装では代わりに log(D(G(z))) を最大化する形(non-saturating loss)を使うことが多いです。これは、この記事の「Generatorの訓練」で示した、fakeのtargetを1にして BCEWithLogitsLoss へ渡す書き方と同じ効果を持ちます(BCEWithLogitsLoss(D(G(z)), 1) は -log(D(G(z))) を最小化することに相当します)。
Checkerboard Artifacts
転置畳み込みの重なり方によって、格子状の模様が出ることがあります。kernel size、stride、paddingの設計、resize-conv、PixelShuffleなどで軽減することがあります。
Memorization
Generatorが訓練画像をそのまま覚えてしまうことがあります。生成画像がきれいでも、訓練データの近傍検索や重複確認をしないと、分布を学んだのか暗記したのか分かりません。
評価指標には、FID、Inception Score、生成モデル向けPrecision/Recall、人手評価、タスク固有指標などがあります。FID(Fréchet Inception Distance)は、学習済みの画像分類ネットワークから取り出した特徴量の空間で、実画像とfake画像それぞれの分布(平均と共分散)を比較し、その差が小さいほど良いとする指標です。Inception Scoreは、生成画像を分類モデルに通したときのクラス予測の確信度と、クラス全体の多様性から品質を推定する指標です。どちらも唯一の正解基準ではなく、実装、サンプル数、前処理、特徴抽出器によって変わるため、数字だけを単純比較しないようにします。
ここまでは、基本的なGAN/DCGANの訓練と評価の考え方を見てきました。次は、条件を追加したり、画像変換タスクへ応用したりする代表的な派生を整理します。
条件付きGANと画像変換タスク
GANには多くの派生があります。代表的なものを、条件やタスクの観点で整理します。
| 方法 | 条件またはタスク | 考え方 |
|---|---|---|
| Conditional GAN | クラス、属性 | 条件情報をGeneratorとDiscriminatorへ入れる |
| Pix2Pix | ペア画像変換 | 入力画像と正解画像のペアで変換を学習する |
| CycleGAN | 非ペア画像変換 | cycle consistencyで対応関係を制約する |
| SRGAN | 超解像 | adversarial lossと復元lossを組み合わせる |
| Progressive GAN | 高解像度生成 | 解像度を段階的に上げながら訓練する |
| StackGAN | text-to-image | 段階的に画像を細かくする |
| Context Encoder | 画像補完 | 欠損領域を周辺文脈から補う |
| Pose-guided GAN | 姿勢条件人物生成 | 姿勢と外見の条件を使う |
高品質な画像生成では、diffusionやautoregressive model、flow matchingなども広く使われます。GANは、単回のforwardで生成できる速度の強みがあり、画像変換や低遅延が重要な場面では今も選択肢になります。
生成モデルでは、データの利用許諾、個人情報、偏り、生成物の悪用可能性も考える必要があります。特に顔、人物、声、個人に結びつくデータは、技術的に動くかどうかだけで扱いを決めないようにします。
ここまでで、GANでは「2つのネットワークの勾配の流し分け」が重要だと見てきました。次は、時間方向に同じネットワークを繰り返し使うRNNへ進みます。
RNNの役割と基本構造
RNN、Recurrent Neural Networkは、系列データを時間順に読み、hidden stateでそれまでの情報を次の時刻へ渡します。
x_t + h_(t-1) -> recurrent cell -> h_t -> optional output y_t
RNNがよく使われるデータには、次のようなものがあります。
- 文字列、単語列、文
- 時系列データ
- 音声特徴量
- 長さがそろっていない系列
ただし、長さの違う入力を扱えるのはRNNだけではありません。CNN、Transformer、State Space Modelなども、padding、mask、packing、分割処理などと組み合わせて長さの違う系列を扱えます。RNNの特徴は、hidden stateを時間方向に受け渡し、同じパラメータを各時刻で共有する点です。
この「hidden stateを受け渡す」という考え方を、次は具体的な数式とTensorのshapeで確認します。
RNNの数式とTensor形状
基本的なRNN cellは、次のように書けます。
h_t = tanh(W_ih x_t + b_ih + W_hh h_(t-1) + b_hh)
y_t = W_ho h_t + b_ho
ここで、x_t は時刻tの入力、h_t は時刻tのhidden stateです。W_ih と W_hh は、すべての時刻で共有されます。
文字分類の説明では、たとえば次のような形を使うことがあります。
x_t: (1, 58)
h_t: (1, 128)
y_t: (1, 18)
58は使う文字集合のサイズ、128はhidden size、18は分類先の数です。これは特定の文字分類例に合わせた数字であり、RNN一般の固定値ではありません。
この式は1時刻分の計算です。次は、この同じ計算を時間方向に繰り返したとき、訓練(backpropagation)がどう進むかを見ます。
時間展開とBPTT
RNNは、時間方向に同じcellを繰り返し使います。
h0 -> [cell x1] -> h1 -> [cell x2] -> h2 -> [cell x3] -> h3
same W same W same W
訓練では、時間方向に展開された計算グラフに対してbackpropagationを行います。これをBPTT、Backpropagation Through Timeと呼びます。
1. 系列を前から読む
2. 各時刻のhidden stateを作る
3. 最後または各時刻の出力からlossを計算する
4. 時間方向にさかのぼって勾配を流す
5. 共有パラメータに勾配が合算される
長い系列では、計算グラフが大きくなりすぎたり、勾配が消えたり爆発したりします。流式データや長い文章を分割して学習する場合は、区切りでhidden stateをdetachすることがあります。
hidden = hidden.detach()
これはhiddenの数値を捨てる操作ではありません。次の分割へhiddenの値は渡しつつ、前の分割まで勾配を戻さないようにします。
ここまでは数式でRNNの考え方を見てきました。次は、実際にPyTorchでこの計算をどう呼び出すかをコードで確認します。
PyTorchのRNN API
PyTorchには nn.RNN、nn.LSTM、nn.GRU が用意されています。まず nn.RNN のshapeを見ます。
import torch
from torch import nn
torch.manual_seed(0)
rnn = nn.RNN(
input_size=5,
hidden_size=7,
num_layers=2, # h_nの先頭次元 D * num_layers に影響する
batch_first=True,
bidirectional=True, # 双方向なのでD=2
)
x = torch.randn(3, 4, 5) # (N, L, H_in)
output, h_n = rnn(x)
print("output:", tuple(output.shape))
# output: (3, 4, 14)
print("h_n:", tuple(h_n.shape))
# h_n: (4, 3, 7)
batch_first=True のため、入力とoutputは (N, L, H) になります。しかし、hidden stateのshapeはbatch-firstにはなりません。
input : (N, L, H_in)
output: (N, L, D * H_out)
h_n : (D * num_layers, N, H_out)
ここで D は方向数です。片方向なら1、双方向なら2です。上の例では、D=2、num_layers=2 なので、h_n の先頭次元は4になります。
nn.RNN の主な引数は次の通りです。
| 引数 | 意味 |
|---|---|
input_size |
各時刻の入力特徴量の次元 |
hidden_size |
hidden stateの次元 |
num_layers |
RNN層の数 |
nonlinearity |
"tanh" または "relu"
|
batch_first |
入力とoutputを (N, L, H) にするか |
dropout |
多層RNNの層間dropout |
bidirectional |
双方向RNNにするか |
dropout は層と層の間に入ります。num_layers=1 の場合、最後の層の後には適用されないため、期待したようなregularizationにならないことがあります。
基本のRNN cellはシンプルですが、長い系列では情報を覚え続けるのが苦手です。次は、この弱点を補うために設計されたLSTMを見ます。
LSTM
LSTM、Long Short-Term Memoryは、hidden stateに加えてcell stateを持ちます。input gate、forget gate、candidate、output gateで情報の書き込み、保持、出力を制御します。
i_t = sigmoid(input gate)
f_t = sigmoid(forget gate)
g_t = tanh(candidate)
o_t = sigmoid(output gate)
c_t = f_t * c_(t-1) + i_t * g_t
h_t = o_t * tanh(c_t)
PyTorchの nn.LSTM は、outputと (h_n, c_n) を返します。
import torch
from torch import nn
torch.manual_seed(0)
lstm = nn.LSTM(
input_size=5,
hidden_size=7,
num_layers=1,
batch_first=True,
)
x = torch.randn(3, 4, 5)
output, (h_n, c_n) = lstm(x)
print("output:", tuple(output.shape))
# output: (3, 4, 7)
print("h_n:", tuple(h_n.shape), "c_n:", tuple(c_n.shape))
# h_n: (1, 3, 7) c_n: (1, 3, 7)
LSTMには proj_size という引数もあります。projection付きLSTMでは、hiddenの出力次元とcell stateの次元が変わるため、shapeを公式ドキュメントの定義に沿って確認します。
LSTMはgateとcell stateで表現力を高めましたが、その分パラメータも増えます。次は、よりシンプルな構造で近い効果をねらうGRUを見ます。
GRU
GRU、Gated Recurrent Unitは、reset gate、update gate、new gateを使います。LSTMのような独立したcell stateは持たず、hidden stateだけで情報を受け渡します。
r_t = sigmoid(...)
z_t = sigmoid(...)
n_t = tanh(... + r_t * (...))
h_t = (1 - z_t) * n_t + z_t * h_(t-1)
GRUの戻り値は、output, h_n です。
import torch
from torch import nn
torch.manual_seed(0)
gru = nn.GRU(
input_size=5,
hidden_size=7,
num_layers=1,
batch_first=True,
)
x = torch.randn(3, 4, 5)
output, h_n = gru(x)
print("output:", tuple(output.shape))
# output: (3, 4, 7)
print("h_n:", tuple(h_n.shape))
# h_n: (1, 3, 7)
LSTMとGRUのどちらが良いかは、データ、系列長、モデルサイズ、必要な精度、推論速度によって変わります。GRUは構造がやや軽く、LSTMはcell stateを明示的に持つ、という違いから整理すると分かりやすいです。
ここまでは1本の系列を前提にRNN cellの中身を見てきました。実際の訓練ではbatchでまとめて処理するため、次は長さの違う系列をどうそろえるかを見ます。
PaddingとPacking
同じbatchに長さの違う系列を入れるとき、短い系列をpaddingして長さをそろえることがあります。
Chou length 4
Smith length 5
Li length 2
padded -> shape (N, L_max, feature)
paddingしたままRNNへ入れると、padding部分も計算されます。これを避けるには、pack_padded_sequence を使います。
import torch
from torch import nn
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
torch.manual_seed(0)
token_ids = torch.tensor(
[[1, 2, 3, 4, 0],
[5, 6, 7, 0, 0],
[8, 9, 0, 0, 0]]
)
lengths = torch.tensor([4, 3, 2])
embedding = nn.Embedding(num_embeddings=10, embedding_dim=4, padding_idx=0) # padding_idx=0の行はEmbeddingの勾配更新から除外される
gru = nn.GRU(input_size=4, hidden_size=6, batch_first=True)
embedded = embedding(token_ids)
packed = pack_padded_sequence(
embedded,
lengths.cpu(), # lengthsがTensorの場合はCPU上に置く
batch_first=True,
enforce_sorted=False, # 長さの降順に並べ替え済みでなくてもよい
)
packed_output, h_n = gru(packed) # PackedSequenceを渡すとpadding部分の計算をスキップする
output, output_lengths = pad_packed_sequence(packed_output, batch_first=True)
print("embedded:", tuple(embedded.shape))
# embedded: (3, 5, 4)
print("output:", tuple(output.shape), output_lengths.tolist())
# output: (3, 4, 6) [4, 3, 2]
print("h_n:", tuple(h_n.shape))
# h_n: (1, 3, 6)
pack_padded_sequence は元々、系列を長さの降順に並べておくことを前提としたAPIでした。enforce_sorted=False を指定すると、この並び替えをPyTorch側が内部で行うため、呼び出し側でbatch内のsortを気にしなくてよくなります。gru(packed) は通常のTensorではなく PackedSequence を受け取り、内部でpadding部分の計算を省略します。pad_packed_sequence の出力長は、実際の最大系列長に合わせて戻ります。上の例では入力Tensorの列数は5ですが、実際の最大lengthは4なので、outputの系列長は4になります。入力時のpadding長まで戻したい場合は total_length を指定します。
最後の有効時刻を取りたいときに、単純に output[:, -1] と書くと、短い系列ではpadding位置を取ってしまうことがあります。lengths を使って最後の有効位置を選ぶか、h_n の意味を理解して使います。
ここまでは、系列の長さをどうそろえるかを見てきました。次は、そもそも文字や単語をどうやって数値のTensorへ変換するかを見ます。
文字、one-hot、Embedding
文字レベルの分類では、文字を数値へ変換します。古典的な説明では、文字ごとにone-hot vectorを作ります。
character -> integer index -> one-hot vector
小さな例で、Unicode文字をASCIIへ正規化し、文字列をone-hot Tensorへ変換してみます。
import string
import unicodedata
import torch
allowed_characters = string.ascii_letters + " .,;'" + "_"
n_letters = len(allowed_characters)
def unicode_to_ascii(text):
return "".join(
character
for character in unicodedata.normalize("NFD", text)
if unicodedata.category(character) != "Mn"
and character in allowed_characters
)
def letter_to_index(letter):
if letter not in allowed_characters:
return allowed_characters.find("_")
return allowed_characters.find(letter)
def line_to_tensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for position, letter in enumerate(line):
tensor[position, 0, letter_to_index(letter)] = 1.0
return tensor
print("n_letters:", n_letters)
# n_letters: 58
print(unicode_to_ascii("Ślusàrski"))
# Slusarski
name_tensor = line_to_tensor("Ahn")
print("shape:", tuple(name_tensor.shape))
# shape: (3, 1, 58)
print("active indices:", name_tensor.argmax(dim=2).squeeze(1).tolist())
# active indices: [26, 7, 13]
one-hotは仕組みを理解しやすい一方で、文字数や語彙数が増えると疎なTensorになります。実用的なbatch処理では、文字やtokenを整数IDにして nn.Embedding に渡すことが多いです。
string
-> Unicode normalization
-> character/token IDs
-> padded ID batch
-> Embedding
-> RNN/LSTM/GRU
-> sequence representation
-> Linear classifier
Embeddingには次の利点があります。
- 入力が整数IDなのでコンパクト
- 文字やtokenの表現を学習できる
- batch化しやすい
-
padding_idxを指定できる
文字レベルモデルでは、未知文字、アクセント記号、大文字小文字、空白、句読点をどう扱うかを最初に決めます。
ここまでのone-hotとEmbeddingの考え方を、次は具体的な文字列分類のタスクに当てはめて見ます。
人名分類の流れ
PyTorchの文字レベルRNNチュートリアルでは、複数言語由来の姓を分類する例が使われます。データは https://download.pytorch.org/tutorial/data.zip から取得でき、展開すると data/names に18個の言語名 .txt ファイルが入ります。各ファイルには、主にローマ字化された名前が1行に1つずつ入っています。
"Chou"
-> C, h, o, u
-> 各文字をIDまたはone-hotへ変換
-> RNNへ順に入力
-> 最終hiddenまたは最終出力
-> 18クラスのlogits
-> language label
逐次処理として描くと、次のようになります。
h0
-> cell(C, h0) -> h1
-> cell(h, h1) -> h2
-> cell(o, h2) -> h3
-> cell(u, h3) -> h4
-> classifier(h4)
このタスクは、RNNの系列処理を理解するための学習用の例としては便利です。ただし、名前と国籍、言語、民族性は一対一に対応しません。実在人物の属性推定や重要な判断に使うものではありません。
手作りRNNで人名分類を行う例
RNNの計算を一段ずつ見たい場合は、nn.RNN を使う前に、Linear(input -> hidden)、Linear(hidden -> hidden)、Linear(hidden -> output) を自分で組み合わせると流れが分かりやすくなります。次の例は、文字列をone-hot Tensorへ変換し、1文字ずつhidden stateを更新して、最後の出力で言語ラベルを分類する骨組みです。なお、先ほどのone-hot変換例では未知文字用に _ を加えて58文字にしましたが、ここでは元のチュートリアルの構成に合わせて57文字(_なし)のまま扱います。
from pathlib import Path
import math
import random
import string
import time
import unicodedata
import torch
from torch import nn
torch.manual_seed(1)
random.seed(1)
device = torch.device("cpu")
data_dir = Path("data/names") # data.zipを展開したdata/namesに合わせる
all_letters = string.ascii_letters + " .,;'"
n_letters = len(all_letters) # 57文字(アルファベット52+空白と.,;'の5文字。ここでは未知文字用の記号は含めない)
def unicode_to_ascii(text):
return "".join(
character
for character in unicodedata.normalize("NFD", text)
if unicodedata.category(character) != "Mn"
and character in all_letters
)
def read_lines(path):
lines = path.read_text(encoding="utf-8").strip().split("\n")
return [unicode_to_ascii(line) for line in lines]
def letter_to_index(letter):
return all_letters.find(letter)
def line_to_tensor(line):
tensor = torch.zeros(len(line), 1, n_letters)
for position, letter in enumerate(line):
tensor[position, 0, letter_to_index(letter)] = 1.0
return tensor
def time_since(start_time):
elapsed = time.time() - start_time
minutes = math.floor(elapsed / 60)
seconds = elapsed - minutes * 60
return f"{minutes}m {seconds:.0f}s"
class ManualRNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size):
super().__init__()
self.hidden_size = hidden_size
self.u = nn.Linear(input_size, hidden_size) # 前掲の数式のW_ih, b_ihに相当
self.w = nn.Linear(hidden_size, hidden_size) # 前掲の数式のW_hh, b_hhに相当
self.v = nn.Linear(hidden_size, output_size) # 前掲の数式のW_ho, b_hoに相当
self.tanh = nn.Tanh()
self.log_softmax = nn.LogSoftmax(dim=1)
def forward(self, inputs, hidden):
input_to_hidden = self.u(inputs)
hidden_to_hidden = self.w(hidden)
hidden = self.tanh(input_to_hidden + hidden_to_hidden) # h_t = tanh(W_ih x_t + b_ih + W_hh h_(t-1) + b_hh)
output = self.log_softmax(self.v(hidden)) # y_t = W_ho h_t + b_hoをlog probabilityに変換
return output, hidden
def init_hidden(self):
return torch.zeros(1, self.hidden_size) # h_0はゼロベクトルから始める
category_lines = {}
for path in sorted(data_dir.glob("*.txt")):
category = path.stem
category_lines[category] = read_lines(path)
all_categories = sorted(category_lines)
n_categories = len(all_categories)
def random_training_example():
category = random.choice(all_categories)
line = random.choice(category_lines[category])
category_tensor = torch.tensor([all_categories.index(category)], dtype=torch.long)
line_tensor = line_to_tensor(line)
return category, line, category_tensor, line_tensor
def category_from_output(output):
_, top_index = output.topk(1) # LogSoftmax出力の中で最大のlog probabilityを持つクラスを1つだけ取り出す
category_index = top_index[0].item()
return all_categories[category_index], category_index
def train_one_example(category_tensor, line_tensor):
hidden = model.init_hidden().to(device)
line_tensor = line_tensor.to(device)
category_tensor = category_tensor.to(device)
optimizer.zero_grad(set_to_none=True)
for position in range(line_tensor.size(0)):
output, hidden = model(line_tensor[position], hidden) # 1文字ずつhiddenを更新し、ループを抜けた後はoutputに最後の文字の結果だけが残る
loss = criterion(output, category_tensor) # 最後の文字の出力だけをNLLLossに渡す
loss.backward()
optimizer.step()
return output, loss.item()
n_hidden = 128
learning_rate = 0.005
n_iters = 200000
print_every = 5000
plot_every = 5000
model = ManualRNN(n_letters, n_hidden, n_categories).to(device)
criterion = nn.NLLLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
current_loss = 0.0
loss_history = []
start_time = time.time()
for iteration in range(1, n_iters + 1):
category, line, category_tensor, line_tensor = random_training_example()
output, loss = train_one_example(category_tensor, line_tensor)
current_loss += loss
if iteration % print_every == 0:
guess, _ = category_from_output(output)
correct = "yes" if guess == category else f"no ({category})"
print(
f"Iter: {iteration:<7} time: {time_since(start_time):>8s} "
f"loss: {loss:.4f} name: {line:>10s} pred: {guess:>8s} label: {correct:>8s}"
)
if iteration % plot_every == 0:
loss_history.append(current_loss / plot_every)
current_loss = 0.0
torch.save(model.state_dict(), "rnn_state_dict.pkl")
このコードは、data/names/*.txt のファイル名をカテゴリ名として読み、各ファイル内の名前をサンプルとして使います。実行すると、一定iterationごとにloss、入力された名前、予測カテゴリ、正解カテゴリが表示されます。出力値はデータの順序、乱数、学習回数に依存しますが、書式のイメージは次のようになります(200,000 iterationの実行結果ではなく、あくまで表示形式の例です)。
Iter: 5000 time: 0m 12s loss: 2.1034 name: Satoh pred: Japanese label: yes
loss が徐々に下がり、pred が label と一致する頻度が増えていけば、学習が進んでいるサインです。
訓練したモデルで予測する関数は、次のように書けます。
def evaluate(line_tensor):
hidden = model.init_hidden().to(device)
line_tensor = line_tensor.to(device)
with torch.inference_mode():
for position in range(line_tensor.size(0)):
output, hidden = model(line_tensor[position], hidden)
return output
def predict(input_line, n_predictions=3):
print(f"\n> {input_line}")
output = evaluate(line_to_tensor(input_line))
top_values, top_indices = output.topk(n_predictions, dim=1)
for rank in range(n_predictions):
value = top_values[0, rank].item()
category_index = top_indices[0, rank].item()
print(f"({value:.2f}) {all_categories[category_index]}")
predict("Yue Tingsong")
predict("test your name")
この予測結果も、訓練済み重みとカテゴリ一覧の順序に依存します。LogSoftmax の出力なので、表示される値は確率そのものではなくlog probabilityです。書式のイメージは次のようになります(実際の値やカテゴリは訓練データと乱数によって変わります)。
> Yue Tingsong
(-0.31) Chinese
(-1.84) Vietnamese
(-2.45) Korean
3つの候補は、log probabilityが大きい順(確率が高い順)に並びます。
batch化したRNN分類モデル
one-hotを1文字ずつ処理する実装は分かりやすいですが、効率はよくありません。ここでは、token ID、Embedding、GRU、packingを使うbatch分類モデルの骨組みを見ます。
import torch
from torch import nn
from torch.nn.utils.rnn import pack_padded_sequence
class NameClassifier(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, padding_idx):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=padding_idx)
self.gru = nn.GRU(
embedding_dim,
hidden_size,
batch_first=True,
bidirectional=True,
)
self.classifier = nn.Linear(hidden_size * 2, num_classes) # 双方向なのでhidden_size * 2
def forward(self, token_ids, lengths):
embedded = self.embedding(token_ids) # (N, L) -> (N, L, embedding_dim)
packed = pack_padded_sequence(
embedded,
lengths.cpu(),
batch_first=True,
enforce_sorted=False,
)
_, hidden = self.gru(packed)
representation = torch.cat([hidden[-2], hidden[-1]], dim=1) # 最後の層のforward/backwardを連結する
return self.classifier(representation)
model = NameClassifier(
vocab_size=20,
embedding_dim=6,
hidden_size=8,
num_classes=4,
padding_idx=0,
)
token_ids = torch.tensor(
[[1, 2, 3, 0],
[4, 5, 0, 0]]
)
lengths = torch.tensor([3, 2])
logits = model(token_ids, lengths)
print("logits:", tuple(logits.shape))
# logits: (2, 4)
訓練では、CrossEntropyLoss を使い、targetはshape (N,) の torch.long クラスIDにします。双方向RNNは系列全体を見て分類する用途には便利ですが、未来のtokenを見られないオンライン予測には向きません。
モデルの形が整っても、RNNの訓練では特有のつまずきが起こりやすいです。次は、その代表的な問題を整理します。
RNNの訓練でよく見る問題
RNNの訓練では、次のような問題が起こりやすいです。
勾配消失と勾配爆発
長い系列では、時間方向に何度も微分が掛け合わされます。LSTMやGRUはこの問題を緩和しますが、完全に消すわけではありません。勾配クリッピングを使うことがあります。
import torch
from torch import nn
torch.manual_seed(0)
model = nn.Linear(3, 1)
x = torch.randn(4, 3)
y = torch.randn(4, 1)
loss = nn.MSELoss()(model(x), y)
loss.backward()
total_norm = nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 全パラメータの勾配をまとめて1つのnormにし、max_normを超えた分だけスケールダウンする
print("clipped from:", round(float(total_norm), 4))
# clipped from: 2.5123
clip_grad_norm_ が返すのは、clipする前の全体normです。大きすぎる勾配を一定範囲へ抑え、学習の発散を防ぎやすくします。
Hidden Stateの持ち越し
独立したサンプルの間でhidden stateを使い回すと、本来関係ない系列の情報が混ざります。文章や時系列を分割して連続処理する場合は、どこでhiddenをリセットし、どこでdetachするかを明確にします。
Paddingの混入
padding tokenを普通のtokenとして扱うと、最後の時刻や平均poolingにpaddingの影響が入ります。Embeddingの padding_idx、packing、mask、loss側の ignore_index を使い分けます。
双方向RNNのhiddenの取り違え
h_n の先頭次元は num_layers * num_directions です。双方向かつ多層の場合、最後のforward/backward stateを正しく取り出す必要があります。
one-layer bidirectional:
hidden[-2] -> forward final
hidden[-1] -> backward final
Pythonループで遅くなる
1文字ずつPythonのfor文で処理する実装は、学習の仕組みを理解するには便利です。しかし、実際の訓練では nn.RNN、nn.LSTM、nn.GRU にbatch化したTensorを渡し、packingやGPU内核を使うほうが効率的です。
RNN、CNN、Transformerの違い
系列データに使えるモデルはRNNだけではありません。代表的な違いを整理します。
| モデル | 系列処理の特徴 | 長距離依存 | 並列性 |
|---|---|---|---|
| RNN/LSTM/GRU | hidden stateを再帰的に渡す | 時間方向の鎖に影響される | 時間方向の並列性は弱い |
| 1D CNN/TCN | 局所畳み込み、dilation | 受容野で決まる | 強い |
| Transformer | attentionと位置情報 | 全体またはmask範囲を参照できる | 訓練時の並列性が強い |
RNNは、ストリーミング、低遅延、小さなモデル、連続状態を持つタスクで使いやすいことがあります。Transformerは大規模な自然言語処理でよく使われますが、系列長が伸びると計算量やメモリが重くなります。どれを使うかは、データ、遅延、必要な文脈長、実行環境で決めます。
図として整理する
GANの全体像
z -> Generator -> fake sample --+
|
real sample --------------------+-> Discriminator -> real/fake logit
GANの2段階更新
Step D: real target=1 + fake.detach target=0 -> update D
Step G: noise -> G -> D target=1 -> update G
DCGAN
latent (N, nz, 1, 1)
-> ConvTranspose blocks
-> image (N, 3, 64, 64)
image (N, 3, 64, 64)
-> strided Conv blocks
-> logit (N, 1, 1, 1)
RNN cell
x_t ----+
+-> recurrent cell -> h_t -> output
h_t-1 --+
RNNの時間展開
x1 -> [RNN] -> h1
|
x2 -> [RNN] -> h2
|
x3 -> [RNN] -> h3 -> classifier
文字分類
characters
-> IDs / one-hot
-> Embedding or one-hot Tensor
-> RNN / LSTM / GRU
-> sequence representation
-> class logits
よくあるつまずきどころ
| つまずき | 原因 | 対処 |
|---|---|---|
Discriminatorの最後にSigmoidを入れて BCEWithLogitsLoss を使う |
Sigmoidを二重に扱っている | logitsを返すD + BCEWithLogitsLoss にする |
D更新で fake.detach() を忘れる |
G側にも不要な勾配が流れる | D stepではfakeをdetachする |
| G更新でfakeをdetachする | Gに勾配が戻らない | G stepではdetachしない |
| GとDで同じoptimizerを使う | 更新対象が混ざる | optimizerを分ける |
| 実画像の値域とG出力が合わない |
Tanh は [-1, 1] を出す |
実画像もNormalizeで [-1, 1] にする |
| GAN lossだけで品質判断する | lossと見た目、多様性が単純対応しない | 固定noise、FID、多様性、近傍確認を見る |
| D出力が0.5付近なら成功だと思う | Dが弱いだけの場合もある | 生成画像、多様性、訓練データとの近さも確認する |
| RNNだけが不定長入力を扱えると思う | 他のモデルもpaddingやmaskで扱える | モデルごとの長所で選ぶ |
batch_first=True でhiddenもbatch-firstだと思う |
hiddenのshape規則は別 |
h_n: (D*num_layers, N, H) を確認する |
単層RNNで dropout が効くと思う |
層間dropoutなので最後の層後には入らない |
num_layers とdropout位置を確認する |
padding済み系列で output[:, -1] を取る |
短い系列ではpadding位置になる |
lengths や h_n を使う |
lengths をGPU Tensorのままpackingへ渡す |
APIがCPU lengthsを要求する |
lengths.cpu() を渡す |
| 独立系列でhiddenを使い回す | サンプル間で情報が漏れる | サンプル境界でhiddenをリセットする |
| truncated BPTTでhiddenをdetachしない | 計算グラフが長く伸びる | 分割境界で hidden = hidden.detach() する |
| 分類targetをfloatにする |
CrossEntropyLoss はクラスIDを要求する |
targetを torch.long にする |
| 双方向RNNのhiddenを取り違える |
num_layers*num_directions 次元を誤解する |
layerとdirectionを分けて読む |
| 人名モデルを実在人物の属性推定に使う | 名前と属性は安定対応しない | 学習用の分類例として扱う |
参考リンク
- PyTorch
torch.nn: https://docs.pytorch.org/docs/2.13/nn.html -
BCEWithLogitsLoss: https://docs.pytorch.org/docs/2.13/generated/torch.nn.BCEWithLogitsLoss.html -
ConvTranspose2d: https://docs.pytorch.org/docs/2.13/generated/torch.nn.ConvTranspose2d.html - DCGAN tutorial: https://docs.pytorch.org/tutorials/beginner/dcgan_faces_tutorial.html
- GAN paper: https://papers.nips.cc/paper/5423-generative-adversarial-nets
- DCGAN paper: https://arxiv.org/abs/1511.06434
- WGAN-GP paper: https://arxiv.org/abs/1704.00028
- Pix2Pix paper: https://arxiv.org/abs/1611.07004
- CycleGAN paper: https://arxiv.org/abs/1703.10593
-
RNN: https://docs.pytorch.org/docs/2.13/generated/torch.nn.RNN.html -
LSTM: https://docs.pytorch.org/docs/2.13/generated/torch.nn.LSTM.html -
GRU: https://docs.pytorch.org/docs/2.13/generated/torch.nn.GRU.html -
Embedding: https://docs.pytorch.org/docs/2.13/generated/torch.nn.Embedding.html -
pack_padded_sequence: https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.rnn.pack_padded_sequence.html -
pad_packed_sequence: https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.rnn.pad_packed_sequence.html -
pack_sequence: https://docs.pytorch.org/docs/2.13/generated/torch.nn.utils.rnn.pack_sequence.html - Character-level RNN classification tutorial: https://docs.pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html
- LSTM paper: https://www.bioinf.jku.at/publications/older/2604.pdf
- GRU paper: https://arxiv.org/abs/1406.1078
おわりに
今回は、GANとRNNをまとめて整理しました。
GANでは、GeneratorとDiscriminatorを別々の目的で更新します。Discriminator更新では fake.detach() でGenerator側への勾配を止め、Generator更新ではdetachせずにDiscriminatorの出力を通してGeneratorへ勾配を戻します。Discriminatorはlogitsを返し、損失には BCEWithLogitsLoss を使うと整理しやすくなります。
DCGANでは、Generatorがlatent vectorを転置畳み込みで画像へ広げ、Discriminatorが画像を畳み込みでscalar logitへ縮めます。Tanh 出力と実画像の値域をそろえること、lossだけで品質を判断しないこと、生成データの多様性や訓練データとの近さを見ることが重要です。
RNNでは、hidden stateを時間方向に渡しながら系列を処理します。batch_first=True でもhidden stateの次元順は変わらず、paddingされたbatchではpackingやmaskを使って有効な時刻だけを扱います。LSTMやGRUは長期依存を扱いやすくしますが、勾配クリッピング、hiddenのdetach、paddingの扱いは引き続き重要です。
生成モデルも系列モデルも、表面的なモデル名よりも、Tensorのshape、値域、勾配の流れ、訓練時と推論時の違いを追えることが大切です。そこを確認できるようになると、PyTorchの公式実装や自作モデルを読むときに、どこで何が起きているのかをかなり見通しやすくなります。