1. 项目缘起为什么数学建模绕不开数据清洗这道坎做数学建模的朋友尤其是刚接触国赛、美赛或者亚太杯这类竞赛的同学大概率都经历过这样的场景好不容易从题目描述里找到了数据来源兴冲冲地打开Excel或者CSV文件准备大展拳脚结果第一眼就看到了一堆“NaN”、“NULL”、“9999”或者干脆就是一片空白。紧接着你画了个简单的散点图又发现有几个点远远地飘在云外跟其他数据点格格不入。这时候一股烦躁感就会涌上来——这数据根本没法直接用啊。没错这就是现实世界数据的常态不完美。缺失值和异常值是横在原始数据和可用模型之间最普遍的两道障碍。我见过太多队伍把大量时间花在复杂的算法选择和模型调优上却对数据预处理草草了事要么直接删除有缺失的行要么对异常值视而不见。最后模型跑出来的结果要么不稳定要么完全偏离常识复盘时才发现问题出在最开始的“脏数据”上。所以今天我们不谈那些高深的算法就扎扎实实地聊聊在Python这个数学建模的主力工具里怎么用pandas、numpy、scikit-learn这些库系统性地处理好缺失值和异常值。这绝不是简单的“填充一个平均数”或者“删掉离谱值”而是一套需要结合业务背景、数据分布和后续模型特点进行综合判断的方法论。处理得当它能将数据的价值最大化为后续建模铺平道路处理不当则可能引入偏见甚至导致完全错误的结论。接下来我就结合自己多次参赛和实际项目中的经验把这套流程掰开揉碎了讲清楚。2. 缺失值处理识别、诊断与策略选择面对缺失值第一步不是急着动手处理而是先搞清楚状况。盲目操作比缺失本身更危险。2.1 系统性识别与可视化诊断拿到数据后我习惯先用pandas进行快速扫描建立一个全局认知。import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns # 假设df是我们的DataFrame print(df.info()) # 查看各列非空数量快速感知缺失概况 print(df.isnull().sum()) # 精确计算每列的缺失值数量 print(round(df.isnull().sum() / len(df) * 100, 2)) # 计算缺失率百分比光看数字还不够直观我强烈推荐做一个缺失值矩阵图。import missingno as msno msno.matrix(df) plt.show()这个图能一目了然地看出缺失值在数据集中的分布模式是随机零星出现还是整块整块地缺失如果某些列的缺失位置高度一致可能意味着这些数据来自同一个有问题的采集批次或传感器。这种模式识别对于选择后续处理策略至关重要。2.2 理解缺失机制MCAR MAR MNAR这是选择处理方法的理论基础很多新手会忽略但极其重要。完全随机缺失MCAR数据缺失的概率与任何已观测或未观测的变量都无关。比如问卷因为印刷问题丢失了一页这一页上的所有问题对所有受访者都缺失。这是最“友好”的缺失类型但现实中很少见。处理起来相对简单删除或填充对结果偏差影响较小。随机缺失MAR数据缺失的概率只与已观测到的变量有关与未观测到的值本身无关。例如在收入调查中高收入群体可能更不愿意透露具体数字但“是否缺失”这个行为可以由“年龄”、“职业”等已观测变量来预测。这是比较常见的假设也是大多数高级填充方法如回归、多重插补成立的前提。非随机缺失MNAR数据缺失的概率与未观测到的值本身有关。例如在心理健康量表中抑郁程度最严重的人可能根本不愿意填写量表。这种情况下缺失本身就包含了重要信息。处理MNAR最为棘手通常需要专业的统计模型或对缺失机制本身进行建模。在实际建模中我们通常假设数据是MAR因为这是最务实且可操作的假设。但心里要绷紧一根弦如果怀疑是MNAR比如价格越高的商品其“折扣率”字段越容易缺失那么直接删除或简单填充都可能带来严重偏差。2.3 核心处理策略详解与Python实现策略无好坏只有是否合适。下面我结合场景和代码分析几种主要方法。策略一直接删除这是最粗暴但也最快的方法适用于两种情况缺失率极低例如5%且样本量足够大删除后对统计功效影响很小。缺失数据所在的整行或整列对于分析目标毫无意义。# 删除含有任何缺失值的行 df_drop_rows df.dropna(axis0) # 删除缺失率超过50%的列 threshold len(df) * 0.5 df_drop_cols df.dropna(axis1, threshthreshold)注意dropna()会永久删除数据务必先确认数据备份或使用副本。在时间序列数据中随意删除行可能会破坏序列的连续性。策略二简单填充适用于缺失率不高且特征本身波动不大的情况。常用方法包括均值/中位数/众数填充对于数值型数据若分布对称用均值若偏态严重或有离群值用中位数更稳健。对于分类数据用众数出现最频繁的类别。# 用该列的均值填充 df_filled_mean df.fillna(df.mean()) # 对特定列用中位数填充 df[salary] df[salary].fillna(df[salary].median()) # 对分类列用众数填充 mode_value df[category].mode()[0] # mode()返回一个Series df[category] df[category].fillna(mode_value)前向填充ffill/后向填充bfill在时间序列数据中特别有用用前一个或后一个有效值来填充。df_sorted df.sort_values(timestamp) # 确保按时间排序 df_ffill df_sorted.fillna(methodffill)策略三模型预测填充高级方法当缺失不是完全随机且数据间存在相关性时用模型预测缺失值是最佳选择之一。其核心思想是将缺失字段作为目标变量其他完整字段作为特征训练一个预测模型。K-最近邻KNN填充scikit-learn的KNNImputer基于距离寻找相似样本进行填充。from sklearn.impute import KNNImputer imputer KNNImputer(n_neighbors5, weightsuniform) df_knn_imputed pd.DataFrame(imputer.fit_transform(df), columnsdf.columns)心得n_neighbors不宜过大通常3-5即可。对于特征量纲差异大的数据一定要先做标准化StandardScaler否则距离计算会被大数值特征主导。迭代式多元插补MICE - Multiple Imputation by Chained Equations这是目前学术界和工业界处理缺失值的黄金标准之一。它通过多次迭代为每个缺失值生成多个可能的填充值形成多个完整数据集最后汇总分析结果能更好地反映缺失带来的不确定性。我们可以用fancyimpute库但安装可能麻烦或statsmodels。# 使用IterativeImputer (scikit-learn的实现类似MICE) from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer from sklearn.ensemble import RandomForestRegressor # 以随机森林为估计器 imputer IterativeImputer(estimatorRandomForestRegressor(), max_iter10, random_state0) df_mice_imputed pd.DataFrame(imputer.fit_transform(df), columnsdf.columns)为什么MICE更优简单填充假设所有缺失值都等于一个确定值这低估了方差。MICE通过生成多个版本的数据保留了因缺失而产生的不确定性使得后续的模型参数估计和置信区间更可靠。在最终论文中如果使用了MICE记得说明你汇总多个数据集结果的方法如Rubin规则。策略四将缺失本身作为特征对于某些场景数据“缺失”这一行为本身可能就极具信息量MNAR的典型情况。例如在金融风控中客户不填写“单位电话”可能与其信用风险相关。这时除了填充更好的做法是新增一个二值特征“IsMissing_XXX”。df[is_missing_income] df[income].isnull().astype(int) # 然后再用中位数等填充income列本身 df[income] df[income].fillna(df[income].median())3. 异常值检测是噪音还是宝藏异常值不一定是错误它可能是罕见的特殊事件也可能是数据录入的错误。区分“有价值的异常点”和“有害的噪声点”是关键。3.1 基于统计分布的检测方法这类方法假设数据服从某种分布将远离分布中心的点视为异常。3σ原则 / Z-Score法适用于近似正态分布的数据。计算每个数据点与均值的差距有多少个标准差。通常将|Z-Score| 3的数据点视为异常值。from scipy import stats z_scores np.abs(stats.zscore(df[numeric_column])) threshold 3 outliers df[z_scores threshold]局限均值和标准差本身受异常值影响很大不稳健且要求数据大致正态。箱线图IQR法这是我最常用、也最推荐给新手的稳健方法。它基于四分位数不受极端值影响。Q1 df[column].quantile(0.25) Q3 df[column].quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 1.5 * IQR upper_bound Q3 1.5 * IQR outliers df[(df[column] lower_bound) | (df[column] upper_bound)]经验之谈1.5倍的IQR是一个经验阈值比较宽松。在金融、网络安全等对异常敏感的领域可以尝试使用3倍的IQR来定义“极端异常值”。箱线图能同时检测出高端和低端的异常可视化非常直观sns.boxplot(xdf[column])。3.2 基于距离与密度的检测方法当数据存在多个簇或者非球形分布时统计方法可能失效。这时需要基于“局部”思想的方法。DBSCAN聚类这个聚类算法本身就能识别噪声点。它将高密度区域内的点划分为簇低密度区域的点标记为噪声-1。它不需要预先指定簇的个数且能发现任意形状的簇。from sklearn.cluster import DBSCAN from sklearn.preprocessing import StandardScaler # 对需要检测的多维数据做标准化 X StandardScaler().fit_transform(df[[feature1, feature2]]) clustering DBSCAN(eps0.5, min_samples10).fit(X) df[dbscan_label] clustering.labels_ # 标签为-1的点即为异常点参数选择是关键eps是邻域半径min_samples是核心点所需的最小样本数。可以通过k-距离图来辅助选择eps。局部异常因子LOF - Local Outlier Factor这是我处理复杂异常检测问题时的首选。它计算一个点的“局部密度”与其邻居的局部密度之比。比值显著小于1的点其局部密度低于邻居被认为是异常点。from sklearn.neighbors import LocalOutlierFactor lof LocalOutlierFactor(n_neighbors20, contamination0.1) # contamination是异常值比例的估计 outlier_labels lof.fit_predict(X) lof_scores -lof.negative_outlier_factor_ # 分数越大越异常优势LOF是一种无监督方法能量化异常程度得分并且对数据的全局分布假设较少更适合处理具有不同密度簇的数据。3.3 基于模型的检测方法孤立森林Isolation Forest它的思想很巧妙通过随机选择特征和划分值来“隔离”每个数据点。异常点由于与正常点差异大更容易被快速隔离所需路径长度短。from sklearn.ensemble import IsolationForest iso_forest IsolationForest(contamination0.1, random_state42) iso_labels iso_forest.fit_predict(X) # 返回1表示正常-1表示异常 df[is_anomaly] iso_labels特点孤立森林在高维大数据集上效率很高且不依赖于距离或密度度量对全局异常点敏感。3.4 异常值的处理策略检测出来之后怎么办同样需要谨慎。删除仅当确信异常值是由数据录入错误、测量错误或无关噪声引起时。删除前务必记录删除的数量和比例。替换盖帽法/缩尾这是更温和的方法。例如对于IQR法检测出的异常值不删除而是将其值调整到上下边界上。df[column_capped] df[column].clip(lowerlower_bound, upperupper_bound)分箱离散化将连续变量分段异常值会被归入最高或最低的箱中减弱其极端影响。保留并标记如果怀疑异常点可能是重要的特殊模式如欺诈交易、疾病爆发则不应删除或修改而应将其作为一个特殊的类别或标签在建模时单独考虑。有时为异常值数据子集单独建模效果更好。4. 实战流程一个完整的数学建模数据清洗案例让我们模拟一个数学建模竞赛中常见的情景分析某城市共享单车的使用需求。数据集bike_data.csv包含日期、天气、温度、湿度、风速、节假日、工作日、租赁数量等字段。4.1 数据加载与初步探索import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns df pd.read_csv(bike_data.csv) print(数据形状:, df.shape) print(\n前5行数据:) print(df.head()) print(\n数据基本信息:) print(df.info()) print(\n描述性统计:) print(df.describe())假设我们发现wind_speed风速有15%的缺失temperature温度有个别异常高的值比如50摄氏度而该城市夏季最高通常38度。4.2 分步骤处理流程第一步处理wind_speed缺失值考虑到风速可能与天气、季节有关我们选择用KNN进行填充。由于数据中包含分类变量如weather我们需要先将其转换为数值哑变量。from sklearn.impute import KNNImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline # 1. 分离特征和目标假设‘count’是租赁数量为目标变量 X df.drop(count, axis1) y df[count] # 2. 定义预处理管道对分类变量编码对数值变量标准化 numeric_features X.select_dtypes(include[int64, float64]).columns.tolist() categorical_features X.select_dtypes(include[object]).columns.tolist() preprocessor ColumnTransformer( transformers[ (num, StandardScaler(), numeric_features), (cat, OneHotEncoder(handle_unknownignore), categorical_features) ]) # 3. 创建包含KNN插补的管道 # 注意KNNImputer需要在编码和标准化之后进行因为它基于数值距离。 # 但sklearn的Pipeline按顺序执行我们可以把Imputer放在最后一步。 # 更严谨的做法是构建更复杂的管道这里为清晰起见我们分步操作。 # 先进行预处理编码和标准化 X_processed preprocessor.fit_transform(X) # 将处理后的数据转回DataFrame获取特征名稍复杂此处略过实践中可用get_feature_names_out # 假设我们得到了一个数值矩阵 X_processed_array # 应用KNNImputer imputer KNNImputer(n_neighbors5) X_imputed_array imputer.fit_transform(X_processed) # 现在X_imputed_array包含了填充后的所有特征值。 # 我们需要反向映射找到对应‘wind_speed’原始列的位置将其填充回原df。 # 在实际复杂管道中推荐使用sklearn的FunctionTransformer或自定义转换器来更优雅地处理。 # 为简化演示这里假设我们只对‘wind_speed’这一列用KNN填充且其他特征无缺失。 # 更简单的做法直接对数值列子集做KNN填充。 numeric_df X[numeric_features].copy() imputer_simple KNNImputer(n_neighbors5) numeric_df_imputed imputer_simple.fit_transform(numeric_df) numeric_df_imputed pd.DataFrame(numeric_df_imputed, columnsnumeric_features) # 将填充好的数值列与未处理的原分类列合并 df_filled pd.concat([numeric_df_imputed, X[categorical_features]], axis1) df_filled[count] y第二步处理temperature异常值我们用箱线图IQR法来识别并处理温度的异常值。# 计算IQR边界 Q1 df_filled[temperature].quantile(0.25) Q3 df_filled[temperature].quantile(0.75) IQR Q3 - Q1 lower_bound Q1 - 3 * IQR # 使用3倍IQR因为温度是重要特征我们想保留更多信息 upper_bound Q3 3 * IQR print(f温度IQR边界: [{lower_bound:.2f}, {upper_bound:.2f}]) outliers_temp df_filled[(df_filled[temperature] lower_bound) | (df_filled[temperature] upper_bound)] print(f检测到温度异常值数量: {len(outliers_temp)}) # 绘制处理前后的箱线图对比 fig, axes plt.subplots(1, 2, figsize(12, 5)) sns.boxplot(ydf[temperature], axaxes[0]) axes[0].set_title(原始温度数据箱线图) sns.boxplot(ydf_filled[temperature], axaxes[1]) axes[1].set_title(填充缺失值后温度箱线图) plt.show() # 采用缩尾法Winsorization处理将异常值拉回边界 df_filled[temperature_capped] df_filled[temperature].clip(lower_bound, upper_bound) # 检查处理后的分布 print(df_filled[temperature_capped].describe())第三步整合与验证处理完成后务必进行一致性检查。# 检查是否还有缺失值 print(处理后的缺失值统计:) print(df_filled.isnull().sum().sum()) # 应该为0 # 对比处理前后目标变量租赁数量与温度的相关性 corr_original df[temperature].corr(df[count]) corr_processed df_filled[temperature_capped].corr(df_filled[count]) print(f原始温度与租赁数的相关性: {corr_original:.4f}) print(f处理后的温度与租赁数的相关性: {corr_processed:.4f}) # 可视化处理效果温度与租赁数的散点图 fig, axes plt.subplots(1, 2, figsize(14, 6)) axes[0].scatter(df[temperature], df[count], alpha0.5) axes[0].set_xlabel(原始温度) axes[0].set_ylabel(租赁数) axes[0].set_title(处理前) axes[1].scatter(df_filled[temperature_capped], df_filled[count], alpha0.5) axes[1].set_xlabel(处理后的温度缩尾) axes[1].set_ylabel(租赁数) axes[1].set_title(处理后) plt.tight_layout() plt.show()通过对比你可以看到异常值被修正后散点图变得更加集中相关性系数也可能发生合理变化。这一步的验证至关重要它能确保你的清洗操作没有意外地破坏数据中真实存在的模式。5. 在建模管道中自动化与评估清洗影响在实际项目中尤其是在使用scikit-learn进行机器学习建模时我们应该将数据清洗步骤集成到Pipeline中以确保训练集和测试集以完全相同的方式处理避免数据泄露。5.1 构建集成清洗步骤的建模管道from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error, r2_score # 假设我们最终的DataFrame是 df_clean X df_clean.drop(count, axis1) y df_clean[count] # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) # 重新定义预处理转换器明确包含插补步骤 numeric_features X_train.select_dtypes(include[int64, float64]).columns.tolist() categorical_features X_train.select_dtypes(include[object]).columns.tolist() # 创建数值和分类特征的分别处理管道 numeric_transformer Pipeline(steps[ (imputer, KNNImputer(n_neighbors5)), # 数值列缺失值填充 (scaler, StandardScaler()) # 标准化 ]) categorical_transformer Pipeline(steps[ (imputer, SimpleImputer(strategymost_frequent)), # 分类列缺失值用众数填充 (onehot, OneHotEncoder(handle_unknownignore)) ]) # 合并预处理步骤 preprocessor ColumnTransformer( transformers[ (num, numeric_transformer, numeric_features), (cat, categorical_transformer, categorical_features) ]) # 创建完整的建模管道预处理 模型 model_pipeline Pipeline(steps[ (preprocessor, preprocessor), (regressor, RandomForestRegressor(n_estimators100, random_state42)) ]) # 训练模型 model_pipeline.fit(X_train, y_train) # 预测并评估 y_pred model_pipeline.predict(X_test) print(f测试集R²分数: {r2_score(y_test, y_pred):.4f}) print(f测试集RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.2f})5.2 评估不同清洗策略的影响为了科学地证明你的清洗工作是有效的一个很好的做法是进行对比实验。# 定义不同的预处理策略 strategies { 删除缺失值: Pipeline(steps[ (preprocessor, ColumnTransformer( transformers[ (num, StandardScaler(), numeric_features), (cat, OneHotEncoder(handle_unknownignore), categorical_features) ])), (regressor, RandomForestRegressor(n_estimators100, random_state42)) ]), 均值填充: Pipeline(steps[ (preprocessor, ColumnTransformer( transformers[ (num, Pipeline(steps[(imputer, SimpleImputer(strategymean)), (scaler, StandardScaler())]), numeric_features), (cat, Pipeline(steps[(imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(handle_unknownignore))]), categorical_features) ])), (regressor, RandomForestRegressor(n_estimators100, random_state42)) ]), KNN填充: model_pipeline # 使用上面定义的包含KNNImputer的管道 } results {} for name, pipeline in strategies.items(): # 注意对于“删除缺失值”策略需要先对X_train, X_test单独应用dropna这里为简化假设我们在构建strategies时已处理好数据子集。 # 在实际对比中应确保每种策略都在相同的数据划分上评估。 # 这里仅展示逻辑框架。 if name 删除缺失值: # 需要创建一个不包含插补步骤但能处理训练/测试集匹配特征的管道略复杂。 # 更简单的做法是在策略循环外为每种策略准备对应的、已处理过缺失值的数据。 pass else: pipeline.fit(X_train, y_train) y_pred pipeline.predict(X_test) results[name] { R2: r2_score(y_test, y_pred), RMSE: np.sqrt(mean_squared_error(y_test, y_pred)) } # 打印对比结果 for strategy, metrics in results.items(): print(f{strategy}: R² {metrics[R2]:.4f}, RMSE {metrics[RMSE]:.2f})通过这样的对比你可以清晰地展示你所选择的KNN填充缩尾处理策略在测试集上的预测性能如R²更高RMSE更低优于简单的删除法或均值填充法。这在你数学建模论文的“数据预处理”部分是非常有说服力的论据。数据处理是建模的基石基石不稳高楼易倾。花在数据清洗上的每一分钟都会在模型稳定性和结果可信度上得到回报。别嫌麻烦把这些步骤踏实走完你的模型就已经赢在起跑线上了。