Block 구조
- BasicBlock: ResNet 18, 34
- Bottleneck: ResNet 50, 101, 152
Q. ResNet의 Architecture를 보면 Pooling을 맨 처음과 맨 끝을 제외하고 사용하지 않는다. 그렇다면 Feature Map의 size를 어떻게 줄이는가?
A. 실선을 Projection Shorcut, 점선을 Identity Shortcut 이라 한다.
Projection은 $1 \times 1$ Convolution이며, Down-Sampling (with stride=2) 및 Channel ($1 \times 1$ Convolution) 수를 맞춰준다.
만약 Size가 같을 때는 stride=1로 맞춰준다.
BasicBlock 정의
import torch
import torch.nn as nn
import torch.nn.functional as F
# ResNet 18, 34는 BasicBlock을, ResNet50, 101, 152는 BottleNeck을 사용한다
class BasicBlock(nn.Module):
expansion = 1 # class 속성 (class 변수)
def __init__(self, in_channels, inner_channels, stride=1, projection=None):
super(BasicBlock, self).__init__()
self.residual = nn.Sequential(
nn.Conv2d(
in_channels=in_channels,
out_channels=inner_channels,
kernel_size=3,
stride=stride,
padding=1,
bias=False,
),
nn.BatchNorm2d(inner_channels),
nn.ReLU(inplace=True),
nn.Conv2d(
in_channels=inner_channels,
out_channels=inner_channels * self.expansion,
kernel_size=3,
stride=1,
padding=1,
bias=False,
),
nn.BatchNorm2d(inner_channels)
)
self.projection = projection
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
residual = self.residual(x)
if self.projection: # 점선: 다른 stage로부터 projection된 값을 받음
shortcut = self.projection(x)
else: # 실선: just identity mapping
shortcut = x
out = self.relu(residual + shortcut)
return out
Bottleneck 정의
- 기존 BasicBlock과 같이 $3 \times 3$ 사용하고 깊게 만들면 파라미터 수가 너무 많아짐
- $1 \times 1$ Convolution으로 Channel수를 줄인 다음 $3 \times 3$을 통과시키고 다시 $1 \times 1$ Convolution으로 채널 수를 키운다
- $1 \times 1$ Convolution: Channel수 조절
- $3 \times 3$ Convolution: Spatial Information 확인
class Bottleneck(nn.Module):
expansion = 4
def __init__(self, in_channels, inner_channels, stride=1, projection=None):
super(Bottleneck, self).__init__()
self.residual = nn.Sequential(
nn.Conv2d(
in_channels=in_channels,
out_channels=inner_channels,
kernel_size=1,
stride=1,
padding=0,
bias=False,
),
nn.BatchNorm2d(inner_channels),
nn.ReLU(inplace=True),
nn.Conv2d(
in_channels=inner_channels,
out_channels=inner_channels,
kernel_size=3,
stride=stride,
padding=1,
bias=False,
),
nn.BatchNorm2d(inner_channels),
nn.ReLU(inplace=True),
nn.Conv2d(
in_channels=inner_channels,
out_channels=inner_channels * self.expansion,
kernel_size=1,
stride=1,
padding=0,
bias=False
),
nn.BatchNorm2d(inner_channels * self.expansion),
)
self.projection = projection
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
residual = self.residual(x)
if self.projection:
shortcut = self.projection(x)
else:
shortcut = x
out = self.relu(residual + shortcut)
return out
ResNet Architecture 설계
- Projection(점선 연결): conv3_1, conv4_1, conv5_1에서 Channel 수 변경($1 \times 1$ Convolution)이 공통적으로 일어남
- Size 조절, Down-Sampling (stride=2): stride=2는 con3v_1, conv4_1, conv5_1(First Block)의 First Layer에서 발생
- Channel 수 변경($1 \times 1$ Convolution): ResNet50, 101, 152는 conv2_1 (First Block) 에서도 Projection
stride=2: conv3_1, conv4_1, conv5_1에서 BasicBlock의 first $3 \times 3$ Conv Layer or Bottleneck의 $3 \times 3$에 stride=2를 적용한다
# block: BasicBlock인지, BottleNeck인지 (ResNet의 Layer수에 따라 다르다)
class ResNet(nn.Module):
def __init__(self, block, num_block_list, num_classes=1000, zero_init_residual=True):
super(ResNet, self).__init__()
self.in_channels = 64
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) # 112 * 112 * 64
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True) # Memory Efficiency
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 56 * 56 * 64
self.stage1 = self.make_stage(block, 64, num_block_list[0], stride=1)
self.stage2 = self.make_stage(block, 128, num_block_list[1], stride=2)
self.stage3 = self.make_stage(block, 256, num_block_list[2], stride=2)
self.stage4 = self.make_stage(block, 512, num_block_list[3], stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
# bias는 없으니 weight만 initialization
for module in self.modules():
if isinstance(module, nn.Conv2d):
nn.init.kaiming_normal_(module.weight, mode="fan_out", nonlinearity="relu")
if zero_init_residual:
for module in self.modules():
if isinstance(module, block):
nn.init.constant_(module.residual[-1].weight, 0)
def make_stage(self, block, inner_channels, num_blocks, stride=1):
"""
- self.in_channels: 현재 처리 중인 block의 in_channels (직전 block의 out_channels와 동일)
- inner_channels: 특정 block(BasicBlock, Bottleneck)에서 사용되는 중간 Channel 수
- 같은 block안에서는 convolution의 stride가 항상 1
"""
# Stage가 바뀔 때 (projection을 통해 block의 마지막 layer 이후로 이동)
if (stride != 1) or (self.in_channels != inner_channels * block.expansion):
# stride = 1이여도 채널 수가 다르면 (stage1의 첫번째 BottleNeck) projection 해야함 (이 때는 resoltion은 그대로, 채널 수만 늘어남)
projection = nn.Sequential(
nn.Conv2d(self.in_channels, inner_channels * block.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(inner_channels * block.expansion) # 점선 Connection
) # projection을 통해 1 x 1 convolution으로 channel 개수 조정
else:
projection = None
layers = []
layers += [block(self.in_channels, inner_channels, stride, projection)] # projection은 첫 block에서만
self.in_channels = inner_channels * block.expansion
# 해당 stage의 (각 block 개수 - 1) 만큼 반복
for _ in range(1, num_blocks):
layers += [block(self.in_channels, inner_channels)]
return nn.Sequential(*layers)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
x = self.stage1(x)
x = self.stage2(x)
x = self.stage3(x)
x = self.stage4(x)
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
Q. self.in_channels가 현재 처리 중인 block의 in_channels (직전 block의 out_channels와 동일)
이므로 직전 block의 out_channels을 기준으로 projection 시키는것인가?
A. Yes, 'self.in_channels'는 현재 처리 중인 블록의 입력 채널 수를 나타내고, 해당 값은 직전 블록의 출력 채널 수와 동일해야 한다.
네트워크의 각 스테이지나 블록이 처리되면서 채널 수가 변할 수 있다. 예를 들어, 네트워크에서 다운샘플링을 수행하거나, 더 복잡한 특징을 추출하기 위해 채널 수를 증가시킬 수 있다. 이때 'self.in_channels'와 다음 블록 'inner_channels * block.expansion' 값이 일치하지 않는 경우가 다음의 2가지 상황에서 발생할 수 있다.
Projection의 2가지 기능
- Down Sampling: 스테이지가 변경되면서 공간적 차원이 줄어들고(예: 특성 맵의 크기가 줄어듬), 종종 채널 수가 증가한다. 이때 stride가 1이 아닌 다른 값으로 설정된다.
- Channel 수의 변경: 블록을 통과하면서 출력 채널 수가 입력 채널 수와 다르게 설정될 수 있다. 이는 block.expansion을 통해 관리된다. 예를 들어, Bottleneck 블록에서는 expansion 값이 4이므로, 블록의 출력 채널 수는 입력 채널 수의 4배가 된다.
def resnet18(**kwargs):
return ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)
def resnet34(**kwargs):
return ResNet(BasicBlock, [3, 4, 6, 3], **kwargs)
def resnet50(**kwargs):
return ResNet(Bottleneck, [3, 4, 6, 3], **kwargs)
def resnet101(**kwargs):
return ResNet(Bottleneck, [3, 4, 23, 3], **kwargs)
def resnet152(**kwargs):
return ResNet(Bottleneck, [3, 8, 36, 3], **kwargs)
Q. Convolution Operation에서 bias=False로 설정하는 이유는?
A. ResNet 구현에서 bias=False를 사용하는 주된 이유는 컨볼루션 레이어 다음에 바로 배치 정규화(Batch Normalization) 레이어가 오기 때문이다. Batch Normalization Layer는 입력의 평균을 0으로 정규화하고, 분산을 1로 정규화하는 과정을 수행한다.
이 과정에서 Batch Normalization Layer는 자체적으로 two learnable parameter, 즉 스케일(scale)과 시프트(shift) 매개변수를 갖는다. 이 매개변수들은 각 레이어의 출력을 조정하는 데 사용된다.
Convolution Layer에서의 bias term은 Additional Linear Transformation을 제공한다. 즉, Convolution Operation 후에 상수 항을 더하는 역할을 한다. 그러나 Batch Normalization를 적용하면, 입력의 평균을 조정하는 과정에서 이 바이어스 항이 무효화된다. 즉, Batch Normalization 과정에서 평균을 0으로 만드는 단계가 biast term의 영향을 상쇄하기 때문에, Convolution Layer에서 bias를 추가하는 것은 불필요한 매개변수를 늘리는 것과 같다.
따라서, bias=False를 설정함으로써 모델의 매개변수 수를 줄이고, 학습 과정에서의 계산량을 약간이나마 줄일 수 있다. 또한, 이는 모델의 과적합(overfitting) 위험을 줄이는 데에도 도움이 될 수 있다. 이러한 이유로, 배치 정규화를 사용하는 대부분의 현대적인 신경망 아키텍처에서는 컨볼루션 레이어에 바이어스를 포함시키지 않는 것이 일반적인 관례가 되었다.
Q. expansion을 생성자 안에 정의하지 않아도 self.expansion으로 접근할 수 있는가?
A. expansion을 class 변수로 사용하기에 가능하다.
Q. self.projection에서 projection을 할당하는데, 이때 projection은 nn.Sequential( )이 container라 attribute로 보는건가?
A. Yes, self.projection 이라는 속성에 function 혹은 Module이 할당될 수 있다.
PyTorch의 nn.Sequential이 컨테이너라는 사실은 이 속성이 호출 가능한 객체(즉, 함수처럼 동작할 수 있는 객체)를 참조할 수 있음을 의미한다. 이는 OOP에서 흔히 볼 수 있는 패턴으로, 객체(또는 클래스의 인스턴스)가 다른 객체를 속성으로 가질 수 있다.
즉, Python에서는 모든 것이 객체이므로, 함수나 모듈(여기서는 nn.Sequential 인스턴스) 역시 다른 객체의 속성으로 할당될 수 있다.
nn.Sequential을 self.projection 같은 속성에 할당하는 것은 PyTorch에서 일반적인 사용 방법이다. 이렇게 할당된 nn.Sequential Container는 해당 인스턴스의 메서드가 아니라 멤버 변수(속성)로 취급된다. 하지만, nn.Sequential 인스턴스는 내부적으로 __call__메서드를 구현하고 있어, 함수처럼 호출할 수 있으며, 이 호출은 nn.Sequential에 포함된 모든 모듈의 forward 메서드를 순차적으로 실행한다.
따라서, self.projection을 속성으로 두고 nn.Sequential 인스턴스를 할당하는 것은 완전히 정상적이며, 이 속성을 나중에 "호출"할 수 있다(예: self.projection(x)). 이런 방식은 PyTorch에서 모델의 구성 요소를 조직화하고 재사용하는 데 매우 유용하며, 코드의 가독성과 유지보수성을 향상시킨다.
Q. zero_init_residual의 의미는 무엇인가?
A. 이 코드는 ResNet 구조에서 잔차 블록(residual block)의 마지막 배치 정규화 레이어(BatchNorm2d)의 가중치를 0으로 초기화하는 역할을 한다.
각 잔차 블록(residual block)의 마지막 배치 정규화(Batch Normalization, BN) 레이어의 가중치를 0으로 설정하는 주된 이유는, 네트워크의 학습 초기 단계에서 각 Residual Block이 identity mapping에 가깝게 동작하도록 유도하기 위함이다.
일반적으로 ResNet에서는 Layer를 추가하여 깊게 쌓았을 때 task를 위해 도움이 되는 순기능을 할 수 있다. 다만 초반에는 random initialization에서 시작하는 수많은 parameter들이 순기능을 내기까지 많은 시간이 걸릴 수 있다. 따라서, Layer를 필요할 때 건너뛸 수 있는 Skip-Connection(Identity Mapping)을 사용한다.
이렇게 하면 학습 초기에 네트워크가 더 안정적으로 수렴할 수 있으며, 특히 깊은 네트워크에서 발생할 수 있는 그래디언트 소실(vanishing gradient) 또는 폭발(exploding gradient) 문제를 완화하는 데 도움이 된다.
0으로 설정하는 이유
- Identity Mapping 강화: 잔차 블록의 출력을 초기에 0으로 만들어 주면, 해당 블록은 입력에 아무런 변화도 가하지 않는 것처럼 동작한다. 이는 네트워크가 깊어질수록 추가적인 레이어가 단순히 항등 함수처럼 동작하게 하여, 초기 학습 단계에서 더 안정적으로 학습할 수 있는 기반을 마련해 준다.
- Learning Stability: 초기에 네트워크의 각 부분이 안정적으로 학습할 수 있도록 하여, 전체 네트워크의 학습 과정을 안정화시킨다.
0으로 설정하지 않을 경우:
- Batch Normalization 레이어의 가중치(gamma)는 학습 가능한 매개변수로, 초기에는 일반적으로 1로 설정된다. Batch Normalization은 입력된 특성을 정규화한 다음, 이 가중치(gamma)와 편향(beta)을 사용하여 스케일 및 시프트를 수행한다. gamma가 1이고 beta가 0으로 초기화되면, 초기에는 BN 레이어가 단순히 정규화된 출력을 그대로 전달하게 된다.
- 가중치(gamma)를 1로 초기화하는 경우, 잔차 블록은 학습 초기부터 입력에 어느 정도 변화를 가할 수 있다. 이는 네트워크가 깊어질 때 학습을 복잡하게 만들 수 있으며, 초기 학습 단계에서 학습의 안정성을 저해할 수 있다.
Q. expansion의 경우 class variable인데, self.expansion을 통해 instance에도 접근해도 되는가?
A. Python과 PyTorch에서 클래스 변수(또는 클래스 속성)를 self.class_variable 형태로 사용하는 것은 일반적인 패턴이다. 여기서 언급한 주의사항은 클래스 변수와 인스턴스 변수 간의 잠재적인 이름 충돌과 관련이 있다.
self.expansion사용의 정당성
expansion 같은 클래스 변수를 self.expansion을 통해 사용하는 것은 PyTorch에서 널리 퍼진 관례입니다. BasicBlock 또는 Bottleneck 같은 경우, expansion은 모든 인스턴스에 대해 동일한 값을 가지므로, 이 값을 클래스 변수로 정의하는 것이 합리적입니다.
주의사항
- 클래스 변수를 self.class_variable 형태로 접근하는 것은 기술적으로 문제가 없지만, 이 방식을 사용하면 나중에 인스턴스 변수를 같은 이름으로 선언할 때 클래스 변수를 가리게 되는(은폐하는) 경우가 발생할 수 있습니다. 이런 의도치 않은 은폐는 버그의 원인이 될 수 있습니다.
- 하지만, expansion과 같이 변경되지 않거나 모든 인스턴스에 대해 일관된 값을 가져야 하는 속성의 경우, self.expansion을 사용하는 것이 코드의 가독성을 높이고 PyTorch의 모듈화된 구조에 적합합니다.
결론
expansion과 같은 클래스 변수가 모든 인스턴스에 공통적인 값을 가지며, 해당 변수가 인스턴스 메서드 내에서 사용될 때, self.expansion을 사용하는 것은 안전하고, PyTorch의 설계 패턴에 부합합니다. 그러나 클래스와 인스턴스 변수 간의 잠재적인 이름 충돌에 주의하며 코드를 작성해야 합니다. 클래스 레벨에서 접근해야 하는 상황이 명확한 경우에는 ClassName.class_variable 형식을 사용하여 명시적으로 클래스 변수에 접근하는 것이 좋습니다.