feat: complete statistics trait coverage (Min/Max/Median/Mode) - #424
Draft
agene0001 wants to merge 3 commits into
Draft
feat: complete statistics trait coverage (Min/Max/Median/Mode)#424agene0001 wants to merge 3 commits into
agene0001 wants to merge 3 commits into
Conversation
Closes statrs-dev#276. Multinomial had neither Min nor Max, which is what prompted the issue; Dirichlet was missing both as well. MultivariateNormal and MultivariateStudent already had them, so these were the only two gaps among the multivariate distributions. Typed to match each distribution's support, following what the univariate distributions already do: Multinomial's Discrete impl is over OVector<u64, D>, and every univariate discrete distribution uses its integer support type for these traits (Min<u64> for Binomial, Min<i64> for DiscreteUniform), so Multinomial gets Min/Max<OVector<u64, D>>. Dirichlet is continuous and gets OVector<f64, D>, agreeing with Beta, of which it is the multivariate generalization. Multinomial: min = 0, max = n, in every coordinate Dirichlet: min = 0, max = 1, in every coordinate The issue raised a doubt worth answering rather than papering over -- that "Max and Min for multivariate distributions are not clear". A partial order admits no unique greatest element, so these can only be componentwise bounds, and the vector that results need not be a point of the support: Multinomial's max sums to k*n and Dirichlet's to k, while their supports require sums of n and 1 respectively. Both are the corner of the smallest axis-aligned box containing the support, and each individual bound is attained (or approached, for Dirichlet) one coordinate at a time. That distinction is documented at each impl, per the second bullet of the issue's actionable list, and pinned by tests rather than only asserted in prose: Multinomial's pmf is checked to be zero at both bounds and nonzero at the per-coordinate extremes, and n == 0 is covered as the sole case where the two bounds coincide and are an outcome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows the two actionable bullets in the issue.
Newly implemented, all where the value is unambiguous:
Mode Dirichlet, Categorical
Median Beta, Chi, Erlang, FisherSnedecor, Gamma, InverseGamma,
Hypergeometric, NegativeBinomial
Min and Max are now implemented for all 33 distributions, and Median and
Mode for every univariate one.
Dirichlet's mode mirrors Beta, of which it is the generalization: the
formula (α_i - 1) / (α_0 - K) reduces to (α - 1) / (α + β - 2) at K == 2,
and it returns None unless every α_i > 1, exactly as Beta returns None
unless both shapes exceed 1. Mode<Option<_>> is the crate's norm, used by
26 of the 27 univariate impls, so this needed no new signature. A test
verifies the formula really maximizes the density by perturbing mass
between coordinates, which stays on the simplex.
Categorical had Median but not Mode, though the mode is the simpler of the
two. Ties are genuine -- a fair die has none unique -- so this returns the
lowest maximizing index, the only choice independent of iteration order.
The eight new Medians are inverse_cdf(0.5). None has a closed form, and the
trait already admits both approximations (Poisson uses
floor(λ + 1/3 - 0.02/λ), Binomial floor(n*p)) and cdf inversion (Categorical
was already inverse_cdf(0.5)). Each impl documents that it is a root-find
rather than the O(1) arithmetic of the closed-form impls; Beta and
FisherSnedecor reduce to inv_beta_reg, the rest to a numerical search.
Validated three ways in tests/median_consistency.rs, since self-consistency
alone would hide an error shared with the cdf:
- cdf(median()) == 0.5 across 45 parameter sets
- against mpmath's independent incomplete beta and gamma at 50 digits:
worst |cdf(median) - 0.5| = 2.5e-13, typically ~1e-15
- against closed forms where the distributions coincide -- Gamma(1, r) and
Erlang(1, r) against Exp(r)'s ln(2)/r, Chi(1) against the normal 0.75
quantile, Beta(1,1) against 0.5, InverseGamma(1,1) against 1/ln(2)
Agreement is ~1e-13 relative, not to the last bit; a root-find is not
correctly rounded the way a closed form is, and the tests say so.
Also makes Dirichlet::pdf return 0.0 off the simplex instead of panicking,
with ln_pdf returning NEG_INFINITY, matching Multinomial::pmf and every
univariate distribution. The set of inputs classified as out-of-support is
unchanged; only the response is. The dimension-mismatch panic is kept, as
that is a usage error with no density to return. This converts four
#[should_panic] tests into behavioural ones and is called out for the
maintainer in the PR description.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mpirical The three remaining cases from the statrs-dev#276 audit where the value is unambiguous. Both multivariate types are symmetric about their location parameter. A multivariate distribution has several competing notions of median -- the vector of marginal medians, the geometric (spatial) median, the halfspace median -- and they disagree in general, but for a centrally symmetric distribution they all coincide at the centre, along with the mean and mode. So median == mu is the only value any definition could give, which is what makes these safe to implement. For MultivariateStudent this is defined for every freedom, including v <= 1 where mean() is None because the first moment does not converge; a median needs no moments to exist. A test covers that case. Empirical gets the sample median: the middle order statistic for an odd count, the mean of the two middles for an even one. The latter is a convention rather than a derivation -- it is what NumPy and R default to, and is the only value equidistant from both -- so it is documented as such, including that the result need not then be an observed value. Repeated observations count with their multiplicity, which is the part of the BTreeMap walk most likely to be wrong, so it is checked against a naive sort-and-index reference over 156 generated data sets plus hand-written edge cases. Panics on empty data, matching Empirical's existing Min and Max. Deliberately still unimplemented, with reasons: - Median for Multinomial and Dirichlet. The vector of marginal medians is not the multivariate median and, worse, is not even in the support: Dirichlet(1,1,1) has Beta(1,2) marginals with median 1 - sqrt(1/2), and three of those sum to 0.879 rather than 1. Publishing that as "the median" would be wrong, not merely approximate. The geometric median has no closed form for either. - Mode for Multinomial. There is no closed form; the exact mode is the Jefferson/D'Hondt divisor apportionment of n among the p_i, found by a threshold search. The Mode trait's own documentation states that it "specifies that an object has a closed form solution for its mode(s)", which every existing impl honours, so this one belongs behind a maintainer decision rather than in this PR. - Mode for Empirical. Every value of continuous sample data occurs once, so everything ties and the answer is arbitrary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
agene0001
marked this pull request as draft
July 27, 2026 08:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #276. Independent of my other open PRs — branches directly off
main.Addresses both actionable bullets from your comment: implement the traits whose
value is unambiguous, and document the ambiguity where it isn't.
Dirichlet::pdfno longer panics for input off the simplex — it returns0.0(andln_pdfreturnsNEG_INFINITY). This is the one thing here thatisn't purely additive, so please look at it first.
Why it's in scope:
Multinomial::pmfalready returns0for coordinates thatdon't sum to
n, and every univariate distribution returns0outside itssupport.
Dirichletwas the sole holdout, and I found it by writingassert_eq!(d.pdf(&d.min()), 0.0)for theMin/Maxtests below and watchingit panic.
What exactly changed:
(f64::MIN_POSITIVE..1.0)element predicate, same1e-4sum tolerance. Onlythe response changed, from
panic!to0.0.meaningful density to return, and
Multinomialpanics on it too.#[should_panic]tests became behavioural tests(
test_pdf_bad_input_range,test_pdf_bad_input_sum, and the twoln_pdfequivalents →
test_pdf_out_of_support_is_zero,test_ln_pdf_out_of_support_is_neg_infinity). Those tests encoded the oldcontract, so their rewrite is the honest record of the break.
If you'd rather not take this, say so and I'll drop it to a separate PR — the
rest of the branch doesn't depend on it, beyond two assertions in the
Min/Maxtest.Coverage
MinMaxMedianModeMultinomialDirichletMultivariateNormalMultivariateStudentEmpiricalCategoricalBetaChiErlangFisherSnedecorGammaInverseGammaHypergeometricNegativeBinomial⊘= deliberately not implemented; reasons at the bottom.MinandMaxare now implemented for all 33 distributions.MedianandModeare complete except for the four⊘cells.Min/Maxfor the multivariate typesTyped to each distribution's support, following the univariate precedent:
Multinomial'sDiscreteimpl is overOVector<u64, D>, and every univariatediscrete distribution uses its integer support type (
Min<u64>forBinomial,Min<i64>forDiscreteUniform).DirichletgetsOVector<f64, D>, agreeingwith
Beta.On your "
MaxandMinfor multivariate distributions are not clear". Apartial order admits no unique greatest element, so these can only be
componentwise bounds — and the resulting vector need not be in the support:
Multinomial::maxsums tok*nwhere an outcome must sum ton;Dirichlet::maxsums tokwhere a sample lies on the unit simplex. Both arethe corner of the smallest axis-aligned box containing the support. Each
individual bound is attained one coordinate at a time. Documented at each
impland pinned by tests:pmfis zero at both bound vectors, nonzero at theper-coordinate extremes, and
n == 0is covered as the sole case where the twobounds coincide and are a real outcome.
Medianfor the multivariate typesMultivariateNormalandMultivariateStudentare symmetric about theirlocation. The competing notions of multivariate median — marginal, geometric
(spatial), halfspace — disagree in general, but at a centre of symmetry they all
coincide, along with the mean and the mode. So
median == μis the only valueany definition could give, which is what makes these safe.
For
MultivariateStudentthis holds for everyν, includingν <= 1wheremean()isNonebecause the first moment doesn't converge. A median needs nomoments; there's a test for that case.
MedianforEmpiricalThe sample median: middle order statistic for odd counts, mean of the two
middles for even ones. That second half is a convention, not a derivation — it
matches NumPy's and R's defaults and is the only value equidistant from both —
so it's documented as such, including that the result need not be an observed
value. Multiplicity is respected, so
[1, 1, 1, 2]has median1, not1.5.Multiplicity is the part of the
BTreeMapwalk most likely to be wrong, so it'schecked against a naive sort-and-index reference across 156 generated data sets
plus hand-written edge cases. Panics on empty data, matching
Empirical'sexisting
Min/Max.ModeforDirichletandCategoricalDirichletmirrorsBeta, of which it is the generalization:(α_i - 1) / (α_0 - K)reduces to(α - 1) / (α + β - 2)atK == 2, and itreturns
Noneunless everyα_i > 1exactly asBetareturnsNoneunlessboth shapes exceed 1.
Mode<Option<_>>is the crate's norm — 26 of 27univariate impls — so no new signature was needed. Unlike
min/maxthe modeis a support point: its coordinates sum to
(α_0 - K)/(α_0 - K) = 1. A testconfirms the formula genuinely maximizes the density, by shifting mass between
coordinate pairs (which keeps the point on the simplex) and checking the density
never increases.
CategoricalhadMedianbut notMode, though the mode is the simpler of thetwo. Ties are real — a fair die has no unique mode — so this returns the
lowest maximizing index, the only choice that doesn't depend on iteration
order.
The eight new
Medianimpls for univariate distributionsAll are
inverse_cdf(0.5). None of these has a closed-form median, and thetrait already admits both approximations and cdf inversion:
Poissonusesfloor(λ + 1/3 - 0.02/λ),Binomialusesfloor(n*p), andCategoricalwasalready
inverse_cdf(0.5). Note also thatMedian's trait docs make noclosed-form claim, whereas
Mode's explicitly do — see below.The cost is not uniform, and each impl says so.
BetaandFisherSnedecorreduce to
inv_beta_reg;Gamma,Erlang,ChiandInverseGammato anumerical search;
HypergeometricandNegativeBinomialto a discretebisection returning an exact integer. So
median()is O(many cdf evaluations)for these where it is O(1) elsewhere, with nothing in the signature to say so.
That asymmetry is the main reason to review this section — happy to drop any
subset.
Validated three ways in
tests/median_consistency.rs, because self-consistencyalone would hide an error shared with the cdf:
cdf(median()) == 0.5across 45 parameter sets, including extremes likeBeta(1e3, 1e3),Beta(0.1, 0.1),Gamma(0.1, 1)andHypergeometric(1000, 500, 100).rather than statrs's: worst
|cdf(median) - 0.5| = 2.5e-13, typically~1e-15.Gamma(1, r)andErlang(1, r)againstExp(r)'sln(2)/r,Chi(1)against the normal0.75quantile,Beta(1,1)against0.5,InverseGamma(1,1)against1/ln(2). Plus the known bracket[shape - 1/3, shape]forGamma, checkedindependently of the cdf.
Agreement is ~1e-13 relative, not to the last bit — a root-find isn't
correctly rounded the way a closed form is. The tests assert that tolerance
explicitly rather than pretending otherwise.
Deliberately not implemented
MedianforMultinomialandDirichlet. The vector of marginal mediansis not the multivariate median and, worse, is not even in the support:
Dirichlet(1,1,1)hasBeta(1,2)marginals with median1 - sqrt(1/2), andthree of those sum to
0.879, not1. Publishing that as "the median" wouldbe wrong, not merely approximate. The geometric median has no closed form
for either.
ModeforMultinomial. There is no closed form. The exact mode is theJefferson/D'Hondt divisor apportionment of
namong thep_i, found by athreshold search — and the
Modetrait's own docs say it "specifies that anobject has a closed form solution for its mode(s)", which every existing impl
honours. Implementing it would be the first violation of that stated contract,
so it belongs behind your decision rather than in this PR. Happy to add it,
either as an inherent method or with the trait doc amended, whichever you
prefer.
ModeforEmpirical. Every value of continuous sample data occurs once,so everything ties and the answer is arbitrary.
Also noticed, not touched
multinomial.rs:371has a commented-outimpl Skewness<Vec<f64>> for Multinomialreferencing aSkewnesstrait that doesn't exist instatistics::traits. Dead code, worth deleting whenever convenient.Verification
cargo fmt --check,cargo clippy --all-features --all-targetsclean,RUSTDOCFLAGS=-D warnings cargo doc --all-featuresclean,cargo hack check --feature-powerset --no-dev-depsclean,cargo hack check --rust-version --lockedclean againstCargo.lock.MSRV, and the full suite across--all-features, default,--no-default-features, and--no-default-features --features rand.🤖 Generated with Claude Code