@2e0byo 一针见血地说,在这种情况下,pandas 的算法“效率低下”。
就.loc 而言,它并没有真正做任何了不起的事情。它在这里的使用类似于使用相同形状的布尔数组索引 numpy 数组,并添加对特定列的类似 dict-key 的访问 - 也就是说,df['city'] == 'paris' 本身就是一个数据帧,具有相同的行数以及与df 相同的索引,具有单列布尔值。 df.loc[df['city'] == 'paris'] 然后给出一个数据框,该数据框仅包含 df['city'] == 'paris' 中为真的行(在“城市”列中有“巴黎”)。添加附加参数 'final_target' 然后只返回这些行的 'final_target' 列,而不是全部三个(并且因为它只有一列,它在技术上是一个 Series 对象 - df['arr'] 也是如此)。
当 pandas 实际尝试添加这两个系列时,就会发生内存爆炸。正如@2e0byo 指出的那样,它必须重塑系列才能做到这一点,它通过调用第一个系列的align() 方法来做到这一点。在align 操作期间,函数pandas.core.reshape.merge.get_join_indexers() 使用三个参数调用pandas._libs.join.full_outer_join() (line 155):left、right 和max_groups(澄清点:这些是它们的名称inside em> 函数full_outer_join)。 left 和 right 是整数数组,包含两个 Series 对象的索引(索引列中的值),max_groups 是 left 或 right 中唯一元素的最大数量(在我们的例,即5,对应s中原来的5行)。
full_outer_join 立即转身并调用pandas._libs.algos.groupsort_indexer() (line 194),一次以left 和max_groups 作为参数,一次以right 和max_groups 作为参数。 groupsort_indexer 返回两个数组 - 通常,indexer 和 counts(对于使用 left 的调用,它们被称为 left_sorter 和 left_count,对应于 right)。 counts 的长度为 max_groups + 1,每个元素(除了第一个未使用的元素)都包含相应索引组在输入数组中出现的次数。所以对于我们的例子,max_groups = 5,count 数组的形状为(6,),元素 1-5 表示 5 个唯一索引值出现在 left 和 right 中的次数。
另一个数组indexer 的构造使得用它索引原始输入数组会返回按升序分组的所有元素 - 因此是“排序器”。在为left 和right 完成此操作后,full_outer_join 将两个分拣机切碎并将它们串起来。 full_outer_join 返回两个大小相同的数组,left_idx 和 right_idx - 这些数组变得非常大并引发错误。排序器中元素的顺序决定了它们在最后两个输出数组中出现的顺序,count 数组决定了每个元素出现的频率。由于left 排在第一位,因此它的元素保持在一起——在left_idx 中,left_sorter 中的第一个left_count[1] 元素每个都重复right_count[1] 次(aaabbbccc ...)。在right_idx 的同一位置,第一个right_count[1] 元素连续重复left_count[1] 次(abcabcabc...)。 (方便的是,由于s 中的0 行是'paris' 行,所以left_count[1] 和right_count[1] 总是相等的,所以你得到x 个重复次数x 开始的次数)。然后left_sorter 的下一个left_count[2] 元素重复right_count[2] 次,依此类推……如果counts 中的任何一个元素为零,则idx 数组中的对应点用-1 填充,稍后被屏蔽(如,right_count[i] = 0 表示 right_idx 中的元素为 -1,反之亦然 - left_count[3] 和 left_count[4] 总是如此,因为 @ 中的行 2 和 3 987654398@ 不是'paris')。
最后,_idx 数组的元素数量等于N_elements,计算如下:
left_nonzero = (left_count[1:] != 0)
right_nonzero = (right_count[1:] != 0)
left_repeats = left_count[1:]*left_nonzero + np.ones(len(left_counts)-1)*(1 - left_nonzero)
right_repeats = right_count[1:]*right_nonzero + np.ones(len(right_counts)-1)*(1 - right_nonzero)
N_elements = sum(left_repeats*right_repeats)
count 数组的对应元素相乘(所有的 0 都替换为 1),相加得到N_elements。
你可以看到这个数字增长很快(O(n^2))。对于具有 1,000,000 个采样行的原始数据帧,每个行的出现大致相同,那么 count 数组看起来像:
left_count = array([0, 2e5, 2e5, 0, 0, 2e5])
right_count = array([0, 2e5, 2e5, 2e5, 2e5, 2e5])
总长度约为1.2e11。一般来说,对于初始样本N (df = pd.read_csv(io.StringIO(s)).sample(N, replace=True)),最终大小约为0.12*N**2
一个例子
看一个小例子可能会有所帮助,以了解full_outer_join 和groupsort_indexer 在制作这些巨大的数组时正在尝试做什么。我们将从只有 10 行的小样本开始,然后按照各种数组到达最终输出 left_idx 和 right_idx。我们将从定义初始数据框开始:
df = pd.read_csv(io.StringIO(s)).sample(10, replace=True)
df['final_target'] = df['city'] # this line doesn't change much, but meh
看起来像:
city arr final_target
3 miami 15 miami
1 paris 11 paris
0 paris 12 paris
0 paris 12 paris
0 paris 12 paris
1 paris 11 paris
2 dallas 22 dallas
3 miami 15 miami
2 dallas 22 dallas
4 paris 16 paris
df.loc[df['city'] == 'paris', 'final_target'] 看起来像:
1 paris
0 paris
0 paris
0 paris
1 paris
4 paris
和df['arr'].astype(str):
3 15
1 11
0 12
0 12
0 12
1 11
2 22
3 15
2 22
4 16
然后,在对full_outer_join 的调用中,我们的参数看起来像:
left = array([1,0,0,0,1,4]) # indexes of df.loc[df['city'] == 'paris', 'final_target']
right = array([3,1,0,0,0,1,2,3,2,4]) # indexes of df['arr'].astype(str)
max_groups = 5 # the max number of unique elements in either left or right
函数调用groupsort_indexer(left, max_groups)返回以下两个数组:
left_sorter = array([1, 2, 3, 0, 4, 5])
left_count = array([0, 3, 2, 0, 0, 1])
left_count 保存 left 中每个唯一值的出现次数 - 第一个元素未使用,但随后在 left 中有 3 个零、2 个一、0 个二、0 个三和 1 个四。
left_sorter 是这样的 left[left_sorter] = array([0, 0, 0, 1, 1, 4]) - 一切都井井有条。
现在right:groupsort_indexer(right, max_groups)返回
right_sorter = array([2, 3, 4, 1, 5, 6, 8, 0, 7, 9])
right_count = array([0, 3, 2, 2, 2, 1])
再次,right_count 包含每个计数出现的次数:未使用的第一个元素,然后是 3 个零、2 个 1、2 个 2、2 个 3 和 1 个 4(注意元素 1、2 和 5两个count 数组是相同的:这些是s 和'city' = 'paris' 中的行)。另外,right[right_sorter] = array([0, 0, 0, 1, 1, 2, 2, 3, 3, 4])
计算count 数组后,我们可以计算idx 数组的大小(实际数字比上面的公式简单一点):
N_total = 3*3 + 2*2 + 2 + 2 + 1*1 = 18
3 是两个 counts 数组的元素 1,所以我们可以期待像 [1,1,1,2,2,2,3,3,3] 这样的东西开始 left_idx,因为 [1,2,3] 开始 left_sorter,而 [2,3,4,2,3,4,2,3,4] 开始 right_idx,因为right_sorter 以[2,3,4] 开头。然后我们有两个,所以[0,0,4,4] 代表left_idx 和[1,5,1,5] 代表right_idx。那么left_count 有两个零,right_count 有两个二,所以接下来进入left_idx 中的4 个-1,right_sorter 中接下来的四个元素进入right_idx:[6,8,0,7]。 count 都以 1 结束,所以 sorters 中的每个最后一个元素都进入 idx:5 用于 left_idx 和 9 用于 right_idx,离开:
left_idx = array([1, 1, 1, 2, 2, 2, 3, 3, 3, 0, 0, 4, 4,-1, -1, -1, -1, 5])
right_idx = array([2, 3, 4, 2, 3, 4, 2, 3, 4, 1, 5, 1, 5, 6, 8, 0 , 7, 9])
这确实是 18 个元素。
由于两个索引数组的形状相同,pandas 可以从我们原来的数组中构造两个形状相同的 Series 来执行它需要的任何操作,然后它可以屏蔽这些数组以获取排序后的索引。使用一个简单的布尔过滤器来查看我们如何使用输出对 left 和 right 进行排序,我们得到:
left[left_idx[left_idx != -1]] = array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 4])
right[right_idx[right_idx != -1]] = array([0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 3, 3, 4])
回溯所有函数调用和模块后,此时相加的结果是:
0 paris_12
0 paris_12
0 paris_12
0 paris_12
0 paris_12
0 paris_12
0 paris_12
0 paris_12
0 paris_12
1 paris_11
1 paris_11
1 paris_11
1 paris_11
2 NaN
2 NaN
3 NaN
3 NaN
4 paris_16
在pandas.core.generic.NDFrame._inplace_method (line 11066) 中的result = op(self, other) 行中是result,其中op = pandas.core.series.Series.__add__ 和self 和other 是我们添加之前的两个系列。
所以,据我所知,pandas 基本上 尝试 为相同索引行的每个组合执行操作(例如,第一个系列中索引为 1 的任何和所有行应与其他系列中的所有行索引1 一起操作)。如果其中一个系列具有另一个没有的索引,则这些行将被屏蔽掉。在这种情况下,具有相同索引的每一行都是相同的。只要您不需要在原地执行任何操作,它就可以工作(尽管是多余的) - 当 pandas 尝试将此结果重新索引为原始形状时,小数据帧的问题就会出现数据框df.
分割线(较小的数据帧可以通过,但较大的数据帧不能通过的线)是上面的result = op(self, other) 线。稍后在同一个函数中(称为,注意,_inplace_method),程序在self._update_inplace(result.reindex_like(self, copy=False), verify_is_copy=False) 退出。它尝试重新索引result,使其看起来像self,因此它可以用result 替换self(self 是原始系列,添加中的第一个,df.loc[df['city'] == 'paris', 'final_target'])。这就是较小的情况失败的地方,因为很明显,result 有一堆重复的索引,pandas 不想在删除其中一些时丢失任何信息。
最后一件事
可能值得一提的是,此行为并非特定于此处的加法运算。每当您尝试对具有大量重复索引的两个大型数据帧进行算术运算时,都会发生这种情况 - 例如,尝试以与第一个数据帧完全相同的方式定义第二个数据帧 df2 = pd.read_csv(io.StringIO(s)).sample(1000000, replace=True),然后尝试运行 df.arr*df2.arr。你会得到同样的内存错误。
有趣的是,逻辑和比较运算符有防止这样做的保护 - 它们需要相同的索引,并在调用它们的运算符方法之前检查它。
我在 pandas 1.2.4、python 3.7.10 中完成了所有工作,但我提供了 pandas Github 的链接,该链接目前为 1.3.3 版本。据我所知,差异不会影响结果。