alitrack

duckdb_stats: 64 个宏补齐 DuckDB 统计推断层

仓库地址:https://github.com/alitrack/duckdb_stats

DuckDB 的聚合和分布函数已经很全,均值、方差、分位数、t 检验这些开箱即用。但真到写分析报告的时候,缺口马上露出来:置信区间怎么算?效应量呢?事后检验呢?功效和样本量呢?这些"推断统计"层的东西,DuckDB 里一个都没有。

我给 DuckDB 补了这一层,仓库本周已开源。一个社区扩展加一个 SQL 文件,64 个宏,零编译。

● ● ●

先看能干什么

装好之后(两条命令,后面讲),这些查询直接跑:

-- 均值的置信区间:返回 lower, upper, level
SELECT ci_mean(array_agg(x ORDER BY id)) FROM t;

-- 单因素方差分析
SELECT * FROM oneway_anova(
    (SELECT array_agg(x ORDER BY id) FROM t),
    (SELECT array_agg(grp ORDER BY id) FROM t));
-- 返回 F, p_value, df_between, df_within, eta_squared

-- 效应量:Cohen's d(两独立样本)
SELECT cohens_d_2samp(array_agg(a ORDER BY id),
                      array_agg(b ORDER BY id)) FROM t;

-- Fisher 精确检验,双侧 p 值
SELECT fisher_exact_p(20, 10, 5, 15);

-- 两样本 t 检验的功效
SELECT power_2samp(50, 0.5);

-- Tukey 事后检验
SELECT * FROM tukey_hsd(
    (SELECT array_agg(x ORDER BY id) FROM t),
    (SELECT array_agg(grp ORDER BY id) FROM t));

全部 64 个宏的签名和返回字段,见仓库的 docs/FUNCTION_REFERENCE.md。

● ● ●

stats_fill.sql 一个文件,10 个分区:

分区
内容
代表宏
A
描述统计补齐
几何/调和/截尾均值, CV, IQR, MAD 缩放
B
置信区间
ci_mean, ci_proportion, ci_variance, Welch/paired 均值差
C
功效与样本量
power_2samp, sample_size_moe_mean
D
参数检验
z_test, z_test_proportion(±两样本), f_test_var
E
ANOVA 与事后
oneway/twoway_anova, tukey_hsd, scheffe_p, dunnett_approx
F
效应量
cohens_d, hedges_g, glass_delta, η², t/d/r 互转
G
分类变量
odds_ratio, risk_ratio, risk_difference, nnt, fisher_exact_p
H
时间序列与误差
acf, acf_vector, mae/mse/rmse/mape
I
特殊函数
erf, erfc, norm_sf
J
加权与集中度
weighted_mean/stddev, quantile_weighted, gini, hhi

这些正是 R 和 SciPy 里最常被用到、而 DuckDB 完全空白的那一层。

● ● ●

为什么对标 statcpp

动手之前需要一个完整的参照系。statcpp 是 GitHub 上一个 C++ 统计库,386 个函数、30 个功能模块,从描述统计到生存分析都有。我把它当作清单,逐模块核对 DuckDB 三层(内核 + stats_duck 扩展 + duckdb-ml 扩展)能覆盖多少。

结论:386 个函数,每个都有落点。

statcpp 模块
落点
分布族(正态/t/F/卡方/gamma/beta…四件套)
stats_duck 扩展
假设检验(t 检验族、卡方、非参数)
stats_duck 扩展
机器学习、聚类、生存分析
duckdb-ml 扩展(可选)
推断层:置信区间、效应量、功效、事后检验、OR/RR/NNT
本仓库 stats_fill.sql

逐模块矩阵在 docs/COVERAGE.md(30 行,每行都在真实 DuckDB 1.5.x 上实测过)。这就是仓库起名 duckdb_stats 的意思:不是又一个统计函数集,是把 DuckDB 的统计能力补到"能对上 R"的位置。

● ● ●

怎么对拍验证的

64 个宏不是"写完能跑"就完事。tests/ 里有一套对拍:gen_refs.py 用固定随机种子生成参考值,verify_fill.py 加载全部 64 个宏,对 scipy 逐项断言。

本次发布前在 WSL 里完整重跑了一遍:

  • 宏创建:64/64 ok,0 失败
  • 断言核对:58 项 vs scipy 1.18,TOTAL FAILS: 0

抽样看几个对拍结果(SQL 宏 vs scipy):

项目
duckdb_stats
scipy
Cohen's d(两样本)
-0.3588703467
-0.3588703467
odds_ratio
6.0
6.0
OR 95% 下界
1.69317956
1.69317956
Fisher 精确检验 p
0.008578508
0.008578508
NNT
2.4000000000
2.4000000000

全表数字,前 8 位有效数字逐位一致。复跑命令在仓库 README 的 Verification 一节,任何机器五分钟可以复现。

● ● ●

安装:两条命令

INSTALL stats_duck FROM community;
LOAD stats_duck;
-- 再执行仓库里的 stats_fill.sql(64 条 CREATE OR REPLACE MACRO)

Python 用户更省事,一行引导:

from duckdb_stats_setup import stats_connect
con = stats_connect()   # 自动 INSTALL、LOAD、挂载全部宏

DuckDB 1.4 及以上,跨平台,零编译。要在同一个连接里做机器学习,stats_connect(ml_extension=...) 可以再挂一层 duckdb-ml(34 个算法的 Rust 扩展,kmeans、Cox、ARIMA、XGBoost 都在里面)。

● ● ●

几个踩出来的坑

写 DuckDB 宏和写普通 SQL 不一样,有几条是用失败换来的:

  1. 01
    DuckDB 列表下标从 1 开始,不是 0。遍历要写 unnest(range(1, len(l)+1))。
  2. 02
    多个 array_agg 是彼此独立的聚合,行序不对齐。分组类宏必须共享同一个 ORDER BY 键,比如 array_agg(x ORDER BY id) 和 array_agg(grp ORDER BY id)。
  3. 03
    表宏的入参必须是标量子查询,不能裸聚合,也不能写双层 FROM。
  4. 04
    stats_duck 的 pf/pt/pchisq 是下尾 CDF,要上尾 p 值得写 1 - pf(...)。

这几条 README 里都有,初次写 DuckDB 宏的人可以直接抄作业。

● ● ●

诚实框

  • Tukey 和 Dunnett 的 p 值是正态近似(DuckDB 没有 studentized range 分布的 CDF),小样本下偏"激进",标记为 APPROX。scheffe_p 是精确的。
  • 30 个模块里 2 个明确不做:数值工具(内部 helper)、高级缺失数据(多重插补超出 SQL 扩展的合理边界)。做不了的事写清楚,比硬凑一个近似好。
  • 对标是"覆盖"不是"等价":386 个函数每个都有落点,但个别落点是近似解或可选层。

● ● ●

为什么不写 Rust 扩展

我评估过三条路。Lua 路线(duckdb-luajit)实测过:有状态聚合做不了,标量函数比内置慢约 7 倍,放弃。Rust 包装 statcpp 只在需要单一闭源依赖或 386 函数完全等价时才值得。现在这条路:一个社区扩展加一个 SQL 文件,零编译、跨平台、可读可改,宏本身就是文档。

统计需求超出这层的时候(Mahalanobis 距离、logrank、偏自相关),再上 Rust 也不迟——补的顺序和理由都写在 COVERAGE.md 里。

● ● ●

参考来源

https://github.com/alitrack/duckdb_stats

https://github.com/mitsuruk/statcpp

https://github.com/kometa-ai/kolistat

https://duckdb.org/community_extensions/list_of_extensions