298 lines
8.4 KiB
Markdown
298 lines
8.4 KiB
Markdown
# 亲密指数计算说明(代码翻译版)
|
||
|
||
## 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。
|
||
|