多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python二手车价格预测实战:从数据清洗到LightGBM模型调参完整链路

Python二手车价格预测实战:从数据清洗到LightGBM模型调参完整链路 简介这份资源面向Python课程设计、期末大作业及数据挖掘入门学习者围绕二手车价格预测这一典型回归任务提供从数据清洗、特征工程到模型训练与评估的完整实现方案帮助读者快速理解数据挖掘项目的整体流程与落地方式。压缩包共27个文件约34.86MB包含2个Python源码文件、1份CSV数据集、1份docx设计报告、1份md说明文档以及9张png结果图、8个xml配置文件和若干工程配置文件覆盖代码、数据、报告与运行环境说明。目前已有211人学习下载适合作为课程大作业参考或自学练手项目。源码配有详细注释新手也能看懂下载后简单部署即可运行设计报告与结果图可直接用于作业撰写与答辩展示具有较高的实际应用与参考价值。1. 二手车价格预测从课程大作业到能跑通的完整数据挖掘链路二手车价格预测这个题目几乎每年都会出现在数据挖掘或 Python 课程的选题清单里。原因很直接数据获取门槛低、业务含义清晰、建模路径完整从爬虫、清洗、特征工程到回归模型全都能串一遍。但真正动手做过的人都知道这类项目最容易翻车的地方不在模型选型而在数据本身——同一款车不同年份、不同里程、不同城市的价格能差出一倍缺失值和异常值处理不好后面调参调到天亮也白搭。这篇笔记面向两类人一是正在做 Python 课程大作业、需要一份能跑通且注释清楚的完整方案的同学二是想快速搭一套二手车估价原型的开发者。我会按真实落地顺序拆解数据从哪来、怎么洗、特征怎么造、模型怎么选、评估怎么做以及那些只有踩过才知道的坑。全程用 Python 实现代码可直接复现参数会说明为什么这么设。2. 数据获取与初探别急着建模先看清数据长什么样2.1 数据来源的三种常见路径做二手车价格预测数据来源决定了后续所有工作的上限。常见做法有三类第一类是公开数据集比如一些数据竞赛平台发布过的二手车交易记录字段通常包含品牌、车型、注册年份、里程、变速箱类型、燃油类型、售价等。这类数据已经过一定脱敏拿来练手最省事。第二类是自己采集。用 requests 配合解析库抓取公开的二手车信息页面或者用 Selenium 处理动态渲染。这里要注意频率控制别给目标站点造成压力也要遵守其 robots 协议。第三类是模拟生成。课程大作业如果拿不到真实数据可以基于业务规则构造一份带噪声的数据集重点是把完整流程跑通。我一般会建议先用模拟数据把 pipeline 搭好再替换成真实数据。不管哪条路径拿到数据后第一件事不是建模而是做数据初探。下面这段代码是标准的初探模板import pandas as pd import numpy as np # 读取数据注意编码中文数据常见 gbk 或 utf-8 df pd.read_csv(used_car.csv, encodingutf-8) # 基本信息行列数、字段类型、内存占用 print(df.shape) print(df.dtypes) print(df.info()) # 数值字段描述统计重点看均值和中位数差多少 print(df.describe()) # 类别字段看取值分布 for col in df.select_dtypes(includeobject).columns: print(col, df[col].nunique()) print(df[col].value_counts().head(10)) # 缺失值统计 missing df.isnull().sum() missing_rate missing / len(df) print(pd.DataFrame({缺失数: missing, 缺失率: missing_rate}) .sort_values(缺失率, ascendingFalse))这段代码的逻辑是先看整体规模再分字段类型看分布最后定位缺失。参数上没什么可调的但有一个习惯值得养成describe()之后一定对比均值和 50% 分位数如果差距很大说明分布偏斜严重后面可能要做对数变换。类别字段的nunique()能帮你判断哪些字段适合做独热编码哪些应该做目标编码或直接舍弃。2.2 目标变量分布检查与处理决策二手车价格是典型的右偏分布少数豪车会把均值拉得很高。建模前必须看目标变量的分布import matplotlib.pyplot as plt # 原始价格分布 plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.hist(df[price], bins50) plt.title(原始价格分布) # 对数变换后 plt.subplot(1, 2, 2) plt.hist(np.log1p(df[price]), bins50) plt.title(对数变换后价格分布) plt.show() # 偏度和峰度 print(偏度:, df[price].skew()) print(峰度:, df[price].kurt())如果偏度大于 1我一般会对价格做log1p变换再训练预测时用expm1还原。这样做的好处是让模型更关注相对误差而不是绝对误差对高价车的预测不会过度主导损失函数。注意log1p而不是log是为了处理价格可能为 0 的边界情况。提示变换后一定要检查还原后的误差指标别只看对数域的 MSE那个数值没有业务解释力。3. 特征工程决定预测精度的主战场3.1 从原始字段里榨出可用特征原始字段往往不能直接喂给模型。以注册年份为例直接放年份数字模型会认为 2020 比 2010 大但真正影响价格的是车龄。所以第一步是构造衍生特征# 假设当前年份为 2024 CURRENT_YEAR 2024 # 车龄 df[car_age] CURRENT_YEAR - df[register_year] # 里程分箱把连续里程变成有序类别 bins [0, 1, 3, 5, 8, 12, 20, 100] labels [1万内, 1-3万, 3-5万, 5-8万, 8-12万, 12-20万, 20万以上] df[mileage_bin] pd.cut(df[mileage], binsbins, labelslabels) # 品牌保值率用品牌下所有车的中位价格作为该品牌的粗略保值指标 brand_price_median df.groupby(brand)[price].median() df[brand_price_median] df[brand].map(brand_price_median) # 车龄与里程的交互项反映使用强度 df[usage_intensity] df[mileage] / (df[car_age] 1)这里有几个参数需要说明。里程分箱的边界不是拍脑袋定的而是根据业务常识一般家用车年均行驶 1 到 2 万公里所以 3 万、5 万、8 万这些节点对应的是不同车况阶段。brand_price_median这个特征在训练集上计算然后映射到测试集注意避免用测试集信息去算中位数否则就是数据泄露。usage_intensity加 1 是防止车龄为 0 时除零。3.2 类别字段编码独热、目标编码还是频率编码类别字段的处理方式直接影响模型表现。品牌、变速箱、燃油类型这些字段取值数量差别很大字段取值数推荐编码理由变速箱2-3独热编码取值少无顺序燃油类型3-5独热编码取值少无顺序品牌20-50目标编码取值多独热会爆炸车型100频率编码或目标编码取值极多需降维目标编码的实现要配合交叉验证否则容易过拟合from sklearn.model_selection import KFold def target_encode(df, col, target, n_splits5, smoothing10): 带平滑的目标编码用 K 折避免泄露 df[col _target_enc] np.nan kf KFold(n_splitsn_splits, shuffleTrue, random_state42) global_mean df[target].mean() for train_idx, val_idx in kf.split(df): train_fold df.iloc[train_idx] val_fold df.iloc[val_idx] # 计算每类的均值和中位数样本数 agg train_fold.groupby(col)[target].agg([mean, count]) # 平滑样本少时向全局均值靠拢 smooth (agg[mean] * agg[count] global_mean * smoothing) / (agg[count] smoothing) df.loc[val_fold.index, col _target_enc] val_fold[col].map(smooth) # 用全量数据再算一次供测试集使用 agg df.groupby(col)[target].agg([mean, count]) smooth (agg[mean] * agg[count] global_mean * smoothing) / (agg[count] smoothing) return df, smooth df, brand_map target_encode(df, brand, price)平滑参数smoothing控制的是某个品牌样本很少时它的编码值向全局均值靠拢的程度。设 10 意味着至少要有 10 条样本该品牌的均值才有足够话语权。这个值可以根据数据量调整数据量大就调小数据量小就调大。3.3 缺失值处理删除、填充还是模型自己扛缺失值处理没有万能方案要看缺失比例和缺失机制# 缺失率超过 60% 的字段考虑直接删除 high_missing_cols [c for c in df.columns if df[c].isnull().mean() 0.6] df df.drop(columnshigh_missing_cols) # 数值字段用中位数填充比均值抗异常值 num_cols df.select_dtypes(include[np.number]).columns for col in num_cols: if df[col].isnull().sum() 0: df[col] df[col].fillna(df[col].median()) # 类别字段用众数填充 cat_cols df.select_dtypes(includeobject).columns for col in cat_cols: if df[col].isnull().sum() 0: df[col] df[col].fillna(df[col].mode()[0])如果用的是 LightGBM 或 XGBoost其实可以保留缺失值让模型自己处理这类树模型对缺失值有原生支持。但线性模型和神经网络必须填充。我一般会先跑一版保留缺失的树模型作为 baseline再对比填充后的效果。4. 模型训练与调参从线性回归到梯度提升树4.1 基线模型先跑通再优化别一上来就上复杂模型。先用线性回归跑一个基线确认整个流程没有泄露、没有逻辑错误from sklearn.model_selection import train_test_split from sklearn.linear_model import Ridge from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_absolute_error, r2_score import numpy as np # 准备特征矩阵去掉目标列和原始文本列 feature_cols [c for c in df.columns if c not in [price, brand, model, city]] X df[feature_cols] y np.log1p(df[price]) # 对数变换 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) # 标准化 scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test) # 岭回归alpha 控制正则强度 ridge Ridge(alpha1.0) ridge.fit(X_train_scaled, y_train) # 预测并还原 y_pred_log ridge.predict(X_test_scaled) y_pred np.expm1(y_pred_log) y_true np.expm1(y_test) print(MAE:, mean_absolute_error(y_true, y_pred)) print(R2:, r2_score(y_true, y_pred))alpha是正则化强度越大越不容易过拟合但也可能欠拟合。可以先试 0.1、1、10 几个值。注意标准化只在训练集上 fit测试集用同样的 scaler 做 transform这是防止数据泄露的基本操作。4.2 梯度提升树主力模型的选择与参数含义树模型在表格数据上通常表现最好。LightGBM 训练快、内存占用低是我做这类任务的首选import lightgbm as lgb from sklearn.model_selection import KFold params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 31, max_depth: -1, min_data_in_leaf: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 5, lambda_l2: 1.0, verbose: -1, seed: 42 } # 5 折交叉验证 kf KFold(n_splits5, shuffleTrue, random_state42) oof_preds np.zeros(len(X_train)) test_preds np.zeros(len(X_test)) for fold, (tr_idx, val_idx) in enumerate(kf.split(X_train)): X_tr, X_val X_train.iloc[tr_idx], X_train.iloc[val_idx] y_tr, y_val y_train.iloc[tr_idx], y_train.iloc[val_idx] dtrain lgb.Dataset(X_tr, labely_tr) dval lgb.Dataset(X_val, labely_val, referencedtrain) model lgb.train( params, dtrain, num_boost_round2000, valid_sets[dval], callbacks[lgb.early_stopping(100), lgb.log_evaluation(200)] ) oof_preds[val_idx] model.predict(X_val, num_iterationmodel.best_iteration) test_preds model.predict(X_test, num_iterationmodel.best_iteration) / kf.n_splits print(OOF MAE:, mean_absolute_error(np.expm1(y_train), np.expm1(oof_preds)))几个关键参数需要解释。learning_rate设 0.05 是速度和精度的折中设太小训练慢设太大容易震荡。num_leaves控制单棵树复杂度31 是常用起点数据量大可以调到 63 或 127。min_data_in_leaf防止叶子节点样本过少导致过拟合20 是保守值。feature_fraction和bagging_fraction都是行采样和列采样增加随机性来提升泛化。early_stopping(100)表示验证集指标 100 轮不提升就停这是防止过拟合最实用的手段。4.3 特征重要性分析与模型解释训练完要看哪些特征真正起了作用# 用最后一折的模型看特征重要性 importance pd.DataFrame({ feature: X_train.columns, gain: model.feature_importance(importance_typegain), split: model.feature_importance(importance_typesplit) }).sort_values(gain, ascendingFalse) print(importance.head(20)) # 如果装了 shap可以看单样本解释 # import shap # explainer shap.TreeExplainer(model) # shap_values explainer.shap_values(X_test.iloc[:100]) # shap.summary_plot(shap_values, X_test.iloc[:100])gain表示该特征带来的总信息增益比split次数更能反映重要性。如果发现brand_price_median这类目标编码特征排第一要警惕是否泄露了目标信息检查编码时是否严格按折计算。5. 避坑与排查那些让模型翻车的细节5.1 数据泄露最常见也最致命现象线下交叉验证 MAE 很低上线或换一批数据后误差翻倍。原因目标编码用了全量数据计算或者标准化时用了包含测试集的统计量或者构造特征时用到了未来信息。解决所有依赖目标值的特征必须在交叉验证的每一折内部单独计算。标准化、填充中位数这些操作只能在训练折上 fit然后应用到验证折。写代码时养成习惯先 split再在训练集上做任何 fit。5.2 异常价格不处理会带偏整个模型现象模型对正常车的预测还行但偶尔给出离谱高价或负值。原因训练数据里混入了标价错误、事故车、限量收藏车等极端样本。解决用分位数截断比如把价格低于 1% 分位和高于 99% 分位的样本剔除或缩尾。也可以对价格做 log 变换来压缩极端值的影响。但别一刀切太狠否则会丢失真实的高价车模式。5.3 类别字段取值不一致现象训练时品牌有 30 个取值预测时来了一个没见过的品牌模型直接报错或给出随机结果。原因训练集和测试集的类别空间不一致或者线上新出现了训练时没有的品牌。解决目标编码时给未知类别留一个全局均值作为兜底。独热编码用handle_unknownignore。更稳妥的做法是维护一个类别映射表新类别统一归入“其他”。5.4 评估指标选错MAE 和 RMSE 讲的是不同故事现象用 RMSE 调参结果模型对大误差很敏感但业务方更关心平均误差。原因RMSE 对大误差惩罚重MAE 对所有误差一视同仁。二手车场景里偶尔一个极端误差可能比整体平均误差更值得关注也可能相反。解决同时看 MAE、RMSE 和 MAPE。如果高价车样本少但重要用 RMSE如果追求整体平均准确度用 MAE。我一般以 MAE 为主RMSE 为辅两个都监控。5.5 过拟合训练集完美验证集拉胯现象训练集 R2 到 0.95验证集只有 0.6。原因模型太复杂、特征太多、样本太少或者交叉验证方式不对。解决先降模型复杂度减少num_leaves、增大min_data_in_leaf、提高正则lambda_l2。再检查特征数量是否远超样本量必要时做特征筛选。最后确认交叉验证的折数和数据量匹配小数据用 5 折大数据可以用 3 折。6. 进阶技巧让预测结果更稳的几个实操习惯第一个习惯是固定随机种子并记录。上面代码里random_state42和seed42不是随便写的是为了让每次运行结果可复现。课程大作业要交报告复现性直接影响可信度。我一般会在脚本开头统一设SEED42所有涉及随机的地方都引用它。第二个习惯是保存完整的预处理 pipeline。别只保存模型文件标准化器、目标编码映射表、特征列顺序都要一起存import joblib # 保存模型和预处理对象 joblib.dump(model, lgb_model.pkl) joblib.dump(scaler, scaler.pkl) joblib.dump(brand_map, brand_target_map.pkl) joblib.dump(feature_cols, feature_cols.pkl) # 预测时按同样顺序加载和变换 def predict_new(data_dict): model joblib.load(lgb_model.pkl) scaler joblib.load(scaler.pkl) brand_map joblib.load(brand_target_map.pkl) feature_cols joblib.load(feature_cols.pkl) row pd.DataFrame([data_dict]) row[car_age] 2024 - row[register_year] row[brand_target_enc] row[brand].map(brand_map).fillna(brand_map.mean()) row row[feature_cols] row_scaled scaler.transform(row) pred_log model.predict(row_scaled) return np.expm1(pred_log)[0]第三个习惯是给预测结果加一个合理的区间。点预测容易给人虚假的精确感实际业务里更希望知道“大概在什么范围”。可以用分位数回归或者对残差建模来给出区间# 用验证集残差的分位数构造预测区间 residuals np.expm1(y_train) - np.expm1(oof_preds) lower np.percentile(residuals, 5) upper np.percentile(residuals, 95) def predict_with_interval(x): point predict_new(x) return point lower, point, point upper这样给出的区间虽然粗糙但比单点预测更有参考价值。课程报告里加上这个会比只报一个 MAE 显得更完整。最后一个习惯是留一份“后悔药”每次调参或改特征前把当前版本的验证集指标记下来。我见过太多人改了一堆东西结果效果反而下降又回不去之前的状态。用简单的文本日志或者表格记录版本、改动内容、MAE、RMSE花不了几分钟但能省下大量重复劳动。希望帮到你。本文还有配套的精品资源点击获取
返回列表