Skip to content

第 4 章 特征工程 ​

对应源码: src/features/browse_features.py, src/features/user_features.py, src/features/feature_pipeline.py

模型的上限由特征决定。本章从原始浏览日志和用户注册信息出发,构建 25 维浏览特征 + 用户画像特征,为后续所有模型(意图预测、Uplift、价格敏感度、GMV)提供统一的输入。

4.1 特征工程的目标 ​

我们有两个数据源:

  1. 浏览日志:每条记录是"某用户在某时刻浏览了某商品"
  2. 用户注册信息:注册时间、设备、渠道、城市等

需要从中提取出用户级别的特征,回答这些问题:

  • 这个用户浏览活跃吗?(浏览强度)
  • 有没有购买意图?(加购、重复浏览)
  • 偏好什么价位?(价格偏好)
  • 兴趣多广泛?(品类偏好)
  • 处于什么阶段?(生命周期)

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_ratioTop1 品类占比高 = 兴趣集中
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_3d3 天浏览趋势>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 merged

4.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 编码
💡 思考题
  1. 当前使用 -999 填充数值缺失值。这个值有什么特殊含义?LightGBM 对缺失值有原生支持,直接传 NaN 和用 -999 填充有什么区别?
  2. browse_trend_3d 用后半段 PV / 前半段 PV 来衡量趋势。如果用户只在最后 1 小时有 1 次浏览,这个值会是多少?这种极端情况需要处理吗?
  3. 品类 Shannon 熵假设所有品类等价。如果某些品类天然更"有价值"(如高毛利品类),应该如何改进这个特征?

基于 Uplift Modeling 的电商优惠券智能发放系统