coursera - Neural Networks and Deep Learning - (2) hidden layer
Intro
이번에는 하나의 hidden layer 가 있을때 forward propagation/ backward propagation/ loss 를 계산하고 이전에 사용했던 activation function 과는 다른 tanh를 이용하여 2개의 클래스를 가진 planar data를 분류하고자 한다.
neural network를 구성하는 다음 단계를 거치며, 어떻게 공식이 유도가 되었고, 이 공식을 토대로 코드를 어떻게 구성했는지 그리고 구성하면서 들었던 궁금증을 기술하고자 한다.
- Neural Network 구조 정의 (# of input units, # of hidden units, etc)
- 모델 파라미터 초기화
- 반복:
- forward propagation
- compute loss
- backward propagation to get the gradients
- update parameters (gradient descent)
Define Neural Network Structure
Neural network를 정의할 때 가장 먼저 해야할 일은 네트워크의 구조를 설계하는 것이다. 즉, 몇개의 뉴런을 사용할지, 어떤 형태의 레이어 구성을 가질지를 결정하는 과정이다.
이번 실습에서는 입력층(input layer), 하나의 은닉층(hidden layer), 출력층(output layer)로 구성된 간단한 구조를 가진다.
- 입력층 (Input layer, \( n_x \)): 주어진 데이터 X 의 feature 개수
- 은닉층 (Hidden layer, \( n_h \)): 뉴런의 개수는 실험적으로 결정하지만, 여기서는 4개로 설정한다. \( n_h = 4 \)
- 출력층 (Output layer, \( n_y = 1 \)): 분류 문제에서 class의 개수와 같다. 이진 분류(binary classification)이므로, \( n_y = 1 \)
Initialize Model Parameters
Neural Network의 학습을 진행하기전에 weight와 bias를 초기화하는 과정은 매우 중요하다. 그 이유는 초기화하는 방법에 따라 학습속도가 크게 달라질 수 있기 때문이다. 만약 가중치를 0으로 초기화하게 되면 모든 뉴런이 동일하게 학습되어 의미가 없어진다. 따라서, 작은 랜덤값을 사용해서 대칭성을 깬다. bias는 뉴런이 학습되는 기준을 조정한다. 0으로 해도 무방하다.
코드로 구현하면 다음과 같다.
def initialize_parameters(n_x,n_h,n_y):
"""
신경망의 가중치(W)와 편향(b)을 초기화하는 함수
Arguments:
n_x -- 입력층 뉴런 개수
n_h -- 은닉층 뉴런 개수
n_y -- 출력층 뉴런 개수
Returns:
parameters -- 초기화된 파라미터 딕셔너리 (W1, b1, W2, b2)
"""
np.random.seed(42)
W1 = np.random.randn(n_h, n_x) * 0.01
W2 = np.random.randn(n_y, n_h) * 0.01
b1 = np.zeros((n_h,1))
b2 = np.zeros((n_y,1))
parameters = {"W1": W1, "b1": b1, "W2": W2, "b2": b2}
return parameters
Forward Propagation
forward propagation은 입력값 X를 네트워크에 통과시키고 \hat{Y} 을 출력하는 과정이다. 다음의 순서로 진행된다.
\[ Z^{[1]} = W^{[1]}X + b^{[1]} \]\[ A^{[1]} = \tanh(Z^{[1]}) \]\[ Z^{[2]} = W^{[2]} A^{[1]} + b^{[2]} \]\[ A^{[2]} = \sigma(Z^{[2]}) \]\[ \hat{Y} = A^{[2]} \]은닉층에서 사용하는 활성화함수는 tanh(x)로 다음과 같다.
\[ \tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}} \]이 함수가 가지는 값의 범위는 (-1,1) 이고, 시그모이드 함수보다 출력값의 범위가 넓어 vanishing gradient problem
을 해결하는데 더 유리하다.
데이터의 중심이 0을 가지게 되므로 시그모이드보다 학습을 더 효율적으로 진행할 수 있다. 그 이유는, 시그모이드 함수는 (0,1)사이의 값으로 항상 양수이다. 그렇기 때문에
역전파를 진행하면서 가중치 업데이트가 편향될 수 있기 때문이다. 하지만 tanh 의 경우 중심값이 0이고 양수,음수 모두 가지기 때문에 평균이 0에 가깝게 유지된다. 따라서 가중치 변화가 더 균형잡히게 된다.
Loss Function
이번 모델에서는 binary classification 을 사용하므로, cross entropy loss function을 사용하여 손실함수를 계산한다. cross entropy loss function 은 다음과 같이 정의한다.
\[ J = -\frac{1}{m} \sum\_{i=1}^{m} \left( y^{(i)} \log(a^{[2](i)}) + (1 - y^{(i)}) \log(1 - a^{[2](i)}) \right) \]- m : # of samples
- y(i) : 실제 레이블 (0 또는 1)
- a[2] : 모델의 예측값 (0~1 사이의 확률값)
a[2]가 y에 가까워질수록 J값은 작아지고, 손실값이 낮아진다. (좋은 예측)
a[2]가 y에 멀어질수록 J값은 커지고, 손실값이 커진다. (나쁜예측)
다음은 loss function을 코드로 구현한 것이다.
def compute_loss(A2,Y):
"""
크로스 엔트로피 손실 함수 (Cost Function) 계산
Arguments:
A2 -- 모델의 예측값 (shape: (1, m))
Y -- 실제 레이블 (shape: (1, m))
Returns:
cost -- 손실값 (scalar)
"""
m = Y.shape[1] # number of samples
logprobs = np.multiply(Y, np.log(1-A2)) + np.multiply(1-Y, np.log(1-A2)) # element-wise product
cost = -np.sum(logprobs)/m
cost = np.squeeze(cost)
return cost
손실함수 계산 시 np.sum + np.multiply 조합을 사용하거나 np.dot 을 사용하여 계산할 수 있다.
## np.sum + np.multiply 조합
logprobs = np.multiply(np.log(A2), Y) + np.multiply(np.log(1 - A2), (1 - Y))
cost = -np.sum(logprobs) / m
## np.dot 사용
cost = -np.dot(Y, np.log(A2).T) - np.dot((1 - Y), np.log(1 - A2).T)
cost = np.squeeze(cost) / m
np.multiply() + np.sum() 을 사용하면 결과가 float(단일 값) 으로 나오므로, 코드가 직관적이다. np.dot() 을 사용하면 2D 행렬 형태로 반환되므로 np.squeeze() 가 필요하다. 연산량이 적은 np.multiply() 방식이 더 직관적이고, 코드 유지보수에도 좋다.
Back propagation
역전파는 Chain Rule을 이용하여 손실함수 J에 대한 각 파라미터의 미분을 구하는 과정이다. 이 미분값들은 이후에 Gradient Descent 를 이용해 각 파라미터를 업데이트하는데 사용된다.
수식유도
Step 1 : 출력층
\[ \begin{aligned} &\textbf{출력층 미분 유도} \\ J &= -\frac{1}{m} \sum\_{i=1}^{m} \left[ y^{(i)} \log(A^{[2](i)}) + (1 - y^{(i)}) \log(1 - A^{[2](i)}) \right] \\ \frac{\partial J}{\partial A^{[2]}} &= -\frac{Y}{A^{[2]}} + \frac{1 - Y}{1 - A^{[2]}} \\ \frac{\partial A^{[2]}}{\partial Z^{[2]}} &= A^{[2]} (1 - A^{[2]}) \\ \frac{\partial J}{\partial Z^{[2]}} &= \left( -\frac{Y}{A^{[2]}} + \frac{1 - Y}{1 - A^{[2]}} \right) \cdot A^{[2]} (1 - A^{[2]}) \\ &= A^{[2]} - Y \\ dZ^{[2]} &= A^{[2]} - Y \\ \frac{\partial J}{\partial W^{[2]}} &= \frac{\partial J}{\partial Z^{[2]}} \cdot \frac{\partial Z^{[2]}}{\partial W^{[2]}} = dZ^{[2]} A^{[1]T} \\ dW^{[2]} &= \frac{1}{m} dZ^{[2]} A^{[1]T} \\ \frac{\partial J}{\partial b^{[2]}} &= \frac{\partial J}{\partial Z^{[2]}} \cdot \frac{\partial Z^{[2]}}{\partial b^{[2]}} = dZ^{[2]} \\ db^{[2]} &= \frac{1}{m} \sum dZ^{[2]} \\ \\ \end{aligned} \]Step 2 : 은닉층
\[ \begin{aligned} dA^{[1]} &= W^{[2]T} dZ^{[2]} \\ \frac{\partial A^{[1]}}{\partial Z^{[1]}} &= 1 - A^{[1] 2} \\ \frac{\partial J}{\partial Z^{[1]}} &= \frac{\partial J}{\partial A^{[1]}} \cdot \frac{\partial A^{[1]}}{\partial Z^{[1]}} \\ dZ^{[1]} &= dA^{[1]} \cdot (1 - A^{[1] 2}) \\ \frac{\partial J}{\partial W^{[1]}} &= \frac{\partial J}{\partial Z^{[1]}} \cdot \frac{\partial Z^{[1]}}{\partial W^{[1]}} = dZ^{[1]} X^T \\ dW^{[1]} &= \frac{1}{m} dZ^{[1]} X^T \\ \frac{\partial J}{\partial b^{[1]}} &= \frac{\partial J}{\partial Z^{[1]}} \cdot \frac{\partial Z^{[1]}}{\partial b^{[1]}} = dZ^{[1]} \\ db^{[1]} &= \frac{1}{m} \sum dZ^{[1]} \end{aligned} \]Step 3 : 결론
우리는 다음과 같은 미분값을 얻어낼 수 있다.
\[ \begin{aligned} dZ^{[2]} &= A^{[2]} - Y \\ dW^{[2]} &= \frac{1}{m} dZ^{[2]} A^{[1]T} \\ db^{[2]} &= \frac{1}{m} \sum dZ^{[2]} \\ dA^{[1]} &= W^{[2]T} dZ^{[2]} \\ dZ^{[1]} &= dA^{[1]} \cdot (1 - A^{[1] 2}) \\ dW^{[1]} &= \frac{1}{m} dZ^{[1]} X^T \\ db^{[1]} &= \frac{1}{m} \sum dZ^{[1]} \end{aligned} \]다음은 코드로 나타낸 것이다.
def backward_propagation(parameters, cache, X, Y):
"""
신경망의 역전파 (Backward Propagation) 구현
Arguments:
parameters -- 신경망의 가중치 및 편향을 포함하는 딕셔너리
cache -- 순전파에서 저장한 값들 (Z1, A1, Z2, A2)
X -- 입력 데이터 (n_x, m)
Y -- 실제 레이블 (n_y, m)
Returns:
grads -- 각 가중치 및 편향에 대한 미분 값 (Gradient) 딕셔너리
"""
m = X.shape[1] # 샘플 개수
# 순전파에서 저장한 값 불러오기
A1 = cache["A1"]
A2 = cache["A2"]
W2 = parameters["W2"]
# 1️⃣ 출력층의 미분 계산
dZ2 = A2 - Y # (n_y, m)
dW2 = (1 / m) * np.dot(dZ2, A1.T) # (n_y, n_h)
db2 = (1 / m) * np.sum(dZ2, axis=1, keepdims=True) # (n_y, 1)
# 2️⃣ 은닉층의 미분 계산
dA1 = np.dot(W2.T, dZ2) # (n_h, m)
dZ1 = dA1 * (1 - np.power(A1, 2)) # (n_h, m) → tanh 미분 적용
dW1 = (1 / m) * np.dot(dZ1, X.T) # (n_h, n_x)
db1 = (1 / m) * np.sum(dZ1, axis=1, keepdims=True) # (n_h, 1)
# 결과 저장
grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
return grads
Update Parameters
이제까지 우리는 순전파(Forward Propagation) 를 수행하여 예측값을 계산하고, 역전파(Backward Propagation) 를 통해 손실 함수의 미분값(Gradient)을 구했다.
이제 마지막 단계로, Gradient Descent (경사 하강법) 을 사용하여 가중치와 편향을 업데이트하는 과정을 정리하자.
Gradient Descent (경사 하강법) 은 모델이 손실 함수의 최솟값을 찾도록 하는 최적화 기법이다. 즉, 손실 함수 J 가 최소가 되는 가중치 W 와 편향 b 를 찾는 과정이다.
가중치 및 편향을 업데이트하는 기본 공식은 다음과 같다.
\[ \theta = \theta - \alpha \frac{\partial J}{\partial \theta} \]- \theta : 업데이트할 매개변수 ( W 또는 b )
- \alpha : 학습률 (learning rate) - 업데이트 크기를 조절하는 하이퍼파라미터
- \frac{\partial J}{\partial \theta} : 매개변수에 대한 손실 함수의 미분값 (Gradient)
이 공식을 이용하여 각 층의 가중치와 편향을 업데이트한다.
def update_parameters(parameters, grads, learning_rate):
"""
가중치 및 편향을 경사 하강법(Gradient Descent)으로 업데이트하는 함수
Arguments:
parameters -- 현재 가중치 및 편향 딕셔너리 (W1, b1, W2, b2)
grads -- 역전파를 통해 계산된 미분값 딕셔너리 (dW1, db1, dW2, db2)
learning_rate -- 학습률 (alpha)
Returns:
parameters -- 업데이트된 가중치 및 편향
"""
# 딕셔너리 복사 (원본 데이터 손상 방지)
parameters = copy.deepcopy(parameters)
# 업데이트 공식 적용
parameters["W1"] -= learning_rate * grads["dW1"]
parameters["b1"] -= learning_rate * grads["db1"]
parameters["W2"] -= learning_rate * grads["dW2"]
parameters["b2"] -= learning_rate * grads["db2"]
return parameters
Neural Network 통합 및 최종 모델 구현
이제까지 우리는 순전파(Forward Propagation), 손실 함수 계산(Cost Computation), 역전파(Backward Propagation), 경사 하강법(Gradient Descent) 적용 등의 개별 단계를 구현했다. 이제 이 모든 요소를 하나의 신경망 모델에 통합하여 최종적으로 동작하는 모델을 구축해보자.
우리는 아래의 과정들을 하나의 함수 nn_model() 에 통합해야 한다.
- Neural Network 구조 정의
- 가중치 초기화
- Gradient Descent 루프 실행
- 순전파 실행 (Forward Propagation)
- 손실 함수 계산 (Compute Cost)
- 역전파 실행 (Backward Propagation)
- 가중치 업데이트 (Update Parameters)
import numpy as np
def nn_model(X, Y, n_h, num_iterations=10000, learning_rate=0.01, print_cost=False):
"""
신경망 모델을 학습하는 함수
Arguments:
X -- 입력 데이터 (features, m)
Y -- 레이블 데이터 (1, m)
n_h -- 은닉층 크기
num_iterations -- 경사 하강법 반복 횟수
learning_rate -- 학습률
print_cost -- True일 경우 1000회 반복마다 비용 출력
Returns:
parameters -- 학습된 가중치 및 편향
"""
np.random.seed(3)
# 입력 및 출력 크기 설정
n_x = X.shape[0]
n_y = Y.shape[0]
# 1. 가중치 초기화
parameters = initialize_parameters(n_x, n_h, n_y)
# 2. Gradient Descent 루프 실행
for i in range(num_iterations):
# 순전파 (Forward Propagation)
A2, cache = forward_propagation(X, parameters)
# 비용 (Cost) 계산
cost = compute_cost(A2, Y)
# 역전파 (Backward Propagation)
grads = backward_propagation(parameters, cache, X, Y)
# 가중치 업데이트 (Update Parameters)
parameters = update_parameters(parameters, grads, learning_rate)
# 비용 출력
if print_cost and i % 1000 == 0:
print(f"Cost after iteration {i}: {cost:.6f}")
return parameters
은닉층 크기 n_h 를 변경하면 어떻게 될까? 아래 실험을 통해 은닉층 크기(노드 수)가 신경망의 성능에 미치는 영향을 살펴보자.
plt.figure(figsize=(16, 32))
hidden_layer_sizes = [1, 2, 3, 4, 5]
for i, n_h in enumerate(hidden_layer_sizes):
plt.subplot(5, 2, i+1)
plt.title(f'Hidden Layer of size {n_h}')
# 모델 학습
parameters = nn_model(X, Y, n_h=n_h, num_iterations=5000, learning_rate=0.01)
# 결정 경계 시각화
plot_decision_boundary(lambda x: predict(parameters, x.T), X, Y)
# 정확도 평가
predictions = predict(parameters, X)
accuracy = float((np.dot(Y, predictions.T) + np.dot(1 - Y, 1 - predictions.T)) / float(Y.size) * 100)
print(f"Accuracy for {n_h} hidden units: {accuracy:.2f}%")
- 은닉층이 1~2개일 경우 → 학습이 잘 안 됨 (선형적인 결정 경계)
- 은닉층이 3~5개일 경우 → 학습 성능이 향상됨 (비선형적인 결정 경계)
- 은닉층이 너무 많으면? → 과적합(Overfitting) 위험 존재.