長格式與寬格式,以及各自何時派上用場
想像你經營三家小店——北店、南店、東店——而你把每家店在一月、二月、三月各賣了多少都記了下來。要把這九個數字排進一張表格,自然有兩種方式,而這兩者之間的差別,竟然是整個資料整理裡最有用的觀念之一。
第一種排法是試算表使用者會直覺採用的:每家店一列,每個月一欄。這叫做寬格式(wide format),因為每多加一個月,表格就往右多長出一欄,愈來愈寬。
store jan feb mar North 120 135 150 South 90 100 110 East 200 180 220
第二種排法乍看比較奇怪,但電腦很愛它。在這裡,每一個量測值都獨佔一列,再用欄位標明它屬於哪家店、哪個月。這叫做長格式(long format),因為每多加資料,表格就往下多長出幾列,愈來愈長。
store month sales North jan 120 North feb 135 North mar 150 South jan 90 South feb 100 South mar 110 East jan 200 East feb 180 East mar 220
請注意,沒有任何資訊被增加或遺失——兩張表裝的就是同樣那九個數字。它們是同一份資料穿了不同的衣服。那張長表正是我們上一篇所說的整齊資料:每一列一筆觀測值、每一欄一個變數、每一格一個值。而寬表打破了這條規則,因為一月、二月、三月其實是同一個變數(月份)的不同「值」,這裡卻把它們攤成了欄名。
一張並排示意圖:左邊是寬表,每家店一列、每月一欄;右邊是同樣的數字,排成長表,每個「店-月」組合一列,共三欄(店、月份、銷售額)。
樞紐:寬轉長、長轉寬
既然兩種形狀裝的是同一份資料,你就必須能在兩者之間自由轉換。這個轉換叫做樞紐(pivot),或稱重塑(reshape)——見樞紐/重塑。它是你整個職涯中最常做的兩三種操作之一,所以把名詞弄清楚很值得。
從寬轉長,常被稱為融化(melt,或稱反樞紐 unpivot、聚攏 gather)。想像寬表的欄名——jan、feb、mar——往下「融」進一個新欄位的格子裡。這三個欄名,對每家店各重複一次,就變成 month 欄裡的值;而它們底下的數字,則對齊排進 sales 欄。
反方向,從長轉寬,常被稱為樞紐(pivot,或稱攤開 spread、轉鑄 cast)。你挑一欄當作新的欄名(這裡是 month),再挑一欄填進格子(這裡是 sales)。每一個不同的月份,又各自變回一欄。
# wide -> long (melt / unpivot)
long = sales_wide.melt(
id_vars='store', # keep these as identifying columns
var_name='month', # old column headers go here
value_name='sales') # the numbers go here
# long -> wide (pivot)
wide = long.pivot(
index='store', # one row per store
columns='month', # one column per month
values='sales') # fill cells with sales到底為什麼要轉換?因為不同工具要求不同形狀。畫一張銷售隨時間變化的折線圖,需要長格式(線上每一點各一列)。而相關係數矩陣、或一張印出來給人比較的表,常常需要寬格式。你會不斷在「進入圖表或模型」的路上重塑資料,之後也許再把結果重塑回寬表,做成報表。
分組彙總:分割-套用-合併的模式
重塑只是搬動資料,並不做摘要。一旦你想做摘要——每家店的總銷售、每件商品的平均評分、每天的訂單數——你就會用到分組,正式名稱是分組彙總。它是所有分析操作裡最常被用到的一種,一旦你想通了,就會處處看見它。
這個心智模型有個著名的名字:分割-套用-合併(split-apply-combine)。你先把列分割成幾組,在每一組內套用一個摘要計算,再把「每組一個數字」的答案合併成一張小小的結果表。見分割-套用-合併。
- 分割:把長表切成幾組——北店的三列一組、南店的三列一組、東店的三列一組。用來分組的那一欄就是鍵。
- 套用:在每一組內各自跑一個摘要計算——例如,把三個銷售數字加起來得到總和。
- 合併:把每一組的答案疊成一張新的、較小的表,每一組佔一列。
以我們的店家資料來說,依店分組再加總銷售,得到北店 = 120 + 135 + 150 = 405、南店 = 90 + 100 + 110 = 300、東店 = 200 + 180 + 220 = 600。九列細節收斂成三列洞察。
# pandas
summary = (sales_long
.groupby('store')['sales']
.sum())
# the same idea in SQL
# SELECT store, SUM(sales) AS total_sales
# FROM sales
# GROUP BY store;一張整齊表格示意圖:列是觀測值、欄是變數;其中一欄被標示為分組鍵,箭頭顯示各列依該鍵的不同值被分到不同組。
能回答真實問題的彙總
你在每一組內套用的那個摘要計算,叫做彙總函數(aggregation function):它吃進許多值、吐出一個值。加總只是第一個例子。日常會用到的這一家成員不多,值得背起來:計數(count,有幾個)、總和(sum)、平均數(mean)、最小與最大(min/max)、中位數(median,最中間的值),以及標準差(standard deviation,衡量分散程度)。
多數真實問題,骨子裡只是「一個彙總」配上「一個分組」。要變得熟練,訣竅就是把白話翻譯成這一對。問題本身選定彙總函數;而「每個 X」或「各 X」這個片語,則選定分組欄位。
- 「每家店的總銷售是多少?」-> 依店分組,用 sum 彙總銷售。
- 「各地區的平均訂單金額是多少?」-> 依地區分組,用 mean 彙總訂單金額。
- 「我們每天有多少活躍使用者?」-> 依日期分組,用「相異值計數」彙總 user_id。
- 「每座城市記錄到的最高溫是多少?」-> 依城市分組,用 max 彙總溫度。
最常見的彙總——組內平均數,值得寫下它的公式。先用文字讀一遍:對某一組 g,把組內每個值加起來,再除以這組有幾個。底下的符號只是把這句話說得精確。
第 g 組的平均數:把屬於 g 的值加起來,再除以該組的個數 n_g。以北店為例,就是 (120 + 135 + 150) / 3 = 135。
你通常會一次算好幾個彙總——總和、平均、計數並排——因為每一個告訴你不同的事,而計數能讓你不被「小到沒代表性」的一組給騙了。
# several aggregations in one pass (pandas)
summary = (sales_long
.groupby('store')['sales']
.agg(['sum', 'mean', 'count']))
# SQL
# SELECT store,
# SUM(sales) AS total_sales,
# AVG(sales) AS avg_sales,
# COUNT(*) AS n_months
# FROM sales
# GROUP BY store;用一段話講完視窗函數
分組彙總有一個限制:它把每一組壓成單獨一列,於是細節列就消失了。但你常常想保留每一筆原始列,只是在旁邊附上一個「依組計算」的摘要——讓每家店的每一列旁邊都帶著該店的平均,或把每一筆銷售顯示成它佔該店總額的百分比,又或在一欄上往下做累計加總。能保留列、又加上一個「懂分組」欄位的工具,就是視窗函數(window function,有時稱分析函數)。
關鍵的對比就一句話:分組彙總給你較少的列(每組一列);視窗函數給你一樣多的列,外加一個新欄位。在 SQL 裡你寫 OVER (PARTITION BY ...) 而不是 GROUP BY;PARTITION BY 子句就是分組,但列會保留下來。
SELECT store, month, sales,
AVG(sales) OVER (PARTITION BY store) AS store_avg,
sales - AVG(sales) OVER (PARTITION BY store) AS vs_avg
FROM sales;在視窗函數出現之前,要得到同樣的結果得繞遠路:先彙總成一張小小的「每組一列」表,再依分組鍵把這份摘要合併(join)回細節列。這做法至今仍然管用,也值得理解,因為它揭示了視窗函數底層在做什麼——而且當你的摘要本來就住在另一張表時,合併才是對的招。
一張示意圖:左邊的細節表與右邊的小摘要表,依一個共用的鍵欄位合併,產生一張結合後的表,使每一筆細節列都帶上了它所屬組的摘要值。
從原始列到摘要表
讓我們把這些零件兜在一起,用更接近真實的資料試試。假設你的應用程式每當有人按下「購買」,就往事件記錄寫一列:一個時間戳記、一個使用者 id、一個國家、一筆金額。這份原始記錄可能有上百萬列,單憑自己卻回答不了任何問題。重塑與彙總,正是從那一堆原始資料,通往「圖表或模型能用的表」的橋。
- 從整齊開始。確認這份記錄是「每筆購買一列」,並有乾淨的日期、使用者、國家、金額欄位。若不是,先把形狀修好。
- 決定答案的「粒度」。「每個國家每天的營收」意味著結果是「每個國家-每天一列」;這一對就是你的分組。
- 分組並彙總。依國家與日期分組,再用 sum 彙總金額(營收)、用相異計數彙總使用者(買家數)。
- 為目的地重塑。折線圖要長格式;「國家對月份」的報表要樞紐成寬格式。最後再重塑,等你確定它要去哪裡。
daily = (events
.groupby(['country', 'date'])
.agg(revenue=('amount', 'sum'),
buyers=('user_id', 'nunique'))
.reset_index())
# now reshape the summary for a report:
# one row per country, one column per month
report = daily.pivot_table(
index='country',
columns='date',
values='revenue')這種「先縮小、再重塑」的節奏,是分析每天的骨幹。原始事件列就是沿著這條路餵給探索與儀表板——探索性資料分析有很大一部分,不過就是一個接一個地試分組彙總,直到某個樣態跳出來。把這裡練熟,你午餐前就能回答多數商業問題。
重塑與彙總的陷阱
這些操作很強大,這也意味著它們是悄悄出錯,而非大聲報錯。輸出看起來仍是一張完全合理的表,只是它錯了。以下這些陷阱,每個人至少都會中一次。
陷阱一——弄丟了你需要的粒度。彙總在設計上就是會丟掉細節。如果你壓成每日總額,之後有人問起每小時的樣態,答案已經沒了;你必須回到原始列。永遠保留原始資料,把每一張摘要都當成一個衍生的視圖,絕不要當成唯一的副本。
陷阱二——還沒彙總就先合併。如果你合併兩張表,而其中一側對同一個鍵有好幾列,列數就會相乘,之後的 SUM 便會悄悄重複計算。一個營收數字可能因此大上兩三倍。解法幾乎總是:先把每張表彙總到正確的粒度,再去合併這些小摘要。每次合併前後,都盯著列數看。
陷阱三——遺漏值會改變計數。多數彙總函數會跳過空值。所以平均數會忽略空白,而「對某一欄計數」只算非空白的格子,「對列計數」卻全部都算。如果你的金額欄有一半是遺漏的,對它取平均,得到的是「有記錄那一半」的平均,而不是全體的平均——這可能正是錯的。在彙總之前,就刻意決定好怎麼處理遺漏值。
陷阱四——用重複的鍵去樞紐。樞紐假設每一列恰好對應一個格子。如果有兩列想搶同一個格子——比方說兩筆「北店/一月」的記錄——樞紐就只能報錯、或悄悄把它們彙總起來,而背著你偷偷加總是最糟的那種驚喜。一次乾淨的樞紐,需要「列鍵與欄鍵的組合」是唯一的,這正是主鍵背後同一個「唯一性」的概念。
陷阱五——平均的平均。把每組的平均再平均一次,當作整體平均,很誘人。但這只在「各組大小相同」時才正確。看看兩個地區:A 區有 1000 筆訂單、平均 10 美元;B 區有 10 筆訂單、平均 50 美元。把這兩個平均天真地再平均是 30 美元——但真正的整體平均低得多,因為 A 區那一千筆訂單佔了絕大多數。
正確的整體平均,要用各組的大小 n_g 加權。這裡是 (1000 x 10 + 10 x 50) / 1010 = 10500 / 1010 ~= 10.40 美元,而不是未加權平均所宣稱的 30 美元。
- 彙總之前,用文字寫下你答案的粒度:「每一列代表一個 ___」。
- 先把每張表彙總到那個粒度;合併的是小摘要,而不是原始大表。
- 每次合併、每次樞紐之後都檢查列數——數字意外變動,就是出事了。
- 決定好空值怎麼處理,並在每個平均與總和旁邊都帶上一個計數。
- 保留原始資料;把每一張重塑或彙總過的表,都當成可重現的衍生視圖。