4 Estimate orientation
The beta estimates resulting from GLM1 can be used to estimate the orientation of the hexadirectional signal using \[\varphi = [arctan(\beta2/\beta1)]/6\] This is illustrated in the figure below.
The orientation is typically estimated based on beta images that are averaged across voxels in a region of interest such as the entorhinal cortex. Often, a weighted average is used with the weight being the amplitude of the sinusoidal modulation.
From Doeller et al. (Nature, 2010):
“The ‘mean grid orientation’ for the region (\(\varphi\)) was defined as the population vector (vector average of grid orientations in each voxel weighted by the amplitude of sinusoidal modulation in that voxel, or \([arctan(<\beta2>/<\beta1>)]/6\) where <> denotes the mean across voxels)…”
From Kunz et al. (Science, 2015):
“Next, the \(\beta\)-values of the two parametric modulators were extracted from the right EC ROI to calculate its putative mean grid orientation in 60° space (varying between -180° and 180°): mean grid orientation \(\varphi = arctan(\beta1/\beta2)\), where \(\beta1\) = averaged beta value for \(sin[\theta(t) * 6]\) and \(\beta2\) = averaged beta value for \(cos[\theta(t) * 6]\) across voxels of the right EC ROI.”
4.1 Load PEs of parametric modulators
Based on the parameter estimates for the sine and cosine regressor we will now estimate the orientation of the n-fold symmetric signal. This estimated orientation then forms the basis for GLM2, where we look for signal modulations as a function of angular difference to the estimated orientation.
We begin by building a dataframe holding the parameter estimates (PEs) of the sine and cosine regressor for each subject, run and periodicity.
<- tibble()
pmod_df
for (i_sub in subjects){
# load the right EC mask and linearize it
<- readNIfTI2(here("data", "masks", "ROI_masks",
ec_mask_nii "ROImask_entorhinalCortex_RH.nii"))
<- c(ec_mask_nii)
ec_mask_lin
# load the combined brain mask and linearize it
<- readNIfTI2(here("data", "masks", "brain_mask", i_sub,
brain_mask_nii "comb_brain_mask_tMean100.nii.gz"))
<- as.logical(c(brain_mask_nii))
brain_mask_lin
for (i_run in runs){
for (i_period in periodicities){
# load the sine and cosine parameter estimates
# sine and cosine regressors were the second/third regressors and are thus the
# third and fifth PEs in the output because of the temporal derivatives
<- here("data", "glm1", "glm1_firsthalf", paste0(i_period, "fold"),
sin_fn "pe3.nii.gz")
i_sub, i_run, <- here("data", "glm1", "glm1_firsthalf", paste0(i_period, "fold"),
cos_fn "pe5.nii.gz")
i_sub, i_run, <- readNIfTI2(sin_fn)
sin_nii <- readNIfTI2(cos_fn)
cos_nii
<- tibble(subID = i_sub, run = i_run, periodicity = i_period,
curr_df sin = c(sin_nii)[brain_mask_lin],
cos = c(cos_nii)[brain_mask_lin],
ec = as.logical(ec_mask_lin[brain_mask_lin]),
vox_num = which(brain_mask_lin==1))
<- rbind(pmod_df, curr_df)
pmod_df
}
} }
4.2 Compute Voxelwise Orientation
We can now compute the orientation for each voxel and periodicity. The resulting dataframe has orientation values in radians and in degrees. Note that these are in a range from 2*pi/periodicity and 360/periodicity, respectively.
<- pmod_df %>% mutate(
orient_allvox orient_rad = atan2(sin, cos), # estimate orientation in radians (range -pi to pi)
orient_rad = ifelse(orient_rad<0, orient_rad+(2*pi), orient_rad), # bring to range 0 to 2*pi
orient_rad = orient_rad/periodicity, # divide by periodicity because of n-fold symmetry
orient_deg = orient_rad * 180/pi, # convert to degrees
amplitude = sqrt((c(cos)*c(cos)) + (c(sin)*c(sin))) # calculate amplitude for later voxel weighting
)
head(orient_allvox)
## # A tibble: 6 × 10
## subID run periodicity sin cos ec vox_num orient_rad orient_deg amplitude
## <chr> <chr> <dbl> <dbl> <dbl> <lgl> <int> <dbl> <dbl> <dbl>
## 1 sub01 run01 6 6.53 -2.83 FALSE 4802 0.330 18.9 7.12
## 2 sub01 run01 6 5.82 -2.89 FALSE 4803 0.339 19.4 6.50
## 3 sub01 run01 6 3.62 -2.45 FALSE 4804 0.361 20.7 4.37
## 4 sub01 run01 6 1.73 -1.73 FALSE 4805 0.393 22.5 2.45
## 5 sub01 run01 6 0.808 -1.25 FALSE 4807 0.428 24.5 1.49
## 6 sub01 run01 6 1.42 -1.16 FALSE 4808 0.376 21.5 1.83
Because we have multiple runs, we might want to estimate a voxel’s orientation based on average across runs of the sine and cosine parameter estimates.
<- orient_allvox %>%
ori_df_avg group_by(subID, periodicity, vox_num) %>%
summarize(sin = mean(sin),
cos = mean(cos),
ec = unique(ec), .groups = "drop") %>%
add_column(run = "average", .after = "subID") %>%
mutate(
orient_rad = atan2(sin, cos), # estimate orientation in radians (range -pi to pi)
orient_rad = ifelse(orient_rad<0, orient_rad+(2*pi), orient_rad), # bring to range 0 to 2*pi
orient_rad = orient_rad/periodicity, # divide by periodicity because of n-fold symmetry
orient_deg = orient_rad * 180/pi, # convert to degrees
amplitude = sqrt((c(cos)*c(cos)) + (c(sin)*c(sin)))) # calculate amplitude for later voxel weighting
<- rbind(orient_allvox, ori_df_avg) orient_allvox
4.3 Average grid orientations
To average the estimated orientations across voxels, let’s define a custom function. This is similar to the function in the GridCAT toolbox. It is written to be used with pipes, but probably this could be done more elegantly.
Usage:
- in_df is the input data frame with one row per voxel
- ori_values is the name of the column holding the voxelwise orientations
- x-fold is the periodicity of the signal you are looking at
- weighted is an option for an amplitude-weighted average of the orientation. If you want no weighted average, set it to FALSE
- in_deg is a logical for whether the orientation value columns is in degrees (TRUE) or radians (FALSE)
# custom function to average grid orientation across voxels, for usage see text above
<- function(in_df = NULL, ori_values = NULL, x_fold = "periodicity", weighted = FALSE, in_deg = TRUE){
calc_avg_ori
# Input checking (pull variables from dataframe etc., could probably be more elegant)
<- in_df %>% pull(ori_values) # returns numeric vector of orientations
ori_values
if(!is.numeric(x_fold)){ # make sure x_fold is one integer
<- in_df %>% pull(x_fold) %>% unique() # if it's a column, find the unique values
x_fold if(!length(x_fold == 1)){ # break if there is not one unique value
stop(sprintf(("Can't average over multiple periodicities! Length of x_fold is %d"),
length(x_fold)))}
}if(weighted == FALSE){weighted <- 1} # if no weighting is desired, all weights = 1
else{weighted <- in_df %>% pull(weighted)} # else get numeric vector of weights
# Transform voxelwise orientations from range [0 360/xFoldSymmetry] to [0 360] degrees or [0 2*pi] radians,
# in order to enable calculation of subsequent trigonometric functions which expect this range.
<- ori_values * x_fold
ori_values360
# Transformation from polar to cartesian cordinate system (for weighting)
<- useful::pol2cart(r = weighted, theta = ori_values360, degrees = in_deg)
cart_df
# find the mean across X and across Y separately
= mean(cart_df$x)
x_mean = mean(cart_df$y)
y_mean
# Transformation back from cartesian to polar cordinate system
<- cart2pol(x=x_mean, y=y_mean, degrees = in_deg)
out_df
# touch up the output a bit
if(in_deg){
<- out_df %>% mutate(
out_df avg_ori_deg = theta/x_fold,
avg_ori_rad = (theta*pi/180)/x_fold)
else{
} <- out_df %>% mutate(
out_df avg_ori_rad = theta/x_fold,
avg_ori_deg = (theta*180/pi)/x_fold)
}
# return the df after removing the angle in 360 deg space and the x and y coords
<- out_df %>% select(-theta, -x, -y)
out_df return(out_df)
}
4.3.1 Wholebrain average orientation
Let’s first compute the average grid orientation across all voxels.
# weighted average orientation across voxels for each participant, run and periodicity
<- orient_allvox %>%
avg_orient_weighted group_by(subID, run, periodicity) %>%
do(calc_avg_ori(in_df = ., ori_values = "orient_deg",
x_fold = "periodicity", weighted = "amplitude", in_deg = TRUE)) %>%
ungroup() %>%
mutate(weighted = "weighted")
## Warning: `data_frame()` was deprecated in tibble 1.1.0.
## Please use `tibble()` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was generated.
# unweighted average orientation across voxels for each participant, run and periodicity
<- orient_allvox %>%
avg_orient_unweighted group_by(subID, run, periodicity) %>%
do(calc_avg_ori(in_df = ., ori_values = "orient_deg",
x_fold = "periodicity", weighted = FALSE, in_deg = TRUE)) %>%
ungroup() %>%
mutate(weighted = "unweighted")
# bind the two together
<- rbind(avg_orient_weighted, avg_orient_unweighted)
avg_orient_allvox head(avg_orient_allvox)
## # A tibble: 6 × 7
## subID run periodicity r avg_ori_deg avg_ori_rad weighted
## <chr> <chr> <dbl> <dbl> <dbl> <dbl> <chr>
## 1 sub01 average 6 0.285 28.8 0.502 weighted
## 2 sub01 run01 6 0.214 26.6 0.464 weighted
## 3 sub01 run02 6 0.364 30.1 0.525 weighted
## 4 sub01 average 6 0.287 29.4 0.513 unweighted
## 5 sub01 run01 6 0.143 28.6 0.499 unweighted
## 6 sub01 run02 6 0.284 29.8 0.520 unweighted
To get an overview of the estimated orientations, let’s plot them as a circular histogram for all brain voxels, together with the weighted and unweighted angular means.
# how many bins do we want for the polar histogram?
= 60
n_bins
# add a variable with the maximum histogram count with the given number of bins
# this will be used later to determine the length of the arrow indicating the means
<- inner_join(avg_orient_allvox, orient_allvox %>%
avg_orient_allvox group_by(subID, run, periodicity) %>%
summarise(hcounts = max(hist(orient_deg, plot = FALSE,
breaks = n_bins)$counts),
.groups="drop"),
by = c("subID", "run", "periodicity"))
# make polar histogram across voxels and highlight (un)weighted mean orientations
ggplot(orient_allvox, aes(x=orient_deg)) +
coord_polar() +
scale_x_continuous(limits = c(0,60)) +
geom_freqpoly(bins = 60) +
facet_grid(subID ~ run) +
labs(title = "Estimated grid orientations [wholebrain]") +
geom_segment(data=avg_orient_allvox,
aes(x=avg_ori_deg,xend=avg_ori_deg, y=0, yend=hcounts,
group=weighted, color=weighted),
arrow=arrow(angle=10,type="closed",length=unit(0.3,"cm"))) +
labs(color = "mean orientation") +
theme_minimal() +
theme(legend.position = "bottom")
## Warning: Removed 2 row(s) containing missing values (geom_path).
4.3.2 Entorhinal Cortex average orientation
Because our first steps here will focus on the right EC (to check results against the GridCAT tutorial), let’s look at the orientations in that ROI.
# let's look at the EC voxels only
<- orient_allvox %>%
orient_ec filter(ec==TRUE)
head(orient_ec)
## # A tibble: 6 × 10
## subID run periodicity sin cos ec vox_num orient_rad orient_deg amplitude
## <chr> <chr> <dbl> <dbl> <dbl> <lgl> <int> <dbl> <dbl> <dbl>
## 1 sub01 run01 6 -0.0603 -0.448 TRUE 98647 0.546 31.3 0.453
## 2 sub01 run01 6 0.113 -0.628 TRUE 98755 0.494 28.3 0.638
## 3 sub01 run01 6 0.108 -0.440 TRUE 98756 0.483 27.7 0.453
## 4 sub01 run01 6 0.214 -0.748 TRUE 98971 0.477 27.3 0.778
## 5 sub01 run01 6 0.416 -0.841 TRUE 98972 0.447 25.6 0.938
## 6 sub01 run01 6 0.0366 -0.550 TRUE 99079 0.513 29.4 0.551
We first compute the average grid orientation across all EC voxels.
# weighted average orientation across voxels for each participant, run and periodicity
<- orient_ec %>%
avg_orient_weighted group_by(subID, run, periodicity) %>%
do(calc_avg_ori(in_df = ., ori_values = "orient_deg",
x_fold = "periodicity", weighted = "amplitude", in_deg = TRUE)) %>%
ungroup() %>%
mutate(weighted = "weighted")
# unweighted average orientation across voxels for each participant, run and periodicity
<- orient_ec %>%
avg_orient_unweighted group_by(subID, run, periodicity) %>%
do(calc_avg_ori(in_df = ., ori_values = "orient_deg",
x_fold = "periodicity", weighted = FALSE, in_deg = TRUE)) %>%
ungroup() %>%
mutate(weighted = "unweighted")
# bind the two together
<- rbind(avg_orient_weighted, avg_orient_unweighted)
avg_orient_ec head(avg_orient_ec)
## # A tibble: 6 × 7
## subID run periodicity r avg_ori_deg avg_ori_rad weighted
## <chr> <chr> <dbl> <dbl> <dbl> <dbl> <chr>
## 1 sub01 average 6 0.220 29.3 0.512 weighted
## 2 sub01 run01 6 0.331 25.3 0.442 weighted
## 3 sub01 run02 6 0.193 36.7 0.641 weighted
## 4 sub01 average 6 0.348 30.6 0.535 unweighted
## 5 sub01 run01 6 0.363 28.0 0.488 unweighted
## 6 sub01 run02 6 0.291 36.2 0.632 unweighted
To get an overview of the estimated orientations, let’s plot them as a circular histogram for all entorhinal cortex voxels, together with the weighted and unweighted angular means.
# how many bins do we want for the polar histogram?
= 60
n_bins
# add a variable with the maximum histogram count with the given number of bins
# this will be used later to determine the length of the arrow indicating the means
<- inner_join(avg_orient_ec,
avg_orient_ec %>%
orient_ec group_by(subID, run, periodicity) %>%
summarise(hcounts = max(hist(orient_deg, plot = FALSE,
breaks = n_bins)$counts),
.groups="drop"),
by = c("subID", "run", "periodicity"))
# make polar histogram across voxels and highlight (un)weighted mean orientations
ggplot(orient_ec, aes(x=orient_deg)) +
coord_polar() +
scale_x_continuous(limits = c(0,60)) +
geom_freqpoly(bins = 60) +
facet_grid(subID ~ run) +
labs(title = "Estimated grid orientations [entorhinal cortex]") +
geom_segment(data=avg_orient_ec,
aes(x=avg_ori_deg,xend=avg_ori_deg, y=0, yend=hcounts,
group=weighted, color=weighted),
arrow=arrow(angle=10,type="closed",length=unit(0.3,"cm"))) +
labs(color = "mean orientation") +
theme_minimal() +
theme(legend.position = "bottom")
## Warning: Removed 2 row(s) containing missing values (geom_path).
4.4 Brain plot of estimated orientations
The computations above have been done for each voxel in the entire brain. To see how estimated orientations are distributed throughout the brain, let’s plot the orientation for each voxel. As our brain mask is not very precise, this is not a very good plot, but serves as a proof-of-principle.
# initialize a nifti object for the orientation values
<- brain_mask_nii
orient_nii
# for the voxels in the brain mask, set the values to the orientation values based on the average across run
==1] <- orient_allvox %>%
orient_nii[brain_mask_niifilter(run=="average") %>%
pull(orient_deg)
# overlay of estimated orientation
ortho2(brain_mask_nii, orient_nii, xyz = c(46,46,15),
ycolorbar = TRUE,
col.y = oro.nifti::hotmetal(60),
ybreaks = seq(0,60,1))
4.5 Store estimated grid orientation
Calculating GLM 2 requires one reference grid orientation. This could be one separate one per run, but for now, let’s go with the orientation resulting from the average across runs. We pick the amplitude-weighte values.
<- here("data", "glm1", "glm1_firsthalf", "avg_orient_ec.txt")
fn
%>%
avg_orient_ec filter(run=="average", weighted == "weighted") %>%
select(-hcounts) %>%
write_delim(file = fn, delim = "\t", col_names = TRUE)