{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "\n# PyGeNN implementation of SuperSpike\nThis example model is a reimplementation of the model developed by \nFriedemann Zenke and Surya Ganguli [Zenke2018]_. It uses the SuperSpike \nlearning rule to learn the transformation between fixed spike trains of \nPoisson noise and a target spiking output (by default the Radcliffe Camera at Oxford).\n\nThis example can be used as follows:\n\n.. argparse::\n :filename: ../userproject/superspike_demo.py\n :func: get_parser\n :prog: superspike_demo\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": { "collapsed": false }, "outputs": [], "source": [ "import numpy as np\n\nfrom argparse import ArgumentParser\nfrom pygenn import (create_custom_update_model, create_neuron_model,\n create_postsynaptic_model, create_var_ref,\n create_weight_update_model, create_wu_var_ref,\n init_postsynaptic, init_var, init_weight_update)\nfrom pygenn import GeNNModel\n\n# ----------------------------------------------------------------------------\n# Parameters\n# ----------------------------------------------------------------------------\nTIMESTEP_MS = 0.1\n\n# Network structure\nNUM_INPUT = 200\nNUM_OUTPUT = 200\nNUM_HIDDEN = 256\n\n# Model parameters\nTAU_RISE_MS = 5.0\nTAU_DECAY_MS = 10.0\nTAU_RMS_MS = 30000.0\nTAU_AVG_ERR_MS = 10000.0\nR0 = 0.001 * 1000.0\nEPSILON = 1E-32\nTAU_DECAY_S = TAU_DECAY_MS / 1000.0\nTAU_RISE_S = TAU_RISE_MS / 1000.0\nTAU_AVG_ERR_S = TAU_AVG_ERR_MS / 1000.0\nSCALE_TR_ERR_FLT = 1.0 / (pow((TAU_DECAY_S * TAU_RISE_S)/(TAU_DECAY_S - TAU_RISE_S),2) * (TAU_DECAY_S/2+TAU_RISE_S/2-2*(TAU_DECAY_S*TAU_RISE_S)/(TAU_DECAY_S+TAU_RISE_S))) / TAU_AVG_ERR_S\n\n# Weights\n# **NOTE** Auryn units are volts, seconds etc so essentially 1000x GeNN parameters\nW_MIN = -0.1 * 1000.0\nW_MAX = 0.1 * 1000.0\nW0 = 0.05 * 1000.0\n\n# Experiment parameters\nINPUT_FREQ_HZ = 5.0\nUPDATE_TIME_MS = 500.0\nTRIAL_MS = 1890.0\n\n# Convert parameters to timesteps\nUPDATE_TIMESTEPS = int(UPDATE_TIME_MS / TIMESTEP_MS)\nTRIAL_TIMESTEPS = int(TRIAL_MS / TIMESTEP_MS)\n\n# ----------------------------------------------------------------------------\n# Helper functions\n# ----------------------------------------------------------------------------\ndef calc_t_peak(tau_rise, tau_decay):\n return ((tau_decay * tau_rise) / (tau_decay - tau_rise)) * np.log(tau_decay / tau_rise)\n\ndef write_spike_file(filename, data):\n np.savetxt(filename, np.column_stack(data[0]), fmt=[\"%f\",\"%d\"],\n delimiter=\",\", header=\"Time [ms], Neuron ID\")\n\n# ----------------------------------------------------------------------------\n# Custom models\n# ----------------------------------------------------------------------------\nr_max_prop_model = create_custom_update_model(\n \"r_max_prop\",\n params=[\"updateTime\", \"tauRMS\", \"epsilon\", \"wMin\", \"wMax\", \"r0\"],\n vars=[(\"upsilon\", \"scalar\")],\n derived_params=[(\"updateTimesteps\", lambda pars, dt: pars[\"updateTime\"] / dt),\n (\"expRMS\", lambda pars, dt: np.exp(-pars[\"updateTime\"] / pars[\"tauRMS\"]))],\n var_refs=[(\"m\", \"scalar\"), (\"variable\", \"scalar\")],\n update_code=\"\"\"\n // Get gradients\n const scalar gradient = m / updateTimesteps;\n // Calculate learning rate r\n upsilon = fmax(upsilon * expRMS, gradient * gradient);\n const scalar r = r0 / (sqrt(upsilon) + epsilon);\n // Update synaptic parameter\n variable += r * gradient;\n variable = fmin(wMax, fmax(wMin, variable));\n m = 0.0;\n \"\"\")\n\nsuperspike_model = create_weight_update_model(\n \"superspike\",\n params=[\"tauRise\", \"tauDecay\", \"beta\", \"Vthresh\"],\n vars=[(\"w\", \"scalar\"), (\"e\", \"scalar\"), \n (\"lambda\", \"scalar\"), (\"m\", \"scalar\")],\n pre_vars=[(\"z\", \"scalar\"), (\"zTilda\", \"scalar\")],\n post_vars=[(\"sigmaPrime\", \"scalar\")],\n post_neuron_var_refs=[(\"V\", \"scalar\"), (\"errTilda\", \"scalar\")],\n\n pre_spike_syn_code=\"\"\"\n addToPost(w);\n \"\"\",\n\n pre_spike_code=\"\"\"\n z += 1.0;\n \"\"\",\n pre_dynamics_code=\"\"\"\n // filtered presynaptic trace\n z += (-z / tauRise) * dt;\n zTilda += ((-zTilda + z) / tauDecay) * dt;\n \"\"\",\n\n post_dynamics_code=\"\"\"\n // filtered partial derivative\n if(V < -80.0) {\n sigmaPrime = 0.0;\n }\n else {\n const scalar onePlusHi = 1.0 + fabs(beta * 0.001 * (V - Vthresh));\n sigmaPrime = beta / (onePlusHi * onePlusHi);\n }\n \"\"\",\n\n synapse_dynamics_code=\"\"\"\n // Filtered eligibility trace\n e += (zTilda * sigmaPrime - e / tauRise) * dt;\n lambda += ((-lambda + e) / tauDecay) * dt;\n\n // Get error from neuron model and compute full\n // expression under integral and calculate m\n m += lambda * errTilda;\n \"\"\")\n\nfeedback_model = create_weight_update_model(\n \"feedback\",\n vars=[(\"w\", \"scalar\")],\n pre_neuron_var_refs=[(\"errTilda\", \"scalar\")],\n synapse_dynamics_code=\"\"\"\n addToPost(w * errTilda);\n \"\"\")\n\nhidden_neuron_model = create_neuron_model(\n \"hidden\",\n params=[\"C\", \"tauMem\", \"Vrest\", \"Vthresh\", \"tauRefrac\"],\n vars=[(\"V\", \"scalar\"), (\"refracTime\", \"scalar\"), (\"errTilda\", \"scalar\")],\n additional_input_vars=[(\"ISynFeedback\", \"scalar\", 0.0)],\n derived_params=[(\"ExpTC\", lambda pars, dt: np.exp(-dt / pars[\"tauMem\"])),\n (\"Rmembrane\", lambda pars, dt: pars[\"tauMem\"] / pars[\"C\"])],\n \n sim_code=\"\"\"\n // membrane potential dynamics\n if (refracTime == tauRefrac) {\n V = Vrest;\n }\n if (refracTime <= 0.0) {\n scalar alpha = (Isyn * Rmembrane) + Vrest;\n V = alpha - (ExpTC * (alpha - V));\n }\n else {\n refracTime -= dt;\n }\n // error\n errTilda = ISynFeedback;\n \"\"\",\n reset_code=\"\"\"\n refracTime = tauRefrac;\n \"\"\",\n threshold_condition_code=\"\"\"\n refracTime <= 0.0 && V >= Vthresh\n \"\"\")\n\noutput_neuron_model = create_neuron_model(\n \"output\",\n params=[\"C\", \"tauMem\", \"Vrest\", \"Vthresh\", \"tauRefrac\",\n \"tauRise\", \"tauDecay\", \"tauAvgErr\"],\n vars=[(\"V\", \"scalar\"), (\"refracTime\", \"scalar\"), (\"errRise\", \"scalar\"),\n (\"errTilda\", \"scalar\"), (\"avgSqrErr\", \"scalar\"), (\"errDecay\", \"scalar\"),\n (\"startSpike\", \"unsigned int\"), (\"endSpike\", \"unsigned int\")],\n extra_global_params=[(\"spikeTimes\", \"scalar*\")],\n derived_params=[(\"ExpTC\", lambda pars, dt: np.exp(-dt / pars[\"tauMem\"])),\n (\"Rmembrane\", lambda pars, dt: pars[\"tauMem\"] / pars[\"C\"]),\n (\"normFactor\", lambda pars, dt: 1.0 / (-np.exp(-calc_t_peak(pars[\"tauRise\"], pars[\"tauDecay\"]) / pars[\"tauRise\"]) + np.exp(-calc_t_peak(pars[\"tauRise\"], pars[\"tauDecay\"]) / pars[\"tauDecay\"]))),\n (\"tRiseMult\", lambda pars, dt: np.exp(-dt / pars[\"tauRise\"])),\n (\"tDecayMult\", lambda pars, dt: np.exp(-dt / pars[\"tauDecay\"])),\n (\"tPeak\", lambda pars, dt: calc_t_peak(pars[\"tauRise\"], pars[\"tauDecay\"])),\n (\"mulAvgErr\", lambda pars, dt: np.exp(-dt / pars[\"tauAvgErr\"]))],\n\n sim_code=\"\"\"\n // membrane potential dynamics\n if (refracTime == tauRefrac) {\n V = Vrest;\n }\n if (refracTime <= 0.0) {\n scalar alpha = (Isyn * Rmembrane) + Vrest;\n V = alpha - (ExpTC * (alpha - V));\n }\n else {\n refracTime -= dt;\n }\n // error\n scalar sPred = 0.0;\n if (startSpike != endSpike && t >= spikeTimes[startSpike]) {\n startSpike++;\n sPred = 1.0;\n }\n const scalar sReal = (refracTime <= 0.0 && V >= Vthresh) ? 1.0 : 0.0;\n const scalar mismatch = sPred - sReal;\n errRise = (errRise * tRiseMult) + mismatch;\n errDecay = (errDecay * tDecayMult) + mismatch;\n errTilda = (errDecay - errRise) * normFactor;\n // calculate average error trace\n const scalar temp = errTilda * errTilda * dt * 0.001;\n avgSqrErr *= mulAvgErr;\n avgSqrErr += temp;\n \"\"\",\n reset_code=\"\"\"\n refracTime = tauRefrac;\n \"\"\",\n threshold_condition_code=\"\"\"\n refracTime <= 0.0 && V >= Vthresh\n \"\"\")\n\n# ----------------------------------------------------------------------------\n# CLI\n# ----------------------------------------------------------------------------\ndef get_parser():\n parser = ArgumentParser()\n parser.add_argument(\"--record-trial\", type=int, nargs=\"*\", required=True, help=\"Index of trial(s) to record\")\n parser.add_argument(\"--target-file\", type=str, default=\"oxford-target.ras\", help=\"Filename of spike file to train model on\")\n parser.add_argument(\"--num-trials\", type=int, default=600, help=\"Number of trials to train for\")\n parser.add_argument(\"--kernel-profiling\", action=\"store_true\", help=\"Output kernel profiling data\")\n parser.add_argument(\"--save-data\", action=\"store_true\", help=\"Save spike data (rather than plotting it)\")\n return parser\n\n\n# ----------------------------------------------------------------------------\n# Entry point\n# ----------------------------------------------------------------------------\nif __name__ == \"__main__\":\n args = get_parser().parse_args()\n # Sort trial indices to record\n args.record_trial = sorted(args.record_trial)\n\n # ----------------------------------------------------------------------------\n # Load target data\n # ----------------------------------------------------------------------------\n # Load target data\n target_spikes = np.loadtxt(args.target_file,\n dtype={\"names\": (\"time\", \"neuron_id\"),\n \"formats\": (float, int)})\n\n # Make neuron IDs zero-based\n target_spikes[\"neuron_id\"] -= 1\n\n # Convert times to milliseconds\n target_spikes[\"time\"] *= 1000.0\n\n # Sort first by neuron id and then by time\n target_spikes = np.sort(target_spikes, order=[\"neuron_id\", \"time\"])\n\n # Count number of spikes\n target_neuron_end_times = np.cumsum(np.bincount(target_spikes[\"neuron_id\"], minlength=NUM_OUTPUT))\n target_neuron_start_times = np.concatenate(([0], target_neuron_end_times[:-1]))\n\n # ----------------------------------------------------------------------------\n # Generate frozen poisson input\n # ----------------------------------------------------------------------------\n input_isi_ms = 1000.0 / INPUT_FREQ_HZ\n\n # Generate time of first spike for each neuron\n input_spike_times = input_isi_ms * np.random.exponential(size=NUM_INPUT)\n input_spike_times = np.reshape(input_spike_times, (1, NUM_INPUT))\n\n while True:\n # Generate vector of spike times\n s = input_isi_ms * np.random.exponential(size=NUM_INPUT)\n\n # Add previous times\n s += input_spike_times[-1,:]\n\n # If all neurons have reached end of trial\n if np.all(s >= TRIAL_MS):\n break\n # Otherwise stack\n else:\n input_spike_times = np.vstack((input_spike_times, s))\n\n # Count spikes per input neuron\n input_spikes_per_neuron = np.sum(input_spike_times < TRIAL_MS, axis=0)\n\n # Concatenate spikes within trial together\n input_spikes = np.concatenate([input_spike_times[:input_spikes_per_neuron[i],i] \n for i in range(NUM_INPUT)])\n\n # Calculate indices\n input_neuron_end_times = np.cumsum(input_spikes_per_neuron)\n input_neuron_start_times = np.concatenate(([0], input_neuron_end_times[:-1]))\n\n # ----------------------------------------------------------------------------\n # Neuron initialisation\n # ----------------------------------------------------------------------------\n input_init_vars = {\"startSpike\": input_neuron_start_times, \"endSpike\": input_neuron_end_times}\n\n hidden_params = {\"C\" : 10.0, \"tauMem\": 10.0, \"Vrest\": -60.0, \n \"Vthresh\": -50.0 , \"tauRefrac\": 5.0}\n hidden_init_vars = {\"V\": -60.0, \"refracTime\": 0.0, \"errTilda\": 0.0}\n\n output_params = {\"C\": 10.0, \"tauMem\": 10.0, \"Vrest\": -60.0, \n \"Vthresh\": -50.0, \"tauRefrac\": 5.0, \"tauRise\": TAU_RISE_MS, \n \"tauDecay\": TAU_DECAY_MS, \"tauAvgErr\": TAU_AVG_ERR_MS}\n output_init_vars = {\"V\": -60.0, \"refracTime\": 0.0, \"errRise\": 0.0, \n \"errTilda\": 0.0, \"errDecay\": 0.0, \"avgSqrErr\": 0.0,\n \"startSpike\": target_neuron_start_times, \"endSpike\": target_neuron_end_times}\n\n # ----------------------------------------------------------------------------\n # Synapse initialisation\n # ----------------------------------------------------------------------------\n superspike_params = {\"tauRise\": TAU_RISE_MS, \"tauDecay\": TAU_DECAY_MS, \"beta\": 1000.0, \"Vthresh\": -50.0}\n superspike_pre_init_vars = {\"z\": 0.0, \"zTilda\": 0.0}\n superspike_post_init_vars = {\"sigmaPrime\": 0.0}\n\n input_hidden_weight_dist_params = {\"mean\": 0.0, \"sd\": W0 / np.sqrt(float(NUM_INPUT)),\n \"min\": W_MIN, \"max\": W_MAX}\n input_hidden_init_vars = {\"w\": init_var(\"NormalClipped\", input_hidden_weight_dist_params),\n \"e\": 0.0, \"lambda\": 0.0, \"m\": 0.0}\n\n hidden_output_weight_dist_params = {\"mean\": 0.0, \"sd\": W0 / np.sqrt(float(NUM_HIDDEN)),\n \"min\": W_MIN, \"max\": W_MAX}\n hidden_output_init_vars = {\"w\": init_var(\"NormalClipped\", hidden_output_weight_dist_params),\n \"e\": 0.0, \"lambda\": 0.0, \"m\": 0.0} \n \n # ----------------------------------------------------------------------------\n # Custom update initialisation\n # ----------------------------------------------------------------------------\n r_max_prop_params = {\"updateTime\": UPDATE_TIME_MS, \"tauRMS\": TAU_RMS_MS, \n \"epsilon\": EPSILON, \"wMin\": W_MIN, \"wMax\": W_MAX, \"r0\": R0}\n\n # ----------------------------------------------------------------------------\n # Model description\n # ----------------------------------------------------------------------------\n model = GeNNModel(\"float\", \"superspike_demo\", generateLineInfo=True)\n model.dt = TIMESTEP_MS\n model.timing_enabled = args.kernel_profiling\n\n # Add neuron populations\n input = model.add_neuron_population(\"Input\", NUM_INPUT, \"SpikeSourceArray\", \n {}, input_init_vars)\n hidden = model.add_neuron_population(\"Hidden\", NUM_HIDDEN, hidden_neuron_model, \n hidden_params, hidden_init_vars)\n output = model.add_neuron_population(\"Output\", NUM_OUTPUT, output_neuron_model, \n output_params, output_init_vars)\n\n input.extra_global_params[\"spikeTimes\"].set_init_values(input_spikes)\n output.extra_global_params[\"spikeTimes\"].set_init_values(target_spikes[\"time\"])\n\n # Turn on recording\n any_recording = (len(args.record_trial) > 0)\n input.spike_recording_enabled = any_recording\n hidden.spike_recording_enabled = any_recording\n output.spike_recording_enabled = any_recording\n\n # Add synapse populations\n input_hidden = model.add_synapse_population(\n \"InputHidden\", \"DENSE\",\n input, hidden,\n init_weight_update(superspike_model, superspike_params, input_hidden_init_vars, superspike_pre_init_vars, superspike_post_init_vars,\n post_var_refs={\"V\": create_var_ref(hidden, \"V\"), \"errTilda\": create_var_ref(hidden, \"errTilda\")}),\n init_postsynaptic(\"ExpCurr\", {\"tau\": 5.0}))\n\n hidden_output = model.add_synapse_population(\n \"HiddenOutput\", \"DENSE\",\n hidden, output,\n init_weight_update(superspike_model, superspike_params, hidden_output_init_vars, superspike_pre_init_vars, superspike_post_init_vars,\n post_var_refs={\"V\": create_var_ref(output, \"V\"), \"errTilda\": create_var_ref(output, \"errTilda\")}),\n init_postsynaptic(\"ExpCurr\", {\"tau\": 5.0}))\n\n output_hidden = model.add_synapse_population(\n \"OutputHidden\", \"DENSE\",\n output, hidden,\n init_weight_update(feedback_model, {}, {\"w\": 0.0}, pre_var_refs={\"errTilda\": create_var_ref(output, \"errTilda\")}),\n init_postsynaptic(\"DeltaCurr\"))\n output_hidden.post_target_var = \"ISynFeedback\"\n\n # Add custom update for calculating initial tranpose weights\n model.add_custom_update(\"input_hidden_transpose\", \"CalculateTranspose\", \"Transpose\",\n {}, {}, {\"variable\": create_wu_var_ref(hidden_output, \"w\", output_hidden, \"w\")})\n\n # Add custom updates for gradient update\n input_hidden_optimiser_var_refs = {\"m\": create_wu_var_ref(input_hidden, \"m\"), \n \"variable\": create_wu_var_ref(input_hidden, \"w\")}\n input_hidden_optimiser = model.add_custom_update(\"input_hidden_optimiser\", \"GradientLearn\", r_max_prop_model,\n r_max_prop_params, {\"upsilon\": 0.0}, input_hidden_optimiser_var_refs)\n input_hidden_optimiser.set_param_dynamic(\"r0\")\n\n hidden_output_optimiser_var_refs = {\"m\": create_wu_var_ref(hidden_output, \"m\"), \n \"variable\": create_wu_var_ref(hidden_output, \"w\", output_hidden, \"w\")}\n hidden_output_optimiser = model.add_custom_update(\"hidden_output_optimiser\", \"GradientLearn\", r_max_prop_model,\n r_max_prop_params, {\"upsilon\": 0.0}, hidden_output_optimiser_var_refs)\n hidden_output_optimiser.set_param_dynamic(\"r0\")\n\n # Build and load model\n model.build()\n model.load(num_recording_timesteps=TRIAL_TIMESTEPS)\n\n # Calculate initial transpose feedback weights\n model.custom_update(\"CalculateTranspose\")\n\n # Loop through trials\n output_avg_sqr_err_var = output.vars[\"avgSqrErr\"]\n current_r0 = R0\n timestep = 0\n input_spikes = []\n hidden_spikes = []\n output_spikes = []\n for trial in range(args.num_trials):\n # Reduce learning rate every 400 trials\n if trial != 0 and (trial % 400) == 0:\n current_r0 *= 0.1\n\n input_hidden_optimiser.set_dynamic_param_value(\"r0\", current_r0)\n hidden_output_optimiser.set_dynamic_param_value(\"r0\", current_r0)\n\n # Display trial number peridically\n if trial != 0 and (trial % 10) == 0:\n # Get average square error\n output_avg_sqr_err_var.pull_from_device()\n\n # Calculate mean error\n time_s = timestep * TIMESTEP_MS / 1000.0;\n mean_error = np.sum(output_avg_sqr_err_var.view) / float(NUM_OUTPUT);\n mean_error *= SCALE_TR_ERR_FLT / (1.0 - np.exp(-time_s / TAU_AVG_ERR_S) + 1.0E-9);\n\n print(\"Trial %u (r0 = %f, error = %f)\" % (trial, current_r0, mean_error))\n\n # Reset model timestep\n model.timestep = 0\n\n # Loop through timesteps within trial\n for i in range(TRIAL_TIMESTEPS):\n model.step_time()\n\n # If it's time to update weights\n if timestep != 0 and (timestep % UPDATE_TIMESTEPS) == 0:\n model.custom_update(\"GradientLearn\");\n timestep+=1;\n\n\n # Reset spike sources by re-uploading starting spike indices\n # **TODO** build repeating spike source array\n input.vars[\"startSpike\"].push_to_device()\n output.vars[\"startSpike\"].push_to_device()\n\n if trial in args.record_trial:\n model.pull_recording_buffers_from_device();\n \n if args.save_data:\n write_spike_file(\"input_spikes_%u.csv\" % trial, input.spike_recording_data)\n write_spike_file(\"hidden_spikes_%u.csv\" % trial, hidden.spike_recording_data)\n write_spike_file(\"output_spikes_%u.csv\" % trial, output.spike_recording_data)\n else:\n input_spikes.append(input.spike_recording_data[0])\n hidden_spikes.append(hidden.spike_recording_data[0])\n output_spikes.append(output.spike_recording_data[0])\n\n if args.kernel_profiling:\n print(\"Init: %f\" % model.init_time)\n print(\"Init sparse: %f\" % model.init_sparse_time)\n print(\"Neuron update: %f\" % model.neuron_update_time)\n print(\"Presynaptic update: %f\" % model.presynaptic_update_time)\n print(\"Synapse dynamics: %f\" % model.synapse_dynamics_time)\n print(\"Gradient learning custom update: %f\" % model.get_custom_update_time(\"GradientLearn\"))\n print(\"Gradient learning custom update transpose: %f\" % model.get_custom_update_transpose_time(\"GradientLearn\"))\n\n if not args.save_data:\n import matplotlib.pyplot as plt\n \n # Create plot\n fig, axes = plt.subplots(3, len(input_spikes), sharex=\"col\", sharey=\"row\", squeeze=False)\n\n for i, spikes in enumerate(zip(input_spikes, hidden_spikes, output_spikes)):\n # Plot spikes\n start_time_s = float(args.record_trial[i]) * 1.890\n axes[0, i].scatter(start_time_s + (spikes[0][0] / 1000.0), spikes[0][1], s=2, edgecolors=\"none\")\n axes[1, i].scatter(start_time_s + (spikes[1][0] / 1000.0), spikes[1][1], s=2, edgecolors=\"none\")\n axes[2, i].scatter(start_time_s + (spikes[2][0] / 1000.0), spikes[2][1], s=2, edgecolors=\"none\")\n\n axes[2, i].set_xlabel(\"Time [s]\")\n\n axes[0, 0].set_ylabel(\"Neuron number\")\n axes[1, 0].set_ylabel(\"Neuron number\")\n axes[2, 0].set_ylabel(\"Neuron number\")\n\n # Show plot\n plt.show()" ] } ], "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.9.5" } }, "nbformat": 4, "nbformat_minor": 0 }