rules.py 6.3 KB

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