🚢 titanic

[python] 항해일지 02. 승객 명단 펼쳐보기: pandas

Floaty 2026. 6. 24. 10:00

본격적으로 시작하기 전에 왜 `pandas`를 쓰는지부터 이야기를 해보도록 할게요.

데이터는 보통 엑셀처럼 행과 열로 이루어진 표 형태로 존재해요. `pandas`는 이런 표 형태의 데이터를 파이썬으로 다루기 위한 도구입니다. `pandas`의 핵심 자료구조는 DataFrame인데 엑셀 시트 하나라고 생각하면 됩니다. 행과 열에 이름이 붙어있고, 각 열마다 데이터 타입이 다를 수 있어요. 타이타닉 데이터로 보자면, `survived`는 0과 1로 이루어져 있지만, `class`를 보면 First, Second, Third로 이루어져 있어요. 

 

그렇다면 `numpy`와는 어떤 게 다를까요? `numpy`는 숫자 계산에 특화된 배열이고, `pandas`는 이름 있는 열, 결측치 처리, 그룹별 통계 같은 실제 데이터 분석에서 자주 필요한 기능들을 갖추고 있어요. 그래서 데이터를 처음 탐색하고 정리하는 단계에서는 `pandas`가, 수치 연산이 필요한 단계에서는 `numpy`가 주로 쓰입니다. 

 

 

 

데이터 불러오기 & 모양 확인

# [1] 데이터 불러오기 & 모양 확인
import pandas as pd

df = pd.read_csv("data/titanic.csv")

print(df.shape)  # (891, 15) → 891명의 승객, 15개의 정보

print(df.head(3))
"""
   survived  pclass     sex   age  sibsp  parch     fare embarked  class    who  adult_male deck  embark_town alive  alone
0         0       3    male  22.0      1      0   7.2500        S  Third    man        True  NaN  Southampton    no  False
1         1       1  female  38.0      1      0  71.2833        C  First  woman       False    C    Cherbourg   yes  False
2         1       3  female  26.0      0      0   7.9250        S  Third  woman       False  NaN  Southampton   yes   True
"""

가장 먼저 할 일은 이 데이터가 어떻게 생겼는지를 확인하는 겁니다. `read_csv()`로 csv 파일을 읽으면 DataFrame 형태로 불러올 수 있어요. `read_csv()`는 `numpy`에서 더 자세히 설명했으니 넘어가도록 할게요.

`shape`은 (행 수, 열 수)를 알려주고, `head()`는 앞부분 몇 줄을 미리 보여줍니다. 저는 3줄을 보여달라고 했지만, 지정하지 않을 경우 5줄을 보여줍니다. 

 

 

 

구조 & 결측치 확인

# [2] 데이터 구조 & 결측치 확인
print(df.info())
"""
class 'pandas.core.frame.DataFrame'>
RangeIndex: 891 entries, 0 to 890        *
Data columns (total 15 columns):
 #   Column       Non-Null Count  Dtype  
---  ------       --------------  -----  
 0   survived     891 non-null    int64  
 1   pclass       891 non-null    int64  
 2   sex          891 non-null    object 
 3   age          714 non-null    float64 *
 4   sibsp        891 non-null    int64  
 5   parch        891 non-null    int64  
 6   fare         891 non-null    float64
 7   embarked     889 non-null    object 
 8   class        891 non-null    object 
 9   who          891 non-null    object 
 10  adult_male   891 non-null    bool   
 11  deck         203 non-null    object *
 12  embark_town  889 non-null    object 
 13  alive        891 non-null    object 
 14  alone        891 non-null    bool   
dtypes: bool(2), float64(2), int64(4), object(7)
memory usage: 92.4+ KB
None
"""

데이터의 형태를 봤으니 이제 빠진 정보가 없는지 확인을 해야 해요. 데이터가 완벽하게 채워져 있는 경우는 거의 없거든요.

`info()`는 컬럼별 결측치 여부와 데이터 타입을 확인할 수 있어요. `Non-Null Count`를 보면, 실제로 데이터가 들어있는 행이 몇 개인지를 알 수 있어요. `*`로 표시해 둔 부분을 보면, 전체 데이터는 891개이고, `age`의 경우 714개만 존재하기 때문에 177개의 결측치, `deck`의 경우 203개만 존재하기 때문에 688개의 결측치가 존재해요. 

 

그런데 매번 이렇게 "전체 데이터의 개수 - 결측치가 아닌 데이터의 수"를 하는 건 너무 귀찮아요. 그래서 각 컬럼별 결측치의 수를 한 번에 확인해 보도록 할게요.

print(df.isnull().sum())
"""
survived         0
pclass           0
sex              0
age            177
sibsp            0
parch            0
fare             0
embarked         2
class            0
who              0
adult_male       0
deck           688
embark_town      2
alive            0
alone            0
dtype: int64
"""

`isnull(). sum()`은 비어있는 값이 컬럼별로 몇 개인지 숫자로 알려줘요. 결과를 보면, `deck`(선실 데크)는 688개의 결측치가 존재해요. 77%가 결측이기 때문에 이 정도면 분석에 활용하기 어렵습니다. `age`(나이)의 경우, 약 20% 결측이라 버리기엔 아깝고, 다른 값으로 채워서 사용하는 방법을 고려해 볼 수 있어요. `age`의 경우 보통 평균이나 중앙값으로 채우는데 이건 뒤에서 다루도록 할게요. 

 

 

 

 

기초 통계 & 범주별 개수

# [3] 기초 통계 & 범주별 개수
print(df.describe().round())
"""                                             *
       survived  pclass    age  sibsp  parch   fare
count     891.0   891.0  714.0  891.0  891.0  891.0
mean        0.0     2.0   30.0    1.0    0.0   32.0
std         0.0     1.0   15.0    1.0    1.0   50.0  *
min         0.0     1.0    0.0    0.0    0.0    0.0
25%         0.0     2.0   20.0    0.0    0.0    8.0
50%         0.0     3.0   28.0    0.0    0.0   14.0
75%         1.0     3.0   38.0    1.0    0.0   31.0
max         1.0     3.0   80.0    8.0    6.0  512.0
"""

`describe()`는 평균(mean), 표준편차(std), 최솟값(min), 최댓값(max), 사분위수(25%, 50%, 75%)를 한 번에 보여줘요. 출력해 본 김에 데이터를 한 번 봐볼게요. `fare`를 보면 표준편차가 50입니다. 이 수치는 데이터가 평균에서 약 50달러 범위로 퍼져 있다는 뜻이니까 승객마다 요금 차이가 꽤 크다는 걸 알 수 있어요.

 

 

print(df["sex"].value_counts())
"""
sex
male      577
female    314
Name: count, dtype: int64
"""

print(df["pclass"].value_counts())
"""
pclass
3    491
1    216
2    184
Name: count, dtype: int64
"""

`value_counts()`는 범주형 데이터에서 유용하게 사용되는데, 각 값이 몇 번씩 나오는지 카운트해줍니다. 승객의 약 65%가 남성이고, 3등급 객실이 절반 이상인 걸 알 수 있어요. 범주형 데이터에서 유용하다는 것은 범주형 데이터가 아니어도 사용할 수 있다는 말인데요. 실제로 `age`에 사용하게 되면, "22.0: 1명, 25.0: 1명" 이렇게 의미 없이 쭉 나옵니다. 그래서 사용할 수 있지만! 사용할 일이 없습니다. 

 

 

 

그룹별 통계 & 피벗 테이블

전체 통계를 봤으니 자연스러우면서도 자연스럽지 않은 질문이 생깁니다. 그룹별 통계를 하기 위한 빌드업이기도 해요. '성별과 객실 등급을 나눠서 보면 어떨까?'라는 질문이 생겨요. 결과를 보기 전에 생각을 먼저 해보자면,

 

  • 성별 생존율
    타이타닉 침몰 당시 '여성과 아이 먼저' 구조 원칙이 있었다고 알려져 있어요. 그렇다면 여성의 생존율이 남성의 생존율보다 높지 않을까요?
  • 객실 등급별 생존율
    1등급 객실이 구명보트와 더 가까운 위치에 있었을 가능성이 높아요. 그렇다면 상류층 생존율이 더 높을까요?

 

고민은 끝났으니 이제 확인해 볼까요?

# [4] 그룹별 통계 & 피벗테이블
print(f"[성별별 생존율]\n{df.groupby('sex')['survived'].mean().round(3)}")
"""
[성별별 생존율]
sex
female    0.742
male      0.189
Name: survived, dtype: float64
"""

print(f"[객실 등급별 생존율]\n{df.groupby('pclass')['survived'].mean().round(3)}")
"""
[객실 등급별 생존율]
pclass
1    0.630
2    0.473
3    0.242
Name: survived, dtype: float64
"""

`groupby()`는 특정 컬럼을 기준으로 데이터를 묶어서 통계 계산을 해줍니다. 첫 번째 출력을 보면, 성별로 그룹을 만들어서 `survived`의 평균을 구합니다. 그러니까 하나의 기준으로 나눠서 통계를 냅니다. 그런데 이렇게만 보면 성별만, 객실 등급만으로 봐야 하기 때문에 불편하지 않나요? 그래서 두 가지 기준으로 나눠서 통계를 보고 싶습니다.

 

pivot = df.pivot_table(
    values="survived",  # 계산할 값
    index="pclass",     # 세로축(행): 객실 등급
    columns="sex",      # 가로축(열): 성별
    aggfunc="mean",     # 집계 방법: 평균
)
print(f"[성별x등급별 생존율]\n{pivot.round(3)}")
"""
[성별x등급별 생존율]
sex     female   male
pclass               
1        0.968  0.369
2        0.921  0.157
3        0.500  0.135
"""

예상이 맞았습니다. 여성 생존율(74.2%)이 남성(18.9%) 보다 훨씬 높고, 1등급 생존율(63.0%)이 3등급(24.2%)보다 약 2.6배 높아요. 

`groupby()`를 통해서 성별만 보거나 등급만 보는 것보다 `pivot_table()`로 두 가지를 같이 보면 더 세밀한 패턴을 볼 수 있어요. 예를 들자면, 1등급 여성의 생존율은 96.8%로 거의 전원이 살아남았는데, 3등급 남성은 13.5%에 불과합니다. 

 

 

 

결측치 처리 & 새 컬럼 만들기

그룹별로 비교도 해봤으니 이제 분석하기 더 좋은 형태로 가공을 해볼까 합니다. 아까 발견했던 `age`의 결측치를 채우고 나이를 구간으로 나눠보는 작업을 해볼까 해요.

# [5] 결측치 처리 & 새 컬럼 만들기
print(f"before: {df['age'].isnull().sum()}")  # before: 177
df["age_filled"] = df["age"].fillna(df["age"].mean())
print(f"after: {df['age_filled'].isnull().sum()}")  # after: 0

df["age_group"] = pd.cut(
    df["age_filled"],  # 데이터
    bins=[0, 12, 19, 35, 60, 100],  # 구간을 나누는 경계값
    labels=["어린이", "청소년", "청년", "중년", "노년"],  # 각 구간의 이름
)

`fillna()`는 결측치를 다른 값으로 채우는 함수입니다. 여기서는 `df ['age']. mean()`을 사용했기 때문에 나이의 평균으로 채운 겁니다. 

`cut()`은 연속형 숫자를 구간으로 나눠 범주형 칼럼으로 만드는 함수입니다. `bins`는 구간을 나누는 경곗값으로 여기서는 나이에 대한 구분선이 됩니다. 경계선이 6개이기 때문에, 구간은 5개가 됩니다. `labels`는 각 구간의 이름으로 여기서는 어린이, 청소년, 청년, 중년, 노년으로 총 5개가 됩니다. 

 

`cut()`이 필요한 이유는 나이가 제각각인 상태에서 `groupby()`를 하면, 22세 그룹, 25세 그룹 이렇게 너무 잘게 쪼개져서 의미 있는 비교가 어려워요. 20대, 30대처럼 묶어야 패턴이 보이기 시작합니다. 그래서 굳이 나누는 작업을 하는 겁니다. 

 

 

 

그럼 이제 2가지 궁금증이 생깁니다.

  • 평균으로 결측치를 채우면 문제가 없을까?
  • 그룹 크기가 다를 때 평균만 보면 왜곡이 생기지 않을까요?

이 궁금증에 대해서 해결을 해볼까 해요.

 

 

 

평균으로 결측치를 채우면 문제는 없을까?

우리는 나이의 결측치를 평균으로 채웠어요. 이 방법이 항상 좋은 건 아닙니다. 결측치가 특정 그룹에 집중되어 있다면 특정 그룹에 왜곡이 생길 수 있겠죠. 

 

코드를 해석해보자면, 데이터를 성별 기준으로 나눈 뒤 나이 컬럼만 선택해요. 그리고 결측치의 개수를 전체 행 수로 나눠요. 결측 비율(결측치 개수 / 전체 개수)을 구하는 겁니다. 결과를 보면, female은 16.9%, male은 21.5%로 성별 간 결측 비율이 큰 차이 없이 비슷하게 분포되어 있어요.

 

print(
    df.groupby("pclass")["age"].agg(
        결측비율=lambda x: x.isnull().mean().round(3),
        평균나이=lambda x: x.mean().round(1),
    )
)
"""
         결측비율  평균나이
pclass             
1       0.139  38.2
2       0.060  29.9
3       0.277  25.1
"""

이번엔 객실 등급(pclass) 기준으로 그룹을 나눠요. `agg()`는 하나의 그룹에 여러 집계 함수를 동시에 적용할 수 있어서 결측 비율과 평균 나이를 한 번에 계산해서 테이블 형태로 보여줍니다. 결측비율을 보면 3등급의 결측 비율이 27.7%로 가장 높은데, 평균 나이는 25.1세로 가장 낮아요. 이 두 가지가 겹치는 게 문제입니다. 전체 평균(약 30세)으로 결측치를 채우면 3등급 승객의 빈 나이 칸이 실제보다 5세 높은 값으로 채워지고 왜곡된 나이 데이터가 이후 분석에도 영향을 줄 수 있어요. 그래서 그룹별 평균으로 결측치를 채우는 전략이 더 적절해요.

 

 

df["age_filled_v2"] = df.groupby("pclass")["age"].transform(
    lambda x: x.fillna(x.mean())
)

그럼 `age_filled_v2`를 만들어서 그룹별 평균으로 채워볼게요. `groupby('pclass')`로 세 그룹(1 ~ 3등급)으로 나누고, 각 그룹 안에서 `x.mean()`으로 그 그룹의 평균 나이를 구하고, `x.fillna()`로 그 그룹의 결측치를 그 그룹의 평균으로 채웁니다. `transform`은 그룹별로 계산한 값을 원래 DataFrame의 각 행에 그대로 돌려줍니다.

 

물론 이것도 완벽한 방법은 아닙니다. 3등급 안에서도 성별이나 탑승 항구에 따라 나이 분포가 다를 수 있고, 그룹이 세분화될수록 더 정교해지지만 그만큼 복잡해지죠. 결측치 처리에는 정답이 없으니, 데이터의 특성과 분석 목적에 맞게 최선의 방법을 선택하는 것이 중요해요.

 

 

그룹 크기가 다를 때 평균만 보면 왜곡이 생기지 않을까요?

`groupby()`로 생존율을 계산할 때 놓치기 쉬운 함정이 있어요. 그룹 크기가 다를 때 평균만 보면 왜곡이 생길 수 있거든요. 연령대별 생존율을 볼게요.

age_survival = (
    df.groupby("age_group")["survived"].agg(생존율="mean", 인원수="count").round(3)
)
print(age_survival)
"""
             생존율  인원수
age_group            
어린이        0.580   69
청소년        0.411   95
청년         0.353  510
중년         0.400  195
노년         0.227   22
"""

노년의 생존율이 22.7%로 가장 낮아요. 그런데 노년 그룹은 고작 22명이에요. 22명 중 단 1명만 생사가 바뀌어도 생존율이 약 4.5%p나 달라져요. 반면 청년은 510명이라 1명이 바뀌어도 0.2%p 미만이에요. 즉, 인원수가 적은 그룹의 통계는 신뢰도가 낮아요. 생존율 숫자만 보고 노년이 가장 불리했다고 단정하기 어렵습니다. 

 

통계 숫자는 항상 그 숫자가 얼마나 많은 데이터를 기반으로 하는지와 함께 봐야 해요. 평균값 하나만 보는 게 아니라, 그 평균이 몇 명을 기반으로 한 건지도 습관처럼 확인하는 게 좋아요.

 

 

 

참고 - % vs. %p

  • `%`: 전체 중 비율 ex. 3등급의 결측 비율은 27.7%처럼 절댓값을 표현할 때 사용
  • `%p`: 두 비율 간의 차이 ex. 3등급(27.7%)과 2등급(6.0%)의 결측 비율 차이는 21.7%p처럼 비율끼리 뺄 때 사용