티스토리 뷰
안녕하세요. Teus입니다.
일반적인 1d array를 사용해서 ndarray를 만드는 프로그래밍 언어들의 list, array와 다르게
Pnadas의 경우 1d Series, 2d DataFrame이라는 형태로 Data를 보다 편리하게 관리합니다.
덕분에 pivot이나 groupby 같은 SQL에서 특화된 동작도 편하게 사용이 가능합니다.
이번 포스팅은, Pandas DataFrame의 .groupby method의 동작 원리에 대해서 알아보겠습니다.
1. DataFrame.groupby
groupby는 n개 이상의 col을 기준으로 group을 만들고, group마다 특정 function을 적용하는 method로,
SQL의 select Aggregation_method(col) group by grouping_col 과 동일한 결과를 얻을 수가 있습니다.
먼저, DataFrame Class의 groupby method를 확인해보겠습니다.
출처 : pandas/core/frame.py#L7688
class DataFrame:
...
def groupby(
self,
by=None,
axis: Axis = 0,
level: Level | None = None,
as_index: bool = True,
sort: bool = True,
group_keys: bool = True,
squeeze: bool | lib.NoDefault = no_default,
observed: bool = False,
dropna: bool = True,
) -> DataFrameGroupBy:
from pandas.core.groupby.generic import DataFrameGroupBy
'''
squeeze deprecation 관리 및 level or by 필수 입력 체크
'''
return DataFrameGroupBy(
obj=self,
keys=by,
axis=axis,
level=level,
as_index=as_index,
sort=sort,
group_keys=group_keys,
squeeze=squeeze, # type: ignore[arg-type]
observed=observed,
dropna=dropna,
)
첫 단계에서는 특별한게 없습니다.
입력받은 매개변수 중 일부가 정상적으로 들어왔는지 체크하고, 이를 Generic 함수인 DataFrameGroupBy로 넘겨줍니다.
출처 : pandas/core/groupby/groupby.py#768
#in pandas/core/gropuby/generic.py(L764)
class DataFrameGroupBy(GroupBy[DataFrame]):
class GroupBy(BaseGroupBy[NDFrameT]):
#internal Grouper class, which actually holds the generated groups
grouper: ops.BaseGrouper
as_index: bool
@final
def __init__(
self,
obj: NDFrameT,
keys: _KeysArgType | None = None,
axis: int = 0,
level: IndexLabel | None = None,
grouper: ops.BaseGrouper | None = None, #<= None이됨
exclusions: frozenset[Hashable] | None = None, #<= None이됨
selection: IndexLabel | None = None, #<= None이됨
as_index: bool = True,
sort: bool = True,
group_keys: bool = True,
squeeze: bool = False,
observed: bool = False,
mutated: bool = False, #<= False가됨
dropna: bool = True,
):
self._selection = selection
assert isinstance(obj, NDFrame), type(obj)
self.level = level
if not as_index:
if not isinstance(obj, DataFrame):
raise TypeError("as_index=False only valid with DataFrame")
if axis != 0:
raise ValueError("as_index=False only valid for axis=0")
self.as_index = as_index
self.keys = keys
self.sort = sort
self.group_keys = group_keys
self.squeeze = squeeze
self.observed = observed
self.mutated = mutated
self.dropna = dropna
if grouper is None:
from pandas.core.groupby.grouper import get_grouper
grouper, exclusions, obj = get_grouper(
obj,
keys,
axis=axis,
level=level,
sort=sort,
observed=observed,
mutated=self.mutated,
dropna=self.dropna,
)
self.obj = obj
self.axis = obj._get_axis_number(axis)
self.grouper = grouper
self.exclusions = frozenset(exclusions) if exclusions else frozenset()
문제가 위 소스코드 만으로는 groupby가 어떻게 동작하는이 이해하기가 어렵습니다.
현재 위 상황에서는 넘겨준 매개변수 기반으로 GroupBy Object를 생성하고,
이 Object는 self.gropuer라는 property를 가지고 있다는 것 정도를 주의깊게 살펴볼 수가 있습니다.
소스코드의 주석에서 힌트를 볼 수 있는데, 이 grouper라는 object가 grouping된 상태의 generate를 관리하는 것을 추측해 볼 수 있습니다.
그럼 이 grouper에 대해서 좀더 알아보겠습니다.
출처 : pandas/core/groupby/groupby.py
#pandas/core/groupby/grouper.py#L700
#get grouper는 결국 (ops.BaseGrouper, frozenset[Hashable], NDFrameT)
#형태로 return해주는것을 알 수 있음
def get_grouper(
obj: NDFrameT,
key=None,
axis: int = 0,
level=None,
sort: bool = True,
observed: bool = False,
mutated: bool = False,
validate: bool = True,
dropna: bool = True,
) -> tuple[ops.BaseGrouper, frozenset[Hashable], NDFrameT]:
출처 : pandas/core/groupby/ops.py#L636
class BaseGrouper:
axis: Index
def __init__(
self,
axis: Index,
groupings: Sequence[grouper.Grouping],
sort: bool = True,
group_keys: bool = True,
mutated: bool = False,
indexer: npt.NDArray[np.intp] | None = None,
dropna: bool = True,
):
assert isinstance(axis, Index), axis
self.axis = axis
self._groupings: list[grouper.Grouping] = list(groupings)
self._sort = sort
self.group_keys = group_keys
self.mutated = mutated
self.indexer = indexer
self.dropna = dropna
다른 부수적인 flag를 모두 걷어내면,
BaseGrouper의 groupings와 indexer Property가 주요한 정보를 담고있다는 것을 알 수 있습니다.
이때 groupings는 grouper.Grouping을 Sequence 형태로 가지고 있으며, 여기에 Groupby Col기준으로 Group을 나누고, 이를 list형태 또는 generate 형태로 저장하고 있는 것을 추측할 수가 있습니다.
다시 get_grouper로 돌아가 보겠습니다. 결국, Groupings를 이루는 Grouping이 무엇인지 확인할 필요가 있습니다.
#pandas/core/groupby/grouper.py#L700
#get grouper는 결국 (ops.BaseGrouper, frozenset[Hashable], NDFrameT)
#형태로 return해주는것을 알 수 있음
def get_grouper(
obj: NDFrameT,
key=None,
axis: int = 0,
level=None,
sort: bool = True,
observed: bool = False,
mutated: bool = False,
validate: bool = True,
dropna: bool = True,
) -> tuple[ops.BaseGrouper, frozenset[Hashable], NDFrameT]:
...
groupings: list[Grouping] = []
exclusions: set[Hashable] = set()
for gpr, level in zip(keys, levels):
if is_in_obj(gpr): # df.groupby(df['name'])
in_axis = True
exclusions.add(gpr.name)
elif is_in_axis(gpr): # df.groupby('name')
if gpr in obj:
if validate:
obj._check_label_or_level_ambiguity(gpr, axis=axis)
#여기서 col이름이 gpr인 DataFrame이 gpr에 할당됨
in_axis, name, gpr = True, gpr, obj[gpr]
if gpr.ndim != 1:
raise ValueError(f"Grouper for '{name}' not 1-dimensional")
exclusions.add(name)
elif obj._is_level_reference(gpr, axis=axis):
in_axis, level, gpr = False, gpr, None
else:
raise KeyError(gpr)
elif isinstance(gpr, Grouper) and gpr.key is not None:
# Add key to exclusions
exclusions.add(gpr.key)
in_axis = False
else:
in_axis = False
# create the Grouping
# allow us to passing the actual Grouping as the gpr
# 추출된 pandas Series를 가지고 Grouping Object를 만듦
ping = (
Grouping(
group_axis,
gpr,
obj=obj,
level=level,
sort=sort,
observed=observed,
in_axis=in_axis,
dropna=dropna,
)
if not isinstance(gpr, Grouping)
else gpr
)
groupings.append(ping)
...
# create the internals grouper
grouper = ops.BaseGrouper(
group_axis, groupings, sort=sort, mutated=mutated, dropna=dropna
)
# 이 부분에서 grouper는 groupby를 진행한 Col의 grouping 조합을
# generator 형태로 가지고 있음
# ex. DataFrame({a : [0,0,0,1,1,1,2,2,2], b : [0,0,0,0,0,3,3,3,3]})
# ==> grouper는 (0, 0), (1, 0), (1, 3), (2, 3)이 생김
return grouper, frozenset(exclusions), obj
...
중간 ping 임시변수에 Grouping Object가 할당되고, 이 오브젝트가 groupings안에 들어가게 됩니다.
그러므로 이 Grouping Class를 확인해야합니다.
출처 : pandas/core/groupby/grouper.py#L437
class Grouping:
_codes: np.ndarray | None = None
_group_index: Index | None = None
_passed_categorical: bool
_all_grouper: Categorical | None
_index: Index
def __init__(
self,
index: Index,
grouper=None,
obj: NDFrame | None = None,
level=None,
sort: bool = True,
observed: bool = False,
in_axis: bool = False,
dropna: bool = True,
):
self.level = level
self._orig_grouper = grouper
self.grouping_vector = _convert_grouper(index, grouper)
self._all_grouper = None
self._index = index
self._sort = sort
self.obj = obj
self._observed = observed
self.in_axis = in_axis
self._dropna = dropna
self._passed_categorical = False
...
결국
- grouping는 list와 같은 Sequence형태고,
- 이 안에 Element는 Grouping Object가 되며,
- 이 Grouping Object는 Grouping을 진행한 기본 Object와 특정 Col기준으로 Group을 만들기위한 정보가 있다
는 것을 알 수 있습니다.
2. grouper를 사용한 table split
이 grouper가 groupby object를 진행할때 사용되는 핵심 정보가 됩니다.
출처 : pandas/core/groupby/groupby.py#L752
@final
def __iter__(self) -> Iterator[tuple[Hashable, NDFrameT]]:
return self.grouper.get_iterator(self._selected_obj, axis=self.axis)
groupby 이후에 groupby object를 list를 풀었을 때
BaseGroupBy의 __iter__method를 사용하게 되고, 이 method는 self.grouper의 get_iterator함수를 이용합니다.
출처 : pandas/core/groupby/ops.py#L695
def get_iterator(
self, data: NDFrameT, axis: int = 0
) -> Iterator[tuple[Hashable, NDFrameT]]:
splitter = self._get_splitter(data, axis=axis)
keys = self.group_keys_seq
for key, group in zip(keys, splitter):
yield key, group.__finalize__(data, method="groupby")
grouper object의 정보를 바탕으로 _get_splitter method를 실행할 경우 grp로 설정한 grouping 기준으로 나눠진 table을 얻게되는 구조 입니다.
출처 : pandas/core/groupby/ops.py#L712, pandas/core/groupby/ops.py#1263
@final
def _get_splitter(self, data: NDFrame, axis: int = 0) -> DataSplitter:
ids, _, ngroups = self.group_info
return get_splitter(data, ids, ngroups, axis=axis)
def get_splitter(
data: NDFrame, labels: np.ndarray, ngroups: int, axis: int = 0
) -> DataSplitter:
if isinstance(data, Series):
klass: type[DataSplitter] = SeriesSplitter
else:
# i.e. DataFrame
klass = FrameSplitter
return klass(data, labels, ngroups, axis)
class FrameSplitter(DataSplitter):
def _chop(self, sdata: DataFrame, slice_obj: slice) -> DataFrame:
mgr = sdata._mgr.get_slice(slice_obj, axis=1 - self.axis)
return sdata._constructor(mgr)
class DataSplitter(Generic[NDFrameT]):
def __init__(
self,
data: NDFrameT,
labels: npt.NDArray[np.intp],
ngroups: int,
axis: int = 0,
):
self.data = data
self.labels = ensure_platform_int(labels) # _should_ already be np.intp
self.ngroups = ngroups
self.axis = axis
assert isinstance(axis, int), axis
...
def __iter__(self):
sdata = self.sorted_data
if self.ngroups == 0:
return
starts, ends = lib.generate_slices(self.slabels, self.ngroups)
for start, end in zip(starts, ends):
yield self._chop(sdata, slice(start, end))
chop을 통해서 Table이 짤리게 됩니다.
이때, 이 chop을 하기전에 labels기준으로 정렬된 DataFrame을 만들어주고,
이 DataFrame을 Labels(gropuby 로 지정한 col들 가지고 나눈 grouping key리스트) 기준으로 slice를 해주는 방식을 취하는것을 알 수가 있습니다.
👀결국 정리해보면
- groupby를 할 DataFrame과 나눌 기준이될 Col을 설정
- 위 정보를 바탕으로 gropuby object가 만들어지고, 이 groupby object내의 grouper 라는 property에 핵심 정보가 저장
- grouper object를 이용해서 choping을 진행하고, generate 방식으로 table을 반환
의 순서로 동작한다는 것을 소스코드로 부터 유추할 수가 있습니다 :)
이제 groupby동작에 대해서 알았으니,
이 gropuby 다음에 apply또는 mean과같은 method chain이 어떻게 동작하는지 다음시간에 간단히 알아보겠습니다.
'Python 잡지식 > 소스코드 톱아보기' 카테고리의 다른 글
[Pandas]groupby 동작에 대해서 (2편) (0) | 2023.12.06 |
---|---|
[Python]문자열 Encoding과 Python이 문자열을 처리하는 방법 (0) | 2023.12.06 |
[Pandas] Pandas의 apply동작에 대해서 (0) | 2023.12.06 |
[Pandas]inplace=True동작에 대해서 (0) | 2023.12.06 |
[Pandas]Series의 구조에 대해서 알아보자2 (1) | 2023.12.06 |
- Total
- Today
- Yesterday
- git
- stack
- 자료구조
- Sort알고리즘
- hash
- C++
- GDC
- Python
- prime number
- 분할정복
- 이분탐색
- 병렬처리
- 완전탐색 알고리즘
- SIMD
- 알고리즘
- Search알고리즘
- 프로그래머스
- 컴퓨터그래픽스
- Greedy알고리즘
- 사칙연산
- 동적계획법
- 코딩테스트
- heap
- AVX
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |