Open In Colab

Installing requirements¶

The cell below installs every Python package needed to run this notebook, at fully pinned versions, using uv for fast resolution. In Colab the cell is collapsed by default — click the ▶ button to run it.

In [1]:
# install cell skipped during CI (deps preinstalled into system Python)

⚠️ Restart runtime after install

The install may upgrade packages already loaded in the kernel. Go to Runtime → Restart session, then Run all cells below (skip this install cell on re-run).

Reproducing Figure 2G-H: oEPSC Analysis¶

This notebook reproduces Figure 2G-H from Zhai et al. 2025 comparing optogenetically-evoked postsynaptic currents (oEPSCs) between OffState and OnState conditions in a Parkinson's disease model.

Dataset: DANDI:001538 - State-dependent modulation of spiny projection neurons controls levodopa-induced dyskinesia

Analysis approach:

  • Figure 2G: Cumulative distribution of all individual oEPSC events
  • Figure 2H: Box plot comparing mean event amplitudes per experimental session
  • Event detection: MAD-based noise estimation with ±5SD threshold
  • Conditions: OffState (control) vs OnState (L-DOPA treated)

Setup and Data Loading¶

Import Libraries and Configure Plotting Style¶

We use the same plotting parameters as the original publication to ensure visual consistency.

In [2]:
import h5py
import matplotlib.pyplot as plt
import numpy as np
import remfile
import seaborn as sns
from dandi.dandiapi import DandiAPIClient
from pynwb import NWBHDF5IO
from scipy import stats
from tqdm import tqdm

# Set plotting style to match paper
plt.style.use('default')
sns.set_palette("Set2")

def setup_figure_style():
    """Setup matplotlib parameters to match paper style"""
    plt.rcParams.update({
        'font.size': 8,
        'axes.titlesize': 10,
        'axes.labelsize': 9,
        'xtick.labelsize': 8,
        'ytick.labelsize': 8,
        'legend.fontsize': 8,
        'figure.titlesize': 12,
        'axes.linewidth': 0.8,
        'axes.spines.top': False,
        'axes.spines.right': False,
        'xtick.major.width': 0.8,
        'ytick.major.width': 0.8,
        'xtick.minor.width': 0.6,
        'ytick.minor.width': 0.6,
    })

setup_figure_style()
print("Libraries imported and plotting style configured")
Libraries imported and plotting style configured

Session ID Parsing and Filtering Functions¶

These utility functions parse the rich metadata encoded in DANDI file paths and filter experiments by figure, measurement type, and experimental state.

In [3]:
# Session ID helpers for the per-mouse-day dandiset format.
# DANDI paths: sub-<id>/sub-<id>_ses-<cellType>++<state>++<pharm>++<geno>++<date>_<modality>.nwb
# Sr-oEPSC (Fig 2 + 4) files have modality 'icephys+ogen' (icephys + optogenetic stim).

def get_session_id(asset_path):
    fname = asset_path.split('/')[-1]
    if '_ses-' not in fname:
        return ''
    return fname.split('_ses-', 1)[1].rsplit('_', 1)[0]

def parse_session_id(ses):
    t = ses.split('++')
    if len(t) < 5:
        return {}
    return {'cell_type': t[0], 'state': t[1], 'pharm': t[2], 'genotype': t[3], 'date': t[4]}

def get_modality(asset_path):
    fname = asset_path.split('/')[-1]
    if not fname.endswith('.nwb'):
        return ''
    return fname[:-len('.nwb')].rsplit('_', 1)[-1]

def is_f2_oepsc_dspn(asset_path):
    """Figure 2 Sr-oEPSC: dSPN cells with optogenetic stim modality."""
    fields = parse_session_id(get_session_id(asset_path))
    if not fields: return False
    return get_modality(asset_path) == 'icephys+ogen' and fields['cell_type'] == 'dSPN'

Event Detection Functions¶

MAD-Based Event Detection¶

We use Median Absolute Deviation (MAD) for robust noise estimation, avoiding bias from the events themselves. Events are detected as deviations >5SD from baseline and nearby events are merged to handle multi-threshold crossings.

In [4]:
def merge_nearby_events(event_times, event_amplitudes, merge_distance_ms=1.0):
    """Merge events within merge_distance_ms, keeping maximum amplitude."""
    if len(event_times) == 0:
        return [], []
    times = np.array(event_times)
    amplitudes = np.array(event_amplitudes)
    order = np.argsort(times)
    times = times[order]
    amplitudes = amplitudes[order]
    merged_times = []
    merged_amplitudes = []
    i = 0
    while i < len(times):
        current_time = times[i]
        j = i + 1
        max_amp = amplitudes[i]
        max_time = times[i]
        while j < len(times) and (times[j] - current_time) <= merge_distance_ms:
            if amplitudes[j] > max_amp:
                max_amp = float(amplitudes[j])
                max_time = float(times[j])
            j += 1
        merged_times.append(float(max_time))
        merged_amplitudes.append(float(max_amp))
        i = j
    return merged_times, merged_amplitudes

def process_nwb_file_for_events(asset, detection_window_shift_ms=100, event_merge_distance_ms=1.0):
    """Process a single NWB file and return event amplitudes using intracellular_recordings index alignment."""
    s3_url = asset.get_content_url(follow_redirects=1, strip_query=False)
    file_system = remfile.File(s3_url, disk_cache=remfile.DiskCache("nwb-cache"))
    file = h5py.File(file_system, mode="r")
    io = NWBHDF5IO(file=file, load_namespaces=True)
    nwbfile = io.read()
    try:
        opto_df = nwbfile.intervals["optogenetic_epochs_table"].to_dataframe()
        det_rows = opto_df[opto_df["stage_name"] == "detection"].sort_values("start_time").reset_index(drop=True)
    except Exception as e:
        io.close(); file.close()
        raise

    # Iterate intracellular_recordings (ordered) and align by index to detection rows
    ice = nwbfile.get_intracellular_recordings()
    n_rec = len(ice.id)
    if len(det_rows) < n_rec:
        n_rec = len(det_rows)

    file_positive_amplitudes = []
    file_negative_amplitudes = []

    for i in range(n_rec):
        row = ice[i]
        resp_ref = row[("responses", "response")].iloc[0]
        ts = resp_ref.timeseries
        # Extract slice
        data_A = ts.data[resp_ref.idx_start : resp_ref.idx_start + resp_ref.count]
        t_s_all = ts.get_timestamps()
        t_s = t_s_all[resp_ref.idx_start : resp_ref.idx_start + resp_ref.count]

        data_pA = np.asarray(data_A) * 1e12
        t_ms = np.asarray(t_s) * 1000.0  # absolute ms

        det = det_rows.iloc[i]
        det_start_ms = float(det.start_time) * 1000.0 + detection_window_shift_ms
        det_stop_ms = float(det.stop_time) * 1000.0

        m = (t_ms >= det_start_ms) & (t_ms <= det_stop_ms)
        if not np.any(m):
            continue
        seg = data_pA[m]
        seg_t = t_ms[m]

        noise_median = float(np.median(seg))
        mad = float(np.median(np.abs(seg - noise_median)))
        mad_std = mad * 1.4826

        thr_pos = noise_median + 5.0 * mad_std
        thr_neg = noise_median - 5.0 * mad_std

        indices_pos = np.where(seg > thr_pos)[0]
        indices_neg = np.where(seg < thr_neg)[0]

        t_pos_raw = seg_t[indices_pos]
        t_neg_raw = seg_t[indices_neg]
        a_pos_raw = seg[indices_pos] - noise_median
        a_neg_raw = noise_median - seg[indices_neg]

        pos_t, pos_a = merge_nearby_events(t_pos_raw, a_pos_raw, event_merge_distance_ms)
        neg_t, neg_a = merge_nearby_events(t_neg_raw, a_neg_raw, event_merge_distance_ms)

        file_positive_amplitudes.extend(pos_a)
        file_negative_amplitudes.extend(neg_a)

    io.close()
    file.close()

    return file_positive_amplitudes, file_negative_amplitudes

Load DANDI Dataset¶

Connect to DANDI and filter for Figure 2 optogenetic experiments, separating OffState and OnState conditions.

In [5]:
dandiset_id = "001538"
client = DandiAPIClient()
dandiset = client.get_dandiset(dandiset_id, "0.260527.1302")
assets_list = list(dandiset.get_assets())

# Filter: Sr-oEPSC dSPN files with state == OffState / OnState
f2_oepsc = [a for a in assets_list if is_f2_oepsc_dspn(a.path)]
offstate_assets = [a for a in f2_oepsc if parse_session_id(get_session_id(a.path))['state'] == 'OffState']
onstate_assets  = [a for a in f2_oepsc if parse_session_id(get_session_id(a.path))['state'] == 'OnState']

print(f'Found {len(offstate_assets)} OffState and {len(onstate_assets)} OnState dSPN oEPSC files')
print(f'Total F2 oEPSC: {len(offstate_assets) + len(onstate_assets)}')
Found 3 OffState and 4 OnState dSPN oEPSC files
Total F2 oEPSC: 7

Data Processing and Event Detection¶

Process All NWB Files¶

We process each NWB file to extract oEPSC events, collecting both:

  • Individual events: For cumulative distribution analysis
  • File means: For box plot comparison of mean responses per session
In [6]:
# Initialize data collections
all_offstate_events = []  # All individual events for cumulative plot
all_onstate_events = []

offstate_file_means = []  # Mean responses per file for box plot
onstate_file_means = []

# Process OffState files
print("Processing OffState files...")
for i, asset in enumerate(tqdm(offstate_assets, desc="OffState files")):
    session_id = get_session_id(asset.path)
    print(f"  {i+1}/{len(offstate_assets)}: {session_id}")
    
    pos_amps, neg_amps = process_nwb_file_for_events(asset)
    all_events = pos_amps + neg_amps
    
    all_offstate_events.extend(all_events)
    if len(all_events) > 0:
        offstate_file_means.append(np.mean(all_events))

# Process OnState files
print("\nProcessing OnState files...")
for i, asset in enumerate(tqdm(onstate_assets, desc="OnState files")):
    session_id = get_session_id(asset.path)
    print(f"  {i+1}/{len(onstate_assets)}: {session_id}")
    
    pos_amps, neg_amps = process_nwb_file_for_events(asset)
    all_events = pos_amps + neg_amps
    
    all_onstate_events.extend(all_events)
    if len(all_events) > 0:
        onstate_file_means.append(np.mean(all_events))

print(f"\nData collection complete:")
print(f"  OffState: {len(all_offstate_events)} events from {len(offstate_file_means)} files")
print(f"  OnState: {len(all_onstate_events)} events from {len(onstate_file_means)} files")
Processing OffState files...
OffState files:   0%|          | 0/3 [00:00<?, ?it/s]
  1/3: dSPN++OffState++none++WT++20230706
OffState files:  33%|███▎      | 1/3 [00:01<00:02,  1.12s/it]
  2/3: dSPN++OffState++none++WT++20230713
OffState files:  67%|██████▋   | 2/3 [00:02<00:01,  1.06s/it]
  3/3: dSPN++OffState++none++WT++20230817
OffState files: 100%|██████████| 3/3 [00:03<00:00,  1.02s/it]
OffState files: 100%|██████████| 3/3 [00:03<00:00,  1.04s/it]

Processing OnState files...
OnState files:   0%|          | 0/4 [00:00<?, ?it/s]
  1/4: dSPN++OnState++none++WT++20230705
OnState files:  25%|██▌       | 1/4 [00:01<00:03,  1.01s/it]
  2/4: dSPN++OnState++none++WT++20230710
OnState files:  50%|█████     | 2/4 [00:01<00:01,  1.04it/s]
  3/4: dSPN++OnState++none++WT++20230802
OnState files:  75%|███████▌  | 3/4 [00:02<00:00,  1.03it/s]
  4/4: dSPN++OnState++none++WT++20230816
OnState files: 100%|██████████| 4/4 [00:03<00:00,  1.08it/s]
OnState files: 100%|██████████| 4/4 [00:03<00:00,  1.06it/s]
Data collection complete:
  OffState: 196 events from 3 files
  OnState: 351 events from 4 files

Figure 2G: Cumulative Distribution Plot¶

Individual Event Analysis¶

This plot shows the cumulative distribution of all individual oEPSC events across all experimental sessions, allowing comparison of the full event amplitude distributions between OffState and OnState conditions.

In [7]:
# Create cumulative distribution plot
fig, ax = plt.subplots(1, 1, figsize=(4, 4))  # Square aspect ratio like the reference

# Convert to numpy arrays
offstate_amplitudes = np.array(all_offstate_events)
onstate_amplitudes = np.array(all_onstate_events)

# Sort the data
offstate_sorted = np.sort(offstate_amplitudes)
onstate_sorted = np.sort(onstate_amplitudes)

# Calculate cumulative probabilities as percentages
offstate_cumulative = np.arange(1, len(offstate_sorted) + 1) / len(offstate_sorted) * 100
onstate_cumulative = np.arange(1, len(onstate_sorted) + 1) / len(onstate_sorted) * 100

# Plot with paper-style colors and thickness - match reference styling
ax.plot(offstate_sorted, offstate_cumulative, color='black', linewidth=3)
ax.plot(onstate_sorted, onstate_cumulative, color='gray', linewidth=3)

# Formatting to match reference image
ax.set_xlabel('oEPSC amplitude (pA)', fontsize=12, fontweight='normal')
ax.set_ylabel('cumulative probability (%)', fontsize=12, fontweight='normal')

# Set axis limits and ticks to match paper
ax.set_xlim(0, 80)
ax.set_ylim(0, 105)
ax.set_xticks([0, 20, 40, 60, 80])
ax.set_yticks([0, 20, 40, 60, 80, 100])

# Style the axes to match reference
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_linewidth(1.5)
ax.spines['bottom'].set_linewidth(1.5)
ax.tick_params(axis='both', which='major', labelsize=11, width=1.5, length=5)

# Add labels with line markers in middle right
ax.plot([55, 58], [65, 65], color='black', linewidth=3)
ax.text(60, 65, 'off-state', fontsize=11, ha='left', va='center')

ax.plot([55, 58], [55, 55], color='gray', linewidth=3)
ax.text(60, 55, 'on-state', fontsize=11, ha='left', va='center')

# Calculate and display statistics
off_median = np.median(offstate_amplitudes)
on_median = np.median(onstate_amplitudes)
off_mean = np.mean(offstate_amplitudes)
on_mean = np.mean(onstate_amplitudes)

print("=== FIGURE 2G: CUMULATIVE DISTRIBUTION ANALYSIS ===")
print(f"OffState events: {len(offstate_amplitudes)}")
print(f"  Mean: {off_mean:.2f} ± {np.std(offstate_amplitudes):.2f} pA")
print(f"  Median: {off_median:.2f} pA")
print(f"  25th percentile: {np.percentile(offstate_amplitudes, 25):.2f} pA")
print(f"  75th percentile: {np.percentile(offstate_amplitudes, 75):.2f} pA")

print(f"\nOnState events: {len(onstate_amplitudes)}")
print(f"  Mean: {on_mean:.2f} ± {np.std(onstate_amplitudes):.2f} pA")
print(f"  Median: {on_median:.2f} pA")
print(f"  25th percentile: {np.percentile(onstate_amplitudes, 25):.2f} pA")
print(f"  75th percentile: {np.percentile(onstate_amplitudes, 75):.2f} pA")

print(f"\nComparison:")
print(f"  Mean fold change (OnState/OffState): {on_mean/off_mean:.3f}")
print(f"  Median fold change: {on_median/off_median:.3f}")

# Kolmogorov-Smirnov test
ks_stat, ks_p = stats.ks_2samp(offstate_amplitudes, onstate_amplitudes)
print(f"\nKolmogorov-Smirnov test:")
print(f"  KS statistic: {ks_stat:.4f}")
print(f"  p-value: {ks_p:.2e}")
print(f"  Significantly different: {'Yes' if ks_p < 0.05 else 'No'}")

plt.tight_layout()
plt.show()
=== FIGURE 2G: CUMULATIVE DISTRIBUTION ANALYSIS ===
OffState events: 196
  Mean: 14.27 ± 5.52 pA
  Median: 13.00 pA
  25th percentile: 10.31 pA
  75th percentile: 18.26 pA

OnState events: 351
  Mean: 25.84 ± 11.19 pA
  Median: 23.74 pA
  25th percentile: 17.61 pA
  75th percentile: 32.50 pA

Comparison:
  Mean fold change (OnState/OffState): 1.811
  Median fold change: 1.826

Kolmogorov-Smirnov test:
  KS statistic: 0.5106
  p-value: 1.77e-30
  Significantly different: Yes
No description has been provided for this image

Figure 2H: Box Plot Comparison¶

Mean Response Per Session Analysis¶

This box plot compares the mean event amplitudes per experimental session, treating each NWB file as one data point. This approach controls for potential differences in the number of events recorded per session.

In [8]:
# Create box plot
fig, ax = plt.subplots(1, 1, figsize=(3.5, 4.0))

# Convert to numpy arrays
offstate_means = np.array(offstate_file_means)
onstate_means = np.array(onstate_file_means)

# Prepare data for box plot
box_data = [offstate_means, onstate_means]
positions = [1, 2]

# Create box plot with paper-style formatting
bp = ax.boxplot(box_data, positions=positions, patch_artist=True, 
               widths=0.4, showfliers=True, notch=False,
               medianprops=dict(color='black', linewidth=2),
               whiskerprops=dict(color='black', linewidth=1.5),
               capprops=dict(color='black', linewidth=1.5),
               flierprops=dict(marker='o', markersize=4, alpha=0.7, markerfacecolor='gray'))

# Customize box colors to match paper (both white/light gray)
colors = ['white', 'white']
for patch, color in zip(bp['boxes'], colors):
    patch.set_facecolor(color)
    patch.set_edgecolor('black')
    patch.set_linewidth(1.5)

# Add individual data points as gray dots
for i, data in enumerate(box_data):
    # Add some jitter for visibility
    x_vals = np.random.normal(positions[i], 0.04, size=len(data))
    ax.scatter(x_vals, data, color='gray', s=25, alpha=0.8, zorder=3)

# Calculate statistics for significance annotation
off_mean = np.mean(offstate_means)
on_mean = np.mean(onstate_means)

# Statistical test
u_stat, u_p = stats.mannwhitneyu(offstate_means, onstate_means, 
                                alternative='two-sided')

# Add significance annotation (**) at the top
y_max = max(np.max(offstate_means), np.max(onstate_means))
y_sig = y_max + 0.8
ax.text(1.5, y_sig, '**', ha='center', va='center', fontsize=16, fontweight='bold')

# Formatting to match paper exactly
ax.set_xticks([1, 2])
ax.set_xticklabels(['off-state', 'on-state'], fontsize=14)
ax.set_ylabel('oEPSC amplitude (pA)', fontsize=14, fontweight='normal')
ax.set_title('Figure 2H: dSPN oEPSC Amplitude Comparison', fontsize=16, fontweight='bold', pad=15)

# Style the axes
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_linewidth(1.5)
ax.spines['bottom'].set_linewidth(1.5)
ax.tick_params(axis='both', which='major', labelsize=12, width=1.5, length=5)
ax.tick_params(axis='x', which='major', length=0)  # Remove x-axis tick marks

# Calculate and display statistics
off_median = np.median(offstate_means)
on_median = np.median(onstate_means)

print("=== FIGURE 2H: BOX PLOT STATISTICAL ANALYSIS ===")
print(f"\nOffState file means (n={len(offstate_means)}):")
print(f"  Median: {off_median:.2f} pA")
print(f"  Mean: {off_mean:.2f} ± {np.std(offstate_means):.2f} pA")
print(f"  IQR: {np.percentile(offstate_means, 25):.2f} - {np.percentile(offstate_means, 75):.2f} pA")
print(f"  Range: {np.min(offstate_means):.2f} - {np.max(offstate_means):.2f} pA")

print(f"\nOnState file means (n={len(onstate_means)}):")
print(f"  Median: {on_median:.2f} pA")
print(f"  Mean: {on_mean:.2f} ± {np.std(onstate_means):.2f} pA") 
print(f"  IQR: {np.percentile(onstate_means, 25):.2f} - {np.percentile(onstate_means, 75):.2f} pA")
print(f"  Range: {np.min(onstate_means):.2f} - {np.max(onstate_means):.2f} pA")

print(f"\nComparison:")
print(f"  Median fold change: {on_median/off_median:.3f}")
print(f"  Mean fold change: {on_mean/off_mean:.3f}")
print(f"  Difference in medians: {on_median - off_median:.2f} pA")
print(f"  Difference in means: {on_mean - off_mean:.2f} pA")

print(f"\nMann-Whitney U test (non-parametric):")
print(f"  U statistic: {u_stat:.2f}")
print(f"  p-value: {u_p:.2e}")
print(f"  Significantly different: {'Yes' if u_p < 0.05 else 'No'}")

# Welch's t-test (unequal variances)
t_stat, t_p = stats.ttest_ind(offstate_means, onstate_means, 
                            equal_var=False)
print(f"\nWelch's t-test (unequal variances):")
print(f"  t statistic: {t_stat:.4f}")
print(f"  p-value: {t_p:.2e}")
print(f"  Significantly different: {'Yes' if t_p < 0.05 else 'No'}")

# Effect size (Cohen's d)
pooled_std = np.sqrt(((len(offstate_means)-1)*np.var(offstate_means) + 
                    (len(onstate_means)-1)*np.var(onstate_means)) / 
                   (len(offstate_means) + len(onstate_means) - 2))
cohens_d = (on_mean - off_mean) / pooled_std
print(f"\nEffect size (Cohen's d): {cohens_d:.3f}")

if abs(cohens_d) < 0.2:
    effect_size = "negligible"
elif abs(cohens_d) < 0.5:
    effect_size = "small"
elif abs(cohens_d) < 0.8:
    effect_size = "medium"
else:
    effect_size = "large"
print(f"  Effect size interpretation: {effect_size}")

plt.tight_layout()
plt.show()
=== FIGURE 2H: BOX PLOT STATISTICAL ANALYSIS ===

OffState file means (n=3):
  Median: 14.32 pA
  Mean: 14.13 ± 1.14 pA
  IQR: 13.49 - 14.87 pA
  Range: 12.66 - 15.42 pA

OnState file means (n=4):
  Median: 28.64 pA
  Mean: 26.57 ± 6.17 pA
  IQR: 25.33 - 29.87 pA
  Range: 16.29 - 32.71 pA

Comparison:
  Median fold change: 2.000
  Mean fold change: 1.880
  Difference in medians: 14.32 pA
  Difference in means: 12.44 pA

Mann-Whitney U test (non-parametric):
  U statistic: 0.00
  p-value: 5.71e-02
  Significantly different: No

Welch's t-test (unequal variances):
  t statistic: -3.4073
  p-value: 3.65e-02
  Significantly different: Yes

Effect size (Cohen's d): 2.575
  Effect size interpretation: large
No description has been provided for this image

Summary¶

Key Findings¶

This analysis reproduces the key findings from Figure 2G-H of Zhai et al. 2025:

  1. Cumulative Distribution (Figure 2G): Shows the distribution of all individual oEPSC events across experimental conditions
  2. Box Plot Comparison (Figure 2H): Compares mean event amplitudes per experimental session, controlling for session-to-session variability

Methodological Notes¶

  • Event Detection: MAD-based noise estimation with ±5SD threshold
  • Artifact Avoidance: 100ms detection window shift to avoid stimulation artifacts
  • Event Merging: 1ms window to handle multi-threshold crossings from single events
  • Statistical Testing: Both parametric and non-parametric tests for robust comparison

Biological Significance¶

The analysis reveals how L-DOPA treatment (OnState) affects optogenetically-evoked synaptic responses in striatal neurons, providing insights into the synaptic mechanisms underlying levodopa-induced dyskinesia in Parkinson's disease.