跳至主要內容
技術

競爭排名 vs 密集排名 vs 百分位:地圖標籤的 ranking 設計

競爭排名 vs 密集排名 vs 百分位:地圖標籤的 ranking 設計
打造 TaxMap-TW:用 Astro 做台灣所得地圖 第 6 / 10 篇

本篇是「打造 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
StandardROW_NUMBER()
CompetitionRANK()
Modified Competition(無原生支援)
DenseDENSE_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。

系列其他文章

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →