논문 리뷰

[논문 구현] Deep Residual Learning for Image Recognition

Solbi Lee 2025. 8. 8. 16:29
CIFAR-10에서 ResNet-18(직접구현) vs VGG19-BN: 최적화 안정성, 수렴 특성, 일반화 성능 비교

 

 


0. 목표와 설계 개요

목표 

Residual Learning의 실제 이점을 검증하기 위해, torchvision 미사용 직접 구현 ResNet-18을 CIFAR-10에 맞춰 학습하고, VGG19-BN과 동일한 학습 조건에서 성능·수렴 특성을 정량/정성 비교한다.

가설 

 

1. ResNet은 F(x)+x의 identity 경로로 인해 깊이가 같아도 학습이 더 안정되고 수렴이 빠르다.

2. 동일 증강/스케줄에서 ResNet-18은 더 적은 파라미터로도 더 높은 테스트 정확도를 달성한다.

 

실험 설계 

  • 데이터 : CIFAR-10, 표준 정규화 + RandomCrop(32, pad=4), RandomHorizontalFlip
  • 옵티마이저/스케줄: SGD(m=0.9, wd=5e-4), MultiStepLR([50,75], γ=0.1) → 0.1 → 0.01 → 0.001
  • 비교 : 동일 전처리/배치(128)/에폭(100)/스케줄 
Model Test Loss Test Acc(%) #Params(M) Best Val(%)
ResNet-18 0.1937 94.99 11.17 94.99
VGG19(BN) 0.3345 92.87 38.96 92.87

VGG19-BN 곡선
ResNet 곡선
두 모델 비교 곡선


1. 구현 (torchvision 미사용 ResNet-18)

1.1 CIFAR-10에 맞춘 ResNet-18 설계

 

  • Stem: ImageNet 스타일(7×7 Conv + MaxPool)을 생략하고, CIFAR-10 해상도(32×32)에 맞춰
    Conv3×3(3→64, stride=1, pad=1) → BN → ReLU로 간결화.
  • BasicBlock (Residual Unit)
    • Conv3×3(s) → BN → ReLU → Conv3×3(1) → BN
    • 입력/출력 채널 또는 stride가 달라질 때만 1×1 Conv + BN으로 downsample
    • 출력: ReLU( F(x) + identity )
  • Stage 구성: 채널 [64, 128, 256, 512], 블록 수 [2,2,2,2], stage 2~4의 첫 블록만 stride=2
  • 헤드: AdaptiveAvgPool2d(1,1) → FC(512→10)
  • 초기화: Kaiming Normal(He) for Conv, BN γ=1/β=0
# 데이터 전처리

transform_train = T.Compose([
    T.RandomCrop(32, padding=4),
    T.RandomHorizontalFlip(),
    T.ToTensor(),
    T.Normalize([0.4914,0.4822,0.4465],[0.2470,0.2435,0.2616]),
])
transform_test = T.Compose([
    T.ToTensor(),
    T.Normalize([0.4914,0.4822,0.4465],[0.2470,0.2435,0.2616]),
])

train_ds = torchvision.datasets.CIFAR10('./data', train=True,  download=True, transform=transform_train)
test_ds  = torchvision.datasets.CIFAR10('./data', train=False, download=True, transform=transform_test)
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True,  num_workers=2, pin_memory=True)
test_loader  = DataLoader(test_ds, batch_size=128, shuffle=False, num_workers=2, pin_memory=True)

 

 

# BasicBlock

class BasicBlock(nn.Module):
    expansion = 1
    def __init__(self, in_channels, out_channels, stride=1, downsample=None):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False)
        self.bn1   = nn.BatchNorm2d(out_channels)
        self.relu  = nn.ReLU(inplace=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False)
        self.bn2   = nn.BatchNorm2d(out_channels)
        self.downsample = downsample
    def forward(self, x):
        identity = x
        out = self.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        if self.downsample is not None:
            identity = self.downsample(x)
        return self.relu(out + identity)

 

# ResNet

class ResNet(nn.Module):
    def __init__(self, block, layers, num_classes=10):
        super().__init__()
        self.in_channels = 64
        self.conv1 = nn.Conv2d(3,64,3,1,1,bias=False)
        self.bn1   = nn.BatchNorm2d(64)
        self.relu  = nn.ReLU(inplace=True)
        self.layer1 = self._make_layer(block, 64,  layers[0], stride=1)
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
        self.avgpool = nn.AdaptiveAvgPool2d((1,1))
        self.fc      = nn.Linear(512*block.expansion, num_classes)
        # He init
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight,1); nn.init.constant_(m.bias,0)
    def _make_layer(self, block, out_channels, blocks, stride):
        downsample = None
        if stride!=1 or self.in_channels!=out_channels*block.expansion:
            downsample = nn.Sequential(
                nn.Conv2d(self.in_channels, out_channels*block.expansion, 1, stride, bias=False),
                nn.BatchNorm2d(out_channels*block.expansion),
            )
        layers = [block(self.in_channels, out_channels, stride, downsample)]
        self.in_channels = out_channels*block.expansion
        for _ in range(1, blocks):
            layers.append(block(self.in_channels, out_channels))
        return nn.Sequential(*layers)
    def forward(self, x):
        x = self.relu(self.bn1(self.conv1(x)))
        x = self.layer1(x); x = self.layer2(x); x = self.layer3(x); x = self.layer4(x)
        x = self.avgpool(x); x = torch.flatten(x,1)
        return self.fc(x)

def resnet18_cifar(): return ResNet(BasicBlock, [2,2,2,2], 10).to(device)

 

1.2 비교군 VGG19-BN 수정

  • vgg19_bn(weights=None) 불러와 첫 Conv를 3×3, AdaptiveAvgPool2d(1,1)로 바꾸고, 분류 헤드를 512 → 4096 → 4096 → 10으로 수정. ( CIFAR-10 데이터셋 특성에 맞게 모델을 조정 )
# vgg19_cifar

def vgg19_cifar(use_bn=True):
    m = vgg19_bn(weights=None) if use_bn else vgg19(weights=None)
    # CIFAR-10에 맞게 분류 헤드 수정
    m.features[0] = nn.Conv2d(3, 64, kernel_size=3, padding=1)
    m.avgpool = nn.AdaptiveAvgPool2d((1,1))
    m.classifier = nn.Sequential(
        nn.Linear(512, 4096), nn.ReLU(True), nn.Dropout(),
        nn.Linear(4096, 4096), nn.ReLU(True), nn.Dropout(),
        nn.Linear(4096, 10)
    )
    return m.to(device)

 

1.3 학습 루프, 체크포인트, 재현성

 

  • train_model()에서 에폭별 train/val loss/acc현재 LR 기록
  • Best-Only: val acc가 갱신될 때만 best_*.pth 저장 + 옵션 체크포인트(ckpt_*.pth)
  • 이력은 pkl/csv 동시 저장
# 공통 학습 & 검증 함수 

def train_model(model, train_loader, test_loader, criterion, optimizer, scheduler, num_epochs, save_path, tag):
    hist = {'epoch':[], 'lr':[], 'train_loss':[], 'train_acc':[], 'val_loss':[], 'val_acc':[]}
    best_acc=0.0
    for epoch in range(1, num_epochs+1):
        # Train
        model.train()
        run_loss=0.0; correct=0; total=0
        for x,y in tqdm(train_loader, desc=f'{tag} Train {epoch}/{num_epochs}'):
            x,y = x.to(device), y.to(device)
            optimizer.zero_grad()
            logits = model(x)
            loss = criterion(logits, y)
            loss.backward()
            optimizer.step()
            run_loss += loss.item()*y.size(0)
            pred = logits.argmax(1)
            correct += (pred==y).sum().item(); total += y.size(0)
        scheduler.step()
        tr_loss = run_loss/total; tr_acc = 100.*correct/total

        # Val
        model.eval()
        run_loss=0.0; correct=0; total=0
        with torch.no_grad():
            for x,y in tqdm(test_loader, desc=f'{tag} Val   {epoch}/{num_epochs}'):
                x,y = x.to(device), y.to(device)
                logits = model(x)
                loss = criterion(logits, y)
                run_loss += loss.item()*y.size(0)
                pred = logits.argmax(1)
                correct += (pred==y).sum().item(); total += y.size(0)
        val_loss = run_loss/total; val_acc = 100.*correct/total

        # 기록
        current_lr = optimizer.param_groups[0]['lr']
        hist['epoch'].append(epoch); hist['lr'].append(current_lr)
        hist['train_loss'].append(tr_loss); hist['train_acc'].append(tr_acc)
        hist['val_loss'].append(val_loss); hist['val_acc'].append(val_acc)

        # 베스트 저장(가중치만)
        if val_acc > best_acc:
            best_acc = val_acc
            torch.save(model.state_dict(), save_path)
            # 재학습용 체크포인트(옵션)
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'scheduler_state_dict': scheduler.state_dict(),
                'best_val_acc': best_acc
            }, os.path.splitext(save_path)[0].replace('best_', 'ckpt_') + '.pth')
            print(f'>>> New best {tag}: {best_acc:.2f}% -> {save_path}')

        if epoch % 10 == 0 or epoch == 1:
            print(f'[{tag}] Epoch {epoch:03d} | '
                  f'Train {tr_loss:.4f}, {tr_acc:.2f}% | '
                  f' Val {val_loss:.4f}, {val_acc:.2f}% | LR {current_lr:g}')
    return hist, best_acc

def save_history(hist, name_prefix):
    pkl = os.path.join(checkpoint_dir, f'{name_prefix}_history.pkl')
    csv = os.path.join(checkpoint_dir, f'{name_prefix}_history.csv')
    with open(pkl, 'wb') as f: pickle.dump(hist, f)
    pd.DataFrame(hist).to_csv(csv, index=False)
    print(f'[Saved] history -> {pkl} / {csv}')

def plot_curves(hist, title_prefix):
    epochs = hist['epoch']
    plt.figure(figsize=(8,5))
    plt.plot(epochs, hist['train_loss'], '--', label='Train Loss')
    plt.plot(epochs, hist['val_loss'],   '-',  label='Val Loss')
    plt.xlabel('Epoch'); plt.ylabel('Loss'); plt.title(f'{title_prefix} Loss'); plt.legend(); plt.grid(True)
    plt.show()

    plt.figure(figsize=(8,5))
    plt.plot(epochs, hist['train_acc'], '--', label='Train Acc')
    plt.plot(epochs, hist['val_acc'],   '-',  label='Val Acc')
    plt.xlabel('Epoch'); plt.ylabel('Accuracy (%)'); plt.title(f'{title_prefix} Accuracy'); plt.legend(); plt.grid(True)
    plt.show()

def evaluate(model, loader):
    model.eval()
    criterion = nn.CrossEntropyLoss()
    total=0; correct=0; loss_sum=0.0
    with torch.no_grad():
        for x,y in tqdm(loader, desc='Evaluating'):
            x,y = x.to(device), y.to(device)
            logits = model(x)
            loss = criterion(logits, y)
            loss_sum += loss.item()*y.size(0)
            pred = logits.argmax(1)
            correct += (pred==y).sum().item()
            total   += y.size(0)
    return loss_sum/total, 100.*correct/total

def count_params(m): return sum(p.numel() for p in m.parameters())/1e6

 


2. 학습 설정 (공통) & 실행

2.1 데이터 전처리 

 

  • Train: RandomCrop(32, pad=4), RandomHorizontalFlip, ToTensor, Normalize
  • Test: ToTensor + Normalize 동일
# 코드는 1.1과 동일

2.2 하이퍼파라미터 

 

  • 배치 128, 에폭 100
  • SGD(lr=0.1, momentum=0.9, weight_decay=5e-4)
  • MultiStepLR(milestones=[50,75], gamma=0.1)
# 학습 설정 
num_epochs = 100
criterion = nn.CrossEntropyLoss()

# ResNet-18 학습
resnet = resnet18_cifar()
opt_res = optim.SGD(resnet.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4)
sch_res = optim.lr_scheduler.MultiStepLR(opt_res, milestones=[50, 75], gamma=0.1)
res_save = os.path.join(checkpoint_dir, 'best_resnet18.pth')

res_hist, res_best = train_model(resnet, train_loader, test_loader, criterion, opt_res, sch_res, num_epochs, res_save, tag='ResNet-18')
save_history(res_hist, 'resnet18')
plot_curves(res_hist, 'ResNet-18')

# VGG19 학습
USE_VGG_BN = True 
vgg = vgg19_cifar(use_bn=USE_VGG_BN)
vgg_lr = 0.1 if USE_VGG_BN else 0.01

opt_vgg = optim.SGD(vgg.parameters(), lr=vgg_lr, momentum=0.9, weight_decay=5e-4)
sch_vgg = optim.lr_scheduler.MultiStepLR(opt_vgg, milestones=[50, 75], gamma=0.1)
vgg_save = os.path.join(checkpoint_dir, f'best_vgg19{"_bn" if USE_VGG_BN else ""}.pth')

vgg_hist, vgg_best = train_model(vgg, train_loader, test_loader, criterion, opt_vgg, sch_vgg, num_epochs, vgg_save, tag=f'VGG19{"-BN" if USE_VGG_BN else ""}')
save_history(vgg_hist, f'vgg19{"_bn" if USE_VGG_BN else ""}')
plot_curves(vgg_hist, 'VGG19' + ('-BN' if USE_VGG_BN else ''))

 

 

2.3 베스트 모델 공정 비교 

 

  • 저장된 best 가중치를 각각 로드하여 동일 test_loader로 평가
  • 파라미터 수 sum(p.numel())/1e6로 함께 출력
# ResNet 로드
resnet_eval = resnet18_cifar()
resnet_eval.load_state_dict(torch.load(res_save, map_location=device), strict=True)
# VGG 로드
vgg_eval = vgg19_cifar(use_bn=USE_VGG_BN)
vgg_eval.load_state_dict(torch.load(vgg_save, map_location=device), strict=True)

res_test_loss, res_test_acc = evaluate(resnet_eval, test_loader)
vgg_test_loss, vgg_test_acc = evaluate(vgg_eval,    test_loader)

# 출력 
print("="*86)
print(f"{'Model':<14} | {'Test Loss':>10} | {'Test Acc(%)':>12} | {'#Params(M)':>10} | {'Best Val(%)':>11}")
print("-"*86)
print(f"{'ResNet-18':<14} | {res_test_loss:>10.4f} | {res_test_acc:>12.2f} | {count_params(resnet_eval):>10.2f} | {max(res_hist['val_acc']):>11.2f}")
print(f"{'VGG19' + ('(BN)' if USE_VGG_BN else ''):<14} | {vgg_test_loss:>10.4f} | {vgg_test_acc:>12.2f} | {count_params(vgg_eval):>10.2f} | {max(vgg_hist['val_acc']):>11.2f}")
print("="*86)
print(f"Δ Accuracy (ResNet - VGG19) = {res_test_acc - vgg_test_acc:.2f} pp")

 


3. 결과 

3.1 최종 정량 결과 

Accuracy (ResNet − VGG19-BN) = +2.12pp

Model Test Loss Test Acc(%) #Params(M) Best Val(%)
ResNet-18 0.1937 94.99 11.17 94.99
VGG19(BN) 0.3345 92.87 38.96 92.87

- 동일 전처리 / 스케줄 / 배치 / 에폭 조건 

3.2 학습 곡선 (수렴 / 안정성)

ResNet-18 : LR 스텝(50,75)에 맞춰 loss 급락 및 acc 급상승. 80에폭 이후 94.7~95.0%에 안정 수렴

ResNet-18 Loss/Acc 곡선

VGG19-BN: 동일 스케줄에서도 변동성 높음, 후반 개선 둔화. 최종 92.7~92.9% 근처 수렴

VGG19-BN Loss/Acc 곡선

 

동일 조건 비교: ResNet은 전 구간에서 낮은 Val loss높은 Val acc를 유지. LR 스텝 직후 개선 폭이 VGG보다 큼

두 모델 비교 곡선

 

정성 해석

 

  • 초기(1~10에폭): 둘 다 급격한 손실 감소. ResNet이 조금 더 빠르게 val acc 80% 안착.
  • 중기(10~50에폭): VGG는 val 변동성이 큼(오버피팅 시그널), ResNet은 변동폭 작음.
  • LR step @50: 두 모델 모두 성능 점프. ResNet은 0.20대 val loss, 93%+로 치고 올라감.
  • 후기(80~100에폭): ResNet은 95% 근처에서 plateau, VGG는 92~93%에서 정체.

3.3 왜 ResNet이 더 잘 학습되는가 (실증 + 원리)

 

  • Identity Shortcut으로 안정적 역전파
    • 합연산을 통한 직접 경로가 항상 열려 있어 깊이가 늘어나도 gradient 소실/폭주가 크게 억제됨.
    • 네 곡선에서 확인되는 Val loss 하한과 수렴 속도 차이가 이를 반영.
  • 잔차 학습(Residual) = 탐색 공간 축소
    • H(x)를 직접 학습하는 대신 F(x)=H(x)-x만 학습 → identity 주변의 작은 보정을 찾는 문제로 단순화.
    • 스케줄 스텝 이후 ResNet의 빠른 재수렴이 이를 실증.
  • 용량/일반화 균형
    • ResNet-18 11.17M vs VGG19-BN 38.96M → 동일 증강/정규화에서 ResNet의 일반화 오차가 낮아지는 경향.
    • 표에서 loss 격차(0.19 vs 0.33)와 정확도 격차(+2.12pp)로 드러남.
  • 표현력 낭비 최소화
    • Block 단위로 “필요한 변화만 추가”하므로 과도한 특성 중복/공유가 줄어들고 효율적 표현을 학습.

4. 결론 

 

  • 동일한 조건에서 직접 구현한 ResNet-18은 VGG19-BN 대비 테스트 정확도 +2.12pp (94.99% vs 92.87%), 손실 낮음(0.1937 vs 0.3345), 파라미터 3.5× 적음.
  • 곡선 분석에서 확인되듯, Residual 연결이 최적화와 일반화 모두에 실질적 이득을 제공한다.