Load Empirical Data, Extract Features, and Compute Predictions¶

This notebook follows the same empirical LFP workflow used in examples/LFP_developing_brain/LFP_developing_brain.py:

  • load empirical LFP recordings,
  • extract catch22 features,
  • compute predictions with pre-trained ML assets.

In this tutorial, predictions are computed directly from the provided local model and scaler files.

Requirements¶

To run this notebook, install:

  • ncpi
  • pycatch22
  • scikit-learn
  • matplotlib

Expected local resources:

  • Empirical data folder: /home/pablomc/Downloads/empirical_data/LFP/
  • Model file: /home/pablomc/Downloads/ML_models/MLP/catch22/model
  • Scaler file: /home/pablomc/Downloads/ML_models/MLP/catch22/scaler

1) Set up imports and fixed local paths¶

This tutorial uses the exact empirical and model paths provided above.

In [1]:
import pickle
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import ncpi
from ncpi.EphysDatasetParser import EphysDatasetParser, ParseConfig, CanonicalFields

EMPIRICAL_PATH = Path("/home/pablomc/Downloads/empirical_data/LFP/")
MODEL_PATH = Path("/home/pablomc/Downloads/ML_models/MLP/catch22/")
MODEL_FILE = MODEL_PATH / "model"
SCALER_FILE = MODEL_PATH / "scaler"

print("Empirical path:", EMPIRICAL_PATH)
print("Model file:", MODEL_FILE)
print("Scaler file:", SCALER_FILE)

if not EMPIRICAL_PATH.exists():
    raise FileNotFoundError(f"Empirical path not found: {EMPIRICAL_PATH}")
if not MODEL_FILE.exists():
    raise FileNotFoundError(f"Model file not found: {MODEL_FILE}")
if not SCALER_FILE.exists():
    raise FileNotFoundError(f"Scaler file not found: {SCALER_FILE}")
Empirical path: /home/pablomc/Downloads/empirical_data/LFP
Model file: /home/pablomc/Downloads/ML_models/MLP/catch22/model
Scaler file: /home/pablomc/Downloads/ML_models/MLP/catch22/scaler

2) Load and parse empirical LFP recordings¶

Each file is parsed to canonical schema with:

  • 5-second non-overlapping epochs,
  • channel aggregation by sum,
  • metadata fields (subject_id, group, species, recording_type).
In [2]:
def load_empirical_lfp_data(empirical_path: Path) -> pd.DataFrame:
    files = sorted(empirical_path.glob("*.mat"))
    if not files:
        raise FileNotFoundError(f"No .mat files found in: {empirical_path}")

    all_rows = []

    for subject_id, file_path in enumerate(files):
        print(f"\rProgress: {subject_id + 1}/{len(files)} files loaded", end="", flush=True)

        config = ParseConfig(
            fields=CanonicalFields(
                data=lambda d: d["LFP"].LFP,
                fs=lambda d: float(np.asarray(d["LFP"].fs).squeeze()),
                ch_names=lambda d: [f"ch{i}" for i in range(d["LFP"].LFP.shape[0])],
                metadata={
                    "subject_id": subject_id,
                    "group": lambda d: int(np.asarray(d["LFP"].age).squeeze()),
                    "species": "mouse",
                    "recording_type": "LFP",
                },
            ),
            epoch_length_s=5.0,
            epoch_step_s=5.0,
            aggregate_over=("sensor",),
            aggregate_method="sum",
        )

        parser = EphysDatasetParser(config)
        df_file = parser.parse(file_path)
        all_rows.append(df_file)

    print(f"\nLoaded files: {len(all_rows)}")
    return pd.concat(all_rows, ignore_index=True)


emp_data = load_empirical_lfp_data(EMPIRICAL_PATH)
print("Empirical dataframe shape:", emp_data.shape)
emp_data.head()
Progress: 1/86 files loaded
Progress: 2/86 files loaded
Progress: 3/86 files loaded
Progress: 4/86 files loaded
Progress: 5/86 files loaded
Progress: 6/86 files loaded
Progress: 7/86 files loaded
Progress: 8/86 files loaded
Progress: 9/86 files loaded
Progress: 10/86 files loaded
Progress: 11/86 files loaded
Progress: 12/86 files loaded
Progress: 13/86 files loaded
Progress: 14/86 files loaded
Progress: 15/86 files loaded
Progress: 16/86 files loaded
Progress: 17/86 files loaded
Progress: 18/86 files loaded
Progress: 19/86 files loaded
Progress: 20/86 files loaded
Progress: 21/86 files loaded
Progress: 22/86 files loaded
Progress: 23/86 files loaded
Progress: 24/86 files loaded
Progress: 25/86 files loaded
Progress: 26/86 files loaded
Progress: 27/86 files loaded
Progress: 28/86 files loaded
Progress: 29/86 files loaded
Progress: 30/86 files loaded
Progress: 31/86 files loaded
Progress: 32/86 files loaded
Progress: 33/86 files loaded
Progress: 34/86 files loaded
Progress: 35/86 files loaded
Progress: 36/86 files loaded
Progress: 37/86 files loaded
Progress: 38/86 files loaded
Progress: 39/86 files loaded
Progress: 40/86 files loaded
Progress: 41/86 files loaded
Progress: 42/86 files loaded
Progress: 43/86 files loaded
Progress: 44/86 files loaded
Progress: 45/86 files loaded
Progress: 46/86 files loaded
Progress: 47/86 files loaded
Progress: 48/86 files loaded
Progress: 49/86 files loaded
Progress: 50/86 files loaded
Progress: 51/86 files loaded
Progress: 52/86 files loaded
Progress: 53/86 files loaded
Progress: 54/86 files loaded
Progress: 55/86 files loaded
Progress: 56/86 files loaded
Progress: 57/86 files loaded
Progress: 58/86 files loaded
Progress: 59/86 files loaded
Progress: 60/86 files loaded
Progress: 61/86 files loaded
Progress: 62/86 files loaded
Progress: 63/86 files loaded
Progress: 64/86 files loaded
Progress: 65/86 files loaded
Progress: 66/86 files loaded
Progress: 67/86 files loaded
Progress: 68/86 files loaded
Progress: 69/86 files loaded
Progress: 70/86 files loaded
Progress: 71/86 files loaded
Progress: 72/86 files loaded
Progress: 73/86 files loaded
Progress: 74/86 files loaded
Progress: 75/86 files loaded
Progress: 76/86 files loaded
Progress: 77/86 files loaded
Progress: 78/86 files loaded
Progress: 79/86 files loaded
Progress: 80/86 files loaded
Progress: 81/86 files loaded
Progress: 82/86 files loaded
Progress: 83/86 files loaded
Progress: 84/86 files loaded
Progress: 85/86 files loaded
Progress: 86/86 files loaded
Loaded files: 86
Empirical dataframe shape: (72512, 16)
Out[2]:
subject_id species group condition epoch sensor recording_type fs data t0 t1 data_domain f0 f1 spectral_kind source_file
0 0 mouse 12 NaN 0 aggregate <scipy.io.matlab._mio5_params.mat_struct objec... 100.0 [106457.98602902623, -149539.74712124988, -111... 0.0 4.99 time NaN NaN NaN /home/pablomc/Downloads/empirical_data/LFP/1.mat
1 0 mouse 12 NaN 1 aggregate <scipy.io.matlab._mio5_params.mat_struct objec... 100.0 [17046.739847328776, 17337.584541334687, 17325... 5.0 9.99 time NaN NaN NaN /home/pablomc/Downloads/empirical_data/LFP/1.mat
2 0 mouse 12 NaN 2 aggregate <scipy.io.matlab._mio5_params.mat_struct objec... 100.0 [-1650.4351428274397, -1521.4494185280719, -11... 10.0 14.99 time NaN NaN NaN /home/pablomc/Downloads/empirical_data/LFP/1.mat
3 0 mouse 12 NaN 3 aggregate <scipy.io.matlab._mio5_params.mat_struct objec... 100.0 [999.3953207405618, 1053.0786939545467, 1281.7... 15.0 19.99 time NaN NaN NaN /home/pablomc/Downloads/empirical_data/LFP/1.mat
4 0 mouse 12 NaN 4 aggregate <scipy.io.matlab._mio5_params.mat_struct objec... 100.0 [203.7253504844328, 153.98154989454363, 156.40... 20.0 24.99 time NaN NaN NaN /home/pablomc/Downloads/empirical_data/LFP/1.mat

3) Extract catch22 features from empirical epochs¶

In [3]:
feature_engine = ncpi.Features(method="catch22", params={"normalize": True})

emp_data = emp_data.copy()
emp_data["Features"] = feature_engine.compute_features(emp_data["data"].to_list())

X_emp = np.vstack([np.asarray(f, dtype=float) for f in emp_data["Features"].to_list()])
print("Empirical feature matrix shape:", X_emp.shape)
Computing catch22 features:   0%|          | 0/72512 [00:00<?, ?it/s]
Computing catch22 features:   0%|          | 1/72512 [00:04<85:07:33,  4.23s/it]
Computing catch22 features:   0%|          | 284/72512 [00:04<13:46, 87.42it/s] 
Computing catch22 features:   2%|▏         | 1416/72512 [00:04<02:10, 544.83it/s]
Computing catch22 features:   9%|▊         | 6227/72512 [00:04<00:22, 3005.11it/s]
Computing catch22 features:  12%|█▏        | 8491/72512 [00:04<00:14, 4366.42it/s]
Computing catch22 features:  14%|█▍        | 10029/72512 [00:06<00:22, 2749.19it/s]
Computing catch22 features:  16%|█▌        | 11321/72512 [00:06<00:21, 2865.62it/s]
Computing catch22 features:  18%|█▊        | 13019/72512 [00:06<00:16, 3606.36it/s]
Computing catch22 features:  23%|██▎       | 16698/72512 [00:07<00:10, 5213.81it/s]
Computing catch22 features:  25%|██▍       | 18113/72512 [00:07<00:11, 4571.29it/s]
Computing catch22 features:  26%|██▌       | 18818/72512 [00:07<00:11, 4742.15it/s]
Computing catch22 features:  27%|██▋       | 19500/72512 [00:07<00:11, 4797.38it/s]
Computing catch22 features:  28%|██▊       | 20131/72512 [00:08<00:13, 4027.33it/s]
Computing catch22 features:  29%|██▉       | 20943/72512 [00:08<00:11, 4545.49it/s]
Computing catch22 features:  30%|██▉       | 21533/72512 [00:08<00:11, 4388.77it/s]
Computing catch22 features:  31%|███       | 22358/72512 [00:08<00:10, 4745.18it/s]
Computing catch22 features:  32%|███▏      | 22924/72512 [00:08<00:10, 4880.79it/s]
Computing catch22 features:  36%|███▋      | 26320/72512 [00:08<00:04, 9595.26it/s]
Computing catch22 features:  38%|███▊      | 27304/72512 [00:09<00:08, 5341.11it/s]
Computing catch22 features:  39%|███▊      | 28053/72512 [00:09<00:08, 5311.77it/s]
Computing catch22 features:  40%|███▉      | 28733/72512 [00:09<00:08, 5270.84it/s]
Computing catch22 features:  41%|████      | 29433/72512 [00:09<00:11, 3828.55it/s]
Computing catch22 features:  42%|████▏     | 30282/72512 [00:10<00:12, 3514.09it/s]
Computing catch22 features:  43%|████▎     | 31414/72512 [00:10<00:09, 4121.59it/s]
Computing catch22 features:  44%|████▍     | 32263/72512 [00:10<00:08, 4618.94it/s]
Computing catch22 features:  48%|████▊     | 35093/72512 [00:10<00:04, 8422.14it/s]
Computing catch22 features:  50%|████▉     | 36175/72512 [00:10<00:06, 5930.01it/s]
Computing catch22 features:  52%|█████▏    | 37357/72512 [00:11<00:05, 5973.08it/s]
Computing catch22 features:  53%|█████▎    | 38134/72512 [00:11<00:06, 4930.26it/s]
Computing catch22 features:  53%|█████▎    | 38765/72512 [00:11<00:06, 5022.45it/s]
Computing catch22 features:  54%|█████▍    | 39371/72512 [00:11<00:07, 4183.33it/s]
Computing catch22 features:  55%|█████▌    | 40187/72512 [00:11<00:07, 4294.23it/s]
Computing catch22 features:  56%|█████▌    | 40676/72512 [00:12<00:07, 4232.21it/s]
Computing catch22 features:  57%|█████▋    | 41138/72512 [00:12<00:08, 3539.52it/s]
Computing catch22 features:  59%|█████▊    | 42451/72512 [00:12<00:06, 4938.86it/s]
Computing catch22 features:  59%|█████▉    | 43017/72512 [00:12<00:06, 4606.96it/s]
Computing catch22 features:  62%|██████▏   | 44715/72512 [00:12<00:04, 6736.46it/s]
Computing catch22 features:  63%|██████▎   | 45564/72512 [00:12<00:04, 5795.17it/s]
Computing catch22 features:  64%|██████▍   | 46696/72512 [00:13<00:04, 5393.60it/s]
Computing catch22 features:  66%|██████▌   | 47545/72512 [00:13<00:05, 4754.38it/s]
Computing catch22 features:  67%|██████▋   | 48677/72512 [00:13<00:05, 4488.37it/s]
Computing catch22 features:  68%|██████▊   | 49164/72512 [00:13<00:05, 4273.80it/s]
Computing catch22 features:  68%|██████▊   | 49609/72512 [00:14<00:06, 3438.10it/s]
Computing catch22 features:  70%|███████   | 50941/72512 [00:14<00:04, 4769.29it/s]
Computing catch22 features:  71%|███████▏  | 51790/72512 [00:14<00:04, 4849.70it/s]
Computing catch22 features:  73%|███████▎  | 53205/72512 [00:14<00:03, 6319.01it/s]
Computing catch22 features:  75%|███████▍  | 54337/72512 [00:14<00:02, 6661.93it/s]
Computing catch22 features:  76%|███████▋  | 55469/72512 [00:14<00:02, 6311.81it/s]
Computing catch22 features:  78%|███████▊  | 56318/72512 [00:14<00:02, 6666.68it/s]
Computing catch22 features:  79%|███████▊  | 57034/72512 [00:15<00:02, 6001.91it/s]
Computing catch22 features:  80%|███████▉  | 57671/72512 [00:15<00:02, 5055.69it/s]
Computing catch22 features:  80%|████████  | 58216/72512 [00:15<00:04, 2922.24it/s]
Computing catch22 features:  81%|████████  | 58632/72512 [00:15<00:04, 3004.07it/s]
Computing catch22 features:  82%|████████▏ | 59431/72512 [00:15<00:03, 3731.65it/s]
Computing catch22 features:  83%|████████▎ | 60280/72512 [00:16<00:02, 4342.58it/s]
Computing catch22 features:  85%|████████▍ | 61412/72512 [00:16<00:02, 4737.54it/s]
Computing catch22 features:  87%|████████▋ | 63110/72512 [00:16<00:01, 6569.86it/s]
Computing catch22 features:  88%|████████▊ | 63847/72512 [00:16<00:01, 5302.91it/s]
Computing catch22 features:  90%|████████▉ | 65091/72512 [00:16<00:01, 6111.35it/s]
Computing catch22 features:  91%|█████████ | 65940/72512 [00:17<00:01, 5920.04it/s]
Computing catch22 features:  92%|█████████▏| 66789/72512 [00:17<00:00, 5909.76it/s]
Computing catch22 features:  93%|█████████▎| 67415/72512 [00:17<00:01, 4745.65it/s]
Computing catch22 features:  94%|█████████▎| 67937/72512 [00:17<00:01, 3842.96it/s]
Computing catch22 features:  94%|█████████▍| 68487/72512 [00:17<00:01, 3923.79it/s]
Computing catch22 features:  97%|█████████▋| 70468/72512 [00:17<00:00, 6499.59it/s]
Computing catch22 features: 100%|██████████| 72512/72512 [00:17<00:00, 4039.26it/s]

Empirical feature matrix shape: (72512, 22)

4) Load model/scaler and compute predictions¶

This step uses the provided local files directly.

If the loaded model is an ensemble list, predictions are averaged across ensemble members.

In [4]:
with MODEL_FILE.open("rb") as f:
    model = pickle.load(f)

with SCALER_FILE.open("rb") as f:
    scaler = pickle.load(f)


def predict_with_model(X: np.ndarray, scaler_obj, model_obj) -> np.ndarray:
    X_scaled = scaler_obj.transform(X)

    if isinstance(model_obj, list):
        all_preds = [np.asarray(m.predict(X_scaled), dtype=float) for m in model_obj]
        return np.mean(np.stack(all_preds, axis=0), axis=0)

    return np.asarray(model_obj.predict(X_scaled), dtype=float)


pred_raw = predict_with_model(X_emp, scaler, model)
pred_raw = np.asarray(pred_raw, dtype=float)
if pred_raw.ndim == 1:
    pred_raw = pred_raw.reshape(-1, 1)

print("Raw prediction shape:", pred_raw.shape)

if pred_raw.shape[1] >= 7:
    pred_ei = (pred_raw[:, 0] / pred_raw[:, 2]) / (pred_raw[:, 1] / pred_raw[:, 3])
    pred_tau_exc = pred_raw[:, 4]
    pred_tau_inh = pred_raw[:, 5]
    pred_j_ext = pred_raw[:, 6]
elif pred_raw.shape[1] == 4:
    pred_ei = pred_raw[:, 0]
    pred_tau_exc = pred_raw[:, 1]
    pred_tau_inh = pred_raw[:, 2]
    pred_j_ext = pred_raw[:, 3]
else:
    raise ValueError(
        f"Unsupported prediction dimensionality: {pred_raw.shape[1]}. Expected 4 or >=7 outputs."
    )

predictions_df = emp_data[["subject_id", "group"]].copy()
predictions_df["E_I"] = pred_ei
predictions_df["tau_exc"] = pred_tau_exc
predictions_df["tau_inh"] = pred_tau_inh
predictions_df["J_ext"] = pred_j_ext

predictions_df[["E_I", "tau_exc", "tau_inh", "J_ext"]].describe()
/home/pablomc/anaconda3/envs/ncpi-env/lib/python3.10/site-packages/sklearn/base.py:376: InconsistentVersionWarning: Trying to unpickle estimator MLPRegressor from version 1.3.2 when using version 1.5.0. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:
https://scikit-learn.org/stable/model_persistence.html#security-maintainability-limitations
  warnings.warn(
/home/pablomc/anaconda3/envs/ncpi-env/lib/python3.10/site-packages/sklearn/base.py:376: InconsistentVersionWarning: Trying to unpickle estimator StandardScaler from version 1.3.2 when using version 1.5.0. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:
https://scikit-learn.org/stable/model_persistence.html#security-maintainability-limitations
  warnings.warn(
Raw prediction shape: (72512, 7)
Out[4]:
E_I tau_exc tau_inh J_ext
count 72512.000000 72512.000000 72512.000000 72512.000000
mean 1.936655 0.974795 4.248541 26.193148
std 73.363238 0.325364 1.951607 12.951049
min -3983.937370 -9.541862 -33.963710 -85.565175
25% 0.673533 0.788808 3.116595 19.035746
50% 1.205469 0.967518 4.135929 28.194428
75% 2.122702 1.158769 5.293383 35.594296
max 17261.298469 2.555367 38.612775 126.600647

5) Plot boxplots of predicted parameters¶

For each predicted parameter, values are plotted as a function of age (group).

Statistical annotations use Cohen's d (control age vs other ages), shown as stars based on effect-size magnitude.

Panels are shown for:

  • predicted E/I,
  • predicted tau_exc,
  • predicted tau_inh,
  • predicted J_ext.
In [5]:
plot_columns = [
    ("E_I", r"$E/I$"),
    ("tau_exc", r"$\tau_{syn}^{exc}$ (ms)"),
    ("tau_inh", r"$\tau_{syn}^{inh}$ (ms)"),
    ("J_ext", r"$J_{syn}^{ext}$ (nA)"),
]


def cohend_to_stars(d_value: float) -> str:
    """Map |Cohen's d| to significance-style stars."""
    ad = abs(float(d_value))
    if ad < 0.2:
        return "n.s."
    if ad < 0.5:
        return "*"
    if ad < 0.8:
        return "**"
    if ad < 1.2:
        return "***"
    return "****"


predictions_df = predictions_df.copy()
predictions_df["group"] = predictions_df["group"].astype(int)
predictions_df = predictions_df[predictions_df["group"] >= 4].copy()

if predictions_df.empty:
    raise ValueError("No predictions available for ages >= 4.")

ages_sorted = np.array(sorted(predictions_df["group"].unique()), dtype=int)
control_group = 4
if control_group not in ages_sorted:
    raise ValueError("Control group age=4 is not present in the filtered data.")

groups_to_annotate = [int(a) for a in ages_sorted if int(a) != control_group]

fig, axes = plt.subplots(1, 4, figsize=(16, 4.5), dpi=150)

for ax, (col, title) in zip(axes, plot_columns):
    # Boxplots as a function of age
    for age in ages_sorted:
        data_plot = predictions_df.loc[predictions_df["group"] == age, col].dropna().to_numpy(dtype=float)
        if data_plot.size == 0:
            continue

        # Match the LFP_predictions style: clip for violin and overlay boxplots
        q1, q3 = np.percentile(data_plot, [5, 95])
        clipped_data = data_plot[(data_plot >= q1) & (data_plot <= q3)]

        violin = ax.violinplot(clipped_data, positions=[int(age)], widths=0.9, showextrema=False)
        for pc in violin["bodies"]:
            pc.set_facecolor("#8ecae6")
            pc.set_edgecolor("black")
            pc.set_alpha(0.5)
            pc.set_linewidth(0.2)

        ax.boxplot(
            data_plot,
            positions=[int(age)],
            showfliers=False,
            widths=0.5,
            patch_artist=True,
            medianprops=dict(color="red", linewidth=0.9),
            whiskerprops=dict(color="black", linewidth=0.6),
            capprops=dict(color="black", linewidth=0.6),
            boxprops=dict(linewidth=0.6, facecolor=(0, 0, 0, 0), edgecolor="black"),
        )

    # Cohen's d analysis (control age vs each other age)
    df_stat = pd.DataFrame(
        {
            "group": predictions_df["group"].astype(str),
            "sensor": np.zeros(len(predictions_df), dtype=int),
            "Predictions": predictions_df[col].astype(float),
        }
    )
    df_stat = df_stat[~np.isnan(df_stat["Predictions"])].copy()

    analysis = ncpi.Analysis(df_stat)
    cohen_stats = analysis.cohend(
        control_group=str(control_group),
        data_col="Predictions",
        data_index=-1,
        group_col="group",
        sensor_col="sensor",
    )

    # Add stars from Cohen's d
    y_min, y_max = ax.get_ylim()
    delta = (y_max - y_min) * 0.10 if y_max > y_min else 1.0
    line_i = 0

    for age in groups_to_annotate:
        key = f"{age}vs{control_group}"
        d_df = cohen_stats.get(key)
        if d_df is None or len(d_df) == 0 or "d" not in d_df.columns:
            continue

        d_value = float(d_df["d"].iloc[0])
        stars = cohend_to_stars(d_value)

        y_line = y_max + delta * (line_i + 0.2)
        y_text = y_line + delta * 0.05

        ax.plot([control_group, age], [y_line, y_line], color="black", linewidth=0.6)
        ax.text((control_group + age) / 2.0, y_text, stars, ha="center", va="bottom", fontsize=8)

        line_i += 1

    ax.set_ylim(y_min, y_max + delta * max(1, len(groups_to_annotate) + 1))
    ax.set_title(title)
    ax.set_xlabel("Postnatal days")
    ax.set_xticks(ages_sorted)
    ax.set_xticklabels([str(a) for a in ages_sorted])

plt.tight_layout()
plt.show()
No description has been provided for this image