minhui study

06. 2개의 층을 연결하자(다층 신경망) - 배치경사하강법 본문

딥러닝,인공지능/Do it! 딥러닝입문

06. 2개의 층을 연결하자(다층 신경망) - 배치경사하강법

minhui 2021. 5. 21. 15:47

06-1. 신경망 알고리즘을 벡터화하여 한 번에 전체 샘플 사용하기

벡터화된 연산은 알고리즘의 성능을 올린다.

  • 확률적 경사 하강법은 가중치를 1번 업데이트할 때 1개의 샘플을 사용하므로 손실 함수의 전역 최솟값을 불안정하게 찾는다.
  • 배치 경사 하강법은 가중치를 1번 업데이트할 때 전체 샘플을 사용하므로 손실 함수의 전역 최솟값을 안정적으로 찾는다. 단, 알고리즘 1번 수행당 계산 비용이 많이 든다는 점에서 주의해야 한다.

 

 

벡터 연산과 행렬 연산을 알아보기

벡터화된 연산을 제대로 사용하려면 벡터 연산과 행렬 연산을 알아야 한다. 여기서는 신경망에서 자주 사용하는 벡터 연산 중 하나인 점 곱(스칼라 곱)행렬 곱셈에 대해 알아보자

x = [x1, x2, x3, x4, x5, x6....]
w = [w1, w2, w3, w4, w5, w6....]
x*w = [x1*w1, x2*w2, ....]

이때 x와 w는 벡터라고 부르고 위의 두 벡터를 곱하여 합을 구하는 계산(np.sum(x*self.w))을 점 곱 또는 스칼라 곱이라고 한다.

그리고 다음 그림을 보면 행렬의 곱셈 방식을 알 수 있다.

행렬의 곱셈을 계산하는 넘파이의 np.dot()함수를 사용하면 np.sum(x*self.w)를 다음과 같이 수정할 수 있다.

z = np.dot(x,self.w) + self.b

 

아래는 전체 훈련 데이터 X를 가중치 W와 곱하는 예이다. X는 m개의 샘플과 3개의 특성으로 되어있고 가중치는 m개의 행과 1개의 열로 이루어져 있다. 

(m, n) * (n, k) = (m, k)

 

SingleLayer 클래스에 배치 경사 하강법 적용하기

# 이번에 사용할 데이터도 위스콘신 유방암 데이터이다.
import numpy as np
import matplotlib.pyplot as plt

# 위스콘신 유방암 데이터 세트를 훈련, 검증, 테스트 세트로 나누고 데이터 살펴보기
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
cancer = load_breast_cancer() # 데이터 세트를 cancer변수에 대입하기
x = cancer.data
y = cancer.target
x_train_all, x_test, y_train_all, y_test = train_test_split(x, y, stratify=y, test_size=0.2, random_state=42)
x_train, x_val, y_train, y_val = train_test_split(x_train_all, y_train_all, stratify=y_train_all, test_size=0.2, random_state=42)

cancer 데이터 세트의 특성 개수를 30개이다. 다음은 훈련 세트와 검증 세트의 크기이다.

 

정방향 계산을 행렬 곱셈으로 표현하기

다음은 정방향 계산을 행렬 곱셈으로 표현한 것이다. 훈련 세트와 가중치를 곱한 다음 절편을 더한다.

넘파이를 사용하면 절편을 더하는 계산을 위해 (364, 1) 크기의 행렬을 따로 만들 필요가 없다. 

 

그레이디언트 계산 이해하기

가중치를 업데이트하기 위해 쓰이는 그레이디언트는 어떻게 계산할 수 있을까?

그레이디언트는 오차와 입력 데이터의 곱이므로 다음과 같은 행렬 곱셈으로 표현할 수 있다.

* X^T는 X를 전치(행과 열을 바꾸는 것)한 것이고 E는 오차들을 모은 것이다.

행렬을 전치하면 행과 열이 바뀌므로 샘플의 각 특성들을 오차에 곱할 수 있는 형태가 된다.

g1은 모든 샘플의 첫 번째 특성과 오차를 곱하여 더한 값이므로 이후 그레이디언트 평균값을 계산할 때 이 값을 다시 전체 샘플 수로 나눈다.

(30, 364) * (361, 1) = (30, 1) 

 

 

forpass(), backdrop() 메서드에 배치 경사 하강법 적용하기

def forpass(self, x):
        z = np.dot(x, self.w) + self.b        # 선형 출력을 계산합니다.
        return z

def backprop(self, x, err):
        m = len(x)
        w_grad = np.dot(x.T, err) / m         # 가중치에 대한 그래디언트를 계산합니다.
        b_grad = np.sum(err) / m              # 절편에 대한 그래디언트를 계산합니다.
        return w_grad, b_grad

forpass() 메서드는 np.sum() 함수 대신 행렬 곱셈을 해주는 np.dot() 함수를 사용한다.

backdrop() 메서드는 행렬 곱셈을 적용한 결과가 그레이디언트들의 합이므로 전체 샘플 개수로 나눠 평균 그레이디언트를 구한다.

 

 

fit() 메서드 및 나머지 메서드 수정하기

  • 원래 fit()메서드에서는 에포크를 위한 for문과 훈련 세트를 순회하기 위한 for문이 있었다. 
  • 배치 경사 하강법에서는 forpass()메서드와 backdrop()메서드에서 전체 샘플을 한꺼번에 계산하므로 두 번째 for문이 삭제된다.
  • 활성화 출력 a가 열 벡터이므로 이에 맞추어 타깃값을 (m, 1) 크기의 열 벡터로 변환하였고 평균 손실을 구하기 위해 np.sum() 함수로 각 샘플의 손실을 더한 후 전체 샘플의 개수로 나눈다. 
class SingleLayer:
    
    def __init__(self, learning_rate=0.1, l1=0, l2=0):
        self.w = None              # 가중치
        self.b = None              # 절편
        self.losses = []           # 훈련 손실
        self.val_losses = []       # 검증 손실
        self.w_history = []        # 가중치 기록
        self.lr = learning_rate    # 학습률
        self.l1 = l1               # L1 손실 하이퍼파라미터
        self.l2 = l2               # L2 손실 하이퍼파라미터

    def forpass(self, x):
        z = np.dot(x, self.w) + self.b        # 선형 출력을 계산합니다.
        return z

    def backprop(self, x, err):
        m = len(x)
        w_grad = np.dot(x.T, err) / m         # 가중치에 대한 그래디언트를 계산합니다.
        b_grad = np.sum(err) / m              # 절편에 대한 그래디언트를 계산합니다.
        return w_grad, b_grad

    def activation(self, z):
        z = np.clip(z, -100, None)            # 안전한 np.exp() 계산을 위해
        a = 1 / (1 + np.exp(-z))              # 시그모이드 계산
        return a
        
    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        y = y.reshape(-1, 1)                  # 타깃을 열 벡터로 바꿉니다.
        y_val = y_val.reshape(-1, 1)
        m = len(x)                            # 샘플 개수를 저장합니다.
        self.w = np.ones((x.shape[1], 1))     # 가중치를 초기화합니다.
        self.b = 0                            # 절편을 초기화합니다.
        self.w_history.append(self.w.copy())  # 가중치를 기록합니다.
        # epochs만큼 반복합니다.
        for i in range(epochs):
            z = self.forpass(x)               # 정방향 계산을 수행합니다.
            a = self.activation(z)            # 활성화 함수를 적용합니다.
            err = -(y - a)                    # 오차를 계산합니다.
            # 오차를 역전파하여 그래디언트를 계산합니다.
            w_grad, b_grad = self.backprop(x, err)
            # 그래디언트에 페널티 항의 미분 값을 더합니다.
            w_grad += (self.l1 * np.sign(self.w) + self.l2 * self.w) / m
            # 가중치와 절편을 업데이트합니다.
            self.w -= self.lr * w_grad
            self.b -= self.lr * b_grad
            # 가중치를 기록합니다.
            self.w_history.append(self.w.copy())
            # 안전한 로그 계산을 위해 클리핑합니다.
            a = np.clip(a, 1e-10, 1-1e-10)
            # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
            loss = np.sum(-(y*np.log(a) + (1-y)*np.log(1-a)))
            self.losses.append((loss + self.reg_loss()) / m)
            # 검증 세트에 대한 손실을 계산합니다.
            self.update_val_loss(x_val, y_val)
    
    def predict(self, x):
        z = self.forpass(x)      # 정방향 계산을 수행합니다.
        return z > 0             # 스텝 함수를 적용합니다.
    
    def score(self, x, y):
        # 예측과 타깃 열 벡터를 비교하여 True의 비율을 반환합니다.
        return np.mean(self.predict(x) == y.reshape(-1, 1))
    
    def reg_loss(self):
        # 가중치에 규제를 적용합니다.
        return self.l1 * np.sum(np.abs(self.w)) + self.l2 / 2 * np.sum(self.w**2)
    
    def update_val_loss(self, x_val, y_val):
        z = self.forpass(x_val)            # 정방향 계산을 수행합니다.
        a = self.activation(z)             # 활성화 함수를 적용합니다.
        a = np.clip(a, 1e-10, 1-1e-10)     # 출력 값을 클리핑합니다.
        # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
        val_loss = np.sum(-(y_val*np.log(a) + (1-y_val)*np.log(1-a)))
        self.val_losses.append((val_loss + self.reg_loss()) / len(y_val))

 

 

훈련 데이터 표준화 전처리하기

안정적인 학습을 위해 사이킷런의 StandardScaler클래스를 사용해 데이터 세트의 특성을 평균이 0, 표준 편차가 1이 되도록 변환해보자

scaler = StandardScaler() # scalar 객체 만들기
scaler.fit(x_train) # 변환 규칙을 익힌다.
# transform() 메서드로 데이터를 표준화 전처리한다.
# 훈련 세트와 검증 세트에 표준화를 적용한다.
x_train_scaled = scaler.transform(x_train) 
x_val_scaled = scaler.transform(x_val) 

 

▶ 데이터를 SingleLayer 클래스 객체에 전달하여 배치 경사 하강법을 적용하기

확률적 경사 하강법보다 에포크 횟수를 크게 늘려주어야 한다.

single_layer = SingleLayer(l2=0.01)
single_layer.fit(x_train_scaled, y_train, x_val=x_val_scaled, y_val=y_val, epochs=10000)
single_layer.score(x_val_scaled, y_val)
# 0.978021978021978

 

▶ 검증 세트로 성능 측정하고 그래프로 비교하기

→ 확률적 경사 하강법의 손실 그래프는 변동이 심했지만 배치 경사 하강법은 전체 샘플을 사용하여 가중치를 업데이트하기 때문에 손실값이 안정적으로 감소한다.

 

왜 이런 결과가 나오는 것일까??

 

배치 경사 하강법을 적용하니 가중치를 찾는 경로가 다소 부드러운 곡선의 형태를 띈다. 가중치의 변화가 연속적이므로 손실값도 안정적으로 수렴된다.

다만 배치 경사 하강법은 매번 전체 훈련 세트를 사용하므로 연산 비용이 많이 들고 최솟값에 수렴하는 시간도 많이 걸린다.


 

< 참고 자료 >

Do it! 딥러닝 입문 - 박해선 지음

Comments