web-dev-qa-db-ja.com

一連のintを文字列に変換する-なぜastypeよりも速く適用するのですか?

_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のこの動作について興味があります。

26
none

パフォーマンス

世論に反して、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)が遅いのはなぜですか?

パンダは、上記のCythonを使用せずに、シリーズの各アイテムにstrを適用します。

したがって、パフォーマンスは[str(i) for i in x]/list(map(str, x))に匹敵します。

なぜx.values.astype(str)がそんなに速いのですか?

Numpyは、配列の各要素に関数を適用しません。 1つの説明 これが見つかった:

s.values.astype(str)を実行すると、intを保持するオブジェクトが返されます。これはnumpyが変換を行うのに対し、pandasは各項目を反復処理し、その項目でstr(item)を呼び出します。したがって、s.astype(str)strを保持するオブジェクトがあります。

技術的な理由があります numpyバージョンが実装されていない理由 nullがない場合。

14
jpp

少し一般的なアドバイスから始めましょう:Pythonコードのボトルネックを見つけることに興味がある場合は、プロファイラーを使用して、ほとんどの時間を消費する関数/部品を見つけることができます。この場合、実際に実装と各ラインで費やされた時間を確認できるため、ラインプロファイラを使用します。

ただし、これらのツールはデフォルトではCまたはCythonでは動作しません。 CPython(Pythonインタプリタです)、NumPyとpandasはCとCythonを多用します)を考えると、制限はありますプロファイリングで取得します。

実際には、おそらくプロファイリングをデバッグシンボルとトレースで再コンパイルすることにより、CythonコードとCコードに拡張できますが、これらのライブラリをコンパイルするのは簡単な作業ではないので、私はそれをしません(ただし、誰かがそうしたい場合 Cythonのドキュメントには、Cythonコードのプロファイリングに関するページが含まれています )。

しかし、私がどこまで到達できるか見てみましょう:

ラインプロファイリングPythonコード

ここでは line-profiler とJupyter Notebookを使用します。

_%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)
_

ソース

ここでも1行がボトルネックなので、__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
_

ソース

繰り返しますが、1つの関数呼び出しが常にかかっています。今回は_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%を占めるのは1行なので、次の1つの関数に進みます。

_%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>
_

OK、_built-in function_が見つかりました。これは、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)
_

ソース

繰り返しますが、これはほとんどの時間を費やす1つの関数です:_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
_

ソース

このヘルパーで:

_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がわずかに遅いことを示します

しかし、先にasobject関数で述べたapply呼び出しを覚えていますか?それが理由でしょうか?どれどれ:

_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ラッパーを作成する必要はありません(通常、Pythonオブジェクトではない場合、配列はdtype object)です。

しかし、それはあなたが見た巨大な違いを説明していません。私の疑いは、配列が反復され、要素が結果に設定される方法に実際には追加の違いがあるということです。おそらく:

_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)
_

興味深いことに、Python listmapを使用したループは、私のコンピューターで最速のようです。

私は実際にプロットを含む小さなベンチマークを作りました:

_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
_
21
MSeifert