新功能:个性化推荐算法

This commit is contained in:
吕新雨
2026-02-02 16:47:37 +08:00
parent 936094211b
commit 6dc4e2b943
119 changed files with 7427 additions and 357 deletions

View File

@@ -0,0 +1,264 @@
# Rerank & Freqcap重排 / 去重 / 频控Plan
> 对应规范:`spec_kit/Personalized Reco/modules/rerank-freqcap/spec.md`
>
> 规则来源(必须对齐):
>
> - `设计说明文档/個性化推薦算法規則.md`Feed MMR λ=0.7Push/Widget 冷却口径)
> - `spec_kit/Personalized Reco/overview.md`(模块边界:本模块在 Soft Scoring 之后执行)
---
## 1. 目标与交付物
### 1.1 目标
- 将 Soft Scoring 后的候选集变为**可下发的最终排序**(长度 ≤ k
- 实现 V1 最小集合:
- **去重**:排除 `already_recommended_ids touched_or_viewed_ids`
- **Feed 序列多样性**MMR 重排(离散特征版)
- **Push/Widget 频控与冷却**:至少保证“同句不重复”;作者/模板按输入能力做增强
- 输出稳定的 `meta` 统计字段,用于定位 served_k 不足的原因dedup/freqcap 导致清空等)。
### 1.2 交付物
- `modules/rerank-freqcap/plan.md`:本技术计划(本文件)。
- 代码实现tasks 阶段落地)建议位置:
- `server/app/features/personalized_reco/rerank_freqcap/`
- 包含:
- 纯函数 `rerank_and_freqcap(...) -> RerankResult`
- `RerankConfig` 与默认参数(按 scene
- `Sim/Tag` 构造工具函数Feed MMR
- 单元测试tasks 阶段落地):
- 去重正确性
- Feed MMR 的 Top1 + 多样性选择
- Push/Widget 冷却规则(在给定历史集合输入下)
---
## 2. 模块职责边界V1 约定)
### 2.1 本模块负责
- **从 scored_candidates 中做过滤/重排**
- Dedup按历史集合过滤
- Freqcap按冷却维度句子/作者/模板)做“硬过滤或强约束”
- FeedMMR 生成序列(保证多样性)
- 输出 `ranked_items``meta`(候选规模、过滤数量、缺失输入统计等)。
### 2.2 不在本模块实现
- **不计算 Soft Scoring 分数**:只消费 `final_score`(或等价的 score
- **不做 Hard Filter**Hard Filter 发生在更早阶段,本模块只处理已通过 Hard Filter 的候选。
- **不维护服务端长期历史**V1冷却窗口“X 天”由客户端在请求时传入对应的“最近窗口内集合”,或由未来服务端侧补齐。
> 说明V1 冷却窗口语义):本模块以“输入集合代表冷却窗口内的历史”为准。`cooldown_*_days` 作为配置与可观测字段保留,便于未来接入服务端历史后真正按时间计算。
---
## 3. 输入/输出与数据结构V1
### 3.1 输入
- `scene`: `feed | push | widget`
- `scored_candidates`: `List[ScoredCandidate]`,至少包含:
- `content_id: int`
- `final_score: float`(或 `score`
- `author_id: str | None`
- `template_id: str | None`
- `content_profile`(用于 feed 标签stage/need/context 等;缺失时可退化)
- `already_recommended_ids`: `List[str|int]`
- `touched_or_viewed_ids`: `List[str|int]`
- 可选历史若客户端暂不传V1 作为增强项):
- `recent_author_ids: List[str] | None`
- `recent_template_ids: List[str] | None`
- `k`: 目标条数feed 默认 30push/widget 默认 1
- `config`
- `mmr_lambda`Feed 默认 0.7
- `cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(按场景默认)
### 3.2 输出
- `ranked_items`: `List[ScoredCandidate]`(长度 ≤ k
- `meta`V1 必须字段):
- `candidate_pool_size_after_dedup: int`
- `candidate_pool_size_after_freqcap: int`
- `freqcap_filtered_counts: { sentence?: int, author?: int, template?: int }`(可选但建议)
- `missing_history_fields: List[str]`(例如 `recent_author_ids` 未提供)
---
## 4. 关键技术决策V1
### 4.1 ID 归一化(避免 str/int 混用导致漏过滤)
由于输入历史集合可能是 `str|int`V1 统一做:
- 尽量将 `content_id` 归一化为 `int`
- 无法转换的值忽略并记录 debug不影响主流程
### 4.2 Push/Widget 的频控策略:先保证“同句不重复”,再增强作者/模板
V1 选择“安全且可落地”的策略:
- **句子冷却(必做,硬过滤)**
-`content_id` 出现在历史集合中,则直接过滤
- **作者/模板冷却(增强项)**
-`recent_author_ids/recent_template_ids` 有输入,则对命中者执行硬过滤
- 若无输入,则跳过该维度,但在 `meta.missing_history_fields` 记录缺失,便于可观测
> 说明:规范允许作者/模板作为硬频控或强降权。V1 采用“有输入就硬过滤、无输入就跳过”的方式,避免伪实现与误杀。
### 4.3 Feed 的多样性MMR离散特征版
V1 实现 MMR 的离散相似度(不依赖 embedding
\[
MMR(c)=\lambda\cdot Rel(c) - (1-\lambda)\cdot \max_{s\in S} Sim(c,s)
\]
- `Rel(c)`:使用 `final_score`
- `Sim(c,s)`
- `content_id` 相同:`Sim=1`
- `template_id` 相同且非空:`Sim += 0.6`
- `author_id` 相同且非空:`Sim += 0.3`
- 标签重合Jaccard`Sim += 0.1 * Jaccard(tags_c, tags_s)`
- clamp 到 `[0,1]`
标签集合 `tags_*` 的 V1 落地定义(必须可算、且对缺字段鲁棒):
- `stage:<stage>`(例如 `stage:general/expecting/parenting/unknown`
- `need:<key>`:从 `need_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
- `context:<key>`:从 `context_suitability` 中取 **最大值的 key** 作为代表标签(若为空则跳过)
> 说明:内容画像是 suitability0/0.5/1结构V1 取 argmax 能保证标签集合小且稳定,便于测试。后续可扩展为“取所有 ≥0.5 的 key”以增强多样性。
---
## 5. 具体算法流程V1
### 5.1 Dedup必做三场景共用
输入:
- `seen_ids = already_recommended_ids touched_or_viewed_ids`
处理:
- 过滤 `content_id ∈ seen_ids` 的候选
输出:
- `candidate_pool_size_after_dedup = len(filtered_candidates)`
### 5.2 FreqcapPush/Widget 必做Feed 可选)
V1 频控实现顺序(先句子,再作者/模板):
1. 句子冷却:过滤 `content_id ∈ seen_ids`
2. 作者冷却(若提供 `recent_author_ids`):过滤 `author_id ∈ recent_author_ids`
3. 模板冷却(若提供 `recent_template_ids`):过滤 `template_id ∈ recent_template_ids`
输出:
- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`(按维度统计被过滤数量)
### 5.3 FeedMMR 序列重排(建议实现)
步骤:
- Top1直接取 `final_score` 最高者
- 对后续位置 t=2..k
- 对每个未选候选 c 计算 `MMR(c)`
- 选择 `MMR` 最大者加入序列
性能与实现约束V1
- 候选数 N例如 200~500朴素 \(O(kN^2)\) 仍可能偏大V1 可采用:
- 先截断到 `top_n_for_mmr`(例如 200再做 MMR
- 或缓存 `Sim(c,s)` 的最大值并增量更新实现复杂度更高V1 可不做)
### 5.4 Push/Widget选 TopK
在 dedup+freqcap 后:
-`final_score` 降序取前 k 条作为 `ranked_items`
---
## 6. 默认参数V1 建议)
### 6.1 Feed
- `mmr_lambda = 0.7`
- `top_n_for_mmr = 200`(避免候选过大导致重排过慢)
### 6.2 Push冷却窗口口径来自算法规则的工程默认
- `cooldown_sentence_days = 14`(同句 14 天不重复)
- `cooldown_author_days = 7`(同作者 7 天不重复,需输入 `recent_author_ids` 才能执行)
- `cooldown_template_days = 7`(同模板 7 天不重复,需输入 `recent_template_ids` 才能执行)
### 6.3 Widget
- `cooldown_sentence_days = 7`
- `cooldown_author_days = 7`
- `cooldown_template_days = 7`
> 说明V1 冷却天数在本模块主要用于配置与可观测字段;真正“按天”判断需要历史带时间戳或服务端持久化,后续迭代补齐。
---
## 7. 可观测与 metaV1
本模块建议输出(供 `observability` 子模块汇总):
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`sentence/author/template
- `missing_history_fields`
- 例如客户端未提供 `recent_author_ids` → 记录 `author`
- 未提供 `recent_template_ids` → 记录 `template`
> 目标:当 served_k 过少时,能快速判断是 dedup/freqcap 导致,还是上游候选不足。
---
## 8. 测试计划V1
### 8.1 单元测试(纯函数)
- Dedup
- 输入历史包含某些 `content_id`,输出必须不包含这些 id
- `str/int` 混用能正确归一化
- Freqcap
- 仅提供 `content_id` 历史时:句子冷却生效
- 提供 `recent_author_ids` 时:作者维度过滤生效;未提供时 `meta.missing_history_fields` 正确
- 提供 `recent_template_ids` 时:模板维度过滤生效;未提供时 `meta.missing_history_fields` 正确
- Feed MMR
- Top1 恒等于最高分
- 后续序列在候选足够时避免连续同作者/同模板(可用统计阈值断言)
- `tags` 缺失时仍能稳定运行(只使用可得字段)
### 8.2 最小集成验证(与 reco-engine 串联时)
- 输入一批 scored_candidates + 历史集合:
- Feed输出长度 ≤ k且 meta 规模统计正确
- Push/Widget在历史命中时能过滤掉重复句子
---
## 9. 风险与后续演进
### 9.1 已知风险
- V1 冷却窗口“按天”无法严格执行:因为历史输入缺少时间戳或服务端持久化。本模块已通过“输入集合代表窗口内历史”做可落地实现,但需要在产品/客户端侧保证窗口裁剪正确。
- Feed MMR 的性能候选过大时重排可能变慢V1 用 `top_n_for_mmr` 截断兜底。
### 9.2 V1.1+ 演进方向
- 服务端侧持久化冷却历史(按用户维度记录 sentence/author/template 的最近触达时间),真正按 `cooldown_*_days` 判定。
- 将“作者/模板冷却”从硬过滤升级为“强降权 + 允许破例”,并在 meta 中记录“破例原因”(候选不足等)。
-`P_repeat/P_fatigue``rerank-freqcap` 产出并注入 `scoring``external_terms`,实现更平滑的序列控制(而非一刀切过滤)。

View File

@@ -0,0 +1,172 @@
# Rerank & Freqcap重排 / 去重 / 频控Tasks
> 对应计划:`spec_kit/Personalized Reco/modules/rerank-freqcap/plan.md`
>
> 本清单执行原则:
>
> - 本模块只做 **Dedup / Freqcap / Feed MMR 重排**,不做 Soft Scoring 与 Hard Filter。
> - V1 冷却窗口以“输入集合代表窗口内历史”为准(后续接入服务端历史再按天计算)。
---
## 0. 任务标记规则
- 用勾选框标记执行状态:
- `[ ]` 未开始
- `[x]` 已完成
- 每个任务都要求可独立验收(有明确产出/可运行的检查方式)。
---
## 1. 文档对齐(先把口径写死,避免实现漂移)
- [x] 1.1 校对 `modules/rerank-freqcap/spec.md``modules/rerank-freqcap/plan.md` 一致性
- **检查点**
- 输入:`scene/scored_candidates/history/k/config` 字段与命名一致
- 输出:`ranked_items``meta` 字段集合一致
- 去重键:`sentence_key=content_id``author_key``template_key` 口径一致
- FeedMMR λ=0.7 与 Sim 规则一致
- **验收**两份文档不存在冲突描述且“V1 冷却窗口语义”写清楚(集合代表窗口内历史)。
- [x] 1.2 在 `plan.md` 中补充/固定“标签构造策略”与“候选截断策略”(若后续要改再更新)
- **变更点**
- 明确 `tags` 的 V1 定义:`stage + need_argmax + context_argmax`
- 明确 `top_n_for_mmr` 默认值与作用(性能兜底)
- **验收**:实现时不会出现“标签到底取哪些 key”的二义性。
---
## 2. 目录与骨架(与推荐子模块同级)
- [x] 2.1 新建目录 `server/app/features/personalized_reco/rerank_freqcap/`
- **包含**
- `__init__.py`
- `types.py``ScoredCandidate``RerankConfig``RerankMeta``RerankResult`
- `defaults.py`(按 scene 的默认参数λ、cooldown_*、top_n_for_mmr
- `utils.py`ID 归一化、Jaccard、tag 构造等)
- `rerank.py`(主入口 `rerank_and_freqcap`
- **验收**:可通过 `app.features.personalized_reco.rerank_freqcap.*` 正常 import。
---
## 3. 类型与接口(稳定契约,便于 reco-engine 调用)
- [x] 3.1 定义 `ScoredCandidate`(最小字段集合)
- **必须字段**
- `content_id: int`
- `final_score: float`
- **建议字段**(用于多样性/频控):
- `author_id: str | None`
- `template_id: str | None`
- `content_profile`(至少能取到 stage/need_suitability/context_suitability缺失时需降级
- **验收**:能承载 MMR 相似度计算所需数据;缺失字段不会导致异常。
- [x] 3.2 定义 `RerankConfig`(可调参)
- **字段**
- Feed`mmr_lambda`(默认 0.7)、`top_n_for_mmr`(默认 200
- Push/Widget`cooldown_sentence_days/cooldown_author_days/cooldown_template_days`(用于配置与可观测)
- **验收**:能从 scene 推导默认 config或由调用方传入覆盖
- [x] 3.3 定义 `RerankMeta``RerankResult`
- **meta 必须字段**
- `candidate_pool_size_after_dedup`
- `candidate_pool_size_after_freqcap`
- `missing_history_fields`
- **建议字段**
- `freqcap_filtered_counts`sentence/author/template
- **验收**:字段集合固定;任何输入都能产出 meta包括候选为空
---
## 4. 核心算法实现V1 最小集合)
- [x] 4.1 实现历史 ID 归一化(避免 str/int 混用漏过滤)
- **规则**
- `already_recommended_ids` / `touched_or_viewed_ids` 尽量转为 `int` 集合
- 转换失败的值忽略并记录 debug
- **验收**:单测覆盖 `["1", 2, "bad"]` 等混合输入,过滤结果正确且稳定。
- [x] 4.2 实现 Dedup必做
- **规则**:过滤 `content_id ∈ seen_ids` 的候选
- **产出**`meta.candidate_pool_size_after_dedup`
- **验收**:输出不包含历史出现过的 `content_id`
- [x] 4.3 实现 FreqcapPush/Widget 必做Feed 可选)
- **V1 策略**
- 句子维度(必做):同句硬过滤(使用 dedup 的 seen_ids 即可)
- 作者/模板维度(增强项):
- 若提供 `recent_author_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `author`
- 若提供 `recent_template_ids`:命中则硬过滤;否则在 `missing_history_fields` 记录 `template`
- **产出**
- `candidate_pool_size_after_freqcap`
- `freqcap_filtered_counts`(建议)
- **验收**:在有/无 `recent_*_ids` 输入时行为一致且可解释。
- [x] 4.4 实现 Feedtag 构造与相似度 `Sim`
- **tag 规则V1 写死)**
- `stage:<stage>`
- `need:<argmax_key>`(从 `need_suitability` 取最大值 key为空则跳过
- `context:<argmax_key>`(从 `context_suitability` 取最大值 key为空则跳过
- **Jaccard**`|A∩B|/|AB|`,空集合时返回 0
- **Sim 累加规则**
- 同 content_id → 1
- template_id 相同且非空 → +0.6
- author_id 相同且非空 → +0.3
- +0.1 * Jaccard(tags)
- clamp 到 `[0,1]`
- **验收**:单测覆盖缺失字段(无 author/template/tags时仍能算出稳定 Sim。
- [x] 4.5 实现 FeedMMR 选序列
- **规则**
- Top1`final_score` 最大
- 后续:按 `MMR(c)=λ*Rel(c)-(1-λ)*maxSim` 选择
- `Rel=final_score`
- **性能兜底**:先截断候选到 `top_n_for_mmr` 再做 MMR
- **验收**
- Top1 恒等于最高分
- 候选足够时,序列不出现大量同作者/同模板紧邻重复(可用阈值断言)
- [x] 4.6 实现 Push/Widget最终 TopK
- **规则**dedup+freqcap 后按 `final_score` 降序取前 k 条
- **验收**:输出长度 ≤ k且分数单调不增允许相等
- [x] 4.7 实现主入口 `rerank_and_freqcap(...) -> RerankResult`
- **规则**
- 三场景共用 dedup
- FeedMMRPush/WidgetTopK
- 必须输出 meta即使 ranked_items 为空)
- **验收**:任何输入(含空候选)不抛异常,并输出稳定结构。
---
## 5. 单元测试pytest纯函数为主
- [x] 5.1 新建测试文件 `server/tests/test_rerank_freqcap.py`
- **用例覆盖**
- dedup历史集合过滤正确含 str/int 混用)
- freqcap有/无 recent_author/template 的分支与 meta 缺失标记
- feed mmrTop1=最高分;后续避免同作者/模板紧邻(构造数据断言)
- push/widgetTopK 输出正确
- **验收**`pytest -q tests/test_rerank_freqcap.py` 通过。
---
## 6. 最终自检清单(合入前)
- [x] 6.1 文档一致性检查
- **验收**`spec.md` / `plan.md` / 实现接口签名三者一致(尤其 meta 字段与默认参数)。
- [x] 6.2 回归检查(不影响已实施模块)
- **验收**:不修改 `content-repository``scoring` 的既有逻辑;仅新增 `rerank-freqcap` 模块与测试。
- [x] 6.3 全量测试通过
- **命令**(在 `server/`
- `pytest -q`
- **验收**:所有用例通过。
- [x] 6.4 全部完成后更新大规范 `overview.md`
- **变更点**
-`modules/rerank-freqcap/` 标记为“已实施”
- 增加一条变更记录(日期 + 交付物plan/tasks/代码/测试)
- **验收**`spec_kit/Personalized Reco/overview.md` 中模块状态与交付记录准确。