| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- """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,
- compute_roi_summary,
- )
- POLICY_VERSION = "roi_policy_v12"
- POLICY_RUN_SUFFIX = "p12"
- @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_p30_min_uv: float = 500
- one_day_hard_stop_roi: float = 0.20
- one_day_stop_quantile: float = 0.30
- 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:
- """Only creative-level miniapp and official accounts enter global P20."""
- 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_p30_pool_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")
- return (
- summary["entity_type"].eq(ENTITY_SELF)
- & pd.to_numeric(summary[f"首层UV_{latest}"], errors="coerce").gt(
- config.one_day_p30_min_uv
- )
- & pd.to_numeric(summary[f"成本_{latest}"], errors="coerce").gt(0)
- & np.isfinite(latest_roi)
- )
- 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_global_threshold(
- 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(
- summary, ENTITY_SELF, config, expected_dates
- )
- creative_sample = pd.to_numeric(
- summary.loc[creative_eligible, "ROI"], errors="coerce"
- )
- creative_sample = creative_sample[np.isfinite(creative_sample)]
- t_up = (
- float(creative_sample.quantile(config.up_quantile))
- if not creative_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
- else np.nan
- )
- return pd.DataFrame(
- [
- {
- "统计窗口": f"{expected_dates[0]} 至 {expected_dates[-1]}",
- "entity_type": "global",
- "渠道": "小程序创意级+公众号",
- "t_stop": float(sample.quantile(config.stop_quantile)),
- "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)),
- "单日硬关停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)}",
- "广告级是否入池": "否_仅复用统一关停线",
- }
- ]
- )
- 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 apply_actions(
- summary: pd.DataFrame,
- thresholds: pd.DataFrame,
- config: RuleConfig,
- expected_dates: list[str],
- ) -> 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"]
- )
- 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
- )
- 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
- )
- result["是否位于三日ROI后20%"] = 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])):
- result.loc[creative_eligible, "是否位于创意三日ROI前20%"] = (
- pd.to_numeric(result.loc[creative_eligible, "ROI"], errors="coerce")
- >= result.loc[creative_eligible, "t_up"]
- )
- 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"])
- 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}仅作参考,"
- "不参与本次单日判断"
- )
- stop_reason = ""
- 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"{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"])
- ):
- 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}"
- )
- if 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"广告age={age}≤{config.self_stop_min_age - 1}天,暂不关停"
- )
- else:
- result.at[index, "动作"] = "观察"
- result.at[index, "动作原因"] = (
- f"{decision_context}"
- f"最新日首层UV={latest_uv:.0f}>{config.one_day_min_uv:g},"
- f"最新日预测总效率ROI={latest_roi:.2f}未命中单日关停规则"
- 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有效;仅置底展示,不进入P20和自动执行"
- )
- continue
- if bool(ad_eligible.loc[index]):
- if 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,"
- 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}天"
- )
- 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 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}天"
- )
- 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 float(row["ROI"]) <= float(row["t_stop"]):
- result.at[index, "动作"] = "关停"
- result.at[index, "动作原因"] = (
- "三日加权平均效率ROI≤统一实体等权P20;公众号当前仅通知参考"
- )
- return result
- 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_global_threshold(summary, dates, config)
- evaluated = 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
|