본문 바로가기
Programming/Machine Learning

머신러닝 - 데이터 전처리

by IT learning 2021. 4. 2.
728x90

오늘은 머신러닝을 공부하는데 어쩌면 기본중에 기본이라고 할 수 있는 데이터 전처리에 대해 알아보겠다.

[사실 나도 이해하는데 조금 힘들었다. 아니, 잘 모르겠다. 그래도 적어보겠다]

본 내용은 '혼자 공부하는 머신러닝 + 딥러닝' 교재를 이용하여 배운 것을 토대로 작성합니다.

 

위 머신러닝 공부를 할때 사용하는 IDE는 '구글 코랩' 입니다. 

코랩 사용법을 익히고 오시길 바랍니다.

 

또한 파이썬의 기초적인 지식을 알아야 합니다.

위 코드들은 각자의 파일이 아닌 하나의 파일에 작성된 코드들입니다.

 

 

들어가기 전에

저번 시간에 훈련 세트와 테스트 세트로 나눈 머신러닝 프로그램을 가지고 완벽하게 구분해 내는것을 사장님께 보여주고, 실전에 투입해도 괜찮다라는 승낙을 받게되었다. 하지만, 며칠 뒤에 갑자기 사장님이 급하게 필자를 부르는 것이다.

음..조금 문제가 생긴듯 하네, 길이가 25cm이고 무게가 150g이면 도미인데 자네가 준 모델은 빙어라고 예측하네.

무엇이 문제였을까 확인해보자.

 

넘파이로 데이터 준비하기

일단 처음처럼 도미와 빙어 데이터를 준비하겠다.

fish_length = [25.4, 26.3, 26.5, 29.0, 29.0, 29.7, 29.7, 30.0, 30.0, 30.7, 31.0, 31.0, 
                31.5, 32.0, 32.0, 32.0, 33.0, 33.0, 33.5, 33.5, 34.0, 34.0, 34.5, 35.0, 
                35.0, 35.0, 35.0, 36.0, 36.0, 37.0, 38.5, 38.5, 39.5, 41.0, 41.0, 9.8, 
                10.5, 10.6, 11.0, 11.2, 11.3, 11.8, 11.8, 12.0, 12.2, 12.4, 13.0, 14.3, 15.0]
fish_weight = [242.0, 290.0, 340.0, 363.0, 430.0, 450.0, 500.0, 390.0, 450.0, 500.0, 475.0, 500.0, 
                500.0, 340.0, 600.0, 600.0, 700.0, 700.0, 610.0, 650.0, 575.0, 685.0, 620.0, 680.0, 
                700.0, 725.0, 720.0, 714.0, 850.0, 1000.0, 920.0, 955.0, 925.0, 975.0, 950.0, 6.7, 
                7.5, 7.0, 9.7, 9.8, 8.7, 10.0, 9.9, 9.8, 12.2, 13.4, 12.2, 19.7, 19.9]

전에는 이 리스트는 순회하면서 원소를 하나씩 꺼내 생선 하나의 길이와 무게를 이중 리스트로 직접 구성했었다.

하지만 저번 시간에 넘파이를 배웠으니 간편하게 만들수가 있다.

import numpy as np

np.column_stack(([1,2,3], [4,5,6])

넘파이의 column_stack() 함수는 전달받은 리스트를 일렬로 세운 다음 차례대로 나란히 연결한다.

예를 들어보자면, 다음과 같은 간단한2개의 리스트를 나란히 붙여보자. 연결할 리스트는 파이썬 튜플로 전달한다.

array([[1, 4],
       [2, 5],
       [3, 6]])

[1,2,3] 과 [4,5,6] 두 리스트를 일렬로 세운 다음 나란히 옆으로 붙였다. 만들어진 배열의 (3,2) 크기의 배열이다.

즉 3개의 행이 있고 2개의 열이 있다라는 말이다.

 

그럼 이제 이 방법으로 fish_length 와 fish_weight를 합치겠다.

fish_data = np.column_stack((fish_length, fish_weight))

위와 방법은 동일하다.

print(fish_data[:5])
[[ 25.4 242. ]
 [ 26.3 290. ]
 [ 26.5 340. ]
 [ 29.  363. ]
 [ 29.  430. ]]

기대했던 대로 잘 연결되었다.

넘파이 배열을 출력하면 리스트처럼 한줄로 길게 출력되지 않고 행과 열을 맞추어 가지런히 정리된 모습으로 보여준다.

 

동일한 방법으로 타깃 데이터도 만들어보자.

저번 시간에는 리스트[1],[0]을 여러 번 곱해서 타깃 데이터를 만들었다. 하지만 이것마저 넘파이에 더 좋은 방법이 있다.

print(np.ones(5)) # 원하는 수만큼 1과 0을 채워주는 함수

np.ones() 함수와 np.zero() 함수이다. 이 두 함수는 각각 원하는 개수의 1과 0을 채운 배열을 만들어준다.

[1. 1. 1. 1. 1.]

위에 코드에서 np.ones() 함수로 1을 5개 생성했다.

 

이 방법으로 1이 35개인 배열과 0이 14개인 배열을 간단히 만들 수 있다. 그 다음 두 배열을 그대로 연결하면 된다.

np.column_stack() 함수를 사용하지 않고 첫 번째 차원을 따라 배열을 연결하는 np.concatenate() 함수를 사용한다.

fish_target = np.concatenate((np.ones(35), np.zeros(14)))

np.concatenate() 함수를 사용해 타깃 데이터를 만들었다. np.column_stack() 과 마찬가지로 배열을 튜플로 전달해야 한다.

[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0.]

잘 만들어진 듯 하다.

 

넘파이 배열은 핵심 부분이 C,C++과 같은 저수준 언어(Low Level Language)로 개발되어서 빠르고, 데이터 과학 분야에 알맞게 최적화 되었이다.

 

이제 다음 차례는 훈련 세트와 테스트 세트를 나눌 차례이다.

 

사이킷런으로 훈련 세트와 테스트 세트 나누기

사이킷런은 머신러닝 모델을 위한 알고리즘뿐만 아니라 다양한 유틸리티 도구도 제공한다.

대표적인 도구가 바로 지금 사용할 train_test_input() 함수이다.  이 함수는 전달되는 리스트나 배열을 비율에 맞게 훈련 세트와 테스트 세트로 나누어 준다. 물론 나누기 전에 알아서 섞어준다.

from sklearn.model_selection import train_test_split

사용법은 아주 간단하다. 나누고 싶은 리스트나 배열을 원하는 만큼 전달하면 된다. 우리는 fish_data 와 fish_target을 나누겠다.

그리고 나오는 결과를 같게 하기위해 저번 시간에 np.random.seed() 함수를 사용해서 필자와 결과가 같아지도록 만들었었다.

train_test_split() 함수에는 친절하게도 자체적으로 랜덤 시드를 지정할 수 있는 random_state 매개변수가 있다.

 

다음과 같이 훈련 세트와 테스트 세트를 나눈다.

train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42)

fish_data와 fish_target 2개의 배열을 전달했으므로 2개씩 나뉘어 총 4개의 배열이 반환된다.

차례대로 처음 2개는 입력 데이터 (train_input, test_input), 나머지 2개는 타깃 데이터(train_target, test_target)이다.

랜덤 시드는 42로 지정했다.

 

이 함수는 기본적으로 25%를 테스트 세트로 떼어낸다.

잘 나누었는지 넘파이 배열의 shape 속성으로 입력 데이터의 크기를 출력해보겠다.

print(train_input.shape, test_input.shape)
print(train_target.shape, test_target.shape)
(36, 2) (13, 2)
(36,) (13,)

훈련 데이터와 테스트 데이터를 각각 36개와 1개로 나누었다.

입력 데이터는 2개의 열이 있는 2차원 배열이고 타깃 데이터는 1차원 배열이다.

 

도미와 빙어는 잘 섞였는지 확인해보자.

print(test_target)
[1. 0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1.]

13개의 테스트 세트 중에 10개가 도미(1)이고 ,3개가 빙어(0)이다. 잘 섞인 것 같지만 빙어의 비율이 조금 모자라다.

원래 도미와 빙어의 개수가 35개와 14개이므로 비율은 2.5:1이다. 근데 이 테스트 세트의 비율은 3.3:1이다.

여기서도 저번에 본 샘플링 편향이 조금 나타났다.

 

이처럼 무작위로 데이터를 나누었을 때 샘플이 골고루 섞이지 않을 수 있다.

훈련 세트와 테스트 세트에 샘플의 클래스 비율이 일정하지 않으면 모델이 일부 샘플을 올바르게 학습 할 수 없을 것이다.

 

train_test_split()함수는 이러한 문제를 간단히 해결할 방법이 있다.

stratify 매개변수에 타깃 데이터를 전달하면 클래스 비율에 맞게 데이터를 나눈다. 훈련 데이터가 작거나 특정 클래스의 샘플 개수가 적을 때 특히 유용하다.

train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, stratify=fish_target, random_state=42) 

아까 선언한 train_test_split() 함수 매개변수에 stratify 매개변수만 추가해주자.

print(test_target)
[0. 0. 1. 0. 1. 0. 1. 1. 1. 1. 1. 1. 1.]

빙어가 하나 더 늘었다. 이제 테스트 세트의 비율이 2.25:1이 되었다. 이건 데이터가 작아서 전체 훈련 데이터의 비율과 동일하게 맞출 수 없다. 하지만 꽤 비슷한 비율이다.

 

자 모든 데이터가 준비됐다. 문제를 해결해보자.

 

수상한 도미 한 마리

앞에서 준비했던 데이터로 k-최근접 이웃 알고리즘을 훈련해 보겠다.

from sklearn.neighbors import KNeighborsClassifier
kn = KNeighborsClassifier()
kn.fit(train_input,train_target)
kn.score(test_input, test_target)
1.0

테스트 세트의 도미와 빙어를 모두 올바르게 분류했다.

그럼 이 모델에 아까 오류를 일으킨다고 말했던 도미의 데이터를 넣어보고 결과를 확인해보자.

[0.]

아쉽게도 아직도 빙어라고 구분한다. 

그럼 이 샘플을 다른 데이터와 함께 산점도로 그려보자. 눈으로 보면 이해라도 가지 않겠는가?

import matplotlib.pyplot as plt
plt.scatter(train_input[:,0], train_input[:,1])
plt.scatter(25,150, marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

새로운 샘플은 삼각형으로 표시했다. 

 

이상하다. 이 샘플은 분명히 오른쪽 위로 뻗어있는 다른 도미 데이터에 더 가깝다.

왜 이 모델은 왼쪽 아래에 낮게 깔린 빙어 데이터에 가깝다고 판단한걸까?

 

k-최근접 이웃 알고리즘은 주변의 샘플 중에서 다수인 클래스를 예측으로 사용한다. 이 샘플의 주변 샘플을 알아보자.

KNeighborsClassifier 클래스는 주어진 샘플에서 가장 가까운 이웃을 찾아주는 kneighbors() 메소드를 제공한다. 이 메소드는 이웃까지의 거리와 이웃 샘플의 인덱스를 반환한다. KNeighborsClassifier 클래스의 이웃 개수인 n_neighbors의 기본값은 5이므로 5개의 이웃이 반환된다.

distances, indexes = kn.kneighbors([[25,150]])

indexes 배열을 사용해 훈련 데이터 중에서 이웃 샘플을 따로 구분해 그려보겠다.

plt.scatter(train_input[:,0], train_input[:,1])
plt.scatter(25,150, marker='^')
plt.scatter(train_input[indexes,0], train_input[indexes,1], marker='D') # marker='D'로 지정하면 산점도를 마름모로 그린다.
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

marker='D'로 지정하면 산점도를 마름모로 그린다.

 

삼각형 샘플에 가장 가까운 5개의 샘플이 초록 다이아몬드로 표시되었다. 역시 예측 결과와 마찬가지로 가장 가까운 이웃에 도미가 하나밖에 포함되지 않았다. 나머지 4개의 샘플은 모두 빙어이다. 

 

직접 데이터를 확인해 보자.

print(train_input[indexes])
print(train_target[indexes])
[[[ 25.4 242. ]
  [ 15.   19.9]
  [ 14.3  19.7]
  [ 13.   12.2]
  [ 12.2  12.2]]]
  
[[1. 0. 0. 0. 0.]]

길이가 25cm , 무게가 150g인 생선에 가장 가까운 이웃에는 빙어가 압도적으로 많다.

따라서 이 샘플의 클래스를 빙어로 예측하는 것은 무리가 아니다. 왜 가장 가까운 이웃을 빙어라고 생각한 걸까? 

산점도를 보면 직관적으로 도미와 가깝게 보이는데 말이다.

 

이 문제의 해결 실마리를 찾기 위해 kneighbors() 메소드에서 반환한 distances 배열을 출력해 보겠다.

이 배열에는 샘플까지의 거리가 담겨있다.

print(distances)
[[ 92.00086956 130.48375378 130.73859415 138.32150953 138.39320793]]

뭔가 이상한걸 눈치챘나? 어떻게 문제를 해결할 수 있을까?

 

기준을 맞춰라

아까 산점도를 다시 보자.

아까 봤던 산점도이다.

삼각형 샘플에 가장 가까운 첫 샘플까지의 거리는 92, 그 외 가장 가까운 샘플들은 모두 130,138 이다.

그런데 거리가 92와 130이라고 했을 때 그래프에 나타난 거리 비율이 이상하다.

 

대충 보아도 92의 거리보다 족히 몇 배는 되어 보이는데 겨우 거리가 130이란게 이상하다는 것이다. 뭐가 문제일까?

바로 x축은 범위가 좁고(10~40), y축은 범위가 넓다(0~1000). 따라서 y축으로 조금만 멀어져도 거리가 아주 큰 값으로 계산되는 것이다.

이 때문에 오른쪽 위의 도미 샘플이 이웃을 선택되지 못했던 것이다.

 

이를 눈으로 명확히 확인하기 위해 x축의 범위를 동일하게 0~1,000으로 맞추어 보겠다. 

멧플롯립에서 x축의 범위를 지정하려면 xlim() 함수를 사용한다.(비슷하게 y축의 범위를 지정하려면 ylim() 함수를 사용한다.)

plt.scatter(train_input[:,0], train_input[:,1])
plt.scatter(25,150, marker='^')
plt.scatter(train_input[indexes,0], train_input[indexes,1], marker='D')
plt.xlim((0,1000)) # 맷플롯립에서 x 축 범위를 지정하는 함수.
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

산점도가 거의 일직선으로 나타난다! x축과 y축의 범위를 동일하게 맞추었더니 모든 데이터가 수직으로 늘어난 형태가 되었다.

확실히 이런 데이터라면 생선의 길이(x축)는 가장 가까운 이웃을 찾는데 크게 영향을 미치지 못할 것 이다. 오로지 생선의 무게(y축) 만 고려 대상이 된다.

 

특성(길이와 무게)의 값이 놓인 범위가 매우 다르다. 이를 두 특성의 스케일(scale)이 다르다라고도 말한다.

특성 간 스케일이 다른 일은 매우 흔한 일이다. 어떤 사람이 방의 넓이를 재는데 세로는 cm로, 가로는 inch로 쟀다면 정사각형의 방도 직사각형이 되는건 순식간이다.

 

데이터를 표현하는 기준이 다르다면 알고리즘이 올바르게 예측할 수가 없다. 알고리즘이 거리 기반 일 때 특히 더 그렇다.

여기에는 k-최근접 이웃 알고리즘도 포함된다. 이런 알고리즘들을 샘플 간의 거리에 영향을 많이 받으므로 제대로 사용하려면 특성값을 일정한 기준으로 맞춰 주어야 한다. 이런 작업을 데이터 전처리(data preprocessing)라고 부른다.

 

가장 널리 사용하는 전처리 방법 중 하나는 표준점수(standard score)이다.(혹은 z점수라고도 부른다)

표준점수는 각 특성값이 0에서 표준편차의 몇 배만큼 떨어져 있는지를 나타낸다.

이를 통해 실제 특성값의 크기와 상관없이 동일한 조건으로 비교할 수있다.

 

계산하는 방법은 간단하다. 평균을 빼고 표준편차를 나누어 주면 된다. 넘파이는 편하게도 이 두 함수를 모두 제공한다.

mean = np.mean(train_input, axis=0) # mean() 함수는 평균을 계산한다.
std = np.std(train_input, axis=0)   # std() 함수는 표준편차를 제공한다.

np.mean() 함수는 평균을 계산하고, np.std() 함수는 표준편차를 계산한다. train_input은 (36,2) 크기의 배열이다.

특성마다 값의 스케일이 다르므로 평균과 표준편차는 각 특성별로 계산해야 한다. 이를 위해 axis=0으로 지정했다.

이렇게 하면 행을 따라 각 열의 통계 값을 계산한다.

 

계산된 평균과 표준편차를 출력해보면,

print(mean,std)
[ 27.29722222 454.09722222] [  9.98244253 323.29893931]

각 특성마다 평균과 표준편차가 구해졌다.

이제 원본 데이터에서 평균을 빼고 표준편차로 나누어 표준점수를 변환하겠다.

train_scaled = (train_input - mean) / std

이건 어떻게 계산이 되는걸까? 넘파이는 똑똑하게도 train_input의 모든 행에서 mean에 있는 두 평균값을 빼준다.

그다음 std에 있는 두 표준편차를 다시 모든 행에 적용한다. 이런 넘파이 기능을 브로드캐스팅(broadcasting)이라고 부른다.

 

전처리 데이터로 모델 훈련하기

앞에서 표준점수로 변환한 train_scaled를 만들었다. 

그럼 이제 아까 문제의 빙어 표시 샘플을 다시 산점도로 그려보겠다.

plt.scatter(train_scaled[:,0], train_scaled[:,1])
plt.scatter(25,150, marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

엥, 우리가 예상했던거와는 또 다르다. 오른쪽 꼭대기 위에 삼각형 샘플 하나 빼고 다 왼쪽 하단에 몰려있다. 하지만 이건 당연하다.

훈련 세트 mean(평균)으로 빼고 std(표준편차)로 나누어 주었기 때문에 값의 범위가 크게 달라졌다.

그러니까 샘플도 동일한 비율로 변환 시켜줘야 한다라는 뜻이다.

 

여기에 아주 중요한 점이 하나 있다. 바로 훈련 세트의 mean,std를 이용해서 변환해야 한다는 점이다. 사실 샘플 하나만으로는 평균과 표준편차를 구할 수도 없다. 그럼 동일한 기준으로 샘플을 변환하고 산점도를 다시 그려보자.

new = ([25,150] - mean) / std

plt.scatter(train_scaled[:,0], train_scaled[:,1])
plt.scatter(new[0],new[1], marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

이 그래프는 앞서 표준편차로 변환하기 전의 산점도와 거의 동일하다. 크게 달라진 점은 x축과 y축의 범위가 -1.5 ~ 1.5 사이로 바뀌었다는 점이다. 훈련데이터의 두 특성이 비슷한 범위를 차지하고 있다. 이제 이 데이터셋으로 k-최근접 이웃 모델을 다시 훈련해보자.

 

kn.fit(train_scaled, train_target)

훈련을 마치고 테스트 세트로 평가할 때는 주의해야 한다. 앞서 수상한 샘플을 훈련 세트의 평균과 표준편차로 변환해야지 같은 비율로 산점도를 그릴 수 있었다. 마찬가지로 테스트 세트도 훈련 세트의 평균과 표준편차로 변환해야 한다. 그렇지 않으면 훈련한 모델이 쓸모 없어 지게된다. 자 이제 테스트 세트의 스케일을 변환 해보자.

test_scaled = (test_input - mean) / std # test_input 도 표준점수와 동일한 비율로 바꿔줘야 한다.

테스트 해보자!

kn.score(test_scaled, test_target)
1.0

역시 100%의 정확도를 보여준다. 모든 테스트 세트의 샘플을 완벽하게 구분했다.

그럼 아까 말썽을 일으키던 수상한 생선의 샘플은 어떨까? 아까 훈련 세트의 평균과 표준편차로 변환한 샘플을 사용해 예측 해보자.

print(kn.predict([new]))
[1.]

도미로 예측을 했다! 확실히 길이가 25cm 이고 무게가 150g인 생선은 도미일 것이다.

 

마지막으로 kneighbors() 함수로 이 샘플의 k-최근접 이웃을 구한 다음 산점도로 그려 보겠다.

특성을 표준점수로 바꾸었기 때문에 k-최근접 이웃 알고리즘이 올바르게 거리를 측정했을 것이다.

plt.scatter(train_scaled[:,0], train_scaled[:,1])
plt.scatter(new[0], new[1], marker='^')
plt.scatter(train_scaled[indexes,0], train_scaled[indexes,1], marker='D')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 코드의 산점도이다.

아까 보았던 수상한 생선에서 가장 가까운 샘플은 모두 도미이다. 따라서 이 수상한 샘플을 도미로 예측하는 것이 당연하다.

특성값의 스케일에 민감하지 않고 안정적인 예측을 할 수 있는 모델을 만들었다.

 

 

스케일이 다른 특성 처리 - 정리

우리가 만든 모델은 완벽하게 테스트 세트를 분류했었다. 하지만 사장님이 가져온 샘플에서는 엉뚱하게 빙어라고 예측을 한것이다. 그래프를 그려보아도 이 샘플은 도미에 가까운데 말이다.

 

이는 샘플의 두 특성인 길이와 무게의 스케일이 다르기 때문이다. 길이보다 무게의 크기에 따라 예측값이 좌지우지된다.

대부분의 머신러닝 알고리즘은 특성의 스케일이 다르면 잘 작동하지 않는다.

 

이를 위해 특성을 표준점수로 변환했다. 사실 특성의 스케일을 조정하는 방법은 여러가지가 존재 하지만, 대부분의 경우 표준점수로 충분하다. 또 가장 널리 사용하는 방법이기도 하다.

데이터를 전처리할 때 주의할 점은 훈련 세트를 변환한 방식 그대로 테스트 세트를 변환해야 한다는 것이다. 그렇지 않으면 특성값이 엉뚱하게 변환될 것이고, 그것이 곧 모델이 잘 작동하지 않는 길로 인도하게 될 것이기 때문이다.

 

이제 우리는 원인을 해결하고 새로운 모델을 제출했다!

728x90

댓글

IT_learning's Commit