Repository: JuliaStats/HypothesisTests.jl
Pull request #332 (2025-03-07 22:10:51)

Allow vector of groups as an input for variance equality tests
Currently, one-way ANOVA and other variance equality tests require that each group of values is passed as a separate argument (`OneWayAnova([1, 2], [1, 2, 3])`).
When the user has a vector of groups (`groups = [[1, 2], [1, 2, 3]]`), it is therefore required to splat it when passing to the tests (`OneWayAnova(groups...)`).
That adds unnecessary overhead to the Julia compiler, especially when the number of groups is large -- the execution becomes extremely slow.

The PR changes the var-equality tests to accept the vector of groups as the input.
The "splatting" variants of the functions are converted to call the vector-based ones.

The PR also includes the modified version of the #273 fix -- but I will also welcome if that PR is merged first.


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/pull/332?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats) Report
Attention: Patch coverage is `95.23810%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 92.01%. Comparing base [(`1cbeccd`)](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/commit/1cbeccd8e31bc26f6d0f44301243b23eba558b05?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats) to head [(`3718e35`)](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/commit/3718e35e11de78ebea5702d9726f0148fc5a8df2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats).

| [Files with missing lines](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/pull/332?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats) | Patch % | Lines |
|---|---|---|
| [src/var\_equality.jl](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/pull/332?src=pr&el=tree&filepath=src%2Fvar_equality.jl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats#diff-c3JjL3Zhcl9lcXVhbGl0eS5qbA==) | 95.23% | [1 Missing :warning: ](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/pull/332?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #332      +/-   ##
==========================================
- Coverage   94.04%   92.01%   -2.04%     
==========================================
  Files          29       29              
  Lines        1814     1816       +2     
==========================================
- Hits         1706     1671      -35     
- Misses        108      145      +37     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/JuliaStats/HypothesisTests.jl/pull/332?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaStats).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
alyst: bump :)
alyst: @devmotion Thank you, I've applied your suggestions

Patch:
diff --git a/src/var_equality.jl b/src/var_equality.jl
index 3e1775b..6a061a0 100644
--- a/src/var_equality.jl
+++ b/src/var_equality.jl
@@ -57,16 +57,23 @@ function Base.show(io::IOContext, t::VarianceEqualityTest)
     end
 end
 
-function anova(scores::AbstractVector{<:Real}...)
-    Nᵢ = [length(g) for g in scores]
-    Z̄ᵢ = mean.(scores)
-    Z̄ = mean(Z̄ᵢ)
-    SStᵢ = Nᵢ .* (Z̄ᵢ .- Z̄).^2
-    SSeᵢ = sum.( (z .- z̄).^2 for (z, z̄) in zip(scores, Z̄ᵢ) )
+function anova(scores)
+    if isempty(scores) || !all(x -> x isa AbstractVector{<:Real}, scores)
+        throw(ArgumentError("`anova` requires a non-empty collection of vectors of `Real` numbers"))
+    end
+
+    Nᵢ = [length(s) for s in scores]
+    Z̄ᵢ = map(mean, scores)
+    Z̄ = dot(Z̄ᵢ, Nᵢ) / sum(Nᵢ)
+    SStᵢ = Nᵢ .* abs2.(Z̄ᵢ .- Z̄)
+    SSeᵢ = [sum(z -> abs2(z - z̄ᵢ), scoresᵢ) for (scoresᵢ, z̄ᵢ) in zip(scores, Z̄ᵢ)]
     (Nᵢ, SStᵢ, SSeᵢ)
 end
 
+anova(scores::AbstractVector{<:Real}...) = anova(scores)
+
 """
+    OneWayANOVATest(groups)
     OneWayANOVATest(groups::AbstractVector{<:Real}...)
 
 Perform one-way analysis of variance test of the hypothesis that that the `groups`
@@ -86,14 +93,17 @@ Implements: [`pvalue`](@ref)
   * [One-way analysis of variance on Wikipedia
     ](https://en.wikipedia.org/wiki/One-way_analysis_of_variance)
 """
-function OneWayANOVATest(groups::AbstractVector{<:Real}...)
-    Nᵢ, SStᵢ, SSeᵢ = anova(groups...)
+function OneWayANOVATest(groups)
+    Nᵢ, SStᵢ, SSeᵢ = anova(groups)
     k = length(Nᵢ)
     VarianceEqualityTest{FDist}(Nᵢ, SStᵢ, SSeᵢ, k-1, sum(Nᵢ)-k,
-        ("One-way analysis of variance (ANOVA) test","Means","F"))
+        ("One-way analysis of variance (ANOVA) test", "Means", "F"))
 end
 
+OneWayANOVATest(groups::AbstractVector{<:Real}...) = OneWayANOVATest(groups)
+
 """
+    LeveneTest(groups; scorediff=abs, statistic=mean)
     LeveneTest(groups::AbstractVector{<:Real}...; scorediff=abs, statistic=mean)
 
 Perform Levene's test of the hypothesis that that the `groups` variances are equal.
@@ -134,17 +144,21 @@ Implements: [`pvalue`](@ref)
   * [Levene's test on Wikipedia
     ](https://en.wikipedia.org/wiki/Levene%27s_test)
 """
-function LeveneTest(groups::AbstractVector{<:Real}...; scorediff=abs, statistic=mean)
+function LeveneTest(groups; scorediff=abs, statistic=mean)
     # calculate scores
-    Zᵢⱼ = [scorediff.(g .- statistic(g)) for g in groups]
+    Zᵢⱼ = map(g -> scorediff.(g .- statistic(g)), groups)
     # anova
-    Nᵢ, SStᵢ, SSeᵢ = anova(Zᵢⱼ...)
+    Nᵢ, SStᵢ, SSeᵢ = anova(Zᵢⱼ)
     k = length(Nᵢ)
     VarianceEqualityTest{FDist}(Nᵢ, SStᵢ, SSeᵢ, k-1, sum(Nᵢ)-k,
-        ("Levene's test","Variances","W"))
+        ("Levene's test", "Variances", "W"))
 end
 
+LeveneTest(groups::AbstractVector{<:Real}...; kwargs...) =
+    LeveneTest(groups; kwargs...)
+
 """
+    BrownForsytheTest(groups)
     BrownForsytheTest(groups::AbstractVector{<:Real}...)
 
 The Brown–Forsythe test is a statistical test for the equality of `groups` variances.
@@ -165,9 +179,11 @@ Implements: [`pvalue`](@ref)
   * [Brown–Forsythe test on Wikipedia
     ](https://en.wikipedia.org/wiki/Brown%E2%80%93Forsythe_test)
 """
-BrownForsytheTest(groups::AbstractVector{<:Real}...) = LeveneTest(groups...; statistic=median)
+BrownForsytheTest(groups) = LeveneTest(groups; statistic=median)
+BrownForsytheTest(groups::AbstractVector{<:Real}...) = BrownForsytheTest(groups)
 
 """
+    FlignerKilleenTest(groups)
     FlignerKilleenTest(groups::AbstractVector{<:Real}...)
 
 Perform Fligner-Killeen median test of the null hypothesis that the `groups`
@@ -194,17 +210,25 @@ Implements: [`pvalue`](@ref)
   * [Fligner-Killeen test on Statistical Analysis Handbook
     ](https://www.statsref.com/HTML/index.html?fligner-killeen_test.html)
 """
-function FlignerKilleenTest(groups::AbstractVector{<:Real}...)
+function FlignerKilleenTest(groups)
     # calculate scores
-    Zᵢⱼ = [abs.(g .- median(g)) for g in groups]
+    Zᵢⱼ = map(g -> abs.(g .- median(g)), groups)
     # rank scores
-    (ranks, tieadj) = tiedrank_adj(vcat(Zᵢⱼ...))
-    qᵢⱼ = quantile.(Normal(),0.5 .+ ranks./2(length(ranks)+1))
-    Nᵢ = pushfirst!(cumsum([length(g) for g in groups]),0)
-    Qᵢⱼ = [qᵢⱼ[(Nᵢ[i]+1):(Nᵢ[i+1])] for i in 1:length(Nᵢ)-1]
+    (ranks, tieadj) = tiedrank_adj(reduce(vcat, Zᵢⱼ))
+    qᵢⱼ = quantile.(Normal(), 0.5 .+ ranks ./ 2(length(ranks) + 1))
+    lastᵢ = 0
+    Qᵢⱼ = map(groups) do gᵢ
+        n = length(gᵢ)
+        qᵢ = view(qᵢⱼ, (lastᵢ + 1):(lastᵢ + n))
+        lastᵢ += n
+        return qᵢ
+    end
     # anova
-    Nᵢ, SStᵢ, SSeᵢ = anova(Qᵢⱼ...)
+    Nᵢ, SStᵢ, SSeᵢ = anova(Qᵢⱼ)
     k = length(Nᵢ)
-    t3 = VarianceEqualityTest{Chisq}(Nᵢ, SStᵢ, SSeᵢ, k-1, sum(Nᵢ)-k,
-            ("Fligner-Killeen test","Variances","FK"))
+    VarianceEqualityTest{Chisq}(Nᵢ, SStᵢ, SSeᵢ, k-1, sum(Nᵢ)-k,
+        ("Fligner-Killeen test", "Variances", "FK"))
 end
+
+FlignerKilleenTest(groups::AbstractVector{<:Real}...) =
+    FlignerKilleenTest(groups)


Test patch:
diff --git a/test/var_equality.jl b/test/var_equality.jl
index 604ba94..d3ffba6 100644
--- a/test/var_equality.jl
+++ b/test/var_equality.jl
@@ -1,37 +1,69 @@
 using HypothesisTests
 using Test
-using Statistics
+using StatsBase
 using DelimitedFiles
 
 @testset "Equality of Variances" begin
+    @testset "One-way ANOVA" begin
+        # input checks
+        @test_throws ArgumentError OneWayANOVATest([])
+        @test_throws ArgumentError OneWayANOVATest(["A", "B"])
+        @test_throws ArgumentError OneWayANOVATest([["A", "B"], ["C", "D"]])
+        @test_throws MethodError OneWayANOVATest(["A", "B"], ["C", "D"])
 
-    # https://en.wikipedia.org/wiki/One-way_analysis_of_variance#Example
-    groups = [
-        [6, 8, 4, 5, 3, 4],
-        [8, 12, 9, 11, 6, 8],
-        [13, 9, 11, 8, 7, 12]
-    ]
-    t = OneWayANOVATest(groups...)
-    @test nobs(t) == fill(6, 3)
-    @test dof(t) == (2,15)
-    @test pvalue(t) ≈ 0.002 atol=1e-3
-    @test occursin("reject h_0", sprint(show, t))
+        # https://en.wikipedia.org/wiki/One-way_analysis_of_variance#Example
+        groups = [
+            [6, 8, 4, 5, 3, 4],
+            [8, 12, 9, 11, 6, 8],
+            [13, 9, 11, 8, 7, 12]
+        ]
+        t = OneWayANOVATest(groups)
+        @test nobs(t) == fill(6, 3)
+        @test dof(t) == (2,15)
+        @test pvalue(t) ≈ 0.002 atol=1e-3
+        @test occursin("reject h_0", sprint(show, t))
 
-    show(IOContext(IOBuffer(), :table => true), t)
-    show(IOBuffer(), t)
+        # test splatting version
+        t2 = OneWayANOVATest(groups...)
+        @test nobs(t2) == nobs(t)
+        @test dof(t2) == dof(t)
+        @test pvalue(t2) == pvalue(t)
+        @test HypothesisTests.teststatistic(t2) == HypothesisTests.teststatistic(t)
+        @test repr(t2) == repr(t)
 
-    # http://www.real-statistics.com/one-way-analysis-of-variance-anova/confidence-interval-anova/
-    groups = [
-        [51, 87, 50, 48, 79, 61, 53],
-        [82, 91, 92, 80, 52, 79, 73, 74],
-        [79, 84, 74, 98, 63, 83, 85, 58],
-        [85, 80, 65, 71, 67, 51],
-    ]
-    t = OneWayANOVATest(groups...)
-    @test nobs(t) == [7, 8, 8, 6]
-    @test dof(t) == (3,25)
-    @test pvalue(t) ≈ 0.072 atol=1e-3
-    @test occursin("reject h_0", sprint(show, t))
+        # test tuple version
+        t3 = OneWayANOVATest((groups...))
+        @test nobs(t3) == nobs(t)
+        @test dof(t3) == dof(t)
+        @test pvalue(t3) == pvalue(t)
+        @test HypothesisTests.teststatistic(t3) == HypothesisTests.teststatistic(t)
+        @test repr(t3) == repr(t)
+
+        # test generator version
+        t4 = OneWayANOVATest(g for g in groups)
+        @test nobs(t4) == nobs(t)
+        @test dof(t4) == dof(t)
+        @test pvalue(t4) == pvalue(t)
+        @test HypothesisTests.teststatistic(t4) == HypothesisTests.teststatistic(t)
+        @test repr(t4) == repr(t)
+
+        show(IOContext(IOBuffer(), :table => true), t)
+        show(IOBuffer(), t)
+
+        # http://www.real-statistics.com/one-way-analysis-of-variance-anova/confidence-interval-anova/
+        groups = [
+            [51, 87, 50, 48, 79, 61, 53],
+            [82, 91, 92, 80, 52, 79, 73, 74],
+            [79, 84, 74, 98, 63, 83, 85, 58],
+            [85, 80, 65, 71, 67, 51],
+        ]
+        t = OneWayANOVATest(groups)
+        @test nobs(t) == [7, 8, 8, 6]
+        @test dof(t) == (3, 25)
+        @test pvalue(t) ≈ 0.07276 atol=1e-6
+        @test HypothesisTests.teststatistic(t) ≈ 2.62311 atol=1e-6
+        @test occursin("reject h_0", sprint(show, t))
+    end
 
     # http://www.real-statistics.com/one-way-analysis-of-variance-anova/homogeneity-variances/levenes-test/
     groups = [
@@ -40,38 +72,63 @@ using DelimitedFiles
         [79, 84, 74, 98, 63, 83, 85, 58],
         [85, 80, 65, 71, 67, 51, 63, 93],
     ]
-    # with means
-    l = LeveneTest(groups...; statistic=mean)
-    @test nobs(l) == fill(8, 4)
-    @test dof(l) == (3,28)
-    @test pvalue(l) ≈ 0.90357 atol=1e-4
-    # with medians
-    l = LeveneTest(groups...; statistic=median)
-    @test pvalue(l) ≈ 0.97971 atol=1e-4
-    # with 10% trimmed means
-    l = LeveneTest(groups...; statistic=v->mean(trim(v, prop=0.1)))
-    @test pvalue(l) ≈ 0.90357 atol=1e-4
+    @testset "Levene" begin
+        # with means
+        l = LeveneTest(groups; statistic=mean)
+        @test nobs(l) == fill(8, 4)
+        @test dof(l) == (3,28)
+        @test pvalue(l) ≈ 0.90357 atol=1e-4
+
+        l2 = LeveneTest(groups...; statistic=mean)
+        @test nobs(l2) == nobs(l)
+        @test dof(l2) == dof(l)
+        @test pvalue(l2) == pvalue(l)
+        @test HypothesisTests.teststatistic(l2) == HypothesisTests.teststatistic(l)
+        @test repr(l2) == repr(l)
+
+        # with medians
+        l = LeveneTest(groups; statistic=median)
+        @test pvalue(l) ≈ 0.97971 atol=1e-4
+        # with 10% trimmed means
+        l = LeveneTest(groups; statistic=v -> mean(trim(v, prop=0.1)))
+        @test pvalue(l) ≈ 0.90357 atol=1e-4
+    end
+
+    @testset "Fligner-Killeen" begin
+        t = FlignerKilleenTest(groups)
+        @test nobs(t) == fill(8, 4)
+        @test dof(t) == 3
+        @test pvalue(t) ≈ 0.9878 atol=1e-4
+        @test HypothesisTests.teststatistic(t) ≈ 0.1311 atol=1e-5
+        @test occursin("reject h_0", sprint(show, t))
 
-    # Fligner-Killeen Test
-    t = FlignerKilleenTest(groups...)
-    @test nobs(t) == fill(8, 4)
-    @test dof(t) == 3
-    @test pvalue(t) ≈ 0.9878 atol=1e-4
-    @test HypothesisTests.teststatistic(t) ≈ 0.1311 atol=1e-5
-    @test occursin("reject h_0", sprint(show, t))
+        # test splatting version
+        t2 = FlignerKilleenTest(groups...)
+        @test nobs(t2) == nobs(t)
+        @test dof(t2) == dof(t)
+        @test pvalue(t2) == pvalue(t)
+        @test HypothesisTests.teststatistic(t2) == HypothesisTests.teststatistic(t)
+        @test repr(t2) == repr(t)
+    end
 
-    # https://www.itl.nist.gov/div898/handbook/eda/section3/eda35a.htm
+    @testset "Brown-Forsythe" begin
+        # https://www.itl.nist.gov/div898/handbook/eda/section3/eda35a.htm
+        # Columns are gear diameter and batch number
+        gear = readdlm(joinpath(@__DIR__, "data", "gear.txt"))
+        samples = reshape(gear[:, 1], :, 10)
 
-    # Columns are gear diameter and batch number
-    gear = readdlm(joinpath(@__DIR__, "data", "gear.txt"))
-    samples = reshape(gear[:,1],:,10)
-    groups2 = tuple((samples[:,i] for i in 1:size(samples,1))...)
+        l = BrownForsytheTest(collect(eachcol(samples)))
+        @test nobs(l) == fill(10, 10)
+        @test dof(l) == (9, 90)
+        @test HypothesisTests.teststatistic(l) ≈ 1.705910 atol=1e-5
+        @test pvalue(l) ≈ 0.0991 atol=1e-4
+        @test occursin("reject h_0", sprint(show, l))
 
-    # Brown-Forsythe Test
-    l = BrownForsytheTest(groups2...)
-    @test nobs(l) == fill(10, 10)
-    @test dof(l) == (9,90)
-    @test HypothesisTests.teststatistic(l) ≈ 1.705910 atol=1e-5
-    @test pvalue(l) ≈ 0.0991 atol=1e-4
-    @test occursin("reject h_0", sprint(show, l))
+        l2 = BrownForsytheTest(eachcol(samples)...)
+        @test nobs(l2) == nobs(l)
+        @test dof(l2) == dof(l)
+        @test HypothesisTests.teststatistic(l2) == HypothesisTests.teststatistic(l)
+        @test pvalue(l2) == pvalue(l)
+        @test repr(l2) == repr(l)
+    end
 end