|
|
@@ -13,12 +13,18 @@ from .metrics import (
|
|
|
ENTITY_GZH,
|
|
|
ENTITY_SELF,
|
|
|
ENTITY_SELF_AD,
|
|
|
+ GZH_CHANNEL,
|
|
|
+ SELF_CHANNEL,
|
|
|
compute_roi_summary,
|
|
|
)
|
|
|
|
|
|
|
|
|
-POLICY_VERSION = "roi_policy_v12"
|
|
|
-POLICY_RUN_SUFFIX = "p12"
|
|
|
+POLICY_VERSION = "roi_policy_v13"
|
|
|
+POLICY_RUN_SUFFIX = "p13"
|
|
|
+
|
|
|
+STOP_RULE_THREE_DAY = "三日持续低ROI"
|
|
|
+STOP_RULE_ONE_DAY_HARD = "单日绝对低ROI"
|
|
|
+STOP_RULE_ONE_DAY_HIGH_UV = "单日高UV低分位"
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
@@ -29,9 +35,15 @@ class RuleConfig:
|
|
|
partner_min_daily_uv: float = 200
|
|
|
observe_min_latest_uv: float = 200
|
|
|
one_day_min_uv: float = 200
|
|
|
- one_day_p30_min_uv: float = 500
|
|
|
+ one_day_high_uv_min_uv: float = 500
|
|
|
one_day_hard_stop_roi: float = 0.20
|
|
|
- one_day_stop_quantile: float = 0.30
|
|
|
+ self_stop_cost_soft_lower_ratio: float = 0.045
|
|
|
+ self_stop_cost_target_ratio: float = 0.05
|
|
|
+ self_stop_cost_soft_upper_ratio: float = 0.055
|
|
|
+ self_stop_cost_hard_cap_ratio: float = 0.06
|
|
|
+ self_stop_three_day_share: float = 0.60
|
|
|
+ self_stop_one_day_hard_share: float = 0.20
|
|
|
+ self_stop_one_day_high_uv_share: float = 0.20
|
|
|
stop_quantile: float = 0.20
|
|
|
up_quantile: float = 0.80
|
|
|
|
|
|
@@ -72,7 +84,7 @@ def threshold_eligibility_mask(
|
|
|
config: RuleConfig,
|
|
|
expected_dates: list[str],
|
|
|
) -> pd.Series:
|
|
|
- """Only creative-level miniapp and official accounts enter global P20."""
|
|
|
+ """Return formal creative-level samples for channel-independent lines."""
|
|
|
|
|
|
return entity_eligibility_mask(
|
|
|
summary, ENTITY_SELF, config, expected_dates
|
|
|
@@ -105,7 +117,7 @@ def observation_mask(
|
|
|
)
|
|
|
|
|
|
|
|
|
-def one_day_p30_pool_mask(
|
|
|
+def one_day_high_uv_pool_mask(
|
|
|
summary: pd.DataFrame,
|
|
|
config: RuleConfig,
|
|
|
expected_dates: list[str],
|
|
|
@@ -115,7 +127,7 @@ def one_day_p30_pool_mask(
|
|
|
return (
|
|
|
summary["entity_type"].eq(ENTITY_SELF)
|
|
|
& pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
|
|
|
- config.one_day_p30_min_uv
|
|
|
+ config.one_day_high_uv_min_uv
|
|
|
)
|
|
|
& pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
|
|
|
& np.isfinite(latest_roi)
|
|
|
@@ -143,61 +155,90 @@ def one_day_supplement_mask(
|
|
|
)
|
|
|
|
|
|
|
|
|
-def compute_global_threshold(
|
|
|
+def compute_channel_thresholds(
|
|
|
summary: pd.DataFrame,
|
|
|
expected_dates: list[str],
|
|
|
config: RuleConfig,
|
|
|
) -> pd.DataFrame:
|
|
|
- eligible = threshold_eligibility_mask(summary, config, expected_dates)
|
|
|
- sample = pd.to_numeric(summary.loc[eligible, "ROI"], errors="coerce")
|
|
|
- sample = sample[np.isfinite(sample)]
|
|
|
- if sample.empty:
|
|
|
- raise ValueError("最近三日没有满足每日UV和成本门槛的统一阈值样本")
|
|
|
- creative_eligible = entity_eligibility_mask(
|
|
|
+ self_eligible = entity_eligibility_mask(
|
|
|
summary, ENTITY_SELF, config, expected_dates
|
|
|
)
|
|
|
- creative_sample = pd.to_numeric(
|
|
|
- summary.loc[creative_eligible, "ROI"], errors="coerce"
|
|
|
+ gzh_eligible = entity_eligibility_mask(
|
|
|
+ summary, ENTITY_GZH, config, expected_dates
|
|
|
+ )
|
|
|
+ self_sample = pd.to_numeric(
|
|
|
+ summary.loc[self_eligible, "ROI"], errors="coerce"
|
|
|
)
|
|
|
- creative_sample = creative_sample[np.isfinite(creative_sample)]
|
|
|
+ self_sample = self_sample[np.isfinite(self_sample)]
|
|
|
+ gzh_sample = pd.to_numeric(
|
|
|
+ summary.loc[gzh_eligible, "ROI"], errors="coerce"
|
|
|
+ )
|
|
|
+ gzh_sample = gzh_sample[np.isfinite(gzh_sample)]
|
|
|
+ if self_sample.empty and gzh_sample.empty:
|
|
|
+ raise ValueError("最近三日没有满足每日UV和成本门槛的渠道独立阈值样本")
|
|
|
+
|
|
|
t_up = (
|
|
|
- float(creative_sample.quantile(config.up_quantile))
|
|
|
- if not creative_sample.empty
|
|
|
+ float(self_sample.quantile(config.up_quantile))
|
|
|
+ if not self_sample.empty
|
|
|
else np.nan
|
|
|
)
|
|
|
- latest = expected_dates[-1]
|
|
|
- one_day_pool = pd.to_numeric(
|
|
|
- summary.loc[
|
|
|
- one_day_p30_pool_mask(summary, config, expected_dates),
|
|
|
- f"ROI_{latest}",
|
|
|
- ],
|
|
|
- errors="coerce",
|
|
|
- )
|
|
|
- one_day_pool = one_day_pool[np.isfinite(one_day_pool)]
|
|
|
- t_one_day_stop = (
|
|
|
- float(one_day_pool.quantile(config.one_day_stop_quantile))
|
|
|
- if not one_day_pool.empty
|
|
|
+ gzh_stop = (
|
|
|
+ float(gzh_sample.quantile(config.stop_quantile))
|
|
|
+ if not gzh_sample.empty
|
|
|
else np.nan
|
|
|
)
|
|
|
+ latest = expected_dates[-1]
|
|
|
+ high_uv_pool = one_day_high_uv_pool_mask(summary, config, expected_dates)
|
|
|
return pd.DataFrame(
|
|
|
[
|
|
|
{
|
|
|
- "统计窗口": f"{expected_dates[0]} 至 {expected_dates[-1]}",
|
|
|
- "entity_type": "global",
|
|
|
- "渠道": "小程序创意级+公众号",
|
|
|
- "t_stop": float(sample.quantile(config.stop_quantile)),
|
|
|
+ "统计窗口": f"{expected_dates[0]} 至 {latest}",
|
|
|
+ "entity_type": ENTITY_SELF,
|
|
|
+ "渠道": SELF_CHANNEL,
|
|
|
+ "t_stop": np.nan,
|
|
|
"t_up": t_up,
|
|
|
- "t_one_day_stop": t_one_day_stop,
|
|
|
- "阈值样本数": int(len(sample)),
|
|
|
- "扩量样本数": int(len(creative_sample)),
|
|
|
- "单日P30样本数": int(len(one_day_pool)),
|
|
|
+ "t_one_day_hard_stop": np.nan,
|
|
|
+ "t_one_day_stop": np.nan,
|
|
|
+ "关停线分位点": np.nan,
|
|
|
+ "单日绝对低ROI关停线分位点": np.nan,
|
|
|
+ "单日高UV关停线分位点": np.nan,
|
|
|
+ "阈值样本数": int(len(self_sample)),
|
|
|
+ "扩量样本数": int(len(self_sample)),
|
|
|
+ "单日高UV样本数": int(high_uv_pool.sum()),
|
|
|
"单日硬关停ROI": config.one_day_hard_stop_roi,
|
|
|
"单日最低UV": config.one_day_min_uv,
|
|
|
- "单日P30最低UV": config.one_day_p30_min_uv,
|
|
|
- "关停线口径": f"合格实体等权P{int(config.stop_quantile * 100)}",
|
|
|
- "扩量线口径": f"合格小程序创意实体等权P{int(config.up_quantile * 100)}",
|
|
|
- "广告级是否入池": "否_仅复用统一关停线",
|
|
|
- }
|
|
|
+ "单日高UV最低UV": config.one_day_high_uv_min_uv,
|
|
|
+ "关停线口径": "T-1实际成本5%软预算动态线",
|
|
|
+ "扩量线口径": (
|
|
|
+ f"合格小程序创意实体等权P{int(config.up_quantile * 100)}"
|
|
|
+ ),
|
|
|
+ "广告级是否入池": "否_仅复用小程序三日动态关停线",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "统计窗口": f"{expected_dates[0]} 至 {latest}",
|
|
|
+ "entity_type": ENTITY_GZH,
|
|
|
+ "渠道": GZH_CHANNEL,
|
|
|
+ "t_stop": gzh_stop,
|
|
|
+ "t_up": np.nan,
|
|
|
+ "t_one_day_hard_stop": np.nan,
|
|
|
+ "t_one_day_stop": np.nan,
|
|
|
+ "关停线分位点": (
|
|
|
+ config.stop_quantile if np.isfinite(gzh_stop) else np.nan
|
|
|
+ ),
|
|
|
+ "单日绝对低ROI关停线分位点": np.nan,
|
|
|
+ "单日高UV关停线分位点": np.nan,
|
|
|
+ "阈值样本数": int(len(gzh_sample)),
|
|
|
+ "扩量样本数": 0,
|
|
|
+ "单日高UV样本数": 0,
|
|
|
+ "单日硬关停ROI": np.nan,
|
|
|
+ "单日最低UV": np.nan,
|
|
|
+ "单日高UV最低UV": np.nan,
|
|
|
+ "关停线口径": (
|
|
|
+ f"公众号合格实体等权P{int(config.stop_quantile * 100)}"
|
|
|
+ ),
|
|
|
+ "扩量线口径": "不适用",
|
|
|
+ "广告级是否入池": "不适用",
|
|
|
+ },
|
|
|
]
|
|
|
)
|
|
|
|
|
|
@@ -210,20 +251,268 @@ def _sample_percentile(values: pd.Series, target: float) -> float:
|
|
|
return float((clean <= target).mean())
|
|
|
|
|
|
|
|
|
+def _ordered_candidate_indices(
|
|
|
+ result: pd.DataFrame,
|
|
|
+ mask: pd.Series,
|
|
|
+ roi_column: str,
|
|
|
+ cost_column: str,
|
|
|
+) -> list[object]:
|
|
|
+ roi = pd.to_numeric(result[roi_column], errors="coerce")
|
|
|
+ cost = pd.to_numeric(result[cost_column], errors="coerce")
|
|
|
+ eligible = mask & np.isfinite(roi) & cost.gt(0)
|
|
|
+ return result.loc[eligible].assign(_decision_roi=roi.loc[eligible]).sort_values(
|
|
|
+ "_decision_roi",
|
|
|
+ ascending=True,
|
|
|
+ kind="stable",
|
|
|
+ ).index.tolist()
|
|
|
+
|
|
|
+
|
|
|
+def _select_prefix_within_budget(
|
|
|
+ result: pd.DataFrame,
|
|
|
+ ordered: list[object],
|
|
|
+ cost_column: str,
|
|
|
+ budget: float,
|
|
|
+) -> list[object]:
|
|
|
+ selected: list[object] = []
|
|
|
+ cost = 0.0
|
|
|
+ for index in ordered:
|
|
|
+ candidate_cost = float(result.at[index, cost_column])
|
|
|
+ if cost + candidate_cost > budget + 1e-9:
|
|
|
+ break
|
|
|
+ selected.append(index)
|
|
|
+ cost += candidate_cost
|
|
|
+ return selected
|
|
|
+
|
|
|
+
|
|
|
+def _selection_line(
|
|
|
+ result: pd.DataFrame,
|
|
|
+ ordered: list[object],
|
|
|
+ selected: set[object],
|
|
|
+ roi_column: str,
|
|
|
+) -> tuple[float, float]:
|
|
|
+ selected_indices = [index for index in ordered if index in selected]
|
|
|
+ if not selected_indices:
|
|
|
+ return np.nan, np.nan
|
|
|
+ line = float(
|
|
|
+ pd.to_numeric(
|
|
|
+ result.loc[selected_indices, roi_column], errors="coerce"
|
|
|
+ ).max()
|
|
|
+ )
|
|
|
+ sample = pd.to_numeric(result.loc[ordered, roi_column], errors="coerce")
|
|
|
+ return line, _sample_percentile(sample, line)
|
|
|
+
|
|
|
+
|
|
|
+def _allocate_self_stop_budget(
|
|
|
+ result: pd.DataFrame,
|
|
|
+ config: RuleConfig,
|
|
|
+ expected_dates: list[str],
|
|
|
+ formal_creative: pd.Series,
|
|
|
+ one_day_supplement: pd.Series,
|
|
|
+) -> tuple[pd.Series, pd.Series, dict[str, float | str]]:
|
|
|
+ latest = expected_dates[-1]
|
|
|
+ cost_column = f"成本_{latest}"
|
|
|
+ latest_roi_column = f"ROI_{latest}"
|
|
|
+ latest_cost = pd.to_numeric(result[cost_column], errors="coerce").fillna(0.0)
|
|
|
+ latest_roi = pd.to_numeric(result[latest_roi_column], errors="coerce")
|
|
|
+ latest_uv = pd.to_numeric(result[f"首层UV_{latest}"], errors="coerce")
|
|
|
+ age = pd.to_numeric(result.get("广告age"), errors="coerce").fillna(0)
|
|
|
+ actionable_age = age.ge(config.self_stop_min_age)
|
|
|
+
|
|
|
+ three_day_mask = formal_creative & actionable_age
|
|
|
+ one_day_hard_mask = (
|
|
|
+ one_day_supplement
|
|
|
+ & actionable_age
|
|
|
+ & latest_roi.le(config.one_day_hard_stop_roi)
|
|
|
+ )
|
|
|
+ one_day_high_uv_mask = (
|
|
|
+ one_day_supplement
|
|
|
+ & actionable_age
|
|
|
+ & latest_uv.gt(config.one_day_high_uv_min_uv)
|
|
|
+ & latest_roi.gt(config.one_day_hard_stop_roi)
|
|
|
+ )
|
|
|
+ masks = {
|
|
|
+ STOP_RULE_THREE_DAY: three_day_mask,
|
|
|
+ STOP_RULE_ONE_DAY_HARD: one_day_hard_mask,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV: one_day_high_uv_mask,
|
|
|
+ }
|
|
|
+ roi_columns = {
|
|
|
+ STOP_RULE_THREE_DAY: "ROI",
|
|
|
+ STOP_RULE_ONE_DAY_HARD: latest_roi_column,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV: latest_roi_column,
|
|
|
+ }
|
|
|
+ shares = {
|
|
|
+ STOP_RULE_THREE_DAY: config.self_stop_three_day_share,
|
|
|
+ STOP_RULE_ONE_DAY_HARD: config.self_stop_one_day_hard_share,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV: config.self_stop_one_day_high_uv_share,
|
|
|
+ }
|
|
|
+ all_self = result["entity_type"].eq(ENTITY_SELF)
|
|
|
+ total_cost = float(latest_cost.loc[all_self & latest_cost.gt(0)].sum())
|
|
|
+ target_cost = total_cost * config.self_stop_cost_target_ratio
|
|
|
+ soft_lower_cost = total_cost * config.self_stop_cost_soft_lower_ratio
|
|
|
+ soft_upper_cost = total_cost * config.self_stop_cost_soft_upper_ratio
|
|
|
+ hard_cap_cost = total_cost * config.self_stop_cost_hard_cap_ratio
|
|
|
+
|
|
|
+ ordered = {
|
|
|
+ rule: _ordered_candidate_indices(
|
|
|
+ result,
|
|
|
+ mask,
|
|
|
+ roi_columns[rule],
|
|
|
+ cost_column,
|
|
|
+ )
|
|
|
+ for rule, mask in masks.items()
|
|
|
+ }
|
|
|
+ selected: set[object] = set()
|
|
|
+ for rule in (
|
|
|
+ STOP_RULE_THREE_DAY,
|
|
|
+ STOP_RULE_ONE_DAY_HARD,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV,
|
|
|
+ ):
|
|
|
+ selected.update(
|
|
|
+ _select_prefix_within_budget(
|
|
|
+ result,
|
|
|
+ ordered[rule],
|
|
|
+ cost_column,
|
|
|
+ target_cost * shares[rule],
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ selected_cost = float(latest_cost.loc[list(selected)].sum()) if selected else 0.0
|
|
|
+ for rule in (
|
|
|
+ STOP_RULE_ONE_DAY_HARD,
|
|
|
+ STOP_RULE_THREE_DAY,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV,
|
|
|
+ ):
|
|
|
+ for index in ordered[rule]:
|
|
|
+ if index in selected:
|
|
|
+ continue
|
|
|
+ candidate_cost = float(latest_cost.loc[index])
|
|
|
+ proposed = selected_cost + candidate_cost
|
|
|
+ if proposed <= target_cost + 1e-9:
|
|
|
+ selected.add(index)
|
|
|
+ selected_cost = proposed
|
|
|
+ continue
|
|
|
+ if (
|
|
|
+ proposed <= soft_upper_cost + 1e-9
|
|
|
+ and abs(proposed - target_cost) < abs(selected_cost - target_cost)
|
|
|
+ ):
|
|
|
+ selected.add(index)
|
|
|
+ selected_cost = proposed
|
|
|
+ break
|
|
|
+
|
|
|
+ # The normal selection stays within the 5.5% soft upper bound. If discrete
|
|
|
+ # creative costs still leave the result below the 4.5% soft lower bound,
|
|
|
+ # allow the next uninterrupted ROI prefix to approach 5%, but never exceed
|
|
|
+ # the 6% absolute cap.
|
|
|
+ if selected_cost < soft_lower_cost:
|
|
|
+ for rule in (
|
|
|
+ STOP_RULE_ONE_DAY_HARD,
|
|
|
+ STOP_RULE_THREE_DAY,
|
|
|
+ STOP_RULE_ONE_DAY_HIGH_UV,
|
|
|
+ ):
|
|
|
+ for index in ordered[rule]:
|
|
|
+ if index in selected:
|
|
|
+ continue
|
|
|
+ candidate_cost = float(latest_cost.loc[index])
|
|
|
+ proposed = selected_cost + candidate_cost
|
|
|
+ if (
|
|
|
+ proposed <= hard_cap_cost + 1e-9
|
|
|
+ and abs(proposed - target_cost)
|
|
|
+ < abs(selected_cost - target_cost)
|
|
|
+ ):
|
|
|
+ selected.add(index)
|
|
|
+ selected_cost = proposed
|
|
|
+ break
|
|
|
+
|
|
|
+ if selected_cost > hard_cap_cost + 1e-9:
|
|
|
+ raise ValueError("小程序关停候选成本超过绝对上限")
|
|
|
+
|
|
|
+ selected_mask = pd.Series(result.index.isin(selected), index=result.index)
|
|
|
+ candidate_rule = pd.Series("", index=result.index, dtype="object")
|
|
|
+ for rule, mask in masks.items():
|
|
|
+ candidate_rule.loc[mask] = rule
|
|
|
+
|
|
|
+ lines: dict[str, float] = {}
|
|
|
+ for rule in ordered:
|
|
|
+ line, percentile = _selection_line(
|
|
|
+ result,
|
|
|
+ ordered[rule],
|
|
|
+ selected,
|
|
|
+ roi_columns[rule],
|
|
|
+ )
|
|
|
+ lines[f"{rule}_line"] = line
|
|
|
+ lines[f"{rule}_percentile"] = percentile
|
|
|
+ selected_indices = [index for index in ordered[rule] if index in selected]
|
|
|
+ lines[f"{rule}_cost"] = (
|
|
|
+ float(latest_cost.loc[selected_indices].sum())
|
|
|
+ if selected_indices
|
|
|
+ else 0.0
|
|
|
+ )
|
|
|
+
|
|
|
+ stats = {
|
|
|
+ "小程序昨日总成本": total_cost,
|
|
|
+ "软下限关停成本": soft_lower_cost,
|
|
|
+ "目标关停成本": target_cost,
|
|
|
+ "软上限关停成本": soft_upper_cost,
|
|
|
+ "绝对上限关停成本": hard_cap_cost,
|
|
|
+ "实际关停成本": selected_cost,
|
|
|
+ "实际关停成本占比": selected_cost / total_cost if total_cost > 0 else 0.0,
|
|
|
+ "关停成本预算状态": (
|
|
|
+ "正常范围"
|
|
|
+ if soft_lower_cost <= selected_cost <= soft_upper_cost
|
|
|
+ else "低于软下限"
|
|
|
+ if selected_cost < soft_lower_cost
|
|
|
+ else "高于软上限"
|
|
|
+ ),
|
|
|
+ "三日基础预算成本": target_cost * config.self_stop_three_day_share,
|
|
|
+ "单日绝对低ROI基础预算成本": (
|
|
|
+ target_cost * config.self_stop_one_day_hard_share
|
|
|
+ ),
|
|
|
+ "单日高UV基础预算成本": (
|
|
|
+ target_cost * config.self_stop_one_day_high_uv_share
|
|
|
+ ),
|
|
|
+ "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
|
|
|
+ "单日绝对低ROI候选数": len(ordered[STOP_RULE_ONE_DAY_HARD]),
|
|
|
+ "单日高UV候选数": len(ordered[STOP_RULE_ONE_DAY_HIGH_UV]),
|
|
|
+ "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
|
|
|
+ "单日绝对低ROI实际关停成本": lines[f"{STOP_RULE_ONE_DAY_HARD}_cost"],
|
|
|
+ "单日高UV实际关停成本": lines[f"{STOP_RULE_ONE_DAY_HIGH_UV}_cost"],
|
|
|
+ "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
|
|
|
+ "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
|
|
|
+ "单日绝对低ROI关停线": lines[f"{STOP_RULE_ONE_DAY_HARD}_line"],
|
|
|
+ "单日绝对低ROI关停线分位点": lines[
|
|
|
+ f"{STOP_RULE_ONE_DAY_HARD}_percentile"
|
|
|
+ ],
|
|
|
+ "单日高UV关停线": lines[f"{STOP_RULE_ONE_DAY_HIGH_UV}_line"],
|
|
|
+ "单日高UV关停线分位点": lines[
|
|
|
+ f"{STOP_RULE_ONE_DAY_HIGH_UV}_percentile"
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ return selected_mask, candidate_rule, stats
|
|
|
+
|
|
|
+
|
|
|
def apply_actions(
|
|
|
summary: pd.DataFrame,
|
|
|
thresholds: pd.DataFrame,
|
|
|
config: RuleConfig,
|
|
|
expected_dates: list[str],
|
|
|
-) -> pd.DataFrame:
|
|
|
+) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
|
result = summary.copy()
|
|
|
result["动作"] = ""
|
|
|
result["动作原因"] = ""
|
|
|
- result["t_stop"] = float(thresholds.iloc[0]["t_stop"])
|
|
|
- result["t_up"] = float(thresholds.iloc[0]["t_up"])
|
|
|
- result["t_one_day_stop"] = float(
|
|
|
- thresholds.iloc[0]["t_one_day_stop"]
|
|
|
+ thresholds = thresholds.copy()
|
|
|
+ threshold_by_type = thresholds.set_index("entity_type")
|
|
|
+ self_threshold = threshold_by_type.loc[ENTITY_SELF]
|
|
|
+ gzh_threshold = threshold_by_type.loc[ENTITY_GZH]
|
|
|
+ result["t_stop"] = np.nan
|
|
|
+ result.loc[result["entity_type"].eq(ENTITY_GZH), "t_stop"] = float(
|
|
|
+ gzh_threshold["t_stop"]
|
|
|
)
|
|
|
+ result["t_up"] = np.nan
|
|
|
+ result.loc[
|
|
|
+ result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), "t_up"
|
|
|
+ ] = float(self_threshold["t_up"])
|
|
|
+ result["t_one_day_stop"] = np.nan
|
|
|
+ result["关停线分位点"] = np.nan
|
|
|
|
|
|
threshold_eligible = threshold_eligibility_mask(result, config, expected_dates)
|
|
|
ad_eligible = entity_eligibility_mask(
|
|
|
@@ -233,38 +522,102 @@ def apply_actions(
|
|
|
one_day_supplement = one_day_supplement_mask(
|
|
|
result, config, expected_dates
|
|
|
)
|
|
|
- sample_roi = result.loc[threshold_eligible, "ROI"]
|
|
|
creative_eligible = entity_eligibility_mask(
|
|
|
result, ENTITY_SELF, config, expected_dates
|
|
|
)
|
|
|
- creative_sample_roi = result.loc[creative_eligible, "ROI"]
|
|
|
- result["整体三日ROI排名百分位"] = result["ROI"].apply(
|
|
|
- lambda value: _sample_percentile(sample_roi, float(value))
|
|
|
- if pd.notna(value)
|
|
|
- else np.nan
|
|
|
+ gzh_eligible = entity_eligibility_mask(
|
|
|
+ result, ENTITY_GZH, config, expected_dates
|
|
|
)
|
|
|
- result["是否位于三日ROI后20%"] = False
|
|
|
+ self_sample_roi = result.loc[creative_eligible, "ROI"]
|
|
|
+ gzh_sample_roi = result.loc[gzh_eligible, "ROI"]
|
|
|
+ creative_sample_roi = result.loc[creative_eligible, "ROI"]
|
|
|
+ result["整体三日ROI排名百分位"] = np.nan
|
|
|
+ for mask, sample in (
|
|
|
+ (result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD]), self_sample_roi),
|
|
|
+ (result["entity_type"].eq(ENTITY_GZH), gzh_sample_roi),
|
|
|
+ ):
|
|
|
+ result.loc[mask, "整体三日ROI排名百分位"] = result.loc[mask, "ROI"].apply(
|
|
|
+ lambda value: _sample_percentile(sample, float(value))
|
|
|
+ if pd.notna(value)
|
|
|
+ else np.nan
|
|
|
+ )
|
|
|
+ result["是否低于三日关停线"] = False
|
|
|
result["创意三日ROI排名百分位"] = result["ROI"].apply(
|
|
|
lambda value: _sample_percentile(creative_sample_roi, float(value))
|
|
|
if pd.notna(value)
|
|
|
else np.nan
|
|
|
)
|
|
|
result["是否位于创意三日ROI前20%"] = False
|
|
|
- comparable = threshold_eligible | ad_eligible
|
|
|
- result.loc[comparable, "是否位于三日ROI后20%"] = (
|
|
|
- pd.to_numeric(result.loc[comparable, "ROI"], errors="coerce")
|
|
|
- <= result.loc[comparable, "t_stop"]
|
|
|
- )
|
|
|
- if np.isfinite(float(result["t_up"].iloc[0])):
|
|
|
+ if np.isfinite(float(self_threshold["t_up"])):
|
|
|
result.loc[creative_eligible, "是否位于创意三日ROI前20%"] = (
|
|
|
pd.to_numeric(result.loc[creative_eligible, "ROI"], errors="coerce")
|
|
|
>= result.loc[creative_eligible, "t_up"]
|
|
|
)
|
|
|
|
|
|
+ selected_stop, candidate_rule, budget_stats = _allocate_self_stop_budget(
|
|
|
+ result,
|
|
|
+ config,
|
|
|
+ expected_dates,
|
|
|
+ creative_eligible,
|
|
|
+ one_day_supplement,
|
|
|
+ )
|
|
|
+ self_row = thresholds["entity_type"].eq(ENTITY_SELF)
|
|
|
+ threshold_updates = {
|
|
|
+ **budget_stats,
|
|
|
+ "t_stop": budget_stats["三日关停线"],
|
|
|
+ "t_one_day_hard_stop": budget_stats["单日绝对低ROI关停线"],
|
|
|
+ "t_one_day_stop": budget_stats["单日高UV关停线"],
|
|
|
+ "关停线分位点": budget_stats["三日关停线分位点"],
|
|
|
+ "单日绝对低ROI关停线分位点": budget_stats[
|
|
|
+ "单日绝对低ROI关停线分位点"
|
|
|
+ ],
|
|
|
+ "单日高UV关停线分位点": budget_stats["单日高UV关停线分位点"],
|
|
|
+ }
|
|
|
+ for column, value in threshold_updates.items():
|
|
|
+ thresholds.loc[self_row, column] = value
|
|
|
+
|
|
|
+ latest = expected_dates[-1]
|
|
|
+ result["昨日成本"] = np.where(
|
|
|
+ result["entity_type"].eq(ENTITY_SELF),
|
|
|
+ pd.to_numeric(result[f"成本_{latest}"], errors="coerce"),
|
|
|
+ np.nan,
|
|
|
+ )
|
|
|
+ result["关停规则"] = candidate_rule
|
|
|
+ result["关停预算选择状态"] = ""
|
|
|
+ result.loc[candidate_rule.ne(""), "关停预算选择状态"] = "预算未选中"
|
|
|
+ result.loc[selected_stop, "关停预算选择状态"] = "预算已选中"
|
|
|
+ result["小程序昨日总成本"] = budget_stats["小程序昨日总成本"]
|
|
|
+ result["小程序实际关停成本占比"] = budget_stats["实际关停成本占比"]
|
|
|
+ formal_line = budget_stats["三日关停线"]
|
|
|
+ formal_percentile = budget_stats["三日关停线分位点"]
|
|
|
+ self_or_ad = result["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD])
|
|
|
+ result.loc[self_or_ad, "t_stop"] = formal_line
|
|
|
+ result.loc[self_or_ad, "关停线分位点"] = formal_percentile
|
|
|
+ hard_rows = candidate_rule.eq(STOP_RULE_ONE_DAY_HARD)
|
|
|
+ high_uv_rows = candidate_rule.eq(STOP_RULE_ONE_DAY_HIGH_UV)
|
|
|
+ result.loc[hard_rows, "t_stop"] = budget_stats["单日绝对低ROI关停线"]
|
|
|
+ result.loc[hard_rows, "关停线分位点"] = budget_stats[
|
|
|
+ "单日绝对低ROI关停线分位点"
|
|
|
+ ]
|
|
|
+ result.loc[high_uv_rows, "t_stop"] = budget_stats["单日高UV关停线"]
|
|
|
+ result.loc[high_uv_rows, "t_one_day_stop"] = budget_stats[
|
|
|
+ "单日高UV关停线"
|
|
|
+ ]
|
|
|
+ result.loc[high_uv_rows, "关停线分位点"] = budget_stats[
|
|
|
+ "单日高UV关停线分位点"
|
|
|
+ ]
|
|
|
+ result.loc[selected_stop & creative_eligible, "是否低于三日关停线"] = True
|
|
|
+ result.loc[gzh_eligible, "关停线分位点"] = float(
|
|
|
+ gzh_threshold["关停线分位点"]
|
|
|
+ )
|
|
|
+ result.loc[gzh_eligible, "是否低于三日关停线"] = (
|
|
|
+ pd.to_numeric(result.loc[gzh_eligible, "ROI"], errors="coerce")
|
|
|
+ <= float(gzh_threshold["t_stop"])
|
|
|
+ )
|
|
|
+
|
|
|
for index, row in result.iterrows():
|
|
|
entity_type = str(row["entity_type"])
|
|
|
if bool(one_day_supplement.loc[index]):
|
|
|
- latest = expected_dates[-1]
|
|
|
latest_uv = float(row[f"首层UV_{latest}"])
|
|
|
latest_roi = float(row[f"ROI_{latest}"])
|
|
|
three_day_roi = float(row["ROI"])
|
|
|
@@ -282,33 +635,39 @@ def apply_actions(
|
|
|
if latest_roi <= config.one_day_hard_stop_roi:
|
|
|
stop_reason = (
|
|
|
f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
|
|
|
- f"最新日预测总效率ROI={latest_roi:.2f}≤单日硬关停线"
|
|
|
+ f"最新日预测总效率ROI={latest_roi:.2f}≤绝对低ROI候选线"
|
|
|
f"{config.one_day_hard_stop_roi:.2f}"
|
|
|
)
|
|
|
elif (
|
|
|
- latest_uv > config.one_day_p30_min_uv
|
|
|
- and np.isfinite(float(row["t_one_day_stop"]))
|
|
|
- and latest_roi <= float(row["t_one_day_stop"])
|
|
|
+ latest_uv > config.one_day_high_uv_min_uv
|
|
|
):
|
|
|
stop_reason = (
|
|
|
- f"最新日首层UV={latest_uv:.0f}>{config.one_day_p30_min_uv:g},"
|
|
|
- f"最新日预测总效率ROI={latest_roi:.2f}≤单日实体等权P30关停线"
|
|
|
- f"{float(row['t_one_day_stop']):.2f}"
|
|
|
+ f"最新日首层UV={latest_uv:.0f}>{config.one_day_high_uv_min_uv:g},"
|
|
|
+ f"进入单日高UV动态分位候选,最新日预测总效率ROI={latest_roi:.2f}"
|
|
|
)
|
|
|
|
|
|
- if stop_reason and age >= config.self_stop_min_age:
|
|
|
+ if bool(selected_stop.loc[index]):
|
|
|
result.at[index, "动作"] = "关停"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
f"{decision_context}{stop_reason}{three_day_context};"
|
|
|
f"广告age={age}>{config.self_stop_min_age - 1}天,"
|
|
|
+ f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
|
|
|
+ f"按5%成本软预算选中({candidate_rule.loc[index]});"
|
|
|
"建议审批后暂停动态创意"
|
|
|
)
|
|
|
- elif stop_reason:
|
|
|
+ elif stop_reason and age < config.self_stop_min_age:
|
|
|
result.at[index, "动作"] = "观察"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
f"{decision_context}{stop_reason}{three_day_context};"
|
|
|
f"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
|
|
|
)
|
|
|
+ elif stop_reason:
|
|
|
+ result.at[index, "动作"] = "观察"
|
|
|
+ result.at[index, "动作原因"] = (
|
|
|
+ f"{decision_context}{stop_reason}{three_day_context};"
|
|
|
+ f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
|
|
|
+ "满足低质候选条件但未被5%成本软预算选中;建议观察"
|
|
|
+ )
|
|
|
else:
|
|
|
result.at[index, "动作"] = "观察"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
@@ -322,23 +681,25 @@ def apply_actions(
|
|
|
result.at[index, "动作"] = "观察"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
f"最新日首层UV>{config.observe_min_latest_uv:g},但未满足连续三天"
|
|
|
- "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入P20和自动执行"
|
|
|
+ "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入正式阈值和自动执行"
|
|
|
)
|
|
|
continue
|
|
|
if bool(ad_eligible.loc[index]):
|
|
|
- if float(row["ROI"]) <= float(row["t_stop"]):
|
|
|
+ if np.isfinite(float(row["t_stop"])) and float(row["ROI"]) <= float(
|
|
|
+ row["t_stop"]
|
|
|
+ ):
|
|
|
raw_age = row.get("广告age")
|
|
|
age = int(raw_age) if pd.notna(raw_age) else 0
|
|
|
if age >= config.self_stop_min_age:
|
|
|
result.at[index, "动作"] = "关停"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
- f"广告级三日加权平均效率ROI≤统一实体等权P20,"
|
|
|
+ "广告级三日加权平均效率ROI≤小程序三日动态关停线,"
|
|
|
f"广告age>{config.self_stop_min_age - 1}天;审批后暂停整个广告"
|
|
|
)
|
|
|
else:
|
|
|
result.at[index, "动作"] = "观察"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
- f"广告级三日加权平均效率ROI≤统一实体等权P20,但广告age≤"
|
|
|
+ "广告级三日加权平均效率ROI≤小程序三日动态关停线,但广告age≤"
|
|
|
f"{config.self_stop_min_age - 1}天"
|
|
|
)
|
|
|
continue
|
|
|
@@ -347,19 +708,24 @@ def apply_actions(
|
|
|
if entity_type == ENTITY_SELF:
|
|
|
raw_age = row.get("广告age")
|
|
|
age = int(raw_age) if pd.notna(raw_age) else 0
|
|
|
- if float(row["ROI"]) <= float(row["t_stop"]):
|
|
|
- if age >= config.self_stop_min_age:
|
|
|
- result.at[index, "动作"] = "关停"
|
|
|
- result.at[index, "动作原因"] = (
|
|
|
- f"三日加权平均效率ROI≤统一实体等权P20,"
|
|
|
- f"广告age>{config.self_stop_min_age - 1}天;审批后仅暂停动态创意"
|
|
|
- )
|
|
|
- else:
|
|
|
- result.at[index, "动作"] = "观察"
|
|
|
- result.at[index, "动作原因"] = (
|
|
|
- f"三日加权平均效率ROI≤统一实体等权P20,但广告age≤"
|
|
|
- f"{config.self_stop_min_age - 1}天"
|
|
|
- )
|
|
|
+ if bool(selected_stop.loc[index]):
|
|
|
+ result.at[index, "动作"] = "关停"
|
|
|
+ result.at[index, "动作原因"] = (
|
|
|
+ "三日加权平均预测总效率ROI按从低到高排序,"
|
|
|
+ f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
|
|
|
+ "按5%成本软预算的60%基础额度或结余额度选中;"
|
|
|
+ f"广告age>{config.self_stop_min_age - 1}天;审批后仅暂停动态创意"
|
|
|
+ )
|
|
|
+ elif (
|
|
|
+ np.isfinite(float(row["t_stop"]))
|
|
|
+ and float(row["ROI"]) <= float(row["t_stop"])
|
|
|
+ and age < config.self_stop_min_age
|
|
|
+ ):
|
|
|
+ result.at[index, "动作"] = "观察"
|
|
|
+ result.at[index, "动作原因"] = (
|
|
|
+ "三日加权平均预测总效率ROI低于小程序动态关停线,但广告age≤"
|
|
|
+ f"{config.self_stop_min_age - 1}天"
|
|
|
+ )
|
|
|
elif bool(row["是否位于创意三日ROI前20%"]):
|
|
|
if age >= config.self_up_min_age:
|
|
|
result.at[index, "动作"] = "扩量"
|
|
|
@@ -373,12 +739,17 @@ def apply_actions(
|
|
|
f"三日加权平均效率ROI≥合格创意实体等权P80,但广告age<"
|
|
|
f"{config.self_up_min_age}天"
|
|
|
)
|
|
|
- elif entity_type == ENTITY_GZH and float(row["ROI"]) <= float(row["t_stop"]):
|
|
|
+ elif (
|
|
|
+ entity_type == ENTITY_GZH
|
|
|
+ and np.isfinite(float(row["t_stop"]))
|
|
|
+ and float(row["ROI"]) <= float(row["t_stop"])
|
|
|
+ ):
|
|
|
result.at[index, "动作"] = "关停"
|
|
|
result.at[index, "动作原因"] = (
|
|
|
- "三日加权平均效率ROI≤统一实体等权P20;公众号当前仅通知参考"
|
|
|
+ "三日加权平均效率ROI≤公众号独立实体等权关停线;"
|
|
|
+ "公众号当前仅通知参考"
|
|
|
)
|
|
|
- return result
|
|
|
+ return result, thresholds
|
|
|
|
|
|
|
|
|
def evaluate_rules(
|
|
|
@@ -395,14 +766,16 @@ def evaluate_rules(
|
|
|
ad_age,
|
|
|
fission_parameters=fission_parameters,
|
|
|
)
|
|
|
- thresholds = compute_global_threshold(summary, dates, config)
|
|
|
- evaluated = apply_actions(summary, thresholds, config, dates)
|
|
|
+ thresholds = compute_channel_thresholds(summary, dates, config)
|
|
|
+ evaluated, thresholds = apply_actions(summary, thresholds, config, dates)
|
|
|
formal_threshold = threshold_eligibility_mask(evaluated, config, dates)
|
|
|
formal_ad = entity_eligibility_mask(evaluated, ENTITY_SELF_AD, config, dates)
|
|
|
one_day_supplement = one_day_supplement_mask(evaluated, config, dates)
|
|
|
observe_only = observation_mask(evaluated, config, dates) & ~one_day_supplement
|
|
|
evaluated["阈值样本状态"] = "未达到三日正式样本门槛"
|
|
|
- evaluated.loc[formal_threshold, "阈值样本状态"] = "进入三日统一阈值样本池"
|
|
|
+ evaluated.loc[formal_threshold, "阈值样本状态"] = (
|
|
|
+ "进入三日渠道独立阈值样本池"
|
|
|
+ )
|
|
|
evaluated.loc[formal_ad, "阈值样本状态"] = "广告级三日合格_不进入阈值样本池"
|
|
|
evaluated.loc[one_day_supplement, "阈值样本状态"] = (
|
|
|
"单日补充决策_昨日UV>200"
|