티스토리 뷰

728x90
반응형

안녕하세요. 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
        ...

결국

  1. grouping는 list와 같은 Sequence형태고,
  2. 이 안에 Element는 Grouping Object가 되며,
  3. 이 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를 해주는 방식을 취하는것을 알 수가 있습니다.

👀결국 정리해보면

  1. groupby를 할 DataFrame과 나눌 기준이될 Col을 설정
  2. 위 정보를 바탕으로 gropuby object가 만들어지고, 이 groupby object내의 grouper 라는 property에 핵심 정보가 저장
  3. grouper object를 이용해서 choping을 진행하고, generate 방식으로 table을 반환

의 순서로 동작한다는 것을 소스코드로 부터 유추할 수가 있습니다 :)

이제 groupby동작에 대해서 알았으니,
이 gropuby 다음에 apply또는 mean과같은 method chain이 어떻게 동작하는지 다음시간에 간단히 알아보겠습니다.

728x90
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
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
글 보관함