컴퓨터와 책과 연필
블로그 프사

컴퓨터와 책과 연필

Kim Evergood

허접 개발자의 개인 블로그😘 문서화된 작업은 나의 얼굴. 문서화된 공부는 맞춤 교재.

MNIST 딥러닝 깡구현

2025. 11. 29. Kim Evergood이가 씀.

google drive ipynb

궁시렁

아…. 찾아보니까 다들 데이터를 행벡터로 취급하네. 그런데 나는 렬벡터로 취급했다. 그야; 내가 선형대수 공부할 땐 벡터가 다들 렬벡터였단 말이다. X@W가 아니라 W@X라고. 근데 프로그래밍에서 2차원 배열을 취급할 때 m[행][렬]이라고 취급하니까 저쪽이 자연스럽긴 하군…. 근데 나는 처음에 렬벡터로 수식을 써서 코드를 그에 맞춤. 내 박대가리가 헷갈려하니까 1차원 ndarray 없고 벡터도 다 2차원임.(컬럼이 한 개인)

이거뭐냐

What I cannot create, I do not understand.

- Richard Feynman

딥러닝 라이브러리를 쓰지 않고 Multi-Layer Perceptron를 구현하여 MNIST 문제를 푼다.

물론; 크게 아래 네 가지를 한다.

  1. 데이터 가져오기
  2. 모델 구성
  3. 학습
  4. 예측 및 평가

데이터 가져오기와 예측 및 평가는 특별할 게 없고. 사실 예측 기능도 간단한 행렬 계산이면 뚝딱인데. 그넘의 오차역전파 미분 어쩌구.

데이터

MNIST 데이터세트를 가져온다.

from tensorflow.keras.datasets import mnist

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

print(train_images.shape) # (60000, 28, 28)
print(train_labels.shape) # (60000,)
print(train_labels[:10])  # [5 0 4 1 9 2 1 3 1 4]
print(test_images.shape)  # (10000, 28, 28)
print(test_labels.shape)  # (10000,)
print(test_labels[:10])   # [7 2 1 0 4 1 4 9 5 9]

완전연결층에 맞게 데이터를 1차원 벡터로 변형하고; 값을 0-255 정수에서 0-1 실수로 바꾼다.

train_images = train_images.reshape((len(train_images), -1)).astype('float32') / 255
test_images  = test_images .reshape((len(test_images),  -1)).astype('float32') / 255

라벨을 원핫인코딩

def to_one_hot(labels, dimension=10):
    results = np.zeros((len(labels), dimension))
    for i, label in enumerate(labels):
        results[i, label] = 1.
    return results

train_labels_oh = one_hot(train_labels)

옴메나 세상에 근데 내 코드에서는 행렬이 거꾸로 되어있음. 전치.

train_images    = train_images.T
test_images     = test_images.T
train_labels_oh = train_labels_oh.T
test_labels_oh  = test_labels_oh.T

모델 구성 (의사코드)

※ 코드에서 줄임말

  • [i]: 입력 크기
  • [o]: 출력 크기
  • [bat]: 배치 크기

전체 모델은 층 몇 개로 구성된다.

# pseudo-code

layer1 = new MyLayer( ... )
layer2 = new MyLayer( ... )

model = new MyModel(
    [layer1, layer2],
    ...
)
# pseudo-code

class MyModel
{
    layers: MyLayer[]
    ...
}

모델에는 예측 기능과 학습 기능이 있다.

# pseudo-code

class MyModel
{
    ...

    # 예측
    function predict(
            x: Num[i][total]
    ) ->       Num[o][total]
    {
        ...
    }

    # 학습
    function fit(
            x: Num[i][total] ,
            y: Num[o][total]
    ) ->       Void
    {
        ...
    }
}

딥러닝에서 쓰는 층에는 여러 가지가 있으나 여기서는 가장 기본적인 완전연결층을 구현한다.

각 층은 입출력 크기, 가중치, 활성화함수를 갖고 있다. 순전파와 역전파 기능이 있다.

# pseudo-code

class MyLayer
{
    input_size:  int                 = 입력받기
    output_size: int                 = 입력받기
    weights: Num[o][i]               = 입력받기
    bias:    Num[o]                  = 랜덤초기화
    activate: Function( Num -> Num ) = 랜덤초기화

    # 순전파
    function forward(
            x: Num[i]
    ) ->       Num[o]
    {
        ...
        y = f(x)
        return y
    }

    # 역전파
    function backpropagate(
            x: Num[i][bat]
            l: Num[o][bat]
    ) ->       Num[i]
    {
        ...
        l = f(x,l)
        return l
    }
}

예측 기능 (의사코드)

데이터가 모델의 각 층을 통과하며 변형된다. 마지막 층의 출력이 모델의 출력이다.

# pseudo-code

class MyModel
{
    ...

    # 예측
    function predict(
            x: Num[i][bat]
    ) ->       Num[o][bat]
    {
        for( layer in layers ){
            x = layer.forward( x )
        }
        return x
    }

    ...
}

각 층에서는 입력값에 가중치를 곱하고, 편향을 더하고, 활성화함수를 적용한다.

$$ A = f( W \cdot X + b ) $$

그런데 소프트맥스 등의 함수는 원소별 따로 계산하지 않으므로 $ a_i = f( \sum_{j} w_{ij} x_j + b_i) $ 식으로 나타내지 못한다.

# pseudo-code

class MyLayer
{
    ...
    w: Num[o][i]
    b: Num[o]
    activate: Function( Num -> Num )

    # 순전파
    function forward(
            x: Num[i][bat]
    ) ->       Num[o][bat]
    {
        z = w @ x + b
        a = activate( z )
        return a
    }

    ...
}

학습 기능 (의사코드)

에포크와 배치 돌기.

# pseudo-code

class MyModel
{
    ...
    lossFunction: Function( Num[o], Num[o] -> Num )

    ...

    # 학습 - 에포크 수만큼 반복
    function fit(
            x:      Num[i][total] ,
            y_true: Num[o][total] ,
            epochs=10 
    ) -> Void
    {
        for( i in range(epochs) ){
            loss = fit_oneEpoch( x, y )
            print( f'에포크 {i}, loss={loss}' )
        }
    }

    # 학습 - 한 에포크 안에서 배치 반복
    function fit_oneEpoch(
            x:      Num[i][total] ,
            y_true: Num[o][total]
    ) -> Num
    {
        x_shuffled, y_shuffled = 섞기( x, y )

        for( 한 번에 batch_size씩 처리 ){

            x_batch = x_shuffled[이번배치]
            y_batch = y_shuffled[이번배치]

            batch_loss = fit_oneBatch( x_batch, y_batch )
        }

        return batch_loss들의 평균
    }

    # 학습 - 한 배치에서 학습
    function fit_oneBatch(
            x:      Num[i][bat] ,
            y_true: Num[o][bat]
    ) -> Num
    {
        for( layer in layers ){
            xs[i] = x의 배치평균
            x = layer.forward( x )
        }

        loss = lossFunction( y_pred, y_true )  # Num[bat]

        l = y_pred에 대한 loss의 미분

        for( layer in layers.거꾸로 ){
            l = layer.backpropagate( xs[-1-i], l )
        }

        return loss 평균
    }
}

각 층은 손실을 역전파하며 자신의 가중치를 업데이트한다. 그냥 경사하강법으로 업데이트.

여기서는 z에 대한 w와 b의 기울기를 구할 때는 수학 법칙에 따라 단순히 곱셈으로 구했으나 활성화함수와 손실함수의 미분은 모두 수치미분으로 구하였다. 소프트맥스와 교차엔트로피의 합성함수의 미분은 매우 간단하게도 y_pred - y_true라고 하나 여기서는 그것도 이용치 않았다. 암튼 그래서 대충은 됨.

d손실/dz = (d손실/da) * (da/dz) = l * 야코비행렬 (※ l: 이번 층에 전파된 오차)

d손실/dw = (d손실/dz)*(dz/dw) 그럼 dz/dw = ?

$$ Z = W \cdot X + B $$

$$ z_i = \sum\limits_{j} w_{ij} x_j + b_i $$

$$ z_i + \mathrm{d} z_i = \sum\limits_{j} (w_{ij} + \mathrm{d} w_{ij}) x_j + b_i $$

$$ \frac{\partial z_i}{\partial w_{ij}} = x_j $$

$$ \frac{\partial Z}{\partial W} = X $$

d손실/db = (d손실/dz)*(dz/db) 그럼 dz/db = ?

$$ z_i = \sum\limits_{j} w_{ij} x_j + b_i $$

$$ z_i + \mathrm{d} z_i = \sum\limits_{j} w_{ij} x_j + b_i + \mathrm{d} b_i $$

$$ \frac{\partial z_i}{\partial b_i} = 1 $$

다음 층에 넘길 오차 = d손실/dx = (d손실/dz)*(dz/dx) 그럼 dz/dx = ?

$$ \frac{\partial z_i}{\partial x_j} = w_{ij} $$

# pseudo-code

lr = 0.1  # learning_rate

class MyLayer
{
    ...
    activate: Function( Num -> Num )

    ...

    # 역전파
    function backpropagate(
        x: Num[i] ,  # 당시 입력값
        l: Num[o]    # 전파된 오차
    ) -> Num[i]
    {
        z = w @ x + b                # Num[o]
        g_l_z = z에 대한 l의 기울기  # Num[o]

        g_a_w = g_l_z @ x.T          # Num[o][i]  가중치 기울기
        g_a_b = g_l_z                # Num[o]     편향 기울기

        w -= lr * g_a_w              # Num[o][i]
        b -= lr * g_a_b              # Num[o]

        return  w.T @ ( l * g_a_z )  # Num[i]
    }
}

배치까지 고려하면

# pseudo-code

    function backpropagate(
        x: Num[i][bat] ,  # 당시 입력값
        l: Num[o][bat]    # 전파된 오차
    ) -> Num[i][bat]
    {
        z = w @ x + b                # Num[o][bat]
        g_l_z = z에 대한 l의 기울기  # Num[o][bat]

        g_a_w = (g_l_z @ x.T) / bat  # 행렬 곱을 통해 배치 전체의 기울기가 합산되므로 배치크기로 나눔.
        g_a_b = g_l_z의 평균 (배치들을 합침)

        w -= lr * g_a_w              # Num[o][i]
        b -= lr * g_a_b              # Num[o]

        return  w.T @ ( l * g_a_z )  # Num[i]
    }

기울기를 구하기 위한 수치미분 함수를 만들었다.

# pseudo-code

# 함수(벡터→스칼라) 수치미분
function grad(
        f: Function( Num[n] -> Num )
        x: Num[n]
) ->       Num[n]

    g = Num[n]

    for x:
        # 중앙차분법으로 미분
        f1 = f( x + dx )
        f2 = f( x - dx )
        g[xi] = (f1 - f2)/(2*d)

    return g
# pseudo-code

# 함수(벡터→벡터) 수치미분
function jacobian(
        f: Function( Num[n] -> Num[m] )
        x: Num[n]
) ->       Num[m][n]:

    J = Num[m][n]

    for x:
        # 중앙차분법으로 미분
        f1 = f(x + dx)  # Num[m]
        f2 = f(x - dx)  # Num[m]
        J[:, xi] = (f1 - f2) / (2 * d)

    return J

구현 코드

수치미분에서 값이 극한으로 갔을 때 처리가 곤란해서 걍 그대로 끊음. (야코비행렬=0일 때 커스텀예외 J0를 발생시켰다.)

from __future__ import annotations
from typing import Callable

import numpy as np
from numpy import ndarray as arr
d = 1e-7

필요한 함수들

# 함수(벡터->벡터) 수치미분
def jacobian( # 렬벡터 취급
        f: Callable[[arr], arr] ,  # Num[n][1] -> Num[m][1]
        x: arr                     # Num[n][1]
) -> arr:                          # Num[m][n]
    x = np.asarray(x, dtype=float)

    n = x.shape[0]
    m = np.asarray(f(x)).shape[0]

    J = np.zeros((m, n), dtype=float)
    for j in range(n):
        dx = np.zeros_like(x, dtype=float)
        dx[j] = d
        f1 = np.asarray(f(x + dx))
        f2 = np.asarray(f(x - dx))
        J[:, j:j+1] = (f1 - f2) / 2 / d
    return J
def relu(x: arr) -> arr:
    y = np.maximum(0, x)
    return y


def softmax(
        x: arr  # Num[o][bat]
) -> arr:       # Num[o][bat]

    exp_x = np.exp(x - np.max(x, axis=0, keepdims=True))# 오버플로우 방지: 입력값 중 최댓값을 뺀다.

    return exp_x / np.sum(exp_x, axis=0, keepdims=True)


def cross_entropy(
        y_pred: arr ,  # Num[o][bat]
        y_true: arr    # Num[o][bat]
) -> arr:              # Num[1][bat]

    y_pred_safe = y_pred + 1e-6 # 수치 안정성: 아주 작은 값을 더한다. 그러나 이 함수를 중앙차분으로 수치미분할 때 y_pred 파라미터에 d가 빠진 값이 들어오므로; 이 값이 d보다는 커줘야 한다.
    loss = -np.sum(y_true * np.log(y_pred_safe), 
                   axis=0, keepdims=True)
    return loss

데이터 가져오기

from tensorflow.keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((len(train_images), -1)).astype('float32') / 255
test_images  = test_images .reshape((len(test_images),  -1)).astype('float32') / 255

def to_one_hot(labels, dimension=10):
    results = np.zeros((len(labels), dimension), dtype=float)
    for i, label in enumerate(labels):
        results[i, label] = 1.
    return results

train_labels_oh = to_one_hot(train_labels)
test_labels_oh  = to_one_hot(test_labels)

train_images    = train_images.T
test_images     = test_images.T
train_labels_oh = train_labels_oh.T
test_labels_oh  = test_labels_oh.T
print(train_images   .shape)
print(test_images    .shape)
print(train_labels_oh.shape)
print(test_labels_oh .shape)
(784, 60000)
(784, 10000)
(10, 60000)
(10, 10000)

층 클래스

class J0(Exception):
    pass

class MyLayer:
    name: str
    input_size:  int
    output_size: int
    weights: arr # Num[o][i]
    bias:    arr # Num[o]
    activate: Callable[[arr], arr]  # Num[o][bat] -> # Num[o][bat]


    def __init__(
            self,
            input_size,
            output_size,
            activate,
            name='layer0'
    ):
        self.input_size  = input_size
        self.output_size = output_size
        self.activate    = activate
        self.name = name

        self.weights     = np.random.randn(output_size, input_size) * np.sqrt(2. / input_size)
        self.bias        = np.zeros((output_size, 1), dtype=float)


    # 순전파
    def forward(
            self,
            x: arr  # Num[i][bat]
    ) -> arr:       # Num[o][bat]

        z = self.weights @ x + self.bias
        a = self.activate( z )
        return a


    # 역전파
    def backpropagate(
            self,
            x: arr , # Num[i][bat]
            l: arr , # Num[o][bat]
            lr: float  # learning-rate
    ) -> arr:        # Num[i][bat]
        bat = x.shape[1]  # 배치크기

        z = self.weights @ x + self.bias

        g_l_z = self.g_actv( z, l )  # Num[o][bat]

        g_a_w = (g_l_z @ x.T) / bat  # 가중치 기울기 계산
        g_a_b = np.mean(g_l_z, axis=1, keepdims=True)  # 편향 기울기 계산 (배치 축인 axis=1로 평균)

        self.weights -= lr * g_a_w
        self.bias    -= lr * g_a_b

        return  self.weights.T @ g_l_z


    # 활성화함수 미분 * 전파된 오차
    def g_actv(
            self,
            z_batch: arr ,  # Num[o][bat]
            l_batch: arr    # Num[o][bat]
    ) -> arr:               # Num[o][bat]

        bat = z_batch.shape[1]

        g_l_z_batch = np.zeros_like(z_batch, dtype=float)

        for b in range(bat):
            l = l_batch[:, b:b+1]           # Num[o][1]
            z = z_batch[:, b:b+1]           # Num[o][1]
            J = jacobian(self.activate, z)  # Num[o][o]
            if J.min() > -d and J.max() < +d:
                raise J0()
            g_l_z = J @ l                   # Num[o][1]
            g_l_z_batch[:, b:b+1] = g_l_z

        return g_l_z_batch

모델 클래스

class MyModel:
    layers: list[MyLayer]
    lossFunction: Callable[[arr, arr], arr]  # Num[o][bat], Num[o][bat] -> Num[1][bat]

    def __init__(
            self,
            layers: list[MyLayer],
            lossFunction: Callable[[arr, arr], arr],
    ):
        self.layers = layers
        self.lossFunction = lossFunction

        # 층 이름 중복 확인
        layer_names = set()
        for layer in layers:
            if layer.name in layer_names: 
                raise ValueError(f'이름 중복: {layer.name}')
            layer_names.add(layer.name)


    # 예측
    def predict(
        self,
        x: arr  # Num[i][bat]
    ) -> arr:   # Num[o][bat]

        for layer in self.layers:
            x = layer.forward( x )
        return x


    # 학습 - 에포크 수만큼 반복
    def fit(
        self,
        x: arr,
        y: arr,
        epochs=10,
        batch_size=128,
        lr: float=1e-1,  # learning-rate
        metrics: list[Callable[[MyModel, arr, arr], tuple[str, any]]] =[]
    ) -> None:

        try:
            for i in range(epochs):

                loss = self.fit_oneEpoch( x, y, batch_size, lr )
                loss = f'{loss:.4f}'

                metrics_results = [('loss', loss)]
                for metric in metrics:
                    mr = metric(self, x, y)
                    metrics_results.append(mr)

                print( f'에포크 {i+1}/{epochs}', end=': ')
                print( ', '.join([f'{name}={value}' for (name, value) in metrics_results]) )

        except J0:
            loss = self.lossFunction( self.predict( x ), y ).mean()
            loss = f'{loss:.4f}'
            metrics_results = [('loss', loss)]
            for metric in metrics:
                mr = metric(self, x, y)
                metrics_results.append(mr)

            print('J0이라 여기서 그만.')
            print( ', '.join([f'{name}={value}' for (name, value) in metrics_results]) )

            return


    # 학습 - 한 에포크 안에서 배치 반복
    def fit_oneEpoch(
            self,
            x: arr , # Num[i][total]
            y: arr , # Num[o][total]
            batch_size: int ,
            lr: float
    ) -> float:

        total = x.shape[1]

        indices = np.arange(total)
        np.random.shuffle(indices)
        x_shuffled = x[:, indices]
        y_shuffled = y[:, indices]

        batch_losses = []

        for i in range(0, total, batch_size):

            x_batch = x_shuffled[:, i:i+batch_size]
            y_batch = y_shuffled[:, i:i+batch_size]

            batch_loss = self.fit_oneBatch( x_batch, y_batch, lr )
            batch_losses.append(batch_loss)

        return sum(batch_losses) / len(batch_losses)


    # 학습 - 한 배치에서 학습
    def fit_oneBatch(
            self,
            x: arr , # Num[i][bat]
            y: arr , # Num[o][bat]  # y_true
            lr: float ,
    ) -> float:

        y_pred = x
        xs = []
        for layer in self.layers:
            xs.append(y_pred)  # 당시의 x 값 저장
            y_pred = layer.forward(y_pred)

        l = self.g_loss(y_pred, y)  # Num[o][bat]

        for i in range(len(self.layers)-1, -1, -1):# layers 거꾸로 순회
            layer = self.layers[i]
            l = layer.backpropagate( xs[i], l, lr )

        loss = self.lossFunction( y_pred, y )  # Num[1][bat]
        return loss.mean()


    # 손실함수 수치미분
    def g_loss(
            self,
            y_pred_batch: arr ,  # Num[o][bat]
            y_true_batch: arr    # Num[o][bat]
    ) -> arr:                    # Num[o][bat]
        bat = y_pred_batch.shape[1]
        o   = y_pred_batch.shape[0]

        g_loss_batch = np.zeros((o, bat), dtype=float)

        for b in range(bat):
            yp   = y_pred_batch[:, b:b+1]  # Num[o][1]
            yt   = y_true_batch[:, b:b+1]  # Num[o][1]

            for i in range(o):
                dy = np.zeros((o, 1), dtype=float)  # Num[o][1]
                dy[i, 0] = d
                l1 = self.lossFunction( yp + dy, yt )[0, 0]
                l2 = self.lossFunction( yp - dy, yt )[0, 0]
                g_loss_batch[i, b] = l1 - l2

        g_loss_batch /= (2 * d)

        return g_loss_batch

    # 가중치 저장
    def save_weights(self, file_path: str) -> None:
        param_dict = {}
        for layer in self.layers:
            param_dict[f'{layer.name}_weights'] = layer.weights
            param_dict[f'{layer.name}_bias'] = layer.bias

        np.savez_compressed(file_path, **param_dict)
        print(f"가중치가 '{file_path}'에 저장되었습니다.")

    # 가중치 불러오기
    def load_weights(self, file_path: str) -> None:
        try:
            loaded_params = np.load(file_path)
        except FileNotFoundError:
            print(f"오류: 파일 '{file_path}'이가 없습니다.")
            return

        for layer in self.layers:
            w_key = f'{layer.name}_weights'
            b_key = f'{layer.name}_bias'

            if w_key in loaded_params and b_key in loaded_params:
                layer.weights = loaded_params[w_key]
                layer.bias    = loaded_params[b_key].reshape(layer.bias.shape) # bias는 1열 형태로 맞춰줌
            else:
                print(f"오류: 레이어 {layer.name}에 대한 가중치/편향이 파일에 없습니다.")
                return

        print(f"가중치 불러옴.")

지표(정확도)

def metric_cat_acc(
        model: MyModel,
        x: arr,
        y: arr
) -> tuple[str, str]:

    prob = model.predict(x)
    pred = np.zeros_like(prob, dtype=int)
    for col, row in enumerate(np.argmax(prob, axis=0)):# 각 렬에서 최대값의 row index
        pred[row, col] = 1
    correct_count = np.sum(np.all(pred == y, axis=0))  # (pred == y)의 개수
    acc = correct_count / x.shape[1]
    return 'acc', f'{acc:.3f}'

학습 및 테스트 실행

np.random.seed(0)

layer1 = MyLayer( 784, 512, relu   , name='layer1' )
layer2 = MyLayer( 512,  10, softmax, name='layer2' )

model = MyModel(
    [layer1, layer2],
    lossFunction=cross_entropy
)

model.fit(
    train_images,
    train_labels_oh,
    metrics=[metric_cat_acc],
)
에포크 1/10: loss=0.4293, acc=0.924
에포크 2/10: loss=0.2475, acc=0.937
J0이라 여기서 그만.
loss=0.2016, acc=0.943
model.save_weights('please.npz')
model.load_weights('please.npz')
가중치가 'please.npz'에 저장되었습니다.
가중치 불러옴.
_, acc = metric_cat_acc(model, test_images, test_labels_oh)
print(f'test accracy: {acc}')
test accracy: 0.941

궁시렁

아 진짜 수학 잘하고 싶다. 나는 왜 수학박대가리인가. 고작 이딴 걸 얼마나 느리게 하는 거냐 아 진짜. 고작해야 왕초보 선형대수 아니냐고. 수학천재되는법: ♪다시태어나♪

그래서 딥러닝을 더 잘 이해하게 된? 거? 같은 기분이? 드는 거 같냐????? 몰루

728x90

카테고리 다른 글