查看全课大纲(第 34 / 35 节)

数据预处理和特征工程(二)

约 10 分钟

小象实战讲义 · 特征工程精讲与实践

在上一节我们完成了数据的基础清洗、合并与聚合,构建了初步的销量数据集。本节我们将深入进行特征工程的第二阶段:构建时间跨度特征、分组统计特征、以及对于销量预测至关重要的滞后(LAG)特征。这些特征能够从历史数据中提取出趋势、周期性和关联性信息,是提升模型预测能力的关键。

💡 核心导读

  • 时间跨度特征:计算商品、品类、商店等维度从首次出现到当前时间点的“年龄”,刻画其生命周期阶段。
  • 分组统计特征:在不同粒度(如商品、类别、商店、城市)上进行聚合,生成均值、中位数等统计量,捕捉群体行为。
  • 滞后(LAG)特征:平移历史销量数据,用过去的信息预测未来,是时间序列预测的核心特征。
  • 内存优化与特征衍生:通过数据类型降级节省内存,并从文本字段(如商品名称)中提取业务信息。

构建时间跨度与“年龄”特征

时间是一个重要的刻画维度。我们可以基于商品、品类、商店等实体首次出现的日期,计算它们到当前时间点的跨度,这通常被称为“年龄”(age)特征。这类特征能反映一个实体是新品还是老品,处于生命周期的哪个阶段。

在项目中,我们构建了多种维度的年龄特征:

  • 商品年龄(item_age):该商品首次销售至今过去了多少个月。
  • 品类年龄(category_age):该商品所属品类首次出现至今过去了多少个月。
  • 商店年龄(shop_age):该商店首次出现至今过去了多少个月。
  • 商品名称前缀年龄:基于商品名称的前4、6、11个字符构成的“虚拟类别”的年龄。
  • 此外原课还构建了天粒度的跨度特征(如 prev_days_on_sale:从商品首次销售日到当前时间块月初经过的天数,负值按 0 截断),与月粒度的 age 特征互补。

其核心计算逻辑是:当前月份(date_block_num) - 该维度下的最小月份。同时,我们还需要考虑一个特殊情况:某个商店可能从未售卖过某个商品。为此,原课的做法不是改写原始的 item_age,而是新增两个条件年龄特征——item_age_if_shop_sale(该商品在本店有销售记录时取商品年龄)与 item_age_without_shop_sale(无记录时的替代口径),让模型自行区分“新品”与“该店从未卖过”这两种情形。

import pandas as pd
import numpy as np

# 构建一个微型数据集来演示年龄特征的计算
data = {
    'date_block_num': [30, 30, 31, 31, 32, 32],
    'shop_id':        [1, 2, 1, 2, 1, 2],
    'item_id':        [100, 100, 100, 100, 100, 100],
    'category_id':    [5, 5, 5, 5, 5, 5],
    'group_id':       [10, 10, 10, 10, 10, 10],
    'item_name_first4': [123, 123, 123, 123, 123, 123]
}
df_demo = pd.DataFrame(data)

# 计算商品年龄:当前月份 - 该商品首次出现的月份
df_demo['item_age'] = (df_demo['date_block_num'] - df_demo.groupby('item_id')['date_block_num'].transform('min')).astype('int8')
# 计算品类年龄
df_demo['category_age'] = (df_demo['date_block_num'] - df_demo.groupby('category_id')['date_block_num'].transform('min')).astype('int8')
# 计算商店年龄
df_demo['shop_age'] = (df_demo['date_block_num'] - df_demo.groupby('shop_id')['date_block_num'].transform('min')).astype('int8')
# 计算商品名称前缀年龄
df_demo['item_name_first4_age'] = (df_demo['date_block_num'] - df_demo.groupby('item_name_first4')['date_block_num'].transform('min')).astype('int8')

print(df_demo[['date_block_num', 'shop_id', 'item_id', 'item_age', 'category_age', 'shop_age', 'item_name_first4_age']])
date_block_num  shop_id  ...  shop_age  item_name_first4_age
0              30        1  ...         0                     0
1              30        2  ...         0                     0
2              31        1  ...         1                     1
3              31        2  ...         1                     1
4              32        1  ...         2                     2
5              32        2  ...         2                     2

[6 rows x 7 columns]

构建分组统计特征

在获得了时间跨度特征后,我们可以基于不同的分组维度(如商品、类别、商店、城市)和时间,对销量进行统计聚合,从而得到反映群体行为的特征。例如:

  • 统计每个商品在所有商店的月均销量。
  • 统计每个类别在每个商店的月销量中位数。
  • 统计所有商店中某个城市的月均总销量。

这些统计特征能够帮助模型了解一个商品或品类在整体市场或局部市场中的表现水平。

import pandas as pd
import numpy as np

# 构建一个微型数据集来演示分组统计特征
data = {
    'date_block_num': [30, 30, 31, 31, 32, 32],
    'shop_id':        [1, 2, 1, 2, 1, 2],
    'item_id':        [100, 100, 200, 200, 100, 200],
    'category_id':    [5, 5, 5, 5, 5, 5],
    'shop_city':      [0, 1, 0, 1, 0, 1], # 假设0和1代表不同城市
    'item_cnt':       [5.0, 3.0, 8.0, 2.0, 6.0, 4.0]
}
df_demo = pd.DataFrame(data)

# 定义一个通用的聚合函数,用于演示
def agg_cnt_col_demo(df, merging_cols, new_col, aggregation):
    """在指定列上分组并聚合,然后将结果合并回原数据框"""
    temp = df.groupby(merging_cols).agg(aggregation).reset_index()
    temp.columns = merging_cols + [new_col]
    df_result = pd.merge(df, temp, on=merging_cols, how='left')
    return df_result

# 示例1:统计每个商品在所有商店的月均销量
df_demo = agg_cnt_col_demo(df_demo, ['date_block_num', 'item_id'], 'item_cnt_all_shops', {'item_cnt':'mean'})
# 示例2:统计每个类别在每个商店的月销量中位数
df_demo = agg_cnt_col_demo(df_demo, ['date_block_num', 'category_id', 'shop_id'], 'category_cnt_median', {'item_cnt':'median'})
# 示例3:统计每个城市所有商店的月均销量
df_demo = agg_cnt_col_demo(df_demo, ['date_block_num', 'shop_city'], 'city_cnt', {'item_cnt':'mean'})

print(df_demo[['date_block_num', 'shop_id', 'item_id', 'item_cnt', 'item_cnt_all_shops', 'category_cnt_median', 'city_cnt']])
date_block_num  shop_id  ...  category_cnt_median  city_cnt
0              30        1  ...                  5.0       5.0
1              30        2  ...                  3.0       3.0
2              31        1  ...                  8.0       8.0
3              31        2  ...                  2.0       2.0
4              32        1  ...                  6.0       6.0
5              32        2  ...                  4.0       4.0

[6 rows x 7 columns]

构建核心:滞后(LAG)特征

对于销量预测这类时间序列问题,滞后特征(Lag Feature) 是最重要的一类特征。其核心思想是:当前的销量情况与历史销量有很强的相关性。例如,一个网红产品今天卖得好,很可能昨天、前天卖得也不错。因此,我们可以用过去一段时间(如前1个月、前2个月、前12个月)的销量值,经过平移后作为当前月份的特征。

项目中原代码构建了三种滞后特征:

  1. lag1:上一个月的销量值。
  2. lag2:上两个月的销量值。
  3. lag1to12:过去12个月销量的总和。

此外,为了获得更好的表征能力,项目将 lag1to12 除以 min(item_age, 12)(商品年龄与 12 取小,商店维度同理用 min(shop_age, 12)),得到”按有效在售时长折算的月均销量”——统一除以 12 会系统性低估新品/新店的月均水平。同时,还基于滞后特征构建了比例特征(如 item_cnt_diff = lag1 / lag1to12,即近期销量在长期销量中的占比),用于刻画趋势与分布变化。

import pandas as pd
import numpy as np

# 构建一个微型数据集来演示滞后特征
data = {
    'date_block_num': [28, 29, 30, 31, 32, 33],
    'shop_id':        [1, 1, 1, 1, 1, 1],
    'item_id':        [100, 100, 100, 100, 100, 100],
    'item_cnt':       [10.0, 12.0, 8.0, 15.0, 9.0, 11.0]
}
df_demo = pd.DataFrame(data)

# 演示滞后特征的构建
def lag_feature_demo(df, lag, col, merge_cols):
    """构建滞后特征:将指定列按分组列滞后lag个时间块"""
    temp = df[merge_cols + [col]].copy()
    # 按分组列取第一个值(假设每个时间块每组只有一个值,或我们取均值/总和后只有一个值)
    temp = temp.groupby(merge_cols).agg({col:'first'}).reset_index()
    temp.columns = merge_cols + [f'{col}_lag{lag}']
    # 将时间块向后平移lag,表示这个值是未来lag个月的特征
    temp['date_block_num'] += lag
    df_result = pd.merge(df, temp, on=merge_cols, how='left')
    df_result[f'{col}_lag{lag}'] = df_result[f'{col}_lag{lag}'].fillna(0).astype('float32')
    return df_result

# 构建滞后1个月和滞后2个月的特征
df_demo = lag_feature_demo(df_demo, 1, 'item_cnt', ['date_block_num', 'shop_id', 'item_id'])
df_demo = lag_feature_demo(df_demo, 2, 'item_cnt', ['date_block_num', 'shop_id', 'item_id'])

# 计算比例特征:滞后1个月占(滞后1个月+滞后2个月)的比例(简化演示)
df_demo['lag1_ratio'] = df_demo['item_cnt_lag1'] / (df_demo['item_cnt_lag1'] + df_demo['item_cnt_lag2'] + 1e-5)

print(df_demo[['date_block_num', 'item_cnt', 'item_cnt_lag1', 'item_cnt_lag2', 'lag1_ratio']])
date_block_num  item_cnt  item_cnt_lag1  item_cnt_lag2  lag1_ratio
0              28      10.0            0.0            0.0    0.000000
1              29      12.0           10.0            0.0    0.999999
2              30       8.0           12.0           10.0    0.545454
3              31      15.0            8.0           12.0    0.400000
4              32       9.0           15.0            8.0    0.652174
5              33      11.0            9.0           15.0    0.375000

内存优化与文本特征衍生

在大规模特征工程中,内存管理至关重要。项目通过以下方式优化内存:

  1. 删除中间变量:清理不再使用的DataFrame。
  2. 数据类型降级(Downcast):将float64降为float32,将int64降为int16甚至int8,显著减少内存占用。

另有一项容易误归入”内存优化”的规整操作需要单独说明:截断目标变量(clip 0-20)——它并不改变数据类型、也不节省内存,其真实动机是对齐比赛的提交区间(预测值必须在 0~20 之间),并防止越界值进入训练。

此外,项目还从商品名称文本字段中提取了业务信息。通过统计商品名称中的高频词(如“playstation”, “xbox”),并创建布尔特征表示商品是否包含这些高频词,从而捕捉商品的潜在类别或品牌信息。

import pandas as pd
import numpy as np

# 演示内存优化函数
def downcast_demo(df):
    """将DataFrame中的float64和int64列降级到更小的数据类型"""
    float_cols = [c for c in df if df[c].dtype in ["float64"]]
    int_cols = [c for c in df if df[c].dtype in ['int64']]
    if float_cols:
        df[float_cols] = df[float_cols].astype('float32')  # float64 -> float32
    if int_cols:
        # 根据数值范围选择int16或int8,这里演示用int16
        df[int_cols] = df[int_cols].astype('int16')  # int64 -> int16
    return df

# 演示从商品名称中提取高频词特征
item_names = pd.Series([
    'sony playstation 4 pro 1tb',
    'xbox one s 1tb console',
    'nintendo switch with gray joy con',
    'playstation 4 dualshock wireless controller',
    'xbox one wireless controller black'
])

# 简单分词并统计词频(这里忽略大小写和标点)
all_words = ' '.join(item_names.str.lower()).split()
word_counts = pd.Series(all_words).value_counts()
print("高频词统计:")
print(word_counts.head())

# 假设我们选取词频>=2的词作为高频词
high_freq_words = word_counts[word_counts >= 2].index.tolist()
print(f"\n高频词列表: {high_freq_words}")

# 为每个商品名称创建是否包含高频词的布尔特征(以'playstation'为例)
target_word = 'playstation'
has_target = item_names.str.contains(target_word, case=False)
print(f"\n商品名称是否包含 '{target_word}':")
print(has_target)
高频词统计:
playstation    2
4              2
1tb            2
xbox           2
one            2
Name: count, dtype: int64

高频词列表: ['playstation', '4', '1tb', 'xbox', 'one', 'wireless', 'controller']

商品名称是否包含 'playstation':
0     True
1    False
2    False
3     True
4    False
dtype: bool

📝 动手练一练

  1. 概念辨析:滞后特征(Lag Feature)与时间跨度特征(如 item_age)在概念和应用上有何不同?它们分别解决了什么问题?
👉 点击查看参考答案
  • 滞后特征:核心是数据的平移,用历史同期或近期数据作为当前预测的特征。它直接刻画了时间序列的自相关性,解决的是“历史如何影响现在/未来”的问题。例如,用上个月的销量预测本月销量。
  • 时间跨度特征:核心是计算时间间隔,反映某个实体(如商品、商店)从首次出现到当前时刻经历了多长时间。它刻画的是实体的生命周期阶段或新旧程度,解决的是“这个实体处于什么阶段”的问题。例如,新品和老品的销售模式可能不同。
  • 应用区别:滞后特征主要用于捕捉短期趋势和周期性;时间跨度特征更多用于区分不同生命周期实体的行为模式。
  1. 动手实践:假设你有一个包含 [‘销量_上月’, ‘销量_上上月’, ‘销量_过去12月总和’] 的DataFrame,请编写代码,基于这三个特征衍生出两个新特征:a) “上月销量占比”(上月销量/过去12月总和);b) “环比变化”((上月销量-上上月销量)/上上月销量)。注意处理除零错误。
👉 点击查看参考答案
import pandas as pd
import numpy as np

# 构造示例数据
data = {
    '销量_上月': [150, 200, 80, 0],
    '销量_上上月': [120, 180, 100, 50],
    '销量_过去12月总和': [1800, 2400, 1200, 600]
}
df = pd.DataFrame(data)

# 衍生特征a: 上月销量占比
df['上月销量占比'] = df['销量_上月'] / (df['销量_过去12月总和'] + 1e-5)  # 加一个小数避免除零

# 衍生特征b: 环比变化
df['环比变化'] = (df['销量_上月'] - df['销量_上上月']) / (df['销量_上上月'] + 1e-5)

print(df)
销量_上月  销量_上上月  销量_过去12月总和    上月销量占比      环比变化
0    150     120        1800  0.083333  0.250000
1    200     180        2400  0.083333  0.111111
2     80     100        1200  0.066667 -0.200000
3      0      50         600  0.000000 -1.000000

本章小结

本节我们完成了电商销量预测案例中特征工程的核心部分。我们系统性地构建了三大类特征:

  1. 时间跨度特征:量化了商品、品类、商店等维度的“年龄”,为模型提供了实体生命周期的上下文。
  2. 分组统计特征:在不同粒度上进行聚合,生成了反映群体水平(如市场平均水平、品类中位数)的统计量,有助于模型进行相对比较。
  3. 滞后(LAG)特征:作为时间序列预测的基石,通过平移历史数据,让模型能够学习到销量随时间变化的趋势和模式。围绕滞后特征衍生的比例特征(如近期销量在长期销量中的占比),进一步强化了对趋势和分布变化的刻画。

同时,我们贯穿了工业级特征工程中的两个重要实践:内存优化(数据类型降级)和从原始文本中挖掘业务信息(高频词提取),这些都是提升工程效率和特征质量的关键技巧。

📋 行动清单

  • 理解并尝试手动计算一个简单数据集上的“商品年龄”和“商店年龄”特征。
  • 针对一个时间序列数据(如月度销售额),尝试构建滞后1期、2期的特征,并观察其与当期值的相关性。
  • 检查你当前项目中的DataFrame,尝试使用df.info(memory_usage='deep')查看内存占用,并思考是否有列可以通过astype进行数据类型降级以节省内存。

—— 小象教研组