[7/18] 인공신경망 구현: 순전파 알고리즘

해당 시리즈는 프로그래밍 언어 중 하나인 줄리아(Julia)로 딥러닝(Deep learning)을 구현하면서 원리를 설명합니다.


이전 글에서 인공신경망을 학습한다는 것이 어떤 의미인지 살펴보았다. 이번에는 인공신경망을 직접 설계하고 학습하여 손글씨 숫자를 분류하는 인공신경망을 만들 것이다. 인공신경망을 설계하는 것은 앞전에 살펴봤던 도식을 만드는 것이다. 은닉층 개수를 정하여 인공신경망의 깊이를 설정하고, 사용되는 활성화 함수들을 정한다. 그 후 인공신경망을 '어떻게' 학습시킬 것인가를 정해야 한다. 오늘 같이 구현할 '순전파'는 경사하강법에서 기울기를 구하는 방법 중 하나이다.

순전파란

순전파는 기울기를 구하는 과정이 입력값에서 예측값으로 진행되는 것을 의미한다. 즉, 순서대로 이행되는 정직한 미분 알고리즘이다. 우리가 앞서 경사하강법 파트에서 살펴봤던 미분 방법을 사용한다.

순전파 알고리즘의 편미분 원리는 다음과 같다.

  1. 편미분 대상(가중치와 편향)에 아주 작은 수(1e-50)를 더해준다.
  2. 손실 함수 값인 \(f1\)을 구한다.
  3. 편미분 대상(가중치와 편향)에 아주 작은 수(1e-50)를 빼준다.
  4. 손실 함수 값인 \(f2\) 구한다.
  5. \(\frac{(f1 - f2)}{2h}\)를 한다. -> 편미분값 완성
  6. 위의 식 결과를 미분값 객체에 저장한다.

하지만 순전파 알고리즘은 너무 느리다. 그 이유는 매개 변수인 가중치와 편향을 업데이트한 후 다시 처음부터 신경망을 계산하여 손실함수 값을 갱신하기 때문이다. 다음 글에서는 이를 좀 더 빨리 진행할 수 있는 역전파 알고리즘을 살펴볼 것이다.

아래의 수식은 이전 글에서 우리가 만들었던 인공신경망의 프로세스를 나타낸 것이다.

\[\hat{y}=\sigma(h(XW1+B1)\times W2+B2)\]

순전파는 위의 수식에서 합성 함수들 가장 안쪽에 위치한 입력값부터 계산이 진행된다. 즉, 안쪽에서 바깥쪽으로 계산된다고도 볼 수 있다. 도식에서는 왼쪽에서 오른쪽으로 이동하며, 수식에서는 안쪽에서 바깥쪽으로 이동한다.

'MNIST' Project

'MNIST'는 손글씨로 쓴 숫자를 예측하는 인공신경망을 만드는 프로젝트이다. 해당 프로젝트에 대해 더 알고싶다면 이 링크를 참고하면 된다. MNIST는 대부분의 교재에서 사용하는 예시 중 하나이며, 해당 데이터셋이 패키지 안에 준비되어 있다. 먼저 아래의 코드를 입력해서 데이터를 가져오자.

1
2
3
4
5
#import Pkg           # 패키지를 처음 사용할 때 입력해야 한다.
Pkg.add("MNIST")
using MLDatasets
train_x, train_y = MNIST.traindata()
test_x, test_y = MNIST.testdata()

해당 데이터의 입력값 train_x\(28 \times 28\) 이미지 데이터가 60000개 들어 있으며, train_y는 정답 숫자가 \(60000 \times 1\) 형태로 들어 있다. 우리는 이를 신경망 모델에 알맞게 전처리해주어야 한다. 특히 train_y7이나 8같은 숫자로 들어가 있는데 이를 '원-핫 인코딩'형태로 변환해야 한다. 이를 위해 아래의 함수를 먼저 정의하자.

Note 원-핫 인코딩이란? 원-핫 인코딩은 숫자의 형태를 분류하기 위해 사용되는 데이터 형태로 7을 [0,0,0,0,0,0,0,1,0,0]로 변경해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""
원-핫 인코딩 레이블을 만드는 함수
예를 들어 0~9까지의 숫자 중 3을 원-핫 레이블로 만들면
[0 0 0 1 0 0 0 0 0 0]과 같이 출력할 것이다.
x : 만들려는 숫자
y: 메트릭스의 길이, 주의할 점은 이것은 0부터 시작한다!
"""
function making_one_hot_label(x, y)

temp = x + 1
temp_matrix = zeros(Int8, 1, y)
temp_matrix[temp] = 1
return(temp_matrix)
end

function making_one_hot_labels(y_train)
t = making_one_hot_label.(y_train, 10)
return (reduce(vcat, t))
end

위의 함수를 사용하면 숫자로 입력된 train_y가 원-핫 레이블로 변경된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 훈련데이터 입력값 전처리
train_x = reshape(train_x,784, 60000)
train_x = Array{Float64}(train_x)
train_x=train_x'

# 실제 실험데이터 입력값 전처리
test_x = reshape(test_x,784, 10000)
test_x = Array{Float64}(test_x)
test_x=test_x'

# 훈련데이터 정답 레이블 전처리 (원-핫 인코딩)
t = making_one_hot_labels(train_y)
typeof(t), size(t)

이제 신경망 모델을 학습하기 위해 필요한 데이터는 모두 준비되었다. 우리가 만들 신경망은 이전 글에서 정리했던 '2층 신경망(TwoLayerNet)'이다. 신경망 모델에서는 네트워크에서 가중치와 편향이 계속 업데이트되어야 하기 때문에 딕셔너리로 정의한다. 만약 딕셔너리에 대해서 모르거나 더 알고 싶다면 해당 글을 참고하자. 또한 네트워크가 정확히 무엇인지 궁금하다면 이전 글을 참고하면 된다.

1
2
params = Dict()
grads = Dict()

가중치를 할당할 딕셔너리를 정의하였다. params는 기존 매개 변수를 저장하며, grads는 매개 변수의 미분값들을 저장한다. 이제 가중치와 편향의 초기값을 생성하는 함수를 정의하자.

1
2
3
4
5
6
7
function making_network(input_size, hidden_size, output_size, weight_init_std =0.01)
params["W1"] = weight_init_std * randn(Float64, input_size, hidden_size)
params["b1"] = zeros(Float32, 1, hidden_size)
params["W2"] = weight_init_std * randn(Float64, hidden_size, output_size)
params["b2"] = zeros(Float32, 1, output_size)
return(params)
end

위의 함수는 입력 데이터의 행렬 크기, 은닉층의 행렬 크기, 결과값의 행렬 크기를 받아서 그에 알맞는 가중치와 편향 행렬을 생성해준다. 가중치는 오버피팅을 막기 위해 가장 작게 셋팅하는 것이 좋기 때문에 weight_init_std을 곱해준다. 가중치인 W1, W2는 정규분포를 따라 랜덤 수를 생성해주는 randn()를 사용하였으며, 편향인 b1, b2는 초기값인 크기에 맞게 제로 행렬로 만들어준다. 제로 행렬을 만드는 방법은 zeros()를 사용한다.

우리가 사용하는 데이터는 입력값이 \(60000 \times 784\)이며, 결과값은 \(60000 \times 10\)이다. 따라서 W1\(784 \times 50\)이며, W2\(50 \times 10\)이다. b1\(1 \times 50\)이고, b2\(1 \times 10\)이다. 따라서 다음과 같이 함수를 사용하면 알맞은 가중치와 행렬 초기값을 만들 수 있다.

1
making_network(784, 50, 10)

초기값 설정이 끝났다면 이제 신경망을 학습할 차례이다. 학습을 진행하기 전에 필요한 변수들을 미리 세팅해준다.

1
2
train_loss_list = Float64[]
accuracy = Float64[]

train_loss_list는 학습이 진행되는 동안 제공되는 손실 함수의 값을 저장할 리스트이다. 모델의 정확도 또한 accuracy에 저장하여 값이 변화하는 과정을 살펴볼 예정이다.

지금부터는 순전파 기반 경사하강법에 사용되는 함수들을 정의할 것이다. 대부분의 함수들은 우리가 이전 글들에서 살펴봤던 원리와 같으며, 단지 신경망 모델에 맞게 부분적인 수정이 이루어졌다.

먼저 기본적인 활성화 함수를 정의한다. 우리는 이번 신경망 모델에 시그모이드와 소프트맥스를 사용할 예정이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sigmoid(x)
return 1/(1+exp(-x))
end

function softmax_single(a)
c = maximum(a)
exp.(a .- c) / sum(exp.(a .- c))
end

# 배치 데이터에 사용하는 소프트맥스
function softmax(a)
temp = map(softmax_single, eachrow(a))
return(transpose(hcat(temp ...)))
end

시그모이드 함수는 그대로 사용하며, 소프트맥스의 경우는 배치 데이터에 사용하는 함수를 따로 생성해준다. 배치 데이터는 각 행이 하나의 이미지이기 때문에 행 하나의 요소 합이 1이어야 한다. 위의 softmax()는 각 행 별로 softmax_single()를 계산하여 배열로 반환한다.

Note 배치(batch)란? 배치는 모델이 한번에 처리하는 데이터의 개수를 의미한다. 예를 들어 MNIST 데이터를 본다면, 입력데이터 \(1 \times 784\)는 하나의 이미지이다. 만약 모델에 투입하는 입력데이터가 \(100 \times 784\)라면 이미지 100개를 한번에 사용한 것이며, 이는 배치 데이터이다.

그 다음 예측값 \(\hat{y}\)을 반환하는 predict()를 정의한다.

1
2
3
4
5
6
function predict(x)
a1 = (x * params["W1"]) .+ params["b1"]
z1 = sigmoid.(a1)
a2 = (z1 * params["W2"]) .+ params["b2"]
return softmax(a2)
end

predict()params에 있는 초기값 가중치와 편향을 사용하여 계산한다.

1
2
3
4
5
6
7
8
9
10
function cross_entropy_error(y,t)
delta = 1e-7
batch_size = length(y[:,1])
return (-sum(log.(y.+delta).*t) / batch_size)
end

function loss(x, t)
y = predict(x)
return cross_entropy_error(y, t)
end

loss()는 예측값을 계산하고 손실 함수의 결과까지 도출해주는 함수이며, 손실 함수로는 '교차 엔트로피 오차'를 사용하였다. cross_entropy_error()은 배치데이터 손실함수 값의 평균을 제공할 수 있도록 수정하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"""
f는 손실함수, x는 입력값, t는 정답, w는 대상
"""
function numerical_gradient(f, x, t, w)
h=10^-4
vec=zeros(Float64,size(w))

for i in (1:length(w))
origin=w[i]
w[i]+=h
fx1=f(x,t)
w[i]-=2*h
fx2=f(x,t)
vec[i]=(fx1-fx2)/2h
w[i]=origin
end
return vec
end

기존의 numerical_gradient()는 매개 변수로 f()와 미분 대상인 x값만 받았다. 하지만 우리가 적용하려는 loss()는 매개 변수가 x, t로 2개가 들어가기 때문에 이 또한 numerical_gradient()의 매개 변수로 추가하였다. 그 다음 loss()에서 편미분되는 변수가 가중치와 편향이기 때문에 이 또한 매개 변수로 추가하였다.

1
2
3
4
5
6
7
function TwoLayerNet_numerical_gradient(f, x, t)
grads["W1"] = numerical_gradient(f, x, t,params["W1"])
grads["W2"] = numerical_gradient(f, x, t,params["W2"])
grads["b1"] = numerical_gradient(f, x, t,params["b1"])
grads["b2"] = numerical_gradient(f, x, t,params["b2"])
return(grads)
end

위의 TwoLayerNet_numerical_gradient()는 가중치와 편향을 모두 편미분하여 grads에 값을 저장한다.

1
2
3
4
5
6
function SGD(params,grads)
for key in keys(params)
params[key] -= learning_rate * grads[key]
end
return params
end

SGD()는 '확률적 경사하강법'으로 기존 가중치와 편향에서 '학습률 \(\times\) 편미분값'을 빼서 갱신한다.

1
2
3
4
function evaluate(test_x,test_y)
temp = (sum((argmax.(eachrow(predict(test_x))).-1) .== test_y)/size(test_x)[1])
return (temp * 100)
end

마지막으로 정의할 함수 evaluate()는 정확도를 계산한다. 정확도란 실제 실험데이터들을 사용하여 해당 신경망 모델이 얼마나 맞추는지를 확률로 반환한다. 이후 신경망을 직접 학습시키고 정확도를 확인할 것이다.

이제 학습을 진행하기 전에 배치 사이즈와 학습률, 반복 횟수를 지정한다. 보통 학습률은 0.01이나 0.1을 사용한다. 또한 배치 사이즈는 입력 데이터의 형태에 따라 최적화된 크기가 다르지만 이번에는 임의로 배치사이즈를 100으로 지정할 것이다. 그리고 반복 횟수는 100개의 배치데이터 기준으로 1에폭(epoch)인 600을 지정하였다. 에폭이라는 기준에 대해 자세히 알고 싶다면 아래 Note를 확인해보자.

Note 에폭이란? 에폭(epoch)은 훈련데이터 전체의 개수를 의미한다. MNIST 데이터를 예시로 본다면 훈련데이터는 60000개이다. 배치 사이즈가 100인 경우 한번에 100개의 훈련데이터가 사용되므로 모델에 1에폭을 훈련시키려면 600번을 반복해야 한다.

1
2
3
4
train_size = size(train_x)[1]
batch_size = 100
learning_rate = 0.1
iters_num = 600

이제 모든 준비가 완료되었다. 아래의 코드를 입력하여 학습을 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@time begin
for i in 1:iters_num
batch_mask = rand(1:train_size, 100)
x_batch = train_x[batch_mask, :]
t_batch = t[batch_mask, :]

# 편미분값 구하기
TwoLayerNet_numerical_gradient(loss, x_batch, t_batch)

# 확률적 경사하강법
SGD(params, grads)

temp_loss = loss(x_batch, t_batch)
print("NO.$i: ")
println(temp_loss)
append!(train_loss_list, temp_loss)
append!(accuracy, evaluate(test_x, test_y))
end
end

위의 코드는 순전파 기반 경사하강법을 구현한 것이다. 프로세스는 다음과 같다.

  • 먼저 입력 데이터 60000개 중에 100개를 무작위로 뽑아서 배치 데이터셋을 생성한다.

  • 그 다음 가중치와 편향을 편미분한다.(순전파) grads에 가중치와 편향의 편미분 값들을 저장한다.

  • 기존 신경망인 params의 가중치와 편향에서 '학습률 \(\times\) 편미분값'을 빼서 갱신한다. (SGD)

  • 갱신한 가중치와 편향으로 다시 손실 함수의 값을 구한 후 train_loss_list에 추가한다.

  • 해당 모델을 사용하여 실제 실험데이터를 얼마나 맞추는지 확률을 계산하고, accuracy에 추가한다.

  • iters_num만큼 위의 프로세스를 반복한다.

편미분을 진행하는 파트에서 순전파는 역전파보다 시간이 매우 많이 소요된다. 따라서 비교적 시간이 여유로울 때 코드를 진행하기를 추천한다.

위의 학습 결과는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
NO.1: 2.292942018493531
NO.2: 2.288184723441353
NO.3: 2.2987287770939293
NO.4: 2.3000466383158993
NO.5: 2.2887273179016963
NO.6: 2.2926579888410235
NO.7: 2.29624786295159
NO.8: 2.2953948065780376
NO.9: 2.2990271264453774
NO.10: 2.2735668193617418
.
.
.
NO.590: 0.9584272233933265
NO.591: 0.8445645939069023
NO.592: 0.9125098806007815
NO.593: 0.8713494631790804
NO.594: 0.8839853719315479
NO.595: 0.88424914270352
NO.596: 0.8817506678094389
NO.597: 0.9543049814512449
NO.598: 0.867151437734806
NO.599: 0.9474920771844572
NO.600: 0.8690392628614342
17654.229691 seconds (20.70 G allocations: 9.067 TiB, 6.94% gc time)

100 단위의 배치 데이터를 600번 반복한 결과, 처음 2.29였던 손실 함수 값이 0.86까지 떨어졌다. 이는 오답률이 많이 감소되었다는 것을 의미한다. 시간은 대략 17600초가 걸렸으며, 환산해보면 약 5시간 정도 소요되었다.

위의 학습 과정에서 저장했던 train_loss_listaccuracy를 그래프로 나타내보자.

1
2
3
using Plots
x = range(1,length(train_loss_list),step=1)
y = train_loss_list
손실 함수 그래프

저장된 손실 함수 값을 그래프로 그려본 결과, 지속적으로 감소하는 양상을 확인할 수 있다.

1
2
3
4
x = range(1,length(accuracy),step=1)
y = accuracy

plot(x,y)
정확도 그래프

저장된 정확도 또한 계속 상승하는 것을 볼 수 있다. 가장 높은 정확도는 아래의 코드를 통해 확인하자.

1
2
3
4
argmax(accuracy)   # 최대값 인덱스 반환
598
accuracy[598]
80.15

정확도는 80%를 웃돈다. 여기서 추가로 더 학습하면 정확도는 90%도 넘을 수 있다.

위의 코드를 돌려보면 알겠지만 순전파는 매우 느리다. 아마 1에폭을 돌리면 더이상 학습을 진행하고 싶지 않을 것이다. 하지만 학습을 더 진행하고 싶진 않지만 90%가 넘는지 확인하고 싶은 이들을 위해 순전파로 3에폭을 돌려보았다. 결과는 다음과 같다.

손실 함수 그래프

손실 함수 그래프이다. 손실 함수의 값이 0.5 아래로 떨어진 것을 확인할 수 있다.

정확도 그래프

정확도는 80%를 훨씬 넘었다. 가장 높은 정확도를 확인하기 위해 아래의 코드를 입력해보자.

1
2
3
4
argmax(accuracy)   # 최대값 인덱스 반환
1709
accuracy[1709]
90.16999999999999

정확도가 90%가 넘은 것을 확인할 수 있다.

순전파는 반복되는 매 횟수마다 활성화 함수를 미분하기 때문에 시간이 오래 걸린다. 그렇기에 실상 사용되는 미분법은 아니지만, 신경망의 원리를 파악하기 위해서는 알아야 한다. 다음 글에서는 5시간을 3분으로 줄여주는 마법같은 역전파를 살펴볼 것이다.


[7/18] 인공신경망 구현: 순전파 알고리즘
https://dev-bearabbit.github.io/ko/DeeplearningJulia/Deeplearning-7/
Author
Jess
Posted on
2020년 4월 4일
Licensed under