Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Percentile, growth rate, top N: các pattern phân tích

SQL and Data Analysis cho BA Bài 36/60

Mở đầu — vì sao bài này quan trọng

Là một Business Analyst, sớm muộn gì bạn cũng sẽ bị hỏi ba câu hỏi kinh điển trong các cuộc họp: "Trung bình thì sao?", "Năm nay tăng bao nhiêu phần trăm so với năm ngoái?" và "Top 5 sản phẩm bán chạy nhất là gì?". Nghe có vẻ đơn giản, nhưng đây chính là nơi nhiều BA mắc sai lầm nghiêm trọng mà không hề hay biết.

Lấy ví dụ: một sếp hỏi "thời gian giao hàng trung bình của shop mình là bao nhiêu?". Bạn chạy AVG() ra 2.3 ngày, báo cáo tự tin. Nhưng thực tế 90% đơn giao trong 1 ngày, còn 10% đơn ở vùng sâu vùng xa mất 15 ngày — con số trung bình 2.3 ngày đã đánh lừa cả sếp lẫn bạn. Đáng lẽ bạn nên dùng percentile (median, P95) để mô tả đúng trải nghiệm khách hàng.

Bài này tập trung vào ba nhóm pattern phân tích cực kỳ phổ biến mà bất kỳ BA nào cũng cần thành thạo: percentile (median, P90, P95, P99), growth rate (tỷ lệ tăng trưởng theo thời gian), và top N (xếp hạng và lấy nhóm dẫn đầu, kể cả trong từng nhóm con). Đây là những "viên gạch" bạn sẽ ghép lại liên tục trong mọi báo cáo thực tế. Nắm vững chúng, bạn sẽ trả lời câu hỏi nghiệp vụ nhanh, đúng và không bị các con số đẹp đẽ nhưng sai bản chất dẫn dắt.

Khái niệm cốt lõi

1. Percentile — khi trung bình nói dối

Percentile (phân vị) trả lời câu hỏi: "Bao nhiêu phần trăm dữ liệu nằm dưới một ngưỡng nào đó?". Median (trung vị, P50) là giá trị mà 50% dữ liệu nằm dưới nó. P95 là giá trị mà 95% dữ liệu nằm dưới — chỉ 5% vượt quá. Percentile mạnh hơn AVG() ở chỗ nó không bị các giá trị cực đoan (outlier) kéo lệch.

Trong PostgreSQL có hai hàm chính:

-- PostgreSQL: percentile cho dữ liệu liên tục (nội suy)
SELECT
  PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) AS median,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY total) AS p95,
  PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY total) AS p99
FROM orders;

PERCENTILE_CONT nội suy giữa hai giá trị nếu cần (kết quả có thể không phải số thực có trong bảng). PERCENTILE_DISC luôn trả về một giá trị thật sự tồn tại trong dữ liệu. Với BA, PERCENTILE_CONT thường phù hợp hơn cho các chỉ số như thời gian, doanh thu.

MySQL (trước phiên bản 8.0) không có hàm percentile sẵn. Cách phổ biến là dùng window function NTILE hoặc tự tính bằng ROW_NUMBER():

-- MySQL 8.0+: xấp xỉ median bằng window function
WITH ranked AS (
  SELECT total,
         ROW_NUMBER() OVER (ORDER BY total) AS rn,
         COUNT(*) OVER () AS cnt
  FROM orders
)
SELECT AVG(total) AS median
FROM ranked
WHERE rn IN (FLOOR((cnt + 1) / 2), CEIL((cnt + 1) / 2));

Mẹo ghi nhớ: với các chỉ số "trải nghiệm" như thời gian phản hồi, thời gian giao hàng, thời gian xử lý — luôn báo cáo median và P95 thay vì chỉ AVG.

2. Growth rate — đo tốc độ tăng trưởng

Growth rate là tỷ lệ phần trăm thay đổi giữa hai kỳ. Công thức nền tảng:

Growth % = (Giá trị kỳ này − Giá trị kỳ trước) / Giá trị kỳ trước × 100

Trong SQL, "giá trị kỳ trước" thường được lấy bằng window function LAG():

-- Tăng trưởng doanh thu theo tháng (Month-over-Month)
WITH monthly AS (
  SELECT DATE_TRUNC('month', order_date) AS thang,
         SUM(total) AS doanh_thu
  FROM orders
  GROUP BY 1
)
SELECT
  thang,
  doanh_thu,
  LAG(doanh_thu) OVER (ORDER BY thang) AS thang_truoc,
  ROUND(
    (doanh_thu - LAG(doanh_thu) OVER (ORDER BY thang))
    / NULLIF(LAG(doanh_thu) OVER (ORDER BY thang), 0) * 100,
    2
  ) AS tang_truong_pct
FROM monthly
ORDER BY thang;

Lưu ý hai biến thể quan trọng mà BA hay nhầm:

  • MoM (Month-over-Month): so với tháng liền trước. Nhạy với mùa vụ.
  • YoY (Year-over-Year): so với cùng kỳ năm trước (LAG(..., 12) nếu dữ liệu theo tháng). YoY loại bỏ ảnh hưởng mùa vụ — rất quan trọng với thị trường Việt Nam có Tết, mùa sale 11/11, 12/12.
Ngoài ra còn có CAGR (Compound Annual Growth Rate — tỷ lệ tăng trưởng kép hằng năm), dùng khi muốn diễn tả tốc độ tăng trung bình qua nhiều năm:

CAGR = (Giá trị cuối / Giá trị đầu) ^ (1 / số_năm) − 1

3. Top N — xếp hạng và lấy nhóm dẫn đầu

"Top N" có hai mức độ:

  • Top N toàn cục: đơn giản dùng ORDER BY ... LIMIT N.
  • Top N trong mỗi nhóm (Top N per group): đây mới là phần khó và là nơi window function tỏa sáng. Ví dụ "top 3 sản phẩm bán chạy nhất trong mỗi danh mục".
-- Top 3 sản phẩm theo doanh thu trong từng danh mục
WITH revenue AS (
  SELECT category_id, product_id,
         SUM(amount) AS doanh_thu,
         ROW_NUMBER() OVER (
           PARTITION BY category_id
           ORDER BY SUM(amount) DESC
         ) AS hang
  FROM sales
  GROUP BY category_id, product_id
)
SELECT * FROM revenue WHERE hang <= 3;

Điểm mấu chốt: dùng ROW_NUMBER(), RANK() hay DENSE_RANK() với PARTITION BY để xếp hạng trong từng nhóm, rồi lọc ở câu ngoài. ROW_NUMBER() luôn cho thứ hạng duy nhất (kể cả khi bằng điểm); RANK() cho phép đồng hạng và nhảy số; DENSE_RANK() đồng hạng nhưng không nhảy số. Chọn đúng hàm tùy vào việc bạn có muốn giữ các trường hợp bằng điểm hay không.

Tình huống thực tế

Tình huống 1: Tiki và "lời nói dối" của thời gian giao hàng trung bình

Đội vận hành của một sàn TMĐT giả định kiểu Tiki cam kết với khách "giao nhanh 2 ngày". Báo cáo nội bộ dùng AVG(delivery_days) cho ra 1.9 ngày — nghe rất đẹp. Nhưng bộ phận chăm sóc khách hàng liên tục nhận khiếu nại về giao chậm.

BA vào cuộc, chạy lại bằng percentile:

SELECT
  PERCENTILE_CONT(0.5)  WITHIN GROUP (ORDER BY delivery_days) AS median,
  PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY delivery_days) AS p90,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY delivery_days) AS p95,
  AVG(delivery_days) AS avg_days
FROM deliveries
WHERE order_date >= '2026-05-01';

Kết quả: median = 1 ngày, AVG = 1.9 ngày, nhưng P95 = 7 ngàyP90 = 4 ngày. Hóa ra phần lớn đơn giao rất nhanh (kéo median xuống 1 ngày), nhưng 10% đơn ở các tỉnh xa hoặc gặp sự cố kho mất tới 4–7 ngày, đủ để tạo ra làn sóng khiếu nại. Trung bình 1.9 ngày che giấu hoàn toàn nhóm khách hàng đang bất mãn này.

Bài học: với chỉ số trải nghiệm khách hàng, P90/P95 cho bạn thấy "nhóm khách xấu số" mà AVG luôn giấu đi. Đội vận hành sau đó đặt SLA mới theo P95 thay vì theo trung bình.

Tình huống 2: Một chuỗi F&B đo tăng trưởng MoM và bị Tết đánh lừa

Một chuỗi trà sữa giả định tên "Trà Mây" có 40 cửa hàng tại TP.HCM và Hà Nội. Đầu tháng 3/2026, BA báo cáo doanh thu tháng 2 giảm 18% so với tháng 1 (MoM). Ban giám đốc hoảng hốt, định cắt ngân sách marketing.

May mắn là BA đủ tỉnh táo để bổ sung góc nhìn YoY:

WITH monthly AS (
  SELECT DATE_TRUNC('month', order_date) AS thang,
         SUM(total) AS doanh_thu
  FROM orders
  GROUP BY 1
)
SELECT
  thang,
  doanh_thu,
  ROUND((doanh_thu - LAG(doanh_thu, 1) OVER (ORDER BY thang))
        / NULLIF(LAG(doanh_thu, 1) OVER (ORDER BY thang), 0) * 100, 1) AS mom_pct,
  ROUND((doanh_thu - LAG(doanh_thu, 12) OVER (ORDER BY thang))
        / NULLIF(LAG(doanh_thu, 12) OVER (ORDER BY thang), 0) * 100, 1) AS yoy_pct
FROM monthly
ORDER BY thang;

Kết quả cho thấy MoM tháng 2 giảm 18%, nhưng YoY lại tăng 22%. Lý do: tháng 2/2026 trùng Tết Nguyên đán, nhiều cửa hàng đóng cửa hoặc giảm giờ, nên doanh thu thấp hơn tháng 1 là chuyện bình thường năm nào cũng vậy. So với tháng 2/2025 (cũng có Tết), chuỗi thực ra đang tăng trưởng khỏe.

Bài học: ở thị trường Việt Nam, MoM một mình rất dễ gây hiểu lầm vì mùa vụ (Tết, mùa sale cuối năm, mùa tựu trường). Luôn cặp MoM với YoY trước khi đưa ra kết luận, và đừng để một con số âm khiến cả công ty phản ứng thái quá.

Tình huống 3: Grab/Be và bài toán Top tài xế theo từng khu vực

Một nền tảng gọi xe giả định kiểu Be muốn thưởng cho 5 tài xế xuất sắc nhất ở mỗi quận tại TP.HCM, dựa trên số chuyến hoàn thành trong tháng. Nếu chỉ làm ORDER BY trips DESC LIMIT 5, bạn sẽ chỉ lấy được 5 tài xế top toàn thành phố — toàn quận trung tâm, các quận xa bị bỏ rơi.

Giải pháp đúng là Top N per group:

WITH driver_trips AS (
  SELECT district, driver_id,
         COUNT(*) AS so_chuyen,
         DENSE_RANK() OVER (
           PARTITION BY district
           ORDER BY COUNT(*) DESC
         ) AS hang
  FROM trips
  WHERE trip_date >= '2026-06-01'
    AND status = 'completed'
  GROUP BY district, driver_id
)
SELECT district, driver_id, so_chuyen, hang
FROM driver_trips
WHERE hang <= 5
ORDER BY district, hang;

Ở đây BA cố tình chọn DENSE_RANK() thay vì ROW_NUMBER(): nếu có hai tài xế cùng 180 chuyến ở vị trí thứ 5, công ty muốn thưởng cả hai chứ không cắt ngẫu nhiên một người. Quyết định nhỏ này tránh được khiếu nại về tính công bằng.

Bài học: "Top N" hầu như luôn là "Top N per group" trong thực tế nghiệp vụ. Và việc chọn ROW_NUMBER vs RANK vs DENSE_RANK không phải chuyện kỹ thuật khô khan — nó phản ánh một quyết định nghiệp vụ về cách xử lý trường hợp đồng hạng.

Hướng dẫn từng bước

Khi gặp một yêu cầu phân tích, hãy đi theo quy trình sau để chọn đúng pattern:

  • Xác định bản chất câu hỏi. Câu hỏi về "phân bố / điển hình / cực đoan" → percentile. Câu hỏi về "tăng/giảm theo thời gian" → growth rate. Câu hỏi về "ai dẫn đầu / xếp hạng" → top N.
  • Với percentile: quyết định mức phân vị cần báo cáo (P50 cho giá trị điển hình, P95/P99 cho phần đuôi xấu). Dùng PERCENTILE_CONT trên PostgreSQL; nếu dùng MySQL cũ thì dựng bằng ROW_NUMBER()COUNT() OVER(). Luôn cân nhắc báo cáo kèm AVG để đối chiếu — nếu AVG lệch xa median, dữ liệu có outlier.
  • Với growth rate: trước tiên gom dữ liệu về đúng độ chi tiết thời gian bằng một CTE (DATE_TRUNC theo ngày/tuần/tháng). Sau đó dùng LAG() để lấy kỳ trước. Bọc mẫu số trong NULLIF(..., 0) để tránh chia cho 0. Quyết định MoM hay YoY tùy vào mùa vụ.
  • Với top N per group: gom và tính chỉ số xếp hạng trong CTE, gắn ROW_NUMBER()/RANK()/DENSE_RANK() với PARTITION BY <nhóm>. Ở câu ngoài, lọc WHERE hang <= N. Đừng cố nhét điều kiện lọc vào cùng câu chứa window function — SQL không cho phép dùng alias của window function trong WHERE của chính câu đó.
  • Kiểm tra lại bằng mắt thường. Sắp xếp kết quả, nhìn vài dòng đầu và cuối. Một growth rate +800% thường là dấu hiệu mẫu số quá nhỏ (kỳ trước gần như bằng 0), không phải tăng trưởng thật.

Lỗi thường gặp & mẹo

  • Lạm dụng AVG cho mọi thứ. Doanh thu/khách, thời gian xử lý, giá trị đơn hàng đều thường lệch phải (right-skewed). Median mô tả "khách hàng điển hình" trung thực hơn AVG.
  • Dùng alias window function trong WHERE cùng cấp. Câu SELECT ..., ROW_NUMBER() OVER(...) AS hang ... WHERE hang <= 3 sẽ báo lỗi. Bắt buộc bọc trong CTE hoặc subquery rồi lọc ở ngoài.
  • Chia cho 0 trong growth rate. Khi kỳ trước = 0, biểu thức chia sẽ lỗi hoặc trả NULL khó hiểu. Luôn dùng NULLIF(mau_so, 0). Và cẩn thận: tăng trưởng từ một nền cực nhỏ tạo ra phần trăm khổng lồ vô nghĩa — nên kèm theo cả giá trị tuyệt đối.
  • LAG() không có ORDER BY đúng. Window function LAG phụ thuộc hoàn toàn vào ORDER BY bên trong OVER(). Quên sắp xếp theo thời gian thì "kỳ trước" sẽ là một dòng ngẫu nhiên.
  • Nhầm RANKROW_NUMBER. Khi có đồng hạng, ROW_NUMBER chọn tùy tiện một dòng, dễ bỏ sót dữ liệu mà bạn không ngờ. Hỏi rõ nghiệp vụ: "khi bằng điểm thì xử lý sao?" trước khi chọn hàm.
  • Quên lỗ hổng dữ liệu thời gian. Nếu một tháng không có đơn hàng nào, dòng đó biến mất khỏi kết quả GROUP BY, khiến LAG() so sai kỳ. Với chuỗi thời gian quan trọng, cân nhắc tạo một bảng lịch (calendar table) đầy đủ rồi LEFT JOIN vào.
  • Mẹo trình bày: khi báo cáo growth rate cho lãnh đạo, luôn đính kèm cả con số gốc (raw) lẫn phần trăm. "+22% YoY (từ 4,1 tỷ lên 5,0 tỷ)" thuyết phục và minh bạch hơn nhiều so với chỉ "+22%".

Bài tập thực hành

Giả sử bạn có bảng orders(order_id, customer_id, store_id, district, order_date, total).

  • Percentile: Tính median, P90 và P95 của giá trị đơn hàng (total) trong quý 2/2026. So sánh với AVG(total) và viết một câu nhận xét: dữ liệu có bị lệch không, và điều đó nói gì về hành vi mua hàng?
  • Growth rate MoM: Viết query tính doanh thu theo tháng và tỷ lệ tăng trưởng MoM trong 12 tháng gần nhất. Dùng LAG() và nhớ bọc NULLIF. Tháng nào tăng trưởng âm? Hãy đoán lý do nghiệp vụ.
  • Growth rate YoY: Mở rộng query trên để thêm cột YoY (LAG(..., 12)). So sánh MoM và YoY của tháng có chứa Tết — bạn rút ra điều gì?
  • Top N per group: Tìm top 3 cửa hàng có doanh thu cao nhất trong mỗi quận (district) của tháng 6/2026. Dùng RANK() để giữ lại các cửa hàng đồng hạng. Sau đó thử đổi sang ROW_NUMBER() và quan sát kết quả khác nhau ở chỗ nào.
  • Kết hợp: Với mỗi quận, tìm cửa hàng đứng đầu (top 1) và tính tỷ lệ tăng trưởng doanh thu MoM của riêng cửa hàng đó. (Gợi ý: cần hai CTE — một để xếp hạng, một để tính growth.)

Tóm tắt

Ba pattern trong bài này là bộ công cụ phân tích "ăn liền" mà BA dùng hằng ngày. Percentile (median, P95) cứu bạn khỏi cái bẫy của số trung bình, đặc biệt với các chỉ số trải nghiệm bị outlier kéo lệch — hãy dùng PERCENTILE_CONT trên PostgreSQL. Growth rate đo tốc độ thay đổi qua thời gian bằng LAG(); luôn cặp MoM với YoY để không bị mùa vụ (nhất là Tết) đánh lừa, và nhớ NULLIF để tránh chia cho 0. Top N per group dựa vào ROW_NUMBER/RANK/DENSE_RANK với PARTITION BY, và việc chọn hàm nào là một quyết định nghiệp vụ về cách xử lý đồng hạng, không chỉ là kỹ thuật.

Điều quan trọng nhất không phải là cú pháp, mà là phản xạ hỏi đúng: "Con số này có đang giấu điều gì không?". Một BA giỏi không chỉ chạy được query — họ chọn đúng pattern để con số kể đúng câu chuyện. Hãy luyện ba pattern này đến mức thành bản năng, vì bạn sẽ ghép chúng lại trong gần như mọi báo cáo phía sau của khóa học.