Reservoir sampling — mẫu ngẫu nhiên từ stream
Lấy k mẫu đều xác suất từ stream độ dài chưa biết, chỉ O(k) bộ nhớ. Chứng minh xác suất đồng đều và ứng dụng log sampling.
TL;DR: Bài toán: chọn k phần tử ngẫu nhiên từ stream độ dài n chưa biết trước, chỉ dùng O(k) bộ nhớ. Algorithm R (Vitter 1985): giữ k phần tử đầu làm "reservoir"; với phần tử thứ i (i > k), nhận vào reservoir với xác suất k/i, thay thế ngẫu nhiên 1 trong k slot. Cuối stream, mỗi phần tử đều có xác suất đúng k/n nằm trong reservoir — đều xác suất (uniform random). Ứng dụng rộng: log sampling, A/B test routing, random page từ database cursor, distributed sampling không cần biết tổng size.
Hệ thống monitoring của bạn nhận 10 triệu event mỗi giờ. Bạn cần lấy 1000 event đại diện để phân tích — nhưng không thể lưu tất cả. Giải pháp đơn giản: lấy 1000 event đầu tiên? Sai — sẽ thiên vị về buổi sáng, bỏ qua spike buổi chiều. Lấy mỗi event thứ 10.000? Sai — bỏ qua burst ngắn giữa các checkpoint. Bạn cần mẫu đều xác suất (uniform random sample): mỗi event có cùng xác suất được chọn, bất kể vị trí nào trong stream.
Reservoir sampling giải quyết bài toán này với O(k) bộ nhớ mà không cần biết n — ngay cả khi stream chưa kết thúc, sample hiện tại luôn là uniform random sample của những gì đã thấy.
1. Phát biểu bài toán chính xác
Input: stream vô tận (hoặc độ dài n chưa biết) các phần tử x_1, x_2, ..., x_n.
Output: tập k phần tử sao cho mỗi phần tử trong stream có xác suất đúng bằng k/n nằm trong kết quả cuối.
Ràng buộc: bộ nhớ làm việc O(k) — không được lưu cả stream.
Nếu biết n trước, có thể dùng Fisher-Yates shuffle rồi lấy k phần tử đầu — O(n) thời gian, O(n) bộ nhớ. Reservoir sampling phá bỏ ràng buộc bộ nhớ O(n) mà vẫn đảm bảo tính chất xác suất đồng đều.
2. Algorithm R — Vitter 1985
function reservoirSample(stream, k):
reservoir <- []
-- Bước 1: điền k phần tử đầu vào reservoir
for i từ 0 đến k-1:
reservoir[i] <- stream[i]
-- Bước 2: xử lý phần còn lại
for i từ k đến n-1: -- i là chỉ số 0-based
j <- randomInt(0, i) -- j ngẫu nhiên trong [0..i] đều
if j < k: -- xác suất k/(i+1) ~ k/i khi i lớn
reservoir[j] <- stream[i] -- thay thế slot j
return reservoir
// Time: O(n) Space: O(k)
Lưu ý: randomInt(0, i) sinh số nguyên đều trong đoạn [0, i] — tổng i+1 giá trị. Xác suất phần tử thứ i (0-based, i >= k) được chọn = k / (i+1).
2.1 Trace với k=2, n=5
Stream: [A, B, C, D, E]
| Bước | i | Hành động | Reservoir |
|---|---|---|---|
| Khởi tạo | 0,1 | điền A, B | [A, B] |
| i=2 (C) | 2 | j = rand(0,2) ∈ 2; nếu j nhỏ hơn 2 → thay slot j bằng C | [A,B] hoặc [C,B] hoặc [A,C] |
| i=3 (D) | 3 | j = rand(0,3) ∈ 3; xác suất 2/4=1/2 → thay | — |
| i=4 (E) | 4 | j = rand(0,4) ∈ 4; xác suất 2/5 → thay | — |
Sau i=4, mỗi phần tử trong tập [A,B,C,D,E] có xác suất 2/5 = k/n nằm trong reservoir.
flowchart LR
S(["Stream x_1..x_n\n(n chưa biết)"])
FILL["Điền k phần tử đầu\nvào reservoir"]
PROC{"i >= k?\n(còn phần tử)"}
RAND["j = rand(0, i)\nj < k?"]
SWAP["reservoir[j] <- x_i\n(thay thế)"]
SKIP["Bỏ qua x_i"]
NEXT["i++"]
OUT(["Reservoir k phần tử\n(uniform sample)"])
S --> FILL --> PROC
PROC -->|"còn"| RAND
RAND -->|"j < k"| SWAP --> NEXT --> PROC
RAND -->|"j >= k"| SKIP --> NEXT --> PROC
PROC -->|"hết"| OUT3. Chứng minh xác suất đồng đều
Mệnh đề: sau khi xử lý n phần tử, mỗi phần tử x_i (1 <= i <= n) có xác suất đúng k/n nằm trong reservoir.
Chứng minh bằng quy nạp trên n.
Cơ sở: n = k. Cả k phần tử đều trong reservoir → xác suất k/k = 1. Đúng.
Bước quy nạp: Giả sử sau n-1 phần tử, mọi phần tử x_i (i ≤ n-1) có xác suất k/(n-1) trong reservoir. Ta cần chứng minh sau khi xử lý x_n, mọi phần tử (kể cả x_n) có xác suất k/n.
Xác suất x_n được chọn:
P(x_n vào reservoir) = k/n
Vì j = randomInt(0, n-1) trong [0, n-1] (n giá trị), xác suất j < k = k/n.
Xác suất phần tử cũ x_i (i < n) vẫn còn trong reservoir:
P(x_i còn lại) = P(x_i trong reservoir sau n-1 bước) × P(x_i không bị thay khi xử lý x_n)
= k/(n-1) × (1 - P(x_n chọn đúng slot của x_i))
= k/(n-1) × (1 - (k/n) × (1/k))
= k/(n-1) × (1 - 1/n)
= k/(n-1) × (n-1)/n
= k/n
Dòng thứ ba: khi x_n được chọn (xác suất k/n), slot bị thay là 1 trong k slot đều nhau → xác suất chọn đúng slot x_i là 1/k.
Vậy mọi phần tử có xác suất đúng k/n. QED.
Thử nghiệm Monte Carlo xác nhận với n=1000. Nhưng production có n=10 tỷ và k=1000 — thử nghiệm exhaustive không thực tế. Chứng minh toán học đảm bảo tính đúng cho mọi n và k, đặc biệt với stream vô hạn — đây là điểm mạnh của reservoir sampling.
4. Weighted reservoir sampling
Algorithm R giả định mọi phần tử có trọng số đều nhau. Thực tế, log của transaction $100 quan trọng hơn log click thông thường. Weighted reservoir sampling (Efraimidis & Spirakis 2006) gán key u^(1/w_i) cho mỗi phần tử (u là số ngẫu nhiên uniform (0,1], w_i là trọng số), rồi giữ k phần tử có key lớn nhất.
function weightedReservoirSample(stream, k):
H <- MaxHeap() -- heap theo key
for each phần tử (x_i, weight_i) trong stream:
key_i <- random(0, 1) ^ (1.0 / weight_i) -- ^ là lũy thừa
if H.size() < k:
H.push((key_i, x_i))
else if key_i > H.min(): -- min-heap của key để pop nhỏ nhất
H.pop()
H.push((key_i, x_i))
return H.elements()
// Time: O(n log k) Space: O(k)
Xác suất phần tử i được chọn tỉ lệ với weight_i — chứng minh xem Efraimidis & Spirakis (2006).
5. Ứng dụng thực tế
Log sampling hệ thống: Nginx/Envoy giữ reservoir 10.000 request để phân tích latency. Mỗi request đến: quyết định keep/drop theo algorithm R. Kết quả: sample uniform theo thời gian, không bias burst hay idle.
A/B test routing: Khi cần chọn ngẫu nhiên 5% user vào group B từ user stream, reservoir sampling đảm bảo 5% đều xác suất không phụ thuộc tổng số user.
Random sample từ database cursor: Thay vì SELECT * FROM logs ORDER BY RANDOM() LIMIT 1000 (scan toàn bảng + sort — O(n log n)), dùng reservoir sampling qua cursor: O(n) scan, O(k) memory, không cần sort.
Distributed sampling: Mỗi node giữ reservoir k phần tử của mình. Để merge m reservoir thành reservoir k phần tử tổng: dùng weighted reservoir với weight tỉ lệ kích thước stream của mỗi node — đảm bảo tính đồng đều trên toàn distributed stream.
6. Pitfall
Pitfall 1 — Dùng random() < k/n thay vì randomInt(0, i)
-- SAI: nếu không biết n, tính k/n impossible
-- Nếu biết n thì cũng sai vì xác suất mỗi phần tử không đồng đều theo vị trí
-- ĐÚNG: randomInt(0, i) — i là chỉ số hiện tại, tăng dần
-- Xác suất k/(i+1) tự động điều chỉnh theo vị trí trong stream
Pitfall 2 — Chọn slot thay thế không đều
-- SAI: luôn thay slot cuối cùng được thêm vào
reservoir[lastInserted] <- stream[i] -- bias: slot cuối bị thay nhiều nhất
-- ĐÚNG: chọn slot ngẫu nhiên đều trong [0, k-1]
j <- randomInt(0, i)
if j < k:
reservoir[j] <- stream[i] -- j đều trong k slot
Pitfall 3 — Quên trường hợp stream ngắn hơn k
-- SAI: code giả định stream luôn có >= k phần tử
-- → index out of bounds hoặc trả về reservoir chưa đầy
-- ĐÚNG: trả về min(k, n) phần tử
function reservoirSample(stream, k):
...
return reservoir[0 .. min(k, đã_đọc) - 1]
7. Liên hệ các bài khác
- Hash function: weighted reservoir sampling dùng random key
u^(1/w)— ý tưởng tương tự consistent hashing: mapping ngẫu nhiên có cấu trúc. Hash function tốt cũng là nền của nhiều probabilistic algorithm trong module này. - Amortized analysis: chi phí amortized của mỗi phần tử trong stream là O(1) (chỉ gọi randomInt + có thể 1 swap) — tổng O(n) thời gian, mặc dù phần tử đầu (điền reservoir) và phần tử sau (test + thay) có chi phí khác nhau.
- HyperLogLog: cùng lớp thuật toán streaming probabilistic — reservoir lưu mẫu đại diện (exact k phần tử), HyperLogLog ước lượng cardinality. Hiểu cả hai cho thấy tradeoff: exact vs approximate, sample vs sketch.
- Count-Min Sketch: probabilistic data structure khác cho streaming — đếm tần suất xấp xỉ thay vì lấy mẫu. Cross-reference để thấy cả hệ sinh thái "big data structures".
- Mini-challenge: Top-K: reservoir sampling là công cụ cho bài top-k — sampling trước rồi tìm top-k trên sample, khi n quá lớn để exact top-k.
📚 Deep Dive
Bài báo gốc:
- Vitter, J.S. (1985), "Random sampling with a reservoir", ACM TOMS 11(1). Giới thiệu Algorithm R + Algorithm Z (phiên bản nhanh hơn skip các phần tử không được chọn, O(k(1 + log(n/k))) thay vì O(n)).
- Efraimidis & Spirakis (2006), "Weighted random sampling with a reservoir" — weighted variant dùng key
u^(1/w).
Algorithm Z (optimization quan trọng): Thay vì kiểm tra từng phần tử, Algorithm Z tính "bước nhảy" — bao nhiêu phần tử tiếp theo sẽ bị bỏ qua trước khi phần tử kế được chọn. Sinh bước nhảy từ distribution hình học, skip O(1) amortized. Quan trọng khi n rất lớn (terabyte log) và k nhỏ.
Triển khai thực tế:
- PostgreSQL
TABLESAMPLE BERNOULLIvàSYSTEM— dạng sampling có liên quan nhưng không phải reservoir. - Apache Spark
sampleByKey()— reservoir sampling phân tán. - Redis
SRANDMEMBERtrên large set — variant algorithm R.
Tóm tắt
- Reservoir sampling giải bài toán uniform random sample từ stream độ dài chưa biết, O(k) bộ nhớ.
- Algorithm R: giữ k phần tử đầu; với phần tử ở chỉ số
i(0-based,i >= k— khớp pseudocode mục 2), nhận vào reservoir với xác suấtk/(i+1)bằng cách sinhj = randomInt(0, i)và thay slotjnếuj < k. (Phần chứng minh dùng quy ước 1-based nên ghik/ncho phần tử cuối.) - Chứng minh bằng quy nạp: sau
nphần tử, mọi phần tử có xác suất đúngk/n— không bias theo vị trí. - Weighted variant: key
u^(1/w), giữ k phần tử key lớn nhất — phần tử trọng số cao hơn có xác suất chọn cao hơn tỉ lệ. - Ứng dụng: log sampling, A/B routing, database cursor sample, distributed sampling merge.
- Pitfall chính: dùng xác suất cố định
k/n(không biết n trước), chọn slot thay thế không đều, bỏ sót stream ngắn hơn k.
Tự kiểm tra
Q1Vì sao không thể dùng 'lấy mỗi phần tử với xác suất k/n cố định' thay vì Algorithm R? Điều gì xảy ra nếu stream chưa kết thúc?▸
Hai vấn đề: (1) Không biết n trước — stream đang chảy, n chưa xác định, không thể tính k/n. (2) Số mẫu không cố định — xác suất cố định cho mỗi phần tử chỉ đảm bảo số mẫu kỳ vọng là k, nhưng thực tế có thể ra ít hơn hoặc nhiều hơn k (phân phối nhị thức). Reservoir sampling đảm bảo đúng k mẫu mọi lúc.
Algorithm R luôn giữ đúng k phần tử trong reservoir — tại mọi thời điểm sau khi thấy i phần tử (i ≥ k), reservoir là uniform sample của i phần tử đó. Tính chất này cho phép "snapshot" bất kỳ lúc nào mà không cần biết n.
Q2Trong chứng minh quy nạp, xác suất 'x_n thay đúng slot của x_i' là (k/n) × (1/k) = 1/n. Vì sao phải nhân thêm 1/k chứ không chỉ là k/n?▸
Sự kiện "slot của x_i bị thay" là hợp của hai sự kiện độc lập: (1) x_n được chọn vào reservoir — xác suất k/n; (2) trong số k slot, đúng slot của x_i được chọn để thay — xác suất 1/k (đều).
Nhân lại: P(slot x_i bị thay) = (k/n) × (1/k) = 1/n. Điều này có nghĩa: nếu không có bước quy nạp giải thích từng nhân tử, ta có thể nhầm "xác suất bị thay = xác suất x_n được chọn = k/n" — sai vì bỏ qua bước chọn slot trong k.
Q3Bạn cần merge 3 reservoir (từ 3 server), mỗi reservoir có k=100 phần tử, stream tương ứng có n1=1000, n2=2000, n3=3000. Làm thế nào để thu được 1 reservoir k=100 đại diện đều cho toàn bộ n=6000 phần tử?▸
Không thể đơn giản random chọn 100 từ 300 phần tử trong 3 reservoir — sẽ thiên vị về server có stream nhỏ hơn (mỗi phần tử của server 1 được đại diện bởi 100/1000=10% stream của nó, trong khi phần tử server 3 chỉ 100/3000=3.3%).
Cách đúng: weighted reservoir sampling với trọng số tỉ lệ n_i — hoặc tương đương, coi 3 reservoir như stream nhỏ với mỗi phần tử của server i có trọng số n_i / k. Điều này đảm bảo xác suất chọn một phần tử gốc từ server i là n_i / (n1+n2+n3) — tỉ lệ đúng với đóng góp của server đó vào tổng stream.
Q4Algorithm Z tối ưu Algorithm R bằng cách 'skip' phần tử không được chọn. Khi nào nên ưu tiên Algorithm Z hơn Algorithm R?▸
Algorithm R kiểm tra từng phần tử một — O(n) bước dù k nhỏ. Với stream 10 tỷ phần tử và k=1000, Algorithm R thực hiện 10 tỷ lần randomInt — tốn kém.
Algorithm Z tính "bước nhảy" — bao nhiêu phần tử liên tiếp sẽ bị bỏ qua (distribution hình học với tham số k/i). Tổng số bước nhảy O(k log(n/k)) thay vì O(n). Với ví dụ trên: ~10.000 bước thay vì 10 tỷ.
Dùng Algorithm Z khi: stream rất dài (n >> k), chi phí "randomInt" đáng kể (streaming qua network/disk), hoặc cần throughput cao (Spark, Kafka consumer). Dùng Algorithm R khi: n nhỏ, cần code đơn giản, hay k gần n.
Q5Tại sao 'SELECT * FROM events ORDER BY RANDOM() LIMIT 1000' trong PostgreSQL kém hơn reservoir sampling để lấy 1000 sample từ bảng 10 triệu row?▸
Câu SQL đó yêu cầu: (1) scan toàn bảng 10 triệu row — O(n) I/O; (2) gắn số random vào mỗi row — O(n) CPU; (3) sort theo random number — O(n log n) CPU + temp disk nếu không vừa work_mem; (4) lấy 1000 row đầu.
Reservoir sampling qua cursor: scan một lần O(n), tại mỗi row quyết định keep/drop O(1), kết quả k=1000 row. Không sort, không ghi disk, bộ nhớ O(k).
Postgres có TABLESAMPLE SYSTEM(p) lấy khoảng p% row nhanh hơn (block-level sampling), nhưng không đảm bảo uniform across rows. Reservoir sampling qua application cursor là cách duy nhất đảm bảo uniform row-level sample với O(n) I/O và O(k) memory.
Q6Hệ thống A/B test cần đảm bảo cùng một user luôn vào cùng một group (sticky assignment). Reservoir sampling thuần túy có đảm bảo tính chất này không? Cần thêm gì?▸
Reservoir sampling thuần túy không đảm bảo sticky assignment — mỗi lần user xuất hiện trong stream, thuật toán quyết định ngẫu nhiên có thay thế hay không. Cùng user có thể lần trước trong group B, lần sau không còn trong reservoir.
Để sticky: dùng hash-based assignment — hash(userId + experimentId) % 100 < percentage. Deterministic, reproducible, không đòi bộ nhớ per-user. Reservoir sampling phù hợp cho "lấy mẫu log để phân tích" (không cần sticky) chứ không phải cho routing request đến variant (cần sticky).
Phân biệt rõ: reservoir sampling = uniform sample cho analysis/monitoring; hash-based = deterministic routing cho A/B test cần consistency.
Bài tiếp theo: HyperLogLog — đếm distinct xấp xỉ
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên