主题
第 4 章 特征工程
对应源码:
src/features/browse_features.py,src/features/user_features.py,src/features/feature_pipeline.py模型的上限由特征决定。本章从原始浏览日志和用户注册信息出发,构建 25 维浏览特征 + 用户画像特征,为后续所有模型(意图预测、Uplift、价格敏感度、GMV)提供统一的输入。
4.1 特征工程的目标
我们有两个数据源:
- 浏览日志:每条记录是"某用户在某时刻浏览了某商品"
- 用户注册信息:注册时间、设备、渠道、城市等
需要从中提取出用户级别的特征,回答这些问题:
- 这个用户浏览活跃吗?(浏览强度)
- 有没有购买意图?(加购、重复浏览)
- 偏好什么价位?(价格偏好)
- 兴趣多广泛?(品类偏好)
- 处于什么阶段?(生命周期)
4.2 浏览特征:5 组 25 维
BrowseFeatureExtractor 从原始日志聚合出 25 维浏览特征,分为 5 组:
4.2.1 第一组:浏览强度(6 维)
衡量用户整体活跃程度。
python
# src/features/browse_features.py
def _browse_intensity(self, logs: pd.DataFrame) -> pd.DataFrame:
"""PV/UV/Session 维度的浏览强度."""
grouped = logs.groupby("user_id")
feats = pd.DataFrame(index=grouped.groups.keys())
# 分时间窗口 PV: 1天、3天、7天
for days in self.config.time_windows: # (1, 3, 7)
mask = logs["hours_ago"] <= days * 24
feats[f"browse_pv_{days}d"] = (
logs[mask].groupby("user_id").size()
)
# 近1天去重商品数
day1 = logs[logs["hours_ago"] <= 24]
feats["browse_uv_items_1d"] = (
day1.groupby("user_id")["item_id"].nunique()
)
# 近1天 session 数
feats["browse_sessions_1d"] = (
day1.groupby("user_id")["session_id"].nunique()
)
# 平均 session 时长 & 每 session 浏览商品数
session_dur = (
logs.groupby(["user_id", "session_id"])["duration_sec"]
.sum().reset_index()
)
feats["avg_session_duration"] = (
session_dur.groupby("user_id")["duration_sec"].mean()
)
...
return feats.fillna(0)| 特征 | 含义 | 直觉 |
|---|---|---|
browse_pv_1d/3d/7d | 各时间窗口 PV | 短期活跃 vs 长期活跃 |
browse_uv_items_1d | 近1天去重商品数 | 广泛浏览 vs 集中浏览 |
browse_sessions_1d | 近1天 session 数 | 访问频率 |
avg_session_duration | 平均 session 时长 | 深度浏览 vs 快速扫一眼 |
多时间窗口的作用
为什么同时看 1 天、3 天、7 天的 PV?因为趋势很重要:
- PV_1d 高但 PV_7d 也差不多 → 最近才开始活跃(新兴趣)
- PV_1d 低但 PV_7d 很高 → 正在流失(需要干预)
- 三个窗口一起看就是"浏览趋势"的信号
4.2.2 第二组:购买意图(5 维)
从页面类型和重复浏览推断购买意图。
python
def _purchase_intent(self, logs: pd.DataFrame) -> pd.DataFrame:
"""从页面类型和重复浏览推断购买意图."""
grouped = logs.groupby("user_id")
total_pv = grouped.size().rename("_total_pv")
# 详情页浏览占比
detail_pv = (
logs[logs["page_type"] == "detail"]
.groupby("user_id").size().rename("_detail_pv")
)
ratio_df = pd.concat([total_pv, detail_pv], axis=1).fillna(0)
detail_ratio = (ratio_df["_detail_pv"] / ratio_df["_total_pv"])
.rename("detail_page_view_ratio")
# SKU 重复浏览
item_visits = logs.groupby(["user_id", "item_id"]).size().rename("visits")
...| 特征 | 含义 | 直觉 |
|---|---|---|
detail_page_view_ratio | 详情页占比 | 越高越有购买意图 |
sku_revisit_max | 单品最大重复浏览次数 | 反复看同一商品 = 犹豫中 |
sku_revisit_count | 重复浏览的商品数 | 多个候选 = 在比较 |
cart_add_count | 加购次数 | 最强的购买信号 |
search_query_count | 搜索次数 | 主动找商品 = 明确需求 |
4.2.3 第三组:价格偏好(6 维)
浏览商品的价格统计特征。
python
def _price_preference(self, logs: pd.DataFrame) -> pd.DataFrame:
"""浏览商品的价格统计特征."""
price_grouped = logs.groupby("user_id")["item_price"]
feats = pd.DataFrame({
"browse_price_mean": price_grouped.mean(),
"browse_price_median": price_grouped.median(),
"browse_price_std": price_grouped.std().fillna(0),
"browse_price_max": price_grouped.max(),
"browse_price_min": price_grouped.min(),
})
# 高价商品浏览占比
user_avg = logs.groupby("user_id")["item_price"].transform("mean")
threshold = user_avg * self.config.high_price_multiplier # 默认 2.0
logs_copy = logs.assign(
_is_high=(logs["item_price"] > threshold).astype(int)
)
feats["high_price_browse_ratio"] = (
logs_copy.groupby("user_id")["_is_high"].mean()
)
return feats.fillna(0)价格偏好对面额决策至关重要:
- 浏览高价商品的用户,小面额券(5 元)可能没有吸引力
- 价格标准差大的用户,说明还在比价,对折扣更敏感
4.2.4 第四组:品类偏好(4 维)
python
def _category_preference(self, logs: pd.DataFrame) -> pd.DataFrame:
"""品类集中度、多样性和 Top1 品类."""
...
# Shannon 品类熵
def _entropy(group: pd.DataFrame) -> float:
p = group["ratio"].values
p = p[p > 0]
return float(-np.sum(p * np.log2(p)))
...| 特征 | 含义 | 直觉 |
|---|---|---|
top1_category_id | 最常浏览品类 | 类别特征,用于个性化 |
top1_category_ratio | Top1 品类占比 | 高 = 兴趣集中 |
category_count | 浏览品类数 | 兴趣广度 |
category_entropy | 品类 Shannon 熵 | 高 = 兴趣均匀分散 |
为什么用 Shannon 熵?
品类数相同但分布不同的两个用户:
- A 浏览 3 个品类,占比 [90%, 5%, 5%],熵 = 0.57
- B 浏览 3 个品类,占比 [34%, 33%, 33%],熵 = 1.58
熵越高越"均匀"。单纯用品类数无法区分这种差异。
4.2.5 第五组:时间特征(4 维)
python
def _time_features(self, logs, snapshot_time) -> pd.DataFrame:
# 最近一次浏览距今小时数
hours_since_last = (
(snapshot_time - grouped["timestamp"].max())
.dt.total_seconds() / 3600
).rename("hours_since_last_browse")
# 近3天浏览趋势 (后半段PV / 前半段PV)
d3 = logs[logs["hours_ago"] <= 72].copy()
d3["half"] = np.where(d3["hours_ago"] <= 36, "recent", "older")
...| 特征 | 含义 | 直觉 |
|---|---|---|
hours_since_last_browse | 最近浏览距今 | 越短越活跃 |
browse_trend_3d | 3 天浏览趋势 | >1 上升趋势,<1 下降 |
active_hour_mode | 最常活跃时段 | 发券时机参考 |
weekend_active_ratio | 周末活跃占比 | 周末型 vs 工作日型 |
4.3 用户画像特征
UserFeatureExtractor 提取用户基础属性和生命周期阶段。
4.3.1 生命周期判定
这是整个系统中最重要的概念之一——新用户不是一个同质群体,他们处于不同的阶段:
python
# src/features/user_features.py
def _determine_stage(self, row: pd.Series) -> str:
"""根据行为数据判定用户所处生命周期阶段."""
order_count = int(row.get("order_count", 0))
if order_count >= 2:
return UserStage.SECOND_ORDER_DONE # 二单完成
if order_count == 1:
return UserStage.FIRST_ORDER_DONE # 首单完成
has_cart = float(row.get("cart_add_count", 0)) > 0
has_browse = float(row.get("browse_pv_7d", 0)) > 0
hours_since_reg = float(row.get("hours_since_register", 0))
if has_cart:
return UserStage.CARTED_NO_ORDER # 加购未下单
if has_browse:
return UserStage.BROWSED_NO_CART # 浏览未加购
if hours_since_reg >= self.config.silent_hours: # 72h
return UserStage.SILENT # 沉默新客
return UserStage.REGISTERED_NO_BROWSE # 注册未浏览6 个阶段形成一个漏斗:
mermaid
graph TD
A[注册未浏览] -->|浏览| B[浏览未加购]
A -->|72h无行为| F[沉默新客]
B -->|加购| C[加购未下单]
C -->|下单| D[首单完成]
D -->|复购| E[二单完成]不同阶段的发券策略差异很大:
- 加购未下单:距离转化最近,小面额就能推动
- 浏览未加购:需要中等面额吸引进入下一步
- 沉默新客:可能需要大面额唤醒
4.3.2 基础属性
python
output_cols = [
"user_id",
"hours_since_register", # 注册时长
"device_os", # 操作系统 (类别)
"device_brand", # 设备品牌 (类别)
"device_price_tier", # 设备价格档 (类别)
"channel_source", # 获客渠道 (类别)
"city_tier", # 城市等级 (类别)
"has_bindcard", # 是否绑卡 (0/1)
"order_count", # 历史订单数
"total_gmv", # 历史 GMV
"user_stage", # 生命周期阶段
]4.4 特征 Pipeline:合并一切
FeaturePipeline 是特征构建的统一入口,负责调用两个提取器并合并结果:
python
# src/features/feature_pipeline.py
class FeaturePipeline:
def build_features(
self, browse_logs, users, snapshot_time
) -> pd.DataFrame:
# 1. 提取浏览特征
browse_feats = self._browse_extractor.extract(
browse_logs, snapshot_time
)
# 2. 提取用户特征 (依赖浏览摘要判定生命周期)
user_feats = self._user_extractor.extract(
users, browse_feats, snapshot_time
)
# 3. 合并
merged = user_feats.merge(browse_feats, on="user_id", how="left")
# 4. 数值特征填充缺失值 (-999)
numeric_cols = [c for c in _BROWSE_NUMERIC_COLS + _USER_NUMERIC_COLS
if c in merged.columns]
merged[numeric_cols] = merged[numeric_cols].fillna(
self.feature_config.numeric_fill_value
)
# 5. 类别特征填充
cat_cols = [c for c in _CATEGORICAL_COLS if c in merged.columns]
merged[cat_cols] = merged[cat_cols].fillna("unknown")
return merged4.4.1 处理 LightGBM 的类别特征
LightGBM 要求输入为数值类型。encode_categoricals 将类别特征转换为 category codes:
python
def encode_categoricals(self, df: pd.DataFrame) -> pd.DataFrame:
"""将类别特征编码为数值, 供 LightGBM 使用."""
result = df.copy()
for col in self.get_categorical_columns():
if col in result.columns:
result[col] = result[col].astype("category").cat.codes
return result为什么不用 One-Hot?
6 个类别特征中,device_brand 和 top1_category_id 的基数可能很高(几十到几百)。One-Hot 编码会产生大量稀疏列,而 LightGBM 原生支持类别特征,用 .cat.codes 编码就够了。
4.5 特征总览
| 组 | 维度 | 类型 | 代表特征 |
|---|---|---|---|
| 浏览强度 | 6 | 数值 | browse_pv_1d/3d/7d, avg_session_duration |
| 购买意图 | 5 | 数值 | cart_add_count, detail_page_view_ratio |
| 价格偏好 | 6 | 数值 | browse_price_mean/std, high_price_browse_ratio |
| 品类偏好 | 4 | 混合 | category_entropy, top1_category_id(类别) |
| 时间特征 | 4 | 数值 | hours_since_last_browse, browse_trend_3d |
| 用户画像 | 10 | 混合 | hours_since_register, user_stage, city_tier(类别) |
| 合计 | 35 | — | — |
4.6 本章小结
- 特征工程从原始浏览日志中提取了 25 维浏览特征(5 组)和 10 维用户画像特征
- 多时间窗口(1/3/7 天)捕捉浏览趋势,Shannon 熵量化品类多样性
- 生命周期阶段将新用户分为 6 个漏斗层级,是后续规则策略的核心依据
FeaturePipeline提供统一入口,处理缺失值填充和类别编码- LightGBM 原生支持类别特征,无需 One-Hot 编码
💡 思考题
- 当前使用
-999填充数值缺失值。这个值有什么特殊含义?LightGBM 对缺失值有原生支持,直接传NaN和用-999填充有什么区别? browse_trend_3d用后半段 PV / 前半段 PV 来衡量趋势。如果用户只在最后 1 小时有 1 次浏览,这个值会是多少?这种极端情况需要处理吗?- 品类 Shannon 熵假设所有品类等价。如果某些品类天然更"有价值"(如高毛利品类),应该如何改进这个特征?