File size: 6,072 Bytes
f1a3155 | 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 | ---
license: mit
library_name: pytorch
language:
- en
tags:
- pytorch
- safetensors
- diffusion
- ddpm
- tiny-model
- educational
- toy-dataset
---
# TinyDiffusion
DDPM on **2D points**. Not images. The denoiser is a 24,450-parameter MLP, trained on CPU in seconds.
Sibling of [TinyNet](https://huggingface.co/shahfazal/tinynet-relu-v1), and built for the same reason:
strip everything that isn't the mechanism. The forward noising, the epsilon objective, the ancestral
sampler and the time conditioning are unchanged from the ones in Stable Diffusion. Only the denoiser
changes with the data type, and a UNet would be the largest thing on screen while having nothing to
do with diffusion. So: an MLP, and 2D points, and the entire data distribution fits on one scatter plot.
- **Code:** https://github.com/shahfazal/tiny-diffusion
- **Write-up:** https://shahfazal.com/posts/tiny-diffusion/
## Checkpoints
| file | distribution | schedule | steps | train loss |
| -------------------------- | ------------------------------- | ---------- | ------ | ---------- |
| `dot.safetensors` | tight Gaussian at (2, 2), Ο=0.1 | linear | 3,000 | 0.066 |
| `line.safetensors` | `y = x` over [-2, 2], Ο=0.1 | linear | 3,000 | 0.258 |
| `moons-linear.safetensors` | two crescents (v0.1) | linear | 15,000 | 0.306 |
| `moons-cosine.safetensors` | two crescents (v0.1.1) | **cosine** | 15,000 | 0.413 |
Unconditional, one distribution per checkpoint. `moons-cosine` is the one to use: better samples than
`moons-linear` despite the worse loss (see below).
## Three things that will bite you
Everything else is standard DDPM. These are not.
**1. `beta_T = 0.10`, not `0.02`.** The canonical `1e-4 β 0.02` range is tuned for T=1000. At T=100 it
injects a tenth of the total noise and stalls at `alpha_bar_T = 0.36`, so the forward process never
reaches `N(0, I)` while sampling still starts there. With `beta_T = 0.10`, `alpha_bar_T = 0.0056`.
**2. Match the schedule to the checkpoint.** Loading `moons-cosine` and sampling it on the linear
schedule fails silently, producing plausible-looking garbage. See `config.json`.
**3. `moons` lives in normalised space.** It was trained on `make_moons(noise=0.05)` standardised to
zero mean / unit std per axis, so generated points come out in *that* space, not raw `make_moons`
coordinates. `dot` and `line` are unnormalised.
## Usage
No `diffusers`, no `transformers`, no `from_pretrained` β there is no library that knows this
architecture, so the module definition below *is* the API.
```python
import math, torch, torch.nn as nn
from safetensors.torch import load_file
T = 100
class TinyDiffusion(nn.Module):
def __init__(self):
super().__init__()
self.embed = nn.Embedding(T, 32)
self.layer1 = nn.Linear(2 + 32, 128)
self.layer2 = nn.Linear(128, 128)
self.layer3 = nn.Linear(128, 2)
self.act = nn.SiLU()
def forward(self, x, t):
h = torch.cat([x, self.embed(t)], dim=1)
h = self.act(self.layer1(h))
h = self.act(self.layer2(h))
return self.layer3(h)
def linear_betas():
return torch.linspace(1e-4, 0.10, T)
def cosine_betas(s=0.008): # Nichol & Dhariwal
t = torch.linspace(0, T, T + 1) / T
ab = torch.cos((t + s) / (1 + s) * math.pi / 2) ** 2
ab = ab / ab[0]
return (1 - ab[1:] / ab[:-1]).clamp(max=0.999)
@torch.no_grad()
def sample(model, betas, n=512):
alphas, ab = 1 - betas, torch.cumprod(1 - betas, dim=0)
x = torch.randn(n, 2)
for s in reversed(range(T)):
t = torch.full((n,), s, dtype=torch.long)
eps = model(x, t)
mean = (1 / alphas[s].sqrt()) * (x - (betas[s] / (1 - ab[s]).sqrt()) * eps)
x = mean + betas[s].sqrt() * torch.randn_like(x) if s > 0 else mean
return x
model = TinyDiffusion().eval()
model.load_state_dict(load_file("moons-cosine.safetensors"))
pts = sample(model, cosine_betas()) # (512, 2)
```
## Architecture
`Embedding(100, 32)` β concat with the 2D point β `Linear(34, 128)` β SiLU β `Linear(128, 128)` β
SiLU β `Linear(128, 2)`. Output is epsilon, raw. 24,450 params, a third of which were the timestep
embedding before the width went to 128.
Trained with Adam (lr 1e-3, batch 256) on freshly sampled data every step β the dataset is a
generator, not an array, so memorisation is off the table and extra capacity is free.
`make_moons` returns which crescent each point came from. It's discarded: the model has no idea there
are two modes, which is what makes mode-dropping possible at all. Using it would be conditioning.
## The loss ranks these backwards
Why `moons-cosine` ships despite the worse loss. Three seeds each:
| schedule | train loss | off-manifold | minority moon |
| -------- | ---------- | ------------ | ------------- |
| *real* | β | *0.014* | *50%* |
| linear | **0.330** | 0.046 | 47.8% |
| cosine | **0.404** | **0.038** | 48.8% |
*(off-manifold = mean distance from each generated point to the nearest real one.)*
**Cosine: 18% better samples, 22% worse loss. Every seed, no overlap.** Select on the loss curve and
you select linear, which is worse.
The floor is set by how much of `x0` is still recoverable from `x_t`. Cosine keeps signal alive
longer (SNR crosses 1 at t=49 vs t=37 for linear), so mid-trajectory the model is asked a *harder*
question and scores worse on it. The problem moved, not the quality. The same effect makes the loss
incomparable across the ladder: dot 0.06, line 0.27, moons 0.35 β all three succeeded.
## Limitations
- 2D points. This will not generate images, by design.
- Unconditional. You can sample the moons distribution; you cannot ask for the *left* crescent.
- Samples are mildly over-dispersed (dot: Ο 0.12 generated vs 0.10 real) β reverse-step error
accumulating over 100 steps.
## License
MIT.
|