209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, Iterable, Optional
|
||
|
||
from app.features.personalized_reco.rerank_freqcap.defaults import get_default_config
|
||
from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene
|
||
from app.features.personalized_reco.rerank_freqcap.utils import as_finite_float, build_tags, clamp, jaccard, normalize_int_id_set
|
||
|
||
|
||
def _sort_by_score_desc(cands: list[ScoredCandidate]) -> list[ScoredCandidate]:
|
||
return sorted(cands, key=lambda x: as_finite_float(x.final_score, default=float("-inf")), reverse=True)
|
||
|
||
|
||
def _dedup_by_seen_ids(
|
||
cands: list[ScoredCandidate],
|
||
*,
|
||
seen_ids: set[int],
|
||
) -> tuple[list[ScoredCandidate], int]:
|
||
kept: list[ScoredCandidate] = []
|
||
removed = 0
|
||
for c in cands:
|
||
if int(c.content_id) in seen_ids:
|
||
removed += 1
|
||
continue
|
||
kept.append(c)
|
||
return kept, removed
|
||
|
||
|
||
def _apply_author_template_freqcap(
|
||
cands: list[ScoredCandidate],
|
||
*,
|
||
recent_author_ids: Optional[Iterable[str]],
|
||
recent_template_ids: Optional[Iterable[str]],
|
||
) -> tuple[list[ScoredCandidate], dict[str, int], list[str]]:
|
||
"""
|
||
V1 策略:
|
||
- 若 recent_*_ids 未提供(None),不执行该维度过滤,但在 meta 记录缺失
|
||
- 若提供,则执行硬过滤
|
||
"""
|
||
|
||
filtered_counts: dict[str, int] = {"author": 0, "template": 0}
|
||
missing: list[str] = []
|
||
|
||
author_set: set[str] | None
|
||
if recent_author_ids is None:
|
||
author_set = None
|
||
missing.append("author")
|
||
else:
|
||
author_set = set([a for a in recent_author_ids if a is not None and str(a).strip() != ""])
|
||
|
||
template_set: set[str] | None
|
||
if recent_template_ids is None:
|
||
template_set = None
|
||
missing.append("template")
|
||
else:
|
||
template_set = set([t for t in recent_template_ids if t is not None and str(t).strip() != ""])
|
||
|
||
out: list[ScoredCandidate] = []
|
||
for c in cands:
|
||
if author_set is not None and c.author_id and c.author_id in author_set:
|
||
filtered_counts["author"] += 1
|
||
continue
|
||
if template_set is not None and c.template_id and c.template_id in template_set:
|
||
filtered_counts["template"] += 1
|
||
continue
|
||
out.append(c)
|
||
|
||
# 只返回真正生效的维度计数(避免 meta 噪音)
|
||
effective_counts: dict[str, int] = {}
|
||
if author_set is not None:
|
||
effective_counts["author"] = int(filtered_counts["author"])
|
||
if template_set is not None:
|
||
effective_counts["template"] = int(filtered_counts["template"])
|
||
|
||
missing_sorted = sorted(set(missing))
|
||
return out, effective_counts, missing_sorted
|
||
|
||
|
||
def _sim(a: ScoredCandidate, b: ScoredCandidate, *, tags_a: set[str], tags_b: set[str]) -> float:
|
||
# 离散特征版(V1 推荐),对齐 plan.md
|
||
if int(a.content_id) == int(b.content_id):
|
||
return 1.0
|
||
|
||
sim = 0.0
|
||
if a.template_id and b.template_id and a.template_id == b.template_id:
|
||
sim += 0.6
|
||
if a.author_id and b.author_id and a.author_id == b.author_id:
|
||
sim += 0.3
|
||
|
||
sim += 0.1 * jaccard(tags_a, tags_b)
|
||
return clamp(sim, 0.0, 1.0)
|
||
|
||
|
||
def _mmr_rerank(
|
||
*,
|
||
candidates: list[ScoredCandidate],
|
||
k: int,
|
||
lam: float,
|
||
) -> list[ScoredCandidate]:
|
||
if k <= 0:
|
||
return []
|
||
|
||
if not candidates:
|
||
return []
|
||
|
||
lam_f = clamp(as_finite_float(lam, default=0.7), 0.0, 1.0)
|
||
|
||
# 预计算 tags,避免重复构造
|
||
tags_map: dict[int, set[str]] = {}
|
||
for c in candidates:
|
||
tags_map[int(c.content_id)] = build_tags(c)
|
||
|
||
remaining = _sort_by_score_desc(list(candidates))
|
||
selected: list[ScoredCandidate] = []
|
||
|
||
# Top1:最高分
|
||
selected.append(remaining.pop(0))
|
||
|
||
while remaining and len(selected) < k:
|
||
best_idx = 0
|
||
best_val = float("-inf")
|
||
|
||
for idx, c in enumerate(remaining):
|
||
rel = as_finite_float(c.final_score, default=float("-inf"))
|
||
|
||
tags_c = tags_map.get(int(c.content_id), set())
|
||
max_sim = 0.0
|
||
for s in selected:
|
||
tags_s = tags_map.get(int(s.content_id), set())
|
||
max_sim = max(max_sim, _sim(c, s, tags_a=tags_c, tags_b=tags_s))
|
||
|
||
val = lam_f * float(rel) - (1.0 - lam_f) * float(max_sim)
|
||
if val > best_val:
|
||
best_val = val
|
||
best_idx = idx
|
||
|
||
selected.append(remaining.pop(best_idx))
|
||
|
||
return selected
|
||
|
||
|
||
def rerank_and_freqcap(
|
||
*,
|
||
scene: Scene,
|
||
scored_candidates: list[ScoredCandidate],
|
||
already_recommended_ids: list[Any],
|
||
touched_or_viewed_ids: list[Any],
|
||
k: int,
|
||
config: Optional[RerankConfig] = None,
|
||
recent_author_ids: Optional[list[str]] = None,
|
||
recent_template_ids: Optional[list[str]] = None,
|
||
) -> RerankResult:
|
||
"""
|
||
主入口:对 scored_candidates 做去重/频控/重排,输出最终可下发序列。
|
||
|
||
V1 约定:
|
||
- 冷却窗口“按天”由调用方保证输入集合已经裁剪到窗口内,本模块以“集合代表窗口内历史”为准
|
||
- Feed 默认只做 dedup + MMR;Push/Widget 做 dedup + freqcap + TopK
|
||
"""
|
||
|
||
cfg = config or get_default_config(scene)
|
||
|
||
# seen_ids = already_recommended_ids ∪ touched_or_viewed_ids
|
||
seen_ids = normalize_int_id_set(list(already_recommended_ids) + list(touched_or_viewed_ids))
|
||
|
||
# 先按分数降序,保证 Top1 与 TopK 一致
|
||
base_sorted = _sort_by_score_desc(list(scored_candidates))
|
||
|
||
after_dedup, removed_sentence = _dedup_by_seen_ids(base_sorted, seen_ids=seen_ids)
|
||
candidate_pool_size_after_dedup = len(after_dedup)
|
||
|
||
missing_history_fields: list[str] = []
|
||
freqcap_counts: dict[str, int] = {"sentence": int(removed_sentence)}
|
||
|
||
after_freqcap = after_dedup
|
||
|
||
# Push/Widget:作者/模板冷却(增强项)
|
||
if scene in {"push", "widget"}:
|
||
after_freqcap, dim_counts, missing = _apply_author_template_freqcap(
|
||
after_freqcap,
|
||
recent_author_ids=recent_author_ids,
|
||
recent_template_ids=recent_template_ids,
|
||
)
|
||
missing_history_fields = missing
|
||
freqcap_counts.update(dim_counts)
|
||
else:
|
||
# Feed:不强制作者/模板冷却(V1 可选,这里默认跳过)
|
||
missing_history_fields = []
|
||
|
||
candidate_pool_size_after_freqcap = len(after_freqcap)
|
||
|
||
ranked: list[ScoredCandidate]
|
||
if scene == "feed":
|
||
# MMR 前截断,避免性能问题
|
||
top_n = int(cfg.top_n_for_mmr) if int(cfg.top_n_for_mmr) > 0 else len(after_freqcap)
|
||
mmr_pool = after_freqcap[:top_n]
|
||
ranked = _mmr_rerank(candidates=mmr_pool, k=int(k), lam=cfg.mmr_lambda)
|
||
else:
|
||
ranked = after_freqcap[: max(0, int(k))]
|
||
|
||
meta = RerankMeta(
|
||
candidate_pool_size_after_dedup=int(candidate_pool_size_after_dedup),
|
||
candidate_pool_size_after_freqcap=int(candidate_pool_size_after_freqcap),
|
||
missing_history_fields=missing_history_fields,
|
||
freqcap_filtered_counts=freqcap_counts,
|
||
)
|
||
return RerankResult(ranked_items=ranked, meta=meta)
|
||
|