Hari 4 — Pandas Lanjutan: groupby, merge/join, pivot_table
Kamis, 2 jam. Setup & dataset: lihat 00_overview.md.
Tujuan Belajar
- Memakai
.groupby()+ agregasi, termasuk multi-aggregation dengan.agg() - Memakai
.merge()denganhow='inner'/'left'/'right'/'outer', dan mengenali risiko row multiplication - Memakai
.pivot_table()untuk ringkasan 2 dimensi - Terbiasa
.reset_index()setelahgroupbyagar hasilnya gampang dipakai lagi
Untuk Instruktur: Mindset Shift
Ini sesi penyambung paling eksplisit ke Minggu 1 — .groupby() ≈ GROUP BY, .merge() ≈ JOIN. Kalau ada waktu, paling efektif: ambil ulang 2-3 query SQL dari materi/minggu_1/hari_2_agregasi_groupby.md dan hari_3_join.md, minta peserta tulis versi Pandas-nya sendiri sebelum ditunjukkan caranya — mereka sudah punya jawaban SQL sebagai “spek” yang harus direplikasi hasilnya.
Kesalahan yang paling penting diantisipasi: .merge() bisa diam-diam memperbanyak baris kalau key join-nya tidak unik di salah satu sisi — persis versi Pandas dari peringatan “cartesian product” di materi/minggu_1/hari_3_join.md. Developer yang tidak sadar row count-nya membengkak setelah merge adalah salah satu bug paling umum & paling sulit dilacak di pipeline data nyata (biasanya ketauannya belakangan, waktu angka agregat tiba-tiba dobel).
Konsep & Sintaks
groupby — padanan GROUP BY
order_items.groupby("order_id")["subtotal"].sum() # 1 agregasi
order_items.groupby("order_id")["subtotal"].agg(["sum", "count"]) # beberapa agregasi sekaligus
order_items.groupby("order_id").agg(
total_revenue=("subtotal", "sum"),
jumlah_item=("subtotal", "count")
) # named aggregation — hasil kolom langsung dikasih nama, lebih terbaca
Penting: hasil groupby punya kolom yang di-groupby sebagai index, bukan kolom biasa. Kalau mau dipakai lagi seperti DataFrame normal (misal buat di-merge lagi), panggil .reset_index():
revenue_per_order = order_items.groupby("order_id")["subtotal"].sum().reset_index()
# sekarang order_id jadi kolom biasa lagi, bukan index
merge — padanan JOIN
pd.merge(df_kiri, df_kanan, on="key_column", how="inner") # atau df_kiri.merge(df_kanan, ...)
Parameter how= | Padanan SQL |
|---|---|
'inner' (default) | INNER JOIN |
'left' | LEFT JOIN |
'right' | RIGHT JOIN |
'outer' | FULL OUTER JOIN |
customers.merge(orders, on="customer_id", how="inner") # cuma customer yang punya order
customers.merge(orders, on="customer_id", how="left") # semua customer, order-nya NaN kalau gak ada
Kalau nama kolom key beda di kedua tabel: left_on="customer_id", right_on="cust_id". Kalau ada kolom lain (bukan key) yang namanya sama di kedua tabel, Pandas otomatis kasih suffix _x/_y — lebih baik kasih suffix eksplisit yang jelas maknanya: suffixes=("_order", "_product").
pivot_table — ringkasan 2 dimensi
df.pivot_table(index="category", columns="month", values="revenue", aggfunc="sum", fill_value=0)
Ini seperti bikin tabel GROUP BY category, month lalu “diputar” supaya month jadi kolom, bukan baris — mirip pivot table di spreadsheet. fill_value=0 mengisi kombinasi yang tidak ada datanya dengan 0, bukan NaN.
Contoh Kode
import pandas as pd
customers = pd.read_csv("data/customers.csv")
products = pd.read_csv("data/products.csv")
orders = pd.read_csv("data/orders.csv")
order_items = pd.read_csv("data/order_items.csv")
order_items["subtotal"] = order_items["quantity"] * order_items["unit_price"]
1. Padanan query GROUP BY order_id (Hari 2 Minggu 1, contoh #3)
order_items.groupby("order_id")["subtotal"].sum()
Hasil identik dengan tabel di materi/minggu_1/hari_2_agregasi_groupby.md: order 9 = 240.00 tertinggi, dst.
2. Padanan revenue per customer (JOIN 3 tabel + GROUP BY, completed saja)
merged = (order_items
.merge(orders, on="order_id")
.merge(customers, on="customer_id"))
completed = merged[merged["status"] == "completed"]
completed.groupby("customer_name")["subtotal"].sum().sort_values(ascending=False)
Hasil: Grace Tan (240.00), Citra Lestari (235.50), Andi Wijaya (118.50), Budi Santoso (118.00), Emma Watson (60.00), Frank Muller (35.00) — identik persis dengan jawaban Latihan #1 di materi/minggu_1/hari_5_window_function.md.
3. LEFT merge — cari produk yang belum pernah terjual (padanan LEFT JOIN ... WHERE ... IS NULL)
prod_check = products.merge(order_items, on="product_id", how="left")
prod_check[prod_check["order_item_id"].isna()][["product_name"]]
Hasil: Desk Lamp — identik dengan contoh #3 hari_3_join.md Minggu 1. Pola .merge(..., how="left") lalu filter [kolom_kanan].isna() adalah padanan langsung idiom SQL “cari yang tidak ada pasangannya”.
4. pivot_table — revenue per kategori per bulan (completed saja)
completed = completed.copy()
completed["order_date"] = pd.to_datetime(completed["order_date"])
completed["month"] = completed["order_date"].dt.to_period("M")
completed.pivot_table(index="category", columns="month", values="subtotal", aggfunc="sum", fill_value=0)
| category | 2024-01 | 2024-02 | 2024-03 | |—|—|—|—| | Electronics | 100.0 | 60.0 | 160.0 | | Furniture | 0.0 | 120.0 | 240.0 | | Kitchen | 16.0 | 8.0 | 40.0 | | Stationery | 10.5 | 35.0 | 17.5 |
Total tiap kolom bulan harus sama dengan hasil GROUP BY month di materi/minggu_1/hari_4_subquery_cte.md contoh #6 (126.5 / 223.0 / 457.5) — coba jumlahkan tiap kolom sebagai latihan verifikasi silang.
Kesalahan Umum
mergememperbanyak baris diam-diam kalau key tidak unik di salah satu sisi.discounts = pd.DataFrame({ "category": ["Electronics", "Electronics", "Furniture"], "campaign": ["Ramadan Sale", "Year End Sale", "Ramadan Sale"], }) result = products.merge(discounts, on="category", how="left") print(len(products), "->", len(result)) # 7 -> 10 !!categorybukan key unik didiscounts(Electronics muncul 2x) — tiap produk Electronics jadi digandakan sesuai jumlah campaign yang match. Ini bukan bug Pandas, ini konsekuensi logis dari JOIN/merge relasional (sama seperti cartesian product di SQL), tapi sangat mudah tidak disadari kalau tidak dicek. Kebiasaan wajib: selalu bandingkanlen(df)sebelum dan sesudahmerge, terutama untuk merge yang bukan jelas-jelas 1:1.-
Lupa
.reset_index()setelahgroupby, lalu bingung kenapa kolom groupby-nya “hilang”. Kolom hasilgroupbyjadi index, bukan kolom biasa — kalau langsung dipakai untukmergelagi tanpareset_index(), akan error atau perilakunya tidak sesuai ekspektasi. -
how="inner"(default) diam-diam membuang baris yang tidak match, sama seperti lupa pilih jenis JOIN di SQL. Selalu putuskanhow=secara sadar, jangan biarkan defaultinnerdipakai tanpa dipikir — terutama kalau maksudnya “semua data kiri harus tetap muncul” (butuhhow="left", bukan default). - Overlapping column name tanpa
suffixeseksplisit bikin bingungcol_x/col_yyang mana. Kasihsuffixes=("_kiri", "_kanan")yang deskriptif kalau tahu bakal ada tabrakan nama kolom.
Latihan
- Hitung total quantity dan total revenue per
product_iddariorder_items(padanan Latihan #2 Hari 2 Minggu 1). - Gabungkan
customersdanordersdenganhow="left", lalu hitung jumlah order per customer (termasuk yang 0) — padanan contoh #2hari_3_join.mdMinggu 1. - Cari kategori produk dengan total revenue tertinggi (completed saja) — butuh merge 3 tabel.
- Buat
pivot_tablejumlah order (count, bukansum) percustomer_idperstatus. - Ada 2 DataFrame kecil:
orders_a = pd.DataFrame({"order_id":[1,2,3], "amount":[100,200,300]})danorders_b = pd.DataFrame({"order_id":[2,3,4], "shipped":[True, False, True]}). Merge denganhow="outer", lalu identifikasi baris mana yangNaNdi kolomamountdan baris mana yangNaNdi kolomshipped— jelaskan artinya masing-masing.
Kunci Jawaban & Pembahasan
1.
order_items.groupby("product_id").agg(
total_qty=("quantity", "sum"),
total_revenue=("subtotal", "sum")
).sort_values("total_revenue", ascending=False)
Hasil teratas: product_id 4 (Office Chair, 360.00), product_id 2 (Keyboard, 180.00) — sama seperti jawaban Hari 2 Minggu 1.
2.
cust_orders = customers.merge(orders, on="customer_id", how="left")
cust_orders.groupby("customer_name")["order_id"].count().sort_values(ascending=False)
Hasil: Andi Wijaya (3), Budi Santoso (3), Citra Lestari (2), Emma Watson (2), Frank Muller (1), Grace Tan (1), Hassan Ali (0) — identik dengan tabel di hari_3_join.md contoh #2. Perhatikan dipakai .count() pada kolom order_id (bukan .size()) — .count() mengabaikan NaN (persis kenapa COUNT(o.order_id) dipakai, bukan COUNT(*), di versi SQL-nya).
3.
merged = (order_items
.merge(orders, on="order_id")
.merge(products, on="product_id"))
completed = merged[merged["status"] == "completed"]
completed.groupby("category")["subtotal"].sum().sort_values(ascending=False)
Hasil: Furniture (360.00), Electronics (320.00), Kitchen (64.00), Stationery (63.00) — identik dengan hari_3_join.md Latihan #3.
4.
cust_orders.pivot_table(index="customer_id", columns="status", values="order_id", aggfunc="count", fill_value=0)
Hasil: tiap baris customer, kolom completed/cancelled/refunded isinya jumlah order per status, 0 untuk kombinasi yang tidak ada. Tapi perhatikan: Hassan Ali (customer_id 7) tidak muncul sama sekali di hasil pivot, bukan muncul dengan semua kolom 0 seperti dugaan intuitif. Penyebabnya: status milik Hassan bernilai NaN (dia tidak punya order sama sekali), dan pivot_table secara default membuang baris yang nilai kolom columns=-nya NaN — beda dari fill_value yang cuma mengisi kombinasi yang kosong, bukan baris yang datanya sendiri NaN di dimensi columns. Ini gotcha nyata yang gampang lolos code review: kalau butuh Hassan tetap muncul dengan 0, harus reindex manual dengan daftar lengkap customer_id setelah pivot.
5.
result = orders_a.merge(orders_b, on="order_id", how="outer")
print(result)
| order_id | amount | shipped | |—|—|—| | 1 | 100.0 | NaN | | 2 | 200.0 | True | | 3 | 300.0 | False | | 4 | NaN | True |
order_id=1 punya shipped = NaN — artinya order ini ada di orders_a tapi tidak ada datanya di orders_b (mungkin belum pernah di-cek status pengirimannya). order_id=4 punya amount = NaN — ada di orders_b tapi tidak ada di orders_a (mungkin order ini belum tercatat di sistem billing). Ini pola yang sama persis dengan diskusi FULL OUTER JOIN untuk rekonsiliasi 2 sumber data di materi/minggu_1/hari_3_join.md — kali ini dipraktikkan dengan data yang benar-benar tidak dijamin sinkron satu sama lain (orders_b tidak punya foreign key constraint apapun ke orders_a).