Source
Pipeline
Each study is one module. The page you just read is regenerated from that file — figures, tables, and the written results section included.
Study S00
Data provenance, quality control and session mapping
Recovering session boundaries from concatenated recordings and validating them against behavior.
1"""S00 — Data provenance, quality control and session mapping.""" 2 3from __future__ import annotations 4 5import math 6 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10from scipy import stats 11 12from ..behavior import clock_shift_s 13from ..data import CONDITION_COLORS 14from ..stats import fmt_p, perm_p 15from .base import Study 16 17 18def _eta2(x: np.ndarray, lab: np.ndarray) -> float: 19 keep = lab != "none" 20 x, lab = x[keep], lab[keep] 21 if len(lab) < 30 or len(np.unique(lab)) < 2: 22 return np.nan 23 mu = x.mean(0) 24 tot = ((x - mu) ** 2).sum(0) 25 btw = np.zeros(x.shape[1]) 26 for u in np.unique(lab): 27 m = lab == u 28 btw += m.sum() * (x[m].mean(0) - mu) ** 2 29 return float(np.mean(btw / np.maximum(tot, 1e-12))) 30 31 32class DataQC(Study): 33 id = "s00" 34 number = 0 35 title = "Data provenance, quality control and session mapping" 36 short = "Recovering session boundaries from concatenated recordings and validating them against behavior." 37 question = ("How are the calcium recordings organised in time, and can each imaging block be assigned to its " 38 "behavioral session with evidence rather than assumption?") 39 background = """ 40**Acquisition and preprocessing (as reported by the lab).** Six male C57BL/6N mice (6–7 weeks at arrival) were 41imaged in prelimbic mPFC (AP +1.80 mm, ML +0.35 mm, DV −2.30 mm, no angle) with a Doric 2-color fluorescence 42miniscope (0.5 mm lens SICL_D_500_80) and AAV2/9-Syn-GCaMP6f. Frame rate is 6.666 frames/s. Imaging days are 43CSDS days 1, 2, 3, 5, 7 and 9, plus day 11. Each defeat day is a 15-min recording split into baseline (BL), 44social defeat (SD) and sensory stress (SS). Epoch length is nominally 2,000 frames and varies from 1,998 to 452,001. Raw frames (630 × 630 px) had black frames replaced by the mean of neighbouring frames and were 46spatially downsampled by a factor of 3 (210 × 210 px). Slow baseline drift in mean frame intensity was removed 47with a rubberband (convex-hull) baseline fitted by piecewise-linear and smoothing splines. Minian could not 48reliably detect neurons in the defeat data, so ROIs were drawn manually on a maximum projection after epochs 49were merged within each day and fields of view were checked across days, then aligned by hand across days: 50848 neurons in total. ΔF/F was computed on the full concatenated recording (F₀ = 10th percentile in each 12-s 51window; the memoir approximated the frame rate as 7 Hz, about 84 frames). CASCADE converted each ΔF/F sample 52to a spike probability (Rupprecht et al. 2021; model and noise level not recorded). ANY-maze triggered the 53miniscope, so behavior and calcium start together. Annotation files of about 305 s (155 s for each SIT half) 54use a clock that is 5 s ahead of calcium time. 55 56**Why a QC study.** The exported `TS_{id}.xlsx` workbooks hold one long neuron × frame matrix per animal with no 57session markers. Every downstream contrast depends on assigning each ~2,000-frame block to the right session. 58The lab's description fixes the order within a day, and this study verifies it from the data. Day 11 59(BL → SIT → POST) is in the workbooks; the SIT block is two halves of about 1,000 frames (empty enclosure, 60then CD-1) and was not used in the previous article. 61 62**Day 7 and incomplete epochs.** Day 7 was not recorded for mouse 6597, and sensory stress on day 9 was not 63included for that mouse. Mouse 6669 has no SIT imaging block. Day-7 data exist for the other five mice. 64Primary analyses still omit day 7 so that every animal is compared on the same days. 65""" 66 methods = """ 67**Data format.** Each workbook holds two signal families, `dff_mc` (motion-corrected ΔF/F) and 68`predictions_dff_mc` (inferred spike probability). Rows are neurons (row 0 is the frame index) and columns are 69frames; time is split into `part1/2/3` sheets only because Excel limits a sheet to 16,384 columns. Parts were 70concatenated in order. The first and last 33 frames of each recording are constant. CASCADE was used only to 71write the spike-probability columns, so those samples are excluded as unusable frames rather than as 72documented CASCADE padding. 73 74**Block structure.** Behavioral sessions last ≈300 s. Each SIT half lasts ≈150 s of calcium time inside one 75block (annotation files read ≈155 s because of the 5 s clock). We tested whether the 76recording is composed of fixed 2,000-frame blocks (300 s at 6.666 Hz). The lab reports that epoch length can 77be 1,998–2,001 frames, so a boundary can sit a few frames off this grid. For every candidate boundary 78*t* = 2,000·*k*, a boundary score was computed as the Euclidean distance between the population mean ΔF/F 79vectors in the 30 frames before and after *t*. This was compared with the same score at 400 random time 80points per animal, and each boundary is reported as a percentile of that null distribution. True session 81boundaries should sit in the upper tail, because focal plane, baseline fluorescence and behavioral context 82change between sessions. This test uses the nominal grid, so it is not biased by the correction below. 83 84**Epoch-length correction.** Around each nominal cut *k* (frame 2,000·*k*), the step score (20 frames each 85side) was computed at every frame within ±15 frames. A step was *reliable* if it exceeded the 95th percentile 86of step scores at 400 random times and did not sit at the edge of the search window. Because offsets 87accumulate from frame 0, offset_k = k·δ, where δ is the mean deviation of epoch length from 2,000 frames. δ was 88estimated per animal as the median of offset_k / k over reliable cuts (≥ 4 required; otherwise δ = 0), limited 89to the lab's reported 1,998–2,001-frame range. All analyses cut epoch *k* at round(k·(2,000 + δ)). One 90parameter per animal is estimated, so individual noisy detections do not move individual boundaries. 91 92**Session order hypothesis.** The protocol implies, for each of days 1–9, BL → SD → SS. On day 11 it implies 93BL → SIT (empty enclosure, then CD1 target, ≈1,000 frames each) → POST. The lab identified the missing epoch 94for the two short recordings (6597: SS on day 9; 6669: SIT). Every single-slot deletion is still scored below 95as a check on that assignment. 96 97**Behavior-encoding alignment test.** If block *b* truly corresponds to session *s*, neural activity in *b* 98should be better explained by the ethogram of *s* than by the ethogram of any other session. For every 99(block, session) pair we: 1001. Binned inferred spikes in 1-s bins and z-scored each neuron. 1012. Assigned each bin the annotated behavior of the candidate session. 1023. Computed η², the fraction of each neuron's variance explained by behavior labels, averaged over neurons. 1034. Expressed η² as a z-score against 40 circular shifts of the label sequence, which preserves bout structure 104 and destroys alignment. 105 106The mean z on the hypothesised diagonal was compared with 5,000 random block→session permutations, per animal 107and pooled across animals with complete mappings. For animals with one block too few, the mean diagonal z was 108computed for each possible missing slot, giving a likelihood ranking of candidate mappings. 109 110**Frame-to-video timing.** Calcium and behavior start together. Frame *i* of a block maps to annotation time 111*i*/6.666 s, plus 5 s when that file's duration is ≈305 s or ≈155 s. 112""" 113 limitations = """ 114* The drift model assumes a constant mean epoch length per animal. Individual epochs vary by 1–2 frames 115 (≈ 0.2 s) around it, which the model does not recover. 116* ΔF/F baselines were estimated on the concatenated recording, so the 12-s F₀ window can cross an epoch boundary. 117* The 33 constant frames at each end of a workbook are excluded. Their origin is not documented. 118* CASCADE model and noise level were not recorded. Spike probability is paired one-to-one with ΔF/F. 119* Mouse 6597 has no day-9 sensory-stress block, and mouse 6669 has no SIT imaging block. 120""" 121 references = [ 122 "Rupprecht P. et al. (2021) A database and deep learning toolbox for noise-optimized, generalized spike inference from calcium imaging. Nat Neurosci 24:1324–1337.", 123 "Dong Z. et al. (2022) Minian, an open-source miniscope analysis pipeline. eLife 11:e70661.", 124 "Optimal choice of baseline correction for multivariate calibration of spectra (rubberband baseline method; cited by the lab).", 125 "Golden S.A. et al. (2011) A standardized protocol for repeated social defeat stress in mice. Nat Protoc 6:1183–1191.", 126 ] 127 128 def run(self) -> dict: 129 ds = self.ds 130 rng = np.random.default_rng(0) 131 fs, block = ds.fs, ds.block 132 133 summary, boundary_rows = [], [] 134 for sid in ds.subjects: 135 d = ds.neural(sid, "dff") 136 total = d.shape[1] 137 n_blocks = math.ceil(total / block) 138 sess = ds.all_sessions[ds.all_sessions.subject == sid] 139 summary.append({"subject": sid, "neurons": d.shape[0], "frames": total, 140 "minutes": round(total / fs / 60, 1), "blocks": n_blocks, 141 "protocol_slots": len(ds._slots(sid)), "sessions_mapped": len(sess), 142 "mapping": sess.mapping.iloc[0]}) 143 score = lambda t, d=d: np.linalg.norm(d[:, t - 30:t].mean(1) - d[:, t:t + 30].mean(1)) 144 rand = np.array([score(t) for t in rng.integers(100, total - 100, 400)]) 145 for t in range(block, total - 50, block): 146 boundary_rows.append({"subject": sid, "frame": t, "percentile": float((rand < score(t)).mean())}) 147 summary = pd.DataFrame(summary) 148 bnd = pd.DataFrame(boundary_rows) 149 frac90 = float((bnd.percentile > 0.9).mean()) 150 binom_p = float(stats.binomtest(int((bnd.percentile > 0.9).sum()), len(bnd), 0.1, alternative="greater").pvalue) 151 152 # Behavior-encoding alignment matrices 153 align, drop_rows, perm_rows = {}, [], [] 154 for sid in ds.subjects: 155 p = ds.neural(sid, "spikes") 156 total = p.shape[1] 157 n_blocks = math.ceil(total / block) 158 bounds = ds.epoch_boundaries(sid) 159 slots = ds._slots(sid) 160 labels = {} 161 for cond, day in slots: 162 labels[(cond, day)] = self._slot_labels(sid, cond, day, n_bins=300) 163 m = np.full((n_blocks, len(slots)), np.nan) 164 for b in range(n_blocks): 165 seg = p[:, bounds[b]:bounds[b + 1]] 166 n_bins = int(seg.shape[1] / fs) 167 edges = (np.arange(n_bins + 1) * fs).astype(int) 168 x = np.stack([seg[:, edges[i]:edges[i + 1]].mean(1) for i in range(n_bins)]) 169 x = (x - x.mean(0)) / (x.std(0) + 1e-9) 170 for j, sl in enumerate(slots): 171 lab = labels[sl][: len(x)] 172 e = _eta2(x, lab) 173 if np.isnan(e): 174 continue 175 null = [_eta2(x, np.roll(lab, rng.integers(20, len(lab) - 20))) for _ in range(40)] 176 m[b, j] = (e - np.nanmean(null)) / (np.nanstd(null) + 1e-12) 177 align[sid] = (m, slots) 178 if n_blocks == len(slots): 179 diag = np.nanmean(np.diag(m)) 180 null = [np.nanmean(m[np.arange(n_blocks), rng.permutation(n_blocks)]) for _ in range(5000)] 181 perm_rows.append({"subject": sid, "diagonal_z": diag, "shuffled_z_mean": float(np.mean(null)), 182 "p_perm": perm_p(diag, np.array(null))}) 183 else: 184 for j, sl in enumerate(slots): 185 keep = [k for k in range(len(slots)) if k != j] 186 drop_rows.append({"subject": sid, "missing_slot": f"{sl[0]}{sl[1]}", 187 "diagonal_z": float(np.nanmean([m[b, keep[b]] for b in range(n_blocks)]))}) 188 perm_df = pd.DataFrame(perm_rows) 189 drop_df = pd.DataFrame(drop_rows) 190 complete = [sid for sid in ds.subjects if align[sid][0].shape[0] == len(align[sid][1])] 191 obs = np.mean([np.nanmean(np.diag(align[s][0])) for s in complete]) 192 null = [np.mean([np.nanmean(align[s][0][np.arange(len(align[s][1])), rng.permutation(len(align[s][1]))]) 193 for s in complete]) for _ in range(5000)] 194 pooled_p = perm_p(obs, np.array(null)) 195 196 bnd_rows, cut_rows = [], [] 197 for sid in ds.subjects: 198 info = ds.boundary_info[sid] 199 rel = [o for o in info["offsets"] if o["reliable"]] 200 n_cuts = info["n_cuts"] 201 bnd_rows.append({"subject": sid, "cuts": n_cuts, "reliable_cuts": info["n_reliable"], 202 "epoch_frames": round(info["epoch_frames"], 3), 203 "max_shift_vs_fixed_grid": int(round(n_cuts * info["delta_frames"])), 204 "median_abs_residual": float(np.median([abs(o["offset"] - o["cut"] * info["delta_frames"]) 205 for o in rel])) if rel else np.nan, 206 "last_epoch_frames": int(ds.neural(sid).shape[1] - round(n_cuts * info["epoch_frames"]))}) 207 cut_rows += [{"subject": sid, **o, "predicted_offset": round(o["cut"] * info["delta_frames"], 2)} 208 for o in info["offsets"]] 209 bnd_df, cut_df = pd.DataFrame(bnd_rows), pd.DataFrame(cut_rows) 210 211 # Figures 212 self._fig_population(bnd) 213 self._fig_cuts(cut_df, bnd_df) 214 self._fig_alignment(align, complete) 215 if not drop_df.empty: 216 self._fig_drop(drop_df) 217 self._fig_raster() 218 219 sessions = ds.all_sessions[["subject", "condition", "day", "block", "start", "stop", "n_frames", "obs_id", "mapping", "included"]] 220 self.table(summary, "dataset_summary", "Recording summary per animal", 221 "Neurons, frames and the number of 2,000-frame blocks versus protocol slots.") 222 self.table(bnd_df, "epoch_lengths", "Estimated epoch length per animal", 223 "Per-epoch length from the drift of reliable boundary steps away from the 2,000-frame grid; " 224 "max shift = correction applied at the last cut.") 225 self.table(cut_df, "boundary_offsets", "Detected boundary offsets", 226 "Offset (frames) of the largest population ΔF/F step within ±15 frames of each nominal cut.", show=20) 227 self.table(perm_df, "alignment_permutation", "Behavior-encoding alignment (complete mappings)", 228 "Mean z of the hypothesised block→session diagonal vs 5,000 random assignments.") 229 if not drop_df.empty: 230 self.table(drop_df.sort_values(["subject", "diagonal_z"], ascending=[True, False]), "missing_slot_ranking", 231 "Candidate missing sessions (animals with one block too few)", 232 "Higher diagonal z = better behavioral fit of the remaining mapping.", show=12) 233 self.table(sessions, "session_map", "Session map used by all studies", 234 "Frame ranges (in the concatenated recording) for every session.", show=30) 235 236 n_neurons = int(summary.neurons.sum()) 237 ranked = perm_df.sort_values("diagonal_z", ascending=False) 238 strong = ", ".join(str(s) for s in ranked[ranked.p_perm < 0.05].subject) or "none individually" 239 weak = ", ".join(str(s) for s in ranked[ranked.p_perm >= 0.05].subject) or "none" 240 late = [] 241 for sid, d in drop_df.groupby("subject") if not drop_df.empty else []: 242 top = d.sort_values("diagonal_z", ascending=False).head(4) 243 late.append(f"{sid}: " + ", ".join(f"{r.missing_slot} ({r.diagonal_z:.2f})" for r in top.itertuples())) 244 self.highlight("Cohort", f"{len(ds.subjects)} mice · {n_neurons} neurons", 245 f"{summary.neurons.min()}–{summary.neurons.max()} neurons per animal tracked across " 246 f"{int(ds.all_sessions.shape[0])} mapped sessions ({int(ds.sessions.shape[0])} analysed after excluding day 7).", "info") 247 self.highlight("Epoch length", f"{bnd_df.epoch_frames.min():.2f}–{bnd_df.epoch_frames.max():.2f} frames", 248 f"Estimated from {int(bnd_df.reliable_cuts.sum())} reliable boundary steps. Correcting the fixed " 249 f"2,000-frame grid shifts late cuts by up to {int(bnd_df.max_shift_vs_fixed_grid.max())} frames " 250 f"({bnd_df.max_shift_vs_fixed_grid.max() / fs:.1f} s).", "info") 251 self.highlight("Session boundaries", f"{frac90:.0%} of boundaries > 90th pct", 252 f"Population activity changes abruptly every 2,000 frames (binomial vs 10% chance, {fmt_p(binom_p)}).", 253 "significant" if binom_p < 0.05 else "null") 254 self.highlight("Session order validated" if pooled_p < 0.05 else "Session order not confirmed", f"pooled {fmt_p(pooled_p)}", 255 f"Neural blocks are better explained by their own session's ethogram than by shuffled assignments " 256 f"(mean diagonal z = {obs:.2f}, {len(complete)} animals).", 257 "significant" if pooled_p < 0.05 else "null") 258 missing = {int(k): v for k, v in (ds.cfg.get("session_mapping", {}).get("missing_slots") or {}).items()} 259 if not drop_df.empty: 260 named = ", ".join(f"{sid} {slot}" for sid, slot in missing.items()) 261 self.highlight("Missing epochs", named, 262 "Identified by the lab and omitted from the session map. Figure 4 ranks every other " 263 "single-slot deletion as a check.", "info") 264 265 results = f""" 266**Recording structure.** The {len(ds.subjects)} animals contributed {n_neurons} neurons in total 267({summary.neurons.min()}–{summary.neurons.max()} per animal), each tracked continuously for 268{summary.minutes.min()}–{summary.minutes.max()} min of imaging. Recordings split into ≈2,000-frame blocks. 269{frac90:.0%} of nominal block edges exceeded the 90th percentile of random-time boundary scores (expected 10%; 270binomial {fmt_p(binom_p)}). Figure 1 shows the abrupt population-level steps at block edges. 271 272**Epoch length.** Reliable boundary steps ({int(bnd_df.reliable_cuts.sum())} of {int(bnd_df.cuts.sum())} cuts) sat 273progressively later than the 2,000-frame grid, which means epochs are slightly longer than 2,000 frames. The 274estimated length was {bnd_df.epoch_frames.min():.2f}–{bnd_df.epoch_frames.max():.2f} frames per animal, inside the lab's 275reported 1,998–2,001 range (Table 2, Figure 2). The median absolute residual of detected cuts around the drift 276model was {np.nanmedian(bnd_df.median_abs_residual):.1f} frames. Using the corrected cuts moves the last session 277boundary by up to {int(bnd_df.max_shift_vs_fixed_grid.max())} frames ({bnd_df.max_shift_vs_fixed_grid.max() / fs:.1f} s), which would 278otherwise misalign late sessions with their behavior annotations. The final epoch of every recording is short 279({bnd_df.last_epoch_frames.min()}–{bnd_df.last_epoch_frames.max()} frames), consistent with a truncated last epoch. 280 281**Session order.** Under the protocol order (BL → SD → SS on days 1–9; BL → SIT → POST on day 11), the 282behavior-encoding alignment z on the hypothesised diagonal averaged {obs:.2f} across the {len(complete)} animals 283with complete mappings, versus {np.mean(null):.2f} for 5,000 random block assignments (pooled permutation 284{fmt_p(pooled_p)}; per-animal values in Table 4, Figure 3). Individually significant animals: {strong}. Not 285individually significant: {weak}. 286 287**Incomplete recordings.** The lab identified the missing epochs: mouse 6597 has no day-7 recording and no 288sensory-stress block on day 9; mouse 6669 has no SIT imaging block. Alignment rankings of single-slot 289deletions were {'; '.join(late) if late else 'n/a'}. Deleting an early slot fits clearly worse (Figure 4). 290The map uses the lab's assignment, not the top-scoring candidate. 291""" 292 return {"results": results, "parameters": {"block_frames": block, "fs_hz": fs, "boundary_window": 30, 293 "alignment_bin_s": 1.0, "alignment_null_shifts": 40, 294 "permutations": 5000}} 295 296 def _slot_labels(self, sid: int, cond: str, day: int, n_bins: int) -> np.ndarray: 297 ds = self.ds 298 lab = np.full(n_bins, "none", dtype=object) 299 def paint(obs, offset): 300 ev = ds.behavior[ds.behavior["Observation id"] == obs] 301 if ev.empty: 302 return 303 shift = clock_shift_s(float(ev["Total length"].iloc[0]), float(ev["Start (s)"].min())) 304 for a, b, beh in zip(ev["Start (s)"], ev["Stop (s)"], ev["Behavior"]): 305 i0, i1 = int(np.floor(a - shift + offset)), int(np.ceil(b - shift + offset)) 306 lab[max(i0, 0):min(i1, n_bins)] = beh 307 if cond == "SIT": 308 no = ds._obs_id(sid, "SIT", day, "NoCD1") 309 paint(no, 0.0) 310 ev_no = ds.behavior[ds.behavior["Observation id"] == no] 311 length = float(ev_no["Total length"].iloc[0]) - clock_shift_s( 312 float(ev_no["Total length"].iloc[0]), float(ev_no["Start (s)"].min())) 313 cd1 = np.full(n_bins, "none", dtype=object) 314 saved = lab.copy() 315 lab[:] = "none" 316 paint(ds._obs_id(sid, "SIT", day, "CD1"), length) 317 cd1[:] = lab 318 lab[:] = saved 319 lab[int(length):] = cd1[int(length):] 320 else: 321 obs = ds._obs_id(sid, cond, day) 322 if obs: 323 paint(obs, 0.0) 324 return lab 325 326 def _fig_population(self, bnd: pd.DataFrame) -> None: 327 ds = self.ds 328 fig, axes = plt.subplots(len(ds.subjects), 1, figsize=(11, 1.6 * len(ds.subjects)), sharex=False) 329 for ax, sid in zip(axes, ds.subjects): 330 d = ds.neural(sid, "dff") 331 m = np.convolve(d.mean(0), np.ones(25) / 25, "same") 332 t = np.arange(len(m)) / ds.fs / 60 333 for r in ds.all_sessions[ds.all_sessions.subject == sid].itertuples(): 334 ax.axvspan(r.start / ds.fs / 60, r.stop / ds.fs / 60, color=CONDITION_COLORS[r.condition], alpha=0.18, lw=0) 335 ax.plot(t, m, color="k", lw=0.4) 336 ax.set_ylabel(f"{sid}\nΔF/F", fontsize=8) 337 ax.set_xlim(0, t[-1]) 338 axes[-1].set_xlabel("Imaging time (min, concatenated)") 339 handles = [plt.Rectangle((0, 0), 1, 1, color=CONDITION_COLORS[c], alpha=0.4) for c in CONDITION_COLORS] 340 axes[0].legend(handles, list(CONDITION_COLORS), ncol=6, fontsize=7, loc="upper center", bbox_to_anchor=(0.5, 1.6)) 341 self.figure(fig, "population_blocks", "Population activity and recovered sessions", 342 "Population-mean ΔF/F (25-frame smoothing) for each animal across the full concatenated recording. " 343 "Shading marks the recovered sessions, coloured by condition. Steps in baseline fluorescence coincide " 344 "with 2,000-frame block edges.") 345 346 def _fig_cuts(self, cuts: pd.DataFrame, bnd: pd.DataFrame) -> None: 347 subs = list(bnd.subject) 348 fig, axes = plt.subplots(1, len(subs), figsize=(2.4 * len(subs), 2.8), sharey=True) 349 for ax, sid in zip(np.atleast_1d(axes), subs): 350 d = cuts[cuts.subject == sid] 351 rel = d[d.reliable] 352 ax.scatter(d[~d.reliable].cut, d[~d.reliable].offset, s=10, facecolor="none", edgecolor="0.6") 353 ax.scatter(rel.cut, rel.offset, s=14, color="k") 354 ax.plot(d.cut, d.predicted_offset, color="#b5523b", lw=1.2) 355 ax.axhline(0, color="0.5", ls=":", lw=0.7) 356 ep = float(bnd.loc[bnd.subject == sid, "epoch_frames"].iloc[0]) 357 ax.set_title(f"{sid}: {ep:.2f} fr/epoch", fontsize=8) 358 ax.set_xlabel("cut k") 359 np.atleast_1d(axes)[0].set_ylabel("offset vs 2,000·k (frames)") 360 self.figure(fig, "boundary_drift", "Epoch boundaries drift from the 2,000-frame grid", 361 "Offset of the largest population ΔF/F step within ±15 frames of each nominal cut. Filled points are " 362 "reliable steps (> 95th percentile of random-time steps, not at the window edge); open points are not. " 363 "Red line: drift model k·δ used to place the cuts.") 364 365 def _fig_alignment(self, align: dict, complete: list[int]) -> None: 366 fig, axes = plt.subplots(1, len(complete), figsize=(3.3 * len(complete), 3.3)) 367 axes = np.atleast_1d(axes) 368 for ax, sid in zip(axes, complete): 369 m, slots = align[sid] 370 im = ax.imshow(m, cmap="RdBu_r", vmin=-4, vmax=4) 371 ax.plot(np.arange(len(slots)), np.arange(len(slots)), "k.", ms=2) 372 ax.set_title(str(sid)) 373 ax.set_xticks(range(len(slots)), [f"{c}{d}" for c, d in slots], rotation=90, fontsize=5) 374 ax.set_yticks(range(m.shape[0]), [str(i) for i in range(m.shape[0])], fontsize=5) 375 ax.set_xlabel("Behavioral session") 376 axes[0].set_ylabel("Imaging block") 377 fig.colorbar(im, ax=axes, shrink=0.7, label="alignment z") 378 self.figure(fig, "alignment_matrices", "Behavior-encoding alignment matrices", 379 "z-scored variance in neural activity explained by each session's ethogram (columns) for each imaging " 380 "block (rows). Black dots mark the hypothesised protocol order; positive values along the diagonal " 381 "support the mapping.") 382 383 def _fig_drop(self, drop_df: pd.DataFrame) -> None: 384 subs = drop_df.subject.unique() 385 fig, axes = plt.subplots(1, len(subs), figsize=(5.5 * len(subs), 3)) 386 for ax, sid in zip(np.atleast_1d(axes), subs): 387 d = drop_df[drop_df.subject == sid] 388 missing = {int(k): v for k, v in (self.ds.cfg.get("session_mapping", {}).get("missing_slots") or {}).items()} 389 ax.bar(range(len(d)), d.diagonal_z, color=["#b5523b" if s == missing.get(int(sid)) else "0.6" for s in d.missing_slot]) 390 ax.set_xticks(range(len(d)), d.missing_slot, rotation=90, fontsize=7) 391 ax.set_title(f"{sid}: fit if this slot is missing") 392 ax.set_ylabel("mean diagonal z") 393 self.figure(fig, "missing_slot_candidates", "Which session is missing?", 394 "Mean alignment z of the remaining mapping when each protocol slot is assumed missing. Early deletions " 395 "fit worse. Red marks the epoch the lab identified as absent (6597: SS day 9; 6669: SIT).") 396 397 def _fig_raster(self) -> None: 398 ds = self.ds 399 sid = 6675 400 sess = list(ds.iter_sessions(sid, ["BL", "SD", "SS"])) 401 sess = [s for s in sess if s.day == 5] 402 fig, axes = plt.subplots(2, len(sess), figsize=(13, 5), gridspec_kw={"height_ratios": [5, 1]}) 403 behs = sorted({b for s in sess for b in ds.labels(s)} - {"none"}) 404 cmap = plt.get_cmap("tab20") 405 colors = {b: cmap(i % 20) for i, b in enumerate(behs)} 406 for j, s in enumerate(sess): 407 x = ds.activity(s) 408 order = np.argsort(-x.mean(1)) 409 axes[0, j].imshow(x[order], aspect="auto", cmap="magma", vmax=np.percentile(x, 99), 410 extent=[0, s.n_frames / ds.fs, x.shape[0], 0]) 411 axes[0, j].set_title(f"{sid} · {s.condition} · day {s.day}") 412 lab = ds.labels(s) 413 rgb = np.array([colors.get(b, (1, 1, 1, 1)) for b in lab])[None] 414 axes[1, j].imshow(rgb, aspect="auto", extent=[0, s.n_frames / ds.fs, 0, 1]) 415 axes[1, j].set_yticks([]) 416 axes[1, j].set_xlabel("time (s)") 417 axes[0, 0].set_ylabel("neuron (sorted by rate)") 418 handles = [plt.Rectangle((0, 0), 1, 1, color=colors[b]) for b in behs] 419 fig.legend(handles, behs, ncol=min(7, len(behs)), fontsize=7, loc="lower center", bbox_to_anchor=(0.5, -0.06)) 420 self.figure(fig, "raster_example", "Example day: inferred spikes with the ethogram", 421 "Inferred spike probability for every neuron of animal 6675 on CSDS day 5 (baseline, defeat, post-defeat " 422 "sensory contact), with the aligned behavioral annotation below each raster.")
Study S01
Behavioral phenotyping across chronic social defeat
Time budgets, the evolution of defeat responses across days, and a social-interaction phenotype.
1"""S01 — Behavioral phenotyping across CSDS.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import numpy as np 7import pandas as pd 8 9from ..data import CONDITION_COLORS 10from ..stats import fdr_bh, fmt_p 11from ..stats.compare import day_trend 12from .base import Study 13 14DEFEAT = ["Physical interaction", "Attack", "Submissive", "Freezing", "Escape", "Danger"] 15SOCIAL_SIT = ["Interaction", "Approach"] 16 17 18class Behavior(Study): 19 id = "s01" 20 number = 1 21 title = "Behavioral phenotyping across chronic social defeat" 22 short = "Time budgets, the evolution of defeat responses across days, and a social-interaction phenotype." 23 question = ("How does behavior evolve across repeated defeat, and how does social-test behavior relate to the " 24 "lab's susceptible/resilient classification?") 25 background = """ 26In the CSDS paradigm, C57BL/6 mice are defeated daily by a novel CD-1 aggressor and then housed in sensory 27contact across a perforated divider (Golden et al. 2011). Susceptibility is classically defined in the social 28interaction test (SIT) by a social-interaction ratio below 1: time near a CD-1 target divided by time near an 29empty enclosure (Krishnan et al. 2007). Defeat episodes are characterised by attacks, submissive postures, 30freezing and escape. How these responses change across days is itself informative about coping strategy 31(Hollis & Kabbaj 2014). Here the ethograms are hand-scored state annotations (BORIS-style) for every session. 32""" 33 methods = """ 34**Annotations.** State events (start/stop, seconds) were loaded for every session. Labels were canonicalised: 35`standing`→Standing, `exploring`→Exploring, `grooming`→Grooming, `escape/huir`→Escape. Observation IDs encode 36animal, condition (BL, SD, SS, SIT_NoCD1, SIT_CD1, POST_SIT), CSDS day and date. 37 38**Time budgets.** For each session, the time in each behavior was divided by total annotated time. Budgets 39were averaged per animal within condition, then across animals. 40 41**Defeat dynamics.** Within SD sessions (days 1–9 without day 7), the fraction of time spent in each defeat-related state 42(physical interaction, attack, submissive, freezing, escape, danger) was regressed on CSDS day separately for 43each animal. The animal is the unit of inference: the six per-animal slopes were tested against 0 with an exact 44two-sided Wilcoxon signed-rank test (summary-statistic random-effects approach; Holmes & Friston 1998). The 45reported slope is the pooled within-animal estimate from `fraction ~ day + animal` (OLS with animal fixed 46effects). The same analysis was applied to *non-physical interaction* in BL and SS sessions, i.e. time spent 47investigating the aggressor across the divider. A decline would indicate emerging social avoidance. P-values 48were FDR-corrected (Benjamini–Hochberg) across all trend tests. With six animals, the smallest attainable 49two-sided p is 0.031. 50 51**SIT phenotype.** The susceptible/resilient label of each animal was provided by the lab. To check how much of 52it is visible in the annotations, social engagement was quantified as the seconds annotated as *Interaction* or 53*Approach* in each SIT phase (≈150 s each). The social-interaction ratio proxy is SI = engagement(CD1) / engagement(empty), and 54animals with SI < 1 are labelled *susceptible-like*. This is a proxy: the canonical ratio uses tracked time in an 55interaction zone, which is not available in these files. 56""" 57 limitations = """ 58* The SI ratio is derived from ethogram states, not zone tracking. The lab pointed to the interaction- and 59 corner-zone times (Shared_CSDS); those scores are not in this repository. n = 6, with no non-defeated control 60 group. Phenotype labels are descriptive and are not used as a grouping factor for inferential tests. 61* "Attack" refers to the annotated attack state in the focal animal's ethogram. Whether it codes being attacked 62 or retaliation should be confirmed with the scorer. 63* Observer reliability (inter-rater agreement) is not available in these files. 64""" 65 references = [ 66 "Golden S.A. et al. (2011) A standardized protocol for repeated social defeat stress in mice. Nat Protoc 6:1183–1191.", 67 "Krishnan V. et al. (2007) Molecular adaptations underlying susceptibility and resistance to social defeat in brain reward regions. Cell 131:391–404.", 68 "Hollis F. & Kabbaj M. (2014) Social defeat as an animal model for depression. ILAR J 55:221–232.", 69 "Friard O. & Gamba M. (2016) BORIS: a free, versatile open-source event-logging software for video/audio coding and live observations. Methods Ecol Evol 7:1325–1330.", 70 "Holmes A.P. & Friston K.J. (1998) Generalisability, random effects and population inference. NeuroImage 7:S754.", 71 ] 72 73 def run(self) -> dict: 74 b = self.ds.behavior[~self.ds.behavior.day.isin(self.ds.exclude_days)].copy() 75 b["cond"] = np.where(b.condition == "SIT", "SIT_" + b.sit_target.fillna(""), 76 np.where(b.condition == "POST", "POST", b.condition)) 77 tot = b.groupby("Observation id")["Duration (s)"].sum().rename("total") 78 frac = (b.groupby(["subject", "cond", "day", "Observation id", "Behavior"])["Duration (s)"].sum() 79 .reset_index().join(tot, on="Observation id")) 80 frac["fraction"] = frac["Duration (s)"] / frac["total"] 81 wide = frac.pivot_table(index=["subject", "cond", "day", "Observation id"], columns="Behavior", 82 values="fraction", fill_value=0).reset_index() 83 wide = wide.rename(columns={"cond": "condition"}) 84 self.ctx.cache["behavior_fractions"] = wide 85 86 # Time budgets 87 budget = wide.drop(columns=["day", "Observation id"]).groupby(["subject", "condition"]).mean() 88 budget = budget.groupby("condition").mean() 89 order = [c for c in ["BL", "SD", "SS", "SIT_NoCD1", "SIT_CD1", "POST"] if c in budget.index] 90 budget = budget.loc[order] 91 self._fig_budget(budget) 92 93 # Defeat dynamics 94 trend_rows = [] 95 sd = wide[(wide.condition == "SD") & (wide.day <= 9)] 96 for beh in DEFEAT: 97 if beh in sd: 98 trend_rows.append(day_trend(sd.rename(columns={beh: "y"}), "y", "SD") | {"behavior": beh}) 99 for cond in ["BL", "SS"]: 100 d = wide[(wide.condition == cond) & (wide.day <= 9)].rename(columns={"Non-physical interaction": "y"}) 101 trend_rows.append(day_trend(d, "y", cond) | {"behavior": "Non-physical interaction"}) 102 trends = pd.DataFrame(trend_rows)[["behavior", "condition", "slope_per_day", "median_animal_slope", "n_positive", 103 "n_animals", "p", "n_sessions"]] 104 trends["q_fdr"] = fdr_bh(trends.p) 105 self._fig_defeat(wide) 106 107 # SIT 108 sit_ev = b[b.cond.str.startswith("SIT")] 109 eng = (sit_ev[sit_ev.Behavior.isin(SOCIAL_SIT)].groupby(["subject", "cond"])["Duration (s)"].sum() 110 .reindex(pd.MultiIndex.from_product([sorted(sit_ev.subject.unique()), ["SIT_CD1", "SIT_NoCD1"]], 111 names=["subject", "cond"]), fill_value=0.0)) 112 si = eng.unstack("cond").rename(columns={"SIT_CD1": "CD1_engagement_s", "SIT_NoCD1": "empty_engagement_s"}) 113 si["SI_ratio_proxy"] = si["CD1_engagement_s"] / si["empty_engagement_s"].replace(0, np.nan) 114 si["phenotype_proxy"] = np.where(si.SI_ratio_proxy < 1, "susceptible-like", "resilient-like") 115 si = si.reset_index() 116 si["phenotype_lab"] = si.subject.map(self.ds.phenotype) 117 si["proxy_agrees"] = (si.phenotype_proxy.str.replace("-like", "") == si.phenotype_lab) 118 defeat_idx = sd.groupby("subject")[["Submissive", "Freezing", "Escape"]].mean().sum(1).rename("defeat_response_index") 119 si = si.join(defeat_idx, on="subject") 120 self.ctx.cache["phenotype"] = si 121 self._fig_sit(si) 122 123 self.table(trends, "defeat_trends", "Change in behavior across CSDS days", 124 "Pooled within-animal slope (fraction of session time per day); p = exact Wilcoxon of per-animal slopes " 125 "(animal = unit of inference); q = Benjamini–Hochberg.") 126 self.table(si, "sit_phenotype", "SIT engagement and social-interaction ratio proxy", 127 "Engagement = annotated Interaction + Approach seconds in each SIT phase. Defeat-response index = mean fraction of SD " 128 "time in submissive + freezing + escape.") 129 self.table(budget.reset_index().round(4), "time_budgets", "Mean time budget per condition", 130 "Fraction of annotated time, averaged within animal then across animals.") 131 132 n_sus = int((si.phenotype_proxy == "susceptible-like").sum()) 133 sig = trends[trends.q_fdr < 0.05] 134 for _, r in trends.sort_values("p").head(3).iterrows(): 135 kind = "significant" if r.q_fdr < 0.05 else "trend" if r.p < 0.05 else "null" 136 self.highlight(f"{r.behavior} ({r.condition})", f"{r.slope_per_day * 100:+.2f} %/day", 137 f"Change in time fraction per CSDS day; {r.n_positive}/{r.n_animals} animals positive, " 138 f"exact Wilcoxon {fmt_p(r.p)} (q = {r.q_fdr:.3f}).", kind) 139 n_agree = int(si.proxy_agrees.sum()) 140 self.highlight("Lab phenotype", f"{int((si.phenotype_lab == 'susceptible').sum())} susceptible · " 141 f"{int((si.phenotype_lab == 'resilient').sum())} resilient", 142 "Classification provided by the lab; used for all phenotype analyses (S10).", "info") 143 self.highlight("Ethogram SI proxy vs lab phenotype", f"{n_agree}/{len(si)} agree", 144 f"{n_sus}/{len(si)} animals have an annotation-based SI ratio < 1. The proxy does not reproduce the " 145 "lab classification well and is not used to label animals.", "caution") 146 147 sd_budget = budget.loc["SD"].sort_values(ascending=False) 148 top = lambda c: ", ".join(f"{k.lower()} {v:.0%}" for k, v in budget.loc[c].sort_values(ascending=False).head(3).items()) 149 results = f""" 150**Time budgets.** The three dominant states were {top('BL')} in baseline sessions and {top('SS')} in 151post-defeat sensory-contact sessions. Defeat sessions carried a large defeat repertoire: physical interaction 152{sd_budget.get('Physical interaction', 0):.0%}, submissive {sd_budget.get('Submissive', 0):.0%}, freezing 153{sd_budget.get('Freezing', 0):.0%}, escape {sd_budget.get('Escape', 0):.0%} and attack 154{sd_budget.get('Attack', 0):.0%} of annotated time (Figure 1). 155 156**Defeat dynamics.** {len(sig)} of {len(trends)} day-trend tests survived FDR correction. The strongest 157effects are listed in Table 1 and plotted in Figure 2. Positive slopes mean the behavior occupies more of each 158session as defeats accumulate. 159 160**SIT phenotype.** The lab classified 4703, 6597 and 6669 as resilient and 6445, 6672 and 6675 as susceptible. 161An annotation-based proxy (Interaction + Approach time with the CD-1 vs the empty enclosure) gave SI ratios of 162{si.SI_ratio_proxy.min():.2f}–{si.SI_ratio_proxy.max():.2f}, with {n_sus} of {len(si)} animals below 1. The proxy matched the 163lab classification for {n_agree} of {len(si)} animals (Table 2, Figure 3). Ethogram states are not a substitute for 164zone-based tracking, so the lab classification is used for all phenotype analyses (S10). 165""" 166 return {"results": results, "parameters": {"sit_engagement_states": SOCIAL_SIT, "defeat_states": DEFEAT}} 167 168 def _fig_budget(self, budget: pd.DataFrame) -> None: 169 cols = budget.sum().sort_values(ascending=False).index 170 fig, ax = plt.subplots(figsize=(8, 3.6)) 171 bottom = np.zeros(len(budget)) 172 cmap = plt.get_cmap("tab20") 173 for i, c in enumerate(cols): 174 ax.bar(budget.index, budget[c], bottom=bottom, color=cmap(i % 20), label=c, width=0.7) 175 bottom += budget[c].to_numpy() 176 ax.set_ylabel("fraction of annotated time") 177 ax.legend(ncol=1, fontsize=7, bbox_to_anchor=(1.01, 1), loc="upper left") 178 self.figure(fig, "time_budgets", "Time budget by condition", 179 "Mean fraction of annotated time spent in each behavior, per condition (animal-averaged).") 180 181 def _fig_defeat(self, wide: pd.DataFrame) -> None: 182 sd = wide[(wide.condition == "SD") & (wide.day <= 9)] 183 fig, axes = plt.subplots(1, 2, figsize=(10, 3.4)) 184 cmap = plt.get_cmap("tab10") 185 for i, beh in enumerate([b for b in DEFEAT if b in sd]): 186 g = sd.groupby("day")[beh].agg(["mean", "sem"]) 187 axes[0].errorbar(g.index, g["mean"], yerr=g["sem"], marker="o", ms=3, capsize=2, label=beh, color=cmap(i)) 188 axes[0].set_xlabel("CSDS day") 189 axes[0].set_ylabel("fraction of SD session") 190 axes[0].set_title("Defeat repertoire across days") 191 axes[0].legend(fontsize=7) 192 for cond in ["BL", "SS"]: 193 d = wide[(wide.condition == cond) & (wide.day <= 9)].groupby("day")["Non-physical interaction"].agg(["mean", "sem"]) 194 axes[1].errorbar(d.index, d["mean"], yerr=d["sem"], marker="o", ms=3, capsize=2, label=cond, 195 color=CONDITION_COLORS[cond]) 196 axes[1].set_xlabel("CSDS day") 197 axes[1].set_ylabel("fraction of session") 198 axes[1].set_title("Investigation across the divider") 199 axes[1].legend(fontsize=7) 200 self.figure(fig, "defeat_dynamics", "Evolution of defeat and social behavior", 201 "Left: fraction of each defeat session in defeat-related states across CSDS days (mean ± SEM, n = 6). " 202 "Right: non-physical interaction in baseline and post-defeat sessions.") 203 204 def _fig_sit(self, si: pd.DataFrame) -> None: 205 fig, axes = plt.subplots(1, 2, figsize=(8.5, 3.3)) 206 for _, r in si.iterrows(): 207 axes[0].plot([0, 1], [r.empty_engagement_s, r.CD1_engagement_s], marker="o", 208 color="#b5523b" if r.phenotype_lab == "susceptible" else "#3b7d6e") 209 axes[0].text(1.05, r.CD1_engagement_s, str(int(r.subject)), fontsize=7, va="center") 210 axes[0].set_xticks([0, 1], ["empty", "CD1"]) 211 axes[0].set_ylabel("engagement (s)") 212 axes[0].set_title("SIT engagement") 213 s = si.sort_values("SI_ratio_proxy") 214 axes[1].bar(s.subject.astype(str), s.SI_ratio_proxy, 215 color=["#b5523b" if p == "susceptible" else "#3b7d6e" for p in s.phenotype_lab]) 216 axes[1].axhline(1, color="k", ls="--", lw=0.8) 217 axes[1].set_ylabel("SI ratio proxy (CD1 / empty)") 218 axes[1].set_title("Social-interaction ratio proxy") 219 self.figure(fig, "sit_phenotype", "Social interaction test phenotype", 220 "Left: time engaged with the enclosure when empty versus containing a CD-1 (per animal). Right: ratio " 221 "proxy (dashed line = 1). Colour = lab phenotype: red = susceptible, green = resilient.")
Study S02
Neural activity across CSDS phases
Inferred firing, active-neuron fraction and behavior-locked population responses.
1"""S02 — Single-neuron activity across CSDS phases.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import numpy as np 7import pandas as pd 8 9from ..data import CORE_CONDITIONS 10from ..stats import animal_means, fdr_bh, fe_model, fmt_p, friedman, paired 11from ..stats.compare import day_trend 12from ..viz import day_lines, paired_conditions, star 13from .base import Study 14 15 16class Activity(Study): 17 id = "s02" 18 number = 2 19 title = "Neural activity across CSDS phases" 20 short = "Inferred firing, active-neuron fraction and behavior-locked population responses." 21 question = ("Does the overall level of neural activity differ between baseline, defeat and post-defeat sensory " 22 "contact, does it change across defeat days, and which behaviors drive population activity?") 23 background = """ 24Chronic social defeat changes the excitability of stress-related circuits. CSDS increases activity of 25prelimbic glutamatergic neurons and reduces PV interneuron activity (Nat Commun 2023). Resilient animals 26show elevated dCA1 inferred spike rates (Biol Psychiatry Glob Open Sci 2025), and the pre-stress activity of NAc D1-MSNs predicts 27later resilience (Muir et al. 2018). Changes in mean activity also matter methodologically: firing rate 28influences pairwise correlations and therefore network measures (de la Rocha et al. 2007; Cohen & Kohn 292011). Establishing activity effects is a prerequisite for interpreting the network studies that follow. 30""" 31 methods = """ 32**Signal.** CASCADE-inferred spike probability (expected spikes per frame) multiplied by the frame rate gives 33an estimated event rate in Hz (Rupprecht et al. 2021). ΔF/F was analysed in parallel as a sensitivity check. 34 35**Session measures.** For each neuron and session: mean event rate; *active* if mean rate exceeded 0.1 Hz. Session 36values are the mean over neurons and the fraction of active neurons. 37 38**Condition contrasts.** Primary contrasts use days 1–9, day 7 excluded, where BL, SD and SS share a day, an 39animal and a recording length (≈2,000 frames). Only days on which an animal has all three conditions enter its 40means (this drops BL and SD of day 9 for 6597, which has no SS that day), so every condition mean averages the 41same days. Per-animal condition means were compared with a Friedman test followed by exact Wilcoxon 42signed-rank tests (SD vs BL, SS vs BL, SS vs SD). A session-level OLS with animal × day block fixed effects 43(`measure ~ condition + block`) gives within-day effect estimates; its session-level p-values are descriptive 44because sessions of one animal are not independent. The day trend within each condition was estimated per 45animal and the per-animal slopes tested against 0 (exact Wilcoxon). P-values were FDR-corrected across 46measures × contrasts. 47 48**Behavior-locked population responses.** Within each session, the population rate (mean over neurons) was 49z-scored, and its mean computed within each annotated behavior. Per-animal means (behaviors with ≥ 100 frames) 50were tested against 0 with exact Wilcoxon tests, separately for defeat sessions and for baseline/post-defeat 51sessions. 52 53**SIT.** Population rate during the CD-1 phase was compared with the empty-enclosure phase (paired Wilcoxon). 54""" 55 limitations = """ 56* Inferred spike rates depend on CASCADE model choice and noise level. Comparisons are within animal and 57 within the same neurons, which limits but does not remove this dependence. 58* With n = 6 the smallest attainable exact two-sided Wilcoxon p is 0.031. A null result is weak evidence of 59 no effect. 60* BL, SD and SS are always recorded in that order within a day. Condition contrasts are therefore confounded 61 with position in the recording (photobleaching, within-day drift, time since handling). 62* Slow fluorescence drift across the recording can mimic day trends. ΔF/F was computed on the concatenated 63 recording (F₀ = 10th percentile in a 12-s window), so that window can cross an epoch boundary. 64* The CASCADE model and noise level were not recorded. Spike probability is paired one-to-one with ΔF/F. 65""" 66 references = [ 67 "Rupprecht P. et al. (2021) CASCADE spike inference. Nat Neurosci 24:1324–1337.", 68 "CSDS decreases claustrum glutamatergic and prelimbic PV interneuron activity and increases prelimbic glutamatergic activity (2023) Nat Commun. doi:10.1038/s41467-023-43636-x", 69 "Diminished social memory and hippocampal correlates of social interactions in chronic social defeat stress susceptibility (2025) Biol Psychiatry Glob Open Sci. https://www.bpsgos.org/article/S2667-1743(25)00009-6/fulltext", 70 "Muir J. et al. (2018) In vivo fiber photometry reveals signature of future stress susceptibility in nucleus accumbens. Neuropsychopharmacology 43:255–263.", 71 "de la Rocha J. et al. (2007) Correlation between neural spike trains increases with firing rate. Nature 448:802–806.", 72 "Cohen M.R. & Kohn A. (2011) Measuring and interpreting neuronal correlations. Nat Neurosci 14:811–819.", 73 ] 74 75 def run(self) -> dict: 76 ds = self.ds 77 rows, beh_rows = [], [] 78 for s in ds.iter_sessions(): 79 x = ds.activity(s, "spikes") 80 rate = x.mean(1) * ds.fs 81 dff = ds.activity(s, "dff").mean(1) 82 rows.append({"key": s.key, "subject": s.subject, "condition": s.condition, "day": s.day, 83 "mapping": s.mapping, "rate_hz": rate.mean(), "active_fraction": (rate > 0.1).mean(), 84 "dff": dff.mean()}) 85 pop = x.mean(0) 86 z = (pop - pop.mean()) / (pop.std() + 1e-12) 87 lab = ds.labels(s) 88 for b in np.unique(lab): 89 m = lab == b 90 if b != "none" and m.sum() >= 1: 91 beh_rows.append({"subject": s.subject, "condition": s.condition, "behavior": b, 92 "frames": int(m.sum()), "z_sum": float(z[m].sum())}) 93 df = pd.DataFrame(rows) 94 self.ctx.cache["activity"] = df 95 96 core = df[df.condition.isin(CORE_CONDITIONS) & (df.day <= 9)] 97 stat_rows, fe_rows = [], [] 98 pivots = {} 99 for metric in ["rate_hz", "active_fraction", "dff"]: 100 pv = animal_means(core, metric, CORE_CONDITIONS) 101 pivots[metric] = pv 102 fr = friedman(pv) 103 for a, b in [("BL", "SD"), ("BL", "SS"), ("SD", "SS")]: 104 stat_rows.append({"metric": metric, **paired(pv, a, b), **fr}) 105 fe_rows.append(fe_model(core, metric, CORE_CONDITIONS)) 106 stats_df = pd.DataFrame(stat_rows) 107 stats_df["q_fdr"] = fdr_bh(stats_df.p) 108 fe_df = pd.concat(fe_rows) 109 trend = pd.DataFrame([day_trend(core, m, c) for m in ["rate_hz", "active_fraction"] for c in CORE_CONDITIONS]) 110 trend["q_fdr"] = fdr_bh(trend.p) 111 112 # Behavior-locked 113 bl = pd.DataFrame(beh_rows) 114 bl["group"] = np.where(bl.condition == "SD", "SD", np.where(bl.condition.isin(["BL", "SS"]), "BL+SS", "other")) 115 agg = bl[bl.group != "other"].groupby(["group", "subject", "behavior"]).agg(frames=("frames", "sum"), z_sum=("z_sum", "sum")) 116 agg["z"] = agg.z_sum / agg.frames 117 agg = agg[agg.frames >= 100].reset_index() 118 from scipy import stats as st 119 brow = [] 120 for (g, b), d in agg.groupby(["group", "behavior"]): 121 if len(d) >= 4: 122 p = st.wilcoxon(d.z, method="exact").pvalue if np.any(d.z != 0) else np.nan 123 brow.append({"group": g, "behavior": b, "n_animals": len(d), "mean_z": d.z.mean(), "p": p}) 124 beh_stats = pd.DataFrame(brow) 125 beh_stats["q_fdr"] = fdr_bh(beh_stats.p) 126 127 sit = animal_means(df, "rate_hz", ["SIT_NoCD1", "SIT_CD1"]) 128 sit_test = paired(sit, "SIT_NoCD1", "SIT_CD1") if len(sit) >= 3 else {} 129 130 self._fig_conditions(pivots, stats_df) 131 self._fig_days(core) 132 self._fig_behavior(agg, beh_stats) 133 134 self.table(stats_df[["metric", "contrast", "n", "median_diff", "dz", "p", "q_fdr", "friedman_chi2", "friedman_p"]], 135 "condition_tests", "Condition contrasts (per-animal means, days 1–9, day 7 excluded)", 136 "Exact Wilcoxon signed-rank on per-animal means; Friedman across BL/SD/SS; q = BH-FDR.") 137 self.table(fe_df, "fixed_effects", "Session-level OLS with animal × day fixed effects", 138 "measure ~ condition + block (animal × day). p_session is descriptive (sessions are not independent).") 139 self.table(trend, "day_trends", "Day trends within condition", 140 "slope_per_day = pooled within-animal slope; p = exact Wilcoxon of per-animal slopes.") 141 self.table(beh_stats.sort_values("p"), "behavior_locked", "Behavior-locked population rate", 142 "Mean z-scored population rate during each behavior (per-animal mean tested against 0).") 143 self.table(df, "session_activity", "Session-level activity", show=15) 144 145 r = stats_df[(stats_df.metric == "rate_hz")].set_index("contrast") 146 sdbl = r.loc["SD vs BL"] 147 self.highlight("Firing during defeat", f"{sdbl.median_diff:+.3f} Hz (SD − BL)", 148 f"Median paired change in inferred event rate, {sdbl.n_increase}/{sdbl.n} animals increased, " 149 f"{fmt_p(sdbl.p)}; Friedman {fmt_p(sdbl.friedman_p)}.", 150 "significant" if sdbl.q_fdr < 0.05 else "trend" if sdbl.p < 0.05 else "null") 151 top = beh_stats.sort_values("p").head(2) 152 for _, b in top.iterrows(): 153 self.highlight(f"Population response: {b.behavior}", f"z = {b.mean_z:+.2f}", 154 f"{b.group} sessions, {b.n_animals} animals, {fmt_p(b.p)} (q = {b.q_fdr:.3f}).", 155 "significant" if b.q_fdr < 0.05 else "trend" if b.p < 0.05 else "null") 156 if sit_test: 157 self.highlight("SIT CD-1 vs empty", f"{sit_test['median_diff']:+.3f} Hz", 158 f"Population rate change when the CD-1 is present, {fmt_p(sit_test['p'])}.", 159 "trend" if sit_test["p"] < 0.05 else "null") 160 161 means = pivots["rate_hz"].mean() 162 results = f""" 163**Activity levels.** Mean inferred event rates on days 1–9, day 7 excluded were BL {means['BL']:.3f} Hz, SD {means['SD']:.3f} Hz and 164SS {means['SS']:.3f} Hz (animal means; Friedman {fmt_p(r.loc['SD vs BL'].friedman_p)}). For SD vs BL, the median 165paired difference was {sdbl.median_diff:+.3f} Hz ({sdbl.n_increase}/{sdbl.n} animals higher during defeat, 166{fmt_p(sdbl.p)}, q = {sdbl.q_fdr:.3f}). All contrasts for rate, active fraction and ΔF/F are in Table 1; 167fixed-effect estimates are in Table 2. 168 169**Across days.** Day trends are reported in Table 3 (Figure 2). {int((trend.q_fdr < 0.05).sum())} of {len(trend)} 170trends survived FDR correction. 171 172**Behavior-locked responses.** Figure 3 and Table 4 show the population z-score during each behavior. 173{int((beh_stats.q_fdr < 0.05).sum())} of {len(beh_stats)} behavior × context combinations differed from the session 174mean after FDR correction{'' if (beh_stats.q_fdr < 0.05).any() else '. At the population-average level, behavior did not consistently shift activity across animals. Single-neuron selectivity is examined in S03 and decoding in S08'}. 175""" 176 return {"results": results, "parameters": {"active_threshold_hz": 0.1, "min_behavior_frames": 100, 177 "fs_hz": ds.fs}} 178 179 def _fig_conditions(self, pivots: dict, stats_df: pd.DataFrame) -> None: 180 fig, axes = plt.subplots(1, 3, figsize=(10, 3.3)) 181 labels = {"rate_hz": "event rate (Hz)", "active_fraction": "fraction active (>0.1 Hz)", "dff": "mean ΔF/F"} 182 for ax, (m, pv) in zip(axes, pivots.items()): 183 st = stats_df[stats_df.metric == m].set_index("contrast") 184 stars = {("BL", "SD"): star(st.loc["SD vs BL", "p"]), ("BL", "SS"): star(st.loc["SS vs BL", "p"])} 185 paired_conditions(ax, pv, labels[m], labels[m].split(" (")[0], stars) 186 self.figure(fig, "activity_by_condition", "Activity by CSDS phase", 187 "Per-animal means (grey lines) and group mean ± SEM for days 1–9, day 7 excluded. Stars: exact Wilcoxon vs BL " 188 "(* p<0.05; n.s. not significant; with n = 6 the minimum p is 0.031).") 189 190 def _fig_days(self, core: pd.DataFrame) -> None: 191 fig, axes = plt.subplots(1, 2, figsize=(9, 3.2)) 192 day_lines(axes[0], core, "rate_hz", CORE_CONDITIONS, "event rate (Hz)", "Event rate across days") 193 day_lines(axes[1], core, "active_fraction", CORE_CONDITIONS, "fraction active", "Active neurons across days") 194 self.figure(fig, "activity_by_day", "Activity across CSDS days", 195 "Mean ± SEM across animals for each condition and day.") 196 197 def _fig_behavior(self, agg: pd.DataFrame, beh_stats: pd.DataFrame) -> None: 198 groups = ["SD", "BL+SS"] 199 fig, axes = plt.subplots(1, 2, figsize=(11, 3.4), gridspec_kw={"width_ratios": [1.4, 1]}) 200 for ax, g in zip(axes, groups): 201 d = agg[agg.group == g] 202 s = beh_stats[beh_stats.group == g].set_index("behavior") 203 order = d.groupby("behavior").z.mean().sort_values().index 204 order = [b for b in order if b in s.index] 205 for i, b in enumerate(order): 206 v = d[d.behavior == b].z 207 ax.bar(i, v.mean(), color="#b5523b" if v.mean() > 0 else "#3b7d6e", alpha=0.75) 208 ax.scatter(np.full(len(v), i) + np.random.default_rng(i).uniform(-0.15, 0.15, len(v)), v, s=8, c="k") 209 ax.text(i, max(v.max(), 0) + 0.05, star(s.loc[b, "p"]), ha="center", fontsize=7) 210 ax.axhline(0, color="k", lw=0.6) 211 ax.set_xticks(range(len(order)), order, rotation=45, ha="right", fontsize=7) 212 ax.set_ylabel("population rate (z)") 213 ax.set_title(f"{g} sessions") 214 self.figure(fig, "behavior_locked_population", "Behavior-locked population activity", 215 "Mean z-scored population rate during each annotated behavior; dots are animals. Stars: exact Wilcoxon vs 0.")
Study S03
Behavior-tuned neurons and ensemble reactivation
Which neurons encode defeat behaviors, and are defeat-responsive neurons re-engaged in the SIT?
1"""S03 — Behavior-tuned neurons and defeat-to-SIT ensemble reactivation.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import numpy as np 7import pandas as pd 8from scipy import stats 9 10from ..stats import fdr_bh, fmt_p 11from ..viz import star 12from .base import Study 13 14N_SHIFTS = 500 15N_OVERLAP_NULL = 100 16ALPHA = 0.01 17 18 19def tuning(x: np.ndarray, lab: np.ndarray, behaviors: list[str], rng, min_frames: int = 60) -> pd.DataFrame: 20 """Per-neuron selectivity (mean in − mean out) vs circular shifts of the label sequence.""" 21 t = x.shape[1] 22 shifts = rng.integers(int(0.05 * t), int(0.95 * t), N_SHIFTS) 23 xs = x - x.mean(1, keepdims=True) 24 rows = [] 25 for b in behaviors: 26 m = lab == b 27 k = int(m.sum()) 28 if k < min_frames or k > t - min_frames: 29 continue 30 obs = xs[:, m].mean(1) - xs[:, ~m].mean(1) 31 idx = (np.flatnonzero(m)[None, :] + shifts[:, None]) % t 32 null_in = np.stack([xs[:, row].mean(1) for row in idx], axis=1) 33 tot = xs.sum(1, keepdims=True) 34 null_out = (tot - null_in * k) / (t - k) 35 null = null_in - null_out 36 z = (obs - null.mean(1)) / (null.std(1) + 1e-12) 37 p_exc = (1 + (null >= obs[:, None]).sum(1)) / (1 + N_SHIFTS) 38 p_inh = (1 + (null <= obs[:, None]).sum(1)) / (1 + N_SHIFTS) 39 for i in range(x.shape[0]): 40 rows.append({"neuron": i, "behavior": b, "frames": k, "z": z[i], "excited": p_exc[i] < ALPHA, 41 "inhibited": p_inh[i] < ALPHA}) 42 return pd.DataFrame(rows) 43 44 45class Tuning(Study): 46 id = "s03" 47 number = 3 48 title = "Behavior-tuned neurons and ensemble reactivation" 49 short = "Which neurons encode defeat behaviors, and are defeat-responsive neurons re-engaged in the SIT?" 50 question = ("What fraction of neurons is selectively excited or inhibited during defeat-related behaviors, and do " 51 "neurons recruited during physical defeat overlap with neurons that respond to the CD-1 in the SIT?") 52 background = """ 53Calcium imaging in CSDS has identified *social ensembles*: neurons active during close interactions whose 54reactivation in the SIT differs between susceptible and resilient mice (Biol Psychiatry Glob Open Sci 2025). Threat- and 55defeat-responsive populations also exist in the lateral septum (Li et al. 2023) and amygdala–hippocampal 56circuits (Hultman et al. 2016). Selectivity is usually tested against null distributions that preserve the 57temporal structure of behavior. Circular shifting does this and avoids the inflated false-positive rates of 58frame shuffling (Gründemann et al. 2019). 59""" 60 methods = """ 61**Data pooling.** For each animal, frames from all SD sessions (days 1–9 without day 7) were concatenated with their 62behavior labels. The same was done for BL + SS sessions (days 1–9 without day 7) and for the SIT CD-1 phase. 63 64**Selectivity statistic.** For neuron *i* and behavior *b*: Δ = mean spike probability during *b* − mean 65outside *b*. 66 67**Null model.** The behavior mask was circularly shifted 500 times by 5–95% of the recording length, 68keeping bout lengths and number intact, and Δ was recomputed for each shift. A neuron is *excited* if 69Δ ≥ the null at p < 0.01 (one-sided), and *inhibited* if Δ ≤ the null at p < 0.01. Selectivity z = (Δ − null 70mean)/null SD. Behaviors needed ≥ 60 frames (≈ 9 s) per animal. 71 72**Fractions.** The expected false-positive fraction is 1% per direction. For each behavior, the per-animal 73fraction of excited neurons was tested against 0.01 (one-sample exact Wilcoxon, one-sided), with FDR across 74behaviors. 75 76**Reactivation.** *Defeat ensemble* = neurons excited by physical interaction in SD. *Social ensemble* = neurons 77excited by Interaction/Approach during the SIT CD-1 phase. A hypergeometric test treats neurons as 78exchangeable, but neurons with high rate or variance reach significance more easily in any context, which 79inflates overlap. The primary null therefore keeps neuron identity: the SIT label sequence was circularly 80shifted (10–90% of its length, 100 shifts), the SIT social ensemble re-derived with the same selectivity test, 81and its overlap with the (unchanged) defeat ensemble recorded. Per-animal p = (1 + #null ≥ observed)/101. 82Per-animal p-values were combined with Fisher's method, and overlap − null mean was tested across animals 83with a one-sided exact Wilcoxon. The hypergeometric p is reported as a secondary value. The same analysis was 84run for neurons excited by non-physical interaction in BL + SS (the *non-physical ensemble*). 85""" 86 limitations = """ 87* Behaviors co-occur in time (e.g. submissive postures follow attacks). Selectivity is marginal, not 88 conditional on other behaviors. A multivariate encoding model (GLM) is the natural extension. 89* SIT phases are short (~150 s), which limits power to detect SIT-tuned neurons. Mouse 6669 has no SIT 90 imaging block. In this cohort only a minority of animals yield a non-empty SIT-social ensemble at the 91 chosen threshold, so reactivation overlap is underpowered and is reported as descriptive. 92* Neuron identity across sessions relies on the manually drawn ROIs being applied to the whole longitudinal movie. 93""" 94 references = [ 95 "Diminished social memory and hippocampal correlates of social interactions in chronic social defeat stress susceptibility (2025) Biol Psychiatry Glob Open Sci. https://www.bpsgos.org/article/S2667-1743(25)00009-6/fulltext", 96 "Li L. et al. (2023) Social trauma engages lateral septum circuitry to occlude social reward. Nature 613:696–703.", 97 "Hultman R. et al. (2016) Dysregulation of prefrontal cortex-mediated slow-evolving limbic dynamics drives stress-induced emotional pathology. Neuron 91:439–452.", 98 "Gründemann J. et al. (2019) Amygdala ensembles encode behavioral states. Science 364:eaav8736.", 99 ] 100 101 def run(self) -> dict: 102 ds = self.ds 103 rng = np.random.default_rng(1) 104 sd_beh = ["Physical interaction", "Attack", "Submissive", "Freezing", "Escape", "Danger", "Exploring", "Standing"] 105 bs_beh = ["Non-physical interaction", "Exploring", "Standing", "Digging", "Grooming"] 106 all_t, overlap_rows = [], [] 107 for sid in ds.subjects: 108 x, lab, _ = ds.pooled(sid, ["SD"], max_day=9) 109 t_sd = tuning(x, lab, sd_beh, rng).assign(subject=sid, context="SD") 110 x2, lab2, _ = ds.pooled(sid, ["BL", "SS"], max_day=9) 111 t_bs = tuning(x2, lab2, bs_beh, rng).assign(subject=sid, context="BL+SS") 112 all_t += [t_sd, t_bs] 113 n = x.shape[0] 114 null_social: list[set] = [] 115 if any(True for _ in ds.iter_sessions(sid, ["SIT_CD1"])): 116 x3, lab3, _ = ds.pooled(sid, ["SIT_CD1"]) 117 lab3 = np.where(np.isin(lab3, ["Interaction", "Approach"]), "Social engagement", lab3) 118 t_sit = tuning(x3, lab3, ["Social engagement"], rng, min_frames=30).assign(subject=sid, context="SIT_CD1") 119 all_t.append(t_sit) 120 social = set(t_sit[t_sit.excited].neuron) if not t_sit.empty else set() 121 if social: 122 t3 = len(lab3) 123 for sh in rng.integers(int(0.1 * t3), int(0.9 * t3), N_OVERLAP_NULL): 124 tn = tuning(x3, np.roll(lab3, sh), ["Social engagement"], rng, min_frames=30) 125 null_social.append(set(tn[tn.excited].neuron) if not tn.empty else set()) 126 else: 127 social = set() 128 for src_ctx, src_beh, tab in [("SD", "Physical interaction", t_sd), ("BL+SS", "Non-physical interaction", t_bs)]: 129 ens = set(tab[(tab.behavior == src_beh) & tab.excited].neuron) 130 k = len(ens & social) 131 p = stats.hypergeom.sf(k - 1, n, len(ens), len(social)) if ens and social else np.nan 132 null_k = np.array([len(ens & s) for s in null_social]) 133 p_shift = float((1 + (null_k >= k).sum()) / (1 + len(null_k))) if ens and len(null_k) else np.nan 134 overlap_rows.append({"subject": sid, "source": f"{src_beh} ({src_ctx})", "n_neurons": n, 135 "source_ensemble": len(ens), "SIT_social_ensemble": len(social), "overlap": k, 136 "expected": len(ens) * len(social) / n, 137 "null_overlap_mean": float(null_k.mean()) if len(null_k) else np.nan, 138 "p_shift": p_shift, "p_hypergeom": p}) 139 tun = pd.concat(all_t, ignore_index=True) 140 tun[["excited", "inhibited"]] = tun[["excited", "inhibited"]].astype(bool) 141 self.ctx.cache["tuning"] = tun 142 frac = (tun.groupby(["context", "behavior", "subject"]) 143 .agg(excited=("excited", "mean"), inhibited=("inhibited", "mean"), neurons=("neuron", "count")) 144 .reset_index()) 145 rows = [] 146 for (c, b), d in frac.groupby(["context", "behavior"]): 147 if len(d) >= 4: 148 d = d.astype({"excited": float, "inhibited": float}) 149 p = stats.wilcoxon(d.excited - ALPHA, alternative="greater", method="exact").pvalue 150 p_inh = stats.wilcoxon(d.inhibited - ALPHA, alternative="greater", method="exact").pvalue 151 rows.append({"context": c, "behavior": b, "n_animals": len(d), "excited_mean": d.excited.mean(), 152 "inhibited_mean": d.inhibited.mean(), "p_excited": p, "p_inhibited": p_inh}) 153 fstats = pd.DataFrame(rows) 154 fstats["q_excited"] = fdr_bh(fstats.p_excited) 155 fstats["q_inhibited"] = fdr_bh(fstats.p_inhibited) 156 ov = pd.DataFrame(overlap_rows) 157 ov["ratio_obs_exp"] = ov.overlap / ov.expected.replace(0, np.nan) 158 ov["ratio_obs_null"] = ov.overlap / ov.null_overlap_mean.replace(0, np.nan) 159 comb, comb_hyper, grp_w = {}, {}, {} 160 for src, d in ov.groupby("source"): 161 ps, ph = d.p_shift.dropna(), d.p_hypergeom.dropna() 162 comb[src] = stats.combine_pvalues(ps, method="fisher").pvalue if len(ps) else np.nan 163 comb_hyper[src] = stats.combine_pvalues(ph, method="fisher").pvalue if len(ph) else np.nan 164 dd = (d.overlap - d.null_overlap_mean).dropna() 165 grp_w[src] = (stats.wilcoxon(dd, alternative="greater", method="exact").pvalue 166 if len(dd) >= 4 and np.any(dd != 0) else np.nan) 167 168 self._fig_fractions(frac, fstats) 169 self._fig_heatmap(tun) 170 self._fig_overlap(ov, comb) 171 172 self.table(fstats.sort_values(["context", "excited_mean"], ascending=[True, False]), "tuned_fractions", 173 "Fraction of behavior-tuned neurons", 174 "Mean per-animal fraction of significantly excited/inhibited neurons (p < 0.01 vs 500 circular shifts); " 175 "tests vs the 1% false-positive rate.") 176 self.table(ov, "ensemble_overlap", "Defeat and non-physical ensembles vs SIT social ensemble", 177 "Overlap between neurons excited by the source behavior and neurons excited by social engagement with the " 178 f"CD-1 in the SIT. p_shift: vs {N_OVERLAP_NULL} SIT ensembles from circularly shifted SIT labels (primary); " 179 "p_hypergeom: neurons treated as exchangeable (secondary).") 180 self.table(frac, "tuned_fractions_per_animal", "Tuned fractions per animal", show=20) 181 182 sd = fstats[fstats.context == "SD"].sort_values("excited_mean", ascending=False) 183 for _, r in sd.head(2).iterrows(): 184 self.highlight(f"{r.behavior}-excited neurons", f"{r.excited_mean:.1%}", 185 f"Mean fraction per animal during defeat (chance 1%), {fmt_p(r.p_excited)}, q = {r.q_excited:.3f}.", 186 "significant" if r.q_excited < 0.05 else "trend" if r.p_excited < 0.05 else "null") 187 def kind(src: str, d: pd.DataFrame) -> str: 188 p_c, p_w = comb.get(src, np.nan), grp_w.get(src, np.nan) 189 if len(d) < 4: 190 return "caution" 191 if np.isfinite(p_c) and p_c < 0.05: 192 return "significant" if np.isfinite(p_w) and p_w < 0.05 else "trend" 193 return "null" 194 195 src = "Physical interaction (SD)" 196 d = ov[ov.source == src].dropna(subset=["p_shift"]) 197 self.highlight("Defeat → SIT reactivation", f"×{d.overlap.sum() / max(d.null_overlap_mean.sum(), 1e-9):.2f} vs null", 198 f"Overlap between defeat-excited and SIT-social neurons relative to shifted-label SIT ensembles " 199 f"({len(d)} informative animal{'s' if len(d) != 1 else ''}; Fisher-combined {fmt_p(comb.get(src))}; " 200 f"Wilcoxon {fmt_p(grp_w.get(src))}).", 201 kind(src, d)) 202 src2 = "Non-physical interaction (BL+SS)" 203 d2 = ov[ov.source == src2].dropna(subset=["p_shift"]) 204 self.highlight("Non-physical → SIT reactivation", f"×{d2.overlap.sum() / max(d2.null_overlap_mean.sum(), 1e-9):.2f} vs null", 205 f"Overlap between neurons excited by non-physical interaction (BL+SS) and SIT-social neurons " 206 f"(Fisher-combined {fmt_p(comb.get(src2))}; Wilcoxon {fmt_p(grp_w.get(src2))}; " 207 f"{int((d2.p_shift < 0.05).sum())}/{len(d2)} animals individually p < 0.05).", 208 kind(src2, d2)) 209 210 n_sit = int(ov.drop_duplicates("subject").SIT_social_ensemble.gt(0).sum()) 211 n_sit_rec = int(ov.drop_duplicates("subject").subject.isin( 212 [s for s in ds.subjects if any(True for _ in ds.iter_sessions(s, ["SIT_CD1"]))]).sum()) 213 testable = min(len(d), len(d2)) >= 4 214 power_note = ("" if testable else 215 f" Only {n_sit} of {n_sit_rec} animals with a SIT recording had any SIT-social neuron at p < {ALPHA}, " 216 f"and {len(d)} (defeat) and {len(d2)} (non-physical) animals had non-empty ensembles in both " 217 "contexts. Reactivation therefore cannot be tested in this cohort, and the values below are " 218 "descriptive.") 219 results = f""" 220**Behavior tuning during defeat.** The largest fractions of selectively excited neurons in SD sessions were 221{', '.join(f"{r.behavior.lower()} ({r.excited_mean:.1%})" for r in sd.head(3).itertuples())}, against a 1% 222false-positive expectation (Table 1, Figure 1). {int((fstats.q_excited < 0.05).sum())} of {len(fstats)} behavior × context 223combinations showed above-chance excited fractions after FDR correction, and {int((fstats.q_inhibited < 0.05).sum())} 224showed above-chance inhibited fractions. 225 226**Reactivation in the SIT.**{power_note} Across {len(d)} informative animal{'s' if len(d) != 1 else ''}, {int(d.overlap.sum())} neuron(s) were 227in both the defeat (physical interaction) ensemble and the SIT social ensemble, against {d.null_overlap_mean.sum():.1f} 228under the shifted-label null and {d.expected.sum():.1f} under exchangeable neurons (Fisher-combined shift 229{fmt_p(comb.get(src))}; hypergeometric {fmt_p(comb_hyper.get(src))}; Wilcoxon of overlap − null {fmt_p(grp_w.get(src))}). 230For the non-physical ensemble, {int(d2.overlap.sum())} neuron(s) overlapped against {d2.null_overlap_mean.sum():.1f} under 231the shifted-label null (Fisher-combined {fmt_p(comb.get(src2))}; hypergeometric {fmt_p(comb_hyper.get(src2))}; 232Wilcoxon {fmt_p(grp_w.get(src2))}; Table 2, Figure 3). Fisher's method is sensitive to a single strongly 233overlapping animal, so a combined effect counts as robust only when the across-animal Wilcoxon agrees. 234""" 235 return {"results": results, "parameters": {"n_shifts": N_SHIFTS, "alpha": ALPHA, "min_frames": 60, 236 "overlap_null_shifts": N_OVERLAP_NULL}} 237 238 def _fig_fractions(self, frac: pd.DataFrame, fstats: pd.DataFrame) -> None: 239 ctxs = ["SD", "BL+SS"] 240 fig, axes = plt.subplots(1, 2, figsize=(11, 3.4), gridspec_kw={"width_ratios": [1.5, 1]}) 241 for ax, c in zip(axes, ctxs): 242 s = fstats[fstats.context == c].sort_values("excited_mean", ascending=False) 243 x = np.arange(len(s)) 244 ax.bar(x - 0.2, s.excited_mean, 0.4, color="#b5523b", label="excited") 245 ax.bar(x + 0.2, -s.inhibited_mean, 0.4, color="#3b7d6e", label="inhibited") 246 for i, r in enumerate(s.itertuples()): 247 d = frac[(frac.context == c) & (frac.behavior == r.behavior)] 248 ax.scatter(np.full(len(d), i - 0.2), d.excited, s=6, c="k") 249 ax.scatter(np.full(len(d), i + 0.2), -d.inhibited, s=6, c="k") 250 ax.text(i - 0.2, d.excited.max() + 0.01, star(r.p_excited), ha="center", fontsize=7) 251 ax.axhline(ALPHA, color="k", ls=":", lw=0.7) 252 ax.axhline(-ALPHA, color="k", ls=":", lw=0.7) 253 ax.set_xticks(x, s.behavior, rotation=45, ha="right", fontsize=7) 254 ax.set_ylabel("fraction of neurons (inhibited < 0)") 255 ax.set_title(f"{c} sessions") 256 axes[0].legend(fontsize=7) 257 self.figure(fig, "tuned_fractions", "Behavior-tuned neurons", 258 "Fraction of neurons significantly excited (up) or inhibited (down) by each behavior; dots are animals; " 259 "dotted lines mark the 1% false-positive rate.") 260 261 def _fig_heatmap(self, tun: pd.DataFrame) -> None: 262 sid = tun.subject.iloc[0] 263 d = tun[(tun.subject == sid) & (tun.context == "SD")].pivot(index="neuron", columns="behavior", values="z") 264 order = d.idxmax(axis=1).map({b: i for i, b in enumerate(d.columns)}).sort_values().index 265 d = d.loc[order] 266 fig, ax = plt.subplots(figsize=(5.5, 5)) 267 im = ax.imshow(d.to_numpy(), aspect="auto", cmap="RdBu_r", vmin=-5, vmax=5) 268 ax.set_xticks(range(d.shape[1]), d.columns, rotation=45, ha="right", fontsize=7) 269 ax.set_ylabel("neuron (sorted by preferred behavior)") 270 ax.set_title(f"Animal {sid}: selectivity z during defeat") 271 fig.colorbar(im, ax=ax, label="selectivity z") 272 self.figure(fig, "selectivity_heatmap", "Single-neuron selectivity map", 273 "Selectivity z (vs circular-shift null) of every neuron for each defeat-session behavior in one animal, " 274 "sorted by preferred behavior.") 275 276 def _fig_overlap(self, ov: pd.DataFrame, comb: dict) -> None: 277 srcs = list(ov.source.unique()) 278 fig, axes = plt.subplots(1, len(srcs), figsize=(4.5 * len(srcs), 3.2)) 279 for ax, src in zip(np.atleast_1d(axes), srcs): 280 d = ov[(ov.source == src) & ov.p_shift.notna()] 281 x = np.arange(len(d)) 282 ax.bar(x - 0.27, d.overlap, 0.27, color="#4a5d8a", label="observed") 283 ax.bar(x, d.null_overlap_mean, 0.27, color="0.55", label="shifted-label null") 284 ax.bar(x + 0.27, d.expected, 0.27, color="0.8", label="hypergeometric") 285 ax.set_xticks(x, d.subject.astype(str), fontsize=7) 286 ax.set_ylabel("neurons in both ensembles") 287 ax.set_title(f"{src}\nvs SIT social ({fmt_p(comb.get(src))})", fontsize=8) 288 np.atleast_1d(axes)[0].legend(fontsize=7) 289 self.figure(fig, "ensemble_reactivation", "Ensemble reactivation in the SIT", 290 "Number of neurons belonging to both the source ensemble and the SIT CD-1 social-engagement ensemble, " 291 "observed vs the mean of the shifted-label null and the hypergeometric expectation, per animal.")
Study S04
Functional network topology across CSDS phases
Neuron-level co-activity graphs, null-model thresholding and graph-theoretic contrasts.
1"""S04 — Functional network topology across CSDS phases.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import networkx as nx 7import numpy as np 8import pandas as pd 9 10from ..data import CONDITION_COLORS, CORE_CONDITIONS 11from ..networks import louvain, membership, proportional 12from ..stats import animal_means, fdr_bh, fe_model, fmt_p, friedman, paired 13from ..stats.compare import day_trend 14from ..viz import day_lines, paired_conditions, star 15from .base import Study 16from .shared import session_networks 17 18METRICS = { 19 "mean_r": "mean pairwise r", 20 "sig_density": "significant-edge density", 21 "prop_clustering": "clustering coefficient", 22 "prop_efficiency": "global efficiency", 23 "prop_modularity": "modularity Q", 24 "prop_modularity_excess": "Q − null Q", 25 "prop_assortativity": "degree assortativity", 26 "prop_small_world": "small-world σ", 27} 28 29 30class Topology(Study): 31 id = "s04" 32 number = 4 33 title = "Functional network topology across CSDS phases" 34 short = "Neuron-level co-activity graphs, null-model thresholding and graph-theoretic contrasts." 35 question = ("Does the functional network formed by co-active neurons reorganise during defeat and post-defeat " 36 "sensory contact relative to baseline, and does topology change as defeats accumulate?") 37 background = """ 38Network neuroscience treats neurons as nodes and statistical dependencies between their activity as edges 39(Bullmore & Sporns 2009; Rubinov & Sporns 2010). In miniscope data, co-activation graphs built from pairwise 40correlations, thresholded against shuffled surrogates, reveal stress effects. Repeated stress increases the 41modularity and degree assortativity of dorsal CA1 networks before coding deficits appear (Transl Psychiatry 422022). Chronic stress also increases between-module cortical connectivity in mesoscale calcium imaging, 43reversible by ketamine (Neuropsychopharmacology 2022). At the systems level, resilience and susceptibility to 44CSDS map onto distinct functional connectivity patterns (Mol Psychiatry 2025; Hultman et al. 2018). CaGraph 45(Porubsky et al.) formalises this approach for calcium imaging. 46""" 47 methods = """ 48**Nodes and edges.** Each tracked neuron is a node. For each session, the edge weight between neurons *i* and 49*j* is the Pearson correlation of their inferred spike-probability time series at the native frame rate 50(150 ms bins, ≈2,000 frames per BL/SD/SS session). 51 52**Two complementary thresholds.** 531. *Statistical graph.* Each neuron was circularly shifted independently (shift 10–90% of the session; 50 54 surrogates). This preserves each neuron's rate and autocorrelation and destroys co-activity. Edges exceed the 55 99th percentile of the pooled surrogate correlation distribution. Its density (fraction of significant pairs) 56 measures how much genuine co-activity exists. 572. *Proportional graph.* The strongest 10% of positive correlations. Fixing density removes the trivial 58 dependence of topology on edge count (van Wijk et al. 2010), so this graph is used for all topological 59 measures. 60 61**Graph measures (binary, undirected).** Clustering coefficient; global efficiency (mean inverse shortest path); 62characteristic path length (largest component); Louvain modularity Q (resolution 1; Blondel et al. 2008); Q in 63excess of Q from 3 circular-shift surrogate graphs at the same density; degree assortativity; and small-world 64σ = (C/C_rand)/(L/L_rand) with Erdős–Rényi approximations (Humphries & Gurney 2008). 65 66**Statistics.** Primary contrasts use days 1–9, day 7 excluded, restricted to days on which the animal has all of 67BL, SD and SS: per-animal means compared with a Friedman test and exact Wilcoxon signed-rank tests (SD vs BL, 68SS vs BL, SS vs SD). Session-level OLS with animal × day block fixed effects gives within-day effect estimates; 69adding the session mean event rate as a covariate shows how much of each effect firing rate accounts for. 70These session-level p-values are descriptive (sessions of one animal are not independent). Day trends within 71SD and BL are tested on per-animal slopes (exact Wilcoxon). P-values were FDR-corrected across all metrics × 72contrasts, and separately across day trends. 73 74**Sensitivity analyses.** (i) ΔF/F instead of spike probability; (ii) global signal regression: each neuron's 75activity is residualised on the population mean before correlation, which removes shared population-wide 76fluctuations (Murphy & Fox 2017); (iii) SIT CD-1 vs empty phase. Session assignment for the two short 77recordings is the lab's (6597 lacks SS on day 9; 6669 lacks SIT), so those animals stay in the primary set. 78""" 79 limitations = """ 80* Pearson correlations of calcium-derived signals reflect shared input and common modulation as well as 81 synaptic coupling. Edges are *functional*, not anatomical. 82* Correlations rise with firing rate (de la Rocha et al. 2007). The rate-covariate model addresses this 83 partially. 84* Mean pairwise correlations are high (≈0.4–0.7), consistent with a strong shared component (global 85 co-fluctuation, residual neuropil or motion). Proportional graphs keep the strongest edges, which partly 86 mitigates this. The global-signal-regressed sensitivity analysis tests whether effects survive removing it. 87 Global signal regression can itself introduce negative correlations, so neither version is definitive. 88* The proportional threshold (10%) is a choice. Effects consistent across the statistical and proportional 89 graphs are more trustworthy. 90* n = 6 animals, no unstressed control group: condition effects are within-animal contrasts of contexts, not 91 stress-vs-control comparisons. 92* BL, SD and SS are always recorded in that order within a day, so condition contrasts are confounded with 93 position in the recording. 94* Small-world σ uses Erdős–Rényi approximations (C_rand = k/(n−1), L_rand = ln n / ln k), with C over all nodes 95 and L over the largest component. It is a relative index, not an absolute small-world test. 96""" 97 references = [ 98 "Bullmore E. & Sporns O. (2009) Complex brain networks: graph theoretical analysis of structural and functional systems. Nat Rev Neurosci 10:186–198.", 99 "Murphy K. & Fox M.D. (2017) Towards a consensus regarding global signal regression for resting state functional connectivity MRI. NeuroImage 154:169–173.", 100 "Rubinov M. & Sporns O. (2010) Complex network measures of brain connectivity: uses and interpretations. NeuroImage 52:1059–1069.", 101 "Repeated stress exposure leads to structural synaptic instability prior to disorganization of hippocampal coding (2022) Transl Psychiatry. doi:10.1038/s41398-022-02107-5", 102 "Selective effects of acute and chronic stress on cortical functional connectivity and reversal with ketamine (2022) Neuropsychopharmacology. doi:10.1038/s41386-022-01506-y", 103 "Resting-state fMRI reveals altered functional connectivity associated with resilience and susceptibility to CSDS (2025) Mol Psychiatry. doi:10.1038/s41380-025-02897-2", 104 "Hultman R. et al. (2018) Brain-wide electrical spatiotemporal dynamics encode depression vulnerability. Cell 173:166–180.", 105 "Blondel V.D. et al. (2008) Fast unfolding of communities in large networks. J Stat Mech P10008.", 106 "van Wijk B.C.M., Stam C.J. & Daffertshofer A. (2010) Comparing brain networks of different size and connectivity density using graph theory. PLoS One 5:e13701.", 107 "Humphries M.D. & Gurney K. (2008) Network 'small-world-ness': a quantitative method for determining canonical network equivalence. PLoS One 3:e0002051.", 108 "Porubsky V. et al. CaGraph: graph theory analysis of calcium imaging data. github.com/vporubsky/CaGraph", 109 ] 110 111 def run(self) -> dict: 112 df, fc = session_networks(self.ctx, "spikes") 113 df_dff, _ = session_networks(self.ctx, "dff") 114 df_gsr, _ = session_networks(self.ctx, "spikes_gsr") 115 core_gsr = df_gsr[df_gsr.condition.isin(CORE_CONDITIONS) & (df_gsr.day <= 9)] 116 core = df[df.condition.isin(CORE_CONDITIONS) & (df.day <= 9)] 117 core_dff = df_dff[df_dff.condition.isin(CORE_CONDITIONS) & (df_dff.day <= 9)] 118 119 rows, fe_rows, fe_rate, pivots = [], [], [], {} 120 for m in METRICS: 121 pv = animal_means(core, m, CORE_CONDITIONS) 122 pivots[m] = pv 123 fr = friedman(pv) 124 for a, b in [("BL", "SD"), ("BL", "SS"), ("SD", "SS")]: 125 rows.append({"metric": m, **paired(pv, a, b), **fr}) 126 fe_rows.append(fe_model(core, m, CORE_CONDITIONS)) 127 fe_rate.append(fe_model(core, m, CORE_CONDITIONS, covariates=["mean_rate"]).assign(model="+ rate covariate")) 128 st = pd.DataFrame(rows) 129 st["q_fdr"] = fdr_bh(st.p) 130 fe = pd.concat(fe_rows).assign(model="condition + animal") 131 fe = pd.concat([fe, pd.concat(fe_rate)]) 132 133 sens_rows = [] 134 for m in METRICS: 135 for label, d in [("ΔF/F signal", core_dff), ("global signal regressed", core_gsr)]: 136 pv = animal_means(d, m, CORE_CONDITIONS) 137 sens_rows.append({"metric": m, "analysis": label, **paired(pv, "BL", "SD")}) 138 sit = animal_means(df, m, ["SIT_NoCD1", "SIT_CD1"]) 139 if len(sit) >= 3: 140 sens_rows.append({"metric": m, "analysis": "SIT CD1 vs empty", **paired(sit, "SIT_NoCD1", "SIT_CD1")}) 141 sens = pd.DataFrame(sens_rows) 142 trend = pd.DataFrame([day_trend(core, m, c) for m in METRICS for c in ["SD", "BL"]]) 143 trend["q_fdr"] = fdr_bh(trend.p) 144 145 self._fig_example(fc, df) 146 self._fig_metrics(pivots, st) 147 self._fig_forest(st, sens) 148 self._fig_days(core) 149 150 self.table(st[["metric", "contrast", "n", "median_diff", "dz", "n_increase", "p", "q_fdr", "friedman_chi2", "friedman_p"]], 151 "condition_tests", "Network topology: condition contrasts (days 1–9 without day 7)", 152 "Exact Wilcoxon on per-animal means; Friedman across BL/SD/SS; q = BH-FDR over metrics × contrasts.", show=30) 153 self.table(fe, "fixed_effects", "Session-level models with and without a firing-rate covariate", 154 "OLS with animal × day fixed effects; the rate covariate shows how much of each change activity explains. " 155 "p_session is descriptive.", show=40) 156 self.table(sens, "sensitivity", "Sensitivity analyses (SD vs BL unless stated)", 157 "Same contrast with ΔF/F, after global signal regression, and SIT CD-1 vs empty.", show=30) 158 self.table(trend, "day_trends", "Day trends of topology", 159 "slope_per_day = pooled within-animal slope; p = exact Wilcoxon of per-animal slopes.", show=20) 160 self.table(df, "session_metrics", "All session-level network measures", show=12) 161 162 sdbl = st[st.contrast == "SD vs BL"].sort_values("p") 163 for _, r in sdbl.head(3).iterrows(): 164 kind = "significant" if r.q_fdr < 0.05 else "trend" if r.p < 0.05 else "null" 165 self.highlight(f"{METRICS[r.metric]} (SD vs BL)", f"{r.median_diff:+.3g}", 166 f"Median paired change; {r.n_increase}/{r.n} animals increased; {fmt_p(r.p)}, q = {r.q_fdr:.3f}; " 167 f"Friedman {fmt_p(r.friedman_p)}.", kind) 168 robust = [] 169 for m in METRICS: 170 base = st[(st.metric == m) & (st.contrast == "SD vs BL")].iloc[0] 171 s = sens[(sens.metric == m) & sens.analysis.isin(["ΔF/F signal", "global signal regressed"])] 172 if base.p < 0.05 and (np.sign(s.median_diff) == np.sign(base.median_diff)).all(): 173 robust.append(METRICS[m]) 174 self.highlight("Robust to sensitivity analyses", f"{len(robust)} metric(s)", 175 ("Direction preserved with ΔF/F and after global signal regression: " + ", ".join(robust)) if robust 176 else "No SD vs BL effect was both nominally significant and direction-consistent across sensitivity analyses.", 177 "significant" if robust else "null") 178 179 fr = st.drop_duplicates("metric").set_index("metric") 180 results = f""" 181**Co-activity.** The statistical graphs show how much co-activity exceeds the surrogate null. Significant-edge 182density averaged {', '.join(f"{c} {v:.3f}" for c, v in pivots['sig_density'].mean().items())} (Friedman 183{fmt_p(fr.loc['sig_density','friedman_p'])}), and mean pairwise correlation averaged 184{', '.join(f"{c} {v:.3f}" for c, v in pivots['mean_r'].mean().items())} (Friedman {fmt_p(fr.loc['mean_r','friedman_p'])}). 185Pairwise correlations are high overall, which indicates a strong shared (population-wide) component in the signal. 186 187**Topology at fixed density.** The SD vs BL contrasts with the smallest p-values were 188{'; '.join(f"{METRICS[r.metric]}: median Δ = {r.median_diff:+.3g} ({r.n_increase}/{r.n} animals up, {fmt_p(r.p)}, q = {r.q_fdr:.3f})" for r in sdbl.head(3).itertuples())}. 189{int((st.q_fdr < 0.05).sum())} of {len(st)} metric × contrast tests survived FDR correction. All values are in 190Table 1 and Figures 2–3. 191 192**Confounds and robustness.** Table 2 repeats the fixed-effect models with the session firing rate as a 193covariate; condition terms that shrink markedly are rate-driven. Direction-consistent SD vs BL effects across 194signal (ΔF/F) and global-signal-regression analyses: {', '.join(robust) if robust else 'none'} (Table 3, Figure 3). 195 196**Across defeat days.** {int((trend.q_fdr < 0.05).sum())} of {len(trend)} day trends survived FDR correction 197(Table 4, Figure 4). 198""" 199 return {"results": results, "parameters": {**self.cfg["networks"], "signal": "CASCADE spike probability"}} 200 201 def _fig_example(self, fc: dict, df: pd.DataFrame) -> None: 202 sid, day = 6675, 5 203 keys = [f"{sid}_{c}_D{day}" for c in CORE_CONDITIONS] 204 base = fc[keys[0]] 205 a0 = proportional(base, self.cfg["networks"]["proportional_density"]) 206 order = np.argsort(membership(louvain(a0, seed=0), base.shape[0]), kind="stable") 207 fig, axes = plt.subplots(2, 3, figsize=(12, 7.6)) 208 off = ~np.eye(base.shape[0], dtype=bool) 209 lo, hi = np.percentile(np.concatenate([fc[k][off] for k in keys]), [2, 98]) 210 for j, (k, c) in enumerate(zip(keys, CORE_CONDITIONS)): 211 m = fc[k][np.ix_(order, order)].copy() 212 np.fill_diagonal(m, np.nan) 213 im = axes[0, j].imshow(m, cmap="magma", vmin=lo, vmax=hi) 214 axes[0, j].set_title(f"{c} · day {day}", fontsize=10) 215 axes[0, j].set_xticks([]) 216 axes[0, j].set_yticks([]) 217 a = proportional(fc[k], self.cfg["networks"]["proportional_density"]) 218 g = nx.from_numpy_array(a) 219 g.remove_nodes_from([n for n, d in dict(g.degree()).items() if d == 0]) 220 comms = louvain(nx.to_numpy_array(g), seed=0) if g.number_of_edges() else [] 221 col = {} 222 cmap = plt.get_cmap("tab10") 223 nodes = list(g.nodes) 224 for ci, cset in enumerate(comms): 225 for idx in cset: 226 col[nodes[idx]] = cmap(ci % 10) 227 pos = nx.spring_layout(g, seed=1) 228 nx.draw_networkx_edges(g, pos, ax=axes[1, j], alpha=0.15, width=0.5) 229 nx.draw_networkx_nodes(g, pos, ax=axes[1, j], node_size=14, node_color=[col.get(n, "0.5") for n in g.nodes]) 230 row = df[df.key == k].iloc[0] 231 axes[1, j].set_title(f"Q = {row.prop_modularity:.2f}, Eglob = {row.prop_efficiency:.2f}") 232 axes[1, j].axis("off") 233 fig.colorbar(im, ax=axes[0], shrink=0.7, label="Pearson r") 234 fig.suptitle(f"Animal {sid}: FC matrices ordered by baseline modules (top) and 10%-density graphs (bottom)", fontsize=10) 235 self.figure(fig, "example_networks", "Example functional networks", 236 "Animal 6675, CSDS day 5. Top: neuron × neuron correlation matrices, ordered by the baseline Louvain modules. " 237 "Bottom: 10%-density graphs (isolated nodes hidden), coloured by Louvain module.") 238 239 def _fig_metrics(self, pivots: dict, st: pd.DataFrame) -> None: 240 fig, axes = plt.subplots(2, 4, figsize=(13, 6.2)) 241 for ax, (m, label) in zip(axes.ravel(), METRICS.items()): 242 s = st[st.metric == m].set_index("contrast") 243 paired_conditions(ax, pivots[m], label, label, 244 {("BL", "SD"): star(s.loc["SD vs BL", "p"]), ("BL", "SS"): star(s.loc["SS vs BL", "p"])}) 245 fig.tight_layout() 246 self.figure(fig, "topology_by_condition", "Network measures by CSDS phase", 247 "Per-animal means (grey) and group mean ± SEM across BL, SD and SS (days 1–9 without day 7). Stars: exact Wilcoxon vs BL, uncorrected (* p < 0.05; with n = 6 the minimum two-sided p is 0.031). See the tables for FDR-corrected q.") 248 249 def _fig_forest(self, st: pd.DataFrame, sens: pd.DataFrame) -> None: 250 d = st[st.contrast.isin(["SD vs BL", "SS vs BL"])] 251 fig, ax = plt.subplots(figsize=(7.5, 5)) 252 y = 0 253 ticks, labels = [], [] 254 for m, label in METRICS.items(): 255 for contrast, color in [("SD vs BL", CONDITION_COLORS["SD"]), ("SS vs BL", CONDITION_COLORS["SS"])]: 256 r = d[(d.metric == m) & (d.contrast == contrast)].iloc[0] 257 ax.scatter(r.dz, y, color=color, s=40, zorder=3, label=contrast if m == "mean_r" else None) 258 ax.text(3.6, y, star(r.p), va="center", fontsize=7) 259 y -= 0.35 260 for analysis, mk in [("ΔF/F signal", "^"), ("global signal regressed", "D")]: 261 r = sens[(sens.metric == m) & (sens.analysis == analysis)] 262 if len(r): 263 ax.scatter(r.dz.iloc[0], y + 0.7, marker=mk, facecolor="none", edgecolor=CONDITION_COLORS["SD"], s=30, 264 label=f"SD vs BL, {analysis}" if m == "mean_r" else None) 265 ticks.append(y + 0.35) 266 labels.append(label) 267 y -= 0.6 268 ax.axvline(0, color="k", lw=0.7) 269 ax.set_yticks(ticks, labels) 270 ax.set_xlabel("paired effect size dz (mean diff / SD of diffs)") 271 ax.set_xlim(-4, 4) 272 ax.legend(fontsize=7, loc="lower left") 273 self.figure(fig, "effect_sizes", "Effect sizes and robustness", 274 "Paired effect size dz per network measure for SD vs BL and SS vs BL (filled), with SD vs BL repeated using " 275 "ΔF/F (triangles) and after global signal regression (diamonds). " 276 "Stars: exact Wilcoxon p, uncorrected.") 277 278 def _fig_days(self, core: pd.DataFrame) -> None: 279 ms = ["mean_r", "prop_modularity", "prop_efficiency", "prop_clustering"] 280 fig, axes = plt.subplots(1, 4, figsize=(14, 3.2)) 281 for ax, m in zip(axes, ms): 282 day_lines(ax, core, m, CORE_CONDITIONS, METRICS[m], METRICS[m]) 283 fig.tight_layout() 284 self.figure(fig, "topology_by_day", "Network measures across CSDS days", 285 "Mean ± SEM across animals per condition and day.")
Study S05
Communities, hubs and rich-club organisation
Ensemble (module) structure, connector hubs, rich clubs and their stability across days.
1"""S05 — Community structure, hubs and rich-club organisation.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import numpy as np 7import pandas as pd 8from sklearn.metrics import adjusted_rand_score 9 10from ..data import CONDITION_COLORS, CORE_CONDITIONS 11from ..networks import ( 12 louvain, 13 membership, 14 participation_within_z, 15 proportional, 16 rich_club_normalized, 17) 18from ..stats import animal_means, fdr_bh, fmt_p, friedman, paired 19from ..viz import paired_conditions, star 20from .base import Study 21from .shared import session_networks 22 23 24class Communities(Study): 25 id = "s05" 26 number = 5 27 title = "Communities, hubs and rich-club organisation" 28 short = "Ensemble (module) structure, connector hubs, rich clubs and their stability across days." 29 question = ("Are neurons organised into stable co-active modules and hub neurons, and does defeat change hub roles, " 30 "rich-club organisation or the day-to-day stability of modules?") 31 background = """ 32Modules in neuronal co-activity graphs correspond to cell assemblies, groups of neurons that fire together 33(Hebb 1949; Carrillo-Reid et al. 2016). Within modular networks, hub nodes can be classified by their 34within-module degree *z* and participation coefficient *P* into provincial and connector hubs (Guimerà & Amaral 352005). Rich-club organisation, densely interconnected high-degree nodes, is a recurrent feature of neural 36networks (van den Heuvel & Sporns 2011) and has been reported in cortical microcircuits (Nigam et al. 2016). 37Stress-related hippocampal network reorganisation is driven disproportionately by highly active neurons 38(Transl Psychiatry 2022), which motivates asking whether hubs change under defeat. Day-to-day stability of 39modules connects to work on representational drift (Ziv et al. 2013). 40""" 41 methods = """ 42**Graphs.** 10%-density proportional graphs per session (as in S04). 43 44**Modules.** Louvain community detection (resolution 1, fixed seed). Modularity was compared with 45circular-shift surrogates at equal density (`Q − null Q`, from S04). 46 47**Cartography.** For every neuron: within-module degree z and participation coefficient P (Guimerà & Amaral 482005). *Hubs* are neurons in the top 10% of degree. Of these, *connector hubs* have P > 0.3 and 49*provincial hubs* P ≤ 0.3. Session summaries: mean P of hubs, fraction of hubs that are connectors. 50 51**Rich club.** Rich-club coefficient φ(k) was normalised by the mean of 10 degree-preserving rewired graphs 52(double-edge swaps). The session index is the mean normalised φ over k in the 70th–90th percentile of the 53degree distribution (> 1 indicates a rich club). 54 55**Stability.** Because the same neurons are tracked across sessions, partitions are directly comparable. 56- *Module stability*: adjusted Rand index (ARI) between Louvain partitions of consecutive same-condition sessions 57 (e.g. SD day 1 vs SD day 2), compared with ARI after permuting neuron labels (200 permutations). 58- *Hub persistence*: Jaccard overlap of hub sets between consecutive same-condition sessions vs the 59 expectation for random sets of equal size. 60 61**Statistics.** Per-animal means over days that contain all of BL, SD and SS (days 1–9 without day 7) were 62compared with Friedman and exact Wilcoxon tests, FDR-corrected across metrics × contrasts. Module and hub 63stability against their nulls were tested on per-animal means with one-sided exact Wilcoxon tests. 64""" 65 limitations = """ 66* Louvain is stochastic. A single seed was used per session; consensus clustering would reduce variability. 67* BL, SD and SS are recorded in a fixed order within each day, so condition contrasts are confounded with 68 position in the recording. 69* The hub definition (top 10% degree, P > 0.3) follows common practice but is a choice. The direction of 70 effects should be checked at other cut-offs. 71* Rich-club normalisation with 10 rewired graphs per session gives coarse estimates. 72""" 73 references = [ 74 "Guimerà R. & Amaral L.A.N. (2005) Functional cartography of complex metabolic networks. Nature 433:895–900.", 75 "van den Heuvel M.P. & Sporns O. (2011) Rich-club organization of the human connectome. J Neurosci 31:15775–15786.", 76 "Colizza V. et al. (2006) Detecting rich-club ordering in complex networks. Nat Phys 2:110–115.", 77 "Nigam S. et al. (2016) Rich-club organization in effective connectivity among cortical neurons. J Neurosci 36:670–684.", 78 "Carrillo-Reid L. et al. (2016) Imprinting and recalling cortical ensembles. Science 353:691–694.", 79 "Ziv Y. et al. (2013) Long-term dynamics of CA1 hippocampal place codes. Nat Neurosci 16:264–266.", 80 "Hubert L. & Arabie P. (1985) Comparing partitions. J Classif 2:193–218.", 81 ] 82 83 def run(self) -> dict: 84 df, fc = session_networks(self.ctx, "spikes") 85 dens = self.cfg["networks"]["proportional_density"] 86 rng = np.random.default_rng(2) 87 rows, parts, hubs, cart = [], {}, {}, [] 88 rc_curves = [] 89 for r in df.itertuples(): 90 c = fc[r.key] 91 a = proportional(c, dens) 92 comms = louvain(a, seed=0) 93 mem = membership(comms, a.shape[0]) 94 p, z = participation_within_z(a, comms) 95 deg = a.sum(1) 96 hub = deg >= np.quantile(deg, 0.9) 97 hub &= deg > 0 98 parts[r.key], hubs[r.key] = mem, set(np.flatnonzero(hub)) 99 ks, ratio = rich_club_normalized(a, n_rand=10, seed=int(rng.integers(1 << 31))) 100 if len(ks): 101 lo, hi = np.quantile(deg, [0.7, 0.9]) 102 sel = (ks >= lo) & (ks <= hi) 103 rc = float(np.nanmean(ratio[sel])) if sel.any() else np.nan 104 if r.condition in CORE_CONDITIONS and r.day <= 9: 105 kn = ks / max(deg.max(), 1) 106 rc_curves.append(pd.DataFrame({"subject": r.subject, "condition": r.condition, "k_norm": kn, "ratio": ratio})) 107 else: 108 rc = np.nan 109 rows.append({"key": r.key, "subject": r.subject, "condition": r.condition, "day": r.day, "mapping": r.mapping, 110 "modularity_excess": r.prop_modularity_excess, "n_modules": r.prop_n_modules, 111 "hub_participation": float(p[hub].mean()) if hub.any() else np.nan, 112 "connector_hub_fraction": float((p[hub] > 0.3).mean()) if hub.any() else np.nan, 113 "mean_participation": float(p[deg > 0].mean()) if (deg > 0).any() else np.nan, 114 "rich_club": rc}) 115 if r.subject == 6675 and r.day == 5 and r.condition in CORE_CONDITIONS: 116 cart.append(pd.DataFrame({"condition": r.condition, "P": p, "z": z, "hub": hub})) 117 m = pd.DataFrame(rows) 118 119 core = m[m.condition.isin(CORE_CONDITIONS) & (m.day <= 9)] 120 metrics = {"modularity_excess": "Q − null Q", "connector_hub_fraction": "connector-hub fraction", 121 "hub_participation": "hub participation P", "rich_club": "normalised rich club"} 122 st_rows, pivots = [], {} 123 for k in metrics: 124 pv = animal_means(core, k, CORE_CONDITIONS) 125 pivots[k] = pv 126 fr = friedman(pv) 127 for a_, b_ in [("BL", "SD"), ("BL", "SS"), ("SD", "SS")]: 128 st_rows.append({"metric": k, **paired(pv, a_, b_), **fr}) 129 st = pd.DataFrame(st_rows) 130 st["q_fdr"] = fdr_bh(st.p) 131 132 stab_rows = [] 133 for sid in self.ds.subjects: 134 for cond in CORE_CONDITIONS: 135 d = m[(m.subject == sid) & (m.condition == cond) & (m.day <= 9)].sort_values("day") 136 keys = d.key.tolist() 137 for k1, k2 in zip(keys[:-1], keys[1:]): 138 a1, a2 = parts[k1], parts[k2] 139 ari = adjusted_rand_score(a1, a2) 140 null = [adjusted_rand_score(a1, rng.permutation(a2)) for _ in range(200)] 141 h1, h2 = hubs[k1], hubs[k2] 142 n = len(a1) 143 jac = len(h1 & h2) / max(len(h1 | h2), 1) 144 e_int = len(h1) * len(h2) / n 145 jac_exp = e_int / max(len(h1) + len(h2) - e_int, 1e-9) 146 stab_rows.append({"subject": sid, "condition": cond, "pair": f"{k1}→{k2}", "ARI": ari, 147 "ARI_null_mean": float(np.mean(null)), "ARI_z": (ari - np.mean(null)) / (np.std(null) + 1e-12), 148 "hub_jaccard": jac, "hub_jaccard_expected": jac_exp}) 149 stab = pd.DataFrame(stab_rows) 150 stab_animal = stab.groupby(["subject", "condition"])[["ARI", "ARI_null_mean", "hub_jaccard", "hub_jaccard_expected"]].mean().reset_index() 151 from scipy import stats as sst 152 ari_vs_null = sst.wilcoxon(stab_animal.groupby("subject").ARI.mean() - stab_animal.groupby("subject").ARI_null_mean.mean(), 153 alternative="greater", method="exact").pvalue 154 hub_vs_null = sst.wilcoxon(stab_animal.groupby("subject").hub_jaccard.mean() - stab_animal.groupby("subject").hub_jaccard_expected.mean(), 155 alternative="greater", method="exact").pvalue 156 ari_pv = stab_animal.pivot(index="subject", columns="condition", values="ARI")[CORE_CONDITIONS] 157 ari_cond = friedman(ari_pv) 158 159 self._fig_cartography(pd.concat(cart) if cart else pd.DataFrame()) 160 self._fig_metrics(pivots, st) 161 self._fig_richclub(pd.concat(rc_curves) if rc_curves else pd.DataFrame()) 162 self._fig_stability(stab_animal, ari_pv) 163 164 self.table(st[["metric", "contrast", "n", "median_diff", "dz", "p", "q_fdr", "friedman_p"]], "condition_tests", 165 "Community and hub measures: condition contrasts", "Exact Wilcoxon on per-animal means, days 1–9, day 7 excluded; q = BH-FDR.") 166 self.table(stab_animal, "stability", "Day-to-day stability of modules and hubs", 167 "Mean ARI between consecutive same-condition partitions and hub-set Jaccard overlap, with null expectations.") 168 self.table(m, "session_community_metrics", "Session-level community metrics", show=12) 169 170 mean_ari = stab_animal.ARI.mean() 171 self.highlight("Modules persist across days", f"ARI = {mean_ari:.3f}", 172 f"Consecutive same-condition partitions agree above label-permutation null " 173 f"({stab_animal.ARI_null_mean.mean():.3f}), {fmt_p(ari_vs_null)}.", 174 "significant" if ari_vs_null < 0.05 else "null") 175 self.highlight("Hub persistence", f"Jaccard {stab_animal.hub_jaccard.mean():.2f}", 176 f"vs {stab_animal.hub_jaccard_expected.mean():.2f} expected for random hub sets, {fmt_p(hub_vs_null)}.", 177 "significant" if hub_vs_null < 0.05 else "null") 178 best = st[st.contrast == "SD vs BL"].sort_values("p").iloc[0] 179 self.highlight(f"{metrics[best.metric]} (SD vs BL)", f"{best.median_diff:+.3g}", 180 f"Strongest community/hub effect of defeat; {fmt_p(best.p)}, q = {best.q_fdr:.3f}.", 181 "significant" if best.q_fdr < 0.05 else "trend" if best.p < 0.05 else "null") 182 rc_mean = core.rich_club.mean() 183 self.highlight("Rich club", f"normalised φ = {rc_mean:.2f}", 184 "Mean over sessions in the 70th–90th degree percentile range (> 1 = rich-club organisation).", 185 "info") 186 187 results = f""" 188**Modular organisation.** At 10% density, networks split into {core.n_modules.median():.0f} non-trivial modules per 189session (median). Modularity exceeded circular-shift surrogates by {core.modularity_excess.mean():.3f} on average, 190indicating co-activity modules beyond what each neuron's own temporal statistics produce. 191 192**Hubs and rich club.** Hubs (top 10% degree) had mean participation P = {core.hub_participation.mean():.2f}, and 193{core.connector_hub_fraction.mean():.0%} of them were connector hubs. The normalised rich-club index averaged 194{rc_mean:.2f}. Condition contrasts are in Table 1 and Figure 2. {int((st.q_fdr < 0.05).sum())} of {len(st)} tests survived 195FDR correction. 196 197**Stability across days.** Partitions of consecutive same-condition sessions agreed above chance (mean ARI 198{mean_ari:.3f} vs {stab_animal.ARI_null_mean.mean():.3f}; {fmt_p(ari_vs_null)}). Hub sets overlapped more than 199random sets (Jaccard {stab_animal.hub_jaccard.mean():.2f} vs {stab_animal.hub_jaccard_expected.mean():.2f}; 200{fmt_p(hub_vs_null)}). Module stability differed between conditions with Friedman {fmt_p(ari_cond['friedman_p'])} 201(Figure 4). 202""" 203 return {"results": results, "parameters": {"density": dens, "hub_quantile": 0.9, "connector_P": 0.3, 204 "rich_club_rewires": 10, "ari_permutations": 200}} 205 206 def _fig_cartography(self, cart: pd.DataFrame) -> None: 207 if cart.empty: 208 return 209 fig, axes = plt.subplots(1, 3, figsize=(11, 3.4), sharex=True, sharey=True) 210 for ax, c in zip(axes, CORE_CONDITIONS): 211 d = cart[cart.condition == c] 212 ax.scatter(d.P, d.z, s=8, c="0.6") 213 ax.scatter(d.P[d.hub], d.z[d.hub], s=18, c=CONDITION_COLORS[c], edgecolor="k", lw=0.4, label="hubs") 214 ax.axvline(0.3, color="k", ls=":", lw=0.7) 215 ax.set_title(f"6675 · {c} · day 5") 216 ax.set_xlabel("participation coefficient P") 217 axes[0].set_ylabel("within-module degree z") 218 axes[0].legend(fontsize=7) 219 self.figure(fig, "cartography", "Functional cartography of neurons", 220 "Within-module degree z vs participation coefficient P for every neuron (animal 6675, day 5). Coloured points " 221 "are hubs (top 10% degree); P > 0.3 marks connector hubs.") 222 223 def _fig_metrics(self, pivots: dict, st: pd.DataFrame) -> None: 224 labels = {"modularity_excess": "Q − null Q", "connector_hub_fraction": "connector-hub fraction", 225 "hub_participation": "hub participation P", "rich_club": "normalised rich club"} 226 fig, axes = plt.subplots(1, 4, figsize=(13, 3.3)) 227 for ax, (k, pv) in zip(axes, pivots.items()): 228 s = st[st.metric == k].set_index("contrast") 229 paired_conditions(ax, pv, labels[k], labels[k], 230 {("BL", "SD"): star(s.loc["SD vs BL", "p"]), ("BL", "SS"): star(s.loc["SS vs BL", "p"])}) 231 fig.tight_layout() 232 self.figure(fig, "community_by_condition", "Community and hub measures by phase", 233 "Per-animal means and group mean ± SEM (days 1–9 without day 7). Stars: exact Wilcoxon vs BL, uncorrected (* p < 0.05; with n = 6 the minimum two-sided p is 0.031). See the tables for FDR-corrected q.") 234 235 def _fig_richclub(self, rc: pd.DataFrame) -> None: 236 if rc.empty: 237 return 238 fig, ax = plt.subplots(figsize=(5, 3.3)) 239 bins = np.linspace(0, 1, 11) 240 rc["bin"] = pd.cut(rc.k_norm, bins, labels=(bins[:-1] + bins[1:]) / 2) 241 for c in CORE_CONDITIONS: 242 d = rc[rc.condition == c].groupby(["subject", "bin"], observed=True).ratio.mean().groupby("bin", observed=True).agg(["mean", "sem"]) 243 ax.errorbar(d.index.astype(float), d["mean"], yerr=d["sem"], marker="o", ms=3, capsize=2, color=CONDITION_COLORS[c], label=c) 244 ax.axhline(1, color="k", ls="--", lw=0.7) 245 ax.set_xlabel("degree k (fraction of max)") 246 ax.set_ylabel("normalised rich-club φ") 247 ax.legend(fontsize=7) 248 self.figure(fig, "rich_club_curves", "Rich-club curves", 249 "Rich-club coefficient normalised by degree-preserving rewired graphs as a function of degree (mean ± SEM " 250 "across animals, days 1–9, day 7 excluded).") 251 252 def _fig_stability(self, stab: pd.DataFrame, ari_pv: pd.DataFrame) -> None: 253 fig, axes = plt.subplots(1, 2, figsize=(9, 3.3)) 254 paired_conditions(axes[0], ari_pv, "ARI (consecutive days)", "Module stability") 255 axes[0].axhline(stab.ARI_null_mean.mean(), color="k", ls=":", lw=0.8) 256 piv = stab.pivot(index="subject", columns="condition", values="hub_jaccard")[CORE_CONDITIONS] 257 paired_conditions(axes[1], piv, "hub Jaccard (consecutive days)", "Hub persistence") 258 axes[1].axhline(stab.hub_jaccard_expected.mean(), color="k", ls=":", lw=0.8) 259 self.figure(fig, "stability", "Stability of modules and hubs", 260 "Agreement between consecutive same-condition sessions; dotted lines show the permutation / random-set expectation.")
Study S06
Network reorganisation and drift
Similarity of neuron-level FC across all sessions: context-specific configurations vs slow drift.
1"""S06 — Network reorganisation and drift: FC similarity across sessions.""" 2 3from __future__ import annotations 4 5import warnings 6 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10import statsmodels.formula.api as smf 11from scipy import stats 12 13from ..data import CONDITION_COLORS, CONDITION_ORDER, CORE_CONDITIONS 14from ..networks import fc_similarity 15from ..stats import fdr_bh, fmt_p 16from .base import Study 17from .shared import session_networks 18 19 20class Reorganization(Study): 21 id = "s06" 22 number = 6 23 title = "Network reorganisation and drift" 24 short = "Similarity of neuron-level FC across all sessions: context-specific configurations vs slow drift." 25 question = ("Does each CSDS context (baseline, defeat, sensory contact, SIT) evoke its own network configuration " 26 "that recurs across days, and how quickly does the functional network drift over the 11 days?") 27 background = """ 28Comparing whole connectivity matrices across sessions, rather than summary statistics, captures *which* 29neurons are coupled. In longitudinal imaging, neural representations drift over days while preserving coarse 30structure (Ziv et al. 2013; Driscoll et al. 2017; Rule et al. 2019). Context-dependent reconfiguration of 31functional connectivity is well established in human network neuroscience (Cole et al. 2014; Bassett et al. 322011). For CSDS the question is whether defeat and sensory contact each impose a recurring network state, and 33whether that state bleeds into baseline sessions as stress accumulates. 34""" 35 methods = """ 36**FC similarity.** For every pair of sessions within an animal, similarity = Pearson correlation between the 37Fisher-z-transformed upper triangles of the two neuron × neuron FC matrices (inferred spikes). The same neurons 38are present in every session, so this compares the identity of coupled pairs, not just summary topology. 39 40**Drift.** For pairs from the same condition (BL, SD or SS; days 1–9, day 7 excluded), similarity was modelled as a function of 41day lag, separately for each animal. Pairs share sessions and are therefore not independent, so inference uses 42one slope per animal (exact Wilcoxon signed-rank across animals, BH-FDR across the three contexts). A negative 43slope indicates progressive drift. 44 45**Same-day similarity.** Different-context pairs recorded on the same day were compared with different-context 46pairs from different days (per-animal means, exact Wilcoxon). 47 48**Context specificity.** All pairs from *different days* among BL/SD/SS (days 1–9 without day 7) were modelled as 49`similarity ~ same_condition + |lag| + animal`. Restricting to different days removes the same-day confound 50(shared focal plane and slow fluorescence state). The `same_condition` coefficient estimates how much more alike 51two sessions are when they share a context. Significance was assessed by permuting condition labels *within 52day* for each animal (1,000 permutations; statistic = same-context minus different-context similarity after 53removing each animal's mean), which preserves the day structure. The per-animal mean difference 54(same − different context) was also tested with an exact Wilcoxon test. 55 56**Day-11 sessions.** The SIT (empty, CD-1) and post-SIT networks were compared with the mean BL, SD and SS 57networks of days 1–9, day 7 excluded, to ask whether the SIT evokes a defeat-like configuration (exact Wilcoxon, SD vs BL 58reference). 59 60**Embedding.** Classical (Torgerson) MDS of 1 − similarity per animal, coloured by condition. 61""" 62 limitations = """ 63* FC similarity is sensitive to session length and noise. BL/SD/SS sessions have equal length (≈2,000 frames), 64 but SIT phases are ~1,000 frames and are shown descriptively only. 65* Slow changes in imaging quality across days also produce apparent drift. 66""" 67 references = [ 68 "Ziv Y. et al. (2013) Long-term dynamics of CA1 hippocampal place codes. Nat Neurosci 16:264–266.", 69 "Driscoll L.N. et al. (2017) Dynamic reorganization of neuronal activity patterns in parietal cortex. Cell 170:986–999.", 70 "Rule M.E., O'Leary T. & Harvey C.D. (2019) Causes and consequences of representational drift. Curr Opin Neurobiol 58:141–147.", 71 "Cole M.W. et al. (2014) Intrinsic and task-evoked network architectures of the human brain. Neuron 83:238–251.", 72 "Bassett D.S. et al. (2011) Dynamic reconfiguration of human brain networks during learning. PNAS 108:7641–7646.", 73 ] 74 75 def run(self) -> dict: 76 df, fc = session_networks(self.ctx, "spikes") 77 rng = np.random.default_rng(3) 78 pair_rows, mats = [], {} 79 for sid in self.ds.subjects: 80 d = df[df.subject == sid].copy() 81 d["order"] = d.condition.map({c: i for i, c in enumerate(CONDITION_ORDER)}) 82 d = d.sort_values(["day", "order"]) 83 keys = d.key.tolist() 84 sim = np.eye(len(keys)) 85 for i in range(len(keys)): 86 for j in range(i + 1, len(keys)): 87 sim[i, j] = sim[j, i] = fc_similarity(fc[keys[i]], fc[keys[j]]) 88 ri, rj = d.iloc[i], d.iloc[j] 89 pair_rows.append({"subject": sid, "a": keys[i], "b": keys[j], "cond_a": ri.condition, "cond_b": rj.condition, 90 "day_a": ri.day, "day_b": rj.day, "similarity": sim[i, j]}) 91 mats[sid] = (sim, d[["condition", "day"]].reset_index(drop=True)) 92 pairs = pd.DataFrame(pair_rows) 93 pairs["lag"] = (pairs.day_b - pairs.day_a).abs() 94 pairs["same_condition"] = (pairs.cond_a == pairs.cond_b).astype(int) 95 96 core = pairs[pairs.cond_a.isin(CORE_CONDITIONS) & pairs.cond_b.isin(CORE_CONDITIONS) 97 & (pairs.day_a <= 9) & (pairs.day_b <= 9)] 98 drift_rows = [] 99 for c in CORE_CONDITIONS: 100 d = core[(core.cond_a == c) & (core.cond_b == c)] 101 slopes = d.groupby("subject").apply(lambda g: np.polyfit(g.lag, g.similarity, 1)[0], include_groups=False) 102 drift_rows.append({"condition": c, "slope_per_day": slopes.mean(), "n_negative": int((slopes < 0).sum()), 103 "n_animals": len(slopes), "p": stats.wilcoxon(slopes, method="exact").pvalue, 104 "similarity_lag_min": d[d.lag == d.lag.min()].similarity.mean(), "n_pairs": len(d)}) 105 drift = pd.DataFrame(drift_rows) 106 drift["q_fdr"] = fdr_bh(drift.p) 107 108 xctx = core[core.same_condition == 0] 109 same_day = xctx[xctx.lag == 0].groupby("subject").similarity.mean() 110 other_day = xctx[xctx.lag > 0].groupby("subject").similarity.mean() 111 p_sameday = stats.wilcoxon(same_day, other_day.loc[same_day.index], method="exact").pvalue 112 other_day = other_day.loc[same_day.index] 113 114 diff_day = core[core.lag > 0].copy() 115 with warnings.catch_warnings(): 116 warnings.simplefilter("ignore") 117 fit = smf.ols("similarity ~ same_condition + lag + C(subject)", data=diff_day).fit() 118 beta = fit.params["same_condition"] 119 null = [] 120 for _ in range(1000): 121 perm = diff_day.copy() 122 lab = {} 123 for _, g in df[df.condition.isin(CORE_CONDITIONS) & (df.day <= 9)].groupby(["subject", "day"]): 124 conds = g.condition.tolist() 125 shuffled = rng.permutation(conds) 126 for k, c2 in zip(g.key, shuffled): 127 lab[k] = c2 128 perm["same_condition"] = (perm.a.map(lab) == perm.b.map(lab)).astype(int) 129 null.append(np.polyfit(perm.same_condition + 0.0, perm.similarity - perm.groupby("subject").similarity.transform("mean"), 1)[0]) 130 obs_simple = np.polyfit(diff_day.same_condition + 0.0, diff_day.similarity - diff_day.groupby("subject").similarity.transform("mean"), 1)[0] 131 p_perm = float((1 + np.sum(np.array(null) >= obs_simple)) / 1001) 132 spec = diff_day.groupby(["subject", "same_condition"]).similarity.mean().unstack() 133 spec["specificity"] = spec[1] - spec[0] 134 p_wil = stats.wilcoxon(spec.specificity, alternative="greater", method="exact").pvalue 135 136 per_cond = [] 137 for c in CORE_CONDITIONS: 138 same = diff_day[(diff_day.cond_a == c) & (diff_day.cond_b == c)].groupby("subject").similarity.mean() 139 other = diff_day[((diff_day.cond_a == c) ^ (diff_day.cond_b == c))].groupby("subject").similarity.mean() 140 t = pd.DataFrame({"same": same, "other": other}).dropna() 141 p = stats.wilcoxon(t.same, t.other, alternative="greater", method="exact").pvalue 142 per_cond.append({"condition": c, "within_context": t.same.mean(), "to_other_contexts": t.other.mean(), "p": p}) 143 per_cond = pd.DataFrame(per_cond) 144 145 sit_rows = [] 146 for sid in self.ds.subjects: 147 for sk in ["SIT_CD1", "SIT_NoCD1", "POST"]: 148 k = f"{sid}_{sk}_D11" 149 if k not in fc: 150 continue 151 for c in CORE_CONDITIONS: 152 ks = df[(df.subject == sid) & (df.condition == c) & (df.day <= 9)].key 153 sit_rows.append({"subject": sid, "session": sk, "reference": c, 154 "similarity": float(np.mean([fc_similarity(fc[k], fc[x]) for x in ks]))}) 155 sit = pd.DataFrame(sit_rows) 156 157 self._fig_matrices(mats) 158 self._fig_drift(core) 159 self._fig_specificity(spec, per_cond, p_wil) 160 self._fig_mds(mats) 161 if not sit.empty: 162 self._fig_sit(sit) 163 164 self.table(drift, "drift", "Drift of same-context networks across days", 165 "Per-animal least-squares slope of similarity on day lag; exact two-sided Wilcoxon of slopes across " 166 "animals; q = BH-FDR across contexts.") 167 self.table(per_cond, "context_specificity", "Within- vs between-context similarity (different days only)", 168 "Exact one-sided Wilcoxon across animals.") 169 self.table(spec.reset_index().rename(columns={0: "different_context", 1: "same_context"}), "specificity_per_animal", 170 "Context specificity per animal", "Mean FC similarity for different-day pairs sharing vs not sharing a context.") 171 if not sit.empty: 172 self.table(sit.pivot_table(index=["subject", "session"], columns="reference", values="similarity").reset_index(), 173 "day11_similarity", "Day-11 sessions vs days 1–9, day 7 excluded contexts", "Mean FC similarity to each context.") 174 self.table(pairs, "pairwise_similarity", "All pairwise session similarities", show=10) 175 176 self.highlight("Context-specific networks", f"Δsim = {spec.specificity.mean():+.3f}", 177 f"Sessions sharing a context are more alike than different-context sessions on other days " 178 f"(within-day permutation {fmt_p(p_perm)}; Wilcoxon {fmt_p(p_wil)}; OLS β = {beta:+.3f}).", 179 "significant" if p_perm < 0.05 else "null") 180 for _, r in drift.iterrows(): 181 if r.condition == "SD": 182 self.highlight("Drift of defeat networks", f"{r.slope_per_day:+.4f} per day", 183 f"Mean per-animal change in FC similarity per day of separation between SD sessions; " 184 f"{r.n_negative}/{r.n_animals} animals negative, exact Wilcoxon {fmt_p(r.p)}, " 185 f"q = {r.q_fdr:.3f} (FDR over BL, SD, SS).", 186 "significant" if r.q_fdr < 0.05 else "trend" if r.p < 0.05 else "null") 187 if not sit.empty: 188 cd1 = sit[sit.session == "SIT_CD1"].pivot(index="subject", columns="reference", values="similarity") 189 p = stats.wilcoxon(cd1.SD, cd1.BL, method="exact").pvalue if len(cd1) >= 4 else np.nan 190 pref = ("SD" if cd1.SD.mean() > cd1.BL.mean() else "BL") if p < 0.05 else "no preference" 191 self.highlight("SIT network resembles…", pref, 192 f"SIT (CD-1) FC similarity to defeat {cd1.SD.mean():.3f} vs baseline {cd1.BL.mean():.3f}, {fmt_p(p)}.", 193 "trend" if p < 0.05 else "null") 194 195 results = f""" 196**Overall structure.** Figure 1 shows session-by-session FC similarity. Different-context sessions recorded on the 197same day had a mean similarity of {same_day.mean():.3f}, against {other_day.mean():.3f} for different-context sessions on 198different days ({int((same_day > other_day).sum())}/{len(same_day)} animals higher on the same day; exact Wilcoxon 199{fmt_p(p_sameday)}). 200 201**Drift.** Same-context similarity changed with lag at {', '.join(f"{r.condition} {r.slope_per_day:+.4f}/day ({fmt_p(r.p)}, q = {r.q_fdr:.3f})" for r in drift.itertuples())} 202(mean per-animal slope, exact Wilcoxon across animals; Table 1, Figure 2). Negative slopes indicate progressive 203reorganisation of which neurons are coupled. Elapsed time and accumulated defeat are confounded in this design, so drift 204cannot be attributed to stress alone. 205 206**Context specificity.** Among different-day pairs, sessions sharing a context were more similar by 207{spec.specificity.mean():+.3f} on average ({int((spec.specificity > 0).sum())}/{len(spec)} animals positive; 208within-day label permutation {fmt_p(p_perm)}; Wilcoxon {fmt_p(p_wil)}). Per-context tests are in Table 2: 209{'; '.join(f"{r.condition}: within {r.within_context:.3f} vs other {r.to_other_contexts:.3f} ({fmt_p(r.p)})" for r in per_cond.itertuples())}. 210""" 211 return {"results": results, "parameters": {"similarity": "Pearson r of Fisher-z FC upper triangles", 212 "permutations": 1000}} 213 214 def _fig_matrices(self, mats: dict) -> None: 215 n = len(mats) 216 fig, axes = plt.subplots(2, (n + 1) // 2, figsize=(4.2 * ((n + 1) // 2), 8.4)) 217 for ax, (sid, (sim, meta)) in zip(axes.ravel(), mats.items()): 218 im = ax.imshow(sim, cmap="viridis", vmin=0, vmax=np.percentile(sim[~np.eye(len(sim), dtype=bool)], 99)) 219 ticks = [f"{c[:6]}{d}" for c, d in zip(meta.condition, meta.day)] 220 ax.set_xticks(range(len(ticks)), ticks, rotation=90, fontsize=4.5) 221 ax.set_yticks(range(len(ticks)), ticks, fontsize=4.5) 222 for i, c in enumerate(meta.condition): 223 ax.add_patch(plt.Rectangle((-1.5, i - 0.5), 1, 1, color=CONDITION_COLORS[c], clip_on=False)) 224 ax.set_title(str(sid)) 225 fig.colorbar(im, ax=axes, shrink=0.6, label="FC similarity") 226 self.figure(fig, "similarity_matrices", "Session × session FC similarity", 227 "Pearson correlation between Fisher-z FC matrices of every pair of sessions, chronologically ordered; " 228 "the coloured strip marks condition.") 229 230 def _fig_drift(self, core: pd.DataFrame) -> None: 231 fig, ax = plt.subplots(figsize=(5, 3.3)) 232 for c in CORE_CONDITIONS: 233 d = core[(core.cond_a == c) & (core.cond_b == c)].groupby(["subject", "lag"]).similarity.mean().groupby("lag").agg(["mean", "sem"]) 234 ax.errorbar(d.index, d["mean"], yerr=d["sem"], marker="o", ms=3, capsize=2, color=CONDITION_COLORS[c], label=c) 235 ax.set_xlabel("days between sessions") 236 ax.set_ylabel("FC similarity") 237 ax.legend(fontsize=7) 238 self.figure(fig, "drift", "Drift of same-context networks", 239 "FC similarity between sessions of the same context as a function of days apart (mean ± SEM across animals).") 240 241 def _fig_specificity(self, spec: pd.DataFrame, per_cond: pd.DataFrame, p: float) -> None: 242 fig, axes = plt.subplots(1, 2, figsize=(9, 3.3)) 243 for _, r in spec.iterrows(): 244 axes[0].plot([0, 1], [r[0], r[1]], marker="o", color="0.5") 245 axes[0].set_xticks([0, 1], ["different\ncontext", "same\ncontext"]) 246 axes[0].set_ylabel("FC similarity (different days)") 247 axes[0].set_title(f"Context specificity ({fmt_p(p)})") 248 x = np.arange(len(per_cond)) 249 axes[1].bar(x - 0.2, per_cond.within_context, 0.4, color=[CONDITION_COLORS[c] for c in per_cond.condition]) 250 axes[1].bar(x + 0.2, per_cond.to_other_contexts, 0.4, color="0.75") 251 axes[1].set_xticks(x, per_cond.condition) 252 axes[1].set_ylabel("mean FC similarity") 253 axes[1].set_title("Within (colour) vs other contexts (grey)") 254 self.figure(fig, "context_specificity", "Context-specific network configurations", 255 "Left: per-animal mean similarity of different-day session pairs that share vs do not share a context. " 256 "Right: per-context within-context vs cross-context similarity.") 257 258 def _fig_mds(self, mats: dict) -> None: 259 n = len(mats) 260 fig, axes = plt.subplots(1, n, figsize=(3.2 * n, 3.2)) 261 for ax, (sid, (sim, meta)) in zip(axes, mats.items()): 262 dist = np.clip(1 - sim, 0, None) 263 np.fill_diagonal(dist, 0) 264 emb = _classical_mds(dist) 265 for c in meta.condition.unique(): 266 m = (meta.condition == c).to_numpy() 267 ax.scatter(emb[m, 0], emb[m, 1], c=CONDITION_COLORS[c], s=10 + 4 * meta.day[m], label=c, edgecolor="k", lw=0.3) 268 ax.set_title(str(sid)) 269 ax.set_xticks([]) 270 ax.set_yticks([]) 271 axes[0].legend(fontsize=6) 272 self.figure(fig, "mds", "Low-dimensional map of network states", 273 "Classical MDS of FC dissimilarity (1 − similarity) per animal; colour = condition, marker size grows with CSDS day.") 274 275 def _fig_sit(self, sit: pd.DataFrame) -> None: 276 fig, ax = plt.subplots(figsize=(6, 3.3)) 277 sessions = ["SIT_NoCD1", "SIT_CD1", "POST"] 278 for i, s in enumerate(sessions): 279 for j, c in enumerate(CORE_CONDITIONS): 280 v = sit[(sit.session == s) & (sit.reference == c)].similarity 281 ax.bar(i + (j - 1) * 0.25, v.mean(), 0.25, yerr=v.sem(), color=CONDITION_COLORS[c], label=c if i == 0 else None) 282 ax.set_xticks(range(len(sessions)), sessions) 283 ax.set_ylabel("mean FC similarity") 284 ax.legend(fontsize=7, title="vs days 1–9, day 7 excluded") 285 self.figure(fig, "day11_similarity", "Day-11 networks relative to CSDS contexts", 286 "Similarity of SIT (empty, CD-1) and post-SIT networks to the baseline, defeat and sensory-contact networks of " 287 "days 1–9, day 7 excluded (mean ± SEM across animals).") 288 289 290def _classical_mds(dist: np.ndarray, k: int = 2) -> np.ndarray: 291 n = len(dist) 292 j = np.eye(n) - 1.0 / n 293 b = -0.5 * j @ (dist ** 2) @ j 294 w, v = np.linalg.eigh(b) 295 idx = np.argsort(w)[::-1][:k] 296 return v[:, idx] * np.sqrt(np.clip(w[idx], 0, None))
Study S07
Behavior-state-dependent networks
Functional networks computed separately within each annotated behavior, frame-count matched.
1"""S07 — Behavior-state-dependent functional networks.""" 2 3from __future__ import annotations 4 5import matplotlib.pyplot as plt 6import numpy as np 7import pandas as pd 8from scipy import stats 9 10from ..networks import corr, fc_similarity, proportional, topology, upper 11from ..stats import fdr_bh, fmt_p, friedman 12from ..viz import star 13from .base import Study 14 15SD_STATES = ["Physical interaction", "Submissive", "Freezing", "Escape", "Exploring", "Standing"] 16BS_STATES = ["Non-physical interaction", "Exploring", "Standing"] 17MIN_FRAMES = 200 18N_REPEATS = 20 19MIN_HALF_FRAMES = 100 20 21 22class StateNetworks(Study): 23 id = "s07" 24 number = 7 25 title = "Behavior-state-dependent networks" 26 short = "Functional networks computed separately within each annotated behavior, frame-count matched." 27 question = ("Within the defeat context, does the network configuration depend on what the animal is doing " 28 "(physical interaction, submission, freezing, escape vs exploration)?") 29 background = """ 30Population coupling is not fixed. Correlations between neurons change with behavioral state, arousal and 31locomotion (Vinck et al. 2015; Stringer et al. 2019). Behaviorally specific patterns of co-activity carry social 32information beyond single-neuron rates in prefrontal cortex (Frost et al. 2021). Amygdala ensembles encode 33behavioral states such as exploration and defensive behavior (Gründemann et al. 2019). Here we ask whether 34defensive states during defeat reconfigure the neuron-level network relative to non-social exploration. 35""" 36 methods = """ 37**State frames.** For each animal, frames from SD sessions (days 1–9 without day 7) were grouped by annotated state: 38physical interaction, submissive, freezing, escape, exploring and standing. States needed ≥ 200 frames 39(≈ 30 s). The same was done for BL + SS sessions (non-physical interaction, exploring, standing). 40 41**Frame-count matching.** Correlation estimates and graph measures depend on sample size. For every animal, *N* 42= the smallest frame count among its eligible states. Each state was subsampled to *N* frames (random frames, 4320 repeats), and FC and measures were averaged over repeats. 44 45**Measures.** Mean pairwise correlation; and on 10%-density graphs: clustering, global efficiency and Louvain 46modularity. 47 48**State similarity (cross-validated).** Recording days were split into alternating halves (odd vs even day rank), and each state's FC was 49estimated separately in each half from the same number of frames. *Reliability* of a state is the FC similarity 50(Pearson r between Fisher-z upper triangles, as in S06) between its odd-half and even-half networks. *Cross-state 51similarity* of states A and B is the mean of sim(A_odd, B_even) and sim(B_odd, A_even). Both quantities therefore 52compare independent sessions with equal sample sizes, so autocorrelation and estimation noise affect them 53equally. Cross-state similarity well below reliability means states reconfigure the network. 54 55**Statistics.** Friedman test across states for animals with all states. Each state vs exploring was compared 56with exact Wilcoxon tests, FDR-corrected within context. 57""" 58 limitations = """ 59* Frames within a state come from many bouts. Correlations therefore mix within-bout co-fluctuation with 60 between-bout rate differences. 61* States differ in locomotion and arousal. Network differences may reflect these correlates rather than 62 social meaning per se. 63* Rare states (attack, danger) did not reach the frame threshold in enough animals. 64""" 65 references = [ 66 "Vinck M. et al. (2015) Arousal and locomotion make distinct contributions to cortical activity patterns and visual encoding. Neuron 86:740–754.", 67 "Stringer C. et al. (2019) Spontaneous behaviors drive multidimensional, brainwide activity. Science 364:eaav7893.", 68 "Frost N.A. et al. (2021) Dynamic patterns of correlated activity in the prefrontal cortex encode information about social behavior. PLoS Biol 19:e3001235.", 69 "Gründemann J. et al. (2019) Amygdala ensembles encode behavioral states. Science 364:eaav8736.", 70 ] 71 72 def run(self) -> dict: 73 ds = self.ds 74 rng = np.random.default_rng(4) 75 dens = self.cfg["networks"]["proportional_density"] 76 rows, sim_rows = [], [] 77 for ctx_name, conds, states in [("SD", ["SD"], SD_STATES), ("BL+SS", ["BL", "SS"], BS_STATES)]: 78 for sid in ds.subjects: 79 x, lab, sess = ds.pooled(sid, conds, max_day=9) 80 elig = [s for s in states if (lab == s).sum() >= MIN_FRAMES] 81 if len(elig) < 2: 82 continue 83 n = min(int((lab == s).sum()) for s in elig) 84 days = np.array([s.day for s in ds.iter_sessions(sid, conds, max_day=9)]) 85 rank = {d: i for i, d in enumerate(sorted(set(days)))} 86 odd = np.array([rank[d] % 2 == 1 for d in days])[sess] 87 n_half = min(int(((lab == s) & (odd == h)).sum()) for s in elig for h in (True, False)) 88 half_fc = {} 89 for s in elig: 90 idx = np.flatnonzero(lab == s) 91 acc = [] 92 for _ in range(N_REPEATS): 93 sub = rng.choice(idx, n, replace=False) 94 c = corr(x[:, np.sort(sub)]) 95 t = topology(proportional(c, dens)) 96 acc.append({"mean_r": upper(c).mean(), "clustering": t["clustering"], 97 "efficiency": t["efficiency"], "modularity": t["modularity"]}) 98 if n_half >= MIN_HALF_FRAMES: 99 half_fc[s] = [corr(x[:, np.sort(rng.choice(np.flatnonzero((lab == s) & (odd == h)), n_half, 100 replace=False))]) for h in (True, False)] 101 rel = fc_similarity(*half_fc[s]) if s in half_fc else np.nan 102 rows.append({"context": ctx_name, "subject": sid, "state": s, "frames_used": n, 103 "half_frames": n_half, "split_half_reliability": rel, **pd.DataFrame(acc).mean().to_dict()}) 104 for i, a in enumerate(elig): 105 for b in elig[i + 1:]: 106 if a in half_fc and b in half_fc: 107 sim = 0.5 * (fc_similarity(half_fc[a][0], half_fc[b][1]) + fc_similarity(half_fc[b][0], half_fc[a][1])) 108 sim_rows.append({"context": ctx_name, "subject": sid, "a": a, "b": b, "similarity": sim}) 109 m = pd.DataFrame(rows) 110 sims = pd.DataFrame(sim_rows) 111 112 test_rows = [] 113 for ctx_name, d in m.groupby("context"): 114 for metric in ["mean_r", "clustering", "efficiency", "modularity"]: 115 pv = d.pivot(index="subject", columns="state", values=metric).dropna(axis=1, thresh=4) 116 full = pv.dropna() 117 fr = friedman(full) if full.shape[1] >= 3 else {"friedman_p": np.nan} 118 for s in pv.columns: 119 if s == "Exploring": 120 continue 121 t = pv[["Exploring", s]].dropna() 122 if len(t) >= 4: 123 p = stats.wilcoxon(t[s], t["Exploring"], method="exact").pvalue 124 test_rows.append({"context": ctx_name, "metric": metric, "state": s, "vs": "Exploring", 125 "n": len(t), "median_diff": float((t[s] - t["Exploring"]).median()), "p": p, 126 "friedman_p": fr["friedman_p"]}) 127 tests = pd.DataFrame(test_rows) 128 tests["q_fdr"] = np.nan 129 for c in tests.context.unique(): 130 msk = tests.context == c 131 tests.loc[msk, "q_fdr"] = fdr_bh(tests.loc[msk, "p"]) 132 133 rel = m.groupby(["context", "subject"]).split_half_reliability.mean() 134 cross = sims.groupby(["context", "subject"]).similarity.mean() 135 rc = pd.DataFrame({"reliability": rel, "cross_state": cross}).dropna().reset_index() 136 rc_tests = {} 137 for c, d in rc.groupby("context"): 138 rc_tests[c] = stats.wilcoxon(d.reliability, d.cross_state, alternative="greater", method="exact").pvalue if len(d) >= 4 else np.nan 139 140 self._fig_metrics(m, tests) 141 self._fig_similarity(sims, m) 142 self._fig_reliability(rc, rc_tests) 143 144 self.table(tests.sort_values(["context", "p"]), "state_tests", "State vs exploring contrasts", 145 "Exact Wilcoxon on per-animal, frame-matched network measures; q = BH-FDR within context.", show=30) 146 self.table(rc, "reliability_vs_cross_state", "Split-half reliability vs cross-state FC similarity", 147 "If cross-state similarity is below within-state reliability, states reconfigure the network.") 148 self.table(m, "state_metrics", "Per-animal state networks", show=20) 149 150 sd = tests[(tests.context == "SD")].sort_values("p") 151 if len(sd): 152 r = sd.iloc[0] 153 self.highlight(f"{r.state}: {r.metric} vs exploring", f"{r.median_diff:+.3g}", 154 f"Defeat context, {r.n} animals, {fmt_p(r.p)}, q = {r.q_fdr:.3f}.", 155 "significant" if r.q_fdr < 0.05 else "trend" if r.p < 0.05 else "null") 156 d = rc[rc.context == "SD"] 157 self.highlight("States reconfigure the network", f"{d.reliability.mean():.2f} vs {d.cross_state.mean():.2f}", 158 f"Cross-validated (odd vs even days) within-state reliability vs between-state FC similarity, defeat context, " 159 f"{int((d.reliability > d.cross_state).sum())}/{len(d)} animals, {fmt_p(rc_tests.get('SD'))}.", 160 "significant" if rc_tests.get("SD", 1) < 0.05 else "null") 161 162 results = f""" 163**Frame-matched state networks.** Eligible states per animal were matched to the smallest state 164({int(m[m.context == 'SD'].frames_used.median())} frames median in defeat sessions). Mean pairwise correlation and 165topology by state are shown in Figure 1. {int((tests.q_fdr < 0.05).sum())} of {len(tests)} state-vs-exploring contrasts 166survived FDR correction. The smallest p-values in the defeat context were 167{'; '.join(f"{r.state} {r.metric} Δ = {r.median_diff:+.3g} ({fmt_p(r.p)})" for r in sd.head(3).itertuples())}. 168 169**Reconfiguration.** In defeat sessions, cross-validated FC similarity between different states averaged 170{d.cross_state.mean():.3f}, against {d.reliability.mean():.3f} for the same state across day halves 171({int((d.reliability > d.cross_state).sum())}/{len(d)} animals lower across states; {fmt_p(rc_tests.get('SD'))}). In BL + SS 172the corresponding p was {fmt_p(rc_tests.get('BL+SS'))} (Table 2, Figure 3). Figure 2 shows which state pairs are most 173dissimilar. 174""" 175 return {"results": results, "parameters": {"min_frames": MIN_FRAMES, "repeats": N_REPEATS, "density": dens}} 176 177 def _fig_metrics(self, m: pd.DataFrame, tests: pd.DataFrame) -> None: 178 metrics = ["mean_r", "clustering", "efficiency", "modularity"] 179 fig, axes = plt.subplots(2, 4, figsize=(14, 6.5)) 180 for row, ctx_name in enumerate(["SD", "BL+SS"]): 181 d = m[m.context == ctx_name] 182 states = [s for s in (SD_STATES if ctx_name == "SD" else BS_STATES) if s in set(d.state)] 183 for ax, metric in zip(axes[row], metrics): 184 for _, g in d.groupby("subject"): 185 g = g.set_index("state").reindex(states) 186 ax.plot(range(len(states)), g[metric], color="0.75", marker="o", ms=2, lw=0.7) 187 mean = d.groupby("state")[metric].mean().reindex(states) 188 ax.plot(range(len(states)), mean, color="k", marker="o", lw=1.5) 189 for i, s in enumerate(states): 190 t = tests[(tests.context == ctx_name) & (tests.metric == metric) & (tests.state == s)] 191 if len(t): 192 ax.text(i, ax.get_ylim()[1], star(t.p.iloc[0]), ha="center", va="top", fontsize=7) 193 ax.set_xticks(range(len(states)), states, rotation=45, ha="right", fontsize=7) 194 ax.set_title(f"{ctx_name}: {metric}") 195 fig.tight_layout() 196 self.figure(fig, "state_metrics", "Network measures by behavioral state", 197 "Frame-count-matched network measures per state (grey = animals, black = mean). Stars: exact Wilcoxon vs exploring.") 198 199 def _fig_similarity(self, sims: pd.DataFrame, m: pd.DataFrame) -> None: 200 fig, axes = plt.subplots(1, 2, figsize=(10, 4)) 201 for ax, ctx_name in zip(axes, ["SD", "BL+SS"]): 202 states = [s for s in (SD_STATES if ctx_name == "SD" else BS_STATES) if s in set(m[m.context == ctx_name].state)] 203 mat = np.full((len(states), len(states)), np.nan) 204 rel = m[m.context == ctx_name].groupby("state").split_half_reliability.mean() 205 for i, s in enumerate(states): 206 mat[i, i] = rel.get(s, np.nan) 207 d = sims[sims.context == ctx_name].groupby(["a", "b"]).similarity.mean() 208 for (a, b), v in d.items(): 209 if a in states and b in states: 210 i, j = states.index(a), states.index(b) 211 mat[i, j] = mat[j, i] = v 212 im = ax.imshow(mat, cmap="viridis") 213 ax.set_xticks(range(len(states)), states, rotation=45, ha="right", fontsize=7) 214 ax.set_yticks(range(len(states)), states, fontsize=7) 215 for i in range(len(states)): 216 for j in range(len(states)): 217 if np.isfinite(mat[i, j]): 218 ax.text(j, i, f"{mat[i, j]:.2f}", ha="center", va="center", fontsize=6, color="w") 219 ax.set_title(f"{ctx_name}: state FC similarity") 220 fig.colorbar(im, ax=ax, shrink=0.7) 221 self.figure(fig, "state_similarity", "Similarity of state-specific networks", 222 "Mean FC similarity between states (off-diagonal) and split-half reliability within state (diagonal).") 223 224 def _fig_reliability(self, rc: pd.DataFrame, tests: dict) -> None: 225 fig, ax = plt.subplots(figsize=(5, 3.3)) 226 for i, (c, d) in enumerate(rc.groupby("context")): 227 for _, r in d.iterrows(): 228 ax.plot([i - 0.15, i + 0.15], [r.reliability, r.cross_state], color="0.5", marker="o", ms=3) 229 ax.text(i, d[["reliability", "cross_state"]].to_numpy().max() + 0.02, fmt_p(tests.get(c)), ha="center", fontsize=7) 230 ax.set_xticks(range(rc.context.nunique()), [f"{c}\n(within → between)" for c in sorted(rc.context.unique())]) 231 ax.set_ylabel("FC similarity") 232 self.figure(fig, "reliability", "Within-state reliability vs between-state similarity", 233 "Each line is an animal: split-half reliability within states (left point) vs mean similarity between states (right point).")
Study S08
Population decoding of behavior and context
Cross-validated linear decoders with temporally structured null models.
1"""S08 — Population decoding of behavior and CSDS context.""" 2 3from __future__ import annotations 4 5import warnings 6 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10from scipy import stats 11from sklearn.discriminant_analysis import LinearDiscriminantAnalysis 12from sklearn.metrics import balanced_accuracy_score, confusion_matrix 13from sklearn.model_selection import GroupKFold 14from sklearn.pipeline import make_pipeline 15from sklearn.preprocessing import StandardScaler 16 17from ..data import CORE_CONDITIONS, bin_frames, bin_labels 18from ..stats import fmt_p 19from .base import Study 20 21N_NULL = 50 22CHUNK_BINS = 30 23 24 25def _clf(y_train: np.ndarray): 26 k = len(np.unique(y_train)) 27 lda = LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto", priors=np.full(k, 1 / k)) 28 return make_pipeline(StandardScaler(), lda) 29 30 31def cv_decode(x: np.ndarray, y: np.ndarray, groups: np.ndarray, n_splits: int = 5): 32 pred = np.empty(len(y), dtype=object) 33 with warnings.catch_warnings(): 34 warnings.simplefilter("ignore") 35 for tr, te in GroupKFold(n_splits=n_splits).split(x, y, groups): 36 if len(np.unique(y[tr])) < 2: 37 pred[te] = y[tr][0] 38 continue 39 pred[te] = _clf(y[tr]).fit(x[tr], y[tr]).predict(x[te]) 40 return balanced_accuracy_score(y, pred), pred 41 42 43class Decoding(Study): 44 id = "s08" 45 number = 8 46 title = "Population decoding of behavior and context" 47 short = "Cross-validated linear decoders with temporally structured null models." 48 question = ("How much information about ongoing defeat behavior and about the CSDS context (baseline, defeat, " 49 "sensory contact) is carried by the population activity of the imaged neurons?") 50 background = """ 51Decoding quantifies the information a population carries about a variable, without assuming single-neuron 52tuning (Glaser et al. 2020). Linear classifiers trained on calcium ensembles decode social behaviors and social 53rank in prefrontal cortex (Frost et al. 2021; Padilla-Coreano et al. 2022), and LFP-based decoders separate 54social behavioral states in CSDS mice (Front Neurosci 2022). Because behavior and neural activity are both 55autocorrelated, valid null models must preserve temporal structure. Circular shifting of the label sequence and 56blocked cross-validation are the standard safeguards (Gründemann et al. 2019). 57""" 58 methods = """ 59**Features.** Inferred spike probability was averaged in 7-frame bins (1.05 s). Each bin took its majority 60behavior label, and bins whose majority covered < 70% were discarded. 61 62**Behavior decoding.** Separately for defeat sessions (days 1–9 without day 7) and for BL + SS sessions, per animal, classes with 63≥ 30 bins were decoded by linear discriminant analysis with Ledoit–Wolf covariance shrinkage (equal class 64priors; features z-scored within training folds). Shrinkage LDA is a regularised linear decoder that is robust 65when neurons outnumber effective samples. Cross-validation was 5-fold *grouped* by 30-bin (≈ 31.5 s) chunks of 66recording time within each session, so a test bin's temporal neighbours fall in the same fold except at chunk 67edges. Performance is balanced accuracy, whose chance is 1/K for K classes. 68 69**Null distribution.** The full bin-wise label sequence (including unlabelled bins) was circularly shifted 70relative to the neural data (≥ 30 bins; 50 shifts), which keeps bout structure and the true time axis. Bins whose 71shifted label is an eligible class were decoded with the same cross-validation. Per-animal p = 72(1 + #null ≥ observed)/(1 + #null), so the smallest attainable p is 1/51 ≈ 0.02. Group-level: one-sided exact 73Wilcoxon of (observed − null mean) across animals. 74 75**Context decoding.** BL vs SD vs SS (days 1–9 without day 7) were decoded from 10-s population vectors with *leave-one-day-out* 76cross-validation, testing generalisation to an unseen day. The null permuted the three condition labels among 77the sessions of each day (200 permutations), destroying context information while preserving day effects. 78""" 79 limitations = """ 80* Linear decoders give a lower bound on information. Non-linear decoders or decoders exploiting correlations 81 could extract more (Frost et al. 2021). 82* Behavior classes are imbalanced and partly confounded with locomotion. 83* Context decoding across days can exploit any feature that differs between sessions recorded in the same 84 order each day (BL → SD → SS), including within-day drift. Generalisation across days mitigates but does not 85 eliminate this. 86""" 87 references = [ 88 "Ledoit O. & Wolf M. (2004) A well-conditioned estimator for large-dimensional covariance matrices. J Multivar Anal 88:365–411.", 89 "Glaser J.I. et al. (2020) Machine learning for neural decoding. eNeuro 7:ENEURO.0506-19.2020.", 90 "Frost N.A. et al. (2021) Dynamic patterns of correlated activity in the prefrontal cortex encode information about social behavior. PLoS Biol 19:e3001235.", 91 "Padilla-Coreano N. et al. (2022) Cortical ensembles orchestrate social competition through hypothalamic outputs. Nature 603:667–671.", 92 "Behavioral state-dependent oscillatory activity in prefrontal cortex induced by chronic social defeat stress (2022) Front Neurosci. doi:10.3389/fnins.2022.885432", 93 "Gründemann J. et al. (2019) Amygdala ensembles encode behavioral states. Science 364:eaav8736.", 94 ] 95 96 def run(self) -> dict: 97 ds = self.ds 98 rng = np.random.default_rng(5) 99 width = int(round(ds.fs)) 100 rows, conf = [], {} 101 for ctx_name, conds in [("SD", ["SD"]), ("BL+SS", ["BL", "SS"])]: 102 for sid in ds.subjects: 103 xs, ys, gs = [], [], [] 104 for i, s in enumerate(ds.iter_sessions(sid, conds, max_day=9)): 105 xb = bin_frames(ds.activity(s), width).T 106 xs.append(xb) 107 ys.append(bin_labels(ds.labels(s), width)[: len(xb)]) 108 gs.append(i * 10_000 + np.arange(len(xb)) // CHUNK_BINS) 109 x_all, y_all, g_all = np.concatenate(xs), np.concatenate(ys), np.concatenate(gs) 110 cls, cnt = np.unique(y_all[~np.isin(y_all, ["none", "mixed"])], return_counts=True) 111 eligible = cls[cnt >= 30] 112 ok = np.isin(y_all, eligible) 113 x, y, groups = x_all[ok], y_all[ok], g_all[ok] 114 if len(np.unique(y)) < 2: 115 continue 116 acc, pred = cv_decode(x, y, groups) 117 null = [] 118 for _ in range(N_NULL): 119 y_s = np.roll(y_all, rng.integers(CHUNK_BINS, len(y_all) - CHUNK_BINS)) 120 k = np.isin(y_s, eligible) 121 null.append(cv_decode(x_all[k], y_s[k], g_all[k])[0] if len(np.unique(y_s[k])) >= 2 else np.nan) 122 null = [v for v in null if np.isfinite(v)] 123 classes = sorted(np.unique(y)) 124 conf.setdefault(ctx_name, []).append(pd.DataFrame(confusion_matrix(y, pred, labels=classes, normalize="true"), 125 index=classes, columns=classes)) 126 rows.append({"analysis": f"behavior ({ctx_name})", "subject": sid, "n_classes": len(classes), 127 "n_bins": len(y), "chance": 1 / len(classes), "balanced_accuracy": acc, 128 "null_mean": float(np.mean(null)), "null_95": float(np.quantile(null, 0.95)), 129 "p": (1 + np.sum(np.array(null) >= acc)) / (1 + len(null))}) 130 131 width10 = int(round(10 * ds.fs)) 132 for sid in ds.subjects: 133 xs, ys, days, sess = [], [], [], [] 134 for i, s in enumerate(ds.iter_sessions(sid, CORE_CONDITIONS, max_day=9)): 135 b = bin_frames(ds.activity(s), width10).T 136 xs.append(b) 137 ys += [s.condition] * len(b) 138 days += [s.day] * len(b) 139 sess += [i] * len(b) 140 x, y, days, sess = np.concatenate(xs), np.array(ys, dtype=object), np.array(days), np.array(sess) 141 acc = self._lodo(x, y, days) 142 null = [] 143 for _ in range(200): 144 yp = y.copy() 145 for d in np.unique(days): 146 idx = np.flatnonzero(days == d) 147 ss = np.unique(sess[idx]) 148 mapping = dict(zip(ss, rng.permutation([y[sess == k][0] for k in ss]))) 149 yp[idx] = [mapping[k] for k in sess[idx]] 150 null.append(self._lodo(x, yp, days)) 151 rows.append({"analysis": "context (BL/SD/SS, leave-one-day-out)", "subject": sid, "n_classes": 3, 152 "n_bins": len(y), "chance": 1 / 3, "balanced_accuracy": acc, "null_mean": float(np.mean(null)), 153 "null_95": float(np.quantile(null, 0.95)), "p": (1 + np.sum(np.array(null) >= acc)) / 201}) 154 res = pd.DataFrame(rows) 155 grp = [] 156 for a, d in res.groupby("analysis"): 157 p = stats.wilcoxon(d.balanced_accuracy - d.null_mean, alternative="greater", method="exact").pvalue if len(d) >= 4 else np.nan 158 grp.append({"analysis": a, "n_animals": len(d), "mean_accuracy": d.balanced_accuracy.mean(), 159 "mean_chance": d.chance.mean(), "mean_null": d.null_mean.mean(), 160 "animals_p<0.05": int((d.p < 0.05).sum()), "group_p": p}) 161 grp = pd.DataFrame(grp) 162 163 self._fig_accuracy(res) 164 self._fig_confusion(conf) 165 166 self.table(grp, "group_decoding", "Decoding performance (group level)", 167 "Group p: exact one-sided Wilcoxon of (observed − null mean) across animals.") 168 self.table(res, "per_animal_decoding", "Decoding performance per animal", 169 "Balanced accuracy with circular-shift (behavior) or within-day label permutation (context) nulls.", show=30) 170 171 for _, r in grp.iterrows(): 172 self.highlight(f"Decoding {r.analysis}", f"{r.mean_accuracy:.0%} (chance {r.mean_chance:.0%})", 173 f"{r['animals_p<0.05']}/{r.n_animals} animals above their own null; group {fmt_p(r.group_p)}.", 174 "significant" if r.group_p < 0.05 else "null") 175 176 results = "\n\n".join( 177 f"**{r.analysis[0].upper() + r.analysis[1:]}.** Mean balanced accuracy {r.mean_accuracy:.1%} vs chance {r.mean_chance:.1%} " 178 f"and temporally structured null {r.mean_null:.1%}. {r['animals_p<0.05']}/{r.n_animals} animals decoded above " 179 f"their individual null (group {fmt_p(r.group_p)})." 180 for _, r in grp.iterrows()) 181 results += "\n\nPer-animal values are in Table 2 and Figure 1. Average confusion matrices (Figure 2) show which behaviors are most confusable." 182 return {"results": results, "parameters": {"bin_frames": width, "context_bin_frames": width10, 183 "decoder": "shrinkage LDA (Ledoit-Wolf, equal priors)", 184 "cv": "5-fold grouped (30-bin chunks within session)", 185 "null_shifts": N_NULL, "context_permutations": 200}} 186 187 @staticmethod 188 def _lodo(x, y, days) -> float: 189 pred = np.empty(len(y), dtype=object) 190 with warnings.catch_warnings(): 191 warnings.simplefilter("ignore") 192 for d in np.unique(days): 193 te = days == d 194 pred[te] = _clf(y[~te]).fit(x[~te], y[~te]).predict(x[te]) 195 return balanced_accuracy_score(y, pred) 196 197 def _fig_accuracy(self, res: pd.DataFrame) -> None: 198 analyses = list(res.analysis.unique()) 199 fig, ax = plt.subplots(figsize=(7.5, 3.6)) 200 for i, a in enumerate(analyses): 201 d = res[res.analysis == a] 202 jit = np.linspace(-0.15, 0.15, len(d)) 203 ax.scatter(i + jit, d.balanced_accuracy, c=["#b5523b" if p < 0.05 else "0.4" for p in d.p], s=30, zorder=3) 204 for j, r in zip(jit, d.itertuples()): 205 ax.plot([i + j, i + j], [r.null_mean, r.null_95], color="0.7", lw=3, zorder=1) 206 ax.hlines(d.chance.mean(), i - 0.3, i + 0.3, color="k", ls=":", lw=0.8) 207 ax.set_xticks(range(len(analyses)), [a.replace(" (", "\n(") for a in analyses], fontsize=8) 208 ax.set_ylabel("balanced accuracy") 209 self.figure(fig, "decoding_accuracy", "Decoding accuracy vs null", 210 "Each dot is an animal (red: above its own null, p < 0.05). Grey bars span the null mean to its 95th percentile; " 211 "dotted line = chance.") 212 213 def _fig_confusion(self, conf: dict) -> None: 214 fig, axes = plt.subplots(1, len(conf), figsize=(5.5 * len(conf), 4.5)) 215 for ax, (name, mats) in zip(np.atleast_1d(axes), conf.items()): 216 labels = sorted(set().union(*[m.index for m in mats])) 217 stack = np.nanmean([m.reindex(index=labels, columns=labels).to_numpy() for m in mats], axis=0) 218 im = ax.imshow(stack, cmap="Blues", vmin=0, vmax=1) 219 ax.set_xticks(range(len(labels)), labels, rotation=45, ha="right", fontsize=7) 220 ax.set_yticks(range(len(labels)), labels, fontsize=7) 221 for i in range(len(labels)): 222 for j in range(len(labels)): 223 if np.isfinite(stack[i, j]): 224 ax.text(j, i, f"{stack[i, j]:.2f}", ha="center", va="center", fontsize=6, 225 color="w" if stack[i, j] > 0.5 else "k") 226 ax.set_xlabel("predicted") 227 ax.set_ylabel("true") 228 ax.set_title(f"{name} behavior decoder") 229 fig.colorbar(im, ax=ax, shrink=0.7) 230 self.figure(fig, "confusion", "Average confusion matrices", 231 "Row-normalised confusion matrices averaged across animals (classes present in at least one animal).")
Study S09
Population dynamics: dimensionality and hidden states
Participation-ratio dimensionality and hidden Markov model population states across CSDS.
1"""S09 — Population dynamics: dimensionality and hidden network states.""" 2 3from __future__ import annotations 4 5import warnings 6 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10from hmmlearn.hmm import GaussianHMM 11from scipy import stats 12from sklearn.decomposition import PCA 13from sklearn.metrics import mutual_info_score 14 15from ..data import ( 16 CONDITION_ORDER, 17 CORE_CONDITIONS, 18 bin_frames, 19 bin_labels, 20) 21from ..stats import animal_means, fdr_bh, fmt_p, friedman, paired 22from ..viz import paired_conditions, star 23from .base import Study 24 25N_STATES = 6 26N_PCS = 10 27 28 29def participation_ratio(x: np.ndarray) -> float: 30 """(Σλ)² / Σλ² of the covariance spectrum of z-scored activity (neurons × time).""" 31 x = x[x.std(1) > 0] 32 z = (x - x.mean(1, keepdims=True)) / x.std(1, keepdims=True) 33 lam = np.clip(np.linalg.eigvalsh(np.cov(z)), 0, None) 34 return float(lam.sum() ** 2 / (lam**2).sum()) 35 36 37class Dynamics(Study): 38 id = "s09" 39 number = 9 40 title = "Population dynamics: dimensionality and hidden states" 41 short = "Participation-ratio dimensionality and hidden Markov model population states across CSDS." 42 question = ("Does defeat compress or expand the dimensionality of population activity, and does the population " 43 "visit different discrete states in each context, states that track defensive behavior?") 44 background = """ 45The participation ratio summarises how many dimensions population activity occupies (Gao & Ganguli 2015; 46Recanatesi et al. 2022). Lower dimensionality indicates stronger shared fluctuations, a population-level 47counterpart of increased network coupling. Hidden Markov models segment population activity into recurring 48discrete states (Mazzucato et al. 2015). In CSDS, susceptible mice show more distinct basolateral amygdala 49population states in Neuropixels recordings (PMC10854313), and HMM-GLM models decode 50social-competition states from mPFC ensembles (Padilla-Coreano et al. 2022). 51""" 52 methods = """ 53**Dimensionality.** For each session, 1-s binned inferred spikes were z-scored per neuron, and the participation 54ratio PR = (Σλᵢ)² / Σλᵢ² was computed from the eigenvalues λ of the neuron covariance matrix. PR / number of 55neurons gives the normalised dimensionality. Conditions were compared on per-animal means over days that contain 56all of BL, SD and SS (days 1–9, day 7 excluded; Friedman, exact Wilcoxon, FDR across metrics × contrasts). 57 58**Hidden Markov model.** Per animal, 1-s bins from *all* sessions were z-scored per neuron and projected on the top 5910 principal components. A Gaussian HMM with 6 states (diagonal covariance, 200 EM iterations, fixed seed) was fit 60with session boundaries respected, and Viterbi decoding gave the state sequence. Per session we computed: 61- occupancy entropy, in bits (how evenly states are used); 62- mean dwell time, in seconds; 63- transitions per minute; 64- the occupancy distribution. 65 66*Context specificity of states*: the Jensen–Shannon divergence between occupancy distributions of different-day 67sessions with the same context vs different contexts (per-animal means, one-sided exact Wilcoxon). 68 69**State–behavior coupling.** In defeat sessions, mutual information between HMM state and annotated behavior was 70compared with 200 circular shifts of the full 1-s behavior sequence relative to the state sequence (shift ≥ 30 s). 71MI was computed on bins whose behavior label, after shifting, is a single annotated behavior. Per animal, 72z = (MI − null mean) / null SD and p = (1 + #null ≥ MI) / 201; the group test is a one-sided exact Wilcoxon of z. 73""" 74 limitations = """ 75* The number of HMM states (6) and PCs (10) were fixed a priori rather than selected by cross-validated likelihood. 76* PR depends on the number of neurons and samples. Comparisons are therefore within animal and between equal-length 77 sessions. 78* The HMM is fit to all sessions of an animal, including day 11, so states are defined on data that also enter 79 the condition contrasts. Contrasts compare how the same states are used, not whether states differ. 80* BL, SD and SS are always recorded in that order within a day, so condition contrasts are confounded with 81 position in the recording. 82""" 83 references = [ 84 "Gao P. & Ganguli S. (2015) On simplicity and complexity in the brave new world of large-scale neuroscience. Curr Opin Neurobiol 32:148–155.", 85 "Recanatesi S. et al. (2022) A scale-dependent measure of system dimensionality. Patterns 3:100555.", 86 "Mazzucato L., Fontanini A. & La Camera G. (2015) Dynamics of multistable states during ongoing and evoked cortical activity. J Neurosci 35:8214–8231.", 87 "Identifying and modulating neural signatures of stress susceptibility and resilience enables control of anhedonia (BLA/vCA1 Neuropixels, HMM states). PMC10854313.", 88 "Padilla-Coreano N. et al. (2022) Cortical ensembles orchestrate social competition through hypothalamic outputs. Nature 603:667–671.", 89 "Lin J. (1991) Divergence measures based on the Shannon entropy. IEEE Trans Inf Theory 37:145–151.", 90 ] 91 92 def run(self) -> dict: 93 ds = self.ds 94 rng = np.random.default_rng(6) 95 width = int(round(ds.fs)) 96 rows, occ_rows, mi_rows, examples = [], [], [], {} 97 for sid in ds.subjects: 98 sess = list(ds.iter_sessions(sid)) 99 bins = [bin_frames(ds.activity(s), width) for s in sess] 100 for s, b in zip(sess, bins): 101 pr = participation_ratio(b) 102 rows.append({"key": s.key, "subject": sid, "condition": s.condition, "day": s.day, "mapping": s.mapping, 103 "PR": pr, "PR_norm": pr / b.shape[0]}) 104 allb = np.concatenate(bins, axis=1) 105 mu, sd = allb.mean(1, keepdims=True), allb.std(1, keepdims=True) + 1e-9 106 z = ((allb - mu) / sd).T 107 pcs = PCA(N_PCS, random_state=0).fit_transform(z) 108 lengths = [b.shape[1] for b in bins] 109 with warnings.catch_warnings(): 110 warnings.simplefilter("ignore") 111 hmm = GaussianHMM(N_STATES, covariance_type="diag", n_iter=200, random_state=0).fit(pcs, lengths) 112 states = hmm.predict(pcs, lengths) 113 off = np.cumsum([0] + lengths) 114 for i, s in enumerate(sess): 115 st = states[off[i]:off[i + 1]] 116 occ = np.bincount(st, minlength=N_STATES) / len(st) 117 nz = occ[occ > 0] 118 changes = np.flatnonzero(np.diff(st)) + 1 119 runs = np.diff(np.concatenate([[0], changes, [len(st)]])) 120 rows[-len(sess) + i].update({"occupancy_entropy": float(-(nz * np.log2(nz)).sum()), 121 "dwell_s": float(runs.mean() * width / ds.fs), 122 "transitions_per_min": float(len(changes) / (len(st) * width / ds.fs / 60))}) 123 occ_rows.append({"key": s.key, "subject": sid, "condition": s.condition, "day": s.day, 124 **{f"state_{k}": occ[k] for k in range(N_STATES)}}) 125 if s.condition == "SD" and s.day <= 9: 126 lab = bin_labels(ds.labels(s), width)[: len(st)] 127 examples.setdefault(sid, []).append((st, lab)) 128 sd_pairs = examples.get(sid, []) 129 if sd_pairs: 130 st_all = np.concatenate([p[0] for p in sd_pairs]) 131 lab_all = np.concatenate([p[1] for p in sd_pairs]) 132 keep = ~np.isin(lab_all, ["none", "mixed"]) 133 mi = mutual_info_score(lab_all[keep], st_all[keep]) 134 null = [] 135 for _ in range(200): 136 lab_s = np.roll(lab_all, rng.integers(30, len(lab_all) - 30)) 137 k = ~np.isin(lab_s, ["none", "mixed"]) 138 null.append(mutual_info_score(lab_s[k], st_all[k])) 139 mi_rows.append({"subject": sid, "MI_bits": mi / np.log(2), "null_mean_bits": np.mean(null) / np.log(2), 140 "z": (mi - np.mean(null)) / (np.std(null) + 1e-12), 141 "p": (1 + np.sum(np.array(null) >= mi)) / 201}) 142 m = pd.DataFrame(rows) 143 occ = pd.DataFrame(occ_rows) 144 mi = pd.DataFrame(mi_rows) 145 146 core = m[m.condition.isin(CORE_CONDITIONS) & (m.day <= 9)] 147 metrics = {"PR_norm": "normalised dimensionality (PR/N)", "occupancy_entropy": "state-occupancy entropy (bits)", 148 "dwell_s": "mean dwell time (s)", "transitions_per_min": "state transitions / min"} 149 st_rows, pivots = [], {} 150 for k in metrics: 151 pv = animal_means(core, k, CORE_CONDITIONS) 152 pivots[k] = pv 153 fr = friedman(pv) 154 for a, b in [("BL", "SD"), ("BL", "SS"), ("SD", "SS")]: 155 st_rows.append({"metric": k, **paired(pv, a, b), **fr}) 156 st = pd.DataFrame(st_rows) 157 st["q_fdr"] = fdr_bh(st.p) 158 159 oc = occ[occ.condition.isin(CORE_CONDITIONS) & (occ.day <= 9)] 160 js_rows = [] 161 cols = [f"state_{k}" for k in range(N_STATES)] 162 for sid, d in oc.groupby("subject"): 163 d = d.reset_index(drop=True) 164 same, diff = [], [] 165 for i in range(len(d)): 166 for j in range(i + 1, len(d)): 167 if d.day[i] == d.day[j]: 168 continue 169 p, q = d.loc[i, cols].to_numpy(float) + 1e-9, d.loc[j, cols].to_numpy(float) + 1e-9 170 js = float(stats.entropy(p, (p + q) / 2, base=2) / 2 + stats.entropy(q, (p + q) / 2, base=2) / 2) 171 (same if d.condition[i] == d.condition[j] else diff).append(js) 172 js_rows.append({"subject": sid, "JS_same_context": np.mean(same), "JS_different_context": np.mean(diff)}) 173 js = pd.DataFrame(js_rows) 174 js_p = stats.wilcoxon(js.JS_different_context, js.JS_same_context, alternative="greater", method="exact").pvalue 175 mi_p = stats.wilcoxon(mi.z, alternative="greater", method="exact").pvalue if len(mi) >= 4 else np.nan 176 177 self._fig_metrics(pivots, st, metrics) 178 self._fig_occupancy(occ) 179 self._fig_example(examples) 180 self._fig_mi(mi, js, mi_p, js_p) 181 182 self.table(st[["metric", "contrast", "n", "median_diff", "dz", "p", "q_fdr", "friedman_p"]], "condition_tests", 183 "Dimensionality and HMM measures: condition contrasts", "Exact Wilcoxon on per-animal means (days 1–9 without day 7).") 184 self.table(js, "state_context_specificity", "Context specificity of HMM state occupancy", 185 "Jensen–Shannon divergence between occupancy distributions of different-day sessions.") 186 self.table(mi, "state_behavior_mi", "HMM state – behavior mutual information (defeat sessions)", 187 "MI vs 200 circular shifts of the behavior sequence.") 188 self.table(m, "session_dynamics", "Session-level dynamics measures", show=12) 189 190 r = st[(st.metric == "PR_norm") & (st.contrast == "SD vs BL")].iloc[0] 191 self.highlight("Dimensionality during defeat", f"{r.median_diff:+.3f} PR/N", 192 f"Median paired change (SD − BL), {r.n_increase}/{r.n} animals higher, {fmt_p(r.p)}, q = {r.q_fdr:.3f}.", 193 "significant" if r.q_fdr < 0.05 else "trend" if r.p < 0.05 else "null") 194 self.highlight("Context-specific population states", f"JS {js.JS_same_context.mean():.3f} vs {js.JS_different_context.mean():.3f}", 195 f"Occupancy divergence for same- vs different-context sessions (different days), {fmt_p(js_p)}.", 196 "significant" if js_p < 0.05 else "null") 197 self.highlight("States track defeat behavior", f"MI z = {mi.z.mean():.1f}", 198 f"{int((mi.p < 0.05).sum())}/{len(mi)} animals above circular-shift null; group {fmt_p(mi_p)}.", 199 "significant" if mi_p < 0.05 else "null") 200 201 means = pivots["PR_norm"].mean() 202 results = f""" 203**Dimensionality.** Normalised participation ratio averaged BL {means['BL']:.3f}, SD {means['SD']:.3f} and SS 204{means['SS']:.3f} (days 1–9, day 7 excluded; SD vs BL median Δ = {r.median_diff:+.3f}, {fmt_p(r.p)}). Lower values mean activity is 205dominated by fewer shared modes. All contrasts are in Table 1 and Figure 1. 206 207**Hidden states.** A 6-state HMM per animal segmented population activity into recurring states. State-occupancy 208divergence between different-day sessions averaged {js.JS_same_context.mean():.3f} (JS) for the same context and 209{js.JS_different_context.mean():.3f} for different contexts ({fmt_p(js_p)}). 210{'Each context therefore biases which population states are visited' if js_p < 0.05 else 'State occupancy was therefore not detectably context-specific'} 211(Figure 2, Table 2). 212 213**States and behavior.** In defeat sessions, mutual information between HMM state and annotated behavior exceeded 214the circular-shift null in {int((mi.p < 0.05).sum())}/{len(mi)} animals (mean z = {mi.z.mean():.1f}, group {fmt_p(mi_p)}; 215Figure 4). An example state sequence aligned with the ethogram is shown in Figure 3. 216""" 217 return {"results": results, "parameters": {"bin_s": 1.0, "n_states": N_STATES, "n_pcs": N_PCS, "covariance": "diag"}} 218 219 def _fig_metrics(self, pivots, st, metrics) -> None: 220 fig, axes = plt.subplots(1, 4, figsize=(13, 3.3)) 221 for ax, (k, pv) in zip(axes, pivots.items()): 222 s = st[st.metric == k].set_index("contrast") 223 paired_conditions(ax, pv, metrics[k], metrics[k].split(" (")[0], 224 {("BL", "SD"): star(s.loc["SD vs BL", "p"]), ("BL", "SS"): star(s.loc["SS vs BL", "p"])}) 225 fig.tight_layout() 226 self.figure(fig, "dynamics_by_condition", "Dimensionality and state dynamics by phase", 227 "Per-animal means and group mean ± SEM (days 1–9 without day 7). Stars: exact Wilcoxon vs BL, uncorrected (* p < 0.05; with n = 6 the minimum two-sided p is 0.031). See the tables for FDR-corrected q.") 228 229 def _fig_occupancy(self, occ: pd.DataFrame) -> None: 230 cols = [f"state_{k}" for k in range(N_STATES)] 231 subs = occ.subject.unique() 232 fig, axes = plt.subplots(1, len(subs), figsize=(3 * len(subs), 3.2), sharey=True) 233 cmap = plt.get_cmap("Set2") 234 for ax, sid in zip(axes, subs): 235 d = occ[occ.subject == sid].groupby("condition")[cols].mean().reindex([c for c in CONDITION_ORDER if c in set(occ.condition)]) 236 bottom = np.zeros(len(d)) 237 for k, c in enumerate(cols): 238 ax.bar(range(len(d)), d[c], bottom=bottom, color=cmap(k), label=f"S{k}") 239 bottom += d[c].to_numpy() 240 ax.set_xticks(range(len(d)), [c.replace("_", "\n") for c in d.index], fontsize=6) 241 ax.set_title(str(sid)) 242 axes[0].set_ylabel("state occupancy") 243 axes[-1].legend(fontsize=6, bbox_to_anchor=(1, 1)) 244 self.figure(fig, "state_occupancy", "HMM state occupancy by context", 245 "Fraction of time in each of the 6 hidden states, per animal and context (states are animal-specific).") 246 247 def _fig_example(self, examples: dict) -> None: 248 sid = list(examples)[-1] 249 st, lab = examples[sid][len(examples[sid]) // 2] 250 behs = sorted(set(lab) - {"none", "mixed"}) 251 cmap = plt.get_cmap("tab20") 252 colors = {b: cmap(i) for i, b in enumerate(behs)} 253 fig, axes = plt.subplots(2, 1, figsize=(11, 2.6), sharex=True) 254 axes[0].imshow(st[None], aspect="auto", cmap="Set2", vmin=0, vmax=7) 255 axes[0].set_yticks([0], ["HMM state"]) 256 axes[1].imshow(np.array([colors.get(b, (1, 1, 1, 1)) for b in lab])[None], aspect="auto") 257 axes[1].set_yticks([0], ["behavior"]) 258 axes[1].set_xlabel("time (s)") 259 handles = [plt.Rectangle((0, 0), 1, 1, color=colors[b]) for b in behs] 260 fig.legend(handles, behs, ncol=min(8, len(behs)), fontsize=7, loc="lower center", bbox_to_anchor=(0.5, -0.12)) 261 self.figure(fig, "state_sequence_example", "Hidden-state sequence during defeat", 262 f"Viterbi HMM state sequence (top) and annotated behavior (bottom) for one defeat session of animal {sid}.") 263 264 def _fig_mi(self, mi, js, mi_p, js_p) -> None: 265 fig, axes = plt.subplots(1, 2, figsize=(9, 3.2)) 266 axes[0].bar(mi.subject.astype(str), mi.z, color=["#b5523b" if p < 0.05 else "0.6" for p in mi.p]) 267 axes[0].axhline(0, color="k", lw=0.6) 268 axes[0].set_ylabel("MI z vs circular-shift null") 269 axes[0].set_title(f"State–behavior coupling ({fmt_p(mi_p)})") 270 for _, r in js.iterrows(): 271 axes[1].plot([0, 1], [r.JS_same_context, r.JS_different_context], marker="o", color="0.5") 272 axes[1].set_xticks([0, 1], ["same context", "different context"]) 273 axes[1].set_ylabel("JS divergence of occupancy") 274 axes[1].set_title(f"Context specificity ({fmt_p(js_p)})") 275 self.figure(fig, "state_behavior_coupling", "HMM states, behavior and context", 276 "Left: mutual information between hidden state and behavior in defeat sessions (red: p < 0.05 per animal). " 277 "Right: occupancy divergence between different-day sessions sharing vs not sharing a context.")
Study S10
Susceptible vs resilient: neural correlates of the CSDS phenotype
Which per-animal network, activity and coding measures separate the lab-classified susceptible and resilient mice?
1"""S10 — Susceptible vs resilient: neural correlates of the CSDS phenotype (exploratory).""" 2 3from __future__ import annotations 4 5from itertools import combinations 6 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10from scipy import stats 11 12from ..data import CORE_CONDITIONS 13from ..stats import fdr_bh, fmt_p, matched_days, spearman_exact 14from .base import Study 15from .shared import session_networks 16 17PHENO_COLORS = {"susceptible": "#b5523b", "resilient": "#3b7d6e"} 18NET = {"mean_rate": "event rate (Hz)", "mean_r": "mean pairwise r", "sig_density": "significant-edge density", 19 "prop_clustering": "clustering", "prop_efficiency": "global efficiency", "prop_modularity": "modularity Q"} 20 21 22def group_perm(values: pd.Series, labels: pd.Series) -> dict: 23 """Exact permutation test of the susceptible − resilient mean difference over all relabelings.""" 24 d = pd.DataFrame({"v": values, "g": labels}).dropna() 25 v, sus = d.v.to_numpy(float), (d.g == "susceptible").to_numpy() 26 k, n = int(sus.sum()), len(v) 27 if k < 2 or n - k < 2: 28 return {} 29 obs = v[sus].mean() - v[~sus].mean() 30 null = [] 31 for idx in combinations(range(n), k): 32 m = np.zeros(n, bool) 33 m[list(idx)] = True 34 null.append(v[m].mean() - v[~m].mean()) 35 null = np.array(null) 36 sp = np.sqrt(((k - 1) * v[sus].var(ddof=1) + (n - k - 1) * v[~sus].var(ddof=1)) / (n - 2)) 37 g = obs / sp * (1 - 3 / (4 * (n - 2) - 1)) if sp > 0 else np.nan 38 separated = v[sus].min() > v[~sus].max() or v[sus].max() < v[~sus].min() 39 return {"susceptible_mean": v[sus].mean(), "resilient_mean": v[~sus].mean(), "difference": obs, "hedges_g": g, 40 "complete_separation": bool(separated), "p_exact": float(np.mean(np.abs(null) >= abs(obs) - 1e-12)), 41 "n_relabelings": len(null)} 42 43 44class Phenotype(Study): 45 id = "s10" 46 number = 10 47 title = "Susceptible vs resilient: neural correlates of the CSDS phenotype" 48 short = "Which per-animal network, activity and coding measures separate the lab-classified susceptible and resilient mice?" 49 question = ("Do susceptible and resilient mice differ in how their neuronal activity, functional networks and " 50 "population codes respond to defeat, and in how those responses evolve across days?") 51 background = """ 52Only a subset of mice subjected to CSDS develop lasting social avoidance (susceptible); the rest remain resilient 53(Krishnan et al. 2007; Golden et al. 2011). Resilience is an active process with its own neural signatures. Pre-stress 54nucleus accumbens D1-MSN activity predicts later resilience (Muir et al. 2018), a brain-wide electrical network 55signature tracks vulnerability (Hultman et al. 2018), and susceptible and resilient mice differ in large-scale 56functional connectivity after CSDS (Mol Psychiatry 2025). Hippocampal social coding also differs between the 57phenotypes (Biol Psychiatry Glob Open Sci 2025). Here the lab classified 3 mice as resilient (4703, 6597, 6669) and 583 as susceptible (6445, 6672, 6675), so we can ask which single-neuron network measures, measured *during* the defeat 59period, differ between the groups. 60""" 61 methods = """ 62**Per-animal features (days 1–9 without day 7).** 63* *Behavior (S01):* defeat-response index (fraction of SD time submissive + freezing + escape) and the 64 annotation-based SI proxy. 65* *Activity and networks (S02, S04):* for event rate, mean pairwise correlation, significant-edge density, 66 clustering, global efficiency and modularity, the animal's baseline (BL) mean and the defeat response 67 (SD − BL) and sensory-stress response (SS − BL), computed over days on which the animal has all of BL, SD and SS. 68* *Reorganisation (S06):* FC context specificity and the drift slope of SD networks (similarity per day of 69 separation, from the pairwise session similarities). 70* *Coding (S08):* context-decoding accuracy and behavior-decoding accuracy above each animal's own null. 71 72**Group comparison.** For each feature, the susceptible − resilient mean difference was tested with an exact 73permutation test over all 20 ways of labelling 3 of the 6 animals as susceptible (two-sided). With 3 vs 3 the 74smallest attainable p is 0.10, which happens only when the groups are completely separated. The study therefore reports 75Hedges' g and *complete separation* (every susceptible value above, or below, every resilient value) as the 76primary descriptors. Under the null, any given feature shows complete separation with probability 0.10. BH-FDR 77q-values are reported for completeness. 78 79**Trajectories.** Session-level measures were averaged per phenotype for each recording day and condition to show 80whether the groups diverge as defeats accumulate. 81 82**Continuous coupling.** Spearman ρ (exact, 720 permutations) between each neural feature and the defeat-response 83index. 84""" 85 limitations = """ 86* n = 3 per group. No feature can reach p < 0.05, and with ~30 features about 3 would be expected to separate 87 the groups by chance. Results identify candidate markers for a confirmatory cohort and are not evidence of a 88 phenotype difference. 89* Mouse 6597 has no sensory-stress block on day 9, so that animal's network features use days 1, 2, 3 and 5. Mouse 6669 90 lacks the SIT imaging block; day-1–9 features for that animal are complete. Day 7 is excluded for every mouse. 91* No non-defeated control group, so "resilient" differences cannot be separated from "less affected by the assay". 92""" 93 references = [ 94 "Krishnan V. et al. (2007) Molecular adaptations underlying susceptibility and resistance to social defeat in brain reward regions. Cell 131:391–404.", 95 "Golden S.A. et al. (2011) A standardized protocol for repeated social defeat stress in mice. Nat Protoc 6:1183–1191.", 96 "Muir J. et al. (2018) In vivo fiber photometry reveals signature of future stress susceptibility in nucleus accumbens. Neuropsychopharmacology 43:255–263.", 97 "Hultman R. et al. (2018) Brain-wide electrical spatiotemporal dynamics encode depression vulnerability. Cell 173:166–180.", 98 "Resting-state fMRI reveals altered functional connectivity associated with resilience and susceptibility to CSDS (2025) Mol Psychiatry. doi:10.1038/s41380-025-02897-2", 99 "Diminished social memory and hippocampal correlates of social interactions in chronic social defeat stress susceptibility (2025) Biol Psychiatry Glob Open Sci. https://www.bpsgos.org/article/S2667-1743(25)00009-6/fulltext", 100 "Button K.S. et al. (2013) Power failure: why small sample size undermines the reliability of neuroscience. Nat Rev Neurosci 14:365–376.", 101 ] 102 103 def run(self) -> dict: 104 root = self.ctx.table_root 105 pheno = pd.Series(self.ds.phenotype, name="phenotype") 106 net, _ = session_networks(self.ctx, "spikes") 107 core = net[net.condition.isin(CORE_CONDITIONS) & (net.day <= 9)].copy() 108 core["phenotype"] = core.subject.map(pheno) 109 110 means = matched_days(core, CORE_CONDITIONS).groupby(["subject", "condition"])[list(NET)].mean() 111 bl, sd, ss = (means.xs(c, level="condition") for c in CORE_CONDITIONS) 112 feats = pd.concat([bl.rename(columns=lambda m: f"{NET[m]} · BL"), 113 (sd - bl).rename(columns=lambda m: f"{NET[m]} · SD − BL"), 114 (ss - bl).rename(columns=lambda m: f"{NET[m]} · SS − BL")], axis=1) 115 beh = self.ctx.cache.get("phenotype") 116 if beh is None and (root / "s01" / "sit_phenotype.csv").exists(): 117 beh = pd.read_csv(root / "s01" / "sit_phenotype.csv") 118 if beh is not None: 119 beh = beh.set_index("subject") 120 feats["defeat-response index"] = beh["defeat_response_index"] 121 feats["SI proxy (annotations)"] = beh["SI_ratio_proxy"] 122 if (root / "s06" / "specificity_per_animal.csv").exists(): 123 spec = pd.read_csv(root / "s06" / "specificity_per_animal.csv").set_index("subject") 124 feats["FC context specificity"] = spec["specificity"] 125 if (root / "s06" / "pairwise_similarity.csv").exists(): 126 pairs = pd.read_csv(root / "s06" / "pairwise_similarity.csv") 127 sdp = pairs[(pairs.cond_a == "SD") & (pairs.cond_b == "SD") & (pairs.day_b <= 9) & (pairs.day_a <= 9)] 128 feats["SD network drift (Δsim/day)"] = sdp.groupby("subject").apply( 129 lambda g: np.polyfit(g.lag, g.similarity, 1)[0], include_groups=False) 130 if (root / "s08" / "per_animal_decoding.csv").exists(): 131 dec = pd.read_csv(root / "s08" / "per_animal_decoding.csv") 132 dec["above_null"] = dec.balanced_accuracy - dec.null_mean 133 for a, d in dec.groupby("analysis"): 134 feats[f"decoding {a.split(' (')[0]} {a[a.find('('):].split(',')[0].strip('()')} (above null)"] = d.set_index("subject").above_null 135 136 rows = [] 137 lab = pheno.reindex(feats.index) 138 for f in feats.columns: 139 r = group_perm(feats[f], lab) 140 if r: 141 rho, p_rho = (spearman_exact(feats[f], feats["defeat-response index"]) 142 if "defeat-response index" in feats and f != "defeat-response index" else (np.nan, np.nan)) 143 rows.append({"feature": f, **r, "rho_defeat_index": rho, "p_rho": p_rho}) 144 res = pd.DataFrame(rows) 145 res["q_fdr"] = fdr_bh(res.p_exact) 146 res = res.sort_values(["complete_separation", "hedges_g"], key=lambda s: s.abs() if s.name == "hedges_g" else s, 147 ascending=False).reset_index(drop=True) 148 149 self._fig_features(feats, lab, res) 150 self._fig_trajectories(core) 151 self._fig_profiles(core) 152 153 self.table(res, "phenotype_comparison", "Susceptible vs resilient: per-animal features", 154 "Exact permutation over all 20 relabelings (two-sided; minimum attainable p = 0.10). " 155 "Hedges' g = susceptible − resilient in pooled-SD units.", show=40) 156 self.table(feats.assign(phenotype=lab).reset_index().rename(columns={"index": "subject"}), "animal_features", 157 "Per-animal feature matrix", "All features used in the comparison.") 158 159 sep = res[res.complete_separation] 160 n_feat = len(res) 161 p_count = float(stats.binom.sf(len(sep) - 1, n_feat, 0.1)) 162 self.highlight("Phenotype groups", "3 susceptible · 3 resilient", 163 "Lab classification: resilient 4703, 6597, 6669; susceptible 6445, 6672, 6675.", "info") 164 self.highlight("Features separating the groups", f"{len(sep)}/{n_feat}", 165 f"Complete separation of susceptible and resilient animals; ≈{0.1 * n_feat:.1f} expected by chance " 166 f"(binomial p = {p_count:.2f}, ignoring correlations among features).", "trend" if p_count < 0.05 else "null") 167 for _, r in sep.head(3).iterrows(): 168 direction = "higher" if r.difference > 0 else "lower" 169 self.highlight(r.feature, f"g = {r.hedges_g:+.2f}", 170 f"Susceptible {direction} ({r.susceptible_mean:.3g} vs {r.resilient_mean:.3g}), complete separation, " 171 f"exact {fmt_p(r.p_exact)} (minimum attainable).", 172 "trend" if p_count < 0.05 else "info") 173 self.highlight("Power", "n = 3 per group", "No 3-vs-3 comparison can reach p < 0.05; these are candidate markers.", 174 "caution") 175 176 listing = "; ".join(f"{r.feature} (g = {r.hedges_g:+.2f}, susceptible {'>' if r.difference > 0 else '<'} resilient)" 177 for r in sep.itertuples()) or "none" 178 top = res.iloc[:5] 179 results = f""" 180**Group separation.** Of {n_feat} per-animal features, {len(sep)} completely separated the 3 susceptible from the 3 181resilient mice, against ≈{0.1 * n_feat:.1f} expected by chance: {listing}. 182The chance of seeing at least this many separating features is p = {p_count:.2f} (binomial, treating features as 183independent; they are correlated, so this is optimistic). {'The count exceeds chance.' if p_count < 0.05 else 'The count is compatible with chance, so no single feature should be read as a phenotype marker.'} Each such feature has the minimum 184attainable exact p of 0.10. Table 1 lists all features ranked by separation and |Hedges' g|, and Figure 1 shows the 185strongest ones per animal. 186 187**Largest effects.** {'; '.join(f"{r.feature}: susceptible {r.susceptible_mean:.3g} vs resilient {r.resilient_mean:.3g} (g = {r.hedges_g:+.2f})" for r in top.itertuples())}. 188 189**Trajectories.** Figure 2 shows activity and network measures per recording day for each phenotype in BL, SD 190and SS sessions. Figure 3 shows the BL → SD → SS profile of each group. 191 192**Interpretation.** With three animals per group, this analysis cannot establish a phenotype difference. Features 193that separate the groups *and* agree with prior literature are the natural pre-registered hypotheses for a 194larger cohort. 195""" 196 return {"results": results, "parameters": {"groups": "lab classification (3 vs 3)", "test": "exact permutation (20)", 197 "days": "1, 2, 3, 5, 9 (day 7 excluded)"}} 198 199 def _fig_features(self, feats: pd.DataFrame, lab: pd.Series, res: pd.DataFrame) -> None: 200 top = res.head(8) 201 fig, axes = plt.subplots(2, 4, figsize=(13, 6.2)) 202 rng = np.random.default_rng(0) 203 for ax, r in zip(axes.ravel(), top.itertuples()): 204 for i, g in enumerate(["resilient", "susceptible"]): 205 v = feats.loc[lab == g, r.feature] 206 ax.scatter(i + rng.uniform(-0.08, 0.08, len(v)), v, color=PHENO_COLORS[g], s=40, edgecolor="k", lw=0.4) 207 ax.hlines(v.mean(), i - 0.2, i + 0.2, color="k", lw=1.5) 208 for sid, val in v.items(): 209 ax.annotate(str(sid), (i, val), fontsize=5.5, xytext=(6, 0), textcoords="offset points", va="center") 210 ax.set_xticks([0, 1], ["resilient", "susceptible"]) 211 ax.set_xlim(-0.6, 1.8) 212 ax.set_title(f"{r.feature}\ng = {r.hedges_g:+.2f}, {fmt_p(r.p_exact)}" + (" · separated" if r.complete_separation else ""), 213 fontsize=8) 214 for ax in axes.ravel()[len(top):]: 215 ax.axis("off") 216 self.figure(fig, "phenotype_features", "Features that best distinguish susceptible from resilient mice", 217 "Per-animal values (labelled by ID) for the 8 features ranked highest by complete separation and |Hedges' g|. " 218 "Bars = group means. p = exact permutation (minimum attainable 0.10).") 219 220 def _fig_trajectories(self, core: pd.DataFrame) -> None: 221 metrics = ["mean_rate", "mean_r", "prop_modularity", "prop_efficiency"] 222 fig, axes = plt.subplots(len(CORE_CONDITIONS), len(metrics), figsize=(13, 8), sharex=True) 223 for i, c in enumerate(CORE_CONDITIONS): 224 d = core[core.condition == c] 225 for j, m in enumerate(metrics): 226 ax = axes[i, j] 227 for _, g in d.groupby("subject"): 228 ax.plot(g.day, g[m], color=PHENO_COLORS[g.phenotype.iloc[0]], alpha=0.25, lw=0.8) 229 for ph, g in d.groupby("phenotype"): 230 s = g.groupby("day")[m].agg(["mean", "sem"]) 231 ax.errorbar(s.index, s["mean"], yerr=s["sem"], color=PHENO_COLORS[ph], marker="o", ms=3, lw=1.8, 232 capsize=2, label=ph if (i, j) == (0, 0) else None) 233 if i == 0: 234 ax.set_title(NET[m], fontsize=9) 235 if j == 0: 236 ax.set_ylabel(c) 237 if i == len(CORE_CONDITIONS) - 1: 238 ax.set_xlabel("CSDS day") 239 axes[0, 0].legend(fontsize=7) 240 self.figure(fig, "phenotype_trajectories", "Day-by-day trajectories by phenotype", 241 "Rows: BL, SD and SS sessions; columns: activity and network measures. Thin lines = animals, thick = phenotype " 242 "mean ± SEM. Day 7 excluded.") 243 244 def _fig_profiles(self, core: pd.DataFrame) -> None: 245 fig, axes = plt.subplots(1, len(NET), figsize=(15, 3)) 246 for ax, (m, label) in zip(axes, NET.items()): 247 a = core.groupby(["phenotype", "subject", "condition"])[m].mean().reset_index() 248 for ph, g in a.groupby("phenotype"): 249 for _, h in g.groupby("subject"): 250 h = h.set_index("condition").reindex(CORE_CONDITIONS) 251 ax.plot(range(3), h[m], color=PHENO_COLORS[ph], alpha=0.3, lw=0.8) 252 s = g.groupby("condition")[m].mean().reindex(CORE_CONDITIONS) 253 ax.plot(range(3), s, color=PHENO_COLORS[ph], marker="o", lw=2, label=ph) 254 ax.set_xticks(range(3), CORE_CONDITIONS) 255 ax.set_title(label, fontsize=9) 256 axes[0].legend(fontsize=7) 257 self.figure(fig, "phenotype_profiles", "Condition profiles by phenotype", 258 "Mean over days of each measure in BL, SD and SS for each animal (thin) and phenotype (thick).")