Lección 1 - Similitud entre conjuntos

Min-Hashing y Locality-Sensitive Hashing

Objetivo

El objetivo de esta lección es mostrar métodos que permitan encontrar vecinos cercanos en espacios de alta dimensionalidad de forma eficiente.

Nota: Esta lección está basada en el Capítulo 3 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.

Dependencias

Para esta lección necesitamos instalar el paquete pyhash

conda install pyhash

Fijamos el display a 5 decimales:


In [2]:
%precision 5


Out[2]:
u'%.5f'

Similitud / distancia de Jaccard

La similitud de Jaccard de dos conjuntos es el tamaño de su intersección dividido entre el tamaño de su unión.

$$Sim(C_1, C_2) = \frac{||C_1 \cap C_2 ||}{||C_1 \cup C_2||}$$

Ejemplo: ¿Cuál es la similitud de Jaccard entre $C_1 = \{a, b, d, f, x, y\}$ y $C_2 = \{x, z,w, d, a, p\}$?


In [3]:
def jaccard(c1, c2):
    num = len(c1.intersection(c2))
    den = len(c1.union(c2))
    return num / float(den)

In [4]:
c1 = set(['a','b','d','f','x','y'])
c2 = set(['x','z','w','d','a','p'])

In [5]:
jaccard(c1,c2)


Out[5]:
0.33333

La distancia de Jaccard se define como: $dist(C_1, C_2) = 1 - Sim(C_1, C_2)$

Similitud entre documentos

Dada una colección de millones de documentos, encontrar pares que sean casi duplicados.

  • Primer paso: Convertir los documentos en conjuntos (Shingling).
  • Segundo paso: Derivar firmas que preserven la similitud de cada conjunto (Min-hashing).
  • Tercer paso: Encontrar los pares de firmas que pueden provenir de documentos similares (Locality Sensitive Hashing).

Shingling

Para convertir los documentos en conjuntos, vamos partir los documentos en secuencias de caracteres de tamaño fijo. Estas secuencias son conocidas como shingles.

Un k-shingle es una secuencia de k caracteres que aparece en un documento.


In [6]:
def shingle(string, k):
    return [string[i:i + k] for i in xrange(len(string) - (k - 1))]

Nota: La función xrange es equivalente a la función range pero genera los elementos conforme va iterando en lugar de almacenarlos todos de una vez.


In [7]:
print shingle("Sabado y Domingo", 3)


['Sab', 'aba', 'bad', 'ado', 'do ', 'o y', ' y ', 'y D', ' Do', 'Dom', 'omi', 'min', 'ing', 'ngo']

Ejemplo: ¿Cúal es la similitud de Jaccard entre las siguientes dos cadenas? "el perro persigue al gato" y "el perro persigue al conejo" usando 5-shingle


In [8]:
s1 = shingle('el perro persigue al gato', 5)
s2 = shingle('el perro persigue al conejo', 5)
jaccard(set(s1), set(s2))


Out[8]:
0.62963

Razonamiento

  • Los documentos que tienen muchas shingles en común, contienen texto similar; independientemente de que los bloques de texto aparezcan en distinto orden.
  • Es importante elegir una k grande de otra forma todos los documentos contendrían las mismas shingles (el caso extremo es k=1).
  • Se ha observado que k = 5 es razonable para documentos chicos, k = 10 es mejor para documentos grandes.

Funciones hash

Una función hash es un algoritmo que permite generar un resumen de longitud fija a partir de una entrada de datos arbitraria. Típicamente una función hash es no-invertible, es decir, no se puede reconstruir el contenido original a partir de la información contenida en el resumen. En general las funciones hash están diseñadas para minimizar la probabilidad de colisión dadas dos entradas distintas.

El siguiente ejemplo muestra el resultado de aplicar la función hash() de python a cadenas de caracteres de distinta longitud. Para ver un ejemplo de la implementación de una función hash ver la 'Nota 1' al final de la lección.


In [9]:
print hash('hola')
print hash('el perro persigue al gato')
print hash('El siguiente ejemplo muestra una función hash muy simple que mapea una cadena de caracteres de longitud arbitraria a un número entero de 4 bytes.')


7799586877594763614
5803988330609553052
6422655065068680020

Optimización del espacio requerido para almacenar los shingles

Es posible reducir los requerimientos de memoria al almacenar los shingles, si a cada shingle le aplicamos una función hash que laotransforme a un número entero en lugar de utilizar directamente las sub-cadenas. Es importante notar que esto puede reducir la precisión ya que los hashes de las sub-cadenas pueden tener colisiones.

Ejercicio: Re-implementa la función shingle para que represente cada teja como un entero. Y comprueba que la similitud entre las dos frases es aproximadamente la misma.


In [9]:

Min-Hashing

El método anterior nos permite comparar la similitud entre un par de documentos. Sin embargo no es posible almacenar en memoria la lista de shingles para todos los documentos (la lista de shingles es más grande que el tamaño del documento original).

Para solucionar este problema buscamos un método que nos permita generar firmas pequeñas o resúmenes de cada conjunto que preserven la similitud de los conjuntos originales.

Matriz característica

Empecemos por definir una representación alternativa donde cada conjunto es un vector en una matríz booleana $M$, donde los renglones corresponenden a los shingles y las columnas a los documentos. Por ejemplo:

$d_1$$d_2$$d_3$
a011
b100
c110
d001
e111
f011

En esta representación podemos calcular la similitud de Jaccard como:

$$||C_1 \cap C_2|| = \text{número de renglones en que ambas entradas son 1}$$$$||C_1 \cup C_2|| = \text{número de renglones en que al menos una entrada es 1}$$

Es decir el número de elementos que ambos conjuntos tienen en común, entre el número total de elementos únicos en cada conjuntos.

En el ejemplo anterior la similitud de Jaccard es $Sim(d_1, d_2) = \frac{2}{5} = 0.4$, $Sim(d_2, d_3) = \frac{3}{5} = 0.6$ y $Sim(d_1, d_3) = \frac{1}{6} = 0.167$

Min-hashing

Para generar una representación compacta de las columnas de $M$ buscamos una función hash $h(C)$, que cumpla con 1) $h(C)$ es suficientemente pequeña para almacenarse en memoria, 2) $h(C)$ preserva la proporción de la similitud, es decir que si $Sim(C_1, C_2)$ es alta, entonces la probabilidad de que $h(C_1) = h(C_2))$ es alta y viceversa.

Una función de hash que preserva la similitud de Jaccard es la función minhash, definida como:

  • Dada una permutación aleatoria $\pi$ de los renglones de $M$
  • Se define el "minhash" de una columna, $h(C)$ como el primer renglón en la que la columna permutada $C_\pi$ contiene 1.

Para el ejemplo anterior dada la permutación $\pi = [b,e,a,c,f,d]$, los minhashes de los documentos $d_1$, $d_2$ y $d_3$ son: $h(d_1) = b$, $h(d_2) = e$ y $h(d_3) = e$.

$d_1$$d_2$$d_3$
b__1__00
e1__1____1__
a011
c110
f011
d001

¿Cuáles serían los minhashes de $d_1$, $d_2$ y $d_3$ dado $\pi = [c,d,a,f,b,e]$?

¿Por qué la función minhash preserva la similitud?

La razón por la que el método de min-hashing preserva la similitud entre dos conjuntos es que, dada una permutación aleatoria de renglones, la probabilidad de que la función minhash produzca el mismo valor para esos conjuntos es igual a su similitud de Jaccard.

$$Pr[h(C_1) = h(C_2)] = Sim(C_1, C_2)$$

La intuición es, que al permutar los renglones de los conjuntos los conjuntos que son más similares tendrán un mayor número de entradas con 1 en las mismas posiciones y por lo tanto una mayor probabilidad de que sus minhashes coincidan.

Para mostrar lo anterior: En el caso de dos columnas, cada renglón puede ser de uno de los siguientes tipos:

  • Tipo A, las dos columnas contienen 1
  • Tipo B, una de las columnas contiene 1 y la otra 0
  • Tipo C, las dos columnas contienen 0

Así pues la similitud de Jaccard es equivalente a $Sim(C_1, C_2) = \frac{\#a}{\#a + \#b}$, donde $\#a$ y $\#b$ representan el número de renglones de tipo A y B respectivamente.

Para el caso de Min-hashing, sabemos que el total de renglones que contienen al menos un $1$ es $\#a + \#b$ y que el número de renglones en que ambas columnas contienen $1$ es $\#a$. Si procedemos de arriba hacia abajo en ambas columnas, la probabilidad de observar un renglón de tipo A antes de uno de tipo B es $\frac{\#a}{\#a + \#b}$ esto es la similitud de Jaccard. Por lo tanto $Pr[h(C_1) = h(C_2)] = Sim(C_1, C_2)$. Nota: los renglones de tipo C no son tomados en cuenta ya que no afectan el calculo de $h(C)$.

Resumen de una columna

Es importante considerar que al aplicar una sola permutación a cada columna es posible obtener tanto falsos negativos como falsos positivos. Del el ejemplo anterior consideremos si la permutación es $\pi = [c,d,a,f,b,e]$ los minhashes de las columnas serían $h(d_1) = c$, $h(d_2) = c$ y $h(d_3) = d$, aún cuando sabemos que la similitud de Jaccard en mayor entre $d_2$ y $d_3$ que entre $d_1$ y $d_2$.

Es posible generar un resumen o sketch que permita aproximar mejor la similitud entre columnas aplicando la función Min-hash para $n$ distintas permutaciones (en general $n = 100$ es un buen número). Por la ley de los grandes números, la fracción de permutaciones en las que coinciden las funciones hash, aproxima adecuadamente la similitud de Jaccard.

$$Sim(C_1, C_2) \approx \frac{1}{n}\sum_{x=1}^n{I(h(C_1) = h(C_2))}$$

Es importante notar que para $n=100$ e incluso $n=1000$ se genera un espacio mucho menor que la cardinalidad de la union de los conjuntos originales.

¿Cuál sería la similitud aproximada utilizando los resúmenes dados $\pi = [b,e,a,c,f,d]$, $\pi = [c,d,a,f,b,e]$ y $\pi = [e,f,c,b,d,a]$?


In [9]:

Implementación

En términos prácticos implementar la permutación aleatoria de la matriz característica de millones de documentos es muy costoso. Una alternativa eficiente que genera resultados equivalentes es utilizar $K$ funciones hash distintas que simulen el orden de los renglones para cada permutación a partir de los valores mínimos en cada columna.

Esto es, aplicar cada una de las funciones hash $k_i$ a los elementos del conjunto $C$ y determinar cual es el valor mínimo de $k_i(C)$. Bajo el principio anteriormente descrito si dos columnas son similares, los valores mínimos bajo diferentes funciones hash (sus minhashes) tenderán a ser similares, y por tanto la similitud de Jaccard entre sus resúmenes será una buena aproximación a la similitud de Jaccard de las columnas originales.

Referencias: A. Broder'97 Syntactic Clustering of the Web


In [10]:
import hashlib
import random as pyrand
import pyhash
import sys

K = 100
rnd = pyrand.Random(11)
seeds = [rnd.randint(0,100000) for _ in range(K)]
hasher = pyhash.murmur3_32()

def signature(col):
    minhashes = [sys.maxint] * K
    for i in range(K):
        hashes = [hasher(_, seed=seeds[i]) for _ in col]
        minhashes[i] = min(hashes)
    return minhashes

Supongamos que tenemos una base de datos de documentos y queremos conocer, dado un nuevo documento si existe uno similar en la base de datos.

A manera de ejemplo tenemos una pequeña base de datos se compone de cuatro frases, a las cuales aplicamos el proceso arriba descrito:


In [11]:
cols = ['el perro persigue al gato', 'el gato persigue al perro', 'la vaca come pasto', 'el perro persigue al conejos']
# Generar las tejas
shingled = [shingle(_, 5) for _ in cols]
# Calcular la matriz de resúmenes
signatures = [signature(col) for col in shingled]

Cuando queremos comparar un nuevo documento podríamos comparar contra las tejas originales almacenadas en memoria (por ser un conjunto pequeño)


In [12]:
s = shingle('el perro persigue al conejo', 5)
[jaccard(set(s),set(_)) for _ in shingled]


Out[12]:
[0.62963, 0.41935, 0.00000, 0.95833]

A continuación utilizamos la matriz de resúmenes obteniendo resultados equivalentes.


In [13]:
[jaccard(set(signature(s)),set(_)) for _ in signatures]


Out[13]:
[0.47059, 0.29870, 0.00000, 0.85185]

¿Por qué cuando la similitud de Jaccard entre dos columnas es cero, min-hashing da siempre una estimación correcta?


In [13]:

Locality-Sensitive Hashing

En la sección anterior generamos resúmenes para cada conjunto de cardinalidad mucho menor que los conjuntos originales. Sin embrago para colecciones el orden de millones o miles de millones de documentos aún tenemos que comparar el resumen de cada nuevo documento con el resumen los documentos almacenados para determinar que tan similar es a cada uno de ellos. Esta tarea se vuelve casi imposible si queremos encontrar los elementos más similares entre todos los pares, ya que esto es un problema de orden $O(n^2)$.

Nuestro objetivo es solo calcular la similitud entre los pares más similares arriba de cierto umbral $t$ con el fin de reducir sustancialmente el tiempo requerido para calcular los conjuntos de documentos que son similares entre si. El marco teórico general para este enfoque es conocido como Locality Sensitive Hashing.

En el caso de Min-hashing la idea es aplicar una función hash a las columnas de minhash, de tal forma que los pares de documentos que tengan un hash común se consideren pares candidato.

Una forma eficiente de generar los pares candidato es dividir la matriz de resúmenes en $b$ bandas de $r$ renglones y mapear cada banda a $k$ cubetas (en nuestro caso volver a aplicar una función hash). Los pares candidato serán aquellos que coincidan a la misma cubeta en al menos una banda. Los parámetros $b$ y $r$ permiten ajustar la precisión del método.

La intuición de este método es que los hashes que coinciden en una cubeta fueron generados por columnas idénticas en esa banda, y que mientras más similares sean dos columnas es más probable que coincidan en al menos una banda.

Análisis

La probabilidad de que las firmas de dos documentos sean pares candidatos se deriva de la siguiente forma:

Recordemos que $Pr[h(C_1) = h(C_2)] = Sim(C_1, C_2)$, por lo tanto, si la similitud de Jaccard entre dos documentos es $s$, la probabilidad de que los minhashes entre esos documentos coincidan en un renglón de la matriz de resúmenes es $s$:

  • La probabilidad de que los minhashes coincidan en todos los renglones $r$ de una banda es $s^r$.
  • La probabilidad de que ninguno de los nuevos $b$ hashes coincida es $(1 - s^r)^b$.
  • Por lo tanto la probabilidad de que al menos uno de los nuevos hashes coincida es $ 1- (1 - s^r)^b$
Ejemplo

Dada una matriz de 100 renglones y una similitud del 80%, para $b = 20$ y $r = 5$, la probabilidad de que los 5 renglones de una banda coincidan $s^r$ es:


In [14]:
0.8 ** 5


Out[14]:
0.32768

Y de que no coincidan $(1 - s^r)$ es:


In [15]:
(1 - 0.8 ** 5)


Out[15]:
0.67232

Dado que tenemos 20 bandas de 5 renglones la probabilidad de que ninguna banda coincida $(1 - s^r)^b$ es:


In [16]:
(1 - 0.8 ** 5)**20


Out[16]:
0.00036

Es decir, aproximadamente 1 de cada 2500 pares de documentos que sean hasta 80% similares no serán pares candidato.

Finalmente, la probabilidad de que al menos una banda coincida $1 - (1 - s^r)^b$ para un par de documentos con similitud del 80% es:


In [17]:
1 - (1 - 0.8 ** 5)**20


Out[17]:
0.99964

Si graficamos la probabilidad de que las firmas de dos documentos sean pares candidatos para distintos grados de similitud podemos ver que se forma una curva con forma de S. En esta curva, la probabilidad de que un par de documentos sean pares candidatos será mayor a mayor similitud de Jaccard y menor a menor similitud de Jaccard.


In [18]:
%pylab inline


Populating the interactive namespace from numpy and matplotlib

In [19]:
s = arange(0,1,0.01)
y0 = 1 - (1 - s ** 5) ** 20
y1 = 1 - (1 - s ** 10) ** 10
y2 = 1 - (1 - s ** 2) ** 50

In [20]:
figure()
plot(s, y0, 'r')
plot(s, y1, 'g')
plot(s, y2, 'b')
xlabel('Similitud (Jaccard)')
ylabel('Probabilidad de ser candidato')
show()


Podemos observar que el umbral $t$ a partir del cual la probabilidad de ser pares candidato es mayor a $1/2$ es distinto para distintos parámetros de $b$ y $r$. Un valor apróximado del umbral es $(1/b)^{1/r}$.

Ejercicio: derivar por qué $(1/b)^{1/r}$ aproxima el umbral $t$ y comparar con los valores de $b$ y $r$ utilizados en la gráfica.


In [20]:

Para entender aprender más sobre LSH consulta el capítulo 3.6 del libro Mining of Massive Datasets

Implementación

Para implementar LSH, separamos las columnas de min-hashes en bandas y mandamos el hash de cada banda a la cubeta que le corresponde.


In [41]:
import hashlib
import random as pyrand
import pyhash
import sys

hasher = pyhash.murmur3_32()
rnd = pyrand.Random(11)
band_seed = rnd.randint(0,100000)

def signature2(col, b, r):
    K = b * r
    rnd = pyrand.Random(11)
    seeds = [rnd.randint(0,100000) for _ in range(K)]
    minhashes = [sys.maxint] * K
    for i in range(K):
        hashes = [hasher(_, seed=seeds[i]) for _ in col]
        minhashes[i] = min(hashes)
    return minhashes

def lsh(col, b, r):
    minhashes = signature2(col, b, r)
    bands = [] 
    for i in xrange(0, len(minhashes), r):
        band = str(minhashes[i:i + r]) 
        h = str(i) + '-' + str(hasher(band, seed=band_seed)) 
        bands.append(h)
    return bands

A fin de que los hashes de una banda no tengan colisiones con hashes de otras bandas prefijamos el hash con el número de banda.


In [32]:
lsh(shingle('el perro come carne',5),20,5)


Out[32]:
['0-1861235366',
 '5-2309783512',
 '10-2415092328',
 '15-1895517411',
 '20-2827795853',
 '25-805185373',
 '30-2446715809',
 '35-1253509979',
 '40-3256103102',
 '45-2641870886',
 '50-134433551',
 '55-1441343526',
 '60-266708131',
 '65-317375494',
 '70-1648295830',
 '75-368999501',
 '80-741240678',
 '85-3975053756',
 '90-2465026918',
 '95-3445897786']

In [34]:
lsh(shingle('el perro come carne fresca',5),20,5)


Out[34]:
['0-81722849',
 '5-1636405941',
 '10-2415092328',
 '15-2603777761',
 '20-3761706869',
 '25-1336805964',
 '30-3458342241',
 '35-1253509979',
 '40-3878603289',
 '45-4020910772',
 '50-1911978815',
 '55-3301073492',
 '60-2170549621',
 '65-317375494',
 '70-1648295830',
 '75-368999501',
 '80-1888752270',
 '85-2627202614',
 '90-1306860017',
 '95-551722307']

A continuación analizamos cuáles documentos son posibles candidatos en la base de datos que definimos anteriormente y comparamos con su similitud de Jaccard real para distintos valores de $b$ y $r$.


In [38]:
docs = ['el perro persigue al gato', 'el gato persigue al perro', 'la vaca come pasto', 'el perro persigue al conejos']

def test(new, b, r):
    t = (1 / float(b)) ** (1/ float(r))
    print 'Umbral ~ %d%%' % (t * 100)
    lshd = lsh(new, b, r)
    for doc in docs:
        shingled = shingle(doc, 5)
        hashed = lsh(shingled, b, r)
        print '> %s' % doc
        print ('\tjaccard: %.4f, posible candidato: %s') % \
                (jaccard(set(new), set(shingled)), 
                 len(set(lshd).intersection(set(hashed)))>0)   

comp = shingle('el perro persigue al conejo', 5)
test(comp, 20, 5)


Umbral ~ 54%
> el perro persigue al gato
	jaccard: 0.6296, posible candidato: True
> el gato persigue al perro
	jaccard: 0.4194, posible candidato: False
> la vaca come pasto
	jaccard: 0.0000, posible candidato: False
> el perro persigue al conejos
	jaccard: 0.9583, posible candidato: True

In [39]:
test(comp, 10, 10)


Umbral ~ 79%
> el perro persigue al gato
	jaccard: 0.6296, posible candidato: False
> el gato persigue al perro
	jaccard: 0.4194, posible candidato: False
> la vaca come pasto
	jaccard: 0.0000, posible candidato: False
> el perro persigue al conejos
	jaccard: 0.9583, posible candidato: True

In [40]:
test(comp, 50, 2)


Umbral ~ 14%
> el perro persigue al gato
	jaccard: 0.6296, posible candidato: True
> el gato persigue al perro
	jaccard: 0.4194, posible candidato: True
> la vaca come pasto
	jaccard: 0.0000, posible candidato: False
> el perro persigue al conejos
	jaccard: 0.9583, posible candidato: True

El número de bandas y funciones hash se puede resolver numéricamente utilizando la siguiente función.


In [27]:
# Adapted from 
    # https://github.com/twitter/algebird/blob/master/algebird-core/src/main/scala/com/twitter/algebird/MinHasher.scala
    # Numerically solve the inverse of threshold, given numBands*numRows
 
    def pick_bands(threshold, hashes):
        target = hashes * -1 * math.log(threshold)
        bands = 1
        while bands * math.log(bands) < target:
          bands += 1
        return bands

    def pick_hashes_and_bands(threshold, max_hashes):
        bands = pick_bands(threshold, max_hashes)
        hashes = (max_hashes / bands) * bands
        return (hashes, bands)

In [28]:
pick_hashes_and_bands(0.9, 100)


Out[28]:
(96, 6)

Tarea

Para realmente aprovechar la eficiencia del método descrito es importante almacenar los valores generados por LSH en un índice de tal forma que podamos encontrar los documentos similares en tiempo menor a $O(N)$.

La tarea consiste en implementar los métodos de la clase LSH. Esta clase se construye con el umbral deseado y el tamaño de los shingles y tiene dos métodos create_db(self, docs) para inicializar la base de datos y similar(self, doc) que regresa una lista con los ids de los documentos similares.

La clase debe implementar la versión eficiente de LSH, donde primero se obtienen solo aquellos documentos que son candidatos y sobre ellos se prueba si su similitud real es mayor al umbral. Se sugiere utilizar los métodos definidos en la lección para llevar a cabo el ejercicio.

Tip: Es posible implementar un multimap (donde cada llave mapea a una lista) utilizando:

from collections import defaultdict
multimap = defaultdict(list)

In [29]:
from collections import defaultdict

class LSH(object):
    
    def __init__(self, threshold, k):
        self.k = k
        hashes, bands = pick_hashes_and_bands(threshold, 100)
        self.b = bands
        self.r = hashes / bands

    def create_db(self, docs):
        pass

    def similar(self, doc):
        return []

docs = {1:'el perro persigue al gato', 
        2:'el gato persigue al perro', 
        3:'la vaca come pasto', 
        4:'el perro persigue al conejos'}

lsher = LSH(0.9, 5)
lsher.create_db(docs)
lsher.similar('el perro persigue al conejo')


Out[29]:
[]

Nota 1

Esta nota muesta la implementación de una función hash para el tipo String como está implementada en algunas versiones de python, http://effbot.org/zone/python-hash.htm.

Es muy importante notar que esta no es una función hash de tipo criptográfico, para más información ver http://en.wikipedia.org/wiki/Cryptographic_hash_function.


In [30]:
def c_mul(a, b):
    #C type multiplication
    return eval(hex((int(a) * b) & 0xFFFFFFFF)[:-1])

def simple_hash(string):
    h = ord(string[0]) << 7
    for char in string:
        h = c_mul(1000003, h) ^ ord(char)
    h = h ^ len(string)
    return h

print simple_hash('Esta nota muesta la implementación de una función hash para string como se implementa en python.')


144283000