pandas 数据帧中 .iloc() 的计算复杂度是多少?

Bru*_*aga 7 python pandas

我试图了解 Pandas 中iloc函数的执行复杂度是多少。我阅读了以下 Stack Exchange 线程(Pandas DataFrame 搜索是线性时间还是常数时间?):

“按索引访问单行(索引已排序且唯一)应具有运行时 O(m) 其中m << n_rows

提到按时iloc运行O(m)。什么是m(线性,对数,常数,...)?

我运行的一些实验:

import pandas as pd
>>> a = pd.DataFrame([[1,2,3],[1,3,4],[2,3,4],[2,4,5]], columns=['a','b','c'])
>>> a = a.set_index('a').sort_index()
>>> a
  b c
a
1 3 4
1 4 5
2 2 3
2 3 4

>>> a.iloc[[0,1,2,3]]
  b c
a
1 3 4
1 4 5
2 2 3
2 3 4
Run Code Online (Sandbox Code Playgroud)

所以iloc显然适用于偏移量而不是基于整数的索引(列a)。即使我们删除顶部的几行,iloc基于偏移量的查找也能正常工作:

>>> a.drop([1]).iloc[[0,1]]
  b c
a
2 2 3
2 3 4
Run Code Online (Sandbox Code Playgroud)

那么,iloc当每一列只是一个可以在恒定时间(很少操作)访问的 numpy 数组时,为什么偏移查找的运行时间与 numpy 数组相当?它的复杂性是什么?谢谢

更新:

我试图在 10000000x2 矩阵上比较 pandas 与 numpy 的效率。比较 DataFramedf和 array 中每行值增量的效率,arrfor循环和没有循环:

#Initialization
SIZE = 10000000
arr = np.ones((SIZE,2), dtype=np.uint32)
df = pd.DataFrame(arr)

#numpy, no for loop
arr[range(SIZE),1]+=1

#pandas, no for loop
df.iloc[range(SIZE),1]+=1

#numpy, for loop
for i in range(SIZE):
arr[i,1]+=1

#pandas, for loop
for i in range(SIZE):
df.iloc[i,1]+=1
Run Code Online (Sandbox Code Playgroud)

执行次数:

  • numpy,没有 for 循环:7 秒
  • 熊猫,没有 for 循环:24 秒
  • numpy,带 for 循环:27 秒
  • 熊猫,带有 for foop:超过 2 小时

bna*_*ker 5

对于iloc. 该方法接受大量的输入类型,而这种灵活性必然会带来成本。这些成本可能包括较大的恒定因素和非常数成本,这些成本几乎肯定取决于其使用方式。

回答您的问题的一种方法是逐步执行这两种情况下的代码。

索引与range

首先,使用range(SIZE). 假设df按照您的方式定义,您可以运行:

import pdb
pdb.run('df.iloc[range(SIZE), 1]')
Run Code Online (Sandbox Code Playgroud)

然后s逐步执行代码以遵循路径。最终,这到达了这一行

self._values.take(indices)
Run Code Online (Sandbox Code Playgroud)

其中indices是从原始 构造的整数 ndarray rangeself._values是数据帧的源 ndarray 。

对此有两点需要注意。首先,范围被具体化为 ndarray,这意味着您至少有SIZE元素的内存分配。所以...这会花费你一些时间:)。我不知道 NumPy 本身的索引是如何发生的,但考虑到您生成的时间测量,可能没有发生(或发生更小的分配)。

第二件事要注意的是numpy.take制作副本。您可以通过查看调用此方法返回的对象的属性来验证这一点.flags,该属性表明它拥有其数据并且不是原始数据的视图。(另请注意,np.may_share_memory返回False。)所以...还有另一个分配:)。

结论:这里并不明显存在任何非线性运行时间,但显然存在很大的常数因子。多次分配可能是最大的杀手,但是属性下的调用树中复杂的分支逻辑.iloc肯定没有帮助。

循环索引

这条路径中的代码要短得多。它很快就到达这里

return self.obj._get_value(*key, takeable=self._takeable)
Run Code Online (Sandbox Code Playgroud)

这里真正糟糕的运行时可能是由于元组解包造成的。在每次循环迭代中,重复地解包和重新打包key为元组。(请注意,这已经是 tuple ,所以这很糟糕。接受通用可迭代的成本。)key(i, 1)

运行时分析

无论如何,我们可以通过分析来估计特定用例的实际运行时间。以下脚本将生成一个以日志间隔排列的数组大小列表,数组大小为 10 到 10e9,索引范围为,并打印出运行该__getitem__方法所需的时间。(树中只有两个这样的调用,因此很容易看出哪一个是我们关心的。)

import pandas as pd
import numpy as np

import cProfile
import pstats

sizes = [10 ** i for i in range(1, 9)]
for size in sizes:
    df = pd.DataFrame(data=np.zeros((size, 2)))
    with cProfile.Profile() as pr:
        pr.enable()
        df.iloc[range(size), 1]
        pr.disable()
        stats = pstats.Stats(pr)
        print(size)
        stats.print_stats("__getitem__")
Run Code Online (Sandbox Code Playgroud)

一旦输出达到最小分辨率,您可以在此处看到非常清晰的线性行为:

Size     | Runtime
------------------
10000    | 0.002
100000   | 0.021
1000000  | 0.206
10000000 | 2.145
100000000| 24.843
Run Code Online (Sandbox Code Playgroud)

所以我不确定你指的是哪些来源来谈论索引的非线性运行时。它们可能已经过时,或者考虑使用与使用range.