본문 바로가기
  • 비둘기다
  • 비둘기다
  • 비둘기다
AI/Deep learning from Scratch

[머신러닝, 딥러닝] 오차역전파법 (2) - 계층

by parzival56 2023. 2. 15.

이번 페이지에서는 이전 페이지의 예시들을 바탕으로 코드와 함께 다뤄보겠습니다.

 

먼저 곱셈 계층입니다.

class MulLayer:
    def __init__(self):
        self.x = None
        self.y = None

    def forward(self, x, y):
        self.x = x
        self.y = y                
        out = x * y

        return out

    def backward(self, dout):
        dx = dout * self.y  # x와 y를 바꾼다.
        dy = dout * self.x

        return dx, dy

forward()는 순전파, backward()는 역전파이고, dout은 상류에서 넘어온 미분입니다. 

이전 페이지에서 살펴봤던 예시를 위의 코드를 기반으로 계산하면 다음과 같습니다.

apple = 100
apple_num = 2
tax = 1.1

# 계층
mul_apple_layer = MulLayer()
mul_tax_layer = MulLayer()

# 순전파
apple_price = mul_apple_layer.forward(apple, apple_num)
price = mul_tax_layer.forward(apple_price, tax)

print(price)

# 역전파
dprice = 1
dapple_price, dtax = mul_tax_layer.backward(dprice)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)

print(dapple, dapple_num, dtax)

 

다음은 덧셈 계층입니다.

class AddLayer:
    def __init__(self):
        pass

    def forward(self, x, y):
        out = x + y

        return out

    def backward(self, dout):
        dx = dout * 1
        dy = dout * 1

        return dx, dy

곱셈 계층과 원리는 같지만 역전파에서 1이 곱해지기 때문에 비교적 간단하다고 할 수 있습니다.

코드 중 __init__에서 pass가 나오는데 이는 아무것도 하지 말라는 의미입니다. 곱셈 계층과 다른 이유는 덧셈 계층의 경우에는 순전파에서 입력받는 x, y 자체만 쓰고 나머지는 dout과 1로 계산이 종료되기 때문에 self.x, self.y와 같은 변수가 필요가 없는 것입니다. 

 

이전과 같이 좀 더 복잡한 예시를 나타내보겠습니다.

apple = 100
apple_num = 2
orange = 150
orange_num = 3
tax = 1.1

# layer
mul_apple_layer = MulLayer()
mul_orange_layer = MulLayer()
add_apple_orange_layer = AddLayer()
mul_tax_layer = MulLayer()

# forward
apple_price = mul_apple_layer.forward(apple, apple_num)  # (1)
orange_price = mul_orange_layer.forward(orange, orange_num)  # (2)
all_price = add_apple_orange_layer.forward(apple_price, orange_price)  # (3)
price = mul_tax_layer.forward(all_price, tax)  # (4)

# backward
dprice = 1
dall_price, dtax = mul_tax_layer.backward(dprice)  # (4)
dapple_price, dorange_price = add_apple_orange_layer.backward(dall_price)  # (3)
dorange, dorange_num = mul_orange_layer.backward(dorange_price)  # (2)
dapple, dapple_num = mul_apple_layer.backward(dapple_price)  # (1)

print("price:", int(price))
print("dApple:", dapple)
print("dApple_num:", int(dapple_num))
print("dOrange:", dorange)
print("dOrange_num:", int(dorange_num))
print("dTax:", dtax)

코드가 길지만 나타내는 바는 순전파에서는 곱셈과 덧셈을 역전파에서는 미분인데 역전파에서 함수의 매개변수로 들어갈 변수를 잘 봐야 합니다. orange의 미분값을 apple이고 apple의 미분값이 orange이기 때문입니다.


다음은 활성화 함수 계층입니다.

 

그 중 먼저 ReLU 계층입니다.

ReLU는 아시다시피 0보다 크면 값 자체를 0 이하이면 0을 반환합니다. 그래서 이는 다른 활성화 함수에 비해 훨씬 간단하게 구현할 수 있습니다. 왜냐하면 역전파 과정에서 미분이 쓰인다 해도 값 자체를 미분하면 1이고 0을 미분하면 0이기 때문에 0과 1로만 결괏값을 구성할 수 있게 되기 때문입니다.

 

ReLU 계층을 구현하면 다음과 같습니다.

class Relu:
    def __init__(self):
        self.mask = None

    def forward(self, x):
        self.mask = (x <= 0)
        out = x.copy()
        out[self.mask] = 0

        return out

    def backward(self, dout):
        dout[self.mask] = 0
        dx = dout

        return dx

 

위 클래스는 mask라는 인스턴스 변수를 가지는데 mask는 True/False로 구성된 넘파이 배열입니다. 여기서 순전파에서 0이하인 값에는 True를 반대에는 False로 유지합니다. 

예를 들면 다음과 같이 활용할 수 있습니다.

x = np.array([[1.0, -0.5], [-2.0, 3.0]])

mask = (x <= 0)
print(mask)

그리고 ReLU 계층에서 알고 넘어가야할 점은 dout의 처리입니다. 보통은 그냥 앞뒤에 맞는 식을 넣어주면 되지만 ReLU에서는 mask를 사용하였기 때문에 mask가 True인 곳에서는 dout을 0으로 처리해 줄 필요가 있습니다. 하나하나를 0으로 지정할 필요 없이 조건문 등으로 한꺼번에 처리할 수 있기 때문에 편리합니다.

 

다음은 시그모이드 계층입니다.

 

시그모이드는 ReLU와 달리 식이 존재하기 때문에 이에 대한 식의 변화가 눈에 띄는 차이라고 할 수 있습니다.

먼저 x부터 보개 된다면 시그모이드는 다음과 같은 과정을 통해 만들어지는 식입니다.

여기서 새롭게 등장하는게 exp와 /입니다. 

순조로운 계산을 하기 위해서 먼저 /부터 역으로 돌아가면서 미분값을 알아둘 필요가 있습니다.

 

1. /노드는 y=1/x를 의미하고 1/x의 미분값은 -1/x**2죠. 그리고 이는 -y**2와도 같습니다. 

2. 다음은 +노드입니다. +노드는 역일 때는 같은 값을 보내기 때문에 영향을 주지 않습니다.

3. 다음은 exp노드입니다. 흔히 말하는 e의 미분은 e이기 때문에 순전파 때와 같이 exp를 취해주면 되지만 위에서는 x가 -를 달고 이동하기 때문에 exp(-x)라고 하겠습니다.

4. 마지막은 x노드입니다. 여기서는 그냥 -1을 곱하면 됩니다.

 

위의 모든 과정을 간소화시키면 다음과 같습니다.

 

이를 구현하면 다음과 같습니다.

# 시그모이드 계층

class Sigmoid:
    def __init__(self):
        self.out = None

    def forward(self, x):
        out = 1 / (1 + np.exp(-x))
        self.out = out
        return out

    def backward(self, dout):
        dx = dout * (1.0 - self.out) * self.out

        return dx

위 코드에서는 out을 순전파의 출력이자 역전파의 계산에서 값을 한 번 더 활용할 수 있게 해 주었습니다.

 


마지막은 Affine/Softmax 계층입니다.

 

먼저 Affine 계층입니다. 

Affine은 기하학에서 말하는 어파인 변환, 다른 말로 하면 행렬의 곱셈을 나타내는 것입니다. 여태까지 단순한 class에서 np.dot()을 이용하여 행렬 곱을 사용했습니다. 

 넘파이 배열을 기반으로 한 어파인 배열의 계산 그래프입니다. 여기서 X, W, B는 모두 다차원 배열입니다.

그렇다면 이를 역으로 풀이할 때에 신경 써야 할 요소가 생기는데 바로 전치행렬입니다. 기존에는 단순한 미분을 생각했지만 행렬이기 때문에 전치 행렬을 곱해주는 것이 올바르다고 할 수 있습니다.

 

자세한 전개는 다음과 같습니다.

이처럼 행렬에 관한 내용을 다룰 때는 계산을 위해 항상 행렬의 형상을 주의해야 합니다.

 

다음은 배치용 Affine 계층입니다.

 

위에서 설명한 어파인 계층은 단일 데이터 X를 기준으로 한 것입니다. 그러나 배치용 어파인 계층은 말 그대로 배치에서 쓰일 수 있는 어파인 계층이기 때문에 데이터를 N개 이상 묶은 경우에 사용 가능합니다.

위의 계산 그래프가 일반 어파인과 다른 점이라고 한다면 X가 (N, 2)가 된 것입니다. N은 행렬의 형상에 영향을 주기 때문에 각 X, W, Y의 형상이 모두 변합니다.

 

여기서 중요한 점은 편향을 처리하는 방법입니다. 편향은 데이터의 개수만큼 더해지기 때문에 역전파 때는 각 데이터의 역전파 값이 편향의 원소에 모여야 합니다. 

 

구현은 다음과 같습니다.

class Affine:
    def __init__(self, W, b):
        self.W = W
        self.b = b
        
        self.x = None
        self.original_x_shape = None
        # 가중치와 편향 매개변수의 미분
        self.dW = None
        self.db = None

    def forward(self, x):
        # 텐서 대응
        self.original_x_shape = x.shape
        x = x.reshape(x.shape[0], -1)
        self.x = x

        out = np.dot(self.x, self.W) + self.b

        return out

    def backward(self, dout):
        dx = np.dot(dout, self.W.T)
        self.dW = np.dot(self.x.T, dout)
        self.db = np.sum(dout, axis=0)
        
        dx = dx.reshape(*self.original_x_shape)  # 입력 데이터 모양 변경(텐서 대응)
        return dx

 

마지막은 Softmax-with-Loss 계층입니다.

 

소프트맥스 함수는 입력 값을 정규화하여 출력합니다. 이 계층을 Softmax-with-Loss라고 붙이는 이유는 교차 엔트로피 오차를 포함하기 때문입니다.

이처럼 굉장히 복잡한 모습을 하고 있습니다.

위를 간소화한다면 다음과 같이 표현할 수 있습니다.

신경망에서 수행하는 작업은 학습 추론 두 가지입니다. 추론할 때는 일반적으로 Softmax 계층을 사용하지 않습니다. 신경망 추론에서 답을 하나만 내는 경우에는 가장 높은 점수만 알면 되니 Softmax 계층은 필요 없습니다. 반면, 신경망을 학습할 때는 Softmax 계층이 필요합니다.

 

여기에서 주목할 것은 역전파의 결과입니다. Softmax 계층의 역전파는 (, , )라는 말끔한 결과를 내놓고 있습니다. (, , )은 Softmax 계층의 출력이고, ()은 정답 레이블 이므로 (, , )는 Softmax 계층의 출력과 정답 레이블의 차분인 것입니다. 신경망의 역전파에서는 이 차이인 오차가 앞 계층에 전해지는 것입니다. 이는 신경망 학습의 중요한 성질입니다.

 

신경망 학습의 목적은 신경망의 출력(Softmax의 출력)이 정답 레이블과 가까워지도록 가중치 매개변수의 값을 조정하는 것이었습니다. 그래서 신경망의 출력과 정답 레이블의 오차를 효율적으로 앞 계층에 전달해야 합니다. 앞의 (, , )라는 결과는 신경망의 현재 출력과 정답 레이블의 오차를 있는 그대로 나타내는 것입니다.

 

'소프트맥스 함수'의 손실 함수로 '교차 엔트로피 오차'를 사용하는 이유
 역전파의 결과가 ()로 말끔히 떨어지기 때문입니다. 교차 엔트로피 오차라는 함수가 그렇게 설계되었기 때문입니다.

'항등 함수'의 손실 함수로 '오차 제곱합'을 사용하는 이유
 역전파의 결과가 ()로 말끔히 떨어지기 때문입니다.

 

정답 레이블이 (0, 1, 0) 일 때 Softmax 계층이 (0.3, 0.2, 0.5)를 출력했다고 해봅시다. 이 경우 Softmax 계층의 역전파는 (0.3, -0.8, 0.5)라는 커다란 오차를 전파합니다. 결과적으로 Softmax 계층의 앞 계층들은 그 큰 오차로부터 큰 깨달음을 얻게 됩니다.

 

그렇다면 계층의 구현은 다음과 같습니다.

class SoftmaxWithLoss:
    def __init__(self):
        self.loss = None # 손실함수
        self.y = None    # softmax의 출력
        self.t = None    # 정답 레이블(원-핫 인코딩 형태)
        
    def forward(self, x, t):
        self.t = t
        self.y = softmax(x)
        self.loss = cross_entropy_error(self.y, self.t)
        
        return self.loss

    def backward(self, dout=1):
        batch_size = self.t.shape[0]
        if self.t.size == self.y.size: # 정답 레이블이 원-핫 인코딩 형태일 때
            dx = (self.y - self.t) / batch_size
        else:
            dx = self.y.copy()
            dx[np.arange(batch_size), self.t] -= 1
            dx = dx / batch_size
        
        return dx

 


이상으로 오차역전파법에 사용되는 다양한 계층에 대해 알아봤습니다.

댓글