Lección 6 - Mining Streams Counting

Objetivo

El objetivo de esta lección es entender como funcionan distintos algoritmos para minería de flujos de datos, es decir información que no está almacenada sino se va adquiriendo de forma incremental.

Nota: Esta lección está basada en el Capítulo 4 del libro Mining of Massive Datasets. Rajaraman, Leskovec and Ullman 2012 y en las notas del curso de Métodos Analíticos del 2013 por Felipe Gonzalez.

Conteo de elementos distintos

En esta lección analizaremos el problema de conteo de elementos distintos en un flujo datos, es dedir, calcular distintos momentos de frecuencia acerca de los elementos en un flujo de datos.

Sea $A = a_1, a_2, \ldots, a_m$ una secuencia de números naturales y sea $m_i = |j: a_j = i|$ el número de veces que $i$ ocurre en la secuencia $A$. El $k$-esimo momento de el flujo está dado por:

$$F_k=\sum^{n}_{i=1}m_i^k$$
  • $F_0$ es el conteo de elementos distintos que aparecen en el flujo
  • $F_1$ representa el tamaño de la secuencia (número total de elementos)
  • $F_2$ es el índice de que tan desigual es la distribución
    • Si la distribución de los elementos es más o menos uniforme el número será menor.
    • Por ejemplo, para un flujo de longitud 100 donde aparecen 11 elementos
      • $10^2 + 10 \times 9^2 = 910$
      • $90^2 + 10 \times 1^2 = 8110$

Cada uno de los momentos de frecuencia nos proporciona una estadística util acerca de los elementos de la secuencia.

Es claro que cuando el número de elementos a contar es demasiado grande para ser almacenados en memoria tenemos que buscar una alternativa.

Almacenar la información sobre los elementos observados en el disco duro haría la tarea sumamante lenta.

Una vez más utilizaremos estructuras de datos probabilísticas para estimar el número de elementos distintos en un flujo.

Para más referencias acerca de los momentos de frecuencia ver:

The space complexity of approximating the frequency moments http://www.tau.ac.il/~nogaa/PDFS/amsz4.pdf

El algoritmo de Flajolet-Martin

La idea de este algoritmo es tomar una función hash $h$ que mapee los elementos a un número grande de cubetas (de manera uniforme)

  • Cuando observamos un valor $a$ contamos el número de ceros al final de la expansión binaria de $h(a)$, la cola de $h(a)$
  • Hacemos $R$ la longitud máxima de cualquier cola observada hasta ahora.
  • Estimamos el número de elementos distintos con $2^R$
  • ?!!??!?

La intuición detrás de este algoritmo es que entre más elementos observemos la probabilidad de que observemos un elemento cuya expansión binaria tenga muchos ceros es mayor.

Dado un elemento $a$ y $h(a)$

  • La probabilidad de que representación binaria de $h(a)$ tenga una cola de al menos $r$ ceros es $2^{-r}$

Es decir que debe tomar cerca de $2^r$ elementos antes de obervar un hash con $r$ ceros.

Dados $m$ elementos en el flujo:

  • La probabilidad de que ninguno de los $m$ elementos tenga una cola de longitud al menos $r$ es $(1 - 2^{-r})^m = (1 - 2^{-r})^{2^r(m2^{-r})} \approx e^{-m2^{-r}}$
    • La expresión $(1 - 2^{-r})^{2^r}$ es de la forma $(1 - \epsilon)^{1/\epsilon} \approx 1/e$
  • Si $m$ es mucho menor que $2^r$ la probabilidad de no encontrar una cola con al menos $r$ ceros se aproxima a 1
    • $m/2^r \to 0$
  • Si $m$ es mucho mayor que $2^r$ la probabilidad de no encontrar una cola con al menos $r$ ceros se aproxima a 0
    • $m/2^r \to\infty$

In [386]:
%pylab inline


Populating the interactive namespace from numpy and matplotlib

In [364]:
import random as rndm

NUM_ITEMS = 10000

rnd = rndm.Random(11)
M = 8388133
lsk = int(numpy.ceil(numpy.log2(M)))

p = 15485863
a = rnd.randint(0, p - 1)
b = rnd.randint(0, p - 1)

def hasher(x, va, vb):
    return ((va * x + vb) % p) % M

def get_lsb(bits):
    for i in range(lsk):
        if (bits & (0x01 << i)) != 0:
            return i + 1

def update(items):
    for item in items:
        lsb = get_pos(item)

def get_pos(item):       
    h = hasher(item, a, b)
    return get_lsb(h)

In [365]:
items = [rnd.randint(0,NUM_ITEMS) for i in range(1000000)]
pos = [get_pos(item) for item in items]
plt = hist(pos)


Podemos notar de forma empírica que la probabilidad de observar un hash con un mayor número de ceros dismuye exponencialmente.

Sin embargo este método presenta dos problemas:

  • $2^R$ solo puede representar potencias de dos

Si utilizamos, múltiples funciones hash $h_k(a)$ para cada valor $a$ podemos obtener una mejor estimación de $m$. Sin embargo no podemos promediar ya que el valor esperado de $2^R$ es infinito

  • Algunas posibles soluciones:
    • Estimar utilizando la mediana (la mediana sigue siendo potencia de dos)
    • Estimar utilizando una media recortada
    • Agrupar en grupos chicos, promediar y obtener la mediana de los promedios

El método de medianas de promedios tiene un mejor desempeño entre más grande sea el tamaño de los grupos.


In [405]:
import time 

LONG_NUM_ITEMS = 1000000

total = set()
max_pos = 0

result = list() 
error = list() 

for l in range(1000):
    items = [rnd.randint(0, LONG_NUM_ITEMS) for i in range(100)]
    total.update(items)
    pos = [get_pos(item) for item in items]
    max_pos = max(max(pos), max_pos)
    estimate = 2**max_pos
    tot = len(total)
    result.append((tot, estimate, tot - estimate))
    error.append(((estimate / float(tot)) - 1) * 100)

In [406]:
subplot(2,1,1)
plt = plot(result)
subplot(2,1,2)
plt = plot(error)


En el ejemplo con un solo hash podemos ver como el estimado es bastante malo dado que no hay nada que equilibre una mala predicción.

A continuación utilizaremos múltiples hashes y la mediana de los promedios.


In [391]:
seeds = list()

NUM_HASHES = 2**8
GROUP_SIZE = 16

for _ in range(NUM_HASHES):
    la = rnd.randint(0, p - 1)
    lb = rnd.randint(0, p - 1)
    seeds.append((la, lb))
    
def update_pos(item, ints):    
    for i in range(NUM_HASHES):
        h = hasher(item, seeds[i][0], seeds[i][1])
        ints[i] = max(get_lsb(h), max_ints[i])
        
def get_estimate(ints):
    nh = NUM_HASHES / GROUP_SIZE
    averages = list()
    for group in range(nh):
        averages.append(2**(sum(ints[group:group + GROUP_SIZE]) / GROUP_SIZE))
    return numpy.median(averages)

In [407]:
total_2 = set()

result_2 = list() 
error_2 = list() 

max_ints = [0] * NUM_HASHES 

for l in range(1000):
    items = [rnd.randint(0, LONG_NUM_ITEMS) for i in range(100)]
    total_2.update(items)
    for item in items:
        update_pos(item, max_ints) 
    estimate = get_estimate(max_ints)
    tot = len(total_2)
    result_2.append((tot, estimate, tot - estimate))
    error_2.append(((estimate / float(tot)) - 1) * 100)

In [408]:
subplot(2,1,1)
plt = plot(result_2)
subplot(2,1,2)
plt = plot(error_2)


La idea es utilizar el mayor número de hashes posibles de tal forma que los promedios nos den un mejor aproximado a valor real.

El límite es que tanta capacidad de procesamiento tenemos para calcular los hashes.

Más información sobre el algoritmo de FM, está disponible en su paper: Probabilistic Counting Algorithms for Database Applications http://www.mathcs.emory.edu/~cheung/papers/StreamDB/Probab/1985-Flajolet-Probabilistic-counting.pdf

Otros algoritmos de conteo

K-Minimum Values

Otro algoritmo útil para aproximar los elementos distintos en un flujo es el algortitmo de K-Minimum Values.

La intuición es la siguiente, si los hashes están uniformemente distribuidos, es posible estimar el número de elementos observados a partir del espacio promedio entre los hashes.

Una manera de estimar el espacio entre los hashes es manteniendo referencia al valor más chico. De tal forma que si $X = min(S)$ podemos esperar que $n$ esté en orden de $1/X$. Sin embargo una vez más nos enfrentamos al probema de la varianza.

Para mejorar el aproximado mantenemos los $k$ valores más pequeños y estimamos utilizando $(k - 1)/max(\text{kmv})$

Podemos observar que este algoritmo funciona razonablemente bien y a un costo de procesamiento mucho menor.

Counting Distinct Elements in a Data Stream http://dl.acm.org/citation.cfm?id=711822


In [397]:
import heapq as hq

total_3 = set()

result_3 = list() 
error_3= list() 

min_items = []

kmvk = 3

for l in range(1000):
    items = [rnd.randint(0, LONG_NUM_ITEMS) for i in range(100)]
    total_3.update(items)
    for item in items:
        hv = hasher(item, a, b)
        if len(min_items) < kmvk:
            hq.heappush(min_items, -hv)
        elif -hv > hq.nsmallest(1, min_items)[0]:
            hq.heappop(min_items)
            hq.heappush(min_items, -hv)
    min_item =  hq.nsmallest(1, min_items)
    estimate = (kmvk - 1) / (-min_item[0] / float(M))
    tot = len(total_3)
    result_3.append((tot, estimate, tot - estimate))
    error_3.append(((estimate / float(tot)) - 1) * 100)

In [398]:
subplot(2,1,1)
plt = plot(result_3)
subplot(2,1,2)
plt = plot(error_3)


El algoritmo HyperLogLog

Uno de lo algoritmos más populares para el conteo de elementos distintos es el algoritmo de HyperLogLog.

La idea principal del algoritmo es emular el efecto de realizar $m$ hashes utilizando un promedio estocástico a partir de una sola función hash.

Esto se hace dividiendo el flujo en sub-flujos que corresponden a una partición de la unidad.

Utilizando los primeros $k$ bits del hash para seleccionar la partición y el resto de los bits para calcular la cola de ceros para cada partición $R_j$.

La intución es la siguiente, si partimos el flujo en $n/m$ elementos entonces podemos utilizar el promedio de $\bar{R}$ en las $m$ cubetas para calcular la cardinalidad utilizando $m * 2^\bar{R}$

El estimado HLL corrige un sesgo importante utilizando la media armónica de $2^{R_j}$

$$E_{\text{HLL}} = \frac{\alpha_{m}m^2}{\sum_{j=1}^{m}{2^{-R_j}}}$$

Donde $\alpha_m$ es una constante que corrige el sesgo para pra valores bajos de $m$.

Este algoritmo presenta un error estandar de $1.04/\sqrt{m}$. Por ejemplo para estimar cardinalidades de flujos arriba de $10^9$ con 2% de precisión solo se necesitan 1.5 KB de memoria.

Adicionalmente éste algoritmo es altamente paralelizable ya que múltiples máquinas pueden correr el algoritmo utilizando el mismo hash y $m$.

HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf

HyperLogLog in Practice: Algorithmic Engineering of a State of The Art Cardinality Estimation Algorithm http://static.googleusercontent.com/media/research.google.com/en/us/pubs/archive/40671.pdf

Count - Min - Sketch