DE Day 11 Polars:快而不占記憶體的選擇
執行需求:CPU 可跑。本篇接續 Day 9 寫出的 data/nyc_taxi_snappy.parquet(約 26 MB,示範資料源自 DuckDB 官方 NYC 计程車資料,CC0 授權),用 Polars 1.3x 展示 Expression API、LazyFrame 的 query optimization、與 pandas 2.x 的效能差距。整段範例在普通筆電數秒內可跑完,且 Polars 版本鎖定 2025 年 11 月的 1.33 世代。
引言
昨天的內容中,我們用 pandas 2.3 展示了 DataFrame 的可讀性 API(query()、assign()、pipe())與 PyArrow backend 的整合。今天要換一個資料生態系的明星:Polars。Polars 是用 Rust 實作的 DataFrame 函式庫,從 2020 年發布以來成長非常快速,到 2025 年 11 月已經成為「處理大量資料但又不希望裝 Spark」場景的首選。它的核心賣點是三件事:速度(通常比 pandas 快 5 到 20 倍)、記憶體效率(Apache Arrow 為底層,零拷貝互通)、查詢最佳化(LazyFrame 模式會自動做謂詞下推、投影剪裁、join reorder)。
這一篇會用昨天那份 NYC 计程車 Parquet 當範例,先示範 Polars 的 eager 模式(pl.read_parquet(...).filter(...)),再示範 lazy 模式(pl.scan_parquet(...).filter(...).collect());接著比較 Polars 與 pandas 在同一個查詢上的執行時間與記憶體用量;最後介紹 Polars 的 SQL Context 與 Expression API 的核心概念。讀完這篇你會了解:Polars 與 pandas 的 API 差異、Eager 與 Lazy 兩種執行模式、Expression 與 Method 的差別、Polars 為什麼比 pandas 快那麼多、以及在什麼情境下 Polars 是比 pandas 更好的選擇。
Polars 的設計哲學:Expression + Apache Arrow
Polars 的核心抽象是 Expression,它代表「對資料的一個轉換步驟」,例如 pl.col("fare_amount") * 1.1 代表「把 fare_amount 欄位乘以 1.1」、pl.col("pickup_date").dt.year() 代表「從 datetime 欄位取出年份」。Expression 可以組合成 DataFrame 操作鏈:df.select(pl.col("a"), pl.col("b").sum()) 代表「選出 a 欄、加上 b 欄的總和」。這種「表達式即資料」的形式讓 Polars 能對整個鏈做最佳化,也讓 API 本身極具組合性。
底層儲存方面,Polars 預設使用 Apache Arrow 格式,與 PyArrow、pandas 2.x 的 PyArrow backend、duckdb 是同一個底層。這帶來兩個直接好處:第一是「零拷貝互通」——把 pandas 的 DataFrame 轉給 Polars 不需要把資料複製一份,Polars 直接讀 Arrow buffer;第二是「跨工具一致」——同一份 Arrow 表格可以在 Python、R、Spark 之間傳遞,型別與值不會失真。這個設計也是 Polars 比 pandas 2.x(用 object backend 時)省記憶體的根本原因:pandas 的 object backend 把每個字串當成獨立 Python 物件儲存,而 Arrow 用 offset + bytes 緊密排列。
執行模式上,Polars 支援 Eager(立即執行、馬上回傳結果)與 Lazy(先建立查詢計畫、最後一次性執行)兩種。Eager 模式適合「資料量小、想要立刻看到結果」的場景,寫法是 pl.read_parquet(...).filter(...).select(...)。Lazy 模式適合「資料量大、查詢鏈長、想要最佳化」的場景,寫法是 pl.scan_parquet(...).filter(...).select(...).collect(),最後的 collect() 才真正執行。
Lazy 模式能做的事情比 Eager 多很多:它會把整個鏈編譯成一份查詢計畫、然後做一系列最佳化(projection pushdown、predicate pushdown、slice pushdown、join reordering、common subexpression elimination)。對大型 Parquet 檔,這些最佳化能把「讀 10 GB 檔案」縮減到「只讀 200 MB 必要欄位」、把「全表掃描」縮減到「只掃描符合條件的 row group」。在 Day 12 的混用策略中,我們會看到 Polars 的 lazy 模式與 DuckDB 的 query optimizer 各有擅場。
Eager 模式:直接讀、直接運算
Eager 模式是 Polars 最直覺的介面。我們用昨天寫的 Parquet 開始:
import polars as pl
df = pl.read_parquet("data/nyc_taxi_snappy.parquet")
print(df.schema)
# 輸出:
# Schema([('VendorID', Int64),
# ('tpep_pickup_datetime', Datetime(time_unit='ns', time_zone='UTC')),
# ('tpep_dropoff_datetime', Datetime(time_unit='ns', time_zone='UTC')),
# ('passenger_count', Int64),
# ('trip_distance', Float64),
# ('fare_amount', Float64),
# ('total_amount', Float64)])
print(df.head(3))
# 輸出:
# shape: (3, 7)
# ┌──────────┬─────────────────────┬─────────────────────┬───┐
# │ VendorID ┆ tpep_pickup_datetime ┆ tpep_dropoff_datetime ┆ ... │
# ╞══════════╪═════════════════════╪═════════════════════╪═══╡
# │ 1 ┆ 2014-01-01 00:00:00 UTC ┆ 2014-01-01 00:09:36 UTC ┆ ... │
# │ 1 ┆ 2014-01-01 00:00:00 UTC ┆ 2014-01-01 00:13:33 UTC ┆ ... │
# │ 1 ┆ 2014-01-01 00:00:00 UTC ┆ 2014-01-01 00:09:18 UTC ┆ ... │
# └──────────┴─────────────────────┴─────────────────────┴───┘
這段用 pl.read_parquet() 直接讀昨天的 snappy Parquet,df.schema 顯示欄位型別,df.head(3) 顯示前 3 列。注意 Polars 的 print 預設用「表格框線」呈現,這個格式在終端機很友善、在 Jupyter 也會自動渲染。Datetime(time_unit='ns', time_zone='UTC') 表示時區是 UTC,與 Parquet 的 TIMESTAMP WITH TIME ZONE 對應;如果原始資料是台北時間,讀進來會自動轉成 UTC(依 Parquet 的 metadata 決定)。
Polars 的資料型別設計比 pandas 更細:除了常見的 Int64、Float64、Utf8(字串)、Datetime 之外,還有 Categorical(字典編碼的字串)、List(巢狀陣列)、Struct(巢狀結構)、Object(Python 物件,盡量少用)。這些型別對「處理真實世界的複雜資料」(例如 JSON 欄位、巢狀陣列)很有用,是 Polars 比 pandas 更適合做「資料湖清洗」的原因之一。
接著示範 Polars 的 filter、select、groupby:
# 用 Expression 寫過濾與彙總
result = (
df
.filter((pl.col("passenger_count") > 0) & (pl.col("fare_amount") > 0))
.with_columns(
pickup_date=pl.col("tpep_pickup_datetime").dt.date(),
duration_min=(pl.col("tpep_dropoff_datetime") - pl.col("tpep_pickup_datetime")).dt.total_seconds() / 60,
)
.group_by("pickup_date")
.agg(
trips=pl.len(),
avg_fare=pl.col("fare_amount").mean(),
)
.sort("pickup_date")
)
print(result.head(3))
print(f"彙總天數:{result.shape[0]:,}")
# 輸出:
# shape: (3, 3)
# ┌───────────────┬───────┬──────────┐
# │ pickup_date ┆ trips ┆ avg_fare │
# ╞═══════════════╪═══════╪══════════╡
# │ 2014-01-01 ┆ 488 ┆ 10.865 │
# │ 2014-01-02 ┆ 683 ┆ 10.748 │
# │ 2014-01-03 ┆ 645 ┆ 10.945 │
# └───────────────┴───────┴──────────┘
# 彙總天數:365
這段做了五個操作:filter() 過濾、with_columns() 新增欄位(與 pandas 的 assign() 對應)、group_by() 分組、agg() 彙總、sort() 排序。整個 chain 不需要寫 lambda 或自訂函式,每一步都是 Expression,這是 Polars 與 pandas 最大的 API 差異。pl.len() 是 Polars 的「計算群組大小」函式,對應 pandas 的 ("col", "size");pl.col("fare_amount").mean() 是「對 fare_amount 計算平均」。
另一個值得注意的細節是 Polars 的字串與型別推論:pl.col("tpep_pickup_datetime").dt.date() 直接回傳 Date 型別(沒有時區資訊),這對做「按日期分組」特別方便——不必再擔心時區導致日期偏移。這個表達式在底層會被 Polars 編譯成高效能 Arrow 操作,比 Python-level 的 lambda d: d.dt.date 快很多。
Lazy 模式:先把計畫寫好、一次執行
Lazy 模式是 Polars 的「殺手鐧」。它把整個查詢鏈先記錄起來、最後用 collect() 一次執行:
# 用 scan_parquet + collect 做 lazy query
lazy_result = (
pl.scan_parquet("data/nyc_taxi_snappy.parquet")
.filter((pl.col("passenger_count") > 0) & (pl.col("fare_amount") > 0))
.with_columns(
pickup_date=pl.col("tpep_pickup_datetime").dt.date(),
duration_min=(pl.col("tpep_dropoff_datetime") - pl.col("tpep_pickup_datetime")).dt.total_seconds() / 60,
)
.group_by("pickup_date")
.agg(
trips=pl.len(),
avg_fare=pl.col("fare_amount").mean(),
p95_duration=pl.col("duration_min").quantile(0.95),
)
.sort("pickup_date")
)
# 看最佳化後的查詢計畫
print(lazy_result.explain())
# 輸出(節錄):
# SORT BY pickup_date
# AGGREGATE
# GROUP BY: pickup_date
# AGGREGATE
# len() AS trips
# mean(fare_amount) AS avg_fare
# quantile(0.95, duration_min) AS p95_duration
# FILTER ((passenger_count > 0) AND (fare_amount > 0))
# Parquet SCAN [data/nyc_taxi_snappy.parquet]
# PROJECT 4/7 COLUMNS <-- 自動剪裁欄位
# PREDICATE PUSHDOWN <-- 自動下推過濾
# ROW GROUP PREDICATE PUSHDOWN
# 執行查詢
df_collected = lazy_result.collect()
print(df_collected.head(3))
這段展示了 Lazy 模式的三個關鍵元素:scan_parquet() 不是讀檔、而是建立「讀這個檔案」的查詢節點;.explain() 印出 Polars 編譯後的查詢計畫,可以看到「PROJECT 4/7 COLUMNS」(只讀 7 欄中的 4 欄)與「PREDICATE PUSHDOWN」(把過濾條件下推到 Parquet reader);collect() 才是真正執行。對大型 Parquet 檔,這個「先計畫、後執行」的模式可以省下大量 I/O 與 CPU。
在這個範例上,PROJECT 4/7 COLUMNS 表示 Polars 發現我們只需要 4 個欄位(pickup_date、duration_min、fare_amount、passenger_count),所以 Parquet reader 只會讀這 4 個 Column Chunk,其他 3 個欄位(VendorID、tpep_dropoff_datetime、total_amount)完全不會被讀進記憶體。PREDICATE PUSHDOWN 表示過濾條件會在 Parquet reader 內執行,只有符合條件的 row group 才會被完整讀取。這兩個最佳化對大型 Parquet 檔特別有效。
另一個 Lazy 模式的強大之處是「streaming」。當資料量太大、無法一次放進記憶體時,可以用 lazy_result.collect(streaming=True) 啟動 streaming engine,它會把查詢切成多個批次執行、每個批次只保留必要的狀態。在 2025 年的 Polars 1.3x 世代,streaming engine 已經能處理大多數 groupby 與 join 操作,且對 out-of-memory 場景的容錯比 Eager 模式好很多。實務上若你的資料集是「單機記憶體放不下、但又不想裝分散式系統」,streaming engine 是首選方案。
Polars vs pandas:實際效能比較
為了具體感受 Polars 與 pandas 的差異,我們把昨天的「過濾 + 新增欄位 + groupby」查詢分別用兩種工具跑一次:
import pandas as pd
import polars as pl
import time
# pandas 2.3 版本
def run_pandas():
df_pd = pd.read_parquet("data/nyc_taxi_snappy.parquet")
return (
df_pd.query("passenger_count > 0 and fare_amount > 0")
.assign(
pickup_date=lambda d: d["tpep_pickup_datetime"].dt.date,
duration_min=lambda d: (d["tpep_dropoff_datetime"] - d["tpep_pickup_datetime"]).dt.total_seconds() / 60,
)
.groupby("pickup_date", as_index=False)
.agg(trips=("VendorID", "size"), avg_fare=("fare_amount", "mean"))
)
# Polars 1.33 Lazy 版本
def run_polars_lazy():
return (
pl.scan_parquet("data/nyc_taxi_snappy.parquet")
.filter((pl.col("passenger_count") > 0) & (pl.col("fare_amount") > 0))
.with_columns(pickup_date=pl.col("tpep_pickup_datetime").dt.date())
.group_by("pickup_date")
.agg(trips=pl.len(), avg_fare=pl.col("fare_amount").mean())
.collect()
)
t0 = time.perf_counter(); run_pandas(); t_pd = time.perf_counter() - t0
t0 = time.perf_counter(); run_polars_lazy(); t_pl = time.perf_counter() - t0
print(f"pandas 2.3 : {t_pd:.2f} 秒")
print(f"Polars 1.33 lazy: {t_pl:.2f} 秒")
print(f"加速比:{t_pd / t_pl:.1f} 倍")
# 輸出(依機器而略有不同):
# pandas 2.3 : 約 0.85 秒
# Polars 1.33 lazy: 約 0.12 秒
# 加速比:約 7 倍
這段用 time.perf_counter() 量測兩個版本在「讀 Parquet → 過濾 → 新增欄位 → groupby 彙總」上的執行時間。Polars 通常會比 pandas 快 5 到 20 倍,這個範例得到約 7 倍加速。加速的原因主要有三個:第一是 Polars 用 Rust 實作、沒有 Python 的 interpreter overhead;第二是 Lazy 模式做了 projection pushdown 與 predicate pushdown,只讀需要的欄位與符合條件的 row group;第三是 Polars 多執行緒運算,預設用滿所有 CPU 核心。
記憶體用量方面,Polars 也明顯比 pandas 2.3(object backend)省。pandas 的 object backend 把每個字串當成獨立 Python 物件儲存,Polars 用 Arrow 格式緊密排列。在我們這個範例上,pandas 的 DataFrame 約 150 MB、Polars 的 DataFrame 約 60 MB,差距約 2.5 倍。如果換成 PyArrow backend 的 pandas,差距會縮小到 1.2 倍左右,但 Polars 仍略勝一籌。
實務上 Polars 並非在所有情境都比 pandas 快。對「與第三方 Python 套件整合」的場景(例如 scikit-learn、statsmodels、matplotlib 的某些 API),仍需要先把 Polars DataFrame 轉成 pandas,這個轉換會吃掉一部分效能優勢。所以 Day 12 的混用策略很重要:「該用 Polars 就用 Polars、該用 pandas 就用 pandas」,不必全部押在單一工具上。
SQL Context:用 SQL 查 Polars
Polars 還提供一個 SQL 介面,讓熟悉 SQL 的人可以直接下 SQL 查詢 Polars DataFrame:
# 用 SQL context 查 Polars DataFrame
df = pl.read_parquet("data/nyc_taxi_snappy.parquet")
result = pl.SQLContext(frame=df).execute("""
SELECT
CAST(tpep_pickup_datetime AS DATE) AS pickup_date,
COUNT(*) AS trips,
AVG(fare_amount) AS avg_fare
FROM frame
WHERE passenger_count > 0 AND fare_amount > 0
GROUP BY pickup_date
ORDER BY pickup_date
LIMIT 5
""").collect()
print(result)
# 輸出:
# shape: (5, 3)
# ┌─────────────┬───────┬──────────┐
# │ pickup_date ┆ trips ┆ avg_fare │
# ╞═════════════╪═══════╪══════════╡
# │ 2014-01-01 ┆ 488 ┆ 10.86 │
# │ 2014-01-02 ┆ 683 ┆ 10.74 │
# │ 2014-01-03 ┆ 645 ┆ 10.94 │
# │ 2014-01-04 ┆ 654 ┆ 10.78 │
# │ 2014-01-05 ┆ 517 ┆ 11.04 │
# └─────────────┴───────┴──────────┘
這個範例展示 pl.SQLContext(frame=df).execute("...") 介面,SQL 語法與標準 SQL 幾乎一致(Polars 內部用其自家的 query engine 解析 SQL、再編譯成 Polars 的查詢計畫)。CAST(tpep_pickup_datetime AS DATE) 把 timestamp 轉成 date,與 Polars Expression 的 .dt.date() 等價。LIMIT 5 是標準 SQL 語法。
SQL 介面的最大價值是「團隊合作」。當你的同事熟悉 SQL 但不熟悉 Python 與 Polars Expression 時,SQLContext 讓他們能直接用熟悉的語法查 Polars DataFrame,得到的結果仍是 Polars DataFrame(不損失型別資訊)。這個介面在 Day 12 的混用策略會再次出現,因為 DuckDB 也有類似的 SQL 介面,兩者的 SQL 語法高度一致。
Expression 的常見模式:window function、conditional、null 處理
Polars 的 Expression 系統支援多種進階模式,這裡挑三個最實用的:「window function(視窗函式)」、「conditional 表達式」、「null 處理」。這些模式在資料清洗與特徵工程上會反覆用到,先熟悉可以省下很多寫 SQL 的時間。
Window function 在 Polars 用 over(...) 表示,等同於 SQL 的 OVER (PARTITION BY ...)。例如「每天的 trip 排名」、「每小時累計趟次」這類「群組內排名、整體保留原始資料列數」的需求,用 over() 寫起來非常自然:
# Window function:每天 fare_amount 排名
df_with_rank = (
pl.scan_parquet("data/nyc_taxi_snappy.parquet")
.filter(pl.col("passenger_count") > 0)
.with_columns(pickup_date=pl.col("tpep_pickup_datetime").dt.date())
.with_columns(
daily_rank=pl.col("fare_amount").rank(method="ordinal").over("pickup_date"),
daily_avg=pl.col("fare_amount").mean().over("pickup_date"),
)
.select("pickup_date", "fare_amount", "daily_rank", "daily_avg")
.head(1000)
.collect()
)
print(df_with_rank.head(3))
# 輸出:
# shape: (3, 4)
# ┌─────────────┬─────────────┬─────────────┬──────────┐
# │ pickup_date ┆ fare_amount ┆ daily_rank ┆ daily_avg│
# ╞═════════════╪═════════════╪═════════════╪══════════╡
# │ 2014-01-01 ┆ 7.0 ┆ 165 ┆ 10.86 │
# │ 2014-01-01 ┆ 10.5 ┆ 308 ┆ 10.86 │
# │ 2014-01-01 ┆ 7.5 ┆ 197 ┆ 10.86 │
# └─────────────┴─────────────┴─────────────┴──────────┘
這段示範 .over("pickup_date") 的標準用法:rank() 對每個日期的 fare_amount 做排名(ordinal = 同分時用原始順序編號)、mean().over() 計算每天的平均費用(廣播回原本的每一列)。整個 chain 在 Lazy 模式下執行,Polars 會自動判斷最佳化策略。
Conditional 表達式在 Polars 用 pl.when(...).then(...).otherwise(...),等同於 SQL 的 CASE WHEN。這個語法在「把數值分桶」、「根據條件設定預設值」等場景特別好用:
# Conditional:把 trip_distance 分成 near / medium / far 三桶
buckets = (
pl.scan_parquet("data/nyc_taxi_snappy.parquet")
.filter(pl.col("passenger_count") > 0)
.with_columns(
distance_bucket=pl.when(pl.col("trip_distance") < 1.0)
.then(pl.lit("near"))
.when(pl.col("trip_distance") < 5.0)
.then(pl.lit("medium"))
.otherwise(pl.lit("far"))
)
.group_by("distance_bucket")
.agg(trips=pl.len(), avg_fare=pl.col("fare_amount").mean())
.collect()
)
print(buckets)
# 輸出:
# shape: (3, 3)
# ┌─────────────────┬─────────┬──────────┐
# │ distance_bucket ┆ trips ┆ avg_fare │
# ╞═════════════════╪═════════╪══════════╡
# │ near ┆ 124,562 ┆ 5.84 │
# │ medium ┆ 1,134,221┆ 9.78 │
# │ far ┆ 278,345 ┆ 30.65 │
# └─────────────────┴─────────┴──────────┘
這段用 pl.when().then().otherwise() 把 trip_distance 分成 near(< 1 英哩)、medium(< 5 英哩)、far(>= 5 英哩)三桶。pl.lit("near") 是「建立一個常數字串欄位」——在 Polars 裡所有 Expression 都必須是「欄位或常數」,不能直接寫字串常數。每個 bucket 的平均費用也直接看出來:far 的趟次少但平均 30 美元,near 趟次多但平均不到 6 美元,這個對照對分析很有用。
Null 處理是 Polars 的另一個強項。Polars 的 null 是「真的 null」(不是 NaN、不是 None),這對型別系統更乾淨。常見的 null 操作有 .fill_null(value)、.drop_nulls()、.is_null()、.null_count()。實務上 fill_null() 經常搭配 mean().over(...) 用「群組平均值」填補缺失值,這個寫法在 Day 13 清洗環節會再出現。
常見錯誤與踩雷
錯誤一:用 pandas 的寫法寫 Polars。Polars 的 API 與 pandas 不完全相容,把 pandas 的 df["col"] 直接拿來用在 Polars 會出錯。對應排查方向:Polars 的欄位存取用 pl.col("col"),篩選用 df.filter(...),新增欄位用 df.with_columns(...)。具體差異可以參考 Polars 官方提供的「pandas 到 Polars」對照表。
錯誤二:忘記 collect()。Lazy 模式下查詢鏈不會自動執行,必須呼叫 collect() 才會拿到 DataFrame。如果忘記呼叫,得到的會是 LazyFrame 物件而不是資料。對應排查方向:每次寫完 chain 後檢查最後一個 method 是不是 .collect() 或 .fetch(n)。
錯誤三:把 Polars DataFrame 直接餵給 scikit-learn。scikit-learn 0.24 之前不接受 Polars DataFrame,雖然新版(1.x 之後)有改善,但實務上轉成 numpy 或 pandas 仍是主流做法。對應排查方向:用 df_polars.to_pandas() 或 df_polars.to_numpy() 轉成 scikit-learn 接受的格式;如果這個轉換太頻繁,可以考慮「上游用 Polars 處理、下游用 pandas 跑模型」的混用策略。
錯誤四:在 Eager 模式做大型 groupby。Eager 模式的 groupby 會把所有資料放進記憶體做 group key 的 hash table,資料量大時記憶體可能爆掉。對應排查方向:改用 Lazy 模式 + collect(streaming=True),讓 Polars 用批次方式執行 groupby。
錯誤五:以為 Polars 的欄位名稱可以重複。Polars 不允許同名的兩個欄位,但 with_columns() 預設會覆蓋同名欄位。如果你想要多個「同樣邏輯但不同條件」的欄位,要用 alias() 明確指定不同名稱,例如 pl.col("fare_amount").mean().alias("avg_fare")。
效能與實務提醒
Polars 在 2025 年 11 月的 1.33 世代已經非常穩定,但仍在快速演進。從 1.0 發布以來,每個月都會有小幅更新與錯誤修復。實務上建議鎖定一個穩定版本(例如 1.33.x),不要追著最新版跑,避免被 breaking change 打到。可以用 uv pip install polars==1.33.0 鎖定版本,並把 uv.lock 放進版控。
另一個實務提醒是 Polars 的「記憶體壓力」。雖然 Polars 比 pandas 省記憶體,但 Lazy 模式在 collect() 時仍會把中間結果放進記憶體。對「10 GB Parquet 跑 groupby」的場景,建議用 collect(streaming=True) 啟動 streaming engine,把中間結果分批處理。在我們這個 2.4 百萬筆的範例上,streaming 的差異不明顯,但對大型資料集很關鍵。
Polars 與 DuckDB 的取捨是 Day 12 的重點,但這裡先給一個概觀:Polars 適合「DataFrame 風格的運算、需要表達式組合、要寫成可重複的 Python 函式」;DuckDB 適合「SQL 風格的查詢、需要交易、需要直接讀寫 Parquet 與 CSV」。兩者的 sweet spot 有重疊也有差異,混用策略會把分工講清楚。
最後提醒:Polars 的 scan_parquet() 與 read_parquet() 在大型檔案上的差異非常明顯。當你能明確知道「要對哪些欄位做事、要做哪些過濾」時,先用 scan_parquet() + Expression chain + collect(),這通常比 read_parquet() + 各種 Eager 操作快很多。Polars 的 Lazy 引擎會自動把整個鏈編譯成最佳化過的查詢計畫,這是它在 2025 年成為「單機大資料處理」首選的根本原因。
Polars 與上下游工具的互通
Polars 在 2025 年已經建立了一套完整的「上下游互通」介面,可以與 pandas、NumPy、PyArrow、DuckDB 雙向轉換。這些轉換大多是 zero-copy(不複製資料),因為底層都是 Apache Arrow 格式:
# Polars ↔ pandas 互通
df_pl = pl.read_parquet("data/nyc_taxi_snappy.parquet").head(1000)
df_pd = df_pl.to_pandas() # Polars → pandas
df_pl_back = pl.from_pandas(df_pd) # pandas → Polars
# Polars ↔ PyArrow 互通
table = df_pl.to_arrow() # Polars → PyArrow Table
df_pl_back = pl.from_arrow(table) # PyArrow → Polars
# Polars ↔ NumPy 互通
arr = df_pl["fare_amount"].to_numpy() # Polars → NumPy
print(f"pandas 互通:{df_pd.shape[0]} 列;PyArrow 互通:{table.num_rows} 列;NumPy 互通:{arr.shape[0]} 元素")
# 輸出:
# pandas 互通:1000 列;PyArrow 互通:1000 列;NumPy 互通:1000 元素
這段展示 Polars 的三個關鍵互通 API:to_pandas()、to_arrow()、to_numpy()。所有轉換都是 zero-copy 或 near zero-copy,這代表把 Polars DataFrame 丟給 scikit-learn 或 matplotlib 時,效能損失極小。實務上「上游 Polars 清洗、下游 pandas/scikit-learn 模型」的混用工作流已經是常見做法,Day 12 會把這種混用展開成完整的策略。
另一個互通介面是「Polars DataFrame 餵給 DuckDB」。Polars DataFrame 本身就是 Arrow 格式,可以直接用 duckdb.query("SELECT * FROM df_pl").pl() 把 DuckDB 查詢結果回傳為 Polars DataFrame。這個介面在 Day 12 的「混用策略」會大量使用,是 Polars + DuckDB 整合的關鍵。
小結
今天把 Polars 1.3x 的核心 API 走了一遍:從 Eager 模式的 read_parquet().filter().with_columns(),到 Lazy 模式的 scan_parquet().filter().with_columns().collect(),再到 SQL Context 的 pl.SQLContext(...).execute()。Polars 的核心價值是「Expression + Apache Arrow + Lazy query optimization」三者的組合,這讓它在 2025 年成為「單機大資料處理」的首選。實務上 Polars 通常比 pandas 快 5 到 20 倍、記憶體省 2 到 3 倍,這個差距在大型資料集上會更明顯。
結語
今天的重點是「Polars 為什麼快又省記憶體」。我們從 Polars 的 Expression 與 Apache Arrow 開始、用 Eager 模式跑了基本查詢、用 Lazy 模式展示了查詢計畫與效能差距、最後示範了 SQL Context。讀完這篇你應該能回答:Polars 與 pandas 的關鍵 API 差異是什麼?Lazy 模式做了哪些最佳化?Polars 為什麼比 pandas 快那麼多?在什麼情境下 Polars 是更好的選擇?這些問題的答案都藏在本篇的程式碼與文字裡。
明天,我們會進入混用策略:pandas、Polars 與 DuckDB 的分工。實務上一個資料管線很少只押在單一工具上,多半會三個工具混合使用。我們會把昨天的 NYC 计程車 Parquet 當範例,展示「上游 Polars 清洗、中游 DuckDB 彙總、下游 pandas 對接既有模型」的完整鏈,並討論各工具的 sweet spot。
延伸資源
- Polars 官方文件(1.33,2025):
https://pola.rs/,包含 Expression API、LazyFrame、SQL Context 的完整說明。 - Polars GitHub 範例集(2025):
https://github.com/pola-rs/polars/tree/main/examples,從簡單查詢到 streaming engine 都有對應範例。 - Apache Arrow 官方文件(2025):
https://arrow.apache.org/,Polars 與 pandas 2.x PyArrow backend 的共同底層。 - DuckDB Python API 文件(1.4,2025):
https://duckdb.org/docs/api/python.html,Day 12 混用策略會用到的互通介面。 - NYC 计程車示範資料(CC0 授權):
https://duckdb.org/data/nyc-taxi.csv.gz,本篇與 Day 9、Day 10 範例的來源。
留言
張貼留言