泰坦尼克号数据预处理实战:缺失值、特征工程与防泄漏 1. 先弄清楚这份数据集在考什么“泰坦尼克号数据集”这个名字做机器学习的人大概都绕不开。它出现在各种入门教程的第一课里891 行训练样本、418 行测试样本、12 个字段看起来小小一份却最考验基本功。我前后拿它练手不下十次每次重做数据预处理都有新发现。第一次我照着示例把 Age 用均值一填就交差模型在本地交叉验证上跑到 0.83结果换一版数据划分就掉到 0.77。后来才明白数据预处理实验做的不是“把空填上”而是搞清楚每一列为什么空、空的地方藏着什么信息、填完之后会不会把不该给模型的东西提前送过去。这篇就把我自己的这套流程完整展开从缺失画像怎么画、Name 和 Ticket 这种字符串字段怎么提取特征到缩放和编码最容易出问题的地方。代码全部用 pandas 原生写法不需要装额外框架一份 CSV 就能跑起来。1.1 字段盘点先建立一张“脏数据地图”动手之前我习惯先做一件事把每个字段的类型、取值分布、缺失比例、唯一值个数全部打印出来做成一张表。这一步花不了五分钟但能省掉后面两小时的调试。泰坦尼克号这份数据的“脏”很有代表性——它脏得有规律几乎覆盖了真实业务数据里最常见的那几类问题连续值缺失、类别值缺失、高缺失率字段、字符串里混杂信息、长尾分布。字段类型缺失情况处理方向PassengerId整数无仅作索引不进模型Survived0/1无测试集无此列标签绝不能进特征Pclass1/2/3无有序类别直接数值化Name字符串无提取称谓 TitleSex字符串无二值编码Age浮点训练集缺 177 个约 19.9%分组中位数填充 分箱SibSp / Parch整数无合成家庭规模Ticket字符串无提取前缀Fare浮点测试集缺 1 个分组中位数填充 log 变换Cabin字符串缺 687 个约 77.1%转成“有无”标记 甲板首字母Embarked字符串缺 2 个众数填充 one-hot这张表里最值得琢磨的是 Cabin。77% 的缺失率很多人的第一反应是“这列废了删掉”。但我实测过把它转成HasCabin这个 0/1 标记之后它对模型的贡献比想象中大——有没有舱位记录本身就和舱位等级、购票渠道相关。缺失率高不等于信息量为零关键看这个“缺失”本身是不是随机的。如果缺失和标签有关系那缺失就是特征。反过来看 Embarked只缺 2 个比例小到可以忽略用众数填掉就行甚至可以只留一个“缺失”类别做标记。同一份数据里两种缺失要用两种完全不同的思路这就是预处理不能一把梭的原因。1.2 路线图为什么我选“先拼后拆”而不是“分别处理”整个预处理我走的是这样一条链合并训练集和测试集 → 统一做特征工程 → 统一做缺失填充统计量只取训练集→ 统一编码 → 拆分 → 只在训练集上 fit 缩放器。这条链的核心矛盾在于特征工程必须是两边一致的否则 one-hot 出来的列数对不上模型直接报错但缺失值填充和缩放的统计量又必须只来自训练集否则就是数据泄漏。所以我采取的做法是“结构层面合并、统计层面隔离”——凡是只涉及列的变换提取 Title、算 FamilySize、拆 Deck在合并表上做凡是需要“看数据算出一个数”的操作中位数、均值、标准差一律只用训练集算出来的值再映射到测试集。有人会问合并之后再拆索引不就乱了吗这是个很实际的问题。我的处理是拼接前先把原始索引存进一列_src标记值为 train / test拆的时候按这个标记切不要依赖行号。我踩过一次坑先用reset_index(dropTrue)再拼接拆的时候用iloc[:891]结果中间某一步做了排序行序全变了切出来的训练集混进了测试样本交叉验证分数虚高到 0.9 以上排查了半天才发现是排序惹的祸。提示任何涉及“按行位置切分”的操作都是脆弱的。用显式的来源标记列比用行号或索引安全得多。还有一点容易忽略合并之后Survived列在测试集那边全是空值做缺失统计的时候它会把整体缺失率算歪。所以做缺失画像一定要分开做两边的统计结果分别打印别混在一起看。2. 缺失值三种缺法三套打法2.1 先画缺失画像别急着填我见过太多人上来就df.fillna(0)理由是“先跑通再说”。跑是能跑通但填 0 之后的 Age 会变成一条在 0 附近的尖峰模型学到的东西完全是假的。缺失值处理的第一步永远是观察不是填。我的观察顺序是三步。第一步看缺失比例超过 60% 的字段基本不适合做精细填充更适合转成标记位10% 到 60% 之间是“可救区”值得花心思低于 5% 的怎么填都影响不大选最简单的方案就行。第二步看缺失是否随机。判断方法很土但很有效把数据按Survived分组分别统计每个字段的缺失率如果两组差异明显说明缺失和标签相关这个缺失模式本身就是信息。Age 这个字段我实测过在标签为 1 和 0 的样本里缺失率差不多说明它更接近随机缺失用分组中位数填充是合理的而 Cabin 的缺失率和Pclass强相关一等舱的缺失率明显低于三等舱所以它不能简单填一个常数。第三步才是选填充策略。这里我给一张我自己常用的对照表缺失类型判断依据推荐策略为什么不选别的低比例类别缺失缺失率 5%且取值集中在少数几类众数填充均值无意义中位数对类别也牵强中比例连续缺失10%~50%且与其他字段相关分组中位数填充全局均值会被极端值拉偏分组更贴近个体高比例缺失缺失率 60%转 0/1 标记位原始列可删可留强行填充等于编造 77% 的数据与标签相关的缺失分组缺失率差异明显保留“缺失”作为一个独立类别填掉就把信息抹平了2.2 Age 的分组中位数填充为什么是中位数为什么分组Age 有 177 个缺失占训练集 19.9%属于典型的“可救区”。填之前先看分布年龄从 0.42 岁到 80 岁均值约 29.7中位数约 28。均值略高于中位数说明右偏——有一批高龄样本把尾巴拉长了。这种情况下用均值填充会把整体的年龄分布往高龄方向推一点对于缺失的那些样本往往集中在低价舱位来说这个偏差会被放大。所以我用中位数而且是分组中位数。分组依据是Pclass和Sex的组合一共 6 组。理由很直接三等舱的男性乘客里年轻劳动力占多数一等舱的女性乘客中年长者比例明显更高。用一个全局中位数去填等于把所有人群的年龄特征抹平了。具体实现上有个细节值得说。分组中位数必须只用训练集算不能用合并后的表算否则测试集的年龄分布会反过来影响填充值——虽然影响很小但这是原则问题养成习惯比省那一行代码重要。import pandas as pd import numpy as np train pd.read_csv(data/train.csv) test pd.read_csv(data/test.csv) # 只用训练集算分组中位数 age_map train.groupby([Pclass, Sex])[Age].median() global_med train[Age].median() def fill_age_by_group(df): df df.copy() med df.set_index([Pclass, Sex]).index.map(age_map) med pd.Series(med, indexdf.index).fillna(global_med) df[Age] df[Age].fillna(med) return df train fill_age_by_group(train) test fill_age_by_group(test)这里有个坑index.map映射之后返回的是Index对象转成Series时索引可能对不上一定要显式指定indexdf.index。我第一次写的时候没加结果填充值和行错位了年龄填到了别人身上模型分数不降反升还以为自己发现了新大陆。填完之后我还会做一次分布对比填充前后的 Age 直方图叠在一起看如果形状基本重合说明填充没有引入明显偏移如果出现新的尖峰说明填充值太集中考虑加一点随机扰动或者干脆改用模型预测填充。这个方法听起来粗糙但比任何统计检验都直观。2.3 Cabin 从“填不上”到“用得上”Cabin 缺 687 个只剩 204 个有值。直接删列是最省事的做法但会丢掉两类信息一是“有没有记录”这个二值信号二是舱位编号里的甲板层级。我的做法是拆成两个特征。HasCabin是 0/1 标记有记录为 1没有为 0。Deck取舱位号的首字母缺失的统一归到UUnknown这一类。为什么取首字母而不是整个编号因为完整编号的唯一值太多直接 one-hot 会炸出几百列而且编号里的数字部分房号对生存的区分度很低甲板层级才是关键。for df in (train, test): df[HasCabin] df[Cabin].notnull().astype(int) df[Deck] df[Cabin].str[0].fillna(U)实测下来Deck里U这一类的样本量最大占了七成多模型会自动学到“没有舱位记录”这个群体的基准风险。如果直接把 Cabin 删掉这部分信息就彻底没了。注意str[0]遇到 NaN 会返回 NaN 而不是报错但如果你用的是str.split().str[0]NaN 会变成字符串nan后面 one-hot 就多出一个莫名其妙的类别。这类“隐形字符串”是排查起来最费劲的问题之一建议每次 one-hot 之后都打印一下列名。2.4 Embarked 和 Fare 的两个小坑Embarked 只缺 2 个而且是训练集里缺的。最省事的做法是填众数S南安普顿是最大类填它没错。但我更喜欢把它当成一个独立类别Unknown因为只有 2 个样本对模型影响可以忽略但能保留“这条记录本身就不完整”的信号。两种做法我都试过分数差异在小数点后第三位属于噪声范围选哪个看个人习惯。Fare 的坑在于测试集缺了 1 个。很多人会用全局中位数填但这 1 个缺失值对应的是一位三等舱乘客三等舱的票价中位数和全局中位数差了将近 3 倍用全局值填会把他的票价抬高好几倍。正确做法是按Pclass分组取中位数。这种“只缺一个”的情况最容易被忽略因为缺失报告里它排在最后眼睛一扫就过去了。fare_map train.groupby(Pclass)[Fare].median() for df in (train, test): mask df[Fare].isnull() df.loc[mask, Fare] df.loc[mask, Pclass].map(fare_map)填完之后还有个问题Fare 有若干个 0 值。这些是不是异常值我倾向于不是。票价为零可能对应工作人员、赠票、或者团体票的记账方式。直接删掉这些样本会损失有效信息用中位数替换更是凭空捏造。我的处理是保留原值另加一列IsFreeFare标记让模型自己去判断这个群体的风险特征。3. 特征工程把字符串里的信息提取出来3.1 Name 字段的称谓提取Name 这一列乍看是纯噪声其实藏着这份数据集里最有价值的信息之一——称谓。Mr、Mrs、Miss、Master这四个高频称谓分别对应成年男性、已婚女性、未婚女性、男童它们同时编码了性别、婚姻状态和年龄段三重信息。提取用正则最简单import re for df in (train, test): df[Title] df[Name].str.extract(r([A-Za-z])\., expandFalse) print(train[Title].value_counts())打印出来会看到一长串低频称谓Dr、Rev、Col、Major、Capt、Countess、Don、Jonkheer、Lady、Mlle、Mme、Ms、Sir、Dona。这些加起来不到 30 个样本每个都单独 one-hot 只会制造噪声。我的合并规则是原始称谓归并后归并理由Mr / Miss / Mrs / Master保持原样四大主类样本量大区分度强Mlle / MsMiss语义等价MmeMrs语义等价Dr / Rev / Col / Major / Capt / Sir / Lady / Countess / Don / Dona / JonkheerRare样本量太小合并成“稀有称谓”一类title_map { Mr: Mr, Miss: Miss, Mrs: Mrs, Master: Master, Mlle: Miss, Ms: Miss, Mme: Mrs, } train[Title] train[Title].map(title_map).fillna(Rare) test[Title] test[Title].map(title_map).fillna(Rare)这里必须强调map之后一定要fillna(Rare)。我见过有人直接map完就往下走结果所有稀有称谓变成了 NaN后面填充的时候被填成了众数Mr等于把一群有身份特征的人硬生生改成了普通成年男性模型当然学不出东西。另外一个细节Master这个称谓专门指未成年男性样本量大约 40 个生存率明显高于成年男性。如果不做这个提取模型只能靠 Age 和 Sex 两个字段组合去逼近而 Age 本身又有 20% 是填出来的信息损耗很大。这是我认为这份数据集里性价比最高的一步特征工程。3.2 Ticket 与 Cabin 的前缀Ticket 字段的格式很杂纯数字的、带字母前缀的PC、C.A.、A/5、STON/O2、带空格的。我的提取思路是取“第一个数字之前的部分”作为前缀纯数字的归为NUM。def ticket_prefix(s): s str(s).upper().replace(., ).replace(/, ).strip() m re.match(r^([A-Z0-9]*?)\s*\d, s) return m.group(1) if m and m.group(1) else NUM for df in (train, test): df[TicketPrefix] df[Ticket].apply(ticket_prefix)提取完之后再数一下频次把出现次数少于 10 次的前缀统一归到RARE。这一步不是必须的但能显著减少 one-hot 之后的列数。我试过不归并的版本TicketPrefix 一个字段就炸出 30 多列模型训练时间翻了近一倍分数反而降了 0.005 左右——典型的维度噪声。Cabin 的甲板信息在上一节已经处理过这里补充一个组合思路把Deck和Pclass交叉看一下会发现一等舱基本集中在 A、B、C、D 几个甲板三等舱几乎全是U。这说明Deck和Pclass有较强的相关性同时放进线性模型可能会引起共线性问题。树模型不受影响但如果你用的是逻辑回归建议先算一下方差膨胀因子或者干脆只用HasCabin不用Deck。3.3 家庭规模与独行标记SibSp和Parch分别是同船的兄弟姐妹/配偶数、父母/子女数。这两个字段单独看意义不大合成一个“家庭规模”就直观多了for df in (train, test): df[FamilySize] df[SibSp] df[Parch] 1 df[IsAlone] (df[FamilySize] 1).astype(int)加 1 是因为要把乘客本人算进去。FamilySize 1就是独自出行。这两个特征我实测下来对模型的贡献比原始的两个字段加起来还大原因是“独自一人”和“拖家带口”在紧急情况下的决策模式完全不同这是一个非线性关系必须显式构造出来模型才能学到。还有一层更深的信息家庭规模相同、姓氏相同的乘客往往是同一家人。如果能按Surname FamilySize分组算出“这一家人有几个幸存”那就是极强的特征。但这已经超出数据预处理的范畴进入了特征构造和标签利用的灰色地带——同一家族的幸存信息在训练集里可以用但在测试集上你并不知道结果处理不好就变成标签泄漏。我的建议是入门阶段不要碰这一层等把基础流程跑顺了再考虑。4. 编码、异常值与缩放4.1 类别编码one-hot 还是序数看模型脸色Pclass是有序类别1、2、3 之间有明显的大小关系直接当数值用是合理的不需要 one-hot。Sex是二值map({male: 0, female: 1})一行搞定。Embarked、Title、Deck、AgeBand、TicketPrefix这些无序类别就得 one-hot。这里有个必须注意的点one-hot 必须在合并表上做不能分别做。分别做的后果是训练集出现了Deck_A到Deck_G测试集只出现了Deck_C和Deck_U两边列数对不上predict直接报错。我刚开始就吃过这个亏报错信息是“特征数量不匹配”但实际原因藏在 one-hot 里查了很久。all_df pd.concat([train, test], axis0, sortFalse, ignore_indexTrue) all_df[_src] [train] * len(train) [test] * len(test) cat_cols [Sex, Embarked, Title, Deck, AgeBand, TicketPrefix] all_df pd.get_dummies(all_df, columnscat_cols, drop_firstFalse) train all_df[all_df[_src] train].drop(columns_src) test all_df[all_df[_src] test].drop(columns_src)drop_first该不该设我的经验是树模型保留全部类别drop_firstFalse因为树不关心共线性线性模型和神经网络设drop_firstTrue避免虚拟变量陷阱导致系数不可解释。这个参数没有绝对对错取决于后面接什么模型。还有个小问题get_dummies出来的列是布尔类型True/False有些模型能直接吃有些会报错。保守做法是统一转成 int8bool_cols all_df.select_dtypes(includebool).columns all_df[bool_cols] all_df[bool_cols].astype(int8)4.2 异常值先看分布再动手Fare 最大 512.33中位数 14.45均值 32.2。均值是中位数的两倍多典型的右偏长尾。这种分布我一般做两件事先 log 变换压长尾再考虑要不要截断。for df in (train, test): df[Fare_log] np.log1p(df[Fare])用log1p而不是log是为了兼容 Fare 为 0 的样本。np.log(0)是负无穷直接凉凉。Age 的“异常值”更有意思。最小值 0.42这是婴儿的年龄不是录入错误最大值 80也没问题。真正需要警惕的是那些看起来合理的边界值比如 Age 接近 0 但又不是 0可能是按月龄估算的。我倾向于不删这些因为生存率和年龄的关系本身就不是单调的——儿童生存率高老人低中年男性最低——删掉边界值反而会破坏这个模式。我的异常值判断标准是这个值物理上可能吗如果可能就不要删最多做变换。票价 512 是完全可能的豪华套房删掉它等于人为缩短了分布的尾部模型会低估高票价的影响。真正该删的是逻辑矛盾的数据比如 Age 为负、Fare 为负、SibSp 加起来超过船上总人数这类。提示异常值处理前一定要保存一份原始数据的副本。我见过有人在同一份 df 上反复做截断、填充、替换最后连自己都说不清哪一列被改过几次出了问题无法回滚。4.3 缩放与数据泄漏98% 的人在这里犯过错缩放本身很简单StandardScaler一行。真正的坑在于——fit只能在训练集上绝不能在合并表或测试集上。原因不复杂StandardScaler的fit会计算出均值和标准差。如果用全量数据算测试集的均值信息就通过这两个参数“漏”进了训练过程模型在验证集上的表现会虚高但上线之后大概率拉胯。这个错误在入门教程里极其常见因为很多人是用fit_transform(X)一次性处理全量数据代码简洁逻辑错误。from sklearn.preprocessing import StandardScaler num_cols [Age, Fare_log, FamilySize, SibSp, Parch] scaler StandardScaler() scaler.fit(train[num_cols]) # 只在训练集上 fit train[num_cols] scaler.transform(train[num_cols]) test[num_cols] scaler.transform(test[num_cols])如果你用交叉验证这个问题会更隐蔽。正确的姿势是把预处理逻辑包进Pipelinefrom sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.linear_model import LogisticRegression pipe Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()), (clf, LogisticRegression(max_iter1000)), ])在 Pipeline 里每一折交叉验证的时候缩放器只在当前折的训练部分fit验证部分只transform。这才是无泄漏的正确做法。很多人做的“手写 K 折”里先在全量上归一化再切折分数能高出好几个百分点全是水分。还有一点常被忽略Pclass要不要缩放它只有 1、2、3 三个值理论上不需要但如果你用的是基于距离的模型KNN、SVM不缩放会让它和其他标准化后的特征量纲不一致距离计算就失真了。我一般把Pclass、IsAlone、HasCabin这类离散数值列排除在缩放之外只缩放真正的连续量。这个决策不绝对但排除掉离散列之后线性模型的系数更容易解释。5. 完整实操一份能直接跑的预处理脚本5.1 依赖与目录结构整个流程只需要三个库pandas、numpy、scikit-learn。不需要装任何深度学习框架。目录我习惯这样组织project/ ├── data/ │ ├── train.csv │ └── test.csv ├── output/ │ ├── train_clean.csv │ └── test_clean.csv └── preprocess.py输出目录单独放好处是原始数据永远不被覆盖出了问题随时可以重跑。这一点在反复试错阶段特别重要我有一次直接在train.csv上做原地修改改到第三轮发现想回退已经回不去了只能重新下载。5.2 全流程代码骨架把前面几节的内容串起来主函数大概长这样import pandas as pd import numpy as np import re from sklearn.preprocessing import StandardScaler def load_data(train_path, test_path): train pd.read_csv(train_path) test pd.read_csv(test_path) train[_src] train test[_src] test return train, test def extract_title(df): t df[Name].str.extract(r([A-Za-z])\., expandFalse) keep {Mr: Mr, Miss: Miss, Mrs: Mrs, Master: Master, Mlle: Miss, Ms: Miss, Mme: Mrs} return t.map(keep).fillna(Rare) def ticket_prefix(s): s str(s).upper().replace(., ).replace(/, ).strip() m re.match(r^([A-Z0-9]*?)\s*\d, s) return m.group(1) if m and m.group(1) else NUM def build_features(all_df, age_map, fare_map): all_df all_df.copy() all_df[Title] extract_title(all_df) all_df[TicketPrefix] all_df[Ticket].apply(ticket_prefix) all_df[HasCabin] all_df[Cabin].notnull().astype(int) all_df[Deck] all_df[Cabin].str[0].fillna(U) all_df[FamilySize] all_df[SibSp] all_df[Parch] 1 all_df[IsAlone] (all_df[FamilySize] 1).astype(int) all_df[IsFreeFare] (all_df[Fare] 0).astype(int) # 缺失填充映射来自训练集 med_age all_df.set_index([Pclass, Sex]).index.map(age_map) all_df[Age] all_df[Age].fillna(pd.Series(med_age, indexall_df.index)) mask all_df[Fare].isnull() all_df.loc[mask, Fare] all_df.loc[mask, Pclass].map(fare_map) all_df[AgeBand] pd.cut(all_df[Age], bins[0, 12, 18, 35, 60, 100], labels[Child, Teen, Adult, Middle, Senior]) all_df[Fare_log] np.log1p(all_df[Fare]) return all_df def encode_and_split(all_df, cat_cols): all_df pd.get_dummies(all_df, columnscat_cols, drop_firstFalse) bool_cols all_df.select_dtypes(includebool).columns all_df[bool_cols] all_df[bool_cols].astype(int8) train all_df[all_df[_src] train].drop(columns_src).reset_index(dropTrue) test all_df[all_df[_src] test].drop(columns_src).reset_index(dropTrue) return train, test if __name__ __main__: train, test load_data(data/train.csv, data/test.csv) age_map train.groupby([Pclass, Sex])[Age].median() fare_map train.groupby(Pclass)[Fare].median() all_df pd.concat([train, test], axis0, sortFalse, ignore_indexTrue) all_df build_features(all_df, age_map, fare_map) cat_cols [Sex, Embarked, Title, Deck, AgeBand, TicketPrefix] train_out, test_out encode_and_split(all_df, cat_cols) num_cols [Age, Fare_log, FamilySize, SibSp, Parch] scaler StandardScaler().fit(train_out[num_cols]) train_out[num_cols] scaler.transform(train_out[num_cols]) test_out[num_cols] scaler.transform(test_out[num_cols]) train_out.to_csv(output/train_clean.csv, indexFalse) test_out.to_csv(output/test_clean.csv, indexFalse) print(done:, train_out.shape, test_out.shape)5.3 结果校验三个必查项脚本跑完不能直接拿去训练得先做三项校验。第一项是缺失检查。train_out.isnull().sum().sum()应该等于 0。如果不为 0说明有一列的填充逻辑漏了。这次我遇到过AgeBand分箱之后产生 NaN 的情况——因为pd.cut对超出边界的值返回 NaN如果 Age 最大值恰好超过 100那一行就会掉出来。这是分箱最容易翻车的地方务必在分箱之后加一句assert。第二项是列一致性检查。训练集和测试集的特征列必须完全相同顺序也要一致。可以用list(train_out.columns) list(test_out.columns)校验。因为是合并后统一 one-hot 再拆的理论上不会出问题但如果中间对某一边单独做过drop就会失配。第三项是量纲抽查。缩放之后连续列的均值应该接近 0标准差接近 1。如果某列的均值是 5 或者标准差是 12说明它没被缩放或者被重复缩放了。这个检查很简单但能抓住大部分粗心导致的问题。assert train_out.isnull().sum().sum() 0, 训练集仍有缺失 assert test_out.isnull().sum().sum() 0, 测试集仍有缺失 assert list(train_out.columns) list(test_out.columns), 两边列不一致 print(train_out[num_cols].describe().loc[[mean, std]].round(3))6. 常见问题与排查技巧实录6.1 问题速查表下面这张表是我自己踩过和帮别人排查过的真实问题按出现频率排序现象大概率原因排查动作修复方式predict 报特征数不匹配one-hot 分别在两边做列数不同打印两边 columns 做差集合并后统一 one-hot 再拆交叉验证分数异常高0.9缩放或填充用了全量数据检查 scaler.fit 的输入包进 Pipeline只在训练折 fit分数比基线还低稀有类别 map 后变 NaN 被填成众数检查 Title 等映射列的 value_countsmap 后统一 fillna训练集混入测试样本用行号切分且中间做过排序检查拆分逻辑是否依赖位置用来源标记列切分Age 分布出现异常尖峰缺失填充值过于集中对比填充前后直方图改用分组中位数或加扰动内存占用暴涨高基数类别 one-hot 炸列统计每列唯一值个数低频类别归并到 RARElog 变换后出现 -infFare 含 0 且用了 np.log检查最小值改用 np.log1p布尔列导致模型报错get_dummies 输出 bool 而非 int检查 dtypes统一 astype(int8)这张表里前三项的杀伤力最大因为它们不会让代码报错只会让结果悄悄变差。尤其是第二项分数虚高的那种“成功感”最容易让人放松警惕——我见过一个团队把 0.92 的交叉验证分数写进报告实际测试集只有 0.78查了两天才发现是预处理阶段就泄漏了。6.2 几条踩出来的心得心得一先写校验再写处理。我现在写预处理脚本的习惯是反过来的先在脑子里想清楚“处理完之后数据应该长什么样”把assert写好再填处理逻辑。看起来多花时间实际上省掉了大量“跑完了发现不对但不知道哪一步错的”的调试。断言是最便宜的单测。心得二每一步都落盘。特征工程做到一半中间结果存一份 CSV。后面某一步出了问题可以直接从中间结果重新开始不用从头跑。这份数据量小整个流程跑一遍也就几秒钟换成百万级数据中间落盘就是刚需。心得三别迷信“填满”。新手最大的执念是把所有 NaN 填上觉得填满就是干净。实际上一个设计良好的缺失标记特征价值可能超过填进去的那个值本身。Cabin 就是最好的例子——77% 缺失填是填不出来的但转成HasCabin之后信息保住了。心得四对比实验要控制变量。我在调特征的时候每次只改一处其他全部固定随机种子也固定。有一次我同时改了 Age 的填充策略和 one-hot 的drop_first参数分数涨了 0.01但我完全不知道是哪个改动带来的。后来拆开单独跑才发现其中一个改动其实是负收益被另一个的正收益掩盖了。心得五记录每一版的分数和配置。用个最简单的文本文件就行写清楚时间、改了哪里、交叉验证分数、测试集分数。我做过一个对比同一份数据只调整 Age 填充策略从全局均值 → 分组中位数 → 分组中位数加 AgeBand交叉验证分数分别是 0.812、0.826、0.831。差距看起来不大但这是稳定可复现的提升比调模型超参数来得实在。心得六预处理的天花板比模型低。这句话听起来有点反直觉但我的实测是这样在这份数据上把预处理从“中等”做到“精细”分数大概能提升 3 到 4 个百分点而换模型、调参带来的提升通常不超过 2 个百分点。原因在于这份数据的信息量本身就有限特征工程能挖的都快挖完了。所以如果你的分数卡在 0.78 左右上不去先别急着换模型回去检查一遍缺失值处理和类别编码往往能找到问题。最后说一个我自己一直在用的验证方法把预处理后的训练集随机抽 20 行逐行对着原始 CSV 手工核对一遍关键字段。这个方法很笨但能抓出所有自动化校验抓不到的“语义错误”——比如 Title 提取对了但归类错了比如 FamilySize 算的时候忘了加 1。我每次改完预处理逻辑都会抽几行看看十分钟的事能避免很多低级但致命的错误。