Extracción de términos relevantes

En este artículo se abordará la extracción del significado de un documento desde un punto de vista estadístico. Y se hará uso de librerías de python como BeautifulSoup, nltk, spacy, collections y pandas. El programa completo puede visualizarse aquí

No todas las palabras de un texto son igualmente relevantes para determinar su significado general ¿es suficiente determinar la frecuencia de un término para determinar su relevancia? No, la frecuencia no puede ser el criterio primario ya que existen palabras en la lengua denominadas palabras vacías o stopwords, de las que forman parte las gramaticales y las preposiciones, que son las más frecuentes mientras que las palabras de la categoría léxica son las menos frecuentes en la lengua. Hace falta pues otro parámetro: interesan las que sean más específicas a ese texto.

Definiremos pues la relevancia de un término como el producto de su frecuencia en un texto por su especificidad. Las palabras vacías tienden a una especificidad cero, con lo que su relevancia es nula sin importar cuantas veces aparezcan en un texto. Pero, ¿cómo definimos la especificidad? Depende: si estamos considerando un texto aislado, consideraremos más específicos los términos menos frecuentes de la lengua para lo cual disponemos de tablas de frecuencias; si, por otra parte, manejamos un documento dentro de una colección y queremos caracterizarlo respecto a los demás, serán más específicos los términos que aparezcan menos en el resto de documentos (para más información léase el epígrafe tf·idf más adelante).

Extracción de artículos de la wikipedia (con BeautifulSoup)

La siguiente sección del programa va a extraer información de varios artículos de la web de Wikipedia (átomo, célula, cerebro, CPU, ciudad y Sol). El algoritmo escogerá los párrafos dentro del contenido de la página. Se proporcionan dos funciones: una extrae todo el artículo y otra sólo la introducción (identificada como la parte previa a la tabla de contenido)

import requests
from bs4 import BeautifulSoup
import re


def extract_wikipedia_article(url):
    page = requests.get(url)
    soup = BeautifulSoup(page.content, features='lxml')

    content_text = soup.find(id='mw-content-text')
    children = content_text.find_all('p')
    text = '\n'.join(_.get_text() for _ in children)
    text = re.sub(r'\[\d+\]|​', '', text)
    return text


def extract_wikipedia_intro(url):
    page = requests.get(url)
    soup = BeautifulSoup(page.content, features='lxml')

    toc = soup.find(id='toc')
    if toc:
        children = []
        node = toc
        while node.find_previous_sibling('p'):
            node = node.find_previous_sibling('p')
            children.append(node)
        children = children[::-1]
    else:  # no toc found -> extract all paragraphs
        children = soup.findChildren("p", recursive=False)

    text = '\n'.join(_.get_text() for _ in children)
    text = re.sub(r'\[\d+\]|​| ', '', text)
    return text


documents_urls = [
    'https://es.wikipedia.org/wiki/%C3%81tomo',
    'https://es.wikipedia.org/wiki/C%C3%A9lula',
    'https://es.wikipedia.org/wiki/Cerebro',
    'https://es.wikipedia.org/wiki/Unidad_central_de_procesamiento',
    'https://es.wikipedia.org/wiki/Ciudad',
    'https://es.wikipedia.org/wiki/Sol',
]

documents_text = [extract_wikipedia_article(_) for _ in documents_urls]
print(documents_text)

El resultado es:

documents_text

Tokenización y lematización (con Spacy)

Segmentar o tokenizar es el acto por el cuál segmentamos el texto en unidades menores (tokens). Generalmente se basa en el análisis de la gramática tradicional que considera a la palabra como unidad lingüística.

Este torpe análisis hace que no se analicen palabras constituidas por varios morfemas (del=de el, o cantaba=cant aba, verte=ver te) .

La lematización consiste en asociar una forma de la lengua a una clase o lema prescindiendo de la morfología flexiva presente en las clases de palabras variables (determinantes, nombres, adjetivos, verbos). El lema se hace manifiesto mediante un representante, que es una de sus formas, denominado forma de cita, que es el término definido que vemos en los diccionarios. La forma de cita en los nombres es la forma singular (casa <- casas), y en los verbos en español es el infinitivo (cantar <- cantaba) aunque en otras lenguas como el vasco es el participio y en árabe es la tercera persona masculina singular del aspecto perfecto (por ser la forma más sencilla de la conjugación árabe).

import spacy


def get_lemmas(text):
    """
    Removes punctuation and function affixes; lexemes and derivational affixes remain
    :param text: text to tokenize
    :return: tokens
    """
    doc = nlp(text)
    # import explacy
    # explacy.print_parse_info(nlp, text)
    return [token.lemma_ for token in doc if token.pos_ not in ['PUNCT', 'SPACE']]


def get_lemmatized_sentences(text):
    """
    Removes punctuation and function affixes; lexemes and derivational affixes remain

    :param text: text to tokenize
    :return: tokenized sentences
    """
    doc = nlp(text)
    lemmatized_sentences = [[]]
    for token in doc:
        if token.pos_ == 'SPACE':  # aparte
            lemmatized_sentences.append([])  # new sentence
        elif token.pos_ != 'PUNCT':
            lemma = token.lemma_
            lemmatized_sentences[-1].append(lemma)
    return lemmatized_sentences


# NOTA: existen diversos modelos para el NLP aparte del utilizado a continuación
nlp = spacy.load("es_dep_news_trf")

documents_lemmas = [get_lemmas(_) for _ in documents_text]
print(documents_lemmas)

El resultado es:

documents_lemmas

Descarte de palabras poco específicas

Las palabras más frecuentes de la lengua pueden consultarse, por ejemplo, en wiktionary. Pueden presentarse como listas de formas o lemas. Al ser obtenidas de corpus con un gran número de palabras son muy similares al menos en los términos maś frecuentes.

Sin embargo existen diferentes listas de palabras vacías o stopwords. La lista de nltk puede extraerse en python de la siguiente manera:

from nltk.corpus import stopwords
stopwords = stopwords.words('spanish')
print(stopwords)

obtenemos la siguiente lista:

['de', 'la', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'este', 'sí', 'porque', 'esta', 'entre', 'cuando', 'muy', 'sin', 'sobre', 'también', 'me', 'hasta', 'hay', 'donde', 'quien', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'ese', 'eso', 'ante', 'ellos', 'e', 'esto', 'mí', 'antes', 'algunos', 'qué', 'unos', 'yo', 'otro', 'otras', 'otra', 'él', 'tanto', 'esa', 'estos', 'mucho', 'quienes', 'nada', 'muchos', 'cual', 'poco', 'ella', 'estar', 'estas', 'algunas', 'algo', 'nosotros', 'mi', 'mis', 'tú', 'te', 'ti', 'tu', 'tus', 'ellas', 'nosotras', 'vosotros', 'vosotras', 'os', 'mío', 'mía', 'míos', 'mías', 'tuyo', 'tuya', 'tuyos', 'tuyas', 'suyo', 'suya', 'suyos', 'suyas', 'nuestro', 'nuestra', 'nuestros', 'nuestras', 'vuestro', 'vuestra', 'vuestros', 'vuestras', 'esos', 'esas', 'estoy', 'estás', 'está', 'estamos', 'estáis', 'están', 'esté', 'estés', 'estemos', 'estéis', 'estén', 'estaré', 'estarás', 'estará', 'estaremos', 'estaréis', 'estarán', 'estaría', 'estarías', 'estaríamos', 'estaríais', 'estarían', 'estaba', 'estabas', 'estábamos', 'estabais', 'estaban', 'estuve', 'estuviste', 'estuvo', 'estuvimos', 'estuvisteis', 'estuvieron', 'estuviera', 'estuvieras', 'estuviéramos', 'estuvierais', 'estuvieran', 'estuviese', 'estuvieses', 'estuviésemos', 'estuvieseis', 'estuviesen', 'estando', 'estado', 'estada', 'estados', 'estadas', 'estad', 'he', 'has', 'ha', 'hemos', 'habéis', 'han', 'haya', 'hayas', 'hayamos', 'hayáis', 'hayan', 'habré', 'habrás', 'habrá', 'habremos', 'habréis', 'habrán', 'habría', 'habrías', 'habríamos', 'habríais', 'habrían', 'había', 'habías', 'habíamos', 'habíais', 'habían', 'hube', 'hubiste', 'hubo', 'hubimos', 'hubisteis', 'hubieron', 'hubiera', 'hubieras', 'hubiéramos', 'hubierais', 'hubieran', 'hubiese', 'hubieses', 'hubiésemos', 'hubieseis', 'hubiesen', 'habiendo', 'habido', 'habida', 'habidos', 'habidas', 'soy', 'eres', 'es', 'somos', 'sois', 'son', 'sea', 'seas', 'seamos', 'seáis', 'sean', 'seré', 'serás', 'será', 'seremos', 'seréis', 'serán', 'sería', 'serías', 'seríamos', 'seríais', 'serían', 'era', 'eras', 'éramos', 'erais', 'eran', 'fui', 'fuiste', 'fue', 'fuimos', 'fuisteis', 'fueron', 'fuera', 'fueras', 'fuéramos', 'fuerais', 'fueran', 'fuese', 'fueses', 'fuésemos', 'fueseis', 'fuesen', 'sintiendo', 'sentido', 'sentida', 'sentidos', 'sentidas', 'siente', 'sentid', 'tengo', 'tienes', 'tiene', 'tenemos', 'tenéis', 'tienen', 'tenga', 'tengas', 'tengamos', 'tengáis', 'tengan', 'tendré', 'tendrás', 'tendrá', 'tendremos', 'tendréis', 'tendrán', 'tendría', 'tendrías', 'tendríamos', 'tendríais', 'tendrían', 'tenía', 'tenías', 'teníamos', 'teníais', 'tenían', 'tuve', 'tuviste', 'tuvo', 'tuvimos', 'tuvisteis', 'tuvieron', 'tuviera', 'tuvieras', 'tuviéramos', 'tuvierais', 'tuvieran', 'tuviese', 'tuvieses', 'tuviésemos', 'tuvieseis', 'tuviesen', 'teniendo', 'tenido', 'tenida', 'tenidos', 'tenidas', 'tened']

Como puede observarse la lista contiene:

Utilicemos pues esta lista para eliminar las palabras vacías:

documents_filtered = [[token for token in document if not token.lower() in stopwords] for document in documents_lemmas]
print(documents_filtered)

El resultado es:

documents_filtered

Relevancia tf·idf (frecuencia de término por inversa de documento)

Para hallar los términos más específicos de una colección de documentos utilizamos el cálculo tf·idf (frecuencia de término – frecuencia inversa de documento). tf es la frecuencia del término y tiene la potencialidad de dirigir al tópico del texto al multiplicarse con idf, que es la especificidad, que se calcula como la inversa de la presencia del término nt en el conjunto de documentos.

idf
fórmula de la frecuencia inversa de documento

Si el término está presente en pocos será más específica; si lo está en muchos será poco específica. Si tenemos N=60 documentos y ese término se halla en los 60, es decir, nt=60 luego idf será cero, si sólo aparece en uno, nt=1, será máxima. La función puede visualizarse a continuación, donde el eje de abscisas representa el porcentaje de documentos en los que aparece el término (avanza hacia la menor especificidad)

Plot IDF functions.png
Mquantin, CC BY-SA 4.0, via Wikimedia Commons

Para empezar determinemos la frecuencia de los términos de cada documento:

import collections
import pandas as pd

documents_frequencies = [collections.Counter(_) for _ in documents_filtered]
documents_frequencies

Los diccionarios de frecuencias de lemas así creados pueden ser exportados convenientemente con pandas a un dataframe, en el que las claves son las columnas.

df = pd.DataFrame(documents_frequencies)

El resto consiste en operar mediante pandas con el objeto dataframe para realizar los cálculos. Lo empaquetamos en una función:

import math
import collections
import pandas as pd


def tfidf(documents_tokens, max_results=1000, threshold=0):
    documents_frequencies = [collections.Counter(_) for _ in documents_tokens]
    df = pd.DataFrame(documents_frequencies)
    length = len(df)

    for col in df:
        counts = df[col].count()
        df[col] = df[col] * math.log(length / counts)

    tfidf_weights = [row_object[row_object.values > threshold].sort_values(ascending=False).head(max_results).to_dict()
                     for row, row_object in df.iterrows()]
    [print(index, len(_), _) for index, _ in enumerate(tfidf_weights)]

    return df, tfidf_weights

Podemos asimismo exportar el proceso de cálculo a una hoja de cálculo completando la función anterior:

"""
pip install xlsxwriter
pip install xlwt
"""
import math
import collections
import pandas as pd


def get_tfidf(documents_tokens, max_results=1000, threshold=0, save_as=None):
    documents_frequencies = [collections.Counter(_) for _ in documents_tokens]
    df = pd.DataFrame(documents_frequencies)
    length = len(df)

    rs = pd.DataFrame(df.count(axis=0)).transpose()
    export_df1 = df.append(rs, ignore_index=True).transpose()

    for col in rs:
        rs[col] = math.log(length / rs[col])

    for col in df:
        counts = df[col].count()
        df[col] = df[col] * math.log(length / counts)

    export_df2 = df.append(rs, ignore_index=True).transpose()

    tfidf_weights = [row_object[row_object.values > threshold].sort_values(ascending=False).head(max_results).to_dict()
                     for row, row_object in df.iterrows()]
    [print(index, len(_), _) for index, _ in enumerate(tfidf_weights)]

    highest_keys = df.apply(lambda s, n: pd.Series(s.nlargest(n).index), axis=1, n=max_results)
    if save_as:
        writer = pd.ExcelWriter(save_as, engine='xlsxwriter')
        # write each DataFrame to a specific sheet
        export_df1.to_excel(writer, sheet_name='frequencies')
        export_df2.to_excel(writer, sheet_name='tf·idf')
        highest_keys.to_excel(writer, sheet_name='highest_keys')
        writer.save()

    return df, tfidf_weights


df, tfidf_weights = get_tfidf(documents_filtered, max_results=1000, threshold=0, save_as='relevant_terms_extraction.xlsx')
print(df)
print(tfidf_weights)

Resultados

Los resultados son ordenados por su relevancia y son también exportados a una hoja de cálculo de la que se presenta un extracto a continuación:

  0 1 2 3 4 5 6
doc0 átomo atómico electrón protón neutrón Rutherford partícula
doc1 célula membrana bacteria celular genético arquea orgánulo
doc2 cerebro cerebral nervioso neurona encéfalo hemisferio lóbulo
doc3 CPU instrucción diseño reloj procesador ejecución programa
doc4 ciudad habitante urbano villa población Buenos Aires
doc5 Sol solar fotosfera estrella helio Tierra mancha

No por casualidad el término más relevante de cada artículo es el tópico del artículo de wikipedia y es seguido por otros cuya coherencia con este reconocemos.


El programa completo puede visualizarse aquí