ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

二手车价格预测竞赛:数据清洗、特征工程与模型融合实战

二手车价格预测竞赛:数据清洗、特征工程与模型融合实战 简介这份资料包是阿里天池与Datawhale联合举办的二手车交易价格预测竞赛的优胜奖方案面向计算机、应用数学、电子信息工程等专业的学生与研究人员可作为课程设计、毕业设计或学术竞赛的参考素材帮助读者理解特征工程、模型构建与集成调优的完整思路。压缩包共15个文件约610KB以Python脚本为主涵盖神经网络、LightGBM、树模型及stack与mix等集成方案另附CSV预测结果、Markdown说明文档、TXT依赖清单与若干备份文件结构清晰便于复现。目前已有42人学习。读者可从中获得一份完整的赛题建模流程、多模型对比与融合策略、预测结果输出示例及项目说明适合具备一定编程与数据分析基础、希望深入钻研预测建模细节并自主调试优化的学习者参考。1. 二手车价格预测竞赛从数据清洗到模型融合的完整落地路径二手车价格预测是阿里天池与 Datawhale 联合推出的经典回归竞赛目标是根据车辆的品牌、里程、排量、变速箱类型、注册日期等字段预测一辆二手车在交易市场上的公允售价。这个赛题看起来像普通的表格回归任务但真正动手做过的人都知道它的难点不在模型本身而在数据清洗和特征构造——缺失值比例高、类别字段基数大、价格分布长尾严重任何一个环节处理不当线上评分就会差出一大截。适合已经掌握 Pandas 和 sklearn 基础、想通过一个完整竞赛项目把特征工程和模型融合串起来的从业者。这篇文章会按我实际打这个比赛的顺序把数据读取、清洗、特征构造、模型训练、融合提分的每一步拆开讲清楚源码结构也会一并说明让你能直接复现一套有竞争力的方案。2. 数据读取与清洗把 15 万条脏数据变成可用表格2.1 先看清数据长什么样再动手拿到数据的第一件事不是急着 dropna而是把训练集和测试集的字段类型、缺失比例、取值分布全部打印一遍。这个比赛的数据通常包含几十个字段其中 bodyType、fuelType、gearbox 这类类别字段缺失率可能超过 20%而 power、kilometer 这类数值字段也会有零星缺失。我一般会先写一段概览代码把每个字段的 dtype、缺失率、唯一值数量列出来再决定每个字段的清洗策略。import pandas as pd import numpy as np train pd.read_csv(used_car_train_20200313.csv, sep ) test pd.read_csv(used_car_testA_20200313.csv, sep ) # 概览字段类型、缺失率、唯一值数 def overview(df, name): info pd.DataFrame({ dtype: df.dtypes, missing_rate: df.isnull().mean().round(4), nunique: df.nunique() }) print(f {name} ) print(info.sort_values(missing_rate, ascendingFalse)) overview(train, train) overview(test, test)这段代码的核心目的是建立数据认知。missing_rate帮你判断哪些字段需要填充、哪些字段直接删掉更划算nunique帮你识别高基数类别字段比如 brand 和 model 的组合可能有上千种取值后续需要做目标编码而不是简单的 one-hot。注意读取时sep 是因为天池这份数据用空格分隔如果你下载的版本是逗号分隔改成默认即可。2.2 缺失值填充与异常值处理的具体策略缺失值处理没有万能公式我的原则是数值字段用中位数填充并加一个缺失标记列类别字段用众数填充或单独归为 unknown 类。异常值方面price 字段存在少量 0 值和极端高价这些样本会严重干扰回归模型需要设阈值截断。notRepairedDamage 字段里有个坑——它的取值是 - 和 0.0/1.0 混在一起- 其实代表缺失必须先替换成 NaN 再统一处理。# 训练集和测试集合并处理保证特征工程一致 data pd.concat([train, test], ignore_indexTrue) data data.drop([SaleID], axis1) # ID 列不参与建模 # notRepairedDamage 的 - 替换为 NaN data[notRepairedDamage] data[notRepairedDamage].replace(-, np.nan) # 数值字段中位数填充 缺失标记 num_cols [power, kilometer, v_0, v_1, v_2] # 按实际字段调整 for col in num_cols: data[f{col}_isnull] data[col].isnull().astype(int) data[col] data[col].fillna(data[col].median()) # 类别字段众数填充 cat_cols [bodyType, fuelType, gearbox, notRepairedDamage] for col in cat_cols: data[col] data[col].fillna(data[col].mode()[0]) # price 异常值截断仅训练集有 price data[price] data[price].clip(lower100, upperdata[price].quantile(0.99))这里有几个参数需要根据实际数据分布调整。clip的下限 100 是排除明显不合理的极低价上限用 99 分位数是为了去掉长尾极端值但不损失太多样本。缺失标记列看起来简单但在树模型里往往能带来 0.001 左右的提升因为缺失本身可能携带信息——比如没填 power 的车可能是卖家不重视参数录入这类车价格分布确实有差异。2.3 类别字段编码目标编码比 one-hot 更适合这个场景brand、model、bodyType 这些字段的基数差异很大。brand 可能只有几十种one-hot 还能接受但 model 可能有几千种one-hot 会导致维度爆炸且稀疏。我一般对低基数类别用 label encoding对高基数类别用目标编码target encoding也就是用该类别的价格均值来替换类别值同时加平滑防止过拟合。from sklearn.model_selection import KFold # 低基数label encoding low_card_cols [bodyType, fuelType, gearbox, notRepairedDamage] for col in low_card_cols: data[col] data[col].astype(category).cat.codes # 高基数目标编码5 折交叉防止泄漏 high_card_cols [brand, model, regionCode] kf KFold(n_splits5, shuffleTrue, random_state42) train_mask data[price].notnull() for col in high_card_cols: data[f{col}_target_enc] np.nan for tr_idx, val_idx in kf.split(data[train_mask]): tr_data data.iloc[tr_idx] val_data data.iloc[val_idx] mean_map tr_data.groupby(col)[price].mean() data.loc[val_data.index, f{col}_target_enc] val_data[col].map(mean_map) # 测试集用全量训练集均值填充 full_mean data.loc[train_mask].groupby(col)[price].mean() data[f{col}_target_enc] data[f{col}_target_enc].fillna( data[col].map(full_mean) )目标编码的关键在于交叉验证的折数选择。5 折是常用起点折数太少会导致编码不稳定折数太多会增加计算量且平滑效果减弱。平滑方面如果某个类别只有一两个样本它的均值噪声很大我一般会加一个最小样本数阈值低于阈值的类别直接用全局均值。这段代码里没有显式写平滑实际使用时可以在mean_map计算时加一个alpha参数做贝叶斯平滑。3. 特征构造让模型看到车龄和使用强度3.1 从日期字段里挖出车龄和季节信息原始数据里的 regDate 和 creatDate 是格式化的日期直接扔给模型没有意义。需要转换成车龄单位年或天以及交易月份、星期等周期特征。车龄是二手车定价最核心的变量之一一辆 3 年车和 8 年车的残值率差异巨大。另外交易月份可能反映季节性波动比如年底卖车的人多、价格偏低。# 解析日期 data[regDate] pd.to_datetime(data[regDate], format%Y%m%d, errorscoerce) data[creatDate] pd.to_datetime(data[creatDate], format%Y%m%d, errorscoerce) # 车龄注册到创建发布之间的天数 data[car_age_days] (data[creatDate] - data[regDate]).dt.days data[car_age_days] data[car_age_days].clip(lower0, upper365*30) # 交易月份和星期 data[creat_month] data[creatDate].dt.month data[creat_weekday] data[creatDate].dt.weekday # 车龄分桶捕捉非线性关系 data[age_bucket] pd.cut(data[car_age_days], bins[-1, 365, 1095, 1825, 3650, 99999], labels[0, 1, 2, 3, 4]).astype(int)car_age_days的上下限截断是为了处理数据录入错误——有些 regDate 晚于 creatDate或者车龄超过 30 年这些异常值如果不处理会拉偏模型。分桶特征age_bucket的作用是让线性模型也能捕捉车龄的非线性影响对树模型来说分桶和原始值同时保留通常效果更好。3.2 构造使用强度特征年均里程比总里程更有信息量kilometer 字段是总行驶里程但同样跑 10 万公里一辆 3 年车和一辆 10 年车的使用强度完全不同。用总里程除以车龄得到年均里程能更准确地反映车辆磨损程度。这个特征在竞赛中往往能带来明显提升因为它把两个原始字段的信息做了非线性组合。# 年均里程总里程 / 车龄年 data[car_age_years] data[car_age_days] / 365.0 data[km_per_year] data[kilometer] / (data[car_age_years] 0.1) # 加 0.1 防止除零 # 使用强度分档 data[usage_intensity] pd.cut(data[km_per_year], bins[-1, 0.5, 1.0, 2.0, 5.0, 99999], labels[0, 1, 2, 3, 4]).astype(int) # 功率与排量的比值反映发动机效率 data[power_per_displacement] data[power] / (data[displacement] 1)km_per_year加 0.1 是工程上的小技巧避免车龄为 0 时除零报错。usage_intensity的分箱边界是根据业务经验设定的年均 0.5 万公里以下算低强度1 到 2 万公里是正常家用超过 5 万公里通常是营运车辆价格折损会明显加大。这些边界值不是绝对的你可以根据数据分布用分位数来调整。3.3 品牌与型号的聚合统计特征除了目标编码还可以构造品牌级别的统计特征比如该品牌在训练集中的平均价格、价格标准差、样本数量。这些特征能让模型知道这个品牌整体是高端还是低端对于样本量少的型号尤其有用。# 品牌级别聚合仅用训练集计算避免泄漏 train_only data[data[price].notnull()] brand_stats train_only.groupby(brand)[price].agg([mean, std, count]) brand_stats.columns [brand_price_mean, brand_price_std, brand_count] data data.merge(brand_stats, left_onbrand, right_indexTrue, howleft) # 型号在品牌内的相对位置 model_stats train_only.groupby([brand, model])[price].mean().reset_index() model_stats.columns [brand, model, model_price_mean] data data.merge(model_stats, on[brand, model], howleft)聚合特征的计算必须严格限定在训练集上否则测试集的信息会泄漏到训练过程中导致线上评分虚高。brand_count这个特征看起来简单但它能帮助模型判断某个品牌的统计均值是否可靠——样本量少的品牌其均值噪声大模型应该更谨慎地使用。4. 模型训练与调参LightGBM 和 XGBoost 的双线并行4.1 LightGBM 的 baseline 配置与关键参数LightGBM 是这个竞赛的首选模型训练快、内存占用低、对类别特征支持好。我的 baseline 配置通常从学习率 0.05、树数量 2000 起步配合早停防止过拟合。关键参数里num_leaves 控制模型复杂度min_child_samples 控制叶节点最小样本数这两个是调参的重点。import lightgbm as lgb from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error # 准备特征矩阵 feature_cols [c for c in data.columns if c not in [price, regDate, creatDate]] train_data data[data[price].notnull()].copy() test_data data[data[price].isnull()].copy() X train_data[feature_cols] y train_data[price] X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42) # LightGBM 参数 lgb_params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 64, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, lambda_l1: 0.1, lambda_l2: 0.1, n_estimators: 2000, verbose: -1 } model_lgb lgb.LGBMRegressor(**lgb_params) model_lgb.fit(X_train, y_train, eval_set[(X_val, y_val)], eval_metricmae, callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)])num_leaves64是经验起点如果数据量大可以调到 128 甚至 256但要注意配合min_child_samples一起调否则容易过拟合。feature_fraction和bagging_fraction都设 0.8 是为了增加模型多样性这对后续融合有帮助。早停的early_stopping(100)表示验证集 MAE 连续 100 轮不下降就停止训练这个值设太小容易欠拟合设太大浪费计算资源。4.2 XGBoost 的差异化配置XGBoost 和 LightGBM 的树生长策略不同前者是 level-wise后者是 leaf-wise。这种差异使得两个模型在融合时能互补。XGBoost 的参数里max_depth 控制树深度subsample 和 colsample_bytree 控制采样比例。import xgboost as xgb xgb_params { objective: reg:absoluteerror, eval_metric: mae, learning_rate: 0.05, max_depth: 7, subsample: 0.8, colsample_bytree: 0.8, reg_alpha: 0.1, reg_lambda: 1.0, n_estimators: 2000, early_stopping_rounds: 100, verbosity: 0 } model_xgb xgb.XGBRegressor(**xgb_params) model_xgb.fit(X_train, y_train, eval_set[(X_val, y_val)], verbose200)XGBoost 的max_depth7比 LightGBM 的 num_leaves 更直观7 层深度大约对应 128 个叶节点。reg_lambda1.0是 L2 正则比 LightGBM 的 0.1 更重这是因为 XGBoost 的树生长方式更容易过拟合需要更强的正则约束。两个模型的验证集 MAE 通常会在 500 到 700 之间具体取决于特征工程的质量。4.3 五折交叉验证与 OOF 预测的生成单次 train_test_split 的评估结果波动较大更可靠的做法是五折交叉验证同时生成 OOFOut-of-Fold预测用于后续融合。OOF 预测是指每个样本的预测值都来自没有见过该样本的模型这样能避免融合时的信息泄漏。from sklearn.model_selection import KFold def get_oof_predictions(model_class, params, X, y, X_test, n_splits5): kf KFold(n_splitsn_splits, shuffleTrue, random_state42) oof_pred np.zeros(len(X)) test_pred np.zeros(len(X_test)) for fold, (tr_idx, val_idx) in enumerate(kf.split(X)): X_tr, X_val X.iloc[tr_idx], X.iloc[val_idx] y_tr, y_val y.iloc[tr_idx], y.iloc[val_idx] model model_class(**params) model.fit(X_tr, y_tr, eval_set[(X_val, y_val)], verboseFalse) oof_pred[val_idx] model.predict(X_val) test_pred model.predict(X_test) / n_splits print(fFold {fold1} MAE: {mean_absolute_error(y_val, oof_pred[val_idx]):.2f}) return oof_pred, test_pred oof_lgb, test_lgb get_oof_predictions(lgb.LGBMRegressor, lgb_params, X, y, test_data[feature_cols]) oof_xgb, test_xgb get_oof_predictions(xgb.XGBRegressor, xgb_params, X, y, test_data[feature_cols])五折交叉验证的折数选择需要权衡折数越多每个模型训练集越大OOF 预测越可靠但计算时间线性增长。5 折是竞赛中的常用折中。注意test_pred是五折模型预测的平均值这本身就是一种简单的 bagging 融合比单模型预测更稳定。5. 避坑与排查那些让我掉过排名的细节5.1 目标编码泄漏导致线上评分暴跌现象本地验证集 MAE 只有 400 多提交线上却掉到 800 开外。原因目标编码时用了全量训练集的均值没有做交叉验证导致验证集的编码值包含了自身价格信息。解决严格按 K 折计算编码验证集的编码值只能用训练折的均值映射测试集用全量训练集均值。这个坑我在第一次做这个比赛时踩过本地和线上差距大到怀疑人生。5.2 日期解析失败导致车龄特征全为 NaN现象构造完 car_age_days 后发现缺失率超过 50%。原因regDate 字段里混有非法日期格式pd.to_datetime默认报错或返回 NaT。解决加errorscoerce参数让非法日期变成 NaT然后对 NaT 的样本用车龄中位数填充同时加一个日期缺失标记列。另外要注意 regDate 的格式可能是 20040402 这种紧凑格式需要显式指定format%Y%m%d。5.3 类别编码顺序不一致导致预测错位现象训练时 label encoding 的映射和测试时不一致模型预测结果完全乱套。原因分别对训练集和测试集做astype(category).cat.codes两边的类别顺序可能不同。解决合并训练集和测试集后再统一编码或者保存训练集的编码映射字典测试集用map应用同一套映射。这个坑在特征工程流水线化之后容易复发每次改代码都要检查。5.4 早停轮数设置不当导致模型欠拟合现象验证集 MAE 还在下降训练就停了最终评分比预期差很多。原因early_stopping(50)设得太小验证集指标在小波动时触发了停止。解决把早停轮数调到 100 到 200同时观察训练日志里的验证集曲线确认是真的收敛而不是随机波动。如果计算资源允许可以直接固定树数量不早停后续通过融合来选最优迭代次数。5.5 特征重要性里发现作弊特征现象某个特征的 importance 异常高但业务上说不通。原因可能是 ID 类字段没删干净或者某个聚合特征的计算范围包含了测试集。解决每次训练后打印 top 20 特征重要性逐个核对业务含义。如果发现可疑特征先删掉再训练看验证集 MAE 是否大幅下降——如果下降说明这个特征确实是泄漏。6. 模型融合与提交从单模型到加权融合的提分技巧模型融合是竞赛提分的最后一步也是性价比最高的一步。我一般会先试简单加权平均再试 Stacking。加权平均的权重可以用验证集 MAE 的倒数来分配也可以用 scipy 的 optimize 模块搜索最优权重。下面这段代码展示了如何用 OOF 预测来搜索最优融合权重。from scipy.optimize import minimize def mae_loss(weights, oof_preds, y_true): weights np.abs(weights) / np.abs(weights).sum() # 归一化 blended np.zeros_like(y_true, dtypefloat) for w, pred in zip(weights, oof_preds): blended w * pred return mean_absolute_error(y_true, blended) # 两个模型的 OOF 预测 oof_preds [oof_lgb, oof_xgb] init_weights [0.5, 0.5] result minimize(mae_loss, init_weights, args(oof_preds, y), methodNelder-Mead) best_weights np.abs(result.x) / np.abs(result.x).sum() print(f最优权重: LightGBM{best_weights[0]:.3f}, XGBoost{best_weights[1]:.3f}) # 融合测试集预测 final_pred best_weights[0] * test_lgb best_weights[1] * test_xgb这段代码的核心是minimize函数它通过迭代搜索让融合后的 MAE 最小。Nelder-Mead方法不需要梯度信息适合这种低维优化问题。注意权重归一化那一步np.abs是为了处理优化过程中可能出现的负权重——负权重在融合中通常意味着模型间存在较强的负相关实际中很少见但加上保护更稳妥。如果两个模型的融合提升有限可以考虑加入第三个模型比如 CatBoost 或者一个简单的 Ridge 回归作为 stacking 的 meta-learner。Stacking 的做法是把 OOF 预测作为新特征再训练一个线性模型来学习最优组合方式。不过要注意Stacking 在小数据集上容易过拟合五折 OOF 的样本量如果不够大加权平均往往更稳。还有一个容易被忽略的技巧对预测结果做后处理。比如把预测价格 clip 到训练集价格的 [min, max] 范围内或者对预测值做轻微的排序调整——如果两辆车的特征几乎一样但预测价格差很多可以取平均。这些后处理操作看起来不起眼但在竞赛后期排名胶着时往往能帮你往前挤几名。我自己打这个比赛最大的教训是特征工程的质量决定了你的下限模型融合决定了你的上限但真正拉开差距的是对数据的理解。那些看起来玄学的提升背后往往是对某个字段业务含义的重新认识。比如我发现 notRepairedDamage 为 1 的车价格分布明显偏低但这个字段的缺失率很高后来我加了一个是否填写了维修记录的二值特征验证集 MAE 直接降了 30 多。这种细节没有捷径只能一遍遍看数据、试特征、验证效果。希望帮到你。本文还有配套的精品资源点击获取
返回列表