해커에게 전해들은 머신러닝 #3

이 글에서 사용한 코드는 깃허브에서 확인할 수 있습니다.


x9791162241646

본격 머신러닝 입문서 <[개정판] 파이썬 라이브러리를 활용한 머신러닝> 출간.

이 책은 사이킷런(Scikit-Learn) 라이브러리에 있는 지도학습, 비지도학습, 모델 평가, 특성공학, 파이프라인, 그리드서치 등 머신러닝 프로젝트에 필요한 모든 단계를 다루고 있습니다. 또한 파이썬을 사용한 영문, 한글 텍스트 처리 방법도 포함되어 있습니다! 🙂

온/오프라인 서점에서 판매 중입니다. [YES24] [교보문고] [리디북스] [한빛미디어]


여러개를 분류하기

로지스틱 회귀 분석에서 암이 양성(1)인지 악성(0)인지를 판단했습니다. 그런데 우리가 원하는 결과가 1/0의 이진 값이 아니고 여러개의 클래스(class)를 분류하는 것이라면 어떻게 할 수 있을까요? 쉽게 생각해 보면 시그모이드 활성화 함수를 가진 뉴런 하나는 어떤 클래스가 참(1)인지 거짓(0)인지를 구분할 수 있을 테니 여러개의 뉴런을 배치하면 여러개의 클래스를 분류할 수 있을 것 같습니다. 즉 각 뉴런의 출력이 해당 클래스에 대한 확률인 것이죠.

hackers_3_1

그림 1. 여러개의 시그모이드 함수로 분류하기

우리가 강아지 이미지를 입력 데이터로 넣고 마지막 뉴런 세개에서 강아지일 확률이 0.1, 고양이일 확률이 0.8, 토끼일 확률 0.3 을 얻었다면 이 모델은 제대로 분류를 하지 못한 셈입니다. 그렇다면 이런 경우 손실 함수를 어떻게 정의할 수 있을까요? 분류 모델의 손실 함수는 로지스틱 회귀와 마찬가지로 크로스 엔트로피 방식을 사용합니다. 사실 로지스틱 회귀의 손실 함수는 크로스 엔트로피의 특수한 경우라고 볼 수 있습니다.

J = -\dfrac{1}{m} \sum_{i=1}^{m}y\,log(\hat{y})

y 는 훈련 데이터에서 구한 클래스의 정답 확률이고 \hat{y} 는 모델을 통해 계산한 확률입니다. 위의 경우 입력 데이터에 대한 출력의 정답 y 는

y =\begin{pmatrix} dog \\ cat \\ rabbit \end{pmatrix} = \begin{pmatrix} 1 \\ 0 \\ 0 \end{pmatrix}

이고 모델이 계산한 확률은 \hat{y} 는

\hat{y} = \begin{pmatrix} 0.1 \\ 0.8 \\ 0.3 \end{pmatrix}

입니다. 그러므로 이 경우의 손실 함수 계산은 아래와 같이 합니다.

J = -( 1 \times ln(0.1) + 0 \times ln(0.8) + 0 \times ln(0.3))

 

소프트맥스

그런데 예를들어 어떤 훈련 데이터에 대해서 마지막 레이어 뉴런의 출력 값이 (강아지, 고양이, 토끼) = (0.9, 0.8, 0.7) 이렇게 나왔고 또 다른 훈련 데이터에 대해서는 (강아지, 고양이, 토끼) = (0.5, 0.2, 0.1) 와 같은 출력이 나왔다고 가정하면 둘 중 어떤 데이터가 강아지에 가까운 것일까요? 전자의 경우 세개의 클래스에 대한 확률이 거의 비슷하므로 어떤 이미지인지 잘 구분하기 어렵습니다. 하지만 후자의 경우는 강아지인 절대 확률은 낮지만 고양이나 토끼 보다는 강아지에 더 가까운 것 같습니다. 이렇게 여러개의 클래스를 구분할 경우 마지막 뉴런의 활성화 함수로 시그모이드를 사용하면 출력 값을 공정하게 평가하기 어렵습니다. 그래서 뉴런의 출력 값을 정규화하는 소프트맥스(softmax) 함수를 주로 사용합니다.

소프트맥스는 뉴런의 출력 값에 지수함수를 적용하되 모든 뉴런에서 나온 값으로 정규화하는 형태를 가집니다. 예를 들어 강아지에 대한 소프트맥스 출력은 아래와 같습니다.

p_{dog} \,=\, \dfrac{e^z_{dog}}{e^z_{dog}+e^z_{cat}+e^z_{rabbit}} \,=\, \dfrac{e^z_{dog}}{\sum_{i=1}^ne^{z_i}} \;\;,\;\; z = w \times x + b

시그모이드 함수를 적용한 (강아지, 고양이, 토끼) 의 확률이 (0.9, 0.8, 0.7) 인 경우 이를 이용해 z 를 구하면

z = -ln \left( \dfrac{1}{p} - 1 \right) 이므로

(z_{dog}, z_{cat}, z_{rabbit}) = \left( -ln\left(\dfrac{1}{0.9}-1\right) ,-ln\left(\dfrac{1}{0.8}-1\right) ,-ln\left(\dfrac{1}{0.7}-1\right) \right) = (2.2, 1.39, 0.85)

입니다. 여기서 구한 z 값으로 소프트맥스를 적용해 보겠습니다.

\left( \dfrac{e^{2.2}}{e^{2.2}+e^{1.39}+e^{0.85}}, \dfrac{e^{1.39}}{e^{2.2}+e^{1.39}+e^{0.85}}, \dfrac{e^{0.85}}{e^{2.2}+e^{1.39}+e^{0.85}} \right) = (0.59, 0.26, 0.15)

이 됩니다. 같은 방식으로 (0.5, 0.2, 0.1) 인 경우도 구해 보겠습니다.

(z_{dog}, z_{cat}, z_{rabbit}) = \left( -ln\left(\dfrac{1}{0.5}-1\right) ,-ln\left(\dfrac{1}{0.2}-1\right) ,-ln\left(\dfrac{1}{0.1}-1\right) \right) = (0, -1.39, -2.2)

이므로 소프트맥스 함수 값은

\left( \dfrac{e^{0}}{e^{0}+e^{-1.39}+e^{-2.2}}, \dfrac{e^{-1.39}}{e^{0}+e^{-1.39}+e^{-2.2}}, \dfrac{e^{-2.2}}{e^{0}+e^{-1.39}+e^{-2.2}} \right) = (0.74, 0.18, 0.08)

우리가 예상했던 대로 시그모이드 함수로 구한 확률이 (0.9, 0.8, 0.7) 인 것은 소프트맥스로 바꾸었을 때 (59%, 26%, 15%) 정도로 강아지일 가능성을 높게 나타내고 있습니다. 하지만 (0.5, 0.2, 0.1) 인 데이터는 전체적으로 시그모이드 값이 낮음에도 불구하고 소프트맥스로 바꾸었을 때 (74%, 18%, 8%) 로 매우 강하게 이 데이터는 강아지임을 나타내고 있습니다. 이런 이유로 멀티 클래스(multi-class) 분류인 경우 소프트맥스 함수를 자주 사용하고 있습니다.

앞에서 손실 함수로 크로스 엔트로피 함수를 보았고 클래스 분류를 위한 확률 함수 혹은 활성화 함수(Activation Function)로 소프트맥스 함수를 보았습니다. 그럼 이제 경사하강법을 사용하기 위해 소프트맥스 뒷편의 뉴런으로 전달되는 그래디언트 즉, 손실 함수의 미분 방정식은 어떻게 될까요? 설마 이것도 선형 회귀 분석이나 로지스틱 회귀와 같을까요? 네 같습니다. 🙂

J = \dfrac{1}{m} \sum_{i=1}^m (y-\hat{y})x

앞서 말한 내용이 장황하지만 결론은 좀 간단하네요. 물론 여기서 이 식을 유도하기 위해 크로스 엔트로피와 소프트맥스 함수를 미분하는 과정을 유도하지는 않겠습니다. 굳이 이를 알아야 할 필요는 꼭 없으니까요. 관심있으신 분들은 인터넷을 조금 찾아보시면 금새 확인할 수 있습니다.

이를 그림으로 나타내면 조금 더 쉽게 이해할 수 있습니다.

hackers_3_2

그림 2. 소프트맥스가 적용된 크로스 엔트로피 함수의 역전파

그런데 한가지 고려해야 할 사항이 있습니다. 소프트맥스 함수를 정방향 계산에 적용할 때 z 가 커지면 e^z 가 아주 커져서 넘파이 함수에서 오류가 발생합니다. 지수함수에서 오버플로우가 발생하면 소프트맥스 함수의 값을 계산할 수가 없습니다.

np.exp(1000)
RuntimeWarning: overflow encountered in exp

이를 피하려면 뉴런에서 나온 값 중 가장 큰 값으로 출력 값을 모두 뺀 후 소프트맥스 함수를 적용하는 것입니다. 예를 들어 (강아지, 고양이, 토끼) 에 대한 뉴런의 출력이 (1000, 500, 10) 이라고 하면 e^{1000} 의 오버플로우를 피하기 위한 소프트맥스 함수는 아래와 같이 적용할 수 있습니다.

p_{dog} \,=\, \dfrac{e^{1000}}{e^{1000}+e^{500}+e^{10}} \,=\, \dfrac{e^{1000} \times e^{-1000}}{(e^{1000}+e^{500}+e^{10}) \times e^{-1000}}

=\,\dfrac{e^{1000-1000}}{(e^{1000-1000}+e^{500-1000}+e^{10-1000})} \,=\,\dfrac{e^{0}}{(e^{0}+e^{-500}+e^{-900})} = 1.0

이와 동일한 이유로 이전에 만들었던 시그모이드 함수도 수정할 필요가 있습니다. 이전 예제에서는 시그모이드 함수를 수식을 설명하기 위해서 np.exp 함수를 그대로 사용했는데 이 함수는 위와 같은 이유로 안정된 결과를 제공하지 못합니다. 다행히 scipy 에서 시그모이드 함수 expit 을 제공합니다. 그래서 이전에 만들었던 시그모이드 함수를 아래와 같이 간단히 바꿀 수 있습니다.

from scipy.special import expit
...
def _sigmoid(self, y_hat):
    return expit(y_hat)

 

손글씨 숫자 데이터

딥 러닝 계의 헬로우 월드(Hello World) 프로그램이라 하면 손으로 쓴 숫자 이미지를 알아 맞추는 분류 문제를 꼽습니다. 숫자로 쓰여진 이미지가 주어지면 0 에서 부터 9 까지 숫자 중 어떤 것인지를 알아맞추는 것이죠. 즉 입력은 이미지가 되고 출력은 0~9 까지의 레이블이 됩니다. 만약 입력 데이터를 직접 준비하려면 손으로 쓴 글씨를 스캔해서 이미지로 만들고 사람이 하나씩 확인해서 레이블을 따로 저장해야 합니다. 다행히도 머신러닝 데이터 레파지토리나 사이킷런에서 미리 만들어진 데이터를 제공하고 있습니다.

사이킷런에 있는 손글씨 데이터load_digits 함수로 로드할 수 있습니다.

from sklearn.datasets import load_digits
digits = load_digits()
print(digits.images.shape, digits.data.shape,
      digits.target.shape, digits.target_names)
((1797, 8, 8), (1797, 64), (1797,), array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]))

digits 데이터는 전부 1797 개 이고 8×8 사이즈의 이미지로 되어 있습니다. digits.images 와 digits.data 는 동일한 값을 가지고 있습니다. 다만 digits.images 는 2 차원 배열로 구성되어 있고 digits.data 는 1 차원 배열로 구성된 차이가 있을 뿐입니다. digits.target 은 1797 개의 출력 값을 가진 1 차원 배열이며 출력은 0 에서 부터 9 까지의 숫자입니다.

digits.images 를 사용하여 첫번째 데이터를 화면에 프린트해 보겠습니다.

print(digits.images[0])
array([[  0.,   0.,   5.,  13.,   9.,   1.,   0.,   0.],
       [  0.,   0.,  13.,  15.,  10.,  15.,   5.,   0.],
       [  0.,   3.,  15.,   2.,   0.,  11.,   8.,   0.],
       [  0.,   4.,  12.,   0.,   0.,   8.,   8.,   0.],
       [  0.,   5.,   8.,   0.,   0.,   9.,   8.,   0.],
       [  0.,   4.,  11.,   0.,   1.,  12.,   7.,   0.],
       [  0.,   2.,  14.,   5.,  10.,  12.,   0.,   0.],
       [  0.,   0.,   6.,  13.,  10.,   0.,   0.,   0.]])

이 데이터는 어떤 숫자로 보이시나요?

print(digits.target[0])
0

이 데이터는 0 을 스캔한 데이터 입니다.

컴퓨터에서는 0~255 까지의 숫자로 색깔을 나타낼 때 0 은 검은색, 255 는 흰색 입니다. 그런데 이 데이터는 0~16 까지의 범위를 가지고 있으며 거꾸로 숫자가 높을 때 검은색에 가깝고 0 에 가까운 숫자는 흰색에 가깝습니다. 즉 스캔한 이미지 데이터를 반전시킨 셈입니다. 따라서 맷플롯립의 imshow 함수를 사용해서 이미지로 출력할 때 컬러 맵을 반전 그레이톤(gray_r)으로 지정해 주어야 합니다.

plt.imshow(digits.images[0], cmap=plt.cm.gray_r)
zero

그림 3. 손글씨 숫자 이미지

이 데이터를 훈련 데이터와 테스트 데이터로 나누어 보겠습니다. 테스트 데이터 비율은 10% 정도로 하였습니다.

from sklearn.model_selection import train_test_split
digits_data = digits.data / 16
X_train, X_test, y_train, y_test = train_test_split(digits_data, digits.target,
                                   test_size=0.1)
print(X_train.shape, X_test.shape, y_train.shape, y_test.shape)
((1617, 64), (180, 64), (1617,), (180,))

훈련 데이터와 테스트 데이터를 나누고 나서 모델을 만들기 전에 마지막 작업을 해야할 것이 있습니다. 이 모델은 10 개의 숫자 이미지를 분류하는 멀티 클래스 분류 모델입니다. 따라서 소프트맥스 함수를 사용할 것이므로 모델을 통해 계산한 \hat{y} 는 각 숫자에 대한 가능성을 표현한 10개의 원소로 이루어진 벡터입니다. 따라서 크로스엔트로피 공식을 이용하려면 훈련 데이터 y_train 도 10개의 원소로 이루어진 벡터여야 편리합니다. 즉,

print(y_train[0])

1

이 아니고

[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]

이 되어야 하는 거죠. 이런 벡터를 원 핫 벡터(One-hot-vector)라고 하며 이렇게 변환하는 작업을 원 핫 인코딩(One-hot-encoding)이라고 부릅니다. 워낙 유명한 용어라 아마 한번쯤을 들어보았을 것 같습니다. 물론 우리가 직접 y_train 의 차원을 위와 같이 변경할 수도 있으나 역시 사이킷런에서 제공해 주는 기능을 이용하는 게 편리합니다.

사이킷런의 preprocessing 모듈 하위에는 OneHotEncoder 클래스가 있습니다. 사실 이 클래스가 무슨 모델을 만들거나 하는 것은 아니지만 우리가 SGDRegressor 와 SGDClassifier 에서 본 것처럼 fit 메소드 스타일을 따르고 있습니다. 이게 사이킷런의 스타일이죠.

다만 predict 란 메소드 대신에 transform 이란 메소드가 제공되고 fit 과 transform 을 합친 fit_transform 메소드를 제공해 주고 있습니다. 데이터 전처리를 위한 클래스이므로 fit 과 transform 을 한꺼번에 처리하는 경우가 많을 것 같습니다. 다른 사이킷런의 메소드들 처럼 fit_transform 에 입력되는 데이터는 2 차원 배열이어야 하므로 reshape 명령으로 열벡터로 변환하여 주입하였고 결과를 넘파이 배열로 변환하여 y_train_enc 변수에 저장하였습니다.

from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(n_values=10)
y_train_enc = ohe.fit_transform(y_train.reshape(-1, 1)).toarray()
print(y_train[:3)
print(y_train_enc[:3])
array([1, 6, 9])
array([[ 0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
       [ 0., 0., 0., 0., 0., 0., 1., 0., 0., 0.],
       [ 0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]])

우리에게 필요한 원 핫 벡터가 준비된 것 같습니다. 본격적으로 뉴럴 네트워크 모델을 만들기 전에 잠시 뉴럴 네트워크의 종류에 대해 살펴 보겠습니다.

 

딥 뉴럴 네트워크

우리가 만들었던 기본 뉴런들을 활성화 함수와 함께 층층이 쌓아서 만든 것이 뉴럴 네트워크(Neural Networks)입니다. 아래 그림과 같은 거죠.

mini-nn

그림 4. 뉴럴 네트워크

뉴럴 네트워크의 종류를 구분하는 분류가 존재하는 것은 아니지만 통용되는 용어를 이해하기 위해 몇가지로 나누어 설명해 보도록 하겠습니다.

1957년 프랑크 로젠블라트(Frank Rosenblatt)가 퍼셉트론(Perceptron)이라는 신경망 알고리즘을 제한했습니다. 이 퍼셉트론은 우리가 앞서서 보았던 한개의 뉴런과 매우 유사합니다. 그래서 멀티 레이어 퍼셉트론(Multi-layer Perceptron)이란 말이 뉴럴 네트워크를 동일한 의미로 자주 사용되고 있습니다. 실제로 사이킷런 0.18 버전에서 추가된 뉴럴 네트워크 클래스의 이름도 멀티 레이어 퍼셉트론이란 이름의 약자를 사용해서 MLPClassifier, MLPRegressor 라고 명명되어 있습니다.

위 그림처럼 앞 레이어의 뉴런과 뒷 레이어의 뉴런이 모두 연결되어 있는 경우를 완전 연결 뉴럴 네트워크(Fully Connected Neural Networks)라 혹은 간단하게 덴스 네트워크(Dense Networks)라고 부르기도 합니다. 말 그대로 하나의 뉴런이 앞뒤 레이어의 모든 뉴런과 연결이 되어 있는 경우 입니다.

아마 이미지 인식에 주로 사용되는 뉴럴 네트워크인 콘볼루션 뉴럴 네트워크(Convolution Neural Network)란 말을 들어 보았을지 모르겠습니다. 콘볼루션은 레이어 사이의 뉴런이 모두 연결되어 있지 않습니다. 이전 레이어의 일부 뉴런이 다음 레이어의 한 뉴런에 연결되어 있죠. 그래서 필요한 가중치 수도 많이 줄일 수 있습니다.

리커런트 뉴럴 네트워크(Recurrent Neural Networks)는 음성 인식이나 기계 번역 등에 많이 쓰이는데 뉴런이 다른 레이어의 뉴런은 물론 자기 자신에게도 연결되어 있다는 점이 특징입니다. 자기 자신에게 연결된 뉴런의 가중치와 상태 값은 새로운 데이터가 입력됨에 따라 조금씩 변하게 됩니다. 그래서 리커런트 뉴럴 네트워크는 뉴런이 과거의 상태를 기억하는 효과를 띠게 되어 일렬로 늘여 놓은 데이터를 처리하기에 좋습니다.

콘볼루션 뉴럴 네트워크나 리커런트 뉴럴 네트워크가 아닌 완전 연결 뉴럴 네트워크를 피드 포워드 뉴럴 네트워크(Feed Forward Neural Networks)라고도 부릅니다. 이러다 보니 멀티 레이어 퍼셉트론, 완전 연결 뉴럴 네트워크, 덴스 네트워크, 피드 포워드 뉴럴 네트워크 모두 같은 의미로 종종 쓰입니다. 용어는 많지만 모두 같은 것이니 혼돈하지 마세요.

뉴럴 네트워크 구조에서 한개 이상의 중간 레이어가 있는 경우를 딥 뉴럴 네트워크(Deep Neural Networks)라고 하고 이런 중간 레이어를 히든 레이어(Hidden Layer)라고 부릅니다. 사실 요즘 대부분의 뉴럴 네트워크는 한 개 이상의 히든 레이어를 포함하고 있기 때문에 뉴럴 네트워크를 딥 뉴럴 네트워크와 명확하게 구분하여 사용하지 않습니다. 딥 러닝(Deep Learning)이란 말은 딥 뉴럴 네트워크를 의미하는 또 다른 말로 혼용해서 많이 사용되고 있습니다. 그리고 딥 뉴럴 네트워크를 줄여서 DNN 이라고도 많이 표기 합니다.

그럼 위 그림처럼 하나의 입력 레이어(Input Layer)와 하나의 히든 레이어, 하나의 출력 레이어(Output Layer)를 갖는 딥 뉴럴 네트워크를 직접 만들어 보겠습니다. 한가지 혼돈하지 말아야 할 것은 입력 레이어에는 가중치를 곱하거나 바이어스를 더하는 역할이 없습니다. 사실 입력 레이어는 그냥 입력 데이터 그 자체입니다. 왜 이렇게 부르게 되었는지는 정확히는 알 수 없으나 많은 사람들이 입력 데이터 자체를 입력 레이어로 부릅니다. 그러니 입력 레이어를 위해 뭔가 뉴런을 만들거나 그럴 필요가 없죠. 그냥 입력 데이터만 준비하면 됩니다.

 

완전 연결 레이어

사이킷런에서 제공하는 이미지 데이터는 위에서 본 것 처럼 64 개의 숫자로 이루어져 있습니다. 우리는 이 64개의 숫자 데이터를 모두 입력으로 사용하려고 합니다. 즉 한개의 픽셀이 하나의 입력 뉴런이 되는 셈이죠. 히든 레이어는 100 개의 뉴런을 두고 활성화 함수로는 시그모이드 함수를 사용합니다. 10 개의 숫자를 알아 맞추어야 하므로 출력 레이어는 10 개의 뉴런과 활성화 함수로 소프트맥스 함수를 사용합니다. 즉 대략 아래와 같은 구조가 됩니다.

hackers_3_3

그림 5. 100개의 히든 유닛으로 구성된 딥 뉴럴 네트워크

하나의 뉴런을 그릴 때는 가중치와 바이어스를 모두 표기했지만 네트워크 구조를 그릴 때는 가중치와 바이어스를 그리기가 어렵네요. 보통 가중치는 뉴런 간의 연결선에 포함되어 있다고 가정하고 바이어스는 종종 별도의 뉴런으로 떼어내어 이전 레이어의 상단에 그리는 경우도 많습니다. 아래 그림을 참고하세요.

hackers_3_4

그림 6. 바이어스를 별도로 표기하는 뉴럴 네트워크

입력 레이어는 차지하고서라도 히든 레이어를 위해 100 개의 LogisticNeuron 객체를 만들어야 할 것 같습니다. 그런데 만약 대규모의 뉴럴 네트워크일 경우 레이어의 뉴런이 수천, 수만개개에 레이어도 수십개가 훌쩍 넘습니다. 이런 클래스의 객체를 모두 만들면 컴퓨터의 메모리가 심각하게 부족하게 될 것입니다. 그런데 각 뉴런을 자세히 들여다 보면 시그모이드 같은 활성화 함수를 제외하고는 단순한 선형계산입니다. 이런 계산을 레이어 있는 전체 뉴런에 대해 한꺼번에 계산할 수 있다면 꽤 효율적일 것 같습니다. 이런 역할을 할 수 있도록 도와주는 것이 행렬(matrix) 연산입니다.

앞에서 말했듯이 가중치 w 와 바이어스 b 표시하면 그림으로 나타내기가 어려워 가중치와 바이어스는 뉴런간의 연결선에 포함되어 있는 것으로 생각해 주세요. 간단하게 3 개의 뉴런이 있는 레이어와 2 개의 뉴런이 있는 레이어를 생각해 보죠.

hackers_3_5

그림 7. 히든 유닛의 곱셈(1)

A 의 출력이 a 와 b 에 전달이 되고 각각 a 와 b 에 있는 가중치와 곱해진 다음 a 와 b 에 있는 바이어스와 더해집니다. 즉 이 레이어는 완전 연결 레이어이므로 A 의 출력은 앞 레이어의 뉴런 a, b 에게 두번 전달되어 지는 것이고 a 와 b 에서 곱해지는 가중치는 각기 다른 값이 됩니다.

다음 B 의 출력이 a 와 b 에 전달이 되고 a 와 b 에 있는 가중치와 곱한 후 바이어스와 더해집니다. 그런데 a 뉴런의 입장에서는 A 의 출력에 곱해질 가중치와 B 의 출력에 곱해질 가중치가 서로 다릅니다. 다시말하면 a, b 뉴런은 이전 레이어의 뉴런 수 만큼 가중치를 가지고 있는 셈이죠. 이를 식으로 나타내면 보다 명확해 집니다. 출력 값은 첨자에 o 를 붙이고 가중치를 구분하기 위해서 출력 뉴런의 기호를 첨자로 넣었습니다.

a = A \times w_{aA} + B \times w_{aB} + C \times w_{aC} + b_a

이를 행렬로 표현하면 아래와 같습니다.

\begin{pmatrix} A & B & C \end{pmatrix} \times \begin{pmatrix} w_{aA} \\ w_{aB} \\ w_{aC} \end{pmatrix} + \begin{pmatrix} b_a \end{pmatrix} = \begin{pmatrix} a \end{pmatrix}

이런 계산을 행렬 곱이라고 배웠었습니다. 이제 b 뉴런에 대해서도 추가해 보겠습니다.

hackers_3_6

그림 8. 히든 유닛의 곱셈(2)

\begin{pmatrix} A & B & C \end{pmatrix} \times \begin{pmatrix} w_{aA} & w_{bA} \\ w_{aB} & w_{bB} \\ w_{aC} & w_{bC} \end{pmatrix} + \begin{pmatrix} b_a & b_b \end{pmatrix} = \begin{pmatrix} a & b \end{pmatrix}

가중치 행렬의 크기는 (이전 레이어의 크기) x (앞 레이어의 크기) 가 되고 바이어스는 앞 레이어의 크기의 행벡터가 됩니다. 이렇게 행렬 형태로 곱할 수 있다면 레이어의 뉴런이 늘어나더라도 클래스의 인스턴스를 뉴런 갯수만큼 만들 필요가 없을 것 같습니다. 넘파이에서는 행렬 연산을 위한 다양한 기능을 제공하고 있는데 그중에 행렬곱 연산을 제공하는 dot 함수가 있습니다.

np.dot(x, w) + b = y_hat

넘파이 배열에서 덧셈은 각 요소의 자리에 맞춰서(element-wise) 자동으로 덧셈이 순서대로 이뤄집니다. 이야기가 길었는데 우리 코드에서 적용할 부분은 그리 많지 않습니다. 새로운 클래스의 이름은 FCNeuron 이라고 하겠습니다.

class FCNeuron(object):
...
def forpass(self, x):
    """정방향 수식 w * x + b 를 계산하고 결과를 리턴합니다."""
    self._x = x
    self._t = self._sigmoid(np.dot(self._x, self._w1) + self._b1)
    _y_hat = np.dot(self._t, self._w2) + self._b2
    return self._softmax(_y_hat)

forpass 함수에서 이전과 달리 두번의 계산을 했습니다. 첫번째 히든 레이어의 출력을 계산하는 식이 있고 다음으로는 출력 레이어의 계산이 따라옵니다. 히든 레이어의 출력의 활성화 함수로는 시그모이드 함수를 사용하였고 출력 레이어의 활성화 함수로는 소프트맥스 함수를 사용하였습니다.

히든 레이어의 출력을 self._t 로 임시 저장하였습니다. 이 값은 나중에 오차가 역전파될 때 사용될 것입니다. 히든 레이어의 입력 self._x 와 가중치 self._w1 를 np.dot 함수로 행렬곱하였고 출력 레이어의 입력 self._t 와 가중치 self._w2 를 행렬곱하였습니다.

변수들의 차원을 잠시 계산해 보겠습니다. 입력 값이 한개라면 self._x 는 64 개의 숫자가 일렬로 늘어져 있는 행벡터입니다. 하지만 훈련 데이터(X_train)의 갯수는 총 1617 개 이므로 self._x 는 1617 개의 행을 가지고 64 개의 열을 가진 행렬이 됩니다.

\_x = \begin{pmatrix} 0 & \cdots & 0 \\ \vdots & \ddots & \vdots \\ 0 & \cdots & 0 \end{pmatrix}_{1617 \times 64}

1617 x 64 크기의 입력 행렬(self._x)과 64 x 100 크기의 가중치 행렬(self._w1)을 곱하면 출력 self._t 의 크기는 1617 x 100 이 됩니다. 시그모이드 함수는 각 행렬 요소에 시그모이드를 적용할 뿐 행렬 크기에는 영향을 미치지 않습니다. 그리고 가중치 행렬의 크기는 이전 레이어와 현재 레이어의 뉴런의 갯수로 결정되는 것이지 입력 데이터의 갯수에 따라 달라지지 않습니다. 입력 데이터의 양이 많을 수록 가중치가 학습이 잘 되는 것 뿐이죠. 바이어스 행렬(self._b1)의 크기는 당연히 레이어 갯수인 100 입니다. 이를 그림으로 나타내면 아래와 같습니다.

hackers_3_7

그림 9. 행렬 곱 구조

출력 레이어에서는 1617 x 100 크기인 self._t 와 100 x 10 인 가중치 행렬 self._w2 를 곱하면 출력 _y_hat 은 1617 x 10 의 크기가 됩니다. 바이어스(self._b2)의 크기는 10 입니다. 마지막으로 소프트맥스 함수를 통과하면 1617 개의 훈련 데이터에 대해 정방향 계산을 한번에 수행해서 10개 숫자에 대한 확률을 계산해낸 것입니다. 소프트맥스 함수도 행렬의 크기에는 영향을 미치지 않습니다.

hackers_3_8

시그모이드 함수는 위에서 말한대로 scipy.special.expit 함수를 사용합니다. 소프트맥스 함수 계산은 위에서 언급한 대로 지수함수의 폭주를 막기 위해 각 행의 최대 값을 찾아서(y_hat.max(axis=1)) 이를 열벡터로 바꾼 다음(reshape(-1, 1)) 각 행의 모든 요소에 빼 줍니다(tmp). 이 값으로 넘파이 np.exp 함수를 사용합니다.

def _softmax(self, y_hat):
    tmp = y_hat - y_hat.max(axis=1).reshape(-1, 1)
    exp_tmp = np.exp(tmp)
    return exp_tmp / exp_tmp.sum(axis=1).reshape(-1, 1)

exp_tmp 변수는 y_hat 에 np.exp 함수를 적용한 동일 크기의 행렬입니다. 소프트맥스 함수의 분모를 만들기 위해서 각 행의 값을 모두 더한 후(exp_tmp.sum(axis=1)) 열벡터로 바꾸었습니다(reshape(-1, 1)). 그리고 exp_tmp 의 각 행 별로 모든 요소에 나눗셈을 해 주었습니다.

결국 뉴럴 네트워크의 레이어는 행렬 연산을 하는 하나의 뉴런으로 만들 수 있습니다. 사실 대부분의 딥 러닝 라이브러리들이 이와 같은 방식으로 구현되어 있습니다. 우리는 레이어의 뉴런들을 그리고 뉴런들 사이의 연결선을 빼곡하게 채운 후 이에 대한 복잡한 계산식을 유도하는 걸 많이 보지만 정작 코드는 행렬 계산 하나로 표현될 뿐입니다. 정방향 계산을 처리했으니 다음에는 역방향 계산을 만들겠습니다.

 

그래디언트 행렬 계산

그래디언트를 계산하는 것이 조금 헷갈리 수 있으나 차근 차근 진행하면 어렵지 않습니다. 우리는 이전에 가중치와 바이어스에 대해 하나씩 그래디언트 변수를 만들었습니다. 여기에서는 히든 레이어와 출력 레이어에 대해 각각 가중치와 바이어스 그래디언트 변수를 만들겠습니다.

 self._w1_grad = 0
 self._w2_grad = 0
 self._b1_grad = 0
 self._b2_grad = 0

로지스틱 회귀 분석에서 가중치에 적용되는 그래디언트는 오차(y - \hat{y})에 입력 값을 곱한 것이었습니다. 행렬로 바뀌었다고 해서 이 미분 공식이 바뀌지 않습니다.

\dfrac{\partial \hat{y}}{\partial w_2} = \dfrac{1}{m} \sum_{i=1}^m (y - \hat{y}) t

출력 레이어에 제공되는 입력은 위에서 임시 값으로 구한 self._t 입니다. 그리고 크로스엔트로피에서 소프트맥스를 적용해서 미분한 오차는 y - y_hat 로 간단하게 계산됩니다. 이 오차는 y_hat 과 동일한 크기인 1617 x 10 의 크기를 가지고 있습니다. self._t 의 크기는 1617 x 100 이므로 1617 개의 훈련 데이터에 각자의 오차가 곱해지기 위해서는 self._t 행렬의 행과 열을 뒤집어 전치 행렬로 만들 필요가 있습니다. 즉 100 x 1617 크기의 행렬이되는 거죠. 넘파이에서 전치 행렬을 만드는 방법은 간단합니다. self._t.T 와 같이 마치 속성을 호출하는 듯 하면 됩니다.

np.dot(self._t.T, err) / self._x.shape[0]

이 행렬곱의 결과는 100 x 10 이 되어 출력 레이어의 가중치 self._w2 의 크기와 같습니다. 다만 1617 개(self._x.shape[0]) 훈련 데이터에 대한 누적값이므로 평균을 냈습니다. 그림으로 보면 좀 더 이해가 쉽습니다.

hackers_3_9

그림 10. 출력 레이어의 그래디언트 전파

로지스틱 회귀에서 바이어스에 대해 미분을 하면 1 이 남기 때문에 오차에 1 을 곱해서 바이어스에 더했습니다. 즉 그냥 오차를 더한 셈이죠.

\dfrac{\partial \hat{y}}{\partial b_2} = \dfrac{1}{m} \sum_{i=1}^m (y - \hat{y}) 1

여기서도 오차를 그냥 더하면 되겠지만 1617 개의 훈련 데이터에 대한 오차이기 때문에 평균을 내야 합니다. 위에서는 명시적으로 나눗셈을 했지만 바이어스의 경우에는 넘파이의 average 함수를 사용하면 편리합니다. axis = 0 을 지정해서 열 방향으로 평균을 내도록 지시합니다. 결국 이 함수의 결과는 모든 열이 합산되어 평균이 내어지므로 행벡터가 됩니다.

np.average(err, axis=0)

이제 출력 레이어에서 히든 레이어로 전달되는 그래디언트를 계산할 차례입니다. 앞서 두개의 뉴런의 섹션에서 보았듯이 t 로 전달되는 그래디언트는 오차에 가중치를 곱한 것이었습니다. 여기서도 마찬가지로 두번째 뉴런에 전달되는 그래디언트는 오차 errself._w2 를 곱하여 구할 수 있습니다.

\dfrac{\partial \hat{y}}{\partial t} = \dfrac{1}{m} \sum_{i=1}^m (y - \hat{y}) w_2

err 의 크기는 1617 x 10 이고 self._w2 는 100 x 10 의 크기를 가지고 있습니다. 따라서 히든 레이어의 뉴런 100 개에 오차를 모두 전달하기 위해서는 self._w2 행렬을 전치시켜서 곱합니다. 그러면 1617 x 100 크기의 행렬이 만들어 집니다. 즉 1617 개의 훈련 데이터에 대해 히든 레이어 100 개에 전달되는 그래디언트입니다.

두개의 뉴런에서 보았던 것과 여기서 다른 점은 히든 레이어에 시그모이드 함수가 추가된 것입니다. 따라서 우리가 구한 그래디언트가 시그모이드 함수를 통과해서 히든 레이어로 전달되기 위해서는 앞서 배웠던 체인룰을 적용하여 시그모이드 함수의 미분 값을 곱해 주면 됩니다. 시그모이드 함수의 미분 값은 아래와 같습니다.

s = \dfrac{1}{1+e^{-z}} \;\;,\;\; \dfrac{\partial s}{\partial x} = s(1-s)

즉 시그모이드의 미분 값은 자기 자신을 1 에서 뺀 값과 곱해준 것입니다. 우리는 정방향 계산에서 히든 뉴런의 출력, 즉 시그모이드 함수를 통과한 값을 self._t 에 저장했었습니다. 따라서 그래디언트를 전파시킬 때 이 값을 이용해서 시그모이드 미분을 적용해 주면 됩니다.

err2 = np.dot(err, self._w2.T)
err2 *= self._t * (1 - self._t)

이를 그림에서 나타내 보도록 하겠습니다.

hackers3_hidden_gradient

그림 11. 히든 레이어의 그래디언트 전파

히든 레이어의 가중치와 바이어스를 위한 그래디언트를 계산하는 것은 출력 레이어에서 했던 것과 매우 비슷합니다. 출력레이어에서 나온 그래디언트 (y - \hat{y}) w_2 에다가 시그모이드 미분 값을 곱해준 것이 히든레이어로 전달된 그래디언트 err2 입니다.

\dfrac{\partial \hat{y}}{\partial w_1} = \dfrac{1}{m} \sum_{i=1}^m (y - \hat{y}) w_2t(1-t)x =\dfrac{1}{m} \sum_{i=1}^m err_2 x

히든 레이어의 가중치에 업데이트할 그래디언트는 입력 값 self._x 에 출력 레이어로 부터 전달된 그래디언트 err2 를 곱합니다. 입력 값 self._x 는 1617 x 64 크기의 행렬이고 err2 는 1617 x 100 크기의 행렬입니다. 따라서 입력 값 self._x 행렬을 전치하여 64 x 1617 크기의 행렬로 만들어 err2 를 행렬곱하면 self._w1 의 크기와 같은 64 x 100 크기의 행렬이 만들어집니다. 이 행렬이 self._w1 에 업데이트할 그래디언트인 거죠.

이 행렬 계산을 다시 생각해 보면 픽셀 하나가 히든 레이어 100 개의 뉴런에 적용되어 낸 결과 값의 오차를 다시 100 개의 뉴런을 통해 받는 것 입니다. 그리고 이 과정은 1617 개의 훈련 데이터에 대해 누적한 것을 평균 내는 계산을 행렬 곱 하나로 표현된 것이죠. 계산식은 출력 레이어의 그래디언트 계산과 비슷합니다.

np.dot(self._x.T, err2) / self._x.shape[0]

히든 레이어의 바이어스에 대한 그래디언트 계산도 출력 레이어의 바이어스 계산과 비슷합니다. 바이어스에 대한 미분은 에러에 1 을 곱한 것이었죠.

\dfrac{\partial \hat{y}}{\partial b_1} = \dfrac{1}{m} \sum_{i=1}^m (y - \hat{y}) w_2t(1-t)1 = \dfrac{1}{m} \sum_{i=1}^m err_2 1

그러므로 1617 개의 훈련 데이터에 대한 그래디언트(err2)를 평균내어 100 개의 뉴런에 적용하면 그만입니다.

np.average(err2, axis=0)

이렇게 계산한 것을 각 그래디언트 변수에 업데이트 하는 것이 backprop 함수에서 처리할 일입니다.

def backprop(self, err, lr=0.1):
    """에러를 입력받아 가중치와 바이어스의 변화율을 곱하고 평균을 낸 후 감쇠된 변경량을 저장합니다."""self._w2_grad = lr * np.dot(self._t.T, err) / self._x.shape[0]
    self._w2_grad = lr * np.dot(self._t.T, err) / self._x.shape[0]
    self._b2_grad = lr * np.average(err, axis=0)
    err2 = np.dot(err, self._w2.T)
    err2 *= self._t*(1 - self._t)
    self._w1_grad = lr * np.dot(self._x.T, err2) / self._x.shape[0]
    self._b1_grad = lr * np.average(err2, axis=0)

계산된 그래디언트를 파라메타에 업데이트 하는 update_grad 함수는 간단한 산술 계산입니다. 바뀐 것은 set_params 함수가 두개의 가중치와 두개의 바이어스를 업데이트할 수 있도록 변경된 것이죠.

def set_params(self, w, b):
    """가중치와 바이어스를 저장합니다."""
    self._w1, self._w2 = w[0], w[1]
    self._b1, self._b2 = b[0], b[1]

def update_grad(self, l2=0):
    """계산된 파라메타의 변경량을 업데이트하여 새로운 파라메타를 셋팅합니다."""
    w1 = self._w1 + self._w1_grad - l2 * self._w1
    w2 = self._w2 + self._w2_grad - l2 * self._w2
    b1 = self._b1 + self._b1_grad
    b2 = self._b2 + self._b2_grad
    self.set_params([w1, w2], [b1, b2])

여기까지 진행했다면 뉴럴 네트워크를 거의 다 만든 셈입니다. 로지스틱 회귀에서 만들었던 뉴런 클래스에서 행렬 곱을 이용한 방식으로 정방향 계산과 역방향 계산을 변경한 것이 중요 포인트입니다.

 

완전 연결 뉴럴 네트워크

LogisticNeuron 에서 만든 fit 메소드와 predict 메소드는 거의 변경없이 쓸 수 있습니다. fit 메소드에서 소프트맥스 함수를 거쳐 구한 확률 \hat{y} 을 이용하여 크로스 엔트로피 비용 함수를 계산할 때 로그 함수가 0 이 되면 음수 무한대가 되므로 계산에 에러가 발생합니다.

np.log(0)
RuntimeWarning: divide by zero encountered in log
-Inf

그리고 \hat{y} 가 1 이 되면 로그 함수가 0 이 되어 소프트맥스 함수 값이 y 값에 상관없이 0 이되므로 np.clip 함수를 사용하여 \hat{y} 의 값을 일정한 범위 이내로 제한하도록 추가합니다.

def fit(self, X, y, n_iter=10, lr=0.1, cost_check=False, l2=0):
    """정방향 계산을 하고 역방향으로 에러를 전파시키면서 모델을 최적화시킵니다."""
    cost = []
    for i in range(n_iter):
        y_hat = self.forpass(X)
        error = y - y_hat
        self.backprop(error, lr)
        self.update_grad(l2/y.shape[0])
        if cost_check:
            y_hat = np.clip(y_hat, 0.00001, 0.99999)
            cost.append(-np.sum(y * np.log(y_hat))/y.shape[0])
    return cost

로지스틱 회귀에서는 predict 메소드에서 0.5 보다 크면 1 이라고 생각하고 0.5 보다 작으면 0 이라고 생각했습니다. 즉 둘 중에 하나로 분류하는 것이라 모아니면 도인거죠. 손글씨 숫자를 구분하는 이 예제는 멀티 클래스 분류 문제이므로 0.5 를 기준으로 정하는 것이 아니고 10 개의 출력 값 중에 가장 높은 것을 고르는 문제가 됩니다.

출력 값 10 개는 각 위치가 숫자 0 에서 부터 9 까지의 확률 값을 가지고 있으므로 가장 높은 확률을 가진 요소의 위치만 찾으면 그 인덱스가 가장 높은 확률을 가진 숫자가 됩니다. 넘파이의 np.argmax 함수를 사용하면 가장 큰 값을 가진 배열의 위치를 손쉽게 찾을 수 있습니다. \hat{y} 은 180 개의 테스트 데이터에 대한 예측 값을 가지고 있게 되므로 행 방향으로 최대 값을 찾을 수 있도록 axis 옵션을 주었습니다.

def predict(self, X):
    y_hat = self.forpass(X)
    return np.argmax(y_hat, axis=1)

자 이제 뉴런 객체를 만들고 학습을 시켜 보겠습니다. 이전 처럼 가중치와 바이어스의 초기 값을 수동으로 부여하기에는 너무 갯수가 많습니다. 사실 초기 가중치를 어떻게 부여하는 가에 따라 학습에 영향을 미칠 수 있어 바람직한 몇가지 방법들이 논문으로도 나와있지만 여기서는 그냥 간단하게 앞 레이어의 뉴런 수를 제곱근하고 역수를 구한 후 그 범위 내에서 균등하게 랜덤 초기화하도록 하겠습니다.

n4 = FCNeuron()
bound = np.sqrt(1./64)
w1 = np.random.uniform(-bound, bound, (64, 100))
b1 = np.random.uniform(-bound, bound, 100)
bound = np.sqrt(1./100)
w2 = np.random.uniform(-bound, bound, (100, 10))
b2 = np.random.uniform(-bound, bound, 10)
n4.set_params([w1, w2], [b1, b2])

학습 속도는 0.1 로 하고 입력 값 X_train 과 원 핫 인코딩된 y_train_enc 를 이용하여 fit 메소드를 실행합니다. 테스트는 X_test 데이터를 이용해서 predict 메소드를 실행합니다.

costs = n4.fit(X_train, y_train_enc, 1000, 0.1, cost_check=True)
y_hat = n4.predict(X_test)

predict 메소드에서 리턴된 y_hat 을 이용해서 테스트 데이터에 대한 정답 레이블 y_test 와 비교해 보겠습니다. 정확도는 이전처럼 사이킷런의 accuracy_score 함수를 사용합니다.

from sklearn.metrics import accuracy_score
accuracy_score(y_test, y_hat)
0.94444444444444442

94.4% 정도가 나왔습니다. 이정도면 꽤 훌륭하지 않은가요? 🙂

fit 메소드에서 리턴된 costs 변수에 담긴 비용 함수를 그래프로 그려 보도록 하겠습니다.

plt.plot(costs)

hackers3_graph

비용 감소 그래프로 알 수 있듯이 학습 과정이 지수 함수 그래프의 형태를 띠면서 원만히 잘 진행된 것을 볼 수 있습니다.

 

사이킷런에서는

사이킷런 0.18 버전에 추가된 MLPClassifier 클래스를 이용하여 같은 예제를 훈련시켜 보겠습니다. MLPClassifier 클래스는 sklearn.neural_network 아래에 위치해 있습니다.

from sklearn.neural_network import MLPClassifier
mlp = MLPClassifier(solver='sgd', learning_rate_init=0.1, alpha=0, 
                    batch_size=1617, activation='logistic', 
                    random_state=10, max_iter=700,
                    hidden_layer_sizes=100, momentum=0)

MLPClassifier 의 객체를 생성할 때 필요한 옵션들을 지정합니다. solver 는 최적화 방식을 고르는 것으로 여기서는 경사하강법을 사용하기 위해 sgd 라고 지정했습니다. 학습 속도는 0.1 로 같게 하였고 정형화 텀을 사용하지 않으려고 alpha 를 0 으로 지정했습니다. 이 클래스는 기본 동작이 미니 배치 방식입니다. 우리 예는 그리 데이터가 크지 않으므로 훈련 데이터 전체인 1617 개를 지정해서 배치 경사 하강법처럼 동작하도록 했습니다. 활성화 함수는 시그모이드나 소프트맥스일 경우를 의미하는 logistic 이라고 셋팅합니다.

히든 레이어 사이즈는 100 개로 같게 하였습니다. MLPClassifier 함수를 사용하는 편리한 점은 또 하나는 레이블 데이터를 원 핫 인코딩 할 필요 없이 그냥 넣어주면 된다는 점이죠.

mlp.fit(X_train, y_train)
mlp.score(X_test, y_test)
0.94444444444444442

MLPClassifier 는 편리하게 score 함수를 따로 제공해 주고 있습니다. 이 함수에 테스트 입력 데이터와 레이블 데이터를 넣어주면 스코어를 계산해 줍니다. 94.4% 가 나왔습니다. 우리가 만든 것과 동일한 결과를 내었습니다.

 

다음장에서는

이미지 인식에 뛰어난 성과를 내고 있는 콘볼루션 뉴럴 네트워크를 다루겠습니다. 콘볼루션 뉴럴 네트워크는 간단한 예제로 직접 구현하기 어려우므로 텐서플로우를 이용해 보겠습니다. 참고를 위해 아래 FCNeuron 의 전체 코드를 싣습니다.

from scipy.special import expit


class FCNeuron(object):

    def __init__(self):
        self._w1 = None # 가중치 w1
        self._w2 = None # 가중치 w2
        self._b1 = None # 바이어스 b1
        self._b2 = None # 바이어스 b2
        self._w1_grad = 0
        self._w2_grad = 0
        self._b1_grad = 0
        self._b2_grad = 0
        self._x = None # 첫번째 뉴런 입력값 x
        self._t = None # 두번째 뉴런 입력값 t

    def set_params(self, w, b):
        """가중치와 바이어스를 저장합니다."""
        self._w1, self._w2 = w[0], w[1]
        self._b1, self._b2 = b[0], b[1]

    def forpass(self, x):
        """정방향 수식 w * x + b 를 계산하고 결과를 리턴합니다."""
        self._x = x
        self._t = self._sigmoid(np.dot(self._x, self._w1) + self._b1)
        _y_hat = np.dot(self._t, self._w2) + self._b2
        return self._softmax(_y_hat)

    def backprop(self, err, lr=0.1):
        """에러를 입력받아 가중치와 바이어스의 변화율을 곱하고 평균을 낸 후 감쇠된 변경량을 저장합니다."""
        self._w2_grad = lr * np.dot(self._t.T, err) / self._x.shape[0]
        self._b2_grad = lr * np.average(err, axis=0)
        err2 = np.dot(err, self._w2.T)
        err2 *= self._t * (1 - self._t)
        self._w1_grad = lr * np.dot(self._x.T, err2) / self._x.shape[0]
        self._b1_grad = lr * np.average(err2, axis=0)

    def update_grad(self, l2=0):
        """계산된 파라메타의 변경량을 업데이트하여 새로운 파라메타를 셋팅합니다."""
        w1 = self._w1 + self._w1_grad - l2 * self._w1
        w2 = self._w2 + self._w2_grad - l2 * self._w2
        b1 = self._b1 + self._b1_grad
        b2 = self._b2 + self._b2_grad
        self.set_params([w1, w2], [b1, b2])

    def fit(self, X, y, n_iter=10, lr=0.1, cost_check=False, l2=0):
        """정방향 계산을 하고 역방향으로 에러를 전파시키면서 모델을 최적화시킵니다."""
        cost = []
        for i in range(n_iter):
            y_hat = self.forpass(X)
            error = y - y_hat
            self.backprop(error, lr)
            self.update_grad(l2/y.shape[0])
            if cost_check:
                y_hat = np.clip(y_hat, 0.00001, 0.99999)
                cost.append(-np.sum(y * np.log(y_hat))/y.shape[0])
        return cost
 
    def predict(self, X):
        y_hat = self.forpass(X)
        return np.argmax(y_hat, axis=1)
 
    def _sigmoid(self, y_hat):
        return expit(y_hat)
 
    def _softmax(self, y_hat):
        tmp = y_hat - y_hat.max(axis=1).reshape(-1, 1)
        exp_tmp = np.exp(tmp)
        return exp_tmp / exp_tmp.sum(axis=1).reshape(-1, 1)

해커에게 전해들은 머신러닝 #3”에 대한 12개의 생각

  1. clcell3

    정말 잘 보고있고 언제나 감사드립니다.
    너무 감사해서 정말 말로는 표현할 수가 없습니다. 몇년 뒤에 메일로 치킨 쏴 드리겠습니다.

    Liked by 1명

    응답
  2. 핑백: Softmax classification | BIRC

  3. DoHyun Jung

    보석같은 글 감사합니다.
    여러 딥러닝 관련 책과 온라인 강의를 봐 왔는데 제겐 박해선님의 블로그 글이 가장 이해하기 쉽네요.

    좋아요

    응답
  4. windy

    소프트맥스 설명부분에서 z=wx+b로 되어 있는데, z=-ln(1 – 1/p) 가 어디서 나온 것인지 알 수 있을까요?
    실제로 softmax 코드 구현에는 z를 따로 구하지 않는 것 같아서 질문드립니다.

    좋아요

    응답

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중

This site uses Akismet to reduce spam. Learn how your comment data is processed.