Skip to content

fix: compute binomial and multinomial coefficients exactly#413

Open
agene0001 wants to merge 1 commit into
statrs-dev:mainfrom
agene0001:feat/exact-binomial-multinomial
Open

fix: compute binomial and multinomial coefficients exactly#413
agene0001 wants to merge 1 commit into
statrs-dev:mainfrom
agene0001:feat/exact-binomial-multinomial

Conversation

@agene0001

Copy link
Copy Markdown

binomial and checked_multinomial computed floor(0.5 + exp(ln n! − ln k! − ln (n−k)!)), which returns the wrong integer well before f64 runs out of precision:

C(50, 25)  126410606437750    exact 126410606437752    (off by 2)
C(60, 30)  ...4581564863664   exact ...4581564861424   (off by 2240)
C(67, 33)  ...0737620170752   exact ...0737620288370   (off by 116736 = 57 ulp)

C(50, 25) is exactly representable in f64, so this is not a representation limit — the exp/ln round trip just isn't integer-accurate.

Uses C(n, i+1) = C(n, i)·(n−i)/(i+1) in u128; the division is exact at every step (the intermediate is itself a binomial coefficient), so the whole computation stays in integer arithmetic, and u128 → f64 is correctly rounded, so results past 2^53 are the nearest double. Coefficients overflowing u128 fall back to the log form. checked_multinomial shares the kernel via n!/(n₁!…nₖ!) = Π C(sᵢ, nᵢ) over prefix sums.

Tested by brute force: every C(n, k) for n ≤ 170 round-trips exactly against a u128 reference, every two-part multinomial equals the corresponding binomial, and the fallback agrees with ln_binomial to 1e-11.

Cost: the worst exact case C(67, 33) is 190 ns vs ~25 ns before; small-k cases are unaffected (C(1000, 3): 8 ns).

🤖 Generated with Claude Code

Both were computed as `floor(0.5 + exp(ln n! - ln k! - ln (n-k)!))`, which
returns the wrong integer well before f64 runs out of precision:

    C(50, 25)   126410606437750   exact 126410606437752   (off by 2)
    C(60, 30)   ...863664         exact ...861424         (off by 2240)
    C(67, 33)   ...170752         exact ...288370         (off by 116736, 57 ulp)

`C(50, 25)` is exactly representable in f64, so this is not a representation
limit.

Uses the recurrence `C(n, i+1) = C(n, i) * (n - i) / (i + 1)` in `u128`. The
division is exact at every step because `C(n, i+1)` is an integer, so the whole
computation stays in integer arithmetic; `u128 -> f64` is correctly rounded, so
the result is the nearest double even past 2^53. Only coefficients that overflow
`u128` fall back to logs.

`checked_multinomial` gets the same treatment via
`n! / (n1! ... nk!) == prod_i C(s_i, n_i)` with `s_i` the running prefix sums,
sharing the same kernel.

Tested by brute force: every `C(n, k)` for `n <= 170` round-trips exactly
against a `u128` reference, and every two-part multinomial equals the
corresponding binomial.

Cost: the exact path for `C(67, 33)` is 190 ns against ~25 ns before; small-k
cases are unaffected (`C(1000, 3)` is 8 ns).
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.

1 participant