[NLP] Sigmoid with Loss 층과 Negative Sampling

O(logn)·2023년 10월 30일
0
post-thumbnail
post-custom-banner

썸네일 출처: 사진: UnsplashIshan @seefromthesky

목차

  • Review: Embedding dot 층
  • Sigmoid with Loss
  • Negative Sampling

Review: Embedding dot 층

SimpleCBOW의 단점을 보완하기 위한 방법으로 Embedding 층, Embedding dot 층을 살펴봤다. 그 중 Embedding dot 층은 출력측 가중치 행렬(W_out)을 곱할 때와 softmax 함수를 적용할 때 발생하는 병목을 해결하였다. 다중분류를 이진분류로 변환하여 W_out행렬도 축소시키고, O(N)의 연산이 필요한 softmax를 O(1)인 sigmoid로 바꾸는 것이 그 핵심이다. Embedding dot을 이용해 스코어를 구할 때 필요한 연산은 W_out에서 하나의 열(타깃 후보 단어 id)만 추출한 뒤 h와 내적하는 것이다. 이 때 내적을 dot product라고 하기 때문에 Embedding dot층이라 불리는 것이다.

이전 CBOW에서 SoftmaxWithLoss층이 있었다. 이와 유사하게 Sigmoid와 Cross entropy error를 계산한 SigmoidWithLoss에 대해 소개하고자 한다.

구현

class EmbeddingDot:
    def __init__(self, W):
        self.embed = Embedding(W)
        self.params = self.embed.params
        self.grads = self.embed.grads
        self.cache = None

    def forward(self, h, idx):
        target_W = self.embed.forward(idx)
        out = np.sum(target_W * h, axis=1)

        self.cache = (h, target_W)
        return out

    def backward(self, dout):
        h, target_W = self.cache
        dout = dout.reshape(dout.shape[0], 1)

        dtarget_W = dout * h
        self.embed.backward(dtarget_W)
        dh = dout * target_W
        return dh
class EmbeddingDot:
   def __init__(self, W):
       self.embed = Embedding(W)
       self.params = self.embed.params
       self.grads = self.embed.grads
       self.cache = None

: Embedding Dot을 구현한 클래스이다. 초기화 메서드의 입력 파라미터는 (W)이고, self.embed, self.params, self.grads, self.cache속성 등이 클래스 호출과 동시에 생성된다. embed속성에는 가중치(입력측인지 출력측인지는 정해지지 않았으나 입력측일 것이다.)에서 타깃 인덱스에 해당하는 행이 추출된 Embedding(W)가 저장된다. paramsgrads속성에는 embed에 저장된 파라미터와 가중치를 각각 저장하고 있다. cache 속성은 역전파를 위한 순전파 중간 계산 결과를 저장하는 데 쓰이기 때문에 초기화해준다.

    def forward(self, h, idx):
        target_W = self.embed.forward(idx)
        out = np.sum(target_W * h, axis=1)
        self.cache = (h, target_W)
        return out

: forwamrd()메서드의 입력 파라미터는 은닉 벡터h와 정답 레이블(인덱스) idx이다. idx를 입력으로 받는 이유는 미니 배치를 처리하기 위함이다. target_W에는 단어 임베딩을 추출하여 저장하였고 out에는 target_Wh를 내적한 행렬을 열방향으로 더해 압축한다. 아래 그림을 보면 이해가 빠르다.

아래는 구체적인 숫자를 넣어 이해를 돕는 예시이다.

그 다음 self.cache속성에는 htarget_W를 튜플로 묶어 저장한다. 최종적으로 out을 리턴한다.

    def backward(self, dout):
        h, target_W = self.cache
        dout = dout.reshape(dout.shape[0], 1)
        dtarget_W = dout * h
        self.embed.backward(dtarget_W)
        dh = dout * target_W
        return dh

: backward()메서드의 입력 파라미터는 dout이다. 이는 self.forward() 메서드에서 계산된 출력에 대한 기울기이다. 입력받은 doutreshape()하는데 dout.shape[0]dout의 첫 번째 차원이다. 이는 다음 줄에 h와 내적을 하기 위해 shape을 맞춰 준 것이다.
dtarget_W에는 douth를 내적한 값이 저장된다. 그 후 dtarget_W를 임베딩층으로 역전파해주고 dh에는 douttarget_w의 내적이 저장된다. dh를 리턴한다.

Sigmoid with Loss

시그모이드 함수 식은 다음과 같다.

위 식을 그래프로 그리면 다음과 같은 그림이 나온다.

그래프는 S자 곡선 형태이며, 입력값(x)은 0에서 1 사이의 실수로 변환되어 출력(y)된다.

시그모이드 함수를 적용해 확률 yy를 얻으면 이 확률 yy로부터 손실을 구한다. 시그모이드 함수에 사용되는 손실 함수는 다중 분류 때처럼 cross entropy error이다. 교차 엔트로피 오차라고도 하며 다음과 같이 쓸 수 있다.

  • yy: 시그모이드 함수의 출력
  • tt: 정답 레이블(0 또는 1을 가짐)
  • yytt의 관계: tt가 1이면 정답이 'Yes'이고, 0이면 정답이 'No'이다. tt가 1이면 loss값이 -logyy가 되고, tt가 0이면 -log(1-yy)가 된다.

아래는 Sigmoid계층과 Cross Entropy Error 계층의 계산 그래프이다.

[그림 4-10]에서 핵심은 역전파의 yy-tt값이다. yy는 신경망의 예측 결과이고 tt는 정답 레이블이며 둘의 차이는 오차이다. 예컨대 정답 레이블이 2라면 yy가 2(100%)에 가까워질수록 오차가 줄어든다는 뜻이다. 반대로 yy가 2로부터 멀어지면 오차가 커진다. 그리고 그 오차가 앞 계층으로 흘러가므로, 오차가 크면 더 학습하고, 오차가 작으면 덜 학습하게 된다.

Negative Sampling

앞서 다중 분류를 이진 분류로 바꾸었지만 여전히 한계가 존재한다. 현재 신경망은 긍정적 예인 "say"에 대해서만 학습을 한 상황이다. 이 경우 정답이 아닌 부정적 예시를 물어보면 거의 틀릴 것이다. 모델이 쓸모가 있으려면 어떤 단어가 긍정인지 부정인지 스스로 판단할 수 있어야 할 것이다. 예를 들면 "you"와 "goodbye" 사이에 들어갈 단어로 "i"가 올 확률을 물어봤을 때 0에 가깝게 대답할 수 있어야 한다. 이를 위해 부정적 예시를 적당한 양만큼 학습 시킬 것이며 이것이 '네거티브 샘플링 기법'이다.

네거티브 샘플링 기법은 긍정적 예시를 타깃으로 했을 때의 손실과 부정적 예시를 타깃으로 했을 떄의 손실을 더한 값을 최종 손실로 한다.


이 때 인덱스 1은 긍정적 예시인 "say"의 인덱스이고, 5와 4는 각각 "hello"와 "I"로 부정적 예시이다. 긍정적 예시의 정답 레이블을 1, 부정적 예시의 정답 레이블을 0으로 설정한다. (1: 정답, 0: 오답)

학습에 필요한 자원은 한정되어 있기 때문에 모든 부정적 예시를 다 학습시킬 수 없다. 몇 개만을 골라서 학습에 써야하는데, 이왕이면 학습을 효과적으로 돕는 부정적 예시를 선별하는 것이 좋다. 학습에 효과적인 부정적 예시는 정답과 차이가 거의 없는, 즉 해당 자리에 올 수도 있는 단어를 선별하는 것이 좋다. 이를 위해 말뭍치에서 각 단어의 출현 횟수를 구해 '확률분포'로 나타낸 다음 확률분포대로 단어를 샘플링하면 된다.

다음으로 확률분포에 따라 부정적 예시를 샘플링하는 파이썬 코드를 살펴볼 것이다. 아래는 np.random.choice()메서드의 사용법에 대한 코드이다.

이렇듯 np.random.choice()는 무작위 샘플링 용도로 이용할 수 있다. 추가적으로 word2vec의 네거티브 샘플링에서는 [식4.4]처럼 기본 확률분포에 0.75를 제곱하는 것이 좋다.

분모에도 0.75를 제곱하면 확률의 총합을 1로 유지할 수 있다. 이렇게 처리를 해주면 원래 확률이 낮은 단어의 확률을 살짝 높여 출현 확률이 낮은 단어를 살릴 수 있게 된다.

p와 출력 결과를 비교하면 높은 확률은 조금 낮아지고 낮은 확률일수록 큰 폭으로 증가하는 것을 확인할 수 있다. (비슷한 효과가 있는 수라면 0.75를 대신할 수 있다.)

지금까지 살펴본 모든 기능을 하나로 합친 코드가 UnigramSampler이다. Unigram이란 하나의 연속된 단어를 뜻한다. 한 단어를 대상으로 확률분포를 만든다는 의미를 담어 지어진 이름이라고 한다.

class UnigramSampler:
    def __init__(self, corpus, power, sample_size):
        self.sample_size = sample_size
        self.vocab_size = None
        self.word_p = None

        counts = collections.Counter()
        for word_id in corpus:
            counts[word_id] += 1

        vocab_size = len(counts)
        self.vocab_size = vocab_size

        self.word_p = np.zeros(vocab_size)
        for i in range(vocab_size):
            self.word_p[i] = counts[i]

        self.word_p = np.power(self.word_p, power)
        self.word_p /= np.sum(self.word_p)

    def get_negative_sample(self, target):
        batch_size = target.shape[0]

        if not GPU:
            negative_sample = np.zeros((batch_size, self.sample_size), dtype=np.int32)

            for i in range(batch_size):
                p = self.word_p.copy()
                target_idx = target[i]
                p[target_idx] = 0
                p /= p.sum()
                negative_sample[i, :] = np.random.choice(self.vocab_size, size=self.sample_size, replace=False, p=p)
        else:
            # GPU(cupy)로 계산할 때는 속도를 우선한다.
            # 부정적 예에 타깃이 포함될 수 있다.
            negative_sample = np.random.choice(self.vocab_size, size=(batch_size, self.sample_size),
                                               replace=True, p=self.word_p)

        return negative_sample
class UnigramSampler:
    def __init__(self, corpus, power, sample_size):
        self.sample_size = sample_size
        self.vocab_size = None
        self.word_p = None

: UnigramSmpler 클래스는 초기화 시 3개의 인수를 받는다. 단어 ID 목록인 corpus, 확률분포에 제곱할 값인 power(default = 0.75), 부정적 예시 샘플링을 수행하는 횟수인 sample_size이다. get_neagtive_sample(target)메서드는 target 인수로 지정한 단어를 긍정적 예로 해석하고, 그 외의 단어 ID를 샘플링한다. 부정적 예를 골라주는 것이다. UnigramSampler클래스를 사용하는 방법은 다음과 같다.

긍정적 예로 [1,3,0]이라는 3개의 데이터를 미니배치로 다뤘다. 각각의 데이터에 대해 부정적 예를 2개씩 샘플링한다. 첫 번째 데이터에 대한 부정적 예는 [0 3] 두 번째는 [1 2] 세 버너째는 [2 3]이 뽑혔음을 알 수 있다. 실행할 때마다 결과는 달라질 수 있다. 이렇게 해서 부정적 예를 샘플링할 수 있다.

네거티브 샘플링 구현

class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power, sample_size)
        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]

        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads

    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)

        # 긍정적 예 순전파
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype=np.int32)
        loss = self.loss_layers[0].forward(score, correct_label)

        # 부정적 예 순전파
        negative_label = np.zeros(batch_size, dtype=np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1 + i].forward(h, negative_target)
            loss += self.loss_layers[1 + i].forward(score, negative_label)

        return loss

    def backward(self, dout=1):
        dh = 0
        for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
            dscore = l0.backward(dout)
            dh += l1.backward(dscore)

        return dh
class NegativeSamplingLoss:
    def __init__(self, W, corpus, power=0.75, sample_size=5):
        self.sample_size = sample_size
        self.sampler = UnigramSampler(corpus, power, sample_size)
        self.loss_layers = [SigmoidWithLoss() for _ in range(sample_size + 1)]
        self.embed_dot_layers = [EmbeddingDot(W) for _ in range(sample_size + 1)]
        self.params, self.grads = [], []
        for layer in self.embed_dot_layers:
            self.params += layer.params
            self.grads += layer.grads

:초기화 메서드의 인수로는 출력 측 가중치를 나타내는 W, 말뭉치(단어 ID의 리스트)를 뜻하는 corpus, 확률분포에 제곱할 값인 power, 그리고 부정적 예의 샘플링 횟수인 sample_size이다. 앞에서 설명한 UnigramSampler 클래스를 생성하여 인스턴스 변수인 sampler로 저장한다. 또한 부정적 예의 샘플링 횟수는 인스턴스 변수인 sample_size에 저장한다. 인스턴스 변수인 loss_layersembed_dot_layers에는 원하는 계층을 리스트로 보관한다. 이 때 이 두 리스트에는 sample_size 보다 하나 더 많은 수의 계층을 생성하는데, sample_size만큼 부정적 예를 다루고, 긍정적 예를 다루는 계층이 하나 더 필요하기 때문이다. loss_layers[0]embed_dot_layers[0]이 긍정적 예를 다루는 계층이다. 그 뒤 이 계층에서 사용하는 매개변수와 기울기를 각각 배열로 저장한다.

    def forward(self, h, target):
        batch_size = target.shape[0]
        negative_sample = self.sampler.get_negative_sample(target)
        # 긍정적 예 순전파
        score = self.embed_dot_layers[0].forward(h, target)
        correct_label = np.ones(batch_size, dtype=np.int32)
        loss = self.loss_layers[0].forward(score, correct_label)
        # 부정적 예 순전파
        negative_label = np.zeros(batch_size, dtype=np.int32)
        for i in range(self.sample_size):
            negative_target = negative_sample[:, i]
            score = self.embed_dot_layers[1 + i].forward(h, negative_target)
            loss += self.loss_layers[1 + i].forward(score, negative_label)
        return loss

:forward(h,target) 메서드가 받는 인수는 은닉층 뉴런 h와 긍정적 예의 타깃을 뜻하는 target이다. 이 메서드에서는 우선 self.sampler를 이용해 부정적 예를 샘플링하여 negative_sample에 저장한다. 그 뒤 긍정적 예와 부정적 예 각각의 데이터에 대해 순전파를 수행해 그 손실들을 더한다. 구체적으로는 Embedding Dot 계층의 forward점수를 구하고, 이어서 이 점수와 레이블을 Sigmoid With Loss 계층으로 흘려 손실을 구한다. 여기에서 긍정적 예의 정답 레이블(correct_label)은 "1"이고, 부정적 예의 정답 레이블(negative_label)은 "0"임에 주의한다.

    def backward(self, dout=1):
        dh = 0
        for l0, l1 in zip(self.loss_layers, self.embed_dot_layers):
            dscore = l0.backward(dout)
            dh += l1.backward(dscore)
        return dh

역전파는 순전파 때의 역순으로 각 계층의 backwrd()를 호출하기만 하면된다. 은닉층의 뉴런은 순전파 시에 여러 개로 복사가 되었는데, 이는 Repeat노드에 해당한다. 따라서 역전파 때는 여러개의 기울기 값을 더해준다.

profile
는 내 성장의 시간 복잡도!!
post-custom-banner

0개의 댓글