简介:阿里天池与Datawhale联合发起的二手车交易价格预测竞赛优胜奖方案,压缩包内包含完整程序代码与项目文档,下载后可直接复现和继续研究。面向计算机科学、应用数学、电子信息工程等专业的学生或科研人员,适合作为课程设计、毕业设计或参与学术竞赛的参考素材;使用者需具备相应的编程基础与数据分析能力,能够理解代码逻辑并进行自主调试与二次开发。压缩包共15个文件,以8个Python脚本为核心,覆盖神经网络特征生成、树模型特征生成、LightGBM/CatBoost模型训练以及Stacking融合等关键环节,另含1个zip备份包、3个zbak备份文件、1个CSV预测结果、1个Markdown说明文档及1个环境依赖清单,整体约610KB,目录结构清晰,便于按模块查阅。已有42人学习下载,对于希望借鉴获奖方案解决同类回归预测问题、快速搭建Baseline并优化模型的读者,有不错的参考价值。
1. 二手车价格预测:一份优胜奖方案源码的拆解与复现路线
第一次接到二手车价格预测这个课程设计时,我差点被“回归”两个字骗了。真正难的不是调模型,而是怎么把品牌、款型、上牌时间、交易时间这些又脏又不齐的特征,变成一套稳定可复现的建模流程。这份资料包来自某数据竞赛平台和一个开源学习社区联合举办的二手车价格预测赛事,里面包含一份优胜奖方案的完整源码与项目说明。压缩包里既有生成特征的脚本,也有 LightGBM、神经网络、残差模型和融合脚本,整体上是从原始数据一路处理到最终提交预测结果的完整链路。
把这套代码拆完一遍后,我发现它特别适合计算机科学、应用数学、电子信息这类专业的学生用于课程设计、毕业设计或竞赛入门。前提是你已经会用 pandas 处理表格、跑过至少一个 sklearn 或 LightGBM 模型。照着复现不会太重,但如果你想改成自己的方案,需要啃透特征生成环节——那才是这套代码的核心价值。
下面我按一个工程师拿到资源后的真实操作顺序来讲:先看懂骨架,再抄特征工程,然后调模型,接着踩坑,最后提交前检查。
2. 先看懂方案骨架:生成、模型、融合三段各管哪一段预测
解压这份资料包之后,最容易让人困惑的是文件名后缀。比如 README.md.zbak 其实内容就是说明文档,只是打包时改了后缀;requirements.txt.zbak 也是同样的味道。真正要动手的代码主要在 code 目录里,命名分工其实很明确:带 generation 的是特征生成脚本,带 model 的是模型脚本,stack+mix 是融合脚本,predictions.csv 是最终的提交结果。
整个方案的执行链路不算长,但每一步都依赖前一步的输出。建议先别急着跑模型,先把代码文件的职责分清楚。
2.1 生成层三个脚本:一份 DataFrame 的三种加工口径
我拿到数据后的习惯是先看“谁在改数据”。这份方案里的 generation.py、Tree_generation.py、NN_generation.py 都干特征生成的活,但服务对象不同。
| 脚本名 | 主要作用 | 输出给谁 |
|---|---|---|
| generation.py | 基础清洗:缺失值、价格下限裁剪、日期转车龄、价格取对数 | 全部下游脚本 |
| Tree_generation.py | 额外构造类别统计特征、目标编码、车型中位数偏差 | LightGBM 和其他树模型 |
| NN_generation.py | 把类别字段映射成 Embedding 索引,对数值字段做标准化 | 神经网络模型 |
先看 generation.py 的一般处理思路:
# generation.py 的基础链路(示意) import pandas as pd import numpy as np df = pd.read_csv('raw_data.csv', parse_dates=['reg_date', 'trade_time']) # 1. 价格下限裁剪:去掉一批接近 0 的异常样本 df = df[df['price'] >= 500] # 2. 回归目标取对数:拉回到近似正态,RMSE 不再被高价车牵着走 df['log_price'] = np.log1p(df['price']) # 3. 里程缺失用同车型中位数回填 df['mileage'] = df.groupby('model')['mileage'].transform( lambda s: s.fillna(s.median()) ) # 4. 车龄精确到天,而不是按年取整 df['car_age_days'] = (df['trade_time'] - df['reg_date']).dt.days这里有几个参数值得琢磨。价格下限设成 500 不是随便写的,二手车交易数据里偶尔会出现几百块钱的“半报废车”,这种样本对 RMSE 的杀伤力比缺失值还大。目标变量取log_price而不是直接用价格,几乎是价格类回归竞赛的标准做法,后面所有模型都是在对数空间里做训练,最后提交前再np.expm1()还原。
车龄按天计算也是有讲究的。很多教程把车龄按年取整,但同一个年份内 1 月和 12 月上牌的车,折旧差异能差出好几千块钱。按天算之后,模型能捕捉到这种“半年内的差异”,对降低残差非常有效。
Tree_generation.py 和 NN_generation.py 并不重写上面这些逻辑,而是在这个基础上叠加不同侧重的特征。我一般会把三个脚本串起来跑一遍,先看中间输出的一致性,再判断哪些特征在模型里真正起了作用。
2.2 模型与融合层:主力、辅助和最终兜底
模型层的五个文件看起来多,实际只有两条主线:
| 脚本名 | 角色 | 特点 |
|---|---|---|
| lgb_model.py | 主力模型 | LightGBM 五折交叉验证,输出 OOF 和测试集预测 |
| nn_model.py | 神经网络模型 | 类别 Embedding + 数值特征 MLP,捕捉非线性交互 |
| cab_model.py | 辅助模型 | 一棵基于类别特征的树模型变体,作为多样性补充 |
| model.py | 基准模型 | 岭回归或简单线性模型,主要用来兜底和对照 |
| stack+mix.py | 融合脚本 | 对多个模型的预测做加权混合或简单 Stacking |
# stack+mix.py 的融合输出示意 import numpy as np # lgb_pred/nn_pred/cab_pred 分别为三组测试集预测 # 先做分位数归一化,把不同模型的预测尺度拉到同一水平 from scipy.stats import rankdata def rank_norm(pred): return rankdata(pred) / len(pred) final_pred = ( 0.5 * rank_norm(lgb_pred) + 0.3 * rank_norm(nn_pred) + 0.2 * rank_norm(cab_pred) ) # 训练时如果目标取了对数,这里要还原成真实价格 final_price = np.expm1(final_pred)这里要注意一个容易被忽略的细节:不同模型的预测值尺度不一样。神经网络如果用了标准化,预测出来的 log_price 范围可能比 LightGBM 更集中;如果直接加权,权重会被尺度牵着走。所以常见做法是先对预测做过 RankGauss 或分位数归一化,再做融合。stack+mix.py 的核心逻辑就是这个,它没有去叠一个复杂的二层模型。
3. 特征工程怎么抄:品牌编码、车龄天数和区域统计的取舍
特征工程是这份方案里信息量最大的部分。二手车价格预测和普通销量预测不一样,价格主要由“品牌溢价 + 款型基准价 + 折旧 + 车况波动”构成。写特征代码前,要先理解这几个成分各自对应哪些字段。
3.1 类别特征:低频合并加目标编码,别直接写 LabelEncoder
品牌、款型、车型这类字段,最大的问题是类别数多且分布极不均衡。某些热门车型占了几千条样本,冷门车型可能只有三五条。如果直接把品牌做 LabelEncoder,等于给模型灌入一堆没有实际意义的整数大小关系;如果做 One-Hot,特征维度会爆炸且全是稀疏列。
我在这套代码里看到的核心处理是两件事:低频合并和目标编码。
# Tree_generation.py 类别特征处理示意 import pandas as pd from sklearn.model_selection import KFold # 1. 低频品牌合并成 other brand_count = df.groupby('brand')['brand'].transform('count') df['brand_clean'] = df['brand'].where(brand_count >= 100, 'other') # 2. 车型目标编码:用 5 折交叉验证避免标签泄漏 df['model_target_enc'] = np.nan kf = KFold(n_splits=5, shuffle=True, random_state=42) for train_idx, valid_idx in kf.split(df): train_df = df.iloc[train_idx] global_mean = train_df['log_price'].mean() # 在每一折训练集内统计车型均价 model_avg = train_df.groupby('model')['log_price'].transform('mean') enc_map = train_df.groupby('model')['log_price'].mean() # 用当前折训练集的均值映射到验证集,防止整表统计造成泄漏 df.loc[valid_idx, 'model_target_enc'] = ( df.loc[valid_idx, 'model'].map(enc_map).fillna(global_mean) )这段代码里的KFold(n_splits=5)是容易抄错的地方。如果先对整个 DataFrame 做groupby求出每个车型的log_price均值,再把这个均值作为特征填回原表,模型在交叉验证时就会看到自己这一折的标签信息,导致本地分数虚高、线上分数翻车。正确做法是每一折内部分别计算编码映射,让验证集样本永远“看不到”自己这一折的标签统计。
低频品牌合并的阈值设为 100 是这个数据集的合理选择。如果样本量更小,建议降到 30 到 50;如果样本量很大,100 也可以接受。注意合并后要重新做 LabelEncoder 或直接保留字符串类别,让树模型自己切分。
3.2 时间特征:交易时间和上牌时间之间的差异才是真正的折旧
很多入门方案只算了一个“车龄 = 当前年份 - 上牌年份”就结束了。这套代码没有止步于此,它把时间差精确到天,并且加了一组相对比例特征:
# 时间与里程交互特征 df['car_age_days'] = (df['trade_time'] - df['reg_date']).dt.days df['car_age_years'] = df['car_age_days'] / 365.25 # 年均里程:长途用车和市区代步的折旧逻辑完全不同 df['mileage_per_year'] = df['mileage'] / (df['car_age_years'] + 1) # 该车价格相对同款车型中位价的偏离程度 model_price_med = df.groupby('model')['price'].transform('median') df['price_dev_from_model'] = df['price'] / (model_price_med + 1) # 交易月份,用来捕捉年底促销、新车上市等周期性波动 df['trade_month'] = df['trade_time'].dt.monthtrade_time和reg_date的差值不只是简单的车龄。同样一辆 2019 年上牌的车,2022 年初卖和 2022 年底卖,价格可能差出 5% 到 8%。按月捕捉交易时间能帮模型识别市场行情波动。
这里有个容易踩的坑:(df['trade_time'] - df['reg_date']).dt.days在缺失日期时会得到 NaT。有些实现图省事直接.fillna(0),结果把缺失样本当成了“当天买当天卖”的新车,模型学出一堆假规律。我通常会先看缺失比例,低于 3% 就直接删除,高于 3% 才考虑用同款型均值填充。
区域字段在这个方案里也被用起来了。常见做法是按省份或城市统计历史平均成交价,然后计算“本车价格相对本区域均价”的偏离度。虽然这份代码没有给出特别复杂的空间特征,但如果你自己复现,建议至少保留城市 ID 的目标编码,因为不同城市的二手车供需差异非常明显。
4. 模型方程:LightGBM 当主力、NN 抓残差、融合抬分数
这套方案的模型部分没有追求花哨结构。LightGBM 作为主力树模型,神经网络作为结构补充,最后用加权融合把两者合起来。
4.1 LightGBM 参数选择:让单模型先把 OOF 压到合理区间
lgb_model.py 里的参数比较保守,符合价格预测这种噪声大、样本量中等场景的调参习惯:
# lgb_model.py 的核心参数 import lightgbm as lgb import numpy as np from sklearn.model_selection import KFold params = { 'objective': 'regression', 'metric': 'rmse', 'learning_rate': 0.03, 'num_leaves': 63, 'max_depth': 7, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l2': 1.0, 'min_child_samples': 40, 'verbosity': -1, } X = df[feature_cols] y = df['log_price'] kf = KFold(n_splits=5, shuffle=True, random_state=42) oof = np.zeros(len(df)) test_pred = np.zeros(len(test_df)) for train_idx, valid_idx in kf.split(X): d_train = lgb.Dataset(X.iloc[train_idx], y.iloc[train_idx]) d_valid = lgb.Dataset(X.iloc[valid_idx], y.iloc[valid_idx]) model = lgb.train( params, d_train, num_boost_round=5000, valid_sets=[d_valid], callbacks=[lgb.early_stopping(200), lgb.log_evaluation(200)] ) oof[valid_idx] = model.predict(X.iloc[valid_idx], num_iteration=model.best_iteration) test_pred += model.predict(test_df, num_iteration=model.best_iteration) / 5几个参数值得对着你自己数据场景改:learning_rate设 0.03 是为了配合 5000 轮迭代和早停;如果你时间紧张,调到 0.05 也能跑出不错结果,但早停轮数建议加大到 300。num_leaves=63对应深度 6 到 7,对几千条样本比较合适。min_child_samples=40是防止树在价格长尾上过拟合的关键,如果特征噪音大,这个值可以提到 60。
我一般会用model.feature_importance('gain')看哪些特征贡献最大。在这个数据集上,car_age_days、mileage、model_target_enc 常年排前三,交易月份和 region 类的统计特征排中间,纯数值的车身尺寸类字段相对靠后。
4.2 神经网络模型:用 Embedding 处理类别,不指望单飞
nn_model.py 的角色不是打败 LightGBM,而是提供一种和树模型结构差异足够大的预测视角。树模型擅长处理表格数值特征,但在品牌、款型这种高基数类别上,Embedding 的泛化方式不一样。
# nn_model.py 的 PyTorch 结构示意 import torch import torch.nn as nn class PriceNet(nn.Module): def __init__(self, n_brand, n_model, num_dim): super().__init__() self.brand_emb = nn.Embedding(n_brand, 16) self.model_emb = nn.Embedding(n_model, 32) self.fc = nn.Sequential( nn.Linear(16 + 32 + num_dim, 128), nn.ReLU(), nn.Dropout(0.2), nn.Linear(128, 1) ) def forward(self, brand, model, num_feat): brand_vec = self.brand_emb(brand) model_vec = self.model_emb(model) x = torch.cat([brand_vec, model_vec, num_feat], dim=1) return self.fc(x)注意这里n_brand和n_model必须和 NN_generation.py 里构建的索引表一致。如果测试集里出现了新品牌,索引映射会直接报错或者映射到 0,导致预测值整体偏移。所以神经网络训练之前,一定要把训练集和测试集的类别做一次取交集,把测试集里独有的类别先映射到unknown。
Embedding 维度在这个方案里没有设得很大。品牌用 16 维,车型用 32 维,原因是样本量不够支撑太宽的表征。如果你有几十万条数据,可以适当翻倍。
4.3 融合脚本:简单加权有时比 Stacking 更稳
stack+mix.py 这个文件名字里有 Stacking,但实际实现仍然是“归一化加加权”。我第一次看到的时候有点意外,后来在自己的数据集上试了一遍才理解:价格预测这种噪声很大的任务,如果样本量只有几千条,Stacking 的第二层模型很容易过拟合第一层模型验证集上的误差。
# stack+mix.py 的权重搜索示意 best_rmse = 1e9 best_w = 0.5 for w in np.arange(0.0, 1.01, 0.05): blend = w * lgb_oof + (1 - w) * nn_oof rmse = np.sqrt(np.mean((blend - y) ** 2)) if rmse < best_rmse: best_rmse = rmse best_w = w权重搜索直接在 OOF 上进行,不碰测试集。如果 lgb_oof 和 nn_oof 都是对数空间的结果,就直接加权;否则先做归一化再搜索。这个搜索粒度是 0.05,够用了,没必要细到 0.01,因为第一层模型的误差本身就带着不小的随机波动。
如果你只有两个模型,上面这套就已经足够。如果你手上还有 catboost 之类的第三个单模型,建议按“先合并两个相似模型,再融合另一个差异模型”的顺序来做,而不是一次性给三个模型分配权重。
5. 复现避坑:切分泄漏、价格长尾和线上分数对不上的三个教训
这部分是我自己复现时踩过的坑。每个坑都配一个排查思路,你在自己的项目里遇到类似问题时,可以直接对照着处理。
5.1 本地 CV 和线上分数一直对不上:先怀疑切分污染
现象:本地五折交叉验证 RMSE 是 0.42,看着很漂亮,提交到线上分数直接掉到 0.58,排名大幅后退。查了一遍特征,没有发现明显错误。
原因:问题通常出在切分策略上。很多入门代码直接使用KFold(shuffle=True),把不同交易月份的样本随机分到同一折里。但竞赛测试集通常是未来某个时间段的数据,本地随机切分会让模型“偷看”到未来行情,本地分数自然虚高。另外,目标编码如果先对全量训练集做groupby,也会造成预期外的标签泄漏。
解决:换用按时间顺序的切分,或者在切分时按trade_month做分组。
# 按交易月份顺序切分的参考写法 months = np.sort(df['trade_time'].dt.to_period('M').astype(str).unique()) split_idx = int(len(months) * 0.8) train_months = months[:split_idx] valid_months = months[split_idx:] train_mask = df['trade_time'].dt.to_period('M').astype(str).isin(train_months) valid_mask = df['trade_time'].dt.to_period('M').astype(str).isin(valid_months)本地验证和线上分数的合理差距应该在 0.02 到 0.05 之间。如果差距超过 0.1,先别调参,直接检查特征生成过程里有没有用到全量数据统计量,以及是否有人把目标编码写在了交叉验证外层。
5.2 价格长尾:直接回归原价被一批高端车带偏
现象:模型在普通家用车上的误差很小,但整体 RMSE 一直压不下来。画出预测值和真实值的散点图,发现误差集中在一批价格非常高的车型上。
原因:二手车价格分布天然是长尾的。大部分交易集中在 5 到 20 万区间,但存在少量上百万的豪华车或经典车。RMSE 对大误差给予平方惩罚,几个极端样本就能把整体指标拉高。
解决:这个方案给出的做法很直接,训练时把价格取对数。我复现后做了一组对照实验,同一套特征,用log_price训练比直接回归原价在 RMSE 上能低 10% 以上。需要注意提交时的还原操作和评价指标仍然基于真实价格,所以最终提交文件里必须做np.expm1()还原。
另外,对价格做下限裁剪也很有必要。数据里偶尔会出现几百块钱的僵尸数据,这些样本在取对数后依然是一群“孤立点”,会影响树模型的分裂点选择。我常用clip(lower=500, upper=None),也就是只裁下界,不裁上界,因为真实存在的高价车是有价值的样本。
5.3 测试集出现训练集没见过的类别:预测结果大范围失真
现象:跑完预测后,pred_labels里出现了大量 NaN,或者某个品牌的所有预测值都比实际低一截。提交后分数异常低,甚至报格式错误。
原因:我在生成目标编码特征时,把model字段的编码字典直接建立在训练集上。测试集里出现一个训练集没见过的车型时,map返回 NaN,后续树模型没有针对缺失值做特殊分支,预测值就偏了。
解决:在生成编码字典时显式处理 unseen 类别。最稳妥的方法是给每个类别编码表加一个global_mean兜底键。这也是 Tree_generation.py 里那段.fillna(global_mean)的作用。
同样的坑也会出现在神经网络里。NN_generation.py 建立品牌 ID 映射时,必须把训练集和测试集的类别取并集,让未知类别落到unknown编号;否则测试阶段 Embedding 查不到对应行,程序直接崩溃。
5.4 提交文件行数对不上:所有努力毁在最后一步
现象:模型跑通了,OOF 分数也不错,但提交时系统提示行数不一致,或者预测结果整体错位。
原因:最常见的原因是reset_index和merge导致索引错位。test_pred是按数据顺序输出的,但如果你在特征处理过程中对测试集做过筛选、填充或排序,行顺序早就变了。
解决:提交前强制按测试集的 ID 做对齐,不要相信数组下标。
# 提交前的最后对齐 submission = pd.read_csv('sample_submission.csv') submission = submission[['id']].copy() submission['price'] = np.expm1(test_pred) submission.to_csv('predictions.csv', index=False)这道工序看起来简单,但每次都能拦住一批“明明训练没问题,提交却翻车”的人。我现在的习惯是:跑完 stack+mix.py 之后,立刻检查len(test_pred)和测试集行数是否相等,再做一次submission['price'].isnull().sum(),全部为 0 才敢提交。
6. 提交前再抠一分:五折 OOF 排序与加权混合的完整流水线
最后这步是我从这套方案里学到最有用的习惯,也是你拿这份源码做自己的项目时可以立即复用的技巧:把“模型训练”和“提交文件生成”做成两个完全分离的阶段。训练阶段只输出两个东西——OOF 预测和测试集预测;提交阶段只干一件事——处理行顺序、还原价格、检查缺失。
第一个技巧是提交文件生成时尽量用.loc按 ID 对齐,而不是pd.concat后直接写文件。因为测试集的原始顺序可能被特征工程阶段的各种 merge 打乱。哪怕这次没乱,保不齐哪次就乱了。
# 提交流水线最后一段 sample = pd.read_csv('sample_submission.csv') sample['price'] = final_pred_df.loc[sample['id'].values, 'pred'].values sample['price'] = sample['price'].clip(lower=500) sample.to_csv('predictions.csv', index=False)第二个技巧我在本章开头提到过:融合权重不要拍脑袋。你可以创建一个 0 到 1 的网格,只在 OOF 上搜索最佳混合权重,然后用这个权重去混合测试集预测。搜索次数不用太多,几十次就够。
# OOF 上的权重搜索 best_score = 1e9 best_w = 0.5 for w in np.arange(0.1, 1.0, 0.05): mix = w * rank_norm(lgb_oof) + (1 - w) * rank_norm(nn_oof) score = np.sqrt(np.mean((mix - y) ** 2)) if score < best_score: best_score = score best_w = w第三个技巧是提交前看预测分布。把predictions.csv里的价格做一个分位数统计,如果中位数明显低于训练集价格中位数,说明你的模型很可能把价格整体低估了。这种情况经常出现在训练时裁剪过异常值、但测试集没有裁剪的场景里。解决办法是给预测结果整体乘一个校准系数,或者检查是否存在类别缺失没有兜底。
从那以后,我每次拆竞赛源码都强制走一遍这个流程:先读 README 搞清脚本依赖,再跑生成脚本验证中间输出,然后复现单模型 OOF,最后用融合脚本生成并按 ID 对齐提交文件。等这一整套跑通,再回头调参数和特征,才能判断改动带来的提升是不是真的、有没有引入新的泄漏。希望帮你少走这段弯路。
本文还有配套的精品资源,点击获取