F-statistic of manner separability¶
Calculating F-statistic¶
The F-statistic measures the ratio of between-category variance over within-category variance. Higher values indicate greater category separability. This metric can be computed for PRPs as a measure of neural separability of different manner-of-articulation classes. Here, we consider these 4 major sound classes: vowel, stop, fricative, and nasal-approximant. You can check the manner category of each phoneme:
import neuraspeech as ns
for k, v in ns.PHON2MANNER.items():
print(f'{k}: {v}')
AA: Vowel AE: Vowel AH: Vowel AO: Vowel AW: Vowel AY: Vowel EH: Vowel ER: Vowel EY: Vowel IH: Vowel IY: Vowel OW: Vowel OY: Vowel UH: Vowel UW: Vowel L: Nasal-approximant M: Nasal-approximant N: Nasal-approximant NG: Nasal-approximant R: Nasal-approximant W: Nasal-approximant Y: Nasal-approximant DH: Fricative F: Fricative HH: Fricative S: Fricative SH: Fricative V: Fricative Z: Fricative TH: Fricative ZH: Fricative B: Stop D: Stop G: Stop K: Stop P: Stop T: Stop CH: Stop JH: Stop Q: Stop
We'll use FStatistic from the toolbox to compute F-statistic. Specifically, an F-value will be computed at each electrode at each time point over the duration of PRP. By default, we compute F seaprately for each unique value of the "filename" factor, so that
. In the example data, there are 2 subjects and 8 conditions (resulting from 3 binary factors). This means that there'll be 16 rows in the output dataset.
# Load in the PRP data
prps = ns.PRPData.from_pickle('examples\saved\prps_saved.pkl')
# Compute F
fstats = ns.FStatistic(
prps, # Pass the PRPData object here
by = 'filename', # Default to 'filename', meaning that the results will be computed separately for each filename value
category = 'manner' # Defualt to 'manner'. Name of variable indicatings cateogry labels.
)
Loaded attributes from examples\saved\prps_saved.pkl
100%|██████████| 16/16 [01:45<00:00, 6.59s/it]
Done.
Note that the computation of F may take some time especially when the dataset is large. But just like PRPData, we can set the results to disk when the code finishes running and skip the calculation next time by loading it. Highly recommend to do this.
# Save the F-stat results
fstats.save('examples/saved/fstats_saved.pkl')
# Load it again
fstats = ns.FStatistic.from_pickle('examples/saved/fstats_saved.pkl')
Saved to examples/saved/fstats_saved.pkl Loaded attributes from examples/saved/fstats_saved.pkl
Like PRPData, the F-statistic results in the retunred FStatistic object are stored as a eelbrain Dataset, which can be acessed using the .get_data() method:
fstats_data = fstats.get_data()
print(fstats.get_data())
# filename sub ses int pres stat ----------------------------------------------------------------------- 0 sub-01_ses-1_task-60bi_acq-2.mat 01 1 60 bi F-stat 1 sub-01_ses-1_task-60mo_acq-1.mat 01 1 60 mo F-stat 2 sub-01_ses-1_task-75bi_acq-3.mat 01 1 75 bi F-stat 3 sub-01_ses-1_task-75mo_acq-4.mat 01 1 75 mo F-stat 4 sub-01_ses-2_task-60bi_acq-2.mat 01 2 60 bi F-stat 5 sub-01_ses-2_task-60mo_acq-1.mat 01 2 60 mo F-stat 6 sub-01_ses-2_task-75bi_acq-3.mat 01 2 75 bi F-stat 7 sub-01_ses-2_task-75mo_acq-4.mat 01 2 75 mo F-stat 8 sub-02_ses-1_task-60bi_acq-3.mat 02 1 60 bi F-stat 9 sub-02_ses-1_task-60mo_acq-4.mat 02 1 60 mo F-stat 10 sub-02_ses-1_task-75bi_acq-2.mat 02 1 75 bi F-stat 11 sub-02_ses-1_task-75mo_acq-1.mat 02 1 75 mo F-stat 12 sub-02_ses-2_task-60bi_acq-3.mat 02 2 60 bi F-stat 13 sub-02_ses-2_task-60mo_acq-4.mat 02 2 60 mo F-stat 14 sub-02_ses-2_task-75bi_acq-2.mat 02 2 75 bi F-stat 15 sub-02_ses-2_task-75mo_acq-1.mat 02 2 75 mo F-stat ----------------------------------------------------------------------- NDVars: F
For each row (i.e., a unique filename), there is a 2-dimensional F-statistic array with shape of (no_timepoints, no_electrodes). Here we have 64 time points and 32 electrodes.
print(fstats_data['F'])
<NDVar 'F': 16 case, 64 time, 32 sensor>
You can also recode the variable values as in the case of PRPData:
# Recode levels "pres" and "int" using a dictionary
recode_pres = {'bi': 'Binaural',
'mo': 'Monaural'}
fstats.recode('pres', recode_pres)
recode_int= {'60': '60_dB',
'75': '75_dB'}
fstats.recode('int', recode_int)
print(fstats.get_data().head())
# filename sub ses int pres stat ---------------------------------------------------------------------------- 0 sub-01_ses-1_task-60bi_acq-2.mat 01 1 60_dB Binaural F-stat 1 sub-01_ses-1_task-60mo_acq-1.mat 01 1 60_dB Monaural F-stat 2 sub-01_ses-1_task-75bi_acq-3.mat 01 1 75_dB Binaural F-stat 3 sub-01_ses-1_task-75mo_acq-4.mat 01 1 75_dB Monaural F-stat 4 sub-01_ses-2_task-60bi_acq-2.mat 01 2 60_dB Binaural F-stat 5 sub-01_ses-2_task-60mo_acq-1.mat 01 2 60_dB Monaural F-stat 6 sub-01_ses-2_task-75bi_acq-3.mat 01 2 75_dB Binaural F-stat 7 sub-01_ses-2_task-75mo_acq-4.mat 01 2 75_dB Monaural F-stat 8 sub-02_ses-1_task-60bi_acq-3.mat 02 1 60_dB Binaural F-stat 9 sub-02_ses-1_task-60mo_acq-4.mat 02 1 60_dB Monaural F-stat ---------------------------------------------------------------------------- NDVars: F
Plotting F-statistics¶
The usage of the plotting function of the F-statistic results is almost identical to that of PRP data. The only difference is that this time only one curve representing the F-value will be plotted in each panel.
# Fronto-central channels
fronto_central_chs = ['AF3', 'AF4', 'Fz', 'F3', 'F4', 'FC1', 'FC2']
# Mark these time points and their corresponding text labels
mark = {'time': [0.05, 0.12, 0.23, 0.4], 'label': ['R1', 'R2', 'R3', 'R4']}
# Plot the averaged PRP for each manner, divided by the factors in split_by
fstats.plot_f_stats(
split_by = ['pres', 'int'], # Factor(s) used to divide the figure.
title_prefix = '(FC)', # Optional, append a prefix to the title of each subplot
subset_sensors = fronto_central_chs, # Plot the average F-stat in this subset of electrodes
equal_y_scale = True, # Ensure that the y-scale is the same across subplots
se = 1, # SE around mean
lw = 3.5, # Linewidth
mark = mark, # Mark these time points
show_topo = True, # Show topo of the plotted sensors
line_colors = '#008ADC', # Line color, default is red,
fig_path = 'examples/figures/fstats_fig.svg' # Optional. Save the figure to disk by giving its path
)
Included sensors: ['AF3', 'AF4', 'Fz', 'F3', 'F4', 'FC1', 'FC2']
Additionally, you can plot the topographies of F values at different time points:
fstats.plot_f_topo(
split_by = ['pres', 'int'], # Split the figure based on combinations of the levels of these factors
times = [0.05, 0.12, 0.23], # Plot topomaps of F at these time points
vlim = (0.0, 6.0), # Lower and upper bounds of the colormap
cmap = 'Reds', # Matplitlib colormap. Default is "Reds"
fig_path = 'examples/figures/f_topo.svg'
)
Extracting F-statistics¶
We can also get the F-statistics at particular time points and electrodes using get_values(). The usage is exactly the same as the get_values() of PRPData (see Extracting PRP amplitudes in PRP basics), except this time it will return F-statistic value instead of PRP amplitudes. In the example below, we get F-statists averaged across fronto_central_chs electrodes within 20-ms time windows centered at 50 ms, 110 ms, 200 ms, and 400 ms and label them as R1, R2, R3, and R4:
fstats_df = fstats.get_values(
times = [(0.04, 0.06), (0.10, 0.12), (0.19, 0.21), (0.39, 0.41)],
subset_sensors = fronto_central_chs,
summarize_sensors = True,
summarize_time = True,
summary_func = 'mean',
output_col_labels = ['R1', 'R2', 'R3', 'R4']
)
fstats_df.head(3)
| filename | sub | ses | int | pres | stat | R1 | R2 | R3 | R4 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | sub-01_ses-1_task-60bi_acq-2.mat | 01 | 1 | 60_dB | Binaural | F-stat | 2.132361 | 0.996413 | 0.495814 | 0.475428 |
| 1 | sub-01_ses-1_task-60mo_acq-1.mat | 01 | 1 | 60_dB | Monaural | F-stat | 2.223798 | 2.390641 | 0.853467 | 0.305553 |
| 2 | sub-01_ses-1_task-75bi_acq-3.mat | 01 | 1 | 75_dB | Binaural | F-stat | 1.433542 | 0.927914 | 1.404303 | 0.299543 |
Statistical analyses¶
There are many ways to run statistical tests on manner separability or similar measures so a complete coverage is beyond the scope of this tutorial. But one quick way to test if the factors of interest have significant effects is to use a mass-univariate permutation-based approach, which allows you to identify clusters of electrodes with a significant effect and are already implemented in eelbrain. Below is an example of a mass-univariate ANOVA on our example dataset. For more details about this statistical method, see Brodbeck et al. (2023) and the eelbrain tutorial here.
import eelbrain
fstats_data = fstats.get_data()
fstats_data['F'].sensor.set_adjacency(connect_dist = 1.6) # Setting adjacency defining which electrodes are neighbors
fstats_data['sub'].random = True # Subject as a random effect
# ANOVA: F ~ ses * int * pres
fstats_anova_res = eelbrain.testnd.ANOVA(
'F',
'ses * int * pres',
data = fstats_data,
pmin = 0.05, # Use uncorrected p = 0.05 as threshold for forming clusters
tstart = 0.0,
tstop = 0.5,
samples = 10000 # Number of permutations to speed up the example; use 10'000 when the exact p-value matters
)
fstats_anova_res.find_clusters(0.05)
Permutation test: 100%|██████████| 10000/10000 [00:08<00:00, 1168.79 permutations/s]
| # | id | tstart | tstop | duration | n_sensors | v | p | sig | effect |
If there are significant clusters, find_clusters() will print out a table listing the start/end times, number of electrodes, etc. in those clusters.
In addition to mass-univariate tests, you may also extract average F-statistics as in the fstats_df example above and run standard statistical tests, if you have specific interest periods/regions.