📖 本篇简介:本篇用机器学习界的"Hello World"——Kaggle 泰坦尼克号竞赛数据集,完整走一遍真实项目的数据分析流程:数据加载与兜底、字段理解、缺失值诊断与清洗、单变量/多变量探索、scipy 统计显著性检验、特征工程,最后用数据讲一个"什么样的人更容易幸存"的故事。前置知识:第 6~8 章的缺失值处理、第 15 章的分组聚合、第 19 章的分组变换。学完后你能把前面所有零散知识点串成一条可复用的分析流水线。
前 52 章我们学的是"单点技能"。从本篇开始进入真实数据集实战系列:每篇都用一份完整数据,演示从原始脏数据到业务结论的全过程。本篇做泰坦尼克号生存分析,重点是缺失值处理的取舍与用统计检验代替"看表格拍脑袋"。
目录
1. 案例背景与数据来源
2. 加载数据集(含失败兜底)
3. 字段含义一览
4. 数据概览与缺失诊断
5. 数据清洗
6. 探索性分析:谁更可能幸存
7. 统计显著性检验(scipy)
8. 特征工程:家庭规模与是否独行
9. 数据讲故事:什么样的人更容易活下来
10. 可视化
11. 结论与行动建议
12. 常见坑
13. 扩展练习
1. 案例背景与数据来源
1912 年 4 月 15 日,泰坦尼克号在首航途中撞上冰山沉没。船上约 2224 名乘客与船员中,超过 1500 人遇难。Kaggle 把其中 891 名乘客的信息整理成竞赛数据集,任务是根据乘客的身份特征预测其是否幸存。这是入门数据科学最经典的数据集,原因是:样本量适中(891 行)、有缺失值、字段既有数值也有类别、结论直观可解释。
本篇不做预测建模(那需要 scikit-learn,本环境未安装),而是做描述性分析 + 统计推断,回答一个业务味十足的问题:在那场灾难里,什么样的人更容易活下来?
数据来源(如实说明):本篇数据来自公开数据集(Kaggle Titanic 竞赛数据集),本机通过 seaborn 内置副本sns.load_dataset("titanic")加载。seaborn 会把 Kaggle 原始的Cabin列折叠成deck(甲板字母)列,字段略有简化但统计结论一致。
2. 加载数据集(含失败兜底)
sns.load_dataset 实际是从 seaborn 官方 GitHub 仓库在线下载 CSV,国内网络偶尔会超时。所以健壮的做法是 try/except:在线加载失败时,退化为一份结构一致的模拟数据,保证教程代码永远能跑通。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# 让 matplotlib 正常显示中文与负号
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
# 优先用 seaborn 内置的真实泰坦尼克号副本;网络失败则退回结构一致的模拟数据
try:
df = sns.load_dataset("titanic")
print("数据来源:seaborn 内置 Kaggle Titanic 副本")
except Exception as e:
# —— 兜底分支:构造列名/类型与真实数据一致的模拟数据 ——
print("在线加载失败(%s),改用模拟数据演示" % e)
rng = np.random.default_rng(42)
n = 891
df = pd.DataFrame({
"survived": rng.integers(0, 2, n),
"pclass": rng.choice([1, 2, 3], n, p=[0.24, 0.21, 0.55]),
"sex": rng.choice(["male", "female"], n, p=[0.35, 0.65]),
"age": np.clip(rng.normal(30, 14, n), 0.4, 80),
"sibsp": rng.poisson(0.5, n),
"parch": rng.poisson(0.4, n),
"fare": rng.uniform(0, 512, n),
"embarked": rng.choice(["S", "C", "Q"], n, p=[0.7, 0.2, 0.1]),
})
print(df.shape)
print(df.columns.tolist())
(891, 15)
['survived', 'pclass', 'sex', 'age', 'sibsp', 'parch', 'fare', 'embarked',
'class', 'who', 'adult_male', 'deck', 'embark_town', 'alive', 'alone']
⚠️ 常见坑:seaborn 的泰坦尼克号有 15 列,比 Kaggle 原始train.csv(12 列)多出class/who/adult_male/deck/embark_town/alive/alone几个派生列。这些列很多是冗余信息(例如alive就是survived的文字版,class就是pclass的文字版),分析时要主动挑列,别让它们污染相关系数计算。
3. 字段含义一览
拿到数据第一件事不是急着算,而是搞清楚每一列是什么。这是字段对照表:
| 字段 | 含义 | 类型 | 备注 |
|------|------|------|------|
| survived | 是否幸存(1=幸存,0=遇难) | 数值 | 我们要分析的目标 |
| pclass | 客舱等级(1/2/3 等舱) | 数值(有序类别) | 社会地位/票价的代理变量 |
| sex | 性别(male/female) | 类别 | |
| age | 年龄(岁) | 数值 | 有缺失,婴儿 < 1 |
| sibsp | 同行的兄弟姐妹/配偶数 | 数值 | |
| parch | 同行的父母/子女数 | 数值 | |
| fare | 船票价格(英镑) | 数值 | |
| embarked | 登船港(S/C/Q) | 类别 | S=南安普敦, C=瑟堡, Q=皇后镇 |
| deck | 所在甲板(A~G) | 类别 | 即原 Kaggle 的 Cabin 字段,缺失极多 |
| embark_town | 登船港全名 | 类别 | 与 embarked 冗余 |
| who | man/woman/child | 类别 | 派生列 |
| alone | 是否独自一人 | 布尔 | 派生列 |
记住一个原则:目标列是 `survived`,其余都是候选解释变量。
4. 数据概览与缺失诊断
真实数据到手后,先做三件事:看形状、看类型、看缺失。
# 形状
print("形状:", df.shape)
# 数值列的分布概览(count 列能直接看出谁有缺失)
print(df.describe(include="all").T[["count", "mean", "min", "max"]])
# 缺失值统计:绝对数量 + 百分比
miss = pd.DataFrame({
"缺失数": df.isna().sum(),
"缺失比例%": (df.isna().mean() * 100).round(1),
})
print(miss[miss["缺失数"] > 0])
形状: (891, 15)
缺失数 缺失比例%
age 177 19.9
embarked 2 0.2
deck 688 77.2
embark_town 2 0.2
缺失诊断一眼就能看出三类问题,处理策略完全不同:
| 字段 | 缺失比例 | 性质 | 处理思路 |
|------|----------|------|----------|
| age | 19.9% | 中等缺失、数值列 | 用合理统计量填充(见下) |
| embarked / embark_town | 0.2%(仅 2 行) | 极少量缺失 | 填众数,影响可忽略 |
| deck(原 Cabin) | 77.2% | 超高缺失 | 不要硬填,转成"有无舱位"二值特征 |
⚠️ 常见坑:看到缺失就dropna()是新手本能。这里deck一删就剩 203 行,样本量损失 77%,结论彻底失真。缺失比例高不是删除的理由,反而是信息——"没记录舱位"本身往往意味着三等舱、底层乘客。
5. 数据清洗
5.1 Age:分组中位数填充,并说明取舍
年龄缺了 177 个。填什么?常见三种方案:
1. 填全局均值/中位数:简单,但忽略了"一等舱女性和三等舱少年年龄结构不同"。
2. 按分组中位数填充:用 pclass + sex 分组后的中位数填补,更贴合该人群的真实年龄。
3. 直接删行:丢 177 行(20%),浪费样本。
这里选方案 2。先看分组中位数:
# 各 pclass+sex 组的年龄中位数差异明显,说明分组填充有意义
print(df.groupby(["pclass", "sex"])["age"].median())
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: float64
# 用【分组中位数】填充 Age;transform 保证结果与原表等长、可直接赋值
# 若某组整体都缺失(极端情况),再回退到全局中位数兜底
df["age"] = df.groupby(["pclass", "sex"])["age"].transform(lambda s: s.fillna(s.median()))
df["age"] = df["age"].fillna(df["age"].median())
print("age 剩余缺失:", df["age"].isna().sum()) # 0
为什么不用均值? 年龄偏态分布(有婴儿也有 80 岁老人),均值易被极端值拉偏,中位数更稳健。
5.2 deck:缺失率 77%,转为"有无舱位"特征
deck 缺了 688 个,且它本质是"有没有记录到舱位号"。与其纠结填哪个字母,不如提炼成一个二值特征:这个人是否登记了甲板/舱位。
# deck 非空 → 有舱位记录(往往是一等舱、靠近上层甲板);空 → 无记录
df["has_cabin"] = df["deck"].notna().astype(int)
print(df.groupby("has_cabin")["survived"].agg(["count", "mean"]).round(3))
count mean
has_cabin
0 688 0.299
1 203 0.670
一个细节立刻浮现:有舱位记录的人幸存率 67%,没记录的只有 30%。这个二值特征的预测力比保留 deck 字母还强。
5.3 Embarked:填众数
只缺 2 行,直接用出现最多的港口 S(南安普敦)填充。
print(df["embarked"].value_counts(dropna=False))
# S 644
# C 168
# Q 77
# NaN 2
df["embarked"] = df["embarked"].fillna(df["embarked"].mode()[0])
5.4 类型转换与去冗余
# pclass 本质是有序类别(一等>二等>三等),转成 category 更省内存、分组更快
df["pclass"] = df["pclass"].astype("category")
# 删除冗余列:alive=survived 的文字版,class/embark_town 与 pclass/embarked 重复,
# deck 已被 has_cabin 取代;保留它们只会在算相关矩阵时制造重复相关。
df = df.drop(columns=["alive", "class", "embark_town", "deck"])
print("清洗后剩余缺失总数:", int(df.isna().sum().sum())) # 0
⚠️ 常见坑(pandas 3.0):pandas 3.0 默认开启 Copy-on-Write,旧版中df.groupby(...)["age"] = ...这类链式赋值会触发的SettingWithCopyWarning在 3.0 默认不再弹出;同时多数方法的inplace=True参数已被移除。本教程统一用赋值回原列(df["age"] = ...),这在新旧版本都安全。
6. 探索性分析:谁更可能幸存
6.1 整体生存率
print("整体幸存率: %.1f%%" % (df["survived"].mean() * 100))
print(df["survived"].value_counts())
整体幸存率: 38.4%
survived
0 549
1 342
Name: count, dtype: int64
全船 891 人里 342 人幸存,整体幸存率 38.4%。这是后面所有分组对比的"基准线"——任何子群显著高于 38.4% 就是"更幸运",显著低于就是"更不幸"。
6.2 性别与生存
print(df.groupby("sex", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
sex
female 314 0.742
male 577 0.189
女性幸存率 74.2%,男性仅 18.9%——"妇女儿童优先"(women and children first)在数据里体现得淋漓尽致。
6.3 客舱等级与生存
print(df.groupby("pclass", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
pclass
1 216 0.630
2 184 0.473
3 491 0.242
一等舱 63.0% > 二等舱 47.3% > 三等舱 24.2%。舱位越高(越贵),幸存率越高,几乎是单调递减。
6.4 年龄分段与生存
连续变量直接 groupby 看不出规律,先分箱:
# pd.cut 按边界切箱;labels 给中文段名
df["age_bin"] = pd.cut(df["age"], [0, 12, 18, 35, 60, 100],
labels=["儿童(0-12)", "青少年(12-18)", "青年(18-35)",
"中年(35-60)", "老年(60+)"])
print(df.groupby("age_bin", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
age_bin
儿童(0-12) 69 0.580
青少年(12-18) 70 0.429
青年(18-35) 514 0.358
中年(35-60) 216 0.384
老年(60+) 22 0.227
儿童(12 岁以下)幸存率 58%,明显高于基准;老年人最低(22.7%)。注意"儿童"组只有 69 人,比例看着高但样本小,下一节要做检验。
6.5 票价分段与生存
# qcut 按分位数切成 4 等份,每组人数大致相等
df["fare_bin"] = pd.qcut(df["fare"], 4, labels=["低价", "中低", "中高", "高价"])
print(df.groupby("fare_bin", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
fare_bin
低价 223 0.197
中低 224 0.304
中高 222 0.455
高价 222 0.581
票价越高幸存率越高(19.7% → 58.1%),与舱位结论互相印证。
6.6 登船港与生存
print(df.groupby("embarked", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
embarked
C 168 0.554
Q 77 0.390
S 644 0.337
瑟堡(C)登船的人幸存率最高(55.4%)。原因不是港口本身"风水好",而是从瑟堡上船的一等舱乘客比例更高——这就是后面要讲的"辛普森式"混杂。
7. 统计显著性检验(scipy)
分组比例有差异,怎么排除"只是抽样巧合"? 这就要上假设检验。本环境 scipy 可用,我们不用 sklearn。
7.1 卡方检验:性别与生存是否独立
from scipy.stats import chi2_contingency
# 列联表:行=性别,列=是否幸存
ct = pd.crosstab(df["sex"], df["survived"])
print(ct)
chi2, p, dof, expected = chi2_contingency(ct)
print(f"卡方值={chi2:.2f}, p={p:.3e}, 自由度={dof}")
survived 0 1
sex
female 81 233
male 468 109
卡方值=260.72, p=1.197e-58, 自由度=1
p ≈ 1.2e-58,远小于 0.05,强烈拒绝"性别与幸存无关"的原假设:性别差异不是随机波动。
7.2 其它变量的检验结果
from scipy.stats import ttest_ind
# 舱位等级 vs 生存:卡方
chi2_pc, p_pc, _, _ = chi2_contingency(pd.crosstab(df["pclass"], df["survived"]))
# 有无舱位 vs 生存:卡方
chi2_hc, p_hc, _, _ = chi2_contingency(pd.crosstab(df["has_cabin"], df["survived"]))
# 票价 vs 生存:幸存组与遇难组的票价均值差异( Welch t 检验,不假设方差相等)
g1 = df.loc[df["survived"] == 1, "fare"]
g0 = df.loc[df["survived"] == 0, "fare"]
t_stat, p_t = ttest_ind(g1, g0, equal_var=False)
print(f"pclass : chi2={chi2_pc:.2f}, p={p_pc:.3e}")
print(f"has_cabin : chi2={chi2_hc:.2f}, p={p_hc:.3e}")
print(f"fare 票价 : t={t_stat:.2f}, p={p_t:.3e}, 幸存均票价={g1.mean():.1f}, 遇难均票价={g0.mean():.1f}")
pclass : chi2=102.89, p=4.549e-23
has_cabin : chi2=89.44, p=3.169e-21
fare 票价 : t=6.84, p=2.699e-11, 幸存均票价=48.4, 遇难均票价=22.1
汇总成一张检验结论表:
| 变量 | 检验方法 | 统计量 | p 值 | 结论 |
|------|----------|--------|------|------|
| 性别 sex | 卡方 | χ²=260.72 | 1.2e-58 | 高度显著 |
| 舱位 pclass | 卡方 | χ²=102.89 | 4.5e-23 | 高度显著 |
| 有无舱位 has_cabin | 卡方 | χ²=89.44 | 3.2e-21 | 高度显著 |
| 票价 fare | Welch t | t=6.84 | 2.7e-11 | 高度显著 |
四个变量的 p 值都远小于 0.05,说明前面"看比例"得到的差异是统计上可靠的,不是噪声。
怎么读 p 值:p < 0.05 表示"如果变量真的无关,观测到这么大差异的概率不到 5%"。p 小只说明有关,不说明因果——因果要靠业务知识解释。
8. 特征工程:家庭规模与是否独行
原始有 sibsp(同行兄弟姐妹/配偶)和 parch(同行父母/子女)两列。把它们合成一个"家庭规模"特征,往往更有解释力。
# 家庭人数 = 同行亲属 + 自己(+1)
df["FamilySize"] = df["sibsp"] + df["parch"] + 1
# 是否独自一人:家庭规模=1
df["IsAlone"] = (df["FamilySize"] == 1).astype(int)
print(df.groupby("IsAlone", observed=True)["survived"]
.agg(["count", "mean"]).round(3))
count mean
IsAlone
0 354 0.506
1 537 0.304
结伴同行的人幸存率 50.6%,独行的人只有 30.4%。背后的故事:结伴的家庭里有妇女儿童优先上船,而独行乘客绝大多数是三等舱成年男性。
再看家庭规模的完整分布(这是经典的"非线性"规律):
print(df.groupby("FamilySize")["survived"]
.agg(["count", "mean"]).round(3))
count mean
FamilySize
1 537 0.304
2 161 0.553
3 102 0.578
4 29 0.724
5 15 0.200
6 22 0.136
7 12 0.333
8 6 0.000
11 7 0.000
规律非常清晰:2~4 人的小家庭幸存率最高(55%~72%);独自一人(1 人)和超大家庭(5 人以上)幸存率都很低。大家庭反而劣势,可能是因为"要照顾太多人、行动不便"。
可选扩展(Title 提取):原始 Kaggle 数据有Name列,可用df["Name"].str.extract(r' ([A-Za-z]+)\.')提取称呼(Mr/Mrs/Master/Miss…)作为 Title 特征,往往比纯性别还强。seaborn 副本没有Name列,用自带的who(man/woman/child)验证即可:儿童幸存率 59%、成年女性 76%、成年男性仅 16%,再次坐实"妇女儿童优先"。
9. 数据讲故事:什么样的人更容易活下来
把前面的数字串成一个有因果解释的故事。先看数值变量与幸存的相关系数:
num = df.select_dtypes(include=[np.number])
print(num.corr(numeric_only=True)["survived"]
.sort_values(ascending=False).round(3))
survived 1.000
has_cabin 0.320
fare 0.257
parch 0.082
FamilySize 0.017
sibsp -0.035
age -0.060
IsAlone -0.203
Name: survived, dtype: float64
与幸存正相关最强的是 has_cabin(0.32)和 fare(0.26),负相关最强的是 IsAlone(-0.20)。综合所有证据:
更容易幸存的画像:
1. 女性 / 儿童(女性 74%、儿童 58%,远高于男性 19%)——因为"妇女儿童先上救生艇"。
2. 一等舱 / 买贵票的人(一等舱 63% vs 三等舱 24%;高价票组 58% vs 低价组 20%)——救生艇甲板上层更近、船员优先安排。
3. 有舱位记录的人(67% vs 30%)——本质还是一等舱的代理。
4. 2~4 人的小家庭结伴者(55%~72%)——有亲友互相照应,且多带妇女儿童。
更难幸存的画像:
- 独行的成年男性(三等舱):幸存率不到 20%。
- 超大家庭(5 人以上)和 60 岁以上老人。
关于登船港的"混杂":C 港幸存率高,并不是港口本身的功劳,而是因为 C 港(瑟堡)停靠了更多一等舱豪华乘客。做分析时一定要问一句:这个变量是真原因,还是只是和"舱位"绑在一起?
10. 可视化
图保存到临时目录即可,不必存进教程工程目录。下面四张图是一份"迷你报告"。
import tempfile, os
outdir = tempfile.gettempdir() # 系统临时目录,不污染教程目录
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
# 1) 性别 × 幸存:分组柱状
sns.barplot(data=df, x="sex", y="survived", ax=axes[0, 0],
errorbar=None, palette="Set2")
axes[0, 0].set_title("不同性别的幸存率")
axes[0, 0].set_ylabel("幸存率")
# 2) 舱位 × 幸存
sns.barplot(data=df, x="pclass", y="survived", ax=axes[0, 1],
errorbar=None, palette="Set2")
axes[0, 1].set_title("不同舱位的幸存率")
axes[0, 1].set_ylabel("幸存率")
# 3) 年龄分布按幸存与否(直方图)
sns.histplot(data=df, x="age", hue="survived", bins=30,
ax=axes[1, 0], kde=True, palette="Set1")
axes[1, 0].set_title("年龄分布 vs 是否幸存")
# 4) 家庭规模 × 幸存
fs = df.groupby("FamilySize")["survived"].mean()
fs.plot(marker="o", ax=axes[1, 1], color="steelblue")
axes[1, 1].set_title("家庭规模 vs 幸存率")
axes[1, 1].set_ylabel("幸存率"); axes[1, 1].set_xlabel("家庭人数")
plt.tight_layout()
path = os.path.join(outdir, "titanic_report.png")
plt.savefig(path, dpi=120)
plt.show()
print("图已保存到:", path)
这张 2×2 报告图分别对应:性别幸存率、舱位幸存率、年龄分布叠加幸存、家庭规模折线。其中第四张折线能一眼看出"2~4 人家庭最优"的非线性规律,比相关系数直观得多。
11. 结论与行动建议
=== 分析结论(基于真实 Kaggle Titanic 891 名乘客)===
1. 整体幸存率 38.4%(342/891);
2. 性别是最强信号:女性幸存率 74.2%,男性仅 18.9%(卡方 p≈1e-58);
3. 阶层信号:一等舱 63.0% > 二等舱 47.3% > 三等舱 24.2%,票价越高幸存率越高;
4. 有无舱位记录是强代理变量:有记录者 67%,无记录者 30%;
5. 家庭结构:2~4 人小家庭幸存率最高,独行成年男性与 5 人以上大家庭最危险;
6. 儿童优先(58%)、老年最低(23%)。
=== 方法论层面的"行动建议"(迁移到其它项目)===
1. 缺失值不要一律删:按"缺失比例 + 业务含义"分别策略——
中缺失用分组中位数、超高缺失转二值特征、极少量填众数;
2. 分组比例有差异后,务必用 scipy 卡方/t 检验确认不是噪声;
3. 注意混杂变量:C 港幸存率高是因为一等舱多,别把代理变量当原因;
4. 连续变量先分箱(cut/qcut)再分组,规律比直接算相关更直观;
5. 结论要能讲出"为什么"(妇女儿童优先、上层甲板更近),而不只是"相关"。
12. 常见坑
⚠️ 坑 1:把 `alive`/`class` 等冗余列也拿去建模或算相关。 它们和目标列或主特征是同一件事的两种写法,会制造"假高相关"。清洗时主动 drop。⚠️ 坑 2:Age 直接填全局中位数。 一等舱 40 岁男性和三等舱 21 岁少女年龄结构完全不同,全局中位数会抹平差异。用 groupby.transform 做分组填充更准。⚠️ 坑 3:`deck` 缺失 77% 就 `dropna`。 一下丢掉 77% 样本,且"没舱位记录"本身就是信息。正确做法是转成 has_cabin 二值特征。⚠️ 坑 4:看到幸存率差异就下因果结论。 "C 港登船幸存率高"≠"从 C 港上船更安全",背后是舱位结构。先问混杂变量。
⚠️ 坑 5:pandas 3.0 还在用 `inplace=True`。 3.0 多数方法已移除/弃用inplace,统一写df = df.sort_values(...)或df["col"] = df["col"].fillna(...)这种赋值形式。
13. 扩展练习
1. 把 Age 的"分组中位数填充"改成"用 Sex + Pclass + Parch 回归预测填充"(可用简单的均值回归),对比两种填充下"青年/中年"幸存率结论是否变化。
2. 提取 deck 的首字母(本数据已在 deck 列),分析 A~G 各甲板的幸存率是否单调,是否和"甲板越上层越安全"一致。
3. 对"儿童 vs 成年人"做一次卡方检验,写出原假设、p 值和结论。
4. 构造 Mother 特征(女性、年龄>18、Parch>0),分析母亲的幸存率是否高于普通女性。
5. 用 pd.crosstab(index=sex, columns=pclass, values=survived, aggfunc='mean') 做"性别 × 舱位"双因素表,解释为什么三等舱女性幸存率反而不高。
6. 把本篇的分析流水线封装成一个 analyze_titanic(df) 函数,输入任意同类 DataFrame 就能输出结论表。
下一篇预告:第 54 章 真实数据集实战:电商订单分析 —— 用模拟的电商订单数据走一遍 RFM 用户分层、时段与品类区域分析、复购分析的完整经营复盘。
文章回复
0 条公开回复