rules.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Versioned ROI thresholds and action recommendation policy."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from typing import Iterable
  5. import numpy as np
  6. import pandas as pd
  7. from .metrics import (
  8. ENTITY_GZH,
  9. ENTITY_QIWEI,
  10. ENTITY_SELF,
  11. compute_roi_summary,
  12. )
  13. from .fission_multiplier import FissionMultiplierParameters
  14. POLICY_VERSION = "roi_policy_v1"
  15. POLICY_RUN_SUFFIX = "p1"
  16. @dataclass(frozen=True)
  17. class RuleConfig:
  18. self_stop_min_age: int = 5
  19. self_up_min_age: int = 3
  20. self_min_avg_uv: float = 200
  21. partner_min_avg_uv: float = 200
  22. min_daily_cost: float = 100
  23. stop_quantile: float = 0.10
  24. up_quantile: float = 0.80
  25. gzh_adjust_rank_min: float = 0.10
  26. gzh_adjust_rank_max: float = 0.30
  27. def threshold_eligibility_mask(
  28. summary: pd.DataFrame,
  29. config: RuleConfig,
  30. ) -> pd.Series:
  31. self_eligible = (
  32. summary["entity_type"].eq(ENTITY_SELF)
  33. & summary["日均首层UV"].gt(config.self_min_avg_uv)
  34. )
  35. gzh_eligible = (
  36. summary["entity_type"].eq(ENTITY_GZH)
  37. & summary["日均首层UV"].gt(config.partner_min_avg_uv)
  38. )
  39. return (
  40. (self_eligible | gzh_eligible)
  41. & summary["成本"].gt(0)
  42. & summary["三日最小单日成本"].gt(config.min_daily_cost)
  43. & np.isfinite(summary["ROI"])
  44. )
  45. def compute_global_thresholds(
  46. summary: pd.DataFrame,
  47. expected_dates: list[str],
  48. config: RuleConfig,
  49. ) -> pd.DataFrame:
  50. valid = summary.loc[threshold_eligibility_mask(summary, config)].copy()
  51. if valid.empty:
  52. raise ValueError("没有满足渠道UV门槛且三日聚合ROI有效的实体,无法计算全局阈值")
  53. type_counts = valid["entity_type"].value_counts()
  54. return pd.DataFrame(
  55. [
  56. {
  57. "统计窗口": f"{expected_dates[0]} 至 {expected_dates[-1]}",
  58. "t_stop": float(valid["ROI"].quantile(config.stop_quantile)),
  59. "t_up": float(valid["ROI"].quantile(config.up_quantile)),
  60. "阈值样本数": int(len(valid)),
  61. "小程序样本数": int(type_counts.get(ENTITY_SELF, 0)),
  62. "公众号样本数": int(type_counts.get(ENTITY_GZH, 0)),
  63. "企微参考实体数": int(
  64. summary["entity_type"].eq(ENTITY_QIWEI).sum()
  65. ),
  66. }
  67. ]
  68. )
  69. def apply_actions(
  70. summary: pd.DataFrame,
  71. thresholds: pd.DataFrame,
  72. config: RuleConfig,
  73. ) -> pd.DataFrame:
  74. result = summary.copy()
  75. result["动作"] = ""
  76. result["动作原因"] = ""
  77. result["渠道内ROI排名百分位"] = np.nan
  78. t_stop = float(thresholds.iloc[0]["t_stop"])
  79. t_up = float(thresholds.iloc[0]["t_up"])
  80. result["t_stop"] = t_stop
  81. result["t_up"] = t_up
  82. gzh_mask = result["entity_type"].eq(ENTITY_GZH) & np.isfinite(result["ROI"])
  83. if gzh_mask.any():
  84. result.loc[gzh_mask, "渠道内ROI排名百分位"] = result.loc[gzh_mask, "ROI"].rank(
  85. method="average", ascending=False, pct=True
  86. )
  87. for index, row in result.iterrows():
  88. entity_type = row["entity_type"]
  89. avg_uv = row["日均首层UV"]
  90. roi = row["ROI"]
  91. cost_confident = row["三日最小单日成本"] > config.min_daily_cost
  92. stop_side = bool(np.isfinite(roi) and (roi <= t_stop or roi == 0))
  93. up_side = bool(np.isfinite(roi) and roi > 0 and roi >= t_up)
  94. if entity_type == ENTITY_SELF:
  95. if (
  96. row["广告age"] >= config.self_stop_min_age
  97. and stop_side
  98. and avg_uv > config.self_min_avg_uv
  99. and cost_confident
  100. ):
  101. result.at[index, "动作"] = "关停"
  102. result.at[index, "动作原因"] = (
  103. f"广告age≥{config.self_stop_min_age}天,三日聚合ROI不高于"
  104. f"全局t_stop(ROI=0明确关停),日均首层UV>"
  105. f"{config.self_min_avg_uv:g},连续3天每天成本>"
  106. f"{config.min_daily_cost:g}"
  107. )
  108. elif (
  109. row["广告age"] >= config.self_up_min_age
  110. and up_side
  111. and avg_uv > config.self_min_avg_uv
  112. and cost_confident
  113. ):
  114. result.at[index, "动作"] = "扩量"
  115. result.at[index, "动作原因"] = (
  116. f"广告age≥{config.self_up_min_age}天,三日聚合ROI不低于"
  117. f"全局t_up,日均首层UV>{config.self_min_avg_uv:g},"
  118. f"连续3天每天成本>{config.min_daily_cost:g}"
  119. )
  120. elif entity_type == ENTITY_GZH:
  121. if stop_side and avg_uv > config.partner_min_avg_uv and cost_confident:
  122. result.at[index, "动作"] = "关停"
  123. result.at[index, "动作原因"] = (
  124. "三日聚合ROI不高于全局t_stop(ROI=0明确关停),"
  125. f"日均首层UV>{config.partner_min_avg_uv:g},"
  126. f"连续3天每天成本>{config.min_daily_cost:g}"
  127. )
  128. elif up_side and avg_uv > config.partner_min_avg_uv and cost_confident:
  129. result.at[index, "动作"] = "扩量"
  130. result.at[index, "动作原因"] = (
  131. "三日聚合ROI不低于全局t_up,"
  132. f"日均首层UV>{config.partner_min_avg_uv:g},"
  133. f"连续3天每天成本>{config.min_daily_cost:g}"
  134. )
  135. else:
  136. rank_pct = row["渠道内ROI排名百分位"]
  137. if (
  138. np.isfinite(rank_pct)
  139. and config.gzh_adjust_rank_min <= rank_pct <= config.gzh_adjust_rank_max
  140. ):
  141. result.at[index, "动作"] = "调整封面&落地页视频"
  142. result.at[index, "动作原因"] = (
  143. "三日综合ROI在公众号即转渠道排名"
  144. f"{config.gzh_adjust_rank_min:.0%}–"
  145. f"{config.gzh_adjust_rank_max:.0%}"
  146. )
  147. return result
  148. def evaluate_rules(
  149. raw_daily: pd.DataFrame,
  150. expected_dates: Iterable[str],
  151. ad_age: pd.DataFrame | None = None,
  152. config: RuleConfig = RuleConfig(),
  153. *,
  154. fission_parameters: FissionMultiplierParameters,
  155. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  156. summary, dates = compute_roi_summary(
  157. raw_daily,
  158. expected_dates,
  159. ad_age,
  160. fission_parameters=fission_parameters,
  161. )
  162. thresholds = compute_global_thresholds(summary, dates, config)
  163. evaluated = apply_actions(summary, thresholds, config)
  164. evaluated["阈值样本状态"] = "未达到阈值样本门槛"
  165. evaluated.loc[
  166. threshold_eligibility_mask(evaluated, config),
  167. "阈值样本状态",
  168. ] = "进入阈值样本池"
  169. evaluated.loc[
  170. evaluated["entity_type"].eq(ENTITY_QIWEI),
  171. "阈值样本状态",
  172. ] = "企微仅展示_不进入阈值样本池"
  173. candidates = evaluated[evaluated["动作"].ne("")].copy()
  174. return candidates, thresholds, evaluated