一.引言
Alias Sample 即别名采样应用于离散采样,假设有一个随机事件包含 N 中情况,每种情况发生的概率为 P1,P2,...Pn 且其和为1,我们希望采样得到的事件能够符合随机事件的原始概率分布,这时候就需要 Alias 采样, Alias 是一个通过空间复杂度换取时间复杂度的算法,构造采样表的复杂度为 O(n),而采样的复杂度为 O(1)。在Graph Embedding 中,本质上节点用户对不同邻居节点的权重大多是不一致的,但之前提到的 DeepWalk 对用户的权重没有考虑或者默认节点之间权重均为1,所以 Alias 采样更适合于带权边的采样,Node2vec 就是采用该方法进行采样。
二.传统采样
1.建表
首先按照 1-N 的顺序将元素概率累加构造 N 个线段:

2.随机生成数字
随机生成 0-1 的随机数 p
3.采样
看对应的概率 p 落在哪个区间中,落入 P1 + P2 + ... + PK 到 P1 + P2 + ... + PK + P(K+1) 区间内,则取 K+1 号事件作为该次采样结果
4.复杂度与概率分布
A.复杂度
构造第一个查询表需要 O(n) 的复杂度,因为需要遍历全部的概率,随机生成数字 O(1),采样需要判断 p 落在哪个区间内,常规查询 O(n),二分查找 O(logN)
B.概率分布
线段是通过 PDF 生成的 CDF,每一次随机采样落入线段内的事件 K 概率为 P(K) / 1,所以采样得到的结果理论上是服从原始概率分布的
三.Alias 采样分析
1.根据PDF构建表
Alias 采样与传统采样准备工作类似,先通过原始 PDF 构造原始表,各个矩形面积和为1
2.将原始PDF表乘n
原始面积乘n后变为 1xn=n,这里由于篇幅设计,图像只显示扩大关系,并未严格按照扩大比例展示。其次准备一个 small 和 big 数组,用于存放 *n 后概率大于1和小于1的时间索引
3.多退少补进行填充补齐
已知原始 PDF 分布表中的概率 p 均为小于1的数且和为1,乘n后面积扩大n倍,正常情况下乘n后会得到大于1的面积和小于1的面积
A. 将大于1的面积补给面积小于1的部分
B. 对应大于1的面积减去刚才补走的面积,重新划分其为大于1还是小于1
C. 如此往复,最多经过 n-1 次即可将乘n的 PDF 表格填充为 1 x N,且每个面积和均为1的表格
不正常情况是P1-Pn的概率值相等,此时退化为均匀分布,就不用 Alias 了,随机游走就可以
经过上述操作后会得到两个表,一个是 Prob (有的也称为 Accept ),另一个是 Alias
-> Prop [P1, P2, P3, ..., Pn]
注意这里 P1 和前面提到的 p1 做了大小写区分,这里 P 代表事件 I 在第 I 列矩形中占的面积大小
-> Alias [event_x, event_y, ... ,event_z ]
Alias 表中存储第 I 列矩阵中不是第 I 个事件的编号,如果 I 事件的 Prob 对应值为1,则 Alias 中存放 Null 或者事件 I 的编号
4.采样
A. 首先随机生成一个 1-N 的整数,决定在 1 x n 矩形中选择第几个矩阵,假定选择了第二行
B. 其次随机生成一个 0-1 的整数
假设生成值为 p-random,如果 p-random 小于第二列 event-2 的概率 P2,则采样 P2 对应的 event-2,反之则采样 Alias 表中对应的另一个事件的索引,上例中另一个事件为 P1 对应的 event-1
5.复杂度与概率分布
A.复杂度
构建 Prob 和 Alias 表的复杂度为 O(n),而后续采样由于不需要根据随机概率在区分度为 N 的线段中寻找,只需要2选1,所以复杂度降低至 O(1),这也是其优于传统采样的原因
B.概率分布
事件 I 对应的概率 P1 乘 n 后在整体面积 n 中所占比例为 p1 * n / n 仍为 p1,所以 Alias 采样是符合原始概率分布要求的
四.Alias 采样代码图示详解
1.根据PDF构建表
这里通过 random.random() 随机构造节点之间的转移概率,即带权边的权重,由于关注关系图中,一个节点对其邻居节点的权重和不一定为1,所以 Alias 建表时需要对权重进行归一化,这里采用分量除以总量和的方式进行归一化。
prob_num = 4
transform_prob = []
for i in range(prob_num):
transform_prob.append(random.random())
print("归一化前:", transform_prob)
# 当前node的邻居的nbr的转移概率之和
norm_const = sum(transform_prob)
# 当前node的邻居的nbr的归一化转移概率
normalized_prob = [float(u_prob) / norm_const for u_prob in transform_prob]
print("归一化后:", normalized_prob)
为了后续流程展示的清晰,这里给定一组 normalized_prob 当做测试概率:
normalized_prob = [0.5, 0.3, 0.1, 0.1]
2. 将原始PDF表乘n
本例中 n=4,为了美观这里就不把上图扩大4倍了,并初始化 big,small 存放大于1与小于1的事件的索引,其对应的判断标准为上述的 normalized_prob
length = len(transform_prob)
# 建表复杂度o(N)
accept, alias = [0] * length, [0] * length
# small,big 存放比1小和比1大的索引
small, big = [], []
# 归一化转移概率 * 转移概率数
transform_N = np.array(normalized_prob) * length
print("归一化 * N 后:", transform_N)
# 根据概率放入small large
for i, prob in enumerate(transform_N):
if prob < 1.0:
small.append(i)
else:
big.append(i)
big: [0, 1]
small: [2, 3]
3.多退少补进行填充补齐
先分析一下执行方案的可行性,只要有一个大于1就必然有一个小于1,因为其总和为 N,这个可以用反证法简单证明,这里不多赘述,所以共有 N 个乘n后的概率P的分布表,每次用一个大于1的填充一个小于1的,理论上最多需要 n-1 次即可全部填平。
# 只要有大于1和小于1的就说明还需要执行填平操作,所以 while small and big
while small and big:
small_idx, large_idx = small.pop(), big.pop()
# 事件I的原始概率
accept[small_idx] = transform_N[small_idx]
# 大于1补充的事件J的编号
alias[small_idx] = large_idx
# J事件填充后需要对J对应的概率相减
transform_N[large_idx] = transform_N[large_idx] - (1 - transform_N[small_idx])
# 重新归并到 small 或 big
if transform_N[large_idx] < 1.:
small.append(large_idx)
else:
big.append(large_idx)
可以中途添加日志打印 accept,alias 和 transform_N 的具体变化情况:
start---------------------------------
epoch: 1
small 3 big 1
accept: [0, 0, 0, 0.4]
alias [0, 0, 0, 1]
transpose: [2. 0.6 0.4 0.4]
epoch: 2
small 1 big 0
accept: [0, 0.6, 0, 0.4]
alias [0, 0, 0, 1]
transpose: [1.6 0.6 0.4 0.4]
epoch: 3
small 2 big 0
accept: [0, 0.6, 0.4, 0.4]
alias [0, 0, 0, 1]
transpose: [1. 0.6 0.4 0.4]
end--------------------------------------
原始概率:
big: [0, 1]
small: [2, 3]
accept: [0, 0, 0, 0]
alias [0, 0, 0, 0]
transpose: [2. 1.2 0.4 0.4]
A. epoch1
big=1, small=3,所以用事件1补事件3,将3的面积补充到①
accept 加入事件3的概率 P4=0.4 [0, 0, 0, 0] -> [0, 0, 0, 0.4]
alias 加入给他填充的事件1的概率 [0, 0, 0, 0] -> [0, 0, 0, 1]
结束后将事件1对应的索引 1 加入到 small 中,转移概率 [2. 0.6 0.4 0.4]
B. epoch2
big=0,small=1, 所以用事件0补事件1,将事件0的面积补充到事件1
accept 加入事件1的概率 P1=0.6 [0, 0, 0, 0.4] -> [0, 0.6, 0, 0.4]
alias 加入给他填充的事件0的概率 [0, 0, 0, 0] -> [0, 0, 0, 1]
结束后将事件0对应的索引 0 加入到 big 中 ,转移概率 [1.6 0.6 0.4 0.4]
C. epoch3
big=0,small=2,所以用事件0补事件2,将事件0的面积补充到事件2

accept 加入事件2的概率 P1=0.6 [0, 0.6, 0, 0.4] -> [0, 0.6, 0.4, 0.4]
alias 加入给他填充的事件0的概率 [0, 0, 0, 1] -> [0, 0, 0, 1]
结束后将事件0对应的索引 0 加入到 big 中,转移概率 [1. 0.6 0.4 0.4]
D.收尾
n=4,所以最多只需要 epoch=3 次即可填充完毕,但是我们看到 accept 还有一个位置的概率没有填充,所以需要收尾,将拼图的最后一块索引补充完毕。
while big:
large_idx = big.pop()
accept[large_idx] = 1
while small:
small_idx = small.pop()
accept[small_idx] = 1
最终的事件0的对应概率为 1.0,根据逻辑放入 big 中,所以 while big 的逻辑将该索引添加到对应 Accept 数组中,由于随机概率值为0-1,所以 alias 中也不需要再存储了,因为当前索引只会发生对应索引一种事件 (P=1)
Tips:
这里解释一下为什么会有 while small 这个逻辑,最后一个 epoch 填充完,上一个 big 对应索引的面积一定为1,按道理一定会到 big 中,为什么代码里都写到了 while small,一开始也是百思不得其解,后来打断点研究了一下,原来是因为万恶的 python 浮点数。
if transform_N[large_idx] < 1.0:
该情况下,如果对应 prob 为1.,python会做精度转换。 [0.88304627 0.9873218 1. 0.41422909] 如果索引为2时,上述索引会得到 0.9999999999999997 ,所以顺理成章放到了 small 中。如果想解决这个问题可以采用 np.float32 对 transform_N 进行修饰,解决对应的浮点数问题。
4.采样

def alias_sample(accept, alias):
N = len(accept)
# 生成随机索引
i = int(np.random.random() * N)
# 生成0-1随机数
r = np.random.random()
if r < accept[i]:
return i
else:
return alias[i]
根据前三步得到的 porb(accept) 与 alias 执行 Alias 采样 ,这里模拟 10000 次采样看看概率分布如何:
all_sample = {}
for i in range(10000):
sample = alias_sample(accept, alias)
if sample not in all_sample:
all_sample[sample] = 0
all_sample[sample] += 1
k = list(range(4))
v = [all_sample[key] for key in k]
import matplotlib.pyplot as plt
plt.title("PDF")
plt.bar(k, v)
plt.show()
re -> {0: 5051, 1: 2989, 3: 988, 2: 972}

# 原始转移概率
[0.5, 0.3, 0.1, 0.1]
# 采样10000次 PDF
[0.5072 0.2942 0.0985 0.1001]
可以看到 ALias 采样确实可以满足原始的概率分布采样要求。
5.全部代码
A.建表 O(n)
import numpy as np
def create_alias_table(transform_prob):
print("归一化前:", transform_prob)
# 当前node的邻居的nbr的转移概率之和
norm_const = sum(transform_prob)
# 当前node的邻居的nbr的归一化转移概率
normalized_prob = [float(u_prob) / norm_const for u_prob in transform_prob]
print("归一化后:", normalized_prob)
length = len(transform_prob)
# 建表复杂度o(N)
accept, alias = [0] * length, [0] * length
# small,big 存放比1小和比1大的索引
small, big = [], []
# 归一化转移概率 * 转移概率数
transform_N = np.array(normalized_prob) * length
print("归一化 * N 后:", transform_N)
# 根据概率放入small large
for i, prob in enumerate(transform_N):
if prob < 1.0:
small.append(i)
else:
big.append(i)
while small and big:
small_idx, large_idx = small.pop(), big.pop()
accept[small_idx] = transform_N[small_idx]
alias[small_idx] = large_idx
transform_N[large_idx] = transform_N[large_idx] - (1 - transform_N[small_idx])
if np.float32(transform_N[large_idx]) < 1.:
small.append(large_idx)
else:
big.append(large_idx)
while big:
large_idx = big.pop()
accept[large_idx] = 1
while small:
small_idx = small.pop()
accept[small_idx] = 1
return accept, alias
B.采样 O(1)
def alias_sample(accept, alias):
N = len(accept)
i = int(np.random.random() * N)
r = np.random.random()
if r < accept[i]:
return i
else:
return alias[i]
五.总结
Alias 采样大致就这么多,一开始看有点蒙圈,仔细了解后发现不仅简单且巧妙,了解了 Alias 采样,后续 GraphEmbedding 很多基于带权的关系图序列生成都会用到 Alias 采样,后续有空继续介绍~
更多推荐算法相关深度学习:深度学习导读专栏