# The Stats Duck A statistical computing toolkit for DuckDB. The Stats Duck brings statistical workflows — descriptive statistics, hypothesis tests, grammar-of-graphics visualization, and direct readers/writers for SAS, SPSS, and Stata files — into SQL. Functions are implemented as streaming aggregates and scalar primitives, so they scale from local notebooks to billion-row warehouses and also run inside DuckDB-WASM in the browser. > The extension installs and loads in DuckDB under the technical name > `stats_duck` (matching the binary, the SQL function namespace, and the > `INSTALL` keyword). "The Stats Duck" is the project / brand name; `stats_duck` > is what you type at the SQL prompt. ## Scope The Stats Duck is meant to cover the everyday work of a general-purpose statistician without leaving SQL. The current release covers four areas: - **Hypothesis tests** — parametric and non-parametric, with effect sizes and confidence intervals returned alongside the test statistic. - **Visualizations (VISUALIZE)** — `VISUALIZE … FROM DRAW `, a Posit-published Grammar-of-Graphics SQL dialect compiled to Vega-Lite v5. No server-side rendering: the extension emits a spec + per-layer SQL, and the client (browser, notebook, …) runs the SQL and feeds the rows to vega. - **Statistical file I/O** — first-class readers AND writers for SAS, SPSS, and Stata files, integrated with DuckDB's virtual file system so they work transparently with local paths, `httpfs://`, `s3://`, and registered WASM file buffers. - **Streaming aggregates** — every test is a single-pass aggregate over the data, so it composes naturally with `GROUP BY`, window frames, and DuckDB's parallel execution. Future releases will add Spearman/Kendall correlations, regression with full diagnostics, multiple-testing corrections, and more distribution families. ## Functions ### Hypothesis tests (aggregate) | Function | Description | | --------------------------------------------------------------------- | -------------------------------------------- | | `ttest_1samp(column, [mu], [alpha], [alternative])` | One-sample t-test | | `ttest_2samp(column1, column2, [equal_var], [alpha], [alternative])` | Two-sample t-test (Welch's or Student's) | | `ttest_paired(column1, column2, [alpha], [alternative])` | Paired t-test | | `mann_whitney_u(column1, column2, [alternative], [continuity])` | Mann-Whitney U test (Wilcoxon rank-sum) | | `wilcoxon_signed_rank(column1, column2, [alternative], [continuity])` | Wilcoxon signed-rank test | | `pearson_test(x, y, [alpha], [alternative])` | Pearson correlation with significance | | `spearman_test(x, y, [alpha], [alternative])` | Spearman rank correlation | | `kendall_test(x, y, [alternative])` | Kendall's tau-b rank correlation | | `anova_oneway(value, group)` | One-way ANOVA | | `chisq_independence(row, col, [continuity])` | Chi-square test of independence | | `chisq_goodness_of_fit(category)` | Chi-square goodness-of-fit (uniform) | | `jarque_bera(column)` | Jarque-Bera normality test | | `shapiro_wilk(column)` | Shapiro-Wilk normality test (Royston AS R94) | | `anderson_darling(column)` | Anderson-Darling normality test | | `ks_test_1samp(column)` | Kolmogorov-Smirnov one-sample (vs fitted normal) | | `ks_test_2samp(column1, column2)` | Kolmogorov-Smirnov two-sample | | `sign_test_1samp(column, [mu], [alternative])` | Sign test on the median | | `sign_test_paired(column1, column2, [alternative])` | Paired sign test | All tests return a `STRUCT` with the test statistic, degrees of freedom, p-value, and relevant effect sizes / confidence intervals. #### Common parameters | Parameter | Type | Default | Description | | ------------- | --------- | ------------- | ---------------------------------------------------------------- | | `mu` | `DOUBLE` | `0.0` | Hypothesized population mean (one-sample t-test only) | | `equal_var` | `BOOLEAN` | `false` | Assume equal variances — Student's pooled test (two-sample only) | | `alpha` | `DOUBLE` | `0.05` | Significance level for confidence intervals (t-tests only) | | `alternative` | `VARCHAR` | `'two-sided'` | `'two-sided'`, `'less'`, or `'greater'` | | `continuity` | `BOOLEAN` | `false` | Apply continuity correction (mann_whitney_u, chisq_independence) | #### Result struct fields **t-test:** `test_type`, `t_statistic`, `df`, `p_value`, `alternative`, `mean_diff`, `ci_lower`, `ci_upper`, `cohens_d` **Mann-Whitney:** `test_type`, `u_statistic`, `z_statistic`, `p_value`, `alternative`, `rank_biserial` **Wilcoxon:** `test_type`, `w_statistic`, `z_statistic`, `p_value`, `alternative`, `effect_size_r` **Pearson:** `test_type`, `r`, `t_statistic`, `df`, `p_value`, `alternative`, `ci_lower`, `ci_upper`, `n` **Spearman:** `test_type`, `rho`, `t_statistic`, `df`, `p_value`, `alternative`, `ci_lower`, `ci_upper`, `n` **Kendall:** `test_type`, `tau`, `z_statistic`, `p_value`, `alternative`, `n` **ANOVA:** `test_type`, `f_statistic`, `df_between`, `df_within`, `p_value`, `ss_between`, `ss_within`, `eta_squared`, `n_groups`, `n` **Chi-square:** `test_type`, `chi_square`, `df`, `p_value`, `n`, `n_rows`/`n_cols` or `n_categories` **Jarque-Bera:** `test_type`, `jb_statistic`, `skewness`, `excess_kurtosis`, `df`, `p_value`, `n` **Anderson-Darling:** `test_type`, `a_squared`, `a_squared_adjusted`, `p_value`, `n` **Shapiro-Wilk:** `test_type`, `w_statistic`, `p_value`, `n` **KS one-sample:** `test_type`, `d_statistic`, `p_value`, `n` **KS two-sample:** `test_type`, `d_statistic`, `p_value`, `n_x`, `n_y` **Sign test:** `test_type`, `m_statistic`, `n_pos`, `n_neg`, `n_zero`, `p_value`, `alternative`, `n` ### Descriptive statistics (aggregate) | Function | Description | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `summary_stats(column, [bias_correction], [quantile_type])` | n, n_missing, mean, sd, variance, min, q1, median, q3, max, iqr, skewness, kurtosis, mode, mode_frequency, is_multimodal | `bias_correction` (BOOLEAN, default `true`) toggles the skewness/kurtosis formulas. With `true` the output matches SAS PROC MEANS, scipy with `bias=False`, and Excel SKEW/KURT. With `false` the population formulas `m3/m2^1.5` and `m4/m2² - 3` are used, matching R's default. Mean, SD, variance, quantiles, and IQR are unaffected. `mode` is the smallest modal value (when more than one value shares the maximum frequency, the smallest of them is returned and `is_multimodal` is set to `true`). For all-distinct input — every value appears exactly once — `mode` is `NaN` and `mode_frequency` is `0`, matching SAS PROC UNIVARIATE's "Mode ." output. `quantile_type` (INTEGER, default `7`) picks the Hyndman & Fan (1996) quantile algorithm used for `q1`, `median`, and `q3`. Supported values: - `7` — R / Excel INC default. `position = 1 + q * (n - 1)`. - `5` — SAS PROC UNIVARIATE default. `position = q * n + 0.5`. For `x = [1, 2, 3, 4]`: type 7 gives Q1=1.75 / Q3=3.25 (matching R); type 5 gives Q1=1.5 / Q3=3.5 (matching SAS PROC MEANS). ### Distribution functions (scalar) | Function | Description | | ------------------------ | ----------------------- | | `dnorm(x, [mean], [sd])` | Normal PDF | | `pnorm(x, [mean], [sd])` | Normal CDF | | `qnorm(p, [mean], [sd])` | Normal quantile | | `dt(x, df)` | Student's t PDF | | `pt(x, df)` | Student's t CDF | | `qt(p, df)` | Student's t quantile | | `dchisq(x, df)` | Chi-square PDF | | `pchisq(x, df)` | Chi-square CDF | | `qchisq(p, df)` | Chi-square quantile | | `df(x, df1, df2)` | F distribution PDF | | `pf(x, df1, df2)` | F distribution CDF | | `qf(p, df1, df2)` | F distribution quantile | | `dgamma(x, shape, [rate])` | Gamma PDF (rate=1 default) | | `pgamma(x, shape, [rate])` | Gamma CDF | | `qgamma(p, shape, [rate])` | Gamma quantile | | `dbeta(x, alpha, beta)` | Beta PDF on [0, 1] | | `pbeta(x, alpha, beta)` | Beta CDF | | `qbeta(p, alpha, beta)` | Beta quantile | | `dexp(x, [rate])` | Exponential PDF (rate=1 default) | | `pexp(x, [rate])` | Exponential CDF | | `qexp(p, [rate])` | Exponential quantile (closed form) | | `dweibull(x, shape, [scale])` | Weibull PDF (scale=1 default) | | `pweibull(x, shape, [scale])` | Weibull CDF | | `qweibull(p, shape, [scale])` | Weibull quantile (closed form) | | `dlnorm(x, [meanlog], [sdlog])` | Log-normal PDF (meanlog=0, sdlog=1 defaults) | | `plnorm(x, [meanlog], [sdlog])` | Log-normal CDF | | `qlnorm(p, [meanlog], [sdlog])` | Log-normal quantile | | `dpois(k, lambda)` | Poisson PMF (discrete) | | `ppois(q, lambda)` | Poisson CDF | | `qpois(p, lambda)` | Poisson quantile (integer search) | | `dnbinom(k, size, prob)` | Negative binomial PMF (count of failures before `size` successes; `prob` per-trial) | | `pnbinom(q, size, prob)` | Negative binomial CDF — closed form via regularized incomplete beta | | `qnbinom(p, size, prob)` | Negative binomial quantile (integer search) | | `dhyper(x, m, n, k)` | Hypergeometric PMF (`m` successes / `n` failures / `k` draws without replacement) | | `phyper(q, m, n, k)` | Hypergeometric CDF (direct PMF sum; tractable for typical population sizes) | | `qhyper(p, m, n, k)` | Hypergeometric quantile | | `rnorm([mean], [sd])` | Random sample from a normal — **volatile, per-row** | | `rt(df)` / `rchisq(df)` / `rf(df1, df2)` | Student-t / χ² / F samples | | `rgamma(shape, [rate])` / `rbeta(alpha, beta)` / `rexp([rate])` | Gamma / Beta / Exponential samples | | `rweibull(shape, [scale])` / `rlnorm([meanlog], [sdlog])` / `rpois(lambda)` | Weibull / Log-normal / Poisson samples | | `rnbinom(size, prob)` / `rhyper(m, n, k)` | Negative binomial / Hypergeometric samples | | `poibin_cdf(probs LIST, k BIGINT)` | Poisson Binomial CDF — `P(X ≤ k)` for `X = Σᵢ Bᵢ`, `Bᵢ ∼ Bernoulli(pᵢ)` | | `bin_edges(x [, method])` *(aggregate)* | Auto bin-edge vector for `x` — `sturges` (default), `fd`, `scott`, `sqrt`, `rice`, `auto` | | `bin_label(x, edges)` | Label for the bin containing `x` given an edge vector (typically from `bin_edges`) | | `bootstrap(x, statistic, n_iters [, seed])` *(aggregate)* | With-replacement resampling — emits `LIST` of length `n_iters`. `statistic` ∈ `{mean, median, sum, stddev, variance, min, max}` | ### Dataset profile (table function) | Function | Description | | -------------- | -------------------------------------------------------- | | `meta(data)` | One row per column with kind classification + light stats | ```sql SELECT * FROM meta('penguins'); ``` Output columns (fixed schema): `column_name`, `column_type`, `kind`, `n_rows`, `n_missing`, `n_distinct`, `min`, `p25`, `median`, `p75`, `max`, `mean`, `stddev`, `top`, `top_freq`. - `kind` is a semantic classification — `numeric` / `categorical` / `temporal` / `boolean` / `other` — derived from the catalog type. It controls which distribution columns are populated: numeric kinds fill `min` / `p25` / `median` / `p75` / `max` / `mean` / `stddev` (cast to DOUBLE; `quantile_cont` type 7, sample stddev), and `categorical` / `boolean` fill `top` (the mode, ties broken by smaller value) and `top_freq` (its count). - `n_rows` is the source table's row count, repeated on every row so any single row is self-contained. - Dataset-level summaries fall out via aggregation: ```sql SELECT count(*) FILTER (WHERE kind = 'numeric') AS n_numeric, count(*) FILTER (WHERE kind = 'categorical') AS n_categorical, sum(n_missing) AS total_missing, count(*) FILTER (WHERE n_missing > 0) AS cols_with_nulls FROM meta('penguins'); ``` - Overlaps DuckDB's built-in `SUMMARIZE` but is a table function (joinable, filterable, composable in CTEs), adds the kind classifier, and reports a mode for categorical / boolean columns. Per-column detail (skewness, kurtosis, custom quantile types, bias-corrected variants) lives in `summary_stats(column)`. ### Table 1 summary (table function) | Function | Description | | ------------------------------------------------- | ---------------------------------------------------------- | | `table_one(data, variables [, by])` | Long-format descriptives table for mixed variable types | | `corr_matrix(data, variables [, method])` | Long-format pairwise correlation matrix (`pearson` / `spearman` / `kendall`) | | `lm(data, y, x)` / `lm_summary(data, y, x)` | OLS regression — `lm` returns per-term coefficients, `lm_summary` returns model R² / F / σ | | `lm_fit(y, x [, vcov [, cluster] [, add_intercept]])` | OLS regression **aggregate** (one model per `GROUP BY`) with classical, HC0–HC3, or CR0/CR1 cluster-robust standard errors | ```sql SELECT * FROM table_one( 'patients', variables := ['age', 'sex', 'bmi'], by := ['arm'] -- optional; pass multiple columns for cross-stratification ); ``` Output columns (long format, fixed schema): `variable`, `level`, `statistic`, `stratum`, `display`, `p_value`, `effect_size` - Each numeric variable yields rows for `n`, `missing`, `mean (sd)`, `median [q1, q3]`, `min, max` — `level` is NULL. - Each categorical variable yields one row per level with `n (%)` plus a trailing `Missing` level row that is always emitted (filter with `WHERE level <> 'Missing'` if you don't want it). All level percentages share the stratum-total denominator so they sum to 100%. - `stratum` is `'Overall'` when `by` is unset / empty; otherwise the Cartesian product of distinct value tuples across the listed by-columns, labelled by joining values with `' / '` in declared order (e.g. `'Adelie / female'` for `by := ['species', 'sex']`). Rows where any by-column is NULL are excluded from the stratum breakdown. - `p_value` is the between-group test result, repeated on every row of the same variable so a PIVOT can grab it with `FIRST(p_value)`. NULL when `by` is unset or has only one stratum. Numeric variables use one-way ANOVA (`anova_oneway`); categorical variables use chi-square independence (`chisq_independence`). NULL when the underlying test is infeasible (zero variance, too few samples). - `effect_size` is the matching magnitude — **η² (eta-squared)** for numeric variables (from ANOVA's `ss_between / ss_total`), **Cramér's V** for categorical (`√(χ² / (n · (min(rows, cols) - 1)))`). Both are in [0, 1] and larger means stronger association, so a single uniform column name works across kinds. Same repetition and NULL handling as `p_value`. - Variable types are auto-classified from the catalog: integer / floating- point types are numeric, everything else (VARCHAR, BOOLEAN, ENUM, date/time) is categorical. Override per-variable with `force_categorical := ['stage']` (integer column that's really a category) or `force_numerical := ['height']` (VARCHAR column holding numeric strings). Entries must appear in `variables`, and the two lists must not overlap. Pivot to wide for display: ```sql PIVOT table_one('patients', variables := ['age', 'sex'], by := ['arm']) ON stratum USING first(display) GROUP BY variable, level, statistic; ``` ### Linear regression (table function) ```sql SELECT * FROM lm('mtcars', y := 'mpg', x := ['wt', 'hp']); -- term estimate std_error t_statistic p_value -- (Intercept) 37.2273 1.5988 23.285 2.57e-20 -- wt -3.8778 0.6327 -6.129 1.12e-06 -- hp -0.0318 0.0090 -3.519 1.45e-03 SELECT * FROM lm_summary('mtcars', y := 'mpg', x := ['wt', 'hp']); -- r_squared adj_r_squared f_statistic f_p_value df_model df_residual sigma n -- 0.8268 0.8148 69.21 9.11e-12 2 29 2.593 32 ``` A `formula` named parameter accepts an R-style spec as an alternative to the explicit `y` / `x` form: ```sql SELECT * FROM lm('mtcars', formula := 'mpg ~ wt + hp'); SELECT * FROM lm('mtcars', formula := 'mpg ~ wt + hp - 1'); -- no intercept SELECT * FROM lm('weird_names', formula := '"My Y" ~ "x.with.dots"'); ``` The formula grammar supports additive predictors, `- 1` or `+ 0` to drop the intercept, bare and `"..."`-quoted identifiers, and free whitespace. Interactions (`x1:x2`), wildcards (`*`, `^`, `.`) and inline expressions (`I(x^2)`, `log(x)`) are not supported in v0.6 — wrap into a CTE if you need transformed columns. `formula` and `y` / `x` are mutually exclusive. OLS via Cholesky decomposition of `X'X`. Rows with NULL in `y` or any `x` are dropped (complete-case). Term order follows the user-supplied predictor order, after the intercept. Calling `lm` and `lm_summary` with the same arguments fits the model twice — use a CTE if you need both shapes from a single fit. Errors on singular `X'X` (perfectly collinear predictors) or insufficient rows (`n ≤ k` parameters). When the intercept is removed, R²/adj-R² use the uncentered TSS = Σ y² to match R's `summary.lm` — interpret with care. ### Linear regression aggregate with robust SEs (`lm_fit`) `lm_fit` is the **aggregate** companion to `lm`: it fits OLS over the rows of a group, so a single `GROUP BY` returns one regression per key. Where `lm` takes column *names* (and labels its terms), the aggregate takes the design-matrix row as a `LIST(DOUBLE)` of predictor *values* — so coefficients come back **by position**. The intercept is prepended automatically. ```sql -- example data: y ~ x1 over 8 rows in a table `points(y, x1)` SELECT (u).term, (u).estimate, (u).std_error FROM (SELECT unnest((lm_fit(y, [x1])).coefficients) AS u FROM points); -- term estimate std_error -- (Intercept) 0.0357 0.1404 -- x1 1.9976 0.0278 ``` The headline is **heteroskedasticity-consistent standard errors**. A third, constant argument selects the covariance estimator — `'const'` (default, classical), `'HC0'` (Eicker–Huber–White), `'HC1'` (the Stata `,robust` default, `× n/(n−k)`), `'HC2'`, or `'HC3'` (the recommended small-sample default). Only the `std_error` / `t_statistic` / `p_value` change; the point estimates do not: ```sql SELECT (u).term, (u).std_error FROM (SELECT unnest((lm_fit(y, [x1], 'HC3')).coefficients) AS u FROM points); -- term std_error -- (Intercept) 0.1313 -- x1 0.0282 ``` The killer query is a regression *per group*, computed where the data lives: ```sql -- a separate fit for every cylinder count, robust SEs, in one pass SELECT cyl, lm_fit(mpg, [wt, hp], 'HC1') AS model FROM mtcars GROUP BY cyl; ``` **Cluster-robust standard errors** (`'CR0'`, or `'CR1'` — the Stata `vce(cluster)` / statsmodels `cov_type='cluster'` default) account for within-cluster correlation. Unlike `vcov`, the cluster key is a real **per-row column** (not a constant); pass it as `VARCHAR` and cast a non-text key with `::VARCHAR`: ```sql -- SEs clustered by firm; 'CR1' applies the [G/(G−1)]·[(N−1)/(N−k)] correction SELECT (u).term, (u).estimate, (u).std_error FROM (SELECT unnest((lm_fit(ret, [mktrf, smb], 'CR1', firm_id::VARCHAR)).coefficients) AS u FROM panel); ``` Cluster-robust inference uses a `t(G − 1)` reference (G = number of clusters, surfaced as `n_clusters`), so it differs from the `t(n − k)` used by classical / HC. `'cluster'` is accepted as an alias for `'CR1'`. `lm_fit` returns a single `STRUCT`: | Field | Type | Notes | | ----------------------------------------------------------- | ------------------- | ----- | | `coefficients` | `LIST` | one element per term: `term`, `estimate`, `std_error`, `t_statistic`, `p_value` | | `n`, `k`, `df_residual` | `BIGINT` | rows used, parameters (incl. intercept), `n − k` | | `r_squared`, `adj_r_squared`, `sigma` | `DOUBLE` | classical model fit | | `f_statistic`, `f_p_value` | `DOUBLE` | classical overall-significance F (not robustified) | | `has_intercept` | `BOOLEAN` | | | `vcov_type` | `VARCHAR` | the estimator actually used | | `n_clusters` | `BIGINT` | number of clusters G (CR0/CR1 only; NULL otherwise) | A trailing constant `add_intercept := false` (positionally `lm_fit(y, x, 'const', false)`, or `lm_fit(y, x, 'CR1', cluster, false)` when clustered) drops the constant term — note aggregates take **positional** constants, not the `name := value` form `lm` uses. Rows with a NULL `y`, any NULL list element, or (when clustered) a NULL cluster key are dropped (complete-case). A group with too few rows (`n ≤ k`), a singular/collinear design, or — for CR0/CR1 — fewer than two clusters yields a **NULL** result for that group rather than aborting the query. `t`/`p` use the t(n−k) distribution for classical/HC and t(G−1) for CR0/CR1 (matching Stata / statsmodels `use_t`). All numerics run on the shared header-only linear-algebra kernel (`(X'X)⁻¹`, the robust sandwich), validated against statsmodels — see `test/cpp/test_lm_fit.cpp`. The bias-reduced CR2/CR3 cluster estimators are a planned follow-up. ### Multiple-testing correction (scalar) | Function | Description | | --------------------------------------- | -------------------------------------------------------------------------- | | `adjust_p(pvals, method)` | Apply a multiple-testing correction to a list of p-values | `adjust_p` takes a `LIST` of raw p-values and a method name, and returns adjusted p-values in input order. Methods (case-sensitive, matching R's `p.adjust`): - `'bonferroni'` — `min(1, n · p_i)`. - `'holm'` — Holm step-down (1979). - `'hochberg'` — Hochberg step-up (1988). - `'BH'` (alias `'fdr'`) — Benjamini-Hochberg FDR (1995). - `'BY'` — Benjamini-Yekutieli FDR (2001) for arbitrary dependence. - `'none'` — pass-through, returns the input unchanged. NULLs in the input list are passed through to the output at the same position and are excluded from `n`. ### Data import (table function) | Function | Description | | --------------------------------------- | ----------------------------- | | `read_stat(path, [format], [encoding])` | Read SAS / SPSS / Stata files | ### Data export (COPY function) | Statement | Description | | --------------------------------- | --------------------------------- | | `COPY
TO 'file.xpt'` | Write SAS Transport (XPT v5) | | `COPY
TO 'file.sas7bdat'` | Write SAS7BDAT (see caveat below) | | `COPY
TO 'file.sav'` | Write SPSS SAV | > **SAS7BDAT caveat.** ReadStat's SAS7BDAT writer is reverse-engineered: files > round-trip through ReadStat-family readers (this extension's `read_stat()`, > pyreadstat, haven, R) but are **not opened by real SAS / SAS Universal > Viewer / SAS OnDemand**. Use XPT for SAS-native readability. ### Visualizations (VISUALIZE parser extension) A Grammar-of-Graphics SQL dialect: `VISUALIZE` returns a single row with two columns — `spec` (a complete Vega-Lite v5 JSON spec) and `layer_sqls` (a `MAP(VARCHAR, VARCHAR)` of named SQL strings, one per layer). The client runs each layer's SQL and feeds the rows to vega-embed via the `datasets` API. **Tutorial:** [`docs/visualize.md`](docs/visualize.md) walks through a worked example of every mark and clause. **See also:** [posit-dev/ggsql-duckdb](https://github.com/posit-dev/ggsql-duckdb) — the dedicated grammar-of-graphics DuckDB extension from the ggplot2 team that inspired this syntax. `stats_duck`'s `VISUALIZE` is **not** a reimplementation of ggsql and doesn't track its syntax — it's a deliberately minimal, WebAssembly-friendly built-in supporting only a fixed set of marks and clauses, for plotting stats output inline. For the full grammar of graphics, use ggsql. ``` [WITH [RECURSIVE] AS (...) [, AS (...)]*] VISUALIZE AS [: ] (, AS ...) FROM
DRAW [STAT ] (DRAW [STAT ...])* [FACET BY [ROWS | COLS] | FACET BY , ] [SCALE {TO | ZERO true|false | DOMAIN | LABEL ''}+]* [TITLE '' [SUBTITLE '']] ``` Multiple `SCALE` options may be **stacked** on one channel (`SCALE x LABEL 'Bill Depth' ZERO false`) or split across repeated `SCALE x` clauses — they merge into one scale/axis block either way. SQL comments (`-- …` to end of line, and `/* … */`) may appear anywhere in a `VISUALIZE` statement; they're skipped like whitespace (but never treated as comments inside a string literal). A leading `WITH` clause is supported; CTEs are scoped to each layer's projected SQL so they compose with wrapping marks (`line`, `bar`, `area`, `errorband`, `regression`) without extra work. `WITH … SELECT …` statements without a top-level `VISUALIZE` keyword fall through to DuckDB's normal SQL parser unchanged. **Marks:** `point`, `line`, `bar`, `histogram`, `text`, `area`, `rule`, `tick`, `errorbar`, `errorband`, `boxplot`, `violin`, `heatmap`, `density`, `regression`. Custom marks register as `visualize_mark_v1_` scalar functions and are discovered via DuckDB's catalog, so other extensions can ship their own marks without modifying stats_duck. `heatmap` is a `rect` mark with ordinal x/y and quantitative color (correlation matrices, contingency tables). `density` runs Vega-Lite's KDE on the `x` aesthetic, grouped by `color` if mapped (one curve per category). `violin` renders one horizontal density per category of `x`, laid out via vega-lite's `column` facet (composes with `FACET BY ... ROWS` but conflicts with `FACET BY ... COLS`). `regression` fits a linear `y ~ x` model server-side via Vega-Lite's regression transform, also grouped by `color`. Use `DRAW point DRAW regression` for a scatter-with-fit overlay. **Aesthetic channels:** `x`, `y`, `color`, `fill`, `stroke`, `shape`, `size`, `opacity`, `tooltip`, `text`, `x2`, `y2`. Unknown channels are silently dropped. **Type overrides:** append `:quantitative`, `:ordinal`, `:nominal`, or `:temporal` to an aesthetic to force its Vega-Lite type (e.g. `year AS color:ordinal`). **Axis labels:** `SCALE x LABEL 'Bill length (mm)'` injects an `axis.title` into the channel; pairs with `TO` / `ZERO` / `DOMAIN` on the same channel. **Titles:** `TITLE 'Plot title' [SUBTITLE 'Plot subtitle']` appears once per spec, after `SCALE` clauses. Always emitted as a Vega-Lite `TitleParams` object so a subtitle can be added without reshaping consumer code. ## SAS compatibility stats_duck defaults follow modern statistical conventions (scipy / R with `correct=FALSE` / `var.equal=FALSE`). The one exception is `summary_stats` — its skewness/kurtosis formulas are the Fisher-Pearson bias-corrected ones, which is what SAS PROC MEANS, pandas, and Excel report. To reproduce SAS PROC output exactly, use the toggles below. | SAS procedure / statistic | stats_duck call | | ---------------------------------------------- | ------------------------------------------------------------------- | | PROC MEANS — mean, SD, skewness, kurtosis | `summary_stats(x)` _(default already matches SAS)_ | | PROC TTEST — Pooled (equal variances) | `ttest_2samp(x, y, true)` | | PROC TTEST — Satterthwaite (default) | `ttest_2samp(x, y)` _(default Welch's matches Satterthwaite)_ | | PROC NPAR1WAY — Wilcoxon two-sample Z | `mann_whitney_u(x, y, 'two-sided', true)` _(continuity correction)_ | | PROC FREQ — Continuity Adj. χ² (2x2 only) | `chisq_independence(row, col, true)` _(Yates' correction)_ | | PROC FREQ — Chi-Square (no adjustment) | `chisq_independence(row, col)` _(default)_ | | PROC CORR — Pearson / Spearman / Kendall | `pearson_test(x, y)` / `spearman_test(x, y)` / `kendall_test(x, y)` | | PROC GLM — one-way ANOVA F-test | `anova_oneway(value, group)` | | PROC UNIVARIATE — Signed Rank (sign-rank test) | `wilcoxon_signed_rank(x, 0)` _(against a 0 column or constant)_ | | PROC UNIVARIATE — Sign test (M statistic) | `sign_test_1samp(x, [mu_0])` | | PROC UNIVARIATE — quantiles (Type 5) | `summary_stats(x, true, 5)` | Defaults preserve modern conventions so users on the modern side of the fence get sensible numbers without touching the API; SAS users add the appropriate flag during migration and validation. ## Examples ### Hypothesis tests #### One-sample t-test ```sql -- Test whether the mean of v3 differs from zero SELECT ttest_1samp(v3) FROM measurements; -- Test against a specific mean SELECT ttest_1samp(v3, 5.0) FROM measurements; -- One-sided test with 99% confidence interval SELECT ttest_1samp(v3, 0.0, 0.01, 'greater') FROM measurements; ``` #### Two-sample t-test ```sql -- Welch's t-test (default, does not assume equal variances) SELECT ttest_2samp(group_a, group_b) FROM experiment; -- Student's t-test (assumes equal variances) SELECT ttest_2samp(group_a, group_b, true) FROM experiment; ``` #### Paired t-test ```sql -- Compare before/after measurements SELECT ttest_paired(before, after) FROM patients; ``` #### Mann-Whitney U test Non-parametric alternative to the two-sample t-test: ```sql -- Compare two independent samples SELECT mann_whitney_u(group_a, group_b) FROM experiment; -- One-sided test SELECT mann_whitney_u(group_a, group_b, 'less') FROM experiment; ``` #### Wilcoxon signed-rank test Non-parametric alternative to the paired t-test: ```sql -- Compare paired measurements SELECT wilcoxon_signed_rank(before, after) FROM patients; ``` ### Working with results The result of any hypothesis test is a `STRUCT` — access individual fields with dot notation: ```sql SELECT (ttest_1samp(v3)).t_statistic, (ttest_1samp(v3)).p_value FROM measurements; ``` Or unpack all fields: ```sql SELECT (r).* FROM (SELECT ttest_1samp(v3) AS r FROM measurements); ``` #### Group-by Run a test per group with no extra plumbing: ```sql SELECT id3, (ttest_1samp(v3)).t_statistic, (ttest_1samp(v3)).p_value FROM measurements GROUP BY id3; ``` #### Inline data Use `VALUES` for quick experiments with literal data: ```sql SELECT (r).* FROM (SELECT ttest_1samp(v) AS r FROM (VALUES (2.0), (4.0), (6.0), (8.0), (10.0)) AS t(v)); ``` ### Data import #### Reading statistical file formats Read SAS (`.sas7bdat`, `.xpt`), SPSS (`.sav`, `.zsav`, `.por`), and Stata (`.dta`) files: ```sql -- Auto-detect format from file extension SELECT * FROM read_stat('data.sas7bdat'); -- Explicit format SELECT * FROM read_stat('data.dat', format := 'dta'); -- Replacement scan: query files directly SELECT * FROM 'survey.sav'; ``` Date, datetime, and time columns are automatically detected from format metadata and converted to DuckDB's native temporal types. `read_stat` is wired through DuckDB's virtual file system, so the same call works against: - local paths: `read_stat('/data/survey.sav')` - remote files (with the `httpfs` extension loaded): `read_stat('https://…/survey.sav')` - object stores: `read_stat('s3://bucket/survey.sav')` - in-browser DuckDB-WASM file buffers registered via `registerFileBuffer()` #### Writing statistical file formats The same VFS-backed writers, exposed as DuckDB `COPY` functions: ```sql -- SAS Transport (XPT v5) — universally readable, FDA / pharma standard COPY survey TO 'survey.xpt'; -- SAS7BDAT — round-trips through stats_duck and pyreadstat (NOT real SAS, see caveat) COPY survey TO 'survey.sas7bdat' (COMPRESSION 'rows'); -- SPSS SAV COPY survey TO 'survey.sav'; -- COPY form works too COPY (SELECT * FROM huge_table WHERE region = 'EU') TO 'eu.xpt'; ``` NULL handling differs by storage format: numeric NULLs round-trip as SAS/SPSS system-missing, but VARCHAR NULLs collapse to empty strings (these formats have no NULL/empty distinction for character columns). ### Visualizations (VISUALIZE) ```sql -- Set up — penguin morphology, classic ggplot2 demo data CREATE TABLE penguins AS SELECT * FROM (VALUES (39.1, 18.7, 'Adelie', 2018), (39.5, 17.4, 'Adelie', 2019), (44.0, 18.0, 'Gentoo', 2020), (45.2, 14.5, 'Chinstrap', 2021) ) AS t(bill_len, bill_dep, species, year); ``` #### Scatter ```sql VISUALIZE bill_len AS x, bill_dep AS y FROM penguins DRAW point; ``` #### Scatter colored by species, with a viridis scale ```sql VISUALIZE bill_len AS x, bill_dep AS y, species AS color FROM penguins DRAW point SCALE color TO viridis; ``` #### Multi-layer (scatter + line overlay) ```sql VISUALIZE bill_len AS x, bill_dep AS y FROM penguins DRAW point DRAW line; ``` #### Faceted plot — one panel per species, vertically stacked ```sql VISUALIZE bill_len AS x, bill_dep AS y FROM penguins DRAW point FACET BY species ROWS; ``` #### 2D facet grid — species rows × sex columns ```sql VISUALIZE bill_len AS x, bill_dep AS y FROM penguins DRAW point FACET BY species, sex; ``` #### Scatter with LOESS overlay via STAT smooth ```sql VISUALIZE bill_len AS x, bill_dep AS y FROM penguins DRAW point DRAW line STAT smooth; ``` #### SQL expressions in mappings ```sql VISUALIZE bill_len * 2 AS x, log(bill_dep) AS y FROM penguins DRAW point; ``` #### Type-annotated aesthetic (year is INTEGER but should be ordinal here) ```sql VISUALIZE bill_len AS x, bill_dep AS y, year AS color:ordinal FROM penguins DRAW point SCALE color TO viridis; ``` The output of any `VISUALIZE` query is a single row with `(spec, layer_sqls)`. The client side (a browser app, a notebook renderer, …) is responsible for running each layer's SQL and feeding the rows into vega-embed via its `datasets` API. See [Bedevere Wise](https://github.com/caerbannogwhite/bedevere-wise) for a reference DuckDB-WASM consumer. ## Building ```bash git submodule update --init --recursive make release ``` ### Building with MinGW DuckDB extensions are tagged at build time with a _platform string_ that the host process uses to validate ABI compatibility before loading. The default `make release` on Windows uses MSVC and stamps `windows_amd64`. A consumer DuckDB built with mingw-w64 (e.g. via zig's bundled clang + mingw-w64 sysroot, or the rtools/MSYS2 toolchains) will refuse to load that binary with: ``` Failed to load 'stats_duck.duckdb_extension'. The file was built for the platform 'windows_amd64', but we can only load extensions built for platform 'windows_amd64_mingw'. ``` For those hosts, build with: ```bash make mingw_release ``` This drives a CMake build with `-G "MinGW Makefiles"`, sets `CC=gcc`, `CXX=g++`, and stamps `DUCKDB_EXPLICIT_PLATFORM=windows_amd64_mingw`. The binary lands at: ``` build/mingw_release/extension/stats_duck/stats_duck.duckdb_extension build/mingw_release/repository//windows_amd64_mingw/stats_duck.duckdb_extension ``` The MSVC `windows_amd64` binary at `build/release/...` is left alone — both flavors can coexist on disk and ship side by side. CI already produces both on every release; this target is for local-only verification or for distributing to consumers that bundle their own DuckDB and need an exact ABI match. **Toolchain requirements.** Anything ABI-compatible with `x86_64-w64-mingw32` works. Tested with TDM-GCC 10.3 and MSYS2's `mingw-w64-x86_64-toolchain`. On TDM-GCC the Makefile passes `-D_WIN32_WINNT=0x0A00` because TDM-GCC's default predates the Vista-era APIs DuckDB uses; MSYS2 already defaults to that value, where the flag is harmless. **vcpkg is intentionally not used** for the MinGW build — its Windows default triplet is MSVC and would not match the toolchain. zlib (the only external dep, used for SPSS `.zsav` read/write) becomes optional and gracefully degrades if absent (see `CMakeLists.txt:97`); install zlib via your mingw package manager if you need `.zsav` support. ### Building with zig as the C++ toolchain (matches sassy) DuckDB's platform string `windows_amd64_mingw` does not distinguish which C++ runtime the binary is linked against. Two `windows_amd64_mingw` extensions can both pass the platform check on load and then segfault during function registration if their `std::map` / `std::shared_ptr` / etc. layouts differ — which they do across **libstdc++** (GNU's STL, what mingw-w64 GCC uses) and **libc++** (LLVM's STL, what zig's bundled clang uses with `link_libcpp`). The downstream [sassy](https://github.com/caerbannogwhite/sassy) SAS interpreter builds DuckDB with zig + `link_libcpp = true`, so its DuckDB links against libc++. The `make mingw_release` target above produces a libstdc++-linked binary and is _not_ compatible — it'll segfault inside sassy. Use the zig variant instead: ```bash make zig_mingw_release ``` This drives the same CMake configuration as `mingw_release` but routes `CC` / `CXX` through `scripts/zig-shims/zig-cc.cmd` / `zig-cxx.cmd`, which forward to `zig cc -target x86_64-windows-gnu` and `zig c++ -target x86_64-windows-gnu`. Output: ``` build/zig_mingw_release/extension/stats_duck/stats_duck.duckdb_extension build/zig_mingw_release/repository//windows_amd64_mingw/stats_duck.duckdb_extension ``` Same platform stamp (`windows_amd64_mingw`), different C++ runtime. On a correctly-built artifact, `objdump -p stats_duck.duckdb_extension | grep DLL` shows only Windows system DLLs (`KERNEL32.dll`, `api-ms-win-crt-*`) — no `libstdc++-6.dll` or `libgcc_s_seh-1.dll`. If any GNU runtime DLL appears, the build slipped back to GCC. **Requirements.** zig 0.16+ on `PATH`. Tested with the `zig.zig` WinGet-installed copy. ## Testing ```bash make release && make test ``` Or on Windows where the Makefile test runner may not work: ```bash ./build/release/test/Release/unittest.exe "test/sql/*" ``` ## License The Stats Duck is released under the [Apache License 2.0](LICENSE). If you use it in academic work, see [`CITATION.cff`](CITATION.cff) for citation metadata.