본문 바로가기

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

[밑시딥] 나만의 딥러닝 프레임워크 만들기, 신경망 만들기(2)

반응형

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

 

출처: Yes24


이번 포스팅에서는 드디어 우리가 흔히 아는 오픈소스 딥러닝 프레임워크, 예를 들어 Tensorflow, Pytorch, Chainer를 사용하면서 접했던 API 형태로 Dezero를 추상화시켜볼 예정이다. 매개변수를 한데 모아주는 계층, Linear 계층같은 것들도 한데 모아주는 계층, 더 나아가 Optimizer, Dataset, DataLoader 까지 만들어볼 예정이다. 아마 딥러닝 프레임워크를 많이 사용해본 분들이라면 해당 이름의 API들이 어떤 역할을 하는지 알 것이다. 하지만 실제적으로 그 내부적으로 어떻게 동작하고 어떤 기초로 만들어왔는지 알고 있다면 프레임워크에 대한 이해는 한층 더 깊어질 것이다. 그 과정을 우린 이번 포스팅에서 알아본다.

1. 매개변수를 모아두는 계층

여기서는 매개변수를 모아두는 구조를 만들기 위해 Parameter 와 Layer 라는 클래스를 구현할 예정이다. 먼저 Parameter 클래스를 구현하는 것은 간단하다. 단지 우리가 만들어온 변수 클래스 즉, Variable 클래스를 상속받기만 하면 된다. Parameter 클래스의 진정한 효과는 Layer 클래스를 구현할 때 빛을 발한다. 먼저 Parameter 클래수 구현 코드이다.

 

class Parameter(Variable):
    pass

 

다음은 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 __setattr__(self, name, value):
        if isinstance(name, Parameter):
            self._params.add(name)
        super().__setattr__(name, value)

    def __call__(self, *inputs):
        outputs = self.forward(*inputs)
        if not isinstance(outputs, tuple):
            outputs = (outputs,)
        self.inputs = [weakref.ref(x) for x in inputs]
        self.outputs = [weakref.ref(y) for y in outputs]
        return outputs if len(outputs) > 1 else outputs[0]

    def forward(self, x):
        raise NotImplementedError("This method should be run outside of Layer class.")

    def params(self):
        for name in self._params:
            yield self.__dict__[name]

    def clear_grads(self):
        for param in self.params():
            param.clear_grad()

 

하나씩 살펴보자. 우선 초기화 메소드에서 _params 라는 변수에 집합형 자료구조를 할당한다. 우리는 여기에다가 신경망의 파라미터 변수를 담아낼 것이다. 변수를 어떻게 담아낼 것이냐 한다면 __setattr__ 이라는 매직 메소드를 활용해서 담는다. __setattr__ 은 name, value 라는 인자를 받는다. __setattr__ 은 name 이라는 변수에다가 value 라는 값을 할당할 때 내부적으로 자동호출되는 메소드이다. 파이썬 코드를 예시로 들면 아래와 같다.

 

layer = Layer() # 위에서 정의한 Layer 클래스
layer.p1 = Parameter(np.array(3))
layer.p2 = np.array([1,2,3])

 

위 코드를 예시로 들면 layer라는 클래스를 정의했고, layer 라는 클래스의 'p1' 이라는 인스턴스 변수에다가 Parameter(np.array(3)) 이라는 값을 할당하라는 이야기다. 즉, 이렇게 '할당'하는 순간에 내부적으로 작성된 Layer 코드 내에 있는 __setattr__ 매직 메소드가 자동호출된다. 물론 우리가 위에서 정의한 __setattr__ 메소드는 원래 애초부터 존재했던 메소드이고 우리가 오버로딩시켜준 메소드이다.

 

다음은 __call__ 매직 메소드를 새롭게 정의한다. 이는 우리가 예전 초반 포스팅에서 구현했던 함수들의 기반(base) 클래스인 Functions 클래스를 정의할 때와 거의 유사하다. 단, 순전파(forward) 메소드는 이 Layer 클래스를 상속받는 다른 클래스들에서 구체적으로 정의해준다. 

 

다음으로는 params 라는 메소드인데, 이는 yield 구문을 활용해서 제네레이터를 만드는데, 이 제네레이터가 인스턴스 변수 즉, 여기서는 self._params 라는 하나의 인스턴스 변수에 담겨져 있는 파라미터 변수들을 반환시켜준다. 한 가지 추가로 알아둘 점은 해당 클래스의 인스턴스 변수이름을 key로 하고 그 값을 value로 하는 딕셔너리 형태로 받아오려면 self.__dict__ 형태로 호출해주면 된다.

 

마지막으로 clear_grads 메소드는 말 그대로 모든 파라미터 변수의 기울기값을 초기화하는 메소드이다. 이 메소드 안에서는 방금 위에서 정의한 params 메소드를 활용한다. params 메소드가 현재 제네레이터 함수로 정의했기 때문에, clear_grads 메소드안에서 for loop를 활용해 params 메소드를 호출하고, 결국 params 메소드가 내뱉는 건 파라미터 변수들이기 때문에 파라미터 변수들을 하나씩 받아올 수 있게 된다. 그리고 그 파라미터 변수는 Variable 클래스를 상속하고 있는 Parameter 클래스이고 일전에 Variable 클래스에서 정의했던 기울기를 초기화하는 clear_grad() 메소드 호출이 가능해진다(메소드 이름 끝이 -s가 안붙어야 한다)

 

이제 그러면 위 Layer 클래스를 상속받으면서 선형연산을 수행하는 Linear 클래스라는 것을 새롭게 정의해보자. 이것도 코드를 먼저 보고 메소드 하나하나씩 이해해보자.

 

class Linear(Layer):
    def __init__(self, out_size, nobias=False, dtype=np.float32, in_size=None):
        super().__init__()
        self.in_size = in_size
        self.out_size = out_size
        self.dtype = dtype

        self.W = Parameter(None, name='W')
        if self.in_size is not None:
            self._init_W()

        if nobias:
            self.b = Parameter(np.zeros(self.out_size, dtype=self.dtype), name='b')
        else:
            self.b = None

    def _init_W(self):
        I, O = self.in_size, self.out_size
        W_data = np.random.rand(I, O).astype(self.dtype) * np.sqrt(1 / I)
        self.W.data = W_data

    def forward(self, x):
        if self.W.data is None:
            self.in_size = x.shape[1]
            self._init_W()
        y = F.Linear(x, self.W, self.b)
        return y

 

우선 가장 먼저 초기화 메소드로 출력 노드 개수(out_size), 편향 존재 여부, data type, 입력 노드 개수(in_size)를 argument로 준다. 단, 입력 노드 개수 디폴트 값은 None 이며, 입력 노드 개수를 직접 사용자가 넣어도 되긴 하지만, 만약 None으로 정의하면 입력 노드 개수를 자동으로 유추해서 Linear 클래스를 만들어주게 된다. 또 초기화 메소드에서 self.W 라는 변수로 파라미터 변수를 데이터가 None인 값으로 정의해준다. 이 때 입력 노드 개수를 넣어주었다면 _init_W() 라는 메소드를 호출해서 파라미터 변수에 데이터 값을 집어넣는다. 그리고 편항 값을 설정했다면 편향 값에 대한 파라미터 변수를 만들어준다.

 

그리고 순전파(forward) 메소드를 수행할 때, 먼저 파라미터 변수의 데이터가 None인지 여부를 본다. 만약 forward 메소드를 호출했을 때도 여전히 값이 None 이라면 입력 노드 개수를 설정해주어야 한다. 입력 노드 개수는 forward 메소드 인자로 들어오는 x 라는 변수로부터 유추해낼 수 있다. 그리고 난 후 Linear 함수를 활용해서 입력(x), 파라미터(W, b)와 선형 연산을 취해준다.

 

그런데 위처럼 왜 이렇게 복잡하게 해놓았을까? 라고 의문을 가질 수 있다. 하지만 위 코드를 구현해놓음으로써 우리는 계층 클래스를 정의할 때, 단지 출력노드 개수만 넣어주면 입력 노드 개수는 알아서 계산해주어 우리에게 드디어 익숙한 아래의 API 형태로 코딩이 가능해진다.

 

import numpy as np
from dezero import functions as F
import dezero.layers as L

# dataset
np.random.seed(42)
x = np.random.rand(100, 1)
y = np.sin(2 * np.pi * x) + np.random.rand(100, 1)   # label

# -> 출력 노드 개수만 지정하면 알아서 가능! 딥러닝 프레임워크 API와 비슷해짐
layer1 = L.Linear(25)
layer2 = L.Linear(1)


def predict(x):
    y = layer1(x)
    y = F.sigmoid(y)
    y = layer2(y)
    return y


lr = 0.2
iters = 10000

for i in range(iters):
    # predict and get loss
    y_pred = predict(x)
    loss = F.mean_squared_error(y, y_pred)

    # clear gradients
    for l in [layer1, layer2]:
        l.clear_grads()

    # backpropagation
    loss.backward()

    # update parameters
    for l in [layer1, layer2]:
        for p in l.params():
            p.data -= lr * p.grad.data

    if (0 <= i <= 30) or (i+1) % 1000 == 0:
        print(f"Epoch:{i+1} -> Loss:{loss.data}")

 

지금까지 만든 Dezero 코드로 간단한 신경망 학습 코드이다. 주목할 점은 주석에 써놓은 듯이 딥러닝 프레임워크 API 형태와 유사한 포맷으로 신경망 계층을 정의할 수 있게 되었다. 아래는 Pytorch 공식 홈페이지에 있는 코드 예시이다.

 

Pytorch example

2. 계층들을 모아두는 계층

그런데 아직 더 개선할 점이 있다. 1번 목차에서 우리는 구현한 Linear 클래스 계층을 하나하나씩 직접 정의해주었고 기울기 초기화나 파라미터 갱신을 하기 위해서 리스트에 일일이 모아주어서 for loop 구문으로 일일이 작업을 수행해주었다. 그러면 이것도 한 줄의 코드로 간단하게 자동화시킬 순 없을까? 이를 구현하기 위해서는 위와 같은 Linear 클래스 계층들을 한 데 모아주는 큰 범위의 계층을 만들어줄 필요가 있다. 앞으로 구현하려는 '큰 범위의 계층'이라 함은 아래처럼 도식화해서 나타낼 수 있다.

 

계층을 담아두는 '큰 계층'을 구현해보자

 

우리는 위 그림에서 가장 바깥 쪽에 있는 모든 것을 포괄하는 계층 클래스를 구현해볼 것이다. 즉, 가장 바깥 쪽에 있는 계층을 호출하면 안에 종속되어 있는 모든 Layer들의 파라미터들을 모두 한 데 모아서 한번에 기울기를 초기화한다거나 파라미터를 갱신시킬 수 있도록 할 것이다. 일단은 위에서 정의한 Layer 라는 기반(base) 클래스에서 약간의 개선이 필요하다.

 

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

    def __setattr__(self, name, value):
        if isinstance(value, (Parameter, Layer)): # Layer클래스도 파라미터에 추가!
            self._params.add(name)
        super().__setattr__(name, value)

    def __call__(self, *inputs):
        outputs = self.forward(*inputs)
        if not isinstance(outputs, tuple):
            outputs = (outputs,)
        self.inputs = [weakref.ref(x) for x in inputs]
        self.outputs = [weakref.ref(y) for y in outputs]
        return outputs if len(outputs) > 1 else outputs[0]

    def forward(self, x):
        raise NotImplementedError("This method should be run outside of Layer class.")

    def params(self):
        for name in self._params:
            # 파라미터가 Layer/Parameter 클래스인지에 따라 분기
            obj = self.__dict__[name]
            if isinstance(obj, Layer):
                yield from obj.params()
            else:
                yield obj

    def clear_grads(self):
        for param in self.params():
            param.clear_grad()

 

변경된 부분은 주석처리된 부분이다. 우리가 달성하려는 목적은 Layer 와 Parameter 모두 담을 수 있는 큰 Layer 클래스를 만드는 것이었다. 따라서 파라미터 변수 self._params에 Parameter 클래스 뿐만 아니라 또 다른 Layer 클래스도 보관할 수 있도록 해준다. 그리고 파라미터를 제네레이터 함수로 꺼내주는 params() 메소드에서 self._params에서 꺼낸 것이 Layer 클래스라면 재귀적으로 또 params() 메소드를 호출(이 때는 yield from 구문을 사용한다는 점에 주의)하고 Parameter 클래스면 기존처럼 변수값을 yield 하도록 해준다.

 

이렇게 개선한 Layer 클래스를 상속해서 2개의 계층을 갖는 신경망 모델을 갖는 클래스를 새롭게 정의해보도록 하자.

 

import numpy as np
import dezero.layers as L
from dezero.layers import Layer
from dezero import functions as F


# dataset
np.random.seed(42)
x = np.random.rand(100, 1)
y = np.sin(2 * np.pi * x) + np.random.rand(100, 1)   # label


class TwoLayerNet(Layer):
    def __init__(self, hidden_size, out_size):
        super().__init__()
        self.layer1 = L.Linear(hidden_size)
        self.layer2 = L.Linear(out_size)

    def forward(self, x):
        y = self.layer1(x)
        y = F.sigmoid(y)
        y = self.layer2(y)
        return y


# build model
model = TwoLayerNet(25, 1)
lr = 0.2
iters = 10000

for i in range(iters):
    # predict and get loss
    y_pred = model(x)
    loss = F.mean_squared_error(y, y_pred)

    # clear gradients
    model.clear_grads()

    # backpropagation
    loss.backward()

    # update parameters
    for param in model.params():
        param.data -= lr * param.grad.data

    if (0 <= i <= 30) or (i % 1000) == 0:
        print(f"Epoch:{i+1} -> Loss:{loss.data}")

 

위와 같이 TwoLayerNet 클래스를 Layer라는 클래스를 상속받으면서 새롭게 정의하게 되면 학습할 때 작성되는 코드가 더 줄었음을 알 수 있다. 이렇게 클래스를 상속받아 새로운 클래스를 정의하는 구조는 아래처럼 Pytorch에서 매우 자주 사용되는 API 형태이다.

 

빨간색 네모칸을 잘보자!

 

다음은 간단한 추가 개선인데, 우리가 예전에 만들었던, 신경망 모델이 순전파를 수행했을때 만들어지는 계산 그래프를 시각화할 수 있는 유틸리티 함수를 추가로 넣어보도록 하자. 개선한 Layer 클래스를 상속하면서 계산 그래프 시각화를 해주는 기능을 추가한 것을 Model 이라는 새로운 클래스로 아래처럼 만들어보자.

 

from dezero import Layer
from dezero import utils


class Model(Layer):
    def plot(self, *inputs, to_file='model.png'):
        outputs = self.forward(*inputs)
        return utils.plot_dot_graph(outputs, verbose=True, to_file=to_file)

 

다음은 위에 개선한 작업들을 기반으로 사용자가 출력층 노드 개수만 촤르륵 나열해줘서 집어넣어주기만 하면 MLP(Multi-Layer Perceptrn) 모델 즉, 다층 신경망 모델을 만들 수 있도록 MLP 라는 새로운 클래스를 만들어보자.

 

class MLP(Model):
    def __init__(self, fc_output_sizes, activation=F.sigmoid):
        super().__init__()
        self.activation = activation
        self.layers = []

        for i, output_size in enumerate(fc_output_sizes):
            layer = L.Linear(output_size)
            setattr(self, 'l' + str(i), layer)
            self.layers.append(layer)

    def forward(self, x):
        for l in self.layers[:-1]:
            x = self.activation(l(x))
        return self.layers[-1](x)
import numpy as np
from dezero.models import MLP
from dezero import functions as F

# dataset
np.random.seed(42)
x = np.random.rand(100, 1)
y = np.sin(2 * np.pi * x) + np.random.rand(100, 1)   # label

# build model
model = MLP((25, 40, 3, 1))
lr = 0.01
iters = 10000

for i in range(iters):
    # predict and get loss
    y_pred = model(x)
    loss = F.mean_squared_error(y, y_pred)

    # clear gradients and backpropagation
    model.clear_grads()
    loss.backward()

    # update parameters
    for param in model.params():
        param.data -= lr * param.grad.data

    if (0 <= i <= 30) or (i % 1000) == 0:
        print(f"Epoch:{i+1} -> Loss:{loss.data}")

 

위처럼 이제 만들려고 하는 층들의 각 출력 노드 개수들만 입력시켜주어도 알아서 Dezero가 MLP 모델을 만들어주고 손쉽게 학습까지 구현할 수 있도록 확장시켰다. 

3. 다양한 최적화기법이 담긴 Optimizer 계층

지금까지는 우리가 Dezero로 만들어낸 신경망 모델을 학습시킬 때, 단순히 SGD 기법을 활용해서만 구현했다. 하지만 SGD 기법은 손실함수 생김새에 따라 매우 비효율적으로 학습시키는 경우가 존재한다. 그러면 다른 최적화 기법을 사용할 수 있도록 할 수 있지 않을까? 그리고 또 한가지 개선할 점이 있다. 바로 위쪽 코드의 파라미터 업데이트하는 부분을 보면 여전이 for loop 구문을 사용해서 파라미터를 갱신해주고 있다. 그렇다면 이것도 내부적인 구조로 숨겨놓고 단 한줄 만으로 모델의 모든 파라미터를 갱신시키도록 API 형태를 개선시킬 순 없을까? 이 두가지를 충족시켜주는 Optimizer 계층을 정의해보도록 하자.

 

이것도 Layer 때와 마찬가지로 Optimizer라는 기반(base) 클래스를 먼저 정의해야 한다.

 

import numpy as np

class Optimizer:
    def __init__(self):
        self.target = None
        self.hooks = []
        
    def setup(self, target):
        self.target = target
        return self
    
    def update(self):
        params = [p for p in self.target.params() if p.grad is not None]
        # preprocess parametrs
        for f in self.hooks:
            f(p)
        
        # update parametrs
        for param in params:
            self.update_one(param)
        
    def update_one(self):
        raise NotImplementedError("This method should be run outside of Optimizer class.")
    
    def add_hook(self, f):
        self.hooks.append(f)

 

setup() 이라는 메소드는 일종의 타겟(target)을 '등록'하는 셈인데, 이 때 타겟은 우리가 위에서 정의한 신경망 모델 클래스를 의미한다. 그래서 update() 메소드를 보면 self.target 인스턴스 변수에서 params() 메소드를 호출하는 것을 알 수 있다. 단, 이 때 update_one() 이라는 메소드는 여기 클래스에서 정의하지 않는데, 바로 이것이 실질적으로 어떤 최적화 기법(SGD, Momentum, Adagrad, Adam 등등..)을 선택해서 최적화시킬지 정의해주는 부분이다. 따라서 Optimizer 클래스를 상속하면서 update_one() 메소드를 오버로딩시켜주도록 하자. 참고로 self.hooks 인스턴스 변수에는 기울기 값을 후처리하는 예를 들어, Weight Decay(가중치 감소) 기법 또는 Gradient Clipping(기울기 클리핑) 등의 후처리 함수를 집어넣어 갱신하기 전 수행해주도록 한다.

 

그러면 이제 위 Optimizer 클래스를 상속받는 SGD 클래스를 정의해보도록 하자.

 

class SGD(Optimizer):
    def __init__(self, learning_rate=0.01):
        super().__init__()
        self.lr = learning_rate
        
    def update_one(self, param):
        param.data -= self.lr * param.grad.data

 

매우 간단하다. 추가적으로 해당 포스팅에서는 Momentum 클래스를 만들어보겠다. 참고로 Momentum에 대한 이론 설명은 예전 포스팅을 참조하자. 뿐만 아니라 그 외 기법인 AdaGrad, Adam과 같은 클래스는 여기를 참고하도록 하자.

 

class MomentumSGD(Optimizer):
    def __init__(self, learning_rate=0.01, momentum=0.9):
        super().__init__()
        self.lr = learning_rate
        self.momentum = momentum
        self.vs = {}

    def update_one(self, param):
        v_key = id(param)
        if v_key not in self.vs:
            self.vs[v_key] = np.zeros_like(param)

        v = self.momentum * self.vs[v_key]
        v -= self.lr * param.grad.data
        param.data += v

 

그러면 이제 위 Optimizer 클래스와 SGD 클래스를 활용해서 모델을 학습시키는 코드를 살펴보자. 기존 학습 코드에 비해 다른 차이점은 주석으로 처리했다.

 

import numpy as np
from dezero.models import MLP
from dezero import functions as F
from dezero.optimizers import SGD  # SGD를 import


# dataset
np.random.seed(42)
x = np.random.rand(100, 1)
y = np.sin(2 * np.pi * x) + np.random.rand(100, 1) 

# build and compile model
model = MLP((25, 40, 3, 1))
lr = 0.01
iters = 10000
optimizer = SGD(lr).setup(model)  # 달라진 부분!

for i in range(iters):
    y_pred = model(x)
    loss = F.mean_squared_error(y, y_pred)

    model.clear_grads()
    loss.backward()

    # 달라진 부분!
    optimizer.update()

    if (0 <= i <= 30) or (i % 1000) == 0:
        print(f"Epoch:{i+1} -> Loss:{loss.data}")

 

학습 시 모델의 파라미터를 업데이트하는 과정이 매우 단순화 되고 편리해졌음을 알 수 있다.

4. Softmax 함수와 Cross-Entropy 오차

지금까지의 신경망은 실수(Real-value)를 예측하는 회귀 문제를 다루었다. 이제는 다중분류(Multi-class classification)도 해결할 수 있도록 하기 위해 다중 분류 문제에 가장 많이 사용되는 활성함수인 소프트맥스 함수와 손실함수인 크로스-엔트로피 오차를 구현해보도록 하겠다.

 

다만, 들어가기에 앞서 우리는 Dezero 만의 슬라이싱 함수를 만들어보려고 한다. 즉, 파이썬 리스트나 튜플 자료구조 또는 넘파이의 nd-array 자료구조에 내장되어 있는 __getitem__ 이라는 매직 메소드를 오버로딩한다. 이것을 오버로딩 하는 이유는 추후에 Dezero에서 만든 자료구조 즉, Parameter 또는 Variable 클래스를 갖는 인스턴스 객체도 넘파이나 파이썬 자료구조처럼 슬라이싱 할 수 있도록 만들기 위함이다. 먼저 __getitem__ 이라는 메소드가 어떤 동작을 취할 것인지 도식화한 사진부터 살펴보자.

 

우리가 구현하려는 __get_item__ 함수의 역할

 

위는 순전파, 아래는 역전파를 의미한다. 순전파 과정을 살펴보면 get_item 함수를 호출했을 때, 사용자가 원하는 배열을 슬라이싱할 수 있도록 할 것이다. 반대로 역전파 시에는 순전파 시에 우리가 추출했던 배열의 기울기는 그대로 두되, 추출하지 않았던 배열의 칸들에는 모두 기울기 원소를 0으로 채워주도록 한다. 이를 구현하기 위해 먼저 GetItem, GetItemGrad 이라는 클래스를 아래처럼 구현해보자.

 

class GetItem(Function):
    def __init__(self, slices):
        self.slices = slices

    def forward(self, x):
        y = x[self.slices]
        return y

    def backward(self, gy):
        x, = self.inputs
        f = GetItemGrad(self.slices, x.shape)
        gx = f(gy)
        return gx

def get_item(x, slices):
    return GetItem(slices)(x)
    
class GetItemGrad(Function):
    def __init__(self, slices, x_shape):
        self.slices = slices
        self.x_shape = x_shape

    def forward(self, gy):
        gx = np.zeros_like(self.x_shape)
        np.add.at(gx, self.slices, gy)
        return gx

    def backward(self, ggx):
        return get_item(ggx, self.slices)

 

그리고 위의 get_item 메소드를 Variable 클래스의 __getitem__ 매직 메서드로 아래처럼 오버로딩 시켜보자. 

 

Variable.__getitem__ = dezero.functions.get_item

 

그러면 아래처럼 Variable 인스턴스 객체에서도 일반적인 파이썬 자료구조나 넘파이 다차원 배열처럼 슬라이싱이 가능하다.

 

import numpy as np
from dezero import Variable

dezero_arr = Variable(np.array([[1,2,3],
                                [4,5,6]]))
numpy_arr = np.array([[1,2,3],
                      [4,5,6]])

print('Dezero slicing:\n', dezero_arr[1])
print('Numpy slicing:\n', numpy_arr[1])

 

이제 다중분류를 위한 활성함수로 사용되는 소프트맥스 함수를 구현해보자. 책에서는 3가지 정도의 구현방법을 제안하는데, 여기서는 메모리를 가장 효율적으로 처리하는 방법의 코드만 게시하도록 하겠다. 여기서 메모리를 가장 효율적으로 처리한다함은 소프트맥스 계산을 처리(순전파)를 수행할 때, 역전파 시에 사용되지 않는 불필요한 중간 변수들을 메모리에 저장해두지 않도록 한다는 점이다. 참고로 소프트맥스를 구현하는 방식은 예전 포스팅에서 배웠던 것과 크게 다르지 않다는 점이다. 오버플로를 예방하는 점도 동일하다. 소프트맥스의 개념과 오버플로간의 관계는 예전 포스팅을 참조하자.

 

class Softmax(Function):
    def __init__(self, axis=1):
        self.axis = axis

    def forward(self, x):
        y = x - x.max(axis=self.axis, keepdims=True)
        y = np.exp(y)
        y /= y.sum(axis=self.axis, keepdims=True)
        return y

    def backward(self, gy):
        # 수식으로 아직 이해 X
        y = self.outputs[0]()
        gx = y * gy
        sumdx = gx.sum(axis=self.axis, keepdims=True)
        gx -= y * sumdx
        return gx
        
def softmax(x, axis=1):
    return Softmax(axis)(x)

 

아쉽게도 소프트맥스 함수의 역전파 과정을 아직 수식적으로는 이해하지 못한 상태다.. 일단은 넘어간 뒤 나중에 다시 생각해볼 필요가 있는 듯 하다. 이런 부분에서 놓치는 게 나중에 고도화된 모델을 구현할 때 벽에 부딪칠 수도 있지 않을까 하는 불안함이다.

 

다음은 크로스-엔트로피 교차 손실함수이다. 이에 대해서도 이론적인 내용은 예전 포스팅을 참조하자. 참고로 여기서는 소프트맥스는 거의 항상 손실함수 이전에 사용되는 최종 활성함수이고 그 이후에 바로 크로스-엔트로피 같은 손실함수가 등장하기 때문에 이 두개를 연이어 붙인 하나의 계층으로 구현하려고 한다. 참고로 아래 코드에서 정답에 해당하는 t 값은 원-핫 인코딩 형태가 아닌 레이블 인코딩 형태임을 인지하도록 하자.

 

class SoftmaxCrossEntropy(Function):
    def forward(self, x, t):
        N = x.shape[0]
        log_z = utils.logsumexp(x, axis=1)
        log_p = x - log_z
        log_p = log_p[np.arange(N), t.ravel()]
        y = -log_p.sum() / np.float32(N)   # average CEE
        return y

    def backward(self, gy):
        x, t = self.inputs
        N, CLASS_N = x.shape

        gy *= 1 / N
        y = softmax(x)
        t_one_hot = np.eye(CLASS_N, dtype=t.dtype)[t.data]  # 레이블 값을 인덱스로하는 원소값이 1인 행 벡터들만 슬라이싱
        y = (y - t_one_hot) * gy
        return y
        
 def softmax_cross_entropy(x, t):
    return SoftmaxCrossEntropy()(x, t)

5. 데이터셋을 준비시키자, Dataset 클래스

데이터셋 클래스가 존재하는 이유는 뭘까? 데이터가 엄청나게 많아짐에 따라 갖고 있는 하드웨어에 따라 그 하드웨어의 전체 메모리에 올릴 수 있는 데이터의 양이 한계가 있을 것이다. 일반적으로 우리가 사용하는 노트북에 100억개의 데이터를 메모리에 한번에 올릴 수 있을까? 불가능할 것이다. 따라서 이러한 '큰 데이터섯'에 대응하고 일부씩만 메모리에 올리도록 하는 기능을 하는 Dataset 클래스를 정의해보자. 이 기능을 구현할 때 비로소 우리가 4번 목차에서 구현해 오버로딩시킨 __getitem__ 매직 메소드가 효과를 발휘한다. 

 

class Dataset:
    def __init__(self, train=True, transform=None, target_transform=None):
        self.train = train
        self.transform = transform
        self.target_transform = target_transform
        if self.transform is None:
            self.transform = lambda x: x  # identity function
        if self.target_transform is None:
            self.target_transform = lambda x: x
        self.data = None
        self.label = None
        self.prepare()  # 자식 클래스에서 구현

    def __getitem__(self, index):
        # case when index is both integer and slicing
        if self.label is None:
            return self.transform(self.data[index]), None
        else:
            return self.transform(self.data[index]), \
                   self.target_transform(self.label[index])

    def __len__(self):
        return len(self.data)

    def prepare(self):
        raise NotImplementedError(f"This method should be run outside of Dataset class.")
 
 # 100만개 데이터를 처리할 경우 예시 -> 추후 53단계에서 설명
 class BigData(Dataset):
    def __getitem__(self, index):
        x = np.load('data/{}.npy'.format(index))
        t = np.load('label/{}.npy'.format(index))
    
    def __len__(self):
        return 1000000

위 코드의 Dataset 클래스에서 transform 인자는 피쳐 데이터셋을 전처리, target_transform 인자는 레이블 데이터셋을 전처리하도록 한다. 그런데 이 때, 이 두개를 None으로 줘버리면 람다함수로 적용되는데 인자를 받은 상태로 그대로 return 하게 된다. 즉, 여기에서의 람다함수는 항등함수를 의미하고 이는 결국 전처리하지 않겠다는 것을 의미한다.

6. 로드한 데이터셋에서 Mini-batch를 뽑아주는 DataLoader

방금 위에서 만들어낸 Dataset 클래스는 데이터셋을 로드하는 기능을 갖추었다. 하지만 우리는 모델을 학습시킬 때, 로드한 데이터 전체를 한번에 때려붓지(?) 않는다. 전체 중 일부만 학습시키는 미니 배치 학습을 수행한다. 이번에 구현할 DataLoader는 이 미니 배치 학습을 가능토록 해주는 클래스이다.

 

이 클래스를 구현하기 전에 파이썬의 반복자(iterator)에 대해 알아야 한다. 이에 대해서는 예전에 다룬 적이 있으므로 해당 포스팅을 꼭 읽고 오도록 하자. 그리고 파이썬에서 반복자를 직접 만들어보는 실습도 간단히 수행해보자. 크게 2가지만 오버로딩 시켜주면 우리의 입맛에 맞는 반복자를 만들 수 있다.

 

class MyIterator:
    def __init__(self, max_cnt):
        self.max_cnt = max_cnt
        self.cnt = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.cnt == self.max_cnt:
            raise StopIteration()

        self.cnt += 1
        return self.cnt


obj = MyIterator(10)
for x in obj:
    print(x)

 

첫 번째는 __iter__ 이라는 매직 메소드에 자기 자신(self)을 반환하도록 해준다. 두번째로는 __next__ 매직 메서드에 우리가 구현하고 싶은 반복행동을 정의해주면 된다. 위 코드는 인자로 설정한 max_cnt 값까지 +1씩 해주면서 값을 반환하도록 했다. 위와 같은 방법으로 미니-배치 데이터를 반환하는 우리만의 반복자인 DataLoader를 만들어보자.

 

class DataLoader:
    def __init__(self, dataset, batch_size, shuffle=True):
        self.dataset = dataset
        self.batch_size = batch_size
        self.shuffle = shuffle
        self.data_size = len(dataset)
        self.max_iter = math.ceil(self.data_size / self.batch_size)

        self.reset()  # init iteration count

    def reset(self):
        self.iteration = 0
        if self.shuffle:
            self.index = np.random.permutation(self.data_size)
        else:
            self.index = np.arange(self.data_size)

    def __iter__(self):
        return self

    def __next__(self):
        if self.iteration >= self.max_iter:
            self.reset()
            raise StopIteration("One epoch is finished. Iteration count is initialized.")

        i, batch_size = self.iteration, self.batch_size
        batch_index = self.index[i * batch_size: (i+1) * batch_size]
        batch_x, batch_t = self.dataset[batch_index]

        self.iteration += 1
        return batch_x, batch_t

    # checking for example batch data
    def next(self):
        return self.__next__()

 

참고로 코드 마지막 부분에 있는 next() 라는 일반 메소드는 1개의 미니-배치 데이터가 어떤 형태, 형상으로 되어있는지 체크하기 위함이다. 이것도 오픈소스 딥러닝 프레임워크에도 동일한 방식으로 구현되어 있음을 알 수 있다.

 

다음은 유틸리티성 함수로 분류 결과의 정확도를 측정해주는 함수다. 이부분은 간단하므로 별다른 설명을 생략하겠다. 단순히 불리언 인덱스 형태를 활용해 정확도 측정을 구현했다.

 

def accuracy(y, t):
    y, t = as_variable(y), as_variable(t)

    pred = y.data.argmax(axis=1).reshape(t.shape)
    result = (pred == t.data)
    acc = result.mean()
    return as_variable(as_array(acc))

 

이제 지금까지 배운 것들을 총망라해서 스파이럴 데이터셋을 학습하는 코드를 살펴보자. 그동안 배우고 쌓아왔던 것들을 발휘할 차례다!

 

import sys
import logging
import dezero
import dezero.functions as F
from dezero import DataLoader
from dezero.models import MLP
from dezero.optimizers import AdaGrad
from dezero.datasets import Spiral

logger = logging.getLogger()
logger.setLevel(logging.INFO)
logging.basicConfig(stream=sys.stdout,
                    format='[%(asctime)s] %(levelname)s : %(message)s')

# hyper-parameter
max_epoch = 300
batch_size = 30
hidden_size = (10, 5)
lr = 1.0

# dataset
train_set = Spiral(train=True)
test_set = Spiral(train=False)

# dataloader
train_loader = DataLoader(train_set, batch_size, shuffle=True)
test_loader = DataLoader(test_set, batch_size, shuffle=False)

# build and compile model
model = MLP((*hidden_size, 3))
optimizer = AdaGrad(lr=lr).setup(model)

# train and test(validation) in each Epoch
for epoch in range(max_epoch):
    sum_loss, sum_acc = 0, 0

    # mini-batch for train
    for x, t in train_loader:
        # predict and get loss, accuracy
        y_pred = model(x)
        loss = F.softmax_cross_entropy(y_pred, t)
        acc = F.accuracy(y_pred, t)

        # clear gradients
        model.clear_grads()
        # backward
        loss.backward(use_heap=True)
        # update params
        optimizer.update()

        # verbose metric
        sum_loss += float(loss.data) * len(t)
        sum_acc += float(acc.data) * len(t)
    if (epoch+1) % 20 == 0:
        logging.info(f"(Epoch{epoch+1})Train Loss: {sum_loss / len(train_set)}, Accuracy: {sum_acc / len(train_set)}")

    # mini-batch for test
    sum_loss, sum_acc = 0, 0
    with dezero.no_grad():   # deactivate backward
        for x, t in test_loader:
            y_pred = model(x)
            loss = F.softmax_cross_entropy(y_pred, t)
            acc = F.accuracy(y_pred, t)
            sum_loss += float(loss.data) * len(t)
            sum_acc += float(acc.data) * len(t)
    if (epoch+1) % 20 == 0:
        logging.info(f"(Epoch{epoch+1})Test Loss: {sum_loss / len(test_set)}, Accuracy: {sum_acc / len(test_set)}")
        print()

지금까지의 다양한 구현을 통해 Dezero를 오픈 소스 딥러닝 프레임워크의 API 형태로 만들어올 수 있었다. 딥러닝 프레임워크 생성 초기에는 종류마다 API에서 큰 차이를 보였다고 한다. 하지만 점점 성숙기에 접어들면서 파이토치, 체이너, 텐서플로 같은 인기 프레임워크들이 같은 방향으로 나아가고 있고, 이러한 방향성은 우리가 지금까지 Dezero를 구현해오면서 배운 철학들이 고스란히 담겨 있다. 따라서 우리가 여기서 배운 철학들을 숙지한다면 어떤 인기있는 딥러닝 프레임워크를 다루더라도 거부감 없이 쉽게 익숙해질 수 있을 것이다. 앞서 Dezero에서 배운 '철학들'을 나열하면 다음과 같다.

 

  • 데이터를 흘려보내면서 계산 그래프가 정의되는 동적 계산 그래프 즉, Define-by-Run 방식을 주로 취한다.(단, 성능 향상과 실제 서비스 IoT와 같은 엣지 디바이스에 적용할 것을 대비해 Define-and-Run 방식도 인기 프레임워크들은 지원)
  • 사전 정의된 다양한 함수와 계층, 다양한 최적화 기법 클래스를 제공
  • 모델 클래스 상속을 통해 '나만의 모델 클래스'를 정의하는 객체 지향 프로그래밍 방식
  • 데이터셋을 관리하는 Dataset, DataLoader 같은 클래스를 제공
  • CPU 외, GPU도 활용 가능

이제 우리는 Dezero의 기본적인 구현 능력과 오픈소스 딥러닝 프레임워크의 API 형태로 만드는 것을 드디어 마쳤다. 마지막 고지만을 남겨두었는데, GPU를 활용하거나 모델 파일을 외부로 저장하는 기능, 또 학습 및 테스트 시 성능 향상을 위한 드롭아웃 같은 기능을 추가하며 Dezero의 기능을 한층더 강화할 것이다. 그 후에는 더 고도화된 신경망인 CNN, RNN 모델을 직접 구현하고 이미 공개되어 있으며 역사적인(?) 모델들인 VGG16, LSTM 등과 같은 복잡한 딥러닝 모델도 Dezero를 통해 구현해보도록 하자!

반응형