CLUSTERING METHODS FOR LAGRANGIAN ANALYSIS#

What do we mean by clustering?#

In ocean Lagrangian analysis, clustering methods are used to group particle or drifter trajectories that share similar characteristics in space, time, or dynamical behaviour. The main question we try to answer is:

How do we classify a spatiotemporal distribution of trajectories?

The choice of method depends on what your particles represent and whether you are interested in spatial aggregation (where particles cluster) or path similarity (how trajectories evolve). Several studies illustrate the diversity of clustering approaches in the field of geophysical fluid dynamics:

From simulated trajectories

From drifter data

Additionally, there are a wide variety of designed clustering techniques (see Rasyid, L. A., and Andayani, S., (2018) for a review). In this tutorial, we will be comparing the results from these algorithms:

  • K-Means: a spatial partitioning method that minimises within-cluster variance (squared Euclidean distance) to create K clusters

  • DBSCAN (Sander, J., et al. 1998): a density-based algorithm that groups points that are closely packed (points with many nearby neighbours), and marks as outliers points that lie alone in low-density regions.

  • OPTICS (Ankerst, M., et al. 1999): extension of DBSCAN algorithm that can handle clusters of varying densities.

Import packages & data#

[58]:
import xarray as xr
import numpy as np
from sklearn.cluster import DBSCAN, OPTICS, HDBSCAN, KMeans
from tqdm import tqdm
import pandas as pd
import matplotlib.pyplot as plt
import cartopy
import matplotlib as mpl
import cartopy.crs as ccrs
import cmocean

dataset= xr.open_dataset('../../Simulations/toy_data_01.nc')
dataset
[58]:
<xarray.Dataset> Size: 627kB
Dimensions:     (traj: 144, obs: 121)
Dimensions without coordinates: traj, obs
Data variables:
    trajectory  (traj, obs) float64 139kB ...
    time        (traj, obs) datetime64[ns] 139kB ...
    lat         (traj, obs) float32 70kB ...
    lon         (traj, obs) float32 70kB ...
    z           (traj, obs) float32 70kB ...
    U           (traj, obs) float32 70kB ...
    V           (traj, obs) float32 70kB ...
Attributes:
    feature_type:           trajectory
    Conventions:            CF-1.6/CF-1.7
    ncei_template_version:  NCEI_NetCDF_Trajectory_Template_v2.0
    parcels_version:        2.3.1.dev20+g92f2fb90
    parcels_mesh:           spherical

πŸ‘‰Case Study: Spatial clustering of particles in the Agulhas region#

In this analysis, we capture a snapshot of the particle trajectories in the Agulhas region and use K-Means, DBSCAN, and OPTICS algorithms to uncover hidden patterns in their spatial distribution. This approach helps reveal how the flow field groups particles.

I. Selection of the snapshot πŸ”΄#

[ ]:
time_index=... #indicate time index
time= dataset.time.isel(obs=time_index).values[0]
ds=dataset.isel(obs=time_index)

II. Define & apply clustering algorithms πŸ”΄#

πŸ’‘ Look-up at the webiste of Scikit learn package to understand the physical meaning of the eps, min_samples, and max_eps parameters

[90]:

clustering_dict={ 'K-means': {'algorithm': KMeans(n_clusters=10)}, 'DBSCAN': {'algorithm': DBSCAN(eps=np.radians(1), min_samples=5, metric='haversine', algorithm='ball_tree')}, 'OPTICS': {'algorithm': OPTICS(min_samples= 10, metric='haversine', algorithm='ball_tree', max_eps=np.radians(2))}, }

πŸ’‘ Go through the cluster function & ensure you understand all the code

[91]:
def cluster(ds, algorithm, algorithm_name):
    """
    Perform clustering on particle trajectories using a specified clustering algorithm.

    Parameters
    ----------
    ds : xarray.Dataset
        dataset containing 'lon', 'lat', and 'traj' (trajectory index) variables.
    algorithm : clustering object
initialised clustering algorithm (e.g., KMeans, DBSCAN, OPTICS) from scikit-learn.
    algorithm_name : str
        name of the algorithm ('KMeans', 'DBSCAN', 'OPTICS') to handle algorithm-specific outputs.

    Returns
    -------
    df_cluster : pd.DataFrame
        DataFrame with original trajectory points and clustering results including:
        - cluster_label: cluster assignment (-1 for noise in DBSCAN/OPTICS)
        - is_noise: boolean flag for noise points
        - reachability: OPTICS reachability distance or zeros for other algorithms
        - ordering: OPTICS ordering of points or zeros for others
        - core_distances: OPTICS core distances or zeros for others
    """

    # convert longitude and latitude to radians and stack into Nx2 array for clustering
    points = np.column_stack([np.radians(ds.lon.values), np.radians(ds.lat.values)])

    # fit the clustering algorithm to the points
    clustering = algorithm.fit(points)

    # get cluster labels assigned by the algorithm
    labels = clustering.labels_

    # ensure noise points are labelled as -1, otherwise keep the cluster label
    unique_labels = np.where(labels == -1, -1, labels)

    # algorithm-specific handling for OPTICS
    if algorithm_name == 'OPTICS':
        # initialize reachability array with NaNs
        reachability_values = np.full(len(ds.lon.values), np.nan)
        # fill reachability in the order determined by OPTICS
        reachability_values[clustering.ordering_] = clustering.reachability_[clustering.ordering_]

        # initialise ordering array with -1
        ordering_values = np.full(len(ds.lon.values), -1)
        # store the position in the OPTICS ordering for each original point
        for order_pos, orig_idx in enumerate(clustering.ordering_):
            ordering_values[orig_idx] = order_pos

        # get core distances from OPTICS
        core_values = clustering.core_distances_

    else:
        # for non-OPTICS algorithms, reachability, ordering, and core distances are not defined
        reachability_values = np.full_like(unique_labels, 0)
        ordering_values = np.full_like(unique_labels, 0)
        core_values = np.full_like(unique_labels, 0)

    # Create a DataFrame with clustering results and original trajectory info
    df_cluster = pd.DataFrame({
        'traj_index': ds.traj.values,
        'lon': ds.lon.values,
        'lat': ds.lat.values,
        'cluster_label': unique_labels,
        'is_noise': (labels == -1),
        'reachability': reachability_values,
        'ordering': ordering_values,
        'core_distances': core_values,
    })

    return df_cluster
[92]:
#execute clustering for all algorithms
for clustering_method in tqdm(clustering_dict):
    algorithm= clustering_dict[clustering_method]['algorithm']
    clustering_dict[clustering_method]['result']= cluster(ds, algorithm, clustering_method)
100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 3/3 [00:00<00:00, 80.48it/s]

III. Visualise results πŸ”΄#

πŸ’‘ Make a plot with the following characteristics:

  • Noise points appear in grey

  • Clustered points are colour-coded by cluster label

  • Includes land mask & coastline

πŸš€ CHALLENGE: Include the bathymetry of the region in the background!

[ ]:
# for clustering_method in clustering_dict:

πŸš€ CHALLENGE: Try to calculate the centre of mass of each cluster & add it to the plots above!

πŸ’‘ What to do now?

  • Experiment with changing the parameters defining the algorithms & observe the differences

  • Change the time index & assess the stability of the clusters in time

IV. Statistical analysis of clusters πŸ”΄#

In order to compare the results from different clustering algorithms, we can focus on the following statistical properties:

  • Number of clusters

  • Number of points per cluster

  • Within-cluster concentration [points/\(km^2\)]

[110]:
def calculate_number_clusters(clustering_dict):
    number_clusters={}
    for clustering_method in clustering_dict:
        number_clusters[clustering_method]=len(np.unique(clustering_dict[clustering_method]['result']['cluster_label']))
    return number_clusters

calculate_number_clusters(clustering_dict)
[110]:
{'K-means': 10, 'DBSCAN': 9, 'OPTICS': 6}

πŸš€ Your turn! Create functions to calculate the number of points per cluster for each algorithm & calculate the concentration of each cluster

Hint: to calculate the area covered by points, use scipy.spatial.ConvexHull

[ ]:
#def cardinality_cluster(clustering_dict):

#def within_cluster_concentration(clustering_dict):

πŸš€ Choose two options to visualise your results of points per cluster and concentration of each cluster:

  • Make a box plot with the mean & std property per algorithm

  • Make a histogram per cluster with the distribution points per cluster/ concentration

Still looking for a challenge ? πŸ”΄#