从 2 年前的 LightGCN 代码学习到的一些知识点。

LightGCN 是 何向南 团队在 SIGIR 2020 上发表的一篇论文,目前看还是有一定影响力的。 之前简单看了 paddlepaddle pgl 的实现,看起来很简单; 最近想看看从头写一个 GCN 是怎么写的, 于是看了下官方开源的代码: LightGCN-PyTorch,从中发现了一些之前不太关注的点。 这里记录下来,防止遗忘。

1. dropout 里的随机mask是如何实现的 ?

作为一个调包侠,平时我们都是直接使用底层库的 x.nn.dropout 类的函数, 最多知道 dropout 的做法:

  1. 训练时开启,随机 mask 掉 drop_prob 的连接,并把剩余节点的值放大 1 / (1 - drop_prob) 倍来 保证结果经过 dropout 后期望不变(或者说量纲不变?)。
  2. 测试时,dropout 不激活

但因为 LightGCN 使用的是稀疏张量,大概是不能直接用针对稠密张量的 dropout, 因此需要手工实现一版。 实现如下:

def __dropout_x(self, x, keep_prob):
    size = x.size()
    # indices 默认是 2 x N 的,第一行是横坐标列表,第二行是纵坐标列表; 
    # 转置一下,就成了 Nx2, 每个元素就是一对坐标
    index = x.indices().t()
    values = x.values()
    # 秀啊!
    # rand是均一分布; 
    # v  + keep_prob , 然后转 int,再转bool; 
    #    - 在 1 - keep_prob 下的元素,int后就是0;
    #    - 而 1-keep_prob 就是1;
    # 而 >= 1-keep_prob 的概率就是  keep_prob
    # 简直黑人问号??
    # 不过,是不是比choice / shuffle 等高效呢? 可能没那么明显?
    random_index = torch.rand(len(values)) + keep_prob
    random_index = random_index.int().bool()
    # 
    index = index[random_index]
    values = values[random_index]/keep_prob
    g = torch.sparse.FloatTensor(index.t(), values, size)
    return g

这个函数特别的点,在注释中已经说明。我们重点关注其中对节点的mask:先 rand 一个 [0, 1] 的概率向量,然后通过 +keep_prob 和 int 截断,达成了按指定概率生成 mask 向量的目标。

如注释所言,这个做法其实理解有点绕。我又看了下几个库的实现:

  1. TF nn.dropout

    nn_ops.dropout, 关键逻辑:

    # Sample a uniform distribution on [0.0, 1.0) and select values larger
    # than or equal to `rate`.
    random_tensor = uniform_sampler(shape=noise_shape, dtype=x_dtype)
    keep_mask = random_tensor >= rate
    

    可以看到,这个逻辑其实和 LightGCN 是一致的,但显然这个实现直接啊……

  2. PyTorch dropout

    torch 找最底层实现还有点难,在仓库里找了下,不确定这个 caff2/op/dropout.cc 是不是CPU上的实现:

     for (int i = 0; i < X.numel(); ++i) {
         mask_data[i] = dist(gen) > 0.5;
         // NOLINTNEXTLINE(cppcoreguidelines-narrowing-conversions,bugprone-narrowing-conversions)
         Ydata[i] = Xdata[i] * scale * mask_data[i];
     }
    

    这才是最基本的实现啊,符合直觉…… 看还有 CUDA 的op,但是直接调的是 CuDNN的实现,这里就不先不深究了……

由上,看出来 LightGCN 的 dropout 里的随机mask,其实不是那么直观的。

附:按我自己之前的想法,我会怎么实现这个随机 mask 呢?
如前面注释所言,我想的其实是直接用 choice 取 1 - drop_prob 比例个元素; 或者 shuffle 全部全素后再取Top 1 - drop_prob个。 这个更符合『随机选择』的直觉,但显然不符合NN里面一贯的 mask 的思想。
最后,还额外想到了如何来实现shffle? 之前在 word2vec 里看到过,有点忘了。网上search一下,就是它,如此巧妙: 名为 Knuth-Shuffle 或者 Fisher-Yates shuffle.

2. BPR loss 怎么用 softplus 实现?

LightGCN 使用的 loss 是 BPR loss. BPR loss 的公式为

\[\operatorname{bpr}(pos, neg) = -\ln \operatorname{sigmoid}(pos - neg)\]

而在 LightGCN 中, BPR loss 的计算为

\[\operatorname{bpr}(pos, neg) = \operatorname{softplus}(neg - pos)\]

这是为何?

原理其实很简单——二者是等价的:

\[\begin{align} \operatorname{softplus}(x) &= \ln(1 + \exp(x)) \\ \operatorname{sigmoid}(x) &= \frac{1} {1 + \exp(-x)} \\ \operatorname{softplus}(x) &= - \ln \frac{1}{1 + \exp(x)} \\ &= -\ln \operatorname{sigmoid}(-x) \end{align}\]

这个可以作为结论记住:用 softplus 来实现 BRP loss.