| A | B | C | |
|---|---|---|---|
| 1 | 2.0 | NaN | 2 |
| 2 | NaN | 3.0 | 3 |
| 3 | 4.0 | NaN | 4 |
6 변환과 정제
정제는 결측치, 이상치, 오기입 등 데이터 품질 문제를 다루는 작업입니다. 결측과 이상과 같은 분야는 일반적으로 통계를 전공하거나 해당 분야에서 오랜기간 데이터를 다뤄본 전문가에게 맡겨집니다. 따라서, 우리는 결측과 이상에 대한 기초적인 관점을 가지고 해당 문제를 해결하는 것을 목표로 합니다.
데이터 분석과 모델링의 80%는 ’데이터를 어떻게 정리하고 변환 할 것인가’에 달려 있습니다[1]. Hadley Wickham의 Tidy Data논문에 따르면, 분석 시간의 상당 부분이 정제와 변환에 쓰이며, 일관된 구조(각 변수는 열, 각 관측은 행, 각 값은 한 칸)를 갖추는 것이 이후 도구 사용과 해석을 크게 쉽게 합니다.
6.1 결측치
결측치(missing data)는 특정 관측값이 누락된 것을 의미합니다. 이는 데이터 분석과 모델링에 큰 영향을 줄 수 있습니다. 예를 들어, 결측이 있는 경우 추정치 편향, 정보 손실, 검정력 감소, 표준오차 증가 등으로 이어질 수 있습니다. 따라서 결측이 왜 발생했는지를 먼저 생각한 뒤, 삭제, 대체, 모델 기반 방법 중 하나를 선택하는 것이 좋습니다. 결측치를 크게 세 가지로 나눌 수 있습니다.
6.1.1 유형 이해하기
결측치를 처리하기 전에, 결측이 무작위성과 어떻게 관련되는지 이해하는 것이 중요합니다. 아래 세 유형은 Rubin(1976)[2] 이후 널리 쓰이는 분류입니다.
MCAR (Missing Completely At Random): 결측이 관측된 값, 비관측 값 모두와 무관하게 완전히 무작위로 발생합니다. 예를 들어, 설문조사에서 응답자가 무작위로 일부 문항을 건너뜀, 실험 장비의 무작위 고장 등이 대표적입니다. 이 경우에 한해 단순 삭제(listwise deletion)나 평균, 중앙값 대체가 비교적 안전합니다. 다만 표본 수가 줄어들어 검정력이 떨어질 수 있음을 감안해야 합니다.
MAR (Missing At Random): 결측 여부가 관측된 다른 변수와는 관련 있지만, 결측값 자체와는 (그 다른 변수를 조건으로 했을 때) 무관하다고 가정하는 경우입니다. 예를 들어, 고소득자일수록 소득 문항을 비공개하는 경향이 있지만, 소득 자체가 비공개를 ’설명’하지는 않는다고 보는 경우입니다. 다른 변수를 활용한 회귀 대체, 다중 대체(multiple imputation), 최대우도법 등 모델 기반 방법이 권장됩니다. pandas만으로는 한계가 있으므로, 필요 시 statsmodels, scikit-learn 등과 함께 사용합니다.
MNAR (Missing Not At Random): 결측이 비관측값 자체와 관련이 있는 경우입니다. 예를 들어, 낮은 점수를 받은 학생이 성적을 숨기는 경우, 극단적 온도에서 센서가 고장나는 경우. 어렵고, 도메인 지식과 (가능하다면) 결측 메커니즘을 명시한 모델이 필요합니다. 단순 평균 대체나 삭제는 편향을 키울 수 있습니다.
실무에서는 MCAR/MAR/MNAR을 정확히 구분하기 어렵기 때문에, 가능하면 결측 비율과 패턴을 먼저 탐색하고, 도메인 전문가와 상의한 뒤 방법을 정하는 것이 좋습니다.
6.1.2 결측치 탐지
어떤 방법을 쓸지 정하기 전에, 결측이 어디에 얼마나 있는지 파악해야 합니다. 열별 결측 개수, 비율, 결측이 몰려 있는 행, 열, 그리고 결측 패턴(특정 그룹에만 결측이 많은지)을 보면, 삭제, 대체, 그룹별 처리 중 무엇이 적절한지 판단하는 데 도움이 됩니다. pandas에서는 isnull()(또는 isna())이 결측 위치를 불리언으로 반환하고, sum()을 쓰면 열별 결측 개수, any(axis=1)을 쓰면 ’결측이 하나라도 있는 행’을 골라낼 수 있습니다.
6.1.3 결측치 채우기
결측을 삭제하지 않고 다른 값으로 바꾸는 것을 대체(imputation)라고 합니다. 단순히 상수(0, 빈 문자열 등)나 열의 평균, 중앙값으로 채우는 방법은 구현이 쉽고 pandas만으로 가능합니다. 열마다 다른 규칙을 쓰고 싶으면 fillna()에 딕셔너리(열 이름 → 대체값)를 넘기면 됩니다. 연구, 보고서에서는 다중 대체(multiple imputation)나 최대우도법 같은 방법이 선호되지만, 탐색적 분석이나 파이프라인 구축 단계에서는 pandas의 fillna()로 빠르게 처리한 뒤, 필요 시 나중에 더 정교한 방법으로 교체하는 경우가 많습니다.
import pandas as pd
import numpy as np
df = pd.DataFrame(
{
"A": [1, 2, np.nan, 4, 5],
"B": [1.5, np.nan, 3.5, np.nan, 5.5],
"C": ["a", "b", np.nan, "d", "e"],
}
)
df_filled_zero = df.fillna(0)
df_filled_dict = df.fillna({"A": 0, "B": df["B"].mean()})
df_filled_mean = df.fillna(df.mean(numeric_only=True))
df = df.fillna(0)
df| A | B | C | |
|---|---|---|---|
| 0 | 1.0 | 1.5 | a |
| 1 | 2.0 | 0.0 | b |
| 2 | 0.0 | 3.5 | 0 |
| 3 | 4.0 | 0.0 | d |
| 4 | 5.0 | 5.5 | e |
df.fillna(0)는 모든 결측값을 0으로 채우는 것을 보여줍니다. df.fillna({"A": 0, "B": df["B"].mean()})는 A열은 0으로, B열은 B열의 평균값으로 채우는 것을 보여줍니다. df.fillna(df.mean(numeric_only=True))는 숫자형 열의 평균값으로 채우는 것을 보여줍니다. 결측치를 채우는 방법은 여러 가지가 있으므로, 상황에 따라 적절한 방법을 선택하는 것이 중요합니다.
df = df.fillna(0)는 결과를 변수에 다시 넣는 방식을 보여줍니다. pandas 3.x에서는 fillna(), dropna() 등에서 inplace=True를 써도 수정된 DataFrame을 반환합니다. 다만 함수형 스타일의 메서드 체이닝과의 일관성을 위해 inplace=False(기본값)를 쓰고, 결과를 변수에 다시 넣는 방식(df = df.fillna(0))을 권장합니다. Copy-On-Write 덕분에 불필요한 복사 비용은 거의 없습니다. inplace의 의미가 이렇게 정리된 배경은 PDEP-8: In-place methods in pandas에 정리되어 있습니다.
6.1.4 Forward Fill과 Backward Fill
시계열에서는 ’직전(또는 직후) 관측값을 그대로 유지한다’는 가정이 맞을 때가 많습니다. 예를 들어 주가, 센서 값은 갱신 전까지 이전 값을 유지하고, 설비 점검 중에는 마지막 측정값이 유효하다고 보는 경우가 있습니다. Forward fill(ffill)은 결측을 그 이전 행의 값으로 채우고, backward fill(bfill)은 그 다음 행의 값으로 채웁니다. 연속된 결측이 길면 같은 값이 길게 이어질 수 있으므로, ’구간이 짧을 때나 값이 거의 변하지 않는다’고 볼 때 쓰는 것이 적절합니다.
df_ffill() 결측을 이전 값으로 채우는 것을 보여줍니다.
| date | value | |
|---|---|---|
| 0 | 2024-01-01 | 1.0 |
| 1 | 2024-01-02 | 2.0 |
| 2 | 2024-01-03 | 2.0 |
| ... | ... | ... |
| 7 | 2024-01-08 | 8.0 |
| 8 | 2024-01-09 | 9.0 |
| 9 | 2024-01-10 | 10.0 |
10 rows × 2 columns
df_bfill()는 결측을 다음 값으로 채우는 것을 보여줍니다.
6.1.5 보간법
결측 구간 앞뒤 값을 이용해 중간 값을 추정하는 방법입니다. 보간은 ’결측이 무작위이고, 값이 시간에 따라 부드럽게 변한다’고 가정할 때 유용합니다. 결측이 극단적 상황(센서 고장 등)과 연관되어 있다면, 보간이 오히려 잘못된 패턴을 만들 수 있으므로 이 또한 관련 데이터의 특성을 고려하고, 전문가의 자문이 필요합니다.
선형 보간(linear)은 인덱스가 등간격일 때 앞뒤 두 점을 잇는 직선 위의 값으로 채웁니다.
| date | value | |
|---|---|---|
| 0 | 2024-01-01 | 1.0 |
| 1 | 2024-01-02 | 2.0 |
| 2 | 2024-01-03 | 3.0 |
| ... | ... | ... |
| 7 | 2024-01-08 | 8.0 |
| 8 | 2024-01-09 | 9.0 |
| 9 | 2024-01-10 | 10.0 |
10 rows × 2 columns
시간 기반 보간(method="time")은 인덱스가 DatetimeIndex일 때 실제 시간 간격을 반영하므로, 관측 시점이 들쭉날쭉한 시계열에 적합합니다.
| value | |
|---|---|
| date | |
| 2024-01-01 | 1.0 |
| 2024-01-02 | 2.0 |
| 2024-01-03 | 3.0 |
| ... | ... |
| 2024-01-08 | 8.0 |
| 2024-01-09 | 9.0 |
| 2024-01-10 | 10.0 |
10 rows × 1 columns
다항식 보간은 더 부드러운 곡선을 만들 수 있지만, 구간 끝에서 진동이 생길 수 있어 해석에 주의가 필요합니다.
| value | |
|---|---|
| date | |
| 2024-01-01 | 1.0 |
| 2024-01-02 | 2.0 |
| 2024-01-03 | 3.0 |
| ... | ... |
| 2024-01-08 | 8.0 |
| 2024-01-09 | 9.0 |
| 2024-01-10 | 10.0 |
10 rows × 1 columns
df_linear()는 선형 보간 결과를 보여주고, df_time()은 시간 기반 보간 결과를 보여주고, df_poly()는 다항식 보간 결과를 보여줍니다.
6.1.6 그룹별 결측치 처리
데이터가 그룹(성별, 지역, 제품군 등)으로 나뉘어 있고, 그룹마다 분포가 다르다면 그룹별로 결측치를 처리하는 것이 타당합니다. 예를 들어 ‘성별별 평균’, ‘지역별 중앙값’ 등으로 채우면, 전체 평균으로 채울 때보다 그룹 구조를 더 잘 반영합니다. pandas에서는 groupby(...).transform(함수)를 쓰면, 각 그룹에 함수를 적용한 결과를 원본 행 수와 같은 길이로 돌려주므로, fillna()에 그대로 넘겨 그룹별 평균, 중앙값 등으로 채울 수 있습니다.
import pandas as pd
import numpy as np
import seaborn as sns
tips = sns.load_dataset("tips")
tips_with_na = tips.copy()
missing_indices = np.random.choice(
tips.index, size=20, replace=False
)
tips_with_na.loc[missing_indices, "total_bill"] = np.nan
def fill_na_mean(x):
avg = x.mean()
return x.fillna(avg)
tips_filled = tips_with_na.groupby("sex", observed=True)[
"total_bill"
].transform(fill_na_mean)
tips_with_na["total_bill_filled"] = tips_filled
tips_with_na["total_bill_filled"]0 16.990000
1 10.340000
2 21.010000
...
241 20.898194
242 17.820000
243 18.780000
Name: total_bill_filled, Length: 244, dtype: float64
tips_filled는 그룹별 평균으로 결측치를 채운 결과를 보여줍니다. 이를 위해서 fill_na_mean() 함수를 정의하고, groupby("sex", observed=True)["total_bill"].transform(fill_na_mean)으로 그룹별 평균으로 결측치를 채운 결과를 보여줍니다.
6.1.7 결측치 삭제
결측을 대체하지 않고 제거하는 방법입니다. dropna()는 기본적으로 결측이 하나라도 있는 행을 모두 버립니다. subset=["A"]를 주면 특정 열에만 결측이 있을 때만 해당 행을 삭제하고, "how="all"은 모든 열이 결측인 행만 삭제합니다. thresh=n은 “비결측 값이 최소 \(n\)개 이상인 행만 남긴다”는 의미이므로, 결측이 많지만 일부 열이라도 있는 행을 유지하고 싶을 때 씁니다. 삭제는 표본 수를 줄이므로, 결측 비율이 높거나 MAR(missing at random)/MNAR(missing not at random)이 의심되면 대체나 모델 기반 방법을 고려하는 것이 좋습니다.
df_dropped는 모든 결측치가 있는 행을 삭제한 결과를 보여줍니다.
df_dropped_col는 A열에 결측치가 있는 행을 삭제한 결과를 보여줍니다.
df_dropped_all는 모든 열이 결측인 행을 삭제한 결과를 보여줍니다.
df_dropped_thresh는 비결측 값이 최소 2개 이상인 행만 남긴 결과를 보여줍니다.
6.2 이상치
이상치(outlier)는 나머지 데이터의 분포나 패턴에서 크게 벗어난 관측값입니다. 오타, 센서 오류, 실험 사고로 생긴 것일 수도 있고, 진짜 극단적인 사례일 수도 있어, ’제거할지, 남길지, 다르게 다룰지’는 분석 목적과 도메인 지식에 따라 달라집니다.
제거하면 정보를 잃고, 남겨두면 평균, 분산, 회귀 계수 등이 왜곡될 수 있으므로, 이상치는 탐지한 뒤 시각화와 함께 판단하는 것이 좋습니다. 통계적으로 널리 쓰이는 IQR, Z-score 방법과, 처리 전략(삭제, 제한, 대체)을 pandas로 적용하는 방법을 다룹니다.
6.2.1 IQR 방법
IQR(사분위 범위, Interquartile Range)는 3사분위수(Q3)에서 1사분위수(Q1)를 뺀 값으로, 데이터의 가운데 50%가 퍼져 있는 폭을 나타냅니다. IQR 방법은 ’Q1 − 1.5 * IQR 보다 작거나, Q3 + 1.5 * IQR 보다 큰 값’을 이상치로 간주합니다. 평균과 표준편차를 쓰지 않고 사분위수만 쓰기 때문에, 극단값에 덜 민감합니다. 따라서 분포가 한쪽으로 치우친(왜도가 큰) 데이터, 소득, 주택 가격처럼 실무에서 자주 나오는 형태에 적합합니다. 정규분포를 가정하지 않아도 되므로, 분포를 잘 모를 때도 사용할 수 있습니다.
| value | |
|---|---|
| 0 | 107.450712 |
| 1 | 97.926035 |
| 2 | 109.715328 |
| ... | ... |
| 1017 | 195.161142 |
| 1018 | 196.796527 |
| 1019 | 204.241659 |
1020 rows × 1 columns
먼저 IQR을 계산하고, lower_bound와 upper_bound를 계산합니다
61.08646066960378 139.84167097524565
outliers_mask는 이상치를 탐지하는 마스크를 생성합니다.
np.int64(27)
df_clean는 이상치를 제거한 결과를 보여줍니다.
6.2.2 Z-score 방법
Z-score는 ’(값 - 평균) / 표준편차’로, 평균에서 표준편차 몇 배만큼 떨어져 있는지를 나타냅니다. 보통 \(|Z| > 3\) (또는 \(|Z| > 2.5\))인 점을 이상치로 봅니다. 이 방법은 정규분포에 가까운 데이터에서 해석이 직관적이고, 품질 관리, 공정 모니터링처럼 정규성을 가정하는 상황에서 자주 씁니다. 단, 평균과 표준편차 자체가 극단값에 크게 끌려가기 때문에, 이상치가 이미 많이 섞여 있으면 ’진짜 극단값’이 Z-score로는 잘 걸리지 않을 수 있습니다. 분포가 심하게 비대칭이거나 이상치 비율이 높다면 IQR 방법을 먼저 고려하는 것이 안전합니다.
z_scores는 Z-score를 계산한 결과를 보여줍니다. threshold는 이상치를 탐지하는 임계값을 설정합니다. outliers_mask는 이상치를 탐지하는 마스크를 생성합니다. df_clean는 이상치를 제거한 결과를 보여줍니다.
6.2.3 시각화를 통한 이상치 탐지
수치만으로 경계를 정하기 전에 시각화로 분포와 극단값을 확인하는 것이 좋습니다. 박스플롯(boxplot)은 사분위수, IQR, 보통 1.5 * IQR 밖의 점을 점으로 표시하므로, IQR 기준 이상치를 한눈에 볼 수 있습니다. 히스토그램은 분포 형태와 꼬리, 이상치로 인한 봉우리 왜곡을 파악하는 데 유용합니다. 평균과 중앙값을 세로선으로 겹쳐 그리면, 이상치가 평균을 어느 쪽으로 끌어당기는지도 확인할 수 있습니다. 이상치 후보를 찾았다면, ’오류인지, 실제 극단 사례인지’는 도메인 전문가와 함께 판단하는 것이 좋습니다.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import platform
# 운영체제별 한글 폰트 설정
if platform.system() == "Darwin":
plt.rcParams["font.family"] = "Apple SD Gothic Neo"
elif platform.system() == "Windows":
plt.rcParams["font.family"] = "Malgun Gothic"
else:
plt.rcParams["font.family"] = "NanumGothic"
plt.rcParams["axes.unicode_minus"] = False
np.random.seed(42)
data = np.random.normal(100, 15, 100)
data = np.append(data, [250, 300, -50])
df = pd.DataFrame({"value": data})
plt.figure(figsize=(7, 5))
sns.boxplot(data=df, y="value")
plt.title("Boxplot을 통한 이상치 탐지")
plt.show()
plt.figure(figsize=(7, 5))
plt.hist(df["value"], bins=30, edgecolor="black")
plt.axvline(
df["value"].mean(),
color="red",
linestyle="--",
label="Mean",
)
plt.axvline(
df["value"].median(),
color="green",
linestyle="--",
label="Median",
)
plt.xlabel("Value")
plt.ylabel("Frequency")
plt.title("Histogram을 통한 이상치 탐지")
plt.legend()
plt.show()
6.2.4 이상치 처리 전략
이상치를 탐지한 뒤에는 1) 삭제, 2) 상, 하한으로 자르기(capping/winsorization), 3) 다른 값으로 대체(중앙값) 중 하나를 선택합니다. 삭제는 오타, 기계 오류처럼 명백한 오류일 때 적합하고, 극단적이지만 유효한 관측은 삭제하면 정보를 잃습니다. 상, 하한 자르기는 clip(lower=..., upper=...)로 구현할 수 있으며, 극단값의 영향만 줄이고 행 자체는 유지할 때 씁니다. 중앙값(또는 그룹별 중앙값)으로 대체하면 분포 중심은 유지하면서 극단값만 완화할 수 있습니다. 어떤 전략을 쓸지는 ’이상치의 원인’과 ’분석 목적(설명 vs 예측, 보고서 vs 모델)’에 따라 정하는 것이 좋습니다.
import pandas as pd
import numpy as np
df = pd.DataFrame(
{
"value": [
1.0,
2.0,
3.0,
4.0,
5.0,
100.0,
6.0,
7.0,
8.0,
9.0,
]
}
)
df_dropped = df[df["value"] < 50]
Q1 = df["value"].quantile(0.25)
Q3 = df["value"].quantile(0.75)
IQR = Q3 - Q1
upper_bound = Q3 + 1.5 * IQR
lower_bound = Q1 - 1.5 * IQR
df_capped = df.copy()
df_capped["value"] = df_capped["value"].clip(
lower=lower_bound, upper=upper_bound
)
median_value = df["value"].median()
df_median = df.copy()
df_median.loc[df_median["value"] > 50, "value"] = (
median_value
)
df_median| value | |
|---|---|
| 0 | 1.0 |
| 1 | 2.0 |
| 2 | 3.0 |
| ... | ... |
| 7 | 7.0 |
| 8 | 8.0 |
| 9 | 9.0 |
10 rows × 1 columns
df_dropped는 100 이상의 값을 삭제한 결과를 보여줍니다. df_capped는 상한/하한으로 제한한 결과를 보여줍니다. df_median는 중앙값으로 대체한 결과를 보여줍니다.
6.3 타입 변환
데이터를 읽은 뒤 열의 타입(dtype)이 기대와 다르거나, 결측 때문에 의도치 않게 float로 올라가는 경우가 많습니다. 타입을 맞추지 않으면 집계, 조인, 시각화에서 오류가 나거나, 메모리, 연산이 비효율적일 수 있습니다. Nullable 정수, 문자열과 타입 변환 시 주의할 점을 다룹니다.
6.3.1 Nullable 정수형
결측값이 포함된 정수형 컬럼을 위한 명시적 nullable integer 타입(Int64, Int32 등)이 지원됩니다. 이 타입을 사용하면 ’정수열에 결측이 있어 float로 변환된다’는 불편을 피할 수 있습니다. 즉, 결측치가 포함된 정수 시리즈 역시 .astype("Int64") 또는 생성 시 dtype="Int64"로 지정하여 안전하게 다룰 수 있습니다. 예를 들어, s_old는 결측이 포함된 기본 시리즈로 dtype=float64가 되지만, s_new는 dtype="Int64"로 지정되어 결측을 포함해도 정수형을 그대로 유지합니다.
6.3.2 Nullable 문자열
pandas 3.x에서 문자열 데이터의 기본 dtype이 str로 변경되어 object 대신 자동 추론됩니다. 이는 문자열 처리 성능과 타입 안전성을 향상시킵니다. str dtype은 기존 nullable string dtype의 변형으로, 결측치를 pd.NA가 아닌 NaN(np.nan)으로 일관되게 처리합니다. 이는 다른 기본 dtype(float64)과의 일관성을 위한 설계입니다.
6.3.3 타입 변환 시 주의사항
문자열이 섞인 숫자 열은 pd.to_numeric(..., errors="coerce")로 변환하면 숫자가 아닌 부분이 NaN이 되고, 그다음 astype("Int64")로 Nullable 정수로 바꿀 수 있습니다. 실수를 정수로 바꿀 때는 소수 이하가 버려지므로, 반올림이 필요하면 먼저 round()를 적용해야 합니다. 타입 변환 후 dtypes와 샘플 행을 확인하면, 의도치 않게 NaN이 늘어나거나 잘못 변환된 열을 빠르게 찾을 수 있습니다.
6.4 문자열 처리
실무 데이터에서는 이름, 주소, 코드, 로그 메시지 등 문자열 열이 많고, 대소문자, 공백, 구분자, 패턴이 제각각인 경우가 흔합니다. 그대로 두면 그룹화, 매칭, 시각화에서 오류가 나므로, 정규화, 분할, 치환, 추출 같은 문자열 정제가 필요합니다. pandas의 str 접근자는 문자열 열에 대해 NumPy/pandas 스타일의 벡터화 연산을 제공합니다. 즉, 행마다 for 루프를 돌리지 않고 한 번에 upper(), split(), replace() 등을 적용할 수 있습니다. 이 절에서는 자주 쓰는 str 메서드와 정규표현식을 활용한 추출, 치환을 다룹니다.
6.4.1 기본 문자열 연산
import pandas as pd
df = pd.DataFrame(
{
"name": ["John Doe", "Jane Smith", "Bob Johnson"],
"email": [
"john@example.com",
"jane@test.com",
"bob@sample.org",
],
}
)
df["name_upper"] = df["name"].str.upper()
df["name_lower"] = df["name"].str.lower()
df["name_title"] = df["name"].str.title()
df["name_length"] = df["name"].str.len()
df["name_stripped"] = df["name"].str.strip()upper(), lower(), title(), len(), strip() 메서드를 사용해 문자열 열을 정규화합니다. upper()는 모두 대문자로, lower()는 모두 소문자로, title()는 각 단어의 첫 글자를 대문자로, len()은 문자열 길이를 반환하고, strip()은 문자열 양쪽의 공백을 제거합니다. 해당 사항은 Python에서 다루는 메서드와 동일합니다.
6.4.2 문자열 분할
한 열에 ‘이름 공백 성’, ‘주소, 도시, 주’처럼 구분자로 이어진 값이 있을 때, str.split(구분자, expand=True, n)를 쓰면 구분자 기준으로 쪼개서 여러 열로 만들 수 있습니다. expand=True는 분할 결과를 DataFrame으로 반환합니다. 그리고 n=1을 주면 구분자를 처음 한 번만 나누어 두 개 열로 만들고(’John Doe James’ \(\to\) ‘John’, ‘Doe James’), n=2를 주면 구분자를 두 번 나누어 세 개 열로 만듭니다(‘John Doe James’ \(\to\) ‘John’, ‘Doe’, ‘James’).
import pandas as pd
df = pd.DataFrame(
{
"full_name": [
"John Doe",
"Jane Smith",
"Bob Johnson",
],
"address": [
"123 Main St, New York, NY",
"456 Oak Ave, Los Angeles, CA",
"789 Pine Rd, Chicago, IL",
],
}
)
df[["first_name", "last_name"]] = df["full_name"].str.split(
" ", expand=True, n=1
)
df[["street", "city", "state"]] = df["address"].str.split(
", ", expand=True
)6.4.3 정규표현식을 활용한 추출
고정된 구분자 대신 패턴으로 부분 문자열을 뽑고 싶을 때는 ‘str.extract’(정규식)을 씁니다. 정규식에 괄호로 묶은 캡처 그룹이 있으면, 각 그룹이 한 열이 되어 DataFrame이 반환됩니다.
예를 들어 r"^(\w+):"는 ’처음부터 단어 문자만 : 전까지’를 첫 번째 열로, r"(\d+)"는 ’연속된 숫자’를 추출합니다. 이메일에서 r"([^@]+)@(.+)"로 사용자명과 도메인을 나누는 것처럼, 반복되는 형식이 있으면 extract로 일괄 정제할 수 있습니다.
일반적으로 문자열을 다루는데 있어서 정규식을 뛰어넘는 기술이나 기교는 거의 없습니다. 따라서, 문자열을 정교하게 다루는 분들은 가능하면 정규식을 별도로 학습하시는 것을 권장합니다.
import pandas as pd
df = pd.DataFrame(
{
"text": [
"John: 25 years old",
"Jane: 30 years old",
"Bob: 35 years old",
],
"email": [
"john.doe@example.com",
"jane.smith@test.com",
"bob@sample.org",
],
}
)
df["name"] = df["text"].str.extract(r"^(\w+):")
df["age"] = df["text"].str.extract(r"(\d+)").astype("Int64")
df[["username", "domain"]] = df["email"].str.extract(
r"([^@]+)@(.+)"
)r"^(\w+):"에서 ’w’는 단어 문자(알파벳, 숫자, _)를 의미합니다. 따라서 r"^(\w+):"는 ’처음부터 단어 문자만 : 전까지’를 추출합니다. r"(\d+)"는 ’연속된 숫자’를 추출합니다. r"([^@]+)@(.+)"는 “이메일 주소에서 사용자명과 도메인을 나누는 것”을 의미합니다.
6.4.4 문자열 치환
특정 문자, 문구를 없애거나 다른 문자열로 바꿀 때는 str.replace(찾을_문자열, 바꿀_문자열)을 씁니다. 하이픈 제거, 전화번호 포맷 통일, 불필요한 접두사 제거 등에 자주 쓰입니다.
정규식을 쓰려면 regex=True를 반드시 명시해야 합니다. 이를 빠뜨리면 패턴 문자열이 그냥 리터럴로 취급되어 아무것도 치환되지 않습니다. 문제는 오류도 경고도 나지 않고 원본이 그대로 남는다는 점입니다. 결과를 눈으로 확인하지 않으면 ’정제했다고 믿는 원본 데이터’를 그대로 들고 다음 단계로 넘어가게 됩니다.
아래 예제에서 phone_clean은 하이픈이라는 리터럴을 지우는 것이므로 regex가 필요 없지만, phone_formatted는 그룹 캡처와 백레퍼런스(r"\1")를 쓰는 정규식이므로 regex=True가 반드시 필요합니다.
import pandas as pd
df = pd.DataFrame(
{
"phone": [
"010-1234-5678",
"010-9876-5432",
"02-123-4567",
]
}
)
# 리터럴 치환 - regex 불필요
df["phone_clean"] = df["phone"].str.replace("-", "")
# 정규식 치환 - regex=True를 빠뜨리면 조용히 아무 일도 일어나지 않는다
df["phone_formatted"] = df["phone"].str.replace(
r"(\d{3})-(\d{4})-(\d{4})", r"(\1) \2-\3", regex=True
)
df| phone | phone_clean | phone_formatted | |
|---|---|---|---|
| 0 | 010-1234-5678 | 01012345678 | (010) 1234-5678 |
| 1 | 010-9876-5432 | 01098765432 | (010) 9876-5432 |
| 2 | 02-123-4567 | 021234567 | 02-123-4567 |
마지막 행 02-123-4567은 패턴(3자리-4자리-4자리)과 맞지 않아 치환되지 않고 원본 그대로 남습니다. 정규식 치환은 이렇게 ‘일치하지 않는 값을 조용히 통과시키므로’, 변환 후 결과를 눈으로 확인하는 습관이 필요합니다.
6.4.5 문자열 포함 여부 확인
특정 단어, 패턴이 포함, 시작, 끝인지에 따라 행을 걸러내거나 플래그 열을 만들 때는 str.contains(), str.startswith(), str.endswith()를 씁니다. str.contains(패턴, case=False)로 대소문자를 무시할 수 있고, 정규식을 쓰면 Python 또는 Java처럼 여러 패턴을 한 번에 검사할 수 있습니다. 필터링(df[df["desc"].str.contains("Python")])이나 분류용 열을 만드는 데 유용합니다.
import pandas as pd
df = pd.DataFrame(
{
"description": [
"Python programming",
"Java development",
"Python data analysis",
"R statistics",
]
}
)
df["has_python"] = df["description"].str.contains(
"Python", case=False
)
df["starts_with"] = df["description"].str.startswith(
"Python"
)
df["ends_with"] = df["description"].str.endswith("ing")6.5 예제: 결측치와 이상치
UCI에서 공개한 가정용 전력 소비(Household Power Consumption) 데이터를 사용해, 앞에서 다룬 결측치 탐지, 대체와 이상치 탐지, 처리를 한 번에 적용해 보겠습니다. 데이터는 1분 단위로 측정된 전력 사용량이며, 날짜, 시간과 함께 글로벌 유효/무효 전력, 전압, 전류, 세 개의 서브미터(주방, 세탁, 에어컨 등) 값이 포함됩니다. 원본에서는 결측이 ?로 기록되어 있으므로, 읽을 때 na_values=['?']를 지정해 NaN으로 처리해야 합니다.
6.5.1 데이터 읽기 및 결측치 탐지
import pandas as pd
import numpy as np
df = pd.read_parquet(
"../data/household_power_consumption.parquet"
)
df["datetime"] = pd.to_datetime(
df["Date"].astype(str) + " " + df["Time"].astype(str),
format="%d/%m/%Y %H:%M:%S",
errors="coerce",
)
print("Shape:", df.shape)
print("컬럼:", df.columns.tolist())
print("\n열별 결측치 개수:")
print(df.isnull().sum())
print("\n열별 결측치 비율 (%):")
print((df.isnull().sum() / len(df) * 100).round(2))Shape: (2075259, 10)
컬럼: ['Date', 'Time', 'Global_active_power', 'Global_reactive_power', 'Voltage', 'Global_intensity', 'Sub_metering_1', 'Sub_metering_2', 'Sub_metering_3', 'datetime']
열별 결측치 개수:
Date 0
Time 0
Global_active_power 25979
...
Sub_metering_2 25979
Sub_metering_3 25979
datetime 0
Length: 10, dtype: int64
열별 결측치 비율 (%):
Date 0.00
Time 0.00
Global_active_power 1.25
...
Sub_metering_2 1.25
Sub_metering_3 1.25
datetime 0.00
Length: 10, dtype: float64
결측이 어느 열에, 얼마나 있는지 확인한 뒤, 특정 열만 기준으로 삭제할지, 대체할지 결정할 수 있습니다. 시계열이므로 구간별로 결측이 몰려 있는지도 df[df.isnull().any(axis=1)]로 살펴보면 좋습니다.
6.5.2 결측치 처리: 삭제 vs 대체
핵심 분석 대상인 Global_active_power 등에 결측이 있으면, 해당 행만 삭제하거나, 시계열 가정이 맞을 때 이전 값으로 채우기(ffill) 또는 선형 보간(interpolate) 을 쓸 수 있습니다. 아래는 핵심 분석 대상인 숫자 열(num_cols)을 선택한 뒤, 1) 결측이 있는 행 삭제, 2) 시계열 인덱스로 두고 ffill, 선형 보간을 적용하는 예입니다.
num_cols = [
"Global_active_power",
"Global_reactive_power",
"Voltage",
"Global_intensity",
"Sub_metering_1",
"Sub_metering_2",
"Sub_metering_3",
]
df_drop = df.dropna(subset=num_cols)
print(
"결측 행 삭제 후 행 수:",
len(df_drop),
"(원본:",
len(df),
")",
)
df_ts = df.set_index("datetime").sort_index()
df_ffill = df_ts[num_cols].ffill()
df_interp = df_ts[num_cols].interpolate(method="time")
print(
"\nffill 적용 후 남은 결측치 개수:",
df_ffill.isnull().sum().sum(),
)
print(
"interpolate(method='time') 적용 후 남은 결측치 개수:",
df_interp.isnull().sum().sum(),
)결측 행 삭제 후 행 수: 2049280 (원본: 2075259 )
ffill 적용 후 남은 결측치 개수: 0
interpolate(method='time') 적용 후 남은 결측치 개수: 0
삭제는 표본 수를 줄이고, ffill 보간은 ’값이 부드럽게 이어진다’는 가정에 의존하므로, 도메인(전력 데이터는 순간 끊김이 있을 수 있음)을 고려해 선택하면 됩니다.
6.5.3 이상치 탐지: IQR과 Z-score
Global_active_power(전력 사용량)처럼 물리적으로 음수가 아니어야 하는 열은 0 미만을 오류로 볼 수 있고, 분포가 한쪽으로 치우쳐 있으면 IQR 방식이 Z-score보다 안정적입니다. 아래는 IQR로 상, 하한을 구한 뒤 이상치 개수를 세고, clip으로 상, 하한 밖을 자르는 예입니다.
ser = df_drop["Global_active_power"].astype(float)
q1, q3 = ser.quantile(0.25), ser.quantile(0.75)
iqr = q3 - q1
low, high = q1 - 1.5 * iqr, q3 + 1.5 * iqr
outliers = (ser < low) | (ser > high)
print(
f"Global_active_power IQR 기준 이상치 개수: {outliers.sum()}"
)
print(f"하한: {low:.2f}, 상한: {high:.2f}")
ser_clipped = ser.clip(lower=low, upper=high)
print(
f"\nclip 적용 후 최소값: {ser_clipped.min():.2f}, 최대값: {ser_clipped.max():.2f}"
)Global_active_power IQR 기준 이상치 개수: 94907
하한: -1.52, 상한: 3.36
clip 적용 후 최소값: 0.08, 최대값: 3.36
같은 열에 Z-score를 쓰면 평균, 표준편차가 극단값에 끌려, 이상치 판단이 IQR과 다를 수 있습니다. 분포가 정규에 가깝고 극단값 비율이 낮을 때만 Z-score를 쓰는 것이 안전합니다.
6.5.4 통합 파이프라인 예시
’읽기 → 결측 인식 → 탐지 → 처리 → 이상치 탐지 → 처리’를 한 파이프라인으로 묶는 경우가 많습니다. 아래는 위 단계를 요약한 형태입니다.
raw = pd.read_parquet(
"../data/household_power_consumption.parquet"
)
raw["datetime"] = pd.to_datetime(
raw["Date"].astype(str) + " " + raw["Time"].astype(str),
format="%d/%m/%Y %H:%M:%S",
errors="coerce",
)
num_cols = [
"Global_active_power",
"Global_reactive_power",
"Voltage",
"Global_intensity",
"Sub_metering_1",
"Sub_metering_2",
"Sub_metering_3",
]
clean = raw[["datetime"] + num_cols].dropna()
ser = clean["Global_active_power"].astype(float)
q1, q3 = ser.quantile(0.25), ser.quantile(0.75)
low, high = q1 - 1.5 * (q3 - q1), q3 + 1.5 * (q3 - q1)
clean = clean.assign(
Global_active_power_clipped=ser.clip(
lower=low, upper=high
)
)
print("정제 후 행 수:", len(clean))
print(
clean[
[
"datetime",
"Global_active_power",
"Global_active_power_clipped",
]
].head(10)
)정제 후 행 수: 2049280
datetime Global_active_power Global_active_power_clipped
0 2006-12-16 17:24:00 4.216 3.358
1 2006-12-16 17:25:00 5.360 3.358
2 2006-12-16 17:26:00 5.374 3.358
.. ... ... ...
7 2006-12-16 17:31:00 3.700 3.358
8 2006-12-16 17:32:00 3.668 3.358
9 2006-12-16 17:33:00 3.662 3.358
[10 rows x 3 columns]
이 예제처럼 결측치는 na_values 지정 → isnull() 탐지 → dropna() 또는 ffill() 또는 interpolate()로 처리하고, 이상치는 IQR(또는 Z-score)로 경계를 정한 뒤 clip() 또는 삭제, 대체로 완화하면 됩니다. 데이터 크기가 크더라도 pandas로 충분히 처리할 수 있습니다.
6.6 예제: 문자열 처리
Cases_Guinea, Deaths_Liberia처럼 변수명에 상태와 국가가 한 덩어리로 들어 있는 경우, str.split("_", expand=True)로 나누면 ‘상태’ 열과 ‘국가’ 열을 얻을 수 있습니다. 같은 결과를 str.extract()로도 낼 수 있습니다. 정규식 r"^(\w+)_"는 맨 앞 단어(밑줄 전까지)를, r"_(\w+)$"는 맨 뒤 단어(밑줄 다음부터 끝까지)를 추출합니다. 넓은 형식(wide format) 컬럼 이름을 tidy한 형태로 쪼갤 때 자주 쓰는 패턴입니다.
예를 들어, Cases_Guinea, Deaths_Guinea, Cases_Liberia, Deaths_Liberia처럼 변수명에 상태와 국가가 한 덩어리로 들어 있는 경우, str.split("_", expand=True)로 나누면 ‘상태’ 열과 ‘국가’ 열을 얻을 수 있습니다.
import pandas as pd
ebola = pd.DataFrame(
{
"variable": [
"Cases_Guinea",
"Deaths_Guinea",
"Cases_Liberia",
"Deaths_Liberia",
],
"value": [100, 50, 200, 100],
}
)
ebola[["status", "country"]] = ebola["variable"].str.split(
"_", expand=True
)
ebola_extracted = ebola.copy()
ebola_extracted["status"] = ebola_extracted[
"variable"
].str.extract(r"^(\w+)_")
ebola_extracted["country"] = ebola_extracted[
"variable"
].str.extract(r"_(\w+)$")
print(ebola) variable value status country
0 Cases_Guinea 100 Cases Guinea
1 Deaths_Guinea 50 Deaths Guinea
2 Cases_Liberia 200 Cases Liberia
3 Deaths_Liberia 100 Deaths Liberia