단일층 신경망과 사이킷런으로 로지스틱 회귀 수행
이전에 로지스틱 회귀를 직접 구현해 보았다.
로지스틱 회귀이 단일 신경층 망(single layer neural network) 동일하다고 한다.
신경망??? 단일층 ??? 이부분에 대해 좀더 자세히 알아보고,
또한 이 단일 신경층 망 즉 로지스틱 회귀를 구현하는 것이 귀찮기 때문에 사이킷 런으로 간편하게 로지스틱 회귀를 수행해 보자
신경망(Neural Network) :
위 그림을 보면 일반적인 신경망의 구조로 가장 왼쪽에 입력층(input layer), 가장 오른쪽이 출력층(output layer), 그리고 가운데 층들을 은닉층(hidden layer)라 부른다. 또한 작은 원으로 표시된 활성화 함수는 은닉층과 출력층의 한부분에 속한다.
그럼면 이전에 로지스틱 회귀는 단일층 신경망과 같다고 하였는데 단일층 신경망에 대해 알아보자.
단일층 신경망(Single Layer Neural Network) :
단일층 신경망은 일반적인 신경망의 구조에서 은닉층이 없는 신경망 즉 입력층과 출력층만 가지는 신경망을 의미한다.
로지스틱 회귀는 이러한 특징을 가지는 단일층 신경망이다.
지금부터 단일층 신경망을 구현해보고자 한다.
로지스틱 회귀가 단일층 신경망이라며 그러면 이전에 구현한 로지스틱 회귀가 단일층 신경망 구현한것 아님 ???
맞는데 다시 구현하려는 이유가 몇가지 유용한 기능 추가하기 위해서이다.
대표적으로 선형 회귀나 로지스틱 회귀 모두 경사 하강법을 사용했는데 이 방법은 손실함수의 결괏값을 최소화 하는 방향으로 가중치, 절편을 업데이트 하엿다.
선형 회귀 경우 손실 함수로 제곱 오차 손실 함수를,
로지스틱 회귀 경우 손실 함수로 로지스틱 손실 함수를 사용했었다.
만약 손실 함수의 결괏값이 줄어들지 않는다면 어떠한 문제가 있다는 것인데 그 결괏값이 줄어들고 있는지 확인하기 위한 기능을 추가할 것이다.
그럼 코드로 구현해 보자.
# 손실 함수의 결괏값 저장하는 기능을 추가하자
# 먼저 이전에 구현한 로지스틱 회귀 LogisticNeuron 클래스를 그대로 복사 후 클래스 이름을 SingleLayer로 바꾸자
# 여기에 수정 및 새로운 기능을 추가할 것이다.
# 기존 LogisticNeuron 클래스의
# __init__() 메서드에 self.losses 리스트를 만들어서 손실 함수의 결괏값을 저장할 것임
# 각 샘플마다 손실 함수를 계산하고 그 결괏값을 모두 더한 후 샘플 개수로 나눈 평균값을 구할 것임
# 그 평균값을 self.losses에 저장할 것임
# 그리고 self.activation() 메서드로 계산한 a를 np.log()계산을 위해 np.clip()을 적용할 것이다.
# 이게 무슨말 ??
# a가 만약 0에 가까워지게 되면 np.log() 값은 음의 무한대로 가게 되고 a가 1에 가까워 지면 np.log() 값은
# 0에 수렴한다. 따라서 손실값이 무한해지면 정확한 계산히 힘들어지기 때문에 a의 값의 범위를 조절하고 싶다.
# 이때 np.clip()을 이용하면 된다.
# 여기에선 np.clip(a, 1e - 10, 1 - 1e - 10)을 적용하여 a의 값이
# -1 x 10^(-10) ~ 1 - 1 x 10^(10) 사이가 되도록 조절한다.
# np.clip()은 주어진 범위 밖의 값을 잘라 내어 이러한 기능을 구현한다.
import numpy as np
from math import *
class SingleLayer:
def __init__(self):
self.w = None
self.b = None
self.losses = [] # 손실 함수의 결괏값을 저장할 리스트
def forpass(self, x):
z = np.sum(x * self.w) + self.b
return z
def backprop(self, x, err):
w_grad = x * err
b_grad = 1 * err
return w_grad, b_grad
def activation(self, z):
z = np.clip(z, -100, None) # 안전한 np.exp() 계산을 위해 클리핑한다.
a = 1 / (1 + np.exp(-z))
return a
def fit(self, x, y, epochs = 100):
self.w = np.ones(x.shape[1])
self.b = 0
for i in range (epochs):
loss = 0
indexes = np.random.permutation(np.arange(len(x))) # indexes를 섞음
# ** np.random.permutation()을 써서 왜 indexes를 굳이 섞지??? 이부분에 대해서는 아래에 설명하겠다.
for i in indexes:
z = self.forpass(x[i])
a = self.activation(z)
err = -(y[i] - a)
w_grad, b_grad = self.backprop(x[i], err)
self.w -= w_grad
self.b -= b_grad
a = np.clip(a, 1e-10, 1 - 1e-10) # 안전한 로그 계산을 위해 클리핑한 후 손실을 누적함
loss += -(y[i] * np.log(a) + (1 - y[i]) * np.log(1 - a)) # 에포크 마다 평균 손실을 구함
self.losses.append(loss/len(y)) # 구한 손실을 losses에 저장
def predict(self, x):
z = [self.forpass(x_i) for x_i in x]
return np.array(z) > 0 # 계단 함수 적용
def score(self, x, y):
return np.mean(self.predict(x) == y)
# 위에 score() 메서드를 추가하고 predict() 메서드를 기존의 LogisticNeuron 클래스에서 수정을 하였다.
# 시그모이드 함수의 출력값이 0 ~ 1 사이 이므로 이것을 확률값으로 보고
# 양성 클래스를 판단하는 기준이 0.5 이상이다.
# z가 0보다 크게 되면 시그모이드 함수의 출력값이 0.5보다 커지고
# z가 0보다 작으면 시그모이드 함수의 출력값은 0.5보다 작다.
# 따라서 predict() 메서드에 굳이 시그모이드 함수를 사용하지 않고 z값이 0보다 큰지 작은지만 판단하면 된다.
# 그래서 predict() 메서드를 굳이 로지스틱 함수 적용 안하고 z 값 크기로 비교하여 결과 반환함.
위 SingleLayer 클래스를 구현할 때 왜 np.random.permutation()을 사용하여 indexes를 굳이 섞는지 궁금해 햐였다
이 부분을 설명하기 위해서 여러가지 경사하강법에 대해 먼저 알아 보아야 한다.
여러가지 경사 하강법 :
지금까지 사용해왔던 경사 하강법을 생각해보자..
def fit(self, x, y, epochs = 100):
self.w = np.ones(x.shape[1]) #가중치를 초기화 한다. np.ones()함수를 이용해 1로 초기화
self.b = 0 #절편을 0으로 초기화
for i in range (epochs): # epochs만큼 반복
for x_i, y_i in zip(x, y):
z = self.forpass(x_i) # 선형함수식에 대입하여 z 구함
a = self.activation(z) # z를 활성화 함수에 대입하여 a 구함
err = -(y_i - a) # 오차를 계산함
w_grad, b_grad = self.backprop(x_i, err) # 역방향 계산으로 가중치 gradient와 절편 gradient 구함
self.w -= w_grad # 가중치를 갱신한다.
self.b -= b_grad # 절편을 갱신한다.
위 코드는 LogisticNeuron 클래스의 fit() 메서드 이다.
경사하강법을 통해 가중치와 절편을 갱신하는데 x, y 샘플에서 z와 a를 갱신할때 x_i, y_i 이렇게 하나의 샘플만 뽑아서 하나의 샘플 데이터 1개에 대해서만 gradient를 구하여 갱신하고 있다.
즉 전체 샘플 수 만큼 만복해서 계산하지만 1개의 샘플 데이터를 뽑아서 gradient 구함
확률적 경사 하강법(Stochastic Gradient Descent) :
위의 fit()메서드 즉 지금까지 사용해왔던 경사 하강법인 샘플 데이터 1개에 대한 gradient 계산으로 가중치와 절편을 갱신을 하고. 이러한 경사 하강법을 확률적 경사하강법 이라고 한다.
사실 엄밀한 확률적 경사 하강법은 아니다. 왜냐하면 엄밀한 확률적 경사하강법은 gradient 계산을 위해 샘플 데이터 1개를 뽑을때 위의 방법처럼 순서대로 1개씩 뽑는게 아니라 무작위로(랜덤으로) 뽑아야 한다. -> 확률적
즉 np.random.permutaiton()을 사용하는 이유가 무작위로 샘플을 1개씩 뽑기 위함이다.
-> 엄밀한 확률적 경사하강법 적용 위해
이 부분은 뒤에서 구체적으로 설명할 것이다.
1개 샘플을 중복되지 않게 무작위로 선택 후 gradient를 계산한다.
배치 경사 하강법 (Batch Gradient Descent) :
전체 훈련 세트를 사용하여 한 번에 gradient를 계산하는 방식
전체 샘플을 모두 선택하여 gradient 계산을 한다 (epoch 마다)
미니 배치 경사 하강법(Mini-Batch Gradient Descent) :
배치의 크기를 작게 하여 (훈련 세트를 여러번 나눔) 처리하는 방식
전체 샘플 중 몇개의 샘플을 중복되지 않도록 무작위로 선택하여 gradient를 계산함.
여러가지 경사 하강법들의 장단점에 대해 알아보자
확률적 경사 하강법 :
장점 : 샘플 데이터 1개마다 gradient 계산하므로 계산 비용이 적게 든다.
단점 : 가중치, 절편이 최적값에 수렴하는 과정이 불안함.
images_reference : https://valuefactory.tistory.com/460
배치 경사 하강법 :
장점 : 전체 훈련 데이터 세트를 사용하여 한 번에 gradient 계산하므로 가중치, 절편이 최적값에 수렴하는 과정이 안정적임
단점 : 계산비용이 많이듬
images_reference : https://valuefactory.tistory.com/460
미니 배치 경사 하강법 :
확률적 경사 하강법과 배치 경사 하강법을 절충해서 만든 경사 하강법임
images_reference : https://valuefactory.tistory.com/460
자 이제 SingleLayer 클래스를 구현할 때 왜 np.random.permutation()을 사용하여 indexes를 굳이 왜 섞는지에 대한 의문으로 다시 돌아오자
-> 엄격한 확률적 경사 하강법 적용 (샘플을 무작위로(랜덤하게) 뽑아서 gradient 계산)
매 epoch마다 후련 세트의 샘플 순서를 섞어 사용하자!!
위에서 살펴본 모든 경사하강법은 매 epoch마다 훈련 세트의 샘플 순서를 섞어서 가중치, 절편의 최적값을 계산해야 한다.
왜 ??
훈련 세트의 샘플 순서를 섞으면 최적의 값을 찾는 과정이 다양해지기 떄문에 손실 함수의 값이 줄어들고 그로 인하여 최적의 값을 제대로 찾을 수 있다.
위 그림을 보면 1번쨰 epoch에서 사용된 훈련 세트의 샘플 순서는 1, 2, 4
2번째 사용된 훈련 세트의 샘플 순서는 3, 1, 2이다.
이런식으로 훈련 세트의 샘플 순서를 섞는 방법은 넘파이 배열의 인덱스를 섞고 인덱스 순서대로 샘플을 뽑는 것이다.
이러한 방법이 훈련 세트 자체를 섞는 것보다 효울적이고 빠르다. (훈련 세트를 나두면 되니까)
이 방법은 np.random.permutation()함수를 사용하여 index를 섞는다.
즉 이전 까지는 그냥 샘플 순서대로 1개씩 뽑아서 gradient 계산하여 가중치와 절편을 갱신하였다.
하지만 이 방법이 가중치와 절편의 최적값을 제대로 찾을 수 었었다.
따라서 랜덤하게 샘플을 1개씩 뽑아서 gradient를 계산하면 최적의 가중치와 절편을 찾는 탐색과정이 다양해 지므로 더 좋은 결과를 내놓기때문에 이 방법을 사용할 것이다. (엄밀한 확률적 경사 하강법)
단일층 신경망 클래스 SingleLayer 클래스가 완성 되었으니 이전에 사용했던 유방암 데이터 세트에 적용해보도록 하자
# 단일층 신경망 훈련하기
# 단일층 신경망을 훈련하고 정확도를 출력 해볼 것이다.
# 이전 LogisticRegression 클래스에서 하엿듯이 SingleLayer 객체를 만들고 훈련세트 x_train, y_train로 신경망 훈련하자
# 이후 score() 메서드로 정확도 출력해보자
from sklearn.datasets import load_breast_cancer
cancer = load_breast_cancer()
x = cancer.data
y = cancer.target
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(x, y, stratify = y, test_size = 0.2, random_state = 42)
layer = SingleLayer() # 단일층 신경망 객체(모델) 생성
layer.fit(x_train, y_train) # 모델 훈련
layer.score(x_test, y_test) # 정확도 측정
0.9035087719298246
이전 LogisticRegression 클래스보다 정확도가 훨씬 높아졌다.
그 이유는 경사하강법에서 샘플 데이터를 순서대로 뽑아서 gradient 계산이 아닌 무작위 순서로 뽑아서 계산 -> 손실함수의 값 줄임
즉 엄격한 확률 경사 하강법 적용을 하니 정확도가 높아졌음을 알 수 있다.
# 진짜 손실 함수의 누적값이 작아졌는지 한번 확인해 보자
import matplotlib.pyplot as plt
plt.plot(layer.losses) # 에포크당 손실 함수 누적값을 담아놓은 리스트인 lyaer.losses를 그래프로 그려보자
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()
#그래프 그려보니까 로지스틱 손실 함수의 값이 에포크가 진행할수록 감소한다. (물론 어느 횟수 이상부터는 크게 감소하지 않고 왔다갔다함)

정리해보면 로지스틱 회귀 알고리즘을 확장하면 가장 기초적인 신경망 알고리즘으로 보면 된다.
(이전에 구현한 LogisticRegression 클래스에서 좀더 수정, 추가하여 SingleLayerRegression 클래스 만듬)
SingleLayerRegression 클래스를 기초적인 단일층 신경망 알고리즘으로 보면 된다.
단일층 신경망을 구현하기 위해선 로지스틱 회귀 알고리즘을 구현해야 한다.
그런데 단일층 신경망이 필요할때 이것을 직접 구현하기란 정말 귀찮은 일이다.
이러한 귀찮은 일을 사이킷런에서 해결해준다.
사이킷런에서 이런 알고리즘을 미리 구현해 놓았기 때문이다.
SGDclassifier :
SGDClassifier 클래스는 사이킷런에서 제공하는 경사 하강법이 구현된 클래스이다.
그럼 SGDClassifier 클래스를 이용해 로지스틱 회귀 문제를 간단히 해결해 볼것이다
(SGDClassifier 클래스를 이용하면 로지스틱 회귀 문제 뿐만 아니라 여러 가지 문제에 경사 하강법 적용할 수 있다.)
# 사이킷런으로 경사 하강법을 적용해보자
# 로지스틱 손실 함수를 먼저 지정해보자
from sklearn.linear_model import SGDClassifier
sgd = SGDClassifier(loss = 'log', max_iter = 100, tol = 1e-3, random_state = 42)
# SGDClassifier 클래스에 로지스틱 회귀 적용 위해 loss 매개변수에 손실 함수로 log를 지정
# max_iter는 최대 반복 횟수를 의미한다.
# 만약 tol에 지정한 값만큼 로지스틱 손실 함수의 값이 감소되지 않으면 반복 중단한다.
# (먄약 tol값 지정 안할시 max_iter 늘리라는 경고가 발생함)
# random_state = 42로 지정하여 난수 초깃값을 42로 설정하여 반복 실행시 동일한 난수값 나오게 한다.
# 사이킷런으로 훈련하고 평가해보자
# SGDClassifier 클래스에는 이전에 직접 구현한 모델을 훈련시키는 fit() 메서드와 정확도를 측정하는 score() 메서드가 준비되어 있다.
sgd.fit(x_train, y_train)
sgd.score(x_test, y_test)
0.8333333333333334
# 마지막으로 사이킷런으로 특정 결과를 예측해보자
# SGDClassifier 클래스에는 이전에 직접 구현한 예측을 하는 predict() 메서드 또한 준비가 되어있다.
# !! 단 주의할 점은 사이킷런은 입력 데이터로 2차원 배열만 받아들인다.!!!!!
# 만약 샘플 하나만 넣어야하는 경우에도 2차원 배열로 만들어서 넣어야 한다는 의미!!
# 배열 슬라이싱 이용해서 테스트 세트에서 10개의 샘플만 뽑아 예측해봄
sgd.predict(x_test[0:10])
array([0, 1, 0, 0, 0, 0, 1, 0, 0, 0])
자 사이킷런에서 제공하는 클래스로 로지스틱 회귀를 직접 구현하지 않고도 이렇게 간단히 사용할 수 있다.
앞으로 사이킷런을 이용해서 이러한 방법으로 빨리 사용하는 경우가 대부분일 것이다.
그렇다고 지금까지 직접 구현해본 것이 아무 의미 없었던 일이 아니다.
내가 사용하고자 하는 어떠한 것이 어떠한 원리로 동작하는지를 안다면 더욱 그것을 적합한 상황에 잘 사용할 것이라 생각하기 때문이다.
Reference
박해선, 딥러닝 입문, 이지스퍼블리싱, 2019, 105~115pg