5 분 소요

본 글은 스탠포드 대학의 “Convolutional Neural Networks for Visual Recognition” 2021년 강의를 듣고 정리한 내용입니다. 해당 강의는 2021년 4월에 진행되었습니다. 개인 공부 목적으로 작성되었으며, 설명이 맞지 않거나 글 오타가 있으면 알려주시길 바랍니다.

  • 원본 링크 : cs231n.stanford.edu
  • 한글 번역 링크 : aikorea.org - cs231n
  • 강의 링크 : youtube - 2017 Spring (English)

  • 본 강의는 CPU와 GPU의 구조적 차이 및 하드웨어 가속 원리를 소개한 후 딥러닝 소프트웨어 프레임워크를 다룬다. 여기서는 하드웨어 비교를 간략히 갈음하고 파이토치(PyTorch) 프레임워크의 동작 원리에 집중한다.1

Computational Graphs

  • 먼저 계산 그래프를 numpy로 작성해본다.

    import numpy as np
    np.random.seed(0)
    
    N, D = 3, 4
    
    x = np.random.randn(N, D)
    y = np.random.randn(N, D)
    z = np.random.randn(N, D)
    
    a = x * y
    b = a + z
    c = np.sum(b)
    
    grad_c = 1.0
    grad_b = grad_c * np.ones((N, D))
    grad_a = grad_b.copy()
    grad_z = grad_b.copy()
    grad_x = grad_a * y
    grad_y = grad_a * x
    
  • Good
    • API가 깔끔하다.
    • 수치적 코드를 쓰기 편하다.
  • Bad
    • 고유의 그레이디언트를 계산하지 못한다.
    • GPU에서 실행할 수 없다.

PyTorch

  • torch.Tensor : 넘파이(numpy) 어레이(array)와 비슷하지만, GPU에서 실행할 수 있다.
  • torch.autograd : 텐서로 계산 그래프(computational graph)를 만들고, 그레이디언트(gradient)를 자동으로 계산하는 패키지이다.
  • torch.nn.Module : 신경망(neural network) 레이어; 상태(state) 또는 학습 가능한 가중치를 저장할 수 있다.

Tensors

  • 다음은 torch.Tensor에 관한 예제이다.
  • torch.randn : 평균이 0이고, 표준편차가 1인 가우시안 정규분포를 이용해 생성한다.
  • torch.mm : 행렬의 행렬 곱셈을 수행한다.
  • torch.clamp(min, max) : 모든 요소를 [min, max] 범위로 고정한다. 2
  • torch.t(input) : input이 2차원 텐서일 때 차원 0과 1을 전치(transpose)한다.
import torch

# device 정의 = gpu가 가능하면 cuda, 아니면 cpu
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# 데이터와 가중치를 위해 랜덤 텐서 생성
  # H : hidden의 약어
N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in, device=device)
y = torch.randn(N, D_out, device=device)
w1 = torch.randn(D_in, H, device=device)
w2 = torch.randn(H, D_out, device=device)

learning_rate = 1e-6
for t in range(500):
    # 정방향 전달(forward pass) : 예측(prediction)과 손실(loss)을 계산한다.
    h = x.mm(w1)
    h_relu = h.clamp(min=0)
    y_pred = h_relu.mm(w2)
    loss = (y_pred - y).pow(2).sum()
    
    # 역방향 전달(backward pass) : 수동으로 그레이디언트를 계산한다.
    grad_y_pred = 2.0 * (y_pred - y)
    grad_w2 = h_relu.t().mm(grad_y_pred)
    grad_h_relu = grad_y_pred.mm(w2.t())
    grad_h = grad_h_relu.clone()
    grad_h[h < 0] = 0
    grad_w1 = x.t().mm(grad_h)

    # 가중치에 대한 경사 하강(gradient descent) 단계(step)
    w1 -= learning_rate * grad_w1
    w2 -= learning_rate * grad_w2

Autograd

  • requires_grad=True로 텐서를 생성하면 autograd가 활성화된다.
    • 모든 연산 추적
  • requires_grad=True가 있는 텐서에 대한 연산(operation)은 pytorch가 계산 그래프(computational graph)를 빌드한다.
  • torch.randn(requires_grad=True) : autograd가 반환된 텐서의 연산을 기록해야 하는 경우 쓰인다.
  • torch.Tensor.backward : 현재 계산 그래프에 대한(w.r.t.) 텐서의 그레이디언트를 계산한다.
    • 텐서에 대한 그레이디언트는 .grad 속성에 누적된다.
  • torch.autograd.grad : 입력에 대한 출력의 그레이디언트 합계를 계산하고 반환한다.
  • torch.no_grad : 이 부분에서는 계산 그래프를 만들지 말라는 의미이다.
  • grad.zero_ : _로 끝나는 파이토치 메서드는 텐서를 제자리에서 수정하고, 새로운 텐서를 반환하지 않는 메서드이다.
import torch

N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
w1 = torch.randn(D_in, H, requires_grad=True)
w2 = torch.randn(H, D_out, requires_grad=True)

learning_rate = 1e-6
for t in range(500):
    # forward pass이지만, pytorch가 그래프에서 자동으로 추적해준다.
    y_pred = x.mm(w1).clamp(min=0).mm(w2)
    loss = (y_pred - y).pow(2).sum()

    # w1 및 w2에 대한 손실 그레이디언트 계산
    loss.backward()

    # 가중치에 gradient step을 만든 다음 0으로 만든다.
    with torch.no_grad():
        w1 -= learning_rate * w1.grad
        w2 -= learning_rate * w2.grad
        w1.grad.zero_()
        w2.grad.zero_()

New Autograd Functions

  • 텐서에 대한 정방향(forward) 및 역방향(backward) 함수를 만들어 자체 autograd 함수를 정의해본다.
  • torch.autograd.Function : 사용자 지정 autograd.Function를 만들 기본 클래스이다.
    • 서브클래스 함수 및 forward 와 backward 메서드 구현
    • forward : 연산 수행
  • backward : 역방향 모드 자동 미분(reverse-mode autodiff) 연산 공식을 정의한다.
  • ctx(=context object) : 컨텍스트 객체에 제공되는 메서드를 활용한다.
    • ctx.save_for_backward : 나중에 backward() 호출 시 사용할 텐서를 저장한다.
import torch

class MyReLU(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        ctx.save_for_backward(x)
        return x.clamp(min=0)

    @staticmethod
    def backward(ctx, grad_y):
        x, = ctx.saved_tensors
        grad_input = grad_y.clone()
        grad_input[x < 0] = 0
        return grad_input

def my_relu(x):
    return MyReLU.apply(x)

N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)
w1 = torch.randn(D_in, H, requires_grad=True)
w2 = torch.randn(H, D_out, requires_grad=True)

learning_rate = 1e-6
for t in range(500):
    # 정방향에 새로운 autograd function 사용
    y_pred = my_relu(x.mm(w1)).mm(w2)
    loss = (y_pred - y).pow(2).sum()

    # w1 및 w2에 대한 손실 그레이디언트 계산
    loss.backward()

    # 가중치에 gradient step을 만든 다음 0으로 만든다.
    with torch.no_grad():
        w1 -= learning_rate * w1.grad
        w2 -= learning_rate * w2.grad
        w1.grad.zero_()
        w2.grad.zero_()

nn

  • 신경망 작업을 위한 상위 레벨 래퍼(wrapper)이다.
  • torch.nn.Linear(in_features, out_features) : 들어오는 데이터에 선형 변환 적용
  • $y=xA^T+b$
  • torch.nn.ReLU : rectified linear unit function을 요소별로 적용한다.
  • $ReLU(x)=max(0,x)$
  • torch.nn.functional.mse_loss(input, target) : 요소별 평균 제곱 오차를 측정한다.
  • torch.no_grad : 그레이디언트 계산을 사용하지 않도록 설정한 컨텍스트 관리자(context-manager)
  • model.parameters : 파라미터 반환(weight, bias 등 torch.tensor로 이루어진 것들을 말함) 3
import torch

N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)

# 모델을 일련의 데이터로 정의
model = torch.nn.Sequential(
  torch.nn.Linear(D_in, H),
  torch.nn.ReLU(),
  torch.nn.Linear(H, D_out)
)

learning_rate = 1e-2
for t in range(500):
  # forward pass
  y_pred = model(x)
  loss = torch.nn.functional.mse_loss(y_pred, y)

  # 모든 모델 가중치에 대한 그레이디언트 계산
  # requires_grad = True 를 가지고 있는 것들을
  loss.backward()

  # 각 모델 매개변수에 gradient step 만들기
  # 이때는 그레이디언트 사용 안함
  with torch.no_grad():
    for param in model.parameters():
      param -= learning_rate * param.grad
  model.zero_grad()

optim

  • optimizer.step : 파라미터 값을 업데이트하는 과정이다.
  • optimizer.zero_grad : 역전파 단계 전에, optimizer 객체를 사용하여 갱신할 변수들에 대한 모든 그레이디언트(gradient)를 0으로 만든다. 갱신할 변수는 모델의 학습 가능한 가중치이다. 이렇게 하는 이유는 기본적으로 .backward()를 호출할 때마다 그레이디언트가 버퍼(buffer)에 (덮어쓰지 않고) 누적되기 때문이다. 4
import torch

N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)

# 모델을 일련의 데이터로 정의
model = torch.nn.Sequential(
  torch.nn.Linear(D_in, H),
  torch.nn.ReLU(),
  torch.nn.Linear(H, D_out)
)

learning_rate = 1e-4
# 다른 업데이트 규칙으로 옵티마이저(optimizer)를 적용
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)

for t in range(500):
  # forward pass
  y_pred = model(x)
  loss = torch.nn.functional.mse_loss(y_pred, y)

  # 모든 모델 가중치에 대한 그레이디언트 계산
  # requires_grad = True 를 가지고 있는 것들을
  loss.backward()

  optimizer.step()
  optimizer.zero_grad()
 

nn - Define new Modules

  • PyTorch Module : 신경망 계층으로 텐서를 입력 및 출력한다.
    • autograd를 사용하여 자체 모듈을 정의할 수 있다.
import torch

# 전체 모듈을 단일 모듈로 정의
class TwoLayerNet(torch.nn.Module):
  def __init__(self, D_in, H, D_out):
    super(TwoLayerNet, self).__init__()
    self.linear1 = torch.nn.Linear(D_in, H)
    self.linear2 = torch.nn.Linear(H, D_out)
  
  # child module을 사용하여 forward pass 정의
  # autograd가 알아서 처리하기 때문에 backward 정의할 필요 없음
  def forward(self, x):
    h_relu = self.linear1(x).clamp(min=0)
    y_pred = self.linear2(h_relu)
    return y_pred

N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)

model = TwoLayerNet(D_in, H, D_out)

optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)
for t in range(500):
  y_pred = model(x)
  loss = torch.nn.functional.mse_loss(y_pred, y)

  loss.backward()
  optimizer.step()
  optimizer.zero_grad()
  • 사용자 지정 모듈 하위 클래스와 sequential container를 혼합하여 쓸 수도 있다.
import torch

class ParallelBlock(torch.nn.Module):
  def __init__(self, D_in, D_out):
    super(ParallelBlock, self).__init__()
    self.linear1 = torch.nn.Linear(D_in, D_out)
    self.linear2 = torch.nn.Linear(D_in, D_out)
  def forward(self, x):
    h1 = self.linear1(x)
    h2 = self.linear2(x)
    return (h1 * h2).clamp(min=0)
  
N, D_in, H, D_out = 64, 1000, 100, 10
x = torch.randn(N, D_in)
y = torch.randn(N, D_out)

model = torch.nn.Sequential(
  ParallelBlock(D_in, H),
  ParallelBlock(H, H),
  torch.nn.Linear(H, D_out)
)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
for t in range(500):
  y_pred = model(x)
  loss = torch.nn.functional.mse_loss(y_pred, y)
  loss.backward()
  optimizer.step()
  optimizer.zero_grad()

Pretrained Models

  • torchvision 패키지를 이용하면 ImageNet 등으로 사전 학습된(pre-trained) 모델 가중치를 바로 불러와 활용할 수 있다.
import torch
import torchvision

alexnet = torchvision.models.alexnet(pretrained=True)
vgg16 = torchvision.models.vgg16(pretrained=True)
resnet101 = torchvision.models.resnet101(pretrained=True)

Dynamic Computation Graphs

  • 다음은 연산 단계별 동적 계산 그래프(Dynamic Computation Graph)의 생성 과정을 나타낸 도식이다.

  • PyTorch는 Define-by-Run 방식을 사용하므로 매 반복(iteration)마다 정방향 연산에 따라 그래프를 새로 생성하고, 역전파가 완료되면 그래프 버퍼를 해제한다.

한계 및 트레이드오프

  • 동적 그래프 생성 오버헤드: 매 반복마다 파이썬 레벨에서 연산 그래프를 새로 구성하므로 런타임 인터프리터 오버헤드가 발생할 수 있다. 정적 그래프(Define-and-Run) 프레임워크는 사전에 전체 계산 그래프를 최적화하고 컴파일한다. 네트워크 구조가 고정된 경우에는 이 오버헤드가 상대적으로 두드러질 수 있다.
  • 중간 활성화 메모리(VRAM) 점유: 자동 미분(autograd)을 위해 정방향 연산에서 산출된 중간 텐서(intermediate activations)를 보존해야 한다. 이 중간 활성화는 역전파 완료 시점까지 메모리에 유지된다. 따라서 모델이 깊어지거나 배치 크기가 커질수록 GPU VRAM 요구량이 증가한다.
  • 그레이디언트 누적 버퍼 관리 필요: PyTorch의 autograd 엔진은 가중치 텐서의 .grad 속성에 기울기를 덮어쓰지 않고 기본적으로 누적(accumulate)한다. 따라서 매 학습 스텝마다 optimizer.zero_grad() 또는 param.grad = None을 호출해야 한다. 버퍼를 명시적으로 초기화하지 않으면 이전 배치의 기울기가 누적되어 학습이 불안정해지거나 발산할 위험이 있다.

References

댓글남기기