Clustering


Índice

1 - Introducción
    1.1 - Tipos de Clustering
    1.2 - Aplicaciones de Clustering
2 - Medidas de distancia
3 - Algoritmo K-Means
    3.1. - Implementación en Python de K-Means
4 - Clustering Jerárquico
5 - Clustering Probabilista
    5.1. - Algoritmo Expectación-Maximización

1 - Introducción


El clustering o agrupación es una técnica de inteligencia artificial dentro del abanico de Aprendizaje No Supervisado. Esto es así debido a que de antenamo no se conocen las clases o el número de clases que se tendrán como resultado de la agrupación de dichos datos.

Es decir, a diferencia de, por ejemplo, los árboles de decisión, donde sabíamos que clases podrían ser (por ejemplo: día lluvioso / soleado / nublado / nieve...), en el método de clustering, no tenemos esta información, y tendremos que deducir cuántas y cuáles son las clases que hay en nuestro conjunto de datos.

Un cluster será cada uno de los grupos a los que pertenezcan un conjunto de ejemplos tras la clasificiación. En la imagen vemos el proceso de un conjunto de datos desconocidos en los que los puntos podrían pertencer a cualquier grupo y al final la solución en la que se encuentran 3 clusters bien definidos.

1.1 - Tipos de Clustering

Existen diferentes tipos de clustering en función del tipo de agrupamientos que producen:

Agrupamientos Exclusivos

Pueden generarse por métodos que particionan los datos creando un número de k determinados clusters. Generalmente, utilizan medidas de distancia para generar los clusters.

  • Cada uno de los clusters tiene al menos 1 objeto
  • Cada objeto puede pertenecer a 1 solo cluster El ejemplo más representativo de este grupo es el algoritmo K-means. También están los algoritmos basados en densidad, los cuales utilizan la densidad de puntos alrededor y permiten generar formas irregulares en los clusters.

Agrupamientos Jerárquicos

Algoritmos que generan una estructura jerárquica de clusters a través de creación de clusters durante iteraciones. Existen 2 maneras diferentes de llegar a esta jerarquía.
De forma divisoria, en la que partimos con uno único cluster en el primer nivel, y a partir de él vamos construyendo clusters en los siguientes niveles.
De forma aglomerativa, en la que partimos de numerosos clusterers más pequeños , los cuales vamos agrupando progresivamente hasta generar las estructura jerárquica hasta el primer nivel.

Agrupamientos Probabilistas

Los clusteres se generan mediante algún método probabilísitico. El ejemplo más representativo sea prosiblemente el algoritmo Expectación-Maximización (EM).

Agrupamientos Solapados

Los objetos se agrupan en clusters difusos, pudiendo un objeto pertenecer a más de un cluster al mismo tiempo. También podría un objeto pertencer a varios cluster con diferentes grados de permanencia. El algoritmo Fuzzy C-means genera clusters difusos.

1.2 - Aplicaciones de Clustering

Algunos de los ejemplos más conocidos donde se aplica el clustering son:

  • Encontrar grupos homogéneos: por ejemplo, encontrar clientes similares para diseñar estrategias de Marketing
  • Encontrar grupos y describirlos en base a sus propiedades: por ejemplo, la clasificación de enfermedades raras en medicina
  • Detección de casos anómalos: por ejemplo, un sistema de detección de fraudes de tarjetas de crédito

2 - Medidas de Distancia


Muchos algoritmos se basan en medidas de distancia entre objetos para, en función de su cercanía, incluirlos en un mismo cluster o separarlos en distintos clusters. La medida de distancia apropiada es importante, pero no siempre se puede saber a ciencia cierta cuál es la medida óptima. La distancia euclídea entre dos puntos es una de las más utilizadas.

Otras medidas muy utilizadas reciben el nombre de linkage measures, medidas de conectividad.

Enlace Sencillo (Single Linkage):

La similitud entre dos clusters se calcula como la simulitud de los dos puntos más cercanos pertenecientes a los diferentes clusters.

La distancia se calcula entonces:

Enlace Completo (Complete Linkage):

La similitud entre dos clusters se calcula como la simulitud de los dos puntos más alejados pertenecientes a los diferentes clusters.

La distancia se calcula entonces:

Enlace Completo (Complete Linkage):

La distancia entre dos clusters se calcula como la distancia media de cualquier punto del primer cluster con cualquier punto del segundo cluster.

La distancia se calcula entonces:

3 - Algoritmo K-Means


Es un método iterativo basado en distancia que define k centroides de los k
clústeres que genera en cada iteración.  
Un objeto pertenece a un clúster o a otro en
base a su distancia a los centroides de los distintos clústeres.  
El centroide de un
clúster lo calcula como el valor medio de los puntos del clúster.

El algoritmo K-Means resuelve el problema partiendo de un número determinado de K clusters a priori.
Los pasos que sigue el algoritmo son 4:

(1) Elegir K centroides de manera aleatoria para definir el estado inicial.
(2) Asignar cada punto Xi al cluster del centroide que más cerca tengan en función de la medida de distancia elegida.
(3) Encontrar un nuevo centroide para cada cluster, promediando los puntos que han caido en cada cluster (recálculo de centroides) (4) Repetir pasos 2-3 hasta que en el paso 3 los centroides no varíen.

El siguiente GIF es una representación del algoritmo K-Means en el caso de datos en 2 dimensiones:

Comentarios sobre K-Means

  • Es difícil saber si el valor de k que se elige de antemano es un valor adecuado. Existen algunos métodos que ahora comentaremos (Elbow-Method) para determinarlo.
  • El algoritmo es sensible a los valores iniciales de los centroides, por lo que conviene ejecutarlo varias veces y para diferentes valores de k antes de tomar una decisión.
  • Cuidado con valores altos de k que puedan dar lugar a overfitting.
  • No podemos calcular la media sobre atributos nominales. Necesitaremos mapearlos a valores numéricos.

3.1 - Implementando K-Means en Python


In [5]:
import copy as cp
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib inline
In [6]:
# Importar los datos
datos = pd.read_csv('./2d_data_clustering.csv')
print('Forma de los datos: ', datos.shape)
datos.head(3)
Forma de los datos:  (3000, 2)
Out[6]:
V1 V2
0 2.072345 -3.241693
1 17.936710 15.784810
2 1.083576 7.319176
In [7]:
# Separar las variables y representar los datos iniciales
x1 = datos['V1'].values
x2 = datos['V2'].values

X = np.array(list(zip(x1, x2))) # ** Método zip de Python **
print(X.shape)

plt.figure(figsize=(20,6))
plt.title('Datos iniciales', fontsize=18)
plt.scatter(x1, x2, color='black', s=10)
plt.show()
(3000, 2)

Nota: en este link hay más información sobre qué esta haciendo el método zip. Básicamente es en una lista ir poniendo por orden un valor de cada vector, de forma que quedarían: X = [ x1(1), x2(1), x1(2), x2(2), ..., x1(n), x2(n) ]

Todos los puntos negros porque no conocemos a que clase pueden pertenecer.

Se utiliza este conjunto de datos (dataset) porque se ve claramente que la solución que buscarmos debe resultar en 3 clusters bien diferenciados. En la vida real, podemos encontrar distribuciones de puntos sobre los que no podamos hacer ninguna intuición. Ahí es donde entra en juego vuestro arte como científicos de datos!

In [8]:
# Función para calcular la distancia euclídea
def distancia(a, b, ax=1):
    # El método norm del módulo de algebra dentro de numpy nos devuelve la norma de la ecuación
    # que introduzcamos dentro. En nuestro caso, la distancia entre dos puntos es su resta.
    return np.linalg.norm(a - b, axis=ax) 
In [9]:
# Parámetros iniciales

# Número de clusters
k = 3

# Coordenadas de los Centroides 
# Queremos k valores (3) aleatorios entre 0 y el máximo menos un margen (sabemos que un centroide
# no puede estar en una esquina, porque no sería un CENTROide)
c_x = np.random.randint(0, np.max(X)-20, size=k) 
c_y = np.random.randint(0, np.max(X)-20, size=k)

# Almacenar los valores de los centroides en una sola variable C
C = np.array(list(zip(c_x, c_y)), dtype=np.float32)
print(C)

# Visualizar dónde están los centroides
plt.figure(figsize=(20,6))
plt.title('Datos iniciales', fontsize=18)
plt.scatter(x1, x2, color='black', s=10)
plt.scatter(c_x, c_y, marker='P', s=300, c='r')
plt.show()
[[11. 50.]
 [37.  1.]
 [68. 74.]]

Vemos que no se corresponde, puesto que ha sido una inicialización aleatoria. Ahora tenemos que asignar los puntos a cada uno de esos centroides en función de su distancia a ellos, perteneciendo al cluster que más cerca tengan. Utilizaremos nuestra función distancia

In [10]:
# Crear la variable C_o para guardar los valores de los centroides antes de actualizarlos.
C_o = np.zeros(C.shape) # Primero inicializamos un vector del tamaño de C con todo ceros

# Creamos la variable clusters de la misma longitud que X, que guardará el cluster al que 
# pertenece cada punto. Por ejemplo, el valor quinto de clusters guarda el cluster al que
# pertenece el quinto punto guardado en X
clusters = np.zeros(len(X))

# Creamos la variable error que mide la distancia entre los nuevos centroides y los antiguos
error = distancia(C, C_o, None)

# PASO 4 - Creamos un bucle que se repita hasta que el error sea 0
while error != 0:
    # PASO 2 - Asignamos los puntos a sus clusters en función de su distancia
    for i in range(len(X)):
        # Calculamos la distancia a los centroides para cada punto
        distancias = distancia(X[i], C)
        # Buscamos cual es la distancia mínima - ** argmin devuelve el índice dónde se encuentra
        cluster = np.argmin(distancias) 
        clusters[i] = cluster

    # Guardamos el valor de los centroides antiguos (utilizamos la función copy de Python)
    C_o = cp.deepcopy(C)
    
    # PASO 3 - Calculamos los nuevos centroides
    for i in range(k):
        # Para cada centroide k
        puntos = [X[j] for j in range(len(X)) if clusters[j] == i] # **List Comprehesion**
        C[i] = np.mean(puntos, axis=0).reshape(2,)
        
    error = distancia(C, C_o, None)
/Users/pablorr10/miniconda3/envs/datascience/lib/python3.6/site-packages/numpy/linalg/linalg.py:2257: RuntimeWarning: invalid value encountered in sqrt
  ret = sqrt(sqnorm)
In [11]:
colores = ['y', 'g', 'b']

fig = plt.figure(figsize=(20,6))
for i in range(k):
    # Para cada cluster
    puntos = np.array([X[j] for j in range(len(X)) if clusters[j] == i])
    plt.scatter(puntos[:, 0], puntos[:, 1], s=10, color=colores[i])
plt.scatter(C[:,0], C[:,1], marker='P', s=300, c='r')
plt.show()

¿Cuál sería el resultado si hubieramos elegido mal el número de clusters?
Esto nos ayudará a entender por qué es tan importante la selección del número k.

In [12]:
k = 6
c_x = np.random.randint(0, np.max(X)-20, size=k) 
c_y = np.random.randint(0, np.max(X)-20, size=k)
C = np.array(list(zip(c_x, c_y)), dtype=np.float32)

C_o = np.zeros(C.shape) # Primero inicializamos un vector del tamaño de C con todo ceros
clusters = np.zeros(len(X))
error = distancia(C, C_o, None)

while error != 0:
    for i in range(len(X)):
        distancias = distancia(X[i], C)
        cluster = np.argmin(distancias) 
        clusters[i] = cluster
    C_o = cp.deepcopy(C)
    for i in range(k):
        puntos = [X[j] for j in range(len(X)) if clusters[j] == i] # **List Comprehesion**
        C[i] = np.mean(puntos, axis=0).reshape(2,)
    error = distancia(C, C_o, None)

colores = ['y', 'g', 'b', 'c', 'm', 'k']

fig = plt.figure(figsize=(20,6))
for i in range(k):
    puntos = np.array([X[j] for j in range(len(X)) if clusters[j] == i])
    plt.scatter(puntos[:, 0], puntos[:, 1], s=10, color=colores[i])
plt.scatter(C[:,0], C[:,1], marker='P', s=300, c='r')
plt.show()
/Users/pablorr10/miniconda3/envs/datascience/lib/python3.6/site-packages/numpy/linalg/linalg.py:2257: RuntimeWarning: invalid value encountered in sqrt
  ret = sqrt(sqnorm)

¡Buenas noticias!
De nuevo, la librería sklearn tiene funcionalidad para llevar a cabo el algoritmo de clustering K-Means. En este capítulo veremos un ejemplo real utilizando la librería y veremos que es mucho más fácil que tener que estar haciendo todos los cálculos.

Clustering Jerárquico


El clustering jeráriquico trabaja de manera incremental. Si recordamos, K-means trabaja con todo el conjunto de datos en cada iteración. En cambio, los algoritmos de clustering jerárquico dividen el total de datos en jerarquías.

Además, el hecho de tener los datos agrupados en jerarquías facilita el posterior análisis y la visualización de los datos.

Los dos tipos de clustering jerárquico derivan en el orden de agrupamiento o división. Mientras que el divisoria empieza con un solo cluster y va diviendo en nuevos clusters, el aglomerativo actúa al contrario, siendo cada punto un cluster que se van aglomerando hasta formar un único cluster. La siguiente imagen ilustra dicho comportamiento:

Cuando se trabaja con algoritmos jerárquicos, es común utilizar ilustraciones denominadas dendogramas, ya que representan bien los niveles jerárquicos en cada iteración del algoritmo.

Utilidad de Categoría

La pregunta que os estará vieniendo a la mente y, es lógico, es ¿cómo se por donde tengo que dividir o aglomerar?.
La utilidad de la categoría es la medidad de las calidad de las particiones/aglomeraciones de los objetos en clusters. Puesto que los método aglomerativos son mucho más frecuentes que los métodos divisorios, vamos a ver el proceso del algoritmo jerárquico aglomerativo.

(1) A cada uno de los N puntos se le asigna un cluster. Por tanto, empezamos con N clusters
(2) Tras calcular la distancia entre los clusters, se agrupan los que estén más cerca
(3) Se calcula la distancia entre el nuevo cluster y el resto de clusters
(4) Se repiten 2 y 3 hasta que todos los puntos estén en un único cluster

Ahora, una vez se tiene el dendograma (o árbol jerárquico) construido, si se quiere tener K clusters, simplemente hay que tomar los clusters del nivel con ese número k de clusters.

5 - Clustering Probabilista


Como su nombre indica, en lugar de asignar de forma exclusiva las instancias a los distintos clusters, indican la probabilidad de que ls instancias pertenezcan a cada uno de los clusters.

La base de estos algoritmos es el modelo estadístico denominado mezclas finitas (finite mixtures)

Por tanto, dado un conjunto de instancias con sus atributos, tenemos que determinar los parámetros que modelan el conjunto de k distribuciones de probabilidad para los k clusters, así como la probabilidad de población. Es decir, la probabilidad de que el propio cluster tenga cierto número de instancias.

En este vídeo se explica de forma muy detallada y muy intuitiva lo que se va a explicar a continuación. Recomiendo altamente que le echéis un vistazo, aunque está en inglés, es muy comprensible.

Si nos fijamos en la imagen de la derecha, veríamos la solución a la que queremos llegar. Cada cluster esta definido por una distribución normal con media y varianza.

Imaginemos el caso sencillo de una única dimesión, es decir, tener instancias de un único atributo y, por ejemplo, los tres clusters. Sabemos que vamos a modelar los clusters con distribuciones normales, por lo tanto, necesitaremos 2 parámetros por cada cluster para definirlos completamente: μA, σA, μB, σB, μC, σC; más las probabilidades de cada cluster: pA, pB y pC.

La pregunta ahora es evidente, si intuyo que el conjunto de punto que tengo vienen de k clusters, y por tanto, de k distribuciones normales (gaussianas); ¿cómo modelo estas distribución? ¿Cómo determino el valor de estos parámetros?

Algormito Esperanza - Maximización


El algoritmo EM actúa de forma paralela al algoritmo K-Means dentro del concepto de distribucciones gaussianas.
(1) Se comienzan con valores aleatorios para los parámetros, que utilizaremos para:
(2) Calcular las probabilidades de cada instancia de pertenecer a cada uno de los clusters.
(3) En una segunda etapa, se utilizan dichas probabilidades para reestimar los parámetros.
Este proceso se repite iterativamente hasta que los clusters no cambian.

In [15]:
# Volvemos a traer los datos y les asignamos un cluster inicial random
datos = pd.read_csv('./2d_data_clustering.csv') 
datos.columns = ['x', 'y']

columna_cluster = np.random.randint(low=1, high=4, size=len(datos.x))
datos['cluster'] = columna_cluster
datos['cluster'].replace([1,2,3], ['A','B','C'], inplace=True)

datos.head()
Out[15]:
x y cluster
0 2.072345 -3.241693 C
1 17.936710 15.784810 A
2 1.083576 7.319176 A
3 11.120670 14.406780 A
4 23.711550 2.557729 C
In [16]:
# 1 - Valores iniciales
'''
Usando los mismos que usamos para K-Means:
[[11. 50.]
 [37.  1.]
 [68. 74.]]
'''
parametros = {'muA': [11, 50],           # μAx, μAy
              'sigA': [[10,0], [0,10]],  # σAx, σAy
              'pA' : [0.4, None],        # pA
              'muB': [37, 1],            # μBx, μBy
              'sigB': [[10,0], [0,10]],  # σBx, σBy
              'pB' : [0.3, None],        # pB
              'muC': [68, 74],           # μCx, μCy
              'sigC': [[10,0], [0,10]],  # σCc, σCy
              'pC' : [0.3, None]}        # pC

parametros = pd.DataFrame(parametros)
parametros.head()
Out[16]:
muA muB muC pA pB pC sigA sigB sigC
0 11 37 68 0.4 0.3 0.3 [10, 0] [10, 0] [10, 0]
1 50 1 74 NaN NaN NaN [0, 10] [0, 10] [0, 10]
In [17]:
plt.figure(figsize=(20,6))
plt.title('Datos iniciales', fontsize=18)
plt.scatter(datos['x'], datos['y'], color='black', s=10)
plt.scatter(parametros.loc[0, 'muA'], parametros.loc[1, 'muA'], 
            marker='P', s=300, c='r')
plt.scatter(parametros.loc[0, 'muB'], parametros.loc[1, 'muB'], 
            marker='P', s=300, c='r')
plt.scatter(parametros.loc[0, 'muC'], parametros.loc[1, 'muC'], 
            marker='P', s=300, c='r')
plt.show()

Queremos ahora calcular la probabilidad de pertenencia de un objeto a cada uno de los Cluster. Para ello tenemos lo siguiente:

que es la probabilidad condicional de que el punto X pertenezca al cluster C, dados la media. la distribución y la probabilidad del cluser. Al lado derecho de la ecuación, tenemos el resultado de aplicar el Teorema de Bayes. La lectura en lenguaje natural de la ecucación sería: "La probabilidad condicionada de que X pertencezca al cluster C dado (π, μ, σ) es la probabilidad de X dado el C y sus parámetros(μ, σ) por la probabilidad del cluster C dado π".

Siguiendo el desarrollo llegamos a:

Y, por úlitmo, lo que nos interesa es asignar el objeto al Cluster al que mayor probabilidad tenga de pertenecer:

In [18]:
# Método para calcular la probabilidad
from scipy.stats import norm
def prob(val, mu, sig, lam):
    '''
    Función que calcular la probabilidad de un punto de pertenecer a un cluster con distribución
    normal dado por los parámetros val, mu, sig, lam
    '''
    p = lam
    for i in range(len(val)):
        p *= norm.pdf(val[i], mu[i], sig[i][i]) # pdf - Función de densidad de probabilidad
    
    return p

Nota: Este link y este otro link contienen más información sobre la FDP.
La siguiente imagen representa para nuestro caso de 2 dimensiones, como cada variable tiene su distribución y la combinación de ambas define una distribución conjunta sobre la que situamos nuestros puntos.
En cada iteración, por tanto, hemos definido las dos pautas que le dan nombre al algoritmo.

Esperanza (Expectation):
Calcula las probabilidades de pertenecia a un cluster y asignar a cada punto el cluster al que más probablemente pertenezca

In [19]:
# Función Esperanza
def esperanza(datos, params):
    '''
    Método que calcula la probabilidad de pertenecer a un cluster y devuelve una ---
    '''
    for i in range(datos.shape[0]):
        # Para cada punto
        x = datos['x'][i]
        y = datos['y'][i]
        p_cluster1 = prob([x,y], 
                          list(params['muA']), 
                          list(params['sigA']), 
                          params['pA'][0])
        p_cluster2 = prob([x,y], 
                          list(params['muB']), 
                          list(params['sigB']), 
                          params['pB'][0])
        p_cluster3 = prob([x,y], 
                          list(params['muC']), 
                          list(params['sigC']), 
                          params['pC'][0])
        
        if p_cluster1 > p_cluster2:
            
            if p_cluster2 > p_cluster3: 
                datos.loc[i, 'cluster'] = 'A'
            
            elif p_cluster1 < p_cluster3:
                datos.loc[i, 'cluster'] = 'C'
                
        elif p_cluster2 > p_cluster3: 
                datos.loc[i, 'cluster'] = 'B'
            
        else: datos.loc[i, 'cluster'] = 'C'
                
    return datos

Maximización (Maximization): determina los nuevos parámetros que maximizan la verosimilitud esperada en el anterior paso (expected likelihood).

In [20]:
# Función maximización
def maximizacion(datos, params):
    '''
    Método que recalcula los valores de los parámetros basando en los resultados de la esperanza
    calculada en el paso anterior de la iteración
    '''
    puntos_cluster_A = datos[datos['cluster'] == 'A'] # Los datos cuya columna cluster sea A
    puntos_cluster_B = datos[datos['cluster'] == 'B']
    puntos_cluster_C = datos[datos['cluster'] == 'C']
    
    porcentaje_en_cluster_A = len(puntos_cluster_A) / float(len(datos))
    porcentaje_en_cluster_B = len(puntos_cluster_B) / float(len(datos))
    porcentaje_en_cluster_C = len(puntos_cluster_C) / float(len(datos))
    
    params['pA'] = porcentaje_en_cluster_A
    params['pB'] = porcentaje_en_cluster_B
    params['pC'] = porcentaje_en_cluster_C
    
    params['muA'] = [np.mean(puntos_cluster_A['x']), np.mean(puntos_cluster_A['y'])]
    params['muB'] = [np.mean(puntos_cluster_B['x']), np.mean(puntos_cluster_B['y'])]
    params['muC'] = [np.mean(puntos_cluster_C['x']), np.mean(puntos_cluster_C['y'])]
    
    params['sigA'] = [[np.std(puntos_cluster_A['x']), 0], [0, np.std(puntos_cluster_A['y'])]]
    params['sigB'] = [[np.std(puntos_cluster_B['x']), 0], [0, np.std(puntos_cluster_B['y'])]]
    params['sigC'] = [[np.std(puntos_cluster_C['x']), 0], [0, np.std(puntos_cluster_C['y'])]]
    
    return params
Ventajas

Algoritmo sencillo y más general que otros algoritmos de clustering.

Desventajas

Puede converger a un óptimo local y no global.
Si el número de distribuciones es muy alto, el coste computacional puede ser muy grande.

In [21]:
# Necesitamos una función distancia de nuevo, reformulada un poco, ya que ahora no se mueven 
# las coordenadas de un centroide, sino la media de diferentes distribuciones gaussianas
def distancia(parametros_viejos, parametros_nuevos):
    '''
    Método para calcular la distancia entre puntos y determinar si hemos convergido o seguimos 
    con el proceso de iteración
    '''
    distancia = 0
    for cluster in ['muA', 'muB', 'muC']:
        # Para cada media de cada distribución
        for i in range(len(parametros_viejos)):
            # Para x, y
            distancia += (parametros_viejos.loc[i, cluster] - parametros_nuevos.loc[i, cluster])**2
    
    return np.sqrt(distancia)
In [22]:
# Cuerpo del algoritmo EM - Iteraciones

# Parámetros del algoritmo EM
cambio = np.float('inf') # Le asignamos un valor inicial enorme (infinito)
umbral = 0.01 # El valor el cual estamos dispuestos a tolerar como distancia aceptable
iteraciones = 0
copia_datos = cp.deepcopy(datos)
last_it = np.float(0)
In [24]:
# Bucle para iterar
import time
start = time.time()
while (cambio > umbral) and (iteraciones <10):
    # Mientras que el cambio sea mayor que el límite que toleramos
    iteraciones += 1
    
    # Esperanza
    cluster_actualizados = esperanza(datos.copy(), parametros)
    
    # Maximización
    parametros_actualizados = maximizacion(cluster_actualizados, parametros.copy())
    
    # Calcular el cambio de las estimaciones
    cambio = distancia(parametros, parametros_actualizados)
    
    # Imprimir para seguir por donde va el algoritmo
    finish = time.time()
    print('Interación número: %d, cambio = %d' % (iteraciones, cambio))
    #print('Tiempo de iteración: %.2f segundos' & ((finish - last_it)))
    print('Tiempo de ejecución: %.2f segundos' % ((finish-start)))
    last_it = time.time()
    
    # Actualizar los clusters y los parametros para la siguiente iteracion
    datos = cluster_actualizados
    parametros = parametros_actualizados
    
    # Imprimir algunas de las iteraciones para ver el progreso
    if iteraciones % 1 == 0: # Aquí podremos controlar cada cuantas hacemos una figura        
        datos_pintar = cp.deepcopy(datos)
        datos_pintar['cluster'].replace([1,2,3], ['A','B','C'], inplace=True)
        
        colors = {'A': 'blue', 'B': 'green', 'C': 'brown'}
        
        plt.figure() 
        plt.scatter(datos['x'], datos['y'], 10, 
                    c=datos['cluster'].apply(lambda x: colors[x]))
        plt.scatter(parametros_actualizados.loc[0, 'muA'], 
                    parametros_actualizados.loc[1, 'muA'], marker='P', s=300, c='k')
        plt.scatter(parametros_actualizados.loc[0, 'muB'], 
                    parametros_actualizados.loc[1, 'muB'], marker='P', s=300, c='k')
        plt.scatter(parametros_actualizados.loc[0, 'muC'], 
                    parametros_actualizados.loc[1, 'muC'], marker='P', s=300, c='k')
        plt.show()
Interación número: 1, cambio = 31
Tiempo de ejecución: 4.07 segundos
Interación número: 2, cambio = 6
Tiempo de ejecución: 8.69 segundos
Interación número: 3, cambio = 3
Tiempo de ejecución: 13.29 segundos
Interación número: 4, cambio = 5
Tiempo de ejecución: 17.92 segundos
Interación número: 5, cambio = 2
Tiempo de ejecución: 22.41 segundos
Interación número: 6, cambio = 1
Tiempo de ejecución: 27.06 segundos