| Classical Test | bossanova Equivalent | When |
|---|---|---|
| Paired t-test | model("y ~ condition + (1|subject)", df) | 2 conditions |
| RM-ANOVA | model("y ~ condition + (1|subject)", df) | 3+ conditions |
| Wilcoxon signed-rank (paired) | model("rank(y) ~ condition + (1|subject)", df) | 2 conditions, robust |
| Friedman test | model("rank(y) ~ condition + (1|subject)", df) | 3+ conditions, robust |
| RM-ANOVA + covariate | model("y ~ condition + covariate + (1|subject)", df) | Between-subject covariate |
Notice that paired tests and repeated measures are the same formula. Adding (1|subject) accounts for within-subject variation; with 2 conditions this is equivalent to the paired t-test, with 3+ conditions it becomes RM-ANOVA.
Two conditions (paired t-test)¶
Classical:
where is the number of paired observations.
As GLM (mixed):
The varying intercept absorbs subject-level baseline differences — equivalent to computing difference scores. The test of asks whether conditions differ after accounting for within-subject variation.
scipy¶
male = penguins.filter(pl.col("sex") == "male")["body_mass_g"].to_numpy()
female = penguins.filter(pl.col("sex") == "female")["body_mass_g"].to_numpy()
scipy_ttest = ttest_ind(male, female)
scipy_ttestTtestResult(statistic=np.float64(8.541720337994516), pvalue=np.float64(4.897246751596224e-16), df=np.float64(331.0))bossanova (simple linear model)¶
Without varying effects, the linear model recovers the classical independent t-test exactly:
m_lm = model("body_mass_g ~ sex", penguins).fit().infer()
m_lm.params[1].select("term", "statistic", "df", "p_value")bossanova (mixed model)¶
Adding (1|species) accounts for species-level variation, giving a more powerful test -- the t-statistic is larger because residual variance is reduced:
m = model("body_mass_g ~ sex + (1|species)", penguins).fit().infer()
m.params.select("term", "statistic", "df", "p_value")The mixed-model statistic differs from the classical t-test because it partitions variance into species-level and residual components. By accounting for group structure, the test becomes more sensitive.
Three+ conditions (RM-ANOVA)¶
Classical:
As GLM (mixed):
Standard ANOVA ignores within-group correlation, inflating Type I error. The varying intercept captures group-level baseline differences, properly partitioning variance.
scipy¶
adelie = penguins.filter(pl.col("species") == "Adelie")["flipper_length_mm"].to_numpy()
chinstrap = penguins.filter(pl.col("species") == "Chinstrap")["flipper_length_mm"].to_numpy()
gentoo = penguins.filter(pl.col("species") == "Gentoo")["flipper_length_mm"].to_numpy()
scipy_anova = f_oneway(adelie, chinstrap, gentoo)
scipy_anovaF_onewayResult(statistic=np.float64(567.4069920123421), pvalue=np.float64(1.5874180554406345e-107))bossanova¶
m_rm = model("flipper_length_mm ~ species + (1|island)", penguins).fit().infer()
m_rm.params.select("term", "estimate", "statistic", "p_value")# Joint F-test for species effect
m_rm.infer("joint").effects# Random effects variance and model fit
m_rm.diagnostics.select("aic", "bic", "rsquared_marginal", "rsquared_conditional", "icc")Rank-based variants (robust)¶
The rank() transformation makes within-subject inference robust to outliers and non-normality. With 2 conditions this parallels the Wilcoxon signed-rank test; with 3+ it parallels the Friedman test.
Two conditions (Wilcoxon)¶
Classical:
As GLM (mixed):
scipy_mann = mannwhitneyu(male, female)
scipy_mannMannwhitneyuResult(statistic=np.float64(20845.5), pvalue=np.float64(1.8133343032461053e-15))m_wilcox = model("rank(body_mass_g) ~ sex + (1|species)", penguins).fit().infer()
m_wilcox.params.filter(pl.col("term").str.contains("sex")).select("term", "statistic", "p_value")scipy reports the Mann-Whitney U statistic; bossanova reports a t-statistic on ranks with varying intercepts. The test statistics differ but both test H_0: no group difference and yield equivalent conclusions.
Three+ conditions (Friedman)¶
Classical:
where is the sum of ranks for condition across subjects.
As GLM (mixed):
Joint -test on the condition coefficients parallels the Friedman .
scipy_kruskal = kruskal(adelie, chinstrap, gentoo)
scipy_kruskalKruskalResult(statistic=np.float64(237.34574750210166), pvalue=np.float64(2.890851468876691e-52))m_friedman = model("rank(flipper_length_mm) ~ species + (1|island)", penguins).fit().infer("joint")
m_friedman.effectsAdding covariates¶
Mixed models naturally extend to include between-subject covariates -- there is no simple classical equivalent.
m_cov = model("flipper_length_mm ~ species + sex + (1|island)", penguins).fit().infer()
m_cov.params.select("term", "estimate", "p_value")