Pandas Apply: 사용자 정의 함수로 DataFrame과 Series 변환하기
Updated on
데이터 변환은 모든 데이터 분석 워크플로의 핵심입니다. pandas는 일반적인 작업을 위한 수백 가지의 내장 메서드를 제공하지만, 실제 데이터는 표준 함수로 처리할 수 없는 사용자 정의 로직을 요구하는 경우가 많습니다. 이로 인해 딜레마가 발생합니다. 수천 또는 수백만 개의 행에 복잡한 사용자 정의 변환을 효율적으로 적용하려면 어떻게 해야 할까요?
apply() 메서드는 DataFrame의 열, 행 또는 Series 요소에 대해 모든 Python 함수를 실행할 수 있도록 하여 이 문제를 해결합니다. 일관성 없는 문자열 형식을 정리하거나, 조건부 비즈니스 로직을 구현하거나, 머신러닝 모델을 위한 특성을 설계해야 하는 경우, apply()는 pandas의 내장 도구 키트를 벗어나는 작업을 처리할 수 있는 유연성을 제공합니다. 그러나 이러한 강력함은 많은 데이터 과학자들이 간과하는 성능 트레이드오프를 수반하며, 최적화된 대안보다 10~100배 느리게 실행되는 코드를 초래합니다.
이 가이드는 apply()를 효과적으로 사용하는 방법, 완전히 피해야 할 때, 그리고 어떤 벡터화된 대안이 훨씬 짧은 시간에 동일한 결과를 제공하는지 알려줍니다.
pandas apply() 기본 이해하기
apply() 메서드는 두 가지 형태로 존재합니다. 전체 열 또는 행에 대한 작업을 위한 DataFrame.apply()와 단일 열에 대한 요소별 변환을 위한 Series.apply()입니다.
Series.apply() 구문
단일 열(Series)로 작업할 때 apply()는 각 요소에 대해 함수를 실행합니다.
import pandas as pd
import numpy as np
# 샘플 데이터 생성
df = pd.DataFrame({
'price': [29.99, 45.50, 15.75, 89.00],
'quantity': [2, 1, 5, 3],
'product': ['widget', 'gadget', 'tool', 'device']
})
# Series에 함수 적용
def add_tax(price):
return price * 1.08
df['price_with_tax'] = df['price'].apply(add_tax)
print(df)출력:
price quantity product price_with_tax
0 29.99 2 widget 32.38920
1 45.50 1 gadget 49.14000
2 15.75 5 tool 17.01000
3 89.00 3 device 96.12000axis 매개변수가 있는 DataFrame.apply()
axis 매개변수는 apply()가 열을 처리할지 행을 처리할지 제어합니다.
axis=0(기본값): 각 열에 함수 적용 (수직 작업)axis=1: 각 행에 함수 적용 (수평 작업)
# axis=0: 각 열 처리
def get_range(column):
return column.max() - column.min()
ranges = df[['price', 'quantity']].apply(get_range, axis=0)
print(ranges)
# 출력:
# price 73.25
# quantity 4.00# axis=1: 각 행 처리
def calculate_total(row):
return row['price'] * row['quantity']
df['total'] = df.apply(calculate_total, axis=1)
print(df)출력:
price quantity product total
0 29.99 2 widget 59.98
1 45.50 1 gadget 45.50
2 15.75 5 tool 78.75
3 89.00 3 device 267.00람다 함수 vs 명명된 함수
람다 함수는 간결한 인라인 변환을 제공하고, 명명된 함수는 복잡한 로직에 대해 더 나은 가독성을 제공합니다.
람다 함수
간단한 한 줄 작업에 완벽함:
# 제품명을 대문자로 변환
df['product_upper'] = df['product'].apply(lambda x: x.upper())
# 할인 가격 계산
df['discounted'] = df['price'].apply(lambda x: x * 0.9 if x > 30 else x)
# 여러 열 결합
df['description'] = df.apply(
lambda row: f"{row['quantity']}x {row['product']} @ ${row['price']}",
axis=1
)
print(df['description'])출력:
0 2x widget @ $29.99
1 1x gadget @ $45.5
2 5x tool @ $15.75
3 3x device @ $89.0명명된 함수
다단계 변환 및 재사용 가능한 로직에 필수적:
def categorize_price(price):
"""가격대별로 제품 분류"""
if price < 20:
return 'Budget'
elif price < 50:
return 'Standard'
else:
return 'Premium'
df['tier'] = df['price'].apply(categorize_price)
def validate_order(row):
"""주문 데이터에 비즈니스 규칙 적용"""
if row['quantity'] > 10:
return 'Bulk Order - Review Required'
elif row['price'] * row['quantity'] > 200:
return 'High Value - Priority Shipping'
else:
return 'Standard Processing'
df['order_status'] = df.apply(validate_order, axis=1)
print(df[['product', 'tier', 'order_status']])result_type 매개변수 이해하기
result_type 매개변수(DataFrame.apply()만)는 함수가 여러 값을 반환할 때 apply()가 출력을 형식화하는 방법을 제어합니다.
| result_type | 동작 | 사용 사례 |
|---|---|---|
| None (기본값) | 출력 형식을 자동으로 추론 | 일반 목적 |
| 'expand' | 리스트 형태의 결과를 별도의 열로 분할 | 여러 반환 값 |
| 'reduce' | DataFrame 대신 Series를 반환하려고 시도 | 집계 작업 |
| 'broadcast' | 원래 모양으로 DataFrame을 반환 | 요소별 변환 |
def get_stats(column):
"""여러 통계 반환"""
return [column.mean(), column.std(), column.max()]
# 기본 동작 (구조 추론)
stats = df[['price', 'quantity']].apply(get_stats)
print(stats)
# 별도의 행으로 확장
stats_expanded = df[['price', 'quantity']].apply(get_stats, result_type='expand')
print(stats_expanded)메서드 비교: apply vs map vs applymap vs transform
각 메서드를 언제 사용해야 하는지 이해하면 성능 병목 현상을 방지할 수 있습니다.
| 메서드 | 작동 대상 | 입력 함수가 받는 것 | 출력 타입 | 최적의 용도 |
|---|---|---|---|---|
Series.apply() | 단일 열 | 각 요소를 개별적으로 | Series | 한 열에 대한 요소별 변환 |
Series.map() | 단일 열 | 각 요소 (dict/Series도 허용) | Series | 대체/조회 작업 |
DataFrame.apply() | 전체 DataFrame | 전체 열 (axis=0) 또는 행 (axis=1) | Series/DataFrame | 열/행별 작업 |
DataFrame.applymap() (더 이상 사용 안 함) | 전체 DataFrame | 각 요소를 개별적으로 | DataFrame | 모든 열의 요소별 (대신 map() 사용) |
DataFrame.transform() | DataFrame/GroupBy | 전체 열/그룹 | 입력과 동일한 모양 | DataFrame 모양을 유지하는 작업 |
# Series.apply() - 요소별 사용자 정의 함수
df['price_rounded'] = df['price'].apply(lambda x: round(x, 0))
# Series.map() - 대체 매핑
tier_map = {'Budget': 1, 'Standard': 2, 'Premium': 3}
df['tier_code'] = df['tier'].map(tier_map)
# DataFrame.apply() - 열별 집계
totals = df[['price', 'quantity']].apply(sum, axis=0)
# DataFrame.transform() - groupby로 모양 유지
df['price_norm'] = df.groupby('tier')['price'].transform(
lambda x: (x - x.mean()) / x.std()
)성능: apply()가 느린 이유
apply() 메서드는 Python 루프에서 데이터를 처리하며 pandas의 최적화된 C/Cython 구현을 우회합니다. 대규모 데이터셋의 경우 심각한 성능 저하가 발생합니다.
성능 비교
import time
# 큰 데이터셋 생성
large_df = pd.DataFrame({
'values': np.random.randn(100000)
})
# 방법 1: lambda로 apply()
start = time.time()
result1 = large_df['values'].apply(lambda x: x ** 2)
apply_time = time.time() - start
# 방법 2: 벡터화된 작업
start = time.time()
result2 = large_df['values'] ** 2
vectorized_time = time.time() - start
print(f"apply() 시간: {apply_time:.4f}s")
print(f"벡터화 시간: {vectorized_time:.4f}s")
print(f"속도 향상: {apply_time/vectorized_time:.1f}x")일반적인 출력:
apply() 시간: 0.0847s
벡터화 시간: 0.0012s
속도 향상: 70.6xapply()를 피해야 할 때
다음과 같은 경우 벡터화된 작업을 대신 사용하세요:
# 안 됨: 산술 연산에 apply 사용
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# 좋음: 벡터화된 곱셈 사용
df['total'] = df['price'] * df['quantity']
# 안 됨: 문자열 메서드에 apply 사용
df['upper'] = df['product'].apply(lambda x: x.upper())
# 좋음: 내장 문자열 접근자 사용
df['upper'] = df['product'].str.upper()
# 안 됨: 조건에 apply 사용
df['expensive'] = df['price'].apply(lambda x: 'Yes' if x > 50 else 'No')
# 좋음: np.where 또는 직접 비교 사용
df['expensive'] = np.where(df['price'] > 50, 'Yes', 'No')벡터화된 대안
| 작업 | 느림 (apply) | 빠름 (벡터화) |
|---|---|---|
| 산술 연산 | .apply(lambda x: x * 2) | * 2 |
| 조건문 | .apply(lambda x: 'A' if x > 10 else 'B') | np.where(condition, 'A', 'B') |
| 문자열 작업 | .apply(lambda x: x.lower()) | .str.lower() |
| 날짜 작업 | .apply(lambda x: x.year) | .dt.year |
| 여러 조건 | .apply(complex_if_elif_else) | np.select([cond1, cond2], [val1, val2], default) |
# np.select로 복잡한 조건
conditions = [
df['price'] < 20,
(df['price'] >= 20) & (df['price'] < 50),
df['price'] >= 50
]
choices = ['Budget', 'Standard', 'Premium']
df['tier_fast'] = np.select(conditions, choices, default='Unknown')pandas apply()의 일반적인 사용 사례
성능 제한에도 불구하고 apply()는 벡터화된 동등물이 없는 작업에 필수적입니다.
1. 복잡한 로직을 사용한 문자열 정리
# 지저분한 데이터 예시
messy_df = pd.DataFrame({
'email': ['John.Doe@COMPANY.com', ' jane_smith@test.co.uk ', 'ADMIN@Site.NET']
})
def clean_email(email):
"""이메일 형식 표준화"""
email = email.strip().lower()
# 추가 점 제거
username, domain = email.split('@')
username = username.replace('..', '.')
return f"{username}@{domain}"
messy_df['email_clean'] = messy_df['email'].apply(clean_email)
print(messy_df)2. 외부 데이터를 사용한 조건부 로직
# 외부 조회 기반 가격 조정
discount_rules = {
'widget': 0.10,
'gadget': 0.15,
'device': 0.05
}
def apply_discount(row):
"""최소 로직으로 제품별 할인 적용"""
base_price = row['price']
discount = discount_rules.get(row['product'], 0)
discounted = base_price * (1 - discount)
# 최저 가격
return max(discounted, 9.99)
df['final_price'] = df.apply(apply_discount, axis=1)3. ML을 위한 특성 엔지니어링
# 상호작용 특성 생성
def create_features(row):
"""예측 모델링을 위한 특성 생성"""
features = {}
features['price_per_unit'] = row['price'] / row['quantity']
features['is_bulk'] = 1 if row['quantity'] > 5 else 0
features['revenue_tier'] = pd.cut(
[row['price'] * row['quantity']],
bins=[0, 50, 150, 300],
labels=['Low', 'Medium', 'High']
)[0]
return pd.Series(features)
feature_df = df.apply(create_features, axis=1)
df = pd.concat([df, feature_df], axis=1)4. API 호출 및 외부 조회
def geocode_address(address):
"""지오코딩을 위한 외부 API 호출"""
# 실제 API 호출의 자리 표시자
# 실제로는: requests.get(f"api.geocode.com?q={address}")
return {'lat': 40.7128, 'lon': -74.0060}
# 속도 제한과 함께 적용
import time
def safe_geocode(address):
time.sleep(0.1) # 속도 제한
return geocode_address(address)
# df['coords'] = df['address'].apply(safe_geocode)고급 기법
추가 인수 전달
함수는 args와 kwargs를 통해 추가 매개변수를 받을 수 있습니다.
def apply_markup(price, markup_pct, min_profit):
"""최소 이익으로 퍼센트 마크업 추가"""
markup_amount = price * (markup_pct / 100)
return price + max(markup_amount, min_profit)
# 추가 인수 전달
df['retail_price'] = df['price'].apply(
apply_markup,
args=(25,), # markup_pct=25
min_profit=5.0 # 키워드 인수
)groupby()와 함께 apply() 사용
복잡한 집계를 위해 groupby와 apply를 결합:
# 그룹 수준 변환
def normalize_group(group):
"""그룹 내 Z-점수 정규화"""
return (group - group.mean()) / group.std()
df['price_normalized'] = df.groupby('tier')['price'].apply(normalize_group)
# 사용자 정의 그룹 집계
def group_summary(group):
"""그룹에 대한 요약 통계 생성"""
return pd.Series({
'total_revenue': (group['price'] * group['quantity']).sum(),
'avg_price': group['price'].mean(),
'item_count': len(group)
})
tier_summary = df.groupby('tier').apply(group_summary)
print(tier_summary)tqdm으로 진행률 표시줄
장시간 실행되는 apply 작업 모니터링:
from tqdm import tqdm
tqdm.pandas()
# apply 대신 progress_apply 사용
# df['result'] = df['column'].progress_apply(slow_function)오류를 우아하게 처리
def safe_transform(value):
"""오류 처리가 있는 변환 적용"""
try:
return complex_operation(value)
except Exception as e:
return None # 또는 np.nan, 또는 기본값
df['result'] = df['column'].apply(safe_transform)일반적인 실수와 디버깅
실수 1: 벡터화가 가능한데 axis=1 사용
# 잘못됨: 느린 행별 작업
df['total'] = df.apply(lambda row: row['price'] * row['quantity'], axis=1)
# 올바름: 빠른 벡터화된 작업
df['total'] = df['price'] * df['quantity']실수 2: return 문 잊기
# 잘못됨: 반환 값 없음
def broken_function(x):
x * 2 # return 누락!
# 올바름: 명시적 return
def working_function(x):
return x * 2실수 3: apply() 내에서 DataFrame 수정
# 잘못됨: 반복 중에 DataFrame을 수정하려고 시도
def bad_function(row):
df.loc[row.name, 'new_col'] = row['price'] * 2 # 안전하지 않음!
return row['price']
# 올바름: 값을 반환하고 나중에 할당
def good_function(row):
return row['price'] * 2
df['new_col'] = df.apply(good_function, axis=1)apply() 함수 디버깅
# 먼저 단일 행/요소에서 함수 테스트
test_row = df.iloc[0]
result = your_function(test_row)
print(f"테스트 결과: {result}")
# 함수 내에 디버그 출력 추가
def debug_function(row):
print(f"행 {row.name} 처리 중: {row.to_dict()}")
result = complex_logic(row)
print(f"결과: {result}")
return result
# 작은 부분 집합에서 테스트
df.head(3).apply(debug_function, axis=1)PyGWalker로 결과 시각화
apply()로 DataFrame을 변환한 후 결과를 시각화하면 변환을 검증하고 패턴을 발견하는 데 도움이 됩니다. PyGWalker는 Jupyter 노트북에서 pandas DataFrame을 Tableau 스타일의 대화형 인터페이스로 변환합니다.
import pygwalker as pyg
# 변환된 데이터를 대화형으로 시각화
walker = pyg.walk(df)PyGWalker는 적용된 변환의 드래그 앤 드롭 분석을 가능하게 합니다:
- 나란히 있는 차트로 원본 대 변환된 열 비교
- 필터링 및 그룹화로 조건부 로직 검증
- 분포 플롯을 통해 계산된 필드의 이상값 발견
- 문서화를 위한 시각화 내보내기
github.com/Kanaries/pygwalker (opens in a new tab)에서 PyGWalker를 탐색하여 데이터 탐색을 정적 플롯에서 대화형 분석으로 전환하세요.
FAQ
여러 열에 한 번에 함수를 적용하려면 어떻게 해야 합니까?
선택한 각 열을 처리하기 위해 axis=0으로 DataFrame.apply()를 사용하거나 여러 열에 대해 직접 벡터화된 작업을 사용하세요.
# 여러 열에 적용
df[['price', 'quantity']] = df[['price', 'quantity']].apply(lambda x: x * 1.1)
# 또는 벡터화 (더 빠름)
df[['price', 'quantity']] = df[['price', 'quantity']] * 1.1apply()와 map()의 차이점은 무엇입니까?
apply()는 요소별로 호출 가능한 모든 함수를 실행하는 반면, map()은 딕셔너리, Series 또는 함수를 통한 대체에 최적화되어 있습니다. 조회 및 대체에는 map()을 사용하고(더 빠름), 복잡한 로직이 필요한 사용자 정의 변환에는 apply()를 사용하세요.
apply() 함수가 왜 이렇게 느립니까?
apply()는 컴파일된 C 코드가 아닌 Python 루프에서 실행되므로 벡터화된 작업보다 10~100배 느립니다. apply()를 사용하기 전에 pandas에 내장 메서드(.str, .dt, 산술 연산자)가 있는지 또는 np.where()/np.select()가 로직을 대체할 수 있는지 항상 확인하세요.
여러 열에 액세스하는 람다 함수로 apply()를 사용할 수 있습니까?
예, 행을 처리하려면 axis=1을 사용하세요.
df['result'] = df.apply(lambda row: row['col1'] + row['col2'] * 0.5, axis=1)그러나 이것은 큰 DataFrame에서는 느립니다. 다음을 선호하세요: df['result'] = df['col1'] + df['col2'] * 0.5
단일 apply() 호출에서 여러 열을 반환하려면 어떻게 해야 합니까?
명명된 인덱스가 있는 pd.Series를 반환하면 pandas가 자동으로 열로 확장합니다.
def multi_output(row):
return pd.Series({
'sum': row['a'] + row['b'],
'product': row['a'] * row['b']
})
df[['sum', 'product']] = df.apply(multi_output, axis=1)결론
pandas apply() 메서드는 pandas의 내장 작업 범위를 벗어나는 사용자 정의 변환에 필수적인 유연성을 제공합니다. Python 루프 구현은 성능 트레이드오프를 만들지만, apply() 대 벡터화된 대안을 언제 사용해야 하는지 이해하면 효율적인 데이터 워크플로와 프로덕션 데이터셋에서 멈추는 워크플로를 구분할 수 있습니다.
주요 요점: 벡터화된 메서드, 문자열 접근자 또는 NumPy 함수로 목표를 달성할 수 없을 때만 apply()를 사용하세요. 전체 DataFrame에 적용하기 전에 단일 행에서 함수를 테스트하세요. 대규모 데이터셋의 행별 작업의 경우 Cython, Numba JIT 컴파일 또는 병렬 실행을 위한 polars로의 전환을 조사하세요.
apply()의 능력과 한계를 모두 마스터하면 데이터 변환 툴킷이 pandas가 던지는 모든 과제를 처리할 수 있습니다.