이미지를 패치(patch)로 자르고, 각 패치를 단어처럼 다루고, 표준 트랜스포머(transformer)를 실행합니다. 뒤돌아보지 않습니다.
유형: Build
언어: Python
선수 학습: Phase 7 Lesson 02 (Self-Attention), Phase 4 Lesson 04 (Image Classification)
소요 시간: 약 45분
학습 목표
- 패치 임베딩(patch embedding), 학습 가능한 위치 임베딩(learned positional embedding), 클래스 토큰(class token), 트랜스포머 인코더 블록(transformer encoder block)을 직접 구현해 최소 형태의 ViT를 만듭니다.
- ViT가 왜 DeiT와 MAE 이전에는 대규모 사전학습 데이터(pretraining data)가 필요하다고 여겨졌는지 설명합니다.
- ViT, Swin, ConvNeXt의 구조적 사전 가정(architectural prior)을 비교합니다. 각각 사전 가정 없음, 지역 윈도우 어텐션(local window attention), 합성곱 백본(conv backbone)에 해당합니다.
timm을 사용해 사전학습된 ViT를 작은 데이터셋(dataset)에 미세 조정(fine-tuning)하고 표준 선형 탐침/미세 조정(linear-probe / fine-tune) 레시피를 적용합니다.
문제
지난 10년 동안 합성곱(convolution)은 컴퓨터 비전(computer vision)과 거의 동의어였습니다. CNN은 지역성(locality)과 이동 등변성(translation equivariance)이라는 강한 귀납적 편향(inductive bias)을 가지고 있었고, 이를 대체할 수 있다고 생각한 사람은 많지 않았습니다. 그러다 Dosovitskiy et al.(2020)은 평탄화된 이미지 패치(flattened image patch)에 합성곱 장치가 전혀 없는 평범한 트랜스포머를 적용해도, 규모(scale)가 충분하다면 최고 수준의 CNN과 비슷하거나 더 나은 성능을 낼 수 있음을 보였습니다.
문제는 바로 "규모가 충분하다면"이라는 단서였습니다. ImageNet-1k에서 ViT는 ResNet에 졌습니다. 하지만 ImageNet-21k나 JFT-300M에서 사전학습(pretrain)한 뒤 ImageNet-1k에서 미세 조정하면 이겼습니다. 결론은 트랜스포머에는 유용한 사전 가정이 부족하지만, 데이터가 충분하면 이를 데이터에서 배울 수 있다는 것이었습니다. 이후 DeiT, MAE, DINO는 강한 데이터 증강(augmentation), 자기지도 사전학습(self-supervised pretraining), 지식 증류(distillation) 같은 올바른 학습 레시피(training recipe)가 있으면 ViT도 작은 데이터에서 잘 학습된다는 것을 보였습니다.
2026년에도 엣지 디바이스(edge device)에서는 순수 CNN이 여전히 경쟁력이 있습니다. 특히 ConvNeXt가 강합니다. 하지만 나머지 대부분 영역에서는 트랜스포머가 지배적입니다. 분할(segmentation; Mask2Former, SegFormer), 객체 검출(detection; DETR, RT-DETR), 멀티모달(multimodal; CLIP, SigLIP), 비디오(video; VideoMAE, VJEPA)가 모두 그렇습니다. 반드시 알아야 할 핵심 구조는 ViT 블록입니다.
개념
파이프라인(Pipeline)
flowchart LR
IMG["Image<br/>(3, 224, 224)"] --> PATCH["Patch embedding<br/>conv 16x16 s=16<br/>-> (768, 14, 14)"]
PATCH --> FLAT["Flatten to<br/>(196, 768) tokens"]
FLAT --> CAT["Prepend<br/>[CLS] token"]
CAT --> POS["Add learned<br/>positional embed"]
POS --> ENC["N transformer<br/>encoder blocks"]
ENC --> CLS["Take [CLS]<br/>token output"]
CLS --> HEAD["MLP classifier"]
style PATCH fill:#dbeafe,stroke:#2563eb
style ENC fill:#fef3c7,stroke:#d97706
style HEAD fill:#dcfce7,stroke:#16a34a
총 일곱 단계입니다. 패치 → 토큰 → 어텐션 → 분류기로 흐릅니다. DeiT, Swin, ConvNeXt, MAE 사전학습 같은 모든 변형(variant)은 이 일곱 단계 중 한두 개만 바꾸고 나머지는 그대로 유지합니다.
패치 임베딩(Patch embedding)
첫 합성곱 연산이 핵심입니다. 커널 크기(kernel size) 16, 스트라이드(stride) 16이므로 224x224 이미지는 16x16 패치들의 14x14 격자(grid)가 되고, 각 패치는 768차원 임베딩(embedding)으로 투영(projection)됩니다. 이 단일 합성곱이 패치화(patchify)와 선형 투영(linear projection)을 동시에 수행합니다.
Input: (3, 224, 224)
Conv (3 -> 768, k=16, s=16, no padding):
Output: (768, 14, 14)
Flatten spatial: (196, 768)
196개 패치 = 196개 토큰입니다. 각 토큰의 특징 차원(feature dimension)은 ViT-B에서는 768, ViT-L에서는 1024, ViT-H에서는 1280입니다.
클래스 토큰(Class token)
시퀀스(sequence) 맨 앞에 붙는 하나의 학습 가능한 벡터(learned vector)입니다.
tokens = [CLS; patch_1; patch_2; ...; patch_196] shape (197, 768)
N개의 트랜스포머 블록을 지나면 [CLS]의 출력이 이미지 전체에 대한 전역 표현(global image representation)이 됩니다. 분류 헤드(classification head)는 이 벡터 하나만 읽습니다.
위치 임베딩(Positional embedding)
트랜스포머는 공간적 위치(spatial position)를 기본적으로 알지 못합니다. 따라서 모든 토큰에 학습 가능한 벡터를 더해줍니다.
tokens = tokens + learned_pos_embedding (also shape (197, 768))
이 임베딩은 모델 파라미터(parameter)이고, 경사 기반 학습(gradient-based training)이 이를 2차원 이미지 구조에 맞게 조정합니다. 사인파 형태의 2차원 대안(sinusoidal 2D alternative)도 있지만 실무에서는 학습 가능한 위치 임베딩이 더 흔하게 쓰입니다.
표준 구조입니다. 다중 헤드 셀프 어텐션(multi-head self-attention), MLP, 잔차 연결(residual connection), 사전 LayerNorm(pre-LayerNorm)으로 구성됩니다.
x = x + MSA(LN(x))
x = x + MLP(LN(x))
MLP is two-layer with GELU: Linear(d -> 4d) -> GELU -> Linear(4d -> d)
ViT-B/16은 이런 블록 12개를 쌓고, 각 블록에는 어텐션 헤드(attention head)가 12개 들어 있으며, 전체 파라미터는 약 86M입니다.
Pre-LN을 쓰는 이유
초기 트랜스포머는 사후 LayerNorm(post-LN; x = LN(x + sublayer(x)))을 사용했고, 학습률 워밍업(warmup) 없이는 6~8 레이어를 넘어 안정적으로 학습하기 어려웠습니다. 사전 LayerNorm(pre-LN; x = x + sublayer(LN(x)))은 깊은 신경망을 워밍업 없이도 안정적으로 학습시킵니다. 모든 ViT와 현대 LLM은 pre-LN을 사용합니다.
패치 크기 절충(Patch size trade-off)
- 16x16 패치 → 196개 토큰, 표준 설정입니다.
- 32x32 패치 → 49개 토큰, 빠르지만 해상도가 낮습니다.
- 8x8 패치 → 784개 토큰, 더 섬세하지만 어텐션 비용(attention cost)이 O(n^2)로 증가해 불리해집니다.
큰 패치는 토큰 수가 적어 빠르지만 공간 디테일(spatial detail)을 잃습니다. SwinV2는 계층적 윈도우(hierarchical window) 안에서 4x4 패치를 사용합니다.
ImageNet-1k에서 ViT를 학습시킨 DeiT 레시피
원래 ViT는 CNN을 이기기 위해 JFT-300M이 필요했습니다. DeiT(Touvron et al., 2020)는 네 가지 변화만으로 ImageNet-1k만 사용해 ViT-B top-1 81.8%를 달성했습니다.
- 강한 데이터 증강(heavy augmentation): RandAugment, Mixup, CutMix, Random Erasing.
- 확률적 깊이(stochastic depth): 학습 중 블록 전체를 무작위로 떨어뜨립니다(drop).
- 반복 증강(repeated augmentation): 같은 이미지를 배치(batch)마다 3번 샘플링합니다.
- CNN 교사로부터의 지식 증류(distillation from a CNN teacher): 선택 사항이지만 정확도를 더 끌어올립니다.
현대 ViT 학습 레시피는 거의 모두 DeiT에서 이어집니다.
Swin과 ConvNeXt 비교
- Swin(Liu et al., 2021) — 윈도우 기반 어텐션(window-based attention)을 사용합니다. 각 블록은 지역 윈도우 안에서 어텐션을 수행하고, 교대로 등장하는 블록은 윈도우를 이동(shift)시켜 윈도우 사이의 정보를 섞습니다. 어텐션 연산자(attention operator)는 유지하면서 CNN과 비슷한 지역성 사전 가정을 다시 도입한 방식입니다.
- ConvNeXt(Liu et al., 2022) — Swin의 구조 선택(깊이별 합성곱(depthwise conv), LayerNorm, GELU, 역병목(inverted bottleneck))에 맞춰 다시 설계된 CNN입니다. 성능 격차가 "어텐션 대 합성곱"이 아니라 "현대적 학습 레시피 + 구조"에서 비롯됨을 보여주었습니다.
2026년 기준 ConvNeXt-V2와 Swin-V2는 모두 프로덕션(production)에서 쓸 수 있는 수준입니다. 어느 쪽을 택할지는 추론 스택(inference stack), 엣지 컴파일(edge compilation)에 ConvNeXt가 더 유리한지 여부, 그리고 사전학습 코퍼스(pretraining corpus)에 따라 달라집니다.
MAE 사전학습
마스킹된 오토인코더(Masked Autoencoder; MAE)(He et al., 2022)는 패치의 75%를 무작위로 마스킹(mask)하고, 인코더(encoder)는 보이는 25%만 처리하게 하며, 작은 디코더(decoder)가 마스킹된 패치를 복원(reconstruct)하도록 학습합니다. 사전학습이 끝나면 디코더는 버리고 인코더만 미세 조정합니다.
MAE는 ViT를 ImageNet-1k만으로도 잘 학습되게 만들었고, SOTA를 달성했으며, 현재 기본적으로 쓰이는 자기지도 학습 레시피 중 하나입니다.
만들어보기
Step 1: 패치 임베딩
import torch
import torch.nn as nn
class PatchEmbedding(nn.Module):
def __init__(self, in_channels=3, patch_size=16, dim=192, image_size=64):
super().__init__()
assert image_size % patch_size == 0
self.proj = nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size)
num_patches = (image_size // patch_size) ** 2
self.num_patches = num_patches
def forward(self, x):
x = self.proj(x)
return x.flatten(2).transpose(1, 2)
합성곱 한 번, 평탄화(flatten) 한 번, 전치(transpose) 한 번이 이미지에서 토큰으로 가는 전체 단계입니다.
Step 2: 트랜스포머 블록
pre-LN, 다중 헤드 셀프 어텐션, GELU를 사용하는 MLP, 잔차 연결로 구성됩니다.
class Block(nn.Module):
def __init__(self, dim, num_heads, mlp_ratio=4, dropout=0.0):
super().__init__()
self.ln1 = nn.LayerNorm(dim)
self.attn = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True)
self.ln2 = nn.LayerNorm(dim)
self.mlp = nn.Sequential(
nn.Linear(dim, dim * mlp_ratio),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(dim * mlp_ratio, dim),
nn.Dropout(dropout),
)
def forward(self, x):
a, _ = self.attn(self.ln1(x), self.ln1(x), self.ln1(x), need_weights=False)
x = x + a
x = x + self.mlp(self.ln2(x))
return x
nn.MultiheadAttention은 헤드 분할, 스케일드 내적(scaled dot-product), 출력 투영(output projection)을 처리합니다. batch_first=True이므로 텐서 형상(shape)은 (N, seq, dim)입니다.
Step 3: ViT 본체
class ViT(nn.Module):
def __init__(self, image_size=64, patch_size=16, in_channels=3,
num_classes=10, dim=192, depth=6, num_heads=3, mlp_ratio=4):
super().__init__()
self.patch = PatchEmbedding(in_channels, patch_size, dim, image_size)
num_patches = self.patch.num_patches
self.cls_token = nn.Parameter(torch.zeros(1, 1, dim))
self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, dim))
self.blocks = nn.ModuleList([
Block(dim, num_heads, mlp_ratio) for _ in range(depth)
])
self.ln = nn.LayerNorm(dim)
self.head = nn.Linear(dim, num_classes)
nn.init.trunc_normal_(self.pos_embed, std=0.02)
nn.init.trunc_normal_(self.cls_token, std=0.02)
def forward(self, x):
x = self.patch(x)
cls = self.cls_token.expand(x.size(0), -1, -1)
x = torch.cat([cls, x], dim=1)
x = x + self.pos_embed
for blk in self.blocks:
x = blk(x)
x = self.ln(x[:, 0])
return self.head(x)
vit = ViT(image_size=64, patch_size=16, num_classes=10, dim=192, depth=6, num_heads=3)
x = torch.randn(2, 3, 64, 64)
print(f"출력: {vit(x).shape}")
print(f"파라미터 수: {sum(p.numel() for p in vit.parameters()):,}")
약 280만 개 파라미터를 가진 작은 ViT입니다. CPU에서도 다룰 수 있습니다. 실제 ViT-B는 dim=768, depth=12, num_heads=12 설정을 쓰며, 같은 클래스 정의로 약 86M개 파라미터가 됩니다.
Step 4: 동작 점검(Sanity check) — 단일 이미지 추론
logits = vit(torch.randn(1, 3, 64, 64))
print(f"로짓: {logits}")
print(f"확률: {logits.softmax(-1)}")
오류 없이 실행되어야 합니다. 출력된 확률(probability)의 합은 1이 됩니다.
활용하기
timm은 ImageNet에서 사전학습된 가중치(weight)와 함께 사실상 거의 모든 ViT 변형을 제공합니다. 한 줄이면 됩니다.
import timm
model = timm.create_model("vit_base_patch16_224", pretrained=True, num_classes=10)
timm은 2026년 기준 비전 트랜스포머의 프로덕션 표준 라이브러리입니다. ViT, DeiT, Swin, Swin-V2, ConvNeXt, ConvNeXt-V2, MaxViT, MViT, EfficientFormer 등 수많은 모델을 동일한 API로 지원합니다.
이미지와 텍스트를 함께 다루는 멀티모달 작업에는 transformers의 CLIP, SigLIP, BLIP-2, LLaVA를 사용합니다. 이들의 이미지 인코더는 모두 ViT 변형입니다.
산출물 만들기
이 레슨의 최종 산출물은 다음과 같습니다.
outputs/prompt-vit-vs-cnn-picker.md — 데이터셋 크기, 연산 자원(compute), 추론 스택을 바탕으로 ViT, ConvNeXt, Swin 중 하나를 고르도록 도와주는 프롬프트입니다.
outputs/skill-vit-patch-and-pos-embed-inspector.md — ViT의 패치 임베딩과 위치 임베딩 형상(shape)이 모델이 기대하는 시퀀스 길이(sequence length)와 일치하는지 검증해 흔히 발생하는 포팅(porting) 버그를 잡는 스킬입니다.
연습문제
- (쉬움) 위 작은 ViT의 순전파(forward pass) 과정에서 모든 중간 텐서의 형상을 출력해 봅니다. 입력
(N, 3, 64, 64) → 패치 (N, 16, 192) → CLS 포함 (N, 17, 192) → 분류기 입력 (N, 192) → 출력 (N, num_classes) 순서를 확인합니다.
- (중간) Lesson 4의 합성 CIFAR(synthetic-CIFAR) 데이터셋에서 사전학습된
timm ViT-S/16을 미세 조정합니다. 같은 데이터에서 ResNet-18 미세 조정 결과와 비교하고, 학습 시간(training time)과 최종 정확도(final accuracy)를 보고합니다.
- (어려움) 작은 ViT에 대해 MAE 사전학습을 직접 구현합니다. 패치의 75%를 마스킹하고, 인코더와 작은 디코더가 마스킹된 패치를 복원하도록 학습합니다. 사전학습 전후로 합성 데이터에서의 선형 탐침(linear-probe) 정확도를 평가합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 패치 임베딩(Patch embedding) | "첫 합성곱" | 커널 크기 = 스트라이드 = 패치 크기인 합성곱입니다. 이미지를 토큰 임베딩 격자로 바꿉니다. |
| 클래스 토큰(Class token) | "[CLS]" | 토큰 시퀀스 앞에 붙는 학습 가능한 벡터입니다. 최종 출력이 이미지 전체의 전역 표현이 됩니다. |
| 위치 임베딩(Positional embedding) | "학습된 위치(learned pos)" | 트랜스포머가 각 패치의 위치를 알 수 있도록 모든 토큰에 더하는 학습 가능한 벡터입니다. |
| Pre-LN | "서브 레이어 앞의 LayerNorm" | 안정적인 트랜스포머 변형입니다. LN(x + sublayer(x)) 대신 x + sublayer(LN(x))를 씁니다. |
| 다중 헤드 어텐션(Multi-head attention) | "병렬 어텐션" | 표준 트랜스포머 어텐션을 여러 독립적인 부분 공간(subspace)으로 나눈 뒤 다시 이어붙이는(concat) 방식입니다. |
| ViT-B/16 | "Base, 패치 16" | 정규(canonical) 크기 설정입니다. dim=768, depth=12, heads=12, patch_size=16, image=224, 약 86M 파라미터입니다. |
| DeiT | "데이터 효율적인 ViT" | 강한 데이터 증강을 사용해 ImageNet-1k만으로 학습한 ViT입니다. 대규모 사전학습 데이터셋이 절대 필수는 아님을 보였습니다. |
| MAE | "마스킹된 오토인코더" | 패치의 75%를 마스킹하고 복원하는 자기지도 사전학습 방식입니다. 현재 ViT 사전학습에서 가장 지배적인 레시피입니다. |
더 읽을거리