[ch02-2] 혼자 공부하는 머신러닝 + 딥러닝

02-2 데이터 전처리


앞에서 사용했던 도미와 빙어 데이터를 보면 도미는 길이와 무게가 큰 편이고 빙어는 작은 편이었다.
만약, 빙어처럼 작은 도미가 있거나 도미처럼 큰 빙어가 있다면 ?!

넘파이로 데이터 준비하기

먼저 앞에서 했던 데이터와는 다른 데이터를 준비하자.

노트북에서 새로운 셀을 만들어서 입력

👉 도미와 빙어 리스트2

1
2
3
4
5
6
7
8
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 하고 넘파이의 column_stack() 함수를 이용해 fish_length와 fish_weight를 합치자.

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

1
2
import numpy as np
fish_data = np.column_stack((fish_length, fish_weight))

리스트가 잘 연결됐는지 처음 3개의 데이터를 확인해보면
column1

column_stack(A, B)의 A는 첫 번째 열로, B는 두 번째 열로 들어간 것을 알 수 있다.

동일하게 타깃 데이터도 만든다.
이전에는 0과 1을 갯수만큼 곱해서 입력해줬는데 이번엔 더 나은 방법인 ones()zeros() 함수를 이용해 각각 원하는 개수의 1과 0을 채운 배열을 만들어서 입력해보자.

예를 들면 np.ones(5)는

1
print(np.ones(5))

column2 1이 5개인 배열이 만들어진다.
같은 방식으로 zeros(N)는 0이 N개인 배열이 만들어지는 것이다.

이 두 함수를 이용해 1이 35개인 배열과 0이 14개인 배열을 만들 수 있고 concatenate()함수를 사용해 두 배열을 연결해보자.

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

fish_target

  • column_stack()과 concatenate()의 차이점 ?
    column_stack() 함수는 각 리스트를 열에 하나씩 넣고 concatenate() 함수는 앞 리스트 뒤에 뒷 리스트를 붙인다.
    예를 들면 이렇게 표현할 수 있다. 👇 difference

이제 훈련 세트와 테스트 세트를 나눠보자.

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

훈련 세트와 테스트 세트를 비율에 맞게 나누기 위해 사이킷런의 train_test_split()함수를 사용한다.
이 함수에 나누고 싶은 리스트나 배열을 원하는 만큼 전달하면 된다.
사용하기에 앞서 train_test_split() 함수는 사이킷런의 model_selection 모듈 아래에 있어서 import 해준다.

1
from sklearn.model_selection import train_test_split


train_test_split()함수로 fish_data와 fish_target을 train_input, test_input, train_target, test_target으로 나눠야한다.
앞서 했던 random.seed()함수를 사용해 출력 결과와 책의 결과와 동일하게 해준 것 처럼 train_test_split()함수에는 자체적으로 랜덤 시드를 지정할 수 있는 random_state 매개변수가 존재한다.
앞에서 랜덤시드를 42로 설정한 것과 동일하게 random_state 매개변수를 42로 설정한다.

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


이 함수는 기본적으로 25%를 테스트 세트로 떼어낸다.
잘 나누었는지 확인하기 위해 넘파이 배열의 shape 속성으로 입력 데이터의 크기를 출력해보자.

1
2
print(train_input.shape, test_input.shape)
print(train_target.shape, test_target.shape)

shapeofdata

출력 결과를 통해 훈련 데이터와 테스트 데이터가 각각 36, 13개로 나누어진 것을 확인할 수 있다.

튜플의 원소가 하나일 경우 원소 뒤에 콤마(,)를 추가한다.
입력 데이터(_input)의 ( , 2)는 2개의 열이 있는 2차원 배열을 의미하고, 타깃 데이터(_target)의 ( ,)는 1차원 배열을 의미한다.

테스트 데이터를 출력해 도미와 빙어가 잘 섞였는지 출력해보자.

1
print(test_target)

testtarget

총 13개의 테스트 세트 중에 10개는 도미(1), 3개는 빙어(0)다.
그럴듯하게 섞여있지만 첫 번째 테스트 세트인 ‘1’ 이 영 찝찝하다.
원래 도미와 빙어의 개수는 35 : 14 이므로 두 생선의 비율은 2.5 : 1 인데 이 테스트 세트는 10 : 3 으로 비율이 3.3 : 1 이므로 비율이 맞지 않다.

즉, 샘플링 편향이 조금이지만 나타난 것이다.
이렇게 골고루 섞이지 않은 이유는 샘플 개수가 적어서 이런 일이 생긴 것이다.

다시 원래 비율대로 맞추기 위해서 train_test_split() 함수에 stratify 매개변수에 타깃 데이터를 전달해 비율에 맞게 데이터를 나눈다.

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

aft_stratify

아까 있던 찝찝했던 ‘1’은 사라지고 9개의 도미(1), 4개의 빙어(0)가 출력되고 비율은 2.25 : 1로 아까보단 2.5 : 1 과 근접한 비율이 되었다!

수상한 도미 한 마리

다시 k-최근접 이웃 훈련을 해보자.

1
2
3
4
from sklearn.neighbors import KNeighborsClassifier
kn = KNeighborsClassifier()
kn.fit(train_input, train_target)
kn.score(test_input, test_target)

02_2_3_1

1.0이 나와서 정말 기분이 좋다.
테스트 세트의 도미와 빙어를 올바르게 분류했다는 걸 보여준다.

이제 다시 무게가 25이고 길이가 150인 도미의 데이터를 넣고 결과를 확인해보자.

1
print(kn.predict([[25,150]]))

02_2_3_2

빙어라고 나왔다.. 내 세상이 무너졌다.
이 샘플과 다른 데이터를 함께 산점도로 그려 확인해보자.

1
2
3
4
5
6
import matplotlib.pyplot as plt
plt.scatter(train_input[:,0], train_input[:,1]) #0은 길이 1은 무게
plt.scatter(25, 150, marker = '^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

02_2_3_3

🔺가 25, 150인 데이터인데 그래프상 오른쪽 위로 뻗어 있는 도미 데이터에 더 가까운데 왜 왼쪽 아래의 빙어 데이터와 가깝다고 나오는 걸까?

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

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

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

02_2_3_4

kn.kneighbors([[25, 150]])을 출력한 결과를 통해 알 수 있듯이, [[25, 150]]과 가장 가까운 5개 이웃까지의 거리 배열과 5개 이웃 샘플의 인덱스가 반환됐다.
구한 indexes 배열을 사용해 훈련 데이터 중에서 이웃 샘플을 따로 구분해 그려보자.

1
2
3
4
5
6
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()

marker = ‘^’는 세모로, marker = ‘D’는 마름모로 표시된다.

02_2_3_5

초록색 🔷 5개가 🔺에 가장 가까운 샘플로 표시됐다.

🔷 5개 중 4개는 왼쪽 아래(빙어), 1개만 오른쪽 위(도미)에 포함되어 있으므로 빙어라고 예측한 것이다.
데이터를 확인해보자.

1
print(train_input[indexes])

02_2_3_6

타깃 데이터로도 확인해보자.

1
print(train_target[indexes])

02_2_3_7

빙어(0)가 압도적이다..
이렇게 본다면 이 샘플의 클래스를 빙어로 예측하는 것은 당연해 보인다.
산점도를 보면 도미와 더 가깝게 보이는데 왜 가장 가까운 이웃을 빙어라고 생각하는 걸까?

기준을 맞춰라

아까 kn.kneighbors()에서 구했던 distances 배열을 출력해보자.

1
print(distances)

02_2_3_8

거리가 짧은 순으로 나열되어 있는데

02_2_3_9

92랑 130이 차이가 이렇게 나는게 맞나 ?
좀 이상하긴 하지만 산점도를 자세히 보면 x축은 칸 간격이 5씩이지만 y축은 200씩이다.
그래서 수직으로 조금만 떨어져 있어도 큰 값으로 계산이 될테니 수평적으로 가까운 도미 샘플보단
수평적으로 멀지만 수직적으로 가까운 빙어 샘플들이 이웃으로 선택된 것이다.

그렇다면 x축과 y축 간격을 맞춰서 다시 산점도를 그려보자 !

matplotlib에서 xlim()함수를 사용해 x축 범위를 지정해준다.

x + lim(it). 즉 경곗값을 지정해주는 함수. y축은 ylim()으로 하면 된다.

1
2
3
4
5
6
7
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()

02_2_3_10

x축과 y축의 범위를 동일하게 맞췄더니 산점도가 거의 일직선으로 나타났다.
이런 데이터들이면 x값(길이)보단 y값(무게)에만 영향을 받아서 오로지 y값(무게)만 고려 대상이 된다.

두 특성의 값이 놓인 범위가 매우 다른데 이것을 두 특성의 스케일이 다르다고도 말한다.
여기에서만 봐도 무게는 g 단위고 크기는 m 단위라 이런 현상이 생긴다.

데이터를 표현하는 기준이 다르면 알고리즘이 올바르게 예측할 수 없다.
특히 알고리즘이 거리 기반일 경우 그런데 우리가 사용한 k-최근접 이웃 알고리즘이 여기에 해당된다.
그렇기 때문에 특성값들을 일정한 기준으로 맞춰 줘야 하는데 이런 작업을 데이터 전처리(data preprocessing)이라고 한다.

가장 널리 사용하는 전처리 방법 중 하나는 표준점수(standard score) / z 점수이다.
표준점수는 각 특성값이 0에서 표준편차의 몇 배만큼 떨어져 있는지를 나타낸다.
이를 통해 실제 특성값의 크기와 상관없이 동일한 조건으로 비교할 수 있다.

계산하는 방법은 평균을 빼고 표준편차를 나누어 주는 것이다.
넘파이에서 제공하는 함수를 이용하자.

1
2
3
mean = np.mean(train_input, axis = 0)
std = np.std(train_input, axis = 0)
print(mean, std)

02_2_3_11

mean()함수는 평균을 계산하고, std()함수는 표준편차를 계산한다.
train_input은 36개의 길이와 무게의 특성이 있는 배열이므로 각각의 평균과 표준편차가 계산이 된다.

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

1
train_scaled = ( train_input - mean ) / std

train_input의 모든 행에서 mean에 있는 두 평균값을 빼주고 std에 있는 두 표준편차로 모든 행을 나눈다.
이런 넘파이 기능을 브로드캐스팅(broadcasting)이라고 부른다.

참고로 브로드캐스팅은 넘파이 배열 사이에서 일어난다.

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

앞에서 표준점수로 변환한 train_scaled를 만들었으니 다시 산점도를 그려보자.

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

02_2_3_12

어 음 샘플이 안녕히계세요 여러분을 시전해버렸다..
왜냐면 저 친구 혼자만 평균으로 빼고 표준편차로 나누지 않았기 때문이다.
샘플까지 변환해주고 다시 산점도를 그려보자.

1
2
3
4
5
6
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()

02_2_3_13

앞에서 평균과 표준편차로 변환하기 전의 산점도와 거의 비슷하다.
눈에 띄게 달라진 점은 x축과 y축이 범위가 -1.5 ~ 1.5로 바뀐 것이다.
두 특성이 비슷한 범위를 차지하고 있으니 이 데이터셋으로 k-최근접 이웃 모델을 다시 훈련해보자.

1
kn.fit(train_scaled, train_target)

금방 샘플로도 확인했듯이 평균과 표준편차로 값을 변환해주지 않으면 아까와 같은 상황(덩그러니 남겨진)이 반복될 것이다.
그렇기 때문에 테스트 세트 또한 변환해주자.

1
2
test_scaled = ( test_input - mean ) / std
kn.score(test_scaled, test_target)

02_2_3_14

잘 분류가 되었다.
이제 샘플이 잘 분류가 되는지 확인해보자.

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

02_2_3_15

와! 드디어 도미(1)로 예측을 한다!!
마지막으로 kneighbors() 함수로 이 샘플의 k-최근접 이웃을 구한 다음 산점도로 그려보자.
가까운 이웃들이 도미로 나오는지 산점도로 확인해보자!

1
2
3
4
5
6
7
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()

02_2_3_16

가장 가까운 이웃인 초록색 🔷 5개가 오른쪽 위(도미)에서 나타났다 !

특성값의 스케일이 다르지만 안정적으로 예측할 수 있는 모델을 만드는 데에 성공했다 !!