用 Python 做資料分析:numpy、pandas 與 matplotlib 實戰
從一份成績 CSV 出發,用向量化陣列、DataFrame 與視覺化串起完整工作流,並接回優統計與優智慧 AI。
從一份成績 CSV 開始:你的第一場資料偵探任務
假設你手上有一份匯出的課堂資料:三百位同學、五次小考、出席率、還有一欄「最後總成績」。老師問你一個看似簡單的問題:「出席率高的同學,成績真的比較好嗎?」
你可以打開試算表,一格一格地框選、排序、目測。但當資料變成三千筆、三萬筆,或是每天自動更新時,手動的方法就崩潰了。這時候,Python 的資料分析三劍客——numpy、pandas、matplotlib——會把這份偵探任務變成幾行可重複執行的程式。更棒的是,這條工作流不會停在「畫一張圖」,它會自然接到「優統計」的敘述統計與迴歸,以及「優智慧 AI」的資料前處理。
這篇文章帶你一步一步動手,從原始的數字陣列,走到一個能回答真實問題的分析腳本。

numpy:把「一堆數字」變成會算術的陣列
先從最底層說起。如果你只用 Python 內建的串列(list)來存一萬個分數,要把每個分數乘以 1.1(加分),你得寫一個迴圈:
scores = [62, 75, 88, 91, 54]
adjusted = []
for s in scores:
adjusted.append(s * 1.1)
print(adjusted)
# 輸出:[68.2, 82.5, 96.8, 100.10000000000001, 59.400000000000006]
這能跑,但既冗長又慢。numpy 提供了 ndarray(N 維陣列),讓你把整個運算「一次套用到所有元素」,這叫做向量化(vectorization):
import numpy as np
scores = np.array([62, 75, 88, 91, 54])
adjusted = scores * 1.1 # 一行,整個陣列一起算
print(adjusted)
# 輸出:[68.2 82.5 96.8 100.1 59.4]
注意 scores * 1.1 並沒有迴圈,卻把每個元素都乘上了 1.1。這種「純量對整個陣列」的運算稱為廣播(broadcasting)。numpy 還內建大量的統計函式,直接對陣列操作:
print(scores.mean()) # 平均
# 輸出:74.0
print(scores.std()) # 母體標準差
# 輸出:14.177446878757825
print(scores.max(), scores.min())
# 輸出:91 54
print((scores >= 60).sum()) # 及格人數:布林陣列加總
# 輸出:4
最後一行特別值得玩味:scores >= 60 會回傳一個布林陣列 [True True True True False],而 True 在求和時當作 $1$、False 當作 $0$,所以 .sum() 直接數出及格人數。這種「用布林陣列當遮罩」的思路,是整個資料分析的核心慣用法。
我們也可以用布林遮罩篩選元素:
passing = scores[scores >= 60] # 只留下及格的
print(passing)
# 輸出:[62 75 88 91]
向量化的好處不只是程式變短,更重要的是快——這點我們留到最後一節從底層解釋。現在先記住一個原則:在 numpy / pandas 裡,能不寫 Python 迴圈就不寫。
pandas:給資料一個「表格」的形狀
numpy 的陣列是純數字的方陣,但真實資料有欄位名稱、有不同型別(姓名是字串、分數是數字、出席是日期)。這時候就輪到 pandas 的 DataFrame 登場——你可以把它想成「程式版的試算表」。
讀進一份 CSV
假設我們有一個 grades.csv:
name,attendance,quiz_avg,final
Amy,0.95,82,88
Ben,0.60,55,51
Cathy,0.88,79,84
Dan,0.45,48,40
Eva,0.92,90,93
讀進來只要一行:
import pandas as pd
df = pd.read_csv("grades.csv")
print(df.head()) # 看前幾列
print(df.shape) # 輸出:(5, 4) → 5 列、4 欄
print(df.dtypes) # 每欄的資料型別
df.head() 預設顯示前 5 列,是你拿到任何資料的第一個動作。df.describe() 則一次給你所有數值欄的敘述統計——這正是「優統計」敘述統計的起點:
print(df.describe())
# 輸出(節錄):
# attendance quiz_avg final
# count 5.000000 5.000000 5.000000
# mean 0.760000 70.800000 71.200000
# std 0.218861 17.633774 23.491488
# min 0.450000 48.000000 40.000000
# ...
篩選:用條件挑出你要的列
延續開頭的問題,我們想看「出席率高於 0.8」的同學:
high_attend = df[df["attendance"] > 0.8]
print(high_attend[["name", "attendance", "final"]])
# 輸出:
# name attendance final
# 0 Amy 0.95 88
# 2 Cathy 0.88 84
# 4 Eva 0.92 93
df["attendance"] > 0.8 跟 numpy 一樣回傳一個布林序列,放進 df[...] 就會把為 True 的列留下。多個條件要用 &(且)、|(或)連接,而且每個條件都要用括號包起來(這是初學者最常踩的雷):
# 出席率高「且」小考平均及格
mask = (df["attendance"] > 0.8) & (df["quiz_avg"] >= 60)
print(df[mask]["name"].tolist())
# 輸出:['Amy', 'Cathy', 'Eva']
groupby:分組後再彙總
groupby 是 pandas 最有威力的工具。先幫每位同學貼上「出席等級」標籤,再分組算平均:
# 用 numpy 的條件函式產生分組欄位
df["attend_level"] = np.where(df["attendance"] >= 0.8, "高出席", "低出席")
summary = df.groupby("attend_level")["final"].agg(["mean", "count"])
print(summary)
# 輸出:
# mean count
# attend_level
# 低出席 45.500000 2
# 高出席 88.333333 3
短短三行,我們就回答了開頭的問題:高出席組的總成績平均 88.3,低出席組只有 45.5。groupby(欄位)[目標欄].agg([...]) 是一個你會用一輩子的句型——「依某欄分組,對另一欄做彙總」。
matplotlib:讓數字說話
數字再清楚,也不如一張圖直觀。matplotlib 是 Python 最基礎的繪圖庫,pandas 與它無縫整合。畫一張「出席率 vs 總成績」的散佈圖:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(df["attendance"], df["final"], s=80, color="#3C938A")
ax.set_xlabel("出席率")
ax.set_ylabel("總成績")
ax.set_title("出席率與總成績的關係")
plt.tight_layout()
plt.savefig("scatter.png", dpi=120) # 存檔,不一定要 show()
幾個慣例值得記住:用 fig, ax = plt.subplots() 取得「畫布」與「座標軸」物件,之後所有設定都掛在 ax 上,這是比舊式 plt.plot() 更清楚、更好維護的物件導向寫法。畫長條圖呈現分組結果也很直接:
fig, ax = plt.subplots(figsize=(5, 4))
summary["mean"].plot(kind="bar", ax=ax, color=["#3C6F9A", "#00b297"])
ax.set_ylabel("平均總成績")
ax.set_title("不同出席等級的平均成績")
plt.tight_layout()
plt.savefig("bar.png", dpi=120)
summary["mean"].plot(kind="bar") 直接把 pandas 的彙總結果畫成長條圖——這就是三劍客協作的縮影:numpy 算、pandas 整理、matplotlib 呈現。
動手寫一段:一個完整的迷你分析腳本
把上面所有片段串成一個能獨立執行的腳本。它建立資料、做敘述統計、分組彙總、並計算一個簡單的相關係數(這一步就踏進了「優統計」的領域):
import numpy as np
import pandas as pd
# 1. 建立資料(實務上改成 pd.read_csv("grades.csv"))
data = {
"name": ["Amy", "Ben", "Cathy", "Dan", "Eva"],
"attendance": [0.95, 0.60, 0.88, 0.45, 0.92],
"quiz_avg": [82, 55, 79, 48, 90],
"final": [88, 51, 84, 40, 93],
}
df = pd.DataFrame(data)
# 2. 敘述統計(接「優統計」敘述統計)
print("總成績平均:", round(df["final"].mean(), 1))
print("總成績標準差:", round(df["final"].std(), 1))
# 3. 分組彙總
df["attend_level"] = np.where(df["attendance"] >= 0.8, "高出席", "低出席")
print(df.groupby("attend_level")["final"].mean())
# 4. 出席率與成績的相關係數(接「優統計」迴歸的前奏)
corr = df["attendance"].corr(df["final"])
print("出席率與總成績的相關係數:", round(corr, 3))
# 輸出:
總成績平均: 71.2
總成績標準差: 23.5
attend_level
低出席 45.5
高出席 88.3
Name: final, dtype: float64
出席率與總成績的相關係數: 0.974
相關係數 $0.974$ 接近 $1$,量化地確認了我們的直覺:出席率與成績高度正相關。下一步如果要建立預測模型(例如「給定出席率,預測成績」),就會用到「優統計」的線性迴歸——而它收的輸入,正是這份整理好的 DataFrame。
接回優統計與優智慧 AI
這條工作流的價值在於它是各模組共同的「資料底座」:
- 接「優統計」(敘述統計、迴歸):
df.describe()給你平均、標準差、四分位數;df["x"].corr(df["y"])給你相關係數。要做線性迴歸時,常見的scikit-learn模型直接吃 numpy 陣列或 DataFrame:model.fit(df[["attendance"]], df["final"])。換句話說,pandas 整理好的資料就是迴歸的原料。 - 接「優智慧 AI」(資料前處理):機器學習模型不能直接吃髒資料。處理缺失值(
df.fillna(df.mean()))、特徵縮放、把類別欄轉成數字(pd.get_dummies(df["attend_level"]))——這些前處理幾乎全在 pandas 裡完成。可以說,任何 AI 專案的第一個小時,幾乎都花在 pandas 上。
# 前處理示範:補缺失值 + 類別轉 one-hot
df_clean = df.copy()
df_clean["quiz_avg"] = df_clean["quiz_avg"].fillna(df_clean["quiz_avg"].mean())
features = pd.get_dummies(df_clean[["attendance", "attend_level"]])
print(features.columns.tolist())
# 輸出:['attendance', 'attend_level_低出席', 'attend_level_高出席']
常見錯誤
- 多條件篩選忘了加括號:
df["a"] > 1 & df["b"] < 2會因運算子優先序而報錯。&的優先序高於比較運算子,務必寫成(df["a"] > 1) & (df["b"] < 2),而且用&/|而不是 Python 的and/or。 - 在 DataFrame 上手寫 for 迴圈逐列計算:
for i in range(len(df)): df.loc[i, "x"] = ...又慢又容易出錯。先想想能不能用向量化或groupby取代——九成情況可以。 - 鏈式賦值的
SettingWithCopyWarning:df[df["a"] > 1]["b"] = 0可能改不到原資料。要修改請用df.loc[df["a"] > 1, "b"] = 0一次定位列與欄。 - 混淆母體與樣本標準差:
numpy的.std()預設是母體標準差(分母 $N$),pandas的.std()預設是樣本標準差(分母 $N-1$)。做統計推論時這個差別會影響結果,要看清楚你用的是哪一個。 - 以為讀進來的型別都對:CSV 全是文字,
read_csv會猜型別但不一定準。拿到資料先df.dtypes檢查,數字欄若變成object,多半是有非數字的髒值混在裡面。
深入探討(研究所視角)
向量化為什麼比 Python 迴圈快?
關鍵在於 Python 是直譯式、動態型別的語言。當你寫 for s in scores: s * 1.1,每一圈直譯器都要做一連串昂貴的工作:檢查 s 是什麼型別、找出 * 對這個型別該怎麼做、把結果裝箱成一個新的 Python 物件。對一萬個元素,這些「解譯開銷」就重複一萬次。
numpy 的陣列則完全不同。一個 ndarray 在記憶體裡是一塊連續(contiguous)、同質(homogeneous)型別的緩衝區——一萬個 float64 就是緊密排列的 $10000 \times 8$ 位元組,中間沒有 Python 物件的指標與標頭。當你寫 scores * 1.1,numpy 把整個運算交給底層用 C 寫好的迴圈執行:型別只檢查一次、迴圈在編譯後的機器碼裡跑、完全不碰 Python 直譯器。這通常帶來數十到上百倍的加速。
連續記憶體還帶來第二層好處:CPU 快取友善。現代處理器一次會把鄰近的一整段記憶體載入快取(cache line),連續存放的陣列正好讓每次載入都「物盡其用」;而 Python 串列存的是一堆指向四散物件的指標,存取時不斷在記憶體裡跳躍,快取命中率低。再加上 numpy 的 C 迴圈能讓編譯器啟用 SIMD(單指令多資料)向量指令,一道指令同時處理多個浮點數——這就是「向量化」一詞的硬體根源。
所以「能不寫迴圈就不寫」不只是風格偏好,而是把計算從「慢速的 Python 解譯層」推到「快速的 C/硬體層」的具體策略。
pandas 背後其實是 numpy
理解 pandas 的效能與行為,最好的角度是:DataFrame 的每一欄(Series),底層多半就是一個 numpy 陣列。你可以親眼驗證:
print(type(df["final"].to_numpy()))
# 輸出:<class 'numpy.ndarray'>
這解釋了許多現象。為什麼 df["final"] * 2 這麼快?因為它直接調用 numpy 的向量化乘法。為什麼數值欄要型別一致才有效率?因為背後是同質型別的 numpy 緩衝區;一旦混入字串,整欄退化成 object 型別(一堆 Python 物件指標),就失去了向量化優勢,這也是 dtype 變成 object 通常是效能警訊的原因。
更廣地看,這正是整個 Python 資料科學生態的設計哲學:以 numpy 的 ndarray 作為共通的資料交換格式。pandas 在它之上加了標籤與彙總語意;matplotlib 直接畫 numpy 陣列;scikit-learn 的模型輸入輸出也是 numpy 陣列。於是一條典型工作流長這樣:
pandas 讀檔與清理 → 轉成 numpy 陣列 → scikit-learn 訓練模型 → numpy 算出結果 → pandas 整理 / matplotlib 視覺化
當你之後接觸到 PyTorch 的 tensor、或處理大到放不進記憶體的資料而改用 Dask、Polars 時,會發現它們大量沿用同一套「陣列與向量化」的心智模型。換句話說,這篇文章裡那個小小的 np.array([62, 75, 88, 91, 54]),其實是通往整個現代資料科學與 AI 工程的第一塊基石。把向量化、DataFrame、視覺化這三件事練熟,你就握住了之後所有進階主題的鑰匙。