#!/usr/bin/env python # coding: utf-8 # # Clustering en Python: Parte II (Clustering jerárquico y dendograma) # # Vamos a ver un ejemplo de cómo podemos aplicar clustering jerárquico y generar un dendograma. # # Importamos los módulos que vamos a necesitar: # * para medir tiempos de ejecución: time # * para lectura y manejo de los datos: numpy y pandas # * para disponer de implementaciones de distintos algoritmos de clustering: cluster de Scikit-learn # * para normalizar el conjunto de datos: preprocessing de Scikit-learn # * para las visualizaciones y los dendogramas: matplotlib, seaborn y scipy # In[1]: get_ipython().run_line_magic('matplotlib', 'inline') # In[2]: import time import pandas as pd import numpy as np from sklearn import cluster from sklearn import preprocessing import matplotlib.pyplot as plt import seaborn as sns from scipy.cluster import hierarchy # Creamos la función norm_to_zero_one para aplicar normalización min-max. # In[3]: def norm_to_zero_one(df): return (df - df.min()) * 1.0 / (df.max() - df.min()) # ### Datos # # Trabajamos sobre el conjunto de datos facilitado para la segunda práctica de la asignatura que contiene información recogida por el INE. # In[4]: censo = pd.read_csv('censo_granada.csv') # Los valores en blanco en realidad son otra categoría que vamos a nombrar con el valor 0. # In[5]: censo = censo.replace(np.NaN,0) # In[6]: censo.shape # In[7]: censo.columns.values # Seleccionamos el caso de estudio, en este ejemplo concreto, aquellos casos para los que el campo 'EDADMAD' (grupo quinquenal de la edad de la madre) no estaba vacío. # In[8]: subset = censo.loc[censo['EDADMAD']>0] # Seleccionamos variables de interés para clustering. # In[9]: usadas = ['EDAD', 'ANORES', 'NPFAM', 'H6584', 'ESREAL'] X = subset[usadas] # Podemos comprobar las dimensiones (variables e instancias) del subconjunto seleccionado. # In[10]: X.shape # Para sacar el dendrograma en el jerárquico, no podemos tener muchos elementos. # Hacemos un muestreo aleatorio para quedarnos solo con 1000, aunque lo ideal es elegir un caso de estudio que ya dé un tamaño pequeño. # In[11]: X = X.sample(1000, random_state=123456) # En clustering hay que normalizar para las métricas de distancia. Normalizamos el dataframe aplicando la función 'norm_to_zero_one'. # * 'apply' aplica una función a lo largo de un eje concreto de un dataframe. Por defecto el parámetro 'axis' tiene el valor cero lo que significa que la función se aplica a cada columna del dataframe. # In[12]: X_normal = X.apply(norm_to_zero_one) # Podemos comprobar las dimensiones del subconjunto seleccionado con el que vamos a trabajar. # In[13]: X_normal.shape # In[14]: list(X_normal) # ### Clustering Jerárquico # # El clustering jerárquico engloba una familia de algoritmos que construyen clusters anidadas fusionándolos o dividiéndolos sucesivamente. Esta jerarquía de clusters se representa como un árbol (o dendrograma). La raíz del árbol es el cluster único que recoge todas las muestras, siendo las hojas del árbol los cluster que incluyen un solo dato o muestra. # El objeto 'AgglomerativeClustering' realiza un clustering jerárquico usando un enfoque 'de abajo a arriba': cada dato/muestra comienza en su propio cluster, y los clusters van fusionándose sucesivamente. En cada paso fusiona los dos clusters más cercanos (la definición de cercanos va a depender de la métrica elegida). Las opciones son: # * Ward: fusionar el par de cluster que genera un agrupamiento con mínima varianza (media de la distancia cuadrática de cada elemento al centroide) # * Maximum or complete linkage: minimiza la distancia máxima entre elementos de dos clusters. # * Average linkage: minimiza la distancia media entre elementos de dos clusters. # * Single linkage: minimiza la distancia mínima entre elementos de dos clusters. # Vamos a utilizar 'AgglomerativeClustering' con 'Ward' como criterio de enlace y eligiendo quedarnos con 100 clusters (100 ramificaciones del dendograma). # In[15]: ward = cluster.AgglomerativeClustering(n_clusters=100, linkage='ward') # n_clusters: nº de clusters a encontrar name, algorithm = ('Ward', ward) # Aplicamos el algoritmo de clustering jerárquico sobre nuestros datos (registrando el tiempo de ejecución). # In[16]: cluster_predict = {} k = {} print(name,end='') t = time.time() cluster_predict[name] = algorithm.fit_predict(X_normal) tiempo = time.time() - t k[name] = len(set(cluster_predict[name])) print(": k: {:3.0f}, ".format(k[name]),end='') print("{:6.2f} segundos".format(tiempo)) # In[17]: cluster_predict['Ward'] # In[18]: type(cluster_predict) # In[19]: type(cluster_predict['Ward']) # In[20]: cluster_predict['Ward'].shape # Convertimos la asignación de clusters a un DataFrame con una única columna 'cluster'. # In[21]: clusters = pd.DataFrame(cluster_predict['Ward'],index=X.index,columns=['cluster']) # Añadimos la asignación de clusters a las variables de entrada que habíamos seleccionado para el clustering. # In[22]: X_cluster = pd.concat([X, clusters], axis=1) # In[23]: print(list(X_cluster)) print(X_cluster.shape) # Filtramos outliers quitando aquellos elementos que el algoritmo ha agrupado en clusters muy pequeños: # * usamos 'groupby' para generar grupos de instancias según el valor de la columna 'cluster', es decir, según el cluster asignado # * 'transform' devuelve un objeto que está indexado igual (mismo tamaño) que el objeto que está siendo agrupado (en este caso 'X_cluster') # # https://pandas.pydata.org/pandas-docs/stable/groupby.html # In[24]: min_size = 3 # Agrupamos las instancias de 'X_cluster' por el cluster asignado X_filtrado = X_cluster[X_cluster.groupby('cluster').cluster.transform(len) > min_size] # Para entender un poco mejor lo que está pasando en esa última línea de código, podemos ver los grupos de instancias generados por 'groupby'. # In[25]: print(X_cluster.groupby('cluster').groups) # Lo que queremos es saber la longitud (el número de instancias) de cada uno de los grupos. Si llamamos a la función 'len' con la salida de groupby como argumento ... # In[26]: print(len(X_cluster.groupby('cluster'))) # Usando 'transform' 'len' nos devuelve para cada instancia de 'X_cluster' la longitud (nº de instancias) del grupo en el que se ha incluido a esa instancia. # In[27]: print(X_cluster.groupby('cluster').cluster.transform(len)) # Comprobamos cuántos clusters quedan después de haber eliminado aquellos que no llegaban al tamaño mínimo. # In[28]: k_filtrado = len(set(X_filtrado['cluster'])) print("De los {:.0f} clusters hay {:.0f} con más de {:.0f} elementos. Del total de {:.0f} elementos, se seleccionan {:.0f}".format(k['Ward'],k_filtrado,min_size,len(X),len(X_filtrado))) # Eliminamos la columna con la asignación de cluster. # * drop permite eliminar una fila o columna de un DataFrame # In[29]: X_filtrado = X_filtrado.drop('cluster', axis=1) # axis=1 para indicar que lo que queremos eliminar es una columna y no una fila print(list(X_filtrado)) # Normalizamos el conjunto filtrado. Volvemos a normalizar porque al eliminar outliers puede que los valores mínimos/máximos de algunas variables hayan cambiado. # In[30]: X_filtrado_normal = X_filtrado.apply(norm_to_zero_one) # Obtenemos el dendograma usando scipy (que realmente va a volver a ejecutar el clustering jerárquico). # In[31]: linkage_array = hierarchy.ward(X_filtrado_normal) plt.figure(1) plt.clf() h_dict = hierarchy.dendrogram(linkage_array,orientation='left') #lo ponemos en horizontal para compararlo con el generado por seaborn # 'h_dict' es un diccionario con información para representar el dendograma. # Generamos el dendograma usando seaborn (que a su vez usa scipy) para incluir un heatmap. # In[32]: #Ahora lo saco usando seaborn (que a su vez usa scipy) para incluir un heatmap sns.clustermap(X_filtrado_normal, method='ward', col_cluster=False, figsize=(20,10), cmap="YlGnBu", yticklabels=False)