데이터 전처리 2
pandas로 데이터 정제 & 문제 해결
학습개요
데이터 분석의 신뢰도는 데이터의 품질과 구조에 크게 좌우됩니다. 결측치(값이 없음)나 이상치(일반 범위를 벗어남), 오류값(형식·범위·논리 위반) 등이 그대로 남아 있으면 모델 성능 저하와 왜곡된 인사이트로 이어집니다. 본 차시에서는 pandas를 중심으로 결측치·이상치·오류값을 탐지→시각화→처리하는 절차를 익히고, 제거·대치·보간 등 전략을 비교합니다. 또한 날짜(시계열) 데이터 표준화와 여러 출처 데이터 통합(merge) 실습까지 수행합니다.
용어 확장
- NaN: Not a Number (숫자 아님; 결측치 표현)
- IQR: Interquartile Range (사분위 범위)
- Z-score: Standard Score (표준 점수)
- SQL: Structured Query Language (구조적 질의어)
학습목표
- 결측치·이상치를 체계적으로 탐지하고 적절한 방법으로 처리할 수 있다.
- 날짜(시계열) 데이터의 형식 통일과 파생 특성을 추출할 수 있다.
- 서로 다른 출처의 데이터를 분석 가능한 형태로 통합할 수 있다.
1. 데이터 정제(Data Cleansing) 핵심 개념
- 목적: 불완전/부정확 데이터(결측치, 이상치, 중복, 오류값)를 탐지·수정·제거
- 일반 흐름:
- 구조·기술 통계 파악 (info, describe)
- 결측·이상치 탐지 (isna, 시각화, 통계량)
- 처리 전략 선택 (제거·대치·보간)
- 형식·범위·논리 오류 수정 (타입 변환, 범위 제한, 규칙 기반 교정)
- 재검증(품질 확인) 및 리포트
2. 결측치 탐지와 처리
2.1 결측치 탐지
import pandas as pd
def profile_missing(df: pd.DataFrame) -> pd.Series:
"""
DataFrame의 열별 결측치 개수와 비율을 요약합니다.
Returns: 결측치 개수/비율 문자열 시리즈
"""
n = len(df)
return (df.isna().sum()
.astype(int)
.map(lambda c: f"{c} ({c/n:.1%})"))
# 사용 예
# summary = profile_missing(df)
# print(summary.sort_values(ascending=False))
- 빠른 확인: df.isna().sum() 또는 df.isna().any()
- “완전 처리 여부” 점검: 전처리 후 다시 isna().any()로 확인
시각화 참고: seaborn.heatmap, missingno.matrix 등을 활용하면 결측 패턴을 한눈에 볼 수 있습니다. (seaborn: statistical data visualization)
2.2 결측치 처리 전략
- 제거: 결측이 특정 열에 몰림·비중이 매우 낮음·MNAR(특정 이유로 결측) 편향 우려 적음 등일 때
- df.dropna(axis=0/1, how='any'/'all', subset=[...])
- 대치(치환): 정보 손실을 줄이면서 활용도↑
- 수치형: 평균/중앙값/최빈값/도메인 특정값
- 범주형: 최빈값, “Unknown/기타”
- 고급: KNN, 회귀/분류 예측 기반, 다변량 대치(MICE 등)
- df['col'] = df['col'].fillna(value) 또는 df['col'].fillna(df['col'].median(), inplace=True)
- 보간(Interpolation): 시계열에서 시간 연속성 활용
- 선형/다항/스플라인/시간 기반 보간
- df['value'] = df['value'].interpolate(method='time')
- 대치 후 분포–모델 성능 변화 확인(전/후 비교)
- 누락 메커니즘(MCAR/MAR/MNAR)을 가정하고 과도한 낙관/편향을 피하기
3. 이상치 탐지와 처리
3.1 IQR(사분위 범위) 기반
import pandas as pd
def iqr_outlier_mask(s: pd.Series, k: float = 1.5) -> pd.Series:
"""
IQR 방식으로 이상치 여부 마스크를 반환합니다.
- s: 단일 수치형 시리즈
- k: IQR 배수(기본 1.5; 보수적이면 3.0 권장)
Returns: 이상치(True) / 정상(False)
"""
q1, q3 = s.quantile([0.25, 0.75])
iqr = q3 - q1
lower, upper = q1 - k*iqr, q3 + k*iqr
return (s < lower) | (s > upper)
# 예시
# mask = iqr_outlier_mask(df['score'])
# outliers = df[mask]
- 장점: 비정규분포·긴 꼬리 데이터에 강건
- 주의: 샘플 수가 작거나 다봉분포일 때 경계 신뢰도↓ → k 조정·로버스트 스케일링 고려
3.2 Z-score(표준 점수) 기반
import numpy as np
import pandas as pd
def zscore_outlier_mask(s: pd.Series, threshold: float = 3.0) -> pd.Series:
"""
Z-score 방식 이상치 탐지 (정규성 가정).
- threshold: |z| 임계치(보통 2~3)
"""
mu, sigma = s.mean(), s.std(ddof=0)
if sigma == 0 or np.isnan(sigma):
return pd.Series(False, index=s.index)
z = (s - mu) / sigma
return z.abs() > threshold
- 장점: 정규분포 근사 시 해석 용이
- 주의: 평균/표준편차에 민감 → 로버스트 대안(IQR, Median Absolute Deviation) 병행 검토
3.3 처리 전략
- 제거: 명백한 센서 오류·입력 실수
- 윈저라이징: 극단값을 경계값으로 잘라냄
- 변환: 로그/박스-콕스 등으로 분포 안정화
- 모델링에서의 강건화: 로스·스케일러·알고리즘 선택(트리 계열 등)
4. 날짜(시계열) 데이터 다루기
4.1 기본 전처리
import pandas as pd
df['date'] = pd.to_datetime(df['date_str'], errors='coerce', utc=False)
df['year'] = df['date'].dt.year
df['month'] = df['date'].dt.month
df['day'] = df['date'].dt.day
df['dow'] = df['date'].dt.dayofweek # 0=월 ~ 6=일
df['is_wknd']= df['dow'].isin([5,6]).astype(int)
df['ymd'] = df['date'].dt.strftime('%Y-%m-%d')
- 포맷 통일: 지역별 표현식 혼재 시 to_datetime(..., format=...) 지정
- 윤년/말일/시간대(Timezone) 주의
4.2 시간 기반 보간·리샘플
# 인덱스가 시간축이면 보간 품질↑
ts = df.set_index('date').sort_index()
ts['value_filled'] = ts['value'].interpolate(method='time')
weekly = ts['value'].resample('W').mean()
5. 데이터 값 변경과 오류 수정
# 값 치환
df['city'] = df['city'].replace({'Seoul': '서울'})
# 매핑(딕셔너리/함수)
code2name = {'S': 'Small', 'M': 'Medium', 'L': 'Large'}
df['size_name'] = df['size_code'].map(code2name).fillna('Unknown')
# 조건부 변경
df.loc[df['score'] < 90, 'score'] = 90
# where: 조건 만족은 유지, 불만족은 대체
df['age_fixed'] = df['age'].where(df['age'] >= 30, other=0)
6. 데이터 통합(merge) — SQL의 JOIN과 유사
- 공통 키로 결합: pd.merge(left, right, on='key', how='inner/left/right/outer')
- 키 이름 다르면: left_on='A', right_on='B'
- 누락 키·중복 키·카디널리티(1:1, 1:N, N:N) 주의
# 안전한 머지 유틸
def safe_merge(left, right, on, how='inner', validate=None):
"""
pandas.merge 래퍼: validate로 카디널리티를 검증해 조용한 N:N 폭발을 방지합니다.
- validate 예: 'one_to_one', 'one_to_many', 'many_to_one', 'many_to_many'
"""
return pd.merge(left, right, on=on, how=how, validate=validate)
7. 실전 실습: Kaggle “Store Sales – Time Series Forecasting” 전처리 뼈대
목표: 학습용 일관된 전처리 파이프라인을 만들고, 재현 가능한 실험 기반을 제공합니다.
주요 파일 예시: train.csv(일별 판매), transactions.csv(상점별 거래량), oil.csv(유가), holidays_events.csv(휴일), stores.csv(상점 메타)
7.1 로드 & 기본 점검
import pandas as pd
from pathlib import Path
DATA = Path('./data') # 다운로드 폴더
train = pd.read_csv(DATA/'train.csv', parse_dates=['date'])
trans = pd.read_csv(DATA/'transactions.csv', parse_dates=['date'])
oil = pd.read_csv(DATA/'oil.csv', parse_dates=['date'])
hol = pd.read_csv(DATA/'holidays_events.csv', parse_dates=['date'])
stores= pd.read_csv(DATA/'stores.csv')
for name, df in [('train', train), ('trans', trans), ('oil', oil), ('hol', hol), ('stores', stores)]:
print(f"[{name}] shape={df.shape}")
print(df.info())
7.2 결측치 탐지·처리(예시)
# 결측 요약
for name, df in [('train', train), ('trans', trans), ('oil', oil), ('hol', hol), ('stores', stores)]:
miss = df.isna().sum()
print(f"\n[{name}] missing:\n", miss[miss>0].sort_values(ascending=False))
# oil의 누락값은 시계열 ‘시간기반 보간’이 자연스러움
oil = oil.sort_values('date')
oil['dcoilwtico'] = oil['dcoilwtico'].interpolate(method='time') # 유가 결측 보간
7.3 이상치 탐지·처리(예시)
# 상점 거래량의 극단값 탐지(IQR)
def clip_iqr(s: pd.Series, k: float = 3.0) -> pd.Series:
q1, q3 = s.quantile([0.25, 0.75])
iqr = q3 - q1
lo, hi = q1 - k*iqr, q3 + k*iqr
return s.clip(lower=lo, upper=hi)
trans['transactions_clipped'] = trans.groupby('store_nbr')['transactions']\
.transform(lambda s: clip_iqr(s, k=3.0))
7.4 날짜 파생 및 휴일 정규화
# train에 날짜 특징 추가
train['year'] = train['date'].dt.year
train['month'] = train['date'].dt.month
train['dow'] = train['date'].dt.dayofweek
train['is_wknd'] = train['dow'].isin([5,6]).astype(int)
# 휴일: 불필요한(전국적 적용 X, 전년 반복 공휴일 등) 필터링/정규화 필요
# 국가·로컬 컬럼에 따라 단순화된 'is_holiday' 플래그 생성 예
hol_simplified = hol.copy()
hol_simplified['is_holiday'] = 1
hol_s = hol_simplified[['date','is_holiday']].drop_duplicates()
7.5 통합(merge)
# train + oil (일자 기준)
train = pd.merge(train, oil, on='date', how='left', validate='many_to_one')
# train + trans (상점·일자 기준)
train = pd.merge(train, trans[['date','store_nbr','transactions_clipped']]
.rename(columns={'transactions_clipped':'transactions'}),
on=['date','store_nbr'], how='left', validate='many_to_one')
# train + stores (상점 메타)
train = pd.merge(train, stores, on='store_nbr', how='left', validate='many_to_one')
# train + holidays (일자 기준)
train = pd.merge(train, hol_s, on='date', how='left', validate='many_to_one')
train['is_holiday'] = train['is_holiday'].fillna(0).astype(int)
7.6 품질 재검증 & 최종 스키마 예시
# 남은 결측치 확인
remain = train.isna().sum()
print("Remaining missing:\n", remain[remain>0].sort_values(ascending=False))
# 타입/범위 검증 예시
assert train['sales'].ge(0).all(), "음의 매출 발견"
assert train['transactions'].isna().mean() < 0.3, "거래량 결측 비율 과다: 데이터 전략 재검토"
7.7 학습을 위한 분할(시계열 CV 스켈레톤)
from sklearn.model_selection import TimeSeriesSplit
def build_xy(df: pd.DataFrame, target: str = 'sales'):
"""
단순 피처/타깃 분리 예시.
- 실제로는 라그/롤링 통계 등 시계열 피처를 추가하세요.
"""
features = ['dcoilwtico','transactions','year','month','dow','is_wknd'] # 최소 예시
X = df[features].copy()
y = df[target].astype(float).copy()
return X, y
X, y = build_xy(train)
tscv = TimeSeriesSplit(n_splits=5)
for i, (tr_idx, va_idx) in enumerate(tscv.split(X)):
X_tr, X_va = X.iloc[tr_idx], X.iloc[va_idx]
y_tr, y_va = y.iloc[tr_idx], y.iloc[va_idx]
print(f"Fold {i}: train={len(X_tr)}, valid={len(X_va)}")
다음 확장 아이디어
- 라그/롤링 피처(판매량/거래량의 7·14·28일 평균, 표준편차, 요일별 이동평균 등)
- 카테고리 인코딩(상점 도시/유형 등)
- 추세/계절성 분해, 프로모션/휴일 상호작용
- 모델 후보: LightGBM/CatBoost/XGBoost(그래디언트 부스팅), LSTM/Temporal Fusion Transformer(딥러닝)
8. 전처리 체크리스트 (실무용)
- 스키마 고정: 열 타입·범위·허용값 정의 → 일괄 검증
- 결측·이상치 처리 로그 남기기: 몇 건, 어떤 규칙으로, 전/후 분포
- 리소스 분리: 학습 시 사용된 전처리기(스케일러/인코더/보간 규칙)를 저장해 추론 시 동일 적용
- 시계열은 누수(leakage) 금지: 미래 정보가 과거 훈련에 섞이지 않도록 주의
9. 요약
- describe(), info()로 구조와 분포를 먼저 파악
- isna()와 시각화로 결측 패턴을 확인하고 제거·대치·보간 중 적합한 전략 선택
- 이상치는 IQR/Z-score 등으로 탐지하고, 상황에 맞게 제거·클리핑·변환
- 날짜는 to_datetime 후 dt 접근자로 파생, 필요 시 시간 기반 보간·리샘플
- 여러 데이터는 merge로 통합하되 키/카디널리티 검증 필수
- 시계열 학습은 누수 방지와 TimeSeriesSplit을 기본으로
부록: 짧은 코드 예시 모음
A. Z-score 분석 출력(학습 데이터 프린트 예시)
import pandas as pd
s = pd.Series([72, 68, 75, 282, 64, 31, 78, 69, 88, 92, 22, 84, 61, -90, 130, 66])
df = pd.DataFrame({'score': s})
mu, sigma = df['score'].mean(), df['score'].std()
df['z'] = (df['score'] - mu) / sigma
df['is_outlier'] = df['z'].abs() > 2
print(df)
print(f"Outlier ratio: {df['is_outlier'].mean()*100:.2f}%")
B. 문자열·날짜 정리 스니펫
df['city'] = df['city'].fillna('Unknown').str.strip().str.title()
df['date'] = pd.to_datetime(df['date'], errors='coerce')
bad = df['date'].isna().sum()
if bad:
print(f"[경고] 변환 실패 날짜 {bad}건 — 원본 포맷 점검 필요")
오픈소스 기반 데이터 분석 7강 — 데이터 전처리 2
Data-Analysis-with-Open-Source/오픈소스_데이터_분석_7강.ipynb at main · mors119/Data-Analysis-with-Open-Source
Data Analysis with Open Source. Contribute to mors119/Data-Analysis-with-Open-Source development by creating an account on GitHub.
github.com
7-1 데이터 결측치 찾기
# --- 결측치 탐색 기본 패턴 ---
import pandas as pd
import numpy as np
data = {'이름': ['김철수', '이영희', '박민수', '최지훈', '정소희'],
'나이': [25, 30, np.nan, 22, 35],
'도시': ['서울', None, '인천', '서울', '대전'],
'점수': [90, 85, np.nan, 80, 92]}
df = pd.DataFrame(data)
# 1) 결측치 여부 (True/False DataFrame)
_ = df.isnull()
_ = df.isna() # isnull과 동일 동작
# 2) 열/행 단위 결측 개수
_ = df.isnull().sum() # 열별 합
_ = df.isnull().sum(axis=1) # 행별 합
# 3) 결측 포함 행, 특정 열 결측 행
_ = df[df.isnull().any(axis=1)]
_ = df[df['나이'].isnull()]
# 4) 결측이 아닌 항목
_ = df.notnull()
# 5) 전체 결측 비율(%)
missing_ratio = df.isnull().sum().sum() / (df.shape[0] * df.shape[1]) * 100
print(missing_ratio) # np.float64(15.0)
# 대안:
# - [import numpy] np.isnan(series.to_numpy()) # 수치형 단일 컬럼 빠른 검사
# - [import pandas] df.info()로 결측 개괄 파악
7-2 시각화로 결측치 찾기
# --- 결측 시각화: heatmap/missingno ---
# 역할: 어디에 결측이 몰려있는지 직관적으로 확인
# Colab에서 한글 폰트 경고 시 나눔 폰트 설치(환경 의존)
# (로컬/서버는 시스템 폰트 사용 권장)
import numpy as np, pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# [import missingno]
import missingno as msno
data = {'이름': ['김철수', '이영희', '박민수', '최지훈', '정소희'],
'나이': [25, 30, np.nan, 22, 35],
'도시': ['서울', None, '인천', '서울', '대전'],
'점수': [90, 85, np.nan, 80, 92]}
df = pd.DataFrame(data)
plt.figure(figsize=(8, 6))
sns.heatmap(df.isnull(), cbar=False)
plt.title("결측치 히트맵"); plt.show()
msno.matrix(df) # 결측 위치/길이/패턴
plt.title("결측치 매트릭스"); plt.show()
# 대안:
# - sns.heatmap(df.isna(), cmap='viridis') # 색상 지도
# - msno.bar(df) # 컬럼별 결측 카운트 바차트
7-3 사분위 범위(IQR)로 이상치 찾기
# --- IQR 기준 하한/상한 계산 ---
import pandas as pd
import numpy as np
점수_데이터 = [72, 68, 75, 282, 64, 31, 78, 69, 88, 92, 22, 84, 61, -90, 130, 66]
학번_데이터 = list(range(1001, 1001 + len(점수_데이터)))
df = pd.DataFrame({'학번': 학번_데이터, '점수': 점수_데이터})
q1 = df['점수'].quantile(0.25)
q3 = df['점수'].quantile(0.75)
iqr = q3 - q1
하한값, 상한값 = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(q1, q3, iqr, 하한값, 상한값, sep="\n")
# 대안:
# - [import numpy] np.percentile(series, [25, 75])
# - 양쪽 컷오프 다르게: 3*IQR 등 문제 특성에 맞게 조정
# - 로버스트 스케일링 후 임계 적용: [import sklearn] RobustScaler
7-4 Z-점수로 이상치 찾기
# --- 표준화(Z-score)로 임계 이상치 ---
import pandas as pd
import numpy as np
점수_데이터 = [72, 68, 75, 282, 64, 31, 78, 69, 88, 92, 22, 84, 61, -90, 130, 66]
학번_데이터 = list(range(1001, 1001 + len(점수_데이터)))
df = pd.DataFrame({'학번': 학번_데이터, '점수': 점수_데이터})
점수_평균, 점수_표준편차 = df['점수'].mean(), df['점수'].std()
df['점수_Z'] = (df['점수'] - 점수_평균) / 점수_표준편차
임계값 = 2 # |Z|>2를 이상치로
df['이상치여부'] = df['점수_Z'].abs() > 임계값
print(df[df['이상치여부']])
print(df['이상치여부'].mean() * 100) # np.float64(12.5)
# 대안:
# - 표준편차 대신 IQR/Median Absolute Deviation(MAD) 사용(로버스트)
# [import scipy] from scipy.stats import median_abs_deviation
# - [import sklearn] StandardScaler 후 임계 적용
7-5 평균값/선형보간으로 결측치 처리
# --- 컬럼별 전략 혼합: 드랍 + 평균 대치 + 보간 ---
import pandas as pd
import numpy as np
file_path = "raw_large_shopping_customer.csv"
df = pd.read_csv(file_path)
print(df.isnull().sum())
# 1) 허용 가능한 결측 개수 기준으로 행 드랍
df_cleaned = df.dropna(thresh=df.shape[1] - 1) # 전체 컬럼-1 이상이 채워진 행만 유지
# 2) 수치형 평균 대치(예: '나이','소득')
num_cols_mean = ['나이', '소득']
df_cleaned.loc[:, num_cols_mean] = df_cleaned[num_cols_mean].fillna(df_cleaned[num_cols_mean].mean())
# 3) 시계열/연속값 보간(예: '지출','평균구매횟수')
cont_cols_interp = ['지출', '평균구매횟수']
df_cleaned.loc[:, cont_cols_interp] = df_cleaned[cont_cols_interp].interpolate(method='linear')
df_cleaned.to_csv("cleaned_large_shopping_customer.csv", index=False, encoding="utf-8-sig")
# 대안:
# - 중앙값 대치: df.fillna(df.median(numeric_only=True))
# - 그룹별 평균 대치: df.groupby('성별')['소득'].transform(lambda s: s.fillna(s.mean()))
# - 시계열 보간: method='time' (DatetimeIndex 필요)
# - 다단계 보간: .interpolate(...).bfill().ffill()
7-6 DataFrame 값 변경(replace/map/apply/where/loc)
# --- 값 변환 패턴 모음 ---
import pandas as pd
import numpy as np
data = {'age': [25, 30, None, 22, 35],
'city': ['Seoul', None, 'Incheon', 'Seoul', 'Daejeon'],
'score': [90, 85, None, 80, 92]}
df = pd.DataFrame(data)
# 1) replace로 단일/다중 값 치환
# df['city'] = df['city'].replace('Seoul', '서울')
# df['city'] = df['city'].replace({None: '미정', 'Incheon': '인천'})
# 2) map으로 매핑(키 없는 값은 NaN)
city_map = {'Seoul': '서울특별시', None: '미정', 'Incheon':'인천광역시', 'Daejeon':'대전광역시'}
df['city'] = df['city'].map(city_map)
# 3) map으로 문자열 가공
df['age_str'] = df['age'].map(lambda x: f"{x}살" if pd.notna(x) else '알수없음')
# 4) apply로 컬럼/행 단위 변환
df['age_apply'] = df['age'].apply(lambda x: x * 2 if pd.notna(x) else np.nan)
def age_plus_score(row):
age = row['age'] if pd.notna(row['age']) else 0 # 주의: 원본 예시의 () → 0으로 수정
score = row['score'] if pd.notna(row['score']) else 0
return age + score
df['age_plus_score'] = df.apply(age_plus_score, axis=1)
# 5) 조건부 치환: loc/where
df.loc[df['score'] < 90, 'score'] = 90 # 90 미만 → 90
df['age_where'] = df['age'].where(df['age'] >= 30, other=0)
print(df)
# 대안:
# - pd.cut/pd.qcut으로 구간화
# - .mask(조건, 값) : where의 반대 동작
# - 문자열 변환 벡터화: df['city'].str.upper()
7-7 날짜 타입 다루기(파싱/추출/포맷)
# --- 문자열 → datetime → 파생 날짜 컬럼 ---
import pandas as pd
date_str = ['2025-07-01', '2025-08-01', '2025-09-01']
df_date = pd.DataFrame({'date_str': date_str})
df_date['date'] = pd.to_datetime(df_date['date_str']) # 자동 파싱(포맷 지정은 format=...)
# 구성 요소 추출
print(df_date['date'].dt.year)
print(df_date['date'].dt.month)
print(df_date['date'].dt.day)
# 포맷 변경
df_date['date_formatted'] = df_date['date'].dt.strftime('%Y/%m/%d')
print(df_date)
# 대안:
# - 시계열 인덱스: df.set_index('date').resample('M').sum()
# - 현지화/타임존: df['date'].dt.tz_localize('UTC').dt.tz_convert('Asia/Seoul')
7-8 실습 — 결측/이상치 처리 + 날짜 파생 + 다중 테이블 통합
# --- 데이터 로드 ---
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
train = pd.read_csv('train.csv')
stores = pd.read_csv('stores.csv')
transactions = pd.read_csv('transactions.csv')
oil = pd.read_csv('oil.csv')
holidays_events = pd.read_csv('holidays_events.csv')
# 분포 확인(한글 폰트 경고는 환경 의존)
plt.figure(figsize=(10, 6))
sns.histplot(train['sales'], bins=50)
plt.title("판매량 분포"); plt.xlabel("판매량"); plt.ylabel("빈도")
plt.xlim(0, 5000); plt.show()
store_sales = train.groupby('store_nbr')['sales'].mean().reset_index()
plt.figure(figsize=(12, 6))
sns.barplot(x='store_nbr', y='sales', data=store_sales)
plt.title("매장별 평균 판매량")
plt.xlabel("매장 번호"); plt.ylabel("평균 판매량")
plt.xticks(rotation=90); plt.show()
# --- 결측 탐색 & 보간(oil) ---
import missingno as msno
print(train.isnull().sum().sum()) # 0
print(stores.isnull().sum().sum()) # 0
print(transactions.isnull().sum().sum()) # 0
print(oil.isnull().sum().sum()) # 43
print(holidays_events.isnull().sum().sum()) # 0
msno.matrix(oil); plt.title("원유 가격 데이터 결측치 분포"); plt.show()
oil['date'] = pd.to_datetime(oil['date'])
plt.figure(figsize=(12, 6))
plt.plot(oil['date'], oil['dcoilwtico']); plt.grid(True)
plt.title("시간에 따른 원유 가격 변화"); plt.xlabel("날짜"); plt.ylabel("원유 가격"); plt.show()
oil_cleaned = oil.copy()
oil_cleaned['dcoilwtico'] = oil_cleaned['dcoilwtico'].interpolate(method='linear') # 선형 보간
plt.figure(figsize=(12, 6))
plt.plot(oil_cleaned['date'], oil_cleaned['dcoilwtico'])
plt.title("원유 가격 보간 후 데이터"); plt.xlabel("날짜"); plt.ylabel("원유 가격")
plt.grid(True); plt.show()
# 대안:
# - time 보간: interpolate(method='time') # DatetimeIndex 필요
# - 앞/뒤 채우기: .ffill().bfill()
# --- IQR 기반 이상치 탐지 & 상한 대체 ---
# 역할: family별 판매량 상한(upper_bound)을 계산하여 초과치를 상한으로 캡
# KeyError 수정 포인트: 'upper_bound'는 train_enhanced에 있으므로
# 대체 시 해당 컬럼을 train_cleaned로 먼저 병합/복사해야 함.
family_bounds = train.groupby('family')['sales'].apply(
lambda x: x.quantile(0.75) + 1.5 * (x.quantile(0.75) - x.quantile(0.25))
)
train_enhanced = train.copy()
train_enhanced['upper_bound'] = train_enhanced['family'].map(family_bounds)
train_enhanced['is_outlier'] = train_enhanced['sales'] > train_enhanced['upper_bound']
print(f"이상치 개수: {train_enhanced['is_outlier'].sum():,}건")
print(f"전체 대비 비율: {train_enhanced['is_outlier'].mean():.2%}")
plt.figure(figsize=(15, 6))
sns.stripplot(x='family', y='sales',
hue=train_enhanced['is_outlier'],
data=train_enhanced, palette={False:'blue', True:'red'})
plt.xticks(rotation=90); plt.yscale('log')
plt.title('계열별 판매량 분포 (파랑: 정상, 빨강: 이상)')
plt.show()
# 1) 상한 대체를 위해 'upper_bound'를 train_cleaned에 포함
train_cleaned = train.copy()
train_cleaned['upper_bound'] = train_cleaned['family'].map(family_bounds)
# 2) 이상치 마스크 작성
mask = train_cleaned['sales'] > train_cleaned['upper_bound']
# 3) 이상치 값을 상한으로 치환
train_cleaned.loc[mask, 'sales'] = train_cleaned.loc[mask, 'upper_bound']
# 4) 변경 건수 확인
changed_count = (train['sales'] != train_cleaned['sales']).sum()
print(f"대체된 데이터 수: {changed_count}건")
# 대안:
# - 로그 변환 후 모델링: train.assign(sales=np.log1p(train['sales']))
# - 윈저라이즈: [import scipy] from scipy.stats.mstats import winsorize
# --- 날짜 파생 컬럼 생성 ---
train_cleaned['date'] = pd.to_datetime(train_cleaned['date'])
train_cleaned['year'] = train_cleaned['date'].dt.year
train_cleaned['month'] = train_cleaned['date'].dt.month
train_cleaned['day'] = train_cleaned['date'].dt.day
train_cleaned['dayofweek'] = train_cleaned['date'].dt.dayofweek # 월=0 … 일=6
# weekend 계산 버그 수정: '>= 5'가 토/일
train_cleaned['weekend'] = (train_cleaned['dayofweek'] >= 5).astype(int)
train_cleaned['is_month_start'] = train_cleaned['date'].dt.is_month_start
train_cleaned['is_month_end'] = train_cleaned['date'].dt.is_month_end
print("날짜 특성 추출 결과:")
print(train_cleaned[['date','year','month','day','dayofweek','weekend','is_month_start','is_month_end']].head())
# 요일별 평균 판매량
day_sales = train_cleaned.groupby('dayofweek')['sales'].mean().reset_index()
day_sales['day_name'] = day_sales['dayofweek'].map({0:'월',1:'화',2:'수',3:'목',4:'금',5:'토',6:'일'})
plt.figure(figsize=(10, 6))
sns.barplot(x='day_name', y='sales', data=day_sales)
plt.title("요일별 평균 판매량"); plt.xlabel("요일"); plt.ylabel("평균 판매량")
plt.show()
# 대안:
# - 공휴일/이벤트 조인으로 특일 플래그 추가
# - 주차/분기: dt.isocalendar().week, dt.quarter
# --- 다중 테이블 통합(merge) ---
# 순서: 판매+거래 → 매장 → 원유(보간본)
train_with_trans = pd.merge(
train_cleaned, transactions,
on=['date', 'store_nbr'],
how='left'
)
train_with_store = pd.merge(
train_with_trans, stores,
on='store_nbr',
how='left'
)
# oil_cleaned['date']는 datetime, train_with_store['date']도 datetime이어야 조인 원활
train_final = pd.merge(
train_with_store, oil_cleaned,
on='date',
how='left'
)
print("최종 데이터셋 정보:")
print(f"행 개수: {train_final.shape[0]}, 열 개수: {train_final.shape[1]}")
print(train_final.columns.tolist())
print(train_final.head())
# 대안:
# - 키 다중화: on=['date','store_nbr','family'] (문제 정의에 맞게)
# - 조인 성능: [import polars] 또는 [import duckdb] 활용
# - 인덱스 조인: .join(다른DF.set_index('date'), on='date')