Autor

Alejandro Alcalde

Graduado en Ingeniería Informática en la ETSIIT, Granada. Creador de El Baúl del Programador

Más artículos de Alejandro Alcalde

¿Has visto algún error?: Por favor, ayúdame a corregirlo contactando conmigo.

Hace un tiempo quería mostrar artículos similares / relacionados al final de cada artículo de este blog. Al momento de plantear este problema, Hugo no tenía soporte para esta característica, hoy día sí. Para ello decidí implementar mi propio sistema usando python, sklearn y Clustering.

Diseño del programa

Leer y Parsear los artículos

Ya que escribo tanto en Inglés y Español, necesito entrenar el modelo dos veces, para poder mostrar artículos relacionados en inglés a los lectores ingleses, y en Castellano a los Hispanos. La función readPosts se encarga de esto. Recibe como argumentos el directorio donde se encuentran los artículos, y un booleano indicando si quiero leer los escritos en inglés o castellano.

dfEng = readPosts('blog/content/post',
                  english=True)
dfEs = readPosts('blog/content/post',
                 english=False)

Dentro de esta función (puedes consultarla en mi github), leo los artículos y devuelvo un Data Frame de Pandas. Lo más relevante que hace esta función es seleccionar el parser correcto, para abrir los ficheros usando el parseador de yaml o el de TOML. Una vez leido el frontmatter, readPosts crea el DataFrame usando estos metadatos. En concreto solo se queda con estos:

tags = ('title', 'tags', 'introduction', 'description')

Lo cual significa, que usaremos esta información para clasificar los posts.


¿Te gusta el blog? Ayúdame a seguir escribiendo


Selección del Modelo

Como dije al principio, decidí usar Clustering. Ya que estoy tratando con datos de texto, necesito una forma de convertir esta información a forma numérica. Para conseguirlo se usa una técnica llamada TF-IDF (Term frequency – Inverse document frequency). No entraré en los detalles, pero daré una pequeña introducción.

¿Qué es TF-IDF? (frecuencia de término – frecuencia inversa de documento)

Cuando se trabaja con datos de texto, muchas palabras aparecen muchas veces en distintos ducumentos pertenecientes a distintas clases, dichas palabras no suelen contener información discriminatoria. TF-IDF se encarga de rebajar el peso que tienen estos términos en los datos, para que no influyan en la clasificación.

tf-idf se define como el producto de:

Al multiplicar ambos, obtenemos el tf-idf, citando Wikipedia:

Un peso alto en tf-idf se alcanza con una elevada frecuencia de término (en el documento dado) y una pequeña frecuencia de ocurrencia del término en la colección completa de documentos. Como el cociente dentro de la función logaritmo del idf es siempre mayor o igual que 1, el valor del idf (y del tf-idf) es mayor o igual que 0. Cuando un término aparece en muchos documentos, el cociente dentro del logaritmo se acerca a 1, ofreciendo un valor de idf y de tf-idf cercano a 0.

En resumen, conforme más común es un término entre todos los documentos, menor será el valor tf-idf, lo cual indica que esa palabra no es importante para la clasificación.

Hiperparámetros

Para seleccionar los parámetros apropiados para el modelo he usado el método GridSearchCV de sklearn, puedes verlo en la línea 425 del código.

Limpiando los datos

Con el método a usar (clustering) y teniendo los datos de texto en formato numérico (TF-IDF), ahora toca limpiar los datos. Cuando se trabaja con datos de texto, es muy frecuente eliminar lo que se denominan stop words, palabras que no añaden significado alguno (el, la, los, con, a, eso...). Para ello creo la función generateTfIdfVectorizer. Esta misma función se encarga de realizar el stemming. De Wikipedia, Stemming es el proceso de:

reducir una palabra a su raíz o (en inglés) a un stem.

Dependiendo de en qué idioma esté generando los artículos relacionados (inglés o Castellano) uso:

def tokenizer_snowball(text):
    stemmer = SnowballStemmer("spanish")
    return [stemmer.stem(word) for word in text.split()
            if word not in stop]

para Castellano o

def tokenizer_porter(text):
    porter = PorterStemmer()
    return [porter.stem(word) for word in text.split()
            if word not in stop]

para inglés.

Tras este proceso, finalmente tengo todos los datos listos para aplicar clustering.

Clustering

He usado KMeans para realizar el clustering. La mayor carga de trabajo de este proceso era limpiar los datos, así que este paso es sencillo de programar. Solo es necesario saber cuantos clusters debería tener. Para ello he usado un método llamado Elbow Method (El método del codo). Sirve para hacernos una idea del valor óptimo de k (Cuantos clusters). El metodo nos indica cuando la distorsión entre clusters empieza a aumentar rápidamente. Se muestra mejor con una imagen:

En este ejemplo, se aprecia un codo en k=12

Tras ejecutar el modelo, usando 16 características, estas son las seleccionadas para Catellano:

[u'andro', u'comand', u'curs', u'dat', u'desarroll',
u'funcion', u'googl', u'jav', u'libr', u'linux',
u'program', u'python', u'recurs', u'script',
u'segur', u'wordpress']

y para inglés:

[u'blogs', u'chang', u'channels', u'curat', u'error',
u'fil', u'gento',u'howt', u'list', u'lists', u'podcasts',
u'python', u'scal', u'scienc', u'script', u'youtub']

Cómo intregrar el resultado con Hugo

Esta parte me llevó bastante tiempo ya que es necesario leer el resultado del modelo, en formato CSV, y mostrar 10 artículos del mismo cluster. Aunque ya no estoy usando este método (ahora uso el propio de Hugo), lo dejo por aquí como referencia:

{{ $url := string (delimit (slice "static/" "labels." .Lang ".csv" ) "") }}
{{ $sep := "," }}
{{ $file := string .File.LogicalName }}

{{/* First iterate thought csv to get post cluster */}}
{{ range $i, $r := getCSV $sep $url }}
   {{ if in $r (string $file) }}
       {{ $.Scratch.Set "cluster" (index . 1) }}
   {{ end }}
{{ end }}

{{ $cluster := $.Scratch.Get "cluster" }}

{{/* loop csv again to store post in the same cluster */}}
{{ range $i, $r := getCSV $sep $url }}
    {{ if in $r (string $cluster) }}
        {{ $.Scratch.Add "posts" (slice $r) }}
    {{ end }}
{{ end }}

{{ $post := $.Scratch.Get "posts" }}

{{/* Finally, show 5 randomly related posts */}}
{{ if gt (len $post) 1 }}
    <h1>{{T "related" }}</h1>
    <ul>
    {{ range first 5 (shuffle $post) }}
        <li><a id="related-post"  {{ printf "href=%q" ($.Ref (index . 2)) | safeHTMLAttr }} {{ printf "title=%q" (index . 3) | safeHTMLAttr }}>{{ index . 3 }}</a></li>
    {{ end }}
    </ul>
{{ end }}

Si tienes algún comentario, o quiere mejorar algo, comenta abajo.

Referencias

Quizá también te interese leer...