본격적으로 시작하기 전에 왜 `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처럼 비율끼리 뺄 때 사용
'🚢 titanic' 카테고리의 다른 글
| [python] 항해일지 05. 더 선명하게 항해도 그리기: seaborn (0) | 2026.06.27 |
|---|---|
| [python] 항해일지 04. 갑판 위 풍경 스케치: matplotlib (0) | 2026.06.26 |
| [python] 항해일지 03. 선실 정리함 채우기: utils와 ModuleNotFoundError (0) | 2026.06.25 |
| [python] 항해일지 01. 엔진룸 구경하기: numpy (0) | 2026.06.23 |
| [python] 두 항해를 떠나기 전에.. (0) | 2026.06.22 |