🚢 titanic

[python] 탐사일지 02. 쓸 것과 버릴 것: 컬럼 감별과 결측치 전략

Floaty 2026. 6. 30. 10:00

컬럼 감별: 쓸 것 / 버릴 것 / 가공할 것

타이타닉 데이터는 15개의 컬럼으로 이루어져 있습니다. 그렇다고 이 컬럼을 전부 사용하느냐 하면 그건 아닙니다. 오히려 중복되거나 정보가 부족한 컬럼을 그대로 사용하면 분석에 문제가 생길 수 있어요.

 

그럼 먼저 중복되는 컬럼이 있는지부터 볼까요?

# [4] 컬럼 중복 여부 확인하기
# class['First', 'Second', 'Third'] vs. pclass[1, 2, 3]
is_class_same = (
    df["class"].map(
        {
            "First": 1,
            "Second": 2,
            "Third": 3,
        }
    )
    == df["pclass"]
).all()
print(f"class == pclass: {is_class_same}")
# class == pclass: True

# alive['yes', 'no'] vs. survived[0, 1]
is_alive_same = (df["alive"].map({"yes": 1, "no": 0}) == df["survived"]).all()
print(f"alive == survived: {is_alive_same}")
# alive == survived: True

# alone[True, False] vs. sibsp + parch
is_alone_same = (df["alone"] == ((df["sibsp"] + df["parch"]) == 0)).all()
print(f"alone == sibsp + parch: {is_alone_same}")
# alone == sibsp + parch: True

 

코드 형태가 대부분 비슷하니 먼저 함수부터 짚고 넘어갈게요.

  • `map()`은 값을 딕셔너리에 정의된 규칙에 따라 다른 값으로 치환하는 함수입니다.
    `is_class_same`에서는 'First'를 1로, 'Second'를 2로, 'Third'를 3으로 치환해서 pclass와 같은 형태로 맞춰줍니다. 
  • `all()`은 모든 원소가 `True`인지 검사하는 함수입니다. 단 하나라도 `False`가 있으면 전체 결과는 `False`가 됩니다.
  • `is_alone_same`을 보면 `sibsp`는 형제/배우자 수, `parch`는 부모/자녀 수인데, 이 둘의 합이 0인지를 `True/False`로 계산한 뒤 그 결과를 `alone` 컬럼과 비교합니다. 

결과를 하나씩 살펴볼게요. `class`와 `pclass`는 같은 정보를 다른 표현(문자열, 숫자)으로 담고 있을 뿐 완전히 동일한 데이터입니다.  `alive`와 `survived` 역시 같은 정보를 다른 표현(문자열, 숫자)으로 담고 있습니다. 반면 `alone`과 `sibsp + parch`는 앞의 두 쌍과 달리 단순 표현 차이가 아니라 논리적으로 같은 정보인지를 확인한 경우입니다. 형제/배우자 수와 부모/자녀수의 합이 0이라면 그 사람은 혼자 탑승한 것이므로 두 정보가 일치하는 것이 당연합니다.

 

이번에는 `who`와 `adult_male`의 관계를 살펴보겠습니다. 

who_adult_male_crosstab = (
    pd.crosstab(           # 두 범주형 컬럼 간의 교차표(빈도표)를 만들어주는 함수
        df["who"],         # 행(who: child, man, woman)
        df["adult_male"],  # 열(adult_male: True, False)
    )
)
print(who_adult_male_crosstab)
"""
adult_male  False  True 
who                     
child          83      0 child인 사람은 전부 adult_male=False
man             0    537 man인 사람은 전부 adult_male=True
woman         271      0 woman인 사람은 전부 adult_male=False
"""

`crosstab`은 두 범주형 컬럼 간의 교차표(빈도표)를 만들어주는 함수입니다. 두 조건의 조합별로 데이터가 몇 개씩 존재하는지 개수를 세어 표로 보여줍니다.

 

결과를 보면 `who가 child, woman인 사람은 전부 `adult_male`이 `False`입니다. 성인 남성이 아니니 당연한 결과죠. 반대로 `who`가 man인 사람은 전부 `adult_male`이 `True`입니다. `crosstab`을 통해 `who == 'man'`일 때만 `adult_male`이 `True`이고, 그 외(child, woman)은 항상 `False`라는 두 컬럼의 논리적 관계를 한눈에 확인할 수 있어요.

 

사실 `who` 컬럼은 `age`, `sex` 컬럼을 조합해서 누군가 미리 만들어둔 파생변수입니다. 이렇게 이미 만들어진 파생변수를 데이터셋에서 종종 만나게 되는데, 이때 무작정 신뢰하기보다는 원본 컬럼으로 재현이 되는지 검증한 다음 사용 여부를 결정하는 것이 중요합니다. 여기서는 검증을 통과했으므로 글대로 신뢰하고 사용하겠습니다. 

 

 

 

컬럼별 최종 결정

지금까지의 검토를 바탕으로 15개의 컬럼에 대한 정리를 하자면 아래와 같아요. 

컬럼 결측 결정 근거
survived 0% 타겟변수 우리가 설명하려는 대상 그 자체
pclass 0% 사용 사회경제적 지위의 대리지표, 결측 없음
sex 0% 사용 핵심 변수, 결측 없음
age 19.9% 가공 후 사용 등급x성별 그룹의 중앙값으로 결측 보완
sibsp 0% 가공 parch와 합쳐 family_size로 재구성
parch 0% 가공 위와 동일
fare 0% 사용(주의) pclass와 상관계수 -0.55 (다중공산성 의심, 등급 내부 효과 따로 검증 필요)
embarked 0.2% 가공 후 사용 최빈값으로 결측 2건 보완
class 0% 버림 pclass와 100% 동일
who 0% 사용 age+sex 기반 파생변수, 검증 완료 후 신뢰하고 사용
adult_male 0% 버림 who에서 100% 결정됨
deck 77.2% 가공 후 사용 결측이 무작위가 아니라 pclass와 강하게 연관됨 - 객실기록 유무 이진변수로 변환
embark_town 0% 버림 embarked를 풀었쓴 동일 정보
alive 0% 버림 survived와100% 동일
alone 0% 버림 family_size==1과 동일 정보 

 

15개 컬럼 중 그대로 살아남은 컬럼은 5개(`survived`, `pclass`, `sex`, `fare`, `who`), 가공해서 사용하는 컬럼은 4개(`age`, `sibsp+parch`, `deck`, `embarked`), 그대로 버리는 컬럼은 5개(`class`, `adult_male`, `embark_town`, `alive`, `alone`)입니다. 

 

표에서 보면 다중공산성에 대해서 나오는데 다중공산성은 서로 다른 두 설명변수가 너무 강하게 상관되어 있으면 모델이 어느 변수가 실제로 결과에 영향을 주는지 구분하기 어려워지는 현상을 말합니다. `fare`와 `pclass`의 상관계수가 -0.5로 비교적 높게 나온 것은 등급이 높을수록 운임이 비싸지는 경향 때문입니다. 그렇다고 `fare`를 무조건 버릴 필요는 없어요. 같은 등급 안에서도 운임 차이가 생존율과 관련이 있는지 확인한 뒤 사용 여부와 활용 방식을 정하면 됩니다.

 

가공해서 사용하기로 한 4개 컬럼은 각각 다음과 같은 방식으로 처리할 예정입니다. 

  • `age`: 결측치를 `pclass`와 `sex` 그룹별 중앙값으로 채웁니다.
  • `sibsp`, `parch`: 두 컬럼을 더해 `family_size`라는 새로운 변수로 합칩니다.
  • `embarked`: 결측치 2건을 최빈값으로 채웁니다.
  • `deck`: 원래 값 대신 객실 기록이 있는지 없는지를 나타내는 이진 변수(`has_cabin_record`)로 변환합니다.

 

 

 

결측치 처리하기

저번에 확인했던 결측치는 3개 컬럼에 존재했습니다. `age`(19.9%), `embarked`(0.2%), `deck`(77.2%)입니다. 이 세 컬럼의 결측치를 모두 같은 방법으로 처리하면 안 됩니다. 결측 비율도 다를뿐더러 결측이 생긴 이유도 서로 다르기 때문입니다.

가장 익숙한 `age`부터 처리해보겠습니다.

# [4] 나이 결측치 처리하기: 전체 평균(x) 그룹별 중앙값(o)
age_median_by_group = df.groupby(["pclass", "sex"])["age"].median()
print(age_median_by_group)
"""
pclass  sex   
1       female    35.0
        male      40.0
2       female    28.0
        male      30.0
3       female    21.5
        male      25.0
"""
df["age_filled"] = df.groupby(["pclass", "sex"])["age"].transform(
    lambda x: x.fillna(x.median())
)

전체 평균 나이는 29.7세입니다. 이 값 하나로 177개의 빈 값을 한 번에 채우면 어떻게 될까요? 1등급의 부유한 중년 승객과 3등급의 어린 이민 노동자가 똑같은 나이로 채워지게 됩니다. 이렇게 채우면 실제 분포가 왜곡됩니다. 그보다 합리적인 방법은 등급과 성별이 비슷한 사람들끼리 묶어서, 그 그룹의 중앙값으로 채우는 것입니다.

 

출력 결과를 보면 1등급 남성의 중앙값 나이는 40세, 3등급 여성의 중앙값 나이는 21.5세입니다. 거의 20년 가까이 차이가 납니다. 전체 평균 하나로 모든 결측치를 채웠다면 이런 계급별 인구통계학적 차이가 통째로 사라졌을 겁니다.

 

 

두 번째로 처리할 결측치는 `embarked`입니다. 결측 비율이 전체의 0.2%, 단 2건뿐이므로 가장 흔한 값(최빈값)으로 채우는 것으로 충분합니다.

# [5] 탑승 항구 결측치 처리하기: 결측이 적을 때는 최빈값으로 보완
print(df[df["embarked"].isnull()][["pclass", "sex", "fare", "embarked"]])
"""
     pclass     sex  fare embarked
61        1  female  80.0      NaN
829       1  female  80.0      NaN
"""
print(f"최빈 탑승 항구: {df['embarked'].mode()[0]}")  # 최빈 탑승 항구: S

df["embarked_filled"] = df["embarked"].fillna(df["embarked"].mode()[0])

`isnull()`을 이용해 `embarked`가 비어있는 행만 따로 뽑아 살펴볼게요.

결측치를 채우기 전에 "이 두 사람이 어떤 사람인지"를 먼저 확인하기 위해서입니다. 출력 결과를 보면 두 사람 모두 1등급 여성이고 운임도 80달러로 동일합니다. 이렇게 결측 행의 특징을 먼저 살펴보는 습관을 들이면, 단순히 최빈값으로 채워도 괜찮은 상황인지(특이한 패턴은 없는지) 판단하는 데 도움이 됩니다.

 

`mode()`는 Series에서 가장 빈번하게 등장하는 값(최빈값)을 찾아주는 함수입니다. 최빈값이 여러 개일 수도 있기 때문에 결과를 리스트(Series) 형태로 반환하며, 그래서 첫 번째 값을 가져오기 위해 [0]을 붙입니다. 출력 결과 `embarked`의 최빈값은 "S"(Southampton)였고, 이 값으로 결측치 2건을 채웠습니다.

 

 

마지막으로 채울 결측치는 `deck`입니다. 결측 비율이 77.2%나 되기 때문에 "빈 값을 무엇으로 채우지?"보다는 "왜 이렇게 많이 비어있을까?"를 먼저 생각해봐야 합니다. 결측 비율이 적당하다면 적절한 값으로 채우면 되지만, 절반 이상이 비어있다는 것은 비어있는 데에 어떤 이유가 있을 가능성이 높다는 뜻입니다.

# [6] deck 결측 패턴 활용하기: 많이 비어있는 컬럼은 삭제 대신 기록 여부 변수로
df["has_cabin_record"] = df["deck"].notna()

print(df.groupby("pclass")["has_cabin_record"].mean().round(3))
"""
pclass
1    0.810
2    0.087
3    0.024
Name: has_cabin_record, dtype: float64
"""
print(df.groupby("has_cabin_record")["survived"].mean().round(3))
"""
has_cabin_record
False    0.299
True     0.670
Name: survived, dtype: float64
"""

`notna()`는 값이 결측치(NaN)가 아닌지를 `True/False`로 판별하는 함수입니다. `df["deck"].notna()`는 `deck` 값이 비어있지 않으면 `True`, 비어있으면 `False`를 반환하며, 이를 통해 "객실 기록이 남아있는가"라는 새로운 이진 변수 `has_cabin_record`를 만들 수 있습니다.

 

원인을 짐작할 수 있을 것 같습니다. `deck`이 비어있는 것은 무작위가 아니었습니다. 1등급 객실의 81%는 객실 기록이 남아있지만, 3등급은 2.4%만 남아있습니다. 결측 자체가 객실 등급과 강하게 얽혀있는 정보인 셈입니다. 이런 경우를 '무작위가 아닌 결측(Missing Not At Random)'이라고 부릅니다.

 

두 번째 출력 결과를 보면, 객실 기록이 없는 사람의 생존율은 29.9%인 반면 객실 기록이 있는 사람의 생존율은 67.0%로 두 배 이상 차이가 납니다. 이는 객실 기록의 유무 자체가 생존 여부와 관련된 유의미한 정보임을 보여줍니다. 이런 상황에서는 빈 값을 임의로 채우기보다, '기록이 있다/없다'라는 이진 변수 자체를 새로운 정보로 사용하는 것이 더 합리적입니다.

 

 

 

오늘은 컬럼 중복 여부를 검증해서 쓸 컬럼과 버릴 컬럼을 가려내고, `age`, `embarked`, `deck`의 결측치를 각각 원인에 맞게 처리했습니다. 다음 시간에는 `family_size` 같은 새로운 변수를 만들고 도메인 지식을 바탕으로 가설을 세워볼게요.