Lección 2 - Similitud entre conjuntos, escalando con PySpark

PySpark y similitud entre conjuntos

Objetivo

El objetivo de esta lección es mostrar como escalar los métodos de minhashing aprendidos en la lección anterior utilizando cómputo distribuido sobre Spark.

Dependencias

Para esta lección necesitamos configurar iPython para encontrar las librerías de PySpark.

Editar en archivo $HOME/.ipython/profile_default/startup/00-pyspark-setup.py

# Configure the necessary Spark environment
import os
os.environ['SPARK_HOME'] = '[path-to-spark-home]/'

# And Python path
import sys
sys.path.insert(0, '[path-to-spark-home]/python')

Descomprimir el archivo data/wiki-100000.zip en el directorio data.

PySpark

Empezemos por entender un poco de Spark. Spark es una palataforma de computo distribido que permite hacer análsis de datos de una forma ágil, tanto para escribrlo como para ejecutarlo.

El concepto central en Spark es una colección distribuida de objetos llamad RDD (Resilient Distributed Dataset), estos pueden ser creados a partir de archivos o a partir de la transformación de otros RDD.

Nota: Se puede utilizar PySpark desde la línea de comandos utilizando el comando pyspark

El primer paso es inicializar el contexto de Spark (este paso no es necesario si se utiliza la consola de PySpark)


In [1]:
from pyspark import SparkContext
sc = SparkContext("local", "Minhashing", pyFiles=['lsh.py'])

A continuación leemos un archivo que contiene 100 mil entradas de artículos en Wikipedia con su clasificación y mostramos sus primeros 10 renglones.

Nota: Los archivos originales están disponibles en la DBpedia, bajo la licencia Creative Commons Attribution-ShareAlike License


In [2]:
f = sc.textFile("data/wiki-100000.txt")
f.take(10)


Out[2]:
[u'# 2012-06-04T11:00:11Z',
 u'Autism Autism',
 u'Autism Communication_disorders',
 u'Autism Mental_and_behavioural_disorders',
 u'Autism Neurological_disorders',
 u'Autism Neurological_disorders_in_children',
 u'Autism Pervasive_developmental_disorders',
 u'Autism Psychiatric_diagnosis',
 u'Autism Learning_disabilities',
 u'Anarchism Anarchism']

Podemos observar que los renglones continen dos partes: el nombre del artículo y la categoría a la que pertencen. A continuación dividimos cada renglón en sus componentes utilizando operaciones estandar sobre un RDD, en este caso map y filter.


In [3]:
filtered = f.filter(lambda line: not line.startswith('#'))
split = filtered.map(lambda line: line.split(" "))
print split


<pyspark.rdd.PipelinedRDD object at 0x10367da10>

Este segmento de código nos muestra varias cosas interesantes, la primera es que estamos pasando funciones lambda a los métodos, para que sean aplicadas a cada renglón del RDD.

La segunda es que al aplicar los métodos sobre un RDD obtenemos de nuevo una estructura RDD. En Spark, la mayoría de las funciones sobre un RDD no ejecutan el contenido sino que lo agregan al grafo de ejecución, y solamente cuando se llaman a funciones como take o collect es que se ejectua dicho grafo.


In [4]:
split.take(10)


Out[4]:
[[u'Autism', u'Autism'],
 [u'Autism', u'Communication_disorders'],
 [u'Autism', u'Mental_and_behavioural_disorders'],
 [u'Autism', u'Neurological_disorders'],
 [u'Autism', u'Neurological_disorders_in_children'],
 [u'Autism', u'Pervasive_developmental_disorders'],
 [u'Autism', u'Psychiatric_diagnosis'],
 [u'Autism', u'Learning_disabilities'],
 [u'Anarchism', u'Anarchism'],
 [u'Anarchism', u'Political_culture']]

A continuación asociaremos a cada artículo las categorías a las que pertence, utilizando la función groupByKey, en este caso se considera el primer elemento de cada renglón como la llave.


In [5]:
articles = split.groupByKey()
articles.take(5)


Out[5]:
[(u'Rage_Hard',
  [u'Frankie_Goes_to_Hollywood_songs',
   u'1986_singles',
   u'Number-one_singles_in_Germany',
   u'Songs_produced_by_Stephen_Lipson']),
 (u'Diogo_C%C3%A3o',
  [u'1450s_births',
   u'15th-century_deaths',
   u'People_from_Vila_Real_Municipality',
   u'Portuguese_explorers',
   u'Explorers_of_Africa',
   u'15th_century_in_Africa',
   u'15th-century_explorers',
   u'Navigators',
   u'Maritime_history_of_Portugal',
   u'15th-century_Portuguese_people',
   u'Age_of_Discovery']),
 (u'Dhrystone', [u'Computer_benchmarks', u'1984_introductions']),
 (u'Dubbing_(filmmaking)', [u'Dubbing_(filmmaking)']),
 (u'Szczecin',
  [u'Szczecin',
   u'Port_cities_and_towns_in_Poland',
   u'Port_cities_and_towns_of_the_Baltic_Sea',
   u'City_counties_of_Poland',
   u'Cities_and_towns_in_West_Pomeranian_Voivodeship'])]

El ejemplo anterior nos muestra algunas de las operaciones básicas que se puden realizat con Spark. En la siguiente sección vamos a retomar el ejemplo de Min-hashing y aplicarlo para encontrar artículos similares.

Similitud entre conjuntos, modelo distribuido

Aplicando el modelo LSH aprendido en la lección anterior, aplicamos la función lsh a cada uno de los conjuntos que definen los temas de cada artículo.


In [6]:
from lsh import LSH, get_conf

conf = get_conf(0.90)

lsh = articles.map(lambda _: (_[0], LSH(conf).lsh(_[1])))
lsh.take(2)


Out[6]:
[(u'Rage_Hard',
  ['0-1698531787',
   '16-674338419',
   '32-294784065',
   '48-2132353950',
   '64-2922530270',
   '80-3092529889']),
 (u'Diogo_C%C3%A3o',
  ['0-3652685019',
   '16-1510720539',
   '32-973807287',
   '48-2397071194',
   '64-2239725734',
   '80-1219044189'])]

La clase LSH y el método get_conf están definidos en el archivo lsh.py.

Para probar la aplicación de este método creamos un mapa con las firmas a las firmas asociadas a cada artículo.


In [7]:
def entry(data):
    id = data[0]
    return [(sig, id) for sig in data[1]]

signatures = lsh.flatMap(lambda _: entry(_)).groupByKey()\
    .filter(lambda _: len(_[1]) > 1).cache()
signatures.take(5)


Out[7]:
[('16-1782177572',
  [u'Book_of_Habakkuk',
   u'Book_of_Micah',
   u'Book_of_Malachi',
   u'Book_of_Zephaniah',
   u'Book_of_Haggai',
   u'Book_of_Nahum',
   u'Book_of_Zechariah']),
 ('80-2966173331', [u'KAOS', u'KSL', u'Word_(disambiguation)']),
 ('16-1719549260',
  [u'Biconditional_introduction', u'Biconditional_elimination']),
 ('32-3331037536', [u'Intercalation', u'Calendar', u'Calendar_date']),
 ('48-2472318991', [u'Uncountable_set', u'Countable_set'])]

En este ejemplo es posible observar como artículos que contienen las mismas categorías se agrupan en una sola cubeta. A continuación obtenemos el conjunto de artículos similares a un artículo dado a partir del valor de sus firmas.


In [22]:
db_90 = signatures.collectAsMap()
index = articles.collectAsMap()

def similar(doc, db, conf):
    hashes = LSH(conf).lsh(doc)
    candidates = set()
    for sig in hashes:
        candidates.update(set(db[sig]))
    for sim in candidates:
        print '%s -> %s' % (sim, index[sim])

doc = index['Third_Epistle_of_John']
similar(doc, db_90, conf)


First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']

In [23]:
def create_lsh(threshold, dataset):
    conf_s = get_conf(threshold)
    lsh_s = dataset.map(lambda _: (_[0], LSH(conf_s).lsh(_[1])))
    db_s = lsh_s.flatMap(lambda _: entry(_)).groupByKey()\
        .filter(lambda _: len(_[1]) > 1).collectAsMap()
    return (conf_s, db_s)

In [24]:
doc = index['Third_Epistle_of_John']
conf_s70, db_s70 = create_lsh(0.7, articles)

In [25]:
similar(doc, db_s70, conf_s70)


Epistle_of_James -> [u'New_Testament_books', u'Canonical_epistles']
Second_Epistle_of_John -> [u'New_Testament_books', u'Anti-Gnosticism', u'Canonical_epistles', u'Johannine_literature']
Epistle_of_Jude -> [u'New_Testament_books', u'Canonical_epistles']
First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']

In [28]:
doc = index['Third_Epistle_of_John']
conf_s40, db_s40 = create_lsh(0.4, articles)

In [32]:
similar(doc, db_s40, conf_s40)


Second_Epistle_of_John -> [u'New_Testament_books', u'Anti-Gnosticism', u'Canonical_epistles', u'Johannine_literature']
Epistle_to_the_Romans -> [u'50s_books', u'Canonical_epistles', u'New_Testament_books', u'Pauline-related_books', u'Epistle_to_the_Romans']
First_Epistle_of_Peter -> [u'New_Testament_books', u'Petrine-related_books', u'Canonical_epistles']
First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Second_Epistle_of_Peter -> [u'New_Testament_books', u'Petrine-related_books', u'Canonical_epistles']
Epistle_of_James -> [u'New_Testament_books', u'Canonical_epistles']
Epistle_of_Jude -> [u'New_Testament_books', u'Canonical_epistles']

In [30]:
print len(db_90), len(db_s70), len(db_s40)


1604 4197 16293

A continuación comparamos la precisión de el método de LSH con el método directo utilizando similitud de Jaccard.


In [18]:
alt = split.map(lambda _: (_[1], _[0])).groupByKey().collectAsMap()

from lsh import jaccard

def alt_similar(doc, db, threshold):
    candidates = set()
    for cat in doc:
        candidates.update(set(db[cat]))
    for sim in candidates:
        if jaccard(set(doc), set(index[sim])) > threshold:
            print '%s -> %s' % (sim, index[sim])

len(alt)


Out[18]:
46759

In [33]:
doc = index['Third_Epistle_of_John']
alt_similar(doc, alt, 0.9)


Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']

In [15]:
doc = index['Third_Epistle_of_John']
alt_similar(doc, alt, 0.7)


Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Second_Epistle_of_John -> [u'New_Testament_books', u'Anti-Gnosticism', u'Canonical_epistles', u'Johannine_literature']

In [16]:
doc = index['Third_Epistle_of_John']
alt_similar(doc, alt, 0.4)


First_Epistle_of_Peter -> [u'New_Testament_books', u'Petrine-related_books', u'Canonical_epistles']
Third_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Epistle_of_James -> [u'New_Testament_books', u'Canonical_epistles']
First_Epistle_of_John -> [u'New_Testament_books', u'Canonical_epistles', u'Johannine_literature']
Epistle_of_Jude -> [u'New_Testament_books', u'Canonical_epistles']
Second_Epistle_of_John -> [u'New_Testament_books', u'Anti-Gnosticism', u'Canonical_epistles', u'Johannine_literature']
Second_Epistle_of_Peter -> [u'New_Testament_books', u'Petrine-related_books', u'Canonical_epistles']

Podemos observar en los resultados que el método LSH efectivamente genero falsos positivos que estaban por arriba del umbral de similitud.