InternVideo2를 RAG 파이프라인에 통합하며 발견한 것: ITC Embedding Collapse 진단기

InternVideo2의 ITC text embedding이 cosine ≈ 0.9997로 collapse되는 현상을 발견했고, 레이어별 측정으로 원인을 token embedding 단계까지 추적했다. text_proj는 무관하며, mean pooling으로 우회 가능하다.


배경: 우리가 만들던 것

정부 R&D 과제의 일환으로 VideoRAG라는 시스템을 만들고 있었습니다. 이는 자연어 쿼리로 방송 영상 아카이브를 검색하고, 검색된 클립을 조립해 최종 영상을 만드는 PD 워크스테이션입니다.

 

파이프라인 구조는 이렇습니다.

자연어 쿼리
  ↓
BM25 (sparse) + FAISS (dense) → WRRF 융합
  ↓
ColBERT v2 리랭킹
  ↓
ITM 재순위
  ↓
최종 클립 반환

 

dense retrieval에 InternVideo2-1B를 썼습니다. 이는 MSR-VTT 벤치마크에서 R@1 51.9%를 낸 영상-텍스트 멀티모달 모델입니다. 7,010개 MSR-VTT 영상을 인덱싱하고, 쿼리가 들어오면 실시간으로 검색하는 구조였습니다.

 

그런데 평가 과정에서 이상한 점을 발견했습니다.


이상 증상: R@1 3.5%

MSR-VTT 1k-A 벤치마크로 검색 성능을 측정했더니:

논문 (InternVideo2-1B ITC+ITM): R@1 = 51.9%
우리 (ITC만):                   R@1 = 3.5%

 

논문 대비 48.4%p 차이가 났습니다. 이건 구현 오류 수준이 아닙니다.


원인 추적: 26단계 진단

1구간 — BGR 이중 변환

cv2.VideoCapture는 BGR로 읽습니다. 저희 시스템에서는 RGB로 변환했는데, frames2tensor()가 내부적으로 또 한 번 뒤집고 있었습니다.

cv2.read()      → BGR
cvtColor(→RGB)  → RGB   ← 1차 변환
x[:,:,::-1]     → BGR   ← frames2tensor 2차 변환
모델 입력: BGR   (모델은 RGB를 기대함)

 

버그를 수정했습니다. cosine 0.6576 → 0.6292로 소폭 개선했습니다. 주원인 아님, 기각.

2구간 — fp16, 정규화 파라미터

fp16 양자화 노이즈를 의심해서 fp32로 측정해봤습니다. 결과는 동일했습니다. ImageNet normalize 파라미터도 확인했습니다. 표준값 그대로였습니다. 기각.

3구간 — vision_proj를 범인으로 잘못 특정

이에 단계별로 쪼개서 측정했습니다:

vision_encoder 출력 (768-dim): cosine = 0.2824  ← 정상
vision_proj 통과 후 (512-dim): cosine = 0.6273  ← collapse?

 

"범인은 vision_proj다"라고 생각하고 SVD까지 돌렸습니다.

effective rank = 512/512 (full-rank)

 

가중치 랭크가 멀쩡하다는 게 나와서 혼란스러웠습니다. 그때 text_proj도 측정해봤습니다.

텍스트 간 cosine: 0.9997

 

영상(0.6273)보다 텍스트(0.9997)가 훨씬 심각했습니다. vision_proj 단독 원인이 아니라 텍스트 쪽에 systemic 문제가 있다는 신호였습니다.

4구간 — is_pretrain=True, tokenizer 오작동 (기각)

setup_internvideo2가 is_pretrain=True로 모델을 생성하는 것을 발견했습니다. inference 동작을 바꾸는지 소스 추적했고, 영향 없었습니다. tokenizer 오작동 의심 → 정상. 기각.

5구간 — 결정적 발견: ITM이 없었다

InternVideo2 공식 retrieval_utils.py 소스를 직접 분석해서 2단계 파이프라인 구조를 발견했습니다:

1단계 ITC: text_proj(CLS) × vision_proj(pooled) → top-128 후보 선별
2단계 ITM: 텍스트 40토큰 ↔ 영상 1025토큰 cross-attention → 재순위

 

우리는 1단계(ITC)만 구현하고 끝냈던 것이었습니다. itm_head가 모델 클래스에 정의되어 있지 않아 체크포인트 로딩 시 unexpected_keys로 조용히 무시되었습니다.

이에 Linear(1024→2) 가중치를 체크포인트에서 직접 꺼내 수동으로 탑재했습니다:

state_dict = torch.load(ckpt_path, map_location='cpu')['module']
itm_keys = {k: v for k, v in state_dict.items() if 'itm_head' in k}

self.itm_head = nn.Linear(1024, 2)
self.itm_head.weight = nn.Parameter(state_dict['itm_head.weight'].float())
self.itm_head.bias   = nn.Parameter(state_dict['itm_head.bias'].float())

 

ITM 전체 1,000개 적용 → R@1 = 44.4% (3.5%에서 +41.6%p)


남은 갭: 논문 51.9% vs 우리 44.4%

ITM을 추가했는데도 -10.8%p 차이가 있었습니다. 논문 파이프라인을 분석해봤습니다:

논문: ITC → top-128 후보 → ITM → 51.9%
우리: ITC collapse → top-128 못 씀 → ITM 전체 1000개 → 44.4%

 

ITC cosine이 전부 ≈0.9997로 collapse되어 top-128 필터를 쓸 수 없는 상태였습니다. 랜덤 순위에서 top-128 안에 정답이 들어올 확률 = 128/1000 = 12.8%이므로, ITC pre-filter를 강제로 쓰면 오히려 성능이 떨어집니다.

 

실제로 실험해봤습니다:

방법 R@1
ITC → top-128 → ITM 39.5%
full ITM (전체 1000개) 44.4%
논문 (ITC+ITM) 51.9%

 

ITC pre-filter 포기. full ITM 확정.


ITC Collapse 원인 추적: 레이어별 진단

이게 이 글의 핵심입니다. "왜 ITC가 collapse됐는가"를 추적했습니다.

ITC vs ITM: 구조적 차이

먼저 두 방식의 차이를 짚고 가겠습니다.

 

ITC (Image-Text Contrastive)

영상과 텍스트를 각각 하나의 벡터로 압축해서 cosine 유사도로 매칭합니다:

텍스트 → BERT → CLS 추출 → text_proj → [512] 벡터
영상   → ViT  → CLS 추출 → vis_proj  → [512] 벡터
유사도 = 두 벡터의 내적 (L2 정규화 후)

 

계산이 빠르지만 정보 손실이 큽니다. 문장 전체를 CLS 벡터 하나로 뭉개는 것 입니다.

 

ITM (Image-Text Matching)

텍스트 40토큰과 영상 1025토큰이 cross-attention으로 서로 "대화"합니다:

텍스트 40토큰 [40, 1024] → Query
영상 1025토큰 [1025, 1408] → Key, Value
cross-attention → fusion CLS → itm_head(Linear 1024→2) → 매칭 점수

 

정밀하지만 쌍마다 계산해야 해서 느립니다. 그래서 보통 추론에서 ITC로 후보를 줄이고 ITM으로 재순위하는 2-stage를 씁니다.

단계별 cosine 측정

MSR-VTT eval_pairs에서 캡션 200개를 샘플링해서 단계별로 측정하였습니다:

tok = iv_model.tokenizer(
    sample_captions,
    padding="max_length",
    truncation=True,
    max_length=iv_model.config.max_txt_l,  # 40
    return_tensors="pt"
).to(device)

with torch.no_grad():
    feat, _ = iv_model.encode_text(tok)  # [200, 40, 1024]

n = 200

# 1. BERT mean pooling
mask = tok.attention_mask.unsqueeze(-1).float()
mean_pooled = (feat * mask).sum(1) / mask.sum(1)  # [200, 1024]
mean_pooled = F.normalize(mean_pooled, dim=-1)
cos_mean = (mean_pooled @ mean_pooled.T)
cos_mean.fill_diagonal_(0)
print(f"BERT mean pooling: {cos_mean.sum()/(n*(n-1)):.4f}")

# 2. CLS 추출
cls = feat[:, 0].float()  # [200, 1024]
cls_norm = F.normalize(cls, dim=-1)
cos_cls = (cls_norm @ cls_norm.T)
cos_cls.fill_diagonal_(0)
print(f"CLS 추출 후: {cos_cls.sum()/(n*(n-1)):.4f}")

# 3. text_proj 통과
projected = iv_model.text_proj(cls)  # [200, 512]
proj_norm = F.normalize(projected, dim=-1)
cos_proj = (proj_norm @ proj_norm.T)
cos_proj.fill_diagonal_(0)
print(f"text_proj 통과 후: {cos_proj.sum()/(n*(n-1)):.4f}")

 

결과:

단계 cosine 평균 변화량
BERT mean pooling 0.6738
CLS 추출 후 0.9326 +0.2588 ← 급등
text_proj 통과 후 0.9360 +0.0034 (미미)

 

text_proj는 거의 무관합니다. collapse는 CLS 추출 단계에서 발생합니다.

레이어별 CLS cosine 측정

CLS가 어느 레이어부터 collapse되는지 추적했습니다.

 

BertConfig를 확인하니:

num_hidden_layers: 24
fusion_layer: 19

 

fusion_layer=19에 의해 mode="text"에서는 layer 0~18만 실행됩니다 (총 19레이어). mode="fusion"(ITM)일 때 layer 19~23에서 영상과 cross-attention합니다.

bert_model = iv_model.get_text_encoder()

with torch.no_grad():
    output = bert_model(
        input_ids=tok.input_ids,
        attention_mask=tok.attention_mask,
        output_hidden_states=True,
        return_dict=True,
        mode="text"
    )

hidden_states = output.hidden_states  # 튜플 20개 (embedding + 19레이어)

for i, hs in enumerate(hidden_states):
    cls = hs[:, 0].float()
    cls_norm = F.normalize(cls, dim=-1)
    cos = (cls_norm @ cls_norm.T)
    cos.fill_diagonal_(0)
    print(f"layer {i:2d}: {cos.sum()/(n*(n-1)):.4f}")

 

결과:

layer  0: 1.0000  ← token embedding 단계, 이미 완전 동일
layer  1: 0.9957
layer  2: 0.9898
layer  3: 0.9902
layer  4: 0.9824
layer  5: 0.9681
layer  6: 0.9254  ← 최저점 (그나마 가장 구별 가능)
layer  7: 0.9545
layer  8: 0.9799
layer  9: 0.9899
layer 10: 0.9952
layer 11: 0.9969
layer 12: 0.9972
layer 13: 0.9978
layer 14: 0.9988
layer 15: 0.9987
layer 16: 0.9986
layer 17: 0.9964
layer 18: 0.9719
layer 19: 0.9330  ← 최종 출력

 

layer 0에서 이미 1.0000입니다.

왜 layer 0이 1.0000인가

CLS 토큰(id=101)은 모든 문장에서 항상 같은 위치(position 0)에 있습니다. token embedding table에서 101번 벡터는 항상 동일하게 꺼내집니다. 즉 모든 문장의 CLS가 token embedding 단계에서 완전히 동일한 벡터로 시작합니다.

"a woman cooking" → CLS token id=101 → embedding[101] → [동일한 1024-dim 벡터]
"a man playing guitar" → CLS token id=101 → embedding[101] → [동일한 1024-dim 벡터]
"a dog running in a park" → CLS token id=101 → embedding[101] → [동일한 1024-dim 벡터]

 

BERT 레이어를 거치면서 주변 토큰들을 self-attention으로 보고 조금씩 달라져야 합니다. layer 6에서 0.9254까지 내려가며 분화를 시도하는데, 이후 다시 0.99대로 수렴하며 분화에 실패합니다.

 

이 체크포인트에서 ITC 파인튜닝이 CLS를 문장 내용에 맞게 충분히 분화시키지 못한 것으로 추정하였습니다.

왜 mean pooling이 해결책인가

mean pooling은 CLS를 쓰지 않습니다. 실제 내용 토큰들의 평균을 쓴다:

# 기존 (CLS): collapse
emb = encode_text()[1]  # pooled CLS → [1, 512]

# 변경 (mean pooling): 정상
feat, _ = iv_model.encode_text(tok)      # [1, 40, 1024]
mask = tok.attention_mask.unsqueeze(-1)  # [1, 40, 1]
pooled = (feat * mask).sum(1) / mask.sum(1)  # [1, 1024]
projected = iv_model.text_proj(pooled)   # [1, 512]

 

"a", "woman", "cooking" 같은 내용 토큰들은 문장마다 다른 token id를 가지므로 embedding부터 달라집니다. layer 0에서 이미 구별력이 있고, BERT 통과 후에도 0.6738 수준을 유지합니다.


영상 벡터는 정상인가: PCA 시각화

ITC 영상 벡터(1,000개)를 PCA로 시각화했습니다:

video_ids = list(video_vecs.keys())
vecs = np.stack(list(video_vecs.values()))  # [1000, 512]

pca = PCA(n_components=2)
reduced = pca.fit_transform(vecs)

plt.scatter(reduced[:, 0], reduced[:, 1], alpha=0.3, s=5)
plt.title("ITC video vectors — PCA")

 

결과: 위와 같이 벡터들이 2D에서 고르게 퍼져있었습니다. 한 점으로 뭉치는 패턴이 없었습니다.

영상 ITC 벡터 → 정상 분포 ✓
텍스트 ITC 벡터 → collapse (cosine ≈ 0.9327) ✗

 

즉, collapse는 텍스트 쪽에만 국한되었습니다. 영상은 4프레임에서 다양한 패치 정보를 받아 vision encoder를 통과하므로, 영상마다 다른 입력 → 다른 벡터가 자연스럽게 나옵니다.


다른 사람들도 같은 문제를 겪고 있었다

HuggingFace InternVideo2 discussion을 찾아보니:

"I'm also getting very low metrics for MSR-VTT... even after using these models, there still appears to be about a 10-point gap compared to the results reported in their paper."

 

같은 gap을 경험하는 사람들이 있었지만, 레이어별로 collapse 위치를 특정한 사람은 없었습니다. 또한 DSL(Dual Softmax Loss)이 test-time reranking에 적용됐을 가능성, 논문이 8프레임을 썼는데 config는 4프레임인 점도 gap의 원인으로 지목되었습니다.


결론 및 수치 요약

측정 Cosine 평균
BERT mean pooling 0.6738
BERT CLS (layer 19) 0.9326
text_proj 통과 후 0.9360
CLS token embedding (layer 0) 1.0000
CLS 최저점 (layer 6) 0.9254

 

확정된 것:

  • collapse는 text_proj가 아닌 CLS token embedding 단계에서 시작합니다
  • BERT가 layer 6까지 분화를 시도하나 이후 다시 수렴합니다
  • mean pooling으로 우회 시 cosine 0.9997 → 0.6738
  • 영상 ITC 벡터는 정상 분포

미확정인 것:

  • 왜 이 체크포인트에서 BERT가 CLS를 충분히 분화시키지 못하는가
  • 논문 평가에 쓰인 체크포인트와 HuggingFace 공개 버전이 동일한가

최종 파이프라인 성능

방법 R@1 R@5 R@10
ITC dense 단독 3.5%
full ITM 44.4% 66.3% 75.8%
논문 (ITC+ITM) 51.9% 74.6% 81.7%

 

mean pooling으로 production dense 채널을 복구하고, full ITM으로 R@1 44.4%를 달성했습니다.


코드 및 데모


이 글은 VideoRAG 개발 과정에서 작성된 이슈 보고서(1~10차)를 기반으로 정리한 것입니다.

DDIM Inversion + TokenFlow 완전 분해

영상 속성 변환 파이프라인 — 소스 코드 기반 구조 분석

목차

  1. 왜 OpenCV로는 안 되는가
  2. 문제 제기 — 프레임마다 그냥 돌리면?
  3. TokenFlow의 핵심 관찰
  4. PHASE 1 — preprocess.py : DDIM Inversion
  5. PHASE 2 — run_tokenflow_pnp.py : TokenFlow Denoising
  6. PHASE 3 — VAE Decode & 재조립
  7. 전체 인과 구조 요약

왜 OpenCV로는 안 되는가

OpenCV는 픽셀 수준 연산이다. 픽셀의 RGB 값을 직접 건드리거나, 엣지를 검출하거나, 행렬 변환으로 왜곡하는 도구다. "이 영상을 oil painting 스타일로 바꿔줘"를 OpenCV로 하려면 결국 color histogram 조정이나 edge filter 합성 수준에서 멈춘다. 왜냐면 OpenCV는 이미지 안에 뭐가 있는지 이해하지 못하기 때문이다.

"사람을 사이보그로 만들어라"는 지시는 이미지 안에서 사람이 어디 있는지, 사이보그가 어떻게 생겼는지, 어떤 부분을 어떻게 바꿔야 하는지를 모두 이해해야 수행할 수 있다. 이건 픽셀 연산이 아니라 의미 이해 문제다. 그래서 언어와 이미지를 함께 이해하는 generative model이 필요하다.


문제 제기 — Plug-and-Play Diffusion을 프레임마다 그냥 돌리면?

Plug-and-Play(PnP) Diffusion은 이미지 한 장을 텍스트 지시에 따라 편집하는 기법이다. TokenFlow는 이 PnP를 베이스 편집 기법으로 활용하며, 파일명도 run_tokenflow_pnp.py다. 영상은 이미지의 연속이니까, 프레임 하나씩 꺼내서 편집하고 다시 붙이면 되지 않을까?

문제: 안 된다. Stable Diffusion 기반 모델은 denoising 과정에서 매번 새로운 노이즈를 샘플링한다. 프레임 1을 편집할 때 선택된 노이즈와 프레임 2를 편집할 때 선택된 노이즈가 다르다. 노이즈가 다르면 같은 텍스트 프롬프트를 줘도 결과의 세부 표현이 달라진다. 프레임 1의 하늘은 약간 주황빛 oil painting이 되고, 프레임 2의 하늘은 약간 보라빛 oil painting이 된다. 연속으로 재생하면 배경이 깜빡이고 텍스처가 흔들린다.

 

이걸 temporal inconsistency라고 한다.

근본 원인은 Stable Diffusion의 self-attention 구조에 있다. UNet 안의 self-attention은 지금 처리 중인 이 이미지 내부의 토큰들끼리만 서로를 참조한다. 옆 프레임이 어떻게 생겼는지, 이전 프레임에서 하늘이 어떤 색으로 결정됐는지를 아예 모른다. 매 프레임이 독립적으로 편집 결정을 내리기 때문에 일관성이 깨진다.

해결하려면 프레임들이 편집 결정을 서로 공유하게 만들어야 한다.


TokenFlow의 핵심 관찰

TokenFlow 저자들이 발견한 것: 같은 영상의 인접 프레임들을 UNet에 통과시켜 보면, diffusion feature 공간에서 대응하는 물체의 토큰끼리 코사인 유사도가 자연스럽게 높다. 프레임 A의 하늘 토큰과 프레임 B의 하늘 토큰은 비슷하고, 사람 토큰끼리도 비슷하다.

이걸 역이용한다. 몇 개의 "피벗 프레임"만 먼저 편집해서 attention output을 만들어둔다. 나머지 프레임들은 자기가 직접 attention을 계산하는 게 아니라, 피벗의 attention output에서 자기 토큰과 유사한 위치를 찾아서 그 값을 가져다 쓴다. 이러면 모든 프레임이 같은 "편집 결정"을 공유하게 되어 temporal inconsistency가 사라진다.

핵심: 학습이나 파인튜닝은 전혀 없다. 기존 SD 가중치를 그대로 쓰고, attention 계산 방식만 바꾼다.

PHASE 1 — preprocess.py : DDIM Inversion

왜 inversion이 필요한가

Stable Diffusion은 순방향으로 학습돼 있다. "완전한 노이즈 → 이미지"로 가는 방법을 안다. 편집하려면 반대 방향, 즉 "원본 이미지 → 그 이미지에 해당하는 노이즈 상태"로 먼저 거슬러 올라가야 한다. 그 노이즈 상태에서 출발해서 새로운 텍스트 조건을 들고 다시 "노이즈 → 이미지" 방향으로 내려오면, 원본의 구조는 유지하면서 텍스트에 따라 변형된 이미지를 얻을 수 있다.

이 역방향 과정을 DDIM Inversion이라고 한다.

코드에서 무슨 일이 일어나는가

① 프레임 추출

원본 영상에서 n_frames장을 균등 간격으로 추출한다. 전체 200프레임짜리 영상이면 n_frames=8로 설정했을 때 25프레임 간격으로 8장을 뽑는다. 각 프레임은 지정 해상도(보통 512×512)로 resize된다.

② VAE encode

각 프레임을 latent 공간으로 압축한다. 실제 코드에서 vae.encode()는 latent를 바로 반환하지 않고, .latent_dist 확률분포 객체를 먼저 반환한다. 여기서 .mean(결정론적) 또는 .sample()(확률적)로 latent를 뽑은 뒤, 반드시 0.18215를 곱해 SD의 latent 스케일에 맞춘다. 그 결과 512×512 RGB 이미지가 64×64×4 짜리 latent tensor z₀가 된다. 픽셀 공간에서 직접 노이즈를 다루면 연산량이 너무 크기 때문에 이 압축된 latent 공간에서 모든 diffusion 연산이 일어난다.

# 실제 코드
posterior = self.vae.encode(imgs[i:i + batch_size]).latent_dist
latent = posterior.mean if deterministic else posterior.sample()
latents.append(latent * 0.18215)  # SD latent 스케일 맞추기

③ DDIM Inversion — 노이즈 경로 추적

DDIMInverterz₀에서 출발해서 타임스텝을 역순으로 밟는다. t=0(깨끗한 상태)에서 t=T(노이즈 가득한 상태) 방향으로, 매 스텝마다 노이즈를 조금씩 더하면서 올라간다.

이 과정이 단순한 랜덤 노이즈 추가와 다른 이유가 있다. 랜덤 노이즈 추가는 이렇다:

z_noisy = z₀ + torch.randn_like(z₀) * sigma

가우시안 노이즈를 아무 방향으로나 더한다. 이렇게 만든 z_noisy에서 denoising을 시작하면 원본과 완전히 다른 이미지로 갈 수 있다.

DDIM Inversion은 다르다. 매 스텝에서 UNet을 실제로 돌려서, 현재 latent에서 예측한 노이즈 방향으로만 이동한다. forward process의 업데이트 식은 이렇다:

z_{t+1} = √ᾱ_{t+1} · predicted_x₀ + √(1−ᾱ_{t+1}) · ε_θ(z_t, t)
의미
ᾱ_t 타임스텝 t에서의 누적 노이즈 스케줄 계수
ε_θ(z_t, t) UNet이 현재 z_t를 보고 예측한 노이즈
predicted_x₀ 그 예측 노이즈로부터 역산한 깨끗한 이미지 추정값

 

 

여기서 predicted_x₀가 왜 필요한지 짚고 넘어가야 한다.

 

predicted_x₀의 역할

ε_θ만 있으면 "지금 어디로 가야 하는가"만 안다. 즉 현재 스텝의 국소적인 방향만 알려줄 뿐, 최종적으로 어디에 도착해야 하는지를 모른다. 스텝이 쌓일수록 경로가 표류할 수 있다.

predicted_x₀는 "최종적으로 어디에 도착할 것인가"를 매 스텝마다 추정한다. 계산 방식은 이렇다:

predicted_x₀ = (z_t − √(1−ᾱ_t) · ε_θ) / √ᾱ_t

 

"지금 이 noisy latent에서 노이즈를 전부 빼면 어떤 이미지가 나올 것 같은가"의 추정값이다. 백지가 아니라 원본 이미지에 가까운 무언가다. (t=0이 원본 이미지 그 자체고, t=T로 갈수록 노이즈가 쌓이는 구조이기 때문이다.)

GPS 비유로 이해하면: ε_θ만 쓰면 나침반은 있는데 목적지를 모르는 상태다. predicted_x₀를 같이 쓰면 매 스텝마다 목적지를 재확인하면서 경로를 보정한다.

수식의 두 항이 각자 다른 역할을 한다.

  • √ᾱ_{t+1} · predicted_x₀ 항 — 목적지 방향으로 당기는 힘. "최종적으로 이 이미지로 도착해야 한다"는 정보를 매 스텝에 반영한다.
  • √(1−ᾱ_{t+1}) · ε_θ 항 — 현재 스텝에서 추가해야 할 노이즈 성분. 타임스텝 t+1에 맞는 노이즈 레벨로 맞춰주는 역할이다.

즉 매 스텝의 이동 방향이 원본 이미지 z₀의 내용에 따라 결정된다. 원본 이미지가 만들어낸 특정 노이즈 경로 위를 걷는 것이다.

④ 각 타임스텝 latent 저장

DDIM의 핵심 성질: denoising 과정이 deterministic하다. 같은 노이즈 z_T에서 출발하면 항상 같은 z₀에 도착한다. 랜덤성이 없다.

z₀ → z₁ → z₂ → ... → z_T   (inversion: 원본에서 노이즈로)
z_T → ... → z₂ → z₁ → z₀   (denoising: 노이즈에서 원본으로)

 

이 두 경로가 같은 궤적이다. 이상적으로는 z_T만 저장하면 되지만, 실제론 수치 오차가 쌓인다. 그래서 각 타임스텝의 noisy latent를 noisy_latents_{t}.pt로 저장한다. 50 스텝 기준이면 50개의 파일이 생긴다.

여기서 t는 latent 자체가 아니라 타임스텝 인덱스 숫자다. 예를 들어 t = 981, t = 921 같은 정수값이다. 이 숫자들이 랜덤해 보이지만 랜덤이 아니다. DDIM 스케줄러가 1000에서 0까지 균등하게 나눈 고정된 간격이다. 50스텝이면 약 20씩 줄어드는 구조다. 숫자가 클수록 노이즈가 많은 상태, 0에 가까울수록 깨끗한 이미지다.

PHASE 2에서 denoising loop가 각 타임스텝의 출발점으로 이 파일을 불러쓰기 때문에, 오차가 누적될 틈 없이 매 스텝의 정확한 위치를 강제할 수 있다.

# denoising loop 안에서
latents = load_source_latents_t(t, latents_path)  # 저장해둔 값 불러오기
# 이 latents에서 한 스텝 denoising
latents = scheduler.step(noise_pred, t, latents).prev_sample

⑤ inverted.mp4 저장 (품질 확인용)

inversion 후 즉시 다시 decode해서 inverted.mp4를 저장한다. 원본과 inversion 결과가 크게 다르면 이후 편집 품질도 나빠지기 때문에, 여기서 먼저 확인하고 스텝 수나 파라미터를 조정한다.


PHASE 2 — run_tokenflow_pnp.py : TokenFlow Denoising

시작 전 준비 — UNet 교체

set_tokenflow(model)이 호출되면 UNet 안의 모든 BasicTransformerBlock을 순회하면서 클래스를 TokenFlowBlock으로 교체한다.

for _, module in model.named_modules():
    if isinstance_str(module, "BasicTransformerBlock"):
        module.__class__ = make_tokenflow_block_fn(module.__class__)

 

make_tokenflow_attention_block이 기존 블록 클래스를 상속해서 forward() 메서드만 오버라이드한다. 가중치는 전혀 건드리지 않는다. 즉 편집 능력은 원래 SD 가중치에서 오고, temporal consistency는 새로운 forward() 로직에서 온다.

추가로 두 가지가 더 등록된다.

  • register_extended_attention_pnp(model, injection_schedule) — UNet decoder의 특정 self-attention 레이어들(up_blocks 4~11번)의 forward를 교체해서, K와 V를 모든 프레임에 걸쳐 concat한 버전으로 계산하게 만든다.
  • register_conv_injection(model, injection_schedule)up_blocks[1].resnets[1]의 conv forward를 교체한다.

Denoising Loop — t=T에서 t=0까지

PHASE 1에서 저장해둔 noisy_latents_T.pt를 불러와서 시작한다. 매 타임스텝마다 아래 과정이 반복된다. 타임스텝이 50개면 아래의 pivotal pass → non-pivotal pass 세트가 50번 반복된다.

register_time(model, t) 호출

현재 타임스텝 t(숫자 인덱스)를 UNet 전체에 broadcast한다. attention 모듈과 conv 모듈 각각에 setattr(module, 't', t)로 t값을 심어둔다. 모듈이 forward를 실행하는 시점에는 바깥 루프의 t값을 직접 참조할 수 없기 때문이다. forward 안에서 self.t로 꺼내 쓴다.

injection_schedule은 "어떤 타임스텝에서 feature injection을 할지"를 리스트로 지정한 것이다. 초반 타임스텝(노이즈 많을 때)에만 injection하는 식으로 제어한다.


첫 번째 단계 — Pivotal pass

register_pivotal(model, True)가 모든 TokenFlowBlockpivotal_pass = True를 세팅한다. 그 다음 피벗 프레임들만 UNet을 통과시킨다.

피벗 프레임은 각 배치 내에서 랜덤하게 선택된다. 실제 코드는 다음과 같다.

pivotal_idx = torch.randint(batch_size, (len(x)//batch_size,)) + torch.arange(0, len(x), batch_size)

 

torch.randint(batch_size, (len(x)//batch_size,))가 각 배치 안에서 0과 batch-size 사이의 정수를 하나씩 뽑고, torch.arange(0, len(x), batch_size)로 각 배치의 시작 인덱스를 더해준다. 즉 피벗은 배치 중간 인덱스가 아니라 매 실행마다 배치 내 임의 위치에서 결정된다. n_frames=8, batch_size=4라면 배치 0에서는 0~

3 중 랜덤 1개, 배치 1에서는 4~7 중 랜덤 1개가 피벗이 된다.

hidden_states란 무엇인가

입력은 noisy latent z_t다. 이게 UNet의 첫 레이어에 들어가면서 hidden_states라는 이름으로 불리기 시작한다. UNet은 수십 개의 레이어(Conv, ResNet, Transformer)로 이루어져 있고, hidden_states는 이 레이어들을 하나씩 통과하면서 계속 변형된다.

TokenFlowBlock에 도달했을 때 hidden_states는 이미 앞쪽 레이어들을 다 통과한 상태다. 단순한 픽셀값이나 latent가 아니라, 앞쪽 레이어들이 추출한 특징들이 인코딩된 고차원 표현이다. shape은 [batch_size, sequence_length, dim]으로 바뀐다. 64×64 latent가 Transformer 블록에 들어오면서 64×64=4096개의 토큰으로 펼쳐진다. 각 토큰이 latent의 특정 위치에 해당하는 표현이다.

TokenFlowBlock.forward() — pivotal_pass == True일 때

norm_hidden_states = self.norm1(hidden_states)
# norm_hidden_states shape: [3, n_frames, seq_len, dim]

if self.pivotal_pass:
    self.pivot_hidden_states = norm_hidden_states

 

norm_hidden_states는 layer normalization을 통과한 hidden state다. normalization을 하는 이유는 값 분포를 평균 0, 분산 1로 정규화해서 attention 계산을 안정적으로 하기 위해서다. shape의 첫 번째 차원 3은 CFG를 위한 3× batch(source/unconditional/conditional)다. 이걸 self.pivot_hidden_states에 저장한다. 나중에 비피벗 프레임이 유사도를 계산할 때 비교 대상으로 쓰인다.

그 다음 self.attn1()을 호출한다.

self.attn_output = self.attn1(
    norm_hidden_states.view(batch_size, sequence_length, dim),
    encoder_hidden_states=None,
)
self.kf_attn_output = self.attn_output

 

attn1self-attention 레이어다. encoder_hidden_states=None인 게 중요한데, 외부 텍스트 조건 없이 이미지 토큰들끼리만 서로를 참조한다는 뜻이다.

attn1 내부에서 일어나는 일:

  • norm_hidden_states에서 선형변환으로 Q, K, V 세 행렬을 뽑는다. Q는 "나는 지금 어떤 정보가 필요한가", K는 "나는 어떤 정보를 제공할 수 있는가", V는 "내가 실제로 가진 값"이다.
  • softmax(QKᵀ / √d) × V로 각 토큰이 다른 토큰들을 얼마나 참조할지 결정하고, 그 가중합을 계산한다. 이 결과가 attn_output이다.

attn_output이 담고 있는 의미: 각 토큰이 주변 문맥을 참조해서 자신을 어떻게 업데이트했는지의 결과값이다. "하늘 토큰이 구름 토큰과 태양 토큰을 참조해서 oil painting 하늘 표현으로 업데이트된 상태"가 여기 담긴다.

텍스트 프롬프트는 언제 반영되는가

attn1의 output에는 텍스트가 없다. attn1은 이미지 토큰끼리만 참조하기 때문이다. 텍스트 프롬프트는 그 다음에 실행되는 attn2(cross-attention)에서 반영된다.

# attn1 끝난 후
hidden_states = attn_output + hidden_states  # residual

# attn2: 여기서 텍스트가 들어온다
norm_hidden_states = self.norm2(hidden_states)
attn_output = self.attn2(
    norm_hidden_states,
    encoder_hidden_states=encoder_hidden_states,  # 텍스트 임베딩
)
hidden_states = attn_output + hidden_states

 

TokenFlow가 attn1을 공유하는 이유가 바로 이거다. 텍스트 스타일이 아니라 이미지의 공간 구조 정보를 프레임 간에 공유하는 것이다. "하늘은 저기 있고, 사람은 여기 있다"는 구조적 판단을 피벗 프레임에서 한 번 내리고, 나머지 프레임들이 그걸 따른다. 텍스트 반영은 그 다음 attn2에서 각 프레임이 개별적으로 수행한다.

attn_outputself.kf_attn_output에 저장한다. 이게 "레퍼런스 편집 결정"이다. 피벗 프레임이 이 타임스텝에서 처음으로 내린 편집 판단이 여기 인코딩돼 있기 때문이다. 비피벗 프레임들은 이 판단을 복사해다 쓰게 된다.


두 번째 단계 — Non-pivotal pass

register_pivotal(model, False)로 전환하고 나머지 프레임들을 처리한다. register_batch_idx(model, batch_idx)로 현재 처리 중인 배치 인덱스도 세팅한다.

왜 이 단계 전체가 필요한가

피벗 pass에서 피벗 프레임이 attn1을 돌려서 kf_attn_output을 만들어뒀다. 이걸 비피벗 프레임들한테도 적용해야 한다. 근데 그냥 똑같이 복사하면 안 된다. 왜냐면 피벗 프레임과 비피벗 프레임은 카메라가 움직이거나 물체가 이동해서 같은 위치에 다른 내용이 있을 수 있기 때문이다.

프레임 4(피벗)에서 하늘이 토큰 52번 위치에 있었는데, 프레임 6(비피벗)에서는 카메라가 살짝 움직여서 하늘이 토큰 48번 위치에 있을 수 있다. 이 경우 피벗의 52번 편집 결정을 비피벗의 48번 위치에 가져다 써야 한다.

그래서 "내 토큰이 피벗의 어떤 토큰과 같은 내용인가"를 먼저 찾고, 그 위치의 편집 결정을 가져오는 것이다. 이 전체 과정(Step 1~6)은 매 타임스텝마다 한 번씩, 피벗 pass 직후에 end-to-end로 실행된다. 점진적으로 누적되는 게 아니라, 각 타임스텝 안에서 독립적으로 완결된다.


Step 1 — 유사도 계산

sim = batch_cosine_sim(
    norm_hidden_states[0].reshape(-1, dim),
    self.pivot_hidden_states[0][batch_idxs].reshape(-1, dim)
)

 

비피벗 프레임의 토큰 전체와 피벗 프레임의 토큰 전체 사이의 코사인 유사도를 한 번에 계산한다. 각 토큰이 dim차원 벡터인데, 같은 내용(하늘, 사람, 나무 등)이면 벡터 방향이 비슷하다는 성질을 이용한다.

  • 비피벗 8프레임 전체 토큰: [8×4096, dim] = [32768, dim]
  • 피벗 프레임 전체 토큰: [4096, dim]
  • 결과 sim의 크기: [32768, 4096×2]

결과 행렬이 4096×2인 이유는 피벗이 2개이기 때문이다. 배치마다 피벗이 1개씩 선택되고, non-pivotal pass는 배치 2개를 동시에 처리하기 때문에 피벗도 2개가 된다. 두 피벗에 대한 유사도가 옆으로 나란히 붙어 있는 구조다.

sim[48, 52] = 0.94의 의미: "비피벗의 48번 토큰은 피벗의 52번 토큰과 94% 유사하다"


Step 2 — argmax로 인덱스 추출

sim1, sim2 = sim.chunk(2, dim=1)
idx1 = sim1.argmax(dim=-1)  # shape: [n_frames * seq_len]
idx2 = sim2.argmax(dim=-1)

 

sim.chunk(2, dim=1)으로 행렬을 반으로 잘라 피벗1에 대한 유사도(sim1)와 피벗2에 대한 유사도(sim2)를 분리한다. 각각 [32768, 4096] 크기다.

argmax는 비피벗 토큰 각각에 대해, 피벗 4096개 토큰 중 가장 유사한 피벗 토큰 1개의 인덱스를 뽑는 연산이다. 비피벗 토큰 하나가 피벗에서 자기랑 제일 비슷한 짝을 찾는 것이다.

비피벗 토큰 0번 → 피벗1의 52번이 제일 유사 → idx1[0] = 52
비피벗 토큰 1번 → 피벗1의 103번이 제일 유사 → idx1[1] = 103
비피벗 토큰 2번 → 피벗1의 52번이 제일 유사 → idx1[2] = 52
...

 

idx1[48] = 52의 의미: "비피벗의 48번 토큰은 피벗1의 52번 토큰과 가장 유사하다." 이 인덱스를 Step 5의 gather에서 "피벗의 어느 위치 output을 가져올지" 결정하는 데 쓴다.


Step 3 — 두 피벗으로부터의 거리 계산

s  = torch.arange(0, n_frames) + batch_idxs[0] * n_frames
p1 = batch_idxs[0] * n_frames + n_frames // 2
p2 = batch_idxs[1] * n_frames + n_frames // 2
d1 = torch.abs(s - p1)
d2 = torch.abs(s - p2)

 

Step 2에서 비피벗 토큰이 피벗1, 피벗2 각각에서 짝을 찾았다. Step 5에서 두 피벗의 attn output을 모두 가져올 건데, 이걸 그냥 반반 섞으면 안 된다. 비피벗 프레임이 어느 피벗에 시간적으로 더 가까운지에 따라 섞는 비율이 달라야 한다.

프레임, 피벗, 비피벗의 관계를 먼저 짚고 넘어가야 한다. 전체 8프레임을 batch_size=4로 나누면 배치가 2개 생긴다. 각 배치에서 torch.randint로 1개가 피벗으로 랜덤하게 뽑히고, 나머지가 비피벗이다. 즉 모든 프레임은 피벗 아니면 비피벗이다.

프레임 번호:  0  1  2  3  4  5  6  7
배치:        [    배치0      ][    배치1      ]
피벗:                 ↑p2          ↑p1       ← 각 배치에서 랜덤으로 1개 선택
비피벗:       ↑  ↑  (p2제외)  ↑  ↑  (p1제외) ← 나머지 전부

 

피벗이 먼저 UNet을 통과해서 kf_attn_output을 저장해두고, 비피벗은 그걸 가져다 쓰는 구조다. 위 예시에서 "배치0 내 임의 위치, 예: 2번"이라는 표현은 torch.randint로 뽑혔더니 마침 2번이 됐다는 뜻이다. 매 실행마다 달라질 수 있다.

거리 계산의 논리는 이렇다. n_frames=4, 전체 8프레임이라고 하면:

배치0: 프레임 0,1,2,3  → 피벗 p2 (랜덤 선택, 예: 2번)
배치1: 프레임 4,5,6,7  → 피벗 p1 (랜덤 선택, 예: 6번)

 

지금 배치1을 처리 중이라면:

s  = [0,1,2,3] + 4 = [4,5,6,7]   # 현재 처리 중인 프레임 번호들
p1 = 6                             # 배치1의 피벗 위치 (절대 프레임 번호)
p2 = 2                             # 배치0의 피벗 위치 (절대 프레임 번호)

d1 = |[4,5,6,7] - 6| = [2,1,0,1]  # 각 프레임이 p1에서 얼마나 먼지
d2 = |[4,5,6,7] - 2| = [2,3,4,5]  # 각 프레임이 p2에서 얼마나 먼지

 

여기서 torch.arange(0, n_frames)[0,1,2,3]을 만드는데, 이건 배치 내 상대적 인덱스다. p1, p2는 전체 영상 기준 절대 프레임 번호로 표현돼 있기 때문에, 거리를 계산하려면 s도 절대 번호로 맞춰야 한다. 그래서 배치 시작점(batch_idxs[0] * n_frames, 배치1이면 1×4=4)을 더하는 것이다.

배치0이면: [0,1,2,3] + 0 = [0,1,2,3]  (절대 프레임 번호)
배치1이면: [0,1,2,3] + 4 = [4,5,6,7]  (절대 프레임 번호)

 

이 거리값을 다음 단계에서 가중치로 변환한다.


Step 4 — sigmoid로 부드러운 가중치 생성

w1 = d2 / (d1 + d2)
w1 = torch.sigmoid(w1)

 

d2 / (d1 + d2)의 논리: 분자가 d2인 게 처음엔 반직관적으로 보인다. 이렇게 생각하면 된다. 피벗2에서 멀수록(d2가 클수록) 피벗1의 영향을 더 받아야 한다. 피벗2가 멀다는 건 상대적으로 피벗1이 가깝다는 뜻이니까 w1이 커야 한다. 그래서 분자에 d2가 온다.

sigmoid를 한 번 더 씌우는 이유: 선형 보간이 아니라 S자 곡선으로 전환을 만들기 위해서다. 두 피벗 정중앙에서 0.5가 되고, 피벗 가까이 갈수록 0 또는 1로 급격히 수렴한다. 이러면 한 피벗의 지배 구간이 명확해지고, 경계 부근에서만 부드럽게 섞인다.

w1의 shape은 [n_frames]인데, 이후에 [3, n_frames, seq_len, dim]으로 unsqueeze/repeat해서 실제 attention output과 shape을 맞춘다.


Step 5 — gather로 피벗 attention output 가져오기

attn_output1 = attn_1.gather(
    dim=1, index=idx1.unsqueeze(-1).repeat(1, 1, dim)
)
attn_output2 = attn_2.gather(
    dim=1, index=idx2.unsqueeze(-1).repeat(1, 1, dim)
)

 

attn_1은 피벗1이 저장해둔 kf_attn_output이다. shape [4096, dim]. gather(dim=1, index=idx1)은 seq_len 차원에서 idx1이 가리키는 위치의 값을 뽑는다.

구체적으로: idx1[48] = 52이면 kf_attn_output[52]를 비피벗의 48번 위치에 가져다 놓는다. "피벗1이 하늘 토큰에 대해 내린 편집 결정"을 현재 프레임의 하늘 토큰 위치에 적용하는 것이다. 피벗과 비피벗 사이에 카메라가 움직여서 위치가 달라졌어도, 유사도로 찾은 인덱스 덕분에 올바른 위치에 편집 결정이 들어간다.


Step 6 — 두 피벗 결과를 w1로 보간

attn_output = w1 * attn_output1 + (1 - w1) * attn_output2

 

Step 5에서 피벗1, 피벗2 각각으로부터 attn output을 가져왔다. 이걸 Step 4에서 구한 w1로 섞는다.

  • 피벗1에 가까운 프레임 → w1이 크다 → attn_output1을 더 많이 반영
  • 피벗2에 가까운 프레임 → w1이 작다 → attn_output2를 더 많이 반영
  • 두 피벗 정중앙 프레임 → w1 ≈ 0.5 → 반반 섞음

이 보간 덕분에 피벗과 피벗 사이 구간도 편집이 자연스럽게 연속된다. 이게 비피벗 프레임의 최종 attn output이 된다. 이걸로 hidden_states를 업데이트하고, 그 다음 attn2에서 텍스트 조건이 각 프레임 개별적으로 반영된다.


Conv Injection — 구조 보존

attention이 "의미 단위"로 일관성을 잡는다면, conv는 "모양 단위"로 잡는다. 이 둘은 UNet 안에서 서로 다른 레이어에서 독립적으로 작동한다.

UNet 안에서 같은 타임스텝에 일어나는 일:
conv 레이어      → Conv Injection 발생 (형태 고정)
    ↓
TransformerBlock
    ├── attn1    → TokenFlow Step1~6 발생 (의미 일관성)
    └── attn2    → 텍스트 프롬프트 반영

 

attention은 "하늘은 하늘답게, 사람은 사람답게 편집한다"를 보장한다. 근데 사람의 팔 윤곽선이 프레임마다 살짝 다르게 그려지는 건 막지 못한다. 저수준 형태 정보는 conv 레이어가 다루기 때문이다. conv가 프레임마다 다른 형태를 만들면 사람 팔 윤곽선이 1~2픽셀씩 달라지는 식으로 흔들린다.

이걸 막기 위해 conv 레이어의 output을 원본 것으로 덮어쓴다.

source_batch_size = int(hidden_states.shape[0] // 3)
# unconditional 위치에 source conv feature 덮어쓰기
hidden_states[source_batch_size:2 * source_batch_size] = hidden_states[:source_batch_size]
# conditional 위치에도 덮어쓰기
hidden_states[2 * source_batch_size:] = hidden_states[:source_batch_size]

 

CFG 때문에 hidden_states가 3배로 구성돼 있다는 걸 먼저 이해해야 한다.

hidden_states = [source | unconditional | conditional]
                 0~N      N~2N            2N~3N
  • source = 원본 프레임의 conv feature (편집 안 된 것)
  • unconditional = 빈 프롬프트로 현재 편집 중인 것
  • conditional = 텍스트 프롬프트로 현재 편집 중인 것

이 코드가 하는 일은 unconditional과 conditional 자리에 원본 source feature를 덮어쓰는 것이다.

덮어쓰기 전: [원본 | 편집중_uncond | 편집중_cond]
덮어쓰기 후: [원본 | 원본          | 원본        ]

 

결과적으로 conv 레이어에서는 편집된 feature가 아니라 원본의 공간 구조를 그대로 쓰게 된다. 편집 방향은 텍스트 조건(attn2)에서 오지만, 공간 구조 자체는 원본에서 온다. 사람의 윤곽선이나 배경의 큰 구조가 흔들리지 않는 이유다.

injection_schedule에 현재 t가 포함돼 있을 때만 실행된다. 보통 초반 타임스텝(노이즈가 많아 구조가 결정되는 시점)에만 injection하고, 후반(세부 디테일이 채워지는 시점)에는 injection을 끈다.

정리: 비피벗 프레임이 피벗의 편집 결정을 그냥 복사하는 게 아니라, 유사도로 위치를 맞춰서 가져오고(Step 1-5), 두 피벗 사이면 거리 비율로 섞고(Step 3-6), 거기에 conv로 형태까지 고정한다(Conv Injection). 이 세 가지가 합쳐져서 시간 일관성이 만들어진다.

PHASE 3 — VAE Decode & 재조립

denoising loop가 t=0까지 내려오면 편집된 latent z₀가 남는다.

pipe.vae.decode(z₀)64×64×4 latent를 다시 512×512×3 RGB로 올린다. VAE decoder는 학습된 신경망이기 때문에 단순 역변환이 아니라, latent에 인코딩된 의미를 풀어내면서 픽셀을 생성한다.

각 프레임을 PIL Image로 저장한 다음, ffmpeg가 프레임 시퀀스를 원본 오디오와 합쳐서 최종 mp4를 만든다.


전체 인과 구조 요약

원본 영상
↓
[PHASE 1] DDIM Inversion
  └ 원본을 노이즈 경로 위로 역변환 → noisy_latents_{t}.pt 저장
↓
[PHASE 2] TokenFlow Denoising (t=T → t=0 루프, 타임스텝마다 반복)
  └ 피벗 pass: 배치 내 랜덤 선택된 피벗 프레임 먼저 attn1 실행 → kf_attn_output 저장
  └ 비피벗 pass:
      Step1: 비피벗 전체 토큰 vs 피벗 2개 토큰 코사인 유사도 계산
      Step2: 각 비피벗 토큰이 피벗1, 피벗2에서 가장 유사한 짝 인덱스 추출 (argmax)
      Step3: 각 비피벗 프레임이 피벗1, 피벗2에서 얼마나 먼지 거리 계산
      Step4: 거리 비율로 sigmoid 가중치 생성
      Step5: gather로 피벗 attn output을 비피벗 위치에 맞게 가져오기
      Step6: 두 피벗 output을 w1로 보간 → 최종 attn output
  └ Conv Injection: conv 레이어에서 원본 feature로 덮어써서 공간 구조 고정
  └ attn2에서 텍스트 프롬프트 반영 (각 프레임 개별 수행)
↓
[PHASE 3] VAE Decode
  └ edited latent → RGB 프레임 → ffmpeg → 최종 mp4
↓
시간 일관성이 보존된 편집 영상

 

각 단계는 이전 단계의 출력이 없으면 시작할 수 없는 구조다. 원본 영상을 DDIM Inversion으로 noise 공간에 올려놔야(PHASE 1) 편집이 가능하고, noise 공간에서 denoising할 때 피벗 프레임의 attention output을 나머지 프레임들이 공유해야(PHASE 2) 시간 일관성이 생기고, 편집된 latent를 VAE로 decode해서 다시 영상으로 합친다(PHASE 3).

 

QnA

질문) denoising은 정확히 왜 있는건가요? denoising 하면서 attention이 돌아가서 피벗이 완성되는건가요?

답변)

denoising 루프가 전체 틀입니다. 타임스텝 t=T에서 t=0까지 내려오는 반복문인데, 매 타임스텝 안에서 피벗 pass → 비피벗 pass가 세트로 실행됩니다.

타임스텝 t=981
  ├── 피벗 pass: 피벗 프레임이 UNet(attn1→attn2) 통과 → kf_attn_output 저장
  └── 비피벗 pass: 비피벗 프레임이 코사인 유사도로 피벗 결과 가져다 씀

타임스텝 t=961
  ├── 피벗 pass
  └── 비피벗 pass

... (50번 반복)

타임스텝 t=0
  → 편집 완료된 latent

 

그러니까 피벗/비피벗은 denoising 안에서 매 스텝마다 반복되는 구조입니다. 피벗이 먼저 그 타임스텝의 attention 결과를 만들고, 비피벗이 그걸 가져다 쓰는 게 50번 일어나는 것이죠.

그리고 DDIM Inversion에서 저장해둔 latent는 각 타임스텝의 출발점으로 쓰입니다. 피벗이 UNet 통과하기 직전에 그 타임스텝에 맞는 저장된 latent를 불러와서 시작합니다. 원본 구조를 잃지 않기 위해서죠.


TokenFlow Plug-and-Play Diffusion Stable Diffusion DDIM Inversion video editing diffusion model temporal consistency self-attention ICLR 2024

목차

  1. Decoder란?
  2. 전체 흐름 한눈에 보기
  3. Step 0: 입력 준비
  4. Step 1: conv_in - 채널 확장
  5. Step 2: Middle Block - 전역 맥락 재확인
  6. Step 3: Level 3 - 첫번째 업샘플링 (16x16 -> 32x32)
  7. Step 4: Level 2 - 두번째 업샘플링 (32x32 -> 64x64, 1024 -> 512)
  8. Step 5: Level 1 - 세번째 업샘플링 (64x64 -> 128x128, 512 -> 256)
  9. Step 6: Level 0 - 네번째 업샘플링 (128x128 -> 256x256, 256 -> 128)
  10. Step 7: 최종 출력 (conv_out)
  11. 최종 결과 정리
  12. Encoder vs Decoder 비교
  13. 마무리

Decoder는 Encoder가 만든 압축된 표현(16×16×256 또는 512)을 받아서 원본 크기의 이미지(256×256×3)로 복원하는 신경망입니다.

📌 Decoder란?

Decoder는 압축 해제를 담당합니다. Encoder와 정반대 방향으로 작동합니다!

[압축된 표현 16×16×256]
    ↓ 점점 커지고
    ↓ 점점 구체적으로
[복원된 이미지 256×256×3 RGB]

🎯 전체 흐름 한눈에 보기

단계 해상도 채널 수 표현하는 정보
입력 16×16 256 or 512 압축된 추상 표현
conv_in 16×16 1024 채널 확장
Middle 16×16 1024 전역 맥락 재확인
Level 3 16×16 → 32×32 1024 → 512 고수준 구조
Level 2 32×32 → 64×64 512 → 256 부위 관계
Level 1 64×64 → 128×128 256 → 128 기본 패턴
Level 0 128×128 → 256×256 128 세부 특징
출력 256×256 3 (RGB) 픽셀 색상

🚀 Step 0: 입력 준비

Encoder에서 받은 압축 표현

# Encoder 출력 (double_z=True인 경우)
z = encoder(image)  # (1, 512, 16, 16)

# VAE의 Reparameterization
mean = z[:, :256, ...]      # (1, 256, 16, 16)
logvar = z[:, 256:, ...]    # (1, 256, 16, 16)

# 샘플링
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
z_sample = mean + eps * std  # (1, 256, 16, 16)

# Decoder 입력
z_input = z_sample  # (1, 256, 16, 16)

 

구체적 예시 - 고양이 얼굴

위치 (8, 8) - 얼굴 중심 (코 영역)

# 압축된 256개 특징값:
[0.52, -0.31, 0.89, 0.23, ..., -0.12]

특징 0: 0.52   → "고양이 얼굴 중심"
특징 1: -0.31  → "코 영역"
특징 2: 0.89   → "주변에 눈이 있음"
특징 3: 0.23   → "아래에 입이 있음"
...
특징 255: -0.12 → "전체적인 조명 상태"

매우 추상적! 이제 이것을 구체적인 픽셀로 변환해야 합니다.


🔍 Step 1: conv_in - 채널 확장

코드 구현

# Decoder 초기화에서
block_in = ch * ch_mult[self.num_resolutions-1]  # 128 * 8 = 1024
self.conv_in = torch.nn.Conv2d(z_channels,      # 256
                               block_in,        # 1024
                               kernel_size=3,
                               stride=1,
                               padding=1)

 

무슨 일이 일어나나?

256개의 압축 채널을 1024개의 특징 채널로 확장합니다. 3×3 컨볼루션을 사용하여 주변 정보도 함께 고려합니다.

 

차원 변화:

입력: (1, 256, 16, 16)
      ↓ conv_in
출력: (1, 1024, 16, 16)

 

구체적 예시 - 위치 (8, 8)

# 입력: 256개 압축 특징
입력 = [0.52, -0.31, 0.89, ..., -0.12]  # 256개

# 3×3 영역 처리
┌─────────┬─────────┬─────────┐
│ (7,7)   │ (8,7)   │ (9,7)   │  각 위치마다 256개 특징
├─────────┼─────────┼─────────┤
│ (7,8)   │ (8,8)   │ (9,8)   │  총 9×256 = 2,304개 값
├─────────┼─────────┼─────────┤
│ (7,9)   │ (8,9)   │ (9,9)   │
└─────────┴─────────┴─────────┘

# 1024개 필터로 처리
필터 0번: 2,304개 값의 가중합 → 출력 채널 0
필터 1번: 2,304개 값의 가중합 → 출력 채널 1
...
필터 1023번: 2,304개 값의 가중합 → 출력 채널 1023

# 출력: 1024개 확장 특징
출력 = [0.234, -0.456, 0.678, 0.123, ..., 0.345]  # 1024개

 

확장된 특징들:

특징 0: 0.234    → "수평 엣지 준비"
특징 1: -0.456   → "수직 엣지 준비"
특징 2: 0.678    → "밝은 영역 (코 끝)"
특징 3: 0.123    → "어두운 영역 (콧구멍)"
특징 4: 0.890    → "갈색 색상 정보"
...
특징 512: 0.456  → "코의 3D 형태"
특징 513: 0.789  → "코의 하이라이트"
...
특징 1023: 0.345 → "주변과의 경계"

 

왜 1024개로 확장?

  • 256개: 매우 압축된 추상 정보만
  • 1024개: 구체적인 복원에 필요한 모든 세부 정보 준비

🎨 Step 2: Middle Block - 전역 맥락 재확인

코드 구조

# middle
self.mid = nn.Module()
self.mid.block_1 = ResnetBlock(in_channels=block_in,      # 1024
                               out_channels=block_in,     # 1024
                               temb_channels=self.temb_ch, # 0
                               dropout=dropout)
self.mid.attn_1 = AttnBlock(block_in)  # Self-Attention!
self.mid.block_2 = ResnetBlock(in_channels=block_in,      # 1024
                               out_channels=block_in,     # 1024
                               temb_channels=self.temb_ch,
                               dropout=dropout)

 

Forward 처리

def forward(self, z):
    temb = None  # Decoder는 timestep 안 씀
    
    # z to block_in
    h = self.conv_in(z)  # (1, 1024, 16, 16)
    
    # middle
    h = self.mid.block_1(h, temb)  # ResNet 정제
    h = self.mid.attn_1(h)         # Self-Attention!
    h = self.mid.block_2(h, temb)  # ResNet 정제

 

ResNet Block 1 - 특징 정제

ResnetBlock은 정규화, 활성화, 컨볼루션을 순차적으로 적용하며, Skip connection을 통해 원본 정보를 보존합니다.

 

위치 (8, 8) - 코 영역:

# 입력 1024개 특징
입력 = [0.234, -0.456, 0.678, ...]

# 1. GroupNorm: 값 범위 조정
정규화 = [-0.132, -1.512, 0.756, ...]

# 2. Swish: 비선형 활성화
활성화 = [-0.066, -0.274, 0.515, ...]

# 3. Conv 3×3: 주변과 통합
주변통합 = [0.123, -0.234, 0.456, ...]

# 4. 한 번 더 반복 (norm2 → swish → conv2)
두번째처리 = [0.089, -0.167, 0.378, ...]

# 5. Skip Connection: 원본 + 처리결과
최종 = 입력 + 두번째처리
     = [0.323, -0.623, 1.056, ...]

 

Self-Attention - 모든 위치가 대화

AttnBlock은 Query, Key, Value를 생성하고, 각 위치가 다른 모든 위치의 정보를 가중합하여 전역 맥락을 통합합니다.

 

코가 다른 부위와 대화:

# 위치 (8, 8) - 코
Query(코): "나는 눈, 입, 귀가 어디 있는지 확인하고 싶어"

# 모든 위치와 유사도 계산
유사도(코 ↔ 왼쪽 눈): 9.3  → Softmax → 52%
유사도(코 ↔ 오른쪽 눈): 8.9  → Softmax → 35%
유사도(코 ↔ 입): 7.3  → Softmax → 7%
유사도(코 ↔ 귀들): ~5.0  → Softmax → 각 1.8%
유사도(코 ↔ 배경들): ~1.5  → Softmax → 각 0.02%

# Value 가중합
새로운특징(코) = Value(왼쪽눈) × 52%
              + Value(오른쪽눈) × 35%
              + Value(입) × 7%
              + ...

# 결과
원래: "나는 중심부야"
이제: "나는 두 눈 사이, 입 위에 있는 얼굴 중심의 코야"

 

ResNet Block 2 - 최종 정제

# Attention 후 한 번 더 정제
h = self.mid.block_2(h, temb)

# 위치 (8, 8) 최종 특징:
특징들 = [0.567, -0.234, 0.891, ...]

특징 0: 0.567   → "코는 얼굴 중심에 있고"
특징 1: -0.234  → "양쪽에 눈이 대칭으로 배치"
특징 2: 0.891   → "아래에 입이 있으며"
특징 3: 0.456   → "전체적으로 정면을 향함"
...
→ 완전한 맥락 정보를 가진 특징

📊 Step 3: Level 3 - 첫 번째 업샘플링 (16×16 → 32×32)

코드 구현 - Upsampling Loop

# upsampling
self.up = nn.ModuleList()
for i_level in reversed(range(self.num_resolutions)):  # 3, 2, 1, 0
    block = nn.ModuleList()
    attn = nn.ModuleList()
    block_out = ch * ch_mult[i_level]
    
    # Level 3: block_out = 128 * 8 = 1024
    # Level 2: block_out = 128 * 4 = 512
    # Level 1: block_out = 128 * 2 = 256
    # Level 0: block_out = 128 * 1 = 128
    
    for i_block in range(self.num_res_blocks + 1):  # 보통 3번 (0, 1, 2)
        block.append(ResnetBlock(in_channels=block_in,
                                 out_channels=block_out,
                                 temb_channels=self.temb_ch,
                                 dropout=dropout))
        block_in = block_out
        if curr_res in attn_resolutions:
            attn.append(AttnBlock(block_in))
    
    up = nn.Module()
    up.block = block
    up.attn = attn
    
    if i_level != 0:  # 마지막 레벨 제외하고 업샘플링
        up.upsample = Upsample(block_in, resamp_with_conv)
        curr_res = curr_res * 2
    
    self.up.insert(0, up)  # 앞에 추가 (순서 맞추기)

 

ResNet Block × 3번

Level 3은 해상도는 유지하고 특징만 정제합니다.

# 현재 상태: (1, 1024, 16, 16)

# Block 0: 1024 → 1024
h = self.up[i_level].block[0](h, temb)

# Block 1: 1024 → 1024
h = self.up[i_level].block[1](h, temb)

# Block 2: 1024 → 1024
h = self.up[i_level].block[2](h, temb)

# 여전히: (1, 1024, 16, 16)

 

위치 (8, 8) - 코 영역:

# Block 0 후
특징들: [0.567, -0.234, 0.891, ...]
→ "고양이 얼굴의 코" (여전히 추상적)

# Block 1 후
특징들: [0.634, -0.189, 0.923, ...]
→ "갈색의 둥근 코" (조금 더 구체적)

# Block 2 후
특징들: [0.701, -0.145, 0.956, ...]
→ "중앙이 밝고 가장자리가 어두운 입체적인 코" (더욱 구체적)

 

Upsample - 해상도 2배 증가

Upsample은 nearest neighbor interpolation으로 해상도를 2배로 늘린 후, 3×3 컨볼루션으로 부드럽게 만듭니다.

class Upsample(nn.Module):
    def forward(self, x):
        # 1. Nearest neighbor로 2배 확대
        x = torch.nn.functional.interpolate(
            x, scale_factor=2.0, mode="nearest"
        )
        # 2. Conv로 부드럽게
        if self.with_conv:
            x = self.conv(x)
        return x

 

어떻게?

# 입력: 16×16, 1024채널
원본 16×16:
┌──┬──┬──┬──┐
│A │B │C │D │
├──┼──┼──┼──┤
│E │F │G │H │
├──┼──┼──┼──┤
│I │J │K │L │
├──┼──┼──┼──┤
│M │N │O │P │
└──┴──┴──┴──┘

# Step 1: Nearest Neighbor 2배 확대 → 32×32
각 픽셀을 2×2로 복사:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│A A │A A │B B │B B │C C │C C │D D │D D │
│A A │A A │B B │B B │C C │C C │D D │D D │
├────┼────┼────┼────┼────┼────┼────┼────┤
│A A │A A │B B │B B │C C │C C │D D │D D │
│A A │A A │B B │B B │C C │C C │D D │D D │
├────┼────┼────┼────┼────┼────┼────┼────┤
│E E │E E │F F │F F │G G │G G │H H │H H │
│E E │E E │F F │F F │G G │G G │H H │H H │
└────┴────┴────┴────┴────┴────┴────┴────┘
→ 계단 현상 발생 (blocky)

# Step 2: Conv 3×3로 부드럽게
┌─────┬─────┬─────┬─────┐
│ A'  │ AB' │ BC' │ C'  │  각 값이 주변과 블렌딩됨
├─────┼─────┼─────┼─────┤
│ AE' │ BF' │ CG' │ DH' │
├─────┼─────┼─────┼─────┤
│ EI' │ FJ' │ GK' │ HL' │
└─────┴─────┴─────┴─────┘
→ 부드러운 전환

 

구체적 예시 - 코 영역:

# 원래 위치 (8, 8)
원본특징 = [0.701, -0.145, 0.956, ...]  # 1024개
→ "중앙이 밝은 코"

# Upsample 후 32×32에서 4개 위치로 확장:
# (16, 16), (17, 16), (16, 17), (17, 17)

위치(16,16): [0.723, -0.123, 0.967, ...]  
→ "코의 왼쪽 위 부분"

위치(17,16): [0.745, -0.101, 0.978, ...]  
→ "코의 오른쪽 위 부분"

위치(16,17): [0.689, -0.167, 0.945, ...]  
→ "코의 왼쪽 아래 부분"

위치(17,17): [0.712, -0.145, 0.956, ...]  
→ "코의 오른쪽 아래 부분"

 

결과:

입력: (1, 1024, 16, 16)
출력: (1, 1024, 32, 32)

✅ 해상도 4배 증가 (16² → 32²)
✅ 채널 수 유지 (1024)
✅ 각 영역이 더 세밀하게 표현됨

🎨 Step 4: Level 2 - 두 번째 업샘플링 (32×32 → 64×64, 1024 → 512)

채널 감소

# Level 2 파라미터
block_in = 1024  # 이전 레벨 출력
block_out = ch * ch_mult[2] = 128 * 4 = 512  # 이번 레벨 출력

 

왜 채널을 줄이나?

  • 해상도가 커지면서 공간 정보가 증가
  • 각 위치가 더 좁은 영역만 담당
  • 추상적 특징 → 구체적 특징으로 변화

ResNet Block × 3 (1024 → 512)

Block 0: 첫 번째 블록에서 채널 감소

# Block 0
입력: (1, 1024, 32, 32)
출력: (1, 512, 32, 32)

# 위치 (16, 16) - 코 왼쪽 위
입력 1024개 특징:
[0.723, -0.123, 0.967, ..., 0.234]
→ "코의 왼쪽 위 영역의 추상적 표현"

# Conv2d(1024, 512)로 압축
출력 512개 특징:
[0.812, -0.067, 0.934, ..., 0.456]
→ "코 왼쪽 위의 구체적 형태 정보"

특징 0: 0.812   → "이 부분은 둥글다"
특징 1: -0.067  → "밝은 하이라이트"
특징 2: 0.934   → "갈색 톤"
특징 3: 0.456   → "부드러운 질감"
...
특징 511: 0.456 → "주변보다 밝음"

 

Block 1, 2: 512 채널 유지하며 정제

# Block 1
h = self.up[i_level].block[1](h, temb)  # 512 → 512

# Block 2
h = self.up[i_level].block[2](h, temb)  # 512 → 512

# 위치 (16, 16) 최종
특징들: [0.867, -0.034, 0.945, ..., 0.523]
→ "코 왼쪽 위의 둥근 하이라이트, 갈색, 부드러움"

 

Upsample: 32×32 → 64×64

# 현재: (1, 512, 32, 32)
h = self.up[i_level].upsample(h)
# 결과: (1, 512, 64, 64)

# 원래 위치 (16, 16)이 64×64에서 4개 위치로:
# (32, 32), (33, 32), (32, 33), (33, 33)

위치(32,32): "코 왼쪽 위의 가장 밝은 부분"
위치(33,32): "코 왼쪽 위에서 중앙으로 가는 부분"
위치(32,33): "코 왼쪽 위에서 아래로 가는 부분"
위치(33,33): "코 왼쪽 위의 중심"

 

Level 2 결과:

입력: (1, 1024, 32, 32)
출력: (1, 512, 64, 64)

✅ 해상도 4배 증가
✅ 채널 절반 감소
✅ 구조적 관계 → 부분 패턴으로 구체화

📐 Step 5: Level 1 - 세 번째 업샘플링 (64×64 → 128×128, 512 → 256)

더욱 구체적인 특징

# Level 1 파라미터
block_out = ch * ch_mult[1] = 128 * 2 = 256

 

ResNet Block × 3 처리

위치 (32, 32) - 코 왼쪽 위의 가장 밝은 부분:

# Block 0: 512 → 256
입력 512개 특징: "코의 하이라이트 영역"
출력 256개 특징:

특징 0: 0.923   → "매우 밝은 영역"
특징 1: 0.867   → "베이지색"
특징 2: 0.789   → "부드러운 그라데이션"
특징 3: 0.834   → "주변보다 돌출"
특징 4: 0.712   → "반사광"
...
특징 255: 0.645 → "털 질감의 시작"

# Block 1, 2: 256 유지하며 정제
→ "밝은 베이지색의 부드러운 하이라이트, 약간의 털 질감"

 

Upsample: 64×64 → 128×128

# 위치 (32, 32) → 128×128의 (64, 64), (65, 64), (64, 65), (65, 65)

위치(64,64): [0.934, 0.878, 0.801, ...]
→ "하이라이트의 중심 - 가장 밝음"

위치(65,64): [0.912, 0.856, 0.789, ...]
→ "하이라이트에서 오른쪽 - 약간 어두워짐"

위치(64,65): [0.901, 0.845, 0.778, ...]
→ "하이라이트에서 아래 - 약간 어두워짐"

위치(65,65): [0.889, 0.834, 0.767, ...]
→ "하이라이트 가장자리 - 더 어두워짐"

 

Level 1 결과:

입력: (1, 512, 64, 64)
출력: (1, 256, 128, 128)

✅ 부분 패턴 → 기본 특징 (엣지, 색상, 질감)
✅ "코의 하이라이트" → "밝은 베이지 영역의 그라데이션"

🔍 Step 6: Level 0 - 네 번째 업샘플링 (128×128 → 256×256, 256 → 128)

가장 세밀한 특징

# Level 0 파라미터
block_out = ch * ch_mult[0] = 128 * 1 = 128

 

ResNet Block × 3

위치 (64, 64) - 하이라이트 중심:

# Block 0: 256 → 128
입력 256개: "밝은 베이지 하이라이트"
출력 128개:

특징 0: 0.945   → "수평 엣지 약함"
특징 1: 0.912   → "수직 엣지 약함"
특징 2: 0.967   → "매우 밝은 영역"
특징 3: 0.123   → "어두운 영역 아님"
특징 4: 0.889   → "베이지 색상"
특징 5: 0.834   → "부드러운 질감"
특징 6: 0.756   → "약간의 털 패턴"
...
특징 127: 0.678 → "주변과의 경계"

# Block 1, 2: 세부 질감 정제
→ "밝은 베이지의 부드러운 영역, 약한 엣지, 미세한 털 질감"

 

Upsample: 128×128 → 256×256

# 위치 (64, 64) → 256×256의 (128, 128), (129, 128), (128, 129), (129, 129)

위치(128,128): [0.956, 0.923, 0.978, ...]
→ "코 하이라이트 픽셀 - 정확한 위치"

위치(129,128): [0.945, 0.912, 0.967, ...]
→ "바로 오른쪽 픽셀 - 미세하게 다름"

위치(128,129): [0.934, 0.901, 0.956, ...]
→ "바로 아래 픽셀 - 미세하게 다름"

위치(129,129): [0.923, 0.890, 0.945, ...]
→ "대각선 픽셀 - 조금 더 어두움"

 

Level 0 결과:

입력: (1, 256, 128, 128)
출력: (1, 128, 256, 256)

✅ 원본 해상도 복원!
✅ 각 픽셀마다 128개의 세밀한 특징
✅ RGB 변환 직전 단계

🎬 Step 7: 최종 출력 (conv_out)

코드 구현

# end
self.norm_out = Normalize(block_in)  # block_in = 128
self.conv_out = torch.nn.Conv2d(block_in,     # 128
                                out_ch,       # 3 (RGB)
                                kernel_size=3,
                                stride=1,
                                padding=1)

def forward(self, z):
    # ... 모든 업샘플링 처리 후 ...
    
    # end
    h = self.norm_out(h)    # (1, 128, 256, 256)
    h = nonlinearity(h)     # Swish 활성화
    h = self.conv_out(h)    # (1, 3, 256, 256)
    return h

 

Step 7-1: 정규화

h = self.norm_out(h)  # GroupNorm(32그룹, 128채널)

# 위치 (128, 128) - 코 하이라이트 중심
정규화 전: [0.956, 0.923, 0.978, 0.834, ..., 0.678]
정규화 후: [0.623, 0.456, 0.734, 0.234, ..., 0.123]

 

Step 7-2: Swish 활성화

h = nonlinearity(h)  # x * sigmoid(x)

# 위치 (128, 128)
Swish 전: [0.623, 0.456, 0.734, 0.234, ...]
Swish 후: [0.401, 0.285, 0.521, 0.143, ...]

 

Step 7-3: RGB 변환

conv_out은 128개 특징 채널을 3개 RGB 채널로 변환하여 최종 이미지를 생성합니다.

h = self.conv_out(h)  # Conv2d(128, 3, kernel_size=3)

# 차원 변화
입력: (1, 128, 256, 256)
      ↓ conv_out
출력: (1, 3, 256, 256)  # RGB!

 

어떻게 128개 → 3개로?

# 3개 필터 사용 (R, G, B)

# 위치 (128, 128)의 128개 특징:
특징들 = [0.401, 0.285, 0.521, 0.143, ..., 0.234]

# 필터 R (빨강 채널):
R = 특징0×wR0 + 특징1×wR1 + ... + 특징127×wR127
  = 0.401×0.5 + 0.285×0.3 + ... + 0.234×(-0.1)
  = 0.762
  → 정규화하면 (0.762 + 1) / 2 = 0.881
  → 0-255 스케일로 변환: 0.881 × 255 = 224

# 필터 G (초록 채널):
G = 특징0×wG0 + 특징1×wG1 + ... + 특징127×wG127
  = 0.401×0.4 + 0.285×0.4 + ... + 0.234×(-0.05)
  = 0.634
  → 정규화: 0.817
  → 0-255: 208

# 필터 B (파랑 채널):
B = 특징0×wB0 + 특징1×wB1 + ... + 특징127×wB127
  = 0.401×0.3 + 0.285×0.3 + ... + 0.234×(-0.08)
  = 0.545
  → 정규화: 0.773
  → 0-255: 197

# 최종 픽셀 색상
위치 (128, 128) = RGB(224, 208, 197)
→ 밝은 베이지색! (코의 하이라이트)

 

전체 이미지 복원

# 모든 픽셀에 대해 동일한 과정

# 코 영역
(128, 128): RGB(224, 208, 197) - 밝은 베이지 (하이라이트)
(130, 130): RGB(198, 182, 171) - 중간 베이지
(132, 135): RGB(165, 148, 137) - 어두운 갈색

# 왼쪽 눈 영역
(64, 128): RGB(30, 30, 30) - 검은색 (눈동자)
(62, 126): RGB(245, 245, 245) - 흰색 (흰자위)

# 입 영역
(128, 200): RGB(220, 150, 160) - 분홍색

# 배경
(220, 220): RGB(255, 255, 255) - 흰색

# 귀 영역
(40, 40): RGB(245, 230, 220) - 밝은 베이지

📊 최종 결과 정리

전체 변환 과정

[입력] 압축된 표현
16×16 × 256채널
= 65,536개 숫자
    ↓
16×16 × 1024채널 (conv_in: 특징 확장)
    ↓
16×16 × 1024채널 (Middle: 전역 맥락 재확인)
    ↓
32×32 × 1024채널 (Level 3: 해상도 2배, ResNet×3)
32×32 × 512채널 (Level 2: 채널 감소 시작)
    ↓
64×64 × 512채널 (Level 2 Upsample)
64×64 × 256채널 (Level 1: 더 구체적으로)
    ↓
128×128 × 256채널 (Level 1 Upsample)
128×128 × 128채널 (Level 0: 세밀한 특징)
    ↓
256×256 × 128채널 (Level 0 Upsample)
    ↓
[출력] 복원된 이미지
256×256 × 3채널 (RGB)
= 196,608개 숫자

 

압축률

Encoder: 196,608 → 65,536 (약 1/3로 압축)
Decoder: 65,536 → 196,608 (3배 복원)

정보 손실: 있음 (완벽한 복원은 아님)
하지만: 중요한 특징은 보존됨

💡 각 단계에서 표현되는 정보

해상도 채널 표현 정보
16×16 (입력) 256 "고양이 얼굴, 정면, 리얼 사진" (매우 압축)
16×16 (conv_in) 1024 "고양이 얼굴의 모든 구조 정보 준비"
16×16 (Middle) 1024 "전역 맥락 재확인: 눈-코-입의 관계"
32×32 (Level 3) 512 "얼굴 구조: 눈 2개, 코 1개, 입 1개, 귀 2개"
64×64 (Level 2) 256 "각 부위의 형태: 둥근 눈, 삼각형 코, 귀의 각도"
128×128 (Level 1) 128 "질감과 색상: 갈색 털, 검은 눈동자, 분홍 입"
256×256 (Level 0) 128 "세밀한 디테일: 개별 털, 하이라이트, 그림자"
256×256 (출력) 3 (RGB) "최종 픽셀 색상값"

🔄 Encoder vs Decoder 비교

Encoder (압축)

256×256, 3채널 (구체적)
    ↓ Downsample (해상도 감소)
    ↓ 채널 증가
    ↓ 추상화
16×16, 256채널 (추상적)

 

Decoder (복원)

16×16, 256채널 (추상적)
    ↓ Upsample (해상도 증가)
    ↓ 채널 감소
    ↓ 구체화
256×256, 3채널 (구체적)

 


📝 마무리

Decoder의 핵심

  1. 점진적 복원: 16×16 → 32×32 → 64×64 → 128×128 → 256×256
  2. 채널 감소: 1024 → 512 → 256 → 128 → 3
  3. 구체화: 추상 개념 → 구조 → 패턴 → 세부 → 픽셀
  4. Upsample: Nearest neighbor + Conv로 부드럽게 확대
  5. ResNet: 각 단계에서 특징 정제
  6. RGB 변환: 128개 특징 → 3개 색상 채널

정보 손실?

완벽한 복원은 불가능하지만:

  • ✅ 전체 구조 보존
  • ✅ 중요한 특징 보존
  • ✅ 자연스러운 이미지 생성
  • ❌ 개별 털 하나하나까지 정확하진 않음

이것이 "압축의 예술"입니다!

 

💬 궁금한 점이 있다면 댓글로 남겨주세요!

0. 한 줄 요약

Encoder는 이미지를 점점 작고 추상적인 표현으로 압축하는 신경망이다.


1. 전체 흐름 (차원 추적)

이 글에서 아래 변환을 하나씩 분해한다.

입력:  1×3×256×256       (RGB 이미지)
           ↓ conv_in
       1×128×256×256
           ↓ Level 0 ResnetBlock×2 + Downsample
       1×128×128×128
           ↓ Level 1 ResnetBlock×2 + Downsample
       1×256×64×64
           ↓ Level 2 ResnetBlock×2 + Downsample
       1×512×32×32
           ↓ Level 3 ResnetBlock×2
       1×1024×32×32
           ↓ Middle (ResnetBlock → AttnBlock → ResnetBlock)
       1×1024×32×32
           ↓ norm → Swish → conv_out
출력:  1×512×32×32        (latent, double_z=True 기준)

2. 사전 지식 — 세 가지 부품

2-1. nonlinearity (Swish) — Conv만 쌓으면 안 되는 이유

def nonlinearity(x):
    return x * torch.sigmoid(x)

Conv는 곱하고 더하는 선형 연산이다.

Conv1: y = W1 * x
Conv2: y = W2 * x

둘을 쌓으면:
Conv2(Conv1(x)) = W2 * (W1 * x) = (W2*W1) * x = W3 * x

아무리 쌓아도 Conv 하나랑 수학적으로 같다. 층을 깊게 쌓는 의미가 없어진다.

비선형 함수를 끼워야 이 문제가 해결된다. taming VQGAN은 Swish를 쓴다.

x = -5  →  -5  × 0.007 = -0.033  (거의 죽임)
x =  0  →   0  × 0.500 =  0.000
x = +5  →   5  × 0.993 = +4.963  (거의 살림)

음수는 거의 죽이고, 양수는 거의 살린다. ReLU와 달리 음수를 완전히 자르지 않아서 학습이 더 안정적이다.

2-2. Normalize (GroupNorm) — 값이 폭발하지 않게

def Normalize(in_channels):
    return torch.nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)

Conv를 반복하면 값이 점점 커지거나 작아진다. 값이 너무 크면 학습이 불안정하고, 너무 작으면 gradient가 사라진다.

GroupNorm은 채널을 32개씩 묶어서 각 그룹 안에서 평균 0, 분산 1로 맞춘다.

128채널 → 4개 그룹 (32채널씩)

그룹 0: 채널 0~31   → 평균 0, 분산 1로 정규화
그룹 1: 채널 32~63  → 평균 0, 분산 1로 정규화
그룹 2: 채널 64~95  → 평균 0, 분산 1로 정규화
그룹 3: 채널 96~127 → 평균 0, 분산 1로 정규화

affine=True는 정규화 후에 학습 가능한 scale(γ)과 shift(β)를 추가한다. 모델이 필요하면 다시 조절할 수 있게 해두는 것이다.

BatchNorm 대신 GroupNorm을 쓰는 이유는 배치 크기와 무관하게 안정적이기 때문이다. 이미지 생성 모델은 배치 크기가 작은 경우가 많다.

2-3. Conv2d 파라미터 읽는 법

torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding)

파라미터 의미

in_channels 입력 채널 수 (필터가 몇 채널을 보냐)
out_channels 출력 채널 수 = 필터 개수
kernel_size 필터 크기 (3이면 3×3 이웃을 봄)
stride 필터 이동 간격 (2면 출력 크기 절반)
padding 테두리에 0을 몇 겹 두르냐 (크기 유지용)

출력 크기 공식:

출력 크기 = (입력 - kernel_size + 2×padding) / stride + 1

stride=1, kernel=3, padding=1:
(256 - 3 + 2×1) / 1 + 1 = 256  → 크기 유지

stride=2, kernel=3, padding=0:
(256 - 3 + 0) / 2 + 1 = 127 ≈ 128  → 절반

3. conv_in — 첫 번째 관문

self.conv_in = torch.nn.Conv2d(in_channels=3, out_channels=128,
                                kernel_size=3, stride=1, padding=1)

차원 변화: 1×3×256×256 → 1×128×256×256

RGB 3채널이 어떻게 128채널이 되는지가 핵심이다.

필터 1개는 3×3×3 = 27개 가중치를 갖는다. (3×3 공간) × (RGB 3채널)

한 위치 (i,j)에서 일어나는 일:

3×3 이웃 × RGB 3채널 = 27개 값 추출
                           ↓
              필터 0번 × 27개 가중치 → 합산 → 채널 0번 값
              필터 1번 × 27개 가중치 → 합산 → 채널 1번 값
              ...
              필터 127번 × 27개 가중치 → 합산 → 채널 127번 값
                           ↓
                      128개 값 완성

이걸 256×256 모든 위치에서 반복하면 1×128×256×256이 된다.

필터마다 가중치가 다르기 때문에 각자 다른 패턴을 감지한다. 채널 수 = 필터 수 = 감지하는 패턴의 종류다.


4. ResnetBlock — 특징 추출의 핵심

def forward(self, x, temb):
    h = x
    h = self.norm1(h)
    h = nonlinearity(h)
    h = self.conv1(h)

    h = self.norm2(h)
    h = nonlinearity(h)
    h = self.dropout(h)
    h = self.conv2(h)

    if self.in_channels != self.out_channels:
        x = self.nin_shortcut(x)

    return x + h

흐름을 도식으로 보면:

입력 x
├─────────────────────────┐
↓                         │  ← x: 건드리지 않는 원본
norm1 → Swish → conv1     │
↓                         │
norm2 → Swish → dropout → conv2
↓                         │
h (새로 학습한 변화량)  +  x
└─────────────────────────┘
            ↓
          x + h

x는 원본이고, h는 Conv가 새로 만든 변화량이다. 둘을 더하는 게 skip connection이다.

skip connection이 왜 필요한가

층이 깊어질수록 gradient가 역전파되면서 점점 작아진다. 초반 층까지 학습 신호가 닿지 않게 된다. skip connection이 있으면 gradient가 x 경로로 곱셈 없이 바로 흐를 수 있다.

nin_shortcut이 언제 붙는가

if self.in_channels != self.out_channels:
    x = self.nin_shortcut(x)  # Conv2d(in, out, kernel_size=1)

채널이 바뀌는 첫 번째 블록에서만 붙는다.

Level 1, i_block=0:
  in_channels  = 128
  out_channels = 256
  128 ≠ 256 → nin_shortcut으로 128→256 변환 후 더함

Level 1, i_block=1:
  in_channels  = 256
  out_channels = 256
  256 = 256 → 그냥 더함

1×1 Conv는 주변을 보지 않고 채널 수만 바꾼다. 공간 정보는 건드리지 않는다.

실제 차원 — Level 1, i_block=0 기준

입력 x:  1×128×128×128

메인 경로:
norm1 → Swish → conv1(128→256) → norm2 → Swish → dropout → conv2(256→256)
→ h: 1×256×128×128

skip:
nin_shortcut(128→256): x → 1×256×128×128

최종: x + h = 1×256×128×128

5. Downsample — 해상도를 줄이는 법

class Downsample(nn.Module):
    def __init__(self, in_channels, with_conv):
        if self.with_conv:
            self.conv = torch.nn.Conv2d(in_channels, in_channels,
                                        kernel_size=3, stride=2, padding=0)

    def forward(self, x):
        if self.with_conv:
            pad = (0, 1, 0, 1)
            x = torch.nn.functional.pad(x, pad, mode="constant", value=0)
            x = self.conv(x)
        else:
            x = torch.nn.functional.avg_pool2d(x, kernel_size=2, stride=2)
        return x

stride=2가 왜 절반인가

stride=1이면 필터가 한 칸씩 이동한다.

0, 1, 2, 3 → 4번 멈춤 → 출력 4개

stride=2이면 두 칸씩 건너뛴다.

0, 2 → 2번 멈춤 → 출력 2개

필터가 멈춘 위치에서만 출력값이 계산된다. 멈추는 횟수 = 출력 크기다.

with_conv=True일 때 padding을 수동으로 하는 이유

stride=2, padding=0인 Conv는 입력 크기가 홀수면 출력이 정확히 절반이 안 된다. (0,1,0,1) 패딩으로 오른쪽과 아래에만 0을 추가해서 짝수로 맞춘다.

채널은 왜 동시에 늘리나

Downsample 자체는 채널을 바꾸지 않는다. 해상도가 줄면 각 위치가 더 넓은 영역을 대표한다. 담아야 하는 정보가 많아지므로, 뒤따르는 ResnetBlock에서 채널을 늘려 표현력을 보충한다.

256×256×128 → (Downsample) → 128×128×128
128×128×128 → (ResnetBlock) → 128×128×256

6. 다운샘플링 루프 전체 추적

hs = [self.conv_in(x)]
for i_level in range(self.num_resolutions):
    for i_block in range(self.num_res_blocks):
        h = self.down[i_level].block[i_block](hs[-1], temb)
        if len(self.down[i_level].attn) > 0:
            h = self.down[i_level].attn[i_block](h)
        hs.append(h)
    if i_level != self.num_resolutions-1:
        hs.append(self.down[i_level].downsample(hs[-1]))

hs는 중간 결과물을 쌓아두는 스택이다. Decoder가 업샘플링할 때 꺼내 쓴다.

Level별 차원 추적

hs[0]:  1×128×256×256   ← conv_in 결과

Level 0 (block_in=128, block_out=128):
  i_block=0: ResnetBlock(128→128) → 1×128×256×256
  hs[1]:  1×128×256×256
  i_block=1: ResnetBlock(128→128) → 1×128×256×256
  hs[2]:  1×128×256×256
  Downsample → 1×128×128×128
  hs[3]:  1×128×128×128

Level 1 (block_in=128, block_out=256):
  i_block=0: ResnetBlock(128→256) → 1×256×128×128
  hs[4]:  1×256×128×128
  i_block=1: ResnetBlock(256→256) → 1×256×128×128
  hs[5]:  1×256×128×128
  Downsample → 1×256×64×64
  hs[6]:  1×256×64×64

Level 2 (block_in=256, block_out=512):
  i_block=0: ResnetBlock(256→512) → 1×512×64×64
  hs[7]:  1×512×64×64
  i_block=1: ResnetBlock(512→512) → 1×512×32×32 (Downsample 후)
  hs[8]:  1×512×32×32
  Downsample → 1×512×32×32
  hs[9]:  1×512×32×32

Level 3 (block_in=512, block_out=1024):
  i_block=0: ResnetBlock(512→1024) → 1×1024×32×32
  hs[10]: 1×1024×32×32
  i_block=1: ResnetBlock(1024→1024) → 1×1024×32×32
  hs[11]: 1×1024×32×32
  Downsample 없음 (마지막 레벨)

AttnBlock은 curr_res in attn_resolutions 조건으로 붙는데, Encoder 루프에서 curr_res는 최소 32까지만 내려가고 attn_resolutions=[16]이라 루프 안에서는 한 번도 발동되지 않는다.


7. AttnBlock — 유일한 Attention

ResnetBlock의 Conv는 3×3 이웃만 본다. 이미지 양 끝에 있는 두 스크래치가 같은 결함이라도 Conv는 이 관계를 잡지 못한다.

AttnBlock은 모든 위치 쌍의 관계를 동시에 계산한다.

def forward(self, x):
    h_ = self.norm(x)
    q = self.q(h_)   # 1×1 Conv
    k = self.k(h_)   # 1×1 Conv
    v = self.v(h_)   # 1×1 Conv

    b, c, h, w = q.shape
    q = q.reshape(b, c, h*w).permute(0, 2, 1)  # b, hw, c
    k = k.reshape(b, c, h*w)                    # b, c, hw
    w_ = torch.bmm(q, k)                        # b, hw, hw
    w_ = w_ * (int(c)**(-0.5))
    w_ = softmax(w_, dim=2)

    v = v.reshape(b, c, h*w)
    w_ = w_.permute(0, 2, 1)
    h_ = torch.bmm(v, w_).reshape(b, c, h, w)
    h_ = self.proj_out(h_)

    return x + h_

Q, K, V가 뭔가

Q (Query):  "나는 이런 정보가 필요해"
K (Key):    "나는 이런 정보를 갖고 있어"
V (Value):  "내가 실제로 줄 수 있는 정보"

셋 다 1×1 Conv로 만든다. 1×1 Conv는 주변을 보지 않고 채널만 변환한다. Q, K, V는 각 위치가 어떤 정보를 갖고 있냐를 변환하는 것이므로 주변을 볼 필요가 없다.

차원 흐름 — 1×1024×32×32 기준

q: 1×1024×32×32
   ↓ reshape(b, c, h*w)
   1×1024×1024   (32×32=1024개 위치를 1줄로 펼침)
   ↓ permute(0, 2, 1)
   1×1024×1024   (배치, 위치, 채널)

k: 1×1024×32×32
   ↓ reshape(b, c, h*w)
   1×1024×1024   (배치, 채널, 위치)

bmm(q, k): 1×1024×1024

bmm(q, k)가 계산하는 것

w_[i, j] = i번 위치 Q벡터 · j번 위치 K벡터
          = 채널 1024개짜리 두 벡터의 내적
          = 숫자 하나
          = "i번 위치가 j번 위치를 얼마나 주목해야 하나"

결과는 1×1024×1024. 1024개 위치 쌍 전체의 유사도 행렬이다.

왜 c**(-0.5)로 스케일링하나

채널이 1024개면 내적값이 엄청 커짐
→ softmax에 큰 값이 들어가면 하나만 1, 나머지 0
→ 한 위치만 주목하게 됨

1/√1024 = 1/32로 줄이면
→ 여러 위치를 골고루 주목

softmax → V와 가중 합산

softmax 후: 각 행의 합 = 1.0 (확률 분포)

h_[위치 j] = Σ (v[위치 i] × w_[i→j])
           = 관련 있는 위치들의 정보를 가중치대로 모아온 것

마지막에 x + h_로 skip connection을 더한다. 원본 정보를 보존하면서 전역 관계 정보를 추가한다.


8. Middle Block

self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in, ...)
self.mid.attn_1  = AttnBlock(block_in)
self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in, ...)

루프 밖에 따로 있는 이유는 두 가지다.

첫째, AttnBlock이 실제로 발동되는 유일한 지점이다. 루프 안에서는 curr_res가 16이 되는 시점이 없어서 AttnBlock이 붙지 않는다. Middle Block에서는 조건 없이 무조건 붙는다.

둘째, 가장 압축된 상태에서 전역 관계를 계산해야 한다. 32×32=1024개 위치에서 Attention을 계산한다. 해상도가 더 클 때 하면 계산량이 제곱으로 늘어난다.

차원은 유지된다

hs[-1]: 1×1024×32×32
    ↓ mid.block_1: ResnetBlock(1024→1024)
1×1024×32×32
    ↓ mid.attn_1: AttnBlock(1024)
1×1024×32×32   ← 전역 관계 반영됨
    ↓ mid.block_2: ResnetBlock(1024→1024)
1×1024×32×32

9. conv_out — latent로 압축

self.norm_out = Normalize(block_in)
self.conv_out = torch.nn.Conv2d(block_in,
                                2*z_channels if double_z else z_channels,
                                kernel_size=3, stride=1, padding=1)
# forward
h = self.norm_out(h)   # GroupNorm
h = nonlinearity(h)    # Swish
h = self.conv_out(h)   # 1024 → 512 (double_z=True, z_channels=256 기준)
return h

차원 변화

1×1024×32×32
    ↓ norm_out
1×1024×32×32
    ↓ Swish
1×1024×32×32
    ↓ conv_out (필터 512개)
1×512×32×32   ← 최종 latent

double_z=True가 뭔가

출력 512채널을 256 + 256으로 쪼갠다.

앞쪽 256개: mean    (평균)
뒤쪽 256개: logvar  (로그 분산)

이건 VAE의 reparameterization을 위한 준비다. Encoder가 확률 분포를 출력하게 해서, Decoder가 분포에서 샘플링할 수 있게 한다. 단순히 하나의 값으로 압축하는 게 아니라 "이 부근의 어떤 값"으로 압축하는 것이다.

double_z=False면 1×256×32×32가 최종 출력이다.


10. 전체 흐름 한눈에

단계 입력 차원 출력 차원 하는 일

conv_in 1×3×256×256 1×128×256×256 RGB → 128가지 패턴 감지
Level 0 ResnetBlock×2 1×128×256×256 1×128×256×256 특징 정제
Level 0 Downsample 1×128×256×256 1×128×128×128 해상도 절반
Level 1 ResnetBlock×2 1×128×128×128 1×256×128×128 채널 2배, 특징 정제
Level 1 Downsample 1×256×128×128 1×256×64×64 해상도 절반
Level 2 ResnetBlock×2 1×256×64×64 1×512×64×64 채널 2배, 특징 정제
Level 2 Downsample 1×512×64×64 1×512×32×32 해상도 절반
Level 3 ResnetBlock×2 1×512×32×32 1×1024×32×32 채널 2배, 특징 정제
Middle ResnetBlock 1×1024×32×32 1×1024×32×32 특징 정제
Middle AttnBlock 1×1024×32×32 1×1024×32×32 전역 관계 계산
Middle ResnetBlock 1×1024×32×32 1×1024×32×32 특징 정제
conv_out 1×1024×32×32 1×512×32×32 latent 압축 (mean+logvar)

압축률: 256×256×3 = 196,608 → 32×32×256 = 262,144 (mean 기준)

공간은 1/64로 줄었고, 채널이 늘어서 정보 밀도는 높아졌다.

들어가며

이전 글에서 데이터 파이프라인을 완전히 분해했습니다. 이번 편에서는 VQGAN의 핵심인 Vector Quantization을 완전히 파헤칩니다.

"Vector Quantization은 연속적인 벡터를 이산적인 코드로 바꾸는 마법입니다."

 

추상적인 설명 대신, 실제 고양이 사진 한 장을 따라가며 모든 과정을 이해해 봅시다.

이 글은 VectorQuantizer의 작동 원리를 고양이 이미지 예시로 단계별로 풀어낸 설명서입니다.


시작: 고양이 사진 한 장

원본 이미지

파일: cat.jpg
크기: 256×256 픽셀
포맷: RGB

실제 픽셀 데이터

위치 (0, 0): RGB = (245, 230, 220)     # 밝은 베이지 (고양이 귀)
위치 (0, 1): RGB = (242, 228, 218)     # 약간 어두운 베이지
위치 (128, 128): RGB = (180, 160, 140) # 갈색 (고양이 코)
위치 (200, 150): RGB = (255, 255, 255) # 흰색 (배경)
...

총 데이터량:

  • 256 × 256 = 65,536개 픽셀
  • 각 픽셀마다 RGB 3개 값
  • 총 196,608개 숫자

1단계: 인코더를 통과

인코더가 하는 일

이미지를 압축해서 핵심 특징만 추출

encoder = Encoder()  # CNN 네트워크
z = encoder(cat_image)

# 입력: (1, 3, 256, 256)
#       1장, RGB 3채널, 256×256
# 출력: (1, 256, 16, 16)
#       1장, 256채널, 16×16

무슨 일이 일어났나?

압축 전 (원본 이미지):

  • 256×256 = 65,536개 위치
  • 각 위치는 RGB 3개 값
  • 총 196,608개 숫자

압축 후 (인코더 출력 z):

  • 16×16 = 256개 위치 ← 1/16로 압축!
  • 각 위치는 256개 값 (채널)
  • 총 65,536개 숫자

각 위치가 의미하는 것

# 위치 (0, 0)의 256개 값
[0.523, -0.312, 0.891, 0.234, ..., -0.123]
→ "왼쪽 위 영역의 특징"
  (귀 부분의 질감, 색상, 모양을 256차원으로 압축)

# 위치 (8, 8)의 256개 값
[0.823, 0.412, -0.291, 0.634, ..., 0.445]
→ "중앙 영역의 특징"
  (코와 입 주변의 정보)

# 위치 (15, 15)의 256개 값
[0.123, 0.234, 0.891, -0.423, ..., 0.567]
→ "오른쪽 아래 영역의 특징"
  (배경 정보)

핵심: 각 위치는 더 이상 "RGB 색깔"이 아니라 추상적인 특징 벡터


2단계: "벡터"와 "코드"의 차이 이해하기

벡터 (Vector) = 실제 숫자들

# 위치 (0, 0)의 벡터
vector_0_0 = [0.523, -0.312, 0.891, 0.234, ..., -0.123]
# 256개의 실수(소수점 있는 숫자)

특징:

  • 연속적: 0.523, 0.524, 0.525... 무한히 많은 가능한 값
  • 정밀함: 0.52341234... 매우 세밀한 표현
  • 용량 큼: 256개 × 4바이트(float) = 1,024바이트

코드 (Code) = 인덱스 번호

# 코드북에 미리 저장된 512개의 "대표 벡터"
codebook = {
    0: [0.001, -0.002, 0.003, ...],    # "밝은 털 질감"
    1: [0.523, -0.310, 0.890, ...],    # "고양이 귀"
    2: [0.821, 0.410, -0.290, ...],    # "코 주변"
    ...
    511: [0.120, 0.230, 0.890, ...]    # "흰 배경"
}

# 벡터를 코드로 변환
vector_0_0 = [0.523, -0.312, 0.891, ...]
코드북에서 가장 가까운 것 찾기 → 코드 1번!
→ 그냥 숫자 "1"로 저장!

특징:

  • 이산적: 0, 1, 2, 3, ... 511 (딱 512개만 가능)
  • 근사값: 원래 벡터와 정확히 같지 않고 "가장 비슷한" 것
  • 용량 작음: 1개 × 2바이트(int) = 2바이트

비유로 이해하기

벡터 = 정확한 GPS 좌표

서울 어딘가: (위도 37.5665, 경도 126.9780)
부산 어딘가: (위도 35.1796, 경도 129.0756)
→ 무한히 많은 위치 표현 가능, 매우 정밀

코드 = 우편번호

서울 어딘가: 우편번호 03000
부산 어딘가: 우편번호 48000
→ 딱 정해진 번호만 사용, 대략적인 위치

3단계: Vector Quantization 실제 동작

설정

quantizer = VectorQuantizer(
    n_e=512,      # 코드북에 512개 벡터
    e_dim=256,    # 각 벡터는 256차원
    beta=0.25     # Commitment loss 가중치
)

코드북 내용 (초기화 직후)

# 512개의 "대표 벡터"
codebook[0] = [0.00195, -0.00123, 0.00087, ..., -0.00145]
codebook[1] = [0.00134, 0.00178, -0.00192, ..., 0.00156]
codebook[2] = [-0.00167, 0.00089, 0.00123, ..., 0.00198]
...
codebook[511] = [0.00172, -0.00184, 0.00091, ..., -0.00102]
# 각 벡터는 256차원

실제 의미 (학습 완료 후)

코드 의미

0번 "매끄러운 흰색 털" 패턴
1번 "고양이 귀의 질감" 패턴
2번 "어두운 줄무늬" 패턴
... ...
511번 "단순한 배경" 패턴

4단계: 인코더 출력 → 양자화 과정

입력

z = encoder(cat_image)  # (1, 256, 16, 16)

# 구체적인 값들
z[0, :, 0, 0] = [0.523, -0.312, 0.891, 0.234, ..., -0.123]  # 위치 (0,0)
z[0, :, 0, 1] = [0.512, -0.301, 0.878, 0.245, ..., -0.134]  # 위치 (0,1)
z[0, :, 8, 8] = [0.823, 0.412, -0.291, 0.634, ..., 0.445]   # 위치 (8,8)
...

Step 1: 재배열

z = z.permute(0, 2, 3, 1)  # (1, 16, 16, 256)
z_flattened = z.view(-1, 256)  # (256, 256)
# 256개 위치 각각을 독립적인 벡터로

결과:

벡터 0 (위치 0,0): [0.523, -0.312, 0.891, ...]
벡터 1 (위치 0,1): [0.512, -0.301, 0.878, ...]
벡터 2 (위치 0,2): [0.489, -0.289, 0.856, ...]
...
벡터 255 (위치 15,15): [0.123, 0.234, 0.891, ...]

Step 2: 거리 계산 (핵심!)

# 벡터 0과 모든 코드의 거리 계산
벡터 0 = [0.523, -0.312, 0.891, ...]

거리² = (벡터 - 코드북)²의 합

코드 0과의 거리²:
  (0.523-0.00195)² + (-0.312-(-0.00123))² + ... = 8.234
  
코드 1과의 거리²:
  (0.523-0.00134)² + (-0.312-0.00178)² + ... = 8.156
  
코드 2와의 거리²:
  (0.523-(-0.00167))² + (-0.312-0.00089)² + ... = 8.301
  
...

코드 87과의 거리²:
  (0.523-0.00521)² + (-0.312-(-0.00314))² + ... = 7.892  ← 최소!
  
...

코드 511과의 거리²:
  (0.523-0.00172)² + (-0.312-(-0.00184))² + ... = 8.567

결과: 벡터 0은 코드 87번에 가장 가까움! → 인덱스 87 저장


Step 3: 모든 위치에 대해 반복

벡터 0 (위치 0,0):   → 코드 87번  (고양이 귀)
벡터 1 (위치 0,1):   → 코드 87번  (고양이 귀, 비슷한 영역)
벡터 2 (위치 0,2):   → 코드 91번  (귀에서 이마로 전환)
벡터 3 (위치 0,3):   → 코드 91번
...
벡터 64 (위치 4,0):  → 코드 123번 (눈 주변)
벡터 65 (위치 4,1):  → 코드 123번
...
벡터 128 (위치 8,8): → 코드 234번 (코 중앙)
...
벡터 255 (위치 15,15): → 코드 456번 (흰 배경)

Step 4: 양자화된 벡터 생성

# 인덱스를 실제 벡터로 복원
벡터 0: 인덱스 87 → codebook[87] = [0.00521, -0.00314, 0.00892, ...]
벡터 1: 인덱스 87 → codebook[87] = [0.00521, -0.00314, 0.00892, ...]
벡터 2: 인덱스 91 → codebook[91] = [0.00534, -0.00289, 0.00876, ...]
...

5단계: 결과 비교

원본 인코더 출력 (연속적)

위치 (0, 0): [0.523, -0.312, 0.891, 0.234, ..., -0.123]
위치 (0, 1): [0.512, -0.301, 0.878, 0.245, ..., -0.134]
위치 (0, 2): [0.489, -0.289, 0.856, 0.253, ..., -0.145]
위치 (8, 8): [0.823, 0.412, -0.291, 0.634, ..., 0.445]

저장 용량: 256 위치 × 256 차원 × 4바이트 = 262,144바이트

양자화 후 (이산적)

인덱스만 저장:

위치 (0, 0): 87
위치 (0, 1): 87
위치 (0, 2): 91
위치 (8, 8): 234

저장 용량: 256 위치 × 2바이트 = 512바이트

→ 512배 압축!

실제 사용할 때는 코드북에서 복원:

위치 (0, 0): 인덱스 87 → [0.00521, -0.00314, 0.00892, ...]
위치 (0, 1): 인덱스 87 → [0.00521, -0.00314, 0.00892, ...]
위치 (0, 2): 인덱스 91 → [0.00534, -0.00289, 0.00876, ...]

6단계: 시각화된 전체 흐름

[원본 고양이 이미지 256×256]
         ↓ Encoder
         
[특징 맵 16×16, 각 위치 256차원 벡터]
┌──────────────────────────────────┐
│ [0.52,-0.31,...] [0.51,-0.30,...]│  ← 귀 부분
│ [0.49,-0.29,...] [0.47,-0.28,...]│
│        ...           ...          │
│ [0.82,0.41,...]  [0.81,0.40,...] │  ← 코 부분
│        ...           ...          │
│ [0.12,0.23,...]  [0.13,0.24,...] │  ← 배경
└──────────────────────────────────┘
         ↓ Vector Quantization
         
[코드 인덱스로 변환 16×16]
┌──────────────────┐
│  87  87  91  96 │  ← 귀 부분 (비슷한 코드)
│  87  91  96 103 │
│  ... ... ... ...│
│ 234 234 237 240 │  ← 코 부분
│  ... ... ... ...│
│ 456 456 456 456 │  ← 배경 (같은 코드)
└──────────────────┘
         ↓ Codebook Lookup
         
[다시 벡터로 복원]
┌─────────────────────────────────┐
│ codebook[87]  codebook[87]  ... │
│ codebook[87]  codebook[91]  ... │
│      ...          ...        ... │
│ codebook[234] codebook[234] ... │
└─────────────────────────────────┘
         ↓ Decoder
         
[복원된 고양이 이미지 256×256]

7단계: 정보 손실 이해

원본 벡터

위치 (0, 0): [0.523, -0.312, 0.891, 0.234, ...]
위치 (0, 1): [0.512, -0.301, 0.878, 0.245, ...]  ← 미묘하게 다름

양자화 후

위치 (0, 0): 코드 87 → [0.00521, -0.00314, 0.00892, ...]
위치 (0, 1): 코드 87 → [0.00521, -0.00314, 0.00892, ...]  ← 완전히 같음!

결과 분석

손실 발생:

  • 원본에서는 미묘한 차이가 있었음 (귀의 약간 다른 부분)
  • 양자화 후에는 같은 코드 사용 (디테일 손실)

핵심 정보 유지:

  • 큰 틀에서는 "귀 부분"이라는 정보는 유지됨

실제 이미지 품질

구분 특징

원본 고양이 털의 미세한 질감까지 표현
양자화 털의 전체적인 패턴은 유지, 미세한 질감은 약간 뭉개짐
육안 거의 차이 없음!

8단계: 왜 이렇게 하나?

1. 압축 효율

원본 저장:
  - 이미지 1장: 262,144바이트
  
양자화 저장:
  - 인덱스: 512바이트
  - 코드북: 131,072바이트 (한 번만 저장, 모든 이미지 공유)
  
이미지 1,000장이면:
  - 원본: 262MB
  - 양자화: 0.5MB + 0.13MB = 0.63MB
  → 약 400배 압축!

2. Transformer로 생성 가능

연속적인 벡터 (불가능):

"다음 벡터는 [0.523, -0.312, ...] 입니다"
← 무한한 조합, 예측 불가능

이산적인 코드 (가능!):

"다음 코드는 87번입니다"
← 512개 중 선택, 텍스트 생성처럼 다룰 수 있음!

3. 실제 사용 예시

텍스트 특징 벡터  ← Text Encoder가 만들어둔 것
    ↓
Transformer (autoregressive)
  - 텍스트 조건을 참고하면서
  - Codebook 인덱스를 하나씩 예측
  - [87, 87, 91, 96, ..., 234, ..., 456]
    ↓
Codebook Lookup
  - 인덱스 → 벡터로 변환
    ↓
Decoder → 이미지

핵심 개념 정리

Vector Quantization의 본질

입력: 연속적인 벡터 (무한한 가능성)
출력: 이산적인 코드 (제한된 선택지)

작동 원리

  1. 코드북 준비: n개의 대표 벡터 저장
  2. 거리 계산: 입력 벡터와 모든 코드의 거리 측정
  3. 최근접 선택: 가장 가까운 코드 선택
  4. 인덱스 반환: 코드 번호만 저장

장점

항목 효과

압축 수백 배 용량 절감
이산화 Transformer로 생성 가능
품질 육안으로 거의 차이 없음

단점

항목 영향

정보 손실 미세한 디테일 손실
근사 오차 원본 벡터와 정확히 같지 않음

다음 편 예고

다음 글에서는 Encoder/Decoder 아키텍처 완전히 분해합니다.

 

  • ResNet block 구조
  • Attention 메커니즘
  • Down/Upsampling 방식

 

들어가며

이전 글에서 VQGAN 학습의 전체 로드맵을 살펴봤습니다. 이번 편에서는 Phase 2: 데이터 파이프라인을 완전히 분해합니다.

"모델이 아무리 좋아도 데이터 파이프라인이 엉망이면 학습이 안 됩니다."

 

VQGAN이 실제로 어떤 데이터를 먹고, 어떻게 전처리하는지 한 단계씩 파헤쳐 봅시다.


파일 위치와 전체 구조

파일: taming/data/faceshq.py

FacesBase (추상 클래스)
├─ CelebAHQTrain / CelebAHQValidation
│  └─ NumpyPaths (numpy 배열 형식 로드)
├─ FFHQTrain / FFHQValidation
│  └─ ImagePaths (PNG, JPG 파일 로드)
└─ FacesHQTrain / FacesHQValidation
   └─ ConcatDatasetWithIndex (두 데이터셋 결합)

이 코드는 얼굴 이미지 데이터셋을 로드하고 전처리하는 PyTorch Dataset 클래스들입니다. CelebA-HQ와 FFHQ 두 데이터셋을 개별적으로 또는 통합하여 사용할 수 있습니다.


1단계: FacesBase - 공통 인터페이스

목적

모든 얼굴 데이터셋의 기본이 되는 추상 클래스

동작 방식

초기화

  • data와 keys 변수를 None으로 초기화
  • 하위 클래스에서 실제 데이터 로딩 로직 구현

__len__() 메서드

  • 데이터셋에 포함된 샘플의 총 개수 반환
  • len(dataset) 호출 시 동작

__getitem__() 메서드

  • 주어진 인덱스에 해당하는 샘플 반환
  • keys가 지정되어 있으면: 해당 키의 데이터만 선택적 반환
  • keys가 None이면: 전체 샘플 반환

사용 시나리오

이 클래스는 직접 사용되지 않고, 하위 클래스들이 상속받아 공통 기능(__len__, __getitem__)을 재사용합니다.


2단계: CelebAHQ - 고품질 유명인 얼굴

CelebAHQTrain / CelebAHQValidation

목적: CelebA-HQ 데이터셋의 훈련/검증 데이터 로드

동작 방식

1. 디렉토리 구조 설정

data/celebahq/          # 루트 디렉토리
├─ 00001.npy
├─ 00002.npy
├─ 00003.npy
└─ ...

2. 파일 목록 읽기

  • data/celebahqtrain.txt 또는 data/celebahqvalidation.txt 파일 오픈
  • 각 줄에 이미지 파일의 상대 경로 기록
  • 예시:
    00001.npy00002.npy00003.npy
    

3. 전체 경로 생성

  • 루트 디렉토리 + 상대 경로 = 전체 경로
  • data/celebahq/00001.npy

4. NumpyPaths 객체 생성

  • numpy 배열 형식으로 저장된 이미지 로드
  • size=256: 이미지 크기
  • random_crop=False: 이 단계에서는 크롭 안 함

데이터 포맷

CelebA-HQ는 30,000장의 고해상도 유명인 얼굴 이미지로, numpy 배열 형식으로 저장되어 있습니다.


3단계: FFHQ - 고품질 일반인 얼굴

FFHQTrain / FFHQValidation

목적: FFHQ 데이터셋의 훈련/검증 데이터 로드

동작 방식

1. 디렉토리 구조

data/ffhq/             # 루트 디렉토리
├─ 00000.png
├─ 00001.png
├─ 00002.png
└─ ...

2. 파일 목록 읽기

  • data/ffhqtrain.txt 또는 data/ffhqvalidation.txt 오픈

3. ImagePaths 객체 생성

  • 일반 이미지 파일(PNG, JPG 등) 로드
  • CelebAHQ와 다르게 ImagePaths 사용 (NumpyPaths 대신)

CelebAHQ와의 차이점

항목 CelebAHQ FFHQ

저장 형식 .npy (numpy 배열) .png, .jpg (이미지 파일)
로더 클래스 NumpyPaths ImagePaths
샘플 수 30,000장 70,000장
특징 유명인, 다양한 포즈/표정 일반인, 고품질 정면 위주

4단계: FacesHQTrain - 두 데이터셋의 결합

목적

CelebA-HQ와 FFHQ 훈련 데이터를 통합하여 사용

초기화 과정

1. 개별 데이터셋 생성

d1 = CelebAHQTrain(size=256)  # 30,000장
d2 = FFHQTrain(size=256)      # 70,000장

2. ConcatDatasetWithIndex로 결합

self.data = ConcatDatasetWithIndex([d1, d2])
# 총 100,000장 (30,000 + 70,000)

이때 각 샘플에는 어느 데이터셋에서 왔는지 나타내는 인덱스가 함께 반환됩니다:

  • y = 0: CelebA-HQ (인덱스 0~29,999)
  • y = 1: FFHQ (인덱스 30,000~99,999)

3. 크롭 설정

  • crop_size가 지정되면 albumentations.RandomCrop 생성
  • coord=True이면 좌표 정보도 함께 변환되도록 설정

5단계: 데이터 반환 과정 (getitem)

시나리오 1: 좌표 없이 이미지만 크롭

초기화

dataset = FacesHQTrain(size=256, crop_size=128, coord=False)

데이터 가져오기

sample = dataset[100]  # CelebA-HQ의 100번째 샘플

내부 동작 흐름

  1. 데이터셋에서 샘플 로드
    • self.data[100] 호출
    • CelebA-HQ의 100번째 이미지 로드
    • ex = {"image": np.array([256, 256, 3])}, y = 0
  2. 랜덤 크롭 적용
  3. 원본: 256×256 이미지 ↓ RandomCrop이 랜덤 위치 선택 (예: 시작점 (50, 80)) ↓ 128×128 크기로 자름
  4. 클래스 레이블 추가
  5. ex["class"] = 0 # CelebA-HQ
  6. 반환
  7. return { "image": np.array([128, 128, 3]), # 크롭된 이미지 "class": 0 # CelebA-HQ }

시나리오 2: 좌표 정보 포함 (coord=True)

초기화

dataset = FacesHQTrain(size=256, crop_size=128, coord=True)

데이터 가져오기

sample = dataset[50000]  # FFHQ 영역의 샘플

내부 동작 흐름

  1. 데이터셋에서 샘플 로드
    • self.data[50000] 호출
    • FFHQ의 20,000번째 이미지 (50000 - 30000 = 20000)
    • ex = {"image": np.array([256, 256, 3])}, y = 1
  2. 좌표 맵 생성좌표 맵의 의미:
    • coord[0, 0, 0] = 0.0 (맨 왼쪽 위)
    • coord[0, 1, 0] = 0.000015...
    • coord[255, 255, 0] = 0.999984... (맨 오른쪽 아래)
  3. h, w = 256, 256 coord = np.arange(h*w).reshape(h, w, 1) / (h*w) # 형태: [256, 256, 1] # 값 범위: 0.0 ~ 1.0 (정규화된 위치 정보)
  4. 이미지와 좌표를 동시에 크롭
  5. RandomCrop이 (30, 70) 위치 선택 ↓ 이미지의 [30:158, 70:198] 영역 추출 좌표의 [30:158, 70:198] 영역 추출 (동일 위치!) ↓ 동기화 완료
  6. 반환
  7. return { "image": np.array([128, 128, 3]), # 크롭된 이미지 "coord": np.array([128, 128, 1]), # 크롭된 좌표 "class": 1 # FFHQ }

6단계: FacesHQValidation - 검증용 데이터셋

FacesHQTrain과의 차이점

항목 FacesHQTrain FacesHQValidation

크롭 방식 RandomCrop CenterCrop
목적 데이터 증강 일관된 평가
크롭 위치 매번 다름 항상 중앙

왜 다른 크롭을 사용하나?

훈련 시 (RandomCrop)

  • 같은 이미지를 매번 다른 위치에서 크롭
  • 모델이 다양한 상황을 학습
  • 과적합(overfitting) 방지

검증 시 (CenterCrop)

  • 항상 중앙에서 크롭
  • 모든 검증 샘플에 공정한 조건 적용
  • 성능 평가의 일관성 보장

왜 이런 파이프라인이 필요한가?

1. 데이터 증강 (Data Augmentation)

문제: 같은 이미지를 반복 학습하면 과적합 발생

해결:

256×256 이미지 → 매 epoch마다 다른 위치에서 128×128 크롭
↓
같은 이미지도 epoch마다 다르게 보임
↓
모델의 일반화 성능 향상

2. 메모리 효율성

GPU 메모리는 제한적

이미지 크기 배치 크기 (예시)

512×512 2
256×256 8
128×128 32

작은 이미지 = 더 큰 배치 = 학습 안정성 향상

3. 좌표 정보의 역할

왜 필요한가?

생성 모델은 "이미지의 어느 부분인지" 알아야 할 때가 있습니다.

예시:

  • 얼굴의 눈 부분 → 위쪽에 위치
  • 얼굴의 입 부분 → 아래쪽에 위치

좌표 정보를 함께 주면:

모델이 공간 구조 학습
↓
"이 패치는 이미지의 왼쪽 위에 있구나"
↓
위치에 맞는 특성 생성

Transformer 단계에서 활용:

  • 조건부 생성 시 coord를 조건으로 사용
  • "이미지의 특정 위치에 이런 특징 생성"

4. 두 데이터셋 통합 이유

데이터셋 특징 장점

CelebA-HQ 유명인 얼굴, 다양한 포즈/표정 다양성 확보
FFHQ 일반인 얼굴, 고품질 정면 품질 확보

두 가지를 섞으면:

  • 다양한 얼굴 학습
  • robust한 모델
  • 다양한 시나리오에 대응 가능

전체 흐름 요약

초기화 단계

FacesHQTrain(size=256, crop_size=128, coord=True)
    ↓
CelebAHQTrain(30,000장) + FFHQTrain(70,000장)
    ↓
ConcatDatasetWithIndex로 결합 (총 100,000장)
    ↓
albumentations.RandomCrop 준비
    ↓
coord용 additional_targets 설정

데이터 로딩 단계

dataset[인덱스]
    ↓
해당 이미지 로드 (numpy 또는 이미지 파일)
    ↓
coord 맵 생성 (coord=True인 경우)
    ↓
이미지 + coord를 동일한 위치에서 RandomCrop
    ↓
클래스 레이블 추가 (0: CelebAHQ, 1: FFHQ)
    ↓
딕셔너리 반환

핵심 개념 정리

1. 데이터 증강 전략

훈련: RandomCrop → 다양성 검증: CenterCrop → 일관성

2. 클래스 레이블

  • CelebA-HQ → 클래스 0
  • FFHQ → 클래스 1
  • 모델이 데이터 출처 구분 가능

3. 좌표 정보

  • 픽셀의 절대적 위치 정보
  • 0~1로 정규화된 값
  • 크롭 후에도 동기화 유지
  • 공간적 일관성 학습에 활용

4. 동기화 크롭

albumentations의 additional_targets 기능 사용:

albumentations.Compose([cropper],
    additional_targets={"coord": "image"})

→ 이미지와 좌표를 정확히 같은 위치에서 크롭


다음 편 예고

다음 글에서는 Vector Quantization 개념을 이해해봅니다.

  • 고양이 이미지로 직관적 이해
  • "벡터"와 "코드"의 차이
  • 전체 흐름 시각화

들어가며

VQGAN(Vector Quantized Generative Adversarial Network)은 고품질 이미지 생성의 핵심 기술입니다. 논문을 읽는 것도 중요하지만, 실제 코드를 직접 분석하면서 배우는 것이 진짜 이해로 이어집니다.

이 시리즈는 taming-transformers 공식 구현을 기반으로, VQGAN의 모든 것을 단계별로 파헤칩니다.

시리즈 예고

1. 학습 로드맵 (현재 글)
2. 데이터 파이프라인 완전 분해
3. Vector Quantization 핵심 원리
4. Encoder/Decoder 아키텍처 분석
5. Loss 함수의 모든 것
6. Transformer 기반 생성 모델
7. 실전 학습 및 샘플링


학습 전략

VQGAN은 크게 두 단계로 나뉩니다.

Stage 1: VQGAN 학습 - 이미지를 discrete code로 압축하고 복원
Stage 2: Transformer 학습 - Code 시퀀스를 학습해 새로운 이미지 생성

우리는 코드 구조를 따라 7단계로 나눠서 접근할 겁니다.


Phase 1: 기초 이해 - Config 파일부터

시작점

# configs/faceshq_vqgan.yaml
model:
  params:
    embed_dim: 256      # Codebook vector 차원
    n_embed: 1024       # Codebook 크기 (=vocabulary)
    
    ddconfig:
      ch_mult: [1,1,2,2,4]  # Encoder/Decoder 채널 배수
      attn_resolutions: [16]  # Attention 적용 해상도
      
    lossconfig:
      disc_start: 30001    # Discriminator 시작 시점

주목할 핵심 파라미터

  • embed_dim: 각 코드북 벡터의 차원 (256D)
  • n_embed: 코드북에 저장될 벡터 개수 (1024개)
  • ch_mult: 인코더가 이미지를 압축하는 비율 결정
  • disc_start: GAN Loss를 언제부터 적용할지

왜 이게 중요한가?

Config 파일은 모델의 DNA입니다. 여기서 정의된 값들이 전체 아키텍처를 결정하죠.


Phase 2: 데이터 파이프라인

파일 위치

taming/data/faceshq.py

데이터 구조

FacesHQTrain
├─ CelebAHQ (30,000장)
├─ FFHQ (70,000장)
└─ 전처리
   ├─ 256×256 resize
   ├─ Random crop (Train) / Center crop (Val)
   └─ coord 정보 생성 (Transformer용)

핵심 포인트

VQGAN은 픽셀 단위가 아닌 패치 단위로 작동합니다. 256×256 이미지가 16×16 latent로 압축되면, 각 위치의 좌표 정보(coord)가 중요해집니다.

Transformer 단계에서는 이 coord 정보를 조건으로 사용해 위치별 특성을 학습합니다.

데이터 로딩 프로세스

  1. FacesBase: 모든 데이터셋의 기본 추상 클래스
  2. CelebAHQ/FFHQ: 각각 numpy와 이미지 파일 형식으로 로드
  3. FacesHQTrain: 두 데이터셋을 결합하고 클래스 레이블 부여 (0: CelebAHQ, 1: FFHQ)
  4. 좌표 맵 생성: 각 픽셀의 위치를 0~1로 정규화한 coord 생성
  5. 동기화 크롭: 이미지와 좌표를 동일한 위치에서 크롭하여 일관성 유지

다음 편에서 이 모든 과정을 코드와 함께 상세히 분석합니다.


Phase 3: VQGAN 핵심 구조

3-1. Vector Quantization의 심장

파일: taming/modules/vqvae/quantize.py

VectorQuantizer
├─ Codebook embedding (n_embed × embed_dim)
├─ forward(): continuous latent → discrete codes
├─ get_codebook_entry(): codes → latent vectors
└─ Loss 계산
   ├─ Codebook loss: 코드북이 인코더 출력에 가까워지도록
   └─ Commitment loss: 인코더가 코드북에 가까운 값 출력하도록

세 가지 Quantizer 변형

  1. VectorQuantizer: 기본 버전 (legacy 버그 포함)
  2. VectorQuantizer2: 버그 수정 + 효율성 개선 버전 (실제 사용)
  3. EMAVectorQuantizer: Exponential Moving Average 업데이트 방식
  4. GumbelQuantize: Gumbel-Softmax로 미분 가능한 버전

핵심 동작

 

1. 연속적인 벡터 → 이산적인 코드 변환

 - 인코더 출력과 코드북의 거리 계산

 - 가장 가까운 코드 선택 (최근접 이웃)

2. 압축과 복원

 - 인덱스만 저장 (수백 배 압축)

 - 코드북에서 lookup하여 복원

왜 중요한가?

 - 압축: 수백 배 용량 절감

 - 이산화: Transformer로 이미지 생성 가능 (텍스트처럼)

 - 품질: 육안으로 거의 차이 없음


3-2. Encoder/Decoder 아키텍처

파일: taming/modules/diffusionmodules/model.py

Encoder (압축: 256×256×3 → 16×16×256)
├─ conv_in: RGB 3채널 → 128채널 (기본 특징 추출)
├─ Down blocks ×4: 점진적 압축
│  ├─ ResNet ×2: 특징 정제 + Skip connection
│  ├─ Downsample: Stride-2 Conv로 해상도 1/2
│  └─ 채널 증가: 128→256→512→1024 (구체→추상)
├─ Middle (16×16): 전역 맥락 통합
│  ├─ ResNet → Self-Attention → ResNet
│  └─ "나는 돌출부" → "두 눈 사이 코" (전체 구조 이해)
└─ conv_out: 1024 → 512 (Mean 256 + LogVar 256)

Decoder (복원: 16×16×256 → 256×256×3)
├─ conv_in: 256 → 1024채널 (복원 준비)
├─ Middle: Self-Attention으로 맥락 재확인
├─ Up blocks ×4: 점진적 복원
│  ├─ ResNet ×3: 특징 복원 (Encoder보다 1번 더)
│  ├─ Upsample: Nearest + Conv로 해상도 2배
│  └─ 채널 감소: 1024→512→256→128 (추상→구체)
└─ conv_out: 128 → 3채널 RGB (최종 픽셀)

 

핵심 동작

 

1. 점진적 압축/복원

Encoder: 256×256 픽셀색상 → 128×128 엣지/질감 
         → 64×64 부분패턴 → 32×32 구조관계 
         → 16×16 고수준개념
Decoder: 역순으로 복원

 

2. Self-Attention의 역할

  • 위치: 16×16 해상도 (계산 효율 + 충분한 추상화)
  • 효과: 각 위치가 전체 이미지 참조하여 관계 학습
  • 압축률: 196,608 → 65,536개 숫자 (약 1/3)

왜 중요한가?

  • 균형: ResNet(지역 특징) + Attention(전역 맥락) 조합
  • 안정성: Skip connection으로 정보 손실 최소화
  • 효율성: 16×16에서만 Attention (256×256이면 계산 폭발)

3-3. VQGAN 전체 흐름

파일: taming/models/vqgan.py

VQModel (PyTorch Lightning Module)
├─ encoder: image → continuous latent z
├─ quant_conv: z 차원 조정 (z_channels → embed_dim)
├─ quantize: z → discrete codes
├─ post_quant_conv: quantized → z' (embed_dim → z_channels)
├─ decoder: z' → reconstructed image
└─ loss: VQLPIPSWithDiscriminator

주요 메서드

  • encode(): 이미지 → (quantized latent, codebook loss, indices)
  • decode(): Quantized latent → 이미지
  • forward(): End-to-end reconstruction (encode + decode)
  • training_step(): 2-player 게임 (Generator vs Discriminator)
    • optimizer_idx=0: Generator 업데이트
    • optimizer_idx=1: Discriminator 업데이트

학습 파이프라인

Input Image (256×256×3)
    ↓ encoder
Continuous Latent (16×16×256)
    ↓ quant_conv
Adjusted Latent (16×16×256)
    ↓ quantize
Discrete Codes (16×16) + Codebook Loss
    ↓ post_quant_conv
Quantized Latent (16×16×256)
    ↓ decoder
Reconstructed Image (256×256×3)

Phase 4: Loss 함수 - VQGAN이 학습하는 방법

4-1. Perceptual Loss (LPIPS)

파일: taming/modules/losses/lpips.py

단순 L1/L2 Loss는 흐릿한 이미지를 만듭니다. Perceptual Loss는 VGG16의 feature space에서 비교해 사람의 지각적 품질을 개선합니다.

LPIPS
└─ VGG16의 5개 layer features
   ├─ relu1_2 (64채널)
   ├─ relu2_2 (128채널)
   ├─ relu3_3 (256채널)
   ├─ relu4_3 (512채널)
   └─ relu5_3 (512채널)
   → 각 layer의 feature 차이를 가중 평균

4-2. 전체 Loss 구조

파일: taming/modules/losses/vqperceptual.py

VQLPIPSWithDiscriminator
├─ Reconstruction Loss (L1): 픽셀 단위 차이
├─ Perceptual Loss (LPIPS): VGG feature 차이
├─ Codebook Loss (VQ): Vector quantization loss
├─ GAN Loss: Generator vs Discriminator
└─ Adaptive weight: Reconstruction과 GAN loss 동적 밸런싱

Generator 업데이트 (optimizer_idx=0)

total_loss = nll_loss 
           + adaptive_weight × disc_factor × g_loss 
           + codebook_weight × codebook_loss

여기서:
- nll_loss = rec_loss + perceptual_weight × p_loss
- adaptive_weight = ||∇_nll|| / (||∇_g|| + 1e-4)
- disc_factor = 0 if step < disc_start else 1.0

Discriminator 업데이트 (optimizer_idx=1)

disc_loss = disc_factor × hinge_loss(logits_real, logits_fake)

hinge_loss:
- loss_real = mean(max(0, 1 - logits_real))
- loss_fake = mean(max(0, 1 + logits_fake))

Adaptive weight는 왜 필요한가?

Reconstruction loss와 GAN loss의 gradient scale이 다르기 때문에, 마지막 레이어의 gradient norm 비율로 동적 가중치를 계산합니다.


4-3. Discriminator 구조

파일: taming/modules/discriminator/model.py

NLayerDiscriminator (PatchGAN)
├─ Conv(3→64) + LeakyReLU
├─ Conv(64→128) + BatchNorm + LeakyReLU
├─ Conv(128→256) + BatchNorm + LeakyReLU
├─ Conv(256→256) + BatchNorm + LeakyReLU
└─ Conv(256→1) → Real/Fake logits

PatchGAN의 특징

  • 전체 이미지가 아닌 패치 단위로 Real/Fake 판별
  • 30×30 receptive field (70×70 patches)
  • 고주파 디테일 개선에 효과적

Phase 5: 학습 프레임워크

파일: main.py

실행 흐름

1. Config 로드 (YAML → OmegaConf)
2. Model 초기화
   ├─ VQModel (PyTorch Lightning Module)
   └─ Loss, Encoder, Decoder, Quantizer 구성
3. Data 초기화
   ├─ DataModuleFromConfig
   └─ FacesHQTrain/Validation Dataset
4. Trainer 설정
   ├─ Callbacks: ModelCheckpoint, ImageLogger, LearningRateMonitor
   ├─ Logger: TestTube (TensorBoard)
   └─ Multi-GPU 설정 (DDP)
5. Learning Rate Scaling
   lr = accumulate_grad_batches × num_gpus × batch_size × base_lr
6. trainer.fit(model, data)

Phase 6: Transformer - 생성의 완성

6-1. GPT 기반 Transformer

파일: taming/modules/transformer/mingpt.py

VQGAN으로 얻은 discrete code를 언어처럼 취급합니다.

GPT
├─ Token embedding (code indices → n_embd 차원 벡터)
├─ Positional encoding (위치 정보 학습 가능한 parameter)
├─ Transformer blocks (×n_layer)
│  ├─ Layer Norm
│  ├─ Causal Self-Attention (masked)
│  ├─ Layer Norm
│  └─ MLP (4×n_embd 확장 후 축소)
└─ Output head (n_embd → vocab_size)

6-2. 조건부 생성

파일: taming/models/cond_transformer.py

Net2NetTransformer
├─ first_stage_model: VQGAN (frozen, gradient 전파 안됨)
├─ cond_stage_model: 조건 인코더
│  └─ coord: 좌표 정보를 discrete code로 변환
├─ transformer: GPT
│  └─ [condition codes][target codes] 형태로 학습
└─ sample(): Autoregressive generation
   ├─ Top-k sampling: 확률 상위 k개만 고려
   └─ Temperature: 샘플링 다양성 조절

조건부 생성 프로세스

1. 조건(coord) 인코딩
   coord (256×256) → VQGAN encode → condition codes (16×16)

2. Transformer 학습
   Input:  [cond_codes][partial_target_codes]
   Output: [next_code_prediction]
   Loss:   CrossEntropy(prediction, true_target_codes)

3. 생성 시 (Autoregressive)
   - 조건 codes로 시작
   - 한 번에 하나씩 code 예측
   - 256개 code 모두 생성할 때까지 반복
   - VQGAN decoder로 이미지 복원

Phase 7: 추론 및 샘플링

샘플링 스크립트

# 빠른 샘플링 (cached past 사용)
python scripts/sample_fast.py

# 조건부 생성 (Streamlit UI)
streamlit run scripts/sample_conditional.py

# 대량 샘플링
python scripts/make_samples.py

Sampling 전략

1. Top-k Sampling

확률 분포에서 상위 k개만 고려
- k=100: 다양성 ↑, 품질 약간 ↓
- k=1: 결정론적, 품질 ↑, 다양성 ↓

2. Temperature Scaling

logits = logits / temperature

- temperature=1.0: 원래 확률 분포
- temperature>1.0: 확률 평탄화 (다양성 ↑)
- temperature<1.0: 확률 집중 (안정성 ↑)

3. Sampling vs Deterministic

# Sampling: 확률 분포에서 무작위 선택
ix = torch.multinomial(probs, num_samples=1)

# Deterministic: 가장 높은 확률 선택
_, ix = torch.topk(probs, k=1, dim=-1)

다음 편 예고

다음 글에서는 데이터 파이프라인을 완전히 분해합니다.

  • FacesBase 추상 클래스의 역할
  • CelebAHQ와 FFHQ의 로딩 방식 차이
  • ConcatDatasetWithIndex로 두 데이터셋 결합하는 방법
  • 좌표 맵(coord) 생성의 수학적 원리
  • RandomCrop과 CenterCrop의 차이와 사용 시나리오
  • albumentations를 이용한 동기화 변환
  • 실제 데이터 샘플이 반환되는 전체 흐름

코드를 읽고, 실험하고, 이해하는 과정을 함께 걸어가 봅시다.

이 글은 Google Research의 [BERT 공식 구현](https://github.com/google-research/bert)  `modeling.py` 스크립트를 기반으로 작성되었습니다.

 

들어가며: 왜 BERT 모델 아키텍처 설명이 필요한가?

 

BERT를 공부하다 보면 이런 경험 없으신가요?

  • "Multi-Head Attention이 중요하대!" → 수식은 이해했는데... 실제로 뭐가 어떻게 되는 거지?
  • "Transformer는 12개 레이어로 구성돼!" → 그래서 데이터가 어떻게 흘러가는데?
  • "[batch_size, seq_length, hidden_size]" → 이 숫자들이 실제로 의미하는 게 뭐야?

이론은 알겠는데 실제로 텐서가 어떻게 변하고, 왜 그렇게 설계되었는지는 잘 모르겠다는 느낌.

바로 그 간극을 메우기 위해 이 글을 씁니다.

 

이 글의 목표: "아~ BERT가 이렇게 돌아가는구나!"를 느끼게 하는 것.


 

아래 시각화를 보면서 각 단계를 따라오시면, BERT의 전체 흐름이 명확하게 보일 겁니다.


위 그림이 이해되셨나요? 이제 구체적인 숫자로 따라가 보겠습니다! 📊

1단계: BERT에게 문장을 입력하면 정확히 뭘 넣는 건가?

예제 상황

문장: "The quick brown fox"

Step 1-1: 토큰화 (Tokenization)

원본 문장:

"The quick brown fox"

 

BERT가 이해하는 형태로 변환:

[CLS] The quick brown fox

↓ 토큰 ID로 변환

[101, 2023, 4248, 4419, 7592]

 

각 토큰의 의미:

  • 101: [CLS] (Classification) - 문장 시작 표시, 분류 작업에 사용
  • 2023: "The"
  • 4248: "quick"
  • 4419: "brown"
  • 7592: "fox"

총 5개 토큰입니다! (CLS 포함)

Step 1-2: 입력 텐서 만들기

input_ids = [[101, 2023, 4248, 4419, 7592]]  
# shape: [1, 5]
#         ↑  ↑
#      배치1 시퀀스길이5

 

차원 해석:

  • [1, 5] = [배치 크기, 시퀀스 길이]
  • 배치 1: 문장 1개만 처리
  • 시퀀스 길이 5: [CLS] + 4개 단어 = 5개 토큰

Step 1-3: 배치 처리 예시

# 실제로는 여러 문장을 동시에 처리
문장 1: "The quick brown fox"
문장 2: "Hello world"

# 토큰화
문장 1: [101, 2023, 4248, 4419, 7592]  # 5개 토큰
문장 2: [101, 7592, 2088]              # 3개 토큰

# 문제: 길이가 다름! (5 vs 3)
# 해결: 짧은 문장에 패딩(0) 추가

input_ids = [
    [101, 2023, 4248, 4419, 7592, 0, 0, 0],  # 문장 1 + 패딩 3개
    [101, 7592, 2088, 0, 0, 0, 0, 0]         # 문장 2 + 패딩 5개
]
# shape: [2, 8]
#         ↑  ↑
#      배치2  최대길이8

 

패딩 마스크도 함께 전달:

input_mask = [
    [1, 1, 1, 1, 1, 0, 0, 0],  # 앞 5개만 실제 토큰
    [1, 1, 1, 0, 0, 0, 0, 0]   # 앞 3개만 실제 토큰
]
# 1 = 실제 토큰, 0 = 패딩

 

핵심 포인트:

  • 짧은 문장은 0 (PAD 토큰)으로 채워서 같은 길이로 맞춤
  • 왜? GPU는 직사각형 행렬을 좋아하니까! 병렬 처리 효율성 ↑
  • 패딩은 나중에 Attention에서 무시됨 (마스킹)

2단계: 임베딩 - 숫자를 의미있는 벡터로

임베딩이 왜 필요한가?

토큰 ID 2023 (단어 "The")은 그냥 정수일 뿐, 의미가 없습니다. BERT는 이걸 768차원 벡터로 변환해서 "의미"를 담습니다.

Step 2-1: 토큰 임베딩 (Token Embeddings)

임베딩 테이블이란?

  • 30,522개 단어 × 768차원 = 거대한 룩업 테이블
  • 각 단어마다 768개 숫자로 이루어진 "의미 벡터" 보유
# 임베딩 테이블 (학습 가능한 파라미터)
embedding_table = [
    [0.01, -0.02, 0.03, ..., 0.00],  # 토큰 0 (PAD)의 768차원 벡터
    [0.05, 0.12, -0.08, ..., 0.03],  # 토큰 101 (CLS)의 벡터
    [0.02, -0.01, 0.04, ..., -0.02], # 토큰 2023 (The)의 벡터
    [-0.01, 0.07, 0.04, ..., 0.01],  # 토큰 4248 (quick)의 벡터
    ...
]  # shape: [30522, 768]

 

입력 → 출력 변환:

# 입력: [101, 2023, 4248, 4419, 7592]
# 각 ID를 임베딩 테이블에서 찾아옴

token_embeddings = [
    [0.05, 0.12, -0.08, ..., 0.03],   # CLS (ID 101)
    [0.02, -0.01, 0.04, ..., -0.02],  # The (ID 2023)
    [-0.01, 0.07, 0.04, ..., 0.01],   # quick (ID 4248)
    [0.03, -0.05, 0.09, ..., 0.02],   # brown (ID 4419)
    [0.01, 0.03, -0.04, ..., 0.05]    # fox (ID 7592)
]
# shape: [5, 768]
#        ↑  ↑
#     5개토큰 768차원

 

시각화:

입력 토큰 ID         임베딩 테이블              출력 벡터
    101      →     [30522개 단어]    →   [0.05, 0.12, ..., 0.03]
    2023     →     [각각 768차원]    →   [0.02, -0.01, ..., -0.02]
    4248     →                        →   [-0.01, 0.07, ..., 0.01]
    4419     →                        →   [0.03, -0.05, ..., 0.02]
    7592     →                        →   [0.01, 0.03, ..., 0.05]

[5개 ID] → [5개 × 768차원 벡터]

Step 2-2: 위치 임베딩 (Position Embeddings)

문제 상황:

"The quick brown fox" vs "fox brown quick The"
→ Transformer는 단어 순서를 모름!
→ 둘 다 같은 4개 벡터의 집합으로 인식

 

해결책: 각 위치에 고유한 패턴 부여

# 위치 임베딩 테이블 (최대 512 위치)
position_embedding_table = [
    [0.02, 0.01, ..., -0.01],  # 위치 0의 768차원 벡터
    [0.03, -0.02, ..., 0.02],  # 위치 1의 벡터
    [-0.01, 0.04, ..., 0.01],  # 위치 2의 벡터
    [0.01, 0.02, ..., -0.02],  # 위치 3의 벡터
    [0.04, -0.01, ..., 0.03],  # 위치 4의 벡터
    ...
]  # shape: [512, 768]

# 우리 문장은 5개 토큰이므로 앞 5개만 사용
position_embeddings = [
    [0.02, 0.01, ..., -0.01],  # 위치 0 (CLS)
    [0.03, -0.02, ..., 0.02],  # 위치 1 (The)
    [-0.01, 0.04, ..., 0.01],  # 위치 2 (quick)
    [0.01, 0.02, ..., -0.02],  # 위치 3 (brown)
    [0.04, -0.01, ..., 0.03]   # 위치 4 (fox)
]
# shape: [5, 768]

 

핵심:

  • 모든 문장의 "첫 번째 위치"는 같은 위치 벡터 사용
  • 위치 정보가 학습됨 (고정된 sin/cos가 아님!)

Step 2-3: 세그먼트 임베딩 (Token Type Embeddings)

언제 필요한가?

  • 문장 쌍 입력 시: [CLS] 문장A [SEP] 문장B [SEP]
  • 예: 자연어 추론(NLI), 질의응답(QA)

단일 문장 예시 (우리 경우):

token_type_ids = [0, 0, 0, 0, 0]  # 모두 문장 A

segment_embedding_table = [
    [0.01, -0.01, 0.02, ..., 0.00],  # 타입 0 (문장 A)
    [0.02, 0.03, -0.01, ..., 0.01]   # 타입 1 (문장 B)
]  # shape: [2, 768]

segment_embeddings = [
    [0.01, -0.01, 0.02, ..., 0.00],  # 모두 타입 0 벡터
    [0.01, -0.01, 0.02, ..., 0.00],
    [0.01, -0.01, 0.02, ..., 0.00],
    [0.01, -0.01, 0.02, ..., 0.00],
    [0.01, -0.01, 0.02, ..., 0.00]
]
# shape: [5, 768]

 

문장 쌍 예시:

입력: "[CLS] I love NLP [SEP] BERT is great [SEP]"
token_type_ids: [0, 0, 0, 0, 0, 1, 1, 1, 1]
                ↑ 문장 A      ↑ 문장 B

Step 2-4: 최종 임베딩 = 3가지 더하기

# 3가지 임베딩을 원소별로 더함 (element-wise addition)
embedding_output = token_embeddings + position_embeddings + segment_embeddings
# shape: [5, 768]

# 예시 (CLS 토큰의 경우):
token:    [0.05, 0.12, -0.08, ...]
position: [0.02, 0.01, -0.01, ...]
segment:  [0.01, -0.01, 0.02, ...]
          ----------------------------- +
결과:     [0.08, 0.12, -0.07, ...]  # CLS의 최종 임베딩

 

안정화 처리:

# LayerNorm: 평균 0, 분산 1로 정규화
embedding_output = layer_norm(embedding_output)

# Dropout: 10% 확률로 무작위 값을 0으로 (과적합 방지)
embedding_output = dropout(embedding_output, prob=0.1)

# 최종 shape: [5, 768]

 

시각화:

      [CLS]  The  quick  brown  fox
       ↓     ↓     ↓      ↓     ↓
토큰   [v1]  [v2]   [v3]   [v4]  [v5]  (각 768차원)
+ 
위치   [p0]  [p1]   [p2]   [p3]   [p4]
+
세그먼트 [s0]  [s0]   [s0]   [s0]   [s0]
= 
최종   [e0]  [e1]   [e2]   [e3]   [e4]
       ↓
  LayerNorm + Dropout
       ↓
    [5, 768] → Transformer 입력!

3단계: Attention - 단어들이 서로를 바라보다

Attention이 왜 필요한가?

 

문제:

"The quick brown fox jumps over the lazy dog"
  • "fox"를 이해하려면 → "quick", "brown" 정보 필요
  • "jumps"를 이해하려면 → "fox" (주어) 정보 필요

해결: 각 단어가 다른 모든 단어를 "참조"하여 문맥 파악!

Step 3-1: Q, K, V 생성 (Query, Key, Value)

핵심 아이디어:

  • Query (Q): "나는 무엇을 찾고 있나?" (질문)
  • Key (K): "나는 무엇에 대한 정보인가?" (색인)
  • Value (V): "내가 가진 실제 정보" (내용)
# 입력: embedding_output [5, 768]

# 3개의 Dense Layer로 변환
Q = Dense(768)(embedding_output)  # [5, 768]
K = Dense(768)(embedding_output)  # [5, 768]
V = Dense(768)(embedding_output)  # [5, 768]

 

Multi-Head로 분할 (BERT는 12개 헤드):

# [5, 768] → [5, 12, 64]
#  ↑  ↑      ↑  ↑   ↑
# 토큰 차원   토큰 헤드 헤드당차원

Q = reshape(Q, [5, 12, 64])
K = reshape(K, [5, 12, 64])
V = reshape(V, [5, 12, 64])

# 배치 계산을 위해 transpose
Q = transpose(Q, [12, 5, 64])  # [헤드, 토큰, 차원]
K = transpose(K, [12, 5, 64])
V = transpose(V, [12, 5, 64])

 

왜 12개 헤드?

  • 헤드 1: 구문 관계 학습
  • 헤드 2: 의미 관계 학습
  • 헤드 3: 위치 관계 학습
  • ...
  • 다양한 관점에서 문맥 파악!

Step 3-2: Attention Score 계산

"The"가 다른 단어들과 얼마나 관련있나?"

# Q @ K^T (행렬 곱)
scores = Q @ K.transpose()  # [12, 5, 64] @ [12, 64, 5] = [12, 5, 5]
                            #  헤드  Q토큰  K토큰

# Scaling (√64 = 8로 나눔)
scores = scores / 8  # [12, 5, 5]

 

예시 (헤드 0, "The"의 점수):

         [CLS]  The  quick  brown  fox
The       0.8   0.9   0.5    0.3   0.2

→ "The"는 자기 자신(0.9)과 "CLS"(0.8)에 높은 점수!

 

전체 Score Matrix (헤드 0):

        [CLS]  The  quick  brown  fox
[CLS]    1.0   0.8   0.5    0.4   0.3
The      0.8   0.9   0.6    0.4   0.2
quick    0.5   0.6   1.0    0.8   0.5
brown    0.4   0.4   0.8    1.0   0.7
fox      0.3   0.2   0.5    0.7   1.0

5×5 행렬 = 각 토큰이 각 토큰에 주는 점수

Step 3-3: 마스킹 (패딩 무시)

# 만약 패딩이 있다면
input_mask = [1, 1, 1, 1, 1, 0, 0, 0]  # 뒤 3개는 패딩
               ↑ 실제       ↑ 무시

# 마스크 적용
adder = (1.0 - mask) * -10000
scores = scores + adder

# 결과: 패딩 위치는 -10000으로 (softmax 후 ~0)
        [CLS]  The  quick  brown  fox  [PAD]
The      0.8   0.9   0.5    0.3   0.2  -9999
                                        ↑ 무시!

Step 3-4: Softmax (점수 → 확률)

attention_probs = softmax(scores)  # [12, 5, 5]

# "The"의 확률 분포 (헤드 0)
        [CLS]  The  quick  brown  fox
The     0.30  0.35  0.20   0.10  0.05
        ↑합계 = 1.0 (확률 분포)

→ "The"는 자기 자신(35%)과 CLS(30%)에 주목!

Step 3-5: Value에 확률 적용

# attention_probs @ V
context = attention_probs @ V  # [12, 5, 5] @ [12, 5, 64] = [12, 5, 64]

# "The"의 최종 표현 (헤드 0)
context[0][1] = 0.30 * V[CLS] + 0.35 * V[The] + 0.20 * V[quick] 
                + 0.10 * V[brown] + 0.05 * V[fox]

→ 주변 단어들의 정보를 가중합하여 "The"의 새로운 표현 생성!

Step 3-6: 헤드 합치기

# 12개 헤드를 concat
context = concat(context, dim=2)  # [12, 5, 64] → [5, 12*64] = [5, 768]

# Dense로 변환
context = Dense(768)(context)  # [5, 768]

# Residual Connection + LayerNorm
attention_output = layer_norm(context + layer_input)  # layer_input = 이전 레이어 출력

 

Residual Connection이 왜 중요한가?

입력:   [0.05, 0.12, -0.08, ...]
Attention: [0.02, -0.01, 0.03, ...]
         +
출력:   [0.07, 0.11, -0.05, ...]  ← 원본 정보 보존!

→ 기울기 소실 방지, 깊은 네트워크 학습 가능

4단계: Feed-Forward Network - 비선형 변환

왜 필요한가?

Attention만으로는 부족합니다:

  • Attention: 정보를 "모으기만" 함 (선형 결합)
  • FFN: 비선형 변환으로 "새로운 특징" 생성

구조

# 입력: attention_output [5, 768]  (Step 3-6의 출력)

# 중간층: 차원 확장
intermediate = Dense(3072, activation=gelu)(attention_output)  # [5, 3072]

# 출력층: 차원 복원
ffn_output = Dense(768)(intermediate)  # [5, 768]

# Dropout 추가
ffn_output = dropout(ffn_output, 0.1)

# Residual: FFN 출력 + FFN 입력
layer_output = layer_norm(ffn_output + attention_output)  # [5, 768]

 

시각화:

attention_output 입력 [5, 768]  ← Step 3-6의 출력
   ↓
Dense 3072 + GELU
   ↓
중간 [5, 3072]  ← 4배 확장! 표현력 증가
   ↓
Dense 768
   ↓
출력 [5, 768]
   +
입력 [5, 768]  ← Residual
   ↓
LayerNorm
   ↓
layer_output [5, 768] → 다음 레이어로

 

GELU 활성화 함수:

# ReLU vs GELU
ReLU(x) = max(0, x)      # 음수는 0
GELU(x) = x * Φ(x)       # 음수도 작은 값 유지

→ GELU가 더 부드럽고 표현력 좋음!

5단계: Transformer Layer 반복

BERT-Base = 12번 반복!

x = embedding_output  # [5, 768]

for i in range(12):  # 12개 레이어
	
    layer_input = x  # 이 레이어의 입력
    
    # Sub-Layer 1: Multi-Head Attention
    attn_output = multi_head_attention(layer_input)
    attn_output = dropout(attn_output, 0.1)
    attention_output = layer_norm(attn_output + layer_input)
    
    # Sub-Layer 2: Feed-Forward
    ffn_output = feed_forward(attention_output)
    ffn_output = dropout(ffn_output, 0.1)
    layer_output = layer_norm(ffn_output + attention_output)
    
    x = layer_output  # 다음 레이어의 입력
    # x shape: [5, 768] 유지

final_output = x  # [5, 768]

 

각 레이어의 역할:

  • Layer 1-4: 단어 수준 특징 (형태, 품사)
  • Layer 5-8: 구문 수준 특징 (주어-동사)
  • Layer 9-12: 의미 수준 특징 (문맥, 감정)

 

시각화:

Embedding [5, 768]
    ↓
Layer 1:  Attention → FFN → [5, 768]
    ↓
Layer 2:  Attention → FFN → [5, 768]
    ↓
    ...
    ↓
Layer 12: Attention → FFN → [5, 768]
    ↓
Final Output [5, 768]

6단계: 출력 - 태스크별 활용

Pooled Output (분류 작업)

# [CLS] 토큰만 추출
cls_token = final_output[0]  # [768]

# Dense + Tanh
pooled = Dense(768, activation=tanh)(cls_token)  # [768]

# 분류 (예: 감정 분석)
logits = Dense(2)(pooled)  # [2] (긍정/부정)
probs = softmax(logits)    # [0.8, 0.2] → 80% 긍정!

 

Sequence Output (토큰 레벨 작업)

# 모든 토큰 사용
sequence_output = final_output  # [5, 768]

# NER (Named Entity Recognition)
ner_logits = Dense(9)(sequence_output)  # [5, 9]
# 9 = O, B-PER, I-PER, B-LOC, I-LOC, B-ORG, I-ORG, B-MISC, I-MISC

# 각 토큰별 예측
[CLS]: O
The:   O
quick: B-MISC  ← "quick"은 형용사
brown: I-MISC
fox:   B-MISC  ← "fox"는 동물

전체 흐름 총정리

입력 문장: "The quick brown fox"
    ↓
토큰화: [101, 2023, 4248, 4419, 7592]  [5개]
    ↓
임베딩: [5, 768]
  - 토큰 임베딩
  - 위치 임베딩 +
  - 세그먼트 임베딩 +
  - LayerNorm + Dropout
    ↓
Transformer Layer 1:
  - Multi-Head Attention [5, 768]
  - Feed-Forward Network [5, 768]
    ↓
Transformer Layer 2:
  - ...
    ↓
    ...
    ↓
Transformer Layer 12:
  - Multi-Head Attention [5, 768]
  - Feed-Forward Network [5, 768]
    ↓
출력:
  - Pooled: [CLS] 토큰 [768] → 분류
  - Sequence: 모든 토큰 [5, 768] → NER, QA

 

마치며

BERT는 복잡해 보이지만, 핵심은 단순합니다:

  1. 임베딩: 단어를 벡터로
  2. Attention: 문맥 정보 통합
  3. FFN: 비선형 변환
  4. 반복: 12번 반복하여 깊은 이해

이제 BERT가 실제로 어떻게 동작하는지 감이 오시나요?

 

다음 단계로는 BERT가 어떻게 훈련되는지 알아보도록 하겠습니다.

 

감사합니다! 

 

🙏 궁금한 점이 있다면 댓글로 남겨주세요!

안녕하세요. 오늘은 현대 자연어 처리(NLP)의 기반이 된 Transformer 아키텍처를 처음부터 끝까지 완벽하게 뜯어보려고 합니다.

2017년 Google이 발표한 "Attention is All You Need" 논문에서 소개된 Transformer는 기존의 RNN이나 LSTM 없이도 뛰어난 성능을 보여주었고, 이후 BERT, GPT 등 수많은 모델의 기반이 되었습니다.

 

이 글에서 다룰 내용

 

이 글은 Transformer를 깊이 있게 이해하고 싶은 분들을 위한 글입니다. 단순히 개념만 설명하는 것이 아니라, 실제 계산 과정을 예시 문장 "The cat sat"을 통해 처음부터 끝까지 따라가 보겠습니다.

  • Transformer의 전체 구조
  • Encoder의 Layer와 Head의 관계
  • Multi-Head Attention의 동작 원리
  • Q, K, V, Attention Score의 의미와 계산 과정
  • Decoder의 Masked Self-Attention
  • 실제 번역 생성 과정 (영어 → 한국어)

수식이 많이 등장하지만, 각 수식이 무엇을 의미하는지 단계별로 설명하겠습니다. 천천히 따라오시면 Transformer의 모든 것을 이해하실 수 있을 것입니다.


목차

  1. Transformer의 구조
  2. Transformer의 Encoder
    • 2.1 Encoder에서 Layer와 Head의 관계
    • 2.2 Layer 1의 내부 구조
    • 2.3 Encoder 내 6개의 Layer 구조
  3. Scaled Dot-Product Attention과 Multi-Head Attention의 구조
    • 3.1 Q, K, V, Attention Score란 무엇인가?
  4. Transformer의 Decoder
    • 4.1 시점, HEAD, Layer의 관계 시각화
    • 4.2 Decoder에서 Masked Self-Attention이 필요한 이유
    • 4.3 시점 1: "고양이가" 생성하는 중
    • 4.4 시점 2: "앉았다" 생성하는 중
  5. 핵심 내용 정리
  6. 마치며

1. Transformer의 구조

Figure 1. 트랜스포머 - 모델 아키텍처

Transformer는 크게 EncoderDecoder 두 부분으로 나뉩니다.

Figure 1을 보시면 Transformer의 전체 구조를 확인할 수 있습니다. 왼쪽이 Encoder, 오른쪽이 Decoder입니다.


2. Transformer의 Encoder

2.1 Transformer의 Encoder에서 Layer와 Head의 관계

 

Transformer의 Encoder는 6개의 Layer로 구성되어 있습니다.

Encoder 안에는 Multi-Head Attention이 있습니다. 하나의 Layer별로 Head 8개가 병렬로 계산됩니다.

( 예시의 차원 구조가 논문의 원래 설계와 다릅니다. 이는 설명을 위해 단순화한 구조입니다.)

정리하면: 

  • Layer: 6개 (직렬)
  • Head: Layer당 8개 (병렬)
  • 전체 Attention 계산: 6 × 8 = 48번

2.2 Layer 1의 내부 구조

 

지금부터 설명에는 예시가 들어갑니다.

 

[예시]

 

"The cat sat" 이라는 문장을 Transformer로 학습시키는 상황입니다.

Figure 1에 따르면 자연어를 Embedding으로 변환함과 동시에 Positional Encoding으로 합해서 Multi-Head Attention 입력값이 됩니다.

저희는 Multi-Head Attention에 대해서 알아볼 예정이기 때문에 Positional Encoding까지 합산하는 과정을 마쳤다고 가정하겠습니다.

The = [0.1, 1.2, 0.3, 1.1]
cat = [1.6, 0.9, 0.3, 1.4]
sat = [1.4, 0.5, 0.8, 1.1]

 

첫번째 Layer인 Layer 1 내부 구조를 봅시다. 입력값 X가 Multi-Head Attention에 들어가는 것을 알 수 있습니다.

Multi-Head Attention은 8개의 헤드를 가졌기 때문에 병렬로 8번의 Scaled Dot-Product Attention을 거칩니다.

뒤에서 살펴볼 내용이지만 각 Head에서는 Scaled Dot-Product Attention을 거쳐 Attention Score가 계산됩니다. (여기서 각 Head별로 다른 Attention Score를 갖습니다. 그리고 Attention Score 계산을 위해서 W_Q, W_K, W_V를 곱하는 과정이 있습니다. W_Q, W_K, W_V 역시 Head 마다 다릅니다.)

 

각 Head에서 계산된 Attention Score들을 concat하여(연결하여) 합칩니다. 이후, 선형 변환을 거칩니다. 이 결과가 Layer 1의 Multi-Head Attention 출력물입니다.


Head와 Layer의 역할 이해하기

 

계산하기 전에 잠깐 환기를 해봅시다.

  • Head를 굳이 8개씩이나 두고 계산하는 이유는 무엇일까요?
  • 어차피 8개씩이나 Head를 둬서 계산하는데 왜 Layer는 6개씩이나 넣을까요?

비유로 표현한다면, Head는 동일한 입력값을 서로 다른 관점으로 분석하는 역할을 합니다.

Layer는 많으면 많을수록 정보를 분석하는 깊이가 깊어집니다. 아래에서 예시로 확인할 수 있습니다.

 

Head의 역할

비유: 8명의 전문가가 동시에 같은 문장을 분석

전문가 1 (HEAD 1): "주어-동사 관계를 볼게"
전문가 2 (HEAD 2): "시제 정보를 볼게"
전문가 3 (HEAD 3): "품사를 볼게"
전문가 4 (HEAD 4): "의존 관계를 볼게"
전문가 5 (HEAD 5): "대명사 참조를 볼게"
전문가 6 (HEAD 6): "수식 관계를 볼게"
전문가 7 (HEAD 7): "감정을 볼게"
전문가 8 (HEAD 8): "문맥을 볼게"

모두 동시에 분석 → 8개 의견을 합침!

💡 같은 Layer 내에서 다양한 관점!
💡 → 더 풍부한 표현 학습!

 

Layer의 역할

역할: 정보를 점진적으로 정제

Layer 1: "기본 단어 관계 파악"
  ↓
Layer 2: "문법 구조 파악"
  ↓
Layer 3: "의미 관계 파악"
  ↓
Layer 4: "문맥 이해"
  ↓
Layer 5: "뉘앙스 파악"
  ↓
Layer 6: "완전한 이해"

각 Layer는 이전 Layer의 출력이 필요!
→ 순차적(직렬)으로만 실행 가능!

💡 Layer가 깊어질수록 고수준 이해!

"sat" 토큰의 Attention 출력값 계산

다시 돌아와서, sat이라는 단어의 Attention 출력값까지의 계산 과정을 알아보겠습니다.

 

아래 계산 과정을 보시면, Q, K, V, Attention Score라는 생소한 단어들이 있음을 알 수 있습니다.

이는 3.1 Q, K, V, Attention Score란 무엇인가? 항목에서 학습할 수 있습니다.

 

사실 해당 항목에서 Attention Score 계산하는 과정을 상세 제시하였습니다. 따라서 해당 과정에 대한 설명은 생략하도록 하겠습니다.

 

(참고로, Attention Score은 α_The, α_cat, α_sat 입니다. Output = Σ (Attention Score × Value)은 Attention 출력값입니다)

 

시간에 따른 계산 과정:

시간 t=0에서 HEAD 1 ~ 8의 Attention Score가 동시에 계산됩니다.

시간 t=1에서는 Attention 출력값을 concat(연결하여) 16차원으로 변환합니다.

시간 t=2에서는 W_0와 선형결합하여 16차원을 4차원으로 변환합니다.

입력: X_sat = [1.4, 0.5, 0.8, 1.1]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
           시간 t=0 (동시에!)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HEAD 1 (주어-동사 관계 학습)
────────────────────────────
W_Q₁ = [[0.1, 0.3], [0.2, 0.1], [0.1, 0.2], [0.3, 0.1]]
W_K₁ = [[0.2, 0.1], [0.3, 0.4], [0.1, 0.2], [0.2, 0.3]]
W_V₁ = [[0.4, 0.2], [0.1, 0.3], [0.3, 0.1], [0.2, 0.4]]

Q_sat₁ = X_sat × W_Q₁ = [0.61, 0.69]

K_The₁ = X_The × W_K₁ = [0.76, 0.74]
K_cat₁ = X_cat × W_K₁ = [1.10, 1.06]
K_sat₁ = X_sat × W_K₁ = [0.81, 0.83]

Attention₁:
α_The = 29.2%,  α_cat = 39.6%,  α_sat = 31.2%
Output₁ = [1.180, 1.049]

HEAD 2 (시제 정보 학습)  ← HEAD 1과 동시에 계산!
────────────────────────────
W_Q₂ = [[0.3, 0.2], [0.1, 0.4], [0.4, 0.2], [0.2, 0.3]]  ← 다른 가중치!
W_K₂ = [[0.1, 0.4], [0.2, 0.3], [0.3, 0.1], [0.4, 0.2]]
W_V₂ = [[0.5, 0.3], [0.2, 0.4], [0.1, 0.5], [0.3, 0.2]]

Q_sat₂ = X_sat × W_Q₂ = [0.75, 0.82]  ← 다른 Query!

K_The₂ = X_The × W_K₂ = [0.68, 0.91]
K_cat₂ = X_cat × W_K₂ = [1.24, 1.38]
K_sat₂ = X_sat × W_K₂ = [0.93, 1.05]

Attention₂:
α_The = 25.1%,  α_cat = 42.3%,  α_sat = 32.6%  ← 다른 패턴!
Output₂ = [1.235, 1.104]

HEAD 3 (품사 정보 학습)  ← HEAD 1, 2와 동시에 계산!
────────────────────────────
W_Q₃ = [[0.2, 0.4], [0.3, 0.1], [0.1, 0.3], [0.4, 0.2]]
...
Output₃ = [1.152, 1.083]

HEAD 4, 5, 6, 7, 8도 모두 동시에 계산!
...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
           시간 t=1 (연결)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Concat:
[Output₁ | Output₂ | Output₃ | ... | Output₈]
= [1.180, 1.049, 1.235, 1.104, 1.152, 1.083, ..., 1.198, 1.067]
  ↑─ 2차원  ↑─ 2차원  ↑─ 2차원                    ↑─ 2차원

총 2×8 = 16차원

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
         시간 t=2 (출력 변환)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

W_O (16 → 4 차원으로 변환):
Output = Concat × W_O
       = [1.180, 1.049, ..., 1.067] × W_O
       = [1.2, 0.9, 0.7, 1.0]  ← Layer 1의 Attention 최종 출력

 

이 과정을 통해 8개의 Head가 서로 다른 관점에서 분석한 정보를 모두 활용하여, 하나의 풍부한 표현을 만들어냅니다.


2.3 Encoder 내 6개의 Layer 구조

 

 

위 그림에서 볼 수 있듯이, Layer 1의 출력은 Layer 2의 입력이 되고, 이것이 Layer 6까지 반복됩니다.

 

각 Layer는:

  1. Multi-Head Attention
  2. Add & Norm
  3. Feed-Forward
  4. Add & Norm

이 과정을 거치며, Layer가 깊어질수록 더 고수준의 의미를 이해하게 됩니다.


3. Scaled Dot-Product Attention과 Multi-Head Attention의 구조

 

 

Figure 2를 보시면 왼쪽이 Scaled Dot-Product Attention, 오른쪽이 Multi-Head Attention입니다.

 

Scaled Dot-Product Attention의 기본 수식

 

이 수식이 Attention의 핵심입니다. 하나씩 뜯어보겠습니다.


3.1 Q, K, V, Attention Score란 무엇인가?

 

직관적 비유

Attention 메커니즘을 이해하는 가장 쉬운 방법은 도서관에서 책을 찾는 과정에 비유하는 것입니다.

"The cat sat" 예시로 구체적 설명

상황: 당신이 도서관에서 "고양이 키우는 법"에 대한 책을 찾고 싶다!

┌─────────────────────────────┐
│           도서관              │
├─────────────────────────────┤
│                             │
│  책 1: "개 키우는 법"         │
│  책 2: "고양이 건강 관리"     │
│  책 3: "요리 레시피"          │
│  책 4: "고양이 행동 이해하기"  │
│                             │
└─────────────────────────────┘

당신의 질문 (Query):
"고양이 키우는 법에 대한 정보를 원합니다"
  ↓
각 책의 라벨/제목 (Key):
책1: "개", "반려동물", "훈련"
책2: "고양이", "건강", "병원"  ← 매칭!
책3: "음식", "레시피", "요리"
책4: "고양이", "행동", "심리"  ← 매칭!

매칭 점수 (Attention Score):
책1: 0.1 (10%) - "반려동물"만 약간 관련
책2: 0.5 (50%) - "고양이" 직접 관련!
책3: 0.0 (0%)  - 전혀 관련 없음
책4: 0.4 (40%) - "고양이" 관련!

각 책의 내용 (Value):
책2의 내용: "고양이는 독립적이고..."
책4의 내용: "고양이는 영역 동물로..."

최종 답변 (Attention 출력):
= 0.5 × "고양이는 독립적이고..." 
  + 0.4 × "고양이는 영역 동물로..."
= "고양이는 독립적이고 영역 동물로..."

💡 핵심 개념
┌────────────────────────────┐
│ Query: 내가 찾는 것 (질문)    │
│ Key: 각 항목의 라벨 (제목)    │
│ Value: 각 항목의 실제 내용 (본문) │
│ Attention Score: 관련도 점수  │
└────────────────────────────┘

"The cat sat" 예시로 상세 설명

이제 실제 문장에서 어떻게 동작하는지 봅시다.

상황: "sat"이 문장 내 다른 단어들과의 관계를 파악하고 싶음

┌──────────────────────────────────┐
│      문장: "The cat sat"          │
└──────────────────────────────────┘

Step 1: Query 생성 (내가 묻고 싶은 것)
───────────────────────────────────

"sat" 토큰이 묻는다: "나와 관련된 단어가 뭐야?"

원본: X_sat = [1.4, 0.5, 0.8, 1.1]
           ↓ (W_Q와 곱셈)
Query: Q_sat = [0.61, 0.69]

이 Query의 의미:
Q_sat = [0.61, 0.69]
        ↑     ↑
        "주어를 찾고 있음"
        "동작의 주체를 원함"

Step 2: Key 생성 (각 단어의 라벨)
───────────────────────────────────

모든 단어가 자신을 소개:

"The": X_The = [0.1, 1.2, 0.3, 1.1]
              ↓ (W_K와 곱셈)
       K_The = [0.76, 0.74]
       "나는 관사야, 별로 중요하지 않아"

"cat": X_cat = [1.6, 0.9, 0.3, 1.4]
              ↓ (W_K와 곱셈)
       K_cat = [1.10, 1.06]
       "나는 명사야, 주어 역할을 해!"  ← 중요!

"sat": X_sat = [1.4, 0.5, 0.8, 1.1]
              ↓ (W_K와 곱셈)
       K_sat = [0.81, 0.83]
       "나는 동사야, 자기 자신"

 

Key 벡터 정리

토큰 Key 벡터 의미
The [0.76, 0.74] "관사"
cat [1.10, 1.06] "명사, 주어" ← 가장 중요!
sat [0.81, 0.83] "동사, 자신"
Step 3: Attention Score 계산 (매칭 점수)
────────────────────────────────────────

Q_sat와 각 Key의 유사도 계산:

Score_The = Q_sat · K_The / √d_k
          = (0.61×0.76 + 0.69×0.74) / √2
          = (0.464 + 0.511) / 1.414
          = 0.689

Score_cat = Q_sat · K_cat / √d_k
          = (0.61×1.10 + 0.69×1.06) / √2
          = (0.671 + 0.731) / 1.414
          = 0.992  ◄─── 가장 높음! "cat"이 주어다!

Score_sat = Q_sat · K_sat / √d_k
          = (0.61×0.81 + 0.69×0.83) / √2
          = (0.494 + 0.573) / 1.414
          = 0.755

 

Attention Score 정리

토큰 Attention Score 의미
The 0.689 "조금 관련있음"
cat 0.992 "매우 관련있음!" ← MAX
sat 0.755 "중간 정도"
Softmax로 확률로 변환:
α_The = 29.2% - "The는 약간만 봐"
α_cat = 39.6% - "cat을 가장 많이 봐!" ◄─── 핵심!
α_sat = 31.2% - "자기 자신도 좀 봐"

Step 4: Value 생성 (실제 정보)
─────────────────────────────

각 단어가 제공하는 실제 정보:

"The": X_The × W_V = V_The = [0.83, 0.88]
       내용: "한정사 정보"

"cat": X_cat × W_V = V_cat = [1.51, 1.15]
       내용: "고양이, 명사, 주어, 3인칭 단수" ◄─── 중요!

"sat": X_sat × W_V = V_sat = [1.09, 1.08]
       내용: "앉다, 동사, 과거형"

Step 5: 최종 출력 (가중 평균)
─────────────────────────────

Output_sat = α_The × V_The + α_cat × V_cat + α_sat × V_sat
           = 0.292 × [0.83, 0.88] 
             + 0.396 × [1.51, 1.15]  ◄─── cat 정보 40%!
             + 0.312 × [1.09, 1.08]
           
           = [0.242, 0.257] + [0.598, 0.455] + [0.340, 0.337]
           = [1.180, 1.049]

이 벡터의 의미:
[1.180, 1.049]
 ↑      ↑
 "주어는 cat이다"
 "cat이 앉는 행동을 했다"

💡 "sat" 토큰이 이제 알게 된 것:
┌────────────────────────────────┐
│ "나의 주어는 cat이구나!" (40%)    │
│ "나 자신은 동사야" (31%)          │
│ "The는 별로 중요하지 않네" (29%)  │
└────────────────────────────────┘

 

이 과정을 통해 "sat"은 단순한 단어 벡터가 아니라, 문맥을 반영한 풍부한 표현으로 변환됩니다.


4. Transformer의 Decoder

 

Decoder는 Encoder가 생성한 의미 표현을 바탕으로 번역 문장을 순차적으로 생성합니다.

4.1 시점, HEAD, Layer의 관계 시각화

위 그림에서 볼 수 있듯이, Decoder는:

  • 시점 1: "<START>"만으로 "고양이가" 생성
  • 시점 2: "<START>, 고양이가"로 "앉았다" 생성
  • 시점 3: "<START>, 고양이가, 앉았다"로 "</END>" 생성

각 시점마다 Layer 1~6을 모두 거치며, 각 Layer에서 8개의 Head가 병렬로 동작합니다.


4.2 Decoder에서 Masked Self-Attention이 필요한 이유

 

마스킹이 필요한 이유

 

Decoder는 훈련할 때 전체 정답 문장을 이미 알고 있습니다. 하지만 실제 추론(번역) 시에는 한 단어씩 생성해야 합니다.

만약 훈련 시 미래 토큰을 볼 수 있다면, 모델은 "정답을 미리 보고" 학습하게 되어, 실전에서 제대로 동작하지 않습니다.

 

상황 1: 추론 (Inference) - 실전 번역

"The cat sat" → 번역하세요!

시점 1: [<START>] → ?
           ↓
       "고양이가" 생성
           ↓
시점 2: [<START>, 고양이가] → ?
           ↓
       "앉았다" 생성

→ 자연스럽게 순차적!
→ 미래 토큰이 없음! (아직 생성 안 됨)
→ 마스킹 불필요해 보임...🤔

 

상황 2: 훈련 (Training) 

"The cat sat" → "고양이가 앉았다" (정답 알고 있음!)

훈련 시에는 정답을 미리 알고 있습니다!
→ 모든 토큰 한꺼번에 입력합니다!

디코더에 한꺼번에 입력:
[<START>, 고양이가, 앉았다, </END>]

마스킹 없이 훈련한 경우

훈련 데이터:
입력: "The cat sat"
정답: "고양이가 앉았다"

디코더에 한꺼번에 입력:
[<START>, 고양이가, 앉았다, </END>]

Self-Attention 계산 (마스킹 없음):

 

Self-Attention 계산 (마스킹 없음) <START> 고양이가 앉았다 </END>
<START> 0.25 0.25 0.25 0.25
고양이가 0.20 0.30 0.40  0.10
앉았다 0.15 0.25 0.35  0.25
</END> 0.20 0.20 0.30 0.30
"고양이가" 위치에서:

Attention:
- <START>: 20%
- 고양이가: 30%
- 앉았다: 40%  ← 미래를 봄! ✗
- </END>: 10%

문제: "앉았다"를 미리 보고 "고양이가" 다음은 "앉았다"라고 쉽게 학습함!

→ 실전(추론)에서 "앉았다"가 없음!
→ 훈련과 추론이 다름!
→ 성능 폭망! ✗

 

실전 추론 시:

디코더 입력: [<START>, 고양이가]
"앉았다"가 없음!

모델: "어? 훈련 때처럼 '앉았다'를 보고 예측했는데... 지금은 없네? 뭘 예측하지?"

→ 엉뚱한 출력! ✗

마스킹 있는 상태에서 훈련한 경우

훈련 데이터:
입력: "The cat sat"
정답: "고양이가 앉았다"

디코더에 한꺼번에 입력:
[<START>, 고양이가, 앉았다, </END>]

Self-Attention 계산 (마스킹 있음):

 

 

Self-Attention 계산 (마스킹 있음) <START>
고양이가 앉았다 </END>
<START> 1.00  MASK MASK MASK
고양이가 0.60  0.40  MASK MASK
앉았다 0.30  0.35  0.35  MASK
</END> 0.25 0.25 0.25 0.25
"고양이가" 위치에서:

Attention:
- <START>: 60%
- 고양이가: 40%
- 앉았다: MASK (차단!) ✓
- </END>: MASK (차단!)

효과: "앉았다"를 못 보고 "<START>, 고양이가"만 보고 다음 토큰 예측!

→ 실전과 동일한 상황! ✓
→ 진짜 학습! ✓

 

실전 추론 시:

디코더 입력: [<START>, 고양이가]
훈련과 똑같은 상황!(미래 토큰 없음)

모델: "훈련 때처럼 과거만 보고 예측하면 되네!"

→ 정확한 출력! ✓

 

이것이 Masked Self-Attention의 핵심입니다. 훈련과 추론의 상황을 일치시켜 모델이 제대로 학습하고 추론할 수 있게 합니다.


4.3 시점 1: "고양이가" 생성하는 중

╔═══════════════════════════════════════════════╗
║          시점 1: 첫 번째 토큰 생성            ║
╚═══════════════════════════════════════════════╝

소스 문장: "The cat sat" (영어)
목표 번역: "고양이가 앉았다" (한국어)

현재 상황:
- 인코더 출력 Z: 이미 계산 완료 ✅
- 디코더에서 이미 생성: [<START>] ✅
- 디코더 입력: [<START>] ✅
- 이번에 생성할 토큰: "고양이가" ✅

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

인코더 출력 (영어 "The cat sat"):
Z = [z_The, z_cat, z_sat]
  = [[2.1, 0.8, -0.5, 1.2],   ← "The" 정보
     [3.5, 1.9,  0.7, 2.8],   ← "cat" 정보 (주어!)
     [2.8, 1.4,  0.3, 2.1]]   ← "sat" 정보 (동사!)

 

이제 디코더가 이 정보를 바탕으로 한국어 번역을 생성합니다.


Step 1: 디코더 입력 준비

1️⃣ 임베딩 + 위치 인코딩
─────────────────────

한국어 어휘:
<START> 임베딩: [0.9, 0.7, 0.5, 0.8]
위치 0 인코딩:  [0.0, 1.0, 0.0, 1.0]
<START> 최종:   [0.9, 1.7, 0.5, 1.8]

디코더 입력:
X = [X_<START>]
  = [[0.9, 1.7, 0.5, 1.8]]

Step 2: Masked Self-Attention (8개 헤드, HEAD 1만 상세)

╔═══════════════════════════════════════════════╗
║     서브 레이어 1: Masked Self-Attention      ║
╚═══════════════════════════════════════════════╝

목적: 디코더 내부 토큰들 간의 관계 파악 (미래 차단!)

━━━━━━━━━━━━━━━━━━━━━━━━━━━

HEAD 1 계산:

가중치 행렬:
W_Q₁ = [[0.2, 0.3],
        [0.1, 0.4],
        [0.3, 0.2],
        [0.4, 0.1]]

W_K₁ = [[0.3, 0.1],
        [0.4, 0.2],
        [0.2, 0.3],
        [0.1, 0.4]]

W_V₁ = [[0.5, 0.2],
        [0.3, 0.4],
        [0.2, 0.5],
        [0.4, 0.3]]

Step 2-1: Q, K, V 생성
──────────────────────

<START> 토큰:
Q_<START>₁ = X_<START> × W_Q₁
           = [0.9, 1.7, 0.5, 1.8] × W_Q₁
           = [0.9×0.2 + 1.7×0.1 + 0.5×0.3 + 1.8×0.4,
              0.9×0.3 + 1.7×0.4 + 0.5×0.2 + 1.8×0.1]
           = [0.18 + 0.17 + 0.15 + 0.72,
              0.27 + 0.68 + 0.10 + 0.18]
           = [1.22, 1.23]

K_<START>₁ = X_<START> × W_K₁
           = [0.9, 1.7, 0.5, 1.8] × W_K₁
           = [0.9×0.3 + 1.7×0.4 + 0.5×0.2 + 1.8×0.1,
              0.9×0.1 + 1.7×0.2 + 0.5×0.3 + 1.8×0.4]
           = [0.27 + 0.68 + 0.10 + 0.18,
              0.09 + 0.34 + 0.15 + 0.72]
           = [1.23, 1.30]

V_<START>₁ = X_<START> × W_V₁
           = [0.9, 1.7, 0.5, 1.8] × W_V₁
           = [0.9×0.5 + 1.7×0.3 + 0.5×0.2 + 1.8×0.4,
              0.9×0.2 + 1.7×0.4 + 0.5×0.5 + 1.8×0.3]
           = [0.45 + 0.51 + 0.10 + 0.72,
              0.18 + 0.68 + 0.25 + 0.54]
           = [1.78, 1.65]

 

정리:

토큰 Q K V
<START> [1.22,1.23] [1.23,1.30] [1.78,1.65]

 

Step 2-2: Attention Score 계산
──────────────────────────────

"<START>" 토큰의 Attention:
────────────────────────────

토큰이 하나뿐이므로 자기 자신만 봄!

Score_<START>_to_<START> = Q_<START>₁ · K_<START>₁ / √2
                         = (1.22×1.23 + 1.23×1.30) / 1.414
                         = (1.501 + 1.599) / 1.414
                         = 3.100 / 1.414
                         = 2.192

 

<START> ? Score
<START> 2.192

 

Softmax:
exp(2.192) = 8.953
총합 = 8.953
α_<START>_to_<START> = 8.953 / 8.953 = 1.0 (100%)

 

 

<START> → ? Attention Score
<START> 100%

 

Output_<START>₁ = 1.0 × V_<START>₁
                = 1.0 × [1.78, 1.65]
                = [1.78, 1.65]

HEAD 2~8도 동시에 계산...

Concat (16차원) → W_O 변환 (4차원):
Masked_Attn_Output_<START> = [1.75, 1.62, 0.88, 1.45]

Step 3: Encoder-Decoder Attention

이제 가장 중요한 부분입니다. 디코더가 인코더의 영어 정보를 활용하여 한국어 번역을 생성합니다.

╔═══════════════════════════════════════════════╗
║   서브 레이어 2: Encoder-Decoder Attention    ║
╚═══════════════════════════════════════════════╝

영어 소스 정보를 한국어 번역에 활용!

디코더 현재 상태 (Masked Self-Attn + Add&Norm 거친 후):
X'_<START> = [1.82, 1.68, 0.92, 1.51]

━━━━━━━━━━━━━━━━━━━━━━━━━━━

HEAD 1 계산:

가중치 행렬 (Encoder-Decoder용):
W_Q₁ = [[0.4, 0.3],
        [0.2, 0.5],
        [0.3, 0.4],
        [0.5, 0.2]]

W_K₁ = [[0.4, 0.3],
        [0.2, 0.5],
        [0.3, 0.4],
        [0.5, 0.2]]

W_V₁ = [[0.6, 0.3],
        [0.4, 0.5],
        [0.3, 0.6],
        [0.5, 0.4]]

Step 3-1: Query 생성 (디코더에서)
──────────────────────────────

Q_<START>₁ = X'_<START> × W_Q₁
           = [1.82, 1.68, 0.92, 1.51] × W_Q₁
           = [1.82×0.4 + 1.68×0.2 + 0.92×0.3 + 1.51×0.5,
              1.82×0.3 + 1.68×0.5 + 0.92×0.4 + 1.51×0.2]
           = [0.728 + 0.336 + 0.276 + 0.755,
              0.546 + 0.840 + 0.368 + 0.302]
           = [2.095, 2.056]

Step 3-2: Key와 Value 생성 (인코더 출력에서)
──────────────────────────────────────────

K_z_The₁ = z_The × W_K₁
         = [2.1, 0.8, -0.5, 1.2] × W_K₁
         = [2.1×0.4 + 0.8×0.2 + (-0.5)×0.3 + 1.2×0.5,
            2.1×0.3 + 0.8×0.5 + (-0.5)×0.4 + 1.2×0.2]
         = [0.840 + 0.160 - 0.150 + 0.600,
            0.630 + 0.400 - 0.200 + 0.240]
         = [1.450, 1.070]

K_z_cat₁ = z_cat × W_K₁
         = [3.5, 1.9, 0.7, 2.8] × W_K₁
         = [3.5×0.4 + 1.9×0.2 + 0.7×0.3 + 2.8×0.5,
            3.5×0.3 + 1.9×0.5 + 0.7×0.4 + 2.8×0.2]
         = [1.400 + 0.380 + 0.210 + 1.400,
            1.050 + 0.950 + 0.280 + 0.560]
         = [3.390, 2.840]

K_z_sat₁ = z_sat × W_K₁
         = [2.8, 1.4, 0.3, 2.1] × W_K₁
         = [2.8×0.4 + 1.4×0.2 + 0.3×0.3 + 2.1×0.5,
            2.8×0.3 + 1.4×0.5 + 0.3×0.4 + 2.1×0.2]
         = [1.120 + 0.280 + 0.090 + 1.050,
            0.840 + 0.700 + 0.120 + 0.420]
         = [2.540, 2.080]

V_z_The₁ = z_The × W_V₁
         = [2.1, 0.8, -0.5, 1.2] × W_V₁
         = [2.1×0.6 + 0.8×0.4 + (-0.5)×0.3 + 1.2×0.5,
            2.1×0.3 + 0.8×0.5 + (-0.5)×0.6 + 1.2×0.4]
         = [1.260 + 0.320 - 0.150 + 0.600,
            0.630 + 0.400 - 0.300 + 0.480]
         = [2.030, 1.210]

V_z_cat₁ = z_cat × W_V₁
         = [3.5, 1.9, 0.7, 2.8] × W_V₁
         = [3.5×0.6 + 1.9×0.4 + 0.7×0.3 + 2.8×0.5,
            3.5×0.3 + 1.9×0.5 + 0.7×0.6 + 2.8×0.4]
         = [2.100 + 0.760 + 0.210 + 1.400,
            1.050 + 0.950 + 0.420 + 1.120]
         = [4.470, 3.540]

V_z_sat₁ = z_sat × W_V₁
         = [2.8, 1.4, 0.3, 2.1] × W_V₁
         = [2.8×0.6 + 1.4×0.4 + 0.3×0.3 + 2.1×0.5,
            2.8×0.3 + 1.4×0.5 + 0.3×0.6 + 2.1×0.4]
         = [1.680 + 0.560 + 0.090 + 1.050,
            0.840 + 0.700 + 0.180 + 0.840]
         = [3.380, 2.560]

Step 3-3: "<START>" 토큰의 Attention
────────────────────────────────────

Q_<START>₁와 영어 소스의 매칭:

Score_to_The = Q_<START>₁ · K_z_The₁ / √2
             = (2.095×1.450 + 2.056×1.070) / 1.414
             = (3.038 + 2.200) / 1.414
             = 5.238 / 1.414 = 3.704

Score_to_cat = Q_<START>₁ · K_z_cat₁ / √2
             = (2.095×3.390 + 2.056×2.840) / 1.414
             = (7.102 + 5.839) / 1.414
             = 12.941 / 1.414 = 9.152  ◄─── 최대!

Score_to_sat = Q_<START>₁ · K_z_sat₁ / √2
             = (2.095×2.540 + 2.056×2.080) / 1.414
             = (5.321 + 4.276) / 1.414
             = 9.597 / 1.414 = 6.786

 

 

영어 소스 Score 의미
The 3.704 "관사, 별로..."
cat 9.152 "고양이다!" ← 최대!
sat 6.786 "동사..."

 

Softmax:
exp(3.704) = 40.6
exp(9.152) = 9420.4  
exp(6.786) = 886.9
총합 = 10347.9

α_<START>_to_The = 40.6 / 10347.9 = 0.004 (0.4%)
α_<START>_to_cat = 9420.4 / 10347.9 = 0.910 (91.0%)  
α_<START>_to_sat = 886.9 / 10347.9 = 0.086 (8.6%)

 

영어 소스 Attention 번역 의미
The 0.4% 무시
cat 91.0% "고양이" 번역! ← 핵심!
sat 8.6% 조금만

 

Context_<START>₁ = 0.004 × V_z_The₁ + 0.910 × V_z_cat₁ + 0.086 × V_z_sat₁
                 = 0.004 × [2.030, 1.210]
                   + 0.910 × [4.470, 3.540]  
                   + 0.086 × [3.380, 2.560]
                 = [0.008, 0.005]
                   + [4.068, 3.221]
                   + [0.291, 0.220]
                 = [4.367, 3.446]

💡 "<START>" 다음 토큰 생성 준비:
┌────────────────────────────────┐
│ "cat" 정보 91% 활용!            │
│ → "고양이가" 출력 예정! ✅       │
└────────────────────────────────┘

HEAD 2~8도 동시에 계산...

최종 Context Vector (W_O 변환 후):
<START>: [4.28, 3.38, 1.92, 2.76]

Step 4: Feed-Forward & 최종 출력

(Add & Norm, FFN, Add & Norm 반복... Layer 1 완료)
Layer 2~6 반복...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Layer 6까지 거친 후 → Linear + Softmax:

<START> 토큰의 최종 출력:
────────────────────────

디코더 Layer 6 출력: [4.12, 3.25, 1.85, 2.68]
            ↓
Linear 변환 (W_vocab, 한국어 어휘 10,000개):
            ↓
logits = h × W_vocab
       = [고양이가: 9.8, 개가: 3.2, 사람이: 2.1, ...]
            ↓
Softmax:
            ↓
P(고양이가) = exp(9.8) / Σ = 0.947 (94.7%)  ◄─── 선택!
P(개가) = exp(3.2) / Σ = 0.018 (1.8%)
P(사람이) = exp(2.1) / Σ = 0.006 (0.6%)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

✅ 출력: "고양이가"
✅ 생성 완료: [<START>, 고양이가]

4.4 시점 2: "앉았다" 생성하는 중

╔═══════════════════════════════════════════════╗
║          시점 2: 두 번째 토큰 생성            ║
╚═══════════════════════════════════════════════╝

소스 문장: "The cat sat" (영어)
목표 번역: "고양이가 앉았다" (한국어)

현재 상황:
- 인코더 출력 Z: 이미 계산 완료 ✅
- 디코더에서 이미 생성: [<START>, 고양이가] ✅
- 디코더 입력: [<START>, 고양이가] ✅
- 이번에 생성할 토큰: "앉았다" ✅

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

인코더 출력 (영어 "The cat sat" - 동일):
Z = [z_The, z_cat, z_sat]
  = [[2.1, 0.8, -0.5, 1.2],   ← "The" 정보
     [3.5, 1.9,  0.7, 2.8],   ← "cat" 정보 (주어!)
     [2.8, 1.4,  0.3, 2.1]]   ← "sat" 정보 (동사!)

 

이제 디코더가 이 정보를 바탕으로 한국어 번역을 생성합니다.


Step 1: 디코더 입력 준비

1️⃣ 임베딩 + 위치 인코딩
─────────────────────

한국어 어휘:
<START> 임베딩: [0.9, 0.7, 0.5, 0.8]
위치 0 인코딩:  [0.0, 1.0, 0.0, 1.0]
<START> 최종:   [0.9, 1.7, 0.5, 1.8]

"고양이가" 임베딩: [0.8, 0.6, 0.4, 0.7]  ← 한국어 토큰!
위치 1 인코딩:    [0.8, 0.5, 0.1, 0.9]
"고양이가" 최종:   [1.6, 1.1, 0.5, 1.6]

디코더 입력:
X = [X_<START>, X_고양이가]  ◄─── 둘 다!
  = [[0.9, 1.7, 0.5, 1.8],
     [1.6, 1.1, 0.5, 1.6]]

Step 2: Masked Self-Attention (8개 헤드, HEAD 1만 상세)

╔═══════════════════════════════════════════════╗
║     서브 레이어 1: Masked Self-Attention      ║
╚═══════════════════════════════════════════════╝

목적: 디코더 내부 토큰들 간의 관계 파악 (미래 차단!)

━━━━━━━━━━━━━━━━━━━━━━━━━━━

HEAD 1 계산:

가중치 행렬 (시점 1과 동일):
W_Q₁ = [[0.2, 0.3],
        [0.1, 0.4],
        [0.3, 0.2],
        [0.4, 0.1]]

W_K₁ = [[0.3, 0.1],
        [0.4, 0.2],
        [0.2, 0.3],
        [0.1, 0.4]]

W_V₁ = [[0.5, 0.2],
        [0.3, 0.4],
        [0.2, 0.5],
        [0.4, 0.3]]

Step 2-1: Q, K, V 생성
──────────────────────

<START> 토큰:
Q_<START>₁ = [1.22, 1.23]  (시점 1과 동일)
K_<START>₁ = [1.23, 1.30]
V_<START>₁ = [1.78, 1.65]

"고양이가" 토큰:
Q_고양이가₁ = X_고양이가 × W_Q₁
           = [1.6, 1.1, 0.5, 1.6] × W_Q₁
           = [1.22, 1.18]

K_고양이가₁ = X_고양이가 × W_K₁
           = [1.6, 1.1, 0.5, 1.6] × W_K₁
           = [1.18, 1.17]

V_고양이가₁ = X_고양이가 × W_V₁
           = [1.6, 1.1, 0.5, 1.6] × W_V₁
           = [1.87, 1.49]

 

정리:

토큰 Q₁  K₁ V₁
<START> [1.22,1.23] [1.23,1.30] [1.78,1.65]
고양이가 [1.22,1.18] [1.18,1.17] [1.87,1.49]

 

Step 2-2: Attention Score 계산 (마스킹 포함!)
────────────────────────────────────────────

"<START>" 토큰의 Attention:
────────────────────────────

(시점 1과 동일하게 계산되지만, 이번엔 "고양이가"가 존재!)

Score_<START>_to_<START> = 2.192
Score_<START>_to_고양이가 = 2.036  ← BUT 마스킹! -∞

Softmax:
α_<START>_to_<START> = 1.0 (100%)
α_<START>_to_고양이가 = 0.0 (0%)  ◄─── 미래라 차단!

Output_<START>₁ = 1.0 × [1.78, 1.65]
                = [1.78, 1.65]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

"고양이가" 토큰의 Attention:  ◄─── 핵심!
──────────────────────────────

Score_고양이가_to_<START> = Q_고양이가₁ · K_<START>₁ / √2
                         = (1.22×1.23 + 1.18×1.30) / 1.414
                         = (1.501 + 1.534) / 1.414
                         = 3.035 / 1.414
                         = 2.146

Score_고양이가_to_고양이가 = Q_고양이가₁ · K_고양이가₁ / √2
                         = (1.22×1.18 + 1.18×1.17) / 1.414
                         = (1.440 + 1.381) / 1.414
                         = 2.821 / 1.414
                         = 1.995

 

고양이가 → ? Score 마스킹?
<START> 2.146 NO (과거 시점, 볼 수 있음)
고양이가 1.995 NO (현재, 볼 수 있음)

 

Softmax:
exp(2.146) = 8.552
exp(1.995) = 7.350
총합 = 15.902

α_고양이가_to_<START> = 8.552 / 15.902 = 0.538 (53.8%)
α_고양이가_to_고양이가 = 7.350 / 15.902 = 0.462 (46.2%)

 

 

고양이가 → ? Attention Score
<START> 53.8% (시작 토큰 정보)
고양이가 46.2% (자기 자신)

 

Output_고양이가₁ = 0.538 × V_<START>₁ + 0.462 × V_고양이가₁
                = 0.538 × [1.78, 1.65] + 0.462 × [1.87, 1.49]
                = [0.958, 0.888] + [0.864, 0.688]
                = [1.822, 1.576]

HEAD 2~8도 동시에 계산...

Concat (16차원) → W_O 변환 (4차원):
Masked_Attn_Output_<START> = [1.75, 1.62, 0.88, 1.45]
Masked_Attn_Output_고양이가 = [1.79, 1.55, 0.85, 1.38]

Step 3: Encoder-Decoder Attention

╔═══════════════════════════════════════════════╗
║   서브 레이어 2: Encoder-Decoder Attention    ║
╚═══════════════════════════════════════════════╝

영어 소스 정보를 한국어 번역에 활용!

디코더 현재 상태 (Masked Self-Attn + Add&Norm 거친 후):
X'_<START> = [1.82, 1.68, 0.92, 1.51]
X'_고양이가 = [1.86, 1.61, 0.88, 1.44]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HEAD 1 계산:

Step 3-1: Query 생성 (디코더에서)
──────────────────────────────

Q_<START>₁ = [2.095, 2.056]  (시점 1과 동일)

Q_고양이가₁ = X'_고양이가 × W_Q₁
           = [1.86, 1.61, 0.88, 1.44] × W_Q₁
           = [2.050, 2.003]

Step 3-2: Key와 Value 생성 (인코더 출력에서)
──────────────────────────────────────────

(시점 1과 완전히 동일)
K_z_The₁ = [1.450, 1.070]
K_z_cat₁ = [3.390, 2.840]
K_z_sat₁ = [2.540, 2.080]

V_z_The₁ = [2.030, 1.210]
V_z_cat₁ = [4.470, 3.540]
V_z_sat₁ = [3.380, 2.560]

Step 3-3: "<START>" 토큰의 Attention
────────────────────────────────────

(시점 1과 동일)
α_<START>_to_The = 0.4%
α_<START>_to_cat = 91.0%
α_<START>_to_sat = 8.6%

Context_<START>₁ = [4.367, 3.446]

Step 3-4: "고양이가" 토큰의 Attention
─────────────────────────────────────

Q_고양이가₁와 영어 소스의 매칭:

Score_to_The = Q_고양이가₁ · K_z_The₁ / √2
             = (2.050×1.450 + 2.003×1.070) / 1.414
             = (2.973 + 2.143) / 1.414
             = 5.116 / 1.414 = 3.618

Score_to_cat = Q_고양이가₁ · K_z_cat₁ / √2
             = (2.050×3.390 + 2.003×2.840) / 1.414
             = (6.950 + 5.689) / 1.414
             = 12.639 / 1.414 = 8.938  ◄─── 여전히 높음!

Score_to_sat = Q_고양이가₁ · K_z_sat₁ / √2
             = (2.050×2.540 + 2.003×2.080) / 1.414
             = (5.207 + 4.166) / 1.414
             = 9.373 / 1.414 = 6.628

 

영어 소스 Score 의미
The 3.618 "관사..."
cat 8.938 "여전히 고양이!" ← 높음!
sat 6.628 "동사, 중요해짐"

 

Softmax:
exp(3.618) = 37.3
exp(8.938) = 7620.9  ◄─── 여전히 높음
exp(6.628) = 755.0
총합 = 8413.2

α_고양이가_to_The = 37.3 / 8413.2 = 0.004 (0.4%)
α_고양이가_to_cat = 7620.9 / 8413.2 = 0.906 (90.6%)  ◄─── 여전히!
α_고양이가_to_sat = 755.0 / 8413.2 = 0.090 (9.0%)  ◄─── 증가!

 

영어 소스 Attention 의미
The 0.4% 무시
cat 90.6% "고양이" 확정! ← 계속 집중!
sat 9.0% 조금 증가 (8.6%→9.0%)

 

Context_고양이가₁ = 0.004 × V_z_The₁ + 0.906 × V_z_cat₁ + 0.090 × V_z_sat₁
                 = 0.004 × [2.030, 1.210]
                   + 0.906 × [4.470, 3.540]
                   + 0.090 × [3.380, 2.560]
                 = [0.008, 0.005]
                   + [4.050, 3.207]
                   + [0.304, 0.230]
                 = [4.362, 3.442]

💡 "고양이가" 다음 토큰 생성 준비:
┌────────────────────────────────┐
│ cat 90.6% + sat 9.0% 혼합!     │
│ → "앉았다" 출력 예정! ✅        │
└────────────────────────────────┘

HEAD 2~8도 동시에 계산...

최종 Context Vector (W_O 변환 후):
<START>: [4.28, 3.38, 1.92, 2.76]
고양이가: [4.24, 3.35, 1.88, 2.72]

Step 4: Feed-Forward & 최종 출력

(Add & Norm, FFN, Add & Norm 반복... Layer 1 완료)
Layer 2~6 반복...

━━━━━━━━━━━━━━━━━━━━━━━━━━━

Layer 6까지 거친 후 → Linear + Softmax:

<START> 토큰의 최종 출력:
────────────────────────
(이미 "고양이가" 생성했으므로 무시)

고양이가 토큰의 최종 출력:  ◄─── 핵심!
──────────────────────────

디코더 Layer 6 출력: [4.08, 3.21, 1.81, 2.64]
            ↓
Linear 변환 (W_vocab, 한국어 어휘 10,000개):
            ↓
logits = h × W_vocab
       = [앉았다: 8.7, 뛰었다: 4.3, 먹었다: 3.1, ...]
            ↓
Softmax:
            ↓
P(앉았다) = exp(8.7) / Σ = 0.892 (89.2%)  ◄─── 선택!
P(뛰었다) = exp(4.3) / Σ = 0.067 (6.7%)
P(먹었다) = exp(3.1) / Σ = 0.021 (2.1%)

━━━━━━━━━━━━━━━━━━━━━━━━━━━

✅ 출력: "앉았다"
✅ 생성 완료: [<START>, 고양이가, 앉았다]

 

이렇게 시점 2에서 "앉았다"가 생성되었습니다.

 

주목할 점은:

  • Masked Self-Attention에서 "고양이가"는 과거인 "<START>"만 볼 수 있음
  • Encoder-Decoder Attention에서는 여전히 "cat"에 90.6% 집중하지만, "sat"의 비중이 8.6%→9.0%로 증가
  • 이 정보들을 종합하여 "앉았다"를 정확히 생성

5. 핵심 내용 정리

이 글에서 우리는 Transformer를 다음과 같이 완벽하게 뜯어보았습니다:

1. 구조적 이해

  • Transformer = Encoder (6 Layer) + Decoder (6 Layer)
  • 각 Layer마다 8개의 Head가 병렬로 동작
  • Head: 다양한 관점 (병렬), Layer: 깊은 이해 (직렬)

2. Attention 메커니즘의 핵심

  • Query (Q): 내가 찾는 정보
  • Key (K): 각 토큰의 라벨/특성
  • Value (V): 각 토큰의 실제 내용
  • Attention Score: Query와 Key의 유사도로 계산된 가중치
  • 수식: Attention(Q, K, V) = softmax(QK^T / √d_k) × V

3. Encoder의 동작

  • Multi-Head Attention으로 문장 내 모든 토큰 간의 관계 파악
  • 8개의 Head가 동시에 다른 관점에서 분석
  • 6개의 Layer를 거치며 점진적으로 깊은 의미 이해

4. Decoder의 특별함

  • Masked Self-Attention: 미래 토큰을 보지 못하게 차단하여 훈련-추론 일치
  • Encoder-Decoder Attention: 소스 언어 정보를 활용하여 타겟 언어 생성
  • 순차적 생성: 한 토큰씩 생성하며, 각 토큰은 이전 토큰들을 활용

5. 실제 계산 과정

"The cat sat" → "고양이가 앉았다" 번역 예시를 통해:

  • Encoder가 어떻게 영어 문장을 이해하는지
  • Decoder가 어떻게 한국어를 생성하는지
  • 각 시점마다 어떤 계산이 일어나는지

모든 수식과 함께 단계별로 확인했습니다.


6. 마치며

Transformer는 복잡해 보이지만, 핵심은 "Attention"입니다.

"어떤 정보가 중요한가?"를 학습하여, 문맥을 이해하고 적절한 출력을 생성하는 것.

 

이 글을 통해 Transformer의 모든 계산 과정을 따라가 보셨다면, 이제 여러분은 현대 AI의 핵심 메커니즘을 완전히 이해하신 것입니다.

 

수식이 많아 어려웠을 수 있지만, 각 수식이 "무엇을 계산하고, 왜 필요한지"를 이해하셨다면 충분합니다.

앞으로 BERT, GPT 등의 모델을 접하실 때, 이 기초 지식이 큰 도움이 될 것입니다.

 

긴 글 읽어주셔서 감사합니다!

 

질문이나 피드백은 댓글로 남겨주세요. 🙏

















 

+ Recent posts