将一系列整数转换为字符串 - 为什么使用apply比astype快得多?

30

我有一个包含整数的 pandas.Series,但我需要将它们转换为字符串以供一些下游工具使用。所以假设我有一个 Series 对象:

import numpy as np
import pandas as pd

x = pd.Series(np.random.randint(0, 100, 1000000))

在StackOverflow和其他网站上,我看到大多数人认为最好的方法是:

%% timeit
x = x.astype(str)

这需要大约2秒钟的时间。

当我使用x = x.apply(str)时,仅需要0.2秒。

为什么x.astype(str)如此缓慢? 推荐的方式应该是x.apply(str)吗?

我主要关注于Python 3的行为。


2
仍然不知道原因,但是 list(map(str,x))x.apply(str) 更快。 - BENY
5
你可能对这个问题的元讨论感兴趣。jpp声称你已经放弃了这个问题。 - BSMP
任何回答的人,请假定 Python 3.x,因为 OP 没有确认。我已经在我的(现在是社区 Wiki)答案中指定了 Python / Pandas / Numpy 版本供参考。 - jpp
2
@jpp 是的,我正在使用Python 3上的pandas。感谢您的回答。 - none
2个回答

27

让我们先给出一些通用的建议:如果你想找到Python代码的瓶颈,你可以使用性能分析器来查找耗费时间最多的函数/部分。在这种情况下,我使用了行性能分析器,因为它可以实际上看到每行代码的实现和所花费的时间。

然而,这些工具默认情况下不适用于C或Cython。鉴于CPython(我要使用的Python解释器)、NumPy和pandas大量使用C和Cython,在分析方面会有一定的限制。

实际上,可以通过重新编译带有调试符号和跟踪功能的Cython代码和C代码来扩展分析范围,但编译这些库并非易事,因此我不会这样做(但如果有人愿意这样做,Cython文档包括了关于分析Cython代码的页面)。

但让我们看看自己能做到什么程度:

使用行性能分析器分析Python代码

我将在这里使用line-profiler和Jupyter笔记本:

%load_ext line_profiler

import numpy as np
import pandas as pd

x = pd.Series(np.random.randint(0, 100, 100000))

代码性能分析:x.astype

%lprun -f x.astype x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    87                                                   @wraps(func)
    88                                                   def wrapper(*args, **kwargs):
    89         1           12     12.0      0.0              old_arg_value = kwargs.pop(old_arg_name, None)
    90         1            5      5.0      0.0              if old_arg_value is not None:
    91                                                           if mapping is not None:
   ...
   118         1       663354 663354.0    100.0              return func(*args, **kwargs)

那么这只是一个装饰器,100%的时间都被用在装饰的函数上。因此,让我们对这个装饰函数进行分析:

%lprun -f x.astype.__wrapped__ x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  3896                                               @deprecate_kwarg(old_arg_name='raise_on_error', new_arg_name='errors',
  3897                                                                mapping={True: 'raise', False: 'ignore'})
  3898                                               def astype(self, dtype, copy=True, errors='raise', **kwargs):
  3899                                                   """
  ...
  3975                                                   """
  3976         1           28     28.0      0.0          if is_dict_like(dtype):
  3977                                                       if self.ndim == 1:  # i.e. Series
  ...
  4001                                           
  4002                                                   # else, only a single dtype is given
  4003         1           14     14.0      0.0          new_data = self._data.astype(dtype=dtype, copy=copy, errors=errors,
  4004         1       685863 685863.0     99.9                                       **kwargs)
  4005         1          340    340.0      0.0          return self._constructor(new_data).__finalize__(self)

源代码

再次发现一行代码成为了瓶颈,因此让我们检查 _data.astype 方法:

%lprun -f x._data.astype x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  3461                                               def astype(self, dtype, **kwargs):
  3462         1       695866 695866.0    100.0          return self.apply('astype', dtype=dtype, **kwargs)

好的,又一个代理对象,让我们看看_data.apply做了什么:

%lprun -f x._data.apply x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  3251                                               def apply(self, f, axes=None, filter=None, do_integrity_check=False,
  3252                                                         consolidate=True, **kwargs):
  3253                                                   """
  ...
  3271                                                   """
  3272                                           
  3273         1           12     12.0      0.0          result_blocks = []
  ...
  3309                                           
  3310         1           10     10.0      0.0          aligned_args = dict((k, kwargs[k])
  3311         1           29     29.0      0.0                              for k in align_keys
  3312                                                                       if hasattr(kwargs[k], 'reindex_axis'))
  3313                                           
  3314         2           28     14.0      0.0          for b in self.blocks:
  ...
  3329         1       674974 674974.0    100.0              applied = getattr(b, f)(**kwargs)
  3330         1           30     30.0      0.0              result_blocks = _extend_blocks(applied, result_blocks)
  3331                                           
  3332         1           10     10.0      0.0          if len(result_blocks) == 0:
  3333                                                       return self.make_empty(axes or self.axes)
  3334         1           10     10.0      0.0          bm = self.__class__(result_blocks, axes or self.axes,
  3335         1           76     76.0      0.0                              do_integrity_check=do_integrity_check)
  3336         1           13     13.0      0.0          bm._consolidate_inplace()
  3337         1            7      7.0      0.0          return bm

源代码

再次出现...一个函数调用占用了所有的时间,这次是x._data.blocks[0].astype

%lprun -f x._data.blocks[0].astype x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   542                                               def astype(self, dtype, copy=False, errors='raise', values=None, **kwargs):
   543         1           18     18.0      0.0          return self._astype(dtype, copy=copy, errors=errors, values=values,
   544         1       671092 671092.0    100.0                              **kwargs)

这是另一个代理所以我们需要将它转换成常规函数调用。

%lprun -f x._data.blocks[0]._astype x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   546                                               def _astype(self, dtype, copy=False, errors='raise', values=None,
   547                                                           klass=None, mgr=None, **kwargs):
   548                                                   """
   ...
   557                                                   """
   558         1           11     11.0      0.0          errors_legal_values = ('raise', 'ignore')
   559                                           
   560         1            8      8.0      0.0          if errors not in errors_legal_values:
   561                                                       invalid_arg = ("Expected value of kwarg 'errors' to be one of {}. "
   562                                                                      "Supplied value is '{}'".format(
   563                                                                          list(errors_legal_values), errors))
   564                                                       raise ValueError(invalid_arg)
   565                                           
   566         1           23     23.0      0.0          if inspect.isclass(dtype) and issubclass(dtype, ExtensionDtype):
   567                                                       msg = ("Expected an instance of {}, but got the class instead. "
   568                                                              "Try instantiating 'dtype'.".format(dtype.__name__))
   569                                                       raise TypeError(msg)
   570                                           
   571                                                   # may need to convert to categorical
   572                                                   # this is only called for non-categoricals
   573         1           72     72.0      0.0          if self.is_categorical_astype(dtype):
   ...
   595                                           
   596                                                   # astype processing
   597         1           16     16.0      0.0          dtype = np.dtype(dtype)
   598         1           19     19.0      0.0          if self.dtype == dtype:
   ...
   603         1            8      8.0      0.0          if klass is None:
   604         1           13     13.0      0.0              if dtype == np.object_:
   605                                                           klass = ObjectBlock
   606         1            6      6.0      0.0          try:
   607                                                       # force the copy here
   608         1            7      7.0      0.0              if values is None:
   609                                           
   610         1            8      8.0      0.0                  if issubclass(dtype.type,
   611         1           14     14.0      0.0                                (compat.text_type, compat.string_types)):
   612                                           
   613                                                               # use native type formatting for datetime/tz/timedelta
   614         1           15     15.0      0.0                      if self.is_datelike:
   615                                                                   values = self.to_native_types()
   616                                           
   617                                                               # astype formatting
   618                                                               else:
   619         1            8      8.0      0.0                          values = self.values
   620                                           
   621                                                           else:
   622                                                               values = self.get_values(dtype=dtype)
   623                                           
   624                                                           # _astype_nansafe works fine with 1-d only
   625         1       665777 665777.0     99.9                  values = astype_nansafe(values.ravel(), dtype, copy=True)
   626         1           32     32.0      0.0                  values = values.reshape(self.shape)
   627                                           
   628         1           17     17.0      0.0              newb = make_block(values, placement=self.mgr_locs, dtype=dtype,
   629         1          269    269.0      0.0                                klass=klass)
   630                                                   except:
   631                                                       if errors == 'raise':
   632                                                           raise
   633                                                       newb = self.copy() if copy else self
   634                                           
   635         1            8      8.0      0.0          if newb.is_numeric and self.is_numeric:
   ...
   642         1            6      6.0      0.0          return newb

源代码

... 好的,还没到达目标。我们来看一下 astype_nansafe

%lprun -f pd.core.internals.astype_nansafe x.astype(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   640                                           def astype_nansafe(arr, dtype, copy=True):
   641                                               """ return a view if copy is False, but
   642                                                   need to be very careful as the result shape could change! """
   643         1           13     13.0      0.0      if not isinstance(dtype, np.dtype):
   644                                                   dtype = pandas_dtype(dtype)
   645                                           
   646         1            8      8.0      0.0      if issubclass(dtype.type, text_type):
   647                                                   # in Py3 that's str, in Py2 that's unicode
   648         1       663317 663317.0    100.0          return lib.astype_unicode(arr.ravel()).reshape(arr.shape)
   ...

源代码

再次,这是占用100%的一行代码,所以我将进一步解释一个函数:

%lprun -f pd.core.dtypes.cast.lib.astype_unicode x.astype(str)

UserWarning: Could not extract a code object for the object <built-in function astype_unicode>

好的,我们找到了一个内置函数,这意味着它是一个C语言函数。在这种情况下,它是一个Cython函数。但这意味着我们不能使用行级分析进一步深入挖掘。所以我现在会先停止。

x.apply进行性能分析

%lprun -f x.apply x.apply(str)
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
  2426                                               def apply(self, func, convert_dtype=True, args=(), **kwds):
  2427                                                   """
  ...
  2523                                                   """
  2524         1           84     84.0      0.0          if len(self) == 0:
  2525                                                       return self._constructor(dtype=self.dtype,
  2526                                                                                index=self.index).__finalize__(self)
  2527                                           
  2528                                                   # dispatch to agg
  2529         1           11     11.0      0.0          if isinstance(func, (list, dict)):
  2530                                                       return self.aggregate(func, *args, **kwds)
  2531                                           
  2532                                                   # if we are a string, try to dispatch
  2533         1           12     12.0      0.0          if isinstance(func, compat.string_types):
  2534                                                       return self._try_aggregate_string_function(func, *args, **kwds)
  2535                                           
  2536                                                   # handle ufuncs and lambdas
  2537         1            7      7.0      0.0          if kwds or args and not isinstance(func, np.ufunc):
  2538                                                       f = lambda x: func(x, *args, **kwds)
  2539                                                   else:
  2540         1            6      6.0      0.0              f = func
  2541                                           
  2542         1          154    154.0      0.1          with np.errstate(all='ignore'):
  2543         1           11     11.0      0.0              if isinstance(f, np.ufunc):
  2544                                                           return f(self)
  2545                                           
  2546                                                       # row-wise access
  2547         1          188    188.0      0.1              if is_extension_type(self.dtype):
  2548                                                           mapped = self._values.map(f)
  2549                                                       else:
  2550         1         6238   6238.0      3.3                  values = self.asobject
  2551         1       181910 181910.0     95.5                  mapped = lib.map_infer(values, f, convert=convert_dtype)
  2552                                           
  2553         1           28     28.0      0.0          if len(mapped) and isinstance(mapped[0], Series):
  2554                                                       from pandas.core.frame import DataFrame
  2555                                                       return DataFrame(mapped.tolist(), index=self.index)
  2556                                                   else:
  2557         1           19     19.0      0.0              return self._constructor(mapped,
  2558         1         1870   1870.0      1.0                                       index=self.index).__finalize__(self)

源代码

再次说明,这是一个函数占用了大部分时间:lib.map_infer ...

%lprun -f pd.core.series.lib.map_infer x.apply(str)
Could not extract a code object for the object <built-in function map_infer>

好的,这是另一个Cython函数。

这次有另一个(尽管不那么重要)贡献者占据了大约3%: values = self.asobject。但是现在我会忽略它,因为我们只关心主要的贡献者。

进入C / Cython

astype调用的功能

这是astype_unicode函数:

cpdef ndarray[object] astype_unicode(ndarray arr):
    cdef:
        Py_ssize_t i, n = arr.size
        ndarray[object] result = np.empty(n, dtype=object)

    for i in range(n):
        # we can use the unsafe version because we know `result` is mutable
        # since it was created from `np.empty`
        util.set_value_at_unsafe(result, i, unicode(arr[i]))

    return result

源代码

该函数使用此辅助函数:

cdef inline set_value_at_unsafe(ndarray arr, object loc, object value):
    cdef:
        Py_ssize_t i, sz
    if is_float_object(loc):
        casted = int(loc)
        if casted == loc:
            loc = casted
    i = <Py_ssize_t> loc
    sz = cnp.PyArray_SIZE(arr)

    if i < 0:
        i += sz
    elif i >= sz:
        raise IndexError('index out of bounds')

    assign_value_1d(arr, i, value)

源代码

它本身使用了这个C函数:

PANDAS_INLINE int assign_value_1d(PyArrayObject* ap, Py_ssize_t _i,
                                  PyObject* v) {
    npy_intp i = (npy_intp)_i;
    char* item = (char*)PyArray_DATA(ap) + i * PyArray_STRIDE(ap, 0);
    return PyArray_DESCR(ap)->f->setitem(v, item, ap);
}

源代码

apply调用的函数

这是map_infer函数的实现:

def map_infer(ndarray arr, object f, bint convert=1):
    cdef:
        Py_ssize_t i, n
        ndarray[object] result
        object val

    n = len(arr)
    result = np.empty(n, dtype=object)
    for i in range(n):
        val = f(util.get_value_at(arr, i))

        # unbox 0-dim arrays, GH #690
        if is_array(val) and PyArray_NDIM(val) == 0:
            # is there a faster way to unbox?
            val = val.item()

        result[i] = val

    if convert:
        return maybe_convert_objects(result,
                                     try_float=0,
                                     convert_datetime=0,
                                     convert_timedelta=0)

    return result

使用这个辅助程序:

Source

cdef inline object get_value_at(ndarray arr, object loc):
    cdef:
        Py_ssize_t i, sz
        int casted

    if is_float_object(loc):
        casted = int(loc)
        if casted == loc:
            loc = casted
    i = <Py_ssize_t> loc
    sz = cnp.PyArray_SIZE(arr)

    if i < 0 and sz > 0:
        i += sz
    elif i >= sz or sz == 0:
        raise IndexError('index out of bounds')

    return get_value_1d(arr, i)

源代码

它使用了这个C函数:

PANDAS_INLINE PyObject* get_value_1d(PyArrayObject* ap, Py_ssize_t i) {
    char* item = (char*)PyArray_DATA(ap) + i * PyArray_STRIDE(ap, 0);
    return PyArray_Scalar(item, PyArray_DESCR(ap), (PyObject*)ap);
}

源代码

Cython 代码的一些想法

最终调用的 Cython 代码之间存在一些差异。

astype 中使用了 unicode,而 apply 路径使用传入的函数。让我们看看这是否有区别(再次感谢 IPython/Jupyter,使得编译 Cython 代码变得非常容易):

%load_ext cython

%%cython

import numpy as np
cimport numpy as np

cpdef object func_called_by_astype(np.ndarray arr):
    cdef np.ndarray[object] ret = np.empty(arr.size, dtype=object)
    for i in range(arr.size):
        ret[i] = unicode(arr[i])
    return ret

cpdef object func_called_by_apply(np.ndarray arr, object f):
    cdef np.ndarray[object] ret = np.empty(arr.size, dtype=object)
    for i in range(arr.size):
        ret[i] = f(arr[i])
    return ret

时间:

import numpy as np

arr = np.random.randint(0, 10000, 1000000)
%timeit func_called_by_astype(arr)
514 ms ± 11.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr, str)
632 ms ± 43.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

好的,虽然有些差别,但那是错误的,实际上这会使得 apply 函数稍微慢一些

但请记住我之前提到的 apply 函数中的 asobject 调用?它可能是原因吗?让我们看看:

import numpy as np

arr = np.random.randint(0, 10000, 1000000)
%timeit func_called_by_astype(arr)
557 ms ± 33.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr.astype(object), str)
317 ms ± 13.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

现在看起来更好了。将其转换为对象数组使得被 apply 调用的函数运行速度更快。这其中有一个简单的原因:str 是 Python 函数,如果您已经拥有 Python 对象并且 NumPy(或 Pandas)不需要为数组中存储的值创建 Python 包装器(除非数组是 dtype 为 object 的 Python 对象),则通常情况下这些函数会更快。

然而,这并不能解释您观察到的巨大差异。我怀疑实际上还有一种方法对数组进行迭代并在结果中设置元素的额外差异。很可能是:

val = f(util.get_value_at(arr, i))
if is_array(val) and PyArray_NDIM(val) == 0:
    val = val.item()
result[i] = val

map_infer 函数的一部分比以下操作更快:

for i in range(n):
    # we can use the unsafe version because we know `result` is mutable
    # since it was created from `np.empty`
    util.set_value_at_unsafe(result, i, unicode(arr[i]))
在第一个函数中,astype(str) 被调用。第一个函数的注释似乎表明 map_infer 的编写者试图使代码尽可能地快(参见关于 "是否有更快的方式来取消盒装?" 的注释),而第二个函数可能没有考虑性能方面的特殊注意。但这只是猜测。
另外,在我的电脑上,x.astype(str)x.apply(str) 的性能已经非常接近了。
import numpy as np

arr = np.random.randint(0, 100, 1000000)
s = pd.Series(arr)
%timeit s.astype(str)
535 ms ± 23.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_astype(arr)
547 ms ± 21.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)


%timeit s.apply(str)
216 ms ± 8.48 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit func_called_by_apply(arr.astype(object), str)
272 ms ± 12.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

请注意,我还检查了其他一些返回不同结果的变体:

%timeit s.values.astype(str)  # array of strings
407 ms ± 8.56 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit list(map(str, s.values.tolist()))  # list of strings
184 ms ± 5.02 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

有趣的是,在我的电脑上,使用 listmap 的Python循环似乎是最快的。

我实际上做了一个包括图表的小基准测试:

import pandas as pd
import simple_benchmark

def Series_astype(series):
    return series.astype(str)

def Series_apply(series):
    return series.apply(str)

def Series_tolist_map(series):
    return list(map(str, series.values.tolist()))

def Series_values_astype(series):
    return series.values.astype(str)


arguments = {2**i: pd.Series(np.random.randint(0, 100, 2**i)) for i in range(2, 20)}
b = simple_benchmark.benchmark(
    [Series_astype, Series_apply, Series_tolist_map, Series_values_astype],
    arguments,
    argument_name='Series size'
)

%matplotlib notebook
b.plot()

enter image description here

需要注意的是,由于基准测试中涵盖了巨大的大小范围,因此这是一个双对数轴。然而,在此处更低的值表示更快的速度。

Python/NumPy/Pandas 的不同版本可能会有不同的结果。如果您想进行比较,请查看我的版本:

Versions
--------
Python 3.6.5
NumPy 1.14.2
Pandas 0.22.0

非常详细的内部细节,我从未考虑过行分析。请问您使用的Python / Numpy / Pandas版本号是多少? - jpp
1
@jpp 我添加了版本信息,并且提供了源代码链接(至少对于非常规函数)。是的,只要是纯Python代码,行性能分析就很好用。但是对于Cython/C代码来说,它变得非常复杂。 - MSeifert

18

性能

在开始任何调查之前,值得看一下实际的性能,因为与普遍观点相反,list(map(str, x)) 似乎比 x.apply(str)

import pandas as pd, numpy as np

### Versions: Pandas 0.20.3, Numpy 1.13.1, Python 3.6.2 ###

x = pd.Series(np.random.randint(0, 100, 100000))

%timeit x.apply(str)          # 42ms   (1)
%timeit x.map(str)            # 42ms   (2)
%timeit x.astype(str)         # 559ms  (3)
%timeit [str(i) for i in x]   # 566ms  (4)
%timeit list(map(str, x))     # 536ms  (5)
%timeit x.values.astype(str)  # 25ms   (6)

需要注意的要点:

  1. (5)比(3)/(4)稍微快一点,这是我们所期望的,因为更多的工作被移动到了C语言中[假设没有使用lambda函数]。
  2. (6)明显是最快的。
  3. (1)/(2)非常相似。
  4. (3)/(4)也非常相似。

x.map / x.apply为什么很快?

这似乎是因为它使用了快速的编译的Cython代码

cpdef ndarray[object] astype_str(ndarray arr):
    cdef:
        Py_ssize_t i, n = arr.size
        ndarray[object] result = np.empty(n, dtype=object)

    for i in range(n):
        # we can use the unsafe version because we know `result` is mutable
        # since it was created from `np.empty`
        util.set_value_at_unsafe(result, i, str(arr[i]))

    return result

x.astype(str) 为什么很慢?

Pandas 对系列中的每个项应用 str,而不使用以上的 Cython。

因此性能与 [str(i) for i in x] / list(map(str, x)) 相当。

x.values.astype(str) 为什么很快?

Numpy 不会在数组的每个元素上应用函数。我找到的一种描述是:

如果你执行 s.values.astype(str),则返回一个包含 int 的对象。这是 numpy 进行的转换,而 pandas 遍历每个项并对其调用 str(item)。所以如果你执行 s.astype(str),则会得到一个包含 str 的对象。

在没有 null 值的情况下,存在技术原因,导致 numpy 版本未被实现。


2
你可能想要注明你正在进行基准测试的NumPy、Pandas和Python版本,以及你的计算机规格。否则这并没有什么意义。例如,在NumPy 1.14.1、Pandas 0.22.0、Python 3.6.4下,使用%timeit进行基准测试,对于你的样本数据,x.apply(str)需要18毫秒,而list(map(str, x))只需要15毫秒。基准测试的顺序完全不同。 - miradulo
是的,x.values.astype(str) 返回类型 <U11,因此不是 Python 字节码 str。但我无法通过源代码确认 x.map(str)x.astype(str) 的差异。 - jpp
@miradulo,已将版本添加到测试代码中;同时转换为 Wiki 格式,以便其他人参与贡献。 - jpp
一个名为astype_str的函数听起来像是用于astype(str),而不是用于mapapply - user2357112
1
@jpp 是的,我昨晚看了一下。如果我能理解我昨晚提出的一个相关问题,我可能会写一个答案 :) - miradulo
显示剩余3条评论

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