rules.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. """版本化三日策略,并补充最新日创意关停规则。"""
  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. GZH_CHANNEL,
  13. SELF_CHANNEL,
  14. compute_roi_summary,
  15. )
  16. POLICY_VERSION = "roi_policy_v15"
  17. POLICY_RUN_SUFFIX = "p15"
  18. STOP_RULE_THREE_DAY = "三日持续低ROI"
  19. STOP_RULE_ONE_DAY = "单日绝对低ROI且P10"
  20. @dataclass(frozen=True)
  21. class RuleConfig:
  22. self_stop_min_age: int = 4
  23. self_up_min_age: int = 3
  24. self_min_daily_uv: float = 200
  25. partner_min_daily_uv: float = 200
  26. observe_min_latest_uv: float = 200
  27. one_day_min_uv: float = 200
  28. one_day_hard_stop_roi: float = 0.20
  29. one_day_stop_quantile: float = 0.10
  30. self_stop_cost_soft_lower_ratio: float = 0.045
  31. self_stop_cost_target_ratio: float = 0.05
  32. self_stop_cost_soft_upper_ratio: float = 0.055
  33. self_stop_cost_hard_cap_ratio: float = 0.06
  34. self_stop_three_day_share: float = 0.60
  35. self_stop_one_day_share: float = 0.40
  36. stop_quantile: float = 0.20
  37. up_quantile: float = 0.80
  38. def _daily_sample_mask(
  39. summary: pd.DataFrame,
  40. entity_type: str,
  41. dt: str,
  42. config: RuleConfig,
  43. ) -> pd.Series:
  44. min_uv = (
  45. config.partner_min_daily_uv
  46. if entity_type == ENTITY_GZH
  47. else config.self_min_daily_uv
  48. )
  49. return (
  50. summary["entity_type"].eq(entity_type)
  51. & pd.to_numeric(summary[f"首层UV_{dt}"], errors="coerce").gt(min_uv)
  52. & pd.to_numeric(summary[f"成本_{dt}"], errors="coerce").gt(0)
  53. & np.isfinite(pd.to_numeric(summary[f"ROI_{dt}"], errors="coerce"))
  54. )
  55. def entity_eligibility_mask(
  56. summary: pd.DataFrame,
  57. entity_type: str,
  58. config: RuleConfig,
  59. expected_dates: list[str],
  60. ) -> pd.Series:
  61. eligible = summary["entity_type"].eq(entity_type)
  62. for dt in expected_dates:
  63. eligible &= _daily_sample_mask(summary, entity_type, dt, config)
  64. return eligible
  65. def threshold_eligibility_mask(
  66. summary: pd.DataFrame,
  67. config: RuleConfig,
  68. expected_dates: list[str],
  69. ) -> pd.Series:
  70. """返回渠道独立阈值使用的正式创意级样本。"""
  71. return entity_eligibility_mask(
  72. summary, ENTITY_SELF, config, expected_dates
  73. ) | entity_eligibility_mask(summary, ENTITY_GZH, config, expected_dates)
  74. def report_formal_mask(
  75. summary: pd.DataFrame,
  76. config: RuleConfig,
  77. expected_dates: list[str],
  78. ) -> pd.Series:
  79. return threshold_eligibility_mask(
  80. summary, config, expected_dates
  81. ) | entity_eligibility_mask(summary, ENTITY_SELF_AD, config, expected_dates)
  82. def observation_mask(
  83. summary: pd.DataFrame,
  84. config: RuleConfig,
  85. expected_dates: list[str],
  86. ) -> pd.Series:
  87. latest = expected_dates[-1]
  88. formal = report_formal_mask(summary, config, expected_dates)
  89. return (
  90. summary["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH])
  91. & ~formal
  92. & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
  93. config.observe_min_latest_uv
  94. )
  95. )
  96. def one_day_supplement_mask(
  97. summary: pd.DataFrame,
  98. config: RuleConfig,
  99. expected_dates: list[str],
  100. ) -> pd.Series:
  101. latest = expected_dates[-1]
  102. latest_roi = pd.to_numeric(summary[f"ROI_{latest}"], errors="coerce")
  103. formal_creative = entity_eligibility_mask(
  104. summary, ENTITY_SELF, config, expected_dates
  105. )
  106. return (
  107. summary["entity_type"].eq(ENTITY_SELF)
  108. & ~formal_creative
  109. & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
  110. config.one_day_min_uv
  111. )
  112. & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
  113. & np.isfinite(latest_roi)
  114. )
  115. def compute_channel_thresholds(
  116. summary: pd.DataFrame,
  117. expected_dates: list[str],
  118. config: RuleConfig,
  119. ) -> pd.DataFrame:
  120. self_eligible = entity_eligibility_mask(
  121. summary, ENTITY_SELF, config, expected_dates
  122. )
  123. gzh_eligible = entity_eligibility_mask(
  124. summary, ENTITY_GZH, config, expected_dates
  125. )
  126. self_sample = pd.to_numeric(
  127. summary.loc[self_eligible, "ROI"], errors="coerce"
  128. )
  129. self_sample = self_sample[np.isfinite(self_sample)]
  130. gzh_sample = pd.to_numeric(
  131. summary.loc[gzh_eligible, "ROI"], errors="coerce"
  132. )
  133. gzh_sample = gzh_sample[np.isfinite(gzh_sample)]
  134. if self_sample.empty and gzh_sample.empty:
  135. raise ValueError("最近三日没有满足每日UV和成本门槛的渠道独立阈值样本")
  136. t_up = (
  137. float(self_sample.quantile(config.up_quantile))
  138. if not self_sample.empty
  139. else np.nan
  140. )
  141. gzh_stop = (
  142. float(gzh_sample.quantile(config.stop_quantile))
  143. if not gzh_sample.empty
  144. else np.nan
  145. )
  146. latest = expected_dates[-1]
  147. return pd.DataFrame(
  148. [
  149. {
  150. "统计窗口": f"{expected_dates[0]} 至 {latest}",
  151. "entity_type": ENTITY_SELF,
  152. "渠道": SELF_CHANNEL,
  153. "t_stop": np.nan,
  154. "t_up": t_up,
  155. "t_one_day_stop": np.nan,
  156. "关停线分位点": np.nan,
  157. "单日实际关停线分位点": np.nan,
  158. "阈值样本数": int(len(self_sample)),
  159. "扩量样本数": int(len(self_sample)),
  160. "单日候选池样本数": 0,
  161. "单日硬关停ROI": config.one_day_hard_stop_roi,
  162. "单日P10线": np.nan,
  163. "单日合并资格线": np.nan,
  164. "单日最低UV": config.one_day_min_uv,
  165. "关停线口径": "T-1实际成本5%软预算动态线",
  166. "扩量线口径": (
  167. f"合格小程序创意实体等权P{int(config.up_quantile * 100)}"
  168. ),
  169. "广告级是否入池": "否_仅复用小程序三日动态关停线",
  170. },
  171. {
  172. "统计窗口": f"{expected_dates[0]} 至 {latest}",
  173. "entity_type": ENTITY_GZH,
  174. "渠道": GZH_CHANNEL,
  175. "t_stop": gzh_stop,
  176. "t_up": np.nan,
  177. "t_one_day_stop": np.nan,
  178. "关停线分位点": (
  179. config.stop_quantile if np.isfinite(gzh_stop) else np.nan
  180. ),
  181. "单日实际关停线分位点": np.nan,
  182. "阈值样本数": int(len(gzh_sample)),
  183. "扩量样本数": 0,
  184. "单日候选池样本数": 0,
  185. "单日硬关停ROI": np.nan,
  186. "单日P10线": np.nan,
  187. "单日合并资格线": np.nan,
  188. "单日最低UV": np.nan,
  189. "关停线口径": (
  190. f"公众号合格实体等权P{int(config.stop_quantile * 100)}"
  191. ),
  192. "扩量线口径": "不适用",
  193. "广告级是否入池": "不适用",
  194. },
  195. ]
  196. )
  197. def _sample_percentile(values: pd.Series, target: float) -> float:
  198. clean = pd.to_numeric(values, errors="coerce")
  199. clean = clean[np.isfinite(clean)]
  200. if clean.empty or not np.isfinite(target):
  201. return np.nan
  202. return float((clean <= target).mean())
  203. def _ordered_candidate_indices(
  204. result: pd.DataFrame,
  205. mask: pd.Series,
  206. roi_column: str,
  207. cost_column: str,
  208. ) -> list[object]:
  209. roi = pd.to_numeric(result[roi_column], errors="coerce")
  210. cost = pd.to_numeric(result[cost_column], errors="coerce")
  211. eligible = mask & np.isfinite(roi) & cost.gt(0)
  212. return result.loc[eligible].assign(_decision_roi=roi.loc[eligible]).sort_values(
  213. "_decision_roi",
  214. ascending=True,
  215. kind="stable",
  216. ).index.tolist()
  217. def _select_prefix_within_budget(
  218. result: pd.DataFrame,
  219. ordered: list[object],
  220. cost_column: str,
  221. budget: float,
  222. ) -> list[object]:
  223. selected: list[object] = []
  224. cost = 0.0
  225. for index in ordered:
  226. candidate_cost = float(result.at[index, cost_column])
  227. if cost + candidate_cost > budget + 1e-9:
  228. break
  229. selected.append(index)
  230. cost += candidate_cost
  231. return selected
  232. def _selection_line(
  233. result: pd.DataFrame,
  234. ordered: list[object],
  235. selected: set[object],
  236. roi_column: str,
  237. ) -> tuple[float, float]:
  238. selected_indices = [index for index in ordered if index in selected]
  239. if not selected_indices:
  240. return np.nan, np.nan
  241. line = float(
  242. pd.to_numeric(
  243. result.loc[selected_indices, roi_column], errors="coerce"
  244. ).max()
  245. )
  246. sample = pd.to_numeric(result.loc[ordered, roi_column], errors="coerce")
  247. return line, _sample_percentile(sample, line)
  248. def _allocate_self_stop_budget(
  249. result: pd.DataFrame,
  250. config: RuleConfig,
  251. expected_dates: list[str],
  252. formal_creative: pd.Series,
  253. one_day_supplement: pd.Series,
  254. ) -> tuple[pd.Series, pd.Series, dict[str, float | str]]:
  255. latest = expected_dates[-1]
  256. cost_column = f"成本_{latest}"
  257. latest_roi_column = f"ROI_{latest}"
  258. latest_cost = pd.to_numeric(result[cost_column], errors="coerce").fillna(0.0)
  259. latest_roi = pd.to_numeric(result[latest_roi_column], errors="coerce")
  260. age = pd.to_numeric(result.get("广告age"), errors="coerce").fillna(0)
  261. actionable_age = age.ge(config.self_stop_min_age)
  262. three_day_mask = formal_creative & actionable_age
  263. one_day_pool = one_day_supplement & actionable_age
  264. one_day_sample = latest_roi.loc[one_day_pool]
  265. one_day_sample = one_day_sample[np.isfinite(one_day_sample)]
  266. one_day_p10_line = (
  267. float(one_day_sample.quantile(config.one_day_stop_quantile))
  268. if not one_day_sample.empty
  269. else np.nan
  270. )
  271. one_day_eligibility_line = (
  272. min(config.one_day_hard_stop_roi, one_day_p10_line)
  273. if np.isfinite(one_day_p10_line)
  274. else np.nan
  275. )
  276. one_day_candidate = (
  277. one_day_pool
  278. & latest_roi.le(config.one_day_hard_stop_roi)
  279. & latest_roi.le(one_day_p10_line)
  280. )
  281. result["单日ROI排名百分位"] = np.nan
  282. if not one_day_sample.empty:
  283. result.loc[one_day_pool, "单日ROI排名百分位"] = latest_roi.loc[
  284. one_day_pool
  285. ].apply(lambda value: _sample_percentile(one_day_sample, float(value)))
  286. result["单日P10线"] = one_day_p10_line
  287. result["单日合并资格线"] = one_day_eligibility_line
  288. masks = {
  289. STOP_RULE_THREE_DAY: three_day_mask,
  290. STOP_RULE_ONE_DAY: one_day_candidate,
  291. }
  292. roi_columns = {
  293. STOP_RULE_THREE_DAY: "ROI",
  294. STOP_RULE_ONE_DAY: latest_roi_column,
  295. }
  296. shares = {
  297. STOP_RULE_THREE_DAY: config.self_stop_three_day_share,
  298. STOP_RULE_ONE_DAY: config.self_stop_one_day_share,
  299. }
  300. all_self = result["entity_type"].eq(ENTITY_SELF)
  301. total_cost = float(latest_cost.loc[all_self & latest_cost.gt(0)].sum())
  302. target_cost = total_cost * config.self_stop_cost_target_ratio
  303. soft_lower_cost = total_cost * config.self_stop_cost_soft_lower_ratio
  304. soft_upper_cost = total_cost * config.self_stop_cost_soft_upper_ratio
  305. hard_cap_cost = total_cost * config.self_stop_cost_hard_cap_ratio
  306. ordered = {
  307. rule: _ordered_candidate_indices(
  308. result,
  309. mask,
  310. roi_columns[rule],
  311. cost_column,
  312. )
  313. for rule, mask in masks.items()
  314. }
  315. selected: set[object] = set()
  316. for rule in (STOP_RULE_THREE_DAY, STOP_RULE_ONE_DAY):
  317. selected.update(
  318. _select_prefix_within_budget(
  319. result,
  320. ordered[rule],
  321. cost_column,
  322. target_cost * shares[rule],
  323. )
  324. )
  325. selected_cost = float(latest_cost.loc[list(selected)].sum()) if selected else 0.0
  326. for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
  327. for index in ordered[rule]:
  328. if index in selected:
  329. continue
  330. candidate_cost = float(latest_cost.loc[index])
  331. proposed = selected_cost + candidate_cost
  332. if proposed <= target_cost + 1e-9:
  333. selected.add(index)
  334. selected_cost = proposed
  335. continue
  336. if (
  337. proposed <= soft_upper_cost + 1e-9
  338. and abs(proposed - target_cost) < abs(selected_cost - target_cost)
  339. ):
  340. selected.add(index)
  341. selected_cost = proposed
  342. break
  343. # 常规选择保持在 5.5% 软上限内。若离散创意消耗使结果仍低于 4.5% 软下限,
  344. # 允许下一个连续 ROI 前缀接近 5%,但绝不能超过 6% 绝对上限。
  345. if selected_cost < soft_lower_cost:
  346. for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
  347. for index in ordered[rule]:
  348. if index in selected:
  349. continue
  350. candidate_cost = float(latest_cost.loc[index])
  351. proposed = selected_cost + candidate_cost
  352. if (
  353. proposed <= hard_cap_cost + 1e-9
  354. and abs(proposed - target_cost)
  355. < abs(selected_cost - target_cost)
  356. ):
  357. selected.add(index)
  358. selected_cost = proposed
  359. break
  360. if selected_cost > hard_cap_cost + 1e-9:
  361. raise ValueError("小程序关停候选成本超过绝对上限")
  362. selected_mask = pd.Series(result.index.isin(selected), index=result.index)
  363. candidate_rule = pd.Series("", index=result.index, dtype="object")
  364. for rule, mask in masks.items():
  365. candidate_rule.loc[mask] = rule
  366. lines: dict[str, float] = {}
  367. for rule in ordered:
  368. line, percentile = _selection_line(
  369. result,
  370. ordered[rule],
  371. selected,
  372. roi_columns[rule],
  373. )
  374. lines[f"{rule}_line"] = line
  375. lines[f"{rule}_percentile"] = percentile
  376. selected_indices = [index for index in ordered[rule] if index in selected]
  377. lines[f"{rule}_cost"] = (
  378. float(latest_cost.loc[selected_indices].sum())
  379. if selected_indices
  380. else 0.0
  381. )
  382. stats = {
  383. "小程序昨日总成本": total_cost,
  384. "软下限关停成本": soft_lower_cost,
  385. "目标关停成本": target_cost,
  386. "软上限关停成本": soft_upper_cost,
  387. "绝对上限关停成本": hard_cap_cost,
  388. "实际关停成本": selected_cost,
  389. "实际关停成本占比": selected_cost / total_cost if total_cost > 0 else 0.0,
  390. "关停成本预算状态": (
  391. "正常范围"
  392. if soft_lower_cost <= selected_cost <= soft_upper_cost
  393. else "低于软下限"
  394. if selected_cost < soft_lower_cost
  395. else "高于软上限"
  396. ),
  397. "三日基础预算成本": target_cost * config.self_stop_three_day_share,
  398. "单日基础预算成本": target_cost * config.self_stop_one_day_share,
  399. "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
  400. "单日候选池样本数": int(one_day_pool.sum()),
  401. "单日合并候选数": len(ordered[STOP_RULE_ONE_DAY]),
  402. "单日P10线": one_day_p10_line,
  403. "单日合并资格线": one_day_eligibility_line,
  404. "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
  405. "单日实际关停成本": lines[f"{STOP_RULE_ONE_DAY}_cost"],
  406. "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
  407. "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
  408. "单日实际关停线": lines[f"{STOP_RULE_ONE_DAY}_line"],
  409. "单日实际关停线分位点": lines[f"{STOP_RULE_ONE_DAY}_percentile"],
  410. }
  411. return selected_mask, candidate_rule, stats
  412. def apply_actions(
  413. summary: pd.DataFrame,
  414. thresholds: pd.DataFrame,
  415. config: RuleConfig,
  416. expected_dates: list[str],
  417. ) -> tuple[pd.DataFrame, pd.DataFrame]:
  418. result = summary.copy()
  419. result["动作"] = ""
  420. result["动作原因"] = ""
  421. thresholds = thresholds.copy()
  422. threshold_by_type = thresholds.set_index("entity_type")
  423. self_threshold = threshold_by_type.loc[ENTITY_SELF]
  424. gzh_threshold = threshold_by_type.loc[ENTITY_GZH]
  425. result["t_stop"] = np.nan
  426. result.loc[result["entity_type"].eq(ENTITY_GZH), "t_stop"] = float(
  427. gzh_threshold["t_stop"]
  428. )
  429. result["t_up"] = np.nan
  430. result.loc[
  431. result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), "t_up"
  432. ] = float(self_threshold["t_up"])
  433. result["t_one_day_stop"] = np.nan
  434. result["关停线分位点"] = np.nan
  435. threshold_eligible = threshold_eligibility_mask(result, config, expected_dates)
  436. ad_eligible = entity_eligibility_mask(
  437. result, ENTITY_SELF_AD, config, expected_dates
  438. )
  439. observe_only = observation_mask(result, config, expected_dates)
  440. one_day_supplement = one_day_supplement_mask(
  441. result, config, expected_dates
  442. )
  443. creative_eligible = entity_eligibility_mask(
  444. result, ENTITY_SELF, config, expected_dates
  445. )
  446. gzh_eligible = entity_eligibility_mask(
  447. result, ENTITY_GZH, config, expected_dates
  448. )
  449. self_sample_roi = result.loc[creative_eligible, "ROI"]
  450. gzh_sample_roi = result.loc[gzh_eligible, "ROI"]
  451. creative_sample_roi = result.loc[creative_eligible, "ROI"]
  452. result["整体三日ROI排名百分位"] = np.nan
  453. for mask, sample in (
  454. (result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), self_sample_roi),
  455. (result["entity_type"].eq(ENTITY_GZH), gzh_sample_roi),
  456. ):
  457. result.loc[mask, "整体三日ROI排名百分位"] = result.loc[mask, "ROI"].apply(
  458. lambda value: _sample_percentile(sample, float(value))
  459. if pd.notna(value)
  460. else np.nan
  461. )
  462. result["是否低于三日关停线"] = False
  463. result["创意三日ROI排名百分位"] = result["ROI"].apply(
  464. lambda value: _sample_percentile(creative_sample_roi, float(value))
  465. if pd.notna(value)
  466. else np.nan
  467. )
  468. result["是否位于创意三日ROI前20%"] = False
  469. if np.isfinite(float(self_threshold["t_up"])):
  470. result.loc[creative_eligible, "是否位于创意三日ROI前20%"] = (
  471. pd.to_numeric(result.loc[creative_eligible, "ROI"], errors="coerce")
  472. >= result.loc[creative_eligible, "t_up"]
  473. )
  474. (
  475. selected_stop,
  476. candidate_rule,
  477. budget_stats,
  478. ) = _allocate_self_stop_budget(
  479. result,
  480. config,
  481. expected_dates,
  482. creative_eligible,
  483. one_day_supplement,
  484. )
  485. self_row = thresholds["entity_type"].eq(ENTITY_SELF)
  486. threshold_updates = {
  487. **budget_stats,
  488. "t_stop": budget_stats["三日关停线"],
  489. "t_one_day_stop": budget_stats["单日实际关停线"],
  490. "关停线分位点": budget_stats["三日关停线分位点"],
  491. "单日实际关停线分位点": budget_stats["单日实际关停线分位点"],
  492. }
  493. for column, value in threshold_updates.items():
  494. thresholds.loc[self_row, column] = value
  495. latest = expected_dates[-1]
  496. result["昨日成本"] = np.where(
  497. result["entity_type"].eq(ENTITY_SELF),
  498. pd.to_numeric(result[f"成本_{latest}"], errors="coerce"),
  499. np.nan,
  500. )
  501. result["关停规则"] = candidate_rule
  502. result["关停预算选择状态"] = ""
  503. result.loc[candidate_rule.ne(""), "关停预算选择状态"] = "预算未选中"
  504. result.loc[selected_stop, "关停预算选择状态"] = "预算已选中"
  505. result["小程序昨日总成本"] = budget_stats["小程序昨日总成本"]
  506. result["小程序实际关停成本占比"] = budget_stats["实际关停成本占比"]
  507. formal_line = budget_stats["三日关停线"]
  508. formal_percentile = budget_stats["三日关停线分位点"]
  509. self_or_ad = result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
  510. result.loc[self_or_ad, "t_stop"] = formal_line
  511. result.loc[self_or_ad, "关停线分位点"] = formal_percentile
  512. one_day_rows = candidate_rule.eq(STOP_RULE_ONE_DAY)
  513. result.loc[one_day_rows, "t_stop"] = budget_stats["单日实际关停线"]
  514. result.loc[one_day_rows, "t_one_day_stop"] = budget_stats["单日实际关停线"]
  515. result.loc[one_day_rows, "关停线分位点"] = budget_stats[
  516. "单日实际关停线分位点"
  517. ]
  518. result.loc[selected_stop & creative_eligible, "是否低于三日关停线"] = True
  519. result.loc[gzh_eligible, "关停线分位点"] = float(
  520. gzh_threshold["关停线分位点"]
  521. )
  522. result.loc[gzh_eligible, "是否低于三日关停线"] = (
  523. pd.to_numeric(result.loc[gzh_eligible, "ROI"], errors="coerce")
  524. <= float(gzh_threshold["t_stop"])
  525. )
  526. for index, row in result.iterrows():
  527. entity_type = str(row["entity_type"])
  528. if bool(one_day_supplement.loc[index]):
  529. latest_uv = float(row[f"首层UV_{latest}"])
  530. latest_roi = float(row[f"ROI_{latest}"])
  531. three_day_roi = float(row["ROI"])
  532. raw_age = row.get("广告age")
  533. age = int(raw_age) if pd.notna(raw_age) else 0
  534. decision_context = (
  535. "未满足连续三天每天首层UV>200、成本>0且ROI有效,"
  536. "启用单日补充规则;"
  537. )
  538. three_day_context = (
  539. f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
  540. "不参与本次单日判断"
  541. )
  542. one_day_p10_line = float(budget_stats["单日P10线"])
  543. meets_absolute = latest_roi <= config.one_day_hard_stop_roi
  544. meets_p10 = (
  545. np.isfinite(one_day_p10_line)
  546. and latest_roi <= one_day_p10_line
  547. )
  548. qualifies = meets_absolute and meets_p10
  549. stop_reason = (
  550. f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
  551. f"最新日预测总效率ROI={latest_roi:.2f}同时满足ROI≤"
  552. f"{config.one_day_hard_stop_roi:.2f}和单日候选池P10线"
  553. f"{one_day_p10_line:.2f}"
  554. )
  555. if qualifies and age < config.self_stop_min_age:
  556. result.at[index, "动作"] = "观察"
  557. result.at[index, "动作原因"] = (
  558. f"{decision_context}{stop_reason}{three_day_context};"
  559. f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
  560. )
  561. elif bool(selected_stop.loc[index]):
  562. result.at[index, "动作"] = "关停"
  563. result.at[index, "动作原因"] = (
  564. f"{decision_context}{stop_reason}{three_day_context};"
  565. f"广告age={age}>{config.self_stop_min_age - 1}天,"
  566. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  567. f"按5%成本软预算选中({candidate_rule.loc[index]});"
  568. "建议审批后暂停动态创意"
  569. )
  570. elif qualifies:
  571. result.at[index, "动作"] = "观察"
  572. result.at[index, "动作原因"] = (
  573. f"{decision_context}{stop_reason}{three_day_context};"
  574. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  575. "满足低质候选条件但未被5%成本软预算选中;建议观察"
  576. )
  577. elif not meets_absolute:
  578. result.at[index, "动作"] = "观察"
  579. result.at[index, "动作原因"] = (
  580. f"{decision_context}最新日首层UV={latest_uv:.0f}>"
  581. f"{config.one_day_min_uv:g},最新日预测总效率ROI="
  582. f"{latest_roi:.2f}高于绝对线{config.one_day_hard_stop_roi:.2f},"
  583. f"不满足单日两个条件{three_day_context};建议继续观察"
  584. )
  585. elif not meets_p10:
  586. result.at[index, "动作"] = "观察"
  587. result.at[index, "动作原因"] = (
  588. f"{decision_context}最新日预测总效率ROI={latest_roi:.2f}≤"
  589. f"{config.one_day_hard_stop_roi:.2f},但高于单日候选池P10线"
  590. f"{one_day_p10_line:.2f}、未进入后10%{three_day_context};"
  591. "建议继续观察"
  592. )
  593. else:
  594. result.at[index, "动作"] = "观察"
  595. result.at[index, "动作原因"] = (
  596. f"{decision_context}单日候选池不足,无法计算P10线"
  597. f"{three_day_context};建议继续观察"
  598. )
  599. continue
  600. if bool(observe_only.loc[index]):
  601. result.at[index, "动作"] = "观察"
  602. result.at[index, "动作原因"] = (
  603. f"最新日首层UV>{config.observe_min_latest_uv:g},但未满足连续三天"
  604. "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入正式阈值和自动执行"
  605. )
  606. continue
  607. if bool(ad_eligible.loc[index]):
  608. if np.isfinite(float(row["t_stop"])) and float(row["ROI"]) <= float(
  609. row["t_stop"]
  610. ):
  611. raw_age = row.get("广告age")
  612. age = int(raw_age) if pd.notna(raw_age) else 0
  613. if age >= config.self_stop_min_age:
  614. result.at[index, "动作"] = "关停"
  615. result.at[index, "动作原因"] = (
  616. "广告级三日加权平均效率ROI≤小程序三日动态关停线,"
  617. f"广告age>{config.self_stop_min_age - 1}天;审批后暂停整个广告"
  618. )
  619. else:
  620. result.at[index, "动作"] = "观察"
  621. result.at[index, "动作原因"] = (
  622. "广告级三日加权平均效率ROI≤小程序三日动态关停线,但广告age≤"
  623. f"{config.self_stop_min_age - 1}天"
  624. )
  625. continue
  626. if not bool(threshold_eligible.loc[index]):
  627. continue
  628. if entity_type == ENTITY_SELF:
  629. raw_age = row.get("广告age")
  630. age = int(raw_age) if pd.notna(raw_age) else 0
  631. if bool(selected_stop.loc[index]):
  632. result.at[index, "动作"] = "关停"
  633. result.at[index, "动作原因"] = (
  634. "三日加权平均预测总效率ROI按从低到高排序,"
  635. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  636. "按5%成本软预算的60%基础额度或结余额度选中;"
  637. f"广告age>{config.self_stop_min_age - 1}天;审批后仅暂停动态创意"
  638. )
  639. elif (
  640. np.isfinite(float(row["t_stop"]))
  641. and float(row["ROI"]) <= float(row["t_stop"])
  642. and age < config.self_stop_min_age
  643. ):
  644. result.at[index, "动作"] = "观察"
  645. result.at[index, "动作原因"] = (
  646. "三日加权平均预测总效率ROI低于小程序动态关停线,但广告age≤"
  647. f"{config.self_stop_min_age - 1}天"
  648. )
  649. elif bool(row["是否位于创意三日ROI前20%"]):
  650. if age >= config.self_up_min_age:
  651. result.at[index, "动作"] = "扩量"
  652. result.at[index, "动作原因"] = (
  653. f"三日加权平均效率ROI≥合格创意实体等权P80,"
  654. f"广告age≥{config.self_up_min_age}天;审批后提高广告永久基础出价"
  655. )
  656. else:
  657. result.at[index, "动作"] = "观察"
  658. result.at[index, "动作原因"] = (
  659. f"三日加权平均效率ROI≥合格创意实体等权P80,但广告age<"
  660. f"{config.self_up_min_age}天"
  661. )
  662. elif (
  663. entity_type == ENTITY_GZH
  664. and np.isfinite(float(row["t_stop"]))
  665. and float(row["ROI"]) <= float(row["t_stop"])
  666. ):
  667. result.at[index, "动作"] = "关停"
  668. result.at[index, "动作原因"] = (
  669. "三日加权平均效率ROI≤公众号独立实体等权关停线;"
  670. "公众号当前仅通知参考"
  671. )
  672. return result, thresholds
  673. def evaluate_rules(
  674. raw_daily: pd.DataFrame,
  675. expected_dates: Iterable[str],
  676. ad_age: pd.DataFrame | None = None,
  677. config: RuleConfig = RuleConfig(),
  678. *,
  679. fission_parameters: FissionMultiplierParameters,
  680. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  681. summary, dates = compute_roi_summary(
  682. raw_daily,
  683. expected_dates,
  684. ad_age,
  685. fission_parameters=fission_parameters,
  686. )
  687. thresholds = compute_channel_thresholds(summary, dates, config)
  688. evaluated, thresholds = apply_actions(summary, thresholds, config, dates)
  689. formal_threshold = threshold_eligibility_mask(evaluated, config, dates)
  690. formal_ad = entity_eligibility_mask(evaluated, ENTITY_SELF_AD, config, dates)
  691. one_day_supplement = one_day_supplement_mask(evaluated, config, dates)
  692. observe_only = observation_mask(evaluated, config, dates) & ~one_day_supplement
  693. evaluated["阈值样本状态"] = "未达到三日正式样本门槛"
  694. evaluated.loc[formal_threshold, "阈值样本状态"] = (
  695. "进入三日渠道独立阈值样本池"
  696. )
  697. evaluated.loc[formal_ad, "阈值样本状态"] = "广告级三日合格_不进入阈值样本池"
  698. evaluated.loc[one_day_supplement, "阈值样本状态"] = (
  699. "单日补充决策_昨日UV>200"
  700. )
  701. evaluated.loc[observe_only, "阈值样本状态"] = "补充观察_昨日UV>200"
  702. candidates = evaluated[evaluated["动作"].ne("")].copy()
  703. return candidates, thresholds, evaluated