ARotting commited on
Commit
9ade7a4
·
verified ·
1 Parent(s): b505575

Publish 55K parameter class-conditional diffusion model

Browse files
README.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ task_categories:
4
+ - unconditional-image-generation
5
+ tags:
6
+ - diffusion
7
+ - classifier-free-guidance
8
+ - tiny-model
9
+ - pytorch
10
+ ---
11
+
12
+ # PocketDiffusion
13
+
14
+ PocketDiffusion is a compact class-conditional denoising diffusion model for 8x8
15
+ handwritten digits. It learns to predict Gaussian noise over 50 diffusion steps and
16
+ uses classifier-free guidance during sampling.
17
+
18
+ The same frozen Tiny Vision classifier used for GlyphForge evaluates conditional
19
+ recognizability, making the VAE and diffusion results directly comparable under one
20
+ judge.
21
+
22
+ ## Reproduce
23
+
24
+ ```powershell
25
+ uv run python projects/tiny-vision-foundry/prepare_data.py
26
+ uv run python projects/pocket-diffusion/train.py
27
+ ```
28
+
29
+ ## Verified results
30
+
31
+ - Parameters: **55,608**
32
+ - Diffusion steps: **50**
33
+ - Training epochs: **300**
34
+ - Generated samples: **1,000**
35
+ - Selected classifier-free guidance: **3.0**
36
+ - Frozen-judge class fidelity: **96.20%**
37
+
38
+ Guidance search improved fidelity monotonically from 46.10% at `1.0` to 96.20% at
39
+ `3.0`. Per-class fidelity ranged from 83% for digit `8` to 100% for digits `0` and
40
+ `6`. Mean within-class pixel variance ranged from 0.0209 to 0.0456, noticeably higher
41
+ than the CVAE's 0.0066 to 0.0168 range under the same 100-samples-per-class protocol.
evaluation.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "PocketDiffusion",
3
+ "parameters": 55608,
4
+ "diffusion_steps": 50,
5
+ "epochs": 300,
6
+ "guidance_search": {
7
+ "1.0": 0.460999995470047,
8
+ "1.5": 0.7149999737739563,
9
+ "2.0": 0.8560000061988831,
10
+ "2.5": 0.9359999895095825,
11
+ "3.0": 0.9620000123977661
12
+ },
13
+ "generation": {
14
+ "judge_accuracy": 0.9620000123977661,
15
+ "judge_accuracy_by_class": {
16
+ "0": 1.0,
17
+ "1": 0.9200000166893005,
18
+ "2": 0.9900000095367432,
19
+ "3": 0.9800000190734863,
20
+ "4": 0.9599999785423279,
21
+ "5": 0.9800000190734863,
22
+ "6": 1.0,
23
+ "7": 0.9900000095367432,
24
+ "8": 0.8299999833106995,
25
+ "9": 0.9700000286102295
26
+ },
27
+ "mean_pixel_variance_by_class": {
28
+ "0": 0.020940322428941727,
29
+ "1": 0.03187673166394234,
30
+ "2": 0.03836727514863014,
31
+ "3": 0.03142565116286278,
32
+ "4": 0.04158321022987366,
33
+ "5": 0.034002821892499924,
34
+ "6": 0.022990796715021133,
35
+ "7": 0.036991652101278305,
36
+ "8": 0.04558330774307251,
37
+ "9": 0.03457583114504814
38
+ },
39
+ "samples": 1000,
40
+ "guidance": 3.0
41
+ },
42
+ "judge": "Tiny Vision labels-only student, 98.52% real-image test accuracy"
43
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f4f48f2fbc3b5dd6af4c631c3d86cff984ffbd50e624064c5cf929054bd7d813
3
+ size 223088
samples.png ADDED
source/app.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import gradio as gr
6
+ import numpy as np
7
+ import torch
8
+ from model import PocketDenoiser
9
+ from PIL import Image
10
+ from safetensors.torch import load_file
11
+
12
+ STEPS = 50
13
+ ARTIFACT = Path(__file__).resolve().parent / "artifacts" / "pocket-diffusion"
14
+ MODEL = PocketDenoiser(diffusion_steps=STEPS)
15
+ MODEL.load_state_dict(load_file(ARTIFACT / "model.safetensors"))
16
+ MODEL.eval()
17
+
18
+
19
+ def generate_digit(label: int, seed: int, guidance: float) -> Image.Image:
20
+ generator = torch.Generator().manual_seed(seed)
21
+ betas = torch.linspace(1e-4, 0.025, STEPS)
22
+ alphas = 1.0 - betas
23
+ cumulative = torch.cumprod(alphas, dim=0)
24
+ pixels = torch.randn(1, 64, generator=generator)
25
+ labels = torch.tensor([label])
26
+ null_labels = torch.tensor([10])
27
+ with torch.no_grad():
28
+ for step in reversed(range(STEPS)):
29
+ timesteps = torch.tensor([step])
30
+ conditional = MODEL(pixels, timesteps, labels)
31
+ unconditional = MODEL(pixels, timesteps, null_labels)
32
+ noise_prediction = unconditional + guidance * (conditional - unconditional)
33
+ alpha = alphas[step]
34
+ mean = (
35
+ pixels - (1 - alpha) / torch.sqrt(1 - cumulative[step]) * noise_prediction
36
+ ) / torch.sqrt(alpha)
37
+ if step:
38
+ pixels = mean + torch.sqrt(betas[step]) * torch.randn(
39
+ pixels.shape,
40
+ generator=generator,
41
+ )
42
+ else:
43
+ pixels = mean
44
+ image = torch.clamp((pixels[0] + 1) / 2, 0, 1).reshape(8, 8).numpy()
45
+ array = np.clip(image * 255, 0, 255).astype(np.uint8)
46
+ return Image.fromarray(array, mode="L").resize(
47
+ (512, 512),
48
+ Image.Resampling.NEAREST,
49
+ )
50
+
51
+
52
+ with gr.Blocks(title="PocketDiffusion") as demo:
53
+ gr.Markdown("# PocketDiffusion\nGenerate a digit through 50 reverse-denoising steps.")
54
+ with gr.Row():
55
+ label = gr.Slider(0, 9, value=8, step=1, label="Digit")
56
+ seed = gr.Slider(0, 100_000, value=2032, step=1, label="Noise seed")
57
+ guidance = gr.Slider(1.0, 4.0, value=3.0, step=0.1, label="Guidance")
58
+ output = gr.Image(value=generate_digit(8, 2032, 3.0), label="Generated glyph")
59
+ button = gr.Button("Denoise", variant="primary")
60
+ button.click(generate_digit, inputs=[label, seed, guidance], outputs=output)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ demo.launch()
source/model.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class PocketDenoiser(nn.Module):
8
+ def __init__(self, diffusion_steps: int = 50) -> None:
9
+ super().__init__()
10
+ self.diffusion_steps = diffusion_steps
11
+ self.time_embedding = nn.Embedding(diffusion_steps, 24)
12
+ self.label_embedding = nn.Embedding(11, 24)
13
+ self.network = nn.Sequential(
14
+ nn.Linear(64 + 24 + 24, 160),
15
+ nn.GELU(),
16
+ nn.Linear(160, 160),
17
+ nn.GELU(),
18
+ nn.Linear(160, 64),
19
+ )
20
+
21
+ def forward(
22
+ self,
23
+ noisy_pixels: torch.Tensor,
24
+ timesteps: torch.Tensor,
25
+ labels: torch.Tensor,
26
+ ) -> torch.Tensor:
27
+ features = torch.cat(
28
+ [
29
+ noisy_pixels,
30
+ self.time_embedding(timesteps),
31
+ self.label_embedding(labels),
32
+ ],
33
+ dim=1,
34
+ )
35
+ return self.network(features)
36
+
37
+
38
+ class TinyVisionJudge(nn.Module):
39
+ def __init__(self) -> None:
40
+ super().__init__()
41
+ self.features = nn.Sequential(
42
+ nn.Conv2d(1, 8, kernel_size=3, padding=1),
43
+ nn.GELU(),
44
+ nn.Conv2d(8, 8, kernel_size=3, padding=1, groups=8),
45
+ nn.GELU(),
46
+ nn.Conv2d(8, 12, kernel_size=1),
47
+ nn.GELU(),
48
+ nn.MaxPool2d(2),
49
+ )
50
+ self.classifier = nn.Sequential(
51
+ nn.Flatten(),
52
+ nn.Linear(12 * 4 * 4, 10),
53
+ )
54
+
55
+ def forward(self, pixels: torch.Tensor) -> torch.Tensor:
56
+ return self.classifier(self.features(pixels))
57
+
58
+
59
+ def parameter_count(model: nn.Module) -> int:
60
+ return sum(parameter.numel() for parameter in model.parameters())
source/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=5,<7
2
+ numpy>=2,<3
3
+ pillow>=11,<13
4
+ safetensors>=0.6,<1
5
+ torch>=2.7,<3
source/train.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+ import trackio
11
+ from model import PocketDenoiser, TinyVisionJudge, parameter_count
12
+ from PIL import Image
13
+ from safetensors.torch import load_file, save_file
14
+ from torch.nn import functional as F
15
+ from torch.utils.data import DataLoader, TensorDataset
16
+
17
+ PROJECT_DIR = Path(__file__).resolve().parent
18
+ ROOT_DIR = PROJECT_DIR.parents[1]
19
+ VISION_DIR = ROOT_DIR / "projects" / "tiny-vision-foundry"
20
+ DATA_DIR = VISION_DIR / "data"
21
+ JUDGE_WEIGHTS = VISION_DIR / "artifacts" / "tiny-student-scratch" / "model.safetensors"
22
+ ARTIFACT_DIR = PROJECT_DIR / "artifacts" / "pocket-diffusion"
23
+ STEPS = 50
24
+
25
+
26
+ def seed_everything(seed: int) -> None:
27
+ random.seed(seed)
28
+ np.random.seed(seed)
29
+ torch.manual_seed(seed)
30
+
31
+
32
+ def load_training_data() -> DataLoader:
33
+ frame = pd.read_parquet(DATA_DIR / "train.parquet")
34
+ pixels = np.stack(frame["image"].to_numpy()).astype(np.float32) / 8.0 - 1.0
35
+ labels = frame["label"].to_numpy(dtype=np.int64, copy=True)
36
+ return DataLoader(
37
+ TensorDataset(torch.from_numpy(pixels), torch.from_numpy(labels)),
38
+ batch_size=128,
39
+ shuffle=True,
40
+ generator=torch.Generator().manual_seed(2032),
41
+ )
42
+
43
+
44
+ def schedule() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
45
+ betas = torch.linspace(1e-4, 0.025, STEPS)
46
+ alphas = 1.0 - betas
47
+ cumulative = torch.cumprod(alphas, dim=0)
48
+ return betas, alphas, cumulative
49
+
50
+
51
+ @torch.inference_mode()
52
+ def sample(
53
+ model: PocketDenoiser,
54
+ labels: torch.Tensor,
55
+ guidance: float,
56
+ seed: int,
57
+ ) -> torch.Tensor:
58
+ generator = torch.Generator().manual_seed(seed)
59
+ betas, alphas, cumulative = schedule()
60
+ pixels = torch.randn(len(labels), 64, generator=generator)
61
+ null_labels = torch.full_like(labels, 10)
62
+ model.eval()
63
+ for step in reversed(range(STEPS)):
64
+ timesteps = torch.full((len(labels),), step, dtype=torch.long)
65
+ conditional = model(pixels, timesteps, labels)
66
+ unconditional = model(pixels, timesteps, null_labels)
67
+ predicted_noise = unconditional + guidance * (conditional - unconditional)
68
+ alpha = alphas[step]
69
+ cumulative_alpha = cumulative[step]
70
+ mean = (
71
+ pixels - (1 - alpha) / torch.sqrt(1 - cumulative_alpha) * predicted_noise
72
+ ) / torch.sqrt(alpha)
73
+ if step:
74
+ noise = torch.randn(pixels.shape, generator=generator)
75
+ pixels = mean + torch.sqrt(betas[step]) * noise
76
+ else:
77
+ pixels = mean
78
+ return torch.clamp((pixels + 1) / 2, 0, 1)
79
+
80
+
81
+ @torch.inference_mode()
82
+ def generation_metrics(
83
+ model: PocketDenoiser,
84
+ judge: TinyVisionJudge,
85
+ guidance: float,
86
+ ) -> tuple[dict, torch.Tensor, torch.Tensor]:
87
+ labels = torch.arange(10).repeat_interleave(100)
88
+ generated = sample(model, labels, guidance=guidance, seed=2032)
89
+ predictions = judge(generated.reshape(-1, 1, 8, 8)).argmax(dim=1)
90
+ accuracy_by_class = {
91
+ str(label): float(
92
+ (predictions[labels == label] == labels[labels == label]).float().mean()
93
+ )
94
+ for label in range(10)
95
+ }
96
+ diversity = {
97
+ str(label): float(generated[labels == label].var(dim=0).mean())
98
+ for label in range(10)
99
+ }
100
+ return (
101
+ {
102
+ "judge_accuracy": float((predictions == labels).float().mean()),
103
+ "judge_accuracy_by_class": accuracy_by_class,
104
+ "mean_pixel_variance_by_class": diversity,
105
+ "samples": len(labels),
106
+ "guidance": guidance,
107
+ },
108
+ generated,
109
+ labels,
110
+ )
111
+
112
+
113
+ def save_grid(generated: torch.Tensor, labels: torch.Tensor, path: Path) -> None:
114
+ selected = [generated[labels == label][:10] for label in range(10)]
115
+ images = torch.cat(selected).reshape(10, 10, 8, 8).cpu().numpy()
116
+ canvas = np.zeros((80, 80), dtype=np.uint8)
117
+ for row in range(10):
118
+ for column in range(10):
119
+ canvas[
120
+ row * 8 : (row + 1) * 8,
121
+ column * 8 : (column + 1) * 8,
122
+ ] = np.clip(images[row, column] * 255, 0, 255).astype(np.uint8)
123
+ Image.fromarray(canvas, mode="L").resize((800, 800), Image.Resampling.NEAREST).save(
124
+ path
125
+ )
126
+
127
+
128
+ def main() -> None:
129
+ seed_everything(2032)
130
+ loader = load_training_data()
131
+ model = PocketDenoiser()
132
+ judge = TinyVisionJudge()
133
+ judge.load_state_dict(load_file(JUDGE_WEIGHTS))
134
+ judge.eval()
135
+ betas, _, cumulative = schedule()
136
+ optimizer = torch.optim.AdamW(model.parameters(), lr=0.0015, weight_decay=0.001)
137
+ epochs = 300
138
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
139
+ trackio.init(
140
+ project="pocket-diffusion",
141
+ name="conditional-ddpm-cfg-v1",
142
+ config={
143
+ "parameters": parameter_count(model),
144
+ "diffusion_steps": STEPS,
145
+ "epochs": epochs,
146
+ "label_dropout": 0.12,
147
+ },
148
+ )
149
+ for epoch in range(1, epochs + 1):
150
+ model.train()
151
+ running_loss = 0.0
152
+ examples = 0
153
+ for pixels, labels in loader:
154
+ timesteps = torch.randint(0, STEPS, (len(labels),))
155
+ noise = torch.randn_like(pixels)
156
+ cumulative_alpha = cumulative[timesteps].unsqueeze(1)
157
+ noisy = (
158
+ torch.sqrt(cumulative_alpha) * pixels
159
+ + torch.sqrt(1 - cumulative_alpha) * noise
160
+ )
161
+ conditioned_labels = labels.clone()
162
+ drop = torch.rand(len(labels)) < 0.12
163
+ conditioned_labels[drop] = 10
164
+ prediction = model(noisy, timesteps, conditioned_labels)
165
+ loss = F.mse_loss(prediction, noise)
166
+ optimizer.zero_grad(set_to_none=True)
167
+ loss.backward()
168
+ optimizer.step()
169
+ running_loss += loss.item() * len(labels)
170
+ examples += len(labels)
171
+ scheduler.step()
172
+ if epoch == 1 or epoch % 10 == 0:
173
+ trackio.log(
174
+ {
175
+ "epoch": epoch,
176
+ "noise_prediction_mse": running_loss / examples,
177
+ "learning_rate": scheduler.get_last_lr()[0],
178
+ }
179
+ )
180
+ trackio.finish()
181
+
182
+ guidance_candidates = {}
183
+ for guidance in [1.0, 1.5, 2.0, 2.5, 3.0]:
184
+ metrics, _, _ = generation_metrics(model, judge, guidance)
185
+ guidance_candidates[str(guidance)] = metrics["judge_accuracy"]
186
+ best_guidance = float(max(guidance_candidates, key=guidance_candidates.get))
187
+ generation, generated, labels = generation_metrics(model, judge, best_guidance)
188
+ results = {
189
+ "model": "PocketDiffusion",
190
+ "parameters": parameter_count(model),
191
+ "diffusion_steps": STEPS,
192
+ "epochs": epochs,
193
+ "guidance_search": guidance_candidates,
194
+ "generation": generation,
195
+ "judge": "Tiny Vision labels-only student, 98.52% real-image test accuracy",
196
+ }
197
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
198
+ save_file(model.state_dict(), ARTIFACT_DIR / "model.safetensors")
199
+ save_grid(generated, labels, ARTIFACT_DIR / "samples.png")
200
+ (ARTIFACT_DIR / "evaluation.json").write_text(
201
+ json.dumps(results, indent=2),
202
+ encoding="utf-8",
203
+ )
204
+ print(json.dumps(results, indent=2))
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()