handstats 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handstats/__init__.py +92 -0
- handstats/__main__.py +17 -0
- handstats/_registry.py +35 -0
- handstats/anova/__init__.py +17 -0
- handstats/anova/levene.py +68 -0
- handstats/anova/oneway.py +119 -0
- handstats/anova/posthoc.py +76 -0
- handstats/anova/twoway.py +253 -0
- handstats/base.py +250 -0
- handstats/distributions.py +111 -0
- handstats/hypothesis/__init__.py +30 -0
- handstats/hypothesis/_common.py +31 -0
- handstats/hypothesis/glrt.py +208 -0
- handstats/hypothesis/tests_categorical.py +143 -0
- handstats/hypothesis/tests_mean.py +166 -0
- handstats/hypothesis/tests_proportion.py +103 -0
- handstats/hypothesis/tests_variance.py +62 -0
- handstats/interval/__init__.py +16 -0
- handstats/interval/ci_mean.py +66 -0
- handstats/interval/ci_mean_2.py +148 -0
- handstats/interval/ci_prop.py +48 -0
- handstats/interval/ci_var.py +51 -0
- handstats/multivariate/__init__.py +7 -0
- handstats/nonparametric/__init__.py +8 -0
- handstats/py.typed +0 -0
- handstats/regression/__init__.py +14 -0
- handstats/regression/nonlinear.py +109 -0
- handstats/regression/ols.py +157 -0
- handstats/resampling/__init__.py +11 -0
- handstats/shell/__init__.py +6 -0
- handstats/shell/__main__.py +20 -0
- handstats/shell/app.py +219 -0
- handstats/shell/parsing.py +109 -0
- handstats/shell/specs.py +213 -0
- handstats/validate.py +114 -0
- handstats-0.8.0.dist-info/METADATA +145 -0
- handstats-0.8.0.dist-info/RECORD +40 -0
- handstats-0.8.0.dist-info/WHEEL +5 -0
- handstats-0.8.0.dist-info/licenses/LICENSE +21 -0
- handstats-0.8.0.dist-info/top_level.txt +1 -0
handstats/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""handstats —— 面向学习的数理统计工具包。
|
|
2
|
+
|
|
3
|
+
设计理念
|
|
4
|
+
--------
|
|
5
|
+
1. 每个统计过程都返回“富结果对象”(TestResult / IntervalResult / FitResult),
|
|
6
|
+
而不是一个裸数字:统计量、p 值、结论、中间步骤一次拿全;
|
|
7
|
+
2. steps 机制输出【手算核对表】:把教材里的每一步计算(标准误、统计量、
|
|
8
|
+
自由度、p 值来源)逐行打印,方便和考试手算互相核对;
|
|
9
|
+
3. 底层数值统一走 distributions.py 一个接口(目前由 scipy 提供支持),
|
|
10
|
+
以后想换成自己实现的数值算法,只改那一个文件。
|
|
11
|
+
|
|
12
|
+
目录结构(可延展性)
|
|
13
|
+
--------
|
|
14
|
+
hypothesis/ 假设检验(已有 z/t/卡方/F/比例/GLRT)
|
|
15
|
+
interval/ 区间估计(已有均值/均值差/配对/方差/比例区间)
|
|
16
|
+
regression/ 回归分析(已有 OLS/非线性 + 诊断与预测)
|
|
17
|
+
anova/ 方差分析(已有单/双因素、Tukey、Levene)
|
|
18
|
+
nonparametric/ 非参数检验(预留)
|
|
19
|
+
resampling/ Bootstrap / 置换检验(预留)
|
|
20
|
+
multivariate/ 多元统计分析(预留)
|
|
21
|
+
|
|
22
|
+
无代码外壳(规划):外壳遍历 list_procedures() 即可自动生成方法菜单,
|
|
23
|
+
展示统一结果对象的 conclusion() / show_steps() 文本即可,
|
|
24
|
+
新增过程时外壳自动发现、零改动。雏形见 `python -m handstats`。
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from handstats._registry import PROCEDURES, register
|
|
29
|
+
from handstats.anova import anova_oneway, anova_twoway, levene_test, tukey_hsd
|
|
30
|
+
from handstats.base import FitResult, IntervalResult, TestResult
|
|
31
|
+
from handstats.hypothesis import (
|
|
32
|
+
chisquare_gof,
|
|
33
|
+
chisquare_ind,
|
|
34
|
+
ftest_2samp_var,
|
|
35
|
+
glrt_exponential_mean,
|
|
36
|
+
glrt_normal_mean,
|
|
37
|
+
glrt_test,
|
|
38
|
+
ttest_1samp,
|
|
39
|
+
ttest_2samp_ind,
|
|
40
|
+
ztest_1prop,
|
|
41
|
+
ztest_1samp,
|
|
42
|
+
ztest_2prop,
|
|
43
|
+
)
|
|
44
|
+
from handstats.interval import (
|
|
45
|
+
ci_mean,
|
|
46
|
+
ci_mean_2samp,
|
|
47
|
+
ci_paired_diff,
|
|
48
|
+
ci_proportion,
|
|
49
|
+
ci_var,
|
|
50
|
+
)
|
|
51
|
+
from handstats.regression import lin_reg, nonlin_reg
|
|
52
|
+
|
|
53
|
+
__version__ = "0.8.0"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"TestResult",
|
|
57
|
+
"IntervalResult",
|
|
58
|
+
"FitResult",
|
|
59
|
+
"ztest_1samp",
|
|
60
|
+
"ttest_1samp",
|
|
61
|
+
"ttest_2samp_ind",
|
|
62
|
+
"chisquare_gof",
|
|
63
|
+
"chisquare_ind",
|
|
64
|
+
"ftest_2samp_var",
|
|
65
|
+
"ztest_1prop",
|
|
66
|
+
"ztest_2prop",
|
|
67
|
+
"glrt_test",
|
|
68
|
+
"glrt_normal_mean",
|
|
69
|
+
"glrt_exponential_mean",
|
|
70
|
+
"ci_mean",
|
|
71
|
+
"ci_mean_2samp",
|
|
72
|
+
"ci_paired_diff",
|
|
73
|
+
"ci_var",
|
|
74
|
+
"ci_proportion",
|
|
75
|
+
"lin_reg",
|
|
76
|
+
"nonlin_reg",
|
|
77
|
+
"anova_oneway",
|
|
78
|
+
"anova_twoway",
|
|
79
|
+
"tukey_hsd",
|
|
80
|
+
"levene_test",
|
|
81
|
+
"register",
|
|
82
|
+
"list_procedures",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_procedures() -> dict:
|
|
87
|
+
"""返回 {过程名: 函数}——当前已注册的所有统计过程一览。
|
|
88
|
+
|
|
89
|
+
将来做命令行界面或图形界面时,直接遍历这个注册表就能自动生成
|
|
90
|
+
“可用方法”菜单,不需要改动任何已有代码。
|
|
91
|
+
"""
|
|
92
|
+
return dict(PROCEDURES)
|
handstats/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""`python -m handstats`:命令行一览当前包里所有可用的统计过程。
|
|
2
|
+
|
|
3
|
+
这是未来“无代码外壳”的雏形:外壳程序同样只需要遍历注册表,
|
|
4
|
+
就能自动生成方法菜单——新增过程时不需要为它写任何界面代码。
|
|
5
|
+
"""
|
|
6
|
+
from handstats import __version__, list_procedures
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
procs = sorted(list_procedures())
|
|
11
|
+
print(f"handstats v{__version__} —— 可用统计过程({len(procs)} 个):")
|
|
12
|
+
for i, name in enumerate(procs, start=1):
|
|
13
|
+
print(f" {i:>2}. {name}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
handstats/_registry.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""过程注册表:包里所有统计过程的“花名册”。
|
|
2
|
+
|
|
3
|
+
设计目的(可延展性的关键一环)
|
|
4
|
+
--------
|
|
5
|
+
- 每写一个新过程(检验/区间/回归……),只要在函数头上加
|
|
6
|
+
@register("名字"),它就自动出现在 handstats.list_procedures() 里;
|
|
7
|
+
- 将来做 CLI / GUI / 自动生成文档时,遍历 PROCEDURES 即可,零改动;
|
|
8
|
+
- 重复登记同名过程会直接抛错,防止笔误悄悄覆盖别人的函数。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
# {过程名: 函数}。包内模块通过 @register 写入,外部通过 list_procedures() 读取。
|
|
15
|
+
PROCEDURES: dict[str, Callable] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(name: str) -> Callable:
|
|
19
|
+
"""装饰器:把一个统计过程登记进注册表。
|
|
20
|
+
|
|
21
|
+
用法::
|
|
22
|
+
|
|
23
|
+
@register("ttest_1samp")
|
|
24
|
+
def ttest_1samp(x, mu0, ...):
|
|
25
|
+
...
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def decorator(func: Callable) -> Callable:
|
|
29
|
+
if name in PROCEDURES:
|
|
30
|
+
raise ValueError(f"过程名 {name!r} 已被注册,请换一个名字")
|
|
31
|
+
PROCEDURES[name] = func
|
|
32
|
+
func._procedure_name = name # 反查用:从函数找到它的注册名
|
|
33
|
+
return func
|
|
34
|
+
|
|
35
|
+
return decorator
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""方差分析子包(阶段 4)。
|
|
2
|
+
|
|
3
|
+
已有:
|
|
4
|
+
- anova_oneway:单因素方差分析(ANOVA 表、η² 效应量)
|
|
5
|
+
- tukey_hsd:事后多重比较(族错误率控制在 α,Tukey-Kramer 支持不等样本量)
|
|
6
|
+
- levene_test:方差齐性检验(Levene / Brown-Forsythe,ANOVA 的前提检查)
|
|
7
|
+
- anova_twoway:双因素(无重复 r×c / 等重复 r×c×m 含交互检验),
|
|
8
|
+
返回复合结果 TwoWayResult
|
|
9
|
+
|
|
10
|
+
规划:随机区组设计、非平衡双因素、多元方差分析(见 multivariate)。
|
|
11
|
+
"""
|
|
12
|
+
from handstats.anova.levene import levene_test
|
|
13
|
+
from handstats.anova.oneway import anova_oneway
|
|
14
|
+
from handstats.anova.posthoc import tukey_hsd
|
|
15
|
+
from handstats.anova.twoway import TwoWayResult, anova_twoway
|
|
16
|
+
|
|
17
|
+
__all__ = ["anova_oneway", "anova_twoway", "tukey_hsd", "levene_test", "TwoWayResult"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""方差齐性检验:Levene / Brown-Forsythe(做 ANOVA 前的前提检查)。
|
|
2
|
+
|
|
3
|
+
原理很巧:把每个观测换成“到组中心的绝对离差”z_ij = |y_ij - 中心_i|,
|
|
4
|
+
再对 z 做一遍单因素 ANOVA——方差大的组,离差的平均幅度自然也大。
|
|
5
|
+
中心用均值 = 经典 Levene;用中位数 = Brown-Forsythe(对偏态/离群更稳健)。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from handstats import distributions
|
|
12
|
+
from handstats._registry import register
|
|
13
|
+
from handstats.base import TestResult
|
|
14
|
+
from handstats.anova.oneway import _as_groups, _oneway_core
|
|
15
|
+
from handstats.validate import check_alpha
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register("levene_test")
|
|
19
|
+
def levene_test(groups, alpha=0.05, center="mean"):
|
|
20
|
+
"""Levene 方差齐性检验:H0: k 个总体方差全相等。
|
|
21
|
+
|
|
22
|
+
参数
|
|
23
|
+
----
|
|
24
|
+
groups : 若干组样本的序列
|
|
25
|
+
center : "mean" 经典 Levene(正态数据下功效高);
|
|
26
|
+
"median" Brown-Forsythe(数据偏态或有离群点时更稳健)
|
|
27
|
+
alpha : 显著性水平
|
|
28
|
+
|
|
29
|
+
注:两总体场合可用 hypothesis.ftest_2samp_var(精确 F 检验,
|
|
30
|
+
但要求正态);多总体场合用本检验。
|
|
31
|
+
"""
|
|
32
|
+
alpha = check_alpha(alpha)
|
|
33
|
+
center = str(center).strip().lower()
|
|
34
|
+
if center not in ("mean", "median"):
|
|
35
|
+
raise ValueError(f'center 只能是 "mean" 或 "median",收到 {center!r}')
|
|
36
|
+
arrays = _as_groups(groups)
|
|
37
|
+
|
|
38
|
+
if center == "mean":
|
|
39
|
+
centers = [float(np.mean(a)) for a in arrays]
|
|
40
|
+
desc = "均值"
|
|
41
|
+
else:
|
|
42
|
+
centers = [float(np.median(a)) for a in arrays]
|
|
43
|
+
desc = "中位数(Brown-Forsythe)"
|
|
44
|
+
z = [np.abs(a - c) for a, c in zip(arrays, centers)]
|
|
45
|
+
core = _oneway_core(z)
|
|
46
|
+
W = core["f_stat"]
|
|
47
|
+
p = distributions.f_sf(W, core["df_a"], core["df_e"])
|
|
48
|
+
|
|
49
|
+
centers_str = ", ".join(f"{c:.6g}" for c in centers)
|
|
50
|
+
steps = [
|
|
51
|
+
f"组数 k = {core['k']},各组中心({desc}) = ({centers_str})",
|
|
52
|
+
"离差 z_ij = |y_ij - 组中心|(若方差齐,各组 z 的平均水平应接近)",
|
|
53
|
+
f"对 z 做单因素 ANOVA:W = MS_A/MS_E = {W:.6g},"
|
|
54
|
+
f"df = ({core['df_a']}, {core['df_e']})",
|
|
55
|
+
f"p = P(F ≥ W) = {p:.6g}(F 分布)",
|
|
56
|
+
]
|
|
57
|
+
return TestResult(
|
|
58
|
+
statistic=W,
|
|
59
|
+
pvalue=p,
|
|
60
|
+
method=f"Levene 方差齐性检验({desc})",
|
|
61
|
+
alternative="greater",
|
|
62
|
+
alpha=alpha,
|
|
63
|
+
h0=f"{core['k']} 个总体方差全相等",
|
|
64
|
+
h1="至少有两个总体方差不相等",
|
|
65
|
+
params={"k": core["k"], "df_a": core["df_a"], "df_e": core["df_e"],
|
|
66
|
+
"centers": centers, "center": center},
|
|
67
|
+
steps=steps,
|
|
68
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""单因素方差分析(ANOVA)与它的两个配套:ANOVA 表、事后比较的公共底座。
|
|
2
|
+
|
|
3
|
+
方差分析的骨架是一张表(教材核心):
|
|
4
|
+
SS_A(组间/因素) + SS_E(组内/误差) = SS_T(总)
|
|
5
|
+
F = MS_A/MS_E = (SS_A/(k-1))/(SS_E/(n-k))
|
|
6
|
+
H0: k 个总体均值全相等;H1: 至少两个不等。F 越大越异常(右尾)。
|
|
7
|
+
本文件提供 _as_groups / _oneway_core / _anova_table 三个内部工具,
|
|
8
|
+
tukey_hsd(posthoc.py)与 levene_test(levene.py)都复用它们。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from handstats import distributions
|
|
15
|
+
from handstats._registry import register
|
|
16
|
+
from handstats.base import TestResult
|
|
17
|
+
from handstats.validate import as_sample, check_alpha
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _as_groups(groups):
|
|
21
|
+
"""把“若干组样本”统一为若干一维数组并检查(单因素家族共用)。"""
|
|
22
|
+
if isinstance(groups, (str, bytes)):
|
|
23
|
+
raise ValueError("groups 应是若干组样本的序列(列表/元组),不是字符串")
|
|
24
|
+
try:
|
|
25
|
+
items = list(groups)
|
|
26
|
+
except TypeError:
|
|
27
|
+
raise ValueError("groups 应是若干组样本的序列(列表/元组)")
|
|
28
|
+
if len(items) < 2:
|
|
29
|
+
raise ValueError(f"方差分析至少需要 2 组,收到 {len(items)} 组")
|
|
30
|
+
arrays = [as_sample(g, f"groups[{i}]") for i, g in enumerate(items)]
|
|
31
|
+
n = int(sum(a.size for a in arrays))
|
|
32
|
+
if n - len(arrays) < 1:
|
|
33
|
+
raise ValueError("总观测数必须大于组数(否则误差自由度 n - k = 0)")
|
|
34
|
+
return arrays
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _oneway_core(arrays):
|
|
38
|
+
"""单因素 ANOVA 的 SS 分解与 F(Levene 内部对 |离差| 复用同一函数)。"""
|
|
39
|
+
k = len(arrays)
|
|
40
|
+
n = int(sum(a.size for a in arrays))
|
|
41
|
+
means = np.array([float(a.mean()) for a in arrays])
|
|
42
|
+
grand = float(np.concatenate(arrays).mean())
|
|
43
|
+
ss_a = float(sum(arrays[i].size * (means[i] - grand) ** 2 for i in range(k)))
|
|
44
|
+
ss_e = float(sum(float(np.sum((a - a.mean()) ** 2)) for a in arrays))
|
|
45
|
+
df_a, df_e = k - 1, n - k
|
|
46
|
+
ms_a, ms_e = float(ss_a / df_a), float(ss_e / df_e)
|
|
47
|
+
return {
|
|
48
|
+
"k": k, "n": n, "means": means, "sizes": [int(a.size) for a in arrays],
|
|
49
|
+
"grand": grand, "ss_a": ss_a, "ss_e": ss_e, "df_a": df_a, "df_e": df_e,
|
|
50
|
+
"ms_a": ms_a, "ms_e": ms_e,
|
|
51
|
+
# ms_e = 0(组内无变异)时 F 视为无穷;调用方负责给出友好的 ValueError
|
|
52
|
+
"f_stat": float("inf") if ms_e == 0 else float(ms_a / ms_e),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _anova_table(rows):
|
|
57
|
+
"""渲染方差分析表:rows = [(来源, ss, df, ms, f, p), ...],None 表示空格。
|
|
58
|
+
|
|
59
|
+
教材里的方差分析表(来源 / SS / df / MS / F / p)逐行对应。
|
|
60
|
+
"""
|
|
61
|
+
lines = [f"{'来源':<6}{'SS':>14}{'df':>6}{'MS':>14}{'F':>11}{'p':>11}"]
|
|
62
|
+
for src, ss, df, ms, f_, p in rows:
|
|
63
|
+
ms_s = f"{ms:>14.6g}" if ms is not None else f"{'':>14}"
|
|
64
|
+
f_s = f"{f_:>11.6g}" if f_ is not None else f"{'':>11}"
|
|
65
|
+
p_s = f"{p:>11.6g}" if p is not None else f"{'':>11}"
|
|
66
|
+
lines.append(f"{src:<6}{ss:>14.6g}{df:>6g}{ms_s}{f_s}{p_s}")
|
|
67
|
+
return lines
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@register("anova_oneway")
|
|
71
|
+
def anova_oneway(groups, alpha=0.05):
|
|
72
|
+
"""单因素方差分析:H0: k 个总体均值全相等。
|
|
73
|
+
|
|
74
|
+
参数
|
|
75
|
+
----
|
|
76
|
+
groups : 若干组样本的序列(各组样本量可以不同)
|
|
77
|
+
alpha : 显著性水平
|
|
78
|
+
|
|
79
|
+
返回 TestResult;params 里带完整 SS 分解、各组均值和 η²(效应量:
|
|
80
|
+
因素解释的方差占比,SS_A/SS_T)以及 anova_table(表格文本行)。
|
|
81
|
+
前提:各组近似正态、方差齐(用 levene_test 检查)。
|
|
82
|
+
"""
|
|
83
|
+
alpha = check_alpha(alpha)
|
|
84
|
+
arrays = _as_groups(groups)
|
|
85
|
+
core = _oneway_core(arrays)
|
|
86
|
+
k = core["k"]
|
|
87
|
+
if core["ms_e"] <= 0:
|
|
88
|
+
raise ValueError("组内均方为 0(每组内部数据完全相同),F 无定义")
|
|
89
|
+
F = core["f_stat"]
|
|
90
|
+
p = distributions.f_sf(F, core["df_a"], core["df_e"])
|
|
91
|
+
|
|
92
|
+
means_str = ", ".join(f"{m:.6g}" for m in core["means"])
|
|
93
|
+
ss_t = core["ss_a"] + core["ss_e"]
|
|
94
|
+
table = _anova_table([
|
|
95
|
+
("因素A", core["ss_a"], core["df_a"], core["ms_a"], F, p),
|
|
96
|
+
("误差E", core["ss_e"], core["df_e"], core["ms_e"], None, None),
|
|
97
|
+
("总和T", ss_t, core["n"] - 1, None, None, None),
|
|
98
|
+
])
|
|
99
|
+
steps = [
|
|
100
|
+
f"组数 k = {k},各组样本量 = {core['sizes']},总 n = {core['n']}",
|
|
101
|
+
f"各组均值 = ({means_str}),总均值 = {core['grand']:.6g}",
|
|
102
|
+
f"SS_A = Σn_i·(组均值_i - 总均值)² = {core['ss_a']:.6g},"
|
|
103
|
+
f"SS_E = ΣΣ(y_ij - 组均值_i)² = {core['ss_e']:.6g}",
|
|
104
|
+
"方差分析表:", *table,
|
|
105
|
+
f"p = P(F ≥ {F:.6g}) = {p:.6g}"
|
|
106
|
+
f"(F 分布,dfn = {core['df_a']},dfd = {core['df_e']})",
|
|
107
|
+
]
|
|
108
|
+
return TestResult(
|
|
109
|
+
statistic=F,
|
|
110
|
+
pvalue=p,
|
|
111
|
+
method="单因素方差分析",
|
|
112
|
+
alternative="greater",
|
|
113
|
+
alpha=alpha,
|
|
114
|
+
h0=f"{k} 个总体均值全相等",
|
|
115
|
+
h1="至少有两个总体均值不相等",
|
|
116
|
+
params={**core, "ss_t": ss_t, "eta_sq": core["ss_a"] / ss_t,
|
|
117
|
+
"anova_table": table},
|
|
118
|
+
steps=steps,
|
|
119
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""事后多重比较(post-hoc):Tukey HSD。
|
|
2
|
+
|
|
3
|
+
为什么 ANOVA 拒绝 H0 之后不直接逐对做 t 检验?
|
|
4
|
+
--------
|
|
5
|
+
k 组有 k(k-1)/2 个均值对;逐对 t 检验会让“至少一个假阳性”的族错误率
|
|
6
|
+
随对数膨胀(3 组时 3 对、6 组时 15 对)。Tukey HSD 把族错误率精确控制
|
|
7
|
+
在 α:用学生化极差分布的临界值 q(α; k, df),所有对共用同一个
|
|
8
|
+
“诚实显著差”HSD = q·SE,一次比较全部对。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from handstats import distributions
|
|
15
|
+
from handstats._registry import register
|
|
16
|
+
from handstats.base import TestResult
|
|
17
|
+
from handstats.anova.oneway import _as_groups, _oneway_core
|
|
18
|
+
from handstats.validate import check_alpha
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@register("tukey_hsd")
|
|
22
|
+
def tukey_hsd(groups, alpha=0.05):
|
|
23
|
+
"""Tukey HSD 事后多重比较(族错误率 = α):到底哪些组的均值不同?
|
|
24
|
+
|
|
25
|
+
参数
|
|
26
|
+
----
|
|
27
|
+
groups : 若干组样本的序列(与 anova_oneway 相同的输入)
|
|
28
|
+
alpha : 族错误率(整体犯第一类错误的概率上限)
|
|
29
|
+
|
|
30
|
+
返回 list[TestResult],按 (组i, 组j) 字典序排列(i < j);
|
|
31
|
+
每个的 params 里有均值差 diff、区间 [lower, upper]、HSD 半宽。
|
|
32
|
+
组样本量不同时自动用 Tukey-Kramer 公式。
|
|
33
|
+
"""
|
|
34
|
+
alpha = check_alpha(alpha)
|
|
35
|
+
arrays = _as_groups(groups)
|
|
36
|
+
core = _oneway_core(arrays)
|
|
37
|
+
if core["ms_e"] <= 0:
|
|
38
|
+
raise ValueError("组内均方为 0(每组内部数据完全相同),无法做多重比较")
|
|
39
|
+
k, df_e, mse = core["k"], core["df_e"], core["ms_e"]
|
|
40
|
+
q_crit = distributions.studentized_range_ppf(1 - alpha, k, df_e)
|
|
41
|
+
|
|
42
|
+
results = []
|
|
43
|
+
for i in range(k):
|
|
44
|
+
for j in range(i + 1, k):
|
|
45
|
+
ni, nj = arrays[i].size, arrays[j].size
|
|
46
|
+
diff = float(core["means"][i] - core["means"][j])
|
|
47
|
+
se = float(np.sqrt(mse / 2.0 * (1.0 / ni + 1.0 / nj)))
|
|
48
|
+
q = abs(diff) / se
|
|
49
|
+
p = distributions.studentized_range_sf(q, k, df_e)
|
|
50
|
+
half = q_crit * se
|
|
51
|
+
steps = [
|
|
52
|
+
f"前提:ANOVA 的 MSE = {mse:.6g},误差自由度 df = {df_e},组数 k = {k}",
|
|
53
|
+
f"均值{i + 1} = {core['means'][i]:.6g}(n = {ni}),"
|
|
54
|
+
f"均值{j + 1} = {core['means'][j]:.6g}(n = {nj}),"
|
|
55
|
+
f"均值差 = {diff:.6g}",
|
|
56
|
+
f"SE = √(MSE/2·(1/n_i + 1/n_j)) = {se:.6g}",
|
|
57
|
+
f"统计量 q = |均值差|/SE = {q:.6g}",
|
|
58
|
+
f"临界值 q(α; k, df) = {q_crit:.6g},"
|
|
59
|
+
f"诚实显著差 HSD = 临界值×SE = {half:.6g}",
|
|
60
|
+
f"区间 = 均值差 ± HSD = [{diff - half:.6g}, {diff + half:.6g}],"
|
|
61
|
+
f"p = P(Q ≥ q) = {p:.6g}(学生化极差分布,k = {k},df = {df_e})",
|
|
62
|
+
]
|
|
63
|
+
results.append(TestResult(
|
|
64
|
+
statistic=float(q),
|
|
65
|
+
pvalue=float(p),
|
|
66
|
+
method=f"Tukey HSD:组{i + 1} vs 组{j + 1}",
|
|
67
|
+
alternative="greater",
|
|
68
|
+
alpha=alpha,
|
|
69
|
+
h0=f"μ{i + 1} = μ{j + 1}",
|
|
70
|
+
h1=f"μ{i + 1} ≠ μ{j + 1}",
|
|
71
|
+
params={"diff": diff, "se": se, "lower": diff - half,
|
|
72
|
+
"upper": diff + half, "hsd": half, "q_crit": q_crit,
|
|
73
|
+
"n_i": ni, "n_j": nj, "k": k, "df": df_e},
|
|
74
|
+
steps=steps,
|
|
75
|
+
))
|
|
76
|
+
return results
|