行为数据分析实战:用户路径分析从 Clickstream 到决策树

行业场景与项目复盘 · 第4周 · 朱大喜的数据手记

用户路径分析是行为数据分析中最"硬核"的方向之一——不是看"用户做了什么",而是看"用户为什么这么做"。从原始的 Clickstream 日志到可解释的决策树模型,中间的数据清洗、路径抽象、特征提取过程,每一步都踩过坑。今天就来实战复盘这条完整的分析链路。

一、Clickstream 数据处理与清洗

Clickstream 数据是最"脏"的数据之一——机器人流量、重复点击、异常跳转,各种噪音混在一起。

import pandas as pd
import numpy as np
from collections import Counter

# ===== 加载原始点击流数据 =====
clickstream = pd.read_csv("clickstream_raw_2025.csv", parse_dates=["timestamp"])

print(f"原始数据量: {len(clickstream)} 条")
# 输出: 原始数据量: 2,350,000 条

# ===== 数据清洗七步法 =====

# Step 1: 机器人流量过滤
# 识别特征:短时间内大量重复动作、无间隔的页面跳转
def filter_bots(df):
    """过滤机器人流量"""
    # 计算每个用户的平均点击间隔
    df = df.sort_values(["user_id", "timestamp"])
    df["time_diff"] = df.groupby("user_id")["timestamp"].diff().dt.total_seconds()

    # 机器人判定规则:平均点击间隔 < 0.5秒 或 单小时点击 > 200次
    bot_thresholds = {
        "min_avg_interval": 0.5,   # 最小平均间隔(秒)
        "max_hourly_clicks": 200   # 每小时最大点击数
    }

    hourly_clicks = df.groupby(["user_id", df["timestamp"].dt.hour]).size()
    bot_users = hourly_clicks[hourly_clicks > bot_thresholds["max_hourly_clicks"]].index.get_level_values(0).unique()

    avg_intervals = df.groupby("user_id")["time_diff"].mean()
    fast_users = avg_intervals[avg_intervals < bot_thresholds["min_avg_interval"]].index

    all_bots = set(bot_users) | set(fast_users)
    df_clean = df[~df["user_id"].isin(all_bots)]

    print(f"过滤机器人: {len(all_bots)} 个用户, {len(df) - len(df_clean)} 条记录")
    return df_clean

# Step 2: 重复点击去重
def deduplicate_clicks(df):
    """去除同一用户在同一页面的连续重复点击"""
    df = df.sort_values(["user_id", "timestamp"])
    # 同一用户连续访问同一页面且间隔 < 2秒,视为重复点击
    df["same_page"] = (df["page"] == df["page"].shift(1)) & (df["user_id"] == df["user_id"].shift(1))
    df["short_interval"] = df["time_diff"] < 2
    duplicates = df["same_page"] & df["short_interval"]
    df_clean = df[~duplicates]

    print(f"去重复点击: {duplicates.sum()} 条")
    return df_clean

# Step 3: 异常跳转过滤
def filter_abnormal_jumps(df):
    """过滤不可能的页面跳转(如1秒内从首页跳到支付页)"""
    # 定义合理的页面跳转时间间隔
    page_transitions = df.groupby("user_id").apply(
        lambda x: list(zip(x["page"], x["timestamp"]))
    )

    # 检查跳转是否合理
    valid_transitions = set([
        ("home", "product"), ("product", "cart"), ("cart", "checkout"),
        ("checkout", "payment"), ("payment", "success"),
        ("home", "search"), ("search", "product"),
        # ... 更多合理跳转
    ])

    # 过滤不合理的快速跳转(间隔 < 1秒 的跨域跳转)
    df = df.sort_values(["user_id", "timestamp"])
    df_clean = df.copy()  # 这里简化处理
    return df_clean

# Step 4-7: 会话切分、页面标准化、路径补全、设备关联
def split_sessions(df, max_gap_minutes=30):
    """按时间间隔切分用户会话"""
    df = df.sort_values(["user_id", "timestamp"])
    df["new_session"] = (
        (df["time_diff"] > max_gap_minutes * 60) |  # 间隔超过30分钟
        (df["user_id"] != df["user_id"].shift(1))    # 用户切换
    )
    df["session_id"] = df["new_session"].cumsum()
    return df

# 执行完整清洗流程
clickstream_clean = filter_bots(clickstream)
clickstream_clean = deduplicate_clicks(clickstream_clean)
clickstream_clean = filter_abnormal_jumps(clickstream_clean)
clickstream_clean = split_sessions(clickstream_clean)

print(f"清洗后数据量: {len(clickstream_clean)} 条")
# 输出: 清洗后数据量: 1,890,000 条(减少了约19%)

为什么 19% 的噪音必须清? 路径分析最怕的就是"假高频"。机器人流量的特征是短时间内疯狂点击——如果不清洗,这群机器人用户产生的"首页→首页→首页→搜索→首页"模式会被路径挖掘算法当成"用户最主流的浏览路径",直接在转移矩阵里霸占前几名。后果是什么?你的桑基图最粗的那根线是机器人画的,真实用户的行为被淹没在海量噪音里。这不是"锦上添花"的问题,是结论对错的生死线。我踩过的一个坑是只过滤了平均间隔小于 0.5 秒的明显机器人,但还有一种"慢机器人"——每 2-3 秒点一下,恰好躲过阈值,单小时点击只有 150 次。不结合 session 粒度的精细化判断,20% 的机器人会漏网。

二、用户路径提取与抽象

清洗后的数据需要从"页面点击序列"抽象为"行为路径"。

# ===== 路径提取 =====
def extract_user_paths(df):
    """从点击流提取用户行为路径"""
    # 每个会话的页面访问序列
    session_paths = df.groupby("session_id")["page"].apply(list).reset_index()
    session_paths.columns = ["session_id", "path"]

    # 添加会话属性
    session_attrs = df.groupby("session_id").agg({
        "user_id": "first",
        "timestamp": ["min", "max"],
        "device_type": "first"
    }).reset_index()
    session_paths = session_paths.merge(session_attrs, on="session_id")

    # 判断会话是否有转化
    session_paths["converted"] = session_paths["path"].apply(
        lambda p: "payment_success" in p or "order_confirm" in p
    )

    return session_paths

paths_df = extract_user_paths(clickstream_clean)
print(f"总会话数: {len(paths_df)}")
print(f"转化会话: {paths_df['converted'].sum()}")
print(f"转化率: {paths_df['converted'].mean():.2%}")

# ===== 路径抽象:从页面级到行为级 =====
# 页面 → 行为类型映射
page_to_action = {
    "home": "浏览首页",
    "search": "搜索",
    "product_list": "浏览列表",
    "product_detail": "查看详情",
    "cart": "加购/查看购物车",
    "checkout": "发起结算",
    "payment": "支付",
    "payment_success": "完成购买",
    "refund": "申请退款",
    "review": "查看评价",
    "compare": "对比商品"
}

def abstract_path(raw_path, mapping):
    """将页面序列抽象为行为序列,并去除连续重复"""
    actions = [mapping.get(p, p) for p in raw_path]
    # 去除连续重复行为(如"浏览列表→浏览列表→查看详情" → "浏览列表→查看详情")
    abstracted = [actions[0]]
    for a in actions[1:]:
        if a != abstracted[-1]:
            abstracted.append(a)
    return abstracted

paths_df["abstracted_path"] = paths_df["path"].apply(
    lambda p: abstract_path(p, page_to_action)
)

# ===== 路径长度与复杂度分析 =====
paths_df["path_length"] = paths_df["abstracted_path"].apply(len)
paths_df["unique_actions"] = paths_df["abstracted_path"].apply(lambda p: len(set(p)))

# 转化与非转化路径对比
converted_paths = paths_df[paths_df["converted"]]
non_converted_paths = paths_df[~paths_df["converted"]]

print(f"转化路径平均长度: {converted_paths['path_length'].mean():.1f}")
print(f"非转化路径平均长度: {non_converted_paths['path_length'].mean():.1f}")
# 输出: 转化路径平均长度: 5.8  非转化路径平均长度: 3.2

高频路径模式挖掘

# ===== Sankey 图数据准备 =====
def build_transition_matrix(paths):
    """构建行为转移矩阵,用于路径可视化"""
    transitions = Counter()
    for path in paths:
        for i in range(len(path) - 1):
            transitions[(path[i], path[i+1])] += 1
    return transitions

# 转化路径的转移矩阵
conv_transitions = build_transition_matrix(converted_paths["abstracted_path"])

# Top 10 高频转移
top_transitions = conv_transitions.most_common(10)
for (from_action, to_action), count in top_transitions:
    print(f"{from_action} → {to_action}: {count}次")

# 输出示例:
# 浏览首页 → 搜索: 8523次
# 搜索 → 查看详情: 7156次
# 查看详情 → 加购/查看购物车: 5621次
# 加购/查看购物车 → 发起结算: 3201次
# 发起结算 → 支付: 2890次
# 支付 → 完成购买: 2652次

为什么必须抽象到"行为级"而不是直接用"页面级"路径? 原始页面路径有上千种变体——首页→列表页→商品A详情 和 首页→列表页→商品B详情,对机器学习来说是两个不同的样本。如果你用这上千种路径直接做决策树或关联规则,结果就是一堆碎片化的模式,每个模式只有几十个样本,毫无统计意义。抽象到行为级之后,上千种变体坍缩到 30 种以内,每个路径模式都有足够的样本支撑。底层原理是"归纳偏置"——你人为地告诉模型"商品A详情和商品B详情本质上是同一种行为(查看详情)",这降低了模型的学习难度。反面教材是我早期没做抽象直接跑频繁序列挖掘,结果 top 10 高频路径里有 6 条是仅样本数不足 50 的长尾组合,产品经理直接说"这数字没意义"。

三、决策树建模与路径归因

路径分析不能停留在"描述性统计",要进到"归因性分析"——什么行为模式导致了转化或流失?

from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.model_selection import cross_val_score
import matplotlib.pyplot as plt

# ===== 路径特征提取(用于决策树输入) =====
def extract_path_features(paths_df):
    """将路径序列转化为决策树可用的特征矩阵"""
    features = pd.DataFrame()

    # 特征1: 是否包含关键行为
    key_actions = ["搜索", "查看详情", "加购/查看购物车", "查看评价", "对比商品"]
    for action in key_actions:
        features[f"has_{action}"] = paths_df["abstracted_path"].apply(
            lambda p: 1 if action in p else 0
        )

    # 特征2: 行为顺序(关键行为出现的位置)
    for action in ["搜索", "查看详情"]:
        features[f"pos_{action}"] = paths_df["abstracted_path"].apply(
            lambda p: p.index(action) / len(p) if action in p else -1
        )

    # 特征3: 路径结构性指标
    features["path_length"] = paths_df["path_length"]
    features["unique_actions"] = paths_df["unique_actions"]
    features["backtrack_count"] = paths_df["abstracted_path"].apply(
        lambda p: sum(1 for i in range(1, len(p)) if p[i] in p[:i])  # 回溯次数
    )
    features["detour_ratio"] = features["backtrack_count"] / features["path_length"]

    # 特征4: 关键转移是否发生
    features["has_search_to_detail"] = paths_df["abstracted_path"].apply(
        lambda p: 1 if any(p[i] == "搜索" and p[i+1] == "查看详情" for i in range(len(p)-1)) else 0
    )
    features["has_detail_to_cart"] = paths_df["abstracted_path"].apply(
        lambda p: 1 if any(p[i] == "查看详情" and p[i+1] == "加购/查看购物车" for i in range(len(p)-1)) else 0
    )

    # 特征5: 设备类型
    features["is_mobile"] = (paths_df["device_type"] == "mobile").astype(int)

    # 特征6: 会话时长(分钟)
    features["session_duration"] = paths_df["session_duration_minutes"]

    return features

feature_matrix = extract_path_features(paths_df)
target = paths_df["converted"].astype(int)

# ===== 决策树训练 =====
dt_model = DecisionTreeClassifier(
    max_depth=5,          # 限制深度,保持可解释性
    min_samples_split=50, # 每个节点至少50个样本
    min_samples_leaf=20,  # 叶节点至少20个样本
    criterion="gini",
    random_state=42
)

dt_model.fit(feature_matrix, target)

# 交叉验证评估
cv_scores = cross_val_score(dt_model, feature_matrix, target, cv=5, scoring="accuracy")
print(f"决策树5折CV准确率: {cv_scores.mean():.4f}")
# 输出: 决策树5折CV准确率: 0.8234

# 输出决策规则(人类可读)
tree_rules = export_text(dt_model, feature_names=list(feature_matrix.columns))
print(tree_rules)
# 输出示例:
# |--- has_加购/查看购物车 <= 0.50
# |   |--- has_查看详情 <= 0.50
# |   |   |--- class: 0 (未转化, 82%)
# |   |--- has_查看详情 >  0.50
# |   |   |--- session_duration <= 3.50
# |   |   |   |--- class: 0 (未转化, 65%)  ← 看了详情但太短
# |   |   |--- session_duration >  3.50
# |   |   |   |--- class: 0 (未转化, 55%)  ← 深度浏览但没加购
# |--- has_加购/查看购物车 >  0.50
# |   |--- has_发起结算 <= 0.50
# |   |   |--- class: 0 (未转化, 70%)  ← 加购但没结算
# |   |--- has_发起结算 >  0.50
# |   |   |--- class: 1 (转化, 78%)  ← 加购且结算=高转化

为什么决策树比 LSTM 更适合路径归因? 不是精度的问题——我在同一个数据集上跑 LSTM 编码路径序列做预测,AUC 能到 0.91,决策树只有 0.82。但 LSTM 的隐层状态你没法翻译成"首页搜索引导不够"这种业务语言。决策树的分裂规则是透明的——"如果用户没有加购行为且没有查看详情,流失率 82%",产品经理一看就知道"首页到列表页的引导链路有问题"。底层原理:决策树是一种基于特征的规则学习器,它的每个分裂节点对应一个可解释的特征阈值;而 LSTM 是把路径塞进一个高维向量空间,你拆不开这个黑盒。数据世界里,可行动的粗糙结论 > 不可行动的高精度。这个教训是我花了两个月调 LSTM 模型、被产品经理一句话打回原型后深刻理解的。

四、业务洞察与优化建议

决策树揭示的路径归因洞察,直接转化为产品优化建议:

# ===== 基于决策树的业务建议 =====
insights_and_actions = pd.DataFrame({
    "洞察": [
        "82%没看详情也没加购的用户流失",
        "看了详情但时长<3.5分钟的用户65%流失",
        "深度浏览但没加购的用户55%流失",
        "加购但没结算的用户70%流失",
        "加购且结算的用户78%转化"
    ],
    "根因分析": [
        "首页→列表页直接流失,搜索引导不足",
        "详情页信息密度不够,用户快速离开",
        "详情页→评价页回溯多,路径复杂",
        "购物车页面体验差(移动端尤甚)",
        "路径简洁,关键行为都完成"
    ],
    "优化建议": [
        "首页增加热门商品推荐和搜索引导",
        "详情页优化信息架构,核心卖点前置",
        "评价页增加'看完评价返回加购'引导",
        "购物车页简化操作,增加促销提示",
        "优化结算→支付流程(减少2个步骤)"
    ],
    "预期效果": [
        "首页→搜索转化提升15%",
        "详情页停留时长增加1分钟",
        "回溯率降低20%",
        "加购→结算转化提升10%",
        "结算→支付完成率提升5%"
    ]
})

print(insights_and_actions.to_string(index=False))

上线优化后的实测效果:

路径节点 优化前转化率 优化后转化率 提升
首页→搜索 35% 48% +13%
详情页停留≥3min 42% 56% +14%
加购→结算 38% 51% +13%
整体购买转化率 3.8% 5.2% +1.4%

为什么这些优化建议不是靠"直觉"而是靠树节点反推? 表面上每一条建议看起来都像'常识'——谁不知道首页要加搜索引导?但决策树给了你量化依据。比如"深度浏览但没加购的用户 55% 流失"这一条,对应树的第三个分裂节点,它告诉你不是所有深度浏览都会转化,浏览而不加购才是流失信号。这个粒度比"详情页做得好不好"精确了一个数量级。没有决策树的路径归因,你只能笼统地说"优化详情页",有了决策树,你会说"在评价页看完后加一个返回详情页加购的引导按钮"。产品经理拿着这个建议去改页面,两周后转化率就涨了 1.4 个百分点——不是瞎猜,是模型算出来的。

踩坑提醒

  1. 机器人过滤的阈值不能一刀切——"平均间隔 < 0.5 秒"和"单小时 > 200 次"这两个阈值在不同业务场景下差异巨大。电商大促期间,真实用户的点击频率可能是平时的 3 倍。建议先用历史数据画分布图,取 P99 分位数作为阈值,再手动抽查 100 条。如果过滤掉 30% 以上的数据,大概率阈值设错了。

  2. 路径抽象映射表是活的,不是一次写完就封存的——page_to_action 字典里把 product_detail 映射为"查看详情"看似没问题,但如果业务上线了新功能(比如"3D 试穿"),对应的新页面没在映射表里,就会以原始 URL 的形式出现在路径里,变成一个独立的行为类型——也就是数据噪音。每次业务上线新页面,同步更新映射表,保持路径抽象的一致性。

  3. 决策树的 max_depth 不是越小越好——把 depth 设为 3 想让规则"更简洁",结果分裂太少,把"加购且结算"和"加购但没结算"两类用户归到了一个叶子节点里,完全丢失了关键区分信号。depth=5 是目前实践中可解释性与精度的最佳平衡点。超过 7 层开始难解释,但某些场景(如风控)可以接受更深的树。

五、总结

用户路径分析从 Clickstream 到决策树,整个链路的核心感悟:

  1. 数据清洗不是"锦上添花",而是"生死线"——19% 的噪音数据(机器人、重复点击、异常跳转)如果不清洗,路径模式完全失真。特别是机器人流量,会把"首页→首页→首页→首页"的模式放大 10 倍,掩盖真实的用户行为。

  2. 路径抽象比路径挖掘更重要——原始路径有上千种变体,直接做模式挖掘结果碎片化不可用。抽象到"行为级"后,路径类型从上千种缩减到 30 种以内,决策树才有意义。

  3. 决策树是路径归因的最佳工具——不是因为它精度最高(随机森林/AUC 更高),而是因为它的输出是人类可读的规则。"加购但没结算的用户 70% 流失"比一个 0.85 的 AUC 数字对产品经理更有价值。数据分析的终极目标不是高精度,是可行动

踩过的坑:初期我用 LSTM 编码路径序列做预测,AUC 达到了 0.91,但产品经理看完结果问"所以我们应该改哪个页面?"我答不出来。后来换成决策树,AUC 只有 0.82,但每一条规则都能直接对应到页面优化。精度换可解释性,这笔交易值得做。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐