Data Preprocessing

변현섭·2024년 7월 1일
0
post-thumbnail

1. Numpy를 활용하여 훈련 데이터와 정답 데이터 만들기

1) 훈련 데이터 만들기

이전 포스팅에서는 zip() 메서드와 List Comprehension을 이용하여, 생선의 length와 weight를 하나의 리스트로 병합하였다. 그러나 지금부터는 Numpy의 column_stack 메서드를 이용하여 Features 데이터를 통합할 것이다. 주의해야 할 것은 column_stack 메서드의 입력 인자는 연결할 리스트를 튜플의 형태로 전달해야 한다는 것이다.

import numpy as np

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(fish_length, fish_weight)라고 입력하지 않도록 주의
fish_data = np.column_stack((fish_length, fish_weight))

print(fish_data[:5])

2) 정답 데이터 만들기

기존에는 도미와 빙어를 구분하기 위해 [1]과 [0]을 곱하여 정답 데이터를 만들었다. 그러나 이 방법보다는 Numpy의 np.onesnp.zeros 메서드를 사용하는 방법이 권장된다. 또한, 0과 1로만 구성된 두 배열을 병합할 때에도 '+' 연산자 대신 Numpy의 concatenate() 메서드를 사용할 것이다. 이 때, concatenate() 메서드의 입력 인자도 튜플이라는 점에서 주의가 필요하다.

아래는 1이 35번 반복되는 배열과 0이 14번 반복되는 배열을 생성한 후, 병합하는 코드를 나타낸 것이다.

import numpy as np

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

물론, 위와 같은 과정이 오히려 파이썬의 리스트를 사용할 때보다 불편하다고 느껴질 수 있다. 그러나 우리의 목적은 코드를 간단히 하는데에 있는 것이 아니라, 실행 시간을 단축하는 데에 있다. 파이썬의 리스트는 매우 느리게 실행되는 반면, Numpy 배열은 내부적으로 C/C++을 사용하기 때문에 비교적 빠르게 실행된다. 바로 이러한 이유 때문에, 데이터 과학 분야에서 Numpy 배열을 사용하는 것이다.

2. 훈련 Set과 테스트 Set 만들기

1) Sample 랜덤 선택

지난 포스팅에서 Sample을 랜덤 선택하여 훈련 Set과 테스트 Set을 나누는 방법에 대해 알아보았다. 그러나, 이 방법은 랜덤 선택 로직을 직접 구현해야 한다는 점에서, 다소 번거롭게 느껴진다. 이 때, 사이킷런의 train_test_split() 메서드를 사용하면, 보다 손쉽게 훈련 Set과 테스트 Set을 만들 수 있다.

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

저번 포스팅과 동일하게, random_state 매개변수를 사용하여 랜덤 Seed를 42로 지정하였다. 위의 코드에서 알 수 있다시피, train_test_split() 메서드는 입력 Data Set과 정답 Data Set을 전달받아 차례로 훈련용 input, 테스트용 input, 훈련용 정답, 테스트용 정답 배열을 반환한다.

그렇다면 전체 데이터 중 몇 개의 데이터를 테스트용 데이터에 할당하였을까? 지난 시간에 배운 Numpy의 shape 메서드를 활용하여 훈련용 데이터와 테스트용 데이터의 개수를 확인해보자.

import numpy as np
from sklearn.model_selection import train_test_split

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 = np.column_stack((fish_length, fish_weight))
fish_target = np.concatenate((np.ones(35), np.zeros(14)))
train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42)

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

49개의 입력 데이터 중 13개를 테스트용 입력으로 사용하였다. 실제로, train_test_split() 메서드는 기본적으로 전체 데이터의 25%(올림 적용)를 테스트 Set으로 사용한다.

2) 샘플링 편향 방지

모델의 정확성을 높이기 위해서는 훈련 Set과 테스트 Set에 도미와 빙어가 골고루 섞여있어야 할 것이다. 그러나 Sample이 랜덤하게 선택되었기 때문에, 어느 정도의 샘플링 편향이 발생할 수 밖에 없다.

print(test_target) # [1. 0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1.] 출력

테스트 Set을 출력해보면 13개의 원소 중 10개는 도미, 3개는 빙어이다. 어느 정도 균일하게 섞인 것은 맞지만, 엄밀히 따지면 빙어의 개수가 조금 모자라다. 그 이유는 전체 입력 Set 중 도미와 빙어의 비율은 35:14 = 2.5:1인 반면, 위 테스트 Set에서 도미와 빙어의 비율은 10:3 = 3.3:1이기 때문이다. 따라서, 빙어가 1마리 추가되어 9:4 = 2.25:1의 비율을 맞추는 편이 좀 더 정확했을 것이다.

생각보다 위 문제에 대한 해결 방법은 간단하다. 그저 train_test_split 메서드의 stratify 매개변수에 정답 데이터를 전달하면 된다. stratify 매개변수는 정답 데이터의 비율에 맞게 자동으로 훈련 Set과 테스트 Set을 구분해준다.

train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42, stratify=fish_target)
print(test_target) # [0. 0. 1. 0. 1. 0. 1. 1. 1. 1. 1. 1. 1.] 출력

빙어의 개수가 하나 늘어나면서, 입력 Data Set과 비슷한 비율로 테스트 Set을 구성할 수 있게 되었다. (이 예제는 데이터의 개수가 적다보니 정확히 일치하는 비율을 만들지 못했지만, 데이터의 개수가 더 많았더라면, 거의 동일한 비율로 테스트 Set을 구성할 수 있었을 것이다.)

3. 데이터 전처리

1) 개념

먼저, 지금까지 구성한 훈련 데이터 Set을 이용해 모델을 학습시켜보자.

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier

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 = np.column_stack((fish_length, fish_weight))
fish_target = np.concatenate((np.ones(35), np.zeros(14)))
train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42, stratify=fish_target)

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

모델 학습이 성공적으로 완료되었다. 이제 이 모델을 이용해 길이가 25, 무게가 150인 생선의 정체를 예측해보자. 빙어에 비해 월등히 길이와 무게가 크다보니 당연히 도미로 예측할 것으로 예상된다.

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

예상과 달리, 모델은 해당 생선의 정체를 빙어로 예측하였다. 어떻게 된 일일까? 정말 빙어에 더 유사한 것인지 직접 Scatter Flot을 그려 확인해보자.

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

실제 Scatter Flot를 통해 보더라도, 도미에 더 가까운 것처럼 보인다. 그럼에도 불구하고 모델이 이 생선을 빙어로 예측한 이유가 무엇일까? 이유를 알아보기 위해 kneighbors 메서드를 통해 어떠한 샘플을 최근접 이웃으로 선택했는지 알아보자. 참고로, kneighbors 메서드는 선택된 이웃까지의 거리와 선택된 이웃의 인덱스를 반환하는 함수이다.

distances, indexes = kn.kneighbors([[25, 150]])
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()

무려 4개의 빙어 Sample이 선택되었다는 사실을 확인할 수 있다. 측정된 거리 데이터를 확인해보면, 문제에 대한 실마리를 찾을 수 있다.

print(distances) # [[ 92.00086956 130.48375378 130.73859415 138.32150953 138.39320793]] 출력

위에서 92는 도미 Sample까지의 거리이고 나머지는 빙어 Sample까지의 거리이다. Scatter Flot을 다시 보자.

이제는 무엇이 문제였는지를 쉽게 파악할 수 있을 것이다. x축과 y축의 범위가 다르기 때문에, 눈으로 보는 일직선 상의 거리는 실제 거리와 큰 차이가 존재하게 된다는 것이다. 쉽게 말해, Scatter Flot에서 y축 방향으로 조금만 멀어져도, 거리 값이 매우 큰 값으로 계산된다는 것이다.

지금까지 우리는 길이 데이터와 무게 데이터를 모두 사용하여 도미와 빙어를 구분해냈다고 생각했지만, 사실을 그렇지 않다. 거리 기반 알고리즘인 KNN의 특성 상, 길이 데이터는 도미와 빙어를 구분하는 데에 거의 영향을 미치지 못한다. 이 내용을 직접 확인해보기 위해 x의 범위를 y와 동일하게 설정한 후 산점도를 그려보겠다. 참고로, x축의 범위를 지정할 때에는 xlim() 메서드를 사용한다.

plt.xlim((0, 1000))

위 산점도를 보면, length가 거리 계산에 거의 영향을 주지 못한다는 말의 의미를 이해할 수 있을 것이다. 하지만, 우리는 길이와 무게 데이터를 모두 고려하여 도미와 빙어를 구분하기를 원한다. 즉, 두 Feature를 표현하는 일정한 기준이 존재해야 한다는 것이다.

Features의 범위를 Scale이라고 하는데, Features 간 Scale이 다른 경우는 매우 빈번하다. 따라서, 거리 기반의 알고리즘에서는 Scale을 일정한 기준으로 맞추는 작업이 반드시 선행되어야 하는데, 이러한 작업을 가리켜 데이터 전처리(Data Preprocessing)라고 부른다.

2) Standard Score

가장 널리 사용되는 전처리 방법은 Standard Score(표준 점수, z 점수)이다. 여기서 표준 점수란, 각 특성 값이 평균에서 표준 편차의 몇 배만큼 떨어져 있는지를 나타낸 것이다. 표준 점수를 계산하는 과정을 살펴보자.

① 평균과 표준편차 계산

  • Numpy의 mean, std 메서드를 활용한다.
  • axis=0은 row 방향을 의미하고, axis=1은 col 방향을 의미한다. 즉, axis=0은 각 열의 통계 값을 계산한다는 의미가 된다.
import numpy as np

mean = np.mean(train_input, axis=0)
std = np.std(train_input, axis=0)

② 표준 점수 계산

  • 각 Sample 데이터에서 평균을 뺀 값을 표준 편차로 나누어 표준 점수를 구할 수 있다.
train_scaled = (train_input - mean) / std

표준 점수를 계산하는 방식이 조금 특이해보이는데, 이는 Numpy의 브로드캐스팅 기능을 이용한 것이다. 참고로, 브로드캐스팅이란 Numpy 배열 간의 연산을 도와주는 기능 정도로 생각하면 된다. 당연히 여기에서 사용된 train_input, mean, std 모두 Numpy 배열이다.

train_input의 각 튜플에는 length와 weight 데이터가 저장되어 있다. 모든 튜플의 length와 weight에 대해 각각의 평균 값을 빼고, 각각의 표준 편차로 나누어준다. 그 결과, 손쉽게 전처리 데이터를 얻을 수 있게 된다.

3) 전처리 데이터를 이용한 모델 훈련

전처리 데이터를 이용하여 Scatter Flot을 다시 그려보자. 이 때, 주의해야 할 것은 길이가 25, 무게가 150인 생선의 데이터도 표준 점수로 변환해야 한다는 것이다.

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
import matplotlib.pyplot as plt

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 = np.column_stack((fish_length, fish_weight))
fish_target = np.concatenate((np.ones(35), np.zeros(14)))
train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42, stratify=fish_target)

mean = np.mean(train_input, axis=0)
std = np.std(train_input, axis=0)

train_scaled = (train_input - 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()

얼핏 보기에는 표준 점수로 변환하기 전의 Scatter Flot과 큰 차이가 없어보이지만, length와 weight가 도미와 빙어를 구분하는 데에 동일한 영향을 준다는 점에서 큰 차이가 있다.

이제 모델의 성능을 평가해보자. 이 때, 테스트 Set의 Scale도 훈련 Set을 통해 구한 평균과 표준 편차를 이용해 표준 점수로 변환해야 한다.

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

모델의 성능 평가도 완료된 것 같으니, 이제 생선의 정체를 예측해보자.

kn.predict([new]) # 2차원 배열 전달

드디어 도미로 예측하는 데에 성공하였다. 이는 KNN으로 선택된 5개의 최근접 이웃 중 다수가 도미 데이터라는 의미가 된다. 마지막으로 어떤 데이터 포인트를 선택하였는지 Scatter Flot으로 확인해보자.

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
import matplotlib.pyplot as plt

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 = np.column_stack((fish_length, fish_weight))
fish_target = np.concatenate((np.ones(35), np.zeros(14)))
train_input, test_input, train_target, test_target = train_test_split(fish_data, fish_target, random_state=42, stratify=fish_target)

mean = np.mean(train_input, axis=0)
std = np.std(train_input, axis=0)

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


kn = KNeighborsClassifier()
kn.fit(train_scaled, train_target)
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
LG전자 Connected Service 1 Unit 연구원 변현섭입니다.

0개의 댓글