Python 데이터 전처리: 결측치, 이상치, 정규화
이 글의 핵심
결측치 대체, IQR·Z-Score 이상치 처리, Min-Max 정규화와 표준화, Label·One-Hot 인코딩을 pandas와 scikit-learn 예제로 따라갑니다. 문자열 열이 섞인 표에서 df.mean()이 실패하는 문제, 작은 표본에서 Z-Score가 이상치를 못 잡는 이유, 전체 데이터로 스케일러를 학습해 생기는 데이터 누수처럼 예제에 숨은 함정도 함께 짚습니다.
들어가며
데이터 분석과 머신러닝 프로젝트에서 모델 코드보다 데이터 정리에 더 많은 시간이 든다는 말은 과장이 아닙니다. 실제 데이터에는 빈 칸, 입력 실수로 생긴 극단값, “서울”과 “서울시”처럼 표기가 다른 범주, 원 단위와 만 원 단위가 섞인 열이 흔합니다. 모델은 이런 문제를 스스로 걸러 주지 않고 그대로 패턴으로 학습합니다.
이 글에서는 결측치, 이상치, 스케일링, 범주 인코딩, 피처 생성을 순서대로 다룹니다. 각 방법이 무엇을 가정하는지, 그리고 예제를 그대로 옮겼을 때 어디서 오류나 잘못된 결과가 나는지를 함께 설명합니다. pandas 기본 문법은 Pandas 기초에서 먼저 다뤘습니다.
결측치 확인과 처리
결측치 확인
표 안에 빈 칸(NaN)이 있으면 평균이나 합계 계산이 달라지고, scikit-learn 모델 대부분은 NaN이 있는 입력을 받으면 ValueError: Input X contains NaN으로 학습을 거부합니다. isnull()로 어디가 비었는지 표를 만들고, 열마다 합을 세어 어느 변수부터 손댈지 정합니다.
# 필요한 모듈 import
import pandas as pd
import numpy as np
df = pd.DataFrame({
'name': ['철수', '영희', '민수', None],
'age': [25, None, 28, 30],
'salary': [3000, 4000, None, 5000]
})
# 결측치 확인
print(df.isnull())
print(df.isnull().sum()) # 열별 결측치 개수
개수보다 비율이 판단에 유용합니다. df.isnull().mean()은 열별 결측 비율을 보여 주는데, 몇 퍼센트 수준이면 대체해도 무리가 없지만 절반 이상 비어 있는 열은 대체값이 오히려 가짜 정보를 만들 수 있습니다. 결측이 왜 생겼는지도 확인해야 합니다. 고소득자가 소득 항목을 비워 두는 경향이 있다면 결측 자체가 정보이므로, 평균으로 채우면 그 신호가 지워집니다.
CSV를 읽을 때 빈 칸이 NaN이 아니라 빈 문자열이나 "-", "N/A" 같은 문자열로 들어오는 경우도 흔합니다. 이러면 isnull().sum()이 0으로 나와 결측이 없는 것처럼 보이고, 숫자 열의 dtype이 object로 잡힙니다. pd.read_csv(..., na_values=['-', 'N/A'])로 결측 표기를 지정하거나 df.dtypes를 먼저 확인하는 습관이 필요합니다.
결측치 처리
# 방법 1: 제거
df_dropped = df.dropna() # 결측치 있는 행 제거
df_dropped = df.dropna(axis=1) # 결측치 있는 열 제거
# 방법 2: 채우기
df_filled = df.fillna(0) # 0으로 채우기
df_filled = df.fillna(df.mean(numeric_only=True)) # 수치형 열을 평균으로 채우기
df_filled = df.ffill() # 앞 값으로 채우기
# 방법 3: 보간
df['age'] = df['age'].interpolate()
제거는 가장 단순하지만 데이터가 줄어듭니다. 이 예제의 dropna()는 네 행 모두 어딘가에 결측이 있어서 빈 표가 되고, dropna(axis=1)도 모든 열에 결측이 있어 열이 하나도 남지 않습니다. 결측이 여러 열에 흩어져 있으면 행 제거만으로 데이터 대부분을 잃을 수 있으므로, subset=['salary']처럼 꼭 필요한 열만 기준으로 삼는 편이 낫습니다.
0으로 채우기는 “값이 없음”과 “값이 0”을 같게 취급합니다. 구매 횟수처럼 결측이 실제로 0을 뜻하는 경우가 아니라면 평균을 크게 끌어내립니다. 평균 대체에서 numeric_only=True를 붙인 이유는, pandas 2.0부터 문자열 열이 섞인 표에서 df.mean()을 호출하면 TypeError가 나기 때문입니다(이전 버전은 문자열 열을 조용히 건너뛰었습니다). 소득처럼 한쪽으로 치우친 분포는 평균보다 median()이 대표값으로 안정적입니다.
앞 값 채우기(ffill)와 보간(interpolate)은 행 순서에 의미가 있는 시계열 데이터용입니다. 센서 값처럼 시간 순으로 정렬된 데이터에서는 자연스럽지만, 이 예제처럼 사람별 행이 나열된 표에서 쓰면 앞 사람의 나이를 뒷사람에게 복사하는 셈입니다. 예전에 쓰던 fillna(method='ffill')는 pandas 2.1부터 폐기 예정 경고가 나오므로 ffill()을 씁니다.
IQR과 Z-Score로 이상치 처리
IQR 방법
# IQR (Interquartile Range)
Q1 = df['salary'].quantile(0.25)
Q3 = df['salary'].quantile(0.75)
IQR = Q3 - Q1
# 이상치 범위
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
# 이상치 제거
df_clean = df[
(df['salary'] >= lower_bound) &
(df['salary'] <= upper_bound)
]
IQR은 가운데 50% 데이터가 퍼진 폭입니다. 사분위수는 극단값 몇 개에 거의 영향을 받지 않기 때문에, 평균과 표준편차를 쓰는 방법보다 이상치 자체에 휘둘리지 않는 경계를 만들 수 있습니다. 1.5배라는 계수는 박스플롯의 수염 길이에서 온 관례일 뿐이라, 데이터에 따라 3배로 넓혀 “극단적인 값”만 거르기도 합니다.
비교 조건에서 NaN은 항상 False가 되므로, 위 필터는 이상치뿐 아니라 salary가 결측인 행도 함께 지웁니다. 결측 처리를 먼저 했는지, 결측 행을 남기고 싶은지에 따라 | df['salary'].isna() 조건을 추가해야 합니다.
더 근본적인 질문은 이상치를 지워야 하는가입니다. 나이 250세처럼 입력 오류가 분명한 값은 제거하거나 결측으로 바꾸는 것이 맞지만, 매출 데이터의 대량 주문처럼 드물지만 실제로 일어난 값을 지우면 모델이 중요한 사례를 못 보게 됩니다. 이런 경우 경계값으로 잘라 붙이는 clip(lower, upper)(winsorizing)이나 로그 변환이 대안입니다. 이상치 탐지 결과는 Matplotlib으로 박스플롯이나 히스토그램을 그려 눈으로 확인한 뒤 결정하는 것이 안전합니다.
Z-Score 방법
from scipy import stats
# Z-Score 계산
salary = df['salary'].dropna()
z_scores = np.abs(stats.zscore(salary))
# |Z| > 3인 것을 이상치로 간주
df_clean = df.loc[salary.index[z_scores < 3]]
Z-Score는 각 값이 평균에서 표준편차 몇 배만큼 떨어져 있는지입니다. 정규분포라면 |Z| > 3인 값은 약 0.3%뿐이라 이상치 기준으로 씁니다. 결측을 뺀 salary로 계산하면 결과 배열의 길이가 원래 df와 달라지므로, 불리언 배열을 df[...]에 바로 넣으면 ValueError: Item wrong length 3 instead of 4. 같은 길이 불일치 오류가 납니다. 위 코드처럼 salary.index로 원래 행 번호를 되찾아 선택해야 합니다.
Z-Score 방식에는 잘 알려진 한계가 두 가지 있습니다. 첫째, 이상치가 평균과 표준편차를 함께 부풀려서 자기 자신의 Z값을 낮춥니다. 극단값이 클수록 오히려 잘 안 걸리는 역설이 생깁니다. 둘째, 표본이 작으면 수학적으로 |Z|가 3을 넘을 수 없습니다. scipy.stats.zscore의 기본 설정(모표준편차, ddof=0) 기준으로 n개 데이터의 최대 |Z|는 √(n−1)을 넘지 못하므로, 데이터가 10개 이하면 어떤 값도 이상치로 잡히지 않습니다. 이 예제처럼 몇 행짜리 데이터로 테스트하면 “Z-Score가 아무것도 안 걸러요”라는 결과가 나오는 것이 정상입니다. 치우친 분포나 작은 데이터에는 IQR이 더 믿을 만합니다.
Min-Max 정규화와 표준화
Min-Max 정규화 (0-1)
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
df['salary_normalized'] = scaler.fit_transform(df[['salary']])
print(df[['salary', 'salary_normalized']])
스케일링이 필요한 이유는 모델이 숫자의 크기를 그대로 비교하기 때문입니다. 나이(2060)와 연봉(30009000)을 함께 넣으면, KNN이나 K-means 같은 거리 기반 모델은 거리를 거의 연봉 차이만으로 계산하고 나이는 무시하다시피 합니다. 경사 하강법을 쓰는 선형 모델과 신경망도 열마다 크기가 다르면 학습이 느리거나 불안정해집니다.
Min-Max는 (x - min) / (max - min)으로 모든 값을 0~1에 넣습니다. 이미지 픽셀처럼 범위가 정해진 데이터에 잘 맞지만, 최댓값 하나가 극단적으로 크면 나머지 값이 0 근처로 몰립니다. df[['salary']]처럼 대괄호를 두 번 쓴 이유는 scikit-learn이 2차원 입력(행 × 열)을 기대하기 때문이며, df['salary']로 넘기면 “Expected 2D array, got 1D array instead” 오류가 납니다. scikit-learn 스케일러는 NaN을 무시하고 통계를 계산한 뒤 결측은 그대로 NaN으로 남깁니다.
표준화 (평균 0, 표준편차 1)
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
df['salary_standardized'] = scaler.fit_transform(df[['salary']])
print(df[['salary', 'salary_standardized']])
표준화는 (x - 평균) / 표준편차로 변환합니다. 결과 범위가 정해져 있지 않은 대신 극단값 하나에 전체가 찌그러지는 정도가 Min-Max보다 덜합니다. 이상치가 많다면 중앙값과 IQR을 쓰는 RobustScaler도 선택지입니다. 결정 트리, 랜덤 포레스트, XGBoost 같은 트리 기반 모델은 값의 순서로만 분기를 나누기 때문에 스케일링을 해도 결과가 거의 달라지지 않습니다.
여기서 가장 중요한 원칙은 스케일러를 학습 데이터로만 fit하는 것입니다. 전체 데이터로 fit_transform한 뒤 학습·테스트로 나누면 테스트 데이터의 평균과 분산 정보가 학습에 섞여 들어가는데, 이를 데이터 누수(data leakage)라고 합니다. 검증 점수가 실제보다 좋게 나오고, 서비스에 배포한 뒤에야 성능이 떨어지는 것을 알게 됩니다. 학습 데이터로 scaler.fit(X_train)을 한 뒤 테스트와 실제 입력에는 transform만 적용해야 합니다. 처음 모델을 만들 때 이 실수를 하기 쉬운데, 코드가 오류 없이 돌아가고 점수도 좋게 나오기 때문에 스스로 알아채기 어렵습니다.
Label Encoding과 One-Hot Encoding
Label Encoding
from sklearn.preprocessing import LabelEncoder
df = pd.DataFrame({
'city': ['서울', '부산', '서울', '대구', '부산']
})
encoder = LabelEncoder()
df['city_encoded'] = encoder.fit_transform(df['city'])
print(df)
# city city_encoded
# 0 서울 2
# 1 부산 1
# 2 서울 2
# 3 대구 0
# 4 부산 1
LabelEncoder는 범주를 정렬한 순서대로 0부터 번호를 붙입니다. 한글은 유니코드 순서라 대구(0), 부산(1), 서울(2)이 됩니다. encoder.classes_로 번호와 원래 값의 대응을 확인할 수 있고, inverse_transform으로 되돌릴 수 있습니다.
문제는 이 번호에 크기가 생긴다는 점입니다. 선형 모델은 “서울(2)은 대구(0)의 두 배”처럼 의미 없는 관계를 학습할 수 있습니다. 순서가 있는 범주라도 LabelEncoder는 문자열 정렬 순서로 번호를 매기므로 ['low', 'mid', 'high']가 high(0), low(1), mid(2)가 되어 버립니다. 의미 있는 순서를 주려면 df['grade'].map({'low': 0, 'mid': 1, 'high': 2})처럼 직접 매핑하거나 OrdinalEncoder(categories=[['low', 'mid', 'high']])를 씁니다. 참고로 scikit-learn 문서상 LabelEncoder는 입력 피처가 아니라 타깃 레이블(y) 인코딩용으로 설계되었습니다.
학습 때 없던 범주가 새 데이터에 나오면 transform이 ValueError: y contains previously unseen labels로 실패하는 것도 운영에서 자주 겪는 문제입니다.
One-Hot Encoding
# Pandas 사용
df_encoded = pd.get_dummies(df, columns=['city'])
print(df_encoded)
# city_encoded city_대구 city_부산 city_서울
# 0 2 False False True
# 1 1 False True False
# 2 2 False False True
# ... (pandas 2.0 이상은 True/False, dtype=int를 주면 0/1)
# Scikit-learn 사용
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder(sparse_output=False)
encoded = encoder.fit_transform(df[['city']])
One-Hot은 범주마다 열을 하나씩 만들어 해당하는 칸만 1(True)로 표시하므로 번호의 크기 문제가 없습니다. pandas 2.0부터 get_dummies의 결과는 불리언이며, 정수가 필요하면 dtype=int를 넘깁니다.
pd.get_dummies는 간편하지만 그 자리에 있는 데이터의 범주만 보고 열을 만듭니다. 학습 데이터에 서울·부산·대구가 있었는데 새 데이터에 서울만 있으면 열이 하나만 생겨 모델 입력의 열 개수가 달라집니다. 학습과 예측을 분리하는 실제 파이프라인에서는 학습 데이터로 fit한 OneHotEncoder를 저장해 두고 transform만 쓰며, handle_unknown='ignore'를 주면 처음 보는 범주는 모든 열을 0으로 처리합니다. 범주가 수천 개인 열(우편번호, 상품 ID)은 One-Hot으로 열이 폭증하므로 빈도 기반 묶기나 타깃 인코딩을 검토합니다.
새로운 피처 만들기
새로운 피처 생성
df = pd.DataFrame({
'date': pd.date_range('2024-01-01', periods=100),
'sales': np.random.randint(100, 500, 100)
})
# 날짜에서 피처 추출
df['year'] = df['date'].dt.year
df['month'] = df['date'].dt.month
df['day_of_week'] = df['date'].dt.dayofweek
df['is_weekend'] = df['day_of_week'].isin([5, 6]).astype(int)
# 이동 평균
df['sales_ma7'] = df['sales'].rolling(window=7).mean()
# 차분
df['sales_diff'] = df['sales'].diff()
날짜 자체는 모델이 쓰기 어려운 값이지만, 요일·월·주말 여부로 쪼개면 “주말에 매출이 높다” 같은 패턴을 학습할 수 있게 됩니다. dt.dayofweek는 월요일이 0, 일요일이 6이라서 isin([5, 6])이 토·일요일입니다. .dt 접근자는 열이 datetime64 타입일 때만 동작하므로, CSV에서 읽은 문자열 날짜라면 먼저 pd.to_datetime()으로 변환해야 합니다. 그렇지 않으면 “Can only use .dt accessor with datetimelike values” 오류가 납니다.
rolling(window=7).mean()은 처음 6행이 NaN이고, diff()는 첫 행이 NaN입니다. 새 피처를 만든 뒤 결측이 생긴다는 점을 잊고 모델에 넣으면 결측치 절에서 본 오류가 다시 납니다. 또 이동 평균은 현재 행을 포함한 과거 7일 평균인데, 당일 매출을 예측하는 모델이라면 당일 값이 피처에 섞여 들어가 미래 정보 누수가 됩니다. 예측 시점에 알 수 있는 값만 쓰려면 shift(1)로 하루 밀어서 사용합니다.
전처리 파이프라인 함수로 묶기
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler, LabelEncoder
def preprocess_data(df):
"""데이터 전처리 파이프라인"""
# 1. 결측치 처리
numeric_cols = df.select_dtypes(include=[np.number]).columns
df[numeric_cols] = df[numeric_cols].fillna(df[numeric_cols].mean())
categorical_cols = df.select_dtypes(include=['object']).columns
df[categorical_cols] = df[categorical_cols].fillna('Unknown')
# 2. 이상치 제거 (IQR)
for col in numeric_cols:
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
df = df[(df[col] >= lower) & (df[col] <= upper)]
# 3. 범주형 인코딩
for col in categorical_cols:
le = LabelEncoder()
df[f'{col}_encoded'] = le.fit_transform(df[col])
# 4. 정규화
scaler = StandardScaler()
df[numeric_cols] = scaler.fit_transform(df[numeric_cols])
return df
# 사용
raw_data = pd.read_csv('raw_data.csv')
clean_data = preprocess_data(raw_data)
clean_data.to_csv('clean_data.csv', index=False)
앞 절의 단계를 한 함수로 묶은 예제입니다. 순서에는 이유가 있습니다. 결측을 먼저 채워야 이상치 비교에서 NaN 행이 조용히 사라지지 않고, 이상치를 제거한 뒤 스케일링해야 극단값이 평균과 표준편차를 왜곡하지 않습니다.
다만 이 함수를 그대로 쓰기 전에 알아 둘 점이 있습니다.
- 원본이 바뀝니다. 1단계의
df[numeric_cols] = ...는 인자로 받은raw_data를 직접 수정합니다. 함수 첫 줄에df = df.copy()를 두는 것이 안전합니다. 2단계에서df = df[...]로 필터링한 뒤 새 열을 추가하면SettingWithCopyWarning이 나오는 것도 같은 문제에서 비롯됩니다. - 열마다 차례로 행을 지웁니다. 수치형 열이 여러 개면 열마다 IQR 필터가 누적되어, 각 열에서 조금씩만 걸러도 전체 행이 크게 줄 수 있습니다. 각 단계 후
len(df)를 출력해 얼마나 줄었는지 확인하세요. - 원래 범주형 열이 남습니다. 인코딩된 열을 추가만 하고 원래 문자열 열은 지우지 않으므로, 이 결과를 그대로 모델에 넣으면 “could not convert string to float” 오류가 납니다.
- 학습과 예측에 같은 기준을 쓸 수 없습니다. 평균, IQR 경계, 인코더, 스케일러를 매번 새로 계산하므로 새 데이터에는 다른 기준이 적용되고, 전체 데이터로 계산하면 정규화 절에서 말한 누수가 생깁니다.
마지막 문제를 제대로 풀려면 scikit-learn의 Pipeline과 ColumnTransformer로 SimpleImputer, OneHotEncoder, StandardScaler를 묶고, 학습 데이터에 fit한 파이프라인 객체를 저장해 예측 때 transform만 호출합니다. 이상치 제거처럼 행 수를 바꾸는 작업은 파이프라인에 넣기 어려우므로 학습 데이터 정리 단계에서 따로 처리하는 것이 일반적입니다.
전처리 순서를 정리한 체크리스트
머신러닝 전처리는 요리 전에 재료를 손질하는 과정과 비슷합니다. 먼저 info·describe로 표를 읽으며, 결측·이상치·범주 인코딩·스케일링을 모델 가정에 맞게 순서대로 적용합니다. 거리 기반 모델은 스케일이 특히 민감하며, 트리 계열은 상대적으로 덜한 편입니다.
# ✅ 1. 데이터 확인
df.info()
df.describe()
df.isnull().sum()
# ✅ 2. 결측치 처리
# - 제거 vs 대체 결정
# - 도메인 지식 활용
# ✅ 3. 이상치 처리
# - 시각화로 확인
# - IQR 또는 Z-Score
# ✅ 4. 인코딩
# - 순서 있음: 직접 매핑 또는 OrdinalEncoder
# - 순서 없음: One-Hot Encoding
# ✅ 5. 스케일링
# - 거리 기반 모델: 필수
# - 트리 기반 모델: 선택
df.info()는 열별 dtype과 결측이 아닌 값의 개수를, df.describe()는 수치형 열의 평균·사분위수·최솟값·최댓값을 보여 줍니다. 최솟값이 음수인 나이, 최댓값이 99999인 소득처럼 “센티넬 값”으로 결측을 표시한 흔적은 대부분 describe()의 min/max에서 처음 발견됩니다. 그리고 가능한 한 이 모든 단계보다 먼저 학습·테스트 데이터를 나눠 두는 것이 누수를 막는 가장 확실한 방법입니다.
데이터 전처리 요약
- 결측치: 비율과 원인을 먼저 확인하고 제거, 대체, 보간 중 선택.
df.mean(numeric_only=True),ffill()사용 - 이상치: IQR은 치우친 분포와 작은 데이터에 강하고, Z-Score는 이상치에 스스로 끌려가는 한계가 있음
- 정규화: Min-Max (0-1), 극단값에 민감
- 표준화: 평균 0, 표준편차 1. 스케일러는 학습 데이터로만
fit - 인코딩: 순서 없는 범주는 One-Hot, 순서 있는 범주는 직접 매핑. 학습 때 만든 인코더를 예측에도 재사용
다음 단계
같이 보면 좋은 글
- Pandas 기초 | Python 데이터 분석 라이브러리
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- Python 예외 처리 | try-except, raise, 커스텀 예외
- Python 데코레이터 | @decorator 동작 원리
자주 묻는 질문 (FAQ)
Q. Label Encoding과 One-Hot Encoding 중 무엇을 써야 하나요?
A. Label Encoding은 범주를 0, 1, 2 같은 정수로 바꾸기 때문에 모델이 숫자의 크기와 순서에 의미가 있다고 받아들일 수 있습니다. 그래서 도시 이름처럼 순서가 없는 범주에는 pd.get_dummies나 OneHotEncoder로 One-Hot Encoding을 쓰는 것이 기본입니다. 등급처럼 순서가 있는 범주라도 LabelEncoder는 알파벳·유니코드 정렬 순서로 번호를 매기므로, 의미 있는 순서를 주려면 직접 매핑하거나 OrdinalEncoder에 순서를 지정하세요. 범주 종류가 매우 많으면 One-Hot은 열이 크게 늘어나므로 모델과 데이터 크기를 함께 고려해야 합니다.