14 결합과 병합
데이터 분석에서 “결합(concatenation)”과 “병합(merge/join)”은 모두 여러 데이터셋을 하나로 합치는 과정이지만, 목적과 방식에 차이가 있습니다.
결합(concatenation)은 행 또는 열을 단순히 이어붙이는 작업입니다. 예를 들어, 여러 파일에서 읽어온 동일한 구조의 표(table)를 세로로 쌓거나, 관련 없는 정보를 옆에 붙여서 열을 확장할 때 사용합니다. pandas에서는 주로
concat()함수를 사용하며, 단일 축을 따라 데이터를 단순히 합치는 데 초점을 둡니다. 즉, ’누적’이나 ’연장’의 의미에 가깝습니다.병합(merge/join)은 두 데이터프레임에 존재하는 공통 키(열) 값을 기준으로, 관계형 데이터베이스의 join처럼 논리적으로 데이터를 연결하는 작업입니다. 각 데이터세트에서 지정한 키의 값이 일치하는 행을 맞춰 하나의 표로 만드는 것에 초점이 있습니다. pandas에서는
merge()혹은join()메서드를 사용하며, 현실 세계의 마스터 데이터(고객/거래, 제품/판매정보 등)를 통합할 때 자주 활용됩니다.
결합은 구조가 같은 여러 테이블을 수평/수직으로 ’단순 연결’이며, 병합은 서로 다른 정보가 있는 두 테이블을 ’논리적 연결(관계형)’입니다. 데이터 분석 과정에서 보통 여러 파일이나 기간별 데이터를 합칠 때는 결합을 사용하고, 서로 다른 유형의 관련 정보를 하나의 표로 만들 때는 병합을 사용합니다.
14.1 결합: concat() 함수
pandas의 concat() 함수는 여러 개의 데이터프레임이나 시리즈 객체를 한 번에 결합할 때 사용하는 매우 중요한 함수입니다. 다양한 데이터 구조를 쉽고 빠르게 결합할 수 있는 표준적인 방법으로 자리잡고 있습니다. 특히 테이블 구조가 동일한 데이터셋을 세로(행) 혹은 가로(열) 방향으로 이어붙이는 경우에 많이 사용됩니다.
concat() 함수의 주요 개념 및 기본 동작에 관한 주요 사항은 다음과 같습니다.
축 방향 지정:
concat()함수는 반드시 결합하고자 하는 축(axis)을 명시해야 합니다. 기본값은axis=0으로, 이 경우 인덱스를 따라(아래로) 데이터를 이어붙입니다.axis=1로 지정하면 열 방향(옆으로) 결합합니다.인덱스 관리: 결합되는 데이터프레임의 인덱스가 중복될 수 있는데, 필요하면
ignore_index=True옵션으로 새로운 RangeIndex를 부여할 수 있습니다.열/행 불일치 처리: 결합 대상 간에 열(혹은 행)이 다르면, 존재하지 않는 부분은 자동으로
NaN으로 채워집니다.키 기반 계층적 인덱싱:
keys옵션을 사용하면, 각 데이터프레임의 출처를 알 수 있도록 계층적 멀티인덱스(MultiIndex)를 생성할 수 있습니다.조인 방식: 기본적으로
outer조인(전체 열, 결측은 NaN)이 사용되지만,join='inner'로 공통 열 또는 행만 남길 수도 있습니다.
아래는 실제로 대표적으로 사용하는 세로, 가로 결합의 예시를 보여줍니다. 첫 번째 결합은 두 데이터프레임을 아래로 이어붙이고 인덱스를 새로 부여합니다(ignore_index=True). 두 번째 결합은 양쪽 데이터프레임의 동일한 인덱스를 기준으로 옆으로 붙여 새로운 열을 만듭니다.
만약 행방향으로 결합하는 데이터프레임 간에 열 이름이 다르면, 없는 부분은 NaN으로 자동 채워집니다. df1과 df2는 서로 다른 열 이름을 가지고 있습니다. pd.concat([df1, df2])는 두 데이터프레임을 기본(axis=0) 행 방향으로 결합합니다. 두 데이터프레임의 열 이름이 다르기 때문에, 각 데이터프레임에 없는 열 위치에는 자동으로 NaN이 채워집니다. 요약하자면, concat() 함수로 행방향 결합할 때 열 이름이 다르면 자동으로 NaN이 삽입되어 결손 데이터가 생기는 것이 기본 동작입니다. 이는 여러 데이터셋을 종종 결합해야 하는 실무 데이터 작업에서 매우 유용하며, 결측 처리에 유의해야 함을 보여주는 예시입니다.
다음은 계층 인덱스를 활용하는 예시입니다. 또, 계층 인덱스를 활용하고 싶다면 다음과 같이 keys 옵션을 사용할 수 있습니다. 이렇게 하면, 첫 번째 레벨 인덱스에 각 데이터프레임의 이름(x1, x2)이 표시되어, 데이터 출처를 손쉽게 추적할 수 있습니다.
여러 파일에 흩어진 동일 구조의 데이터를 읽은 뒤 리스트로 모아 concat()으로 한 번에 결합하는 패턴이 많이 쓰입니다. 이렇게 하면, 각 데이터프레임을 순차적으로 결합하는 것보다 훨씬 더 빠르고 효율적으로 결합할 수 있습니다. 다양한 데이터 정제 단계에서 중복 인덱스나 혼합 컬럼이 발생할 수 있으므로, 결합 전후 인덱스와 컬럼의 일관성을 반드시 확인하는 것이 좋습니다. 이처럼 pandas의 concat() 함수는 데이터 전처리 단계에서 테이블 결합이 필요한 거의 모든 상황에 유연하게 활용할 수 있는 강력한 도구입니다.
14.2 병합: merge() 함수
데이터 분석의 핵심 철학인 Tidy Data는 세 가지 원칙[1]으로 정의됩니다. 다시 한번 요약하면 첫째, 각 변수는 하나의 열을 형성해야 하며, 둘째, 각 관측치는 하나의 행을 형성해야 하며, 셋째, 각 관측 단위는 하나의 테이블을 형성해야 합니다. 따라서, 잘 구성된 데이터는 관측 단위에 따라서 하나의 테이블을 구성하고 있습니다. 데이터 분석을 진행하는 경우 여러 데이터셋을 하나의 테이블로 병합하는 것이 필요합니다. pandas의 merge()는 데이터를 병합하는 핵심 도구입니다. merge() 함수를 사용할 때, 데이터를 어떻게 병합할지 전략이 있어야 하고, 해당 전략에 따라 적절한 매개변수를 사용해야 합니다.
merge()는 두 테이블의 공통 속성을 기준으로 행을 결합합니다. 이런 개념은 데이터베이스의 Join에서 가져온 것으로, 데이터베이스의 관계대수에서 수학적 정의는 다음과 같습니다.
\[ R \bowtie S = \left\{\, (r, s)\;\middle|\; r \in R,\, s \in S,\, \text{조건 만족}(r, s) \,\right\} \]
해당 수식을 pandas의 merge() 함수로 표현하면 df1.merge(df2, on='key', how='inner')와 같이 표현할 수 있습니다. 즉, df1과 df2를 key 열을 기준으로 병합하는 것입니다. Join을 좀 더 직관적으로 이해하기 위해서 Join을 집합 연산의 관점에서 바라보는 것 입니다. 예를 들어, df1 = {A, B, C, D}와 df2 = {B, C, D, E}가 있을 때, 4가지 Join 결과는 각각 다음과 같습니다.
- Inner Join : 교집합 {B, C, D}
- Left Join : df1 전체 {A, B, C, D} (+ df2의 정보)
- Right Join : df2 전체 {B, C, D, E} (+ df1의 정보)
- Outer Join : 합집합 {A, B, C, D, E}
14.2.1 merge() 함수의 매개변수 옵션
데이터 병합은 여러 데이터 소스를 결합할 때 필수적입니다. 다양한 merge() 함수의 활용 방법을 살펴봅겠습니다.
how옵션은 병합 방식을 결정하는 옵션입니다.
how='inner': 양쪽 모두에 존재하는 key만 결과에 포함 (교집합)how='left': 왼쪽 데이터프레임의 모든 행을 유지 (왼쪽 기준, 오른쪽에 없으면 NaN)how='right': 오른쪽 데이터프레임의 모든 행을 유지how='outer': 양쪽에 있는 모든 key를 결과에 포함 (합집합, 없는 곳은 NaN)
on옵션은 병합 기준 열 이름을 결정하는 옵션입니다.
on: 병합 기준 열 이름(문자열 또는 리스트)left_on,right_on: 왼쪽/오른쪽 각각의 기준 열 이름이 다를 때 지정
indicator옵션은 병합 결과 각 행이 어느 테이블에서 왔는지 표시하는 옵션입니다. suffixes 옵션은 겹치는 열 이름이 있을 때 접미사를 지정하는 옵션입니다.
indicator=True:_merge열이 추가되어 각 행이 어느 테이블에서 왔는지 표시합니다.suffixes=('_x', '_y'): 겹치는 열 이름이 있을 때 접미사를 지정합니다.
14.2.1.1 how와 on 옵션
간단하게 코드를 사용해서 merge() 함수를 사용해 보겠습니다. 먼저 왼쪽 데이터프레임과 오른쪽 데이터프레임을 생성합니다.
Inner Join은 left.merge(right, on='key', how='inner')와 같이 표현할 수 있습니다. 두 데이터프레임에서 'key' 값이 모두 존재하는(교집합) 행만 남깁니다. 예시에서는 'B'와 'C'가 공통으로 존재하기 때문에, 해당 key의 값만 결과로 반환됩니다.
Left Join은 left.merge(right, on='key', how='left')와 같이 표현할 수 있습니다. 왼쪽 데이터프레임의 모든 행을 유지합니다. 오른쪽(right)에 매칭되는 값이 없을 경우 NaN으로 표시됩니다. 'A' key는 오른쪽에 없으므로 val_right는 NaN이 됩니다.
Right Join은 left.merge(right, on='key', how='right')와 같이 표현할 수 있습니다. 오른쪽 데이터프레임의 모든 행을 유지합니다. 왼쪽에 매칭되는 값이 없을 경우 val_left가 NaN이 됩니다. 'D' key는 왼쪽에는 없으므로 val_left가 NaN이 됩니다.
Outer Join은 left.merge(right, on='key', how='outer')와 같이 표현할 수 있습니다. 양쪽(left, right)의 모든 key를 결과에 포함시킵니다(합집합). 한쪽에만 존재하는 key의 값은 반대쪽 열에서 NaN이 됩니다. 'A'(왼쪽만), 'D'(오른쪽만)처럼 한쪽에만 있는 key들도 결과에 나타납니다.
위 코드를 통해 데이터의 결합 방식에 따라 결과가 어떻게 달라지는지 쉽게 비교할 수 있습니다. 실전 데이터 분석에서 서로 다른 소스의 데이터를 합치는 경우, 원하는 결과(교집합, 전체, 한쪽만 등)에 따라 적절한 how 옵션을 선택해야 합니다.
14.2.1.2 indicator와 suffixes 활용
indicator 옵션을 사용하면 병합 결과 각 행이 어느 테이블에서 왔는지 표시합니다. 예시에서는 left.merge(right, on='key', how='outer', indicator=True)와 같이 표현할 수 있습니다. suffixes 옵션을 사용하면 겹치는 열 이름이 있을 때 접미사를 지정할 수 있습니다.
데이터의 일치성, 결측치, join 방식, 결과 검증 등을 항상 꼼꼼히 확인하는 것이 중요합니다.
14.2.2 데이터 품질과 merge() 함수의 관계
merge() 함수는 데이터 소스 간 관계를 구축해주는 핵심 도구이지만, 데이터의 품질 문제가 결합 과정에서 쉽게 드러나기도 합니다. 예를 들어, 키 불일치나 중복, 결측, 스키마 불일치 등은 merge 과정에서 데이터 손실, 불필요한 중복 행 생성, 잘못된 매칭 등으로 나타날 수 있으므로 반드시 병합 후 결과의 행 개수나 누락 데이터(결측값) 여부를 점검해야 합니다. 또한, merge의 validate 옵션을 사용하면 기대하는 카디널리티 조건을 사전에 검증할 수 있어, 잘못 연결된 데이터로 인한 품질 저하를 방지할 수 있습니다. Kandel 등[2]은 “Research Directions in Data Wrangling” 논문에서 실무 데이터 병합의 5가지 핵심 문제를 제시했습니다.
| 문제 | 의미 | 해결책 |
|---|---|---|
| Schema Heterogeneity | 테이블마다 다른 열 이름/타입 | left_on=, right_on= |
| Key Mismatch | 일부 키가 매칭되지 않음 | how='left' + 검증 |
| Data Quality Issues | 결측치, 오타로 인한 실패 | 전처리 필수 |
| Join Cardinality | 1:1? 1:N? N:M? | validate= 옵션 |
| Validation & Debugging | 병합 결과의 신뢰성 | indicator=True |
따라서 merge() 함수와 데이터 품질 관리는 항상 함께 고려되어야 하며, merge 이후 데이터의 무결성 및 정확성 진단을 습관화하는 것이 중요합니다.
14.2.2.1 Schema Heterogeneity 문제
데이터를 다루면서 흔한 문제는 동일한 개념이 다른 이름으로 저장되는 것입니다. 이러한 문제는 Schema Heterogeneity 문제라고 합니다. 예를 들어, 다른 시스템에서 가져온 고객 정보가 있을 때, cust_id와 customer_name이 다른 이름으로 저장되어 있을 수 있습니다. 해결 방법은 데이터를 정규화하는 것입니다. 예를 들어, cust_id와 customer_name을 하나의 열로 병합하여 customer_id와 customer_name으로 저장할 수 있습니다.
customers_system_A = pd.DataFrame({
'cust_id': [1, 2, 3],
'customer_name': ['Alice', 'Bob', 'Charlie']
})
customers_system_B = pd.DataFrame({
'custid': [1, 2, 3],
'cname': ['Alice', 'Bob', 'Charlie']
})
customers_B_normalized = customers_system_B.rename(columns={
'custid': 'cust_id',
'cname': 'customer_name'
})
result = customers_system_A.merge(customers_B_normalized, on='cust_id', how='inner')14.2.2.2 Key Mismatch와 데이터 손실 탐지
키 불일치(Key Mismatch)는 두 데이터 소스의 조인(merge) 기준 컬럼에서 값이 일치하지 않아 데이터가 손실되는 현상입니다. 예를 들어 주문(orders) 데이터에는 존재하지만 고객(customers) 데이터에는 등록되지 않은 고객 ID가 있는 경우, 단순히 inner join(내부 조인)을 수행하면 일치하지 않는 데이터가 조인 결과에서 사라져버립니다. 이처럼 조인 기준의 키가 불일치할 때 데이터 손실이 암묵적으로 발생하기 때문에 주의가 필요합니다.
이러한 손실을 탐지하려면 다음과 같은 방법을 활용할 수 있습니다.
- Inner Join 결과의 행 수 비교: 조인 전후의 행 개수를 비교하여 손실된 데이터를 파악할 수 있습니다.
- Left Join 후 결측치(NaN) 확인: 한쪽 데이터프레임의 값을 잃지 않으면서, 조인 결과에서 매칭이 안 된 값(결측치로 표시됨)을 명시적으로 탐지할 수 있습니다.
- indicator 옵션 활용:
merge()함수의indicator=True옵션을 사용하면, 각 행이 어느 데이터프레임에서 왔는지, 매칭에 성공했는지 여부를 별도 컬럼(_merge)으로 표시할 수 있습니다.
이런 방식으로 merge 결과를 점검하면 키 불일치로 인한 데이터 손실 위험을 사전에 차단하고, 데이터 품질 이슈를 신속히 진단할 수 있습니다.
아래 코드는 키 불일치로 인한 데이터 손실을 탐지하는 예제입니다.
Inner Join을 수행하면 데이터가 손실될 수 있습니다.
Inner Join: 4 → 2 행 손실!
검증을 위해서 Left Join과 indicator 옵션을 사용하면 데이터가 손실되지 않습니다.
left_result = orders.merge(customers, on='customer_id', how='left')
unmatched = left_result[left_result['name'].isna()]
print(f"\n매칭 실패한 주문: {len(unmatched)}개")
print(unmatched[['order_id', 'customer_id', 'amount']])
validation = orders.merge(customers, on='customer_id', how='outer', indicator=True)
missing_customers = validation[validation['_merge'] == 'left_only']
print(f"\n고객 마스터에 없는 주문 {len(missing_customers)}개")
매칭 실패한 주문: 2개
order_id customer_id amount
2 3 103 150
3 4 104 300
고객 마스터에 없는 주문 2개
14.2.2.3 Anti-join
Anti-join은 한 데이터프레임에는 있지만 다른 데이터프레임에는 없는 행을 찾는 데 유용합니다. 이는 누락된 데이터를 찾거나 데이터 품질 검증에 매우 유용한 기능입니다.
- Left Anti-join: 첫 번째 데이터프레임에는 있지만 두 번째 데이터프레임에는 없는 행을 반환합니다.
이런 특성으로 인해서 누락된 데이터를 찾거나, 거래 기록이 없는 고객과 같이 특정 조건을 만족하는 데이터를 찾을 때 유용합니다.
customers = pd.DataFrame({
'customer_id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']
})
transactions = pd.DataFrame({
'customer_id': [1, 2, 4],
'amount': [100, 200, 150]
})
missing_data = customers.merge(
transactions, on='customer_id', how='left_anti'
)
print("거래 기록이 없는 고객:")
print(missing_data)거래 기록이 없는 고객:
customer_id name amount
2 3 Charlie NaN
4 5 Eve NaN
14.2.3 merge() 함수 활용을 위한 전략
Kandel의 의사결정 모델을 참고하여 merge() 함수를 선택하는 전략을 정리하면 다음과 같습니다.
- 모든 왼쪽 데이터를 유지해야 하면
how='left'옵션을 사용합니다. - 매칭된 행만 필요한 경우
how='inner'옵션을 사용합니다. - 모든 데이터를 유지하면서 이상을 탐지하려고 하면
how='outer'옵션을 사용합니다.
merge() 함수 활용을 위한 흔한 오류는 카디널리티 불일치입니다. validate 옵션으로 검증할 수 있습니다. 항상 validate 옵션으로 기대되는 카디널리티를 명시하시는 것도 좋은 습관입니다.
validate에 넘길 수 있는 값은 다음 네 가지이며, 소문자 m을 씁니다. '1:N'이나 'N:M' 같은 표기는 받아들여지지 않고 ValueError가 발생합니다.
'1:1': 각 키가 양쪽에서 정확히 1번씩만 나타남 (일대일 병합)'1:m': 왼쪽 키는 유일하고, 오른쪽에서 여러 번 나타남 (일대다 병합)'m:1': 왼쪽에서 여러 번 나타나고, 오른쪽 키는 유일함 (다대일 병합)'m:m': 양쪽 모두 여러 번 나타남 (다대다 병합)
여기서 주의할 점이 있습니다. 'm:m'은 아무것도 검증하지 않습니다. 다대다는 어떤 조합이든 허용되는 상태이므로, validate='m:m'을 적어도 검증 효과는 없고 “이 병합이 다대다임을 알고 있다”는 의사 표시에 가깝습니다. 실제로 사고를 막아주는 것은 '1:1', '1:m', 'm:1' 세 가지입니다. 다대다 병합은 결과 행 수가 양쪽 중복도의 곱으로 불어나므로(fan-out), 의도한 것이 아니라면 병합 전에 키 중복을 확인하는 편이 안전합니다.
1:1은 각 키가 양쪽에서 정확히 1번씩만 나타남을 의미합니다.
1:m은 왼쪽 키가 오른쪽에서 여러 번 나타남을 의미합니다.
m:m은 양쪽 모두 여러 번 나타남을 의미합니다.
14.2.4 merge() 함수 활용을 위한 간단한 예제
실제 상황을 시뮬레이션한 예제로 다양한 병합 시나리오를 살펴봅시다. 먼저 고객 정보와 주문 정보를 생성합니다.
customers = pd.DataFrame({
'customer_id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
'city': ['Seoul', 'Busan', 'Seoul', 'Incheon', 'Daegu']
})
orders = pd.DataFrame({
'order_id': [101, 102, 103, 104, 105, 106],
'customer_id': [1, 2, 1, 3, 6, 4], # 6번 고객은 customers에 없음
'amount': [100, 200, 150, 300, 250, 180],
'order_date': pd.date_range('2024-01-01', periods=6, freq='D')
})
print("고객 정보:")
print(customers)
print("\n주문 정보:")
print(orders)고객 정보:
customer_id name city
0 1 Alice Seoul
1 2 Bob Busan
2 3 Charlie Seoul
3 4 Diana Incheon
4 5 Eve Daegu
주문 정보:
order_id customer_id amount order_date
0 101 1 100 2024-01-01
1 102 2 200 2024-01-02
2 103 1 150 2024-01-03
3 104 3 300 2024-01-04
4 105 6 250 2024-01-05
5 106 4 180 2024-01-06
14.2.4.1 시나리오 1: 모든 주문에 고객 정보 추가 (Left Join)
Left Join을 사용하면 모든 주문에 고객 정보를 추가할 수 있습니다.
14.2.4.2 시나리오 2: 주문이 있는 고객만 (Inner Join)
주문이 있는 고객만 추출할 수 있습니다.
14.2.4.3 시나리오 3: 모든 고객과 모든 주문 (Outer Join)
모든 주문과 모든 고객을 병합할 수 있습니다.
14.3 재구조화: melt()와 pivot_table() 함수
데이터는 보통 ‘Wide(넓은)’와 ’Long(긴)’ 두 가지 대표적 형식으로 표현됩니다[1]. Wide Format(넓은 형식)은 각 변수(특성)가 별도의 열(column)로 존재합니다. 즉, 하나의 행에 여러 변수 값이 펼쳐져 있습니다. Long Format(긴 형식)은 모든 관측값이 한 열에 나열되고, 어떤 변수의 값인지를 표시하는 열이 따로 존재합니다. 즉, ’변수’와 ’값’이 쌍을 이루어 행이 길어집니다.
둘 중 어떤 형식이 더 ‘좋다’ 또는 ’나쁘다’고 할 수는 없습니다. 실제 분석에서는 다음과 같은 특징을 보입니다. Wide Format은 여러 변수(열) 간의 ’비교’나, 하나의 행이 하나의 사례(개인별, 날짜별 등)를 대표할 때 유리합니다. Long Format은 특정 변수(열)에 대해 그룹별/조건별 요약, 집계, 통계(즉, 열 벡터 연산), 또 시각화(특히 seaborn, ggplot) 등에 적합합니다.
Tidy Data에서 강조된 것은 모든 변수가 별도의 열에 있어 벡터 연산이 가능해야 한다는 점입니다. 하지만 통계와 시각화, 집계에서는 Long Format이 훨씬 유용할 수 있습니다. 실제 분석 작업에서는 Wide와 Long 변환(Reshape)이 반드시 필요합니다. 이 때 자주 쓰는 함수가 melt()(Wide → Long)와 pivot_table()(Long → 집계/다시 Wide)입니다.
Kandel 등은 다음과 같은 의견을 제시합니다[2].
“Reshaping is not just a convenience operation. It represents a fundamental transformation between different data representations with distinct computational properties.”
Reshape는 단순히 보기 좋게 바꾸는 게 아니라, 계산 방식 자체를 바꾸는 매우 중요한 변화입니다. 따라서, 데이터의 분석 목적, 연산 특성에 따라 Wide, Long을 오가며 자유롭게 구조 변환이 가능해야 합니다. 이를 가능하게 해주는 것이 바로 melt()와 pivot_table() 함수입니다.
14.3.1 melt() 함수
pandas의 melt() 함수는 Wide Format(넓은 형식)의 데이터를 Long Format(긴 형식)으로 변환해주는 함수입니다. 쉽게 말해 여러 개의 열(column)에 분산된 데이터 값을 한 개의 열로 모아서, 분석이나 시각화, 집계 등에 유리하게 데이터를 “길게” 펼쳐줍니다.
melt() 함수의 주요 매개변수는 다음과 같습니다.
frame: 변환할 DataFrameid_vars: 그대로 유지할 column 목록 (주로 식별자/기준열)value_vars: 녹일(column을 행으로 만들) column 목록 (생략하면 id_vars를 제외한 모든 column)var_name: 녹인 column의 이름을 담을 새 열 이름 (생략시 ‘variable’)value_name: 값 데이터를 담을 새 열 이름 (생략시 ‘value’)
간단한 예제를 사용해서 melt() 함수를 살펴봅시다. 다음과 같은 넓은 형태(wide) 데이터가 있다고 가정해 보세요.
이 데이터를 melt()를 이용해 “과목”별 점수를 긴 형식으로 변환할 수 있습니다.
이러한 melt() 함수는 다음과 같은 경우에 유용합니다.
- 여러 열이 동일한 기준(시간, 제품, 변수 등)에 따라 반복되는 데이터일 때
- 집계, groupby, 통계, 시각화 seaborn plot 등에서 데이터가 길게 펴진(long) 형식을 요구할 때
또한 melt() 함수는 한 번에 여러 열을 하나의 값 열로 통합할 수 있습니다. 이 경우 벡터 연산을 통해 데이터를 쉽게 집계할 수 있습니다.
14.3.2 pivot_table() 함수
pandas의 pivot_table() 함수는 Long Format(긴 형식)의 데이터를 Wide Format(넓은 형식)으로 변환해주는 함수입니다. 쉽게 말해 여러 개의 행(row)에 분산된 데이터 값을 한 개의 열로 모아서, 분석이나 시각화, 집계 등에 유리하게 데이터를 “넓게” 펼쳐줍니다.
pivot_table() 함수의 주요 매개변수는 다음 항목과 같습니다.
frame: 변환할 DataFrameindex: 행으로 변환할 column 목록columns: 열로 변환할 column 목록values: 값으로 변환할 column 목록
간단한 예제를 사용해서 pivot_table() 함수를 살펴봅시다. 다음과 같은 긴 형식(long) 데이터가 있다고 가정해 보세요.
학생 과목 점수
0 영희 수학 80
1 영희 영어 90
2 철수 수학 85
3 철수 영어 95
이 데이터를 pivot_table()를 이용해 “학생”별 점수를 넓은 형식으로 변환할 수 있습니다.
점수
과목 수학 영어
학생
영희 80.0 90.0
철수 85.0 95.0
이러한 pivot_table() 함수는 다음과 같은 경우에 유용합니다.
- 여러 행이 동일한 기준(시간, 제품, 변수 등)에 따라 반복되는 데이터일 때
- 집계, groupby, 통계, 시각화 seaborn plot 등에서 데이터가 넓게 펴진(wide) 형식을 요구할 때
14.4 assign()을 이용한 메서드 체이닝
assign() 메서드를 활용해 데이터프레임에 새로운 파생 변수(새로운 컬럼)를 효율적으로 추가하는 방법을 살펴봅시다.
아래 예제는 데이터프레임 raw_df를 만듭니다. 여기에는 'value'와 'category'라는 두 개의 열이 있습니다.
메서드 체이닝(Method Chaining) 방식을 이용해 여러 단계를 한 번에 이어가며 처리합니다. 이렇게 하면 불필요한 중간 변수를 만들지 않고도 데이터 변환을 물 흐르듯이 처리할 수 있습니다. 결과적으로 새로운 변수('log_value')가 추가되고, 기존 변수의 이름도 직관적으로 바꿔 실무에서의 가독성과 유지보수성을 높일 수 있습니다. clean_df에는 이러한 변환이 반영된 최종 데이터프레임이 담깁니다.
실제 데이터셋인 tips를 사용하여 assign()의 활용법을 살펴보겠습니다.
assign()을 사용하여 여러 파생 변수를 한 번에 생성해봅시다. 아래 코드는 pandas의 assign() 메서드와 람다(lambda) 함수를 적극적으로 활용해, 여러 개의 파생 변수를 효율적으로 생성하는 방법을 보여줍니다.
먼저, tips_processed 데이터프레임을 만드는 단계에서는 다음과 같은 과정을 거칩니다.
assign()내에서 여러 파생 변수(팁 비율, 팁 포함 총액, 인당 요금, 로그 변환값, 고액 주문 여부 등)를 한 번에 생성합니다. 각 파생 변수는 람다 함수를 이용하여 기존 열을 참조해 계산됩니다.예를 들어, 팁 비율(
tip_rate)은 각 행의 ’tip’을 ’total_bill’로 나눈 값으로 계산되고, ’total_with_tip’은 팁과 총액을 더한 값입니다. 또한, 고액 주문 여부(is_high_value)는 전체 금액이 20달러 이상인지를 불리언 값으로 나타냅니다.이어서, 팁 비율이 0보다 큰 데이터만 선별(
query('tip_rate > 0'))하고, 총액 기준으로 내림차순 정렬(sort_values)합니다.
이렇게 처리하면, 불필요한 중간 변수 선언 없이 여러 단계를 체인처럼 연결해 가독성이 높고, 일관된 데이터 가공이 가능합니다.
tips_processed = (tips
.assign(
# 팁 비율 계산
tip_rate=lambda x: x['tip'] / x['total_bill'],
# 총액 (팁 포함)
total_with_tip=lambda x: x['total_bill'] + x['tip'],
# 인당 금액
per_person=lambda x: x['total_bill'] / x['size'],
# 로그 변환 (분포 정규화)
log_total_bill=lambda x: np.log1p(x['total_bill']),
# 고액 주문 여부 (20달러 이상)
is_high_value=lambda x: x['total_bill'] >= 20
)
.query('tip_rate > 0') # 팁 비율이 0보다 큰 경우만
.sort_values('total_bill', ascending=False)
)이전 단계에서 생성한 변수를 참조하여 새로운 변수를 만들 수 있습니다. 아래 tips_advanced 예제는 연속적으로 파생 변수를 생성하는 순차적 변수 생성 방법을 보여줍니다. 먼저, 기존 열을 사용해 팁 비율(tip_rate)을 계산하고, 이어서 이 팁 비율을 기준으로 pd.cut()을 활용해 각 행을 등급(tip_grade)으로 분류합니다(‘낮음’, ‘보통’, ‘좋음’, ‘매우좋음’). 마지막으로 등급별로 보너스 점수(bonus_score)를 부여하는 새로운 변수를 np.where()로 만듭니다.
tips_advanced = (tips
.assign(
# 1단계: 기본 계산
tip_rate=lambda x: x['tip'] / x['total_bill'],
# 2단계: 1단계 결과를 참조하여 등급 부여
tip_grade=lambda x: pd.cut(
x['tip_rate'],
bins=[0, 0.1, 0.15, 0.2, 1.0],
labels=['낮음', '보통', '좋음', '매우좋음']
),
# 3단계: 등급에 따른 보너스 점수
bonus_score=lambda x: np.where(
x['tip_grade'] == '매우좋음', 10,
np.where(x['tip_grade'] == '좋음', 5, 0)
)
)
)14.4.1 람다 함수를 활용한 순차적 변수 생성
이와 같이, assign() 내부의 각 람다가 바로 앞에서 생성된 변수를 참조할 수 있기 때문에, 복잡한 순차 계산도 한 번에 깔끔하게 처리할 수 있습니다. 이 패턴을 익히면 파생 변수/특성 엔지니어링 작업을 매우 효율적으로 수행할 수 있게 됩니다.
assign() 메서드의 강력한 기능 중 하나는 람다 함수를 사용하여 이전 단계에서 생성한 열을 참조할 수 있다는 점입니다. 람다 함수의 매개변수는 현재까지 변환된 데이터프레임을 받습니다.
assign() 메서드를 사용할 때는 결과 dtype이 자동으로 선택되지만, 명시적으로 dtype을 지정하는 것이 권장됩니다. 특히 nullable dtype을 사용하면 결측치 처리가 더욱 안전해집니다. 앞선 단계에서 결측치를 제대로 처리하지 않으면, 해당 결측치가 계속해서 전파되는 문제가 있을 수 있다는 점은 반드시 기억해야 합니다.
아래 예제는 날짜 문자열을 datetime 타입으로 변환한 뒤, 사망일과 출생일 차이로 생존 기간(age_days)을 만들고, 이를 일수에서 년수(age_years)로 변환합니다. 이때 assign()을 사용해서 앞서 생성된 열을 이용해 누적 계산하는 것이 핵심입니다. 기존에 블럭 혹은 Jupyter의 셀 단위로 계산하는 방식에서 벗어나, 람다 함수를 사용하여 순차적으로 열을 생성하는 방법을 통해서 효율적인 데이터 변환을 구현할 수 있습니다.
import pandas as pd
import numpy as np
plato_socrates = pd.DataFrame({
'Name': ['Socrates', 'Plato'],
'Born': ['-0470-06-04', '-0427-05-01'],
'Died': ['-0399-02-15', '-0347-01-01']
})
plato_socrates['Born'] = pd.to_datetime(plato_socrates['Born'], format='%Y-%m-%d')
plato_socrates['Died'] = pd.to_datetime(plato_socrates['Died'], format='%Y-%m-%d')
plato_socrates = plato_socrates.assign(
# 생존 일수
age_days=lambda df_: df_['Died'] - df_['Born'],
# 첫 번째 열을 참조하여 연수로 변환
age_years=lambda df_: (df_['age_days'].dt.days / 365).apply(np.floor)
)14.4.2 복잡한 파이프라인 구성
파이프라인을 구축해야 하는 이유는 데이터 변환 과정을 논리적인 단계로 나누어 일관성 있게 처리하고, 각 단계의 중간 결과를 쉽게 추적하며, 코드의 가독성과 재사용성을 높이기 위해서입니다. 한 번에 복잡한 연산을 모두 처리하기보다, 파이프라인(즉, 체이닝 방식)으로 각 과정(필터링, 파생 변수 생성, 그룹화 등)을 명확하게 구분하여 작성하면 다음과 같은 장점이 있습니다.
가독성: 여러 연산이 한 줄에 밀집되어 있지 않고 각 단계가 논리적으로 분리되어 있어, 데이터가 어떻게 변환되는지 한눈에 파악할 수 있습니다.
유지보수성: 파이프라인 내의 한 단계만 수정하면 전체 흐름에 영향을 최소화하여 수정 및 디버깅이 쉬워집니다.
중간결과 추적: 각 assign, filter, groupby 단계에서 원하는 시점에 데이터를 print하거나 결과를 확인할 수 있으므로, 데이터 처리 흐름을 쉽게 점검할 수 있습니다.
재현성 및 재사용성: 동일한 처리 흐름을 다양한 데이터셋에 반복 적용하기 쉽고, 함수형 프로그램이나 메서드 체이닝 방식의 장점과도 연결됩니다.
복잡한 로직의 단순화: 여러 개의 중간 변수를 따로 만들지 않고 한 번에 단일 객체(sales_df -> processed_df)로 변환 과정을 연결함으로써, 오류를 줄이고 코드 흐름을 더 명확하게 만듭니다.
즉, 파이프라인 방식은 데이터를 점진적으로 가공하면서, 각 변환 단계를 직관적으로 설계하고, 데이터 분석 전체 과정에 “일관성을 부여”하기 위한 매우 실용적이고 강력한 전략입니다. assign()을 중첩해서 사용하는 패턴은, 데이터 가공 파이프라인에서 단계별로 파생변수를 생성해야 할 때 매우 유용합니다.
각 단계에서 이전에 만든 열을 직접 활용하며, 코드의 가독성과 유지보수성도 높아집니다.
특히 파이프라인 방식으로 쓰면, 복잡한 변수 생성 과정을 “한 번에”, “명확한 순서대로” 처리할 수 있음을 보여주는 좋은 사례입니다.
import pandas as pd
import numpy as np
sales_df = pd.DataFrame({
'product': ['A', 'B', 'C', 'A', 'B'],
'price': [100, 200, 150, 120, 210],
'quantity': [5, 3, 7, 4, 2],
'discount': [0.1, 0.0, 0.15, 0.05, 0.0]
})
processed_df = (sales_df
.query('quantity > 0') # 필터링
.assign(
# 할인 적용 전 총액
total_before_discount=lambda x: x['price'] * x['quantity'],
# 할인 금액
discount_amount=lambda x: x['total_before_discount'] * x['discount'],
# 최종 총액
total_after_discount=lambda x: x['total_before_discount'] - x['discount_amount'],
# 로그 스케일 변환
log_total=lambda x: np.log1p(x['total_after_discount'])
)
.sort_values('total_after_discount', ascending=False)
)14.4.3 조건부 변수 생성
assign() 내에서 조건부 로직을 사용하여 변수를 생성할 수 있습니다.
데이터 분석을 수행할 때 변수 생성 과정에서 조건부 로직(if-else, np.where, np.select 등)은 자주 요구됩니다. 현실의 데이터는 단순하지 않고, 변수의 값에 따라 새로운 값을 유연하게 생성해야 하는 상황이 많습니다. 예를 들어, 점수에 따라 등급을 매기거나, 특정 카테고리에 따라 수수료, 혜택, 위험군 여부, 정책 적용 여부 등을 다르게 부여해야 합니다.
assign() 메서드 안에서 조건부 로직(np.where, np.select)을 사용하면, 다음과 같은 장점을 가집니다. 첫쨰, 파생변수 생성 시 중간변수 없이 한 곳에 로직을 집중시켜, 코드가 간결해집니다. 둘째, 메서드 체이닝과 결합하면, 분석 파이프라인 내에서 여러 조건부 파생 변수를 동시에 체계적으로 추가할 수 있습니다. 셋째, 복잡한 조건도 람다 함수, np.where, np.select 등과 결합하여 매우 유연하게 처리할 수 있습니다.
assign() 내에서 조건부 연산을 사용하면, 데이터 파이프라인 중 다양한 분기와 실무적 규칙을 구현할 수 있어 생산성과 가독성 모두를 크게 높일 수 있습니다. 복잡한 변수 생성도 코드 한 블록에서 체계적으로 관리할 수 있고, 파이프라인 분석의 강점을 극대화합니다.
import pandas as pd
import numpy as np
df = pd.DataFrame({
'score': [85, 92, 78, 96, 88],
'attempt': [1, 1, 2, 1, 2]
})
df = df.assign(
grade=lambda x: np.where(
x['score'] >= 90, 'A',
np.where(x['score'] >= 80, 'B', 'C')
),
bonus=lambda x: np.where(x['attempt'] == 1, 0, -5),
final_score=lambda x: x['score'] + x['bonus']
)