NITISHRG15102007 commited on
Commit
a484e09
·
verified ·
1 Parent(s): aacd61e

sync: push from tools/sync_space_to_hub.py (no artifacts/)

Browse files
Files changed (3) hide show
  1. README.md +12 -0
  2. tools/road_reward_smoke.py +48 -0
  3. training/train_grpo.ipynb +95 -104
README.md CHANGED
@@ -112,6 +112,18 @@ REASON: max 20 words
112
  CONFIDENCE: 0.0-1.0
113
  ```
114
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  ### Reward (verifiable + anti‑hack)
116
 
117
  Total reward is the sum of components (each logged) in `ev_grid_oracle/reward.py`:
 
112
  CONFIDENCE: 0.0-1.0
113
  ```
114
 
115
+ ### Road-graph RL (connected-edge actions)
116
+
117
+ This repo also includes a road-graph RL environment mounted under `POST /road/reset` and `POST /road/step`.
118
+ Its action schema is:
119
+
120
+ ```text
121
+ CURRENT_NODE: <int>
122
+ NEXT_NODE: <int>
123
+ REASON: max 20 words
124
+ CONFIDENCE: 0.0-1.0
125
+ ```
126
+
127
  ### Reward (verifiable + anti‑hack)
128
 
129
  Total reward is the sum of components (each logged) in `ev_grid_oracle/reward.py`:
tools/road_reward_smoke.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from ev_grid_oracle.road_env import RoadCore
6
+ from ev_grid_oracle.road_models import RoadAction, RoadState
7
+
8
+
9
+ def main() -> int:
10
+ core = RoadCore(g=None, nodes=[]) # type: ignore[arg-type]
11
+ obs = core.reset(seed=0)
12
+ st = obs.state
13
+ nb = list(core.g.neighbors(st.node))[0]
14
+
15
+ ok = f"CURRENT_NODE: {st.node}\nNEXT_NODE: {int(nb)}\nREASON: go\nCONFIDENCE: 0.7\n"
16
+ bad = f"CURRENT_NODE: {st.node + 999}\nNEXT_NODE: {int(nb)}\nREASON: hack\nCONFIDENCE: 0.7\n"
17
+
18
+ r = re.compile(r"CURRENT_NODE:\s*(\d+)\s*\nNEXT_NODE:\s*(\d+)\s*\n", re.I)
19
+
20
+ def parse(t: str) -> RoadAction | None:
21
+ m = r.search(t.strip())
22
+ if not m:
23
+ return None
24
+ return RoadAction(current_node=int(m.group(1)), next_node=int(m.group(2)))
25
+
26
+ def reward(comp: str) -> float:
27
+ st2 = RoadState.model_validate(st.model_dump(mode="json"))
28
+ a = parse(comp)
29
+ if a is None or int(a.current_node) != int(st2.node):
30
+ return -1.0
31
+ local = RoadCore(g=core.g, nodes=core.nodes)
32
+ local.node = int(st2.node)
33
+ local.battery_pct = float(st2.battery_pct_0_100)
34
+ local.target_station_id = str(st2.target_station_id)
35
+ local.steps_remaining = int(st2.steps_remaining)
36
+ ob = local.step(a)
37
+ base = float(ob.reward_breakdown.get("total", 0.0))
38
+ cheat = -1.0 if ob.anti_cheat_flags else 0.0
39
+ return base + cheat
40
+
41
+ print("ok", reward(ok))
42
+ print("bad", reward(bad))
43
+ return 0
44
+
45
+
46
+ if __name__ == "__main__":
47
+ raise SystemExit(main())
48
+
training/train_grpo.ipynb CHANGED
@@ -6,24 +6,25 @@
6
  "source": [
7
  "# EV Grid Oracle — GRPO Training (Colab T4)\n",
8
  "\n",
9
- "This notebook trains a small LLM (Qwen 2.5 3B Instruct) with **verifier-based GRPO** to route EVs in the `EVGridCore` simulation.\n",
 
 
 
10
  "\n",
11
  "## Run order (Colab — once per runtime)\n",
12
  "\n",
13
  "1. **Runtime → Change runtime type → GPU** (T4 matches the defaults below).\n",
14
  "2. Run the **next code cell** first. It clones this repo, moves into it, and runs `pip install -e .` so `import ev_grid_oracle` works.\n",
15
- "3. Run cells **top to bottom**. After changing only hyperparameters, you can restart from the dataset cell once setup has run.\n",
16
  "4. After training, use the save cell and upload `ev_oracle_lora/` to the Hub (or copy to Drive).\n",
17
  "\n",
18
- "**Links:** [Open in Colab](https://colab.research.google.com/github/NITISH-R-G/ev-grid-oracle/blob/main/training/train_grpo.ipynb) · [Notebook on GitHub](https://github.com/NITISH-R-G/ev-grid-oracle/blob/main/training/train_grpo.ipynb) · [HF mini-blog (markdown in repo)](https://github.com/NITISH-R-G/ev-grid-oracle/blob/main/docs/hf-mini-blog-ev-grid-oracle.md)\n",
19
  "\n",
20
  "**Action schema (strict):**\n",
21
  "\n",
22
- "```\n",
23
- "ACTION: route|defer|load_shift\n",
24
- "STATION: BLR-01..BLR-25 or NONE\n",
25
- "CHARGE_RATE: slow|fast|ultra_fast\n",
26
- "DEFER_MINUTES: integer\n",
27
  "REASON: max 20 words\n",
28
  "CONFIDENCE: 0.0-1.0\n",
29
  "```\n",
@@ -85,13 +86,12 @@
85
  "\n",
86
  "from datasets import Dataset\n",
87
  "\n",
88
- "from ev_grid_oracle.city_graph import build_city_graph\n",
89
- "from ev_grid_oracle.env import EVGridCore\n",
90
- "from ev_grid_oracle.models import ActionType, ChargeRate, EVGridAction, GridState\n",
91
  "\n",
92
  "\n",
93
- "graph = build_city_graph()\n",
94
- "core = EVGridCore(city_graph=graph)\n"
95
  ],
96
  "execution_count": null,
97
  "outputs": [],
@@ -102,65 +102,21 @@
102
  "metadata": {},
103
  "source": [
104
  "ACTION_RE = re.compile(\n",
105
- " r\"ACTION:\\s*(?P<action>route|defer|load_shift)\\s*\\n\"\n",
106
- " r\"STATION:\\s*(?P<station>BLR-\\d\\d|NONE)\\s*\\n\"\n",
107
- " r\"CHARGE_RATE:\\s*(?P<rate>slow|fast|ultra_fast)\\s*\\n\"\n",
108
- " r\"DEFER_MINUTES:\\s*(?P<defer>\\d+)\\s*\\n\",\n",
109
  " re.IGNORECASE,\n",
110
  ")\n",
111
  "\n",
112
- "SIM_RE = re.compile(\n",
113
- " r\"<SIMULATE>\\s*\\n\"\n",
114
- " r\"T\\+5_GRID_LOAD_PCT:\\s*(?P<grid>[01](?:\\.\\d+)?)\\s*\\n\"\n",
115
- " r\"T\\+5_RENEWABLE_PCT:\\s*(?P<ren>[01](?:\\.\\d+)?)\\s*\\n\"\n",
116
- " r\"T\\+5_TOP_STATIONS:\\s*(?P<tops>.+?)\\s*\\n\"\n",
117
- " r\"</SIMULATE>\",\n",
118
- " re.IGNORECASE | re.DOTALL,\n",
119
- ")\n",
120
  "\n",
121
- "\n",
122
- "def parse_action(text: str, *, ev_id: str) -> Optional[EVGridAction]:\n",
123
  " m = ACTION_RE.search(text.strip())\n",
124
  " if not m:\n",
125
  " return None\n",
126
- "\n",
127
- " action_type = ActionType(m.group(\"action\").lower())\n",
128
- " station = m.group(\"station\").upper()\n",
129
- " rate = ChargeRate(m.group(\"rate\").lower())\n",
130
- " defer = int(m.group(\"defer\"))\n",
131
- "\n",
132
- " station_id = None if station == \"NONE\" else station\n",
133
- "\n",
134
  " try:\n",
135
- " return EVGridAction(\n",
136
- " action_type=action_type,\n",
137
- " ev_id=ev_id,\n",
138
- " station_id=station_id,\n",
139
- " charge_rate=rate,\n",
140
- " defer_minutes=defer,\n",
141
- " )\n",
142
  " except Exception:\n",
143
  " return None\n",
144
- "\n",
145
- "\n",
146
- "def parse_sim(text: str):\n",
147
- " m = SIM_RE.search(text)\n",
148
- " if not m:\n",
149
- " return None\n",
150
- " try:\n",
151
- " grid = float(m.group(\"grid\"))\n",
152
- " ren = float(m.group(\"ren\"))\n",
153
- " tops_raw = m.group(\"tops\").strip()\n",
154
- " parts = [p.strip() for p in tops_raw.split(\"|\") if p.strip()]\n",
155
- " tops = []\n",
156
- " for p in parts[:3]:\n",
157
- " sid, load_s, q_s = [x.strip() for x in p.split(\":\")]\n",
158
- " tops.append((sid.upper(), float(load_s), int(q_s)))\n",
159
- " if not tops:\n",
160
- " return None\n",
161
- " return {\"grid\": grid, \"ren\": ren, \"tops\": tops}\n",
162
- " except Exception:\n",
163
- " return None\n"
164
  ],
165
  "execution_count": null,
166
  "outputs": [],
@@ -170,22 +126,40 @@
170
  "cell_type": "code",
171
  "metadata": {},
172
  "source": [
173
- "def generate_episode_dataset(n: int = 500, *, seed: int = 123) -> Dataset:\n",
 
 
 
 
 
 
 
174
  " rows = []\n",
175
  " for i in range(n):\n",
176
  " obs = core.reset(seed=seed + i)\n",
177
- " # Keep verifier honest: reward_fn uses state_json, not prompt parsing.\n",
178
- " rows.append(\n",
179
- " {\n",
180
- " \"prompt\": obs.prompt,\n",
181
- " \"state_json\": obs.state.model_dump(mode=\"json\"),\n",
182
- " }\n",
 
 
 
 
 
 
 
 
 
183
  " )\n",
 
184
  " return Dataset.from_list(rows)\n",
185
  "\n",
186
  "\n",
187
- "train_ds = generate_episode_dataset(n=500)\n",
188
- "train_ds[0][\"prompt\"][:400]\n"
 
189
  ],
190
  "execution_count": null,
191
  "outputs": [],
@@ -221,55 +195,45 @@
221
  "cell_type": "code",
222
  "metadata": {},
223
  "source": [
224
- "from ev_grid_oracle.models import SimulationPrediction, SimTopStation\n",
225
- "from ev_grid_oracle.world_model_verifier import score_prediction\n",
226
  "\n",
227
  "\n",
228
  "def reward_fn(prompts, completions, **kwargs):\n",
229
  " rewards = []\n",
230
  "\n",
231
- " # TRL passes prompt strings; we recover the matching state via dataset column.\n",
232
- " # We rely on GRPOTrainer passing `kwargs[\"batch\"]` with original examples.\n",
233
  " batch = kwargs.get(\"batch\")\n",
234
  " state_jsons = batch[\"state_json\"] if batch is not None and \"state_json\" in batch else None\n",
235
  "\n",
236
- " for prompt, completion, state_json in zip(prompts, completions, state_jsons or [None] * len(prompts)):\n",
237
  " if state_json is None:\n",
238
  " rewards.append(0.0)\n",
239
  " continue\n",
240
  "\n",
241
- " # pick target ev_id = first pending EV in state (matches prompt_builder v0)\n",
242
- " state = GridState.model_validate(state_json)\n",
243
- " ev_id = state.pending_evs[0].ev_id if state.pending_evs else \"EV-000\"\n",
244
- "\n",
245
- " action = parse_action(completion, ev_id=ev_id)\n",
246
  " if action is None:\n",
247
  " rewards.append(-1.0)\n",
248
  " continue\n",
249
  "\n",
250
- " # Base env reward\n",
251
- " local = EVGridCore(city_graph=graph)\n",
252
- " local._grid_state = state\n",
 
 
 
 
 
 
 
 
 
253
  " obs = local.step(action)\n",
254
  " base_r = float(obs.reward_breakdown.get(\"total\", 0.0))\n",
255
  "\n",
256
- " # Dream-state prediction reward (aggregate-only, verifiable)\n",
257
- " sim = parse_sim(completion)\n",
258
- " if sim is None:\n",
259
- " pred_r = -1.0\n",
260
- " else:\n",
261
- " pred = SimulationPrediction(\n",
262
- " t5_grid_load_pct=sim[\"grid\"],\n",
263
- " t5_renewable_pct=sim[\"ren\"],\n",
264
- " t5_top_stations=[\n",
265
- " SimTopStation(station_id=sid, load_pct=load, queue=q) for sid, load, q in sim[\"tops\"]\n",
266
- " ],\n",
267
- " )\n",
268
- " sc = score_prediction(state, action, pred)\n",
269
- " # map [0,1] -> [-1,+1]\n",
270
- " pred_r = (sc.score_0_1 * 2.0) - 1.0\n",
271
- "\n",
272
- " rewards.append(base_r + 2.0 * pred_r)\n",
273
  "\n",
274
  " return rewards\n",
275
  ""
@@ -285,25 +249,52 @@
285
  "from trl import GRPOConfig, GRPOTrainer\n",
286
  "\n",
287
  "config = GRPOConfig(\n",
288
- " output_dir=\"ev_oracle_grpo\",\n",
289
  " num_train_epochs=1,\n",
290
  " per_device_train_batch_size=2,\n",
291
  " gradient_accumulation_steps=8,\n",
292
  " learning_rate=5e-5,\n",
293
  " num_generations=4,\n",
294
- " max_completion_length=160,\n",
295
  " report_to=[],\n",
 
296
  ")\n",
297
  "\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  "trainer = GRPOTrainer(\n",
299
  " model=model,\n",
300
  " processing_class=tokenizer,\n",
301
  " reward_funcs=reward_fn,\n",
302
  " args=config,\n",
303
  " train_dataset=train_ds,\n",
 
304
  ")\n",
305
  "\n",
306
- "trainer.train()\n"
 
307
  ],
308
  "execution_count": null,
309
  "outputs": [],
 
6
  "source": [
7
  "# EV Grid Oracle — GRPO Training (Colab T4)\n",
8
  "\n",
9
+ "This notebook trains a small LLM (Qwen 2.5 3B Instruct) with **verifier-based GRPO** on the **real Bangalore road graph** (connected-edge actions only).\n",
10
+ "\n",
11
+ "- **Environment**: OpenEnv-compatible `EVGridRoadEnvironment` mounted at `/road/` in the Space.\n",
12
+ "- **Key constraint**: the policy can only choose a **connected neighbor** in the OSM-derived graph (no teleporting).\n",
13
  "\n",
14
  "## Run order (Colab — once per runtime)\n",
15
  "\n",
16
  "1. **Runtime → Change runtime type → GPU** (T4 matches the defaults below).\n",
17
  "2. Run the **next code cell** first. It clones this repo, moves into it, and runs `pip install -e .` so `import ev_grid_oracle` works.\n",
18
+ "3. Run cells **top to bottom**.\n",
19
  "4. After training, use the save cell and upload `ev_oracle_lora/` to the Hub (or copy to Drive).\n",
20
  "\n",
21
+ "**Links:** [Open in Colab](https://colab.research.google.com/github/NITISH-R-G/ev-grid-oracle/blob/main/training/train_grpo.ipynb) · [Notebook on GitHub](https://github.com/NITISH-R-G/ev-grid-oracle/blob/main/training/train_grpo.ipynb)\n",
22
  "\n",
23
  "**Action schema (strict):**\n",
24
  "\n",
25
+ "```text\n",
26
+ "CURRENT_NODE: <int>\n",
27
+ "NEXT_NODE: <int>\n",
 
 
28
  "REASON: max 20 words\n",
29
  "CONFIDENCE: 0.0-1.0\n",
30
  "```\n",
 
86
  "\n",
87
  "from datasets import Dataset\n",
88
  "\n",
89
+ "from ev_grid_oracle.road_env import RoadCore\n",
90
+ "from ev_grid_oracle.road_models import RoadAction, RoadState\n",
 
91
  "\n",
92
  "\n",
93
+ "core = RoadCore(g=None, nodes=[]) # graph is loaded inside reset()\n",
94
+ ""
95
  ],
96
  "execution_count": null,
97
  "outputs": [],
 
102
  "metadata": {},
103
  "source": [
104
  "ACTION_RE = re.compile(\n",
105
+ " r\"CURRENT_NODE:\\s*(?P<cur>\\d+)\\s*\\n\"\n",
106
+ " r\"NEXT_NODE:\\s*(?P<nxt>\\d+)\\s*\\n\",\n",
 
 
107
  " re.IGNORECASE,\n",
108
  ")\n",
109
  "\n",
 
 
 
 
 
 
 
 
110
  "\n",
111
+ "def parse_action(text: str) -> Optional[RoadAction]:\n",
 
112
  " m = ACTION_RE.search(text.strip())\n",
113
  " if not m:\n",
114
  " return None\n",
 
 
 
 
 
 
 
 
115
  " try:\n",
116
+ " return RoadAction(current_node=int(m.group(\"cur\")), next_node=int(m.group(\"nxt\")))\n",
 
 
 
 
 
 
117
  " except Exception:\n",
118
  " return None\n",
119
+ ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  ],
121
  "execution_count": null,
122
  "outputs": [],
 
126
  "cell_type": "code",
127
  "metadata": {},
128
  "source": [
129
+ "def _format_neighbors(st: RoadState, *, max_k: int = 12) -> str:\n",
130
+ " # Expose valid actions (neighbors) so the LLM can't claim it didn't know.\n",
131
+ " g = core.g\n",
132
+ " neigh = list(g.neighbors(int(st.node)))[:max_k]\n",
133
+ " return \", \".join(str(int(x)) for x in neigh)\n",
134
+ "\n",
135
+ "\n",
136
+ "def generate_episode_dataset(n: int = 800, *, seed: int = 123) -> Dataset:\n",
137
  " rows = []\n",
138
  " for i in range(n):\n",
139
  " obs = core.reset(seed=seed + i)\n",
140
+ " st = obs.state\n",
141
+ " neigh = _format_neighbors(st)\n",
142
+ " prompt = (\n",
143
+ " \"You are routing an EV on Bangalore's real road graph. You must pick NEXT_NODE as a connected neighbor only.\\n\\n\"\n",
144
+ " f\"CURRENT_NODE: {st.node}\\n\"\n",
145
+ " f\"BATTERY_PCT: {st.battery_pct_0_100:.1f}\\n\"\n",
146
+ " f\"TARGET_STATION_ID: {st.target_station_id}\\n\"\n",
147
+ " f\"TARGET_LATLNG: {st.target_lat:.6f},{st.target_lng:.6f}\\n\"\n",
148
+ " f\"STEPS_REMAINING: {st.steps_remaining}\\n\"\n",
149
+ " f\"VALID_NEXT_NODES: {neigh}\\n\\n\"\n",
150
+ " \"Respond in this exact schema:\\n\"\n",
151
+ " \"CURRENT_NODE: <int>\\n\"\n",
152
+ " \"NEXT_NODE: <int>\\n\"\n",
153
+ " \"REASON: max 20 words\\n\"\n",
154
+ " \"CONFIDENCE: 0.0-1.0\\n\"\n",
155
  " )\n",
156
+ " rows.append({\"prompt\": prompt, \"state_json\": st.model_dump(mode=\"json\")})\n",
157
  " return Dataset.from_list(rows)\n",
158
  "\n",
159
  "\n",
160
+ "train_ds = generate_episode_dataset(n=800)\n",
161
+ "train_ds[0][\"prompt\"][:450]\n",
162
+ ""
163
  ],
164
  "execution_count": null,
165
  "outputs": [],
 
195
  "cell_type": "code",
196
  "metadata": {},
197
  "source": [
198
+ "from ev_grid_oracle.road_models import RoadState\n",
 
199
  "\n",
200
  "\n",
201
  "def reward_fn(prompts, completions, **kwargs):\n",
202
  " rewards = []\n",
203
  "\n",
 
 
204
  " batch = kwargs.get(\"batch\")\n",
205
  " state_jsons = batch[\"state_json\"] if batch is not None and \"state_json\" in batch else None\n",
206
  "\n",
207
+ " for completion, state_json in zip(completions, state_jsons or [None] * len(completions)):\n",
208
  " if state_json is None:\n",
209
  " rewards.append(0.0)\n",
210
  " continue\n",
211
  "\n",
212
+ " st = RoadState.model_validate(state_json)\n",
213
+ " action = parse_action(completion)\n",
 
 
 
214
  " if action is None:\n",
215
  " rewards.append(-1.0)\n",
216
  " continue\n",
217
  "\n",
218
+ " # Hard anti-cheat: must match the provided current node.\n",
219
+ " if int(action.current_node) != int(st.node):\n",
220
+ " rewards.append(-1.0)\n",
221
+ " continue\n",
222
+ "\n",
223
+ " # Step local env from the same state.\n",
224
+ " local = RoadCore(g=core.g, nodes=core.nodes)\n",
225
+ " local.node = int(st.node)\n",
226
+ " local.battery_pct = float(st.battery_pct_0_100)\n",
227
+ " local.target_station_id = str(st.target_station_id)\n",
228
+ " local.steps_remaining = int(st.steps_remaining)\n",
229
+ "\n",
230
  " obs = local.step(action)\n",
231
  " base_r = float(obs.reward_breakdown.get(\"total\", 0.0))\n",
232
  "\n",
233
+ " # Penalize any anti-cheat flags from the verifier.\n",
234
+ " cheat_pen = -1.0 if obs.anti_cheat_flags else 0.0\n",
235
+ "\n",
236
+ " rewards.append(base_r + cheat_pen)\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  "\n",
238
  " return rewards\n",
239
  ""
 
249
  "from trl import GRPOConfig, GRPOTrainer\n",
250
  "\n",
251
  "config = GRPOConfig(\n",
252
+ " output_dir=\"ev_oracle_grpo_road\",\n",
253
  " num_train_epochs=1,\n",
254
  " per_device_train_batch_size=2,\n",
255
  " gradient_accumulation_steps=8,\n",
256
  " learning_rate=5e-5,\n",
257
  " num_generations=4,\n",
258
+ " max_completion_length=120,\n",
259
  " report_to=[],\n",
260
+ " logging_steps=1,\n",
261
  ")\n",
262
  "\n",
263
+ "# Minimal guardrail sampling: print a few raw generations early.\n",
264
+ "class SampleCallback:\n",
265
+ " def __init__(self, every_steps: int = 10, n: int = 3):\n",
266
+ " self.every_steps = every_steps\n",
267
+ " self.n = n\n",
268
+ "\n",
269
+ " def on_step_end(self, args, state, control, **kwargs):\n",
270
+ " step = int(getattr(state, \"global_step\", 0) or 0)\n",
271
+ " if step == 1 or (self.every_steps and step % self.every_steps == 0):\n",
272
+ " ex = train_ds.select(range(min(self.n, len(train_ds))))\n",
273
+ " for i, p in enumerate(ex[\"prompt\"]):\n",
274
+ " out = tokenizer.decode(\n",
275
+ " model.generate(\n",
276
+ " **tokenizer(p, return_tensors=\"pt\").to(model.device),\n",
277
+ " max_new_tokens=80,\n",
278
+ " do_sample=True,\n",
279
+ " temperature=0.7,\n",
280
+ " )[0],\n",
281
+ " skip_special_tokens=True,\n",
282
+ " )\n",
283
+ " print(f\"\\n--- sample step={step} i={i} ---\\n\", out[-400:])\n",
284
+ " return control\n",
285
+ "\n",
286
+ "\n",
287
  "trainer = GRPOTrainer(\n",
288
  " model=model,\n",
289
  " processing_class=tokenizer,\n",
290
  " reward_funcs=reward_fn,\n",
291
  " args=config,\n",
292
  " train_dataset=train_ds,\n",
293
+ " callbacks=[SampleCallback(every_steps=25, n=2)],\n",
294
  ")\n",
295
  "\n",
296
+ "trainer.train()\n",
297
+ ""
298
  ],
299
  "execution_count": null,
300
  "outputs": [],