Titanic数据集从下载到提交:二分类实战避坑指南
1. 先弄清楚这份数据集的三个版本以及它们各自的坑Titanic 数据集在数据科学圈子里几乎是人手一份的存在免费下载的渠道多到让人眼花但真正下载下来能直接跑通、并且跑出来的分数不冤枉的其实没几个人。我自己最开始接触它的时候就是从某个网盘链接扒了一份train.csv结果发现只有 891 行、列名还被 Excel 改成了中文花了半小时才把字段对齐。后来带新人做练习类似的翻车场面又见过好几次。所以这篇东西我不打算讲数据科学入门第一课那种空话而是把这份数据集从下载到跑出第一版提交文件的完整链路拆开重点讲清楚哪些地方看起来没问题、实际上会把你带进沟里。这份数据集记录了一批乘客的登记信息与一个二值的生存标记任务本质是一个典型的二分类监督学习问题给定 11 个左右的字段预测每条记录对应的标签是 0 还是 1。它的价值不在于难度而在于结构足够脏——有缺失值、有高基数类别特征、有大量看似无用实则能挖的文本字段、还有明显的样本不均衡。这些东西在真实业务数据里全都会遇到但真实数据你拿不到、也不敢乱动所以这份数据集成了一台非常合适的练手车。适合谁看刚学完 pandas 和 sklearn 基础、想跑通一个完整流程的人以及跑过几次但分数卡在 0.76 上下、不知道问题出在哪的人。先说下载。目前市面上流传的版本主要分三类差异比大部分人想象的大版本类型文件构成行数规模常见问题完整竞赛版train.csvtest.csvgender_submission.csv训练 891 行、测试 418 行测试集不含标签无法本地验证分数合并版单文件单个 CSV含标签列891 行无法做真正的预测新数据演练清洗过的教学版已填补缺失、已编码不定丢失了练缺失值处理的机会学不到东西我的建议非常明确优先拿完整竞赛版。原因很实在——只有拿到训练集有标签、测试集无标签这个结构你才会被迫去思考交叉验证怎么切、为什么不能用测试集调参、以及提交文件的行数对齐问题。这三件事是真实项目里最容易出事的地方用合并版单文件练你永远碰不到。1.1 下载完第一个动作校验文件完整性很多人下载完直接pd.read_csv就开始head()这是坏习惯。我固定的第一步是三行校验import pandas as pd train pd.read_csv(train.csv) test pd.read_csv(test.csv) print(train.shape, test.shape) # 期望 (891, 12) 和 (418, 11) print(set(train.columns) - set(test.columns)) # 期望 {Survived} print(train[PassengerId].is_unique, test[PassengerId].is_unique)这三行分别验证三件事行数对不对、标签列是不是只在训练集里、主键有没有重复。我遇到过最离谱的一次是某个转载版本把test.csv的前 100 行和train.csv混在了一起shape一打印就露馅了。还有一次是某份文件用分号做分隔符read_csv默认逗号读进来只有一列报错信息还特别绕。注意如果read_csv之后发现列数是 1先别怀疑代码用文本编辑器打开看一眼分隔符和编码。这份数据集流传过程中被 Excel 另存过很多次出现过 GBK 编码、出现过带 BOM 头、还出现过首行多一个空列的情况。1.2 版本差异会影响你后续所有结论有个细节值得单独说不同渠道下载的test.csv行数基本都是 418但样本顺序可能不同。如果你的提交文件是按PassengerId排序生成的那没问题如果图省事直接把预测结果按原顺序写出去、又没带PassengerId列那交上去的分数会莫名其妙地低而且你完全查不出原因——因为准确率只是随机水平偏上而已。这种情况我见过至少三次每次排查都要花很久因为大家第一反应都是模型不行没人怀疑文件顺序。所以我的做法是从读入的第一秒起就把PassengerId单独拎出来存一份最后写提交文件时用它来做索引对齐而不是靠行号。这个小习惯能省掉后面 80% 的玄学问题。2. 十一列里哪些是真信号哪些是被误判的垃圾字段理解这一关很多人是靠直觉过的性别有用、年龄有用、票号没用、名字没用。前两个判断对了一半后两个基本是错的。我按信息密度从高到低把这份数据的字段重新排一遍并说明每一列真正该怎么用。2.1 性别、舱位、票价三个开箱即用的强特征Sex是这份数据里信号最强的单列。女性标签为 1 的比例大约在 0.74 左右男性大约在 0.19差距悬殊到只用这一列做规则分类准确率就能到 0.78 上下。这个数字很关键——它意味着任何低于 0.78 的模型都是在帮倒忙你辛辛苦苦搭的复杂模型如果打不过女性全预测 1、男性全预测 0那就说明流程里有严重 bug而不是模型不够强。Pclass舱位等级取值 1/2/3的标签比例大概是 0.63 / 0.47 / 0.24单调递减非常干净。这类有序类别特征最忌讳直接当数值喂进去做线性模型的输入因为模型会认为 3 和 1 的距离是 2而实际上这只是等级差异。正确做法是交给树模型处理或者做 one-hot或者转成序数编码但明确告诉模型这是序数。Fare的分布非常偏——中位数远低于均值长尾拖得很长最贵的票价比最常见的票价高两个数量级。直接喂原始值线性模型会被少数极端值带跑。我一般做log1p变换然后再标准化。这里有个坑log1p只能处理非负数而这份数据里有若干条Fare为 0 的记录通常是团体票或船员随行0 变换后还是 0不影响但如果你用np.log就会得到-inf训练直接崩。2.2 姓名和票号高基数文本里藏着的结构化信息Name这一列绝大多数人直接drop太可惜了。它的格式是姓氏, 称谓. 名字那个称谓Mr / Mrs / Miss / Master 等是一个天然的、已经分好类的人群标签而且它同时编码了性别、婚姻状态和年龄段三重信息。比如Master专指小男孩这个群体在原始数据里的生存比例明显高于成年男性。提取方式很简单train[Title] train[Name].str.extract(r,\s*([^\.])\.) print(train[Title].value_counts().head(10))跑完你会发现长尾特别严重几十种称谓里出现次数少于 10 的有十几个。这时候必须做归并把Mlle、Ms、Mme归到Miss/Mrs把各种贵族头衔、军衔、神职头衔统一归到Rare把Dr、Rev这类单独考虑。归并完通常剩 5 到 6 类信息密度刚刚好。不做归并直接 one-hot会多出十几个几乎全是 0 的稀疏列对树模型来说是纯噪声。Ticket同样被低估。它的格式混杂有纯数字如24160、有字母前缀加数字如A/5 21171、有纯字母。真正有价值的不是票号本身而是前缀和同票号人数——同一个票号出现多次说明是一起出行的团体团体规模是一个独立的信号和SibSp、Parch提供的家庭规模不完全重合。提取前缀用一条正则就够train[TicketPrefix] train[Ticket].str.replace(r[\./], , regexTrue)\ .str.extract(r^([A-Za-z]), expandFalse)\ .fillna(NONE)2.3 两个被普遍忽略的衍生特征家庭规模和票号人数SibSp同行的兄弟姐妹/配偶数和Parch同行的父母/子女数单独看都很弱但把它们加起来再加 1 得到家庭规模信号会突然清晰家庭规模样本量级标签为 1 的比例近似1独自出行约 5400.302-4约 2900.55 - 0.725 及以上约 60低于 0.20这条中间高、两头低的曲线非常有解释力独自出行缺少互助大家庭在紧急情况下要互相等待、行动迟缓中等规模的家庭反而最灵活。这就是典型的非线性关系也是为什么树模型在这份数据上天然优于线性模型——你不需要手动构造这个交互项树自己会切。Ticket同票号人数是另一个类似的特征逻辑是同票号意味着同行。构造方法ticket_counts train[Ticket].value_counts() train[TicketGroupSize] train[Ticket].map(ticket_counts)但这里有个必须提醒的坑这个特征不能用训练集和测试集分别统计。如果分开算训练集里某个票号出现 3 次、测试集里同一票号只出现 1 次两边得到的特征值就不一致模型学到的规律在线上会失效。正确做法是把训练集和测试集拼起来算频次再拆回去。这是目标泄漏的一个变种很多人栽在这上面而不自知。3. 缺失值三件套为什么无脑填均值是最差的选择这份数据的缺失情况分布很不均匀处理方式必须区别对待。先看统计train.isna().sum().sort_values(ascendingFalse)结果大致是Cabin缺失约 77%、Age缺失约 20%、Embarked缺失 2 行。测试集的Fare还有 1 行缺失。就这三个但每一个都能单独写一段。3.1 Age 的缺失不是随机的填均值会引入偏差20% 的缺失率看起来填个中位数就完事了但问题是缺失本身携带信息。你可以做个简单验证——按Pclass分组看Age的缺失率会发现三等舱的缺失率明显高于一等舱。也就是说Age是否缺失和乘客的舱位相关而舱位和标签强相关这就构成了非随机缺失。你用全局中位数去填等于人为把一批三等舱乘客的年龄往整体中位数上拉抹掉了一部分真实差异。我的处理方案是分层填补先按Sex和Pclass分组用组内中位数填。这一步能吃掉大部分偏差。更进一步可以用Title来填——Master的年龄一定小Mrs通常成年这个约束比统计量更靠谱。实际代码里我会把两者结合起来train[Age] train.groupby([Sex, Pclass])[Age]\ .transform(lambda s: s.fillna(s.median()))填完之后强烈建议再加一列缺失标记train[AgeMissing] train[Age].isna().astype(int)注意顺序——标记要在填补之前生成。这一列在很多场景下能带来实实在在的分数提升因为年龄未知这件事本身就是信号。3.2 Cabin 缺失 77%但有没有值比值是什么更重要Cabin这一列绝大多数教程的做法是直接删列。我不建议完全删因为缺失本身极度有信息量有舱位记录的乘客标签为 1 的比例远高于没有记录的。合理推测是有记录的多为一等舱、二等舱乘客登记更完整。所以正确的处理是拆成两个特征train[HasCabin] train[Cabin].notna().astype(int) train[CabinDeck] train[Cabin].str[0].fillna(U) # U 表示未知那个首字母是甲板层取值大概十来个归并成几类之后是一组还不错的类别特征。剩下的细节舱位号数字部分就没必要挖了信噪比太低。提示这里有个容易忽略的点——Cabin缺失率在训练集和测试集上不一定完全一致。如果你的填补策略依赖训练集的缺失模式记得确认测试集分布没有明显偏移否则线上表现会打折。3.3 Embarked 只缺两行恰恰最容易被写错两行缺失很多人fillna(S)一把梭。严谨一点的做法是用众数填因为S确实是最高频的取值结果一样。但真正的问题是——如果你在训练集上算众数、再把这个众数硬编码进测试集的流程里代码会变得很脆。更稳的写法是用sklearn的SimpleImputer(strategymost_frequent)让它自己从训练数据里学众数然后应用到测试集。这样写出来的管道是可以直接复用的换个数据集也不用改代码。4. 把预处理焊进 Pipeline避免数据泄漏的唯一可靠办法我见过太多本地交叉验证 0.85、实际提交 0.76的案例根因几乎都是数据泄漏——标准化、填补、编码这些步骤在划分训练验证集之前就把全量数据过了一遍导致验证集的信息漏进了训练过程。这个问题靠小心一点是防不住的唯一的解法是把它写进Pipeline让fit和transform的边界由框架强制保证。4.1 数值列和类别列分流处理一份可复现的预处理骨架大概长这样from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder num_cols [Age, Fare, FamilySize, TicketGroupSize] cat_cols [Pclass, Sex, Embarked, Title, CabinDeck] num_pipe Pipeline([ (imp, SimpleImputer(strategymedian)), (sc, StandardScaler()), ]) cat_pipe Pipeline([ (imp, SimpleImputer(strategymost_frequent)), (oh, OneHotEncoder(handle_unknownignore)), ]) pre ColumnTransformer([ (num, num_pipe, num_cols), (cat, cat_pipe, cat_cols), ])handle_unknownignore这个参数千万别省。测试集里出现训练集没见过的类别取值是必然的比如某个罕见称谓只出现在测试集不加这个参数直接报错加了之后未知类别会被编码成全 0模型能正常跑。4.2 编码方式的选择不是玄学看基数编码方式适用场景在这份数据上的表现One-Hot类别数少于 10Sex、Embarked、Pclass用这个最稳序数编码类别有天然顺序Pclass可以用但要配合树模型目标编码高基数类别Ticket前缀可以试但必须放进交叉验证里做否则泄漏频次编码高基数类别CabinDeck、TicketPrefix可以考虑目标编码在这份数据上是个诱人的陷阱。它效果好但如果不在每折内部单独计算几乎必然泄漏分数虚高一大截。我自己的经验是这份数据的特征量级不大one-hot 完全够用没必要上目标编码折腾。4.3 年龄分箱一个被高估的技巧网上流传很广的一个操作是把Age分成儿童/青年/中年/老年几箱。我实测下来在树模型上这个操作几乎没有增益因为树本身就是靠切分点工作的你手动分箱等于替它做了一次粗糙的切分反而损失了信息。但在逻辑回归这类线性模型上分箱确实能带来一点提升因为它引入了非线性。所以这个技巧不是对或错而是取决于你用什么模型。这类判断在实战里非常常见——同样的预处理换个模型效果就反过来了。我的一般原则是用树模型就尽量保留原始数值用线性模型再做离散化。5. 基线跑通之后评估环节才是真正拉开差距的地方模型选型这件事在这份数据上其实没有太多悬念逻辑回归、随机森林、梯度提升这三类足够覆盖 90% 的需求分数差异通常在 1 到 2 个百分点之内。真正让人翻车的是评估和提交环节我按踩坑频率排个序。5.1 准确率会骗人尤其是这份数据标签为 1 的比例大约 0.38属于轻度不均衡。这个程度还不足以让准确率完全失效但足以让它在某些情况下误导你。举个例子模型 A 把所有女性预测为 1、所有男性预测为 0模型 B 是一个训练不充分的逻辑回归。两者的准确率可能很接近但 A 的召回率结构完全不同。如果你只看准确率会以为它们差不多好。我习惯同时看三个指标准确率、AUC、以及混淆矩阵里假阴性的数量。在这类预测问题里假阴性和假阳性的业务代价往往不对称光看一个汇总数字会掩盖问题。写法很简单from sklearn.model_selection import cross_val_predict from sklearn.metrics import classification_report, roc_auc_score oof cross_val_predict(pipe, X, y, cv5, methodpredict_proba)[:, 1] print(AUC:, roc_auc_score(y, oof)) print(classification_report(y, (oof 0.5).astype(int)))注意这里用的是cross_val_predict拿折外预测不是pipe.predict(X)。后者是在训练数据上预测分数虚高毫无参考价值。这个区别每年都有人搞混。5.2 分层抽样不是可选优化是必需项正负样本比例 38:62普通随机切分有可能切出一折里正样本特别少的组合导致该折的评估结果波动很大。用StratifiedKFold保证每折的正负比例和整体一致是标准做法from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42)random_state一定要固定。不固定的话你每次跑出来的分数都不一样然后你就会开始怀疑是不是改了代码——这种自找的麻烦我经历过一次排查了整整一个下午。5.3 提交文件的两个致命格式问题第一行数必须和测试集完全一致418 行一行不多一行不少。第二列名必须和模板一致通常是PassengerId和Survived两列。我写提交文件固定用这段submission pd.DataFrame({ PassengerId: test[PassengerId], Survived: final_pred.astype(int), }) submission.to_csv(submission.csv, indexFalse) assert len(submission) 418 assert submission[Survived].isin([0, 1]).all()indexFalse这个参数漏掉的话文件会多一列索引某些提交入口会直接判格式错误。而Survived列如果是float概率值没做阈值化交上去可能全被判成 0分数直接归零。这两个错误我在不同的人身上都见过都是那种代码跑通了、结果全错的类型排查起来特别费劲因为程序不报错。6. 我在这份数据上栽过的几个跟头以及对应的规避手段最后说几个偏经验性的东西都是文档里不会写、但实际会浪费你大量时间的细节。第一个坑把测试集拿去做了特征统计。前面提过TicketGroupSize类似的还有分词、TF-IDF、目标编码。只要你的特征计算涉及统计量就必须在训练集内部算然后应用到测试集。判断标准很简单问自己一句这个特征的取值会不会因为测试集里有什么而改变。会就是泄漏。第二个坑过拟合交叉验证分数。这份数据集只有 891 行交叉验证分数对随机种子非常敏感。同一个模型换个种子AUC 上下浮动 0.01 到 0.02 是常事。所以不要因为某次跑了 0.85 就兴冲冲去提交然后发现实际 0.78。我的做法是跑 5 个不同种子取平均如果平均之后的分数和单次差异超过 0.02说明模型本身不稳定需要先解决方差问题。第三个坑无限堆特征。这份数据的上限摆在那里再怎么挖也很难突破某个区间。我见过有人把Name做字符级 n-gram、把Ticket做哈希嵌入最后还是那个分数。特征工程的边际收益在 20 到 30 个特征之后会急剧下降这时候该做的是回去检查流程有没有 bug、交叉验证切得对不对而不是继续加特征。第四个坑忘了统一训练集和测试集的类别取值域。比如Title归并规则如果在训练集上把Dr归到Rare测试集上也得用同一份映射表不能各自归各自。我习惯把映射关系写成一个字典封装成函数训练和推理都调它。这样即使后面换数据也只需要改一处。如果一定要给个上手顺序我会这样排先把校验和读取写好确认数据结构没问题然后只做最基础的填补和编码跑一个逻辑回归拿到 0.78 以上的基线确认流程是通的接着再逐步加特征每加一个就跑一次交叉验证看分数变化最后才去调模型超参数。这个顺序看着笨但它保证了你每一步的改动都有明确的归因——分数涨了知道为什么涨跌了也知道是哪一步出的问题。反过来一上来就堆特征、调参数最后分数不行的时候你根本不知道该从哪查起。另外提一句这份数据集最好别只用一次。我自己的习惯是准备两套流程一套是快速跑通版半小时内从原始文件到提交文件用来验证环境另一套是实验版带完整的日志、特征版本管理、多种子评估用来做真正的迭代。两套分开能避免你在调参的时候不小心把已经跑通的流程改坏。