{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"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"},"colab":{"machine_shape":"hm","gpuType":"T4"},"accelerator":"GPU","kaggle":{"accelerator":"gpu","dataSources":[{"sourceType":"competition","sourceId":52254,"databundleVersionId":9674523},{"sourceType":"datasetVersion","sourceId":15319401,"datasetId":9798218,"databundleVersionId":16225399},{"sourceType":"datasetVersion","sourceId":15285492,"datasetId":9777608,"databundleVersionId":16187689},{"sourceType":"datasetVersion","sourceId":1421897,"datasetId":832492,"databundleVersionId":1455197},{"sourceType":"datasetVersion","sourceId":13339539,"datasetId":8458820,"databundleVersionId":14045209},{"sourceType":"datasetVersion","sourceId":13111063,"datasetId":8305320,"databundleVersionId":13795105},{"sourceType":"datasetVersion","sourceId":13416916,"datasetId":8515480,"databundleVersionId":14130063}],"dockerImageVersionId":31090,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install -U transformers","metadata":{"_uuid":"59a95f82-33eb-40e0-b7b9-7dd1f7cd503d","_cell_guid":"039544aa-c457-4f8d-b78b-a97191e2e70f","trusted":true,"execution":{"iopub.status.busy":"2026-03-22T20:25:26.730657Z","iopub.execute_input":"2026-03-22T20:25:26.730939Z","iopub.status.idle":"2026-03-22T20:25:43.29253Z","shell.execute_reply.started":"2026-03-22T20:25:26.730898Z","shell.execute_reply":"2026-03-22T20:25:43.291698Z"},"_kg_hide-input":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% [code] Config\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport json\nimport warnings\nfrom typing import Any\nimport torch\n\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nfrom transformers import pipeline\nfrom transformers.utils import logging as hf_logging\nfrom huggingface_hub import login\nfrom sklearn.metrics import roc_auc_score, precision_recall_fscore_support\n\ntry:\n    from kaggle_secrets import UserSecretsClient\nexcept ImportError:\n    UserSecretsClient = None\n\nwarnings.filterwarnings(\"ignore\")\nhf_logging.set_verbosity_error()\n\nCONFIG = {\n    # HF / auth\n    \"USE_KAGGLE_SECRET\": True,\n    \"KAGGLE_HF_SECRET_NAME\": \"HF_read\",\n    \"HF_TOKEN\": \"YOUR_HF_TOKEN_HERE\",\n    \"MODEL_NAME\": \"google/medgemma-1.5-4b-it\",\n\n    # data\n    \"SELECTION_CSV\": \"/kaggle/input/datasets/bluepill/abdominal-submission-250t-77a/submission_250t_77a.csv\",\n    \"TRAIN_LABELS_CSV\": \"/kaggle/input/competitions/rsna-2023-abdominal-trauma-detection/train_2024.csv\",\n    \"DICOM_ROOT\": \"/kaggle/input/competitions/rsna-2023-abdominal-trauma-detection/train_images\",\n    \"WORK_DIR\": \"/kaggle/working/medgemma_abdominal_dicoms\",\n\n    # model prompt\n    \"PROMPT\": \"\"\"\\\nTask: classify abdominal CT slice for traumatic injuries of the liver, spleen, kidneys, and bowel (blunt trauma).\n\nVisibility rule:\n- Evaluate kidneys, liver, and spleen ONLY if the organ (or a clear portion of it) is actually visible on THIS slice.\n  If an organ is not visible on the slice, do NOT search for or report an abnormality in that organ.\n\nA) Count as ANOMALY only if this slice shows one or more of the following traumatic findings clearly visible on this slice:\n   1) Active contrast extravasation (jet/collection not conforming to a vessel or collecting system).\n   2) Parenchymal laceration or deep contusion of liver/spleen/kidney.\n   3) Subcapsular or periorgan hematoma.\n   4) Devascularization/infarct zone in a target organ.\n   5) Bowel wall discontinuity, focal full-thickness defect, or unequivocal traumatic wall thickening/hematoma.\n   6) Free intraperitoneal air (pneumoperitoneum) or hemoperitoneum/free intraperitoneal fluid attributable to trauma.\n   7) Mesenteric hematoma.\n\nSteps:\n1) Inspect liver, spleen, both kidneys, and bowel\n2) If no abnormalities are visible, output label: normal.\n3) If any abnormality is suspected, output label: anomaly.\n\nOutput format (MUST be exact, lowercase, no extra text):\nlabel: normal\nOR\nlabel: anomaly\n\"\"\".strip(),\n\n    # axes\n    \"AXES_TO_EVAL\": [\"axial\"],\n\n    # intensity window\n    \"HU_CENTER\": 40,\n    \"HU_WIDTH\": 400,\n\n    # slice selection\n    \"USE_CENTER_CROP_FOR_LONG_STUDIES\": False,\n    \"CENTER_CROP_THRESHOLD\": 300,\n    \"CENTER_FRACTION_LOW\": 0.2,\n    \"CENTER_FRACTION_HIGH\": 0.8,\n\n    # study-level decisions\n    \"FRACTION_THRESHOLD\": 0.10,\n    \"CONSECUTIVE_MIN_RUN\": 3,\n    \"N_PARTS\": 10,\n    \"PART_THRESHOLD\": 0.25,\n    \"N_SAMPLES\": 77\n}\n\nLABEL_RE = re.compile(r\"label\\s*:\\s*(normal|anomaly)\", re.IGNORECASE)\nAXES_ALL = (\"axial\", \"sagittal\", \"coronal\")\n\nos.makedirs(CONFIG[\"WORK_DIR\"], exist_ok=True)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-22T20:25:43.29381Z","iopub.execute_input":"2026-03-22T20:25:43.294082Z","iopub.status.idle":"2026-03-22T20:25:57.253342Z","shell.execute_reply.started":"2026-03-22T20:25:43.294054Z","shell.execute_reply":"2026-03-22T20:25:57.252421Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Functions\n\ndef get_hf_token_from_config() -> str:\n    if CONFIG.get(\"USE_KAGGLE_SECRET\", False):\n        if UserSecretsClient is None:\n            raise RuntimeError(\"Kaggle secrets are unavailable. Set USE_KAGGLE_SECRET=False and fill HF_TOKEN.\")\n        return UserSecretsClient().get_secret(CONFIG[\"KAGGLE_HF_SECRET_NAME\"])\n    return CONFIG[\"HF_TOKEN\"]\n\n\ndef build_medgemma_pipeline():\n    print(\"Getting HF token...\")\n    hf_token = get_hf_token_from_config()\n    login(token=hf_token)\n\n    print(f\"Loading model: {CONFIG['MODEL_NAME']}\")\n    pipe = pipeline(\n        \"image-text-to-text\",\n        model=CONFIG[\"MODEL_NAME\"],\n        trust_remote_code=True,\n        device_map=\"auto\",\n    )\n    print(\"Model loaded.\")\n    return pipe\n\n\n\ndef list_dicom_files(folder: str) -> list[str]:\n    if not os.path.isdir(folder):\n        return []\n    return [\n        os.path.join(folder, f)\n        for f in os.listdir(folder)\n        if os.path.isfile(os.path.join(folder, f)) and not f.startswith(\".\")\n    ]\n\n\ndef choose_series_with_max_dicoms(patient_root: str) -> tuple[str | None, int]:\n    if not os.path.isdir(patient_root):\n        return None, 0\n\n    subdirs = [\n        os.path.join(patient_root, d)\n        for d in os.listdir(patient_root)\n        if os.path.isdir(os.path.join(patient_root, d))\n    ]\n\n    if not subdirs:\n        n_files = len(list_dicom_files(patient_root))\n        return (patient_root, n_files) if n_files > 0 else (None, 0)\n\n    counts = [(subdir, len(list_dicom_files(subdir))) for subdir in subdirs]\n    counts = [x for x in counts if x[1] > 0]\n    if not counts:\n        return None, 0\n\n    best_dir, best_n = max(counts, key=lambda x: (x[1], x[0]))\n    return best_dir, best_n\n\n\ndef _to_hu(ds) -> np.ndarray:\n    arr = ds.pixel_array.astype(np.float32)\n    slope = float(getattr(ds, \"RescaleSlope\", 1.0))\n    intercept = float(getattr(ds, \"RescaleIntercept\", 0.0))\n    hu = arr * slope + intercept\n    if str(getattr(ds, \"PhotometricInterpretation\", \"\")).upper() == \"MONOCHROME1\":\n        hu = -hu\n    return hu\n\n\ndef load_dicom_series(series_dir: str) -> np.ndarray:\n    files = list_dicom_files(series_dir)\n    if not files:\n        raise RuntimeError(f\"No DICOM files in {series_dir}\")\n\n    dsets = []\n    for p in files:\n        try:\n            ds = pydicom.dcmread(p, force=True, stop_before_pixels=False)\n            _ = ds.pixel_array\n            dsets.append(ds)\n        except Exception:\n            continue\n\n    if not dsets:\n        raise RuntimeError(f\"No readable DICOMs in {series_dir}\")\n\n    ds0 = dsets[0]\n    iop = getattr(ds0, \"ImageOrientationPatient\", [1, 0, 0, 0, 1, 0])\n    row_dir = np.array(iop[:3], dtype=np.float64)\n    col_dir = np.array(iop[3:], dtype=np.float64)\n    normal = np.cross(row_dir, col_dir)\n\n    def sort_key(ds):\n        ipp = getattr(ds, \"ImagePositionPatient\", None)\n        if ipp is not None:\n            ipp = np.array([float(ipp[0]), float(ipp[1]), float(ipp[2])], dtype=np.float64)\n            return float(ipp @ normal)\n        return int(getattr(ds, \"InstanceNumber\", 0))\n\n    dsets = sorted(dsets, key=sort_key)\n\n    h = int(getattr(ds0, \"Rows\"))\n    w = int(getattr(ds0, \"Columns\"))\n    z = len(dsets)\n\n    volume = np.zeros((h, w, z), dtype=np.float32)\n    for i, ds in enumerate(dsets):\n        volume[:, :, i] = _to_hu(ds)\n\n    return volume\n\n\ndef select_step_abdominal(n_slices: int) -> int:\n    if n_slices < 50:\n        return 1\n    if n_slices < 100:\n        return 2\n    if n_slices < 200:\n        return 4\n    if n_slices < 400:\n        return 6\n    if n_slices < 600:\n        return 8\n    return 10\n\n\ndef choose_indices_for_stack(n_total: int) -> tuple[list[int], dict[str, Any]]:\n    if n_total <= 0:\n        raise ValueError(\"Empty study\")\n\n    use_center_crop = (\n        bool(CONFIG[\"USE_CENTER_CROP_FOR_LONG_STUDIES\"])\n        and n_total > int(CONFIG[\"CENTER_CROP_THRESHOLD\"])\n    )\n\n    if use_center_crop:\n        start = int(n_total * float(CONFIG[\"CENTER_FRACTION_LOW\"]))\n        end = n_total - int(n_total * (1.0 - float(CONFIG[\"CENTER_FRACTION_HIGH\"])))\n        end = max(end, start + 1)\n        n_eff = end - start\n        step = select_step_abdominal(n_eff)\n        indices = list(range(start, end, step))\n    else:\n        start = 0\n        end = n_total\n        step = select_step_abdominal(n_total)\n        indices = list(range(0, n_total, step))\n\n    meta = {\n        \"n_slices\": n_total,\n        \"n_used\": len(indices),\n        \"start\": start,\n        \"end\": end,\n        \"step\": step,\n    }\n    return indices, meta\n\n\ndef extract_slice(volume: np.ndarray, axis: str, idx: int) -> np.ndarray:\n    if axis == \"axial\":\n        return volume[:, :, idx]\n    if axis == \"sagittal\":\n        return volume[:, idx, :]\n    if axis == \"coronal\":\n        return volume[idx, :, :]\n    raise ValueError(f\"Unsupported axis: {axis}\")\n\n\ndef hu_to_u8(slice_2d: np.ndarray, center: float, width: float) -> np.ndarray:\n    lo = center - width / 2.0\n    hi = center + width / 2.0\n    s = np.clip(slice_2d, lo, hi)\n    img = (s - lo) / (hi - lo + 1e-6)\n    return (img * 255.0).astype(np.uint8)\n\n\ndef prepare_slices_for_axis(\n    volume: np.ndarray,\n    axis: str,\n    center: float,\n    width: float,\n) -> tuple[list[int], list[Image.Image], dict[str, Any]]:\n    h, w, d = volume.shape\n\n    if axis == \"axial\":\n        n_slices = d\n        rotate180 = False\n    elif axis == \"sagittal\":\n        n_slices = w\n        rotate180 = True\n    elif axis == \"coronal\":\n        n_slices = h\n        rotate180 = True\n    else:\n        raise ValueError(f\"Unsupported axis: {axis}\")\n\n    indices, meta = choose_indices_for_stack(n_slices)\n\n    images = []\n    for i in indices:\n        sl = extract_slice(volume, axis, i)\n        if rotate180:\n            sl = np.rot90(sl, 2)\n        u8 = hu_to_u8(sl, center=center, width=width)\n        images.append(Image.fromarray(u8, mode=\"L\").convert(\"RGB\"))\n\n    return indices, images, meta\n\n\ndef prepare_slices_all_axes(\n    volume: np.ndarray,\n    axes: list[str],\n    center: float,\n    width: float,\n) -> dict[str, dict[str, Any]]:\n    out = {}\n    for axis in axes:\n        if axis not in AXES_ALL:\n            raise ValueError(f\"axis must be in {AXES_ALL}, got {axis!r}\")\n        indices, images, meta = prepare_slices_for_axis(volume, axis, center, width)\n        out[axis] = {\n            \"indices\": indices,\n            \"images\": images,\n            \"meta\": meta,\n        }\n    return out\n\n\ndef parse_label(text: str) -> int | None:\n    if not isinstance(text, str):\n        return None\n    match = LABEL_RE.search(text)\n    if not match:\n        return None\n    return 1 if match.group(1).lower() == \"anomaly\" else 0\n\n\ndef classify_slices_with_outputs(\n    images,\n    pipe_obj,\n    prompt,\n    axis,\n    slice_indices,\n    patient_id,\n    series_id,\n):\n    rows = []\n\n    for idx, image in tqdm(\n        list(zip(slice_indices, images)),\n        total=len(images),\n        desc=f\"{patient_id}/{series_id}/{axis}\",\n        leave=False,\n    ):\n        messages = [\n            {\n                \"role\": \"system\",\n                \"content\": [{\"type\": \"text\", \"text\": \"You are an expert radiologist.\"}],\n            },\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\"type\": \"text\", \"text\": prompt},\n                    {\"type\": \"image\", \"image\": image},\n                ],\n            },\n        ]\n\n        try:\n            output = pipe_obj(text=messages, max_new_tokens=256)\n            raw_text = output[0][\"generated_text\"][-1][\"content\"]\n            parsed_label = parse_label(raw_text)\n            status = \"ok\" if parsed_label is not None else \"unparsed\"\n        except Exception as exc:\n            print(f\"[ERROR] patient={patient_id} series={series_id} axis={axis} slice={idx}\")\n            raise exc\n\n        rows.append(\n            {\n                \"patient_id\": patient_id,\n                \"series_id\": series_id,\n                \"axis\": axis,\n                \"slice_index\": int(idx),\n                \"raw_response\": raw_text,\n                \"parsed_label\": parsed_label,\n                \"status\": status,\n            }\n        )\n\n    return rows\n\n\n\ndef decision_by_fraction(labels: list[int], threshold: float) -> bool:\n    if not labels:\n        return False\n    return sum(labels) / len(labels) > threshold\n\n\ndef decision_by_consecutive(labels: list[int], min_run: int) -> bool:\n    if min_run <= 0 or not labels:\n        return False\n    run = 0\n    for x in labels:\n        if x == 1:\n            run += 1\n            if run >= min_run:\n                return True\n        else:\n            run = 0\n    return False\n\n\ndef decision_by_parts(labels: list[int], n_parts: int, part_threshold: float) -> bool:\n    if not labels or n_parts <= 0:\n        return False\n    size = len(labels)\n    part_size = max(1, size // n_parts)\n\n    for p in range(n_parts):\n        start = p * part_size\n        end = min(start + part_size, size) if p < n_parts - 1 else size\n        if start >= end:\n            continue\n        part = labels[start:end]\n        if sum(part) / len(part) > part_threshold:\n            return True\n\n    return False\n\n\ndef apply_study_decisions(labels: list[int]) -> dict[str, bool]:\n    return {\n        \"decision_by_fraction\": decision_by_fraction(labels, CONFIG[\"FRACTION_THRESHOLD\"]),\n        \"decision_by_consecutive\": decision_by_consecutive(labels, CONFIG[\"CONSECUTIVE_MIN_RUN\"]),\n        \"decision_by_parts\": decision_by_parts(labels, CONFIG[\"N_PARTS\"], CONFIG[\"PART_THRESHOLD\"]),\n    }\n\n\ndef score_one_study(patient_id: str, series_dir: str, pipe_obj):\n    series_id = os.path.basename(series_dir.rstrip(\"/\"))\n    volume = load_dicom_series(series_dir)\n\n    slices_by_axis = prepare_slices_all_axes(\n        volume=volume,\n        axes=CONFIG[\"AXES_TO_EVAL\"],\n        center=CONFIG[\"HU_CENTER\"],\n        width=CONFIG[\"HU_WIDTH\"],\n    )\n\n    axis_labels = {}\n    axis_indices = {}\n    all_labels = []\n    slice_rows = []\n\n    for axis in CONFIG[\"AXES_TO_EVAL\"]:\n        axis_data = slices_by_axis.get(axis, {})\n        indices = list(axis_data.get(\"indices\", []))\n        images = list(axis_data.get(\"images\", []))\n        axis_indices[axis] = indices\n\n        if not images:\n            axis_labels[axis] = []\n            continue\n\n        rows = classify_slices_with_outputs(\n            images=images,\n            pipe_obj=pipe_obj,\n            prompt=CONFIG[\"PROMPT\"],\n            axis=axis,\n            slice_indices=indices,\n            patient_id=patient_id,\n            series_id=series_id,\n        )\n        slice_rows.extend(rows)\n\n        labels = [int(x[\"parsed_label\"]) for x in rows if x[\"parsed_label\"] is not None]\n        axis_labels[axis] = labels\n        all_labels.extend(labels)\n\n    decisions = apply_study_decisions(all_labels)\n    anomaly_share = float(sum(all_labels) / len(all_labels)) if all_labels else 0.0\n\n    h, w, d = volume.shape\n    available_slices = {\n        \"axial\": int(d),\n        \"sagittal\": int(w),\n        \"coronal\": int(h),\n    }\n    used_slices = {axis: len(axis_indices.get(axis, [])) for axis in CONFIG[\"AXES_TO_EVAL\"]}\n\n    study_row = {\n        \"patient_id\": patient_id,\n        \"series_id\": series_id,\n        \"series_dir\": series_dir,\n        \"axes\": CONFIG[\"AXES_TO_EVAL\"],\n        \"volume_shape_hwd\": [int(h), int(w), int(d)],\n        \"available_slices\": available_slices,\n        \"used_slices\": used_slices,\n        \"axis_indices\": axis_indices,\n        \"axis_labels\": axis_labels,\n        \"decisions\": decisions,\n        \"anomaly_share\": anomaly_share,\n    }\n    return study_row, slice_rows\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-22T20:25:57.255015Z","iopub.execute_input":"2026-03-22T20:25:57.255525Z","iopub.status.idle":"2026-03-22T20:25:57.292395Z","shell.execute_reply.started":"2026-03-22T20:25:57.255501Z","shell.execute_reply":"2026-03-22T20:25:57.29144Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% [code] Run\n\nos.makedirs(CONFIG[\"WORK_DIR\"], exist_ok=True)\n\nif \"train_df\" in globals():\n    labels_df = train_df.copy()\nelse:\n    labels_df = pd.read_csv(CONFIG[\"TRAIN_LABELS_CSV\"])\n\nlabels_df[\"patient_id\"] = labels_df[\"patient_id\"].astype(str)\n\nselect_df = pd.read_csv(CONFIG[\"SELECTION_CSV\"])\nselect_df[\"patient_id\"] = select_df[\"patient_id\"].astype(str)\n\nif \"any_injury\" not in select_df.columns:\n    select_df = select_df.merge(\n        labels_df[[\"patient_id\", \"any_injury\"]],\n        on=\"patient_id\",\n        how=\"left\",\n    )\n\nselect_df[\"any_injury\"] = select_df[\"any_injury\"].astype(int)\nselect_df = select_df.sort_values(\"patient_id\").reset_index(drop=True)\n\nneg_df = select_df[select_df[\"any_injury\"] == 0].head(CONFIG['N_SAMPLES']).copy()\npos_df = select_df[select_df[\"any_injury\"] == 1].head(CONFIG['N_SAMPLES']).copy()\neval_patients_df = pd.concat([neg_df, pos_df], ignore_index=True)\n\nplan_rows = []\nmissing_patients = []\n\nfor _, row in eval_patients_df.iterrows():\n    patient_id = str(row[\"patient_id\"])\n    patient_root = os.path.join(CONFIG[\"DICOM_ROOT\"], patient_id)\n    series_dir, n_dicoms = choose_series_with_max_dicoms(patient_root)\n\n    if series_dir is None:\n        missing_patients.append(patient_id)\n        continue\n\n    plan_rows.append(\n        {\n            \"patient_id\": patient_id,\n            \"target\": int(row[\"any_injury\"]),\n            \"series_id\": os.path.basename(series_dir.rstrip(\"/\")),\n            \"series_dir\": series_dir,\n            \"n_dicoms\": int(n_dicoms),\n        }\n    )\n\nplan_df = pd.DataFrame(plan_rows)\n\nprint(\"Requested patients:\", len(eval_patients_df))\nprint(\"Resolved studies:\", len(plan_df))\nif missing_patients:\n    print(\"Missing patients:\", len(missing_patients))\n\npipe_obj = build_medgemma_pipeline()\n\nstudy_rows = []\nslice_rows_all = []\n\nfor _, row in tqdm(plan_df.iterrows(), total=len(plan_df), desc=\"Abdominal DICOM studies\"):\n    study_row, slice_rows = score_one_study(\n        patient_id=str(row[\"patient_id\"]),\n        series_dir=str(row[\"series_dir\"]),\n        pipe_obj=pipe_obj,\n    )\n    study_row[\"target\"] = int(row[\"target\"])\n    study_rows.append(study_row)\n    slice_rows_all.extend(slice_rows)\n\nstudy_results_df = pd.DataFrame(study_rows)\nslice_results_df = pd.DataFrame(slice_rows_all)\n\nplan_csv = os.path.join(CONFIG[\"WORK_DIR\"], \"plan_154.csv\")\nstudy_csv = os.path.join(CONFIG[\"WORK_DIR\"], \"study_outputs_154.csv\")\nslice_csv = os.path.join(CONFIG[\"WORK_DIR\"], \"slice_outputs_154.csv\")\n\nplan_df.to_csv(plan_csv, index=False)\nstudy_results_df.to_csv(study_csv, index=False)\nslice_results_df.to_csv(slice_csv, index=False)\n\nprint(\"Saved:\")\nprint(plan_csv)\nprint(study_csv)\nprint(slice_csv)\n\ndisplay(plan_df.head())\ndisplay(study_results_df.head())\ndisplay(slice_results_df.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-22T20:25:57.293768Z","iopub.execute_input":"2026-03-22T20:25:57.294117Z","iopub.status.idle":"2026-03-22T20:31:38.296958Z","shell.execute_reply.started":"2026-03-22T20:25:57.294087Z","shell.execute_reply":"2026-03-22T20:31:38.296111Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# %% [code] Metrics\n\nmerged_df = labels_df[[\"patient_id\", \"any_injury\"]].merge(\n    study_results_df[[\"patient_id\", \"series_id\", \"anomaly_share\", \"target\"]],\n    on=\"patient_id\",\n    how=\"inner\",\n)\n\ny_true = merged_df[\"any_injury\"].astype(float)\ny_score = merged_df[\"anomaly_share\"].astype(float)\n\nroc = roc_auc_score(y_true, y_score) if y_true.nunique() > 1 else np.nan\n\nbest_f1 = 0.0\nbest_thr = 0.5\nbest_prec = 0.0\nbest_rec = 0.0\n\nfor thr in np.linspace(0, 1, 101):\n    y_pred = (y_score >= thr).astype(int)\n    prec, rec, f1, _ = precision_recall_fscore_support(\n        y_true, y_pred, average=\"binary\", zero_division=0\n    )\n    if f1 > best_f1:\n        best_f1 = float(f1)\n        best_thr = float(thr)\n        best_prec = float(prec)\n        best_rec = float(rec)\n\nmetrics_df = pd.DataFrame(\n    [\n        {\n            \"metric\": \"any_injury_overall\",\n            \"roc_auc\": roc,\n            \"best_threshold_f1\": best_thr,\n            \"precision_at_best_f1\": best_prec,\n            \"recall_at_best_f1\": best_rec,\n            \"f1_at_best_f1\": best_f1,\n            \"n\": len(merged_df),\n            \"positives\": int(y_true.sum()),\n        }\n    ]\n)\n\nmetrics_csv = os.path.join(CONFIG[\"WORK_DIR\"], \"metrics_154.csv\")\nmerged_csv = os.path.join(CONFIG[\"WORK_DIR\"], \"merged_eval_154.csv\")\n\nmetrics_df.to_csv(metrics_csv, index=False)\nmerged_df.to_csv(merged_csv, index=False)\n\ndisplay(metrics_df)\n\nprint(\"Saved:\")\nprint(metrics_csv)\nprint(merged_csv)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-22T20:31:38.297758Z","iopub.execute_input":"2026-03-22T20:31:38.298028Z","iopub.status.idle":"2026-03-22T20:31:38.52516Z","shell.execute_reply.started":"2026-03-22T20:31:38.297994Z","shell.execute_reply":"2026-03-22T20:31:38.524531Z"}},"outputs":[],"execution_count":null}]}