File size: 14,735 Bytes
0ba9d09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | """DDPM, the reverse-time equation written on a finite grid.
A U-Net reads a corrupted image and the step it sits at, and regresses the Gaussian draw that
corrupted it. The reverse transition rebuilds the previous state from that one prediction, with a
mean that comes from completing the square and the posterior variance as its spread. The sampler
starts at a standard Gaussian and applies that transition K times. The three pieces match the
derivation one to one, and we keep the chapter's names. `forward_diffusion.py` covers the schedule
and the forward corruption, and we rebuild both here so that this file stands alone.
In Chapter 3 step k = 0 holds a clean image and corruption grows with k, up to pure noise at
k = K.
Chapter 3, Sections 3.19, 3.20 and 3.24 through 3.28 of the notes. Trained on 28-by-28 images, the
offline toy set by default and MNIST with --dataset mnist. Prints L_simple over the run, the
reverse posterior variance and the ELBO weight formed from it, the chapter's two-step reverse
transition, and the range of the generated images.
python lecture_3/ddpm.py # 300 steps on the offline images
python lecture_3/ddpm.py --steps 20 # a few seconds, for a first look
python lecture_3/ddpm.py --dataset mnist # the real digits, downloaded on first use
"""
# %% 01. Imports and configuration
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from cis6270.nets import count_parameters
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train
NUM_STEPS = 1000 # Diffusion steps K.
BETA_START = 1e-4 # Noise variance added at step 1.
BETA_END = 0.02 # Noise variance added at step K.
# %% 02. The schedule tensors and the forward corruption
#
# alpha_k = 1 - beta_k is the one-step retention, abar_k its running product, and beta-tilde_k the
# variance of the reverse posterior q(x_{k-1} | x_k, x_0). We prefix a zero to the betas, so
# Python index k holds beta_k and the cumulative product starts at abar_0 = 1, the clean state.
# One Gaussian draw then reaches any level, x_k = a_k x_0 + sigma_k eps with a_k = sqrt(abar_k) and
# sigma_k = sqrt(1 - abar_k).
betas = torch.cat([
torch.zeros(1), # Placeholder so that index k holds beta_k.
torch.linspace(BETA_START, BETA_END, NUM_STEPS), # beta_1 ... beta_K.
])
alphas = 1.0 - betas # One-step signal retention alpha_k.
alphas_cumprod = torch.cumprod(alphas, dim=0) # Cumulative retention.
alphas_cumprod_prev = torch.cat([
torch.ones(1), # abar_0 = 1.
alphas_cumprod[:-1], # Index k now holds abar_{k-1}.
])
posterior_variance = (
betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
) # Reverse variance beta-tilde_k.
def extract(schedule, k, shape):
# One schedule value per item, reshaped to broadcast over C, H, W.
out = schedule.to(k.device).gather(0, k)
return out.reshape(k.shape[0], *((1,) * (len(shape) - 1)))
def q_sample(x_0, k, noise=None):
if noise is None:
noise = torch.randn_like(x_0) # Mean 0, covariance I, shape of x_0.
abar = extract(alphas_cumprod, k, x_0.shape)
a_k = abar.sqrt() # Signal coefficient a_k.
sigma_k = (1.0 - abar).sqrt() # Noise standard deviation sigma_k.
return a_k * x_0 + sigma_k * noise, noise # x_k and the exact noise.
# %% 03. Sinusoidal step embedding
#
# A raw index is a poor input to a linear layer, so we spread the step over many frequencies
# first.
def timestep_embedding(k, dim):
half = dim // 2 # Half the coordinates sine, half cosine.
freqs = torch.exp(
-math.log(10000.0)
* torch.arange(half, device=k.device).to(torch.get_default_dtype()) / half
)
args = k.to(torch.get_default_dtype())[:, None] * freqs[None, :] # [B, half].
return torch.cat([torch.sin(args), torch.cos(args)], dim=1) # [B, dim].
def num_groups(channels):
for g in (8, 4, 2, 1): # Largest valid GroupNorm group count.
if channels % g == 0:
return g
# %% 04. The time-conditioned residual block
#
# The step embedding enters as a per-channel offset between the two convolutions, so every layer
# has the current noise level.
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, time_embed_dim):
super().__init__()
self.norm1 = nn.GroupNorm(num_groups(in_channels), in_channels)
self.conv1 = nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1)
self.time_proj = nn.Linear(time_embed_dim, out_channels)
self.norm2 = nn.GroupNorm(num_groups(out_channels), out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels,
kernel_size=3, padding=1)
self.skip = (
nn.Identity() if in_channels == out_channels
else nn.Conv2d(in_channels, out_channels, kernel_size=1)
)
self.activation = nn.SiLU()
def forward(self, x, t_emb):
h = self.conv1(self.activation(self.norm1(x))) # GN, SiLU, Conv_1.
h = h + self.time_proj(t_emb)[:, :, None, None] # Offset per channel.
h = self.conv2(self.activation(self.norm2(h)))
return h + self.skip(x) # Residual connection.
# %% 05. The U-Net
#
# Three scales, 28 by 28 down to 7 by 7 and back, with a skip at each resolution. The output has
# the shape of the input because it predicts one noise coordinate per pixel.
class UNet(nn.Module):
def __init__(self, in_channels=1, base_channels=64, time_embed_dim=128):
super().__init__()
self.time_embed_dim = time_embed_dim
self.time_mlp = nn.Sequential( # Learned representation of step k.
nn.Linear(time_embed_dim, time_embed_dim),
nn.SiLU(),
nn.Linear(time_embed_dim, time_embed_dim),
)
c1 = base_channels # 64 channels at 28 x 28.
c2 = base_channels * 2 # 128 channels at 14 x 14.
c3 = base_channels * 4 # 256 channels at 7 x 7.
self.in_conv = nn.Conv2d(in_channels, c1, kernel_size=3, padding=1)
self.down1 = ResidualBlock(c1, c1, time_embed_dim)
self.pool1 = nn.Conv2d(c1, c1, kernel_size=3, stride=2, padding=1)
self.down2 = ResidualBlock(c1, c2, time_embed_dim)
self.pool2 = nn.Conv2d(c2, c2, kernel_size=3, stride=2, padding=1)
self.mid = ResidualBlock(c2, c3, time_embed_dim)
self.up1 = nn.ConvTranspose2d(c3, c2, kernel_size=4,
stride=2, padding=1)
self.dec1 = ResidualBlock(c2 + c2, c2, time_embed_dim)
self.up2 = nn.ConvTranspose2d(c2, c1, kernel_size=4,
stride=2, padding=1)
self.dec2 = ResidualBlock(c1 + c1, c1, time_embed_dim)
self.out_conv = nn.Conv2d(c1, in_channels, kernel_size=1)
def forward(self, x, k):
t_emb = self.time_mlp(
timestep_embedding(k, self.time_embed_dim)
) # Learned representation of diffusion step k.
h = self.in_conv(x)
h1 = self.down1(h, t_emb) # 28 x 28 encoder features.
h = self.pool1(h1) # 28 -> 14.
h2 = self.down2(h, t_emb) # 14 x 14 encoder features.
h = self.pool2(h2) # 14 -> 7.
h = self.mid(h, t_emb) # Compressed representation.
h = self.up1(h) # 7 -> 14.
h = self.dec1(torch.cat([h, h2], dim=1), t_emb) # Join 14 x 14 skip.
h = self.up2(h) # 14 -> 28.
h = self.dec2(torch.cat([h, h1], dim=1), t_emb) # Join 28 x 28 skip.
return self.out_conv(h) # [B, 1, 28, 28] predicted noise.
# %% 06. One reverse step and the sampler
@torch.no_grad()
def p_sample(model, x_k, k):
eps_theta = model(x_k, k) # Predicted noise at step k.
beta_k = extract(betas, k, x_k.shape)
alpha_k = extract(alphas, k, x_k.shape)
abar_k = extract(alphas_cumprod, k, x_k.shape)
mean = (
x_k - beta_k / (1.0 - abar_k).sqrt() * eps_theta
) / alpha_k.sqrt() # Reverse mean mu_theta.
var = extract(posterior_variance, k, x_k.shape) # beta-tilde_k.
z = torch.randn_like(x_k) # Fresh Gaussian draw.
keep = (k > 1).to(torch.get_default_dtype()).reshape(-1, *((1,) * (x_k.dim() - 1)))
return mean + keep * var.sqrt() * z # No noise at the final step.
@torch.no_grad()
def sample(model, num_images, device):
x = torch.randn(num_images, 1, 28, 28, device=device) # X_K ~ N(0, I).
for step in range(NUM_STEPS, 0, -1): # k = K down to k = 1.
k = torch.full((num_images,), step, device=device, dtype=torch.long)
x = p_sample(model, x, k)
return x # Generated images in the training range [-1, 1].
# %% 07. The dropped weight and one complete reverse transition
#
# One interior term of the variational bound is beta_k^2 / (2 beta-tilde_k alpha_k (1 - abar_k))
# multiplied by the squared noise error, and L_simple sets every such factor to one. On the
# two-step schedule beta_1 = beta_2 = 0.2 the whole transition is small enough to check by hand.
# The mean there has two equal forms, one built from the noise prediction and one from the clean
# estimate it implies.
def elbo_weight(beta_k, posterior, alpha_k, abar_k) -> float:
"""The schedule constant in front of the squared noise error at one step."""
return beta_k ** 2 / (2.0 * posterior * alpha_k * (1.0 - abar_k))
def two_step_transition(beta: float, x_k: float, eps_theta: float, z: float) -> dict:
"""The reverse step at k = 2 of the two-step schedule, through both forms of the mean."""
alpha = 1.0 - beta # alpha_2.
abar_1, abar_2 = alpha, alpha ** 2 # 0.8 and 0.64 at beta = 0.2.
sigma = math.sqrt(1.0 - abar_2) # 0.6, the corruption standard deviation at k = 2.
mean = (x_k - beta / sigma * eps_theta) / math.sqrt(alpha) # The noise form of mu_theta.
x_0_hat = (x_k - sigma * eps_theta) / math.sqrt(abar_2) # The clean estimate it implies.
weights = (math.sqrt(abar_1) * beta / (1.0 - abar_2), # Weight on the clean estimate.
math.sqrt(alpha) * (1.0 - abar_1) / (1.0 - abar_2)) # Weight on x_k.
variance = beta * (1.0 - abar_1) / (1.0 - abar_2) # beta-tilde_2 = 1/9.
return {
"mean_from_noise": mean, "mean_from_clean": weights[0] * x_0_hat + weights[1] * x_k,
"x_0_hat": x_0_hat, "weights": weights, "variance": variance,
"x_{k-1}": mean + math.sqrt(variance) * z,
"elbo_weight": elbo_weight(beta, variance, alpha, abar_2),
}
# %% 08. The report
def main() -> None:
parser = common_parser(__doc__.split("\n")[0])
parser.add_argument("--dataset", choices=["toy", "mnist"], default="toy")
parser.add_argument("--base-channels", type=int, default=8,
help="U-Net base width; the notes uses 64")
parser.set_defaults(steps=300, batch_size=64, lr=2e-3, samples=4)
args = parser.parse_args()
set_seed(args.seed)
device = pick_device(args.device)
if args.dataset == "toy":
from cis6270.data import toy_images
images = toy_images(512, 28, args.seed).to(device) # [512, 1, 28, 28] offline images.
draw = lambda n: images[torch.randint(0, len(images), (n,), device=device)] # noqa: E731
else:
from cis6270.data import mnist_loader
loader = mnist_loader(batch_size=args.batch_size)
stream = iter(loader)
def draw(n): # One MNIST batch per call, restarting the loader at the end of an epoch.
nonlocal stream
batch = next(stream, None)
if batch is None:
stream = iter(loader)
batch = next(stream)
return batch[0].to(device)
model = UNet(in_channels=1, base_channels=args.base_channels, time_embed_dim=128).to(device)
if not args.quiet:
print(f"device={device} dataset={args.dataset} parameters={count_parameters(model):,}")
def loss_fn(step: int) -> torch.Tensor:
x_0 = draw(args.batch_size) # [B, 1, 28, 28] clean images in [-1, 1].
k = torch.randint(1, NUM_STEPS + 1, (x_0.shape[0],), device=device) # One level per item.
x_k, noise = q_sample(x_0, k) # Corrupt directly to step k.
return F.mse_loss(model(x_k, k), noise) # L_simple on this batch.
# The notes trains at 2e-4 for 20 epochs; 2e-3 for a few hundred steps is the
# classroom version of the same run and reaches a usable sampler in under a minute.
losses = train(model, loss_fn, args.steps, args.lr, quiet=args.quiet)
images_out = sample(model, args.samples, device) # [S, 1, 28, 28] after K reverse steps.
window = min(100, args.steps) # A short run averages over everything it has.
reverse = two_step_transition(0.2, 7.4, -1.0, 0.3)
weight_500 = elbo_weight(betas[500], posterior_variance[500],
alphas[500], alphas_cumprod[500]) # The dropped factor at k = 500.
report = {
"parameters": count_parameters(model),
f"loss, first {window} steps": sum(losses[:window]) / window,
f"loss, last {window} steps": sum(losses[-window:]) / window,
"beta-tilde at k=500": f"{float(posterior_variance[500]):.5f}", # 0.01003.
# The weight L_simple drops, on the thousand-step grid and on the two-step hand schedule.
"ELBO weight, k=500 and two-step": [round(float(weight_500), 6),
round(reverse["elbo_weight"], 4)], # 0.005508, 0.625.
"two-step reverse mean, two forms": [round(reverse["mean_from_noise"], 4),
round(reverse["mean_from_clean"], 4)], # 8.646.
"two-step x_0 estimate and weights":
[round(reverse["x_0_hat"], 4)] + [round(w, 4) for w in reverse["weights"]],
"two-step reverse variance and x_1": [round(reverse["variance"], 4),
round(reverse["x_{k-1}"], 4)], # 1/9 and 8.746.
"generated range": [round(float(images_out.min()), 3), round(float(images_out.max()), 3)],
"generated standard deviation": float(images_out.std()),
}
print_report(f"DDPM on {args.dataset} images", report)
saved = save_run(args.out, model=model, config=vars(args), report=report, losses=losses)
if saved is not None:
try: # Without torchvision the run still trains, samples and saves its weights.
from torchvision.utils import save_image
display = ((images_out.cpu() + 1.0) / 2.0).clamp(0.0, 1.0) # [-1, 1] -> [0, 1].
save_image(display, saved / "samples.png", nrow=4) # A grid of the generated images.
except ImportError:
pass
# %% 09. Run the script
if __name__ == "__main__":
main()
|