Softmax derivative

* 모든 코드는 제 깃허브 (cdjs1432/DeepLearningBasic: Deep Learning from scratch)에서 확인할 수 있습니다.

 

이번 시간에는 Softmax Classification을 구현해 보고, 이를 MNIST 데이터셋으로 테스트 해보겠습니다.

혹시라도 MNIST 데이터셋이 조금 부담스럽다 하시는 분들은, 제 깃허브 (https://github.com/cdjs1432/DeepLearningBasic)에 작은 데이터인 Iris로 훈련하는 코드가 있으니, 확인해 보셔도 좋을 것 같습니다. (IrisTest.py)

 

 

들어가기에 앞서, 간단하게 Softmax 함수와 Cross-Entropy Loss가 무엇인지 먼저 알아봅시다.

 

우선, Softmax 함수는 다음과 같이 정의됩니다.

 

$$ y_i(z) = \frac{e^{z_i}}{\sum_{i=1}^N e^{z_i}} $$

 

여기서 z는 (적어도 이번 차시에서는) x.dot(w) + b입니다.

이 Softmax 함수를 지나온 값들의 합은 1이 되는데, 그렇기에 위의 값들을 "확률"로 생각해도 좋습니다.

이렇게만 말하면 조금 이해가 안될 테니, 그냥 코드로 알아보도록 합시다.

 

 

import numpy as np
import pandas as pd
import ComputeGrad

일단은 필요한 모듈을 모두 import합시다.

지금 당장은 안쓰더라도, 이번 시간에 모두 쓸 것들입니다.

 

def softmax(a):
    C = np.max(a)
    exp_a = np.exp(a - C)
    if a.ndim == 1:
        sum_exp_a = np.sum(exp_a)
        y = exp_a / sum_exp_a
    else:
        sum_exp_a = np.sum(exp_a, 1)
        sum_exp_a = sum_exp_a.reshape(sum_exp_a.shape[0], 1)
        y = exp_a / sum_exp_a
    return y


a = np.array([1.3, 2.4, 3.7])
print(softmax(a))

위 코드를 실행하면, [0.06654537 0.19991333 0.73354131] 라는 값이 출력될 것입니다.

사실 계산 부분은 위의 softmax의 수식과 동일하지만, C값이 눈에 들어옵니다.

 

이 C 값을 놓는 이유는, 만약 이 값 없이 입력인 a값이 너무 큰 값으로 들어오게 되면 오버플로우가 날 확률이 높기 때문입니다.

그러나 이렇게 a를 C로 빼 준 뒤 exp 연산을 하게 되면, 그렇게 오버플로우가 날 확률이 현저히 낮아집니다.

 

참고로, softmax 함수는 저런 식으로 연산 내부 값을 빼 준다고 하더라도 결과값이 동일하게 나오게 됩니다.

(왜 그런지는 직접 계산해 보시면서 해보셔도 재밌을 것 같습니다.)

 

그리고 직접 보시면 아시겠지만, 위 실행 결과를 더해보면 1이 나오게 됩니다. (물론 출력값은 소수점이 살짝은 잘리기에 완벽히 1이 되진 않습니다.)

그리고, 위의 값들을 "0번째 인덱스가 정답일 확률은 0.06, 2번째 인덱스가 정답일 확률은 0.73이겠군" 이라고 생각할 수 있습니다.

바로 이런 성질을 사용하여 Classification을 할 수 있는 것이죠.

 

그리고 a가 행렬로 입력될 경우, 각 행에서의 합이 1이어야 하므로, a.ndim==... 코드를 넣어서 경우를 나눠주었습니다.

(이 구현이 좀 예쁜 구현은 아닐수는 있겠지만, 그래도 시간복잡도는 비슷하니 뭐...)

 

 

 

다음은 Cross-Entropy Loss입니다.

이 오차 함수를 식으로 쓰면,

$$ E=-\sum_{i} t_i \ln y_i $$

가 됩니다.

 

여기서 t는 정답 label, y는 우리의 예측의 출력값이 됩니다.

 

이제 이를 직접 구현해 봅시다.

(위의 코드와 이어집니다.)

def cross_entropy_loss(y, t):
    return -np.sum(t * np.log(y))

y = softmax(a)
print(y)
t = np.array([1, 0, 0])
print(cross_entropy_loss(y, t))
t = np.array([0, 1, 0])
print(cross_entropy_loss(y, t))
t = np.array([0, 0, 1])
print(cross_entropy_loss(y, t))

위 코드를 실행시키면,

2.7098713687418052
1.6098713687418051
0.309871368741805

 

라는 값이 나옵니다. 아까 전의 softmax(a), 즉 y값이 대략 [0.06, 0.19, 0.73] 이었던 것을 고려한다면, y가 가장 높은 인덱스인 0.73에 해당하는 loss값이 가장 낮으므로, 성공적으로 구현됐다고 생각할 수 있겠습니다.

 

그러나, 우리는 Cross Entropy Loss를 미니배치에 적용시켜서 학습을 할 것이므로, 위 함수를 다음과 같이 바꿔줍시다.

def cross_entropy_loss(y, t):
    c = 1e-7
    if y.ndim == 1:
        t = t.reshape(1, t.size)
        y = y.reshape(1, y.size)

    batch_size = y.shape[0]
    return -np.sum(t * np.log(y + c)) / batch_size

위의 y.ndim==1은 y(혹은 t, 어차피 같은 shape로 들어와야 하므로 상관없음)가 2차원 배열이 아니라 1차원 배열일 때도 사용할 수 있게 하기 위한 코드입니다.

 

여기서의 c값은 np.log 연산을 할 때 오버플로우가 나오는 것을 방지해 주는 역할입니다.

log의 진수로 0이 들어오면 분명 오버플로우가 발생할테니까요.

 

 

자, 이제 데이터를 불러와 볼까요?

# load data
train = pd.read_csv("./Data/MNIST_data/mnist_train.csv")
y_train = train["label"]
x_train = train.drop("label", 1)
x_train = x_train.values / x_train.values.max()
y_train = y_train.values

# y to one-hot
one_hot = np.zeros((y_train.shape[0], y_train.max() + 1))
one_hot[np.arange(y_train.shape[0]), y_train] = 1
y_train = one_hot


# initialize parameters and hyperparameters
num_classes = y_train.shape[1]
w = np.random.uniform(-1, 1, (x_train.shape[1], num_classes))
b = np.zeros(num_classes)

learning_rate = 0.01
epoch = 10000
batch_size = 128

w, b = ComputeGrad.SoftmaxGD(x_train, y_train, w, b, learning_rate, epoch, batch_size)

아, 참고로 해당 데이터는 제 깃허브에 올려놓았으니 사용하셔도 되고, 아니면 다른 방식을 사용해서 MNIST를 불러와도 무방합니다.

 

간단히 코드를 설명하자면, load data에서는 당연히 데이터를 불러오고,

 

그리고, 그 아래에는 y_train을 one-hot vector로 만드는 코드가 있습니다.

 

이 글을 볼 사람들이라면 웬만하면 다 알겠지만 그래도 설명하자면, one-hot vector란 정답 클래스를 숫자로 4, 8, ... 이런 식으로 두는 것이 아니라, [0, 0, 0, 1, 0, 0, 0, 0, 0, 0]과 같은 방식으로 두는 것입니다.

 

 

그렇다면 이제 SoftmaxGD를 구현해 봅시다.

 

 

 

우선, 수식을 먼저 다시 확인해 보겠습니다.

 

$$ y_i(z) = \frac{e^{z_i}}{\sum_{i=1}^N e^{z_i}} $$

$$ E=-\sum_{i} t_i \ln y_i $$

 

이 식을 바탕으로 Cross-Entropy Loss E를 w, b에 대해 미분한 값을 구해 봅시다.

 

우선, Cross-Entropy Loss E를 $z_j$에 미분한다 하면, 다음과 같은 식이 얻어집니다.

$$\frac{\partial E}{\partial z_j} = -\frac{\partial {\sum_{i} t_i \ln y_i} }{\partial z_j} = -\sum_{i} t_i \frac{\partial \ln y_i}{\partial z_j}$$

 

이 때, chain rule에 의하여, 위 식은 다음과 같이 변형이 가능합니다.

$$-\sum_{i} t_i \frac{\partial \ln y_i}{\partial z_j} = -\sum_{i} t_i \frac{\partial \ln y_i}{\partial y_i} \frac{\partial y_i}{\partial z_j} = -\sum_{i} \frac{t_i}{y_i} \frac{\partial y_i}{\partial z_j}$$

 

 

자, 이제 $\frac{\partial y_i}{\partial z_j}$를 구해 봅시다.

그런데 이 때, 조심해야 할 점이 있습니다.

그것은 바로 $\frac{\partial y_i}{\partial z_j}$를 계산할 때 j==i인 경우와 j!=i인 경우를 나누어서 생각해 주어야 한다는 것입니다.

일단, j와 i가 둘 다 k로 같은 경우로 계산을 해보도록 하겠습니다.

$$\frac{\partial y_k}{\partial z_k} = \frac{\partial \frac{e^{z_k}}{\sum_{i=1}^N e^{z_i}}}{\partial z_k} $$

여기서, $\sum_{i=1}^N e^{z_i}$를 S라고 치환한다면, $\frac{\partial S}{\partial z_k} = e^{z_k}$ 이므로,

$$\frac{\partial y_k}{\partial z_k} = \frac{\partial \frac{e^{z_k}}{S}}{\partial z_k} = \frac{e^{z_k}(S - e^{z_k})}{S^2}$$

가 됩니다.

그런데, 이 때 $\frac{e^{z_k}}{S} = y_k$이므로, 식을 다음과 같이 정리할 수 있습니다.

$$\frac{\partial y_k}{\partial z_k} = \frac{e^{z_k}(S - e^{z_k})}{S^2} = y_k(1 - y_k)$$

 

그러면, $\frac{\partial E}{\partial z_k}$는 다음과 같이 나오게 됩니다.

$$ \frac{\partial E}{\partial z_k} = \frac{\partial E}{\partial y_k} * \frac{\partial y_k}{\partial z_k} = -\frac{t_k}{y_k} * y_k(1 - y_k) = t_k(y_k - 1) $$

 

그리고, 만약 i!=j인 상황이라면, 계산은 다음과 같이 이루어집니다.

$$\frac{\partial y_i}{\partial z_j} = \frac{\partial \frac{e^{z_j}}{\sum_{i=1}^N e^{z_i}}}{\partial z_j} $$

$$\sum_{i=1}^N e^{z_i} = S, \frac{\partial S}{\partial z_j} = e^{z_j}$$

$$\frac{\partial y_i}{\partial z_j} = \frac{\partial \frac{e^{z_i}}{S}}{\partial z_j} = \frac{0 - e^{z_i}e^{z_j}}{S^2} = {y_i}{y_j}$$

 

아주 훌륭합니다!

이제 위 두 식을 하나로 합치게 되면 다음과 같은 식이 완성됩니다.

 

 

$$\frac{\partial y_i}{\partial z_j} = y_i(1 \lbrace i==j \rbrace - y_j)$$

이 때, $1 \lbrace i==j \rbrace$ 는 i==j일때는 1, 아니면 0이라는 뜻입니다.

 

 

자, 이제 원래 구하고 있던 $\frac{\partial E}{\partial z_j}$을 구해보자면,

$$ \frac{\partial E}{\partial z_j} = -\sum_{i} \frac{t_i}{y_i} \frac{\partial y_i}{\partial z_j} = -\sum_{i} \frac{t_i}{y_i} y_i(1 \lbrace i==j \rbrace - y_j) = -\sum_{i} t_i(1 \lbrace i==j \rbrace - y_j) = -\sum_{i} t_i(1 \lbrace i==j \rbrace) + y_j\sum_{i} t_i$$

라는 식이 나옵니다!

 

 

이 때, 식 $-\sum_{i} t_i(1 \lbrace i==j \rbrace)$는 i==j 일 때  $-t_j$, i!=j 일 때 0으로 정의되므로 시그마가 사라지며 값 자체를 $-t_j$로 바꿀 수 있습니다.

 

또한,  $t_i$는 단 하나의 정답 인덱스에만 1의 값을 가지고 다른 모든 인덱스에 대해선 0의 값을 가지기 때문에 $\sum_{i} t_i=1$입니다.

 

따라서, 위 식을 다시 정리해서 쓰면,

 

$$\frac{\partial E}{\partial z_j} = y_j - t_j$$

로 아름답게 정리가 됩니다!

 

 

드디어 끝이 보이네요! 

마지막으로, 정말 원래 구하려 했던 식인 $\frac{\partial E}{\partial w}$ 와 $\frac{\partial E}{\partial b}$를 구해 보면,

$$\frac{\partial E}{\partial w} = \frac{\partial E}{\partial z_j}\frac{\partial z_j}{\partial w} = (y_j - t_j)x$$

$$\frac{\partial E}{\partial b} = \frac{\partial E}{\partial z_j}\frac{\partial z_j}{\partial b} = (y_j - t_j)$$

 

*(2021-06-04 수정: 설명과 index값 오류를 수정했습니다. 제보해 주셔서 감사드립니다.)

이제 위 식을 코드로 구현해 봅시다!!

 

 

def SoftmaxGD(x, y, w, b, learning_rate=0.01, epoch=100000, batch_size=128):

    for epochs in range(epoch):
        batch_mask = np.random.choice(x.shape[0], batch_size)
        x_batch = x[batch_mask]
        y_batch = y[batch_mask]

        z = x_batch.dot(w) + b
        pred = softmax(z)
        dz = (pred - y_batch) / batch_size
        dw = np.dot(x_batch.T, dz)
        db = dz * 1.0
        w -= dw * learning_rate
        b -= (db * learning_rate).mean(0)

        if epochs % (epoch / 10) == 0:
            pred = softmax(x.dot(w) + b)
            print("ACC : ", (pred.argmax(1) == y.argmax(1)).mean())
            err = cross_entropy_loss(pred, y)
            print("ERR : ", err)


    return w, b

 

 

우선 코드를 보시면 batch가 눈에 띄실 건데요, MNIST는 60000개의 데이터로 이루어진 데이터셋이기에 SGD가 아닌 그냥 GD를 쓰면 너무 훈련이 오래 걸리기에, SGD를 적용하였습니다.

그 외의 나머지 부분은 딱히 설명할 부분이 없는 것 같네요. 수식을 코드에 직접 적용시켰을 뿐입니다.

 

 

위에서 hyperparameter를 바꾸지 않고 학습시켰다면, 아마 마지막에는 ACC가 대략 0.8이상 (80% 이상)이 나올 것입니다.

계층 하나만으로도 손글씨 인식률이 80%나 되다니!

 

 

그렇다면, 이제 test 데이터에서도 잘 작동하나 확인해 봅시다.

# load data
test = pd.read_csv("./Data/MNIST_data/mnist_test.csv")
y_test = test["label"]
x_test = test.drop("label", 1)
x_test = x_test.values / x_test.values.max()
y_test = y_test.values

# y to one-hot
one_hot = np.zeros((y_test.shape[0], y_test.max() + 1))
one_hot[np.arange(y_test.shape[0]), y_test] = 1
y_test = one_hot

pred = x_test.dot(w) + b
pred = softmax(pred)
print("ACC : ", (pred.argmax(1) == y_test.argmax(1)).mean())

 

 

이 부분의 코드는 그냥 윗부분 그대로 복사 붙여넣기 한 부분이니, 그냥 그대로 넣으시면 될 것 같습니다.

 

그리고, 결과를 확인해 보니 test 데이터에서도 좋은 결과가 나오는 것을 확인할 수 있습니다!

 

 

그러면 이제 이것으로 Softmax Classification의 구현을 마치도록 하겠습니다.

+ Recent posts