Lección 6 - Mining Streams

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.

Minería de flujos

Existen diversas razones por las que es necesario minar flujos de datos en tiempo real en memoria, en lugar de almacenarla para realizar consultas en otro momento, por ejemplo,

  • el flujo de información es tan grande que no es práctico almacenarla
  • el flujo de información es tan rápido que si movieramos la información a disco perderíamos capacidad de procesarla

No obstante queremos tener la capacidad de analizar esta información conforme se genera.

Algunas procesos interesantes que podemos aplicar sobre estos flujos son por ejemplo:

  • muestrear aleatoriamente un flujo
  • filtrar objetos no deseados
  • estimar el número de elementos distintos utilizando estructuras de datos probabilísticas

Y algunos otros más simples:

  • ventanas de tiempo
  • resúmenes acumulados (máximo, mínimo, promedio, ¿mediana?)

A todo esto, ¿cuáles son algunos ejemplos de flujos?

  • datos de sensores
  • flujos de imágenes
  • logs de internet

Resúmenes acumulados

Comencemos por explorar la forma más simple de análisis de flujos.

¿Cuáles son los algoritmos para encontrar estadisticas descriptivas sobre un flujo?

  • máximo
  • mínimo
  • promedio
Estadísticas de orden

Las estadísticas de orden tienen una característica especial, esta es, que tienen que conocer el orden de los elementos para poder contestar a la pregunta, ¿Cuál es el valor del elemento que está a la mitad?

Ventanas de tiempo

Otra posibilidad para consultar información sobre un flujo, es hacer consultas sobre ventanas de tiempo de tamaño $n$ que en general representan los $n$ elementos más recientes del flujo. O guardar un numero de elementos arbitrarios que permitan consultar los datos para un periodo de tiempo $t$.

Muestreo de flujos

Supongamos que queremos tomar una musestra de un flujo para posteriormente realizar operaciones sobre de ella, ¿Cómo hacemos para que la muestra sea representativa del flujo total? La estrategia de muestreo depende de que es lo que se quiere muestrear. Por ejemplo, dado un sistema que recibe información de múltiples sensores.

  • Muestrar eventos que tienen una duración mayor a D, se pueden muestrear los eventos directamente
  • Muestrar la proporción de sensores que tienen eventos de duración mayor a D, muestrear los eventos

En el primer caso es sencillo realizar el muestreo, generar un número aleatorio entre 0 y $n$ donde $n$ es el inverso de la proporción $1/n$ que se quiere muestrear.

El segundo caso sin embargo, requiere que podamos identificar cuales son los usuarios que estamos muestreando, para que tomemos en cuenta todas las observaciones que les corresponden.

¿Cómo podemos utilizar hashing para no tener que almacenar una lista con todos los usuarios que sí debemos muestrear?


In [1]:
import random

NUM_USERS = 10000

rnd = random.Random(11)
events = list()
for i in range(1000000):
    duration = 0
    if rnd.random() > 0.2:
        duration = rnd.randint(0, 200 - 1)
    else: 
        duration = rnd.randint(200, 1000 - 1)
    events.append((rnd.randint(0, NUM_USERS - 1), duration))

In [2]:
def sample(elements, pct):
    sampled = list()
    for elem in elements: 
        r = rnd.randint(0,int(1/pct))
        if r == 0:
            sampled.append(elem)
    return sampled

def proportion(sampled, threshold):
    count = 0
    for event in sampled:
        if event[1] > threshold:
            count += 1
    print "Proporción de eventos > %s: %.4f" % (threshold, count / float(len(sampled)))

In [3]:
# Contar eventos con duración mayor a 200
proportion(events, 200)
sampled = sample(events, .01)
proportion(sampled, 200)


Proporción de eventos > 200: 0.1992
Proporción de eventos > 200: 0.2004

In [4]:
def sampled_per_user(data):
    r = rnd.randint(0, len(data))
    user = data[r][0]
    filtered = [_ for _ in data if _[0] == user]
    proportion(filtered, 200)

In [20]:
# Que proporción de los eventos de un usuario aleatorio fueron mayores a 200
for i in range(5):
    sampled_per_user(sampled)


Proporción de eventos > 200: 0.0000
Proporción de eventos > 200: 0.5000
Proporción de eventos > 200: 0.0000
Proporción de eventos > 200: 0.0000
Proporción de eventos > 200: 0.0000

Podemos observar que el número de eventos mayores a 200 para los usuarios muestreados no corresponde a los de su distribución. Ya que los eventos que la muestra se hace sobre todos elementos sin tomar en cuenta que algunos eventos para un usuario suceden con menor probabilidad

¿Cómo podemos obtener una muestra representativa por usuario?

Nuestro objetivo es muestrear todos los eventos de la proporción correcta de los usuarios.


In [6]:
def sample_users(elements, pct):
    seen = dict()
    sampled = list()
    for elem in elements: 
        user = elem[0]
        if not user in seen:
            r = rnd.randint(0,int(1/pct))
            seen[user] = (r == 0)
        if seen[user]:
            sampled.append(elem)
    return sampled

In [7]:
user_sampled = sample_users(events, 0.01)
proportion(user_sampled, 200)


Proporción de eventos > 200: 0.1956

In [8]:
# Que proporción de los eventos de un usuario aleatorio fueron mayores a 200
for i in range(5):
    sampled_per_user(user_sampled)


Proporción de eventos > 200: 0.1684
Proporción de eventos > 200: 0.1759
Proporción de eventos > 200: 0.1909
Proporción de eventos > 200: 0.1731
Proporción de eventos > 200: 0.2809

El problema con este enfoque es que tenemos que mantener la información sobre todos los usuarios que hemos observado hasta el momento, para poder determinar si los tenemos que muestrear o no.

Una alternativa eficiente en memoria es... utilizar hashes. Si el hash del usuario corresponde a la cubeta 0 lo muestreamos de otra forma no.


In [9]:
def sample_users_hash(elements, pct):
    sampled = list()
    for elem in elements: 
        if hash(elem[0]) % int(1/pct) == 0:
            sampled.append(elem)
    return sampled

In [10]:
user_sampled_hash = sample_users_hash(events, 0.01)
proportion(user_sampled_hash, 200)


Proporción de eventos > 200: 0.1990

In [11]:
for i in range(5):
    sampled_per_user(user_sampled_hash)


Proporción de eventos > 200: 0.2222
Proporción de eventos > 200: 0.2300
Proporción de eventos > 200: 0.2182
Proporción de eventos > 200: 0.2589
Proporción de eventos > 200: 0.3196

En este caso la función hash funciona como un generador de números aleatorios que siempre mapea al mismo usuario al mismo número aleatorio.

En general se puede obtener una muestra de una fracción $a/b$ aplicando hashes a $b$ cubetas y muestreando aquellos cuyo hash sea menor a $a$.


In [12]:
def sample_hash(elements, a, b):
    sampled = list()
    for elem in elements: 
        if hash(elem[0]) % b < a:
            sampled.append(elem)
    return sampled

In [13]:
sampled_hash = sample_hash(events, 1, 100)
proportion(sampled_hash, 200)


Proporción de eventos > 200: 0.1990

In [14]:
for i in range(5):
    sampled_per_user(sampled_hash)


Proporción de eventos > 200: 0.1731
Proporción de eventos > 200: 0.1630
Proporción de eventos > 200: 0.1532
Proporción de eventos > 200: 0.1727
Proporción de eventos > 200: 0.2190

In [16]:
from collections import defaultdict

def get_proportion(sampled, threshold):
    count = 0
    for event in sampled:
        if event[1] > threshold:
            count += 1
    return count / float(len(sampled))    

def proportion_per_user(sdata):
    per_user = defaultdict(int)
    per_user_total = defaultdict(int)
    for event in sdata:
        per_user[event[0]] += (1 if event[1] > 200 else 0)
        per_user_total[event[0]] += 1

    sm = 0
    for user, user_events in per_user.items():
        sm += user_events / float(per_user_total[user])
        
    print "Promedio de proporción de eventos por usuario > %s: %.2f @%s" % (200, sm / len(per_user), len(per_user))
    
proportion_per_user(sampled)
proportion_per_user(user_sampled)
proportion_per_user(user_sampled_hash)
proportion_per_user(events)


Promedio de proporción de eventos por usuario > 200: 0.20 @6327
Promedio de proporción de eventos por usuario > 200: 0.20 @98
Promedio de proporción de eventos por usuario > 200: 0.20 @100
Promedio de proporción de eventos por usuario > 200: 0.20 @10000

Filtrado

Otro proceso común a aplicar sobre flujos es el filtrado, nos gustaría tener un proceso para dejar pasar ciertos registros y no otros, por ejemplo filtrar las direcciones peligrosas en un navegador. Este proceso se complica cuando

  • El flujo es muy grande y no podemos mantener en memoria la membresía de todos los datos del conjunto
  • No queremos acceder al disco cada vez que queremos hacer una consulta.

Suponiendo que tenemos 1 megabyte de memoria disponible para almacenar esta información podríamos almacenar hasta 1 millon de registros de direcciones peligrosas utilizando un bit por dirección, utilizando... una función hash para mapear cada dirección a uno de los bits.

Cuando queremos validar una dirección,

  • Aplicamos una función hash a la dirección
  • Checamos si dicha dirección está en nuestro arreglo de memoria
  • Si encontramos la dirección en el arreglo mandamos una página alertando al usuario.

Es posible que al utilizar este método algunas páginas que no son peligrosas mapen a un bit encendido. En cuyo caso tendremos un falso positivo.

El filtro de Bloom

Un filtro de bloom consiste en:

  1. Un arreglo de $n$ bits
  2. Una colección de $k$ funciones hash que mapean de manera única el valor a uno de los $n$ los buckets
  3. Un conjunto $S$ de $m$ llaves

El objeto de esta función es dejar pasar aquellos elementos cuyo valor esté en el conjunto y no rechazar la mayoría de los que no están en el conjunto.

En el caso de las páginas maliciosas lo utilizamos de forma inversa pero el principio es el mismo.

  • Para construir el filtro, inicializamos el arreglo con ceros
  • Para cada valor $s$ en $S$
    • Aplicamos cada una de las $h_k$ funciones a $s$
    • Cambiamos a 1 cada uno de los bits designados por las funciones $h_k$
  • Para probar si un nuevo elemento está en el filtro, aplicamos el mismo procedimiento y verificamos que todos los bits esten encendidos.
  • Si alguno de los bits no está encendido entonces es seguro que $s$ no estaba en el conjunto.
  • En cambio, si todos los bits están prendidos es probable sea un falso positivo debido a una colisión
Análsis

A continuación analizamos las propiedades estadísticas del filtro. La cantidad que nos interesa conocer es cual es la probabilidad de un falso positivo, como función de $n$ la longitud del filtro, $m$ el número de elementos en el conjunto y $k$ el número de funciones.

La probabilidad de que un bit no sea 1 para un elemento y una función $h_k$ es:

$$1 - \frac{1}{n}$$

De que no sea 1 para ninguna de las funciones $h_k$ es:

$$\left(1 - \frac{1}{n}\right)^k$$

La probabilidad de que después de insertar $m$ elementos un bit aún sea 0 es:

$$\left(1 - \frac{1}{n}\right)^{km}$$

Por lo tanto la probabilidad de que un elemento sea 1 después de insertar $m$ elementos es:

$$1 - \left(1 - \frac{1}{n}\right)^{km}$$

Cuando insertamos un nuevo elemento cada uno de los las $k$ posiciones tiene una probabilidad de ser uno igual a $1 - \left(1 - \frac{1}{n}\right)^{km}$ por lo tanto la probabilidad de que las $k$ posiciones sean 1 es:

$$\left[1 - \left(1 - \frac{1}{n}\right)^{km}\right]^k \approx \left(1 - e^{-km/n}\right)^k$$

Esta aproximación asume independencia entre cada una de las probabilidades, si bien esto no es estrictamente correcto para valores grandes de $n$ y valores chicos de $k$ la aproximación es buena. Ver http://people.scs.carleton.ca/~kranakis/Papers/TR-07-07.pdf

Por lo tanto podemos concluir que la probabilidad de falsos positivos disminuye conforme utilizamos arreglos de bits más grandes (valores más grandes de $n$) e incrementa conforme agregamos más elementos al conjunto (valores más grandes de $m$).

Número óptimo de funciones

Dado $n$ y $m$ el valor de $k$ que minimiza la probabilidad de falsos positivos es:

$$k = \frac{n}{m}\text{ln }2$$

El número de bits $n$, requerido para mantener esta probabilidad de falsos positivos es:

$$n = -\frac{m\text{ ln }p}{(\text{ln }2)^2}$$

In [ ]: