
开头先交代一个我见过太多次的场景同样一份泰坦尼克号数据集有人跑出 0.78 的准确率就卡住了换模型、调参数折腾一周没动静另一个人用差不多的模型只是把预处理重做了一遍交叉验证直接抬到 0.83。这份数据之所以被当作入门教材不是因为它简单恰恰是因为它把现实项目里最烦人的几件事全塞进了 891 行里近两成的年龄缺失、超过七成的舱位缺失、混在姓名里的头衔、长尾拖得极长的票价、还有几个看着像废字段实则藏信息的文本列。数据预处理在这里不是走流程而是决定模型上限的那道工序。这篇内容适合刚接触结构化数据清洗的读者也适合已经能跑通模型、但说不清每个处理步骤为什么这么做的朋友。我会把每一步的动机、代码、以及实测中容易翻车的地方摊开讲代码基于 pandas 和 scikit-learn思路可以直接迁移到其他表格类任务上。1. 动手之前先把数据摸清楚字段清单背后的信息结构很多人拿到 CSV 的第一反应是read_csv然后直接fit这一步跳过的代价会在后面以各种奇怪报错的形式还回来。泰坦尼克号训练集是 891 行 12 列测试集 418 行 11 列少了标签列这个规模小到可以逐列肉眼检查正是练习数据探查的最佳体量。真正要做的是搞清楚每一列属于哪一类数值型、类别型、文本型以及它们各自的缺失情况和分布形状这三件事决定了后面每一步的处理方式。1.1 十二个字段先做一次分类归档原始字段可以按处理难度分成三档。第一档是干净可直接用的PassengerId纯索引建模时丢掉、Survived标签、Pclass有序类别1/2/3 等舱、Sex二值类别、SibSp和Parch离散计数。第二档是带缺失需要决策的Age缺约 177 条、Cabin缺约 687 条、Embarked只缺 2 条、Fare在测试集里缺 1 条。第三档是看起来没用但藏着信号的文本列Name、Ticket、Cabin的字母前缀。这个分类动作的价值在于它直接对应了后面三套不同的处理策略。第一档基本只需要编码第二档要做缺失值决策第三档要做特征抽取。新手最容易犯的错是把三档混在一起处理比如对Name直接做标签编码结果把几百个几乎唯一的值变成了几百个无意义的整数模型完全学不到东西。import pandas as pd import numpy as np train pd.read_csv(train.csv) test pd.read_csv(test.csv) print(train.shape, test.shape) # (891, 12) (418, 11) print(train.dtypes) print(train.head())1.2 用 info 和 describe 做第一轮体检info()给出的是非空计数和数据类型这是发现缺失最快的方式describe()给出的是数值列的均值、标准差和四分位数这是发现异常值和长尾最快的方式。两个输出放在一起看往往能提前暴露问题。比如Fare的均值明显大于中位数、最大值远超 75% 分位这就是典型的长尾右偏后面要么做对数变换要么做分箱Age的均值和中位数接近说明分布相对对称用中位数填充的偏差不会太大。我习惯把缺失情况整理成一张表直接看比例而不是看绝对数。因为绝对数在小数据集上没有直觉比例才有。missing train.isnull().sum() missing_pct (missing / len(train) * 100).round(2) report pd.DataFrame({缺失数: missing, 缺失比例(%): missing_pct}) print(report[report[缺失数] 0].sort_values(缺失数, ascendingFalse))跑出来大致是这样字段缺失数缺失比例处理倾向Cabin68777.10%不能简单删除提取甲板信息后置为 UnknownAge17719.87%分组填充不能全局均值Embarked20.22%众数填充即可Fare测试集10.24%按舱位分组中位数填充这张表看着简单但处理倾向那一列才是真正要动脑的地方下一节逐个拆。1.3 分布图提前暴露的问题比缺失值更隐蔽缺失值是显性的分布问题才是隐性的。Fare有若干条记录为 0这些乘客是持赠票或者船员家属他们的生存率和普通乘客差异明显Age在婴儿段有一个小峰Master 头衔的那批孩子SibSp和Parch单独看都很稀疏但合并成家庭规模之后呈现出明显的中间高两头低的生存率曲线。这些问题不画图基本发现不了。画图不需要多复杂几个value_counts加上分组统计就够print(train.groupby(Sex)[Survived].mean()) print(train.groupby(Pclass)[Survived].mean()) print(train.groupby(pd.cut(train[Fare], 5))[Survived].agg([mean, count]))第一行输出女性生存率约 0.74、男性约 0.19这个差距大到可以直接作为强特征第二行输出一等舱 0.63、二等舱 0.47、三等舱 0.24说明舱位本身就是生存率的强代理。这两个结论会反过来影响后面的特征工程决策性别和舱位必须保留而且它们的组合比如Sex_Pclass交叉特征值得尝试。提示数据探查阶段不要急着写清洗代码先把所有哪里不对劲记下来列成清单等看完再统一决定处理方案。边看边改的做法很容易导致前后处理逻辑互相冲突比如先填了均值后面又想做分组填充结果把自己绕进去。2. 缺失值不是填个均值就完事三种列的三套手术方案缺失值处理最能体现一个人的水平。新手的第一反应是fillna(df.mean())一行搞定老手会先问三个问题这一列为什么缺缺失本身有没有信息填充引入的偏差会不会影响下游特征之间的关系泰坦尼克号这三列缺失恰好对应三种完全不同的处理逻辑值得逐个说透。2.1 Age 缺失近两成为什么全局均值是个坏主意Age缺 177 条占近两成这个比例不算小。如果直接用全体中位数 28 岁填进去会造成一个隐蔽的问题把大量不同群体的年龄抹平成同一个值破坏了年龄与舱位、头衔之间的相关性。头等舱乘客的平均年龄明显高于三等舱男性乘客中带Master头衔的小男孩年龄集中在个位数这些结构信息会被全局填充直接抹掉。更合理的做法是分组填充。分组的依据要选那些与年龄强相关、同时缺失率低的字段Pclass和Sex是最常见的组合Title从姓名里提取的头衔更强因为它直接编码了年龄和婚姻状态。实际做法是先按Title分组算中位数对于Title罕见导致组内样本太少的再回退到Pclass Sex的分组中位数。# 先抽头衔 train[Title] train[Name].str.extract(r,\s*([^\.])\., expandFalse) # 合并罕见头衔 rare [Lady, Countess, Capt, Col, Don, Dr, Major, Rev, Sir, Jonkheer, Dona] train[Title] train[Title].replace(rare, Rare) train[Title] train[Title].replace({Mlle: Miss, Ms: Miss, Mme: Mrs}) # 分组中位数填充 age_median train.groupby([Title, Pclass])[Age].median() train[Age] train.apply( lambda r: age_median.get((r[Title], r[Pclass]), train[Age].median()) if pd.isna(r[Age]) else r[Age], axis1 )这段代码有个坑要注意groupby算出的中位数索引是元组用get取值时键必须完全匹配如果某个(Title, Pclass)组合在整个数据集里不存在就会返回默认值。我在实测中遇到过TitleRare且Pclass2的组合样本极少填出来的值波动很大后来干脆把这类回退逻辑写得更保守直接用全局中位数兜底。填充完之后还有一个动作不能省检查填充前后的分布有没有明显偏移。画一下填充前后的直方图或者对比分位数如果填充后中位数附近出现一个异常尖峰说明填充值过于集中可能需要改用随机采样填充或者加一个Age_is_missing的标记位让模型自己学。2.2 Cabin 缺了七成多删字段太浪费直接用又不合适Cabin缺失 77%这是个典型的删了心疼、留着难用的字段。直接删掉会丢掉一个重要信号有舱位记录的乘客生存率明显高于没有记录的乘客因为舱位记录本身就和舱位等级、社会地位相关。直接对原始字符串做编码又不可行因为舱位号形如C85、B42、E12几百个几乎唯一的值。正确的思路是降维提取。舱位号的首字母代表甲板层A 到 G 共七层加上缺失的 U一共八个类别这个粒度刚好适合编码。很多教程到此为止但我建议再加一个二值特征HasCabin标记这条记录原本有没有舱位信息因为这个有没有本身携带的信号可能比甲板层更强。train[Deck] train[Cabin].str[0].fillna(U) train[HasCabin] train[Cabin].notna().astype(int) print(train.groupby(Deck)[Survived].agg([mean, count]))实测下来DeckU那一组的生存率明显低于有具体甲板的组HasCabin这个特征在树模型里的重要性排名通常能进前十。这就是缺失本身是信息的典型案例与其想办法猜原来的值不如把缺失这个状态本身变成一个特征。2.3 Embarked 只缺两条众数填充的合理性边界Embarked只缺 2 条这种量级的缺失怎么填都影响不大众数填充完全够用。但这里有个值得展开的细节这两条记录的Fare都是 80属于高票价而同票价区间的乘客登船港口集中在 C。所以如果追求严谨可以按Fare和Pclass反推港口而不是无脑填众数。train[Embarked] train[Embarked].fillna(train[Embarked].mode()[0])我个人的判断是缺失比例低于 1% 的列填充策略的选择对最终指标的影响通常在千分位级别不值得花太多时间。真正值得投入精力的是Age和Cabin这种缺失比例高、且与标签相关性强的列。这个优先级判断在实际项目里非常重要因为时间永远不够好钢要用在刀刃上。注意填充操作必须在训练集和测试集上保持完全一致的逻辑。比如分组中位数应该用训练集算出来的中位数去填测试集而不是各自算各自的。这个细节在后面讲数据泄漏时会再展开。3. 从姓名和票号里榨信号特征工程的取舍标准泰坦尼克号这个数据集最有趣的部分是那几个看起来毫无用处的文本列。Name是几百个不重复的字符串Ticket是混合了字母和数字的编号Cabin前面已经处理过。新手会把它们直接丢掉老手知道这里面至少能挖出四五个有效特征。但特征工程不是越多越好加进去的每个特征都要能说清楚它为什么可能和标签相关否则就是给模型喂噪声。3.1 姓名里的头衔一个正则表达式换来最强特征之一西方姓名格式里逗号后面、句点前面那一段就是头衔Mr、Mrs、Miss、Master、Dr、Rev等等。这个信息之所以强是因为它同时编码了性别、年龄和婚姻状态三个维度Master专指小男孩Miss指未婚女性Mrs指已婚女性Mr指成年男性。提取只需要一行正则train[Title] train[Name].str.extract(r,\s*([^\.])\., expandFalse) print(train[Title].value_counts())跑出来会发现长尾很严重Dr、Rev、Col、Major这些各只有几条直接编码会产生大量低频类别模型学不到稳定模式。所以要合并把贵族头衔和军政头衔统一归为Rare把Mlle、Ms归到MissMme归到Mrs。合并之后剩下六七类粒度刚好。这个特征的重要性在树模型里通常排在前三甚至超过原始性别字段。原因是它把性别和年龄的信息做了非线性组合而树模型虽然能自己学组合但在小数据集上样本不够人工先组合好往往更有效。3.2 家庭规模单独看稀疏合起来才有意义SibSp同船的兄弟姐妹配偶数和Parch同船的父母子女数单独看都很稀疏大部分值是 0。但把三者加起来得到家庭规模FamilySize SibSp Parch 1就会出现一个很有意思的规律家庭规模为 2 到 4 的乘客生存率最高独自一人的偏低大家庭5 人以上的生存率急剧下降。这个规律背后的解释是小家庭在逃生时能互相照应又不至于拖累独身乘客缺乏协助大家庭在混乱中难以全员撤离。不管解释是否完全准确数据上的模式是稳定的所以这个特征值得加。train[FamilySize] train[SibSp] train[Parch] 1 train[IsAlone] (train[FamilySize] 1).astype(int)从FamilySize还能再派生出两个变体一个是把它分箱成Alone、Small2-4、Large5三类用类别编码另一个是保留连续值让树模型自己切分。我两种都试过在小数据集上分箱版本略稳一些因为减少了过拟合到具体数值的风险。3.3 票价与舱位连续值和分箱版本各留一份Fare是连续值但分布极度右偏最大值 512中位数只有 14 左右。这种分布直接喂给线性模型会有问题因为少数高票价样本会主导梯度。处理方法有两个方向对数变换和分箱。对数变换np.log1p(Fare)能明显压缩右尾让分布接近正态适合线性模型和神经网络。分箱则是按分位数切成四段或五段转成有序类别适合树模型。两种做法不冲突可以各留一份让模型自己选。train[Fare_log] np.log1p(train[Fare]) train[FareBin] pd.qcut(train[Fare], 4, labelsFalse, duplicatesdrop)pd.qcut有个坑要注意如果分位点上有大量重复值比如很多票价为 0 的记录会报Bin edges must be unique的错误加duplicatesdrop能绕过但会导致实际分箱数少于设定值。处理完记得value_counts确认一下每箱的样本数是否均衡。另外Fare和Pclass高度相关一等舱票价远高于三等舱。同时保留两个特征不会造成严重问题树模型对共线性不敏感但在做特征重要性分析时要注意它们会互相分摊权重不要因为Fare排名靠后就认为它没用。3.4 票号前缀一个容易被忽略的隐藏特征Ticket字段形如A/5 21171、PC 17599、STON/O2. 3101282数字部分基本唯一但字母前缀部分有重复。前缀相同的乘客往往是一起订票的团体这个团体信息在某种程度上和家庭规模类似但覆盖范围更广。提取方式是去掉数字和空格保留字母部分没有字母的标记为None。train[TicketPrefix] train[Ticket].str.replace(r[\d\s\./], , regexTrue) train[TicketPrefix] train[TicketPrefix].replace(, NONE)这个特征单独用效果一般重要性排名通常在十名开外但它和FamilySize组合使用时偶尔能带来小幅提升。我的建议是把它作为候选项在特征选择的最后一步用交叉验证决定要不要留而不是一开始就无脑加进去。4. 编码、缩放与异常值把数据整理成模型能吃的形状前面三步做完数据还是人看得懂、模型看不懂的状态字符串、混合类型、量纲差异巨大。这一步的任务是把所有列统一成数值矩阵同时尽量不破坏原有信息。这里有几个决策点每个都有明确的适用条件选错了会让模型表现明显下滑。4.1 类别编码有序映射和独热编码要分清场合不是所有类别字段都适合独热编码。判断标准是有没有内在顺序。Pclass是 1/2/3有明显的等级关系保留数值形式让模型学单调关系更高效Sex是二值映射成 0/1 最简单Embarked是三个港口没有顺序关系用独热编码Title合并后剩六七类用独热编码。train[Sex] train[Sex].map({male: 0, female: 1}) embarked_dummies pd.get_dummies(train[Embarked], prefixEmbarked) title_dummies pd.get_dummies(train[Title], prefixTitle) train pd.concat([train, embarked_dummies, title_dummies], axis1)pd.get_dummies在训练集和测试集上分开调用会有一个经典问题如果测试集里出现了训练集没有的类别列数会对不上。解决办法是用sklearn的OneHotEncoder(handle_unknownignore)它会在遇到未见类别时输出全零向量而不是报错。手工get_dummies的话必须用reindex对齐列这个细节在后面写 Pipeline 的时候会统一处理。4.2 数值缩放树模型可以偷懒线性模型躲不掉StandardScaler标准化到均值 0 方差 1和MinMaxScaler缩放到 0-1 区间是两种最常见的缩放方式。它们的适用场景不同标准化对异常值更稳健一些适合梯度下降类的模型归一化保留了原始分布形状适合需要固定输入范围的模型。关键认知是基于树的模型决策树、随机森林、梯度提升对特征缩放完全不敏感因为它们只关心分裂点的相对顺序不关心绝对数值。所以如果你确定只用树模型这一步可以跳过。但只要涉及逻辑回归、支持向量机、K 近邻、神经网络缩放就是必须的否则量纲大的特征比如Fare几百会完全压制量纲小的特征比如Age几十模型相当于只能看到票价。from sklearn.preprocessing import StandardScaler scaler StandardScaler() num_cols [Age, Fare_log, FamilySize] train[num_cols] scaler.fit_transform(train[num_cols])同样的原则fit只能在训练集上做测试集只能transform。这是数据泄漏问题的一个具体表现下一节细说。4.3 异常值先判断它是不是错再决定删不删Fare有几个超过 500 的值用 IQR 法则算出来确实落在上界之外。但这里的异常值是真实的极端值不是录入错误——那几位乘客确实买了昂贵的套票而且他们的生存率很高。盲目用 IQR 把这类点删掉等于删掉了最有信息量的样本。异常值处理的第一步永远是区分错误的异常和真实的极端。录入错误比如年龄 200 岁该删真实的极端值高票价、超大户型不但不该删反而要保留甚至单独标记。泰坦尼克号这份数据整体质量不错几个极端票价都是真实的我的处理方式是不删除但在特征工程里用对数变换或分箱把它的影响控制在合理范围内。Q1, Q3 train[Fare].quantile([0.25, 0.75]) IQR Q3 - Q1 upper Q3 1.5 * IQR print(train[train[Fare] upper][[Fare, Pclass, Survived]].head(10))跑一下这段你会看到这些高票价乘客几乎全是一等舱且大部分幸存这个模式本身就是有用的信号删掉就亏了。5. 预处理顺序里的隐形陷阱数据泄漏是怎么发生的这一节是整篇文章里我最想强调的部分因为它是新手最容易踩、又最难自己发现的坑。前面所有的填充、缩放、编码操作如果顺序搞错模型在交叉验证里能跑出漂亮的分数一上真实测试集就原形毕露而你完全不知道为什么。5.1 先划分还是先处理这个顺序不能反数据泄漏的本质是在训练阶段模型间接接触到了它本不该知道的测试集信息。最常见的发生方式是先对全量数据做fillna(df.mean())或scaler.fit_transform(df)然后再划分训练集测试集。此时填充用的均值、缩放用的均值方差都已经包含了测试集的数据模型相当于提前看到了测试集的分布。在 891 行这样的小数据集上这种泄漏造成的分数虚高可能达到两三个百分点肉眼很难察觉。正确做法只有一条铁律任何依赖数据的统计量均值、中位数、众数、方差、分箱边界、编码类别集合都只能在训练集上计算然后应用到测试集。操作错误做法正确做法缺失值填充全量算均值再填训练集算均值测试集用这个值标准化全量 fit_transform训练集 fit测试集 transform分箱边界全量 qcut训练集算分位点测试集按边界切独热编码全量 get_dummies训练集确定类别测试集对齐列5.2 用 Pipeline 把顺序固化下来手工管理这些顺序很容易出错sklearn的Pipeline和ColumnTransformer就是为这件事设计的。把预处理步骤封装进去fit只会在训练数据上执行predict时自动用学到的参数处理新数据顺序问题从结构上就被消灭了。from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.linear_model import LogisticRegression num_cols [Age, Fare_log, FamilySize, SibSp, Parch] cat_cols [Pclass, Sex, Embarked, Title, Deck] num_pipe Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()) ]) cat_pipe Pipeline([ (imputer, SimpleImputer(strategymost_frequent)), (encoder, OneHotEncoder(handle_unknownignore)) ]) preprocessor ColumnTransformer([ (num, num_pipe, num_cols), (cat, cat_pipe, cat_cols) ]) model Pipeline([ (prep, preprocessor), (clf, LogisticRegression(max_iter1000)) ])这套结构的好处是model.fit(X_train, y_train)时所有填充和缩放的参数只在X_train上学习model.predict(X_test)时自动用学到的参数处理测试集。不需要手动记录均值也不需要担心列对齐问题。5.3 交叉验证要分层否则评估结果不可信泰坦尼克号的正负样本比例大约是 62:38不算极端失衡但用普通的KFold仍然可能出现某一折里幸存者比例偏差较大的情况。StratifiedKFold会保证每一折的标签分布与整体一致评估结果更稳定。from sklearn.model_selection import cross_val_score, StratifiedKFold cv StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores cross_val_score(model, X_train, y_train, cvcv, scoringaccuracy) print(fCV 均值: {scores.mean():.4f}, 标准差: {scores.std():.4f})固定random_state这件事在小数据集上尤其重要。891 行数据不同的划分方式带来的分数波动可能超过一个百分点如果不固定种子你根本分不清两次实验的差异是来自改进还是来自随机性。我的习惯是至少跑三组不同的种子看均值是否稳定提升而不是只对比单次结果。6. 用基线模型回测验证预处理到底有没有用预处理做得好不好最终要靠模型来验证。但验证方式要讲究不能只看最终准确率还要看不同模型的表现差异、特征重要性排序、以及错误样本的分布。这三样结合起来才能判断哪些处理步骤真正起了作用哪些是白忙活。6.1 逻辑回归和随机森林的对照实验逻辑回归对预处理最敏感因为它假设特征与对数几率呈线性关系特征缩放和编码方式会直接影响它。随机森林对预处理最不敏感能自己处理非线性和特征交互。两个模型同时跑可以分离出预处理带来的收益和模型能力带来的收益。from sklearn.ensemble import RandomForestClassifier lr_scores cross_val_score( Pipeline([(prep, preprocessor), (clf, LogisticRegression(max_iter1000))]), X_train, y_train, cvcv, scoringaccuracy ) rf_scores cross_val_score( Pipeline([(prep, preprocessor), (clf, RandomForestClassifier(n_estimators300, random_state42))]), X_train, y_train, cvcv, scoringaccuracy ) print(f逻辑回归: {lr_scores.mean():.4f}) print(f随机森林: {rf_scores.mean():.4f})实测下来逻辑回归的分数对是否缩放、是否加对数变换非常敏感同样一套特征缩放前后能差一到两个百分点。随机森林的分数则相对稳定加不加Title特征的影响更明显能差两到三个百分点。这个差异本身就说明了不同模型对预处理的依赖程度不同也解释了为什么换模型往往不如改预处理有效。6.2 消融实验逐个拿掉特征看分数变化判断某个处理步骤有没有用最直接的方法是消融实验加进去跑一次拿掉再跑一次看交叉验证分数的变化。这个操作比看特征重要性更可靠因为特征重要性只反映模型内部的权重分配而消融实验直接反映对最终指标的影响。我通常会做这么几组对照只保留原始数值特征、加上头衔、加上家庭规模、加上甲板信息、全量特征。每组跑五次交叉验证取均值。特征组合逻辑回归 CV随机森林 CV基础数值特征0.7820.798加 Title0.8010.812加 FamilySize0.8080.819加 Deck 与 HasCabin0.8120.826加 TicketPrefix0.8110.824注意最后一行的变化TicketPrefix加进去之后分数反而略降说明这个特征在当前的样本量下带来的噪声大于信号应该剔除。这就是消融实验的价值它帮你砍掉那些看起来有用、实际上拖后腿的特征。6.3 从错误样本里反查预处理的问题准确率卡在 0.82 左右上不去的时候看错误样本比调参数有用得多。把交叉验证中预测错误的乘客拎出来看他们的共同特征往往集中在某一类边界样本上比如年龄缺失且没有头衔记录的男性三等舱乘客或者家庭规模特别大的乘客。如果发现某一类样本的错误率显著偏高就回到预处理环节找原因。比如年龄缺失的样本被统一填充成中位数导致这批人的年龄特征完全一样模型无法区分他们。这时候可以尝试加一个Age_is_missing标记位让模型知道哪些样本的年龄是猜出来的从而降低对这个特征的依赖。from sklearn.model_selection import cross_val_predict y_pred cross_val_predict(model, X_train, y_train, cvcv) errors X_train[y_pred ! y_train] print(errors.groupby(Sex)[Age].agg([mean, count])) print(errors[Title].value_counts())这个动作看着简单但它是从调模型转向改数据的关键分水岭。很多人在模型参数上打转其实问题一直藏在数据里。6.4 提交前的最后检查清单真到要输出测试集预测的时候还有几个容易漏掉的检查项。第一训练集和测试集的特征列必须完全一致顺序也要一致ColumnTransformer能自动保证这一点手工处理的话必须显式对齐。第二测试集里可能出现的未见类别比如某个罕见头衔要能容错handle_unknownignore是必选项。第三输出格式要严格按要求的列名和行数很多平台会因为格式问题直接判零分。X_test test.copy() X_test[Title] X_test[Name].str.extract(r,\s*([^\.])\., expandFalse) X_test[Title] X_test[Title].replace(rare, Rare) X_test[FamilySize] X_test[SibSp] X_test[Parch] 1 X_test[Fare_log] np.log1p(X_test[Fare].fillna(train[Fare].median())) X_test[Deck] X_test[Cabin].str[0].fillna(U) model.fit(X_train, y_train) pred model.predict(X_test) submission pd.DataFrame({PassengerId: test[PassengerId], Survived: pred}) submission.to_csv(submission.csv, indexFalse)这里Fare的缺失填充用的是训练集中位数而不是测试集中位数这个细节就是前面反复强调的防泄漏原则的具体落地。7. 我在这套数据上反复踩过的几个坑写到这里把几个印象最深的教训单独拎出来说说。这些都不是文档里会写的全是自己撞了南墙才记住的。第一个坑是pd.qcut的重复边界报错。第一次遇到的时候完全没头绪明明是几行标准代码怎么就崩了后来才明白是因为票价里有大量 0 和重复值分位点算出来有重复。加duplicatesdrop能解决但更稳妥的做法是先检查目标列的唯一值数量是否足够支撑设定的箱数。第二个坑是训练集和测试集特征列顺序不一致。手工pd.concat加哑变量的时候训练集和测试集的列顺序可能不同某些模型尤其是直接接 numpy 数组的那种会默默按位置对齐结果特征完全错位分数低得离谱却找不到原因。自从用了ColumnTransformer之后再没碰过这个问题这也是我推荐用 Pipeline 的核心原因之一。第三个坑是分组填充时的索引对齐。用groupby算出的 Seriesget的时候键类型必须完全一致np.int64和 Python 原生int在某些版本下会被当成不同的键导致取值失败返回默认值。这个 bug 极其隐蔽表现出来就是填充后的年龄分布怪怪的排查了快一个小时才定位到。第四个坑是过度依赖特征重要性做选择。随机森林给出的重要性排序在小数据集上波动很大换个随机种子排名就变了。后来改成用消融实验加上多组种子的方式来验证结论才稳定下来。最后一个体会是关于时间分配的在这套数据上把八成的精力花在数据探查和特征工程上两成花在模型选择上比反过来有效得多。模型换来换去准确率提升不到一个百分点而一个合理的头衔特征或者家庭规模特征能带来两三个百分点。如果后续还想继续扩展可以往特征交叉比如头衔与舱位的组合编码、目标编码对高基数类别做平滑处理这两个方向尝试但前提是前面的基础清洗已经做得足够扎实否则加再多花哨的特征也都是在噪声上盖楼。