|
@@ -1,22 +1,14 @@
|
|
|
"""
|
|
"""
|
|
|
-广告决策引擎 — auto_put_ad_mini V3
|
|
|
|
|
-
|
|
|
|
|
-V3 三维度决策引擎:
|
|
|
|
|
- 维度 1(最高优先级): ROI 过低 → 关停
|
|
|
|
|
- 维度 2: 长期无消耗 → 关停
|
|
|
|
|
- 维度 3: 广告衰退 → 关停
|
|
|
|
|
-
|
|
|
|
|
-设计:
|
|
|
|
|
- - DecisionDimension 基类 + 优先级注册
|
|
|
|
|
- - 第一个命中的维度决定动作,后续不再评估
|
|
|
|
|
- - 不满 7 天的广告不参与决策
|
|
|
|
|
- - 所有阈值通过参数传入(来自 SKILL)
|
|
|
|
|
|
|
+广告决策引擎 — auto_put_ad_mini
|
|
|
|
|
+
|
|
|
|
|
+智能引擎:
|
|
|
|
|
+ - LLM 推理 + 候选信号(ROI/裂变/CTR/消耗)驱动
|
|
|
|
|
+ - 三级分类:零消耗待关停(规则)+ 待优化评估(LLM)+ 正常运行(规则)
|
|
|
|
|
+ - 年龄保护三层架构(冷启动 / 早期成长 / 成熟期)
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
import logging
|
|
import logging
|
|
|
import sys
|
|
import sys
|
|
|
-from abc import ABC, abstractmethod
|
|
|
|
|
-from dataclasses import dataclass
|
|
|
|
|
from datetime import datetime, timedelta
|
|
from datetime import datetime, timedelta
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
from typing import Dict, List, Optional, Tuple
|
|
@@ -36,9 +28,14 @@ from config import (
|
|
|
BID_ADJUSTMENT_ENABLED,
|
|
BID_ADJUSTMENT_ENABLED,
|
|
|
BID_DOWN_ROI_FACTOR,
|
|
BID_DOWN_ROI_FACTOR,
|
|
|
BID_UP_ROI_FACTOR,
|
|
BID_UP_ROI_FACTOR,
|
|
|
- BID_UP_LOW_SPEND_FACTOR,
|
|
|
|
|
|
|
+ BID_UP_MAX_SPEND,
|
|
|
BID_CHANGE_MIN_PCT,
|
|
BID_CHANGE_MIN_PCT,
|
|
|
BID_CHANGE_MAX_PCT,
|
|
BID_CHANGE_MAX_PCT,
|
|
|
|
|
+ BID_UP_MIN_PCT,
|
|
|
|
|
+ BID_UP_MAX_PCT,
|
|
|
|
|
+ BID_DOWN_MIN_PCT,
|
|
|
|
|
+ BID_DOWN_MAX_PCT,
|
|
|
|
|
+ BID_DOWN_MIN_SPEND,
|
|
|
BID_FLOOR_YUAN,
|
|
BID_FLOOR_YUAN,
|
|
|
BID_CEILING_YUAN,
|
|
BID_CEILING_YUAN,
|
|
|
COLD_START_DAYS, # ≤3天:冷启动期(极度保护)
|
|
COLD_START_DAYS, # ≤3天:冷启动期(极度保护)
|
|
@@ -47,6 +44,7 @@ from config import (
|
|
|
HIGH_BURN_AGE_THRESHOLD,
|
|
HIGH_BURN_AGE_THRESHOLD,
|
|
|
HIGH_BURN_COST_THRESHOLD,
|
|
HIGH_BURN_COST_THRESHOLD,
|
|
|
ROI_LOW_FACTOR,
|
|
ROI_LOW_FACTOR,
|
|
|
|
|
+ ROI_LOW_MIN_YESTERDAY_COST,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
@@ -74,7 +72,6 @@ def _load_strategy_params():
|
|
|
"ROI_LOW_FACTOR": ROI_LOW_FACTOR,
|
|
"ROI_LOW_FACTOR": ROI_LOW_FACTOR,
|
|
|
"BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
|
|
"BID_DOWN_ROI_FACTOR": BID_DOWN_ROI_FACTOR,
|
|
|
"BID_UP_ROI_FACTOR": BID_UP_ROI_FACTOR,
|
|
"BID_UP_ROI_FACTOR": BID_UP_ROI_FACTOR,
|
|
|
- "BID_UP_LOW_SPEND_FACTOR": BID_UP_LOW_SPEND_FACTOR,
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -122,436 +119,6 @@ def _calculate_ad_age_days(create_time) -> Optional[int]:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 决策结果数据类
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@dataclass
|
|
|
|
|
-class Decision:
|
|
|
|
|
- """单个广告的决策结果。"""
|
|
|
|
|
- ad_id: int
|
|
|
|
|
- action: str # "pause" / "bid_down" / "bid_up" / "hold" / "creative_adjust" / "observe"
|
|
|
|
|
- dimension: str # "ROI过低" / "长期无消耗" / "广告衰退" / "ROI偏低-降价" / "高ROI低量-提价" / "保持"
|
|
|
|
|
- reason: str # 详细原因
|
|
|
|
|
- recommended_change_pct: Optional[float] = None # +0.05 = 提价5%, -0.08 = 降价8%
|
|
|
|
|
- current_bid: Optional[float] = None # 当前出价(元)
|
|
|
|
|
- recommended_bid: Optional[float] = None # 建议出价(元)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 决策维度基类(可扩展)
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class DecisionDimension(ABC):
|
|
|
|
|
- """决策维度基类。"""
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self, priority: int):
|
|
|
|
|
- self.priority = priority
|
|
|
|
|
-
|
|
|
|
|
- @abstractmethod
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- """
|
|
|
|
|
- 评估单个广告是否命中该维度。
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- row: 广告数据行(包含所有指标)
|
|
|
|
|
- context: 全局上下文(如全体均值、阈值参数)
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Decision 对象(命中)或 None(不命中)
|
|
|
|
|
- """
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 维度 1: ROI 过低
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class ROITooLowDimension(DecisionDimension):
|
|
|
|
|
- """维度 1: 动态ROI_7日均值 < 全体均值 × 0.5 → 关停。"""
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self):
|
|
|
|
|
- super().__init__(priority=1)
|
|
|
|
|
-
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- ad_age_days = row.get("ad_age_days")
|
|
|
|
|
- cost_7d_avg = row.get("cost_7d_avg", 0)
|
|
|
|
|
- f_roi_7d = row.get("动态ROI_7日均值") # 决策参考值
|
|
|
|
|
- f_roi_mean_all = context.get("动态ROI_mean_all")
|
|
|
|
|
-
|
|
|
|
|
- min_ad_age = context.get("min_ad_age_days", 7)
|
|
|
|
|
- min_daily_cost = context.get("min_daily_cost", 100)
|
|
|
|
|
- roi_low_factor = context.get("roi_low_factor", 0.5)
|
|
|
|
|
-
|
|
|
|
|
- # 前置条件
|
|
|
|
|
- if ad_age_days is None or ad_age_days < min_ad_age:
|
|
|
|
|
- return None
|
|
|
|
|
- if cost_7d_avg < min_daily_cost:
|
|
|
|
|
- return None
|
|
|
|
|
- if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all):
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # 判断
|
|
|
|
|
- threshold = f_roi_mean_all * roi_low_factor
|
|
|
|
|
- if f_roi_7d < threshold:
|
|
|
|
|
- return Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="pause",
|
|
|
|
|
- dimension="ROI过低",
|
|
|
|
|
- reason=f"动态ROI_7日均值={f_roi_7d:.4f} < 全体均值×{roi_low_factor}={threshold:.4f}"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 维度 2: 长期无消耗
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class NoSpendDimension(DecisionDimension):
|
|
|
|
|
- """维度 2: 7日消耗均值 < 10元 → 关停。"""
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self):
|
|
|
|
|
- super().__init__(priority=2)
|
|
|
|
|
-
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- ad_age_days = row.get("ad_age_days")
|
|
|
|
|
- cost_7d_avg = row.get("cost_7d_avg", 0)
|
|
|
|
|
-
|
|
|
|
|
- min_ad_age = context.get("min_ad_age_days", 7)
|
|
|
|
|
- no_spend_threshold = context.get("no_spend_threshold", 10)
|
|
|
|
|
-
|
|
|
|
|
- # 前置条件
|
|
|
|
|
- if ad_age_days is None or ad_age_days < min_ad_age:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # 判断
|
|
|
|
|
- if cost_7d_avg < no_spend_threshold:
|
|
|
|
|
- return Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="pause",
|
|
|
|
|
- dimension="长期无消耗",
|
|
|
|
|
- reason=f"7日消耗均值={cost_7d_avg:.2f}元 < {no_spend_threshold}元"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 维度 3: 广告衰退
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class AdDecayDimension(DecisionDimension):
|
|
|
|
|
- """
|
|
|
|
|
- 维度 3: 广告衰退 → 关停。
|
|
|
|
|
-
|
|
|
|
|
- 条件:
|
|
|
|
|
- - 30 天内曾连续稳定消耗(>100元/天)
|
|
|
|
|
- - 近 7 天已提价或换创意
|
|
|
|
|
- - 但消耗仍低(<100元)
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self):
|
|
|
|
|
- super().__init__(priority=3)
|
|
|
|
|
-
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- stable_spend_days_30d = row.get("stable_spend_days_30d", 0)
|
|
|
|
|
- cost_7d_avg = row.get("cost_7d_avg", 0)
|
|
|
|
|
- bid_increased_7d = row.get("bid_increased_7d", False)
|
|
|
|
|
- creative_changed_7d = row.get("creative_changed_7d", False)
|
|
|
|
|
-
|
|
|
|
|
- stable_threshold = context.get("stable_spend_threshold", 100)
|
|
|
|
|
-
|
|
|
|
|
- # 判断
|
|
|
|
|
- if stable_spend_days_30d >= 7: # 曾稳定消耗
|
|
|
|
|
- if cost_7d_avg < stable_threshold: # 现在消耗低
|
|
|
|
|
- if bid_increased_7d or creative_changed_7d: # 已干预
|
|
|
|
|
- reason_parts = []
|
|
|
|
|
- if bid_increased_7d:
|
|
|
|
|
- reason_parts.append("已提价")
|
|
|
|
|
- if creative_changed_7d:
|
|
|
|
|
- reason_parts.append("已换创意")
|
|
|
|
|
- reason = f"30天内曾稳定消耗{stable_spend_days_30d}天,近7天{'+'.join(reason_parts)},但消耗仍低({cost_7d_avg:.2f}元)"
|
|
|
|
|
-
|
|
|
|
|
- return Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="pause",
|
|
|
|
|
- dimension="广告衰退",
|
|
|
|
|
- reason=reason
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 维度 4: 出价偏高 — 降价
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class BidDownDimension(DecisionDimension):
|
|
|
|
|
- """
|
|
|
|
|
- 维度 4: ROI 偏低但未达关停线 → 降价。
|
|
|
|
|
-
|
|
|
|
|
- 触发条件:
|
|
|
|
|
- - ROI 在 均值×0.5 ~ 均值×0.8 之间(偏低但非极低)
|
|
|
|
|
- - 日消耗 ≥ 100 元(数据有统计意义)
|
|
|
|
|
- - 非冷启动期(> {COLD_START_DAYS} 天,即≥4天)
|
|
|
|
|
- - 有出价数据(bid_amount > 0)
|
|
|
|
|
-
|
|
|
|
|
- 降幅计算:
|
|
|
|
|
- ROI 距离关停线越近 → 降幅越大(最大 -10%)
|
|
|
|
|
- ROI 刚好低于正常线 → 小幅降价(-3%~-5%)
|
|
|
|
|
- 公式:change_pct = -3% - 7% × (1 - (ROI - hard_stop) / (normal_line - hard_stop))
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self):
|
|
|
|
|
- super().__init__(priority=4)
|
|
|
|
|
-
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- ad_age_days = row.get("ad_age_days")
|
|
|
|
|
- cost_7d_avg = row.get("cost_7d_avg", 0)
|
|
|
|
|
- f_roi_7d = row.get("动态ROI_7日均值")
|
|
|
|
|
- f_roi_mean_all = context.get("动态ROI_mean_all")
|
|
|
|
|
- bid_amount = row.get("bid_amount", 0) # 元
|
|
|
|
|
-
|
|
|
|
|
- cold_start = context.get("cold_start_days", COLD_START_DAYS)
|
|
|
|
|
- cautious = context.get("cautious_days", CAUTIOUS_DAYS)
|
|
|
|
|
- min_daily_cost = context.get("min_daily_cost", 100)
|
|
|
|
|
- roi_low_factor = context.get("roi_low_factor", 0.5)
|
|
|
|
|
- bid_down_factor = context.get("bid_down_roi_factor", BID_DOWN_ROI_FACTOR)
|
|
|
|
|
-
|
|
|
|
|
- # 前置条件
|
|
|
|
|
- if ad_age_days is None or ad_age_days < cold_start:
|
|
|
|
|
- return None
|
|
|
|
|
- if cost_7d_avg < min_daily_cost:
|
|
|
|
|
- return None
|
|
|
|
|
- if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
|
|
|
|
|
- return None
|
|
|
|
|
- if not bid_amount or bid_amount <= 0:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- hard_stop_line = f_roi_mean_all * roi_low_factor
|
|
|
|
|
- normal_line = f_roi_mean_all * bid_down_factor
|
|
|
|
|
-
|
|
|
|
|
- # ROI 必须在 hard_stop ~ normal_line 之间
|
|
|
|
|
- if f_roi_7d >= normal_line or f_roi_7d < hard_stop_line:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # 计算降幅
|
|
|
|
|
- range_width = normal_line - hard_stop_line
|
|
|
|
|
- if range_width <= 0:
|
|
|
|
|
- return None
|
|
|
|
|
- ratio = 1 - (f_roi_7d - hard_stop_line) / range_width
|
|
|
|
|
- change_pct = -(BID_CHANGE_MIN_PCT + (BID_CHANGE_MAX_PCT - BID_CHANGE_MIN_PCT) * ratio)
|
|
|
|
|
-
|
|
|
|
|
- # 谨慎期(4-7天)限制最大降幅 5%
|
|
|
|
|
- if ad_age_days < cautious:
|
|
|
|
|
- change_pct = max(change_pct, -0.05)
|
|
|
|
|
-
|
|
|
|
|
- # 计算建议出价
|
|
|
|
|
- new_bid = bid_amount * (1 + change_pct)
|
|
|
|
|
- new_bid = max(new_bid, BID_FLOOR_YUAN)
|
|
|
|
|
- new_bid = min(new_bid, BID_CEILING_YUAN)
|
|
|
|
|
- new_bid = round(new_bid, 2)
|
|
|
|
|
-
|
|
|
|
|
- # 实际调幅重算(边界钳位后)
|
|
|
|
|
- actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
|
|
|
|
|
-
|
|
|
|
|
- return Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="bid_down",
|
|
|
|
|
- dimension="ROI偏低-降价",
|
|
|
|
|
- reason=(
|
|
|
|
|
- f"动态ROI_7日均值={f_roi_7d:.4f},"
|
|
|
|
|
- f"在关停线{hard_stop_line:.4f}~正常线{normal_line:.4f}之间,"
|
|
|
|
|
- f"当前出价{bid_amount:.2f}元,建议降{abs(actual_pct)*100:.1f}%至{new_bid:.2f}元"
|
|
|
|
|
- ),
|
|
|
|
|
- recommended_change_pct=round(actual_pct, 4),
|
|
|
|
|
- current_bid=round(bid_amount, 2),
|
|
|
|
|
- recommended_bid=new_bid,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 维度 5: 高ROI低量 — 提价
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-class BidUpDimension(DecisionDimension):
|
|
|
|
|
- """
|
|
|
|
|
- 维度 5: ROI 远超均值但消耗不足 → 提价放量。
|
|
|
|
|
-
|
|
|
|
|
- 触发条件:
|
|
|
|
|
- - ROI > 均值×1.2
|
|
|
|
|
- - 7日均消耗 < 全体中位数×0.5(消耗不足)
|
|
|
|
|
- - 非冷启动期(> COLD_START_DAYS 天)
|
|
|
|
|
- - 有出价数据(bid_amount > 0)
|
|
|
|
|
-
|
|
|
|
|
- 提幅计算:
|
|
|
|
|
- 提幅 = min(10%, (ROI/均值 - 1.2) × 20%)
|
|
|
|
|
- 最小提幅 3%
|
|
|
|
|
- """
|
|
|
|
|
-
|
|
|
|
|
- def __init__(self):
|
|
|
|
|
- super().__init__(priority=5)
|
|
|
|
|
-
|
|
|
|
|
- def evaluate(self, row: pd.Series, context: Dict) -> Optional[Decision]:
|
|
|
|
|
- if not context.get("bid_adjustment_enabled", BID_ADJUSTMENT_ENABLED):
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- ad_age_days = row.get("ad_age_days")
|
|
|
|
|
- cost_7d_avg = row.get("cost_7d_avg", 0)
|
|
|
|
|
- f_roi_7d = row.get("动态ROI_7日均值")
|
|
|
|
|
- f_roi_mean_all = context.get("动态ROI_mean_all")
|
|
|
|
|
- cost_median = context.get("cost_7d_avg_median", 0)
|
|
|
|
|
- bid_amount = row.get("bid_amount", 0) # 元
|
|
|
|
|
-
|
|
|
|
|
- cold_start = context.get("cold_start_days", COLD_START_DAYS)
|
|
|
|
|
- bid_up_factor = context.get("bid_up_roi_factor", BID_UP_ROI_FACTOR)
|
|
|
|
|
- low_spend_factor = context.get("bid_up_low_spend_factor", BID_UP_LOW_SPEND_FACTOR)
|
|
|
|
|
-
|
|
|
|
|
- # 前置条件
|
|
|
|
|
- if ad_age_days is None or ad_age_days < cold_start:
|
|
|
|
|
- return None
|
|
|
|
|
- if pd.isna(f_roi_7d) or pd.isna(f_roi_mean_all) or f_roi_mean_all <= 0:
|
|
|
|
|
- return None
|
|
|
|
|
- if not bid_amount or bid_amount <= 0:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # ROI 必须远超均值
|
|
|
|
|
- roi_threshold = f_roi_mean_all * bid_up_factor
|
|
|
|
|
- if f_roi_7d <= roi_threshold:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # 消耗必须不足
|
|
|
|
|
- spend_threshold = cost_median * low_spend_factor
|
|
|
|
|
- if cost_7d_avg >= spend_threshold and spend_threshold > 0:
|
|
|
|
|
- return None
|
|
|
|
|
-
|
|
|
|
|
- # 计算提幅
|
|
|
|
|
- roi_excess = f_roi_7d / f_roi_mean_all - bid_up_factor
|
|
|
|
|
- change_pct = min(BID_CHANGE_MAX_PCT, roi_excess * 0.20)
|
|
|
|
|
- change_pct = max(change_pct, BID_CHANGE_MIN_PCT)
|
|
|
|
|
-
|
|
|
|
|
- # 计算建议出价
|
|
|
|
|
- new_bid = bid_amount * (1 + change_pct)
|
|
|
|
|
- new_bid = max(new_bid, BID_FLOOR_YUAN)
|
|
|
|
|
- new_bid = min(new_bid, BID_CEILING_YUAN)
|
|
|
|
|
- new_bid = round(new_bid, 2)
|
|
|
|
|
-
|
|
|
|
|
- # 实际调幅重算
|
|
|
|
|
- actual_pct = (new_bid - bid_amount) / bid_amount if bid_amount > 0 else 0
|
|
|
|
|
-
|
|
|
|
|
- return Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="bid_up",
|
|
|
|
|
- dimension="高ROI低量-提价",
|
|
|
|
|
- reason=(
|
|
|
|
|
- f"动态ROI_7日均值={f_roi_7d:.4f} > 均值{f_roi_mean_all:.4f}×{bid_up_factor}={roi_threshold:.4f},"
|
|
|
|
|
- f"但7日均消耗仅{cost_7d_avg:.2f}元 < 中位数{cost_median:.2f}×{low_spend_factor}={spend_threshold:.2f},"
|
|
|
|
|
- f"当前出价{bid_amount:.2f}元,建议提{actual_pct*100:.1f}%至{new_bid:.2f}元"
|
|
|
|
|
- ),
|
|
|
|
|
- recommended_change_pct=round(actual_pct, 4),
|
|
|
|
|
- current_bid=round(bid_amount, 2),
|
|
|
|
|
- recommended_bid=new_bid,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# 决策引擎
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _run_decision_engine(
|
|
|
|
|
- df: pd.DataFrame,
|
|
|
|
|
- context: Dict
|
|
|
|
|
-) -> pd.DataFrame:
|
|
|
|
|
- """
|
|
|
|
|
- 运行三维度决策引擎。
|
|
|
|
|
-
|
|
|
|
|
- 流程:
|
|
|
|
|
- 1. 注册所有维度(按优先级排序)
|
|
|
|
|
- 2. 对每个广告,按优先级评估维度
|
|
|
|
|
- 3. 第一个命中的维度决定动作
|
|
|
|
|
- 4. 不满 7 天的广告标记为"投放不足7日"
|
|
|
|
|
-
|
|
|
|
|
- 输入:
|
|
|
|
|
- df: 广告级指标表(包含 动态ROI, cost_7d_avg, ad_age_days 等)
|
|
|
|
|
- context: 全局上下文(阈值参数、全体均值)
|
|
|
|
|
-
|
|
|
|
|
- 输出:
|
|
|
|
|
- 添加 action, dimension, reason 列的 DataFrame
|
|
|
|
|
- """
|
|
|
|
|
- # 注册维度(含出价调整维度)
|
|
|
|
|
- dimensions = [
|
|
|
|
|
- ROITooLowDimension(),
|
|
|
|
|
- NoSpendDimension(),
|
|
|
|
|
- AdDecayDimension(),
|
|
|
|
|
- BidDownDimension(),
|
|
|
|
|
- BidUpDimension(),
|
|
|
|
|
- ]
|
|
|
|
|
- dimensions.sort(key=lambda d: d.priority)
|
|
|
|
|
-
|
|
|
|
|
- decisions = []
|
|
|
|
|
-
|
|
|
|
|
- for _, row in df.iterrows():
|
|
|
|
|
- ad_age_days = row.get("ad_age_days")
|
|
|
|
|
- min_ad_age = context.get("min_ad_age_days", 7)
|
|
|
|
|
-
|
|
|
|
|
- # 不满 7 天的广告
|
|
|
|
|
- if ad_age_days is None or ad_age_days < min_ad_age:
|
|
|
|
|
- decisions.append(Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="hold",
|
|
|
|
|
- dimension="保持",
|
|
|
|
|
- reason=f"投放不足{min_ad_age}日(当前{ad_age_days}日)"
|
|
|
|
|
- ))
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # 按优先级评估维度
|
|
|
|
|
- decision = None
|
|
|
|
|
- for dim in dimensions:
|
|
|
|
|
- decision = dim.evaluate(row, context)
|
|
|
|
|
- if decision is not None:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # 无维度命中 → 保持
|
|
|
|
|
- if decision is None:
|
|
|
|
|
- decision = Decision(
|
|
|
|
|
- ad_id=int(row["ad_id"]),
|
|
|
|
|
- action="hold",
|
|
|
|
|
- dimension="保持",
|
|
|
|
|
- reason="各项指标正常"
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- decisions.append(decision)
|
|
|
|
|
-
|
|
|
|
|
- # 转换为 DataFrame
|
|
|
|
|
- decision_df = pd.DataFrame([
|
|
|
|
|
- {
|
|
|
|
|
- "ad_id": d.ad_id,
|
|
|
|
|
- "action": d.action,
|
|
|
|
|
- "dimension": d.dimension,
|
|
|
|
|
- "reason": d.reason,
|
|
|
|
|
- "recommended_change_pct": d.recommended_change_pct,
|
|
|
|
|
- "current_bid": d.current_bid,
|
|
|
|
|
- "recommended_bid": d.recommended_bid,
|
|
|
|
|
- }
|
|
|
|
|
- for d in decisions
|
|
|
|
|
- ])
|
|
|
|
|
-
|
|
|
|
|
- # 合并回原 DataFrame
|
|
|
|
|
- df = df.merge(decision_df, on="ad_id", how="left")
|
|
|
|
|
-
|
|
|
|
|
- return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════
|
|
# ═══════════════════════════════════════════
|
|
@@ -700,139 +267,6 @@ def _detect_decay_signals(
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-# V3 工具:三维度决策
|
|
|
|
|
-# ═══════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@tool(description="V3 三维度决策引擎:ROI过低 / 长期无消耗 / 广告衰退")
|
|
|
|
|
-async def analyze_ads(
|
|
|
|
|
- ctx: ToolContext,
|
|
|
|
|
- metrics_csv: str,
|
|
|
|
|
- end_date: str = "yesterday",
|
|
|
|
|
- min_ad_age_days: int = 7,
|
|
|
|
|
- min_daily_cost: float = 100.0,
|
|
|
|
|
- roi_low_factor: float = 0.5,
|
|
|
|
|
- no_spend_threshold: float = 10.0,
|
|
|
|
|
- stable_spend_threshold: float = 100.0,
|
|
|
|
|
-) -> ToolResult:
|
|
|
|
|
- """
|
|
|
|
|
- V3 三维度决策引擎。
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- ctx: 工具上下文
|
|
|
|
|
- metrics_csv: ROI 指标 CSV 路径(calculate_roi_metrics 输出)
|
|
|
|
|
- end_date: 结束日期(YYYYMMDD 或 "yesterday")
|
|
|
|
|
- min_ad_age_days: 最小广告年龄(天)
|
|
|
|
|
- min_daily_cost: 最小日消耗(元)
|
|
|
|
|
- roi_low_factor: ROI 过低因子(< 全体均值 × factor)
|
|
|
|
|
- no_spend_threshold: 长期无消耗阈值(元)
|
|
|
|
|
- stable_spend_threshold: 稳定消耗阈值(元/天)
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- ToolResult,包含决策结果 DataFrame
|
|
|
|
|
- """
|
|
|
|
|
- try:
|
|
|
|
|
- # 加载指标数据
|
|
|
|
|
- df = pd.read_csv(metrics_csv)
|
|
|
|
|
-
|
|
|
|
|
- if df.empty:
|
|
|
|
|
- return ToolResult(
|
|
|
|
|
- title="决策引擎",
|
|
|
|
|
- output="指标数据为空,无法决策",
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # 解析日期
|
|
|
|
|
- if end_date == "yesterday":
|
|
|
|
|
- end_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
|
|
-
|
|
|
|
|
- # 计算广告年龄
|
|
|
|
|
- df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
|
|
|
|
|
-
|
|
|
|
|
- # 提取人群包层级
|
|
|
|
|
- df["audience_tier"] = df["ad_name"].apply(_extract_audience_tier)
|
|
|
|
|
-
|
|
|
|
|
- # 检测衰退信号
|
|
|
|
|
- raw_dir = _MINI_DIR / "outputs" / "raw"
|
|
|
|
|
- ad_status_dir = _MINI_DIR / "outputs" / "ad_status"
|
|
|
|
|
-
|
|
|
|
|
- decay_signals = _detect_decay_signals(
|
|
|
|
|
- ad_ids=df["ad_id"].tolist(),
|
|
|
|
|
- raw_dir=raw_dir,
|
|
|
|
|
- ad_status_dir=ad_status_dir,
|
|
|
|
|
- end_date=end_date
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- df = df.merge(decay_signals, on="ad_id", how="left")
|
|
|
|
|
-
|
|
|
|
|
- # 填充缺失值
|
|
|
|
|
- df["bid_increased_7d"] = df["bid_increased_7d"].fillna(False)
|
|
|
|
|
- df["creative_changed_7d"] = df["creative_changed_7d"].fillna(False)
|
|
|
|
|
- df["stable_spend_days_30d"] = df["stable_spend_days_30d"].fillna(0)
|
|
|
|
|
-
|
|
|
|
|
- # 计算全体 动态ROI_7日均值 的均值(决策基准线)
|
|
|
|
|
- f_roi_mean_all = df["动态ROI_7日均值"].mean()
|
|
|
|
|
-
|
|
|
|
|
- # 构建上下文
|
|
|
|
|
- context = {
|
|
|
|
|
- "动态ROI_mean_all": f_roi_mean_all,
|
|
|
|
|
- "min_ad_age_days": min_ad_age_days,
|
|
|
|
|
- "min_daily_cost": min_daily_cost,
|
|
|
|
|
- "roi_low_factor": roi_low_factor,
|
|
|
|
|
- "no_spend_threshold": no_spend_threshold,
|
|
|
|
|
- "stable_spend_threshold": stable_spend_threshold,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- # 运行决策引擎
|
|
|
|
|
- df = _run_decision_engine(df, context)
|
|
|
|
|
-
|
|
|
|
|
- # 统计
|
|
|
|
|
- total_ads = len(df)
|
|
|
|
|
- pause_ads = (df["action"] == "pause").sum()
|
|
|
|
|
- hold_ads = (df["action"] == "hold").sum()
|
|
|
|
|
-
|
|
|
|
|
- dimension_counts = df["dimension"].value_counts().to_dict()
|
|
|
|
|
-
|
|
|
|
|
- output_lines = [
|
|
|
|
|
- f"决策完成(共 {total_ads} 个广告)",
|
|
|
|
|
- f" - 关停: {pause_ads} 个",
|
|
|
|
|
- f" - 保持: {hold_ads} 个",
|
|
|
|
|
- "",
|
|
|
|
|
- "维度分布:",
|
|
|
|
|
- ]
|
|
|
|
|
-
|
|
|
|
|
- for dim, count in dimension_counts.items():
|
|
|
|
|
- output_lines.append(f" - {dim}: {count} 个")
|
|
|
|
|
-
|
|
|
|
|
- output_lines.extend([
|
|
|
|
|
- "",
|
|
|
|
|
- f"全体 动态ROI_7日均值 均值: {f_roi_mean_all:.4f}",
|
|
|
|
|
- f"ROI 过低阈值: {f_roi_mean_all * roi_low_factor:.4f}",
|
|
|
|
|
- ])
|
|
|
|
|
-
|
|
|
|
|
- # 保存决策结果(临时 CSV,供 generate_report 使用)
|
|
|
|
|
- decision_csv = _MINI_DIR / "outputs" / "decision_temp.csv"
|
|
|
|
|
- decision_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
- df.to_csv(decision_csv, index=False)
|
|
|
|
|
-
|
|
|
|
|
- return ToolResult(
|
|
|
|
|
- title=f"决策引擎({total_ads}个广告)",
|
|
|
|
|
- output="\n".join(output_lines),
|
|
|
|
|
- metadata={
|
|
|
|
|
- "total_ads": total_ads,
|
|
|
|
|
- "pause_ads": pause_ads,
|
|
|
|
|
- "hold_ads": hold_ads,
|
|
|
|
|
- "dimension_counts": dimension_counts,
|
|
|
|
|
- "动态ROI_mean_all": f_roi_mean_all,
|
|
|
|
|
- "decision_csv": str(decision_csv),
|
|
|
|
|
- },
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.error("analyze_ads 失败: %s", e, exc_info=True)
|
|
|
|
|
- return ToolResult(title="analyze_ads 失败", output=str(e))
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
# ═══════════════════════════════════════════
|
|
# ═══════════════════════════════════════════
|
|
|
# 智能引擎工具 1:整理待评估广告数据
|
|
# 智能引擎工具 1:整理待评估广告数据
|
|
|
# ═══════════════════════════════════════════
|
|
# ═══════════════════════════════════════════
|
|
@@ -876,6 +310,7 @@ async def get_ads_for_review(
|
|
|
# ===== 新增:读取人群包级别统计数据(同类对比基准)=====
|
|
# ===== 新增:读取人群包级别统计数据(同类对比基准)=====
|
|
|
logger.info("读取人群包级别统计数据...")
|
|
logger.info("读取人群包级别统计数据...")
|
|
|
by_tier_stats = {}
|
|
by_tier_stats = {}
|
|
|
|
|
+ by_tier_goal = {}
|
|
|
try:
|
|
try:
|
|
|
# 读取 portfolio_summary JSON 文件
|
|
# 读取 portfolio_summary JSON 文件
|
|
|
portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
|
|
portfolio_dir = _MINI_DIR / "outputs" / "portfolio_summary"
|
|
@@ -886,14 +321,15 @@ async def get_ads_for_review(
|
|
|
with open(portfolio_file, "r", encoding="utf-8") as f:
|
|
with open(portfolio_file, "r", encoding="utf-8") as f:
|
|
|
portfolio_data = json.load(f)
|
|
portfolio_data = json.load(f)
|
|
|
by_tier_stats = portfolio_data.get("by_audience_tier", {})
|
|
by_tier_stats = portfolio_data.get("by_audience_tier", {})
|
|
|
- logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包的统计数据")
|
|
|
|
|
|
|
+ by_tier_goal = portfolio_data.get("by_tier_goal", {})
|
|
|
|
|
+ logger.info(f"✅ 从 {portfolio_file.name} 加载了 {len(by_tier_stats)} 个人群包 + {len(by_tier_goal)} 个tier+goal组的统计数据")
|
|
|
else:
|
|
else:
|
|
|
logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
|
|
logger.warning(f"未找到 portfolio_summary 文件: {portfolio_file},将使用全局均值兜底")
|
|
|
- # 可以选择在这里调用 calculate_portfolio_summary 生成文件
|
|
|
|
|
- # 但为了简化,我们先用空字典兜底
|
|
|
|
|
|
|
+ by_tier_goal = {}
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
|
|
logger.warning(f"读取人群包统计数据失败,使用空字典兜底: {e}")
|
|
|
by_tier_stats = {}
|
|
by_tier_stats = {}
|
|
|
|
|
+ by_tier_goal = {}
|
|
|
|
|
|
|
|
# 计算广告年龄
|
|
# 计算广告年龄
|
|
|
df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
|
|
df["ad_age_days"] = df["create_time"].apply(_calculate_ad_age_days)
|
|
@@ -920,9 +356,9 @@ async def get_ads_for_review(
|
|
|
roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
|
|
roi_p75 = float(roi_series.quantile(0.75)) if len(roi_series) > 0 else 0.0
|
|
|
roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
|
|
roi_p90 = float(roi_series.quantile(0.90)) if len(roi_series) > 0 else 0.0
|
|
|
|
|
|
|
|
- # 消耗中位数(供出价提升判断)
|
|
|
|
|
- cost_series = df["cost_7d_avg"].dropna()
|
|
|
|
|
- cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
|
|
|
|
|
|
|
+ # 加载调整历史(用于"持续低ROI升级关停"判断)
|
|
|
|
|
+ from guardrails import AdjustmentHistory
|
|
|
|
|
+ adjustment_history = AdjustmentHistory()
|
|
|
|
|
|
|
|
# 分类(业务语言)
|
|
# 分类(业务语言)
|
|
|
zero_spend_ads = [] # 零消耗待关停
|
|
zero_spend_ads = [] # 零消耗待关停
|
|
@@ -936,6 +372,8 @@ async def get_ads_for_review(
|
|
|
bid_inc = bool(row.get("bid_increased_7d", False))
|
|
bid_inc = bool(row.get("bid_increased_7d", False))
|
|
|
creative_chg = bool(row.get("creative_changed_7d", False))
|
|
creative_chg = bool(row.get("creative_changed_7d", False))
|
|
|
stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
|
|
stable_days = float(row.get("stable_spend_days_30d", 0) or 0)
|
|
|
|
|
+ if pd.isna(stable_days):
|
|
|
|
|
+ stable_days = 0.0
|
|
|
bid_amount = float(row.get("bid_amount", 0) or 0)
|
|
bid_amount = float(row.get("bid_amount", 0) or 0)
|
|
|
|
|
|
|
|
# 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
|
|
# 零消耗待关停:7日均消耗 < 10元,几乎无活动(强规则,仍保留)
|
|
@@ -959,28 +397,89 @@ async def get_ads_for_review(
|
|
|
})
|
|
})
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
|
|
+ # 昨日消耗(用于关停消耗门槛:投手经验2.4 "当天消耗>300")
|
|
|
|
|
+ yesterday_cost = float(row.get("yesterday_cost", 0) or 0)
|
|
|
|
|
+
|
|
|
# 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
|
|
# 待优化评估:ROI 偏低 或 衰退信号 或 出价调整候选(需要智能判断)
|
|
|
- roi_low = (not pd.isna(f_roi)) and (f_roi < roi_mean * roi_review_factor)
|
|
|
|
|
|
|
+ # ★ 关停条件对齐投手经验2.4:需要昨日消耗≥300 且 广告年龄>3天
|
|
|
|
|
+ roi_low = (
|
|
|
|
|
+ (not pd.isna(f_roi))
|
|
|
|
|
+ and (f_roi < roi_mean * roi_review_factor)
|
|
|
|
|
+ and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST # 昨日消耗≥300
|
|
|
|
|
+ and (ad_age is not None and ad_age > COLD_START_DAYS) # 广告年龄>3天
|
|
|
|
|
+ )
|
|
|
decay_signal = (
|
|
decay_signal = (
|
|
|
stable_days >= 7
|
|
stable_days >= 7
|
|
|
and cost_7d_avg < 100
|
|
and cost_7d_avg < 100
|
|
|
and (bid_inc or creative_chg)
|
|
and (bid_inc or creative_chg)
|
|
|
)
|
|
)
|
|
|
- # 出价调整候选:高ROI低量(提价)或 ROI偏低(降价)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ # ===== 裂变率 + CTR 数据(用于候选信号判断)=====
|
|
|
|
|
+ ad_fission = row.get("T0裂变系数_7日均值")
|
|
|
|
|
+ if ad_fission is None or pd.isna(ad_fission):
|
|
|
|
|
+ ad_fission = None
|
|
|
|
|
+ else:
|
|
|
|
|
+ ad_fission = float(ad_fission)
|
|
|
|
|
+
|
|
|
|
|
+ tier = _extract_audience_tier(str(row.get("ad_name", "")))
|
|
|
|
|
+ tier_stats = by_tier_stats.get(tier, {})
|
|
|
|
|
+ tier_fission_mean = tier_stats.get("fission_mean")
|
|
|
|
|
+
|
|
|
|
|
+ # CTR 数据
|
|
|
|
|
+ ad_view = float(row.get("view_count", 0) or 0)
|
|
|
|
|
+ ad_click = float(row.get("valid_click_count", 0) or 0)
|
|
|
|
|
+ ad_ctr = ad_click / ad_view if ad_view > 0 else None
|
|
|
|
|
+ tier_ctr_mean = tier_stats.get("ctr_mean")
|
|
|
|
|
+
|
|
|
|
|
+ # ===== 出价调整候选(投手经验对齐)=====
|
|
|
|
|
+ # 提价条件(投手经验1.1+1.2):
|
|
|
|
|
+ # - ROI高于渠道均值5% + 裂变高于同类10% + CTR正常
|
|
|
|
|
+ # - 消耗<1000(投手经验:"均值消耗小于1000")
|
|
|
|
|
+ # - 仅4-7天可提价(投手经验1.2:">7天稳定期建议不调整出价")
|
|
|
bid_up_candidate = (
|
|
bid_up_candidate = (
|
|
|
(not pd.isna(f_roi))
|
|
(not pd.isna(f_roi))
|
|
|
- and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"]
|
|
|
|
|
- and cost_7d_avg < cost_median * params["BID_UP_LOW_SPEND_FACTOR"]
|
|
|
|
|
|
|
+ and f_roi > roi_mean * params["BID_UP_ROI_FACTOR"] # ROI高于渠道均值5%
|
|
|
|
|
+ and cost_7d_avg < BID_UP_MAX_SPEND # 消耗<1000(固定阈值)
|
|
|
and bid_amount > 0
|
|
and bid_amount > 0
|
|
|
|
|
+ and (ad_age is not None and ad_age <= EARLY_GROWTH_DAYS) # ★ 仅4-7天可提价(≤3天已被冷启动排除)
|
|
|
|
|
+ and (tier_fission_mean is None or ad_fission is None # 裂变高于同类均值10%(无数据时跳过)
|
|
|
|
|
+ or ad_fission > tier_fission_mean * 1.10)
|
|
|
|
|
+ and (tier_ctr_mean is None or ad_ctr is None # CTR不低于同类均值80%("正常"定义)
|
|
|
|
|
+ or ad_ctr >= tier_ctr_mean * 0.80)
|
|
|
) if BID_ADJUSTMENT_ENABLED else False
|
|
) if BID_ADJUSTMENT_ENABLED else False
|
|
|
|
|
+
|
|
|
|
|
+ # 降价:ROI低于渠道均值10% + 裂变低于同类10% + 消耗≥500元/天
|
|
|
bid_down_candidate = (
|
|
bid_down_candidate = (
|
|
|
(not pd.isna(f_roi))
|
|
(not pd.isna(f_roi))
|
|
|
- and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"]
|
|
|
|
|
- and f_roi >= roi_mean * params["ROI_LOW_FACTOR"]
|
|
|
|
|
- and cost_7d_avg >= 100
|
|
|
|
|
|
|
+ and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"] # ROI低于渠道均值10%
|
|
|
|
|
+ and f_roi >= roi_mean * params["ROI_LOW_FACTOR"] # 但未达关停线
|
|
|
|
|
+ and cost_7d_avg >= BID_DOWN_MIN_SPEND # 消耗≥500元/天
|
|
|
and bid_amount > 0
|
|
and bid_amount > 0
|
|
|
|
|
+ and (tier_fission_mean is None or ad_fission is None # 裂变低于同类均值10%(无数据时跳过)
|
|
|
|
|
+ or ad_fission < tier_fission_mean * 0.90)
|
|
|
) if BID_ADJUSTMENT_ENABLED else False
|
|
) if BID_ADJUSTMENT_ENABLED else False
|
|
|
|
|
|
|
|
|
|
+ # ===== 持续低ROI升级关停(投手经验2.4:"降价后持续低于均值就关停")=====
|
|
|
|
|
+ persistent_low_roi = False
|
|
|
|
|
+ if (
|
|
|
|
|
+ not roi_low # 当前未达关停线(ROI在0.75~0.90之间)
|
|
|
|
|
+ and (not pd.isna(f_roi))
|
|
|
|
|
+ and f_roi < roi_mean * params["BID_DOWN_ROI_FACTOR"] # ROI仍低于渠道均值10%
|
|
|
|
|
+ and yesterday_cost >= ROI_LOW_MIN_YESTERDAY_COST # 昨日消耗≥300
|
|
|
|
|
+ and (ad_age is not None and ad_age > COLD_START_DAYS) # 年龄>3天
|
|
|
|
|
+ ):
|
|
|
|
|
+ last_bd_ts = adjustment_history.get_last_bid_down_ts(str(row["ad_id"]))
|
|
|
|
|
+ if last_bd_ts is not None:
|
|
|
|
|
+ days_since_bd = (datetime.now() - last_bd_ts).days
|
|
|
|
|
+ if days_since_bd >= 7:
|
|
|
|
|
+ # 降价后≥7天ROI仍低 → 升级为关停候选
|
|
|
|
|
+ persistent_low_roi = True
|
|
|
|
|
+ roi_low = True # 升级!
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ f"广告 {row['ad_id']} 降价后{days_since_bd}天ROI仍低"
|
|
|
|
|
+ f"({f_roi:.4f}<{roi_mean * params['BID_DOWN_ROI_FACTOR']:.4f}),升级为关停候选"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
# 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
|
|
# 扩量候选:成熟期 + 消耗稳定 + 高消耗 + ROI正常(基于决策树)
|
|
|
scale_up_candidate = (
|
|
scale_up_candidate = (
|
|
|
ad_age is not None
|
|
ad_age is not None
|
|
@@ -991,6 +490,18 @@ async def get_ads_for_review(
|
|
|
and f_roi >= roi_mean * 0.9 # ROI正常(≥均值的90%)
|
|
and f_roi >= roi_mean * 0.9 # ROI正常(≥均值的90%)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ # ===== 消耗稳定性前置门控(决策树:成熟期+不稳定→observe)=====
|
|
|
|
|
+ if ad_age is not None and ad_age > EARLY_GROWTH_DAYS and stable_days < 7:
|
|
|
|
|
+ # 成熟期广告但消耗不稳定:清除负向信号,不进入降价/关停评估
|
|
|
|
|
+ if roi_low or decay_signal or bid_down_candidate:
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ f"广告 {row['ad_id']} 成熟期({ad_age}天)但消耗不稳定(稳定天数{stable_days}<7),"
|
|
|
|
|
+ f"清除负向信号: roi_low={roi_low}, decay={decay_signal}, bid_down={bid_down_candidate}"
|
|
|
|
|
+ )
|
|
|
|
|
+ roi_low = False
|
|
|
|
|
+ decay_signal = False
|
|
|
|
|
+ bid_down_candidate = False
|
|
|
|
|
+
|
|
|
# ===== 年龄保护(第一优先级)=====
|
|
# ===== 年龄保护(第一优先级)=====
|
|
|
# 无论是否满足候选条件,年龄保护都是第一层判断
|
|
# 无论是否满足候选条件,年龄保护都是第一层判断
|
|
|
age_protected_skip = False # 标记是否被年龄保护排除
|
|
age_protected_skip = False # 标记是否被年龄保护排除
|
|
@@ -1056,6 +567,10 @@ async def get_ads_for_review(
|
|
|
"bid_amount": round(bid_amount, 2),
|
|
"bid_amount": round(bid_amount, 2),
|
|
|
"bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
|
|
"bid_candidate": "bid_up" if bid_up_candidate else ("bid_down" if bid_down_candidate else None),
|
|
|
"scale_up_candidate": scale_up_candidate, # 新增:扩量候选标记
|
|
"scale_up_candidate": scale_up_candidate, # 新增:扩量候选标记
|
|
|
|
|
+ # ===== 广告自身指标(供LLM对比同类基准) =====
|
|
|
|
|
+ "ad_fission": round(ad_fission, 4) if ad_fission is not None else None,
|
|
|
|
|
+ "ad_ctr": round(ad_ctr, 4) if ad_ctr is not None else None,
|
|
|
|
|
+ "yesterday_cost": round(yesterday_cost, 2),
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
# ===== 新增:添加 audience_tier 和 roi_valid_days =====
|
|
# ===== 新增:添加 audience_tier 和 roi_valid_days =====
|
|
@@ -1075,6 +590,17 @@ async def get_ads_for_review(
|
|
|
ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
|
|
ad_dict["tier_fission_mean"] = tier_stats.get("fission_mean")
|
|
|
ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
|
|
ad_dict["tier_fission_p50"] = tier_stats.get("fission_p50")
|
|
|
|
|
|
|
|
|
|
+ # ===== 新增:CTR + 同类均值出价(基于 tier+goal 分组)=====
|
|
|
|
|
+ ad_dict["tier_ctr_mean"] = tier_stats.get("ctr_mean")
|
|
|
|
|
+ ad_goal = str(row.get("广告优化目标", ""))
|
|
|
|
|
+ tier_goal_key = f"{tier}_{ad_goal}"
|
|
|
|
|
+ tier_goal_stats = by_tier_goal.get(tier_goal_key, tier_stats)
|
|
|
|
|
+ tier_bid_mean = tier_goal_stats.get("bid_mean")
|
|
|
|
|
+
|
|
|
|
|
+ ad_dict["tier_bid_mean"] = tier_bid_mean # 同类(tier+goal)均值出价
|
|
|
|
|
+ ad_dict["bid_up_target_min"] = round(tier_bid_mean * 1.05, 4) if tier_bid_mean else None
|
|
|
|
|
+ ad_dict["bid_up_target_max"] = round(tier_bid_mean * 1.10, 4) if tier_bid_mean else None
|
|
|
|
|
+
|
|
|
# 计算动态阈值(供LLM参考)
|
|
# 计算动态阈值(供LLM参考)
|
|
|
tier_roi_p50 = tier_stats.get("roi_p50", roi_mean) # 兜底用全局均值
|
|
tier_roi_p50 = tier_stats.get("roi_p50", roi_mean) # 兜底用全局均值
|
|
|
|
|
|
|
@@ -1118,6 +644,23 @@ async def get_ads_for_review(
|
|
|
else:
|
|
else:
|
|
|
ad_dict["high_burn_alert"] = False
|
|
ad_dict["high_burn_alert"] = False
|
|
|
|
|
|
|
|
|
|
+ # ===== 调幅参数分离(基于候选类型)=====
|
|
|
|
|
+ if bid_up_candidate:
|
|
|
|
|
+ ad_dict["bid_change_min_pct"] = BID_UP_MIN_PCT # 0.05
|
|
|
|
|
+ ad_dict["bid_change_max_pct"] = BID_UP_MAX_PCT # 0.10
|
|
|
|
|
+ elif bid_down_candidate:
|
|
|
|
|
+ ad_dict["bid_change_min_pct"] = BID_DOWN_MIN_PCT # 0.03
|
|
|
|
|
+ ad_dict["bid_change_max_pct"] = BID_DOWN_MAX_PCT # 0.05
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 兜底:roi_low/decay/scale_up 等非出价候选,LLM 仍可能建议调价
|
|
|
|
|
+ ad_dict["bid_change_min_pct"] = BID_CHANGE_MIN_PCT # 0.03
|
|
|
|
|
+ ad_dict["bid_change_max_pct"] = BID_CHANGE_MAX_PCT # 0.10
|
|
|
|
|
+
|
|
|
|
|
+ # ★ 持续低ROI升级标记(告知LLM这是升级后的关停候选)
|
|
|
|
|
+ if persistent_low_roi:
|
|
|
|
|
+ ad_dict["persistent_low_roi"] = True
|
|
|
|
|
+ ad_dict["recommendation_hint"] = "该广告降价后≥7天ROI仍低于渠道均值,建议关停"
|
|
|
|
|
+
|
|
|
need_review_ads.append(ad_dict)
|
|
need_review_ads.append(ad_dict)
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
@@ -1138,19 +681,20 @@ async def get_ads_for_review(
|
|
|
"p50": round(roi_p50, 4),
|
|
"p50": round(roi_p50, 4),
|
|
|
"p75": round(roi_p75, 4),
|
|
"p75": round(roi_p75, 4),
|
|
|
"p90": round(roi_p90, 4),
|
|
"p90": round(roi_p90, 4),
|
|
|
- "cost_7d_avg_median": round(cost_median, 2),
|
|
|
|
|
},
|
|
},
|
|
|
"bid_adjustment": {
|
|
"bid_adjustment": {
|
|
|
"enabled": BID_ADJUSTMENT_ENABLED,
|
|
"enabled": BID_ADJUSTMENT_ENABLED,
|
|
|
"bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
|
|
"bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
|
|
|
"bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
|
|
"bid_up_line": round(roi_mean * params["BID_UP_ROI_FACTOR"], 4),
|
|
|
- "low_spend_line": round(cost_median * params["BID_UP_LOW_SPEND_FACTOR"], 2),
|
|
|
|
|
|
|
+ "bid_up_max_spend": BID_UP_MAX_SPEND,
|
|
|
|
|
+ "roi_low_min_yesterday_cost": ROI_LOW_MIN_YESTERDAY_COST,
|
|
|
},
|
|
},
|
|
|
"thresholds_used": {
|
|
"thresholds_used": {
|
|
|
"ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
|
|
"ROI_LOW_FACTOR": params["ROI_LOW_FACTOR"],
|
|
|
"BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
|
|
"BID_DOWN_ROI_FACTOR": params["BID_DOWN_ROI_FACTOR"],
|
|
|
"BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
|
|
"BID_UP_ROI_FACTOR": params["BID_UP_ROI_FACTOR"],
|
|
|
- "BID_UP_LOW_SPEND_FACTOR": params["BID_UP_LOW_SPEND_FACTOR"],
|
|
|
|
|
|
|
+ "BID_UP_MAX_SPEND": BID_UP_MAX_SPEND,
|
|
|
|
|
+ "ROI_LOW_MIN_YESTERDAY_COST": ROI_LOW_MIN_YESTERDAY_COST,
|
|
|
"roi_mean": round(roi_mean, 4),
|
|
"roi_mean": round(roi_mean, 4),
|
|
|
"pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
|
|
"pause_line": round(roi_mean * params["ROI_LOW_FACTOR"], 4),
|
|
|
"bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
|
|
"bid_down_line": round(roi_mean * params["BID_DOWN_ROI_FACTOR"], 4),
|
|
@@ -1522,10 +1066,8 @@ async def query_ad_detail(
|
|
|
# 全局 ROI 分布
|
|
# 全局 ROI 分布
|
|
|
roi_series = df["动态ROI_7日均值"].dropna()
|
|
roi_series = df["动态ROI_7日均值"].dropna()
|
|
|
roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
|
|
roi_mean = float(roi_series.mean()) if len(roi_series) > 0 else 0.0
|
|
|
- cost_series = df["cost_7d_avg"].dropna()
|
|
|
|
|
- cost_median = float(cost_series.median()) if len(cost_series) > 0 else 0.0
|
|
|
|
|
|
|
|
|
|
- roi_low_line = roi_mean * ROI_LOW_FACTOR if "ROI_LOW_FACTOR" in dir() else roi_mean * 0.5
|
|
|
|
|
|
|
+ roi_low_line = roi_mean * ROI_LOW_FACTOR
|
|
|
bid_down_line = roi_mean * BID_DOWN_ROI_FACTOR
|
|
bid_down_line = roi_mean * BID_DOWN_ROI_FACTOR
|
|
|
bid_up_line = roi_mean * BID_UP_ROI_FACTOR
|
|
bid_up_line = roi_mean * BID_UP_ROI_FACTOR
|
|
|
|
|
|
|
@@ -1563,10 +1105,11 @@ async def query_ad_detail(
|
|
|
# 全局上下文
|
|
# 全局上下文
|
|
|
global_context = {
|
|
global_context = {
|
|
|
"全体动态ROI均值": round(roi_mean, 4),
|
|
"全体动态ROI均值": round(roi_mean, 4),
|
|
|
- "ROI关停线": round(roi_mean * 0.5, 4),
|
|
|
|
|
|
|
+ "ROI关停线": round(roi_low_line, 4),
|
|
|
"ROI降价线": round(bid_down_line, 4),
|
|
"ROI降价线": round(bid_down_line, 4),
|
|
|
"ROI提价线": round(bid_up_line, 4),
|
|
"ROI提价线": round(bid_up_line, 4),
|
|
|
- "全体消耗中位数": round(cost_median, 2),
|
|
|
|
|
|
|
+ "提价消耗上限": BID_UP_MAX_SPEND,
|
|
|
|
|
+ "关停消耗门槛(昨日)": ROI_LOW_MIN_YESTERDAY_COST,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
result = {
|
|
result = {
|