머신러닝/딥러닝 공부

데이터 세트(1) - 훈련 세트(training set), 테스트 세트(test set), 검증 세트(validation set) 본문

AI 공부/Machine Learning

데이터 세트(1) - 훈련 세트(training set), 테스트 세트(test set), 검증 세트(validation set)

호사린가마데라닌 2021. 11. 1. 02:19

 

 

1 ) 훈련 세트(training set)와 테스트 세트(test set)

 

여태까지의 포스팅에서 저는 모델을 구축한 뒤, 주어진 전체 데이터 세트로 학습을 시켰습니다. 이제 이 모델을 실전에 투입한다고 했을 때, 얼마만큼의 정확도를 갖는지를 먼저 판단해야 합니다. 만약 모델의 정확도를 판단하기 위해 만약 학습을 시켰던 데이터 세트로 다시 테스트를 하면 당연히 높은 정확도가 나올 것입니다. 이렇게 나온 높은 정확도는 당연히 낙관적으로 평가된 정확도이고, 올바르다고 할 수 없습니다. 

 

 

따라서 모델의 실전에서의 성능을 평가하고자 한다면 학습을 시켰던 데이터 세트와 별개의(공통된 데이터가 없는) 데이터셋을 이용하여 평가해야 합니다. 물론 주어진 데이터셋 전체로 학습을 따로 하고, 테스트에 사용할 데이터셋을  따로 찾아보는 것도 하나의 방법이겠지만, 보통 머신러닝에서는 전체 데이터셋을 학습시킬 데이터 세트와 테스트를 할 데이터 세트로 나누어 모델을 학습시키고 성능을 평가합니다.

 

 

 

이때 모델이 학습할 데이터를 훈련 세트(training set), 모델의 성능을 테스트하기 위해 사용할 데이터를 테스트 세트(test set)라고 합니다. 또한 모델이 실전에서 보이는 성능을 일반화 성능(generalization performance)라고 합니다.

 

 

전체 데이터 세트를 훈련 세트와 테스트 세트로 나눌 때 몇 가지 규칙이 있습니다. 

  1. 훈련 세트의 데이터가 테스트 세트의 데이터 보다 많아야 합니다
  2. 훈련 세트와 테스트 세트가 동일한 비율의 데이터(클래스) 분포를 가지고 있어야 합니다.
  3. 훈련 세트와 테스트 세트에 중복되는 데이터가 (최대한) 없어야 합니다.

 

만약 훈련 세트나 테스트 세트가 동일한 비율의 클래스 분포를 가지고 있지 않다면 모델이 데이터의 규칙을 제대로 찾아내지 못하고, 테스트를 함에 있어서도 모델의 정확한 성능을 추정하지 못할 수 있습니다.

 

 

 

 

이제 앞 포스팅에서 사용했던 로지스틱 회귀 모델을 학습시키고 정확도를 평가해보도록 하겠습니다. 모델에 학습시킬 데이터 세트는 위스콘신 유방암 데이터 세트(wisconsin breast cancer dataset)입니다.

 

먼저 데이터 세트를 훈련 세트와 테스트 세트로 나누어보겠습니다. 

 

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

cancer=load_breast_cancer()

x_data=cancer.data
y_data=cancer.target

x_train,x_test,y_train,y_test=train_test_split(x_data,y_data,stratify=y_data,test_size=0.2)

 

처음에 cancer라는 인자에 load_breast_cancer()를 불러온 뒤, x_data와 y_data에 각각 data와 target을 넣어주었습니다. 

 

 

사이킷런에서는 train_test_split()이라는 편리한 툴을 제공하는데, 이 함수는 인자로 받은 x_data와 y_data를 지정한 비율(test_size)로 골고루 분배해줍니다. 위의 코드에서는 test_size=0.2로 지정해주었으니 훈련 세트와 테스트 세트를 80 : 20으로 나누어주겠네요. Default는 0.25(즉, 75 : 25)입니다.

 

stratify는 동일한 비율의 클래스 분포를 위해 사용하는 파라미터입니다.  

 

더 자세한 내용은 밑의 링크를 통해 확인하실 수 있습니다.

 

sklearn.model_selection.train_test_split

Examples using sklearn.model_selection.train_test_split: Release Highlights for scikit-learn 0.23 Release Highlights for scikit-learn 0.23, Release Highlights for scikit-learn 0.24 Release Highligh...

scikit-learn.org

 

 

로지스틱 회귀의 코드는 다음과 같습니다.

class LogisticRegression:
  def __init__(self,learning_rate=0.01):
    self.w=None #가중치
    self.b=None #바이어스
    self.lr=learning_rate #학습률
    self.losses=[] #매 에포크마다 손실을 저장할 배열
    self.weight_history=[] #매 에포크마다 갱신된 가중치를 저장할 배열
    self.bias_history=[] #매 에포크마다 갱신된 바이어스를 저장할 배열

  def forward(self,x):
    z=np.sum(self.w*x)+self.b
    z=np.clip(z,-50,None) # <z가 -50이하인 경우 -50으로 잘라줌>
    # exp함수에 너무 작은 수가 들어가면 NaN(Not a Number)이 될 가능성을 배제하기 위해 사용
    return z
  
  def loss(self,y,a):
    return -(y*np.log(a)+(1-y)*np.log(1-a)) #binary cross-entropy 

  def activation(self,z): #활성화 함수 
    a=1/(1+np.exp(-z)) #시그모이드 함수
    a=np.clip(a,1e-10,1-(1e-10)) 
    #로그 계산에서 a의 값이 0이 되거나 1이 되는 경우를 배제하기 위해 사용
    return a

  def gradient(self,x,y): #gradient 메소드
    z=self.forward(x)
    a=self.activation(z)
    
    w_grad=x*(-(y-a))
    b_grad=-(y-a)
    return w_grad,b_grad 
  
  def fit(self,x_data,y_data,epochs=30):
    self.w=np.ones(x_data.shape[1]) #모델의 가중치들을 모두 1로 초기화
    self.b=0 #모델의 바이어스를 0으로 초기화
    for epoch in range(epochs):
      l=0 #매 에포크마다 손실을 누적할 변수
      w_grad=np.zeros(x_data.shape[1]) #손실함수에 대한 가중치의 기울기
      b_grad=0 #손실함수에 대한 바이어스의 기울기
      for x,y in zip(x_data,y_data):
        z=self.forward(x) 
        a=self.activation(z)
        l+=self.loss(y,a) #손실값 누적

        w_i,b_i=self.gradient(x,y) #가중치와 바이어스의 기울기 계산

        w_grad+=w_i #가중치의 기울기 누적
        b_grad+=b_i #바이어스의 기울기 누적 

      self.w-=self.lr*(w_grad/len(y_data)) #가중치 업데이트
      self.b-=self.lr*(b_grad/len(y_data)) #바이어스 업데이트

      print(f'epoch{(epoch+1)} ===> loss : {l/len(y_data):.4f} | weight : {self.w[0]:.4f} | bias : {self.b:.4f}')
      self.losses.append(l/len(y_data)) #이번 에포크에서의 손실값 저장
      self.weight_history.append(self.w) #이번 에포크에서의 가중치값 저장
      self.bias_history.append(self.b) #이번 에포크에서의 바이어스값 저장

 

 

모델의 클래스 내에 모델의 정확도를 판단할 함수를 추가해주겠습니다. 

  def predict(self,x_data):
    # 입력으로 받은 x_data 내 모든 x에 대해 forward를 적용
    z=[self.forward(x) for x in x_data]
    #step-function 
    return np.array(z)>0 

  def score(self,x_data,y_data):
    return np.mean(self.predict(x_data)==y_data)

 

 

predict(x_data) 함수는 x_data 내의 모든 데이터들에 대해 모델의 forward() 함수를 적용하여 각각이 0보다 큰지 작은 지를 계산해주는 함수입니다. 여기서 z를 시그모이드 함수에 통과시키지 않은 이유는 어차피 z가 0보다 크다면 sigmoid의 값은 0.5보다 커질 것이고, 이는 임계치를 넘는 값이기 때문에 z가 0보다 큰지 작은지만 판단해 주면 되기 때문입니다.

 

 

score(x_data,y_data) 함수는 모델의 predict함수로 예측한 값과 실제 y_data의 값이 일치하는지 확인하고 평균을 내주는 함수입니다. np.mean()은 인자로 받은 값들의 평균을 내줍니다.

 

 

이제 모델을 생성한 후에 0.0005의 학습률로 100 에포크 동안 학습시키고, 테스트 세트로 모델의 정확도를 평가해보겠습니다.

 

model=LogisticRegression(learning_rate=0.0005)
model.fit(x_train,y_train,epochs=100)
model.score(x_test,y_test)

이 모델은 약 87%의 정확도를 가지고 있네요.

 

 

 

2 ) 검증 세트(validation set)

사실 로지스틱 회귀는 사이킷런에서 제공하는 SGDClassifier이라는 패키지를 이용하여 구현할 수 있습니다.

 

 

SGDClassifier의 loss인자를 'log'로 설정해주고  random_state=20으로 설정해주었습니다. SGDClassifier는 이름에서 유추할 수 있듯이 SGD를 사용하여 손실 함수의 최솟값을 찾아갑니다. random_state=20으로 설정해주면 처음에 가중치를 초기화해주는 난수의 시드가 고정되어 매번 실행할 때마다 같은 결과를 얻을 수 있습니다. 

 

from sklearn.linear_model import SGDClassifier

# 로지스틱 회귀 모델 선언
model=SGDClassifier(loss='log',random_state=20)

# 모델 학습
model.fit(x_train,y_train)

# 모델 평가
model.score(x_test,y_test)

 

 

약 89%의 정확도가 나오네요. 이번에는 model의 손실 함수(loss)를 바꿔보겠습니다. SGDClassifier의 경우 loss의 default값이 'hinge'이므로 다음과 같이 모델을 선언하고 같은 데이터세트에 대해 모델을 평가해보도록 하겠습니다.

 

# 로지스틱 회귀 모델 선언
model=SGDClassifier(random_state=20)

# 모델 학습
model.fit(x_train,y_train)

# 모델 평가
model.score(x_test,y_test)

약 93%의 정확도가 나오네요.

 

 

제가 바꾼 부분은 model의 loss함수 하나였는데 정확도가 3%나 올랐습니다. 모델의 가중치와 바이어스는 모델을 구성하는 요소로 '모델 파라미터(model parameter)'라 부르고, 손실 함수, 훈련 데이터의 배치사이즈, 옵티마이저 등 사용자가 모델 내부가 아닌 모델 외부에서 설정하는 요소들을 통틀어 모델의 '하이퍼 파라미터(hyper parameter)'라고 합니다. 그리고 모델의 하이퍼 파라미터들을 조정해주는 것을 '모델 튜닝(model tuning)'이라고 합니다. 

 

 

이렇게 모델을 튜닝하면서 모델의 정확도를 높였습니다. 이 모델은 과연 실전에서도 좋은 성능을 보여줄 수 있을까요? 안타깝게도 대부분 아닐 겁니다. 위의 모델 튜닝은 테스트 세트에 대한 모델의 정확도를 높이기 위한 튜닝이었습니다. 즉, 이 모델은 테스트 세트에 대해서는 좋은 성능을 보이겠지만, 실전에서도 동일한 퍼포먼스를 보일 거라 기대하기는 힘듭니다.

 

 

테스트 세트는 모델 튜닝을 끝낸 후 실전에 투입하기 전에 단 한 번만 사용해야 합니다. 만약 테스트 세트의 데이터가 모델을 튜닝하거나 훈련하는데에 사용되면 낙관적으로 성능을 측정할 가능성이 있기 때문입니다. 이처럼 '테스트 데이터의 정보가 새어나가는 것'을 최대한 지양해야 합니다. 

 

 

모델의 정확도를 높이기 위해 모델의 평가를 토대로 하이퍼 파라미터를 조정해주는 것은 필수불가결입니다. 하지만 테스트 세트를 이용하여 모델의 성능을 평가하는 것은 모델의 일반화 성능(generalization performance)을 왜곡시킵니다. 그럼 어떡해야 할까요?

 

 

간단합니다. 전체 데이터 세트를 훈련 데이터와 테스트 데이터로 나눴었는데, 테스트 데이터를 건드릴 수 없으니 훈련 데이터를 다시 훈련시킬 데이터 세트와 성능을 평가할 데이터 세트로 나누어주면 됩니다. 이때 성능을 평가할 데이터를 검증 세트(validation set) 또는 개발 세트(dev set)라고  합니다. 저는 검증 세트라는 용어가 더 친숙해서 검증 세트라는 용어를 주로 사용합니다.

 

 

 

 

일반적으로 데이터 세트가 10만 개 정도인 경우, 훈련 세트, 검증 세트, 훈련 세트는 각각 8:1:1 정도의 비율로 분할합니다. 딥러닝의 경우 100만 개 이상의 데이터를 사용할때가 많은데(ImageNet이라는 데이터셋은 1000만 개 이상의 이미지 데이터를 가지고 있습니다), 이런 경우 보통 검증 세트와 테스트 세트를 각각 1만 개 정도로 할당해주고 나머지는 훈련 데이터에 할당해줍니다. 물론 이렇게 나누어진 세트들은 모두 동일한 비율의 클래스의 분포를 갖도록 해주어야 합니다. 

 

 

만약 데이터 세트가 충분히 많은 데이터를 가지고 있지 않은 경우, 교차 검증(cross validation)이라는 방식을 사용하여 모델의 성능을 평가하는데, 이 방식은 나중에 소개해드리도록 하겠습니다. 

 

 

마지막으로 검증 세트를 나누고 모델을 평가하는 과정을 코드로 구현해보겠습니다. 데이터 세트와 모델은 각각 위의 코드와 동일한 위스콘신 유방암 데이터 세트와 로지스틱 회귀 모델을 사용하였습니다,

 

 

전체 데이터 세트를 다음과 같이 훈련, 테스트, 검증 세트로 나누었습니다.

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

cancer=load_breast_cancer()

x_data=cancer.data
y_data=cancer.target

x_train,x_test,y_train,y_test=train_test_split(x_data,y_data,stratify=y_data,test_size=0.2)
x_train,x_val,y_train,y_val=train_test_split(x_train,y_train,stratify=y_train,test_size=0.2)

print('   trainig set size : ',len(x_train))
print('      test set size : ',len(x_val))
print('validation set size : ',len(x_test))

 

이제 모델을 구현해보겠습니다. 먼저 기존의 로지스틱 회귀 모델에 검증 세트에 대한 손실값을 저장해주기 위해 val_losses라는 인자를 추가해주었습니다. 

class LogisticRegression:
  def __init__(self,learning_rate=0.01):
    self.w=None #가중치
    self.b=None #바이어스
    self.lr=learning_rate #학습률
    self.losses=[] #매 에포크마다 손실을 저장할 배열
    self.val_losses=[] #매 에포크마다 검증 손실을 저장할 배열
    self.weight_history=[] #매 에포크마다 갱신된 가중치를 저장할 배열
    self.bias_history=[] #매 에포크마다 갱신된 바이어스를 저장할 배열

 

다른 부분은 기존의 코드와 동일하지만 검증 손실을 계산하기 위해 val_loss()라는 함수를 추가해주었습니다.

  def forward(self,x):
    z=np.sum(self.w*x)+self.b
    z=np.clip(z,-50,None) # <z가 -50이하인 경우 -50으로 잘라줌>
    # exp함수에 너무 작은 수가 들어가면 NaN(Not a Number)이 될 가능성을 배제하기 위해 사용
    return z
  
  def loss(self,y,a):
    return -(y*np.log(a)+(1-y)*np.log(1-a)) #binary cross-entropy 

  def activation(self,z): #활성화 함수 
    a=1/(1+np.exp(-z)) #시그모이드 함수
    a=np.clip(a,1e-10,1-(1e-10)) 
    #로그 계산에서 a의 값이 0이 되거나 1이 되는 경우를 배제하기 위해 사용
    return a

  def gradient(self,x,y): #gradient 메소드
    z=self.forward(x)
    a=self.activation(z)

    w_grad=x*(-(y-a))
    b_grad=-(y-a)

    return w_grad,b_grad 

  def predict(self,x_data):
    # 입력으로 받은 x_data 내 모든 x에 대해 forward를 적용
    z=[self.forward(x) for x in x_data]
    #step-function 
    return np.array(z)>0 

  def score(self,x_data,y_data):
    return np.mean(self.predict(x_data)==y_data)

  def val_loss(self,x_val,y_val):
    val_loss=0 #검증세트에 대한 손실값을 누적할 변수
    for x,y in zip(x_val,y_val):
      z=self.forward(x)
      a=self.activation(z)
      val_loss+=self.loss(y,a) #매 에포크마다 검증손실 누적
      
    return val_loss/len(y_val)

 

또한 모델을 학습시키는 함수인 fit()함수에서도 매 에포크마다 검증 손실을 저장하기 위해 코드를 약간 수정하였습니다.

   def fit(self,x_data,y_data,epochs=30):
    self.w=np.ones(x_data.shape[1]) #모델의 가중치들을 모두 1로 초기화
    self.b=0 #모델의 바이어스를 0으로 초기화
    for epoch in range(epochs):
      l=0 #매 에포크마다 손실을 누적할 변수
      w_grad=np.zeros(x_data.shape[1]) #손실함수에 대한 가중치의 기울기
      b_grad=0 #손실함수에 대한 바이어스의 기울기

      for x,y in zip(x_data,y_data):
        z=self.forward(x) 
        a=self.activation(z)
       
        l+=self.loss(y,a) #손실값 누적

        w_i,b_i=self.gradient(x,y) #가중치와 바이어스의 기울기 계산

        w_grad+=w_i #가중치의 기울기 누적
        b_grad+=b_i #바이어스의 기울기 누적 

      self.w-=self.lr*(w_grad/len(y_data)) #가중치 업데이트
      self.b-=self.lr*(b_grad/len(y_data)) #바이어스 업데이트
      
      val_loss=self.val_loss(x_val,y_val) #검증세트에 대한 손실값 계산

      self.val_losses.append(val_loss) #검증세트에 대한 손실값 저장
      self.losses.append(l/len(y_data)) #이번 에포크에서의 손실값 저장
      self.weight_history.append(self.w) #이번 에포크에서의 가중치값 저장
      self.bias_history.append(self.b) #이번 에포크에서의 바이어스값 저장

      print(f'epoch{(epoch+1)} ===> loss : {l/len(y_data):.4f} | val_loss : {val_loss : .4f}')

 

 

이제 모델 객체를 생성한뒤 학습을 시켜보겠습니다. 모델의 학습률은 0.0004로, epoch는 100으로 주었습니다.

model=LogisticRegression(learning_rate=0.0004)
model.fit(x_train,y_train,epochs=100)

 

훈련이 끝난 뒤에 검증 세트와 테스트 세트에 대한 모델의 정확도는 다음과 같습니다.

print('validation score : ',model.score(x_val,y_val))
print('      test score : ',model.score(x_test,y_test))

 

검증 세트와 훈련 세트에 대한 손실을 그래프로 그려보았습니다.

import matplotlib.pyplot as plt

plt.ylim(0,8) 
plt.plot(model.losses)
plt.plot(model.val_losses)
plt.xlabel('epoch')
plt.ylabel('loss')
plt.legend(['loss','val_loss'])
plt.show()

 

훈련 세트와 검증 세트 모두에서 손실값이 작아지다가 약 20 에포크 이후에 어떤 값에  수렴하는 것을 볼 수 있습니다. 다음 포스팅에서는 훈련 세트에 대한 손실과 검증 세트에 대한 손실로 무엇을 판단할 수 있는지, 과대 적합(over-fitting)과 과소 적합 (under-fitting)이 무엇인지에 대해 정리해 보겠습니다.

Comments