티스토리 뷰
안녕하세요. Teus입니다.
이번 포스팅은, Pandas DataFrame의 .apply method에 대해서 파해쳐 봅니다.
1.DataFrame.apply
Pandas의 경우, DataFrame이나 Series에 .apply method를 사용해서, 사용자 지정 함수를 Elementwise하게 적용하는 것이 가능합니다.
import pandas as pd
temp_df = pd.DataFrame({"a" : [i*1 for i in range(100)],
"b" : [i*2 for i in range(100)],
"c" : [i*3 for i in range(100)],
"d" : [i*4 for i in range(100)],
"e" : [i*5 for i in range(100)]})
#"a" col에 ElementWise하게 제곱을 적용
temp_df["a"].apply(lambda x : x*x)
하지만, apply Method는 Code의 가독성을 높여주지만, 반복문을 사용하는 경우보다 속도가 느릴 수 있다고 알려져 있습니다. 과연 그럴까요?
2. 어떻게 동작하는가?
우선 Pandas DataFrame Object의 Source Code를 살펴보겠습니다.
#https://github.com/pandas-dev/pandas/blob/v1.4.3/pandas/core/frame.py#L8688-L8845
class DataFrame(NDFrame, OpsMixin):
...
def apply(
self,
func: AggFuncType,
axis: Axis = 0,
raw: bool = False,
result_type=None,
args=(),
**kwargs,
):
from pandas.core.apply import frame_apply
op = frame_apply(
self,
func=func,
axis=axis,
raw=raw,
result_type=result_type,
args=args,
kwargs=kwargs,
)
return op.apply().__finalize__(self, method="apply")
apply method의 경우, DataFrame Class내에 Method 형태로 원형이 존재합니다.
이때 pandas.core.apply의 source code를 가지고와서, frame_apply Object를 만들어 주는것을 볼 수 있습니다.
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/core/apply.py#L78
def frame_apply(
obj: DataFrame,
func: AggFuncType,
axis: Axis = 0,
raw: bool = False,
result_type: str | None = None,
args=None,
kwargs=None,
) -> FrameApply:
"""construct and return a row or column based frame apply object"""
axis = obj._get_axis_number(axis)
klass: type[FrameApply]
if axis == 0:
klass = FrameRowApply
elif axis == 1:
klass = FrameColumnApply
return klass(
obj,
func,
raw=raw,
result_type=result_type,
args=args,
kwargs=kwargs,
)
apply function의 경우, Row방향 or Col방향 함수적용이기 때문에, input axis에 따라서 klass를 가변적으로 사용하는것을 볼 수 있습니다.
(두가지 모두 FrameApply를 상속받은 Class이기 때문에, Source Code는 FrameApply를 기준으로 살펴봅니다)
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/core/apply.py#L78
class FrameApply(NDFrameApply):
obj: DataFrame
...
def apply(self) -> DataFrame | Series:
"""compute the results"""
# dispatch to agg
if is_list_like(self.f):
return self.apply_multiple()
# all empty
if len(self.columns) == 0 and len(self.index) == 0:
return self.apply_empty_result()
# string dispatch
if isinstance(self.f, str):
return self.apply_str()
# ufunc
elif isinstance(self.f, np.ufunc):
with np.errstate(all="ignore"):
results = self.obj._mgr.apply("apply", func=self.f)
# _constructor will retain self.index and self.columns
return self.obj._constructor(data=results)
# broadcasting
if self.result_type == "broadcast":
return self.apply_broadcast(self.obj)
# one axis empty
elif not all(self.obj.shape):
return self.apply_empty_result()
# raw
elif self.raw:
return self.apply_raw()
return self.apply_standard()
FramApply Class의 경우 NDFramApply를 상속받고, NDFramApply는 Apply Class를 상속받아 구현됩니다.
결국 FrameApply Class에 전달된 매개변수를 이용해서 Apply Class Object가 만들어 지는것을 확인할 수 있으며,
Apply Object가 DataFrame.apply를 통해서 입력한 Function을 self.f self.orig_f로 가지고 있는것을 볼 수 있습니다.
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/core/apply.py#L105
class Apply(metaclass=abc.ABCMeta):
axis: int
def __init__(
self,
obj: AggObjType,
func,
raw: bool,
result_type: str | None,
args,
kwargs,
):
self.obj = obj
self.raw = raw
self.args = args or ()
self.kwargs = kwargs or {}
if result_type not in [None, "reduce", "broadcast", "expand"]:
raise ValueError(
"invalid value for result_type, must be one "
"of {None, 'reduce', 'broadcast', 'expand'}"
)
self.result_type = result_type
# Funciton을 적용하기 위해서 함수를 저장
# curry if needed
if (
(kwargs or args)
and not isinstance(func, (np.ufunc, str))
and not is_list_like(func)
):
def f(x):
return func(x, *args, **kwargs)
else:
f = func
self.orig_f: AggFuncType = func
self.f: AggFuncType = f
이제 frame_apply Object에 대해서 알았고, fram_apply Object의 return으로 DataFrame이 반환된다는 것을 확인 했습니다.
이때 특수 Case를 제외하고, apply_standard에 대해서 알아보면 아래와 같습니다.
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/core/apply.py#L856
@property
def series_generator(self):
return (self.obj._ixs(i, axis=1) for i in range(len(self.columns)))
def apply_standard(self):
results, res_index = self.apply_series_generator()
# wrap results
return self.wrap_results(results, res_index)
def apply_series_generator(self) -> tuple[ResType, Index]:
assert callable(self.f)
series_gen = self.series_generator
res_index = self.result_index
results = {}
with option_context("mode.chained_assignment", None):
#=====================실제 함수 실행 부분===========================#
for i, v in enumerate(series_gen):
# ignore SettingWithCopy here in case the user mutates
results[i] = self.f(v)
if isinstance(results[i], ABCSeries):
# If we have a view on v, we need to make a copy because
# series_generator will swap out the underlying data
results[i] = results[i].copy(deep=False)
#=====================실제 함수 실행 부분===========================#
return results, res_index
def wrap_results(self, results: ResType, res_index: Index) -> DataFrame | Series:
from pandas import Series
# see if we can infer the results
if len(results) > 0 and 0 in results and is_sequence(results[0]):
return self.wrap_results_for_axis(results, res_index)
# dict of scalars
# the default dtype of an empty Series will be `object`, but this
# code can be hit by df.mean() where the result should have dtype
# float64 even if it's an empty Series.
constructor_sliced = self.obj._constructor_sliced
if constructor_sliced is Series:
result = create_series_with_explicit_dtype(
results, dtype_if_empty=np.float64
)
else:
result = constructor_sliced(results)
result.index = res_index
return result
소스코드에서 볼 수 있듯, 내부로 타고 들어와서 결국 apply_series_generator에서 self.series_generator Method로 Series를 Generator 형태로 받고
이 Generator를 반복문을 돌면서 Function을 적용하는 것을 확인 할 수가 있습니다.
(finalize 같은 경우, other이 정의되어 있지 않을 경우 그래도 입력받은 DataFrame을 반환합니다)
3.Series.apply
DataFrame.apply를 보면, Col단위로 Series를 뽑아내고 Function을 적용하는 것을 확인할 수 있었습니다.
이때, Axis를 Row단위로 설정할 경우, Col마다 뽑아온 Series에 추가적으로 Apply를 하게 됩니다.
따라서, Series의 apply에 대해서 추가적으로 보도록 하겠습니다.
#https://github.com/pandas-dev/pandas/blob/v1.4.3/pandas/core/series.py#L4323-L4433
class Series(base.IndexOpsMixin, NDFrame):
...
def apply(
self,
func: AggFuncType,
convert_dtype: bool = True,
args: tuple[Any, ...] = (),
**kwargs,
) -> DataFrame | Series:
return SeriesApply(self, func, convert_dtype, args, kwargs).apply()
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/core/apply.py#L1051
class SeriesApply(NDFrameApply):
obj: Series
axis = 0
def __init__(
self,
obj: Series,
func: AggFuncType,
convert_dtype: bool,
args,
kwargs,
):
self.convert_dtype = convert_dtype
super().__init__(
obj,
func,
raw=False,
result_type=None,
args=args,
kwargs=kwargs,
)
def apply(self) -> DataFrame | Series:
obj = self.obj
if len(obj) == 0:
return self.apply_empty_result()
# dispatch to agg
if is_list_like(self.f):
return self.apply_multiple()
if isinstance(self.f, str):
# if we are a string, try to dispatch
return self.apply_str()
return self.apply_standard()
def apply_standard(self) -> DataFrame | Series:
f = self.f
obj = self.obj
with np.errstate(all="ignore"):
if isinstance(f, np.ufunc):
return f(obj)
# row-wise access
if is_extension_array_dtype(obj.dtype) and hasattr(obj._values, "map"):
# GH#23179 some EAs do not have `map`
mapped = obj._values.map(f)
else:
values = obj.astype(object)._values
# error: Argument 2 to "map_infer" has incompatible type
# "Union[Callable[..., Any], str, List[Union[Callable[..., Any], str]],
# Dict[Hashable, Union[Union[Callable[..., Any], str],
# List[Union[Callable[..., Any], str]]]]]"; expected
# "Callable[[Any], Any]"
mapped = lib.map_infer(
values,
f, # type: ignore[arg-type]
convert=self.convert_dtype,
)
if len(mapped) and isinstance(mapped[0], ABCSeries):
# GH#43986 Need to do list(mapped) in order to get treated as nested
# See also GH#25959 regarding EA support
return obj._constructor_expanddim(list(mapped), index=obj.index)
else:
return obj._constructor(mapped, index=obj.index).__finalize__(
obj, method="apply"
)
apply_standard에서, function이 np.ufunc에 해당하면 바로 function(Series.obj)을 적용 하지만, 그러지 않을 경우 dtype을 object로 변경하고, 해당 object를 lib.map_infer로 넘기는 것을 확인할 수 있습니다.
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/_libs/lib.pyx#L2841
@cython.boundscheck(False)
@cython.wraparound(False)
def map_infer(
ndarray arr, object f, bint convert=True, bint ignore_na=False
) -> np.ndarray:
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):
if ignore_na and checknull(arr[i]):
result[i] = arr[i]
continue
val = f(arr[i])
if cnp.PyArray_IsZeroDim(val):
# unbox 0-dim arrays, GH#690
val = val.item()
result[i] = val
if convert:
#maybe_convert_object source :
#https://github.com/pandas-dev/pandas/blob/e8093ba372f9adfe79439d90fe74b0b5b6dea9d6/pandas/_libs/lib.pyx#L2395
return maybe_convert_objects(result,
try_float=False,
convert_datetime=False,
convert_timedelta=False)
return result
lib.map_infer의 경우, Cython으로 정의되어 있는것을 볼 수 있습니다. numpy.array를 입력받아 동일한 크기의 empty array를 생성하고
Cython으로 반복문을 돌면서 array[index] = function(array[index])을 적용해 주는것을 확인할 수 있습니다.
이후에 Series의 생성자를 이용해서 새로운 Series를 생성해서 반환해 주는것을 확인할 수 있습니다.
참고 : Cython으로 최적화를 진행할 경우, Python 함수 적용 시간은 비슷할 수고 있지만, Cython for를 사용하기 때문에 Python for대비 우월한 속도를 확보할 수 있습니다.
4. BenchMark
간단하게 row count 50만개인 DataFrame에 대해서 power2를 적용하는 예제입니다.
import pandas as pd
import time
row = 5000000
temp_df = pd.DataFrame({"a" : [i*1 for i in range(row)],
"b" : [i*2 for i in range(row)],
"c" : [i*3 for i in range(row)],
"d" : [i*4 for i in range(row)],
"e" : [i*5 for i in range(row)]})
def foo(x):
return x*x
#======================Row 단위 apply===============================
st = time.time()
temp_df["a"].apply(lambda x : foo(x))
print("Series apply time : ", time.time() - st)
temp_series = temp_df["a"].copy()
st = time.time()
for idx, val in enumerate(temp_series):
temp_series[idx] = foo(val)
print("Series normal time : ", time.time() - st)
st = time.time()
temp_df["a"] = foo(temp_df["a"])
print("Series Direct Function time : ", time.time() - st)
#======================Row 단위 apply===============================
#======================Col 단위 apply===============================
st = time.time()
temp_df.apply(lambda x : foo(x), axis = 0)
print("DataFrame apply time : ", time.time() - st)
st = time.time()
result = {}
for i in (temp_df._ixs(i, axis=1) for i in range(len(temp_df.columns))):
result[i.name] = foo(i)
pd.DataFrame(result)
print("DataFrame normal time : ", time.time() - st)
st = time.time()
result = {}
for i in (temp_df._ixs(i, axis=1) for i in range(len(temp_df.columns))):
result[i.name] = foo(i)
print("DataFrame normal time(without DataFrame Construct) : ", time.time() - st)
#======================Col 단위 apply===============================
"""
Series apply time : 2.672856092453003
Series normal time : 26.50118923187256
Series Direct Function time : 0.09272193908691406
DataFrame apply time : 0.1655576229095459
DataFrame normal time : 0.18553519248962402
DataFrame normal time(without DataFrame Construct) : 0.09574413299560547
"""
Pandas apply사용 유무와 완벽히 일치하는 Case는 아니지만
RowApply의 경우 Cython 최적화로 Python 반복문 대비 우월한 속도를 확인할 수 있습니다.
ColApply의 경우 Result Dict을 생성하는데 apply의 상단부분의 시간을 사용하는것을 확인할 수 있으며,
외부에서 Dict을 이용해서 만드는 DataFrame보다 Class내부 _construct로 만드는 것이 더 효율적이기 때문에
역시 normal한 사용 대비 빠른것을 확인할 수 있습니다.
5. 결론
- 수치연산 같은 특수한 함수가 아니면 apply로 쓰는게 무조건 빠르다(Cython 최적화 때문에)
- 연산자가 정의된 수치연산의 경우 function(Series)형태로 쓰는게 가장 빠르다.
도움이 되셨기를 바랍니다!
즐거운 하루되세요
'Python 잡지식 > 소스코드 톱아보기' 카테고리의 다른 글
[Pandas]groupby 동작에 대해서 (1편) (1) | 2023.12.06 |
---|---|
[Python]문자열 Encoding과 Python이 문자열을 처리하는 방법 (0) | 2023.12.06 |
[Pandas]inplace=True동작에 대해서 (0) | 2023.12.06 |
[Pandas]Series의 구조에 대해서 알아보자2 (1) | 2023.12.06 |
[Pandas]Series의 구조에 대해서 알아보자1 (0) | 2023.12.06 |
- Total
- Today
- Yesterday
- 완전탐색 알고리즘
- AVX
- heap
- 동적계획법
- Sort알고리즘
- 분할정복
- 병렬처리
- Search알고리즘
- 자료구조
- 컴퓨터그래픽스
- Python
- prime number
- 프로그래머스
- 코딩테스트
- hash
- stack
- GDC
- 사칙연산
- 알고리즘
- SIMD
- 이분탐색
- C++
- git
- Greedy알고리즘
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |