ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

二手车价格预测实战:LightGBM建模、分布偏移与避坑指南

二手车价格预测实战:LightGBM建模、分布偏移与避坑指南

简介:一个基于机器学习Python的二手车交易预测评估系统项目源码与数据集合集,面向毕业设计、课程设计或机器学习入门人群,聚焦二手车价格预测场景,完整覆盖数据读取、特征处理、模型训练与评估等环节。资源包共10个文件,含3个Python脚本、3个CSV数据集、2个Markdown说明文档及2个Jupyter Notebook,压缩包整体约29.57MB;其中CSV提供训练集、测试集与提交样例,Notebook记录了从数据探索到建模验证的实践过程,便于对照运行和二次开发。目前已有278人学习使用。借助它可快速掌握一套可运行的二手车价格预测项目框架,理解机器学习解决回归问题的典型流程,并复用源码与数据完成类似课题或深化实验。

1. 二手车价格预测:这份源码包到底能帮你把预测做到什么程度

拿到old_car_price-master.zip的第一眼,我注意到的是训练集文件名used_car_train_20200313.csv和测试集文件名used_car_testB_20200421.csv——这两个日期差了整整一个多月。对二手车价格预测来说,时间差就是信息差,市场行情在一个月里可能已经变了好几轮。这份资源是一套完整的机器学习二手车交易预测评估系统,核心是一份 Jupyter Notebook 和三个 CSV 数据集,数据是典型的匿名特征格式(v_0到v_12这种列名),特征含义被脱敏处理过,这在二手车价格预测比赛里很常见。它要解决的核心问题很直接:给定一辆车的匿名特征,预测它的交易价格。适合的人群也很明确:正在做机器学习毕业设计的学生、想快速拿到一套 baseline 跑通的开发者,以及想研究匿名表格数据特征工程和树模型调参的从业者。

2. 数据集结构剖析:先从 CSV 字段分布判断该用什么模型

2.1 三个 CSV 的分工与字段差异

解压后能看到三个 CSV 文件:used_car_train_20200313.csv是训练集,used_car_testB_20200421.csv是测试集,used_car_sample_submit.csv是提交样例。训练集带price标签列,测试集没有,提交样例告诉你最终预测结果应该长什么样。

import pandas as pd train = pd.read_csv('used_car_train_20200313.csv') test = pd.read_csv('used_car_testB_20200421.csv') sub = pd.read_csv('used_car_sample_submit.csv') print('训练集形状:', train.shape) print('测试集形状:', test.shape) print('提交样例形状:', sub.shape) print('\n训练集列名:') print(train.columns.tolist()) print('\n提交样例前5行:') print(sub.head())

这段代码把所有数据载入后先看形状和列名。训练集形状应该是(150000, 32)左右,测试集在(50000, 31)左右,提交样例是(50000, 2)。为什么要先看这些:因为匿名特征数据集里,训练集和测试集的列数不一致是常见坑——测试集通常会少掉某一列。

训练集的列主要分三类:name(车型名称)、regDate(注册日期)、model(车型编码)这类偏结构化的字段,brand、bodyType、fuelType、gearbox这类类别字段,以及v_0到v_12这类匿名数值特征。power是发动机功率,kilometer是行驶里程,notRepairedDamage是是否有未修复损伤——这一列比较特殊,包含'-'值,读取后会变成 NaN 或字符串,需要专门处理。

2.2 为什么这套资源适合用树模型而不是线性回归

匿名数值特征、混合类型字段、存在缺失值——这三个特点叠加,线性回归基本可以放弃。特征被脱敏后,你没法确认v_5到底是排量还是轴距,线性模型要求特征与目标有可解释的线性关系,这在匿名特征下无从保证。而树模型对特征单调变换不敏感,对异常值鲁棒,还能自动处理非线性交互。

我一般会先看缺失比例和类别字段的基数:

missing_ratio = train.isnull().mean().sort_values(ascending=False) print('缺失比例 Top 10:') print(missing_ratio[missing_ratio > 0]) cat_cols = ['name', 'brand', 'model', 'bodyType', 'fuelType', 'gearbox'] for col in cat_cols: print(f'{col}: 类别数 {train[col].nunique()}')

缺失比例的计算决定了后续是填充还是删除。notRepairedDamage的缺失如果比例超过 5%,常见的处理是将'-'映射为1.0,正常值映射为0.0,缺失值单独填2.0;bodyType、fuelType这类字段缺失就直接填众数。name的类别数如果超过 2000,就不适合直接做独热编码,否则稀疏矩阵会让训练速度崩掉,model的类别数也类似。

2.3 价格标签的分布:先对price做对数变换

二手车价格分布是典型的右偏:几万块的车占大多数,上百万的豪车只是长尾。直接拿原始价格训练,模型会被长尾样本牵着走,MAE 会被少数高价车拉高。

import numpy as np import matplotlib.pyplot as plt train['price_log'] = np.log1p(train['price']) print('原始价格偏度:', train['price'].skew()) print('对数价格偏度:', train['price_log'].skew()) fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].hist(train['price'], bins=50, edgecolor='white') axes[0].set_title('Original Price Distribution') axes[1].hist(train['price_log'], bins=50, edgecolor='white') axes[1].set_title('Log Price Distribution') plt.tight_layout() plt.show()

这段代码对比了原始价格和对数价格的分布形态。np.log1p是对price + 1取自然对数,好处是price为 0 时也能计算。偏度值能直接量化分布形态:偏度大于 1 就是强右偏,取对数后偏度会明显下降。注意这里有个关键决策:最后提交时要做逆变换np.expm1把预测值还原回真实价格,但还原后存在偏差校正问题,这个我在后面章节展开。训练目标从price换成price_log之后,模型的优化目标从「绝对价格误差最小化」变成「对数空间误差最小化」,等价于在原始空间优化相对误差,对长尾样本更友好。

3. 基于 LightGBM 的 baseline:完整训练流程与参数解析

3.1 特征工程的边界:哪些该做,哪些不该做

匿名特征v_0到v_12不宜做过多手工特征,这些列已经经过脱敏,可能是 PCA 降维结果也可能是原始数值直接挪过来,你构造v_0 / v_1这种比值特征,在验证集上看到提升很可能是过拟合。我一般只做两类操作:v_列的缺失值统一填-1或者中位数,以及把类别字段转成数值编码。

train['notRepairedDamage'] = train['notRepairedDamage'].replace('-', '2').astype(float) test['notRepairedDamage'] = test['notRepairedDamage'].replace('-', '2').astype(float) for col in ['bodyType', 'fuelType', 'gearbox']: train[col] = train[col].fillna(train[col].mode()[0]) test[col] = test[col].fillna(test[col].mode()[0]) for col in ['model', 'brand']: train[col] = train[col].astype(int) test[col] = test[col].astype(int) feature_cols = [col for col in train.columns if col not in ['price', 'price_log', 'SaleID']] X_train = train[feature_cols] y_train = train['price_log'] X_test = test[feature_cols]

这段代码做了三类基础处理:notRepairedDamage的三态映射、类别字段众数填充、类别字段类型强转。这里有个关键点:train和test的特征列必须完全一致,否则 LightGBM 训练会直接报特征数量不匹配。name列我暂时不编码,因为类别基数太大对树模型没有正向收益,LightGBM 原生支持类别特征,直接把name作为类别特征传入可以避免手工编码的稀疏问题。

3.2 训练集划分:时间序列数据不能随机打乱

二手车价格数据是带时间戳的,训练集收集于 3 月 13 日之前,测试集在 4 月 21 日之后。如果随机打乱训练集来划分验证集,你会把三月份的数据和四月份的数据混在一起,模型会「偷看」到未来的价格趋势。正确做法是按时间顺序切分:用前 80% 的数据训练,后 20% 的数据验证。

from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error train_data = train.sort_values('regDate').reset_index(drop=True) split_idx = int(len(train_data) * 0.8) X_tr, X_val = X_train.iloc[:split_idx], X_train.iloc[split_idx:] y_tr, y_val = y_train.iloc[:split_idx], y_train.iloc[split_idx:] print(f'训练样本数: {len(X_tr)}, 验证样本数: {len(X_val)}')

先按注册日期排序再切分,这样能模拟真实场景:用过去预测未来。随机打乱会让你得到虚高的验证分数,但线上测试集一跑就原形毕露——这是换不回头的坑。regDate本身是类似20150501的整数格式,直接排序没问题,不需要转成日期类型。

3.3 LightGBM 训练:参数怎么定,早停怎么用

import lightgbm as lgb params = { 'objective': 'regression', 'metric': 'mae', 'learning_rate': 0.05, 'num_leaves': 63, 'max_depth': 7, 'min_child_samples': 20, 'feature_fraction': 0.8, 'bagging_fraction': 0.8, 'bagging_freq': 1, 'lambda_l1': 0.1, 'lambda_l2': 1.0, 'verbose': -1 } d_train = lgb.Dataset(X_tr, label=y_tr) d_val = lgb.Dataset(X_val, label=y_val, reference=d_train) model = lgb.train( params, d_train, num_boost_round=3000, valid_sets=[d_val], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)] )

num_leaves设 63 而不是默认的 31,因为二手车价格预测的特征交互比较强,小叶子数容易欠拟合,但也不能再往上加到 127,否则训练集上很快过拟合。min_child_samples设 20 是防止叶子节点样本太少,在匿名特征数据上这个值低于 10 会出现验证分数波动很大的现象。feature_fraction和bagging_fraction都是 0.8,双重随机化能降低方差。早停轮数设 100,意思是一百轮内验证集 MAE 没有下降就直接停止。

为什么验证指标用 MAE 而不是 RMSE:这个数据集的标签经过了 log 变换,MAE 在 log 空间里更稳定,RMSE 对长尾敏感度太高,个别高价车的误差会主导整个指标。如果你是做毕业设计,最后报告里忠实记录训练过程的 MAE 和验证 MAE 就够了。

3.4 预测与提交:格式对齐和逆变换

val_pred = model.predict(X_val, num_iteration=model.best_iteration) val_mae = mean_absolute_error(y_val, val_pred) print(f'验证 MAE(log 空间): {val_mae:.4f}') test_pred_log = model.predict(X_test, num_iteration=model.best_iteration) test_pred = np.expm1(test_pred_log) submission = pd.DataFrame({ 'SaleID': test['SaleID'], 'price': test_pred }) submission.to_csv('submit_lightgbm.csv', index=False) print(submission.head())

注意提交的分数的数量级:log 空间 MAE 0.10 左右对应原始空间约 10% 的相对误差,看起来不错,但实际高价车的绝对误差依然很大,这是 MAE 指标本身的局限。np.expm1恢复原始价格后,会出现预测系统性偏低的问题,原因在下一章讲。

参数这块,我一般会先跑一组默认参数看效果,然后单独调num_leaves、调learning_rate,每次只动一个参数。常见做法是用optuna做贝叶斯搜索,但在这个数据规模下(15 万样本),手动调 3 到 4 轮就足够接近最优了,跑一遍网格搜索的时间成本不划算。

4. 分布偏移与时间差处理:为什么直接用 baseline 会在测试集上翻车

4.1 训练集和测试集之间的两个隐藏差异

训练集文件名里的20200313和测试集里的20200421不是随手写的日期。二手车价格是强时间敏感数据,两个数据集之间存在两个结构性差异:一是市场行情变化,三月和四月的价格基准不同;二是数据采集规则可能微调。

train['regDate_year'] = (train['regDate'] // 10000).astype(int) test['regDate_year'] = (test['regDate'] // 10000).astype(int) print('训练集注册年份分布:') print(train['regDate_year'].value_counts().sort_index().head()) print('测试集注册年份分布:') print(test['regDate_year'].value_counts().sort_index().head())

对比两边的注册年份分布,如果发现测试集里regDate_year有训练集完全没有的年份,说明测试集里有一部分新车数据,模型从没见过这些年份的样本。

但更关键的是匿名特征v_0到v_12的分布差异:

for col in ['v_0', 'v_1', 'v_2', 'v_3']: train_mean = train[col].mean() test_mean = test[col].mean() diff = (test_mean - train_mean) / train_mean print(f'{col}: 训练集均值 {train_mean:.3f}, 测试集均值 {test_mean:.3f}, 相对差异 {diff:.4f}')

如果某个匿名特征的均值在测试集里偏移超过 5%,那这个特征在训练集上学到的规律在测试集上就不可靠。相比直接删掉这个特征,更好的做法是把它加入验证集分布差异检查清单——每次调参后看验证集分数和提交分数的差距是不是系统性偏大。

4.2 对抗验证:量化分布偏移程度

对抗验证是检测训练集和测试集分布差异的常用方法:把训练集和测试集拼在一起,打上标签(训练集为 0,测试集为 1),训练一个二分类模型看能不能把两者区分开。如果分类准确率接近 100%,说明两者差异非常大。

from sklearn.model_selection import cross_val_score import lightgbm as lgb df_train = train[feature_cols].copy() df_test = test[feature_cols].copy() df_train['label'] = 0 df_test['label'] = 1 concat_df = pd.concat([df_train, df_test], axis=0, ignore_index=True) X = concat_df[feature_cols] y = concat_df['label'] clf = lgb.LGBMClassifier(n_estimators=100, learning_rate=0.1, num_leaves=31) scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy') print(f'对抗验证准确率: {scores.mean():.4f} (+/- {scores.std():.4f})')

对抗验证准确率超过 0.8,说明分布偏移明显,模型在训练集上表现再好,测试集上的效果也会打折扣。此时要考虑的应对手段包括:训练集直接包含 3 月的全部数据和 4 月前几天的新数据,然后做时间加权——给靠近测试集日期的样本更高的权重。

4.3 时间加权训练:让模型更重视近期样本

import datetime def date_weight(date_int): year = date_int // 10000 month = (date_int // 100) % 100 day = date_int % 100 try: d = datetime.date(year, month, day) except: return 1.0 base = datetime.date(2020, 3, 13) days = (base - d).days if days < 0: return 1.0 return np.exp(-days / 180.0) train['weight'] = train['regDate'].apply(date_weight)

这个时间衰减权重对越早的样本权重越低,半衰期设 180 天,意味着半年前的车权重只有新车的 60% 左右。权重列传入 LightGBM 的Dataset参数即可:lgb.Dataset(X_tr, label=y_tr, weight=train['weight'].values[:split_idx])。

但要提醒的是,时间加权不是万能药。如果对抗验证准确率在 0.7 以下,说明分布偏移不明显,此时时间加权反而会减少训练数据量,拖低模型效果。我一般先跑对抗验证,准确率超过 0.8 才启用时间加权。

4.4 伪标签:用测试集数据反补训练

分布偏移的另一条处理路径是伪标签:用当前模型预测测试集,把置信度高的样本连同预测结果一起加入训练集。这个方法有上手门槛——预测值本身可能带模型偏差,质量差的伪标签反而会放大偏移。

test_pred_all = model.predict(X_test, num_iteration=model.best_iteration) test_copy = test.copy() test_copy['pseudo_label'] = np.expm1(test_pred_all) pseudo_threshold = 0.5 test_copy['confidence'] = np.abs(test_pred_all - test_copy['pseudo_label']) / test_copy['pseudo_label'] pseudo_data = test_copy[test_copy['confidence'] < pseudo_threshold] train_augmented = pd.concat([train, pseudo_data], axis=0, ignore_index=True) print(f'伪标签样本数: {len(pseudo_data)}, 训练集扩充后: {len(train_augmented)}')

伪标签的置信度阈值怎么定:先用 0.5 的中位数估计,进一步参考预测分布和训练集真实分布的重叠度,重叠度高就降低到 0.3,重叠低就提高到 0.7。伪标签适合在模型效果已经稳定的最后阶段加入,不要从第一轮就混进去。

5. 避坑指南:五个翻车现场与处理办法

5.1 notRepairedDamage 的缺失值和字符串混合

现象:notRepairedDamage列读取后 dtype 是object,里面混着0.0、1.0和-,直接喂给 LightGBM 报数据类型错误。

原因:数据采集时未修复损伤用'-'表示缺失,pandas 把它读成字符串。如果不处理,树模型会把'-'当独立类别,让它和数值 0、1 并列,这没有业务含义。

解决:用替换把'-'映射为2.0,含义是「未知」。缺失比例不高时不建议填 0 或 1,因为业务上「有损伤」和「无损伤」是二分类,「未知」应该独立表示。

5.2 log 逆变换后预测值系统性偏低

现象:np.expm1(model.predict(...))得到的预测中位数低于真实价格中位数,低价车误差小,高价车误差能差到两三万。

原因:在 log 空间用 MAE 优化,等价于在原始空间优化相对误差,但逆变换时exp的非线性会引入偏差——log 空间的均值对应原始空间的中位数,而不是均值。这是一个数学性质,不是模型参数问题。

解决:加一个偏差校正系数。我一般会算验证集上预测值和真实值的比值中位数:

val_ratio = np.expm1(val_pred) / np.expm1(y_val) correction = np.median(val_ratio) test_pred_corrected = test_pred * correction

这个校正系数通常在 1.01 到 1.03 之间,用比值中位数而不是均值是为了避免长尾样本的干扰。注意这个系数要在验证集上估计,不要在测试集上估计,否则就是作弊。

5.3 name 列直接做独热编码把模型拖垮

现象:把name独热编码后,训练时间从 30 秒变成 10 分钟,验证分数反而下降。

原因:name类别数如果上千,独热编码产生几千列稀疏特征,LightGBM 训练时分裂点搜索开销大增,且稀疏矩阵会让feature_fraction采样时大概率抽到零值占多数的特征,信息量被稀释。

解决:直接把name作为类别特征传给 LightGBM,或者做 count encoding(每个名称出现的次数)然后标准化。count encoding 在这个数据集上效果一般,我最终用的是 LightGBM 原生类别特征,速度最快且不会制造稀疏矩阵。

5.4 训练集和测试集列数量不一致导致训练失败

现象:训练时一切正常,预测测试集时报Number of features in predict() does not match错误。

原因:测试集比训练集少一列。这个数据集的测试集里notRepairedDamage列很可能被整体读成了 NaN 或缺失,列名存在但值全空,或者直接被 drop 掉了。

解决:训练前就检查列对齐:

missing_in_test = set(X_train.columns) - set(X_test.columns) print('测试集缺失列:', missing_in_test)

如果确实缺失,在测试集上补全列并填默认值。更保险的做法是训练前先assert list(X_train.columns) == list(X_test.columns),把这个检查写进训练脚本里,不通过就报错停止。

5.5 按 regDate 排序切分验证集后忘记打乱

现象:早停轮数设 100,结果 30 轮就停了,验证 MAE 一直下不去,训练曲线在验证集上跳得厉害。

原因:按regDate排序切分后,lgb.train默认按传入顺序逐轮训练,每一步梯度更新看到的是按时间排列的样本,每个 batch 内的样本分布不均匀——你得到一个时间连续性很强的验证集,模型在这个验证集上不稳定。

解决:虽然验证集按时间切分,但训练时要打乱训练集内部顺序:

shuffle_idx = np.random.RandomState(42).permutation(len(X_tr)) X_tr_shuffled = X_tr.iloc[shuffle_idx] y_tr_shuffled = y_tr.iloc[shuffle_idx]

或者用lgb.Dataset(..., shuffle=True)参数。这是最容易忽略的一步:验证策略按时间,训练内部随机化,两者并不矛盾。

6. 模型融合与参数微调:把验证 MAE 再压 0.005 的实操技巧

最后一个阶段,我会做模型融合和参数微调。LightGBM baseline 的验证 MAE 大概在 0.10 左右,用 XGBoost 做第二份预测,然后加权融合,通常能再压 0.005 到 0.01。这个提升在比赛里可能无关痛痒,但作为毕业设计的亮点,足够支撑「我在 baseline 基础上做了优化」这一章。

import xgboost as xgb xgb_params = { 'objective': 'reg:squarederror', 'eval_metric': 'mae', 'learning_rate': 0.05, 'max_depth': 6, 'subsample': 0.8, 'colsample_bytree': 0.8, 'min_child_weight': 10, 'lambda': 1.0 } d_xgb_tr = xgb.DMatrix(X_tr, label=y_tr) d_xgb_val = xgb.DMatrix(X_val, label=y_val) d_xgb_test = xgb.DMatrix(X_test) xgb_model = xgb.train( xgb_params, d_xgb_tr, num_boost_round=3000, evals=[(d_xgb_val, 'val')], early_stopping_rounds=100, verbose_eval=100 )

XGBoost 和 LightGBM 在匿名特征上的表现差异主要来自分裂点搜索策略:LightGBM 用直方图算法,XGBoost 用预排序近似算法,两者在特征交互的捕捉上各有侧重。融合时用验证集找最优权重:

from scipy.optimize import minimize lgb_val_pred = model.predict(X_val, num_iteration=model.best_iteration) xgb_val_pred = xgb_model.predict(d_xgb_val) def loss(w): blend = w * lgb_val_pred + (1 - w) * xgb_val_pred return mean_absolute_error(y_val, blend) result = minimize(loss, x0=0.5, bounds=[(0, 1)]) best_w = result.x[0] print(f'最优融合权重(LightGBM 占比): {best_w:.3f}') lgb_test_pred = model.predict(X_test, num_iteration=model.best_iteration) xgb_test_pred = xgb_model.predict(d_xgb_test) blend_pred_log = best_w * lgb_test_pred + (1 - best_w) * xgb_test_pred blend_pred = np.expm1(blend_pred_log) * correction

scipy.optimize.minimize在验证集上搜索 LightGBM 预测和 XGBoost 预测的最优混合比例,目标函数是验证 MAE。这样得到的权重比固定 0.5 更可靠,因为两个模型在不同价格区间各有所长。

参数微调阶段的顺序有讲究。先用learning_rate=0.05跑通全流程,确认验证 MAE 稳定,再逐步降到 0.03 并调大num_boost_round到 5000,早停轮数也相应加到 200。num_leaves从 63 试到 95,每次只改一个参数,观察验证 MAE 变化。验证集分数下降但提交分数上升的反直觉情况也出现过——原因是对抗验证显示分布偏移较大,模型在时间加权后对近期样本更敏感,验证集是时间靠后的部分,天然更接近测试集分布。

我从这个项目里学到的最大教训是:模型通过对比验证分数和提交分数的差值来判断自己有没有过拟合到训练集——差值在 0.005 以内说明正常,超过 0.01 就该怀疑分布偏移了。从那以后,我每次做这类带时间戳的价格预测,拿到数据第一件事就是看训练集和测试集日期跨度,再做对抗验证,最后才跑 baseline。数据的时间属性从来不是一行元数据,而是决定整个建模策略的第一约束条件。希望这份拆解对你有帮助。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进