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 | 데이터를 지우지 않고 무효/결측 상태로 변경 |
'- > Pandas' 카테고리의 다른 글
| Pandas: DataFrame 결합(Merge) 기본 및 키 정 (0) | 2026.08.30 |
|---|---|
| Pandas: 데이터 결측 처리 전략 및 시계열 보간 (0) | 2026.08.30 |
| Pandas: 데이터 타입 정제와 중복 제거 (0) | 2026.08.30 |
| Pandas: diagnose (0) | 2026.08.30 |
| Pandas: groupby() (0) | 2026.08.28 |