본문으로 건너뛰기

03-020. 이상치 처리

이상치 처리 - Outlier handling

이상치(Outlier) 처리는 결측치만큼 중요한 전처리 과정이다. 이상치도 분석 결과에 왜곡을 만들거나 모델의 성능에 영향을 준다. 특히 통계 분석에서는 데이터의 대표성에 왜곡을 주고 해석에 혼란을 만들기 때문에 신중하고 잘 처리해야 한다.

이상치 - Outlier

이상치(outlier)는 다른 데이터들과 비교했을 때 비정상적으로 크거나 작은 값을 말한다. 일반적인 데이터 분포에서 벗어난 극단적인 값으로, 데이터의 패턴을 왜곡시킬 수 있다. 이상치는 결측치 처럼 제거하거나 특정 값으로 채우는 것 보다는 상황에 맞게 다뤄야 한다. 이상치도 자체도 중요한 정보일 수 있기 때문이다.

ℹ️알아두기: 이상치(outlier, 아웃라이어)는 데이터내에서 다른 관측값들과 현저히 다른 값을 가지는 것을 말하는데, 데이터의 주요 부분에서 멀리 떨어진 값이다.

이상치가 생기는 이유

  • 측정 오류나 기록 오류
  • 데이터 입력 실수 (예: 나이에 200 입력)
  • 시스템 오류나 센서 고장
  • 실제로 존재하는 극단적인 값 (예: 억만장자의 소득)
  • 데이터 변환 과정에서의 오류

결측치와 마찬가지로 생긴 이유를 안다면 제거하거나 보정 할 수도 있다. 이상치가 발생한 이유도 명백한 것이 아니라면 제공한 곳으로 부터 정보를 받아야 한다.

ℹ️ 이상치 vs 특이값
이상치(Outlier)는 오류로 인한 비정상적인 값이고, 특이값(Extreme Value)은 실제로 존재하는 극단적인 값이다. 하지만 실무에서는 두 용어를 혼용해서 사용하는 경우가 많다.

이상치 예시

데이터에서 이상치가 있는 예를 몇 가지 살펴보자.

❓프롬프트: 이상치 데이터의 예를 보여주세요.

학생 성적 데이터 예시

학생ID이름수학점수영어점수과학점수
S001김철수857892
S002이영희928885
S003박민수788279
S004정수진1509088
S005최지우88-2091
S006김하늘958587

온라인 쇼핑몰 구매 데이터 예시

고객ID나이구매금액구매횟수평점
C0012515000034.5
C002328900024.2
C00328500000014.8
C004-512000043.9
C00545200000510.5
C006389500024.1

센서 데이터 예시

시간온도(°C)습도(%)압력(hPa)
09:0022.5651013.2
09:1023.1671013.1
09:20-50.0681012.9
09:3023.81501012.8
09:4024.272500.0

위 예시에서 굵게 표시된 값들이 이상치다. 수학점수 150점(100점 만점), 나이 -5세, 평점 10.5점(5점 만점), 온도 -50°C, 습도 150% 등은 모두 비현실적인 값들이다.

이상치 탐지 방법

이상치는 결측치와 다르게 값이 있기 때문에 눈으로 보거나 단순한 조건으로 찾아내기 어렵다. 방법이 매우 많다. 상황에 맞게 써야 하므로 어떤 것들이 있는지알아 둘 필요가 있다.

💻참고 프롬프트: 데이터 분석에서 이상치를 탐지하는 방법을 모두 알려주세요.

이상치 탐지 방법 - 통계적 방법

통계적 기법으로 간단하지만 효과적인 Z-score를 이용하는 방법이 있다.

📊 코드 예시

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])

df = pd.DataFrame({'score': data})
print(f"데이터 개수: {len(df)}")
print(f"기본 통계:\n{df.describe()}")
데이터 개수: 1004
기본 통계:
score
count 1004.000000
mean 100.587630
std 17.588007
min -50.000000
25% 90.286145
50% 100.397327
75% 109.757480
max 300.000000
Z-Score 방법으로 탐지된 이상치 개수: 5
이상치 값들:
[157.79097236 200. 250. -50. 300. ]

표준 점수(Z-Score) 방법

이 방법은 표준편차를 이용하는 방법이다. 표준 점수(Z-Score)는 데이터가 평균으로부터 몇 표준편차 떨어져 있는지를 나타내는 지표다. 이 지표를 이용해 이상치를 찾아낸다. 값이 평균으로부터 멀리 떨어진 값을 이상치로 간주하는 방법이다. 일반적으로 표준 점수가 3보다 크거나 -3보다 작은 값을 이상치라고 하며 기준은 직접 조절할 수 있다.

ℹ️알아두기: "Z-Score가 3"이라는 것은, 중심으로부터 떨어진 값의 차이가 평균보다 3배 이상이라는 뜻이다. 즉, 다른 값들에 비해 평균값과 차이가 꽤 많이 나는 값이다.

더 깊은 이해를 위해서 AI챗봇을 이용해보자.

💻참고 프롬프트: Z-score를 이용한 이상치 감지에 대해서 자세히 설명해주세요.

📊 코드 예시

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# Z-Score 계산
df['z_score'] = np.abs(stats.zscore(df['score']))

# 이상치 탐지 (|Z-Score| > 3)
outliers_zscore = df[df['z_score'] > 3]
print(f"Z-Score 방법으로 탐지된 이상치 개수: {len(outliers_zscore)}")
print(f"이상치 값들:\n{outliers_zscore['score'].values}")

# 시각화
plt.figure(figsize=(12, 4))

plt.subplot(1, 2, 1)
plt.scatter(range(len(df)), df['score'], alpha=0.6)
plt.scatter(outliers_zscore.index, outliers_zscore['score'], color='red', s=50)
plt.title('Z-Score 방법으로 탐지된 이상치')
plt.xlabel('인덱스')
plt.ylabel('점수')

plt.subplot(1, 2, 2)
plt.hist(df['score'], bins=30, alpha=0.7)
plt.axvline(df['score'].mean(), color='green', linestyle='--', label='평균')
plt.axvline(df['score'].mean() + 3*df['score'].std(), color='red', linestyle='--', label='평균 + 3σ')
plt.axvline(df['score'].mean() - 3*df['score'].std(), color='red', linestyle='--', label='평균 - 3σ')
plt.title('데이터 분포와 Z-Score 기준')
plt.legend()

plt.tight_layout()
plt.show()
데이터 개수: 1004
기본 통계:
score
count 1004.000000
mean 100.587630
std 17.588007
min -50.000000
25% 90.286145
50% 100.397327
75% 109.757480
max 300.000000
Z-Score 방법으로 탐지된 이상치 개수: 5
이상치 값들:
[157.79097236 200. 250. -50. 300. ]

Z-Score 방법으로 탐지된 이상치

이상치 탐지 방법 - IQR (Interquartile Range) 방법

IQR 방법은 사분위수를 이용한 방법으로, 박스플롯(boxplot)이라는 시각화에서 사용하는 것이다. 이 방법이 이상치 감지에 유용해서 많이 사용한다.

ℹ️알아두기: 사분위수(Quartile)은 사분위수(Quartile)는 데이터를 순서대로 나열한 후 균등하게 4등분한 것이다.

IQR 방법은 다음과 같은 단계로 계산한다.

  1. 데이터를 기준 요소 값의 오름차순으로 정렬한다.
  2. 하위 25% 위치의 값을 Q1(1사분위수)로 지정한다.
  3. 하위 75% 위치의 값을 Q3(3사분위수)로 지정한다.
  4. IQR(사분위 범위) = Q3 - Q1
  5. 이상치 판단
    • 하한 경계: Q1 - 1.5 × IQR 미만인 값
    • 상한 경계: Q3 + 1.5 × IQR 초과인 값

앞서 사용했던 Z-score는 데이터의 분포와 매우 크거나 작은 값에 영향을 많이 받는데 이 방법은 그 문제에 비교적 안전하다.

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# IQR 계산
Q1 = df['score'].quantile(0.25)
Q3 = df['score'].quantile(0.75)
IQR = Q3 - Q1

# 이상치 경계 계산
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

print(f"Q1: {Q1:.2f}")
print(f"Q3: {Q3:.2f}")
print(f"IQR: {IQR:.2f}")
print(f"이상치 경계: [{lower_bound:.2f}, {upper_bound:.2f}]")

# 이상치 탐지
outliers_iqr = df[(df['score'] < lower_bound) | (df['score'] > upper_bound)]
print(f"IQR 방법으로 탐지된 이상치 개수: {len(outliers_iqr)}")
print(f"이상치 값들:\n{outliers_iqr['score'].values}")

# 박스플롯으로 시각화
plt.figure(figsize=(10, 6))
plt.subplot(1, 2, 1)
plt.boxplot(df['score'])
plt.title('박스플롯으로 본 이상치')
plt.ylabel('점수')

plt.subplot(1, 2, 2)
plt.scatter(range(len(df)), df['score'], alpha=0.6)
plt.scatter(outliers_iqr.index, outliers_iqr['score'], color='red', s=50)
plt.axhline(lower_bound, color='red', linestyle='--', label=f'하한: {lower_bound:.1f}')
plt.axhline(upper_bound, color='red', linestyle='--', label=f'상한: {upper_bound:.1f}')
plt.title('IQR 방법으로 탐지된 이상치')
plt.xlabel('인덱스')
plt.ylabel('점수')
plt.legend()

plt.tight_layout()
plt.show()
Q1: 90.29
Q3: 109.76
IQR: 19.47
이상치 경계: [61.08, 138.96]
IQR 방법으로 탐지된 이상치 개수: 12
이상치 값들:
[ 60.70382344 140.8025375 157.79097236 51.3809899 146.18321213
59.54670036 60.23545287 139.48573097 200. 250.
-50. 300. ]

IQR 방법으로 탐지된 이상치

이상치 탐지 방법 - 시각적 방법

시각적 방법은 데이터의 분포를 그래프로 확인하여 이상치를 찾는 방법이다. 몇 가지 잘 알련지 플롯이 있고 그 외 데이터 마다 개별적으로 특이점이나 이상치를 찾기위해 시각화를 고안하기도 한다.

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# 다양한 시각화 방법
fig, axes = plt.subplots(2, 2, figsize=(15, 10))

# 히스토그램
axes[0, 0].hist(df['score'], bins=30, alpha=0.7, edgecolor='black')
axes[0, 0].set_title('히스토그램')
axes[0, 0].set_xlabel('점수')
axes[0, 0].set_ylabel('빈도')

# 박스플롯
axes[0, 1].boxplot(df['score'])
axes[0, 1].set_title('박스플롯')
axes[0, 1].set_ylabel('점수')

# 산점도
axes[1, 0].scatter(range(len(df)), df['score'], alpha=0.6)
axes[1, 0].set_title('산점도')
axes[1, 0].set_xlabel('인덱스')
axes[1, 0].set_ylabel('점수')

# Q-Q 플롯
stats.probplot(df['score'], dist="norm", plot=axes[1, 1])
axes[1, 1].set_title('Q-Q 플롯')

plt.tight_layout()
plt.show()

시각화로 이상치를 탐지하는 방법

이상치 탐지 방법 - 머신러닝을 이용하는 방법

머신러닝(Machine learing, 기계학습)을 이용해서 이상쳐를 탐지 하는 방법이 있다. 딥러닝을 이용한 알고리즘을 비롯해 많은 방법들이 있는데 그 중에 결정 나무 기반의 알고리즘을 소개한다.

Isolation Forest(아이솔레이션 포레스트)

Isolation Forest는 이상치 탐지에 특화된 머신러닝 알고리즘이다. 결정 나무(Decision Tree) 계열의 알고리즘이다. Isolation Forest는 무작위 분할을 통해 데이터를 분리하고. 정상 데이터는 많은 분할이 필요하지만, 이상치는 적은 분할로도 고립된다는 특징을 이용한 것이다.

ℹ️알아두기: 결정 나무는 데이터를 특성에 따라 분할하는 머신러닝 알고리즘이다. 데이터를 가장 잘 구분하는 특성과 기준값을 찾아 데이터를 분류하는 트리를 구성한다.

from sklearn.ensemble import IsolationForest

# Isolation Forest 모델 생성
iso_forest = IsolationForest(contamination=0.1, random_state=42)
outlier_labels = iso_forest.fit_predict(df[['score']])

# 이상치 탐지 (-1이 이상치)
df['outlier_iso'] = outlier_labels
outliers_iso = df[df['outlier_iso'] == -1]

print(f"Isolation Forest로 탐지된 이상치 개수: {len(outliers_iso)}")
print(f"이상치 값들:\n{outliers_iso['score'].values}")

# 시각화
plt.figure(figsize=(10, 6))
normal_data = df[df['outlier_iso'] == 1]
outlier_data = df[df['outlier_iso'] == -1]

plt.scatter(range(len(normal_data)), normal_data['score'], alpha=0.6, label='정상 데이터')
plt.scatter(outlier_data.index, outlier_data['score'], color='red', s=50, label='이상치')
plt.title('Isolation Forest로 탐지된 이상치')
plt.xlabel('인덱스')
plt.ylabel('점수')
plt.legend()
plt.show()
Isolation Forest로 탐지된 이상치 개수: 101
이상치 값들:
[ 71.30079633 74.12623251 127.78417277 70.60494814 73.55439767
60.70382344 70.18646628 128.29278852 71.21843177 136.94863169
... ⚠️중간 생략 ...
127.69955494 127.01410649 71.65688904 63.6418101 76.24145765
69.37397697 126.9652979 200. 250. -50.
300. ]

Isolation Forest

이상치 처리 방법

이상치를 탐지했다면 결측치와 마찬가지로 어떻게 처리할지 결정해야 한다.

제거 (Deletion)

가장 간단한 방법으로, 이상치를 데이터에서 완전히 제거하는 것이다.

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# Z-score계산
df['z_score'] = np.abs(stats.zscore(df['score']))

# 원본 데이터 복사
df_removed = df.copy()

# Z-Score 기준으로 이상치 제거
df_removed = df_removed[df_removed['z_score'] <= 3]

print(f"원본 데이터 개수: {len(df)}")
print(f"이상치 제거 후 데이터 개수: {len(df_removed)}")
print(f"제거된 데이터 개수: {len(df) - len(df_removed)}")

# 제거 전후 비교
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.hist(df['score'], bins=30, alpha=0.7, label='원본')
plt.title('이상치 제거 전')
plt.xlabel('점수')
plt.ylabel('빈도')
plt.legend()

plt.subplot(1, 2, 2)
plt.hist(df_removed['score'], bins=30, alpha=0.7, label='제거 후', color='orange')
plt.title('이상치 제거 후')
plt.xlabel('점수')
plt.ylabel('빈도')
plt.legend()

plt.tight_layout()
plt.show()

print(f"제거 전 평균: {df['score'].mean():.2f}")
print(f"제거 후 평균: {df_removed['score'].mean():.2f}")
원본 데이터 개수: 1004
이상치 제거 후 데이터 개수: 999
제거된 데이터 개수: 5
제거 전 평균: 100.59
제거 후 평균: 100.23

아웃라이어 제거

이상치를 제거하기 전과 후의 평균값이 큰 차이가 없다면 큰 문제는 없다고 할 수 있다. 출력된 그림은 히스토그램(Histogram)인데 분포를 확인하는데 익숙하지 않다면 조금 어려울 것이다. 분포를 보면 이상치를 제거 전에는 중심이 표족하지만 제거 후에 좀더 종모양으로 변화된 것을 알 수 있다. 좀더 정규분포(Normal Distibution)에 가까워 진것이다. 푼포가 정규분포에 가까워지면 통계적인 방법으로 데이터 분석할 때 유리하다.

ℹ️알아두기: 히스토그램(Histogram)은 데이터의 분포를 확인할 때 사용하는 시각화 방법이다. 막대 그래프 모양으로 막대가 서로 붙어 있는 것이 특징이다.

ℹ️알아두기: 정규분포(Normal Distibution)는 통계학 용어이다. 정규분포는 종모양인데 자연계나 일부 사회과학 데이터에서 볼 수 있는 이상적인 데이터 분포다. 데이터가 정규분포이면 통계학에서 연구해 놓은 정규분포 관련 분석 방법을 많이 적용할 수 있다.

이상치를 무조건 제거하는 것도 문제를 만들 수 있다. 이상치가 분석하는데 큰 문제가 되지 않는다면 제거하지 않고 그대로 두는 것이 맞다.

변환 (Transformation)

이상치를 제거하지 않고 다른 값으로 변환하는 방법이다.

경계값으로 대체 (Capping)

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# 경계값으로 대체
df_capped = df.copy()

# IQR 기준으로 경계값 설정
Q1 = df['score'].quantile(0.25)
Q3 = df['score'].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

# 경계값으로 대체
df_capped['score_capped'] = df_capped['score'].clip(lower=lower_bound, upper=upper_bound)

print(f"대체 전 최솟값: {df['score'].min():.2f}")
print(f"대체 후 최솟값: {df_capped['score_capped'].min():.2f}")
print(f"대체 전 최댓값: {df['score'].max():.2f}")
print(f"대체 후 최댓값: {df_capped['score_capped'].max():.2f}")

# 시각화
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.hist(df['score'], bins=30, alpha=0.7, label='원본')
plt.title('경계값 대체 전')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.subplot(1, 2, 2)
plt.hist(df_capped['score_capped'], bins=30, alpha=0.7, label='대체 후', color='green')
plt.title('경계값 대체 후')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.tight_layout()
plt.show()
대체 전 최솟값: -50.00
대체 후 최솟값: 61.08
대체 전 최댓값: 300.00
대체 후 최댓값: 138.96

이상치를 경계값으로 채우기

사분위수를 계산해서 이상치를 하한경계값 또는 상한경계값으로 대체하는 방법이다. 그림을 보면 경계값이 제거되서 분포가 정규분포에 가까워 진것을 볼 수 있다.

ℹ️알아두기: 캐핑(Capping)은 경계값으로 어떤 작동을 제한하는 것을 말한다. 병뚜껑에 뚜껑(cap)을 씌우는 것처럼 경계 이상으로 넘치지 못하게 하는 것이다.

값을 대체하는 방법 중에 알려진 것이 몇가지 있다. 상항에 따라 매 번 다르고 방법을 새로 만들어야 할 때도 있다. 하지만 이 방법도 원본 데이터를 변형하는 것이기 때문에 왜곡을 만들므로 데이터 분석에는 좋지 않다. 모델링에서 많이 사용한다.

로그 변환(Log transform)

데이터에 로그 계산을 해서 스케일(Scale)을 바꾸는 방법이다. 스케일을 바꾸면 분포가 바뀌고 이상치가 사라지는 경우가 있다.

ℹ️알아두기: 스케일(Scale)은 데이터의 각 변수(특성, feature)가 가지는 값의 크기나 범위를 말한다. 단위를 바꾸는 것도 스케일 변환에 포함된다.

# 샘플 데이터 생성 - 로그 변환에 적합한 데이터
np.random.seed(42)
data = np.random.lognormal(mean=3, sigma=1, size=1000) # 오른쪽으로 치우친 분포
# 큰 이상치 추가 (로그 변환의 효과를 볼 수 있도록)
data = np.append(data, [500, 1000, 2000, 5000])
df = pd.DataFrame({'score': data})

# 로그 변환 (양수 데이터만 가능)
# 음수가 있는 경우 상수를 더해서 양수로 만들기
df_log = df.copy()
min_val = df_log['score'].min()
if min_val <= 0:
df_log['score_positive'] = df_log['score'] - min_val + 1
else:
df_log['score_positive'] = df_log['score']

df_log['score_log'] = np.log(df_log['score_positive'])

# 시각화
plt.figure(figsize=(12, 5))

plt.subplot(1, 2, 1)
plt.hist(df_log['score_positive'], bins=30, alpha=0.7)
plt.title('로그 변환 전')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.subplot(1, 2, 2)
plt.hist(df_log['score_log'], bins=30, alpha=0.7, color='purple')
plt.title('로그 변환 후')
plt.xlabel('log(점수)')
plt.ylabel('빈도')

plt.tight_layout()
plt.show()

로그 변환 시각화

로그 변환은 모델링할 때 많이 사용하는 방법인데 이상치를 제거하는 효과가 흔하게 생긴다. 데이터 분석에서는 인사이트를 찾을 때 탐색 용으로 사용할 때가 많다.

대체 (Imputation)

이상치를 평균, 중앙값, 최빈값 등으로 대체하는 방법이다. 역시 값을 바꾸는 방법 중 하나다. 결측치 제거할 때 동일한 방법을 사용했었다.

# 샘플 데이터 생성
np.random.seed(42)
data = np.random.normal(100, 15, 1000) # 평균 100, 표준편차 15
# 이상치 추가
data = np.append(data, [200, 250, -50, 300])
df = pd.DataFrame({'score': data})

# Z-Score 계산
df['z_score'] = np.abs(stats.zscore(df['score']))

# 대체 방법들
df_imputed = df.copy()

# 이상치 위치 찾기 (Z-Score > 3)
outlier_mask = df_imputed['z_score'] > 3

print(f"대체할 이상치 개수: {outlier_mask.sum()}")

# 평균으로 대체
df_imputed['score_mean'] = df_imputed['score'].copy()
df_imputed.loc[outlier_mask, 'score_mean'] = df_imputed['score'].mean()

# 중앙값으로 대체
df_imputed['score_median'] = df_imputed['score'].copy()
df_imputed.loc[outlier_mask, 'score_median'] = df_imputed['score'].median()

# 결과 비교
print(f"원본 평균: {df['score'].mean():.2f}")
print(f"평균 대체 후 평균: {df_imputed['score_mean'].mean():.2f}")
print(f"중앙값 대체 후 평균: {df_imputed['score_median'].mean():.2f}")

# 시각화
plt.figure(figsize=(15, 5))

plt.subplot(1, 3, 1)
plt.hist(df['score'], bins=30, alpha=0.7)
plt.title('원본 데이터')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.subplot(1, 3, 2)
plt.hist(df_imputed['score_mean'], bins=30, alpha=0.7, color='orange')
plt.title('평균으로 대체')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.subplot(1, 3, 3)
plt.hist(df_imputed['score_median'], bins=30, alpha=0.7, color='green')
plt.title('중앙값으로 대체')
plt.xlabel('점수')
plt.ylabel('빈도')

plt.tight_layout()
plt.show()
대체할 이상치 개수: 5
원본 평균: 100.59
평균 대체 후 평균: 100.23
중앙값 대체 후 평균: 100.23

이상치를 대체하기

이상치 제거 실습 예제

잘 알려진 보스턴 주택 정보 데이터셋을 사용하여 이상치 처리 과정을 살펴보자.

ℹ️알아두기: 보스턴 주택 정보는 데이터 분석 서적이나 예제에서 많이 등장하는 잘 알려진 데이터셋이다.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 데이터 직접 다운로드 및 파싱
data_url = "http://lib.stat.cmu.edu/datasets/boston"
raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)

# 데이터 포맷 맞추기 (두 줄씩 한 레코드)
X = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]]) # 13개 특성
y = raw_df.values[1::2, 2] # PRICE

# feature 이름 정의
columns = [
"CRIM", "ZN", "INDUS", "CHAS", "NOX", "RM", "AGE",
"DIS", "RAD", "TAX", "PTRATIO", "B", "LSTAT"
]
df_boston = pd.DataFrame(X, columns=columns)
df_boston['PRICE'] = y

# 데이터 기본 정보와 통계
print("보스턴 주택 데이터 기본 정보:")
print(df_boston.info())
print("\n기본 통계:")
print(df_boston.describe())

# 이상치 탐지 함수 (IQR 방식)
def detect_outliers_iqr(data, column):
Q1 = data[column].quantile(0.25)
Q3 = data[column].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
outliers = data[(data[column] < lower_bound) | (data[column] > upper_bound)]
return outliers, lower_bound, upper_bound

# 주요 변수들의 이상치 탐지 및 박스플롯
columns_to_check = ['CRIM', 'ZN', 'INDUS', 'NOX', 'RM', 'AGE', 'DIS', 'PRICE']

plt.figure(figsize=(20, 10))
for i, col in enumerate(columns_to_check):
plt.subplot(2, 4, i+1)
plt.boxplot(df_boston[col], vert=True)
plt.title(f'{col}의 박스플롯')
plt.ylabel(col)

# 이상치 개수 출력
outliers, _, _ = detect_outliers_iqr(df_boston, col)
plt.text(1.05, df_boston[col].max(), f'이상치: {len(outliers)}개',
horizontalalignment='left', color='red')

plt.tight_layout()
plt.show()

print("\n각 변수별 이상치 개수:")
for col in columns_to_check:
outliers, _, _ = detect_outliers_iqr(df_boston, col)
print(f"{col}: {len(outliers)}개")
보스턴 주택 데이터 기본 정보:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 506 entries, 0 to 505
Data columns (total 14 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 CRIM 506 non-null float64
1 ZN 506 non-null float64
2 INDUS 506 non-null float64
3 CHAS 506 non-null float64
4 NOX 506 non-null float64
5 RM 506 non-null float64
6 AGE 506 non-null float64
7 DIS 506 non-null float64
8 RAD 506 non-null float64
9 TAX 506 non-null float64
10 PTRATIO 506 non-null float64
11 B 506 non-null float64
12 LSTAT 506 non-null float64
13 PRICE 506 non-null float64
dtypes: float64(14)
memory usage: 55.5 KB
None

기본 통계:
CRIM ZN INDUS CHAS NOX RM \
count 506.000000 506.000000 506.000000 506.000000 506.000000 506.000000
mean 3.613524 11.363636 11.136779 0.069170 0.554695 6.284634
std 8.601545 23.322453 6.860353 0.253994 0.115878 0.702617
min 0.006320 0.000000 0.460000 0.000000 0.385000 3.561000
25% 0.082045 0.000000 5.190000 0.000000 0.449000 5.885500
50% 0.256510 0.000000 9.690000 0.000000 0.538000 6.208500
75% 3.677083 12.500000 18.100000 0.000000 0.624000 6.623500
max 88.976200 100.000000 27.740000 1.000000 0.871000 8.780000

AGE DIS RAD TAX PTRATIO B \
count 506.000000 506.000000 506.000000 506.000000 506.000000 506.000000
mean 68.574901 3.795043 9.549407 408.237154 18.455534 356.674032
std 28.148861 2.105710 8.707259 168.537116 2.164946 91.294864
min 2.900000 1.129600 1.000000 187.000000 12.600000 0.320000
25% 45.025000 2.100175 4.000000 279.000000 17.400000 375.377500
50% 77.500000 3.207450 5.000000 330.000000 19.050000 391.440000
75% 94.075000 5.188425 24.000000 666.000000 20.200000 396.225000
max 100.000000 12.126500 24.000000 711.000000 22.000000 396.900000

LSTAT PRICE
count 506.000000 506.000000
mean 12.653063 22.532806
std 7.141062 9.197104
min 1.730000 5.000000
25% 6.950000 17.025000
50% 11.360000 21.200000
75% 16.955000 25.000000
max 37.970000 50.000000

이상치 제거 실습

이 예제는 보스턴 집값 데이터의 8개의 변수에 IRQ로 이상치를 감지하고 시각화하는 예제이다. 이상치를 제거하지는 않는다. 대상이 된 변수명(특성 이름)이 영문 약어로 되어 있는데 이 한국어로 바꿔보면 더 이해하기 쉽다.

변수명설명
CRIM범죄율
ZN25000평방피트 초과 주택 비율
INDUS비소매 상업지구 면적 비율
CHAS찰스강 주변 여부 (1: 주변, 0: 외곽)
NOX일산화질소 농도
RM주택당 방 수
AGE1940년 이전에 건축된 주택 비율
DIS직업센터까지의 거리
RAD방사형 고속도로 접근성 지수
TAX재산세율
PTRATIO학생-교사 비율
B흑인 비율
LSTAT하위 계층 비율
PRICE주택 가격

이상치 처리 시 주의사항

이상치를 처리할 때 주의사항이 있고 요령이 있다.

도메인 지식 활용

도메인 지식을 활용하는 것이다. 즉, 경험의 의해 판단하는 것이다.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 데이터 직접 다운로드 및 파싱
data_url = "http://lib.stat.cmu.edu/datasets/boston"
raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)

# 데이터 포맷 맞추기 (두 줄씩 한 레코드)
X = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]]) # 13개 특성
y = raw_df.values[1::2, 2] # PRICE

# feature 이름 정의
columns = [
"CRIM", "ZN", "INDUS", "CHAS", "NOX", "RM", "AGE",
"DIS", "RAD", "TAX", "PTRATIO", "B", "LSTAT"
]
df_boston = pd.DataFrame(X, columns=columns)
df_boston['PRICE'] = y

# 예: 주택 가격 데이터에서 방 개수(RM) 이상치 검토
rm_outliers, rm_lower, rm_upper = detect_outliers_iqr(df_boston, 'RM')

print(f"방 개수(RM) 이상치 경계: [{rm_lower:.2f}, {rm_upper:.2f}]")
print(f"이상치로 분류된 방 개수들:")
print(rm_outliers['RM'].sort_values().values)

# 실제로는 방이 8개 이상인 집이 존재할 수 있으므로
# 단순히 통계적 기준만으로 제거하면 안 됨
print(f"\n방 개수 분포:")
print(df_boston['RM'].describe())
방 개수(RM) 이상치 경계: [4.78, 7.73]
이상치로 분류된 방 개수들:
[3.561 3.863 4.138 4.138 4.368 4.519 4.628 4.652 7.765 7.802 7.82 7.82
7.831 7.853 7.875 7.923 7.929 8.034 8.04 8.069 8.247 8.259 8.266 8.297
8.337 8.375 8.398 8.704 8.725 8.78 ]

방 개수 분포:
count 506.000000
mean 6.284634
std 0.702617
min 3.561000
25% 5.885500
50% 6.208500
75% 6.623500
max 8.780000
Name: RM, dtype: float64

위의 데이터를 보면 방 개수가 8개 이상이면 이상치가 된다. 하지만 이 데이터에서 방이 8개인 것이 이상하다고 할 수는 없다. 미국의 주택은 넓고 2층까지 있는 고급 주택이나 대형 주택도 많다.

아래 코드를 추가로 덧붙여 실행한 후에 결과를 보자.

# 위의 코드에 이어서 연달아 실행해야 함
print("도메인 지식 기반 분석:")
print("- 방 8개 이상 주택들의 가격 분포:")
high_room_houses = df_boston[df_boston['RM'] > rm_upper]
print(f" 평균 가격: ${high_room_houses['PRICE'].mean():.1f}천")
print(f" 최고 가격: ${high_room_houses['PRICE'].max():.1f}천")
print(f" 최저 가격: ${high_room_houses['PRICE'].min():.1f}천")

print("\n- 일반적인 주택들의 가격 분포:")
normal_houses = df_boston[(df_boston['RM'] >= rm_lower) & (df_boston['RM'] <= rm_upper)]
print(f" 평균 가격: ${normal_houses['PRICE'].mean():.1f}천")

면 방의 갯수는 집의 가격과 관련이 잇으므로 방의 갯수를 이상치 처리 대상으로 잡아서 제거하는 것은 맞지 않다는 것을 알 수 있다.

처리 방법별 영향 분석

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# 데이터 직접 다운로드 및 파싱
data_url = "http://lib.stat.cmu.edu/datasets/boston"
raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)

# 데이터 포맷 맞추기 (두 줄씩 한 레코드)
X = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]]) # 13개 특성
y = raw_df.values[1::2, 2] # PRICE

# feature 이름 정의
columns = [
"CRIM", "ZN", "INDUS", "CHAS", "NOX", "RM", "AGE",
"DIS", "RAD", "TAX", "PTRATIO", "B", "LSTAT"
]
df_boston = pd.DataFrame(X, columns=columns)
df_boston['PRICE'] = y

# 다양한 처리 방법의 영향 비교
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
from sklearn.model_selection import train_test_split

# 특성과 타겟 분리
X = df_boston[['RM', 'LSTAT', 'CRIM']] # 주요 특성 몇 개만 선택
y = df_boston['PRICE']

# 1. 원본 데이터
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
model_original = LinearRegression()
model_original.fit(X_train, y_train)
y_pred_original = model_original.predict(X_test)
r2_original = r2_score(y_test, y_pred_original)

# 2. 이상치 제거 후
# PRICE의 이상치 제거
price_outliers, price_lower, price_upper = detect_outliers_iqr(df_boston, 'PRICE')
df_no_outliers = df_boston[~df_boston.index.isin(price_outliers.index)]

X_no_outliers = df_no_outliers[['RM', 'LSTAT', 'CRIM']]
y_no_outliers = df_no_outliers['PRICE']

X_train_no, X_test_no, y_train_no, y_test_no = train_test_split(
X_no_outliers, y_no_outliers, test_size=0.2, random_state=42)
model_no_outliers = LinearRegression()
model_no_outliers.fit(X_train_no, y_train_no)
y_pred_no = model_no_outliers.predict(X_test_no)
r2_no_outliers = r2_score(y_test_no, y_pred_no)

print("모델 성능 비교:")
print(f"원본 데이터 R²: {r2_original:.4f}")
print(f"이상치 제거 후 R²: {r2_no_outliers:.4f}")
print(f"성능 변화: {r2_no_outliers - r2_original:.4f}")

이상치 처리 가이드라인

이상치 처리 순서

  • 탐색적 데이터 분석: 데이터의 전체적인 분포와 패턴 파악
  • 이상치 탐지: 다양한 방법으로 이상치 후보 식별
  • 도메인 검증: 비즈니스 관점에서 이상치 여부 판단
  • 처리 방법 선택: 분석 목적에 맞는 처리 방법 결정
  • 영향 분석: 처리 전후 결과 비교 및 검증

이상치 제거는 전처리에서 하기도 하지만 분석 과정에서 처리하는 할 때도 많다. 전처리 과정에서 이상치 처리 방침을 결정핬다고 해도 분석 과정에서 이전 과정을 다시 해야 할 일이 생기기 때문이다.

상황별 처리 방법 선택

상황권장 처리 방법이유
명백한 입력 오류제거 또는 수정잘못된 데이터는 분석을 왜곡
측정 오류제거신뢰할 수 없는 데이터
극단적이지만 실제 값유지 또는 변환중요한 정보를 포함할 수 있음
소수의 극값경계값 대체전체 분포에 미치는 영향 최소화
많은 수의 이상치변환 (로그 등)데이터 분포 자체의 문제일 수 있음

이상치 처리 체크리스트

  • 이상치가 실제 오류인지 확인했는가?
  • 도메인 전문가의 의견을 구했는가?
  • 여러 탐지 방법을 시도해봤는가?
  • 처리 전후 데이터 분포 변화를 확인했는가?
  • 모델 성능이나 분석에 미치는 영향을 평가했는가?
  • 처리 과정을 문서화해서 기록했는가?

체크리스트ㄴ은 위와 같은데, 이상치 처리 과정은 암기하는 것보다는 습관이 되는 것이 좋다. 과정은 AI챗봇에게 확인하는 것이 편하다.

💻참고 프롬프트: 데이터 분석하기 전에 이상치를 처리할 때 참고할 체크리스트를 알려주세요.

마무리

이상치 처리는 데이터 분석의 중요한 과정이고 매우 자주 만나는 문제다. 단순히 통계적 기준만으로 제거하는 것이 아니라, 비즈니스 맥락과 도메인 지식을 종합적으로 고려해야 한다.

이상치 처리의 핵심은 **"제거할 것인가, 유지할 것인가"**를 신중하게 판단하는 것이다. 때로는 이상치가 가장 중요한 인사이트를 제공하기도 한다. 가치있는 사실은 이상치에서 나오는 경우가 많다.

💡 실무 팁
이상치 처리는 되돌릴 수 있도록 항상 원본 데이터를 보존하고, 처리 과정을 단계별로 기록해둬야 한다. 나중에 다른 방법을 시도해볼 때 매우 유용하다.