Source code for psp.clobe.combinatorics

"""Counting ordinal patterns: Stirling numbers, weak orderings and semiorders.

Counts are Python integers, so they are exact however large; the R package warns past 2^53."""

from __future__ import annotations

import math
from functools import cache
from typing import Any

__all__ = ["stirling2", "fubini", "semiorders"]


def _check_count(value: Any, name: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int | float) or value < 0:
        raise ValueError(f"'{name}' must be a non-negative whole number, got {value!r}")
    if isinstance(value, float) and not value.is_integer():
        raise ValueError(f"'{name}' must be a non-negative whole number, got {value!r}")
    return int(value)


@cache
def _stirling2_row(n: int) -> tuple[int, ...]:
    """``S(n, j)`` for ``j = 0, ..., n``, by ``S(i, j) = j S(i-1, j) + S(i-1, j-1)``."""
    if n == 0:
        return (1,)
    previous = _stirling2_row(n - 1) + (0,)
    return (0,) + tuple(j * previous[j] + previous[j - 1] for j in range(1, n + 1))


[docs] def stirling2(n: int, k: int) -> int: """Stirling number of the second kind: ways to split ``n`` items into ``k`` non-empty groups. >>> stirling2(5, 2), stirling2(10, 3), stirling2(3, 5) (15, 9330, 0) """ n = _check_count(n, "n") k = _check_count(k, "k") return 0 if k > n else _stirling2_row(n)[k]
[docs] def fubini(k: int, levels: int | None = None) -> int: """The number of weak orderings of ``k`` items (the Fubini or ordered Bell number). Parameters ---------- k Number of items (stimuli). levels At most this many tied groups, e.g. the number of distinct values a measure can take; ``None`` for no limit. Examples -------- >>> [fubini(k) for k in range(6)], fubini(4, levels=3) ([1, 1, 3, 13, 75, 541], 51) """ k = _check_count(k, "k") most = k if levels is None else min(k, _check_count(levels, "levels")) row = _stirling2_row(k) # weak orderings with j tied groups: the ways to split k items into j groups, # times the ways to order the groups return sum(math.factorial(j) * row[j] for j in range(most + 1))
[docs] def semiorders(k: int) -> int: """The number of labelled semiorders of ``k`` items: the universal set of :func:`imac`. The exponential generating function is ``C(1 - exp(-x))``, where ``C`` is that of the Catalan numbers, which gives an alternating sum over Stirling numbers. >>> [semiorders(k) for k in range(7)] [1, 1, 3, 19, 183, 2371, 38703] """ k = _check_count(k, "k") row = _stirling2_row(k) return sum( (-1) ** (k - j) * (math.comb(2 * j, j) // (j + 1)) * math.factorial(j) * row[j] for j in range(k + 1) )