Split View: 모두를 위한 AI 6편 — 111만 파라미터 디퓨전으로 단어에서 숫자 그리기
모두를 위한 AI 6편 — 111만 파라미터 디퓨전으로 단어에서 숫자 그리기
- 들어가며 — 5편이 남긴 숙제
- 핵심 아이디어 — 망가뜨리기는 쉽고 되돌리기는 어렵다
- 조건 붙이기 — 단어로 그림 지시하기
- Reverse — 400번 거꾸로 밟기
- 학습 로그
- 결과
- 정리
- 🧠 이해도 체크 퀴즈
- 참고 자료
들어가며 — 5편이 남긴 숙제
5편의 컬러화는 형태를 잘 살렸지만 색이 바랬습니다. 원인은 모델이 아니라 손실이었습니다. 흑백 자동차는 빨강일 수도 파랑일 수도 있는데, L1은 "절대 오차를 최소화하라"고 지시하므로 여러 정답의 중간값인 회색을 찍는 것이 가장 안전한 전략이 됩니다.
이번 편의 문제도 정답이 여럿입니다. "three"라는 단어에 대응하는 손글씨 3은 수천 가지입니다. 만약 5편처럼 회귀 손실로 "3 이미지를 직접 예측하라"고 시키면, 모든 3의 평균인 흐릿한 얼룩이 나올 것입니다.
디퓨전은 이 문제를 정면으로 피해 갑니다. 한 번에 정답을 만들지 않고, 노이즈에서 조금씩 걷어내는 과정으로 바꿉니다.
핵심 아이디어 — 망가뜨리기는 쉽고 되돌리기는 어렵다
깨끗한 이미지에 노이즈를 조금씩 섞어 완전한 잡음으로 만드는 것은 쉽습니다. 공식 한 줄이면 됩니다. 어려운 건 그 반대 방향입니다.
디퓨전의 발상은 이렇습니다. 어려운 문제를 쉬운 문제 400개로 쪼갠다. 잡음에서 이미지를 한 번에 만드는 것은 어렵지만, "아주 조금 더 깨끗하게 만들기"는 배울 만합니다. 그걸 400번 반복합니다.
Forward — 노이즈를 섞는 과정
T = 400
beta = torch.linspace(1e-4, 0.02, T) # 각 단계에서 섞을 노이즈의 양
alpha = 1 - beta
abar = torch.cumprod(alpha, 0) # 누적곱
abar[t]는 "시각 t까지 원본이 얼마나 남아 있는가"를 나타냅니다. t=0에서는 거의 1(원본 그대로), t=399에서는 거의 0(완전한 잡음)입니다.
여기서 디퓨전의 첫 번째 장치가 나옵니다. 400단계를 순서대로 밟지 않고 임의의 t로 한 번에 점프할 수 있습니다.
t = torch.randint(0, T, (256,), device=dev)
eps = torch.randn_like(x0) # 표준 정규분포 노이즈
a = abar[t][:, None, None, None]
xt = a.sqrt() * x0 + (1 - a).sqrt() * eps # 한 줄로 t단계 노이즈 적용
이 한 줄 덕분에 학습이 효율적입니다. 배치마다 서로 다른 t를 뽑아 모든 시각을 병렬로 학습시킬 수 있습니다.
학습 목표 — 이미지가 아니라 노이즈를 맞힌다
loss = F.mse_loss(model(xt, t.float(), w), eps)
모델이 예측하는 것은 원본 이미지 x0가 아니라 섞여 들어간 노이즈 eps 입니다.
수학적으로는 둘이 동등합니다. xt와 t를 알면 eps에서 x0를 계산할 수 있고 반대도 성립합니다. 하지만 실제로는 노이즈 예측이 훨씬 잘 작동합니다.
이유는 목표의 성질에 있습니다. x0를 예측하라고 하면, t가 클 때(거의 잡음일 때) 모델은 사실상 정보 없이 이미지를 지어내야 하고 여기서 5편의 평균 회귀가 다시 발생합니다. 반면 eps는 어떤 t에서든 항상 표준 정규분포입니다. 목표의 스케일과 분포가 일정하니 학습이 안정적입니다.
조건 붙이기 — 단어로 그림 지시하기
시각 t와 단어를 같은 벡터 공간에서 더해 U-Net 중앙에 주입합니다.
class CondUNet(nn.Module):
def __init__(self):
super().__init__()
self.temb = nn.Sequential(nn.Linear(64, 128), nn.GELU(), nn.Linear(128, 128))
self.wemb = nn.Embedding(10, 128) # zero~nine
self.e1 = block(1, 48); self.e2 = block(48, 96); self.e3 = block(96, 192)
self.cond3 = nn.Linear(128, 192)
self.d2 = block(192 + 96, 96); self.d1 = block(96 + 48, 48)
self.out = nn.Conv2d(48, 1, 1)
def forward(self, x, t, w):
half = torch.exp(-np.log(10000) * torch.arange(32, device=x.device) / 32)
te = torch.cat([torch.sin(t[:, None] * half), torch.cos(t[:, None] * half)], 1)
c = self.temb(te) + self.wemb(w) # 시각 + 단어
s1 = self.e1(x)
s2 = self.e2(F.max_pool2d(s1, 2))
h = self.e3(F.max_pool2d(s2, 2)) + self.cond3(c)[:, :, None, None]
...
시각 임베딩에 사인·코사인을 쓰는 것은 트랜스포머의 위치 인코딩과 같은 발상입니다. 정수 t를 그대로 넣으면 신경망이 다루기 어려운 스케일이지만, 서로 다른 주파수의 사인파로 펼치면 인접한 시각끼리 비슷한 벡터를 갖게 됩니다.
주입 위치가 가장 깊은 층(e3 출력) 인 것도 의도적입니다. 해상도가 8×8로 가장 작아 채널당 정보 밀도가 높고, 여기서 조건을 걸면 이후 디코더 전체가 그 영향을 받습니다.
self.cond3(c)[:, :, None, None] 의 None 두 개는 (배치, 채널) 벡터를 (배치, 채널, 1, 1)로 만들어 공간 차원 전체에 브로드캐스트하기 위한 것입니다. 즉 조건이 모든 위치에 동일하게 더해집니다.
Reverse — 400번 거꾸로 밟기
@torch.no_grad()
def sample(words):
x = torch.randn(len(words), 1, 28, 28, device=dev) # 순수 잡음에서 시작
w = torch.tensor([WORDS.index(s) for s in words], device=dev)
for ti in reversed(range(T)): # 399 → 0
t = torch.full((len(words),), ti, device=dev, dtype=torch.float32)
eps = model(x, t, w) # 노이즈 예측
a, ab = alpha[ti], abar[ti]
x = (x - (1 - a) / (1 - ab).sqrt() * eps) / a.sqrt() # 한 단계 제거
if ti > 0:
x = x + beta[ti].sqrt() * torch.randn_like(x) # 새 노이즈 재주입
return ((x.clamp(-1, 1) + 1) / 2).squeeze(1).cpu().numpy()
마지막 줄의 노이즈 재주입이 이상해 보일 수 있습니다. 애써 걷어낸 노이즈를 왜 다시 넣을까요.
그것이 다양성의 원천이기 때문입니다. 매 단계 새 난수를 넣으므로 같은 단어를 넣어도 매번 다른 3이 나옵니다. 5편에서 회귀가 하나의 평균값에 갇혔던 것과 정반대입니다. 디퓨전은 "여러 정답 중 하나를 확률적으로 골라내는" 구조입니다. 마지막 단계(ti == 0)에서만 재주입을 생략해 깨끗한 결과를 얻습니다.
학습 로그
[ 9.2s] params {"params": 1111777, "T": 400, "n": 60000}
[ 10.1s] step 0 mse=1.1502
[ 12.2s] step 100 mse=0.0807
[ 959.7s] step 44200 mse=0.0324
[ 960.6s] SUMMARY {"steps": 44215, "final_mse": 0.0320}
100스텝, 2초 만에 MSE가 1.15에서 0.081로 떨어졌습니다. 예측 대상이 표준 정규분포라 초기 손실이 1 근처에서 시작하는 것도 자연스럽습니다. 이후 완만하게 0.032까지 내려갔습니다.
결과
각 행은 "zero"부터 "nine"까지 열 단어이고, 두 행은 같은 단어에 대한 서로 다른 샘플입니다.

1행은 0부터 9까지 전부 정확합니다. 획의 두께와 기울기도 손글씨답습니다.
2행에서 두 개가 틀렸습니다. "five" 자리에 9에 가까운 형태가, "nine" 자리에 7에 가까운 형태가 나왔습니다. 10개 중 8개 정확, 80%입니다.
두 행을 비교하면 디퓨전의 성질이 드러납니다. 같은 단어인데 글씨체가 다릅니다. 1행의 2와 2행의 2는 곡선의 굽은 정도가 다르고, 1행의 7과 2행의 7은 가로획 유무가 다릅니다. 평균으로 뭉개지지 않고 매번 하나의 구체적인 손글씨를 만들어 낸다는 뜻입니다. 5편의 바랜 색과 대비되는 지점입니다.
틀린 5와 9는 형태적으로 서로 닮은 숫자입니다. 111만 파라미터, 16분 학습으로는 이 정도 혼동이 남습니다.
정리
| 항목 | 값 |
|---|---|
| 파라미터 | 1,111,777 (1.11M) |
| 학습 시간 | 960.6초 (16분) |
| 스텝 | 44,215 |
| 디퓨전 단계 T | 400 |
| 최종 MSE | 0.0320 |
| 생성 정확도 | 8/10 |
핵심을 세 줄로 요약하면 이렇습니다.
디퓨전은 어려운 생성 문제를 쉬운 노이즈 제거 400개로 쪼갭니다. forward는 공식 한 줄이고 학습은 임의의 t로 점프해 병렬화됩니다.
모델은 이미지가 아니라 노이즈를 예측합니다. 목표가 어떤 t에서든 표준 정규분포로 일정하기 때문에 학습이 안정적이고, x0를 직접 예측할 때 발생하는 평균 회귀를 피합니다.
reverse 과정의 노이즈 재주입이 다양성을 만듭니다. 같은 조건에서도 매번 다른 결과가 나오는 것이 회귀 모델과의 결정적 차이입니다.
다만 이번 구현은 기본형입니다. 400단계를 전부 밟아야 해서 느리고, 조건을 얼마나 강하게 걸지 조절할 수단도 없습니다. 시리즈 11편에서 이 두 가지를 해결하는 Classifier-Free Guidance와 DDIM을 다루면서, 두 기법이 서로 독립이 아니라는 것도 실측으로 보게 됩니다.
🧠 이해도 체크 퀴즈
1. 모델이 원본 이미지 대신 노이즈를 예측하도록 학습시키는 이유는 무엇인가요?
수학적으로는 둘이 동등하지만 학습 안정성이 다릅니다. 노이즈는 어떤 시각 t에서든 항상 표준 정규분포라 목표의 스케일과 분포가 일정합니다. 반면 원본 이미지를 예측하게 하면, t가 커서 거의 잡음만 남은 상황에서 모델이 정보 없이 이미지를 지어내야 하고, 여기서 여러 정답의 평균으로 도망가는 문제가 발생합니다.
2. reverse 과정에서 노이즈를 다시 주입하는 이유는 무엇인가요?
다양성을 만들기 위해서입니다. 매 단계 새 난수를 넣으므로 같은 단어를 조건으로 줘도 매번 다른 손글씨가 나옵니다. 결과 이미지의 두 행이 같은 숫자인데 글씨체가 다른 것이 그 증거입니다. 이것이 회귀 모델이 평균 하나에 갇히는 것과의 결정적 차이입니다. 마지막 단계에서만 재주입을 생략해 깨끗한 결과를 얻습니다.
3. 학습할 때 400단계를 순서대로 밟지 않아도 되는 이유는 무엇인가요?
xt = sqrt(abar[t]) * x0 + sqrt(1 - abar[t]) * eps 라는 닫힌 형태의 공식이 있어, 임의의 t에 해당하는 노이즈 상태를 한 번에 계산할 수 있기 때문입니다. 덕분에 배치마다 서로 다른 t를 무작위로 뽑아 모든 시각을 병렬로 학습시킬 수 있습니다.
4. 조건 벡터를 U-Net의 가장 깊은 층에 주입하고 [:, :, None, None] 을 붙이는 이유는 무엇인가요?
가장 깊은 층은 해상도가 8×8로 가장 작아 채널당 정보 밀도가 높고, 여기서 조건을 걸면 이후 디코더 전체가 영향을 받습니다. None 두 개는 (배치, 채널) 모양의 벡터를 (배치, 채널, 1, 1)로 만들어 공간 차원 전체에 브로드캐스트하기 위한 것으로, 조건이 모든 위치에 동일하게 더해집니다.
참고 자료
AI for Everyone, Part 6 — Drawing Digits From Words With a 1.11M Diffusion Model
- The homework Part 5 left behind
- The core idea — destroying is easy, restoring is hard
- Conditioning — instructing the picture with a word
- Reverse — walking back 400 times
- Training log
- Results
- Summary
- 🧠 Comprehension quiz
- References
The homework Part 5 left behind
Part 5's colourisation preserved shape well but the colours came out washed out. The cause was the loss, not the model. A greyscale car could be red or blue, and since L1 asks to minimise absolute error, predicting grey — the middle of all valid answers — becomes the safest strategy.
This part's problem is also many-to-one. There are thousands of handwritten 3s that correspond to the word "three". Ask a regression loss to "predict the image of a 3 directly", as in Part 5, and you get a blurry smear: the average of every 3.
Diffusion sidesteps this head-on. Instead of producing the answer in one shot, it turns generation into gradually clearing away noise.
The core idea — destroying is easy, restoring is hard
Adding noise to a clean image until it becomes pure static is easy: one formula does it. The hard direction is the other way.
Diffusion's insight is this: break one hard problem into 400 easy ones. Making an image from static in a single step is hard, but "make this slightly cleaner" is learnable. Repeat it 400 times.
Forward — adding the noise
T = 400
beta = torch.linspace(1e-4, 0.02, T) # how much noise to mix at each step
alpha = 1 - beta
abar = torch.cumprod(alpha, 0) # cumulative product
abar[t] expresses "how much of the original survives up to time t". Near 1 at t=0 (the original), near 0 at t=399 (pure static).
Here comes diffusion's first trick. You do not walk the 400 steps in order — you can jump straight to any t.
t = torch.randint(0, T, (256,), device=dev)
eps = torch.randn_like(x0) # standard normal noise
a = abar[t][:, None, None, None]
xt = a.sqrt() * x0 + (1 - a).sqrt() * eps # t steps of noise in one line
That single line is what makes training efficient. Each batch samples different values of t, so every timestep is learned in parallel.
The objective — predict the noise, not the image
loss = F.mse_loss(model(xt, t.float(), w), eps)
What the model predicts is not the original image x0 but the noise eps that was mixed in.
Mathematically the two are equivalent: knowing xt and t lets you compute x0 from eps and vice versa. In practice, though, predicting noise works far better.
The reason lies in the nature of the target. Ask for x0 and, when t is large and almost nothing but static remains, the model must invent an image from no information — which is exactly where Part 5's regression-to-the-mean returns. The noise eps, by contrast, is always standard normal at every t. A target with constant scale and distribution makes training stable.
Conditioning — instructing the picture with a word
The timestep and the word are added in the same vector space and injected into the middle of the U-Net.
class CondUNet(nn.Module):
def __init__(self):
super().__init__()
self.temb = nn.Sequential(nn.Linear(64, 128), nn.GELU(), nn.Linear(128, 128))
self.wemb = nn.Embedding(10, 128) # zero~nine
self.e1 = block(1, 48); self.e2 = block(48, 96); self.e3 = block(96, 192)
self.cond3 = nn.Linear(128, 192)
self.d2 = block(192 + 96, 96); self.d1 = block(96 + 48, 48)
self.out = nn.Conv2d(48, 1, 1)
def forward(self, x, t, w):
half = torch.exp(-np.log(10000) * torch.arange(32, device=x.device) / 32)
te = torch.cat([torch.sin(t[:, None] * half), torch.cos(t[:, None] * half)], 1)
c = self.temb(te) + self.wemb(w) # timestep + word
s1 = self.e1(x)
s2 = self.e2(F.max_pool2d(s1, 2))
h = self.e3(F.max_pool2d(s2, 2)) + self.cond3(c)[:, :, None, None]
...
Using sines and cosines for the timestep embedding follows the same reasoning as positional encoding in transformers. Feeding the raw integer t gives the network an awkward scale, while spreading it across sine waves of different frequencies makes neighbouring timesteps share similar vectors.
The injection point — the deepest layer, the output of e3 — is deliberate too. At 8×8 the resolution is smallest and the information density per channel highest, so conditioning here propagates through the entire decoder.
The two Nones in self.cond3(c)[:, :, None, None] reshape a (batch, channel) vector into (batch, channel, 1, 1) so it broadcasts across the spatial dimensions. In other words, the condition is added identically at every position.
Reverse — walking back 400 times
@torch.no_grad()
def sample(words):
x = torch.randn(len(words), 1, 28, 28, device=dev) # start from pure static
w = torch.tensor([WORDS.index(s) for s in words], device=dev)
for ti in reversed(range(T)): # 399 -> 0
t = torch.full((len(words),), ti, device=dev, dtype=torch.float32)
eps = model(x, t, w) # predict the noise
a, ab = alpha[ti], abar[ti]
x = (x - (1 - a) / (1 - ab).sqrt() * eps) / a.sqrt() # remove one step
if ti > 0:
x = x + beta[ti].sqrt() * torch.randn_like(x) # inject fresh noise
return ((x.clamp(-1, 1) + 1) / 2).squeeze(1).cpu().numpy()
That last injection may look perverse. Why put noise back after working to remove it?
Because it is the source of diversity. Fresh randomness at every step means the same word yields a different 3 each time — the exact opposite of Part 5, where regression got stuck on one average. Diffusion is a structure for probabilistically picking one answer among many. Only the final step (ti == 0) skips the injection, so the result comes out clean.
Training log
[ 9.2s] params {"params": 1111777, "T": 400, "n": 60000}
[ 10.1s] step 0 mse=1.1502
[ 12.2s] step 100 mse=0.0807
[ 959.7s] step 44200 mse=0.0324
[ 960.6s] SUMMARY {"steps": 44215, "final_mse": 0.0320}
Within 100 steps — two seconds — MSE fell from 1.15 to 0.081. Starting near 1 is natural given the target is standard normal. It then descended gently to 0.032.
Results
Each row runs "zero" through "nine"; the two rows are different samples for the same words.

Row one is correct for all ten digits. Stroke thickness and slant look genuinely handwritten.
Row two has two errors. The "five" slot produced something closer to a 9, and the "nine" slot something closer to a 7. Eight out of ten — 80%.
Comparing the rows reveals diffusion's character. The same word produced different handwriting. The 2s differ in how sharply the curve bends; the 7s differ in whether they carry a crossbar. Nothing was averaged away — each sample is one concrete piece of handwriting. That is precisely the contrast with Part 5's drained colours.
The confused 5 and 9 are digits that resemble each other in form. With 1.11 million parameters and 16 minutes, this much confusion remains.
Summary
| Item | Value |
|---|---|
| Parameters | 1,111,777 (1.11M) |
| Training time | 960.6s (16 min) |
| Steps | 44,215 |
| Diffusion steps T | 400 |
| Final MSE | 0.0320 |
| Generation accuracy | 8/10 |
Three lines capture it.
Diffusion splits one hard generation problem into 400 easy denoising problems. The forward process is one formula, and training parallelises by jumping to arbitrary t.
The model predicts noise, not images. Because the target is standard normal at every t, training stays stable and the regression-to-the-mean of predicting x0 directly is avoided.
Noise re-injection during reverse creates diversity. Producing a different result each time under the same condition is the decisive difference from a regression model.
This implementation is the basic form, though. Walking all 400 steps is slow, and there is no dial for how strongly to enforce the condition. Part 11 addresses both with classifier-free guidance and DDIM — and shows, with measurements, that the two are not independent.
🧠 Comprehension quiz
1. Why train the model to predict noise instead of the original image?
The two are mathematically equivalent, but training stability differs. Noise is standard normal at every timestep t, so the target keeps a constant scale and distribution. Predicting the original image instead forces the model to invent an image from nothing when t is large and only static remains — exactly where regression to the mean of many valid answers takes over.
2. Why inject fresh noise during the reverse process?
To create diversity. New randomness at each step means the same conditioning word yields different handwriting every time — evidenced by the two rows showing the same digits in different styles. That is the decisive difference from a regression model stuck on a single average. Only the final step skips the injection, so the output comes out clean.
3. Why is it unnecessary to walk the 400 steps in order during training?
Because the closed form xt = sqrt(abar[t]) * x0 + sqrt(1 - abar[t]) * eps computes the noised state at any t in one shot. Each batch can therefore sample different values of t at random, training all timesteps in parallel.
4. Why inject the conditioning vector at the deepest U-Net layer and append [:, :, None, None]?
The deepest layer sits at 8×8, the smallest resolution and highest information density per channel, so conditioning there propagates through the whole decoder. The two Nones reshape a (batch, channel) vector into (batch, channel, 1, 1) so it broadcasts across the spatial dimensions, adding the condition identically at every position.