Project Overview¶
The OpenScope Community Predictive Processing project investigates how the brain anticipates and responds to violations of sensory expectations — a core idea in predictive processing theory. The central hypothesis is that the brain continuously builds internal models of the sensory world, and that neural activity is shaped not just by what the eyes see, but by the difference between what was predicted and what actually occurred.
This project is fully described in:
Neural mechanisms of predictive processing: a collaborative community experiment through the OpenScope program arXiv:2504.09614
Specific Aims¶
The Specific Aim of this proposal is to elucidate the potential relationship between four most commonly studied mismatch stimuli and their associated error signals, as well as different neuronal implementations of predictive processing.
Hypothesis-Driven Framework¶
We hypothesize that…
H0: Mechanisms of predictive processing fundamentally differ depending on predictive set and prediction error types, and recruit different neuronal mechanisms.
Alternatively, we hypothesize that…
H1: A unified predictive processing mechanism drives all mismatch processing in the mammalian cortex.
To determine which of these two alternative hypotheses is correct, we propose to experimentally examine three major types of mismatch stimuli that have dominated the literature: temporal, motor, and omission. Further, we will examine two types of prediction sets: spatiotemporal (passive) and sensorimotor (active). Importantly, in all experiments, the region recorded (V1) will include spatially intermixed neurons selective for features of both the expected stimulus and the mismatch stimuli. For example, orientation and direction tuning are spatially mixed in V1, and the standard oddball paradigms Zhou et al. (2020); Hamm et al. (2021); Pak et al. (2021); Homann et al. (2022) and sensorimotor mismatch paradigms Jordan & Keller (2020) involve predicted stimuli and mismatch stimuli that differ in orientation or direction.
Significance and Innovation¶
We will collect the first comprehensive set of neuronal data enabling direct comparison across different mismatch error signals. Additionally, our methodology will be designed to integrate two-photon imaging and electrophysiological recordings, leveraging the strengths of both techniques. Resolving these alternative hypotheses will mark major progress for the field, unifying conflicting findings and clarifying how differences in experimental design shape interpretations of predictive processing.
Additionally, the resulting dataset will be a pivotal resource for validating mechanistic computational models across multiple mismatch types, advancing our understanding of predictive processing in the brain.
Proposed Experiment¶
1. Stimulus Set Design¶
Our stimulus set will be designed to contain a few validated mismatch stimuli. Particular attention will be given to the experimental design to allow fitting models of:
Synaptic adaptation
Positive and negative errors
Short-term memory that could emerge through local recurrence
E/I balance
Since we aim to bridge various visual stimuli designs piloted, analyzed, and deployed over the last decades, we will use sequences of drifting gratings. We will present five types of oddballs: a drifting grating halt, two alternative drifting orientations, an omission, and temporal jittered oddballs. All oddballs will be introduced in four different session contexts: standard mismatch, sensory-motor mismatch, sequential mismatch, and temporal jitter mismatch. These contexts will be separated based on the session and habituation design. Individual animals will experience all 4 contexts in different orders. Two cohorts of separate animals will be recorded with Neuropixels probes and multi-area two-photon imaging.
Session 1 – Standard Mismatch: Animals will be habituated to a series of drifting gratings of the same orientation. Various mismatch stimuli will be introduced randomly: differing orientations, omissions, and spatial oddballs.
Session 2 – Sensorimotor Mismatch: Animals will be habituated to a closed-loop visuo-motor running disk where the rotation of the disk will directly control visual flow on a screen in front of the mouse. On recording days, the same mismatch stimuli as in Session 1 will be introduced.
Session 3 – Sequence Mismatch: Animals will be habituated to rapid sequences of 4 stimuli. The same sequences will repeat, once per second, for 37 minutes. The same mismatch stimuli as in Session 1 will be introduced in the third position in the sequence order, once every 11 seconds, on average.
Session 4 – Temporal Mismatch, Duration: Animals will be exposed to a sequence of drifting gratings of the same orientation. Duration mismatches will be created by introducing gratings with durations that are either shorter or longer than expected. To examine temporal prediction and error signaling, we will use experimental manipulations similar to those used in previous work Huang et al. (2025). This involves sessions with two alternating trial blocks: fixed and jittered. In the fixed block, grating durations will remain constant across all trials other than mismatch trials. In the jittered block, grating durations will be drawn from a normal distribution with a large standard deviation, to introduce variability in timing. Each block will include duration mismatches once every 11 seconds, on average.
The inclusion of jittered blocks is critical for dissociating temporal predictive processing from alternative mechanisms. Under a predictive processing framework, duration mismatches should produce substantially stronger responses in fixed blocks, where stimulus timing is highly predictable. However, Huang et al. (2025) found that neural responses in the visual cortex to oddball temporal intervals were of a similar magnitude in both fixed and jittered temporal contexts. This indicates that responses to temporal oddballs may be explained by intrinsic stimulus-evoked dynamics, instead of temporal prediction or prediction-error signaling Huang et al. (2025).
By shifting the expected temporal structure within the standard oddball paradigm varying stimulus duration, we aim to investigate how neurons encode and resolve prediction errors related to stimulus duration, potentially engaging temporal mechanisms distinct from those involved in stimulus feature-based mismatches.
All feature-based sessions (Sessions 1–3) will experience 4 temporally based oddballs with equal frequencies: two alternative drifting grating orientations, one drifting grating halt, and one omission. These 4 oddballs will last 275 ms, will be shuffled and occur randomly, on average every 11 seconds throughout the 37-minute-long block. All sessions will experience 4 shared control blocks:
Randomized drifting gratings presented at 16 orientations with gray periods in between. Each orientation will occur once every 11 seconds to match the occurrence of mismatches in the first experimental block.
Randomized drifting gratings presented at 16 orientations without gray periods in between. Each orientation will occur once every 11 seconds to match the occurrence of mismatches in the first experimental block.
Open-loop replay of a closed-loop sensory-motor block with all oddball types pre-recorded but uncoupled to the movement of animals.
Randomized temporal jittered presentation of drifting gratings. Each jittered stimulus will occur every 11 seconds to match the occurrence of jittered mismatch in the experimental block.

The Mesoscope Recording Platform¶
This notebook focuses on data collected with the Mesoscope, a two-photon calcium imaging rig that images multiple cortical planes simultaneously across two visual areas (primary visual cortex VISp and lateral visual area VISl). Each session captures:
8 imaging planes (4 cortical depths × 2 brain areas), sampled at ~10–11 Hz per plane
GCaMP6f calcium indicator in excitatory neurons (Slc17a7-IRES2-Cre line)
ΔF/F traces for each segmented ROI (neuron), derived via a rolling-baseline neuropil-correction pipeline
Behavioral data: running speed on a wheel, eye tracking (pupil area)
Stimulus tables describing each individual grating presentation, its orientation, duration, type (standard vs. oddball), and block context
Experimental Paradigms¶
The Mesoscope dataset includes four mismatch paradigms:
| Paradigm | What is predicted | How it is violated |
|---|---|---|
| Standard Oddball | Identity of a repeated grating (0°, 2 Hz) | Change in orientation (45°, 90°), motion halt (0 Hz), or omission |
| Sensorimotor Mismatch | Visual flow coupled to the mouse’s own running | Uncoupling of wheel movement from visual feedback |
| Sequence Mismatch | Position of a grating within a fixed 4-element sequence | Replacement of the 3rd sequence element with an unexpected grating |
| Duration Mismatch | Temporal regularity of stimulus timing | Shortening or lengthening of individual stimulus presentations |
Because the same neurons are imaged across multiple sessions, it is possible to compare how individual cells respond to different types of prediction violations—a key feature of the community analysis plan.
Sensorimotor Mismatch Paradigm¶
This notebook uses a sensorimotor mismatch session. Animals were first habituated to a closed-loop environment in which rotation of the running wheel directly drove visual flow on the screen — a drifting grating whose motion was tightly coupled to the animal’s own locomotion. Through repeated exposure, animals learned to expect that running would produce matched visual motion. During recordings, this coupling was occasionally broken: the visual feedback was transiently replaced by an unexpected stimulus (an alternative grating orientation, a halt, or an omission), creating a mismatch between the animal’s motor command and the predicted sensory consequence. This design isolates active prediction errors — violations of an internally generated, movement-based expectation — from passive stimulus adaptation, because the unexpected stimuli are identical to those experienced in other contexts but now occur at a moment when the animal predicts visual flow coupled to its running.
What This Notebook Does¶
This is a data access and visualization template. It walks through:
Finding and opening a Mesoscope NWB file from DANDI
Inspecting session metadata
Exploring the imaging planes and ROI segmentation
Visualizing ROI masks on the mean-projection images
Extracting and plotting ΔF/F traces
Extracting running speed and eye-tracking behavior
Aligning neural and behavioral data on a common time axis
Exploring the stimulus tables and experimental block structure
Selecting oddball (mismatch) stimulus times
Computing and displaying stimulus-aligned average responses across all three modalities
Additional documentation, analysis plans, and community resources:
Community website — the central hub for the project, including the full analysis plan, collaboration policy, data access guides, and links to all community notebooks and discussions.
GitHub repository & Forum — source code, stimulus control scripts, and example data-access notebooks, as well as a discussions board for sharing updates, questions, and connecting.
Neuropixels (Ecephys) notebook — companion data-access notebook for the Ecephys neuropixels modality.
Data citation:
OpenScope Community Predictive Processing — DANDI Archive Dandiset 001637.
This notebook was developed with the assistance of Claude Sonnet (Anthropic).
0. Environment Setup¶
⚠️ Note: If running in a new environment, run the install cell once and then restart the kernel. ⚠️
import warnings
warnings.filterwarnings('ignore')
try:
from databook_utils.dandi_utils import dandi_download_open, dandi_stream_open
except ImportError:
!git clone https://github.com/AllenInstitute/openscope_databook.git
%cd openscope_databook
%pip install -e .import math
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from scipy import interpolate
%matplotlib inline1. Opening the NWB File¶
Mesoscope sessions from the Predictive Processing project are archived on DANDI under Dandiset 001768. Each NWB file packages one imaging session and contains:
One processing module per imaging plane (named
VISp_0,VISp_1, …,VISl_4, …), each containing segmentation, ΔF/F, and event dataA
runningprocessing module with wheel-based running speedAn
eye_trackingprocessing module (or acquisition object) with pupil area measurementsAn
intervalssection with one or more stimulus tables describing every individual grating presentation
The complete NWB files are being finalized and uploaded progressively. You can also stream the partial ophys-only NWB directly from S3 using the open_nwb_from_s3 function shown in the Predictive Processing notebooks.
Set dandiset_id and dandi_filepath to the session you want to work with. The example below uses a pre-released session from mouse 839909 recorded on the sensorimotor mismatch paradigm. In this paradigm, full-field drifting gratings are presented in closed-loop: the grating’s drift speed is yoked to the mouse’s treadmill running speed in real time, so the animal experiences visually-coupled locomotion. Violations are introduced by halting the visual flow while the mouse continues running — a sensorimotor prediction error. Sessions from other paradigms (Standard Oddball, Sequence Mismatch, Duration Mismatch) are also available in the dandiset. To explore what’s available, browse Dandiset 001768 on DANDI.
dandiset_id = "001768"
dandi_filepath = "sub-839909/sub-839909_ses-multiplane-ophys-839909-2026-02-20-12-53-27_ophys.nwb"
download_loc = "."# This can take several minutes the first time; the file is cached locally afterward.
io = dandi_download_open(dandiset_id, dandi_filepath, download_loc) # use dandi_stream_open to stream the file without downloading it
nwb = io.read()File already exists
Opening file
2. Session and Subject Metadata¶
Basic metadata stored in the NWB tells us what animal was used, when the recording happened, and what experiment was run. The genotype identifies the transgenic line, which determines which neurons are labeled with GCaMP. Predictive Processing Mesoscope sessions typically use the Slc17a7-IRES2-Cre driver, labeling the broad population of excitatory neurons.
print(f"Session ID: {nwb.session_id}")
print(f"Session description: {nwb.session_description}")
print(f"Session start time: {nwb.session_start_time}")
print(f"Institution: {nwb.institution}")
print(f"Experimenter: {nwb.experimenter}")
print()
subj = nwb.subject
print(f"Subject ID: {subj.subject_id}")
print(f"Species: {subj.species}")
print(f"Sex: {subj.sex}")
print(f"Age: {subj.age}")
print(f"Genotype: {subj.genotype}")Session ID: multiplane-ophys_839909_2026-02-20_12-53-27
Session description: A Unknown Project experiment performed using OPTICAL_SESSION1_SENSORYMOTOR behavior. Experiment includes Planar optical physiology, Behavior videos, Behavior modalities.
Session start time: 2026-02-20 12:53:27.423567-08:00
Institution: Allen Institute for Neural Dynamics
Experimenter: ("['NSB-1608', 'Jerome Lecoq']",)
Subject ID: 839909
Species: Mus musculus
Sex: M
Age: P95D
Genotype: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
3. Imaging Planes¶
The Mesoscope simultaneously images 8 planes across two brain areas. The naming convention is {area}_{depth_index}, where VISp is primary visual cortex and VISl is the lateral visual area (LM). The four depth indices (0–3) correspond to progressively deeper cortical laminae. This multi-plane design enables population-level analyses across the cortical depth and across areas, which is critical for testing theories about hierarchical predictive processing.
Key fields:
imaging_rate: frames-per-second per plane (~10 Hz for the Mesoscope)excitation_lambda: two-photon excitation wavelength (typically 920 nm for GCaMP6f)indicator: the calcium indicator usedgrid_spacing: spatial resolution in microns per pixelorigin_coords: the stereotaxic coordinates of the field of view
plane_names = sorted(nwb.imaging_planes.keys())
print(f"Number of imaging planes: {len(plane_names)}")
print()
for name in plane_names:
plane = nwb.imaging_planes[name]
print(f" {name}:")
print(f" location: {plane.location}")
print(f" imaging_rate: {plane.imaging_rate} Hz")
print(f" excitation_lambda: {plane.excitation_lambda} nm")
print(f" indicator: {plane.indicator}")
if plane.grid_spacing is not None:
print(f" grid_spacing: {plane.grid_spacing} {plane.grid_spacing_unit}")
print()Number of imaging planes: 8
VISl_4:
location: Structure: VISl Depth: 200
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISl_5:
location: Structure: VISl Depth: 300
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISl_6:
location: Structure: VISl Depth: 62
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISl_7:
location: Structure: VISl Depth: 400
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISp_0:
location: Structure: VISp Depth: 196
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISp_1:
location: Structure: VISp Depth: 314
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISp_2:
location: Structure: VISp Depth: 64
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
VISp_3:
location: Structure: VISp Depth: 400
imaging_rate: 10.71 Hz
excitation_lambda: 920.0 nm
indicator: Snap25-IRES2-Cre/wt;Oi4(TIT2L-jGCaMP8s-RiboL1-WPRE-ICL-IRES-tTA2-WPRE)/wt
grid_spacing: <HDF5 dataset "grid_spacing": shape (2,), type "<f8"> micrometer
4. ROI Segmentation Overview¶
Regions of interest (ROIs) are identified using Cellpose, a deep-learning segmentation model trained on cellular morphology. Each ROI in the Predictive Processing dataset is classified as either a soma or a dendritic structure, along with a continuous probability score. This is important for the project’s scientific aims: one key hypothesis involves whether mismatch signals are computed in apical dendrites vs. somata.
For most downstream analyses, you will want to restrict to soma ROIs (is_soma == True), which correspond to putative cell bodies.
plane_roi_stats = []
for name in plane_names:
proc = nwb.processing[name]
roi_table = proc["image_segmentation"]["roi_table"]
n_total = len(roi_table)
is_soma = np.array(roi_table["is_soma"].data)
is_dendrite = np.array(roi_table["is_dendrite"].data)
plane_roi_stats.append({
"plane": name,
"total_rois": n_total,
"soma_rois": int(is_soma.sum()),
"dendrite_rois": int(is_dendrite.sum()),
})
roi_df = pd.DataFrame(plane_roi_stats)
roi_df["total_soma"] = roi_df["soma_rois"]
roi_df = roi_df.drop(columns="total_soma")
print(roi_df.to_string(index=False))
print()
print(f"Total ROIs across all planes: {roi_df['total_rois'].sum()}")
print(f"Total soma ROIs: {roi_df['soma_rois'].sum()}")
print(f"Total dendrite ROIs: {roi_df['dendrite_rois'].sum()}") plane total_rois soma_rois dendrite_rois
VISl_4 351 325 1
VISl_5 235 218 1
VISl_6 76 67 0
VISl_7 288 268 0
VISp_0 345 315 0
VISp_1 230 209 0
VISp_2 84 77 0
VISp_3 201 190 0
Total ROIs across all planes: 1810
Total soma ROIs: 1669
Total dendrite ROIs: 2
5. Visualizing Planes and ROI Masks¶
Each imaging plane in the NWB has average and max projection images computed from the raw movie during registration. These are useful reference images that show the morphology of the field of view.
Overlaying the ROI masks on the mean projection lets you verify that segmentation has found real cells and gives an intuitive sense of cell density and layout across brain areas and depths. ROI masks are stored as pixel arrays for each identified cell; here we take the maximum across all masks to build a composite overlay.
The panel below shows all 8 planes arranged by area (rows) and depth (columns).
N_COLS_PLANES = 2 # how many planes side by side in the grid
n_planes = len(plane_names)
n_rows = math.ceil(n_planes / N_COLS_PLANES)
# Each plane occupies 2 axes: [max projection | ROI mask overlay]
fig, axes = plt.subplots(
n_rows, N_COLS_PLANES * 2,
figsize=(6 * N_COLS_PLANES * 2, 5 * n_rows)
)
axes = np.array(axes).reshape(n_rows, N_COLS_PLANES * 2)
for i, name in enumerate(plane_names):
row = i // N_COLS_PLANES
col_base = (i % N_COLS_PLANES) * 2 # left of the pair
ax_max = axes[row, col_base]
ax_roi = axes[row, col_base + 1]
proc = nwb.processing[name]
image_mod = proc["images"]
# Max projection (fall back to average if not present)
if "max_projection" in image_mod.images:
proj = np.array(image_mod["max_projection"].data)
proj_label = "Max Projection"
else:
proj = np.array(image_mod["average_projection"].data)
proj_label = "Avg Projection"
roi_table = proc["image_segmentation"]["roi_table"]
all_masks = np.array(roi_table["image_mask"].data[:])
# Binarize: any non-zero weight pixel counts as part of an ROI
binary_mask = (all_masks.max(axis=0) > 0).astype(np.float32)
n_soma = int(np.array(roi_table["is_soma"].data).sum())
ax_max.imshow(proj, cmap="gray")
ax_max.set_title(f"{name}\n{proj_label}", fontsize=9)
ax_max.axis("off")
ax_roi.imshow(proj, cmap="gray")
# Use a masked array so only ROI pixels get colored (zeros stay transparent)
masked = np.ma.masked_where(binary_mask == 0, binary_mask)
ax_roi.imshow(masked, cmap="hot", alpha=0.7, vmin=0, vmax=1)
ax_roi.set_title(f"{name}\nROI Masks ({n_soma} soma)", fontsize=9)
ax_roi.axis("off")
# Hide any unused axes (if n_planes is odd)
for i in range(n_planes, n_rows * N_COLS_PLANES):
row = i // N_COLS_PLANES
col_base = (i % N_COLS_PLANES) * 2
axes[row, col_base].axis("off")
axes[row, col_base + 1].axis("off")
fig.suptitle("All Imaging Planes — Max Projection and ROI Masks", fontsize=14)
plt.tight_layout()
plt.show()
6. Extracting ΔF/F Traces¶
ΔF/F (dF/F) is the standard normalized calcium signal used in population imaging. It is computed as:
where is the neuropil-corrected fluorescence and is a rolling estimate of the baseline. The processing pipeline also stores the raw fluorescence (raw_timeseries), neuropil fluorescence (neuropil_fluorescence_timeseries), neuropil-corrected signal (neuropil_corrected_timeseries), and detected calcium events (event_timeseries).
In the NWB, each plane has its own processing module. The code below extracts dF/F from a chosen plane and selects the 5 most responsive soma ROIs (by variance over a short initial window) for plotting. Restricting to soma ROIs avoids dendritic signals, which have a different biological interpretation.
Tip: The
dff_timeseriesdata array has shape(time, n_rois). When streaming from S3 or DANDI, slicing small chunks at a time is much faster than loading the full array.
# Choose which plane to inspect; change the index to select a different plane.
# The full list of available planes was printed by the Imaging Planes cell above.
PLANE = plane_names[0]
proc = nwb.processing[PLANE]
dff_series = proc["dff_timeseries"]["dff_timeseries"]
dff_data = dff_series.data # shape: (time, n_rois)
dff_ts = np.array(dff_series.timestamps)
roi_table = proc["image_segmentation"]["roi_table"]
is_soma = np.array(roi_table["is_soma"].data).astype(bool)
soma_indices = np.where(is_soma)[0]
print(f"Plane: {PLANE}")
print(f"dF/F array shape: {dff_data.shape} (timepoints × ROIs)")
print(f"Session duration: {dff_ts[-1] - dff_ts[0]:.1f} s")
print(f"Sampling rate: {1 / np.median(np.diff(dff_ts)):.2f} Hz")
print(f"Soma ROIs available: {len(soma_indices)}")Plane: VISl_4
dF/F array shape: (46303, 351) (timepoints × ROIs)
Session duration: 4325.6 s
Sampling rate: 10.70 Hz
Soma ROIs available: 325
N_ROIS_PLOT = 5
SNIPPET_LEN = min(1000, dff_data.shape[0])
# NOTE: loads SNIPPET_LEN × n_soma_rois values from disk/network (soma columns only)
# Load a small initial snippet to rank ROIs by variance
snippet = np.array(dff_data[:SNIPPET_LEN, :][:, soma_indices]) # (SNIPPET_LEN, n_soma)
soma_variances = np.var(snippet, axis=0) # variance already indexed to soma columns
top_soma_local = np.argsort(soma_variances)[-N_ROIS_PLOT:][::-1] # indices into soma_indices
top_roi_indices = soma_indices[top_soma_local] # indices into the full ROI table
print(f"Top {N_ROIS_PLOT} soma ROIs by variance (full ROI indices): {top_roi_indices}")Top 5 soma ROIs by variance (full ROI indices): [ 89 22 340 47 8]
# Display 120 seconds of the session for these top ROIs
DISPLAY_WINDOW = (0, 120) # seconds from session start
t0 = dff_ts[0]
time_mask = (dff_ts - t0 >= DISPLAY_WINDOW[0]) & (dff_ts - t0 <= DISPLAY_WINDOW[1])
idx_start = int(np.argmax(time_mask))
idx_end = len(time_mask) - int(np.argmax(time_mask[::-1]))
t_plot = dff_ts[idx_start:idx_end] - t0
fig, axes = plt.subplots(N_ROIS_PLOT, 1, figsize=(14, 2.5 * N_ROIS_PLOT), sharex=True)
for ax, roi_idx in zip(axes, top_roi_indices):
trace = np.array(dff_data[idx_start:idx_end, int(roi_idx)])
ax.plot(t_plot, trace, linewidth=0.6, color="steelblue")
ax.set_ylabel(f"ROI {roi_idx}\n$\\Delta$F/F", fontsize=8)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
axes[-1].set_xlabel("Time from session start (s)")
fig.suptitle(f"{PLANE} — $\\Delta$F/F traces for top {N_ROIS_PLOT} soma ROIs (by variance)", y=1.01)
plt.tight_layout()
plt.show()
7. Multi-Plane ΔF/F Raster¶
A raster (or heatmap) view of all ROIs in a plane is useful for spotting global response patterns and transitions between stimulus blocks. Each row is one ROI and each column is one time point. Brighter colors indicate higher ΔF/F.
Setting vmax controls the color scale. For GCaMP6f in excitatory neurons with grating stimuli, typical single-trial peak responses range from 0.1–1.0, so vmax=0.5 usually gives a useful dynamic range without saturating large responses.
RASTER_WINDOW = (0, 600) # seconds
RASTER_VMAX = 0.5 # adjust to taste
mask = (dff_ts - dff_ts[0] >= RASTER_WINDOW[0]) & (dff_ts - dff_ts[0] <= RASTER_WINDOW[1])
i0, i1 = int(np.argmax(mask)), len(mask) - int(np.argmax(mask[::-1]))
# NOTE: loads ~RASTER_WINDOW duration × n_soma_rois values from disk/network
dff_soma = np.array(dff_data[i0:i1, soma_indices]) # (time, n_soma)
t_raster = dff_ts[i0:i1] - dff_ts[0]
fig, ax = plt.subplots(figsize=(14, 6))
im = ax.imshow(
dff_soma.T,
aspect="auto",
extent=[t_raster[0], t_raster[-1], 0, dff_soma.shape[1]],
cmap="inferno",
vmin=0, vmax=RASTER_VMAX,
)
ax.set_xlabel("Time from session start (s)")
ax.set_ylabel("Soma ROI #")
ax.set_title(f"{PLANE} — $\\Delta$F/F raster ({len(soma_indices)} soma ROIs, first {RASTER_WINDOW[1]} s)")
plt.colorbar(im, ax=ax, label="$\\Delta$F/F")
plt.tight_layout()
plt.show()
8. Behavioral Data: Running Speed and Eye Tracking¶
The Mesoscope records two behavioral streams alongside the neural data:
Running speed — measured from a rotary encoder on the running wheel. Running strongly modulates visual cortical activity (gain effects), so it is important to know the behavioral state of the animal when interpreting neural responses. For the sensorimotor mismatch paradigm, running is also a direct part of the experimental manipulation.
Eye tracking — pupil area measured from a camera pointed at the animal’s eye. Pupil area is a proxy for arousal and attention. Large pupils typically indicate a more aroused, alert state, which is associated with higher baseline neural activity and reduced signal-to-noise ratio. Blinks appear as rapid drops in area.
In the NWB, running speed is stored in nwb.processing["running"] and eye tracking in nwb.processing["eye_tracking"], with the pupil ellipse data at nwb.processing["eye_tracking"]["pupil"].
The behavioral overview plot below displays the first 600 s of the session. Each signal is independently zeroed to its own first timestamp, so the three subplots are not perfectly time-aligned with one another — they share the same x-axis range but each starts from its own recording clock. Alignment of all three modalities (running, eye tracking, and neural) to a shared time axis is covered in Section 11, after the stimulus tables have been introduced.
# --- Running Speed ---
running_module = nwb.processing.get("running")
if running_module is not None:
speed_key = "speed" if "speed" in running_module.keys() else "running_speed"
running_ts_obj = running_module[speed_key]
running_speed_data = np.array(running_ts_obj.data)
running_speed_timestamps = np.array(running_ts_obj.timestamps)
print(f"Running speed: {len(running_speed_data)} samples, "
f"duration {running_speed_timestamps[-1] - running_speed_timestamps[0]:.1f} s")
else:
print("Running speed not found in this NWB.")
running_speed_data = np.array([])
running_speed_timestamps = np.array([])Running speed: 259139 samples, duration 4325.6 s
# --- Eye Tracking (pupil area) ---
pupil = nwb.processing['eye_tracking']['pupil']
eye_tracking_area = np.array(pupil['area'])
eye_tracking_timestamps = np.array(pupil['timestamps'])
# Alternative tables in the same module (uncomment to use instead):
# corneal_reflection = nwb.processing['eye_tracking']['corneal_reflection']
# ellipse_fits = nwb.processing['eye_tracking']['eye_tracked']
print(f"Eye tracking (pupil area): {len(eye_tracking_area)} samples, "
f"duration {eye_tracking_timestamps[-1] - eye_tracking_timestamps[0]:.1f} s")Eye tracking (pupil area): 266544 samples, duration 4442.2 s
BEHAV_WINDOW = (0, 600) # seconds to display
fig, axes = plt.subplots(3, 1, figsize=(14, 9), sharex=True)
# NOTE: each signal is zeroed to its *own* first timestamp, not a common session
# clock. The three subplots therefore share the same x-axis range (0–600 s) but
# are on independent time axes and are not perfectly aligned with each other.
# See Section 11 for proper interpolation onto a single shared time axis.
# --- Running speed ---
rs_t = running_speed_timestamps - running_speed_timestamps[0]
mask_rs = (rs_t >= BEHAV_WINDOW[0]) & (rs_t <= BEHAV_WINDOW[1])
axes[0].plot(rs_t[mask_rs], running_speed_data[mask_rs], lw=0.7, color="darkorange")
axes[0].set_ylabel("Running speed\n(cm/s)")
axes[0].set_title("Running Speed")
axes[0].spines["top"].set_visible(False)
axes[0].spines["right"].set_visible(False)
# --- Eye tracking (pupil area) ---
et_t = eye_tracking_timestamps - eye_tracking_timestamps[0]
mask_et = (et_t >= BEHAV_WINDOW[0]) & (et_t <= BEHAV_WINDOW[1])
axes[1].plot(et_t[mask_et], eye_tracking_area[mask_et], lw=0.7, color="purple")
axes[1].set_ylabel("Pupil area\n(pixels²)")
axes[1].set_title("Eye Tracking (Pupil Area)")
axes[1].spines["top"].set_visible(False)
axes[1].spines["right"].set_visible(False)
# --- Mean ΔF/F across soma ROIs ---
t_dff_rel = dff_ts - dff_ts[0]
mask_dff = (t_dff_rel >= BEHAV_WINDOW[0]) & (t_dff_rel <= BEHAV_WINDOW[1])
# NOTE: loads BEHAV_WINDOW duration × all ROIs from disk/network, then subsets to soma
mean_dff = np.nanmean(np.array(dff_data[mask_dff, :])[:, soma_indices], axis=1)
axes[2].plot(t_dff_rel[mask_dff], mean_dff, lw=0.7, color="steelblue")
axes[2].set_ylabel("Mean $\\Delta$F/F\n(soma ROIs)")
axes[2].set_title(f"{PLANE} Mean $\\Delta$F/F")
axes[2].set_xlabel("Time from session start (s)")
axes[2].spines["top"].set_visible(False)
axes[2].spines["right"].set_visible(False)
fig.suptitle("Behavioral and Neural Overview (not time-aligned)", fontsize=13)
plt.tight_layout()
plt.show()
9. Stimulus Tables and Block Structure¶
All stimulus information is stored in the nwb.intervals section as a set of stimulus presentation tables. Each row in a table represents one individual stimulus presentation and contains columns like:
start_time,stop_time— onset and offset times in secondsTrialType— the category of the presentation (e.g.,'single','halt','omission')stim_blockorstimulus_block— which block of the session this presentation belongs toGrating properties:
orientation,temporal_frequency,spatial_frequency,contrast
Understanding the epoch structure is essential for the predictive processing analyses: each paradigm interleaves long stretches of a standard (expected) stimulus with rare deviant presentations. The epoch structure also marks any RF-mapping or spontaneous activity periods.
Stimulus Table Catalogue¶
Every session contains all four control blocks plus exactly one mismatch (oddball) block — the specific oddball type identifies the experimental paradigm for that session. Each control block is paired with its corresponding mismatch block as a matched baseline.
| Oddball block | Paired control | Paradigm | What the mouse predicts |
|---|---|---|---|
Standard mismatch block_presentations | Control block 1_presentations | Standard oddball | Identity of a repeated grating |
Sequence mismatch block_presentations | Control block 2_presentations | Sequence mismatch | Position of a grating within a fixed ABCD sequence |
Duration mismatch block_presentations | Control block 3_presentations | Temporal jitter | Regularity of inter-stimulus timing |
Sensory-motor mismatch block_presentations | Control block 4_presentations | Sensorimotor mismatch | Visual flow coupled to the animal’s running |
Additional tables present in all or most sessions:
| Table | Contents |
|---|---|
spontaneous_presentations | Spontaneous activity epoch (no stimulus) |
RF mapping_presentations | Receptive-field mapping stimuli |
Zebra_presentations | Zebra noise stimulus — dynamic stripes that vary randomly in orientation, width, eccentricity, direction, and speed |
40 hz pulse train_presentations | Optogenetic tagging — 40 Hz pulse train |
5 hz pulse train_presentations | Optogenetic tagging — 5 Hz pulse train |
raised_cosine_presentations | Optogenetic tagging — raised-cosine waveform |
Stimulus Paradigm Reminder¶
The session below demonstrates the sensorimotor mismatch paradigm:
Standard stimulus (
'single'): full-field drifting gratings presented in closed-loop — grating speed is yoked to the mouse’s treadmill in real time, so the animal experiences visually-coupled locomotionOddball stimuli:
'halt'— visual flow is abruptly stopped while the mouse may keep running (a sensorimotor prediction error);'omission'— the grating disappears entirelySpontaneous period: a block of spontaneous activity with no stimulus
# List all available stimulus tables
stim_table_names = list(nwb.intervals.keys())
print("Stimulus tables in this NWB:")
for name in stim_table_names:
tbl = nwb.intervals[name]
print(f" {name!r:55s} ({len(tbl)} rows)")Stimulus tables in this NWB:
'Control block 1_presentations' (1088 rows)
'Control block 2_presentations' (1120 rows)
'Control block 3_presentations' (368 rows)
'Control block 4_presentations' (11520 rows)
'RF mapping_presentations' (1215 rows)
'Sensory-motor mismatch block_presentations' (45461 rows)
'Zebra_presentations' (18000 rows)
'spontaneous_presentations' (4 rows)
# Inspect this session's oddball stimulus table
stim_name = 'Sensory-motor mismatch block_presentations'
stim_table = nwb.intervals[stim_name]
# Summarize stimulus types in this table
stim_df = stim_table.to_dataframe()
print(f"Table: {stim_name!r}")
print(f"Columns: {stim_table.colnames}")
print(f"TrialTypes: {set(stim_df['TrialType'])}")
stim_table[:10]Table: 'Sensory-motor mismatch block_presentations'
Columns: ('start_time', 'stop_time', 'stim_name', 'stim_type', 'stim_block', 'Orientation', 'SpatialFrequency', 'TemporalFrequency', 'contrast', 'phase', 'DiameterX', 'DiameterY', 'X', 'Y', 'Duration', 'Delay', 'BlockNumber', 'BlockLabel', 'TrialNumber', 'SequenceNumber', 'TrialInSequence', 'TrialType', 'BlockType', 'stim_index', 'timeseries')
TrialTypes: {'standard', 'motor_omission', 'motor_halt', 'motor_orientation_45', 'motor_orientation_90'}
Epoch Structure Timeline¶
Here we extract epochs — contiguous intervals during which the same stimulus type or block is shown — and visualize them as a colored timeline. This is the same approach used throughout the OpenScope Databook.
The colored bands in the plot below reveal the structure of this recording session: stretches of standard stimuli that contain different kinds of expectation violations (oddballs), and ending with zebra noise and standard RF-mapping stimuli.
def extract_epochs(stim_name, stim_table, epochs):
"""Extract contiguous stimulus epochs from a presentation table.
Scans the table row-by-row and groups consecutive rows with the same
block / type value into a single epoch tuple:
(stim_name, block_id, start_time, stop_time)
"""
block_key = None
for candidate in ["stim_block", "stimulus_block", "stimulus_type", "TrialType"]:
if candidate in stim_table.colnames:
block_key = candidate
break
if block_key is None:
epoch_start = stim_table.start_time[0]
epoch_stop = stim_table.stop_time[-1]
epochs.append((stim_name, stim_name, epoch_start, epoch_stop))
return epochs
epoch_start = stim_table.start_time[0]
epoch_stop = stim_table.stop_time[0]
for i in range(len(stim_table)):
this_block = stim_table[block_key][i]
if i + 1 >= len(stim_table):
epochs.append((stim_name, this_block, epoch_start, epoch_stop))
break
next_block = stim_table[block_key][i + 1]
if next_block == this_block:
epoch_stop = stim_table.stop_time[i + 1]
else:
epochs.append((stim_name, this_block, epoch_start, epoch_stop))
epoch_start = stim_table.start_time[i + 1]
epoch_stop = stim_table.stop_time[i + 1]
return epochs
epochs = []
for sn in stim_table_names:
try:
epochs = extract_epochs(sn, nwb.intervals[sn], epochs)
except Exception:
continue
epochs.sort(key=lambda x: x[2])
print(f"Total epochs found: {len(epochs)}")
for ep in epochs[:20]:
print(f" {ep[1]!s:<35} {ep[2]:.1f} – {ep[3]:.1f} s ({ep[3]-ep[2]:.1f} s)")Total epochs found: 9
spontaneous_presentations 38.2 – 4038.5 s (4000.3 s)
0.0 38.3 – 418.7 s (380.4 s)
1.0 418.7 – 1979.7 s (1561.0 s)
2.0 1980.0 – 2360.8 s (380.8 s)
3.0 2360.8 – 2659.2 s (298.4 s)
4.0 2660.1 – 3044.1 s (384.0 s)
5.0 3044.1 – 3438.6 s (394.5 s)
6.0 3438.6 – 4038.5 s (599.9 s)
7.0 4038.5 – 4362.3 s (323.8 s)
# Build color map: one color per stimulus table name
unique_stim_names = list({ep[0] for ep in epochs})
# Fixed color map — one visually distinct color per stimulus table.
# Mismatch/oddball tables use dark bold colors; paired controls use a lighter version.
STIM_COLORS = {
# Oddball epochs (dark / bold)
"Standard mismatch block_presentations": "#4FC3CF", # aquamarine
"Sequence mismatch block_presentations": "#B5541A", # burnt orange
"Duration mismatch block_presentations": "#C8941A", # goldenrod
"Sensory-motor mismatch block_presentations": "#1B3A7A", # navy blue
# Paired control epochs (lighter version of the paired oddball)
"Control block 1_presentations": "#A8DDE6", # light aquamarine (→ Standard)
"Control block 2_presentations": "#E0905E", # light orange (→ Sequence)
"Control block 3_presentations": "#EDD070", # light goldenrod (→ Duration)
"Control block 4_presentations": "#7A9FC2", # light navy (→ Motor)
# Other epochs
"spontaneous_presentations": "#808080", # grey
"RF mapping_presentations": "#2E7D32", # dark forest green
"Zebra_presentations": "#8BC34A", # lime / yellow-green
"40 hz pulse train_presentations": "#6A1B9A", # deep indigo-purple
"5 hz pulse train_presentations": "#7B68EE", # periwinkle/lavender
"raised_cosine_presentations": "#BF360C", # deep brick red
}
# Fall back to neutral grey for any table not listed above.
color_map = {name: STIM_COLORS.get(name, "#999999") for name in unique_stim_names}
session_end = max(ep[3] for ep in epochs)
fig, ax = plt.subplots(figsize=(16, 2))
legend_handles = {}
for ep in epochs:
stim_name_ep, block_id, t_start, t_stop = ep
color = color_map[stim_name_ep]
patch = ax.axvspan(t_start, t_stop, color=color, alpha=0.8)
if stim_name_ep not in legend_handles:
legend_handles[stim_name_ep] = patch
ax.set_xlim(0, session_end)
ax.set_ylim(0, 1)
ax.set_yticks([])
ax.set_xlabel("Time from session start (s)")
ax.set_title("Stimulus Epoch Structure")
ax.legend(
legend_handles.values(), legend_handles.keys(),
loc="upper right", bbox_to_anchor=(1, 2.5),
fontsize=7, ncols=4, title="Stimulus table"
)
plt.tight_layout()
plt.show()

10. Selecting Oddball (Mismatch) Stimulus Times¶
For the predictive processing analyses, the key events are the oddball presentations — the rare stimuli that violate the established prediction. Here we select a specific oddball type from the TrialType column and collect all its onset times.
Known TrialType values in this session (sensorimotor mismatch):
'single'— the standard closed-loop stimulus (grating drift coupled to the mouse’s running speed)'halt'— the sensorimotor mismatch deviant: visual flow is halted while the mouse continues running'omission'— blank-screen deviant (no grating)
In Standard Oddball sessions (other sessions in the dandiset), you would instead see:
'single'— repeated fixed grating (e.g., 0° orientation, 2 Hz)'orientation_45'/'orientation_90'— orientation-change deviants'halt'— temporal-frequency halt (0 Hz, grating stops moving)'omission'— blank-screen deviant
Set ODDBALL_TYPE and STANDARD_TYPE to the values printed by the exploratory cell below.
# Select the stimulus table — skip spontaneous activity tables
TYPE_COL = "TrialType"
non_spontaneous = [n for n in stim_table_names if "spontaneous" not in n.lower()]
STIM_TABLE_NAME = non_spontaneous[0] # change if needed
stim_df = nwb.intervals[STIM_TABLE_NAME].to_dataframe()
print(f"Using stimulus table: {STIM_TABLE_NAME!r}")
print(f"Using type column: {TYPE_COL!r}")
print(f"\nAvailable TrialTypes: {sorted(stim_df[TYPE_COL].unique().tolist())}")Using stimulus table: 'Control block 1_presentations'
Using type column: 'TrialType'
Available TrialTypes: ['halt', 'omission', 'single']
# --- Choose which oddball type and standard type to align to ---
# Set these to match the TrialType values printed above.
ODDBALL_TYPE = "halt" # <-- EDIT THIS (e.g. 'halt', 'omission', 'orientation_45')
STANDARD_TYPE = "single" # <-- EDIT THIS (the repeated baseline stimulus)
if ODDBALL_TYPE in stim_df[TYPE_COL].values:
oddball_times = stim_df.loc[stim_df[TYPE_COL] == ODDBALL_TYPE, "start_time"].values
print(f"Found {len(oddball_times)} presentations of '{ODDBALL_TYPE}'")
print(f"First 10 onset times (s): {oddball_times[:10]}")
else:
print(f"'{ODDBALL_TYPE}' not found. Available: {sorted(stim_df[TYPE_COL].unique().tolist())}")
oddball_times = np.array([])
if STANDARD_TYPE in stim_df[TYPE_COL].values:
standard_times = stim_df.loc[stim_df[TYPE_COL] == STANDARD_TYPE, "start_time"].values
print(f"Found {len(standard_times)} '{STANDARD_TYPE}' presentations for comparison.")
else:
print(f"'{STANDARD_TYPE}' not found. Available: {sorted(stim_df[TYPE_COL].unique().tolist())}")
standard_times = np.array([])Found 68 presentations of 'halt'
First 10 onset times (s): [ 56.12058 57.52058 61.72259 65.22551 89.04525 119.87075 130.37946
140.88818 149.29517 154.89979]
Found 952 'single' presentations for comparison.
11. Aligning All Three Modalities to a Common Time Axis¶
The three streams — running speed, eye tracking, and ΔF/F — are recorded at different sampling rates and are stored with independent timestamp vectors. Before we can overlay them, we must interpolate them all onto a single uniform time axis.
We use scipy.interpolate.interp1d with kind='nearest' for all signals (preserving the staircase nature of behavioral readouts). The interpolation rate INTERP_HZ should be at least as high as the fastest of the three signals (usually the behavioral cameras at 60–200 Hz). Here we use 100 Hz as a reasonable compromise that keeps array sizes manageable.
INTERP_HZ = 100 # Hz for common time axis
# Build shared time axis spanning all signals
t_start_global = 0.0
t_end_global = float(dff_ts[-1])
if len(running_speed_timestamps) > 0:
t_end_global = max(t_end_global, float(running_speed_timestamps[-1]))
t_end_global = max(t_end_global, float(eye_tracking_timestamps[-1]))
time_axis = np.arange(t_start_global, t_end_global, step=1.0 / INTERP_HZ)
# Interpolate running speed
if len(running_speed_data) > 0:
f_run = interpolate.interp1d(
running_speed_timestamps, running_speed_data,
kind="nearest", bounds_error=False, fill_value=0.0
)
interp_running = f_run(time_axis)
else:
interp_running = np.zeros(len(time_axis))
# Interpolate pupil area
f_eye = interpolate.interp1d(
eye_tracking_timestamps, eye_tracking_area,
kind="nearest", bounds_error=False, fill_value=np.nan
)
interp_eye = f_eye(time_axis)
# NOTE: loads the full session × n_soma_rois into memory — this may take a moment
# for large files or slow network connections
f_dff = interpolate.interp1d(
dff_ts,
np.array(dff_data[:, soma_indices]), # shape (time, n_soma)
axis=0, kind="nearest", bounds_error=False, fill_value=0.0
)
interp_dff = f_dff(time_axis).T # shape (n_soma, time)
print(f"Common time axis: {len(time_axis)} samples at {INTERP_HZ} Hz ({time_axis[-1]:.1f} s)")
print(f"interp_running shape: {interp_running.shape}")
print(f"interp_eye shape: {interp_eye.shape}")
print(f"interp_dff shape: {interp_dff.shape} (soma ROIs × time)")Common time axis: 444264 samples at 100 Hz (4442.6 s)
interp_running shape: (444264,)
interp_eye shape: (444264,)
interp_dff shape: (325, 444264) (soma ROIs × time)
Aligned Overview Plot¶
With all signals on the same time axis, we can overlay them with shared x-axis and add colored epoch bands to show the stimulus block structure. This is one of the most informative diagnostic views of a Predictive Processing session: you can immediately see how neural activity, running, and arousal (pupil) co-vary with the different stimulus phases.
VIEW_START = 0 # seconds
VIEW_END = int(time_axis[-1]) # full session; set to e.g. 600 to zoom in on a region
s0 = int(VIEW_START * INTERP_HZ)
s1 = int(VIEW_END * INTERP_HZ)
t_view = time_axis[s0:s1]
running_view = interp_running[s0:s1]
eye_view = interp_eye[s0:s1]
dff_view = interp_dff[:, s0:s1] # (n_soma, time)
def draw_epoch_bands(ax, epochs, view_start, view_end, cmap=None, alpha=0.30):
"""Shade each epoch as a semi-transparent colored band on the given axis.
Uses ``ax.axvspan`` so the bands span the full y-axis without capturing
the current ylim and without interfering with autoscaling.
Parameters
----------
cmap : dict, optional
A {label: color} mapping. When provided (e.g. the ``color_map`` dict
produced by the Epoch Structure plot) the same colors are reused so
both figures are consistent. When omitted a local palette is built.
alpha : float
Opacity of the shaded bands (0 = invisible, 1 = solid).
"""
if cmap is None:
unique_labels = list({ep[0] for ep in epochs})
tab_colors = plt.cm.tab20(np.linspace(0, 1, len(unique_labels)))
cmap = dict(zip(unique_labels, tab_colors))
legend_handles = {}
for ep in epochs:
stim_name_ep, _, t_s, t_e = ep
if t_e < view_start or t_s > view_end:
continue
color = cmap.get(stim_name_ep, "gray")
patch = ax.axvspan(
max(t_s, view_start), min(t_e, view_end),
color=color, alpha=alpha, zorder=0
)
legend_handles[stim_name_ep] = patch
return legend_handles
# GridSpec: 3 rows × 2 cols. Column 0 is the plots; column 1 is a narrow colorbar
# column only used by the dF/F row — keeps all plot panels the same width.
height_ratios = [1.2, 1.2, 3.5]
fig = plt.figure(figsize=(15, 10))
gs = gridspec.GridSpec(3, 2, figure=fig,
height_ratios=height_ratios,
width_ratios=[40, 1])
ax0 = fig.add_subplot(gs[0, 0])
ax1 = fig.add_subplot(gs[1, 0], sharex=ax0)
ax2 = fig.add_subplot(gs[2, 0], sharex=ax0)
cax = fig.add_subplot(gs[2, 1]) # colorbar column, dF/F row only
axes = np.array([ax0, ax1, ax2])
# --- Running speed ---
ax0.plot(t_view, running_view, lw=0.7, color="darkorange")
ax0.set_ylabel("Running\nspeed (cm/s)")
ax0.set_title("Running Speed")
ax0.spines["top"].set_visible(False); ax0.spines["right"].set_visible(False)
lh = draw_epoch_bands(ax0, epochs, VIEW_START, VIEW_END, cmap=color_map)
# --- Eye area ---
ax1.plot(t_view, eye_view, lw=0.7, color="purple")
ax1.set_ylabel("Pupil area\n(pixels²)")
ax1.set_title("Eye Tracking (Pupil Area)")
ax1.spines["top"].set_visible(False); ax1.spines["right"].set_visible(False)
draw_epoch_bands(ax1, epochs, VIEW_START, VIEW_END, cmap=color_map)
# --- ΔF/F raster ---
im = ax2.imshow(
dff_view,
aspect="auto",
extent=[t_view[0], t_view[-1], 0, dff_view.shape[0]],
cmap="inferno", vmin=0, vmax=0.5, origin="lower"
)
ax2.set_ylabel("Soma ROI #")
ax2.set_xlabel("Time (s)")
ax2.set_title(f"{PLANE} — $\\Delta$F/F (all soma ROIs)")
fig.colorbar(im, cax=cax, label="$\\Delta$F/F")
if lh:
ax0.legend(
lh.values(), lh.keys(),
ncols=4, fontsize=7,
loc="upper left", bbox_to_anchor=(0, 2.7),
title="Stimulus table"
)
fig.suptitle(
f"Time-Aligned Modalities — Predictive Processing Session\n"
f"({VIEW_START}–{VIEW_END} s, plane {PLANE})",
fontsize=13
)
plt.tight_layout()
plt.show()
12. Stimulus-Aligned Response Windows¶
The core analysis approach for predictive processing experiments is peri-stimulus time histogram (PSTH) analysis: for each trial (each oddball presentation), extract a short time window of data centered on stimulus onset, then average across trials. This approach reveals:
The magnitude and latency of the neural response to the mismatch event
Whether running speed or pupil area change in response to the oddball (arousal/motor correlates)
How the response profile varies across oddball types (orientation change, halt, omission)
The function get_stim_windows slices a fixed-length window around each stimulus onset using the pre-interpolated common time axis, yielding a (n_trials × window_samples) array for behavioral signals and (n_soma × n_trials × window_samples) for the neural data.
Tip: Adjust
WINDOW_PREandWINDOW_POSTto change how much baseline and response period you include. Typical choices: -1 to +2 seconds for grating-evoked responses.
WINDOW_PRE = -1.0 # seconds before stimulus onset
WINDOW_POST = 2.0 # seconds after stimulus onset
window_len = int((WINDOW_POST - WINDOW_PRE) * INTERP_HZ)
window_t = np.linspace(WINDOW_PRE, WINDOW_POST, window_len)
def get_stim_windows(signal, stim_times, interp_hz, window_pre, window_post):
"""Extract fixed-length windows around each stimulus onset.
Parameters
----------
signal : ndarray, shape (n_timepoints,) or (n_features, n_timepoints)
Data on the global uniform time axis.
stim_times : array-like
Stimulus onset times in seconds.
interp_hz : float
Sampling rate of the uniform time axis that ``signal`` has already been
interpolated onto (e.g. ``INTERP_HZ``). The function uses this rate to
convert onset times to sample indices, so ``signal`` must be on a
regularly-sampled grid at exactly this rate.
window_pre, window_post : float
Window boundaries relative to onset (window_pre < 0 < window_post).
Returns
-------
ndarray, shape (n_trials, window_len) or (n_features, n_trials, window_len)
"""
wlen = int((window_post - window_pre) * interp_hz)
n_t = signal.shape[-1]
slices = []
for t in stim_times:
i0 = int((t + window_pre) * interp_hz)
i1 = i0 + wlen
if 0 <= i0 and i1 <= n_t:
slices.append(signal[..., i0:i1])
if not slices:
raise ValueError("No valid windows — check stim_times and window bounds.")
if signal.ndim == 1:
return np.array(slices) # (n_trials, window_len)
return np.stack(slices, axis=1) # (n_features, n_trials, window_len)
# Extract windows for oddball stimuli
run_win_odd = get_stim_windows(interp_running, oddball_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
run_win_std = get_stim_windows(interp_running, standard_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
eye_win_odd = get_stim_windows(interp_eye, oddball_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
eye_win_std = get_stim_windows(interp_eye, standard_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
dff_win_odd = get_stim_windows(interp_dff, oddball_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
dff_win_std = get_stim_windows(interp_dff, standard_times, INTERP_HZ, WINDOW_PRE, WINDOW_POST)
# dff_win_odd shape: (n_soma, n_trials, window_len)
print(f"Oddball windows: {run_win_odd.shape[0]} trials")
print(f"Standard windows: {run_win_std.shape[0]} trials")
print(f"dF/F window shape (soma × trials × time): {dff_win_odd.shape}")Oddball windows: 68 trials
Standard windows: 952 trials
dF/F window shape (soma × trials × time): (325, 68, 300)
N_PLOT_CELLS = 20 # number of soma ROIs to show (capped at available)
n_show = min(N_PLOT_CELLS, dff_win_odd.shape[0])
# Shared color scale: 99th-percentile ceiling across both conditions so the
# two columns are directly comparable.
vmax = float(np.nanpercentile(
np.concatenate([dff_win_odd[:n_show].ravel(), dff_win_std[:n_show].ravel()]), 99
))
fig, axes = plt.subplots(
n_show, 2,
figsize=(11, n_show * 1.5),
sharex=True,
)
for i in range(n_show):
for col, (wins, label, title_color) in enumerate([
(dff_win_std[i], "Standard", "steelblue"),
(dff_win_odd[i], f"Oddball ({ODDBALL_TYPE})", "tomato"),
]):
ax = axes[i, col]
im = ax.imshow(
wins,
aspect="auto",
extent=[window_t[0], window_t[-1], 0, wins.shape[0]],
cmap="inferno", vmin=0.0, vmax=vmax,
origin="lower", interpolation="nearest",
)
ax.axvline(0, color="white", lw=0.8, ls="--", alpha=0.7)
ax.set_yticks([])
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
if i == 0:
ax.set_title(f"{label} (n={wins.shape[0]})", fontsize=10,
color=title_color, fontweight="bold", pad=4)
if i < n_show - 1:
ax.tick_params(labelbottom=False)
axes[i, 0].set_ylabel(f"ROI {soma_indices[i]}", fontsize=7,
rotation=0, labelpad=36, va="center")
axes[-1, 0].set_xlabel("Time rel. to onset (s)")
axes[-1, 1].set_xlabel("Time rel. to onset (s)")
# Single shared colorbar pinned to the right of the figure
fig.subplots_adjust(right=0.87, hspace=0.05, wspace=0.04)
cbar_ax = fig.add_axes([0.90, 0.15, 0.018, 0.70])
fig.colorbar(im, cax=cbar_ax, label="ΔF/F")
fig.suptitle(
f"Trial × Time Windows — First {n_show} Soma ROIs\n"
f"(Oddball: {ODDBALL_TYPE!r}, plane: {PLANE})",
fontsize=12,
)
plt.show()
13. Stimulus-Aligned Average Response¶
We average across trials separately for oddball and standard presentations. Plotting both on the same axes reveals the mismatch response — the difference in activity between expected and unexpected stimuli. This is the key neural signature the project is designed to measure.
The three panels show:
Running speed — to check whether the animal moves differently after oddball vs. standard stimuli (especially relevant for the sensorimotor paradigm)
Pupil area — to check whether there is a general arousal response to the mismatch
Mean ΔF/F across all soma ROIs — the population-averaged neural mismatch response
The shaded region around each trace shows ±1 SEM across trials.
def mean_sem(windows):
"""Compute mean and SEM across trials. ``windows`` must have shape (n_trials, time)."""
n = windows.shape[0]
m = np.nanmean(windows, axis=0)
s = np.nanstd(windows, axis=0) / np.sqrt(n)
return m, s
run_mean_odd, run_sem_odd = mean_sem(run_win_odd)
run_mean_std, run_sem_std = mean_sem(run_win_std)
eye_mean_odd, eye_sem_odd = mean_sem(eye_win_odd)
eye_mean_std, eye_sem_std = mean_sem(eye_win_std)
# For dF/F: first average across ROIs within each trial, then average trials
# dff_win_odd: (n_soma, n_trials, time) → average over soma → (n_trials, time)
dff_mean_per_trial_odd = np.nanmean(dff_win_odd, axis=0) # (n_trials, time)
dff_mean_per_trial_std = np.nanmean(dff_win_std, axis=0)
dff_mean_odd, dff_sem_odd = mean_sem(dff_mean_per_trial_odd)
dff_mean_std, dff_sem_std = mean_sem(dff_mean_per_trial_std)
print("Average response shapes computed.")Average response shapes computed.
fig, axes = plt.subplots(3, 1, figsize=(10, 11), sharex=True,
gridspec_kw={"height_ratios": [1, 1, 1.6]})
# --- Panel 1: Running speed ---
axes[0].axvline(0, color="gray", lw=0.8, ls="--", alpha=0.7)
axes[0].fill_between(window_t, run_mean_odd - run_sem_odd,
run_mean_odd + run_sem_odd, alpha=0.25, color="tomato")
axes[0].fill_between(window_t, run_mean_std - run_sem_std,
run_mean_std + run_sem_std, alpha=0.25, color="steelblue")
axes[0].plot(window_t, run_mean_odd, color="tomato", lw=1.5, label=f"Oddball ({run_win_odd.shape[0]} trials)")
axes[0].plot(window_t, run_mean_std, color="steelblue", lw=1.5, label=f"Standard ({run_win_std.shape[0]} trials)")
axes[0].set_ylabel("Running speed\n(cm/s)")
axes[0].set_title("Stimulus-Aligned Running Speed")
axes[0].legend(loc="upper right", fontsize=9)
axes[0].spines["top"].set_visible(False); axes[0].spines["right"].set_visible(False)
# --- Panel 2: Pupil area ---
axes[1].axvline(0, color="gray", lw=0.8, ls="--", alpha=0.7)
axes[1].fill_between(window_t, eye_mean_odd - eye_sem_odd,
eye_mean_odd + eye_sem_odd, alpha=0.25, color="tomato")
axes[1].fill_between(window_t, eye_mean_std - eye_sem_std,
eye_mean_std + eye_sem_std, alpha=0.25, color="steelblue")
axes[1].plot(window_t, eye_mean_odd, color="tomato", lw=1.5, label="Oddball")
axes[1].plot(window_t, eye_mean_std, color="steelblue", lw=1.5, label="Standard")
axes[1].set_ylabel("Pupil area\n(pixels²)")
axes[1].set_title("Stimulus-Aligned Pupil Area")
axes[1].legend(loc="upper right", fontsize=9)
axes[1].spines["top"].set_visible(False); axes[1].spines["right"].set_visible(False)
# --- Panel 3: Mean ΔF/F ---
axes[2].axvline(0, color="gray", lw=0.8, ls="--", alpha=0.7)
axes[2].fill_between(window_t, dff_mean_odd - dff_sem_odd,
dff_mean_odd + dff_sem_odd, alpha=0.25, color="tomato")
axes[2].fill_between(window_t, dff_mean_std - dff_sem_std,
dff_mean_std + dff_sem_std, alpha=0.25, color="steelblue")
axes[2].plot(window_t, dff_mean_odd, color="tomato", lw=2.0, label=f"Oddball")
axes[2].plot(window_t, dff_mean_std, color="steelblue", lw=2.0, label="Standard")
axes[2].axhline(0, color="black", lw=0.5, ls=":")
axes[2].set_ylabel("Mean $\\Delta$F/F\n(soma ROIs)")
axes[2].set_xlabel("Time relative to stimulus onset (s)")
axes[2].set_title(f"{PLANE} — Population-Averaged Neural Mismatch Response")
axes[2].legend(loc="upper right", fontsize=9)
axes[2].spines["top"].set_visible(False); axes[2].spines["right"].set_visible(False)
fig.suptitle(
f"Stimulus-Aligned Average Response: Oddball vs. Standard\n"
f"(Oddball type: {ODDBALL_TYPE!r}, plane: {PLANE})",
fontsize=13
)
plt.tight_layout()
plt.show()
14. Single-Neuron Stimulus-Aligned Responses¶
While population averages reveal overall mismatch responses, individual neurons can be more or less selective. This panel shows the per-ROI average response as a heatmap, sorted by the peak response time after the oddball onset. Neurons are ordered from those that respond early (top) to late (bottom).
This is an important visualization for the project’s scientific goals: some neurons may show prediction error responses (large response specifically to the oddball), while others may show suppression (reduced activity compared to the standard), and others may not discriminate at all. These patterns are the raw material for the more formal analyses described in the analysis plan.
# Per-ROI average response: (n_soma, time)
dff_roi_avg_odd = np.nanmean(dff_win_odd, axis=1) # average over trials
dff_roi_avg_std = np.nanmean(dff_win_std, axis=1)
# Difference (mismatch response)
mismatch_response = dff_roi_avg_odd - dff_roi_avg_std
# Sort by time of peak absolute response after onset
onset_idx = int(-WINDOW_PRE * INTERP_HZ)
peak_times = np.argmax(np.abs(mismatch_response[:, onset_idx:]), axis=1)
sort_order = np.argsort(peak_times)
fig, axes = plt.subplots(1, 3, figsize=(18, 7))
for ax, data, title, cmap, vlim, cbar_label in [
(axes[0], dff_roi_avg_std[sort_order], "Standard",
"inferno", (0, 0.3), "Mean $\\Delta$F/F (standard)"),
(axes[1], dff_roi_avg_odd[sort_order], f"Oddball: {ODDBALL_TYPE}",
"inferno", (0, 0.3), "Mean $\\Delta$F/F (oddball)"),
(axes[2], mismatch_response[sort_order], "Response Difference (Oddball $-$ Standard)",
"RdBu_r", (-0.15, 0.15), "$\\Delta\\Delta$F/F (oddball $-$ standard)"),
]:
im = ax.imshow(
data,
aspect="auto",
extent=[window_t[0], window_t[-1], 0, data.shape[0]],
cmap=cmap, vmin=vlim[0], vmax=vlim[1], origin="lower"
)
ax.axvline(0, color="white", lw=1, ls="--", alpha=0.8)
ax.set_xlabel("Time (s)")
ax.set_ylabel("Soma ROI # (sorted by peak time)")
ax.set_title(title)
plt.colorbar(im, ax=ax, label=cbar_label, shrink=0.6)
fig.suptitle(
f"Per-ROI Stimulus-Aligned Responses — {PLANE}\n"
f"Sorted by peak response time after stimulus onset",
fontsize=13
)
plt.tight_layout()
plt.show()

15. Summary and Next Steps¶
This notebook walked through the core data access and visualization steps for a Predictive Processing Mesoscope session. Here is a summary of what is available in the NWB and what you can do with it:
| Data | Location in NWB | Key variable |
|---|---|---|
| Imaging plane metadata | nwb.imaging_planes | Area, depth, rate, indicator |
| ROI masks | nwb.processing[plane]["image_segmentation"]["roi_table"] | image_mask, is_soma, soma_probability |
| Average/max projections | nwb.processing[plane]["images"] | average_projection, max_projection |
| Raw fluorescence | nwb.processing[plane]["raw_timeseries"] | ROI_fluorescence_timeseries.data |
| Neuropil fluorescence | nwb.processing[plane]["neuropil_fluorescence_timeseries"] | .data |
| Neuropil-corrected | nwb.processing[plane]["neuropil_corrected_timeseries"] | .data |
| ΔF/F | nwb.processing[plane]["dff_timeseries"]["dff_timeseries"] | .data, .timestamps |
| Detected events | nwb.processing[plane]["event_timeseries"] | .data |
| Running speed | nwb.processing["running"][speed_key] | .data, .timestamps |
| Eye/pupil tracking | nwb.processing["eye_tracking"]["pupil"] | .area, .timestamps |
| Stimulus tables | nwb.intervals[stim_table_name] | start_time, stop_time, stimulus_type |
Community Resources¶
GitHub Discussions — for questions, ideas, and collaboration
- Zhou, Z., Huang, W. A., Yu, Y., Negahbani, E., Stitt, I. M., Alexander, M., Hamm, J. P., Kato, H. K., & Fröhlich, F. (2020). Stimulus-specific regulation of visual oddball differentiation in posterior parietal cortex. Scientific Reports, 10, 13973. 10.1038/s41598-020-70448-6
- Hamm, J. P., Shymkiv, Y., Han, S., Yang, W., & Yuste, R. (2021). Cortical ensembles selective for context. Proceedings of the National Academy of Sciences, 118. 10.1073/pnas.2026179118
- Pak, A., Kissinger, S. T., & Chubykin, A. A. (2021). Impaired adaptation and laminar processing of the oddball paradigm in the primary visual cortex of Fmr1 KO mouse. Frontiers in Cellular Neuroscience, 15, 668230. 10.3389/fncel.2021.668230
- Homann, J., Koay, S. A., Chen, K. S., Tank, D. W., & II, M. J. B. (2022). Novel stimuli evoke excess activity in the mouse primary visual cortex. Proceedings of the National Academy of Sciences, 119. 10.1073/pnas.2108882119
- Jordan, R., & Keller, G. B. (2020). Opposing influence of top-down and bottom-up input on excitatory layer 2/3 neurons in mouse primary visual cortex. Neuron, 108, 1194-1206.e5. 10.1016/j.neuron.2020.09.024
- Huang, Y., Shamsnia, A., Chen, M., Wu, S., Stamm, T., Medico, S., & Najafi, F. (2025). Intrinsic timing, not temporal prediction, underlies ramping dynamics in visual and parietal cortex during passive behavior. bioRxiv. 10.1101/2025.09.11.673960