포스트

머신러닝 기초 (12) - 전처리: 스케일링, 인코딩, 결측치

스케일링, 인코딩, 결측치 처리가 각각 왜 필요한지, StandardScaler, OneHotEncoder, SimpleImputer를 언제 쓰는지, 그리고 fit은 왜 훈련 데이터로만 해야 하는지를 정리하는 12편입니다.

머신러닝 기초 (12) - 전처리: 스케일링, 인코딩, 결측치

머신러닝 기초 시리즈의 12편입니다. 11편의 “앙상블: 랜덤포레스트와 부스팅”에 이어집니다.

모델은 정제된 입력을 기대한다

9편부터 11편까지 모델의 안을 열었습니다. 선형 모델, 결정트리, 앙상블이 데이터에서 무엇을 학습하는지를 봤습니다. 그런데 그 예제들은 모두 load_breast_cancerload_diabetes처럼 이미 깨끗하게 정리된 데이터를 썼습니다. 숫자로만 이루어져 있고, 빈칸이 없고, 값의 범위도 크게 문제되지 않는 데이터입니다.

현실의 데이터는 그렇지 않습니다. 특성마다 단위가 다르고(나이는 두 자리, 연봉은 여덟 자리), 문자열로 된 범주가 섞여 있고(직군, 지역), 빈칸이 군데군데 있습니다. 대부분의 모델은 이 상태의 데이터를 그대로 받지 못합니다. 학습 전에 입력을 모델이 기대하는 형태로 바꿔줘야 하고, 그 작업이 전처리(preprocessing)입니다.

이 편은 가장 기본이 되는 세 가지 전처리를 다룹니다. 스케일링(수치 특성의 범위 맞추기), 인코딩(범주형을 숫자로), 결측치 처리(빈칸 채우기)입니다. 각각이 왜 필요한지를 먼저 세우고, 어떤 상황에 어떤 도구를 쓰는지를 정리합니다.

스케일링: 왜 특성의 스케일을 맞추는가

load_breast_cancer의 특성들은 스케일이 제각각입니다. 세포핵의 평균 면적(mean area)은 수백에서 수천 단위이고, 평균 매끄러움(mean smoothness)은 0.1 언저리입니다. 두 특성의 크기가 1만 배쯤 차이 납니다.

이 차이가 문제가 되는 모델이 있습니다.

  • 거리 기반 모델: KNN처럼 관측치 사이의 거리로 판단하는 모델은, 스케일이 큰 특성이 거리를 지배합니다. 면적이 300 차이나면 매끄러움이 0.05 차이나는 것은 거리 계산에서 사실상 묻힙니다. 모델이 면적만 보고 판단하는 셈이 됩니다.
  • 규제 기반 모델: 6편에서 봤듯 Ridge와 Lasso는 계수 크기를 벌합니다. 스케일이 큰 특성은 계수가 자연히 작아져서 페널티가 특성마다 불공평하게 걸립니다.
  • 경사하강법으로 학습하는 모델: 3편에서 다룬 경사하강법은 특성 스케일이 제각각이면 손실 곡면이 길쭉하게 찌그러져 수렴이 느려집니다.

이 세 부류의 공통점은 특성들을 같은 기준 위에 놓고 비교한다는 점입니다. 특성마다 척도가 다르면 비교가 왜곡됩니다. 스케일링은 모든 특성을 같은 척도로 맞추는 작업입니다.

반대로 트리 기반 모델은 스케일링이 필요 없습니다. 10편에서 봤듯 트리는 특성 < 임계값 형태로 분기합니다. 특성에 어떤 단조 변환(순서를 유지하는 변환)을 해도 값들의 순서가 그대로라 같은 분기점을 찾습니다. 면적을 1000으로 나눠도 “면적이 어느 값보다 큰가”라는 질문의 답은 바뀌지 않습니다. 그래서 결정트리, 랜덤포레스트, 부스팅(11편)에는 스케일링을 하지 않아도 됩니다.

StandardScaler와 MinMaxScaler

가장 널리 쓰는 두 스케일러는 방식이 다릅니다.

StandardScaler(표준화): 각 특성에서 평균을 빼고 표준편차로 나눕니다.

1
2
# z = (x - 평균) / 표준편차
# 결과: 특성마다 평균 0, 표준편차 1. 값의 범위는 제한하지 않음

MinMaxScaler(정규화): 각 특성을 최솟값 0, 최댓값 1이 되도록 선형 압축합니다.

1
2
# x_scaled = (x - 최솟값) / (최댓값 - 최솟값)
# 결과: 특성마다 [0, 1] 범위. 범위가 고정됨

선택 기준은 이렇습니다. StandardScaler를 기본값으로 쓰면 대체로 무난합니다. MinMaxScaler는 입력이 특정 범위여야 하는 경우(일부 신경망, 픽셀 값 등)에 씁니다. 다만 MinMaxScaler는 최솟값과 최댓값이라는 양 끝값에 의존하므로, 이상치가 하나만 있어도 나머지 값들이 좁은 구간에 뭉칩니다. 이상치가 걱정되면 중앙값과 사분위수로 스케일하는 RobustScaler가 더 안전합니다.

스케일링은 특성의 분포 모양을 바꾸지 않습니다. 평균과 범위만 옮길 뿐, 치우친 분포는 스케일링 후에도 여전히 치우쳐 있습니다. 분포 자체를 펴는 것은 로그 변환 같은 다른 작업입니다.

코드: 스케일링이 KNN을 바꾼다

스케일링의 효과를 거리 기반 모델인 KNN으로 확인합니다. load_breast_cancer에 스케일링을 하지 않은 경우와 한 경우를 비교합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier

data = load_breast_cancer()
X, y = data.data, data.target
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y)

# 스케일 차이 확인
X_train[:, 3].mean()   # mean area = 650.8
X_train[:, 4].mean()   # mean smoothness = 0.0958. 1만 배 차이

면적과 매끄러움의 평균이 1만 배 차이 납니다. 이 상태로 KNN을 학습하면 거리는 면적이 지배합니다.

1
2
knn = KNeighborsClassifier().fit(X_train, y_train)
knn.score(X_test, y_test)   # 스케일링 없이 0.924

이제 StandardScaler로 모든 특성을 표준화하고 다시 학습합니다.

1
2
3
4
5
6
scaler = StandardScaler().fit(X_train)      # 훈련 데이터로 평균과 표준편차 학습
X_train_s = scaler.transform(X_train)
X_test_s = scaler.transform(X_test)

knn_s = KNeighborsClassifier().fit(X_train_s, y_train)
knn_s.score(X_test_s, y_test)   # 스케일링 후 0.959

정확도가 0.924에서 0.959로 올랐습니다. 모델도, 하이퍼파라미터도, 데이터 분할도 그대로입니다. 바뀐 것은 특성을 같은 척도로 맞췄다는 것뿐입니다. 거리 기반 모델에서 스케일링이 선택이 아니라 필수인 이유입니다.

fit은 훈련 데이터로만, transform은 각각

위 코드에서 한 줄이 결정적입니다. scaler.fit(X_train)으로 스케일러를 훈련 데이터에만 fit 했습니다. 왜 테스트 데이터를 빼는지가 전처리 전체를 관통하는 원칙입니다.

스케일러의 동작은 두 단계로 나뉩니다.

  • fit: 데이터에서 통계를 학습합니다. StandardScaler라면 특성마다 평균과 표준편차를, MinMaxScaler라면 최솟값과 최댓값을 구해 기억합니다.
  • transform: 기억한 통계를 써서 실제로 값을 변환합니다.

fit이 학습하는 평균과 표준편차는 데이터에 대한 정보입니다. 그런데 테스트셋은 “아직 보지 못한 미래 데이터”를 흉내 내는 역할입니다. fit에 테스트셋을 포함하면 미래 데이터의 분포 정보가 전처리 단계로 새어 들어옵니다. 그러면 테스트 점수가 실제보다 좋게 나오고, 실전에서는 그만큼 무너집니다. 이것이 데이터 누수(leakage)이고, 13편에서 정면으로 다룹니다.

그래서 규칙은 하나입니다. fit은 훈련 데이터로만 하고, transform은 훈련과 테스트에 각각 (같은 통계로) 적용한다. 결과를 직접 보면 이 원칙이 눈에 보입니다.

1
2
X_train_s.mean(), X_train_s.std()   # (-0.0, 1.0). 훈련은 정확히 평균 0, 표준편차 1
X_test_s.mean()                     # 0.0309. 테스트는 정확히 0이 아니다

테스트셋을 표준화한 결과의 평균은 0.0309로, 정확히 0이 아닙니다. 훈련셋의 평균으로 뺐기 때문입니다. 테스트셋의 평균은 훈련셋의 평균과 조금 다르니 0에서 살짝 벗어나는 것이 정상입니다. MinMaxScaler에서는 더 분명합니다.

1
2
3
4
5
from sklearn.preprocessing import MinMaxScaler

mm = MinMaxScaler().fit(X_train)
mm.transform(X_train).min(), mm.transform(X_train).max()   # (0.0, 1.0)
mm.transform(X_test).min(),  mm.transform(X_test).max()    # (-0.124, 2.467)

훈련셋은 정확히 [0, 1]에 들어가지만, 테스트셋은 -0.124에서 2.467까지 범위를 벗어납니다. 훈련셋의 최솟값과 최댓값으로 변환했으니, 테스트셋에 그보다 작거나 큰 값이 있으면 [0, 1] 밖으로 나가는 것이 맞습니다. 이걸 버그로 여겨 테스트셋에 따로 fit 하면 그 순간 누수가 됩니다.

fit과 transform을 이렇게 손으로 나눠 관리하는 것은 교차검증(5편)처럼 분할이 여러 번 반복되면 실수하기 쉽습니다. 그래서 scikit-learn은 전처리와 모델을 하나로 묶어 이 순서를 자동으로 지키는 Pipeline을 제공합니다. 자세한 사용법은 13편에서 다룹니다.

범주형 인코딩: 문자열을 숫자로

모델은 숫자만 받습니다. "서울", "부산" 같은 문자열 범주는 그대로 넣을 수 없으니 숫자로 바꿔야 합니다. 방식은 두 가지이고, 범주에 순서가 있는지에 따라 갈립니다.

OrdinalEncoder: 각 범주에 정수 하나를 배정합니다. S→0, M→1, L→2 식입니다.

1
2
3
4
5
6
import numpy as np
from sklearn.preprocessing import OrdinalEncoder

sizes = np.array([["S"], ["L"], ["M"], ["S"], ["XL"]])
oe = OrdinalEncoder(categories=[["S", "M", "L", "XL"]])   # 순서를 직접 지정
oe.fit_transform(sizes).ravel()   # [0. 2. 1. 0. 3.]. S<M<L<XL 순서가 정수에 반영

정수는 크기를 가지므로, 이 인코딩은 범주 사이에 순서를 부여합니다. 옷 사이즈(S < M < L < XL)나 등급(하 < 중 < 상)처럼 실제로 순서가 있는 범주(순서형, ordinal)에는 이 순서가 정보가 됩니다. 순서를 지정하지 않으면 알파벳 순으로 배정되므로, 순서가 의미 있을 때는 categories로 직접 지정합니다.

OneHotEncoder: 범주마다 0/1 열을 하나씩 만듭니다. 값이 그 범주면 1, 아니면 0입니다.

1
2
3
4
5
6
7
8
9
10
from sklearn.preprocessing import OneHotEncoder

colors = np.array([["red"], ["green"], ["blue"], ["red"]])
ohe = OneHotEncoder(sparse_output=False)
ohe.fit_transform(colors)
# [[0. 0. 1.]     ← red
#  [0. 1. 0.]     ← green
#  [1. 0. 0.]     ← blue
#  [0. 0. 1.]]    ← red
ohe.get_feature_names_out()   # ['x0_blue' 'x0_green' 'x0_red']. 범주당 열 하나

색깔처럼 순서가 없는 범주(명목형, nominal)에는 OneHotEncoder가 맞습니다. 여기에 OrdinalEncoder를 쓰면 red=0, green=1, blue=2가 되어 모델이 “green은 red와 blue의 중간”이라는 없는 순서를 학습합니다. 선형 모델이나 거리 기반 모델에서 이 가짜 순서는 실제로 예측을 왜곡합니다. OneHotEncoder는 각 범주를 독립된 열로 분리해 이 문제를 없앱니다.

트리 기반 모델은 순서형/명목형에 덜 민감합니다. 분기 조건이 임계값 비교라, 명목형에 OrdinalEncoder를 써도 가짜 순서에 크게 휘둘리지 않고 잘 동작하는 경우가 많습니다. 열 개수를 늘리지 않아 고윳값이 많은 범주에서 특히 실용적입니다.

정리하면 선택 기준은 범주에 순서가 있는가, 그리고 어떤 모델에 넣는가입니다.

  • 순서가 있는 범주(순서형) → OrdinalEncoder로 그 순서를 살린다.
  • 순서가 없는 범주(명목형) + 선형과 거리 기반 모델 → OneHotEncoder로 가짜 순서를 피한다.
  • 트리 기반 모델 → OrdinalEncoder도 무난하고, 열이 늘지 않아 실용적이다.

한 가지 실전 함정이 있습니다. 훈련셋에 없던 범주가 테스트셋에 나타나면 인코더가 에러를 냅니다. 새 지역, 새 상품처럼 실제로 흔한 상황입니다. OneHotEncoder는 handle_unknown="ignore"로 처음 보는 범주를 모두 0인 열로 처리하게 할 수 있습니다.

1
2
3
4
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore").fit(colors)
ohe.transform(np.array([["red"], ["yellow"]]))
# [[0. 0. 1.]     ← red: 정상 인코딩
#  [0. 0. 0.]]    ← yellow: 훈련에 없던 범주라 전부 0

인코더 역시 스케일러와 같은 원칙을 따릅니다. fit은 훈련 데이터로만. 어떤 범주들이 존재하는지는 훈련셋에서 배우고, 테스트셋은 그 목록으로 변환합니다.

결측치 처리: SimpleImputer

모델은 NaN(빈칸)도 그대로 받지 못합니다. 빈칸이 있는 행을 버리거나, 어떤 값으로 채워야 합니다. SimpleImputer는 채우는 쪽을 담당하며, strategy로 무엇으로 채울지를 정합니다.

  • strategy="mean": 열의 평균
  • strategy="median": 열의 중앙값(이상치가 있으면 평균보다 안전)
  • strategy="most_frequent": 최빈값(범주형에 사용)
  • strategy="constant": 지정한 상수(fill_value)

load_diabetes에는 결측이 없으니 임의로 10%를 비운 뒤 중앙값으로 채웁니다. 임퓨터도 fit에서 채울 값(여기서는 열별 중앙값)을 훈련 데이터로부터 학습하고, transform에서 적용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from sklearn.impute import SimpleImputer

X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42)

rng = np.random.default_rng(42)
X_train_miss = X_train.copy()
X_train_miss[rng.random(X_train.shape) < 0.1] = np.nan   # 약 10%를 결측으로
np.isnan(X_train_miss).sum()   # 빈칸 개수 = 358

imp = SimpleImputer(strategy="median").fit(X_train_miss)  # 훈련 데이터로 중앙값 학습
X_train_f = imp.transform(X_train_miss)
np.isnan(X_train_f).sum()      # 0. 모두 채워짐

주의할 점은 문법이 아니라 판단입니다. 위에서 median을 고른 것은 이상치에 견고하기 때문이지만, 애초에 “이 값을 통계값으로 추정해도 되는가”는 결측이 왜 생겼는지에 달려 있습니다. 값이 실제로 0이라서 비었는지, 기록이 누락됐는지, 특정 조건에서만 체계적으로 빠졌는지에 따라 올바른 처리가 전혀 다릅니다. 이 판단 기준은 Pandas 기초 4편에서 표로 정리했습니다. SimpleImputer는 그 판단이 끝난 뒤 실행을 맡는 도구입니다.

결측치 처리도 같은 원칙 위에 있습니다. 채울 값(평균, 중앙값, 최빈값)은 훈련 데이터의 통계이므로 fit은 훈련 데이터로만 합니다. 테스트셋의 빈칸도 훈련셋에서 배운 값으로 채웁니다.

정리

세 전처리는 대상이 다르지만 하나의 원칙을 공유합니다. 무엇을 어떻게 바꿀지는 훈련 데이터로만 배우고(fit), 그 규칙을 훈련과 테스트에 각각 적용합니다(transform).

개념한 줄 요약
스케일링이 필요한 모델거리 기반(KNN), 규제 기반(Ridge/Lasso), 경사하강법 모델. 특성을 같은 척도로 맞춘다
스케일링이 불필요한 모델트리 기반. 분기가 순서만 보므로 단조 변환에 불변
StandardScaler(x-평균)/표준편차 → 평균 0, 표준편차 1. 기본 선택
MinMaxScaler(x-최솟값)/범위 → [0,1]. 범위 고정, 이상치에 취약
OrdinalEncoder범주에 정수 배정. 순서형에 적합, 트리 모델에도 무난
OneHotEncoder범주당 0/1 열. 명목형 + 선형과 거리 모델에 적합, 가짜 순서 방지
SimpleImputer결측을 평균, 중앙값, 최빈값 등으로 채움. 무엇으로 채울지는 결측 원인에 달림
fit/transform 원칙fit은 훈련 데이터로만, transform은 훈련과 테스트에 각각. 어기면 데이터 누수

다음 편에서는 이 편에서 반복해 강조한 “fit은 훈련 데이터로만”을 정면으로 다룹니다. 전처리가 새어 들어가는 데이터 누수가 어떻게 발생하는지, 그리고 Pipeline으로 어떻게 원천 차단하는지를 봅니다.

다음 글: 머신러닝 기초 (13) - 데이터 누수와 파이프라인

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.