rules.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. """Versioned three-day policy with a latest-day creative stop supplement."""
  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 .fission_multiplier import FissionMultiplierParameters
  8. from .metrics import (
  9. ENTITY_GZH,
  10. ENTITY_SELF,
  11. ENTITY_SELF_AD,
  12. compute_roi_summary,
  13. )
  14. POLICY_VERSION = "roi_policy_v12"
  15. POLICY_RUN_SUFFIX = "p12"
  16. @dataclass(frozen=True)
  17. class RuleConfig:
  18. self_stop_min_age: int = 4
  19. self_up_min_age: int = 3
  20. self_min_daily_uv: float = 200
  21. partner_min_daily_uv: float = 200
  22. observe_min_latest_uv: float = 200
  23. one_day_min_uv: float = 200
  24. one_day_p30_min_uv: float = 500
  25. one_day_hard_stop_roi: float = 0.20
  26. one_day_stop_quantile: float = 0.30
  27. stop_quantile: float = 0.20
  28. up_quantile: float = 0.80
  29. def _daily_sample_mask(
  30. summary: pd.DataFrame,
  31. entity_type: str,
  32. dt: str,
  33. config: RuleConfig,
  34. ) -> pd.Series:
  35. min_uv = (
  36. config.partner_min_daily_uv
  37. if entity_type == ENTITY_GZH
  38. else config.self_min_daily_uv
  39. )
  40. return (
  41. summary["entity_type"].eq(entity_type)
  42. & pd.to_numeric(summary[f"首层UV_{dt}"], errors="coerce").gt(min_uv)
  43. & pd.to_numeric(summary[f"成本_{dt}"], errors="coerce").gt(0)
  44. & np.isfinite(pd.to_numeric(summary[f"ROI_{dt}"], errors="coerce"))
  45. )
  46. def entity_eligibility_mask(
  47. summary: pd.DataFrame,
  48. entity_type: str,
  49. config: RuleConfig,
  50. expected_dates: list[str],
  51. ) -> pd.Series:
  52. eligible = summary["entity_type"].eq(entity_type)
  53. for dt in expected_dates:
  54. eligible &= _daily_sample_mask(summary, entity_type, dt, config)
  55. return eligible
  56. def threshold_eligibility_mask(
  57. summary: pd.DataFrame,
  58. config: RuleConfig,
  59. expected_dates: list[str],
  60. ) -> pd.Series:
  61. """Only creative-level miniapp and official accounts enter global P20."""
  62. return entity_eligibility_mask(
  63. summary, ENTITY_SELF, config, expected_dates
  64. ) | entity_eligibility_mask(summary, ENTITY_GZH, config, expected_dates)
  65. def report_formal_mask(
  66. summary: pd.DataFrame,
  67. config: RuleConfig,
  68. expected_dates: list[str],
  69. ) -> pd.Series:
  70. return threshold_eligibility_mask(
  71. summary, config, expected_dates
  72. ) | entity_eligibility_mask(summary, ENTITY_SELF_AD, config, expected_dates)
  73. def observation_mask(
  74. summary: pd.DataFrame,
  75. config: RuleConfig,
  76. expected_dates: list[str],
  77. ) -> pd.Series:
  78. latest = expected_dates[-1]
  79. formal = report_formal_mask(summary, config, expected_dates)
  80. return (
  81. summary["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH])
  82. & ~formal
  83. & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
  84. config.observe_min_latest_uv
  85. )
  86. )
  87. def one_day_p30_pool_mask(
  88. summary: pd.DataFrame,
  89. config: RuleConfig,
  90. expected_dates: list[str],
  91. ) -> pd.Series:
  92. latest = expected_dates[-1]
  93. latest_roi = pd.to_numeric(summary[f"ROI_{latest}"], errors="coerce")
  94. return (
  95. summary["entity_type"].eq(ENTITY_SELF)
  96. & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
  97. config.one_day_p30_min_uv
  98. )
  99. & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
  100. & np.isfinite(latest_roi)
  101. )
  102. def one_day_supplement_mask(
  103. summary: pd.DataFrame,
  104. config: RuleConfig,
  105. expected_dates: list[str],
  106. ) -> pd.Series:
  107. latest = expected_dates[-1]
  108. latest_roi = pd.to_numeric(summary[f"ROI_{latest}"], errors="coerce")
  109. formal_creative = entity_eligibility_mask(
  110. summary, ENTITY_SELF, config, expected_dates
  111. )
  112. return (
  113. summary["entity_type"].eq(ENTITY_SELF)
  114. & ~formal_creative
  115. & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
  116. config.one_day_min_uv
  117. )
  118. & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
  119. & np.isfinite(latest_roi)
  120. )
  121. def compute_global_threshold(
  122. summary: pd.DataFrame,
  123. expected_dates: list[str],
  124. config: RuleConfig,
  125. ) -> pd.DataFrame:
  126. eligible = threshold_eligibility_mask(summary, config, expected_dates)
  127. sample = pd.to_numeric(summary.loc[eligible, "ROI"], errors="coerce")
  128. sample = sample[np.isfinite(sample)]
  129. if sample.empty:
  130. raise ValueError("最近三日没有满足每日UV和成本门槛的统一阈值样本")
  131. creative_eligible = entity_eligibility_mask(
  132. summary, ENTITY_SELF, config, expected_dates
  133. )
  134. creative_sample = pd.to_numeric(
  135. summary.loc[creative_eligible, "ROI"], errors="coerce"
  136. )
  137. creative_sample = creative_sample[np.isfinite(creative_sample)]
  138. t_up = (
  139. float(creative_sample.quantile(config.up_quantile))
  140. if not creative_sample.empty
  141. else np.nan
  142. )
  143. latest = expected_dates[-1]
  144. one_day_pool = pd.to_numeric(
  145. summary.loc[
  146. one_day_p30_pool_mask(summary, config, expected_dates),
  147. f"ROI_{latest}",
  148. ],
  149. errors="coerce",
  150. )
  151. one_day_pool = one_day_pool[np.isfinite(one_day_pool)]
  152. t_one_day_stop = (
  153. float(one_day_pool.quantile(config.one_day_stop_quantile))
  154. if not one_day_pool.empty
  155. else np.nan
  156. )
  157. return pd.DataFrame(
  158. [
  159. {
  160. "统计窗口": f"{expected_dates[0]} 至 {expected_dates[-1]}",
  161. "entity_type": "global",
  162. "渠道": "小程序创意级+公众号",
  163. "t_stop": float(sample.quantile(config.stop_quantile)),
  164. "t_up": t_up,
  165. "t_one_day_stop": t_one_day_stop,
  166. "阈值样本数": int(len(sample)),
  167. "扩量样本数": int(len(creative_sample)),
  168. "单日P30样本数": int(len(one_day_pool)),
  169. "单日硬关停ROI": config.one_day_hard_stop_roi,
  170. "单日最低UV": config.one_day_min_uv,
  171. "单日P30最低UV": config.one_day_p30_min_uv,
  172. "关停线口径": f"合格实体等权P{int(config.stop_quantile * 100)}",
  173. "扩量线口径": f"合格小程序创意实体等权P{int(config.up_quantile * 100)}",
  174. "广告级是否入池": "否_仅复用统一关停线",
  175. }
  176. ]
  177. )
  178. def _sample_percentile(values: pd.Series, target: float) -> float:
  179. clean = pd.to_numeric(values, errors="coerce")
  180. clean = clean[np.isfinite(clean)]
  181. if clean.empty or not np.isfinite(target):
  182. return np.nan
  183. return float((clean <= target).mean())
  184. def apply_actions(
  185. summary: pd.DataFrame,
  186. thresholds: pd.DataFrame,
  187. config: RuleConfig,
  188. expected_dates: list[str],
  189. ) -> pd.DataFrame:
  190. result = summary.copy()
  191. result["动作"] = ""
  192. result["动作原因"] = ""
  193. result["t_stop"] = float(thresholds.iloc[0]["t_stop"])
  194. result["t_up"] = float(thresholds.iloc[0]["t_up"])
  195. result["t_one_day_stop"] = float(
  196. thresholds.iloc[0]["t_one_day_stop"]
  197. )
  198. threshold_eligible = threshold_eligibility_mask(result, config, expected_dates)
  199. ad_eligible = entity_eligibility_mask(
  200. result, ENTITY_SELF_AD, config, expected_dates
  201. )
  202. observe_only = observation_mask(result, config, expected_dates)
  203. one_day_supplement = one_day_supplement_mask(
  204. result, config, expected_dates
  205. )
  206. sample_roi = result.loc[threshold_eligible, "ROI"]
  207. creative_eligible = entity_eligibility_mask(
  208. result, ENTITY_SELF, config, expected_dates
  209. )
  210. creative_sample_roi = result.loc[creative_eligible, "ROI"]
  211. result["整体三日ROI排名百分位"] = result["ROI"].apply(
  212. lambda value: _sample_percentile(sample_roi, float(value))
  213. if pd.notna(value)
  214. else np.nan
  215. )
  216. result["是否位于三日ROI后20%"] = False
  217. result["创意三日ROI排名百分位"] = result["ROI"].apply(
  218. lambda value: _sample_percentile(creative_sample_roi, float(value))
  219. if pd.notna(value)
  220. else np.nan
  221. )
  222. result["是否位于创意三日ROI前20%"] = False
  223. comparable = threshold_eligible | ad_eligible
  224. result.loc[comparable, "是否位于三日ROI后20%"] = (
  225. pd.to_numeric(result.loc[comparable, "ROI"], errors="coerce")
  226. <= result.loc[comparable, "t_stop"]
  227. )
  228. if np.isfinite(float(result["t_up"].iloc[0])):
  229. result.loc[creative_eligible, "是否位于创意三日ROI前20%"] = (
  230. pd.to_numeric(result.loc[creative_eligible, "ROI"], errors="coerce")
  231. >= result.loc[creative_eligible, "t_up"]
  232. )
  233. for index, row in result.iterrows():
  234. entity_type = str(row["entity_type"])
  235. if bool(one_day_supplement.loc[index]):
  236. latest = expected_dates[-1]
  237. latest_uv = float(row[f"首层UV_{latest}"])
  238. latest_roi = float(row[f"ROI_{latest}"])
  239. three_day_roi = float(row["ROI"])
  240. raw_age = row.get("广告age")
  241. age = int(raw_age) if pd.notna(raw_age) else 0
  242. decision_context = (
  243. "未满足连续三天每天首层UV>200、成本>0且ROI有效,"
  244. "启用单日补充规则;"
  245. )
  246. three_day_context = (
  247. f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
  248. "不参与本次单日判断"
  249. )
  250. stop_reason = ""
  251. if latest_roi <= config.one_day_hard_stop_roi:
  252. stop_reason = (
  253. f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
  254. f"最新日预测总效率ROI={latest_roi:.2f}≤单日硬关停线"
  255. f"{config.one_day_hard_stop_roi:.2f}"
  256. )
  257. elif (
  258. latest_uv > config.one_day_p30_min_uv
  259. and np.isfinite(float(row["t_one_day_stop"]))
  260. and latest_roi <= float(row["t_one_day_stop"])
  261. ):
  262. stop_reason = (
  263. f"最新日首层UV={latest_uv:.0f}>{config.one_day_p30_min_uv:g},"
  264. f"最新日预测总效率ROI={latest_roi:.2f}≤单日实体等权P30关停线"
  265. f"{float(row['t_one_day_stop']):.2f}"
  266. )
  267. if stop_reason and age >= config.self_stop_min_age:
  268. result.at[index, "动作"] = "关停"
  269. result.at[index, "动作原因"] = (
  270. f"{decision_context}{stop_reason}{three_day_context};"
  271. f"广告age={age}>{config.self_stop_min_age - 1}天,"
  272. "建议审批后暂停动态创意"
  273. )
  274. elif stop_reason:
  275. result.at[index, "动作"] = "观察"
  276. result.at[index, "动作原因"] = (
  277. f"{decision_context}{stop_reason}{three_day_context};"
  278. f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
  279. )
  280. else:
  281. result.at[index, "动作"] = "观察"
  282. result.at[index, "动作原因"] = (
  283. f"{decision_context}"
  284. f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
  285. f"最新日预测总效率ROI={latest_roi:.2f}未命中单日关停规则"
  286. f"{three_day_context};建议继续观察"
  287. )
  288. continue
  289. if bool(observe_only.loc[index]):
  290. result.at[index, "动作"] = "观察"
  291. result.at[index, "动作原因"] = (
  292. f"最新日首层UV>{config.observe_min_latest_uv:g},但未满足连续三天"
  293. "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入P20和自动执行"
  294. )
  295. continue
  296. if bool(ad_eligible.loc[index]):
  297. if float(row["ROI"]) <= float(row["t_stop"]):
  298. raw_age = row.get("广告age")
  299. age = int(raw_age) if pd.notna(raw_age) else 0
  300. if age >= config.self_stop_min_age:
  301. result.at[index, "动作"] = "关停"
  302. result.at[index, "动作原因"] = (
  303. f"广告级三日加权平均效率ROI≤统一实体等权P20,"
  304. f"广告age>{config.self_stop_min_age - 1}天;审批后暂停整个广告"
  305. )
  306. else:
  307. result.at[index, "动作"] = "观察"
  308. result.at[index, "动作原因"] = (
  309. f"广告级三日加权平均效率ROI≤统一实体等权P20,但广告age≤"
  310. f"{config.self_stop_min_age - 1}天"
  311. )
  312. continue
  313. if not bool(threshold_eligible.loc[index]):
  314. continue
  315. if entity_type == ENTITY_SELF:
  316. raw_age = row.get("广告age")
  317. age = int(raw_age) if pd.notna(raw_age) else 0
  318. if float(row["ROI"]) <= float(row["t_stop"]):
  319. if age >= config.self_stop_min_age:
  320. result.at[index, "动作"] = "关停"
  321. result.at[index, "动作原因"] = (
  322. f"三日加权平均效率ROI≤统一实体等权P20,"
  323. f"广告age>{config.self_stop_min_age - 1}天;审批后仅暂停动态创意"
  324. )
  325. else:
  326. result.at[index, "动作"] = "观察"
  327. result.at[index, "动作原因"] = (
  328. f"三日加权平均效率ROI≤统一实体等权P20,但广告age≤"
  329. f"{config.self_stop_min_age - 1}天"
  330. )
  331. elif bool(row["是否位于创意三日ROI前20%"]):
  332. if age >= config.self_up_min_age:
  333. result.at[index, "动作"] = "扩量"
  334. result.at[index, "动作原因"] = (
  335. f"三日加权平均效率ROI≥合格创意实体等权P80,"
  336. f"广告age≥{config.self_up_min_age}天;审批后提高广告永久基础出价"
  337. )
  338. else:
  339. result.at[index, "动作"] = "观察"
  340. result.at[index, "动作原因"] = (
  341. f"三日加权平均效率ROI≥合格创意实体等权P80,但广告age<"
  342. f"{config.self_up_min_age}天"
  343. )
  344. elif entity_type == ENTITY_GZH and float(row["ROI"]) <= float(row["t_stop"]):
  345. result.at[index, "动作"] = "关停"
  346. result.at[index, "动作原因"] = (
  347. "三日加权平均效率ROI≤统一实体等权P20;公众号当前仅通知参考"
  348. )
  349. return result
  350. def evaluate_rules(
  351. raw_daily: pd.DataFrame,
  352. expected_dates: Iterable[str],
  353. ad_age: pd.DataFrame | None = None,
  354. config: RuleConfig = RuleConfig(),
  355. *,
  356. fission_parameters: FissionMultiplierParameters,
  357. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  358. summary, dates = compute_roi_summary(
  359. raw_daily,
  360. expected_dates,
  361. ad_age,
  362. fission_parameters=fission_parameters,
  363. )
  364. thresholds = compute_global_threshold(summary, dates, config)
  365. evaluated = apply_actions(summary, thresholds, config, dates)
  366. formal_threshold = threshold_eligibility_mask(evaluated, config, dates)
  367. formal_ad = entity_eligibility_mask(evaluated, ENTITY_SELF_AD, config, dates)
  368. one_day_supplement = one_day_supplement_mask(evaluated, config, dates)
  369. observe_only = observation_mask(evaluated, config, dates) & ~one_day_supplement
  370. evaluated["阈值样本状态"] = "未达到三日正式样本门槛"
  371. evaluated.loc[formal_threshold, "阈值样本状态"] = "进入三日统一阈值样本池"
  372. evaluated.loc[formal_ad, "阈值样本状态"] = "广告级三日合格_不进入阈值样本池"
  373. evaluated.loc[one_day_supplement, "阈值样本状态"] = (
  374. "单日补充决策_昨日UV>200"
  375. )
  376. evaluated.loc[observe_only, "阈值样本状态"] = "补充观察_昨日UV>200"
  377. candidates = evaluated[evaluated["动作"].ne("")].copy()
  378. return candidates, thresholds, evaluated