딥러닝 - 다층 신경망

사과 톡톡톡·2024년 7월 26일

머신러닝 & 딥러닝

목록 보기
9/10

1. 다층 신경망

다층 신경망이란?

다층 신경망(Multilayer Neural Network, MLP)은 인공 신경망의 일종으로, 입력층(Input Layer), 하나 이상의 숨겨진 층(Hidden Layer), 그리고 출력층(Output Layer)으로 구성된다.
각 층에는 여러 뉴런이 있으며, 층과 층 사이의 모든 뉴런은 완전히 연결되어 있다.

다층 신경망의 구성 요소와 특징

1. 입력층 (Input Layer):

  • 입력 데이터를 받아들이는 층
  • 각 뉴런은 하나의 입력 변수를 나타냄

2. 숨겨진 층 (Hidden Layers):

  • 입력층과 출력층 사이에 위치하는 층
  • 신경망의 비선형성을 부여하며, 더 많은 숨겨진 층을 추가함으로써 신경망이 더 복잡한 패턴과 특징을 학습할 수 있게함
  • 각 뉴런은 활성화 함수를 사용하여 입력의 가중치 합계를 변환
  • 일반적으로 사용되는 활성화 함수로는 시그모이드 함수, 하이퍼볼릭 탄젠트 함수(tanh), 렐루(ReLU) 함수 등이 있음

3. 출력층 (Output Layer):

  • 신경망의 최종 출력을 생성하는 층
  • 출력 뉴런의 수는 예측하거나 분류하려는 결과의 수와 일치

4. 가중치와 바이어스 (Weights and Biases):

  • 뉴런 사이의 연결에는 가중치가 부여되어 있으며, 이는 입력 값의 중요도를 조정하는 역할을 함
  • 각 뉴런에는 바이어스가 추가되어, 가중치 합에 더해짐
  • 바이어스는 신경망이 더 유연하게 학습할 수 있게 도와줌

5. 학습 과정 (Learning Process):

  • 학습 데이터에 대해 신경망의 출력을 실제 값과 비교하여 오차를 계산
  • 오차 역전파 알고리즘(Backpropagation)을 사용하여 가중치와 바이어스를 업데이트
  • 이는 오차가 최소화되도록 가중치와 바이어스를 조정하는 과정
  • 최적화 알고리즘(예: 경사 하강법, Adam 등)을 사용하여 학습 과정을 수행

6. 용도:

  • 다층 신경망은 이미지 인식, 음성 인식, 자연어 처리, 자율 주행, 게임 플레이 등의 다양한 인공지능 응용 분야에 사용

신경망 알고리즘의 벡터화

신경망 알고리즘의 벡터화(Vectorization)는 수학적 연산을 벡터와 행렬로 처리하여 계산을 최적화하고, 이를 통해 연산 속도를 크게 향상시키는 방법을 말한다.
벡터화는 주로 고수준의 수학 라이브러리(예: NumPy, TensorFlow, PyTorch 등)를 사용하여 구현된다.

1. 벡터화의 필요성

속도 향상:

  • 반복문을 사용하지 않고 한 번에 대량의 데이터를 처리하기 때문에 연산 속도가 크게 향상됨

효율성:

  • 벡터화된 연산은 메모리 접근 패턴을 최적화하여 캐시 메모리의 효율을 높이고, CPU나 GPU의 SIMD(Single Instruction, Multiple Data) 명령어를 활용할 수 있음

코드 간결성:

  • 벡터화된 코드는 일반적으로 더 간결하고 이해하기 쉬우며, 디버깅과 유지 보수가 용이함

3. 벡터화의 활용

입력 데이터와 가중치의 행렬 곱셈:

  • np.dot(X, W)

활성화 함수 적용:

  • np.maximum(0, Z) (ReLU 함수의 경우)

오차 역전파 시의 그래디언트 계산:

  • 행렬 연산을 통해 그래디언트를 한 번에 계산

배치 경사 하강법

배치 경사 하강법이란?

배치 경사 하강법(Batch Gradient Descent)은 기계 학습 및 신경망 학습에서 사용되는 최적화 알고리즘 중 하나로, 주어진 데이터셋 전체를 사용하여 비용 함수의 기울기를 계산하고 이를 바탕으로 모델의 파라미터를 업데이트하는 방법이다.

배치 경사 하강법의 단계

1. 비용 함수 정의

  • 비용 함수는 모델의 예측값과 실제 값 간의 오차를 측정하는 함수이다.
  • 예를 들어, 선형 회귀의 경우 비용 함수는 평균 제곱 오차(MSE, Mean Squared Error)로 정의될 수 있다.

2. 비용 함수의 기울기 계산

  • 모델의 파라미터를 최적화하기 위해 비용 함수의 기울기(gradient)를 계산한다.
  • 기울기는 각 파라미터에 대한 비용 함수의 편미분으로 이루어진 벡터이다.

3. 파라미터 업데이트

  • 기울기를 계산한 후, 파라미터를 업데이트한다.
  • 이 때 학습률(learning rate)이라는 하이퍼파라미터를 사용하여 한 번에 얼마나 업데이트할지 결정한다.
  • θ=θαJ(θ)\theta = \theta - \alpha \cdot \nabla J(\theta)

    여기서 θ\theta는 모델의 파라미터, α\alpha는 학습률, J(θ)\nabla J(\theta)는 비용 함수의 기울기

4. 전체 데이터셋 반복

  • 위의 과정을 모든 데이터 포인트에 대해 한 번에 수행한 후, 반복적으로 전체 데이터셋을 사용하여 파라미터를 업데이트한다.
  • 이 과정을 수렴할 때까지 반복한다.

장점

1. 글로벌 최적화 가능:

  • 배치 경사 하강법은 모든 데이터를 사용하여 기울기를 계산하기 때문에 최적화 과정에서 안정적이고, 비용 함수가 볼록(Convex)일 경우 글로벌 최적값에 도달할 수 있다.

단점

1. 연산 비용:

  • 데이터셋이 클 경우, 매번 모든 데이터 포인트를 사용하여 기울기를 계산하는 것은 매우 많은 연산을 필요로 한다.

2. 메모리 사용량:

  • 전체 데이터셋을 메모리에 로드하고 처리해야 하므로 메모리 사용량이 커질 수 있다.
import numpy as np
import matplotlib.pyplot as plt

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

cancer = load_breast_cancer()
x = cancer.data
y = cancer.target
x_train_all, x_test, y_train_all, y_test = train_test_split(x, y, stratify=y, 
                                                            test_size=0.2, random_state=42)
x_train, x_val, y_train, y_val = train_test_split(x_train_all, y_train_all, stratify=y_train_all, 
                                                  test_size=0.2, random_state=42)
                                                  
cancer.data

### 출력 결과
array([[1.799e+01, 1.038e+01, 1.228e+02, ..., 2.654e-01, 4.601e-01,
        1.189e-01],
       [2.057e+01, 1.777e+01, 1.329e+02, ..., 1.860e-01, 2.750e-01,
        8.902e-02],
       [1.969e+01, 2.125e+01, 1.300e+02, ..., 2.430e-01, 3.613e-01,
        8.758e-02],
       ...,
       [1.660e+01, 2.808e+01, 1.083e+02, ..., 1.418e-01, 2.218e-01,
        7.820e-02],
       [2.060e+01, 2.933e+01, 1.401e+02, ..., 2.650e-01, 4.087e-01,
        1.240e-01],
       [7.760e+00, 2.454e+01, 4.792e+01, ..., 0.000e+00, 2.871e-01,
        7.039e-02]])
        
cancer.target

### 출력 결과
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
       0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0,
       1, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0,
       1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1,
       1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 0,
       0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1,
       1, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0,
       0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0,
       1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1,
       1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0,
       0, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0,
       0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0,
       1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1,
       1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1,
       1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
       1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1,
       1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1,
       1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1])
       
print(x_train.shape, x_val.shape)

### 출력 결과
(364, 30) (91, 30)
class SingleLayer:
    
    def __init__(self, learning_rate=0.1, l1=0, l2=0):
        self.w = None              # 가중치
        self.b = None              # 절편
        self.losses = []           # 훈련 손실
        self.val_losses = []       # 검증 손실
        self.w_history = []        # 가중치 기록
        self.lr = learning_rate    # 학습률
        self.l1 = l1               # L1 손실 하이퍼파라미터
        self.l2 = l2               # L2 손실 하이퍼파라미터

    def forpass(self, x):
        z = np.dot(x, self.w) + self.b        # 선형 출력을 계산합니다.
        return z

    def backprop(self, x, err):
        m = len(x)
        w_grad = np.dot(x.T, err) / m         # 가중치에 대한 그래디언트를 계산합니다.
        b_grad = np.sum(err) / m              # 절편에 대한 그래디언트를 계산합니다.
        # print(f"{i}번째 w :", w_grad)
        # print(f"{i}번째 b :", b_grad)
        return w_grad, b_grad

    def activation(self, z):
        z = np.clip(z, -100, None)            # 안전한 np.exp() 계산을 위해
        a = 1 / (1 + np.exp(-z))              # 시그모이드 계산
        return a
        
    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        y = y.reshape(-1, 1)                  # 타깃을 열 벡터로 바꿉니다.
        y_val = y_val.reshape(-1, 1)
        m = len(x)                            # 샘플 개수를 저장합니다.
        self.w = np.ones((x.shape[1], 1))     # 가중치를 초기화합니다.
        self.b = 0                            # 절편을 초기화합니다.
        self.w_history.append(self.w.copy())  # 가중치를 기록합니다.
        # epochs만큼 반복합니다.
        for i in range(epochs):
            z = self.forpass(x)               # 정방향 계산을 수행합니다.
            a = self.activation(z)            # 활성화 함수를 적용합니다.
            err = -(y - a)                    # 오차를 계산합니다.
            # 오차를 역전파하여 그래디언트를 계산합니다.
            w_grad, b_grad = self.backprop(x, err)
            # 그래디언트에 페널티 항의 미분 값을 더합니다.
            w_grad += (self.l1 * np.sign(self.w) + self.l2 * self.w) / m
            # 가중치와 절편을 업데이트합니다.
            self.w -= self.lr * w_grad
            self.b -= self.lr * b_grad
            # 가중치를 기록합니다.
            self.w_history.append(self.w.copy())
            # 안전한 로그 계산을 위해 클리핑합니다.
            a = np.clip(a, 1e-10, 1-1e-10)
            # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
            loss = np.sum(-(y*np.log(a) + (1-y)*np.log(1-a)))
            self.losses.append((loss + self.reg_loss()) / m)
            # 검증 세트에 대한 손실을 계산합니다.
            self.update_val_loss(x_val, y_val)
    
    def predict(self, x):
        z = self.forpass(x)      # 정방향 계산을 수행합니다.
        return z > 0             # 스텝 함수를 적용합니다.
    
    def score(self, x, y):
        # 예측과 타깃 열 벡터를 비교하여 True의 비율을 반환합니다.
        return np.mean(self.predict(x) == y.reshape(-1, 1))
    
    def reg_loss(self):
        # 가중치에 규제를 적용합니다.
        return self.l1 * np.sum(np.abs(self.w)) + self.l2 / 2 * np.sum(self.w**2)
    
    def update_val_loss(self, x_val, y_val):
        z = self.forpass(x_val)            # 정방향 계산을 수행합니다.
        a = self.activation(z)             # 활성화 함수를 적용합니다.
        a = np.clip(a, 1e-10, 1-1e-10)     # 출력 값을 클리핑합니다.
        # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
        val_loss = np.sum(-(y_val*np.log(a) + (1-y_val)*np.log(1-a)))
        self.val_losses.append((val_loss + self.reg_loss()) / len(y_val))
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
scaler.fit(x_train)
x_train_scaled = scaler.transform(x_train)
x_val_scaled = scaler.transform(x_val)

print(x_train_scaled)

### 출력 결과
[[ 0.21529445 -0.3313719   0.17983103 ... -0.48870759  1.0446075
  -0.6393202 ]
 [-0.6557398  -1.14919128 -0.71043609 ... -0.49139007  0.27379602
  -0.9632616 ]
 [ 0.18967579  0.55160342  0.16625487 ...  1.00632788  1.19598931
   1.3439391 ]
 ...
 [ 0.11281983 -0.88856752  0.10125385 ...  0.65611522 -0.35953605
  -0.42335927]
 [ 0.96108195  1.44356576  0.94585569 ... -0.08901809 -2.0401831
  -0.8927216 ]
 [-0.74398184 -0.41000838 -0.73429722 ... -0.77275241 -0.52482027
  -0.19925911]]

print(x_val_scaled)

### 출력 결과
[[-0.80945173  2.29958    -0.85730548 ... -1.7268211  -2.09733746
  -1.38812946]
 [ 0.89845857  1.20990308  0.93351373 ...  1.10319521  0.60127505
   2.67334684]
 [ 1.14895208  0.60777233  1.06516136 ... -0.35860732 -0.85693343
  -1.0338016 ]
 ...
 [-0.65004677  0.55385017 -0.69068894 ... -1.17318705 -0.75807184
  -1.03108852]
 [-0.51056743 -0.26846272 -0.54464235 ... -0.50316318  0.29233257
  -0.17972496]
 [ 0.75328619  0.23930426  0.64142054 ... -0.42477516  2.70826272
  -0.4559162 ]]
  
single_layer = SingleLayer(l2=0.01)
single_layer.fit(x_train_scaled, y_train, 
                 x_val=x_val_scaled, y_val=y_val, epochs=10000)
single_layer.score(x_val_scaled, y_val)

### 출력 결과
0.978021978021978

plt.figure(figsize=(15,8))
plt.ylim(0, 0.3)
plt.plot(single_layer.losses)
plt.plot(single_layer.val_losses)
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()

### 출력 결과

w2 = []
w3 = []
for w in single_layer.w_history:
    w2.append(w[2])
    w3.append(w[3])
plt.figure(figsize=(15,8))
plt.plot(w2, w3)
plt.plot(w2[-1], w3[-1], 'ro')
plt.xlabel('w[2]')
plt.ylabel('w[3]')
plt.show()

### 출력 결과

위 코드에서 역방향 계산을 할 때를 보면 w_grad = np.dot(x.T, err) / m로 전치를 해주는것을 볼 수 있다.
그렇다면 왜 전치를 해줘야 할까?

다층 신경망에서의 역전파 계산

다층 신경망에서 역방향 계산, 즉 오차 역전파(Backpropagation) 과정에서 행렬의 전치(transpose)를 사용하는 이유는 주로 행렬 곱셈의 차원을 맞추기 위해서이다.
역방향 계산은 순전파(Forward Propagation) 과정에서 계산된 값을 기반으로 가중치의 기울기를 구하는 과정이며, 이를 통해 모델의 가중치를 업데이트한다.

역전파 계산시 전치의 이유

1. 행렬 곱셈의 차원 일치

  • 역방향 계산에서 각 층의 기울기를 구할 때, 순전파 과정에서의 입력과 가중치 행렬의 곱셈 결과를 이용한다.
  • 행렬 곱셈의 규칙상, 두 행렬을 곱할 때 차원이 일치해야 한다.
    예를 들어, 두 행렬 AABB를 곱할 때, AA의 열의 수가 BB의 행의 수와 같아야 한다.
    만약 순전파에서 입력 XXm×nm \times n 행렬이고, 가중치 WWn×pn \times p 행렬인 경우, 출력 ZZm×pm \times p 행렬이 된다.
  • 역전파 과정에서는 출력 ZZ의 기울기인 δZ\delta Zm×pm \times p 행렬로 주어지고, 가중치 WW의 기울기를 구하기 위해 δZ\delta ZXX를 곱해야 한다.
    이 때, XX의 전치인 XTX^T를 사용하여 차원을 맞추어야 한다.

2. 기울기 계산 과정

  • 가중치 WW의 기울기를 구하는 과정에서, 출력의 기울기 δZ\delta Z와 입력 XX를 사용한다.
  • 이를 수식으로 나타내면 다음과 같다:

δW=XTδZ\delta W = X^T \cdot \delta Z

여기서:

  • XX는 입력 행렬로 m×nm \times n 크기
  • δZ\delta Z는 출력의 기울기로 m×pm \times p 크기
  • XTX^TXX의 전치로 n×mn \times m 크기
  • 이 경우, XTX^TδZ\delta Z를 곱하면 (n×m)(m×p)=n×p(n \times m) \cdot (m \times p) = n \times p 크기의 행렬이 되어, 가중치 WW의 기울기 δW\delta W와 동일한 차원이 된다.

3. 수학적 유도

  • 역전파 과정에서 사용하는 행렬 전치의 수학적 유도는 체인 룰(chain rule)을 기반으로 한다.
  • 가중치 행렬 WW의 업데이트를 위한 기울기는 다음과 같이 구해다:

δW=LW\delta W = \frac{\partial L}{\partial W}

여기서 LL은 손실 함수이다.
손실 LL의 기울기를 구할 때, 중간에 XXWW의 곱셈이 있기 때문에, 이 관계를 풀기 위해 행렬 전치가 필요하다.
이는 역전파 알고리즘의 핵심이며, 모든 뉴런과 연결된 가중치의 기울기를 정확히 계산하기 위해 행렬 전치를 사용한다.

요약

1. 차원 일치:

  • 행렬 곱셈의 차원을 맞추기 위해.

2. 기울기 계산:

  • 입력과 출력의 기울기를 정확하게 반영하기 위해.

2. 2개의 층을 가진 신경망 구현

이전 코드에선 단층이었다면 이 단원에선 2개의 층, 즉 다층 신경망을 구현해보겠다.

class DualLayer(SingleLayer):
    
    def __init__(self, units=10, learning_rate=0.1, l1=0, l2=0):
        self.units = units         # 은닉층의 뉴런 개수
        self.w1 = None             # 은닉층의 가중치
        self.b1 = None             # 은닉층의 절편
        self.w2 = None             # 출력층의 가중치
        self.b2 = None             # 출력층의 절편
        self.a1 = None             # 은닉층의 활성화 출력
        self.losses = []           # 훈련 손실
        self.val_losses = []       # 검증 손실
        self.lr = learning_rate    # 학습률
        self.l1 = l1               # L1 손실 하이퍼파라미터
        self.l2 = l2               # L2 손실 하이퍼파라미터

    def forpass(self, x):
        z1 = np.dot(x, self.w1) + self.b1        # 첫 번째 층의 선형 식을 계산합니다
        self.a1 = self.activation(z1)            # 활성화 함수를 적용합니다
        z2 = np.dot(self.a1, self.w2) + self.b2  # 두 번째 층의 선형 식을 계산합니다.
        return z2

    def backprop(self, x, err):
        m = len(x)       # 샘플 개수
        # 출력층의 가중치와 절편에 대한 그래디언트를 계산합니다.
        w2_grad = np.dot(self.a1.T, err) / m
        b2_grad = np.sum(err) / m
        # 시그모이드 함수까지 그래디언트를 계산합니다.
        err_to_hidden = np.dot(err, self.w2.T) * self.a1 * (1 - self.a1)
        # 은닉층의 가중치와 절편에 대한 그래디언트를 계산합니다.
        w1_grad = np.dot(x.T, err_to_hidden) / m
        b1_grad = np.sum(err_to_hidden, axis=0) / m
        return w1_grad, b1_grad, w2_grad, b2_grad

    def init_weights(self, n_features):
        self.w1 = np.ones((n_features, self.units))  # (특성 개수, 은닉층의 크기)
        self.b1 = np.zeros(self.units)               # 은닉층의 크기
        self.w2 = np.ones((self.units, 1))           # (은닉층의 크기, 1)
        self.b2 = 0
        
    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        y = y.reshape(-1, 1)          # 타깃을 열 벡터로 바꿉니다.
        y_val = y_val.reshape(-1, 1)
        m = len(x)                    # 샘플 개수를 저장합니다.
        self.init_weights(x.shape[1]) # 은닉층과 출력층의 가중치를 초기화합니다.
        # epochs만큼 반복합니다.
        for i in range(epochs):
            a = self.training(x, y, m)
            # 안전한 로그 계산을 위해 클리핑합니다.
            a = np.clip(a, 1e-10, 1-1e-10)
            # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
            loss = np.sum(-(y*np.log(a) + (1-y)*np.log(1-a)))
            self.losses.append((loss + self.reg_loss()) / m)
            # 검증 세트에 대한 손실을 계산합니다.
            self.update_val_loss(x_val, y_val)
            
    def training(self, x, y, m):
        z = self.forpass(x)       # 정방향 계산을 수행합니다.
        a = self.activation(z)    # 활성화 함수를 적용합니다.
        err = -(y - a)            # 오차를 계산합니다.
        # 오차를 역전파하여 그래디언트를 계산합니다.
        w1_grad, b1_grad, w2_grad, b2_grad = self.backprop(x, err)
        # 그래디언트에 페널티 항의 미분 값을 더합니다
        w1_grad += (self.l1 * np.sign(self.w1) + self.l2 * self.w1) / m
        w2_grad += (self.l1 * np.sign(self.w2) + self.l2 * self.w2) / m
        # 은닉층의 가중치와 절편을 업데이트합니다.
        self.w1 -= self.lr * w1_grad
        self.b1 -= self.lr * b1_grad
        # 출력층의 가중치와 절편을 업데이트합니다.
        self.w2 -= self.lr * w2_grad
        self.b2 -= self.lr * b2_grad
        return a
    
    def reg_loss(self):
        # 은닉층과 출력층의 가중치에 규제를 적용합니다.
        return self.l1 * (np.sum(np.abs(self.w1)) + np.sum(np.abs(self.w2))) + \
               self.l2 / 2 * (np.sum(self.w1**2) + np.sum(self.w2**2))
               
dual_layer = DualLayer(l2=0.01)
dual_layer.fit(x_train_scaled, y_train, 
               x_val=x_val_scaled, y_val=y_val, epochs=20000)
dual_layer.score(x_val_scaled, y_val)

### 출력 결과
0.978021978021978


이전 과의 차이점이라면 층이 한 개 더 추가 되었다는 점이다.
따라서 정방향 계산을 할 때 z1과 z2를 받는것을 볼 수 있다.

plt.figure(figsize=(15,8))
plt.ylim(0, 0.3)
plt.plot(dual_layer.losses)
plt.plot(dual_layer.val_losses)
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()

### 출력 결과


확실히 이전 보다는 성능이 좋아졌지만 아직도 train과 val의 loss 격차가 있는 편이다.
이를 해결해주기 위해 가중치 초기화를 개선하겠다.

class RandomInitNetwork(DualLayer):
    
    def init_weights(self, n_features):
        np.random.seed(42)
        self.w1 = np.random.normal(0, 1, 
                                   (n_features, self.units))  # (특성 개수, 은닉층의 크기)
        self.b1 = np.zeros(self.units)                        # 은닉층의 크기
        self.w2 = np.random.normal(0, 1, 
                                   (self.units, 1))           # (은닉층의 크기, 1)
        self.b2 = 0
        
random_init_net = RandomInitNetwork(l2=0.01)
random_init_net.fit(x_train_scaled, y_train,
                    x_val=x_val_scaled, y_val=y_val, epochs=500)
                    
plt.figure(figsize=(15,8))
plt.plot(random_init_net.losses)
plt.plot(random_init_net.val_losses)
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()

### 출력 결과

랜덤 초기화

이전 코드와의 차이점을 구분하여 어떻게 loss를 줄였는지 확인해보자.

# 이전 코드
    def init_weights(self, n_features):
        self.w1 = np.ones((n_features, self.units))  # (특성 개수, 은닉층의 크기)
        self.b1 = np.zeros(self.units)               # 은닉층의 크기
        self.w2 = np.ones((self.units, 1))           # (은닉층의 크기, 1)
        self.b2 = 0
# 개선 코드
class RandomInitNetwork(DualLayer):
    
    def init_weights(self, n_features):
        np.random.seed(42)
        self.w1 = np.random.normal(0, 1, 
                                   (n_features, self.units))  # (특성 개수, 은닉층의 크기)
        self.b1 = np.zeros(self.units)                        # 은닉층의 크기
        self.w2 = np.random.normal(0, 1, 
                                   (self.units, 1))           # (은닉층의 크기, 1)
        self.b2 = 0

개선된 점

1. 초기 가중치 설정 방식:

  • 이전 코드: 모든 가중치 w1 와 w2 를 1로 설정
  • 개선 코드: 가중치를 평균이 0이고 표준편차가 1인 정규분포를 따르는 랜덤 값으로 초기화

2. 초기화 방식의 랜덤성:

  • 이전 코드: 가중치가 모두 동일한 값(1)으로 초기화
  • 개선 코드: 가중치가 정규분포에 따라 랜덤하게 초기화

왜 이렇게 했는가

1. 대칭성 깨기:

문제:

  • 모든 가중치가 동일한 값으로 초기화되면, 모든 뉴런이 동일하게 학습된다.
  • 이는 각 뉴런이 동일한 업데이트를 받고 동일한 출력을 생성하게 되어, 신경망의 학습 효과가 제한된다.
  • 이는 “대칭성 문제”라고 불리며, 이를 해결하기 위해 가중치를 랜덤하게 초기화한다.

해결:

  • 가중치를 랜덤하게 초기화하면 각 뉴런이 서로 다르게 학습되고, 네트워크가 더 풍부한 표현을 학습할 수 있게 된다.

2. 학습 속도 및 성능 향상:

문제:

  • 가중치가 동일한 값으로 초기화되면, 학습 초기에 기울기 소멸/폭주 문제로 인해 학습 속도가 매우 느려지거나 학습이 제대로 이루어지지 않을 수 있다.

해결:

  • 랜덤 초기화는 이러한 문제를 완화하고, 네트워크가 더 빠르고 효과적으로 학습하도록 도와준다.

왜 좋아지는가

1. 효율적인 학습:

  • 랜덤 초기화는 각 뉴런이 다른 학습 경로를 따르게 하여 더 다양한 특징을 학습하게 한다.
  • 이는 신경망의 표현력을 높이고, 더 빠른 수렴을 가능하게 한다.

2. 기울기 소멸/폭주 문제 완화:

  • 가중치를 랜덤하게 초기화하면, 기울기 소멸(gradient vanishing)이나 기울기 폭주(gradient explosion) 문제를 완화할 수 있다.
  • 이는 네트워크가 더 안정적으로 학습하게 한다.

3. 더 나은 일반화 성능:

  • 초기 가중치의 다양성은 네트워크가 훈련 데이터에 과적합(overfitting)하는 것을 방지하고, 더 나은 일반화 성능을 가지게 한다.
  • 이는 테스트 데이터에 대해서도 좋은 성능을 발휘할 가능성을 높인다.

랜덤 초기화의 일반적인 방식

개선된 코드에서는 평균이 0이고 표준편차가 1인 정규분포를 사용했지만, 실제로는 다양한 초기화 기법이 존재한다.
예를 들어, Xavier 초기화와 He 초기화는 특정 활성화 함수에 최적화된 초기화 방법으로 널리 사용된다.

Xavier 초기화:

  • 주로 시그모이드나 하이퍼볼릭 탄젠트(tanh) 활성화 함수와 함께 사용되며, 가중치를 1n\frac{1}{\sqrt{n}} 범위 내에서 초기화

He 초기화:

  • 주로 ReLU 활성화 함수와 함께 사용되며, 가중치를 2n\frac{2}{\sqrt{n}} 범위 내에서 초기화

3. 미니 배치

미니 배치란?

미니 배치(Mini-batch)는 기계 학습, 특히 딥러닝에서 모델을 학습할 때 사용하는 데이터 분할 방법 중 하나이다.
미니 배치는 전체 데이터셋을 작은 부분으로 나누어, 각 부분을 사용하여 모델의 가중치를 업데이트하는 방법이다.
미니 배치 방법은 전체 데이터셋을 사용하여 학습하는 배치 학습(Batch Learning)과 하나의 데이터 포인트를 사용하여 학습하는 확률적 경사 하강법(Stochastic Gradient Descent, SGD) 사이의 절충안이다.

미니 배치의 개념 및 이유

1. 개념:

  • 전체 데이터셋을 일정한 크기의 작은 묶음(미니 배치)으로 나눈다.
  • 각 미니 배치에 대해 모델의 예측과 실제 값 간의 손실을 계산하고, 이 손실에 대한 기울기(gradient)를 계산하여 가중치를 업데이트한다.

2. 이유:

연산 효율성:

  • 배치 학습보다 더 자주 가중치를 업데이트할 수 있어, 학습이 더 빠르게 수렴할 수 있다.

메모리 사용량:

  • 전체 데이터셋을 한 번에 메모리에 올리지 않으므로, 메모리 사용량을 줄일 수 있다.

일반화 성능:

  • 미니 배치를 사용하면, 가중치 업데이트 시 노이즈가 추가되어 모델이 더 나은 일반화 성능을 가지게 된다.

미니 배치의 동작 방식

1. 데이터셋 분할:

  • 데이터셋을 크기 m 인 미니 배치로 나눈다.
  • 예를 들어, 데이터셋이 1000개의 데이터 포인트로 구성되고, 미니 배치 크기 m 이 32라면, 약 31개의 미니 배치와 마지막에 8개의 데이터 포인트를 가진 하나의 미니 배치가 생성된다.

2. 미니 배치 반복:

  • 각 미니 배치에 대해 순전파(Forward Propagation)를 수행하여 예측 값을 계산
  • 예측 값과 실제 값 간의 손실을 계산
  • 손실에 대한 기울기를 계산하여 가중치를 업데이트
class MinibatchNetwork(RandomInitNetwork):
    
    def __init__(self, units=10, batch_size=32, learning_rate=0.1, l1=0, l2=0):
        super().__init__(units, learning_rate, l1, l2)
        self.batch_size = batch_size     # 배치 크기
        
    def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        y_val = y_val.reshape(-1, 1)     # 타깃을 열 벡터로 바꿉니다.
        self.init_weights(x.shape[1])    # 은닉층과 출력층의 가중치를 초기화합니다.
        np.random.seed(42)
        # epochs만큼 반복합니다.
        for i in range(epochs):
            loss = 0
            # 제너레이터 함수에서 반환한 미니배치를 순환합니다.
            for x_batch, y_batch in self.gen_batch(x, y):
                y_batch = y_batch.reshape(-1, 1) # 타깃을 열 벡터로 바꿉니다.
                m = len(x_batch)                 # 샘플 개수를 저장합니다.
                a = self.training(x_batch, y_batch, m)
                # 안전한 로그 계산을 위해 클리핑합니다.
                a = np.clip(a, 1e-10, 1-1e-10)
                # 로그 손실과 규제 손실을 더하여 리스트에 추가합니다.
                loss += np.sum(-(y_batch*np.log(a) + (1-y_batch)*np.log(1-a)))
            self.losses.append((loss + self.reg_loss()) / len(x))
            # 검증 세트에 대한 손실을 계산합니다.
            self.update_val_loss(x_val, y_val)

    # 미니배치 제너레이터 함수
    def gen_batch(self, x, y):
        length = len(x)
        bins = length // self.batch_size # 미니배치 횟수
        if length % self.batch_size:
            bins += 1                    # 나누어 떨어지지 않을 때
        indexes = np.random.permutation(np.arange(len(x))) # 인덱스를 섞습니다.
        x = x[indexes]
        y = y[indexes]
        for i in range(bins):
            start = self.batch_size * i
            end = self.batch_size * (i + 1)
            yield x[start:end], y[start:end]   # batch_size만큼 슬라이싱하여 반환합니다.
            
minibatch_net = MinibatchNetwork(l2=0.01, batch_size=32)
minibatch_net.fit(x_train_scaled, y_train, 
                  x_val=x_val_scaled, y_val=y_val, epochs=500)
minibatch_net.score(x_val_scaled, y_val)

### 출력 결과
0.978021978021978

plt.figure(figsize=(15,8))
plt.plot(minibatch_net.losses)
plt.plot(minibatch_net.val_losses)
plt.ylabel('loss')
plt.xlabel('iteration')
plt.legend(['train_loss', 'val_loss'])
plt.show()

### 출력 결과

0개의 댓글