import platform
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
# 운영체제별 한글 폰트 설정
if platform.system() == "Darwin":
plt.rcParams["font.family"] = "Apple SD Gothic Neo"
elif platform.system() == "Windows":
plt.rcParams["font.family"] = "Malgun Gothic"
else:
plt.rcParams["font.family"] = "NanumGothic"
plt.rcParams["axes.unicode_minus"] = False15 실습: 결합과 병합
Instacart 온라인 식료품 주문 데이터를 사용해 데이터 선택과 필터링, 그룹, 병합 그리고 집계, 데이터 병합과 재구조화를 단계적으로 적용합니다. 주문(orders), 주문-상품(order_products), 상품(products), 부서(departments), 통로(aisles) 테이블을 병합한 뒤, 조건부 선택, 그룹별 집계, pivot_table 재구조화, 시각화까지 진행합니다.
특히 3천만 건이 넘는 대용량 주문-상품 데이터를 효율적으로 처리하기 위해 Apache Arrow 기반의 PyArrow 엔진(engine="pyarrow", dtype_backend="pyarrow")을 활용합니다. PyArrow 백엔드를 사용하면 Parquet 파일의 초고속 로드, 메모리 절감, 정밀한 결측치 처리 및 Arrow 기반 타입(int64[pyarrow], string[pyarrow] 등)을 활용한 안정적인 테이블 병합과 집계 연산이 가능합니다.
Instacart 데이터는 Kaggle에서 다운로드 받을 수 있습니다. URL은 https://www.kaggle.com/datasets/psparks/instacart-market-basket-analysis 입니다.
15.1 데이터 로드 및 테이블 병합
15.1.1 데이터 로드 및 초기 탐색 (PyArrow 백엔드)
여러 Parquet 파일을 읽어 각 테이블의 구조와 키를 파악합니다. 대용량 데이터를 처리할 때는 engine="pyarrow"와 dtype_backend="pyarrow"를 명시하여 PyArrow 백엔드로 로드합니다. 이렇게 하면 전통적인 NumPy/Python 객체 타입 대신 Arrow의 네이티브 데이터 타입(int64[pyarrow], string[pyarrow], double[pyarrow])으로 생성되어 메모리 사용량이 크게 줄고 병합 및 필터링 속도가 향상됩니다.
관계형 데이터는 주로 merge()로 키를 기준으로 연결하므로, orders.order_id, order_products.order_id, order_products.product_id, products.product_id 등 공통 열 이름을 확인합니다. Long 형식의 주문-상품 테이블은 한 행이 (주문, 상품) 한 쌍에 해당합니다.
# PyArrow 엔진 및 백엔드를 적용한 Parquet 데이터 로드
orders = pd.read_parquet(
"../data/instacart-market-basket-analysis-parquet/orders.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
products = pd.read_parquet(
"../data/instacart-market-basket-analysis-parquet/products.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
departments = pd.read_parquet(
"../data/instacart-market-basket-analysis-parquet/departments.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
aisles = pd.read_parquet(
"../data/instacart-market-basket-analysis-parquet/aisles.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
order_products = pd.read_parquet(
"../data/instacart-market-basket-analysis-parquet/order_products__prior.parquet",
engine="pyarrow",
dtype_backend="pyarrow",
)
print("orders 크기:", orders.shape)
print("products 크기:", products.shape)
print("departments 크기:", departments.shape)
print("aisles 크기:", aisles.shape)
print("order_products 크기:", order_products.shape)
# PyArrow 데이터 타입 확인
print("\norders 데이터 타입:")
print(orders.dtypes)orders 크기: (3421083, 7)
products 크기: (49688, 4)
departments 크기: (21, 2)
aisles 크기: (134, 2)
order_products 크기: (32434489, 4)
orders 데이터 타입:
order_id int64[pyarrow]
user_id int64[pyarrow]
eval_set string[pyarrow]
...
order_dow int64[pyarrow]
order_hour_of_day int64[pyarrow]
days_since_prior_order double[pyarrow]
Length: 7, dtype: object
15.1.2 결측 처리 및 키 검증
orders의 days_since_prior_order는 첫 주문인 경우 이전 주문이 없어 결측입니다. PyArrow 백엔드는 결측치를 네이티브 Null(Arrow null)로 정확히 표현합니다. is_first_order 불리언 변수(bool[pyarrow])를 추가하여 첫 주문인지 여부를 표시합니다.
병합 전에 키 일치 여부를 확인하면 키 매칭의 불일치로 발생하는 데이터 손실을 방지할 수 있습니다. 집합(set) 연산이나 merge(..., how='left', indicator=True)로 어느 테이블에서 매칭되었는지 검증합니다.
print("orders 결측 건수:")
print(orders.isnull().sum())
print(
"\ndays_since_prior_order 결측 비율:",
orders["days_since_prior_order"].isna().mean(),
)
# 첫 주문 여부 플래그 생성
orders = orders.assign(
is_first_order=orders["days_since_prior_order"].isna()
)
op_order_ids = set(order_products["order_id"])
ord_order_ids = set(orders["order_id"])
only_in_op = op_order_ids - ord_order_ids
only_in_ord = ord_order_ids - op_order_ids
print("order_products에만 있는 order_id 수:", len(only_in_op))
print("orders에만 있는 order_id 수:", len(only_in_ord))orders 결측 건수:
order_id 0
user_id 0
eval_set 0
...
order_dow 0
order_hour_of_day 0
days_since_prior_order 206209
Length: 7, dtype: int64
days_since_prior_order 결측 비율: 0.06027594185817766
order_products에만 있는 order_id 수: 0
orders에만 있는 order_id 수: 206209
15.1.3 테이블 병합 (merge)
주문-상품 테이블을 주문, 상품, 부서, 통로와 단계적으로 병합합니다. orders와 order_products는 order_id로 1:N 관계, order_products와 products는 product_id로 N:1 관계입니다. products는 department_id, aisle_id로 각각 departments, aisles와 1:1로 연결됩니다. Left Join을 사용해 주문, 주문-상품 기준으로 마스터 정보를 붙입니다.
PyArrow 백엔드의 Arrow 정수/문자열 타입은 병합 시 해시 조인 연산에서 메모리 효율성과 빠른 조회 속도를 보장합니다.
# 1) order_products + orders
# (주문당 상품 목록에 주문 메타정보 추가)
op_ord = order_products.merge(
orders,
on="order_id",
how="left",
)
print("op_ord 크기:", op_ord.shape)
print(op_ord.head(2))
# 2) 상품 정보 추가 (product_id 기준)
op_ord_prod = op_ord.merge(
products,
on="product_id",
how="left",
)
print("\nop_ord_prod 크기:", op_ord_prod.shape)
print(
op_ord_prod[
["order_id", "product_id", "product_name", "department_id"]
].head(2)
)
# 3) 부서, 통로 이름 추가
op_ord_prod = op_ord_prod.merge(
departments,
on="department_id",
how="left",
)
op_ord_prod = op_ord_prod.merge(
aisles,
on="aisle_id",
how="left",
)
print("\n최종 병합 후 컬럼 목록:", list(op_ord_prod.columns))
print(
op_ord_prod[
["order_id", "product_name", "department", "aisle"]
].head(2)
)op_ord 크기: (32434489, 11)
order_id product_id ... days_since_prior_order is_first_order
0 2 33120 ... 8.0 False
1 2 28985 ... 8.0 False
[2 rows x 11 columns]
op_ord_prod 크기: (32434489, 14)
order_id product_id product_name department_id
0 2 33120 Organic Egg Whites 16
1 2 28985 Michigan Organic Kale 4
최종 병합 후 컬럼 목록: ['order_id', 'product_id', 'add_to_cart_order', 'reordered', 'user_id', 'eval_set', 'order_number', 'order_dow', 'order_hour_of_day', 'days_since_prior_order', 'is_first_order', 'product_name', 'aisle_id', 'department_id', 'department', 'aisle']
order_id product_name department aisle
0 2 Organic Egg Whites dairy eggs eggs
1 2 Michigan Organic Kale produce fresh vegetables
15.2 데이터 선택과 필터링
15.2.1 loc과 Boolean Indexing
분석 대상을 특정 부서, 요일, 시간대로 좁히는 것은 데이터 선택과 필터링의 핵심입니다. loc으로 행, 열을 이름 기준으로 선택하고, Boolean Indexing으로 조건을 만족하는 행만 남깁니다. 복합 조건은 괄호로 묶고 &, |를 사용합니다.
# 특정 부서만: produce, dairy eggs, beverages
target_depts = ["produce", "dairy eggs", "beverages"]
subset_dept = op_ord_prod.loc[
op_ord_prod["department"].isin(target_depts), :
]
print("선택 부서 행 수:", len(subset_dept))
print(subset_dept["department"].value_counts())
# 주문 요일 0–4(평일) + 오전(6–12시)인 주문만 필터링
weekday = op_ord_prod["order_dow"] < 5
morning = (op_ord_prod["order_hour_of_day"] >= 6) & (
op_ord_prod["order_hour_of_day"] < 12
)
subset_time = op_ord_prod.loc[weekday & morning, :]
print("\n평일 오전 주문 행 수:", len(subset_time))선택 부서 행 수: 17583436
department
produce 9479291
dairy eggs 5414016
beverages 2690129
Name: count, dtype: int64[pyarrow]
평일 오전 주문 행 수: 7994891
15.2.2 query() 메서드 활용
문자열로 조건을 쓰는 query()는 복잡한 필터를 읽기 쉽게 만듭니다. PyArrow 백엔드에서도 query() 연산이 네이티브로 호환되며, 열 이름과 비교, 논리 연산을 그대로 쓸 수 있고 외부 변수는 @변수명으로 참조합니다.
# 재주문(reordered==1)이면서 장바구니 순서 3 이하인 항목
q1 = op_ord_prod.query("reordered == 1 and add_to_cart_order <= 3")
print("재주문이며 장바구니 상위 3개인 행 수:", len(q1))
# 특정 부서 + 요일 조건 (외부 변수 참조)
dept_list = ["produce", "bakery"]
q2 = op_ord_prod.query(
"department in @dept_list and order_dow in [0, 5, 6]"
)
print("produce/bakery, 일/월/토 주문 행 수:", len(q2))재주문이며 장바구니 상위 3개인 행 수: 6135554
produce/bakery, 일/월/토 주문 행 수: 5016535
15.3 그룹별 집계
15.3.1 그룹별 집계 (groupby + agg)
데이터를 그룹(사용자, 부서, 상품 등)으로 나누고, 각 그룹에 집계 함수를 적용한 뒤 결과를 합칩니다. groupby()로 분할하고 agg()로 여러 통계를 한 번에 계산할 수 있습니다.
# 사용자별 주문 수, 주문당 평균 상품 수
user_orders = (
op_ord_prod.groupby("user_id", as_index=False)
.agg(
order_count=("order_id", "nunique"),
total_items=("product_id", "count"),
)
.assign(
avg_items_per_order=lambda x: x["total_items"] / x["order_count"]
)
)
print("사용자별 요약 (주문 수 상위 5명):")
print(user_orders.nlargest(5, "order_count"))
# 부서별 주문 건수, 재주문 수, 재주문률
dept_agg = (
op_ord_prod.groupby("department", as_index=False)
.agg(
orders=("order_id", "nunique"),
reordered_sum=("reordered", "sum"),
rows=("order_id", "count"),
)
.assign(reorder_rate=lambda x: x["reordered_sum"] / x["rows"])
)
dept_agg = dept_agg.sort_values("orders", ascending=False)
print("\n부서별 주문 건수, 재주문률:")
print(dept_agg.head(8))사용자별 요약 (주문 수 상위 5명):
user_id order_count total_items avg_items_per_order
209 210 99 1476 14.909091
309 310 99 493 4.979798
312 313 99 787 7.949495
689 690 99 851 8.59596
785 786 99 1010 10.20202
부서별 주문 건수, 재주문률:
department orders reordered_sum rows reorder_rate
19 produce 2409320 6160710 9479291 0.649913
7 dairy eggs 2177338 3627221 5414016 0.669969
3 beverages 1457351 1757892 2690129 0.65346
.. ... ... ... ... ...
16 pantry 1117892 650301 1875577 0.346721
2 bakery 881556 739188 1176787 0.628141
8 deli 770300 638864 1051249 0.607719
[8 rows x 5 columns]
15.3.2 transform: 그룹 내 순위, 비율
그룹별로 계산하되 원본 행 수를 유지하려면 transform()을 사용합니다. 그룹 내 순위, 그룹 내 비율, 그룹 통계량을 새 열로 붙일 수 있습니다.
# 주문 내 장바구니 순위 (add_to_cart_order가 이미 순서)
op_ord_prod = op_ord_prod.assign(
rank_in_order=op_ord_prod.groupby("order_id")["add_to_cart_order"]
.rank(method="min")
.astype("int64[pyarrow]")
)
print("주문 내 순위 예시:")
print(
op_ord_prod[
["order_id", "product_id", "add_to_cart_order", "rank_in_order"]
].head(8)
)
# 부서별 주문 건수(그룹 합계)를 각 행에 붙이기
dept_order_count = op_ord_prod.groupby("department")["order_id"].transform(
"nunique"
)
op_ord_prod = op_ord_prod.assign(dept_order_count=dept_order_count)
print("\n부서별 주문 건수(행 복제) 샘플:")
print(
op_ord_prod[["order_id", "department", "dept_order_count"]]
.drop_duplicates("department")
.head(5)
)주문 내 순위 예시:
order_id product_id add_to_cart_order rank_in_order
0 2 33120 1 1
1 2 28985 2 2
2 2 9327 3 3
.. ... ... ... ...
5 2 17794 6 6
6 2 40141 7 7
7 2 1819 8 8
[8 rows x 4 columns]
부서별 주문 건수(행 복제) 샘플:
order_id department dept_order_count
0 2 dairy eggs 2177338
1 2 produce 2409320
2 2 pantry 1117892
15 3 meat seafood 574731
16 3 bakery 881556
15.3.3 상위 N개 및 그룹 필터링
그룹별로 상위 N개를 추출하거나 특정 조건을 충족하는 그룹만 필터링합니다. 대용량 데이터셋에서는 집계 결과를 기반으로 키를 추출한 후 isin()으로 필터링하는 벡터화 방식이 Python 반복 순회(filter(lambda ...))보다 성능 면에서 훨씬 뛰어납니다.
# 부서별 주문 건수 상위 3개 부서의 행만 유지
top3_depts = (
op_ord_prod.groupby("department", as_index=False)["order_id"]
.nunique()
.nlargest(3, "order_id")["department"]
.tolist()
)
subset_top3 = op_ord_prod.loc[
op_ord_prod["department"].isin(top3_depts)
]
print("상위 3개 부서:", top3_depts)
print("상위 3개 부서 해당 행 수:", len(subset_top3))
# 주문 5회 이상 사용자만 남기기 (벡터화된 isin 필터링)
active_users = user_orders.loc[
user_orders["order_count"] >= 5, "user_id"
]
users_5plus = op_ord_prod.loc[
op_ord_prod["user_id"].isin(active_users)
]
print("주문 5회 이상 사용자 수:", users_5plus["user_id"].nunique())
print("해당 행 수:", len(users_5plus))상위 3개 부서: ['produce', 'dairy eggs', 'beverages']
상위 3개 부서 해당 행 수: 17583436
주문 5회 이상 사용자 수: 162633
해당 행 수: 30992966
15.4 재구조화와 pivot_table
15.4.1 요일×시간대 주문량 (pivot_table)
요일(order_dow)과 시간대(order_hour_of_day)에 따른 주문 수를 보려면 Long 형식을 Wide 형식으로 변환하는 것이 유용합니다. pivot_table()로 인덱스, 열, 값, 집계 함수를 지정하면 요일×시간대 행렬이 생성됩니다.
# 주문 단위로 한 번만 세기 (order_id 기준 고유 행 추출)
order_level = op_ord_prod.drop_duplicates(subset=["order_id"])[
["order_id", "order_dow", "order_hour_of_day"]
]
pivot_orders = order_level.pivot_table(
index="order_dow",
columns="order_hour_of_day",
values="order_id",
aggfunc="count",
fill_value=0,
)
print("요일×시간대 주문 수 (일부):")
print(pivot_orders.iloc[:, :8])요일×시간대 주문 수 (일부):
order_hour_of_day 0 1 ... 6 7
order_dow ...
0 3692 2235 ... 3138 11530
1 3475 1735 ... 5101 15792
2 2906 1485 ... 4524 12550
... ... ... ... ... ...
4 2476 1414 ... 4135 11823
5 2989 1539 ... 4573 12590
6 3067 1781 ... 3007 10632
[7 rows x 8 columns]
15.4.2 부서×요일 주문 건수
부서별, 요일별 주문 건수를 보려면 부서와 요일로 그룹화한 뒤 집계하고, pivot_table로 재구조화합니다.
dept_dow = (
op_ord_prod.groupby(
["department", "order_dow"], as_index=False
)["order_id"]
.nunique()
.rename(columns={"order_id": "order_count"})
)
dept_dow_wide = dept_dow.pivot_table(
index="department",
columns="order_dow",
values="order_count",
fill_value=0,
)
print("부서×요일 주문 건수:")
print(dept_dow_wide.head(6))부서×요일 주문 건수:
order_dow 0 1 ... 5 6
department ...
alcohol 11231.0 11455.0 ... 14335.0 12121.0
babies 33594.0 30320.0 ... 22043.0 23507.0
bakery 170017.0 150990.0 ... 114526.0 123573.0
beverages 249487.0 251453.0 ... 198209.0 194244.0
breakfast 97187.0 93687.0 ... 68426.0 69294.0
bulk 5979.0 6211.0 ... 4468.0 4612.0
[6 rows x 7 columns]
15.5 시각화
15.5.1 부서별 주문 건수 막대 그래프
그룹별 집계 결과를 가로 막대 그래프로 나타내어 부서별 주문 규모를 한눈에 비교합니다.
15.5.2 요일×시간대 히트맵
pivot_table로 만든 요일×시간대 행렬을 히트맵으로 시각화하여 주문이 집중되는 특정 요일과 시간대를 파악합니다.
15.5.3 재주문률 상위 부서 (막대 그래프)
부서별 재주문률 상위 10개 부서를 막대 그래프로 시각화합니다.
top_reorder = dept_agg.nlargest(10, "reorder_rate")
fig, ax = plt.subplots(figsize=(10, 5))
ax.barh(
top_reorder["department"],
top_reorder["reorder_rate"],
alpha=0.8,
color="coral",
label="재주문률",
)
ax.set_xlabel("재주문률")
ax.set_ylabel("부서")
ax.set_title("재주문률 상위 10개 부서")
ax.set_xlim(0, 1)
plt.tight_layout()
plt.show()
15.5.4 assign()을 이용한 메서드 체이닝
assign()으로 파생 변수를 추가하고 파이프라인 형태로 정리합니다. lambda 함수로 이전 단계에서 생성된 열이나 Arrow 불리언 시리즈를 다룰 수 있습니다.
order_level = (
op_ord_prod.drop_duplicates(subset=["order_id"])[
["order_id", "user_id", "order_dow", "order_hour_of_day"]
]
.assign(
is_weekend=lambda x: x["order_dow"].isin([5, 6]),
is_morning=lambda x: (x["order_hour_of_day"] >= 6)
& (x["order_hour_of_day"] < 12),
)
)
print("요일, 시간대 파생 변수 샘플:")
print(order_level.head(5))요일, 시간대 파생 변수 샘플:
order_id user_id ... is_weekend is_morning
0 2 202279 ... True True
9 3 205970 ... True False
17 4 178520 ... False True
30 5 156122 ... True False
56 6 22352 ... False False
[5 rows x 6 columns]
15.5.5 사용자별 주문 수 분포 (히스토그램)
그룹별 집계로 얻은 사용자별 주문 수의 분포를 히스토그램으로 확인합니다.