Upload folder using huggingface_hub
Browse files- .gitattributes +1 -34
- README.md +230 -0
- SHA256SUMS +11 -0
- artifact_metadata.json +66 -0
- generate_tiny_digits.py +53 -0
- model/config.json +18 -0
- model/model.safetensors +3 -0
- requirements-train.txt +3 -0
- requirements.txt +3 -0
- sample_grid.png +0 -0
- tiny_digit_diffusion.py +318 -0
- train_tiny_digit_diffusion.py +273 -0
.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 |
-
|
| 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 |
+

|
| 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()
|