
简介一个基于机器学习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(ascendingFalse) 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.0bodyType、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], bins50, edgecolorwhite) axes[0].set_title(Original Price Distribution) axes[1].hist(train[price_log], bins50, edgecolorwhite) 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(dropTrue) 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, labely_tr) d_val lgb.Dataset(X_val, labely_val, referenced_train) model lgb.train( params, d_train, num_boost_round3000, 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_iterationmodel.best_iteration) val_mae mean_absolute_error(y_val, val_pred) print(f验证 MAElog 空间: {val_mae:.4f}) test_pred_log model.predict(X_test, num_iterationmodel.best_iteration) test_pred np.expm1(test_pred_log) submission pd.DataFrame({ SaleID: test[SaleID], price: test_pred }) submission.to_csv(submit_lightgbm.csv, indexFalse) 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], axis0, ignore_indexTrue) X concat_df[feature_cols] y concat_df[label] clf lgb.LGBMClassifier(n_estimators100, learning_rate0.1, num_leaves31) scores cross_val_score(clf, X, y, cv5, scoringaccuracy) 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, labely_tr, weighttrain[weight].values[:split_idx])。但要提醒的是时间加权不是万能药。如果对抗验证准确率在 0.7 以下说明分布偏移不明显此时时间加权反而会减少训练数据量拖低模型效果。我一般先跑对抗验证准确率超过 0.8 才启用时间加权。4.4 伪标签用测试集数据反补训练分布偏移的另一条处理路径是伪标签用当前模型预测测试集把置信度高的样本连同预测结果一起加入训练集。这个方法有上手门槛——预测值本身可能带模型偏差质量差的伪标签反而会放大偏移。test_pred_all model.predict(X_test, num_iterationmodel.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], axis0, ignore_indexTrue) 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(..., shuffleTrue)参数。这是最容易忽略的一步验证策略按时间训练内部随机化两者并不矛盾。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, labely_tr) d_xgb_val xgb.DMatrix(X_val, labely_val) d_xgb_test xgb.DMatrix(X_test) xgb_model xgb.train( xgb_params, d_xgb_tr, num_boost_round3000, evals[(d_xgb_val, val)], early_stopping_rounds100, verbose_eval100 )XGBoost 和 LightGBM 在匿名特征上的表现差异主要来自分裂点搜索策略LightGBM 用直方图算法XGBoost 用预排序近似算法两者在特征交互的捕捉上各有侧重。融合时用验证集找最优权重from scipy.optimize import minimize lgb_val_pred model.predict(X_val, num_iterationmodel.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, x00.5, bounds[(0, 1)]) best_w result.x[0] print(f最优融合权重LightGBM 占比: {best_w:.3f}) lgb_test_pred model.predict(X_test, num_iterationmodel.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) * correctionscipy.optimize.minimize在验证集上搜索 LightGBM 预测和 XGBoost 预测的最优混合比例目标函数是验证 MAE。这样得到的权重比固定 0.5 更可靠因为两个模型在不同价格区间各有所长。参数微调阶段的顺序有讲究。先用learning_rate0.05跑通全流程确认验证 MAE 稳定再逐步降到 0.03 并调大num_boost_round到 5000早停轮数也相应加到 200。num_leaves从 63 试到 95每次只改一个参数观察验证 MAE 变化。验证集分数下降但提交分数上升的反直觉情况也出现过——原因是对抗验证显示分布偏移较大模型在时间加权后对近期样本更敏感验证集是时间靠后的部分天然更接近测试集分布。我从这个项目里学到的最大教训是模型通过对比验证分数和提交分数的差值来判断自己有没有过拟合到训练集——差值在 0.005 以内说明正常超过 0.01 就该怀疑分布偏移了。从那以后我每次做这类带时间戳的价格预测拿到数据第一件事就是看训练集和测试集日期跨度再做对抗验证最后才跑 baseline。数据的时间属性从来不是一行元数据而是决定整个建模策略的第一约束条件。希望这份拆解对你有帮助。本文还有配套的精品资源点击获取