Algorithm Search⌘K

Zeilberger's algorithm in Python

Computes a recurrence satisfied by a definite hypergeometric sum via creative telescoping.

File
zeilberger_algorithm.py, 343 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
zeilberger_algorithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zeilberger-algorithm/. The authors are listed in prov/8S5NXW.jsonl.
"""Zeilberger's algorithm (fast creative telescoping) over Q.

The summand is a proper hypergeometric term

    F(n, k) = poly(n, k) * x^k * prod_i (a_i n + b_i k + c_i)!^(e_i),

given as poly = {(i, j): c} for c n^i k^j, a rational x != 0 and a list of
(a_i, b_i, c_i, e_i) with integers a_i, b_i, c_i and e_i = +1 or -1. The
algorithm finds the least J >= 0 and polynomials a_0(n), ..., a_J(n), not all
zero, and a rational function R(n, k) with

    sum_j a_j(n) F(n + j, k) = G(n, k + 1) - G(n, k),   G = R F.

Summing over k with natural boundaries gives sum_j a_j(n) S(n + j) = 0 for
S(n) = sum_k F(n, k).

Polynomials in n are lists of Fractions, low degree first. Polynomials in k
with coefficients in Q[n] are lists of such lists. Elements of Q(n) are
pairs (numerator, monic denominator).

Cost model: operations in the field Q(n). For each trial order j <= J the
method solves one linear system with m <= D + J + 2 unknowns, where D is
Gosper's degree bound, by fraction-free Gaussian elimination over Q[n]:
O(m^3) ring operations for each order, O(J m^3) in total (a proved bound). Over Q the cost also
depends on the degrees in n of the entries; it is not bounded here.
Termination for every proper hypergeometric term is the theorem of Wilf and
Zeilberger; max_order only stops the search early.
"""

from __future__ import annotations

from fractions import Fraction
from math import gcd, lcm

# ---------- polynomials in n over Q ----------


def trim(p):
    p = list(p)
    while p and p[-1] == 0:
        p.pop()
    return p


def nadd(a, b):
    r = [Fraction(0)] * max(len(a), len(b))
    for i, x in enumerate(a):
        r[i] += x
    for i, x in enumerate(b):
        r[i] += x
    return trim(r)


def nneg(a):
    return [-x for x in a]


def nmul(a, b):
    if not a or not b:
        return []
    r = [Fraction(0)] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        if x:
            for j, y in enumerate(b):
                r[i + j] += x * y
    return trim(r)


def ndivmod(a, b):
    a = trim(a)
    q = [Fraction(0)] * max(len(a) - len(b) + 1, 0)
    while a and len(a) >= len(b):
        c = a[-1] / b[-1]
        s = len(a) - len(b)
        q[s] = c
        for i, y in enumerate(b):
            a[i + s] -= c * y
        a = trim(a)
    return trim(q), a


def ngcd(a, b):
    a, b = trim(a), trim(b)
    while b:
        a, b = b, ndivmod(a, b)[1]
    return [x / a[-1] for x in a] if a else []


def nshift(a, s):
    """a(n + s) by Horner's rule."""
    r = []
    for c in reversed(a):
        r = nadd(nmul(r, [Fraction(s), Fraction(1)]), [c])
    return r


# ---------- Q(n) ----------

ONE = ([Fraction(1)], [Fraction(1)])
ZERO = ([], [Fraction(1)])


def rf(num, den=None):
    den = [Fraction(1)] if den is None else trim(den)
    num = trim(num)
    if not num:
        return ZERO
    g = ngcd(num, den)
    num, den = ndivmod(num, g)[0], ndivmod(den, g)[0]
    lc = den[-1]
    return [x / lc for x in num], [x / lc for x in den]


# ---------- polynomials in k over Q[n] ----------


def kadd(a, b):
    r = [[] for _ in range(max(len(a), len(b)))]
    for i, x in enumerate(a):
        r[i] = nadd(r[i], x)
    for i, x in enumerate(b):
        r[i] = nadd(r[i], x)
    while r and not r[-1]:
        r.pop()
    return r


def kmul(a, b, *more):
    if more:
        return kmul(kmul(a, b), *more)
    if not a or not b:
        return []
    r = [[] for _ in range(len(a) + len(b) - 1)]
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            r[i + j] = nadd(r[i + j], nmul(x, y))
    while r and not r[-1]:
        r.pop()
    return r


def kshift(p, s):
    """p(k + s) for an integer or rational s."""
    r = []
    for c in reversed(p):
        r = kadd(kmul(r, [[Fraction(s)], [Fraction(1)]]), [c])
    return r


def linear(u):
    """The monic linear polynomial k + u(n)."""
    return [trim(u), [Fraction(1)]]


def from_dict(poly, j):
    """poly(n + j, k) as a polynomial in k over Q[n]."""
    deg_k = max((e[1] for e in poly), default=-1)
    out = [[] for _ in range(deg_k + 1)]
    for (i, kk), c in poly.items():
        out[kk] = nadd(out[kk], nshift([Fraction(0)] * i + [Fraction(c)], j))
    while out and not out[-1]:
        out.pop()
    return out


# ---------- the term and its Gosper form ----------


def form(a, b, c):
    """a n + b k + c as (constant, monic part u) with a n + b k + c = b (k + u)."""
    return Fraction(b), [Fraction(c, b), Fraction(a, b)]


def setup(poly, x, facts, J):
    """phi_j(k) with F(n + j, k) = H(k) phi_j(k), and H(k+1)/H(k) = q(k)/r(k+1)."""
    phis = [from_dict(poly, j) for j in range(J + 1)]
    num, den, cq, cr = [], [], Fraction(x), Fraction(1)
    for a, b, c, e in facts:
        lo, hi = min(0, a * J), max(0, a * J)
        c0 = c + (lo if e > 0 else hi)  # H holds (a n + b k + c0)!^e
        for j in range(J + 1):
            ls = range(c0 + 1, c + a * j + 1) if e > 0 else range(c + a * j + 1, c0 + 1)
            for l in ls:  # one factor a n + b k + l of the polynomial phi_j
                phis[j] = kmul(phis[j], [[Fraction(l), Fraction(a)], [Fraction(b)]])
        if b == 0:
            continue
        shifts = range(1, b + 1) if b > 0 else range(0, b, -1)
        up = (e > 0) == (b > 0)  # the ratio factors go to the numerator
        for l in shifts:
            const, u = form(a, b, c0 + l)
            if up:
                num.append(u)
                cq *= const
            else:
                den.append(u)
                cr *= const
    return phis, cq, num, cr, den


def gosper_form(num, den):
    """Return q, r, c (monic linear factors) with q(k)/r(k+1) = num/den * c(k)/c(k+1).

    Here num/den = prod (k + u) / prod (k + w); on exit no factor k + u of q
    and k + w of r have u - w a positive integer.
    """
    q = [list(u) for u in num]
    r = [nadd(w, [Fraction(-1)]) for w in den]  # r(k) = den(k - 1)
    cfac = []
    changed = True
    while changed:
        changed = False
        for iq, u in enumerate(q):
            for ir, w in enumerate(r):
                diff = nadd(u, nneg(w))
                h = diff[0] if diff else Fraction(0)
                if len(diff) <= 1 and h.denominator == 1 and h >= 1:
                    cfac += [nadd(u, [Fraction(-i)]) for i in range(1, int(h))]
                    del q[iq], r[ir]
                    changed = True
                    break
            if changed:
                break
    return q, r, cfac


def kprod(factors, const=Fraction(1)):
    p = [[Fraction(const)]]
    for u in factors:
        p = kmul(p, linear(u))
    return p


def degree_bound(q, r, dP):
    """Gosper's bound on deg f for q f(k+1) - r f(k) = P with deg P = dP."""
    dq, dr = len(q) - 1, len(r) - 1
    if dq != dr or q[-1] != r[-1]:
        return dP - max(dq, dr)
    bound = dP - dq + 1
    if dq >= 1:
        s = rf(nadd(r[dq - 1], nneg(q[dq - 1])), q[dq])
        if len(s[0]) <= 1 and s[1] == [1]:
            v = s[0][0] if s[0] else Fraction(0)
            if v.denominator == 1 and v >= 0:
                bound = max(bound, int(v))
    return bound


def primitive(row):
    """Divide a row of polynomials in n by the gcd of its entries."""
    g = []
    for p in row:
        if p:
            g = ngcd(g, p) if g else ngcd(p, [])
    return [ndivmod(p, g)[0] for p in row] if g else row


def nullspace(rows, ncols):
    """A basis of the kernel over Q(n) of a matrix with entries in Q[n].

    Fraction-free Gauss-Jordan elimination: row_i <- p row_i - m row_r,
    then the row is divided by its content, so all entries stay in Q[n].
    """
    M = [primitive([trim(e) for e in row]) for row in rows]
    piv = []
    rr = 0
    for c in range(ncols):
        p = next((i for i in range(rr, len(M)) if M[i][c]), None)
        if p is None:
            continue
        M[rr], M[p] = M[p], M[rr]
        pv = M[rr][c]
        for i in range(len(M)):
            if i != rr and M[i][c]:
                m = M[i][c]
                M[i] = primitive([nadd(nmul(pv, x), nneg(nmul(m, y)))
                                  for x, y in zip(M[i], M[rr])])
        piv.append(c)
        rr += 1
    basis = []
    for free in (c for c in range(ncols) if c not in piv):
        v = [ZERO] * ncols
        v[free] = ONE
        for i, c in enumerate(piv):  # M[i][c] x_c + M[i][free] x_free = 0
            v[c] = rf(nneg(M[i][free]), M[i][c])
        basis.append(v)
    return basis


def zeilberger(poly, x, facts, max_order=4):
    """Return (J, [a_0, ..., a_J], R_num, R_den) or None if no order <= max_order works.

    R = R_num / R_den, both {(i, j): Fraction} for n^i k^j.
    """
    if not any(poly.values()):
        return 0, [[Fraction(1)]], {}, {(0, 0): Fraction(1)}  # F = 0, G = 0
    for J in range(max_order + 1):                              # @line L1
        phis, cq, num, cr, den = setup(poly, x, facts, J)       # @line L2
        qf, rf_, cfac = gosper_form(num, den)                   # @line L3
        q, r, cpol = kprod(qf, cq), kprod(rf_, cr), kprod(cfac)
        cols_a = [kmul(phi, cpol) for phi in phis]              # @line L4
        dP = max(len(p) - 1 for p in cols_a)
        D = degree_bound(q, r, dP)                              # @line L5
        D = max(D, -1)  # D = -1: only f = 0 is possible
        cols = [[nneg(c) for c in col] for col in cols_a]
        for i in range(D + 1):                                  # @line L6
            ki = [[] for _ in range(i)] + [[Fraction(1)]]
            cols.append(kadd(kmul(q, kshift(ki, 1)), [nneg(c) for c in kmul(r, ki)]))
        height = max(len(c) for c in cols)
        rows = [[c[e] if e < len(c) else [] for c in cols] for e in range(height)]
        for v in nullspace(rows, len(cols)):                    # @line L7
            if any(t[0] for t in v[:J + 1]):
                break
        else:
            continue
        # clear denominators: a_j and f_i become polynomials in n
        L = [Fraction(1)]
        for t in v:
            L = ndivmod(nmul(L, t[1]), ngcd(L, t[1]))[0]
        vals = [ndivmod(nmul(t[0], L), t[1])[0] for t in v]
        g = []
        for p in vals[:J + 1]:
            g = ngcd(g, p) if g else ngcd(p, [])
        a = [ndivmod(p, g)[0] for p in vals[:J + 1]]
        f = vals[J + 1:]  # f / g is the true f; g goes to the denominator of R
        # scale by a rational: integer coefficients, content 1, LC(a_J) > 0
        coeffs = [c for p in a for c in p]
        den = lcm(*[c.denominator for c in coeffs])
        num = gcd(*[(c * den).numerator for c in coeffs])
        lead = next(p for p in reversed(a) if p)[-1]
        scale = Fraction(den, num) * (1 if lead > 0 else -1)
        a = [[c * scale for c in p] for p in a]
        f = [[c * scale for c in p] for p in f]
        while f and not f[-1]:
            f.pop()
        # G = R F with R = r(k) f(k) / (g c(k) phi_0(k))
        return J, a, to_dict(kmul(r, f)), to_dict(kmul([g], cpol, phis[0]))  # @line L8
    return None


def to_dict(p):
    return {(i, j): c for j, cn in enumerate(p) for i, c in enumerate(cn) if c}
test_zeilberger_algorithm.py 149 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/zeilberger-algorithm/. The authors are listed in prov/8S5NXW.jsonl.
"""Tests for Zeilberger's algorithm, against the defining properties.

1. The telescoping identity sum_j a_j(n) F(n+j, k) = G(n, k+1) - G(n, k),
   with G = R F, is checked exactly at many integer points, with F computed
   from factorials (1/m! = 0 for m < 0).
2. The recurrence sum_j a_j(n) S(n+j) = 0 is checked for S(n) = sum_k F(n, k)
   computed by brute force.
3. Known minimal recurrences (2^n, sum of squares, Franel, Apery) are matched.
"""

import random
from fractions import Fraction
from math import factorial

import pytest

from zeilberger_algorithm import zeilberger


def binom(A, B):
    """C(A, B) as factorial factors, A and B given as (a, b, c) for a n + b k + c."""
    (a1, b1, c1), (a2, b2, c2) = A, B
    return [(a1, b1, c1, 1), (a2, b2, c2, -1), (a1 - a2, b1 - b2, c1 - c2, -1)]


def term(poly, x, facts, n, k):
    """F(n, k), or None where a numerator factorial has a negative argument."""
    v = Fraction(x) ** k * sum((c * Fraction(n) ** i * Fraction(k) ** j
                                for (i, j), c in poly.items()), Fraction(0))
    for a, b, c, e in facts:
        m = a * n + b * k + c
        if m < 0:
            if e > 0:
                return None
            return Fraction(0)
        v = v * factorial(m) if e > 0 else v / factorial(m)
    return v


def peval(p, n, k):
    return sum((c * Fraction(n) ** i * Fraction(k) ** j for (i, j), c in p.items()), Fraction(0))


def neval(p, n):
    return sum((c * Fraction(n) ** i for i, c in enumerate(p)), Fraction(0))


def check_telescoping(poly, x, facts, res, nmax=8):
    J, a, Rn, Rd = res
    checked = 0
    for n in range(nmax + 1):
        for k in range(-3, n + 6):
            vals = [term(poly, x, facts, n + j, k) for j in range(J + 1)]
            f0, f1 = term(poly, x, facts, n, k), term(poly, x, facts, n, k + 1)
            d0, d1 = peval(Rd, n, k), peval(Rd, n, k + 1)
            if None in vals or f0 is None or f1 is None or d0 == 0 or d1 == 0:
                continue
            lhs = sum((neval(a[j], n) * vals[j] for j in range(J + 1)), Fraction(0))
            rhs = peval(Rn, n, k + 1) / d1 * f1 - peval(Rn, n, k) / d0 * f0
            assert lhs == rhs, (n, k)
            checked += 1
    assert checked > 0


def S(poly, x, facts, n, kmax):
    return sum((term(poly, x, facts, n, k) or 0 for k in range(-3, kmax)), Fraction(0))


def check_sum(poly, x, facts, res, n0=0, nmax=9, width=lambda n: 2 * n + 6):
    J, a, _, _ = res
    for n in range(n0, nmax):
        s = [S(poly, x, facts, n + j, width(n + J)) for j in range(J + 1)]
        assert sum((neval(a[j], n) * s[j] for j in range(J + 1)), Fraction(0)) == 0, n


N, K, ONE = (1, 0, 0), (0, 1, 0), {(0, 0): 1}
CNK = binom(N, K)
CNKK = binom((1, 1, 0), K)  # C(n + k, k)


def test_known_recurrences():
    res = zeilberger(ONE, 1, CNK)
    assert res[:2] == (1, [[-2], [1]])
    res = zeilberger(ONE, 1, CNK * 2)
    assert res[:2] == (1, [[-2, -4], [1, 1]])
    # R = -k^2 (3n - 2k + 3) / (n - k + 1)^2 for the sum of squares
    assert res[2] == {(0, 2): -3, (1, 2): -3, (0, 3): 2}
    res = zeilberger(ONE, 1, CNK * 3)  # Franel numbers
    assert res[:2] == (2, [[-8, -16, -8], [-16, -21, -7], [4, 4, 1]])
    res = zeilberger(ONE, 1, CNK * 2 + CNKK * 2)  # Apery numbers
    assert res[:2] == (2, [[1, 3, 3, 1], [-117, -231, -153, -34], [8, 12, 6, 1]])
    for facts in (CNK, CNK * 2, CNK * 3, CNK * 2 + CNKK * 2):
        r = zeilberger(ONE, 1, facts)
        check_telescoping(ONE, 1, facts, r, nmax=6)
        check_sum(ONE, 1, facts, r)


def test_edges():
    # (-1)^k C(n, k) is Gosper-summable: order 0, S(n) = 0 for n >= 1
    res = zeilberger(ONE, -1, CNK)
    assert res[0] == 0
    check_telescoping(ONE, -1, CNK, res)
    check_sum(ONE, -1, CNK, res, n0=1)
    # a summand free of factorials: F = k, and F = 1
    for poly in ({(0, 1): 1}, ONE):
        res = zeilberger(poly, 1, [])
        assert res[0] == 0
        check_telescoping(poly, 1, [], res)
    # the zero summand
    assert zeilberger({}, 1, CNK)[0] == 0
    # a summand that needs order 2 is not found with max_order 1
    assert zeilberger(ONE, 1, CNK * 3, max_order=1) is None
    # a polynomial factor and a geometric factor: sum k C(n, k) 2^k = 2n 3^(n-1)
    poly = {(0, 1): 1}
    res = zeilberger(poly, 2, CNK)
    check_telescoping(poly, 2, CNK, res)
    check_sum(poly, 2, CNK, res)


def random_summand(rng):
    c, d = rng.randint(0, 1), rng.randint(0, 1)
    facts = binom((1, 0, c), (0, 1, d))  # C(n + c, k + d): finite support in k
    extra = rng.choice(["none", "cnk", "c2nk", "cnkk", "cnk2"])
    if extra == "cnk":
        facts += CNK
    elif extra == "c2nk":
        facts += binom((2, 0, 0), K)
    elif extra == "cnkk":
        facts += CNKK
    elif extra == "cnk2":
        facts += binom((1, 0, 0), (0, 2, 0))
    poly = {(rng.randint(0, 1), rng.randint(0, 2)): rng.randint(1, 3)}
    if rng.random() < 0.5:
        poly[(0, 0)] = poly.get((0, 0), 0) + rng.randint(-2, 2)
    poly = {e: v for e, v in poly.items() if v} or ONE
    return poly, rng.choice([1, -1, 2]), facts


@pytest.mark.parametrize("seed", range(8))
def test_random_summands(seed):
    rng = random.Random(500 + seed)
    for _ in range(8):
        poly, x, facts = random_summand(rng)
        res = zeilberger(poly, x, facts, max_order=3)
        assert res is not None
        check_telescoping(poly, x, facts, res, nmax=6)
        check_sum(poly, x, facts, res, n0=2, nmax=8)

What the next level needs

  • the tests did not run in this build

Read the full page about Zeilberger's algorithm