Python hiện đại 2 — Polars: DataFrame tốc độ Rust
Trong Tổng quan Python hiện đại cho Data, chúng ta đã điểm qua bộ công cụ dữ liệu thế hệ mới. Bài này đi sâu vào Polars — thư viện DataFrame viết bằng Rust đang dần thay thế pandas cho các khối lượng xử lý vừa và lớn trên một máy. Nếu bạn từng phải chờ pandas nhá vài phút để groupby một file vài GB, hoặc từng thấy notebook "chết" vì tràn RAM, Polars được sinh ra chính để giải quyết những nỗi đau đó.
Vì sao Polars nhanh
pandas ra đời năm 2008, thiết kế theo tư duy khoa học dữ liệu đơn luồng. Bốn giới hạn cố hữu của nó:
- Đơn lõi (single-threaded): hầu hết phép toán chạy trên một CPU core, dù máy bạn có 16 lõi.
- Ngốn RAM: pandas thường phải giữ nhiều bản sao trung gian; xử lý file 2 GB có thể cần 10+ GB RAM.
- Kiểu dữ liệu lỏng lẻo: dựa trên NumPy, cột chuỗi lưu dưới dạng
object(con trỏ Python) rất chậm, và null (NaN) xử lý không nhất quán giữa các kiểu. - Không tối ưu truy vấn: mỗi lệnh chạy ngay lập tức, không có bước "nhìn tổng thể" để rút gọn công việc.
Polars khắc phục toàn bộ bằng bốn quyết định kiến trúc:
- Viết bằng Rust: ngôn ngữ biên dịch, không có Global Interpreter Lock (GIL), quản lý bộ nhớ an toàn mà không cần garbage collector. Phần lõi tính toán chạy ở tốc độ máy, Python chỉ là lớp vỏ mỏng gọi xuống.
- Đa lõi mặc định: Polars tự động chia công việc ra tất cả CPU core. Một
group_bytrên 40 triệu dòng được chia thành nhiều phân mảnh chạy song song rồi gộp lại — bạn không phải viết thêm dòng code nào. - Dựa trên Apache Arrow: dữ liệu lưu theo cột (columnar) trong bộ nhớ, liền mạch (contiguous). Điều này thân thiện với cache CPU, tận dụng lệnh SIMD (một lệnh xử lý nhiều giá trị), và cho phép chia sẻ dữ liệu với các công cụ Arrow khác mà không cần copy.
- Tối ưu truy vấn (query optimization): với lazy API, Polars xây một kế hoạch trước khi chạy và tự động rút gọn nó (đọc kỹ ở phần dưới).
Kết quả thực nghiệm phổ biến: trên các phép lọc, join, group_by cỡ vài chục triệu dòng, Polars thường nhanh hơn pandas 5–30 lần và dùng ít RAM hơn đáng kể. Con số cụ thể tùy phép toán và phần cứng, nhưng khác biệt là rất rõ.
Hai API: eager và lazy
Đây là khái niệm quan trọng nhất khi học Polars. Polars cung cấp hai cách làm việc song song.
Eager — chạy ngay, giống pandas
Bạn gọi pl.read_csv(), pl.read_parquet() để nạp dữ liệu vào một DataFrame, rồi mỗi phép biến đổi thực thi tức thì. Phong cách này quen thuộc với người dùng pandas, tiện để khám phá tương tác trong notebook.
import polars as pl
df = pl.read_parquet("giao_dich.parquet") # nạp ngay vào RAM
df = df.filter(pl.col("amount") > 0) # chạy ngay
print(df.head())
Lazy — dựng kế hoạch rồi mới chạy
Thay vì read_*, bạn dùng scan_* (pl.scan_parquet, pl.scan_csv). Lệnh này không nạp dữ liệu — nó chỉ tạo một LazyFrame. Mọi phép biến đổi bạn viết sau đó được ghi lại thành một query plan (kế hoạch truy vấn). Chỉ khi gọi .collect(), Polars mới phân tích toàn bộ kế hoạch, tối ưu nó, rồi thực thi.
lf = (
pl.scan_parquet("giao_dich.parquet") # chưa đọc gì cả
.filter(pl.col("amount") > 0)
.group_by("branch")
.agg(pl.col("amount").sum())
)
result = lf.collect() # tối ưu + thực thi tại đây
Sức mạnh của lazy nằm ở bước tối ưu. Vì Polars nhìn thấy toàn bộ chuỗi thao tác trước khi chạy, nó áp dụng các phép tối ưu kiểu database:
- Predicate pushdown: đẩy điều kiện lọc (
filter) xuống tận lúc đọc file. Nếu bạn chỉ cần các dòngamount > 0, Polars bỏ qua những dòng không thỏa ngay khi quét Parquet, thay vì nạp hết rồi mới lọc. - Projection pushdown: chỉ đọc những cột thực sự dùng đến. File có 50 cột nhưng truy vấn chỉ chạm 3 cột thì chỉ 3 cột được đọc từ đĩa. Đây là lợi thế lớn của định dạng cột như Parquet.
- Slice/limit pushdown, loại bỏ phép tính chết, gộp các phép biến đổi: nhìn chung, kế hoạch được viết lại để làm ít việc nhất có thể.
Nhờ pushdown, một truy vấn trên file 30 GB có thể chỉ thực sự đọc vài trăm MB. Đây là lý do lazy là điểm mạnh cốt lõi của Polars, và là thói quen nên hình thành: mặc định dùng scan_* + .collect(), chỉ dùng eager khi cần xem nhanh vài dòng.
Bạn có thể xem kế hoạch bằng lf.explain() (in ra kế hoạch tối ưu) để hiểu Polars sẽ làm gì.
Expression API — trái tim của Polars
Polars không dùng lối truy cập kiểu df['cot'] xáo trộn như pandas. Thay vào đó, mọi phép biến đổi được diễn đạt bằng expression — biểu thức khai báo bắt đầu bằng pl.col("ten_cot"). Một expression mô tả phép tính bạn muốn, còn Polars quyết định cách chạy (vectorized, song song). Đây là phong cách khai báo, gần với SQL hơn là vòng lặp.
Các động từ (verb) chính, ghép chuỗi (method chaining) sạch sẽ:
| Verb | Ý nghĩa |
|---|---|
select | chọn/tạo cột từ expression |
with_columns | thêm hoặc thay cột, giữ nguyên phần còn lại |
filter | lọc dòng theo điều kiện |
group_by + agg | gom nhóm và tổng hợp |
join | nối hai bảng theo khóa |
sort | sắp xếp |
over | tính theo cửa sổ (window) trong từng nhóm |
Vì expression là đối tượng độc lập, bạn có thể tái dùng và tổ hợp chúng. Ví dụ (pl.col("amount") * 1.1).alias("amount_vat") là một expression có thể đặt vào bất kỳ select/with_columns nào.
So sánh cú pháp pandas vs Polars
Thêm một cột dẫn xuất:
# pandas
df["net"] = df["amount"] - df["fee"]
# Polars
df = df.with_columns((pl.col("amount") - pl.col("fee")).alias("net"))
Lọc rồi gom nhóm rồi tổng hợp:
# pandas
out = (df[df["amount"] > 0]
.groupby("branch")["amount"]
.sum()
.reset_index())
# Polars
out = (df.filter(pl.col("amount") > 0)
.group_by("branch")
.agg(pl.col("amount").sum()))
Tính nhiều tổng hợp cùng lúc — Polars gọn và song song hóa tự nhiên:
out = df.group_by("branch").agg(
pl.col("amount").sum().alias("tong"),
pl.col("amount").mean().alias("trung_binh"),
pl.len().alias("so_giao_dich"),
pl.col("customer_id").n_unique().alias("so_khach"),
)
Window function với over
Muốn tính, ví dụ, tỷ trọng mỗi giao dịch trên tổng của chi nhánh mà không làm mất dòng chi tiết? Dùng over:
df = df.with_columns(
(pl.col("amount") / pl.col("amount").sum().over("branch"))
.alias("ty_trong_chi_nhanh")
)
Kiểu dữ liệu, null và xử lý chuỗi/ngày
Nhờ Arrow, Polars có hệ kiểu chặt chẽ và nhất quán: Int64, Float64, Utf8 (chuỗi thực sự, không phải object), Boolean, Date, Datetime, Categorical, List, Struct. Chuỗi được lưu ở tầng Rust nên các phép trên chuỗi nhanh gấp bội pandas.
Null: Polars phân biệt rõ null (thiếu giá trị) với NaN (giá trị số "không phải số"). null áp dụng cho mọi kiểu một cách nhất quán — không còn chuyện cột số nguyên bị ép thành float chỉ vì có giá trị thiếu như trong pandas cũ.
Xử lý chuỗi và ngày qua các namespace .str và .dt:
df = df.with_columns(
pl.col("branch_name").str.to_uppercase().str.strip_chars(),
pl.col("created_at").dt.strftime("%Y-%m").alias("thang"),
pl.col("created_at").dt.year().alias("nam"),
)
Logic điều kiện dùng bộ ba when/then/otherwise — thay cho np.where lồng nhau:
df = df.with_columns(
pl.when(pl.col("amount") >= 500_000_000)
.then(pl.lit("lon"))
.when(pl.col("amount") >= 50_000_000)
.then(pl.lit("vua"))
.otherwise(pl.lit("nho"))
.alias("phan_loai")
)
Streaming — vượt giới hạn RAM
Điều gì xảy ra khi dữ liệu lớn hơn RAM? Polars có chế độ streaming cho lazy API. Thay vì nạp toàn bộ vào bộ nhớ, engine xử lý dữ liệu theo từng lô (batch/morsel), chạy từng phần rồi gộp — cho phép tổng hợp một file 100 GB trên máy chỉ 16 GB RAM.
result = (
pl.scan_parquet("giao_dich_2024/*.parquet")
.filter(pl.col("amount") > 0)
.group_by(["branch", "thang"])
.agg(pl.col("amount").sum())
.collect(engine="streaming") # xử lý out-of-core theo lô
)
Không phải mọi phép toán đều hỗ trợ streaming hoàn hảo, và Polars đang liên tục hoàn thiện engine này. Nhưng cho các tác vụ ETL phổ biến — lọc, group_by, join, ghi ra Parquet — streaming cho phép làm việc với dữ liệu lớn hơn RAM mà không cần cụm Spark. (Tham số/cách bật streaming có thể thay đổi giữa các phiên bản Polars; luôn kiểm tra tài liệu phiên bản bạn dùng.)
Tương tác với hệ sinh thái
Polars không bắt bạn từ bỏ mọi thứ. Nó nói chung một "ngôn ngữ" bộ nhớ với thế giới Arrow:
- Đọc/ghi file:
read_parquet/write_parquet,read_csv/write_csv,read_ipc/write_ipc(Arrow IPC/Feather). Parquet + lazy là cặp bài trùng nhờ projection pushdown. - Qua lại với pandas:
pl.from_pandas(df_pd)vàdf_pl.to_pandas(). Hữu ích khi một thư viện (ví dụ scikit-learn, matplotlib) chỉ nhận pandas. - Chia sẻ Arrow zero-copy:
df_pl.to_arrow()/pl.from_arrow(tbl)trao đổi với PyArrow gần như không tốn chi phí copy, vì cả hai cùng bố cục bộ nhớ Arrow. - Nối DuckDB: DuckDB có thể truy vấn trực tiếp một DataFrame Polars bằng SQL, và trả kết quả về Polars — cả hai đều dựa Arrow. Đây là cầu nối mạnh giữa lối expression và lối SQL; xem sâu ở bài DuckDB.
Khi nào Polars, khi nào pandas, khi nào DuckDB
| Tình huống | Nên chọn |
|---|---|
| Dữ liệu vừa–lớn (triệu đến trăm triệu dòng) trên 1 máy, cần tốc độ | Polars |
| Pipeline ETL cột, tối ưu qua lazy, gần giới hạn RAM | Polars (kèm streaming) |
| Bộ dữ liệu nhỏ, cần thư viện chỉ nhận pandas (sklearn, seaborn) | pandas |
| Người dùng đã quen pandas, script cũ, khám phá nhanh | pandas |
| Thích diễn đạt bằng SQL, join nhiều bảng, truy vấn file tại chỗ | DuckDB |
| Dữ liệu vượt xa một máy, cần phân tán | Spark/Trino (ngoài phạm vi bài) |
Thực tế Polars và DuckDB bổ trợ nhau nhiều hơn là loại trừ: chọn theo việc bạn muốn viết expression hay viết SQL, và cả hai chạy trên cùng dữ liệu Arrow.
Cạm bẫy khi chuyển từ pandas
- Không có index: Polars không có khái niệm index kiểu pandas. Không
set_index, khôngdf.loc[nhan]. Chọn dòng bằngfilter, chọn cột bằngselect. Đây là khác biệt tư duy lớn nhất. - API tên khác:
groupby→group_by,rename(columns=...)→rename({...}),assign→with_columns. Tổng hợp phải nằm trong.agg(...). - Không sửa tại chỗ (immutable): Polars trả về DataFrame mới, không có
inplace=True. Luôn gán lại kết quả. apply/hàm Python là bẫy tốc độ: dùng expression sẵn có thay vìmap_elementsvới hàm Python thuần — hàm Python phá vỡ vectorization và song song, làm mất phần lớn lợi thế của Polars.null≠NaN: kiểm tra thiếu dữ liệu bằngis_null(), đừng lẫn vớiis_nan().
Ví dụ đầy đủ (minh hoạ)
Đây là một pipeline lazy điển hình, minh hoạ luồng scan → filter → group_by → agg → collect. Đoạn code chỉ mang tính minh hoạ (không phải SQL sandbox):
import polars as pl
# Tổng hợp giao dịch năm 2024 theo chi nhánh và tháng, từ nhiều file Parquet
bao_cao = (
pl.scan_parquet("data/giao_dich_2024/*.parquet") # LazyFrame, chưa đọc file
.filter(
(pl.col("amount") > 0) & # predicate pushdown
(pl.col("status") == "SUCCESS")
)
.with_columns(
pl.col("created_at").dt.strftime("%Y-%m").alias("thang")
)
.group_by(["branch", "thang"])
.agg(
pl.col("amount").sum().alias("tong_tien"),
pl.len().alias("so_giao_dich"),
pl.col("customer_id").n_unique().alias("so_khach_hang"),
pl.col("amount").mean().round(0).alias("tb_moi_giao_dich"),
)
.sort(["branch", "thang"])
)
print(bao_cao.explain()) # xem query plan đã tối ưu
ket_qua = bao_cao.collect() # tối ưu + thực thi song song
ket_qua.write_parquet("bao_cao_chi_nhanh_2024.parquet")
Nhờ scan_parquet, Polars chỉ đọc các cột amount, status, created_at, branch, customer_id (projection pushdown) và bỏ qua các dòng không SUCCESS ngay khi quét (predicate pushdown), thay vì nạp toàn bộ vào RAM.
Use case thực tế
Bối cảnh NCB: Đội phân tích cần dựng báo cáo tổng hợp giao dịch theo chi nhánh × tháng cho cả năm, từ kho dữ liệu giao dịch khoảng 40 triệu dòng (nhiều file Parquet, tổng ~12 GB trên đĩa). Yêu cầu: chạy được trên một máy phân tích (16 core, 32 GB RAM), không đụng tới cụm Spark, và ra kết quả trong vòng vài phút để lặp nhanh mỗi lần điều chỉnh logic.
Cách cũ với pandas: đọc từng file bằng pd.read_parquet rồi pd.concat — bước concat 40 triệu dòng đã ngốn ~20–25 GB RAM và có lúc tràn bộ nhớ, phải chia batch thủ công. Một vòng groupby(["branch", "thang"]).agg(...) mất khoảng 3–5 phút. Mỗi lần sửa điều kiện lọc là chờ lại từ đầu.
Cách mới với Polars lazy: đúng pipeline scan_parquet(...).filter(...).group_by(...).agg(...).collect() như ví dụ trên. Nhờ projection pushdown, chỉ 5 cột cần dùng được đọc từ đĩa; nhờ predicate pushdown, các dòng không hợp lệ bị loại ngay lúc quét. Tổng hợp chạy song song trên 16 core.
Kết quả ước lượng (con số minh hoạ, tùy phần cứng và dữ liệu):
| Chỉ số | pandas | Polars lazy |
|---|---|---|
| Thời gian chạy | ~3–5 phút | ~15–30 giây |
| RAM đỉnh | ~20–25 GB (dễ tràn) | ~4–6 GB |
| Dòng code | dài, phải batch tay | gọn, một chuỗi |
Ước tính Polars nhanh hơn khoảng 8–15 lần và dùng RAM chỉ bằng khoảng 1/4–1/5 cho tác vụ này — đủ để đội phân tích lặp lại nhiều lần trong ngày. Nếu dữ liệu phình vượt RAM (ví dụ gộp nhiều năm), chỉ cần thêm collect(engine="streaming") mà không đổi kiến trúc. Khi báo cáo cần join nhiều bảng và đội nghiệp vụ quen SQL hơn, có thể bắc cầu sang DuckDB trên cùng dữ liệu Arrow.
Ghi nhớ
- Polars = DataFrame lõi Rust, đa lõi mặc định, dựa Apache Arrow (bộ nhớ cột) — nhanh hơn pandas nhiều lần và tiết kiệm RAM.
- Hai API: eager (
read_*, chạy ngay, giống pandas) và lazy (scan_*→ query plan →.collect()). Mặc định nên dùng lazy. - Lazy cho phép predicate pushdown và projection pushdown: chỉ đọc cột cần và bỏ dòng không thỏa ngay khi quét file — làm ít việc nhất có thể.
- Expression API khai báo, vectorized:
pl.col(...)vớiselect / with_columns / filter / group_by+agg / join / sort / over, method chaining sạch. - Hệ kiểu Arrow chặt chẽ; phân biệt rõ
nullvớiNaN; xử lý chuỗi/ngày qua.str/.dt; logic điều kiện bằngwhen/then/otherwise. - Streaming (
collect(engine="streaming")) xử lý dữ liệu lớn hơn RAM theo lô, không cần cụm phân tán. - Tương tác dễ: Parquet/CSV/Arrow,
from_pandas/to_pandas,to_arrowzero-copy, và nối SQL qua DuckDB. - Cạm bẫy từ pandas: không có index, API đổi tên (
group_by), immutable (khônginplace), tránhmap_elementshàm Python thuần, phân biệtis_null()vsis_nan(). - Chọn công cụ: Polars cho ETL cột tốc độ cao trên một máy; pandas cho bộ nhỏ và tương thích thư viện; DuckDB khi thích diễn đạt bằng SQL — cả ba cùng chạy trên Arrow.
Nguồn tham khảo
- Polars User Guide — tài liệu chính thức: eager vs lazy, expression API, streaming, query optimization.
- Polars Python API reference — tra cứu hàm/method (
scan_parquet,group_by,with_columns,over…). - Apache Arrow Documentation — định dạng bộ nhớ cột (columnar) mà Polars dựa trên, và trao đổi zero-copy.
- Apache Parquet Documentation — định dạng file cột hỗ trợ predicate/projection pushdown.
- pandas Documentation — đối chiếu cú pháp và mô hình khi chuyển từ pandas sang Polars.
- DuckDB Documentation — công cụ SQL trên Arrow, bổ trợ Polars (xem bài liên quan trong series).
Bài viết liên quan
Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.
Học cách tổ chức code Python: định nghĩa hàm với tham số vị trí/từ khoá/mặc định, *args/**kwargs, lambda và hàm bậc cao, closure, decorator, generator với yield. Đóng gói code thành module và package, cô lập thư viện bằng môi trường ảo venv, quản lý phụ thuộc với pip và requirements.txt để dự án tái lập được trên mọi máy.
Biến script thành pipeline đáng tin cậy: cấu trúc project & packaging (uv/poetry), type hints & pydantic, kiểm thử với pytest, logging & cấu hình, đóng gói Docker, và tích hợp CI cho code dữ liệu.
Hướng dẫn OOP trong Python từ class/instance, kế thừa và super(), đa hình & duck typing, encapsulation tới dunder methods, @property, classmethod/staticmethod, dataclass và type hints (mypy). Kèm nguyên tắc clean code: đặt tên rõ nghĩa, hàm nhỏ, DRY, SOLID cùng chuẩn PEP8 với công cụ ruff/black.
Cảm nhận của bạn
Bình luận
Chưa có bình luận. Hãy là người đầu tiên chia sẻ!