滑动平均滤波法:将连续取 n 个采样值看成一个队列(先进先出);每次先计算队列中 n 个数据的算术平均值,然后将新数据插入队尾,并移除原来的队首数据;直至没有新数据。这样,就得到了滤波结果。

下面,我们尝试在 Python 中使用尽可能高的速度完成滑动平均值的计算。

我们使用 numpy 构造一个长度为 10000 的随机数组作为测试数据;因为使用 Python 原生遍历 numpy 数组的速度比遍历 Python 列表的速度慢,所以为更准确地测试 Python 原生语法的性能,我们将 numpy 随机数组浅拷贝为 Python 列表。

>>> lst_arr = np.random.random((10000,))
>>> lst_lst = list(np.random.random((10000,)))

具体地,我们使用 timeit 来比较性能:

import timeit
1. 原生 Python 的列表生成式实现

使用 Python 原生的列表生成式语法计算滑动平均值:

>>> timeit.timeit(
...     stmt="res = [sum(lst[i:i+5])/5 for i in range(9996)]",
...     setup="import numpy as np\n"
...           "lst = list(np.random.random((10000,)))",
...     number=1000
... )
8.848661999974865

这个方法的时间复杂度是 python滑动平均函数 python求3天滑动平均_python,其中 python滑动平均函数 python求3天滑动平均_滑动平均_02 为元素数,python滑动平均函数 python求3天滑动平均_python滑动平均函数_03 为窗口长度。因为 Python 循环性能较差,而此方法不但需要对所有元素做一次遍历,而且在计算每一个元素时使用的 sum 函数还需要一次隐式的遍历,所以性能很差。

2. 原生 Python 先计算前缀和再错位相减

首先使用 Python 的 itertools.accumulate 函数计算前缀和,然后使用 Python 原生的列表生成式语法错位相减:

>>> timeit.timeit(
...     stmt="res = [0] + list(accumulate(lst))\n"
...          "res = [(res[i + 5] - res[i]) / 5 for i in range(9996)]",
...     setup="from itertools import accumulate\n"
...           "import numpy as np\n"
...           "lst = list(np.random.random((10000,)))",
...     number=1000
... )
4.91459230001783

这个方法的时间复杂度是 python滑动平均函数 python求3天滑动平均_滑动平均_04,其中 python滑动平均函数 python求3天滑动平均_滑动平均_02

3. np.sum 函数求和

使用 Python 循环遍历窗口长度,然后使用 np.sum 函数直接将多个数组求和:

>>> timeit.timeit(
...     stmt="res = np.sum(lst[i:i+9996] for i in range(5)) / 5",
...     setup="import numpy as np\n"
...           "lst = np.random.random((10000,))",
...     number=1000
... )
0.04497509999782778

这个方法的时间复杂度是 python滑动平均函数 python求3天滑动平均_numpy_06,其中 python滑动平均函数 python求3天滑动平均_滑动平均_02 为元素数,python滑动平均函数 python求3天滑动平均_python滑动平均函数_03

4. np.convolve 函数计算

使用 np.convolve 函数直接计算滑动平均值:

>>> timeit.timeit(
...         stmt="res = np.convolve(lst, np.ones((5,)), mode='valid') / 5",
...         setup="import numpy as np\n"
...               "lst = np.random.random((10000,))",
...         number=1000
...     )
0.03606399998534471

这个方法的时间复杂度是 python滑动平均函数 python求3天滑动平均_numpy_06,其中 python滑动平均函数 python求3天滑动平均_滑动平均_02 为元素数,python滑动平均函数 python求3天滑动平均_python滑动平均函数_03

5. 使用 numpy 计算前缀和再错位相减

先使用 np.cumsum 函数计算前缀和,然后直接将 numpy 数组错位相减:

>>> timeit.timeit(
...     stmt="temp = np.zeros((10001,))\n"
...          "temp[1:] = np.cumsum(lst)\n"
...          "res = (temp[5:] - temp[:-5]) / 5",
...     setup="import numpy as np\n"
...           "lst = np.random.random((10000,))",
...     number=1000
... )
0.04952410000259988

这个方法的时间复杂度是 python滑动平均函数 python求3天滑动平均_滑动平均_04,其中 python滑动平均函数 python求3天滑动平均_滑动平均_02 为元素数。因为前缀和的计算需要处理首元素的边界情况,需要额外的处理,所以当 python滑动平均函数 python求3天滑动平均_python_14

综上所述:方法 4 在时间复杂度上与方法 3 一致,且性能优于方法 3;方法 5 在时间复杂度上虽优于方法 4,但常量时间较大;因此,我们具体比较一下当 k 不同时方法 4 和方法 5 的性能差异。

比较逻辑如下:

import timeit

from matplotlib import pyplot as plt

if __name__ == "__main__":
    x = list(range(1, 31))
    y1 = []
    y2 = []
    for k in x:
        print("compare:", k)
        y1.append(timeit.timeit(
            stmt=f"res = np.convolve(lst, np.ones(({k},)), mode='valid') / {k}",
            setup="import numpy as np\n"
                  "lst = np.random.random((10000,))",
            number=10000
        ))
        y2.append(timeit.timeit(
            stmt=(f"temp = np.zeros((10001,))\n"
                  f"temp[1:] = np.cumsum(lst)\n"
                  f"res = (temp[{k}:] - temp[:-{k}]) / {k}"),
            setup="import numpy as np\n"
                  "lst = np.random.random((10000,))",
            number=10000
        ))

    plt.plot(x, y1, label="Method 4")
    plt.plot(x, y2, label="Method 5")
    plt.title("Compare Method 4 and Method 5")
    plt.xlabel("k")
    plt.ylabel("second")
    plt.legend()
    plt.show()

python滑动平均函数 python求3天滑动平均_滑动平均_15

通过上图,可以看到 np.convolve 函数的运行时间基本上符合 python滑动平均函数 python求3天滑动平均_numpy_06

因此,当滑动平均值的窗口在 10 以内时,方法 4(使用 np.convolve 函数)是最优解;当滑动平均值的窗口在 10 以上时,方法 5(先使用 np.consum 函数计算前缀和再错位相减)是最优解。