[Coursera] Neural Networks and Deep Learning
1. Basics of Neural Networks Programming
(1) Logistic Regression as a Neural Network
로지스틱 회귀는 이진 분류를 위한 알고리즘으로, y = σ (w^T x + b)로 나타낼 수 있다. 여기서 시그모이드 함수는 결과값을 0과 1 사이의 값으로 변환시켜주는 역할을 한다.
코세라 과정에서는 이 매우 간단한 알고리즘을 통해 뉴럴 네트워크의 개념을 살펴보게 된다. 64*64픽셀의 이미지에서 해당 이미지가 고양이 이미지인지 여부를 분류한다고 하자. 이미지에는 R, G, B 각각에 대해 채도값이 각각 존재하므로, 하나의 이미지에는 64*64*3 = 12288의 숫자(= n_x)가 존재한다. 이 숫자들을 일렬로 세워 1*12288의 벡터로 만들 수 있고, 이는 1 또는 0의 y로 매핑된다. 훈련 데이터 m쌍이 모이면 이들을 전치시킨 뒤 열로 쌓아 다음과 같이 행렬로 구성할 수 있다.
로지스틱 회귀에서는 MSE가 아닌 Binary Cross-Entropy손실함수를 사용한다. MSE의 경우 시그모이드 함수에서 비볼록 함수가 되어 로컬 미니멈에 빠지게 될 위험이 있으나, Binary Cross-Entropy 손실은 시그모이드 출력과 함께 사용될 때 볼록구조가 되어 경사하강법으로 잘 최적화되기 때문이라고 한다.
위는 단일 데이터에 대한 손실(loss)이고, 전체 데이터셋에 대한 비용(cost)는 각 데이터의 손실값의 평균이 된다.
우리가 원하는 것은 이 비용함수 J를 최소화 시키는 w와 b를 찾는 것이다. 그리고 앞서 언급한 것과 같이 이 J는 볼록함수임이 밝혀져 있다. (한가지 짚고 넘어가면 볼록함수(convex)라 함은 로컬 미니멈이 글로벌 미니멈이 된다는 의미이다) 그리고 익히 공부한 바와 같이 경사하강법을 통해 글로벌 미니멈에서의 w와 b의 값을 찾아낼 수 있다.
여기서 편미분항은 이제 좀더 간결하게 변수 dw로 쓰게 된다. 즉, w - α dw가 된다.
신경망에서의 계산은 1) 순방향 전파를 통해 신경망의 출력값 계산, 2) 역방향 전파로 도함수 계산으로 구성된다. Computation Graph를 통해 이를 직관적으로 살펴볼 수 있다. J(a, b, c) = 3(a + bc)라고 하면 계산 그래프는 다음과 같다.
순방향으로 출력값이 계산되는 것은 누가보더라도 바로 이해가 된다. 중요한 것은 역전파이다. 이전에 학습했던 것을 복기해보면 역전파에서는 연쇄법칙이 중요한 역할을 하게 된다. 역전파는 연쇄법칙에 기반하여, 출력에 대한 손실 함수의 기울기를 각 층별 파라미터에 대한 미분값으로 곱의 형태로 분해할 수 있게 해준다. 이 과정은 복잡한 전체 미분 문제를 단계별 도함수 계산의 조합 문제로 단순화시키며, 이는 프로그래밍적으로도 효율적이다.
다시 로지스틱 회귀로 돌아와서, 이를 계산 그래프를 활용하여 경사 하강법을 적용시켜보자. 처음 주어지는 식은 다음과 같다.
강의에서는 두 개의 특성이 있다고 가정하고 다음과 같이 계산그래프를 그렸다.
역전파 방향에 따라 da, dz, dw, db 순서대로 구해보면 각각 da = -y/a + (1-y)/(1-a), dz = a - y, dw = x * dz, db = dz임을 알 수 있다. 이는 1개의 데이터에 대한 손실을 구한 것이고, m개의 데이터쌍에 대하여 비용 함수를 구하는 과정을 코드로 살펴보면 다음과 같다.
# 파라미터 초기화
w1 = 0.0
w2 = 0.0
b = 0.0
# 반복 (i = 1 to m)
for i in range(m):
x1 = X[i][0]
x2 = X[i][1]
y = Y[i]
z = w1 * x1 + w2 * x2 + b
a = sigmoid(z)
J += - (y * np.log(a) + (1 - y) * np.log(1 - a))
dz = a - y
dw1 += x1 * dz
dw2 += x2 * dz
db += dz
# 평균으로 나눔
J /= m
dw1 /= m
dw2 /= m
db /= m
# 경사하강법으로 파라미터 업데이트 (학습률 alpha 사용)
alpha = 0.1
w1 = w1 - alpha * dw1
w2 = w2 - alpha * dw2
b = b - alpha * db
(2) Python and Vectorization
위에서 구현한 코드를 살펴보면 반복문이 하나 들어가 있다. 그리고 사실 위 코드에서는 두 개의 특성만 고려하였으나, 실제로 고려해야 하는 특성의 수가 훨씬 많기 때문에 여기서도 반복문이 필요하게 된다.
그런데 딥러닝 알고리즘을 구현할 때 이러한 명시적인 반복문은 알고리즘을 비효율적으로 만들게 된다. 즉, 속도가 느려진다는 것인데, 이를 해결해주는 것이 '벡터화'이다. CPU나 GPU는 SIMD(Single Instruction Multiple Data: 하나의 명령으로 여러 데이터를 동시에 처리하는 방식)를 지원하며, 벡터화를 통해 이러한 SIMD 명령이 활용되면, 행렬 곱 연산과 같은 반복 계산을 병렬로 수행할 수 있어 연산 속도와 자원 효율이 크게 향상되는 것이다. 이와 달리 for문을 통한 계산의 경우 각 반복을 순차적으로 실행하여 직렬로 처리하기 때문에 병렬 계산의 장점을 살릴 수 없게 된다.
z = w^T * x + b를 각각의 방식으로 구현해보면 다음과 같다.
for i in range(m):
x_i = X[i] # i번째 샘플 (벡터)
z[i] = np.dot(w, x_i) + b
### 벡터화
z = np.dot(X, w) + b
앞서 반복문으로 구현한 로지스틱 회귀를 벡터화로 구현해보면 다음과 같다.
# === Forward Propagation ===
Z = np.dot(X, w) + b # shape: (m, 1)
A = sigmoid(Z) # shape: (m, 1)
dZ = A - Y # shape: (m, 1)
# === Backward Propagation (Gradient 계산) ===
dw = (1 / m) * np.dot(X.T, dZ) # shape: (n, 1)
db = (1 / m) * np.sum(dZ) # 스칼라
# === 파라미터 업데이트 ===
w = w - alpha * dw
b = b - alpha * db
2. Shallow Neural Networks
(1) 신경망 구조
여기까지 살펴 본 로지스틱 회귀의 계산 그래프는 다음과 같다.
위 그림은 다음과 같이 다시 그릴 수 있다.
로지스틱 회귀를 나타내는 위 원은 두 단계의 계산을 포함하고 있는데, 신경망에서는 이 작업을 많이 반복하게 된다.
위 신경망 그림에서는 세 개의 층을 확인할 수 있는데, 1) 세 입력특성 x1, x2, x3로 이루어진 입력 층, 2) 네 개의 노드로 이루어진 은닉층, 3) 하나의 노드로 이루어진 출력층이다. 은닉층에서 '은닉'이란 훈련 세트는 x와 y의 값으로 구성되어 있기 때문에 은닉층의 값들은 훈련세트에서 볼 수 없다는 의미이다. 신경망의 층을 셀 때 관례적으로 입력 층은 세지 않기 때문에, 위 신경망은 2층 신경망이 된다.
신경망에서 가중치 w는 0으로 초기화 하면 안된다. 만약 0으로 초기화한다면, 어떤 입력 x에 대해 계산되는 z는 모두 0이고, activation도 전부 동일하고, gradient도 동일하게 계산되어 모든 뉴런의 가중치가 동일하게 업데이트 된다. 결국 각 뉴런이 서로 다른 특징을 학습하지 못해 여러 뉴런을 둔 의미가 사라지게 된다.
(2) 활성화 함수
지금까지는 묻지도 따지지도 않고 두번째 계산에서 시그모이드 함수를 썼었다. 이는 인공 신경망에서 뉴런의 출력값을 비선형적으로 변환하는 활성화 함수이다. 비선형 활성화 함수가 없다면, 은닉층을 아무리 많이 쌓아도 신경망 전체는 하나의 선형 함수로 표현될 수 있기 때문에, 복잡한 비선형 패턴을 학습할 수 없다.
이러한 활성화 함수에는 시그모이드 함수 외에도 다양한 선택지가 존재한다.
- 시그모이드 함수 : 출력의 중심이 0이 아니기 때문에 gradient가 편향되어 흐르고, 다음 층의 입력 분포가 비대칭적으로 변하여 가중치 갱신이 비효율적이 된다. 결국 학습이 느려진다는 단점이 있다. -> 출력층 외에는 잘 사용되지 않음
- 하이퍼볼릭 탄젠트 함수 : 출력의 중심이 0이기 때문에 시그모이드 함수에 비해 성능이 좋으나, 여전히 x가 매우 커지거나 매우 작아지면 기울기가 0에 가까워져 Gradient Vanishing 문제 (역전파를 통해 가중치 학습시 gradient가 점점 작아져 결국 0에 수렴 -> 모델의 초기 층 까지 학습이 거의 이루어지지 않게 되고, 전체 학습이 매우 느려짐)가 존재한다.
- ReLU : Gradient Vanishing 문제를 완화하였다. 그러나 x < 0 에서 기울기가 0이기 때문에 Dead Neuron문제(입력값이 0보다 작으면 gradient가 0이 되어 가중치가 업데이트 되지 않음 : 이러한 상태가 계속되면 해당 뉴런은 영구적으로 비활성 상태가 됨)가 있다.
- Leaky ReLU : Dead Neuron 문제를 완화하기 위해 x < 0에서 작은 기울기를 갖게 하였음 (그러나 ReLU가 대부분의 경우 충분히 잘 동작하고, 간단하고 빠르기 때문에 더 많이 쓰임)
3. Deep Neural Networks
지금까지 은닉층이 1~2개에 불과한 Shallow Neural Networks를 살펴보았는데, 여기서 은닉층이 더 많아지면 Deep Neural Networks가 된다.
위 그림은 CNN이 이미지 데이터를 처리할 때 계층별로 학습하는 feature의 추상화 수준이 어떻게 발전하는지를 설명하고 있다. 초기 레이어는 저수준의 특징을 학습하는데, 레이어가 쌓이면 이전 레이어에서 학습한 패턴을 모아 더욱 고수준의 특징을 학습하게 된다.결국 뉴럴 네트워크의 층이 깊어질수록, '일반적으로' 더욱 복잡한 패턴을 학습할 수 있어 표현력이 커지게 된다.
또한, 딥러닝에서 '층이 깊다'는 것이 왜 중요한지를 설명할 때 자주 인용되는 근거 중 하나는, 어떤 함수들은 얕은 신경망으로 표현하려면 너무 많은 뉴런이 필요하지만, 깊은 신경망에서는 훨씬 적은 자원으로 표현이 가능하다는 것이다. 예를 들어, X1 XOR X2 XOR ... XOR Xn처럼 여러 입력을 XOR 연산으로 연결한 함수를 생각해보자. 이 함수를 얕은 신경망, 즉 은닉층이 하나밖에 없는 네트워크로 구현하려면, 입력 수가 n개면 2ⁿ에 가까운 유닛이 필요하다. 반면, 깊은 신경망을 사용하면, XOR 연산을 계층적으로 처리할 수 있기 때문에 층 수는 log(n) 정도만으로도 같은 함수를 구현할 수 있다. 즉, 깊은 구조가 어떤 함수들을 훨씬 더 효율적으로 표현할 수 있다는 점에서 이론적으로도 표현력이 더 크다는 것을 말해준다.