포스트

NumPy, Pandas 기초 (5) — 쪼개고 세고 합치기: groupby·reindex·merge

split-apply-combine으로 이해하는 groupby, 없는 조합을 데이터로 만드는 reindex, SQL join에 대응하는 merge와 조용히 일어나는 행 증가·소실 사고까지, 집계와 결합의 핵심 개념을 정리했습니다.

NumPy, Pandas 기초 (5) — 쪼개고 세고 합치기: groupby·reindex·merge

NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 다섯 번째 글입니다. 3편4편에서 다룬 읽기·선택 도구를 전제로 합니다.

groupby — 분할, 적용, 결합

pandas에서 가장 강력한 도구 하나를 꼽으라면 groupby입니다. 개념은 한 문장입니다.

그룹으로 나누고(split) → 각 그룹에 연산을 적용하고(apply) → 결과를 합친다(combine)

1
2
df.groupby("course")["score"].mean()     # 과목별 평균 점수
df.groupby("course").size()              # 과목별 수강 인원

SQL의 GROUP BY와 같은 개념이라, SQL을 알면 그대로 대응됩니다. 다만 함수 선택에서 자주 틀리는 구분이 하나 있습니다.

함수세는 것
size()그룹의 행 수 (NaN 포함)
count()각 컬럼의 NaN이 아닌 값 수
sum(), mean(), …값의 집계

성적 데이터로 말하면 수강 인원은 size(), 응시 인원은 count()입니다. 미응시(점수 결측)가 있는 과목에서 둘은 다른 숫자이고, 구분 없이 쓰면 인원수가 미묘하게 어긋나는 원인이 됩니다.

여러 통계를 한 번에 — agg

그룹마다 여러 통계가 필요하면 agg에 이름과 함수를 지정합니다.

1
2
3
4
5
stats = df.groupby("course").agg(
    n_students=("score", "size"),
    score_mean=("score", "mean"),
    score_max=("score", "max"),
)

새컬럼명=("대상컬럼", "함수") 형태(named aggregation)를 쓰면 결과 컬럼 이름까지 한 번에 정리되어, 뒤에 rename을 덧붙이는 코드가 사라집니다.

transform — 그룹 통계를 원래 행에 되돌려 붙이기

agg는 그룹당 한 행으로 요약합니다. 그런데 “각 행이 자기 그룹 평균 대비 어디쯤인가”처럼, 그룹 통계를 원래 행 수를 유지한 채 붙여야 할 때가 있습니다. 그 용도가 transform입니다.

1
2
course_mean = df.groupby("course")["score"].transform("mean")
df["vs_course_mean"] = df["score"] - course_mean
 반환 크기용도
agg그룹당 1행요약표, 리포트
transform원래 행 수 유지파생 컬럼 — 그룹 기준 정규화, 평균 대비 편차

이것이 바로 상대평가의 계산 구조입니다. “과목 평균보다 12점 높다”는 학생별 값은 과목 단위 통계(평균)를 학생 단위 행에 되돌려 붙여야 나오는데, transform이 없으면 요약표를 만들어 merge로 되돌리는 왕복이 필요합니다. transform은 그 왕복을 한 줄로 줄인 것이고, 그룹 기준 표준화 같은 피처 엔지니어링의 단골 도구입니다.

그룹 키로 컬럼이 아니라 “변환된 Series”를 넘길 수 있다

실전 집계의 핵심 기술입니다. 과제 제출 로그에서 (1시간 단위 시간대 × 과목)별 제출 건수를 구하고 싶은데, 데이터에는 초 단위 제출 시각만 있다고 합시다. “시간대” 컬럼을 먼저 만들어야 할 것 같지만, groupby는 키 자리에 변환식 자체를 받아줍니다.

1
2
3
counts = submissions.groupby(
    [submissions["submitted_at"].dt.floor("h"), "course_id"]
).size()

4편에서 본 .dt.floor("h")23:47:2323:00:00으로 내려 그룹 키를 만들고, 그것과 과목의 조합으로 묶어 size()로 셉니다. 중간 컬럼을 만들지 않으니 원본을 오염시키지 않고, 한 줄로 의도가 읽힙니다.

키를 두 개 넘겼으므로 결과는 (시간, 과목) 2단 인덱스(MultiIndex)를 가진 Series가 됩니다. 이 구조가 다음 절로 이어집니다.

reindex — “없는 조합”을 데이터로 만들기

집계 결과에는 실제로 발생한 조합만 남습니다. 제출이 한 건도 없었던 (새벽 4시, 특정 과목) 조합은 행 자체가 없습니다.

그런데 제출 패턴 분석에서 “제출 0건”은 결측이 아니라 그 자체로 정보입니다. 마감 일주일 전에는 아무도 제출하지 않다가 마감 전날 밤에 몰린다는 패턴을 보려면, 조용했던 시간대의 행도 0으로 존재해야 합니다. 그래서 모든 (시간 × 과목) 조합의 완전한 그리드를 만들어 빈칸을 0으로 채웁니다.

1
2
3
4
5
hours = pd.date_range("2024-06-01", "2024-07-01", freq="h", inclusive="left")
grid = pd.MultiIndex.from_product(
    [hours, sorted(course_ids)], names=["hour", "course_id"]
)
submit = counts.reindex(grid, fill_value=0).rename("submit_count").reset_index()

세 단계로 읽으면:

  1. date_range — 한 달치 시간대를 1시간 간격으로 전부 생성 (720개)
  2. MultiIndex.from_product — 시간대 × 과목의 모든 조합 인덱스 생성 (과목이 40개면 720 × 40 = 28,800개)
  3. reindex(grid, fill_value=0) — 집계 결과를 이 그리드에 맞춰 재배열하고, 없던 조합은 0으로

마지막의 reset_index()는 MultiIndex를 일반 컬럼으로 되돌려서 이후 처리(저장, merge)를 편하게 만드는 마무리입니다.

groupby가 “있는 것을 세는” 도구라면, reindex는 “있어야 할 모양을 강제하는” 도구입니다. 이 조합은 성적 데이터 밖에서도 계속 반복됩니다 — 제출이 없던 시간대, 판매가 없던 날, 로그가 없던 구간. “없음”이 정보인 도메인이라면 그리드 채우기가 필수 단계입니다.

참고로 fill_value=0이라는 선택 자체는 문법이 아니라 도메인 판단입니다. “기록이 없음 = 실제로 0건”이 성립하는 데이터라서 0이 맞는 것이고, 센서 고장으로 기록이 없는 데이터였다면 0으로 채우는 순간 거짓말이 됩니다. 이 이야기는 6편의 결측치 처리에서 이어집니다.

pivot_table — groupby의 2차원 뷰

같은 집계를 “행 × 열” 표 모양으로 보고 싶을 때는 pivot_table을 씁니다.

1
2
3
4
5
6
heatmap = submissions.pivot_table(
    index=submissions["submitted_at"].dt.dayofweek,   # 행: 요일
    columns=submissions["submitted_at"].dt.hour,      # 열: 시각
    values="course_id",
    aggfunc="size",                                   # 건수
)

요일 × 시각의 제출 히트맵이 한 번에 나옵니다. 마감 전날 밤 칸이 유난히 진하게 나오는 것까지 확인하면 데이터가 실감 납니다. 내부적으로는 groupby + 모양 변환이므로 새로운 개념은 아니고, 분석·시각화 단계에서는 pivot_table, 파이프라인 데이터 산출에는 groupby(긴 형태 유지)가 일반적인 역할 분담입니다.

melt — 넓은 표를 긴 표로

pivot_table이 긴 표를 넓게 편다면, melt는 반대로 넓은 표를 길게 되돌립니다. 엑셀에서 온 성적표가 전형적인 대상입니다 — 과목이 컬럼으로 옆으로 퍼져 있는 형태 말입니다.

1
2
3
4
5
6
7
8
9
10
11
12
wide = pd.DataFrame({
    "student": ["김철수", "이영희"],
    "수학": [92, 85],
    "영어": [88, 95],
})

long = wide.melt(id_vars="student", var_name="subject", value_name="score")
#   student subject  score
# 0  김철수    수학     92
# 1  이영희    수학     85
# 2  김철수    영어     88
# 3  이영희    영어     95

이 변환이 필요한 이유는 pandas의 핵심 연산들 — groupby, 마스크 필터, merge — 이 전부 긴 형태(long format)를 전제로 설계되어 있기 때문입니다. “과목별로 컬럼이 늘어나는” 넓은 성적표에서는 새 과목이 생길 때마다 코드를 고쳐야 하지만, 긴 표에서는 행이 늘어날 뿐 코드는 그대로입니다. 넓은 표를 받으면 일단 melt로 길게 만들고 시작하는 것이 정석이고, 사람 눈에 보여줄 때만 pivot_table로 다시 폅니다.

merge — 데이터프레임의 SQL join

실무 데이터는 한 테이블에 다 들어있지 않습니다. 수강 기록에는 과목 ID만 있고, 과목 이름과 학점 수는 별도의 과목 마스터 테이블에 있는 식입니다. 이 둘을 키로 붙이는 것이 merge, SQL의 join입니다.

1
2
3
4
5
6
7
8
courses = pd.read_csv("courses.csv")   # id, name, credits

enrollments = enrollments.merge(
    courses,
    left_on="course_id",    # 왼쪽 테이블의 키
    right_on="id",          # 오른쪽 테이블의 키
    how="left",             # 왼쪽 행은 전부 유지
)

키 이름이 양쪽에서 같다면 on="course_id" 하나로 충분합니다. how가 SQL join 종류에 대응합니다.

how의미SQL
left왼쪽 행 전부 유지, 매칭 안 되면 NaNLEFT JOIN
inner양쪽 다 있는 키만 (pandas 기본값)INNER JOIN
outer양쪽 전부 유지FULL OUTER JOIN

“본 데이터에 참조 정보를 붙인다”는 전형적 사용에서는 how="left"가 표준입니다 — 참조 테이블에 없는 키 때문에 본 데이터 행이 사라지면 안 되기 때문입니다.

merge에서 조용히 일어나는 두 가지 사고

merge는 에러 없이 결과를 왜곡하는 사고 유형이 두 가지 있어서, 쓸 때마다 의식적으로 방어해야 합니다.

사고 1: 행이 늘어난다 (1:N 조인). 오른쪽 테이블에서 키가 중복되면, 왼쪽 한 행이 중복 수만큼 복제됩니다. 과목 마스터에 id 101이 실수로 두 번 있으면, 101번 과목의 모든 수강 기록이 두 배가 됩니다. 평점 평균과 취득 학점 합계가 통째로 틀어지는데 에러는 없습니다.

사고 2: 행이 사라진다 (inner 기본값). how를 지정하지 않으면 inner라서, 매칭 안 되는 키의 행이 조용히 삭제됩니다. “왜 행이 줄었지?”를 눈치채지 못하면 데이터 일부가 증발한 채 분석이 진행됩니다.

방어법은 두 가지를 습관화하는 것입니다.

1
2
3
4
5
6
# 1) 관계를 선언한다 — 어기면 즉시 에러 (조용한 사고를 시끄러운 에러로)
enrollments = enrollments.merge(courses, on="course_id", how="left",
                                validate="many_to_one")

# 2) 전후 행 수를 확인한다
assert len(enrollments) == n_before, f"merge로 행 수 변화: {n_before}{len(enrollments)}"

validate="many_to_one"은 “왼쪽은 여러 행이어도 되지만 오른쪽 키는 유일해야 한다”는 선언입니다. 참조 테이블에 중복이 생기는 순간 에러가 나므로, 사고 1이 원천 차단됩니다.

또 하나 — how="left"에서 매칭 실패로 생긴 NaN은 3편에서 예고한 대로 정수 컬럼을 float로 승격시킵니다. merge 후에 142.0 같은 값이 보인다면 그것이 매칭 실패의 흔적이고, 오히려 데이터 문제를 알려주는 탐지기로 쓸 수 있습니다(6편에서 상세히).

concat — 같은 구조를 이어붙이기

merge가 “옆으로 붙이기(키 매칭)”라면, concat은 “위아래로 쌓기”입니다. 학기별 성적 파일을 전체 이력으로 합치는 경우가 전형적입니다.

1
2
terms = [pd.read_parquet(f"grades_{t}.parquet") for t in ["2024-1", "2024-2", "2025-1"]]
history = pd.concat(terms, ignore_index=True)

ignore_index=True는 각 파일의 행 인덱스(0부터 시작)를 버리고 새로 매기는 옵션입니다. 빼먹으면 인덱스 0이 학기 수만큼 있는 DataFrame이 되는데, 당장은 문제없어 보이다가 .loc[0]이 12행을 반환하는 순간 문제가 됩니다.

구분 기준은 단순합니다. 컬럼 구조가 같고 행만 늘리는 것이면 concat, 키를 맞춰 컬럼을 붙이는 것이면 merge.

전체 흐름 — 도구들이 하나로 연결된다

이번 편까지의 도구들은 실전에서 하나의 집계 흐름으로 이어집니다. 과제 제출 로그를 일별·과목별 집계 테이블로 만드는 전형적인 예입니다.

1
2
3
4
5
6
7
8
9
10
df = pd.read_parquet("submissions.parquet",
                     columns=["submitted_at", "course_id"])       # 3편: 필요한 컬럼만
df = df.loc[df["submitted_at"].between(term_start, term_end,
                                       inclusive="left")]         # 4편: 마스크로 정제
counts = df.groupby(
    [df["submitted_at"].dt.floor("D"), "course_id"]
).size()                                                          # 5편: 날짜×과목 집계
daily = counts.reindex(grid, fill_value=0) \
              .rename("submit_count").reset_index()               # 5편: 완전한 그리드로
daily.to_parquet("daily_submissions.parquet", index=False)        # 3편: 타입 보존 저장

각 단계가 시리즈의 한 편씩에 대응합니다. 기초 문법이 그대로 데이터 파이프라인의 부품이 되는 셈입니다.

정리

개념한 줄 요약
groupbysplit → apply → combine. 수강 인원은 size(), 응시 인원은 count()
transform그룹 통계를 원래 행 수 그대로 — 그룹 기준 파생 컬럼
변환 키.dt.floor("h") 같은 변환식을 그룹 키로 직접 사용
reindex“있어야 할 모양”을 강제. 없음이 정보인 도메인의 필수 단계
pivot_table / melt긴 표 ↔ 넓은 표. 연산은 긴 형태, 표시용만 넓게
mergeSQL join. 기본값 inner 주의, validate + 행 수 확인 습관
concat같은 구조를 위아래로. ignore_index=True

이제 도구는 다 갖췄습니다. 마지막 편은 도구가 아니라 사고를 다룹니다 — 에러 없이 조용히 틀리는 지점들(결측치, dtype 승격, 뷰/복사)과, 계속 업데이트되는 라이브러리와 함께 사는 법입니다.

다음 글: NumPy, Pandas 기초 (6) — 조용히 틀리는 것들

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.