Danh sách bài viết

Bài 41: Groupby và Aggregation cơ bản

Split-apply-combine với Pandas groupby: aggregation cơ bản (mean, sum, count, size, nunique), agg() và named aggregation, group theo nhiều cột, transform() giữ shape, filter() giữ group thoả điều kiện, apply() linh hoạt, pivot_table, value_counts và use case trong feature engineering. Bài cuối Series 1 kèm tổng kết kiến thức trước khi sang Series 2.

24/05/2026
15 phút đọc
0 lượt xem
1

Mục tiêu bài học

  • Hiểu pattern split-apply-combine và tại sao nó là công cụ phân tích trung tâm trong Pandas.
  • Dùng df.groupby() với 1 hoặc nhiều cột; biết kết quả là DataFrameGroupBy lazy.
  • Thực hiện aggregation cơ bản: mean, sum, count, size, nunique; phân biệt countsize.
  • Dùng agg(), named aggregation, custom function, transform(), filter(), apply() đúng tình huống.
  • Preview pivot_tablevalue_counts trong tương quan với groupby.
  • Biết các pitfall (NaN trong key, warning với cột string trong Pandas 2.x).
2

Vì sao cần groupby

Rất nhiều câu hỏi data trong thực tế có cùng dạng: "tính một con số nào đó theo từng nhóm". Ví dụ:

  • Doanh số trung bình theo từng region.
  • Số user theo từng subscription plan.
  • Tỉ lệ chuyển đổi theo từng nguồn traffic.
  • Click-through rate trung bình theo từng phiên bản A/B test.

Trong SQL bạn viết SELECT region, AVG(sales) FROM ... GROUP BY region. Trong Pandas đó là df.groupby("region")["sales"].mean(). Cùng tư duy, cùng kết quả; nhưng API Pandas còn cho phép nhiều phép biến đổi mà SQL phải vòng vèo qua window function.

Groupby là 1 trong 3 nhóm thao tác bạn dùng đi dùng lại trong mọi notebook EDA: filter row, group + aggregate, và join. Nắm chắc groupby là nắm được phần lớn data wrangling thường ngày.

3

Pattern split-apply-combine

Hadley Wickham (2011) đặt tên pattern này trong bài báo cùng tên — và đó cũng là cách Pandas tổ chức nội bộ groupby:

  • Split: chia DataFrame thành các nhóm con dựa trên giá trị của 1 hay nhiều cột key.
  • Apply: áp dụng 1 hàm trên mỗi nhóm — có thể là aggregation (Series → scalar), transformation (Series → Series cùng shape), hoặc filter (Series → boolean).
  • Combine: gộp các kết quả con lại thành 1 cấu trúc duy nhất (Series, DataFrame hoặc cùng shape với đầu vào).

Bốn API chính của Pandas tương ứng với 3 kiểu "apply":

  • agg() / aggregation method (.mean(), .sum()...) — apply trả scalar.
  • transform() — apply trả Series cùng shape với group đầu vào; kết quả cuối cùng có cùng shape với DataFrame gốc.
  • filter() — apply trả True/False, giữ nguyên cả group hoặc loại bỏ cả group.
  • apply() — bao trùm cả 3 loại trên, linh hoạt nhất nhưng cũng chậm nhất.
4

df.groupby() trả về cái gì

df.groupby("col") KHÔNG trả về DataFrame. Nó trả về 1 đối tượng DataFrameGroupBy "lazy": Pandas mới chỉ ghi nhớ "sẽ chia theo cột này", chưa compute gì cả. Bạn cần gọi tiếp một aggregation/transform/filter để Pandas thực sự làm.

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
    "units":  [10, 12, 11, 7, 18, 9],
})

g = df.groupby("region")
print(g)
# <pandas.core.groupby.generic.DataFrameGroupBy object at 0x...>

# Chỉ khi gọi aggregation thì mới ra kết quả
print(g["sales"].mean())
# region
# East      80.000000
# North    103.333333
# South    175.000000
# Name: sales, dtype: float64

DataFrameGroupBy hỗ trợ một số thao tác hữu ích trước khi aggregate:

# Liệt kê các nhóm và index thuộc nhóm đó
print(g.groups)
# {'East': [3], 'North': [0, 2, 5], 'South': [1, 4]}

# Lấy đúng 1 nhóm
print(g.get_group("North"))

# Iterate qua các nhóm — name là giá trị key, group là DataFrame con
for name, group in g:
    print(name, "->", len(group), "rows")

Iterate chỉ dùng khi cần làm gì đó phức tạp không vừa với API tổng quát. Trong 99% trường hợp, gọi thẳng agg, transform, hoặc apply đủ và nhanh hơn nhiều.

5

Aggregation cơ bản

Pandas cung cấp sẵn nhiều aggregation method dưới dạng attribute trực tiếp trên GroupBy:

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
    "units":  [10, 12, 11, 7, 18, 9],
})

g = df.groupby("region")

print(g.mean(numeric_only=True))   # trung bình từng cột số
print(g.sum(numeric_only=True))    # tổng
print(g.count())                   # đếm non-NaN, mỗi cột riêng
print(g.size())                    # tổng số row mỗi group (kể cả NaN)
print(g.min(numeric_only=True))    # min từng cột số
print(g.max(numeric_only=True))    # max
print(g.std(numeric_only=True))    # std mẫu
print(g.var(numeric_only=True))    # variance
print(g.median(numeric_only=True)) # trung vị
print(g["region"].first())         # giá trị đầu mỗi group
print(g["region"].last())          # giá trị cuối
print(g["sales"].nunique())        # số giá trị duy nhất

Phân biệt 2 cái dễ nhầm:

  • count() trả về DataFrame: đếm non-NaN riêng cho từng cột. Nếu cột có NaN, kết quả 2 cột có thể khác nhau.
  • size() trả về Series: tổng số row mỗi group, kể cả row có NaN.

Tham số numeric_only=True bỏ qua các cột không phải số; trong Pandas 2.x nếu để mặc định và có cột string, một số phép như mean sẽ raise TypeError hoặc warning. Để cho code rõ ràng, nên truyền tường minh.

6

agg() với nhiều hàm

agg() (alias: aggregate()) là chiếc API "đa năng" cho phép tính nhiều thống kê cùng lúc.

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
    "units":  [10, 12, 11, 7, 18, 9],
})

# 1) Một cột, nhiều aggregation
print(df.groupby("region")["sales"].agg(["mean", "max", "min", "std"]))
#         mean  max  min        std
# region
# East    80.0   80   80        NaN
# North  103.33  120   90  15.275252
# South  175.0   200  150  35.355339

# 2) Per-column aggregation với dict
print(df.groupby("region").agg({"sales": "mean", "units": "sum"}))
#         sales  units
# region
# East     80.0      7
# North   103.33    30
# South   175.0    30

# 3) Named aggregation (Pandas 0.25+) — KHUYẾN NGHỊ
print(
    df.groupby("region").agg(
        avg_sales=("sales", "mean"),
        max_sales=("sales", "max"),
        total_units=("units", "sum"),
    )
)
#         avg_sales  max_sales  total_units
# region
# East        80.00         80            7
# North      103.33        120           30
# South      175.00        200           30

Cú pháp named aggregation đặc biệt tiện vì kết quả ra DataFrame với cột được đặt tên rõ ràng — code dễ đọc hơn, và không sinh ra MultiIndex cột (vốn khá khó dùng tiếp).

Bạn cũng có thể truyền function (không phải string) vào agg: g["sales"].agg(np.mean), g["sales"].agg(lambda s: s.quantile(0.9))...

7

Group theo nhiều cột

Truyền list các cột vào groupby để chia theo tổ hợp nhiều cột:

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "North", "North", "South", "South", "South"],
    "month":  ["Jan", "Feb", "Jan", "Jan", "Feb", "Feb"],
    "sales":  [100, 120, 90, 150, 200, 180],
})

# Kết quả có MultiIndex (region, month)
print(df.groupby(["region", "month"])["sales"].sum())
# region  month
# North   Feb      120
#         Jan      190
# South   Feb      380
#         Jan      150
# Name: sales, dtype: int64

# as_index=False để giữ cột phẳng (không MultiIndex)
print(df.groupby(["region", "month"], as_index=False)["sales"].sum())
#   region month  sales
# 0  North   Feb    120
# 1  North   Jan    190
# 2  South   Feb    380
# 3  South   Jan    150

MultiIndex result chuẩn hơn về mặt cấu trúc (mỗi level là 1 key) nhưng đôi khi gây khó khi chain với các API khác. as_index=False hoặc .reset_index() để có DataFrame "phẳng" — thường tiện hơn khi cần export, join hay đem vẽ chart.

8

Custom aggregation function

Khi cần hàm không có sẵn, truyền function bất kỳ nhận 1 Series và trả 1 scalar:

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
})

# Tầm dao động (range) trong mỗi nhóm
print(df.groupby("region")["sales"].agg(lambda s: s.max() - s.min()))
# region
# East      0
# North    30
# South    50
# Name: sales, dtype: int64

# Tỉ lệ top-1 trên total trong nhóm
def top_share(s: pd.Series) -> float:
    return s.max() / s.sum()

print(df.groupby("region")["sales"].agg(top_share))

Function dạng này được gọi 1 lần cho mỗi group. Mỗi lần input là 1 pd.Series tương ứng với 1 cột × 1 group; output bắt buộc phải là 1 scalar (số, string, hoặc None).

9

transform() — giữ nguyên shape

transform() khác agg() ở chỗ kết quả CÙNG SHAPE với DataFrame gốc — phù hợp khi bạn muốn gắn 1 giá trị tính theo group vào TỪNG ROW.

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
})

# 1) Trung bình theo region, gắn vào từng row
df["region_avg"] = df.groupby("region")["sales"].transform("mean")
print(df)
#   region  sales  region_avg
# 0  North    100  103.333333
# 1  South    150  175.000000
# 2  North    120  103.333333
# 3   East     80   80.000000
# 4  South    200  175.000000
# 5  North     90  103.333333

# 2) Z-score TRONG mỗi nhóm (chuẩn hoá theo group)
df["sales_z"] = df.groupby("region")["sales"].transform(
    lambda s: (s - s.mean()) / s.std()
)

# 3) Điền NaN của cột "sales" bằng MEAN CỦA CÙNG REGION
# df["sales"] = df.groupby("region")["sales"].transform(lambda s: s.fillna(s.mean()))

Use case transform phổ biến trong ML / phân tích:

  • Per-group normalization: z-score theo region, ngành, cohort.
  • Per-group imputation: fill NaN bằng mean/median của cùng nhóm (đã thấy ở bài missing data).
  • Feature engineering: lệch của 1 giá trị so với trung bình nhóm, tỉ lệ trên tổng nhóm.
10

filter() — giữ group thoả điều kiện

filter() nhận 1 function trả về True/False trên mỗi group; nếu True thì giữ TOÀN BỘ group, ngược lại bỏ. Kết quả là DataFrame gốc đã thu hẹp.

import pandas as pd

df = pd.DataFrame({
    "city":   ["HN", "HN", "HCM", "DN", "DN", "HCM", "HN"],
    "sales":  [100, 110, 90, 70, 80, 120, 95],
})

# Chỉ giữ city có >= 3 record
print(df.groupby("city").filter(lambda g: len(g) >= 3))
#   city  sales
# 0   HN    100
# 1   HN    110
# 6   HN     95

# Chỉ giữ city có tổng sales > 200
print(df.groupby("city").filter(lambda g: g["sales"].sum() > 200))

Đây là 1 trong những use case sạch đẹp nhất của split-apply-combine: thay vì phải viết 2 bước (compute size mỗi nhóm, join lại, filter), bạn diễn đạt thẳng "loại các group nhỏ".

11

apply() — linh hoạt nhất

apply() truyền nguyên 1 DataFrame con của mỗi group vào function — function có thể trả về scalar, Series, hoặc DataFrame; Pandas sẽ tự ghép lại. Linh hoạt nhất, nhưng cũng chậm nhất vì không tối ưu được nội bộ như agg/transform.

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "North", "East", "South", "North"],
    "sales":  [100, 150, 120, 80, 200, 90],
    "units":  [10, 12, 11, 7, 18, 9],
})

# Trả về Series: top 2 sales trong mỗi region
print(df.groupby("region").apply(lambda g: g.nlargest(2, "sales")))

# Trả về DataFrame nhỏ tự thiết kế cho từng group
def summarize(g: pd.DataFrame) -> pd.Series:
    return pd.Series({
        "n":         len(g),
        "total":     g["sales"].sum(),
        "avg_price": g["sales"].sum() / g["units"].sum(),
    })

print(df.groupby("region").apply(summarize))

Quy tắc thực hành: ưu tiên agg / transform / filter nếu vừa; chỉ rơi xuống apply khi cấu trúc output không vừa cả 3. Trên dataset lớn (hàng triệu row), apply có thể chậm gấp nhiều lần và là điểm nghẽn phổ biến trong notebook EDA.

12

pivot_table preview

pivot_table là một dạng "view" của groupby — nó cũng là split-apply-combine, nhưng kết quả được trải ra dạng bảng chéo (cross-tab) với 1 trục là index, 1 trục là columns:

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "North", "South", "South", "East"],
    "month":  ["Jan", "Feb", "Jan", "Feb", "Jan"],
    "sales":  [100, 120, 150, 200, 80],
})

print(df.pivot_table(values="sales", index="region", columns="month", aggfunc="sum"))
# month   Feb    Jan
# region
# East    NaN   80.0
# North   120.0 100.0
# South   200.0 150.0

Tương đương với df.groupby(["region", "month"])["sales"].sum().unstack("month") — cùng dữ liệu, layout khác. pivot_table thuận khi cần báo cáo dạng bảng cho người đọc; groupby + agg thuận khi tiếp tục pipeline tính toán.

13

value_counts() vs groupby().size()

2 cách viết cùng làm 1 việc: đếm số row theo từng giá trị của 1 cột.

import pandas as pd

df = pd.DataFrame({"city": ["HN", "HN", "HCM", "DN", "HCM", "HN"]})

print(df["city"].value_counts())
# city
# HN     3
# HCM    2
# DN     1
# Name: count, dtype: int64

print(df.groupby("city").size())
# city
# DN     1
# HCM    2
# HN     3
# dtype: int64

Khác biệt nhỏ:

  • value_counts() mặc định sort theo count giảm dần; groupby().size() mặc định sort theo key.
  • value_counts(normalize=True) trả về tỉ lệ (proportion) — rất hay dùng cho EDA.
  • value_counts bỏ NaN mặc định; truyền dropna=False để giữ.
14

Pitfall thường gặp

  • NaN trong cột key bị loại: mặc định groupby bỏ qua row có NaN ở cột key. Nếu muốn giữ, dùng df.groupby("col", dropna=False) (Pandas 1.1+).
  • Cột string trong aggregation số: Pandas 2.x raise warning hoặc lỗi khi gọi .mean() trên DataFrame có cột string. Dùng numeric_only=True hoặc chọn cột tường minh trước: g["sales"].mean().
  • MultiIndex sau group nhiều cột: kết quả có index 2 level; dùng reset_index() hoặc as_index=False để có DataFrame phẳng cho dễ join.
  • Nhầm count với size: count bỏ NaN per-column, size đếm tổng row. Nếu có NaN, 2 con số sẽ khác.
  • Lạm dụng apply: apply không tận dụng được vectorization; với dataset hàng triệu row, đổi sang agg/transform thường nhanh gấp nhiều lần.
  • Sort không ổn định: groupby mặc định sort theo key (sort=True). Tắt bằng sort=False nếu muốn giữ thứ tự xuất hiện — đôi khi nhanh hơn trên dataset lớn.
  • Quên là groupby lazy: gán g = df.groupby(...) không hề tốn chi phí compute; chỉ khi gọi tiếp aggregation mới chạy. Có thể tái dùng g nhiều lần.
15

Use case AI/ML

  • Aggregate feature per user/session: trung bình số click, tổng thời gian session, max amount mua hàng — rồi gắn vào table user để làm feature cho model.
  • Group by class label để tính class statistics: mean / std từng feature trong từng class — giúp hiểu xem feature có phân biệt được class không (intuition cho linear model, hoặc xây Naive Bayes thủ công).
  • Time series resampling (preview): df.resample("D")["sales"].sum() là groupby chuyên cho time, group các record theo bucket thời gian.
  • Feature engineering theo group: transform("mean") để tạo cột "trung bình spending theo region của user này" — rất phổ biến trong các bài tabular ML có cột categorical cardinality cao.
  • A/B test analysis: df.groupby("variant").agg(n=("user_id", "nunique"), conv=("converted", "mean")) ra ngay bảng size + conversion rate cho từng variant.
  • Class balance check: df["target"].value_counts(normalize=True) hoặc df.groupby("target").size() để biết tỉ lệ class trước khi train classifier.
16

Code Python tổng hợp

import numpy as np
import pandas as pd

# Dataset giả lập: đơn hàng theo region và category
rng = np.random.default_rng(42)
df = pd.DataFrame({
    "order_id": range(1, 13),
    "region":   ["North", "South", "North", "East",
                 "South", "North", "East", "South",
                 "North", "South", "East", "North"],
    "category": ["A", "B", "A", "A", "B", "B",
                 "A", "B", "A", "A", "B", "B"],
    "amount":   rng.integers(50, 500, size=12),
    "units":    rng.integers(1, 10, size=12),
})

# --- 1) Aggregation cơ bản theo region ---
print("Doanh số trung bình theo region:")
print(df.groupby("region")["amount"].mean().round(2))

# --- 2) Nhiều agg với named aggregation ---
print("\nThống kê theo region:")
print(
    df.groupby("region").agg(
        n_orders=("order_id", "count"),
        total_amount=("amount", "sum"),
        avg_amount=("amount", "mean"),
        max_amount=("amount", "max"),
        total_units=("units", "sum"),
    ).round(2)
)

# --- 3) Group theo (region, category) — MultiIndex ---
print("\nTổng doanh số theo (region, category):")
print(df.groupby(["region", "category"])["amount"].sum())

# --- 4) Transform: z-score amount TRONG mỗi region ---
df["amount_z_in_region"] = df.groupby("region")["amount"].transform(
    lambda s: (s - s.mean()) / s.std()
)
print("\nDataFrame với z-score per region:")
print(df[["region", "amount", "amount_z_in_region"]].round(3))

# --- 5) Filter: chỉ giữ region có >= 4 đơn ---
print("\nChỉ giữ region có >= 4 đơn:")
print(df.groupby("region").filter(lambda g: len(g) >= 4))

# --- 6) Custom agg: tầm dao động (range) amount ---
print("\nTầm dao động amount theo region:")
print(df.groupby("region")["amount"].agg(lambda s: s.max() - s.min()))
17

Bài tập

  1. Tạo DataFrame đơn hàng với 3 cột category, price, quantity (~20 row, có lặp category). Tính revenue = price * quantity, rồi tính doanh thu trung bình theo từng category bằng groupby("category")["revenue"].mean().
  2. Tạo DataFrame sinh viên với cột class_idstudent_id. Đếm số sinh viên mỗi lớp bằng groupby("class_id").size(). Đếm lại bằng value_counts() và so sánh.
  3. Với DataFrame có cột region, customer_id, spending: tìm khách hàng có total spending cao nhất trong MỖI region (gợi ý: df.groupby(["region", "customer_id"])["spending"].sum().groupby(level=0).idxmax()).
  4. Z-score normalization theo group: tạo cột score_z bằng df.groupby("class_id")["score"].transform(lambda s: (s - s.mean()) / s.std()). Kiểm tra: df.groupby("class_id")["score_z"].agg(["mean", "std"]).round(3) — mean ≈ 0 và std ≈ 1 trong mỗi nhóm.
  5. (Mở rộng) Dùng pivot_table tạo bảng doanh số theo region × month. So sánh code với groupby + unstack — kết quả phải giống.
18

Tổng kết Series 1

Đây là bài cuối của Series 1. Nhìn lại 41 bài, khối kiến thức nền tảng đã đi qua gồm 4 nhóm:

  • Python cơ bản — kiểu dữ liệu, control flow, function, list / dict comprehension, file I/O, virtualenv. Đây là ngôn ngữ làm việc chính.
  • Toán nền tảng — đại số tuyến tính (vector, matrix, dot product, eigen), giải tích (đạo hàm, gradient, gradient descent ở B27), xác suất - thống kê (mean / variance / std, phân phối, Bayes).
  • NumPy — array, dtype, indexing, broadcasting, vectorization, các phép linalg cơ bản. Là backend tính toán cho gần như mọi thư viện ML/DL.
  • Pandas — Series / DataFrame, đọc - ghi CSV / Parquet, indexing (loc / iloc), filtering, sort, missing data, và groupby trong bài này.

Series 2 (Machine Learning Foundations) sẽ dùng lại tất cả: sklearn xây trên NumPy và Pandas; gradient descent (B27) là cốt lõi training của hầu hết model; mean / std (B22) cho normalization và scaling; xử lý missing data (B40) cho preprocessing pipeline; groupby (B41) cho feature engineering theo nhóm.

Không cần thuộc lòng từng API. Quay lại tra cứu bài tương ứng khi cần. Mỗi bài Series 2 sẽ giả định bạn đã quen với cấu trúc DataFrame, np.ndarray, và biết tự viết 1 pipeline đơn giản load - clean - feature - split.

19

Tóm tắt bài

  • Groupby = pattern split-apply-combine: chia theo key, áp dụng hàm trên mỗi nhóm, gộp kết quả.
  • df.groupby("col") lazy, trả về DataFrameGroupBy; phải gọi tiếp aggregation/transform/filter để compute.
  • Aggregation cơ bản: mean, sum, count (non-NaN per column), size (tổng row per group), nunique, min, max, std, var, median.
  • agg() đa năng; named aggregation (avg=("x","mean")) là cú pháp được khuyến nghị vì output có cột đặt tên rõ ràng.
  • transform() giữ shape — dùng cho z-score per group, fill NaN per group, feature engineering.
  • filter() giữ hoặc loại nguyên cả group theo điều kiện.
  • apply() linh hoạt nhất nhưng chậm — chỉ dùng khi 3 cái trên không vừa.
  • pivot_table là groupby + unstack; value_counts() là alias tiện cho groupby().size() (kèm sort desc và normalize).
  • Pitfall: NaN trong key bị loại (dùng dropna=False), warning với cột string (dùng numeric_only=True), nhầm count với size.