Meta K-Means: um ensemble de K-Means

Após ouvir falar superficialmente sobre comitês de algoritmos de clusterização [1], me perguntei: qual seria um jeito esperto de agregar as decisões individuais de cada um dos clusters em um valor final? A resposta não é imediata, principalmente porque o problema aqui é que a definição de cada cluster pode ser diferente mesmo quando eles concordam nas separações.

Por exemplo, dado um conjunto de oito exemplos, as segmentações [0, 0, 1, 0, 2, 2, 2, 1] e [1, 1, 0, 1, 3, 3, 3, 0] são idênticas a menos de uma permutação de nomes, isto é, basta chamar o cluster 0 de 1 e o 1 de 0 em alguma das listas e o 3 de 2 na segunda lista (ou o 2 de 3 na primeira lista). É importante ter clareza de que esses clusters de fato concordam, uma vez que a nomenclatura não tem significado algum já que não estamos num problema de classificação.

Isso motiva a criação de métricas de "avaliação de clusterização" como a sklearn.metrics.rand_score que responde a pergunta: o quão similar são duas clusterizações? Em que, obter o valor próximo de 1 significa que os agrupamentos concordam bastante (a menos de possíveis trocas de nomes).

from sklearn.metrics import rand_score

rand_score([0, 0, 1, 0, 2, 2, 2, 1], [1, 1, 0, 1, 3, 3, 3, 0])
1.0

$\oint$ A ideia por trás do (unadjusted) rand index é bem intuitiva e para explicar, vamos pensar em um exemplo específico. Imagine o cenário em que temos um conjunto de dados [a, b, c, d] e duas clusterizações possíveis: A = [1, 1, 0, 0] e B = [1, 1, 1, 2].

  1. Primeiro, separamos todos os pares possíveis de elementos que temos no nosso conjunto. No nosso exemplo teríamos (a, b), (a, c), (a, d), (b, c), (b, d) e (c, d).

  2. Em seguida, contabilizamos quantos desses pares concordam nas clusterizações A e B. Concordar nas clusterizações significa que estão no mesmo cluster ao mesmo tempo, tanto em A quanto em B, ou não estão no mesmo cluster ao mesmo tempo nas duas clusterizações. No nosso caso, o par (a, b) concorda porque, tanto em A quanto em B, ambos estão no mesmo cluster. Mas também os pares (a, d) e (b, d) concordam nas duas clusterizações porque são alocados em clusters diferentes simultaneamente.

  3. Com o número de pares concordantes, fazemos a razão pelo número total de pares para ter o valor do unadjusted rand index calculado, nossa medida de similaridade entre agrupamentos. No nosso caso, 3/6=0.5.

rand_score([1, 1, 0, 0], [1, 1, 1, 2])
0.5

Essas permutações deixam o problema extremamente mais desafiador do que temos num comitê supervisionado e existe uma literatura extensa [1] que tenta abordá-lo uma vez que gostaríamos de poder utilizar ideias de comitê também aqui.

Conversando com o Alessandro, tentamos encarar esse problema em uma versão mais compacta dele, analisando o caso específico de comitê de sklearn.cluster.KMeans (apesar de mais simples, ainda assim seria um caso com possível ganho prático pela popularidade do método). A hipótese seria de que é possível utilizar os centróides para achar as concordâncias entre os diferentes estimadores individuais e daí surgiu a ideia de clusterizar os centróides dos sklearn.cluster.KMeans individuais para renomear os clusters finais de uma maneira única entre os diferentes estimadores individuais.

Para exemplificar a ideia, um exemplo ajuda: se temos dois sklearn.cluster.KMeans com n_clusters=3, então teríamos três centróides $K_1, K_2, K_3$ associados ao primeiro sklearn.cluster.KMeans e os centróides $C_1, C_2, C_3$ do segundo sklearn.cluster.KMeans. Se, ao clusterizar (com o mesmo número de clusters n_clusters), encontrássemos os metaclusters $G_1 = \{ K_1, C_1 \}$, $G_2 = \{ K_2, K_3, C_3\}$ e $G_3 = \{ C_2\}$, então teríamos um mapeamento na hora de agregar o resultado dos diferentes sklearn.cluster.KMeans individuais.

Um exemplo que cai no cluster do centróide $K_1$ no primeiro agrupamento e no de $C_3$ no segundo é associado ao cluster $G_1$ com peso $1/2=0.5$ (já que um de dois K-Means base associou-o a esse grupo), ao cluster $G_2$ com peso $1/2=0.5$ (já que um de dois K-Means base associou-o a esse grupo) e ao cluster $G_3$ com peso $0/2=0$ (já que nenhum dos dois K-Means base associou-o a esse grupo). Já um exemplo que cai em $K_3$ e $C_3$ nos agrupamentos individuais estaria associado ao grupo $G_2$ com peso $2/2=1$, enquanto nos outros $G_i$ com peso $0$. Outros casos são análogos. Nesse formato, estamos voltando à mesma ideia de uma votação de um comitê clássico de classificação para criar um índice de pertencimento de cada exemplo em cada cluster como um algoritmo de soft clustering.


Testando a ideia no dataset de dígitos

Para fazer um experimento com esse modelo, vamos brincar com o conjunto de imagens de baixa resolução de dígitos escritos à mão que podemos carregar usando a função sklearn.datasets.load_digits.

from sklearn.datasets import load_digits

digits = load_digits(n_class=9)
X = digits.data
X.shape
(1617, 64)

Para introduzir variância nos clusters individuais e eles não concordarem totalmente (a menos de alguma permutação), podemos tanto mudar a estratégia de treinamento do sklearn.cluster.KMeans (por exemplo, diminuindo o número de inicializações que ele faz para encontrar a melhor partição em termos de inércia), quanto fazer um bootstrap do nosso conjunto de treino (inspirado em como um bagging funciona no caso supervisionado). Nesse experimento, estamos seguindo com a segunda opção.

from sklearn.cluster import KMeans

n_estimators = 250
n_clusters = 9

km_list = \
[KMeans(n_clusters=n_clusters, random_state=i)
 .fit(X[np.random.RandomState(i).choice(X.shape[0], X.shape[0])]) 
 for i in tqdm(range(n_estimators))]

Após treinar os diferentes sklearn.cluster.KMeans, precisamos treinar o "Meta K-Means" que utilizará os centróides para treinamento.

cluster_centers = np.vstack([km.cluster_centers_ for km in km_list])

meta_kmeans = KMeans(n_clusters=n_clusters, random_state=42).fit(cluster_centers)

Desse modo, conseguimos construir os mapeamentos que agrupam os centróides fazendo a tradução dos clusters individuais de forma que eles concordem de acordo com o critério de agrupamento do "Meta K-Means".

meta_clusters_map = \
[{j: meta_kmeans.labels_[n_clusters*i+j] for j in range(n_clusters)} for i in range(n_estimators)]

Para fazer o agrupamento dos clusters individuais, fazemos algum tipo de agrupamento (como a média, pensando em uma votação simples) dos diferentes clusters para obter um índice de pertencimento de cada exemplo a cada cluster.

from sklearn.preprocessing import LabelBinarizer

lb = LabelBinarizer().fit(list(range(n_clusters)))

aggregated_predicts = \
np.array([lb.transform(np.array(list(map(map_dic.get, km.predict(X)))))
          for km, map_dic in zip(km_list, meta_clusters_map)]).mean(axis=0)

aggregated_predicts.shape
(1617, 9)

Para analisar se o que encontramos parece fazer sentido, vamos tentar interpretar os metacentróides encontrados (ou seja, os centróides que encontramos quando rodamos o sklearn.cluster.KMeans nos centróides dos sklearn.cluster.KMeans base). Como estamos mexendo com essa base de dígitos, podemos olhar para a imagem representada pelo plot do metacentróide de cada cluster final.

fig, ax = plt.subplots(ncols=3, nrows=3, figsize=(4, 4))

plt.gray()
for i, j in product(range(3), range(3)):
    ax[i, j].matshow(meta_kmeans.cluster_centers_[3*i+j].reshape(8, 8))
    ax[i, j].set_xticks([])
    ax[i, j].set_yticks([])
    ax[i, j].set_title(f"Cluster {3*i+j} centroid", fontsize=8)
plt.tight_layout()

A inspeção visual nos permite dar nomes para os clusters seguindo o formato dos números, construindo o seguinte dicionário:

dict_cluster = {0: 2, 1: 4, 2: 8, 3: 6, 4: 0, 5: 5, 6: 3, 7: 1, 8: 7}

Para ver os clusters finais e em que regiões do espaço estão os nosso pontos associados a clusters incertos, vamos aplicar um sklearn.manifold.MDS e, em seguida, um sklearn.manifold.TSNE para reduzir a dimensionalidade dos nossos dados.

from sklearn.manifold import MDS, TSNE

X_emb = \
(TSNE(random_state=42).fit_transform(MDS(random_state=42).fit_transform(X)))

É legal ver que nossos clusters estão fazendo sentido com a marcação original de dígitos, mas o gráfico mais importante aqui é o último: vemos que de fato, existem exemplos que parecem ser mais confusos de atribuir a algum cluster de forma certa (como as imagens associadas ao número 8 que são facilmente confundidas com outros números e exemplos que parecem estar "na fronteira"; entre dois agrupamentos).

fig, ax = plt.subplots(ncols=4, figsize=(12, 3))

im0 = ax[0].scatter(X_emb[:, 0], X_emb[:, 1], s=3, c=digits.target , cmap="Set1")
cbar0 = plt.colorbar(im0, ax=ax[0], ticks=np.linspace(0.5, 7.5, 9))
cbar0.ax.set_yticklabels(np.arange(0, 9))
ax[0].set_title("Real number class", fontsize=11)

im1 = ax[1].scatter(X_emb[:, 0], X_emb[:, 1], s=3,
                    c=list(map(dict_cluster.get, aggregated_predicts.argmax(axis=1))),
                    cmap="Set1")
cbar1 = plt.colorbar(im1, ax=ax[1], ticks=np.linspace(0.5, 7.5, 9))
cbar1.ax.set_yticklabels(np.arange(0, 9))
ax[1].set_title("Cluster class", fontsize=11)

cmap2 = colors.ListedColormap(["#e41a1c", "#4daf4a"])
im2 = ax[2].scatter(X_emb[:, 0], X_emb[:, 1], s=3,
                    c=(aggregated_predicts.max(axis=1)==1).astype(int), cmap=cmap2)
im2.set_clim(0, 1)
cbar2 = plt.colorbar(im2, ax=ax[2], ticks=[0.25, 0.75])
cbar2.ax.set_yticklabels(["Some uncertainty", "No uncertainty"],
                         rotation=270, ha="center", rotation_mode="anchor", fontsize=9)
cbar2.ax.tick_params(pad=10)
ax[2].set_title("Certainty about the assigned cluster", fontsize=11)

cmap3 = colors.LinearSegmentedColormap.from_list('', colors=["#e41a1c", "#4daf4a"])
im3 = ax[3].scatter(X_emb[:, 0], X_emb[:, 1], s=3,
                    c=aggregated_predicts.max(axis=1), cmap=cmap3, norm=colors.LogNorm())
im3.set_clim(0.73, 1.02)
cbar3 = plt.colorbar(im3, ax=ax[3], ticks=[0.75, 0.8, 0.85, 0.9, 0.95, 1])
cbar3.ax.set_yticklabels(['$\leq$0.75', '0.80', '0.85', '0.9', '0.95', '1.00'])
ax[3].set_title('Maximum of "predict_proba"', fontsize=11)

for axs in ax:
    clean_axes(axs)
plt.tight_layout()

Observando o histograma do máximo do nosso ".predict_proba", vemos que para um número razoável de exemplos, os clusters encontrados pelos agrupamentos individuais podem discordar ligeiramente gerando uma visão de incerteza e robustez associada à sua atribuição de agrupamento (ideia central dos algoritmos de soft clustering). Entretanto, para maioria dos exemplos os sklearn.cluster.KMeans individuais concordam totalmente.

fig, ax = plt.subplots(figsize=(5, 2.5))
ax.hist(aggregated_predicts.max(axis=1), bins=np.linspace(0, 1, 25))
ax.set_yscale("log")
ax.set_xlabel('Maximum of "predict_proba" per instance')
ax.set_ylabel("Frequency (log scale)")
ax.set_title("Histogram of assigned cluster certainty")
plt.tight_layout()

Essa visão nos permite ver os exemplos mais difíceis de agrupar, dando uma noção de instance hardness para o nosso problema de clusterização que, no nosso, exemplo parece estar associado a números parecidos com o 8.

(pd.DataFrame(aggregated_predicts)[(aggregated_predicts<0.45).all(axis=1)]
 .rename(columns=dict_cluster).T.sort_index().T)

fig, ax = plt.subplots(ncols=5, figsize=(5, 2.5))

plt.gray()
for axs, i in zip(ax, pd.DataFrame(aggregated_predicts)[(aggregated_predicts<0.45).all(axis=1)].index):
    axs.matshow(X[i].reshape(8,8))
    axs.set_xticks([])
    axs.set_yticks([])
    axs.set_title(f"{i} - Target: {digits.target[i]}", fontsize=7)
plt.tight_layout()


Considerações finais

Essa ideia de clusterização de centróides não é nova e, inclusive, pode ser utilizada para definir a inicialização do K-Means. Esse algoritmo é chamado Refined K-Means [1], entretanto não parece ter uma vantagem clara quando comparado ao K-Means++ com múltiplas inicializações (maneira como o sklearn.cluster.KMeans segue).

Apesar de claramente ter aplicações em que vale a pena testar essa visão, nos experimentos feitos para construir essa discussão, os clusters encontrados individualmente raramente discordam muito (conseguimos ver isso pelo número significativo de exemplos com aggregated_predicts.max(axis=1) sendo igual a 1) e os hard clusters encontrados no final da nossa estratégia de soft clustering (pegando o .argmax) são muito parecidos com os clusters encontrados em um K-Means usual. Portanto, não acho que seja uma técnica extremamente promissora, apesar de valer o teste sempre que você estiver interessado em um K-Means pelo baixo esforço adicional.

unique_km_labels = KMeans(random_state=42).fit(X).labels_

(rand_score(unique_km_labels, aggregated_predicts.argmax(axis=1)),
 (aggregated_predicts.max(axis=1)==1).mean())
(0.9799745280650514, 0.6951144094001237)

Por fim, é fácil generalizar as ideias aqui para qualquer outro algoritmo de clusterização baseado em centróides como o K-Medians ou o K-Medoids. Isso significa que não estamos necessariamente presos à distância euclidiana, que é a distância utilizada pelo K-Means.


Implementação grosseira da classe do estimador

Se você estiver interessado em utilizar essas ideias, elas deveriam funcionar utilizando algo na linha da classe implementada a seguir, que é compatível com bibliotecas que seguem o padrão de código do scikit-learn. Apenas fique atento ao caso em que n_clusters=2, pois o sklearn.preprocessing.LabelBinarizer mantém apenas uma coluna ao invés de criar duas e, nesse caso, o return do seu .predict_proba terá apenas uma dimensão.

import numpy as np
from sklearn.base import BaseEstimator
from sklearn.cluster import KMeans
from sklearn.preprocessing import LabelBinarizer

class MetaKMeans(BaseEstimator):
    """Meta K-Means clustering.

    A Meta K-Means is a meta estimator that fits several K-Means
    on various sub-samples of the dataset and uses averaging to
    measure uncertainty related to predicted clusters.

    Parameters
    ----------
    n_clusters : int, default=8
        The number of clusters to form as well as the number of
        metacentroids to generate.

    n_estimators : int, default=100
        The number of K-Means in the ensemble.

    random_state : int, default=42
        Controls both the randomness of the bootstrapping of the samples used
        when building the individual K-Means and the randomness of the
        choice of initial centroids of each K-Means.

    KMeans_params : dict, default={}
        Explicitly set some of the base K-Means parameters as **KMeans_params.
    """
    
    def __init__(self, n_clusters=8, n_estimators=100, random_state=42, KMeans_params={}):
        self.n_clusters = n_clusters
        self.n_estimators = n_estimators
        self.random_state = random_state
        self.KMeans_params = KMeans_params

    def fit(self, X, y=None):
        self.estimators_ = \
        [KMeans(n_clusters=self.n_clusters, random_state=i+self.random_state, **self.KMeans_params)
         .fit(X[np.random.RandomState(i).choice(X.shape[0], X.shape[0])]) 
         for i in range(self.n_estimators)]
        
        cluster_centers = np.vstack([km.cluster_centers_ for km in self.estimators_])

        self.meta_kmeans_ = KMeans(n_clusters=self.n_clusters, random_state=42).fit(cluster_centers)
        
        self.metacluster_centers_ = self.meta_kmeans_.cluster_centers_
        
        self.meta_clusters_map_ = \
        [{j: self.meta_kmeans_.labels_[self.n_clusters*i+j] for j in range(self.n_clusters)} for i in range(self.n_estimators)]
        
        self.lb_ = LabelBinarizer().fit(list(range(self.n_clusters)))
        
        return self
    
    def predict_proba(self, X):
        return \
        np.array([self.lb_.transform(np.array(list(map(map_dic.get, km.predict(X)))))
                  for km, map_dic in zip(self.estimators_, self.meta_clusters_map_)]).mean(axis=0)
    
    def predict(self, X):
        return self.predict_proba(X).argmax(axis=1)
class_meta_kmeans_with_params = \
MetaKMeans(n_clusters=9, n_estimators=10, random_state=0, KMeans_params={"init": "random"}).fit(X)

class_meta_kmeans = \
MetaKMeans(n_clusters=9, n_estimators=250, random_state=0).fit(X)
class_predict_probas = class_meta_kmeans.predict_proba(X)

# As I'm choosing the same random_state, I expect results of the class
# to match the ones we did above.
((class_predict_probas == aggregated_predicts).all(),
 (class_meta_kmeans.predict(X) == aggregated_predicts.argmax(axis=1)).all())
(True, True)

Referências

[1] Cluster ensembles: A survey of approaches with recent extensions and applications. Tossapon Boongoen Natthakan Iam-On. Computer Science Review Volume 28, 2018.


Todos os arquivos e ambiente para reprodução dos experimentos podem ser encontrado no repositório deste post.