""" 逐年预测撒网密度 / 分辨力基线实验 ================================= 为什么要有这个文件 ------------------ `~/.claude/skills/trinity-destiny/SKILL.md`「Step 5.5 证据强度审计」②: 命中次数在没有基线率 / 假阳性率的情况下没有意义。 算不出假阳性率的规律只能标「待追踪」,不能升级为判读规则。 逐年吉凶是付费正文的一部分。要卖它,就得先能回答「闭着眼睛猜能中多少」。 本脚本只产出数字,不产出结论。 三个踩过的坑(写在这里免得下次重犯) ------------------------------------ 1. 必须用 BaZiCalculator.calculate_bazi_complete(include_dayun=True)。 用 calculate_bazi() 拿不到大运→拿不到流年→风险年恒为 0, 会得到一份「全 0」的漂亮结果。全 0 是 harness 坏了,不是发现。 2. 用户实际看到的是 analyze_*() 的公开输出;它会先裁到当前窗口,再按显著度 封顶。基线必须同时报告「窗口内候选」与「最终呈现」,否则封顶会被误读成 命理分辨力。 3. 分母是**当前大运窗口长度**,不是一辈子。用户读的是这 12 年;窗口内 被隐藏的候选不能悄悄算进命中率。 用法 ---- python3 experiments/baseline_density.py """ import sys, os from collections import defaultdict sys.path.insert(0, '/Volumes/sparksverse/huggingface-spaces/astrologybazi-api') os.chdir('/Volumes/sparksverse/huggingface-spaces/astrologybazi-api') from bazi_calculator import BaZiCalculator from genju_analyzer import GenjuAnalyzer from dayun_analyzer import DayunAnalyzer from marriage_analyzer import MarriageAnalyzer from official_analyzer import OfficialAnalyzer from legal_risk_analyzer import LegalRiskAnalyzer from fortune_window import filter_to_window CHARTS = [('小鱼','1981-10-04','20:38','female'),('林磊','1983-12-13','13:15','female'), ('Jason','1978-10-30','10:00','male'),('C4','2008-07-31','21:00','female'), ('C5','2012-04-09','07:30','male'),('C6','2014-04-15','16:30','male'), ('C7','2013-05-13','03:40','male'),('C8','1990-01-08','06:30','male'), ('C9','1980-06-07','17:30','female')] NOW = 2026 DOM = ['婚姻风险','官非风险','升迁','灾劫'] def ages(lst): return sorted({e['age'] for e in (lst or []) if isinstance(e, dict) and 'age' in e}) class _NoCapList(list): """诊断用列表:让婚姻模块跳过展示层 [:3],保留候选供对照。 MarriageAnalyzer 的「全局最多 20 条 + 当前窗口最多 3 条」逻辑目前写在 一个方法里,没有单独的 raw API。这个小替身只在本实验中使用,不能进入 生产路径;结果应理解为“模块内部全局筛选之后、窗口展示封顶之前”的候选。 """ def __len__(self): actual = super().__len__() return min(actual, 3) def marriage_candidates_before_window(chart, dayun): """读取婚姻模块的窗口前候选,不改变生产方法。""" import fortune_window original_filter = fortune_window.filter_to_window try: fortune_window.filter_to_window = lambda entries, window, age_key='age': _NoCapList(entries or []) diagnostic = MarriageAnalyzer(chart, dayun).analyze_marriage() finally: fortune_window.filter_to_window = original_filter return (diagnostic.get('marriage_fortune_years') or {}).get('risk_years') or [] def collect(name, date, time, gender): chart = BaZiCalculator.calculate_bazi_complete(birth_date=date, birth_time=time, gender=gender, is_lunar=False, include_dayun=True, num_dayun=12) genju = GenjuAnalyzer(chart).analyze_genju() age = NOW - int(date[:4]) dayun = DayunAnalyzer(chart, genju, current_age=age).analyze_all_dayun() mar = MarriageAnalyzer(chart, dayun).analyze_marriage() off = OfficialAnalyzer(chart, genju, dayun).analyze_official() leg = LegalRiskAnalyzer(chart, genju, dayun).analyze_legal_risk() fy = mar.get('marriage_fortune_years') or {} win = (fy.get('window') or {}) lo, hi = win.get('start_age'), win.get('end_age') d = {'婚姻风险': ages(fy.get('risk_years')), '官非风险': ages(off.get('official_risk_years')), '升迁': ages(off.get('promotion_years')), '灾劫': ages(leg.get('disaster_years'))} # 诊断层:公开输出以外,量出窗口内原本有多少候选。这里不把候选当作 # “预测命中”,只是为了识别 [:3] 封顶造成的密度变化。 off_analyzer = OfficialAnalyzer(chart, genju, dayun) leg_analyzer = LegalRiskAnalyzer(chart, genju, dayun) raw_entries = { '婚姻风险': marriage_candidates_before_window(chart, dayun), '官非风险': off_analyzer._analyze_official_risk_years(), '升迁': off_analyzer._analyze_promotion_years(), '灾劫': leg_analyzer._predict_disaster_years(), } raw_window = {x: ages(filter_to_window(raw_entries[x], (lo, hi))) for x in DOM} return {'name': name, 'by': int(date[:4]), 'age': age, 'win': (lo, hi), 'len': (hi - lo + 1) if lo else None, 'd': d, 'raw_window': raw_window, 'raw_total': {x: sum(1 for _ in raw_entries[x]) for x in DOM}} R = [collect(*c) for c in CHARTS] print('='*86) print('Q1 · 上线后的实际密度(analyze_*() 公开方法输出)') print('='*86) print(f"{'命盘':<7}{'龄':>4}{'窗口':>9}{'窗长':>5}" + ''.join(f'{x:>11}' for x in DOM) + f"{'并集':>13}") print('-'*86) acc = defaultdict(list); uni = [] for r in R: n = r['len'] if not n: print(f"{r['name']:<7}{r['age']:>4} 窗口不可用"); continue winstr = '%d-%d' % (r['win'][0], r['win'][1]) row = f"{r['name']:<7}{r['age']:>4}{winstr:>9}{n:>5}" allyrs = set() for x in DOM: k = len(r['d'][x]); acc[x].append(k/n); allyrs |= set(r['d'][x]) row += f"{k:>3}年{k/n:>6.0%}" u = len(allyrs)/n; uni.append(u) row += f"{len(allyrs):>5}年{u:>7.0%}" print(row) print('-'*86) print(f"{'平均':<7}{'':>18}" + ''.join(f"{'':>3} {sum(acc[x])/len(acc[x]):>6.0%}" for x in DOM) + f"{'':>5} {sum(uni)/len(uni):>7.0%}") print() print('='*86) print('Q2 · 分辨力:同一公历年,9 张盘里有几张同时被标') print('='*86) for x in DOM: hits = defaultdict(list) for r in R: for a in r['d'][x]: hits[r['by'] + a].append(r['name']) if not hits: print(f'【{x}】无标记'); continue w = max(hits.items(), key=lambda kv: len(kv[1])) tot = sum(len(v) for v in hits.values()) print(f'【{x}】{len(hits)} 个不同年份承载 {tot} 个标记;' f'最拥挤 {w[0]} 被 {len(w[1])}/9 盘同时标记 ({",".join(w[1])})') print() print('='*86) print('Q3 · 唯一能精确核对的四件事(林磊)') print('='*86) ll = [r for r in R if r['name'] == '林磊'][0] lo, hi = ll['win'] print(f"当前大运窗口 {lo}-{hi} 岁 = 公历 {ll['by']+lo}–{ll['by']+hi}") print(f"婚姻风险标记年: {[ll['by']+a for a in ll['d']['婚姻风险']]}") for y, lab in [(2020,'离婚(2020-21)'),(2021,'离婚(2020-21)'),(2010,'儿子出生'),(2012,'女儿出生')]: a = y - ll['by'] inw = lo <= a <= hi flag = a in ll['d']['婚姻风险'] print(f' {y}({a}岁) {lab:<14} 落在窗口内={"是" if inw else "否"} 被标记={"是" if flag else "否"}') print() print('='*86) print('Q4 · 排除替代解释:被选中的年份是「最显著」还是「最早」?') print('='*86) print('_focus_years 的排序是 (level, age) 再 [:3],同级别时年龄小的胜出。') print('若被选年份系统性挤在窗口前半段 → 呈现的是「最早」不是「最显著」。') print() print(f"{'领域':<10}{'被选年份在窗口中的相对位置(0=窗口头,1=窗口尾)':<20}") for x in DOM: pos = [] for r in R: lo, hi = r['win'] if not lo or hi == lo: continue for a in r['d'][x]: pos.append((a - lo) / (hi - lo)) if not pos: continue pos.sort() front = sum(1 for p in pos if p <= 0.5) / len(pos) print(f' {x:<10} 平均位置 {sum(pos)/len(pos):.2f} ' f'落在窗口前半段的比例 {front:.0%} (n={len(pos)})') print() print('若随机分布,平均位置应≈0.50、前半段比例应≈50%。') print() print('='*86) print('Q5 · 当前窗口候选 vs 实际呈现(上限效应)') print('='*86) print('候选 = 模块已完成自身规则筛选、但尚未套当前窗口展示上限的年份;') print('呈现 = 用户实际拿到的年份。隐藏不是“被验证为错误”,只是展示层封顶。') print() print(f"{'领域':<10}{'窗口候选':>12}{'实际呈现':>12}{'隐藏':>10}{'呈现率':>10}") for x in DOM: raw = sum(len(r['raw_window'][x]) for r in R) shown = sum(len(r['d'][x]) for r in R) hidden = raw - shown ratio = shown / raw if raw else 0 print(f"{x:<10}{raw:>12}{shown:>12}{hidden:>10}{ratio:>9.0%}") print() print('解释:若“窗口候选”远高于“实际呈现”,密度下降来自产品封顶而非规则本身;') print('因此在获得真实事件追踪前,逐年模块只能作为阶段提示,不能宣称准确率。')