競爭排名 vs 密集排名 vs 百分位:地圖標籤的 ranking 設計
本篇是「打造 TaxMap-TW:用 Astro 做台灣所得地圖」系列的第 6 / 10 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
TaxMap-TW 的每個村里詳細頁都顯示「全國第 #58 / 7,748」這種排名。
我以為這是 5 分鐘的事:
sorted.sort((a, b) => b.value - a.value);
const rank = sorted.findIndex(v => v.code === target.code) + 1;
寫完跑一下 verify 腳本,發現一個怪事:
Max national rank == village count (7,748): expected 7748, got 7744 ✗
最大排名是 7,744,不是 7,748。為什麼?
挖下去發現「排名」其實有 5 種演算法,差別都在「同分怎麼處理」。這篇整理 5 種 ranking 演算法、選擇邏輯、以及為什麼地圖視覺化選 Competition Ranking。
排名為什麼會少 4 名?
先確認問題:
const lowest = Object.entries(r.rankings)
.map(([k, v]) => ({ key: k, rank: v.median.national.rank }))
.sort((a, b) => b.rank - a.rank)
.slice(0, 5);
// [
// { key: '新北市|石碇區|碧山里', rank: 7744 },
// { key: '宜蘭縣|大同鄉|土場村', rank: 7744 },
// { key: '澎湖縣|馬公市|新復里', rank: 7744 },
// { key: '臺南市|南區|荔宅里', rank: 7743 },
// { key: '新北市|瑞芳區|碩仁里', rank: 7742 },
// ]
3 個村里並列 7744 名。他們的中位數所得都是 0(沒有任何納稅單位申報)。
照「直觀」排名應該是 7746、7747、7748 — 但實際是 7744、7744、7744。
這就是 Competition Ranking 在「並列最後一名」時的行為:並列共享一個排名,剩下的名次被「跳過」。
5 種 ranking 演算法
維基百科把排名演算法分成 5 種。用 [100, 90, 90, 80, 70] 當範例:
1. Standard Ranking(也稱 Ordinal Ranking,1, 2, 3, 4, 5)
維基百科把這個序列正名為 Ordinal ranking。我這裡沿用程式裡寫的 Standard 名稱,但它對應的就是維基的 Ordinal。
sorted.indexOf(target) + 1; // 比物件參考而非值,同分也各自拿到唯一名次
簡單按 index 排,每個人都拿到唯一名次、永遠剛好 1 到 N。代價是同分時誰排前面取決於 sort 的細節 —— 如果你只用 value 排,同一個值可能拿到 2 或 3,結果不穩定。
❌ 沒定義 tie-breaker 時不適合公開展示,因為名次會跟著排序實作飄。 ✅ 但只要補一個次要排序鍵(村里代碼、名稱字典序),它就變 deterministic,而且是「需要強制唯一名次」時的正解 —— 像分頁、產生穩定的 URL、做有序匯出,你就是不想看到並列。我自己的看法:Ordinal 不是壞演算法,只是它的「壞」全來自你沒把 tie-breaker 想清楚。
2. Competition Ranking (1, 2, 2, 4, 5)
同分共享名次,下一個名次跳過(“olympic ranking”)。
function competitionRank(entries: { key: string; value: number }[]) {
const sorted = [...entries].sort((a, b) => b.value - a.value);
const ranks = new Map<string, number>();
let lastValue: number | null = null;
let lastRank = 0;
for (let i = 0; i < sorted.length; i++) {
const { key, value } = sorted[i];
const rank = value === lastValue ? lastRank : i + 1;
ranks.set(key, rank);
lastValue = value;
lastRank = rank;
}
return ranks;
}
✅ 公平、直覺、廣泛使用於體育(奧運、F1)。 ❌ 最大排名可能 < N(並列尾巴會壓縮)。
3. Modified Competition Ranking (1, 3, 3, 4, 5)
同分共享,但跳過的是「前面」(同分組共享後面的名次)。
[100, 90, 90, 80, 70]
1 3 3 4 5
✅ 跟 Competition 一樣公平,但對「並列第二」的視覺感更強(不是 2、2,而是 3、3)。 ❌ 直覺上有點怪,少用。
4. Dense Ranking (1, 2, 2, 3, 4)
同分共享,下一個名次不跳過。
function denseRank(entries: { key: string; value: number }[]) {
const sorted = [...entries].sort((a, b) => b.value - a.value);
const ranks = new Map<string, number>();
let lastValue: number | null = null;
let currentRank = 0;
for (const { key, value } of sorted) {
if (value !== lastValue) currentRank++;
ranks.set(key, currentRank);
lastValue = value;
}
return ranks;
}
✅ 最大排名 = 唯一值的數量。「全國第 X 級」這種概念上更貼。 ❌ 不直覺:你拿第 5 名可能其實是第 10 個人(前面有人並列)。
Dense 不是 Competition 的次等品,它有自己最對味的場景:當重複值很多、或你想傳達的是「分到第幾級」而不是「絕對名次」時,Dense 更合適。比方價格分 A/B/C/D 級、遊戲段位、或像我這個案例如果中位數所得只切成 5 段級距,Dense 給的「第幾級」反而比 Competition 的稀疏名次更好讀。我自己的取捨很簡單:使用者腦中想的是「我在第幾名」就用 Competition,是「我在哪一級」就用 Dense。
5. Fractional Ranking (1, 2.5, 2.5, 4, 5)
同分組分享「平均排名」。
[100, 90, 90, 80, 70]
1 2.5 2.5 4 5
✅ 統計學正統(用於 Mann-Whitney U test 等非參數檢定)。 ❌ 出現小數,user-facing 不好看(「你是第 2.5 名」🤔)。
SQL 對照
SQL 有對應的 window function,方便理解:
| 演算法 | SQL Window Function |
|---|---|
| Standard | ROW_NUMBER() |
| Competition | RANK() |
| Modified Competition | (無原生支援) |
| Dense | DENSE_RANK() |
| Fractional | (無原生支援) |
如果你用 PostgreSQL / MySQL,RANK() 就是 Competition Ranking。
TaxMap-TW 為什麼選 Competition Ranking
對「展示給使用者看」這個場景,候選是 Competition vs Dense。我選了 Competition:
1. 跟體育、考試成績一致
讀者看到「第 58 名 / 7,748」會自動套用「奧運排名」的直覺。這是 Competition Ranking 的語意。
2. 同分共享給予正確的「相對位置」
如果 3 個村里中位數都是 0,他們的「位置」就是相同的,應該共享同一個排名。Dense Ranking 反而會說他們是「第 N 級」,但這層抽象對地圖讀者意義不大。
3. 「跳過名次」這件事其實 OK
verify 腳本第一次失敗讓我以為這是 bug,但仔細想:沒有「第 7745 名、7746 名、7747 名」這 3 個位置是合理的 — 因為有 3 個人並列 7744。
我把 verify 腳本的斷言從 === 改成 <=:
check(
`${y} max national rank ≤ village count (${count})`,
maxRank <= count,
);
Competition 的代價我也認:最大排名 < N 這件事,第一眼確實會讓人懷疑「是不是漏算了」(我自己就被 verify 腳本嚇到一次);而且如果底部並列的村里很多 —— 像有一大票里中位數都是 0 —— Competition 會把它們全壓到同一個名次,名次的尾段等於被「壓扁」,看不出彼此差異。我覺得在這個案例可以接受,是因為那批所得 0 的村里本來就「沒有差異可言」,給它們同一名次反而誠實;要是換成一份重複值很密、又需要在尾段分出高下的資料,我就會回頭選 Dense。
跨年「比去年變動」的計算
排名穩定後,下一個需求是「比去年上升 / 下降幾名」(YoY delta):
const deltaYoY = previousYearRank - currentYearRank;
// 正值 = 名次上升(rank 數字變小)
// 負值 = 名次下降
// 0 = 持平
// null = 去年無資料
注意正負號:rank 是「越小越好」,所以 delta = 去年 - 今年。
實際範例(中華里 2022 vs 2021):
2021 全國中位數第 85 名
2022 全國中位數第 58 名
Delta = 85 - 58 = +27(上升 27 名)
UI 顯示:↑ 27(綠色 chip)。
邊界情況:
- 去年該村里無資料 → delta = null(顯示 ”—”)
- 今年該村里無資料 → 不顯示卡片
- delta = 0 → 顯示「持平」(不是 ↑0 也不是 ↓0)
排名 vs 百分位 — 兩個都要
地圖視覺化常會搞混「排名」和「百分位」這兩個概念:
排名(rank):絕對位置,用於展示
你的村里中位數所得 95 萬,全國第 58 名 / 7,748
百分位(percentile):分布位置,用於色階分級
你的村里中位數所得 95 萬,落在全國第 80 百分位(前 20% 的村里)
我一開始想偷懶,打算用排名直接驅動色階 —— 結果地圖糊成一片才想起這兩個根本不能混用:
- 詳細頁的大數字 → 用排名
- 地圖上的色塊 → 用百分位(quintile 分 5 級對應 5 種顏色)
百分位適合視覺化的理由是 單調性:第 50 百分位以下的村里色塊都比第 50 以上淺。但如果用排名,第 100 名和第 7000 名的色塊差異會被視覺壓縮(因為 1-7748 是線性的,但人眼的「淺到深」感受不是線性)。
不過用 quantile(等樣本數分桶)分級也不是沒代價:它保證每一級村里數量相等,但會把「值很接近、卻剛好跨桶邊界」的兩個村里塗成不同顏色,也會把「值差很多、卻擠在同一桶」的村里塗成同色 —— 所得這種長尾分布尤其明顯,最高那一桶可能從 200 萬一路涵蓋到 500 萬。所以分桶到底用 quantile 還是改用絕對門檻,本身就是另一個決策,我在色階那篇有完整討論。
色階分級的詳細討論在 這篇色階文章;至於最後我為什麼把色階從 viridis 換成 OrRd、怎麼把分桶和選色拆成兩個獨立決策,寫在 為什麼我把所得地圖色階從 viridis 換成 OrRd。
反思
本來估 5 分鐘搞定的功能,最後花了一個晚上讀維基百科的 ranking 條目、再回頭把 verify 腳本改一遍。讓我意外的不是排名很難,而是它根本不是一件事 —— sort 完寫個 index 的時候,我以為「排名」就是排名,完全沒意識到自己已經默默選了 Ordinal、還沒處理同分。
這種「以為是常識、其實是默認選擇」的感覺很熟悉,做色階分級那次也一模一樣:當時把資料丟進 quantile 分 5 級就覺得理所當然,後來才發現「要不要等樣本數分桶」本身就是個有後果的決定。兩次都是同一個教訓 —— 看起來最沒爭議的步驟,往往藏著你沒注意到自己做了的選擇。也因為這樣,我現在會刻意把「展示給使用者的數字」和「拿去算的數字」分開:詳細頁的大數字用 rank 求直覺,地圖色塊用 percentile 求單調,硬要共用一個 metric 只會兩邊都將就。
最有趣的觀察是 SQL window function 命名:RANK() 是 Competition,DENSE_RANK() 才是 Dense。也就是 SQL 標準把沒前綴的 RANK() 對應到 Competition。當然這不代表所有領域都這樣 —— 統計檢定預設用 Fractional、有些排行榜要的是 Dense —— 但至少在資料庫的世界,「排名」沒特別說明時通常指 Competition。