shibatch commited on
Commit
58f76dc
·
verified ·
1 Parent(s): ddf7a5f

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.safetensors filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: pytorch
3
+ pipeline_tag: text-to-image
4
+ datasets:
5
+ - ylecun/mnist
6
+ tags:
7
+ - diffusion
8
+ - ddim
9
+ - mnist
10
+ - handwritten-digits
11
+ - tiny-model
12
+ ---
13
+
14
+ # Tiny Digit Diffusion 3M
15
+
16
+ Tiny Digit Diffusion 3M is a **2,767,529-parameter** conditional diffusion
17
+ model that renders a prompt containing one to eight ASCII digits as a
18
+ handwritten grayscale image. It was trained from scratch on dynamically
19
+ composed MNIST digit strings and contains no weights from another generative
20
+ model.
21
+
22
+ This is a deliberately narrow tiny-model experiment, not a general-purpose
23
+ text-to-image model. Its complete prompt language is a numeric string such as
24
+ `7`, `2026`, or `31415926`.
25
+
26
+ ![Generated samples](sample_grid.png)
27
+
28
+ ## Repository contents
29
+
30
+ - `model/model.safetensors`: FP32 EMA inference weights
31
+ - `model/config.json`: architecture and diffusion configuration
32
+ - `tiny_digit_diffusion.py`: model, schedule, DDIM sampler, and weight loader
33
+ - `generate_tiny_digits.py`: command-line generation example
34
+ - `train_tiny_digit_diffusion.py`: training script
35
+ - `artifact_metadata.json`: training settings and evaluation summary
36
+ - `sample_grid.png`: generations from the final checkpoint
37
+
38
+ Optimizer checkpoints, MNIST files, per-epoch samples, and the training log are
39
+ not included in the distribution package.
40
+
41
+ ## Quick start
42
+
43
+ Create an environment and install the inference dependencies:
44
+
45
+ ```bash
46
+ python -m venv .venv
47
+ source .venv/bin/activate
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+ Generate an eight-digit image:
52
+
53
+ ```bash
54
+ python generate_tiny_digits.py 31415926 --output 31415926.png
55
+ ```
56
+
57
+ The script accepts the following useful options:
58
+
59
+ ```bash
60
+ python generate_tiny_digits.py 2026 \
61
+ --steps 50 \
62
+ --guidance-scale 1.0 \
63
+ --seed 0 \
64
+ --device auto \
65
+ --output 2026.png
66
+ ```
67
+
68
+ The output is a fixed `32 x 256` grayscale PNG. Short prompts are centered on
69
+ the eight available 32-pixel slots.
70
+
71
+ ## Python example
72
+
73
+ ```python
74
+ import torch
75
+ from PIL import Image
76
+
77
+ from tiny_digit_diffusion import ddim_sample, load_model
78
+
79
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
80
+ model = load_model("model", device)
81
+
82
+ image = ddim_sample(
83
+ model,
84
+ prompts=["31415926"],
85
+ device=device,
86
+ sampling_steps=50,
87
+ guidance_scale=1.0,
88
+ seed=0,
89
+ )[0, 0]
90
+
91
+ pixels = ((image.cpu() + 1) * 127.5).round().clamp(0, 255).byte().numpy()
92
+ Image.fromarray(pixels, mode="L").save("31415926.png")
93
+ ```
94
+
95
+ Prompts must contain only one to eight ASCII digits. Spaces, signs, decimal
96
+ points, letters, and strings longer than eight digits are rejected.
97
+
98
+ ## Recommended inference settings
99
+
100
+ ```yaml
101
+ sampling_method: deterministic DDIM
102
+ sampling_steps: 50
103
+ guidance_scale: 1.0
104
+ output_size: 32 x 256
105
+ ```
106
+
107
+ Use `guidance_scale=1.0`. Larger classifier-free-guidance values are not
108
+ helpful for this checkpoint. In particular, the original diagnostic setting
109
+ of `3.0` over-amplifies the conditioning difference and creates digit-like
110
+ artifacts in padding slots. At `1.0`, the sampler uses the conditional model
111
+ directly and skips the unnecessary unconditional pass.
112
+
113
+ ## Architecture
114
+
115
+ ```yaml
116
+ model: custom conditional diffusion U-Net
117
+ parameter_count: 2,767,529
118
+ weight_dtype: float32
119
+ weight_file_size: approximately 11 MB
120
+
121
+ image_channels: 1
122
+ image_height: 32
123
+ image_width: 256
124
+ maximum_digits: 8
125
+ slot_width: 32
126
+
127
+ base_channels: 48
128
+ channel_multipliers: [1, 1.5, 2, 2.6667]
129
+ embedding_dim: 192
130
+ token_embedding_dim: 32
131
+ spatial_condition_channels: 8
132
+ attention_heads: 4
133
+ diffusion_steps: 400
134
+ ```
135
+
136
+ The denoiser has four resolution levels with residual blocks, skip
137
+ connections, a bottleneck self-attention layer, sinusoidal timestep
138
+ conditioning, global prompt conditioning, and slot-aligned spatial digit
139
+ conditioning. Prompt tokens consist of digits `0` through `9`, a padding token,
140
+ and a classifier-free null token.
141
+
142
+ ## Training
143
+
144
+ Each training example was assembled dynamically from MNIST:
145
+
146
+ 1. Sample a prompt length uniformly from one to eight digits.
147
+ 2. Sample an MNIST image independently for every digit.
148
+ 3. Center the string on a `32 x 256` canvas.
149
+ 4. Apply small horizontal and vertical offsets and an intensity variation.
150
+ 5. Train the model to predict noise at a random cosine-schedule timestep.
151
+
152
+ Training configuration:
153
+
154
+ ```yaml
155
+ dtype: float32
156
+ epochs: 30
157
+ steps: 28,110
158
+ batch_size: 64
159
+ samples_processed: 1,799,040
160
+ optimizer: AdamW
161
+ learning_rate: 2.0e-4
162
+ warmup_steps: 500
163
+ minimum_learning_rate: 2.0e-5
164
+ weight_decay: 0.0
165
+ gradient_clip: 1.0
166
+ condition_dropout: 0.1
167
+ ema_decay: 0.999
168
+ final_recent_noise_mse: 0.015345
169
+ training_time: approximately 70 minutes
170
+ ```
171
+
172
+ The distributed checkpoint is the EMA model, not the raw final optimizer
173
+ weights.
174
+
175
+ To train from scratch, install the additional dependencies and choose an output
176
+ directory:
177
+
178
+ ```bash
179
+ pip install -r requirements-train.txt
180
+ python train_tiny_digit_diffusion.py \
181
+ --output-dir runs/tiny_digit_diffusion_3m \
182
+ --data-dir data/mnist \
183
+ --epochs 30 \
184
+ --batch-size 64 \
185
+ --device cuda
186
+ ```
187
+
188
+ ## Evaluation
189
+
190
+ A separate small MNIST classifier was trained only for automated readability
191
+ measurement. It reached 98.68% accuracy on the MNIST test split. The final
192
+ generator was then evaluated on 240 random prompts: 30 prompts for every length
193
+ from one through eight digits, using 50 DDIM steps and guidance 1.0.
194
+
195
+ | Prompt length | Exact string | Per-digit accuracy |
196
+ |---:|---:|---:|
197
+ | 1 | 29/30 (96.7%) | 96.7% |
198
+ | 2 | 26/30 (86.7%) | 91.7% |
199
+ | 3 | 26/30 (86.7%) | 95.6% |
200
+ | 4 | 22/30 (73.3%) | 93.3% |
201
+ | 5 | 22/30 (73.3%) | 93.3% |
202
+ | 6 | 14/30 (46.7%) | 88.9% |
203
+ | 7 | 22/30 (73.3%) | 95.7% |
204
+ | 8 | 13/30 (43.3%) | 90.4% |
205
+ | **Overall** | **174/240 (72.5%)** | **92.6%** |
206
+
207
+ These are OCR-proxy measurements, not human ratings. A generated digit can be
208
+ legible to a person while being classified differently, and the classifier can
209
+ also be confidently wrong. The samples should therefore be considered
210
+ alongside the numeric results.
211
+
212
+ ## Limitations
213
+
214
+ - Longer strings compound individual digit errors; eight-digit exact match is
215
+ substantially lower than single-digit accuracy.
216
+ - Some handwritten `3`, `5`, `7`, `8`, and `9` shapes can be ambiguous.
217
+ - The output canvas and maximum string length are fixed by the architecture.
218
+ - The model supports only ASCII digits and does not render signs, punctuation,
219
+ decimal values, mathematical expressions, or arbitrary text.
220
+ - This checkpoint reproduces MNIST-like handwriting only. It is not suitable
221
+ for OCR security testing, document generation, or realistic typography.
222
+ - Results vary with the random seed even though each DDIM trajectory is
223
+ deterministic for a fixed seed.
224
+
225
+ ## Scope
226
+
227
+ This checkpoint is intended for education, architecture experiments, tests,
228
+ and demonstrations of a complete conditional image generator at a very small
229
+ parameter count. It should not be interpreted as an official MNIST model or as
230
+ an image-generation counterpart of any production diffusion system.
SHA256SUMS ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 32c3d5d9a15f591030c3a116ba7d068f88aa3e59f3f50edfa173e9174fa8d627 .gitattributes
2
+ 1f1c500038095b00eef5c89dac5fa260f7f3b132052b4788281874e08329dbf3 README.md
3
+ 334a86682c1450ba57900e4ac690557b5ccab31feb67e780b0d8c56eeaabe1ab artifact_metadata.json
4
+ 3cf8877a074fc77f0e50197a800b3614a532dbba1386ed0b30ae5252004747cc generate_tiny_digits.py
5
+ 421b80e7ba8f6b89125b7d6f65bb2356a1df2c7f39b7c125001a093a09d20795 model/config.json
6
+ 035ccfb2876e5d78a637e282288e6bfaff227d7a18b3e9fe4562ff2a441458c2 model/model.safetensors
7
+ e18e17c2ae10c8fa2a3947abc31ea4a0eac86fc5c2ecad39578b9fe027999aca requirements-train.txt
8
+ 7db726c6b928d639520fbeb88c6146fb7b33cb6960dc8a01e2cc8380822c51dc requirements.txt
9
+ d382d9dae68f91c93c9da353ee49a746428ab0c64a4b3e43ac88024765bd4a79 sample_grid.png
10
+ 38e6cb6b1daa5b27a6663a6c29455cfdd0255d43ae1b4f487d48417334d211bb tiny_digit_diffusion.py
11
+ 8c70ce74440d2a4930ddef0199d68c191de598fca7220241ac75d46e9b72fb89 train_tiny_digit_diffusion.py
artifact_metadata.json ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "parameter_count": 2767529,
3
+ "training_steps": 28110,
4
+ "epochs_completed": 30,
5
+ "final_recent_loss": 0.01534514181315899,
6
+ "training_seconds": 4215.443364993001,
7
+ "args": {
8
+ "output_dir": "runs/tiny_digit_diffusion_3m",
9
+ "data_dir": "/home/codex/data/mnist",
10
+ "epochs": 30,
11
+ "samples_per_epoch": 60000,
12
+ "batch_size": 64,
13
+ "learning_rate": 0.0002,
14
+ "weight_decay": 0.0,
15
+ "warmup_steps": 500,
16
+ "grad_clip": 1.0,
17
+ "condition_dropout": 0.1,
18
+ "ema_decay": 0.999,
19
+ "num_workers": 4,
20
+ "max_steps": 0,
21
+ "log_steps": 50,
22
+ "sample_every": 1,
23
+ "sample_steps": 40,
24
+ "guidance_scale": 3.0,
25
+ "checkpoint_every": 5,
26
+ "seed": 1234,
27
+ "device": "cuda"
28
+ },
29
+ "config": {
30
+ "image_height": 32,
31
+ "image_width": 256,
32
+ "max_digits": 8,
33
+ "slot_width": 32,
34
+ "base_channels": 48,
35
+ "channel_mults": [
36
+ 1,
37
+ 1.5,
38
+ 2,
39
+ 2.6666666666666665
40
+ ],
41
+ "embedding_dim": 192,
42
+ "token_embedding_dim": 32,
43
+ "condition_channels": 8,
44
+ "attention_heads": 4,
45
+ "diffusion_steps": 400
46
+ },
47
+ "sample_prompts": [
48
+ "0",
49
+ "7",
50
+ "42",
51
+ "2026",
52
+ "12345678",
53
+ "99999999"
54
+ ],
55
+ "recommended_inference": {
56
+ "sampling_steps": 50,
57
+ "guidance_scale": 1.0
58
+ },
59
+ "evaluation": {
60
+ "classifier_test_accuracy": 0.9868,
61
+ "generated_digit_accuracy": 0.926,
62
+ "generated_sequence_exact_match": 0.725,
63
+ "evaluation_prompts": 240,
64
+ "prompts_per_length_1_to_8": 30
65
+ }
66
+ }
generate_tiny_digits.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate a handwritten 1-8 digit image from a numeric prompt."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ from PIL import Image
11
+
12
+ from tiny_digit_diffusion import ddim_sample, load_model
13
+
14
+
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("prompt", help="A numeric prompt containing 1-8 digits, for example 2026.")
18
+ parser.add_argument(
19
+ "--model-dir",
20
+ default=str(Path(__file__).resolve().parent / "model"),
21
+ )
22
+ parser.add_argument("--output", default="generated_digits.png")
23
+ parser.add_argument("--steps", type=int, default=50)
24
+ parser.add_argument("--guidance-scale", type=float, default=1.0)
25
+ parser.add_argument("--seed", type=int, default=0)
26
+ parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
27
+ return parser.parse_args()
28
+
29
+
30
+ def main() -> None:
31
+ args = parse_args()
32
+ device = torch.device(
33
+ "cuda" if args.device == "auto" and torch.cuda.is_available() else
34
+ "cpu" if args.device == "auto" else args.device
35
+ )
36
+ model = load_model(args.model_dir, device)
37
+ image = ddim_sample(
38
+ model,
39
+ [args.prompt],
40
+ device,
41
+ sampling_steps=args.steps,
42
+ guidance_scale=args.guidance_scale,
43
+ seed=args.seed,
44
+ )[0, 0]
45
+ pixels = ((image.cpu() + 1) * 127.5).round().clamp(0, 255).byte().numpy()
46
+ output = Path(args.output)
47
+ output.parent.mkdir(parents=True, exist_ok=True)
48
+ Image.fromarray(pixels, mode="L").save(output)
49
+ print(output.resolve())
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
model/config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "image_height": 32,
3
+ "image_width": 256,
4
+ "max_digits": 8,
5
+ "slot_width": 32,
6
+ "base_channels": 48,
7
+ "channel_mults": [
8
+ 1,
9
+ 1.5,
10
+ 2,
11
+ 2.6666666666666665
12
+ ],
13
+ "embedding_dim": 192,
14
+ "token_embedding_dim": 32,
15
+ "condition_channels": 8,
16
+ "attention_heads": 4,
17
+ "diffusion_steps": 400
18
+ }
model/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:035ccfb2876e5d78a637e282288e6bfaff227d7a18b3e9fe4562ff2a441458c2
3
+ size 11081924
requirements-train.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ torchvision>=0.18
3
+ tqdm>=4.66
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch>=2.3
2
+ safetensors>=0.4
3
+ Pillow>=10.0
sample_grid.png ADDED
tiny_digit_diffusion.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tiny conditional diffusion model for 1-8 digit MNIST strings."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import math
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from PIL import Image, ImageDraw
14
+ from safetensors.torch import load_file, save_file
15
+ from torch import nn
16
+
17
+
18
+ PAD_TOKEN = 10
19
+ NULL_TOKEN = 11
20
+
21
+
22
+ @dataclass
23
+ class ModelConfig:
24
+ image_height: int = 32
25
+ image_width: int = 256
26
+ max_digits: int = 8
27
+ slot_width: int = 32
28
+ base_channels: int = 48
29
+ channel_mults: tuple[int, ...] = (1, 1.5, 2, 8 / 3)
30
+ embedding_dim: int = 192
31
+ token_embedding_dim: int = 32
32
+ condition_channels: int = 8
33
+ attention_heads: int = 4
34
+ diffusion_steps: int = 400
35
+
36
+ @classmethod
37
+ def from_json(cls, path: str | Path) -> "ModelConfig":
38
+ values = json.loads(Path(path).read_text(encoding="utf-8"))
39
+ if "channel_mults" in values:
40
+ values["channel_mults"] = tuple(values["channel_mults"])
41
+ return cls(**values)
42
+
43
+ def save(self, path: str | Path) -> None:
44
+ Path(path).write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
45
+
46
+
47
+ def prompt_to_tokens(prompt: str, config: ModelConfig) -> tuple[torch.Tensor, int]:
48
+ prompt = prompt.strip()
49
+ if not prompt or len(prompt) > config.max_digits or not prompt.isascii() or not prompt.isdigit():
50
+ raise ValueError(f"Prompt must contain 1-{config.max_digits} ASCII digits, got {prompt!r}.")
51
+ tokens = torch.full((config.max_digits,), PAD_TOKEN, dtype=torch.long)
52
+ start = (config.max_digits - len(prompt)) // 2
53
+ tokens[start : start + len(prompt)] = torch.tensor([int(ch) for ch in prompt])
54
+ return tokens, len(prompt)
55
+
56
+
57
+ class SinusoidalTimeEmbedding(nn.Module):
58
+ def __init__(self, dim: int):
59
+ super().__init__()
60
+ self.dim = dim
61
+
62
+ def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
63
+ half = self.dim // 2
64
+ scale = math.log(10_000) / max(half - 1, 1)
65
+ frequencies = torch.exp(-scale * torch.arange(half, device=timesteps.device))
66
+ angles = timesteps.float()[:, None] * frequencies[None]
67
+ embedding = torch.cat([angles.sin(), angles.cos()], dim=-1)
68
+ if self.dim % 2:
69
+ embedding = F.pad(embedding, (0, 1))
70
+ return embedding
71
+
72
+
73
+ class ResBlock(nn.Module):
74
+ def __init__(self, in_channels: int, out_channels: int, embedding_dim: int):
75
+ super().__init__()
76
+ self.norm1 = nn.GroupNorm(8, in_channels)
77
+ self.conv1 = nn.Conv2d(in_channels, out_channels, 3, padding=1)
78
+ self.embedding = nn.Linear(embedding_dim, out_channels)
79
+ self.norm2 = nn.GroupNorm(8, out_channels)
80
+ self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1)
81
+ self.skip = nn.Conv2d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity()
82
+
83
+ def forward(self, x: torch.Tensor, embedding: torch.Tensor) -> torch.Tensor:
84
+ h = self.conv1(F.silu(self.norm1(x)))
85
+ h = h + self.embedding(F.silu(embedding))[:, :, None, None]
86
+ h = self.conv2(F.silu(self.norm2(h)))
87
+ return h + self.skip(x)
88
+
89
+
90
+ class SelfAttention2d(nn.Module):
91
+ def __init__(self, channels: int, heads: int):
92
+ super().__init__()
93
+ if channels % heads:
94
+ raise ValueError("Attention channels must be divisible by the number of heads.")
95
+ self.heads = heads
96
+ self.norm = nn.GroupNorm(8, channels)
97
+ self.qkv = nn.Conv2d(channels, channels * 3, 1)
98
+ self.proj = nn.Conv2d(channels, channels, 1)
99
+
100
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
101
+ batch, channels, height, width = x.shape
102
+ head_dim = channels // self.heads
103
+ qkv = self.qkv(self.norm(x)).view(batch, 3, self.heads, head_dim, height * width)
104
+ q, k, v = qkv.unbind(dim=1)
105
+ q = q.transpose(-2, -1)
106
+ k = k.transpose(-2, -1)
107
+ v = v.transpose(-2, -1)
108
+ h = F.scaled_dot_product_attention(q, k, v)
109
+ h = h.transpose(-2, -1).reshape(batch, channels, height, width)
110
+ return x + self.proj(h)
111
+
112
+
113
+ class TinyDigitDiffusion(nn.Module):
114
+ def __init__(self, config: ModelConfig):
115
+ super().__init__()
116
+ self.config = config
117
+ channels = [int(config.base_channels * mult) for mult in config.channel_mults]
118
+ if any(c % 8 for c in channels):
119
+ raise ValueError(f"All channels must be divisible by 8, got {channels}.")
120
+
121
+ time_dim = 64
122
+ self.time_embedding = nn.Sequential(
123
+ SinusoidalTimeEmbedding(time_dim),
124
+ nn.Linear(time_dim, config.embedding_dim),
125
+ nn.SiLU(),
126
+ nn.Linear(config.embedding_dim, config.embedding_dim),
127
+ )
128
+ self.token_embedding = nn.Embedding(12, config.token_embedding_dim)
129
+ self.position_embedding = nn.Parameter(
130
+ torch.randn(config.max_digits, config.token_embedding_dim) * 0.02
131
+ )
132
+ self.length_embedding = nn.Embedding(config.max_digits + 1, config.embedding_dim)
133
+ self.condition_mlp = nn.Sequential(
134
+ nn.Linear(config.max_digits * config.token_embedding_dim, config.embedding_dim),
135
+ nn.SiLU(),
136
+ nn.Linear(config.embedding_dim, config.embedding_dim),
137
+ )
138
+ self.spatial_condition = nn.Embedding(12, config.condition_channels)
139
+
140
+ self.input_conv = nn.Conv2d(1 + config.condition_channels, channels[0], 3, padding=1)
141
+ self.down_blocks = nn.ModuleList()
142
+ self.downsamples = nn.ModuleList()
143
+ for i, channel in enumerate(channels):
144
+ self.down_blocks.append(ResBlock(channel, channel, config.embedding_dim))
145
+ if i < len(channels) - 1:
146
+ self.downsamples.append(nn.Conv2d(channel, channels[i + 1], 4, stride=2, padding=1))
147
+
148
+ self.mid1 = ResBlock(channels[-1], channels[-1], config.embedding_dim)
149
+ self.mid_attention = SelfAttention2d(channels[-1], config.attention_heads)
150
+ self.mid2 = ResBlock(channels[-1], channels[-1], config.embedding_dim)
151
+
152
+ self.upsamples = nn.ModuleList()
153
+ self.up_blocks = nn.ModuleList()
154
+ for i in range(len(channels) - 1, 0, -1):
155
+ self.upsamples.append(nn.ConvTranspose2d(channels[i], channels[i - 1], 4, stride=2, padding=1))
156
+ self.up_blocks.append(ResBlock(channels[i - 1] * 2, channels[i - 1], config.embedding_dim))
157
+
158
+ self.output_norm = nn.GroupNorm(8, channels[0])
159
+ self.output_conv = nn.Conv2d(channels[0], 1, 3, padding=1)
160
+
161
+ def _condition(
162
+ self, tokens: torch.Tensor, lengths: torch.Tensor, height: int, width: int
163
+ ) -> tuple[torch.Tensor, torch.Tensor]:
164
+ token_features = self.token_embedding(tokens) + self.position_embedding[None]
165
+ global_condition = self.condition_mlp(token_features.flatten(1))
166
+ global_condition = global_condition + self.length_embedding(lengths)
167
+
168
+ spatial = self.spatial_condition(tokens).transpose(1, 2)
169
+ spatial = spatial.repeat_interleave(self.config.slot_width, dim=-1)
170
+ if spatial.shape[-1] != width:
171
+ spatial = F.interpolate(spatial, size=width, mode="nearest")
172
+ spatial = spatial[:, :, None, :].expand(-1, -1, height, -1)
173
+ return global_condition, spatial
174
+
175
+ def forward(
176
+ self,
177
+ noisy_images: torch.Tensor,
178
+ timesteps: torch.Tensor,
179
+ tokens: torch.Tensor,
180
+ lengths: torch.Tensor,
181
+ ) -> torch.Tensor:
182
+ condition, spatial = self._condition(tokens, lengths, noisy_images.shape[-2], noisy_images.shape[-1])
183
+ embedding = self.time_embedding(timesteps) + condition
184
+ h = self.input_conv(torch.cat([noisy_images, spatial], dim=1))
185
+
186
+ skips = []
187
+ for i, block in enumerate(self.down_blocks):
188
+ h = block(h, embedding)
189
+ skips.append(h)
190
+ if i < len(self.downsamples):
191
+ h = self.downsamples[i](h)
192
+
193
+ h = self.mid2(self.mid_attention(self.mid1(h, embedding)), embedding)
194
+ for upsample, block, skip in zip(self.upsamples, self.up_blocks, reversed(skips[:-1])):
195
+ h = upsample(h)
196
+ h = block(torch.cat([h, skip], dim=1), embedding)
197
+ return self.output_conv(F.silu(self.output_norm(h)))
198
+
199
+
200
+ def cosine_beta_schedule(steps: int, s: float = 0.008) -> torch.Tensor:
201
+ x = torch.linspace(0, steps, steps + 1, dtype=torch.float64)
202
+ cumulative = torch.cos(((x / steps + s) / (1 + s)) * math.pi * 0.5).square()
203
+ cumulative = cumulative / cumulative[0]
204
+ betas = 1 - cumulative[1:] / cumulative[:-1]
205
+ return betas.clamp(1e-5, 0.999).float()
206
+
207
+
208
+ class DiffusionSchedule:
209
+ def __init__(self, steps: int, device: torch.device):
210
+ self.steps = steps
211
+ self.betas = cosine_beta_schedule(steps).to(device)
212
+ self.alphas = 1 - self.betas
213
+ self.alpha_bars = self.alphas.cumprod(dim=0)
214
+
215
+ def add_noise(
216
+ self, clean: torch.Tensor, noise: torch.Tensor, timesteps: torch.Tensor
217
+ ) -> torch.Tensor:
218
+ alpha_bar = self.alpha_bars[timesteps][:, None, None, None]
219
+ return alpha_bar.sqrt() * clean + (1 - alpha_bar).sqrt() * noise
220
+
221
+
222
+ @torch.inference_mode()
223
+ def ddim_sample(
224
+ model: TinyDigitDiffusion,
225
+ prompts: list[str],
226
+ device: torch.device,
227
+ sampling_steps: int = 50,
228
+ guidance_scale: float = 3.0,
229
+ seed: int = 0,
230
+ ) -> torch.Tensor:
231
+ config = model.config
232
+ if not 2 <= sampling_steps <= config.diffusion_steps:
233
+ raise ValueError(f"sampling_steps must be in [2, {config.diffusion_steps}].")
234
+ encoded = [prompt_to_tokens(prompt, config) for prompt in prompts]
235
+ tokens = torch.stack([item[0] for item in encoded]).to(device)
236
+ lengths = torch.tensor([item[1] for item in encoded], device=device)
237
+ null_tokens = torch.full_like(tokens, NULL_TOKEN)
238
+ null_lengths = torch.zeros_like(lengths)
239
+
240
+ generator = torch.Generator(device=device).manual_seed(seed)
241
+ images = torch.randn(
242
+ len(prompts), 1, config.image_height, config.image_width,
243
+ generator=generator, device=device,
244
+ )
245
+ schedule = DiffusionSchedule(config.diffusion_steps, device)
246
+ timesteps = torch.linspace(
247
+ config.diffusion_steps - 1, 0, sampling_steps, device=device
248
+ ).round().long()
249
+
250
+ was_training = model.training
251
+ model.eval()
252
+ for i, timestep in enumerate(timesteps):
253
+ t = torch.full((len(prompts),), int(timestep), device=device, dtype=torch.long)
254
+ if guidance_scale == 1.0:
255
+ # At exactly 1.0, CFG reduces algebraically to the conditional
256
+ # prediction. Avoiding the unconditional half nearly halves
257
+ # inference memory and compute and also makes the recommended path
258
+ # straightforward.
259
+ predicted_noise = model(images, t, tokens, lengths)
260
+ else:
261
+ model_input = torch.cat([images, images], dim=0)
262
+ time_input = torch.cat([t, t], dim=0)
263
+ token_input = torch.cat([null_tokens, tokens], dim=0)
264
+ length_input = torch.cat([null_lengths, lengths], dim=0)
265
+ eps_unconditional, eps_conditional = model(
266
+ model_input, time_input, token_input, length_input
267
+ ).chunk(2)
268
+ predicted_noise = eps_unconditional + guidance_scale * (
269
+ eps_conditional - eps_unconditional
270
+ )
271
+
272
+ alpha_bar = schedule.alpha_bars[timestep]
273
+ if i + 1 < len(timesteps):
274
+ previous_alpha_bar = schedule.alpha_bars[timesteps[i + 1]]
275
+ else:
276
+ previous_alpha_bar = torch.ones((), device=device)
277
+ predicted_clean = (
278
+ images - (1 - alpha_bar).sqrt() * predicted_noise
279
+ ) / alpha_bar.sqrt()
280
+ predicted_clean = predicted_clean.clamp(-1, 1)
281
+ images = previous_alpha_bar.sqrt() * predicted_clean + (
282
+ 1 - previous_alpha_bar
283
+ ).sqrt() * predicted_noise
284
+ if was_training:
285
+ model.train()
286
+ return images.clamp(-1, 1)
287
+
288
+
289
+ def save_prompt_sheet(images: torch.Tensor, prompts: list[str], path: str | Path) -> None:
290
+ images = ((images.detach().cpu() + 1) * 127.5).round().clamp(0, 255).byte()
291
+ row_height = images.shape[-2] + 10
292
+ label_width = 90
293
+ sheet = Image.new("L", (label_width + images.shape[-1], row_height * len(prompts)), 255)
294
+ draw = ImageDraw.Draw(sheet)
295
+ for i, (image, prompt) in enumerate(zip(images, prompts)):
296
+ y = i * row_height
297
+ draw.text((4, y + 10), prompt, fill=0)
298
+ digit_image = Image.fromarray(image[0].numpy(), mode="L")
299
+ sheet.paste(digit_image, (label_width, y + 5))
300
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
301
+ sheet.save(path)
302
+
303
+
304
+ def save_weights(model: TinyDigitDiffusion, path: str | Path) -> None:
305
+ tensors = {name: value.detach().cpu().contiguous() for name, value in model.state_dict().items()}
306
+ save_file(tensors, str(path))
307
+
308
+
309
+ def load_model(model_dir: str | Path, device: torch.device) -> TinyDigitDiffusion:
310
+ model_dir = Path(model_dir)
311
+ config = ModelConfig.from_json(model_dir / "config.json")
312
+ model = TinyDigitDiffusion(config)
313
+ model.load_state_dict(load_file(model_dir / "model.safetensors", device=str(device)))
314
+ return model.to(device).eval()
315
+
316
+
317
+ def count_parameters(model: nn.Module) -> int:
318
+ return sum(parameter.numel() for parameter in model.parameters())
train_tiny_digit_diffusion.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train TinyDigitDiffusion on dynamically composed multi-digit MNIST images."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import copy
8
+ import json
9
+ import math
10
+ import random
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import torch
15
+ import torch.nn.functional as F
16
+ from torch.utils.data import DataLoader, Dataset
17
+ from torchvision.datasets import MNIST
18
+ from tqdm import tqdm
19
+
20
+ from tiny_digit_diffusion import (
21
+ NULL_TOKEN,
22
+ PAD_TOKEN,
23
+ DiffusionSchedule,
24
+ ModelConfig,
25
+ TinyDigitDiffusion,
26
+ count_parameters,
27
+ ddim_sample,
28
+ save_prompt_sheet,
29
+ save_weights,
30
+ )
31
+
32
+
33
+ class MultiDigitMNIST(Dataset):
34
+ def __init__(self, root: str | Path, max_digits: int, samples_per_epoch: int, train: bool = True):
35
+ self.mnist = MNIST(root=str(root), train=train, download=True)
36
+ self.images = self.mnist.data
37
+ self.labels = self.mnist.targets
38
+ self.max_digits = max_digits
39
+ self.samples_per_epoch = samples_per_epoch
40
+
41
+ def __len__(self) -> int:
42
+ return self.samples_per_epoch
43
+
44
+ def __getitem__(self, index: int):
45
+ del index
46
+ length = int(torch.randint(1, self.max_digits + 1, ()).item())
47
+ tokens = torch.full((self.max_digits,), PAD_TOKEN, dtype=torch.long)
48
+ start_slot = (self.max_digits - length) // 2
49
+ canvas = torch.zeros(1, 32, self.max_digits * 32, dtype=torch.float32)
50
+
51
+ chosen = torch.randint(0, len(self.images), (length,))
52
+ for offset, image_index in enumerate(chosen):
53
+ digit = self.images[image_index].float().div(255)
54
+ label = int(self.labels[image_index])
55
+ slot = start_slot + offset
56
+ tokens[slot] = label
57
+ x = slot * 32 + 2 + int(torch.randint(-2, 3, ()).item())
58
+ y = 2 + int(torch.randint(-2, 3, ()).item())
59
+ x = min(max(x, slot * 32), slot * 32 + 4)
60
+ y = min(max(y, 0), 4)
61
+ intensity = float(torch.empty(()).uniform_(0.85, 1.0))
62
+ canvas[0, y : y + 28, x : x + 28] = torch.maximum(
63
+ canvas[0, y : y + 28, x : x + 28], digit * intensity
64
+ )
65
+ return canvas.mul(2).sub(1), tokens, torch.tensor(length, dtype=torch.long)
66
+
67
+
68
+ def parse_args() -> argparse.Namespace:
69
+ parser = argparse.ArgumentParser()
70
+ parser.add_argument("--output-dir", required=True)
71
+ parser.add_argument("--data-dir", default="data/mnist")
72
+ parser.add_argument("--epochs", type=int, default=30)
73
+ parser.add_argument("--samples-per-epoch", type=int, default=60_000)
74
+ parser.add_argument("--batch-size", type=int, default=64)
75
+ parser.add_argument("--learning-rate", type=float, default=2e-4)
76
+ parser.add_argument("--weight-decay", type=float, default=0.0)
77
+ parser.add_argument("--warmup-steps", type=int, default=500)
78
+ parser.add_argument("--grad-clip", type=float, default=1.0)
79
+ parser.add_argument("--condition-dropout", type=float, default=0.1)
80
+ parser.add_argument("--ema-decay", type=float, default=0.999)
81
+ parser.add_argument("--num-workers", type=int, default=4)
82
+ parser.add_argument("--max-steps", type=int, default=0)
83
+ parser.add_argument("--log-steps", type=int, default=50)
84
+ parser.add_argument("--sample-every", type=int, default=1)
85
+ parser.add_argument("--sample-steps", type=int, default=40)
86
+ parser.add_argument("--guidance-scale", type=float, default=1.0)
87
+ parser.add_argument("--checkpoint-every", type=int, default=5)
88
+ parser.add_argument("--seed", type=int, default=1234)
89
+ parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
90
+ return parser.parse_args()
91
+
92
+
93
+ def update_ema(ema_model: torch.nn.Module, model: torch.nn.Module, decay: float) -> None:
94
+ with torch.no_grad():
95
+ for ema_parameter, parameter in zip(ema_model.parameters(), model.parameters()):
96
+ ema_parameter.lerp_(parameter, 1 - decay)
97
+ for ema_buffer, buffer in zip(ema_model.buffers(), model.buffers()):
98
+ ema_buffer.copy_(buffer)
99
+
100
+
101
+ def main() -> None:
102
+ args = parse_args()
103
+ random.seed(args.seed)
104
+ torch.manual_seed(args.seed)
105
+ if torch.cuda.is_available():
106
+ torch.cuda.manual_seed_all(args.seed)
107
+ device = torch.device(
108
+ "cuda" if args.device == "auto" and torch.cuda.is_available() else
109
+ "cpu" if args.device == "auto" else args.device
110
+ )
111
+
112
+ output_dir = Path(args.output_dir).resolve()
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+ model_dir = output_dir / "model"
115
+ sample_dir = output_dir / "samples"
116
+ checkpoint_dir = output_dir / "checkpoints"
117
+ model_dir.mkdir(exist_ok=True)
118
+ sample_dir.mkdir(exist_ok=True)
119
+ checkpoint_dir.mkdir(exist_ok=True)
120
+
121
+ config = ModelConfig()
122
+ config.save(model_dir / "config.json")
123
+ model = TinyDigitDiffusion(config).to(device=device, dtype=torch.float32)
124
+ ema_model = copy.deepcopy(model).requires_grad_(False).eval()
125
+ parameters = count_parameters(model)
126
+ print("Device:", device)
127
+ print("Dtype: float32")
128
+ print("Parameters:", f"{parameters:,}")
129
+ print("Image size:", f"{config.image_height}x{config.image_width}")
130
+ print("Maximum digits:", config.max_digits)
131
+
132
+ dataset = MultiDigitMNIST(args.data_dir, config.max_digits, args.samples_per_epoch)
133
+ loader = DataLoader(
134
+ dataset,
135
+ batch_size=args.batch_size,
136
+ shuffle=False,
137
+ num_workers=args.num_workers,
138
+ pin_memory=device.type == "cuda",
139
+ persistent_workers=args.num_workers > 0,
140
+ drop_last=True,
141
+ )
142
+ steps_per_epoch = len(loader)
143
+ total_steps = args.max_steps if args.max_steps > 0 else args.epochs * steps_per_epoch
144
+ if total_steps <= args.warmup_steps:
145
+ args.warmup_steps = max(0, total_steps // 10)
146
+ print("Steps per epoch:", steps_per_epoch)
147
+ print("Training steps:", total_steps)
148
+
149
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay)
150
+
151
+ def lr_factor(step: int) -> float:
152
+ if args.warmup_steps and step < args.warmup_steps:
153
+ return max((step + 1) / args.warmup_steps, 1 / args.warmup_steps)
154
+ progress = (step - args.warmup_steps) / max(total_steps - args.warmup_steps, 1)
155
+ return 0.1 + 0.9 * 0.5 * (1 + math.cos(progress * math.pi))
156
+
157
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_factor)
158
+ diffusion = DiffusionSchedule(config.diffusion_steps, device)
159
+ history: list[dict] = []
160
+ recent_losses: list[float] = []
161
+ global_step = 0
162
+ started = time.monotonic()
163
+ sample_prompts = ["0", "7", "42", "2026", "12345678", "99999999"]
164
+
165
+ model.train()
166
+ stop = False
167
+ for epoch in range(1, args.epochs + 1):
168
+ progress = tqdm(loader, desc=f"epoch {epoch}/{args.epochs}")
169
+ for clean, tokens, lengths in progress:
170
+ clean = clean.to(device, non_blocking=True)
171
+ tokens = tokens.to(device, non_blocking=True)
172
+ lengths = lengths.to(device, non_blocking=True)
173
+ drop = torch.rand(len(clean), device=device) < args.condition_dropout
174
+ tokens = tokens.clone()
175
+ lengths = lengths.clone()
176
+ tokens[drop] = NULL_TOKEN
177
+ lengths[drop] = 0
178
+
179
+ timesteps = torch.randint(0, config.diffusion_steps, (len(clean),), device=device)
180
+ noise = torch.randn_like(clean)
181
+ noisy = diffusion.add_noise(clean, noise, timesteps)
182
+ predicted = model(noisy, timesteps, tokens, lengths)
183
+ loss = F.mse_loss(predicted, noise)
184
+ if not torch.isfinite(loss):
185
+ raise RuntimeError(f"Non-finite loss at step {global_step + 1}: {loss}")
186
+
187
+ optimizer.zero_grad(set_to_none=True)
188
+ loss.backward()
189
+ if args.grad_clip > 0:
190
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
191
+ optimizer.step()
192
+ scheduler.step()
193
+ global_step += 1
194
+ effective_ema_decay = min(
195
+ args.ema_decay, (1 + global_step) / (10 + global_step)
196
+ )
197
+ update_ema(ema_model, model, effective_ema_decay)
198
+ recent_losses.append(float(loss.detach().cpu()))
199
+
200
+ if global_step % args.log_steps == 0:
201
+ average = sum(recent_losses[-args.log_steps:]) / min(len(recent_losses), args.log_steps)
202
+ record = {
203
+ "step": global_step,
204
+ "epoch": epoch,
205
+ "loss": average,
206
+ "learning_rate": scheduler.get_last_lr()[0],
207
+ }
208
+ history.append(record)
209
+ progress.set_postfix(loss=f"{average:.4f}", lr=f"{record['learning_rate']:.2e}")
210
+ if global_step >= total_steps:
211
+ stop = True
212
+ break
213
+
214
+ if args.sample_every > 0 and (epoch % args.sample_every == 0 or stop):
215
+ samples = ddim_sample(
216
+ ema_model, sample_prompts, device,
217
+ sampling_steps=args.sample_steps,
218
+ guidance_scale=args.guidance_scale,
219
+ seed=args.seed + epoch,
220
+ )
221
+ save_prompt_sheet(samples, sample_prompts, sample_dir / f"epoch_{epoch:03d}.png")
222
+ if args.checkpoint_every > 0 and epoch % args.checkpoint_every == 0 and not stop:
223
+ torch.save(
224
+ {
225
+ "epoch": epoch,
226
+ "step": global_step,
227
+ "model": model.state_dict(),
228
+ "ema_model": ema_model.state_dict(),
229
+ "optimizer": optimizer.state_dict(),
230
+ "scheduler": scheduler.state_dict(),
231
+ "args": vars(args),
232
+ },
233
+ checkpoint_dir / f"epoch_{epoch:03d}.pt",
234
+ )
235
+ (output_dir / "training_history.json").write_text(
236
+ json.dumps(history, indent=2), encoding="utf-8"
237
+ )
238
+ if stop:
239
+ break
240
+
241
+ save_weights(ema_model, model_dir / "model.safetensors")
242
+ elapsed = time.monotonic() - started
243
+ metadata = {
244
+ "parameter_count": parameters,
245
+ "training_steps": global_step,
246
+ "epochs_completed": epoch,
247
+ "final_recent_loss": sum(recent_losses[-100:]) / min(len(recent_losses), 100),
248
+ "training_seconds": elapsed,
249
+ "args": vars(args),
250
+ "config": vars(config),
251
+ "sample_prompts": sample_prompts,
252
+ "recommended_inference": {
253
+ "sampling_steps": 50,
254
+ "guidance_scale": 1.0,
255
+ },
256
+ }
257
+ (output_dir / "artifact_metadata.json").write_text(
258
+ json.dumps(metadata, indent=2), encoding="utf-8"
259
+ )
260
+ final_samples = ddim_sample(
261
+ ema_model, sample_prompts, device,
262
+ sampling_steps=max(args.sample_steps, 50),
263
+ guidance_scale=args.guidance_scale,
264
+ seed=0,
265
+ )
266
+ save_prompt_sheet(final_samples, sample_prompts, output_dir / "final_samples.png")
267
+ print("Done:", output_dir)
268
+ print("Final recent loss:", metadata["final_recent_loss"])
269
+ print("Training seconds:", elapsed)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ main()