{ "cells": [ { "cell_type": "code", "execution_count": null, "id": "63c1ce8d", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "V10.1 ECG Digitization - Kaggle Inference Notebook\n", "===================================================\n", "Model: EfficientNet-B4 encoder + CoordConv decoder\n", "Scale: 1x (1696 x 4352)\n", "Version: Epoch 60, Holdout SNR 38.32 dB\n", "\"\"\"\n", "\n", "import os\n", "import sys\n", "import numpy as np\n", "import pandas as pd\n", "import cv2\n", "import torch\n", "import torch.nn as nn\n", "import torch.nn.functional as F\n", "from pathlib import Path\n", "from tqdm.auto import tqdm\n", "import timm\n", "\n", "# Kaggle paths\n", "BASELINE_PATH = '/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet'\n", "WEIGHTS_PATH = '/kaggle/input/ecg-v10-best/pytorch/pytorch/4'\n", "COMPETITION_PATH = '/kaggle/input/physionet-ecg-image-digitization'\n", "\n", "sys.path.insert(0, BASELINE_PATH)\n", "\n", "# Import baseline stage 0/1\n", "from stage0_model import Net as Stage0Net\n", "from stage0_common import image_to_batch, output_to_predict, normalise_by_homography, load_net\n", "from stage1_model import Net as Stage1Net\n", "from stage1_common import output_to_predict as stage1_output_to_predict, rectify_image\n", "\n", "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n", "print(f\"Device: {device}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "dcb6646c", "metadata": {}, "outputs": [], "source": [ "# =============================================================================\n", "# V10.1 Constants (1x scale)\n", "# =============================================================================\n", "# Target resolution after preprocessing\n", "TARGET_HEIGHT = 1696\n", "TARGET_WIDTH = 4352\n", "\n", "# Crop region (applied BEFORE resize)\n", "X0, X1 = 0, 2176 # Crop width\n", "Y0, Y1 = 0, 1696 # Crop height\n", "\n", "# Signal extraction region (in target resolution)\n", "T0, T1 = 235, 4161 # Signal region in X\n", "OUTPUT_WIDTH = T1 - T0 # 3926 samples\n", "\n", "# ECG calibration constants\n", "ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5]) # Zero line Y position for each row\n", "MV_TO_PIXEL = 78.5 # Pixels per mV\n", "\n", "# Model parameters\n", "SOFT_ARGMAX_TEMP = 100.0\n", "\n", "# Lead layout (12-lead ECG standard)\n", "LEAD_NAMES = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']\n", "ROW_LAYOUT = [\n", " ['I', 'aVR', 'V1', 'V4'], # Row 0: 4 short segments\n", " ['II', 'aVL', 'V2', 'V5'], # Row 1: 4 short segments\n", " ['III', 'aVF', 'V3', 'V6'], # Row 2: 4 short segments\n", "]\n", "# Row 3: Full Lead II rhythm strip" ] }, { "cell_type": "markdown", "id": "9ad2a13b", "metadata": {}, "source": [ "## V10.1 Model Architecture (EfficientNet-B4 Encoder)" ] }, { "cell_type": "code", "execution_count": null, "id": "7b8f88e8", "metadata": {}, "outputs": [], "source": [ "class CoordDecoderBlock(nn.Module):\n", " \"\"\"Decoder block with coordinate convolution for spatial awareness.\"\"\"\n", " def __init__(self, in_ch, skip_ch, out_ch, scale=2):\n", " super().__init__()\n", " self.scale = scale\n", " self.conv = nn.Sequential(\n", " nn.Conv2d(in_ch + skip_ch + 2, out_ch, 3, padding=1, bias=False),\n", " nn.BatchNorm2d(out_ch),\n", " nn.ReLU(inplace=True),\n", " nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),\n", " nn.BatchNorm2d(out_ch),\n", " nn.ReLU(inplace=True),\n", " )\n", "\n", " def forward(self, x, skip=None):\n", " x = F.interpolate(x, scale_factor=self.scale, mode='nearest')\n", " if skip is not None:\n", " x = torch.cat([x, skip], dim=1)\n", " \n", " b, c, h, w = x.shape\n", " cy, cx = torch.meshgrid(\n", " torch.linspace(-1, 1, h, device=x.device, dtype=x.dtype),\n", " torch.linspace(-1, 1, w, device=x.device, dtype=x.dtype),\n", " indexing='ij'\n", " )\n", " coord = torch.stack([cx, cy]).unsqueeze(0).expand(b, -1, -1, -1)\n", " x = torch.cat([x, coord], dim=1)\n", " return self.conv(x)\n", "\n", "\n", "class ECGNetV10(nn.Module):\n", " \"\"\"V10.1 ECG Signal Extraction Network with EfficientNet-B4 encoder.\"\"\"\n", " def __init__(self, encoder='efficientnet_b4', decoder_dims=[256, 128, 64, 32, 16]):\n", " super().__init__()\n", " \n", " # EfficientNet-B4 with features_only\n", " self.encoder = timm.create_model(\n", " 'efficientnet_b4.ra2_in1k', pretrained=False, \n", " features_only=True, out_indices=(0, 1, 2, 3, 4)\n", " )\n", " enc_dims = [24, 32, 56, 160, 448]\n", " \n", " self.enc_dims = enc_dims\n", " \n", " # Decoder blocks\n", " self.dec_blocks = nn.ModuleList()\n", " in_ch = enc_dims[-1]\n", " skip_chs = enc_dims[:-1][::-1] + [0]\n", " \n", " while len(decoder_dims) < len(skip_chs):\n", " decoder_dims.append(decoder_dims[-1])\n", " decoder_dims = decoder_dims[:len(skip_chs)]\n", " \n", " for i, (skip_ch, out_ch) in enumerate(zip(skip_chs, decoder_dims)):\n", " self.dec_blocks.append(CoordDecoderBlock(in_ch, skip_ch, out_ch))\n", " in_ch = out_ch\n", " \n", " # Segmentation head: outputs 4-channel heatmap for 4 rows\n", " self.seg_head = nn.Conv2d(decoder_dims[-1], 4, 1)\n", " \n", " # Regression head (auxiliary, not used for soft-argmax inference)\n", " self.reg_head = nn.Sequential(\n", " nn.Conv2d(decoder_dims[-1], 64, 3, padding=1),\n", " nn.ReLU(inplace=True),\n", " nn.AdaptiveAvgPool2d((1, None)),\n", " )\n", " self.reg_out = nn.Sequential(\n", " nn.Conv1d(64, 32, 3, padding=1),\n", " nn.ReLU(inplace=True),\n", " nn.Conv1d(32, 4, 1),\n", " nn.Sigmoid()\n", " )\n", " \n", " def forward(self, x):\n", " input_size = x.shape[2:]\n", " \n", " enc = self.encoder(x)\n", " \n", " d = enc[-1]\n", " skips = enc[:-1][::-1] + [None]\n", " for block, skip in zip(self.dec_blocks, skips):\n", " d = block(d, skip)\n", " \n", " # Ensure output matches input size\n", " if d.shape[2:] != input_size:\n", " d = F.interpolate(d, size=input_size, mode='bilinear', align_corners=False)\n", " \n", " seg_logits = self.seg_head(d)\n", " reg_feat = self.reg_head(d).squeeze(2)\n", " reg_coords = self.reg_out(reg_feat)\n", " \n", " return seg_logits, reg_coords" ] }, { "cell_type": "markdown", "id": "3669ebf7", "metadata": {}, "source": [ "## Load Models" ] }, { "cell_type": "code", "execution_count": null, "id": "98cc337f", "metadata": {}, "outputs": [], "source": [ "# Load Stage 0 (orientation correction)\n", "print(\"Loading Stage 0...\")\n", "stage0_net = Stage0Net(pretrained=False)\n", "stage0_net = load_net(stage0_net, f'{BASELINE_PATH}/weight/stage0-last.checkpoint.pth')\n", "stage0_net.to(device).eval()\n", "\n", "# Load Stage 1 (grid rectification)\n", "print(\"Loading Stage 1...\")\n", "stage1_net = Stage1Net(pretrained=False)\n", "stage1_net = load_net(stage1_net, f'{BASELINE_PATH}/weight/stage1-last.checkpoint.pth')\n", "stage1_net.to(device).eval()\n", "\n", "# Load V10.1 model\n", "print(\"Loading V10.1 model...\")\n", "model = ECGNetV10(encoder='efficientnet_b4')\n", "checkpoint = torch.load(f'{WEIGHTS_PATH}/ecg_v10_best.pth', map_location='cpu', weights_only=False)\n", "\n", "# Handle DDP state dict prefix\n", "state_dict = checkpoint['model']\n", "if list(state_dict.keys())[0].startswith('module.'):\n", " state_dict = {k[7:]: v for k, v in state_dict.items()}\n", "model.load_state_dict(state_dict)\n", "model.to(device).eval()\n", "\n", "# Print checkpoint info\n", "epoch = checkpoint.get('epoch', '?')\n", "snr = checkpoint.get('holdout_snr_soft', checkpoint.get('snr_soft', 0))\n", "print(f\"Loaded epoch {epoch}, Holdout SNR: {snr:.2f} dB\")\n", "print(\"All models loaded!\")" ] }, { "cell_type": "markdown", "id": "ce3a8cb2", "metadata": {}, "source": [ "## Inference Functions" ] }, { "cell_type": "code", "execution_count": null, "id": "53da120c", "metadata": {}, "outputs": [], "source": [ "def soft_argmax(heatmap, temperature=SOFT_ARGMAX_TEMP):\n", " \"\"\"Extract sub-pixel Y coordinates using soft-argmax.\n", " \n", " Args:\n", " heatmap: [B, 4, H, W] segmentation logits\n", " temperature: Softmax temperature (higher = sharper peaks)\n", " \n", " Returns:\n", " [B, 4, W] Y-coordinates in pixel space\n", " \"\"\"\n", " B, C, H, W = heatmap.shape\n", " y_coords = torch.arange(H, device=heatmap.device, dtype=heatmap.dtype).view(1, 1, H, 1)\n", " weights = F.softmax(heatmap * temperature, dim=2)\n", " return (weights * y_coords).sum(dim=2)\n", "\n", "\n", "@torch.no_grad()\n", "def process_stage0(image):\n", " \"\"\"Stage 0: Orientation correction.\n", " \n", " Args:\n", " image: RGB numpy array (H, W, 3)\n", " \n", " Returns:\n", " Normalized RGB image\n", " \"\"\"\n", " batch = image_to_batch(image)\n", " with torch.amp.autocast('cuda', dtype=torch.float32):\n", " output = stage0_net(batch)\n", " rotated, keypoint = output_to_predict(image, batch, output)\n", " normalized, _, _ = normalise_by_homography(rotated, keypoint)\n", " return normalized\n", "\n", "\n", "@torch.no_grad()\n", "def process_stage1(image):\n", " \"\"\"Stage 1: Grid rectification.\n", " \n", " Args:\n", " image: RGB numpy array (H, W, 3)\n", " \n", " Returns:\n", " Rectified RGB image\n", " \"\"\"\n", " batch = {'image': torch.from_numpy(np.ascontiguousarray(image.transpose(2, 0, 1))).unsqueeze(0)}\n", " with torch.amp.autocast('cuda', dtype=torch.float32):\n", " output = stage1_net(batch)\n", " gridpoint_xy, _ = stage1_output_to_predict(image, batch, output)\n", " rectified = rectify_image(image, gridpoint_xy)\n", " return rectified\n", "\n", "\n", "@torch.no_grad()\n", "def process_stage2(image_bgr, use_soft_argmax=True):\n", " \"\"\"Stage 2: Signal extraction using V10.1 model.\n", " \n", " CRITICAL: V10.1 was trained on BGR images (cv2.imread without conversion).\n", " \n", " Preprocessing (must match training exactly):\n", " 1. Crop to [Y0:Y1, X0:X1] = [0:1696, 0:2176]\n", " 2. Resize to (TARGET_WIDTH, TARGET_HEIGHT) = (4352, 1696)\n", " 3. Normalize to [0, 1]\n", " \n", " Output:\n", " - Model outputs [4, 4352] pixel coordinates for each row\n", " - Extract signal region [T0:T1] = [235:4161] = 3926 samples\n", " \n", " Args:\n", " image_bgr: BGR numpy array (H, W, 3)\n", " use_soft_argmax: Use soft-argmax (True) or regression head (False)\n", " \n", " Returns:\n", " signal_pixel: [4, OUTPUT_WIDTH] Y-coordinates in pixel space\n", " \"\"\"\n", " h, w = image_bgr.shape[:2]\n", " \n", " # Step 1: Crop to ECG region (MUST match training)\n", " crop_h = min(h, Y1)\n", " crop_w = min(w, X1)\n", " image_cropped = image_bgr[:crop_h, :crop_w]\n", " \n", " # Step 2: Resize to model input size\n", " image_resized = cv2.resize(image_cropped, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)\n", " \n", " # Step 3: Normalize to [0, 1] and convert to tensor (BGR order, same as training)\n", " image_tensor = torch.from_numpy(image_resized.astype(np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0)\n", " image_tensor = image_tensor.to(device)\n", " \n", " # Inference\n", " with torch.amp.autocast('cuda', dtype=torch.float16):\n", " seg_logits, reg_coords = model(image_tensor)\n", " \n", " if use_soft_argmax:\n", " # Soft-argmax: convert heatmap to pixel coordinates\n", " seg_probs = torch.sigmoid(seg_logits.float())\n", " signal_full = soft_argmax(seg_probs).cpu().numpy()[0] # [4, TARGET_WIDTH]\n", " else:\n", " # Regression: directly use normalized coordinates\n", " signal_norm = reg_coords.float().cpu().numpy()[0] # [4, TARGET_WIDTH]\n", " signal_full = signal_norm * (TARGET_HEIGHT - 1)\n", " \n", " # Step 4: Extract signal region [T0:T1]\n", " signal_pixel = signal_full[:, T0:T1] # [4, OUTPUT_WIDTH=3926]\n", " \n", " return signal_pixel\n", "\n", "\n", "def pixel_to_mv(signal_pixel):\n", " \"\"\"Convert pixel Y-coordinates to mV values.\n", " \n", " Args:\n", " signal_pixel: [4, N] Y-coordinates in pixel space\n", " \n", " Returns:\n", " signal_mv: [4, N] mV values\n", " \"\"\"\n", " signal_mv = np.zeros_like(signal_pixel)\n", " for row_idx in range(4):\n", " # Higher Y = lower voltage (ECG paper convention)\n", " signal_mv[row_idx] = (ZERO_MV[row_idx] - signal_pixel[row_idx]) / MV_TO_PIXEL\n", " return signal_mv\n", "\n", "\n", "def series_to_leads(series_mv):\n", " \"\"\"Convert 4-row signal series to 12-lead dictionary.\n", " \n", " Layout:\n", " - Row 0: I, aVR, V1, V4 (4 segments, each 2.5s)\n", " - Row 1: II, aVL, V2, V5 (4 segments, each 2.5s) \n", " - Row 2: III, aVF, V3, V6 (4 segments, each 2.5s)\n", " - Row 3: Full Lead II rhythm strip (10s)\n", " \n", " Args:\n", " series_mv: [4, OUTPUT_WIDTH] mV values\n", " \n", " Returns:\n", " leads: dict mapping lead name to signal array\n", " \"\"\"\n", " leads = {}\n", " segment_width = series_mv.shape[1] // 4 # ~981 samples per segment\n", " \n", " # Rows 0-2: 4 short segments each\n", " for row_idx in range(3):\n", " for seg_idx, lead_name in enumerate(ROW_LAYOUT[row_idx]):\n", " start = seg_idx * segment_width\n", " end = (seg_idx + 1) * segment_width\n", " leads[lead_name] = series_mv[row_idx, start:end]\n", " \n", " # Row 3: Full rhythm strip (Lead II, 10 seconds)\n", " leads['II'] = series_mv[3]\n", " \n", " return leads\n", "\n", "\n", "def process_image(image_path):\n", " \"\"\"Full inference pipeline for single image.\n", " \n", " Flow:\n", " 1. Load image (BGR)\n", " 2. Convert to RGB for stage0/stage1 (baseline expects RGB)\n", " 3. Stage0: orientation correction\n", " 4. Stage1: grid rectification\n", " 5. Convert back to BGR for V10.1 model (trained on BGR)\n", " 6. Stage2: signal extraction\n", " \n", " Args:\n", " image_path: Path to PNG image\n", " \n", " Returns:\n", " signal_mv: [4, OUTPUT_WIDTH] mV values\n", " \"\"\"\n", " # Load as BGR\n", " image_bgr = cv2.imread(str(image_path))\n", " if image_bgr is None:\n", " raise ValueError(f\"Failed to load image: {image_path}\")\n", " \n", " # Convert to RGB for baseline preprocessing\n", " image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)\n", " \n", " # Stage 0: Orientation correction\n", " try:\n", " normalized = process_stage0(image_rgb)\n", " except Exception as e:\n", " normalized = image_rgb\n", " \n", " # Stage 1: Grid rectification\n", " try:\n", " rectified = process_stage1(normalized)\n", " except Exception as e:\n", " rectified = normalized\n", " \n", " # Convert back to BGR for V10.1 model\n", " rectified_bgr = cv2.cvtColor(rectified, cv2.COLOR_RGB2BGR)\n", " \n", " # Stage 2: Signal extraction\n", " signal_pixel = process_stage2(rectified_bgr, use_soft_argmax=True)\n", " signal_mv = pixel_to_mv(signal_pixel)\n", " \n", " return signal_mv" ] }, { "cell_type": "code", "execution_count": null, "id": "97902125", "metadata": {}, "outputs": [], "source": [ "# =============================================================================\n", "# Post-processing: Einthoven's Law Correction and Smoothing\n", "# =============================================================================\n", "# ECG constraint: Lead II = Lead I + Lead III (Einthoven's Law)\n", "# Applying this constraint can improve consistency between leads.\n", "\n", "def apply_savgol_smoothing(leads_dict, window=7, polyorder=2):\n", " \"\"\"Apply Savitzky-Golay smoothing to all leads.\n", " \n", " This removes high-frequency noise while preserving signal shape.\n", " \n", " Args:\n", " leads_dict: dict mapping lead name to signal array\n", " window: Window length for savgol filter (must be odd)\n", " polyorder: Polynomial order (must be < window)\n", " \n", " Returns:\n", " smoothed: dict with smoothed signals\n", " \"\"\"\n", " from scipy.signal import savgol_filter\n", " \n", " smoothed = {}\n", " for lead, signal in leads_dict.items():\n", " if len(signal) >= window:\n", " smoothed[lead] = savgol_filter(signal, window_length=window, polyorder=polyorder)\n", " else:\n", " smoothed[lead] = signal\n", " return smoothed\n", "\n", "\n", "def apply_einthoven_correction(leads_dict, alpha=0.33):\n", " \"\"\"Apply Einthoven's law correction on SHORT segments only.\n", " \n", " Einthoven's Law: II = I + III (in mV)\n", " \n", " If there's a violation e = II_short - (I + III), distribute the error:\n", " - I' = I + α*e\n", " - III'= III + α*e \n", " - II' = II - α*e (for short segment only)\n", " \n", " Note: This only applies to the 2.5s segments from rows 0-2.\n", " The full 10s Lead II (row 3) is not corrected.\n", " \n", " Args:\n", " leads_dict: dict mapping lead name to signal array\n", " alpha: Error distribution factor (default 0.33 = equal distribution)\n", " \n", " Returns:\n", " corrected leads_dict\n", " \"\"\"\n", " # For now, we just return the leads unchanged\n", " # Einthoven correction is optional and may not improve competition score\n", " return leads_dict" ] }, { "cell_type": "markdown", "id": "3a06d023", "metadata": {}, "source": [ "## Generate Submission" ] }, { "cell_type": "code", "execution_count": null, "id": "f093977b", "metadata": {}, "outputs": [], "source": [ "def resample_signal(signal, target_length):\n", " \"\"\"Resample signal to target length.\"\"\"\n", " if len(signal) == target_length:\n", " return signal\n", " x_old = np.linspace(0, 1, len(signal))\n", " x_new = np.linspace(0, 1, target_length)\n", " return np.interp(x_new, x_old, signal)\n", "\n", "\n", "# Load test metadata\n", "test_df = pd.read_csv(f'{COMPETITION_PATH}/test.csv')\n", "test_dir = Path(f'{COMPETITION_PATH}/test')\n", "image_ids = test_df['id'].unique()\n", "\n", "print(f\"Processing {len(image_ids)} images...\")\n", "print(f\"Signal output width: {OUTPUT_WIDTH} (T0={T0} to T1={T1})\")" ] }, { "cell_type": "code", "execution_count": null, "id": "cf2ba721", "metadata": {}, "outputs": [], "source": [ "# Quick validation on a few training samples (if available)\n", "# This helps catch preprocessing issues before full submission\n", "VALIDATE_LOCALLY = False # Set to True if you have training data on Kaggle\n", "\n", "if VALIDATE_LOCALLY:\n", " import glob\n", " train_samples = glob.glob('/kaggle/input/physionet-ecg-image-digitization/train/*.png')[:3]\n", " \n", " for img_path in train_samples:\n", " img_id = Path(img_path).stem\n", " print(f\"\\nValidating: {img_id}\")\n", " \n", " try:\n", " signal_mv = process_image(img_path)\n", " leads = series_to_leads(signal_mv)\n", " \n", " print(f\" Signal shape: {signal_mv.shape}\")\n", " print(f\" Lead II range: [{leads['II'].min():.2f}, {leads['II'].max():.2f}] mV\")\n", " \n", " # Check if values are reasonable (ECG should be roughly -2 to 2 mV)\n", " if np.abs(leads['II']).max() > 10:\n", " print(\" WARNING: Signal amplitude seems too large!\")\n", " except Exception as e:\n", " print(f\" Error: {e}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "03f8ceb4", "metadata": {}, "outputs": [], "source": [ "all_rows = []\n", "\n", "for img_id in tqdm(image_ids):\n", " img_path = test_dir / f\"{img_id}.png\"\n", " \n", " if not img_path.exists():\n", " print(f\"Missing: {img_path}\")\n", " continue\n", " \n", " img_df = test_df[test_df['id'] == img_id]\n", " \n", " try:\n", " signal_mv = process_image(img_path)\n", " leads = series_to_leads(signal_mv)\n", " except Exception as e:\n", " print(f\"Error {img_id}: {e}\")\n", " # Fallback to zeros\n", " for _, row in img_df.iterrows():\n", " for i in range(row['number_of_rows']):\n", " all_rows.append({'id': f\"{img_id}_{i}_{row['lead']}\", 'value': 0.0})\n", " continue\n", " \n", " for _, row in img_df.iterrows():\n", " lead = row['lead']\n", " num_samples = row['number_of_rows']\n", " \n", " if lead in leads:\n", " signal = resample_signal(leads[lead], num_samples)\n", " for i, val in enumerate(signal):\n", " all_rows.append({'id': f\"{img_id}_{i}_{lead}\", 'value': float(val)})\n", " else:\n", " for i in range(num_samples):\n", " all_rows.append({'id': f\"{img_id}_{i}_{lead}\", 'value': 0.0})\n", "\n", "print(f\"Total rows: {len(all_rows)}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "245d5021", "metadata": {}, "outputs": [], "source": [ "# Create submission\n", "submission_df = pd.DataFrame(all_rows)\n", "\n", "# Ensure proper types\n", "submission_df['id'] = submission_df['id'].astype(str)\n", "submission_df['value'] = submission_df['value'].astype(float)\n", "\n", "submission_df.to_csv('/kaggle/working/submission.csv', index=False)\n", "\n", "print(f\"Submission saved!\")\n", "print(f\"Shape: {submission_df.shape}\")\n", "print(f\"Types: id={submission_df['id'].dtype}, value={submission_df['value'].dtype}\")\n", "print(submission_df.head(10))\n", "print(f\"\\nValue stats:\")\n", "print(submission_df['value'].describe())" ] } ], "metadata": { "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }