Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

수치 해석적 최적화

1미분

어떤 함수의 특정 지점에서의 경사, 즉 기울기는 미분으로 구할 수 있습니다. 미분은 두 지점을 잇는 기울기에서 두 지점 사이의 간격을 극한으로 보내 정의합니다. 미분에는 크게 두 가지 방법이 있습니다. 하나는 기호를 조작하여 도함수를 직접 구하는 해석적 방법이고, 다른 하나는 근삿값을 구하는 수치 미분입니다. 수치 미분은 해석적 미분이 어려울 때 사용하며, 서로 다르지만 무한히 가까운 두 지점 사이의 기울기를 구한다는 미분의 정의를 그대로 반영합니다. 서로 다르면서 무한히 가깝다는 것은 수치로는 표현할 수 없으므로, 0으로 해석되지 않으면서 가능한 한 작은 값을 두 지점의 차이로 두어 근사적으로 구합니다.

2경사 하강법

측정된 손실을 기준으로 학습 매개변수를 갱신하여 더 낮은 손실을 내는 최적의 매개변수를 찾는 과정을 수학적으로 최적화라고 합니다. 최적화의 기본적인 방법론이 경사 하강법(gradient descent)입니다. 경사 하강법의 기본 구상은, 어떤 지점의 경사, 즉 기울기를 구한 뒤 기울기가 더 낮아지는 방향으로 매개변수를 갱신해 나가면 이전보다 더 낮은 곳에 도달할 수 있다는 것입니다. 스키장에서 산을 타고 내려올수록 경사가 점차 완만해지고, 가장 낮은 곳에 이르면 경사가 사라져 더 이상 움직이지 않게 되는 것과 비슷합니다. 그래서 경사가 없어지면 가장 낮은 지점에 도달했다고 봅니다.

3학습률

경사 하강을 수행할 때 갱신의 정도를 조절하기 위해 학습률을 설정합니다. 너무 작은 값은 최저점까지 내려가는 데 오래 걸리고, 너무 큰 값은 최적점을 사이에 두고 지나쳐 발산할 수 있습니다. 목적지까지 이동할 때 적절한 속도가 중요한 것과 비슷합니다. 처음부터 지나치게 느리게 가면 너무 오래 걸리고, 지나치게 빠르게 가면 목적지를 지나쳐 되돌아와야 할 수도 있습니다. 학습률의 적절한 값은 데이터셋과 모델에 따라 달라지며 미리 알아낼 방법이 없으므로, 사람이 여러 값을 실험적으로 바꿔 가며 좋은 값을 찾아야 하는 학습 환경에 해당합니다.

import sys
print(sys.version)
import numpy as np
print(np.__version__)
np.show_config()
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def 경사산출(f, x):
    h = 1e-4
    경사 = np.empty_like(x)
    
    for i, xi in enumerate(x):
        x[i] = xi + h
        fxh1 = f(x)
        x[i] = xi - h
        fxh2 = f(x)
        
        경사[i] = (fxh1 - fxh2) / (2 * h)
        x[i] = xi # 원래 값 복원

    return 경사

def 경사산출_2d(f, X):
    경사 = np.zeros_like(X)
    
    for j, xj in enumerate(X):
        경사[j] = 경사산출(f, xj)
    return 경사

def 교차엔트로피오차(y, y_pred):
    delta = 1e-7
    배치크기 = y.shape[0]
    return -np.sum(y * np.log(y_pred + delta)) / 배치크기

def softmax(z):
    exp_z = np.exp(z - np.max(z))
    y = exp_z / np.sum(exp_z)
    return y

def softmax_2d(Z):
    return np.apply_along_axis(softmax, axis=1, arr=Z)

class 완전연결:
    def __init__(self, 입력, 출력, 활성화=None):
        self.W = np.random.randn(입력, 출력)
        self.b = np.zeros(출력)
        self.activation = 활성화
        
    def forward(self, X):
        Z = np.dot(X, self.W) + self.b
        if self.activation:
            return self.activation(Z)
        return Z

class 신경망:
    def __init__(self, 손실함수):
        self.layers = []
        self.loss_func = 손실함수
        
    def add(self, layer):
        self.layers.append(layer)
        
    def predict(self, X):
        """순전파 (feedforward)"""
        output = X
        for layer in self.layers:
            output = layer.forward(output)
        return output
    
    def 손실산출(self, X, y):
        y_pred = self.predict(X)
        손실 = self.loss_func(y, y_pred)
        return 손실
    
    def fit(self, X, y, 배치크기, 학습횟수, 학습률):
        """학습"""
        표본수 = X.shape[0]
        손실변화 = []
        for i in range(학습횟수):
            print(f'학습 {i+1}')
            # 1. 미니배치
            배치색인 = np.random.choice(표본수, 배치크기)
            X_batch = X[배치색인]
            y_batch = y[배치색인]
            # 2. 경사 산출        
            f = lambda 매개변수: self.손실산출(X_batch, y_batch)
            층별경사 = []
            for layer in self.layers:
                dW = 경사산출_2d(f, layer.W)
                db = 경사산출(f, layer.b)
                층별경사.append((dW, db))
            # 3. 매개변수 갱신 (경사 하강)
            for layer, (dW, db) in zip(self.layers, 층별경사):
                layer.W -= dW * 학습률
                layer.b -= db * 학습률
                
            # (선택적) 손실확인
            손실 = self.손실산출(X_batch, y_batch)
            손실변화.append(손실)
            print(f'\t손실: {손실}')
        return 손실변화
import pickle

with open('../data/mnist/mnist_data.pkl', 'rb') as f:
    mnist = pickle.load(f)

(train_images, train_labels), (test_images, test_labels) = mnist

def 전처리(images, target_shape=(784,)):
    X = images.reshape(-1, *target_shape)
    X = X.astype('float32')
    X /= 255
    return X

X_train = 전처리(train_images)
X_test = 전처리(test_images)

assert X_train.shape == (60000, 784)
assert X_test.shape == (10000, 784)

# 원핫인코딩
one_hot_encoding = lambda labels: np.eye(10)[labels]
Y_train = one_hot_encoding(train_labels)
model = 신경망(교차엔트로피오차)
model.add(완전연결(784, 50, sigmoid))
model.add(완전연결(50, 100, sigmoid))
model.add(완전연결(100, 10, softmax_2d))

배치크기 = 100
에폭당_배치수 = len(X_train) // 배치크기
에폭수 = 1
학습횟수 = 에폭당_배치수 * 에폭수

손실변화 = model.fit(X_train, Y_train, 배치크기=배치크기, 학습횟수=1, 학습률=0.1)

outputs = model.predict(X_train)
y_pred = np.argmax(outputs, axis=1)
print(f'Acc: {np.mean(train_labels == y_pred):.2%}')

수치 미분은 미분의 정의를 그대로 구현하면 되기 때문에 구현이 수월합니다. 그러나 매개변수의 개수가 많아질수록 편미분을 구하기 위한 연산이 가파르게 증가하는 것이 문제입니다. 이때 각 변수의 편미분을 구하기 위해 수행하는 순전파의 출력은 손실입니다.

수치 미분의 문제점

편미분 수행을 위해 각 변수마다 순전파를 반복적으로 수행해야 합니다. 미분 연산을 위해 각 변수마다 두 번씩 순전파가 발생합니다.

예를 들어, 입력이 2차원 벡터(xR2 \textbf{x} \in \mathbb{R}^{2} )이고, 1층에 세 개의 뉴런(W1R2×3,b1R3 W_1 \in \mathbb{R}^{2 \times 3}, b_1 \in \mathbb{R}^{3}), 2층에 두 개의 뉴런(W2R3×2,b2R2W_2 \in \mathbb{R}^{3 \times 2}, b_2 \in \mathbb{R}^2 )이 활용된다면 매개변수의 개수는 (2×3+3)+(3×2+2)=17 (2 \times 3 + 3) + (3 \times 2 + 2) = 17 이고, 그에 따라 순전파 횟수는 17×2=34 17 \times 2 = 34 입니다.

from functools import wraps

def count_method_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

model = 신경망(교차엔트로피오차)
model.add(완전연결(2, 3, sigmoid))
model.add(완전연결(3, 2, softmax_2d))

model.손실산출 = count_method_calls(신경망.손실산출).__get__(model, 신경망)

X = np.array([[0.1, 0.2]])
Y = np.array([[0, 1]])
model.fit(X, Y, 배치크기=1, 학습횟수=1, 학습률=0.1)

# 마지막 순전파 호출은 손실 측정을 위한 것이므로 제외
print(f'순전파 횟수: {model.손실산출.calls - 1}')

다음 신경망의 매개변수는 45,360개입니다. 학습 시점에 경사 산출을 위해 필요한 순전파 횟수는 다음과 같습니다.

45,365×2=90,72045,365 \times 2 = 90,720

전체 표본수가 60,000일 때 학습 시점에서 미니 배치 크기를 100으로 설정한다면, 에폭당 600번의 미니 배치 반복이 필요합니다. 그렇다면, 1 에폭에 필요한 순전파 횟수는 54,432,000입니다. 즉, 1 에폭에 5천만 번 이상의 순전파 연산을 수행해야 한다는 뜻입니다.

90,720×600=54,432,00090,720 \times 600 = 54,432,000

이러한 연산량 문제는 매개변수의 개수가 많아질수록 그에 비례하여 증가합니다. 지금 다룬 것은 지극히 간단한 3층 신경망임을 고려하면, 딥러닝을 수치 미분으로 학습시키는 것은 사실상 불가능에 가깝습니다. 딥러닝에서는 매개변수의 개수가 수억에서 수십억, 나아가 수조 개에 이를 수 있기 때문입니다.

model = 신경망(교차엔트로피오차)
model.add(완전연결(784, 50, sigmoid))
model.add(완전연결(50, 100, sigmoid))
model.add(완전연결(100, 10, softmax_2d))

model.손실산출 = count_method_calls(신경망.손실산출).__get__(model, 신경망)
model.fit(X_train, Y_train, 배치크기=100, 학습횟수=1, 학습률=0.1)

# 마지막 순전파 호출은 손실 측정을 위한 것이므로 제외
print(f'순전파 횟수: {model.손실산출.calls - 1}')

4연산 성능의 변천

1980년대와 비교하면 컴퓨터의 연산 성능은 만 배 가까이 차이가 납니다. 오늘날의 준수한 CPU에서 5분 정도 소요되는 연산이라면, 1980년대의 컴퓨터에서는 1년 가까이 소요된다는 의미입니다. 미니 배치 하나를 훈련하는 데 현재의 컴퓨터로 5분이 걸리고 1 에폭이 100개의 미니 배치로 구성된다면 1 에폭에 500분이 소요되는데, 같은 연산을 1980년대 컴퓨터로 수행하면 1 에폭 훈련에 500년이 필요합니다. 신경망이 퍼셉트론과 달리 비선형성을 극복할 수 있다 하더라도, 훈련에 수백 년을 기다리는 것은 현실적이지 않습니다. 더구나 훈련 결과가 만족스럽지 않으면 신경망 구성을 바꿔 다시 훈련하고 결과를 확인하는 과정을 반복해야 합니다. 수치 미분적 방법은 이해가 쉽고 구현이 간단하지만, 신경망 구성이 조금만 복잡해져도 연산 요구량이 기하급수적으로 증가하여 실제로 사용하기는 어렵습니다.