Data Science를 학습하면서, 전처리에 많이 사용되는 groupby를 정리해본다. 예제 데이터는 아래를 가정한다.
import pandas as pd
df # columns: SEASON, A, B, C (SEASON은 반복되는 범주값)
1. 개요 — Series가 모여 DataFrame이 된다
1.1 Series = 값 배열 + 인덱스
Series는 1차원이고, 값과 라벨(인덱스)이 짝지어진 구조다.
s = pd.Series([10, 20, 30], index=['x', 'y', 'z'], name='A')
x 10
y 20
z 30
Name: A, dtype: int64
s.values→array([10, 20, 30])s.index→Index(['x','y','z'])s.name→'A'(나중에 DataFrame의 열 이름이 된다)
1.2 DataFrame = 인덱스를 공유하는 Series들의 모음
df = pd.DataFrame({
'A': pd.Series([10, 20, 30], index=['x','y','z']),
'B': pd.Series([1.0, 2.0, 3.0], index=['x','y','z']),
})
A B
x 10 1.0
y 20 2.0
z 30 3.0
핵심은 축이 두 개라는 점이다.
| 구성요소 | 의미 |
|---|---|
df.index |
행 라벨 (axis=0) |
df.columns |
열 라벨 (axis=1) |
df['A'] |
열 하나 → Series |
df[['A','B']] |
열 여러 개 → DataFrame |
df.loc['x'] |
행 하나 → Series (인덱스가 열 이름이 된다) |
대괄호 하나(df['A'])와 둘(df[['A']])의 차이가 이후 전부에 영향을 준다. 하나는 Series, 둘은 DataFrame이다.
1.3 MultiIndex = 라벨이 여러 층
인덱스의 한 칸이 단일 값이 아니라 튜플인 경우다.
mi = pd.MultiIndex.from_tuples([('2022','A'), ('2022','B'), ('2023','A')],
names=['SEASON', 'VAR'])
SEASON VAR
2022 A
B
2023 A
mi.nlevels→ 2level=0→ 바깥층(SEASON),level=1→ 안쪽층(VAR)- 이름이 있으면 숫자 대신 이름 사용 가능:
level='SEASON'
groupby의 결과나 corr(), describe(), pivot 계열은 MultiIndex를 자주 만들어낸다. 그래서 "층을 어떻게 골라내고 없애느냐"가 실무의 대부분이다.
2. groupby 개요
2.1 split - apply - combine
df.groupby('SEASON')['A'].mean()
- split —
SEASON값이 같은 행끼리 묶는다 - apply — 각 묶음에
mean()을 적용한다 - combine — 결과를 다시 하나로 합친다. 그룹 키가 결과의 인덱스가 된다
SEASON
2022 18.3
2023 21.7
Name: A, dtype: float64
2.2 groupby 객체 자체는 계산을 하지 않는다
g = df.groupby('SEASON')
g # <DataFrameGroupBy object ...> ← 아직 아무것도 안 함
g.ngroups # 그룹 개수
g.size() # 그룹별 행 수
g.groups.keys() # 그룹 키 목록
g.get_group('2022') # 특정 그룹의 원본 행들
list(g)[0] # ('2022', DataFrame) 튜플
집계 함수(mean, agg, sum 등)를 붙이는 순간 실제로 계산된다. 디버깅할 때 get_group으로 한 그룹만 꺼내 확인하는 습관이 유용하다.
2.3 반환 형태 규칙
df.groupby('SEASON')['A'].mean() # Series (열 하나 → 1차원)
df.groupby('SEASON')[['A']].mean() # DataFrame (대괄호 둘)
df.groupby('SEASON')[['A','B']].mean() # DataFrame
df.groupby(['SEASON','TEAM'])['A'].mean() # Series, 인덱스는 MultiIndex
2.4 자주 쓰는 옵션
df.groupby('SEASON', as_index=False) # 그룹 키를 인덱스가 아닌 일반 열로
df.groupby('SEASON', sort=False) # 그룹 키 정렬 안 함 (등장 순서 유지, 약간 빠름)
df.groupby('SEASON', dropna=False) # 키가 NaN인 행도 하나의 그룹으로
df.groupby('SEASON', observed=True) # Categorical 키에서 실제 등장한 조합만
as_index=False를 안 썼다면 나중에 .reset_index()로 같은 효과를 낼 수 있다.
2.5 agg / transform / apply 구분
셋의 차이를 헷갈리면 이후가 전부 꼬인다.
| 메서드 | 그룹당 반환 | 결과 행 수 | 용도 |
|---|---|---|---|
agg |
스칼라 1개 | 그룹 수 | 평균, 합, max-min |
transform |
원본과 같은 길이 | 원본 행 수 | 그룹평균 붙이기, 정규화 |
apply |
자유 (스칼라/Series/DataFrame) | 상황에 따라 | 복잡한 그룹 로직 |
# agg: 시즌별 평균 (행 수 = 시즌 수)
df.groupby('SEASON')['A'].mean()
# transform: 각 행에 자기 시즌 평균을 붙인다 (행 수 = 원본)
df['A_season_mean'] = df.groupby('SEASON')['A'].transform('mean')
df['A_z'] = df.groupby('SEASON')['A'].transform(lambda s: (s - s.mean()) / s.std())
# apply: 여러 열을 동시에 봐야 할 때
df.groupby('SEASON').apply(
lambda g: g['A'].corr(g['B']),
include_groups=False, # pandas 2.2+ 경고 방지
)
apply는 유연하지만 느리다. agg 또는 transform으로 표현 가능하면 그쪽을 쓴다.
3. groupby + corr 결과 인덱싱
3.0 만들어지는 구조 확인
c = df.groupby('SEASON')[['A', 'B', 'C']].corr()
문법 주의:
df.groupby('SEASON').['A','B','C']는 오류. 대괄호를 두 번 쓴다 →[['A','B','C']]
A B C
SEASON VAR
2022 A 1.00 0.35 -0.12
B 0.35 1.00 0.48
C -0.12 0.48 1.00
2023 A 1.00 0.71 0.05
B 0.71 1.00 0.22
C 0.05 0.22 1.00
2024 A 1.00 0.19 0.60
B 0.19 1.00 -0.33
C 0.60 -0.33 1.00
- 행 인덱스:
(SEASON, VAR)2층 - 열:
A, B, C - 시즌마다 3×3 대칭행렬이 세로로 쌓인 모양
- 대각선은 항상 1.0, 상삼각과 하삼각은 중복. 실제 정보는 쌍 3개(
A-B,A-C,B-C)뿐이다
c.index.names # ['SEASON', None]
c.index[0] # ('2022', 'A')
3.1 인덱스로 직접 접근 (loc)
스칼라 하나
c.loc[('2022', 'A'), 'B'] # 2022의 corr(A, B) → 0.35
행 라벨이 튜플이므로 ('2022','A')를 통째로 넘긴다.
한 시즌의 행렬 전체
c.loc['2022']
A B C
VAR
A 1.00 0.35 -0.12
B 0.35 1.00 0.48
C -0.12 0.48 1.00
바깥층은 전부, 안쪽층은 특정 값 — slice(None) 사용
c.loc[(slice(None), 'A'), :] # 모든 시즌의 A행
c.loc[(slice(None), 'A'), 'B'] # 모든 시즌의 corr(A,B)
slice(None)이 눈에 거슬리면 IndexSlice가 같은 뜻이다.
idx = pd.IndexSlice
c.loc[idx[:, 'A'], 'B']
c.loc[idx['2022':'2023', 'A'], :] # 시즌 범위 + 안쪽층 지정
이 방식의 결과는 인덱스가 여전히 2층이다.
c.loc[idx[:, 'A'], 'B'].idxmax() # ('2023', 'A') ← 튜플이 나온다
시즌 라벨만 원하면 [0]을 떼거나, 아래 xs를 쓴다.
MultiIndex 정렬 경고 loc에 슬라이스를 쓰다가 UnsortedIndexError가 나면 먼저 정렬한다.
c = c.sort_index()
3.2 xs로 단면 자르기
xs = cross-section. 지정한 층의 라벨이 일치하는 행만 골라내고, 그 층을 인덱스에서 제거한다.
c.xs('A', level=1)
A B C
SEASON
2022 1.00 0.35 -0.12
2023 1.00 0.71 0.05
2024 1.00 0.19 0.60
동작을 두 단계로 풀면:
VAR == 'A'인 행만 남긴다 → 시즌당 1행VAR층은 전부'A'라 정보가 없으므로 인덱스에서 떨어뜨린다
읽는 법: "각 시즌에서 A가 다른 변수와 갖는 상관계수". A 열은 자기 자신이므로 항상 1.00이고, 쓸모 있는 건 B, C 열이다.
인덱스가 1층이 됐으므로 이후는 평범한 DataFrame이다.
c.xs('A', level=1)['B']
SEASON
2022 0.35
2023 0.71
2024 0.19
Name: B, dtype: float64
시즌별 corr(A,B) Series. idxmax, max, sort_values, plot 전부 그대로 동작한다.
c.xs('A', level=1)['B'].idxmax() # '2023' ← 튜플이 아니라 라벨
level을 생략하면 뜻이 달라진다
c.xs('A', level=1) # 안쪽층(VAR)에서 'A' 찾기 ← 의도한 것
c.xs('2022') # 바깥층(level=0 기본)에서 찾기 → 한 시즌 행렬
c.xs('A') # 바깥층에서 'A'를 찾다가 KeyError
기본값이 level=0이므로 안쪽층을 노릴 때 level=1은 필수다.
층을 남기고 싶으면
c.xs('A', level=1, drop_level=False)
대칭성 상관행렬은 대칭이므로 어느 방향으로 뽑아도 값이 같다.
c.xs('A', level=1)['B'] # A행의 B열
c.xs('B', level=1)['A'] # B행의 A열 → 동일한 값
실전 조회 패턴
ab = c.xs('A', level=1)['B'] # 시즌별 corr(A, B)
ac = c.xs('A', level=1)['C'] # 시즌별 corr(A, C)
bc = c.xs('B', level=1)['C'] # 시즌별 corr(B, C)
s_best = ab.idxmax() # corr(A,B) 최대 시즌
ab.max() # 그때의 corr(A,B) 값
ac.loc[s_best] # 그 시즌의 corr(A,C)
bc.loc[s_best] # 그 시즌의 corr(B,C)
bc.loc[ac.idxmax()] # corr(A,C) 최대 시즌의 corr(B,C)
ab.nlargest(3) # corr(A,B) 상위 3시즌
3.3 unstack으로 평탄화 (flat)
무슨 일이 일어나는가
unstack()은 행 인덱스의 가장 안쪽 층을 뽑아 열로 올려붙인다. 시즌마다 3행이었던 것이 시즌마다 1행이 된다.
flat = c.unstack()
A B C
VAR A B C A B C A B C
SEASON
2022 1.00 0.35 -0.12 0.35 1.00 0.48 -0.12 0.48 1.00
2023 1.00 0.71 0.05 0.71 1.00 0.22 0.05 0.22 1.00
2024 1.00 0.19 0.60 0.19 1.00 -0.33 0.60 -0.33 1.00
- 행 인덱스:
SEASON1층으로 줄었다 - 열 인덱스: 2층으로 늘었다 (3 × 3 = 9개 열)
- 층의 총 개수는 보존된다. 행에서 뺀 만큼 열로 옮겨갔을 뿐이다
열 튜플 읽는 규칙 (가장 헷갈리는 지점)
flat.columns
# MultiIndex([('A','A'), ('A','B'), ('A','C'),
# ('B','A'), ('B','B'), ('B','C'),
# ('C','A'), ('C','B'), ('C','C')])
(원래_열이름, 올라온_VAR값)— 기존 열이 겉층, 새로 올라온 층이 안층.
따라서 ('B','A') = "원래 표에서 VAR='A' 행의 B 열" = corr(A, B).
flat[('B', 'A')] # 시즌별 corr(A, B)
flat['B']['A'] # 같은 것, 두 단계 접근
flat['B'] # B 겉층 전체 묶음
사람이 읽기 좋게 이름 붙이기
flat.columns = [f'{b}_{a}' for a, b in flat.columns]
a = 겉층(원래 열), b = 안층(VAR). 순서를 뒤집어 ('B','A') → 'A_B' 로 만든다.
A_A B_A C_A A_B B_B C_B A_C B_C C_C
↑ ↑ ↑
1.0 corr(A,B) corr(B,C)
대각선(A_A, B_B, C_C)은 1.0이고 B_A는 A_B의 중복이므로, 의미 있는 3개만 남긴다.
flat = flat[['A_B', 'A_C', 'B_C']]
A_B A_C B_C
SEASON
2022 0.35 -0.12 0.48
2023 0.71 0.05 0.22
2024 0.19 0.60 -0.33
시즌이 행, 상관쌍이 열인 평범한 표가 됐다. 조회가 한 줄로 끝난다.
flat.loc[flat['A_B'].idxmax(), 'A_C'] # corr(A,B) 최대 시즌의 corr(A,C)
flat.loc[flat['A_C'].idxmax(), 'A_B']
flat.nlargest(3, 'A_B') # 상위 3시즌 전체 비교
flat.sort_values('B_C', ascending=False)
flat.corr() # 쌍끼리의 관계
flat.plot() # 시즌 추이 그래프
unstack(level=0) — 방향 반대
c.unstack(level=0) # == c.unstack('SEASON')
A B ...
SEASON 2022 2023 2024 2022 2023 2024
VAR
A 1.00 1.00 1.00 0.35 0.71 0.19
B 0.35 0.71 0.19 1.00 1.00 1.00
C -0.12 0.05 0.60 0.48 0.22 -0.33
변수가 행, 시즌이 열. 시즌 수가 많으면 열이 폭발하므로 보통 기본값(VAR을 올리는 쪽)이 실용적이다.
역연산 stack
flat.stack() # 열 층을 행으로 내린다 → 원래 c 모양에 가깝게 복귀
3.4 행렬을 거치지 않는 대안
corr()은 대각선과 중복을 모두 계산한다. 쌍 값만 필요하면 처음부터 직접 만드는 게 가볍고 명확하다.
pairs = df.groupby('SEASON').apply(
lambda g: pd.Series({
'A_B': g['A'].corr(g['B']),
'A_C': g['A'].corr(g['C']),
'B_C': g['B'].corr(g['C']),
}),
include_groups=False,
)
pairs.loc[pairs['A_B'].idxmax()]
또는 reset_index로 아예 긴 형태(long format)로 펴는 방법.
t = c.reset_index().rename(columns={'level_1': 'VAR'})
t.loc[t['VAR'] == 'A', ['SEASON', 'B', 'C']]
3.5 세 방식 비교
| 방식 | 결과 인덱스 | idxmax() 반환 |
언제 |
|---|---|---|---|
loc / IndexSlice |
2층 유지 | 튜플 ('2023','A') |
스칼라 하나, 범위 슬라이싱 |
xs(..., level=1) |
1층으로 축소 | 라벨 '2023' |
특정 변수 기준 조회 (가장 흔함) |
unstack + 이름정리 |
1층 + 열 평탄 | 라벨 '2023' |
여러 쌍을 나란히 비교/정렬/시각화 |
4. groupby().agg() 사용법
4.1 named aggregation — 새컬럼명=(원본컬럼, 함수)
권장 기본형이다. 결과 열이 평평한 단일 레벨로 나와 후처리가 없다.
out = df.groupby('SEASON').agg(
A_mean=('A', 'mean'),
B_range=('B', lambda s: s.max() - s.min()),
)
A_mean B_range
SEASON
2022 18.3 7.2
2023 21.7 5.4
lambda 대신 이름 있는 함수를 쓰면 재사용과 가독성이 좋다.
def rng(s):
return s.max() - s.min()
df.groupby('SEASON').agg(A_mean=('A', 'mean'), B_range=('B', rng))
한 열에 여러 집계, 서로 다른 열을 자유롭게 섞을 수 있다.
df.groupby('SEASON').agg(
A_mean=('A', 'mean'),
A_std=('A', 'std'),
B_max=('B', 'max'),
B_min=('B', 'min'),
B_range=('B', rng),
C_median=('C', 'median'),
N=('A', 'size'), # 행 수 (NaN 포함)
N_valid=('A', 'count'), # 결측 제외 개수
A_p90=('A', lambda s: s.quantile(0.9)),
)
제약: 키워드 인자 이름이므로 파이썬 식별자여야 한다. SALES 2023=(...) 같은 건 불가.
4.2 dict 방식 — agg({열: 함수})
df.groupby('SEASON').agg({'A': 'mean', 'B': rng}) # 단일 → 열이 평평
df.groupby('SEASON').agg({'A': ['mean', 'std'], 'B': [rng]}) # 다중 → 열이 MultiIndex
다중일 때 결과:
A B
mean std rng
SEASON
2022 18.3 3.1 7.2
평탄화는 습관적으로 붙여두면 편하다.
g = df.groupby('SEASON').agg({'A': ['mean','std'], 'B': [rng]})
g.columns = ['_'.join(col) for col in g.columns] # A_mean, A_std, B_rng
g = g.reset_index()
dict가 유리한 경우
1) 열이 많고 집계가 반복적일 때
num_cols = df.select_dtypes('number').columns
df.groupby('SEASON').agg({c: 'mean' for c in num_cols})
named agg로는 **{f'{c}_mean': (c,'mean') for c in num_cols} 언패킹이 필요해 장황하다.
2) 집계 스펙을 외부(설정/인자)에서 주입할 때
spec = {'A': 'mean', 'B': rng, 'C': ['min', 'max']}
df.groupby('SEASON').agg(spec)
3) MultiIndex 열을 그대로 활용할 때
g = df.groupby('SEASON').agg({'A': ['mean','std'], 'B': ['mean','std']})
g.stack(level=0) # 변수 × 통계량 형태로 재배치
g['A']['mean'] # 계층 접근
named agg는 항상 1층이라 이런 재배치가 안 된다.
4) 열 이름이 파이썬 식별자가 아닐 때 — 'SALES 2023', '매출/원' 등
5) pivot_table의 aggfunc — named agg 문법 자체가 없다
df.pivot_table(index='SEASON', aggfunc={'A': 'mean', 'B': rng})
주의: dict 안에서 이름 바꾸기는 제거됐다
pandas 0.20 deprecated → 2.0 제거. 아래는 모두 에러다.
df.groupby('SEASON').agg({'A': {'평균': 'mean'}}) # ✗
df.groupby('SEASON')['A'].agg({'평균': 'mean'}) # ✗
이름을 정하려면 named agg를 쓰거나, dict로 집계한 뒤 rename한다.
4.3 Series에 대한 agg
열 하나만 다룰 때는 리스트로 넘기면 된다.
df.groupby('SEASON')['A'].agg(['mean', 'std', 'max', rng])
df.groupby('SEASON')['A'].agg(평균='mean', 범위=rng) # Series named agg (가능)
4.4 자주 쓰는 집계 문자열
'mean' 'median' 'sum' 'min' 'max' 'std' 'var' 'count' 'size' 'first' 'last' 'nunique' 'idxmax' 'idxmin' 'any' 'all' 'sem' 'skew' 'prod'
문자열은 내부 최적화 경로를 타서 lambda보다 훨씬 빠르다. 가능하면 문자열을 쓴다.
countvssize:count는 NaN 제외,size는 포함idxmax: 그룹 내 최대값의 인덱스 라벨을 돌려준다
4.5 두 방식 선택 기준
| 상황 | 선택 |
|---|---|
| 열 몇 개, 결과 이름을 명시하고 싶다 | named agg |
| 열을 동적으로 생성 / 스펙 주입 | dict |
MultiIndex 열로 후처리 (stack 등) |
dict |
| 열 이름이 파이썬 식별자가 아니다 | dict |
pivot_table |
dict |
한 agg 호출 안에서 두 방식을 섞을 수는 없다. 하나만 고른다.
5. 자주 밟는 함정
| 증상 | 원인 / 해결 |
|---|---|
df.groupby('S').['A','B'] 문법 오류 |
[['A','B']] 대괄호 두 번 |
xs('A')에서 KeyError |
level 기본값이 0. 안쪽층은 level=1 명시 |
idxmax()가 튜플을 반환 |
loc/IndexSlice는 층을 남긴다. xs나 unstack을 쓰거나 [0]으로 꺼낸다 |
UnsortedIndexError |
c = c.sort_index() 후 슬라이싱 |
unstack 후 열 접근 실패 |
튜플 순서는 (원래열, 올라온층). 이름 붙일 때 뒤집는다 |
| agg 결과 열이 2층 | dict + 다중 함수. '_'.join으로 평탄화 |
agg({'A': {'평균':'mean'}}) 에러 |
제거된 문법. named agg 사용 |
apply에서 그룹 키 관련 FutureWarning |
include_groups=False 추가 (pandas 2.2+) |
np.ptp 사용 시 경고 |
직접 lambda s: s.max()-s.min() 정의 |
| 결과 행 수가 예상과 다름 | agg(그룹 수) vs transform(원본 수) 혼동 |
| 그룹 키가 인덱스로 들어가 불편 | as_index=False 또는 .reset_index() |
| 키가 NaN인 행이 사라짐 | 기본 dropna=True. dropna=False |
6. 한눈 치트시트
# 구조 확인
c.index.names / c.index.nlevels / c.columns
# 기본 집계
df.groupby('SEASON')['A'].mean() # Series
df.groupby('SEASON')[['A','B']].mean() # DataFrame
df.groupby('SEASON', as_index=False)['A'].mean() # 키를 열로
# corr 결과 인덱싱
c = df.groupby('SEASON')[['A','B','C']].corr()
c.loc[('2022','A'), 'B'] # 스칼라
c.loc['2022'] # 한 시즌 행렬
c.xs('A', level=1) # A행만, 층 제거
c.xs('A', level=1)['B'] # 시즌별 corr(A,B)
flat = c.unstack()
flat.columns = [f'{b}_{a}' for a, b in flat.columns]
flat = flat[['A_B','A_C','B_C']]
# 조회
ab, ac = flat['A_B'], flat['A_C']
ac.loc[ab.idxmax()] # A_B 최대 시즌의 A_C
flat.nlargest(3, 'A_B')
# agg
df.groupby('SEASON').agg(A_mean=('A','mean'), B_range=('B', rng))
df.groupby('SEASON').agg({'A': ['mean','std'], 'B': [rng]})
# transform (원본 행 수 유지)
df['A_dev'] = df['A'] - df.groupby('SEASON')['A'].transform('mean')'Machine Learning' 카테고리의 다른 글
| LLM 시스템은 어떻게 설계해야 하는가 — 데이터, 아키텍처, 그리고 SFT + QLoRA (0) | 2026.04.29 |
|---|---|
| LLM은 왜 틀릴까? — 그리고 RAG vs SFT, 언제 무엇을 써야 하는가 (0) | 2026.04.22 |
| RAG 노코드 Dify로 맞춤형 챗봇 만들기 2/2 (0) | 2025.12.11 |
| RAG 노코드 Dify로 맞춤형 챗봇 만들기 1/2 (0) | 2025.12.10 |
| AI Coding Tools 비교 (0) | 2025.09.27 |
