5  실습: PyArrow

Kaggle의 Instacart Market Basket Analysis 데이터셋을 바탕으로 PyArrow을 활용해서 EDA 과정을 다시 한 번 실습을 진행해보겠습니다.

Kaggle의 Instacart Market Basket Analysis(이하 Instacart 데이터셋)은 유저들의 온라인 식료품 주문 내역(약 3,400만 건의 장바구니 상품 내역)을 포함하고 있습니다. 이번 실습에서는 instacart-market-basket-analysis-parquet 디렉터리에 저장된 Parquet 포맷의 데이터셋을 활용하여 아래 3가지 핵심 주제를 실습합니다.

  1. pandas에서 PyArrow 활용
  2. 주문 요일 및 시간대별 주문 분포와 재주문 간격 분석
  3. 장바구니 담긴 순서 및 카테고리별 재주문율 분석

필요한 라이브러리를 가져옵니다.

import platform
import pandas as pd
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.dataset as ds
import matplotlib.pyplot as plt
import seaborn as sns
from pathlib import Path

# 운영체제별 한글 폰트 설정
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"] = False

# 데이터 디렉터리 경로 설정
data_dir = Path(
    "../data/instacart-market-basket-analysis-parquet"
)
  1. pyarrow (pa): pyarrow 는 Apache Arrow 의 Python 바인딩으로, 메모리 내 열 기반 데이터 표현을 위한 라이브러리입니다. pandas 의 DataFrame 을 pyarrow.Table 로 변환하면, 대용량 데이터 처리 시 메모리 효율성과 성능을 크게 향상시킬 수 있습니다.
    • 열 기반: CPU 캐시 효율성을 높여 분석 성능을 극대화
    • 영복사(zero-copy): pandas, NumPy, Spark 등 다른 시스템과 메모리 공유 가능
    • 직렬화 포맷: Parquet, Feather 등
  2. pyarrow.compute (pc): pyarrow.compute 모듈은 Arrow 배열에 대한 벡터화된 연산 함수들을 제공합니다. 이 모듈을 사용하면 pandas 의 apply() 보다 훨씬 빠른 속도로 대용량 데이터를 처리할 수 있습니다.
    • 산술 연산: pc.add(), pc.subtract(), pc.multiply()
    • 집계 함수: pc.sum(), pc.mean(), pc.min(), pc.max()
    • 논리 연산 및 비교: pc.equal(), pc.greater(), pc.is_null()
    • 문자열 및 날짜 처리: pc.utf8_trim(), pc.year(), pc.month()
  3. pyarrow.dataset (ds): pyarrow.dataset은 디스크나 클라우드 스토리지에 저장된 대용량 데이터셋을 효율적으로 조회하고 처리하기 위한 고수준 API를 제공합니다. 예를 들어, ds.dataset("data/", format="parquet").to_table(filter=ds.field("year") == 2025)를 사용하면 2025년 데이터만 필터링하여 읽을 수 있습니다.
    • 파티셔닝된 데이터셋: ds.dataset("data/", format="parquet")과 같이 디렉터리 구조를 인식
    • 필터링 및 열 선택: 필요한 데이터만 메모리에 로드하여 효율적인 처리
    • 클라우드 스토리지 지원: S3, GCS 등 다양한 스토리지 지원
  4. pathlib.Path: pathlib은 파일 및 디렉터리 경로를 객체 지향적으로 다루기 위한 표준 라이브러리입니다. Path 클래스를 사용하면 운영체제에 독립적인 경로 연산이 가능합니다. 예를 들어, Path("data") / "file.parquet"는 운영체제에 맞게 해석됩니다.
    • OS 간 호환성: 윈도우 (\) 와 유닉스 (/) 의 경로 구분자를 자동으로 처리
    • 직관적인 메서드: Path("data") / "file.parquet" 와 같이 경로 연결이 가능
    • 파일 시스템 작업: exists(), mkdir(), glob() 등 다양한 파일 시스템 작업을 제공

5.1 데이터 로드 및 PyArrow 백엔드 검증

pandas 3.x에서는 read_parquet() 호출 시 dtype_backend="pyarrow"를 지정하여 전통적인 NumPy 백엔드 대신 ArrowDtype을 기반으로 데이터를 저장할 수 있습니다. 먼저, orders, products, aisles, departments 메타데이터 테이블을 PyArrow 백엔드로 읽어옵니다.

orders = pd.read_parquet(
    data_dir / "orders.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
)
products = pd.read_parquet(
    data_dir / "products.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
)
aisles = pd.read_parquet(
    data_dir / "aisles.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
)
departments = pd.read_parquet(
    data_dir / "departments.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
)

# orders 데이터프레임의 스키마 및 메모리 타입 확인
orders.info()
<class 'pandas.DataFrame'>
RangeIndex: 3421083 entries, 0 to 3421082
Data columns (total 7 columns):
 #   Column                  Dtype          
---  ------                  -----          
 0   order_id                int64[pyarrow] 
 1   user_id                 int64[pyarrow] 
 2   eval_set                string[pyarrow]
 3   order_number            int64[pyarrow] 
 4   order_dow               int64[pyarrow] 
 5   order_hour_of_day       int64[pyarrow] 
 6   days_since_prior_order  double[pyarrow]
dtypes: double[pyarrow](1), int64[pyarrow](5), string[pyarrow](1)
memory usage: 188.3 MB

5.1.1 결측치 보존

orders 테이블의 days_since_prior_order 컬럼은 이전 주문 후 경과된 일수를 나타내며, 첫 주문(‘order_number == 1’)인 경우 결측치(NaN 또는 Null)가 존재합니다.

전통적인 NumPy 백엔드에서는 결측치가 포함되면 원래 정수형인 컬럼이 float64로 자동 형변환되었지만, int64[pyarrow] 타입에서는 정수 타입을 유지하면서 결측치를 완벽하게 관리합니다.

orders.head(5)
order_id user_id ... order_hour_of_day days_since_prior_order
0 2539329 1 ... 8 <NA>
1 2398795 1 ... 7 15.0
2 473747 1 ... 12 21.0
3 2254736 1 ... 7 29.0
4 431534 1 ... 15 28.0

5 rows × 7 columns

5.2 Dataset & Scanner를 통한 필터링 및 Selective 로드

약 3,240만 행에 달하는 order_products__prior.parquet 데이터는 전체를 무작정 메모리에 불러오기보다, PyArrow Dataset과 Scanner를 사용해 필요한 컬럼만 선택하거나 스캔 단계에서 조건 필터링(Predicate Pushdown)을 적용하는 것이 효율적입니다.

5.2.1 조건부 스캔

재주문된 상품(reordered == 1) 데이터만 선택적으로 스캔하여 메모리로 불러옵니다. PyArrow의 Dataset API를 직접 사용할 수도 있지만, pandas의 read_parquet() 함수 내에서 filterscolumns 인자를 사용하면 PyArrow의 Predicate Pushdown 기능을 내부적으로 활용하면서 훨씬 간결하고 pandas 중심적인 코드를 작성할 수 있습니다.

# pandas read_parquet을 통한 조건부 데이터 로드 (Predicate Pushdown)
df_reordered = pd.read_parquet(
    data_dir / "order_products__prior.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
    columns=[
        "order_id",
        "product_id",
        "add_to_cart_order",
        "reordered",
    ],
    filters=[("reordered", "==", 1)],
)

print(f"전체 필터링 결과 행 수: {len(df_reordered):,}행")
df_reordered.head(5)
전체 필터링 결과 행 수: 19,126,536행
order_id product_id add_to_cart_order reordered
0 2 33120 1 1
1 2 28985 2 1
2 2 45918 4 1
3 2 17794 6 1
4 2 40141 7 1

이처럼 pandas 3.x에서는 PyArrow 백엔드를 지정함으로써, 복잡한 PyArrow 코드를 몰라도 강력한 성능 최적화 혜택을 누릴 수 있습니다.

5.3 탐색적 데이터 분석

5.3.1 요일별/시간대별 주문 패턴 분석

Instacart 고객들은 어떤 요일과 시간대에 주문을 가장 많이 실행할까요? orders 테이블을 바탕으로 groupby와 메서드 체인(method chaining)인 assign(), agg(), reset_index()을 결합하여 요일별 총 주문 건수와 평균 재주문 주기(일수)를 산출합니다.

dow_summary = (
    orders.groupby("order_dow", observed=True)
    .agg(
        total_orders=("order_id", "count"),
        avg_days_prior=("days_since_prior_order", "mean"),
    )
    .reset_index()
    .assign(
        day_name=lambda df: df["order_dow"].map(
            {
                0: "일요일",
                1: "월요일",
                2: "화요일",
                3: "수요일",
                4: "목요일",
                5: "금요일",
                6: "토요일",
            }
        )
    )
    .loc[
        :,
        [
            "order_dow",
            "day_name",
            "total_orders",
            "avg_days_prior",
        ],
    ]
)

dow_summary
order_dow day_name total_orders avg_days_prior
0 0 일요일 600905 11.773868
1 1 월요일 587478 11.311552
2 2 화요일 467260 11.169766
... ... ... ... ...
4 4 목요일 426339 10.522935
5 5 금요일 453368 10.508295
6 6 토요일 448761 11.438924

7 rows × 4 columns

위의 결과를 시각화하여 확인해 보겠습니다.

fig, ax1 = plt.subplots()
sns.barplot(
    data=dow_summary,
    x="day_name",
    y="total_orders",
    color="skyblue",
    ax=ax1,
)
ax1.set_title("요일별 총 주문 건수", fontsize=14)
ax1.set_xlabel("요일", fontsize=12)
ax1.set_ylabel("주문 건수", fontsize=12)

# 주문 건수 포맷팅 (K 단위)
ax1.yaxis.set_major_formatter(
    plt.FuncFormatter(lambda x, loc: f"{int(x/1000):,}K")
)
plt.tight_layout()
plt.show()
그림 5.1: 요일별 총 주문 건수. 요일 코드 0을 일요일로 가정했다.

일요일(0)과 월요일(1)에 가장 많은 주문(약 60만 건 이상)이 발생하고, 주중(수~목)에는 상대적으로 주문 건수가 감소하는 명확한 주말 및 주초 집중 패턴을 시각적으로 뚜렷하게 확인할 수 있습니다.

5.3.2 가장 인기 있는 TOP 10 상품 및 카테고리 분석

PyArrow Dataset으로 스캔한 주문 상품 데이터를 메타 데이터(products, aisles, departments)와 병합(merge)하여 Instacart에서 가장 높은 주문량을 기록한 상위 10개 상품을 추출합니다.

# pandas read_parquet을 사용한 선택적 컬럼 로드 (Selective Load)
df_prior = pd.read_parquet(
    data_dir / "order_products__prior.parquet",
    engine="pyarrow",
    dtype_backend="pyarrow",
    columns=[
        "product_id",
        "reordered",
        "add_to_cart_order",
    ],
)

top10_products = (
    df_prior.groupby("product_id", observed=True)
    .agg(
        total_orders=("reordered", "count"),
        reorder_rate=("reordered", "mean"),
    )
    .reset_index()
    .merge(products, on="product_id", how="inner")
    .merge(aisles, on="aisle_id", how="inner")
    .merge(departments, on="department_id", how="inner")
    .sort_values(by="total_orders", ascending=False)
    .head(10)
    .loc[
        :,
        [
            "product_name",
            "aisle",
            "department",
            "total_orders",
            "reorder_rate",
        ],
    ]
)

top10_products
product_name aisle department total_orders reorder_rate
24848 Banana fresh fruits produce 472565 0.843501
13172 Bag of Organic Bananas fresh fruits produce 379450 0.832555
21133 Organic Strawberries fresh fruits produce 264683 0.777704
... ... ... ... ... ...
16793 Strawberries fresh fruits produce 142951 0.698155
26204 Limes fresh fruits produce 140627 0.681007
27839 Organic Whole Milk milk dairy eggs 137905 0.830354

10 rows × 5 columns

결과를 가로 막대 그래프로 시각화해 보겠습니다.

fig, ax = plt.subplots()
sns.barplot(
    data=top10_products,
    y="product_name",
    x="total_orders",
    palette="viridis",
    hue="product_name",
    legend=False,
    ax=ax,
)
ax.set_title("가장 인기 있는 TOP 10 상품", fontsize=14)
ax.set_xlabel("총 주문 건수", fontsize=12)
ax.set_ylabel("상품명", fontsize=12)

ax.xaxis.set_major_formatter(
    plt.FuncFormatter(lambda x, loc: f"{int(x/1000):,}K")
)
plt.tight_layout()
plt.show()
그림 5.2: 주문 건수 기준 상위 10개 상품.

분석 결과, Banana, Bag of Organic Bananas, Organic Strawberries, Organic Baby Spinach 등 신선 식품(Produce) 부문의 품목들이 최상위를 독점하고 있으며, 재주문율(reorder_rate) 역시 75%~84%에 달할 정도로 높은 충성도를 보입니다.

5.3.3 장바구니 담는 순서와 재주문율의 관계

고객이 장바구니에 가장 먼저 담는 상품(1순위)과 나중에 담는 상품(10순위) 간의 재주문율 차이가 존재할까요?

cart_order_analysis = (
    df_prior.query("add_to_cart_order <= 10")
    .groupby("add_to_cart_order", observed=True)
    .agg(
        total_items=("reordered", "count"),
        reorder_rate=("reordered", "mean"),
    )
    .reset_index()
)

cart_order_analysis
add_to_cart_order total_items reorder_rate
0 1 3214874 0.677533
1 2 3058126 0.676251
2 3 2871133 0.658037
... ... ... ...
7 8 1766014 0.573247
8 9 1562640 0.561474
9 10 1378293 0.551018

10 rows × 3 columns

순서에 따른 재주문율의 변화 추이를 선 그래프로 살펴보겠습니다.

fig, ax = plt.subplots()
sns.lineplot(
    data=cart_order_analysis,
    x="add_to_cart_order",
    y="reorder_rate",
    marker="o",
    color="coral",
    linewidth=2,
    ax=ax,
)
ax.set_title(
    "장바구니 담는 순서에 따른 재주문율", fontsize=14
)
ax.set_xlabel("장바구니 담는 순서", fontsize=12)
ax.set_ylabel("재주문율", fontsize=12)
ax.set_xticks(range(1, 11))
ax.set_ylim(0.5, 0.7)

# 백분율 포맷팅
ax.yaxis.set_major_formatter(
    plt.FuncFormatter(lambda x, loc: f"{x:.0%}")
)
plt.grid(True, linestyle="--", alpha=0.6)
plt.tight_layout()
plt.show()
그림 5.3: 장바구니에 담는 순서와 재주문율의 관계.

시각화에서 뚜렷하게 나타나듯, 장바구니에 1번째로 담긴 상품의 재주문율은 약 67.8%인 반면, 10번째로 담긴 상품의 재주문율은 약 55.1%로 지속적으로 감소합니다. 이는 소비자가 평소 자주 습관적으로 구매하는 생필품을 장바구니에 가장 먼저 담는 경향이 있음을 시사합니다.

5.4 to_batches()를 활용한 대용량 스트리밍 집계

pandas의 read_parquet() 함수는 기본적으로 전체 데이터를 한 번에 메모리로 로드합니다. 데이터가 시스템의 메모리(RAM) 용량을 초과할 경우, OOM(out of memory) 에러가 발생합니다.

이러한 한계를 극복하기 위해, PyArrow 백엔드의 Dataset과 to_batches()를 활용하여 전체 데이터를 배치(Chunk) 단위로 순차적으로 받아 처리하는 스트리밍(Streaming) 패턴을 구성할 수 있습니다. 이 방법을 통해 수십~수백 GB 단위의 초대용량 데이터셋도 일정한 소량의 메모리(수 MB)만 사용하여 집계를 완수할 수 있습니다.

# 상품 ID -> 부서 ID 매핑 딕셔너리 생성
prod_to_dept = dict(
    zip(
        products["product_id"].to_numpy(),
        products["department_id"].to_numpy(),
    )
)

# 스트리밍을 위한 PyArrow Dataset 준비
prior_ds = ds.dataset(
    data_dir / "order_products__prior.parquet",
    format="parquet",
)
scanner_stream = prior_ds.scanner(
    columns=["product_id", "reordered"]
)

# 누적 상태 저장 딕셔너리
dept_counts = {}
dept_reorders = {}

# 배치(Batch) 단위 스트리밍 반복문
for batch in scanner_stream.to_batches():
    tbl_batch = pa.Table.from_batches([batch])
    df_batch = tbl_batch.to_pandas()
    df_batch["department_id"] = df_batch["product_id"].map(
        prod_to_dept
    )

    grp = df_batch.groupby("department_id", observed=True)[
        "reordered"
    ].agg(["count", "sum"])

    for dept_id, row in grp.iterrows():
        if pd.isna(dept_id):
            continue
        d_id = int(dept_id)
        dept_counts[d_id] = dept_counts.get(d_id, 0) + int(
            row["count"]
        )
        dept_reorders[d_id] = dept_reorders.get(
            d_id, 0
        ) + int(row["sum"])

# 최종 누적 결과를 데이터프레임으로 변환
dept_summary = (
    pd.DataFrame(
        {
            "department_id": list(dept_counts.keys()),
            "total_orders": list(dept_counts.values()),
            "reorder_sum": list(dept_reorders.values()),
        }
    )
    .assign(
        reorder_rate=lambda df: df["reorder_sum"]
        / df["total_orders"]
    )
    .merge(departments, on="department_id", how="inner")
    .sort_values(by="total_orders", ascending=False)
    .loc[:, ["department", "total_orders", "reorder_rate"]]
)

dept_summary
department total_orders reorder_rate
3 produce 9479291 0.649913
15 dairy eggs 5414016 0.669969
18 snacks 2887550 0.574180
... ... ... ...
20 missing 69145 0.395849
1 other 36291 0.407980
9 bulk 34573 0.577040

21 rows × 3 columns

부서별 총 주문 건수를 시각화하여 확인해 보겠습니다.

fig, ax = plt.subplots()
sns.barplot(
    data=dept_summary.head(10),
    y="department",
    x="total_orders",
    palette="magma",
    hue="department",
    legend=False,
    ax=ax,
)
ax.set_title(
    "부서(Department)별 총 주문 건수 TOP 10", fontsize=14
)
ax.set_xlabel("총 주문 건수", fontsize=12)
ax.set_ylabel("부서명", fontsize=12)

ax.xaxis.set_major_formatter(
    plt.FuncFormatter(lambda x, loc: f"{int(x/1000000):,}M")
)
plt.tight_layout()
plt.show()
그림 5.4: 부서(department)별 총 주문 건수 상위 10개.

그림 5.4를 보면 produce와 dairy eggs 부서가 전체 장바구니 주문의 압도적인 비중을 차지하고 있음을 알 수 있습니다. 반면, pantry나 dry goods pasta와 같은 장기 보관 식품은 상대적으로 낮은 재주문율(34%~46%)을 나타냅니다.