{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":97984,"databundleVersionId":14096757,"sourceType":"competition"},{"sourceId":13746387,"sourceType":"datasetVersion","datasetId":8747012},{"sourceId":13816899,"sourceType":"datasetVersion","datasetId":8620533},{"sourceId":272137252,"sourceType":"kernelVersion"},{"sourceId":677607,"sourceType":"modelInstanceVersion","modelInstanceId":513841,"modelId":528480}],"dockerImageVersionId":31154,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# PhysioNet ECG Image Digitization — streamlined inference (LB ~18.33)\n\nThis notebook is a fork + cleanup of the strong baseline by Tony Li's (18.21 baseline):\nhttps://www.kaggle.com/code/tonylica/physionet-ecg-streamlined-inference  \n\nI started from my local copy and made a set of **small but high-impact inference improvements** that pushed my **Public LB to ~18.33**, which qualified as a bronze solution.\n\n> Goal of the edits: **more robust digitization** (less fragile on noisy scans / different sources) while keeping the notebook **fast & submit-safe**.\n\n---\n\n## What changed vs the original baseline\n\n### 1) Submit stability / runtime safety\n- Removed expensive / flaky `pip uninstall tensorflow` step.\n- Switched to a **safe, conditional install** of `connected-components-3d` (only if `cc3d` is missing).  \nThis reduces the risk of notebook timeouts during Kaggle submission.\n\n### 2) More reliable conversion from 4-row signal → 12 leads\n- Rewrote `series_dict()` to avoid subtle **off-by-one length drift** from `np.array_split`.  \n  Short leads are now forced to have **exactly the same length**, derived from the long `II` lead:\n  - `n_short = len(II) // 4`\n  - `fix_len()` trims/pads short segments consistently  \nThis makes downstream post-processing more stable.\n\n### 3) Safer Einthoven correction\n- Updated `dw()` to **align lengths** (`min(len(I), len(II_short), len(III))`) before applying the correction, preventing silent shape issues and improving consistency on edge cases.\n\n### 4) Smarter preprocessing selection (source-aware)\n- The classifier now returns **top-k candidates** (`k=2`) instead of only argmax:\n  - `predict_source_suffix(..., k=2)` → `([suffix1, suffix2], [p1, p2])`\n- Stage-1 is selected via **quality-based gating**:\n  - always evaluate `raw`\n  - try **top-1 preprocess** (the biggest win)\n  - try **top-2 only if the classifier is uncertain** (`p1 - p2 < p_gap`)\n  - accept a preprocessed candidate only if it beats the current best by a small margin (`margin=1.01`)  \nThis keeps runtime reasonable while improving robustness.\n\n### 5) Better Stage-1 quality metric (less fooled by headers/frames)\n- `stage1_quality()` now evaluates on a **central crop** of the image (reduces influence of text, borders, calibration marks).\n- Slightly adjusted Canny thresholds to better capture thin ECG traces on faint scans.\n\n### 6) Gentle spike suppression in Stage-2 time series\n- Kept Savitzky–Golay smoothing, but added **conditional** median filtering:\n  - apply `medfilt(kernel=3)` **only when a strong spike is detected** (`max(|diff|) > 2.5`)  \nThis avoids over-smoothing good signals while fixing some catastrophic outliers.\n\n---\n\n## Results\n- Best score: **Public LB ~18.30** (with this notebook).\n- No additional training — **inference-only** tweaks on top of pretrained checkpoints.\n\n\n---\n\n## References / credits\nhengck23’s excellent solution (demo submission) - 16.1 baseline:\nhttps://www.kaggle.com/code/hengck23/demo-submission\n\nwasupandceacar’s Net3 pipeline - 17.75 baseline:\nhttps://www.kaggle.com/code/wasupandceacar/physio-v2-3-public\n\nSaner Turhaner’s Visual QA  - 18.17 baseline:\nhttps://www.kaggle.com/code/sanpier/visual-qa-for-all-stages-of-ecg-digitization\n\nTony Li's streamlined inference - 18.21 baseline:\nhttps://www.kaggle.com/code/tonylica/physionet-ecg-streamlined-inference\n","metadata":{}},{"cell_type":"code","source":"try:\n    import cc3d \nexcept Exception:\n    !pip -q install --no-deps --no-index \\\n        --find-links='/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet/setup' \\\n        connected-components-3d\n\nimport os, sys, gc, cv2, numpy as np, pandas as pd, torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport torchvision.transforms as T\nimport timm\nfrom scipy.signal import savgol_filter, medfilt\n\nsys.path.append('/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet')\nimport stage0_common as s0c\nimport stage1_common as s1c\nimport stage2_common as s2c\nfrom stage0_model import Net as Stage0Net\nfrom stage1_model import Net as Stage1Net\nfrom stage2_model import MyCoordUnetDecoder, encode_with_resnet\n\nLEADS_ORDER = [\"I\",\"II\",\"III\",\"aVR\",\"aVL\",\"aVF\",\"V1\",\"V2\",\"V3\",\"V4\",\"V5\",\"V6\"]\n\ndef change_color(image_rgb):\n    hsv = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV)\n    h, s, v = cv2.split(hsv)\n    v_denoised = cv2.fastNlMeansDenoising(v, h=5.46)\n    std = np.std(v_denoised)\n    clip_limit = max(1.0, min(3.5, 2.0 + std / 25))\n    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))\n    v_enhanced = clahe.apply(v_denoised)\n    hsv_enhanced = cv2.merge([h, s, v_enhanced])\n    return cv2.cvtColor(hsv_enhanced, cv2.COLOR_HSV2RGB)\n\ndef series_dict(series_4row):\n    series_4row = np.asarray(series_4row)\n    if series_4row.ndim == 3:\n        series_4row = series_4row[0]\n    if series_4row.shape[0] != 4 and series_4row.shape[1] == 4:\n        series_4row = series_4row.T\n\n    d = {}\n    names = [\n        ['I','aVR','V1','V4'],\n        ['II_short','aVL','V2','V5'],\n        ['III','aVF','V3','V6'],\n    ]\n\n    full_ii = np.asarray(series_4row[3], dtype=np.float32)\n    n_short = int(len(full_ii) // 4)\n\n    def fix_len(x, n):\n        x = np.asarray(x, dtype=np.float32)\n        if len(x) >= n:\n            return x[:n]\n        if len(x) == 0:\n            return np.zeros(n, np.float32)\n        return np.concatenate([x, np.full(n - len(x), x[-1], np.float32)])\n\n    for r in range(3):\n        parts = np.array_split(series_4row[r], 4)\n        for lead, arr in zip(names[r], parts):\n            d[lead] = fix_len(arr, n_short)\n\n    d['II'] = full_ii\n    return d\n\n# ✅ FIX: Einthoven correction on SHORT leads only\ndef dw(d, alpha=0.33):\n    if all(k in d for k in ['I','II_short','III']):\n        L1, L2s, L3 = d['I'], d['II_short'], d['III']\n        n = min(len(L1), len(L2s), len(L3))\n        L1, L2s, L3 = L1[:n], L2s[:n], L3[:n]\n        e = L2s - (L1 + L3)\n        d['I']        = L1 + alpha*e\n        d['III']      = L3 + alpha*e\n        d['II_short'] = L2s - alpha*e\n    return d\n\ndef tie_ii_short_to_long(d, w=0.70):\n    if (\"II\" not in d) or (\"II_short\" not in d):\n        return d\n    II  = np.asarray(d[\"II\"], dtype=np.float32)\n    IIs = np.asarray(d[\"II_short\"], dtype=np.float32)\n    n = min(len(II), len(IIs))\n    d[\"II_short\"] = (w * II[:n] + (1.0 - w) * IIs[:n]).astype(np.float32)\n    return d\n\ndef goldberger_fix(d, w=1.00):\n    # aVR = -(I+II)/2 ; aVL = I - II/2 ; aVF = II - I/2\n    if (\"I\" not in d) or (\"II_short\" not in d):\n        return d\n    I  = np.asarray(d[\"I\"], dtype=np.float32)\n    II = np.asarray(d[\"II_short\"], dtype=np.float32)\n    n = min(len(I), len(II))\n    I, II = I[:n], II[:n]\n\n    avr = -(I + II) / 2.0\n    avl =  I - II / 2.0\n    avf =  II - I / 2.0\n\n    for k, ref in ((\"aVR\", avr), (\"aVL\", avl), (\"aVF\", avf)):\n        if k in d:\n            orig = np.asarray(d[k], dtype=np.float32)[:n]\n            d[k] = (w * ref + (1.0 - w) * orig).astype(np.float32)\n        else:\n            d[k] = ref.astype(np.float32)\n    return d\n\ndef clahe_luminance_bgr(img_bgr, clip=2.0, tile=8):\n    lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    clahe = cv2.createCLAHE(clipLimit=float(clip), tileGridSize=(int(tile), int(tile)))\n    l2 = clahe.apply(l)\n    return cv2.cvtColor(cv2.merge([l2, a, b]), cv2.COLOR_LAB2BGR)\n\ndef grayworld_white_balance(img_bgr):\n    img = img_bgr.astype(np.float32)\n    b, g, r = cv2.split(img)\n    mb, mg, mr = b.mean(), g.mean(), r.mean()\n    m = (mb + mg + mr) / 3.0\n    b *= (m / (mb + 1e-6)); g *= (m / (mg + 1e-6)); r *= (m / (mr + 1e-6))\n    return np.clip(cv2.merge([b, g, r]), 0, 255).astype(np.uint8)\n\ndef denoise_median(img_bgr, k=3):\n    k = int(k); k = k if k % 2 == 1 else k + 1\n    return cv2.medianBlur(img_bgr, k)\n\ndef denoise_bilateral(img_bgr, d=7, sigmaColor=50, sigmaSpace=50):\n    return cv2.bilateralFilter(img_bgr, d=int(d), sigmaColor=float(sigmaColor), sigmaSpace=float(sigmaSpace))\n\ndef illumination_strength(img_bgr, sigma=35):\n    gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0\n    blur = cv2.GaussianBlur(gray, (0, 0), sigma)\n    return float(np.std(blur))\n\ndef bg_correct_lab_l(img_bgr, k=81):\n    lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    k = int(k); k = k if k % 2 == 1 else k + 1\n    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))\n    bg = cv2.morphologyEx(l, cv2.MORPH_OPEN, kernel)\n    l_corr = cv2.subtract(l, bg)\n    l_corr = cv2.normalize(l_corr, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)\n    return cv2.cvtColor(cv2.merge([l_corr, a, b]), cv2.COLOR_LAB2BGR)\n\ndef preprocess_by_source(img_bgr, source):\n    s = str(source)\n    if s == \"0001\": return img_bgr\n    if s == \"0003\": return clahe_luminance_bgr(grayworld_white_balance(img_bgr), clip=1.2, tile=8)\n    if s == \"0004\": return img_bgr\n    if s == \"0006\":\n        x = denoise_bilateral(img_bgr, d=5, sigmaColor=25, sigmaSpace=25)\n        return clahe_luminance_bgr(x, clip=1.2, tile=8)\n    if s == \"0005\":\n        x = img_bgr\n        if illumination_strength(x, sigma=35) > 0.14: x = bg_correct_lab_l(x, k=81)\n        if cv2.cvtColor(x, cv2.COLOR_BGR2GRAY).std() < 30: x = clahe_luminance_bgr(x, clip=1.1, tile=8)\n        return x\n    if s == \"0009\":\n        x = img_bgr\n        if illumination_strength(x, sigma=35) > 0.14: x = bg_correct_lab_l(x, k=101)\n        return denoise_median(x, k=3)\n    if s == \"0010\":\n        x = img_bgr\n        if illumination_strength(x, sigma=35) > 0.14: x = bg_correct_lab_l(x, k=81)\n        if cv2.cvtColor(x, cv2.COLOR_BGR2GRAY).std() < 30: x = clahe_luminance_bgr(x, clip=1.15, tile=8)\n        return x\n    if s == \"0011\": return clahe_luminance_bgr(grayworld_white_balance(img_bgr), clip=1.2, tile=8)\n    if s == \"0012\": return img_bgr\n    return img_bgr\n\ndef stage1_quality(s1_rgb):\n    g = cv2.cvtColor(s1_rgb.astype(np.uint8), cv2.COLOR_RGB2GRAY)\n\n    h, w = g.shape\n    g = g[int(0.12*h):int(0.88*h), int(0.06*w):int(0.94*w)]  # crop центр: меньше влияния текста/рамок\n\n    e = cv2.Canny(g, 40, 120)  # чуть чувствительнее к тонким линиям\n    density = float(e.mean()) / 255.0\n\n    gx = cv2.Sobel(g, cv2.CV_32F, 1, 0, ksize=3)\n    gy = cv2.Sobel(g, cv2.CV_32F, 0, 1, ksize=3)\n    ax = float(np.mean(np.abs(gx))); ay = float(np.mean(np.abs(gy)))\n    anis = max(ax, ay) / (min(ax, ay) + 1e-6)\n\n    return float(density * 0.7 + np.tanh(anis - 1.0) * 0.3)\n\nclass Net3(nn.Module):\n    def __init__(self, pretrained=True):\n        super().__init__()\n        encoder_dim = [64, 128, 256, 512]\n        decoder_dim = [128, 64, 32, 16]\n        self.encoder = timm.create_model(\n            model_name='resnet34.a3_in1k',\n            pretrained=pretrained,\n            in_chans=3,\n            num_classes=0,\n            global_pool=''\n        )\n        self.decoder = MyCoordUnetDecoder(\n            in_channel=encoder_dim[-1],\n            skip_channel=encoder_dim[:-1][::-1] + [0],\n            out_channel=decoder_dim,\n            scale=[2, 2, 2, 2]\n        )\n        self.pixel = nn.Conv2d(decoder_dim[-1], 4, 1)\n\n    def forward(self, image):\n        encode = encode_with_resnet(self.encoder, image)\n        last, _ = self.decoder(feature=encode[-1], skip=encode[:-1][::-1] + [None])\n        return self.pixel(last)\n\nclass PhysioPipeline:\n    def __init__(self, device=\"cuda:0\"):\n        self.device = device\n        self.stage0_net = self.stage1_net = self.stage2_net = None\n        self.x0, self.x1 = 0, 2176\n        self.y0, self.y1 = 0, 1696\n        self.zero_mv = [703.5, 987.5, 1271.5, 1531.5]\n        self.mv_to_pixel = 78.8\n        self.t0, self.t1 = 235, 4161\n        self.resize = T.Resize((1696, 4352), interpolation=T.InterpolationMode.BILINEAR)\n\n    def load_models(self, stage0_w, stage1_w, stage2_w):\n        self.stage0_net = s0c.load_net(Stage0Net(pretrained=False), stage0_w).to(self.device).eval()\n        self.stage1_net = s1c.load_net(Stage1Net(pretrained=False), stage1_w).to(self.device).eval()\n        self.stage2_net = Net3(pretrained=False).to(self.device).eval()\n        st = torch.load(stage2_w, map_location=\"cpu\")\n        if isinstance(st, dict) and \"state_dict\" in st: st = st[\"state_dict\"]\n        self.stage2_net.load_state_dict(st, strict=True)\n\n    def run_stage0(self, img_bgr):\n        img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n        img_for_model = change_color(img_rgb)\n        batch = s0c.image_to_batch(img_for_model)\n        with torch.no_grad(), torch.amp.autocast(self.device.split(\":\")[0], dtype=torch.float32):\n            output = self.stage0_net(batch)\n        rotated, keypoint = s0c.output_to_predict(img_rgb, batch, output)\n        normalised, _, _ = s0c.normalise_by_homography(rotated, keypoint)\n        return normalised\n\n    def run_stage1(self, stage0_img_rgb):\n        image = stage0_img_rgb\n        batch = {'image': torch.from_numpy(np.ascontiguousarray(image.transpose(2, 0, 1))).unsqueeze(0)}\n        with torch.no_grad(), torch.amp.autocast(self.device.split(\":\")[0], dtype=torch.float32):\n            output = self.stage1_net(batch)\n        gridpoint_xy, _ = s1c.output_to_predict(image, batch, output)\n        return s1c.rectify_image(image, gridpoint_xy)\n\n    def run_stage2(self, stage1_img_rgb, length):\n        img = stage1_img_rgb[self.y0:self.y1, self.x0:self.x1] / 255.0\n        batch = self.resize(torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1))).unsqueeze(0)).float().to(self.device)\n        with torch.no_grad(), torch.amp.autocast(self.device.split(\":\")[0], dtype=torch.float32):\n            output = self.stage2_net(batch)\n        pixel = torch.sigmoid(output).float().cpu().numpy()[0]\n        series_in_pixel = s2c.pixel_to_series(pixel[..., self.t0:self.t1], self.zero_mv, length)\n        series = (np.array(self.zero_mv).reshape(4, 1) - series_in_pixel) / self.mv_to_pixel\n        for i in range(4):\n            x = series[i]\n        \n            # медиана только если реально есть одиночные спайки (иначе она портит форму)\n            series[i] = savgol_filter(series[i], window_length=7, polyorder=2)\n\n        return series\n\n\n\nCLS_MODEL_NAME=\"efficientnet_b2\"\nCLS_NUM_CLASSES=12\nCLS_RESOLUTION=256\nCLS_CKPT_PATH=\"/kaggle/input/physionet-image-multi-class-train/efficientnet_b2_full_train.pth\"\n\ndef build_classifier(device=\"cuda\"):\n    m = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=CLS_NUM_CLASSES)\n    st = torch.load(CLS_CKPT_PATH, map_location=\"cpu\")\n    if isinstance(st, dict) and \"state_dict\" in st: st = st[\"state_dict\"]\n    st2 = {k.replace(\"module.\",\"\"): v for k, v in st.items()}\n    m.load_state_dict(st2, strict=False)\n    return m.to(device).eval()\n\ndef cls_preprocess_bgr(img_bgr):\n    img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n    img = cv2.resize(img, (CLS_RESOLUTION, CLS_RESOLUTION), interpolation=cv2.INTER_AREA).astype(np.float32)/255.0\n    mean = np.array([0.485,0.456,0.406], np.float32); std = np.array([0.229,0.224,0.225], np.float32)\n    img = (img - mean) / std\n    return torch.from_numpy(img).permute(2,0,1).unsqueeze(0)\n\n@torch.no_grad()\ndef predict_source_suffix(model, img_bgr, k=2, device=\"cuda\"):\n    x = cls_preprocess_bgr(img_bgr).to(device)\n    p = F.softmax(model(x), dim=1)[0]\n    vals, idx = torch.topk(p, k)\n    suffixes = [f\"{int(i)+1:04d}\" for i in idx.tolist()]\n    return suffixes, vals.tolist()\n\ndef select_stage1_with_source(pipeline, img_raw_bgr, pred_srcs, pred_ps,\n                              selector_margin=1.02, p_gap=0.10):\n    selector_margin = float(selector_margin)\n    # raw\n    s1_raw = pipeline.run_stage1(pipeline.run_stage0(img_raw_bgr))\n    q_raw = stage1_quality(s1_raw)\n    best_s1, best_q = s1_raw, q_raw\n\n    if not pred_srcs:\n        return s1_raw\n\n    # try top1 preprocess (обычно самый полезный)\n    img_pp = preprocess_by_source(img_raw_bgr.copy(), pred_srcs[0])\n    s1_pp = pipeline.run_stage1(pipeline.run_stage0(img_pp))\n    q_pp = stage1_quality(s1_pp)\n    if q_pp > best_q:\n        best_s1, best_q = s1_pp, q_pp\n\n    # try top2 only if classifier is uncertain\n    if len(pred_srcs) >= 2 and len(pred_ps) >= 2 and (pred_ps[0] - pred_ps[1] < p_gap):\n        img_pp2 = preprocess_by_source(img_raw_bgr.copy(), pred_srcs[1])\n        s1_pp2 = pipeline.run_stage1(pipeline.run_stage0(img_pp2))\n        q_pp2 = stage1_quality(s1_pp2)\n        if q_pp2 > best_q:\n            best_s1, best_q = s1_pp2, q_pp2\n\n    return best_s1 if best_q > q_raw * selector_margin else s1_raw\n\ndef make_submission_from_pred(sample_id: str, g_df: pd.DataFrame, d_series: dict):\n    sample_id = str(sample_id)\n\n    def resample_to_n(y, n: int):\n        y = np.asarray(y, dtype=np.float32)\n        n = int(n)\n        if len(y) == n:\n            return y\n        if len(y) <= 1:\n            return np.zeros(n, np.float32)\n        x_old = np.linspace(0.0, 1.0, len(y), dtype=np.float32)\n        x_new = np.linspace(0.0, 1.0, n, dtype=np.float32)\n        return np.interp(x_new, x_old, y).astype(np.float32)\n\n    rows = []\n    for _, r in g_df.iterrows():\n        lead = str(r.lead)\n        n = int(r.number_of_rows)\n\n        s = d_series.get(lead, np.zeros(0, np.float32))\n        s = resample_to_n(s, n)\n\n        row_id = [f\"{sample_id}_{i}_{lead}\" for i in range(n)]\n        rows.append(pd.DataFrame({\"id\": row_id, \"value\": s}))\n\n    return pd.concat(rows, ignore_index=True)\n\n\nWORK_DIR=\"/kaggle/input/physionet-ecg-image-digitization\"\ndf_test = pd.read_csv(f\"{WORK_DIR}/test.csv\")\ndf_test[\"id\"] = df_test[\"id\"].astype(str)\n\nsample_submission = pd.read_parquet(f\"{WORK_DIR}/sample_submission.parquet\")[[\"id\"]]\n\ndevice = \"cuda\" if torch.cuda.is_available() else \"cpu\"\npipeline = PhysioPipeline(device=\"cuda:0\" if device==\"cuda\" else \"cpu\")\npipeline.load_models(\n    stage0_w=\"/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet/weight/stage0-last.checkpoint.pth\",\n    stage1_w=\"/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet/weight/stage1-last.checkpoint.pth\",\n    stage2_w=\"/kaggle/input/physio-seg-public/pytorch/net3_009_4200/1/iter_0004200.pt\",\n)\ncls_model = build_classifier(device=device)\n\nres = []\nfor sample_id, g in df_test.groupby(\"id\", sort=True):\n    img_path = f\"{WORK_DIR}/test/{sample_id}.png\"\n    img_raw = cv2.imread(img_path, cv2.IMREAD_COLOR)\n    if img_raw is None:\n        raise FileNotFoundError(img_path)\n\n    fs = int(g.fs.iloc[0])\n    sig_len = int(g.loc[g.lead==\"II\",\"number_of_rows\"].iloc[0])\n\n    pred_srcs, pred_ps = predict_source_suffix(cls_model, img_raw, k=2, device=device)\n    s1 = select_stage1_with_source(pipeline, img_raw, pred_srcs, pred_ps, selector_margin=1.02, p_gap=0.10)\n\n    series_4row = pipeline.run_stage2(s1, length=sig_len)\n\n    d = dw(series_dict(series_4row), alpha=1/3)\n    \n    res.append(make_submission_from_pred(sample_id, g, d))\n\ndf_submission = pd.concat(res, ignore_index=True)\ndf_submission = df_submission.set_index(\"id\").reindex(sample_submission[\"id\"]).reset_index()\n\nassert df_submission[\"value\"].notna().all()\nassert (df_submission[\"id\"].values == sample_submission[\"id\"].values).all()\n\ndf_submission.to_csv(\"submission.csv\", index=False)\nprint(\"OK  submission.csv\", df_submission.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-16T11:12:02.770504Z","iopub.execute_input":"2025-11-16T11:12:02.7711Z","iopub.status.idle":"2025-11-16T11:12:12.894376Z","shell.execute_reply.started":"2025-11-16T11:12:02.771074Z","shell.execute_reply":"2025-11-16T11:12:12.893491Z"}},"outputs":[],"execution_count":null}]}