引爆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):
        # 如果转换失败(例如日期格式错误),返回NaN
        return 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.days
        data['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:
                # 创建并训练LabelEncoder
                le = LabelEncoder()
                data[feature] = le.fit_transform(data[feature].astype(str))
                label_encoders[feature] = le
        return data, label_encoders
    else:
        # 测试集使用已经训练好的LabelEncoder
        for 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()

总结

记住,数据决定了机器学习的上限,而模型只是在逼近这个上限。今天我们通过驯服时间、摆平分类、扫清障碍这三板斧,成功地将原始数据“点石成金”。这套心法不仅能让你在二手车价格预测中游刃有余,更能让你在未来的任何数据科学项目中,都成为那个最懂数据的“炼金术师”。

觉得有用的话,点赞、收藏、关注三连,就是对我最大的支持!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐