Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

用 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 做資料分析(進階):向量化、記憶體與惰性運算的底層機制〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。