机器学习特征工程的最佳实践
特征工程是模型训练之前运行的关键过程,因为输入数据的质量直接决定了模型输出的质量。
虽然深度学习模型擅长从图像或文本等非结构化数据中自动学习特征,但明确的特征工程对于表格数据集仍然至关重要。
在本文中,云朵君将展示特征工程对回归任务的影响,特别关注具有混合数字、分类和基于时间的特征的大型表格数据集。
什么是特征工程
特征工程是从原始数据中选择、转换和创建新特征以提高机器学习模型性能的过程。
它涉及使用领域知识从数据中提取最相关的信息,并以适合所选机器学习算法的方式表示它。
特征工程的好处
精心设计的特征可以显著提高模型的预测能力,即使是简单的模型也可以掌握复杂的关系,因为它可以:
降低数据稀疏性:大多数现实世界的数据集都是稀疏的,包含许多零值和缺失值。特征工程可以整合信息并创建更密集的表示,使模型更容易学习。 处理多种数据类型: 原始数据有多种格式,例如数值、分类、文本和时间。特征工程将这些类型转换为模型可以处理的数字格式。 解决数据噪声和异常值:特征工程减轻了噪声数据和异常值的影响,从而产生了更稳健的模型。
当我们可以将特征格式化为直接对应于问题域中的有意义的概念时,它可以使模型的决策更具解释性、准确性和稳健性。
特征工程中的常用技术
一些常见的特征工程技术包括:
对数转换
将对数应用于数值特征。 可以使分布更加对称,使模型呈现正态分布。 最佳情况:处理倾斜的数值特征。
多项式特征创建
通过将现有特征提升到幂(例如 x2、x3)来生成新特征。 创建交互项(例如,x∗y)。 最佳情况:捕捉非线性关系。
分箱(离散化)
将连续的数值分组到箱中。 可以减少微小波动的影响并使关系更加线性。 最佳情况:用线性模型处理非线性关系,数据包含显著的异常值或偏差。
基于时间的特征
提取星期几、月份、年份、小时、季度,甚至更复杂的特征,如“是周末”、“是假期”。 计算事件之间的时间差。 最佳情况:季节性影响预测。
工作流程
虽然没有一刀切的方法,但特征工程的一般工作流程通常涉及从定义问题和成功指标开始的整个项目生命周期。
在本文中,我将演示第 1 阶段和第 2 阶段(图中的蓝色框),特别关注特征工程。
对于第 3 阶段,“通过机器学习实现准确性”涵盖了泛化的核心概念,假设了损失的根本原因。
第一阶段:基础
1. 问题
对于在线零售业务,了解未来客户支出对于营销、库存管理和战略规划至关重要。
让我们想象这样一个场景:企业在销售增长方面苦苦挣扎,寻找可行的见解。
2. 成功指标
我选择平均绝对误差 (MAE)作为主要指标,因为它对偏斜数据具有稳健性(稍后,在 EDA 期间,我将讨论目标变量:销售额的数据分布)并选择MSE作为支持指标。
3. EDA 和特征工程
这是初始阶段数据准备的基础部分。
为了演示,我将使用来自加州大学欧文分校机器学习库的在线零售数据:直接在@公众号:数据STUDIO 原文文末留言需要即可,云朵君将回复给大家:
import os
import pandas as pd
df = pd.read_csv(csv_file_path)
df.info()
该数据集有541,909 个数据点,具有八个特征:
实际上,这些数据可以是简单的 Excel 表,也可以是存储在云服务器上的信息,或者我们可以组合多个数据源。
数据清理
在进入 EDA 之前,我将按照一般原则清理数据:
保留数字类型: 如果某列应为数字(例如 quantity、unitprice、customerid),请保留该列。使用数字类型进行数字运算会更加高效且更有意义。混合/字符串数据的对象类型: Pandas 中的dtype object指的是包含混合类型或主要为字符串的列。如果某列确实包含数字和字符串的混合,object则 dtype 为默认值。各种缺失数据: Pandas 会处理 NaN缺失的数字数据和None缺失的对象数据。然而,当我们读取数据时,缺失值可能表示为空字符串、特定文本(例如“N/A”),甚至只是空格,而 Pandas 可能无法自动将其解释为NaN。我们需要识别这些值。
识别 NaN
对于具有混合数据类型的列,我将潜在的缺失值(如空格或“nan”字符串)转换为NumPy’s NaN。
这对于后期的准确归集是一个至关重要的准备。
import numpy as np
obj_cols = [ 'invoiceno' , 'stockcode' , 'country' , 'invoicedate' ]
# 列出潜在的 NaN 值
null_vals = [ '' , 'nan' , 'N/A' , None , 'na' , 'None' , 'none' ]
removed_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):
对于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,假设没有 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()
探索性数据分析(EDA)和特征工程
清理完数据集后,我们现在可以转到 EDA。
探索性数据分析 (EDA) 是一种专注于总结和可视化数据以了解其主要特征的数据分析技术。
从技术角度来说,我们可以执行无数次 EDA,尤其是在复杂数据集上。但我们的主要重点是揭示需要设计哪些特征,并深入了解数据预处理,从而提升模型性能。
任何其他分析都应该由模型本身处理,因为真正的底层模式要么太微妙,要么太复杂,我们无法手动发现。
因此,EDA 成为模型确定哪些分析(例如隐藏趋势、群体差异)值得转化为预测的第一步。
EDA 必须包含:
用于理解数据的基本分析(以单变量为重点) ; 基于与项目目标直接相关的假设进行的项目特定分析(以双变量为重点)
1)基本分析(单变量重点)
这个初始阶段是通过单独分析每个变量来从根本上了解数据集。
首先,为了准备 EDA,我从列中提取了year、month,并按以下顺序对数据进行排序:day_of_week,invoicedate,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']
数据集如下:
了解数据分布
我绘制了数值特征的 PDF 和分类特征的直方图,以识别异常值、倾斜和重尾等特征。
尽管真实的数据分布过于复杂而难以完全掌握,但分析对于有效的预处理和模型选择至关重要。
数值列的 PDF
unitprice 和sales都是稀疏的,并且尾部严重,存在显著的异常值。我将使用MAE作为评估指标,因为它对倾斜数据具有较好的鲁棒性。
unitprice:最大值:38,970.0,最小值:-11,062.1,平均值:4.6,标准差:96.8sales:最大值:168,469.6,最小值:-168,469.6,平均值:18.0,标准差:378.8
分类特征直方图
invoiceno、year_month和day_of_week均匀分布:
stockcode左侧有一个峰值,右侧有一个长尾,而quantity和country则显示出退化分布,其中数据集中在几个类别中:
is_registered和year结果也是二分类的:
在此基础上,我将进行针对特定项目的 EDA,以找出额外的特征工程机会。
2)项目特定的EDA
此阶段深入研究,特别是寻找变量之间的关系,特别是潜在特征和目标变量之间的关系:sales。
首先,我将根据“销售增长”这一挑战的潜在解决方案提出三个假设。实际上,可以利用商业和专家的见解来完善这些假设。
假设1
“销售趋势是由一周中的某天或一个月中的某天决定的。”
鉴于数据集有限的 13 个月的销售数据(二进制year),我关注较短的趋势周期。
需要设计的潜在特性 is_weekend:day_of_month潜在商业解决方案:顺应趋势的大量促销。
假设2
“产品销售受时间和价格点驱动。”
需要设计的潜在特性:
unit_price_bin:unitprice离散化为“低”、“中”、“高”类别,直接解决价格影响和非线性。product_avg_quantity_last_month:计算每个产品quantity在上一日历月的平均销量stockcode并获取近期产品的受欢迎程度。product_sales_growth_last_month:从 2 个月前到上个月stockcode的销售额百分比变化,以确定流行产品。
潜在的商业解决方案:
动态定价(通过促销时机预测最佳价格点的模型)。 定制产品推荐(预测产品因素相似性的模型)。
假设3
“活跃的顾客往往会购买更多商品,从而促进销售。”
需要设计的潜在特性:
customer_recency_days:预测日期(上个月底)与客户上次购买日期之间的天数,以评估近期购买的可能性。customer_total_spend_ltm:客户过去三个月产生的总销售收入。这是对客户近期货币价值的直接衡量。customer_freq_ltm:过去三个月内客户开具的唯一发票总数。这是直接影响销售额的参与度指标之一。
潜在的商业解决方案:
分层客户忠诚度计划(预测唯一用户保留时间的模型) 营销媒体组合优化(预测新客户价值的模型)
现在,执行 EDA 并决定要设计哪些特性。
假设 1
“销售趋势受一周中的某天或一个月中的某天的影响。”
除了11月的峰值之外,按月和按周划分的销售趋势没有出现明显的模式。因此,我选择不添加基于此假设的其他特征。
假设2
“产品销售受时间和价格点驱动。”
对于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] ifnot x.mode().empty elseNone
).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()
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()
另一方面,product_sales_growth_last_month它没有表现出很强的线性/单调关系。考虑到这个特征的预测能力有限,我选择不添加它。
假设3
“活跃顾客倾向于购买更多产品并对销售做出贡献。”
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()
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()
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()
对缺失值的最终检查
特征工程完成后,我检查了剩余的缺失值,在更新后的数据集中的stockcode、quantity、unit_price_bin和country列中发现了五个缺失项:
df_fin.isna().sum ()
逐一检查这些缺失的项目并进行估算。
注意:鉴于 540k+ 个样本中最多只有 20 个有缺失值,因此可以选择按行删除(即从数据集中删除这些样本)。
对于stockcode和unit_price_bin,样本中缺失stockcode或unit_price_bin的其他值看起来是合法的。
我用“unknown”(字符串)和“low”换替换了stockcode和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分别用其众数值和销售额/单价值填充:
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由于其缺失值量较大且对预测的影响有限。
4. 模型选择
鉴于数据集复杂且庞大,我选择了以下三种模型:
弹性网络:正则化线性回归模型,适合作为线性可分数据的基线。 随机森林:一种能够捕捉复杂、非线性关系的强大机器学习模型。 深度前馈网络:一种深度学习模型,可作为非线性可分离数据的强大基础。为了有效地管理大型数据集,我使用了PyTorch库。
5. 在预处理数据上训练模型
首先,我将数据集分成所有模型的训练集、验证集和测试集。
我故意没有对数据集进行打乱,以保留其时间顺序。
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
num_transformer = Pipeline(steps=[
( 'scaler' , RobustScaler(with_centering= True , with_scaling= True ))
])
# cat
cat_transformer = Pipeline(steps=[
( 'encoder' , BinaryEncoder(cols=cat_cols, handle_missing= '0' ))
])
# 定义一个预处理器
preprocessor_en = ColumnTransformer(
transforms=[
( '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 , # 1,000 个 epochs
copy_X= True , # 拟合前复制 X
tol= 1e-5 , # tol 停止迭代
random_state= 42 , # 随机数生成器的种子
warm_start= False , # 忽略前一次拟合调用中的解
positive= False , # 可以同时 选择负系数和正系数"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(
transforms=[
( '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
DFN 需要缩放和编码。对于数值特征,我使用了StandardScaler,因为它在处理复杂数据方面具有良好的鲁棒性。之后,数据集被转换为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(
transforms=[
( '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
classDFN(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)
defforward(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)
结果
平均辅助能量吸收
弹性网络:训练:19.773 → 验证:18.508 随机森林:训练:4.147 → 验证:10.551 深度前馈网络:训练:10.570 → 验证:10.987
Elastic Net 的泛化能力良好(训练集 19.77,验证集 18.51),但平均误差最高。其预测与实际销售额的偏差约为18.50 美元至 19.77 美元。
随机森林过拟合严重(训练集 4.15,验证集 10.55)。平均而言,其对新数据的预测偏差约为10.55 美元。
深度前馈网络 (DFN)表现出了出色的泛化能力(训练集 10.57,验证集 10.99),并且在未见数据上实现了较低的平均误差。其预测偏差约为10.99 美元。
总而言之,随机森林是表现最好的模型,但 DFN 在泛化方面也表现出色。
第二阶段:迭代改进
第一阶段的结果表明,这三个模型的泛化能力仍有提升空间。
我对销售值应用了对数转换,以便为模型的目标变量创建更加对称的分布。
为了区分退款(sales列中的负销售额)和正销售额,我创建了一个is_return二分类token(1 表示退款,0 表示销售额)。这样一来,sales列就可以只关注正销售额。
从数学上讲,对负值取对数的结果是NaN。因此,我先用零替换负销售额,然后应用拉普拉斯平滑法。这也能避免对数销售额中出现负无穷值。
import numpy as np
# 使用新数据集
df_fin_rev = df_fin.copy()
# 添加 is_return 标志
df_fin_rev['is_return'] = (df_fin_rev['sales'] < 0).astype(int)
# sales 列中的零值或正值
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()
在确保数据集中除 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_abolute_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 都有所提高,这表明目标变量密度的重要性。
其中,DFN 在训练集(5.64)和泛化集(6.20)中均表现出较低的 MAE,展现出最佳性能,表明其在复杂、大型数据集上的学习和泛化能力较强。其对未见数据的预测偏差约为6.20 美元。
Elastic Net表现出了极好的泛化能力,但其对未见数据的预测偏差为16.51 美元,是所有模型中偏差最大的,这表明其在处理复杂数据集时遇到了困难。
随机森林表现出严重的过拟合,其较低的训练 MAE(4.14)与较高的泛化 MAE(9.04)之间存在较大差距。该模型的下一步可以进行超参数调整,以收紧正则化变量和树结构。
实验总结
实验表明,PyTorch 上的 DFN 在具有 EDA 期间识别的特征的转换数据集上表现最佳。
回到业务解决方案的初始假设,我们可以将这一发现直接用于营销媒体组合优化,例如,使 DFN 能够预测新客户的终身价值并优化对高价值客户渠道的预算分配。
下一步,我们可以在第 2 阶段进一步探索特征工程,或者进入第 3 阶段调整超参数以完善结果。
写在最后
特征工程不仅仅是数据操作;它是一种从原始数据中获取强大洞察力并显著提高模型解决当前问题的能力的战略方法。
在我们的实验中,我们观察到特征工程显著提升了模型的性能,尤其是在与 EDA 和业务目标紧密结合的情况下。通过与领域专家和业务利益相关者合作完善假设,我们有望实现进一步的改进。
通过投入时间和精力来制定有效的输入,我们从根本上增强了模型的学习、概括和提供卓越预测性能的能力。