// 아래 내용은 [밑바닥부터 시작하는 딥러닝]으로 학습한 내용을 정리하였다.
신경망의 예
신경망을 그림으로 표현하면 아래와 같다. 왼쪽 부터 순서대로 입력층, 은닉층, 출력층이라고 하는데 은닉층의 뉴런은 사람 눈에는 보이지 않는다.

퍼셉트론 복습
아래 그림은 x1과 x2라는 두 신호를 입력받아 y를 출력하는 퍼셉트론으로 수식으로 표현하면 오른쪽 그림과 같다.


위의 수식에서 b는 편향을 나타내는 매개변수로, 뉴런이 얼마나 쉽게 활성화되느냐를 제어한다. w1과 w2는 각 신호의 가중치를 나타내는 매개변수로, 각 신호의 영향력을 제어한다. 위의 네트워크에는 편향 b가 안보이는데 편향을 명시하려면, 가중치가 b이고 입력이 1인 뉴런을 추가해서 아래의 그림과 같이 나타낼 수 있다.

이 퍼셉트론의 동작은 x1, x2, 1이라는 3개의 신호가 뉴런에 입력 되고, 각 신호에 가중치를 곱한 뒤, 다음 뉴런에 전달되는 것이다. 그 다음 뉴런에서는 이 신호들의 값을 더해 그 합이 0을 넘으면 1을, 그렇지 않으면 1을 출력한다.
위의 식을 더 간결한 형태로 작성하기 위해서 조건 분기의 동작을 하나의 함수로 나타내고, 이를 h(x)라고 하면 아래와 같이 표현할 수 있다.


왼쪽 식은 입력 신호의 총합이 h(x)라는 함수를 거쳐 변환되어, 그 변환된 값이 y의 출력이 됨을 보여준다. 오른쪽 식은 h(x) 함수는 입력이 0을 넘으면 1을, 그렇지 않으면 0을 반환한다.
활성화 함수의 등장
활성화 함수(activation function): 입력 신호의 총합을 출력 신호로 변환하는 함수. 즉, 입력 신호의 총합이 활성화를 일으키는지 정하는 역할을 한다.
위의 왼쪽 식은 아래처럼 두 가지 식으로 나눌 수 있는데, 간단히 설명하자면 첫 번째 식은 가중치가 달린 입력 신호와 편향의 총합을 계산하고 이를 a라고 표현하고, 두 번째 식은 a를 함수 h()에 넣어 y를 출력하는 흐름이다.

활성화 함수의 처리과정을 그림으로 나타내면 아래와 같은데, 가중치 신호를 조합한 결과가 a라는 노드(뉴런)가 되고, 활성화 함수 h()를 통과해 y라는 노드로 변환되는 과정을 나타내고 있다.

활성화 함수: 시그모이드 함수(Sigmoid Function)
시그모이드 함수: s자형 곡선을 가지는 수학 함수
수식은 다음과 같다.

신경망에서는 활성화 함수로 시그모이드 함수를 이용해 신호를 변환하고, 그 변환된 신호를 다음 뉴런에 전달한다.
시그모이드 함수 구현하기
def sigmoid(x):
return 1 / (1 + np.exp(-x))
여기서 np.exp(-x)는 exp(-x) 수식에 해당하며, 인수 x가 넘파이 배열이어도 올바른 결과가 나올 수 있다.
x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)
# array([0.26894142, 0.73105858, 0.88079708])
이 함수가 넘파이 배열도 훌륭히 처리하는 데는 넘파이의 브로드캐스트와 관련이 있다.
여기서 브로드캐스트 기능은 넘파이 배열과 스칼라 값의 연산을 넘파이 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행하는 것이다.
시그모이드 함수를 그래프로 그리는 코드는 y를 출력하는 함수를 sigmoid로 변경하면 된다.
x = np.arrange(-5.0, 5.0, 0.1)
y = sigmoid(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축 범위 지정
plt.show()
코드를 실행해보면 아래와 같은 그래프를 볼 수 있다

활성화 함수: 계단 함수(Step Function)
계단 함수: 임계값을 경계로 출력이 바뀌는 활성화 함수. 입력이 0을 넘으면 1을, 그 외에는 0을 출력하는 함수이다.
계단 함수 구현하기
계단 함수를 단순히 구현하면 아래와 같다.
def step_function(x):
if x > 0:
return 1
else:
return 0
이 구현은 단순하고 쉽지만, 인수 x는 실수만 받아들이기 때문에 넘파이 배열을 인수로 넣을 수는 없다.
넘파이 배열도 가능하게 구현하려면 아래와 같이 수정할 수 있다.
def step_function(x):
y = x > 0
return y.astype(np.int)
계단함수를 그래프로 표현하기 위해서는 아래와 같이 구현하면 된다.
import numpy as np
import matplotlib.pylab as plt
def step_function(x):
return np.array(x > 0, dtype = np.int)
x = np.arrange([-5.0, 5.0, 0.1])
y = step_function(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축의 범위 지정
plt.show()
np.arrange(-5.0, 5.0, 0.1)은 -5.0에서 5.0 전까지 0.1 간격의 넘파이 배열을 생성한다. 여기서 step_function()은 인수로 받은 넘파이 배열의 원소 각각을 인수로 계단 함수로 실행하고, 그 결과를 다시 배열로 만들어 반환한다. 실행 결과는 다음과 같다.

시그모이드 함수 vs 계단 함수

차이점
① 시그모이드 함수는 부드러운 곡선으로 입력에 따라 출력이 연속적으로 변화하는 것에 반해, 계단함수는 0을 경계로 출력이 갑자기 바뀐다. 시그모이드 함수의 매끈함이 신경망 학습에서 중요한 역할을 함을 기억하자!
② 계단함수는 0과 1 중 하나의 값만 반환하지만. 시그모이드 함수는 실수를 반환한다.
공통점
① 둘 다 입력이 작을 때의 출력은 0에 가깝고, 입력이 커지면 출력이 1에 가까워지는 구조이다.
② 입력이 아무리 작거나 커도 출력은 0에서 1 사이이다.
비선형 함수
계단 함수와 시그모이드 함수의 가장 중요한 공통점은 둘다 비선형 함수라는 것이다. 신경망에서는 선형 함수를 이용하면 신경망의 층을 깊게 하는 의미가 없어지기 때문에 활성화 함수로 무조건 비선형 함수를 사용해야한다.
ReLU 함수(Rectified Linear Unit)
신경망 분야에서는 오래전부터 시그모이드 함수를 이용해왔지만, 최근에는 ReLU함수를 주로 이용한다.
ReLU 함수: 입력이 0을 넘으면 그 입력을 그대로, 0 이하이면 0을 출력하는 함수

ReLU 함수의 수식은 다음과 같다.

렐루 함수는 아래와 같은 코드로 쉽게 구현해서 쓸 수 있고, 넘파이의 maximum 함수를 사용한다.
def relu(x):
return np.maximum(0, x)
다차원 배열
다차원 배열의 기본은 '숫자의 집합'이다.
아래는 1차원 배열을 구현한 코드이며, 여기서 np.ndim() 함수로 배열의 차원의 수를 확인할 수 있다.
import numpy as np
A = np.array([1, 2, 3, 4])
print(A)
# [1 2 3 4]
np.ndim(A)
# 1
A.shape
(4,)
A.shape[0]
4
배열의 형상은 인스턴스 변수인 shape으로 알 수 있다. 위 코드에서 A.shape은 튜플을 반환한다는 것에 주의하자
2차원 배열 부터는 처음 차원은 0번째 차원, 다음 차원은 1번째 차원에 대응한다. 특히 2차원 배열은 행렬이라고 부르며, 여기서 배열의 가로 방향은 행(row), 세로 방향은 열(column)이라고 한다.

행렬의 내적(행렬 곱)
행렬의 내적을 구하는 방법을 알아보자!
행렬 내적은 왼쪽 행렬의 행과 오른쪽 행렬의 열을 원소별로 곱하고 그 값들을 더해서 계산 후, 그 계산 결과가 새로운 다차원 배열의 원소가 된다. 아래의 그림은 2 x 2 행렬의 내적을 계산하는 것을 예를 들어 설명한 것이다.

이 계산을 파이썬으로 구현하면 아래와 같다. 두 행렬의 내적은 넘파이 함수 np.dot()으로 계산하는데, 여기서 np.dot()은 넘파이 배열 2개를 인수로 받아 그 내적을 반환한다. 단, np.dot(A, B)와 np.dot(B, A)는 다른 값이 될 수 있다. 즉, 행렬의 곱에서는 피연산자의 순서가 다르면 결과도 다르다.
A = np.array([[1, 2], [3, 4]])
A.shape
# (2, 2)
B = np.array([[5, 6], [7, 8]])
B.shape
# (2, 2)
np.dot(A, B)
array([[19, 22],
[43, 50]])
형상이 다른 행렬의 내적도 같은 방법으로 구할 수 있다. 예를 들어, 2 X 3 행렬과 3 X 2 행렬의 곱을 구현해보면 다음과 같다.
A = np.array([[1, 2, 3], [4, 5, 6]])
A.shape
# (2, 3)
B = np.array([[1, 2], [3, 4], [5, 6]])
B.shape
# (3, 2)
np.dot(A, B)
array([[22, 23],
[49, 64]])
이때 행렬의 형상에 주의해야 한다. 행렬 A의 1번째 차원의 열 수와 행렬 B의 0번째 차원의 행 수가 같아야한다, 즉 다차원 배열을 곱하려면 두 행렬의 대응하는 차원의 원소 수를 일치시켜야 한다는 것이다.
신경망의 내적
넘파이 행렬을 써서 신경망을 구현하는 것을 아래의 신경망을 사용해 구현해보자.

X = np.array([1, 2])
X.shape
# (2,)
W = np.array([[1, 3, 5], [2, 4, 6]])
print(W)
# [[1 3 5]
# [2 4 6]]
W.shape
(2, 3)
Y = np.dot(X, W)
print(Y)
# [ 5 11 17]
np.dot 함수를 사용하면 단번에 결과 Y를 계산할 수 있는데, 이 기능은 신경망을 구현할 때 매우 중요하다.
3층 신경망 구현하기
아래의 그림과 같은 3층 신경망에서 수행되는 입력부터 출력까지의 처리를 넘파이의 다차원 배열을 사용하여 구현해보도록 하자.

여기서는 아래와 같은 표기법을 사용할 건데, 뉴런의 오른쪽 위의 (1)은 1층의 가중치, 그 아래 두 숫자는 각각 다음 층 뉴런과 앞 층 ㄴ런의 인덱스 번호이다.


1) 각 층의 신호 전달 구현하기
① 1층의 첫 번째 뉴런으로 가는 신호

위의 그림에서 a1은 가중치를 곱한 신호 두 개와 편향을 합해서 다음과 같은 수식으로 나타낼 수 있다.

여기서 행렬 내적을 이용해 1층의 가중치 부분을 아래와 같이 간소화하면 다음과 같다.


넘파이의 다차원 배열을 사용해 위에서 간소화한 식을 구현해보자.
X = np.array([1.0, 0.5])
W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])
print(W1.shape) # (2, 3)
print(X.shape) # (2,)
print(B1.shape) # (3,)
A1 = np.dot(X, W1) + B1
② 1층의 활성화 함수에서의 처리

위처럼 은닉층에서의 가중치 합을 a로 표기하고 활성화 함수 h()로 변환된 신호를 z로 표기하며, 활성화 함수로는 시그모이드 함수를 사용한다.
Z1 = sigmoid(A1)
print(A1) # [0.3, 0.7, 1.1]
print(Z1) # [0.57444252, 0.66818777, 0.75026011]
이 시그모이드 함수는 넘파이 배열을 받아 같은 수의 원소로 구성된 넘파이 배열을 반환한다.
③ 1층에서 2층으로의 신호 전달

W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
B2 = np.array([0.1, 0.2])
print(Z1.shape) # (3,)
print(W2.shape) # (3, 2)
print(B2.shape) # (2,)
A2 = np.dot(Z1, W2) + B2
Z2 + sigmoid(A2)
이 구현은 1층의 출력 Z1이 2층의 입력이 되는 점을 제외하면 이전의 구현과 동일하다.
④ 2층에서 출력층으로의 신호 전달

def identity_function(x):
return x
W3 = np.array([[0.1, 0.3], [0.2, 0.4]])
B3 = np.array([0.1, 0.2])
A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3) # 혹은 Y = A3
여기서는 입력을 그대로 출력하는 항등함수 identity_function()을 출력층의 활성화 함수로서 사용했다. 위의 그림에서는 출력층의 활성화 함수를 σ()로 표시해 은닉층의 활성화 함수 h()와는 다름을 명시했다.
2) 구현 정리
지금까지의구현을 정리하면 아래와 같이 나타낼 수 있다.
def init_network():
network = {}
network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
network['b1'] = np.array([0.1, 0.2, 0.3])
network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
network['b2'] = np.array([0.1, 0.2])
network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
network['b3'] = np.array([0.1, 0.2])
return network
def forward(network, x):
W1, W2, W3 = network['W1'], network['W2'], network['W3']
b1, b2, b3 = network['b1'], network['b2'], network['b3']
a1 = np.dot(x, W1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid(a2)
a3 = np.dot(z2, W3) + b3
y = identity_function(a3)
return y
network = init_network()
x = np.array([1.0, 0.5])
y = forward(network, x)
print(y) # [0.31682708 0.69627909]
이 코드에서 init_network() 함수는 가중치와 편향을 초기화하고 이들을 딕셔너리 변수인 network에 저장하고, forward() 함수는 입력 신호를 출력으로 변환하는 처리 과정을 모두 구현하고 있다.
출력층 설계
1) 항등 함수(identity function): 입력과 출력이 항상 같은 함수

2) 소프트맥스 함수(softmax function)
수식으로 표현하면 다음과 같다.

exp(x)는 지수함수이며, n은 출력층의 뉴런 수, yk는 그 중 k번째 출력임을 뜻한다. 그림으로 표현하면 소프트맥스의 출력은 모든 입력 신호로부터 화살표를 받음을 알 수 있다.

소프트맥스 함수는 다음과 같이 구할 수 있다.
a = np.array([0.3, 2.9, 4.0])
exp_a = np.exp(a) # 지수함수
print(exp_a)
# [ 1.34985881 18.17414537 54.59815003]
sum_exp_a = np.sum(exp_a) # 지수 함수의 합
print(sum_exp_a)
# 74.1221542102
y = exp_a / sum_exp_a
print(y)
# [ 0.01821127 0.24519181 0.73659691]
소프트맥스 함수 구현 시 주의점
위에서 구현한 softmax() 함수의 코드는 식을 제대로 표현하고 있지만, 컴퓨터로 계산할 때는 오버플로 문제가 발생한다.
Why? 소프트맥스 함수는 쉽게 아주 큰 값을 내뱉는 지수함수를 사용하는데, 이런 큰 값끼리 나눗셈을 하면 결과 수치가 불안정해지기 때문에!
이 문제를 해결하도록 개선한 소프트맥스 함수의 수식이다.

이 식은 소프트맥스의 지수 함수를 계산할 때 어떤 정수를 더하거나 빼도 결과는 바뀌지 않음을 나타낸다. 여기서 C'에 어떤 값을 대입해도 상관없지만, 오버플로를 막을 목적으로는 입력 신호 중 최댓값을 이용하는 것이 일반적이다. 이를 바탕으로 개선한 소프트맥스 함수의 코드는 다음과 같다.
def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c) # 오버플로 대책
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a
return y
소프트맥스 함수의 특징
softmax() 함수를 사용하면 신경망의 출력은 다음과 같이 계산할 수 있는데, 소프트맥스 함수의 출력은 0에서 1.0 사이의 실수이고, 출력의 총합은 1이다. 소프트맥스 함수의 출력은 확률로 해석할 수 있어, 문제를 통계적으로 대응할 수 있게 된다.
a = np.array([0.3, 2.9, 4.0])
y = softmax(a)
print(y)
# [ 0.01821127 0.24519181 0.73659691]
np.sum(y)
# 1.0
주의점은 소프트맥스 함수를 적용해도 각 원소의 대소 관계는 변하지 않는다는 것이다.
Why? 지수함수 y = exp(x)가 단조 증가 함수이기 때문!
신경망을 이용한 분류에서는 일반적으로 가장 큰 출력을 내는 뉴런에 해당하는 클래스로만 인식하며, 소프트맥스 함수를 적용해도 출력이 가장 큰 뉴런의 위치는 달라지지 않는다.
=> 신경망으로 분류할 때는 출력층의 소프트맥스 함수 생략 가능!
'AI > Deep Learning' 카테고리의 다른 글
| [Deep Learning for Scratch] 오차역전파법 (0) | 2021.06.06 |
|---|---|
| [Deep Learning for Scratch] 신경망 학습 (0) | 2021.05.25 |
| [Deep Learning for Scratch] 퍼셉트론 (0) | 2021.05.23 |