Python 資料全景課 / 第四部分 應用 · M11

M11 表格資料

到目前為止,統計都靠自己寫迴圈:數銷量、加金額、按條件篩選。資料多了、問題多了,迴圈就寫不完。pandas 把資料變成一張表,篩選、分組、統計、畫圖都是一兩行。這個單元把小書店三個月的訂單攤平成表格,按月統計。
約 60 分鐘練習 notebook 1 個先修:M10
學完本單元,你能夠:
  1. 把 list of dict 變成 DataFrame,分清 DataFrame 和 Series;
  2. 用 json_normalize 把巢狀的訂單攤平成「一行一本書」的表格;
  3. 用條件篩選、新增欄、merge 合併、groupby 分組統計;
  4. 畫出統計圖(中文正常顯示),把結果寫出 CSV 和 JSON。

1為甚麼要表格 Why tables

M5 計算「每本書賣出多少本」,寫了兩層迴圈和一個計數的 dict。現在店長又問:「每個月賣了多少錢?哪間分店賣得最好?八月和九月比較呢?」每個問題都要再寫一段迴圈。

這些問題有一個共通點:資料是一行一筆、欄位固定的表格,問題都是「按某一欄分組,計算另一欄」。pandas 是專門處理這種資料的套件。在專案裏加入它和畫圖用的 matplotlib:

uv add pandas matplotlib

本課用的是 pandas 3。網上不少教學是 pandas 1、2 時代寫的,個別寫法已經不同,下文遇到時會指出。

2DataFrame 和 Series DataFrames and Series

一個 list of dict(M4 最常見的資料形狀),直接就能變成一張表:

import pandas as pd

books = [
    {"title": "看得見的資料", "price": 120, "stock": 3},
    {"title": "給初學者的 Python", "price": 180, "stock": 0},
    {"title": "設定檔的世界", "price": 95, "stock": 5},
]
df = pd.DataFrame(books)
           title  price  stock
0         看得見的資料    120      3
1  給初學者的 Python    180      0
2         設定檔的世界     95      5
圖 1 list of dict 和 DataFrame。每個 dict 變成一行,每個鍵變成一欄;左邊的 0、1、2 是行的索引(index)。取出一欄,得到的是 Series。
圖 1 list of dict 和 DataFrame。每個 dict 變成一行,每個鍵變成一欄;左邊的 0、1、2 是行的索引(index)。取出一欄,得到的是 Series。

對一欄做運算,會對每一行做,不用寫迴圈:

df["price"] * df["stock"]          # 每本書的庫存值:360、0、475
(df["price"] * df["stock"]).sum()  # 835

3讀入 CSV 和 JSON Reading files

df = pd.read_csv("books.csv", encoding="utf-8-sig")

read_csv 和 M10 的 csv 模組不同:它會猜每一欄的型別,"120" 讀入就是數字,方便得多。但 M9 的教訓同樣適用,猜就會猜錯:

pd.read_csv("contacts.csv")                        # phone 欄:0123456 → 123456,前面的 0 不見了
pd.read_csv("contacts.csv", dtype={"phone": str})  # 指明這欄是文字:'0123456'

編號、電話、代碼這類欄,用 dtype= 指明是字串。

4攤平巢狀的 JSON Flattening nested JSON

小書店的訂單是巢狀的:一張訂單有幾本書(M5)。表格只有行和列,要先攤平:一本書一行,訂單的資料在每一行重複。json_normalize 就是做這件事的:

orders = json.loads(Path("m11-orders.json").read_text(encoding="utf-8"))

items = pd.json_normalize(orders, record_path="items", meta=["id", "date", "customer", "branch"])
圖 2 攤平一張有兩本書的訂單。record_path 指出要展開的那一串(items),meta 列出要複製到每一行的訂單欄位。
圖 2 攤平一張有兩本書的訂單。record_path 指出要展開的那一串(items),meta 列出要複製到每一行的訂單欄位。
          title  qty    id        date customer branch
0        設定檔的世界    1  O001  2026-08-03      黃同學   機場分店
1  給初學者的 Python    2  O001  2026-08-03      黃同學   機場分店
2  給初學者的 Python    1  O002  2026-08-04      李先生     門市

訂單 O001 有兩本書,攤平後變成兩行,id、date、customer、branch 在兩行重複。這時還沒有價錢欄,下一節再合併。

巢狀的 dict(不是 list)更簡單,json_normalize 會把每一層用點號接起來:{"author": {"name": …}} 變成一欄 author.name。

5篩選、新增欄、合併 Filtering, new columns, merging

篩選:在方括號裏放一個條件,只留下條件成立的行:

df[df["stock"] == 0]                                   # 缺貨的書
df[(df["stock"] > 0) & (df["price"] < 150)]            # 有貨而且低於 150 元
用 & 和 |,而且每個條件加括號

pandas 的條件組合要用 &(而且)、|(或者),不能用 M5 的 and、or,否則會報 ValueError: The truth value of a Series is ambiguous。每個條件都要加括號:& 比 > 先算,漏了括號同樣會報這個錯。

新增欄:直接賦值,對每一行計算:

items["amount"] = items["price"] * items["qty"]

但訂單裏只有書名,沒有價錢。價錢在目錄裏。合併(merge)就是按某一欄把兩張表對上,和 M6 的 titles dict 查價錢是同一個意思:

catalog = pd.DataFrame(yaml.safe_load(Path("catalog.yaml").read_text(encoding="utf-8")))
items = items.merge(catalog[["title", "price"]], on="title", how="left")

how="left" 表示保留左邊(訂單)的每一行;目錄裏找不到的書,價錢是空值 NaN,不會靜靜地消失。

修改要用 .loc

想把缺貨的書庫存改成 10,直覺會寫 df[df["stock"] == 0]["stock"] = 10。在 pandas 3 裏這一句甚麼都不會改,只會出一個 ChainedAssignmentError 警告。改資料一律用 .loc[行的條件, 欄名]:

df.loc[df["stock"] == 0, "stock"] = 10

6分組統計 Grouping

「每本書賣出多少本」、「每月賣了多少錢」都是同一個模式:按一欄分組,對另一欄計算。

flowchart LR
    T["items 表格<br/>每本書一行"] -->|"分組<br/>groupby('title')"| G1["看得見的資料:2、1、3…"]
    T --> G2["設定檔的世界:1、3…"]
    T --> G3["…"]
    G1 -->|"計算<br/>sum()"| R["結果<br/>每本書一行"]
    G2 --> R
    G3 --> R
    classDef t fill:#f1f1ef,stroke:#d3d1cb,color:#37352f
    classDef g fill:#ffffff,stroke:#d3d1cb,color:#37352f
    class T,R t
    class G1,G2,G3 g
圖 3 groupby 的三步:按 title 把行分成幾組,每組各自加總 qty,再把結果合成一張新表。
items.groupby("title")["qty"].sum().sort_values(ascending=False)    # 每本書的銷量,由多到少

按月統計,先把日期字串轉成日期,再取出月份:

items["date"] = pd.to_datetime(items["date"])
items["month"] = items["date"].dt.to_period("M")          # 2026-08、2026-09…

items.groupby("month")["amount"].sum()                     # 每月營業額
items.groupby(["month", "branch"])["amount"].sum()         # 每月每間分店

一次計算幾樣東西,用 agg:

items.groupby("month").agg(訂單數=("id", "nunique"), 銷量=("qty", "sum"), 營業額=("amount", "sum"))

nunique 數有幾個不同的值:一張訂單攤平後有幾行,訂單編號重複出現,所以數訂單要用它,不能用行數。

7畫圖和寫出 Plotting and saving

統計的結果可以直接畫圖:

import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = ["Heiti TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Arial Unicode MS", "DejaVu Sans"]

monthly = items.groupby("month")["amount"].sum()
monthly.plot(kind="bar", title="每月營業額")
plt.show()
圖裏的中文變成方格

matplotlib 預設的字型沒有中文字,標題和標籤會變成一個個方格,並出現 Glyph … missing from font(s) DejaVu Sans 的警告。在畫圖之前加上上面那一行 font.sans-serif:Mac 會用到 Heiti TC,Windows 會用到 Microsoft JhengHei(微軟正黑體),清單裏第一個找得到的就會被用上。Linux 先安裝 fonts-noto-cjk,再刪除 ~/.cache/matplotlib 資料夾(matplotlib 的字型快取),重新啟動 kernel。JupyterLite 在瀏覽器裏執行,沒有中文字型,中文仍會顯示成方格。

寫出結果:

monthly.to_csv("monthly.csv", encoding="utf-8-sig")                     # 給 Excel 開(M10 的 BOM)
items.to_json("items.json", orient="records", force_ascii=False, indent=2)

orient="records" 寫出 list of dict(和讀入時的形狀一樣);force_ascii=False 就是 M8 的 ensure_ascii=False。

8容易混淆 Easily confused

這兩樣 分別
DataFrame 和 Series 前者是整張表;後者是一欄。
df["title"] 和 df[["title"]] 前者得到 Series;後者得到只有一欄的 DataFrame。
& 和 and pandas 的條件組合用 &、`
df[條件]["欄"] = 值 和 df.loc[條件, "欄"] = 值 前者在 pandas 3 不會改到資料;後者才會。
行數(len、count)和 nunique 前者連重複的訂單編號也算(count 數的是非空值);後者數有幾個不同的值。數訂單要用後者。
read_csv 和 csv 模組 前者猜型別(方便,但會吃掉前面的 0);後者一律字串。

9動手:三個月的訂單 Hands-on: three months of orders

  1. 下載 m11-orders.ipynb 和 m11-orders.json,連同 M4 的 catalog.yaml,放進 python-course 資料夾;
  2. 在終端機執行 uv add pandas matplotlib;
  3. 執行 uv run jupyter lab,打開 notebook,逐格執行。

小書店八至十月共 80 張訂單。你要把它們攤平、和目錄合併算出金額,再回答店長的問題:每月營業額、最暢銷的書、哪間分店賣得最好;畫兩張圖,最後寫出一份給會計部的 CSV。

工程線(選做)

m11-sensor-series-eng.ipynb 和 m11-sensor-hourly.csv:三日的每小時溫度讀數,中間斷線了三小時,還有一個明顯的異常值。把時間設為索引,用 resample 改為每六小時平均、找出缺了的時段、用滾動平均平滑雜訊,畫折線圖。

下一步

M12 看看身邊的設定檔:VS Code、GitHub Actions、docker-compose、Markdown 的 frontmatter,以及 Claude Code 的設定,全部都是 JSON 或 YAML。