개념
분류 파이프라인(Classification pipeline)
flowchart LR
A["Dataset<br/>(images + labels)"] --> B["Augment<br/>(random transforms)"]
B --> C["Normalise<br/>(mean/std)"]
C --> D["DataLoader<br/>(batch + shuffle)"]
D --> E["Model<br/>(CNN)"]
E --> F["Logits<br/>(N, C)"]
F --> G["Cross-entropy loss"]
F --> H["Argmax<br/>at eval"]
G --> I["Backward"]
I --> J["Optimizer step"]
J --> K["Scheduler step"]
K --> E
style A fill:#dbeafe,stroke:#2563eb
style E fill:#fef3c7,stroke:#d97706
style G fill:#fecaca,stroke:#dc2626
style H fill:#dcfce7,stroke:#16a34a
이 루프의 모든 줄이 버그가 들어갈 수 있는 자리입니다. 교차 엔트로피(cross-entropy)는 소프트맥스(softmax) 출력이 아니라 원시 로짓(raw logits)을 받습니다. 믹스업을 제외하면 데이터 증강은 입력(input)에만 적용되고 라벨(label)에는 적용되지 않습니다. optimizer.zero_grad()는 스텝(step)마다 한 번, .backward() 전에 호출해야 합니다.
교차 엔트로피(Cross-entropy), 로짓(logits), 소프트맥스(softmax)
분류기는 이미지마다 C개의 숫자, 즉 로짓을 출력합니다. 소프트맥스는 이를 확률 분포로 바꿉니다.
softmax(z)_i = exp(z_i) / sum_j exp(z_j)
CE(z, y) = -log(softmax(z)_y)
= -z_y + log(sum_j exp(z_j))
오른쪽 형태가 로그-합-지수(log-sum-exp)를 사용하는 수치적으로 안정적인(numerically stable) 형태입니다. PyTorch의 nn.CrossEntropyLoss는 소프트맥스와 음의 로그 우도(NLL; Negative Log Likelihood)를 하나의 안정적인 연산(op)으로 융합(fuse)하므로 원시 로짓을 직접 받아야 합니다. 소프트맥스를 먼저 적용하면 사실상 log(softmax(softmax(z)))를 계산하게 되어 거의 항상 버그입니다.
데이터 증강이 동작하는 이유
CNN은 가중치 공유(weight sharing) 덕분에 평행 이동(translation)에 대한 귀납 편향(inductive bias)을 갖지만, 잘라내기(crop), 뒤집기(flip), 색상 변형(color jitter), 가림(occlusion)에 대한 불변성(invariance)이 자동으로 생기지는 않습니다. 학습 중에 적용되는 무작위 변환(random transform)은 "이 두 이미지는 같은 라벨이다. 차이를 무시하는 특징(feature)을 학습하라"는 데이터 수준의 지시입니다.
데이터 증강은 라벨을 보존해야 합니다. 숫자 데이터셋(digit dataset)에서 회전(rotation)이 6을 9처럼 만들 수 있다면 작은 회전 범위를 쓰거나 다른 증강 기법을 선택해야 합니다.
Original crop: "dog facing left"
Flip: "dog facing right" <- same label, different pixels
Rotate(+15): "dog, slight tilt"
Colour jitter: "dog in warmer light"
RandomErasing: "dog with patch missing"
믹스업(Mixup)과 컷믹스(Cutmix)
일반 데이터 증강은 픽셀을 바꾸지만 원-핫(one-hot) 라벨은 그대로 둡니다. 믹스업(Mixup)과 컷믹스(Cutmix)는 입력과 라벨을 함께 섞습니다.
Mixup:
lambda ~ Beta(a, a)
x = lambda * x_i + (1 - lambda) * x_j
y = lambda * y_i + (1 - lambda) * y_j
Cutmix:
x_i 안에 x_j의 random rectangle을 붙임
y = area-weighted mix of y_i and y_j
모델은 날카로운 원-핫 타깃(target)을 외우는 대신 클래스 사이를 부드럽게 보간(interpolate)하는 법을 배웁니다. 학습 손실(train loss)은 올라가지만 테스트 정확도(test accuracy)와 보정(calibration)이 좋아질 수 있습니다.
라벨 스무딩(Label smoothing)
라벨 스무딩은 [0, 0, 1, 0] 같은 타깃 대신 [eps/C, eps/C, 1-eps, eps/C]처럼 부드러운 타깃을 사용합니다. 작은 eps=0.1 정도로 모델이 지나치게 날카로운(sharp) 로짓을 만들지 않게 하며 보정 성능을 개선합니다. PyTorch에서는 nn.CrossEntropyLoss(label_smoothing=0.1)로 사용할 수 있습니다.
정확도 너머의 평가
전체 정확도는 데이터 불균형(imbalance)을 숨깁니다. 9:1 비율의 이진 분류기(binary classifier)가 항상 다수 클래스(majority class)만 예측해도 90% 정확도를 냅니다. 실제 진단에는 다음이 필요합니다.
- 클래스별 정확도(Per-class accuracy): 클래스별 성능을 바로 드러냅니다.
- 혼동 행렬(Confusion matrix): i행 j열은 실제 클래스(true class) i를 예측 클래스(predicted class) j로 예측한 횟수입니다.
- Top-1 / Top-5: 정답 클래스가 상위 k개 예측 안에 들어가는지 봅니다.
- 보정(Calibration; ECE): 0.8 신뢰도(confidence)의 예측이 실제로 80% 맞는지 확인합니다.
만들어 보기
Step 1: 결정론적 합성 데이터셋(deterministic synthetic dataset)
실제 CIFAR-10 대신 빠르고 재현 가능한(reproducible) 합성(synthetic) CIFAR 스타일 데이터셋을 만듭니다. 32x32 RGB 이미지에 클래스별 고유 색상 팔레트(color palette)와 주파수 패턴(frequency pattern)을 넣고 잡음(noise)을 추가합니다. 같은 파이프라인은 실제 CIFAR-10에도 그대로 적용됩니다.
import numpy as np
import torch
from torch.utils.data import Dataset
def synthetic_cifar(num_per_class=1000, num_classes=10, seed=0):
rng = np.random.default_rng(seed)
X = []
Y = []
for c in range(num_classes):
centre = rng.uniform(0, 1, (3,))
freq = 2 + c
for _ in range(num_per_class):
yy, xx = np.meshgrid(np.linspace(0, 1, 32), np.linspace(0, 1, 32), indexing="ij")
r = np.sin(xx * freq) * 0.5 + centre[0]
g = np.cos(yy * freq) * 0.5 + centre[1]
b = (xx + yy) * 0.5 * centre[2]
img = np.stack([r, g, b], axis=-1)
img += rng.normal(0, 0.08, img.shape)
img = np.clip(img, 0, 1)
X.append(img.astype(np.float32))
Y.append(c)
X = np.stack(X)
Y = np.array(Y)
idx = rng.permutation(len(X))
return X[idx], Y[idx]
class ArrayDataset(Dataset):
def __init__(self, X, Y, transform=None):
self.X = X
self.Y = Y
self.transform = transform
def __len__(self):
return len(self.X)
def __getitem__(self, i):
img = self.X[i]
if self.transform is not None:
img = self.transform(img)
img = torch.from_numpy(img).permute(2, 0, 1)
return img, int(self.Y[i])
ArrayDataset은 NumPy HWC 이미지를 PyTorch CHW 텐서(tensor)로 바꿔 반환합니다.
Step 2: 정규화(Normalization)와 데이터 증강(augmentation)
def standardize(mean, std):
mean = np.array(mean, dtype=np.float32)
std = np.array(std, dtype=np.float32)
def _fn(img):
return (img - mean) / std
return _fn
def random_hflip(p=0.5):
def _fn(img):
if np.random.random() < p:
return img[:, ::-1, :].copy()
return img
return _fn
def random_crop(pad=4):
def _fn(img):
h, w = img.shape[:2]
padded = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), mode="reflect")
y = np.random.randint(0, 2 * pad)
x = np.random.randint(0, 2 * pad)
return padded[y:y + h, x:x + w, :]
return _fn
def compose(*fns):
def _fn(img):
for fn in fns:
img = fn(img)
return img
return _fn
잘라내기 전에는 0으로 채우는 패딩(zero-pad)보다 거울 반사 패딩(reflect-pad)이 더 자연스럽습니다. 검은 테두리(black border)를 모델이 지름길(shortcut)로 학습하는 일을 줄여 줍니다.
Step 3: 믹스업(Mixup)
믹스업은 데이터셋 내부가 아니라 학습 스텝 근처의 배치 변환(batch transform)으로 구현합니다.
def mixup_batch(x, y, num_classes, alpha=0.2):
if alpha <= 0:
return x, torch.nn.functional.one_hot(y, num_classes).float()
lam = float(np.random.beta(alpha, alpha))
idx = torch.randperm(x.size(0), device=x.device)
x_mixed = lam * x + (1 - lam) * x[idx]
y_onehot = torch.nn.functional.one_hot(y, num_classes).float()
y_mixed = lam * y_onehot + (1 - lam) * y_onehot[idx]
return x_mixed, y_mixed
def soft_cross_entropy(logits, soft_targets):
log_probs = torch.log_softmax(logits, dim=-1)
return -(soft_targets * log_probs).sum(dim=-1).mean()
소프트 타깃(soft target)에 대한 교차 엔트로피는 log_softmax와 타깃 분포(target distribution)의 원소별 곱(element-wise product)으로 계산합니다.
Step 4: 학습 루프(Training loop)
한 에포크(epoch) 동안 데이터를 한 번 돌고, 배치(batch)마다 기울기(gradient)를 계산하고, 스케줄러는 에포크마다 한 번씩 스텝합니다.
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torch.optim import SGD
from torch.optim.lr_scheduler import CosineAnnealingLR
def train_one_epoch(model, loader, optimizer, device, num_classes, use_mixup=True):
model.train()
total, correct, loss_sum = 0, 0, 0.0
for x, y in loader:
x, y = x.to(device), y.to(device)
if use_mixup:
x_m, y_soft = mixup_batch(x, y, num_classes)
logits = model(x_m)
loss = soft_cross_entropy(logits, y_soft)
else:
logits = model(x)
loss = F.cross_entropy(logits, y, label_smoothing=0.1)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss.item() * x.size(0)
total += x.size(0)
with torch.no_grad():
pred = logits.argmax(dim=-1)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total
@torch.no_grad()
def evaluate(model, loader, device, num_classes):
model.eval()
total, correct = 0, 0
loss_sum = 0.0
cm = torch.zeros(num_classes, num_classes, dtype=torch.long)
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss = nn.functional.cross_entropy(logits, y)
pred = logits.argmax(dim=-1)
for t, p in zip(y.cpu(), pred.cpu()):
cm[t, p] += 1
loss_sum += loss.item() * x.size(0)
total += x.size(0)
correct += (pred == y).sum().item()
return loss_sum / total, correct / total, cm
학습 루프를 쓸 때마다 확인할 불변 조건(invariant)은 다음과 같습니다.
- 학습 전
model.train(), 평가 전 model.eval()을 호출합니다.
.zero_grad()는 배치마다 .backward() 전에 호출합니다.
- 지표(metric) 누적에는
.item()을 사용해 계산 그래프(graph)를 잡아두지 않습니다.
- 평가에는
@torch.no_grad() 또는 with torch.no_grad()를 사용합니다.
- 손실에는 소프트맥스가 아니라 원시 로짓을 전달합니다.
Step 5: 전체 연결
합성 데이터셋을 학습/검증(train/val)으로 나누고, TinyResNet 또는 MiniClassifier를 학습합니다. 파이프라인이 올바르다면 합성 데이터셋에서는 몇 에포크 안에 거의 완벽한 검증 정확도에 도달합니다. 데이터셋을 실제 CIFAR-10으로 바꿔도 루프는 그대로 유지됩니다.
from main import synthetic_cifar, ArrayDataset
from main import standardize, random_hflip, random_crop, compose
from main import mixup_batch, soft_cross_entropy
from main import train_one_epoch, evaluate
from cnns_lenet_to_resnet import TinyResNet
X, Y = synthetic_cifar(num_per_class=500)
split = int(0.9 * len(X))
X_train, Y_train = X[:split], Y[:split]
X_val, Y_val = X[split:], Y[split:]
mean = [0.5, 0.5, 0.5]
std = [0.25, 0.25, 0.25]
train_tf = compose(random_hflip(), random_crop(pad=4), standardize(mean, std))
eval_tf = standardize(mean, std)
train_ds = ArrayDataset(X_train, Y_train, transform=train_tf)
val_ds = ArrayDataset(X_val, Y_val, transform=eval_tf)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=0)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=0)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = TinyResNet(num_classes=10).to(device)
optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True)
scheduler = CosineAnnealingLR(optimizer, T_max=10)
for epoch in range(10):
tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, device, 10, use_mixup=True)
va_loss, va_acc, _ = evaluate(model, val_loader, device, 10)
scheduler.step()
print(f"epoch {epoch:2d} lr {scheduler.get_last_lr()[0]:.4f} "
f"train {tr_loss:.3f}/{tr_acc:.3f} val {va_loss:.3f}/{va_acc:.3f}")
합성 데이터셋에서는 이 파이프라인이 5 에포크 안에 거의 완벽한 검증 정확도에 도달합니다. 핵심은 파이프라인이 올바르고, 모델이 학습 가능한 신호(learnable signal)를 학습할 수 있다는 점입니다. 데이터셋을 실제 CIFAR-10으로 바꿔도 같은 루프로 약 90%까지 학습할 수 있습니다.
Step 6: 혼동 행렬 읽기
def print_confusion(cm, labels=None):
c = cm.shape[0]
labels = labels or [str(i) for i in range(c)]
print(f"{'':>6}" + "".join(f"{l:>5}" for l in labels))
for i in range(c):
row = cm[i].tolist()
print(f"{labels[i]:>6}" + "".join(f"{v:>5}" for v in row))
print()
tp = cm.diag().float()
fp = cm.sum(dim=0).float() - tp
fn = cm.sum(dim=1).float() - tp
prec = tp / (tp + fp).clamp_min(1)
rec = tp / (tp + fn).clamp_min(1)
f1 = 2 * prec * rec / (prec + rec).clamp_min(1e-9)
for i in range(c):
print(f"{labels[i]:>6} prec {prec[i]:.3f} rec {rec[i]:.3f} f1 {f1[i]:.3f}")
_, _, cm = evaluate(model, val_loader, device, 10)
print_confusion(cm)
핵심 계산만 따로 보면 다음과 같습니다.
tp = cm.diag().float()
fp = cm.sum(dim=0).float() - tp
fn = cm.sum(dim=1).float() - tp
prec = tp / (tp + fp).clamp_min(1)
rec = tp / (tp + fn).clamp_min(1)
f1 = 2 * prec * rec / (prec + rec).clamp_min(1e-9)
행(row)은 실제 클래스, 열(column)은 예측 결과입니다. 클래스 3과 5 사이의 비대각(off-diagonal) 값이 크다면 두 클래스의 혼동된 예시(confused example)를 직접 보고, 목표 지향적 데이터 수집(targeted data collection)이나 클래스별 데이터 증강(class-specific augmentation)을 설계해야 합니다.
사용하기
실제 CIFAR-10에서는 torchvision 구성 요소로 같은 파이프라인을 구성할 수 있습니다.
from torchvision.datasets import CIFAR10
from torchvision.transforms import Compose, RandomCrop, RandomHorizontalFlip, ToTensor, Normalize
mean = (0.4914, 0.4822, 0.4465)
std = (0.2470, 0.2435, 0.2616)
train_tf = Compose([
RandomCrop(32, padding=4, padding_mode="reflect"),
RandomHorizontalFlip(),
ToTensor(),
Normalize(mean, std),
])
eval_tf = Compose([ToTensor(), Normalize(mean, std)])
train_ds = CIFAR10(root="./data", train=True, download=True, transform=train_tf)
val_ds = CIFAR10(root="./data", train=False, download=True, transform=eval_tf)
평균(mean)과 표준편차(std)는 데이터셋마다 다릅니다. CIFAR-10에는 CIFAR-10 학습 세트에서 계산한 CIFAR-10 통계량을 사용하고, ImageNet 통계량을 그대로 복사해 붙여 넣지 않습니다. 여기서 거울 반사 패딩(reflect padding)은 커뮤니티 기본 잘라내기 정책(community-default crop policy)입니다. ImageNet 통계량을 그대로 붙여 넣으면 약 1%의 정확도 누수(accuracy leak)가 생길 수 있는데, 누군가 성능을 분석(profile)하기 전까지 잘 드러나지 않습니다.
산출물 만들기
이 강의의 최종 산출물은 다음 두 가지입니다.
outputs/prompt-classifier-pipeline-auditor.md: 학습 스크립트의 다섯 가지 불변 조건을 감사(audit)하고 첫 번째 위반 사항을 찾는 프롬프트(prompt)
outputs/skill-classification-diagnostics.md: 혼동 행렬과 클래스 이름으로 클래스별 실패를 요약하고 가장 영향이 큰 수정안(fix)을 제안하는 스킬(skill)
연습문제
- 쉬움: 같은 모델을 합성 데이터셋에서 믹스업 사용 여부에 따라 각각 5 에포크씩 학습합니다. 학습/검증 손실을 그래프로 그리고, 믹스업을 적용했을 때 학습 손실이 더 높음에도 검증 정확도가 비슷하거나 더 나은 이유를 설명합니다.
- 중간: 컷아웃(Cutout)을 구현합니다. 학습 이미지마다 무작위 8x8 정사각형 영역을 0으로 만듭니다(zero out). 데이터 증강 없음, 수평 뒤집기(hflip)+잘라내기(crop), hflip+crop+cutout, hflip+crop+mixup을 비교하고 각 경우의 검증 정확도를 보고합니다.
- 어려움: CIFAR-100 파이프라인을 만들고 ResNet-34 학습 실행을 발표된(published) 정확도의 1% 이내로 재현합니다. 학습률 3개와 가중치 감쇠(weight decay) 2개를 스윕(sweep)하고 로컬 CSV에 기록한 뒤 최상위 혼동(top confusions) 표를 만듭니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 로짓(Logits) | 원시 출력 | 이미지마다 클래스별로 나오는, 소프트맥스 적용 전(pre-softmax) 벡터 |
| 교차 엔트로피(Cross-entropy) | 손실 | 정답 클래스의 음의 로그 확률. log-softmax와 NLL을 수치적으로 안정적으로 결합 |
| 데이터 로더(DataLoader) | 배처(batcher) | 데이터셋에 셔플링, 배치 구성, 멀티 워커 로딩(multi-worker loading)을 더하는 래퍼(wrapper) |
| 데이터 증강(Augmentation) | 무작위 변환 | 학습 시에 라벨을 보존하면서 픽셀을 바꿔 불변성을 가르치는 방법 |
| 믹스업 / 컷믹스(Mixup / Cutmix) | 두 이미지 섞기 | 입력과 라벨을 함께 섞어 부드러운 결정 경계(decision boundary)를 학습 |
| 라벨 스무딩(Label smoothing) | 더 부드러운 타깃 | 원-핫을 덜 날카로운 타깃으로 바꿔 보정 성능을 개선 |
| Top-k 정확도(Top-k accuracy) | top-5 | 정답 클래스가 상위 k개 예측 안에 있는지를 보는 지표 |
| 혼동 행렬(Confusion matrix) | 오류 위치 | 실제 클래스와 예측 클래스의 C x C 횟수 표 |
더 읽을거리