본문 바로가기

Data Science/밑바닥부터시작하는딥러닝(3)

[밑시딥] 나만의 딥러닝 프레임워크 만들기, 더 큰 도전으로(1)

반응형

🔊 해당 포스팅은 밑바닥부터 시작하는 딥러닝 3권을 개인적으로 공부하면서 배운 내용을 기록하고 해당 책을 공부하시는 다른 분들에게 조금이나마 도움이 되고자 하는 목적 하에 작성된 포스팅입니다. 포스팅 내용의 모든 근거는 책의 내용에 기반하였음을 알립니다.

 

출처: Yes24


어느덧 책의 마지막 챕터에 도달했다. 이번 포스팅에서는 그동안 만들어온 Dezero라는 나만의 딥러닝 프레임워크를 더 큰 도전을 향해 나아가도록 발전시켜본다. 학습시킨 모델의 파라미터를 저장하거나 로드하는 기능 뿐만 아니라 더 효율적인 학습을 위한 기법인 드롭아웃 같은 기능을 추가해본다. 다음 포스팅에서는 지금까지는 Fully Connected Layer 로만 구성된 DNN 딥러닝 모델을 구현했지만, 더 복잡한 모델인 CNN, RNN의 계층도 구현해볼 예정이다. 이제 마지막 고지를 정복해보도록 하자!

1. 모델의 학습된 파라미터(Weight) 저장 및 로드하는 기능

유명한 오픈소스 딥러닝 프레임워크에는 학습시킨 모델의 파라미터를 저장하거나 로드하는 기능이 탑재되어 있다. 이러한 기능 덕분에 우리와 같은 서민(?)들은 빅테크 대기업들이 거대한 하드웨어로 학습시켜놓은 큰 모델들의 파라미터값만 로드하고 우리가 직면한 문제를 해결하는 데 쉽게 활용해볼 수 있다. 이렇게 학습시킨 모델의 파라미터를 저장하거나 로드하는 기능을 우리의 딥러닝 프레임워크에도 추가시켜보도록 하자.

 

해당 기능을 구현하기 위해서는 넘파이의 함수를 사용할 예정이다. 구체적으로는 넘파이의 save와 load라는 함수를 사용하는 방법에 대해 알아보자.

 

import numpy as np

x = np.array([1,2,3])
np.save('./param.npy', x)

param = np.load('./param.npy')
print(param)

 

위와 같이 간단한 넘파이 배열을 npy 확장자 파일로 저장한 후 로드를 해볼 수 있다. 그런데 save 함수 말고도 savez 라는 함수도 존재하는데, 우리의 현재 상황에서는 savez 라는 함수가 더 적절할 수 있다. 바로 파라미터의 이름을 명시해줄 수 있기 때문이다. 무슨 말인지는 아래 코드를 통해 이해해보자.

 

import numpy as np

x1 = np.array([1,2,3])
x2 = np.array([4,5,6])

np.savez('./param.npz', param1=x1, param2=x2)

# 아래와 같이 unpacking을 통해서도 가능
# data = {'x1': x1, 'x2': x2}
# np.savez('./param.npz', **data)

param = np.load('./param.npz')

print('x1:', param['param1'])
print('x2:', param['param2'])

 

savez 함수는 이전과 다르게 확장자 파일을 npz로 명시하면 된다. 그리고 savez 함수로 넘파이 배열을 지정해줄 때 특정 배열에 대한 이름을 argument로 명시해주어서 저장할 수 있다. 이렇게 하면 저장된 넘파이 배열을 로드할 때도 argument로 넣어준 이름을 key 값으로 넣어주어 가져올 수 있다. 마치 파이썬의 딕셔너리처럼 말이다. 이렇게 하면 우리가 학습시키는 모델의 파라미터들 각각에 어떤 계층의 어떤 파라미터인지 명시를 해줄 수 있을 것이다. 실제 코드에 반영할 때는 savez_compressed 함수를 사용하려 한다. savez 함수와 사용법은 같되 이름에서 유추할 수 있는 것처럼 저장할 배열을 압축시키는 기능이 추가된 함수이다.

 

저번 포스팅에서 구현한 것처럼 현재 Dezero는 중첩된 계층의 구조를 표현하도록 되어 있다. 

 

중첩된 Layer의 구조를 포현

 

그래서 현재 Dezero로 만든 모델들의 파라미터도 중첩되어 있는 상태이다. 그래서 파라미터들을 모두 1차원 형태로 평탄화(Flatten) 시켜주어야 할 필요가 있다. 이를 위해 아래처럼 Layer 클래스에 특정 함수를 추가해보도록 하자.

 

import weakref
import numpy as np
from dezero import Parameter
from dezero import functions as F


class Layer:
    def __init__(self):
        self._params = set()
        
    (...생략...)
    
    def _flatten_params(self, param_dict, parent_key=''):
        for name in self._params:
            obj = self.__dict__[name]
            key = parent_key + '/' + name if parent_key else name

            if isinstance(obj, Layer):
                obj._flatten_params(param_dict, parent_key=key)
            else:
                param_dict[key] = obj

 

해당 함수는 모델 구조가 만들어진 뒤 호출되어야 하기 때문에 Layer 클래스의 인스턴스 변수에는 모델의 파라미터가 들어가 있는 각 계층 이름이 들어가 있을 것이다. 이를 하나씩 루프를 돌면서 그것이 Parameter 클래스이면 param_dict 라는 변수에 바로 담아버리고, 만약 Layer 클래스라면 중첩된 구조이므로 해당 함수를 재귀적으로 호출하도록 한다.

 

이제 위 함수로 파라미터를 param_dict 라는 딕셔너리에 담아 놓았으니 이를 npz 파일로 저장하고 또 저장된 파라미터 파일을 로드하는 함수를 Layer 클래스에 추가시켜보도록 하자.

 

import os
import weakref
import numpy as np
from dezero import Parameter
from dezero import functions as F


class Layer:
    def __init__(self):
        self._params = set()
        
   (..생략..)

    def _flatten_params(self, param_dict, parent_key=''):
        for name in self._params:
            obj = self.__dict__[name]
            key = parent_key + '/' + name if parent_key else name

            if isinstance(obj, Layer):
                obj._flatten_params(param_dict, parent_key=key)
            else:
                param_dict[key] = obj

    def save_weights(self, path):
        param_dict = {}
        self._flatten_params(param_dict)
        array_dict = {key: param.data for key, param in param_dict.items() if param is not None}

        try:
            np.savez_compressed(path, **array_dict)
        except (Exception, KeyboardInterrupt) as e:
            if os.path.exists(path):
                os.remove(path)
            raise

    def load_weights(self, path):
        npz = np.load(path)
        param_dict = {}
        self._flatten_params(param_dict)

        for key, param in param_dict.items():
            param.data = npz[key]

 

파라미터를 저장하는 save_weights 함수를 보자. 로직은 이해하기 쉽다. 단, try ~ except 구문으로 넣은 점이 특이하다. except 구문에 키보드인터럽트 예외처리가 있는데, 이는 사용자가 파라미터를 저장하는 액션을 갑작스레 취소할 때 처리하도록 한 구문이다. 즉, 갑작스레 취소했을 때 불완전한 파일이 이미 일부 디렉토리에 저장되어 있을텐데 그 불완전한 파일을 삭제버리시키는 구문이다. 

 

다음으로는 저장한 파라미터를 로드하는 load_weights 함수를 보자. 주목할 부분은 마지막 for loop 구문이다. 해당 구문이 가능한 이유는 우리가 로드할 파라미터를 갖고 있던 모델(A라고 하자)의 구조와 로드한 파라미터를 사용하려는 모델(B라고 하자) 즉, A와 B모델의 구조가 동일해야 적용이 가능하다. 만약 구조가 조금이라도 틀리면 어떤 모델에는 존재하는 파라미터 이름이 다른 모델에는 존재하지 않아 KeyError가 발생할 것이 분명하다.

2. 드롭아웃(Dropout)과 테스트 모드 기능 추가하기

학습시킨 딥러닝 모델에서는 주로 과대적합(Overfitting) 이라는 문제가 발생한다. 이러한 과대적합 문제는 크게 2가지 원인으로부터 야기되는데, 첫 번째는 학습 데이터 자체가 적은 문제, 두 번째는 모델의 표현력이 지나치게 높다는 점이다. 여기서 모델의 표현력이 지나치게 높다는 것이 무슨 말인지 이해가 잘 안될 수 있다. 이에 대해서는 해당 책 시리즈 1권을 공부했을 때 배웠던 적이 있다. 딥러닝 은닉층들의 출력값들의 분포를 보고 출력값들이 모두 거의 동일한 값만을 출력할 수도 있는데, 이렇게 되면 딥러닝은 어떤 데이터에도 똑같은 출력값만을 내뱉는 문제가 발생하고 이러한 현상을 "모델의 표현력이 낮다 또는 제한이 있다"라고 이야기한다.

 

그렇다면 이번 목차에서 말하는 과대적합의 원인 중 하나라고 방금 말했던 "모델의 표현력이 지나치게 높다"라는 것이 무슨 의미인지 유추해볼 수 있다. 즉, 딥러닝 은닉층들의 출력값들의 분포가 매우 균일하게 발생(마치 Uniform 분포 모양의 형태처럼) 할 것이고 이는 곧 각 데이터마다 다른 출력값을 내뱉긴 하는데, 일부 특성이 매우 비슷한 데이터끼리는 동일한 출력값으로 나와야 하는데 이 마저도 서로 다른 출력값 형태롤 나오게 되는 문제이다.

 

위 각 2가지 원인에 대응하는 해결책으로는 다음과 같다. 학습 데이터 자체가 부족한 원인은 학습 데이터 개수를 늘리면 되는데, 가장 자주 사용되는 방법으로 데이터 확장(Augmentation) 방법이 있다. 두 번째 원인에 대한 대응으로는 가중치 감소(Weight Decay = Regularization(정규화)), 드롭아웃(Dropout), 배치 정규화(Batch Normalization) 방법들이 있다. 각 방법에 대한 개념적인 것은 이전 포스팅을 참고하도록 하자. 여기서는 드롭아웃 기능을 직접 구현하는 것을 배워보도록 하자. 원저자 코드에는 다른 기법들도 코드로 구현되어 있다.

 

드롭아웃의 핵심은 학습 데이터를 모델에 흘려보낼 때마다 삭제할 뉴런을 무작위(Random)로 선택한다. 드롭아웃에는 크게 2가지 종류가 존재한다. 먼저 가장 일반적인(Directed) 드롭아웃을 살펴보자. 아래 넘파이를 활용한 간단한 예시를 통해 살펴보자.

 

import numpy as np

dropout_ratio = 0.6
x = np.ones(10)

mask = np.random.rand(10) > dropout_ratio
y = x * mask
print(y)

 

위처럼 마스킹이라는 Boolean Index 값을 설정해서 삭제할 뉴런을 무작위로 선택한다. 그런데 잠깐 여기서 책에서 얻은 배움을 하나 적어보려고 한다. 바로 드롭아웃과 앙상블 학습 간의 관계이다. 이 두개 간에는 긴밀한 연관이 있다. 즉, 드롭아웃을 수행한다는 것은 앙상블 학습을 한다고 할 수 있다. 앙상블 학습의 개념은 여러 모델을 개별적으로 학습시킨 후 추론할 때 개별 모델들의 출력값을 (통상적으로는) 평균을 내게 된다. 이러한 관점에서 드롭아웃도 비슷하다. 드롭아웃은 매번 학습 시 마다 삭제하는 뉴런이 다를 것이다. 이는 곧 매번 학습 모델의 구조가 달라짐을 의미한다. 결국 학습 시에 매번 다른 모델로 학습하게 된다는 점에서 앙상블 학습과 비슷하다고 할 수 있다.

 

그래서 앙상블 학습이 추론 시에는 '평균'을 내는 것처럼 드롭아웃도 추론 시에는 '평균'을 내는 것과 같이 개별 모델이 내뱉은 결과값들을 '약화'시키는 과정이 필요하다. 이러한 '약화'시키는 과정은 보통 학습 시에 적용한 드롭아웃 비율(ratio)을 1에서 뺀 값을 출력값에 곱해준다. 넘파이 코드로 나타내면 아래와 같다.

 

import numpy as np

dropout_ratio = 0.6
x = np.ones(10)

# train
mask = np.random.rand(10) > dropout_ratio
y = x * mask

# test -> 출력값을 약화시키기
scale = 1 - dropout_ratio
y = x * scale

 

다른 드롭아웃의 유형으로는 역(Inverted) 드롭아웃이 있다. 역 드롭아웃은 스케일 맞추기를 학습할 때 수행한다. 즉 위의 일반적인 드롭아웃에서 추론(test) 시 정의한 scale 이라는 값을 학습하는 과정에 포함시킨다는 것이다. 그리고 나서는 추론 시에는 출력값에 어떠한 연산도 수행하지 않는 것이다. 넘파이 코드로 이해하면 아래와 같다.

 

import numpy as np

dropout_ratio = 0.6
x = np.ones(10)

# train
scale = 1 - dropout_ratio
mask = np.random.rand(*x.shape) > dropout_ratio
y = x * mask / scale    # 학습 시에 scale을 수행

# test
y = x

 

그러면 역 드롭아웃이 갖는 장점은 무엇일까? 우선 테스트 시에는 어떠한 연산도 하지 않기 때문에 일반적인 드롭아웃에 비해 추론 시 속도가 살짝 향상된다. 두 번째로는 학습할 때의 드롭아웃 비율(dropout_ratio)을 동적으로 변경할 수가 있다. 일반적인 드롭아웃 같은 경우는 학습 시에 지정한 드롭아웃 비율을 추론 시에도 동일하게 사용해야 하기 때문에 학습 시 중간에 드롭아웃 비율을 다른 값으로 바꾸면 결과가 이상해질 우려가 있다. 하지만 역 드롭아웃 비율은 애초에 모든 연산 과정일 학습 내에서 이루어지기 때문에 동적으로 드롭아웃 비율 조정이 가능하다.

 

이러한 이유 때문에 실제로 많은 딥러닝 프레임워크의 드롭아웃 기능에는 역 드롭아웃 방식을 채택하고 있다고 한다. 물론 우리가 만들고 있는 Dezero도 똑같이 역 드롭아웃 방식을 채택할 예정이다.

 

드롭아웃 기능을 본격적으로 구현하기 전에 테스트 모드로 전환되도록 하는 기능을 먼저 추가해보자. 왜냐하면 드롭아웃은 학습/테스트 시에 동작하는 로직이 다르기 때문이다. 먼저 core.py 라는 파일 안의 Config 클래스를 다음과 같이 변경해주고 test_mode() 라는 함수를 추가해보자.

 

import numpy as np
import heapq
import weakref
import contextlib
import dezero

class Config:
    enable_backprop = True
    train = True
    
	(..생략..)
    
@contextlib.contextmanager
def using_config(name: str, value: bool):
    old_value = getattr(Config, name)
    setattr(Config, name, value)
    try:
        yield
    finally:
        setattr(Config, name, old_value)

def test_mode():
    return using_config('train', False)

 

using_config 라는 컨텍스트 매니저 함수는 예전 포스팅에서 구현한 것이므로 여기서는 별도로 설명하지 않겠다. 그러면 이제 드롭아웃 기능을 functions.py 라는 함수에 아래처럼 추가해보자.

 

import numpy as np
from dezero.core import Function
from dezero.core import as_variable
from dezero.core import as_array
from dezero import utils
from dezero import Config

(...생략...)

def dropout(x, dropout_ratio=0.5):
    x = as_variable(x)
    
    if Config.train:
        scale = 1 - dropout_ratio
        mask = np.random.rand(*x.shape) > dropout_ratio
        y = x * mask / scale
        return y
    else:
        return x

 

지금까지 구현한 역 드롭아웃과 테스트 모드 기능을 테스트 해보는 간단한 넘파이 코드는 아래와 같다.

 

import numpy as np
from dezero import test_mode
import dezero.functions as F

x = np.ones(5)
print(x)  # [1. 1. 1. 1. 1.]

# train
y = F.dropout(x)
print(y)  # variable([0. 2. 0. 2. 0.])

# test
with test_mode():
    y = F.dropout(x)
    print(y)  # variable([1. 1. 1. 1. 1.])

 

반응형