程序员求职经验分享与学习资料整理平台

网站首页 > 文章精选 正文

大数据集特征工程实践:将54万样本预测误差降低68%代码实现详解

balukai 2025-07-02 17:35:51 文章精选 8 ℃

特征工程作为机器学习流程中的关键环节,在模型训练之前执行,其质量直接影响模型性能。虽然深度学习模型在图像和文本等非结构化数据的特征自动学习方面表现优异,但对于表格数据集而言,显式特征工程仍然是不可替代的核心技术。本文通过实际案例演示特征工程在回归任务中的应用效果,重点分析包含数值型、分类型和时间序列特征的大规模表格数据集的处理方法。

特征工程基础理论

特征工程是指从原始数据中选择、转换和构建新特征的系统性过程,旨在提升机器学习模型的预测性能。该过程需要运用领域知识,从数据中提取最具预测价值的信息,并将其转换为适合特定机器学习算法的表示形式。

特征工程的核心价值

精心设计的特征能够显著增强模型的预测能力,使简单模型也能够捕获复杂的数据关系。特征工程的主要价值体现在以下几个方面:

首先,特征工程能够有效减少数据稀疏性。现实世界的数据集通常存在大量零值和缺失值,导致数据稀疏。通过特征工程技术整合信息并创建更加密集的数据表示,可以显著提升模型的学习效率。

其次,特征工程解决了异构数据类型的处理问题。原始数据包含数值型、分类型、文本型和时间型等多种格式,特征工程将这些不同类型的数据统一转换为模型可处理的数值格式。

最后,特征工程能够有效降低数据噪声和异常值的影响,从而构建更加稳健的预测模型。

当特征能够直接对应问题域中的有意义概念时,模型的决策过程将更具可解释性、准确性和稳健性。

常用特征工程技术

特征工程包含多种技术方法,每种方法都有其特定的适用场景:

对数变换技术主要应用于数值特征的处理,通过对数函数使数据分布更趋向于正态分布,满足许多机器学习模型的统计假设。该技术特别适用于处理具有显著偏斜分布的数值特征。

多项式特征创建技术通过将现有特征提升到更高次幂(如x^2、x^3)或创建交互项(如x×y)来生成新特征。这种方法主要用于捕获数据中的非线性关系。

分箱(离散化)技术将连续数值划分为若干区间,可以有效减少小幅波动的影响,使非线性关系更趋向于线性。该技术特别适用于使用线性模型处理非线性关系,以及数据包含显著异常值或偏斜的情况。

基于时间的特征工程包括提取星期几、月份、年份、小时、季度等基础时间特征,以及构建"是否周末"、"是否假期"等复合特征。同时,还可以计算事件间的时间差。这类特征主要适用于存在季节性模式影响预测结果的场景。

机器学习项目工作流程

虽然不存在适用于所有情况的通用方法,但特征工程的一般工作流程通常涵盖整个项目生命周期,从问题定义和成功指标制定开始。本文将重点演示第一阶段和第二阶段的实施过程,特别关注特征工程的具体应用。

图:机器学习项目工作流程与特征工程位置

实验设计与实施

为了验证特征工程的效果,本文将按照上述工作流程逐步进行实验演示。

第一阶段:基础建设

问题定义

在线零售业务中,准确预测未来客户支出对于营销策略制定、库存管理和战略规划具有重要意义。假设某企业在销售增长方面遇到瓶颈,急需通过数据分析获得可操作的业务洞察。

成功指标确定

考虑到销售数据可能存在偏斜分布的特性,本文选择平均绝对误差(MAE)作为主要评估指标,因为MAE对偏斜数据具有更好的稳健性。同时,将均方误差(MSE)作为辅助评估指标。

探索性数据分析与特征工程

这是数据准备阶段的核心环节。实验使用来自UC Irvine机器学习存储库的在线零售数据集,该数据集采用知识共享署名4.0国际(CC BY 4.0)许可协议。

图:数据集变量描述表

import os 
import pandas as pd 

df = pd.read_csv(csv_file_path) 
df.info()

数据集包含541,909个数据点和8个特征:

加载的数据集概览

需要注意的是,实际应用中的数据可能来源于简单的Excel表格、云服务器存储的信息,或者多个数据源的组合。

数据清理

在进入探索性数据分析之前,首先根据以下通用原则对数据进行清理:

数值类型保持原则:如果某列在逻辑上应该是数值型(如数量、单价、客户ID),则保持其数值类型。数值操作在数值类型上更加高效且语义明确。

对象类型处理原则:pandas中的object数据类型适用于包含混合类型或主要为字符串的列。当列确实包含数字和字符串的混合数据时,object类型通常是默认且必要的选择。

缺失数据统一处理原则:pandas对数值型缺失数据使用NaN,对对象型缺失数据使用None。然而,在数据读取过程中,缺失值可能表现为空字符串、特定文本(如"N/A")或空格,pandas可能无法自动识别。因此需要主动识别并转换这些值。

缺失值识别与处理

对于混合数据类型的列,将潜在的缺失值(如空格或'nan'字符串)转换为NumPy的NaN格式,这是后续准确填充的关键准备步骤。

import numpy as np 

obj_cols = ['invoiceno', 'stockcode', 'country', 'invoicedate'] 

# 定义潜在的缺失值表示 
null_vals = ['', 'nan', 'N/A', None, 'na', 'None', 'none'] 
replaced_null = { item: np.nan for item in null_vals } 

for col in obj_cols: 
df[col].replace(replaced_null, inplace=True) 

df.info()

识别各列中的缺失数据(NumPy的NaN)分布:

for i, col in enumerate(df.columns): 
unique_num = df[col].nunique() 
nan_num = df[col].isna().sum() 
print(f'{i}. {col} - {unique_num:,} data points (missing data: {nan_num:,})')

图:各列缺失值统计结果

针对description和customerid列的缺失数据采用以下处理策略:

对于description列,基于其对未来预测影响有限的假设,决定删除该列。需要注意的是,文本数据在产品因子分析中可能具有价值,特别是当缺失比例较小时。

对于customerid列,保留原始列以评估唯一用户的影响,同时引入新的is_registered二进制列(1表示注册用户,0表示未注册用户),假设无ID的客户为未注册状态。需要注意customerid可能导致高基数问题,后续将采用二进制编码解决。

# 在特征工程前复制基础数据集 
df_rev = df.copy() 

df_rev = df_rev.drop(columns='description') 
df_rev['is_registered'] = np.where(df_rev['customerid'].isna(), 0, 1)

数据类型转换

考虑到后续特征工程的需要,对invoicedate和customerid的数据类型进行转换:

import pandas as pd 

df_rev['invoicedate'] = pd.to_datetime(df_rev['invoicedate']) 
df_rev['customerid'] = df_rev['customerid'].astype('Int64') 
df_rev.info()

列#7已添加,列#5的数据类型已更新

探索性数据分析(EDA)与特征工程实施

数据清理完成后,开始进行探索性数据分析。EDA是一种专注于总结和可视化数据主要特征的分析技术。虽然对复杂数据集可以进行多维度的EDA,但本文的重点是识别需要工程化的特征,并获得数据预处理的洞察以提升模型性能。

其他更深层次的分析应该由模型本身处理,因为真正的潜在模式往往过于微妙或复杂,难以通过人工发现。因此,EDA成为模型识别哪些分析(如隐藏趋势、组间差异)值得转化为预测特征的第一步。

EDA必须包含两个层面:基础分析(单变量焦点)用于数据理解,以及项目特定分析(双变量焦点)基于与项目目标直接相关的假设。

单变量分析

这个初始阶段通过独立分析每个变量来建立对数据集的基础理解。

为了准备EDA,首先从invoicedate列中提取year、month、day_of_week特征,并按invoicedate顺序对数据进行排序:

df_rev['invoicedate'] = pd.to_datetime(df_rev['invoicedate']) 
df_rev['year'] = df_rev['invoicedate'].dt.year 
df_rev['year_month'] = df_rev['invoicedate'].dt.to_period('M') 
df_rev['month_name'] = df_rev['invoicedate'].dt.strftime('%b') 
df_rev['day_of_week'] = df_rev['invoicedate'].dt.strftime('%a') 
df_rev = df_rev.sort_values('invoicedate')

同时引入sales列用于销售分析:

df_rev['sales'] = df_rev['quantity'] * df_rev['unitprice']

数据集结构如下:

列#8到#12已添加

数据分布分析

为数值特征绘制概率密度函数(PDF),为分类特征绘制直方图,以识别异常值、偏斜和重尾等特征。虽然真实的数据分布过于复杂难以完全掌握,但这种分析对于有效的预处理和模型选择至关重要。

数值列的概率密度分析

unitprice和sales都表现出稀疏性,具有重尾分布和显著异常值。基于对偏斜数据的稳健性考虑,采用MAE作为评估指标。

图:unitprice和sales的概率密度函数

unitprice统计特征:最大值38,970.0,最小值-11,062.1,均值4.6,标准差96.8
sales统计特征:最大值168,469.6,最小值-168,469.6,均值18.0,标准差378.8

分类特征的分布分析

invoiceno、year_month和day_of_week呈现相对均匀的分布:

stockcode在左侧呈现峰值,右侧呈现长尾分布,而quantity和country显示退化分布,数据集中在少数几个类别:

is_registered和year的结果均为二元分布:

基于上述分析,将进行项目特定的EDA以确定其他特征工程机会。

项目特定分析

这个阶段深入探索变量间的关系,特别是潜在特征与目标变量sales之间的关系。

首先,基于销售增长挑战的潜在解决方案建立三个假设。在实际应用中,这些假设可以通过业务专家和领域知识进一步完善。

假设一:时间模式驱动的销售趋势

"销售趋势由星期几或月份中的某一天驱动。"

考虑到数据集包含有限的13个月销售数据(具有二元年份变量),重点关注较短的趋势周期。

潜在特征工程方向:is_weekend、day_of_month
潜在业务解决方案:与趋势一致的短期促销活动

假设二:产品-时间-价格关联的销售驱动

"产品销售由时间和价格点共同驱动。"

潜在特征工程方向包括:

unit_price_bin:将unitprice离散化为'低'、'中'、'高'类别,直接解决价格影响和非线性关系问题。


product_avg_quantity_last_month:计算每个stockcode在上个日历月销售的平均quantity,捕获最近的产品受欢迎程度。


product_sales_growth_last_month:stockcode从两个月前到上个月的销售百分比变化,识别趋势产品。

潜在业务解决方案包括:动态定价(通过促销时间预测最优价格点的模型)和定制产品推荐(预测产品因子相似性的模型)。

假设三:客户活跃度与消费关联

"活跃客户倾向于购买更多并为销售做出贡献。"

潜在特征工程方向包括:

customer_recency_days:预测日期(上个月末)与客户最后购买日期之间的天数,评估近期购买可能性。

customer_total_spend_ltm:客户在最近3个月产生的总销售收入,直接衡量客户近期货币价值。

customer_freq_ltm:客户在最近3个月进行的唯一发票总数,作为直接影响销售的参与度指标。

潜在业务解决方案包括:分层客户忠诚度计划(预测唯一用户保留时间的模型)和营销媒体组合优化(预测新客户价值的模型)。

现在执行EDA并决定实际工程化的特征。

假设验证与特征选择

假设一验证:时间模式分析

按月份和星期几的销售趋势分析显示,除11月的峰值外,没有发现显著的时间模式。因此,决定不从此假设中添加其他特征。

图:按年月和星期几的销售趋势分析

假设二验证:产品-价格模式分析

对于unit_price_bin分析,所有三个价格区间的中位线在几乎所有月份都接近零。所有区间的四分位距(IQR)也很小,表明25-75百分位数据落在很小的低数量范围内。

然而,观察到异常值主导高数量区域,形成清晰的分层结构。

因此,决定添加这个特征,同时保留原始的unit_price以保持粒度,使用区间内的确切值来预测数量。

图:按价格范围(低、中、高)分层的月度总单位数销售,显示中位数和四分位距

将unit_price_bin添加到最终数据集:

import pandas as pd 

# df_fin将是模型训练的主要数据集 
df_fin = df_rev.copy() 

# 创建临时数据集 
_df_prod_month_agg = df_fin.copy().groupby(['stockcode', 'year_month']).agg( 
prod_total_monthly_quantity=('quantity', 'sum'), 
prod_ave_monthly_price=('unitprice', 'mean') 
).reset_index().sort_values(by=['stockcode', 'year_month']) 

_df_prod_month_agg['unit_price_bin'] = pd.qcut( 
_df_prod_month_agg['prod_ave_monthly_price'], 
q=3, 
labels=['low', 'mid', 'high'], 
duplicates='drop' 
) 

_df_prod_bin_per_stockcode = _df_prod_month_agg.groupby('stockcode')['unit_price_bin'].agg( 
lambda x: x.mode()[0] if not x.mode().empty else None 
).reset_index() 

# 合并到主数据集(df_fin) 
df_fin = pd.merge( 
df_fin, 
_df_prod_bin_per_stockcode[['stockcode', 'unit_price_bin']], 
on='stockcode', 
how='left' 
) 

df_fin.info()

列#13已添加


product_avg_quantity_last_month也显示出非常强的正相关性,作为动量特征,表明上个月销售良好的产品在当月也倾向于表现良好。将添加这个特征。

图:本月和上月产品平均销售的相关性分析


product_avg_quantity_last_month添加到最终数据集(同时处理填充):

import pandas as pd 

_df_prod_month_agg['product_avg_quantity_last_month'] = _df_prod_month_agg.groupby('stockcode')['prod_total_monthly_quantity'].shift(1) 
_df_prod_last_month_agg = _df_prod_month_agg.groupby('stockcode')['product_avg_quantity_last_month'].mean().reset_index() 
df_fin = pd.merge( 
df_fin, 
_df_prod_last_month_agg [['stockcode', 'product_avg_quantity_last_month']], 
on='stockcode', 
how='left' 
) 

# 缺失数据表示期间内没有产品销售,用零填充 
df_fin['product_avg_quantity_last_month'] = df_fin['product_avg_quantity_last_month'].fillna(value=0) 
df_fin.info()

列#14已添加

另一方面,
product_sales_growth_last_month没有显示强的线性或单调关系。考虑到此特征的预测能力有限,选择不添加该特征。

图:月度产品数量与上月销售增长率的关系分析

假设三验证:客户活跃度分析

customer_recency_days显示近期度较低的客户(更近期的购买,例如x < 60天)倾向于表现出更高的月度销售收入,表明反向关系(图中的红色虚线)。

将添加这个特征来预测月度销售收入。

图:月度销售与客户近期度天数的关系

将customer_recency_days添加到数据集:

import pandas as pd 

# 创建临时数据集 
_df_all_customers_year_month = pd.MultiIndex.from_product( 
[df_fin['customerid'].unique(), df_fin['year_month'].unique()], # type: ignore 
names=['customerid', 'year_month'] 
).to_frame(index=False).sort_values(by=['customerid', 'year_month']).reset_index(drop=True) 

_df_customer_monthly_agg = df_fin.copy().groupby(['customerid', 'year_month']).agg( 
monthly_sales=('sales', 'sum'), 
monthly_unique_invoices=('invoiceno', 'nunique'), 
monthly_last_purchase_date=('invoicedate', 'max') 
).reset_index() 

_df_cus = _df_all_customers_year_month.merge(_df_customer_monthly_agg, on=['customerid', 'year_month'], how='left').sort_values(by=['customerid', 'year_month']) 

# 添加时间戳 
_df_cus['pfin_last_purchase_date'] = _df_cus.groupby('customerid')['monthly_last_purchase_date'].shift(1) 
_df_cus['invoice_timestamp_end'] = _df_cus['year_month'].dt.end_time 

# 计算近期度天数 
_df_cus['customer_recency_days'] = (_df_cus['invoice_timestamp_end'] - _df_cus['pfin_last_purchase_date']).dt.days 

# 合并和填充 
df_fin['customer_recency_days'] = _df_cus['customer_recency_days'] 

max_recency = _df_cus['customer_recency_days'].max() 
df_fin['customer_recency_days'] = df_fin['customer_recency_days'].fillna(value=max_recency + 30) 

df_fin.info()

列#15已添加

customer_total_spend_ltm显示客户在过去三个月的总支出与其当前月度销售收入之间存在明显的正相关性。这表明较高的历史支出通常对应较高的当前收入,使其成为优秀的预测特征。将添加这个特征。

图:月度销售与客户过去三个月总支出的关系

添加customer_total_spend_ltm:

_df_cus['customer_total_spend_ltm'] = _df_cus.groupby('customerid')['monthly_sales'].rolling(window=3, closed='left').sum().reset_index(level=0, drop=True) 

df_fin['customer_total_spend_ltm'] = _df_cus['customer_total_spend_ltm'] 
df_fin['customer_total_spend_ltm'] = df_fin['customer_total_spend_ltm'].fillna(value=0) 

df_fin.info()

列#16已添加

customer_freq_ltm也显示客户在过去三个月的购买频率与其当前月度销售收入之间的正关系。在前三个月有更多唯一发票的客户倾向于产生更高的月度收入。也将添加这个特征。

图:月度销售与客户过去三个月频率的关系

添加customer_freq_ltm:

_df_cus['customer_freq_ltm'] = _df_cus.groupby('customerid')['monthly_unique_invoices'].rolling(window=3, closed='left').sum().reset_index(level=0, drop=True) 

df_fin['customer_freq_ltm'] = _df_cus['customer_freq_ltm'] 
df_fin['customer_freq_ltm'] = df_fin['customer_freq_ltm'].fillna(value=0) 

df_fin.info()

列#17已添加

缺失值最终处理

特征工程完成后,执行最终的缺失值检查,在更新数据集的stockcode、quantity、country和unit_price_bin列中发现了少量缺失项:

df_fin.isna().sum()

(客户ID中的缺失值将在编码过程中处理。)

对这些缺失项进行逐一检查并执行填充。考虑到540k+样本中只有最多20个缺失值,行删除(简单地从数据集删除样本)可能是一个可行的选择。

对于stockcode/unit_price_bin,缺失stockcode的样本中的其他值看起来是合法的。用'unknown'替换stockcode中的NaN,用'low'替换unit_price_bin:

df_null = df_fin[df_fin['stockcode'].isnull()]
df_null.head().transpose()

df_fin['stockcode'] = df_fin['stockcode'].fillna(value='unknown') 
df_fin['unit_price_bin'] = df_fin['unit_price_bin'].fillna(value='low')

采用相同的处理方式,country和quantity列中的缺失值分别用其众数值和sales/unitprice计算值填充:

import numpy as np 

df_fin['country'] = df_fin['country'].fillna(value=df_fin['country'].mode().iloc[0]) 
df_fin['quantity'] = df_fin['quantity'].fillna(value=np.floor(df_fin['sales'] / df_fin['unitprice']))

最后,转换数据类型以完成数据集构建:

df_fin['year_month'] = df_fin['year_month'].dt.month 
df_fin['invoicedate'] = df_fin['invoicedate'].astype(int) / 10 ** 9 
df_fin = df_fin.drop(columns=['month_name'], axis='columns') 

df_fin.info()

最终数据集结构

最终版本的数据集包含541,909个样本和17个特征:

cat_cols = [ 
'invoiceno', 
'stockcode', 
'quantity', 
'customerid', 
'country', 
'year', 
'year_month', 
'day_of_week', 
'is_registered', 
'unit_price_bin', 
'customer_recency_days', 
] 
num_cols = [ 
'unitprice', 
'product_avg_quantity_last_month', 
'customer_total_spend_ltm', 
'customer_freq_ltm', 
'invoicedate' 
] 

target_col = 'sales'

第一阶段特征工程总结

基于EDA结果,添加了11个特征:

来自单变量EDA的特征:is_registered、year、year_month、month_name、day_of_week、sales
来自双变量EDA的特征:unit_price_bin、
product_avg_quantity_last_month、customer_recency_days、customer_total_spend_ltm、customer_freq_ltm

删除了一个特征:description,由于其大量缺失值和对预测的有限影响。

模型选择与训练

考虑到复杂的大型数据集特点,选择了以下三个模型:

弹性网络(Elastic Net):一个正则化线性回归模型,适合作为线性可分数据的基线模型。

随机森林(Random Forest):一个强大的机器学习模型,能够捕获复杂的非线性关系。

深度前馈网络(Deep Feedforward Network):一个深度学习模型,作为非线性可分数据的强基线。为了有效管理大型数据集,使用PyTorch库。

预处理数据上的模型训练

首先,将数据集分为训练集、验证集和测试集。为了保持时间顺序,故意没有打乱数据集。

from sklearn.model_selection import train_test_split 

target_col = 'sales' 
X = df_fin.copy().drop(columns=target_col) 
y = df_fin.copy()[target_col] 

test_size = 50000 
X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=test_size, random_state=42) 
X_train, X_val, y_train, y_val = train_test_split(X_tv, y_tv, test_size=test_size, random_state=42)

每个模型对预处理有不同的需求:

图:不同模型的数据预处理需求

因此,将分别为训练每个模型准备数据集。

弹性网络模型

弹性网络需要在缩放和编码的数据集上训练。

对于数值特征,应用RobustScaler来处理在EDA期间发现的显著异常值。

对于分类特征,应用BinaryEncoder来限制维度增加,同时用零替换customerid列中的缺失值:

from sklearn.preprocessing import RobustScaler 
from sklearn.compose import ColumnTransformer 
from sklearn.pipeline import Pipeline 
from category_encoders import BinaryEncoder 

# 数值特征处理管道 
num_transformer = Pipeline(steps=[ 
('scaler', RobustScaler(with_centering=True, with_scaling=True)) 
]) 

# 分类特征处理管道 
cat_transformer = Pipeline(steps=[ 
('encoder', BinaryEncoder(cols=cat_cols, handle_missing='0')) 
]) 

# 定义预处理器 
preprocessor_en = ColumnTransformer( 
transformers=[ 
('num', num_transformer, num_cols), 
('cat', cat_transformer, cat_cols) 
], 
remainder='passthrough', 
) 

# 数据转换 
X_train_processed = preprocessor_en.fit_transform(X_train) 
X_val_processed = preprocessor_en.transform(X_val) 
X_test_processed = preprocessor_en.transform(X_test) 

# 模型初始化和训练 
from sklearn.linear_model import ElasticNet 
elastic_net = ElasticNet( 
alpha=1, # 正则化总强度 
l1_ratio=0.5, # l1与l2比例 = 1:1 
fit_intercept=True, # 通过计算拟合y截距 
precompute=False, # 不使用预计算的Gram矩阵 
max_iter=5000, # 5000轮迭代 
copy_X=True, # 在拟合前复制X 
tol=1e-5, # 停止迭代的容差 
random_state=42, # 随机数生成器种子 
warm_start=False, # 忽略前一次拟合调用的解 
positive=False, # 可以有负和正系数 
selection="cyclic" # 循环地逐个更新系数(与随机相对) 
).fit(X_train_processed, y_train)

随机森林模型

对于随机森林,可以跳过缩放步骤:

from sklearn.compose import ColumnTransformer 
from sklearn.pipeline import Pipeline 
from sklearn.ensemble import RandomForestRegressor 
from category_encoders import BinaryEncoder 

# 定义预处理器 
cat_transformer = Pipeline(steps=[ 
('encoder', BinaryEncoder(cols=cat_cols, handle_missing='0')) 
]) 

preprocessor_rf = ColumnTransformer( 
transformers=[ 
('cat', cat_transformer, cat_cols) 
], 
remainder='passthrough', 
) 

# 数据转换 
X_train_processed = preprocessor_rf.fit_transform(X_train) 
X_val_processed = preprocessor_rf.transform(X_val) 
X_test_processed = preprocessor_rf.transform(X_test) 

# 模型初始化和训练 
random_forest = RandomForestRegressor( 
n_estimators=1000, 
criterion="squared_error", 
max_depth=None, 
min_samples_split=2, 
min_samples_leaf=1, 
min_weight_fraction_leaf=0, 
max_features='sqrt', 
max_leaf_nodes=None, 
min_impurity_decrease=1e-10, 
bootstrap=True, 
oob_score=True, 
n_jobs=-1, 
random_state=42, 
verbose=0, 
warm_start=False, 
ccp_alpha=0, 
max_samples=None, 
).fit(X_train_processed, y_train)

深度前馈网络模型

DFN需要缩放和编码。对于数值特征,使用StandardScaler考虑到DFN在处理复杂数据方面的稳健性。然后将数据集转换为TensorDataset:

import torch 
from torch.utils.data import DataLoader, TensorDataset 
from sklearn.preprocessing import StandardScaler 
from sklearn.compose import ColumnTransformer 
from sklearn.pipeline import Pipeline 
from category_encoders import BinaryEncoder 

num_transformer = Pipeline(steps=[('scaler', StandardScaler())]) 
cat_transformer = Pipeline(steps=[('encoder', BinaryEncoder(cols=cat_cols, handle_missing='0'))]) 

# 定义预处理器 
preprocessor_dfn = ColumnTransformer( 
transformers=[ 
('num', num_transformer, num_cols), 
('cat', cat_transformer, cat_cols) 
], 
remainder='passthrough' 
) 

# 数据转换 
X_train_processed_dfn = preprocessor_dfn.fit_transform(X_train) 
X_val_processed_dfn = preprocessor_dfn.transform(X_val) 
X_test_processed_dfn = preprocessor_dfn.transform(X_test) 

# 将NumPy数组转换为PyTorch张量 
X_train_tensor = torch.tensor(X_train_processed_dfn, dtype=torch.float32) 
X_val_tensor = torch.tensor(X_val_processed_dfn, dtype=torch.float32) 
X_test_tensor = torch.tensor(X_test_processed_dfn, dtype=torch.float32) 

# 转换1D张量 
y_train_tensor = torch.tensor(y_train.values, dtype=torch.float32).view(-1, 1) 
y_val_tensor = torch.tensor(y_val.values, dtype=torch.float32).view(-1, 1) 
y_test_tensor = torch.tensor(y_test.values, dtype=torch.float32).view(-1, 1) 

# 转换为TensorDataset 
train_dataset = TensorDataset(X_train_tensor, y_train_tensor) 
val_dataset = TensorDataset(X_val_tensor, y_val_tensor) 
test_dataset = TensorDataset(X_test_tensor, y_test_tensor) 

# 批处理 
batch_size = 32 
train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=False) 
val_loader = DataLoader(dataset=val_dataset, batch_size=batch_size, shuffle=False) 
test_loader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False)

然后,初始化模型:

import numpy as np 
import torch 
import torch.nn as nn 
import torch.optim as optim 

class DFN(nn.Module): 
def __init__(self, input_dim): 
super(DFN, self).__init__() 
self.fc1 = nn.Linear(input_dim, 32) 
self.relu1 = nn.ReLU() 
self.dropout1 = nn.Dropout(0.1) 
self.fc2 = nn.Linear(32, 16) 
self.relu2 = nn.ReLU() 
self.dropout2 = nn.Dropout(0.1) 
self.fc3 = nn.Linear(16, 1) 
def forward(self, x): 
x = self.fc1(x) 
x = self.relu1(x) 
x = self.dropout1(x) 
x = self.fc2(x) 
x = self.relu2(x) 
x = self.dropout2(x) 
x = self.fc3(x) 
return x 

input_dim = X_train_processed_dfn.shape[1] 

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') 

model = DFN(input_dim).to(device) 
criterion = nn.L1Loss() 
optimizer = optim.Adam(model.parameters(), lr=0.001)

模型训练过程:

from sklearn.metrics import mean_squared_error, mean_absolute_error 

num_epochs = 100 
best_val_loss = float('inf') 
patience = 10 
patience_counter = 0 
min_delta = 1e-4 
history = { 
'train_loss': [], 
'val_loss': [], 
'train_mse': [], 
'val_mse': [], 
'train_mae': [], 
'val_mae': [] 
} 

for epoch in range(num_epochs): 
model.train() 

running_train_loss = 0.0 
all_train_preds = [] 
all_train_targets = [] 

for batch_idx, (data, target) in enumerate(train_loader): 
data, target = data.to(device), target.to(device) 
optimizer.zero_grad() 
outputs = model(data) 
loss = criterion(outputs, target) 
loss.backward() 
optimizer.step() 
running_train_loss += loss.item() * data.size(0) 
all_train_preds.extend(outputs.detach().cpu().numpy()) 
all_train_targets.extend(target.detach().cpu().numpy()) 

epoch_train_loss = running_train_loss / len(train_dataset) 
train_mse = mean_squared_error(np.array(all_train_targets), np.array(all_train_preds)) 
train_mae = mean_absolute_error(np.array(all_train_targets), np.array(all_train_preds)) 

model.eval() 
running_val_loss = 0.0 
all_val_preds = [] 
all_val_targets = [] 

with torch.no_grad(): 
for data, target in val_loader: 
data, target = data.to(device), target.to(device) 
outputs = model(data) 
loss = criterion(outputs, target) 
running_val_loss += loss.item() * data.size(0) 
all_val_preds.extend(outputs.cpu().numpy()) 
all_val_targets.extend(target.cpu().numpy()) 

epoch_val_loss = running_val_loss / len(val_dataset) 
val_mse = mean_squared_error(np.array(all_val_targets), np.array(all_val_preds)) 
val_mae = mean_absolute_error(np.array(all_val_targets), np.array(all_val_preds)) 

history['train_loss'].append(epoch_train_loss) 
history['val_loss'].append(epoch_val_loss) 
history['train_mse'].append(train_mse) 
history['val_mse'].append(val_mse) 
history['train_mae'].append(train_mae) 
history['val_mae'].append(val_mae)

第一阶段实验结果

平均绝对误差(MAE)表现:

弹性网络:训练集19.773 → 验证集18.508
随机森林:训练集4.147 → 验证集10.551
深度前馈网络:训练集10.570 → 验证集10.987

结果分析:

弹性网络表现出良好的泛化能力(19.77训练,18.51验证),但具有最高的平均误差。其预测与实际销售的偏差约为19.77。

随机森林显现显著过拟合现象(4.15训练,10.55验证)。平均而言,其在新数据上的预测偏差约$10.55。

深度前馈网络(DFN)显示出优秀的泛化能力(10.57训练,10.99验证)并在未见数据上实现低平均误差。其预测偏差约$10.99。

总结而言,随机森林是性能最佳的模型,但DFN在泛化方面表现最为出色。

图:实际与预测销售对比(左:弹性网络,中:随机森林),DFN的损失历史(右)

第二阶段:迭代优化

第一阶段的结果表明所有三个模型的泛化能力仍有改进空间。

为了进一步优化模型性能,对销售值应用了对数变换,为模型的目标变量创建更对称的分布。

为了区分退款(sales列中的负销售)和正销售,创建了一个is_return二进制标志(1表示退款,0表示销售)。这使得sales列可以专注于正销售值。

从数学角度考虑,对负值取对数会产生NaN。首先用零替换负销售值,然后应用拉普拉斯平滑。这也防止了对数销售中的负无穷值。

import numpy as np 

# 使用新数据集 
df_fin_rev = df_fin.copy() 

# 添加退货标志 
df_fin_rev['is_return'] = (df_fin_rev['sales'] < 0).astype(int) 

# 销售列中的零或正值 
df_fin_rev['sales'] = df_fin_rev['sales'].apply(lambda x: max(x, 0)) 

# 在取对数前应用拉普拉斯平滑 
alpha = 1 
df_fin_rev['sales'] = np.log(df_fin_rev['sales'] + alpha) 

df_fin_rev.info()

列#17已添加,列#11已转换

检查数据集中除customerid列外没有缺失值:

df_fin_rev.isna().sum()

使用相同的预处理步骤和超参数重新训练模型:

import numpy as np 
from sklearn.model_selection import train_test_split 
from sklearn.preprocessing import StandardScaler 
from sklearn.compose import ColumnTransformer 
from sklearn.pipeline import Pipeline 
from sklearn.metrics import mean_squared_error, mean_absolute_error 
from category_encoders import BinaryEncoder 

# 创建数据集 
X = df_fin_rev.copy().drop(columns=target_col) 
y = df_fin_rev.copy()[target_col] 

test_size = 50000 
X_tv, X_test, y_tv, y_test = train_test_split(X, y, test_size=test_size, random_state=42) 
X_train, X_val, y_train, y_val = train_test_split(X_tv, y_tv, test_size=test_size, random_state=42) 

# 预处理 
num_transformer = Pipeline(steps=[('scaler', StandardScaler())]) 
cat_transformer = Pipeline(steps=[('encoder', BinaryEncoder(cols=cat_cols, handle_missing='0'))]) 
preprocessor_en = ColumnTransformer( 
transformers=[ 
('num', num_transformer, num_cols), 
('cat', cat_transformer, cat_cols) 
], 
remainder='passthrough' 
) 
X_train_processed_en = preprocessor_en.fit_transform(X_train) 
X_val_processed_en = preprocessor_en.transform(X_val) 
X_test_processed_en = preprocessor_en.transform(X_test) 

# 模型训练 
elastic_net.fit(X_train_processed_en, y_train) 

# 预测(对数销售) 
y_pred_train = elastic_net.predict(X_train_processed_en) 
y_pred_val = elastic_net.predict(X_val_processed_en) 
y_pred_test = elastic_net.predict(X_test_processed_en) 

# 评估 - 对数销售 - 使用MSE 
mse_train = mean_squared_error(y_train, y_pred_train) 
mse_val = mean_squared_error(y_val, y_pred_val) 
mse_test = mean_squared_error(y_test, y_pred_test) 

# 评估 - 实际销售 - 使用MAE 
mae_train_exp = mean_absolute_error(np.exp(y_train), np.exp(y_pred_train)) 
mae_val_exp = mean_absolute_error(np.exp(y_val), np.exp(y_pred_val)) 
mae_test_exp = mean_absolute_error(np.exp(y_test), np.exp(y_pred_test))

第二阶段实验结果

使用对数销售数据的MSE和实值销售的MAE评估模型性能:

弹性网络:
对数销售的MSE:训练1.133 → 验证1.132,泛化1.122
实值销售的MAE:训练15.825 → 验证14.714,泛化16.509

随机森林:
对数销售的MSE:训练0.020 → 验证0.175,泛化0.176
实值销售的MAE:训练4.135 → 验证7.187,泛化9.041

深度前馈网络(DFN):
对数销售的MSE:训练1.079 → 验证0.165,测试数据集泛化0.079
实值销售的MAE:训练5.644 → 验证5.016,泛化6.197

(注:泛化性能在50,000个测试样本上评估。)

与第一阶段相比,所有模型的实值销售MAE都有改善,表明目标变量密度优化的重要性。

其中,深度前馈网络表现最佳,在训练(5.64)和泛化(6.20)上都有低MAE,表明其学习和泛化复杂大型数据集的高能力。其在未见数据上的预测偏差约$6.20。

弹性网络显示出优秀的泛化能力,但其在未见数据上的预测偏差为$16.51,是所有模型中最大的,表明其在处理复杂数据集方面的局限性。

随机森林表现出显著的过拟合,训练MAE很低(4.14)和更高的泛化MAE(9.04)之间存在很大差距。超参数调优以收紧正则化参数和树结构可以是此模型的下一步优化方向。

实验总结

实验表明,基于PyTorch的深度前馈网络在具有EDA期间识别特征的转换数据集上表现最佳。

回到关于业务解决方案的初始假设,可以直接利用这一发现进行营销媒体组合优化,例如,使DFN能够预测新客户的生命周期价值并优化高价值客户渠道的预算分配。

作为下一步,可以在第二阶段进一步探索特征工程,或者继续第三阶段调优超参数以优化结果。

总结

特征工程不仅仅是数据操作技术,它是从原始数据中获得强大洞察并显著提高模型解决实际问题能力的战略性方法。

在本文的实验中,观察到特征工程显著增强了模型的性能,特别是当它与探索性数据分析和业务目标紧密结合时。通过与领域专家和业务利益相关者合作来完善假设,可以期待进一步的改进。

通过在制作有效输入方面投入时间和精力,从根本上增强了模型学习、泛化和提供卓越预测性能的能力。特征工程作为机器学习流程中的核心环节,其质量直接决定了最终模型的实用价值和业务影响力。

作者:Kuriko IWAI

最近发表
标签列表