본문 바로가기
자격증/빅데이터 분석 기사

pandas groupby 결과를 원본에 붙일 때 NaN이 생기는 이유: transform과 인덱스 정렬

by char_lie 2026. 10. 18.
반응형

그룹 평균을 계산한 Series를 원본 DataFrame 열에 바로 넣었더니 NaN이 생겼다면, 두 결과의 인덱스를 비교하세요. 집계 결과는 그룹 키, 원본은 행 라벨을 기준으로 할 수 있습니다. 원본 각 행에 그룹 통계를 붙이려면 transform을 쓰거나, 그룹 키로 map을 적용하는 방식이 적합합니다.

아래 실습은 pandas 2.2.3·Python 3.12.14에서 실행했습니다. 이 글은 그룹이 빠지는 문제가 아니라 계산된 값을 어느 행에 배치할지를 다룹니다. 코드 블록은 위부터 같은 세션에서 실행하세요.

1. 그룹 인덱스와 원본 행 인덱스가 다릅니다

import pandas as pd

df = pd.DataFrame({
    "team": ["B", "A", "B", "A"],
    "sales": [20, 10, 40, 30],
}, index=[10, 20, 30, 40])

means = df.groupby("team")["sales"].mean()
df["wrong"] = means
print("row labels:", df.index.tolist())
print("group labels:", means.index.tolist())
print("group means:", means.to_dict())
print("missing assigned:", int(df["wrong"].isna().sum()))
row labels: [10, 20, 30, 40]
group labels: ['A', 'B']
group means: {'A': 20.0, 'B': 30.0}
missing assigned: 4

A의 평균은 20, B의 평균은 30으로 계산 자체는 맞습니다. 다만 Series를 열에 대입하면 값 두 개를 네 행에 알아서 반복하지 않고, 원본 행 라벨 10·20·30·40에 맞춰 정렬합니다. 그룹 라벨 A·B와 일치하지 않으므로 네 행이 모두 결측으로 채워졌습니다.

원본 행 라벨과 그룹 키가 달라 직접 정렬되지 않는 개념

2. transform으로 원본 행에 대응하는 결과를 만듭니다

group_mean = df.groupby("team")["sales"].transform("mean")
df["team_mean"] = group_mean
df["difference"] = df["sales"] - group_mean
print("same index:", group_mean.index.equals(df.index))
print("team mean:", df["team_mean"].tolist())
print("difference:", df["difference"].tolist())
same index: True
team mean: [30.0, 20.0, 30.0, 20.0]
difference: [-10.0, -10.0, 10.0, 10.0]

B 행에는 30, A 행에는 20이 들어가며 원본의 행 수와 순서, 행 라벨을 유지합니다. 그룹마다 한 값을 줄이는 mean 집계와 달리, transform은 그룹 계산을 각 원본 행에 대응하는 결과로 돌려줍니다. 이 예제처럼 그룹 평균 대비 차이·비율을 계산할 때 유용합니다.

한 그룹의 요약값을 각 원본 행에 반복해 대응하는 개념

3. reset_index나 to_numpy로 정렬 문제를 숨기지 마세요

try:
    df["wrong_array"] = means.to_numpy()
except ValueError as exc:
    print("four rows, two values:", type(exc).__name__)

tiny = pd.DataFrame({"team": ["B", "A"], "sales": [20, 10]})
tiny_means = tiny.groupby("team")["sales"].mean()
tiny["by_position"] = tiny_means.to_numpy()
tiny["by_group"] = tiny.groupby("team")["sales"].transform("mean")
print("team:", tiny["team"].tolist())
print("by position:", tiny["by_position"].tolist())
print("by group:", tiny["by_group"].tolist())
four rows, two values: ValueError
team: ['B', 'A']
by position: [10.0, 20.0]
by group: [20.0, 10.0]

to_numpy는 라벨을 버리고 위치로 넣습니다. 네 행에 값 두 개를 넣으면 길이 오류가 나지만, 우연히 그룹 수와 행 수가 같으면 오류 없이 다른 팀 평균을 넣을 수 있습니다. reset_index(drop=True)도 그룹별 결과를 원본 행별 결과로 바꾸지 않으므로 범용 해결책이 아닙니다.

행 개수가 맞는지만 확인하면 두 번째 반례를 놓칩니다. 같은 행 단위인지, 그 순서까지 일치하는지를 확인한 경우에만 라벨을 제거한 위치 대입을 선택하세요.

위치 순서와 키에 따른 대응이 달라지는 두 행 비교

4. 이미 만든 그룹 요약은 그룹 키로 map합니다

mapped = df["team"].map(means)
print("mapped:", mapped.tolist())
print("same values:", mapped.equals(group_mean))
assert mapped.index.equals(df.index)

missing_key = pd.DataFrame({
    "team": ["A", None],
    "sales": [10, 30],
})
default = missing_key.groupby("team")["sales"].transform("mean")
included = missing_key.groupby("team", dropna=False)["sales"].transform("mean")
print("default missing:", default.isna().tolist())
print("include missing key:", included.tolist())
mapped: [30.0, 20.0, 30.0, 20.0]
same values: True
default missing: [False, True]
include missing key: [10.0, 30.0]

map은 원본 team 값을 means의 그룹 키와 대응시킵니다. 이처럼 하나의 키로 요약한 Series를 다시 사용할 때 편합니다. 요약에 없는 키는 결측으로 나타나므로 모든 팀이 연결되는지도 확인하세요. 여러 키로 묶었다면 키 조합과 중복 제약을 확인한 merge 등 더 명시적인 연결이 필요합니다.

transform을 사용해도 NaN이 언제나 사라지는 것은 아닙니다. 기본 groupby는 결측 그룹 키를 제외하며, 값이 모두 결측인 그룹의 평균도 결측입니다. 위 예제의 dropna=False는 결측 키를 한 그룹으로 포함한다는 정책 선택입니다. 불필요하게 0으로 채우면 원인을 감출 수 있습니다.

그룹 키와 요약값을 같은 식별자로 연결하는 개념

적용 전 점검과 핵심 정리

그룹별 보고서가 필요하면 집계 결과를 그대로 사용하세요. 원본 각 행에 그룹 통계가 필요하면 transform, 이미 만든 단일 키 요약을 재사용하면 map을 검토합니다. 대입 전후 행 수·인덱스·대표 그룹의 값을 함께 대조하면 조용히 틀리는 배치를 찾기 쉽습니다.

그룹 자체가 누락되는 문제는 groupby의 dropna와 size·count 차이에서 따로 확인할 수 있습니다. 오늘 문제의 핵심은 계산한 통계의 정확성과 원본 행에 붙이는 정확성을 나누어 검사하는 것입니다.

반응형

댓글