[밑바닥부터 시작하는 딥러닝1] 교재 2장을 기반으로 작성되었습니다.
퍼셉트론으로 복잡한 처리까지 할 수 있었지만, 가중치를 적절하게 사람이 설정해주어야하는 불편함이 있었다. 이 불편함을 신경망이 해결해줄 수 있다.
신경망은 가중치 매개변수의 적절한 값을 데이터로부터 자동으로 학습하는 능력이 있다!
은닉층은 입력층이나 출력층과 달리 말 그대로 사람 눈에 보이지 않기 때문에 붙여진 이름이다.
이전에 본 퍼셉트론과 특별히 달라 보이지 않는다. 이번 장에선 이 둘의 차이점을 알아볼 것이다.
차이점을 알아보기 위해 퍼셉트론을 복습해보자.
두 신호를 입력받는 퍼셉트론은 위와 같이 나타내어진다. (b=편향, w1,w2=가중치)
하지만 [식 3.1]과 달리 [그림 3-2]에서는 편향 b가 보이지 않는데, 편향 b를 명시하면 아래와 같다.
이에 더해, [식3.1]을 더 간결한 형태로 나타내면 아래와 같다.
여기서 신호의 총합이 0을 넘으면 1을 출력하고, 그렇지 않으면 0을 출력하는 함수를 h(x)로 나타내주었다. (중요)
위의 h(x), 입력 신호의 총합을 출력 신호로 변환하는 함수를 활성화 함수라고 한다.
이 함수는 입력 신호의 총합이 '활성화'를 일으키는지 정하는 역할을 한다.
활성화 함수의 처리 과정을 나타내면 아래와 같다.
퍼셉트론은 계단 함수를 활성화 함수로 사용하고,
신경망은 시그모이드와 같은 매끈한 활성화 함수를 사용한다.
+) 퍼셉트론과 신경망의 가장 큰 차이는 '활성화 함수의 차이'이다!!!
신경망에서 자주 이용하는 활성화 함수인 시그모이드 함수를 식으로 나타내면 아래와 같다.
시그모이드 함수의 구현과 그래프는 아래에서 알아보자.
퍼셉트론에서 사용되는 활성화 함수인 계단 함수를 코드로 구현해보자.
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
x = np.array([-1.0, 1.0, 2.0])
x
# >>> array([-1., 1., 2.])
y = x > 0
y
# >>> array([False, True, True])
y = y.astype(int) # 위의 bool 타입을 int 타입으로 타입변환 🚨 교재의 np.int는 더이상 안 쓰인다.🚨
y
# >>> array([0, 1, 1])
이제 계단 함수의 그래프를 그려보자.
import numpy as np
import matplotlib.pylab as plt
def step_function(x):
return np.array(x>0, dtype=int) # 🚨 교재의 np.int는 더이상 안 쓰인다.🚨
x = np.arange(-5.0, 5.0, 0.1)
y = step_function(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축의 범위 지정
plt.show()
계단 함수는 0을 경계로 출력이 0에서 1로 바뀐다. 이름처럼 '계단'처럼 생긴 것을 확인할 수 있다.
이제 시그모이드 함수를 구현해보자.
def sigmoid(x):
return 1 / (1 + np.exp(-x))
위 코드는 넘파이 배열도 제대로 처리해주는데, 브로드캐스팅 기능 덕분이다. 아래를 통해 확인/복습해보자.
"""넘파이 배열 처리 확인"""
x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)
# >>> array([0.26894142, 0.73105858, 0.88079708])
"""브로드 캐스팅 복습"""
t = np.array([1.0, 2.0, 3.0])
1.0 + t
# >>> array([2., 3., 4.])
1.0 / t
# >>> array([1. , 0.5 , 0.33333333])
"""
[브로드 캐스트]
넘파이 배열과 스칼라값의 연산을 넘파이 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행해준다.
"""
이제 시그모이드 함수의 그래프를 그려보자.
x = np.arange(-5.0, 5.0, 0.1)
y = sigmoid(x)
plt.plot(x, y)
plt.ylim(-0.1, 1.1) # y축 범위 지정
plt.show()
시그모이드(sigmoid)란 'S자 모양'이라는 뜻이다. 계단 함수처럼 그 모양을 따 이름을 지은 것이다.
차이점
- 시그모이드 함수는 '매끄러운' 반면, 계단 함수는 출력이 갑자기 바뀌어버린다.(0을 경계로)
- 시그모이드 함수는 실수(0.731, ..., 0.880 등)를 돌려주는 반면 계단함수는 0과 1 중 하나의 값만 돌려준다. 즉, 신경망에서는 '연속적인 실수'가 흐르고, 퍼셉트론에서는 0 혹은 1만 흐른다.
공통점
- 입력이 중요하면 큰 값을 출력하고, 중요하지 않으면 작은 값을 출력한다.
- 비선형 함수이다.
계단 함수와 시그모이드 함수는 공통적으로 '비선형 함수'인데,
계단 함수는 구부러진 직선, 시그모이드 함수는 곡선으로 나타나기 때문이다.
신경망에서는 활성화 함수로 비선형 함수를 사용해야만 하는데,
선형 함수를 이용하면 신경망의 층을 깊게 하는 의미가 없어지기 때문이다.
의미가 없어지는 이유는,
층을 깊게 만든 네트워크가 '층이 하나밖에 없는(은닉층이 없는) 네트워크' 와 똑같은 기능을 하기 때문이다.
h(x) = c*x 를 활성화 함수(선형함수)로 사용한 3층 네트워크를 생각해보면,
y(x) = h(h(h(x)))로 나타내지는데, 이는 곧 y(x) = c^3*x로 나타내지고,
이는 곧 a = c^3 인 y(x) = a*x와 같게 된다.
즉, 층이 하나밖에 없는(은닉층이 없는) 네트워크로 표현이 된다.
최근에는 시그모이드 함수 대신 ReLU(Rectified Linear Unit) 함수를 주로 이용한다.
ReLU는 입력이 0을 넘으면 그 입력을 그대로 출력하고, 0 이하면 0을 출력하는 함수이다.
그래프에 수식에서 보이듯이 ReLU는 간단한 함수인데, 구현도 아래와 같이 간단하다.
def relu(x):
return np.maximum(0, x)
신경망을 구현하기에 앞서, 이를 효율적으로 구현하기 위해
넘파이의 다차원 배열을 사용한 계산법에 대해 알아보자.
우선 1차원 배열을 작성해보자.
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]
이어서 2차원 배열을 작성해보자.
B = np.array([[1,2], [3,4], [5,6]])
print(B)
# >>> [[1 2]
# [3 4]
# [5 6]]
np.ndim(B) # 배열의 차원 수 확인
# >>> 2
B.shape # 배열의 형상 확인
# >>> (3, 2)
알다시피, 위와 같은 2차원 배열은 행렬이라고 부른다.
알고 있겠지만, 행렬의 곱을 구하는 방법은 아래와 같다.
이를 코드로 구현하면 아래와 같다.
"""형상이 2x2로 같은 행렬의 곱"""
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]])
"""형상이 2x3 과 3x2로 다른 행렬의 곱"""
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, 28],
# [49, 64]])
"""🚨 형상이 2x2 와 2x3 으로 차원의 원소 수가 일치하지 않는 행렬의 곱 -> 오류🚨"""
C = np.array([[1,2], [3,4]])
C.shape
# >>> (2, 2)
A.shape
# >>> (2, 3)
np.dot(A, C)
# >>>
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-41-bb5afb89b162> in <cell line: 1>()
----> 1 np.dot(A, C)
ValueError: shapes (2,3) and (2,2) not aligned: 3 (dim 1) != 2 (dim 0)
[그림 3-13]을 구현하면 아래와 같다.
A = np.array([[1,2], [3,4], [5,6]])
A.shape
# >>> (3, 2)
B = np.array([7,8])
B.shape
# >>> (2,)
np.dot(A, B) # 행렬의 곱 연산
# >>> array([23, 53, 83])
넘파이 행렬을 사용해서, 편향과 활성화 함수를 생략한 간단한 신경망을 구현해보자.
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층 신경망에서 수행되는 입력부터 출력까지의 처리(순방향)를 구현해보자.
표기법 설명
먼저, '입력층 -> 1층의 첫번째 뉴런' 으로 가는 신호를 살펴보자.
행렬의 곱을 이용하면 1층의 '가중치 부분'을 아래처럼 간소화 할 수 있다.
이때 각 표기는 아래와 같다.
그럼 [식 3.9]를 코드로 구현해보자.
""" 입력층 -> 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층의 '활성화 함수'에서의 처리를 살펴보자.
이를 코드로 구현하면 아래와 같다.
""" 입력층 -> 1층 활성화 함수 부분 """
Z1 = sigmoid(A1)
print(A1) # >>> [0.3 0.7 1.1]
print(Z1) # >>> [0.57444252 0.66818777 0.75026011]
이제 1층에서 2층으로의 신호 전달 과정과 구현을 살펴보자.
""" 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)
마지막으로 2층에서 출력층으로의 신호 전달 과정과 구현을 살펴보자.
""" 2층 -> 3층 신호 전달 """
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()
을 정의하고 사용했지만,
실제로는 모델이 풀고자 하는 문제의 성질에 맞게 활성화 함수를 적용한다.
회귀에는 항등 함수를,
2 클래스 분류 (이중 분류)에는 시그모이드 함수를,
다중 클래스 분류(다중 분류)에는 소프트맥스 함수를 사용한다.
def init_network():
network = {}
network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]]) #2*3
network['b1'] = np.array([0.1, 0.2, 0.3]) #1*3
network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]]) #3*2
network['b2'] = np.array([0.1, 0.2]) #1*2
network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]]) #2*2
network['b3'] = np.array([0.1, 0.2]) #1*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]) #1*2
y = forward(network, x)
print(y)