Hierarchical Agglomerative clustering for Spark

Viewed 554

I am working on a project using Spark and Scala and I am looking for a hierarchical clustering algorithm, which is similar to scipy.cluster.hierarchy.fcluster or sklearn.cluster.AgglomerativeClustering, which will be useable for large amounts of data.

MLlib for Spark implements Bisecting k-means, which needs as input the number of clusters. Unfortunately in my case, I don't know the number of clusters and I would prefer to use some distance threshold as an input parameter, as it is possible to use in those two python implementations above.

If anyone would know the answer, I would be very grateful.

1 Answers

So I had the same problem and after looking high and low found no answers so I will post what I did here in the hopes that it helps anyone else and that maybe someone will build on it.

The basic idea of what I did was to use bisecting K-means recursively to continue to split clusters in half until all points in the cluster were a specified distance away from the centroid. I was using gps data so I have a little bit of extra machinery to deal with that.

The first step is to create a model that will cut the data in half. I used bisecting K means but I think this would work with any of the pyspark clustering methods so long as you can get the distance to the centroid.

import pyspark.sql.functions as f
from pyspark import SparkContext, SQLContext
from pyspark.ml.clustering import BisectingKMeans
from pyspark.ml.linalg import Vectors
from pyspark.ml.feature import VectorAssembler


bkm = BisectingKMeans().setK(2).setSeed(1)
assembler = VectorAssembler(inputCols=['lat','long'], outputCol="features")
adf = assembler.transform(locAggDf)#locAggDf contains my location info
model = bkm.fit(adf)
# predictions will have the original data plus the "features" col which assigns a cluster number
predictions = model.transform(adf)
predictions.persist()

The next step is our recursive function. The idea here is that we specify some distance from the centroid and if any point in a cluster is farther than that distance we cut the cluster in half. When a cluster is tight enough that it meets the condition I add it to a result array that I use to build the final clustering

def bisectToDist(model, predictions, bkm, precision, result = []):
    centers = model.clusterCenters()
    # row[0] is predictedClusterNum, row[1] is unit, row[2] point lat, row[3] point long
    # centers[row[0]] is the lat long of center, centers[row[0]][0] = lat, centers[row[0]][1] = long
    distUdf = f.udf(
        lambda row: getDistWrapper((centers[row[0]][0], centers[row[0]][1], row[1]), (row[2], row[3], row[1])),
        FloatType())##getDistWrapper(is how I calculate the distance of lat and long but you can define any distance metric)
    predictions = predictions.withColumn('dist', distUdf(
        f.struct(predictions.prediction, predictions.encodedPrecisionUnit, predictions.lat, predictions.long)))
    #create a df of all rows that were in clusters that had a point outside of the threshold
    toBig = predictions.join(
        predictions.groupby('prediction').agg({"dist": "max"}).filter(f.col('max(dist)') > self.precision).select(
            'prediction'), ['prediction'], 'leftsemi')


    #this could probably be improved
    #get all cluster numbers that were to big
    listids = toBig.select("prediction").distinct().rdd.flatMap(lambda x: x).collect()

    #if all data points are within the speficed distance of the centroid we can return the clustering
    if len(listids) == 0:
        return predictions

    # assuming binary class now k must be = 2
    # if one of the two clusters was small enough we will not have another recusion call for that cluster
    # we must save it and return it at this depth the clustiering that was 2 big will be cut in half in the loop below
    if len(listids) == 1:
        ok = predictions.join(
            predictions.groupby('prediction').agg({"dist": "max"}).filter(
                f.col('max(dist)') <= precision).select(
                'prediction'), ['prediction'], 'leftsemi')


    for clusterId in listids:
        # get all of the pieces that were to big
        part = toBig.filter(toBig.prediction == clusterId)
        
        # we now deed to refit the subset of the data
        assembler = VectorAssembler(inputCols=['lat', 'long'], outputCol="features")
        adf = assembler.transform(part.drop('prediction').drop('features').drop('dist'))
        model = bkm.fit(adf)
        #predictions now holds the new subclustering and we are ready for recursion
        predictions = model.transform(adf)
        result.append(bisectToDist(model, predictions, bkm, result=result))

    #return anything that was given and already good

    if len(listids) == 1:
        return ok

Finally we can call the function and build the resulting dataframe

result = []
self.bisectToDist(model, predictions, bkm, result=result)
#drop any nones can happen in recursive not top level call
result =[r for r in result if r]


r = result[0]
r = r.withColumn('subIdx',f.lit(0))
result = result[1:]
idx = 1
for r1 in result:
    r1 = r1.withColumn('subIdx',f.lit(idx))
    r = r.unionByName(r1)
    idx = idx + 1

# each of the subclusters will have a 0 or 1 classification in order to make it 0 - n I added the following
r = r.withColumn('delta', r.subIdx * 100 + r.prediction)
r = r.withColumn('delta', r.delta - f.lag(r.delta, 1).over(Window.orderBy("delta"))).fillna(0)
r = r.withColumn('ddelta', f.when(r.delta != 0,1).otherwise(0))
r = r.withColumn('spacialLocNum',f.sum('ddelta').over(Window.orderBy(['subIdx','prediction'])))
#spacialLocNum should be the final clustering 

Admittadly this is quite convoluted and slow but it does get the job done, hope this helps!

Related