🚢 titanic

[python] 탐사일지 04. 침몰의 법칙을 추적하다: 가설 검증

Floaty 2026. 7. 16. 10:00

가설 검증하기

타이타닉 데이터를 가지고 총 7개의 가설을 세운 뒤 하나씩 통계적으로 검증해 보는 글입니다. 이 글에서는 두 가지 검정을 반복해서 사용해요. 

  • 카이제곱 검정(chi2_contingency): 성별, 등급처럼 범주형 변수 두 개 사이에 관계가 있는지를 볼 때 사용
  • t-검정(ttest_ind): 나이, 요금처럼 연속형 변수의 평균을 두 그룹 사이에서 비교할 때 사용

검정 원리는 처음 등장하는 가설(H1)에서 한 번만 자세히 설명하고 이후 같은 검정이 재등장하는 가설에서는 결과 해석 위주로 가볍게 다룹니다. 

 

 

 

H1: 여성이 남성보다 생존율이 높을 것이다.

'타이타닉'하면 가장 먼저 떠오르는 게 "여성과 아이 먼저"라는 구호죠. 실제로 데이터에서도 그랬을지 확인해 볼게요.

# H1: 여성이 남성보다 생존율이 높을 것이다.
print(
    df.groupby("sex")["survived"]     # 성별로 데이터를 나눈 뒤 survived 컬럼만 선택
    .agg(["mean", "count"])           # 각 성별의 평균 생존 여부(=생존율)와 데이터 개수를 계산
    .round(3)
)
"""
         mean  count
sex                 
female  0.742    314
male    0.189    577
"""

`survived`컬럼은 생존하면 1, 사망하면 0이기 때문에 이 값의 평균을 내면 자연스럽게 "생존율"이 됩니다. 성별로 묶어서 평균을 내면 남성 집단의 생존율과 여성 집단의 생존율을 바로 비교할 수 있어요.

 

여기까지는 단순 집계라서 낯설지는 않아요. "이 차이가 우연이 아니라고 확신할 수 있을까?"라는 질문을 하게 됩니다. 

 

# 두 범주형 변수 사이에 통계적으로 유의한 관계가 있는지 확인하는 카이제곱 독립성 검정
ct = pd.crosstab(  # 2개 이상의 범주형 변수 조합이 몇 번 나타났는지 교차표, 분할표, 빈도표를 만듦
    df["sex"], df["survived"]
)  # 성별과 생존 여부의 조합에 해당하는 승객 수

chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")  # chi2=260.72, p=1.20e-58

`chi2_contingency`는 교차표를 넣으면 아래 네 가지를 돌려줍니다.

  • `chi2`: 실제로 관측된 인원 수와 "성별과 생존이 아무 관계가 없다"라고 가정했을 때 기대되는 인원수의 차이를 하나의 숫자로 요약한 값. 이 값이 작을수록 관측치와 기댓값이 비슷하고, 클수록 둘 사이 차이가 크다는 뜻입니다.
  • `p`: p-value, 성별과 생존은 아무 관계가 없다"는 가정(귀무가설)이 실제로 맞다고 쳤을 때, 지금 데이터에서 본 것만큼 큰 차이가 그냥 우연히 나타날 확률. (보통 0.05를 기준으로 삼아서 p가 0.05보다 작으면 이 차이를 우연이라고 보기 어렵다고 판단합니다.)
  • `dof`: 자유도, 검정 통계량을 계산할 때 자유롭게 변할 수 있는 값의 개수, 표의 행과 열 개수에 따라 자동으로 정해지는 값이라 직접 해석할 일은 많지 않습니다. 
  • `exp`: 기대빈도, 두 변수가 서로 독립이라고 가정했을 때 각 칸에 나왔을 것으로 예상되는 인원 수로, 이 값과 실제 관측값(`ct`)을 비교한 결과가 바로 `chi2`입니다.

카이제곱 독립성 검정은 아래와 같이 가설 두 개를 세우고 시작합니다.

  • 귀무가설(H0): 성별과 생존 여부는 서로 독립이다. (성별이 달라져도 생존 여부는 달라지지 않는다.)
  • 대립가설(H1): 성별과 생존 여부는 서로 독립이 아니다. (둘 사이에 관계가 있다.)

`p-value`가 충분히 작게 나온다면, 귀무가설을 기각하고 성별과 생존 여부 사이에는 통계적으로 유의한 관계가 있다고 결론 내릴 수 있습니다. 

 

이제 우리의 결과를 한 번 볼게요. `p-value`가 우리가 기준으로 삼는 0.05보다 사실상 0에 더 가까운 값이에요. 성별과 생존 여부는 아무 관계가 없다는 귀무가설이 맞다면 이런 결과가 우연히 나올 확률이 사실상 0이라는 뜻이니 귀무가설을 확실하게 기각할 수 있습니다. 즉, 성별과 생존 여부 사이에는 매우 강한 통계적 관계가 있다고 결론지을 수 있어요.

 

 

 

더보기
sex_survival = df.groupby("sex")["survived"].mean().mul(100).reindex(["male", "female"])

fig, ax = plt.subplots()

bars = ax.bar(
    ["남성", "여성"],
    sex_survival.values,
    color=get_palette(2),
)

ax.bar_label(bars, fmt="%.1f%%", padding=3, fontsize=11, fontweight="bold")

ax.set_title("성별 생존율")
ax.set_ylabel("생존율")
ax.set_ylim(0, 100)
ax.grid(axis="x", visible=False)

plt.tight_layout()
save_fig(fig, "h1", "analysis")
plt.show()

숫자와 p-value만으로는 와닿지 않기 때문에 성별 생존율을 막대그래프로 그려봤습니다. 집계표, 교차표, 카이제곱 검정, 시각화까지 확인한 결과를 종합해 보자면 남성과 여성의 생존율은 확연히 차이가 났고, 카이제곱 검정 결과 이 차이는 통계적으로도 명확이 유의미했습니다. 

 

"여성이 남성보다 생존율이 높을 것이다"라는 H1은 데이터로 확실히 뒷받침된다고 볼 수 있어요. 

 

 

 

H2: 객실 등급이 높을수록 생존율이 높을 것이다.

print(df.groupby("pclass")["survived"].agg(["mean", "count"]).round(3))
"""
         mean  count
pclass              
1       0.630    216
2       0.473    184
3       0.242    491
"""

1등실 승객은 63.0%가 생존했고 2등실은 47.3%, 3등실은 24.2%로 등급이 낮아질수록 생존율이 뚜렷하게 떨어집니다. 

 

ct = pd.crosstab(df["pclass"], df["survived"])
chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")
# chi2=102.89, p=4.55e-23

p-value가 0.05보다 압도적으로 작습니다. "객실 등급과 생존 여부는 서로 독립"이라는 귀무가설을 기각할 수 있고, 등급과 생존 사이에는 통계적으로 유의한 관계가 있다고 결론지을 수 있어요. 

 

더보기
class_survival = df.groupby("pclass")["survived"].mean().mul(100).reindex([1, 2, 3])

fig, ax = plt.subplots(figsize=(7, 5))

bars = ax.bar(
    ["1등실", "2등실", "3등실"],
    class_survival.values,
    color=get_palette(3),
    width=0.55,
)

ax.bar_label(
    bars,
    fmt="%.1f%%",
    padding=3,
    fontsize=11,
    fontweight="bold",
)

ax.set_title("객실 등급별 생존율")
ax.set_ylabel("생존율")

plt.tight_layout()
save_fig(fig, "h2", "analysis")
plt.show()

시각화를 해보면 이렇습니다. 등급이 높을수록, 그러니까 1등실에 가까울수록 생존율이 뚜렷하게 높았고, 카이제곱 검정으로도 이 관계가 통계적으로 유의미함을 확인했습니다. 

 

 

 

H3: 어린이의 생존율이 성인보다 높을 것이다.

이번에는 성별과 나이 정보를 합쳐놓은 `who`컬럼(child, woman, man)을 사용해서 어린이가 다른 승객군보다 생존율이 높았는지 확인해 볼게요.

 

print(df.groupby("who")["survived"].agg(["mean", "count"]).round(3))
"""
        mean  count
who                
child  0.590     83
man    0.164    537
woman  0.756    271
"""

어린이는 59.0%, 여성은 75.6%가 생존했지만 성인 남성은 16.4% 밖에 생존하지 못했어요. 어린이 생존율이 성인 남성보다 훨씬 높지만, 여성보다는 오히려 낮다는 점이 눈에 띕니다.

 

ct = pd.crosstab(df["who"], df["survived"])
chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")  
# chi2=283.92, p=2.22e-62

앞에서 확인했던 가설들과 마찬가지로 p-value가 매우 작아서 `who`(승객 유형)와 생존 여부 사이에는 통계적으로 유의한 관계가 있다고 할 수 있어요. 

 

더보기
who_survival = (
    df.groupby(["pclass", "who"])["survived"]
    .mean()
    .mul(100)
    .unstack()
    .reindex(index=[1, 2, 3], columns=["child", "woman", "man"])
)

categories = [
    ("child", "어린이"),
    ("woman", "여성"),
    ("man", "남성"),
]

x = np.arange(len(who_survival.index))
width = 0.25

fig, ax = plt.subplots()
offsets = [-width, 0, width]
colors = get_palette(len(categories))

for (column, label), offset, color in zip(categories, offsets, colors):
    bars = ax.bar(
        x + offset,
        who_survival[column],
        width=width,
        label=label,
        color=color,
    )
    ax.bar_label(bars, fmt="%.0f", padding=3)

ax.set(
    title="객실 등급 × 승객 유형별 생존율",
    ylabel="생존율 (%)",
    xticks=x,
    xticklabels=["1등실", "2등실", "3등실"],
)

ax.legend()

plt.tight_layout()
save_fig(fig, "h3", "analysis")
plt.show()

 

단순히 어린이와 성인만 보면 놓칠 수 있는 부분이 있을 것 같아서 객실 등급까지 함께 쪼개서 시각화를 해봤어요. 이 그래프를 보면 "어린이가 성인보다 생존율이 높다"는 단순 비교보다 더 흥미로운 패턴이 보입니다. 어린이, 여성, 남성의 생존율 격차가 등급별로 어떻게 달라지는지 바로 비교해 볼 수 있어요.

 

전체적으로 보면 "어린이 > 성인"이라는 H3의 방향은 맞았지만 여성보다는 낮아서 "여성과 아이 먼저"라는 표현에서 실제로 여성이 가장 먼저 구조됐다고 보는 게 더 정확해 보입니다. 카이제곱 검정 결과로 볼 때 승객 유형과 생존 여부의 관계 자체는 확실히 유의합니다. H3은 "어린이가 성인보다 생존율이 높다"는 부분만 놓고 보면 채택되지만, "가장 높다"는 의미로 확장 해석하기를 어렵습니다. 

 

 

 

H4: 가족 규모가 너무 크거나 혼자인 경우 생존율이 낮고, 중간 규모일 때 가장 높을 것이다.

동승 가족 수를 구간화한 `family_group` 변수로 생존율이 U자 혹은 역 U자 형태를 보이는지 확인하는 가설입니다. 

print(df.groupby("family_group")["survived"].agg(["mean", "count"]).round(3))
"""
               mean  count
family_group              
1인            0.304    537
2~4인          0.579    292
5인 이상        0.161     62
"""

혼자 탑승한 승객은 30.4%, 2~4인 가족은 57.9%, 5인 이상은 16.1%로 2~4인 가족 규모일 때 가장 높고, 대규모로 갈수록 뚝 떨어집니다. 가설대로 중간이 가장 높은 역 U자 패턴이 보이네요.

 

ct = pd.crosstab(df["family_group"], df["survived"])
chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")  
# chi2=74.54, p=6.52e-17

p-value가 매우 작아서 가족 규모 구간과 생존 여부 사이에는 통계적으로 유의한 관계가 있습니다. 

 

더보기
family_survival = df.groupby("family_group")["survived"].mean().mul(100)

fig, ax = plt.subplots(figsize=(7, 5))

bars = ax.bar(
    family_survival.index.astype(str),
    family_survival.values,
    color=get_palette(len(family_survival)),
)

ax.bar_label(bars, fmt="%.1f%%", padding=3)
ax.set_title("가족 규모별 생존율")
ax.set_ylabel("생존율 (%)")

plt.tight_layout()
save_fig(fig, "h4", "analysis")
plt.show()

시각화 자료를 보도록 할게요.

 

가설에서 예상한 대로 중간 규모일 때 생존율이 가장 높고 양극단(혼자, 대가족)에서 낮아지는 역 U자 패턴이 뚜렷하게 나타났습니다. 카이제곱 검정 결과도 이 관계가 통계적으로 유의함을 뒷받침합니다. 

 

 

 

H5: 요금이 높을수록 생존 확률이 높을 것이다.

여기서부터는 `fare`(요금)라는 연속형 변수를 다루기 때문에 카이제곱 검정 대신 t-검정을 처음 사용합니다. t-검정은 "두 그룹의 평균이 통계적으로 다른가?"를 확인하는 검정이예요.

 

died = df.loc[df["survived"] == 0, "fare"].dropna()
survived = df.loc[df["survived"] == 1, "fare"].dropna()

t, p = ttest_ind(survived, died)

print(f"died mean={died.mean():.2f}, survived mean={survived.mean():.2f}")
# died mean=22.12, survived mean=48.40
print(f"t={t:.2f}, p={p:.2e}")
# t=7.94, p=6.12e-15

먼저 생존 여부별 요금 평균을 비교해 보도록 할게요. `ttest_ind`는 두 그룹, 생존과 사망의 요금 평균 차이가 통계적으로 유의한 지를 검정합니다. 

  • `t`: 두 그룹 평균 차이를 표준오차로 나눈 값으로 0에서 멀어질수록 두 그룹 평균 차이가 크다는 뜻
  • `p`: 두 그룹의 실제 평균이 같다고 가정했을 때, 지금 본 정도의 차이가 우연히 나타날 확률

사망한 승객의 평균 요금은 22.12, 생존한 승객의 평균 요금은 48.40으로 2배 이상 차이가 나고 p-value도 0.05보다 훨씬 작아서 이 차이는 통계적으로 유의합니다. 

 

그런데 이 관계가 진짜일지 확인해봐야 할 것 같아요. 요금과 생존이 관계있어 보여도 사실은 "요금이 높은 사람은 대부분 1등실 승객"이라는 등급 효과가 요금 효과처럼 보일 수 있지 않을까요? 이렇게 두 변수 사이의 진짜 관계를 왜곡하는 제3의 변수를 교란변수(confounding variable)라고 부릅니다. 그럼 요금과 승객 객실에 대한 관계를 보도록 할게요.

 

print(f"corr(fare, pclass)={df['fare'].corr(df['pclass']):.2f}")
# corr(fare, pclass)=-0.55

# 등실별 생존자와 사망자의 운임 차이 검정
for pclass, group in df.groupby("pclass"):
    survived_fare = group.loc[group["survived"] == 1, "fare"].dropna()
    died_fare = group.loc[group["survived"] == 0, "fare"].dropna()

    t, p = ttest_ind(survived_fare, died_fare)
    print(f"pclass={pclass}: t={t:.2f}, p={p:.3f}")

# pclass=1: t=2.85, p=0.005
# pclass=2: t=1.34, p=0.183
# pclass=3: t=0.02, p=0.984

요금과 등급의 상관계수가 -0.55로 꽤 강한 음의 상관을 보입니다. 등급 숫자가 작을수록, 그러니까 1등실에 가까울수록 요금이 높으니까 음수가 나오는 게 맞습니다. 실제로 등급을 통제하고 보니 이야기가 많이 달라지네요.

 

1등실 안에서는 요금이 여전히 유의한 차이를 보였어요. 하지만 2등실에서는 유의하지 않았고, 3등실에서는 사실상 차이가 없었습니다.  즉 "요금이 높을수록 생존율이 높다"는 관계는 대부분 등급 효과가 요금이라는 형태로 나타난 것이고 요금 자체의 독립적인 효과는 1등실 내부에서만 제한적으로 남아있는 것으로 보입니다. 

 

그럼 생존여부별 요금 분포를 시각화해서 보도록 할게요. 

 

 

더보기
colors = get_palette(3)

# 생존 여부별 운임 분포
fare_limit = 120

fare_groups = [
    df.loc[
        (df["survived"] == survived_value) & (df["fare"] <= fare_limit),
        "fare",
    ].dropna()
    for survived_value in [0, 1]
]

fig, ax = plt.subplots(figsize=(7, 5))

boxplot = ax.boxplot(
    fare_groups,
    tick_labels=["사망", "생존"],
    patch_artist=True,
    showfliers=False,
)

for box, color in zip(boxplot["boxes"], colors[:2]):
    box.set_facecolor(color)

for median in boxplot["medians"]:
    median.set_color(colors[2])

ax.set(
    title="생존 여부별 요금 분포 (이상치 제외)",
    ylabel="요금 (Fare)",
)

plt.tight_layout()
save_fig(fig, "h5", "analysis")
plt.show()
# 등실 내부에서 생존 여부별 평균 운임 비교
fare_by_class = (
    df.groupby(["pclass", "survived"])["fare"]
    .mean()
    .unstack()
    .reindex(index=[1, 2, 3], columns=[1, 0])
    .rename(columns={1: "생존", 0: "사망"})
)

ax = fare_by_class.plot.bar(
    figsize=(9, 5),
    width=0.7,
    color=colors[:2],
)

for bars in ax.containers:
    ax.bar_label(
        bars,
        fmt="%.1f",
        padding=3,
    )

ax.set(
    title="등실 내부에서의 요금 차이 (생존 vs 사망)",
    xlabel="",
    ylabel="평균 요금",
    xticklabels=["1등실", "2등실", "3등실"],
    ylim=(0, 105),
)

ax.tick_params(axis="x", rotation=0)
ax.legend(title="")
ax.grid(axis="x", visible=False)

plt.tight_layout()
save_fig(ax.figure, "h5_1", "analysis")
plt.show()

극단적으로 비싼 요금(=이상치)이 그래프를 왜곡할 수 있기 때문에 120 이하로 제한된 분포를 박스플롯으로 그려봤어요. 전체 t-검정만 보면 "요금이 높을수록 생존율이 높다"는 이 가설이 강하게 지지되는 것처럼 보입니다. 하지만 요금과 등급의 상관관계를 확인하고 등급별로 다시 검정해 보면 이 관계의 대부분은 등급이라는 교란변수 때문이고 요금 자체의 순수한 효과는 1등실 안에서만 약하게 남아있었습니다. H5는 표면적으로는 채택되지만, 실제 원인은 요금보다 등급에 더 가깝다고 보는 게 정확하다고 할 수 있겠어요.

 

 

 

H6: 객실 기록이 남아있는 승객(=주로 상위 등급)의 생존율이 더 높을 것이다.

`deck` 정보가 남아있다는 건 대게 상대적으로 상위 등급 승객일 가능성이 높아요. 이 가설도 H5에서 다룬 교란변수 문제를 같은 방식으로 확인해 볼 생각입니다. 

 

df["has_cabin_record"] = df["deck"].notna()
print(df.groupby("has_cabin_record")["survived"].agg(["mean", "count"]).round(3))
"""
                   mean  count
has_cabin_record              
False             0.299    688
True              0.670    203
"""


print(df.groupby(["pclass", "has_cabin_record"])["survived"].mean().round(3))
"""
pclass  has_cabin_record
1       False               0.463
        True                0.669
2       False               0.440
        True                0.812
3       False               0.236
        True                0.500
"""

일단 선실기록 유무별로 생존율을 보도록 할게요. 선실 기록이 없는 승객은 29.9%, 있는 승객은 67.0%가 생존해서 2배 넘게 차이가 납니다. 등급별로 쪼개봐도 1/2/3등실 모두에서 기록이 있는 쪽이 더 높게 유지됩니다. 

 

ct = pd.crosstab(df["has_cabin_record"], df["survived"])
chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")  # chi2=89.43, p=3.17e-21

p-value가 매우 작아서 선실기록 유무와 생존 여부 사이에는 통계적으로 유의한 관계가 있습니다. 

 

더보기
cabin_survival = (
    df.groupby(["pclass", "has_cabin_record"])["survived"]
    .mean()
    .mul(100)
    .unstack()
    .reindex([1, 2, 3])
)

x = np.arange(3)
width = 0.35

fig, ax = plt.subplots(figsize=(9, 5))
colors = get_palette(2)
no_cabin_bars = ax.bar(
    x - width / 2,
    cabin_survival[False],
    width=width,
    label="객실기록 없음",
    color=colors[0],
)

cabin_bars = ax.bar(
    x + width / 2,
    cabin_survival[True],
    width=width,
    label="객실기록 있음",
    color=colors[1],
)

ax.bar_label(
    no_cabin_bars,
    fmt="%.1f%%",
    padding=3,
)

ax.bar_label(
    cabin_bars,
    fmt="%.1f%%",
    padding=3,
)

ax.set_title("등실 내부에서의 '객실기록 유무'별 생존율", fontsize=18)
ax.set_ylabel("생존율 (%)")
ax.set_xticks(x)
ax.set_xticklabels(["1등실", "2등실", "3등실"])
ax.set_ylim(0, 90)
ax.legend()
ax.grid(axis="x", visible=False)

plt.tight_layout()
save_fig(fig, "h6", "analysis")
plt.show()

전체적으로도, 등급을 통제한 뒤에도 선실 기록이 있는 승객의 생존율이 일관되게 더 높았고 카이제곱 검정으로도 유의했습니다. H5의 요금과는 다르게 선실기록 유무는 등급을 통제한 뒤에도 효과가 사라지지 않는 걸로 봐서 나름 독립적인 영향력이 있어 보입니다. 그렇기에 H6은 데이터로 뒷받침됩니다. 

 

 

 

H7: 탑승 항구에 따라 생존율 차이가 있을 것이다. 

print(df.groupby("embark_town")["survived"].agg(["mean", "count"]).round(3))
"""
              mean  count
embark_town              
Cherbourg    0.554    168
Queenstown   0.390     77
Southampton  0.337    644
"""

ct = pd.crosstab(df["embark_town"], df["survived"])
chi2, p, dof, exp = chi2_contingency(ct)
print(f"chi2={chi2:.2f}, p={p:.2e}")  
# chi2=26.49, p=1.77e-06

셰르부르(Cherbourg) 출신 승객의 생존율이 55.4%로 가장 높고, 퀸스타운(Queenstown) 39.0%, 사우샘프턴(Southampton) 33.7% 순입니다. p-value가 0.05보다 훨씬 작아서, 항구와 생존 여부 사이에는 통계적으로 유의한 관계가 있습니다.

 

이것도 교란변수일지 항구별 등급 구성비를 확인해 보도록 할게요.

H5, H6에서 살펴본 것처럼 항구 자체보다는 "그 항구에서 주로 탑승한 승객의 등급"이 진짜 원인일 수 있어요. 항구별로 1/2/3등실 승객 비율이 얼마나 다른지 함께 확인해 볼게요.

 

port_order = ["Cherbourg", "Queenstown", "Southampton"]
class_order = [1, 2, 3]

port_survival = (
    df.groupby("embark_town")["survived"].mean().mul(100).reindex(port_order)
)

port_class_ratio = (
    pd.crosstab(
        df["embark_town"],
        df["pclass"],
        normalize="index",
    )
    .mul(100)
    .reindex(index=port_order, columns=class_order)
)

print(port_class_ratio.round(1))
"""
pclass          1     2     3
embark_town                  
Cherbourg    50.6  10.1  39.3
Queenstown    2.6   3.9  93.5
Southampton  19.7  25.5  54.8
"""

 

셰르부르에서 탄 승객의 절반(50.6%)이 1등실이었던 반면 퀸스타운에서 탄 승객의 93.5%는 3등실이었습니다. 셰르부르의 높은 생존율은 항구 자체의 효과라기보다는 그 항구에서 유독 1등실 승객 비중이 높았기 때문일 가능성이 커 보입니다. 

더보기
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
colors = get_palette(3)

# 탑승 항구별 생존율
bars = axes[0].bar(
    port_order,
    port_survival,
    color=colors,
    width=0.6,
)

axes[0].bar_label(bars, fmt="%.1f%%", padding=3)
axes[0].set(
    title="탑승 항구별 생존율",
    ylabel="생존율 (%)",
    ylim=(0, 70),
)
axes[0].grid(axis="x", visible=False)

# 항구별 객실 등급 구성비
bottom = np.zeros(len(port_order))

for pclass, color in zip(class_order, colors):
    values = port_class_ratio[pclass]

    axes[1].bar(
        port_order,
        values,
        bottom=bottom,
        label=f"{pclass}등실",
        color=color,
        width=0.6,
    )

    bottom += values

axes[1].set(
    title="항구별 등실 구성비",
    ylabel="비율 (%)",
    ylim=(0, 105),
)
axes[1].legend()
axes[1].grid(axis="x", visible=False)

plt.tight_layout()
save_fig(fig, "h7", "analysis")
plt.show()

왼쪽 그래프에서 항구별 생존율 차이와 오른쪽 그래프의 등급 구성비도 함께 봐야 합니다. 생존율이 높았던 항구에 1등실 승객 비율이 유난히 높은데 "항구" 자체의 효과라기보다는 "그 항구에서 탄 사람들이 주로 부유한 1등실 승객이었다"는 게 진짜 이유일 수 있어요.

 

항구별 생존율 차이는 통계적으로 유의했지만 등급 구성비를 보면 이 차이의 상당 부분이 "어느 항구에서 어떤 등급 승객이 많이 탔는가"로 설명됩니다. 셰르부르는 1등실 비중이 높아 생존율도 높게 나타났고, 퀸스타운은 3등실 비중이 압도적으로 높아 생존율도 낮게 나타났어요. H7은 표면적으로는 채택되지만 H5의 요금과 마찬가지로 실제 원인은 항구 자체보다 등급 구성일 가능성이 높습니다. 

 

 

가설 검증 결과 요약

가설 사용 검정 p-value 채택여부
H1. 여성이 남성보다 생존율이 높다 카이제곱 1.20e-58 O
H2. 객실 등급이 높을수록 생존율이 높다 카이제곱 4.55e-23 O
H3. 어린이의 생존율이 성인보다 높다 카이제곱 2.22e-62 O (여성보다는 낮음)
H4. 가족 규모가 중간일 때 생존율이 가장 높다 카이제곱 6.52e-17 O
H5. 운임이 높을수록 생존율이 높다 t-검정 6.12e-15 △ (등급 효과일 가능성 큼)
H6. 선실 기록이 있으면 생존율이 높다 카이제곱 3.17e-21 O
H7. 탑승 항구에 따라 생존율이 다르다 카이제곱 1.77e-06 △ (등급 구성비 효과일 가능성 큼)

 

7개 가설 모두 카이제곱/t-검정에서 통계적으로 유의한 결과가 나왔습니다. 다만 유의성만으로 끝내지 않고 등급을 통제해서 다시 들여다보면 이야기가 조금 달라졌어요. 성별(H1), 등급(H2), 승객 유형(H3), 가족규모(H4), 선실기록(H6)은 등급을 통제한 뒤에도 효과가 남아있었지만 요금(H5)과 탑승 항구(H7)는 상단 부분 등급이 요금/항구라는 형태로 나타난 것에 가까웠습니다. 즉 타이타닉 생존을 가장 강하게 갈랐던 축은 결국 성별과 객실 등급이고, 요금이나 항구 같은 변수는 그 등급을 대신 보여주는 대리 지표에 더 가까웠다고 정리할 수 있습니다.