rules.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  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. 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. """Return formal creative-level samples for channel-independent lines."""
  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. # The normal selection stays within the 5.5% soft upper bound. If discrete
  344. # creative costs still leave the result below the 4.5% soft lower bound,
  345. # allow the next uninterrupted ROI prefix to approach 5%, but never exceed
  346. # the 6% absolute cap.
  347. if selected_cost < soft_lower_cost:
  348. for rule in (STOP_RULE_ONE_DAY, STOP_RULE_THREE_DAY):
  349. for index in ordered[rule]:
  350. if index in selected:
  351. continue
  352. candidate_cost = float(latest_cost.loc[index])
  353. proposed = selected_cost + candidate_cost
  354. if (
  355. proposed <= hard_cap_cost + 1e-9
  356. and abs(proposed - target_cost)
  357. < abs(selected_cost - target_cost)
  358. ):
  359. selected.add(index)
  360. selected_cost = proposed
  361. break
  362. if selected_cost > hard_cap_cost + 1e-9:
  363. raise ValueError("小程序关停候选成本超过绝对上限")
  364. selected_mask = pd.Series(result.index.isin(selected), index=result.index)
  365. candidate_rule = pd.Series("", index=result.index, dtype="object")
  366. for rule, mask in masks.items():
  367. candidate_rule.loc[mask] = rule
  368. lines: dict[str, float] = {}
  369. for rule in ordered:
  370. line, percentile = _selection_line(
  371. result,
  372. ordered[rule],
  373. selected,
  374. roi_columns[rule],
  375. )
  376. lines[f"{rule}_line"] = line
  377. lines[f"{rule}_percentile"] = percentile
  378. selected_indices = [index for index in ordered[rule] if index in selected]
  379. lines[f"{rule}_cost"] = (
  380. float(latest_cost.loc[selected_indices].sum())
  381. if selected_indices
  382. else 0.0
  383. )
  384. stats = {
  385. "小程序昨日总成本": total_cost,
  386. "软下限关停成本": soft_lower_cost,
  387. "目标关停成本": target_cost,
  388. "软上限关停成本": soft_upper_cost,
  389. "绝对上限关停成本": hard_cap_cost,
  390. "实际关停成本": selected_cost,
  391. "实际关停成本占比": selected_cost / total_cost if total_cost > 0 else 0.0,
  392. "关停成本预算状态": (
  393. "正常范围"
  394. if soft_lower_cost <= selected_cost <= soft_upper_cost
  395. else "低于软下限"
  396. if selected_cost < soft_lower_cost
  397. else "高于软上限"
  398. ),
  399. "三日基础预算成本": target_cost * config.self_stop_three_day_share,
  400. "单日基础预算成本": target_cost * config.self_stop_one_day_share,
  401. "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
  402. "单日候选池样本数": int(one_day_pool.sum()),
  403. "单日合并候选数": len(ordered[STOP_RULE_ONE_DAY]),
  404. "单日P10线": one_day_p10_line,
  405. "单日合并资格线": one_day_eligibility_line,
  406. "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
  407. "单日实际关停成本": lines[f"{STOP_RULE_ONE_DAY}_cost"],
  408. "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
  409. "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
  410. "单日实际关停线": lines[f"{STOP_RULE_ONE_DAY}_line"],
  411. "单日实际关停线分位点": lines[f"{STOP_RULE_ONE_DAY}_percentile"],
  412. }
  413. return selected_mask, candidate_rule, stats
  414. def apply_actions(
  415. summary: pd.DataFrame,
  416. thresholds: pd.DataFrame,
  417. config: RuleConfig,
  418. expected_dates: list[str],
  419. ) -> tuple[pd.DataFrame, pd.DataFrame]:
  420. result = summary.copy()
  421. result["动作"] = ""
  422. result["动作原因"] = ""
  423. thresholds = thresholds.copy()
  424. threshold_by_type = thresholds.set_index("entity_type")
  425. self_threshold = threshold_by_type.loc[ENTITY_SELF]
  426. gzh_threshold = threshold_by_type.loc[ENTITY_GZH]
  427. result["t_stop"] = np.nan
  428. result.loc[result["entity_type"].eq(ENTITY_GZH), "t_stop"] = float(
  429. gzh_threshold["t_stop"]
  430. )
  431. result["t_up"] = np.nan
  432. result.loc[
  433. result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), "t_up"
  434. ] = float(self_threshold["t_up"])
  435. result["t_one_day_stop"] = np.nan
  436. result["关停线分位点"] = np.nan
  437. threshold_eligible = threshold_eligibility_mask(result, config, expected_dates)
  438. ad_eligible = entity_eligibility_mask(
  439. result, ENTITY_SELF_AD, config, expected_dates
  440. )
  441. observe_only = observation_mask(result, config, expected_dates)
  442. one_day_supplement = one_day_supplement_mask(
  443. result, config, expected_dates
  444. )
  445. creative_eligible = entity_eligibility_mask(
  446. result, ENTITY_SELF, config, expected_dates
  447. )
  448. gzh_eligible = entity_eligibility_mask(
  449. result, ENTITY_GZH, config, expected_dates
  450. )
  451. self_sample_roi = result.loc[creative_eligible, "ROI"]
  452. gzh_sample_roi = result.loc[gzh_eligible, "ROI"]
  453. creative_sample_roi = result.loc[creative_eligible, "ROI"]
  454. result["整体三日ROI排名百分位"] = np.nan
  455. for mask, sample in (
  456. (result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), self_sample_roi),
  457. (result["entity_type"].eq(ENTITY_GZH), gzh_sample_roi),
  458. ):
  459. result.loc[mask, "整体三日ROI排名百分位"] = result.loc[mask, "ROI"].apply(
  460. lambda value: _sample_percentile(sample, float(value))
  461. if pd.notna(value)
  462. else np.nan
  463. )
  464. result["是否低于三日关停线"] = False
  465. result["创意三日ROI排名百分位"] = result["ROI"].apply(
  466. lambda value: _sample_percentile(creative_sample_roi, float(value))
  467. if pd.notna(value)
  468. else np.nan
  469. )
  470. result["是否位于创意三日ROI前20%"] = False
  471. if np.isfinite(float(self_threshold["t_up"])):
  472. result.loc[creative_eligible, "是否位于创意三日ROI前20%"] = (
  473. pd.to_numeric(result.loc[creative_eligible, "ROI"], errors="coerce")
  474. >= result.loc[creative_eligible, "t_up"]
  475. )
  476. (
  477. selected_stop,
  478. candidate_rule,
  479. budget_stats,
  480. ) = _allocate_self_stop_budget(
  481. result,
  482. config,
  483. expected_dates,
  484. creative_eligible,
  485. one_day_supplement,
  486. )
  487. self_row = thresholds["entity_type"].eq(ENTITY_SELF)
  488. threshold_updates = {
  489. **budget_stats,
  490. "t_stop": budget_stats["三日关停线"],
  491. "t_one_day_stop": budget_stats["单日实际关停线"],
  492. "关停线分位点": budget_stats["三日关停线分位点"],
  493. "单日实际关停线分位点": budget_stats["单日实际关停线分位点"],
  494. }
  495. for column, value in threshold_updates.items():
  496. thresholds.loc[self_row, column] = value
  497. latest = expected_dates[-1]
  498. result["昨日成本"] = np.where(
  499. result["entity_type"].eq(ENTITY_SELF),
  500. pd.to_numeric(result[f"成本_{latest}"], errors="coerce"),
  501. np.nan,
  502. )
  503. result["关停规则"] = candidate_rule
  504. result["关停预算选择状态"] = ""
  505. result.loc[candidate_rule.ne(""), "关停预算选择状态"] = "预算未选中"
  506. result.loc[selected_stop, "关停预算选择状态"] = "预算已选中"
  507. result["小程序昨日总成本"] = budget_stats["小程序昨日总成本"]
  508. result["小程序实际关停成本占比"] = budget_stats["实际关停成本占比"]
  509. formal_line = budget_stats["三日关停线"]
  510. formal_percentile = budget_stats["三日关停线分位点"]
  511. self_or_ad = result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
  512. result.loc[self_or_ad, "t_stop"] = formal_line
  513. result.loc[self_or_ad, "关停线分位点"] = formal_percentile
  514. one_day_rows = candidate_rule.eq(STOP_RULE_ONE_DAY)
  515. result.loc[one_day_rows, "t_stop"] = budget_stats["单日实际关停线"]
  516. result.loc[one_day_rows, "t_one_day_stop"] = budget_stats["单日实际关停线"]
  517. result.loc[one_day_rows, "关停线分位点"] = budget_stats[
  518. "单日实际关停线分位点"
  519. ]
  520. result.loc[selected_stop & creative_eligible, "是否低于三日关停线"] = True
  521. result.loc[gzh_eligible, "关停线分位点"] = float(
  522. gzh_threshold["关停线分位点"]
  523. )
  524. result.loc[gzh_eligible, "是否低于三日关停线"] = (
  525. pd.to_numeric(result.loc[gzh_eligible, "ROI"], errors="coerce")
  526. <= float(gzh_threshold["t_stop"])
  527. )
  528. for index, row in result.iterrows():
  529. entity_type = str(row["entity_type"])
  530. if bool(one_day_supplement.loc[index]):
  531. latest_uv = float(row[f"首层UV_{latest}"])
  532. latest_roi = float(row[f"ROI_{latest}"])
  533. three_day_roi = float(row["ROI"])
  534. raw_age = row.get("广告age")
  535. age = int(raw_age) if pd.notna(raw_age) else 0
  536. decision_context = (
  537. "未满足连续三天每天首层UV>200、成本>0且ROI有效,"
  538. "启用单日补充规则;"
  539. )
  540. three_day_context = (
  541. f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
  542. "不参与本次单日判断"
  543. )
  544. one_day_p10_line = float(budget_stats["单日P10线"])
  545. meets_absolute = latest_roi <= config.one_day_hard_stop_roi
  546. meets_p10 = (
  547. np.isfinite(one_day_p10_line)
  548. and latest_roi <= one_day_p10_line
  549. )
  550. qualifies = meets_absolute and meets_p10
  551. stop_reason = (
  552. f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
  553. f"最新日预测总效率ROI={latest_roi:.2f}同时满足ROI≤"
  554. f"{config.one_day_hard_stop_roi:.2f}和单日候选池P10线"
  555. f"{one_day_p10_line:.2f}"
  556. )
  557. if qualifies and age < config.self_stop_min_age:
  558. result.at[index, "动作"] = "观察"
  559. result.at[index, "动作原因"] = (
  560. f"{decision_context}{stop_reason}{three_day_context};"
  561. f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
  562. )
  563. elif bool(selected_stop.loc[index]):
  564. result.at[index, "动作"] = "关停"
  565. result.at[index, "动作原因"] = (
  566. f"{decision_context}{stop_reason}{three_day_context};"
  567. f"广告age={age}>{config.self_stop_min_age - 1}天,"
  568. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  569. f"按5%成本软预算选中({candidate_rule.loc[index]});"
  570. "建议审批后暂停动态创意"
  571. )
  572. elif qualifies:
  573. result.at[index, "动作"] = "观察"
  574. result.at[index, "动作原因"] = (
  575. f"{decision_context}{stop_reason}{three_day_context};"
  576. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  577. "满足低质候选条件但未被5%成本软预算选中;建议观察"
  578. )
  579. elif not meets_absolute:
  580. result.at[index, "动作"] = "观察"
  581. result.at[index, "动作原因"] = (
  582. f"{decision_context}最新日首层UV={latest_uv:.0f}>"
  583. f"{config.one_day_min_uv:g},最新日预测总效率ROI="
  584. f"{latest_roi:.2f}高于绝对线{config.one_day_hard_stop_roi:.2f},"
  585. f"不满足单日两个条件{three_day_context};建议继续观察"
  586. )
  587. elif not meets_p10:
  588. result.at[index, "动作"] = "观察"
  589. result.at[index, "动作原因"] = (
  590. f"{decision_context}最新日预测总效率ROI={latest_roi:.2f}≤"
  591. f"{config.one_day_hard_stop_roi:.2f},但高于单日候选池P10线"
  592. f"{one_day_p10_line:.2f}、未进入后10%{three_day_context};"
  593. "建议继续观察"
  594. )
  595. else:
  596. result.at[index, "动作"] = "观察"
  597. result.at[index, "动作原因"] = (
  598. f"{decision_context}单日候选池不足,无法计算P10线"
  599. f"{three_day_context};建议继续观察"
  600. )
  601. continue
  602. if bool(observe_only.loc[index]):
  603. result.at[index, "动作"] = "观察"
  604. result.at[index, "动作原因"] = (
  605. f"最新日首层UV>{config.observe_min_latest_uv:g},但未满足连续三天"
  606. "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入正式阈值和自动执行"
  607. )
  608. continue
  609. if bool(ad_eligible.loc[index]):
  610. if np.isfinite(float(row["t_stop"])) and float(row["ROI"]) <= float(
  611. row["t_stop"]
  612. ):
  613. raw_age = row.get("广告age")
  614. age = int(raw_age) if pd.notna(raw_age) else 0
  615. if age >= config.self_stop_min_age:
  616. result.at[index, "动作"] = "关停"
  617. result.at[index, "动作原因"] = (
  618. "广告级三日加权平均效率ROI≤小程序三日动态关停线,"
  619. f"广告age>{config.self_stop_min_age - 1}天;审批后暂停整个广告"
  620. )
  621. else:
  622. result.at[index, "动作"] = "观察"
  623. result.at[index, "动作原因"] = (
  624. "广告级三日加权平均效率ROI≤小程序三日动态关停线,但广告age≤"
  625. f"{config.self_stop_min_age - 1}天"
  626. )
  627. continue
  628. if not bool(threshold_eligible.loc[index]):
  629. continue
  630. if entity_type == ENTITY_SELF:
  631. raw_age = row.get("广告age")
  632. age = int(raw_age) if pd.notna(raw_age) else 0
  633. if bool(selected_stop.loc[index]):
  634. result.at[index, "动作"] = "关停"
  635. result.at[index, "动作原因"] = (
  636. "三日加权平均预测总效率ROI按从低到高排序,"
  637. f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
  638. "按5%成本软预算的60%基础额度或结余额度选中;"
  639. f"广告age>{config.self_stop_min_age - 1}天;审批后仅暂停动态创意"
  640. )
  641. elif (
  642. np.isfinite(float(row["t_stop"]))
  643. and float(row["ROI"]) <= float(row["t_stop"])
  644. and age < config.self_stop_min_age
  645. ):
  646. result.at[index, "动作"] = "观察"
  647. result.at[index, "动作原因"] = (
  648. "三日加权平均预测总效率ROI低于小程序动态关停线,但广告age≤"
  649. f"{config.self_stop_min_age - 1}天"
  650. )
  651. elif bool(row["是否位于创意三日ROI前20%"]):
  652. if age >= config.self_up_min_age:
  653. result.at[index, "动作"] = "扩量"
  654. result.at[index, "动作原因"] = (
  655. f"三日加权平均效率ROI≥合格创意实体等权P80,"
  656. f"广告age≥{config.self_up_min_age}天;审批后提高广告永久基础出价"
  657. )
  658. else:
  659. result.at[index, "动作"] = "观察"
  660. result.at[index, "动作原因"] = (
  661. f"三日加权平均效率ROI≥合格创意实体等权P80,但广告age<"
  662. f"{config.self_up_min_age}天"
  663. )
  664. elif (
  665. entity_type == ENTITY_GZH
  666. and np.isfinite(float(row["t_stop"]))
  667. and float(row["ROI"]) <= float(row["t_stop"])
  668. ):
  669. result.at[index, "动作"] = "关停"
  670. result.at[index, "动作原因"] = (
  671. "三日加权平均效率ROI≤公众号独立实体等权关停线;"
  672. "公众号当前仅通知参考"
  673. )
  674. return result, thresholds
  675. def evaluate_rules(
  676. raw_daily: pd.DataFrame,
  677. expected_dates: Iterable[str],
  678. ad_age: pd.DataFrame | None = None,
  679. config: RuleConfig = RuleConfig(),
  680. *,
  681. fission_parameters: FissionMultiplierParameters,
  682. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  683. summary, dates = compute_roi_summary(
  684. raw_daily,
  685. expected_dates,
  686. ad_age,
  687. fission_parameters=fission_parameters,
  688. )
  689. thresholds = compute_channel_thresholds(summary, dates, config)
  690. evaluated, thresholds = apply_actions(summary, thresholds, config, dates)
  691. formal_threshold = threshold_eligibility_mask(evaluated, config, dates)
  692. formal_ad = entity_eligibility_mask(evaluated, ENTITY_SELF_AD, config, dates)
  693. one_day_supplement = one_day_supplement_mask(evaluated, config, dates)
  694. observe_only = observation_mask(evaluated, config, dates) & ~one_day_supplement
  695. evaluated["阈值样本状态"] = "未达到三日正式样本门槛"
  696. evaluated.loc[formal_threshold, "阈值样本状态"] = (
  697. "进入三日渠道独立阈值样本池"
  698. )
  699. evaluated.loc[formal_ad, "阈值样本状态"] = "广告级三日合格_不进入阈值样本池"
  700. evaluated.loc[one_day_supplement, "阈值样本状态"] = (
  701. "单日补充决策_昨日UV>200"
  702. )
  703. evaluated.loc[observe_only, "阈值样本状态"] = "补充观察_昨日UV>200"
  704. candidates = evaluated[evaluated["动作"].ne("")].copy()
  705. return candidates, thresholds, evaluated