File size: 11,636 Bytes
9b23ae9
bce4bae
 
 
 
 
 
 
9b23ae9
8643122
9b23ae9
 
bce4bae
 
9b23ae9
8643122
9b23ae9
 
bce4bae
9b23ae9
bce4bae
 
 
 
9b23ae9
 
bce4bae
9b23ae9
 
bce4bae
 
9b23ae9
 
 
 
 
 
 
 
 
 
 
 
bce4bae
 
 
 
 
 
 
 
 
 
9b23ae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce4bae
 
 
9b23ae9
 
 
 
 
 
 
 
 
bce4bae
9b23ae9
 
bce4bae
9b23ae9
bce4bae
9b23ae9
 
 
 
bce4bae
9b23ae9
 
 
bce4bae
9b23ae9
bce4bae
 
9b23ae9
 
bce4bae
9b23ae9
bce4bae
 
9b23ae9
 
bce4bae
 
9b23ae9
bce4bae
9b23ae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce4bae
9b23ae9
 
 
 
 
 
 
 
bce4bae
9b23ae9
 
 
 
bce4bae
 
9b23ae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce4bae
 
 
 
9b23ae9
bce4bae
 
 
9b23ae9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bce4bae
9b23ae9
 
 
bce4bae
9b23ae9
 
bce4bae
9b23ae9
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
"""Temporal Dynamics - Research Grade."""

import streamlit as st
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from session import init_session, log_analysis, get_carried_rois, download_csv_button, show_analysis_log
from theme import inject_theme, section_header
from utils import make_roi_indices, peak_latency, temporal_correlation, decompose_response, ROI_GROUPS, ALL_ROIS
from synthetic import generate_realistic_predictions, generate_correlated_features

st.set_page_config(page_title="Temporal Dynamics", page_icon="⏱️", layout="wide")
init_session()
inject_theme()
show_analysis_log()

st.title("⏱️ Temporal Dynamics")
st.markdown("Analyze how brain responses evolve over time, including processing hierarchy and temporal coupling with model features.")

# --- Sidebar ---
with st.sidebar:
    st.header("Configuration")
    stim_type = st.selectbox("Stimulus type", ["visual", "auditory", "language", "multimodal"],
                             index=["visual", "auditory", "language", "multimodal"].index(st.session_state.get("stimulus_type", "visual")))
    n_timepoints = st.slider("Duration (TRs)", 30, 200, 80)
    tr_seconds = st.slider("TR (seconds)", 0.5, 2.0, 1.0, 0.1)
    seed = st.number_input("Seed", value=42, min_value=0)

    st.subheader("ROI Selection")
    carried = get_carried_rois()
    use_carried = False
    if carried:
        use_carried = st.checkbox(f"Use {len(carried)} ROIs from Brain Alignment", value=True)

    if use_carried and carried:
        selected_rois = carried
        st.caption(f"Using: {', '.join(selected_rois[:5])}{'...' if len(selected_rois) > 5 else ''}")
    else:
        selected_group = st.selectbox("Region group", list(ROI_GROUPS.keys()))
        available_rois = ROI_GROUPS[selected_group]
        selected_rois = st.multiselect("ROIs to analyze", available_rois, default=available_rois[:4])

    max_lag = st.slider("Max correlation lag (TRs)", 5, 30, 15)
    cutoff = st.slider("Decomposition cutoff (seconds)", 1.0, 10.0, 4.0, 0.5)

if not selected_rois:
    st.warning("Select at least one ROI.")
    st.stop()

# --- Generate Data ---
roi_indices, n_vertices = make_roi_indices()
predictions = generate_realistic_predictions(n_timepoints, roi_indices, stim_type, tr_seconds, seed=seed)
features = generate_correlated_features(predictions, alignment_strength=0.5, feature_dim=64, seed=seed + 1)
log_analysis(f"Temporal dynamics: {stim_type}, {len(selected_rois)} ROIs")

time_axis = np.arange(n_timepoints) * tr_seconds
colors = ["#00D2FF", "#FF6B6B", "#A29BFE", "#FFEAA7", "#55EFC4", "#FD79A8", "#74B9FF", "#E17055"]

# --- Raw ROI Timecourses ---
st.subheader("Raw ROI Timecourses")
st.markdown("Mean absolute activation over time for each selected ROI. Note the hemodynamic response shape after stimulus events.")

fig_raw = go.Figure()
for i, roi in enumerate(selected_rois):
    if roi in roi_indices:
        verts = roi_indices[roi]
        valid = verts[verts < predictions.shape[1]]
        if len(valid) > 0:
            tc = np.abs(predictions[:, valid]).mean(axis=1)
            fig_raw.add_trace(go.Scatter(
                x=time_axis, y=tc, name=roi,
                line=dict(color=colors[i % len(colors)], width=2),
            ))

fig_raw.update_layout(
    xaxis_title="Time (seconds)", yaxis_title="Mean |activation|",
    height=400, template="plotly_dark",
    legend=dict(orientation="h", yanchor="bottom", y=1.02),
)
st.plotly_chart(fig_raw, use_container_width=True)

# --- Peak Latency (sorted = processing hierarchy) ---
st.divider()
st.subheader("Peak Response Latency (Processing Hierarchy)")
st.markdown("ROIs sorted by peak latency reveal the cortical processing hierarchy: early sensory areas respond first, association cortex later.")

latency_data = []
for roi in selected_rois:
    if roi in roi_indices:
        lat = peak_latency(predictions, roi_indices, roi, tr_seconds)
        # Determine functional group
        group = "Other"
        for g, rois in ROI_GROUPS.items():
            if roi in rois:
                group = g
                break
        latency_data.append({"ROI": roi, "Peak Latency (s)": lat, "Group": group})

lat_df = pd.DataFrame(latency_data).sort_values("Peak Latency (s)")
group_colors = {"Visual": "#00D2FF", "Auditory": "#FF6B6B", "Language": "#A29BFE", "Executive": "#FFEAA7", "Other": "#888"}

col1, col2 = st.columns([2, 1])
with col1:
    fig_lat = go.Figure(go.Bar(
        x=lat_df["Peak Latency (s)"], y=lat_df["ROI"],
        orientation="h",
        marker_color=[group_colors.get(g, "#888") for g in lat_df["Group"]],
    ))
    fig_lat.update_layout(
        xaxis_title="Time to peak (seconds)", height=max(250, len(selected_rois) * 30),
        template="plotly_dark", yaxis=dict(autorange="reversed"),
    )
    st.plotly_chart(fig_lat, use_container_width=True)

with col2:
    st.dataframe(lat_df[["ROI", "Peak Latency (s)", "Group"]], use_container_width=True, hide_index=True)
    download_csv_button(lat_df, "peak_latencies.csv")

# --- Lag Correlation with Significance ---
st.divider()
st.subheader("Temporal Correlation (Brain vs Model Features)")
st.markdown("Pearson correlation at different time lags. The peak indicates optimal temporal alignment. "
            "Gray band shows 95% null range from shuffled data.")

lags = np.arange(-max_lag, max_lag + 1) * tr_seconds
fig_corr = go.Figure()

# Null band (shuffle features, compute correlation envelope)
rng = np.random.default_rng(seed)
null_corrs = []
for _ in range(50):
    shuffled = features[rng.permutation(len(features))]
    for roi in selected_rois[:1]:  # Use first ROI for null band
        if roi in roi_indices:
            nc = temporal_correlation(predictions, shuffled, roi_indices, roi, max_lag)
            null_corrs.append(nc)
if null_corrs:
    null_arr = np.array(null_corrs)
    null_hi = np.percentile(null_arr, 97.5, axis=0)
    null_lo = np.percentile(null_arr, 2.5, axis=0)
    fig_corr.add_trace(go.Scatter(x=lags, y=null_hi, mode="lines", line=dict(width=0), showlegend=False))
    fig_corr.add_trace(go.Scatter(x=lags, y=null_lo, mode="lines", line=dict(width=0),
                                   fill="tonexty", fillcolor="rgba(150,150,150,0.2)",
                                   name="95% null range"))

# Actual correlations
optimal_lags = []
for i, roi in enumerate(selected_rois):
    if roi in roi_indices:
        corr = temporal_correlation(predictions, features, roi_indices, roi, max_lag)
        fig_corr.add_trace(go.Scatter(
            x=lags, y=corr, name=roi,
            line=dict(color=colors[i % len(colors)], width=2),
        ))
        opt_idx = np.argmax(np.abs(corr))
        optimal_lags.append({"ROI": roi, "Optimal Lag (s)": lags[opt_idx], "Max |r|": float(np.abs(corr[opt_idx]))})

fig_corr.add_vline(x=0, line_dash="dash", line_color="gray", opacity=0.5)
fig_corr.update_layout(
    xaxis_title="Lag (seconds)", yaxis_title="Pearson Correlation",
    height=400, template="plotly_dark",
    legend=dict(orientation="h", yanchor="bottom", y=1.02),
)
st.plotly_chart(fig_corr, use_container_width=True)

# --- Optimal Lag Summary ---
if optimal_lags:
    st.subheader("Optimal Lag Summary")
    opt_df = pd.DataFrame(optimal_lags).sort_values("Max |r|", ascending=False)
    st.dataframe(opt_df, use_container_width=True, hide_index=True)
    download_csv_button(opt_df, "optimal_lags.csv")

# --- Cross-ROI Lag Matrix ---
if len(selected_rois) >= 2:
    st.divider()
    st.subheader("Cross-ROI Lag Matrix")
    st.markdown("Optimal lag between each pair of ROIs. Positive values mean the row ROI leads the column ROI.")

    n_rois = len(selected_rois)
    lag_matrix = np.zeros((n_rois, n_rois))
    for i, roi_a in enumerate(selected_rois):
        if roi_a not in roi_indices:
            continue
        verts_a = roi_indices[roi_a]
        valid_a = verts_a[verts_a < predictions.shape[1]]
        if len(valid_a) == 0:
            continue
        tc_a = np.abs(predictions[:, valid_a]).mean(axis=1)
        for j, roi_b in enumerate(selected_rois):
            if i == j or roi_b not in roi_indices:
                continue
            verts_b = roi_indices[roi_b]
            valid_b = verts_b[verts_b < predictions.shape[1]]
            if len(valid_b) == 0:
                continue
            tc_b = np.abs(predictions[:, valid_b]).mean(axis=1)
            # Cross-correlation to find optimal lag
            corrs_ab = temporal_correlation(predictions, tc_b, roi_indices, roi_a, max_lag)
            opt_idx = np.argmax(np.abs(corrs_ab))
            lag_matrix[i, j] = lags[opt_idx]

    fig_lagmat = go.Figure(go.Heatmap(
        z=lag_matrix, x=selected_rois, y=selected_rois,
        colorscale="RdBu_r", zmid=0,
        colorbar=dict(title="Lag (s)"),
        text=np.round(lag_matrix, 1), texttemplate="%{text}",
    ))
    fig_lagmat.update_layout(height=400, template="plotly_dark")
    st.plotly_chart(fig_lagmat, use_container_width=True)

# --- Sustained vs Transient ---
st.divider()
st.subheader("Sustained vs Transient Decomposition")
st.markdown("Moving-average filter separates slow sustained responses from fast transient spikes.")

roi_for_decomp = st.selectbox("ROI for decomposition", selected_rois)
sustained, transient = decompose_response(predictions, roi_indices, roi_for_decomp, cutoff, tr_seconds)
original = sustained + transient

fig_decomp = make_subplots(rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.06,
                           subplot_titles=("Original Signal", "Sustained Component", "Transient Component"))

fig_decomp.add_trace(go.Scatter(x=time_axis, y=original, line=dict(color="#888", width=1.5)), row=1, col=1)
fig_decomp.add_trace(go.Scatter(x=time_axis, y=sustained, line=dict(color="#6C5CE7", width=2)), row=2, col=1)
fig_decomp.add_trace(go.Scatter(x=time_axis, y=transient, line=dict(color="#FF6B6B", width=1.5)), row=3, col=1)

fig_decomp.update_xaxes(title_text="Time (seconds)", row=3, col=1)
fig_decomp.update_layout(height=550, template="plotly_dark", showlegend=False)
st.plotly_chart(fig_decomp, use_container_width=True)

# --- Methodology ---
with st.expander("Methodology", expanded=False):
    st.markdown("""
**Peak Latency** is the time at which mean absolute activation reaches its maximum
within an ROI. In real fMRI, early sensory cortex (V1, A1) peaks at ~5-6s post-stimulus
due to the hemodynamic response, while association cortex (dlPFC, angular gyrus) peaks
~1-3s later reflecting higher-order processing.

**Temporal Correlation** computes Pearson correlation between the ROI timecourse and model
feature timecourse at each lag in ``[-max_lag, +max_lag]`` TRs. The lag at maximum absolute
correlation reveals the temporal offset at which model and brain are best aligned.

**Null significance band** is estimated by shuffling the model features 50 times and
computing the lag correlation each time. The 95% envelope of these null correlations
provides a significance threshold.

**Sustained vs Transient Decomposition** uses a moving-average filter with the specified
cutoff period. The sustained component captures slow, maintained responses (e.g., block
design activations), while the transient component captures fast, event-related responses.

**Cross-ROI Lag Matrix** shows the optimal temporal offset between every pair of ROIs,
revealing directional information flow (positive lag = row ROI leads column ROI).

**References**:
- Boynton et al., 1996, *J Neuroscience* (hemodynamic response function)
- Friston et al., 1998, *NeuroImage* (temporal basis functions in fMRI)
""")