7  실습: 변환과 정제

이 실습에서는 타이타닉 승객 데이터(‘titanic_train.csv’)를 활용하여 데이터 변환과 정제 과정을 단계별로 학습합니다. 데이터 분석에서 결측치 처리, 이상치 탐지, 타입 변환, 그리고 문자열 처리는 기본적이면서도 중요한 작업입니다.

7.1 데이터 준비 및 탐색

먼저 필요한 라이브러리를 가져오고 분석할 데이터를 불러옵니다. 데이터의 전반적인 구조와 결측치 현황을 확인하는 것이 정제의 첫 단계입니다.

import pandas as pd
import numpy as np
import platform
import matplotlib.pyplot as plt
import seaborn as sns

# 운영체제별 한글 폰트 설정
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

# 데이터 로드
df = pd.read_csv(
    "../data/titanic_train.csv", dtype_backend="pyarrow", engine="pyarrow"
)

먼저 데이터 크기와 컬럼을 확인합니다.

# 데이터 크기 확인
print("데이터 크기:", df.shape)

# 데이터 컬럼과 상위 데이터 확인
print("컬럼 목록:", df.columns)

데이터 크기와 컬럼 목록을 확인할 수 있습니다.

데이터 크기: (891, 12)
컬럼 목록: Index(['PassengerId', 'Survived', 'Pclass',
    'Name', 'Sex', 'Age', 'SibSp',
    'Parch', 'Ticket', 'Fare', 'Cabin',
    'Embarked'], dtype='str')

데이터가 정상적으로 로드되었다면, 각 열에 결측치가 얼마나 있는지 확인해야 합니다. 결측치의 개수와 비율을 함께 확인하여 정제 전략을 세웁니다.

# 열별 결측치 개수와 비율 계산
missing_counts = df.isnull().sum()
missing_ratio = (df.isnull().sum() / len(df) * 100).round(2)

missing_summary = pd.DataFrame(
    {
        "결측치 개수": missing_counts,
        "결측치 비율 (%)": missing_ratio,
    }
)

# 결측치가 존재하는 열만 필터링하여 출력
print(missing_summary[missing_summary["결측치 개수"] > 0])
          결측치 개수  결측치 비율 (%)
Age          177       19.87
Cabin        687       77.10
Embarked       2        0.22

7.2 결측치 처리

타이타닉 데이터에서 Age, Cabin, Embarked 열에 결측치가 있음을 확인했습니다. 각 열의 특성에 따라 서로 다른 결측치 처리 방법을 적용합니다.

7.2.1 Embarked 열의 결측치 처리 (최빈값 대체)

Embarked(탑승 항구) 열은 결측치가 2개로 매우 적습니다. 이러한 범주형 변수의 결측치는 자주 등장하는 값인 최빈값(Mode)으로 대체하는 것이 일반적입니다.

# Embarked 열의 최빈값 확인
mode_embarked = df["Embarked"].mode()[0]
print("Embarked 최빈값:", mode_embarked)

# 결측치 채우기
df["Embarked"] = df["Embarked"].fillna(mode_embarked)
print(
    "Embarked 결측치 정제 후 남은 개수:",
    df["Embarked"].isnull().sum(),
)
Embarked 최빈값: S
Embarked 결측치 정제 후 남은 개수: 0

7.2.2 Age 열의 결측치 처리 (그룹별 중앙값 대체)

Age(나이) 열은 누락된 값이 많습니다. 단순히 전체 나이의 평균이나 중앙값으로 채우기보다, 승객의 객실 등급(Pclass)과 성별(Sex)에 따라 나이 분포가 다를 수 있으므로 이를 반영하여 그룹별 중앙값으로 채우는 것이 더 정교합니다.

# Pclass와 Sex 그룹별 나이의 중앙값 확인
grouped_medians = df.groupby(
    ["Pclass", "Sex"], observed=True
)["Age"].median()
print("그룹별 나이 중앙값:")
print(grouped_medians)

# transform을 사용하여 각 행에 해당하는 그룹의 중앙값 매핑
age_imputed = df.groupby(["Pclass", "Sex"], observed=True)[
    "Age"
].transform("median")

# 결측치를 그룹별 중앙값으로 채우기
df["Age"] = df["Age"].fillna(age_imputed)
print(
    "Age 결측치 정제 후 남은 개수:",
    df["Age"].isnull().sum(),
)
그룹별 나이 중앙값:
Pclass  Sex   
1       female    35.0
        male      40.0
2       female    28.0
        male      30.0
3       female    21.5
        male      25.0
Name: Age, dtype: double[pyarrow]
Age 결측치 정제 후 남은 개수: 0

7.2.3 Cabin 열의 결측치 처리 (임의의 범주 생성)

Cabin(선실 번호) 열은 결측치 비율이 70%가 넘습니다. 이처럼 누락된 정보가 많은 경우에는 결측치 자체를 하나의 정보로 취급하여 ‘N’(Not Specified)과 같은 새로운 범주값으로 채우는 전략을 취할 수 있습니다.

# Cabin 결측치를 'N'으로 채우기
df["Cabin"] = df["Cabin"].fillna("N")
print(
    "Cabin 결측치 정제 후 남은 개수:",
    df["Cabin"].isnull().sum(),
)
Cabin 결측치 정제 후 남은 개수: 0

7.3 이상치 탐지 및 처리

이상치(Outlier)는 분석 결과를 왜곡할 수 있으므로 적절하게 탐지하고 처리해야 합니다. 승객 요금(Fare) 데이터를 바탕으로 이상치를 확인해 봅니다.

7.3.1 IQR 방법을 통한 이상치 탐색

IQR(사분위 범위)을 사용하여 요금 데이터의 이상치 경계를 설정하고 이상치 개수를 파악합니다.

# 요금(Fare)의 사분위수 계산
Q1 = df["Fare"].quantile(0.25)
Q3 = df["Fare"].quantile(0.75)
IQR = Q3 - Q1

# 이상치 판단 기준이 되는 상한선과 하한선 계산
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR

print("요금 IQR:", round(IQR, 2))
print(
    "하한선:",
    round(lower_bound, 2),
    "상한선:",
    round(upper_bound, 2),
)

# 이상치에 해당하는 행 탐지
outliers = df[
    (df["Fare"] < lower_bound) | (df["Fare"] > upper_bound)
]
print("IQR 기준 이상치 개수:", len(outliers))
요금 IQR: 23.09
하한선: -26.72 상한선: 65.63
IQR 기준 이상치 개수: 116

7.3.2 이상치 정제 (Capping)

요금이 음수인 경우는 물리적으로 불가능하며, 높은 요금은 극단적인 예외 상황일 수 있습니다. 여기서는 행을 삭제하는 대신, 상한선보다 큰 요금은 상한선 값으로, 하한선보다 작은 요금은 하한선 값으로 제한하는 clip 메서드를 적용합니다.

# clip을 적용하여 이상치 정제
df["Fare_clean"] = df["Fare"].clip(
    lower=lower_bound, upper=upper_bound
)

# 정제 전후의 최대값 비교
print("정제 전 최대 요금:", df["Fare"].max())
print("정제 후 최대 요금:", df["Fare_clean"].max())
정제 전 최대 요금: 512.3292
정제 후 최대 요금: 65.6344

7.3.3 분포 시각화 비교

Boxplot을 사용하여 요금 데이터 정제 전후의 분포 변화를 확인합니다.

plt.figure(figsize=(7, 5))

# 정제 전 분포
plt.subplot(1, 2, 1)
sns.boxplot(data=df, y="Fare")
plt.title("정제 전 요금 분포")

# 정제 후 분포
plt.subplot(1, 2, 2)
sns.boxplot(data=df, y="Fare_clean")
plt.title("정제 후 요금 분포")

plt.tight_layout()
plt.show()
그림 7.1: 정제 전 요금(Fare) 분포. 오른쪽으로 심하게 치우쳐 있다.

7.3.4 가족 규모 변수 생성 및 생존율 분석

동반한 형제자매 및 배우자 수(SibSp)와 부모 및 자녀 수(Parch)에 본인을 더하여 가족 규모(FamilySize) 열을 만듭니다. 그 후, 가족 규모가 5명 이상인 대가족 승객들을 필터링하고 이들의 생존율(Survived 컬럼의 평균값)을 계산해 봅니다.

# 가족 규모(FamilySize) 열 생성
df["FamilySize"] = df["SibSp"] + df["Parch"] + 1
print("가족 규모 분포:")
print(df["FamilySize"].value_counts())

# 가족 규모가 5명 이상인 대가족 필터링 및 생존율 계산
large_family_survival = df[df["FamilySize"] >= 5][
    "Survived"
].mean()
print(
    f"5명 이상 대가족의 생존율: {large_family_survival * 100:.2f}%"
)
가족 규모 분포:
FamilySize
1     537
2     161
3     102
     ... 
7      12
11      7
8       6
Name: count, Length: 9, dtype: int64[pyarrow]
5명 이상 대가족의 생존율: 16.13%

7.4 타입 변환

정제된 데이터의 가독성을 높이고 메모리 효율을 향상시키기 위해 적절한 데이터 타입으로 변환합니다.

7.4.1 PyArrow 정수형 변환

나이(Age) 열은 결측치를 대체했으나 여전히 실수형(double[pyarrow])으로 설정되어 있을 수 있습니다. 소수점을 반올림하여 정리한 뒤 결측치를 안전하게 포함할 수 있는 PyArrow 정수형(int64[pyarrow])으로 변환합니다.

# 반올림 후 pyarrow 정수형 변환
df["Age"] = df["Age"].round().astype("int64[pyarrow]")
print("Age 열의 최종 데이터 타입:", df["Age"].dtype)
print(df["Age"].head())
Age 열의 최종 데이터 타입: int64[pyarrow]
0    22
1    38
2    26
3    35
4    35
Name: Age, dtype: int64[pyarrow]

7.4.2 범주형 변환

성별(Sex) 열은 male과 female 두 가지 문자열 값만 가집니다. 이를 category 타입으로 변환하여 메모리 효율을 높이고 범주형 변수로서의 특성을 명확히 합니다.

# category 타입으로 변환
df["Sex"] = df["Sex"].astype("category")
print("Sex 열의 최종 데이터 타입:", df["Sex"].dtype)
Sex 열의 최종 데이터 타입: category

7.5 문자열 처리

텍스트 데이터로 채워진 열들에서 필요한 정보를 추출하고 문자열을 변환하는 작업을 수행합니다.

7.5.1 Name 열에서 호칭 추출하기

승객의 이름(Name)은 ’Lastname, Title. Firstname’의 규칙적인 형식으로 되어 있습니다. 정규표현식을 사용하여 승객의 호칭(Title)을 추출하고 새로운 열을 생성합니다.

# 정규표현식을 사용해 호칭 추출 (쉼표 뒤, 마침표 앞의 문자열)
df["Title"] = df["Name"].str.extract(r",\s*(?P<Title>[A-Za-z]+)\.", expand=False)
print("추출된 호칭 빈도:")
print(df["Title"].value_counts())
추출된 호칭 빈도:
Title
Mr          517
Miss        182
Mrs         125
           ... 
Sir           1
Capt          1
Jonkheer      1
Name: count, Length: 16, dtype: int64[pyarrow]

7.5.2 호칭 그룹화 및 표준화

추출한 호칭 중 빈도가 낮은 호칭들을 Other로 통합하고, 문자열 공백을 제거한 후 모두 대문자로 통일합니다.

# 주요 4대 호칭 외에는 모두 'Other'로 분류
major_titles = ["Mr", "Miss", "Mrs", "Master"]
df["Title"] = df["Title"].where(df["Title"].isin(major_titles), "Other")

# 앞뒤 공백 제거 및 대문자 변환
df["Title"] = df["Title"].str.strip().str.upper()
print("표준화된 호칭 빈도:")
print(df["Title"].value_counts())
표준화된 호칭 빈도:
Title
MR        517
MISS      182
MRS       125
MASTER     40
OTHER      27
Name: count, dtype: int64[pyarrow]

7.5.3 Cabin 열에서 선실 구역(Deck) 추출

선실 번호(Cabin)의 첫 글자는 배의 구역(Deck)을 나타냅니다. Cabin 열의 첫 글자만 슬라이싱하여 새로운 Deck 열을 만듭니다.

# 첫 번째 글자 추출
df["Deck"] = df["Cabin"].str[0]
print("Deck 분포:")
print(df["Deck"].value_counts())
Deck 분포:
Deck
N    687
C     59
B     47
    ... 
F     13
G      4
T      1
Name: count, Length: 9, dtype: int64[pyarrow]

7.5.4 티켓 번호에서 알파벳 접두사 필터링

Ticket(티켓 번호) 열에는 숫자로만 구성된 티켓이 있는 반면, 알파벳 접두사가 포함된 티켓(A/5 21171, PC 17599)도 있습니다. 문자열 메서드나 정규표현식을 사용하여 알파벳 문자가 포함된 티켓을 가진 승객들의 데이터를 필터링하고 해당 행의 개수를 출력합니다.

# Ticket 컬럼에서 알파벳 문자가 포함된 행 필터링
alphabet_tickets = df[
    df["Ticket"].str.contains(r"[A-Za-z]", na=False)
]
print(
    "알파벳 문자가 포함된 티켓 개수:", len(alphabet_tickets)
)
print(alphabet_tickets["Ticket"].head())
알파벳 문자가 포함된 티켓 개수: 230
0            A/5 21171
1             PC 17599
2     STON/O2. 3101282
10             PP 9549
12           A/5. 2151
Name: Ticket, dtype: string[pyarrow]

7.5.5 호칭별 평균 나이를 활용한 결측치 대체

앞선 결측치 처리 단계에서는 객실 등급(Pclass)과 성별(Sex) 그룹의 중앙값으로 나이 결측치를 채웠습니다. 이번에는 문자열 처리 단계에서 추출한 호칭(Title) 정보를 활용하여, 각 호칭별 평균 나이로 결측치를 채우는 대안적인 방법을 살펴봅니다. 이를 위해 먼저 Age 결측치가 존재하는 원본 데이터를 복사하여 실습을 진행합니다.

# 복사본 데이터프레임 준비 및 Title 컬럼 생성
df_alt = pd.read_csv(
    "../data/titanic_train.csv", dtype_backend="pyarrow", engine="pyarrow"
)
df_alt["Title"] = df_alt["Name"].str.extract(
    r",\s*(?P<Title>[A-Za-z]+)\.", expand=False
)
major_titles = ["Mr", "Miss", "Mrs", "Master"]
df_alt["Title"] = df_alt["Title"].where(df_alt["Title"].isin(major_titles), "Other")

# 호칭별 평균 나이 계산
title_age_means = df_alt.groupby("Title", observed=True)[
    "Age"
].mean()
print("호칭별 평균 나이:")
print(title_age_means)

# transform을 사용해 각 호칭에 매핑되는 평균 나이로 결측치 채우기
df_alt["Age"] = df_alt["Age"].fillna(
    df_alt.groupby("Title", observed=True)["Age"].transform(
        "mean"
    )
)
print(
    "나이 결측치 정제 후 남은 개수:",
    df_alt["Age"].isnull().sum(),
)
호칭별 평균 나이:
Title
Master     4.574167
Miss      21.773973
Mr         32.36809
Mrs       35.898148
Other     42.384615
Name: Age, dtype: double[pyarrow]
나이 결측치 정제 후 남은 개수: 0