최대 가능도 추정

모델의 차수를 찾은 다음(즉, p,d,q 값), 다음과 같은 매개변수  , 

R에서 ARIMA 모델을 계산시, MLE를 사용한다. 이 방법은 관찰한 데이터를 얻는 확률을 최대화하는 매개변수의 값을 찾는다. 

다음은 수리통계학에서 나오는 정의이다.

가능도함수
최대가능도 추정량

ARIMA 모델에서 MLE는 다음과 같은 양을 최소화하는 최소제곱(least squares)추정과 비슷하다.

ARIMA 모델이 회귀 모델을 추정하는 것보다 훨씬 더 복잡하고, 서로 다른 소프트웨어가 서로 다른 추정 기법과 최적화 알고리즘을 사용하기 때문에 살짝 다른 결과를 낼 수 있다는 것에 주목하자.

 

실제로, R은 데이터의 로그 가능도(log likelihood) 값을 알려줄 것이다. 즉, 추정한 모델에서 나온ㄱ ㅘㄴ측 데이터의 확률의 로그를 말한다. 다음은 수리통계학에서 나오는 log likelihood 이다.

로그 가능도함수

정보 기준

회귀에서 예측변수(predict)를 선택할 때 유용했던 아카이케(Akaike)의 정보 기준이 ARIMA 모델에서 차수를 결정할 때도 유용하다.

AIC는 다음과 같이 쓸 수 있다.

위에서 L은 데이터의 가능도, c is not 0 이면 k=1이고, c is 0 이면 k=0이다. 괄호 안의 마지막 항이 (σ^2와 잔차(residual)의 분산을 포함하는) 모델의 매개변수 개수라는 것에 주목하자.

 

ARIMA 모델에 대해, 수정된 AIC는 다음과 같다.

또한 베이지안 정보 기준은 다음과 같이 쓸 수 있다.

AIC,AICc 혹은 BIC를 최소화하여 좋은 모델을 얻을 수 있다. 여기서는 AICc를 사용한다.

 

이러한 정보 기준이 모델의 적절한 차분 차수(d)를 고를 때 별로 도움이 되지 않는 경향이 있고, p와 q값을 고를 때만 도움이 된다는 것은 중요한 점이다. 차분을 구하는 것을 통해 likelihood를 계산하는 데이터가 바뀌기 때문에, 서로 다른 차수로 차분을 구한 모델의 AIC 값을 비교할 수 없게 된다. 그렇기에 d를 고르기 위해 다른 방법을 사용해야 하고, 그 후 p와 q를 고르기 위해 AICc를 사용할 수 있다.

 

 

 

 

[출처:otexts.com/fppkr/arima-estimation.html]

차분을 구하는 것을 자기회귀와 이동 평균 모델과 결합하면, 비-계절성(non-seasonal) ARIMA 모델을 얻는다.

ARIMA는 AutoRegressive Integrated Moving Average(이동 평균을 누적한 자기회귀)의 약자이다.

 

*이러한 맥락에서 '누적(integration)'은 차분의 반대 의미를 갖는다).

모델은 다음과 같다.

여기에서

 

 

 

 

ARIMA 모델의 특별한 경우

위 표는 ARIMA 모델의 특수한 경우이다.

이러한 방식으로 더욱 복잡한 모델을 만들기 위해 성분을 결합할 때, 후방이동(backshift)기호를 쓰면 훨씬 쉬워진다.

A

R에서는 약간 다른 매개변수화 과정을 사용한다.

B

여기에서,

u는 y't의 평균이다. 위 A모델로 주어지는 형태로 바꾸기 위해, 다음과 같이 두자.

p,d,q의 적절한 값을 고르는 것이 어려울 수 있다. 하지만, R에선 auto.arima() 파이썬에서는 pmdarima 모듈을 통해 auto_arima를 불러올 수 있다.

이 함수가 어떻게 작동하는지와 스스로 이러한 값을 선택할 수 있도록 몇 가지 기법을 알아보자.

 

다음 예는 R을 사용하여 비계절성을 알아보는 것이다. 다음 내용들은 참고하여 쓰는것이기 때문이다.

파이썬은 개인적으로 포스팅을 다시 하도록 하겠다.

 

미국의 소비 지출

autoplot(uschange[,"Consumption"]) +
  xlab("연도") + ylab("분기별 백분율 변화")

 

미국 소비 지출의 분기별 백분율 변화

위 그래프는 미국 소비 지출 분기별 백분율 변화를 나타낸다. 분기별 시계열이지만, 계절성 패턴이 나타나지 않는 것 같다. 

따라서 비-계절성 ARIMA 모델로 맞춰보자.

 

다음 코드는 모델을 자동으로 선택할 때 사용한다.

fit <- auto.arima(uschange[,"Consumption"], seasonal=FALSE)

#> Series: uschange[, "Consumption"] 
#> ARIMA(1,0,3) with non-zero mean 
#> 
#> Coefficients:
#>         ar1     ma1    ma2    ma3   mean
#>       0.589  -0.353  0.085  0.174  0.745
#> s.e.  0.154   0.166  0.082  0.084  0.093
#> 
#> sigma^2 estimated as 0.35:  log likelihood=-164.8
#> AIC=341.6   AICc=342.1   BIC=361

아래는 ARIMA(1,0,3) 모델이다.

여기에서 c=0.745*(1-0.589)=0.307 이고 Et는 다음과 같이 0.592=Route(0.350)이다.

이러한 표준 편차를 갖는 백색잡음(white noise)이다.

fit %>% forecast(h=10) %>% autoplot(include=80) +
  ggtitle("0이 아닌 평균을 가지는 ARIMA(2,0,2)로부터 얻은 예측값") + ylab("소비")

미국 소비 지출의 분기별 백분율 변동의 예측값

다음과 같은 예측값을 나타내었다.

 

ARIMA 모델 이해하기

auto.arima() 함수는 유용하지만, 모든 입력을 자동으로 결정하게 두면 약간 위험할 수 있다.

그리고 자동으로 모델을 고르게 두더라도 모델이 대략적으로 작동하는 방식은 공부해볼만 하다.

 

상수 c는 이러한 모델에서 얻은 장기 예측값에 중요한 영향을 준다.

  • 이고 이면, 장기 예측값이 0에 가까워질 것이다.
  • 이고 이면, 장기 예측값이 0이 아닌 상수에 가까워질 것이다.
  • 이고 이면, 장기 예측값이 직선 형태로 나타나게 될 것이다.
  • 이고 이면, 장기 예측값이 데이터의 평균에 가까워질 것이다.
  • 이고 이면, 장기 예측값이 직선 형태로 나타나게 될 것이다.
  • 이고 이면, 장기 예측값이 2차 곡선 추세로 나타나게 될 것이다.

d 값은 예측 구간(prediction interval)에도 영향을 준다. d값이 클 수록, 예측 구간의 크기가 더욱 급격하게 늘어난다.

d=0에서, 장기 예측 표준 편차가 과거 데이터의 표준 편차에 가까워질 것이고, 따라서 모든 예측 구간은 실제적으로 같게 될 것이다.

 

위 그래프는 이러한 행동을 나타낸다. 여기에서 d=0이고 c is not equal 0이다. 위 그래프에서, 예측 구간은 마지막 몇 개의 예측 수평선(forecast horizon)에 대한 경우가 거의 같고, 점 예측값(point forecast)은 데이터의 평균과 같다.

p 값은 데이터에서 주기(cycles)가 나타날 때 중요하다. 주기적 예측값을 얻기 위해서는, 매개변수에 대한 몇 가지 추가적인 조건과 함께 p>=2 이어야 한다. AR(2) 모델의 경우, ϕ21+4ϕ2<0이면 주기적인 행동이 나타난다.

이 경우에, 주기(cycle)의 평균 기간은 다음과 같다.

 

ACF와 PACF 그래프

보통은 단순하게 시간 그래프(time plot)만 보고나서 어떤 p와 q값이 데이터에 맞는지 이야기할 수 없다. 하지만, 적절한 pp와 q값을 결정하기 위해서 때때로 ACF 그래프와 PACF 그래프를 이용하면 가능하다.

 

서로 다른 k값에 대해, yt와 yt-k의 관계를 측정하는 자기상관값(autocorrelation)을 나타내는 ACF그래프를 다시 떠올려보자. yt와 yt-1이 상관관계가 있다면, yt-1과 yt-2에도 상관관계가 있어야 한다. 하지만 yt와 yt-2는(yt를 예측하는데 사용될 수 있는) yt-2에 담긴 어떤 새로운 정보 때문이 아니라 단순히 두 값 모두 yt-1과 관련이 있기 때문에 상관관계를 가질 수도 있다.

 

이러한 문제를 극복하기 위해, 부분 자기상관값들(partial autocorrelations)을 이용할 수 있다. 

이 값은 시차 1,2,3,.....,k-1 의 효과를 제거한 후의 yt와 yt-k 사이의 관계를 측정한다. 그래서 첫 번째 부분 자기상관은 제거할 부분이 없어서 첫 번째 자기상관과 같다. 각 부분 자기상관은 자기회귀 모델의 마지막 계수처럼 측정할 수 있다. 구체적으로, k번째 부분 자기상관 계수

 

ggACF(uschange[,"Consumption"])

미국 소비 데이터에서 분기별 백분율 변동의 ACF

ggPacf(uschange[,"Consumption"])

미국 소비 데이터에서 분기별 백분율 변동의 PACF

데이터가 ARIMA(p,d,0)나 ARIMA(0,d,q) 모델에서 왔다면, p나 q 값을 결정할 때 ACF와 PACF 그래프가 유용할 수 있다.

p와 q가 모두 양수라면, 적절한 p와 q값을 찾을 때 이러한 그래프는 도움이 되지 않는다.

 

차분을 구한 데이터의 ACF와 PACF 그래프가 다음과 같은 패턴을 나타내면, 데이터는 ARIMA(p,d,0) 모델을 따를 수도 있다.

  • ACF가 지수적으로 감소하거나 사인 함수 모양인 경우
  • PACF 그래프에서 시차 p에 뾰족한 막대가 유의미하게 있지만, 시차 p 이후에는 없을 때.

차분을 구한 데이터의 ACF와 PACF 그래프가 다음과 같은 패턴을 나타내면 데이터는 ARIMA(0,d,q)모델을 따를 수도있다.

  • PACF가 지수적으로 감소하거나 사인 함수 모양인 경우;
  • ACF 그래프에서 시차 q에 뾰족한 막대가 유의미하게 있지만, 시차 q 이후에는 없을 때.

위 ACF그래프에서 시차 4에서 나타나는 거의 유의미한 뾰족한 막대 이전에 3개의 뾰족한 막대가 있는 것을 알 수 있다. PACF에서 3개의 유의미한 뾰족한 막대가 있고, 그 이후에는 (22 뒤처짐에서 경계를 한 번 벗어나는 것을 제외하고) 유의미하게 경계를 벗어나는 것을 무시할 수 있다. 

결국, 뾰족한 막대가 유의미하게 될 확률은 1/20이고, 각 그래프에서 시차 22에서 나타나는 뾰족한 막대를 나타냈다.

첫 3개의 뾰족한 막대에서 나타나는 패턴은 PACF가 감소하는 경향을 통해 ARIMA(3,0,0)에서 예측했던 것이다.

그래서 ACF와 PACF을 보면 이 경우에는 ARIMA(3,0,0) 모델이 적절할 수 있다고 생각할 수 있다.

(fit2 <- Arima(uschange[,"Consumption"], order=c(3,0,0)))
#> Series: uschange[, "Consumption"] 
#> ARIMA(3,0,0) with non-zero mean 
#> 
#> Coefficients:
#>         ar1    ar2    ar3   mean
#>       0.227  0.160  0.203  0.745
#> s.e.  0.071  0.072  0.071  0.103
#> 
#> sigma^2 estimated as 0.349:  log likelihood=-165.2
#> AIC=340.3   AICc=340.7   BIC=356.5

사실 이 모델은 (342.08와 비교하는 AICc 값 340.67을 가지는) auto.arima()로 찾은 모델보다 살짝 낫다.

auto.arima() 함수는 찾을 때 모든 가능한 모델을 고려하지 않기 때문에 이 모델을 못찾는다.

stepwise=FALSE 와 approximation=FALSE 입력값으로 더 잘 찾을 수 있다.

(fit3 <- auto.arima(uschange[,"Consumption"], seasonal=FALSE,
  stepwise=FALSE, approximation=FALSE))
#> Series: uschange[, "Consumption"] 
#> ARIMA(3,0,0) with non-zero mean 
#> 
#> Coefficients:
#>         ar1    ar2    ar3   mean
#>       0.227  0.160  0.203  0.745
#> s.e.  0.071  0.072  0.071  0.103
#> 
#> sigma^2 estimated as 0.349:  log likelihood=-165.2
#> AIC=340.3   AICc=340.7   BIC=356.5

계절성 ARIMA 모델을 검색하지 않도록 하기 위해 입력값 seasonal=FALSE 도 사용한다. 

이번 장에서는 auto.arima()가 ACF와 PACF 그래프에서 추측할 수 있는 것과 같은 모델을 찾았다.

ARIMA(3,0,0) 모델로부터 얻은 예측값은 ARIMA(2,0,2)모델에 대해 위 그래프에서 나타낸 것과 거의 같기 때문에 더이상 그리지는 않겠다.

 

 

[출처:otexts.com/fppkr/non-seasonal-arima.html]

회귀에서 목표 에상 변수(forecast variable)의 과거 값을 이용하는 대신에, 이동 평균 모델은 회귀처럼 보이는 모델에서 과거 예측 오차(forecast error)을 이용한다.

여기서 E(t)는 백색잡음(White noise)이다. 이것을 q차 이동 평균 모델인 MA(q) 모델이라고 부르자.

물론, E(t)의 값을 관찰하지 않기 때문에, 실제로는 일반적인 회귀가 아니다.

Y(t)의 각 값을 과거 몇 개의 예측 오차(forecast error)의 가중 이동 평균으로 생각할 수 있다는 것에 주목하자. 하지만 이동 평균 평활과 헷갈리면 안된다. 이동 평균 모델은 미래 값을 예측할 때 사용한다. 이동 평균 평활은 과거 값의 추세-주기를 측정할 때 사용한다.

 

매개변수를 다르게 설정한 이동 평균 모델로부터 얻은 데이터의 두가지 예

MA(1): Y(t)=20+E(t)+0.8E(t-1)

MA(2): Y(t)=E(t)-E(t-1)+0.8E(t-2)

*두 가지 경우 모두, E(t)는 평균이 0이고 분산이 1인 정규 분포를 따르는 백색 잡음(White noise)이다.

 

위 그림은 MA(1) 모델과 MA(2) 모델로 얻은 몇몇 데이터를 나타낸다. 매개변수 θ1,…,θq 을 바꾸면 다른 시계열 패턴이 나타난다. 자기회귀 모델을 이용하는 경우처럼, 오차항 E(t)의 분산은 시계열의 패턴이 아니라 눈금만 맞출 것이다.

 

정상성을 나타내는 어떤 AR(p) 모델을 MA(∞) 모델로 쓸 수 있다. 예를 들어, 반복하여 대입하면, AR(1) 모델에 대해 다음과 같이 나타낼 수 있다.

 

-1<ϕ1<1 에 대해, k 가 커질 수록   

이는 MA(∞)의 과정이다.

 

MA 매개변수에 대한 몇몇 제한조건을 도입하면 반대 결과도 성립한다. 그러면 MA 모델을 가역적(invertible)이라고 부른다. 즉, 어떤 가역적인 MA(q) 과정을 AR(∞) 과정으로 쓸 수 있다. 가역적 모델은 단순하게 MA 모델을 AR 모델로 바꿀 수 있도록 하는 것만은 아니다. 몇 가지 수학적 특징도 갖고 있다.

 

예를 들어보자.

MA(1) 과정

다음과 같은 식을 생각해보자. 이것은 AR(∞)로 표현하면, 가장 최근의 오차는 현재와 과거 관측값의 선형 함수로 쓸 수 있다.

|θ|>1 이면, 가중치의 시작(lag) 값이 증가함에 따라 증가하고, 따라서 더 멀리 떨어진 관측값일수록 현재 오차에 미치는 영향이 커진다.

|θ|=1 이면, 가중치가 크기에 대해서는 상수, 멀리 떨어진 관측값과 가까운 관측값 모두 동일하게 영향을 준다.

위 예 AR(1), AR(2) 경우 모두 그럴듯하지 않기 때문에, |θ|<1 가 필요하며, 결국 가장 최근 관측값이 멀리 떨어진 관측값  보다 더 큰 가중치를 갖게 된다. 따라서, |θ|<1일때 과정은 가역적(invertible)이다.

 

다른 모델에 대한 가역성(invertibility) 제한조건은 정상성(stationarity) 제한조건과 비슷하다.

  • MA(1) 모델의 경우: −1<θ1<1.
  • MA(2) 모델의 경우:   θ1−θ2<1.

q>=3에 대해서는 더 복잡한 조건이 성립한다. 여기서, 파이썬, R에서 모델을 다룰 때, 이러한 제한조건을 처리해준다.

 

 

[출처: otexts.com/fppkr/MA.html]

다중 회귀 모델에서, 목표 예상 변수(forecast variable)의 선형 조합을 이용해, 관심 있는 변수를 예측한다. 

자기 회귀(autoregressive) 모델에서는, 변수의 과거 값의 선형 조합을 이용하여 관심 있는 변수를 예측한다.

autoregressive라는 단어에는 자기 자신에 대한 변수의 회귀라는 의미가 있다.

 

따라서, 차수 p의 자기회귀 모델(autoregressive models)은 다음과 같은 수학적 공식을 따른다.

y(t)의 시차 값을 예측변수(predictor)로 다루는 것만 제외하면 다중 회귀와 비슷하다.

 

다중회귀 모델

위 자기회귀 모델을 p 자기회귀 모델인 AR(p) 모델이라 부르자.

 

자기회귀 모델(autoregressive model)은 다양한 종류의 서로 다른 시계열 패턴을 매우 유연하게 다루는 장점이 있다.

매개변수를 다르게 설정한 자기회귀 모델로부터 얻은 데이터의 두가지 예.

AR(1): Yt=18-0.8Y(t-1)+E(t)

AR(2): Yt=8+1.3Y(t-1)-0.7Y(t-2)+E(t)

*두 모델 모두, E(t)는 평균이 0, 분산이 1인 정규 분포를 따르는 백색잡음(White noise)이다.

두 시계열은 AR(1) 모델과 AR(2)모델로 얻은 시계열이다. 매개변수 ϕ1,…,ϕp 을 바꾸면 다른 시계열 패턴이 나온다.

오차항 εt의 분산은 시계열의 패턴이 아니라 눈금만 바꿀 것이다.

 

AR(1) 모델은:

  • ϕ1=0일 때, yt는 백색잡음과 같다.
  • ϕ1=1이고 c=0일 때, yt는 확률보행 모델과 같다;
  • ϕ1=1이고 c≠0일 때, yt는 표류가 있는 확률보행 모델과 같다.
  • ϕ1<0일 때, yt는 평균값을 중심으로 진동하는 경향을 나타낸다.

보통은 자기회귀 모델을 정상성을 나타내는 데이터에만 사용한다. 이 경우, 매개변수 값에 대한 몇몇 제한조건이 필요하다.

  • AR(1) 모델의 경우: −1<ϕ1<1
  • AR(2) 모델의 경우: −1<ϕ2<1, ϕ1+ϕ2<1, ϕ2−ϕ1<1

p>=3일 때는, 제한조건이 훨씬 복잡하다. 

모델을 다룰 때 R이나 파이썬에서 이러한 제한조건을 처리해준다.

 

[출처:otexts.com/fppkr/MA.html]

후방이동(backshift)

후방이동(backshift) 연산자 B는 시계열 시차를 다룰 때 유용한 표기법 장치이다.

(참고 문헌에서는 '후방이동(backshift)'을 나타내는 B 대신에 '시차(lag)'을 나타내는 L을 사용한다.)

다르게 말하면, y(t)에 작용하는 B는 데이터를 한 시점 뒤로 옮기는 효과를 낸다. B를 y(t)에 두 번 적용하면 데이터를 두 시점 뒤로 옮긴다.

월별 데이터에서, '지난해와 같은 달' 을 다루고 싶다면, 다음과 같이 표기한다.

후방이동(backshift)연산자는 차분을 구하는 과정을 설명할 때 편리하다. 1차 차분을 다음과 같이 쓸 수 있다.

1차 차분을 (1-B)로 나타냈다는 것에 주목해보자. 비슷하게, 2차 차분을 계산해야하면, 이 때는 아래와 같이 주어진다.

일반적으로, d차 차분은 다음과 같이 쓸 수 있다.

차분을 연산자로 결합하면 보통의 대수 법칙을 사용하여 다룰 수 있게 되기 때문에, 후방이동(backshift) 기호는 특별히 유용하다. B를 포함하는 항은 서로 곱할 수 있다.

 

예를 들면, 1차 차분 뒤에 이어서 나오는 계절성 차분은 다음과 같이 쓸 수 있다.

 

이는 이전에 얻은 결과와 같다.

ARIMA 모형은 대표적인 통계적 시계열 예측 모형으로, 현재 값을 과거 값과 과거 예측 오차를 통해 설명한다. 이 모형을 적합하려면 시계열 Yt가 정상성 조건을 만족하는 정상 시계열(Stationary series)이어야 한다. (정확하게는 약정상성, weak stationary). 또한, 주어진 시계열 자료에 적합한 ARIMA(p,d,q)를 결정하는 절차는 Box-Jenkins method를 따르며, ACF(Autocorrelation function: 자기상관함수)로 시계열의 특성을 파악하고 적절한 차수의 ARIMA 모형을 선택한다.

 

Box-Jenkins method

 

 

정상성(Stationary)

우리는 시간의 순서에 따라 기록되지 않은 일반적 자료들을 분석할 때, Random sample들에 iid가정을 한다. 

*iid: All samples are independent and identically distributed

시간에 종속되어있는 시게열은 상식적으로 iid 가정을 할 수 없다. 그래서 이러한 시계열 자료에 대해 예측 모형을 적합하고 통계적 검정을 하기 위해서는 분석을 단순화 시킬 수 있는 새로운 가정이 필요하다. 

이중 가장 중요한 것이 시계열 모형의 확률적 성질이 시간에 따라 변하지 않는다고 가정하는 정상성 가정이다.

 

ARIMA모형은 해당 시계열이 약정상성(Weak Stationary)를 만족한다고 가정하며, 약정상성을 만족해야 좋은 fitting과 predict 성능을 보여줄 수 있다. 시계열 Yt가 다음의 세 조건을 만족할 때 약정상성을 가진다고 표현한다. 시계열 분서겡서 말하는 정상 시계열(Stationary series)은 약 정상성을 가지는 시계열을 말한다.

-평균이 모든 시점 t에서 동일하다 : 추세, 계절성, 순환성 등의 패턴이 보이지 않게 된다.

-분산이 모든 시점 t에서 동일하다: 자료 변화의 폭이 일정하게 된다.

-Yt와 Y(t-h) 간의 Covariation(즉, Yt의 자기 공분산 함수)이 모든 시점 t에 대해서 동일하다: 시간에 따라 상이한 자기상관적 패턴을 보이지 않게 된다.

 

즉, 풀어설명하자면 추세와 계절성이 있는 시계열은 정상성을 나타내는 시계열이 아니다. 일반적으로 정상성을 나타내는 시계열은 장기적으로 볼 때 예측할 수 있는 패턴을 나타내지 않을 것이다. (어떤 주기적인 행동이 있을 수 있더라도) 시간 그래프는 시계열이 일정한 분산을 갖고 대략적으로 평평하게 될 것을 나타낼 것이다.

그렇다면 다음 시계열 그래프중 어떤 것이 정상성을 나타낼까? 

a) 거래일 200일 동안의 구글 주식 가격

b) 거래일 200일 동안의 구글 주식 가격의 일일 변동

c) 미국의 연간 파업 수

d) 미국에서 판매되는 새로운 단독 주택의 월별 판매액

e) 미국에서 계란 12개의 연간 가격(고정 달러)

f) 호주 빅토리아 주에서 매월 도살한 돼지의 전체 수

g) 캐나다 북서부 맥킨지 강 지역에서 연간 포획된 시라소니의 전체 수

h) 호주 월별 맥주 생산량

i) 호주 월별 전기 생산량

 

분명하게 계절성이 보이는 d),h),i)는 후보가 되지 못한다. 추세가 있고 수준이 변하는 a),c),e),f),i)도 후보가 되지 못한다. 분산이 증가하는 i)도 후보가 되지 못한다. 그렇다면 b)와 g)만 정상성을 나타내는 시계열 후보로 남았다.

얼핏 보면 시계열 g)에서 나타나는 뚜렷한 주기(cycle)때문에 정상성을 나타내는 시계열이 아닌 것처럼 보일 수 있다. 하지만 이러한 주기는 불규칙적(aperiodic)이다. 

즉, 먹이를 구하기 힘들만큼 시라소니 개체수가 너무 많이 늘어나 번식을 멈춰서, 개체수가 작은 숫자로 줄어들고, 그 다음 먹이를 구할 수 있게 되어 개체수가 다시 늘어나는 식이다. 

장기적으로 볼 때 , 이러한 주기의 시작이나 끝은 예측할 수 없다. 따라서 이 시계열은 정상성을 나타내느 시계열이다.

 

차분(differencing)

구글 주식 가격의 ACF(왼쪽), 일별 변동(오른쪽)

처음 그림에서 패널(a) 의 구글 주식 가격이 정상성을 나타내는 시계열이 아니었지만 (b)의 일별 변화는 정상성을 나타냈다느 것에 주목해보자. 이 그림은 정상성을 나타내지 않는 시계열을 정상성을 나타내도록 만드는 한 가지 방법을 나타낸다.

연이은 관측값들의 차이를 계산하는 것이다. 이를 차분(differencing)이라 부른다.

 

로그변환은 시계열의 분산 변화를 일정하게 만드는데 도움이 될 수 있다. 차분(differencing)은 시계열의 수준에서 나타내느 변화를 제거하여 시계열의 평균 변화를 일정하게 만드는데 도움이 될 수 있다. 결과적으로 추세나 계절성이 제거(또는 감소) 된다.

 

정상성을 나타내지 않는 시계열을 찾아낼 때 데이터의 시간 그래프를 살펴보는 것만큼, ACF 그래프도 유용하다. 정상성을 나타내지 않는 데이터에서는 ACF가 느리게 감소하지만, 정상성을 나타내는 시계열에서는, ACF가 비교적 빠르게 0으로 떨어질 것이다. 그리고 정상성을 나타내지 않는 데이터에서 r1은 종종 큰 양수 값을 갖는다.

 

차분을 구한 구글 주식 가격의 ACF는 단순히 white noise 시계열처럼 생겼다. 95% limitation 바깥에 자기상관(autocorrelation)값이 없고, 융-박스(Ljung-Box) Q* 통계는 h=10 에 대해 0.355라는 p-value를 갖는다. 이 결과는 구글 주식 가격의 일별

Box.test(diff(goog200), lag=10, type="LJung-Box")

#Box-Ljung test

#data: diff(goog200)
#X-Squared=11, df=10, p-value=0.4

변동이 기본적으로는 이전 거래일의 데이터와 상관이 없는 무작위적인 양이라는 것을 말해준다.

 

확률보행 모델

차분(difference)을 구한 시게열은 원래의 시계열에서 연이은 관측값의 차이이고, 다음과 같이 쓸 수 있다.

첫 번째 관측값에 대한 차분 y'1을 계산할 수 없기 때문에 차분을 구한 시계열은 T-1개의 값만 가질 것이다.

차분을 구한 시계열이 white noise이면, 원래 시계열에 대한 모델은 다음과 같이 쓸 수 있다.

여기서 Error(t)는 white noise를 의미한다. 이것을 정리하면 '확률보행(random walk)' 모델을 얻는다.

확률보행(random walk) 모델은 정상성을 나타내지 않은 데이터, 특별히 금융이나 경제 데이터를 다룰 때 널리 사용되고 있다. 확률보행에는 보통 다음과 같은 특징을 가진다.

 

-누가 봐도 알 수 있는 긴 주기를 갖는 상향 또는 하향 추세가 있다.

-갑작스럽고 예측할 수 없는 방향 변화가 있다.

 

미래 이동을 예측할 수 없고 위로 갈 확률이나 아래로 갈 확률이 정확하게 같기 때문에 확률보행 모델에서 얻어낸 예측값은 마지막 관측값과 같다. 따라서, 확률보행 모델은 단순(naive) 예측값을 뒷받침한다.

 

밀접하게 연관된 모델은 차분값이 0이 아닌 평균값을 갖게 한다. 그러면,

c값은 연이은 관측값의 차이의 평균이다. c가 양수이면, 평균 변화는 y(t)값에 따라 증가한다. 따라서 y(t)는 위쪽 방향으로 이동하는 경향을 나타낼 것이다. 하지만 c가 음수이면, y(t)는 아래쪽 방향으로 이동하는 경향을 나타낼 것이다.

 

2차 차분

가끔 차분(difference)을 구한 데이터가 정상성(stationarity)이 없다고 보일 수도 있다. 정상성을 나타내는 시계열을 얻기 위해 데이터에서 다음과 같이 한 번 더 차분을 구하는 작업이 필요할 수 있다.

이 경우에는, y(t)''는 T-2개의 값을 가질 것이다. 그러면, 원본 데이터의 '변화에서 나타나는 변화'를 모델링하게 되는 셈이다. 실제 상황에서는, 2차 차분 이상으로 구해야 하는 경우는 거의 일어나지 않는다.

 

계절성 차분

계절성 차분(seasonal differencing)은 관측치와, 같은 계절의 이전 관측값과의 차이를 말한다. 따라서

여기에서 m은 계절의 개수이다. m 주기 시차 뒤의 관측을 빼기 때문에 시차 m 차분이라고 부르기도 한다.

계절성으로 차분을 구한 데이터가 white nois로 보이면, 원본 데이터에 대해 적절한 모델은 다음과 같다.

이 모델에서 낸 예측값은 관련 있느 계절의 마지막 관측값과 같다. 즉, 이 모델은 계절성 단순(seasonal naive) 예측값을 나타낸다.

 

위 그림의 아래쪽 패널은 호주에서 팔린 A10 약물(당뇨병 약)의 월별 처방전의 수에 로그를 취하여 계절성 차분을 구한 결과를 나타낸다. 변환과 차분을 통해 시계열이 정상성을 나타내는 것처럼 보인다.

cbind("판매량 (백만 달러)"= a10, 
	  "월별 로그 판매량"= log(a10), 
      "로그 눈금에서 연간 변동"=diff(log(a10),12))%>%
  autoplot(facets=TRUE)+
  	xlab("연도")+ylab("")+
    ggtitle("당뇨병 약 판매량")

보통의 차분과 계절성 차분을 구분하기 위해, 때때로 보통의 차분을 시차 1에서 차분을 구한다는 의미로 "1차 차분(first difference)"라고 부른다. 

위 그림에서 나타낸 것처럼, 계절성을 나타내는 데이터를 얻기 위해 계절성 차분과 1차 차분 둘 다 구하는 것이 필요하기도 한다. 여기서는 데이터를 먼저 로그로 변환하고(두번째 패널), 그 후 계절성 차분을 계산했다(세번째 패널). 데이터에서 여전히 정상성이 보이지 않는 것 같아서, 1차 차분을 더 많이 계산했다.

cbind("10억 kWh" = usmelec,
      "로그" = log(usmelec),
      "계절성\n 차분 로그값" =
        diff(log(usmelec),12),
      "두 번\n 차분을 구한 로그값" =
        diff(diff(log(usmelec),12),1)) %>%
  autoplot(facets=TRUE) +
    xlab("연도") + ylab("") +
    ggtitle("미국 월별 순 전기 생산량")

 

어떤 차분(difference)을 구할지 정할 때는 주관적인 요소가 어느정도 들어간다. 위 약물 그림의 계절성 차분(seasonal difference)을 구한 데이터는 월별 순 전기 생산량의 계절성 차분을 구한 데이터와는 큰 차이를 나타내지 않는다. 후자의 경우, 계절성 차분을 구한 데이터로 결정했어야 했고, 차분을 더 구하지 않아도 되었다. 이에 반면해 전자의 경우에는 데이터의 정상성(stationarity)이 충분히 나타나지 않아서 차분을 더 구해야 했다. 차분을 구하는 것에 대한 몇가지 형식적인 검정을 아래에서 다루지만, 모델링 과정에서 항상 몇 가지 선택이 존재하고, 분석하는 사람마다 다른 선택을 할 수 있다.

y'(t)=y(t)-y(t-m)가 계절성 차분(seasonal difference)을 구한 시계열을 나타낸다면, 두 번 차분한 시계열은 다음과 같다.

계절성 차분과 1차 차분을 둘 다 적용할 때, 어떤 것을 먼저 적용하더라도 차이는 없다. 결과는 같을 것이다. 하지만, 데이터에 계절성 패턴이 강하게 나타나면, 계절성 차분을 먼저 계산하는 것을 추천한다. 왜냐면, 때때로 결과 시계열에서 정상성이 나타나기도 해서 이런 경우 1차 차분을 구할 필요가 없게 되기 때문이다. 1차 차분을 먼저 계산했다면, 여전히 남아있는 계절성이 나타날 것이다.

 

차분을 구했다면, 차분 값이 해석 가능할 것이라는 것은 중요하다. 첫 번째 차분값은 한 관측값과 그 다음 관측값 사이의 변화이다. 계절성 차분값은 한 해와 그 다음 해 사이의 변화이다. 다른 시차값(lagged value)은 직관적으로 해석하기가 쉽지 않기 때문에 사용하지 않는 것이 좋다.

 

단위근검정

단위근검정(unit root tests)은 더 객관적으로 차분을 구하는 것이 필요할 지 결정하기 위해 사용하는 한 가지 방법이다. 차분을 구하는 것이 필요한지 결정하는 상황을 위해 설계된 통계적 가설 검정들이 존재한다. 사용할 수 있는 단위근검정은 다양하고, 서로 다른 가정에 기초하고 있으며, 상반되는 답을 낼 수도 있다. 분석 과정에서 KPSS(Kwiatkowski-Phillips-Schmidt-Shin)검정을 사용한다. 이 검정은 데이터에 정상성이 나타난다는 것이 귀무가설이고, 귀무 가설이 거짓이라는 증거를 찾으려고 한다. 결과적으로 ,작은 p-value는 차이를 구하는 것이 필요하다는 것을 나타낸다. 검정은 다음과 같다.

예로는 구글 주식 가격 데이터에 적용해보자.

 

library(urca)
goog %>% ur.kpss() %>% summary()
#> 
#> ####################### 
#> # KPSS Unit Root Test # 
#> ####################### 
#> 
#> Test is of type: mu with 7 lags. 
#> 
#> Value of test-statistic is: 10.72 
#> 
#> Critical value for a significance level of: 
#>                 10pct  5pct 2.5pct  1pct
#> critical values 0.347 0.463  0.574 0.739

검정 통계량은 1% 임계값보다 훨씬 크다. 이것은 귀무가설이 기각 된다는 것을 의미한다. 즉, 데이터가 정상성을 가지고 있지 않다. 데이터에 차분을 수행할 수 있고 검정을 다시 적용할 수 있다.

 

goog %>% diff() %>% ur.kpss() %>% summary()
#> 
#> ####################### 
#> # KPSS Unit Root Test # 
#> ####################### 
#> 
#> Test is of type: mu with 7 lags. 
#> 
#> Value of test-statistic is: 0.0324 
#> 
#> Critical value for a significance level of: 
#>                 10pct  5pct 2.5pct  1pct
#> critical values 0.347 0.463  0.574 0.739

이번에는 검정 통계가 작고(0.0324), 정상성이 나타나는 데이터에서 볼 수 있는 것처럼 범위 안에 잘 들어간다. 따라서 차분을 구한 데이터가 정상성을 나타낸다고 결론내릴 수 있다.

 

1차 차분의 적당한 횟수를 결정하기 위해 여러번의 KPSS 검정을 사용하는 이 과정을 ndiffs()로 수행할 수 있다.

 

ndiffs(goog)
#> [1] 1

위 검정들에서 본 것처럼, google데이터가 정상성을 나타내도록 하려면 한번의 차분(difference)이 필요하다.

계절성 차분(seasonal difference)이 필요한지 결정하기 위한 비슷한 함수는 nsdiffs()이다. 이 함수는 필요한 계절성 차분의 적당한 횟수를 결정하기 위해 계절성 강도 측정량을 사용한다. Fs<0.64이면, 계절성 차분이 필요 없다고 알려주고, 이외의 경우에는 하나의 계절성 차분이 필요하다 알려준다.

 

 

시계열 예측(Time-Series Forecasting)이란?

시계열(Time-Series) 데이터란 시간의 흐름에 따라 순차적으로(sequentially) 기록된 데이터를 가리킨다. 

관측된 시계열 데이터를 분석하여 미래를 예측하는 문제가 바로 시계열 예측 문제이다.

시계열 예측 문제는 흔하게 접하는 문제로써 주로 경제 지표를 예측하거나, 어떤 상품의 수요를 예측하는 문제에 이르기까지 다양한 어플리케이션을 가지고 있다.

특히, 예측된 결과를 바탕으로 여러 정책이나 비즈니스 전략을 결정하는 과정에 활용되기에, 실제 비즈니스 영역에서는 시계열 예측 문제가 매우 중요하게 여겨지고 있다. 

일례로 McKinsey Global Institute의 연구에 따르면, 시계열 데이터가 텍스트나 이미지 데이터 보다 더 큰 잠재적 가치를 가지고 있다고 보고있다.

 

데이터 유형에 따른 잠재적 인공지능 활용 가치(McKinsey Global Institute analysis)

시계열 예측 문제의 주요 챌린지

무엇보다 시계열 문제가 4가지 어려운 점이 있다.

 

1.예측값은 완벽할 수 없으며, 항상 변동의 가능성을 내포.

따라서 시계열 예측에 있어 불확실성(uncertainty)이 고려되어야 한다. 다시 말해 예측값에 대해 적합한 확률 분포 모델(probability distribution)이 고려되어야 한다. 단순히 어떤 에측 값을 제공한다기 보다는 그 예측값의 불확실성(또는 신뢰도)에 대해 정량적인 평가가 함께 고려되어야 예측 모델로써 더욱 가치가 있다.

 

2. 시계열 데이터에 숨겨진 여려 형태의 패턴을 찾아 학습하는 문제.

보통 trend,seasonality,cycle 등의 유형으로 패턴을 구분하곤 한다. 대게 시계열 데이터에는 이러한 패턴이 복잡하게 섞여있고 데이터의 길이가 불충분 하거나, 노이즈, 아웃라이어 데이터로 인해 손쉽게 패턴을 구분해 내어 찾기가 어렵다.

 

3. 다변량 시계열 데이터(multiple time-series)를 다뤄야 하는 경우가 많아지고 있다.

과거에는 주로 다변량의 시계열 데이터(unvariate time-series)의 분석과 예측 문제가 주로 다뤄졌지만 근래에는 다변량 시계열 데이터를 다뤄야 하는 경우가 많아지고 있다. 

순차적으로 관찰된 수많은 변수들에 대해, 이 변수들간의 상관 관계를 학습할 수 있어야 한다. 가령, 어떤 상품의 수요가 다른 상품들의 수요 변화에 영향을 받거나, 어떤 지역의 택시 수요가 다른 지역의 택시 수요와 일정한 시간 차이를 두고 상관 관계를 보일 수 있다. 이 변수가 적게는 수백에서 많게는 수백만에 이를 수 있으므로, 이를 효율적으로 처리하고 학습할 수 있는 알고리즘이 매우 중요해 지고 있다.

 

4. 시계열 데이터에는 노이즈 데이터 또는 관찰되지 못한 기간이 종종 존재한다.

이는 측정하고 데이터를 기록하는 과정에서의 오류나 예측치 못한 상황으로 인해 발생할 수 있다. 예를 들어 상품의 품절로 인하여 장기간 판매량이 없는 경우, 해당 상품에 대한 실제 수요를 판매량으로 유추할 수 없는 경우이다. 

시계열 예측 문제에 있어서는 이에 대한 적절한 전처리 과정이 매우 중요하다.

 

시계열 예측 모델의 평가

일반적으로 supervised machine learning 모델은 데이터 셋을 학습 데이터와 테스트 데이터로 랜덤하게 나누어 평가를 한다. 하지만 시계열 예측 문제는 평가와 학습 대상의 데이터를 특정 시간 기준으로 엄밀하게 분리시켜, data leak이 없이 평가가 이루어져야 한다. 

가령 시계열 데이터의 중간 구간을 잘라내 테스트용으로, 그 이전과 이후를 학습용으로 사용하면 정확한 평가가 이루어질 수 없다. 학습 데이터는 반드시 테스트 데이터 보다 이전에 관찰된 것이어야 한다.

 

모델링 과정에서는 최적의 성능을 가지되, robust한 모델을 찾기 위해 cross-validation을 통해 성능을 평가한다. 제한된 데이터 셋으로부터 여러 쌍의 학습 및 테스트 데이터를 샘플링해서 모델의 성능이 기대치에 부합하며, 안정적인지를 확인하게 된다. 시계열 예측 모델에서는 앞서 언급한 chronological testing 기준을 지키며, cross-validation을 할 수 있는 방법으로 크게 두가지가 고려된다. 

 

1. Sliding window

슬라이딩 윈도우(Sliding Window) 알고리즘은 배열이나 리스트의 요소의 일정 범위의 값을 비교할 때 사용하면 유용한 알고리즘이다. 

예를 들어 정수로 이루어진 배열 [2,4,7,10,8,4,5,6,7,1] 에서 길이가 3인 서브배열의 합계가 가장 큰 서브배열은 무엇일까? 

서브 배열 합계 체크

특정 길이의 하위 배열의 최대 합계값을 구하는 단순한 방식을 먼저 살펴보자. 

단순하게 for 문으로 모든 배열 요소를 특정 길이만큼 순회하며 합계를 구해서 최대 값을 구하는 단순하고 비효율적인 방법이다.

 

import sys

def max_sub_array(arr, k):
    maxsum = -sys.maxsize - 1
    arraysize = len(arr)

    for i in range(arraysize - k + 1):
        current_sum = 0
        for j in range(i, i + k):
            current_sum += arr[j]

        maxsum = max(maxsum, current_sum)

    return maxsum

if __name__ == '__main__':
    print(max_sub_array([2, 4, 7, 10, 8, 4, 5, 6, 7, 1], 3))
    
25 (7 + 10 + 8)

설명이 크게 필요 없는 단순한 코드이다. 

물론 위와 같이 작성해도 데이터가 방대하지 않다면 큰 차이는 없을 것이다. 

그렇다면 좀 더 효율적인 코드를 작성할 방법을 찾아보자.

 

우선 기존 단순한 방식에서 서브 배열의 요소를 순회하다 보면 중복되는 요소들이 존재한다.

서브배열의 공통된 요소 예시

위 이미지 처럼 범위가 5인 서브배열을 탐색하는 경우 0~4 범위의 서브배열과 1~5 범위의 서브배열은 공통적으로 1~4 범위가 중복된다. 

중복되는 요소(공통 요소)들을 재상용하는 방법이 슬라이딩 윈도우 알고리즘의 핵심이다.

 

def max_sub_array(arr, k):
    window_sum = 0
    max_sum = 0
    window_start = 0

    for window_end in range(len(arr)):
        window_sum += arr[window_end]  # 슬라이딩 인덱스 범위 요소 합산
        
        # 슬라이딩 윈도우의 범위가 k 보다 커진 경우
        if window_end >= (k - 1):
            max_sum = max(max_sum, window_sum)
            window_sum -= arr[window_start]  # 슬라이드 윈도우 범위를 벗어난 요소를 합계에서 제거
            window_start += 1  # 슬라이드 윈도우 시작 인덱스 증가

    return max_sum


if __name__ == '__main__':
    print(max_sub_array([2, 4, 7, 10, 8, 4], 3))

25 (7 + 10 + 8)

이해를 쉽게 하기 위해 기존 배열보다 길이가 짧은 배열로 변경했다. 각 루프마다 다음과 같은 식으로 진행된다.

슬라이딩 알고리즘1
슬라이딩 알고리즘2

우선 슬라이딩 윈도우를 관리하기 위한 변수들로 window_start, window_end, window_sum 등을 사용한다.

-window_start: 슬라이딩 윈도우 시작 인덱스

-window_end: 슬라이딩 윈도우 끝 인덱스

-window_sum: 슬라이딩 윈도우 합계

슬라이딩 윈도우는 하위 윈도우의 범위 k 와 동일하게 유지하며 범위 내의 요소는 모두 합산하고 범위 밖으로 벗어난 요소들은 빼준다. 다만 슬라이딩 윈도우의 범위가 k 보다 커질때까지 (window_end>=k-1)는 요소들을 합산하기만 하고 이후부터는 범위 밖의 요소들을 차감시켜준다.

이렇게 하면 기존처럼 매번 서브배열의 합계를 구할 필요 없이 모든 요소를 1번만 순회하면서 최대값을 구하게 되므로 빠르고 효율적으로 처리된다.

 

위 sliding window 알고리즘을 통해 고정된 사이즈 2개의 window를 움직여가며 학습,테스트 데이터를 추출하여 cross-validation을 하는 방법이다. 

 

2. Expanding window

위 sliding window를 이해하였다면 이는 간단하게 이해할 수 있다. 데이터 길이가 충분치 않을때 데이터를 확장해 가며 학습,테스트 데이터를 추출하는 알고리즘이다.

 

Cross-validation for time-series forecasting model(Source: https://eng.uber.com/forecasting-introduction/)

 

 

시계열 예측 모델의 평가는 문제에 따라 다양한 지표가 활용될 수 있다. Root Mean Squared Error(RMSE) 또는 Mean Absolute Percentage Error(MAPE)가 많이 사용되는 지표이다. 일반적으로 baseline 예측 모델(e.g.최근 평균값으로 예측)과의 비교가 매우 유용하다. 머신러닝을 도입하기 이전의 다소 단순한 방식과의 비교를 통해 개발된 예측 모델의 도입 효과를 가늠해 보곤한다. 예측 모델의 평가는 특정 성능 지표 뿐만 아니라 residual error의 분포, 시간축에 따른 Error의 propagation 패턴을 분석하여 모델의 bias가 있는지 혹은 overfitting 여부 등에 대한 검토가 반드시 필요하다.

 

주요한 시계열 패턴

전통적으로 시계열 데이터를 분석할 때 보통 trend, seasonality,cycles 그리고 random noise의 패턴으로 분류하여 분석한다.

Trend 는 전체적으로 긴구간에 걸쳐 일정하게 증가 또는 감소하는 패턴을 가리킨다.

Seasonal component는 규칙적인 주기에 따라 반복되는 패턴을 나타내며, 주기가 규칙적이지 않지만 wave형태로 반복되는 패턴을 cyclical component로 분류하여 분석한다.

Trend,seasonality,cycles를 제외한 그외 불규칙한 fluctuation은 random component로 분류한다.

 

Decomposition of time-series data(Source: Forecasting: Principles and Practices)

일반적을 전통적인 시계열 분석 모델에서는 이 4가지 유형이 패턴이 linear한 조합으로 이루어진 모델(additive model)을 가정한다. 간혹 seasonal 또는 cyclic 패턴의 변동폭이 시계열 값의 크기에 따라서 함께 변하는 경우 multiplicative model을 가정하기도 한다. 분석 모델 안에서 패턴이 어떻게 구성되느냐에 따라 , 개별 component에 대한 수학적 모델 또한 매우 다양하다. 주어진 시계열 데이터에 맞는 모델을 찾아가는 과정이 예측 모델을 만들어 가는 과정이라고 볼 수 있다.

 

대표적인 전통 시계열 예측 모델: ARIMA

전통적으로 시계열 데이터에서 패턴을 추출하고 그 패턴을 이용하여 미래를 예측하는 모델로 Autoregressive integrated moving average(ARIMA)가 있다. 

ARIMA모델은 크게 세가지 component로 조합되어 만들어진다. 먼저 Autoregressive는 예측 시점(t-0)이 과거 p 개의 데이터 포인트(t-1,t-2,...,t-p)와 연관이 있다는 의미이다. 일련의 과거 관측값을 이용하여 미래의 값을 예측하는 regression 모델인 것이다. 

Moving average는 과거의 예측 에러를 예측 대상과 연관 시키는 component이다. 가령 autoregressive 모델로 미래를 예측함에 있어, 과거의 에러들을 고려하여 그 예측값을 보정하는 역할을 한다고 볼 수 있다. 마지막으로 integrated의 의미는 non-stationary time-series의 데이터를 differencing을 통해 시간에 따라 데이터의 통계적 특성(e.g. mean, std)이 변하지 않는 stationary time-series 데이터로 변환하는 모델을 의미한다. 적합한 ARIMA 예측 모델을 만들기 위해서는 differencing order을 조절하여 데이터로부터 non-stationary를 반드시 제거해야 한다.

 

ARIMA 모델은 추가적인 component를 고려함으로써 다양한 형태로 변형, 발전될 수 있다. 예를 들어 추가적인 seasonal component를 고려하는 SARIMA가 있으며, univariate time-series가 아닌 추가적인 covariates를 함께 고려하여 예측할 수 있는 ARIMAX 모델이 존재한다. 물론 이 두가지를 모두 혼합한 SARIMAX모델도 사용되곤 한다.

 

1. 수학과 파이썬 복습

벡터와 행렬

신경망에서는 '벡터'와 '행렬'(또는 텐서)이 등장한다.

 

벡터: 크기와 방향을 가지고 있는 양

행렬: 매트릭스라고도 하는데 행렬의 가로 줄을 행, 세로 줄을 열이라고 함

 

벡터와 행렬의 예

위 그림처럼 1차원 배열로, 행렬은 2차원 배열로 표현할 수있다. 행렬에서 가로줄은 행 세로줄은 열이라고 한다.

 

[Note]

파이선으로 구현할 때 벡터를 '행벡터'로 취급할 경우, 벡터를 가로 방향 '행렬'로 변환해 사용하면 명확해진다. 예컨대 원소 수가 N개인 벡터라면 1*N 형상의 행렬로 처리한다.

 

행렬의 원소별 연산

코드를 통해 간단하게 살펴보자

W=np.array([[1,2,3],[4,5,6]])
X=np.array([[0,1,2],[3,4,5]])
print(W+X)
print(W*X)

[[ 1  3  5]
 [ 7  9 11]]
[[ 0  2  6]
 [12 20 30]]

 

BroadCast

넘파이의 다차원 배열에서는 형상이 다른 배열끼리도 연산할 수 있다. 코드로 살펴보자

 

A=np.array([[1,2],[3,4]])
print(A*10)

[[10 20]
 [30 40]]
A=np.array([[1,2],[3,4]])
b=np.array([10,20])
A*b

array([[10, 40],
       [30, 80]])

위 2번째 계산에서는 1차원 배열인 b가 2차원 배열 A와 형상이 같아지도록 '영리하게' 확장된다.

 

브로드캐스트 예2

벡터의 내적과 행렬의 곱

2개의 벡터 X=(x1,x2,x3,....,xn) Y=(y1,y2,y3,...,yn)이 있다고 가정하자.

 

내적

X·Y=x1y1+x2y2+...+xnyn

벡터의 내적은 위와 같이 두 벡터에서 대응하는 원소들의 곱을 모두 더한 것이다.

 

행렬의 곱

 

헹렬의 곱셈 방법

위 그림처럼 행렬의 곱은 '왼쪽 행렬의 행벡터(가로방향)'와 '오른쪽 행렬의 열벡터(세로방향)'의 내적(원소별 곱의 합)으로 계산한다.

코드로 살펴보자

 

#벡터의 내적
a=np.array([1,2,3])
b=np.array([4,5,6])
np.dot(a,b)

32

 

#행렬의 곱
A=np.array([[1,2],[3,4]])
B=np.array([[5,6],[7,8]])
np.matmul(A,B)

array([[19, 22],
       [43, 50]])

*matmul은 matrix multiply의 약자

 

행렬 형상 확인

행렬이나 벡터를 사용해 계산할 때는 형상에 주의해야 한다. 

형상 확인: 행렬의 곱에서는 대응하는 차원의 원소 수를 일치시킨다.

[Note] 행렬의 곱 등 행렬을 계산할 때는 형상 확인이 중요하다. 그래야 신경망 구현을 부드럽게 진행할 수 있다.

 

신경망의 추론

신경망 추론 전체 그림

신경망의 예

이미 공부한 것이기에 간단하게 계산법과 그림만 보고 넘어가자. 위 그림은 fully-connected neural network 이다.

 

[첫번째 뉴런 계산(벡터)]

 

[첫번째 뉴련 계산(행렬)]

이를 간소하게 표현하면

*X: input h:hidden layer neuran W:weight b:bias

 

행렬의 곱에서는 대응하는 차원의 원소수가 같아야 한다고 했다. 

형상 확인: 대응하는 차원의 원소 수가 일치함(편향은 생략)

위 그림처럼 형상을 살펴보면 올바른 변환인지를 확인할 수 있다.

[Note]

행렬의 곱 계산에서는 행렬의 형상 확인이 중요하다. 형상을 보면 그 계산이 올바른 계산인지, 적어도 계산이 성립하는지를 확인할 수 있다.

 

형상 확인: 미니배치 버전의 행렬 곱(편향 생략)

위 그림처럼 형상확인을 통해 각 미니배치가 올바르게 변환되었는지를 알 수 있다.

코드로 실습해보면,

W1=np.random.randn(2,4) #가중치
b1=np.random.randn(4) #편향
x=np.random.randn(10,2) #입력
h=np.matmul(x,W1)+b1 
print(h)

[[ 0.35116155  0.94789119 -0.67386652 -0.65909502]
 [ 0.384877   -0.72901409  0.18267204  0.7529613 ]
 [ 0.20165288  0.13624162 -0.09941832  0.00564759]
 [ 1.32167646 -0.20576129 -0.99788172  0.41926761]
 [ 1.26638845 -0.1371531  -0.98095405  0.35541107]
 [ 1.58613619 -0.25255841 -1.2280294   0.48841506]
 [-0.50280274  2.07146982 -0.44627022 -1.69904766]
 [ 0.50183445  0.11793651 -0.37910997  0.05489936]
 [ 4.47713058 -3.74912125 -2.16138615  3.7510847 ]
 [-2.8574282   2.54573098  1.5723183  -2.36307749]]

*위 코드에서 마지막 줄에 편향 b1의 덧셈은 broadcast된다. b1의 형상은 (4,)이지만 자동으로 (10,4)로 복제되는 것.

위 코드는 fully-connected layer이며 변환은 선형변환이다. 

여기서 비선형 효과를 부여하는 것이 바로 활성화 함수이다. 

대표적인 예로 시그모이드함수를 사용하자.

 

시그모이드 함수 그래프

활성화함수에 대해서는 이미 포스팅했으니 넘어가겠다.

undeadkwandoll.tistory.com/16?category=909256

 

Chapter 3-1 신경망

가중치 매개 변수의 적절한 값을 데이터로부터 자동으로 학습하는 능력이 이제부터 살펴볼 신경망의 중요한 성질이다. 3.1 퍼셉트론에서 신경망으로 3.1.1 신경망의 예 신경망을 그림으로 나타내

undeadkwandoll.tistory.com

지금까지 한 것을 종합하여 코딩으로 구현해보자.

 

import numpy as np

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

x=np.random.randn(10,2)
W1=np.random.randn(2,4)
b1=np.random.randn(4)
W2=np.random.randn(4,3)
b2=np.random.randn(3)

h=np.matmul(x,W1)+b1
a=sigmoid(h)
s=np.matmul(a,W2)+b2

print(s)

[[ 1.37255171  3.33303451  0.51454931]
 [ 1.45573303  3.28327178  0.59098017]
 [ 1.66378388  2.76835385  0.05013013]
 [-0.01466047  2.41627885  0.00654713]
 [ 1.44857064  2.97941464  0.12383537]
 [ 1.07105157  3.19234836  0.20873783]
 [ 1.03450019  2.88402671  0.72500931]
 [ 0.95191827  3.17697541  0.52755519]
 [-0.14788331  2.33162532 -0.11776118]
 [ 1.16358013  3.10202918  0.73084865]]

계층으로 클래스화 및 순전파 구현

이제 신경망에서 하는 처리를 계층으로 구현해보자. 

완전연결계층: Affine계층으로 변환 활성화함수: Sigmoid 기본변환 메서드: forward()

구현 규칙:

-모든 계층은 forward()와 backward() 메서드를 가진다.

-모든 계층은 인스턴스 변수인 params와 grads를 가진다.

 

forward()와 backward()메서드는 각각 순전파와 역전파를 수행한다. params는 가중치와 편향 같은 매개변수를 담는 리스트이다. grads는 params에 저장된 각 매개변수에 대응하여, 해당 매개변수의 기울기를 보관하는 리스트이다.

 

이번에는 순전파만 구현할 것이므로 앞의 구현 규칙 중 다음 두 사항만 적용한다.

1. 각 계층은 forward() 메서드만 가진다.

2. 매개변수들은 params 인스턴스 변수에 보관한다.

 

코드를 통해 살펴보자.

import numpy as np

class Sigmoid:
    def __init__(self):
        self.params=[]
    def forward(self,x):
        return 1/(1+np.exp(-x))
    

class Affine:
    def __init__(self,W,b):
        self.params=[]
    def forward(self,x):
        W,b=self.params
        out=np.matmul(x,W)+b
        return out
    
    

위 두 클래스의 주 변환처리는 forward(x)가 담당한다.

Sigmoid 계층에는 학습하는 매개변수가 따로 없으므로 인스턴스 변수인 params는 빈 리스트로 초기화한다.

Affine 계층은 초기화될 때 가중치와 편향을 받는다. 즉, 가중치와 편향은 Affine 계층의 매개변수이며, 리스트인 params인스턴스 변수에 보관한다.

 

다음은 TwoLayerNet을 구현해보자.

class TwoLayerNet:
    def __init__(self,input_size,hidden_size,output_size):
        I,H,O=input_size,hidden_size,output_size
        
        #가중치와 편향 초기화
        W1=np.random.randn(I,H)
        b1=np.random.randn(H)
        W2=np.random.randn(H,O)
        b2=np.random.randn(O)
        
        #계층 생성
        self.layers=[Affine(W1,b1),Sigmoid(),Affine(W2,b2)]
        
        #모든 가중치를 리스트에 모은다
        self.params=[]
        for layer in self.layers:
            self.params +=layer.params
            
    def predict(self,x):
        for layer in self.layers:
            x=layer.forward(x)
            
        return x
    

x=np.random.randn(10,2)
model=TwoLayerNet(2,4,3)
s=model.predict(x)
print(s)

[[-1.63600236 -0.58829572  2.12459972]
 [-1.29363917 -0.62995628  2.00383472]
 [-2.18231262 -0.51745636  2.29597345]
 [-0.45702306 -0.56597897  1.78600372]
 [-1.63980911 -0.71859781  2.13101938]
 [-1.95776705 -0.89778093  2.23616852]
 [-1.31212998 -1.19684139  2.04171904]
 [-0.38606698 -0.6532667   1.78814915]
 [-0.96887503 -0.71086643  1.90366492]
 [-1.4403579  -0.96416926  2.07110086]]

이상으로 입력 데이터 x에 대한 점수(s)를 구할 수 있었다. 이처럼 계층을 클래스로 만들어두면 신경망을 쉽게 구현할 수 있다. 

자연어 처리에서 크롤링 등으로 얻어낸 corpus data가 필요에 맞게 전처리되지 않은 상태라면, 용도에 맞게 토큰화(tokenization) & 정제(cleaning) & 정규화(normalization)하는 일을 하게 된다. 

이중 토큰화에 대해 알아보자.

 

토큰화(tokenization): 주어진 코퍼스(corpus)에서 토큰(token)이라 불리는 단위로 나뉘는 작업(보통 의미있는 단위로 토큰을 정의한다)

 

1. 단어 토큰화(Word Tokenization)

토큰의 기준을 단어(word)로 하는 경우, 단어 토큰화(word tokenization)라고 한다. 

*단어(word)는 단어 단위 외에도 단어구, 의미를 갖는 문자열로도 간주되기도 한다.

 

input) Time is an illusion. Lunchtime double so!

 

위 입력으로부터 토큰화 작업을 시키면 다음과 같다.

 

output) 'Time', 'is', 'an', 'illusion', 'Lunchtime', 'double', 'so'

 

위 출력을 나타내기 위해, 구두점을 지운 뒤에 띄어쓰기(whitespace)를 기준으로 잘라냈다.(가장 기초)

보통 토큰화 작업은 단순히 구두점이나 특수문자를 전부 제거하는 정제(cleaning) 작업을 수행하는 것만으로 해결되지 않는다. 구두점이나 특수문자를 전부 제거하면 토큰의 의미를 잃어버리는 경우가 발생하기도 한다. 심지어 띄어쓰기 단위로 자르면 사실상 단어 토큰이 구분되는 영어와 달리, 한국어는 띄어쓰기만으로는 단어 토큰을  구분하기 어렵다.

 

2. 토큰화 중 생기는 선택의 순간

토큰화를 하다보면, 예상하지 못한 경우가 있어 토큰화의 기준을 생각해봐야 하는 경우가 발생한다. 물론, 이러한 선택은 해당 데이터를 가지고 어떤 용도로 사용할 것인가에 따라 그 용도에 영향이 없는 기준으로 정하면 된다.

 

ex) Don't be fooled by the dark sounding name, Mr. Jone's Orphanage is as cheery as cheery goes for a pastry shop.

 

" ' " 가 들어간 상황에서 Dont't 와 Jone's는 어떻게 토큰화할 수 있을까?

 

[Dont't, Dont t, Dont, Do n't, Jone's, Jone s, Jone, Jones]

 

원하는 결과가 나오도록 토큰화 도구를 직접 설계할 수 있겠지만, 기존에 공개된 도구들을 사용하였을 때의 결과가 사용자의 목적과 일치한다면 해당 도구를 사용할 수 있을 것이다. NLTK는 영어 코퍼스를 토큰화하기 위한 도구들을 제공한다.

 

word_tokenize를 사용한 예를 살펴보자.

 

from nltk.tokenize import word_tokenize

print(word_tokenize("Don't be fooled by the dark sounding name, Mr. Jone's Orpanage is as cheery as cheery goes for a pastry shop."))

['Do', "n't", 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', ',', 'Mr.', 'Jone', "'s", 'Orpanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop', '.']

word_tokenize는 Dont't 를 Do 와 n't로 불리하였으며, 반면 Jone's는 Jone과 's로 분리한 것을 확인할 수 있다.

 

wordPunctTokenizer를 사용한 예를 살펴보자

 

from nltk.tokenize import WordPunctTokenizer
print(WordPunctTokenizer().tokenize("Don't be fooled by the dark sounding name, Mr. Jone's Orpanage is as cheery as cheery goes for a pastry shop."))

['Don', "'", 't', 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', ',', 'Mr', '.', 'Jone', "'", 's', 'Orpanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop', '.']

WordPunctTokenizer는 구두점을 별도로 분류하는 특징을 갖고 있기 때문에, word_tokenize와 달리 Don't를 Don과 '와 t로 분리하였으며, 이와 마찬가지로 Jone's를 Jone과 '와 s로 분리한 것을 확인할 수 있다.

 

케라스 또한 토큰화 도구로서 text_to_word_sequence를 지원한다. 

text_to_word_sequence를 사용한 예를 살펴보자

 

from tensorflow.keras.preprocessing.text import text_to_word_sequence
print(text_to_word_sequence("Don't be fooled by the dark sounding name, Mr. Jone's Orphanage is as cheery as cheery goes for a pastry shop."))

["don't", 'be', 'fooled', 'by', 'the', 'dark', 'sounding', 'name', 'mr', "jone's", 'orphanage', 'is', 'as', 'cheery', 'as', 'cheery', 'goes', 'for', 'a', 'pastry', 'shop']

text_to_word_sequence는 기본적으로 모든 알파벳을 소문자로 바꾸면서 마침표나 컴마, 느낌표 등의 구두점을 제거한다. 하지만 어퍼스트로피는 보존하는 것을 볼 수 있다.

 

 

토큰화에서 고려해야할 사항

토큰화작업은 단순하게 코퍼스에서 구두점을 제외하고 공백 기준으로 잘라내는 작업이라 생각하면 안된다. 

이유는 다음과 같다.

1) 구두점이나 특수 문자를 단순 제외해서는 안된다.

코퍼스에 대한 정제 작업을 진행하다보면, 구두점 조차도 하나의 토큰으로 분류하기도 한다. 

이러한 점에 있어 해결법은 단어의 빈도수 확인 및 직접 확인을 통해 사전을 만들어주는 경우, 혹은 오픈소스로 되어있는 단어집을 사용하는 방법이 있다.

2) 줄임말과 단어 내에 띄어쓰기가 있는 경우.

토큰화 작업에서 종종 영어권 언어의 "'"는 압축된 단어를 다시 펼치는 역할을 하기도 한다.

사용 용도에 따라, 하나의 단어 사이에 띄어쓰기가 있는 경우도 하나의 토큰으로 봐야하는 경우가 있을 수 있다. 

 

3) 표준 토큰화 예제

표준으로 쓰이는 토큰화 방법 중 하나인 Peen Treebank Tokenization의 규칙을 알아보자.

Rule 1) 하이푼으로 구성된 단어는 하나로 유지.

Rule 2) dont't 와 같이 아포스토로피가 함께하는 단어는 분리해준다.

 

예를 살펴보자.

input) "Starting a home-based restaurant may be an ideal. it doesn't have a food chain or restaurant of their own."

 

from nltk.tokenize import TreebankWordTokenizer
tokenizer=TreebankWordTokenizer()
text="Starting a home-based restaurant may be an ideal. it doesn't have a food chain or restaurant of their own"
print(tokenizer.tokenize(text))

['Starting', 'a', 'home-based', 'restaurant', 'may', 'be', 'an', 'ideal.', 'it', 'does', "n't", 'have', 'a', 'food', 'chain', 'or', 'restaurant', 'of', 'their', 'own']

결과는, 각 Rule1)과 Rule2)에 따라 home-based는 하나의 토큰으로 취급하고 있으며, doesn't의 경우 does와 n't로 분리되었음을 볼 수 있다.

 

4. 문장 토큰화(Sentence Tokenization)

대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.

이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다.

 

어떤 기준으로 문장을 나누어야할까? 코퍼스가 어떤 국적의 언어인지, 또는 해당 코퍼스 내에서 특수문자들이 어떻게 사용되고 있는지에 따라서 직접 규칙을 정의해야 한다. 이는 또한 100%의 정확성을 얻는 것은 아니다. 

NLTK에서는 영어 문장의 토큰화를 수행하는 sent_tokenize를 지원한다.

 

코드로 살펴보자.

 

from nltk.tokenize import sent_tokenize
text="His barber kept his word. But keeping such a huge secret to himself was driving him crazy. Finally, the barber went up a mountain and almost to the edge of a cliff. He dug a hole in the midst of some reeds. He looked about, to make sure no one was near."
print(sent_tokenize(text))

['His barber kept his word.', 'But keeping such a huge secret to himself was driving him crazy.', 'Finally, the barber went up a mountain and almost to the edge of a cliff.', 'He dug a hole in the midst of some reeds.', 'He looked about, to make sure no one was near.']

위 코드는 text에 저장된 여러 개의 문장들로부터 문장을 구분하는 코드이다. 

이번에는 마침표가 여러번 등장하는 경우를 살펴보자.

 

from nltk.tokenize import sent_tokenize
text="I am actively looking for Ph.D. students. and you are a Ph.D student."
print(sent_tokenize(text))

['I am actively looking for Ph.D. students.', 'and you are a Ph.D student.']

 

NLTK는 단순히 마침표를 구분자로 하여 문장을 구분하지 않았기 때문에, ph.D.를 문장 내의 단어로 인식하여 성공적으로 인식하는 것을 볼 수 있다.

 

한국어 문장 토큰화 도구 KSS(Korean Sentence Splitter)

import kss

text="대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다."
print(kss.split_sentences(text))

['대부분 갖고있는 코퍼스가 정제되지 않은 상태라면, 코퍼스는 문장 단위로 구분되어있지 않을 가능성이 높다.', '이를 사용하고자 하는 용도에 맞게 하기 위해서는 문장 토큰화가 필요할 수 있다.']

 

5. 토큰화가 어려운 한국어

한국어는 띄어쓰기가 되어있는 단어를 '어절'이라 한다. 어절 토큰화는 NLP에서 지양되고 있다. 어절 토큰화와 단어 토큰화가 같지 않기 때문인데, 그 근본적인 이유는 한국어가 영어와는 다르게 다른 형태를 가지는 언어인 교착어라는 점에서 기인한다.

 

교착어: 언어의 형태적 유형의 하나. 실질적인 의미를 가진 단어 또는 어간에 문법적인 기능을 가진 요소가 차례로 결합함으로써 문장 속에서의 문법적인 역할이나 관계의 차이를 나타내는 언어로, 한국어ㆍ터키어ㆍ일본어ㆍ핀란드어 따위가 여기에 속한다.

 

자연어 처리를 하다보면 같은 단어여도 서로 다른 조사가 붙어 다른 단어로 인식이 되는 경우가 많다. 대부분 한국어 NLP에서 조사는 분리해줄 필요가 있다.

 

한국어 토큰화에서는 형태소(morpheme)란 개념을 반드시 알아야 한다.

 

형태소: 단어를 분석한 단위로, 뜻을 가진 가장 작은 말의 단위

 

또한 한국어는 띄어쓰기가 영어보다 잘 지켜지지 않는다.

대부분의 한국어 경우 띄어쓰기가 틀렸거나, 잘 지켜지지 않은 코퍼스가 많다. 한국어는 영어권 언어와 달리 띄어쓰기가 어렵고 잘 지켜지지 않는 경향이 있다.

하지만, 실제로 잘 이루어지지 않더라도 이해가 가능한것이 또 한국어이다.

 

[한국어]

지금이렇게띄어쓰기를안한다해도이해못하는사람없겠죠?

 

[영어]

Itisdifficulttounderstandenglishbecauseitdidn'tsplitsentence

 

이 차이는 한국어(모아쓰기 방식)와 영어(풀어쓰기 방식)라는 언어적 특성의 차이 때문이다.

 

'Deep Learning > NLP' 카테고리의 다른 글

트랜스포머(Transformer)(2)  (0) 2021.03.04
트랜스포머(Transformer)(1)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

합성곱/풀링 계층 구현하기

4차원 배열

앞선 포스팅에서 설명한대로 CNN에서 계층 사이를 흐르는 데이터는 4차원이다. 파이썬으로 구현해보자.

import numpy as np

x=np.random.rand(10,1,28,28)
x.shape

(10, 1, 28, 28)

*첫번째 데이터에 접근하려면 단순히 x[0]이라고 쓴다(파이썬의 인덱스는 0부터 시작한다).

 

x[0].shape #(1,28,28)
x[1].shape #(1,28,28)

 

*또, 첫 번째 데이터의 첫 채널의 공간 데이터에 접근하려면 다음과 같이 구현한다.

x[0,0].shape #또는 x[0][0]

 

im2col로 데이터 전개하기

합성곱 연산을 곧이곧대로 구현하려면 for 문을 겹겹이 써야한다. 하지만 넘파이에 for문을 사용하면 성능이 떨어지는 단점이 있다.(넘파이에서는 원소에 접근할 때 for문을 사용하지 않는 것이 바람직하다).

그렇기에 for 문 대신 im2col이라는 편의 함수를 사용해 간단하게 구현해보자.

(대략적인) im2col의 동작

im2col은 입력 데이터를 필터링(가중치 계산)하기 좋게 전개하는 함수이다. 위 그림과 같이 3차원 입력 데이터에 im2col 을 적용하면 2차원 행렬로 바뀐다. 

정확히는 배치 안의 데이터 수까지 포함한 4차원 데이터를 2차원으로 변환한다.

필터 적용 영역을 앞에서부터 순서대로 1줄로 펼친다.

im2col은 필터링하기 좋게 입력 데이터를 전개한다. 구체적으로는 위 그림과 같이 입력 데이터에서 필터를 적용하는 영역(3차원 블록)을 한줄로 늘어놓는다. 이 전개를 필터를 적용하는 모든 영역에서 수행하는게 im2col이다.

위 그림에서는 보기에 좋게끔 스트라이드를 크게 잡아 필터의 적용 영역이 겹치지 않도록 했지만, 실제 상황에서는 영역이 겹치는 경우가 대부분이다. 

필터 적용 영역이 겹치면 im2col로 전개한 후의 원소 수가 블록의 원소 수보다 많아진다.(이에 메모리를 더 소비하는 단점도 있다)

 

[Note]

im2col은 'image to column' 즉 '이미지에서 행렬로'라는 뜻이다. 카페와 체이너등의 딥러닝 프레임워크는 im2col이라는 이름의 함수를 만들어 합성곱 계층을 구현할 때 이용하고 있다.

 

합성곱 연산의 필터 처리 상세 과정: 필터를 세로로 1열로 전개하고, im2col이 전개한 데이터와 행렬 내적을 계산한다. 마지막으로 출력데이터를 reshape 한다.

  im2col 함수

def im2col(input_data, filter_h, filter_w, stride=1, pad=0):
    N, C, H, W = input_data.shape
    out_h = (H + 2 * pad - filter_h) // stride + 1
    out_w = (W + 2 * pad - filter_w) // stride + 1

    img = np.pad(input_data, [(0, 0), (0, 0), (pad, pad), (pad, pad)], 'constant')
    col = np.zeros((N, C, filter_h, filter_w, out_h, out_w))

    for y in range(filter_h):
        y_max = y + stride * out_h
        for x in range(filter_w):
            x_max = x + stride * out_w
            col[:, :, y, x, :, :] = img[:, :, y:y_max:stride, x:x_max:stride]

    col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1)
    return col
x1=np.random.rand(1,3,7,7) #데이터의 수,채널 수, 높이, 너비
col1=im2col(x1,5,5,stride=1,pad=0)
print(col1.shape) 

(9, 75)

위 예는 batchsize=1(data=1), channel=3, height*width=7*7 

x2=np.random.rand(10,3,7,7)
col2=im2col(x2,5,5,stride=1,pad=0)
print(col2.shape)

(90, 75)

위 예는 batchsize=10(data=10),channel=3,height*width=7*7

 

위 두 예 모두 2번째 차원의 원소는 75개이다. 이 값은 필터의 원소 수와 같다(channel=3, 5*5 data).

 

또한, batchsize=1일 때는 im2col의 결과의 크기가 (9,75)이고, 10일 때는 그 10배인 (90,75) 크기의 데이터가 저장된다.

 

다음은 Convolution class를 구현해보자.

class Convolution:
    def __init__(self,W,b,stride=1,pad=0):
        self.W=W
        self.b=b
        self.stride=stride
        self.pad=pad
    def forward(self,x):
        FN,C,FH,FW=self.W.shape
        N,C,H,W=x.shape
        out_h=int(1+(H+2*self.pad-FH)/self.stride)
        out.w=int(1+(W+2*self.pad-FW)/self.stride)
        
        col=im2col(x,FH,FW,self.stride,self.pad)
        col_W=self.W.reshape(FN,-1).T #필터 전개
        out=np.dot(col,col_W)+self.b
        
        out=out.reshape(N,out_h,out_w,-1).transpose(0,3,1,2)
        
        return out

 

 

합성곱 계층은 필터(가중치), 편향, 스트라이드, 패딩을 인수로 받아 초기화한다.

필터는 (FN,C,FH,FW)의 4차원 형상이다. 여기서 FN은 필터 개수, C는 채널, FH는 필터 높이, FW는 필터 너비이다.

 

col=im2col(x,FH,FW,self.stride,self.pad)
col_W=self.W.reshape(FN,-1).T #필터 전개
out=np.dot(col,col_W)+self.b

위 코드 3줄이 가장 중요하다. 위 코드는 input_data를 im2col로 전개하고 필터도 reshape하여 2차원 배열로 전개한다. 그 후 내적을 진행한다.

 

forward 구현의 마지막에는 넘파이의 transpose 함수를 사용하는데, 이는 다차원 배열의 축 순서를 바꿔주는 함수이다.

np.transpose() 원리

풀링 계층 구현하기

풀링 계층 구현도 함성곱 계층과 마찬가지로 im2col을 사용해 입력 데이터를 전개한다. 단, 풀링의 경우엔 채널 쪽이 독립적이라는 점이 합성곱 계층 때와 다르다. 즉, 풀링 적용 영역을 채널마다 독립적으로 전개한다.

입력 데이터에 풀링 적용 영역을 전개(2*2 풀링의 예)

우선 이렇게 전개한 후, 전개한 행렬에서 행별 최댓값을 구하고 적절한 형상으로 성형하면된다.

풀링 계층 구현의 흐름: 풀링 적용 영역에서 가장 큰 원소는 회색을 표시

 

풀링계층을 코딩으로 구현해보자.

class Pooling:
    def __init__(self,pool_h,pool_w,stride=1,pad=0):
        self.pool_h=pool_h
        self.pool_w=pool_w
        self.stride=stride
        self.pad=pad
        
    def forward(self,x):
        N,C,H,W=x.shape
        out_h=int(1+(H-self.pool_h)/self.stride)
        out_w=int(1+(W-self.pool_w)/self.stride)
        
        #전개(1)
        col=im2col(x,self.pool_h,self.pool_w,self.stride,self.pad)
        col=col.reshape(-1,self.pool*self.pool_w)
        
        #최댓값(2)
        out=np.max(col,axis=1)
        
        #성형(3)
        out=out.reshape(N,out_h,out_w,C).transpose(0,3,1,2)
        return out

[Note]

최댓값 계산에는 넘파이의 np.max 메서드를 사용할 수 있다. np.max는 인수로 축(axis)을 지정할 수 있는데, 이 인수로 지정한 축마다 최댓값을 구할 수 있다. 가령 np.max(x,axis=1)과 같이 쓰면 입력 x의 1번째 축마다 최댓값을 구한다.

 

CNN 구현하기

합성곱 계층과 풀링 계층을 구현했으니, 조합하여 손글자 인식 CNN을 조립해보자.

단순한 CNN의 네트워크 구성

초기화 때 받는 인수

-input_dim :입력 데이터(채널 수, 높이,너비)의 차원

-conv_param: 함성곱 계층의 하이퍼파라미터(딕셔너리). 딕셔너리 키는 다음과 같다.

   filter_num: 필터의 수

   filter_size: 필터 크기

   stride: 스트라이드

   pad: 패딩

   hidden_size: 은닉층(fully-connected)의 뉴런 수

   output_size: 출력층(fully_connected)의 뉴런 수

   weight_init_std: 초기화 때의 가중치 표준편차

 

여기서 CNN 계층의 하이퍼파라미터는 딕셔너리 형태로 주어진다(conv_param).

 

class SimpleConvNet:
    def __init__(self,input_dim=(1,28,28),
                conv_param={'filter_num':30,'filter_size':5,'pad':0,'stride':1},
                hidden_size=100,output_size=10,weight_init_std=0.01):
        filter_num=conv_param['filter_num']
        filter_size=conv_param['filter_size']
        filter_pad=conv_param['pad']
        filter_stride=conv_param['stride']
        input_size=input_dim[1]
        conv_output_size=(input_size-filter_size+2*filter_pad)/filter_stride+1
        pool_output_size=int(filter_num*(conv_output_size/2)*(conv_output_size/2))
        
        
        self.params={}
        self.params['W1']=weight_init_std*np.random.randn(filter_num,input_dim[0],filter_size,filter_size)
        self.params['b1']=np.zeros(filter_num)
        self.params['W2']=weight_init_std*np.random.randn(pool_output_size,hidden_size)
        self.params['b2']=np.zeros(hidden_size)
        self.parmas['W3']=weight_init_std*np.random.randn(hidden_size,output_size)
        self.params['b3']=np.zeros(output_size)
        
        self.layers=OrderedDict()
        self.layers['Conv1']=Convolution(self.params['W1'],self.params['b1'],conv_param['stride'],conv_param['pad'])
        self.layers['Relu1']=Relu()
        self.layers['Pool1']=Pooling(pool_h=2,pool_w=2,stride=2)
        self.layers['Affine1']=Affine(self.params['W2'],self.params['b2'])
        
        self.layers['Relu2']=Relu()
        self.layers['Affine2']=Affine(self.params['W3'],self.params['b3'])
        
        self.last_layer=SoftmaxWithLoss()
    def predict(self,x):
        for layer in self.layers.values():
            x=layer.forward(x)
        return x
    
    def loss(self,x,t):
        y=self.predict(x)
        return self.last_layer.forward(y,t)
    
    def gradient(self,x,t):
        #순전파
        self.loss(x,t)
        
        #역전파
        dout=1
        dout=self.last_layer.backward(dout)
        
        layers=list(self.layers.values())
        layers.reverse()
        for layer in layers:
            dout=layer.backward(dout)
        
        #결과 저장
        grads={}
        grads['W1']=self.layers['Conv1'].dW
        grads['b1']=self.layers['Conv1'].db
        grads['W2']=self.layers['Affine1'].dW
        grads['b2']=self.layers['Affine1'].db
        grads['W3']=self.layers['Affine2'].dW
        grads['b3']=self.layers['Affine2'].db
        
        return grads
    
    
    

 

이상으로 DeepLearning from Scratch 밑바닥부터 시작하는 딥러닝1을 전부 공부하며 리뷰했다. 다음은 

딥러닝 2를 리뷰하겠다.

CNN(Convolutional neural network)은 이미지 인식과 음성 인식 등 다양한 곳에서 사용된다.

특히, 이미지 인식 분야에서 딥러닝을 활용한 기법은 거의 CNN을 기초로 한다.

 

전체 구조

CNN도 지금까지 본 신경망과 같이 레고 블록처럼 계층을 조합하여 만들 수 있다. 다만, 합성곱 계층(Convolutional layer)과 풀링 계층(pooling layer)이 등장한다.

 

복기하자.

완전연결(fully-connected): 인접하는 계층의 모든 뉴런과 결합되어 있는 신경망.

 

위 계층을 Affine 계층이라는 이름으로 구현했다.

완전연결 계층(Affine 계층)으로 이뤄진 네트워크의 예

위 그림과 같이 완전연결 신경망은 Affine 계층 뒤에 활성화 함수를 갖는 ReLU 계층(or Sigmoid 계층)이 이어진다.

이 그림에서는 Affine-ReLU 조합이 4개가 쌓였고, 마지막 5번째 층은 Affine 계층에 이어 Softmax 계층에서 최종 결과(확률)를 출력한다.

CNN으로 이뤄진 네트워크의 예: 합성곱 계층과 풀링 계층이 새로 추가(회색)

위 그림은 새로운 '합성곱 계층'과 '풀링 계층'이 추가된다. 

CNN의 계층은 'Conv-ReLU-(Pooling)'흐름으로 연결된다.(Pooling 계층은 필요에 따라 생략하기도 한다)

 

여기서 주목할 점은 출력에 가까운 층에서는 지금까지의 완전연결 신경망의 'Affine-ReLU'구성을 사용할 수 있다는 것이다. 또한, 마지막 출력 계층에서는 'Affine-Softmax' 조합을 그대로 사용한다.

이상이 일반적인 CNN 구성이다.

 

합성곱 계층

CNN에서는 패딩(padding), 스트라이드(stride) 등 CNN 고유의 용어가 등장한다. 또한, 각 계층 사이에는 3차원 데이터같이 입체적인 데이터가 흐른다는 점에서 fully-connected와 다르다. 

 

완전연결 계층의 문제점

간단하게 말하면 fully-connected 계층은 '데이터의 형상이 무시'된다는 점이다.

예를 들어 데이터가 이미지인 경우를 생각해보자. 이미지는 통상 세로 채널(색상)으로 구성된 3차원 데이터이다.

그러나 완전연결 계층에 입력할 때는 3차원 데이터를 평평한 1차원데이터로 평탄화해줘야 한다. 

이전까지의 MNIST 데이터셋을 사용한 사례에서는 형상이 (1,28,28)인 이미지(1채널, 세로 28픽셀, 가로 28픽셀)를 1줄로 세운 784개의 데이터를 첫 Affine 계층에 입력했다.

 

이미지는 3차원 형상이며, 이 형상에는 공간적 정보가 담겨있다. 예를 들어 공간적으로 가까운 픽셀은 값이 비슷하거나, RGB의 각 채널은 서로 밀접하게 관련되어 있거나, 거리가 먼 픽셀끼리는 별 연관이 없는 등, 3차원 속에서 의미를 갖는 본질적인 패턴이 숨어 있을 것이다. 

 

이에 완전연결 계층은 형상(공간적 정보)은 무시하고 모든 입력 데이터를 동등한 뉴런(같은 차원의 뉴런)으로 취급하여 형상에 담긴 정보를 살릴 수 없다.

 

하지만 합성곱 계층은 형상을 유지한다. 이미지도 3차원 데이터로 입력받으며, 마찬가지로 다음 계층에도 3차원 데이터로 전달한다. 

그래서 CNN에서는 이미지처럼 형상을 가진 데이터를 제대로 이해할 (가능성이 있는) 것이다.

 

CNN에서는 합성곡 계층의 입출력 데이터를 특징 맵(feature map)이라고 한다. 합성곱 계층의 입력 데이터를 입력 특징 맵(input feature map), 출력 데이터를 출력 특징맵(output feature map)이라고 한다.

 

합성곱 연산

합성곱 계층에서의 합성곱 연산을 처리한다. 합성곱 연산은 이미치 처리에서는 필터연산에 해당한다.

합성곱  연산의 예: 합성곱 연산을 *기호로 표기

위 그림과 같이 합성곱 연산은 입력 데이터에 필터를 적용한다. 입력 데이터는 세로*가로 방향의 형상을 가졌고, 필터 역시 세로*가로 방향의 차원을 갖는다. 데이터와 필터의 형상을 (height,width)로 표기하며, 이 예에서는 입력은 (4,4),필터는 (3,3), 출력은 (2,2)가 된다. 문헌에 따라 필터를 커널이라고 칭하기도 한다.

 

합성곱 연산은 필터의 윈도우를 일정 간격으로 이동해가며 입력 데이터에 적용한다. 

합성곱 연산의 계산 순서

여기서 말하는 윈도우는 위 그림에서 회색 3*3 부분을 가리킨다. 그림과 같이 입력 필토에서 대응하는 원소끼리 곱한 후 그 총합을 구한다.(이 계산을 단일 곱셈-누산 fused multiply-add,FMA라 한다).

그 후 결과를 출력의 해당 장소에 저장한다. 이 과정을 모든 장소에서 수행하면 합성곱 연산의 출력이 완성된다.

 

*fully-connected neural network에는 가중치 매개변수와 편향이 존재한다. 

*CNN에서는 필터의 매개변수가 그동안의 '가중치'에 해당한다. 또한 편향도 존재한다.

 

합성곱 연산의 편향: 필터를 적용한 원소에 고정값(편향)을 더한다.

 

패딩(Padding)

패딩이란 합성곱 연산을 수행하기 전에 입력 데이터 주변을 특정 값 으로 채우는 것을 말한다.

합성곱 연산에서는 자주 이용하는 기법이다. 

합성곱 연산의 패딩 처리: 입력 데이터 주위에 0을 채운다(패딩은 점선으로 표시했으며 그 안의 값'0'은 생략)

위 그림과 같이 처음 크기가 (4,4)인 입력 데이터에 패딩이 추가되어 (6,6)이 된다. 이 입력에 (3,3)크기의 필터를 걸면 (4,4) 크기의 출력 데이터가 생성된다. 

위 예는 패딩을 1로 설정했지만, 2로 설정하면 입력 데이터의 크기는 (8,8)이 되고 3으로 설정하면 (10,10)이 된다.

 

[Note]

패딩은 주로 출력 크기를 조정할 목적으로 사용한다. 예를 들어 (4,4) 입력 데이터에 (3,3)필터를 적용하면 출력은 (2,2)가되어 입력보다 2만큼 줄어든다. 이는 합성곱 연산을 몇 번이나 되플이하는 심층 신경망에서는 문제가 될 수 있다. 합성곱 연산을 거칠 때마다 크기가 작아지면 어느 시점에서는 출력 크기가 1이 되버린다. 즉, 더 이상 합성곱 연산을 적용할 수 없다는 뜻이다. 이를 막기 위해 패딩을 사용한다. 앞의 예에서는 패딩의 폭을 1로 설정하니 (4,4) 입력에 대한 출력이 같은 크기인 (4,4)로 유지되었다. 

 

스트라이드(Stride)

스트라이드란 필터를 적용하는 위치의 간격을 말한다. 지금까지 본 예는 스트라이드가 1이었지만, 스트라이드를 2로 하면 필터를 적용하는 윈도우가 두 칸씩 이동한다.

스트라이드가 2인 합성곱 연상

스트라이드를 2로 하면 출력은 (3,3)이 된다. 이처럼 스트라이드를 키우면 출력 크기는 작아진다. 

이와 반대로 패딩을 크게 하면 출력 크기는 커졌다. 수식으로 확인해보자.

입력 크기를 (H,W), 필터 크기를(FH,FW), 출력 크기를 (OH,OW), 패딩을 P, 스트라이드를 S라 하면, 출력크기는 다음과 같다.

 

위 예들처럼 단순히 값을 대입하기만 하면 출력 크기를 구할 수 있다.

단, OH와 OW가 정수로 나눠떨어지는 값이어야 한다는 점에 주의하자.

출력 크기가 정수가 아니면 오류를 내는 등의 대응을 해줘야 한다. 덧붙여, 딥러닝 프레임워크 중에는 값이 딱 나눠떨어지지 않을 때는 가장 가까운 정수로 반올림하는 등, 특별히 에러를 내지 않고 진행하도록 구현하는 경우도 있다.

 

3차원 데이터의 합성곱 연산

지금까지 2차원 형상을 다루는 합성곱 연산을 보았다. 그러나 이미지는 3차원 데이터라고 앞서 언급했다. 3차원 합성곱 연산에 대해 알아보자.

합성곱 연산의 예
3차원 데이터 합성곱 연산의 계산 순서

위 두 그림을 보면 길이 방향(채널 방향)으로 특징 맵이 늘어났다. 채널 쪽으로 특징 맵이 여러 개 있다면 입력 데이터와 필터의 합성곱 연산을 채널마다 수행하고, 그 결과를 더해서 하나의 출력을 얻는다.

 

주의할 점은 입력 데이터의 채널 수와 필터의 채널 수가 같아야 한다.

한편, 필터 자체의 크기는 원하는 값으로 설정할 수 있다.(단, 모든 채널의 필터가 같은 크기여야 한다).

위 예에서는 필터의 크기가 (3,3)이지만, 원한다면 (2,2)나 (1,1) 혹은 (5,5)등으로 설정해도 된다.

 

블록으로 생각하기

3차원의 합성곱 연산은 데이터와 필터를 직육면체 블록이라고 생각하면 쉽다.

합성곱 연산을 직육면체 블록으로 생각한다. 블록의 형상에 주의할 것!

예를 들어 채널 수 C,높이 H, 너비 W인 데이터의 형상은 (C,H,W)로 쓴다. 필터도 같은 순서로 쓴다. 

채널 수 C, 필터 높이(FH filter height), 필터 너비(FW filter width)로 쓴다.

 

합성곱 연산의 출력으로 다수의 채널을 내보내려면 어떻게 해야 할까? 답은 필터(가중치)를 다수 사용하는 것이다.

여러 필터를 사용한 합성곱 연산의 예

위 그림과 같이 필터를 FN개 적용하면 출력 맵도 FN개가 생성된다. 그 FN개의 맵을 모으면 형상이 (FN,OH,OW)인 블록이 완성된다. 

이 완성된 블록을 다음 계층으로 넘기겠다는 것이 CNN의 처리 흐름이다.

 

합성곱 연산에서는 필터의 수도 고려해야 한다. 그런 이유로 필터의 가중치 데이터는 4차원 데이터이다. 예를 들어 채널 수 3, 크기 5*5 인 필터가 20개 있다면 (20,3,5,5)로 쓴다.

 

합성곱 연산에도 (완전연결 계층과 마찬가지로) 편향이 쓰인다. 

 

합성곱 연산의 처리 흐름(편향 추가)

위 그림과 같이 편향은 채널 하나에 값 하나씩으로 구성된다. 위 예에서 편향의 형상은 (FN,1,1)이고, 필터의 출력 결과의 형상은 (FN,OH,OW)이다. 이들 두 블록을 더하면 편향의 각 값이 필터의 출력인 (FN,OH,OW) 블록의 대응 채널의 원소 모두에 더해진다. 

 

배치 처리

신경망 처리에서는 입력 데이터를 한 덩어리로 묶어 배치로 처리했다. 완전연결 신경망을 구현하면서는 이 방식을 지원하여 처리 효율을 높이고, 미니배치 방식의 학습도 지원하도록 했다. 합성곱 연산도 마찬가지로 배치 처리를 지원하고자 한다. 각 계층을 흐르는 데이터의 차원을 하나 늘려 4차원 데이터로 저장한다. 구체적으로는 데이터를 (데이터 수, 채널 수, 높이, 너비) 순으로 저장한다. 

합성곱 연산의 처리 흐름(배치 처리)

위 그림을 보면 각 데이터의 선두에 배치용 차원을 추가했다. 이처럼 데이터는 4차원 형상을 가진 채 각 계층을 타고 흐른다. 주의할 점으로는 신경망에 4차원 데이터가 하나 흐를 때마다 데이터 N개에 대한 합성곱 연산이 이뤄진다는 것이다. 즉, N회 분의 처리를 한번에 수행하는 것이다.

 

 

풀링(Pooling) 계층

풀링은 세로 가로 방향의 공간을 줄이는 연산이다. 

최대 풀링의 처리 순서

위 그림은 2*2 최대 풀링(max pooling)을 스트라이드 2로 처리하는 순서이다. 최대 풀링은 최댓값을 구하는 연산으로, '2*2'는 대상 영역의 크기를 뜻한다. 즉 2*2 최대 풀링은 그림과 같이 2*2 크기의 영역에서 가장 큰 원소 하나를 꺼낸다. 

그 후 윈도우가 2칸 간격으로 이동한다. 

참고로 풀링의 윈도우 크기와 스트라이드는 같은 값으로 설정하는 것이 보통이다.

 

[Note]

최대 풀링(max pooling): 대상 영역(윈도우 영역) 에서 최댓값을 취하는 연산

평균 풀링(average poling): 대상 영역(윈도우 영역)에서의 평균을 계산한다. 

*이미지 인식 분야에서는 주로 최대 풀링을 사용한다. 

 

풀링 계층의 특징

학습해야 할 매개변수가 없다.

풀링은 대상 영역에서 최댓값이나 평균을 취하는 명확한 처리이므로 특별히 학습할 것이 없다.

 

채널 수가 변하지 않는다.

풀링 연산은 입력 데이터의 채널 수 그대로 출력 데이터로 내보낸다. 독립적으로 계산하기 때문.

풀링은 채널 수를 바꾸지 않는다.

입력의 변화에 영향을 적게 받는다(강건하다)

입력 데이터가 조금 변해도 풀링의 결과는 잘 변하지 않는다. 

입력 데이터가 가로로 1원소만큼 어긋나도 출력은 같다(데이터에 따라 다를 수도 있다).

 

Batch Normalization

앞전 포스팅에서 각 층의 활성화 값 분포를 관찰하며, 가중치의 초깃값을 적절히 설정하면 각 층의 활성화값 분포가 적당히 퍼지며, 학습이 원만하게 수행됨을 배웠다.

그렇다면,

각 층이 활성화를 적당히 퍼뜨리도록 '강제' 해보면 어떨까?

 

Batch Normalization Algorithm

배치 정규화가 주목받는 이유는 3가지가 있다.

1) 학습을 빨리 진행할 수 있다(학습 속도 개선)

2) 초깃값에 크게 의존하지 않는다(골치 아픈 초깃값 선택 장애 해소)

3) 오버피팅을 억제한다(드롭아웃 등의 필요성 감소)

 

배치 정규화를 사용한 신경망의 예

배치 정규화는 그 이름과 같이 학습 시 미니배치를 단위로 정규화한다. 데이터 분포가 평균이 0, 분산이 1이 되도록 정규화 하는 것이다.

수식은 다음과 같다.

미니배치 B={x1,x2,x....,xm}이라는 M개의 입력 데이터의 집합에 대해 평균 수식 1과 분산 수식 2를 구한다.

이 처리를 활성화 함수의 앞(혹은 뒤)에 삽입함으로써 데이터 분포가 덜 치우치게 할 수 있다.

또, 배치 정규화 계층마다 이 정규화된 데이터에 고유한 확대와 이동변환을 수행한다.

수식은 다음과 같다.

 

이 식에서 람다가 확대(scale)를 베타가 이동(shift)를 담당한다. 두 값은 처음에는 각 1,0부터 시작하고 학습하면서 적합한 값으로 조정해간다.

배치 정규화 계산 그래프

Batch Normalization 효과

실선이 배치 정규화를 사용한 경우, 점선이 사용하지 않은 경우: 가중치 초깃값의 표준편차는 각 그래프 위에 표기

MNIST데이터셋을 이용해 배치정규화 진도율을 확인해보았다. 거의 모든 경우에서 배치 정규화를 사용할 때의 학습 진도가 빠른 것을 나타난다. 실제로 이용하지 않는 경우, 초깃값이 잘 분포되어 있지 않으면 학습이 전혀 진행되지 않는 모습도 확인된다.

 

바른 학습을 위해

기계학습에서는 오버피팅이 문제가 되는 일이 많다. 

오버피팅이란 신경망이 훈련데이터에만 지나치게 적응되어 그 외의 데이터에는 제대로 대응하지 못하는 상태를 말한다.

우리의 목표는 기계학습의 범용 성능이다. 훈련 데이터에는 포함되지 않는, 아직 보지 못한 데이터가 주어져도 바르게 식별해내는 모델이 바람직하다. 

오버피팅

오버피팅이 일어날 가장 주된 경우는 2가지가 있다.

1)매개변수가 많고 표현력이 높은 모델

2)훈련 데이터가 적을때

 

다음 코드는 일부로 오버피팅을 일으킨 코딩이다.

(x_train,t_train),(x_test,t_test)=load_mnist(normalize=True)
#오버피팅을 재현하기 위해 학습 데이터 수를 줄임
x_train=x_train[:300]
t_train=t_train[:300]

network=MultiLayerNet(input_size=784,hidden_size_list=[100,100,100,100,100,100],output_size=10)
#학습률이 0.01인 SGD로 매개변수 갱신
optimizer=SGD(lr=0.01)
max_epochs=201
train_size=x_train.shape[0]
batch_size=100

train_loss_list=[]
train_acc_list=[]
test_acc_list=[]

iter_per_epoch=max(train_size/batch_size,1)
epoch_cnt=0

for i in range(100000000):
    batch_mask=np.random.choice(train_size,batch_size)
    x_batch=x_train[batch_mask]
    t_batch=t_train[batch_mask]
    
    grads=network.gradient(x_batch,t_batch)
    optimizer.update(network.params,grads)
    
    if i%iter_per_epoch==0:
        train_acc=network.accuracy(x_train,t_train)
        test_acc=network.accuracy(x_test,t_test)
        train_acc_list.append(train_acc)
        test_acc_list.append(test_acc)
        
        epoch_cnt+=1
        if epoch_cnt>=max_epochs:
            break

훈련 데이터를 사용하여 측정환 정확도는 100 에폭을 지나는 무렵부터 거의 100%이다. 

하지만, 시험 데이터에 대해서는 큰 차이를 보인다. 이처럼 정확도가 크게 벌어지는 것은  훈련데이터에만 적응해버린 것이다.

 

가중치 감소

오버피팅 억제용으로 예로부터 많이 이용해온 방법 중 가중치 감소(weight decay)가 있다.

이는 큰 가중치에 대해서는 그에 상응하는 큰 페널티를 부과하여 오버피팅을 억제하는 방법이다. 원래 오버피팅은 가중치 매개변수의 값이 커서 발생하는 경우가 많기 때문이다. 

이와 비슷한 것이 통계학에서는 회귀분석시 수치적 오류가 나는 것을 피하기위해 표준상관계수분석등을 추가로 하는 이유가 이런 것이다.(?)라고 한번 생각해본다.

신경망 학습의 목적은 손실 함수의 값을 줄이는 것이다. 이때 예를 들어 가중치의 제곱 norm을 손실함수에 더해주는 방법도 있다.

 

드롭아웃

가중치 감소는 간단하게 구현할 수 있고 어느 정도 지나친 학습을 억제할 수 있다. 그러나 신경망 모델이 복잡해지면 가중치 감소만으로는 대응하기 어려워진다. 이를 흔히 드롭아웃(drop out)이라는 기법을 이용한다.

 

드롭아웃이란 뉴런을 임의로 삭제하면서 학습하는 방법이다.

훈련 때 은닉층의 뉴런을 무작위로 골라 삭제한다. 삭제된 뉴런은 아래 그림과 같이 신호를 전달하지 않게 된다. 훈련때는 데이터를 흘릴 때마다 삭제할 뉴런을 무작위로 선택하고, 시험 떄는 모든 뉴런에 신호를 전달한다. 

단, 시험 때는 각 뉴런의 출력에 훈련 때 삭제한 비율을 곱하여 출력한다.

 

드롭아웃의 개념: 왼쪽이 일반적인 신경망, 오른쪽이 드롭아웃을 적용한 신경망, 드롭아웃은 뉴런을 무작위로 선택해 삭제하여 신호 전달을 차단한다.

코딩을 살펴보자.

class Dropout:
    def __init__(self,dropout_ratio=0.5):
        self.dropout_ratio=dropout_ratio
        self.mask=None
    def forward(self,x,train_flg=True):
        if train_flg:
            self.mask=np.random.rand(*x.shape)>self.dropout_ratio
            return x*self.mask
    def backward(self,dout):
        return dout*self.mask

핵심은 훈련 시에는 순전파 때마다 self.mask에 삭제할 뉴런을 False로 표시한다는 것이다. 

self.mask는 x와 형상이 같은 배열을 무작위로 생성하고, 그 값이 dropout_ratio보다 큰 원소만 True로 설정한다.

역전파 때의 동작은 ReLU와 같다.

즉, 순전파때 신호를 통과시키는 뉴런은 역전파 때도 신호를 그대로 통과시키고, 순전파 때 통과시키지 않은 뉴런은 역전파 때도 신호를 차단한다.

 

왼쪽은 드롭아웃 없이, 오른쪽은 드롭아웃을 적용한 결과(dropout_ratio=0.15)

위 그림과 같이 드롭아웃을 적용하니 훈련 데이터와 시험 데이터에 대한 정확도 차이가 줄었음을 볼 수있다.

또한, 훈련 데이터에 대한 정확도가 100%에 도달하지도 않게 되었다.

 

[Note]

기계학습에서는 앙상블 학습(ensemble learning)을 애용한다.

앙상블 학습은 개별적으로 학습시킨 여러 모델의 출력을 평균내어 추론하는 방식이다.
신경망의 맥락에서 얘기하면, 가령 같은 구조의 네크워크를 5개 준비하여 따로따로 학습시키고, 시험 때는 그 5개의 출력을 평균 내어 답하는 것이다.
앙상블 학습을 수행하면 신경망의 정확도가 몇% 정도 개선된다는 것이 실험적으로 알려져 있다.

앙상블 학습은 드롭아웃과 밀접하다.
드롭아웃이 학습 때 뉴런을 무작위로 삭제하는 행위를 매번 다른 모델을 학습시키는 것으로 해석할 수 있기 때문이다.
그리고 추론 때는 뉴런의 출력에 삭제한 비율(이를테면 0.5 등)을 곱함으로써 앙상블 학습에서 여러 모델의 평균을 내는 것과 같은 효과를 얻는 것이다.

즉, 드롭아웃은 앙상블 학습과 같은 효과를 (대략) 하나의 네트워크로 구현했다고 생각할 수 있다.

 

적절한 하이퍼파라미터 값 찾기

신경망에는 하이퍼파라미터가 다수 등장한다. 여기서 말하는 하이퍼파라미터는, 예를 들면 각 층의 뉴런 수, 배치 크기, 매개변수 갱신 시의 학습률과 가중치 감소 등이다. 이러한 하이퍼파라미터의 값을 적절히 설정하지 않으면 모델의 성능이 크게 떨어지기도 한다. 하이퍼라라미터의 값은 매우 중요하지만 그 값을 결정하기까지는 일반적으로 많은 시행착오를 겪는다.

 

 

검증 데이터

앞으로 하이퍼파라미터를 다양한 값으로 설정하고 검증할 텐데, 여기서 주의할 점은 하이퍼파라미터의 성능을 평가할 때는 시험 데이터를 사용해서는 안된다.

 

같은 성능 평가인데 안되는 이유가 무엇일까?

대답은 시험 데이터를 사용하여 하이퍼파라미터를 조정하면 하이퍼파라미터 값이 시험 데이터에 오버피팅되기 때문이다. 다시 말해, 하이퍼파라미터 값의 '좋음'을 시험 데이터로 확인하게 되므로 하이퍼파라미터 값이 시험 데이터에만 적합하도록 조정되어 버린다.

 

그래서 하이퍼파라미터를 조정할 떄는 하이퍼파라미터 전용 확인 데이터가 필요하다. 하이퍼파라미터 조정용 데이터를 일반적으로 검증데이터(validation data)라고 부른다.

 

[Note]

훈련 데이터는 매개변수(가중치와 편향)의 학습에 이용하고, 검증 데이터는 하이퍼파라미터의 성능을 평가하는 데 이용한다. 

시험 데이터는 범용 성능을 확인하기 위해서 마지막에 (이상적으로는 한번만) 이용한다.

-훈련데이터: 매개변수 학습

-검증데이터: 하이퍼파라미터 성능 평가

-시험데이터: 신경망의 범용 성능 평가

 

코드로 살펴보자.

(x_train,t_train),(x_test,t_test)=load_mnist()

#훈련 데이터를 뒤섞는다.
x_train,t_train=shuffle_dataset(x_train,t_train)

#20%를 검증 데이터로 분할
validation_rate=0.2
validation_num=int(x_train.shape[0]*validation_rate)

x_val=x_train[:validation_num]
t_val=t_train[:validation_num]
x_train=x_train[validation_num:]
t_train=t_train[validation_num:]

위 코드는 훈련 데이터를 분리하기 전에 입력 데이터와 정답 레이블을 섞는다. 이어서 검증 데이터를 사용하여 하이퍼파라미터를 최적화하는 기법을 살펴보자.

 

하이퍼파라미터 최적화

하이퍼파라미터를 최적화할 때의 핵심은 하이퍼파라미터의 '최적 값'이 존재하는 범위를 줄여나간다는 것이다. 

범위를 조금씩 줄이려면 우선 대략적인 범위를 설정하고 그 범위에서 무작위로 하이퍼파라미터 값을 골라낸(샘플링) 후, 그 값으로 정확도를 평가한다.

 

[Note] 

신경망의 하이퍼파라미터 최적화에서는 그리드 서치(grid search)같은 규칙적인 탐색보다는 무작위로 샘플링해 탐색하는 편이 좋은 결과를 낸다고 알려져 있다. 이는 최종 정확도에 미치는 영향력이 하이퍼파라미터마다 다르기 때문이다.

 

 

하이퍼파라미터의 범위는 '대략적' 지정이 가장 효과적이다. 실제로도 0.001에서 1000사이와 같이 10의 거듭제곱 단위로 범위를 지정한다. 이를 log scale이라고 부른다.

 

정리해보면,

1)하이퍼파라미터 값의 범위를 설정한다.

2)설정된 범위에서 하이퍼파라미터의 값을 무작위로 추출한다.

3)2단계에서 샘플링한 하이퍼파라미터 값을 사용하여 학습하고 검증 데이터로 정확도를 평가한다. (단, epoch은 작게 설정)

4)2단계와 3단계를 특정 횟수(log scale)반복하여, 그 정확도의 결과를 보고 하이퍼파라미터의 범위를 좁힌다.

 

MNIST 예제를 통해 결과를 살펴보면,

 

학습이 잘 진행될 때의 학습률은 0.001~0.01, 가중치 감소 계수는 10^-8~10^-6정도라는 것을 알 수 있다.

이처럼 잘될 것 같은 값의 범위를 관찰하고 범위를 좁혀가면된다.

 

2021/03/04 - [Deep Learning/Deep Leaning inside] - 트랜스포머(Transformer)(1)

 

8)패딩 마스크(Padding Mask)

앞서 포스팅한 Scaled dot-product attention함수 내부를 보면 mask라는 값을 인자로 받아서, 이 mask값에다가 -1e9라는 아주 작은 음수값을 곱한 후 어텐션 스코어 행렬에 더해준다. 

이는 입력 문장에 <PAD>토큰이 있을 경우 어텐션에서 사실상 제외하기 위한 연산이다. 

그림을 통해 이해해보자.

 

사실 단어 <PAD>의 경우에는 실질적인 의미를 가진 단어가 아니다. 그렇기에 트랜스포머에서는 Key의 경우에 <PAD> 토큰이 존재한다면 이에 대해 유사도를 구하지 않도록 마스킹을 해주기로 했다.

*masking이란 어텐션에서 제외하기 위해 값을 가린다는 의미이다.

어텐션 스코어 행렬에서 행에 해당하는 문장은 Query, 열에 해당하는 문장은 Key이다. 그리고 Key에 <PAD>가 있는 경우에는 해당 열 전체를 masking 해준다.

 

masking하는 방법은 어텐션 스코어 행렬의 마스킹 위치에 매우 작은 음수값을 넣어주는 것이다. 현재 어텐션 스코어 함수는 소프트맥스 함수를 지나지 않은 상태이다. 현재 마스킹 위치에 매우 작은 음수 값이 들어가 있으므로 어텐션 스코어 행렬이 소프트맥스 함수를 지난 후에는 해당 위치의 값은 0에 근사한 값을 갖게 되어 단어간 유사도를 구하는 일에 <PAD> 토큰은 반영되지 않는다는 의미이다.

위 그림은 소프트맥스 함수를 지난 후의 그림이다. 각 행의 어텐션 가중치는 총합 1이 되는데, <PAD>의 경우 0이되어 어떤 값도 갖고 있지 않다.

 

패딩마스크를 구현하는 방법은 입력된 정수 시퀀스에서 패딩 토큰의 인덱스인지, 아닌지를 판별하는 함수를 구현하는 것이다. 

 

def create_padding_mask(x):
    mask=tf.cast(tf.math.equal(x,0),tf.float32)
    return mask[:,tf.newaxis,tf.newaxis,:]
    
    
print(create_padding_mask(tf.constant([[1,21,777,0,0]])))

tf.Tensor([[[[0. 0. 0. 1. 1.]]]], shape=(1, 1, 1, 5), dtype=float32)

위 함수는 정수 시퀀스에서 패딩 토큰의 인덱스인지 아닌지를 판별하는 함수이다. 

0인경우에는 1로 변환하고, 그렇지 않은경우에는 0으로 변환하는 함수이다.

 

결과를 살펴보면 위 벡터를 통해서 1의 값을 가진 위치의 열을 어텐션 스코어 행렬에서 마스킹하는 용도로 사용할 수 있다. 위 벡터를 Scaled dot-product attention 인자로 전달하면, scaled dot-product attention에는 매우 작은 음수값인 -1e9를 곱하고 이를 행렬에 더해주어 해당 열을 전부 마스킹하게 되는 것이다.

 

 

포지션-와이즈 피드 포워드 신경망(Position-wise FFNN)

포지션-와이즈 FFNN는 쉽게 말하면 완전 연결(Fully-connected FFNN)이라고 해석할 수 있다. 다음 수식을 살펴보자.

-x는 앞서 멀티 헤드 어텐션의 결과로 나온(seq_len,d(model))의 크기를 가지는 행렬을 말한다. 가중치 행렬 W1은 (d(model),d(ff))의 크기를 가지고, 가중치 행렬 W2는 (d(ff),d(model))의 크기를 가진다. 

*논문에서는 은닉층의 크기인 d(ff)는 2047의 크기를 가진다.

 

여기서 매개변수 W1,b1,W2,b2는 하나의 인코더 층 내에서는 다른 문장, 다른 단어들마다 정확하게 동일하게 사용된다. 하지만 인코더 층마다 다른 값을 가진다.

 

위 그림에서 좌측은 인코더의 입력을 벡터 단위로 봤을 떄, 각 벡터들이 멀티헤드 어텐션 층이라는 인코더 내 첫번째 서브 층을 지나, FFNN을 통과하는 과정을 보여준다.

위 그림을 코딩으로 구현해보자.

 

outputs=tf.keras.layers.Dense(units=dff,activation='relu')(attention)
outputs=tf.keras.layers.Dense(units=d_model)(outputs)

잔차 연결(Residual connection)과 층 정규화(Layer Normalization)

트랜스포머에서는 위 그림처럼 두 개의 서브층을 가진 인코더에 추가적으로 사용하는 기법이 있다. 

바로 Add&Norm이다. 더 정확히는 이를 잔차 연결(residual connection) 과 층 정규화(layer nomalization)이라 부른다.

 

1)잔차 연결(Residual connection)

다음 식을 확인해보자.

위 그림은 입력 x와 x에 대한 어떤 함수 F(x)의 값을 더한 H(x)의 구조를 보여준다. 여기서 F(x)는 트랜스포머에서 서브층에 해당된다. 다시 말해 잔차 연결은 서브층의 입력과 출력을 더하는 것을 말한다.

위에서 언급했듯이 서브층의 입력과 출력은 동일한 차원을 갖고 있으므로, 서브층의 입력과 서브층의 출력은 연산을 할 수 있다.

 

2)층 정규화(Layer Normalization)

잔차 연결을 거친 결과는 이어, 층 정규화 과정을 거친다. 잔차 연결의 입력을 x, 잔차 연결과 층 정규화 두 가지 연산을 모두 수행한 후의 결과 행렬을 LN이라 하였을 때, 잔차 연결 후 층 정규화 연산을 수식으로 표현하면 다음과 같다.

층 정규화란 텐서의 마지막 차원에 대해서 평균과 분산을 구하고, 이를 가지고 어떤 수식을 통해 값을 정규화하여 학습을 돕는다. 

*텐서의 마지막 차원이란 트랜스포머에서는 d(model)차원을 의미한다.

층 정규화를 수행한 후에는 벡터 x(i)는 ln(i) 라는 벡터로 정규화가 된다.

수식을 이해해보자. 과정은 다음과 같다.

1. 평균과 분산을 통한 정규화

2. 감마와 베타 도입

우선,평균과 분사을 통해 벡터 x(i)를 정규화 해준다. x(i)는 벡터인 반면, 평균과 분산은 스칼라이다. 벡터 xi의 각 차원을 k라고 하였을 때, x(i,k)는 다음과 같이 정규화 할 수있다.

여기서 입실론은 분모가 0이되는 것을 방지하는 값이다.

γ(감마)와 β(베타) 벡터를 준비하자. 이들의 초깃값은 각각 1,0이다.

γ β를 도입한 층 정규화의 최종수식은 다음과 같다. γ β는 학습 가능한 파라미터이다.

 

인코더 구현하기

코딩을 살펴보자

 

def encoder_layer(dff,d_model,num_heads,dropout,name='encoder_layer'):
    inputs=tf.keras.Input(shape=(None,d_model),name='inputs')
    
    #인코더는 패딩 마스크 사용
    padding_mask=tf.keras.Input(shape=(1,1,None),name='padding_mask')
    
    #멀티-헤드 어텐션(첫번째 서브층/셀프 어텐션)
    attention=MultiHeadAttention(d_model,num_heads,name='attention')({'query':inputs,'key':inputs,'value':inputs,'mask':padding_mask})
    
    #드롭아웃+잔차 연결과 층 정규화
    attention=tf.keras.layers.Dropout(rate=dropout)(attention)
    attention=tf.keras.layers.LayerNormalization(epsilon=1e-6)(inputs+attention)
    
    
    #포지션 와이즈 피드 포워드 신경망(두번째 서브층)
    outputs=tf.keras.layers.Dense(units=dff,activation='relu')(attention)
    outputs=tf.keras.layers.Dense(units=d_model)(outputs)
    
    #드롭아웃+잔차 연결과 층 정규화
    outputs=tf.keras.layers.Dropout(rate=dropout)(outputs)
    outputs=tf.keras.layers.LayerNormalization(epsilon=1e-6)(attention+outputs)
    
    return tf.keras.Model(inputs=[inputs,padding_mask],outputs=outputs,name=name)

                   

인코더의 입력으로 들어가는 문장에는 패딩이 있을 수 있으므로, 어텐션 시 패딩 토큰을 제외하도록 패딩 마스크를 사용한다. 이는 MultiHeadAttention 함수의 mask의 인자값으로 padding_mask가 들어가는 이유이다.

인코더는 총 두 개의 서브층으로 이루어지는데, 멀티 헤드 어텐션과 피드 포워드 신경망이다. 각 서브층 이후에는 드롭 아웃, 잔차 연결과 층 정규화가 수행된다.

 

인코더 쌓기

인코더 층의 내부 아키텍처에 대해서 이해해보았다. 이러한 인코더 층을 num_layers개만큼 쌓고, 마지막 인코더 층에서 얻는 (seq_len,d_model)크기의 행렬을 디코더로 보내서 트랜스포머 인코더의 인코딩 연산이 끝나게 된다.

다음 코드는 인코더 층을 num_layers개만큼 쌓는 코드이다.

 

def encoder(vocab_size,num_layers,dff,d_model,num_heads,dropout,name='encoder'):
    inputs=tf.keras.Input(shape=(None,),name='inputs')
    
    #인코더는 패딩 마스크 사용
    padding_mask=tf.keras.Input(shape=(1,1,None),name='padding_mask')
    
    #포지셔널 인코딩+드롭아웃
    embeddings=tf.keras.layers.Embedding(vocab_size,d_model)(inputs)
    embeddings*=tf.math.sqrt(tf.cast(d_model,tf.float32))
    embeddings=PositionalEncoding(vocab_size,d_model)(embeddings)
    outputs=tf.keras.layers.Dropout(rate=dropout)(embeddings)
    
    #인코더를 num_layers개 쌓기
    for i in range(num_layers):
        outputs=encoder_layer(dff=dff,d_model=d_model,num_heads=num_heads,dropout=dropout,name='encoder_layer_{}'.format(i),)([outputs,padding_mask])
    
    return tf.keras.Model(inputs=[inputs,padding_mask],outputs=outputs,name=name)

 

인코더에서 디코더로(From Encoder To Decoder)

앞서 인코더는 총 num_layers만큼의 층 연산을 순차적으로 처리한 후, 마지막 층의 인코더의 출력을 디코더에 전달해준다. 인코더 연산이 끝났으므로, 디코더 연산이 시작되어 다시 디코더 또한 num_layers만큼의 연산을 한다. 

이때마다, 인코더가 보낸 출력을 각 디코더 층 연산에 사용한다. 

 

디코더의 첫번째 서브층: 셀프 어텐션과 룩 어헤드 마스크

위 그림과 같이 디코더도 동일하게 임베딩 층과 포지셔널 인코딩을 거친 후의 문장 행렬이 입력된다. 

트랜스포머 또한 seq2seq와 마찬가지로 교사 강요(Teacher Forcing)을 사용하여 훈련되므로 학습 과정에서 디코더는 번역할 문장에 해당되는 <sos> je suis etudiant의 문장 행렬을 한 번에 입력받는다. 그 후, 디코더는 이 문장 행렬로부터 각 시점의 단어를 예측하도록 훈련된다.

 

<문제점>

seq2seq의 디코더에 사용되는 RNN 계열의 신경망은 입력 단어를 매 시점마다 순차적으로 받으므로 다음 단어 예측에 현재 시점 이전에 입력된 단어들만 참고 할 수 있다.

이에 반해, 트랜스포머는 문장 행렬로 입력을 한번에 받으므로 현재 시점의 단오를 예측하고자 할 떄, 입력 문장 행렬로부터 미래시점의 단어까지도 참고할 수 있는 현상이 발생한다.

 

이 문제점을 해결하기 위해 트랜스포머의 디코더에서는 현재 시점의 예측에서 현재 시점보다 미래에 있는 단어들을 참고하지 못하도록 룩어헤드 마스크를 도입했다.

룩어헤드 마스크(look ahead mask)는 디코더의 첫번째 서브층에서 이루어진다.

디코더의 첫번째 서브층인 멀티 헤드 셀프어텐션층은 인코더의 첫번째 서브층인 멀티 헤드 셀프 어텐션 층과 동일한 연산을 한다. 차이점은 마스킹을 적용한다는 점이다. 

위 그림과 같이 셀프 어텐션을 통해 어텐션 스코어 행렬을 얻는다.

이후 자기 자신보다 미래에 있는 단어들은 참고하지 못하도록 마스킹 처리한다.

마스킹 된 후의 어텐션 스코어 행렬의 각 행을 보면 자기 자신과 그 이전 단어들만을 참고할 수 있음을 볼 수있다.

그 외에는 인코더의 첫 번째 서브층과 같다.

룩어헤드 마스크의 구현을 알아보면 패딩마스크와 마찬가지로 앞서 구현한 스케일드 닷 프로덕트 어텐션 함수에 mask라는 인자로 전달된다. 패딩 마스크를 써야하는 경우, 스케일드 닷 프로덕트 어텐션 함수에 패딩 마스크를 전달하고, 룩어헤드 마스킹을 써야 하는 경우에는 스케일드 닷 프로덕트 어텐션 함수에 룩어헤드 마스크를 전달하게 된다.

 

 

트랜스포머에는 총 3가지 어텐션이 존재한다. 모두 멀티 헤드 어텐션을 수행하고, 멀티 헤드 어텐션 함수 내부에서 스케일드 닷 프로덕트 어텐션 함수를 호출하는데 각 어텐션 시 함수에 전달하는 마스킹은 다음과 같다.

-인코더의 셀프 어텐션: 패딩마스크 전달

-디코더의 첫번째 서브층인 마스크드 셀프 어텐션: 룩어헤드 마스크를 전달

-디코더의 두번째 서브층인 인코더-디코더 어텐션: 패딩 마스크를 전달

 

이때, 룩어헤드 마스크를 한다고 해서 패딩 마스크가 불필요한 것이 아니므로 룩어헤드 마스크는 패딩 마스크를 포함할 수 있도록 구현한다. 

 

#디코더의 첫번쨰 서브층(sublayer)에서 미래 토큰을 Mask하는 함수
def create_look_ahead_mask(x):
    seq_len=tf.shape(x)[1]
    look_ahead_mask=1-tf.linalg.band_part(tf.ones((seq_len,seq_len)),-1,0)
    padding_mask=create_padding_mask(x)
    return tf.maximum(look_ahead_mask,padding_mask)

print(create_look_ahead_mask(tf.constant([[1,2,0,4,5]])))

tf.Tensor(
[[[[0. 1. 1. 1. 1.]
   [0. 0. 1. 1. 1.]
   [0. 0. 1. 1. 1.]
   [0. 0. 1. 0. 1.]
   [0. 0. 1. 0. 0.]]]], shape=(1, 1, 5, 5), dtype=float32)

룩어헤드 마스크이므로 삼각형 모양의 마스킹이 형성되면서, 패딩 마스크가 포함되어져 있으므로 세번째 열이 마스킹 되었다.

 

디코더의 두번째 서브층: 인코더 디코더 어텐션

디코더의 두번째 서브층은 멀티 헤드 어텐션을 수행한다는 점에서는 이전의 어텐션들과 같지만, 셀프 어텐션은 아니다.

셀프 어텐션은 Query,Key,Value가 같은 경우를 말하는데, 인코더-디코더 어텐션은 Query가 디코더인 행렬인 반면, Key와 Value는 인코더 행렬이기 때문이다.

디코더의 두번째 서브층을 확대해보면, 위와 같은 그림이다.

두 개의 (빨간)화살표는 각각 Key,Value를 의미하며, 이는 인코더의 마지막 층에서 온 행렬로부터 얻는다.

반면, Query는 디코더의 첫번쨰 서브층의 결과 행렬로부터 얻는다는 점이 다르다.

Query가 디코더 행렬, Key가 인코더 행렬일 때, 어텐션 스코어 행렬을 구하는 과정은 아래 그림과 같다.

 

디코더 구현하기

우선 코딩을 살펴보자.

def decoder_layer(dff,d_model,num_heads,dropout,name='decoder_layer'):
    inputs=tf.keras.Input(shape=(None,d_model),name='inputs')
    enc_outputs=tf.keras.Input(shape=(None,d_model),name='encoder_outputs')
    
    #룩어헤드 마스크(첫번째 서브층)
    look_ahead_mask=tf.keras.Input(shape=(1,None,None),name='look_ahead_mask')
    
    #패딩 마스크(두번째 서브층)
    padding_mask=tf.keras.Input(shape=(1,1,None),name='padding_mask')
    
    #멀티-헤드 어텐션(첫번째 서브층/마스크드 셀프 어텐션)
    attention1=MultiHeadAttention(d_model,num_heads,name='attention_1')(inputs={'query':inputs,'key':inputs,'value':inputs,'mask':look_ahead_mask})
    #잔차 연결과 층 정규화
    attention1=tf.keras.layers.LayerNormalization(epsilon=1e-6)(attention1+inputs)
    
    #멀티-헤드 어텐션(두번째 서브층/디코더-인코더 어텐션)
    attention2=MultiHeadAttention(d_model,num_heads,name='attention_2')(inputs={'query':attention1,'key':enc_outputs,'value':enc_outputs,'mask':padding_mask})
    #드롭아웃+잔차 연결과 층 정규화
    attention2=tf.keras.layers.Dropout(rate=dropout)(attention2)
    attention2=tf.keras.layers.LayerNormalization(epsilon=1e-6)(attention2+attention1)
    
    #포지션 와이즈 피드 포워드 신경망(세번째 서브층)
    outputs=tf.keras.layers.Dense(units=dff,activation='relu')(attention2)
    outputs=tf.keras.layers.Dense(units=d_model)(outputs)
    
    #드롭아웃+잔차 연결과 층 정규화
    outputs=tf.keras.layers.Dropout(rate=dropout)(outputs)
    outputs=tf.keras.layers.LayerNormalization(epsilon=1e-6)(outputs+attention2)
    
    return tf.keras.Model(inputs=[inputs,enc_outputs,look_ahead_mask,padding_mask],outputs=outputs,name=name)

 

디코더는 총 세개의 서브층으로 구성된다. 첫번째와 두번째 서브층 모두 멀티 헤드 어텐션이지만, 첫번째 서브층은 mask의 인자값으로 look_ahead_mask가 들어간다.

반면, 두번째 서브층은 mask의 인자값으로 padding_mask가 들어가는 것을 확인할 수 있다.

이유는 첫번째 서브층은 마스크드 셀프 어텐션을 수행하기 때문이다. 세개의 서브층 모두 서브층 연산 후에는 드롭 아웃, 잔차 연결, 층 정규화가 수행되는 것을 알 수 있다.

 

디코더 쌓기

def decoder(vocab_size,num_layers,dff,d_model,num_heads,dropout,name='decoder'):
    inputs=tf.keras.Input(shape=(None,),name='inputs')
    enc_outputs=tf.keras.Input(shape=(None,d_model),name='encoder_outputs')
    
    #디코더는 룩어헤드 마스크(첫번째 서브층)와 패딩 마스크(두번째 서브층) 둘 다 사용.
    look_ahead_mask=tf.keras.Input(shape=(1,None,None),name='look_ahead_mask')
    padding_mask=tf.keras.Input(shape=(1,1,None),name='padding_mask')
    
    #포지셔널 인코딩 +드롭아웃
    embeddings=tf.keras.layers.Embedding(vocab_size,d_model)(inputs)
    embeddings*=tf.math.sqrt(tf.cast(d_model,tf.float32))
    embeddings=PositionalEncoding(vocab_size,d_model)(embeddings)
    outputs=tf.keras.layers.Dropout(rate=dropout)(embeddings)
    
    #디코더를 num_layers개 쌓기
    for i in range(num_layers):
        outputs=decoder_layer(dff=dff,d_model=d_model,num_heads=num_heads,dropout=dropout,name='decoder_layer_{}'.format(i),)(inputs=[outputs,enc_outputs,look_ahead_mask,padding_mask])
    return tf.keras.Model(inputs=[inputs,enc_outputs,look_ahead_mask,padding_mask],outputs=outputs,name=name)

 

이 또한 앞선 인코더 쌓기와 동일하다.

트랜스포머 전체 구현하기

def transformer(vocab_size,num_layers,dff,d_model,num_heads,dropout,name='transformer'):
    #인코더 입력
    inputs=tf.keras.Input(shape=(None,),name='inputs')
    
    #디코더 입력
    dec_inputs=tf.keras.Input(shape=(None,),name='dec_inputs')
    
    #인코더의 패딩 마스크
    enc_padding_mask=tf.keras.layers.Lambda(create_padding_mask,output_shape=(1,1,None),name='enc_padding_mask')(inputs)
    
    #디코더의 룩어헤드 마스크(첫번째 서브층)
    look_ahead_mask=tf.keras.layers.Lambda(create_look_ahead_mask,output_shape=(1,None,None),name='look_ahead_mask')(dec_inputs)
    
    #디코더의 패딩 마스크(두번째 서브층)
    dec_padding_mask=tf.keras.layers.Lambda(create_padding_mask,output_shape=(1,1,None),name='dec_padding_mask')(inputs)
    
    #인코더의 출력은 enc_outputs. 디코더로 전달하는 과정
    enc_outputs=encoder(vocab_size=vocab_size,num_layers=num_layers,dff=dff,d_model=d_model,num_heads=num_heads,dropout=dropout)(inputs=[inputs,enc_padding_mask])
    
    #디코더의 출력은 de_outputs, 출력층으로 전달하는 과정
    dec_outputs=decoder(vocab_size=vocab_size,num_layers=num_layers,dff=dff,d_model=d_model,num_heads=num_heads,dropout=dropout)(inputs=[dec_inputs,enc_outputs,look_ahead_mask,dec_padding_mask])
    
    #다음 단어 예측을 위한 출력층
    outputs=tf.keras.layers.Dense(units=vocab_size,name='outputs')(dec_outputs)
    return tf.keras.Model(inputs=[inputs,dec_inputs],outputs=outputs,name=name)

 

트랜스포머 하이퍼파라미터 정하기

앞선 과정까지는 전체 모델에 대한 이해와 이 모델을 직접 파이썬으로 구현하는 과정을 알아보았다. 

이제는 학습을 시키기 위해 하이퍼파라미터를 지정해보자.

단어 집합의 크기는 임의로 9000으로 정한다. 단어 집합의 크기로부터 룩업 테이블을 수행할 임베딩 테이블과 포지셔널 인코딩 행렬의 행의 크기를 결정할 수 있다.

 

여기서는 논문에 제시한 것과는 조금 다르게 하이퍼파라미터를 정해본다. 인코더와 디코더의 층의 개수 num_laters는 4개, 인코더와 디코더의 포지션 와이즈 피드 포워드 신경망은 은닉층 d(ff) 128, d(model) 128, 멀티-헤드 어텐션은 병렬적 사용으로 num_heads 4로 지정해본다. 

이렇게 된다면 d(v)는 128/4로 32가 되겠다

 

small_transformer=transformer(
vocab_size=9000,
num_layers=4,
dff=512,
d_model=128,
num_heads=4,
dropout=0.3,
name='small_transformer')

tf.keras.utils.plot_model(small_transformer,to_file='C:/data/small_transformer.png',show_shapes=True)

 

손실 함수 정의하기

다중 클래스 분류 issue를 해결할 것이므로, cross_entropy 를 loss_function으로 정의하겠다.

def loss_function(y_true,y_pred):
    y_true=tf.reshape(y_true,shape=(-1,MAX_LENGTH-1))
    
    loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True,reduction='none')(y_true,y_pred)
    mask=tf.cast(tf.not_equal(y_ture,0),tf.float32)
    loss=tf.multiply(loss,mask)
    
    return tf.reduce_mean(loss)

 

학습률

트랜스포머의 경우 learning rate는 고정된 값을 유지하는 것이 아닌, 학습 경과에 따라 변하도록 설계되었다. 

위 공식으로 학습률을 계산하여 사용하며, warmup_steps의 값으로는 4000을 정의하였다.

class CustomSchedule(tf.keras.optimizers.schedules.LearningRateSchedule):
    def __init__(self,d_model,warmup_steps=4000):
        super(CustomSchedule,self).__init__()
        self.d_model=d_model
        self.d_model=tf.cast(self.d_model,tf.float32)
        self.warmup_steps=warmup_steps
        
    def __call__(self,step):
        arg1=tf.math.rsqrt(step)
        arg2=step*(self.warmup_steps**-1.5)
        
        return tf.math.rsqrt(self.d_model)*tf.math.minimum(arg1,arg2)
    
sample_learning_rate=CustomSchedule(d_model=128)
plt.plot(sample_learning_rate(tf.range(200000,dtype=tf.float32)))
plt.ylabel('Learning Rate')
plt.xlabel('Train Step')

Text(0.5, 0, 'Train Step')

이후에는 이 트랜스포머 모델을 활용해 챗봇을 만들어 보겠다.

 

 

[출처]

wikidocs.net/31379

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(1)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

Transformer는 2017년 구글이 발표한 논문인 'Attention is all you need'에서 나온 모델이다. 

기존의 seq2seq 구조인 encoder-decoder를 따르면서도 논문의 이름처럼 어텐션 기법만으로 구현한 모델이다.

이 모델은 RNN을 사용하지 않고, 단순히 encoder-decoder 구조를 설계하였음에도 RNN보다 성능이 우수하다고 나왔다.

 

원논문은 다음 링크를 타고 확인해보자.

arxiv.org/abs/1706.03762

 

Attention Is All You Need

The dominant sequence transduction models are based on complex recurrent or convolutional neural networks in an encoder-decoder configuration. The best performing models also connect the encoder and decoder through an attention mechanism. We propose a new

arxiv.org

기존의 seq2seq 모델의 한계

트래느포머에 대해 알아보기 전에 기존의 seq2seq모델을 먼저 알아보자. 기존 seq2seq모델은 인코더-디코더 구조로 구성되어 있다. 

인코더가 하는 일: input sequence를 하나의 벡터로 압축

디코더가 하는 일: 이 벡터 표현을 통해 output sequence를 만들어낸다.

 

단점: 인코더가 하나의 input sequence를 하나의 벡터로 압축하는 과정에서 input sequence의 정보가 일부 손실된다.

 

위 단점을 보완하기 위해 어텐션 기법이 사용되었다. 

 

그렇다면 RNN을 보정하기 위한 용도로 사용하지 않고, 직접 어텐션으로 인코더와 디코더를 사용한다면 어떻게 될까

 

Transformer의 Main Hyperparameter

트랜스포머의 하이퍼파라미터를 정의해보자. 각 하이퍼파라미터의 의미에 대해서는 후에 설명하고, 간단하게만 이해해보자. 아래 정의되는 수치값은 트랜스포머를 제안한 논문에 기재된 수치값이다. 그러나 사용자가 모델 설계시 임의로 변경가능한 값이다.

 

d(model) =512

트랜스포머의 인코더와 디코더에서 정해진 입력과 출력의 크기를 의미한다. 임베딩 벡터의 차원 또한 d(model)이며, 각 인코더와 디코더가 다음 층의 인코더와 디코더로 값을 보낼 때에도 이 차원을 유지한다.

 

num_layers=6

트랜스포머에서 하나의 인코더와 디코더를 층으로 생각하였을 때, 트랜스포머 모델에서 인코더와 디코더가 총 몇 층으로 구성되었는지를 의미한다.

 

num_heads=8

트랜스포머에서는 어텐션을 사용할 때, 1번 하는 것 보다 여러 개로 분할해서 병렬로 어텐션을 수행하고 결과값을 다시 하나로 합치는 방식을 선택했다. 이때의 병렬 개수를 의미한다.

d(ff)=2048

트랜스포머 내부에는 feed forward neural network가 존재한다. 이때 hiddenlayer의 크기를 의미한다.

*feed forward neural network의 input layer, output layer의 크기는 d(model)이다. 

 

트랜스포머(Transformer)

기본 구조

트랜스포머는 RNN을 사용하지 않지만 기존의 seq2seq처럼 인코더에서 입력 시퀀스를 입력받고, 디코더에서 출력 시퀀스를 출력하는 인코더-디코더 구조를 유지한다. 

하지만 트랜스포머와 seq2seq의 차이점은 encoder, decoder 단위가 N개 존재 할 수 있다는 것이다.

즉, 이전 seq2seq 구조에서는 encoder,decoder에서 각각 하나의 RNN이 t개의 시점(time_step)을 가지는 구조였다면, 이번에는 encoder,decoder단위가 N개로 구성되는 구조이다. 

논문에서는 encoder,decoder 개수를 6개씩 사용했다.

트랜스포머 6x6 구조

위 그림은 encoder,decoder가 6개씩 존재하는 트랜스포머 구조이다.  

출력결과를 나타내는 트랜스포머구조

위 그림은 encoder로부터 정보를 전달받아 출력 결과를 만들어내는 트랜스포머 구조를 보여준다. decoder는 seq2seq구조처럼 시작 심볼<sos>를 입력으로 받아 종료 심볼<eos>가 나올 때까지 연산을 진행한다. 

RNN은 사용되지 않지만 여전히 encoder-decoder구조는 유지되고 있음을 보여준다.

 

그렇다면 이제 전체적인 큰 흐름은 확인했으니 내부 구조를 확인해보자.

 

포지셔널 인코딩(Positional Encoding)

내부를 이해하기에 앞서, 트랜스포머 입력을 알아보자. RNN이 자연어 처리에 유용했던 이유는 단어 위치에 따라 순차적으로 입력받아서 처리하는 RNN의 특성으로 인해 각 단어의 위치정보를 가질 수 있었다.

 

이와는 다르게 트랜스포머는 단어 입력을 순차적으로 받는 방식이 아니므로, 단어의 위치 정보를 다른 방식으로 알려줄 필요가 있다. 이 정보를 얻기 위해 각 단어의 임베딩 벡터에 위치 정보들을 더하여 모델의 입력으로 사용하는데, 이를 포지셔널 인코딩(Positional Encoding)이라고 한다.

 

Positional Encoding 과정

위 그림은 입력으로 사용되는 임베딩 벡터들이 트랜스포머의 입력으로 사용되기 전에 포지셔널 인코딩 값이 더해지는 것을 보여준다. 

위 그림이 실제 임베딩 벡터가 입력으로 사용되기 전에 포지셔널 인코딩값이 더해지는 과정을 시각화한 그림이다.

 

포지셔널 인코딩 값들은 어떤 값이기에 위치 정보를 반영해준다는 것일까? 다음 두가지 함수를 보자.

위 sin,cos함수는 위아래로 순환하여 요동친다. 즉, 이 함수를 이용해 임베딩벡터에 더해줌으로써, 단어의 순서 정보를 더하여준다. 

pos는 입력 문장에서의 임베딩 벡터의 위치를 나타내며, i는 임베딩 벡터 내의 차원의 인덱스를 의미한다. 각 차원의 인덱스가 짝수인 경우에는 sin함수의 값을, 홀수인 경우에는 cos함수의 값을 사용한다. 

위 식에서 d(model)은 트랜스포머의 모든 층의 출력 차원을 의미하는 하이퍼파라미터이다. 위에서 단어의 정의를 언급하였듯이 계속 나오게 될것이므로 기억해두자.

 

위와 같은 포지셔널 인코딩 방법을 사용하면 순서 정보가 보존되는데, 예를 들어 각 임베딩 벡터에 포지셔널 인코딩 값을 더하면 같은 단어라 하더라도 문장 내의 위치에 따라서 트랜스포머의 입력으로 들어가는 임베딩 벡터의 값이 달라진다.

코드로 살펴보자.

 

class PositionalEncoding(tf.keras.layers.Layer):
    def __init__(self,position,d_model):
        super(PositionalEncoding,self).__init__()
        self.pos_encoding=self.positional_encoding(position,d_model)
        
    def get_angles(self,position,i,d_model):
        angles=1/tf.pow(10000,(2*(i//2))/tf.cast(d_model,tf.float32))
        return position*angles
    
    def positional_encoding(self,position,d_model):
        angle_rads=self.get_angles(position=tf.range(position,dtype=tf.float32)[:,tf.newaxis],i=tf.range(d_model,dtype=tf.float32)[tf.newaxis,:],d_model=d_model)
        #배열의 짝수 인덱스(2i) 사인함수
        sines=tf.math.sin(angle_rads[:,0::2])
        #배열의 홀수 인덱스(2i+1) 코사인함수
        cosines=tf.math.cos(angle_rads[:,1::2])
        
        angle_rads=np.zeros(angle_rads.shape)
        angle_rads[:,0::2]=sines
        angle_rads[:,1::2]=cosines
        pos_encoding=tf.constant(angle_rads)
        pos_encoding=pos_encoding[tf.newaxis,...]
        
        print(pos_encoding.shape)
        return tf.cast(pos_encoding,tf.float32)
    
    def call(self,inputs):
        return inputs+self.pos_encoding[:,:tf.shape(inputs)[1],:]

위 클래스를 이용해 포지셔널인코딩을 정리하였다. 여기서 가장 주의깊게 봐야할 부분은 짝수,홀수일때 어떤 함수를 사용하는가이다.

 

sample_pos_encoding=PositionalEncoding(50,128)
plt.pcolormesh(sample_pos_encoding.pos_encoding.numpy()[0],cmap='RdBu')
plt.xlabel('Depth')
plt.xlim((0,128))
plt.ylabel('Position')
plt.colorbar()
plt.show()

50x128의 크기를 가진 포지셔널 인코딩 행렬을 시각화하여 어떤 형태를 가지는지 확인해보자.

 

(1, 50, 128)

단어가 50개이면서, 각 단어가 128차원의 임베딩 벡터를 가질 때 사용할 수 있는 행렬이다. 좀 더 쉽게 설명하면, '관돌'이라는 단어가 가질 수 있는 고유 숫자(벡터)는 128개라고 이해해도 괜찮다고 본다.

 

어텐션(Attention)

트랜스포머에서 사용되는 3가지 어텐션에 대해 간단하게 정리하자. 

Self-Attention은 인코더에서 이루어지는 반면에 Masked Decoder Self-Attention , Encoder-Decoder Attention은 디코더에서 이루어진다. 셀프 어텐션은 본질적으로 Query,Key,Value가 동일한 경우를 말한다. 반면, 세번째 그림 인코더-디코드 어텐션에서는 Query가 디코더의 벡터인 반면에 Key,Value가 인코더의 벡터이므로 셀프 어텐션이라 불리지 않는다.

*Query,Key 등이 같다는 것은 벡터의 값이 같다는 것이 아닌, 벡터의 출처가 같다는 의미이다.

전체적인 트랜스포머의 구조이다. 여기서 Multi-head라고 추가가 되었는데, 이는 간단하게 병렬수행으로 생각해두자. 뒤에 다시 언급할 것이다.

 

인코더(Encoder)

앞서 말했듯이 트랜스포머는 하이퍼파라미터인 num_layers 개수의 인코더 층을 쌓는다. 총 6개의 인코더 층을 사용하였다. 

인코더를 하나의 층이라는 개념으로 생각해본다면, 그 층은 크게 총 2개의 sublayer로 나뉜다. 이 sublayer는 셀프 어텐션과 피드 포워드 신경망이다. 위 그림에서 FFNN과 Self-Attention이라고 적혔는데, 멀티 해드 어텐션은 셀프 어텐션을 병렬적으로 사용한다는 의미고, Position-wise FFNN은 일반적인 FFNN이다. 

우선 셀프 어텐션에 대해서 먼저 이해해보자.

 

Self-Attention

트랜스포머에서는 셀프 어텐션이라는 어텐션 기법이 등장하는데, 어텐션 기법에 대해서는 추후 다루도록 하겠다.

1) 셀프 어텐션의 의미와 이점

어텐션 함수는 주어진 query에 대해서 모든 key와의 유사도를 각각 구한다. 그리고 구해낸 이 유사도를 가중치로 하여 키와 맵핑되어있는 각각의 value에 반영해준다. 그 후, 유사도가 반영된 value를 모두 가중합하여 return 한다.

셀프 어텐션에 대한 대표적인 효과를 이해해보자.

구글 AI블로그 포스트

위 그림에서 'The animal didn't cross the street because it was too tired'라는 글을 해석해보면 '그 동물은 그것이 너무 힘들어서 길을 건너지 않았다' 이다. 여기서 it 이 의미하는 건 무엇일까? 'street' 혹은 'animal'중 하나일 것이다. 이 셀프 어텐션을 이용하면 결국 animal에 더 연관되어 있다는 즉, 확률이 높다는 것을 찾아내 준다.

 

이제 트랜스포머에서의 셀프 어텐션의 동작 메커니즘을 알아보자.

 

2) Q,K,V 벡터 얻기

셀프 어텐션은 입력 문장의 단어 벡터들을 가지고 수행한다고 하였는데, 사실 어텐션은 인코더의 초기 입력인 d(model)의 차원을 가지는 단어 벡터들을 사용하여 셀프 어텐션을 수행하는 것이 아닌, 각 단어의 벡터들로부터 Q,K,V벡터를 얻는 작업을 거친다. 

이때 Q,K,V벡터들은 초기 입력인 d(model)의 차원을 가지는 단어 벡터들보다 더 작은 차원을 가지는데, 논문에서는 d(model)=512의 차원을 가졌던 각 단어 벡터들을 64의 차원을 갖는 Q,K,V벡터로 변환하였다.

 

여기서, 64라는 값은 트랜스포머의 또 다른 하이퍼파라미터인 num_heads로 인해 결정된다. 트랜스포머는 d(model_을 num_heads로 나누 값을 각 Q,K,V벡터의 차원으로 결정한다. 논문에서는 num_heads를 8로 설정하였다.

Q,K,V벡터 변환 과정

기존 벡터로부터 더 작은 벡터는 가중치 행렬을 곱하므로써 완성된다. 

 

3) Scaled dot-product Attention

Q,K,V 벡터를 얻었다면 기존 어텐션 메커니즘과 동일하다. 각 Q벡터는 모든 K벡터에 대해서 어텐션 스코어를 구하고, 어텐션 분포를 구한 뒤에 이를 사용해, 모든 V벡터를 가중합하여 어텐션 벨류 혹은 컨텍스트 벡터를 구하게 된다. 그리고 이를 모든 Q 벡터에 대해서 반복한다.

 

그러나 어텐션 함수의 종류는 다양하다. 트랜스포머에서는 내적을 사용하는 어텐션 함수

dot-product attention

가 아닌, 특정 값으로 나눠즈는 어텐션함수를 사용한다.

Scaled dot-product Attention

그림을 통해 확인해보자.

 

우선 단어 I에 대한 Q벡터를 살펴보자. 이 과정은 am에 대한 Q벡터, a에 대한 Q벡터, student에 대한 Q벡터에 대해서도 모드 동일한 과정을 거친다. 

위 그림에서 어텐션 스코어는 각각 단어 I가 단어 I,am,a,student와 얼마나 연관되어 있는지를 보여주는 수치이다. 트랜스포머에서는 두 벡터의 내적값을 스케일링하는 값으로 K벡터의 차원을 나타내는 d(k)에 루트를 씌워 사용하였다. 논문에서 d(k)는 d(model)/num_heads라는 식에 따라서 64의 값을 가지므로 루트를 씌운 d(k)는 8의 값을 갖는다.

 

다음은 어텐션 스코어에 softmax함수를 사용하여 어텐션 분포를 구하고, 각 V벡터와 가중합하여 Attention Value를 출력한다. 이를 단어 I에 대한 어텐션 값 또는 단어 I에 대한 context vector 라고도 부른다. 과연 이 간단한 문장에서는 하나하나 따로 연산을 하는게 큰 문제는 없어보이지만, 많은 양의 문장을 사용할 시에는 과연 따로 연산하는게 효율적일까?

 

4)행렬 연산으로 일괄 처리

위 과정을 벡터 연산이 아닌 행렬 연산을 사용하면 일괄 계산이 가능하다. 위 과정을 행렬 연산으로 다시 확인해보자.

위 그림에서 Q 행렬을 K행렬로 전치한 행렬과 곱해준다고 해보자. 이렇게 된다면 각각의 단어의 Q벡터와 K벡터의 내적이 각 행렬의 원소가 되는 행렬이 결과로 나온다.

다시 말해 위 그림의 결과 행렬의 값에 전체적으로 d(k)에 루트를 씌운 값으로 나누어주면 이는 각 행과 열이 어텐션 스코어 값을 가지는 행렬이 된다. I 행과 student 열의 값은 I의 Q벡터와 student의 K벡터의 어텐션 스코어와 동일한 행렬이 된다는 의미이다. 

위 Attention score matrix를 구하였다면 남은 것은 Attention distribution을 구하고, 이를 사용해 모든 단어에 대한 Attention value를 구하면된다. 이를 위해 score matrix에 softmax함ㅅ수를 사용하고, V행렬을 곱하면 된다.

과정은 다음과 같다.

5)Scaled dot-product attention 구하기

코드를 먼저 살펴보자.

def scaled_dot_product_attention(query,key,value,mask):
	# query 크기 : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
    # key 크기 : (batch_size, num_heads, key의 문장 길이, d_model/num_heads)
    # value 크기 : (batch_size, num_heads, value의 문장 길이, d_model/num_heads)
  	# padding_mask : (batch_size, 1, 1, key의 문장 길이)

	# Q와 K의 곱. 어텐션 스코어 행렬.
    matmul_qk=tf.matmul(query,key,transpose_b=True)
    
    # 스케일링
  	# dk의 루트값으로 나눠준다.
    depth=tf.cast(tf.shape(key)[-1],tf.float32)
    logits=matmul_qk/tf.math.sqrt(depth)
    
    # 마스킹. 어텐션 스코어 행렬의 마스킹 할 위치에 매우 작은 음수값을 넣는다.
  	# 매우 작은 값이므로 소프트맥스 함수를 지나면 행렬의 해당 위치의 값은 0이 된다.
    if mask is not None:
        logits+=(mask*-1e9)
        
    # 소프트맥스 함수는 마지막 차원인 key의 문장 길이 방향으로 수행된다.
  	# attention weight : (batch_size, num_heads, query의 문장 길이, key의 문장 길이)
    attention_weights=tf.nn.softmax(logits,axis=-1)
    
    # output : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
    output=tf.matmul(attention_weights,value)
    return output,attention_weights

 

앞서 설명한것을 다 이해하였다면 위 코드를 해석하는데는 큰문제가 없을것이라본다. 이제 테스를 해보자.

 

np.set_printoptions(suppress=True)
temp_k=tf.constant([[10,0,0],
                   [0,10,0],
                   [0,0,10],
                   [0,0,10]],dtype=tf.float32)
temp_v=tf.constant([[1,0],
                   [10,0],
                   [100,5],
                   [1000,6]],dtype=tf.float32)
temp_q=tf.constant([[0,10,0]],dtype=tf.float32)

temp_out,temp_attn=scaled_dot_product_attention(temp_q,temp_k,temp_v,None)
print(temp_attn)
print(temp_out)


tf.Tensor([[0. 1. 0. 0.]], shape=(1, 4), dtype=float32)
tf.Tensor([[10.  0.]], shape=(1, 2), dtype=float32)

 

6)멀티 헤드 어텐션(Multi-head Attention)

앞서 본 어텐션은 d(model)의 차원을 가진 단어 벡터를 num_heads로 나눈 차원을 가지는 Q,K,V벡터로 바꾸고 어텐션을 수행하였다. 논문 기준 512 차원의 각 단어 벡터를 8로 나누고 64차원의 Q,K,V벡터로 바꾸어 어텐션을 수행한 것이다. 

이번에는 num_heads의 의미와 왜 d(model)의 차원을 가진 단어 벡터를 가지고 어텐션을 하지 않고 차원을 축소시킨 벡터로 어텐션을 수행했는지 살펴보자.

 

트랜스포머 연구진은 한 번의 어텐션을 하는 것보다 여러번의 어텐션을 병렬구조로 사용하는 것이 더 효과적이라 판단했다. 그래서 d(model)의 차원을 num_heads개로 나누어 d(model)/num_heads의 차원을 가지는 Q,K,V에 대해서 num_heads개의 병렬 어텐션을 수행하였다. 

 

그렇다면 병렬 어텐션으로 얻을 수 있는 효과는 무엇이 있을까? 어텐션을 병렬로 수행하여 다른 시각으로 정보들을 수집할 수 있다.

예를들어 앞서 말한 '그 동물은 길을 건너지 않았다. 왜냐하면 그것은 너무 피곤하였기 때문이다'를 살펴보면 단어인 it이 쿼리였다고 해보자. 즉 it에 대한 Q벡터로부터 다른 단어와의 연관도를 구하였을 때 첫 번째 어텐션 헤드는 'it'과 'animal'의 연관도를 높게 본다면, 두 번째 어텐션은 'it'과 'tired'를 높게 볼 수있다. 즉, 다각도로 볼 수 있다는 장점이 있다는 것이다.

병렬 어텐션을 모두 수행하였다면, 모든 어텐션 헤드를 연결(concatenate)한다. 모두 연결된 어텐션 헤드 행렬의 크기는 (seq_len,d(model))가 된다.

 

어텐션 헤드를 모두 연결한 행렬은 또 다른 가중치 행렬 W(a)를 곱하게 되는데 이렇게 나온 결과 행렬이 멀티-헤드 어텐션의 최종 결과물이다. 

인코더의 첫번째 서브픙인 멀티-헤드 어텐션 단계를 끝마쳤을 때, 인코더의 입력으로 들어왔던 행렬의 크기가 아직 유지되고 있음을 기억해두자. 첫 번째 서브층인 멀티-헤드 어텐션과 두 번째 서브층인 포지션 와이즈 피드 포워드 신경망을 지나면서 인코더의 입력으로 들어올 때의 행렬의 크기는 계속 유지되어야 한다. 트랜스포머는 다수의 인코더를 쌓은 형태인데, 인코더에서의 입력의 크기가 출력에서도 동일한 크기로 계속 유지되어야만 다음 인코더에서도 다시 입력이 될 수 있기 때문이다.

 

7)멀티 헤드 어텐션(Multi-head Attention)구현하기

코드를 살펴보자.

class MultiHeadAttention(tf.keras.layers.Layer):
    def __init__(self,d_model,num_heads,name='multi_head_attention'):
        super(MultiHeadAttention,self).__init__(name=name)
        self.num_heads=num_heads
        self.d_model=d_model
        
        assert d_model%self.num_heads==0
        
        #d_model을 num_heads로 나눈 값.
        #논문 기준 64
        self.depth=d_model//self.num_heads
        
        #WQ,WK,WV에 해당하는 밀집층 정의
        self.query_dense=tf.keras.layers.Dense(units=d_model)
        self.key_dense=tf.keras.layers.Dense(units=d_model)
        self.value_dense=tf.keras.layers.Dense(units=d_model)
        
        #WQ에 해당하는 밀집층 정의
        self.dense=tf.keras.layers.Dense(units=d_model)
    
    #num_heads의 개수만큼 q,k,v를 나누는 함수
    def split_heads(self,inputs,batch_size):
        inputs=tf.reshape(inputs,shape=(batch_size,-1,self.num_heads,self.depth))
        return tf.transpose(inputs,perm=[0,2,1,3])
    
    
    def call(self,inputs):
        query,key,value,mask=inputs['query'],inputs['key'],inputs['value'],inputs['mask']
        batch_size=tf.shape(query)[0]
        
        #1.WQ,WK,WV에 해당하는 밀집층 지나기
        #q:(batch_size,query의 문장 길이,d_model)
        #k:(batck_size,key의 문장 길이,d_model)
        #v:(batch_size,value의 문장 길이,d_model)
        #참고) 인코더(k,v)-디코더(q)어텐션에서는 query길이와 key,value의 길이는 다를 수 있다.
        query=self.query_dense(query)
        key=self.key_dense(key)
        value=self.value_dense(value)
        
        #2. 헤드 나누기
        # q : (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
    	# k : (batch_size, num_heads, key의 문장 길이, d_model/num_heads)
    	# v : (batch_size, num_heads, value의 문장 길이, d_model/num_heads)
        query=self.split_heads(query,batch_size)
        key=self.split_heads(key,batch_size)
        value=self.split_heads(value,batch_size)
        
        # 3. 스케일드 닷 프로덕트 어텐션. 앞서 구현한 함수 사용.
    	# (batch_size, num_heads, query의 문장 길이, d_model/num_heads)
        scaled_attention,_=scaled_dot_product_attention(query,key,value,mask)
        scaled_attention=tf.transpose(scaled_attention,perm=[0,2,1,3])
        
        # 4. 헤드 연결(concatenate)하기
    	# (batch_size, query의 문장 길이, d_model)
        concat_attention=tf.reshape(scaled_attention,(batch_size,-1,self.d_model))
        
        # 5. WO에 해당하는 밀집층 지나기
    	# (batch_size, query의 문장 길이, d_model)
        outputs=self.dense(concat_attention)
        return outputs

 

8)패딩마스크부터는 다음 포스팅에서 이어가겠다.

 

 

[자료 출처]

wikidocs.net/31379

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(2)  (0) 2021.03.04
Transfer Learning(전이 학습)  (0) 2021.03.02

어느 갱신 방법을 이용할 것인가?

 

앞선 포스팅에 갱신 방법을 4가지를 알아봤다.

최적화 기법 비교: SGD, 모멘텀, AdaGrad, Adam

위 그림만 본다면 AdaGrad가 가장 나은 듯 보인다. 그러나 결과는 어떤 issue를 해결해야 하는가에 따라 달라지므로 주의해야 한다.

또한, 학습률 등의 하이퍼파라미터를 어떻게 설정하느냐에 따라서도 결과가 바뀐다. 

지금도 많은 연구에서 SGD를 사용한다. 또한, Momentum 과 AdaGrad도 시도해 볼만한 가치가 충분하다.

 

MNIST 데이터셋에 대한 학습 진도 비교

위 그림은 MNIST예제에서 사용해봤을때 얼마나 다른지를 비교해 본 것이다. 이 실험은 각 층이 100개의 뉴런으로 구성된 5층 신경망에서 ReLU를 활성화 함수로는 사용해 측정한 것이다.

 

 

가중치의 초기값

신경망 학습에서 특히 중요한 것이 가중치의 초깃값이다. 가중치의 초깃값을 무엇으로 설정하느냐가 신경망 학습의 성패가 갈린다고 해도 무방하다. 

초깃값을 0으로 한다면?

오버피팅을 억제해 범용 성능을 높이는 테크닉인 weight decay(가중치 갑소) 기법을 소개해본다. 가중치 감소는 간단히 말하자면 가중치 매개변수의 값이 작아지도록 학습하는 방법이다. 즉, 가중치 값을 작게 하여 오버피팅이 일어나지 않게하는 것이다.

가중치를 작게 만들고 싶으면 초깃값도 최대한 작은 값에서 시작하는 것이 정공법이다. 그렇다면 가중치의 초깃값을 모두 0으로 설정하면 어떻게 될까? 실제로 가중치 초깃값을 0으로 하면 학습이 올바르게 이뤄지지 않는다.

이유는 오차역전파법에서 모든 가중치의 값이 똑같이 갱신되기 때문이다. 

예를 들어 2층 신경망에서 1,2번째 층의 가중치가 0이라고 가정해보자. 그럼 순전파 때는 입력층의 가중치가 0이기 때문에 두 번째 층의 모든 뉴런에 값이 입력된다는 것은 역전파 때 두 번째 층의 가중치가 모두 똑같이 갱신된다는 말이 된다. 그래서 가중치들은 같은 초깃값에서 시작하고 갱신을 거쳐도 여전히 같은 값을 유지하는 것이다. 이는 곧 가중치를 여러 개 갖는 의미를 사라지게 한다. 

이 '가중치가 고르게 되어버리는 상황'을 막으려면 초깃값을 random 하게 설정해야 한다.

 

은닉층의 활성화 값 분포

은닉층의 활성화 값(활성화 함수의 출력 데이터)의 분포를 관찰하면 중요한 정보를 얻을 수 있다.

다음 코드를 살펴보자.

 

 

import numpy as np
import matplotlib.pyplot as plt

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

x=np.random.randn(1000,100) #1000개의 데이터
node_num=100                #각 은닉층의 노드(뉴런) 수
hidden_layer_size=5         #은닉층이 5개
activations={}              #이곳에 활성화 결과(활성화 값)를 저장

for i in range(hidden_layer_size):
    if i!=0:
        x=activations[i-1]
        
    w=np.random.randn(node_num,node_num)*1
    a=np.dot(x,w)
    z=sigmoid(a)
    activations[i]=z
    
    
for i, a in activations.items():
    plt.subplot(1,len(activations),i+1)
    plt.title(str(i+1)+'-layer')
    plt.hist(a.flatten(),30,range=(0,1))
plt.show()
    

가중치를 표준편차가 1인 정규분포로 초기화할 때의 각 층의 활성화값 분포

위 히스토그램을 보면, 활성화 값들이 0,1에 치우쳐 분포되어있다. 여기에서 사용한 sigmoid function은 그 출력이 0에 가까워지자(또는 1에 가까워지자) 그 미분은 0에 다가간다. 그래서 데이터가 0과 1에 치우쳐 분포하게 되면 역전파의 기울기 값이 점점 작아지다가 사라진다. 이것이 바로 gradient vanishing(기울기 소실)이라 알려진 문제이다. 층을 깊게 하는 딥러닝에서는 기울기 소실은 더 심각한 문제가 될 수 있다.

 

이번에는 가중치의 표준편차를 0.01로 바꿔보자.

import numpy as np
import matplotlib.pyplot as plt

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

x=np.random.randn(1000,100) #1000개의 데이터
node_num=100                #각 은닉층의 노드(뉴런) 수
hidden_layer_size=5         #은닉층이 5개
activations={}              #이곳에 활성화 결과(활성화 값)를 저장

for i in range(hidden_layer_size):
    if i!=0:
        x=activations[i-1]
        
    w=np.random.randn(node_num,node_num)*0.01
    a=np.dot(x,w)
    z=sigmoid(a)
    activations[i]=z
    
for i, a in activations.items():
    plt.subplot(1,len(activations),i+1)
    plt.title(str(i+1)+'-layer')
    plt.hist(a.flatten(),30,range=(0,1))
plt.show()
    

가중치를 표준편차가 0.01로 한 정규분포로 초기화할 때의 각 층의 활성화값 분포.

이번에는 0.5 부근에 집중되었다. 앞선 히스토그램처럼 0과 1로 치우치친 않았으니 기울기 소실 문제는 일어나지 않았다. 여기서 중요한 점이 있다. 활성화 값들이 치우쳤다는 것은 이 상황에서 다수의 뉴런이 거의 같은 값을 출력하고 있으므로, 뉴런을 여러개 둔 의미가 없어진다는 뜻이다. 즉, 뉴런 100개가 같은 값을 출력한다면 1개짜리와 차이가 없다는 것이다.

 

Xavier 초깃값

현재 Xavier 초깃값은 일반적인 딥러닝 프레임워크들이 표준으로 사용하고 있다. 

각 층의 활성화 값들을 광법위하게 분포시킬 목적으로 가중치의 적절한 분포를 찾고자했다. 그리고 앞 계층의 노드가 n개라면 표준편차가 1/Route(n)인 분포를 사용하면 된다.

Xavier 초기화 방식

처음에는 임의로 초깃값을 선택하였다. 

위 그림처럼 uniform Xavier initialization 과 Normal Xavier initialization 두가지가 있다. 

초기화 과정

초기화 과정은 위 그림과 같다. 더 깊은 수학적 내용과 이해는 이 챕터에서 요하지 않으므로 생략하겠다.

가중치 초깃값으로 'Xavier initialization'을 이용할 때의 각 층의 활성화값 분포

Xavier 초깃값을 사용한 결과, 층이 깊어지면서 형태가 다소 일그러지지만, 앞선 방식보다 확실히 넓게 분포됨을 알 수있다. 각 층에 흐르는 데이터는 적당히 퍼져 있으므로, 시그모이드 함수의 표현력도 제한받지 않고 학습이 효율적으로 이뤄질 것이다.

 

 

He 초깃값

sigmoid함수와 tanh함수는 좌우 대칭이라 중앙 부근이 선현인 함수로 볼 수 있다. 그래서 Xavier 초깃값이 적당한 반면, ReLU함수를 이용할 때는 ReLU에 특화된 초깃값을 이용하라고 권장한다. 이 특화된 초깃값을 찾아낸 Kaiming He 의 이름을 따 He 초깃값이라고 한다. 

He초깃값은 앞 계층의 노드가 n개일 때, 표준편차가 Route(2/n)인 NormalDistribution을 사용한다. Xavier은 Route(1/n)이었는데 ReLU는 음의 영역이 0이라 더 넓게 분포시키기 위해 2배의 계수가 필요하다고 볼 수 있다.

He 초깃값을 사용한 경우

"Transfer learning(TL) is a research problem in machine learning(ML) that focuses on storing knowledge gained while solving one problem and applying it to a different but related problem"

[Definition of Wekipedia]

 

일반적으로 CNN 기반의 딥러닝 모델을 제대로 훈련시키려면 많은 수의 데이터가 필요하다. 그러나 이러한 데이터셋을 만드는 것은 비용이나 시간면으로 쉽지 않은일이다.

 

위 문제점을 해결하기 위한 것이 바로  TL(Transfer Learning)이다. TL이란 이미지넷과 같이 아주 큰 데이터셋에 훈련된 모델의 가중치를 가지고 와서 우리가 해결하고자 하는 과제에 맞게 재보정해서 사용하는 것을 의미한다. 

좀 더 쉽게 설명하자면, 흔히 우리가 MNIST예제를 통해 만들어 저장해 놓은 h5 파일이 있을 것이다. 이 파일은 가중치를 저장해 놓은 파일인데 이를 다시 가져와 우리가 가중치를 다시 재보정해 사용하는 것과 동일하다.

 

결과적으로 비교적 적은 수의 데이터를 가지고도 우리가 원하는 과제를 해결할 수 있는 딥러닝 모델을 훈련시킬 수 있게 되는 것이다.

 

전이학습

전이 학습은 특정 환경에서 만들어진 AI 알고리즘을 다른 비슷한 분야에 적용하는 것으로, 현재 많은 연구가 이뤄지는 머신러닝의 한 분야이다. 

간략하게 설명해보면, 체스를 익힌 AI에게 장기를 두게 하는 느낌(?)이다.

 

'Deep Learning > NLP' 카테고리의 다른 글

01) 토큰화(Tokenization)  (0) 2021.03.10
트랜스포머(Transformer)(2)  (0) 2021.03.04
트랜스포머(Transformer)(1)  (0) 2021.03.04

1. Stochastic Gradient descent(SGD)

추출된 데이터 한 개에 대해서 error gradient를 계산하고, gradient descent 알고리즘을 적용하는 방법이다.

모델의 레이어 층은 하나의 행렬곱으로 생각할 수 있고, 여러개의 묶음 데이터는 행렬이라고 생각할 수 있다.

 

SGD의 장점

위 그림에서 볼수 있듯이 Shooting이 일어나기 때문에 local optimal에 빠질 리스크가 적다.

step에 걸리는 시간이 짧기 때문에 수렴속도가 상대적으로 빠르다.

 

SGD의 단점

global optimal을 찾지 못 할 가능성이 있다.

데이터를 한개씩 처리하기 때문에 GPU의 성능을 전부 활용할 수 없다.

 

코드로 살펴보면 다음과 같다.

class SGD:
    def __init__(self,lr=0.01):
        self.lr=lr
        
    def update(self,params, grads):
        for key in params.keys():
            params[key]-=self.lr*grads[key]
            

 

[출처] light-tree.tistory.com/133

 

딥러닝 용어정리, MGD(Mini-batch gradient descent), SGD(stochastic gradient descent)의 차이

제가 공부한 내용을 정리한 글입니다. 제가 나중에 다시 볼려고 작성한 글이다보니 편의상 반말로 작성했습니다. 잘못된 내용이 있다면 지적 부탁드립니다. 감사합니다. MGD(Mini-batch gradient descen

light-tree.tistory.com

2.  Momentum

모멘텀(momentum)은 '운동량'을 뜻하는 단어로, 물리와 관계가 있다. 

W는 갱신할 가중치 매개변수, dL/dW는 W에 대한 손실 함수의 기울기, η은 학습률이다. v라는 변수가 새로 나오는데, 이는 물리에서 말하는 valocity에 해당한다. 

momentum은 위 그림과 같이 공이 그릇의 바닥을 구른는 듯한 움직임을 보여준다.

코드로 살펴보면 다음과 같다.

class Momentum:
    def __init__(self, lr=0.01,momentum=0.9):
        self.lr=lr
        self.momentum=momentum
        self.v=None
    
    def update(self,params,grads):
        if self.v is None:
            self.v={}
            for key, val in params.items():
                self.v[key]=np.zeros_like(val)
            
            for key in params.keys():
                self.v[key]=self.momentum*self.v[key]-self.lr*grads[key]
                params[key]+=self.v[key]
                

모멘텀에 의한 최적화 갱신 경로

위 그림에서 볼 수있듯, 모멘텀의 갱신 경로는 공이 그릇 바닥을 구르듯 움직인다. SGD와 비교하면 '지그재그 정도'가 덜한 것을 알 수 있다. 즉, x축의 힘은 아주 작지만 방향은 변하지 않아서 한 방향으로 일정하게 가속하기 때문이다. 거꾸로 y축의 힘은 크지만 위아래로 번갈아 받아서 상충하여 y축 방향의 속도는 안정적이지 않다. 전체적으로 SGD보다 x축 방향으로 빠르게 다가가 지그재그 움직임이 줄어든다.

3. AdaGrad

신경망 학습에서는 학습률(수식에서는 η로 표기) 값이 중요하다. 

이 학습률을 정하는 효과적 기술로 learning rate decay(학습률 감소)가 있다. 이는 학습률을 점차 줄여가는 방법이다.

학습률을 서서히 낮추는 가장 간단한 방법은 매개변수 '전체'의 합습률 값을 일괄적으로 낮추는 것이다. 이를 더욱 바전시킨 것이 AdaGrad이다. AdaGrad는 '각각의' 매개변수에 '맞춤형'값을 만들어준다.

 

AdaGrad는 개별 매개변수에 적응적으로 학습률을 조정하면서 학습을 진행한다. AdaGrad의 갱신 방법은 수식으로는 다음과 같다.

[Note] AdaGrad는 과거의 기울기를 제곱하여 계속 더해간다. 그래서 학습을 진행할수록 갱신 강도가 약해진다. 실제로 무한히 계속 학습한다면 어느 순간 갱신량이 0이 되어 전혀 갱신되지 않게 된다. 이 문제를 개선한 기법으로서 RMSProp이라는 방법이 있다. RMSProp은 과거의 모든 기울기를 균일하게 더해가는 것이 아니라, 먼 과거의 기울기는 서서히 잊고 새로운 기울기 정보를 크게 반영한다. 이를 Exponential Moving average,EMA(지수이동평균)이라 하며, 과거 기울기의 반영 규모를 기하급수적으로 감소시킨다.

 

코드로 살펴보면 다음과 같다.

class AdaGrad:
    def __init__(self,lr=0.01):
        self.lr=lr
        self.h=None
        
    def update(self,params,grads):
        if self.h is None:
            self.h={}
            
            for key, val in params.items():
                self.h[key]=np.zeros_like(val)
            for key in params.keys():
                self.h[key]+=grads[key]*grads[key]
                params[key]-=self.lr*grads[key]/(np.sqrt(self.h[key])*1e-7)
                

위 코딩에서 주의해야 할 점은 마지막 줄에 1e-7이라는 작은 값을 더하는 부분이다. 이 작은 값은 self.h[key]에 0이 담겨 있다 해도 0으로 나누는 사태를 막아준다. 대부분의 딥러닝 프레임워크에서는 이 값도 인수로 설정할 수 있다.

AdaGrad에 의한 최적화 갱신 경로

위 그림을 보면 최솟값을 향해 효율적으로 움직이는 것을 알 수 있다. y축 방향은 기울기가 커서 처음에는 크게 움직이지만, 그 큰 움직임에 비례해 갱신 정도도 큰 폭으로 작아지도록 조정된다. 그래서 y축 방향으로 갱신 강도가 빠르게 약해지고, 지그재그 움직임이 줄어든다.

 

 

4. Adam

모멘텀은 공이 그릇 바닥을 구르는 듯한 움직임을 보였다. AdaGrad는 매개변수의 원소마다 적응적으로 갱신 정도를 조정했다. 그 두 기법을 융합한 아이디어에서 출발한 기법이 Adam이다. 

Adam에 의한 최적화 갱신 경로

Adam의 갱신 과정도 그릇 바닥을 구르듯 움직인다. 모멘텀과 비슷한 패턴이지만, 모멘텀보다 공의 좌우 흔들림이 적다.

 

 

[Note]

Adam은 하이퍼파라미터를 3개 설정한다. 하나는 지금까지의 학습률(논문에서는 α로 등장), 나머지 두 개는 일차 모멘텀용 계수 β1과 이차 모멘텀용 계수 β2이다. 논문에 따르면 기본 설정값은 β1은0.9, β2는0.999이며, 이 값이면 많은 경우에 좋은 결과를 얻을 수 있다.

 

 

SGD, Momentum, AdaGrad, Adam 총 4가지의 optimizer를 알아보았다. 하지만 모든 경우에 항상 뛰어난 기법은 없다. 각자의 장단점이 있기 때문에 해당 issue에 맞는 optimizer를 선택하는게 좋다. 

앞 장에서 신경망 학습에 대해 배웠다. 가중치 매개변수에 대한 손실 함수의 기울기는 수치 미분을 통해 구했다. 하지만 수치 미분은 단순하고 구현하기도 쉽지만 계산이 오래걸린다는 단점이 있다. 이에 효율적으로 계산하는 오차역전파법(backpropagation)을 알아보자.

5.1 계산그래프

계산 그래프는 계산 과정을 그래프로 나타낸 것이다. 이는 복수의 노드와 에지로 표현된다. 

다음 문제를 확인해보자.

문제1: 현빈 군은 슈퍼에서 1개에 100원인 사과를 2개 샀다. 이때 지불 금액을 구하라. 단, 소비세가 10%부과된다.

계산 그래프로 풀어본 문제 1의 답

위 그림과 같이 처음 사과의 100원이 '*2'노드로 흐르고 200원이 되어 다음 노드로 전달된다. 이제 200원이 '*1.1' 노드를 거쳐 220원이 된다. 그러므로 최종 답은 220원이 된다.

다른 계산 그래프를 살펴보자.

계산 그래프로 풀어본 문제 1의 답: '사과의 개수'와 '소비세'를 변수로 취급해 원 밖에 표기

위 그림은 사칙기호만을 노드에 넣고 나머지는 에지를 통해 들어가는 모습이다.

 

문제2: 현빈 군은 슈퍼에서 사과를 2개, 귤을 3개 샀다. 사과는 1개에 100원, 귤은 1개 150원이다. 소비세가 10%일 때 지불 금액을 구하라.

계산 그래프로 풀어본 문제 2의 답

이 문제에는 덧셈 노드인 '+'가 새로 등장하여 사과와 귤의 금액을 합산한다.

계산 그래프를 이용한 문제 풀이는 다음 흐름으로 진행된다.

1. 계산 그래프를 구성한다.

2. 그래프에서 계산을 왼쪽에서 오른쪽으로 진행한다.

 

여기서 '계산을 왼쪽에서 오른쪽으로 진행'하는 단계를 순전파(forward propagation)

반대 방향의 전파를 역전파(backward propagation)이라 부른다.

 

5.1.2 국소적 계산

계산 그래프의 특징: '국소적 계산'을 전파함으로써 최종 결과를 얻는다는 점에 있다.

국소적 계산은 결국 전체에서 어떤 일이 벌어지든 상관없이 자신과 관계된 정보만으로 결과룰 출력할 수 있다는 것이다.

 

사과 2개를 포함해 여러 식품을 구입하는 예

위 그림의 핵심은 각 노드에서의 계싼은 국소적 계산이라는 점이다. 즉, 노드는 자신과 관련한 계산 외에는 아무것도 신경 쓸게 없다. 전체 계산이 제 아무리 복잡하더라도 각 단계에서 하는 일은 해당 노드의 '국소적 계산'이며 국소적 계산은 단순하지만, 전체를 구성하는 복잡한 계산을 해낼 수 있다.

 

5.1.3 왜 계산 그래프로 푸는가?

계산 그래프의 이점은 다음과 같다.

1. 국소적 계산

2. 계산 그래프는 중간 계산 결과를 모두 보관할 수 있다.

실제 계산 그래프르 사용하는 가장 큰 이유는 역전파를 통해 미분을 효율적으로 계산할 수 있는 점에 있다.

 

5.2 연쇄법칙

그동안 해온 계산 그래프의 순전파는 계산 결과를 왼쪽에서 오른쪽으로 전달했다. 한편 역전파는 '국소적이 미분'을 순방향과는 반대인 오른쪽에서 왼쪽으로 전달한다. 또한 이 '국소적 미분'을 전달하는 원리는 연쇄법칙(chain rule)에 따른 것이다.

5.2.1 계산 그래프의 역전파

생략)

 

5.2.2 연쇄법칙이란?

연쇄법칙은 합성 함수의 미분에 대한 성질이며 다음과 같이 정의된다.

합성 함수의 미분은 합성 함수를 구성하는 각 함수의 미분의 곱을 나타낼 수 있다.

식 5.2,5.3,5.4

5.2.3 연쇄법칙과 계산 그래프

위 식 5.4의 연쇄법칙 계산을 계산 그래프로 나타내보자. 

식5.4의 계산 그래프: 순전파와는 반대 방향으로 국소적 미분을 곱하여 전달한다.

위 그림과 같이 계산 그래프의 역전파는 오른쪽에서 왼쪽으로 신호를 전파한다. 

역전파의 계산 절차에서는 노드로 들어온 입력 신호에 그 노드의 국소적 미분(편미분)을 곱한 후 다음 노드로 전달한다.

즉, 역전파가 하는 일은 연쇄 법칙의 원리와 같다.

 

계산 그래프의 역전파 결과가 다르면 

5.3 역전파

연산을 예로 들어 역전파의 구조를 살펴보자.

 

덧셈 노드의 역전파

z=x+y라는 식을 대상으로 살펴보자.

계산 그래프를 살펴보면,

덧셈노드의 역전파: 왼쪽이 순전파, 오른쪽이 역전파다. 덧셈 노드의 역전파는 입력 값을 그대로 흘려보낸다.
최종 출력으로 가는 계산의 중간에 덧셈 노드가 존재한다. 역전파에서는 국소적 미분이 가장 오른쪽의 출력에서 시작하여 노드를 타고 역방향으로 전파된다.
덧셈 노드 역전파의 구체적인 예

곱셈 노드의 역전파

z=xy라는 식을 생각해보자.

계산 그래프로 나타내면,

곱셈 노드의 역전파: 왼쪽이 순전파, 오른쪽이 역전파다.
곱셈 노드 역전파의 구체적인 예

곱셈 또한 덧셈과 크게 다를 것이 없다. 

 

5.4 단순한 계층 구현하기

5.4.1 곱셈 계층

모든 계층은 forward() 와 backward()라는 공통의 메서드(인터페이스)를 갖도록 구현할 것이다. forward()는 순전파, backward()는 역전파를 처리한다.

다음 코드를 살펴보자.

class MulLayer:
    def __init__(self):
        self.x=None
        self.y=None
        
    def forward(self,x,y):
        self.x=x
        self.y=y
        out=x*y
        return out
    def backward(self,dout):
        dx=dout*self.y
        dy=dout*self.x
        return dx,dy

__init__에서 인스턴스 변수 x,y 초기화. 이는 순전파 시의 입력 값을 유지하기 위해서 사용한다.

forward()에선 x,y를 인수로 받아 두값을 곱해 반환.

backward()에선 미분에 순전파 때의 값을 '서로 바꿔' 곱한 후 넘긴다.

사과예를 살펴보자.

<순전파>

apple=100
apple_num=2
tax=1.1

mul_apple_layer=MulLayer()
mul_tax_layer=MulLayer()

apple_price=mul_apple_layer.forward(apple,apple_num)
price=mul_tax_layer.forward(apple_price,tax)
print(price)

220.00000000000003

<역전파>

dprice=1
dapple_price,dtax=mul_tax_layer.backward(dprice)
dapple,dapple_num=mul_apple_layer.backward(dapple_price)

print(dapple,dapple_num,dtax)

2.2 110.00000000000001 200

5.4.2 덧셈 계층

덧셈 계층 코딩은 다음과 같다.

class AddLayer:
    def __init__(self):
        pass
    def forward(self,x,y):
        out=x+y
        return out
    def backward(self,dout):
        dx=dout*1
        dy=dout*1
        return dx,dy
    

사과 2개와 귤 3개 구입

이 계산 그래프를 코딩으로 구현하면,

apple=100
apple_num=2
orange=150
orange_num=3
tax=1.1

mul_apple_layer=MulLayer()
mul_orange_layer=MulLayer()
add_apple_orange_layer=AddLayer()
mul_tax_layer=MulLayer()

apple_price=mul_apple_layer.forward(apple,apple_num)
orange_price=mul_orange_layer.forward(orange,orange_num)
all_price=add_apple_orange_layer.forward(apple_price,orange_price)
price=mul_tax_layer.forward(all_price,tax)

dprice=1
dall_price,dtax=mul_tax_layer.backward(dprice)
dapple_price,dorange_price=add_apple_orange_layer.backward(dall_price)
dorange,dorange_num=mul_orange_layer.backward(dorange_price)
dapple,dapple_num=mul_apple_layer.backward(dapple_price)

print(price)
print(dapple_num,dapple,dorange,dorange_num,dtax)

715.0000000000001
110.00000000000001 2.2 3.3000000000000003 165.0 650

5.5 활성화 함수 계층 구현하기

활성화 함수에 관련하여 앞선 포스트에 세밀하게 해놓았다. 그러므로 이번은 코딩으로 구현하는 것만 확인하겠다.

 

ReLU

class Relu:
    def __init__(self):
        self.mask=None
    def forward(self,x):
        self.mask=(x<=0)
        out=x.copy()
        out[self.mask]=0
        return out
    def backward(self,dout):
        dout[self.mask]=0
        dx=dout
        
        return dx
    
x=np.array([[1.0,-.5],[-2.0,3.0]])
print(x)

[[ 1.  -0.5]
 [-2.   3. ]]
 
mask=(x<=0)
print(mask)

[[False  True]
 [ True False]]

Sigmoid

class Sigmoid:
    def __init__(self):
        self.out=None
    def forward(self,x):
        out=1/(1+np.exp(-x))
        self.out=out
        return out
    def backward(self,dout):
        dx=dout*(1.0-self.out)*self.out
        return dx

Affine

class Affine:
    def __init__(self,W,b):
        self.W=W
        self.b=b
        self.x=None
        self.dW=None
        self.db=None
        
    def forward(self,x):
        self.x=x
        out=np.dot(x,self.W)+self.b
        return out
    def backward(self,dout):
        dx=np.dot(dout,self.W.T)
        self.dW=np.dot(self.x.T,dout)
        self.db=np.sum(dout,axis=0)
        return dx
    

Softmax-with-Loss

class SoftmaxWithLoss:
    def __init__(self):
        self.loss=None
        self.y=None
        self.t=None
    def forward(self,x,t):
        self.t=t
        self.y=softmax(x)
        self.loss=cross_entropy_error(self.y,self.t)
        return self.loss
    def backward(self,dout=1):
        batch_size=self.t.shape[0]
        dx=(self.y-self.t)/batch_size
        return dx

 

이번장은 신경망 학습이다.

 

학습: 훈련 데이터로부터 가중치 매개변수의 최적값을 자동으로 획득하는 것을 뜻한다.

 

신경망을 학습할 수 있도록 해주는 지표인 손실 함수를 알아보자.

 

4.1.0 데이터에서 학습한다

신경망의 특징은 데이터를 보고 학습할 수 있다는 것이다. 즉, 데이터에서 학습한다는 것은 가중치 매개변수의 값을 데이터를 보고 자동으로 결정한다는 의미이다. 

 

4.1.1 데이터 주도 학습

Machine Learning은 데이터가 생명이다. 데이터에서 답을 찾고 데이터에서 패턴을 발견하고 데이터로 이야기를 만드는, 그것이 바로 기계학습이다. 인간과 기계학습의 차이점을 본다면, 인간은 경험과 직관을 단서로 시행착오를 거듭하여 일을 진행한다. 이에 반해 기계학습은 사람의 개입을 최소화하고 수집한 데이터로부터 데이터를 찾으려 시도한다. 

우리가 흔히 아는 MNIST를 본다면, 이미지에서 특징을 추출하고 그 특징의 패턴을 기계학습 기술로 학습하는 방법이 있다. 이와 같이 기계학습에서는 모아진 데이터로부터 규칙을 찾아내는 역할을 '기계'가 담당한다. 하지만, 이미지를 벡터로 변환할 때 사용하는 특징은 여전히 '사람'이 설계한다.

 

4.1.2 훈련 데이터와 시험 데이터

기계학습에서는 문제를 다룰 때 데이터를 훈련데이터(Train Data)와 시험데이터(Test Data)로 나눠 학습과 실험을 수행하는 것이 일반적이다. 

1. 우선 훈련 데이터만 사용하여 학습하면서 최적의 매개변수를 찾는다.

2. 그 후, 시험 데이터를 사용하여 앞서 훈련한 모델의 실력을 평가한다.

이렇게 나누는 이유는 뭘까?

우리가 설계하는 이유는 갖고 있는 데이터를 통해서만 사용하는 것이 아닌, 범용적으로 사용하기 위한 모델을 만들기 위해서이다. 다시말해, 아직 보지 못한 데이터(훈련 데이터에 포함되지 않는 데이터)로도 문제를 올바르게 풀어내는 능력이다.

또한, 수중의 데이터셋은 제대로 맞히더라도 다른 데이터셋에는 엉망인 일도 벌어진다. 이와 관련해, 한 데이터셋에만 지나치게 최적화된 상태를 오버피팅(overfitting)이라고 한다.

오버피팅(overfitting): 특정 데이터셋에만 너무 많이 들어서 편견이 생겨버린 상태로 이해하자. 과적합, 과대적합, 과학습 등 다양하게 번역되어있다.

 

4.2 손실함수

인간이 예를 들어 행복의 지수를 수치로 파악하고, 이를 근거로 '최적의 인생'을 탐색하듯, 신경망도 '하나의 지표'를 기준으로 최적의 매개변수 값을 탐색한다. 신경망 학습에서 사용하는 지표는 손실함수(loss function)이라고 한다.

이 손실 함수는 임의의 함수를 사용할 수도 있지만, 일반적으로 평균 제곱 오차(MSE)와 교차 엔트로피 (Cross entropy error)를 사용한다.

 

4.2.1 평균 제곱 오차(Mean Squared Error)

가장 많이 쓰이는 손실 함수는 평균 제곱 오차이다. 

 

평균 제곱 오차 정의

여기서 yi는 신경망의 출력(신경망이 추정한 값), ti는 정답 레이블, i는 데이터의 차원수를 나타낸다.  코딩의 예를 통해 살펴보자. 

def mean_squared_error(y,t):
    return 0.5*np.sum((y-t)**2)
#정답은 '2'
t=[0,0,1,0,0,0,0,0,0,0]

#예1: '2'일 확률이 가장 높다고 추정함(0.6)
y1=[.1,.05,.6,.0,.05,.1,.0,.1,.0,.0]
print(mean_squared_error(np.array(y1),np.array(t)))

#예2: '7'일 확률이 가장 높다고 추정함(0.6)
y2=[.1,.05,.1,.0,.05,.1,.0,.6,.0,.0,]
print(mean_squared_error(np.array(y2),np.array(t)))

0.09750000000000003
0.5975

첫 번째 예는 정답이 '2'고 신경망의 출력도 '2'에서 가장 높은 경우다.

두 번째 예는 정답은 똑같이 '2'지만, 신경망의 출력은 '7'에서 가장 높다. 위 실험의 결과로 첫 번째 손실함수 쪽 출력이 작으며 정답레이블과의 오차도 작은 것을 알 수 있다. 즉, 평균 제곱 오차를 기준으로는 첫 번째 추정 결과가 정답에 더 가까운 것으로 판단 할 수 있다.

4.2.2 교차 엔트로피(Cross Entropy Error)

또 다른 손실 함수로서 교차 엔트로피 오차(Cross entropy error)도 자주 이용한다. 

교차 엔트로피 오차 정의

log는 밑이 e인 자연로그, yk는 신경망의 출력, tk는 정답 레이블이다. tk는 정답에 해당하는 인덱스의 원소만 1이고 나머지는 0이다(원-핫 인코딩). 코딩의 예를 통해 살펴보자.

def cross_entropy_error(y,t):
    delta=1e-7
    return -np.sum(t*np.log(y+delta))

t=[0,0,1,0,0,0,0,0,0,0]
y1=[.1,.05,.6,.0,.05,.1,.0,.1,.0,.0]
y2=[.1,.05,.1,.0,.05,.1,.0,.6,.0,.0]
print(cross_entropy_error(np.array(y1),np.array(t)))
print(cross_entropy_error(np.array(y2),np.array(t)))

0.510825457099338
2.302584092994546

첫 번째 예는 정답일 때의 출력이 .6인 경우로, 이때의 교차 엔트로피 오차는 약 .51이다. 

두 번째 예는 정답일 때의 출력이 .1인 경우로, 이때의 교차 엔트ㄹ로피 오차는 무려 2.3이다.

즉, 결과(오차)가 더 작은 첫 번째 추정이 정답일 가능성이 높다고 판단한 것으로 앞서 MSE와 일치하다.

 

4.2.3 미니배치 학습

기계학습 문제는 훈련 데이터에 대한 손실 함수의 값을 구하고, 그 값을 최대한 줄여주는 매개변수를 찾아낸다. 이렇게 하려면 모든 훈련 데이터를 대상으로 손실 함수 값을 구해야 한다. 즉, 훈련 데이터가 100개 있으면 그로부터 계산하 100개의 손실 함수 값들의 합을 지표로 삼는 것이다. 

앞선 내용에서는 데이터 하나에 대한 손실 함수만 생각했으니, 이제 훈련 데이터 모두에 대한 손실 함수의 합을 구하는 방법을 보자.

교차 엔트로피 공식은 다음과 같다.

교차엔트뢰 오차-배치 정의

이때, 데이터가 N개라면 tnk는 n번째 데이터의 k번째 값을 의미한다.(ynk는 신경망의 출력, tnk는 정답 레이블)

앞선 한 개의 대한 CEE에서 단순히 N개의 데이터로 확장했을 뿐이다. 다만, 마지막에 N으로 나누어 정규화하고 있다.

즉, N으로 나눔으로써 '평균 손실 함수'를 구하는 것이다. 이렇게 평균을 구해 사용하면 훈련 데이터의 개수와 상관없이 언제든 통일된 지표를 얻을 수 있다. 

생각해보자. 실제 빅데이터를 다루게 된다면 수천만도 넘는 거대한 데이터를 다루게 될텐데 과연 이를 일일이 손실함수를 계산하는게 현실적인가? 아니라고 본다. 그러므로 일부 데이터를 Random sampling을 통해 수집하고 학습하는게 더 효율적일 것이다. 이러한 학습 방법을 미니배치 학습이라고 한다.  코딩의 예를 통해 살펴보자.

from keras.datasets import mnist
(X_train,t_train),(X_test,t_test)=mnist.load_data()

Downloading data from https://storage.googleapis.com/tensorflow/tf-keras-datasets/mnist.npz
11493376/11490434 [==============================] - 1s 0us/step

print(X_train.shape)
print(t_train.shape)
(60000, 28, 28)
(60000,)

여기서 mnist 데이터셋을 불러오는 방법은 keras,tensorflow등 다양하니 참고하길 바란다.

train_size=X_train.shape[0]
batch_size=10
batch_mask=np.random.choice(train_size,batch_size)
x_batch=X_train[batch_mask]
t_batch=t_train[batch_mask]
np.random.choice(600000,10)
array([ 70533, 465580, 501527, 363874, 118036, 136283,  57982, 367151,
       514364, 529300])

훈련 데이터는 60000개고, 입력 데이터는 28*28인 이미지 데이터임을 알 수 있다. np.random.choice()로는 지정한 범위의 수 중에서 random sampling을 할 수 있다. 예를 들면, np.random.choice(60000,10)은 0 이상 60000미만의 수 중에서 무작위로 10개를 골라낸다는 의미이다.

 

4.2.4 (배치용) 교차 엔트로피 오차 구현하기

#정답 레이블이 원-핫 인코딩일 경우
def input_by_onehot_cross_entropy_error(y,t):
    if y.dim==1:
        t=t.reshape(1,t.size)
        y=y.reshape(1,y.size)
        
    batch_size=y.shape[0]
    return -np.sum(t*np.log(y))/batch_size

#정답 레이블이 '2'나 '7' 처럼 숫자 레이블일 경우
def input_by_shape_cross_entropy_error(y,t):
    if y.dim==1:
        t=t.reshape(1,t.size)
        y=y.reshape(1,y.size)
    batch_size=y.shape[0]
    return -np.sum(np.log(y[np.arange(batch_size),t]))/batch_size

이 구현에서 원-핫 인코딩일 때 t가 0인 원소는 교차 엔트로피 오차도 0이므로, 그 계산은 무시해도 좋다는 것이 핵심이다. 다시 말해, 정답에 해당하는 신경망의 출력만으로 교차 엔트로피의 오차를 계산할 수 있다.

 

4.2.5 왜 손실 함수를 성정하는가?

앞선 내용에서는 왜 손실 함수를 설정하는가에 대한 상세한 설명이 없었다.

그렇다면 왜 굳이 손실 함수를 사용해야할까?

이를 위해서는 우리가 기계학습을 하는 궁극적 목표를 생각해봐야 한다. 우리의 목적은 높은 '정확도'를 끌어내는 매개변수 값을 찾는 것이다.

하지만 '정확도'라는 질표를 놔두고 '손실 함수의 값'이라는 우회적인 방법을 선택하는 이유는 무엇일까?

이는 '미분'과 관련이 있다. 이는 다음장에서 설명을 하고, 간단하게 설명하자면 신경망 학습에서는 최적의 매개변수(가중치와 편향)를 탐색할 때 손실함수의 값을 가능한 작게 하는 매개변수 값을 찾는다. 이때 매개변수의 미분(정확히 기울기)을 계산하고, 그 미분 값을 단서로 매개변수의 값을 서서히 갱신하는 과정을 반복한다.

가중치 매개변수의 손실함수의 미분이란 '가중치 매개변수의 값을 아주 조금 변화시켰을 때, 손실 함수가 어떻게 변하나'라는 의미이다.

미분값이 음이면 그 가중치를 양으로, 반대로 양이면 가중치를 음으로 변화시켜 손실 함수의 값을 줄일 수 있다.

미분 값이 0이면 가중치 매개변수를 어느 쪽으로 움직여도 손실 함수의 값은 달라지지 않는다. 그래서 그 가중치 매개변수의 갱신은 거기서 멈춘다. 

즉, 이말은 미분 값이 대부분이 장소에서 0이 되어 매개변수를 갱신할 수 없기 때문이다.

정리하자면,

신경망을 학습할 때 정확도를 지표로 삼아서는 안된다. 정확도를 지표로 하면 매개변수의 미분이 대부분의 장소에서 0이되기 때문이다.

정확도는 매개변수의 미소한 변화에는 거의 반을을 보이지 않고, 반응이 있더라고 그 값이 불연속적으로 갑자기 변화한다. 이는 '계단 함수'를 활성화 함수로 사용하지 않는 이유와도 들어맞는다. 반대로 sigmoid 함수를 생각해보자. 시그모이드 함수는 출력(세로축의 값)이 연속적으로 변하고 곡선의 기울기도 연속적으로 변한다. 즉, 시그모이드 함수의 미분은 어느 장소라도 0이 되지는 않는다는 의미이다. 

시그모이드 함수 그래프

4.3,4.4 수치 미분, 기울기

미분과 관련하여는 자세하게 설명하지 않겠다. 본인은 수리통계학을 공부하기에 이는 따로 수리통계학에 정리하여 올리도록 하겠다.

대략 미분, 편미분,기울기,경사하강법,신경망에서의 기울기 등이 있는데 이는 생략한다.

 

하이퍼파라미터

학습률 같은 매개변수를 하이퍼파라미터(hyper parameter,초매개변수)라고 한다. 이는 가중치와 편향 같은 신경망의 매개변수와는 성질이 다른 매개변수이다. 신경망의 가중치 매개변수는 훈련 데이터와 학습 알고리즘에 의해서 '자동'으로 획득되는 매개변수인 반면, 학습률 같은 하이퍼파라미터는 사람이 직접 설정해야하는 매개변수인 것이다. 일반적으로 이들 하이퍼파라미터는 여러 후보 값 중에서 시험을 통해 가장 장 학습하는 값을 찾는 과정을 거쳐야 한다.

이와 관련하여 CNN Hyperparameter Optimization Based on CNN Visualization and Perception Hash Algorithm 논문을 조만간 리뷰하도록 하겠다.

 

4.5 학습 알고리즘 구현하기

Given

신경망에서 적응 가능한 가중치와 편향이 있고, 이 가중치와 편향을 훈련 데이터에 적응하도록 조정하는 과정을 '학습'이라 한다. 신경망 학습은 다음과 같이 4단계로 수행한다.

Stage1.-Mini Batch

훈련 데이터 중 일부를 무작위로 가져온다. 이렇게 선별한 데이터를 미니배치라 하며, 그 미니배치의 손실 함수 값을 줄이는 것이 목표이다.

Stage2.-Calculating Slope

미니배치의 손실 함수 값을 줄이기 위해 각 가중치 매개변수의 기울기를 구한다. 기울기는 손실 함수의 값을 가장 작게 하는 방향을 제시한다.

Stage3.-Renewing Parameter

가중치 매개변수를 기울기 방향으로 아주 조금 갱신한다.

Stage4.-Repetition

Stage1~Stage3를 반복한다.

 

이것이 신경망 학습이 이뤄지는 순서이다. 이는 경사 하강법으로 매개변수를 갱신하는 방법이며, 이때 데이터를 미니배치로 무작위로 선정하기 때문에 확률적 경사 하강법(Stochastic Gradient descent, SGD)라고 부른다. 

즉, '확률적으로 무작위로 골라낸 데이터'에 대해 수행하는 경사 하강법이라는 의미이다. 대부분의 딥러닝 프레임워크는 확률적 경사 하강법의 영어 머리글자를 딴 SGD라는 함수로 이 기능을 구현한다.

 

4.5.1,2 전체 코딩 

2-Layer Net

class TwoLayerNet:
    def __init__(self,input_size,hidden_size,output_size,weight_init_std=0.01):
        self.params={}
        self.params['W1']=weight_init_std*np.random.randn(input_size,hidden_size)
        self.params['b1']=np.zeros(hidden_size)
        self.params['W2']=weight_init_std*np.random.randn(hidden_size,output_size)
        self.params['b2']=np.zeros(output_size)
        
    def predict(self,x):
        W1,W2= self.params['W1'], self.params['W2']
        b1,b2=self.params['b1'],self.params['b2']
        
        a1=np.dot(x,W1)+b1
        z1=sigmoid(a1)
        a2=np.dot(z1,W2)+b2
        y=softmax(a2)
        
        return y
    
    def loss(self,x,t):
        y=self.predict(x)
        
        return cross_entropy_error(y,t)
    
    def accuracy(self,x,t):
        y=self.predict(x)
        y=np.argmax(y,axis=1)
        t=np.argmax(t,axis=1)
        
        accuracy=np.sum(y==t)/float(x.shape[0])
        return accuracy
    
    def numerical_gradient(self,x,t):
        loss_W=lambda W: self.loss(x,t)
        
        grads={}
        grads['W1']=numerical_gradient(loss_W,self.params['W1'])
        grads['b1']=numerical_gradient(loss_W,self.params['b1'])
        grads['W2']=numerical_gradient(loss_W,self.params['W2'])
        grads['b2']=numerical_gradient(loss_W,self.params['b1'])
        
        return grads
    
    
    

미니배치 학습 구현하기

 

(x_train,t_train),(x_test,t_test)=mnist.load_data()

train_loss_list=[]

iters_num=10000
train_size=x_train.shape[0]
batch_size=100
learning_rate=.1
network=TwoLayerNet(input_size=784,hidden_size=50,output_size=10)

for i in range(iters_num):
    batch_mask=np.random.choice(train_size,batch_size)
    x_batch=x_train[batch_mask]
    t_batch=t_train[batch_mask]
    
    grad=network.numerical_gradient(x_batch,t_batch)
    
    for key in ('W1','b1','W2','b2'):
        network.params[key]-=learning_rate*grad[key]
        
    loss=network.loss(x_batch,t_batch)
    train_loss_list.append(loss)

 

3.3 다차원 배열

-다차원 배열

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

다차원 배열도 기본은 '숫자의 집합'인데, N차원으로 나열하는 모든 것을 통틀어 다차원 배열이라 한다.

import numpy as np
A=np.array([1,2,3,4]) # 1차원 배열
print(A)
[1 2 3 4]

np.ndim(A)
1
A.shape
(4,)
A.shape[0]
4

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

배열에 대해서는 다들 잘 알것이기에 간단하게 생략하고 넘어가겠다.

-행렬의 내적(행렬 곱)

이공계 출신이라면 당연히 내적을 할 줄 알것이라 믿는다. 그러므로 설명은 생략하고 파이썬을 통해 어떻게 내적을 구현하는지 살펴보자.

 

 

A=np.array([[1,2],[3,4]]) #2차원 배열 #2x2 행렬
B=np.array([[5,6],[7,8]]) #2차원 배열 #2x2 행렬

np.dot(A,B)

array([[19, 22],
       [43, 50]])

우리는 행렬의 내적을 할때 numpy 함수 np.dot()을 통해 계산할 수 있다. np.dot()은 넘파이 배열 2개를 인수로 받아 그 내적을 반환한다. 하지만 주의해야할 점은 np.dot(A,B)와 np.dot(B,A)는 다른 값이 될 수 있다는 점을 꼭 기억하자. 

 

또한 우리가 행렬의 곱을 할때는 중요한 점이 하나 있는데, 

행렬의 곱에서는 대응하는 차원의 원소 수를 일치시켜라.

다음과 같이 곱에서 차원의 원소수는 일치시켜야 한다. 그것이 약속이다!

 

-신경망의 내적

이번에는 신경망을 생각해보자, 

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

위 신경망은 편향과 활성화 함수를 생략하고 가중치만 갖는다. 위 그림을 파이썬으로 구현해보자.

X=np.array([1,2]) #1x2 행렬
W=np.array([[1,3,5],[2,4,6]]) #2x3 행렬
Y=np.dot(X,W)
print(Y)

[ 5 11 17]

 

-3층 신경망 구현하기

2층신경망까지 구현했다면, 3층 신경망은 어떨까? 

3층 신경망

위 신경망은 입력층(0층)은 2개, 첫 번째 은닉층(1층)은 3개, 두 번째 은닉층(2층)은 2개, 출력층(3층)은 2개의 뉴런으로 구성된다.

 

표기법은 다음과 같다.

 

중요한 표기

-각 층의 신호 전달 구현하기

우선 그림을 확인해보자.

입력층에서 1층으로 신호 전달

편향을 뜻하는 뉴런 (1)이 추가되었다. 편향은 오른쪽 아래 인덱스가 하나밖에 없다는 것에 주의하자.

또한, 편향의 입력 신호는 항상 1임을 기억하자.

계산은 다음과 같다. 설명은 생략하겠다.

where)

코딩으로 구현하면,

X=np.array([1.0,.5])
W1=np.array([[.1,.3,.5],[.2,.4,.6]])
B1=np.array([.1,.2,.3])

A1=np.dot(X,W1)+B1
print(A1)

[0.3 0.7 1.1]

다음은 1층의 활성화 함수에서의 처리를 살펴보자. 이 과정은 다음과 같다.

입력층에서 1층으로의 신호 전달

위 그림과 같이 은닉층에서의 가중치 합(가중 신호와 편향의 총합)을 a로 표기하고 활성화 함수h()로 변환된 신호를 z로 표기한다. 여기서 활성화 함수로 앞선 포스팅에서 배운 시그모이드를 사용한다. 

코딩으로 살펴보자

def sigmoid(x):
    return 1/(1+np.exp(-x))
X=np.array([1.0,.5])
W1=np.array([[.1,.3,.5],[.2,.4,.6]])
B1=np.array([.1,.2,.3])

A1=np.dot(X,W1)+B1
Z1=sigmoid(A1)
print(A1)
print(Z1)

[0.3 0.7 1.1]
[0.57444252 0.66818777 0.75026011]

 

그 다음은 1층에서 2층으로 가는 과정을 살펴보자. 우선 그림은 다음과 같다.

1층에서 2층으로의 신호 전달

구현 방식은 똑같다.

이제 코딩으로 구현해보자.

def sigmoid(x):
    return 1/(1+np.exp(-x))
X=np.array([1.0,.5])
W1=np.array([[.1,.3,.5],[.2,.4,.6]])
B1=np.array([.1,.2,.3])

A1=np.dot(X,W1)+B1
Z1=sigmoid(A1)
W2=np.array([[.1,.4],[.2,.5],[.3,.6]])
B2=np.array([.1,.2])

A2=np.dot(Z1,W2)+B2
Z2=sigmoid(A2)

print(A1)
print(Z1)
print(A2)
print(Z2)

[0.3 0.7 1.1]
[0.57444252 0.66818777 0.75026011]
[0.51615984 1.21402696]
[0.62624937 0.7710107 ]

마지막으로 2층에서 출력층으로의 신호 전달을 살펴보자. 우선은 그림은 다음과 같다.

2층에서 출력층으로의 신호 전달

출력층의 구현도 그동안의 구현과 거의 동일하다. 딱 한가지 다른점은, 활성화 함수만 지금까지의 은닉층과 다르다는 점이다. 

이를 코딩으로 구현해보자.

def sigmoid(x):
    return 1/(1+np.exp(-x))
X=np.array([1.0,.5])
W1=np.array([[.1,.3,.5],[.2,.4,.6]])
B1=np.array([.1,.2,.3])

A1=np.dot(X,W1)+B1
Z1=sigmoid(A1)
W2=np.array([[.1,.4],[.2,.5],[.3,.6]])
B2=np.array([.1,.2])

A2=np.dot(Z1,W2)+B2
Z2=sigmoid(A2)

def identity_function(x):
    return x

W3=np.array([[.1,.3],[.2,.4]])
B3=np.array([.1,.2])

A3=np.dot(Z2,W3)+B3
Y=identity_function(A3)
print(A1)
print(Z1)
print(A2)
print(Z2)
print(A3)
print(Y)

[0.3 0.7 1.1]
[0.57444252 0.66818777 0.75026011]
[0.51615984 1.21402696]
[0.62624937 0.7710107 ]
[0.31682708 0.69627909]
[0.31682708 0.69627909]

 

-출력층 설계하기

신경망은 분류와 회귀 모두에 이용 가능하다. 다만 둘 중 어떤 문제냐에 따라 출력층에서 사용하는 활성화 함수가 달라진다. 일반적으로 회귀에는 항등 함수를 분류에는 소프트맥스 함수를 사용한다. 

활성함수는 앞선 포스트에 열심히 다뤄놓았으니 필요하면 꼭 찾아서 보길 바란다.

 

-출력층의 뉴런 수 정하기

출력층의 뉴런 수는 주어진 문제에 맞게 적절히 정해야 한다. 일반적으로 분류하고 싶은 클래스 수로 설정한다. 책의 예를 빌리자면, 흔히 MNIST 과정에서 숫자 이미지 0~9중 하나로 분류하는 문제라면 출력층의 뉴런의 수는 10개로 설정한다.

 

뒤에 손글자 숫자 인식은 너무나 흔하며, 실제 이 과정을 더 심화로 다루는 것이 뒤에 나오니 지금은 생략하도록 하겠다. 그럼 20000

'Deep Learning > 밑바닥부터 시작하는 딥러닝(1)' 카테고리의 다른 글

Chapter 6. 학습 관련 기술들(1)  (0) 2021.03.02
Chapter 5. 오차역전파법  (0) 2021.01.25
Chapter 4. 신경망 학습  (0) 2021.01.23
Chapter 3-1 신경망  (0) 2021.01.17
2.1 퍼셉트론이란?  (0) 2021.01.17

+ Recent posts