| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757 |
- """Versioned three-day policy with a latest-day creative stop supplement."""
- from __future__ import annotations
- from dataclasses import dataclass
- from typing import Iterable
- import numpy as np
- import pandas as pd
- from .fission_multiplier import FissionMultiplierParameters
- from .metrics import (
- ENTITY_GZH,
- ENTITY_SELF,
- ENTITY_SELF_AD,
- GZH_CHANNEL,
- SELF_CHANNEL,
- compute_roi_summary,
- )
- POLICY_VERSION = "roi_policy_v15"
- POLICY_RUN_SUFFIX = "p15"
- STOP_RULE_THREE_DAY = "三日持续低ROI"
- STOP_RULE_ONE_DAY = "单日绝对低ROI且P10"
- @dataclass(frozen=True)
- class RuleConfig:
- self_stop_min_age: int = 4
- self_up_min_age: int = 3
- self_min_daily_uv: float = 200
- partner_min_daily_uv: float = 200
- observe_min_latest_uv: float = 200
- one_day_min_uv: float = 200
- one_day_hard_stop_roi: float = 0.20
- one_day_stop_quantile: float = 0.10
- 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_share: float = 0.40
- stop_quantile: float = 0.20
- up_quantile: float = 0.80
- def _daily_sample_mask(
- summary: pd.DataFrame,
- entity_type: str,
- dt: str,
- config: RuleConfig,
- ) -> pd.Series:
- min_uv = (
- config.partner_min_daily_uv
- if entity_type == ENTITY_GZH
- else config.self_min_daily_uv
- )
- return (
- summary["entity_type"].eq(entity_type)
- & pd.to_numeric(summary[f"首层UV_{dt}"], errors="coerce").gt(min_uv)
- & pd.to_numeric(summary[f"成本_{dt}"], errors="coerce").gt(0)
- & np.isfinite(pd.to_numeric(summary[f"ROI_{dt}"], errors="coerce"))
- )
- def entity_eligibility_mask(
- summary: pd.DataFrame,
- entity_type: str,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> pd.Series:
- eligible = summary["entity_type"].eq(entity_type)
- for dt in expected_dates:
- eligible &= _daily_sample_mask(summary, entity_type, dt, config)
- return eligible
- def threshold_eligibility_mask(
- summary: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> pd.Series:
- """Return formal creative-level samples for channel-independent lines."""
- return entity_eligibility_mask(
- summary, ENTITY_SELF, config, expected_dates
- ) | entity_eligibility_mask(summary, ENTITY_GZH, config, expected_dates)
- def report_formal_mask(
- summary: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> pd.Series:
- return threshold_eligibility_mask(
- summary, config, expected_dates
- ) | entity_eligibility_mask(summary, ENTITY_SELF_AD, config, expected_dates)
- def observation_mask(
- summary: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> pd.Series:
- latest = expected_dates[-1]
- formal = report_formal_mask(summary, config, expected_dates)
- return (
- summary["entity_type"].isin([ENTITY_SELF, ENTITY_SELF_AD, ENTITY_GZH])
- & ~formal
- & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
- config.observe_min_latest_uv
- )
- )
- def one_day_supplement_mask(
- summary: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> pd.Series:
- latest = expected_dates[-1]
- latest_roi = pd.to_numeric(summary[f"ROI_{latest}"], errors="coerce")
- formal_creative = entity_eligibility_mask(
- summary, ENTITY_SELF, config, expected_dates
- )
- return (
- summary["entity_type"].eq(ENTITY_SELF)
- & ~formal_creative
- & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
- config.one_day_min_uv
- )
- & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
- & np.isfinite(latest_roi)
- )
- def compute_channel_thresholds(
- summary: pd.DataFrame,
- expected_dates: list[str],
- config: RuleConfig,
- ) -> pd.DataFrame:
- self_eligible = entity_eligibility_mask(
- summary, ENTITY_SELF, config, expected_dates
- )
- gzh_eligible = entity_eligibility_mask(
- summary, ENTITY_GZH, config, expected_dates
- )
- self_sample = pd.to_numeric(
- summary.loc[self_eligible, "ROI"], errors="coerce"
- )
- 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(self_sample.quantile(config.up_quantile))
- if not self_sample.empty
- else np.nan
- )
- gzh_stop = (
- float(gzh_sample.quantile(config.stop_quantile))
- if not gzh_sample.empty
- else np.nan
- )
- latest = expected_dates[-1]
- return pd.DataFrame(
- [
- {
- "统计窗口": f"{expected_dates[0]} 至 {latest}",
- "entity_type": ENTITY_SELF,
- "渠道": SELF_CHANNEL,
- "t_stop": np.nan,
- "t_up": t_up,
- "t_one_day_stop": np.nan,
- "关停线分位点": np.nan,
- "单日实际关停线分位点": np.nan,
- "阈值样本数": int(len(self_sample)),
- "扩量样本数": int(len(self_sample)),
- "单日候选池样本数": 0,
- "单日硬关停ROI": config.one_day_hard_stop_roi,
- "单日P10线": np.nan,
- "单日合并资格线": np.nan,
- "单日最低UV": config.one_day_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_stop": np.nan,
- "关停线分位点": (
- config.stop_quantile if np.isfinite(gzh_stop) else np.nan
- ),
- "单日实际关停线分位点": np.nan,
- "阈值样本数": int(len(gzh_sample)),
- "扩量样本数": 0,
- "单日候选池样本数": 0,
- "单日硬关停ROI": np.nan,
- "单日P10线": np.nan,
- "单日合并资格线": np.nan,
- "单日最低UV": np.nan,
- "关停线口径": (
- f"公众号合格实体等权P{int(config.stop_quantile * 100)}"
- ),
- "扩量线口径": "不适用",
- "广告级是否入池": "不适用",
- },
- ]
- )
- def _sample_percentile(values: pd.Series, target: float) -> float:
- clean = pd.to_numeric(values, errors="coerce")
- clean = clean[np.isfinite(clean)]
- if clean.empty or not np.isfinite(target):
- return np.nan
- 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")
- 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_pool = one_day_supplement & actionable_age
- one_day_sample = latest_roi.loc[one_day_pool]
- one_day_sample = one_day_sample[np.isfinite(one_day_sample)]
- one_day_p10_line = (
- float(one_day_sample.quantile(config.one_day_stop_quantile))
- if not one_day_sample.empty
- else np.nan
- )
- one_day_eligibility_line = (
- min(config.one_day_hard_stop_roi, one_day_p10_line)
- if np.isfinite(one_day_p10_line)
- else np.nan
- )
- one_day_candidate = (
- one_day_pool
- & latest_roi.le(config.one_day_hard_stop_roi)
- & latest_roi.le(one_day_p10_line)
- )
- result["单日ROI排名百分位"] = np.nan
- if not one_day_sample.empty:
- result.loc[one_day_pool, "单日ROI排名百分位"] = latest_roi.loc[
- one_day_pool
- ].apply(lambda value: _sample_percentile(one_day_sample, float(value)))
- result["单日P10线"] = one_day_p10_line
- result["单日合并资格线"] = one_day_eligibility_line
- masks = {
- STOP_RULE_THREE_DAY: three_day_mask,
- STOP_RULE_ONE_DAY: one_day_candidate,
- }
- roi_columns = {
- STOP_RULE_THREE_DAY: "ROI",
- STOP_RULE_ONE_DAY: latest_roi_column,
- }
- shares = {
- STOP_RULE_THREE_DAY: config.self_stop_three_day_share,
- STOP_RULE_ONE_DAY: config.self_stop_one_day_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):
- 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, STOP_RULE_THREE_DAY):
- 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, STOP_RULE_THREE_DAY):
- 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,
- "单日基础预算成本": target_cost * config.self_stop_one_day_share,
- "三日候选数": len(ordered[STOP_RULE_THREE_DAY]),
- "单日候选池样本数": int(one_day_pool.sum()),
- "单日合并候选数": len(ordered[STOP_RULE_ONE_DAY]),
- "单日P10线": one_day_p10_line,
- "单日合并资格线": one_day_eligibility_line,
- "三日实际关停成本": lines[f"{STOP_RULE_THREE_DAY}_cost"],
- "单日实际关停成本": lines[f"{STOP_RULE_ONE_DAY}_cost"],
- "三日关停线": lines[f"{STOP_RULE_THREE_DAY}_line"],
- "三日关停线分位点": lines[f"{STOP_RULE_THREE_DAY}_percentile"],
- "单日实际关停线": lines[f"{STOP_RULE_ONE_DAY}_line"],
- "单日实际关停线分位点": lines[f"{STOP_RULE_ONE_DAY}_percentile"],
- }
- return selected_mask, candidate_rule, stats
- def apply_actions(
- summary: pd.DataFrame,
- thresholds: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> tuple[pd.DataFrame, pd.DataFrame]:
- result = summary.copy()
- result["动作"] = ""
- result["动作原因"] = ""
- 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(
- result, ENTITY_SELF_AD, config, expected_dates
- )
- observe_only = observation_mask(result, config, expected_dates)
- one_day_supplement = one_day_supplement_mask(
- result, config, expected_dates
- )
- creative_eligible = entity_eligibility_mask(
- result, ENTITY_SELF, config, expected_dates
- )
- gzh_eligible = entity_eligibility_mask(
- result, ENTITY_GZH, config, expected_dates
- )
- 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
- 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_stop": budget_stats["单日实际关停线"],
- "关停线分位点": budget_stats["三日关停线分位点"],
- "单日实际关停线分位点": budget_stats["单日实际关停线分位点"],
- }
- 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
- one_day_rows = candidate_rule.eq(STOP_RULE_ONE_DAY)
- result.loc[one_day_rows, "t_stop"] = budget_stats["单日实际关停线"]
- result.loc[one_day_rows, "t_one_day_stop"] = budget_stats["单日实际关停线"]
- result.loc[one_day_rows, "关停线分位点"] = budget_stats[
- "单日实际关停线分位点"
- ]
- 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_uv = float(row[f"首层UV_{latest}"])
- latest_roi = float(row[f"ROI_{latest}"])
- three_day_roi = float(row["ROI"])
- raw_age = row.get("广告age")
- age = int(raw_age) if pd.notna(raw_age) else 0
- decision_context = (
- "未满足连续三天每天首层UV>200、成本>0且ROI有效,"
- "启用单日补充规则;"
- )
- three_day_context = (
- f";三日预测总效率ROI={three_day_roi:.2f}仅作参考,"
- "不参与本次单日判断"
- )
- one_day_p10_line = float(budget_stats["单日P10线"])
- meets_absolute = latest_roi <= config.one_day_hard_stop_roi
- meets_p10 = (
- np.isfinite(one_day_p10_line)
- and latest_roi <= one_day_p10_line
- )
- qualifies = meets_absolute and meets_p10
- stop_reason = (
- f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
- f"最新日预测总效率ROI={latest_roi:.2f}同时满足ROI≤"
- f"{config.one_day_hard_stop_roi:.2f}和单日候选池P10线"
- f"{one_day_p10_line:.2f}"
- )
- if qualifies 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 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 qualifies:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"{decision_context}{stop_reason}{three_day_context};"
- f"昨日成本={float(row[f'成本_{latest}']):.2f}元,"
- "满足低质候选条件但未被5%成本软预算选中;建议观察"
- )
- elif not meets_absolute:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"{decision_context}最新日首层UV={latest_uv:.0f}>"
- f"{config.one_day_min_uv:g},最新日预测总效率ROI="
- f"{latest_roi:.2f}高于绝对线{config.one_day_hard_stop_roi:.2f},"
- f"不满足单日两个条件{three_day_context};建议继续观察"
- )
- elif not meets_p10:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"{decision_context}最新日预测总效率ROI={latest_roi:.2f}≤"
- f"{config.one_day_hard_stop_roi:.2f},但高于单日候选池P10线"
- f"{one_day_p10_line:.2f}、未进入后10%{three_day_context};"
- "建议继续观察"
- )
- else:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"{decision_context}单日候选池不足,无法计算P10线"
- f"{three_day_context};建议继续观察"
- )
- continue
- if bool(observe_only.loc[index]):
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"最新日首层UV>{config.observe_min_latest_uv:g},但未满足连续三天"
- "每天首层UV>200、成本>0且ROI有效;仅置底展示,不进入正式阈值和自动执行"
- )
- continue
- if bool(ad_eligible.loc[index]):
- 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, "动作原因"] = (
- "广告级三日加权平均效率ROI≤小程序三日动态关停线,"
- f"广告age>{config.self_stop_min_age - 1}天;审批后暂停整个广告"
- )
- else:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- "广告级三日加权平均效率ROI≤小程序三日动态关停线,但广告age≤"
- f"{config.self_stop_min_age - 1}天"
- )
- continue
- if not bool(threshold_eligible.loc[index]):
- continue
- if entity_type == ENTITY_SELF:
- raw_age = row.get("广告age")
- age = int(raw_age) if pd.notna(raw_age) else 0
- 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, "动作"] = "扩量"
- result.at[index, "动作原因"] = (
- f"三日加权平均效率ROI≥合格创意实体等权P80,"
- f"广告age≥{config.self_up_min_age}天;审批后提高广告永久基础出价"
- )
- else:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"三日加权平均效率ROI≥合格创意实体等权P80,但广告age<"
- f"{config.self_up_min_age}天"
- )
- 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≤公众号独立实体等权关停线;"
- "公众号当前仅通知参考"
- )
- return result, thresholds
- def evaluate_rules(
- raw_daily: pd.DataFrame,
- expected_dates: Iterable[str],
- ad_age: pd.DataFrame | None = None,
- config: RuleConfig = RuleConfig(),
- *,
- fission_parameters: FissionMultiplierParameters,
- ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
- summary, dates = compute_roi_summary(
- raw_daily,
- expected_dates,
- ad_age,
- fission_parameters=fission_parameters,
- )
- 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_ad, "阈值样本状态"] = "广告级三日合格_不进入阈值样本池"
- evaluated.loc[one_day_supplement, "阈值样本状态"] = (
- "单日补充决策_昨日UV>200"
- )
- evaluated.loc[observe_only, "阈值样本状态"] = "补充观察_昨日UV>200"
- candidates = evaluated[evaluated["动作"].ne("")].copy()
- return candidates, thresholds, evaluated
|