为什么statistics.mean()函数如此缓慢?

55

我比较了statistics模块的mean函数和简单的sum(l)/len(l)方法的性能,发现由于某种原因mean函数非常慢。我使用了timeit来比较下面两个代码片段,有人知道是什么原因导致执行速度的巨大差异吗?我正在使用Python 3.5。

from timeit import repeat
print(min(repeat('mean(l)',
                 '''from random import randint; from statistics import mean; \
                 l=[randint(0, 10000) for i in range(10000)]''', repeat=20, number=10)))

上述代码在我的机器上大约在0.043秒内执行。

from timeit import repeat
print(min(repeat('sum(l)/len(l)',
                 '''from random import randint; from statistics import mean; \
                 l=[randint(0, 10000) for i in range(10000)]''', repeat=20, number=10)))

在我的机器上,上述代码的执行时间大约为0.000565秒。


5
简而言之,mean函数比简单的sum(x)/len(x)做了更多的错误处理和工作。 - user559633
12
不仅慢,而且还很刻薄。:( - Failed Scientist
6个回答

79

Python的statistics模块不是为了速度而构建的,而是为了精确性。

该模块的规范中,看起来:

当处理迥然不同幅度的浮点数时,内置的sum可能会失去精度。 因此,上述简单平均数未能通过这个“痛苦测试”。

assert mean([1e30, 1, 3, -1e30]) == 1

返回0而不是1,这是一个完全计算错误,100%的误差。

在mean内使用math.fsum将使它在处理浮点数据时更准确,但它也会产生副作用,即使不必要,也将任何参数转换为浮点数。例如,我们应该期望分数列表的平均值是一个分数,而不是一个浮点数。

相反,如果我们查看该模块中_sum()的实现,在该方法的文档字符串的前几行似乎确认了这一点

def _sum(data, start=0):
    """_sum(data [, start]) -> (type, sum, count)

    Return a high-precision sum of the given numeric data as a fraction,
    together with the type to be converted to and the count of items.

    [...] """

所以,statistics中的sum实现,与其简单调用Python内置的sum()函数的一行代码不同,在其自身内部使用一个嵌套的for循环,需要大约20行的代码。

这是因为statistics._sum选择保证对所有类型的数字都具有最大精度(即使它们之间差异很大),而不是强调速度。

因此,内置的sumstatistics快了一百倍似乎是正常的。如果您使用奇特的数字调用它,则会产生较低的精度成本。

其他选项

如果您需要在算法中优先考虑速度,您应该看看Numpy,其中的算法是用C实现的。

Numpy的平均值远不如statistics精确,但自2013年以来它实现了基于成对求和的例程,这比naive的sum/len更好(更多信息请参见链接)。

然而......

import numpy as np
import statistics

np_mean = np.mean([1e30, 1, 3, -1e30])
statistics_mean = statistics.mean([1e30, 1, 3, -1e30])

print('NumPy mean: {}'.format(np_mean))
print('Statistics mean: {}'.format(statistics_mean))

> NumPy mean: 0.0
> Statistics mean: 1.0

我对该模块的标准差函数进行了类似的测试,发现它比我自己简单实现的函数慢了大约120倍。这些函数确实必须做很多额外的工作才能获得我现在不需要的那种额外精度。感谢您让我茅塞顿开。 - Just some guy
我对Pandas不是很熟悉,但NumPy是为速度而构建的。我认为它没有像statistics.mean那样针对数值稳定性进行优化的平均例程;它只是在C中执行朴素的sum/len操作。 - user2357112
6
最近的NumPy版本确实有基于成对求和的改进求和算法,但没有Kahan求和选项,更不用说像statistics模块一样为了精度而做到极致的算法了。 - user2357112

8

如果你关心速度,请使用numpy/scipy/pandas代替:

In [119]: from random import randint; from statistics import mean; import numpy as np;

In [122]: l=[randint(0, 10000) for i in range(10**6)]

In [123]: mean(l)
Out[123]: 5001.992355

In [124]: %timeit mean(l)
1 loop, best of 3: 2.01 s per loop

In [125]: a = np.array(l)

In [126]: np.mean(a)
Out[126]: 5001.9923550000003

In [127]: %timeit np.mean(a)
100 loops, best of 3: 2.87 ms per loop

结论:它将比原来快上数个数量级——在我的例子中,它快了700倍,但可能不是那么精确(因为numpy没有使用Kahan求和算法)。


3
“你会得到同样精确的结果” - 不,你会失去精度。你失去多少以及你是否在意将取决于输入的形式以及你使用结果的目的。 - user2357112
@user2357112,感谢您的评论。我已经更新了我的答案。 - MaxU - stand with Ukraine

5

len()和sum()都是Python内置函数(具有有限的功能),它们是用C编写的,更重要的是,它们被优化为与某些类型或对象(列表)快速运行。

您可以在这里查看内置函数的实现:

https://hg.python.org/sandbox/python2.7/file/tip/Python/bltinmodule.c

statistics.mean()是一个由Python编写的高级函数。在这里查看其实现方式:

https://hg.python.org/sandbox/python2.7/file/tip/Lib/statistics.py

您可以看到后者在内部使用另一个名为_sum()的函数,与内置函数相比,该函数执行了一些额外的检查。


5

我之前曾经问过同样的问题,但是当我注意到在源代码的第317行中mean函数调用了_sum函数时,我就明白为什么了:

def _sum(data, start=0):
    """_sum(data [, start]) -> (type, sum, count)
    Return a high-precision sum of the given numeric data as a fraction,
    together with the type to be converted to and the count of items.
    If optional argument ``start`` is given, it is added to the total.
    If ``data`` is empty, ``start`` (defaulting to 0) is returned.
    Examples
    --------
    >>> _sum([3, 2.25, 4.5, -0.5, 1.0], 0.75)
    (<class 'float'>, Fraction(11, 1), 5)
    Some sources of round-off error will be avoided:
    >>> _sum([1e50, 1, -1e50] * 1000)  # Built-in sum returns zero.
    (<class 'float'>, Fraction(1000, 1), 3000)
    Fractions and Decimals are also supported:
    >>> from fractions import Fraction as F
    >>> _sum([F(2, 3), F(7, 5), F(1, 4), F(5, 6)])
    (<class 'fractions.Fraction'>, Fraction(63, 20), 4)
    >>> from decimal import Decimal as D
    >>> data = [D("0.1375"), D("0.2108"), D("0.3061"), D("0.0419")]
    >>> _sum(data)
    (<class 'decimal.Decimal'>, Fraction(6963, 10000), 4)
    Mixed types are currently treated as an error, except that int is
    allowed.
    """
    count = 0
    n, d = _exact_ratio(start)
    partials = {d: n}
    partials_get = partials.get
    T = _coerce(int, type(start))
    for typ, values in groupby(data, type):
        T = _coerce(T, typ)  # or raise TypeError
        for n,d in map(_exact_ratio, values):
            count += 1
            partials[d] = partials_get(d, 0) + n
    if None in partials:
        # The sum will be a NAN or INF. We can ignore all the finite
        # partials, and just look at this special one.
        total = partials[None]
        assert not _isfinite(total)
    else:
        # Sum all the partial sums using builtin sum.
        # FIXME is this faster if we sum them in order of the denominator?
        total = sum(Fraction(n, d) for d, n in sorted(partials.items()))
    return (T, total, count)

与仅调用内置的sum相比,有大量操作正在进行,根据文档字符串mean计算高精度总和

您可以看到使用meansum可以给出不同的输出:

In [7]: l = [.1, .12312, 2.112, .12131]

In [8]: sum(l) / len(l)
Out[8]: 0.6141074999999999

In [9]: mean(l)
Out[9]: 0.6141075

4
如果你想要更快的平均函数,Python 3.8中的statistics模块引入了fmean函数。它会在计算平均值之前将数据转换为float类型。
(实现代码在这里
以下是一个快速比较:
import timeit, statistics

def test_basic_mean(): return sum(range(10000)) / 10000
def test_mean(): return statistics.mean(range(10000))
def test_fmean(): return statistics.fmean(range(10000))

print("basic mean:", min(timeit.repeat(stmt=test_basic_mean, setup="from __main__ import test_basic_mean", repeat=20, number=10)))
print("statistics.mean:", min(timeit.repeat(stmt=test_mean, setup="from __main__ import statistics, test_mean", repeat=20, number=10)))
print("statistics.fmean:", min(timeit.repeat(stmt=test_fmean, setup="from __main__ import statistics, test_fmean", repeat=20, number=10)))

给我:

basic mean: 0.0013072469737380743
statistics.mean: 0.025932796066626906
statistics.fmean: 0.001833588001318276

2

在 statistics.py 中查看 _sum 函数的链接 它肯定更倾向于使用分数而非其他方法... - Tadhg McDonald-Jensen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接