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.
In [2]:
%precision 5
Out[2]:
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]:
La distancia de Jaccard se define como: $dist(C_1, C_2) = 1 - Sim(C_1, C_2)$
Dada una colección de millones de documentos, encontrar pares que sean casi duplicados.
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)
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]:
k
grande de otra forma todos los documentos contendrían las mismas shingles (el caso extremo es k=1
).k = 5
es razonable para documentos chicos, k = 10
es mejor para documentos grandes.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.')
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]:
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.
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$ | |
---|---|---|---|
a | 0 | 1 | 1 |
b | 1 | 0 | 0 |
c | 1 | 1 | 0 |
d | 0 | 0 | 1 |
e | 1 | 1 | 1 |
f | 0 | 1 | 1 |
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$
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:
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__ | 0 | 0 |
e | 1 | __1__ | __1__ |
a | 0 | 1 | 1 |
c | 1 | 1 | 0 |
f | 0 | 1 | 1 |
d | 0 | 0 | 1 |
¿Cuáles serían los minhashes de $d_1$, $d_2$ y $d_3$ dado $\pi = [c,d,a,f,b,e]$?
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:
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)$.
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]:
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]:
A continuación utilizamos la matriz de resúmenes obteniendo resultados equivalentes.
In [13]:
[jaccard(set(signature(s)),set(_)) for _ in signatures]
Out[13]:
¿Por qué cuando la similitud de Jaccard entre dos columnas es cero, min-hashing da siempre una estimación correcta?
In [13]:
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.
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$:
In [14]:
0.8 ** 5
Out[14]:
Y de que no coincidan $(1 - s^r)$ es:
In [15]:
(1 - 0.8 ** 5)
Out[15]:
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]:
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]:
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
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
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]:
In [34]:
lsh(shingle('el perro come carne fresca',5),20,5)
Out[34]:
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)
In [39]:
test(comp, 10, 10)
In [40]:
test(comp, 50, 2)
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]:
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]:
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.')