Basic Deep Learning/Dive into Deep Learning 리뷰

[D2L] 6. CNN

needmorecaffeine 2022. 7. 25. 11:01

1. Intro 

이번 장부터 Convolution Neural Network에 대해 다루기 시작한다. 구체적인 방법론에 대해 배우기 전 그 기초 내용과 CNN의 intention에 대해 짚고 넘어가고자 한다.

 

 image 데이터는 two-dimensional grid of pixel로 표현된다.(색, 채널에 대해서는 이후에 언급) 

각각의 pixel 하나 또는 여러개의 수치로 각각 표현되는데 이전까지는 image를 flattening하여 vaector로 다룸으로써 pixel간 spatial relation을 무시하였다. 다시 말해 fully connected MLP를 통해 일차원 벡터를 다뤘던 것이다.

 

 이러한 이전까지 진행했던 방식을 다시 되짚어보았고 CNN은 연산의 효율성이 좋고 GPU 병렬 연산화가 쉽다는 점에서 현재 CV 분야에서 가장 많이 사용 및 활용되는 모델이다.

 

 object detection task를 예로 들자면, 목표한 object를 감지할 수 있는 detector가 있고 그 detector는 각각의 path에 그 patch가 object를 포함할 확률에 대해 score를 주는 방식으로 task를 수행할 수 있다. CNN은 이런 spatial invariance 아이디어에 대해 체계화하였고 더 적은 파라미터로 유용한 representation을 학습할 수 있다. 이렇게 얘기한 직관적인 얘기를 다시 정리하면 다음과 같다.

 

  1. 초기 layer에서는 network가 path가 전체 이미지의 어디에 위치하든지 관계 없이 같은 patch에 대해 비슷하게 반응해야 한다. 이것을 translation invariance(translation equivariance)라고 한다.
  2.  초기 layer는 떨어져있는 지역의 contents를 신경쓰는 것이 아닌 local region에 집중해야 한다. 이것이 locality principle이다. 이러한 local representation이 모여 전체 image level에 대한 예측을 할 수 있다.
  3. 점점 더 층이 깊어지고 학습이 진행될수록, 이미지의 더 많은 범위의 feature에 대해 캡쳐할 수 있고 실제의 vision 형태와 가까워진다.

 

2. Constraining MLP

기존에 배웠던 MLP에서 실제 이미지 데이터의 spatial structure 정보를 가지는 형태로의 연산으로 확대하는 과정이다.

 W에서 V로 변환한 것은 단순히 4차원의 텐서간 coeffecients를 일대일 대응하는 방식으로 표현하기 위함이다.

또한 위에서 언급했던 translation invariance를 위해서는 픽셀 위치인 (i,j)에 따라 V, U의 값이 변하지 않아야 하므로 맨 아랫줄과 같은 식으로 fully connected한 방식으로 표현할 수 있다. 

 

 다음의 또다른 표현은 locality principle의 내용을 담고 있다.

 이전에도 말했듯 여기서의 locality는 [H]i,j에 대해 연산할 때는 픽셀 (i,j)에서 멀리 떨어진 위치에 대해서 볼 필요가 없다는 원칙이었다.

이제 CNN의 개념을 조금 덧붙이자면 V는 convolutional kernel, filter, layer's weights라고 이후 불리게 된다.

 

 다시 위 내용들을 살펴보면 MLP와 다르게 input의 차원을 변화시키지 않고 그대로 받아왔으며 동시에 MLP의 fully connected layer와 다르게 훨씬 더 적은 파라미터를 가져 연산에 효율적이다. 다만 위 과정은 모두 bias가 inductive하다는 가정이 성립되어야 한다. bias가 실제 현실과 동떨어진, 즉 이미지에 대해 일반화할 수 없다면 위 두 원칙을 포함해 제대로 학습이 이뤄질 수 없다.

 

 

3. Convolutions

 convolution 연산에 대해 살펴보자

 convolution 연산의 의미는 measure the overlap btw f and g when one function is "flipped" and shifted by x 이다.

마지막 일반화된 convolution 연산을 살펴보면 이전에 (i+a, j+b)로 표현했던 연산이 현재는 그 차이로만 다르게 표현되고 있다는 것을 확인할 수 있다. 이런 convolution 연산은 cross-correlation을 표현한다고 말한다.

 

4. Channel 

 이전까지의 내용들은 channel에 대해 무시한채 height과 width만을 고려했다. 이제 channel의 내용도 반영하자면 input은 2차원이 아닌 3차원이다. height과 width는 spatial relationship을, channel은 각각의 pixel 위치에 대해 multidimensional representation을 부여하는 것으로 이해하면 된다. 

d를 output channel로 index하고 채널의 차원을 반영하면 다음과 같은 식이 최종적으로 유도된다.

5. Cross Correlation operation

 본 챕터에서 가장 강조하는 용어 중 하나로 cross correlation이 있다. 이는 convolution layer의 기능의 표현으로 input tensor와 kernel tensor가 결합되어 output tensor를 반환하는 기능을 말한다. 아래 사진과 같다.

 19 = 0*0+1*1+2*3+4*4 로 kernel window가 좌측, 상단부터 우측, 하단까지 slice하며 연산을 수행한다. 이렇게 연산을 하게 되면 output size는 다음과 같이 정해진다. (input = Nh * Nw, output = Kh * Kw)

 위 연산을 수행하는 함수 코드는 다음과 같다.

# Compute 2D cross-correlation
# X = input tensor / K = kernel tensor / Y = output tensor

def corr2d(X, K):
  h, w = K.shape
  Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
  for i in range(Y.shape[0]):
    for j in range(Y.shape[1]):
      Y[i,j] = (X[i:i+h, j:j+w]*K).sum()
  return Y

실제 연산결과를 확인해보면 제대로 연산이 되는 것을 확인할 수 있다.

 forward propagation과 bias까지 더하는 과정을 class로 구성하면 다음과 같다.

class Conv2D(nn.Module):
  def __init__(self, kernel_size):
    super().__init__()
    self.weight = nn.Parameter(torch.rand(kernel_size))
    self.bias = nn.Parameter(torch.zeros(1))

  def forward(self, x):
    return corr2d(x, self.weight) + self.bias

 위를 이용해 object의 edge를 detect하는 방식을 간단하게 구현해보자. 세부사항은 주석에 명시하였다.

 최종 Y에서 확인할 수 있듯, 같은 색이 인접할 경우 0을, 다른 색이 인접할 경우 1 또는 -1을 반환하여 그 edge를 찾을 수 있다. 이 때 kernel을 torch.tensor([[1, -1]])을 통해 cross correlation을 수행했기에 가능하였다.

 

 위의 task는 input의 크기가 작았고 직관적으로 그 연산을 정의하여 수행할 수 있었다. 하지만 input의 크기가 더 커지고 convolution layer가 더 쌓일수록 위와 같이 수동적으로 정의하고 진행할 수 없다.

 

 그래서 원하는 task 수행을 위한 kernel을 데이터를 통해 학습해야 한다. 그 간단한 구현은 다음과 같다.

# Learning a kernel

# kernel = (1,2) / ignore bias
conv2d = nn.LazyConv2d(1, kernel_size = (1,2), bias = False)

# two dimensional convolution layer uses four dimensions (example, channel, height, width)
# batch size & # of channels are 1

X = X.reshape((1,1,6,8))
Y = Y.reshape((1,1,6,7))
lr = 3e-2

for i in range(10):
  Y_hat = conv2d(X)
  # use square error
  l = (Y_hat - Y) ** 2 
  # calculate gradient and update the kernel
  conv2d.zero_grad()
  l.sum().backward()
  conv2d.weight.data[:] -= lr * conv2d.weight.grad
  if (i+1) % 2 == 0 :
    print(f'epch {i+1}, loss {l.sum():.3f}')

 실제 학습결과 위 torch.tensor([[1, -1]])과 유사해짐을 알 수 있다.

 LazyConv2d에 대한 내용은 아래를 참조했다.

6. Padding

 위 예시에서 input tensor 3*3에서 output tensor 2*2를 반환한 것을 통해 알 수 있듯이, convolution layer를 그냥 통과한다면 pixel의 손실이 일어난다. 특히 convolution size에 따라 아래와 같이 코너에 있는 pixel들을 거의 사용되지 않는 문제가 생긴다.

 그래서 도입된 기법 중 하나가 padding이다. 여기선 0의 값을 가지는 extra pixel을 가장자리에 더해 image의 size를 증가시켜 위와 같은 pixel 손실을 막는 것이다. 

 위와 같이 0 padding을 하여 input이 5*5가 되고 output도 4*4로 증가하여 반환하게 된다. 이 때 Ph rows, Pw columns를 더하게 될 경우 output의 사이즈는 다음과 같이 정해진다.

 이 때 Ph = Kh -1, Pw = Kw -1 이라면 input과 output의 사이즈는 같아진다. 이렇게 되면 input의 dimensionality를 유지하게 된다.

통상적으로 padding은 위아래, 좌우 같은 pixel 사이즈를 더해주기 위해 홀수의 height과 width로 설정한다. 이를 구현한 함수는 다음과 같다.

def comp_conv2d(conv2d, X):
  # (1,1) = (batch size, # of channels)
  X = X.reshape((1,1)+X.shape)
  Y = conv2d(X)
  # strip examples and channels
  return Y.reshape(Y.shape[2:])

  실제 input과 output size가 같음을 알 수 있다.

kernel의 height과 width가 달라도 padding 숫자를 다르게 설정하여 output size를 같게 만들 수 있다.

 

7. Stride

 이전까지는 cross correlation을 계산하는데 있어 좌측 상단부터 우측 하단까지 모든 곳에 대해 slide하며 진행했다. 하지만 연산의 효율성과 downsample을 위해 하나 이상의 element를 skip하며 slide할 수 있고 이를 stride 설정을 통해 구현할 수 있다. 다시말해 한번의 slide마다 몇개의 row와 column을 지나칠 것인지를 설정하는 것이 stride이다. 

 

 아래의 예시는 stride 3 vertically, stride 2 horizontally를 수행한 결과이다.

 stride for height = Sh, stride for width = Sw라고 설정하게 되면 다음과 같이 output shape이 정해진다.

 위의 예시에서 stride를 설정했을 때의 결과이다.


8. Multiple Input & Multiple Output Channels

 이전까지는 channel을 고려하지 않은 2차원 tensor만을 고려했고 여기서는 channel까지 반영한 3차원 tensor로 다뤄보겠다. 

channel이 반영되면 이전까지 고려한 kernel의 Kh x Kw를 각각의 channel과 함께 고려해야한다. 

channel을 반영한 함수는 다음과 같고 이 함수를 통해 위 사진과 동일한 연산 결과를 얻을 수 있다.

def corr2d_multi_in(X, K):
    # Iterate through the 0th dimension (channel) of K first, then add them up
    return sum(d2l.corr2d(x, k) for x, k in zip(X, K))

 

 또한 위에서는 input에 대해 channel을 고려했는데 output에서도 channel을 고려해야 한다. 실제 대부분의 신경망들이 각각의 layer에 multiple output channel을 가지고 있고 이것은 layer를 지날수록 그 channel의 차원이 커지기 때문에 반드시 고려해야 한다.

 

 Ci와 Co가 각각 # of input and output channel이라고 한다면 

Output channel Kernel : Ci x Kh x Kw 이고 이를 concatenate하여 output channel dimension을 구성한다면

Shape of convolution kernel  : Co x Ci x Kh x Kw 이 된다.

 

def corr2d_multi_in_out(X, K):
    # Iterate through the 0th dimension of `K`, and each time, perform
    # cross-correlation operations with input `X`. All of the results are
    # stacked together
    return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

 마지막 output의 첫번째 결과가 위의 ouput과 동일하게 나온 것을 확인할 수 있다.


9. 1X1 Convolution Layer

 1x1 conv layer가 어떤 의미가 있을까? 1x1 conv layer는 인접한 pixel간 relation을 파악하지 못한다. 그럼에도 불구하고 이 연산은 몇몇 모델에도 포함되어있는 유명한 연산이다.

 1x1 conv layer는 인접한 요소간 패턴을 파악할 수 없고 channel dimension에서만 그 연산이 수행된다.

아래 사진을 보면 이 연산이 의미하는 바를 이해할 수 있는데 output의 각각의 요소는 같은 position의 input 요소에 대한 linear combination이다.  다른 conv layer가 nonlinear하다는 점에서 구별된다. 또한 이것은 각각의 pixel에 적용되는 fully connected layer로 Ci를 Co로 변환하는 기능을 한다고도 말할 수 있다. 1x1 conv layer는 Co x Ci weight이 필요하고 픽셀 위치로 연산이 되는 것은 동일하다.

def corr2d_multi_in_out_1x1(X, K):
    c_i, h, w = X.shape
    c_o = K.shape[0]
    X = X.reshape((c_i, h * w))
    K = K.reshape((c_o, c_i))
    # Matrix multiplication in the fully connected layer
    Y = torch.matmul(K, X)
    return Y.reshape((c_o, h, w))

 이 연산은 이전의 corr2d_multi_in_out의 연산과 그 결과를 비교해보면 다음과 같다.


10. Pooling

 pooling 연산도 conv layer와 마찬가지로 모양이 고정되어 있는 window로 stride에 따라 모든 위치를 slide 하지만 conv layer와 다르게 kernel이 없고 고로 파라미터도 존재하지 않는다. pooling은 단순히 window에 해당하는 pixel 값에 대한 maximum 또는 average값을 계산하는 연산이다.(다른 연산도 가능은 하겠지?)

 

 이 연산은 conv layer의 지역에 대한 sensitivity를 다루고 spatially downsampling하는 두가지 목적이 있다.

 

average pooling은 이미지의 downsampling 하기위해 고안되었고 모든 pixel에 대해 연산을 하는 것 보다는 noise ratio를 window의 평균값을 통해 줄이는 효과를 가진다. 하지만 이는 고전적인 방법으로 max pooling이 더 자주 사용된다.

 이렇게 2x2 max pooling을 사용하게 되면 고정되지 않은 이미지의 pixel이 1만큼 이동하더라도 그 변화에 반응하지 않고 동일한 output을 반환한다.

pooling 구현한 코드는 다음과 같다.

def pool2d(X, pool_size, mode = 'max'):
  p_h, p_w = pool_size
  Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
  for i in range(Y.shape[0]):
    for j in range(Y.shape[1]):
      if mode == 'max':
        Y[i,j] = X[i: i+p_h, j: j+p_w].max()
      elif mode == 'avg':
        Y[i,j] = X[i: i+p_h, j: j+p_w].mean()
  return Y

 

 pooling 또한 stride 설정이 가능하다.

 위 MaxPool2d의 pooling에서의 stride는 default로 window size와 같기 때문에 맨 처음 output은 10이 나왔고 그 이후에서는 stride를 조정한 결과이다.

 


11. LeNet

LeNet은 CNN계열의 시초로 이 때는 아직 ReLU와 max pooling의 효용성을 적용하지 않았을 때여서 Sigmoid와 average pooling이 사용되었다. 구성은 다음과 같다.

net = nn.Sequential(
    nn.Conv2d(1,6, kernel_size = 5, padding = 2), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size = 2, stride = 2),
    nn.Conv2d(6, 16, kernel_size = 5), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size = 2, stride = 2),
    nn.Flatten(),
    nn.Linear(16*5*5, 120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.Sigmoid(),
    nn.Linear(84, 10) # 임의로 remove sigmoid
)

 nn.Sequential로 위와 같이 구현하였고 실제 LeNet과 다른점은 마지막 linear에서 sigmoid activation을 사용하지 않았다는 점이다.

X = torch.rand(size = (1,1,28,28), dtype = torch.float32)
for layer in net:
  X = layer(X)
  print(layer.__class__.__name__, 'output shape: \t', X.shape)

 위 코드로 연산과정을 살펴보면 다음과 같다. 첫 conv layer에서는 padding이 2로 kernel size가 줄어들지 않았지만 두번째 conv layer에서는 kernel size가 14로 줄어드는 것을 확인할 수  있다. 그리고 채널 차원또한 6에서 16으로 증가하였다.

training을 위해 설정한 내용은 다음과 같으며 이전 장과 달리 gpu사용을 위해 수정한 라인들이 있다.

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size = batch_size)

# compute the accuracy for a model on a dataset using GPU

def evaluate_accuracy_gpu(net, data_iter, device = None):
  if isinstance(net, nn.Module):
    net.eval()
    if not device :
      device = next(iter(net.parameters())).device
  metric = d2l.Accumulator(2)

  with torch.no_grad():
    for X, y in data_iter:
      if isinstance(X, list):
        # required for BERT fine tuning
        X = [x.to(device) for x in X]
      else:
        X = X.to(device)
        y = y.to(device)
        metric.add(d2l.accuracy(net(X), y), y.numel())
  return metric[0] / metric[1]
  
  
# train a model with GPU

def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
  def init_weights(m):
    if type(m) == nn.Linear or type(m) == nn.Conv2d:
      nn.init.xavier_uniform_(m.weight)
  net.apply(init_weights)
  print("training on", device)
  net.to(device)
  optimizer = torch.optim.SGD(net.parameters(), lr = lr)
  loss = nn.CrossEntropyLoss()
  animator = d2l.Animator(xlabel = 'epoch', xlim = [1, num_epochs], legend = ['train loss', 'train acc', 'test acc'])
  timer, num_batches = d2l.Timer(), len(train_iter)
  for epoch in range(num_epochs):
    metric = d2l.Accumulator(3)
    net.train()
    for i, (X, y) in enumerate(train_iter):
      timer.start()
      optimizer.zero_grad()
      X, y = X.to(device), y.to(device)
      y_hat = net(X)
      l = loss(y_hat, y)
      l.backward()
      optimizer.step()
      with torch.no_grad():
        metric.add(l*X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
      timer.stop()
      train_l = metric[0] / metric[2]
      train_acc = metric[1] / metric[2]
      if (i+1) % (num_batches // 5) == 0 or i == num_batches -1 : 
        animator.add(epoch + (i+1) / num_batches,(train_l, train_acc, None))
    test_acc = evaluate_accuracy_gpu(net, test_iter)
    animator.add(epoch + 1, (None, None, test_acc))
    print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
          f'test acc {test_acc:.3f}')
    print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec 'f'on {str(device)}')

 training 결과이다.