while (1): study();

Ch5. 순환 신경망(RNN) 본문

독서

Ch5. 순환 신경망(RNN)

전국민실업화 2021. 7. 28. 20:51
728x90

 드디어 자연어처리의 핵심 기술 중 하나라고 할 수 있는 순환 신경망에 접어들었습니다. 저번 장에서 구현했던 CBOW는 기본적으로 FFN(Feed Forward Network)이며, 결과적으로 임의의 귀납적 편향(Arbitrary inductive bias)를 주입합니다. 쉽게 말하면 자연어의 특성 중 하나인 순차성에 대해서는 전혀 고려하지 않는다는 것입니다.

 따라서 언어모델을 구축하기 위해서는 다른 아키텍처가 필요합니다. 언어모델(LM; Language Model)이란 특정한 언어 시퀀스의 확률을 반환하는 모델으로, 실제 일상 속 문장의 발생확률을 모사하는 것을 목표로 합니다. 문장의 발생 확률은 간단하게 다음과 같이 동시확률로 표현할 수 있습니다.

$$P(w_{1}, w_{2}, ...)$$

 여기에 순차성이라는 데이터의 특성을 주입시키기 위해서, 곱셈정리를 이용하여 사후확률의 프로덕트로 바꾸어 표현합니다. 이렇게 표현된 언어모델을 조건부 언어모델(Conditional Language Model)이라고 합니다.

$$P(w_{1}, w_{2}, ...) = \prod_{t=1}^{m}P(w_{t}|w_{1}, ..., w_{t})$$

 다만 모든 단어를 고려해가며 조건부 확률을 구하는 것은 상당한 비용이 소모되기 때문에, 마르코프 가정(Markov Assumption)을 따라 뒤의 몇 단어 정도만 추출하여 계산하는 것으로 식을 근사합니다. 마르코프 연쇄(Markov Chain)에서는 미래의 상태가 오직 N개의 현재 상태에만 의존해 결정됩니다. 이를 활용한 언어모델 구축 방법론으로 n-gram, 백오프 등이 유명하죠. 아래의 식은 N=2로 두고 근사한 것입니다.

$$P(w_{1}, w_{2}, ...) = \prod_{t=1}^{m}P(w_{t}|w_{1}, ..., w_{t}) \approx \prod_{t=1}^{m}P(w_{t}|w_{1}, w_{2})$$

 CBOW를 사용하여 window size를 조정하여 위 식을 근사할 수는 있겠으나, 앞서 언급한 대로 문장 내 순서가 고려되지 않기 때문에 정확한 언어모델을 표현할 수는 없을 것입니다. 혹은 여러개의 맥락에 대해서 hidden layer를 순서대로 스택킹하는 방법론이 신경 확률론적 언어모델(Neural Probabilistic Language Model)에서 제시되었지만, 이는 마찬가지로 연산량을 증가시킵니다.

 

RNN

 RNN은 수식으로 다음과 같이 표현합니다.

$$h_{t} = tanh(h_{t-1}W_{h} + x_{t}W_{x} + b)$$

 여기서 h는 RNN이 출력으로 내뱉는 은닉 상태입니다. 즉 RNN의 이전 타임스탭의 출력을 받아 $W_{h}$와 내적하여 '시간'을 표현하고, 입력으로 받은 해당 타임스탭의 단어에 대해서는 $W_{x}$와 내적시켜 '단어의 표현'을 얻습니다. 단어의 표현에 시간 정보를 더하는 연산을 재귀적으로 취하는 것이 바로 RNN입니다. 하이퍼볼릭 탄젠트 함수가 활성화 함수로 사용된 것은 긴 타임스탭에도 최대한 기울기를 소멸시키지 않고 유지하기 위해서입니다. 아래는 하이퍼 볼릭 탄젠트 함수의 수식입니다만, 실제로는 시그모이드 함수를 위아래로 늘린 형태입니다. 만약 ReLU를 사용한다면 기울기 소실에 조금 더 대응할 수 있을 것입니다.

$$tanh(x) = {e^{x} - e^{-x} \over e^{x} + e^{-x}}$$

 추가적으로 하이퍼볼릭 탄젠트 함수의 미분은 다음과 같습니다(역전파 시 사용)

$${tanh(x) \over dx} = 1 - tanh(x)^{2}$$ 

 RNN이 하나의 계층에서 시간순으로 레이어를 받아 연산하는만큼, 새로운 역전파 방식이 필요합니다. 시간순으로 역전파를 펼친 방법이 바로 BPTT(Backpropagation Through Time)입니다. 간단히 앞선 타임스탭(상류)에서 흘러오는 기울기를 이전 타임스탭으로 보내주기만 하면 됩니다.

RNN의 순전파(위)와 역전파(아래) 출처: AI Korea

 단, 문제가 있다면 모든 문장에 대해서 역전파를 수행하기에는 문장이 너무 길다는 것입니다. 이렇게 되면 하이퍼볼릭 탄젠트 함수를 활성화 함수를 사용하더라도 기울기 소실 문제에서 벗어날 수는 없습니다. 또한 모든 타임스탭 길이만큼의 역전파 연산과, 캐싱을 위한 메모리도 고려를 해야 하겠죠.

 따라서 이를 해결하기 위해서 Truncated BPTT가 제안되었습니다. 이는 신경망의 연결을 적당한 길이로 끊어 취급하는 방법입니다. 단, 중요한 점은 순전파 과정에서는 절단해서는 안된다는 것입니다. 기껏 순차성을 주입하기 위해서 RNN을 고안했는데, 중간에 임의로 끊어버리면 이전 맥락의 정보를 앞으로 제대로 전달하지 못할 것입니다. 따라서 오직 역전파 과정만 끊어 블록 단위로 역전파를 수행합니다.

 

Randomized Truncated BPTT

 책에서는 고정된 길이의 블록으로 훈련을 수행했는데, 그렇게 되면 훈련 문장의 특정 위치에서만 기울기 단절이 발생하지는 않을까 싶었습니다. 어쨌든 전체 문장에 대해서 역전파를 수행해야 하는데 근사한 것이니 말이죠. 따라서 저는 임의의 길이로 자르는 방법론을 도입해서 실험해봤습니다.

 TBA 데이터셋을 3000개만 사용하고, 6개 기준으로 역전파 블록을 만들었습니다. 임의의 길이로 블록을 만드는 경우, 5~7 사이의 임의의 길이를 에포크마다 다르게 설정하여 실험하였습니다. 자세한 파라미터와 실험 결과는 다음과 같습니다.

고정 길이
임의의 길이

 우선 데이터셋이 작다보니 그다지 신용할만한 결과는 아니겠습니다만, 전체적으로 양상은 비슷한 모습입니다. 임의의 길이로 훈련시키는 경우 오히려 초반에 조금 더 불안정한 모습을 보였고, 후에는 비슷한 수준까지 PPL이 하락하는 모습을 보여주었습니다. 여러번을 실험해봤는데, 결과는 비슷했습니다. 또한 길이 변동을 너무 심하게 주면 오히려 성능이 떨어지는 모습도 볼 수 있었습니다. 

 제 생각엔, 어쨌든 순전파 과정에서 순차적으로 '제대로' 캐싱을 해놓기 때문에 캐싱된 데이터를 이용한 역전파 과정도 블록 단위로 관리한다고 해서 문제가 생기지는 않는 듯 합니다. 심지어 블록 크기를 2로 주었을 때 가장 빠르게 최적점에 수렴하는 것도 확인했습니다(1일 경우는 전달이 아예 되지 않습니다). 오히려 샘플링하는 과정에서 연산이 추가되어 훈련 속도만 느려지는 것을 확인했습니다.

 또한 블록 크기를 크게 주면 상대적으로 연산 속도도 느리고, 성능면에서도 뒤쳐졌습니다. 반대로 블록 크기를 적게 주면 조금 더 연산이 빨라지고, 성능은 좋아졌습니다.

블록 크기를 크게 준 경우 (15)
블록 크기를 작게 준 경우 (5)

 앞서 모든 문장에 대해서 BPTT를 수행할 수 없다는 것이 이것으로 증명된 셈입니다. 길이가 길어지면 길어질수록 연산시간도 길어지고, 성능도 떨어질테니 말입니다. (위의 결과에서 그래디언트가 아래보다 갱신이 안되고 있음을 확인할 수 있습니다.)

 

Compact RNN

 또한 속도 관련해서 조금 더 빠르게 만들어보고 싶어서, 기존에 타임스탭마다 존재하던 모든 타임스탭의 RNN 레이어를 하나로 통합시켜보았습니다.

class RNNBlock:
    def __init__(self, Wx, Wh, b, stateful=False):
        self.params = [Wx, Wh, b]
        self.grads = [np.zeros_like(p) for p in self.params]
        self.layer = RNN(*self.params)

        self.h, self.dh = None, None
        self.stateful = stateful

    def set_state(self, h):
        self.h = h

    def reset_state(self):
        self.h = None

    def forward(self, xs):
        Wx, Wh, b = self.params
        N, T, D = xs.shape
        D, H = Wx.shape

        hs = np.empty((N, T, H), dtype='f')
        if not self.stateful or self.h is None:
            self.h = np.zeros((N, H), dtype='f')

        for t in range(T):
            self.h = self.layer.forward(xs[:, t, :], self.h)
            hs[:, t, :] = self.h

        return hs

    def backward(self, dhs):
        Wx, Wh, b = self.params
        N, T, H = dhs.shape
        D, H = Wx.shape

        dxs = np.empty((N, T, D), dtype='f')
        dh = 0
        grads = [0, 0, 0]
        for t in reversed(range(T)):
            dx, dh = self.layer.backward(dhs[:, t, :] + dh)
            dxs[:, t, :] = dx

            for i, grad in enumerate(self.layer.grads):
                grads[i] += grad

        for i, grad in enumerate(grads):
            self.grads[i][...] = grad

        self.dh = dh

        return dxs

 그리고 이렇게 구현한 RNNBlock를 이용하여 다시 훈련시켜 보았습니다.

Compact RNN

기울기가 발산하는 것을 보니 훈련이 제대로 진행되지 않고 있습니다. 가만히 생각해보니, 이렇게 구현하면 순전파 과정에서도 역전파 과정에서도 업데이트는 마지막 한번에 대해서만 이루어지겠네요. 그렇다고 매 타임스탭마다 역전파를 수행하자니 연산량이 늘어납니다. 시간순으로 펼쳐서 매 타임스탭마다 기울기를 제대로 전달하는 것은 확실히 유효한 방법인 듯 합니다.

 따라서 고정 길이의 적당히 '짧은' 길이로 BPTT 블록을 구성하고, 타임스탭 길이만큼 레이어를 만들어 마치 FFN처럼 학습시키는 것이 좋은 것으로 마무리하겠습니다.

 추가적으로 언어모델의 평가에는 PPL(Perplexity)을 사용합니다. 다른 말로는 기하평균 분기 수라고 합니다. 이는 현재 모델이 몇 가지 가능성 중 헷갈리고 있는지를 말해주는 지표입니다.

$$L = -{1 \over L}\sum_{n}\sum_{k}t_{nk}logy_{nk}$$

$$perplexity = e^{L}$

즉, PPL은 교차 엔트로피 손실에 exp 연산을 취하여 구할 수 있습니다.

사실 기계번역에서 PPL은 그다지 훌륭한 지표가 아니기 때문에 어느정도 무시하고 있었는데(애초에 너무 그럴듯하게 뽑으면 안된다고 랜덤 샘플링까지 추론과정에 도입하고 있으니 혼란도는 고려할 바가 아니겠죠...), 언어모델의 평가에서는 PPL이 유효하다는 것은 처음 알게되었습니다.

728x90

'독서' 카테고리의 다른 글

Ch7. RNN을 사용한 문장 생성  (0) 2021.07.31
Ch6. 게이트가 추가된 RNN  (0) 2021.07.29
Ch4. Word2Vec 속도 개선  (0) 2021.07.27
Ch3. Word2Vec  (0) 2021.07.26
Ch2. 자연어와 단어의 분산 표현  (0) 2021.07.25
Comments