news 2026/9/26 11:23:52

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
二手车价格预测竞赛:数据清洗、特征工程与模型融合实战

简介:这份资料包是阿里天池与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', ascending=False)) 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_index=True) data = data.drop(['SaleID'], axis=1) # 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(lower=100, upper=data['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_splits=5, shuffle=True, random_state=42) 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', errors='coerce') data['creatDate'] = pd.to_datetime(data['creatDate'], format='%Y%m%d', errors='coerce') # 车龄:注册到创建(发布)之间的天数 data['car_age_days'] = (data['creatDate'] - data['regDate']).dt.days data['car_age_days'] = data['car_age_days'].clip(lower=0, upper=365*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_on='brand', right_index=True, how='left') # 型号在品牌内的相对位置 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'], how='left')

聚合特征的计算必须严格限定在训练集上,否则测试集的信息会泄漏到训练过程中,导致线上评分虚高。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_size=0.2, random_state=42) # 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_metric='mae', callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)])

num_leaves=64是经验起点,如果数据量大可以调到 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)], verbose=200)

XGBoost 的max_depth=7比 LightGBM 的 num_leaves 更直观,7 层深度大约对应 128 个叶节点。reg_lambda=1.0是 L2 正则,比 LightGBM 的 0.1 更重,这是因为 XGBoost 的树生长方式更容易过拟合,需要更强的正则约束。两个模型的验证集 MAE 通常会在 500 到 700 之间,具体取决于特征工程的质量。

4.3 五折交叉验证与 OOF 预测的生成

单次 train_test_split 的评估结果波动较大,更可靠的做法是五折交叉验证,同时生成 OOF(Out-of-Fold)预测用于后续融合。OOF 预测是指每个样本的预测值都来自没有见过该样本的模型,这样能避免融合时的信息泄漏。

from sklearn.model_selection import KFold def get_oof_predictions(model_class, params, X, y, X_test, n_splits=5): kf = KFold(n_splits=n_splits, shuffle=True, random_state=42) 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)], verbose=False) oof_pred[val_idx] = model.predict(X_val) test_pred += model.predict(X_test) / n_splits print(f'Fold {fold+1} 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。解决:加errors='coerce'参数让非法日期变成 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, dtype=float) 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), method='Nelder-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 多。这种细节没有捷径,只能一遍遍看数据、试特征、验证效果。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 11:23:31

智慧乡村旅游小程序毕设:SSM+微信小程序+MySQL全栈搭建指南

简介:面向计算机专业毕业设计的智慧乡村旅游服务平台小程序资源包,基于微信小程序、SSM 框架与 MySQL 数据库构建,涵盖管理员、用户、商家三类角色,完整覆盖旅游景点管理、路线规划、订单处理、我的收藏、账户充值、购物车、我的订…

作者头像 李华
网站建设 2026/9/26 11:22:20

Atlas 300V 24G推理卡部署YOLO实战:从模型转换到性能优化

1. Atlas 300V 24G到底算什么卡,先说大方向Atlas 300V 24G这名字,放在做AI部署的圈子里,基本等同于一个很多人都在问的问题:国产推理卡到底好不好用?我见过不少项目组把这卡和某知名推理卡放在同一张采购对比表里&…

作者头像 李华
网站建设 2026/9/26 11:22:15

在线SPC质量分析系统落地:控制图、判异规则与Python实现

简介:面向制造企业质量管理的在线统计过程控制(SPC)毕业设计,提供一套可运行的产品质量在线分析系统。系统围绕控制图绘制、过程稳定性判定和异常报警展开,涵盖数据采集、统计分析与可视化界面,适用于需要学…

作者头像 李华
网站建设 2026/9/26 11:21:48

基于YOLOv8的智慧果园避障割草机器人毕设资源解析

简介:一份基于YOLOv8的智慧果园避障割草机器人项目,适合计算机视觉、人工智能方向的毕业设计或课程设计。资源已通过运行测试,包含完整源码、训练好的pt权重、可视化交互界面、完整数据集与部署说明,简单部署即可运行,…

作者头像 李华
网站建设 2026/9/26 11:21:20

农业AI毕业设计实战:小样本水稻病害识别与轻量化部署

简介:本资源是一套完整的农作物病虫害智能识别毕业设计项目,面向计算机、农林信息化及人工智能方向的本科生与初学者,解决农业场景中病害图像分类与模型部署的实际问题。压缩包共56个文件,包含15张示例PNG图像、9个Jupyter Notebo…

作者头像 李华
网站建设 2026/9/26 11:20:52

JEV实战:桥接PostgreSQL与RAG,提升AI Agent召回率

1. 从一次深夜调试说起:JEV 到底解决了什么问题第一次听到 JEV 这个词,是在一个做 AI Agent 项目的朋友群里。当时有人甩了一张截图,说“这个 JEV 把我们的 RAG 召回率从 62% 拉到了 89%”,群里瞬间炸了锅。我当时的反应是&#x…

作者头像 李华