MNIST 딥러닝 깡구현
궁시렁
아…. 찾아보니까 다들 데이터를 행벡터로 취급하네. 그런데 나는 렬벡터로 취급했다. 그야; 내가 선형대수 공부할 땐 벡터가 다들 렬벡터였단 말이다. X@W가 아니라 W@X라고. 근데 프로그래밍에서 2차원 배열을 취급할 때 m[행][렬]이라고 취급하니까 저쪽이 자연스럽긴 하군…. 근데 나는 처음에 렬벡터로 수식을 써서 코드를 그에 맞춤. 내 박대가리가 헷갈려하니까 1차원 ndarray 없고 벡터도 다 2차원임.(컬럼이 한 개인)
이거뭐냐
What I cannot create, I do not understand.
- Richard Feynman
딥러닝 라이브러리를 쓰지 않고 Multi-Layer Perceptron를 구현하여 MNIST 문제를 푼다.
물론; 크게 아래 네 가지를 한다.
- 데이터 가져오기
- 모델 구성
- 학습
- 예측 및 평가
데이터 가져오기와 예측 및 평가는 특별할 게 없고. 사실 예측 기능도 간단한 행렬 계산이면 뚝딱인데. 그넘의 오차역전파 미분 어쩌구.
데이터
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
궁시렁
아 진짜 수학 잘하고 싶다. 나는 왜 수학박대가리인가. 고작 이딴 걸 얼마나 느리게 하는 거냐 아 진짜. 고작해야 왕초보 선형대수 아니냐고. 수학천재되는법: ♪다시태어나♪
그래서 딥러닝을 더 잘 이해하게 된? 거? 같은 기분이? 드는 거 같냐????? 몰루