gitlink-cli/skills/statgate/scripts/statgate.py

259 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""statgate实验数据的证据门控与规范统计分析。
先问「数据够不够下这个结论」,再帮你跑规范的统计检验,最后出一份带效应量、
带假设检查的分析报告与统计附录——让你别瞎报,也让审稿人挑不出毛病。
三个核心能力:
1. 证据门控(先于检验)——在做任何检验前,检查样本量是否足够、缺失率是否过高、
分布是否严重偏态、组间方差是否悬殊,给出「能下结论 / 谨慎 / 证据不足」的判定
与原因,避免在不该下结论的数据上硬跑检验。
2. 规范统计检验——根据数据形态自动选择并运行合适的检验(双组用 t / Mann-Whitney
多组用方差分析列联表用卡方同时报告效应量Cohen's d不只给 p 值。
3. 统计附录与报告——输出符合论文写法的统计描述M±SD、检验统计量、df、p、效应量
附录可直接粘进方法/结果部分。
纯标准库实现(见 stats_core.py不依赖 numpy/scipy可在受限环境运行。
用法:
python statgate.py --groups "12,14,11,13" "18,20,17,19" --paired false
python statgate.py --data data.json --output report.md
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Sequence
sys.path.insert(0, str(Path(__file__).resolve().parent))
import stats_core as sc
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
MIN_N = 5 # 每组最小样本量阈值
SKEW_LIMIT = 2.0 # 偏度绝对值告警阈值
VAR_RATIO_LIMIT = 4.0 # 方差比告警阈值(最大/最小)
def evidence_gate(groups: list[list[float]], raw_counts: list[int] | None = None) -> dict[str, Any]:
"""证据门控:检验前判断数据够不够下结论。"""
issues: list[str] = []
warnings: list[str] = []
# 样本量
small = [i + 1 for i, g in enumerate(groups) if len(g) < MIN_N]
if small:
issues.append(f"{small} 组样本量 < {MIN_N},统计功效不足,结论不可靠。")
# 缺失率(若提供了原始条数)
if raw_counts:
for i, (g, raw) in enumerate(zip(groups, raw_counts), start=1):
if raw > 0:
miss = (raw - len(g)) / raw
if miss > 0.2:
warnings.append(f"{i} 组缺失率 {miss:.0%} 偏高(>20%),需说明缺失处理。")
# 偏态
for i, g in enumerate(groups, start=1):
if len(g) >= 3:
sk = sc.skewness(g)
if abs(sk) > SKEW_LIMIT:
warnings.append(f"{i} 组偏度 {sk:.2f} 较大,分布偏离正态,建议用非参数检验。")
# 方差齐性
variances = [sc.variance(g) for g in groups if len(g) >= 2]
if len(variances) >= 2:
vmin, vmax = min(variances), max(variances)
if vmin == 0 and vmax > 0:
warnings.append("存在方差为 0 的组(数据无变异),与其它组方差悬殊,"
"方差严重不齐,优先用非参数检验。")
elif vmin > 0:
ratio = vmax / vmin
if ratio > VAR_RATIO_LIMIT:
warnings.append(f"组间方差比 {ratio:.1f} 偏大(>{VAR_RATIO_LIMIT}),方差不齐,"
f"优先用 Welch / 非参数检验。")
if issues:
verdict = "证据不足"
elif warnings:
verdict = "谨慎下结论"
else:
verdict = "可下结论"
return {"verdict": verdict, "issues": issues, "warnings": warnings,
"recommend_nonparametric": any("非参数" in w for w in warnings)}
def describe(g: Sequence[float]) -> dict[str, float]:
return {"n": len(g), "mean": round(sc.mean(g), 4), "std": round(sc.std(g), 4),
"median": round(sc.median(g), 4)}
def run_analysis(groups: list[list[float]], paired: bool = False,
raw_counts: list[int] | None = None) -> dict[str, Any]:
"""完整分析:门控 → 选检验 → 效应量。"""
gate = evidence_gate(groups, raw_counts)
desc = [describe(g) for g in groups]
result: dict[str, Any] = {"gate": gate, "descriptives": desc, "n_groups": len(groups)}
use_np = gate["recommend_nonparametric"]
if len(groups) == 2:
a, b = groups
if paired:
test = sc.paired_t_test(a, b)
result["test"] = {"name": "配对 t 检验", **test}
elif use_np:
test = sc.mann_whitney_u(a, b)
result["test"] = {"name": "Mann-Whitney U 检验", **test}
else:
test = sc.welch_t_test(a, b)
result["test"] = {"name": "Welch 独立样本 t 检验", **test}
d = sc.cohens_d(a, b)
result["effect_size"] = {"cohens_d": round(d, 4), "magnitude": sc.interpret_d(d)}
elif len(groups) > 2:
test = sc.one_way_anova(*groups)
result["test"] = {"name": "单因素方差分析", **test}
else:
result["test"] = {"name": "样本组不足(需 ≥ 2 组)"}
return result
def _sig_mark(p: float) -> str:
if p < 0.001:
return "***"
if p < 0.01:
return "**"
if p < 0.05:
return "*"
return "n.s."
def render_report(result: dict[str, Any]) -> str:
gate = result["gate"]
lines = [
"# 统计分析报告",
"",
"## 一、证据门控",
"",
f"判定:**{gate['verdict']}**",
"",
]
if gate["issues"]:
lines.append("阻断性问题:")
for it in gate["issues"]:
lines.append(f"- {it}")
lines.append("")
if gate["warnings"]:
lines.append("提示:")
for w in gate["warnings"]:
lines.append(f"- {w}")
lines.append("")
if not gate["issues"] and not gate["warnings"]:
lines.append("样本量、分布、方差检查均通过,可进行参数检验。")
lines.append("")
lines += ["## 二、描述统计", "", "| 组 | n | 均值 | 标准差 | 中位数 |", "|:--:|:--:|:--:|:--:|:--:|"]
for i, d in enumerate(result["descriptives"], start=1):
lines.append(f"| {i} | {d['n']} | {d['mean']} | {d['std']} | {d['median']} |")
lines.append("")
test = result.get("test", {})
lines += ["## 三、假设检验", "", f"检验方法:{test.get('name', '')}", ""]
if "p_value" in test:
p = test["p_value"]
stat_str = ""
if "t" in test:
stat_str = f"t({test['df']:.1f}) = {test['t']:.3f}"
elif "F" in test:
stat_str = f"F({test['df_between']}, {test['df_within']}) = {test['F']:.3f}"
elif "U" in test:
stat_str = f"U = {test['U']:.1f}, z = {test['z']:.3f}"
lines.append(f"- 统计量:{stat_str}")
lines.append(f"- p 值:{p:.4f} {_sig_mark(p)}")
lines.append(f"- 结论:{'差异显著' if p < 0.05 else '差异不显著'}α = 0.05")
lines.append("")
if "effect_size" in result:
es = result["effect_size"]
lines.append(f"- 效应量 Cohen's d = {es['cohens_d']}{es['magnitude']}")
lines.append("")
# 统计附录(论文可粘)
lines += ["## 四、统计附录(可粘入论文)", ""]
lines.append(_appendix_sentence(result))
lines += ["", "---", "",
"由 statgate 生成。先做证据门控再跑检验,统计量用纯标准库计算。"
"显著性标记:*** p<.001, ** p<.01, * p<.05, n.s. 不显著。"]
return "\n".join(lines)
def _appendix_sentence(result: dict[str, Any]) -> str:
"""生成论文风格的一句话统计描述。"""
test = result.get("test", {})
desc = result["descriptives"]
if "p_value" not in test:
return "(检验未执行,无法生成附录句。)"
p = test["p_value"]
p_str = "p < .001" if p < 0.001 else f"p = {p:.3f}"
if len(desc) == 2 and "t" in test:
d = result.get("effect_size", {}).get("cohens_d", 0)
return (f"两组M₁ = {desc[0]['mean']} ± {desc[0]['std']}"
f"M₂ = {desc[1]['mean']} ± {desc[1]['std']})经{test['name']}比较,"
f"t({test['df']:.1f}) = {test['t']:.2f}{p_str}Cohen's d = {d}")
if "F" in test:
return (f"经单因素方差分析,各组差异 "
f"F({test['df_between']}, {test['df_within']}) = {test['F']:.2f}{p_str}")
if "U" in test:
return f"经 Mann-Whitney U 检验U = {test['U']:.1f}{p_str}"
return "(无法生成附录句。)"
def _parse_group(s: str) -> list[float]:
return [float(x) for x in s.replace("", ",").split(",") if x.strip()]
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="statgate", description="证据门控与规范统计分析")
p.add_argument("--groups", nargs="+", help='每组数据,如 "12,14,11" "18,20,17"')
p.add_argument("--data", type=Path, help="JSON 文件:{\"groups\": [[...],[...]], \"paired\": false}")
p.add_argument("--paired", choices=["true", "false"], default="false", help="是否配对(仅两组)")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path)
args = p.parse_args(argv)
paired = args.paired == "true"
groups: list[list[float]] = []
raw_counts = None
if args.data and args.data.exists():
cfg = json.loads(args.data.read_text(encoding="utf-8-sig"))
groups = [[float(v) for v in g] for g in cfg.get("groups", [])]
paired = bool(cfg.get("paired", paired))
raw_counts = cfg.get("raw_counts")
elif args.groups:
groups = [_parse_group(g) for g in args.groups]
if len(groups) < 2:
print("错误:请用 --groups 或 --data 提供至少两组数据。", file=sys.stderr)
return 2
result = run_analysis(groups, paired=paired, raw_counts=raw_counts)
out = (json.dumps(result, ensure_ascii=False, indent=2) if args.format == "json"
else render_report(result))
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out, encoding="utf-8")
print(f"已写入 {args.output}")
else:
print(out)
return 0
if __name__ == "__main__":
raise SystemExit(main())