# INDEX 层任务详解

> 本文档说明飞球 ETL 系统中 INDEX（指数算法层）的所有任务。
> INDEX 层基于 DWD/DWS 层数据，通过自定义算法计算业务指数，
> 服务于会员运营（回流挽回、新客转化）和助教管理（关系归属、付费关联）等场景。

---

## 概述

INDEX 层共有 5 个已注册任务：

| 任务代码 | Python 类 | 目标表 | 指数类型 | 更新策略 |
|----------|-----------|--------|----------|----------|
| `DWS_WINBACK_INDEX` | `WinbackIndexTask` | `dws_member_winback_index` | WBI（回流指数） | delete-before-insert（按门店全量刷新） |
| `DWS_NEWCONV_INDEX` | `NewconvIndexTask` | `dws_member_newconv_index` | NCI（新客转化指数） | delete-before-insert（按门店全量刷新） |
| `DWS_RELATION_INDEX` | `RelationIndexTask` | `dws_member_assistant_relation_index` | RS/OS/MS/ML（关系指数） | delete-before-insert（按门店全量刷新） |
| `DWS_SPENDING_POWER_INDEX` | `SpendingPowerIndexTask` | `dws_member_spending_power_index` | SPI（消费力指数） | delete-before-insert（按门店全量刷新） |
| `DWS_ML_MANUAL_IMPORT` | `MlManualImportTask` | `dws_ml_manual_order_source` / `dws_ml_manual_order_alloc` | ML（手动台账导入） | 按 scope 先删后写 |

> 注册位置：`orchestration/task_registry.py`，所有 INDEX 任务的 `requires_db_config=False`、`layer="INDEX"`。SPI 任务额外声明 `depends_on=["DWS_MEMBER_CONSUMPTION"]`。

---

## BaseIndexTask 公共机制

`BaseIndexTask`（位于 `tasks/dws/index/base_index_task.py`）继承自 `BaseDwsTask`，为所有指数任务提供统一的算法基础设施。

### 继承层次

```
BaseTask
  └── BaseDwsTask
        └── BaseIndexTask
              ├── MemberIndexBaseTask   ← WBI / NCI 共享的会员特征提取
              │     ├── WinbackIndexTask
              │     └── NewconvIndexTask
              ├── RelationIndexTask          ← RS/OS/MS/ML 四合一
              ├── SpendingPowerIndexTask     ← SPI 消费力指数（独立数据提取）
              └── MlManualImportTask         ← ML 人工台账导入
```

### 子类必须实现的抽象方法

```python
def get_index_type(self) -> str:
    """返回指数类型标识，如 'WBI'、'NCI'、'RS'"""
```

### 核心能力

#### 1. 半衰期时间衰减函数

所有指数共享的时间权重模型，核心思想是"越近越重要"：

```
decay(d; h) = exp(-ln(2) × d / h)
```

| 参数 | 含义 | 示例 |
|------|------|------|
| `d` | 事件距今天数（≥0） | 7 天 |
| `h` | 半衰期（>0），单位：天 | 7 天 |
| 返回值 | 衰减权重，范围 (0, 1] | 0.5 |

当 `d = h` 时权重恰好衰减到 0.5；`d = 0` 时权重为 1.0。

#### 2. 分位数计算与截断（Winsorize）

用于消除极端值对归一化的影响：

1. 计算 P5 和 P95 分位点
2. 将所有 Raw Score 截断到 [P5, P95] 范围内

```python
calculate_percentiles(scores, lower=5, upper=95) → (P5, P95)
winsorize(value, lower, upper) → clipped_value
```

#### 3. 0-10 归一化映射

将 Raw Score 映射到 0-10 分的 Display Score，便于业务理解和排序：

```
映射流程：Raw Score → [可选压缩] → Winsorize(P5, P95) → MinMax(0, 10)
```

压缩模式（由 `compression_mode` 参数控制）：

| compression_mode | 方式 | 公式 | 适用场景 |
|------------------|------|------|----------|
| 0 | 无压缩 | `y = x` | 分布较均匀时 |
| 1 | log1p | `y = ln(1 + x)` | 右偏分布（默认） |
| 2 | asinh | `y = asinh(x)` | 含负值或极端右偏 |

当所有分数几乎相同（`max - min < ε`）时，返回中间值 5.0。

#### 4. 算法参数加载

从 `dws.cfg_index_parameters` 表按 `index_type` 加载参数：

- 按 `effective_from` 降序取最新生效的参数值
- 支持按 `index_type` 隔离的内存缓存（TTL = 300 秒）
- 子类可通过 `get_param(name, default)` 获取单个参数

```python
load_index_parameters(index_type=None) → Dict[str, float]
get_param(name, default=0.0, index_type=None) → float
```

#### 5. 分位点历史管理（EWMA 平滑）

为避免分位点在不同批次间剧烈波动，支持 EWMA（指数加权移动平均）平滑：

```
Q_t = (1 - α) × Q_{t-1} + α × Q_now
```

| 参数 | 含义 | 默认值 |
|------|------|--------|
| `α`（ewma_alpha） | 平滑系数，越大越跟随当前值 | 0.2 |
| `Q_{t-1}` | 上一次平滑后的分位点 | 从 `dws_index_percentile_history` 表读取 |
| `Q_now` | 当前批次计算的分位点 | 实时计算 |

首次计算时无历史记录，直接使用当前分位点（不平滑）。每次计算后将原始分位点和平滑分位点保存到 `dws_index_percentile_history` 表。

#### 6. 统计工具方法

| 方法 | 功能 |
|------|------|
| `calculate_median(values)` | 中位数 |
| `calculate_mad(values)` | MAD（中位绝对偏差），比标准差更稳健 |
| `safe_log(value)` | 安全对数（value ≤ 0 时返回默认值） |
| `safe_ln1p(value)` | 安全的 `ln(1+x)` |


---

## MemberIndexBaseTask 会员指数共享基类

`MemberIndexBaseTask`（位于 `tasks/dws/index/member_index_base.py`）继承自 `BaseIndexTask`，为 WBI 和 NCI 提供共享的会员活动特征提取逻辑。

### 模板方法 execute()

`MemberIndexBaseTask` 提供 `execute()` 模板方法，子类只需实现 `_calculate_scores()` 和 `_save_results()` 两个抽象方法：

```python
class MemberIndexBaseTask(BaseIndexTask):
    def execute(self, cursor_data=None) -> dict:
        context = self._build_context(cursor_data)
        site_id = self._get_site_id(context)
        tenant_id = self._get_tenant_id()
        params = self._load_params()
        activities = self._build_member_activity(site_id, tenant_id, params)
        raw_scores = self._calculate_scores(activities, params, site_id, tenant_id)
        normalized = self.batch_normalize_to_display(raw_scores, ...)
        result = self._save_results(normalized, site_id, tenant_id, context)
        return result

    def _calculate_scores(self, activities, params, site_id, tenant_id) -> dict:
        raise NotImplementedError  # 子类实现

    def _save_results(self, normalized, site_id, tenant_id, context) -> dict:
        raise NotImplementedError  # 子类实现
```

### 会员活动特征（MemberActivityData）

从 DWD 层提取并计算的会员特征数据结构：

| 字段 | 类型 | 含义 |
|------|------|------|
| `member_id` | int | 会员 ID |
| `site_id` / `tenant_id` | int | 门店 / 租户 ID |
| `member_create_time` | datetime | 会员建档时间 |
| `first_visit_time` | datetime | 首次到店时间 |
| `last_visit_time` | datetime | 最近到店时间 |
| `last_recharge_time` | datetime | 最近充值时间 |
| `t_v` | float | 距最近到店天数（截断到 recency 窗口） |
| `t_r` | float | 距最近充值天数（截断到 recency 窗口） |
| `t_a` | float | `min(t_v, t_r)`，综合活跃度 |
| `visits_14d` / `visits_60d` / `visits_total` | int | 近 14 天 / 60 天 / 总到店次数 |
| `spend_30d` / `spend_180d` | float | 近 30 天 / 180 天消费金额 |
| `sv_balance` | float | 储值卡余额 |
| `recharge_60d_amt` | float | 近 60 天充值金额 |
| `intervals` | List[float] | 到店间隔天数序列 |
| `interval_ages_days` | List[int] | 每个间隔对应的"年龄"（距今天数） |
| `recharge_unconsumed` | int | 充值后是否未回访（1=是） |

### 数据来源

| 数据 | 来源表 | 提取方式 |
|------|--------|----------|
| 到店记录 | `dwd.dwd_settlement_head` | 按天去重，仅计入正常结账（settle_type=1）和激励课结账（settle_type=3 且关联 BONUS 技能） |
| 充值记录 | `dwd.dwd_recharge_order` | settle_type=5，近 recency_days 天 |
| 会员建档时间 | `dwd.dim_member` | scd2_is_current=1 |
| 首次到店时间 | `dwd.dwd_settlement_head` | 全量 MIN(pay_time) |
| 储值卡余额 | `dwd.dim_member_card_account` | 按 card_type_id 筛选现金卡 |

> 会员 ID 规范化：优先使用 `member_id`，若为 0 则通过 `dim_member_card_account` 关联取 `tenant_member_id`。

### 会员分群（classify_segment）

WBI 和 NCI 共享的三分群逻辑，决定会员进入哪个指数的计算范围：

| 分群 | 条件 | 进入指数 |
|------|------|----------|
| **STOP** | `t_a ≥ recency_days`（默认 60 天无活动） | 不参与评分（除 STOP_HIGH_BALANCE 例外） |
| **NEW** | 满足以下任一：到店 ≤ 2 次、首访 ≤ 30 天、近期充值未回访 | NCI |
| **OLD** | 不满足 STOP 和 NEW 条件 | WBI |

STOP_HIGH_BALANCE 例外：当 `enable_stop_high_balance_exception=1` 且储值余额 ≥ `high_balance_threshold`（默认 1000 元）时，STOP 会员仍参与 WBI 评分。


---

## DWS_WINBACK_INDEX — 老客挽回指数（WBI）

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_WINBACK_INDEX` |
| Python 类 | `WinbackIndexTask`（`tasks/dws/index/winback_index_task.py`） |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → WinbackIndexTask` |
| 目标表 | `dws.dws_member_winback_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `WBI` |
| 更新策略 | 按门店全量刷新（先 DELETE WHERE site_id = %s，再 INSERT） |

### 业务含义

WBI 衡量老客的"挽回紧急度"——分数越高，表示该会员越需要运营人员主动触达。适用于已有多次到店记录但近期活跃度下降的老客群体。

### 计算范围

仅对 `segment = "OLD"` 或 `status = "STOP_HIGH_BALANCE"` 的会员计算。

### 算法概要

WBI Raw Score 由 4 个分项加权求和，再乘以近期抑制系数：

```
WBI_raw = suppression × (w_over × Overdue + w_drop × Drop + w_re × Recharge + w_value × Value)
```

#### 分项 1：超期紧急性（Overdue）

基于会员个人历史到店间隔的加权经验 CDF，衡量当前缺席天数的异常程度：

1. 收集会员历史到店间隔序列 `{interval_i, age_i}`
2. 计算加权 CDF：`P(interval ≤ t_v)`，权重按间隔年龄半衰期衰减
3. 对小样本混合等权分布与加权分布（`λ = min(1, N / blend_min_samples)`）
4. `Overdue = P^α`（α 默认 2.0，放大高概率区间的紧急性）

同时计算理想到店间隔（加权中位数），用于推算 `ideal_next_visit_date`。

#### 分项 2：降频分（Drop）

检测近期到店频率是否低于历史均值：

```
expected_14d = visits_60d × 14 / 60
Drop = clip((expected_14d - visits_14d) / (expected_14d + 1), 0, 1)
```

#### 分项 3：充值未回访压力（Recharge）

若会员充值后未回访（`recharge_unconsumed = 1`），按充值距今天数衰减：

```
Recharge = decay(t_r, h_recharge)    # h_recharge 默认 7 天
```

#### 分项 4：价值分（Value）

综合消费金额和储值余额的对数压缩：

```
Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)
```

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `M0` (amount_base_M0) | 300 | 消费金额压缩基准 |
| `B0` (balance_base_B0) | 500 | 余额压缩基准 |

#### 近期抑制（Suppression）

防止刚到店的会员获得高分，使用 Sigmoid 门控：

```
suppression = σ((t_v - gate_days) / slope_days)
```

- 当 `t_v < hard_floor_days`（默认 14 天）时，`suppression = 0`（完全抑制）
- 当 `t_v` 远大于 `gate_days` 时，`suppression → 1`（不抑制）

### 默认权重

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_over` | 2.0 | 超期紧急性权重 |
| `w_drop` | 1.0 | 降频权重 |
| `w_re` | 0.4 | 充值压力权重 |
| `w_value` | 1.2 | 价值权重 |


---

## DWS_NEWCONV_INDEX — 新客转化指数（NCI）

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_NEWCONV_INDEX` |
| Python 类 | `NewconvIndexTask`（`tasks/dws/index/newconv_index_task.py`） |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MemberIndexBaseTask → NewconvIndexTask` |
| 目标表 | `dws.dws_member_newconv_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `NCI` |
| 更新策略 | 按门店全量刷新（先 DELETE WHERE site_id = %s，再 INSERT） |

### 业务含义

NCI 衡量新客的"转化紧迫度"——分数越高，表示该新客越需要及时跟进以促成二访/三访。适用于首次到店不久或到店次数较少的新客群体。

### 计算范围

仅对 `segment = "NEW"` 的会员计算。

### 算法概要

NCI 由两部分组成：欢迎建联分（Welcome）和转化召回分（Convert），合并为总分：

```
NCI_raw = raw_score_welcome + raw_score_convert
```

#### 欢迎建联分（Welcome）

针对首访后 3 天内的会员，鼓励立即触达：

```
welcome_new = clip(1 - t_v / welcome_window_days, 0, 1)    # 仅 visits_total ≤ 1 时生效
raw_score_welcome = w_welcome × welcome_new
```

#### 转化召回分（Convert）

由 4 个分项加权组成，并受活跃度抑制：

```
raw_score_convert = active_multiplier × (
    w_need × (Need × Salvage) + w_re × Recharge × touch_multiplier + w_value × Value × touch_multiplier
)
```

##### 分项 1：紧迫度（Need）

衡量距二访目标窗口的紧迫程度：

```
Need = clip((t_v - no_touch_days) / (2 × t2_target_days - no_touch_days), 0, 1)
```

- `no_touch_days`（默认 3 天）：免打扰窗口，首访后短期内不催促
- `t2_target_days`（默认 7 天）：二访目标天数

##### 分项 2：挽救系数（Salvage）

30-60 天线性衰减，超过 60 天视为流失：

```
Salvage = clip((salvage_end - t_a) / (salvage_end - salvage_start), 0, 1)
```

##### 分项 3：充值未回访压力（Recharge）

与 WBI 相同：`Recharge = decay(t_r, h_recharge)`

##### 分项 4：价值分（Value）

与 WBI 相同：`Value = w_spend × ln(1 + spend_180d / M0) + w_bal × ln(1 + sv_balance / B0)`

#### 活跃新客抑制

近期高频到店的新客不需要催促，降低其排名权重：

```
若 visits_14d ≥ active_new_visit_threshold_14d 且 t_v ≤ active_new_recency_days:
    active_multiplier = active_new_penalty (默认 0.2)
否则:
    active_multiplier = 1.0
```

#### 免打扰窗口乘数

价值分和充值分在进入免打扰窗口后才逐步生效：

```
touch_multiplier = clip(t_v / no_touch_days, 0, 1)
```

### Display Score 归一化

NCI 产出 3 个 Display Score：
- `display_score`：总分归一化（使用 EWMA 平滑）
- `display_score_welcome`：欢迎分归一化（不平滑）
- `display_score_convert`：转化分归一化（不平滑）

### 默认权重

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_welcome` | 1.0 | 欢迎建联权重 |
| `w_need` | 1.6 | 紧迫度权重 |
| `w_re` | 0.8 | 充值压力权重 |
| `w_value` | 1.0 | 价值权重 |


---

## DWS_SPENDING_POWER_INDEX — 消费力指数（SPI）

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_SPENDING_POWER_INDEX` |
| Python 类 | `SpendingPowerIndexTask`（`tasks/dws/index/spending_power_index_task.py`） |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → SpendingPowerIndexTask` |
| 目标表 | `dws.dws_member_spending_power_index` |
| 主键 | `site_id, member_id` |
| 指数类型 | `SPI` |
| 依赖任务 | `DWS_MEMBER_CONSUMPTION` |
| 更新策略 | 按门店全量刷新（先 DELETE WHERE site_id = %s，再 INSERT） |

### 业务含义

SPI 衡量会员在门店内的综合消费力层级——分数越高，表示该会员的消费能力和消费意愿越强。适用于客户分层、资源分配和精准营销场景，与 WBI/NCI 等运营指数协同使用。

SPI 不使用 `MemberIndexBaseTask` 的会员分群逻辑（NEW/OLD/STOP），所有在近 90 天内有消费或充值记录的会员均参与计算。

### 计算范围

所有在近 90 天内有消费订单（settle_type IN (1, 3)）或充值订单（settle_type = 5）的会员。无任何消费/充值数据的门店跳过计算，返回 `{'status': 'skipped', 'reason': 'no_data'}`。

### 数据来源

| 数据 | 来源表 | 提取方式 |
|------|--------|----------|
| 消费订单 | `dwd.dwd_settlement_head` | settle_type IN (1, 3)，近 90 天，聚合为会员级特征 |
| 充值订单 | `dwd.dwd_recharge_order` | settle_type = 5，近 90 天，聚合为会员级充值特征 |
| 算法参数 | `dws.cfg_index_parameters` | index_type = 'SPI' |

### 基础特征（SPIMemberFeatures）

从 DWD 层提取并计算的会员级消费特征：

| 字段 | 类型 | 含义 |
|------|------|------|
| `spend_30` | float | 近 30 天消费总额 |
| `spend_90` | float | 近 90 天消费总额 |
| `recharge_90` | float | 近 90 天充值总额 |
| `orders_30` | int | 近 30 天消费笔数 |
| `orders_90` | int | 近 90 天消费笔数 |
| `visit_days_30` | int | 近 30 天消费日数（按天去重） |
| `visit_days_90` | int | 近 90 天消费日数（按天去重） |
| `avg_ticket_90` | float | 90 天客单价：`spend_90 / max(orders_90, 1)` |
| `active_weeks_90` | int | 近 90 天有消费的自然周数（最多 13 周） |
| `daily_spend_ewma_90` | float | 近 90 天日消费 EWMA |

### 算法概要

SPI 由三个子分加权合成：

```
SPI_raw = w_L × Level + w_S × Speed + w_P × Stability
```

默认权重：`w_L = 0.60`、`w_S = 0.30`、`w_P = 0.10`。

#### 子分 1：消费水平（Level）

基于消费金额和客单价的 log1p 压缩加权，衡量客户消费金额层级：

```
L = w_s30 × ln(1 + spend_30 / M30)
  + w_s90 × ln(1 + spend_90 / M90)
  + w_ticket × ln(1 + avg_ticket_90 / T0)
  + w_r90 × ln(1 + recharge_90 / R90)
```

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_level_spend_30` (w_s30) | 0.30 | 近 30 天消费权重 |
| `w_level_spend_90` (w_s90) | 0.35 | 近 90 天消费权重 |
| `w_level_ticket_90` (w_ticket) | 0.20 | 客单价权重 |
| `w_level_recharge_90` (w_r90) | 0.15 | 充值权重 |
| `amount_base_spend_30` (M30) | 500 | 30 天消费压缩基数 |
| `amount_base_spend_90` (M90) | 1500 | 90 天消费压缩基数 |
| `amount_base_ticket_90` (T0) | 200 | 客单价压缩基数 |
| `amount_base_recharge_90` (R90) | 1000 | 充值压缩基数 |

当所有消费和充值金额均为 0 时，Level 子分为 0.0。

#### 子分 2：消费速度（Speed）

衡量近期消费推进速度与节奏变化，由三个速度指标加权合成：

```
S = w_abs × V_abs + w_rel × max(0, V_rel) + w_ewma × V_ewma
```

| 速度指标 | 公式 | 含义 |
|----------|------|------|
| V_abs（绝对速度） | `ln(1 + spend_30 / (max(visit_days_30, 1) × V0))` | 每消费日平均消费的对数压缩 |
| V_rel（相对速度） | `ln((v_30 + ε) / (v_90 + ε))`，其中 `v_30 = spend_30/30`，`v_90 = spend_90/90` | 近期消费速率相对长期的变化 |
| V_ewma（EWMA 速度） | `ln(1 + daily_spend_ewma_90 / E0)` | 日消费 EWMA 的对数压缩 |

设计要点：仅对加速（`V_rel > 0`）加分，不对减速直接扣分（通过 `max(0, V_rel)` 实现）。

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `w_speed_abs` | 0.50 | 绝对速度权重 |
| `w_speed_rel` | 0.30 | 相对速度权重 |
| `w_speed_ewma` | 0.20 | EWMA 速度权重 |
| `amount_base_speed_abs` (V0) | 100 | 绝对速度压缩基数 |
| `amount_base_ewma_90` (E0) | 50 | EWMA 速度压缩基数 |
| `speed_epsilon` (ε) | 1e-6 | 防除零小量 |

#### 子分 3：消费稳定性（Stability）

基于近 90 天周覆盖率，识别稳定高消费与偶发冲高：

```
P = active_weeks_90 / 13
```

近 90 天共约 13 个自然周，`active_weeks_90` 为其中有消费的周数。取值范围 [0, 1]。

当 `use_stability = 0` 时，Stability 子分权重视为 0，跳过稳定性计算。

### Display Score 归一化

SPI 产出 4 组 Display Score，各自独立归一化到 0-10 分：

| 展示分 | 对应 Raw Score | 分位历史 index_type |
|--------|---------------|---------------------|
| `display_score` | `raw_score`（SPI 总分） | `SPI` |
| `score_level_display` | `score_level_raw` | `SPI_LEVEL` |
| `score_speed_display` | `score_speed_raw` | `SPI_SPEED` |
| `score_stability_display` | `score_stability_raw` | `SPI_STABILITY` |

归一化流程复用 `BaseIndexTask.batch_normalize_to_display`：

```
Raw Score → [可选压缩] → Winsorize(P5, P95) → MinMax(0, 10) → [可选 EWMA 平滑]
```

### 金额压缩基数校准

SPI 支持金额压缩基数的自动校准机制：

1. 首次执行或参数缺失时，从门店近 90 天消费数据计算各基数的中位数作为建议值
2. 若 `cfg_index_parameters` 中已存在对应参数，优先使用配置表中的值
3. 实际使用的基数值会输出到日志，便于运营人员审查和手动调优

### 执行流程

```
1. 获取 site_id
2. load_index_parameters('SPI') 加载参数（缺失参数使用 DEFAULT_PARAMS）
3. _extract_spending_features：从 dwd_settlement_head 提取消费特征
4. _extract_recharge_features：从 dwd_recharge_order 提取充值特征
5. _compute_daily_spend_ewma：计算日消费 EWMA
6. _calibrate_amount_bases：校准金额压缩基数
7. 逐会员计算：compute_level → compute_speed → compute_stability → compute_spi_raw
8. batch_normalize_to_display：SPI 总分 + 三个子分各自独立归一化
9. DELETE FROM dws_member_spending_power_index WHERE site_id = %s
10. _save_spi_data：批量 INSERT
11. 保存分位点历史到 dws_index_percentile_history（index_type='SPI'）
```

### 默认权重

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `weight_level` | 0.60 | Level 子分在总分中的权重 |
| `weight_speed` | 0.30 | Speed 子分在总分中的权重 |
| `weight_stability` | 0.10 | Stability 子分在总分中的权重 |


---

## DWS_RELATION_INDEX — 关系指数（RS/OS/MS/ML）

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_RELATION_INDEX` |
| Python 类 | `RelationIndexTask`（`tasks/dws/index/relation_index_task.py`） |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → RelationIndexTask` |
| 目标表 | `dws.dws_member_assistant_relation_index` |
| 主键 | `site_id, member_id, assistant_id` |
| 指数类型 | RS / OS / MS / ML（单任务产出四个子指数） |
| 更新策略 | 按门店全量刷新（先 DELETE WHERE site_id = %s，再 INSERT） |

### 业务含义

关系指数以"会员-助教"关系对为粒度，一次执行产出 4 个子指数：

| 子指数 | 全称 | 含义 |
|--------|------|------|
| **RS** | Relation Strength | 关系强度——衡量会员与助教之间的服务关系紧密程度 |
| **OS** | Ownership Share | 归属份额——确定会员的"主责助教"归属关系 |
| **MS** | Momentum Score | 升温动量——衡量关系是在升温还是降温 |
| **ML** | Money Link | 付费关联——基于人工台账的付费归因强度 |

### 数据来源

| 数据 | 来源表 | 说明 |
|------|--------|------|
| 服务记录 | `dwd.dwd_assistant_service_log` | RS/MS 的核心数据源 |
| 助教维度 | `dwd.dim_assistant` | 通过 user_id 关联获取 assistant_id |
| 人工台账 | `dws.dws_ml_manual_order_alloc` | ML 的唯一数据源 |

### 会话合并

服务记录按 `(member_id, assistant_id)` 分组后，相邻服务间隔 ≤ `session_merge_hours`（默认 4 小时）的记录合并为一个会话（ServiceSession）。合并后保留：
- 会话起止时间、总时长
- 课程权重（激励课 `course_weight = incentive_weight`，默认 1.5；普通课 = 1.0）
- 是否包含激励课标记

### 子指数 1：RS（关系强度）

```
RS_raw = (w_f × F + w_d × D) × Gate(R)
```

| 分项 | 公式 | 含义 |
|------|------|------|
| F（频次） | `Σ course_weight × decay(days_ago, halflife_session)` | 加权会话频次 |
| D（时长） | `Σ √(duration_min / 60) × course_weight × decay(days_ago, halflife_session)` | 加权服务时长 |
| R（近期性） | `decay(days_since_last_session, halflife_last)` | 最近一次服务的时间衰减 |
| Gate | `R^gate_alpha` | 近期性门控，无近期服务则整体压低 |

默认参数：`halflife_session=14`, `halflife_last=10`, `w_f=1.0`, `w_d=0.7`, `gate_alpha=0.6`

### 子指数 2：OS（归属份额）

OS 不是独立计算的 Raw Score，而是基于 RS_raw 的份额分配：

1. 筛选 `rs_raw ≥ min_rs_raw_for_ownership`（默认 0.05）的关系对
2. 计算份额：`os_share = rs_raw / Σ rs_raw`（同一会员下所有合格助教）
3. 若 `Σ rs_raw < min_total_rs_raw`（默认 0.10），标记为 `UNASSIGNED`

归属标签判定规则：

| 标签 | 条件 |
|------|------|
| `MAIN` | 第一名份额 ≥ `ownership_main_threshold`（0.60）且与第二名差距 ≥ `ownership_gap_threshold`（0.15） |
| `COMANAGE` | 份额 ≥ `ownership_comanage_threshold`（0.35）但不满足 MAIN 条件 |
| `POOL` | 其余合格关系对 |
| `UNASSIGNED` | 总 RS 不足，无法形成稳定归属 |

### 子指数 3：MS（升温动量）

衡量关系是在升温（MS > 0）还是降温（MS ≈ 0）：

```
f_short = Σ course_weight × decay(days_ago, halflife_short)    # 短期半衰期 7 天
f_long  = Σ course_weight × decay(days_ago, halflife_long)     # 长期半衰期 30 天
MS_raw  = max(0, ln(f_short + ε) / (f_long + ε))
```

短期频次高于长期频次时 MS 为正，表示关系在升温。

### 子指数 4：ML（付费关联）

以人工台账窄表（`dws_ml_manual_order_alloc`）为唯一数据源：

```
ML_raw = Σ ln(1 + allocated_amount / amount_base) × decay(days_ago, halflife_recharge)
```

| 参数 | 默认值 | 含义 |
|------|--------|------|
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 天 | 充值半衰期 |

若某 `(member_id, assistant_id)` 对仅在台账中出现而无服务记录，会自动创建关系对。

### Display Score 归一化

RS、MS、ML 各自独立归一化到 0-10 分，分位历史按 `index_type` 隔离（分别记录 RS/MS/ML 的分位点）。OS 不做归一化，直接输出份额和标签。


---

## DWS_ML_MANUAL_IMPORT — ML 人工台账导入

| 属性 | 值 |
|------|-----|
| 任务代码 | `DWS_ML_MANUAL_IMPORT` |
| Python 类 | `MlManualImportTask`（`tasks/dws/index/ml_manual_import_task.py`） |
| 继承链 | `BaseTask → BaseDwsTask → BaseIndexTask → MlManualImportTask` |
| 目标表 | `dws.dws_ml_manual_order_source`（宽表）+ `dws.dws_ml_manual_order_alloc`（窄表） |
| 主键 | 宽表：`site_id, external_id, import_scope_key, row_no`；窄表：`site_id, external_id, assistant_id` |
| 指数类型 | `ML` |
| 更新策略 | 按 scope 先删后写（DAY 或 P30 批次覆盖） |

### 业务含义

ML 人工台账导入是一个工具型任务，用于将运营人员手工整理的订单-助教归因数据导入系统。导入后的数据作为 `DWS_RELATION_INDEX` 任务中 ML 子指数的唯一数据源。

该任务不依赖时间窗口，由调度器以工具任务方式直接触发。

### 文件路径解析

按以下优先级查找台账文件：

1. 配置项 `run.ml_manual_ledger_file`
2. 配置项 `run.ml_manual_file`
3. 环境变量 `ML_MANUAL_LEDGER_FILE`

### Excel 模板格式

台账文件为 `.xlsx` 格式，第一行为表头，第二行起为数据。模板列定义：

| 列名 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `site_id` | int | 否（默认取配置） | 门店 ID |
| `biz_date` | date | 是 | 业务日期 |
| `external_id` | string | 是 | 外部订单 ID（唯一标识） |
| `member_id` | int | 否 | 会员 ID |
| `pay_time` | datetime | 否（默认取 biz_date） | 支付时间 |
| `order_amount` | decimal | 否 | 订单金额 |
| `currency` | string | 否（默认 CNY） | 币种 |
| `assistant_id_1` ~ `assistant_id_5` | int | 否 | 助教 ID（最多 5 个） |
| `assistant_name_1` ~ `assistant_name_5` | string | 否 | 助教姓名 |
| `remark` | string | 否 | 备注 |

### 导入逻辑

#### 1. 读取与规范化

- 使用 `openpyxl` 读取 Excel，跳过空行
- 每行规范化：类型转换、缺省值填充、助教列表提取
- `external_id` 为必填，缺失则抛出 `ValueError`

#### 2. 助教分摊

同一订单支持最多 5 个助教归因，默认均分：

```
share_ratio = 1 / N
allocated_amount = order_amount × share_ratio
```

#### 3. 覆盖策略（ImportScope）

根据 `biz_date` 与当前日期的距离，采用不同的覆盖粒度：

| 条件 | scope_type | 覆盖范围 | 说明 |
|------|------------|----------|------|
| `today - biz_date ≤ 30 天` | `DAY` | 单日 | 按 `site_id + biz_date` 日覆盖 |
| `today - biz_date > 30 天` | `P30` | 30 天批次 | 以固定纪元（2026-01-01）为锚点，按 30 天分桶 |

P30 分桶算法：
```
bucket_index = (biz_date - EPOCH_ANCHOR).days // 30
bucket_start = EPOCH_ANCHOR + bucket_index × 30 天
bucket_end   = bucket_start + 29 天
```

#### 4. 写入流程

1. 按 scope 删除旧数据（宽表 + 窄表）
2. 插入宽表（`dws_ml_manual_order_source`）
3. Upsert 窄表（`dws_ml_manual_order_alloc`），冲突键为 `(site_id, external_id, assistant_id)`
4. 提交事务

#### 5. 导入批次号

格式：`MLM_<YYYYMMDDHHmmss>_<uuid8>`，如 `MLM_20260215143022_a1b2c3d4`

导入用户按优先级取：环境变量 `ETL_OPERATOR` → `USERNAME` → `USER` → `"system"`


---

## cfg_index_parameters 配置表

所有指数任务的算法参数统一存储在 `dws.cfg_index_parameters` 表中，支持按时间生效和历史追溯。

### 表结构

| 字段 | 类型 | 说明 |
|------|------|------|
| `param_id` | SERIAL PK | 自增主键 |
| `index_type` | VARCHAR(50) NOT NULL | 指数类型：`RS` / `OS` / `MS` / `ML` / `NCI` / `WBI` |
| `param_name` | VARCHAR(100) NOT NULL | 参数名称 |
| `param_value` | NUMERIC(14,6) NOT NULL | 参数值 |
| `description` | TEXT | 参数说明 |
| `effective_from` | DATE NOT NULL | 生效起始日期（默认当天） |
| `effective_to` | DATE | 生效截止日期（NULL = 永久有效） |
| `created_at` | TIMESTAMPTZ | 创建时间 |
| `updated_at` | TIMESTAMPTZ | 更新时间 |

唯一约束：`(index_type, param_name, effective_from)`

索引：`idx_cfg_index_params_type`（index_type）、`idx_cfg_index_params_effective`（effective_from, effective_to）

### 参数加载逻辑

```sql
SELECT param_name, param_value
FROM dws.cfg_index_parameters
WHERE index_type = %s
  AND effective_from <= CURRENT_DATE
  AND (effective_to IS NULL OR effective_to >= CURRENT_DATE)
ORDER BY effective_from DESC
```

同一 `param_name` 若有多条生效记录，取 `effective_from` 最新的一条（代码中通过 `seen` 集合去重）。

### 参数调优方式

新增一条 `effective_from` 为新日期的记录即可覆盖旧参数，旧记录自动失效（无需删除）。如需回滚，将新记录的 `effective_to` 设为过去日期即可。

### WBI 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口（天） |
| `visit_lookback_days` | 180 | 到店记录回溯窗口（天） |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式（0=无/1=log1p/2=asinh） |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `new_visit_threshold` | 2 | 新客到店次数阈值 |
| `new_days_threshold` | 30 | 新客首访天数阈值 |
| `recharge_recent_days` | 14 | 近期充值窗口（天） |
| `new_recharge_max_visits` | 10 | 充值新客最大到店次数 |
| `overdue_alpha` | 2.0 | 超期 CDF 幂指数 |
| `overdue_weight_halflife_days` | 30 | 超期加权 CDF 间隔半衰期（天） |
| `overdue_weight_blend_min_samples` | 8 | 加权 CDF 最小样本数 |
| `h_recharge` | 7 | 充值衰减半衰期（天） |
| `amount_base_M0` | 300 | 消费金额压缩基准 |
| `balance_base_B0` | 500 | 余额压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 1.0 | 价值分中消费/余额权重 |
| `w_over` / `w_drop` / `w_re` / `w_value` | 2.0 / 1.0 / 0.4 / 1.2 | 四分项权重 |
| `recency_hard_floor_days` | 14 | 近期硬抑制天数 |
| `recency_gate_days` | 14 | Sigmoid 门控中心（天） |
| `recency_gate_slope_days` | 3 | Sigmoid 门控斜率（天） |
| `enable_stop_high_balance_exception` | 0 | 是否启用 STOP 高余额例外 |
| `high_balance_threshold` | 1000 | 高余额阈值（元） |

### NCI 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days_recency` | 60 | 近期活跃判定窗口（天） |
| `visit_lookback_days` | 180 | 到店记录回溯窗口（天） |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 0 | 压缩模式 |
| `use_smoothing` | 1 | 是否启用 EWMA 分位平滑 |
| `ewma_alpha` | 0.2 | EWMA 平滑系数 |
| `no_touch_days_new` | 3 | 免打扰窗口（天） |
| `t2_target_days` | 7 | 二访目标天数 |
| `salvage_start` / `salvage_end` | 30 / 60 | 挽救系数衰减区间（天） |
| `welcome_window_days` | 3 | 欢迎建联窗口（天） |
| `active_new_visit_threshold_14d` | 2 | 活跃新客 14 天到店阈值 |
| `active_new_recency_days` | 7 | 活跃新客近期天数 |
| `active_new_penalty` | 0.2 | 活跃新客抑制系数 |
| `h_recharge` | 7 | 充值衰减半衰期（天） |
| `amount_base_M0` / `balance_base_B0` | 300 / 500 | 价值分压缩基准 |
| `value_w_spend` / `value_w_bal` | 1.0 / 0.8 | 价值分权重 |
| `w_welcome` / `w_need` / `w_re` / `w_value` | 1.0 / 1.6 / 0.8 / 1.0 | 分项权重 |

### RS 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 90 | 服务行为回溯窗口（天） |
| `session_merge_hours` | 4 | 会话合并阈值（小时） |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_session` | 14 | 会话半衰期（天） |
| `halflife_last` | 10 | 最近服务半衰期（天） |
| `weight_f` / `weight_d` | 1.0 / 0.7 | 频次/时长权重 |
| `gate_alpha` | 0.6 | 近期性门控指数 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式（默认 log1p） |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |

### OS 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `min_rs_raw_for_ownership` | 0.05 | 参与归属计算的最小 RS_raw |
| `min_total_rs_raw` | 0.10 | 形成稳定归属的最小 sum_rs |
| `ownership_main_threshold` | 0.60 | 主责份额阈值 |
| `ownership_comanage_threshold` | 0.35 | 共管份额阈值 |
| `ownership_gap_threshold` | 0.15 | 主责与次席差距阈值 |
| `eps` | 0.000001 | 数值稳定项 |

### MS 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 90 | 服务行为回溯窗口（天） |
| `session_merge_hours` | 4 | 会话合并阈值（小时） |
| `incentive_weight` | 1.5 | 激励课权重 |
| `halflife_short` / `halflife_long` | 7 / 30 | 短期/长期半衰期（天） |
| `eps` | 0.000001 | 数值稳定项 |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式（默认 log1p） |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |

### ML 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `lookback_days` | 90 | 充值行为回溯窗口（天） |
| `amount_base` | 500 | 金额压缩基准 |
| `halflife_recharge` | 21 | 充值半衰期（天） |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式（默认 log1p） |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 平滑 |

### SPI 参数清单

| 参数名 | 默认值 | 说明 |
|--------|--------|------|
| `spend_window_short_days` | 30 | 短期消费窗口（天） |
| `spend_window_long_days` | 90 | 长期消费窗口（天） |
| `ewma_alpha_daily_spend` | 0.3 | 日消费 EWMA 平滑系数 |
| `amount_base_spend_30` | 500 | 30 天消费压缩基数 |
| `amount_base_spend_90` | 1500 | 90 天消费压缩基数 |
| `amount_base_ticket_90` | 200 | 客单价压缩基数 |
| `amount_base_recharge_90` | 1000 | 充值压缩基数 |
| `amount_base_speed_abs` | 100 | 绝对速度压缩基数 |
| `amount_base_ewma_90` | 50 | EWMA 速度压缩基数 |
| `w_level_spend_30` / `w_level_spend_90` | 0.30 / 0.35 | Level 子分中消费权重 |
| `w_level_ticket_90` / `w_level_recharge_90` | 0.20 / 0.15 | Level 子分中客单/充值权重 |
| `w_speed_abs` / `w_speed_rel` / `w_speed_ewma` | 0.50 / 0.30 / 0.20 | Speed 子分三项权重 |
| `weight_level` / `weight_speed` / `weight_stability` | 0.60 / 0.30 / 0.10 | SPI 总分三子分权重 |
| `stability_window_days` | 90 | 稳定性计算窗口（天） |
| `use_stability` | 1 | 是否启用稳定性子分（0=跳过） |
| `percentile_lower` / `percentile_upper` | 5 / 95 | 归一化分位点 |
| `compression_mode` | 1 | 压缩模式（默认 log1p） |
| `use_smoothing` / `ewma_alpha` | 1 / 0.2 | EWMA 分位平滑 |
| `speed_epsilon` | 1e-6 | 速度计算防除零小量 |

> 种子数据脚本：`db/etl_feiqiu/seeds/seed_index_parameters.sql`
> DDL 定义：`docs/database/ddl/etl_feiqiu__dws.sql`
