尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

机器学习数据预处理实战:缺失值、异常值与特征选择的完整指南

机器学习数据预处理实战:缺失值、异常值与特征选择的完整指南 我接过一个信贷评分项目模型调参折腾了半个月线下AUC从0.72涨到0.78满心欢喜地提交线上结果分数反而比基线还低。排查到最后才发现问题根本不在模型而是数据预处理顺序错了——我先在整个数据集上做了标准化再切分训练测试集测试集的信息早就渗进了训练过程。从那以后我养成一个习惯任何项目动手建模之前先把数据链路完整过一遍。这个习惯帮我省下的时间远比调参省下来的多。这篇笔记把机器学习里最容易被低估的数据预处理环节拆开讲清楚核心围绕缺失值处理、异常值识别、特征编码、量纲统一和特征选择。文章里会用到Kaggle上经典的Give Me Some Credit数据集作为贯穿案例对应的代码和思路可以直接复用到其他表格型任务上。适合刚开始接触机器学习的同学也适合被脏数据折磨过、想系统梳理一遍预处理的从业者。1. 先别急着建模拿到数据后第一小时做的四件事很多人拿到数据集的第一反应是直接model.fit()这大概是数据预处理里最大的坑。模型不会告诉你“这个特征有90%是空的”也不会告诉你“年龄列里混进了字符串”。你看到的训练集准确率可能不错但上线之后会崩得很惨。所以拿到数据的第一小时别碰模型先做四件事。1.1 扫一眼数据形态和字段类型用pandas加载数据之后先看三样东西维度、类型、缺失情况。import pandas as pd import numpy as np df pd.read_csv(cs-training.csv) print(df.shape) print(df.dtypes) print(df.isna().sum().sort_values(ascendingFalse))shape告诉你数据量级判断后续该走内存优化路线还是分块处理路线。dtypes告诉你每个字段是不是真的按预期存下来了比如数值列有没有被读成object。isna().sum()是初步缺失扫描能快速定位哪些特征需要重点关注。Give Me Some Credit这个数据集有15万行、11个字段目标变量是借款人是否经历财务困境。字段包括年龄、月收入、负债率、贷款次数、家属数量等。这个数据集有两个显著特点一是MonthlyIncome有近2万个缺失值二是NumberOfDependents也有少量缺失。这种分布很典型和真实信贷场景几乎一样。1.2 区分特征的真实含义和业务背景拿到字段列表时不要只看名字就开干。比如RevolvingUtilizationOfUnsecuredLines这个字段名字很绕意思是“无担保循环额度使用率”本质上是信用卡额度用了多少的比例。如果不理解这个含义后面做异常值处理时可能把高额度使用率的用户误判成异常点。我的习惯是给每个字段写两行注解一行描述字面含义一行描述业务含义。比如age字面是年龄业务上还隐含了信贷申请人的成熟度和风险偏好。NumberOfOpenCreditLinesAndLoans字面是开放贷款数量业务上代表了多头借贷程度。这些注解在后续特征选择时能帮你判断哪些特征即使统计上不显著也不能乱删。1.3 用缺失值热图看规律而不只是看比例热词里反复出现“缺失值热图”这确实是个高频操作。missingno库可以快速画出缺失矩阵、条形图和热力图。import missingno as msno msno.matrix(df) # 矩阵每行一个样本白线代表缺失位置 msno.bar(df) # 条形图每个特征的缺失比例 msno.heatmap(df) # 热力图特征之间的缺失相关性矩阵图最有价值的一点是看缺失位置有没有规律。如果白线集中在某些行、呈带状分布说明缺失不是完全随机的可能跟另一个特征有关。比如有些系统里月收入缺失往往发生在“自雇人士”这一类群体上收入字段为空本身就是一种信号。热力图能告诉你两个特征之间的缺失是否存在联动关系。比如MonthlyIncome和NumberOfDependents的缺失可能来自同一条数据录入链路。如果相关系数高可以用其中一个字段的信息辅助填充另一个。1.4 记录基线预处理前的模型分数这一步很多人不做但强烈建议做。用一个最简模型跑一次五折交叉验证把分数记下来。这样你能知道后续每一步预处理到底是帮你提分还是降分。from sklearn.model_selection import cross_val_score from sklearn.ensemble import RandomForestClassifier X df.drop(SeriousDlqin2yrs, axis1) y df[SeriousDlqin2yrs] baseline RandomForestClassifier(n_estimators100, random_state42) scores cross_val_score(baseline, X, y, cv5, scoringroc_auc) print(fbaseline AUC: {scores.mean():.4f} ± {scores.std():.4f})这个分数的意义不在于精度有多高而在于给你一把尺子。后续你每做一次处理就重新跑一遍这个评估用数据说话而不是凭感觉判断“这样处理应该更好”。2. 缺失值处理的决策矩阵删除、填充还是把缺失本身变成特征缺失值处理没有银弹。网上很多教程直接告诉你“用均值填充”但均值填充在特征严重偏斜时会把分布往中间拉反而破坏原始信号。正确的做法是先做判断再选择策略。2.1 先回答三个问题第一缺失比例是多少缺失比例低于1%且样本量足够大直接删除整行通常没影响。缺失比例超过60%这个特征的可用性就要打问号了除非它有很强的业务含义否则考虑删除。第二缺失机制是什么完全随机缺失可以放心删除或用简单策略填充。非完全随机缺失比如只有高收入群体才愿意填年收入缺失本身就成了一个有效特征建议保留“是否缺失”这个指示变量。第三这个字段有没有业务特殊含义在信贷场景里有的字段缺失可能是因为申请人没有该业务而不是数据没录全。比如NumberOfDependents缺失可能意味着数据库里没有维护这个信息也可能意味着申请人家庭情况特殊。2.2 数值型变量中位数优先均值要警惕如果决定填充数值型特征首选是中位数。原因很简单数值型特征经常有异常值均值会被极值拉偏中位数更稳健。比如MonthlyIncome这个字段大部分人在几千到几万之间少数人收入极高。如果均值填充会凭空造出一批“中等偏上收入群体”但实际上他们原本是缺失状态。SimpleImputer可以直接实现from sklearn.impute import SimpleImputer imp SimpleImputer(strategymedian) df[[MonthlyIncome]] imp.fit_transform(df[[MonthlyIncome]])注意这里用了add_indicator参数会更稳妥稍后会展开。2.3 类别型变量众数填充或新增“缺失”类别类别型特征的缺失处理思路不同。如果类别分布很不均衡比如90%都是“否”用众数填充会让缺失样本全部变成“否”这可能掩盖真实信息。更稳妥的做法是把缺失值当成一个独立类别比如Unknown让模型自己学习这个类别的含义。在Give Me Some Credit数据集中类别型特征不多但业务上如果有性别、婚姻状况之类的字段强烈建议把缺失单独成类。这样能保留“这部分用户信息不完整”这个信号避免硬造类别。2.4 高阶做法KNNImputer和模型预测填充当特征之间存在明显关联时用行内其他特征的信息来预测缺失值效果通常比单纯填一个统计量好。KNNImputer用K个最近邻的加权值来填充缺失核心思想是“相似样本的取值也相似”。from sklearn.impute import KNNImputer knn_imp KNNImputer(n_neighbors5, weightsdistance) df_filled knn_imp.fit_transform(df[[MonthlyIncome, NumberOfDependents, DebtRatio]])KNNImputer的问题在于计算量大15万行数据训练KNN填充会比较慢。实际使用可以先在训练集上fit再transform训练集和测试集。另一个思路是把这个字段当目标用其他字段做回归或分类预测。比如MonthlyIncome缺失时用age、DebtRatio、NumberOfOpenCreditLinesAndLoans等特征训练一个回归模型来预测。这个方法效果不错但复杂度高而且容易过拟合。建议先用简单方法再根据交叉验证结果决定是否升级。2.5 别忘了“缺失指示器”有一个细节很容易忽略缺失本身可能是信息。比如在信贷数据里月收入缺失的客户往往是自由职业或收入不稳定人群。如果只是把缺失填掉这个风险信号就没了。SimpleImputer提供了add_indicatorTrue参数一行代码就能生成缺失指示列imp SimpleImputer(strategymedian, add_indicatorTrue) X_filled imp.fit_transform(df[[MonthlyIncome]])填充后的矩阵会多出一列有缺失的样本这一列是1否则是0。这一点在后续做特征选择时也可以验证如果“是否缺失”这个指示器进入了重要特征列表说明缺失确实携带了预测信息。3. 异常值与偏态分布三个最容易被忽略的坑异常值处理是数据预处理里“看起来简单做起来全是坑”的环节。很多教程举个IQR移除离群点的例子就结束了但实际业务里远没那么简单。3.1 异常值三σ法则不是万能的3σ法则假设数据服从正态分布但真实业务数据很少正态。收入、负债率、贷款次数基本都是右偏分布用3σ会误删大量正常的高收入样本。更稳健的做法是IQR四分位距Q1 df[age].quantile(0.25) Q3 df[age].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR mask (df[age] lower) (df[age] upper) df_clean df[mask]1.5是默认系数它背后是Tukey的箱线图经验规则大致对应正态分布下约99.3%的覆盖范围。对强偏态分布可以适当调整系数比如用3.0只剔除极少数极端值。3.2 统计方法检出异常不代表一定要删除这是最关键的认知。统计方法只能告诉你“这个点偏离了主体分布”不能告诉你“这个点一定是错误数据”。比如信贷场景里NumberOfOpenCreditLinesAndLoans是20的样本IQR可能会把它标成异常。但这个样本可能是真实的“多头借贷”用户恰恰是风险极高的客群。如果你为了“清洗数据”把他删掉模型就学不到极端风险的用户画像。我的建议是分三步走先统计检出再业务判断最后决定去留。可以给评分卡项目专门建一个异常样本库把检出的样本放进去人工抽验后再决定是删除、封顶还是保留。3.3 封顶比删除更实用Winsorize处理很多业务场景不需要删异常值只需要把极端值压制到合理范围。这种做法叫Winsorize缩尾把超过上下限的值压缩到上下限。def winsorize_series(s, lower_quantile0.01, upper_quantile0.99): lower s.quantile(lower_quantile) upper s.quantile(upper_quantile) return s.clip(lowerlower, upperupper) df[DebtRatio_winsorized] winsorize_series(df[DebtRatio])这个做法的好处是保留了样本的“极端程度”信息又避免极端值在训练中过度影响梯度。对线性模型、逻辑回归尤其有用树的模型对异常值不敏感但也会被极端值影响分裂点的选择所以建议统一处理。3.4 偏态分布先看分布再决定要不要变换机器学习里很多模型对特征的分布形态并不敏感比如树模型但线性模型、KNN、SVM这一类基于距离或假设误差分布的模型对特征偏态非常敏感。检查偏态最简单的方法是看一眼直方图或者算偏度系数print(df[MonthlyIncome].skew()) # 通常会得到很大的正值比如 20如果偏度绝对值大于1考虑做变换。常用方法三种np.log1p(x)对右侧长尾有效要求非负np.sqrt(x)对中度正偏有效PowerTransformer(methodyeo-johnson)支持0和负值自动寻找最佳变换参数from sklearn.preprocessing import PowerTransformer pt PowerTransformer(methodyeo-johnson) df[MonthlyIncome_trans] pt.fit_transform(df[[MonthlyIncome]])值得说明的是log1p对Give Me Some Credit这类收入数据很实用因为它能处理0值同时压缩长尾。变换之后模型对低收入和高收入的区分会更平滑而不是被几个超高收入样本牵着走。4. 编码与量纲统一让模型真正“读懂”数据表格数据里除了数值型特征还有类别型特征。类别型特征不能直接丢给模型需要编码成数值。这里有几个高频错误几乎每个项目都能遇到。4.1 类别编码的三个常见错误第一个错误把类别标签直接当成数值型特征。比如把学历编码成 1小学2初中3高中4大学5研究生。如果直接喂给模型模型会默认学历每增加1目标变量线性变化但实际上本科学历和硕士学历之间的风险差异跟小学和初中之间的差异完全不是一回事。遇到这种问题要么用One-Hot要么用有序编码加排序信息。第二个错误对无序类别无脑One-Hot导致维度爆炸。如果类别有几百个比如城市、职业编码One-Hot会生成几百列模型容易过拟合训练速度也慢。这种情况需要换成Target Encoding或频率编码。第三个错误LabelEncoder乱用。sklearn的LabelEncoder本质是给类别按字典顺序编号它适合用来做目标变量的编码不适合直接做特征编码。很多新手拿它对特征编码产生一堆没有顺序含义的整数模型完全学不到有效信息。4.2 One-Hot的正确打开方式无序类别在类别数不多时直接用One-Hot最稳妥。df_encoded pd.get_dummies(df, columns[category_feature], drop_firstTrue)drop_firstTrue可以丢掉第一列避免多重共线性。在逻辑回归这类模型里不丢会导致设计矩阵不是满秩虽然sklearn能处理但系数的可解释性会变差。One-Hot的缺点是当类别数多且某个类别样本量很少时那一列几乎全是0不仅没信息还会引入噪声。所以先统计类别频次对频次极低的类别合并成“其他”再编码是更工程化的做法。4.3 高基数类别频率编码和Target Encoding对于城市、职业这类高基数特征不建议直接One-Hot。频率编码是一个简单好用的替代方案freq_map df[occupation].value_counts(normalizeTrue) df[occupation_freq] df[occupation].map(freq_map)难点在于“这个类别的样本量占比”本身就是一种信号。比如某个职业在数据集中占比很低可能代表小众或特殊群体对风险预测可能有独特意义。Target Encoding则把类别映射为目标变量的均值target_mean df.groupby(occupation)[SeriousDlqin2yrs].transform(mean) df[occupation_target] target_mean这个东西效果通常不错但很容易过拟合因为可能用到了目标变量的信息。使用Target Encoding时必须配合交叉验证在每一折内单独计算映射值防止泄漏。如果不想自己写循环可以用category_encoders库的TargetEncoder(cv5)。4.4 标准化和归一化并不是所有模型都需要经常有同学问是不是所有特征都要标准化答案是否定的。树模型随机森林、XGBoost、LightGBM是基于分裂的对特征的尺度和分布不敏感标准化不影响分裂结果。而对线性回归、逻辑回归、SVM、KNN、神经网络标准化几乎是必须的。因为这些模型要么假设特征同尺度要么基于距离计算量纲不一致会让大数值特征主导模型。常用的三种方法区别如下方法公式思路适用场景注意事项StandardScaler减均值除标准差特征近似正态或分布对称不改变分布形态只改尺度和位置MinMaxScaler缩放到0-1区间特征有明确上下界对异常值敏感一个极大值会压缩整体分布RobustScaler用中位数和IQR缩放特征含异常值不受异常值影响适合收入这类长尾特征from sklearn.preprocessing import StandardScaler, RobustScaler scaler RobustScaler() X_scaled scaler.fit_transform(X_raw)这里有一个经验当你打算对特征做log变换时先变换再标准化。因为log变换压缩长尾后StandardScaler效果更稳定。5. 特征选择减少维度而不是减少信息特征选择是数据预处理的收尾环节也是很多人容易忽略的一环。特征并不是越多越好。高维稀疏、冗余特征会拖慢训练速度还会增加过拟合风险。5.1 过滤式先删掉明显没用的特征最简单的一步是删除“常量特征”和“近常量特征”。比如某个特征99%的样本都是同一个值它对模型几乎没贡献。用方差选择器可以快速过滤from sklearn.feature_selection import VarianceThreshold selector VarianceThreshold(threshold0.01) X_high_variance selector.fit_transform(X)接下来看相关性。数值型特征之间高度相关比如皮尔逊相关系数大于0.8说明它们可能携带重复信息。保留其中一个即可尤其对线性模型多重共线性会导致系数不稳定。corr_matrix df.corr() high_corr_pairs [(col1, col2, corr_matrix.loc[col1, col2]) for col1 in df.columns for col2 in df.columns if col1 col2 and abs(corr_matrix.loc[col1, col2]) 0.8]对目标变量的相关性可以用互信息mutual information来筛选。互信息不要求线性关系能捕捉到非线性的关联。在Give Me Some Credit数据集上用mutual_info_classif可以快速排出特征重要性然后画累计贡献曲线选择Top 8-10个特征。5.2 包裹式用模型反馈来挑特征过滤式不关心模型怎么用特征包裹式则直接使用模型的性能来反馈。最典型的是RFE递归特征消除每次训练模型消除权重最小的特征重复直到达到目标特征数。from sklearn.feature_selection import RFECV from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier(n_estimators100, random_state42) rfecv RFECV(estimatorrf, step2, cv5, scoringroc_auc) X_selected rfecv.fit_transform(X, y)RFECV最实用的地方在于它会自动帮你选“保留多少个特征”最合适。你不需要拍脑袋定个数它用交叉验证的分数来决定。缺点是计算量大随机森林做RFE在15万条数据上会跑比较久。5.3 嵌入式让模型自己给特征打分嵌入式特征选择是模型训练过程中自动完成的最典型的两个代表是LassoL1正则化和树模型的特征重要性评分。Lasso的L1正则化会把不重要特征的系数压成0所以用它训练后直接看哪些特征系数是0就能判断该删哪些from sklearn.linear_model import LogisticRegression lasso LogisticRegression(penaltyl1, C0.1, solverliblinear) lasso.fit(X, y) important_features X.columns[lasso.coef_.flatten() ! 0]树模型的特征重要性评分非常方便但它有一个坑对高基数的类别型特征或数值范围大的特征有偏好可能会误以为数值范围大的特征更重要。所以不要只看importance排名就下结论要结合业务理解。5.4 特征选择必须放进交叉验证内部这是整篇文章里最重要的一条工程经验。如果你先在前面的完整数据集上选了特征再切分训练测试集那么“特征选择”这一步就已经用到了测试集的信息得到的结果是偏乐观的。正确的做法是把特征选择作为一个步骤放进Pipeline里在每一折训练集内部独立选择。from sklearn.pipeline import Pipeline from sklearn.feature_selection import SelectFromModel from sklearn.ensemble import RandomForestClassifier as RF pipe Pipeline([ (impute, SimpleImputer(strategymedian)), (variance, VarianceThreshold(threshold0.01)), (feature_select, SelectFromModel(RF(...), thresholdmean)), (model, RF(...)) ]) cross_val_score(pipe, X, y, cv5)这样做得到的交叉验证分数才是真实可信的。后面要上线时用全量训练数据重新拟合一次Pipeline再对预测数据做相同的处理即可。6. 数据泄漏陷阱为什么交叉验证分数虚高数据泄漏是数据处理中最隐蔽、后果最严重的问题。它不会报错但会让模型的评估结果完全失真。6.1 最常见的三种泄漏方式第一种在切分数据集之前就做了全量标准化或缺失值填充。比如用全量数据的均值来填充训练集和测试集的缺失值相当于测试集的汇总统计信息被模型“看到”了。正确做法是先切分再在训练集上fit预处理器最后transform训练集和测试集。第二种目标变量参与了特征处理。有一种误操作是做完Target Encoding之后才发现编码时把测试集的y也一起算进去了。这种情况模型会直接学习到目标本身训练时分数奇高线上立即崩盘。第三种特征选择时用了全量数据的统计量。比如在全量数据上计算方差、相关系数、互信息之后才切分同样泄漏。6.2 用Pipeline强制正确顺序代码上正确拆解是from sklearn.model_selection import train_test_split from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) imp SimpleImputer(strategymedian) scaler StandardScaler() X_train pd.DataFrame( scaler.fit_transform(imp.fit_transform(X_train)), columnsX_train.columns ) X_test pd.DataFrame( scaler.transform(imp.transform(X_test)), columnsX_test.columns )注意fit_transform只用于训练集测试集只用transform。更优雅、不易出错的做法是用Pipeline把预处理器和模型串在一起这样交叉验证时每一折的预处理都是独立的不会互相残留。6.3 时间序列场景的特殊处理在信贷等金融任务里如果数据本身按时间生成训练测试集不能随机切分必须按时间先后切分。比如先用2015到2018年的数据训练再用2019年的数据测试。不然模型会“偷看”到未来样本的分布。对时间序列场景连标准化都要在训练集上计算好统计量后直接应用到未来数据。可以在TimeSeriesSplit的每一折里重新fit统计量才能保证线上表现和验证表现一致。在Give Me Some Credit数据集里虽然没有明确的时间戳但这个原则对从该数据集迁移到其他信贷数据集时同样适用。每接手一个新项目先想清楚数据是不是按时间累积的如果是就不要随机划分。最后分享一个我自己的习惯。每处理完一个数据集我会写一个简短的markdown记录包含每个特征的含义、缺失比例、处理方式、是否做了变换、在特征选择里的排名。这个文档在模型迭代和复盘时价值极高。数据预处理表面上是脏活累活但它决定了模型的上限模型只是在逼近这个上限而已。
返回列表