新功能:个性化推荐算法
This commit is contained in:
22
server/app/features/personalized_reco/scoring/__init__.py
Normal file
22
server/app/features/personalized_reco/scoring/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
个性化推荐|Scoring 子模块(软打分与惩罚项)
|
||||
|
||||
说明:
|
||||
- 本模块只做软打分与本模块定义的惩罚项(P_uncertainty、Widget 情绪软降权)。
|
||||
- Hard Filter / 频控重排 / 新鲜度等由其他模块产出,通过入参注入(缺省按 0)。
|
||||
"""
|
||||
|
||||
from .defaults import get_default_config
|
||||
from .score import score_content
|
||||
from .types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult
|
||||
|
||||
__all__ = [
|
||||
"ExternalTerms",
|
||||
"Scene",
|
||||
"ScoreBreakdown",
|
||||
"ScoreConfig",
|
||||
"ScoreResult",
|
||||
"get_default_config",
|
||||
"score_content",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
44
server/app/features/personalized_reco/scoring/defaults.py
Normal file
44
server/app/features/personalized_reco/scoring/defaults.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.features.personalized_reco.scoring.types import Scene, ScoreConfig
|
||||
|
||||
|
||||
_DEFAULTS: dict[Scene, ScoreConfig] = {
|
||||
# 来源:设计说明文档/個性化推薦算法規則.md(V1 建议权重)
|
||||
"feed": ScoreConfig(
|
||||
w_need=0.35,
|
||||
w_emotion=0.20,
|
||||
w_stage=0.15,
|
||||
w_context=0.30,
|
||||
# Feed 默认不启用不确定性惩罚(可按需开启)
|
||||
enable_uncertainty_penalty=False,
|
||||
),
|
||||
"push": ScoreConfig(
|
||||
w_need=0.45,
|
||||
w_emotion=0.35,
|
||||
w_stage=0.15,
|
||||
w_context=0.05,
|
||||
# Push 默认启用不确定性惩罚
|
||||
enable_uncertainty_penalty=True,
|
||||
),
|
||||
"widget": ScoreConfig(
|
||||
w_need=0.25,
|
||||
w_emotion=0.25,
|
||||
w_stage=0.30,
|
||||
w_context=0.20,
|
||||
# Widget 默认不启用不确定性惩罚(可按需开启)
|
||||
enable_uncertainty_penalty=False,
|
||||
widget_emotion_soft_range=(0.4, 0.8),
|
||||
widget_emotion_penalty_gamma=0.25,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_default_config(scene: Scene) -> ScoreConfig:
|
||||
"""
|
||||
获取指定场景的默认打分参数(返回副本,避免被意外修改)。
|
||||
"""
|
||||
|
||||
base = _DEFAULTS[scene]
|
||||
return ScoreConfig.model_validate(base.model_dump())
|
||||
|
||||
201
server/app/features/personalized_reco/scoring/score.py
Normal file
201
server/app/features/personalized_reco/scoring/score.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
|
||||
from app.features.personalized_reco.scoring.defaults import get_default_config
|
||||
from app.features.personalized_reco.scoring.types import ExternalTerms, Scene, ScoreBreakdown, ScoreConfig, ScoreResult
|
||||
from app.features.personalized_reco.scoring.utils import as_finite_float, clamp, pick_one_hot_key
|
||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||
|
||||
|
||||
def _missing_fields(user_profile: UserProfileV1_2) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not user_profile.need:
|
||||
missing.append("need")
|
||||
if not user_profile.context:
|
||||
missing.append("context")
|
||||
if user_profile.emotion_score is None:
|
||||
missing.append("emotion")
|
||||
return missing
|
||||
|
||||
|
||||
def _score_need(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
key = pick_one_hot_key(user_profile.need) # type: ignore[arg-type]
|
||||
if key is None:
|
||||
return 0.5
|
||||
raw = content.need_suitability.get(key, 0.5)
|
||||
return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0)
|
||||
|
||||
|
||||
def _score_context(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
key = pick_one_hot_key(user_profile.context) # type: ignore[arg-type]
|
||||
if key is None:
|
||||
return 0.5
|
||||
raw = content.context_suitability.get(key, 0.5)
|
||||
return clamp(as_finite_float(raw, default=0.5), 0.0, 1.0)
|
||||
|
||||
|
||||
def _score_emotion(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
# V1.2:用户情绪缺失 -> 0.8
|
||||
if user_profile.emotion_score is None:
|
||||
return 0.8
|
||||
|
||||
# 文案 general(emotion_score=None)-> 0.8
|
||||
if content.emotion_score is None:
|
||||
return 0.8
|
||||
|
||||
u = clamp(as_finite_float(user_profile.emotion_score, default=0.8), 0.0, 1.0)
|
||||
c = clamp(as_finite_float(content.emotion_score, default=0.8), 0.0, 1.0)
|
||||
return clamp(1.0 - abs(u - c), 0.0, 1.0)
|
||||
|
||||
|
||||
def _user_stage_key(user_profile: UserProfileV1_2) -> str:
|
||||
# 约定:UserStageOneHot.unknown 必填;但这里仍做防御
|
||||
stage = user_profile.stage
|
||||
if getattr(stage, "expecting", 0) == 1:
|
||||
return "expecting"
|
||||
if getattr(stage, "parenting", 0) == 1:
|
||||
return "parenting"
|
||||
if getattr(stage, "unknown", 1) == 1:
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _score_stage(user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
# 对齐算法规则:
|
||||
# - general=1;命中=1;unknown对非unknown=0.7;其余=0
|
||||
if content.stage == "general":
|
||||
return 1.0
|
||||
|
||||
u_stage = _user_stage_key(user_profile)
|
||||
if content.stage == u_stage:
|
||||
return 1.0
|
||||
|
||||
if u_stage == "unknown" and content.stage != "unknown":
|
||||
return 0.7
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _score_personal(alpha: float, personalization_power: float, s_need: float, s_context: float) -> float:
|
||||
power = clamp(as_finite_float(personalization_power, default=0.0), 0.0, 1.0)
|
||||
a = as_finite_float(alpha, default=0.0)
|
||||
return float(a) * float(power) * max(float(s_need), float(s_context))
|
||||
|
||||
|
||||
def _penalty_uncertainty(beta: float, user_profile: UserProfileV1_2, content: ContentProfileDTO) -> float:
|
||||
b = as_finite_float(beta, default=0.0)
|
||||
power = clamp(as_finite_float(content.personalization_power, default=0.0), 0.0, 1.0)
|
||||
|
||||
# V1 约定:conf_U 缺失时按 1.0(避免过惩罚)
|
||||
conf_u = clamp(as_finite_float(getattr(user_profile, "profile_confidence", 1.0), default=1.0), 0.0, 1.0)
|
||||
conf_c = clamp(as_finite_float(getattr(content, "review_confidence", 0.7), default=0.7), 0.0, 1.0)
|
||||
|
||||
return float(b) * (1.0 - float(conf_u)) * (1.0 - float(conf_c)) * float(power)
|
||||
|
||||
|
||||
def _widget_emotion_penalty(scene: Scene, content: ContentProfileDTO, config: ScoreConfig) -> float:
|
||||
if scene != "widget":
|
||||
return 0.0
|
||||
if content.emotion_score is None:
|
||||
return 0.0
|
||||
|
||||
lo, hi = config.widget_emotion_soft_range
|
||||
lo_f = as_finite_float(lo, default=0.4)
|
||||
hi_f = as_finite_float(hi, default=0.8)
|
||||
width = hi_f - lo_f
|
||||
if width <= 0:
|
||||
return 0.0
|
||||
|
||||
e = clamp(as_finite_float(content.emotion_score, default=0.6), 0.0, 1.0)
|
||||
if e < lo_f:
|
||||
d = lo_f - e
|
||||
elif e > hi_f:
|
||||
d = e - hi_f
|
||||
else:
|
||||
d = 0.0
|
||||
|
||||
gamma = as_finite_float(config.widget_emotion_penalty_gamma, default=0.25)
|
||||
raw = float(gamma) * float(d) / float(width)
|
||||
return clamp(raw, 0.0, float(gamma))
|
||||
|
||||
|
||||
def score_content(
|
||||
*,
|
||||
scene: Scene,
|
||||
user_profile: UserProfileV1_2,
|
||||
content_profile: ContentProfileDTO,
|
||||
config: Optional[ScoreConfig] = None,
|
||||
pass_filters: bool = True,
|
||||
external_terms: Optional[ExternalTerms] = None,
|
||||
now: Optional[datetime] = None, # 预留:V1 不使用
|
||||
) -> ScoreResult:
|
||||
"""
|
||||
主入口:对单条内容 Cᵢ 进行软打分,返回 final_score 与 breakdown。
|
||||
|
||||
说明(V1):
|
||||
- `pass_filters` 来自 Hard Filter(本模块不做硬过滤)
|
||||
- `external_terms` 可注入 S_fresh / P_fatigue / P_repeat / P_risk(缺省按 0)
|
||||
- `now` 预留给未来的 freshness/时间衰减(V1 不实现)
|
||||
"""
|
||||
|
||||
cfg = config or get_default_config(scene)
|
||||
ext = external_terms or ExternalTerms()
|
||||
|
||||
missing = _missing_fields(user_profile)
|
||||
|
||||
s_need = _score_need(user_profile, content_profile)
|
||||
s_context = _score_context(user_profile, content_profile)
|
||||
s_emotion = _score_emotion(user_profile, content_profile)
|
||||
s_stage = _score_stage(user_profile, content_profile)
|
||||
|
||||
w_need = as_finite_float(cfg.w_need, default=0.0)
|
||||
w_emotion = as_finite_float(cfg.w_emotion, default=0.0)
|
||||
w_stage = as_finite_float(cfg.w_stage, default=0.0)
|
||||
w_context = as_finite_float(cfg.w_context, default=0.0)
|
||||
|
||||
s_core = float(w_need) * s_need + float(w_emotion) * s_emotion + float(w_stage) * s_stage + float(w_context) * s_context
|
||||
|
||||
s_personal = _score_personal(cfg.alpha, content_profile.personalization_power, s_need, s_context)
|
||||
|
||||
p_uncertainty = 0.0
|
||||
if cfg.enable_uncertainty_penalty:
|
||||
p_uncertainty = _penalty_uncertainty(cfg.beta, user_profile, content_profile)
|
||||
|
||||
p_widget = _widget_emotion_penalty(scene, content_profile, cfg)
|
||||
|
||||
s_fresh = as_finite_float(ext.S_fresh, default=0.0)
|
||||
p_fatigue = as_finite_float(ext.P_fatigue, default=0.0)
|
||||
p_repeat = as_finite_float(ext.P_repeat, default=0.0)
|
||||
p_risk_external = as_finite_float(ext.P_risk, default=0.0)
|
||||
|
||||
# Widget 软降权并入 P_risk(但在 breakdown 中单独暴露,便于打点)
|
||||
p_risk = float(p_risk_external) + float(p_widget)
|
||||
|
||||
raw_final = s_core + s_personal + float(s_fresh) - float(p_fatigue) - float(p_repeat) - float(p_risk) - float(p_uncertainty)
|
||||
final_score = float(raw_final) if pass_filters else 0.0
|
||||
|
||||
breakdown = ScoreBreakdown(
|
||||
scene=scene,
|
||||
**{
|
||||
"pass": bool(pass_filters),
|
||||
},
|
||||
missing_fields=missing,
|
||||
S_need=float(s_need),
|
||||
S_context=float(s_context),
|
||||
S_stage=float(s_stage),
|
||||
S_emotion=float(s_emotion),
|
||||
S_core=float(s_core),
|
||||
S_personal=float(s_personal),
|
||||
S_fresh=float(s_fresh),
|
||||
P_fatigue=float(p_fatigue),
|
||||
P_repeat=float(p_repeat),
|
||||
P_risk=float(p_risk),
|
||||
P_uncertainty=float(p_uncertainty),
|
||||
P_widget_emotion_out_of_range=float(p_widget),
|
||||
)
|
||||
|
||||
return ScoreResult(final_score=float(final_score), breakdown=breakdown)
|
||||
|
||||
84
server/app/features/personalized_reco/scoring/types.py
Normal file
84
server/app/features/personalized_reco/scoring/types.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
Scene = Literal["feed", "push", "widget"]
|
||||
|
||||
|
||||
class ScoreConfig(BaseModel):
|
||||
"""
|
||||
打分配置(可调参)。
|
||||
|
||||
说明:
|
||||
- 默认值由 `defaults.get_default_config(scene)` 提供
|
||||
- 本模块不负责回退梯度(fallback_level)策略;仅做防御式 clamp
|
||||
"""
|
||||
|
||||
w_need: float
|
||||
w_emotion: float
|
||||
w_stage: float
|
||||
w_context: float
|
||||
|
||||
alpha: float = 0.15
|
||||
beta: float = 0.30
|
||||
|
||||
enable_uncertainty_penalty: bool = False
|
||||
|
||||
# Widget 情绪软区间与软降权强度
|
||||
widget_emotion_soft_range: tuple[float, float] = (0.4, 0.8)
|
||||
widget_emotion_penalty_gamma: float = 0.25
|
||||
|
||||
|
||||
class ExternalTerms(BaseModel):
|
||||
"""
|
||||
外部注入项(V1 可选)。
|
||||
|
||||
说明:
|
||||
- 由 `rerank-freqcap` 或 `reco-engine` 产出
|
||||
- 本模块缺省按 0,保证可排序与输出结构稳定
|
||||
"""
|
||||
|
||||
S_fresh: float = 0.0
|
||||
P_fatigue: float = 0.0
|
||||
P_repeat: float = 0.0
|
||||
P_risk: float = 0.0
|
||||
|
||||
|
||||
class ScoreBreakdown(BaseModel):
|
||||
"""
|
||||
可观测分解项(用于调参与回归测试)。
|
||||
"""
|
||||
|
||||
scene: Scene
|
||||
passed: bool = Field(alias="pass")
|
||||
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
|
||||
S_need: float
|
||||
S_context: float
|
||||
S_stage: float
|
||||
S_emotion: float
|
||||
|
||||
S_core: float
|
||||
S_personal: float
|
||||
S_fresh: float
|
||||
|
||||
P_fatigue: float
|
||||
P_repeat: float
|
||||
P_risk: float
|
||||
P_uncertainty: float
|
||||
|
||||
# Widget 专用:区间外软降权(建议保留,便于打点)
|
||||
P_widget_emotion_out_of_range: float = 0.0
|
||||
|
||||
model_config = {
|
||||
"populate_by_name": True,
|
||||
}
|
||||
|
||||
|
||||
class ScoreResult(BaseModel):
|
||||
final_score: float
|
||||
breakdown: ScoreBreakdown
|
||||
|
||||
59
server/app/features/personalized_reco/scoring/utils.py
Normal file
59
server/app/features/personalized_reco/scoring/utils.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clamp(value: float, min_value: float, max_value: float) -> float:
|
||||
"""
|
||||
将值裁剪到区间内,并对 NaN 做兜底。
|
||||
"""
|
||||
|
||||
if value != value: # NaN
|
||||
return min_value
|
||||
return max(min_value, min(max_value, value))
|
||||
|
||||
|
||||
def as_finite_float(value: Any, *, default: float) -> float:
|
||||
"""
|
||||
将任意值尽量转为有限 float;失败则返回 default。
|
||||
"""
|
||||
|
||||
try:
|
||||
f = float(value)
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
# NaN / inf 都视为不可用
|
||||
if f != f:
|
||||
return float(default)
|
||||
if f == float("inf") or f == float("-inf"):
|
||||
return float(default)
|
||||
return f
|
||||
|
||||
|
||||
def pick_one_hot_key(one_hot: dict[str, Any] | None) -> str | None:
|
||||
"""
|
||||
从稀疏 one-hot({key: 1})中取唯一 key。
|
||||
|
||||
约定:
|
||||
- None / {} → 缺失,返回 None
|
||||
- 单 key → 返回该 key
|
||||
- 多 key → 取“字典序最小”的 key,并记录 debug 日志(避免静默歧义)
|
||||
"""
|
||||
|
||||
if not one_hot:
|
||||
return None
|
||||
|
||||
keys = [k for k, v in one_hot.items() if v == 1 or v is True]
|
||||
if not keys:
|
||||
return None
|
||||
if len(keys) == 1:
|
||||
return keys[0]
|
||||
|
||||
chosen = sorted(keys)[0]
|
||||
logger.debug("one-hot 出现多个 key=1,已按字典序选择:chosen=%s keys=%s", chosen, keys)
|
||||
return chosen
|
||||
|
||||
Reference in New Issue
Block a user