modality_palette = {
"vocal": "#66c2a5", # greenish
"gesture": "#fc8d62", # orangish
"multimodal": "#8da0cb" # bluish
}
def Tukey_outlier_bounds(data_series):
"""Calculate Tukey's outlier bounds for a given data series.
Args:
data_series (pd.Series): Input data series to calculate bounds for
Returns:
tuple: (lower_bound, upper_bound) for outlier detection
"""
Q1 = data_series.quantile(0.25)
Q3 = data_series.quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
return lower_bound, upper_bound
def plot_raincloud_comparison(features_df_nonz, features_df_z, var, var_to_display, log_transform=True, modalityout='', correctionout='c0_only'):
"""Plot raincloud comparison of non-zscored and z-scored data.
Args:
features_df_nonz (pd.DataFrame): Non-zscored features dataframe
features_df_z (pd.DataFrame): Z-scored features dataframe
var (str): Variable name to plot
var_to_display (str): Display name for the variable
log_transform (bool): Whether to apply log transformation
modalityout (str): Modality to exclude from plotting
correctionout (str): Correction level to exclude from plotting
Returns:
None: Displays the raincloud plot comparison
"""
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
modality_palette = {
"vocal": "#66c2a5", # greenish
"gesture": "#fc8d62", # orangish
"multimodal": "#8da0cb" # bluish
}
correction_order = ['c0', 'c1', 'c2']
# If correctionout is not None, remove that correction_info
if correctionout is not None:
features_df_nonz = features_df_nonz[features_df_nonz['correction_info'] != correctionout]
features_df_z = features_df_z[features_df_z['correction_info'] != correctionout]
# If modalityout is not None, remove that modality
if modalityout != '':
features_df_nonz = features_df_nonz[features_df_nonz['modality'] != modalityout]
features_df_z = features_df_z[features_df_z['modality'] != modalityout]
# Remove outliers using Tukey method
lower_bound, upper_bound = Tukey_outlier_bounds(features_df_nonz[var])
cleaned_data_nonz = features_df_nonz[
(features_df_nonz[var] >= lower_bound) &
(features_df_nonz[var] <= upper_bound)
].copy()
# how much was removed?
removed_percentage = 100 * (1 - len(cleaned_data_nonz) / len(features_df_nonz))
print(f"Removed {removed_percentage:.2f}% of data as outliers based on {var_to_display}.")
# Log-transform
if log_transform:
cleaned_data_nonz[var] = np.log(cleaned_data_nonz[var] + 1)
# Ensure correct order of correction_info
cleaned_data_nonz['correction_info'] = pd.Categorical(
cleaned_data_nonz['correction_info'],
categories=correction_order,
ordered=True
)
# Calculate means of modalities in c0
mean_c0_mult_nonz = cleaned_data_nonz[(cleaned_data_nonz['correction_info'] == 'c0') & (cleaned_data_nonz['modality'] == 'multimodal')][var].mean()
mean_c0_ges_nonz = cleaned_data_nonz[(cleaned_data_nonz['correction_info'] == 'c0') & (cleaned_data_nonz['modality'] == 'gesture')][var].mean()
mean_c0_voc_nonz = cleaned_data_nonz[(cleaned_data_nonz['correction_info'] == 'c0') & (cleaned_data_nonz['modality'] == 'vocal')][var].mean()
#### Z-scored data ####
# identify outliers as 5*sd away from the mean
mean_val = features_df_z[var].mean()
std_val = features_df_z[var].std()
outlier_threshold_upper = mean_val + 2 * std_val
outlier_threshold_lower = mean_val - 2 * std_val
cleaned_data_z = features_df_z[(features_df_z[var] <= outlier_threshold_upper) & (features_df_z[var] >= outlier_threshold_lower)].copy()
print("Percentage of z-scored data removed: ", (features_df_z.shape[0] - cleaned_data_z.shape[0]) / features_df_z.shape[0] * 100)
# Ensure correct order of correction_info
cleaned_data_z['correction_info'] = pd.Categorical(
cleaned_data_z['correction_info'],
categories=correction_order,
ordered=True
)
# calculate mean of gesture, c0
mean_c0_mult_z = cleaned_data_z[(cleaned_data_z['correction_info'] == 'c0') & (cleaned_data_z['modality'] == 'multimodal')][var].mean()
mean_c0_ges_z = cleaned_data_z[(cleaned_data_z['correction_info'] == 'c0') & (cleaned_data_z['modality'] == 'gesture')][var].mean()#
mean_c0_voc_z = cleaned_data_z[(cleaned_data_z['correction_info'] == 'c0') & (cleaned_data_z['modality'] == 'vocal')][var].mean()#
# Create the figure and axes
fig, axes = plt.subplots(
nrows=1,
ncols=2,
figsize=(18, 7),
sharey=False
)
# ---------- LEFT: non-zscored + log ----------
pt.RainCloud(
x='correction_info',
y=var,
hue='modality',
data=cleaned_data_nonz,
palette=modality_palette,
width_viol=0.6,
width_box=0.15,
move=0.15,
bw=0.4,
alpha=0.5,
dodge=True,
pointplot=True,
orient='v',
ax=axes[0]
)
if modalityout == 'vocal':
axes[0].axhline(mean_c0_mult_nonz, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[0].axhline(mean_c0_ges_nonz, color=modality_palette['gesture'],
linestyle='--', linewidth=1.5)
elif modalityout == 'gesture':
axes[0].axhline(mean_c0_mult_nonz, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[0].axhline(mean_c0_voc_nonz, color=modality_palette['vocal'],
linestyle='--', linewidth=1.5)
else:
axes[0].axhline(mean_c0_mult_nonz, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[0].axhline(mean_c0_ges_nonz, color=modality_palette['gesture'],
linestyle='--', linewidth=1.5)
axes[0].axhline(mean_c0_voc_nonz, color=modality_palette['vocal'],
linestyle='--', linewidth=1.5)
axes[0].set_title("Non-zscored", fontsize=16, weight='bold')
if log_transform:
ylabel = f"{var_to_display} (log)"
else:
ylabel = var_to_display
axes[0].set_ylabel(ylabel)
axes[0].set_xlabel("")
# ---------- RIGHT: z-scored ----------
pt.RainCloud(
x='correction_info',
y=var,
hue='modality',
data=cleaned_data_z,
palette=modality_palette,
width_viol=0.6,
width_box=0.15,
move=0.15,
bw=0.4,
alpha=0.5,
dodge=True,
pointplot=True,
orient='v',
ax=axes[1]
)
if modalityout == 'vocal':
axes[1].axhline(mean_c0_mult_z, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[1].axhline(mean_c0_ges_z, color=modality_palette['gesture'],
linestyle='--', linewidth=1.5)
elif modalityout == 'gesture':
axes[1].axhline(mean_c0_mult_z, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[1].axhline(mean_c0_voc_z, color=modality_palette['vocal'],
linestyle='--', linewidth=1.5)
else:
axes[1].axhline(mean_c0_mult_z, color=modality_palette['multimodal'],
linestyle='--', linewidth=1.5)
axes[1].axhline(mean_c0_ges_z, color=modality_palette['gesture'],
linestyle='--', linewidth=1.5)
axes[1].axhline(mean_c0_voc_z, color=modality_palette['vocal'],
linestyle='--', linewidth=1.5)
axes[1].axhline(0, color='black', linestyle='-', linewidth=1, alpha=0.6)
axes[1].set_title("Z-scored", fontsize=16, weight='bold')
axes[1].set_ylabel(var_to_display)
axes[1].set_xlabel("")
# Extract legend info from ONE axis only
handles, labels = axes[0].get_legend_handles_labels()
# Keep only the first two (gesture / multimodal)
handles = handles[:2]
labels = labels[:2]
# Remove ALL axis-level legends
for ax in axes:
if ax.get_legend() is not None:
ax.get_legend().remove()
# Add ONE shared legend to the figure
fig.legend(
handles,
labels,
title="modality",
loc="center right",
frameon=True
)
# Final cosmetics
for ax in axes:
ax.tick_params(axis='x', labelsize=12)
ax.tick_params(axis='y', labelsize=12)
ax.grid(True, axis='y', linestyle='--', alpha=0.6)
ax.set_facecolor("white")
plt.tight_layout()
plt.show()
def plot_relationship(df, x_var, y_var, hue_var=None, remove_outliers=True, modalityout='', sign_var=None):
"""Plot relationship between two variables with optional hue and modality filtering.
Creates a joint plot with scatter points, marginal distributions, and trend lines.
Supports filtering by modality and optional outlier removal.
Args:
df (pd.DataFrame): Input dataframe containing the data
x_var (str): Variable name for x-axis
y_var (str): Variable name for y-axis
hue_var (str, optional): Variable name for hue (color) grouping
remove_outliers (bool): Whether to remove outliers using Tukey method
modalityout (str): Modality to exclude from plotting
sign_var (str, optional): Variable indicating sign (positive/negative) for trend lines
Returns:
None: Displays the joint plot
"""
modality_palette = {
"vocal": "#66c2a5", # greenish
"gesture": "#fc8d62", # orangish
"multimodal": "#8da0cb" # bluish
}
if remove_outliers:
# Remove outliers based on Tukey method for y_var
Q1 = df[y_var].quantile(0.25)
Q3 = df[y_var].quantile(0.75)
IQR = Q3 - Q1
lower_bound = Q1 - 1.5 * IQR
upper_bound = Q3 + 1.5 * IQR
df = df[(df[y_var] >= lower_bound) & (df[y_var] <= upper_bound)]
# Set up modern styling
sns.set_context("notebook", font_scale=1.4)
sns.set_style("whitegrid")
plt.rcParams.update({
"axes.edgecolor": "0.2",
"axes.linewidth": 0.8,
"grid.color": "#e5e5e5",
"grid.linestyle": "--",
"grid.linewidth": 0.5,
"font.family": "sans-serif",
"font.sans-serif": ["DejaVu Sans"],
"axes.spines.right": False,
"axes.spines.top": False,
})
df = df[df['modality'] != modalityout]
# Set up JointGrid
g = JointGrid(
data=df,
x=x_var,
y=y_var,
height=8,
space=0,
ratio=4
)
# Scatter plot (main axis)
sns.scatterplot(
data=df,
x=x_var,
y=y_var,
hue='modality',
palette=modality_palette,
alpha=0.25,
s=50,
ax=g.ax_joint,
legend=False
)
# Global LOESS trend (black dashed)
sns.regplot(
data=df,
x=x_var,
y=y_var,
scatter=False,
lowess=True,
line_kws={'color': 'black', 'linewidth': 2, 'linestyle': '--'},
ax=g.ax_joint
)
# if sign_var is provided, plot separate lines for modality-sign_var level combination, otherwise just modality
if sign_var is not None:
for modality in df['modality'].unique():
for sign in df[sign_var].unique():
subset = df[(df['modality'] == modality) & (df[sign_var] == sign)]
sns.regplot(
data=subset,
x=x_var,
y=y_var,
scatter=False,
lowess=True,
line_kws={'color': modality_palette[modality], 'linewidth': 2, 'linestyle': '--' if sign == -1 else '-'},
ax=g.ax_joint
)
else:
for modality in df['modality'].unique():
subset = df[df['modality'] == modality]
sns.regplot(
data=subset,
x=x_var,
y=y_var,
scatter=False,
lowess=True,
line_kws={'color': modality_palette[modality], 'linewidth': 2},
ax=g.ax_joint
)
# Marginal KDEs
sns.kdeplot(
data=df,
x=x_var,
ax=g.ax_marg_x,
fill=True,
color='gray',
alpha=0.2,
linewidth=0
)
sns.kdeplot(
data=df,
y=y_var,
ax=g.ax_marg_y,
fill=True,
color='gray',
alpha=0.2,
linewidth=0
)
# add also linear regression line
sns.regplot(
data=df,
x=x_var,
y=y_var,
scatter=False,
line_kws={'color': 'grey', 'linewidth': 1.5},
ax=g.ax_joint
)
if sign_var is not None:
# add legend for all used lines
handles = [
plt.Line2D([0], [0], color='grey', linestyle='-', linewidth=1.5, label='Linear Trend'),
plt.Line2D([0], [0], color='black', linestyle='--', linewidth=2, label='Global LOESS'),
plt.Line2D([0], [0], color=modality_palette['vocal'], linestyle='-', linewidth=2, label='Vocal Trend'),
plt.Line2D([0], [0], color=modality_palette['vocal'], linestyle='--', linewidth=2, label='Vocal Trend (Negative)'),
plt.Line2D([0], [0], color=modality_palette['gesture'], linestyle='-', linewidth=2, label='Gesture Trend'),
plt.Line2D([0], [0], color=modality_palette['gesture'], linestyle='--', linewidth=2, label='Gesture Trend (Negative)'),
plt.Line2D([0], [0], color=modality_palette['multimodal'], linestyle='-', linewidth=2, label='Multimodal Trend'),
plt.Line2D([0], [0], color=modality_palette['multimodal'], linestyle='--', linewidth=2, label='Multimodal Trend (Negative)'),
]
else:
handles = [
plt.Line2D([0], [0], color='grey', linestyle='-', linewidth=1.5, label='Linear Trend'),
plt.Line2D([0], [0], color='black', linestyle='--', linewidth=2, label='Global LOESS'),
plt.Line2D([0], [0], color=modality_palette['vocal'], linestyle='-', linewidth=2, label='Vocal Trend'),
plt.Line2D([0], [0], color=modality_palette['gesture'], linestyle='-', linewidth=2, label='Gesture Trend'),
plt.Line2D([0], [0], color=modality_palette['multimodal'], linestyle='-', linewidth=2, label='Multimodal Trend'),
]
g.ax_joint.legend(
handles=handles,
loc='upper left',
frameon=True,
title='Trends',
fontsize=8,
title_fontsize=8
)
# add lable for y axes 'Change in envelope (integral)'ArithmeticError
g.ax_joint.set_xlabel(x_var.replace("_", " ").title())
g.ax_joint.set_ylabel(y_var.replace("_", " ").title())
# Minimal tick label visibility tweaks
g.ax_joint.tick_params(axis='x', labelsize=11)
g.ax_joint.tick_params(axis='y', labelsize=11)
# Tight layout
plt.tight_layout()
plt.show()
def plot_raincloud_comparison_with_grids(features_df_nonz, var, var_to_display, grid_var='effort_category', log_transform=True, modalityout='', correctionout='c0_only'):
"""Plot raincloud comparison with grid levels for non-zscored data.
Creates a grid of raincloud plots comparing different correction levels across
different levels of a specified grid variable (e.g., effort category).
Args:
features_df_nonz (pd.DataFrame): Non-zscored features dataframe
var (str): Variable name to plot
var_to_display (str): Display name for the variable
grid_var (str): Variable to use for creating grid levels (default: 'effort_category')
log_transform (bool): Whether to apply log transformation
modalityout (str): Modality to exclude from plotting
correctionout (str): Correction level to exclude from plotting
Returns:
None: Displays the grid of raincloud plots
"""
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
modality_palette = {
"vocal": "#66c2a5",
"gesture": "#fc8d62",
"multimodal": "#8da0cb"
}
correction_order = ['c0', 'c1', 'c2']
# Filter out unwanted correction_info and modality
if correctionout is not None:
features_df_nonz = features_df_nonz[features_df_nonz['correction_info'] != correctionout]
if modalityout != '':
features_df_nonz = features_df_nonz[features_df_nonz['modality'] != modalityout]
# Remove outliers using Tukey method
lower_bound, upper_bound = Tukey_outlier_bounds(features_df_nonz[var])
cleaned_data = features_df_nonz[
(features_df_nonz[var] >= lower_bound) &
(features_df_nonz[var] <= upper_bound)
].copy()
removed_percentage = 100 * (1 - len(cleaned_data) / len(features_df_nonz))
print(f"Removed {removed_percentage:.2f}% of data as outliers based on {var_to_display}.")
# Log-transform
if log_transform:
cleaned_data[var] = np.log(cleaned_data[var] + 1)
# Ensure correct order of correction_info
cleaned_data['correction_info'] = pd.Categorical(
cleaned_data['correction_info'],
categories=correction_order,
ordered=True
)
# Get grid levels from the specified variable
grid_levels = sorted(cleaned_data[grid_var].unique())
n_levels = len(grid_levels)
fig, axes = plt.subplots(
nrows=1,
ncols=n_levels,
figsize=(7 * n_levels, 7),
sharey=True
)
if n_levels == 1:
axes = [axes]
for ax, level in zip(axes, grid_levels):
subset = cleaned_data[cleaned_data[grid_var] == level]
# Compute c0 means per modality for reference lines
active_modalities = [m for m in ['multimodal', 'gesture', 'vocal'] if m != modalityout]
c0_means = {
m: subset[(subset['correction_info'] == 'c0') & (subset['modality'] == m)][var].mean()
for m in active_modalities
}
pt.RainCloud(
x='correction_info',
y=var,
hue='modality',
data=subset,
palette=modality_palette,
width_viol=0.6,
width_box=0.15,
move=0.15,
bw=0.4,
alpha=0.5,
dodge=True,
pointplot=True,
orient='v',
ax=ax
)
# Draw c0 reference lines
for modality, mean_val in c0_means.items():
ax.axhline(mean_val, color=modality_palette[modality],
linestyle='--', linewidth=1.5)
ax.set_title(f"{grid_var}: {level}", fontsize=16, weight='bold')
ax.set_xlabel("")
ax.tick_params(axis='x', labelsize=12)
ax.tick_params(axis='y', labelsize=12)
ax.grid(True, axis='y', linestyle='--', alpha=0.6)
ax.set_facecolor("white")
if ax is axes[0]:
ylabel = f"{var_to_display} (log)" if log_transform else var_to_display
ax.set_ylabel(ylabel, fontsize=13)
else:
ax.set_ylabel("")
# Remove per-axis legend
if ax.get_legend() is not None:
ax.get_legend().remove()
# Shared legend from first axis
handles, labels = axes[0].get_legend_handles_labels()
n_modalities = len(active_modalities)
fig.legend(
handles[:n_modalities],
labels[:n_modalities],
title="modality",
loc="center right",
frameon=True
)
plt.suptitle(var_to_display, fontsize=18, weight='bold', y=1.02)
plt.tight_layout()
plt.show()