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.

src/csds_networks/studies/s00_data_qc.py 423 lines Results
s00_data_qc.py
  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.

src/csds_networks/studies/s01_behavior.py 222 lines Results
s01_behavior.py
  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.

src/csds_networks/studies/s02_activity.py 216 lines Results
s02_activity.py
  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?

src/csds_networks/studies/s03_tuning.py 292 lines Results
s03_tuning.py
  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.

src/csds_networks/studies/s04_topology.py 286 lines Results
s04_topology.py
  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.

src/csds_networks/studies/s05_communities.py 261 lines Results
s05_communities.py
  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.

src/csds_networks/studies/s06_reorganization.py 297 lines Results
s06_reorganization.py
  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.

src/csds_networks/studies/s07_state_networks.py 234 lines Results
s07_state_networks.py
  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.

src/csds_networks/studies/s08_decoding.py 232 lines Results
s08_decoding.py
  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.

src/csds_networks/studies/s09_dynamics.py 278 lines Results
s09_dynamics.py
  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?

src/csds_networks/studies/s10_phenotype.py 259 lines Results
s10_phenotype.py
  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).")

Shared by the studies

Framework

Shared session cache

Used by several studies; not a results section.

src/csds_networks/studies/shared.py 93 lines
shared.py
 1"""Session-level network cache shared by several studies."""
 2
 3from __future__ import annotations
 4
 5import hashlib
 6import json
 7
 8import numpy as np
 9import pandas as pd
10
11from ..networks import (
12    binarize,
13    circular_shift,
14    corr,
15    louvain,
16    modularity,
17    null_threshold,
18    proportional,
19    topology,
20    upper,
21)
22from .base import Context
23
24
25def regress_global(x: np.ndarray) -> np.ndarray:
26    """Residuals of each neuron after least-squares regression on the population mean (global signal regression)."""
27    g = x.mean(0)
28    g = g - g.mean()
29    xc = x - x.mean(1, keepdims=True)
30    beta = xc @ g / max(float(g @ g), 1e-12)
31    return xc - np.outer(beta, g)
32
33
34def session_networks(ctx: Context, signal: str = "spikes") -> tuple[pd.DataFrame, dict[str, np.ndarray]]:
35    """FC matrix and graph measures for every session (cached on disk and in memory).
36
37    A ``_gsr`` suffix (e.g. ``spikes_gsr``) applies global signal regression before computing correlations.
38    """
39    key = f"networks_{signal}"
40    if key in ctx.cache:
41        return ctx.cache[key]
42    net_cfg = ctx.cfg["networks"]
43    out_dir = ctx.root / ctx.cfg["paths"]["processed_networks"]
44    out_dir.mkdir(parents=True, exist_ok=True)
45    csv, npz = out_dir / f"session_metrics_{signal}.csv", out_dir / f"session_fc_{signal}.npz"
46    stamp_file = out_dir / f"session_cache_{signal}.json"
47    sessions = ctx.ds.sessions
48    stamp = hashlib.sha256(json.dumps({
49        "sessions": sessions[["key", "start", "stop"]].astype(str).values.tolist(),
50        "networks": net_cfg, "fs": ctx.ds.fs,
51    }, sort_keys=True).encode()).hexdigest()
52    if csv.exists() and npz.exists() and stamp_file.exists() and json.loads(stamp_file.read_text()).get("stamp") == stamp:
53        df = pd.read_csv(csv)
54        with np.load(npz) as z:
55            fc = {k: z[k] for k in z.files}
56        ctx.cache[key] = (df, fc)
57        return df, fc
58
59    rng = np.random.default_rng(net_cfg["seed"])
60    rows, fc = [], {}
61    for s in ctx.ds.iter_sessions():
62        gsr = signal.endswith("_gsr")
63        x = ctx.ds.activity(s, signal.removesuffix("_gsr"))
64        rate = float(x.mean() * ctx.ds.fs) if signal.startswith("spikes") else float(x.mean())
65        if gsr:
66            x = regress_global(x)
67        c = corr(x)
68        fc[s.key] = c.astype(np.float32)
69        thr = null_threshold(x, net_cfg["null_shuffles"], net_cfg["null_quantile"], rng)
70        a_sig = binarize(c, thr)
71        a_prop = proportional(c, net_cfg["proportional_density"])
72        u = upper(c)
73        row = {"key": s.key, "subject": s.subject, "condition": s.condition, "day": s.day, "mapping": s.mapping,
74               "n_neurons": x.shape[0], "n_frames": x.shape[1],
75               "mean_rate": rate,
76               "mean_r": float(u.mean()), "mean_pos_r": float(u[u > 0].mean()) if (u > 0).any() else 0.0,
77               "null_thr": thr, "sig_density": float(upper(a_sig).mean())}
78        row.update({f"prop_{k}": v for k, v in topology(a_prop, seed=net_cfg["seed"],
79                                                        resolution=net_cfg["louvain_resolution"]).items()})
80        q_null = []
81        for _ in range(3):
82            a_null = proportional(corr(circular_shift(x, rng)), net_cfg["proportional_density"])
83            q_null.append(modularity(a_null, louvain(a_null, seed=net_cfg["seed"])))
84        row["prop_modularity_null"] = float(np.mean(q_null))
85        row["prop_modularity_excess"] = row["prop_modularity"] - row["prop_modularity_null"]
86        rows.append(row)
87    df = pd.DataFrame(rows)
88    df.to_csv(csv, index=False)
89    np.savez_compressed(npz, **fc)
90    stamp_file.write_text(json.dumps({"stamp": stamp}))
91    ctx.cache[key] = (df, fc)
92    return df, fc