InternVideo2를 RAG 파이프라인에 통합하며 발견한 것: ITC Embedding Collapse 진단기
InternVideo2의 ITC text embedding이 cosine ≈ 0.9997로 collapse되는 현상을 발견했고, 레이어별 측정으로 원인을 token embedding 단계까지 추적했다. text_proj는 무관하며, mean pooling으로 우회 가능하다.
배경: 우리가 만들던 것
정부 R&D 과제의 일환으로 VideoRAG라는 시스템을 만들고 있었습니다. 이는 자연어 쿼리로 방송 영상 아카이브를 검색하고, 검색된 클립을 조립해 최종 영상을 만드는 PD 워크스테이션입니다.
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를 씁니다.
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 수준을 유지합니다.
결과: 위와 같이 벡터들이 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%를 달성했습니다.
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 — 노이즈 경로 추적
DDIMInverter가 z₀에서 출발해서 타임스텝을 역순으로 밟는다. t=0(깨끗한 상태)에서 t=T(노이즈 가득한 상태) 방향으로, 매 스텝마다 노이즈를 조금씩 더하면서 올라간다.
이 과정이 단순한 랜덤 노이즈 추가와 다른 이유가 있다. 랜덤 노이즈 추가는 이렇다:
z_noisy = z₀ + torch.randn_like(z₀) * sigma
가우시안 노이즈를 아무 방향으로나 더한다. 이렇게 만든 z_noisy에서 denoising을 시작하면 원본과 완전히 다른 이미지로 갈 수 있다.
DDIM Inversion은 다르다. 매 스텝에서 UNet을 실제로 돌려서, 현재 latent에서 예측한 노이즈 방향으로만 이동한다. forward process의 업데이트 식은 이렇다:
이 두 경로가 같은 궤적이다. 이상적으로는 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 결과가 크게 다르면 이후 편집 품질도 나빠지기 때문에, 여기서 먼저 확인하고 스텝 수나 파라미터를 조정한다.
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한 버전으로 계산하게 만든다.
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)가 모든 TokenFlowBlock의 pivotal_pass = True를 세팅한다. 그 다음 피벗 프레임들만 UNet을 통과시킨다.
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는 layer normalization을 통과한 hidden state다. normalization을 하는 이유는 값 분포를 평균 0, 분산 1로 정규화해서 attention 계산을 안정적으로 하기 위해서다. shape의 첫 번째 차원 3은 CFG를 위한 3× batch(source/unconditional/conditional)다. 이걸 self.pivot_hidden_states에 저장한다. 나중에 비피벗 프레임이 유사도를 계산할 때 비교 대상으로 쓰인다.
attn1은 self-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_output을 self.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로 실행된다. 점진적으로 누적되는 게 아니라, 각 타임스텝 안에서 독립적으로 완결된다.
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)을 더하는 것이다.
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을 맞춘다.
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 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 레이어에서는 편집된 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를 불러와서 시작합니다. 원본 구조를 잃지 않기 위해서죠.
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) # 앞에 추가 (순서 맞추기)
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)
# 모든 픽셀에 대해 동일한 과정
# 코 영역
(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) - 밝은 베이지
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을 쓰는 이유는 배치 크기와 무관하게 안정적이기 때문이다. 이미지 생성 모델은 배치 크기가 작은 경우가 많다.
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)
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]))
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, ...]
텍스트 특징 벡터 ← Text Encoder가 만들어둔 것
↓
Transformer (autoregressive)
- 텍스트 조건을 참고하면서
- Codebook 인덱스를 하나씩 예측
- [87, 87, 91, 96, ..., 234, ..., 456]
↓
Codebook Lookup
- 인덱스 → 벡터로 변환
↓
Decoder → 이미지
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)
↓
딕셔너리 반환
단순 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 동적 밸런싱
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)
# 실제로는 여러 문장을 동시에 처리
문장 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차원 벡터로 변환해서 "의미"를 담습니다.
# 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]
[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"의 새로운 표현 생성!
안녕하세요. 오늘은 현대 자연어 처리(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의 모든 것을 이해하실 수 있을 것입니다.
목차
Transformer의 구조
Transformer의 Encoder
2.1 Encoder에서 Layer와 Head의 관계
2.2 Layer 1의 내부 구조
2.3 Encoder 내 6개의 Layer 구조
Scaled Dot-Product Attention과 Multi-Head Attention의 구조
3.1 Q, K, V, Attention Score란 무엇인가?
Transformer의 Decoder
4.1 시점, HEAD, Layer의 관계 시각화
4.2 Decoder에서 Masked Self-Attention이 필요한 이유
4.3 시점 1: "고양이가" 생성하는 중
4.4 시점 2: "앉았다" 생성하는 중
핵심 내용 정리
마치며
1. Transformer의 구조
Figure 1. 트랜스포머 - 모델 아키텍처
Transformer는 크게 Encoder와 Decoder 두 부분으로 나뉩니다.
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 계산하는 과정을 상세 제시하였습니다. 따라서 해당 과정에 대한 설명은 생략하도록 하겠습니다.
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]]