Source code for glhmm.graphics

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Basic graphics - Gaussian Linear Hidden Markov Model
@author: Diego Vidaurre & Nick Yao Larsen 2025
"""
import os
import math
import logging
import warnings
import itertools
from pathlib import Path

import numpy as np
import pandas as pd
import nibabel as nib
from tqdm import trange

import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
from matplotlib import cm, ticker, gridspec
from matplotlib.colors import LogNorm, Normalize, LinearSegmentedColormap, to_rgba_array
from matplotlib.cm import ScalarMappable
from mpl_toolkits.axes_grid1 import make_axes_locatable
from matplotlib.patches import Patch
from matplotlib.ticker import FormatStrFormatter, ScalarFormatter,MaxNLocator

import seaborn as sns

from nilearn import plotting, surface, image
from nilearn.surface import vol_to_surf
from nilearn.image import resample_to_img
from nilearn._utils.niimg_conversions import check_niimg_3d

from . import utils
from . statistics import resolve_figure_directory, generate_filename, override_dict_defaults
from glhmm.io import *




[docs] def show_trans_prob_mat(hmm,only_active_states=False,show_diag=True,show_colorbar=True): """Displays the transition probability matrix of a given HMM. Parameters: ----------- hmm: HMM object An instance of the HMM class containing the transition probability matrix to be visualized. only_active_states (bool), optional, default=False Whether to display only active states or all states in the matrix. show_diag (bool), optional, defatult=True Whether to display the diagonal elements of the matrix or not. show_colorbar (bool), optional, default=True Whether to display the colorbar next to the matrix or not. """ P = np.copy(hmm.P) if only_active_states: P = P[hmm.active_states,hmm.active_states] K = P.shape[0] if not show_diag: for k in range(P.shape[0]): P[k,k] = 0 P[k,:] = P[k,:] / np.sum(P[k,:]) _,axes = plt.subplots() g = sns.heatmap(ax=axes,data=P,\ cmap='bwr',xticklabels=np.arange(K), yticklabels=np.arange(K), square=True,cbar=show_colorbar) for k in range(K): g.plot([0, K],[k, k], '-k') g.plot([k, k],[0, K], '-k') axes.axhline(y=0, color='k',linewidth=4) axes.axhline(y=K, color='k',linewidth=4) axes.axvline(x=0, color='k',linewidth=4) axes.axvline(x=K, color='k',linewidth=4)
[docs] def show_Gamma(Gamma, line_overlay=None, tlim=None, Hz=1, palette='viridis'): """Displays the activity of the hidden states as a function of time. Parameters: ----------- Gamma : array of shape (n_samples, n_states) The state timeseries probabilities. line_overlay : array of shape (n_samples, 1) A secondary related data type to overlay as a line. tlim : 2x1 array or None, default=None The time interval to be displayed. If None (default), displays the entire sequence. Hz : int, default=1 The frequency of the signal, in Hz. palette (str), default = 'Oranges' The name of the color palette to use. """ T,K = Gamma.shape # Setup colors x = np.round(np.linspace(0.0, 256-1, K)).astype(int) # cmap = plt.get_cmap('plasma').colors cmap = plt.get_cmap(palette) cmap = cmap(np.arange(0, cmap.N))[:, :3] colors = np.zeros((K,3)) for k in range(K): colors[k,:] = cmap[x[k]] # Setup data according to given limits if tlim is not None: T = tlim[1] - tlim[0] data = Gamma[tlim[0] : tlim[1], :] if line_overlay is not None: line = line_overlay[tlim[0] : tlim[1]].copy() else: data = Gamma df = pd.DataFrame(data, index=np.arange(T)/Hz) df = df.divide(df.sum(axis=1), axis=0) # Plot Gamma area ax = df.plot( kind='area', stacked=True, ylim=(0,1), legend=False, color=colors ) # Overlay line if given if line_overlay is not None: df2 = pd.DataFrame(line, index=np.arange(T)/Hz) ax2 = ax.twinx() df2.plot(ax=ax2, legend=False, color="black") ax2.set(ylabel = '') # Adjust axis specifications ax.yaxis.set_major_formatter(mtick.PercentFormatter(1)) ax.set( title = "", xlabel = 'Time [s]', ylabel = 'State probability') ax.margins(0,0) plt.show()
[docs] def show_temporal_statistic(Gamma,indices, statistic='FO',type_plot='barplot'): """Plots a statistic over time for a set of sessions. Parameters: ----------- Gamma : array of shape (n_samples, n_states) The state timeseries probabilities. indices: numpy.ndarray of shape (n_sessions,) The session indices to plot. statistic(str), default='FO' The statistic to compute and plot. Can be 'FO', 'switching_rate' or 'FO_entropy'. type_plot(str), default='barplot' The type of plot to generate. Can be 'barplot', 'boxplot' or 'matrix'. Raises: ------- Exception - Statistic is not one of 'FO', 'switching_rate' or 'FO_entropy'. - type_plot is 'boxplot' and there are less than 10 sessions. - type_plot is 'matrix' and there is only one session. """ s = eval("utils.get_" + statistic)(Gamma,indices) if statistic not in ["FO","switching_rate","FO_entropy"]: raise Exception("statistic has to be 'FO','switching_rate' or 'FO_entropy'") N,K = s.shape sns.set(style='whitegrid') if type_plot=='boxplot': if N < 10: raise Exception("Too few sessions for a boxplot; use barplot") sns.boxplot(data=s,palette='plasma') elif type_plot=='barplot': sns.barplot(data=np.concatenate((s,s)),palette='plasma', errorbar=None) elif type_plot=='matrix': if N < 2: raise Exception("There is only one session; use barplot") fig,ax = plt.subplots() labels_x = np.round(np.linspace(0,N,5)).astype(int) pos_x = np.linspace(0,N,5) if K > 10: labels_y = np.linspace(0,K-1,5) else: labels_y = np.arange(K) im = plt.imshow(s.T,aspect='auto') plt.xticks(pos_x, labels_x) plt.yticks(labels_y, labels_y) ax.set_xlabel('Sessions') ax.set_ylabel('States') fig.tight_layout()
[docs] def show_beta(hmm,only_active_states=True,recompute_states=False, X=None,Y=None,Gamma=None,show_average=None,alpha=1.0): """ Displays the beta coefficients of a given HMM. The beta coefficients can be extracted directly from the HMM structure or reestimated from the data; for the latter, X, Y and Gamma need to be provided as parameters. This is useful for example if one has run the model on PCA space, but wants to show coefficients in the original space. Parameters: ----------- hmm: HMM object An instance of the HMM class containing the beta coefficients to be visualized. only_active_states(bool), optional, default=False If True, only the beta coefficients of active states are shown. recompute_states(bool), optional, default=False If True, the betas will be recomputed from the data and the state time courses X: numpy.ndarray, optional, default=None The timeseries of set of variables 1. Y: numpy.ndarray, optional, default=None The timeseries of set of variables 2. Gamma: numpy.ndarray, optional, default=None The state time courses show_average(bool), optional, default=None If True, an additional row of the average beta coefficients is shown. alpha: float, optional, default=0.1 The regularisation parameter to be applied if the betas are to be recomputed. """ if show_average is None: show_average = not ((X is None) or (Y is None)) K = hmm.get_betas().shape[2] if recompute_states: if (Y is None) or (X is None) or (Gamma is None): raise Exception("The data (X,Y) and the state time courses (Gamma) need \ to be provided if recompute_states is True ") (p,q) = (X.shape[1],Y.shape[1]) beta = np.zeros((p,q,K)) for k in range(K): if hmm.hyperparameters["model_mean"] != 'no': m = (np.expand_dims(Gamma[:,k],axis=1).T @ Yr) / np.sum(Gamma[:,k]) Yr = Y - np.expand_dims(m, axis=0) else: Yr = Y beta[:,:,k] = np.linalg.inv((X * np.expand_dims(Gamma[:,k],axis=1)).T @ X + alpha * np.eye(p)) @ \ ((X * np.expand_dims(Gamma[:,k],axis=1)).T @ Yr) else: beta = hmm.get_betas() (p,q,_) = beta.shape if only_active_states: idx = np.where(hmm.active_states)[0] beta = beta[:,:,idx] K = beta.shape[2] else: idx = np.arange(K) if show_average: Yr = Y - np.expand_dims(np.mean(Y,axis=0), axis=0) b0 = np.linalg.inv(X.T @ X + alpha * np.eye(p)) @ (X.T @ Yr) K += 1 B = np.zeros((p,q,K)) B[:,:,0:K-1] = beta B[:,:,-1] = b0 else: B = beta Bstar1 = np.zeros((p,q,K,K)) for k in range(K): Bstar1[:,:,k,:] = B Bstar2 = np.zeros((p,q,K,K)) for k in range(K): Bstar2[:,:,:,k] = B I1 = np.zeros((p,q,K,K),dtype=object) for j in range(q): I1[:,j,:,:] = str(j) I2 = np.zeros((p,q,K,K),dtype=object) for k in range(K): if show_average and (k==(K-1)): I2[:,:,k,:] = 'Average' else: I2[:,:,k,:] = 'State ' + str(k) I3 = np.zeros((p,q,K,K),dtype=object) for k in range(K): if show_average and (k==(K-1)): I3[:,:,:,k] = 'Average' else: I3[:,:,:,k] = 'State ' + str(k) Bstar1 = np.expand_dims(np.reshape(Bstar1,p*q*K*K,order='F'),axis=0) Bstar2 = np.expand_dims(np.reshape(Bstar2,p*q*K*K,order='F'),axis=0) I1 = np.expand_dims(np.reshape(I1,p*q*K*K,order='F'),axis=0) I2 = np.expand_dims(np.reshape(I2,p*q*K*K,order='F'),axis=0) I3 = np.expand_dims(np.reshape(I3,p*q*K*K,order='F'),axis=0) B = np.concatenate((Bstar1,Bstar2,I1,I2,I3),axis=0).T df = pd.DataFrame(B,columns=('x','y','Variable','beta x','beta y')) g = sns.relplot(x='x', y='y', s=25, hue='Variable', col="beta x", row="beta y", data=df, palette='cool') for item, ax in g.axes_dict.items(): ax.grid(False, axis='x') ax.set_title('')
# def show_r2(r2=None,hmm=None,Gamma=None,X=None,Y=None,indices=None,show_average=False): # if r2 is None: # if (Y is None) or (indices is None): # raise Exception("Y and indices (and maybe X) has to be specified if r2 is not provided") # r2 = hmm.get_r2(X,Y,Gamma,indices) # if show_average: # if (Y is None) or (indices is None): # raise Exception("Y and indices (and maybe X) has to be specified if the average is to computed") # r20 = hmm.get_r2(X,Y,Gamma,indices) # for j in range(N): # tt_j = range(indices[j,0],indices[j,1]) # if X is not None: # Xj = np.copy(X[tt_j,:]) # d = np.copy(Y[tt_j,:]) # if self.hyperparameters["model_mean"] == 'shared': # d -= np.expand_dims(self.mean[0]['Mu'],axis=0) # if self.hyperparameters["model_beta"] == 'shared': # d -= (Xj @ self.beta[0]['Mu']) # for k in range(K): # if self.hyperparameters["model_mean"] == 'state': # d -= np.expand_dims(self.mean[k]['Mu'],axis=0) * np.expand_dims(Gamma[:,k],axis=1) # if self.hyperparameters["model_beta"] == 'state': # d -= (Xj @ self.beta[k]['Mu']) * np.expand_dims(Gamma[:,k],axis=1) # d = np.sum(d**2,axis=0) # d0 = np.copy(Y[tt_j,:]) # if self.hyperparameters["model_mean"] != 'no': # d0 -= np.expand_dims(m,axis=0) # d0 = np.sum(d0**2,axis=0) # r2[j,:] = 1 - (d / d0)
[docs] def custom_colormap(): """ Generate a custom colormap consisting of segments from red to blue. Returns: -------- A custom colormap with defined color segments. """ # Retrieve existing colormaps coolwarm_cmap = plt.get_cmap('coolwarm').reversed() coolwarm_cmap2 = plt.get_cmap('autumn') copper_cmap = plt.get_cmap('copper').reversed() # Define the colors for the colormap copper_color1 = to_rgba_array(copper_cmap(1))[0][:3] # Define the colors for the colormap red = (1,0,0) red2 = (66/255, 13/255, 9/255) orange =(1, 0.5, 0) # red_color1 = to_rgba_array(coolwarm_cmap(0))[0][:3] warm_color2 = to_rgba_array(coolwarm_cmap2(0.8))[0][:3] blue_color1 = to_rgba_array(coolwarm_cmap(0.6))[0][:3] blue_color2 = to_rgba_array(coolwarm_cmap(1.0))[0][:3] # Extract the blue color from coolwarm # Define the color map with three segments: red to white, white, and white to blue cmap_segments = [ (0.0, red2), #(0.002, orange), (0.005, red), # Intermediate color (0.02, orange), # Intermediate color #(0.045, warm_color1), (0.040, warm_color2), # Intermediate color (0.05, copper_color1), (0.09,blue_color1), (1, blue_color2) ] # Create the custom colormap custom_cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_segments) return custom_cmap
[docs] def red_colormap(): """ Generate a custom colormap consisting of red and warm colors. Returns: -------- A custom colormap with red and warm color segments. """ # Get the reversed 'coolwarm' colormap coolwarm_cmap = plt.get_cmap('coolwarm').reversed() # Get the 'autumn' colormap autumn_cmap = plt.get_cmap('autumn') # Define the colors for the colormap red0 = (float(120/255), 0, 0) red = (1, 0, 0) red2 = (66/255, 13/255, 9/255) orange = (1, 0.5, 0) red_color1 = to_rgba_array(coolwarm_cmap(0))[0][:3] warm_color1 = to_rgba_array(autumn_cmap(0.4))[0][:3] warm_color2 = to_rgba_array(autumn_cmap(0.7))[0][:3] # Define the color map with three segments: red to white, white, and white to blue cmap_segments = [ (0.0, red2), (0.3, red0), (0.5, red), (0.7, warm_color1), # Intermediate color (1, warm_color2), # Intermediate color ] # Create the custom colormap custom_cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_segments) return custom_cmap
[docs] def blue_colormap(): """ Generate a custom blue colormap. Returns: -------- A custom colormap with shades of blue. """ coolwarm_cmap = plt.get_cmap('coolwarm').reversed() copper_cmap = plt.get_cmap('copper').reversed() # cool_cmap = plt.get_cmap('cool') # Define the colors for the colormap # white = (1, 1, 1) # White color copper_color1 = to_rgba_array(copper_cmap(1))[0][:3] # cool_color1 = to_rgba_array(cool_cmap(0.3))[0][:3] # blue_color1 = to_rgba_array(coolwarm_cmap(0.5))[0][:3] blue_color2 = to_rgba_array(coolwarm_cmap(0.7))[0][:3] blue_color3 = to_rgba_array(coolwarm_cmap(1.0))[0][:3] # Extract the blue color from coolwarm # Define the color map with three segments: red to white, white, and white to blue cmap_segments = [ (0, copper_color1), #(0.15, cool_color1), (0.2,blue_color2), #(0.7, cool_color1), (1, blue_color3) ] # Create the custom colormap blue_cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_segments) return blue_cmap
[docs] def create_cmap_alpha(cmap_list,color_array, alpha): """ Modify the colors in a colormap based on an alpha threshold. Parameters: ----------- cmap_list (numpy.ndarray) List of colors representing the original colormap. color_array (numpy.ndarray) Array of color values corresponding to each colormap entry. alpha (float) Alpha threshold for modifying colors. Returns: -------- Modified list of colors representing the colormap with adjusted alpha values. """ cmap_list_alpha =cmap_list.copy() _,idx_alpha =np.where(color_array <= alpha) coolwarm_cmap = plt.get_cmap('coolwarm').reversed() #coolwarm_cmap2 = plt.get_cmap('autumn') red = (1,0,0) orange =(1, 0.5, 0) red_color1 = to_rgba_array(coolwarm_cmap(0))[0][:3] list_red = [red,red_color1,orange] idx_interval =int(idx_alpha[-1]/(len(list_red)-1)) # Recolor the first to -idx_interval cmap_list_alpha[:idx_alpha[-1],:3]=list_red[0] for i in range(len(list_red)-1): cmap_list_alpha[idx_interval*(i+1):idx_alpha[-1]+1,:3]=list_red[i+1] return cmap_list_alpha
[docs] def interpolate_colormap(cmap_list): """ Create a new colormap with the modified color_array. Parameters: -------------- cmap_list (numpy.ndarray): Original color array for the colormap. Returns: ---------- modified_cmap (numpy.ndarray): Modified colormap array. """ # Create a new colormap with the modified color_array modified_cmap = np.ones_like(cmap_list) for channel_idx in range(3): # Extract the channel values from the colormap channel_values = cmap_list[:, channel_idx] # Get unique values, their indices, and counts unique_values, unique_indices, counts = np.unique(channel_values, return_index=True, return_counts=True) # Create a copy unique_indices that is will get reduced for every interation remaining_indices = unique_indices.copy() remaining_counts = counts.copy() # Create a list to store the interpolated values new_map_list = [] for _ in range(len(unique_values)-1): # Find the minimum value min_value = np.min(remaining_indices) # Locate the index min_idx =np.where(unique_indices==min_value) # Remove the minimum value from the array remaining_counts = remaining_counts[remaining_indices != min_value] remaining_indices = remaining_indices[remaining_indices != min_value] # Find the location of the next minimum value from remaining_indices next_min_value_idx =np.where(unique_indices==np.min(remaining_indices)) # Calculate interpolation space difference space_diff = (unique_values[next_min_value_idx]-unique_values[min_idx])/int(counts[min_idx]) # Append interpolated values to the list new_map_list.append(np.linspace(unique_values[min_idx], unique_values[next_min_value_idx]-space_diff, int(counts[min_idx]))) last_val =np.where(unique_indices==np.min(remaining_indices)) for _ in range(int(remaining_counts)): # Append the last value to the new_map_list new_map_list.append([unique_values[last_val]]) con_values= np.squeeze(np.concatenate(new_map_list)) # Insert values into the new color map modified_cmap [:,channel_idx]=con_values return modified_cmap
[docs] def plot_p_value_matrix(pval, alpha = 0.05, normalize_vals=True, figsize=(9, 5), title_text="Heatmap (p-values)", fontsize_labels=12, fontsize_title=14, annot=False, cmap_type='default', cmap_reverse=True, xlabel="", ylabel="", xticklabels=None, yticklabels = None,x_tick_min=None, x_tick_max=None, num_x_ticks=None, num_y_ticks=None, tick_positions = [0.001, 0.01, 0.05, 0.1, 0.3, 1], none_diagonal = False, num_colors = 256, xlabel_rotation=0, save_path=None, return_fig= False): """ Plot a heatmap of p-values. Parameters: ----------- pval (numpy.ndarray) The p-values data to be plotted. normalize_vals : (bool, optional), default=False: If True, the data range will be normalized from 0 to 1. figsize tuple, optional, default=(12,7): Figure size in inches (width, height). steps (int, optional), default=11: Number of steps for x and y-axis ticks. title_text (str, optional), default= "Heatmap (p-values)" Title text for the heatmap. fontsize_labels (int, optional), default=12: Font size for the x and y-axis labels. fontsize_title (int, optional), default=14 fontsize of title annot (bool, optional), default=False: If True, annotate each cell with the numeric value. cmap (str, optional), default= "default": Colormap to use. Default is a custom colormap based on 'coolwarm'. xlabel (str, optional), default="" X-axis label. If not provided, default labels based on the method will be used. ylabel (str, optional), default="" Y-axis label. If not provided, default labels based on the method will be used. xticklabels (List[str], optional), default=None: If not provided, labels will be numbers equal to shape of pval.shape[1]. Else you can define your own labels, e.g., xticklabels=['sex', 'age']. x_tick_min (float, optional), default=None: Minimum value for the x-tick labels. x_tick_max (float, optional), default=None: Maximum value for the x-tick labels. num_x_ticks (int, optional), default=5: Number of x-ticks. tick_positions (list, optional), default=[0, 0.001, 0.01, 0.05, 0.1, 0.3, 1] Positions of ticks on the colorbar. none_diagonal (bool, optional), default=False: If you want to turn the diagonal into NaN numbers. num_colors (numpy.ndarray), default=259: Define the number of different shades of color. xlabel_rotation (numpy-mdarray), default=0 The degree of rotation for the labels in the x-axis save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path """ if pval.ndim>2: pval_in = np.squeeze(pval) if pval_in.ndim>2: raise ValueError(f"The p-value is {pval.ndim} dimensional\n" "Adjust your p-values so it becomes 2-dimensional") else: pval_in = pval.copy() if pval_in.ndim==0: pval_in = np.reshape(pval_in, (1, 1)) if xlabel_rotation==45: ha ="right" else: ha = "center" if pval_in.ndim==2: num_x_ticks = num_x_ticks if num_x_ticks is not None else pval_in.shape[1] if pval_in.shape[1]<20 else 5 num_y_ticks = num_y_ticks if num_y_ticks is not None else pval_in.shape[0] if pval_in.shape[0]<20 else 5 else: num_x_ticks = num_x_ticks if num_x_ticks is not None else pval_in.shape[0] if pval_in.shape[0]<20 else 5 #num_y_ticks = num_x_ticks if num_x_ticks is not None else pval.shape[0] if pval.shape[0]<20 else 5 # Ensure p-values are within the log range pval_min = -3 pval_in[pval_in != 0] = np.clip(pval_in[pval_in != 0], 10**pval_min, 1) # Convert to log scale color_array = np.logspace(pval_min, 0, num_colors).reshape(1, -1) fig, axes = plt.subplots(figsize=figsize) if len(pval_in.shape)==1: pval_in =np.expand_dims(pval_in,axis=0) if cmap_type=='default': if alpha == None and normalize_vals==False: cmap = cm.coolwarm.reversed() elif alpha == None and normalize_vals==True: # Create custom colormap coolwarm_cmap = custom_colormap() # Create a new colormap with the modified color_array cmap_list = coolwarm_cmap(color_array)[0] modified_cmap=interpolate_colormap(cmap_list) # Create a LinearSegmentedColormap cmap = LinearSegmentedColormap.from_list('custom_colormap', modified_cmap) else: # Make a jump in color after alpha # Get blue colormap cmap_blue = blue_colormap() # Create a new colormap with cmap_list = cmap_blue(color_array)[0] red_cmap = red_colormap() blue_cmap = blue_colormap() # Specify the number of elements you want (e.g., 50) num_elements_red = np.sum(color_array <= alpha) num_elements_blue = np.sum(color_array > alpha) # Generate equally spaced values between 0 and 1 colormap_val_red = np.linspace(0, 1, num_elements_red) colormap_val_blue = np.linspace(0, 1, num_elements_blue) # Apply the colormap to the generated values cmap_red = red_cmap(colormap_val_red) cmap_blue = blue_cmap(colormap_val_blue) # overwrite the values below alpha cmap_list[:num_elements_red,:]=cmap_red cmap_list[num_elements_red:,:]=cmap_blue cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_list) else: # Get the colormap dynamically based on the input string cmap = getattr(cm, cmap_type, None) if cmap_reverse: cmap =cmap.reversed() # Set the value of 0 to white in the colormap if none_diagonal: # Create a copy of the pval matrix pval_with_nan_diagonal = np.copy(pval_in) # Set the diagonal elements to NaN in the copied matrix np.fill_diagonal(pval_with_nan_diagonal, np.nan) pval_in = pval_with_nan_diagonal.copy() if normalize_vals: norm = LogNorm(vmin=10**pval_min, vmax=1) heatmap = sns.heatmap(pval_in, ax=axes, cmap=cmap, annot=annot, fmt=".3f", cbar=False, norm=norm) else: heatmap = sns.heatmap(pval_in, ax=axes, cmap=cmap, annot=annot, fmt=".3f", cbar=False) # Add labels and title axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title_text, fontsize=fontsize_title) # Number of x-tick steps steps=len(pval_in) # define x_ticks x_tick_positions = np.linspace(0, pval_in.shape[1]-1, num_x_ticks).astype(int) # Generate x-tick labels based on user input or default to time points if x_tick_min is not None and x_tick_max is not None: x_tick_labels = np.linspace(x_tick_min, x_tick_max, num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_min is not None: x_tick_labels = np.linspace(x_tick_min, pval_in.shape[1], num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_max is not None: x_tick_labels = np.linspace(0, x_tick_max, num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) else: x_tick_labels = x_tick_positions # Set the x-axis ticks and labels if xticklabels is not None: if isinstance(xticklabels, str): # Generate labels like "Hello 1", "Hello 2", ... xticklabels = [f"{xticklabels} {i + 1}" for i in range(len(x_tick_labels))] elif not isinstance(xticklabels, list) or len(xticklabels) != len(x_tick_labels): warnings.warn(f"xticklabels must be a list matching x_tick_labels, or a string. Using default numeric labels instead.") xticklabels = [f"Feature {i + 1}" for i in range(len(x_tick_labels))] axes.set_xticks(x_tick_positions + 0.5) axes.set_xticklabels(xticklabels, rotation=xlabel_rotation, fontsize=10, ha=ha) elif pval_in.shape[1] > 1: axes.set_xticks(x_tick_positions + 0.5) axes.set_xticklabels(x_tick_labels+1, rotation=0, fontsize=10, ha=ha) else: axes.set_xticklabels([]) # Define y_ticks y_tick_positions = np.linspace(0, pval_in.shape[0]-1, num_y_ticks).astype(int) if pval_in.shape[0]>1: # Set y-axis tick labels if yticklabels is not None: if isinstance(yticklabels, str): # Generate labels like "Label 1", "Label 2", ... yticklabels = [f"{yticklabels} {i + 1}" for i in range(len(y_tick_positions))] elif not isinstance(yticklabels, list) or len(yticklabels) != len(y_tick_positions): warnings.warn(f"yticklabels must be a list matching y_tick_positions, or a string. Using default numeric labels instead.") yticklabels = [f"{i + 1}" for i in range(len(y_tick_positions))] axes.set_yticks(y_tick_positions + 0.5) axes.set_yticklabels(yticklabels, fontsize=10, rotation=0) else: # Fallback: use index numbers axes.set_yticks(y_tick_positions + 0.5) axes.set_yticklabels(y_tick_positions+1, fontsize=10, rotation=0) else: axes.set_yticklabels([]) # Create an axes on the right side of ax. The width of cax will be 5% # of ax and the padding between cax and ax will be fixed at 0.05 inch. if normalize_vals: # Define tick positions and labels tick_positions = np.array(tick_positions) # Add colorbar divider = make_axes_locatable(axes) cax = divider.append_axes("right", size="5%", pad=0.05) colorbar = plt.colorbar( plt.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cax, ticks=tick_positions, format="%.3g" ) else: divider = make_axes_locatable(axes) cax = divider.append_axes("right", size="3.5%", pad=0.05) # Create a custom colorbar colorbar = plt.colorbar(heatmap.get_children()[0], cax=cax) # Set the ticks to range from the bottom to the top of the colorbar # Get the minimum and maximum values from your data min_value = np.nanmin(pval_in) max_value = np.nanmax(pval_in) # Set ticks with at least 5 values evenly spaced between min and max colorbar.set_ticks(np.linspace(min_value, max_value, 5).round(2)) #colorbar.set_ticks([0, 0.25, 0.5, 1]) # Adjust ticks as needed plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') # Show the plot if return_fig: return fig else: plt.show()
[docs] def plot_permutation_distribution(base_statistics_perms, title_text="Permutation Distribution",xlabel="Test Statistic Values",ylabel="Density", save_path=None, return_fig=False): """ Plot the histogram of the permutation with the observed statistic marked. Parameters: ----------- base_statistics_perms (numpy.ndarray) An array containing the permutation values. title_text (str, optional), default="Permutation Distribution": Title text of the plot. xlabel (str, optional), default="Test Statistic Values" Text of the xlabel. ylabel (str, optional), default="Density" Text of the ylabel. save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path """ fig =plt.figure() sns.histplot(base_statistics_perms, kde=True) plt.axvline(x=base_statistics_perms[0], color='red', linestyle='--', label='Observed Statistic') plt.xlabel(xlabel) plt.ylabel(ylabel) plt.title(title_text, fontsize=14) plt.legend() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_scatter_with_labels(p_values, alpha=0.05, title_text="", xlabel=None, ylabel=None, xlim_start=0.9, ylim_start=0, save_path=None, return_fig=False): """ Create a scatter plot to visualize p-values with labels indicating significant points. Parameters: ----------- p_values (numpy.ndarray) An array of p-values. Can be a 1D array or a 2D array with shape (1, 5). alpha (float, optional), default=0.05: Threshold for significance. title_text (str, optional), default="": The title text for the plot. xlabel (str, optional), default=None: The label for the x-axis. ylabel (str, optional), default=None: The label for the y-axis. xlim_start (float, optional), default=-5 Start position of x-axis limits. ylim_start (float, optional), default=-0.1 Start position of y-axis limits. save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path Notes: ------ Points with p-values less than alpha are considered significant and marked with red text. """ # If p_values is a 2D array with shape (1, 5), flatten it to 1D if len(p_values.shape) == 2 and p_values.shape[0] == 1 and p_values.shape[1] == 5: p_values = p_values.flatten() # Create a binary mask based on condition (values below alpha) mask = p_values < alpha # Create a hue p_values based on the mask (True/False values) hue = mask.astype(int) # Set the color palette and marker style markers = ["o", "s"] # Create a scatter plot with hue and error bars fig, ax = plt.subplots(figsize=(8, 6)) sns.scatterplot(x=np.arange(0, len(p_values)) + 1, y=-np.log(p_values), hue=hue, style=hue, markers=markers, s=40, edgecolor='k', linewidth=1, ax=ax) # Add labels and title to the plot if not title_text: ax.set_title(f'Scatter Plot of P-values, alpha={alpha}', fontsize=14) else: ax.set_title(title_text, fontsize=14) if xlabel is None: ax.set_xlabel('Index', fontsize=12) else: ax.set_xlabel(xlabel, fontsize=12) if ylabel is None: ax.set_ylabel('-log(p-values)', fontsize=12) else: ax.set_ylabel(ylabel, fontsize=12) # Add text labels for indices where the mask is True for i, m in enumerate(mask): if m: ax.text(i + 1, -np.log(p_values[i]), str(i+1), ha='center', va='bottom', color='red', fontsize=10) # Adjust legend position and font size ax.legend(title="Significance", loc="upper right", fontsize=10, bbox_to_anchor=(1.25, 1)) # Set axis limits to focus on the relevant data range ax.set_xlim(xlim_start, len(p_values) + 1) ax.set_ylim(ylim_start, np.max(-np.log(p_values)) * 1.2) # # Customize plot background and grid style # sns.set_style("white") # ax.grid(color='lightgray', linestyle='--') # Show the plot plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_vpath(viterbi_path, signal=None, idx_data=None, figsize=(7, 4), fontsize_labels=13, fontsize_title=16, yticks=None, time_conversion_rate=None, xlabel="Timepoints", ylabel="", title="Viterbi Path", cmap=None, signal_label="Signal", show_legend=True, vertical_linewidth=1.5, save_path=None, return_fig=False): """ Plot Viterbi path with optional signal overlay. Parameters: ----------- viterbi_path The Viterbi path data matrix. signal (numpy.ndarray), optional Signal data to overlay on the plot. Default is None. idx_data (numpy.ndarray), optional Array representing time intervals. Default is None. figsize (tuple), optional Figure size. Default is (7, 4). fontsize_labels (int), optional Font size for axis labels. Default is 13. fontsize_title (int), optional Font size for plot title. Default is 16. yticks (bool), optional Whether to show y-axis ticks. Default is None. time_conversion_rate (float), optional Conversion rate from time steps to seconds. Default is None. xlabel (str), optional Label for the x-axis. Default is "Timepoints". ylabel (str), optional Label for the y-axis. Default is "". title (str), optional Title for the plot. Default is "Viterbi Path". cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). signal_label (str, optional Label for the signal plot. Default is "Signal". show_legend (bool), optional Whether to show the legend. Default is True. vertical_linewidth (float), optional Line width for vertical gray lines. Default is 1.5. save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path """ num_states = viterbi_path.shape[1] colors = sns.color_palette("Set3", n_colors=num_states) # Assign distinct colors for each state if cmap is not None: # Assign distinct colors for each component valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc.") cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) fig, axes = plt.subplots(figsize=figsize) # Plot Viterbi path if time_conversion_rate is not None: time_seconds = np.arange(viterbi_path.shape[0]) / time_conversion_rate axes.stackplot(time_seconds, viterbi_path.T, colors=colors, labels=[f'State {i + 1}' for i in range(num_states)]) if xlabel == "Timepoints": xlabel = "Time (seconds)" axes.set_xlabel(xlabel, fontsize=fontsize_labels) else: axes.stackplot(np.arange(viterbi_path.shape[0]), viterbi_path.T, colors=colors, labels=[f'State {i + 1}' for i in range(num_states)]) axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title, fontsize=fontsize_title) # Plot signal overlay if signal is not None: # Normalize the sig_data to the range [0, 1] min_value = np.min(signal) max_value = np.max(signal) normalized_sig_data = ((signal - min_value) / (max_value - min_value)) if time_conversion_rate is not None: time_seconds = np.arange(len(normalized_sig_data)) / time_conversion_rate axes.plot(time_seconds, normalized_sig_data, color='black', label=signal_label) axes.set_xlabel(xlabel, fontsize=fontsize_labels) else: axes.plot(normalized_sig_data, color='black', label=signal_label) # Draw vertical gray lines for T_t intervals if idx_data is not None: for idx in idx_data[:-1, 1]: axes.axvline(x=idx, color='gray', linestyle='--', linewidth=vertical_linewidth) # Show legend if show_legend: axes.legend(title='States', loc='upper left', bbox_to_anchor=(1, 1)) if yticks and signal is not None: scaled_values = [int(val * len(np.unique(normalized_sig_data))) for val in np.unique(normalized_sig_data)] # Set y-ticks with formatted integers axes.set_yticks(np.unique(normalized_sig_data), scaled_values) else: # Remove x-axis tick labels axes.set_yticks([]) # Remove the frame around the plot axes.spines['top'].set_visible(False) axes.spines['right'].set_visible(False) axes.spines['bottom'].set_visible(False) axes.spines['left'].set_visible(False) # Adjust tick label font size axes.tick_params(axis='both', labelsize=fontsize_labels) plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_average_probability(Gamma_data, title='Average probability for each state', fontsize=16, figsize=(7, 5), vertical_lines=None, line_colors=None, highlight_boxes=False, save_path=None, return_fig=False): """ Plots the average probability for each state over time. Parameters: ----------- Gamma_data (numpy.ndarray) Can be a 2D or 3D array representing gamma values. Shape: (num_timepoints, num_states) or (num_timepoints, num_trials, num_states) title (str, optional), default='Average probability for each state': Title for the plot. fontsize (int, optional), default=16: Font size for labels and title. figsize (tuple, optional), default=(8,6): Figure size (width, height) in inches). vertical_lines (list of tuples, optional), default=None: List of pairs specifying indices for vertical lines. line_colors (list of str or bool, optional), default=None: List of colors for each pair of vertical lines. If True, generates random colors (unless a list is provided). highlight_boxes (bool, optional), default=False: Whether to include highlighted boxes for each pair of vertical lines. save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path """ # Initialize an array for average gamma values Gamma_avg = np.zeros((Gamma_data.shape[0], Gamma_data.shape[-1])) if Gamma_data.ndim==3: # Calculate and store average gamma values for i in range(Gamma_data.shape[0]): filtered_values = Gamma_data[i, :, :] Gamma_avg[i, :] = np.mean(filtered_values, axis=0).round(3) else: Gamma_avg = Gamma_data.copy() # Set figure size fig, axes = plt.subplots(1, figsize=figsize) # Plot each line with a label for state in range(Gamma_data.shape[-1]): plt.plot(Gamma_avg[:, state], label=f'State {state + 1}') # Add vertical lines, line colors, and highlight boxes if vertical_lines: for idx, pair in enumerate(vertical_lines): color = line_colors[idx] if line_colors and len(line_colors) > idx else 'gray' axes.axvline(x=pair[0], color=color, linestyle='--', linewidth=1) axes.axvline(x=pair[1], color=color, linestyle='--', linewidth=1) if highlight_boxes: rect = plt.Rectangle((pair[0], axes.get_ylim()[0]), pair[1] - pair[0], axes.get_ylim()[1] - axes.get_ylim()[0], linewidth=0, edgecolor='none', facecolor=color, alpha=0.2) axes.add_patch(rect) # Add labels and legend plt.xlabel('Timepoints', fontsize=fontsize) plt.ylabel('Average probability', fontsize=fontsize) plt.title(title, fontsize=fontsize) # Add legend for the highlighted boxes if highlight_boxes: legend_rect = plt.Rectangle((0, 0), 1, 1, linewidth=0, edgecolor='none', facecolor='gray', alpha=0.2, label='Interval with significant difference') plt.legend(handles=[legend_rect], loc='upper right') # Place legend for the lines to the right of the figure plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left') # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_FO(FO, figsize=(8, 4), fontsize_ticks=12, fontsize_labels=14, fontsize_title=16, width=0.8, xlabel='Subject', ylabel='Fractional occupancy', title='State Fractional Occupancies', cmap=None, show_legend=True, num_x_ticks=11, num_y_ticks=5, pad_y_spine=None, save_path=None, return_fig=False): """ Plot fractional occupancies for different states. Parameters: ----------- FO (numpy.ndarray): Fractional occupancy data matrix. figsize (tuple, optional), default=(8,4): Figure size. fontsize_ticks (int, optional), default=12: Font size for tick labels. fontsize_labels (int, optional), default=14: Font size for axes labels. fontsize_title (int, optional), default=16: Font size for plot title. width (float, optional), default=0.5: Width of the bars. xlabel (str, optional), default='Subject': Label for the x-axis. ylabel (str, optional), default='Fractional occupancy': Label for the y-axis. title (str, optional), default='State Fractional Occupancies': Title for the plot. cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). show_legend (bool, optional), default=True: Whether to show the legend. num_x_ticks (int, optional), default=11: Number of ticks for the x-axis. num_y_ticks (int, optional), default=5: Number of ticks for the y-axis. pad_y_spine (float, optional), default=None: Shifting the positin of the spine for the y-axis. save_path (str, optional), default=None: If a string is provided, it saves the figure to that specified path. """ fig, axes = plt.subplots(figsize=figsize) bottom = np.zeros(FO.shape[0]) sessions = np.arange(1, FO.shape[0] + 1) num_states = FO.shape[1] # Assign distinct colors for each component if cmap is not None: # Assign distinct colors for each component valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc.") cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) for k in range(num_states): axes.bar(sessions, FO[:, k], bottom=bottom, color=colors[k], width=width) bottom += FO[:, k] axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title, fontsize=fontsize_title) ticks = np.linspace(1, FO.shape[0], FO.shape[0]).astype(int) # If there are more than 11 states then reduce the number of ticks if len(ticks) > 11: n_ticks = num_x_ticks else: n_ticks = len(ticks) axes.set_xticks(np.linspace(1, FO.shape[0], n_ticks).astype(int)) axes.set_yticks(np.linspace(0, 1, num_y_ticks)) # Adjust tick label font size axes.tick_params(axis='x', labelsize=fontsize_ticks) axes.tick_params(axis='y', labelsize=fontsize_ticks) # Remove the frame around the plot axes.spines['top'].set_visible(False) axes.spines['right'].set_visible(False) axes.spines['bottom'].set_visible(False) axes.spines['left'].set_visible(False) if pad_y_spine is None: pad_y_spine = -figsize[0]*2.2 axes.spines['left'].set_position(('outward', pad_y_spine)) # Adjust the outward position of the left spine # Add a legend if needed if show_legend: axes.legend([f'State {i + 1}' for i in range(FO.shape[1])], fontsize=fontsize_ticks, loc='upper left', bbox_to_anchor=(1, 1)) plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_switching_rates(SR, figsize=(8, 4), fontsize_ticks=12,fontsize_labels=14,fontsize_title=16,width=0.18,group_gap=None, xlabel='Subject', ylabel='Switching Rate',title='State Switching Rates',cmap=None, show_legend=True,num_x_ticks=11,num_y_ticks=5,pad_y_spine=None,save_path=None, return_fig=False): """ Plot grouped bar charts of switching rates for different states across sessions. Parameters ---------- SR : np.ndarray Array of shape (n_sessions, n_states) containing switching rates. figsize : tuple of float, default=(8, 4) Size of the figure in inches (width, height). fontsize_ticks : int, default=12 Font size for axis tick labels. fontsize_labels : int, default=14 Font size for x-axis and y-axis labels. fontsize_title : int, default=16 Font size for the title. width : float, default=0.18 Width of each individual bar representing a state. group_gap : float or None, default=None Horizontal spacing between each group (session). If None, uses width * 0.5. xlabel : str, default='Subject' Label for the x-axis. ylabel : str, default='Switching Rate' Label for the y-axis. title : str, default='State Switching Rates' Title of the plot. cmap : str or None, default=None Name of a matplotlib colormap. If None, a default discrete colormap is used. show_legend : bool, default=True Whether to display the legend for states. num_x_ticks : int, default=11 Number of ticks to show on the x-axis (session axis). num_y_ticks : int, default=5 Number of ticks to show on the y-axis. pad_y_spine : float or None, default=None Padding to shift the left spine outward. If None, it's computed from the figure width. save_path : str or None, default=None If provided, saves the figure to this file path. """ fig, axes = plt.subplots(figsize=figsize, constrained_layout=True) num_sessions = SR.shape[0] num_states = SR.shape[1] total_width = num_states * width if group_gap is None: group_gap = width * 0.5 # default spacing between groups # Calculate x positions with gap between groups group_centers = np.arange(num_sessions) * (total_width + group_gap) # Assign distinct colors for each component if cmap is not None: valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) else: warnings.warn( f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc." ) cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) # Plot bars for k in range(num_states): offset = width * k axes.bar(group_centers + offset, SR[:, k], width, color=colors[k]) # Labeling axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title, fontsize=fontsize_title) # Set x-ticks centered on groups xticks_pos = group_centers + total_width / 2 - width / 2 session_labels = np.arange(1, num_sessions + 1) if num_sessions > num_x_ticks: xtick_idx = np.linspace(0, num_sessions - 1, num_x_ticks).astype(int) axes.set_xticks(xticks_pos[xtick_idx]) axes.set_xticklabels(session_labels[xtick_idx]) else: axes.set_xticks(xticks_pos) axes.set_xticklabels(session_labels) # Y-ticks axes.yaxis.set_major_locator(MaxNLocator(nbins=num_y_ticks, prune=None, integer=False)) axes.ticklabel_format(style='sci', axis='y', scilimits=(-3, 3)) axes.tick_params(axis='both', labelsize=fontsize_ticks) # Remove spines axes.spines['top'].set_visible(False) axes.spines['right'].set_visible(False) axes.spines['bottom'].set_visible(False) axes.spines['left'].set_visible(False) if pad_y_spine is None: pad_y_spine = -figsize[0] * 2.2 axes.spines['left'].set_position(('outward', pad_y_spine)) if show_legend: axes.legend( ['State {}'.format(i + 1) for i in range(num_states)], fontsize=fontsize_labels, loc='upper left', bbox_to_anchor=(1, 1) ) if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_state_lifetimes(LT, figsize=(8, 4), fontsize_ticks=12, fontsize_labels=14, fontsize_title=16, width=0.18, group_gap = None, xlabel='Subject', ylabel='Lifetime', title='State Lifetimes', cmap= None, show_legend=True, num_x_ticks=11, num_y_ticks=5, pad_y_spine=None, save_path=None, return_fig=False): """ Plot state lifetimes for different states. Parameters: ----------- LT (numpy.ndarray): State lifetime (dwell time) data matrix. figsize (tuple, optional), default=(8, 4): Figure size. fontsize_ticks (int, optional), default=12: Font size for tick labels. fontsize_labels (int, optional), default=14: Font size for axeses labels. fontsize_title (int, optional), default=16: Font size for plot title. width (float, optional), default=0.18: Width of the bars. group_gap : float or None Gap between groups of bars (sessions). If None, defaults to width * 0.5. xlabel (str, optional), default='Subject': Label for the x-axesis. ylabel (str, optional), default='Lifetime': Label for the y-axesis. title (str, optional), default='State Lifetimes': Title for the plot. cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). show_legend (bool, optional), default=True: Whether to show the legend. num_x_ticks (int, optional), default=11: Number of ticks for the x-axis. num_y_ticks (int, optional), default=5: Number of ticks for the y-axis. pad_y_spine (float, optional), default=None: Shifting the positin of the spine for the y-axis. save_path (str, optional), default=None If a string is provided, it saves the figure to that specified path """ fig, axes = plt.subplots(figsize=figsize, constrained_layout=True) num_sessions = LT.shape[0] num_states = LT.shape[1] total_width = num_states * width if group_gap is None: group_gap = width * 0.5 # Calculate x positions for each group group_centers = np.arange(num_sessions) * (total_width + group_gap) # Assign distinct colors if cmap is not None: valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'.") cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') colors = [cmap(i) for i in range(num_states)] else: colors = get_distinct_colors(num_states, cmap) # Plot bars for k in range(num_states): offset = width * k axes.bar(group_centers + offset, LT[:, k], width, color=colors[k]) axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title, fontsize=fontsize_title) # Center x-ticks under each group xticks_pos = group_centers + total_width / 2 - width / 2 session_labels = np.arange(1, num_sessions + 1) if num_sessions > num_x_ticks: xtick_idx = np.linspace(0, num_sessions - 1, num_x_ticks).astype(int) axes.set_xticks(xticks_pos[xtick_idx]) axes.set_xticklabels(session_labels[xtick_idx]) else: axes.set_xticks(xticks_pos) axes.set_xticklabels(session_labels) # Y-ticks axes.yaxis.set_major_locator(MaxNLocator(nbins=num_y_ticks, prune=None, integer=False)) axes.ticklabel_format(style='sci', axis='y', scilimits=(-3, 3)) axes.tick_params(axis='both', labelsize=fontsize_ticks) # Remove spines axes.spines['top'].set_visible(False) axes.spines['right'].set_visible(False) axes.spines['bottom'].set_visible(False) axes.spines['left'].set_visible(False) if pad_y_spine is None: pad_y_spine = -figsize[0] * 2.2 axes.spines['left'].set_position(('outward', pad_y_spine)) if show_legend: axes.legend( ['State {}'.format(i + 1) for i in range(num_states)], fontsize=fontsize_labels, loc='upper left', bbox_to_anchor=(1, 1) ) if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_initial_state_probabilities(init_stateP, cmap='coolwarm', figsize=(2, 4), title_text="Initial State Probabilities", fontsize_labels=12, fontsize_title=14, tick_size=10, num_ticks=None, save_path=None, return_fig=False): """ Plot the initial state probabilities of a Hidden Markov Model as a vertical heatmap. Parameters ---------- init_probs : np.ndarray 1D array of shape (n_states,) representing the initial state probabilities. cmap : str, default="coolwarm" Colormap used for the heatmap. figsize : tuple of float, default=(2.5, 4) Size of the full figure in inches (width, height). title_text : str, default="Initial State Probabilities" Title displayed above the plot. fontsize_labels : int, default=12 Font size for axis labels and colorbar label. fontsize_title : int, default=14 Font size for the plot title. tick_size : int, default=10 Font size for tick labels. num_ticks : int or None, default=None Number of ticks to show on the y-axis and colorbar. If None, automatically adjusts based on the number of states. save_path : str or None, default=None If provided, saves the figure to this file path. return_fig : bool, default=False If True, returns the figure object instead of displaying it. """ init_stateP = np.atleast_1d(init_stateP) if init_stateP.ndim != 1: raise ValueError("Initial state probabilities must be 1-dimensional.") n_states = len(init_stateP) if num_ticks is None: num_ticks = n_states if n_states <= 20 else 5 fig, ax = plt.subplots(figsize=figsize) # Use seaborn heatmap but disable default colorbar heatmap = sns.heatmap(init_stateP.reshape(-1, 1), ax=ax, cmap=cmap, cbar=False, xticklabels=False, yticklabels=False) # Axis formatting ax.set_title(title_text, fontsize=fontsize_title) ax.set_xlabel("") ax.set_ylabel("State", fontsize=fontsize_labels) ax.tick_params(labelsize=tick_size) # Y-ticks ax.set_yticks(np.linspace(0.5, n_states - 0.5, num_ticks)) ax.set_yticklabels(np.linspace(1, n_states, num_ticks, dtype=int)) # Add colorbar manually divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="40%", pad=0.1) cbar = plt.colorbar(ax.collections[0], cax=cax) cbar.set_label("Probability", fontsize=fontsize_labels) cbar.ax.tick_params(labelsize=tick_size) cbar.locator = MaxNLocator(nbins=num_ticks) cbar.update_ticks() cbar.ax.yaxis.set_major_formatter(FormatStrFormatter('%.2f')) plt.tight_layout() if save_path: plt.savefig(save_path, bbox_inches="tight") if return_fig: return fig else: plt.show()
[docs] def plot_state_means_activations(state_means, cmap_type='coolwarm', cmap_reverse=False, figsize=(3, 5), title_text="State Mean Activations", xlabel="State", ylabel="Brain region", fontsize_labels=12, fontsize_title=14, tick_size=10, annot=False, xticklabels=None, yticklabels=None, xlabel_rotation=None, num_x_ticks=None, num_y_ticks=None, save_path=None, return_fig=False): """ Plot a heatmap of state mean activations with optional tick formatting and custom labels. Parameters ---------- state_means : np.ndarray Array of shape (n_states, n_features) or (n_features, n_states). Each column represents the mean activation per state across features (e.g., brain regions). cmap_type : str, default='coolwarm' Name of the matplotlib colormap to use. cmap_reverse : bool, default=False If True, reverses the selected colormap. figsize : tuple of float, default=(3, 5) Size of the figure in inches (width, height). title_text : str, default="State Mean Activations" Title to display above the heatmap. xlabel : str, default="State" Label for the x-axis. ylabel : str, default="Brain region" Label for the y-axis. fontsize_labels : int, default=12 Font size for axis labels and colorbar label. fontsize_title : int, default=14 Font size for the plot title. tick_size : int, default=10 Font size for tick labels. annot : bool, default=False If True, annotate each cell in the heatmap with its value. xticklabels : list, str, or None, default=None List of custom x-tick labels, or string prefix to auto-generate labels (e.g., "State"). If None, numeric labels are used. yticklabels : list, str, or None, default=None List of custom y-tick labels, or string prefix to auto-generate labels. If None, numeric labels are used. xlabel_rotation : int or None, default=None Rotation angle for x-axis labels. Automatically set to 45 if number of states > 10. num_x_ticks : int or None, default=None Number of ticks to show on the x-axis. Automatically chosen if None. num_y_ticks : int or None, default=None Number of ticks to show on the y-axis. Automatically chosen if None. save_path : str or None, default=None If provided, saves the figure to this path. return_fig : bool, default=False If True, returns the figure object instead of displaying the plot. Returns ------- fig : matplotlib.figure.Figure or None Returns the figure if return_fig=True, otherwise shows the plot directly. """ # Ensure correct orientation state_means = np.atleast_2d(state_means) if state_means.shape[0] < state_means.shape[1]: state_means = state_means.T # shape: (n_features, n_states) n_features, n_states = state_means.shape # Set default tick font size and rotation if xlabel_rotation is None: xlabel_rotation = 45 if n_states > 10 else 0 ha = "center" # Determine number of ticks if num_x_ticks is None: num_x_ticks = n_states if n_states <= 20 else 5 if num_y_ticks is None: num_y_ticks = n_features if n_features <= 20 else 5 x_tick_positions = np.linspace(0, n_states - 1, num_x_ticks).astype(int) y_tick_positions = np.linspace(0, n_features - 1, num_y_ticks).astype(int) # Colormap cmap = getattr(plt.cm, cmap_type, plt.cm.coolwarm) if cmap_reverse: cmap = cmap.reversed() fig, ax = plt.subplots(figsize=figsize) # Create heatmap heatmap = sns.heatmap(state_means, ax=ax, cmap=cmap, annot=annot, fmt=".2f", cbar=False, xticklabels=False, yticklabels=False) # Labels and title ax.set_title(title_text, fontsize=fontsize_title) ax.set_xlabel(xlabel, fontsize=fontsize_labels) ax.set_ylabel(ylabel, fontsize=fontsize_labels) ax.tick_params(labelsize=tick_size) # X-tick labels if xticklabels is not None: if isinstance(xticklabels, str): xticklabels = [f"{xticklabels} {i + 1}" for i in range(len(x_tick_positions))] elif not isinstance(xticklabels, list) or len(xticklabels) != len(x_tick_positions): xticklabels = [f"State {i + 1}" for i in range(len(x_tick_positions))] else: xticklabels = [str(i + 1) for i in x_tick_positions] ax.set_xticks(x_tick_positions + 0.5) ax.set_xticklabels(xticklabels, rotation=xlabel_rotation, ha=ha, fontsize=tick_size) # Y-tick labels if yticklabels is not None: if isinstance(yticklabels, str): yticklabels = [f"{yticklabels} {i + 1}" for i in range(len(y_tick_positions))] elif not isinstance(yticklabels, list) or len(yticklabels) != len(y_tick_positions): yticklabels = [f"{i + 1}" for i in range(len(y_tick_positions))] else: yticklabels = [str(i + 1) for i in y_tick_positions] ax.set_yticks(y_tick_positions + 0.5) ax.set_yticklabels(yticklabels, rotation=0, fontsize=tick_size) # Colorbar divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="10%", pad=0.05) cbar = plt.colorbar(heatmap.get_children()[0], cax=cax) cbar.set_label("Activation Level", fontsize=fontsize_labels) cbar.ax.tick_params(labelsize=tick_size) cbar.locator = MaxNLocator(nbins=5) cbar.update_ticks() plt.tight_layout() if save_path: plt.savefig(save_path, bbox_inches="tight") if return_fig: return fig else: plt.show()
[docs] def plot_state_covariances(state_FC, cmap='coolwarm', fontsize_title=12, fontsize_labels=10, tick_size=8, figsize_per_plot=(2.5, 2.8), wspace=None, hspace=None, same_scale=False, num_cols =3, num_ticks=6, save_path=None, return_fig=False): """ Plot state-specific covariance matrices (e.g., functional connectivity) in a grid layout. Parameters ---------- state_FC : np.ndarray 3D array of shape (n_channels, n_channels, n_states). cmap : str, default='coolwarm' Colormap for plotting. fontsize_title : int Font size of subplot titles. fontsize_labels : int Font size of axis labels. tick_size : int Font size for tick labels. figsize_per_plot : tuple Size per subplot (width, height). wspace, hspace : float or None Spacing between subplots. same_scale : bool If True, uses the same color scale for all subplots. num_ticks : int Number of tick marks on each axis (shared between x and y). save_path : str or None If set, saves the plot to this path. """ n_channels, _, K = state_FC.shape num_cols_new = min(num_cols, K) num_rows = (K + num_cols_new - 1) // num_cols_new fig_width = num_cols_new * figsize_per_plot[0] fig_height = num_rows * figsize_per_plot[1] fig, axes = plt.subplots(num_rows, num_cols, figsize=(fig_width, fig_height)) axes = np.array(axes).reshape(-1) if same_scale: vmin = np.min(state_FC) vmax = np.max(state_FC) norm = Normalize(vmin=vmin, vmax=vmax) else: norm = None tick_positions = np.linspace(0, n_channels - 1, num_ticks).astype(int) tick_labels = [str(i + 1) for i in tick_positions] for k in range(K): ax = axes[k] im = ax.imshow(state_FC[:, :, k], cmap=cmap, interpolation="none", norm=norm) ax.set_title(f"State #{k+1}", fontsize=fontsize_title) ax.set_xlabel("Brain region", fontsize=fontsize_labels) ax.set_ylabel("Brain region", fontsize=fontsize_labels) ax.tick_params(labelsize=tick_size) ax.set_xticks(tick_positions) ax.set_yticks(tick_positions) ax.set_xticklabels(tick_labels, rotation=0, fontsize=tick_size) ax.set_yticklabels(tick_labels, fontsize=tick_size) cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) cbar.ax.tick_params(labelsize=tick_size) cbar.locator = MaxNLocator(nbins=4) cbar.update_ticks() # Hide unused axes for ax in axes[K:]: ax.axis("off") if wspace is None: wspace = 0.3 if num_cols <= 3 else max(0.60, 0.4 / num_cols) if hspace is None: hspace = 0.05 if num_rows <= 3 else max(0.15, 0.5 / num_rows) plt.subplots_adjust(wspace=wspace, hspace=hspace) if save_path: plt.savefig(save_path, bbox_inches="tight") if return_fig: return fig else: plt.show()
[docs] def plot_transition_matrix(TP, with_self_transitions=False, normalize=True, cmap='coolwarm', figsize=(4, 4), fontsize_title=14, fontsize_labels=12, tick_size=10, title_text=None, num_ticks=None, save_path=None, return_fig=False): """ Plot a single transition probability matrix (with or without self-transitions). Parameters ---------- TP : np.ndarray Transition matrix of shape (n_states, n_states). with_self_transitions : bool, default=True If False, self-transitions will be removed and rows re-normalized. normalize : bool, default=True Whether to normalize the rows after removing self-transitions. cmap : str, default='coolwarm' Colormap to use for the heatmap. figsize : tuple, default=(4, 4) Size of the figure. fontsize_title : int, default=14 Font size of the title. fontsize_labels : int, default=12 Font size for x and y axis labels. tick_size : int, default=10 Font size for axis tick labels. title_text : str or None Custom title. If None, a default is used based on `with_self_transitions`. num_ticks : int or None Number of tick labels to show on each axis. save_path : str or None If set, saves the figure to this path. return_fig : bool, default=False If True, returns the matplotlib figure object. """ TP = np.atleast_2d(TP) n_states = TP.shape[0] TP_plot = TP.copy() if not with_self_transitions: np.fill_diagonal(TP_plot, 0) if normalize: row_sums = TP_plot.sum(axis=1, keepdims=True) row_sums[row_sums == 0] = 1 # prevent divide-by-zero TP_plot = TP_plot / row_sums np.fill_diagonal(TP_plot, np.nan) # mask diagonal as NaN for plotting if title_text is None: title_text = "Transition Probabilities" if with_self_transitions else "Transition Probabilities\nWithout Self-Transitions" # Tick logic if num_ticks is None: num_ticks = n_states if n_states <= 20 else 5 tick_positions = np.linspace(0, n_states - 1, num_ticks).astype(int) tick_labels = [str(i + 1) for i in tick_positions] fig, ax = plt.subplots(figsize=figsize) im = ax.imshow(TP_plot, cmap=cmap, interpolation='none') ax.set_title(title_text, fontsize=fontsize_title) ax.set_xlabel("To State", fontsize=fontsize_labels) ax.set_ylabel("From State", fontsize=fontsize_labels) ax.tick_params(labelsize=tick_size) ax.set_xticks(tick_positions) ax.set_yticks(tick_positions) ax.set_xticklabels(tick_labels, rotation=0, fontsize=tick_size) ax.set_yticklabels(tick_labels, fontsize=tick_size) # Colorbar cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) cbar.ax.tick_params(labelsize=tick_size) cbar.locator = MaxNLocator(nbins=5) cbar.update_ticks() plt.tight_layout() if save_path: plt.savefig(save_path, bbox_inches="tight") if return_fig: return fig else: plt.show()
[docs] def plot_state_prob_and_covariance(init_stateP, TP, state_means, state_FC, TP_with_self_trans=False, cmap='coolwarm',figsize=(9, 7), num_ticks=5, save_path=None, title_size=None, label_size=None, tick_size=None, return_fig=False): """ Plot HMM parameters: initial state probabilities, transition matrix, state means, and state covariance matrices. Parameters ---------- init_stateP : np.ndarray Array of shape (n_states,) representing the initial state probabilities. TP : np.ndarray Transition probability matrix of shape (n_states, n_states). state_means : np.ndarray Array of shape (n_states, n_features) representing the mean activity per state. state_FC : np.ndarray Array of shape (n_features, n_features, n_states) representing state-specific covariance matrices. cmap : str or matplotlib colormap, default='coolwarm' Colormap used for all plots. figsize : tuple of float, default=(9, 7) Size of the full figure in inches (width, height). num_ticks : int, default=5 Number of ticks to show on colorbars and axes. save_path : str or None, default=None If provided, the figure will be saved to this path. title_size : int or None, optional Font size for subplot titles. If None, automatically scaled based on figure size. label_size : int or None, optional Font size for axis labels (currently reserved for future extension). tick_size : int or None, optional Font size for tick labels. If None, automatically scaled based on figure size. """ num_states = init_stateP.shape[0] num_cov_states = state_FC.shape[2] num_plots = 3 + num_cov_states num_cols = min(3, num_plots) num_rows = (num_plots + num_cols - 1) // num_cols # ceil division # Dynamic sizing fallback base_scale = (figsize[0] + figsize[1]) / 2 if title_size is None: title_size = int(base_scale * 1.6) if label_size is None: label_size = int(base_scale * 1.3) if tick_size is None: tick_size = int(base_scale * 1.2) fig, axes = plt.subplots(num_rows, num_cols, figsize=figsize, constrained_layout=True) axes = np.atleast_2d(axes) # === Initial state probabilities === im0 = axes[0, 0].imshow(init_stateP.reshape(-1, 1), cmap=cmap) axes[0, 0].set_title("Initial state\nprobabilities", fontsize=title_size) axes[0, 0].set_xticks([]) axes[0, 0].tick_params(labelsize=tick_size) if num_states <= 10: yticks = np.arange(num_states) axes[0, 0].set_yticks(yticks) axes[0, 0].set_yticklabels(yticks + 1) else: axes[0, 0].yaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) yticks = axes[0, 0].get_yticks() axes[0, 0].set_yticks(yticks) axes[0, 0].set_yticklabels([int(t) + 1 for t in yticks if 0 <= t < num_states]) #axes[0, 0].yaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) cbar0 = fig.colorbar(im0, ax=axes[0, 0]) cbar0.locator = MaxNLocator(nbins=num_ticks) cbar0.update_ticks() cbar0.ax.tick_params(labelsize=tick_size) # === Transition probabilities === if TP_with_self_trans== True: im1 = axes[0, 1].imshow(TP, cmap=cmap) axes[0, 1].set_title("Transition probabilities", fontsize=title_size) axes[0, 1].tick_params(labelsize=tick_size) if num_states <= 10: xyticks = np.arange(num_states) axes[0, 1].set_xticks(xyticks) axes[0, 1].set_xticklabels(xyticks + 1) axes[0, 1].set_yticks(xyticks) axes[0, 1].set_yticklabels(xyticks + 1) else: axes[0, 1].xaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) axes[0, 1].yaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) # axes[0, 1].set_xticklabels([int(t) + 1 for t in axes[0, 1].get_xticks()]) # axes[0, 1].set_yticklabels([int(t) + 1 for t in axes[0, 1].get_yticks()]) cbar1 = fig.colorbar(im1, ax=axes[0, 1]) cbar1.locator = MaxNLocator(nbins=num_ticks) cbar1.update_ticks() cbar1.ax.tick_params(labelsize=tick_size) else: TP_noself = TP - np.diag(np.diag(TP)) # Remove self-transitions TP_noself2 = TP_noself / TP_noself.sum(axis=1, keepdims=True) # Normalize probabilities im1 = axes[0, 1].imshow(TP_noself2, cmap=cmap) axes[0, 1].set_title("Transition probabilities\n w.o. self transition", fontsize=title_size) axes[0, 1].tick_params(labelsize=tick_size) if num_states <= 10: xyticks = np.arange(num_states) axes[0, 1].set_xticks(xyticks) axes[0, 1].set_xticklabels(xyticks + 1) axes[0, 1].set_yticks(xyticks) axes[0, 1].set_yticklabels(xyticks + 1) else: axes[0, 1].xaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) axes[0, 1].yaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) # axes[0, 1].set_xticklabels([int(t) + 1 for t in axes[0, 1].get_xticks()]) # axes[0, 1].set_yticklabels([int(t) + 1 for t in axes[0, 1].get_yticks()]) cbar1 = fig.colorbar(im1, ax=axes[0, 1]) cbar1.locator = MaxNLocator(nbins=num_ticks) cbar1.update_ticks() cbar1.ax.tick_params(labelsize=tick_size) # === State Means === im2 = axes[0, 2].imshow(state_means, cmap=cmap, aspect='auto') axes[0, 2].set_title("State means", fontsize=title_size) axes[0, 2].tick_params(labelsize=tick_size) num_features = state_means.shape[1] if num_states <= 10: xyticks = np.arange(num_states) axes[0, 2].set_xticks(xyticks) axes[0, 2].set_xticklabels(xyticks + 1) else: axes[0, 2].xaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) axes[0, 2].yaxis.set_major_locator(MaxNLocator(nbins=num_ticks, integer=True)) #ax.set_xticklabels(cov_ticks + 1, rotation=45 if len(cov_ticks) > 10 else 0) # axes[0, 2].set_xticklabels([int(t) + 1 for t in axes[0, 2].get_xticks()]) # axes[0, 2].set_yticklabels([int(t) + 1 for t in axes[0, 2].get_yticks()]) if num_features > 15: axes[0, 2].tick_params(axis='x', rotation=45) cbar2 = fig.colorbar(im2, ax=axes[0, 2]) cbar2.locator = MaxNLocator(nbins=num_ticks) cbar2.update_ticks() cbar2.ax.tick_params(labelsize=tick_size) # === Covariances === min_val, max_val = np.min(state_FC), np.max(state_FC) cov_ticks = MaxNLocator(nbins=num_ticks).tick_values(0, state_FC.shape[0] - 1).astype(int) cov_ticks = cov_ticks[cov_ticks < state_FC.shape[0]] for k in range((num_cols * num_rows) - 3): row_idx = (k + 3) // num_cols col_idx = (k + 3) % num_cols if k < num_cov_states: ax = axes[row_idx, col_idx] im = ax.imshow(state_FC[:, :, k], cmap=cmap, vmin=min_val, vmax=max_val) ax.set_title(f"State covariance\nstate #{k + 1}", fontsize=title_size) ax.tick_params(labelsize=tick_size) ax.set_xticks(cov_ticks) ax.set_yticks(cov_ticks) ax.set_xticklabels(cov_ticks , rotation=45 if len(cov_ticks) > 10 else 0) ax.set_yticklabels(cov_ticks ) cbar = fig.colorbar(im, ax=ax) cbar.locator = MaxNLocator(nbins=num_ticks) cbar.update_ticks() cbar.ax.tick_params(labelsize=tick_size) else: axes[row_idx, col_idx].axis('off') if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_condition_difference( Gamma_epoch, R_trials, title='Average Probability and Difference', condition_labels=('Condition 1', 'Condition 2'), fontsize_sup_title=16, fontsize_title=14, fontsize_labels=12, figsize=(12, 3), vertical_lines=None, line_colors=None, highlight_boxes=False, stimulus_onset=None, x_tick_min=None, x_tick_max=None, num_x_ticks=5, num_y_ticks=5, xlabel='Timepoints', ylabel='Average Probability', save_path=None, return_fig=False): """ Plots the average probability for each state over time for two conditions and their difference. Parameters: ----------- Gamma_epoch (numpy.ndarray) 3D array representing reconstructed gamma values. Shape: (num_timepoints, num_trials, num_states) R_trials (numpy.ndarray) 1D array representing the condition for each trial. Should have the same length as the second dimension of Gamma_epoch. title (str, optional), default='Average Probability and Difference': Title for the plot. condition_labels : tuple of str, optional Labels for the two conditions. Default is ('Condition 1', 'Condition 2'). fontsize_sup_title (int, optional), default=16: Font size for sup_title. fontsize_title (int, optional), default=14: Font size for title. fontsize_labels (int, optional), default=12: Font size for labels. figsize (tuple, optional), default=(9, 2): Figure size (width, height). vertical_lines (list of tuples, optional), default=None: List of pairs specifying indices for vertical lines. line_colors (list of str or bool, optional), default=None: List of colors for each pair of vertical lines. If True, generates random colors (unless a list is provided). highlight_boxes (bool, optional), default=False: Whether to include highlighted boxes for each pair of vertical lines. stimulus_onset (int, optional), default=None: Index of the data where the stimulus onset should be positioned. x_tick_min (float, optional), default=None: Minimum value for the x-tick labels. x_tick_max (float, optional), default=None: Maximum value for the x-tick labels. num_x_ticks (int, optional), default=5: Number of x-ticks. num_y_ticks (int, optional), default=5: Number of y-ticks. save_path (str), optional, default=None If a string is provided, it saves the figure to that specified path Example usage: -------------- plot_condition_difference(Gamma_epoch, R_trials, vertical_lines=[(10, 100)], highlight_boxes=True) """ # Validate inputs if stimulus_onset is not None and not isinstance(stimulus_onset, (int, float)): raise ValueError("stimulus_onset must be a number.") if len(condition_labels) != 2: raise ValueError("condition_labels must be a tuple with exactly two labels.") filt_val = np.zeros((2, Gamma_epoch.shape[0], Gamma_epoch.shape[2])) # Create subplots fig, axes = plt.subplots(1, 3, figsize=figsize) conditions = np.unique(R_trials) # Variables to store global min and max y-values global_min = float('inf') global_max = float('-inf') # Plot for each condition for idx, condition in enumerate(conditions): for i in range(Gamma_epoch.shape[0]): filtered_values = Gamma_epoch[i, (R_trials == condition), :] filt_val[idx, i, :] = np.mean(filtered_values, axis=0).round(3) # Update global min and max y-values current_min = filt_val[idx, :, :].min() current_max = filt_val[idx, :, :].max() global_min = min(global_min, current_min) global_max = max(global_max, current_max) # Generate x-tick labels num_timepoints = Gamma_epoch.shape[0] x_tick_positions = np.linspace(0, num_timepoints - 1, num_x_ticks).astype(int) # Generate x-tick labels based on user input or default to time points if x_tick_min is not None and x_tick_max is not None: x_tick_labels = np.linspace(x_tick_min, x_tick_max, num_x_ticks).round(1) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_min is not None: x_tick_labels = np.linspace(x_tick_min, x_tick_max, num_x_ticks).round(1) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_max is not None: x_tick_labels = np.linspace(0, x_tick_max, num_x_ticks).round(1) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) else: x_tick_labels = x_tick_positions # # Convert to ms if needed # time_ms = time * 1000 # # Define tick step (in ms) # if tick_step is None: # if range_ms <= 500: # tick_step = 100 # elif range_ms <= 1500: # tick_step = 200 # else: # tick_step = 500 # # Generate ticks aligned to nice values # tick_values = np.arange( # np.ceil(time_ms[0] / tick_step) * tick_step, # time_ms[-1] + tick_step, # tick_step # ) # # Convert time → index # x_tick_positions = [ # int(round((t/1000 - x_tick_min) * num_timepoints / (x_tick_max - x_tick_min))) # for t in tick_values # ] # x_tick_labels = tick_values.astype(int) # Plot for each condition with standardized y-axis for idx, condition in enumerate(conditions): axes[idx].plot(filt_val[idx, :, :]) axes[idx].set_title(condition_labels[idx], fontsize=fontsize_title) axes[idx].set_xticks(x_tick_positions) axes[idx].set_xticklabels(x_tick_labels) axes[idx].set_yticks(np.linspace(global_min, global_max, num_y_ticks).round(2)) axes[idx].set_ylim(global_min, global_max) # Set standardized y-limits # Set standardized y-limits axes[idx].set_xlim(x_tick_positions[0], x_tick_positions[-1]) axes[idx].set_ylabel(ylabel, fontsize=fontsize_labels) # Find the element-wise difference difference = filt_val[0, :, :] - filt_val[1, :, :] # Plot the difference axes[2].plot(difference) axes[2].set_title("Difference", fontsize=fontsize_title) axes[2].set_yticks(np.linspace(axes[2].get_ylim()[0], axes[2].get_ylim()[1], num_y_ticks).round(2)) axes[2].set_xticks(x_tick_positions) axes[2].set_xticklabels(x_tick_labels) axes[2].set_xlim(x_tick_positions[0], x_tick_positions[-1]) axes[2].set_xlabel(xlabel, fontsize=fontsize_labels) # Add stimulus onset line and label if stimulus_onset is not None: for ax in axes: ax.axvline(x=stimulus_onset, color='black', linestyle='--', linewidth=2) # Add vertical lines, line colors, and highlight boxes if vertical_lines: for idx, pair in enumerate(vertical_lines): color = line_colors[idx] if line_colors and len(line_colors) > idx else 'gray' axes[2].axvline(x=pair[0], color=color, linestyle='--', linewidth=1) axes[2].axvline(x=pair[1], color=color, linestyle='--', linewidth=1) if highlight_boxes: rect = plt.Rectangle((pair[0], axes[2].get_ylim()[0]), pair[1] - pair[0], axes[2].get_ylim()[1] - axes[2].get_ylim()[0], linewidth=0, edgecolor='none', facecolor=color, alpha=0.2) axes[2].add_patch(rect) # Set labels fontsize for ax in axes: ax.set_xlabel(xlabel, fontsize=fontsize_labels) # Label each state on the right for the last figure (axes[2]) state_labels = [f"State {state+1}" for state in range(Gamma_epoch.shape[2])] axes[2].legend(state_labels, loc='center left', bbox_to_anchor=(1.05, 0.5), fontsize=fontsize_labels) fig.suptitle(title, fontsize=fontsize_sup_title) # Show the plot plt.tight_layout(rect=[0, 0, 1, 0.95]) # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_p_values_over_time(pval, figsize=(8, 3), xlabel="Timepoints", ylabel="P-values (Log Scale)", title_text="P-values over time", fontsize_labels=12, fontsize_title=14, stimulus_onset=None, x_tick_min=None, x_tick_max=None, num_x_ticks=5, tick_positions=[0.001, 0.01, 0.05, 0.1, 0.3, 1], num_colors=259, alpha=0.05, plot_style="line", linewidth=2.5, scatter_on=True, save_path=None, return_fig=False): """ Plot a scatter plot of p-values over time with a log-scale y-axis and a colorbar. Parameters: ----------- pval (numpy.ndarray): The p-values data to be plotted. figsize (tuple), optional, default=(8, 4): Figure size in inches (width, height). total_time_seconds : float, optional, default=None Total time duration in seconds. If provided, time points will be scaled accordingly. xlabel (str, optional), default="Timepoints": Label for the x-axis. ylabel (str, optional), default="P-values (Log Scale)": Label for the y-axis. title_text (str, optional), default="P-values over time": Title for the plot. fontsize_labels (int, optional), default=12: Font size for the x and y-axis labels. fontsize_title (int, optional), default=14 fontsize of title stimulus_onset (int, optional), default=None: Index of the data where the stimulus onset should be positioned. x_tick_min (float, optional), default=None Minimum value for x-axis ticks. x_tick_max (float, optional), default=None Maximum value for x-axis ticks. num_x_ticks (int, optional), default=5 Number of x-axis ticks. tick_positions (list, optional), default=[0, 0.001, 0.01, 0.05, 0.1, 0.3, 1]: Specific values to mark on the y-axis. num_colors (int, optional), default=259: Resolution for the color bar. alpha (float, optional), default=0.05: Alpha value is the threshold we set for the p-values when doing visualization. plot_style (str, optional), default="line": Style of plot. linewidth (float, optional), default=2.5: Width of the lines in the plot. save_path (str), optional, default=None If a string is provided, it saves the figure to that specified path """ # Check if stimulus_onset is a number if stimulus_onset is not None and not isinstance(stimulus_onset, (int, float)): raise ValueError("stimulus_onset must be a number.") pval_in = np.squeeze(pval.copy()) if pval_in.ndim != 1: # Raise an exception and stop function execution raise ValueError("To use the function 'plot_p_values_over_time', the variable for p-values must be one-dimensional.") # Ensure p-values are within the log range pval_min = -3 pval_in = np.clip(pval_in, 10**pval_min, 1) # Convert to log scale color_array = np.logspace(pval_min, 0, num_colors).reshape(1, -1) time_points = np.arange(len(pval_in)) if alpha == None: # Create custom colormap coolwarm_cmap = custom_colormap() # Create a new colormap with the modified color_array cmap_list = coolwarm_cmap(color_array)[0] cmap_list = interpolate_colormap(cmap_list) else: # Make a jump in color after alpha # Get blue colormap cmap_blue = blue_colormap() # Create a new colormap with cmap_list = cmap_blue(color_array)[0] red_cmap = red_colormap() blue_cmap = blue_colormap() # Specify the number of elements you want (e.g., 50) num_elements_red = np.sum(color_array <= alpha) num_elements_blue = np.sum(color_array > alpha) # Generate equally spaced values between 0 and 1 colormap_val_red = np.linspace(0, 1, num_elements_red) colormap_val_blue = np.linspace(0, 1, num_elements_blue) # Apply the colormap to the generated values cmap_red = red_cmap(colormap_val_red) cmap_blue = blue_cmap(colormap_val_blue) # overwrite the values below alpha cmap_list[:num_elements_red,:]=cmap_red cmap_list[num_elements_red:,:]=cmap_blue cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_list) # Create the line plot with varying color based on p-values fig, axes = plt.subplots(figsize=figsize) # Normalize the data to [0, 1] for the colormap with logarithmic scale norm = LogNorm(vmin=10**pval_min , vmax=1) if plot_style == "line": # Plot the line segments with varying colors for i in range(len(time_points)-1): # Determine the color for the current segment if scatter_on and pval_in[i + 1] > alpha: color = cmap(norm(pval_in[i + 1])) else: color = cmap(norm(pval_in[i])) # Plot the line segment axes.plot([time_points[i], time_points[i + 1]],[pval_in[i], pval_in[i + 1]], color=color, linewidth=linewidth) if scatter_on: # Handle specific scatter cases if pval_in[i + 1] > alpha and pval_in[i] < alpha: if i > 0 and pval_in[i - 1] < alpha: pass # Explicit no-op for clarity else: axes.scatter([time_points[i]],[pval_in[i]],c=pval_in[i],cmap=cmap,norm=norm) elif plot_style=="scatter": axes.scatter(time_points, pval_in, c=pval_in, cmap=cmap, norm=LogNorm(vmin=10**pval_min, vmax=1)) elif plot_style=="scatter_line": axes.scatter(time_points, pval_in, c=pval_in, cmap=cmap, norm=LogNorm(vmin=10**pval_min, vmax=1)) # Draw lines between points axes.plot(time_points, pval_in, color='black', linestyle='-', linewidth=1) # Add labels and title axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title_text, fontsize=fontsize_title) # define x_ticks x_tick_positions = np.linspace(0, len(pval_in), num_x_ticks).astype(int) # Generate x-tick labels based on user input or default to time points if x_tick_min is not None and x_tick_max is not None: x_tick_labels = np.linspace(x_tick_min, x_tick_max, num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_min is not None: x_tick_labels = np.linspace(x_tick_min, pval_in.shape[1], num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) elif x_tick_max is not None: x_tick_labels = np.linspace(0, x_tick_max, num_x_ticks).round(2) if np.all(x_tick_labels == x_tick_labels.astype(int)): x_tick_labels = x_tick_labels.astype(int) else: x_tick_labels = x_tick_positions # Set axis limits to focus on the relevant data range axes.set_xticks(x_tick_positions) axes.set_xticklabels(x_tick_labels) axes.set_xlim(x_tick_positions[0], x_tick_positions[-1]) # Set x-axis limits without white space axes.set_ylim([0.0008, 1.5]) # Set y-axis to log scale axes.set_yscale('log') # Mark specific values on the y-axis plt.yticks([0.001, 0.01, 0.05, 0.1, 0.3, 1], ['0.001', '0.01', '0.05', '0.1', '0.3', '1']) # Add colorbar divider = make_axes_locatable(axes) cax = divider.append_axes("right", size="3.5%", pad=0.05) colorbar = plt.colorbar( plt.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cax, ticks=tick_positions, format="%.3g" ) plt.tight_layout() # Add stimulus onset line and label if stimulus_onset is not None: axes.axvline(x=stimulus_onset, color='black', linestyle='--', linewidth=2) # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_p_values_bar( pval, xticklabels=None, figsize=(9, 4), num_colors=256, xlabel="", ylabel="P-values (Log Scale)", title_text="Bar Plot", fontsize_labels =12,fontsize_title=14, tick_positions=[0.001, 0.01, 0.05, 0.1, 0.3, 1], top_adjustment=0.8, alpha=0.05, pad_title=25, xlabel_rotation=45, pval_text_height_same=False, save_path=None, return_fig=False): """ Visualize a bar plot with LogNorm and a colorbar. Parameters: ----------- pval (numpy.ndarray): Array of p-values to be plotted. xticklabels (str or list, optional), default=None: Either a list of category labels, or a single string. - If a list: Must match the length of pval. - If a string: Auto-generates labels like "<string> 1", "<string> 2", ..., "<string> N" where N = len(pval). - If None or invalid: Default labels will be used ("Var 1", "Var 2", ..., "Var N"). figsize (tuple, optional), default=(9, 4): Figure size in inches (width, height). num_colors (int, optional), default=256: Number of colors in the colormap. xlabel (str, optional), default="": X-axis label. ylabel (str, optional), default="P-values (Log Scale)": Y-axis label. title_text (str, optional), default="Bar Plot": Title for the plot. fontsize_labels (int, optional), default=12: Font size for the x and y-axis labels. fontsize_title (int, optional), default=14 fontsize of title tick_positions (list, optional), default=[0, 0.001, 0.01, 0.05, 0.1, 0.3, 1] Positions of ticks on the colorbar. top_adjustment (float, optional), default=0.9: Adjustment for extra space between title and plot. alpha (float, optional), default=0.05: Alpha value is the threshold we set for the p-values when doing visualization. pad_title (int, optional), default=20: Padding for the plot title. save_path (str), optional, default=None If a string is provided, it saves the figure to that specified path pval_text_height_same (bool), default=False Whether the p-values of each bar should be plotted at the same height or adjusted to the height of each individual bar """ # Validate input and flatten p-values pval_in = np.squeeze(pval).flatten() if pval.shape[0]==1 or pval.ndim==2 and np.any(np.array(pval.shape) == 1) else pval.copy() if pval_in.ndim != 1: raise ValueError("The input 'pval_in' must be a one-dimensional array.") # Validate xticklabels if xticklabels is not None: if isinstance(xticklabels, str): # Generate labels like "Hello 1", "Hello 2", ..., "Hello N" xticklabels = [f"{xticklabels} {i + 1}" for i in range(len(pval_in))] elif not isinstance(xticklabels, list): warnings.warn(f"xticklabels must be a list or a string, but got {type(xticklabels)}. Using default labels instead.") xticklabels = None elif len(xticklabels) != len(pval_in): raise ValueError(f"xticklabels length ({len(xticklabels)}) does not match pval length ({len(pval_in)}).") # Set default labels if needed if xticklabels is None or len(xticklabels) == 0: xticklabels = [f"Var {i + 1}" for i in range(len(pval_in))] # Ensure p-values are within the log range pval_min = -3 pval_in = np.clip(pval_in, 10**pval_min, 1) # Convert to log scale color_array = np.logspace(pval_min, 0, num_colors).reshape(1, -1) if alpha == None: # Create custom colormap coolwarm_cmap = custom_colormap() # Create a new colormap with the modified color_array cmap_list = coolwarm_cmap(color_array)[0] cmap_list = interpolate_colormap(cmap_list) else: # Make a jump in color after alpha # Get blue colormap cmap_blue = blue_colormap() # Create a new colormap with cmap_list = cmap_blue(color_array)[0] red_cmap = red_colormap() blue_cmap = blue_colormap() # Specify the number of elements you want (e.g., 50) num_elements_red = np.sum(color_array <= alpha) num_elements_blue = np.sum(color_array > alpha) # Generate equally spaced values between 0 and 1 colormap_val_red = np.linspace(0, 1, num_elements_red) colormap_val_blue = np.linspace(0, 1, num_elements_blue) # Apply the colormap to the generated values cmap_red = red_cmap(colormap_val_red) cmap_blue = blue_cmap(colormap_val_blue) # overwrite the values below alpha cmap_list[:num_elements_red,:]=cmap_red cmap_list[num_elements_red:,:]=cmap_blue # Create a LinearSegmentedColormap cmap = LinearSegmentedColormap.from_list('custom_colormap', cmap_list) # Plot the bar chart fig, axes = plt.subplots(figsize=figsize) norm = LogNorm(vmin=10**pval_min, vmax=1) bar_colors = cmap(norm(pval_in)) bars = axes.bar(xticklabels, pval_in, color=bar_colors) # Add data labels above bars max_yval = max(pval_in) if pval_text_height_same else None for bar in bars: yval = bar.get_height().round(3) if yval ==1: yval = int(yval) text_y = max_yval + 0.5 if max_yval else yval + 0.5 axes.text( bar.get_x() + bar.get_width() / 2, text_y, f"{yval}", ha='center', va='bottom', fontsize=10, fontweight='bold' ) # Set logarithmic scale for y-axis axes.set_yscale('log') # Set y-axis limits explicitly to ensure it stays between 0.001 and 1 # axes.set_ylim(0.001, 1) # Define tick positions and labels tick_positions = np.array(tick_positions) axes.set_yticks(tick_positions) axes.set_yticklabels([f"{pos:.3g}" for pos in tick_positions]) # Customize plot aesthetics axes.set_xlabel(xlabel, fontsize=fontsize_labels) axes.set_ylabel(ylabel, fontsize=fontsize_labels) axes.set_title(title_text, fontsize=fontsize_title, pad=pad_title) # Define the tick positions explicitly axes.set_xticks(np.arange(len(xticklabels))) # Set the tick positions based on xticklabels length axes.set_xticklabels(xticklabels, rotation=xlabel_rotation, ha='right' if xlabel_rotation == 45 else 'center') axes.spines['right'].set_visible(False) axes.spines['top'].set_visible(False) # Add colorbar divider = make_axes_locatable(axes) cax = divider.append_axes("right", size="3.5%", pad=0.05) colorbar = plt.colorbar( plt.cm.ScalarMappable(norm=norm, cmap=cmap), cax=cax, ticks=tick_positions, format="%.3g" ) # Add extra space for the title plt.subplots_adjust(top=top_adjustment) plt.tight_layout() # Save the plot if required if save_path: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_data_grid(data_list, titles=None, figsize_per_plot=(4, 3), main_title="Data", xlabel="Time (s)", ylabel="Signal", fontsize_labels=12, fontsize_title=18, line_width=1.8, grid=False, title_fontsize=14, tick_fontsize=10, standardize_yaxis=False, y_buffer=0.05, num_y_ticks=None, title_spacing=10, save_path=None, return_fig=False): """ Create a grid of subplots to visualize multiple datasets with a clean layout. Parameters: ----------- data_list (list of numpy.ndarray): List of data arrays to be plotted, each representing one subplot. titles (str, list of str, or None), default=None: Title for each plot. If None, default titles "Plot 1", "Plot 2", ... will be used. If a single string is provided, it will be numbered for each plot. figsize_per_plot (tuple, optional), default=(4, 3): Size of each subplot in the grid. main_title (str, optional), default="Data Visualization": Main title for the entire grid of subplots. xlabel (str, optional), default="Time (s)": Label for the x-axis of each subplot. ylabel (str, optional), default="Signal": Label for the y-axis of each subplot. fontsize_labels (int, optional), default=12: Font size for the x and y-axis labels. fontsize_title (int, optional), default=18: Font size for the main title. line_width (float, optional), default=1.8: Line width for the data plots. grid (bool, optional), default=False: Whether to include a grid in each subplot. title_fontsize (int, optional), default=14: Font size for subplot titles. tick_fontsize (int, optional), default=10: Font size for axis tick labels. standardize_yaxis (bool, optional), default=False: If True, sets the same y-axis limits for all plots based on the global min and max values. y_buffer (float, optional), default=0.05: Buffer added to the min and max y-axis values when standardizing, as a percentage. num_y_ticks (int, optional), default=None: Number of y-ticks for the y-axis. If None, matplotlib default is used. title_spacing (int, optional), default=10: Spacing (padding) between subplot titles and the plots. save_path (str, optional), default=None: If provided, saves the figure to the specified path. """ # Calculate rows and columns for the grid n_plots = len(data_list) n_cols = math.ceil(math.sqrt(n_plots)) n_rows = math.ceil(n_plots / n_cols) # Handle titles: default to numbered "Plot X" if None if titles is None: titles = [f"Plot {i+1}" for i in range(n_plots)] elif isinstance(titles, str): titles = [f"{titles} {i+1}" for i in range(n_plots)] # Determine global y-axis limits if standardize_yaxis is True if standardize_yaxis: valid_min = min(np.nanmin(data) for data in data_list if np.isfinite(data).any()) valid_max = max(np.nanmax(data) for data in data_list if np.isfinite(data).any()) y_range = valid_max - valid_min y_min = valid_min - y_buffer * y_range y_max = valid_max + y_buffer * y_range # Create the grid of subplots fig, axes = plt.subplots(n_rows, n_cols, figsize=(n_cols * figsize_per_plot[0], n_rows * figsize_per_plot[1])) axes = axes.flatten() # Flatten axes for easy iteration # Plot each dataset for i, (ax, data, title) in enumerate(zip(axes, data_list, titles)): ax.plot(data, linestyle='-', linewidth=line_width) ax.set_title(title, fontsize=title_fontsize, pad=title_spacing) ax.set_xlabel(xlabel, fontsize=fontsize_labels) ax.set_ylabel(ylabel, fontsize=fontsize_labels) ax.tick_params(axis='both', which='major', labelsize=tick_fontsize) # Apply standardized y-axis limits if standardize_yaxis: ax.set_ylim(y_min, y_max) # Set number of y-ticks if specified if num_y_ticks is not None: ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=num_y_ticks)) # Add grid if grid: ax.grid(visible=True, linestyle='--', linewidth=0.5, alpha=0.7) # Remove unused axes for ax in axes[len(data_list):]: fig.delaxes(ax) # Main title fig.suptitle(main_title, fontsize=fontsize_title, weight='bold', y=0.98) # Adjust layout plt.tight_layout(rect=[0, 0, 1, 0.95]) # Save if save_path is provided if save_path: plt.savefig(save_path, bbox_inches='tight') # Show the figure if return_fig: return fig else: plt.show()
[docs] def get_distinct_colors(n_colors, cmap=None): """ Generate visually distinct colors using a combination of built-in categorical and perceptually uniform colormaps. Parameters: -------------- n_colors (int): The number of distinct colors to generate. cmap (str, optional): Name of a colormap to use as the primary source of colors. If not provided, defaults to a combination of standard categorical colormaps. If the requested number of colors exceeds what is available, additional colors are sampled from other colormaps to fill the gap. Returns: -------------- colors (list): A list of RGBA tuples representing distinct colors. If the number of requested colors exceeds what is available from standard categorical maps, additional colors are sampled from a continuous colormap. """ base_maps = ['Set3', 'tab10', 'Accent', 'Dark2'] # Remove user-specified cmap if it's in base_maps to avoid duplication if cmap in base_maps: base_maps.remove(cmap) base_maps.insert(0,cmap) elif cmap is not None: base_maps.insert(0,cmap) colors = [] for cmap_name in base_maps: cmap = cm.get_cmap(cmap_name) cmap_colors = [cmap(i) for i in range(cmap.N)] colors.extend(cmap_colors) if len(colors) >= n_colors: break if len(colors) < n_colors: # Fill remaining with perceptually uniform colors extra_needed = n_colors - len(colors) viridis = cm.get_cmap('gist_rainbow') colors.extend([viridis(i / extra_needed) for i in range(extra_needed)]) return colors[:n_colors]
[docs] def plot_nnm_spectral_components(nnmf_components, freqs, x_lim=None, highlight_freq=True, title='Spectral Components from NNMF Decomposition', cmap=None, bands=None, band_colors=None, figsize=(10, 5), fontsize_labels=13, fontsize_title=16, band_legend_anchor=(1.28, 1), save_path=None, return_fig=False): """ Plot the spectral components obtained from NNM decomposition with optional frequency band highlighting. Parameters: -------------- nnmf_components (numpy.ndarray): Array of shape (n_components, n_freqs) representing the decomposed spectral components for each component. freqs (numpy.ndarray): 1D array representing the frequency axis, should match the second dimension of `nnmf_components`. x_lim (int, optional): The upper limit of the frequency axis (x-axis). If None, it will default to the maximum value in `freqs`. highlight_freq (bool, optional): Whether to highlight canonical or custom frequency bands, default is True. title (str, optional): Title of the plot. Default is "Spectral Components from NNMF Decomposition". cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). bands (dict, optional): Dictionary defining frequency bands. Keys are band names and values are (start, end) tuples in Hz. If None, default bands will be used. band_colors (dict, optional): Dictionary mapping band names to color names. Keys must match those in `bands`. figsize (tuple, optional): Tuple defining figure size in inches, default is (10, 5). band_legend_anchor (tuple or None, optional): Tuple for `bbox_to_anchor` to control frequency band legend placement. Default is (1.28, 1). If set to None, legend is placed at 'lower right'. save_path (str), optional, default=None If a string is provided, it saves the figure to that specified path """ if x_lim is None: x_lim = int(np.max(freqs)) # Default frequency bands and colors default_bands = { 'Delta': (0, 4), 'Theta': (4, 8), 'Alpha': (8, 13), 'Beta': (13, 30), 'Gamma': (30, x_lim) } default_band_colors = { 'Delta': 'orange', 'Theta': 'cyan', 'Alpha': 'magenta', 'Beta': 'black', 'Gamma': 'green' } # Use provided or default bands/colors bands = bands if bands is not None else default_bands band_colors = band_colors if band_colors is not None else default_band_colors # Validate consistency if set(bands.keys()) != set(band_colors.keys()): raise ValueError("The keys of 'bands' and 'band_colors' must match exactly.") n_components = nnmf_components.shape[0] # Assign distinct colors for each component if cmap is not None: # Assign distinct colors for each component valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: cmap = plt.get_cmap(cmap) component_colors = [cmap(i) for i in range(n_components)] else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc.") cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(n_components)] elif n_components <= 10: cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(n_components)] else: component_colors = get_distinct_colors(n_components, cmap) fig, ax = plt.subplots(figsize=figsize) # Plot each component for i in range(n_components): ax.plot( freqs, nnmf_components[i], label=f'Component {i + 1}', linewidth=2.5, alpha=1, color=component_colors[i], ) # Highlight frequency bands if highlight_freq: for band, (start, end) in bands.items(): ax.axvspan(start, end, color=band_colors[band], alpha=0.2) ax.set_xlabel('Frequency (Hz)', fontsize = fontsize_labels) ax.set_ylabel('Component Weight', fontsize = fontsize_labels) ax.set_title(title, fontsize = fontsize_title) ax.set_xlim(0, x_lim) # Legend for components state_legend = ax.legend(loc='upper right', title='NNMF Components') # Optional frequency band legend if highlight_freq: band_handles = [ Patch(facecolor=band_colors[band], edgecolor='none', alpha=0.5, label=f'{band}: {start}-{end} Hz') for band, (start, end) in bands.items() ] if band_legend_anchor is not None: band_legend = ax.legend( handles=band_handles, loc='upper right', bbox_to_anchor=band_legend_anchor, title='Frequency Bands' ) else: band_legend = ax.legend( handles=band_handles, loc='lower right', title='Frequency Bands' ) ax.add_artist(state_legend) plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_state_psd(psd, freqs, significant_states=None, x_lim=None, cmap=None, highlight_freq=False, bands=None, band_colors=None, title='Power Spectral Density (PSD) per State', log_scale_y=False, log_scale_x=False, figsize=(10, 5), fontsize_labels=13, fontsize_title=16, band_legend_anchor=(1.28, 1), label_line=None, save_path=None, return_fig=False): """ Plot the power spectral density (PSD) for each state, with optional highlighting of frequency bands and significant states. Parameters: -------------- psd (numpy.ndarray): Array of shape (n_freqs, num_states) representing the PSD of each state. freqs (numpy.ndarray): 1D array representing the frequency axis, should match the second dimension of `psd`. significant_states (set, optional): Set of 1-based indices indicating which states are considered significant. Significant states are highlighted with a solid line and an asterisk. x_lim (int, optional): The upper limit of the frequency axis (x-axis). If None, it will default to the maximum value in `freqs`. cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). highlight_freq (bool, optional): Whether to highlight canonical or custom frequency bands. Default is True. bands (dict, optional): Dictionary defining frequency bands. Keys are band names and values are (start, end) tuples in Hz. If None, default bands will be used. band_colors (dict, optional): Dictionary mapping band names to color names. Keys must match those in `bands`. title (str, optional): Title of the plot. Default is "Power Spectral Density (PSD) per State". log_scale_y (bool, optional): Whether to apply a logarithmic scale to the y-axis (power). Default is True. log_scale_x (bool, optional): Whether to apply a logarithmic scale to the x-axis (frequency). Default is False. figsize (tuple, optional): Tuple defining figure size in inches. Default is (10, 5). fontsize_labels (int): Font size for x and y axis labels. Default is 13. fontsize_title (int): Font size for the plot title. Default is 16. band_legend_anchor (tuple or None, optional): Tuple for `bbox_to_anchor` to control frequency band legend placement. Default is (1.28, 1). If set to None, legend is placed at 'upper right'. label_line (str or list, optional): Custom labels for each line. Can be a string (prefix) or a list of names. If not provided, states are labeled as "State 1", "State 2", etc. save_path (str, optional, default=None): If a string is provided, the figure will be saved to the specified path. """ if significant_states is None: significant_states = set() if x_lim is None: x_lim = int(np.max(freqs)) if np.iscomplexobj(psd): print("Warning: PSD contains complex values — imaginary parts will be discarded.") psd = np.real(psd) default_bands = { 'Delta': (0, 4), 'Theta': (4, 8), 'Alpha': (8, 13), 'Beta': (13, 30), 'Gamma': (30, x_lim) } default_band_colors = { 'Delta': 'orange', 'Theta': 'cyan', 'Alpha': 'magenta', 'Beta': 'black', 'Gamma': 'green' } bands = bands if bands is not None else default_bands band_colors = band_colors if band_colors is not None else default_band_colors if set(bands.keys()) != set(band_colors.keys()): raise ValueError("The keys of 'bands' and 'band_colors' must match exactly.") num_states = psd.shape[1] if psd.ndim == 2 else 1 psd = psd[:, np.newaxis] if psd.ndim==1 else psd if isinstance(label_line, list): if len(label_line) != num_states: raise ValueError("Length of 'label_line' list must match number of lines to plot.") line_labels = label_line elif isinstance(label_line, str): line_labels = [f"{label_line} {i+1}" if num_states > 1 else label_line for i in range(num_states)] else: line_labels = [f"State {i+1}" if num_states > 1 else "State" for i in range(num_states)] # Assign distinct colors for each component if cmap is not None: # Assign distinct colors for each component valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) component_colors = [cmap(i) for i in range(num_states)] else: component_colors = get_distinct_colors(num_states, cmap) else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc.") cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(num_states)] elif num_states ==1: cmap = plt.get_cmap('tab10') component_colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(num_states)] else: component_colors = get_distinct_colors(num_states, cmap) fig, ax = plt.subplots(figsize=figsize) if log_scale_y: if np.any(psd <= 0): warnings.warn("log_scale_y=True, but PSD contains non-positive values. Log scale will be skipped.") log_scale_y = False else: ax.set_yscale('log') if log_scale_x: ax.set_xscale('log') for state in range(num_states): state_num = state + 1 color = component_colors[state] label = line_labels[state] y_vals = psd[:, state] if state_num in significant_states: ax.plot(freqs, y_vals, label=f"{label} *", linewidth=2.5, linestyle='-', alpha=1.0, color=color, zorder=3) elif significant_states == set(): ax.plot(freqs, y_vals, label=label, linewidth=2.5, linestyle='-', alpha=1.0, color=color, zorder=2) else: ax.plot(freqs, y_vals, label=label, linewidth=1.5, linestyle='--', alpha=0.8, color=color, zorder=2) for state in significant_states: max_abs_val = psd[np.abs(psd[state - 1]).argmax()][state - 1] ax.text(x_lim - 15, max_abs_val, f"{line_labels[state - 1]} *", fontsize=12, fontweight='bold') if highlight_freq: for band, (start, end) in bands.items(): ax.axvspan(start, end, color=band_colors[band], alpha=0.2) ax.set_xlabel('Frequency (Hz)', fontsize=fontsize_labels) ax.set_ylabel('Power', fontsize=fontsize_labels) ax.set_title(title, fontsize=fontsize_title) ax.set_xlim(0, x_lim) if label_line is None: legend_title = 'States' elif isinstance(label_line, str): legend_title = label_line if num_states > 1 else '' # use 'label_line' as title if multiple lines elif isinstance(label_line, list): legend_title = 'Lines' else: legend_title = '' state_legend = ax.legend(loc='upper right', title=legend_title) if highlight_freq: band_handles = [ Patch(facecolor=band_colors[band], edgecolor='none', alpha=0.5, label=f'{band}: {start}-{end} Hz') for band, (start, end) in bands.items() ] band_legend = ax.legend( handles=band_handles, loc='upper right' if band_legend_anchor is None else 'upper right', bbox_to_anchor=band_legend_anchor if band_legend_anchor else None, title='Frequency Bands' ) ax.add_artist(state_legend) plt.tight_layout() # Save the figure if save_path is provided if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def plot_state_coherence(coh, freqs, significant_states=None, x_lim=None, cmap=None, highlight_freq=False, bands=None, band_colors=None, title='State Coherence between Two Channels', figsize=(10, 5), fontsize_labels=13, fontsize_title=16, band_legend_anchor=(1.28, 1), label_line=None, save_path=None, return_fig=False): """ Plot the coherence between two channels for each state, with optional highlighting of frequency bands and significant states. Parameters: -------------- coh (numpy.ndarray): Array of shape (n_freqs, num_states) representing the coherence between two selected channels for each state. freqs (numpy.ndarray): 1D array representing the frequency axis, should match the first dimension of `coh`. significant_states (set, optional): Set of 1-based indices indicating which states are considered significant. Significant states are highlighted with a solid line and an asterisk. x_lim (int, optional): The upper limit of the frequency axis (x-axis). If None, it will default to the maximum value in `freqs`. cmap (str, optional): Name of a colormap to use for state line colors (default is 'Set3'). highlight_freq (bool, optional): Whether to highlight canonical or custom frequency bands. Default is False. bands (dict, optional): Dictionary defining frequency bands. Keys are band names and values are (start, end) tuples in Hz. If None, default bands will be used. band_colors (dict, optional): Dictionary mapping band names to color names. Keys must match those in `bands`. title (str, optional): Title of the plot. Default is "State Coherence between Two Channels". figsize (tuple, optional): Tuple defining figure size in inches. Default is (10, 5). fontsize_labels (int): Font size for x and y axis labels. Default is 13. fontsize_title (int): Font size for the plot title. Default is 16. band_legend_anchor (tuple or None, optional): Tuple for `bbox_to_anchor` to control frequency band legend placement. Default is (1.28, 1). If set to None, legend is placed at 'upper right'. label_line (str or list, optional): Custom labels for each line. Can be a string (prefix) or a list of names. If not provided, states are labeled as "State 1", "State 2", etc. save_path (str, optional, default=None): If a string is provided, the figure will be saved to the specified path. """ if significant_states is None: significant_states = set() if x_lim is None: x_lim = int(np.max(freqs)) coh = np.real(coh) n_freqs, num_states = coh.shape # Labels if isinstance(label_line, list): if len(label_line) != num_states: raise ValueError("Length of 'label_line' must match number of states.") line_labels = label_line elif isinstance(label_line, str): line_labels = [f"{label_line} {i+1}" for i in range(num_states)] else: line_labels = [f"State {i+1}" for i in range(num_states)] # Bands and colors default_bands = { 'Delta': (0, 4), 'Theta': (4, 8), 'Alpha': (8, 13), 'Beta': (13, 30), 'Gamma': (30, x_lim) } default_band_colors = { 'Delta': 'orange', 'Theta': 'cyan', 'Alpha': 'magenta', 'Beta': 'black', 'Gamma': 'green' } bands = bands if bands is not None else default_bands band_colors = band_colors if band_colors is not None else default_band_colors if set(bands.keys()) != set(band_colors.keys()): raise ValueError("Keys of bands and band_colors must match.") # Assign distinct colors for each component if cmap is not None: # Assign distinct colors for each component valid_cmaps = plt.colormaps() if isinstance(cmap, str) and cmap in valid_cmaps: if num_states <= 10: cmap = plt.get_cmap(cmap) component_colors = [cmap(i) for i in range(num_states)] else: component_colors = get_distinct_colors(num_states, cmap) else: warnings.warn(f"Invalid colormap '{cmap}'. Falling back to 'Set3'. " f"Use one of: {', '.join(valid_cmaps[:5])}... etc.") cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(num_states)] elif num_states <= 10: cmap = plt.get_cmap('Set3') component_colors = [cmap(i) for i in range(num_states)] else: component_colors = get_distinct_colors(num_states, cmap) fig, ax = plt.subplots(figsize=figsize) for state in range(num_states): state_idx = state + 1 label = line_labels[state] color = component_colors[state] y_vals = coh[:, state] if state_idx in significant_states: ax.plot(freqs, y_vals, label=f"{label} *", linewidth=2.5, linestyle='-', alpha=1.0, color=color) elif significant_states == set(): ax.plot(freqs, y_vals, label=label, linewidth=2.5, linestyle='-', alpha=1.0, color=color) else: ax.plot(freqs, y_vals, label=label, linewidth=1.5, linestyle='--', alpha=0.8, color=color) # Highlight frequency bands if highlight_freq: for band, (start, end) in bands.items(): ax.axvspan(start, end, color=band_colors[band], alpha=0.2) ax.set_xlabel('Frequency (Hz)', fontsize=fontsize_labels) ax.set_ylabel('Coherence', fontsize=fontsize_labels) ax.set_title(title, fontsize=fontsize_title) ax.set_xlim(0, x_lim) # Legends state_legend = ax.legend(loc='upper right', title='States') if highlight_freq: band_handles = [ Patch(facecolor=band_colors[band], edgecolor='none', alpha=0.5, label=f'{band}: {start}-{end} Hz') for band, (start, end) in bands.items() ] band_legend = ax.legend( handles=band_handles, loc='upper right' if band_legend_anchor is None else 'upper right', bbox_to_anchor=band_legend_anchor, title='Frequency Bands' ) ax.add_artist(state_legend) plt.tight_layout() if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return fig else: plt.show()
[docs] def check_exists(filename, fallback_directory="."): """ Check if a file exists, optionally falling back to a secondary directory. Parameters ---------- filename (str) File path or name to check. fallback_directory (str), optional Directory to look in if the file is not found at the original path. Default is current directory. Returns ------- str Full path to the found file. Raises ------ FileNotFoundError If the file is not found in either location. """ if not os.path.exists(filename): fallback = os.path.join(fallback_directory, filename) if os.path.exists(fallback): return fallback else: raise FileNotFoundError(filename) return filename
[docs] def validate(array, correct_dimensionality, allow_dimensions, error_message): """ Validate and reshape an array to the correct dimensionality. Parameters ---------- array (np.ndarray) The array to validate. correct_dimensionality : int The target number of dimensions. allow_dimensions : list of int Acceptable dimensionalities that will be expanded to the target. error_message (str) Error message to raise if dimensionality is incorrect. Returns ------- np.ndarray Array reshaped to the correct number of dimensions. Raises ------ ValueError If the input does not meet dimensionality requirements. """ array = np.array(array) for dimensionality in allow_dimensions: if array.ndim == dimensionality: for _ in range(correct_dimensionality - dimensionality): array = array[np.newaxis, ...] if array.ndim != correct_dimensionality: raise ValueError(error_message) return array
[docs] def parcel_vector_to_voxel_grid(mask_file, parcellation_file, vector): """ Map a vector of parcel values onto a 3D voxel grid using a mask and parcellation. Parameters ---------- mask_file (str) Path to a NIfTI mask file. parcellation_file (str) Path to a NIfTI parcellation file. vector (np.ndarray) 1D array of values (one per parcel). Shape must match number of parcels. Returns ------- np.ndarray 3D voxel grid with mapped values at each brain voxel. Raises ------ ValueError If the number of parcels in the parcellation does not match the length of the vector. """ logging.getLogger("nibabel.global").setLevel(logging.ERROR) mask_file = check_exists(mask_file) parcellation_file = check_exists(parcellation_file) mask = nib.load(mask_file) mask_grid = mask.get_fdata().ravel(order="F") non_zero_voxels = mask_grid != 0 parcellation = nib.load(parcellation_file) parcellation_grid = parcellation.get_fdata() if parcellation_grid.ndim == 3: unique_values = np.unique(parcellation_grid)[1:] parcellation_grid = np.array([(parcellation_grid == v).astype(int) for v in unique_values]) parcellation_grid = np.rollaxis(parcellation_grid, 0, 4) parcellation = nib.Nifti1Image(parcellation_grid, parcellation.affine, parcellation.header) parcellation = resample_to_img(parcellation, mask, force_resample=True, copy_header=True) parcellation_grid = parcellation.get_fdata() n_parcels = parcellation.shape[-1] if vector.shape[0] != n_parcels: raise ValueError("parcellation_file has a different number of parcels to the vector") voxel_weights = parcellation_grid.reshape(-1, n_parcels, order="F")[non_zero_voxels] voxel_weights /= voxel_weights.max(axis=0, keepdims=True) voxel_values = voxel_weights @ vector voxel_grid = np.zeros(mask_grid.shape[0]) voxel_grid[non_zero_voxels] = voxel_values voxel_grid = voxel_grid.reshape(mask.shape, order="F") return voxel_grid
[docs] def get_custom_colormap(): """ Create a custom colormap for brain activation plotting. Returns ------- matplotlib.colors.LinearSegmentedColormap A custom colormap transitioning through turquoise, blue, gray, and red-yellow. """ colors = ["#00FA9A", "#40E0D0", "#0000FF", "#BFBFBF", "#FF0000", "#FFA500", "#FFFF00"] positions = [0.0, 0.16, 0.33, 0.5, 0.66, 0.83, 1.0] return LinearSegmentedColormap.from_list("brain_activation_updated", list(zip(positions, colors)), N=256)
[docs] def save_figure(fig, path, fig_format, show=False): """ Save a matplotlib figure to disk and optionally close it. Parameters ---------- fig (matplotlib.figure.Figure) The figure to save. path (str): Output path where the figure will be saved. fig_format (str): Format to save the figure (e.g., 'svg', 'png'). show (bool): Whether to keep the figure open (True) or close it (False). """ fig.savefig(path, format=fig_format) if not show: plt.close(fig)
[docs] def plot_brain_state_maps(power_map, mask_file, parcellation_file, filename=None, fig_format="png", component=0, subtract_mean=False, mean_weights=None, match_color_scale=False, plot_kwargs=None, show_plots=True, combined=False, titles=None, n_rows=1, save_figures=False, figure_filenames=None, save_folder_name="brain_maps", return_fig=False): """ Plots or saves power spectral brain state maps projected to surface. Parameters: -------------- power_map (np.ndarray): Array of shape (n_components, n_modes, n_channels) or similar. mask_file (str): Path to NIfTI mask file. parcellation_file (str): Path to NIfTI parcellation file. filename (str, optional): Base filename for saving output. Supports .nii/.nii.gz/.png/.svg/.pdf. fig_format (str, optional), default='png': File format for figure export (e.g., "pdf", "png"). component (int, optional): Index of the spectral component to plot. subtract_mean (bool, optional), default=False: Whether to subtract mean across modes. mean_weights (np.ndarray, optional): Weights for computing the average across modes. match_color_scale (bool, optional), default=False: Force consistent vmin/vmax across plots. plot_kwargs : dict, optional Keyword arguments passed to `nilearn.plotting.plot_img_on_surf`. Common options include: - surf_mesh (str) or dict, default='fsaverage5' Cortical mesh to use for plotting. - hemispheres : list of str, default=['left', 'right'] Hemispheres to show ('left', 'right', or both). - views : list of str, default=['lateral', 'medial'] View angles for each hemisphere. - inflate (bool), default=False Whether to use an inflated surface. - bg_on_data (bool), default=False Whether to blend background surface with data overlay. - title (str), optional Title shown above each surface plot. - colorbar (bool), default=True Show colorbar alongside the figure. - vmin, vmax : float, optional Value range for colormap. - threshold : float, optional Values below this (in absolute value) are masked out. - symmetric_cbar (bool) or 'auto', default='auto' Whether to center the colorbar symmetrically around zero. - cmap (str) or colormap, default='cold_hot' Colormap used for surface data. - cbar_tick_format (str), default='%i' Tick formatting for the colorbar. show_plots (bool, optional): Whether to display the plots. combined (bool, optional): Combine plots into a single figure (enables save_figures). titles (list or bool, optional): List of titles for each mode or True for auto-generated labels. n_rows (int, optional): Number of rows in the combined figure. save_figures (bool, optional): Whether to save each plot as a file. figure_filenames (str or list, optional): Base name or list of full paths for saving each plot. save_folder_name (str, optional): Name of the output folder where saved figures will be stored. Default is "brain_maps". """ power_map = np.squeeze(power_map) if power_map.ndim > 1 and power_map.shape[-1] == power_map.shape[-2]: power_map = np.diagonal(power_map, axis1=-2, axis2=-1) if power_map.ndim == 1: power_map = power_map[np.newaxis, ...] else: power_map = power_map[np.newaxis, ...] power_map = validate(power_map, 3, [2], "power_map.shape is incorrect") n_modes = power_map.shape[1] if subtract_mean and n_modes > 1: power_map -= np.average(power_map, axis=1, weights=mean_weights)[:, np.newaxis, ...] power_map = power_map[component] mask_file = check_exists(mask_file) parcellation_file = check_exists(parcellation_file) power_map = [parcel_vector_to_voxel_grid(mask_file, parcellation_file, p) for p in power_map] power_map = np.moveaxis(power_map, 0, -1) mask = nib.load(mask_file) if plot_kwargs is None: plot_kwargs = {} # Use custom colormap by default unless overridden if "cmap" not in plot_kwargs: plot_kwargs["cmap"] = get_custom_colormap() if combined: if not save_figures: print("[Warning] 'combined=True' requires 'save_figures=True'. Enabling save_figures automatically.") save_figures = True if fig_format.lower() != "png": print("[Warning] switching fig_format to 'png' when 'combined=True'.") fig_format = "png" if "cbar_tick_format" in plot_kwargs: if plot_kwargs["cbar_tick_format"] is True: plot_kwargs["cbar_tick_format"] = "%.2f" elif plot_kwargs["cbar_tick_format"] is False: plot_kwargs["cbar_tick_format"] = "" if titles is None: titles = [None] * n_modes elif titles is True: titles = [f"State {i+1}" for i in range(n_modes)] elif isinstance(titles, str): titles = [f"{titles} {i+1}" for i in range(n_modes)] elif isinstance(titles, list): if len(titles) != n_modes: raise ValueError("Length of 'titles' must match number of modes.") if match_color_scale: if plot_kwargs.get("symmetric_cbar", False) is True: abs_max = np.nanmax(np.abs(power_map)) plot_kwargs["vmin"] = -abs_max plot_kwargs["vmax"] = abs_max else: plot_kwargs["vmin"] = np.nanmin(power_map) plot_kwargs["vmax"] = np.nanmax(power_map) output_files = [] # PATH_OUTPUT = Path(".") if filename is None else Path(filename).parent # base_filename = Path(filename).stem if filename else "power_map" PATH_OUTPUT, base_filename = __resolve_figure_directory(save_figures, filename, default_folder=save_folder_name) for i in trange(n_modes, desc="Saving images", disable=not show_plots): nii = nib.Nifti1Image(power_map[:, :, :, i], mask.affine, mask.header) fig, ax = plotting.plot_img_on_surf(nii, output_file=None, **plot_kwargs) if plot_kwargs.get("colorbar", True): for axes_obj in fig.axes: if hasattr(axes_obj, 'get_position'): bbox = axes_obj.get_position() if bbox.width < 0.4 and bbox.height < 0.05: axes_obj.set_position([0.2, 0.05, 0.6, 0.025]) if hasattr(axes_obj, 'collections') and axes_obj.collections: colorbar = getattr(axes_obj.collections[0], 'colorbar', None) if colorbar and plot_kwargs.get("cbar_tick_format"): colorbar.locator = ticker.MaxNLocator(nbins=4) colorbar.formatter = ticker.FormatStrFormatter(plot_kwargs["cbar_tick_format"]) colorbar.update_ticks() if titles: fig.suptitle(titles[i], fontsize=20) # Save figure if requested if save_figures or combined: base = figure_filenames if isinstance(figure_filenames, str) else base_filename path_fig = PATH_OUTPUT / __generate_filename(base, i, fig_format) fig.savefig(path_fig, format=fig_format) output_files.append(path_fig) if not show_plots: plt.close(fig) if combined: n_columns = -(n_modes // -n_rows) fig, axes_grid = plt.subplots(n_rows, n_columns, figsize=(n_columns * 5, n_rows * 5)) for i, ax in enumerate(axes_grid.flatten()): ax.axis("off") if i < len(output_files): ax.imshow(plt.imread(output_files[i])) fig.tight_layout() combined_path = PATH_OUTPUT / f"{base_filename}_combined.{fig_format}" fig.savefig(combined_path) if not show_plots: plt.close(fig) if return_fig: return fig else: plt.show()
[docs] def update_save_flags(save_figures, combined, fig_format): """ Updates save_figures and fig_format based on combined flag. Parameters ---------- save_figures : bool Whether to save individual figures. combined : bool Whether to save a combined multi-panel figure. fig_format : str Desired figure format (e.g., 'pdf', 'png'). Returns ------- save_figures : bool Updated save_figures flag. fig_format : str Updated fig_format (forces 'png' if combined). """ if combined: if not save_figures: print("[Info] 'combined=True' now also saves individual figures.") save_figures = True if fig_format.lower() != "png": print("[Info] Combined figure forced to PNG format.") fig_format = "png" return save_figures, fig_format
[docs] def get_parcellation_centers(parcellation_file): """ Extracts MNI coordinates for each parcel in a 4D NIfTI parcellation. Parameters: -------------- parcellation_file (str): Path to a 4D binary NIfTI file where each volume corresponds to a parcel. Returns: -------------- centers (np.ndarray): Array of shape (n_parcels, 3) containing the MNI coordinates for each parcel. """ img = nib.load(parcellation_file) data = img.get_fdata() affine = img.affine if data.ndim == 4: n_parcels = data.shape[-1] centers = [] for i in range(n_parcels): parcellation_coords = np.argwhere(data[..., i] > 0) if parcellation_coords.size == 0: centers.append([np.nan, np.nan, np.nan]) else: voxel_center = parcellation_coords.mean(axis=0) world_center = nib.affines.apply_affine(affine, voxel_center) centers.append(world_center) return np.array(centers) else: raise ValueError("Parcellation file must be 4D.")
[docs] def plot_connectivity_maps(connectivity_map, parcellation_file, filename=None, fig_format="png", component=None, threshold=0, match_color_scale = True, plot_kwargs=None, show_plots=True, axes=None, combined=False, save_figures=False, titles=None, n_rows=1, figure_filenames=None, save_folder_name="connectivity_maps", return_fig=False): """ Plot connectivity maps, such as functional or spectral connectivity, using a parcellation layout. Parameters: -------------- connectivity_map (numpy.ndarray): Array of shape (n_modes, n_channels, n_channels) or (n_components, n_modes, n_channels, n_channels). Represents connectivity matrices for each mode (or component and mode). parcellation_file (str): Path to a parcellation file used to define node coordinates for plotting the connectome. filename (str, optional): If provided, the base filename for saving the figure(s). The appropriate format (e.g., .png, .svg) will be determined by `fig_format`. fig_format (str, optional): Format to save the figures, e.g., 'png', 'svg', or 'pdf'. Default is 'png'. component (int, optional): If connectivity_map is 4D, this selects which component to plot. If None, all components are plotted. threshold (float, optional): Minimum absolute value for showing a connection. Values below this threshold are not shown. Default is 0 (no thresholding). match_color_scale (bool, optional): Whether to use the same color scale across all plots. Default is True. plot_kwargs (dict, optional): Additional keyword arguments passed to the plotting function (e.g., `edge_cmap`, `node_size`). show_plots (bool, optional): Whether to display the figures on screen. Default is True. axes (matplotlib.axes.Axes or array-like, optional): Axes to use for plotting, if already created externally. If None, new axes will be generated. combined (bool, optional): If True, all maps are shown in a single figure. Otherwise, one figure per map is created. save_figures (bool, optional): Whether to save the plotted figures. Default is False. titles (list of str, optional): Titles to use for each connectivity map. If None, titles will be generated automatically. n_rows (int, optional): Number of rows to use when arranging subplots (if `combined=True`). Default is 1. figure_filenames (list of str, optional): List of custom filenames for each individual figure (only used if `combined=False` and `save_figures=True`). save_folder_name (str, optional): Name of the output folder where saved figures will be stored. Default is "connectivity_maps". """ connectivity_map = np.copy(connectivity_map) # Standardize shape if connectivity_map.ndim == 2: connectivity_map = connectivity_map[np.newaxis, np.newaxis, ...] elif connectivity_map.ndim == 3: connectivity_map = connectivity_map[np.newaxis, ...] elif connectivity_map.ndim != 4: raise ValueError("connectivity_map must be 2D, 3D or 4D") if isinstance(threshold, (float, int)): threshold = np.array([threshold] * connectivity_map.shape[1]) if np.any(threshold > 1) or np.any(threshold < 0): raise ValueError("threshold must be between 0 and 1.") if component is None: component = 0 conn_map = connectivity_map[component] n_modes = conn_map.shape[0] if match_color_scale: # Create a copy to avoid modifying original data conn_map_ = np.copy(conn_map) # Zero out the diagonal (for square matrices) if conn_map_.ndim == 3 and conn_map_.shape[1] == conn_map_.shape[2]: for i in range(conn_map_.shape[0]): np.fill_diagonal(conn_map_[i], 0) abs_max = np.nanmax(np.abs(conn_map_)) if abs_max > 0 and not np.isnan(abs_max): plot_kwargs = plot_kwargs or {} plot_kwargs["edge_vmin"] = -abs_max plot_kwargs["edge_vmax"] = abs_max else: print("[Warning] conn_map contains only zeros or NaNs after removing diagonals.") # set titles if titles is None: titles = [None] * n_modes elif titles is True: titles = [f"State {i+1}" for i in range(n_modes)] elif isinstance(titles, str): titles = [f"{titles} {i+1}" for i in range(n_modes)] elif isinstance(titles, list): if len(titles) != n_modes: raise ValueError("Length of 'titles' must match number of modes.") if combined: # Automatically adjust saving behavior for combined plots save_figures, fig_format = update_save_flags(save_figures, combined, fig_format) if save_figures: if isinstance(figure_filenames, list): if len(figure_filenames) != n_modes: raise ValueError("Length of figure_filenames must match number of modes.") # Zero out diagonal for c in conn_map: np.fill_diagonal(c, 0) parcellation_coords = get_parcellation_centers(parcellation_file) default_plot_kwargs = {"node_size": 10, "node_color": "black"} axes = axes or [None] * conn_map.shape[0] output_files = [] kwargs = __override_dict_defaults(default_plot_kwargs, plot_kwargs) PATH_OUTPUT, base_filename = __resolve_figure_directory(save_figures, filename, default_folder=save_folder_name) for i in trange(conn_map.shape[0], desc="Saving images"): # Only show colorbar if matrix isn't all zeros kwargs["colorbar"] = np.any(conn_map[i][~np.eye(conn_map[i].shape[-1], dtype=bool)] != 0) base = figure_filenames if isinstance(figure_filenames, str) else base_filename PATH_FIG = os.path.join(PATH_OUTPUT, __generate_filename(base, i, fig_format)) if save_figures else None #fig = plt.figure() display = plotting.plot_connectome( conn_map[i], parcellation_coords, edge_threshold=f"{threshold[i] * 100}%", output_file=None, # Don't save here axes=axes[i], **kwargs, ) if titles: # Manually add title using matplotlib fig = plt.gcf() fig.suptitle(titles[i], fontsize=20, color="black", y=0.95) if save_figures or combined: if PATH_FIG is None: # If we're only creating the combined image, still need to save a temporary one PATH_FIG = os.path.join(PATH_OUTPUT, __generate_filename(f"{base_filename}_combined_part", i, fig_format)) fig.savefig(PATH_FIG, format=fig_format) if not show_plots: plt.close(fig) output_files.append(PATH_FIG) if combined: n_columns = -(n_modes // -n_rows) fig, axes_grid = plt.subplots(n_rows, n_columns, figsize=(n_columns * 5, n_rows * 5)) for i, ax in enumerate(axes_grid.flatten()): ax.axis("off") if i < n_modes: ax.imshow(plt.imread(output_files[i])) filename = filename or "connectivity_combined" fig.tight_layout() fig.savefig(os.path.join(PATH_OUTPUT, f"{base_filename}_combined.{fig_format}")) if not show_plots: plt.close(fig) for image_path in output_files: os.remove(image_path) if return_fig: return fig else: plt.show()
[docs] def plot_model_selection(results, n_total_timepoints, save_path=None, similarity_threshold=0.8, figsize=(20, 6), fontsize_title=16, fontsize_labels=14, fontsize_ticks=12, return_fig=False): """ Plot HMM model selection: stability (Gamma similarity), free energy elbow, and EM convergence curves. Displays three panels to support principled selection of the number of hidden states (K): 1. **Gamma similarity violin** - distribution of pairwise similarity scores across repetitions per K. A high mean (red line) combined with a narrow spread indicates a stable, well-identified solution. The grey dashed line marks the minimum-stability threshold. 2. **Free energy elbow** - normalised minimum free energy vs K. Lower values indicate a better model fit; look for the elbow where gains flatten off. 3. **Convergence curves** - free energy across EM cycles for the best repetition per K. Curves should plateau smoothly; a still-descending curve means `cyc` needs to increase. Parameters: -------------- results (dict): Returned by ``utils.run_stability_training()`` or ``utils.load_stability_results()``. Format: ``{K: {'FE': [list of arrays], 'similarity_scores': [list of floats]}}``. n_total_timepoints (int): Total number of timepoints in the training data (``Y.shape[0]``). Used to normalise free energy values across K values. save_path (str, optional), default=None: If provided, saves the figure to that path. similarity_threshold (float, optional), default=0.8: Horizontal dashed threshold line drawn on the stability panel. figsize (tuple, optional), default=(20, 6): Figure size. fontsize_title (int, optional), default=16: Font size for panel titles. fontsize_labels (int, optional), default=14: Font size for axis labels. fontsize_ticks (int, optional), default=12: Font size for tick labels. return_fig (bool, optional), default=False: If ``True``, returns the figure object instead of calling ``plt.show()``. Returns: ---------- avg_similarity (dict): Mean Gamma similarity score per K value, ``{K: float}``. min_fe (dict): Normalised minimum free energy per K value, ``{K: float}``. fig (matplotlib.figure.Figure): Only returned when ``return_fig=True``. """ ks = sorted(results.keys()) sim_rows = [] for K in ks: for score in results[K]['similarity_scores']: sim_rows.append({'K': K, 'Gamma Similarity': score}) df_sim = pd.DataFrame(sim_rows) avg_similarity = {K: np.mean(results[K]['similarity_scores']) for K in ks} min_fe = { K: np.min([fe[-1] for fe in results[K]['FE']]) / n_total_timepoints for K in ks } fig, axes = plt.subplots(1, 3, figsize=figsize) fig.suptitle('HMM Model Selection', fontsize=fontsize_title, y=1.01) # Panel 1: Gamma similarity violin ax = axes[0] if not df_sim.empty: sns.violinplot(x='K', y='Gamma Similarity', hue='K', data=df_sim, palette='coolwarm', inner='point', density_norm='width', cut=0, legend=False, ax=ax) means = df_sim.groupby('K')['Gamma Similarity'].mean() x_pos = list(range(len(ks))) ax.plot(x_pos, [means[K] for K in ks], color='red', marker='o', markersize=7, linewidth=2, label='Mean', zorder=5) ax.axhline(similarity_threshold, color='grey', linestyle='--', linewidth=1, label=f'Threshold ({similarity_threshold})') ax.set_title('Stability (higher + narrow = good)', fontsize=fontsize_title) ax.set_xlabel('Number of States (K)', fontsize=fontsize_labels) ax.set_ylabel('Gamma Similarity', fontsize=fontsize_labels) ax.tick_params(axis='both', labelsize=fontsize_ticks) ax.legend(fontsize=fontsize_ticks) ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) ax.spines['bottom'].set_visible(False) ax.spines['left'].set_visible(False) # Panel 2: Free energy elbow ax = axes[1] fe_vals = [min_fe[K] for K in ks] ax.plot(ks, fe_vals, marker='s', color='darkorange', linewidth=2, markersize=8) if len(ks) >= 3: elbow_idx = int(np.argmax(np.diff(np.diff(fe_vals)))) + 1 ax.axvline(ks[elbow_idx], color='grey', linestyle='--', linewidth=1, label=f'Elbow (K={ks[elbow_idx]})') ax.legend(fontsize=fontsize_ticks) ax.set_title('Model fit (min FE / T) lower = better; look for elbow', fontsize=fontsize_title) ax.set_xlabel('Number of States (K)', fontsize=fontsize_labels) ax.set_ylabel('Min Free Energy / n_timepoints', fontsize=fontsize_labels) ax.tick_params(axis='both', labelsize=fontsize_ticks) ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) ax.spines['bottom'].set_visible(False) ax.spines['left'].set_visible(False) # Panel 3: FE convergence curves (best rep per K) ax = axes[2] cmap = plt.get_cmap('coolwarm', len(ks)) for i, K in enumerate(ks): best_rep = int(np.argmin([fe[-1] for fe in results[K]['FE']])) ax.plot(results[K]['FE'][best_rep], color=cmap(i), linewidth=1.5, label=f'K={K}') ax.set_title('FE convergence (best rep per K) should plateau smoothly', fontsize=fontsize_title) ax.set_xlabel('Cycle', fontsize=fontsize_labels) ax.set_ylabel('Free Energy', fontsize=fontsize_labels) ax.tick_params(axis='both', labelsize=fontsize_ticks) ax.legend(fontsize=fontsize_ticks, ncol=2) ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) ax.spines['bottom'].set_visible(False) ax.spines['left'].set_visible(False) plt.tight_layout() if save_path is not None: plt.savefig(save_path, bbox_inches='tight') if return_fig: return avg_similarity, min_fe, fig else: plt.show() # Summary table print(f" {'K':>4} | {'Mean Sim':>9} | {'Std Sim':>8} | {'Min FE/T':>10} | {'Converged?':>10}") print('-' * 52) for K in ks: sims = results[K]['similarity_scores'] best_fe = results[K]['FE'][int(np.argmin([fe[-1] for fe in results[K]['FE']]))] last_change = abs(best_fe[-1] - best_fe[-2]) / (abs(best_fe[-1] - best_fe[0]) + 1e-10) converged = 'yes' if last_change < 1e-3 else 'CHECK' print(f'{K:>4} | {np.mean(sims):>9.4f} | {np.std(sims):>8.4f} | {min_fe[K]:>10.2f} | {converged:>10}') return avg_similarity, min_fe
def __resolve_figure_directory(save_figures, filename, default_folder): return resolve_figure_directory(save_figures, filename, default_folder) def __generate_filename(base, index, extension): return generate_filename(base, index, extension) def __override_dict_defaults(default_dict, override_dict): return override_dict_defaults(default_dict, override_dict)