-/Pandas

Pandas: 이상치 탐색

106. 2026. 8. 30. 20:54

outliers

데이터 정제 과정에서 통계적 기준(IQR, 종목별 상대 기준)과 도메인 논리 규칙을 활용해 이상치를 탐색하고, 이를 안전하게 결측치(NaN)로 처리하는 절차다.

 

📌 이상치 탐색 및 처리 4단계 프로세스

단계 작업 항목 핵심 목적 주요 메서드 및 키워드
1단계 기본 분포 탐색 전체 중앙값 대비 극단값(50배 초과, 5% 미만) 파악 describe(), transform('median')
2단계 IQR 이상치 탐색 전체 기준 vs 종목별 기준 IQR 탐색 비교 quantile([0.25, 0.75]), transform()
3단계 논리적 이상치 탐색 도메인 규칙 기반 데이터 오류(종가/고가/저가, 음수 거래량) 포착 조건식 (close > high, volume < 0)
4단계 이상치 결측 처리 이상치를 행 삭제 대신 pd.NA로 변환 후 저장 df.loc[mask] = pd.NA, to_pickle()

 

1. 기본 분포 확인 및 중앙값 비교

단순 평균(mean)과 달리 중앙값(median)은 극단치에 영향을 받지 않으므로 종목별 기준점으로 활용한다.

# 이상치 확인
print(df["close"].describe().round(0).to_string())

# median = 중앙값, mean = 평균
med = df.groupby("code")["close"].transform("median")
print(f"""
    종목 중앙값과 비교
     중앙값의 50배 초과 : {(df['close'] > med * 50).sum()}건
     중앙값의 5%미만 : {(df['close'] < med * 0.05).sum()}건
    
    나름의 기준으로 너무 큰값이나 너무 작은값을 찾는다.
""")
  • 나름의 기준으로 너무 큰 값이나 너무 작은 값을 찾는다.

2. IQR(사분위 범위) 기반 이상치 탐지

quantile은 값을 크기순으로 늘어놓았을 때 특정 위치의 값을 구한다.

  • s.quantile([0.25, 0.75]): 목록을 주면 값 2개짜리 Series가 나오며, 순서대로 Q1, Q3 변수에 풀어서 담는다.
  • IQR(사분위 범위) = Q3 - Q1

💡 IQR 규칙 (어디까지를 정상으로 볼 것인가?)

  • Q1 - 1.5 * IQR보다 작거나, Q3 + 1.5 * IQR보다 크면 이상치로 본다.
  • 평균, 표준편차와 달리 극단값 자체에 흔들리지 않기 때문에 이상치 탐지에 사용한다.
q1, q3 = df["close"].quantile([0.25, 0.75])
# q1 : 1사분위 값 (하위 25%)
# q3 : 3사분위 값 (75% -> 상위 25%)
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
global_mask = (df["close"] < lo) | (df["close"] > hi)

print(f" q1={q1}   q3={q3}    IQR={iqr}")
print(f" 정상범위 : {lo} ~ {hi}")
print(f" 이상치 : {global_mask.sum()}건")

💡 전체 기준 IQR의 한계와 종목별 IQR의 필요성

IQR로 이상치를 탐색하는 게 부족할 수 있다. 전체 기준으로 계산 시 하한선이 음수가 나올 수 있어 극소 이상치를 전혀 잡지 못한다. 주가는 음수가 될 수 없으므로 이 기준은 의미가 없다.

전체 기준 하한선보다 커서 정상으로 분류되었더라도, 특정 종목 입장에서는 하루만에 99% 정도 폭락한 이상치일 수 있다.

# 종목별 IQR 계산 함수
def is_outlier(s):
    """
    한 종목의 종가목록을 받아서 같은 길이의 bool mask를 돌려준다.
    각 자리의 True/False로 이상치인가 아닌가의 값을 리턴
    """
    q1, q3 = s.quantile([0.25, 0.75])
    iqr = q3 - q1
    lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
    return (s < lo) | (s > hi)

# 종목별 이상치 탐색
by_stock = df.groupby("code")["close"].transform(is_outlier)

print(f"전체 기준 : {global_mask.sum()}건")
print(f"종목별 : {by_stock.sum()}건")
print(f"전체에는 없는데 종목별로만 잡힌 수 : {(by_stock & ~global_mask).sum()}건")
print(f"전체에만 있는 수 : {(~by_stock & global_mask).sum()}건")
  • 전체 기준 오탐지: 비싼 종목의 정상 가격을 시장 평균보다 높다는 이유로 이상치로 오탐지한다.
  • 종목별 기준의 정교함: 종목별로만 잡힌 건수는 전체 기준으로 했을 때 놓친 실제 이상치들이다.

3. 도메인 규칙 기반 논리적 이상치 탐색

주식 데이터 도메인 특성상 시가, 고가, 저가, 종가의 관계나 거래량의 범위는 절대 깨질 수 없다.

  • 종가만 조작된 경우 종가 > 고가 또는 종가 < 저가 상태가 된다. 관계가 절대 깨질 수 없으므로 조건식 하나로 잡아준다.
  • 거래량 음수도 IQR을 돌릴 필요 없이 규칙으로 잡아낸다.
# 논리적 오류 탐색
broken = (df["close"] > df["high"]) | (df["close"] < df["low"])
neg_vol = df["volume"] < 0

print(f"최저가, 최고가에서 벗어난 종가 : {broken.sum()}건")
print(f"거래량이 음수인 행 : {neg_vol.sum()}건")

print(f" 둘 다 잡음 :  {(by_stock & broken).sum()}건")
print(f" 통계적이상치 :  {(by_stock & ~broken).sum()}건")
print(f" 논리적이상치 :  {(~by_stock & broken).sum()}건")
  • 보완적으로 통계를 통해 잡고, 도메인 규칙에 따라 추가 처리가 필요하다.

4. 이상치 처리 (NaN 변환 및 저장)

시세는 그날의 값이 반드시 있어야 하는 데이터다. 부합하지 않는다고 해서 행 자체를 지우면 날짜에 구멍이 생긴다. 따라서 행을 삭제하지 않고 결측치(NaN)로 변환한다.

  • pd.NA: 결측 표시값이다. 값을 지우는 대신 모르는 상태로 둔다.
  • 값이 없거나 모를 때 0으로 넣으면 안 된다.
before_max = df["close"].max()
mask = by_stock | broken

# 이상치를 pd.NA로 변경
df.loc[mask, "close"] = pd.NA
df["close"] = pd.to_numeric(df["close"], errors="coerce")

# 음수 거래량을 pd.NA로 변경
df.loc[df["volume"] < 0, "volume"] = pd.NA

print()
print(f"이상치 -> NaN : {mask.sum()}건")
print(f"거래량 음수 : {neg_vol.sum()}건")
print(f"종가 결측 : {df['close'].isna().sum()}건")

# 중간 저장
print("====_step2.pkl로 저장====")
df.to_pickle(step_path("_step2.pkl"))
print(f"종가 최대값 : {before_max}")

 

 

5. 핵심 함수 및 개념 요약

구분 개념 / 메서드 역할 및 특성
통계 지표 transform('median') 그룹별 중앙값을 기존 DataFrame과 동일한 행 길이로 반환
quantile([0.25, 0.75]) Q1(25%), Q3(75%) 지점 수치 반환
IQR 규칙 Q1 - 1.5*IQR ~ Q3 + 1.5*IQR 일반적인 통계적 정상 범주 (전체/그룹별 적용)
도메인 규칙 (close > high) | (close < low) 시세 조건 논리 오류 검출
volume < 0 음수 거래량 논리 오류 검출
결측 처리 pd.NA 데이터를 지우지 않고 무효/결측 상태로 변경