Bài 2

Transform & Làm sạch dữ liệu

Học cách biến dữ liệu thô thành dữ liệu dùng được: tổng hợp theo nhóm, tính toán theo cửa sổ (window), ghép bảng (join), xử lý giá trị thiếu và làm sạch dữ liệu bẩn — những thao tác bạn dùng hằng ngày khi làm việc với dữ liệu thật.

Trước khi bắt đầu: Bài này giả định bạn đã load 2 DataFrame từ Bài 1: sales (order_id, customer_id, category, qty, unit_price, payment, order_date) và customers (customer_id, name, region, vip). Nếu chưa, mở lại notebook Bài 1 chạy phần đọc dữ liệu trước.

Aggregation: groupBy & agg
Tổng hợp dữ liệu theo nhóm

Tổng hợp là thao tác cơ bản nhất: gom các dòng theo một (hoặc nhiều) cột rồi tính toán trên từng nhóm — ví dụ doanh thu theo từng category. Trong PySpark, ta dùng groupBy() rồi agg().

from pyspark.sql import functions as F

(sales.groupBy("category")
      .agg(
          F.sum(F.col("qty") * F.col("unit_price")).alias("revenue"),
          F.count("*").alias("orders"),
          F.avg("unit_price").alias("avg_price")
      )
      .orderBy(F.desc("revenue"))
      .show())

groupBy("category") trả về một đối tượng GroupedData — chưa phải DataFrame. Bạn phải gọi tiếp agg() để biến nó về DataFrame. agg() nhận nhiều hàm tổng hợp cùng lúc, và .alias() dùng để đặt tên cột kết quả cho dễ đọc. Cuối cùng orderBy(F.desc(...)) sắp xếp giảm dần theo doanh thu.

Các hàm tổng hợp hay dùng trong module F:

HàmÝ nghĩa
F.sum(col)Tổng các giá trị trong nhóm
F.avg(col)Giá trị trung bình (mean)
F.count("*")Đếm số dòng trong nhóm
F.countDistinct(col)Đếm số giá trị khác nhau
F.min(col) / F.max(col)Giá trị nhỏ nhất / lớn nhất
F.stddev(col)Độ lệch chuẩn
Mẹo: có thể group theo nhiều cột: groupBy("category", "payment"). Nếu chỉ cần một con số tổng cho cả bảng (không nhóm), dùng thẳng sales.agg(F.sum("qty")).
Window function
Tính toán theo "khung" mà vẫn giữ nguyên số dòng

Khác biệt mấu chốt giữa windowgroupBy: groupBy gộp nhiều dòng thành một dòng kết quả, còn window giữ nguyên mọi dòng nhưng gắn thêm một giá trị tính trên "khung" (window) xung quanh dòng đó. Nhờ vậy bạn có thể xếp hạng, tính tổng lũy kế, so sánh dòng hiện tại với dòng trước/sau... mà không mất chi tiết.

from pyspark.sql.window import Window

w = Window.partitionBy("category").orderBy(F.desc("amount"))

(df.withColumn("rank", F.rank().over(w))
   .withColumn(
       "running_total",
       F.sum("amount").over(
           w.rowsBetween(Window.unboundedPreceding, Window.currentRow)
       )
   ))

partitionBy("category") chia dữ liệu thành các "khung" theo category (giống groupBy nhưng không gộp). orderBy(F.desc("amount")) sắp thứ tự trong mỗi khung. F.rank().over(w) gán hạng cho từng dòng. Phần rowsBetween(unboundedPreceding, currentRow) định nghĩa khung "từ dòng đầu đến dòng hiện tại" — chính là công thức tính running total (tổng lũy kế).

Các hàm window thường gặp: rank, dense_rank, row_number (xếp hạng), lag/lead (lấy giá trị dòng trước/sau), và mọi hàm tổng hợp (sum, avg...) khi gọi qua .over(w).

Ba hàm xếp hạng khác nhau ở cách xử lý giá trị bằng nhau (tie):

HàmKhi có giá trị bằng nhauVí dụ (100, 100, 90)
rank()Cùng hạng, nhảy số hạng tiếp theo1, 1, 3
dense_rank()Cùng hạng, không nhảy số1, 1, 2
row_number()Luôn duy nhất, không có hạng bằng nhau1, 2, 3
Khi nào dùng cái nào: cần "top N mỗi nhóm" thì dùng row_number() (đảm bảo mỗi nhóm lấy đúng N dòng); cần xếp hạng kiểu thi đấu thì dùng rank(); cần hạng liên tục không lỗ hổng thì dense_rank().
Join
Ghép hai bảng theo khóa chung

Join dùng để gắn thông tin từ bảng này sang bảng khác qua một cột khóa chung. Ở đây ta ghép sales với customers qua customer_id để biết mỗi đơn hàng thuộc khách nào, vùng nào.

joined = sales.join(customers, on="customer_id", how="inner")
joined.show()

Tham số on là cột (hoặc danh sách cột) khóa chung; how quyết định kiểu join:

howGiữ lại những dòng nào
innerChỉ dòng có khóa khớp ở cả hai bảng (mặc định)
leftTất cả dòng bảng trái + phần khớp bên phải (không khớp → null)
rightTất cả dòng bảng phải + phần khớp bên trái
outerTất cả dòng của cả hai bảng, chỗ thiếu điền null
left_antiChỉ dòng bảng trái không tìm thấy khớp bên phải (lọc ngược)
left_semiDòng bảng trái khớp, nhưng chỉ giữ cột bảng trái
Trùng tên cột sau join: nếu hai bảng có cột cùng tên (ngoài cột khóa), sau join sẽ bị nhập nhằng Reference is ambiguous. Cách xử lý: dùng dạng on="customer_id" (thay vì điều kiện sales.customer_id == customers.customer_id) để Spark gộp cột khóa thành một; hoặc rename cột trùng trước khi join. Với bảng nhỏ (như customers), bọc F.broadcast(customers) để Spark gửi nguyên bảng nhỏ tới mọi node — nhanh hơn nhiều, ta sẽ học kỹ ở Bài 6.
Xử lý null
Giá trị thiếu là chuyện thường gặp

Dữ liệu thật luôn có ô trống (null). Spark cho bạn 3 hướng xử lý chính: bỏ dòng, điền giá trị, hoặc lọc/thay thế theo điều kiện.

# bỏ những dòng có bất kỳ null nào
df.na.drop()

# chỉ bỏ dòng null ở một số cột
df.na.drop(subset=["customer_id", "qty"])

# điền giá trị mặc định cho cột qty
df.na.fill({"qty": 0, "region": "unknown"})

# lọc ra các dòng có cột x bị null
df.filter(F.col("x").isNull())

# coalesce: lấy giá trị không-null đầu tiên
df.withColumn("price", F.coalesce(F.col("unit_price"), F.lit(0.0)))
CáchDùng khi
df.na.drop() / dropna()Muốn bỏ hẳn dòng thiếu dữ liệu. Thêm subset=[...] để chỉ xét vài cột.
df.na.fill({...}) / fillna()Điền giá trị mặc định, có thể chỉ định riêng từng cột bằng dict.
col.isNull() / isNotNull()Lọc / kiểm tra null trong điều kiện.
F.coalesce(a, b, ...)Trả về giá trị không-null đầu tiên trong danh sách cột.
Làm sạch dữ liệu bẩn
Gộp nhiều bước transform thành một pipeline

Nhớ rằng sales.csv cố tình có vài dòng bẩn — ví dụ qty âm (không hợp lệ), đơn trùng, kiểu dữ liệu sai. Đây là code làm sạch điển hình, nối nhiều bước transform liền mạch:

clean = (sales
    .filter(F.col("qty") > 0)
    .dropDuplicates(["order_id"])
    .withColumn("unit_price", F.col("unit_price").cast("double"))
    .withColumn("order_date", F.to_date("order_date", "yyyy-MM-dd")))

clean.printSchema()
clean.show(5)

Diễn giải từng bước: filter(F.col("qty") > 0) loại bỏ dòng lỗi qty âm; dropDuplicates(["order_id"]) bỏ đơn trùng (giữ một dòng cho mỗi order_id); cast("double") ép cột giá về kiểu số thực (CSV đọc vào hay là chuỗi); to_date("order_date", "yyyy-MM-dd") parse chuỗi ngày thành kiểu date thực sự để sau này tính toán theo thời gian.

Quy trình làm sạch dữ liệu chuẩn thường gồm các bước:

Cẩn thận: mọi transform ở trên là lazy — Spark chỉ thực sự chạy khi gặp action (như show, count, write). Nếu kết quả không như mong đợi, kiểm tra lại thứ tự các bước trước khi nghi ngờ dữ liệu.
Bài tập
Tự làm trên dữ liệu thật

Dùng salescustomers đã load, tự viết code cho 3 bài sau:

Gợi ý: làm xong bài 2 hãy thử thay row_number() bằng rank() và quan sát khác biệt khi có hai đơn cùng amount — đó là cách tốt nhất để nhớ sự khác nhau giữa chúng.
← Bài trước
Bài 1: Cơ bản