"""因果链验证核心逻辑 — 数据验证(Pearson相关性)+ 状态机 (2026-08-27 P2) 三层验证: 1. 数据验证(自动化):kpi_values 历史值 → Pearson 相关系数 + 方向一致性 + 滞后对齐 2. AI/人工验证:战略回顾会人工打标 human_verified(API PUT /verify) 3. 状态机流转: pending(初始)→ data_verified / disputed(数据验证 cron) pending/data_verified/disputed → human_verified(人工确认,最终) 任何矛盾 → disputed(待检) 判定规则(可解释、可测试): - 对齐后数据点 < MIN_POINTS(4) → pending(数据不足,无法统计验证) - |r| >= CORR_THRESHOLD(0.5) 且方向与 direction 一致 → data_verified - 否则(点数足够但弱相关/方向矛盾)→ disputed - human_verified 为人工最终确认,脚本默认不覆盖(respect_human=True), 但若数据矛盾会在报告中给出警示(disputed_note) 脚本入口: scripts/correlation-check.py 测试入口: tests/test_causality_verification.py """ from __future__ import annotations import logging import math import re from typing import Dict, List, Optional, Tuple logger = logging.getLogger("causality-verification") # 验证状态 STATUS_PENDING = "pending" STATUS_DATA_VERIFIED = "data_verified" STATUS_HUMAN_VERIFIED = "human_verified" STATUS_DISPUTED = "disputed" ALL_STATUSES = (STATUS_PENDING, STATUS_DATA_VERIFIED, STATUS_HUMAN_VERIFIED, STATUS_DISPUTED) # 建链来源 SOURCE_MANUAL = "manual" SOURCE_AI = "AI_suggested" SOURCE_IMPORTED = "imported" ALL_SOURCE_TYPES = (SOURCE_MANUAL, SOURCE_AI, SOURCE_IMPORTED) # 判定参数 MIN_POINTS = 4 # 最少对齐数据点(少于则无法统计验证) CORR_THRESHOLD = 0.5 # |r| 阈值:达到且方向一致 → 数据证实 VERIFIER_SCRIPT = "correlation-check" # period 粒度(对齐时只允许同粒度配对,避免月度/年度量纲混用) GRANULARITY_ORDER = ("month", "half", "year") _PERIOD_RE = { "month": re.compile(r"^(\d{4})-(\d{2})$"), "half": re.compile(r"^(\d{4})-H([12])$"), "year": re.compile(r"^(\d{4})$"), } def parse_period(period: str) -> Optional[Tuple[str, int]]: """解析 period 为 (granularity, seq)。 seq = year*12 + 月序号(0-indexed),可比较/做滞后偏移。 - '2026-07' → ('month', 2026*12+6) - '2026-H1' → ('half', 2026*12+5) (H1≈6月) - '2026-H2' → ('half', 2026*12+11) (H2≈12月) - '2026' → ('year', 2026*12+5) (年中) 无法解析 → None """ if not period: return None s = str(period).strip() m = _PERIOD_RE["month"].match(s) if m: year, mon = int(m.group(1)), int(m.group(2)) if 1 <= mon <= 12: return ("month", year * 12 + (mon - 1)) m = _PERIOD_RE["half"].match(s) if m: year, half = int(m.group(1)), int(m.group(2)) return ("half", year * 12 + (5 if half == 1 else 11)) m = _PERIOD_RE["year"].match(s) if m: return ("year", int(m.group(1)) * 12 + 5) return None def pearson(xs: List[float], ys: List[float]) -> Tuple[Optional[float], int]: """Pearson 相关系数。点数 < 2 返回 (None, n)。""" n = len(xs) if n < 2: return None, n mx = sum(xs) / n my = sum(ys) / n sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) sxx = sum((x - mx) ** 2 for x in xs) syy = sum((y - my) ** 2 for y in ys) if sxx <= 0 or syy <= 0: # 常数列 → 无相关 return None, n r = sxy / math.sqrt(sxx * syy) # 数值保护:浮点误差可能略超 [-1,1] return max(-1.0, min(1.0, r)), n def align_series( source_values: List[Tuple[str, float]], target_values: List[Tuple[str, float]], lag_months: int = 0, ) -> Tuple[Optional[str], List[Tuple[float, float]]]: """按滞后期对齐 source/target 时间序列,返回 (granularity, pairs)。 语义:source 是因(先发生),target 是果(滞后 lag 月出现)。 pair = (source[t], target[t + lag])。 只使用同粒度(month/half/year)数据配对,避免量纲混用。 按粒度优先级 month > half > year 选取数据点最多的粒度。 """ parsed_src: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER} parsed_tgt: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER} for period, val in source_values: if val is None: continue r = parse_period(period) if r: g, seq = r parsed_src[g][seq] = float(val) for period, val in target_values: if val is None: continue r = parse_period(period) if r: g, seq = r parsed_tgt[g][seq] = float(val) best_g, best_pairs = None, [] for g in GRANULARITY_ORDER: src_map, tgt_map = parsed_src[g], parsed_tgt[g] pairs = [] for seq, sv in sorted(src_map.items()): tv = tgt_map.get(seq + lag_months) if tv is not None: pairs.append((sv, tv)) if len(pairs) > len(best_pairs): best_g, best_pairs = g, pairs return best_g, best_pairs def evaluate_chain( source_values: List[Tuple[str, float]], target_values: List[Tuple[str, float]], lag_months: int = 0, direction: str = "positive", min_points: int = MIN_POINTS, corr_threshold: float = CORR_THRESHOLD, ) -> dict: """对单条因果链做数据验证。 返回: { granularity, n, r, expected_sign, actual_sign, direction_consistent, status (pending/data_verified/disputed), reason } """ granularity, pairs = align_series(source_values, target_values, lag_months) n = len(pairs) r = None if n >= 2: r, _ = pearson([p[0] for p in pairs], [p[1] for p in pairs]) expected_sign = 1 if direction == "positive" else -1 actual_sign = 1 if r is not None and r > 0 else (-1 if r is not None and r < 0 else 0) direction_consistent = r is not None and actual_sign == expected_sign if n < min_points or r is None: return { "granularity": granularity, "n": n, "r": r, "expected_sign": expected_sign, "actual_sign": actual_sign, "direction_consistent": direction_consistent, "status": STATUS_PENDING, "reason": f"数据不足(对齐后{n}点,需≥{min_points}点)" if n < min_points else "序列无方差,无法计算相关性", } abs_r = abs(r) if abs_r >= corr_threshold and direction_consistent: return { "granularity": granularity, "n": n, "r": r, "expected_sign": expected_sign, "actual_sign": actual_sign, "direction_consistent": True, "status": STATUS_DATA_VERIFIED, "reason": f"|r|={abs_r:.3f}≥{corr_threshold} 且方向一致({direction}) → 数据证实", } if not direction_consistent: return { "granularity": granularity, "n": n, "r": r, "expected_sign": expected_sign, "actual_sign": actual_sign, "direction_consistent": False, "status": STATUS_DISPUTED, "reason": f"方向矛盾: 声明{direction}但实际相关方向{'正' if r > 0 else '负'} (r={r:.3f})", } return { "granularity": granularity, "n": n, "r": r, "expected_sign": expected_sign, "actual_sign": actual_sign, "direction_consistent": True, "status": STATUS_DISPUTED, "reason": f"弱相关: |r|={abs_r:.3f}<{corr_threshold},数据暂不能证实该因果强度", } def apply_state_machine( current_status: str, eval_status: str, respect_human: bool = True, ) -> Tuple[str, Optional[str]]: """状态机:根据数据验证结果流转状态。 规则: - human_verified 是人工最终确认: respect_human=True 时不被脚本覆盖 (返回原状态 + disputed_note 警示) - 数据不足(pending) → 保持当前状态(不降级已有结论) - data_verified → 覆盖为非 human_verified 的当前状态 - disputed → 覆盖为非 human_verified 的当前状态 """ if current_status == STATUS_HUMAN_VERIFIED and respect_human: if eval_status == STATUS_DISPUTED: return current_status, "人工已确认但数据复核矛盾,建议重新核对" return current_status, None if eval_status == STATUS_PENDING: return current_status, None return eval_status, None def summarize(results: List[dict]) -> dict: """验证结果汇总统计。""" counter = {s: 0 for s in ALL_STATUSES} for r in results: counter[r.get("status", STATUS_PENDING)] = counter.get(r.get("status", STATUS_PENDING), 0) + 1 return { "total": len(results), "by_status": counter, "data_verified_ratio": round(counter[STATUS_DATA_VERIFIED] / len(results), 3) if results else 0, }