From 5eb13eeaa23d514c9ca682676323c98d7f228eaf Mon Sep 17 00:00:00 2001 From: Brad <1676751+Sinkleberg@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:03:31 -0500 Subject: [PATCH 1/3] Accept abstract unit ranges as count levels --- src/counts.jl | 32 ++++++++++--------- test/counts.jl | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/counts.jl b/src/counts.jl index 43fa1d495..930dcaa3b 100644 --- a/src/counts.jl +++ b/src/counts.jl @@ -14,32 +14,34 @@ end #### functions for counting a single list of integers (1D) """ - addcounts!(r, x, levels::UnitRange{<:Integer}, [wv::AbstractWeights]) + addcounts!(r, x, levels::AbstractUnitRange{<:Integer}, [wv::AbstractWeights]) Add the number of occurrences in `x` of each value in `levels` to an existing -array `r`. For each `xi ∈ x`, if `xi == levels[j]`, then we increment `r[j]`. +array `r`. The `j`th bin counts the `j`th value in `levels`, independently of the +indices of `levels`. If a weighting vector `wv` is specified, the sum of weights is used rather than the raw counts. """ -function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}) +function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}) # add counts of integers from x that fall within levels to r - checkbounds(r, axes(levels)...) + checkbounds(r, Base.OneTo(length(levels))) m0 = first(levels) m1 = last(levels) - b = m0 - firstindex(levels) # firstindex(levels) == 1 because levels::UnitRange{<:Integer} + # Promote narrow bounds before subtracting; keep generic integer arithmetic. + lower = m0 + zero(Int) for xi in x if m0 <= xi <= m1 - r[xi - b] += 1 + r[(xi - lower) + 1] += 1 end end return r end -function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}, wv::AbstractWeights) +function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}, wv::AbstractWeights) # add wv weighted counts of integers from x that fall within levels to r length(x) == length(wv) || @@ -47,16 +49,16 @@ function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::UnitR xv = vec(x) # discard shape because weights() discards shape - checkbounds(r, axes(levels)...) + checkbounds(r, Base.OneTo(length(levels))) m0 = first(levels) m1 = last(levels) - b = m0 - 1 + lower = m0 + zero(Int) for i in eachindex(xv, wv) xi = xv[i] if m0 <= xi <= m1 - r[xi - b] += wv[i] + r[(xi - lower) + 1] += wv[i] end end return r @@ -65,7 +67,7 @@ end """ counts(x, [wv::AbstractWeights]) - counts(x, levels::UnitRange{<:Integer}, [wv::AbstractWeights]) + counts(x, levels::AbstractUnitRange{<:Integer}, [wv::AbstractWeights]) counts(x, k::Integer, [wv::AbstractWeights]) Count the number of times each value in `x` occurs. If `levels` is provided, only values @@ -80,9 +82,9 @@ The output is a vector of length `length(levels)`. """ function counts end -counts(x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}) = +counts(x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}) = addcounts!(zeros(Int, length(levels)), x, levels) -counts(x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}, wv::AbstractWeights) = +counts(x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}, wv::AbstractWeights) = addcounts!(zeros(eltype(wv), length(levels)), x, levels, wv) counts(x::AbstractArray{<:Integer}, k::Integer) = counts(x, 1:k) counts(x::AbstractArray{<:Integer}, k::Integer, wv::AbstractWeights) = counts(x, 1:k, wv) @@ -99,8 +101,8 @@ Equivalent to `counts(x, levels) / length(x)`. If a vector of weights `wv` is provided, the proportion of weights is computed rather than the proportion of raw counts. """ -proportions(x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}) = counts(x, levels) / length(x) -proportions(x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}, wv::AbstractWeights) = +proportions(x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}) = counts(x, levels) / length(x) +proportions(x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}, wv::AbstractWeights) = counts(x, levels, wv) / sum(wv) """ diff --git a/test/counts.jl b/test/counts.jl index cb16abe3f..f0529eb84 100644 --- a/test/counts.jl +++ b/test/counts.jl @@ -4,6 +4,91 @@ using OffsetArrays n = 5000 +@testset "abstract unit ranges as count levels" begin + x = [1, 1, 2, 3] + w = weights([1.0, 2.0, 3.0, 4.0]) + @test counts(x, Base.OneTo(4)) == [2, 1, 1, 0] + @test counts(x, Base.OneTo(5)) == [2, 1, 1, 0, 0] + @test isempty(counts(x, Base.OneTo(0))) + + lo = typemin(Int) + limits = Base.IdentityUnitRange(lo:lo+1) + wide = BigInt[lo, big(lo) + 1] + @test counts(wide, limits) == [1, 1] + @test counts(wide, limits, weights([2.0, 3.0])) == [2.0, 3.0] + + # Bin positions depend on the values, even for a range with offset axes. + levels = Base.IdentityUnitRange(2:5) + @test counts(x, levels) == [1, 1, 0, 0] + @test addcounts!(ones(Int, 4), x, levels) == [2, 2, 1, 1] + @test counts(x, levels, w) == [3.0, 4.0, 0.0, 0.0] + @test proportions(x, levels) == [0.25, 0.25, 0.0, 0.0] + @test proportions(x, levels, w) ≈ [0.3, 0.4, 0.0, 0.0] +end + +@testset "integer count bin arithmetic" begin + # The 201st bin must not wrap during Int8 subtraction. + levels = Int8(-100):Int8(100) + x = Int8[-101, -100, 100, 101] + w = weights([1.0, 2.0, 3.0, 4.0]) + expected = zeros(Int, 201) + expected[[1, 201]] .= 1 + expected_w = zeros(201) + expected_w[[1, 201]] = [2.0, 3.0] + @test counts(x, levels) == expected + @test counts(x, levels, w) == expected_w + @test addcounts!(ones(Int, 201), x, levels) == expected .+ 1 + @test addcounts!(ones(201), x, levels, w) == expected_w .+ 1 + @test proportions(x, levels) == expected ./ 4 + @test proportions(x, levels, w) ≈ expected_w ./ 10 + @test x == Int8[-101, -100, 100, 101] + @test w == weights([1.0, 2.0, 3.0, 4.0]) + + # Bool is an Integer but does not have a widen method. + bools = Bool[false, true, true] + @test counts(bools, 0:1) == [1, 2] + @test counts(bools, 0:1, weights([2.0, 3.0, 5.0])) == [2.0, 8.0] + @test addcounts!(ones(Int, 2), bools, 0:1) == [2, 3] + @test addcounts!(ones(2), bools, 0:1, weights([2.0, 3.0, 5.0])) == [3.0, 9.0] + + # Use ordinal bins with mixed signedness and abstract eltypes. + mixed = Integer[Int8(-100), UInt64(100), big(0)] + expected[101] = 1 + @test counts(mixed, levels) == expected + @test counts(mixed, levels, weights([2.0, 3.0, 4.0])) == + [i == 1 ? 2.0 : i == 101 ? 4.0 : i == 201 ? 3.0 : 0.0 for i in 1:201] + unsigned = UInt64[0, 100, 101] + expected_u = zeros(Int, 201) + expected_u[[101, 201]] .= 1 + @test counts(unsigned, levels) == expected_u + @test counts(unsigned, levels, weights([2.0, 3.0, 4.0])) == + [i == 101 ? 2.0 : i == 201 ? 3.0 : 0.0 for i in 1:201] + + # Convert both the range bound and input before the index arithmetic. + wider = typemin(Int16):typemax(Int16) + expected_wider = zeros(Int, 65536) + expected_wider[32869] = 1 + @test counts(Int8[100], wider) == expected_wider + @test counts(Int8[100], wider, weights([2.0])) == expected_wider .* 2 + + for T in (UInt64, UInt128) + hi = typemax(T) + limits = (hi - one(T)):hi + values = T[hi - one(T), hi] + @test counts(values, limits) == [1, 1] + @test counts(values, limits, weights([2.0, 3.0])) == [2.0, 3.0] + @test counts(BigInt.(values), limits) == [1, 1] + @test counts(BigInt.(values), limits, weights([2.0, 3.0])) == [2.0, 3.0] + zero_limits = zero(T):one(T) + @test counts(BigInt[0, 1], zero_limits) == [1, 1] + @test counts(BigInt[0, 1], zero_limits, weights([2.0, 3.0])) == [2.0, 3.0] + end + + huge = big(10)^50 + @test counts([huge, huge + 1], huge:huge+1) == [1, 1] + @test counts([huge, huge + 1], huge:huge+1, weights([2.0, 3.0])) == [2.0, 3.0] +end + @testset "1D integer counts" begin x = rand(1:5, n) w = weights(rand(n)) From c1f42ce1ca7b8c032141b9efc99ad59a38a10993 Mon Sep 17 00:00:00 2001 From: Brad <1676751+Sinkleberg@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:28:35 -0500 Subject: [PATCH 2/3] Match count documentation to abstract unit range signature --- docs/src/counts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/counts.md b/docs/src/counts.md index e4498a1d7..2765c8248 100644 --- a/docs/src/counts.md +++ b/docs/src/counts.md @@ -7,7 +7,7 @@ The package provides functions to count the occurrences of distinct values. ```@docs counts proportions -addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::UnitRange{<:Integer}) +addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}) ``` ## Counting over arbitrary distinct values From ffe67af1cb1dd38a2ca9e1305438540300830d1f Mon Sep 17 00:00:00 2001 From: Brad <1676751+Sinkleberg@users.noreply.github.com> Date: Tue, 10 Nov 2026 05:52:25 -0500 Subject: [PATCH 3/3] Preserve generic integer counting with the modular index fast path --- src/counts.jl | 29 +++++++++++----- test/counts.jl | 89 ++++++++++++++++++++++---------------------------- 2 files changed, 60 insertions(+), 58 deletions(-) diff --git a/src/counts.jl b/src/counts.jl index 930dcaa3b..6cf39dc72 100644 --- a/src/counts.jl +++ b/src/counts.jl @@ -13,11 +13,20 @@ else end #### functions for counting a single list of integers (1D) + +_count_modulo(x::Union{Base.BitInteger,BigInt}) = x % UInt +_count_modulo(::Integer) = nothing + +@inline _count_index(x::Union{Base.BitInteger,BigInt}, m0::Integer, u0::UInt) = + ((x % UInt) - u0) % Int + 1 +@inline _count_index(x::Integer, m0::Integer, u0) = + Int(x - (m0 + zero(Int))) + 1 + """ addcounts!(r, x, levels::AbstractUnitRange{<:Integer}, [wv::AbstractWeights]) Add the number of occurrences in `x` of each value in `levels` to an existing -array `r`. The `j`th bin counts the `j`th value in `levels`, independently of the +array `r`. `r[j]` counts the `j`th value in `levels`, independently of the indices of `levels`. If a weighting vector `wv` is specified, the sum of weights is used rather than the @@ -26,16 +35,19 @@ raw counts. function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::AbstractUnitRange{<:Integer}) # add counts of integers from x that fall within levels to r - checkbounds(r, Base.OneTo(length(levels))) + checkbounds(r, 1:length(levels)) m0 = first(levels) m1 = last(levels) - # Promote narrow bounds before subtracting; keep generic integer arithmetic. - lower = m0 + zero(Int) + # `m0 <= xi <= m1` implies `0 <= xi - m0 < length(levels) <= length(r) <= typemax(Int)` + # (by the `checkbounds` call above), so the offset is computed exactly in modular + # `UInt` arithmetic for built-in integers, without per-element `BigInt` allocations. + # Other Integer types retain ordinary arithmetic rather than requiring modular conversion. + u0 = _count_modulo(m0) for xi in x if m0 <= xi <= m1 - r[(xi - lower) + 1] += 1 + r[_count_index(xi, m0, u0)] += 1 end end return r @@ -49,16 +61,17 @@ function addcounts!(r::AbstractArray, x::AbstractArray{<:Integer}, levels::Abstr xv = vec(x) # discard shape because weights() discards shape - checkbounds(r, Base.OneTo(length(levels))) + checkbounds(r, 1:length(levels)) m0 = first(levels) m1 = last(levels) - lower = m0 + zero(Int) + # See the unweighted method: the offset `xi - m0` is exact in modular `UInt` arithmetic. + u0 = _count_modulo(m0) for i in eachindex(xv, wv) xi = xv[i] if m0 <= xi <= m1 - r[(xi - lower) + 1] += wv[i] + r[_count_index(xi, m0, u0)] += wv[i] end end return r diff --git a/test/counts.jl b/test/counts.jl index f0529eb84..3e1683093 100644 --- a/test/counts.jl +++ b/test/counts.jl @@ -11,12 +11,6 @@ n = 5000 @test counts(x, Base.OneTo(5)) == [2, 1, 1, 0, 0] @test isempty(counts(x, Base.OneTo(0))) - lo = typemin(Int) - limits = Base.IdentityUnitRange(lo:lo+1) - wide = BigInt[lo, big(lo) + 1] - @test counts(wide, limits) == [1, 1] - @test counts(wide, limits, weights([2.0, 3.0])) == [2.0, 3.0] - # Bin positions depend on the values, even for a range with offset axes. levels = Base.IdentityUnitRange(2:5) @test counts(x, levels) == [1, 1, 0, 0] @@ -24,10 +18,22 @@ n = 5000 @test counts(x, levels, w) == [3.0, 4.0, 0.0, 0.0] @test proportions(x, levels) == [0.25, 0.25, 0.0, 0.0] @test proportions(x, levels, w) ≈ [0.3, 0.4, 0.0, 0.0] + @test addcounts!(ones(4), x, levels, w) == [4.0, 5.0, 1.0, 1.0] + + # `r` must have linear indices `1:length(levels)`. + @test_throws BoundsError addcounts!(zeros(Int, 3), x, levels) + @test_throws BoundsError addcounts!(zeros(3), x, levels, w) + @test_throws BoundsError addcounts!(OffsetVector(zeros(Int, 4), 2:5), x, levels) + @test_throws BoundsError addcounts!(OffsetVector(zeros(4), 2:5), x, levels, w) + + # Examples from #908. + @test counts(x, Base.OneTo(4), w) == [3.0, 3.0, 4.0, 0.0] + @test proportions(x, Base.OneTo(4)) == [0.5, 0.25, 0.25, 0.0] + @test proportions(x, Base.OneTo(4), w) ≈ [0.3, 0.3, 0.4, 0.0] end @testset "integer count bin arithmetic" begin - # The 201st bin must not wrap during Int8 subtraction. + # The offset must not overflow during subtraction of small integers. levels = Int8(-100):Int8(100) x = Int8[-101, -100, 100, 101] w = weights([1.0, 2.0, 3.0, 4.0]) @@ -37,56 +43,39 @@ end expected_w[[1, 201]] = [2.0, 3.0] @test counts(x, levels) == expected @test counts(x, levels, w) == expected_w - @test addcounts!(ones(Int, 201), x, levels) == expected .+ 1 - @test addcounts!(ones(201), x, levels, w) == expected_w .+ 1 - @test proportions(x, levels) == expected ./ 4 - @test proportions(x, levels, w) ≈ expected_w ./ 10 - @test x == Int8[-101, -100, 100, 101] - @test w == weights([1.0, 2.0, 3.0, 4.0]) - - # Bool is an Integer but does not have a widen method. - bools = Bool[false, true, true] - @test counts(bools, 0:1) == [1, 2] - @test counts(bools, 0:1, weights([2.0, 3.0, 5.0])) == [2.0, 8.0] - @test addcounts!(ones(Int, 2), bools, 0:1) == [2, 3] - @test addcounts!(ones(2), bools, 0:1, weights([2.0, 3.0, 5.0])) == [3.0, 9.0] - - # Use ordinal bins with mixed signedness and abstract eltypes. - mixed = Integer[Int8(-100), UInt64(100), big(0)] - expected[101] = 1 - @test counts(mixed, levels) == expected - @test counts(mixed, levels, weights([2.0, 3.0, 4.0])) == - [i == 1 ? 2.0 : i == 101 ? 4.0 : i == 201 ? 3.0 : 0.0 for i in 1:201] - unsigned = UInt64[0, 100, 101] - expected_u = zeros(Int, 201) - expected_u[[101, 201]] .= 1 - @test counts(unsigned, levels) == expected_u - @test counts(unsigned, levels, weights([2.0, 3.0, 4.0])) == - [i == 101 ? 2.0 : i == 201 ? 3.0 : 0.0 for i in 1:201] - - # Convert both the range bound and input before the index arithmetic. - wider = typemin(Int16):typemax(Int16) - expected_wider = zeros(Int, 65536) - expected_wider[32869] = 1 - @test counts(Int8[100], wider) == expected_wider - @test counts(Int8[100], wider, weights([2.0])) == expected_wider .* 2 + + lo = typemin(Int) + limits = Base.IdentityUnitRange(lo:lo+1) + wide = BigInt[lo, big(lo) + 1] + @test counts(wide, limits) == [1, 1] + @test counts(wide, limits, weights([2.0, 3.0])) == [2.0, 3.0] for T in (UInt64, UInt128) - hi = typemax(T) - limits = (hi - one(T)):hi - values = T[hi - one(T), hi] - @test counts(values, limits) == [1, 1] - @test counts(values, limits, weights([2.0, 3.0])) == [2.0, 3.0] - @test counts(BigInt.(values), limits) == [1, 1] - @test counts(BigInt.(values), limits, weights([2.0, 3.0])) == [2.0, 3.0] zero_limits = zero(T):one(T) @test counts(BigInt[0, 1], zero_limits) == [1, 1] @test counts(BigInt[0, 1], zero_limits, weights([2.0, 3.0])) == [2.0, 3.0] end - huge = big(10)^50 - @test counts([huge, huge + 1], huge:huge+1) == [1, 1] - @test counts([huge, huge + 1], huge:huge+1, weights([2.0, 3.0])) == [2.0, 3.0] +end + +struct PromotingCountInteger <: Integer + value::BigInt +end +Base.convert(::Type{BigInt}, x::PromotingCountInteger) = x.value +Base.BigInt(x::PromotingCountInteger) = x.value +Base.convert(::Type{UInt}, x::PromotingCountInteger) = UInt(x.value) +Base.promote_rule(::Type{PromotingCountInteger}, ::Type{T}) where {T<:Integer} = BigInt + +@testset "integer count bin arithmetic with generic promotion" begin + # Ordinary arithmetic works without a specialized modular conversion. + x = PromotingCountInteger.(BigInt[-1, 1]) + @test counts(x, -1:1) == [1, 0, 1] + @test counts(x, -1:1, weights([2.0, 3.0])) == [2.0, 0.0, 3.0] + + lo = big(10)^50 + wide = PromotingCountInteger.([lo, lo + 1]) + @test counts(wide, lo:lo+1) == [1, 1] + @test counts(wide, lo:lo+1, weights([2.0, 3.0])) == [2.0, 3.0] end @testset "1D integer counts" begin