analyze_snapshot.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. """
  2. V4 决策快照分析 — 实测观测用
  3. 用法:
  4. cd /Users/liulidong/project/agent/Agent
  5. .venv/bin/python3 examples/auto_put_ad_mini/analyze_snapshot.py --date 20260415
  6. 输入:examples/auto_put_ad_mini/outputs/decisions_history/{date}/snapshot.json
  7. 输出:
  8. - stdout:可读报告
  9. - 同目录 quality_report.json:结构化指标
  10. 评估维度(9 项):
  11. 1. 决策分布(action / source / LLM 占比)
  12. 2. bid_down 多样性(dimension 种类数 = 上一轮瓶颈)
  13. 3. rule_alignment 分布 + override 率
  14. 4. reasoning_chain 质量(覆盖率、长度、引用信号)
  15. 5. 按 tier 分布
  16. 6. 信号健康度(decay/bid_down/bid_up 分布)
  17. 7. 同桶决策一致性(抽样熵)
  18. 8. 上下文规模估算
  19. 9. 质量评级判定
  20. """
  21. import argparse
  22. import json
  23. import math
  24. import sys
  25. from collections import Counter
  26. from pathlib import Path
  27. from typing import Any, Dict, List, Optional, Tuple
  28. HERE = Path(__file__).resolve().parent
  29. # ═══════════════════════════════════════════
  30. # 工具函数
  31. # ═══════════════════════════════════════════
  32. def _num_stats(values: List[Any]) -> Optional[Dict[str, float]]:
  33. """计算数值列表的统计量(count/mean/min/max/p25/p50/p75),无有效数值时返回 None"""
  34. nums = [float(v) for v in values
  35. if v is not None and not (isinstance(v, float) and math.isnan(v))]
  36. if not nums:
  37. return None
  38. nums_sorted = sorted(nums)
  39. n = len(nums_sorted)
  40. return {
  41. "count": n,
  42. "mean": round(sum(nums_sorted) / n, 4),
  43. "min": round(nums_sorted[0], 4),
  44. "max": round(nums_sorted[-1], 4),
  45. "p25": round(nums_sorted[max(0, n // 4)], 4),
  46. "p50": round(nums_sorted[n // 2], 4),
  47. "p75": round(nums_sorted[min(n - 1, 3 * n // 4)], 4),
  48. }
  49. def _mean(values: List[Any]) -> Optional[float]:
  50. """计算数值列表的均值(忽略 None/NaN),无有效数值时返回 None"""
  51. nums = [float(v) for v in values
  52. if v is not None and not (isinstance(v, float) and math.isnan(v))]
  53. return round(sum(nums) / len(nums), 4) if nums else None
  54. def _nonzero_rate(values: List[Any]) -> Optional[float]:
  55. """计算数值列表中大于 0 的占比(忽略 None/NaN),无有效数值时返回 None"""
  56. nums = [v for v in values
  57. if v is not None and not (isinstance(v, float) and math.isnan(v))]
  58. if not nums:
  59. return None
  60. return round(sum(1 for v in nums if float(v) > 0) / len(nums), 3)
  61. def load_snapshot(date: str) -> Dict[str, Any]:
  62. """加载指定日期的决策快照 snapshot.json,不存在时抛出 FileNotFoundError"""
  63. snap_path = HERE / "outputs" / "decisions_history" / date / "snapshot.json"
  64. if not snap_path.exists():
  65. raise FileNotFoundError(f"快照不存在: {snap_path}")
  66. return json.loads(snap_path.read_text(encoding="utf-8"))
  67. # ═══════════════════════════════════════════
  68. # 分析模块
  69. # ═══════════════════════════════════════════
  70. def action_distribution(decisions: List[Dict]) -> Dict[str, Any]:
  71. """统计决策的 action / source 分布及 LLM 决策占比"""
  72. total = len(decisions)
  73. action_counter: Counter = Counter()
  74. source_counter: Counter = Counter()
  75. for d in decisions:
  76. action_counter[d.get("action", "unknown")] += 1
  77. source_counter[d.get("source", "unknown")] += 1
  78. llm_keys = {"llm", "llm_main", "llm_override"}
  79. llm_count = sum(v for k, v in source_counter.items() if k in llm_keys)
  80. return {
  81. "total": total,
  82. "action_counts": dict(action_counter),
  83. "action_pct": {k: round(v / total, 3) for k, v in action_counter.items()} if total else {},
  84. "source_counts": dict(source_counter),
  85. "llm_decision_count": llm_count,
  86. "llm_decision_pct": round(llm_count / total, 3) if total else 0,
  87. }
  88. def bid_down_diversity(decisions: List[Dict]) -> Dict[str, Any]:
  89. """分析 bid_down 决策的多样性:dimension 种类、scenario 分布及推荐幅度统计"""
  90. bid_down = [d for d in decisions if d.get("action") == "bid_down"]
  91. dim_counter = Counter(d.get("dimension", "unknown") for d in bid_down)
  92. scenario_counter = Counter(
  93. (d.get("signal_scores") or {}).get("bid_down_scenario", "unknown")
  94. for d in bid_down
  95. )
  96. pcts = [d.get("recommended_change_pct") for d in bid_down
  97. if d.get("recommended_change_pct") is not None]
  98. return {
  99. "total": len(bid_down),
  100. "dimension_count": len(dim_counter),
  101. "dimensions": dict(dim_counter.most_common(10)),
  102. "scenarios": dict(scenario_counter.most_common()),
  103. "pct_stats": _num_stats(pcts),
  104. }
  105. def rule_alignment_dist(decisions: List[Dict]) -> Dict[str, Any]:
  106. """统计 rule_alignment 分布与 override 率(跳过 auto_pause),附 override 原因样本"""
  107. align_counter: Counter = Counter()
  108. override_reasons: List[str] = []
  109. for d in decisions:
  110. # 跳过 auto_pause(规则硬底线,不参与 align 统计)
  111. if d.get("source") == "auto_pause":
  112. continue
  113. align = d.get("rule_alignment") or "unknown"
  114. align_counter[align] += 1
  115. if align == "override" and d.get("override_reason"):
  116. override_reasons.append(str(d["override_reason"]))
  117. known_total = sum(v for k, v in align_counter.items() if k != "unknown")
  118. return {
  119. "counts": dict(align_counter),
  120. "override_pct": (round(align_counter.get("override", 0) / known_total, 3)
  121. if known_total else None),
  122. "override_reason_samples": override_reasons[:5],
  123. }
  124. def reasoning_chain_quality(decisions: List[Dict]) -> Dict[str, Any]:
  125. """评估 LLM 决策的 reasoning_chain 质量:覆盖率、长度统计与高频引用信号"""
  126. lengths: List[int] = []
  127. signal_counter: Counter = Counter()
  128. has_chain = 0
  129. total_llm = 0
  130. for d in decisions:
  131. # 只看非 auto_pause 的决策(LLM 产出的)
  132. if d.get("source") == "auto_pause":
  133. continue
  134. total_llm += 1
  135. chain = d.get("reasoning_chain")
  136. if isinstance(chain, list) and len(chain) > 0:
  137. has_chain += 1
  138. lengths.append(len(chain))
  139. for item in chain:
  140. if isinstance(item, dict):
  141. signal_counter[str(item.get("signal", "unknown"))] += 1
  142. return {
  143. "llm_decisions": total_llm,
  144. "with_reasoning_chain": has_chain,
  145. "reasoning_chain_rate": round(has_chain / total_llm, 3) if total_llm else 0,
  146. "length_stats": _num_stats(lengths),
  147. "top_signals": dict(signal_counter.most_common(10)),
  148. }
  149. def tier_breakdown(decisions: List[Dict]) -> Dict[str, Any]:
  150. """按 audience_tier 分组统计决策数、action 分布及各信号分均值"""
  151. tier_groups: Dict[str, List[Dict]] = {}
  152. for d in decisions:
  153. tier = (d.get("input_signals") or {}).get("audience_tier", "default")
  154. tier_groups.setdefault(str(tier), []).append(d)
  155. out = {}
  156. for tier, items in sorted(tier_groups.items()):
  157. action_counter = Counter(d.get("action", "unknown") for d in items)
  158. ss = [(d.get("signal_scores") or {}) for d in items]
  159. out[tier] = {
  160. "count": len(items),
  161. "actions": dict(action_counter),
  162. "avg_decay_score": _mean([s.get("decay_score") for s in ss]),
  163. "avg_bid_down_score": _mean([s.get("bid_down_score") for s in ss]),
  164. "avg_bid_up_score": _mean([s.get("bid_up_score") for s in ss]),
  165. }
  166. return out
  167. def signal_health(decisions: List[Dict]) -> Dict[str, Any]:
  168. """统计 decay/bid_down/bid_up 三类信号分的分布与非零率,评估信号健康度"""
  169. decay, bd, bu = [], [], []
  170. for d in decisions:
  171. ss = d.get("signal_scores") or {}
  172. decay.append(ss.get("decay_score"))
  173. bd.append(ss.get("bid_down_score"))
  174. bu.append(ss.get("bid_up_score"))
  175. return {
  176. "decay_score": _num_stats(decay),
  177. "bid_down_score": _num_stats(bd),
  178. "bid_up_score": _num_stats(bu),
  179. "decay_nonzero_rate": _nonzero_rate(decay),
  180. "bid_down_nonzero_rate": _nonzero_rate(bd),
  181. "bid_up_nonzero_rate": _nonzero_rate(bu),
  182. }
  183. def consistency_check(decisions: List[Dict]) -> Dict[str, Any]:
  184. """
  185. 同桶决策一致性:
  186. 按 (tier, roi bucket 0.2, decay bucket 0.2) 分组,计算每组 action 分布熵。
  187. 熵越高 = 决策越不一致。
  188. """
  189. buckets: Dict[Tuple[str, float, float], List[str]] = {}
  190. for d in decisions:
  191. ss = d.get("signal_scores") or {}
  192. inp = d.get("input_signals") or {}
  193. tier = str(inp.get("audience_tier") or "default")
  194. roi = inp.get("动态ROI_7日均值")
  195. decay = ss.get("decay_score")
  196. if roi is None or decay is None:
  197. continue
  198. try:
  199. roi_f = float(roi)
  200. decay_f = float(decay)
  201. if math.isnan(roi_f) or math.isnan(decay_f):
  202. continue
  203. except (TypeError, ValueError):
  204. continue
  205. roi_b = round(math.floor(roi_f / 0.2) * 0.2, 1)
  206. decay_b = round(math.floor(decay_f / 0.2) * 0.2, 1)
  207. key = (tier, roi_b, decay_b)
  208. buckets.setdefault(key, []).append(str(d.get("action", "unknown")))
  209. high_entropy: List[Dict[str, Any]] = []
  210. entropies: List[float] = []
  211. for key, actions in buckets.items():
  212. if len(actions) < 2:
  213. continue
  214. c = Counter(actions)
  215. n = len(actions)
  216. entropy = -sum((v / n) * math.log2(v / n) for v in c.values() if v > 0)
  217. entropies.append(entropy)
  218. if entropy > 0.8:
  219. high_entropy.append({
  220. "bucket": f"tier={key[0]}, roi~{key[1]}, decay~{key[2]}",
  221. "n": n,
  222. "actions": dict(c),
  223. "entropy": round(entropy, 3),
  224. })
  225. high_entropy.sort(key=lambda x: x["entropy"], reverse=True)
  226. return {
  227. "buckets_with_gt1": sum(1 for _, a in buckets.items() if len(a) >= 2),
  228. "avg_entropy": round(sum(entropies) / len(entropies), 3) if entropies else 0,
  229. "max_entropy": round(max(entropies), 3) if entropies else 0,
  230. "high_entropy_buckets": high_entropy[:5],
  231. }
  232. def context_size_est(decisions: List[Dict]) -> Dict[str, Any]:
  233. """估算每次 LLM tier 批次的输入 token 量"""
  234. tier_counts: Counter = Counter()
  235. for d in decisions:
  236. if d.get("source") == "auto_pause":
  237. continue
  238. tier = str((d.get("input_signals") or {}).get("audience_tier", "default"))
  239. tier_counts[tier] += 1
  240. sample_reviews = [d for d in decisions if d.get("source") != "auto_pause"][:5]
  241. avg_bytes = 0
  242. if sample_reviews:
  243. avg_bytes = sum(len(json.dumps(d, ensure_ascii=False)) for d in sample_reviews) / len(sample_reviews)
  244. by_tier_est: Dict[str, Dict[str, int]] = {}
  245. for tier, cnt in tier_counts.items():
  246. # 1 token ≈ 3 字节(中文偏 2~3 字节/token)+ 8K 系统 prompt
  247. bytes_est = cnt * avg_bytes + 8000
  248. by_tier_est[tier] = {
  249. "review_count": int(cnt),
  250. "est_bytes": int(bytes_est),
  251. "est_tokens": int(bytes_est / 3),
  252. }
  253. max_tier_tokens = max((v["est_tokens"] for v in by_tier_est.values()), default=0)
  254. return {
  255. "avg_review_bytes": int(avg_bytes),
  256. "by_tier": by_tier_est,
  257. "max_tier_tokens": max_tier_tokens,
  258. }
  259. # ═══════════════════════════════════════════
  260. # 报告构建 + 判定
  261. # ═══════════════════════════════════════════
  262. def build_report(snapshot: Dict) -> Dict:
  263. """汇总各分析模块,构建完整的结构化质量报告"""
  264. decisions = snapshot.get("decisions", [])
  265. return {
  266. "metadata": snapshot.get("metadata", {}),
  267. "action_distribution": action_distribution(decisions),
  268. "bid_down_diversity": bid_down_diversity(decisions),
  269. "rule_alignment": rule_alignment_dist(decisions),
  270. "reasoning_chain": reasoning_chain_quality(decisions),
  271. "tier_breakdown": tier_breakdown(decisions),
  272. "signal_health": signal_health(decisions),
  273. "consistency": consistency_check(decisions),
  274. "context_size_est": context_size_est(decisions),
  275. }
  276. def assess(report: Dict) -> Tuple[str, List[str]]:
  277. """决策质量评级判定"""
  278. flags: List[str] = []
  279. # bid_down 多样性
  280. bd_div = report["bid_down_diversity"]["dimension_count"]
  281. if bd_div < 3:
  282. flags.append(f"bid_down dimension 种类 {bd_div}(< 3,多样性不足)")
  283. # LLM 决策占比(auto_pause 不算 LLM)
  284. llm_pct = report["action_distribution"]["llm_decision_pct"]
  285. if llm_pct < 0.7:
  286. flags.append(f"LLM 决策占比 {llm_pct}(< 70%,规则压过 LLM)")
  287. # override 率
  288. ra = report["rule_alignment"]["override_pct"]
  289. if ra is not None:
  290. if ra < 0.05:
  291. flags.append(f"override 率 {ra}(< 5%,LLM 不敢推翻规则)")
  292. elif ra > 0.4:
  293. flags.append(f"override 率 {ra}(> 40%,signal_scores 可能不准)")
  294. # reasoning_chain
  295. rc_rate = report["reasoning_chain"]["reasoning_chain_rate"]
  296. if rc_rate < 0.95:
  297. flags.append(f"reasoning_chain 缺失率 {round(1 - rc_rate, 3)}(LLM 未遵循 schema)")
  298. # 一致性
  299. max_ent = report["consistency"]["max_entropy"]
  300. if max_ent > 1.2:
  301. flags.append(f"同桶决策熵最大 {max_ent}(> 1.2,稳定性不足)")
  302. # token
  303. max_tok = report["context_size_est"]["max_tier_tokens"]
  304. if max_tok > 100_000:
  305. flags.append(f"最大 tier token {max_tok}(> 100K,需细分桶)")
  306. # 分支判定
  307. if not flags:
  308. return "pass: 架构够用,可进入后验采集下一 plan", flags
  309. for f in flags:
  310. if "token" in f:
  311. return "refine_tier: tier 细分桶 / signal_scores 精简", flags
  312. for f in flags:
  313. if "决策熵" in f:
  314. return "deep_judge: 考虑对边缘案例单条深判", flags
  315. return "tune_prompt: 调 prompt 或 DECAY_WEIGHTS", flags
  316. # ═══════════════════════════════════════════
  317. # 可读输出
  318. # ═══════════════════════════════════════════
  319. def print_report(report: Dict, assessment: Tuple[str, List[str]]) -> None:
  320. """将质量报告与判定结果格式化输出到 stdout"""
  321. meta = report["metadata"]
  322. print("=" * 70)
  323. print("V4 决策快照分析报告")
  324. print(f"决策日期 : {meta.get('decision_date')}")
  325. print(f"Agent 版本 : {meta.get('agent_version')}")
  326. print(f"LLM 模型 : {meta.get('llm_model')}")
  327. print(f"决策总数 : {meta.get('decision_count')}")
  328. print(f"运行时间 : {meta.get('run_timestamp')}")
  329. print("=" * 70)
  330. # 1. 决策分布
  331. print("\n【1. 决策分布】")
  332. ad = report["action_distribution"]
  333. print(f" Action: {ad['action_counts']}")
  334. print(f" Source: {ad['source_counts']}")
  335. print(f" LLM 决策占比: {ad['llm_decision_pct'] * 100:.1f}% (目标 >= 70%)")
  336. # 2. bid_down 多样性
  337. print("\n【2. bid_down 多样性(上一轮瓶颈)】")
  338. bd = report["bid_down_diversity"]
  339. print(f" bid_down 总数: {bd['total']}")
  340. print(f" Dimension 种类: {bd['dimension_count']} (目标 >= 4)")
  341. if bd["dimensions"]:
  342. print(f" Dimensions: {bd['dimensions']}")
  343. if bd["scenarios"]:
  344. print(f" Scenarios: {bd['scenarios']}")
  345. if bd["pct_stats"]:
  346. print(f" 推荐幅度: mean={bd['pct_stats']['mean']}, "
  347. f"p25/50/75={bd['pct_stats']['p25']}/{bd['pct_stats']['p50']}/{bd['pct_stats']['p75']}")
  348. # 3. rule_alignment
  349. print("\n【3. rule_alignment(LLM 是否推翻规则)】")
  350. ra = report["rule_alignment"]
  351. print(f" 分布: {ra['counts']}")
  352. print(f" override 率: {ra['override_pct']} (目标 10% ~ 25%)")
  353. if ra["override_reason_samples"]:
  354. print(f" override_reason 样本:")
  355. for i, r in enumerate(ra["override_reason_samples"][:3], 1):
  356. print(f" [{i}] {r[:80]}")
  357. # 4. reasoning_chain
  358. print("\n【4. reasoning_chain 质量】")
  359. rc = report["reasoning_chain"]
  360. print(f" LLM 决策数: {rc['llm_decisions']}")
  361. print(f" 含 chain: {rc['with_reasoning_chain']} ({rc['reasoning_chain_rate'] * 100:.1f}%) (目标 >= 95%)")
  362. if rc["length_stats"]:
  363. print(f" chain 长度: mean={rc['length_stats']['mean']}, p50={rc['length_stats']['p50']}")
  364. if rc["top_signals"]:
  365. print(f" Top 引用信号: {rc['top_signals']}")
  366. # 5. tier 分布
  367. print("\n【5. 按 tier 分布】")
  368. for tier, info in report["tier_breakdown"].items():
  369. print(f" {tier:8s} count={info['count']:3d} actions={info['actions']}")
  370. print(f" avg: decay={info['avg_decay_score']} "
  371. f"bd={info['avg_bid_down_score']} bu={info['avg_bid_up_score']}")
  372. # 6. 信号健康度
  373. print("\n【6. 信号健康度】")
  374. sh = report["signal_health"]
  375. if sh["decay_score"]:
  376. s = sh["decay_score"]
  377. print(f" decay_score : mean={s['mean']} max={s['max']} nonzero={sh['decay_nonzero_rate']}")
  378. if sh["bid_down_score"]:
  379. s = sh["bid_down_score"]
  380. print(f" bid_down_score : mean={s['mean']} max={s['max']} nonzero={sh['bid_down_nonzero_rate']}")
  381. if sh["bid_up_score"]:
  382. s = sh["bid_up_score"]
  383. print(f" bid_up_score : mean={s['mean']} max={s['max']} nonzero={sh['bid_up_nonzero_rate']}")
  384. # 7. 同桶一致性
  385. print("\n【7. 同桶决策一致性】")
  386. c = report["consistency"]
  387. print(f" 有效桶(>= 2 广告): {c['buckets_with_gt1']}")
  388. print(f" 平均熵: {c['avg_entropy']} max: {c['max_entropy']} (告警 > 1.2)")
  389. if c["high_entropy_buckets"]:
  390. print(" 高熵桶 Top 3:")
  391. for b in c["high_entropy_buckets"][:3]:
  392. print(f" {b['bucket']} n={b['n']} {b['actions']} entropy={b['entropy']}")
  393. # 8. 上下文规模
  394. print("\n【8. 上下文规模估算】")
  395. cs = report["context_size_est"]
  396. print(f" 平均 review 字节: {cs['avg_review_bytes']}")
  397. print(f" 最大 tier token: {cs['max_tier_tokens']} (告警 > 100K)")
  398. for tier, info in cs["by_tier"].items():
  399. print(f" {tier:8s} {info['review_count']:3d} ads → ~{info['est_tokens']} tokens")
  400. # 9. 判定
  401. print("\n【9. 判定】")
  402. case, flags = assessment
  403. print(f" → {case}")
  404. if flags:
  405. for f in flags:
  406. print(f" • {f}")
  407. else:
  408. print(" ✅ 所有关键指标达标")
  409. # ═══════════════════════════════════════════
  410. # Entry
  411. # ═══════════════════════════════════════════
  412. def main() -> None:
  413. """脚本入口:加载快照、生成质量报告、打印并落盘 quality_report.json"""
  414. parser = argparse.ArgumentParser(description="V4 决策快照分析")
  415. parser.add_argument("--date", required=True, help="决策日期 YYYYMMDD")
  416. args = parser.parse_args()
  417. try:
  418. snapshot = load_snapshot(args.date)
  419. except FileNotFoundError as e:
  420. print(f"❌ {e}")
  421. sys.exit(1)
  422. report = build_report(snapshot)
  423. assessment = assess(report)
  424. print_report(report, assessment)
  425. # 落盘
  426. out_dir = HERE / "outputs" / "decisions_history" / args.date
  427. out_path = out_dir / "quality_report.json"
  428. out_path.write_text(
  429. json.dumps(
  430. {
  431. "report": report,
  432. "assessment": {"case": assessment[0], "flags": assessment[1]},
  433. },
  434. ensure_ascii=False,
  435. indent=2,
  436. ),
  437. encoding="utf-8",
  438. )
  439. print(f"\n✅ 报告落盘: {out_path}")
  440. if __name__ == "__main__":
  441. main()