컴퓨터공학/인공지능

개인 프로젝트 - 모델 도구를 만들어보자(2) : layer

dori3220 2024. 12. 23. 19:51

서론

 

 전 시간에 뉴런에 대해서 알아보고 직접 뉴런을 구현했습니다. 모델을 만들 수 있는 도구를 만드는 게 최종 목표인데요. 그러기 위해서 처음부터 하나씩 구현해 가며 기능을 확장시켜 보려고 했습니다. 그래서 맨 처음으로 뉴런 하나라도 만들어 보자는 심정으로 뉴런 클래스를 만들어 보았습니다. 그다음은 당연히 뉴런 객체들을 합치면 레이어가 쉽게 완성될 줄 알았습니다. 그러나 더 간단하고 확실한 방법이 있었습니다! 행렬 곱을 통해서 여러 개의 입력에 대해 여러 개의 뉴런을 가진 레이어의 개념을 만들 수 있었죠. 바로 시작해 보겠습니다!

 

이전글 : 개인 프로젝트 - 모델 도구를 만들어보자(1) : 뉴런

 

개인 프로젝트 - 모델 도구를 만들어보자(1) : 뉴런

계기  처음엔 '정말 정말 작고 단순한 프로젝트 하나를 진행해 볼까?' 하고 생각했습니다. 근래에 선형 회귀에 대해서 공부했기도 하고 관련된 무언가가 좋을 것 같아서 다층 퍼셉트론을 만들

dori3220.tistory.com

 


레이어를 행렬로 만들자!

 

 중간 목표가 XOR 게이트를 만드는 것이기 때문에 OR 게이트를 예시로 들어보겠습니다. OR 게이트는 입력이 2개입니다. 0 또는 1을 가지는 입력 2개를 넣으면 0 또는 1을 가지는 출력 1개를 내놓습니다. 이를 신경망으로 표현하면 다음과 같이 그릴 수 있겠죠?

OR 게이트의 진리표는 좌표평면에서 1개의 선형으로 분류 가능하기 때문에 1개의 Layer로 구현 가능합니다. 이 신경망을 학습시키기 위해서 우리는 [0,0], [0,1], [1,0], [1,1] 총 4개의 입력을 학습시켜야 합니다. 따라서 입력 횟수는 4회이고 입력 개수는 2개가 되겠죠. 입력의 개수가 2개 이므로 가중치는 2개이고, 출력은 1개만 필요합니다. 입력 횟수가 4회이므로 출력 횟수도 4회가 되겠군요. 이를 행렬의 곱으로 나타내면 다음과 같습니다.

만약 입력의 개수가 2에서 3으로 늘어난다면 X의 열, W의 행이 2에서 3으로 늘어나겠죠. 그리고 출력의 개수가 1에서 2로 늘어난다면 W의 열, b의 열, Y의 열의 1에서 2로 늘어나겠죠. 입력/출력의 횟수가 4에서 8로 늘어난다면 X의 행, b의 행, Y의 행이 4에서 8로 늘어날 겁니다. 이렇듯 입력(X), 가중치(W), 편향(b), 출력(Y) 행렬에서의 행과 열의 의미를 잘 파악해 놓아야 구현할 때 안 헷갈립니다.

 


구현

 

 뉴런과 거의 비슷하기 때문에 큰 차이점 위주로 짚고 넘어가겠습니다. 아 그리고 저번에 깜빡하고 버전을 명시하지 않았는데 이번엔 버전까지 써놓겠습니다.

Python 3.11.4
matplotlib==3.9.3
numpy==2.2.0
import numpy as np
import matplotlib.pyplot as plt

class Layer2D:
    
    def __init__(self,input_size,output_size,act='sigmoid', lr=0.1):
        self.input_size = input_size
        self.output_size = output_size
        self.act = act.lower()
        self.lr = lr
        self.w = np.random.uniform(size=(input_size[1],output_size))
        self.b = np.random.uniform(size=(input_size[0],output_size))
input_size : 입력의 크기를 저장하는 튜플(shape)형 변수
output_size : 출력의 개수를 저장하는 정수형 변수
act : 활성화 함수의 이름을 받는 문자열
lr : 학습률을 받는 실수형 변수

 

입력 크기와 출력 개수(=입력의 개수)를 생성자에서 입력받습니다. 그래야 가중치 행렬과 편향 행렬의 크기를 알 수 있기 때문입니다. 파라미터는 0~1사이의 실수로 초기화 시켜줍니다.

 

    def sigmoid(self,x):
        return 1/(1+np.exp(-x))
    
    def dsigmoid(self,x):
        return x*(1-x)
    
    def relu(self,x):
        return np.maximum(0, x)
    
    def drelu(self,x):
        return np.where(x > 0, 1, 0)
        
    def mse(self,y,y_pred):
        return np.sum((y-y_pred)**2)
    
    def dmse(self,y,y_pred):
        return -2 * (y - y_pred) / y.shape[0]

활성화 함수(sigmoid, ReLU)와 손실 함수(mse)를 정의하고 그들의 도함수도 정의합니다. 달라진 점은 입력을 한 번에 여려 번 하기 때문에 손실 함수의 n값이 입력 횟수(=y 행렬의 행의 크기)가 됐습니다.

 

    def forward(self,x):
        z = np.dot(x, self.w) + self.b
        if self.act == 'sigmoid':
            h = self.sigmoid(z)
            return h
        elif self.act == 'relu':
            h = self.relu(z)
            return h
        else:
            return z

순전파 함수도 행렬의 곱으로 바뀌었습니다.

 

    def backward(self, x, y, y_pred):
        dL_dh = self.dmse(y,y_pred)
        
        if self.act == 'sigmoid':
            dL_dz = dL_dh * self.dsigmoid(y_pred)
        elif self.act == 'relu':
            dL_dz = dL_dh * self.drelu(y_pred)
        else:
            dL_dz = dL_dh
        
        dL_dw = np.dot(x.T, dL_dz)
        dL_db = np.sum(dL_dz, axis=0, keepdims=True)
        
        self.w -= self.lr * dL_dw
        self.b -= self.lr * dL_db

역전파 함수가 가장 많이 달라졌습니다. 원리는 똑같은데 주목할 점은 입력 행렬을 전치한 다음 행렬 곱을 하여 가중치 행렬의 shape에 맞춰준 모습입니다. 그리고 편향은 dL_dh만 빼줬던 뉴런과는 달리 dL_dz를 np.sum()한 값을 빼줍니다. shape에 신경을 많이 써야 했네요 ㅠㅠ

 

파이썬(python) - 넘파이(numpy)(2) : shape 탐구하기

 

파이썬(python) - 넘파이(numpy)(2) : shape 탐구하기

서론  저번 시간에는 numpy를 소개해 드리고 강력한 도구인 ndarray를 생성하는 방법에 대해서 포스팅했습니다. 이어서 우리가 생성한 배열 ndarray가 가지는 매우 매우 중요한 속성 shape에 대해서

dori3220.tistory.com

 

    def train(self,X,Y,epochs=10000,target_loss=0.01,show=False):
        if show:
            print("**********train start**********")
        errs = []
        for epoch in range(epochs):
            Y_pred = self.forward(X)
            err = self.mse(Y, Y_pred)
            errs.append(err)
            self.backward(X, Y, Y_pred)
            if err <= target_loss:
                break
            if show and epoch%100==0:
                print(f"epoch: {epoch} >> err: {err:.4f}")
        if show:
            print(f"epoch: {epoch} >> err: {err}")
            print("**********train done**********")
            plt.plot(errs)
            plt.show()
    
    def eval(self,X,Y):
        Y_pred = self.forward(X)
        soY = np.sum(abs(Y))
        soY_p = np.sum(abs(Y_pred))
        acc = (soY_p/soY)*100 if soY>soY_p else (soY/soY_p)*100
        print(f"input:\n{X}\noutput:\n{Y_pred}")
        print(f"acc: {acc}%")
        return acc

train 메서드와 eval 메서드도 거의 달라진게 없습니다.

 

    def get_shape(self):
        return (self.input_size[0],self.output_size)

shape을 아는 것이 중요하기 때문에 최종 출력 shape을 반환하는 메서드도 만들어 줍니다.

 


 

최종 코드입니다.

import numpy as np
import matplotlib.pyplot as plt

class Layer2D:
    
    def __init__(self,input_size,output_size,act='sigmoid', lr=0.1):
        self.input_size = input_size
        self.output_size = output_size
        self.act = act.lower()
        self.lr = lr
        self.w = np.random.uniform(size=(input_size[1],output_size))
        self.b = np.random.uniform(size=(input_size[0],output_size))
    
    def sigmoid(self,x):
        return 1/(1+np.exp(-x))
    
    def dsigmoid(self,x):
        return x*(1-x)
    
    def relu(self,x):
        return np.maximum(0, x)
    
    def drelu(self,x):
        return np.where(x > 0, 1, 0)
    
    def mse(self,y,y_pred):
        return np.sum((y-y_pred)**2) / y.shape[0]
    
    def dmse(self,y,y_pred):
        return -2 * (y - y_pred) / y.shape[0]
    
    def forward(self,x):
        z = np.dot(x, self.w) + self.b
        if self.act == 'sigmoid':
            h = self.sigmoid(z)
            return h
        elif self.act == 'relu':
            h = self.relu(z)
            return h
        else:
            return z
    
    def backward(self, x, y, y_pred):
        dL_dh = self.dmse(y,y_pred)
        
        if self.act == 'sigmoid':
            dL_dz = dL_dh * self.dsigmoid(y_pred)
        elif self.act == 'relu':
            dL_dz = dL_dh * self.drelu(y_pred)
        else:
            dL_dz = dL_dh
        
        dL_dw = np.dot(x.T, dL_dz)
        dL_db = np.sum(dL_dz, axis=0, keepdims=True)
        
        self.w -= self.lr * dL_dw
        self.b -= self.lr * dL_db
        
    def train(self,X,Y,epochs=10000,target_loss=0.01,show=False):
        if show:
            print("**********train start**********")
        errs = []
        for epoch in range(epochs):
            Y_pred = self.forward(X)
            err = self.mse(Y, Y_pred)
            errs.append(err)
            self.backward(X, Y, Y_pred)
            if err <= target_loss:
                break
            if show and epoch%100==0:
                print(f"epoch: {epoch} >> err: {err:.4f}")
        if show:
            print(f"epoch: {epoch} >> err: {err}")
            print("**********train done**********")
            plt.plot(errs)
            plt.show()
    
    def eval(self,X,Y):
        Y_pred = self.forward(X)
        soY = np.sum(abs(Y))
        soY_p = np.sum(abs(Y_pred))
        acc = (soY_p/soY)*100 if soY>soY_p else (soY/soY_p)*100
        print(f"input:\n{X}\noutput:\n{Y_pred}")
        print(f"acc: {acc}%")
        return acc
        
    def get_shape(self):
        return (self.input_size[0],self.output_size)

 


테스트

 

잘 학습이 되는지 OR 게이트를 만들어 보겠습니다.

X = np.array([[0,0],[0,1],[1,0],[1,1]])
Y = np.array([0,1,1,1]).reshape((-1,1))

L = Layer2D(X.shape,1)
L.train(X,Y,show=True)
L.eval(X,Y)
**********train start**********
epoch: 0 >> err: 0.1528
epoch: 100 >> err: 0.1318
epoch: 200 >> err: 0.1127
epoch: 300 >> err: 0.0965
epoch: 400 >> err: 0.0832
epoch: 500 >> err: 0.0722
epoch: 600 >> err: 0.0633
epoch: 700 >> err: 0.0559
epoch: 800 >> err: 0.0497
epoch: 900 >> err: 0.0446
epoch: 1000 >> err: 0.0403
epoch: 1100 >> err: 0.0366
epoch: 1200 >> err: 0.0334
epoch: 1300 >> err: 0.0307
epoch: 1400 >> err: 0.0284
epoch: 1500 >> err: 0.0263
epoch: 1600 >> err: 0.0245
epoch: 1700 >> err: 0.0229
epoch: 1800 >> err: 0.0214
epoch: 1900 >> err: 0.0201
epoch: 2000 >> err: 0.0190
epoch: 2100 >> err: 0.0180
epoch: 2200 >> err: 0.0170
epoch: 2300 >> err: 0.0162
epoch: 2400 >> err: 0.0154
epoch: 2500 >> err: 0.0147
epoch: 2600 >> err: 0.0140
epoch: 2700 >> err: 0.0134
epoch: 2800 >> err: 0.0128
epoch: 2900 >> err: 0.0123
epoch: 3000 >> err: 0.0118
epoch: 3100 >> err: 0.0114
epoch: 3200 >> err: 0.0110
epoch: 3300 >> err: 0.0106
epoch: 3400 >> err: 0.0102
epoch: 3465 >> err: 0.009996951778415112
**********train done**********
input:
[[0 0]
 [0 1]
 [1 0]
 [1 1]]
output:
[[0.15161996]
 [0.90784194]
 [0.90785055]
 [0.99899933]]
acc: 98.8770596790182%

3466회 만에 학습이 완료되었습니다. 무려 98.8%의 정확도를 보이네요. 입력이 [0,0]일 때 0.151...을 출력하네요. 0.5 기준으로 더미 변수를 출력하도록 만들면 매우 높은 정확도를 보이는 게이트가 되겠네요. error를 플롯으로 출력도 해줍니다.

아주 아름답군요.

 


 

그러면 최소 2개의 layer가 필요한 XOR 게이트도 만들어 보겠습니다.

X = np.array([[0,0],[0,1],[1,0],[1,1]])
Y = np.array([0,1,1,0]).reshape((-1,1))

L1 = Layer2D(X.shape, 4, act='relu')
L2 = Layer2D(L1.get_shape(), 4)
L3 = Layer2D(L2.get_shape(), 1)

epochs = 100000
for i in range(epochs):
    Y_pred1 = L1.forward(X)
    Y_pred2 = L2.forward(Y_pred1)
    Y_pred3 = L3.forward(Y_pred2)
    
    L3.backward(Y_pred2, Y, Y_pred3)
    L2.backward(Y_pred1, Y, Y_pred2)
    L1.backward(X, Y, Y_pred1)

print(f"X:\n{X}")
Y_pred1 = L1.forward(X)
Y_pred2 = L2.forward(Y_pred1)
L3.eval(Y_pred2,Y)

3개의 레이어를 만들었습니다. 왜 그런지는 모르겠는데 이렇게 했을 때 정확도가 더 잘 나오더라구요. ReLU는 0 이하의 수는 0을 출력하고, 0 이상의 수는 자기 자신의 값을 출력하기 때문에 은닉층에 적합합니다. 반대로 sigmoid는 절댓값이 5가 넘어가는 큰 수가 입력으로 들어오면 0에 가깝고 기울기가 매우 작아지기 때문에 은닉층에 적합하지 않고 0에서 1사이 실수를 출력하기 때문에 출력층에 적합합니다. 만약 매우 큰 수가 ReLU의 출력으로 나올 시 경사 하강법이 제대로 효과를 보기 어려울 것 같아서 중간에 sigmoid를 사용하는 은닉층을 넣었습니다. 예상대로 정확도가 더 만족스러워진 것 같습니다. 결과 보시죠.

 

X:     
[[0 0] 
 [0 1] 
 [1 0] 
 [1 1]]
input:
[[0.0138198  0.01361141 0.0233578  0.02609132] 
 [0.96756166 0.98151718 0.97698024 0.97478566] 
 [0.98611251 0.97009771 0.9737281  0.97572349] 
 [0.02804003 0.0280643  0.02033667 0.01684577]]
output:
[[0.0088464 ]
 [0.99628854]
 [0.99490155]
 [0.00594615]]
acc: 99.70176016098245%

그리고 forward 메서드와 backward 메서드가 직관적이기 때문에 레이어를 각자 학습시키지 않고 여러 개를 이어 붙여서 따로 학습시킬 수 있을 뿐만 아니라 편리하기까지 합니다.

 


마무리

 

 이 프로젝트는 3부작으로 생각하고 있습니다. 아마 다음엔 모델을 만들 수 있도록 클래스를 구현하고 그것에 대해서 다룰 것 같습니다. 최종적으로 pytorch나 tensorflow같은 형태가 될 것 같고 손글씨 분류나 개고양이 분류같은 간단한 예제를 통해서 기능과 성능을 확인할 것 같습니다. 끝까지 봐주셔서 감사합니다!