Aprendizaje no Supervisado de Grupos desde Texto en Python

Clasificando automáticamente los contactos de LinkedIn según el título de su posición

Muchas veces en aplicaciones tenemos campos de texto libre cuyos valores son naturalmente categóricos pero son demasiados o no los conocemos previamente como para presentar al usuario una lista de donde elegir. Un claro ejemplo es el título de los puestos de trabajo en Linkedin. En este artículo vamos a ver como agrupar estos puntos de datos automáticamente sin necesidad de una gran cantidad de datos gracias a la transferencia de aprendizaje que permite FastText (aunque esto también sería posible con otras librerías de vectorización de texto como word2vec).

Podés seguir este artículo descargando este Jupyter Noteboook

Dependencias

Lo primero que necesitamos es los datos, podes descargar la lista de contactos desde Linkedin.

También necesitamos las librerías umap-learn, hdbscan y fasttext (la versión de Python y la compilada).

Para evitar problemas con dependencias instalamos umap y hdbscan a la vez con Conda.

pip install conda
conda install -c conda-forge umap-learn hdbscan

La versión de FastText en Conda está desactualizada así que la instalamos con pip.

pip install fasttext

Y necesitamos la versión independiente (standalone) de FastText que podemos clonar y compilar fácilmente.

git clone https://github.com/facebookresearch/fastText.git
cd fastText
make

Asegurate de tener el resto de las dependencias instaladas y que puedas importarlas todas ejecutando el primer bloque de código del Jupyter Notebook.

Finalmente vamos a necesitar los modelos de FastText, uno para identificar los idiomas, uno para cada idioma que queramos identificar (este articulo utiliza español e inglés) y una matriz entrenada con los vectores de los pares de modelos para traducir de uno al otro.

Limpiando los datos

Cargamos el CSV con los contactos df = pd.read_csv('Connections.csv')

Para empezar borramos las entradas que no tienen un titulo df.dropna(subset=['Position'], inplace=True)

Ahora creamos un par de funciones para limpiar los títulos.

Borrar todos los caracteres que no sean letras y convertir a minúsculas.

def clean_characters(text):
    return re.sub(r'\PL+', ' ', text).lower().strip()

Expandir las siglas y abreviaciones.

def expand_abbreviation(text):
    translations = {
        "cto": "chief technology officer",
        ...
    }
    for abb, sub in translations.items():
        text = re.sub(r'\b%s\b' % re.escape(abb), sub, text)
    return text

Y ahora aplicamos las funciones y guardamos el resultado en una nueva columna.

df['clean_title'] = df.Position.apply(clean_characters).apply(expand_abbreviation)

Identificando el idioma

FastText provee varios modelos para vectorizar texto según cada idioma, para esto antes tenemos que identificar el idioma para aplicar el modelo apropiado. FastText también provee un modelo para identificar idiomas.

Importamos el modelo, obtenemos la lista de predicciones, les quitamos el prefijo __label__ y las guardamos en una nueva columna del dataframe.

langid_model = fasttext.load_model('lid.176.ftz')
lang_predictions, _ = langid_model.predict(df.clean_title.values.tolist())
df['lang'] = list(map(lambda x: x[0].replace('__label__',''), lang_predictions))

Ahora que tenemos el idioma y el titulo limpio podemos descartar las demás columnas y las entradas en idiomas que no vamos a vectorizar.

keep_langs = ['en', 'es']
df = df.loc[df.lang.isin(keep_langs), ['clean_title', 'lang']]

Vectorizando los títulos

Para vectorizar los títulos primero vamos a quitar las palabras comunes como “de”, “en”, “y”, etc., conocidas en procesamiento de lenguaje como “stopwords”. Para esto usaremos una lista de palabras comunes para cada idioma.

def remove_stopwords(txt, stopwords_list):
    return ' '.join([word for word in txt.split() if word not in stopwords_list])

sw_en = stopwords.words('english')
sw_es = stopwords.words('spanish')
df.loc[df.lang == 'en', 'clean_title'] = df.clean_title.apply(lambda txt: remove_stopwords(txt, sw_en))
df.loc[df.lang == 'es', 'clean_title'] = df.clean_title.apply(lambda txt: remove_stopwords(txt, sw_es))

Ahora que tenemos los títulos sin caracteres extraños, en minúscula, sin abreviaciones ni palabras comunes que hagan ruido y sabemos el idioma de cada uno, finalmente podemos vectorizar el texto 🥳.

La forma mas simple de hacerlo seria cargar el modelo con FastText Python y usarlo directamente sobre el texto. Esta es la opción que recomendaría para usar en producción o si tienen suficientes recursos (RAM/procesamiento). Pero el modelo pesa casi 7gb y se come mi laptop por un buen rato. El modelo también puede comprimirse (a otro con extensión .ftz) sacrificando algo de precisión a cambio de menor uso de recursos.

en_model = fasttext.load_model("cc.en.300.bin")
en_vectors = en_model.get_sentence_vector( df[df.lang == 'en'].clean_title.values.tolist() )

La versión alternativa es guardar los textos en un archivo, procesarlos con el binario de FastText y volver a cargar los vectores desde Python. Cabe aclarar que no es mucho más rápido, pero es la opción que usé finalmente. Esto me permitía retomar desde los archivos de vectores mientras experimentaba con los siguientes pasos.

Exportamos los textos de cada idioma uno por linea.

df_es = df[df.lang == 'es']
df_es.clean_title.to_csv('es_strings.txt', index=False)

Los procesamos con el binario de FastText y el modelo del idioma.

fasttext print-sentence-vectors cc.es.300.bin < es_strings.txt > es_vectors.txt

Y cargamos la lista de vectores que va a tener dimensiones m x n, para m entradas y n dimensiones de los vectores, en este caso n=300.

es_vectors = np.loadtxt('es_vectors.txt')

Alineando los vectores

Ahora cada titulo esta representado por un vector de 300 numeros, pero cada modelo aprende una representación diferente segun el material con el que fue entrenado, por esto las representaciones de un idioma no son compatibles con las de otro, o incluso de un mismo idioma entrenado con diferentes fuentes de texto.

Pero si asumimos que las palabras en diferentes idiomas mantienen cierta relación entre ellas, entonces existe una transformación lineal (representada por una matriz de 300x300) que traduce entre los diferentes espacios y FastText nos da herramientas para aprender esta matriz entre dos listas de vectores equivalentes.

Fuente proyecto MUSE de Facebook Research

Para entrenar la matriz descargamos la versión texto de los modelos (extensión .vec) para los 2 idiomas (fijándonos que sea la misma versión “wiki” o “cc”) y los diccionarios con equivalencias es-en.0-5000.txt y es-en.5000-6500.txt

Luego desde la carpeta fastText/alignment

python3 align.py --src_emb cc.es.300.vec --tgt_emb cc.en.300.vec \
  --dico_train es-en.0-5000.txt --dico_test es-en.5000-6500.txt 
  \ --output cc.es-en.vec --lr 25 --niter 10

Esto va a tardar un buen rato y va a dar un archivo con el nombre cc.es-en.vec-mat que va a ser nuestra matriz. Podés descargar la matriz resultante desde aquí.

Ahora simplemente normalizamos los vectores, los centramos, alineamos los del modelo de español al ingles y ya los podemos concatenar y usar conjuntamente.

def normalize_vectors(x):
    x /= np.linalg.norm(x, axis=1)[:, np.newaxis] + 1e-8
    return x

def center_vectors(x):
    x -= x.mean(axis=0)[np.newaxis, :]
    return normalize_vectors(x)

def translate_vectors(x, translation_matrix):
    return normalize_vectors(np.dot(x, translation_matrix.T))

en_vectors = center_vectors(normalize_vectors(en_vectors))
es_vectors = center_vectors(normalize_vectors(es_vectors))

translation_matrix = load_translation_matrix('cc.es-en.vec-mat')
en_es_vectors = translate_vectors(es_vectors, translation_matrix)
vectors = np.concatenate((en_vectors, en_es_vectors))

Ahora tenemos todos los vectores alineados en un mismo espacio y concatenados en un mismo numpy array, solo queda un detalle. Reordenamos el dataframe para que tenga el mismo orden que el array, osea manteniendo el orden original, pero primero ingles y luego español.

df.index.name = 'index'
df.sort_values(['lang','index'], inplace=True)
df['vector_index'] = range(df.shape[0])

Extrayendo información de los vectores

Después de todo este trabajo finalmente tenemos un vector para cada titulo pero aún no hemos aprendido nada de los datos. Siendo que no tenemos las entradas clasificadas o una variable que nos interese predecir vamos a hacer uso de las dos técnicas mas comunes de aprendizaje no supervisado, reducción de dimensiones y agrupamiento (clustering).

En un primer intento usé las técnicas clásicas, análisis de componentes principales (PCA) y K-means con resultados aceptables, consiguiendo 3 grupos, desarrolladores, fundadores y recursos humanos/administrativos. Pero ya que hay técnicas mas modernas y potentes decidí probarlas y obtuve aún mejores resultados.

¡Es muy recomendable que leas en detalle la documentación para entender cómo funcionan estas dos técnicas!

Reducción de dimensiones con UMAP

Reducir las dimensiones es tan simple como inicializar umap y llamar fit_transform con los vectores.

reducer = umap.UMAP(n_components=2, n_neighbors=30, random_state=42, min_dist=0.02)
embedding = reducer.fit_transform(vectors)
sns.scatterplot(embedding[0], embedding[1])

n_components indica cuantas dimensiones queremos tener al final, 2 o 3 para un gráfico o algunas más para usar durante el agrupamiento. n_neighbors indica cuantos de los puntos de datos mas cercanos va a usar el algoritmo para determinar la forma del espacio, a mayor numero mejor va a mantenerse la estructura a mayor escala. min_dist es la distancia mínima entre dos puntos en la representación final.

Video detallado (en inglés) sobre como funciona UMAP

Documentación de UMAP

Agrupamiento (clustering) con HDBSCAN

HDBSCAN tambien sigue la convención de sklearn por lo que vamos a crear una instancia con los valores de configuración apropiados y vamos a llamar a fit_predict con los vectores. Para que funcione mejor y más rápido antes vamos a reducir las dimensiones de los vectores con UMAP. El resultado será la categoría predicha para cada vector y estos los guardaremos en una nueva columna del dataframe.

reducer = umap.UMAP(n_components=5, n_neighbors=30, random_state=42, min_dist=0.02)
embedding = reducer.fit_transform(vectors)
clusterer = hdbscan.HDBSCAN(min_cluster_size=30, min_samples=20)
df['c'] = clusterer.fit_predict(embedding)

min_cluster_size indica cuantos elementos debe tener como mínimo un grupo para ser considerado. min_samples se refiere a cuan conservador es el algoritmo para crear nuevos grupos, un valor bajo creara más grupos, un valor mas alto considerará como ruido más puntos.

Video detallado (en inglés) sobre como funciona HDBSCAN

Documentación HDBSCAN

Etiquetando los grupos

Ahora que tenemos los datos clasificados podemos ver qué palabras son las más utilizadas en cada grupo para tener una idea de como ha sido la partición.

labels = list(range(clusterer.labels_.max()+1))
for c in labels:
    category_corpus = " ".join(df[df.c == c].clean_title).split()
    if (len(category_corpus) <= 0):
        continue
    words, counts = zip(*Counter(category_corpus).most_common(5))
    labels[c] = " ".join(words)

df['label'] = df.c.apply(lambda x: labels[x])
df.loc[df.c == -1, 'label'] = ''

Visualizando los resultados

Finalmente tenemos resultados, hemos aprendido algo a partir de los datos sin tener que etiquetarlos a mano sino en base a su propia estructura y el conocimiento embebido en el modelo de vectorización de texto FasText. Y lo que es mejor, no necesitamos ni siquiera 1000 registros, lo que abre la posibilidad de aplicarlo a muchos más sets de datos.

Pero todo esto sería en vano si no pudiéramos mostrarlo como un lindo y colorido gráfico, de ser posible en 3 dimensiones y animado 🚀.

Notas

  • Puede qué los métodos de instalación hayan cambiado desde que empecé este artículo hasta ahora y pip ya funcione para instalar umap y hdbscan.
  • Es posible que el realineamiento de vectores de un idioma al otro no sea correcto, utilizar con precaución y verificar los resultados.
  • Cualquier corrección o comentario es bienvenido (: