Algorithm Search⌘K

Kahan summation in Python

Compensated summation that keeps a running error term, giving error independent of the number of terms.

File
kahan_summation.py, 48 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
kahan_summation.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kahan-summation/. The authors are listed in prov/7H2DE0.jsonl.

"""Kahan's compensated summation of binary64 floats (Kahan, 1965).

A correction c holds the negated low-order part that the last addition
lost; it is subtracted from the next term before that term is added.
"""

from collections.abc import Iterable


def kahan_sum(xs: Iterable[float]) -> float:
    """Compensated sum of xs. Cost: 4 floating-point operations per term, O(1) space."""
    s = 0.0  # @line L1
    c = 0.0  # @line L2
    for x in xs:  # @line L3
        y = x - c  # @line L4
        t = s + y  # @line L5
        c = (t - s) - y  # @line L6
        s = t  # @line L7
    return s  # @line L8


def kahan_steps(xs: Iterable[float]) -> list[tuple[float, float, float, float]]:
    """The values (y, t, c, s) after each iteration, for inspection and tests."""
    s = 0.0
    c = 0.0
    out = []
    for x in xs:
        y = x - c
        t = s + y
        c = (t - s) - y
        s = t
        out.append((y, t, c, s))
    return out


def kahan_sum_corrected(xs: Iterable[float]) -> float:
    """Kahan summation followed by one final correction, s - c."""
    s = 0.0
    c = 0.0
    for x in xs:
        y = x - c
        t = s + y
        c = (t - s) - y
        s = t
    return s - c
test_kahan_summation.py 134 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kahan-summation/. The authors are listed in prov/7H2DE0.jsonl.

import math
import random
from fractions import Fraction

from kahan_summation import kahan_steps, kahan_sum, kahan_sum_corrected

U = Fraction(1, 2**53)


def exact(xs) -> Fraction:
    return sum((Fraction(x) for x in xs), Fraction(0))


def recursive_sum(xs) -> float:
    s = 0.0
    for x in xs:
        s += x
    return s


def exponent(x: float) -> int:
    return max(math.frexp(x)[1] - 1, -1022)


def hypothesis_holds(xs) -> bool:
    """Every addition s + y has s = 0 or exponent(s) >= exponent(y)."""
    s = 0.0
    c = 0.0
    for x in xs:
        y = x - c
        if s != 0.0 and y != 0.0 and exponent(s) < exponent(y):
            return False
        t = s + y
        c = (t - s) - y
        s = t
    return True


def bound(xs) -> Fraction:
    n = len(xs)
    return (2 * U + (n + 4) * U * U) * exact(abs(x) for x in xs)


def test_small_cases():
    assert kahan_sum([]) == 0.0
    assert kahan_sum([3.25]) == 3.25
    assert kahan_sum([0.1, 0.2]) == 0.1 + 0.2


def test_trace_example():
    xs = [1e16, 1.0, 1.0, 1.0, 1.0]
    steps = kahan_steps(xs)
    assert [c for (_, _, c, _) in steps] == [0.0, -1.0, 0.0, 1.0, 0.0]
    assert [s for (_, _, _, s) in steps] == [1e16, 1e16, 1e16 + 2, 1e16 + 4, 1e16 + 4]
    assert kahan_sum(xs) == 1e16 + 4
    assert recursive_sum(xs) == 1e16


def test_tiny_terms():
    xs = [1.0] + [2.0**-53] * 7
    assert kahan_sum(xs) == math.fsum(xs) == 1.0 + 2.0**-50
    assert recursive_sum(xs) == 1.0


def test_repeated_tenth():
    xs = [0.1] * 10**5
    assert kahan_sum(xs) == math.fsum(xs)


def test_correction_is_exact_under_hypothesis():
    rng = random.Random(77)
    for _ in range(300):
        xs = [rng.uniform(0, 1) * 2.0 ** rng.randint(-30, 0) for _ in range(rng.randint(1, 80))]
        s = Fraction(0)
        c = Fraction(0)
        ok = hypothesis_holds(xs)
        for (y, t, cn, _), x in zip(kahan_steps(xs), xs):
            if ok:
                # c is the exact negated rounding error of t = fl(s + y)
                assert Fraction(cn) == (Fraction(t) - s) - Fraction(y)
                assert abs(Fraction(cn)) <= U * abs(Fraction(t))
            s = Fraction(t)


def test_proved_bound_when_hypothesis_holds():
    rng = random.Random(2024)
    checked = 0
    for trial in range(300):
        n = rng.randint(1, 400)
        if trial % 3 == 0:
            xs = [rng.random() for _ in range(n)]
        elif trial % 3 == 1:
            xs = [rng.uniform(-1, 1) for _ in range(n)]
        else:
            xs = [math.ldexp(rng.uniform(0.5, 1), rng.randint(-40, 0)) for _ in range(n)]
        err = abs(Fraction(kahan_sum(xs)) - exact(xs))
        if hypothesis_holds(xs):
            checked += 1
            assert err <= bound(xs)
    assert checked > 50


def test_observed_bound_in_general():
    rng = random.Random(99)
    for _ in range(300):
        n = rng.randint(1, 300)
        xs = [math.ldexp(rng.uniform(-1, 1), rng.randint(-80, 80)) for _ in range(n)]
        err = abs(Fraction(kahan_sum(xs)) - exact(xs))
        assert err <= bound(xs)


def test_large_random_error_independent_of_n():
    rng = random.Random(1)
    xs = [rng.random() for _ in range(100000)]
    ex = exact(xs)
    assert abs(Fraction(kahan_sum(xs)) - ex) <= U * ex


def test_known_failure_large_term():
    xs = [1.0, 1e100, 1.0, -1e100]
    assert kahan_sum(xs) == 0.0
    assert math.fsum(xs) == 2.0
    assert kahan_sum_corrected(xs) == 0.0


def test_special_values():
    # the correction becomes inf - inf = NaN, which poisons the next term
    assert kahan_sum([1.0, math.inf]) == math.inf
    assert math.isnan(kahan_sum([1.0, math.inf, 1.0]))
    assert math.isnan(kahan_sum([math.nan, 1.0]))
    assert kahan_sum([-0.0]) == 0.0

What the next level needs

  • the tests did not run in this build

Read the full page about Kahan summation