跳到主要內容

DE Day 11 Polars:快而不占記憶體的選擇

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 範例的來源。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...