简介这份资源是面向计算机、人工智能、自动化等专业学生与从业者的机器学习实战项目包以天池二手车价格预测赛题为背景提供从数据读取、特征工程到模型训练与结果提交的完整流程适合作为期末大作业、课程设计或毕业设计参考也便于初学者进阶练手。压缩包共16个文件约32.05MB包含5个csv数据集与提交样例、2个ipynb实验笔记分别基于LightGBM与XGBoost建模、4个xml工程配置、1个md说明文档及requirements.txt依赖清单等结构清晰便于按模块复现与调试。项目代码经过调试测试可直接运行答辩评审分达98分已有66人学习下载。读者可据此掌握回归预测的完整建模思路、两种集成模型的对比调参方法并在此基础上修改调整实现自己的预测功能。1. 从一份 98 分大作业说起这套二手车价格预测源码到底能跑出什么如果你正在为机器学习期末大作业或者毕业设计找一份能直接跑通、结构清晰的参考项目这套基于 LightGBM 和 XGBoost 的二手车价格预测源码值得认真拆一遍。它来自天池竞赛的经典赛题包含完整的训练集、测试集、提交样例和两份 Notebook外加 requirements.txt 和 readme 说明。我拿到手第一件事不是看代码而是先确认数据规模——训练集约 15 万条测试集 5 万条特征涵盖车型、注册日期、里程、功率、变速箱等 30 余列。这个体量刚好卡在「单机可跑、又不至于太玩具」的区间用 LightGBM 在普通笔记本上十几分钟能出第一版结果。适合谁计算机、通信、人工智能方向的学生拿来做课程设计或毕设底稿也适合刚转机器学习、想找一个完整 pipeline 练手的从业者。下面我按实际拆包顺序把数据探查、特征工程、双模型训练和提交生成一步步走一遍中间踩过的坑也一并标出来。2. 数据探查与清洗先搞清楚哪些列是「脏」的2.1 读入数据后第一轮体检拿到used_car_train_20200313.csv和used_car_testB_20200421.csv之后别急着往模型里灌。这个数据集有个特点列之间用空格分隔不是标准逗号 CSV直接pd.read_csv会读成一整列。我一般会先跑一段探查代码把形状、缺失率、类型分布一次性看清楚。import pandas as pd import numpy as np # 注意天池这份数据是空格分隔必须显式指定 sep train pd.read_csv(used_car_train_20200313.csv, sep ) test pd.read_csv(used_car_testB_20200421.csv, sep ) print(train shape:, train.shape) print(test shape:, test.shape) # 缺失率排序只看有缺失的列 missing train.isnull().sum() missing missing[missing 0].sort_values(ascendingFalse) print(missing) # 看一下 price 的分布这是标签列 print(train[price].describe())这段代码的逻辑很直白先确认分隔符再统计缺失。实际跑下来你会发现bodyType、fuelType、gearbox三列缺失比较集中price本身没有缺失。参数上唯一要改的是文件路径如果你把数据放在子目录里记得同步改。sep 这个参数是这份数据的命门漏了它后面全错。2.2 缺失值处理与异常值裁剪缺失值怎么填直接决定模型能不能学到有效信号。bodyType和fuelType是类别型我一般用众数填充gearbox缺失极少同样众数处理即可。但这里有个容易翻车的地方price列存在极端值直接训练会让 LightGBM 的叶子节点被拉偏。# 类别型缺失用众数填充 for col in [bodyType, fuelType, gearbox]: mode_val train[col].mode()[0] train[col] train[col].fillna(mode_val) test[col] test[col].fillna(mode_val) # price 裁剪掉极端值保留 1% 到 99% 分位 low, high train[price].quantile([0.01, 0.99]) train train[(train[price] low) (train[price] high)] print(after clip:, train.shape)逻辑说明众数填充对类别列是稳妥选择不会引入虚假的序关系。裁剪用分位数而不是固定阈值是因为不同批次数据的价格尺度可能不同分位数自适应更强。参数0.01和0.99可以调如果你发现裁剪后样本掉得太多放宽到0.005和0.995也行。注意测试集不做价格裁剪因为测试集根本没有 price 列。2.3 时间特征的拆解regDate和creatDate是字符串格式的日期直接丢给模型等于浪费。我习惯把它们拆成「注册年份」「注册月份」「使用月数」三个数值特征其中使用月数 creatDate 减 regDate这个特征对价格影响很直接。for df in [train, test]: df[regDate] pd.to_datetime(df[regDate], format%Y%m%d, errorscoerce) df[creatDate] pd.to_datetime(df[creatDate], format%Y%m%d, errorscoerce) df[regYear] df[regDate].dt.year df[regMonth] df[regDate].dt.month # 使用月数单位月 df[usedMonths] (df[creatDate] - df[regDate]).dt.days / 30这里errorscoerce是后悔药遇到无法解析的日期会转成 NaT 而不是直接报错。usedMonths可能出现负值说明 creatDate 早于 regDate属于脏数据后续可以统一置零或删掉。这一步做完你的特征表就比原始列多出四个可用维度。3. 特征工程把 30 列原始字段榨出更多信号3.1 类别特征的编码策略这份数据里model、brand、bodyType、fuelType、gearbox都是类别型。LightGBM 和 XGBoost 对类别特征的处理方式不同LightGBM 可以直接声明 categorical_featureXGBoost 则更适合先做编码。我一般对品牌和车型用频次编码对车身类型和燃油类型用 one-hot因为后两者类别数少。# 频次编码用出现次数替代原始类别值 for col in [brand, model]: freq train[col].value_counts() train[col _freq] train[col].map(freq) test[col _freq] test[col].map(freq) test[col _freq] test[col _freq].fillna(0) # one-hot 处理低基数类别 train pd.get_dummies(train, columns[bodyType, fuelType, gearbox]) test pd.get_dummies(test, columns[bodyType, fuelType, gearbox])频次编码的好处是不增加维度同时保留了「这个品牌/车型有多常见」的信息。注意测试集映射时可能出现训练集没见过的类别fillna(0)兜底。one-hot 之后训练集和测试集的列数必须对齐如果测试集少了某个 dummy 列后面预测会报错可以用reindex对齐。3.2 构造交互特征与统计特征原始特征之外我一般会加几个统计量同一品牌下的平均价格、同一车型下的价格标准差、功率与里程的比值。这些特征在树模型里往往能带来一截提升。# 品牌均价只用训练集算避免标签泄露 brand_price_mean train.groupby(brand)[price].mean() train[brand_price_mean] train[brand].map(brand_price_mean) test[brand_price_mean] test[brand].map(brand_price_mean) # 功率里程比加 1 防止除零 for df in [train, test]: df[power_km_ratio] df[power] / (df[kilometer] 1)逻辑说明brand_price_mean是目标编码的简化版用训练集统计量映射到测试集绝不能把测试集的 price 掺进来算。power_km_ratio是领域直觉——同样里程下功率越高车况通常越好。参数上1是防除零如果你的数据里 kilometer 最小值就是 0这个保护必须有。3.3 特征筛选别把噪声当信号特征不是越多越好。构造完一堆特征后我会用 LightGBM 自带的 feature_importance 做一轮筛选把重要性为 0 或者极低的列去掉。常见做法是训练一个 100 轮的小模型看 gain 排序砍掉末尾 10% 到 20% 的特征。这一步能缩短训练时间也能降低过拟合风险。注意不要用 accuracy 或 MSE 单独判断树模型的特征重要性看 gain 比 split 次数更可靠。4. 双模型训练LightGBM 与 XGBoost 的参数怎么设4.1 LightGBM 训练与五折交叉验证LightGBM 在这份数据上的优势是训练快、对类别特征友好。我一般用五折交叉验证每折记录验证集的 MAE最后取平均。核心参数learning_rate0.05、num_leaves31、feature_fraction0.8、bagging_fraction0.8、bagging_freq5。import lightgbm as lgb from sklearn.model_selection import KFold from sklearn.metrics import mean_absolute_error features [c for c in train.columns if c not in [price, SaleID, regDate, creatDate]] X train[features] y train[price] X_test test[features] kf KFold(n_splits5, shuffleTrue, random_state42) oof_preds np.zeros(len(X)) test_preds np.zeros(len(X_test)) for fold, (tr_idx, val_idx) in enumerate(kf.split(X)): tr_x, val_x X.iloc[tr_idx], X.iloc[val_idx] tr_y, val_y y.iloc[tr_idx], y.iloc[val_idx] model lgb.LGBMRegressor( learning_rate0.05, num_leaves31, feature_fraction0.8, bagging_fraction0.8, bagging_freq5, n_estimators2000, verbose-1 ) model.fit(tr_x, tr_y, eval_set[(val_x, val_y)], eval_metricmae, callbacks[lgb.early_stopping(100)]) oof_preds[val_idx] model.predict(val_x) test_preds model.predict(X_test) / kf.n_splits print(LightGBM OOF MAE:, mean_absolute_error(y, oof_preds))逻辑说明early_stopping(100)表示验证集 MAE 连续 100 轮不下降就停防止过拟合。n_estimators2000是上限实际会被 early stopping 截断。test_preds用五折结果平均比单模型预测更稳。参数调整上如果 MAE 偏高先把learning_rate降到 0.03 再跑如果训练太慢把num_leaves降到 15。4.2 XGBoost 训练与参数差异XGBoost 的接口和 LightGBM 类似但参数名不同。max_depth替代num_leavessubsample替代bagging_fractioncolsample_bytree替代feature_fraction。我一般设max_depth6、eta0.05、subsample0.8、colsample_bytree0.8。import xgboost as xgb xgb_test_preds np.zeros(len(X_test)) xgb_oof np.zeros(len(X)) for fold, (tr_idx, val_idx) in enumerate(kf.split(X)): tr_x, val_x X.iloc[tr_idx], X.iloc[val_idx] tr_y, val_y y.iloc[tr_idx], y.iloc[val_idx] model xgb.XGBRegressor( max_depth6, learning_rate0.05, subsample0.8, colsample_bytree0.8, n_estimators2000, early_stopping_rounds100, eval_metricmae, verbosity0 ) model.fit(tr_x, tr_y, eval_set[(val_x, val_y)], verboseFalse) xgb_oof[val_idx] model.predict(val_x) xgb_test_preds model.predict(X_test) / kf.n_splits print(XGBoost OOF MAE:, mean_absolute_error(y, xgb_oof))注意 XGBoost 的early_stopping_rounds是写在构造函数里的和 LightGBM 的 callback 写法不同。另外 XGBoost 对缺失值有自己的处理逻辑但我们已经提前填过所以不冲突。两个模型跑完后可以按 0.6 和 0.4 的权重做加权融合通常比单模型低几十到一百的 MAE。4.3 生成提交文件提交格式参考used_car_sample_submit.csv只有SaleID和price两列。融合后的预测值直接写入即可。final_pred 0.6 * test_preds 0.4 * xgb_test_preds submission pd.DataFrame({SaleID: test[SaleID], price: final_pred}) submission.to_csv(submission.csv, indexFalse) print(submission.head())逻辑说明权重 0.6/0.4 是经验值你可以用 OOF 预测做网格搜索找最优权重。indexFalse避免多出一列索引提交时格式不对会被判错。生成后建议用pd.read_csv再读一遍确认列名和行数无误。5. 避坑与排查那些让 MAE 突然翻倍的细节5.1 现象MAE 高得离谱超过 2000原因最常见的是price没有做对数变换或者极端值没裁。这份数据的 price 分布右偏严重直接回归会被大值主导。解决先裁剪分位数再考虑对 price 取 log1p 训练预测后再 expm1 还原。5.2 现象测试集预测报错列数不匹配原因one-hot 之后训练集和测试集的 dummy 列不一致测试集可能少某个类别。解决用test test.reindex(columnstrain.columns, fill_value0)对齐但注意要把 price 列排除在外。5.3 现象交叉验证分数很好提交后排名很差原因特征工程里用了测试集信息比如用全量数据算品牌均价。解决所有统计量必须只在训练集上计算再映射到测试集。这是血泪经验标签泄露在竞赛里是致命的。5.4 现象LightGBM 训练到一半报内存不足原因num_leaves设太大或者特征维度爆炸。解决先把num_leaves降到 31 以下同时用feature_fraction做列采样。如果还不行把数据转成 float32 再训练。5.5 现象提交文件行数对不上原因测试集读取时用了sep,导致行数错乱或者裁剪时误伤了测试集。解决测试集只做填充和对齐绝不做价格裁剪。提交前用assert len(submission) 50000卡一道。6. 进阶技巧用 OOF 权重搜索把融合效果再压一截最后一章说一个我每次做回归融合都会走的流程用 OOF 预测做权重搜索而不是拍脑袋定 0.6/0.4。具体做法是把 LightGBM 和 XGBoost 的 OOF 预测存下来在 0 到 1 之间以 0.05 为步长遍历权重找 MAE 最低的组合。best_w, best_mae 0, float(inf) for w in np.arange(0, 1.05, 0.05): blend w * oof_preds (1 - w) * xgb_oof mae mean_absolute_error(y, blend) if mae best_mae: best_mae, best_w mae, w print(fbest weight: {best_w:.2f}, best MAE: {best_mae:.2f})这段代码跑完你会得到一个具体的最优权重通常和 0.5 有偏差。参数上步长 0.05 够用想更细可以调到 0.01。注意这个权重是在 OOF 上搜的直接套到测试集预测上即可不需要重新训练。另外两个可以试的方向一是把usedMonths做分箱变成类别特征喂给 LightGBM二是对 brand 做目标编码时加平滑公式是(count * mean prior * smoothing) / (count smoothing)smoothing 取 10 左右。这两个改动我在类似数据集上分别拿到过 1% 到 3% 的 MAE 下降。验证方法很简单每次改动后重新跑五折对比 OOF MAE。如果 OOF 降了但提交分数没动大概率是过拟合了验证集这时候要回头检查特征是否泄露。从那以后我每次做完特征工程都会强制走一遍「只用训练集统计、测试集映射」的检查再跑交叉验证。希望帮到你。本文还有配套的精品资源点击获取