AI代码开发宝库系列:特征工程
引爆Kaggle竞赛的核武器!我用“特征工程”让二手车价格预测模型性能炸裂
【AI入门系列】车市先知:二手车价格预测学习赛
https://tianchi.aliyun.com/competition/entrance/231784/introduction

摘要:别再傻傻地喂原始数据了!
兄弟们,还在为模型不给力而头秃吗?还在疯狂调参却收效甚微吗?今天,我带你揭开一个残酷的真相:在机器学习的世界里,决定你上限的,往往不是模型有多牛,而是你的数据有多“香”!
本文将带你深入一个二手车价格预测的真实战场,手把手教你如何将一堆看似平平无奇的原始数据,通过一套“骚操作”——特征工程,炼成让XGBoost、LightGBM都直呼内行的“神仙数据”。读完这篇,你将掌握一套可以平移到任何回归预测任务(房价、股价、销售额)的“数据炼金术”。
一、故事背景:一个棘手的挑战
想象一下,你是个数据侠客,接到一个任务:预测二手车的价格。你手里有两份卷宗:训练集.csv 和 测试集.csv。里面记录了车的品牌、功率、行驶里程,还有一堆��星文般的匿名特征 v0-v14。
最头疼的是两个字段:regDate (注册日期) 和 creatDate (交易日期),它们长这样:20040402。这串数字,模型看了都摇头,它不理解这代表“时间”,只觉得是个超大的整数。
我们的目标:把这些“生肉”数据,做成一桌满汉全席,让模型吃得开心,预测得精准!
二、核心秘籍:数据预处理与特征工程的“三板斧”
真正的“炼丹”大师,从不直接把草药扔进炉子。他们会清洗、切片、炮制。我们处理数据,亦是如此。下面就是我们的三板斧,招招致命!
第一板斧:驯服“时间”——将数字串变为黄金特征
这是整个项目中最秀的一步,也是提分的关键!面对 20040402 这种数字,普通人选择放弃,而我们选择榨干它!
1. 基础操作:拆解! 我们不能让模型去猜,直接告诉它年月日。
# 将 '20040402' 变为 年(2004), 月(04), 日(02) data['reg_year'] = reg_date_features['year'] data['reg_month'] = reg_date_features['month'] ...
2. 进阶玩法:创造“相对”��念! 模型对“2004年”没感觉,但对“这车已经10年了”这个概念极其敏感。所以,我们创造一个新特征:车龄 (car_age)。
# 车龄 = 交易年份 - 注册年份 data['car_age'] = data['creat_year'] - data['reg_year']
这一招,直接把两个孤立的时间点,变成了一个极具业务价值的特征。车龄越大,价格越低,这是常识,现在我们把这个常识“翻译”给了模型。
3. 宗师级理解:时间的“连续性”! 我们更进一步,将所有日期都转换为相对于某个“基准日期”的天数差。
# 计算日期到某个最早日期的天数差距 base_date = reg_date_features['date'].min() data['reg_date_diff'] = (reg_date_features['date'] - base_date).dt.days
这一下,所有的时间点都被放在了同一条时间轴上,模型可以清晰地感知到时间的流逝和日期的远近,这对于捕捉趋势至关重要!
爽文点评:这套组合拳下来,我们凭空创造了至少3个高质量特征,模型的理解力瞬间提升了几个Level!
第二板斧:摆平“分类”——让文本不再是天书
模型不认识 ‘宝马’、‘奔驰’,它只认识数字。所以,我们需要一个“翻译官”——LabelEncoder。
它的工作很简单:给每个品牌、每个车型、每种燃料类型...都分配一个专属的数字ID。
# '宝马' -> 0, '奔驰' -> 1, '奥迪' -> 2 label_encoders[feature] = LabelEncoder() data[feature] = label_encoders[feature].fit_transform(data[feature].astype(str))
高手过招,防一手“未知”:如果测试集里出现了一个训练集里没有的新品牌(比如“特斯拉”),怎么办?程序会崩溃!我们的代码很稳健,它会把这种“未知”的值,用训练集里最常见的值来代替,保证程序稳定运行。
爽文点评:看似简单的编码,却体现了代码的鲁棒性。在真实世界,脏数据和未知情况才是常态。
第三板斧:扫清“障碍”——优雅地处理缺失值
数据里总有些“空值”(NaN),它们是模型训练的“地雷”。最简单粗暴的方法是删掉,但这样会损失宝贵的信息。
我们的策略是“填充”。对于数值特征,我���用中位数来填充。
# 用中位数填充日期的缺失 data['reg_date_diff'].fillna(data['reg_date_diff'].median(), inplace=True)
为什么是中位数,不是平均数? 因为中位数对“贫富差距”(异常值)不敏感。比如,数据是 [1, 2, 3, 4, 100],平均数是22,严重偏离,而中位数是3,更能代表普遍水平。
爽文点评:细节是魔鬼。一个
median()的选择,体现了你对数据分布的深刻理解。
三、举一反三:如何将这套心法用到你的领域?
这套“数据炼金术”是通用的!不信你看:
-
如果你在做“房价预测”:
-
regDate(注册日期) →construction_year(建造年份) -
creatDate(交易日期) →sale_date(销售日期) -
car_age(车龄) →house_age(房龄) -
brand(品牌) →district(小区/地段) -
kilometer(里程) →area(面积)
-
-
如果你在做“电商销售额预测”:
-
regDate(注册日期) →listing_date(商品上架日期) -
creatDate(交易日期) →order_date(下单日期) -
car_age(车龄) →days_on_market(上架天数) -
brand(品牌) →category(商品品类)
-
-
如果你在做“金融风控” (预测用户是否逾期):
-
regDate(注册日期) →account_creation_date(用户开户日期) -
creatDate(交易日期) →loan_application_date(贷款申请日期) -
car_age(车龄) →customer_tenure(客户在网时长) -
power(功率) →income(收入水平)
-
看到没有?万物皆可特征工程! 核心思想就是:深入理解业务,将原始数据转化为模型能“听懂”且信息量更丰富的语言。
四、源码奉上:可直接运行的“炼丹炉”
项目完整流程
(1)数据预处理
- 日期处理:将 regDate、creatDate(格式如 20040402)拆分为年 / 月 / 日,新增 “车辆年龄”“日期差值”(基于基准日期的天数差)、季节特征、年行驶公里数等
- 缺失值处理:为数值型特征创建缺失标记,用中位数填充缺失值
- 特征工程:新增功率与排量比(性能指标)、品牌统计特征(品牌价格最大 / 最小 / 均值 / 标准差等)、车龄分段(1 年以内、1-3 年等)
(2)特征分析
- 分类特征:分析 name、model、brand 等特征的唯一值个数,筛选关键类别特征
- 相关性分析:计算数值型特征间相关性,通过热力图可视化,辅助特征筛选
(3)模型训练与优化
- 核心模型:XGBoost、LightGBM、CatBoost(梯度提升树类模型,适配回归任务)
- 参数优化:调整 learning_rate(从 0.1 降至 0.01)、增加 n_estimators(提升模型拟合能力),评估指标从 RMSE 改为 MAE
- 模型融合:结合 XGBoost 与 LightGBM,进一步降低 MAE
- 关键提分手段:日期特征优化(新增日期差值)、品牌统计特征、模型融合
(4)预测与输出
- 用测试集(used_car_testB_20200421.csv)进行预测,结果按 “SaleID, price” 格式写入 submit 文件(used_car_sample_submit.csv)
下面就是我们打天下的“利器”——data_preprocessing.py 的完整代码。我已经加上了详细的注释,方便你理解每一处细节。
# -*- coding: utf-8 -*-
"""
二手车数据预处理 - CSDN博客版
核心思想:将原始数据通过特征工程,转化为模型易于理解和学习的高质量特征。
"""
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
import joblib
import os
from datetime import datetime
def process_date(date_str):"""处理日期字符串,提取年、月、日信息,并计算与基准日期的差值。这是特征工程的核心之一,将无意义的数字串转化为有价值的时间特征。"""try:date_str = str(date_str)# 异常处理:确保日期字符串是8位数,否则视为无效if len(date_str) != 8:return pd.Series([np.nan, np.nan, np.nan, np.nan], index=['year', 'month', 'day', 'date'])year = int(date_str[:4])month = int(date_str[4:6])day = int(date_str[6:8])# 异常处理:检查月份和日期是否在有效范围内if not (1 <= month <= 12 and 1 <= day <= 31):return pd.Series([np.nan, np.nan, np.nan, np.nan], index=['year', 'month', 'day', 'date'])# 转换为datetime对象,方便后续计算date = datetime(year, month, day)return pd.Series([year, month, day, date], index=['year', 'month', 'day', 'date'])except (ValueError, TypeError):# 如果转换失败(例如日期格式错误),返回NaNreturn pd.Series([np.nan, np.nan, np.nan, np.nan], index=['year', 'month', 'day', 'date'])
def analyze_categorical_features(data):"""分析分类特征的唯一值个数,帮助我们了解数据概况。"""categorical_features = ['name', 'model', 'brand', 'bodyType', 'fuelType', 'gearbox', 'notRepairedDamage', 'regionCode', 'seller', 'offerType']print("\n分类特征分析:")print("-" * 50)for feature in categorical_features:if feature in data.columns:unique_values = data[feature].nunique()print(f"{feature}: {unique_values} 个唯一值")print("-" * 50)
def process_data(data, label_encoders=None, is_train=True):"""处理数据的主函数,包括日期特征和类别特征。"""# --- 第一板斧:处理日期特征 ---print("\n处理日期特征...")reg_date_features = data['regDate'].apply(process_date)creat_date_features = data['creatDate'].apply(process_date)# 添加拆分后的年月日特征data['reg_year'] = reg_date_features['year']data['reg_month'] = reg_date_features['month']data['creat_year'] = creat_date_features['year']# 创造核心特征:车龄data['car_age'] = data['creat_year'] - data['reg_year']# 创造核心特征:日期差异(时间的连续表示)if is_train:base_date = reg_date_features['date'].min()# 将基准日期保存下来,以便测试集使用if not os.path.exists('processed_data'):os.makedirs('processed_data')joblib.dump(base_date, 'processed_data/base_date.joblib')else:base_date = joblib.load('processed_data/base_date.joblib')
if pd.isna(base_date):print("警告:找不到有效的基准日期!")else:data['reg_date_diff'] = (reg_date_features['date'] - base_date).dt.daysdata['creat_date_diff'] = (creat_date_features['date'] - base_date).dt.days# 用中位数填充可能出现的缺失值data['reg_date_diff'].fillna(data['reg_date_diff'].median(), inplace=True)data['creat_date_diff'].fillna(data['creat_date_diff'].median(), inplace=True)# 对其他可能缺失的日期特征也进行中位数填充for col in ['reg_year', 'reg_month', 'creat_year', 'car_age']:data[col].fillna(data[col].median(), inplace=True)# 删除已经“榨干”价值的原始日期列data = data.drop(['regDate', 'creatDate'], axis=1)# --- 第二板斧:处理类别特征 ---categorical_features = ['name', 'model', 'brand', 'bodyType', 'fuelType', 'gearbox', 'notRepairedDamage', 'regionCode']if is_train:label_encoders = {}for feature in categorical_features:if feature in data.columns:# 创建并训练LabelEncoderle = LabelEncoder()data[feature] = le.fit_transform(data[feature].astype(str))label_encoders[feature] = lereturn data, label_encoderselse:# 测试集使用已经训练好的LabelEncoderfor feature in categorical_features:if feature in data.columns and feature in label_encoders:le = label_encoders[feature]# 处理测试集中可能出现的新类别unknown_mask = ~data[feature].astype(str).isin(le.classes_)if unknown_mask.any():# 将新类别替换为训练集中最常见的类别(这里简化处理,也可以设为特定值如-1)most_common_class_index = pd.Series(le.transform(le.classes_)).mode()[0]data.loc[unknown_mask, feature] = le.classes_[most_common_class_index]data[feature] = le.transform(data[feature].astype(str))return data
def main():"""主执行函数"""# 创建保存处理后数据的目录if not os.path.exists('processed_data'):os.makedirs('processed_data')# --- 处理训练数据 ---print("正在加载训练数据...")train_data = pd.read_csv('used_car_train_20200313.csv', sep=' ', encoding='utf-8')# 预处理processed_train_data, label_encoders = process_data(train_data.copy(), is_train=True)# 分离特征和目标X = processed_train_data.drop('price', axis=1)y = processed_train_data['price']# 划分训练集和验证集X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)# 保存处理后的数据和编码器print("\n保存处理后的训练/验证数据及编码器...")joblib.dump(X_train, 'processed_data/X_train.joblib')joblib.dump(X_val, 'processed_data/X_val.joblib')joblib.dump(y_train, 'processed_data/y_train.joblib')joblib.dump(y_val, 'processed_data/y_val.joblib')joblib.dump(label_encoders, 'processed_data/label_encoders.joblib')print("保存成功!")
# --- 处理测试数据 ---print("\n正在处理测试数据...")test_data = pd.read_csv('used_car_testB_20200421.csv', sep=' ', encoding='utf-8')sale_ids = test_data['SaleID']# 使用训练阶段的编码器来处理测试数据processed_test_data = process_data(test_data.copy(), label_encoders, is_train=False)# 保存处理后的测试数据print("\n保存处理后的测试数据...")joblib.dump(processed_test_data, 'processed_data/test_data.joblib')joblib.dump(sale_ids, 'processed_data/sale_ids.joblib')print("保存成功!")print("\n所有数据预处理完成!可以开始模型训练了!")
if __name__ == "__main__":main()
总结
记住,数据决定了机器学习的上限,而模型只是在逼近这个上限。今天我们通过驯服时间、摆平分类、扫清障碍这三板斧,成功地将原始数据“点石成金”。这套心法不仅能让你在二手车价格预测中游刃有余,更能让你在未来的任何数据科学项目中,都成为那个最懂数据的“炼金术师”。
觉得有用的话,点赞、收藏、关注三连,就是对我最大的支持!
