File size: 11,864 Bytes
9b23ae9
bce4bae
 
 
 
 
 
 
9b23ae9
8643122
bce4bae
9b23ae9
 
bce4bae
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
"""ROI Connectivity - Research Grade."""

import streamlit as st
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px

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, compute_connectivity, cluster_rois, graph_metrics,
    partial_correlation, betweenness_centrality, modularity_score,
    ROI_GROUPS,
)
from synthetic import generate_realistic_predictions

st.set_page_config(page_title="ROI Connectivity", page_icon="🔗", layout="wide")
init_session()
inject_theme()
show_analysis_log()

st.title("🔗 ROI Connectivity Analysis")
st.markdown("Functional connectivity between brain regions: correlation structure, network organization, and graph topology.")

# --- Sidebar ---
with st.sidebar:
    st.header("Configuration")
    stim_type = st.selectbox("Stimulus type", ["visual", "auditory", "language", "multimodal"])
    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("Analysis Parameters")
    n_clusters = st.slider("Number of clusters", 2, 8, 4)
    threshold = st.slider("Edge threshold", 0.1, 0.8, 0.3, 0.05)
    use_partial = st.checkbox("Use partial correlation", value=False,
                              help="Control for shared mean signal across all ROIs")

    carried = get_carried_rois()
    use_carried = False
    if carried:
        use_carried = st.checkbox(f"Filter to {len(carried)} carried ROIs", value=False)

# --- Generate Data ---
roi_indices, n_vertices = make_roi_indices()
predictions = generate_realistic_predictions(n_timepoints, roi_indices, stim_type, tr_seconds, seed=seed)
log_analysis(f"Connectivity: {stim_type}, partial={use_partial}")

# Filter ROIs if carrying from alignment
active_indices = roi_indices
if use_carried and carried:
    active_indices = {k: v for k, v in roi_indices.items() if k in carried}

# --- Compute Connectivity ---
if use_partial:
    corr_matrix, roi_names = partial_correlation(predictions, active_indices)
    corr_label = "Partial Correlation"
else:
    corr_matrix, roi_names = compute_connectivity(predictions, active_indices)
    corr_label = "Pearson Correlation"

n_rois = len(roi_names)

# --- Correlation Matrix with Cluster Boundaries ---
st.subheader(f"{corr_label} Matrix")

clusters, labels = cluster_rois(corr_matrix, roi_names, n_clusters)

# Sort ROIs by cluster for block-diagonal structure
sorted_idx = np.argsort(labels)
sorted_corr = corr_matrix[np.ix_(sorted_idx, sorted_idx)]
sorted_names = [roi_names[i] for i in sorted_idx]
sorted_labels = labels[sorted_idx]

fig_corr = go.Figure(go.Heatmap(
    z=sorted_corr, x=sorted_names, y=sorted_names,
    colorscale="RdBu_r", zmid=0, zmin=-1, zmax=1,
    colorbar=dict(title="r"),
))

# Add cluster boundary lines
boundaries = []
for i in range(1, len(sorted_labels)):
    if sorted_labels[i] != sorted_labels[i - 1]:
        boundaries.append(i - 0.5)

for b in boundaries:
    fig_corr.add_shape(type="line", x0=b, x1=b, y0=-0.5, y1=n_rois - 0.5,
                       line=dict(color="white", width=1.5, dash="dot"))
    fig_corr.add_shape(type="line", x0=-0.5, x1=n_rois - 0.5, y0=b, y1=b,
                       line=dict(color="white", width=1.5, dash="dot"))

fig_corr.update_layout(
    height=550, template="plotly_dark",
    xaxis=dict(tickangle=45, tickfont=dict(size=8)),
    yaxis=dict(tickfont=dict(size=8)),
)
st.plotly_chart(fig_corr, use_container_width=True)
st.caption(f"White dotted lines indicate cluster boundaries ({n_clusters} clusters)")

# --- Dendrogram ---
st.divider()
col_dendro, col_clusters = st.columns([1, 1])

with col_dendro:
    st.subheader("Hierarchical Clustering Dendrogram")
    from scipy.cluster.hierarchy import linkage, dendrogram
    import matplotlib.pyplot as plt
    import matplotlib
    matplotlib.use("Agg")

    dist = 1.0 - np.abs(corr_matrix)
    np.fill_diagonal(dist, 0.0)
    condensed = [dist[i, j] for i in range(n_rois) for j in range(i + 1, n_rois)]
    Z = linkage(condensed, method="average")

    fig_dendro, ax = plt.subplots(figsize=(8, 4))
    ax.set_facecolor("#0E1117")
    fig_dendro.patch.set_facecolor("#0E1117")
    dendrogram(Z, labels=roi_names, leaf_rotation=90, leaf_font_size=7, ax=ax,
               color_threshold=Z[-n_clusters + 1, 2] if n_clusters < n_rois else 0)
    ax.tick_params(colors="white")
    ax.set_ylabel("Distance (1 - |r|)", color="white")
    for spine in ax.spines.values():
        spine.set_color("white")
    st.pyplot(fig_dendro)
    plt.close()

with col_clusters:
    st.subheader("Network Clusters")
    mod_q = modularity_score(corr_matrix, labels)
    st.metric("Modularity (Q)", f"{mod_q:.3f}",
              help="Newman's modularity. Higher = stronger community structure. Q > 0.3 is typically considered meaningful.")

    for cid in sorted(clusters.keys()):
        rois = clusters[cid]
        # Identify dominant functional group
        group_counts = {}
        for roi in rois:
            for g, g_rois in ROI_GROUPS.items():
                if roi in g_rois:
                    group_counts[g] = group_counts.get(g, 0) + 1
        dominant = max(group_counts, key=group_counts.get) if group_counts else "Mixed"
        st.markdown(f"**Network {cid}** ({dominant}): {', '.join(rois)}")

# --- Centrality Comparison ---
st.divider()
st.subheader("Centrality Analysis")

col_deg, col_btw = st.columns(2)

degrees = graph_metrics(corr_matrix, roi_names, threshold)
btw = betweenness_centrality(corr_matrix, roi_names, threshold)

with col_deg:
    st.markdown("**Degree Centrality** - fraction of ROIs connected to each node")
    deg_df = pd.DataFrame(sorted(degrees.items(), key=lambda x: x[1], reverse=True), columns=["ROI", "Degree"])
    fig_deg = go.Figure(go.Bar(x=deg_df["Degree"], y=deg_df["ROI"], orientation="h", marker_color="#6C5CE7"))
    fig_deg.update_layout(xaxis_range=[0, 1], height=max(300, n_rois * 20), template="plotly_dark",
                          yaxis=dict(autorange="reversed", tickfont=dict(size=9)))
    st.plotly_chart(fig_deg, use_container_width=True)

with col_btw:
    st.markdown("**Betweenness Centrality** - how often a node lies on shortest paths between others")
    btw_df = pd.DataFrame(sorted(btw.items(), key=lambda x: x[1], reverse=True), columns=["ROI", "Betweenness"])
    fig_btw = go.Figure(go.Bar(x=btw_df["Betweenness"], y=btw_df["ROI"], orientation="h", marker_color="#FF6B6B"))
    fig_btw.update_layout(height=max(300, n_rois * 20), template="plotly_dark",
                          yaxis=dict(autorange="reversed", tickfont=dict(size=9)))
    st.plotly_chart(fig_btw, use_container_width=True)

# Combined table
centrality_df = pd.merge(deg_df, btw_df, on="ROI")
download_csv_button(centrality_df, "centrality_metrics.csv")

# --- Edge Weight Distribution ---
st.divider()
col_dist, col_graph = st.columns([1, 2])

with col_dist:
    st.subheader("Edge Weight Distribution")
    upper_tri = corr_matrix[np.triu_indices(n_rois, k=1)]
    fig_hist = go.Figure(go.Histogram(x=upper_tri, nbinsx=40, marker_color="rgba(108,92,231,0.7)"))
    fig_hist.add_vline(x=threshold, line_color="red", line_dash="dash", annotation_text="Threshold")
    fig_hist.add_vline(x=-threshold, line_color="red", line_dash="dash")
    fig_hist.update_layout(
        xaxis_title="Correlation", yaxis_title="Count",
        height=350, template="plotly_dark",
    )
    st.plotly_chart(fig_hist, use_container_width=True)
    n_edges = np.sum(np.abs(upper_tri) > threshold)
    max_edges = n_rois * (n_rois - 1) // 2
    st.caption(f"{n_edges}/{max_edges} edges above threshold ({100 * n_edges / max(max_edges, 1):.1f}% density)")

# --- Network Graph ---
with col_graph:
    st.subheader("Network Graph")
    try:
        import networkx as nx

        G = nx.Graph()
        for name in roi_names:
            G.add_node(name)
        for i in range(n_rois):
            for j in range(i + 1, n_rois):
                w = abs(corr_matrix[i, j])
                if w > threshold:
                    G.add_edge(roi_names[i], roi_names[j], weight=w)

        pos = nx.spring_layout(G, seed=seed, k=2.5)
        color_map = px.colors.qualitative.Set2
        node_colors = [color_map[(labels[i] - 1) % len(color_map)] for i in range(n_rois)]

        # Edges with width proportional to weight
        for u, v, data in G.edges(data=True):
            x0, y0 = pos[u]
            x1, y1 = pos[v]
            fig_graph = go.Figure() if not hasattr(st, '_graph_fig') else st._graph_fig

        fig_net = go.Figure()
        for u, v, d in G.edges(data=True):
            x0, y0 = pos[u]
            x1, y1 = pos[v]
            w = d.get("weight", 0.3)
            fig_net.add_trace(go.Scatter(
                x=[x0, x1, None], y=[y0, y1, None], mode="lines",
                line=dict(width=w * 3, color=f"rgba(150,150,150,{min(w, 0.8)})"),
                hoverinfo="none", showlegend=False,
            ))

        # Node sizes by degree
        max_deg = max(degrees.values()) if degrees else 1
        node_sizes = [8 + 20 * degrees.get(name, 0) / max(max_deg, 0.01) for name in roi_names]
        node_x = [pos[n][0] for n in roi_names]
        node_y = [pos[n][1] for n in roi_names]

        fig_net.add_trace(go.Scatter(
            x=node_x, y=node_y, mode="markers+text",
            marker=dict(size=node_sizes, color=node_colors, line=dict(width=1, color="white")),
            text=roi_names, textposition="top center", textfont=dict(size=7, color="white"),
            hovertext=[f"{name}<br>Degree: {degrees.get(name, 0):.2f}<br>Betweenness: {btw.get(name, 0):.3f}" for name in roi_names],
            hoverinfo="text", showlegend=False,
        ))

        fig_net.update_layout(
            height=450, template="plotly_dark",
            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
        )
        st.plotly_chart(fig_net, use_container_width=True)
    except ImportError:
        st.info("Install `networkx` for graph visualization: `pip install networkx`")

# --- Methodology ---
with st.expander("Methodology", expanded=False):
    st.markdown("""
**Functional Connectivity** is computed as pairwise Pearson correlation between ROI
timecourses (mean activation across vertices within each ROI).

**Partial Correlation** controls for the shared mean signal by computing the precision
matrix (inverse covariance) and normalizing. This removes indirect correlations mediated
by a common driver.

**Hierarchical Clustering** uses agglomerative clustering with average linkage on a
distance matrix defined as ``1 - |correlation|``. The dendrogram shows the hierarchical
merging of ROIs into networks.

**Modularity (Q)** quantifies how strongly the network divides into communities compared
to a random network with the same degree distribution. Q > 0.3 typically indicates
meaningful community structure. (Newman, 2006, *PNAS*)

**Degree Centrality** is the fraction of other nodes each node is connected to (above
the correlation threshold). High degree = hub region.

**Betweenness Centrality** counts how often a node lies on the shortest path between
other node pairs. High betweenness = bridge between communities.

**Edge Weight Distribution** shows the histogram of all pairwise correlations. The
threshold (red line) determines which connections are retained for graph analysis.

**References**:
- Rubinov & Sporns, 2010, *NeuroImage* (graph metrics for brain networks)
- Newman, 2006, *PNAS* (modularity in networks)
- Smith et al., 2011, *NeuroImage* (partial correlation for fMRI connectivity)
""")