Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

用 Python 做資料分析

用 Python 做資料分析(進階):向量化、記憶體與惰性運算的底層機制

從 ndarray 的 strides 與廣播,到 pandas 的 BlockManager 與超出記憶體時的串流與惰性引擎,理解「為什麼這樣寫會快」。

為什麼你的 pandas 程式跑得比 Excel 還慢?

你已經會用 numpypandasmatplotlib 處理資料了。現在想像一個情境:你拿到一份 500 萬列的選課紀錄,想算每位學生的平均分數。你很自然地寫了一個 for 迴圈,跑了三分鐘還沒結束;隔壁同學只寫了一行 groupby,零點幾秒就出結果。明明都是 Python,差距為什麼這麼大?

答案不在「Python 慢」這個迷思,而在於你有沒有讓資料留在 C 層、走向量化(vectorization)路徑。入門篇教你「怎麼用」這些工具,這篇要帶你看「為什麼這樣用會快」、「記憶體到底發生什麼事」,以及當資料大到塞不進記憶體時該怎麼辦。理解底層機制,是從「會用套件」進化到「能駕馭資料」的關鍵一步。

用 Python 做資料分析進階概念示意圖

numpy 的靈魂:ndarray 的記憶體佈局

要理解效能,得先理解 numpy 陣列到底是什麼。一個 ndarray 不只是「一堆數字」,它是一塊連續的記憶體緩衝區(contiguous buffer),外加一組描述如何解讀這塊記憶體的中繼資料(metadata)。

最關鍵的兩個概念是 dtypestrides

import numpy as np

a = np.arange(12, dtype=np.int64).reshape(3, 4)
print(a.dtype)     # int64,每個元素佔 8 bytes
print(a.shape)     # (3, 4)
print(a.strides)   # (32, 8)

strides(步幅)告訴 numpy:要往下一列移動,記憶體位址要前進 32 bytes(4 個 int64);要往右一欄移動,前進 8 bytes。這就是為什麼 reshape、transpose 幾乎不花時間——它們大多只是改寫 strides,沒有真的搬動任何資料。

b = a.T              # 轉置
print(b.strides)     # (8, 32) —— 只是把 strides 對調!
print(b.base is a)   # True —— b 與 a 共用同一塊記憶體

這帶出一個常被誤解的重點:view(檢視)與 copy(複製)的差別

view = a[:, 1:3]        # 切片通常產生 view,不複製資料
view[0, 0] = 999
print(a[0, 1])          # 999 —— 改 view 也改到了原陣列!

copy = a[:, 1:3].copy() # 明確複製
copy[0, 0] = -1
print(a[0, 1])          # 999 —— 這次原陣列不受影響

理解這點能避免兩類 bug:一是「我明明沒動原資料,怎麼變了」,二是「我以為是輕量切片,結果不小心複製了整份 GB 級資料」。

動手算一下:為什麼向量化快這麼多

很多人以為「numpy 比較快是因為用 C 寫的」,這只說對一半。真正的差異有三層:

  1. 避免 Python 物件開銷:純 Python 的 list 裡每個 int 都是一個完整的 Python 物件(在 64 位元平台上約佔 28 bytes,還要存指標)。numpy 的 int64 陣列每個元素只佔 8 bytes,且連續排列。
  2. 避免直譯器迴圈:Python 的 for 迴圈每一圈都要做型別檢查、bytecode 分派。numpy 把整個迴圈下放到編譯好的 C 程式碼一次跑完。
  3. 快取友善與 SIMD:連續記憶體讓 CPU 快取命中率高,編譯器還能用 SIMD 指令一次處理多個數字。
import time

n = 10_000_000
py_list = list(range(n))
np_arr = np.arange(n)

t0 = time.perf_counter()
s1 = sum(x * x for x in py_list)      # 純 Python
t1 = time.perf_counter()

s2 = int((np_arr * np_arr).sum())     # 向量化
t2 = time.perf_counter()

print(f"Python: {t1 - t0:.3f}s, numpy: {t2 - t1:.3f}s")
print(f"加速比約 {(t1 - t0) / (t2 - t1):.0f} 倍")

實際跑起來,numpy 通常快 50~100 倍。但要注意一個陷阱:過度向量化會吃光記憶體np_arr * np_arr 會先配置一個全新的 1000 萬元素陣列當中間結果。如果你連續做好幾個運算,記憶體峰值會疊加。這時 numexpr 或 in-place 運算(np.multiply(a, a, out=a))就派得上用場。

廣播(Broadcasting):不複製資料的維度對齊

廣播是 numpy 最優雅、也最容易誤用的機制。它讓不同形狀的陣列能直接運算,而且不需要真的把小陣列複製成大陣列

規則只有兩條,從最右邊的維度開始逐一比對:

  • 兩個維度相等,或
  • 其中一個維度是 1(會被「拉伸」對齊)
# 把一個 (1000, 3) 的資料每一欄做標準化(z-score)
data = np.random.randn(1000, 3) * [5, 100, 0.1] + [10, 200, 0]

mean = data.mean(axis=0)   # shape (3,)
std = data.std(axis=0)     # shape (3,)

# (1000, 3) 與 (3,) 廣播 —— mean、std 不會被複製成 1000 列
z = (data - mean) / std
print(z.mean(axis=0).round(6))  # 接近 0
print(z.std(axis=0).round(6))   # 接近 1

這裡 mean 形狀是 (3,),與 (1000, 3) 對齊時,最右維 3 == 3,左邊缺的維度補成 1 再拉伸。整個過程沒有實際配置 1000×3 的 mean 陣列,只是在計算時「假裝」它存在。

一個常見的進階技巧:用 None(等同 np.newaxis)手動插入維度,計算兩兩之間的距離矩陣。

# 計算 N 個點兩兩之間的歐氏距離,完全不用迴圈
points = np.random.rand(5, 2)         # 5 個二維點
diff = points[:, None, :] - points[None, :, :]   # (5, 1, 2) - (1, 5, 2) -> (5, 5, 2)
dist = np.sqrt((diff ** 2).sum(axis=-1))         # (5, 5)
print(dist.shape)   # (5, 5)

points[:, None, :] 把形狀從 (5, 2) 變成 (5, 1, 2),再與 (1, 5, 2) 廣播成 (5, 5, 2)。一行就完成了原本要雙層迴圈的計算。代價是中間 diff 陣列是 $O(N^2)$ 大小,點數一多就會爆記憶體——這也呼應了前面說的:向量化是用空間換時間。

pandas 的內部:BlockManager 與為什麼 apply 很慢

pandasDataFrame 並不是「一張二維表」那麼單純。它底層用 BlockManager 把相同 dtype 的欄位打包成連續的 numpy 區塊(block)。同型別欄位放一起,運算才能走 numpy 的向量化路徑。

這解釋了一個常見現象:混合型別的欄位(object dtype)特別慢

import pandas as pd

df = pd.DataFrame({
    'score': np.random.randint(0, 100, 1_000_000),
    'name': ['student'] * 1_000_000,
})
print(df.dtypes)
# score 是 int64(走 numpy),name 是 object(每格存 Python 字串指標,慢)

看一個例子:三種寫法的效能天差地遠

假設要把分數轉成等第。我們比較三種寫法:

import time

df = pd.DataFrame({'score': np.random.randint(0, 101, 2_000_000)})

# 寫法一:Python 迴圈 + iterrows(最慢,務必避免)
def grade_loop():
    grades = []
    for _, row in df.iterrows():
        grades.append('A' if row['score'] >= 90 else 'B')
    return grades

# 寫法二:apply(比迴圈快,但仍逐列呼叫 Python 函式)
def grade_apply():
    return df['score'].apply(lambda s: 'A' if s >= 90 else 'B')

# 寫法三:向量化布林索引(最快,整欄一次處理)
def grade_vectorized():
    g = np.where(df['score'] >= 90, 'A', 'B')
    return g

實測順序通常是:iterrowsapply 慢一個數量級,apply 又比向量化慢十幾倍。原因一致:iterrows 每列都要建立一個 Series 物件,apply 每列都要跨越 Python/C 邊界呼叫一次 lambda,而向量化把判斷下放到 C 層一次完成

這帶出一條黃金守則:在 pandas 裡看到 for 迴圈或 iterrows,先問自己能不能改成向量化或 groupby

# groupby 也是向量化的:底層用 hash 把列分組,再對每組做聚合
df2 = pd.DataFrame({
    'class_id': np.random.randint(1, 50, 1_000_000),
    'score': np.random.randint(0, 101, 1_000_000),
})
result = df2.groupby('class_id')['score'].agg(['mean', 'std', 'count'])
print(result.head())

groupby 的計算量約是 $O(n)$(雜湊分組)加上各組聚合,遠勝過自己用迴圈分桶。

category dtype:省記憶體又加速

當某欄是重複性高的字串(如系所、等第、學期),改成 category 型別能大幅省記憶體並加速 groupby。

df3 = pd.DataFrame({'dept': np.random.choice(['資工', '電機', '中文', '財金'], 1_000_000)})
print(df3['dept'].memory_usage(deep=True))   # object:很大

df3['dept'] = df3['dept'].astype('category')
print(df3['dept'].memory_usage(deep=True))   # category:只存整數編碼 + 對照表,小很多

category 的原理是把字串映射成整數編碼(codes),只存一份字串對照表(categories)。1000 萬列只有 4 種值時,記憶體可以從幾十 MB 降到幾 MB。

當資料塞不進記憶體:分塊與惰性運算

前面講的都假設資料能放進 RAM。但真實研究資料動輒幾十 GB,這時要換策略。

策略一:chunksize 分塊讀取

pd.read_csvchunksize 參數讓你一次只讀一塊,邊讀邊算,記憶體用量恆定。

# 假設 huge.csv 有上億列,記憶體放不下
total = 0
count = 0
for chunk in pd.read_csv('huge.csv', chunksize=500_000, usecols=['score']):
    total += chunk['score'].sum()
    count += len(chunk)
print('全體平均:', total / count)

這是典型的串流式聚合(streaming aggregation):把「總和」「筆數」這類可累加的統計量逐塊累積,最後合併。注意:均值、總和、最大值都能這樣算;但中位數、去重計數這類需要看到全部資料的統計量就不能簡單分塊,得用近似演算法(如 t-digest、HyperLogLog)。

策略二:選對 dtype 直接省一半記憶體

很多人讀進 CSV 後從不檢查 dtype,白白浪費記憶體。int64int32float64float32,記憶體立刻砍半。

df = pd.read_csv('data.csv')
print(df.memory_usage(deep=True).sum() / 1e6, 'MB')

# 把不需要 64 位元精度的欄位降型
df['age'] = df['age'].astype('int16')          # 年齡 0-150,int16 綽綽有餘
df['ratio'] = df['ratio'].astype('float32')
print(df.memory_usage(deep=True).sum() / 1e6, 'MB')

策略三:Parquet 與惰性引擎

CSV 是純文字,又大又慢(每次都要解析字串)。Parquet 是欄式(columnar)二進位格式,自帶壓縮與 dtype,讀取只載入需要的欄位。

# 一次轉檔,之後讀取又快又省
df.to_parquet('data.parquet')

# 只讀需要的兩欄,不必載入整份檔案
df_small = pd.read_parquet('data.parquet', columns=['student_id', 'score'])

更進一步,新一代工具如 PolarsDuckDB 提供惰性求值(lazy evaluation):你先描述完整的查詢計畫,引擎做查詢最佳化(如謂詞下推 predicate pushdown、只掃描必要欄位),最後 .collect() 才真正執行。

# Polars 惰性 API 示意
import polars as pl

result = (
    pl.scan_parquet('data.parquet')      # scan 不立刻讀檔,只建立計畫
      .filter(pl.col('score') >= 60)
      .group_by('dept')
      .agg(pl.col('score').mean())
      .collect()                          # 到這裡才真正執行,且已最佳化
)

scan_parquet 配合 filter 能在讀檔階段就跳過不符合條件的資料列群組(row group),這在 pandas 的 eager 模式裡是做不到的。

重點回顧

  • ndarray 的本質是「連續記憶體 + strides 中繼資料」。reshape、transpose、切片多半只改 strides 不搬資料;務必分清 view 與 copy,否則會踩到「改了 A 也改到 B」的雷。
  • 向量化快不只因為 C,而是同時消除了 Python 物件開銷、直譯器迴圈與快取不友善;代價是中間陣列會吃記憶體,必要時用 in-place 或 out= 參數。
  • 廣播讓不同形狀陣列對齊運算且不複製小陣列,但兩兩距離這類運算會產生 $O(N^2)$ 中間結果,要評估記憶體。
  • pandas 用 BlockManager 按 dtype 打包欄位iterrowsapply 慢是因為逐列跨越 Python/C 邊界,能向量化或 groupby 就別寫迴圈;高重複字串改 category 省記憶體。
  • 資料大於記憶體時,用 chunksize 串流聚合、降 dtype、轉 Parquet,或改用 Polars/DuckDB 的惰性引擎做查詢最佳化。

深入探討(研究所視角)

若想再往下鑽,有三條值得探索的路徑。

第一,記憶體模型與零複製互通。 numpy 的 strides 模型背後是 NumPy Array Interface 與更通用的 Apache Arrow 記憶體規格。Arrow 定義了一套跨語言的欄式記憶體佈局,使得 pandas、Polars、DuckDB、Spark 之間能零複製(zero-copy)共享資料——同一塊記憶體不必序列化/反序列化就能傳遞。理解 Arrow 的 buffer 與 validity bitmap(用來表示 null)設計,能幫你看懂為什麼 pandas 2.0 之後主推 Arrow-backed dtype,以及為何它的 string 運算比舊的 object dtype 快上數倍。

第二,計算的執行模型:eager 對 lazy。 pandas 是 eager(即時求值),每個運算立刻產生中間結果;Polars/DuckDB 是 lazy,先建立邏輯查詢計畫(logical plan)再交給最佳化器。這其實是把資料庫領域數十年的成果——關聯代數(relational algebra)、查詢最佳化、謂詞與投影下推(predicate / projection pushdown)、向量化執行引擎(vectorized execution,一次處理一批而非一列)——搬進了資料分析工作流。值得讀的關鍵詞是 MonetDB/X100 的 vectorized execution 論文與 morsel-driven parallelism。

第三,超越單機的平行化。 當資料連單機磁碟都放不下,就要走 out-of-core 或分散式運算。Dask 把 numpy/pandas API 包成可延遲、可分塊、可跨機器的任務圖(task graph),底層用拓撲排序排程;Spark 則以 RDD/DataFrame 配合 Catalyst 最佳化器。這裡的核心張力是計算移動到資料,而非資料移動到計算——理解資料局部性(data locality)、shuffle 成本與容錯(lineage-based recovery)的取捨,是大規模資料分析的真正分水嶺。

一個好的延伸練習:拿同一份 GB 級資料集,分別用 pandas、Polars lazy、DuckDB SQL 寫同一個分組聚合查詢,用 time 與記憶體監測工具比較三者,並試著解釋差異從何而來。當你能說清楚「為什麼這個查詢在 Polars 上快了五倍」,你對資料分析的理解就真正進到了系統層次。

AI 共讀助教正在陪你讀:用 Python 做資料分析(進階):向量化、記憶體與惰性運算的底層機制
嗨!我是這篇文章的共讀助教,只根據〈用 Python 做資料分析(進階):向量化、記憶體與惰性運算的底層機制〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。