# 亲密指数计算说明(代码翻译版) ## 1. 目的 本文档不是“业务口头定义”,而是**按当前代码真实实现**翻译出来的计算逻辑,便于你做以下事情: - 跟业务同学对齐“现在系统到底怎么算的” - 排查为什么某个客户-助教分数高/低 - 做参数调优前的影响评估 --- ## 2. 代码入口与依赖 - 任务主类:`etl_billiards/tasks/dws/index/intimacy_index_task.py` - 指数基类(衰减、分位、映射、平滑):`etl_billiards/tasks/dws/index/base_index_task.py` - 课型映射(BASE/BONUS):`etl_billiards/tasks/dws/base_dws_task.py` - 参数表:`billiards_dws.cfg_index_parameters` - 结果表:`billiards_dws.dws_member_assistant_intimacy` - 分位历史表:`billiards_dws.dws_index_percentile_history` 执行主流程函数:`IntimacyIndexTask.execute()` --- ## 3. 总流程(按代码执行顺序) 1. 读取门店、租户、参数 2. 抽取助教服务记录(近 `lookback_days`) 3. 按 `(member_id, assistant_id)` 分组并做“会话合并” 4. 做充值归因(服务结束后 `recharge_attribute_hours` 内充值) 5. 计算分项分数 `F/R/M/D` 和激增放大 `mult` 6. 合成 `raw_score` 7. 把 `raw_score` 映射到 `display_score`(0-10) 8. 保存分位历史(支持 EWMA 平滑) 9. 删除旧记录并写入新记录 --- ## 4. 数据抽取口径 ### 4.1 服务记录(`_extract_service_records`) 来源表:`billiards_dwd.dwd_assistant_service_log`,并 `JOIN billiards_dwd.dim_assistant` 获取 `assistant_id`。 过滤条件: - `site_id = 当前门店` - `tenant_member_id > 0`(排除散客) - `is_delete = 0` - `user_id > 0` - `last_use_time` 在 `[now - lookback_days, now)` 内 - `dim_assistant.scd2_is_current = 1` 输出核心字段: - `member_id` - `assistant_id` - `assistant_user_id` - `start_time` - `end_time`(对应 `last_use_time`) - `duration_minutes`(`income_seconds / 60`) - `skill_id` --- ## 5. 会话合并逻辑(`_group_and_merge_sessions`) 先按 `(member_id, assistant_id)` 分组,再对每组按 `start_time` 排序后做合并。 ### 5.1 合并规则 - 相邻两条服务若满足:`next.start_time - current.session_end <= session_merge_hours`(默认 4 小时) - 则视为同一次会话,执行: - `session_end = max(end_time)` - `total_duration_minutes += 当前时长` - `course_weight = max(历史权重, 当前权重)` - `is_incentive = 历史 or 当前` ### 5.2 课型与权重 通过 `get_course_type(skill_id)` 决定课型: - `BONUS`:权重 `incentive_weight`(默认 1.5) - 其他:权重 1.0 `get_course_type` 依赖 `cfg_skill_type`。若未命中映射,默认 `BASE`(权重 1.0)。 ### 5.3 会话级统计 每个客户-助教对会得到: - `session_count` - `total_duration_minutes` - `basic_session_count` - `incentive_session_count` - `days_since_last_session` --- ## 6. 充值归因逻辑(`_extract_attributed_recharges`) 来源表:`billiards_dwd.dwd_recharge_order` 查询条件: - `site_id = 当前门店` - `member_id IN 本轮出现的会员` - `settle_type = 5`(充值订单) - `pay_time >= now - lookback_days` 归因条件(对每笔充值): - 找到该会员对应的会话 - 若 `session_end <= pay_time` 且 `pay_time - session_end <= recharge_attribute_hours`(默认 1 小时) - 则记为该助教贡献: - `attributed_recharge_count += 1` - `attributed_recharge_amount += pay_amount` - 记录一条 `AttributedRecharge` --- ## 7. 分数计算(`_calculate_component_scores`) ## 7.1 时间衰减函数 来自 `BaseIndexTask.decay(days, halflife)`: `decay(d, h) = exp(-ln(2) * d / h)` 含义:`d = h` 时权重衰减到 0.5。 ### 7.2 分项定义 设: - `w_i` = 会话权重(1.0 或 1.5) - `d_i` = 会话距今天数(按 `session_end`) - `A0` = `amount_base`(默认 500) #### F:频次强度 `F = sum( w_i * decay(d_i, halflife_session) )` #### R:最近温度 `R = decay(days_since_last_session, halflife_last)`,无最近会话则 0 #### M:归因充值强度 对每笔归因充值 `r`: `M += ln(1 + pay_amount_r / A0) * decay(days_ago_r, halflife_recharge)` #### D:时长贡献 `D = sum( sqrt(duration_hours_i) * w_i * decay(d_i, halflife_session) )` 其中 `duration_hours_i = total_duration_minutes_i / 60` #### burst 与 mult:激增放大 先算: - `F_short = sum( w_i * decay(d_i, halflife_short) )` - `F_long = sum( w_i * decay(d_i, halflife_long) )` 再算: - `ratio = F_short / (F_long + 1e-6)` - `burst = ln(1 + (ratio - 1))` 当 `ratio > 1`,否则 `0` - `mult = 1 + burst_gamma * burst` --- ## 8. Raw Score 合成 `raw_score = (weight_frequency * F + weight_recency * R + weight_recharge * M + weight_duration * D) * mult` 默认权重(代码默认值): - `weight_frequency = 2.0` - `weight_recency = 1.5` - `weight_recharge = 2.0` - `weight_duration = 0.5` - `burst_gamma = 0.6` --- ## 9. Display Score(0-10)映射 由 `BaseIndexTask.batch_normalize_to_display` 完成。 1. 收集全体 `raw_score` 2. 计算分位点 `q_l/q_u`(默认 P5/P95) 3. 可选 EWMA 平滑分位点(`use_smoothing=1` 时) 4. Winsorize:`clipped = min(max(raw, q_l), q_u)` 5. 可选压缩(`compression_mode`): - `0 -> none` - `1 -> log1p` - `2 -> asinh` 6. MinMax 映射到 `[0,10]` 7. 四舍五入到 2 位小数 特殊情况: - 若 `max_val - min_val < 1e-6`,直接返回 5.0(避免分母接近 0) EWMA 公式: `Q_t = (1 - alpha) * Q_{t-1} + alpha * Q_now`,默认 `alpha=0.2` --- ## 10. 参数加载优先级 函数:`_load_params()` - 先用代码默认参数(`DEFAULT_PARAMS`) - 再用数据库参数覆盖(`cfg_index_parameters`) 数据库参数加载规则(`load_index_parameters`): - 只取 `effective_from <= CURRENT_DATE` 且 `effective_to` 未过期 - 按 `effective_from DESC` 排序 - 同名参数取第一条 即:**DB > 代码默认值**。 --- ## 11. 持久化逻辑 函数:`_save_intimacy_data` 1. 先删除当前门店下本轮 `(member_id, assistant_id)` 对应旧记录 2. 再逐条插入新结果 3. 插入字段包含: - 输入特征(会话数、时长、归因充值等) - 分项得分(F/R/M/D、burst) - `raw_score/display_score` - 时间戳(`calc_time/created_at/updated_at`) 唯一键:`(site_id, member_id, assistant_id)`。 --- ## 12. 默认参数清单(代码 + 种子一致) | 参数 | 默认值 | 含义 | |---|---:|---| | `lookback_days` | 60 | 回看窗口(天) | | `session_merge_hours` | 4 | 会话合并间隔(小时) | | `recharge_attribute_hours` | 1 | 充值归因窗口(小时) | | `amount_base` | 500 | 充值强度压缩基数 | | `incentive_weight` | 1.5 | 附加课权重 | | `halflife_session` | 14 | 会话衰减半衰期 | | `halflife_last` | 10 | 最近服务衰减半衰期 | | `halflife_recharge` | 21 | 充值衰减半衰期 | | `halflife_short` | 7 | 短期频次半衰期 | | `halflife_long` | 30 | 长期频次半衰期 | | `weight_frequency` | 2.0 | F 权重 | | `weight_recency` | 1.5 | R 权重 | | `weight_recharge` | 2.0 | M 权重 | | `weight_duration` | 0.5 | D 权重 | | `burst_gamma` | 0.6 | 激增放大系数 | | `percentile_lower` | 5 | 下分位(P5) | | `percentile_upper` | 95 | 上分位(P95) | | `ewma_alpha` | 0.2 | 分位平滑系数 | | `compression_mode` | 1 | 压缩方式(1=log1p) | | `use_smoothing` | 1 | 是否启用 EWMA | --- ## 13. 代码语义下的关键注意点(非常重要) 以下不是业务理想设计,而是“按当前实现”的真实行为: 1. 课型映射依赖 `cfg_skill_type` - 若 `skill_id` 未映射,默认按 `BASE` 处理(不会给 1.5 权重)。 2. 会话合并后权重取 `max` - 同一合并会话里如果出现过 `BONUS`,整个会话的 `course_weight` 可能被抬到 1.5。 3. 充值归因“注释意图”与“实际循环”可能有偏差 - 代码注释写“1 笔充值只归因 1 个助教”, - 但 `break` 只跳出“会话循环”,不会跳出“pair 循环”,在特定时序下同一笔充值可能落到多个助教对上。 4. Display Score 是相对分 - 同一人不同批次跑数,若整体分布变化,即使 raw 接近,display 也可能变化(因分位映射)。 --- ## 14. 一句话总结 当前亲密指数本质上是:**“近期加权服务频次 + 最近接触 + 归因充值 + 服务时长”** 的加权和,再乘上**短期活跃激增放大因子**,最后经分位截断与归一化映射到 0-10。