본문 바로가기
Study/책『밑바닥부터 시작하는 딥러닝』

3-1. 신경망

by 코드포휴먼 2020. 3. 7.

앞장에서 배운 2. 퍼셉트론 관련해서는 좋은 점과 나쁜 점이 존재한다.

좋은 점은 퍼셉트론으로 복잡한 함수도 표현할 수 있다는 것이다.

(ex. 컴퓨터가 수행하는 복잡한 처리도 이론상으로는 퍼셉트론으로 표현 가능하다)

그러나 나쁜 점은 가중치를 설정하는 작업을 여전히 사람이 수동으로 한다는 것이다.

 

신경망은 이 나쁜 점을 해결해준다.

가중치 매개변수의 적절한 값을 데이터로부터 자동으로 학습하는 능력이 신경망의 중요한 성질이다.

 

 

3.1. 퍼셉트론에서 신경망으로

3.1.1. 신경망의 예

신경망을 그림으로 나타내면 아래와 같다.

여기서 가장 왼쪽 줄을 입력층, 맨 오른쪽 줄을 출력층, 중간 줄을 은닉층이라고 한다.

은닉층의 뉴런은 (입력층이나 출력층과 달리) 사람 눈에는 보이지 않는다. 그래서 '은닉'이다.

신경망의 예

입력층을 0층, 은닉층을 1층, 출력층을 2층이라고 부르겠다.

파이썬의 인덱스도 0부터 시작하여 나중에 구현할 때 짝짓기 편하기 때문이다.

모두 3층으로 구성되지만 가중치를 갖는 층은 2개뿐이기 때문에 '2층 신경망'이라고 한다. 

실제로 뉴런이 연결되는 방식은 앞 장의 퍼셉트론에서 달라진 것이 없다.

 

 

3.1.2. 퍼셉트론 복습

신경망의 신호 전달 방법을 보기 전에 퍼셉트론을 복습한다.

x1, x2라는 두 신호를 받아 y를 출력하는 퍼셉트론을 수식으로 나타내면 아래와 같다.

퍼셉트론 수식

b는 편향을 나타내는 매개변수로, 뉴런이 얼마나 쉽게 활성화되느냐를 제어한다.

w1, w2는 각 신호의 가중치를 나타내는 매개변수로, 각 신호의 영향력을 제어한다.

 

다음은 편향을 명시한 퍼셉트론을 나타낸 것이다.

편향을 명시한 퍼셉트론

가중치가 b고 입력이 1인 뉴런이 추가됐다.

이 퍼셉트론의 동작은 x1, x2, 1이라는 3개의 신호가 뉴런에 입력되어, 각 신호에 가중치를 곱한 후 다음 뉴런에 전달된다.

다음 뉴런에서는 이 신호들의 값을 더하여, 그 합이 0을 넘으면 1을 출력하고 그렇지 않으면 0을 출력한다.

참고로, 편향의 입력 신호는 항상 1이기 때문에 그림에서는 해당 뉴런을 회색으로 채워 다른 뉴런과 구별했다.  

 

퍼셉트론 수식을 간결히 작성해보겠다.

이를 위해 조건 분기의 동작(0을 넘으면 1을 출력하고 그렇지 않으면 0을 출력)을 하나의 함수로 나타낸다.

이 함수를 h(x)라 하면 다음과 같이 표현할 수 있다. 

간결한 퍼셉트론 수식

입력 신호의 총합이 h(x)라는 함수를 거쳐 변환되어, 그 변환된 값이 y의 출력이 됨을 보여준다.

 

 

3.1.3. 활성화 함수의 등장

h(x) 함수처럼 입력 신호의 총합을 출력 신호로 변환하는 함수를 활성화 함수(activation function)이라고 한다.

활성화 함수는 입력 신호의 총합이 활성화를 일으키는지를 정하는 역할을 한다.

 

3.1.2.에서 간결화한 수식은 가중치가 곱해진 입력 신호의 총합을 계산하고 그 합을 활성화 함수에 입력해 결과를 내는 2단계로 처리된다. 이를 한번 더 2개의 식으로 나눌 수 있다.

2개의 식으로 나눠진 식

가중치가 달린 입력 신호와 편향의 총합을 계산하고, 이를 a라 한다. 그리고 a를 함수 h()에 넣어 y를 출력하는 흐름이다. 

이 식은 아래처럼 나타낼 수 있다. a는 입력 신호의 총합, h()는 활성화 함수, y는 출력이다. 

활성화 함수의 처리 과정

기존 뉴런의 원을 키우고, 그 안에 활성화 함수의 처리 과정을 명시적으로 그려 넣었다.

즉, 가중치 신호를 조합한 결과가 a라는 노드가 되고, 활성화 함수 h()를 통과하여 y라는 노드로 변환되는 과정이 분명하게 나타나 있다. (※뉴런과 노드 용어를 같은 의미로 사용함)

신경망의 동작을 더 명확히 드러내고자 할 때엔 활성화 처리 과정을 명시하기도 한다. 

왼쪽: 일반적인 뉴런 / 오른쪽: 활성화 처리 과정을 명시한 뉴런

 

더보기

단순 퍼셉트론 : 단층 네트워크에서 계단 함수(임계값을 경계로 출력이 바뀌는 함수)를 활성화 함수로 사용한 모델

다층 퍼셉트론 : 신경망(여러 층으로 구성되고 시그모이드 함수 등의 매끈한 활성화 함수를 사용하는 네트워크)

 

 

3.2. 활성화 함수

3.1.2.에서 보았던 수식의 활성화 함수는 임계값을 경계로 출력이 바뀐다. 이런 함수를 계단 함수(step function)이라 한다.

퍼셉트론에서는 활성화 함수로 계단 함수를 이용한다. 활성화 함수를 계단 함수에서 다른 함수로 변경하는 것이 신경망으로 나아가는 단계다.

 

3.2.1. 시그모이드 함수

아래는 신경망에서 자주 이용하는 활성화 함수인 시그모이드(sigmoid function)을 나타낸 식이다.

시그모이드(sigmoid function)

exp(-x)자연상수 e의 -x제곱을 뜻한다. 자연상수는 2.7182...의 값을 갖는 실수다. 

더보기

e는 다음의 극한식으로 표현되는 값이다.

시그모이드 함수에 1.0과 2.0을 입력하면 h(1.0) = 0.731..., h(2.0) = 0.880...처럼 특정 값을 출력한다.

신경망에서는 활성화 함수로 시그모이드 함수를 이용하여 신호를 변환하고, 그 변환된 신호를 다음 뉴런에 전달한다.

퍼셉트론과 신경망의 주된 차이는 활성화 함수뿐이다. 그 외 뉴런이 여러 층으로 이어지는 구조와 신호를 전달하는 방법은 퍼셉트론과 같다.

 

 

3.2.2. 계단 함수 구현하기

파이썬으로 계단 함수를 구현해보겠다.

계단 함수는 아래처럼 입력이 0을 넘으면 1을 출력하고, 그 외에는 0을 출력하는 함수다. 

def step_function(x):
    if x>0:
        return 1
    else:
        return 0

단순하고 쉬운 구현이지만 인수 x는 실수(부동소수점)만 받아들인다. 

즉, step_function(3.0)은 되지만 step_function(np.array([1.0, 2.0]))처럼 넘파이 배열을 인수로 넣을 순 없다.

 

넘파이 배열도 지원하도록 수정하려면 넘파이의 트릭을 사용하면 된다.

import numpy as np
def step_function(x):
    y = x>0  
    return y.astype(np.int)

step_function(np.array([-1.0, 1.0, 2.0]))   
#array([0, 1, 1])

x라는 넘파이 배열을 준비하고 그 넘파이 배열에 부등호 연산을 수행한다. 
넘파이 배열에 부등호 연산을 수행하면 배열의 원소 각각에 부등호 연산을 수행한 bool 배열이 생성된다. 
y는 bool 배열인데, 우리가 원하는 계단 함수는 0이나 1의 int형을 출력하는 함수다.
그래서 배열 y의 원소를 bool에서 int형으로 바꿔준다. 넘파이 배열의 자료형을 변환할 때는 astype() 메서드를 이용한다. 원하는 자료형을 인수로 지정하면 되며 여기서는 np.int를 지정했다. 파이썬에서는 bool을 int로 변환하면 True는 1로, False는 0으로 변환된다.

 

 

3.2.3. 계단 함수의 그래프

계단 함수를 그래프로 그려본다. matplotlib 라이브러리를 사용한다.

import numpy as np
import matplotlib.pyplot as plt

def step_function(x):
    return np.array(x>0, dtype=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()

arange(-5.0, 5.0, 0.1)은 -5.0에서 5.0전까지 0.1 간격의 넘파이 배열을 생성한다. [-5.0, -4.9, ..., 4.9]
step_function()은 인수로 받은 넘파이 배열의 원소 각각을 인수로 계단 함수 실행하여, 그 결과를 다시 배열로 만들어 돌려준다. 
이 x, y 배열을 그래프로 그리고(plot) 화면에 출력하면(show) 아래처럼 된다.

계단 함수는 0을 경계로 출력이 0에서 1로 바뀐다. 값이 바뀌는 형태가 계단처럼 생겼다.

계단 함수의 그래프

 

 

3.2.4. 시그모이드 함수 구현하기

시그모이드 함수는 파이썬으로 아래처럼 작성할 수 있다.

def sigmoid(x):
	return 1 / (1 + np.exp(-x))

np.exp(-x)는 exp(-x) 수식에 해당한다. 

 

실제로 넘파이 배열을 제대로 처리하는지 실험해보았다.  

x = np.array([-1.0, 1.0, 2.0])
sigmoid(x)   #array([0.26894142, 0.73105858, 0.88079708])

 

이 함수가 넘파이 배열도 훌륭히 처리해줄 수 있는 비밀은 넘파이의 브로드캐스트에 있다. 

브로드캐스트 기능이란 넘파이 배열과 스칼라값의 연산을 넘파이 배열의 원소 각각과 스칼라값의 연산으로 바꿔 수행하는 것이다.

sigmoid 함수에서 np.exp(-x)가 넘파이 배열을 반환하기 때문에 1 / (1 + np.exp(-x)) 도 넘파이 배열의 각 원소에 연산을 수행한 결과를 내어준다.

 

시그모이드 함수를 그래프를 그려본다. 계단 함수 코드와 다른 점은 y를 출력하는 함수가 sigmoid 함수로 변경한 점이다.

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()

 

 

3.2.5. 시그모이드 함수와 계단 함수 비교

시그모이드(sigmoid)란 'S자 모양'이라는 뜻이다. 계단 함수처럼 그 모양을 따 이름 지은 것이다. 

계단 함수(점선)과 시그모이드 함수(실선)

 

차이점

  • 계단 함수는 0을 경계로 출력이 갑자기 바뀌어버린다. 한편 시그모이드 함수는 곡선이며 입력에 따라 출력이 연속적으로 변화한다. 시그모이드 함수의 매끈함은 신경망 학습에서 중요한 역할을 하게 된다.
  • 계단 함수가 0과 1 중 하나의 값만 돌려주는 반면 시그모이드 함수는 실수(0.731, ..., 0.880... 등)를 돌려준다는 점도 다르다. 즉, 퍼셉트론에서는 뉴런 사이에 0 혹은 1이 흘렀다면, 신경망에서는 연속적인 실수가 흐른다. 

 

공통점

  • 두 함수는 매끄러움이라는 점에서는 다르지만 큰 관점에서는 같은 모양을 하고 있다.
  • 둘 다 입력이 작을 때의 출력은 0에 가깝고(혹은 0이고), 입력이 커지면 출력이 1에 가까워지는(혹은 1이 되는) 구조다. 즉, 입력이 중요하면 큰 값을 출력하고 입력이 중요하지 않으면 작은 값을 출력한다. 
  • 입력이 아무리 작거나 커도 출력은 0에서 1 사이라는 것도 공통점이다. 

 

 

3.2.6. 비선형 함수

추가적으로 계단 함수와 시그모이드 함수의 공통점은 비선형 함수라는 것이다. 두 함수는 비선형 함수로 분류된다.

더보기

활성화 함수를 설명할 때 비선형 함수와 선형 함수라는 용어가 자주 등장한다. 함수란 어떤 값을 입력했을 때 출력이 입력의 상수배만큼 변하는 함수를 선형 함수라고 한다. 수식으로는 f(x) = ax+b 이고, 이때 a와 b는 상수다. 그래서 선형 함수는 곧은 1개의 직선이된다. 한편 비선형 함수는 직선 1개로는 그릴 수 없는 함수를 말한다. 

신경망에서는 활성화 함수로 비선형 함수를 사용해야 한다. 선형 함수를 이용하면 신경망의 층을 깊게 하는 의미가 없어진다. 

선형 함수의 문제는 층을 아무리 깊게 해도 '은닉층이 없는 네트워크'로도 똑같은 기능을 해버린다는 것에 있다. 선형 함수를 이용해서는 여러 층으로 구성하는 이점을 살릴 수 없다.

예를 들어 h(x) = cx를 활성화 함수로 사용한 3층 네트워크는 y(x) = h(h(h(x))))이 된다.

이 계산은 y(x) = c*c*c*x처럼 곱셈을 세 번 수행하지만, 실은 y(x) = ax와 똑같은 식이다. 

 

 

3.2.7. ReLU 함수

시그모이드 함수는 신경망 분야에서 오래전부터 이용해왔으나, 최근에는 ReLU(Rectified Linear Unit, 렐루) 함수를 주로 이용한다.

rectify는 '오류를 잡다' correct와 같은 의미로 rectified는 '수정된', '정류된'의 뜻이라고 한다.

더보기

rectified란 '정류된'이란 뜻이다. 정류(整流)는 전기회로 쪽 용어로, 예를 들어 반파정류회로(half-wave rectification circuit)는 +/-가 반복되는 교류에서 -흐름을 차단하는 회로다. 위의 그래프와 비교하면 x가 0 이하일 때를 차단하여 아무 값도 출력하지 않는(0을 출력하는) 것이다. 

 

ReLU는 입력이 0을 넘으면 그 입력을 그대로 출력하고, 0 이하이면 0을 출력하는 함수다.

ReLU 함수의 그래프

 

수식으로는 아래처럼 쓸 수 있다. 간단한 함수다.

ReLU 함수 수식

 

구현도 간단하다.

def relu(x):
	return np.maximum(0, x)

넘파이의 maximum 함수를 사용했는데, maximum은 두 입력 중 큰 값을 선택해 반환하는 함수다. 

이번 장에서는 시그모이드 함수를 활성화 함수로 사용하긴 한다.

 

 

3.3. 다차원 배열의 계산

넘파이의 다차원 배열을 사용한 계산법을 숙달하면 신경망을 효율적으로 구현할 수 있다.

 

3.3.1. 다차원 배열

다차원 배열도 그 기본은 '숫자의 집합' 이다.

숫자가 한 줄로 늘어선 것이나 직사각형으로 늘어놓은 것, 3차원으로 늘어놓은 것이나 (더 일반화한) N차원으로 나열하는 것을 통틀어 다차원 배열이라고 한다. 

 

넘파이를 사용해서 먼저 1차원 배열을 작성해본다. 아래는 원소 4개로 구성된 1차원 배열이다.

#1차원 배열
A = np.array([1, 2, 3, 4])
print(A)   #[1 2 3 4]
print(np.ndim(A))   #1
print(A.shape)   #(4, )
print(A.shape[0])   #4

배열의 차원 수는 np.ndim() 함수로 확인할 수 있다. 또, 배열의 형상은 인스턴스 변수인 shape으로 알 수 있다. 

A.shape이 튜플을 반환하는 것에 주의한다. 이는 1차원 배열이라도 다차원 배열일 때와 통일된 결과를 반환하기 위함이다.

예를 들어 2차원 배열일 때는 (4, 3), 3차원 배열일 떄는 (4, 3, 2)같은 튜플을 반환한다. 

 

#2차원 배열
B = np.array([[1,2], [3,4], [5,6]])
print(B)   
#[[1 2]
# [3 4]
# [5 6]]
print(np.ndim(B))   #2
print(B.shape)   #(3, 2)
print(B.shape[0])   #3

#3차원 배열 (참고용)
C = np.array([[[1,2,3], [4,5,6]], [[7,8,9], [10,11,12]]])
print(C)   
#[[[ 1  2  3]
#  [ 4  5  6]]
            
# [[ 7  8  9]
#  [10 11 12]]]
print(np.ndim(C))   #3
print(C.shape)   #(2, 2, 3)
print(C.shape[0])   #2

2차원 배열에서는 '3X2 배열'인 B를 작성했다. 3X2 배열은 처음 차원에는 원소가 3개, 다음 차원에는 원소가 2개 있다는 뜻이다.

이때 처음 차원은 0번째 차원, 다음 차원은 1번째 차원에 대응한다(파이썬의 인덱스는 0부터 시작한다).

 

2차원 배열은 특히 행렬(matrix)라고 부른다. 아래와 같이 가로 방향을 행(row), 세로 방향을 열(column)이라고 한다.

2차원 배열(행렬)의 행(가로)와 열(세로)

 

 

3.3.2. 행렬의 곱

행렬(2차원 배열)의 곱을 구하는 방법을 보겠다. 

예를 들어 2X2 행렬의 곱은 아래처럼 계산한다.

행렬의 곱 계산 방법

행렬 곱은 왼쪽 행렬의 행(가로)과 오른쪽 행렬의 열(세로)을 원소별로 곱하고 그 값들을 더해서 계산한다. 

그리고 그 계산 결과가 새로운 다차원 배열의 원소가 된다. 

예를 들어 A의 1행과 B의 1열을 곱한 값은 결과 행렬의 1행 1번째 요소가 된다. 

 

#2X2 행렬 곱
A = np.array([[1,2], [3,4]])
print(A.shape)   #(2, 2)
B = np.array([[5,6], [7,8]])
print(B.shape)   #(2,2)
print(np.dot(A,B))   
#[[19 22]
# [43 50]]

A와 B는 2X2 행렬이며, 이 두 행렬의 곱은 넘파이 함수 np.dot()으로 계산한다. 
np.dot()은 입력이 1차원 배열이면 벡터를, 2차원 배열이면 행렬 곱을 계산한다. 
주의할 것은 np.dot(A,B)와 np.dot(B,A)는 다른 값이 될 수 있다는 점이다. 
*와 + 등의 일반적인 연산과 달리 행렬의 곱에서는 피연산자의 순서가 다르면 결과도 다르다. 

 

더보기

np.dot()의 연산 원리는 다음과 같다. 아래 그림은 1차원 배열 연산에 해당하는 흐름이다. 

1차원 배열 두 개를 곱하면 배열이 아닌 숫자를 반환한다.

np.dot() 함수의 1차원 배열 연산 원리

 

2X2 행렬 외에 형상이 다른 행렬의 곱도 마찬가지 방법으로 계산할 수 있다.

다음은 2X3 행렬과 3X2 행렬의 곱을 파이썬으로 구현한 코드다.

#2X3행렬과 3X2행렬의 곱
A = np.array([[1,2,3], [4,5,6]])
print(A.shape)   #(2, 3)
B = np.array([[1,2], [3,4], [5,6]])
print(B.shape)   #(3, 2)
print(np.dot(A,B))   
#[[22 28]
# [49 64]]
C = np.dot(A,B)
print(C.shape)   #(2, 2)

행렬의 형상(shape)에 주의해야 한다. 행렬 A의 열 수(1번째 차원의 원소 수)와 행렬 B의 행 수(0번째 차원의 원소 수)가 같아야 한다. 이 값이 다르면 행렬의 곱을 계산할 수 없어 오류를 출력한다. 

 

 

3.3.3. 신경망에서의 행렬 곱

넘파이 행렬을 써서 신경망을 구현해보겠다. 

간단한 신경망을 가정해볼건데, 이 신경망은 편향과 활성화 함수를 생략하고 가중치만 갖는다.

행렬의 곱으로 신경망의 계산을 수행한다.

numpy는 1차원의 nX1 배열과 1Xn 배열을 구분하지 않고 표기는 (n,)으로 한다. (numpy 배열 포스트 참고)

 

X와 W의 대응하는 차원의 원소 수가 같아야 한다는 것에 주의한다.

X = np.array([1, 2])
print(X.shape)   #(2,)
W = np.array([[1,3,5], [2,4,6]])
print(W)   
#[[1 3 5]
# [2 4 6]]
print(W.shape)   #(2, 3)
Y = np.dot(X, W)
print(Y)   #[ 5 11 17]  
print(Y.shape)   #(3,)

다차원 배열의 스칼라곱을 구해주는 np.dot 함수를 사용하면 이처럼 단번에 결과 Y를 계산할 수 있다.

 만약 np.dot()을 사용하지 않으면 Y의 원소를 하나씩 따져보거나 for문을 사용해서 계산해야 한다.

행렬의 곱으로 한꺼번에 계산해주는 기능은 신경망을 구현할 때 매우 중요하다. 

 

 

3.4. 3층 신경망 구현부터는 이어지는 포스트에서 작성한다.

 


<참고 문헌>

사이토 고키(2019), 『밑바닥부터 시작하는 딥러닝』, 한빛미디어, pp.63-82.

'Study > 책『밑바닥부터 시작하는 딥러닝』' 카테고리의 다른 글

4-2. 신경망 학습  (0) 2020.03.25
4-1. 신경망 학습  (0) 2020.03.16
3-2. 신경망  (0) 2020.03.09
보충) numpy 행렬의 형상 차이 - (N,) (N,1)(1,N)  (0) 2020.03.08
2. 퍼셉트론(Perceptron)  (0) 2020.03.02

댓글