Estimating second derivative in python

Dear MNE community

I am analyzing EEG microstate using MNE-Pycorstate, and I am going to estimate MetaCreation based on the qulity metrics defined in Pycorstate.
However, for some, I need to calculate the second derivative, like cross-validation or Silhouette; however, I couldn’t find a formula to calculate in Python.

I was wondering if you know how to do it?

Here is my code for the second derivative, and using it in MetaCreation only used for cross-validation. Here I asked AI to help, but I am not sure if this is correct.

Best regards,
Pouya

def compute_cv_criterion(X, A, labels):
    """
    How well the cluster centroids explain the assigned EEG maps.
    It shows how much variance the model failed to explain.

    Higher CV_criterion = worse clustering
    Lower CV_criterion = better clustering (more stable, lower residual variance)
    
    If maps match their cluster centroid well = low residual = CV small
    If maps are scattered or misassigned = residual high = CV large
  
    Pascual-Marqui cross-validation criterion.
    X: (n_channels, n_samples)
    A: (n_channels, n_clusters)
    labels: (n_samples,)
    
    """
    C, N = X.shape
    K = A.shape[1]
    
    # Filter out unassigned samples (Cartool threshold < 0.5)
    valid_idx = labels != -1
    X = X[:, valid_idx]
    labels = labels[valid_idx]
    N = X.shape[1]

    if N == 0:
        # In case all samples were unassigned (extremely rare)
        return np.inf, np.inf
    
    # Isolate normalization to copies (does NOT alter originals)
    X = X - X.mean(axis=0, keepdims=True)
    A = A - A.mean(axis=0, keepdims=True)
    A = A / np.linalg.norm(A, axis=0, keepdims=True)

    # Vectorized residual variance ---
    # Compute squared norm of each sample (||x_n||^2)
    x_norm2 = np.sum(X ** 2, axis=0)

    # Get the template assigned to each sample
    A_sel = A[:, labels]  # (C, N)

    # Compute projection (a^T x) for each sample
    proj = np.sum(A_sel * X, axis=0)

    # Residual variance = ||x||^2 - (a^T x)^2
    resid = x_norm2 - proj**2

    # CV criterion
    sigma2 = np.sum(resid) / (N * (C - 1))
    cv = sigma2 * ((C - 1) / (C - K - 1))**2
    
    return cv, sigma2

def _second_derivative_discrete(values: np.ndarray) -> np.ndarray:
    """
    second derivative on a 1D array of metric values.

    values: array ordered by k (ascending).
    Returns: array of same length with second derivative at each index.
    """
    n = len(values)
    d2 = np.empty(n, dtype=float)

    for i in range(n):
        i_prev = max(0, i - 1)
        i_next = min(n - 1, i + 1)

        vm1 = values[i_prev]
        v0  = values[i]
        vp1 = values[i_next]

        d2[i] = vm1 + vp1 - 2.0 * v0

    return d2

def compute_meta_criterion(df):
    """
Compute the MetaCriterion (Custo et al., 2017) combining multiple clustering metrics.

For each number of clusters (k), metric values are ranked across all k.
The MetaCriterion is defined as:

    MetaCriterion(k) = IQM(ranks_k)** 2 / IQR(ranks_k)
    
Where:
    IQM = Interquartile Mean of the metric ranks (central tendency)
    IQR = Interquartile Range of the metric ranks (dispersion)

A higher MetaCriterion indicates high overall performance and strong agreement
among metrics, suggesting the most stable and reliable cluster solution.

   """
    data = df.copy().sort_values("k").reset_index(drop=True)

    # Convert metrics to numeric safely
    for col in ["Silhouette", "Calinski-Harabasz", "Dunn",
                "Davies-Bouldin", "CV"]:
        if col in data.columns:
            data[col] = pd.to_numeric(data[col], errors="coerce")

    # Invert DB index = higher DB_inv is better
    if "Davies-Bouldin" in data.columns:
        data["DB_inv"] = 1.0 / (1.0 + data["Davies-Bouldin"])

    # Second derivative of CV
    if "CV" in data.columns and data["CV"].notna().any():
        cv_vals = data["CV"].to_numpy()
        data["CV_d2"] = _second_derivative_discrete(cv_vals)
    else:
        data["CV_d2"] = np.nan

    # Select metrics to feed into MetaCriterion
    metrics = []
    for m in ["Silhouette", "Calinski-Harabasz", "Dunn", "DB_inv", "CV_d2"]:
        if m in data.columns and data[m].notna().any():
            metrics.append(m)

    if len(metrics) == 0:
        raise ValueError("No valid metrics available for MetaCriterion.")

    # Rank each metric across k (higher rank = better)
    ranks = np.vstack([
        rankdata(data[m], method="average")
        for m in metrics
    ]).T  # shape: (n_k, n_metrics)

    # Compute IQM and IQR of ranks (Custo et al., 2017)
    iqm, iqr_vals = [], []
    for row in ranks:
        sorted_r = np.sort(row)
        q1, q3 = np.percentile(sorted_r, [25, 75])

        in_iqr = sorted_r[(sorted_r >= q1) & (sorted_r <= q3)]
        iqm.append(np.mean(in_iqr))

        iqr_vals.append(
            iqr(sorted_r, rng=(25, 75), nan_policy="omit")
        )

    iqm = np.array(iqm)
    iqr_vals = np.array(iqr_vals)

    # Avoid division by zero in degenerate cases
    iqr_safe = np.where(iqr_vals == 0, np.nan, iqr_vals)

    # Compute MetaCriterion exactly as defined
    data["MetaCriterion"] = (iqm ** 2) / iqr_safe

    return data

Hi Pouya,

Welcome to the MNE community!

The AI was on the right track conceptually, but the code it generated has a couple of subtle bugs. The max() and min() bounds in that for loop will mess up the math at the very beginning and end of your array, which will distort the MetaCriterion ranks for your highest and lowest k values. Plus, looping through a NumPy array is pretty slow!

You actually don’t need to write this from scratch. NumPy has a built-in np.gradient() function that handles those edge-cases automatically and is fully vectorized. To get the second derivative, you just apply it twice.

You can replace that entire function with just this:

import numpy as np

def _second_derivative_discrete(values: np.ndarray) -> np.ndarray:
    # np.gradient handles boundaries automatically. Applying it twice yields the 2nd derivative.
    return np.gradient(np.gradient(values))

Quick tip: Your CV logic looks great, but just in case you weren’t aware, pycrostates actually has many of these clustering metrics (like Silhouette, Dunn, Calinski-Harabasz) already built-in and optimized under pycrostates.metrics!

Best of luck with the microstate analysis! Let us know if you hit any other snags.

Thank you so much for your reply