Skip to content

Unified probabilistic filter API #1997

Description

@nevillelyh

This is inspired by the discussion about Algebird BloomFilter, mutable BloomFilter from #1806, and CuckooFilter (paper).

cc @anish749 @clairemcginty

Background:

  • There're multiple probabilistic implementations with different properties and performance.
  • This discussion is limited to using Bloom/Cuckoo Filter for probabilistic set membership.
  • Not all of them can be expressed as Algebird Semigroup or Aggregator.
  • A common use case is to convert an SCollection[T] (where T can be String, Array[Byte], etc.) as a singleton SCollection[ProbFilter], which can then be used as a side input or store on disk.

For this specific case we want to abstract over the existing mutable BloomFilter & Java CuckooFilter (also mutable) and a few use cases. A few points about the 2 filter algorithsm:

  • Filter construction
    • BF can be constructed in parallel and expressed as a Semigroup or Aggregator, since BF ++ BF is just bitwise OR.
    • CF cannot be constructed in parallel since insertion is non-deterministic (random entry in a bucket) and may involve eviction. So it requires a .groupBy(_ => ()) followed by sequential insertion of all elements. OTOH we can do a .map() into (index1: Long, fingerprint: Int) per element to reduce shuffle IO, since each entry is represented by index1, index2, fingerprint alone and we can compute index2 from index1 XOR fingerprint and vice versa.
  • Insertion
    • BF insertions are idempotent, so we don't have to do SCollection#unique before building the filter. BF insertions will also never fail since it's just flipping bits to 1.
    • CF insertions are not idempotent, and cannot insert the same item more than 2b times where b is bucket size (usually 2, 4, or 8). In our case of set membership, we should do SCollection#unique before building the filter. CF insertions can fail when it's close to full capacity.
  • Probabilistic interpretations
    • Both BF and CF has well defined false positive probability, which is usually set at filter construction.
    • BF can have false positives but no false negatives, i.e. either maybe contains or definitely does not contain.
    • CF can have false negatives if we ignore failed insertions. The false negative probability = # of insertion failure / # of insertions.
    • To make a CF that behaves like BF, i.e. no false negatives, we can either A retry with bigger capacity until no failures occur (more time) or B memorize failed items and build a second CF, and chain maybeContains lookup.

Proposal:

I sketched something like this:

import com.spotify.scio.values.SCollection
import com.twitter.algebird

// immutable, read only filter interface
trait ProbFilter[T] {
  val capacity: Long // maximum number of items allowed
  val fpp: Double // false positive prob
  val fnp: Double // false negative prob
  def hasFalsePos: Boolean = fpp > 0.0
  def hasFalseNeg: Boolean = fnp > 0.0

  // may return both false positive and negative depending on `fpp` and `fnp`
  def contains(item: T): Boolean
}

// builder
trait ProbFilterBuilder[PF[_]] {
  def build[T](data: Seq[T])(implicit hash: algebird.Hash128[T]): PF[T]
  def build[T](data: SCollection[T])(implicit hash: algebird.Hash128[T]): SCollection[PF[T]]

  // probably methods for ser/de too
}

// naive BF impl with immutable Algebird BF
case class BloomFilter[T] private (capacity: Long, fpp: Double, internal: algebird.BF[T])
    extends ProbFilter[T] {
  override val fnp: Double = 0.0

  override def contains(item: T): Boolean = internal.maybeContains(item)
}

case class BloomFilterBuilder(capacity: Long, fpp: Double)
  extends ProbFilterBuilder[BloomFilter] {
  override def build[T](data: Seq[T])(implicit hash: algebird.Hash128[T]): BloomFilter[T] = {
    require(capacity <= Int.MaxValue)
    val bfm = algebird.BloomFilter(capacity.toInt, fpp)
    val bfa = algebird.BloomFilterAggregator(bfm)
    val bf = bfa.appendAll(data)
    BloomFilter(capacity, fpp, bf)
  }

  override def build[T](data: SCollection[T])(
    implicit hash: algebird.Hash128[T]
  ): SCollection[BloomFilter[T]] = ???
}

We can implement this for the following and hide all impl details, including aggregator/groupBy/pre-hash and Algebird vs our mutable instances away.

  • mutable BF
  • one-sided CF with no false negative
  • two-sided CF with both false positive & negative

On the Scio side, we can add the following API to make it more user friendly:

  • SCollection[T]#toProbFilter[PF[_]](builder: ProbFilter[PF]): SCollection[PF[T]]
  • Methods to save/load SCollection[PF[_]] to/from disk

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions