PostHog 实验查询运行器深入指南:漏斗评估表达式与统计参数配置
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
导读
本文聚焦 PostHog 实验(Experiments)后端查询运行器(query runner)中最核心的两个技术主题:漏斗(Funnel)指标评估表达式的构建原理,以及**可配置统计参数(stats_config)**的完整结构与校验规则。你将掌握aggregate_funnel_arrayUDF 的输入输出契约、步骤条件预计算的设计动机与实现方式、事件数组的构造细节、漏斗结果评估逻辑,以及 Bayesian / Frequentist 两类统计方法的全部可调参数及默认值。文中所有结论均有仓库源码与测试用例佐证,可直接用于理解实验漏斗查询的执行链路或在此基础上进行二次开发。
关联文档:products/experiments/backend/hogql_queries/README.md
一、为什么实验漏斗查询需要"表达式级"文档说明
PostHog 实验的漏斗指标在查询层面有一套不同于普通产品分析漏斗的特殊约束:实验需要把每个用户的转化行为与实验曝光(exposure)和变体(variant)归属绑定在一起,而完成这一逻辑的aggregate_funnel_arrayUDF 对输入格式有严格要求——它要求每个事件以"元组数组"的形式传入,且每个元组必须携带时间戳、事件 ID、分组值(breakdown value)和命中的步骤编号。
由于构造这种输入格式的 SQL 相对复杂,README.md 用一整篇文档专门拆解了查询构造的各个部分。本文在此基础上,结合 experiment_funnel_query_builder.py 与 base_query_utils.py 的源码实现,还原完整的执行链路。
二、漏斗步骤预计算:把属性解析留在 SQL 层
2.1 设计动机:避免 UDF 内的属性解析问题
当漏斗步骤带有属性过滤器时(例如按wizard_step = "step_1"过滤事件),如果把这些过滤器直接塞进 UDF 内部解析,会遇到属性解析(property resolution)层面的问题——尤其是嵌套属性、复杂操作符等场景。因此,实验查询运行器要求步骤条件在指标事件查询(metric events query)中预先计算,而不是在 UDF 内部解析。
这一设计带来的三个核心收益,在 README.md 中明确列出:
- 属性解析(Property resolution):复杂属性过滤器(包括嵌套属性)在 SQL 层面解析,HogQL 类型系统在这里能正确工作;
- 性能(Performance):属性过滤在查询管线早期完成,尽早缩小数据量;
- 兼容性(Compatibility):所有属性类型和操作符都可正常工作,不受 UDF 自身能力限制。
2.2 指标事件查询中的步骤条件预计算
在漏斗指标的_get_metric_events_query()中,每个步骤条件被计算为独立的布尔列。README 给出了完整示例:
SELECT events.timestamp, events.person_id AS entity_id, exposure_data.variant, events.event, events.uuid, events.properties, if(and(equals(events.event, '$pageview'), equals(events.properties.wizard_step, 'step_1')), 1, 0) AS step_0, if(and(equals(events.event, '$pageview'), equals(events.properties.wizard_step, 'step_2')), 1, 0) AS step_1 FROM events INNER JOIN exposure_data ON events.person_id = exposure_data.entity_id WHERE ...源码印证:FunnelStepBuilder
仓库中负责生成这些步骤列的正是 funnel_step_builder.py 中的FunnelStepBuilder类。它支持两种构建模式(见类注释 L27-L32):
- 布尔列模式(
build_boolean_columns):用于纯事件漏斗,所有步骤在单个查询中针对 events 表求值,每个步骤是一个布尔表达式; - 常量列模式(
build_constant_columns):用于含数据仓库(Data Warehouse)源的UNION ALL查询,每个子查询代表一个步骤,活动步骤置 1、其余置 0。
关键实现细节:
num_steps = len(series) + 1——步骤总数包含曝光步骤step_0(L54-L55);build_boolean_columns中,step_0直接使用曝光条件表达式(exposure_filter),而step_1..N用if(step_filter, 1, 0)包裹(L83-L99);- 步骤过滤条件由
event_or_action_to_filter()生成(base_query_utils.py),它统一处理EventsNode(按事件名匹配)与ActionsNode(按动作 ID 匹配),并叠加properties属性过滤。
build_boolean_columns的 docstring 示例(L71-L79):
>>> builder = FunnelStepBuilder([ ... EventsNode(event="pageview"), ... EventsNode(event="purchase") ... ], team) >>> exposure_filter = parse_expr("event = '$feature_flag_called'") >>> columns = builder.build_boolean_columns(exposure_filter) >>> [col.alias for col in columns] ['step_0', 'step_1', 'step_2']两条执行路径:legacy(3-CTE)与 optimized(单扫描)
FunnelQueryBuilder.build_funnel_query()会根据条件在两条路径间分派(experiment_funnel_query_builder.py#L51-L79):
- Legacy 路径(
build_funnel_query_legacy):3 个 CTE(exposures→metric_events→entity_metrics),主要服务于预计算(precomputed)场景和含 DW 步骤的 UNION ALL 漏斗; - Optimized 路径(
build_funnel_query_optimized):消除对 events 表的第二次扫描和中间 JOIN,有序漏斗用 2 个 CTE(base_events→entity_metrics),无序漏斗用 3 个 CTE(额外增加first_exposures做时间过滤)。
在 legacy 路径的metric_eventsCTE 中,WHERE 条件为(exposure_predicate OR funnel_steps_filter),随后build_funnel_step_columns()将步骤列注入 SELECT(L287-L298)。
2.3 预计算路径:从events表直接读取步骤数组
值得一提的相关优化:当启用了指标事件预计算时,metric_eventsCTE 不再扫描 events 表,而是读取experiment_metric_events_preaggregated表,并通过arrayElement(t.steps, N)从打包的Array(UInt8)中解出每一步的布尔值。这一步的具体实现与数据流详见仓库内的姊妹文档 METRIC_EVENTS_PRECOMPUTATION.md。
三、UDF 步骤条件构造:把布尔列变成步骤编号
预计算出的step_0、step_1等布尔列随后被funnel_evaluation_expr()消费。README 中展示了其核心形式:
multiply(1, metric_events.step_0), multiply(2, metric_events.step_1),在 base_query_utils.py 的funnel_evaluation_expr中,这一逻辑以程序化方式生成(L490-L490):
step_conditions = [f"{i + 1} * {events_alias}.step_{i}" for i in range(num_steps)]即:第i个步骤条件 =(i+1) * step_i。
- 目的:为每个事件创建数值型步骤标识符;
- 逻辑:如果事件匹配某步骤条件,返回该步骤的编号(1、2、3…),否则返回 0;
- 结果:每个事件被标记为"满足了漏斗中的哪些步骤"。
注意这里的步骤编号从 1 开始(step_0对应编号 1,即曝光步骤),这保证了arrayFilter(x -> x > 0, ...)能够恰好滤掉未命中任何步骤的事件。
四、事件数组构造:UDF 的主输入
这是传给aggregate_funnel_array函数的主输入。查询部分将每个用户的全部事件转换为函数所需的格式——一个元组数组,其中每个元素代表该用户的一个事件,包含时间戳、事件标识,以及该事件是否满足漏斗中的某些步骤。
arraySort(t -> t.1, groupArray(tuple( timestamp_float, -- 排序键:时间戳 uuid, -- 事件标识 array(''), -- 分组值(空 = 无分组) arrayFilter(x -> x > 0, [...]) -- 该事件命中的步骤编号 )))在funnel_evaluation_expr的完整表达式中,实际生成的是(base_query_utils.py#L521-L529):
arraySort(t -> t.1, arrayFilter( t -> isNotNull(t.1) AND isNotNull(t.2), groupArray(tuple( toFloat(timestamp_field), uuid_field, array(''), arrayFilter(x -> x > 0, [step_conditions]) )) ))各部分职责:
- 排序:按时间戳(
t.1)排序,确保事件按时间先后排列; - 过滤:
arrayFilter(x -> x > 0, [...])移除 0 与 NULL,只保留真实命中的步骤编号; - 空值防御:外层
arrayFilter(t -> isNotNull(t.1) AND isNotNull(t.2), ...)过滤掉时间戳或 UUID 为空的事件; - 示例结果:
[(1704110400.0, uuid1, '', [1]), (1704110700.0, uuid2, '', [2])]
五、UDF 函数调用:五个核心参数
README 给出了aggregate_funnel_array的完整调用形态:
aggregate_funnel_array( 3, -- 漏斗步骤数 3600, -- 转化窗口:1 小时 'first_touch', -- 归因方式:使用分组值的首次出现 'ordered', -- 顺序:事件必须按序发生 array(array('')), -- 分组值,空 -> 无分组 events_array -- 上述预处理好的事件数据 )参数逐个解读:
| 参数 | 示例值 | 含义 |
|---|---|---|
| 步骤数 | 3 | 漏斗中的步骤总数(含曝光步骤时需相应增加) |
| 转化窗口(秒) | 3600 | 首尾步骤之间的最大时间间隔,3600 秒 = 1 小时 |
| 归因方式 | 'first_touch' | 仅在启用分组(breakdown)时有意义,实验场景通常不分组 |
| 顺序模式 | 'ordered' | ordered表示步骤 2 必须发生在步骤 1 之后、步骤 3 在步骤 2 之后 |
| 分组值 | array(array('')) | 空值表示无分组 |
| 事件数组 | events_array | 前述预处理后的事件数据 |
源码中的默认值与细节
- 转化窗口:当指标未显式配置
conversion_window/conversion_window_unit时,funnel_evaluation_expr默认取3 年(3 * 365 * 24 * 60 * 60秒,base_query_utils.py#L478-L479),确保选中时间段内的所有事件都被纳入; - 顺序模式:
funnel_order_type未设置时默认"ordered"(base_query_utils.py#L495); - 单位换算:
conversion_window_to_seconds()支持 SECOND / MINUTE / HOUR / DAY / WEEK / MONTH 六种单位,其中 MONTH 按 30 天折算(base_query_utils.py#L245-L258); - 无序漏斗:当
funnel_order_type == UNORDERED时,UDF 不做"曝光必须先于转化"的时间约束,因此 legacy 路径会在 JOIN 时附加AND metric_events.timestamp >= exposures.first_exposure_time的时间过滤(experiment_funnel_query_builder.py#L159-L178),optimized 路径则通过first_exposuresCTE + INNER JOIN 实现同样的语义(L443-L493)。
返回值:每个用户的元组数组
UDF 返回每个用户的元组数组(每个分组值一个元素;由于实验不关心分组值,数组内只有一个元素),元组结构如下:
step_reached:完成的最高步骤(0 索引,因此2表示完成全部 3 个步骤);breakdown_values:分组属性值数组(实验场景下为空);conversion_times:步骤间耗时数组[step1→step2, step2→step3];event_uuids:每个步骤所用事件的 UUID 数组。
注意funnel_evaluation_expr实际取用的是result.1(最高步骤)与result.4(事件 UUID 数组),通过arraySort(x -> -x.1, ...)[1]选出"最高完成步骤"对应的结果元组(base_query_utils.py#L501-L532),同时把命中的首个事件 UUID 一并带出。
六、结果评估:完整漏斗判定
最后一步是判断用户是否完成了完整漏斗(返回 1)或未完成(返回 0)。评估逻辑如下:
aggregate_funnel_array为每个用户返回一个元组数组(每个分组值一个元素;实验不关心分组值,因此数组只有一个元素);- 先过滤数组,只保留"漏斗完成"的元素,即满足
step_reached >= num_steps - 1(step_reached是 0 索引的); - 若过滤后列表非空(
length > 0),返回 1,否则返回 0。
最终聚合查询中的体现
这一"是否完成"判定最终体现在entity_metricsCTE 后的外层 SELECT 中(experiment_funnel_query_builder.py#L253-L268):
SELECT entity_metrics.variant AS variant, count(entity_metrics.entity_id) AS num_users, countIf(entity_metrics.value.1 = num_steps_minus_1) AS total_sum, countIf(entity_metrics.value.1 = num_steps_minus_1) AS total_sum_of_squares FROM entity_metrics WHERE notEmpty(variant) GROUP BY entity_metrics.variant源码注释明确说明(L256-L258):"漏斗评估的返回值是零索引的。到达第一步返回 0,以此类推;到达最后一步返回num_steps - 1"。因此用value.1 = num_steps - 1判定"完整漏斗"。
此外,为了渲染漏斗图,查询还会追加step_counts列——统计"到达每一步的用户数"(L300-L307):
tuple(countIf(value.1 >= 1), countIf(value.1 >= 2), ...) AS step_counts七、参数可配置性:stats_config完整指南
实验支持通过stats_config字段配置统计参数,允许用户自定义 Bayesian 与 Frequentist 两类统计方法的行为。
7.1 配置结构
stats_config是一个按方法名分键的 JSON 对象:
{ "bayesian": { "ci_level": 0.95, "difference_type": "RELATIVE", "prior_type": "RELATIVE" }, "frequentist": { "alpha": 0.05, "difference_type": "RELATIVE" } }7.2 Bayesian 参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ci_level | float | 0.95 | 可信区间(Credible interval)水平,取值必须在 0 与 1 之间 |
difference_type | string | "RELATIVE" | 差异计算方式:"RELATIVE"为相对基线的百分比变化;"ABSOLUTE"为相对基线的绝对差 |
prior_type | string | "RELATIVE" | 先验类型:"RELATIVE"为相对基线的先验;"ABSOLUTE"为绝对先验 |
7.3 Frequentist 参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
alpha | float | 0.05 | 显著性水平,取值必须在 0 与 1 之间 |
difference_type | string | "RELATIVE" | 差异计算方式:"RELATIVE"为相对基线的百分比变化;"ABSOLUTE"为绝对差 |
7.4 校验与默认值回退
根据 README.md 与测试用例 test_stats_config.py,配置遵循以下回退规则:
- 数值越界或非数值(如
alpha: 5.0、alpha: -0.5、ci_level: 1.5)→ 回退到默认值(L185-L230); - 枚举值拼写错误或类型错误(如
difference_type: "INVALID"、difference_type: 123)→ 回退到默认值(L112-L150); - 参数缺失→ 使用默认值;
null或空配置→ 全部使用默认值。
测试还验证了配置确实影响统计输出:
ci_level从 0.90 调到 0.99 会实际改变可信区间宽度(test_bayesian_ci_level_actually_affects_interval_width,L152-L182);- 空配置(
None/{}/{"frequentist": {}})下统计计算不会报错(test_frequentist_defaults/test_bayesian_defaults,L51-L110); - 数据不足时(样本过小、基线总和为零、方差为零)返回原始值而不产出统计推断(L27-L31)。
7.5 服务层的补充校验
除了统计数值本身的校验,实验服务层 experiment_service.py 的validate_stats_config()还会校验stats_config的结构形态、method取值以及baseline_variant_key基线变体键(L908-L924),确保配置能够正确定位基线变体用于对比计算。
八、延伸阅读
- METRIC_EVENTS_PRECOMPUTATION.md:漏斗指标事件预计算的完整设计(写路径、读路径、转化窗口延伸、与曝光预计算的差异);
- LAZY_COMPUTATION.md:惰性计算系统(按天分窗、任务管理、TTL);
- experiment_funnel_query_builder.py:漏斗查询构建器(legacy 与 optimized 双路径);
- funnel_step_builder.py:步骤列构建(布尔列 / 常量列);
- base_query_utils.py:
funnel_evaluation_expr()与转换窗口工具; - funnel_validation.py:DW 漏斗配置校验(必填字段、join key 一致性、复杂度上限:最多 3 个 DW 步骤 / 2 张不同 DW 表);
- 测试佐证:test_funnel_metric.py、test_stats_config.py。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考