astrologybazi-api / experiments /baseline_density.py
th
fix(reading): add multi-chart evidence guardrails
fefbb42
Raw History Blame Contribute Delete
9.29 kB
"""
逐年预测撒网密度 / 分辨力基线实验
=================================
为什么要有这个文件
------------------
`~/.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('因此在获得真实事件追踪前,逐年模块只能作为阶段提示,不能宣称准确率。')