IT/[혼공머신] 혼자 공부하는 머신러닝 + 딥러닝

[혼공머신] Ch.2 데이터 다루기_2. 데이터 전처리

vulter3653 2023. 8. 20. 19:49

<혼자 공부하는 머신러닝 + 딥러닝>의 'Ch.2 데이터 다루기_2. 데이터 전처리'의 내용을 요약 및 정리한 내용입니다.

 

https://product.kyobobook.co.kr/detail/S000001810330

 

혼자 공부하는 머신러닝+딥러닝 | 박해선 - 교보문고

혼자 공부하는 머신러닝+딥러닝 | 혼자 해도 충분하다! 1:1 과외하듯 배우는 인공지능 자습서이 책은 수식과 이론으로 중무장한 머신러닝, 딥러닝 책에 지친 ‘독학하는 입문자’가 ‘꼭 필요한

product.kyobobook.co.kr


1. 올바른 결과 도출을 위해서 데이터를 사용하기 전에 데이터 전처리 과정을 거칩니다.

2. 전처리 과정을 거친 데이터로 훈련했을 때의 차이를 알고 표준점수로 특성의 스케일을 변환하는 방법을 배웁니다.

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]

 

다음 넘파이를 임포트합니다. 이후 넘파이의 column_stack() 함수를 통해 fish_length와 fish_weight 합쳐줍니다. column_stack() 함수는 전달받은 리스트를 일렬로 세운 다음 차례대로 나란히 연결하는 역할을 합니다.

import numpy as np

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

 

두 리스트가 잘 연결되었는지 처음 5개의 데이터를 통해 확인해보겠습니다. 

 

잘 연결된 것을 확인할 수 있습니다. 추가로 위에 결과처럼 넘파이 배열을 출력하면 리스트처럼 한 줄로 길게 출력되지 않고 행과 열을 맞추어 가지런히 정리된 모습으로 보여 줍니다. 

 

이번엔 타깃 데이터도 만들어 보겠습니다. 여기서 사용되는 함수는 np.ones()와 np.zeros() 함수입니다. 이 두 함수는 각각 원하는 개수의 1과 0을 채운 배열을 만들어 줍니다. 이 두 함수를 사용해 1이 35개인 배열과 0이 14개인 배열을 간단히 만들 수 있습니다.

 

그다음 두 배열을 그대로 연결하면 됩니다. 여기에서는 np.concatenate() 함수가 사용됩니다. np.column_stack() 함수와 동일하게 리스트나 배열을 튜플로 전달해야 하며 연결하는 역할을 하지만 행의 형태로 연결해준다는 특징이 있습니다. 

 

이제 훈련 세트와 테스트 세트를 나누겠습니다.

 

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

이전에는 넘파이 배열의 인덱스를 직접 섞어서 훈련 세트와 테스트 세트로 나누었습니다. 이번에는 좀 더 세련된 방법을 사용해 보겠습니다.

 

사이킷런은 머신러닝 모델을 위한 알고리즘뿐만 아니라 다양한 유틸리티 도구도 제공합니다. 대표적인 도구가 지금 사용할 train_test_split() 함수입니다. 이 함수는 전달되는 리스트나 배열을 비율에 맞게 훈련 세트와 테스트 세트로 나누어 줍니다. 

 

train_test_split() 함수는 사이킷런의 model_selection 모델 아래 있으며 다음과 같이 임포트합니다.

from sklearn.model_selection import train_test_split

 

이후 나누고 싶은 리스트나 배열을 원하는 만큼 전달합니다.  이 예제에서는 fish_data와 fist_target을 나누겠습니다. 코드에 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)입니다. 랜덤 시드(random_state)는 42를 지정했습니다.

 

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

 

훈련 데이터와 테스트 데이터를 각각 36개와 13개로 나누었습니다. 입력 데이터는 2개의 열이 있는 2차원 배열이고 타깃 데이터는 1차원 배열입니다.

 

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

 

13개의 테스트 세트 중에 10개가 도미(1)이고, 3개가 빙어(0)입니다. 잘 섞인 것 같지만 빙어의 비율이 조금 모자랍니다. 원래 도미와 빙어의 개수가 35개와 14개이므로 두 생선의 비율은 2.5:1입니다. 하지만 이 테스트 세트의 도미와 빙어의 비율은 3.3:1입니다. 샘플링 편향이 이번에도 조금 나타났습니다.

 

이처럼 무작위로 데이터를 나누었을 때 샘플이 골고루 섞이지 않을 수 있습니다. 특히 일부 클래스의 개수가 적을 때 이런 일이 생길 수 있습니다. 훈련 세트와 테스트 세트에 샘플의 클래스 비율이 일정하지 않다면 모델이 일부 샘플을 올바르게 학습할 수 없을 것입니다.

 

이럴 때, train_test_split() 함수에 추가로 stratify 매개변수에 타깃 데이터를 전달하면 해결됩니다. 이는 클래스 비율에 맞게 데이터를 나누는 역할을 하며, 훈련 데이터가 작거나 특정 클래스의 샘플 개수가 적을 때 특히 유용합니다.

 

적용하여 다시 훈련 세트와 테스트 세트를 나눈 후, test_target을 확인해보겠습니다.

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

 

빙어가 하나 늘었습니다. 이제 테스트 세트의 비율이 2.25:1이 되었습니다. 드디어 데이터가 모두 준비되었습니다! 

 

3. 수상한 도미 한 마리

준비한 데이터로 k-최근접 이웃을 훈련해 보겠습니다. 훈련 데이터로 모델을 훈련하고 테스트 데이터로 모델을 평가합니다.

 

완벽한 결과입니다. 테스트 세트의 도미와 빙어를 모두 올바르게 분류했습니다. 이 모델에 길이가 25cm이고 무게가 150g인 도미 데이터를 넣어 결과를 확인해 보겠습니다.

 

분명 도미로 예측해야 되나, 빙어로 예측됩니다. 이 샘플을 다른 데이터와 함께 산점도로 그려 보겠습니다.

 

새로운 샘플은 marker 매개변수를 '^'로 지정하여 삼각형으로 나타냈습니다. 이렇게 하면 구분하기 더 쉽습니다.

 

이 샘플은 분명히 오른쪽 위로 뻗어 있는 다름 도미 데이터에 더 가깝습니다. 그럼에도 이 모델은 왼쪽 아래에 낮게 깔린 빙어 데이터에 가깝다고 판단합니다.

 

k-최근접 이웃은 주변의 샘플 중에서 다수인 클래스를 예측으로 사용합니다. 이 샘플의 주변 샘플을 알아보겠습니다. KNeighborsClassifier 클래스는 주어진 샘플에서 가장 가까운 이웃을 찾아주는 kneightbors() 메서드를 제공합니다. 이 메서는 이웃까지의 거리와 이웃 샘플의 인덱스를 반환합니다. KNeighborsClassfier 클래스의 이웃 개수인 n_neighbors의 기본값은 5이므로 5개의 이웃이 반환됩니다.

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

 

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

 

marker = 'D'로 지정하면 산점도를 마름모로 그립니다. 삼각형 샘플에 가장 가까운 5개의 샘플이 초록 다이아몬드로 표시되었습니다. 가장 가까운 이웃에 도미가 하나밖에 포함되지 않았습니다. 나머지 4개의 샘플은 모두 빙어입니다. 직접 데이터를 확인해 보겠습니다.

 

확실히 가장 가까운 생선 4개는 빙어(0)인 것 같습니다. 타깃 데이터로 확인하면 더 명확합니다.

 

길이가 25cm, 무게가 150g인 생선에 가장 가까운 이웃에는 빙어가 압도적으로 많습니다. 따라서 이 샘플의 클래스를 빙어로 예측할 수도 있습니다. 그렇지만 가장 가까운 이웃을 빙어라고 하는 것은 이상합니다.

 

이 문제를 해결하기 위해 kneighbors() 메서드에서 반환한 distance 배열을 출력해 보겠습니다. 이 배열에는 이웃 샘플까지의 거리가 있습니다.

 

값에 무언가 이상한 점이 있습니다. 무엇인지 이상한 점을 눈치채셨나요?

 

4. 기준을 맞춰라

산점도를 다시 천천히 살펴 보겠습니다. 삼각형 샘플에 가장 가까운 첫 번째 샘플까지의 거리는 92이고, 그 외 가장 가까운 샘플들은 모두 130, 138입니다. 그런데 거리가 92와 130이라고 했을 때 그래프에 나타난 거리 비율이 이상합니다.

 

이는 x축은 범위가 좁고 , y축은 범위가 넓기에 발생한 문제입니다. 따라서 y축으로 조금만 멀어져도 거리가 아주 큰 값으로 계산됩니다. 이 때문에 오른쪽 위의 도미 샘플이 이웃으로 선택되지 못했습니다.

 

확인하기 위해 x축의 범위를 y축과 동일하게 0~1,000으로 맞추어 보겠습니다. 맷플룻립에서 x축 범위를 지정하려면 xlim() 함수를 사용합니다. 

 

x축과  y축의 범위를 동일하게 맞추었더니 모든 데이터가 수직으로 늘어선 형태가 되었습니다. 이런 데이터라면 생선의 길이인 x축은 가장 가까운 이웃을 찾는 데 크게 영향을 미치지 못하며, 오로지 생선의 무게인 y축만 고려 대상이 됩니다.

 

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

 

데이터를 표현하는 기준이 다르면 알고리즘이 올바르게 예측할 수 없습니다. 알고리즘이 거리 기반일 때 특히 그렇습니다. 여기에는 k-최근접 이웃도 포함됩니다. 이런 알고리즘은 샘플 간의 거리에 영향을 많이 받으므로 제대로 사용하려면 특성값을 일정한 기준으로 맞춰 주어야 합니다. 이런 작업을 데이터 전처리 라고 부릅니다.

 

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

 

계산하는 방법은 간단합니다. 평균을 빼고 표준편차를 나누어 주면 됩니다. 

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

 

np.mean() 함수는 평균을 계산하고, np.std() 함수는 표준편차를 계산합니다. train_input은 (36,2) 크기의 배열입니다. 특성마다 값의 스케일이 다르므로 평균과 표준편차는 각 특성별로 계산해야 합니다. 이를 위해 axis=0으로 지정합니다. 이렇게 하면 행을 따라 각 열의 통계 값을 계산합니다. 계산된 평균과 표준편차는 아래와 같습니다.

 

각 특성마다 평균과 표준편차가 구해졌습니다. 이제 원본 데이터에서 평균을 빼고 표준편차로 나누어 표준점수로 반환하겠습니다.

train_scaled = (train_input - mean) / std

 

이 식에서 넘파이는  train_input의 모든 행에서 mean에 있는 두 평균값을 빼주고 나서,  그다음 std에 있는 두 표준편차를 다시 모든 행에 적용합니다. 이런 넘파이 기능을 브로드캐스팅이라고 부릅니다.

 

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

앞에서 표준점수로 변환한 trian_scaled를 만들었습니다. 이 데이터와 문제가 되는 샘플을 다시 산점도로 그려 보겠습니다.

 

예상과 또 다르게, 오른쪽 맨 꼭대기에 문제가 되는 하나만 덩그러니 떨어져 있습니다. 하지만 이렇게 된 이유는 사실 당연합니다. 훈련 세트를 mean(평균)으로 빼고 std(표준편차)로 나누어 주었기 때문에 값의 범위가 크게 달라졌습니다. 샘플 [25, 150]을 동일한 비율로 변환하지 않으면 이런 현상이 발생합니다.

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

 

그럼 동일한 기준으로 문제가 되는 샘플을 변환하고 다시 산점도를 그려 보겠습니다.

 

이 그래프는 앞서 표준편차로 변환하기 전의 산점도와 거의 동일하며, 훈련 데이터의 두 특성이 비슷한 범위를 차지하고 있습니다. 이제 이 데이터셋으로 k-최근점 이웃 모델을 다시 훈련해 보겠습니다.

kn.fit(train_scaled, train_target)

 

훈련을 마치고 테스트 세트로 평가할 때는 주의해야 합니다. 앞선 경우와 마찬가지로 테스트 세트도 훈련 세트의 평균과 표준편차로 변환해야 합니다. 그렇지 않다면 데이터의 스케일이 같아지지 않으므로 훈련한 모델이 쓸모없게 됩니다. 

test_scaled = (test_input - mean) / std

 

모델을 평가합니다.

 

모든 테스트 세트의 샘플을 완벽하게 분류했습니다. 다시 문제가 되는 샘플을 예측해보겠습니다. 앞서 훈련 세트의 평균과 표준편차로 변환한 샘플을 사용해 모델의 예측을 출력해보겠습니다.

 

드디어 도미(1)로 예측했습니다. 확실히 길이가 25cm이고 무게가 150g인 생선은 도미일 것입니다.

 

마지막으로 kneighbors() 함수로 이 샘플의 k-최근접 이웃을 구한 다음 산점도로 그려 보겠습니다. 특성을 표준점수로 바꾸었기 때문에 k-최근접 이웃 알고리즘이 올바르게 거리를 측정했을 것입니다. 이로 인해 가장 가까운 이웃에 변화가 생겼을 것으로 기대할 수 있습니다.

 

샘플에서 가장 가까운 샘플은 모두 도미입니다. 따라서 이 수상한 샘플을 도미로 예측하는 것이 당연합니다. 성공입니다! 특성값의 스케일에 민감하지 않고 안정적인 예측을 할 수 있는 모델을 만들었습니다.

 

6. 스케일이 다른 특성 처리

기존에 만든 모델은 완벽하게 테스트 세트를 분류했습니다. 하지만 새로운 샘플에서는 엉뚱하게 빙어라고 예측했습니다. 그래프로 그려보면 이상하게도 이 샘플은 도미에 가깝습니다.

 

이는 샘플의 두 특성인 길이와 무게의 스케일이 다르기 때문입니다. 길이보다 무게의 크기에 따라 예측값이 좌지우지됩니다. 대부분의 머신러닝 알고리즘은 특성의 스케일이 다르면 잘 작동하지 않습니다.

 

이를 위해 특성을 표준점수로 변환했습니다. 사실 특성의 스케일을 조정하는 방법은 표준점수 말고도 더 있습니다. 하지만 대부분의 경우 표준점수로 충분합니다. 또 가장 널리 사용하는 방법입니다. 데이터를 전처리할 때 주의할 점은 훈련 세트를 변환한 방식 그대로 테스트 세트를 변환해야 한다는 것입니다. 그렇지 않으면 특성값이 엉뚱하게 변환될 것이고 훈련 세트로 훈련한 모델이 제대로 동작하지 않을 것입니다.