{ "cells": [ { "cell_type": "markdown", "id": "15d23bcd", "metadata": {}, "source": [ "# Pipeline-based processing in pypillometry" ] }, { "cell_type": "code", "execution_count": 1, "id": "270a473b", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:27:59.301629Z", "iopub.status.busy": "2026-11-09T20:27:59.301337Z", "iopub.status.idle": "2026-11-09T20:27:59.408595Z", "shell.execute_reply": "2026-11-09T20:27:59.407910Z" } }, "outputs": [], "source": [ "import pypillometry as pp\n", "from pypillometry import u" ] }, { "cell_type": "markdown", "id": "19445980", "metadata": {}, "source": [ "`pypillometry` processes data in pipelines: processing steps (e.g., blink detection, a filter, downsampling) are combined with the `|` operator and applied to an `EyeData` object:" ] }, { "cell_type": "code", "execution_count": 2, "id": "b441c1a2", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:27:59.412626Z", "iopub.status.busy": "2026-11-09T20:27:59.412335Z", "iopub.status.idle": "2026-11-09T20:28:00.154506Z", "shell.execute_reply": "2026-11-09T20:28:00.153809Z" } }, "outputs": [], "source": [ "# load a small example dataset\n", "d = pp.get_example_data(\"rlmw_002_short\")\n", "d = d | pp.pupil.blinks_detect() \\\n", " | pp.pupil.blinks_merge_close(distance=100 * u.ms) \\\n", " | pp.pupil.lowpass_filter(3) \\\n", " | pp.downsample(50)" ] }, { "cell_type": "markdown", "id": "037dfead", "metadata": {}, "source": [ "This command loads a data file (here we use an example dataset included with `pypillometry`), detects blinks in the signal and masks them, merges short, successive blinks, applies a 3 Hz low-pass filter, and downsamples the signal to 50 Hz. The final result of this processing pipeline is stored in object `d`.\n", "\n", "The steps for pupil data are `pp.pupil.(...)`, those for gaze data `pp.gaze.(...)`, and the generic steps are available at the top level of the package (`pp.downsample`, `pp.crop`, `pp.scale`, ...). They take the same arguments as the methods of the same name (`d.pupil.blinks_detect()`, `d.downsample()`, ...), which run the same code.\n", "\n", "Here, for better visibility, we put each operation on a separate line. For that to work, we need to tell Python that the statement has not yet ended, which we achieve by putting a backslash `\\` at the end of each (non-final) line (or by putting the whole expression in parentheses).\n", "\n", "We can get a useful summary of the dataset and the operations applied to it by simply printing it:" ] }, { "cell_type": "code", "execution_count": 3, "id": "c3bb1ef1", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.157489Z", "iopub.status.busy": "2026-11-09T20:28:00.157278Z", "iopub.status.idle": "2026-11-09T20:28:00.476270Z", "shell.execute_reply": "2026-11-09T20:28:00.475587Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "

EyeData — rlmw_002_short

\n", "\n", "
\n", "
\n", "
Samples
\n", "
2,047
\n", "
\n", "
\n", "
Sampling rate
\n", "
50.0 Hz
\n", "
\n", "
\n", "
Duration
\n", "
0.68 min (start 0.00, end 0.68)
\n", "
\n", "
\n", "
Eyes
\n", "
left, right
\n", "
\n", "
\n", "
Variables
\n", "
left_x, left_y, left_pupil, right_x, right_y, right_pupil
\n", "
\n", "
\n", "
Events
\n", "
40
\n", "
\n", "
\n", "
Blinks
\n", "
left_pupil: 13, right_pupil: 13
\n", "
\n", "
\n", "
Size
\n", "
143.8KiB
\n", "
\n", "
\n", "\n", "\n", "
\n", " Signal overview\n", "
\n", "
\n", "
left_pupil
\n", " \"left_pupil\n", "
\n", "
\n", "
left_x
\n", " \"left_x\n", "
\n", "
\n", "
left_y
\n", " \"left_y\n", "
\n", "
\n", "
right_pupil
\n", " \"right_pupil\n", "
\n", "
\n", "
right_x
\n", " \"right_x\n", "
\n", "
\n", "
right_y
\n", " \"right_y\n", "
\n", "
\n", "
\n", "\n", "\n", "
\n", " Blink statistics\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
EyeCountMean ± SD (ms)Range (ms)
left_pupil13236.9 ± 256.9[60.0, 980.0]
right_pupil13136.9 ± 84.8[40.0, 400.0]
\n", "
\n", "\n", "
\n", " Data glimpse\n", "
EyeDataDict(vars=6,n=2047,shape=(2047,)): \n",
       "  left_x (float64): 656.55, 656.14, 654.04, 652.02, 650.4...\n",
       "  left_y (float64): 597.54, 591.5, 588.39, 593.78, 598.32...\n",
       "  left_pupil (float64): 1128.8869, 1133.8058, 1139.7069, 1146.4462, 1153.8028...\n",
       "  right_x (float64): 775.56, 777.14, 777.08, 776.99, 778.72...\n",
       "  right_y (float64): 597.31, 601.3, 600.48, 595.34, 595.1...\n",
       "  right_pupil (float64): 954.2415, 958.9101, 963.6321, 968.2405, 972.675...\n",
       "
\n", "
\n", "\n", "
\n", " Recent history\n", "
    \n", "
  1. reset_time()
  2. \n", "
  3. pupil.blinks_detect()
  4. \n", "
  5. pupil.blinks_merge_close(distance=100 ms)
  6. \n", "
  7. pupil.lowpass_filter(3)
  8. \n", "
  9. downsample(50)
  10. \n", "
\n", "
\n", "
\n" ], "text/plain": [ "EyeData(rlmw_002_short, 143.8KiB):\n", " n : 2047\n", " sampling_rate : 50.0\n", " eyes : ['left', 'right']\n", " data : ['left_x', 'left_y', 'left_pupil', 'right_x', 'right_y', 'right_pupil']\n", " nevents : 40\n", " experimental_setup: {'screen_resolution': (1280, 1024), 'physical_screen_size_mm': (300.0, 200.0), 'eye_to_screen_distance_mm': 600.0, 'camera_position': 'not set'}\n", " calibration : not available\n", " duration_minutes : 0.6823\n", " start_min : 0.00015\n", " end_min : 0.6821\n", " params : {}\n", " nblinks : {'left_pupil': 13, 'right_pupil': 13}\n", " blinks : {'left_pupil': {'n': 13, 'mean': 236.9231, 'sd': 256.9461, 'min': 60.0, 'max': 980.0, 'total_duration': 3080.0, 'units': 'ms'}, 'right_pupil': {'n': 13, 'mean': 136.9231, 'sd': 84.797, 'min': 40.0, 'max': 400.0, 'total_duration': 1780.0, 'units': 'ms'}}\n", " glimpse : EyeDataDict(vars=6,n=2047,shape=(2047,)): \n", " left_x (float64): 656.55, 656.14, 654.04, 652.02, 650.4...\n", " left_y (float64): 597.54, 591.5, 588.39, 593.78, 598.32...\n", " left_pupil (float64): 1128.8869, 1133.8058, 1139.7069, 1146.4462, 1153.8028...\n", " right_x (float64): 775.56, 777.14, 777.08, 776.99, 778.72...\n", " right_y (float64): 597.31, 601.3, 600.48, 595.34, 595.1...\n", " right_pupil (float64): 954.2415, 958.9101, 963.6321, 968.2405, 972.675...\n", "\n", " History:\n", " *\n", " └ reset_time()\n", " └ pupil.blinks_detect()\n", " └ pupil.blinks_merge_close(distance=100 ms)\n", " └ pupil.lowpass_filter(3)\n", " └ downsample(50)" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "d" ] }, { "cell_type": "markdown", "id": "02cf70aa", "metadata": {}, "source": [ "We see that sampling rate, number of datapoints and more is printed along with the history of all operations applied to the dataset. The same information can be retrieved in a form useful for further processing with the function `summary()`, which returns a `dict`:" ] }, { "cell_type": "code", "execution_count": 4, "id": "3a594561", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.479567Z", "iopub.status.busy": "2026-11-09T20:28:00.479165Z", "iopub.status.idle": "2026-11-09T20:28:00.484521Z", "shell.execute_reply": "2026-11-09T20:28:00.483910Z" } }, "outputs": [ { "data": { "text/plain": [ "{'name': 'rlmw_002_short',\n", " 'n': 2047,\n", " 'sampling_rate': 50.0,\n", " 'eyes': ['left', 'right'],\n", " 'data': ['left_x',\n", " 'left_y',\n", " 'left_pupil',\n", " 'right_x',\n", " 'right_y',\n", " 'right_pupil'],\n", " 'nevents': 40,\n", " 'experimental_setup': {'screen_resolution': (1280, 1024),\n", " 'physical_screen_size_mm': (300.0, 200.0),\n", " 'eye_to_screen_distance_mm': 600.0,\n", " 'camera_position': 'not set'},\n", " 'calibration': 'not available',\n", " 'duration_minutes': 0.6823333333333333,\n", " 'start_min': 0.00015,\n", " 'end_min': 0.6820666666666667,\n", " 'params': {},\n", " 'nblinks': {'left_pupil': 13, 'right_pupil': 13},\n", " 'blinks': {'left_pupil': {'n': 13,\n", " 'mean': 236.92307692307693,\n", " 'sd': 256.94610675226807,\n", " 'min': 60.0,\n", " 'max': 980.0,\n", " 'total_duration': 3080.0,\n", " 'units': 'ms'},\n", " 'right_pupil': {'n': 13,\n", " 'mean': 136.92307692307693,\n", " 'sd': 84.79700787397336,\n", " 'min': 40.0,\n", " 'max': 400.0,\n", " 'total_duration': 1780.0,\n", " 'units': 'ms'}}}" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "d.summary()" ] }, { "cell_type": "markdown", "id": "0b192985", "metadata": {}, "source": [ "The history alone can be shown with `print_history()`:" ] }, { "cell_type": "code", "execution_count": 5, "id": "1b025930", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.486249Z", "iopub.status.busy": "2026-11-09T20:28:00.486109Z", "iopub.status.idle": "2026-11-09T20:28:00.488839Z", "shell.execute_reply": "2026-11-09T20:28:00.488387Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "* rlmw_002_short\n", "└ reset_time()\n", " └ pupil.blinks_detect()\n", " └ pupil.blinks_merge_close(distance=100 ms)\n", " └ pupil.lowpass_filter(3)\n", " └ downsample(50)\n" ] } ], "source": [ "d.print_history()" ] }, { "cell_type": "markdown", "id": "8c01fcc2", "metadata": {}, "source": [ "## Pipelines are values\n", "\n", "A combination of steps is a `Pipeline`. It can be stored in a variable, printed, and applied to as many datasets as needed (e.g., to all participants of a study):" ] }, { "cell_type": "code", "execution_count": 6, "id": "9dc9f99a", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.490368Z", "iopub.status.busy": "2026-11-09T20:28:00.490244Z", "iopub.status.idle": "2026-11-09T20:28:00.493654Z", "shell.execute_reply": "2026-11-09T20:28:00.493154Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "pp.pupil.blinks_detect() | pp.pupil.blinks_merge_close(distance=100 ms) | pp.pupil.lowpass_filter(3)\n" ] } ], "source": [ "preproc = pp.pupil.blinks_detect() \\\n", " | pp.pupil.blinks_merge_close(distance=100 * u.ms) \\\n", " | pp.pupil.lowpass_filter(3)\n", "print(preproc)" ] }, { "cell_type": "code", "execution_count": 7, "id": "c2a928c3", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.494969Z", "iopub.status.busy": "2026-11-09T20:28:00.494851Z", "iopub.status.idle": "2026-11-09T20:28:00.545894Z", "shell.execute_reply": "2026-11-09T20:28:00.545209Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "d has 20465 samples and 0 blinks\n", "d2 has 2047 samples and 13 blinks\n" ] } ], "source": [ "d = pp.get_example_data(\"rlmw_002_short\")\n", "d2 = d | preproc | pp.downsample(50)\n", "print(f\"d has {len(d)} samples and {len(d.get_blinks()['left_pupil'])} blinks\")\n", "print(f\"d2 has {len(d2)} samples and {len(d2.get_blinks()['left_pupil'])} blinks\")" ] }, { "cell_type": "markdown", "id": "7312552e", "metadata": {}, "source": [ "`d | pipeline` does not change `d`: it copies `d` once and applies all steps to the copy, which is returned. (Python reads `d | a | b` as `(d | a) | b`, which copies at each `|`; for large datasets, apply the whole pipeline at once, as in `d | preproc` or `d | (a | b)`.)\n", "\n", "Parameters that differ between participants can be passed with a small function that returns the pipeline:" ] }, { "cell_type": "code", "execution_count": 8, "id": "24ee2e13", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.547919Z", "iopub.status.busy": "2026-11-09T20:28:00.547797Z", "iopub.status.idle": "2026-11-09T20:28:00.559946Z", "shell.execute_reply": "2026-11-09T20:28:00.559323Z" } }, "outputs": [], "source": [ "def preproc_subj(vel_onset):\n", " return pp.pupil.blinks_detect(vel_onset=vel_onset) | pp.pupil.lowpass_filter(3)\n", "\n", "d2 = d | preproc_subj(vel_onset=-10)" ] }, { "cell_type": "markdown", "id": "81776a59", "metadata": {}, "source": [ "If a step fails, the error message says which step it was.\n", "\n", "## Calling the methods directly\n", "\n", "The same operations are available as methods of the data object: the pupil methods in `d.pupil`, the gaze methods in `d.gaze` and the generic ones directly on `d`. This is convenient for single steps:" ] }, { "cell_type": "code", "execution_count": 9, "id": "3adf65bc", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.561676Z", "iopub.status.busy": "2026-11-09T20:28:00.561561Z", "iopub.status.idle": "2026-11-09T20:28:00.639377Z", "shell.execute_reply": "2026-11-09T20:28:00.638564Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "

EyeData — rlmw_002_short

\n", "\n", "
\n", "
\n", "
Samples
\n", "
2,047
\n", "
\n", "
\n", "
Sampling rate
\n", "
50.0 Hz
\n", "
\n", "
\n", "
Duration
\n", "
0.68 min (start 0.00, end 0.68)
\n", "
\n", "
\n", "
Eyes
\n", "
left, right
\n", "
\n", "
\n", "
Variables
\n", "
left_x, left_y, left_pupil, right_x, right_y, right_pupil
\n", "
\n", "
\n", "
Events
\n", "
40
\n", "
\n", "
\n", "
Blinks
\n", "
left_pupil: 14, right_pupil: 13
\n", "
\n", "
\n", "
Size
\n", "
143.0KiB
\n", "
\n", "
\n", "\n", "\n", "
\n", " Signal overview\n", "
\n", "
\n", "
left_pupil
\n", " \"left_pupil\n", "
\n", "
\n", "
left_x
\n", " \"left_x\n", "
\n", "
\n", "
left_y
\n", " \"left_y\n", "
\n", "
\n", "
right_pupil
\n", " \"right_pupil\n", "
\n", "
\n", "
right_x
\n", " \"right_x\n", "
\n", "
\n", "
right_y
\n", " \"right_y\n", "
\n", "
\n", "
\n", "\n", "\n", "
\n", " Blink statistics\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
EyeCountMean ± SD (ms)Range (ms)
left_pupil14218.6 ± 232.9[60.0, 980.0]
right_pupil13136.9 ± 84.8[40.0, 400.0]
\n", "
\n", "\n", "
\n", " Data glimpse\n", "
EyeDataDict(vars=6,n=2047,shape=(2047,)): \n",
       "  left_x (float64): 656.55, 656.14, 654.04, 652.02, 650.4...\n",
       "  left_y (float64): 597.54, 591.5, 588.39, 593.78, 598.32...\n",
       "  left_pupil (float64): 1123.2, 1132.5, 1135.1, 1142.6, 1152.3...\n",
       "  right_x (float64): 775.56, 777.14, 777.08, 776.99, 778.72...\n",
       "  right_y (float64): 597.31, 601.3, 600.48, 595.34, 595.1...\n",
       "  right_pupil (float64): 956.7, 964.2, 970.6, 970.8, 970.3...\n",
       "
\n", "
\n", "\n", "
\n", " Recent history\n", "
    \n", "
  1. reset_time()
  2. \n", "
  3. pupil.blinks_detect()
  4. \n", "
  5. downsample(50)
  6. \n", "
\n", "
\n", "
\n" ], "text/plain": [ "EyeData(rlmw_002_short, 143.0KiB):\n", " n : 2047\n", " sampling_rate : 50.0\n", " eyes : ['left', 'right']\n", " data : ['left_x', 'left_y', 'left_pupil', 'right_x', 'right_y', 'right_pupil']\n", " nevents : 40\n", " experimental_setup: {'screen_resolution': (1280, 1024), 'physical_screen_size_mm': (300.0, 200.0), 'eye_to_screen_distance_mm': 600.0, 'camera_position': 'not set'}\n", " calibration : not available\n", " duration_minutes : 0.6823\n", " start_min : 0.00015\n", " end_min : 0.6821\n", " params : {}\n", " nblinks : {'left_pupil': 14, 'right_pupil': 13}\n", " blinks : {'left_pupil': {'n': 14, 'mean': 218.5714, 'sd': 232.9272, 'min': 60.0, 'max': 980.0, 'total_duration': 3060.0, 'units': 'ms'}, 'right_pupil': {'n': 13, 'mean': 136.9231, 'sd': 84.797, 'min': 40.0, 'max': 400.0, 'total_duration': 1780.0, 'units': 'ms'}}\n", " glimpse : EyeDataDict(vars=6,n=2047,shape=(2047,)): \n", " left_x (float64): 656.55, 656.14, 654.04, 652.02, 650.4...\n", " left_y (float64): 597.54, 591.5, 588.39, 593.78, 598.32...\n", " left_pupil (float64): 1123.2, 1132.5, 1135.1, 1142.6, 1152.3...\n", " right_x (float64): 775.56, 777.14, 777.08, 776.99, 778.72...\n", " right_y (float64): 597.31, 601.3, 600.48, 595.34, 595.1...\n", " right_pupil (float64): 956.7, 964.2, 970.6, 970.8, 970.3...\n", "\n", " History:\n", " *\n", " └ reset_time()\n", " └ pupil.blinks_detect()\n", " └ downsample(50)" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "d = pp.get_example_data(\"rlmw_002_short\")\n", "d.pupil.blinks_detect()\n", "d.downsample(50)" ] }, { "cell_type": "markdown", "id": "f7f7b5e2", "metadata": {}, "source": [ "## In-place vs. copying data\n", "\n", "`d | step` never changes `d`. The methods, however, work in-place by default. That means, after running an operation (e.g., `d.downsample()`), the dataset will have changed and the returned object is the same object as `d`.\n", "\n", "For example, the next code downsamples the data to 50 Hz and returns dataset `d2`. However, since the operation was in-place, the original dataset `d` has also changed:" ] }, { "cell_type": "code", "execution_count": 10, "id": "3e781453", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.641956Z", "iopub.status.busy": "2026-11-09T20:28:00.641834Z", "iopub.status.idle": "2026-11-09T20:28:00.679455Z", "shell.execute_reply": "2026-11-09T20:28:00.678493Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "d has 2047 samples\n", "d2 has 2047 samples\n", "d and d2 are the same: True\n" ] } ], "source": [ "d = pp.get_example_data(\"rlmw_002_short\")\n", "d2 = d.downsample(50)\n", "print(f\"d has {len(d)} samples\")\n", "print(f\"d2 has {len(d2)} samples\")\n", "print(f\"d and d2 are the same: {d is d2}\")" ] }, { "cell_type": "markdown", "id": "8b71f6b7", "metadata": {}, "source": [ "This can be tricky when re-running a cell of a notebook as the operations will be applied again to the already processed data (i.e., blink detection, downsampling, smoothing etc. will be run multiple times on the same data).\n", "\n", "This can be prevented by adding `inplace=False` to any operation or by using `d.copy()` explicitly:" ] }, { "cell_type": "code", "execution_count": 11, "id": "b5118b6f", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.681906Z", "iopub.status.busy": "2026-11-09T20:28:00.681759Z", "iopub.status.idle": "2026-11-09T20:28:00.719107Z", "shell.execute_reply": "2026-11-09T20:28:00.718020Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "d has 20465 samples\n", "d2 has 2047 samples\n", "d and d2 are the same: False\n" ] } ], "source": [ "# using inplace=False\n", "d = pp.get_example_data(\"rlmw_002_short\")\n", "d2 = d.downsample(50, inplace=False)\n", "print(f\"d has {len(d)} samples\")\n", "print(f\"d2 has {len(d2)} samples\")\n", "print(f\"d and d2 are the same: {d is d2}\")" ] }, { "cell_type": "code", "execution_count": 12, "id": "7cb635a0", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.721222Z", "iopub.status.busy": "2026-11-09T20:28:00.720931Z", "iopub.status.idle": "2026-11-09T20:28:00.760484Z", "shell.execute_reply": "2026-11-09T20:28:00.759541Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "d has 20465 samples\n", "d2 has 2047 samples\n", "d and d2 are the same: False\n" ] } ], "source": [ "# using copy()\n", "d = pp.get_example_data(\"rlmw_002_short\")\n", "d2 = d.copy()\n", "d2.downsample(50)\n", "print(f\"d has {len(d)} samples\")\n", "print(f\"d2 has {len(d2)} samples\")\n", "print(f\"d and d2 are the same: {d is d2}\")" ] }, { "cell_type": "markdown", "id": "59d1cd52", "metadata": {}, "source": [ "If you prefer that operations never change the object they are called on, you can change the default for a single object (`inplace=False` when creating it) or for the whole package with `pp.set_default_inplace()`:" ] }, { "cell_type": "code", "execution_count": 13, "id": "7770e627", "metadata": { "execution": { "iopub.execute_input": "2026-11-09T20:28:00.762108Z", "iopub.status.busy": "2026-11-09T20:28:00.761895Z", "iopub.status.idle": "2026-11-09T20:28:00.801864Z", "shell.execute_reply": "2026-11-09T20:28:00.800770Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "d has 20465 samples\n", "d2 has 2047 samples\n" ] } ], "source": [ "pp.set_default_inplace(False)\n", "d = pp.get_example_data(\"rlmw_002_short\")\n", "d2 = d.downsample(50)\n", "print(f\"d has {len(d)} samples\")\n", "print(f\"d2 has {len(d2)} samples\")\n", "pp.set_default_inplace(True) # back to the default" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.15.1" } }, "nbformat": 4, "nbformat_minor": 5 }