[혼공머신] 2장 데이터 다루기

Changh2·2024년 10월 2일
0

[혼자 공부하는 머신러닝+딥러닝] 교재 2장을 기반으로 작성되었습니다.


2-1 훈련 세트와 테스트 세트

지도 학습과 비지도 학습

머신 러닝은 크게 지도 학습과 비지도 학습으로 나뉜다.
지도 학습은 훈련하기 위한 데이터와 정답이 필요한데, (데이터 = 입력, 정답 = 타깃) 이라 하고, 이 둘을 합쳐 훈련 데이터라고 부른다.

비지도 학습은 타깃(정답)없이 입력 데이터만 사용하는 알고리즘이다.


훈련 세트와 테스트 세트

도미와 빙어의 데이터와 타깃을 주고 훈련한 다음, 같은 데이터로 테스트한다면 모두 맞히는 것이 당연하다.
머신러닝의 알고리즘의 성능을 제대로 평가하려면'훈련 데이터'와 '평가에 사용할 데이터'가 각각 달라야 한다. 즉, '훈련 세트'와 '테스트 세트'는 서로 달라야 한다.

훈련 데이터에서 일부를 떼어 내어 테스트 세트로 사용하겠다. (많이 사용하는 방법)

먼저, 1장에서 한것과 같이 도미와 빙어의 데이터를 합쳐 하나의 리스트로 준비한다.

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]
                
fish_data = [[l, w] for l, w in zip(fish_length, fish_weight)]
fish_target = [1]*35 + [0]*14

이때, 하나의 생선 데이터를 샘플이라고 부른다.
49개의 샘플 중 35개를 훈련 세트로, 14개를 테스트 세트로 사용하겠다.

그 전에, 인덱스와 슬라이싱에 대해 먼저 알아보자.

from sklearn.neighbors import KNeighborsClassifier
kn = KNeighborsClassifier()
print(fish_data[4])   # 4번째 인덱스 출력
>> [29.0, 430.0]
print(fish_data[0:5])	# 0~5 슬라이싱 (마지막 인덱스는 포함되지 않는다)
>> [[25.4, 242.0], [26.3, 290.0], [26.5, 340.0], [29.0, 363.0], [29.0, 430.0]]
print(fish_data[:5])   # 처음부터 5인덱스 전까지
>> [[25.4, 242.0], [26.3, 290.0], [26.5, 340.0], [29.0, 363.0], [29.0, 430.0]]
print(fish_data[44:])	# 44인덱스 포함 부터 마지막까지
>> [[12.2, 12.2], [12.4, 13.4], [13.0, 12.2], [14.3, 19.7], [15.0, 19.9]]

위 방법을 사용하여 훈련 세트와 테스트 세트를 지정하자.

# 훈련 세트: 0~34 인덱스의 입력값, 타겟값 지정 
train_input = fish_data[:35]
train_target = fish_target[:35]

# 테스트 세트: 0~34 인덱스의 입력값, 타겟값 지정 
test_input = fish_data[35:]
test_target = fish_target[35:]

이제 모델을 훈련하고, 평가해보자.

kn.fit(train_input, train_target)
kn.score(test_input, test_target)
>>> 0.0

정확도가 0.0이다! 뭐가 잘못된 것일까?


샘플링 편향

현재 훈련 세트에는 도미 샘플만 들어가 있고, 테스트 세트에는 빙어 샘플만 들어가 있다.
이렇게 훈련 세트와 테스트 세트에 샘플이 골고루 섞여있지 않고 샘플링이 한쪽으로 치우친 것을
샘플링 편향
이라고 한다.

위 예시에서는 테스트 세트가 무엇이던지 무조건 도미라고 분류한다. 그런데 테스트 세트는 빙어만 있기 때문에 정답을 하나도 맞히지 못해 0.0의 정확도가 나온것이다.

샘플을 골고루 하는 작업을 간편하게 처리할 수 있는 라이브러리인 넘파이에 대해 알아보자.


넘파이

파이썬의 대표적인 배열 라이브러리로, 고차원의 배열을 손쉽게 만들고 조작할 수 있는 도구를 제공한다.

# 넘파이 라이브러리 임포트
import numpy as np
# 파이썬 리스트를 넘파이 배열로 바꾸기
input_arr = np.array(fish_data)
target_arr = np.array(fish_target)

print(input_arr)
>>> [[  25.4  242. ]
     [  26.3  290. ]
     [  26.5  340. ]
     [  29.   363. ]
     [  29.   430. ]
     [  29.7  450. ]
     [  29.7  500. ]
     [  30.   390. ]
     [  30.   450. ]
     [  30.7  500. ]
     [  31.   475. ]
     [  31.   500. ]
     [  31.5  500. ]
     [  32.   340. ]
     [  32.   600. ]
     [  32.   600. ]
     [  33.   700. ]
     [  33.   700. ]
     [  33.5  610. ]
     [  33.5  650. ]
     [  34.   575. ]
     [  34.   685. ]
     [  34.5  620. ]
     [  35.   680. ]
     [  35.   700. ]
     [  35.   725. ]
     [  35.   720. ]
     [  36.   714. ]
     [  36.   850. ]
     [  37.  1000. ]
     [  38.5  920. ]
     [  38.5  955. ]
     [  39.5  925. ]
     [  41.   975. ]
     [  41.   950. ]
     [   9.8    6.7]
     [  10.5    7.5]
     [  10.6    7. ]
     [  11.     9.7]
     [  11.2    9.8]
     [  11.3    8.7]
     [  11.8   10. ]
     [  11.8    9.9]
     [  12.     9.8]
     [  12.2   12.2]
     [  12.4   13.4]
     [  13.    12.2]
     [  14.3   19.7]
     [  15.    19.9]]
print(input_arr.shape)  # 이 명령은 (샘플 수, 특성 수)를 출력한다.
>>> (49, 2)

이제 무작위로 샘플을 고르는 방법을 사용하여 훈련 세트와 테스트 세트를 만들어보자.

seed( )

넘파이에서 난수를 생성하기 위한 정수 초깃값을 지정한다. 초기값이 같으면 동일한 난수를 뽑을 수 있다.

arange( )

일정한 간격의 정수 또는 실수 배열을 만드는데, 기본 간격은 1이다.
( ) 사이의 매개변수가 하나이면 종료 숫자를 의미하고, 매개변수가 2개면 시작 숫자, 종료 숫자를 의미한다.

print(np.arange(3))
# >>> [0, 1, 2]
print(np.arange(1, 3))
# >>> [1, 2]

shuffle( )

주어진 배열을 랜덤하게 섞는다. 다차원 배열일 경우 첫번째 축(행)에 대해서만 섞는다.

arr = np.array([[1, 2], [3, 4], [5, 6]])
np.random.shuffle(arr)
print(arr)
# >>> [[5 6]
# 	   [1 2]
#      [3 4]]

위의 방법들을 사용해 훈련 세트와 테스트 세트를 만든다.

# 교재와 동일한 실습 결과를 얻을 수 있또록 랜덤 시드를 42로 지정
np.random.seed(42)
index = np.arange(49)
np.random.shuffle(index)

print(index)
>>> [13 45 47 44 17 27 26 25 31 19 12  4 34  8  3  6 40 41 46 15  9 16 24 33
     30  0 43 32  5 29 11 36  1 21  2 37 35 23 39 10 22 18 48 20  7 42 14 28
     38]
     
# 인덱스 출력을보니 인덱스 값이 잘 섞인 것을 확인할 수 있다.

넘파이는 배열 인덱싱이라는 기능을 제공하는데, 아래와 같이 입력하면 input_arr의 1, 3 인덱스의 샘플을 출력할 수 있다.

print(input_arr[[1,3]])
>>> [[ 26.3 290. ]
	 [ 29.  363. ]]

위의 배열 인덱싱을 사용해 훈련 세트와 테스트 세트를 만든다.

# "훈련 세트"  
train_input = input_arr[index[:35]]
train_target = target_arr[index[:35]]
print(input_arr[13], train_input[0])
# >>> [ 32. 340.] [ 32. 340.]

# 만들어진 인덱스의 첫번째 값은 13이다. 훈련 세트가 섞인 인덱스에 맞게 만들어졌는지 확인
# 정확히 일치하는것을 볼 수 있음.
# "테스트 세트"
test_input = input_arr[index[35:]]
test_target = target_arr[index[35:]]

모든 데이터가 준비되었으니, 훈련 세트와 테스트 세트에 도미와 빙어가 잘 섞여있는지 산점도를 확인하자.

import matplotlib.pyplot as plt

plt.scatter(train_input[:, 0], train_input[:, 1])
plt.scatter(test_input[:, 0], test_input[:, 1])
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

잘 섞여있는 것을 확인할 수 있다. 이제 모델을 다시 훈련시켜 보자.


두 번째 머신러닝 프로그램

fit() 메서드를 실행할 때마다 KNeighborsClassifier 클래스의 객체는 이전에 학습한 모든 것을 잃어버린다. 여기선 이전에 만든 kn 객체를 그대로 사용하겠다.

kn.fit(train_input, train_target)

fit() 메서드로 모델을 훈련시켰다.

kn.score(test_input, test_target)
>>> 1.0

score() 메서드의 결과 100%의 정확도로 모든 생선을 맞췄다!

kn.predict(test_input)
>>> array([0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0])
kn.predict(test_target)
>>> array([0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0])

predict() 메서드의 결과, 즉 테스트 세트에 대한 예측 결과가 정답과 일치한다. 아주 좋은 성능!
출력이 array() 감싸져 있는것은 넘파이 배열을 의미한다.



2-2 데이터 전처리

넘파이로 데이터 준비하기

우선, 생선 데이터를 준비한다.

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]

np.column_stack( )

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

import numpy as np
np.column_stack(([1,2,3], [4,5,6]))
>> array([[1, 4],
           [2, 5],
           [3, 6]])

위의 방법으로 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. ]]

np.ones( )와 np.zeros( )

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

print(np.ones(5))
[1. 1. 1. 1. 1.]

np.concatenate( )

배열을 연결하는 함수이다. np.column_stack() 과의 차이를 보이자면 아래와 같다.

위의 방법들을 이용해 타깃 데이터를 만든다.

fish_target = np.concatenate((np.ones(35), np.zeros(14)))
print(fish_target)
>>> [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.]

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

train_set_split( )

이 함수는 전달되는 리스트나 배열을 비율에 맞게 훈련 세트와 테스트 세트로 나누어 준다.
물론, 기본적으로 나누기 전에 알아서 섞어준다! --> shuffle 매개변수로 True/False 지정 가능
이 함수는 기본적으로 25%를 테스트 세트로 떼어낸다. --> test_size 매개변수로 지정 가능

위의 방법으로 훈련, 테스트 세트를 나누고, 잘 나누었는지 넘파이의 shape 속성으로 확인해준다.

from sklearn.model_selection import train_test_split
train_input, test_input, train_target, test_target = train_test_split(
    fish_data, fish_target, random_state=42) # 교재와 같은 결과를 얻기 위해 랜덤 시드를 42로 지정

print(train_input.shape, test_input.shape)
>>> (36, 2) (13, 2)

# 입력 데이터는 2차원 배열이기 때문
print(train_target.shape, test_target.shape)
>>> (36,) (13,)

# 타깃 데이터는 1차원 배열이기 때문

잘 나누어진거 같으니, 도미와 빙어가 잘 섞였는지 테스트 데이터를 출력해보자.

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

13개의 테스트 세트 중에 10개가 도미(1)이고, 3개가 빙어(0)이다. 빙어의 비율이 모자라다.
샘플링 편향이 여기에서도 조금 나타난것.

이렇게 무작위로 데이터를 나누었을 때 샘플이 골고루 섞이지 않을 수 있고, 비율이 일정하지 않다면 모델이 일부 샘플을 올바르게 학습할 수 없다.

stratify 매개변수

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)
    
print(test_target)
>>> [0. 0. 1. 0. 1. 0. 1. 1. 1. 1. 1. 1. 1.]

빙어가 하나 늘어 비율이 비슷해진 것을 볼 수 있다.(데이터가 작아 비율을 동일하게 맞출 순 없다)


수상한 도미 한 마리

앞서 준비한 데이터로 k-최근접 이웃을 훈련해보겠다.

from sklearn.neighbors import KNeighborsClassifier

kn = KNeighborsClassifier()
kn.fit(train_input, train_target)
kn.score(test_input, test_target)
>>> 1.0

완벽한 결과가 나왔다!
이제 하나의 특정 도미의 샘플 데이터를 넣고 결과를 확인해보자.

print(kn.predict([[25, 150]]))
>>> [0.]

도미(1)가 아닌 빙어(0)의 결과가 나왔다... 왜일까???

이유를 알기 위해 이 샘플 데이터와 다른 데이터를 함께 산점도로 나타내보자.

import matplotlib.pyplot as plt

plt.scatter(train_input[:,0], train_input[:,1])
plt.scatter(25, 150, marker='^')   # marker 매개변수는 모양을 지정한다
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

산점도를 보니 샘플은 도미 데이터에 더 가까운걸 볼 수 있다.
그럼에도 불구하고 왜 이 모델은 빙어에 가깝다고 판단한 것인지 알기 위해 KNeighbors() 메서드로 샘플에게서 가까운 이웃들을 찾아보자.

KNeighbors(): 입력한 데이터에 가장 가까운 이웃을 찾아 거리와 이웃 샘플의 인덱스를 반환한다. n_neighbors 매개변수로 이웃의 개수를 지정할 수 있다.

distances, indexes = kn.kneighbors([[25, 150]])
# 기본값으로 5개의 이웃이 설정되어있다. 
# 샘플의 이웃 5개의 거리와 인덱스를 assign한다.

이제 훈련 데이터 중 이웃 샘플들을 따로 구분해서 그려보겠다.

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.xlabel('length')
plt.ylabel('weight')
plt.show()

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

산점도와 위의 출력 결과를 보니 확실히 가장 가까운 이웃 5개 중 4개가 빙어로 보인다.
이을 해결하기 위해 각 이웃별 거리를 출력해보자.

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

여기서 이상한 점이 보인다!


기준을 맞춰라

산점도에 나타난 거리 비율이 이상하다... 92보다 몇배는 되어 보이는데 거리가 130인게 수상하다.
이는 x축은₩ 10~40으로 범위가 좁고, y축은 0~1000으로 범위가 넓기 때문인데,
y축으로 조금만 멀어져도 거리가 아주 큰 값으로 계산된다.

x축도 y축과 범위를 동일하게 지정해서 산점도를 확인해보자.

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))
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

x축의 특성 데이터는 거의 의미가 없어지다 싶이 되었다.
이웃을 찾는데에 사실상 y축만 고려 대상이 되었던 것이다.

두 특성의 값이 놓인 범위가 매우 다르다. 이를 두 특성의 스케일이 다르다고도 말한다.

거리 기반 알고리즘들은 샘플 간의 거리에 영향을 많이 받으므로,
특성값을 일정한 기준으로 맞춰주어야 하는데, 이를 데이터 전처리라고 한다.

데이터 전처리: 머신러닝 모델에 훈련 데이터를 주입하기 전에 가공하는 단계를 말한다.

가장 널리 사용하는 전처리 방법 중 하나는 표준점수이다.

표준점수

각 특성값이 평균에서 표준편차의 몇 배만큼 떨어져 있는지를 나타낸다.
이를 통해 실제 특성값의 크기와 상관없이 동일한 조건으로 비교할 수 있다.
계산하는 방법은 데이터값에서 평균을 빼고 표준편차로 나누어주면된다.

[표준점수 vs 표준편차]
표준편차는 분산의 제곱근으로, 데이터가 분산된 정도를 나타낸다.
표준점수는 각 데이터가 원점에서 몇 표준편차만큼 떨어져 있는지를 나타내는 값이다.

이제 표준점수를 계산해보자.

mean = np.mean(train_input, axis=0)  # 평균을 계산
std = np.std(train_input, axis=0)  # 표준편차를 계산

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

평균과 표준편차는 각 특성별로 계산이 이루어져야하기 때문에 axis=0 으로 설정했다.

train_scaled = (train_input - mean) / std

위의 코드로 인해 아래와 같은 동작이 이루어지는데, 이런 넘파이 기능을 브로드캐스팅이라고 부른다.

브로드캐스팅: 크기가 다른 넘파이 배열에서 자동으로 사칙 연산을 모든 행이나 열로 확장하여 수행하는 기능



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

이제 변환된 데이터도 구했으니, 아까 이상했던 샘플과 데이터를 산점도로 다시 그려보겠다.

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


샘플의 위치가 많이 이상한 것을 볼 수 있다.
당연한 것인데, 샘플은 동일한 비율로 변환해주지 않았기 때문이다.
샘플을 변환하고 다시 산점도를 그려보자.

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

이제 모델을 평가해보자.

kn.score(test_scaled, test_target)
>>> 1.0

완벽환 결과가 나왔다. 그럼 아까 이상했던 도미 샘플의 예측결과도 출력해보자.

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

드디어 도미(1)로 예측한 것을 볼 수 있다.

마지막으로 이상했던 도미 샘플의 이웃들까지 표시하여 산점도를 확인해보자.

distances, indexes = kn.kneighbors([new])

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()

profile
Shoot for the moon!

0개의 댓글