随机、优先与权重——非平均概率的选择工具

文章目录

  • 随机、优先与权重
    • 动机
      • 非平均概率
      • 随机数的使用
    • 实现
      • 随机算法和选择算法分离
      • Poker
      • Croupier
        • 随机索引
        • 随机选中一个元素
        • 随机抽取一个元素
        • 从只读列表中随机选取多个元素
        • 从可变列表中随机抽取一个元素
        • 从可变列表中随机抽取 n 个元素
        • 随机选择 n 个元素
        • 回顾
      • 非平均概率算法
        • 按权重选择
        • 按 Rank 选择
      • 概率递降选择
      • 概率递增选择
    • 工具函数

随机、优先与权重

动机

除了汇编语言这样的另类,常规的编程语言几乎都提供了按平均概率生成整数或者浮点数的标准库。这也是应用开发中非常基本的功能。不过,有时候我们需要一些关于随机性的更复杂的功能。
这种复杂性主要来自两个方面:非平均的随机分布和随机结果的使用方式。

非平均概率

标准库的随机算法,通常都是以一个平均概率的,生成(0,1)之间浮点数的函数,或者以一个生成[0, MAX_INT)区间的整数值的方法为基础,构造相关的算法(Java标准库的Random类型,nextInt 和 nextDouble是分别实现的,互不依赖),这就使得相关的随机数生成总是在已知区间内的平均概率。有时候我们会希望以非平均概率生成随机数。例如我手上有一个推荐系统,在我们从数据集中抽取推荐内容发送到客户端时,并不希望每个内容都有平均的被选择机会,肯定是希望权重更高,更靠前的内容,有更大的几率被选中。
一种常见的方法是计算出每个内容的权重——weight、rank、score或者其它什么名字,整数或者浮点数——然后根据这个权重去选择内容。但其实严格的按权重计算概率是一个相对比较奢侈的做法,很多时候我们未必需要一个严格按照权重分布的随机数,而是仅仅要求一个有序列表中的元素,排在更前面的元素总是有更大的几率被选中——这正是推荐系统常见的需求。

随机数的使用

很奇怪,C++ 的 STL 都提供了 random_shuffle 这样的针对集合的随机算法,但是Java没有,不但Java的标准库没有,scala的标准库,甚至 Apache Commons,都没有从一个容器中随机选择若干元素的方法,Apache Commons 的 RandomUtils 中包含 nextBoolean、nextBytes这样的随机内容构造,但是就是没有从一个有限集合中做随机选择的支持。但是对我的日常工作来说,随机数一个非常重要的用途,就是利用随机行为,生成一个数据列表的子集。有时候我需要一个从固定集合中反复采样,有时候我希望做一个随机分割,还有时候,我还要处理只读和可变的容器。
针对这两个方面的缺失,我回顾了这些年来遇到的各种相关问题,在 Jaskell Core 和 Jaskell Java 8 中各自加入了一组随机工具,用于对 Java 的 List 和 Scala 的 Seq、ListBuffer 做随机选择操作。

实现

随机算法和选择算法分离

我没有计划自己重写一个平均概率的随机算法,各种语言的标准库,一般来说提供的是足够高质量的伪随机数发生器,要超过这些实现并不容易。相反,基于这些基础工具,实现实用的非平均概率算法,是个可行的选择。
另一方面,如何根据随机数生成器构造出不同的选择行为,其实是独立于随机数生成器的逻辑。初步想到的包括:

  • 从列表中随机计算出一个索引位置
  • 从列表中返回一个随机元素,不改变原列表
  • 从列表中随机返回n个元素,不改变原列表
  • 从只读列表(这里指Scala的Seq)中取出一个元素,返回被选择的元素和列表的剩余部分
  • 从只读列表中选取n个元素,返回生成的随机元素集合和列表的剩余部分
  • 从可变列表中随机选取一个元素,返回元素时从原列表中删除
  • 从可变列表中随机选取n个元素,返回元素集合时从原列表中删除

以上这些功能,都应该能正确处理传入的参数的边界条件,包括空值和空列表,返回值也应该是安全的。
基于这些需要,我把选择组件和随机数发生器分开,前者名为 Croupier ,即赌场发牌的荷官,后者名为 Poker,即扑克牌 。对于 Croupier ,其行为是固定的,发牌过程是否公平(遵循平均概率)或者“作弊”,由 Poker 决定,如果我们选择了一个“不公平”的 Poker,那么发牌结果就是非平均概率的。
这样,我们就可以通过组合不同分布的随机数发生器,构造出不同需要的随机选择行为。这些组件不像我在近期工作中写的非平均概率算法那么紧凑,但是更通用。
后续的章节主要介绍 Scala 版本的实现——下班写点儿 Scala 是我的一个很重要的消遣。相比之下 Jaskell Java8 中的实现主要是为了未来可能的工作需要,一些重要的点我会提及。
为了保持一致性和使用上的安全便利,所有的随机方法返回值都是 Optional 或者 Seq ,确保不会产生空值问题。

Poker

因为 Croupier 的接口上使用了 Poker ,所以我们先从 Poker 开始了解。
Poker 其实就是根据给定的列表生成随机数。所以,它的接口定义是一个 SAM

trait Poker[T]{
	def select(cards: Seq[T]): Int
}

如果只是为了使用平均概率,它就只是简单的调用 Random 对象的 nextInt

  override def select(cards: Seq[T]): Option[Int] = {
    if (cards == null || cards.isEmpty) {
      return None
    }
    if (cards.size == 1) {
      return Some(0)
    }
    Some(random.nextInt(cards.size))
  }

在这里,我还处理了边界条件,除此之外只有一个 nextInt 调用。但是这里我们仍然要传入待抽取的集合而非仅仅它的尺寸,因为某些随机算法需要更详细的内容信息。
这个方法是 Fair 类型的 Poker 实现,这个类型中还有一些辅助方法,包括默认构造函数,和传入 Random 对象或随机种子数的构造方法等,这里不一一列举了,其它的几个 Poker 实现类型,我也都提供了对应的功能接口。这里我们主要讨论 select 方法的不同实现,对 Rank 和 Weight 的运用。但是在此之前,我先介绍 Croupier 类型。

Croupier

在讨论各种不同的 Poker 实现之前,我们先了解一下 Croupier ,这样我们可以更好的了解 Poker 的作用,和这种组合方式的设计目标。
在设计角度,Croupier 是我们最终使用的选择算法对象,它的接口体现了这个算法库的功能接口,这里我们先看一下它有哪些方法:

class Croupier[T](val poker: Poker[T]) {
  def randIndex(seq: Seq[T]): Option[Int] = poker.select(seq)

  def randDraw(seq: Seq[T]): (Option[T], Seq[T]) 
  def randDraw(seq: Seq[T], size: Int): (Seq[T], Seq[T])
  def randDraw(list: mutable.ListBuffer[T]): Option[T] 
  def randDraw(data: mutable.ListBuffer[T], size: Int): Seq[T] 

  def randSelect(seq: Seq[T], size: Int): Seq[T]
  def randSelect(seq: Seq[T]): Option[T] 

}

我暂时忽略了方法的实现。在 Croupier 构造时,需要传入某个具体的 Poker ,我们还在 object Croupier 中提供了对应的构造方法。Croupier有7个对象方法,对应了我们之前提到的七个功能点,下面我们逐个介绍:

随机索引

根据 Poker ,返回列表中一个随机的索引,是 randIndex 方法:

def randIndex(seq: Seq[T]): Option[Int] = poker.select(seq)

这里没有太多要解释的,它只是直接执行 poker 的 select 方法。不过后面六个方法,都在直接或间接的使用它,而这个东西下面,是不同的 Poker 实现,也是我们后面的章节要着重介绍的内容。

随机选中一个元素

如果我们不需要从列表中删除被选择的元素,只是把它拿出来,那么下面这个 randSelect 很适合:

def randSelect(seq: Seq[T]): Option[T] = randIndex(seq).map(seq)

随机选中一组元素的方法稍复杂一些,我们放在 randDraw之后介绍

随机抽取一个元素

通过 randDraw(seq: Seq[T]): (Option[T], Seq[T]) 方法,我们可以从 seq 中抽取一个元素,因为 seq 是个只读类型,所以返回值是一个 tuple,同时携带了被选择的元素和剩余部分:

  def randDraw(seq: Seq[T]): (Option[T], Seq[T]) =
    poker.select(seq) match {
      case Some(idx) =>
        (Some(seq(idx)), seq.take(idx) ++ seq.drop(idx + 1))
      case None =>
        (None, seq)
    }

因为 poker 中已经正确处理的边界条件,通过对其结果的模式匹配,我们很容易保证 randDraw 的正确性。

从只读列表中随机选取多个元素

从只读列表中抽取多个元素,通过向 randDraw 传入抽取个数实现:

  def randDraw(seq: Seq[T], size: Int): (Seq[T], Seq[T]) = {
    if (seq == null) {
      return (Seq(), Seq())
    }

    @tailrec
    def helper(data: Seq[T], seq: Seq[T], size: Int): (Seq[T], Seq[T]) = {
      if (size == 0 || seq.isEmpty) {
        return (data, seq)
      }
      randDraw(seq) match {
        case (Some(element), rest: Seq[T]) =>
          helper(data :+ element, rest, size - 1)
        case (None, _) =>
          (data, seq)
      }
    }

    helper(Seq(), seq, size)
  } 

这里我用了一个带尾递归的辅助函数,其逻辑也不复杂,反复在递归过程中调用抽取单个元素的 randDraw 即可。主要需要注意的是边界条件的处理。
返回值同时包含了被选择的列表和剩余的列表,这对于应用项目使用和Croupier类型内部的互相调用,都是很有用的设计。

从可变列表中随机抽取一个元素

如果传入了可变的 ListBuffer,randDraw 返回随机选中的元素同时,将其从列表中删除:

  def randDraw(list: mutable.ListBuffer[T]): Option[T] =
    poker.select(list.toSeq)
      .map(idx => list.remove(idx))

从可变列表中随机抽取 n 个元素

同样,可变列表的随机抽取,也有多元素版本:

  def randDraw(data: mutable.ListBuffer[T], size: Int): Seq[T] = {
    if (data == null) {
      return Seq()
    }

    for {
      _ <- 0 until Math.min(data.size, size)
      element <- randDraw(data)
    } yield element
  }

有兴趣的同行可以试试把这个函数改写成尾递归,思路类似def randDraw(seq: Seq[T], size: Int) ,但是要注意边界条件的处理。

随机选择 n 个元素

因为 randSelect 不修改原列表,直接执行 n 次 randSelect(seq:Seq[T])可能会出现重复元素,但是有了 randDraw,我们可以利用它实现 randSelect:

  def randSelect(seq: Seq[T], size: Int): Seq[T] = {
    randDraw(seq, size)._1
  }

回顾

以上是 Croupier 的全部功能,和它如何使用 Poker ,下面我们讨论 Poker 的各种非平均概率实现,首先从大家比较熟悉的按权重选择开始。

非平均概率算法

按权重选择

如果可以对元素计算出一个整数权重,然后根据这个权重决定这个元素在整个概率空间内的分布,就是我们熟悉的一类随机算法,它的做法,应用开发人员应该都不陌生,简单的说先构造一个新的列表,包含每个元素的权重值,然后根据这个权重值的总和,用平均概率做 nextInt 计算,然后查找这个生成结果对应的元素索引。
为了将算法通用化,这里定义一个新的 trait:

trait Scale[T] {
  def weight(item: T): Int
}

显然,这个 SAM 类型大多数情况可以简化为一个 lambda,但是一个明确的定义可以建立更清晰的表达,这个接口是用来给人阅读的。
那么,最简单的情况下,列表和列表元素的权重都不大的情况下,我们可以把索引为i的元素的权重n展开成一段长为n,内容为i的列表,然后将这些列表连起来构成一个大的列表,在其中按平均概率选择一项,它的值就是我们要生成的随机索引值:

  override def select(cards: Seq[T]): Option[Int] = {
	// 省略边界条件

    val steps: Seq[Int] = for {
      idx <- cards.indices
      w = scale.weight(cards(idx))
      value <- Seq.fill(w)(idx)
    } yield value

    val top = steps.size
    val score = random.nextInt(top)
    Some(steps(score))
  }

这段代码来自 Jaskell Core 中的 LiteScaled 类型。充分利用了 scala 的 list comprehension 。比较离谱的是我实现 java 8版本时,stream api 版本比用 for 循环更啰嗦,先看for循环,还是比较朴素的:

        List<Integer> steps = new ArrayList<>();
        for (int idx=0; idx < cards.size(); idx ++) {
            int weight = scale.weight(cards.get(idx));
            for(int i=0; i < weight; i++){
                steps.add(idx);
            }
        }

Stream 版本:

        List<Integer> steps = IntStream
                .range(0, cards.size())
                .mapToObj(idx -> {
                    Integer[] pair = new Integer[2];
                    pair[0] = idx;
                    pair[1] = scale.weight(cards.get(idx));
                    return pair;
                }).flatMap(pair -> IntStream.range(0, pair[1])
                        .mapToObj(x -> pair[0]))
                .collect(Collectors.toList());

真的就是……何必呢……不但 stream 不方便,java 还没有 tuple——所以几乎所有功能稍微丰富一点儿的 Java 框架或库,都有自己的 tuple 和 pair 实现。
这个 Scale Lite 非常容易理解,但是如果 weight 中有一些值很大的,可能会构造出一个比待选择的列表大很多的索引列表,这就不太经济了。这种情况下我们可以用更通用的 Scaled 算法:

  override def select(cards: Seq[T]): Option[Int] = {
	// 省略边界条件处理

    val steps: Seq[Int] = cards
      .map(scale.weight)
      .foldLeft(Seq[Int](0)) { (result: Seq[Int], r: Int) =>
        result :+ (result.last + r)
      }
    val top = steps.last
    val score = random.nextInt(top)
    for (idx <- cards.indices) {
      if (steps(idx) <= score && steps(idx + 1) > score) {
        return Some(idx)
      }
    }
    Some(cards.size - 1)
  }

这个实现的思路是,生成一个从0开始的整数列表,列表中每个值都是当前位置之前所有元素的权重之和——所以第一个元素是0,而这个权重列表的元素个数会比元素列表大 1。然后以最终的总和为上限生成随机数,找出不大于这个随机数的最大位置,就是我们要找的索引。
这个算法比较紧凑,而且空间复杂度有限,通常情况是足够用的。如果我们需要处理特别长的列表,可以考虑把遍历改成二分查找,因为权重值的累加列表是单调递增的,二分查找对这种大列表很有效。
二分查找是很基本的算法,就不多介绍了——嗯,其实我有次面试没写对。
BinaryScaled 的 select 实现如下:

  override def select(cards: Seq[T]): Option[Int] = {
    // 省略边界条件处理

    val steps: Seq[Int] = cards
      .map(scale.weight)
      .foldLeft(Seq[Int](0)) { (result: Seq[Int], r: Int) =>
        result :+ (result.last + r)
      }
    val top = steps.last
    val score = random.nextInt(top)

    var idx: Int = steps.size / 2
    var low = 0
    var high = steps.size - 1
    while (true) {
      if (steps(idx) <= score && steps(idx + 1) > score) {
        return Some(idx)
      }
      if (score < steps(idx)) {
        high = idx
        idx -= math.max((idx - low) / 2, 1)
      } else {
        low = idx
        idx += math.max((high - idx) / 2, 1)
      }
    }

    Some(cards.size - 1)
  }

如果待选择的数据集比较大,二分查找应该会更有效。这里我没有做性能测试,经验来看只要列表有几千个元素,这个差异就可以表现出来了,更何况我们如果要从列表中选择多个元素的话,这个计算会反复进行。
另一方面,如果我们有一个相当大的列表,但是其中涉及的权重值只有很少的几个整数,也就是说大量的元素有相同的权重,另一种算法可能更有效:我们先将元素按权重分组,用权重和元素索引的集合构建一个集合,然后按每个组的总权重(权重值乘以元素个数)进行按权重选择,再从被选择的索引集合中,按平均概率选择出最终的结果。
这个类型在 jaskell core 中称为 ZipScaled

  override def select(cards: Seq[_ <: T]): Option[Int] = {
	// 省略边界条件处理	

    val group = new mutable.TreeMap[Int, Seq[Int]]()
    val steps = cards.map(scale.weight)
    for (position <- steps.indices) {
      val w = steps(position)
      group.put(w, group.getOrElse(w, Seq()).appended(position))
    }
    val pairs: Seq[(Int, Int)] = 
			(for ((weight, positions) <- group) 
				yield (weight, weight * positions.size)
					).toSeq

    scaled.select(pairs)
      .map(pairs)
      .map({ weight =>
        group(weight._1)
      })
      .flatMap({ positions =>
        val result = fair.select(positions).map(positions)
        result
      })
  }

按 Rank 选择

针对离散的整数权重,我们可以很容易的构造出不同性能表现的选择实现,但是有很多业务系统使用浮点数评分,这里我们称之为 Rank,对应的提取接口:

 trait Ranker[T] {
  def rank(item: T): Double
}

随机生成浮点数时,几乎不会出现它精确的等于一个预定值。所以ZipScale的优化方式对浮点数并不很有效,我这里只提供了类似标准 Scaled 算法的 Ranked 算法:

  override def select(cards: Seq[T]): Option[Int] = {
    if (cards == null || cards.isEmpty) {
      return None
    }
    if (cards.size == 1) {
      return Some(0)
    }

    val steps: Seq[Double] = cards
      .map(ranker.rank)
      .foldLeft(Seq[Double](0)) { (result: Seq[Double], r: Double) =>
        result :+ (r + result.last)
      }
    val top = steps.last
    val score = random.nextDouble() * top
    for (idx <- cards.indices) {
      if (steps(idx) <= score && steps(idx + 1) > score) {
        return Some(idx)
      }
    }
    Some(cards.size - 1)
  }

它同样是构造累加和“阶梯”,然后找到对应的位置,因此也有二分查找的版本 BinaryRanked。不过它的代码和 BinaryScaled 几乎一样,这里就不展开讨论了,

概率递降选择

有时候我们按权重做随机选择,其实并不真的需要严格的按权重计算概率,仅仅需要一个有序的概率变化,确保权重更高的元素有更大的几率被选中就可以。那么,非常容易由 Rank 得到启发,我们只要构造一个开头最大,后面单调递减的浮点数序列,让它按照这些浮点数项作为概率分布作为选择就可以。方法有很多,我提供了一个比较简单的 damping 实现:

class Damping[T](val random: Random) extends Poker[T] {
  override def select(cards: Seq[T]): Option[Int] = {
	//省略边界条件处理

    val range = Math.log(cards.size + 1)
    val value = Math.exp(random.nextDouble() * range)
    Some(Math.floor(value).intValue() - 1)
  }
}

在 Scale 和 Rank 算法中隐含的逻辑是:steps看作是一个递增函数的话,相邻两项的差值是这个函数的差分,我们其实是将这些差分值作为概率分布进行随机计算。直接取元素索引作为steps的话,它的差分就是常数1,而Damping先计算列表尺寸的对数,那么每一个索引的对数,构成了一个越来越“紧密”的刻度尺,其差分值数列是以 log(n+1) - log(n) 递减的。当我们以平均概率计算出一个在此区间内的浮点数,再通过 e^v 还原到线性空间时,越靠前的索引,其被选中的概率会显著的大于靠后的索引值。
即使算上文档注释、空行、import和我们这里省略掉的边界条件,Dampling.scala 也只有26行。核心的计算函数只有三行。但是这个算法非常有效,对于很多业务系统,它已经足够用。

概率递增选择

我其实还没有在业务系统中遇到过需要按递增的概率做随机选择的情况,不过作为对偶,我也顺手写了一个递增算法:

class Invert[T](val random: Random) extends Poker[T] {
  override def select(cards: Seq[T]): Option[Int] = {
    if (cards == null || cards.isEmpty) {
      return None
    }
    if (cards.size == 1) {
      return Some(0)
    }

    val range = Math.log(cards.size + 1)
    val value = Math.exp(random.nextDouble() * range)
    val score = cards.size - value
    Some(Math.floor(score).intValue() + 1)
  }

如果能理解 damping,这个invert也就很容易理解,它只是一个反向的 damping。

工具函数

最终,我们有了提供选择逻辑的 Croupier 类型,和实现不同随机算法的各种 Poker ,通过一组 object 方法,我们提供了快捷的使用入口:

object Croupier {

  def fair[T]: Croupier[T] = new Croupier(new Fair(new Random()))

  def fair[T](random: Random): Croupier[T] = new Croupier[T](new Fair(random))

  def fair[T](seed: Long): Croupier[T] = new Croupier[T](new Fair(new Random(seed)))

  def damping[T]: Croupier[T] = new Croupier(new Damping(new Random()))

 	//..

  def invert[T]: Croupier[T] = new Croupier(new Invert(new Random()))

 	//..

  def byWeight[T](scale: Scale[T]) = new Croupier[T](new Scaled[T](scale, new Random()))

 	//..

  def byWeightLite[T](scale: Scale[T]) = new Croupier[T](new LiteScaled[T](scale, new Random()))

 	//..

  def byWeightBinary[T](scale: Scale[T]) = new Croupier[T](new BinaryScaled[T](scale, new Random()))

 	//..

  def byRank[T](ranker: Ranker[T]) = new Croupier[T](new Ranked[T](ranker, new Random()))

 	//..

  def byRankBinary[T](ranker: Ranker[T]) = new Croupier[T](new BinaryRanked[T](ranker, new Random()))

 	//..

  def byZipScale[T](scale: Scale[T]) = new Croupier[T](new ZipScaled[T](scale, new Random()))

 	//..
}

每一个构造方法,我都提供了三个不同的构造方法:直接构造,或者传入一个既有的 Random 对象,或者传入一个随机种子。这样可以满足将来对不同随机行为的选择需求。当然使用者也可以实现自己的 Poker,组装到 Croupier 中使用。下面我们以一段测试代码为例,演示一下使用方法:

  it should "select more elements while more nearer front user damping" in {
    val counter: mutable.Map[Int, Int] = new mutable.TreeMap[Int, Int]()
    val croupier = Croupier.damping[Int]
    for (_ <- 0 until 100000) {
      val element = croupier.randSelect(elements)
      element should not be None
      element.foreach { value =>
        counter.put(value, counter.getOrElse(value, 0) + 1)
      }
    }
    for (num <- elements) {
      info(s"damping select $num times ${counter.getOrElse(num, 0)}")
    }
  }

因为这是个随机逻辑,我很难通过精确的 Assertion 判断其有效性,所以这里我把返回结果打印出来观察。各种功能的测试逻辑大同小异,都是构造测试集和 Croupier,将随机选择的结果写入计数器字典,做简单的检查后,打印观察详细情况。所以就不一一展开了。

你可能感兴趣的:(真理与美,java,scala,开发语言)