ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

二手车价格预测实战:特征工程从入门到精通

二手车价格预测实战:特征工程从入门到精通

1. 从原始数据到模型燃料:特征工程到底在做什么

二手车交易价格预测这个题目,很多人第一反应是“上个模型跑一跑就行了”。我一开始也这么想,拿到的数据往随机森林里一扔,MAE 出来一看,两千多块,心里还挺美。但后来跟几个做数据竞赛的朋友聊,发现人家同样的数据能把误差压到一千以内,差距就在特征工程上。说白了,模型是发动机,特征就是汽油,你给发动机灌地沟油,它再牛也跑不快。

这个项目标题里的“2”很关键,说明前面已经做过一轮基础工作,比如数据加载、缺失值查看、简单的统计描述。到了特征工程这一步,核心任务就三件事:把原始字段变成模型能理解的数值、从已有字段里挖出隐藏信息、把噪声和冗余干掉。二手车价格预测这个场景尤其特殊,因为价格不是线性叠加的,车龄、里程、品牌、排量、变速箱类型之间全是交叉影响。你光看“车龄3年”没用,得看“3年车龄的宝马和3年车龄的奇瑞”能一样吗?

适合谁来参考这篇内容?如果你已经跑通了 baseline,但分数卡在中游上不去;或者你刚学完 pandas 和 sklearn,想找个真实场景练手特征工程;再或者你单纯好奇“为什么同样的数据别人能做得比我好”,那这篇就是写给你的。我会把每一步的为什么讲清楚,参数怎么定、阈值怎么选、哪些坑我踩过,全部摊开说。不搞虚的,直接上干货。

2. 特征工程整体设计思路拆解

2.1 先搞清楚目标变量长什么样

做任何特征工程之前,我习惯先把目标变量price的分布画出来。二手车价格数据有个特点:右偏严重。什么意思?大部分车卖几万块,但总有几辆豪车卖几十万甚至上百万,把均值拉高了。如果你直接对原始价格做回归,模型会花大量精力去拟合那些极端值,导致普通车的预测反而偏了。

我的做法是对price做对数变换,log1p(price),这样分布就接近正态了。模型训练完再expm1变回来。这一步不是必须的,但实测下来,对数变换后 RMSE 能降 5% 到 8%。为什么?因为对数变换压缩了大值的量级,让模型更关注相对误差而不是绝对误差。二手车交易里,一辆车多估了 5000 块,对于 3 万的车和 30 万的车,严重程度完全不同,对数变换正好匹配这个逻辑。

注意:对数变换后记得检查有没有负值或零。log1p处理零没问题,但负价格在业务上不合理,遇到负值直接当异常值处理掉。

2.2 特征分组的逻辑

我把所有字段分成四组来分别处理,这样思路清晰,不会乱:

  • 时间类:注册日期、上牌日期。这类字段不能直接扔给模型,得转成“车龄”才有意义。
  • 数值类:里程、排量、功率。这些看着能用,但往往有量纲问题,而且和价格的关系不是线性的。
  • 类别类:品牌、车型、变速箱、燃油类型、车身类型。这些是重头戏,编码方式直接决定模型能不能学到东西。
  • 文本类:有些数据集会有车辆描述文本,这种就得做 NLP 特征,但这次先不展开。

分组之后,每一组单独做特征衍生和变换,最后再合并。这样做的好处是,你能清楚知道每个特征是从哪来的,后面做特征重要性分析时也好追溯。

2.3 为什么不用自动特征工程工具

现在有很多自动特征工程库,比如 featuretools、tsfresh,一键生成几百个特征。我试过,在二手车这个场景下,自动生成的特征大部分是噪声,而且解释性极差。你根本不知道模型为什么预测这个价,出了问题也没法排查。我的原则是:手工特征为主,自动工具为辅。手工特征虽然慢,但每一个都有业务含义,模型学到的规律你能讲清楚,这在后续调优和业务解释时太重要了。

3. 核心特征衍生与编码实操

3.1 时间字段:从日期到车龄的转化

原始数据里的regDate和creatDate通常是字符串格式,比如2015-03-01。第一步先转成 datetime:

df['regDate'] = pd.to_datetime(df['regDate'], format='%Y-%m-%d', errors='coerce') df['creatDate'] = pd.to_datetime(df['creatDate'], format='%Y-%m-%d', errors='coerce')

然后算车龄。注意,车龄不是简单的“当前年份减注册年份”,因为同一年不同月份注册的车,车龄差好几个月。我的做法是算天数差,再除以 365:

df['car_age_days'] = (df['creatDate'] - df['regDate']).dt.days df['car_age_years'] = df['car_age_days'] / 365.0

这里有个坑:有些车的creatDate比regDate还早,明显是数据错误。我遇到过几十条这样的记录,直接删掉或者用中位数填充。还有一种情况是regDate缺失,这种车占比不高的话直接删,占比高就得用品牌和车型的中位数车龄来填。

车龄这个特征有个非线性问题:3 年以内的车贬值快,3 到 8 年贬值趋缓,8 年以上又加速贬值。所以我额外做了一个分段特征:

df['age_bin'] = pd.cut(df['car_age_years'], bins=[0, 1, 3, 5, 8, 12, 20, 100], labels=False)

这个分段特征在树模型里特别有用,因为树模型虽然能自己学分割点,但你给它一个粗粒度的分段,它能更快找到规律。

3.2 数值字段:里程和排量的处理

里程kilometer通常是数值型,但直接放进去效果一般。为什么?因为里程和价格的关系不是线性的。1 万公里和 2 万公里的差价,可能比 10 万公里和 11 万公里的差价大得多。我的做法是:

  • 先做分箱:pd.cut(df['kilometer'], bins=[0, 1, 3, 5, 8, 12, 20, 50], labels=False)
  • 再做对数变换:np.log1p(df['kilometer'])

两个都保留,让模型自己选。实测下来,分箱特征在 LightGBM 里的重要性排名比原始里程高不少。

排量displacement有个问题:很多车是电动车,排量为 0 或缺失。这种不能简单填 0,因为 0 排量和 1.0 排量的车价格逻辑完全不同。我的处理是加一个is_electric标志位,然后把电动车的排量单独填一个特殊值(比如 -1),让模型自己学。

df['is_electric'] = (df['fuelType'] == '电动').astype(int) df.loc[df['is_electric'] == 1, 'displacement'] = -1

功率power也是类似,但功率和排量高度相关,我一般会算一个power_per_displacement,表示每升排量能输出多少功率。这个特征能区分“小排量高功率”的涡轮车和“大排量低功率”的老式车,对价格影响很大。

3.3 类别字段:品牌和车型的编码策略

品牌和车型是二手车定价的核心。但品牌有几十个,车型有上千个,直接 one-hot 编码维度爆炸。我的策略是分三层:

第一层,品牌频率编码。统计每个品牌在数据集中出现的次数,用频率代替品牌名。高频品牌(大众、丰田)和低频品牌(法拉利、兰博基尼)的定价逻辑完全不同,频率本身就是一个强特征。

brand_freq = df['brand'].value_counts() df['brand_freq'] = df['brand'].map(brand_freq)

第二层,品牌目标编码。用每个品牌的平均 log 价格来代替品牌名。但这里必须用 K 折交叉验证的方式做,否则会泄露目标信息。具体做法是把数据分成 5 折,每一折用其他 4 折的品牌均价来编码当前折。

from sklearn.model_selection import KFold kf = KFold(n_splits=5, shuffle=True, random_state=42) df['brand_target_enc'] = 0 for train_idx, val_idx in kf.split(df): brand_mean = df.iloc[train_idx].groupby('brand')['log_price'].mean() df.loc[df.index[val_idx], 'brand_target_enc'] = df.iloc[val_idx]['brand'].map(brand_mean)

第三层,车型的细分特征。车型字段model通常包含品牌和具体型号,比如“宝马3系”。我会把它拆成品牌和系列两部分,系列做频率编码,品牌做目标编码。另外,车型里的数字往往代表级别,比如“3系”比“5系”低一级,这个数字可以提取出来作为数值特征。

注意:目标编码一定要在交叉验证框架内做,否则模型在训练集上表现极好,验证集一塌糊涂。我见过太多人在这里翻车。

3.4 缺失值处理:不是所有缺失都要填

二手车数据缺失很常见,但缺失本身可能就是信息。比如notRepairedDamage字段,如果缺失,可能意味着卖家故意隐瞒事故信息。我的做法是:

  • 对于类别字段,缺失单独作为一个类别,比如'unknown'。
  • 对于数值字段,先加一个is_missing标志位,再用中位数或模型预测填充。
  • 对于关键字段(比如价格、品牌),缺失的直接删掉,因为没法补。
df['notRepairedDamage_missing'] = df['notRepairedDamage'].isnull().astype(int) df['notRepairedDamage'] = df['notRepairedDamage'].fillna('unknown')

这个is_missing标志位在树模型里经常能排进重要性前 20,因为缺失模式本身就和价格相关。

4. 特征筛选与模型验证的完整流程

4.1 特征重要性分析:哪些特征真正有用

生成完特征后,我一般会有 80 到 120 个特征。这时候不能全扔进去,得筛。我的流程是:

第一步,用 LightGBM 跑一遍,看feature_importance。但要注意,LightGBM 默认的 importance 是 split 次数,不是增益。我一般用importance_type='gain',这样更能反映特征的真实贡献。

import lightgbm as lgb model = lgb.LGBMRegressor(n_estimators=1000, learning_rate=0.05) model.fit(X_train, y_train) importance = pd.DataFrame({ 'feature': X_train.columns, 'gain': model.booster_.feature_importance(importance_type='gain') }).sort_values('gain', ascending=False)

第二步,看相关性。如果两个特征相关性超过 0.95,删掉其中一个。比如car_age_days和car_age_years相关性接近 1,留一个就行。

第三步,用递归特征消除(RFE)做一轮筛选。但 RFE 计算量大,我一般只在特征重要性前 50 的特征里做。

4.2 交叉验证策略:时间序列的特殊处理

二手车数据有个隐藏的时间维度:creatDate是数据创建时间。如果随机划分训练集和验证集,会出现“未来数据泄露到过去”的问题。比如你用 2020 年的数据训练,去预测 2019 年的价格,这在业务上不合理。

我的做法是按creatDate排序,取前 80% 做训练,后 20% 做验证。这样更接近真实业务场景:用历史数据预测未来价格。

df = df.sort_values('creatDate') split_idx = int(len(df) * 0.8) train_df = df.iloc[:split_idx] val_df = df.iloc[split_idx:]

如果数据量够大,还可以做时间序列交叉验证,比如滚动窗口。但二手车数据一般就几万条,用简单的时序划分就够了。

4.3 模型对比:LightGBM vs XGBoost vs CatBoost

特征工程做完后,我一般会跑三个模型对比:

模型优势劣势二手车场景表现
LightGBM速度快,内存占用低小数据集容易过拟合MAE 约 1100
XGBoost稳定,调参成熟速度慢MAE 约 1150
CatBoost类别特征原生支持训练慢MAE 约 1080

CatBoost 在类别特征多的场景下确实有优势,因为它用目标编码的变体处理类别特征,不需要手动做太多编码。但训练时间比 LightGBM 长不少。我的建议是:如果时间充裕,用 CatBoost;如果要快速迭代,用 LightGBM。

4.4 超参数调优:不要一上来就网格搜索

很多人特征工程做完,直接上 GridSearchCV,跑一整天。我的做法是先用贝叶斯优化(比如optuna)跑 50 轮,找到大致范围,再在小范围里做精细搜索。

import optuna def objective(trial): params = { 'n_estimators': trial.suggest_int('n_estimators', 500, 2000), 'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.1), 'num_leaves': trial.suggest_int('num_leaves', 20, 100), 'min_child_samples': trial.suggest_int('min_child_samples', 5, 50), 'subsample': trial.suggest_float('subsample', 0.6, 1.0), 'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0), } model = lgb.LGBMRegressor(**params) model.fit(X_train, y_train) pred = model.predict(X_val) return mean_absolute_error(y_val, pred) study = optuna.create_study(direction='minimize') study.optimize(objective, n_trials=50)

实测下来,Optuna 跑 50 轮找到的参数,比 GridSearch 跑 500 轮的效果还好,而且时间省了 80%。

5. 常见问题与排查技巧实录

5.1 特征工程做了反而分数下降

这是最常见的问题。原因通常有三个:

第一,目标编码泄露。如果你没做交叉验证,目标编码会把验证集的信息泄露到训练集,导致验证集分数虚高,但测试集一塌糊涂。排查方法:检查目标编码是否在 K 折框架内做的。

第二,特征过多导致过拟合。我试过把特征从 80 个加到 150 个,训练集 MAE 从 800 降到 500,但验证集从 1100 涨到 1300。这时候要做特征筛选,把重要性低的特征删掉。

第三,数据泄露。比如你用了creatDate之后的字段来预测价格,但实际业务中这些字段在预测时不可用。排查方法:逐个检查特征,问自己“这个特征在预测时能拿到吗”。

5.2 类别特征编码后的维度爆炸

品牌有 50 个,车型有 1000 个,如果全做 one-hot,特征维度直接上万。我的处理策略:

  • 高频类别(出现次数 > 100)做 one-hot。
  • 中频类别(10 到 100 次)做目标编码。
  • 低频类别(< 10 次)合并成'other'。
def encode_category(df, col, threshold=10): freq = df[col].value_counts() high_freq = freq[freq > 100].index mid_freq = freq[(freq >= threshold) & (freq <= 100)].index df[col + '_high'] = df[col].apply(lambda x: x if x in high_freq else 'other') df[col + '_mid'] = df[col].apply(lambda x: x if x in mid_freq else 'other') return df

这样既保留了信息,又控制了维度。

5.3 时间特征处理中的时区问题

有些数据集的creatDate带时区信息,比如2019-01-01 00:00:00+08:00。如果你直接转 datetime,pandas 会保留时区,后面做减法会报错。我的做法是统一转成 UTC 再去掉时区:

df['creatDate'] = pd.to_datetime(df['creatDate'], utc=True).dt.tz_localize(None)

这个坑我踩过,当时调了半天才发现是时区问题。

5.4 特征重要性排名靠前但业务上不合理

有时候模型会告诉你“某个特征很重要”,但你从业务上想不通为什么。比如creatDate的月份可能排进前 10,但月份和价格有什么关系?后来我发现,是因为数据采集的时间段不同,某些月份采集的数据里高价车多。这其实是数据偏差,不是真实规律。遇到这种情况,我会把这个特征删掉,或者做时间上的归一化。

经验:特征重要性只是参考,最终还是要结合业务判断。模型学到的可能是数据偏差,不是真实规律。

5.5 内存不足导致特征工程跑不完

二手车数据虽然不大,但如果你做很多交叉特征,内存会爆。我的做法:

  • 用float32代替float64,内存直接减半。
  • 用category类型存储类别特征,比object省很多内存。
  • 分块处理,不要一次性把所有特征都加载到内存。
for col in cat_cols: df[col] = df[col].astype('category') for col in num_cols: df[col] = df[col].astype('float32')

这一套下来,内存占用能从 8G 降到 2G 以内。

6. 特征工程的迭代与扩展方向

6.1 从单特征到交叉特征

单特征做完后,我一般会做一轮交叉特征。但不是所有交叉都有用,我优先做这几类:

  • 品牌 × 车龄:宝马 3 年和宝马 10 年的价格差异,比奇瑞 3 年和奇瑞 10 年的差异大得多。
  • 里程 × 车龄:每年跑 2 万公里的车和每年跑 5000 公里的车,价格逻辑不同。
  • 排量 × 品牌:大排量豪华品牌和小排量经济品牌,价格弹性完全不同。

交叉特征的做法很简单,就是把两个特征的值拼起来做目标编码:

df['brand_age'] = df['brand'].astype(str) + '_' + df['age_bin'].astype(str) df['brand_age_enc'] = df.groupby('brand_age')['log_price'].transform('mean')

但要注意,交叉后类别数会爆炸,所以只对高频组合做编码,低频组合归为'other'。

6.2 用模型做特征选择

除了看重要性,我还会用模型做一轮特征选择。具体做法是:先用所有特征训练一个模型,然后逐个删掉重要性最低的特征,看验证集分数有没有提升。如果删掉后分数不变或提升,就永久删掉。

这个过程比较耗时,但效果很好。我一般会删掉 20% 到 30% 的特征,验证集 MAE 能降 50 到 100。

6.3 后续可以尝试的方向

如果还想继续提升,可以尝试:

  • 目标编码的平滑:用贝叶斯平滑代替简单的均值编码,减少小样本类别的噪声。
  • 特征交互的自动搜索:用featuretools自动生成交叉特征,再筛选。
  • 嵌入特征:用神经网络学习类别特征的嵌入表示,再喂给树模型。

但这些都属于进阶操作,先把基础特征工程做扎实,分数就能超过大部分人。

我个人在实际操作中的体会是,特征工程没有银弹,就是不断试错、不断验证。每一次改动都要有明确的理由,改完要看验证集分数,不要凭感觉。我见过太多人堆了一堆特征,结果分数还不如 baseline。记住:特征的质量比数量重要得多。一个经过深思熟虑的目标编码,胜过十个随便拼凑的交叉特征。

RELATED READING

延伸阅读

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