{ "cells": [ { "cell_type": "markdown", "id": "a2175596", "metadata": {}, "source": [ "# Scenarios\n", "\n", "One way of modeling uncertainty in decision making is using _scenarios_. A scenario in optimization context is typically a set of parameters or constraints that describes what our optimization problem might look like in a plausible future.\n", "\n", "In DESDEO scenarios are described by a [ScenarioModel](../../api/desdeo_problem/#desdeo.problem.ScenarioModel). It contains base_problem that is a [Problem](../../api/desdeo_problem/#desdeo.problem.schema) object. The base_problem is then modified by the different [Scenario](../../api/desdeo_problem/#desdeo.problem.Scenario)s which are stored in a dict called scenarios. How different scenarios flow into one another is described by scenario_tree dict. The scenarios can also be assigned probabilities, which are stored in the scenario_probabilities dict.\n", "\n", "## Building a ScenarioModel\n", "\n", "We will take a look at how a [ScenarioModel](../../api/desdeo_problem/#desdeo.problem.ScenarioModel) is constructed with the help of an example. The first thing our ScenarioModel needs is a base_problem that will be modified by the different scenario. \n", "\n", "We will use the [summer_cabin_electricity](../../api/desdeo_problem/#desdeo.problem.testproblems.summer_cabin_battery_problem) Problem as our base_problem. It is a MILP-problem with a few thousand variables. We will use the split version of the problem, where the decision variables relating to electricity usage have been split into three time periods. The reason for this will become apparent later." ] }, { "cell_type": "code", "execution_count": null, "id": "17d05a77", "metadata": {}, "outputs": [], "source": [ "# ruff: noqa: T203\n", "from pprint import pprint\n", "\n", "from desdeo.problem.testproblems import summer_cabin_battery_problem_split\n", "\n", "base = summer_cabin_battery_problem_split(initial_soc=0, n_panels_max=50)\n", "pprint({i: base.objectives[i].name for i in range(len(base.objectives))})" ] }, { "cell_type": "markdown", "id": "8da0933e", "metadata": {}, "source": [ "The problem is about choosing what kind of investments should be done in to the electricity supply of the summer cabin. The options are installing a battery with a capacity of 14-42 kWh or installing a number of solar panels that produce up to 160W of power each depending on the weather and the time of day.\n", "\n", "### Defining scenarios\n", "\n", "The type of uncertainty we want to introduce is the possibility of losing the connection to the power grid because of a storm. We have two storms that each may or may not cause an electricity outage for 4 hours.\n", "\n", "To describe the scenarios arising from these possible events, we write a scenario_tree and put the scenario_probabilities in a dict." ] }, { "cell_type": "code", "execution_count": null, "id": "8a095865", "metadata": {}, "outputs": [], "source": [ "scenario_tree = {\n", " \"ROOT\": [\"S1\", \"S2\"],\n", " \"S1\": [\"S1a\", \"S1b\"],\n", " \"S2\": [\"S2a\", \"S2b\"],\n", " \"S1a\": [],\n", " \"S1b\": [],\n", " \"S2a\": [],\n", " \"S2b\": [],\n", "}\n", "scenario_probabilities = {\n", " \"S1\": 0.9,\n", " \"S2\": 0.1,\n", " \"S1a\": 0.81,\n", " \"S1b\": 0.09,\n", " \"S2a\": 0.09,\n", " \"S2b\": 0.01,\n", "}\n", "\n", "# Including the leaf nodes in the scenario tree is optional,\n", "# so we can also define the scenario tree as follows:\n", "scenario_tree = {\n", " \"ROOT\": [\"S1\", \"S2\"],\n", " \"S1\": [\"S1a\", \"S1b\"],\n", " \"S2\": [\"S2a\", \"S2b\"],\n", "}" ] }, { "cell_type": "markdown", "id": "ec0ad6c3", "metadata": {}, "source": [ "The `scenario_tree` is a dict, where the keys represent scenarios, and the values are lists that show which scenarios follow them. Leaf scenarios have an empty list, because there is no scenario following them. You can leave the leaf scenarios out of your scenario tree keys if you want.\n", "\n", "The tree above shows that first either scenario `S1` or `S2` happens. They can then be followed by second stage scenarios `S1a` or `S1b` and `S2a` or `S2b` respectively.\n", "\n", "If all of your scenarios would happen at the same time (from the perspective of the mathematical model), you would only have the `ROOT` scenario followed by a list of your scenarios. In that case, you could just provide the list of scenario names to the ScenarioModel instead a dict containing the tree.\n", "\n", "The `scenario_probabilities` dict assigns a probability to every scenario in the `scenario_tree`. The ScenarioModel validates that the probabilities of child scenarios sum up to the probability of their parent.\n", "\n", "### Describing the effects of the scenarios\n", "\n", "We also need to describe how the scenarios change our `base_problem`. [ScenarioModel](../../api/desdeo_problem/#desdeo.problem.ScenarioModel) handles this by having lists of constants, variables, objectives, constraints, extra functions, and scalarization functions, which are then assigned to scenarios as need be. It works like this, because it is often desirable to use, for example, same constraints in multiple different scenarios, and having them all be drawn from a single lists saves space is computer memory and database.\n", "\n", "We wanted to describe an electricity outage, so let's define constraints that say we cannot buy or sell electricity at specified times. We put one of these outages at the start of slices 2 and 3 of our timeseries variables." ] }, { "cell_type": "code", "execution_count": null, "id": "73e88b63", "metadata": {}, "outputs": [], "source": [ "from desdeo.problem import Constraint, ConstraintTypeEnum\n", "\n", "con_pool: list[Constraint] = []\n", "con_idx: dict[str, int] = {}\n", "\n", "H = 4 # hours of grid outage\n", "for k in (2, 3): # start of timeseries slices 2 and 3 are the outage periods\n", " c = Constraint(\n", " name=f\"Outage no-trade s{k} t=1..{H}\",\n", " symbol=f\"outage_trade_s{k}\",\n", " func=[\"Add\", [\"Extract\", f\"buy_s{k}\", [\"Tuple\", 1, H]], [\"Extract\", f\"sell_s{k}\", [\"Tuple\", 1, H]]],\n", " cons_type=ConstraintTypeEnum.EQ,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", " con_idx[c.symbol] = len(con_pool)\n", " con_pool.append(c)" ] }, { "cell_type": "markdown", "id": "c3b8700d", "metadata": {}, "source": [ "The summer cabin is not primarily a business, so the real impact of the electricity outages is not really measured in how they affect the costs associated. What is more important, is having access to electricity at the cabin.\n", "\n", "### Adding objectives\n", "\n", "To see, how for how many hours the cabin does not have enough electricity to meet the normal consumption, we are going to introduce a new objective function, that counts the hours when electricity demand is not met by either solar panels or battery storage." ] }, { "cell_type": "code", "execution_count": null, "id": "6940752e", "metadata": {}, "outputs": [], "source": [ "from desdeo.problem import Objective\n", "\n", "\n", "def _f3(segments: tuple[int, ...]) -> Objective:\n", " z_terms = [[\"Sum\", f\"z_s{k}\"] for k in segments]\n", " return Objective(\n", " name=\"Hours with unserved electricity demand\",\n", " symbol=\"f_3\",\n", " func=z_terms[0] if len(z_terms) == 1 else [\"Add\", *z_terms],\n", " unit=\"h\",\n", " maximize=False,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", "\n", "\n", "obj_pool: list[Objective] = []\n", "obj_idx: dict[str, int] = {} # scenario name → pool index\n", "for scenario_name, segs in [(\"S2a\", (2,)), (\"S1b\", (3,)), (\"S2b\", (2, 3))]:\n", " obj_idx[scenario_name] = len(obj_pool)\n", " obj_pool.append(_f3(segs))\n", "obj_idx[\"S1a\"] = len(obj_pool)\n", "obj_pool.append(\n", " Objective(\n", " name=\"Hours with unserved electricity demand\",\n", " symbol=\"f_3\",\n", " func=[\"Multiply\", 0, \"y\"], # always 0 — no outage possible in this scenario\n", " unit=\"h\",\n", " maximize=False,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", ")" ] }, { "cell_type": "markdown", "id": "514855d2", "metadata": {}, "source": [ "The objective function has different values for all four scenarios. Of course, it would be possible in this problem to use the same objective function formulation for all scenarios, but that would require us to define the `z_sk` variables for all scenarios, and we do not really want to do that to keep the problem size smaller.\n", "\n", "### Adding variables\n", "\n", "Of course, we also need to add the variables `z_sk` that appear in the objective functions." ] }, { "cell_type": "code", "execution_count": null, "id": "f65640a5", "metadata": {}, "outputs": [], "source": [ "from desdeo.problem import TensorVariable, Variable, VariableTypeEnum\n", "\n", "var_pool: list[Variable] = []\n", "var_idx: dict[str, int] = {}\n", "\n", "for k in (2, 3):\n", " v = TensorVariable(\n", " name=f\"Demand unserved indicator s{k}\",\n", " symbol=f\"z_s{k}\",\n", " shape=[H],\n", " variable_type=VariableTypeEnum.binary,\n", " lowerbounds=0,\n", " upperbounds=1,\n", " initial_values=0,\n", " )\n", " var_idx[v.symbol] = len(var_pool)\n", " var_pool.append(v)" ] }, { "cell_type": "markdown", "id": "ae202515", "metadata": {}, "source": [ "To have have variables `z_sk` to actually indicate whether there is enough electricity or not, we are also going to need additional variables and constraints that implement that logic." ] }, { "cell_type": "code", "execution_count": null, "id": "b6a51fa9", "metadata": {}, "outputs": [], "source": [ "_M_UNMET = 15.0 # kWh upper bound for big-M constraints (exceeds any single-hour load)\n", "\n", "for k in (2, 3):\n", " v = TensorVariable(\n", " name=f\"Unmet demand s{k} (kWh)\",\n", " symbol=f\"unmet_s{k}\",\n", " shape=[H],\n", " variable_type=VariableTypeEnum.real,\n", " lowerbounds=0.0,\n", " upperbounds=None,\n", " initial_values=0.0,\n", " )\n", " var_idx[v.symbol] = len(var_pool)\n", " var_pool.append(v)\n", "\n", "for k in (2, 3):\n", " c = Constraint(\n", " name=f\"Energy balance s{k} t=1..{H} (with unmet slack)\",\n", " symbol=f\"energy_bal_out_s{k}\",\n", " func=[\n", " \"Add\",\n", " [\"Extract\", f\"d_s{k}\", [\"Tuple\", 1, H]], # discharging from battery\n", " [\"Negate\", [\"Extract\", f\"c_s{k}\", [\"Tuple\", 1, H]]], # charging battery\n", " [\"Multiply\", \"n\", [\"Extract\", f\"sol_s{k}\", [\"Tuple\", 1, H]]], # solar generation\n", " f\"unmet_s{k}\", # unmet demand\n", " [\"Negate\", [\"Extract\", f\"l_s{k}\", [\"Tuple\", 1, H]]], # load\n", " ],\n", " cons_type=ConstraintTypeEnum.EQ,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", " con_idx[c.symbol] = len(con_pool)\n", " con_pool.append(c)\n", "\n", "for k in (2, 3):\n", " c = Constraint(\n", " name=f\"Big-M unmet indicator s{k} t=1..{H}\",\n", " symbol=f\"bigm_s{k}\",\n", " func=[\"Subtract\", f\"unmet_s{k}\", [\"Multiply\", _M_UNMET, f\"z_s{k}\"]],\n", " cons_type=ConstraintTypeEnum.LTE,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", " con_idx[c.symbol] = len(con_pool)\n", " con_pool.append(c)" ] }, { "cell_type": "markdown", "id": "c68db919", "metadata": {}, "source": [ "### Changing constraints\n", "\n", "We also need to update the energy balance constraint for the scenarios where we have an outage. It does not include the unmet demand component and would lead to model infeasibility when electricity cannot be bought or sold and the battery and solar are not enough.\n", "\n", "By using a same symbol that already exist in the base_problem for the Constraint (or any other Problem component), we are overwriting that constraint in the base problem. This only affects the scenarios that we include the constraint in." ] }, { "cell_type": "code", "execution_count": null, "id": "a2b90de9", "metadata": {}, "outputs": [], "source": [ "for k in (2, 3):\n", " c = Constraint(\n", " name=f\"Energy balance s{k} t={H}+1.. (no unmet slack)\",\n", " symbol=f\"energy_bal_s{k}\",\n", " func=[\n", " \"Add\",\n", " [\"Exclude\", f\"buy_s{k}\", [\"Tuple\", 1, H]],\n", " [\"Negate\", [\"Exclude\", f\"sell_s{k}\", [\"Tuple\", 1, H]]],\n", " [\"Exclude\", f\"d_s{k}\", [\"Tuple\", 1, H]],\n", " [\"Negate\", [\"Exclude\", f\"c_s{k}\", [\"Tuple\", 1, H]]],\n", " [\"Multiply\", \"n\", [\"Exclude\", f\"sol_s{k}\", [\"Tuple\", 1, H]]],\n", " [\"Negate\", [\"Exclude\", f\"l_s{k}\", [\"Tuple\", 1, H]]],\n", " ],\n", " cons_type=ConstraintTypeEnum.EQ,\n", " is_linear=True,\n", " is_convex=True,\n", " is_twice_differentiable=True,\n", " )\n", " con_idx[c.symbol] = len(con_pool)\n", " con_pool.append(c)" ] }, { "cell_type": "markdown", "id": "0e1c95da", "metadata": {}, "source": [ "### Constructing the scenarios\n", "\n", "Now we are ready to construct the [Scenario](../../api/desdeo_problem/#desdeo.problem.ScenarioModel) objects that describe which Variables, Constraints, and Objectives should be used in which scenario." ] }, { "cell_type": "code", "execution_count": null, "id": "20995500", "metadata": {}, "outputs": [], "source": [ "from desdeo.problem import Scenario\n", "\n", "# Dict mapping scenario names to the segments that are outaged in that scenario\n", "_outage_segs: dict[str, tuple[int, ...]] = {\"S1a\": (), \"S2a\": (2,), \"S1b\": (3,), \"S2b\": (2, 3)}\n", "\n", "scenarios: dict[str, Scenario] = {}\n", "for name, segs in _outage_segs.items():\n", " variables: dict[str, int] = {}\n", " constraints: dict[str, int] = {}\n", " for k in segs:\n", " variables[f\"unmet_s{k}\"] = var_idx[f\"unmet_s{k}\"]\n", " variables[f\"z_s{k}\"] = var_idx[f\"z_s{k}\"]\n", " constraints[f\"energy_bal_s{k}\"] = con_idx[f\"energy_bal_s{k}\"]\n", " constraints[f\"energy_bal_out_s{k}\"] = con_idx[f\"energy_bal_out_s{k}\"]\n", " constraints[f\"bigm_s{k}\"] = con_idx[f\"bigm_s{k}\"]\n", " constraints[f\"outage_trade_s{k}\"] = con_idx[f\"outage_trade_s{k}\"]\n", " scenarios[name] = Scenario(\n", " variables=variables,\n", " objectives={\"f_3\": obj_idx[name]},\n", " constraints=constraints,\n", " )" ] }, { "cell_type": "markdown", "id": "7770098b", "metadata": {}, "source": [ "### Defining anticipation stop\n", "\n", "The last thing we need to define in our [ScenarioModel](../../api/desdeo_problem/#desdeo.problem.ScenarioModel) is anticipation stop. It describes which scenarios are allowed to affect values of the listed Variables. The scenario under which the Variable is listed is the last scenario that can affect the value of the Variable. Thus, Variables listed under `\"ROOT\"` must have the same values in all scenarios. Variables listed under `\"S2\"` must have the same value in all scenarios that follow `\"S2\"`, and so on.\n", "\n", "In this summer cabin electricity problem, the decisions associated with time before the first possible electricity outage must all have the same values in all scenarios. The decision variables that follow the first potential outage can have their values depend on whether that outage happened, but they cannot depend on the second outage happening." ] }, { "cell_type": "code", "execution_count": null, "id": "aec3d5dd", "metadata": {}, "outputs": [], "source": [ "investments = [\"y\", \"E\", \"n\"]\n", "s1_sched = [\"c_s1\", \"d_s1\", \"soc_s1\", \"buy_s1\", \"sell_s1\"]\n", "s2_sched = [\"c_s2\", \"d_s2\", \"soc_s2\", \"buy_s2\", \"sell_s2\"]\n", "s2_unmet = [\"unmet_s2\", \"z_s2\"]\n", "\n", "anticipation_stop = {\n", " \"ROOT\": [*investments, *s1_sched],\n", " \"S1\": s2_sched, # no unmet demand variables since no outage in S1\n", " \"S2\": [*s2_sched, *s2_unmet],\n", "}" ] }, { "cell_type": "markdown", "id": "9bb4b9ce", "metadata": {}, "source": [ "### Ready scenario model\n", "\n", "Now we just put the scenario model together." ] }, { "cell_type": "code", "execution_count": null, "id": "c816b183", "metadata": {}, "outputs": [], "source": [ "from desdeo.problem.scenario import ScenarioModel\n", "\n", "summer_cabin_scenarios = ScenarioModel(\n", " scenario_tree=scenario_tree,\n", " scenario_probabilities=scenario_probabilities,\n", " base_problem=base,\n", " variables=tuple(var_pool),\n", " objectives=tuple(obj_pool),\n", " constraints=tuple(con_pool),\n", " scenarios=scenarios,\n", " anticipation_stop=anticipation_stop,\n", ")" ] }, { "cell_type": "markdown", "id": "60bca34a", "metadata": {}, "source": [ "# Using scenario models\n", "\n", "Currently, there are two ways of using scenario models withing DESDEO: constructing Problems corresponding to individual scenarios and building aggregate problems consisting of multiple scenarios.\n", "\n", "You can construct individual scenario problems by calling the `get_scenario_problem` function. This is unlikely to be that useful on its own, but can be useful for sanity checks or for constructing more complex methods." ] }, { "cell_type": "code", "execution_count": null, "id": "9e6f2a7f", "metadata": {}, "outputs": [], "source": [ "from desdeo.tools import guess_best_solver\n", "\n", "scenario_2b = summer_cabin_scenarios.get_scenario_problem(\"S2b\")\n", "results = guess_best_solver(scenario_2b)(scenario_2b).solve(\"f_1\")\n", "pprint(results.optimal_objectives)" ] }, { "cell_type": "markdown", "id": "a917bf8d", "metadata": {}, "source": [ "Likely a more useful way to utilize scenario models is by using one of the implemented aggregation methods that combine multiple scenarios into a single multiobjective optimization problem." ] }, { "cell_type": "code", "execution_count": null, "id": "130e1b96", "metadata": {}, "outputs": [], "source": [ "import inspect\n", "\n", "import desdeo.tools.stochastic as mod\n", "\n", "for name, obj in inspect.getmembers(mod, inspect.isfunction):\n", " if obj.__module__ == mod.__name__:\n", " pprint(f\"{name}\")\n", "\n", "import desdeo.tools.robust as mod # noqa: E402\n", "\n", "for name, obj in inspect.getmembers(mod, inspect.isfunction):\n", " if obj.__module__ == mod.__name__:\n", " pprint(f\"{name}\")\n", "\n", "del mod" ] }, { "cell_type": "markdown", "id": "3ccd9633", "metadata": {}, "source": [ "The way these aggregation functions work is they construct one big scenario model using [build_combined_scenario_problem](../../api/desdeo_tools/#desdeo.tools.scenarios.build_combined_scenario_problem) and then add aggregation functions that combine the values from multiple scenarios into a single function expression.\n", "\n", "Let us look at the [add_expected_value](../../api/desdeo_tools/#desdeo.tools.stochastic.add_expected_value) as an example." ] }, { "cell_type": "code", "execution_count": null, "id": "92eaad16", "metadata": {}, "outputs": [], "source": [ "from desdeo.tools import add_expected_value\n", "\n", "# We want to compute the expected value of all three objectives across the scenarios\n", "combined_ev_problem, new_symbols = add_expected_value(\n", " scenario_model=summer_cabin_scenarios, symbols=[\"f_1\", \"f_2\", \"f_3\"]\n", ")\n", "\n", "pprint([o.name for o in combined_ev_problem.objectives])" ] }, { "cell_type": "markdown", "id": "6ea44817", "metadata": {}, "source": [ "As we can see, our `combined_ev_problem` has a total of 12 objectives including the expected values of the three original objective functions. It is unlikely anyone is going to care about all these 12 objectives that much, especially considering how similar they are. Thus, to find a solution that a decision maker might be interested in, we are using partial scalarization, that only uses a subset of the objectives." ] }, { "cell_type": "code", "execution_count": null, "id": "8746d4e7", "metadata": {}, "outputs": [], "source": [ "from desdeo.tools import CVXPYSolver, add_asf_partial_diff, payoff_table_method\n", "\n", "# Calculate ideal and nadir values, so that the ASF can be properly scaled\n", "ideal, nadir = payoff_table_method(combined_ev_problem, CVXPYSolver)\n", "combined_ev_problem = combined_ev_problem.update_ideal_and_nadir(ideal, nadir)\n", "pprint(ideal)\n", "pprint(nadir)" ] }, { "cell_type": "code", "execution_count": null, "id": "a56d9a60", "metadata": {}, "outputs": [], "source": [ "reference_point = {\"E_f_1\": 0, \"E_f_2\": 5000.0, \"E_f_3\": 0}\n", "asf_problem, symbol = add_asf_partial_diff(combined_ev_problem, symbol=\"ASF\", reference_point=reference_point)\n", "\n", "\n", "solver = CVXPYSolver(asf_problem)\n", "results = solver.solve(symbol)\n", "\n", "pprint(results.optimal_objectives)" ] }, { "cell_type": "markdown", "id": "795c3605", "metadata": {}, "source": [ "### Adding multiple aggregation functions\n", "\n", "It is also possible add multiple scenario aggregation functions into the same problem. If you want to do that, you need to handle the combined scenario manually, because otherwise the aggregation functions have no way to figure out what is part of the original base problem and what was added later. In the example below we add worst case robust aggregation and expected value aggregation into the summer cabin problem." ] }, { "cell_type": "code", "execution_count": null, "id": "3ed66edc", "metadata": {}, "outputs": [], "source": [ "from desdeo.tools import add_worst_case_robust, build_combined_scenario_problem\n", "\n", "combined_problem, symbol_maps = build_combined_scenario_problem(summer_cabin_scenarios)\n", "combined_problem, added = add_worst_case_robust(\n", " scenario_model=summer_cabin_scenarios,\n", " symbols=[\"f_1\", \"f_2\", \"f_3\"],\n", " combined=combined_problem,\n", " symbol_maps=symbol_maps,\n", ")\n", "combined_problem, added2 = add_expected_value(\n", " scenario_model=summer_cabin_scenarios,\n", " symbols=[\"f_1\", \"f_2\", \"f_3\"],\n", " combined=combined_problem,\n", " symbol_maps=symbol_maps,\n", ")\n", "\n", "# Calculate ideal and nadir values, so that the ASF can be properly scaled\n", "ideal, nadir = payoff_table_method(combined_problem, CVXPYSolver)\n", "combined_problem = combined_problem.update_ideal_and_nadir(ideal, nadir)\n", "pprint(ideal)\n", "pprint(nadir)" ] }, { "cell_type": "markdown", "id": "a7f082a6", "metadata": {}, "source": [ "As seen above, the number of objective functions is quite large now. We can once again solve the problem using partial scalarization. Let's say we care about the expected value for the electricity costs, the cost of investments, and the maximum value of hours without electricity." ] }, { "cell_type": "code", "execution_count": null, "id": "34622c94", "metadata": {}, "outputs": [], "source": [ "reference_point = {\"E_f_1\": 0, \"f_2\": 5000.0, \"robust_f_3\": 0}\n", "asf_problem, symbol = add_asf_partial_diff(combined_problem, symbol=\"ASF\", reference_point=reference_point)\n", "\n", "solver = CVXPYSolver(asf_problem)\n", "results = solver.solve(symbol)\n", "\n", "pprint(results.optimal_objectives)" ] } ], "metadata": { "kernelspec": { "display_name": "ismoo (3.12.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.12.3" } }, "nbformat": 4, "nbformat_minor": 5 }