minhui study

05. 훈련노하우 - 검증 세트와 전처리 과정, 과대/과소 적합 본문

딥러닝,인공지능/Do it! 딥러닝입문

05. 훈련노하우 - 검증 세트와 전처리 과정, 과대/과소 적합

minhui 2021. 5. 5. 15:20

05-1 검증 세트를 나누고 전처리 과정을 배우자

검증 데이터 준비

모델을 튜닝할 때 테스트 세트를 사용하게 되면 테스트 세트에 대해서만 좋은 성능을 낼 수 있고 실전에서 같은 성능을 기대하기는 어렵다. 따라서 모델 튜닝 시 테스트 세트를 사용하지 않아야 한다. 즉, 테스트 세트는 모델 튜닝을 모두 마치고 실전에 투입하기 전에 딱 한 번만 사용하는 것이 좋다.따라서 모델 튜닝을 위한 세트는 따로 준비해야 한다. 모델을 튜닝하는 용도의 세트는 검증 세트라고 하며 훈련 세트를 조금 떼어 만든다. 

 

1. 데이터 세트 준비하기

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
cancer = load_breast_cancer()
x = cancer.data
y = cancer.target
x_train_all, x_test, y_train_all, y_test = train_test_split(x, y, stratify=y, test_size=0.2, random_state=42)

 

2. 검증 세트 분할하기

전체 데이터 세트를 8:2로 나누어 훈련 세트와 테스트 세트를 만들고 다시 훈련 세트를 8:2로 나누어 훈련 세트와 검증 세트를 만든다. ( 64:20:16 )

455개의 훈련 세트가 8:2 비율로 나누어져 훈련 세트는 364개, 검증 세트는 91개가 되었다.

x_train, x_val, y_train, y_val = train_test_split(x_train_all, y_train_all, stratify=y_train_all, test_size=0.2, random_state=42)
print(len(x_train), len(x_val))
# 364 91

 

3. 검증 세트 사용해 모델 평가하기

sgd = SGDClassifier(loss='log', random_state=42)
sgd.fit(x_train, y_train)
sgd.score(x_val, y_val)
# 0.6923076923076923

전의 평가 점수보다 조금 낮아졌는데 왜 그럴까?

훈련 세트의 크기가 줄어들었기 때문이다. 비록 성능은 낮아졌지만 검증 세트가 잘 준비되 상태이다. 

 

 

 

 

데이터 전처리와 특성의 스케일

실제 수집된 데이터의 형태는 균일하지 않은 경우가 있는데 이런 데이터들을 적절히 가공하는 과정이 데이터 전처리 과정이다. 다만 잘 정리된 데이터도 전처리를 해야 하는 경우가 있는데 이는 특성의 스케일이 다른 경우이다.

* 특성의 스케일이란? 어떤 특성이 가지고 있는 값의 범위

 

 

스케일을 조정하지 않고 모델 훈련하기

1. 훈련 데이터 준비하고 스케일 비교하기

print(cancer.feature_names[[2,3]])
plt.boxplot(x_train[:, 2:4])
plt.xlabel('feature')
plt.ylabel('value')
plt.show()
# ['mean perimeter' 'mean area']

유방암 데이터의 mean perimeter와 mean area 두 특성의 스케일을 확인해보니 투 특성의 스케일은 차이가 크다.

이렇게 스케일이 다른 두 특성에 경사 하강법 알고리즘을 적용하면 가중치가 어떻게 변할까?

 

 

2. 가중치를 기록할 변수와 학습률 파라미터 추가하기

SingleLayer 클래스에 인스턴스 변수를 추가하여 에포크마다 가중치의 값을 저장하여 가중치의 변화를 관찰할 때 사용하고, 학습률이라는 개념도 도입한다. 먼저 init( ) 메서드에서 인스턴스 변수 w_history를 만들고 학습률 파라미터 learning_rate(학습률)를 추가한다.

  def __init__(self, learning_rate=0.1):
        self.w = None
        self.b = None
        self.losses = []
        self.w_history = []
        self.lr = learning_rate

learning_rate 경우 학습률이 너무 높아 손실함수의 전역 최솟값 포인트를 지나쳐 버리는 것을 방지해준다. 주어진 문제마다 학습률은 다르지만 보통 0.001, 0.01 등의 로그 스케일로 학습률을 지정하여 테스트한다. 

 

3. 가중치 기록하고 업데이트 양 조절하기

이제 fit()메서드에서 가중치가 바뀔 때마다 w_history 리스트에 가중치를 기록한다. 

넘파이 배열을 리스트에 추가하면 실제 값이 복사되는 것이 아니라 배열을 참조하기 때문에 가중치 변수 self.w값이 바뀔 때마다 그 값을 복사하여 w_history 리스트에 추가해야 한다.

또 w_grad에 학습률 self.lr을 곱하는 연산이 추가되어 가중치 업데이트 양(가중치 업데이트 속도)을 조절하다.

def fit(self, x, y, epochs=100, x_val=None, y_val=None):
        self.w = np.ones(x.shape[1])               # 가중치를 초기화합니다.
        self.b = 0                                 # 절편을 초기화합니다.
        self.w_history.append(self.w.copy())       # 가중치를 기록합니다.
        np.random.seed(42)                         # 랜덤 시드를 지정합니다.
        for i in range(epochs):                    # epochs만큼 반복합니다.
            loss = 0
            # 인덱스를 섞습니다
            indexes = np.random.permutation(np.arange(len(x)))
            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 -= self.lr * w_grad         # 가중치 업데이트
                self.b -= b_grad                   # 절편 업데이트
                # 가중치를 기록합니다.
                self.w_history.append(self.w.copy())
                # 안전한 로그 계산을 위해 클리핑한 후 손실을 누적합니다
                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))

 

4. 모델 훈련하고 평가하기

스케일을 조정하지 않은 훈련 세트를 사용하여 모델을 훈련하고 모델의 성능 점수를 확인해보니 약 91%의 정확도를 보인다.

layer1 = SingleLayer()
layer1.fit(x_train, y_train)
layer1.score(x_val, y_val)
# 0.9120879120879121

이제 인스턴스 변수 w_history에 100번의 에포크 동안 변경된 가중치가 모두 기록되어 있다. 

이때 3번째, 4번째 요소(w[2].w[3])는 각각 mean perimeter와 mean area 특성에 대한 가중치이다. 

밑에 점은 최종으로 결정된 가중치이다.

 

w2 = []
w3 = []
for w in layer1.w_history:
    w2.append(w[2])
    w3.append(w[3])
plt.plot(w2, w3)
plt.plot(w2[-1], w3[-1], 'ro')
plt.xlabel('w[2]')
plt.ylabel('w[3]')
plt.show()

 

위의 그래프를 보면 가중치의 최적값에 도달하는 동안 w3 값이 크게 요동치므로 모델이 불안정하게 수렴한다는 것을 알 수 있다. 이런 현상을 줄이기 위해서는 스케일을 조정하면 된다.

 

스케일은 조정해 모델 훈련하기

1. 넘파이로 표준화 구현하기

스케일 조정 방법 중 하나는 '표준화'이다. 표준화는 평균을 빼고 표준 편차로 나누면 된다.

표준화를 하면 평균이 0이고 분산이 1인 특성이 만들어진다.

https://m.blog.naver.com/vitality1994/221782495009

train_mean = np.mean(x_train, axis=0)
train_std = np.std(x_train, axis=0)
x_train_scaled = (x_train - train_mean) / train_std

넘파이의 mean(), std()함수를 사용하여 평균과 표준 편차를 계산하면 표준화를 쉽게 구현할 수 있다.

 

2. 모델 훈련하기

layer2 = SingleLayer()
layer2.fit(x_train_scaled, y_train)
w2 = []
w3 = []
for w in layer2.w_history:
    w2.append(w[2])
    w3.append(w[3])
plt.plot(w2, w3)
plt.plot(w2[-1], w3[-1], 'ro')
plt.xlabel('w[2]')
plt.ylabel('w[3]')
plt.show()

 

w2와 w3의 변화 비율이 비슷하기 때문에 대각선 방향으로 가중치가 이동되었다. 또한 두 특성의 스케일을 비슷하게 맞추었으므로 최적값에 빠르게 근접하고 있음을 알 수 있다.

 

3. 모델 성능 평가하기

layer2.score(x_val, y_val)
# 0.37362637362637363

기대와는 달리 성능이 좋지 않다. 그 이유는 검증 세트의 스케일을 바꾸지 않았기 때문이므로 검증 세트로 표준화 전처리를 적용해야 한다.

val_mean = np.mean(x_val, axis=0)
val_std = np.std(x_val, axis=0)
x_val_scaled = (x_val - val_mean) / val_std
layer2.score(x_val_scaled, y_val)
# 0.967032967032967

검증 세트에 대한 정확도가 약 96%가 나왔다.

 

 

스케일을 조정한 다음에 실수하기 쉬운 함정 알아보기

1. 원본 훈련 세트와 검증 세트로 산점도 그리기

다음은 윈본 훈련 세트와 검증 세트로 산점도를 그린 것이다. 파란점이 훈련 세트이고, 빨간 점이 검증 세트이다.

plt.plot(x_train[:50, 0], x_train[:50, 1], 'bo')
plt.plot(x_val[:50, 0], x_val[:50, 1], 'ro')
plt.xlabel('feature 1')
plt.ylabel('feature 2')
plt.legend(['train set', 'val. set'])
plt.show()

2. 전처리한 훈련 세트와 검증 세트로 산점도 그리기

plt.plot(x_train_scaled[:50, 0], x_train_scaled[:50, 1], 'bo')
plt.plot(x_val_scaled[:50, 0], x_val_scaled[:50, 1], 'ro')
plt.xlabel('feature 1')
plt.ylabel('feature 2')
plt.legend(['train set', 'val. set'])
plt.show()

1,2번 산점도를 자세리 비교해보면 훈련 세트와 검증 세트가 각각 다른 비율로 변환되었다. 

만약 데이터가 제대로 전처리했다면 훈련 세트와 검증 세트의 거리가 그대로 유지되어야 한다. 점과 점 사이의 거리가 달라진 이유는 훈련 세트와 검증 세트를 각각 다른 비율로 전처리했기 때문이다.

 

 

3. 올바른 검증 세트 전처리하기

훈련 세트의 평균, 표준 편차를 사용하여 검증 세트를 변환하면 훈련 세트와 같은 비율로 검증 세트를 변환할 수 있다.

x_val_scaled = (x_val - train_mean) / train_std

plt.plot(x_train_scaled[:50, 0], x_train_scaled[:50, 1], 'bo')
plt.plot(x_val_scaled[:50, 0], x_val_scaled[:50, 1], 'ro')
plt.xlabel('feature 1')
plt.ylabel('feature 2')
plt.legend(['train set', 'val. set'])
plt.show()

4. 모델 평가하기

layer2.score(x_val_scaled, y_val)
# 0.967032967032967

05-2 과대적합과 과소적합

훈련 세트의 크기와 과대적합, 과소적합 분석하기

(1) 과대적합의 전형적인 모습 (2) 과소적합된 모델 (3) 과대적합과 과소적합의 절충점
' 분산이 크다 '
* 해결법
  - 더 많은 훈련 샘플을 모아 검증 세트  의 성능을 향상시킨다.
 - 가중치를 제한하여 모델의 복잡도를 낮춘다.
' 편향이 크다 '
* 해결법
 - 복잡도가 더 높은 모델을 사용한다.
 - 가중치의 규제를 완화한다.

 

에포크와 손실 함수의 그래프로 과대적합과 과소적합 분석하기

왼쪽 그래프는 검증 세트의 손실과 훈련 세트의 손실을 나타낸 것이다.

훈련 세트의 손실은 에포크가 진행될수록 감소하지만 검증 세트의 손실은 에포크의 횟수가 최적점을 지나면 오히려 상승한다.

왜? 최적점 이후에도 계속해서 훈련 세트로 모델을 학습시키면 모델이 훈련 세트의 샘플에 더 밀착하여 학습하기 때문이다.

즉, 모델이 과대적합되기 시작하는 것이다.

반대로 최적점 이전에 여역에서 학습을 중지하면 과소적합된 모델이 만들어진다.

오른쪽 그래프는 세로 축에 손실 대신 정확도를 사용했다. 손실이 적어지고 있으므로 정확도는 증가하고 손실이 점점 천천히 줄어들 듯 정확도는 점점 천천히 증가하는 것이다.

 

 

모델 복잡도와 손실 함수의 그래프로 과대적합과 과소적합 분석하기

가로 축에 에포크 대신 모델 복잡도를 넣은 그래프이다. 

모델 복잡도란 모델이 가진 학습 간으한 가중치 개수를 만하는데 층의 개수나 유닛의 개수가 많아지면 복잡도가 높은 모델이 만들어진다. 하지만  모델이 복잡해지면 무조건 좋은 것은 아니다. 훈련 세트에만 잘 맞는 형태로 만들어지면 과대적합이 되는 것이기 때문이다.

 

 

적절한 편향-분산 트레이오프 선택

과소적합된 모델과 과대적합된 모델 사이의 관계를 편향-분산 트레이드오프(vias-variance tradeoff)라고 한다. 

트레이드오프라는 말이 들어간 이유는 '하나를 얻기 우해서는 다른 하나를 희생해야 하기 때문'이다.

즉, 편향을 줄이면(훈련 세트의 성능을 높이면) 분산이 커지고(검증 세트와 성능 차이가 커지고) 반대로 분산을 줄이면(검증 세트와 성능 차이를 줄이면) 편향이 커지는(훈련 세트의 성능이 낮아진다) 것을 말한다.

 

class SingleLayer:
    
    def __init__(self, learning_rate=0.1, l1=0, l2=0):
        self.w = None
        self.b = None
        self.losses = []
        self.val_losses = []
        self.w_history = []
        self.lr = learning_rate

    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, x_val=None, y_val=None):
        self.w = np.ones(x.shape[1])               # 가중치를 초기화합니다.
        self.b = 0                                 # 절편을 초기화합니다.
        self.w_history.append(self.w.copy())       # 가중치를 기록합니다.
        np.random.seed(42)                         # 랜덤 시드를 지정합니다.
        for i in range(epochs):                    # epochs만큼 반복합니다.
            loss = 0
            # 인덱스를 섞습니다
            indexes = np.random.permutation(np.arange(len(x)))
            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 -= self.lr * w_grad         # 가중치 업데이트
                self.b -= b_grad                   # 절편 업데이트
                # 가중치를 기록합니다.
                self.w_history.append(self.w.copy())
                # 안전한 로그 계산을 위해 클리핑한 후 손실을 누적합니다
                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))
            # 검증 세트에 대한 손실을 계산합니다
            self.update_val_loss(x_val, y_val)
    
    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)
    
    def reg_loss(self):
        return self.l1 * np.sum(np.abs(self.w)) + self.l2 / 2 * np.sum(self.w**2)
    
    def update_val_loss(self, x_val, y_val):
        if x_val is None:
            return
        val_loss = 0
        for i in range(len(x_val)):
            z = self.forpass(x_val[i])     # 정방향 계산
            a = self.activation(z)         # 활성화 함수 적용
            a = np.clip(a, 1e-10, 1-1e-10)
            val_loss += -(y_val[i]*np.log(a)+(1-y_val[i])*np.log(1-a))
        self.val_losses.append(val_loss/len(y_val) + self.reg_loss())
  • 검증 손실을 기록하기 위한 __init__() 메서드에 self.val_losses 인스턴스 변수 추가
  • fit() 메서드에 검증 세트를 전달받을 수 있도록 x_val, y_val 매개변수를 추가한다.
  • update_val_loss( )메서드에서 검증 손실 계산하기
  • 모델 훈련하기
layer3 = SingleLayer()
layer3.fit(x_train_scaled, y_train, x_val=x_val_scaled, y_val=y_val)
plt.ylim(0, 0.3)
plt.plot(layer3.losses)
plt.plot(layer3.val_losses)
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train_loss', 'val_loss'])
plt.show()

 

위의 그래프를 보면 검증 손실이 대햑 20번째 에포크 이후에 훈련 세트보다 높아지는 것을 알 수 있다. 조금 더 자세히 말하면 에포크가 진행됨에 따라 가중치는 훈련 세트에 잘 맞게되지만 검증 세트에는 잘 맞지 않게 되는 것이다. 

즉 이 모델은 20번의 에포크 이후에는 훈련할 필요가 없는 것이다.

 

layer4 = SingleLayer()
layer4.fit(x_train_scaled, y_train, epochs=20)
layer4.score(x_val_scaled, y_val)
# 0.978021978021978

위와 같이 훈련을 일찍 멈추는 기법을 조기 종료라고 부른다. 20번의 에포크까지 모델을 훈련한 다음 검증 세트의 성능을 보면 과대적합되기 전에 훈련은 멈추었으므로 검증 세트의 성능이 0.978로 조금 더 향상된 것을 확인할 수 있다.


< 참고 자료 >

Do it! 딥러닝 입문 - 박해선 지음

 

 

 

 

 

 

 

 

 

 

Comments