加權隨機取樣 (Weighted Random Sampling)
一個集合裡有 n
個元素,每個元素有不同的權重,現在要不放回地隨機抽取 m
個元素,每個元素被抽中的概率為元素的權重佔總權重的比例。要怎麼做呢?
簡單的解法
現在考慮只抽取一個元素,假設權重之和為 1
。我們可以從 [0, 1]
中隨機得到一個權重,假設為 0.71
,而後從第一個元素開始,不斷累加它們的權重,直到有一個元素的累加權重包含 0.71
,則選取該元素。下面是個示意圖:
要選取 m 個元素,則可以按上面的方法先選取一個,將該元素從集合中去除,再反覆按上面的方法抽取剩餘的元素。這種方法的複雜度是 O(mn)
,並且將元素從集合中刪除其實不太方便實現。
當然,最重要的是這個演算法需要多次遍歷資料,不適合用在流處理的場景中。
Algorithm A
- 對於集合 $V$ 中的元素 $v_i \in V$,選取均勻分佈的隨機數 $u_i = rand(0, 1)$ ,計算元素的特徵 $k_i = u_i^{(1/w_i)}$
- 將集合按 $k_i$ 排序,選取前 $m$ 大的元素。
演算法的正確性在作者 2006 年的論文 Weighted random sampling with a
reservoir
裡給了詳細的證明。論文中給出了演算法的兩個變種 A-Res 與 A-ExpJ,它們都能在一次掃描中得到 m
個樣本。非常適合在流處理的場合中。
A-Res 演算法
A-Res(Algorithm A With a Reservoir) 是 Algorithm 的“蓄水池”版本,即維護含有
m
- 將集合 $V$ 的前 $m$ 個元素放入結果集合 $R$。
- 對於結果集裡的每個元素,計算特徵值 $k_i = u_i^{(1/w_i)}$,其中 $u_i = rand(0, 1)$
- 對 $i = m+1, m+2, \dots, n$ 重複步驟 4 ~ 6
- 將結果集中最小的特徵 $k$ 作為當前的閾值 $T$
- 對於元素 $v_i$,計算特徵 $k_i = u_i^{(1/w_i)}$,其中 $u_i = rand(0, 1)$
- 如果 $k_i > T$ 則將 $R$ 中擁有最小 $k$ 值的元素替換成 $v_i$。
論文證明了如果權重 $w_i$ 是一般連續分佈上的隨機變數,則上面的演算法中插入 $R$ 的次數為 $O(m \log(\frac{n}{m}))$。該演算法用 Python 實現如下:
import heapq |
A-ExpJ 演算法
A-Res 需要對每個元素產生一個隨機數,而生成高質量的隨機數有可能會有較大的效能開銷,,所以論文中給出了 A-ExpJ 演算法,能將隨機數的生成量從 $O(n)$ 減少到 $O(m\log(\frac{n}{m})))$。從步驟上看,很像我們最開始提出的簡單版本,設定一個閾值並跳過一些元素。具體步驟如下:
- 將集合 $V$ 的前 $m$ 個元素放入結果集合 $R$。
- 對於結果集裡的每個元素,計算特徵值 $k_i = u_i^{(1/w_i)}$,其中 $u_i = rand(0, 1)$
- 將 $R$ 中小最的特徵值記為閾值 $T_w$
- 對剩下的元素重複步驟 5 ~ 10
- 令 $r = rand(0, 1)$ 且 $X_w = \log( r )/\log(T_w)$
- 從當前元素 $v_c$ 開始跳過元素,直到遇到元素 $v_i$,滿足
- $w_c + w_{c+1} + \dots + w_{i-1} \lt X_w \le w_c + w_{c+1} + \dots + w_{i-1} + w_{i}$
- 使用 $v_i$ 替換 $R$ 中特徵值最小的元素。
- 令 $t_w = T_w^{w_i}$, $r2 = rand(t_w, 1)$, $v_i$ 的特徵 $k_i = r_2^{(1/w_i)}$
- 令新的閾值 $T_w$ 為此時 $R$ 中的最小特徵值。
Python 實現如下:
def a_expj(samples, m): |
驗證
我們用多次取樣的方式來嘗試驗證演算法的正確性。下面程式碼1中為 a
、b
、c
等元素賦予了不同的權重,取樣 10 萬次後計算被取樣的次數與元素 a
被取樣次數的比值。
overall = [('a', 10), ('b', 20), ('c', 50), ('d', 100), ('e', 200)] |
首先驗證 A-Res 演算法:
test_weighted_sampling(a_res, 1) |
看到,在取樣一個元素時,b
被取樣到的次數約為 a
的 2
倍,而 e
則約為 20
倍,與overall
陣列中指定的權重一致。而取樣 5 個元素時,所有元素都會被選中。
同理驗證 A-ExpJ 演算法:
test_weighted_sampling(a_expj, 1) |
與 A-Res 的結果類似。
小結
文章中介紹了 A-Res 與 A-ExpJ 兩種演算法,按照步驟用 Python 實現了一個簡單的版本,最後用取樣的方式驗證了演算法的正確性。
加權隨機取樣本身不難,但如果需要在一次掃描中完成就不容易了。難以想像上面的演算法直到 2006 年才提出。演算法本身如此之簡單,也讓不感嘆數學與概率的精妙。
參考
- 1.修改自 https://blog.xingwudao.me/2017/09/26/sampling/ ↩