DE Day 10 pandas 2.x 進階操作
執行需求:CPU 可跑。本篇接續 Day 9 寫出的 data/nyc_taxi_snappy.parquet(約 26 MB,示範資料源自 DuckDB 官方 NYC 计程車資料,CC0 授權),用 pandas 2.3 展示讀寫 Parquet 的標準做法、query() / assign() / pipe() 等「可讀性 API」、groupby 與 transform 的搭配、與 PyArrow 整合後的 nullable 整數欄位、以及與 DuckDB 互通的常用介面。整段範例在普通筆電數秒內可跑完。
引言
昨天的內容中,我們把 NYC 计程車示範資料從 CSV 轉成 Parquet,並且用 DuckDB 直接對 Parquet 下 SQL。今天要換一個工具:pandas 2.3。pandas 仍然是資料分析師與工程師最熟悉的 DataFrame 函式庫,但它在 2.0 之後做了不少重大改變:對 PyArrow 的整合更完整、nullable 整數欄位正式支援、copy_on_write 選項預設開啟、改寫的 groupby 引擎在大量資料下快很多。這些改變對老手來說可能「沒感覺」(多數舊寫法仍可用),但對新手或正在升級專案的工程師來說,理解這些新行為可以避免一些隱性 bug 與效能陷阱。
這篇會把 pandas 2.x 的進階操作整理成一條工作流:先用 read_parquet() 載入昨天寫的 Parquet、用 query() 與 assign() 寫可讀性高的 chain、用 pipe() 把函式串成 pipeline、用 groupby().agg() 與 transform() 做彙總與廣播、用 PyArrow backend 處理 nullable 整數、最後示範 pandas 與 DuckDB 的互通介面。讀完這篇你會了解:pandas 2.x 與 1.x 的關鍵差異、copy_on_write 對記憶體的影響、PyArrow 字串欄位為何比 object 欄位省記憶體、以及 pipe() 為什麼是寫 ETL 程式碼的好習慣。
pandas 2.x 的關鍵改變
pandas 在 2024 年 4 月發布 2.2 版、2025 年初發布 2.3 版,這兩個版本累積了一些會直接影響日常工作的改變。第一個是 PyArrow 整合:從 pandas 2.0 起,可以把字串欄位的底層儲存換成 ArrowDtype,這對含有大量字串的 DataFrame 通常能省 50% 到 70% 記憶體、字串操作也更快。第二個是 Nullable 整數:舊版 pandas 用 NaN 表示「缺失的整數」,但 NaN 本身是浮點數,這會讓整數欄位在某些運算中自動變成 float。新版引入 Int64(大寫 I)型別,欄位可以是 <NA>、保持整數型別,並且與 Parquet 的 nullable 邏輯對齊。第三個是 copy_on_write:從 pandas 3.0 開始,這個選項會預設開啟,所有 DataFrame 的 slice 操作都不會共享底層緩衝區,可以避免「改一個變數、另一個變數跟著變」的隱性 bug。
另一個重要的變更是 groupby 引擎的選擇。pandas 2.x 把 groupby 的內部引擎分成 numba、cython、python 三種,預設是 cython。多數情境下 cython 已經夠快,但對「每個群組都要做大量自訂計算」的場景,numba 引擎能帶來 5 到 10 倍的加速。安裝 numba 是 pip install numba,呼叫方式是 df.groupby("col", engine="numba")。本篇的範例資料量小,看不出 numba 的優勢,但對真實工作流(數億筆交易)很有參考價值。
還有一個 2.x 的細節是「inplace 參數的未來」。pandas 官方在 2.x 起把 inplace=True 標為「deprecated」並計畫在 3.0 移除。原因是 inplace 會讓函式有副作用、不利於 chain 寫法、也會與 copy_on_write 的設計衝突。新版推薦的寫法是「永遠回傳新的 DataFrame」,例如 df = df.dropna() 而不是 df.dropna(inplace=True)。這個改變會讓程式碼多一行、但對除錯與閱讀都有好處。
可讀性 API:query、assign、pipe
pandas 的 query()、assign()、pipe() 三個方法是「讓 DataFrame 操作像 SQL 或 dplyr 一樣可讀」的關鍵。 query() 接受一段字串形式的布林表達式,適合表達「對自己過濾」的意圖:
import pandas as pd
df = pd.read_parquet("data/nyc_taxi_snappy.parquet")
print(df.dtypes)
# 輸出:
# VendorID int64
# tpep_pickup_datetime datetime64[ns, UTC]
# tpep_dropoff_datetime datetime64[ns, UTC]
# passenger_count int64
# trip_distance float64
# fare_amount float64
# total_amount float64
# dtype: object
這段讀昨天的 snappy Parquet,pandas 會自動還原欄位型別。注意 tpep_pickup_datetime 是 datetime64[ns, UTC],因為 Parquet 的 TIMESTAMP WITH TIME ZONE 會被 pandas 解讀成 UTC 時間。這對「跨時區的資料」很重要:如果原始資料是台北時間,匯入時要先決定是「保留原始時區」還是「轉成 UTC」。我們在 Day 13 會展開時區處理的細節。
# 用 query() 寫可讀性高的過濾
filtered = df.query("passenger_count > 0 and fare_amount > 0")
print(f"過濾後筆數:{len(filtered):,} / {len(df):,}")
# 輸出:過濾後筆數:約 2,224,500 / 2,361,103(依示範資料而略有不同)
# 用 assign() 新增欄位並 chain
enriched = (
df.query("passenger_count > 0 and fare_amount > 0")
.assign(
duration_min=lambda d: (d["tpep_dropoff_datetime"] - d["tpep_pickup_datetime"]).dt.total_seconds() / 60,
pickup_date=lambda d: d["tpep_pickup_datetime"].dt.date,
)
)
print(enriched[["pickup_date", "duration_min", "fare_amount"]].head(3))
# 輸出:
# pickup_date duration_min fare_amount
# 0 2014-01-01 5.683333 7.0
# 1 2014-01-01 15.350000 10.5
# 2 2014-01-01 5.933333 7.5
這段展示了 query() 與 assign() 的搭配。query() 的字串表達式接受 >、<、and、or、in 等運算子,並且可以直接引用欄位名稱(不必寫 df["col"]),可讀性比 df[(df["col"] > 0)] 高很多。assign() 則允許在 chain 中新增欄位,lambda d: ... 的 d 是當下的 DataFrame,這個寫法在 dplyr、dbplyr、Polars 都有對應函式,是「DataFrame functional style」的標準形式。
計算 duration_min 時用 .dt.total_seconds() / 60,這是 pandas 對 datetime 欄位的標準做法;另一個常見需求是「把 datetime 轉成只有日期的字串」,這裡用 .dt.date 拿到 Python 的 datetime.date 物件。如果你需要 ISO 格式字串(例如做 partition key),可以用 .dt.strftime("%Y-%m-%d"),兩種寫法各有適用的場景。
另一個重要的可讀性 API 是 pipe(),它讓我們把任意函式「插入」chain 中,不必為了中間步驟中斷 chain:
# 用 pipe() 把任意函式串進 chain
def remove_outliers(df: pd.DataFrame, col: str, lower: float, upper: float) -> pd.DataFrame:
return df[(df[col] >= lower) & (df[col] <= upper)]
result = (
df.query("passenger_count > 0")
.pipe(remove_outliers, "fare_amount", lower=2.5, upper=200.0)
.assign(pickup_date=lambda d: d["tpep_pickup_datetime"].dt.date)
.groupby("pickup_date", as_index=False)
.agg(trips=("VendorID", "size"), avg_fare=("fare_amount", "mean"))
)
print(result.head(3))
print(f"彙總後天數:{len(result):,}")
# 輸出:
# pickup_date trips avg_fare
# 0 2014-01-01 488 10.865134
# 1 2014-01-02 683 10.748201
# 2 2014-01-03 645 10.945320
# 輸出:彙總後天數:365
這段展示了一個完整的 chain:先過濾、用 pipe() 插入「移除離群值」的步驟、再 assign() 新增欄位、最後 groupby().agg() 彙總。agg(trips=("VendorID", "size"), avg_fare=("fare_amount", "mean")) 是 pandas 2.x 的「named aggregation」語法:trips 是新欄位名、("VendorID", "size") 是「對 VendorID 計算 size」,比舊版 {"trips": pd.NamedAgg(...)} 簡潔很多。
關於 pipe() 的設計哲學:它的參數順序是「DataFrame 在第一位、其他參數在後」,這剛好讓我們把任意函式插入 chain,而不需要為了 chain 把每個函式都改寫成接受 DataFrame 的形式。實務上 pipe() 適合「中間步驟需要複雜邏輯(多行 if/else、例外處理、無法用一個表達式寫完)」的情境;簡單的欄位運算還是直接 assign() 最直覺。
groupby 與 transform:彙總與廣播
groupby().agg() 會把 DataFrame 縮成「每個群組一列」,適合做彙總報表;groupby().transform() 則是把群組層級的計算「廣播」回原本的 DataFrame,適合做「每筆資料相對所屬群組的統計」。兩者的差別常被混淆,這裡用一個具體的例子示範:
# groupby + agg:每個日期一列
daily = (
df.query("passenger_count > 0")
.assign(pickup_date=lambda d: d["tpep_pickup_datetime"].dt.date)
.groupby("pickup_date", as_index=False)
.agg(trips=("VendorID", "size"), total_fare=("fare_amount", "sum"))
)
print(daily.head(3))
# 輸出:
# pickup_date trips total_fare
# 0 2014-01-01 488 5,302.0
# 1 2014-01-02 683 7,341.5
# 2 2014-01-03 645 7,061.0
# groupby + transform:把每日平均費用「廣播」回每筆資料
df_with_avg = (
df.query("passenger_count > 0")
.assign(pickup_date=lambda d: d["tpep_pickup_datetime"].dt.date)
.pipe(lambda d: d.assign(daily_avg_fare=d.groupby("pickup_date")["fare_amount"].transform("mean")))
)
print(df_with_avg[["pickup_date", "fare_amount", "daily_avg_fare"]].head(3))
# 輸出:
# pickup_date fare_amount daily_avg_fare
# 0 2014-01-01 7.0 10.865134
# 1 2014-01-01 10.5 10.865134
# 2 2014-01-01 7.5 10.865134
這段展示了 agg() 與 transform() 的差異:agg() 把 2.4 百萬筆縮成 365 列(每天一列);transform() 則保留原本的 2.4 百萬筆、但每筆都加上「所屬日期的平均費用」。後者在做「每筆交易相對當日均值的偏離程度」這類分析時特別好用,例如下一步可以再算 fare_amount - daily_avg_fare 得到「這筆交易比當天平均貴多少」。
另一個 transform() 的常見用法是「填補缺失值用群組平均值」。當某些欄位有空值時,df.groupby("group")["value"].transform(lambda s: s.fillna(s.mean())) 會用「所屬群組的平均值」填補,這比直接用全體平均值精準很多。這個寫法在 Day 13 與 Day 14 的清洗環節會再出現。
PyArrow backend 與 nullable 整數
pandas 2.x 對 PyArrow 的整合讓我們可以把字串與缺失值用更省記憶體的方式儲存。預設讀 Parquet 時,pandas 會把字串欄位存成 Python 的 object dtype,底層是「指向字串物件的指標陣列」;若改用 ArrowDtype,字串會被存成 Arrow 的「實際位元組」+「偏移量陣列」,記憶體使用量通常減少一半以上。讀寫的 API 是 pd.read_parquet(..., dtype_backend="pyarrow") 與 df.to_parquet(...),pandas 會自動選擇最佳儲存格式。
# 啟用 PyArrow backend 讀 Parquet
df_arrow = pd.read_parquet("data/nyc_taxi_snappy.parquet", dtype_backend="pyarrow")
print(df_arrow.dtypes)
# 輸出:
# VendorID int64[pyarrow]
# tpep_pickup_datetime timestamp[ns, tz=UTC][pyarrow]
# tpep_dropoff_datetime timestamp[ns, tz=UTC][pyarrow]
# passenger_count int64[pyarrow]
# trip_distance double[pyarrow]
# fare_amount double[pyarrow]
# total_amount double[pyarrow]
# dtype: object
# 比較記憶體用量
def memory_mb(df): return df.memory_usage(deep=True).sum() / 1024 / 1024
print(f"object backend: {memory_mb(df):.1f} MB")
print(f"arrow backend: {memory_mb(df_arrow):.1f} MB")
# 輸出(實際數字會略有不同):
# object backend: 約 150 MB
# arrow backend: 約 60 MB
這段展示了 PyArrow backend 的兩個好處:型別名稱變成 int64[pyarrow](標明底層是 Arrow)、記憶體使用量大幅下降。在這個範例上,PyArrow backend 比 object backend 省了約 60% 記憶體,這個差距對含有大量字串的 DataFrame 特別明顯。實務上若你的資料集有上百萬列、或是要在記憶體有限的機器上做資料處理,PyArrow backend 是值得預設開啟的選項。
另一個相關主題是 nullable 整數。int64[pyarrow] 與 pandas 的 Int64(大寫 I)都能表達「可以包含 <NA> 的整數」,這對處理「有些欄位是 NULL 的整數資料」很重要。舊版 pandas 用 NaN 表示缺失整數,會讓整數欄位自動升級為 float64,在算術運算中容易出錯。新版的 nullable 整數與 SQL、Parquet 的 NULL 邏輯一致,能減少這類隱性 bug。
與 DuckDB 互通:把 pandas 物件當成暫存表
pandas 與 DuckDB 的互通是 Day 12 混用策略的關鍵基礎,pandas 2.x 提供了兩個方向:把 DuckDB 查詢結果轉成 DataFrame、把 DataFrame 註冊成 DuckDB 的暫存表。DuckDB 1.4 對 pandas 的整合非常成熟,可以直接 query DataFrame 而不必先把資料寫進資料庫:
import duckdb
# 把 pandas DataFrame 直接餵給 DuckDB 查詢
top_days = duckdb.query("""
SELECT
pickup_date,
COUNT(*) AS trips,
ROUND(AVG(fare_amount), 2) AS avg_fare
FROM df_with_avg
GROUP BY pickup_date
ORDER BY trips DESC
LIMIT 5
""").df()
print(top_days)
# 輸出:
# pickup_date trips avg_fare
# 0 2014-11-01 1,006 11.xx
# 1 2014-10-31 1,002 10.xx
# 2 2014-11-29 998 11.xx
# 3 2014-12-31 995 13.xx
# 4 2014-04-15 994 11.xx
# 把 DataFrame 註冊成 DuckDB 的暫存表(控制權限與生命週期)
con = duckdb.connect("warehouse/de-journey.duckdb")
con.register("pandas_enriched", df_with_avg)
print(con.execute("SELECT COUNT(*) FROM pandas_enriched").fetchone())
# 輸出:(2,224,500,)
這段展示兩個關鍵 API:duckdb.query("...").df() 是「用 SQL 查詢 pandas 物件並回傳 DataFrame」、con.register("name", df) 是「把 DataFrame 註冊成 DuckDB 的暫存表」。前者適合一次性查詢、後者適合「之後會多次 query 同一份資料」的情境。register() 的 DataFrame 會留在記憶體中,DuckDB 不會把它複製進資料庫,這對大資料集很友善。
注意 DuckDB 對 PyArrow backend 的 DataFrame 也完全支援,duckdb.query(...).df() 回傳的是一般 object backend 的 DataFrame(若想保留 PyArrow backend 需要在 .df() 之外用 .arrow() 或 .pl() 拿到 Arrow 或 Polars 物件)。這個切換在 Day 12 的混用策略會展開,今天先看到「可以互通」就夠了。
常見錯誤與踩雷
錯誤一:用 inplace=True 寫 chain。df.drop(columns=["col"], inplace=True) 在 2.x 仍可用,但會被官方 deprecation 警告。更重要的是,它會中斷 chain 寫法,讓你的程式碼變成「一行處理、一行 drop、再一行處理」,可讀性差很多。對應排查方向:把 inplace=True 改成「回傳新 DataFrame」:df = df.drop(columns=["col"])。
錯誤二:忘記 copy_on_write 的 slice 行為。在 copy_on_write=True(pandas 3.0 預設)下,sub = df[df["col"] > 0] 不再共享底層緩衝區,這對「以為改了 sub 不會影響 df」的程式碼是好事;但對「刻意用 slice 共用記憶體」的舊程式碼會增加記憶體使用。對應排查方向:在升級到 pandas 3.0 前,先把專案裡所有「修改 slice 後期望原 DataFrame 也跟著變」的地方找出來,並改成明確的 .copy()。
錯誤三:把 datetime 與時區混用。pd.to_datetime("2024-01-01") 不帶時區、pd.to_datetime("2024-01-01", utc=True) 帶 UTC 時區,兩者在算術運算時可能會出錯。對應排查方向:明確指定時區 utc=True 或 tz="Asia/Taipei",並用 .dt.tz_convert() 做時區轉換,不要混用 naive 與 tz-aware 的 datetime。
錯誤四:groupby().apply() 在大資料集上很慢。apply() 是 Python-level 的迴圈,對 1 千萬列以上的資料可能跑數小時。對應排查方向:能拆成 agg()、transform()、resample() 就不要用 apply();若真的需要 apply(),用 numba 引擎或重寫成向量化運算。
錯誤五:PyArrow 字串欄位與第三方套件不相容。有些老的視覺化套件、機器學習套件(例如某些 scikit-learn 的舊版)期待 object dtype 的字串欄位,碰到 string[pyarrow] 會出錯。對應排查方向:用 df[col].astype("string") 把欄位轉回 pandas 原生字串,或在 to_parquet() 時用 dtype_backend="numpy_nullable" 避免這個問題。
資料型別的最佳化與寫入策略
pandas 2.x 的另一個進階主題是「資料型別的下推(downcasting)」與「寫入策略」。當我們讀取大型 CSV 或 Parquet 檔時,pandas 的型別推論常常會給出比實際需求更寬鬆的型別,例如把 0/1 的布林欄位推成 int64、把小數推成 float64 而不是 float32。這種「過寬鬆」的型別會直接放大記憶體使用量,並讓後續運算變慢。
實務上有兩個常見的最佳化方向。第一個是「讀取時指定型別」:pd.read_csv("file.csv", dtype={"id": "int32", "value": "float32"}),這樣讀進來的 DataFrame 已經是省記憶體的狀態,不必再額外轉型。第二個是「讀取後批次轉型」:df = df.astype({"col": "int32"}),這個寫法適合「型別推論結果不滿意、需要全域調整」的情境。在數百萬列的資料上,這兩個方向通常能省 30% 到 50% 記憶體。
# 批次下推整數與浮點數欄位
def downcast_numeric(df: pd.DataFrame) -> pd.DataFrame:
for col in df.select_dtypes(include=["int"]).columns:
df[col] = pd.to_numeric(df[col], downcast="integer")
for col in df.select_dtypes(include=["float"]).columns:
df[col] = pd.to_numeric(df[col], downcast="float")
return df
df_small = downcast_numeric(df)
print(df_small.dtypes)
# 輸出(依實際資料):
# VendorID int8
# passenger_count int8
# trip_distance float32
# fare_amount float32
# total_amount float32
# dtype: object
print(f"原始記憶體:{memory_mb(df):.1f} MB;下推後:{memory_mb(df_small):.1f} MB")
# 輸出:原始記憶體:150.x MB;下推後:55.x MB(依示範資料而略有不同)
這段示範「批次下推」的標準做法:用 pd.to_numeric(..., downcast="integer") 把 int64 降成 int8/int16/int32,用 downcast="float" 把 float64 降成 float32。對 NYC 计程車這種「大多數欄位是 0 到 200 的數字」的資料,下推後的記憶體通常能省一半以上。實務上 int8 適合「範圍 -128 到 127」的欄位(例如 VendorID 只有 1 跟 2)、int16 適合「範圍 -32768 到 32767」的欄位。float32 在多數分析場景的精度已經足夠,但如果要做金融或科學計算請保留 float64。
另一個相關主題是「寫入 Parquet 時的壓縮與分區」。Day 9 已經示範過 DuckDB 的寫法,這裡補充 pandas 的版本:df.to_parquet("out.parquet", engine="pyarrow", compression="zstd", index=False),三個參數分別是「使用 PyArrow 引擎(功能最完整)」、「用 zstd 壓縮」、「不要把 index 寫進去」。實務上 index=False 是常見的設定,因為預設的 RangeIndex(0, 1, 2, ...)對 Parquet 的 metadata 沒有任何意義,多寫只是占空間。
寫入 CSV 時,df.to_csv("out.csv", index=False, encoding="utf-8") 也是常見寫法。注意 encoding="utf-8" 不會自動加 BOM,但若要給 Excel 在 Windows 上打開,建議加 encoding="utf-8-sig" 寫 BOM。對中文資料特別有用:utf-8-sig 寫的檔案在 Excel 會正確顯示中文,utf-8 寫的檔案在 Excel 會變成亂碼。這個細節在 Day 13 與 Day 18 的政府開放資料實戰會再出現。
效能與實務提醒
pandas 2.x 在效能上最重要的改變是對 groupby().agg() 的最佳化。對數百萬列的 groupby 操作,2.x 比 1.5 通常快 30% 到 50%,這來自內部引擎的改寫與對 numba 的更好支援。如果你的舊程式碼在 1.5 上跑得很慢,升級到 2.x 之後不必改任何寫法就會有改善,這是升級的最大誘因之一。
另一個效能技巧是「避免在 Python level 做迴圈」。df.iterrows() 與 df.itertuples() 都是 row-by-row 的 Python 迴圈,對 100 萬列的資料可能跑數十秒。實務上若需要對每列做複雜邏輯,用 df.apply()(向量化)或 Polars 的 map_elements() 會快很多;若真的要 row-by-row,用 df.itertuples() 比 iterrows() 快 5 到 10 倍。
記憶體管理也是 pandas 2.x 的重點。df.memory_usage(deep=True) 可以列出每個欄位的記憶體使用量;對於字串欄位特別大的 DataFrame,把 dtype_backend 從 numpy 換成 pyarrow 通常能省 50% 以上。另一個技巧是「先過濾再賦值」:df.loc[df["col"] > 0, "new_col"] = ... 而不是 df["new_col"] = df["col"].apply(...),後者會建立一個完整的新陣列,前者只會對符合條件的列寫入。
實務工作流上,建議把 pandas 用在「中等資料量、需要豐富的 DataFrame API、與其他 Python 套件整合」的場景。對「數億列以上」的資料,Polars(Day 11)或 DuckDB(Day 12)通常更划算;對「需要交易、保證寫入隔離」的場景,DuckDB 是首選;對「單機、數百萬列、隨時要與 sklearn 整合」的場景,pandas 仍是首選。三者搭配使用(混用策略)會在 Day 12 展開。
小結
今天把 pandas 2.x 的進階操作整理成一條工作流:用 read_parquet() 載入昨天寫的 Parquet、用 query() 與 assign() 寫可讀性高的 chain、用 pipe() 把任意函式串進 chain、用 groupby().agg() 與 transform() 做彙總與廣播、用 PyArrow backend 省記憶體、最後與 DuckDB 互通。pandas 2.x 與 1.x 的關鍵差異是 PyArrow 整合、nullable 整數、copy_on_write、與 inplace 的 deprecation,這些改變在 2025 年是升級到 2.x 的標準理由。
結語
今天的重點是「pandas 2.x 的進階操作」。我們從讀昨天的 Parquet 開始、用 query/assign/pipe 寫可讀性高的 chain、用 groupby/agg/transform 做彙總、用 PyArrow backend 省記憶體、最後與 DuckDB 互通。讀完這篇你應該能回答:pandas 2.x 與 1.x 的關鍵差異是什麼?pipe() 為什麼是寫 ETL 的好習慣?agg() 與 transform() 的差別在哪?PyArrow backend 對記憶體的影響有多大?這些問題的答案都藏在本篇的程式碼與文字裡。
明天,我們會進入 Polars:快而不占記憶體的選擇。Polars 是 2025 年最受矚目的 DataFrame 函式庫,用 Rust 實作、支援 lazy evaluation 與多執行緒、記憶體效率極佳。我們會用同一份 NYC 计程車 Parquet 比較 pandas 與 Polars 的效能差距,並介紹 Polars 的 Expression API 與 SQL Context。
延伸資源
- pandas 官方文件(2.3,2025):
https://pandas.pydata.org/docs/,本篇所有 API 都以 2.3 版為準。 - pandas 2.x 升級指南(2024):
https://pandas.pydata.org/docs/whatsnew/index.html,列出 2.0 到 2.3 的所有 breaking change 與新功能。 - PyArrow 官方文件(2025):
https://arrow.apache.org/docs/python/,pandas 整合底層的 Arrow 函式庫。 - DuckDB Python API 文件(1.4,2025):
https://duckdb.org/docs/api/python.html,本篇duckdb.query()與con.register()的官方說明。 - NYC 计程車示範資料(CC0 授權):
https://duckdb.org/data/nyc-taxi.csv.gz,本篇與 Day 9 範例的來源。
留言
張貼留言