본문 바로가기

Machine Learning Models/Covolutions

Temporal Convolutional Network (TCN)

반응형

Transformer 모델이 등장하기 전에는 자연어처리, 시계열 데이터 처리 등에는 RNN의 LSTM/GRU 모델이 압도적으로 많이 사용되었습니다. 그 와중에서 convolution의 locality를 잡는 특성과 dilation을 이용해 receptive field를 넓힌 WaveNet의 등장 이후 1차원 convolution을 시퀀스 데이터에 적용하려는 시도가 많이 있었는데요, 이번 포스트에서 알아볼 내용은 다양한 시퀀스 벤치마크 데이터셋에 대해서 LSTM/GRU에 비해 높은 성능을 보인 TCN (Temporal Convolutional Network) 모델입니다.

Temporal convolutional network

Causal convolutions

먼저 시퀀스 모델링을 입력 시퀀스 $x_0, ..., x_T$에 대해 출력 시퀀스 $y_0, ..., y_T$를 예측하는 태스크로 정의한다면 입력을 출력으로 매핑하는 최적의 함수 $f$를 찾는 것이라고 정의할 수 있습니다.

$\hat{y}_0, ..., \hat{y}_T = f(x_0, ..., x_T)$

여기서 중요한 점은 시계열 데이터 특성상 $t$ 시점의 출력은 미래에 의존하지 않고 현재와 과거에만 의존하는 causaulity를 만족해야 한다는 점인데요, TCN은 1차원 convolution을 사용하여 'kernel 크기 - 1' 만큼의 제로 패딩을 전체 시퀀스의 길이를 유지하기 위해 양쪽에 붙이게 되는데요, convolution 이후에 시퀀스 길이 이후의 convolution한 결과물을 잘라내어 미래시점의 정보가 현재 시점의 출력으로 흐르지 않도록 방지합니다. 이를 causal convolution이라 하며 이를 Pytorch로 구현하면 다음과 같습니다.

class Chomp1d(nn.Module):
    def __init__(self, chomp_size):
        super(Chomp1d, self).__init__()
        self.chomp_size = chomp_size

    def forward(self, x):
        return x[:, :, :-self.chomp_size].contiguous()
  • chomp_size 인수에는 제로 패딩된 패딩 크기가 들어갑니다.

Dilated convolutions

일반적인 1차원 convolution으로 멀리 떨어진 타임스텝의 정보도 파악하기 위해서는 매우 많은 층과 필터가 필요하게 됩니다. 이를 극복하기 위해서 TCN 에서는 WaveNet 모델에서 사용된 dilated convolution을 이용합니다. 필터를 $f:\{0, ..., k-1\} \rightarrow R$, 입력을 $x\in R^n$이라 할때 dilated convolution $F$는 Equation 1과 같이 동작하며 $d$는 dilation 계수, $k$는 필터 크기를 말합니다. 입력 시퀀스의 요소 $s$에 대해 $s-d\times i$ 만큼 shift 되어 필터와 곱해지므로 dilation은 인접한 필터요소 사이에 고정된 길이를 삽입하는 것과 같습니다. $d=1$이면 일반적인 convolution 이며 $d$를 크게 잡을수록 Figure 1(a) 처럼 top 레벨에서 입력의 매우 넓은 범위를 표현할 수 있습니다. 즉, convolution 층을 쌓거나 필터를 키울 필요 없이 receptive field를 손쉽게 늘릴 수 있는 것이죠.

Equation 1

TCN 에서는 convolution 층을 쌓을 수록 dilation 계수 $d$를 2의 제곱만큼 증가시켜 입력 시퀀스의 모든 요소를 바라보면서 큰 receptive field를 효율적으로 구현할 수 있게 합니다.

Residual connections

TCN의 receptive field는 네트워크 층 $d$와 $k, d$로 결정되므로 깊고 큰 TCN의 안정적인 훈련을 위해 Figure 1(b, c)와 같이 residual connection이 매 layer마다 적용됩니다. Figure 1(b)와 같이 하나의 residual block에는 같은 $d$를 가진 dilated convolution이 두번 적용되며 ReLU 함수와 dropout이 적용됩니다. 이때 Residual block의 입력과 residual function의 출력의 channel 너비가 다를 수 있으므로 다른 경우에는 입력에 1x1 convolution을 취해 channel 너비를 맞춰줍니다.

Weight normalization

Weight normalization은 batch normalization이 미니배치 단위에 적용되어 큰 배치 사이즈에 의존하고 파라미터가 공유되는 RNN 특성상 시퀀스의 각 타임스텝 별로 다른 통계치를 반영할 수 없는 문제를 (타임스텝 길이가 데이터 별로 다를 때 문제가 되겠죠.) 해결하고자 제안된 normalization 방법으로 기존 normalization은 feature activation을 대상으로 수행했다면 weight normalization은 파라미터 자체에 대한 normalization을 파라미터 $w$를 Equation 2와 같이 파라미터의 크기 $g$와 방향 $v$로 reparametrize를 통해 수행합니다.

Equation 2

$v$가 $k$ 차원의 벡터, $g$가 스칼라이고 Equation 2의 의미는 방향 $v$에 상관없이 $w$의 크기를 (norm) $\Vert w\Vert=g$로 고정시키겠다는 것을 뜻합니다. Equation 2와 같이 $w$를 $v, g$로 나눈후 각각에 대해 미분을 취하면 Equation 3이 유도되고 $\nabla_v L$은 Equation 4와 같이 표현할 수 있습니다.

Equation 3
Equation 4

Equation 4의 $M_w$는 $v$를 $w$ 벡터의 complement로 projection하는 행렬로 $w$의 반대방향으로 (orthogonal) 사상함으로써 $v$의 크기를 파라미터 업데이트가 될때마다 피타고라스 정리에 의해 증가시키게 됩니다. $\Vert v\Vert$가 커지게 되면 그만큼 Equation 4에 의해 $g/\Vert v\Vert$가 줄어들게 되는 방식으로 파라미터 기울기의 크기를 일정히 유지하게 됩니다. 이에따라 초기 learning rate가 매우 크더라도 $\Vert v\Vert$가 급격히 증가하다가 포화됨으로써 해당 learning rate에 맞추어 훈련을 안정화시킵니다. 이로인해 빠르고 안정적인 수렴이 가능하고 배치 사이즈와 상관없고 추가적인 파라미터 없이 약간의 연산량만 추가됩니다. 


TCN 에서는 Figure 1(b)와 같이 dilation convolution 이후에 weight normalization을 적용합니다. Pytorch 에서는 "torch.nn.utils" 모듈에 weight_norm 함수를 제공합니다. Figure 2와 같이 파라미터가 forward 함수 호출 이전에  "weight_g", "weight_v" 두 개의 파라미터로 나누고 hook에 등록합니다.

Figure 2

weight_norm 함수를 호출할 때의 디폴트 차원은 'dim=0'으로 선언되어 있어 출력 channel 별로 norm이 독립적으로 계산됩니다. 전체 파라미터에 대한 norm을 계산하기 위해서는 'dim=None'으로 선언해야 하며 fully-connected 함수에 대한 간단한 동작은 다음과 같습니다.

from torch.nn.utils import weight_norm

m = weight_norm(nn.Linear(20, 40), name='weight')
m # Linear(in_features=20, out_features=40, bias=True)
m.weight_g.size() # torch.Size([40, 1])
m.weight_v.size() # torch.Size([40, 20])

 

TCN implementation

먼저 Figure 1(b)의 residual block을 정의합니다. Residual function의 출력과 입력의 channel 너비가 다를 경우 1x1 convolution을 정의해줍니다.

class TemporalBlock(nn.Module):
    def __init__(self, n_inputs, n_outputs, kernel_size, stride, dilation, padding, dropout=0.2):
        super(TemporalBlock, self).__init__()
        self.conv1 = weight_norm(nn.Conv1d(n_inputs, n_outputs, kernel_size,
                                           stride=stride, padding=padding, dilation=dilation))
        self.chomp1 = Chomp1d(padding)
        self.relu1 = nn.ReLU()
        self.dropout1 = nn.Dropout(dropout)

        self.conv2 = weight_norm(nn.Conv1d(n_outputs, n_outputs, kernel_size,
                                           stride=stride, padding=padding, dilation=dilation))
        self.chomp2 = Chomp1d(padding)
        self.relu2 = nn.ReLU()
        self.dropout2 = nn.Dropout(dropout)

        self.net = nn.Sequential(self.conv1, self.chomp1, self.relu1, self.dropout1,
                                 self.conv2, self.chomp2, self.relu2, self.dropout2)
        self.downsample = nn.Conv1d(n_inputs, n_outputs, 1) if n_inputs != n_outputs else None
        self.relu = nn.ReLU()
        self.init_weights()

    def init_weights(self):
        self.conv1.weight.data.normal_(0, 0.01)
        self.conv2.weight.data.normal_(0, 0.01)
        if self.downsample is not None:
            self.downsample.weight.data.normal_(0, 0.01)

    def forward(self, x):
        out = self.net(x)
        res = x if self.downsample is None else self.downsample(x)
        return self.relu(out + res)

이후에는 층을 쌓을 수록 dilation 팩터 $d$를 2의 제곱만큼 증가시킵니다.

class TemporalConvNet(nn.Module):
    def __init__(self, num_inputs, num_channels, kernel_size=2, dropout=0.2):
        super(TemporalConvNet, self).__init__()
        layers = []
        num_levels = len(num_channels)
        for i in range(num_levels):
            dilation_size = 2 ** i
            in_channels = num_inputs if i == 0 else num_channels[i-1]
            out_channels = num_channels[i]
            layers += [TemporalBlock(in_channels, out_channels, kernel_size, stride=1, dilation=dilation_size,
                                     padding=(kernel_size-1) * dilation_size, dropout=dropout)]

        self.network = nn.Sequential(*layers)

    def forward(self, x):
        return self.network(x)

예를 들어 MNIST 예측 같은 경우 시퀀스의 마지막 타임스텝에 대해 fully-connected layer를 달아 전체 TCN 모델을 완성합니다.

class TCN(nn.Module):
    def __init__(self, input_size, output_size, num_channels, kernel_size, dropout):
        super(TCN, self).__init__()
        self.tcn = TemporalConvNet(input_size, num_channels, kernel_size=kernel_size, dropout=dropout)
        self.linear = nn.Linear(num_channels[-1], output_size)

    def forward(self, inputs):
        """Inputs have to have dimension (N, C_in, L_in)"""
        y1 = self.tcn(inputs)  # input should have dimension (N, C, L)
        o = self.linear(y1[:, :, -1])
        return F.log_softmax(o, dim=1)

 

Discussion

그렇다면 TCN이 RNN 계열에 비해 가지고 있는 장점은 무엇일까요?

  • RNN의 치명적인 단점은 현재시점의 계산을 수행하기 위해서는 직전 단계의 계산이 완료되기까지 기다려야 되므로 병렬화가 매우 힘들다는 점입니다. 하지만 TCN은 convolution을 사용하므로 같은 파라미터에 대해 병렬적으로 연산되므로 직렬적인 RNN 연산에 비해 매우 빠릅니다.
  • TCN은 모델의 깊이와 dilation으로 receptive field 크기를 유연하게 조절할 수 있습니다.
  • RNN 계열은 전 타임스텝까지 기울기를 계산해야하므로 exploding/vanishing 기울기 문제에 매우 취약합니다. 
  • 훈련 시에 매우 긴 입력 시퀀스에 대하여 LSTM/GRU 등은 타임 스텝별 게이트 결과를 저장해야하므로 많은 메모리가 소요되지만 TCN은 하나의 layer에 대하여 같은 파라미터가 공유되므로 메모리 소요량이 적습니다.
  • RNN과 마찬가지로 다양한 입력 길이에 대해 적용 가능합니다.

단점으로는 테스트 시에 RNN 에서는 전체 시퀀스에 대한 요약 압축본이 $h_t$에 담겨있으므로 기존의 관측치가 제거되어도 상관없지만 TCN 에서는 전체 시퀀스에 대한 convolution 연산을 수행해야하므로 메모리가 더 필요할 수 있다는 점입니다. 마지막으로 다양한 시퀀스 모델링 태스크에 대한 TCN과 LSTM/GRU의 결과는 Table 1과 같습니다.

 

참조

반응형

'Machine Learning Models > Covolutions' 카테고리의 다른 글

MobileNet (2)  (0) 2021.05.29
MobileNet (1)  (0) 2021.05.28
Convolution 의 종류  (0) 2021.05.27
Slimmable Neural Networks  (0) 2021.03.14
Learning Efficient Convolutional Networks through Network Slimming  (0) 2021.03.13