Skip to content

Accept abstract unit ranges as count levels - #1021

Open
Sinkleberg wants to merge 3 commits into
JuliaStats:masterfrom
Sinkleberg:codex/count-abstract-unit-ranges
Open

Sinkleberg wants to merge 3 commits into
JuliaStats:masterfrom
Sinkleberg:codex/count-abstract-unit-ranges

Conversation

@Sinkleberg

Copy link
Copy Markdown
Contributor

Fixes #908.

counts([1, 1, 2, 3], Base.OneTo(4)) now returns [2, 1, 1, 0]. Weighted counts and proportions use the same bin order.

I used Codex for the patch and tests. The StatsBase suite passed on Julia 1.10.13.

@andreasnoack

Copy link
Copy Markdown
Member

The signature in the docs would have to be adjusted as well. That's why the docs build fails.

Comment thread src/counts.jl
Comment on lines 19 to +21
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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe make the link to r explicit?

Suggested change
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`.
Add the number of occurrences in `x` of each value in `levels` to an existing
array `r`. `r[j]` counts the `j`th value in `levels`, independently of the
indices of `levels`.

Comment thread src/counts.jl Outdated
# add counts of integers from x that fall within levels to r

checkbounds(r, axes(levels)...)
checkbounds(r, Base.OneTo(length(levels)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct (a single index checks the linear indices 1:length(levels) of r, exactly the indices written below), but IMO 1:length(levels) reads more clearly:

Suggested change
checkbounds(r, Base.OneTo(length(levels)))
checkbounds(r, 1:length(levels))

Comment thread src/counts.jl Outdated
Comment on lines 33 to 40
# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since m0 <= xi <= m1, the offset xi - m0 lies in 0:(length(levels) - 1) and fits in an Int. So we can compute it modulo 2^N and avoid both the wrap-around of m0 - 1 and the overflow of xi - m0 for narrow types, without relying on mixed-type promotion. The index is then always an Int.

In a quick benchmark it gives identical results to this PR on all the edge cases in the new tests (and a few more), with the same speed for Int/Int8 input and ~20x faster for BigInt input (no BigInt index allocated per element).

Caveat: for custom Integer types without a specialized rem(::T, ::Type{UInt}), Base falls back to convert, which throws for negative values.

Suggested change
# 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
# `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: no overflow for small integer types and no allocations for `BigInt`.
u0 = m0 % UInt
for xi in x
if m0 <= xi <= m1
r[((xi % UInt) - u0) % Int + 1] += 1
end
end

Comment thread src/counts.jl Outdated
Comment on lines 52 to 63
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here:

Suggested change
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
checkbounds(r, 1:length(levels))
m0 = first(levels)
m1 = last(levels)
# See the unweighted method: the offset `xi - m0` is exact in modular `UInt` arithmetic.
u0 = m0 % UInt
for i in eachindex(xv, wv)
xi = xv[i]
if m0 <= xi <= m1
r[((xi % UInt) - u0) % Int + 1] += wv[i]
end
end

Comment thread test/counts.jl
@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]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for the checkbounds error path, for weighted addcounts! with a non-zero r, and for the exact examples from #908?

Suggested change
@test proportions(x, levels, w) ≈ [0.3, 0.4, 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)
# 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]

Comment thread test/counts.jl Outdated
end

@testset "integer count bin arithmetic" begin
# The 201st bin must not wrap during Int8 subtraction.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this testset already passes on master: 28 of its 32 tests pass there. In particular the Int8 case worked before, since m0 - 1 promotes to Int. The actual regression is first(levels) - 1 wrapping when x has a wider eltype, e.g. counts(BigInt[0, 1], UInt(0):UInt(1)) throws InexactError on master. Could you reduce this testset to those regressions (BigInt input with UInt64/UInt128 ranges starting at 0, BigInt input with a range starting at typemin(Int)) plus a single small-integer case guarding against overflow in xi - first(levels)?

Comment thread test/counts.jl Outdated
Comment on lines +44 to +45
@test x == Int8[-101, -100, 100, 101]
@test w == weights([1.0, 2.0, 3.0, 4.0])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These non-mutation checks are unrelated to this change, so I'd remove them.

Comment thread test/counts.jl Outdated
@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is outdated: the implementation does not use widen.

Comment thread test/counts.jl Outdated

# Use ordinal bins with mixed signedness and abstract eltypes.
mixed = Integer[Int8(-100), UInt64(100), big(0)]
expected[101] = 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing expected halfway through the testset makes the following tests hard to follow. Could you use a separate expected vector?

Comment thread test/counts.jl Outdated
@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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is inaccurate: the input is not converted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

counts should accept AbstractUnitRange as levels argument

3 participants