Programming & Machine Learning/Python X 머신러닝

인공뉴런(퍼셉트론, 에이다라인)과 그래디언트 디센트(Gradient Descent)의 개념 및 구현

Yamarae 2017. 8. 3. 01:51

머신러닝의 핵심 원리

1. 인공 뉴런의 개념

머신 러닝과 관련된 학문은 초기에 인공 뉴런을 표방하는 것으로 시작되었다.
뇌가 어떻게 일하는지를 이해하려는 노력의 산물이 바로 인공 뉴런이다.

뉴런은 뇌에서 서로 연결되어있는 신경 세포를 의미하는데, 이를 알고리즘으로 해부하면 다음과 같다.
하나하나의 신경세포는 바이너리 출력을 갖는 간단한 논리 게이트를 가지고 있다.
신경세포가 다음 신경세포로 신호를 보내는 임계치를 판단하는 것이 바로 활성함수의 개념이다. 
(정확히는 활성함수의 아웃풋은 비용함수로 이용되고, 임계함수가 최종 판단의 역할을 한다. 
아래의 예제코드에서 활성함수는 new_input, 임계함수는 predict이다. 글을 정독한 뒤 다시 이 문단을 읽으면 이해가 된다)

이 활성함수의 최적의 가중계수를 자동으로 찾는 것이 머신 러닝과 인공 뉴런의 핵심이라고 할 수 있다.
활성함수 여러개의 output이 다시 활성함수의 input으로 들어가고, 
마치 tree 구조에서 parent 노드에 도달하는 것과 유사한 흐름으로 예측치 Y에 도달하게 된다.

예측치 Y가 실제의 Y값과 다르다면, 각각의 활성함수들의 계수에 문제가 있는 것이다.
비용함수를 최소화하기 위해 활성함수들의 계수를 조정하는 과정을 거치게 되는데, 
이 과정이 머신 러닝에서의 학습 과정이다.

2. 퍼셉트론

인공뉴런의 시작으로 퍼셉트론이라는 것이 있다.
퍼셉트론은 학습 규칙을 가지고 있는데, 이것의 메인 아이디어는 하나의 뉴런을 작동시킬지 말지에 대해
최적의 가중계수를 학습한 뒤 그것을 입력 피처에 곱하는 것이었다. 

이 개념은 주로 분류에 사용되었으며, 간단한 예를 들어 설명하면 다음과 같다.
만약 두 가지로 분류를 하는 문제가 있다고 하자. 그 결과값은 1,-1 두 가지 이다.
여기서 입력값 x와 가중 벡터(가중 계수) w가 선형 조합을 취하는 수식을 활성함수 f(z)라고 한다.
여기서의 z는 순 입력(new input function)이라고 한다. (z = w1x1 + w2x2 + ...)
만약 활성함수에 대한 출력값이 만약 특정 임계값을 넘어선다면 결과값은 1이 되는 것이고, 아니라면 -1이 되는 것이다.

이것이 단일 뉴런의 작동 방식이다.
바로 이런 작동 원리 위에서, 퍼셉트론 규칙의 가중치를 업데이트 할 수 있다.

1. 가중치를 0 혹은 그에 준하는 작은 값으로 초기화한다.
2. 훈련 데이터에 대하여 최종 출력 값을 계산한다. 
	(가중치를 아직 업데이트 하지 않는 원시 수식에서 데이터와 라벨 쌍이 몽땅 들어온다면 온라인 방식, 
	나뉘어져 들어온다면 배치 방식이라고 부른다.)
3. 가중치를 업데이트한다.

이제 가중벡터 내에서 업데이트 되는 각각의 가중치 wj를 수식으로 표현하면 다음과 같다.

wj = wj + Δwj

가중치를 업데이트 하는 데 사용된 Δwj값은 퍼셉트론의 학습 규칙에 의해 계산되는데, 이부분이 학습의 핵심이다.

Δwj = μ(y - y_pred) * xi

여기서 μ은 학습률을 나타내는데, 수식적으로 생각해본다면 학습률이 크면 자연히 Δwj의 절대값이 커질 수 밖에 없을 것이다.
또한, 오분류에 대해서는 반대 방향으로 시프트 현상이 일어날 것임이 매우 직관적이다.

만약 위처럼 예를 든 분류같이, 
분류 대상이 선형적으로 명확히 분류가 가능하고 학습률이 충분히 작다면 퍼셉트론은 결국 수렴하게 될 것이다.
물론 오분류 허용 임계치를 설정해 주지 않으면 무한히 이를 반복할 것이다.

선형적으로 분류가 가능하다는 것은, 
그래프로 표현하면 평면상의 두 분류를 하나의 직선을 그어서 하는것이 가능하다는 것이고,
수식적으로는 최대 차수가 일차항인 단일 함수를 통해 두 분류를 해낼 수 있다는 것이다.

3. 퍼셉트론의 구현

python machine learning 교재에서는 퍼셉트론을 직접 구현하는 예제를 삽입해 두었다.
아마 실전에서 퍼셉트론을 직접 구현할 일은 없겠지만, 
가중계수를 코딩으로 직접 업데이트 해보는 것은 제법 의미가 있는 일인 것 같다.
import numpy as np


class Perceptron(object):

    def __init__(self, eta=0.01, n_iter=10):
        self.eta = eta
        self.n_iter = n_iter
        # 퍼셉트론의 학습률과 가중치 업데이트 반복 회수

    def fit(self, X, y):
        self.w_ = np.zeros(1 + X.shape[1]) 
        # a.shape[k] : a가 n차원의 데이터일때, k차원의 attribute 숫자를 리턴해줌.
        # np.zeros(a) : a가 int라면, aX1 일차원 행렬을 생성해줌. a가 shape=(b,c)라면 bXc 행렬을 생성해줌.
        self.errors_ = []

        for _ in range(self.n_iter):
            errors = 0
            for xi, target in zip(X, y):
                update = self.eta * (target - self.predict(xi))
                self.w_[1:] += update * xi
                self.w_[0] += update
                errors += int(update != 0.0)
            self.errors_.append(errors)
            # error는 iter의 각 단계에서 얼마나 error가 발생했는지 보기 위함.
        return self

    def net_input(self, X):
        return np.dot(X, self.w_[1:]) + self.w_[0]
        # np.dot(a,b) : a,b 행렬의 곱. 내적값 --> a.dot(b) == np.dot(a,b)

    def predict(self, X):
        return np.where(self.net_input(X) >= 0.0, 1, -1) 
        # np.where(a, b, c) : 조건 a에 따라 b,c로 결과값 할당

4. 에이다라인과 학습의 수렴

에이다라인이 퍼셉트론보다 발전된 점은, 비용함수를 따로 정의하고 이를 최소화 하는 것을 목표로 하는 점이다.
퍼셉트론이 오차를 처리하는 방법은, 실제 값과 예측 값의 차이를 단순 보정하여 업데이트에 이용하는 것이었다.
하지만 에이다라인의 경우 비용함수를 더욱 발전된 방법으로 정의한다.

그 예가 바로 Gradient Descent이다.
비용함수를 SSE으로 정의하였다면, 이 비용함수는 2차함수이기 때문에 미분이 가능하다.
이 함수의 또 다른 특징으로는 볼록성이다. 

미분이 가능하며 볼록성을 띠는 SSE 2차함수는 U자형 모양을 띠게 되는데, 그곳이 극소점이다.
이 알고리즘은 그래디언트 디센트라는 이름 자체에서 오는 느낌 그대로,
미분을 통해 점진적으로 오차제곱합이 극소값이 되는 곳을 찾는다.

퍼셉트론과 에이다라인은 굉장히 유사한 알고리즘이지만, 
비용함수 최소화에 의해 가중치가 갱신되는 부분이 다르다.
이 내용은 수식으로 봐도, 피부로도 직접적으로 와닿지 않기 때문에 코드로 비교해보면 훨씬 와닿는다.

5. 에이다라인의 구현

class AdalineGD(object):

    def __init__(self, eta=0.01, n_iter=50):
        self.eta = eta
        self.n_iter = n_iter

    def fit(self, X, y):
        self.w_ = np.zeros(1 + X.shape[1])
        self.cost_ = []

        for i in range(self.n_iter):
            output = self.net_input(X)
            errors = (y - output)
            # 퍼셉트론과 달리, 모든 입력데이터의 결과값을 1 iter에 한번에 구한 뒤,
            # 입력 레이블과의 오차도 모두 구함.
            self.w_[1:] += self.eta * X.T.dot(errors)
            self.w_[0] += self.eta * errors.sum()
            # 퍼셉트론과의 차이는 데이터가 한번에 들어간다는 점.
            cost = (errors**2).sum() / 2.0
            self.cost_.append(cost)
        return self

    def net_input(self, X):
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def activation(self, X):
        return self.net_input(X)

    def predict(self, X):
        return np.where(self.activation(X) >= 0.0, 1, -1)
이러한 머신러닝의 방법에서 에포크를 잘못 조정하게 되면,
평생토록 가중계수를 수렴시키지 못하거나(...) 아예 SSE값이 안드로메다로 가버릴 수 있다.
그래서 에포크를 항상 로그로 찍어서 관찰한 뒤 계수를 조정하거나, 그래프를 그려보는 것이 바람직하다.
fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(8, 4))

ada1 = AdalineGD(n_iter=10, eta=0.01).fit(X, y)
ax[0].plot(range(1, len(ada1.cost_) + 1), np.log10(ada1.cost_), marker='o')
ax[0].set_xlabel('Epochs')
ax[0].set_ylabel('log(Sum-squared-error)')
ax[0].set_title('Adaline - Learning rate 0.01')

ada2 = AdalineGD(n_iter=10, eta=0.0001).fit(X, y)
ax[1].plot(range(1, len(ada2.cost_) + 1), ada2.cost_, marker='o')
ax[1].set_xlabel('Epochs')
ax[1].set_ylabel('Sum-squared-error')
ax[1].set_title('Adaline - Learning rate 0.0001')

plt.tight_layout()
# plt.savefig('./adaline_1.png', dpi=300)
plt.show()
그래프를 보면, 0.01 에포크에서는 비용함수의 값이 안드로메다로 여행을 떠난다.
이는 에포크의 값이 잘못된 탓도 있지만, 데이터가 표준화되지 않았기 때문이다.
표준화를 시켜준 뒤 다시 그려보면 이번에는 비용함수의 값이 제자리를 찾게 된다.
X_std = np.copy(X)
X_std[:,0] = (X[:,0] - X[:,0].mean()) / X[:,0].std()
X_std[:,1] = (X[:,1] - X[:,1].mean()) / X[:,1].std()

ada = AdalineGD(n_iter=15, eta=0.01)
ada.fit(X_std, y)

plot_decision_regions(X_std, y, classifier=ada)
plt.title('Adaline - Gradient Descent')
plt.xlabel('sepal length [standardized]')
plt.ylabel('petal length [standardized]')
plt.legend(loc='upper left')
plt.tight_layout()
# plt.savefig('./adaline_2.png', dpi=300)
plt.show()

plt.plot(range(1, len(ada.cost_) + 1), ada.cost_, marker='o')
plt.xlabel('Epochs')
plt.ylabel('Sum-squared-error')

plt.tight_layout()
# plt.savefig('./adaline_3.png', dpi=300)
plt.show()

6. 미니배치 방식의 그래디언트 디센트 구현

배치방식 vs 온라인 방식의 개념 위에서 구현한 그래디언트 디센트는 배치 방식이다. 온라인 방식의 그래디언트 디센트는 흔히 확률적 그래디언트 디센트라고 불리는데, 이 방식을 이용하면 계산비용이 극적으로 감소하며, 데스크탑과 심신의 안정을 도모할 수 있다. 확률적 그래디언트 디센트는 가중치 업데이트의 사이클이 훨씬 짧기 때문에 더 빠르게 수렴에 이르게 된다. 더 빠르게 수렴이 되는 만큼 국지적 최소값으로 빠질 수가 있다. 이러한 단점을 보완하기 위해 그래디언트 디센트에서의 학습률을 동적으로 대체시킨다. 시간이 지남에 따라 감소하는 적응적 학습률을 사용하는데, 본 글에서는 사용하지는 않기로 한다. 이제 온라인 방식의 그래디언트 디센트의 소스코드를 훑어보자. input data만 나눠놓고, 업데이트하는 부분만 나눠놓으면 사실 별로 손 볼 부분도 없다.

from numpy.random import seed

class AdalineSGD(object):

    def __init__(self, eta=0.01, n_iter=10, shuffle=True, random_state=None):
        self.eta = eta
        self.n_iter = n_iter
        self.w_initialized = False # 초기화와 관련된 변수
        self.shuffle = shuffle # chunk data의 순서를 섞어주는 역할
        if random_state:
            seed(random_state)
        
    def fit(self, X, y):
        self._initialize_weights(X.shape[1]) # 가중계수 초기화
        self.cost_ = []
        for i in range(self.n_iter):
            if self.shuffle:
                X, y = self._shuffle(X, y)
            cost = []
            for xi, target in zip(X, y):
                cost.append(self._update_weights(xi, target))
            avg_cost = sum(cost)/len(y)
            self.cost_.append(avg_cost)
        return self

    def partial_fit(self, X, y): # 들어오는 partial data에 대해서 바로 학습, 업데이트
        if not self.w_initialized:
            self._initialize_weights(X.shape[1])
        if y.ravel().shape[0] > 1:
            for xi, target in zip(X, y):
                self._update_weights(xi, target)
        else:
            self._update_weights(X, y)
        return self

    def _shuffle(self, X, y): # chunk data의 순서를 섞어주는 역할
        r = np.random.permutation(len(y))
        return X[r], y[r]
    
    def _initialize_weights(self, m): # 가중계수 초기화
        self.w_ = np.zeros(1 + m)
        self.w_initialized = True
        
    def _update_weights(self, xi, target): # 업데이트를 작은 단위로 바로 시행
        output = self.net_input(xi)
        error = (target - output)
        self.w_[1:] += self.eta * xi.dot(error)
        self.w_[0] += self.eta * error
        cost = 0.5 * error**2
        return cost
    
    def net_input(self, X):
        return np.dot(X, self.w_[1:]) + self.w_[0]

    def activation(self, X):
        return self.net_input(X)

    def predict(self, X):
        return np.where(self.activation(X) >= 0.0, 1, -1)