개요

이 편은 SSAFY 시리즈 < 29 >실습편이다. 제목을 일부러 같게 달았다.

이론 (SSAFY 시리즈) 실습 (이 시리즈)
< 21 > GAN · Diffusion · Latent Diffusion · CLIP 6장 — SANA로 뽑고 CLIP으로 재본다
< 27 > CLIP을 자로 쓰기, VLM 6장 — CLIP vs ResNet-50 비교
< 29 > 남의 모델을 내 것으로 만드는 법 1~5장 — Linear Probing과 Fine-tuning

이론편에서는 그림으로 보고 넘어갔던 것들이다. 이번 편은 그걸 손으로 돌린다. 사전학습된 ResNet-18을 가져와 내 데이터에 맞게 고치고, 디퓨전 모델로 그림을 뽑고, CLIP한테 그 그림을 평가시킨다.

그리고 지난 < 10 >과 한 쌍이 된다. 거기서는 LLM으로 텍스트 데이터를 만들었는데, 이번엔 디퓨전으로 이미지 데이터를 만들어 그걸로 모델을 학습시킨다.

이번 글이 답할 질문 남이 학습시킨 모델을 어디까지 가져다 쓰고, 어디부터 내가 고쳐야 하나?

실습 자료는 SSAFY 교육 컨텐츠라 원본을 싣지 않는다. 개념만 가져와 예제는 새로 짰다. 다만 6장에 올린 여우 그림은 내가 직접 돌려 뽑은 결과물이라 그때 쓴 프롬프트를 그대로 적어둔다.


1. 전이학습 — 남이 배운 걸 어디까지 가져오나

전이학습은 이미 학습이 끝난 모델의 가중치를 그대로 가져와 내 문제에 쓰는 것이다.

왜 이게 되나. CNN이 이미지를 볼 때 앞쪽 층은 엣지, 색, 선, 질감 같은 걸 본다. 이건 여우 사진이든 자동차 사진이든 똑같이 필요한 것들이다. 뒤로 갈수록 “물체의 형태” 같은 구체적인 걸 보고, 마지막 분류층에 가서야 “이건 1000종 중 몇 번” 같은 태스크 전용 정보가 들어간다.

전이학습 구조

그래서 하는 일이 단순해진다. 앞은 그대로 두고 마지막 칸 수만 바꾼다.

import torchvision
import torch.nn as nn

# ImageNet 100만 장으로 학습이 끝난 ResNet-18
model = torchvision.models.resnet18(
    weights=torchvision.models.ResNet18_Weights.IMAGENET1K_V1
)

# 1000칸짜리 마지막 층을 10칸짜리 새 층으로 갈아끼운다
model.fc = nn.Linear(model.fc.in_features, 10)

model.fc.in_features를 쓰는 게 포인트다. ResNet-18의 마지막 층 입력이 몇 개인지 외울 필요 없이 원래 층에게 물어본 뒤 그 크기로 새 층을 만든다.

옛날 코드에서는 resnet18(pretrained=True)를 쓰는데, 이건 더 이상 권장되지 않는 방식이다. 지금은 weights=로 어떤 가중치를 쓸지 명시한다. 같은 모델에 학습 방식이 다른 가중치가 여러 벌 존재하기 때문이다.

더 쉬운 비유로

전이학습 — 남이 다 지어놓은 건물을 사서 간판만 바꿔 다는 것 Linear Probing — 건물은 절대 못 건드리고 간판만 다는 것 Fine-tuning — 간판 달고 나서 내부 인테리어도 조금씩 손보는 것

건물을 처음부터 짓는 게 밑바닥부터 학습이다. 땅 사고 기초 파고 골조 올리고… 돈도 시간도 엄청나게 든다. 그런데 위치 좋은 건물이 이미 서 있는데 굳이 그럴 이유가 없다. 어차피 벽이랑 배관은 카페를 하든 서점을 하든 똑같이 필요하니까.

두 전략을 정리하면 이렇다.

구분 Linear Probing Fine-tuning
학습하는 것 마지막 분류층만 전체 (또는 상위 일부)
학습 속도 빠르다 느리다
필요한 데이터 적어도 된다 많을수록 좋다
과적합 위험 낮다 높다
성능 상한 낮다 높다
학습률 상대적으로 크게 반드시 작게

전이학습은 얼마나 적게 건드리고 성능을 얻느냐의 싸움이다. 다 풀어놓고 학습시키는 건 언제나 가능하지만, 그건 남의 모델을 쓰는 의미가 절반쯤 사라진 것이다.


2. 동결 — 자물쇠가 두 개다

Linear Probing을 하려면 backbone이 안 배우게 막아야 한다. 여기서 처음 걸렸다.

막는 방법이 두 가지인데, 둘이 하는 일이 다르다.

# 자물쇠 A — 기울기를 아예 만들지 않는다
for name, param in model.named_parameters():
    if "fc" not in name:
        param.requires_grad = False

# 자물쇠 B — 갱신 대상 목록에서 뺀다
optimizer = optim.SGD(model.fc.parameters(), lr=0.001)

처음엔 둘이 같은 말인 줄 알았다. 아니다.

  • requires_grad = False — 역전파 때 그 파라미터의 기울기를 계산조차 안 한다. 계산을 건너뛰니 빨라지고 메모리도 덜 먹는다
  • optim.SGD(model.fc.parameters()) — 기울기는 만들어지지만 옵티마이저가 그걸 안 본다. 갱신은 안 되는데 계산은 그대로 한다

두 개의 자물쇠

그래서 하나만 걸면 이렇게 갈린다.

건 것 결과
requires_grad만 False 갱신은 안 된다. 동작은 맞는데 옵티마이저가 쓸데없이 전체 목록을 들고 있다
옵티마이저 범위만 좁힘 갱신은 안 된다. 그런데 backbone 기울기를 매 배치마다 계산하고 버린다
둘 다 계산도 안 하고 갱신도 안 한다 — 이게 정석이다
둘 다 안 함 전체가 학습된다. 이게 Fine-tuning이다

결과만 보면 셋 다 정확도가 비슷하게 나온다. 그래서 잘못 짜도 티가 안 난다. 차이는 속도와 메모리에서 난다. 이런 종류의 버그가 제일 무섭다..

자물쇠 두 개는 막는 지점이 다르다. 하나는 계산 단계, 하나는 갱신 단계. 결과가 같아 보인다고 아무거나 걸면 계산을 그냥 버리게 된다.


3. transform — 순서가 결과를 바꾼다

두 번째로 걸린 곳. 데이터 증강은 학습 이미지에 무작위 변형을 줘서 데이터를 억지로 다양하게 만드는 것이다. 좌우로 뒤집거나, 일부를 잘라내거나, 밝기를 흔든다.

train_transform = transforms.Compose([
    transforms.RandomCrop(32, padding=4),      # 원본 크기에서 자른다
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.Resize((224, 224)),             # 키우는 건 맨 마지막
    transforms.ToTensor(),
    transforms.Normalize(mean, std),
])

Compose적어놓은 순서대로 함수를 이어 붙여 실행한다. 그래서 순서가 곧 로직이다.

RandomCrop(32, padding=4)는 “가장자리에 4픽셀을 덧대고 거기서 32×32를 무작위로 오려낸다”는 뜻이다. 32×32짜리 원본에서 부르니까 이미지가 살짝 밀린 버전이 나온다. 이게 의도다.

그런데 Resize((224, 224))를 먼저 부르면 이야기가 달라진다.

transform 순서

224로 키워놓은 그림에서 32×32를 오려내니까 그림의 2%만 남는다. 여우 사진에서 코털 한 뭉치만 잘라놓고 “이건 여우야”라고 학습시키는 꼴이다.

정리하면 규칙 하나다.

증강은 원본 크기에서, 리사이즈는 맨 마지막에. Compose의 순서는 취향이 아니라 로직이다.

그리고 하나 더. CIFAR-10은 원래 32×32인데 이걸 224×224로 늘려서 쓴다. ResNet-18이 ImageNet 224 기준으로 학습돼서 그렇다. 32짜리를 7배로 늘리면 당연히 흐릿해지는데, 그래도 처음부터 학습하는 것보다 훨씬 낫다. 사전학습된 필터가 그만큼 강하다는 뜻이기도 하다.


4. Fine-tuning — 다 풀되 조심스럽게

Linear Probing으로 어느 정도 나왔으면 이제 동결을 푼다.

# 1. 전부 다시 학습 대상으로
for param in model.parameters():
    param.requires_grad = True

# 2. 학습률을 낮춘다 (0.001 -> 0.0005)
optimizer = optim.SGD(model.parameters(), lr=0.0005)

# 3. 5 에포크마다 학습률을 0.1배로
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)

여기서 학습률을 왜 낮추는지가 핵심이다.

backbone에 들어 있는 가중치는 이미 100만 장을 보고 다듬어진 것이다. 여기에 큰 학습률을 때리면 그 다듬어진 값이 한 번에 뭉개진다. 애써 산 건물을 사자마자 벽을 부수는 셈이다. 그래서 살살 밀어야 한다.

StepLR은 거기서 한 번 더 줄인다. 학습이 진행될수록 답에 가까워지니까, 처음엔 성큼성큼 가다가 나중엔 잔걸음으로 바꾸는 것이다.

단계 학습률 이유
Linear Probing 0.001 새 층은 난수 상태라 크게 움직여야 한다
Fine-tuning 초반 0.0005 사전학습 가중치를 보존하며 민다
Fine-tuning 후반 0.00005 StepLR이 5 에포크마다 0.1배로 깎는다

교재의 관찰 포인트는 Linear Probing만으로 70%대를 찍고, Fine-tuning + 증강을 얹으면 거기서 더 올라가는 것을 확인하라는 것이었다. 밑바닥부터 학습시켰다면 5 에포크로는 어림도 없는 숫자다.

학습률은 가진 것이 얼마나 아까운가로 정한다. 난수 층은 잃을 게 없으니 크게, 사전학습 층은 잃을 게 많으니 작게.


5. ViT — 이미지를 문장처럼 읽는다

3단계에서는 CNN이 아니라 Vision Transformer를 써본다.

발상이 재밌다. 224×224 이미지를 16×16짜리 조각 196개로 자른다. 그리고 그 조각 하나하나를 단어처럼 취급해서 Transformer에 넣는다. 지난 < 10 >에서 문장을 토큰으로 쪼개 넣은 것과 완전히 같은 구조다. 쪼개는 대상이 글자에서 그림으로 바뀌었을 뿐이다.

논문 제목이 아예 그렇다 — “An Image is Worth 16x16 Words”.

from transformers import ViTImageProcessor, ViTForImageClassification

model_name = "nateraw/vit-base-patch16-224-cifar10"
image_processor = ViTImageProcessor.from_pretrained(model_name)
vit_model = ViTForImageClassification.from_pretrained(model_name)

inputs = image_processor(images=sample_images, return_tensors="pt")
outputs = vit_model(**inputs)

ViTImageProcessor가 3장에서 손으로 짠 transform을 통째로 대신한다. 리사이즈도 정규화도 그 모델이 학습될 때 쓴 값 그대로 알아서 맞춰준다. 정규화 평균을 잘못 넣어 성능이 떨어지는 사고를 원천 차단하는 셈이다.

CNN과 ViT는 보는 방식이 다르다.

구분 CNN (ResNet) ViT
핵심 연산 Convolution Self-Attention
보는 범위 옆동네부터 차근차근 처음부터 전체를 한꺼번에
미리 심어둔 가정 강하다 (가까운 픽셀끼리 관련 있다) 약하다 (거의 아무것도 안 가정한다)
데이터가 적을 때 잘 버틴다 잘 안 된다
데이터가 많을 때 한계가 온다 계속 올라간다

가정이 약하다는 게 양날의 검이다. CNN은 “가까운 픽셀은 서로 관련 있다”를 처음부터 알고 시작하니까 적은 데이터로도 굴러간다. ViT는 그걸 모르니 데이터로 직접 배워야 한다. 대신 배우고 나면 CNN이 못 하는 먼 거리 관계까지 본다.

ViT는 이미지를 문장처럼 읽는다. < 10 >에서 본 Attention이 그대로 여기 들어와 있다.


6. 만든 이미지로 다시 학습시킨다

여기서 방향이 한 번 더 뒤집힌다. 모델로 이미지를 만들고, 그 이미지로 다시 모델을 학습시킨다.

< 10 >에서 LLM으로 텍스트 데이터를 만들었던 것과 정확히 같은 구조다. 만드는 대상만 이미지로 바뀌었다.

디퓨전으로 그림 뽑기

디퓨전 모델은 노이즈로 가득한 화면에서 시작해 노이즈를 조금씩 걷어내며 그림을 만든다. 조각가가 돌덩이에서 필요 없는 부분을 깎아내는 쪽에 가깝다.

from diffusers import SanaPipeline

pipe = SanaPipeline.from_pretrained(
    "Efficient-Large-Model/Sana_1600M_1024px_diffusers",
    torch_dtype=torch.float16,      # 16비트로 올려 메모리를 절반으로
)
pipe.to(device)

prompt = "A watercolor painting of a red fox sitting on a forest floor, vibrant autumn colors"

generator = torch.Generator(device=device).manual_seed(42)
image = pipe(
    prompt=prompt,
    guidance_scale=4.5,       # 프롬프트를 얼마나 세게 따를 것인가
    num_inference_steps=20,   # 노이즈를 몇 번에 걷어낼 것인가
    height=1024, width=1024,
    generator=generator,
).images[0]

실제로 나온 그림이다.

SANA로 생성한 수채화 여우

프롬프트 한 줄에서 이게 나온다는 게 매번 신기하다! 파라미터 세 개가 결과를 크게 좌우한다.

파라미터 올리면 내리면
guidance_scale 프롬프트에 충실해진다 자유롭지만 딴 그림이 나온다
num_inference_steps 곱게 다듬어진다, 대신 느리다 빠른 대신 거칠다
manual_seed (같은 값이면 같은 그림)

torch.Generator에 시드를 박는 이유가 여기 있다. 같은 프롬프트라도 시드가 다르면 다른 그림이 나온다. 그래서 학습 데이터를 여러 장 뽑을 땐 시드를 다르게 준다.

CLIP한테 물어보기

뽑았으면 제대로 뽑혔는지 확인해야 한다. < 10 >에서 LLM이 LLM을 채점했듯, 여기선 CLIP이 그림을 채점한다.

CLIP은 이미지와 문장을 같은 공간의 벡터로 바꾼다. 그러면 둘 사이 거리를 잴 수 있다.

$$ \mathrm{sim}(I, T) = \frac{v_I \cdot v_T}{\lVert v_I \rVert \lVert v_T \rVert} $$

from transformers import CLIPProcessor, CLIPModel

processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")

labels = [
    "a watercolor painting of a fox",   # 노린 것
    "an oil painting of a fox",         # 화풍만 다름
    "a photo of a fox",                 # 아예 사진
    "a watercolor painting of a dog",   # 동물만 다름
]

inputs = processor(text=labels, images=image, return_tensors="pt", padding=True)
probs = clip_model(**inputs).logits_per_image.softmax(dim=1)

오답 후보를 일부러 한 군데씩만 틀리게 만든 게 잘 짜인 부분이다. 화풍만 바꾼 것, 동물만 바꾼 것을 나란히 두면 CLIP이 무엇을 보고 판단했는지가 드러난다.

ResNet-50은 이걸 못 한다

같은 그림을 ImageNet ResNet-50에 넣으면 red fox, kit fox 같은 답이 나온다. 여우인 건 맞힌다. 그런데 수채화인지 사진인지는 절대 말 못 한다.

CLIP과 ResNet의 차이

ImageNet 1000칸 안에 “수채화”라는 칸이 없기 때문이다. 모델이 멍청해서가 아니라 대답할 수 있는 선택지가 처음부터 그렇게 정해져 있어서다.

ResNet은 정해진 칸 중에 고르고, CLIP은 내가 칸을 만들어 물어본다. SSAFY < 27 >에서 “CLIP을 자로 쓴다”고 한 게 이 얘기였다.

그리고 그 그림으로 학습시킨다

마지막 단계. 뽑아낸 이미지를 폴더로 나눠 담고, 1~2장에서 한 리니어 프로빙을 그대로 돌린다.

train_dataset = ImageFolder("data/train", transform=train_transforms)

model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
for param in model.parameters():
    param.requires_grad = False
model.fc = nn.Linear(model.fc.in_features, len(train_dataset.classes))

optimizer = optim.SGD(model.fc.parameters(), lr=0.001, momentum=0.9)

ImageFolder폴더 이름을 클래스 이름으로 읽는다. data/train/fox/, data/train/dog/ 로 담아두면 레이블을 따로 안 적어도 된다.

다만 솔직하게 말하면 이 단계는 성능 실험이 아니다. 클래스당 학습 2장, 테스트 2장짜리 데이터셋이다. 정확도 숫자에 의미를 두면 안 되고, 생성 → 폴더 구성 → 리니어 프로빙이 끊김 없이 돈다는 걸 확인하는 파이프라인 체험으로 봐야 맞다.

항목 이 실습 실제로 쓰려면
클래스당 장수 2장 최소 수백 장
시드 2개 장수만큼 전부 다르게
프롬프트 클래스당 1개 고정 배경·구도·조명을 바꿔가며 여러 개
검증 없음 CLIP 점수로 걸러낸 뒤 학습

합성 데이터는 만드는 것보다 거르는 게 어렵다. < 10 >에서 LLM 답을 Judge로 걸러냈던 자리에, 여기선 CLIP이 들어가야 한다.


정리

  • 전이학습은 앞을 그대로 두고 마지막 칸만 바꾸는 것이다. 엣지·색·질감은 어느 이미지에나 쓸모가 있고, 태스크 전용 정보는 마지막 층에만 있다
  • pretrained=True는 옛 방식이다. 지금은 weights=ResNet18_Weights.IMAGENET1K_V1처럼 어떤 가중치인지 명시한다
  • 동결 자물쇠는 두 개다. requires_grad=False는 계산을 막고, 옵티마이저 범위는 갱신을 막는다. 결과는 같아 보여도 하나만 걸면 계산을 버린다
  • Compose의 순서는 로직이다. 증강은 원본 크기에서, Resize는 맨 마지막에. 224로 키운 뒤 RandomCrop(32)를 부르면 그림의 2%만 남는다
  • Fine-tuning은 학습률을 낮춰서 한다. 사전학습 가중치는 잃을 게 많아서 살살 밀어야 하고, StepLR로 뒤로 갈수록 더 깎는다
  • ViT는 이미지를 16×16 조각 196개로 쪼개 문장처럼 읽는다. 가정이 약해서 데이터가 적으면 불리하고, 많으면 CNN보다 멀리 간다
  • CLIP은 칸을 내가 만들고, ResNet은 정해진 칸에서 고른다. 그래서 CLIP은 화풍까지 묻고, ResNet-50은 여우 종류까지만 답한다
  • 생성 → 평가 → 학습이 한 바퀴로 돈다. < 10 >의 텍스트 합성 데이터와 같은 구조고, 여기서도 거르는 단계가 빠지면 안 된다

참고 자료

한줄 평

  • 남의 모델을 내 것으로 만드는 건 결국 어디를 잠그고 어디를 풀지 정하는 일이었다 -> 다 풀어버리면 편하긴 한데, 그럼 남의 걸 가져온 이유가 없어진다!