{"cells":[{"cell_type":"markdown","id":"0ce6ae74","metadata":{},"source":"# RSNA Knee Abnormalities Detection: What the data tells us\n\nThe [2026 RSNA Knee Abnormality Detection AI Challenge](https://www.kaggle.com/competitions/rsna-knee-abnormality-detection/discussion/733343) asks for models\nthat detect twelve clinically important abnormalities on knee MRI. It is the first RSNA AI Challenge to pair every imaging study with its original radiology report,\nacross a large, multilingual, international dataset, so models can learn from both the images and the report text, as knee MRI is read and reported in practice.\n\nThe stakes are broad. The knee is one of the most injured and most imaged joints, and the most common site of osteoarthritis, which affects nearly a quarter\nof adults aged 40 and older (about 654 million people). Knee injuries make up an estimated 15–40% of sports injuries.\n\nThis notebook covers what one training example is, where the targets come from, what the images look like, which header fields can be trusted,\nand what that means for a model.\n\n<a id=\"competition\"></a>\n\n### The competition in one screen\n\n| | |\n|---|---|\n| **Predict** | a confidence for each of 12 findings, one row per study: ACL, MCL, Medial / Lateral Meniscus, Medial / Lateral / PF OA, Effusion, Synovitis, Baker's cyst, Contusion, Fracture |\n| **Score** | mean of the 12 per-finding ROC AUCs. Only the ranking of studies *within* each finding matters; calibration does not, and a rare finding counts as much as a common one |\n| **Train** | 4,407 studies, each with a free-text **report** and its MRI **series**. The 12 finding columns are filled on **58** expert-labelled studies |\n| **Test** | about 1,300 studies at scoring, **images only, no report**. Code runs offline within 9 hours |\n| **Ground truth** | two musculoskeletal radiologists read each test exam independently from the images, a third adjudicates. Borderline calls count as negative; several findings only count above a size or severity threshold |\n\n**The central gap.** Training supervision can be read from what radiologists *wrote*; the score comes from what experts *see*. Most of what follows is about that gap\nand about the shape of the images a model has to read.\n\n### The training data in numbers\n\n```\nPatient ──1 : 1──> Study ──1 : 3–14──> Series ──1 : ~30──> Slice (.dcm)\n 4,407              4,407               24,371              819,078\n```\n\n| Level | Key | Count | Per parent | What carries labels |\n|---|---|---|---|---|\n| Patient | `PatientID` (DICOM header only) | **4,407** | exactly 1 study per patient | — |\n| Study (one MRI exam of one knee) | `StudyInstanceUID` | **4,407** | — | a **report on all 4,407**; the 12 finding columns on **58** |\n| Series (one plane and contrast) | `SeriesInstanceUID` | **24,371** | **3–14, median 5** per study | none: plane and fat-suppression flag only |\n| Slice (one 2D image) | `SOPInstanceUID` | **819,078** files | **~30** per series (median 29–32) | none |\n\n- **Planes:** every study has all three. Sagittal 9,864 series, coronal 8,609, axial 5,898.\n- **Contrast flag:** `Fat_Suppression` equals `Fluid_Sensitive` on all 24,371 series (14,010 FS, 10,361 non-FS), so six series types = plane × FS.\n- **Test at scoring:** about 1,300 studies, images only.\n\nNo label points to a series or a slice: the model gets 12 study-level targets and has to find the evidence across 3–14 stacks by itself."},{"cell_type":"markdown","id":"3427d7df","metadata":{},"source":"## Setup\n\nNeeds the competition data and the report-label dataset listed under Credits. The header scan reads three slice files per training series (no pixels),\nabout 73 thousand files, in about 3 minutes; slice counts come from the folder listing."},{"cell_type":"code","execution_count":null,"id":"73b69223","metadata":{"_kg_hide-input":true},"outputs":[],"source":"import glob, html, importlib, importlib.util, os, re, subprocess, sys, time\nimport multiprocessing as mp\nfrom concurrent.futures import ProcessPoolExecutor\nfrom pathlib import Path\n\nfor package in ('duckdb', 'polars', 'plotly'):\n    if importlib.util.find_spec(package) is None:\n        subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', package], check=False)\n\nimport duckdb\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport plotly.express as px\nimport polars as pl\nimport pydicom\nfrom IPython.display import HTML, display\nfrom matplotlib.colors import LinearSegmentedColormap\n\nTEAL, GREY, AMBER, INK = '#0F6E78', '#B8C4C6', '#E3B75A', '#15212B'\nPLANE_COLOUR = {'Sagittal': '#2D65A8', 'Coronal': '#B0502F', 'Axial': '#5F7F22'}\nplt.rcParams.update({'figure.dpi': 110, 'font.size': 9, 'axes.titlesize': 9})\n\n\ndef find_input(pattern):\n    # First file under /kaggle/input matching the pattern, or None.\n    hits = sorted(glob.glob(f'/kaggle/input/**/{pattern}', recursive=True))\n    return Path(hits[0]) if hits else None\n\n\ndef find_data():\n    # Competition data: RSNA_KNEE_DATA if set, else the attached Kaggle input, else a data/ folder at or above the working directory.\n    if os.environ.get('RSNA_KNEE_DATA'):\n        return Path(os.environ['RSNA_KNEE_DATA'])\n    if find_input('train_series.csv'):\n        return find_input('train_series.csv').parent\n    for folder in (Path.cwd(), *Path.cwd().parents):\n        if (folder / 'data' / 'train_series.csv').exists():\n            return folder / 'data'\n    raise FileNotFoundError('train_series.csv not found: attach the competition data or set RSNA_KNEE_DATA')\n\n\nDATA = find_data()\nOUT = Path('/kaggle/working') if Path('/kaggle/working').exists() else Path(os.environ.get('OUT', 'out'))\nOUT.mkdir(parents=True, exist_ok=True)\n\ncon = duckdb.connect()\nfor name in ('train', 'train_series', 'test', 'test_series'):\n    con.execute(f\"CREATE OR REPLACE VIEW {name} AS SELECT * FROM read_csv('{DATA / (name + '.csv')}', header = true)\")\ncon.execute('''\n    CREATE OR REPLACE VIEW ts AS\n    SELECT *, Anatomical_Plane || '_' || CASE WHEN Fat_Suppression = 1 THEN 'FS' ELSE 'nonFS' END AS series_type\n    FROM train_series\n''')\n\n\ndef q(sql):\n    # Run SQL in DuckDB and return a Polars DataFrame.\n    return con.sql(sql).pl()\n\n\nFINDINGS = [c for c in q('DESCRIBE train')['column_name'] if c not in ('StudyInstanceUID', 'Report')]\nGROUPS = {'trauma': ['ACL', 'MCL', 'Contusion', 'Fracture'],\n          'degenerative': ['Medial Meniscus', 'Lateral Meniscus', 'Medial OA', 'Lateral OA', 'PF OA'],\n          'fluid': ['Effusion', 'Synovitis', \"Baker's\"]}\nGROUP_ORDER = [f for group in GROUPS.values() for f in group if f in FINDINGS]"},{"cell_type":"code","execution_count":null,"id":"354d147a","metadata":{"_kg_hide-input":true},"outputs":[],"source":"HEAT = LinearSegmentedColormap.from_list('heat', ['#FFFFFF', '#BFE0E3', '#5FA8B0'])\nTABLE_STYLE = [\n    {'selector': '', 'props': 'border-collapse: collapse; margin: 6px 0 22px 0; font-size: 13px; font-family: \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;'},\n    {'selector': 'caption', 'props': f'caption-side: top; text-align: left; color: {INK}; font-size: 15px; padding: 0 0 8px 2px;'},\n    {'selector': 'th', 'props': f'background: {TEAL}; color: #FFFFFF; font-weight: 600; text-align: left; padding: 7px 14px; border: none; white-space: nowrap;'},\n    {'selector': 'td', 'props': f'color: {INK}; text-align: left; padding: 6px 14px; border-bottom: 1px solid #E1E7E8; font-variant-numeric: tabular-nums; max-width: 520px;'},\n    {'selector': 'tbody tr:nth-child(even) td', 'props': 'background-color: #F5F8F8;'},\n]\n\n\ndef show(df, title, subtitle=None, percent=(), heat=(), highlight=None):\n    # Render a Polars DataFrame as a styled table.\n    #   percent:   columns holding fractions (0-1), shown as percentages with an in-cell bar\n    #   heat:      other numeric columns, shaded on one shared colour scale\n    #   highlight: optional boolean column name; rows where it is true are shown bold (the column itself is hidden)\n    label = lambda c: c.replace('_', ' ')\n    integers = [label(c) for c, t in df.schema.items() if t.is_integer()]\n    decimals = [label(c) for c, t in df.schema.items() if t.is_float() and c not in percent]\n    percent = [label(c) for c in percent]\n    heat = [label(c) for c in heat if label(c) not in percent]\n\n    frame = df.to_pandas()\n    frame.columns = [label(c) for c in frame.columns]\n    formats = {**{c: '{:,.0f}' for c in integers}, **{c: '{:,.2f}' for c in decimals}, **{c: '{:.1%}' for c in percent}}\n    caption = f'<b>{html.escape(title)}</b>' + (f'<br><span style=\"color:#5B6B70;font-size:12px\">{html.escape(subtitle)}</span>' if subtitle else '')\n\n    styler = (frame.style.hide(axis='index').format(formats, na_rep='–').set_caption(caption).set_table_styles(TABLE_STYLE)\n                   .set_properties(subset=integers + decimals + percent, **{'text-align': 'right'}))\n    if percent:\n        styler = styler.bar(subset=percent, vmin=0, vmax=1, color='#A9D4D8', height=70, width=100)\n    if heat:\n        values = frame[heat].apply(pd.to_numeric, errors='coerce').to_numpy(dtype=float)\n        if np.isfinite(values).any() and np.nanmax(values) > np.nanmin(values):\n            styler = styler.background_gradient(subset=heat, cmap=HEAT, vmin=np.nanmin(values), vmax=np.nanmax(values))\n    if 'result' in frame.columns:\n        styler = styler.map(lambda v: 'color: #1E7B3A; font-weight: 600' if str(v).startswith('✓') else 'color: #B0502F; font-weight: 600',\n                            subset=['result'])\n    if highlight:\n        key = label(highlight)\n        styler = styler.apply(lambda row: ['font-weight: 700; background-color: #FBF3DF' if row[key] else '' for _ in row], axis=1).hide([key], axis='columns')\n    display(styler)\n\n\ndef chart(fig, title, subtitle=None, height=380):\n    # Show a Plotly figure with the notebook's title style; hover shows exact values.\n    fig.update_layout(template='simple_white', height=height, title_x=0.01, margin=dict(l=10, r=10, t=70 if subtitle else 50, b=10),\n                      title=f'<b>{title}</b>' + (f'<br><sup>{subtitle}</sup>' if subtitle else ''),\n                      font=dict(family='Segoe UI, Roboto, Helvetica, Arial, sans-serif', size=12, color=INK), legend_title_text='')\n    display(HTML(fig.to_html(include_plotlyjs='cdn', full_html=False, config={'displaylogo': False})))\n\n\ndef takeaway(text, label='Finding'):\n    # A short finding under a table or chart; values are computed in the cell that calls it.\n    display(HTML(f'<div style=\"border-left:4px solid {TEAL};background:#EDF5F6;color:{INK};padding:10px 16px;margin:4px 0 22px 0;'\n                 f'font-size:14px;line-height:1.5;max-width:960px\"><b>{label}.</b> {text}</div>'))\n\n\ndef for_the_model(text):\n    display(HTML(f'<div style=\"border-left:4px solid {AMBER};background:#FBF5E6;color:{INK};padding:10px 16px;margin:4px 0 22px 0;'\n                 f'font-size:14px;line-height:1.5;max-width:960px\"><b>For a model.</b> {text}</div>'))\n\n\ndef contrast_expr():\n    # Contrast from the headers: fat-suppressed series are fluid-weighted (PD or T2 FS); otherwise short TR and TE -> T1, moderate TE -> PD, long TE -> T2.\n    te, tr = pl.col('EchoTime'), pl.col('RepetitionTime')\n    return (pl.when(pl.col('Fat_Suppression') == 1).then(pl.lit('PD/T2 FS'))\n              .when(te.is_null()).then(pl.lit('unknown'))\n              .when((tr < 1000) & (te < 30)).then(pl.lit('T1'))\n              .when(te < 60).then(pl.lit('PD'))\n              .otherwise(pl.lit('T2')))"},{"cell_type":"code","execution_count":null,"id":"1e03854e","metadata":{"_kg_hide-input":true},"outputs":[],"source":"# Header scan, training series only: three files per series (first, middle and last in the folder listing) are opened with stop_before_pixels=True and a\n# fixed tag set is read. The slice count comes from the listing. Spacing and InstanceNumber direction need only two slices with their positions and\n# instance numbers, so three are enough. Results are shown in sections 1, 2, 5 and 6.\nTAGS = [\n    'PatientID', 'StudyInstanceUID', 'SeriesInstanceUID', 'SeriesNumber', 'SeriesTime', 'AcquisitionTime', 'SOPInstanceUID', 'InstanceNumber',\n    'FrameOfReferenceUID', 'SeriesDescription', 'ImageType', 'ScanningSequence', 'MRAcquisitionType', 'EchoTime', 'RepetitionTime', 'InversionTime',\n    'SliceThickness', 'SpacingBetweenSlices', 'ImagePositionPatient', 'ImageOrientationPatient', 'PixelSpacing', 'Rows', 'Columns',\n    'Manufacturer', 'ManufacturerModelName', 'MagneticFieldStrength', 'Laterality',\n]\nCOLUMNS = ['split', 'dir_study', 'dir_series', 'file', 'files_in_folder', 'error', *TAGS]\nFILES_PER_SERIES = 3\n\n\ndef tag_text(value):\n    if value is None:\n        return None\n    if isinstance(value, (pydicom.multival.MultiValue, list, tuple)):\n        text = '\\\\'.join(str(v) for v in value)\n    else:\n        text = str(value)\n    return text.strip() or None\n\n\ndef read_header(job):\n    study, series, path, files_in_folder = job\n    row = dict.fromkeys(COLUMNS)\n    row.update(split='train', dir_study=study, dir_series=series, file=os.path.basename(path), files_in_folder=str(files_in_folder))\n    try:\n        ds = pydicom.dcmread(path, stop_before_pixels=True, specific_tags=TAGS)\n        for tag in TAGS:\n            row[tag] = tag_text(ds.get(tag))\n    except Exception as e:\n        row['error'] = repr(e)\n    return row\n\n\ndef spread(names, k):\n    # k names evenly spaced through the sorted listing (all of them if there are k or fewer).\n    names = sorted(names)\n    return names if len(names) <= k else [names[round(i * (len(names) - 1) / (k - 1))] for i in range(k)]\n\n\ndef scan_jobs():\n    root = DATA / 'train_series'\n    jobs = []\n    for study in (os.scandir(root) if root.is_dir() else []):\n        for series in (s for s in os.scandir(study.path) if s.is_dir()):\n            names = [n for n in os.listdir(series.path) if n.endswith('.dcm')]\n            jobs += [(study.name, series.name, os.path.join(series.path, n), len(names)) for n in spread(names, FILES_PER_SERIES)]\n    return jobs\n\n\nstart = time.time()\njobs = scan_jobs()\nworkers = max(2 * (os.cpu_count() or 2), 4)\nwith ProcessPoolExecutor(workers, mp_context=mp.get_context('fork')) as pool:\n    rows = list(pool.map(read_header, jobs, chunksize=64))\nSCAN_S = time.time() - start\nheaders = pl.DataFrame(rows, schema={c: pl.Utf8 for c in COLUMNS})\nheaders.write_parquet(OUT / 'headers.parquet')\ncon.register('f', headers.to_arrow())\nprint(f'header scan: {headers.height:,} files from {headers[\"dir_series\"].n_unique():,} series in {headers[\"dir_study\"].n_unique():,} studies, {SCAN_S / 60:.1f} min')"},{"cell_type":"code","execution_count":null,"id":"191b5680","metadata":{"_kg_hide-input":true},"outputs":[],"source":"# One row per series from the files read: slice normal from ImageOrientationPatient, position along it, spacing as distance per InstanceNumber step\n# between the files read, and whether InstanceNumber runs with or against position. Slice count is the number of files in the folder.\ndef split_values(column, names):\n    return pl.col(column).str.split_exact('\\\\', len(names) - 1).struct.rename_fields(names).alias(f'{column}_parts')\n\n\ndef seconds(column):\n    t = pl.col(column).str.replace_all(':', '')\n    return (t.str.slice(0, 2).cast(pl.Float64, strict=False) * 3600 + t.str.slice(2, 2).cast(pl.Float64, strict=False) * 60\n            + t.str.slice(4).cast(pl.Float64, strict=False))\n\n\nNUMERIC = ['EchoTime', 'RepetitionTime', 'InversionTime', 'SliceThickness', 'SpacingBetweenSlices', 'MagneticFieldStrength', 'Rows', 'Columns',\n           'SeriesNumber', 'InstanceNumber', 'files_in_folder']\nORIENT, POSITION, SPACING = ['r0', 'r1', 'r2', 'c0', 'c1', 'c2'], ['px', 'py', 'pz'], ['ps_row', 'ps_col']\nslices = (\n    q('SELECT * FROM f WHERE error IS NULL')\n    .with_columns(split_values('ImageOrientationPatient', ORIENT), split_values('ImagePositionPatient', POSITION), split_values('PixelSpacing', SPACING))\n    .unnest('ImageOrientationPatient_parts', 'ImagePositionPatient_parts', 'PixelSpacing_parts')\n    .with_columns(pl.col(ORIENT + POSITION + SPACING + NUMERIC).cast(pl.Float64, strict=False), acquisition_s=seconds('AcquisitionTime'))\n    .with_columns(nx=pl.col('r1') * pl.col('c2') - pl.col('r2') * pl.col('c1'),\n                  ny=pl.col('r2') * pl.col('c0') - pl.col('r0') * pl.col('c2'),\n                  nz=pl.col('r0') * pl.col('c1') - pl.col('r1') * pl.col('c0'))\n    .with_columns(position=pl.col('px') * pl.col('nx') + pl.col('py') * pl.col('ny') + pl.col('pz') * pl.col('nz'))\n)\n\nfirst = lambda column: pl.col(column).drop_nulls().first().alias(column)\nFIRST_OF_SERIES = ['PatientID', 'SeriesDescription', 'ImageType', 'Manufacturer', 'ManufacturerModelName', 'Laterality', 'EchoTime', 'RepetitionTime',\n                   'InversionTime', 'MagneticFieldStrength', 'SeriesNumber']\nseries_hdr = (\n    slices.sort('SeriesInstanceUID', 'InstanceNumber')\n    .with_columns(gap=(pl.col('position').diff().abs() / pl.col('InstanceNumber').diff().abs()).over('SeriesInstanceUID'))\n    .with_columns(gap=pl.when(pl.col('gap').is_finite()).then('gap'))\n    .group_by('split', 'StudyInstanceUID', 'SeriesInstanceUID')\n    .agg(n_slices=pl.col('files_in_folder').max().cast(pl.Int64), files_read=pl.len(), n_orientations=pl.col('ImageOrientationPatient').n_unique(),\n         *[first(c) for c in FIRST_OF_SERIES],\n         acquisition_start_s=pl.col('acquisition_s').min(),\n         instance_position_corr=pl.corr('InstanceNumber', 'position'),\n         nx=pl.col('nx').median(), ny=pl.col('ny').median(), nz=pl.col('nz').median(),\n         pixel_mm=pl.col('ps_row').median(), rows=pl.col('Rows').median(), cols=pl.col('Columns').median(),\n         thickness_mm=pl.col('SliceThickness').median(), spacing_mm=pl.col('gap').median())\n    .with_columns(\n        coverage_mm=pl.col('spacing_mm') * (pl.col('n_slices') - 1),\n        fov_mm=pl.col('pixel_mm') * pl.max_horizontal('rows', 'cols'),\n        obliquity_deg=pl.max_horizontal(pl.col('nx').abs(), pl.col('ny').abs(), pl.col('nz').abs()).clip(0, 1).arccos().degrees(),\n        header_plane=pl.when((pl.col('nx').abs() >= pl.col('ny').abs()) & (pl.col('nx').abs() >= pl.col('nz').abs())).then(pl.lit('Sagittal'))\n                       .when(pl.col('ny').abs() >= pl.col('nz').abs()).then(pl.lit('Coronal')).otherwise(pl.lit('Axial')),\n        instance_order=pl.when(pl.col('instance_position_corr') < 0).then(pl.lit('reversed')).otherwise(pl.lit('with position')),\n        field_T=pl.when(pl.col('MagneticFieldStrength') > 100).then(pl.col('MagneticFieldStrength') / 10_000).otherwise(pl.col('MagneticFieldStrength')).round(1),\n        vendor=pl.col('Manufacturer').str.to_uppercase().str.extract(r'(SIEMENS|GE|PHILIPS|TOSHIBA|CANON|HITACHI)').fill_null('OTHER'),\n        derived=pl.col('ImageType').str.to_uppercase().str.contains('DERIVED|MPR|REFORMAT|PROJECTION').fill_null(False))\n)\ncsv_series = q('SELECT SeriesInstanceUID, Anatomical_Plane, Fat_Suppression, series_type FROM ts')\nseries_hdr = series_hdr.join(csv_series, on='SeriesInstanceUID', how='left').with_columns(contrast=contrast_expr())\nseries_hdr.write_parquet(OUT / 'series_headers.parquet')\ntrain_hdr = series_hdr.filter(pl.col('split') == 'train')"},{"cell_type":"markdown","id":"3bdb7031","metadata":{},"source":"<a id=\"section-1\"></a>\n\n## 1. One training example is a study: a report, about five series, about thirty slices each, twelve targets\n\nTraining data is made of **studies**. Each study has one radiology **report** and a set of **series**; a series is one MRI stack, stored as a folder with one DICOM file per slice.\n\n```\nPatient ─1:1─> Study ──────────────1 : 3–14─────────> Series ──1 : ~30──> Slice\n               train.csv (1 row)                      train_series.csv   train_series/<Study>/<Series>/<SOP>.dcm\n               ├─ StudyInstanceUID  ──── join key ──> ├─ StudyInstanceUID\n               ├─ Report (free text)                  ├─ SeriesInstanceUID ─> folder name\n               └─ 12 finding columns                  ├─ Anatomical_Plane: Sagittal / Coronal / Axial\n                  (filled on 58 studies)              └─ Fat_Suppression (Fluid_Sensitive is identical)\n```"},{"cell_type":"code","execution_count":null,"id":"b0075bad","metadata":{"_kg_hide-input":true},"outputs":[],"source":"schema = pl.concat([\n    q(f'DESCRIBE {name}').select(file=pl.lit(f'{name}.csv'), column=pl.col('column_name'))\n    for name in ('train', 'train_series', 'test', 'test_series')\n])\nfiles = (schema.group_by('file', maintain_order=True).agg(columns=pl.col('column').str.join(', '))\n           .join(q('''\n                SELECT 'train.csv' AS file, count(*) AS rows, 'study' AS one_row_is FROM train\n                UNION ALL SELECT 'train_series.csv', count(*), 'series' FROM train_series\n                UNION ALL SELECT 'test.csv', count(*), 'study' FROM test\n                UNION ALL SELECT 'test_series.csv', count(*), 'series' FROM test_series'''), on='file'))\nshow(files.select('file', 'one_row_is', 'rows', 'columns'), 'The four CSV files',\n     subtitle='The scoring test set has about 1,300 studies and no report: test.csv carries only the study ID')\n\nlevels = q('''\n    WITH per_study AS (SELECT StudyInstanceUID, count(*) AS n FROM ts GROUP BY 1)\n    SELECT (SELECT count(*) FROM train) AS studies, (SELECT count(*) FROM ts) AS series,\n           (SELECT min(n) FROM per_study) AS min_series, (SELECT median(n) FROM per_study) AS median_series, (SELECT max(n) FROM per_study) AS max_series\n''').row(0, named=True)\nhdr_counts = q(\"SELECT count(DISTINCT PatientID) AS patients, count(DISTINCT dir_study) AS studies FROM f WHERE error IS NULL\").row(0, named=True)\nmedian_slices = train_hdr['n_slices'].median()\nshow(pl.DataFrame({\n    'level': ['Patient', 'Study', 'Series', 'Slice'],\n    'count': [hdr_counts['patients'], levels['studies'], levels['series'], int(train_hdr['n_slices'].sum())],\n    'each_holds': ['1 study', f\"{levels['min_series']}–{levels['max_series']} series, median {levels['median_series']:.0f}\",\n                   f'median {median_slices:.0f} slices' if median_slices else '–', 'one 2D image + header'],\n    'where': ['PatientID tag (files read)', 'train.csv', 'train_series.csv', '.dcm files in the series folders'],\n}), 'The hierarchy, counted', subtitle=f\"Patient and slice counts come from the {hdr_counts['studies']:,} study folders present\")"},{"cell_type":"code","execution_count":null,"id":"a7280c33","metadata":{"_kg_hide-input":true},"outputs":[],"source":"def join_checks(labels, series):\n    return q(f'''\n        SELECT '{labels}' AS split,\n          (SELECT count(*) FROM (SELECT StudyInstanceUID FROM {labels} EXCEPT SELECT StudyInstanceUID FROM {series})) AS studies_without_series,\n          (SELECT count(*) FROM (SELECT StudyInstanceUID FROM {series} EXCEPT SELECT StudyInstanceUID FROM {labels})) AS series_of_unlisted_studies,\n          (SELECT count(*) FROM (SELECT StudyInstanceUID FROM {labels} GROUP BY 1 HAVING count(*) > 1)) AS duplicate_study_rows,\n          (SELECT count(*) FROM (SELECT SeriesInstanceUID FROM {series} GROUP BY 1 HAVING count(DISTINCT StudyInstanceUID) > 1)) AS series_in_two_studies\n    ''')\n\n\noverlap = q('SELECT count(*) FROM (SELECT StudyInstanceUID FROM train INTERSECT SELECT StudyInstanceUID FROM test)').item()\ncsv_checks = (pl.concat([join_checks('train', 'train_series'), join_checks('test', 'test_series')])\n                .unpivot(index='split', variable_name='check', value_name='count')\n                .group_by('check', maintain_order=True).agg(count=pl.col('count').sum())\n                .vstack(pl.DataFrame({'check': ['studies_in_both_train_and_test'], 'count': [overlap]}, schema={'check': pl.Utf8, 'count': pl.Int64}))\n                .with_columns(source=pl.lit('CSV')))\n\nLINKS = [('study → patients', 'StudyInstanceUID', 'PatientID'), ('patient → studies', 'PatientID', 'StudyInstanceUID'),\n         ('series → studies', 'SeriesInstanceUID', 'StudyInstanceUID'), ('slice → series', 'SOPInstanceUID', 'SeriesInstanceUID')]\nheader_checks = pl.concat([\n    q(f'''SELECT '{name} (more than one)' AS \"check\", count(*) AS count, 'headers' AS source\n          FROM (SELECT {parent} FROM f WHERE error IS NULL AND {parent} IS NOT NULL GROUP BY 1 HAVING count(DISTINCT {child}) > 1)''')\n    for name, parent, child in LINKS\n] + [q('''SELECT 'slice tags that disagree with their folder' AS \"check\",\n                 sum((StudyInstanceUID <> dir_study OR SeriesInstanceUID <> dir_series)::INT) AS count, 'headers' AS source\n          FROM f WHERE error IS NULL''')], how='vertical_relaxed')\nchecks = (pl.concat([csv_checks, header_checks], how='vertical_relaxed')\n            .with_columns(check=pl.col('check').str.replace_all('_', ' '),\n                          result=pl.when(pl.col('count') == 0).then(pl.lit('✓ none')).otherwise(pl.lit('✗ look closer'))))\nshow(checks.select('source', 'check', 'count', 'result'), 'Does the hierarchy hold?',\n     subtitle=f'every count should be 0; header checks use the {FILES_PER_SERIES} files read per series')\ntakeaway(f\"{int((checks['count'] == 0).sum())} of {checks.height} checks hold. Every series belongs to exactly one study, every study has series, \"\n         'each patient has one study, and the identifiers inside the slice files agree with their folders.')\nfor_the_model('A prediction is made per study, from all of its series. Because a patient never appears in two studies, folds split by '\n              '<code>StudyInstanceUID</code> cannot leak a patient across train and validation.')"},{"cell_type":"markdown","id":"454185ec","metadata":{},"source":"<a id=\"section-2\"></a>\n\n## 2. One study, opened end to end\n\nCounts are easier to read with one concrete exam in view. The study is picked by rule, not by hand: it has expert labels, a report in English, the most common set of series types,\nand the most positive findings among those. Below: its labels next to what its report says, the header of each series, the middle slice of every series, and every slice of one series."},{"cell_type":"code","execution_count":null,"id":"0de523c8","metadata":{"_kg_hide-input":true},"outputs":[],"source":"short_uid = lambda uid: '…' + uid[-9:]\nstudies = q('SELECT * FROM train')\nlabelled_ids = studies.filter(pl.all_horizontal([pl.col(c).is_not_null() for c in FINDINGS]))['StudyInstanceUID']\nlayouts = (q('SELECT StudyInstanceUID, series_type FROM ts').group_by('StudyInstanceUID')\n             .agg(layout=pl.col('series_type').sort().str.join(' + '), n_series=pl.len()))\ncommon_layout = layouts['layout'].value_counts().sort(['count', 'layout'], descending=[True, False])['layout'][0]\non_disk = set(os.listdir(DATA / 'train_series')) if (DATA / 'train_series').is_dir() else set()\nENGLISH_WORDS = {'the', 'of', 'and', 'is', 'no', 'with', 'there', 'are', 'normal', 'in', 'tear', 'joint'}\n\n\ndef english_share(report):\n    # Share of words that are common English words; reports in other languages score near 0, English ones above about 0.15.\n    words = re.findall(r'[a-z]+', (report or '').lower())\n    return sum(w in ENGLISH_WORDS for w in words) / max(len(words), 1)\n\n\ncandidates = (studies.join(layouts, on='StudyInstanceUID')\n                .with_columns(n_positive=pl.sum_horizontal([pl.col(c).fill_null(0) for c in FINDINGS]),\n                              has_folder=pl.col('StudyInstanceUID').is_in(list(on_disk)),\n                              labelled=pl.col('StudyInstanceUID').is_in(labelled_ids.to_list()),\n                              english=pl.col('Report').map_elements(english_share, return_dtype=pl.Float64) > 0.15,\n                              common=pl.col('layout') == common_layout)\n                .sort(['has_folder', 'labelled', 'english', 'common', 'n_positive', 'StudyInstanceUID'], descending=[True, True, True, True, True, False]))\nexample = candidates.row(0, named=True)\nexample_uid = example['StudyInstanceUID']\n\n\ndef read_series(folder):\n    # All slices of one series, sorted by position along the slice normal (not by InstanceNumber).\n    datasets = [pydicom.dcmread(p) for p in glob.glob(str(folder / '*.dcm'))]\n    row, col = (np.array(datasets[0].ImageOrientationPatient[:3], float), np.array(datasets[0].ImageOrientationPatient[3:], float))\n    normal = np.cross(row, col)\n    return sorted(datasets, key=lambda d: float(np.dot(np.array(d.ImagePositionPatient, float), normal)))\n\n\ndef pixels(ds):\n    # Decode pixels; if the image is compressed and no decoder is installed, install one (internet on) and retry.\n    try:\n        return ds.pixel_array.astype(np.float32)\n    except Exception:\n        subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', 'pylibjpeg', 'pylibjpeg-libjpeg', 'pylibjpeg-openjpeg'], check=False)\n        importlib.invalidate_caches()\n        return ds.pixel_array.astype(np.float32)\n\n\nexample_series = (q(f\"SELECT * FROM ts WHERE StudyInstanceUID = '{example_uid}'\")\n                    .with_columns(plane_rank=pl.col('Anatomical_Plane').replace_strict({'Sagittal': 0, 'Coronal': 1, 'Axial': 2}, default=3))\n                    .sort('plane_rank', 'Fat_Suppression', descending=[False, True]))\nvolumes = {s: read_series(DATA / 'train_series' / example_uid / s) for s in example_series['SeriesInstanceUID']}\ndisplay(HTML(f'<div style=\"font-size:14px;color:{INK};margin:0 0 10px 0\"><b>Study</b> <code>{example_uid}</code> · '\n             f'{example_series.height} series: {html.escape(example[\"layout\"])} · {example[\"n_positive\"]} expert-positive findings</div>'))"},{"cell_type":"code","execution_count":null,"id":"3019f944","metadata":{"_kg_hide-input":true},"outputs":[],"source":"labels_path = Path(os.environ.get('RSNA_KNEE_LABELS') or find_input('report_labels_v2.csv') or next(DATA.glob('**/report_labels_v2.csv'), ''))\nHAVE_REPORT_LABELS = labels_path.is_file()\nreports = pl.read_csv(labels_path, infer_schema_length=10_000) if HAVE_REPORT_LABELS else None\n\n# Where each finding is read. Host = the competition host's example slides; practice = standard musculoskeletal MRI reading.\nFINDING_PLANES = {\n    'ACL': (['Sagittal', 'Coronal'], 'host'), 'MCL': (['Coronal', 'Axial'], 'host'),\n    'Medial Meniscus': (['Sagittal', 'Coronal'], 'host'), 'Lateral Meniscus': (['Sagittal', 'Coronal'], 'host'),\n    'Medial OA': (['Coronal'], 'host'), 'Lateral OA': (['Coronal'], 'host'), 'PF OA': (['Axial', 'Sagittal'], 'host'),\n    'Effusion': (['Sagittal'], 'host'), \"Baker's\": (['Axial'], 'host'),\n    'Synovitis': (['Sagittal', 'Axial'], 'practice'), 'Contusion': (['Sagittal', 'Coronal'], 'practice (fat-suppressed)'),\n    'Fracture': (['Coronal', 'Sagittal'], 'practice'),\n}\nreport_row = reports.filter(pl.col('StudyInstanceUID') == example_uid).row(0, named=True) if HAVE_REPORT_LABELS and reports.filter(pl.col('StudyInstanceUID') == example_uid).height else {}\nseries_types = example_series.select('Anatomical_Plane', 'series_type').rows()\nstudy_view = pl.DataFrame([{\n    'finding': c,\n    'expert_label': example[c],\n    'report_verdict': report_row.get(f'{c}__verdict'),\n    'report_score': report_row.get(c),\n    'read_on': ' / '.join(FINDING_PLANES[c][0]) + f' ({FINDING_PLANES[c][1]})',\n    'series_in_this_study': ', '.join(t for p, t in series_types if p in FINDING_PLANES[c][0]) or 'none',\n    'positive': example[c] == 1,\n} for c in GROUP_ORDER], schema_overrides={'expert_label': pl.Float64, 'report_score': pl.Float64})\nshow(study_view, 'Its twelve findings: expert label, what the report says, and where each is read',\n     subtitle='expert label from train.csv (1 = present); report verdict and score from the report-read labels (section 3); positives highlighted', highlight='positive')\n\ndisplay(HTML(f'<div style=\"max-width:960px;margin:0 0 22px 0;padding:12px 16px;background:#F5F8F8;border:1px solid #E1E7E8;font-size:13px;line-height:1.55;color:{INK}\">'\n             f'<div style=\"font-size:12px;color:#5B6B70;margin-bottom:6px\">Its report, first 600 of {len(example[\"Report\"] or \"\"):,} characters</div>'\n             f'{html.escape((example[\"Report\"] or \"\")[:600])} …</div>'))"},{"cell_type":"code","execution_count":null,"id":"f4b0e7b9","metadata":{"_kg_hide-input":true},"outputs":[],"source":"example_hdr = (series_hdr.filter(pl.col('StudyInstanceUID') == example_uid)\n                 .join(example_series.select('SeriesInstanceUID', 'plane_rank'), on='SeriesInstanceUID')\n                 .sort('plane_rank', 'Fat_Suppression', descending=[False, True]))\nshow(example_hdr.select('series_type', 'contrast', description='SeriesDescription', slices='n_slices',\n                        matrix=pl.format('{}×{}', pl.col('rows').cast(pl.Int32), pl.col('cols').cast(pl.Int32)),\n                        pixel_mm='pixel_mm', spacing_mm='spacing_mm', coverage_mm='coverage_mm', fov_mm='fov_mm',\n                        instance_number='instance_order', scanner=pl.format('{} {} · {} T', 'vendor', 'ManufacturerModelName', 'field_T'),\n                        laterality=pl.col('Laterality').fill_null('absent')),\n     'Its series, read from the DICOM headers',\n     subtitle='slices = files in the series folder; contrast from echo and repetition time; spacing = distance along the slice normal per InstanceNumber step; '\n              'instance number compared with slice position')\n\nfig, axes = plt.subplots(1, len(volumes), figsize=(2.6 * len(volumes), 3.2), squeeze=False, facecolor='#101418')\nfor ax, s in zip(axes[0], example_hdr.iter_rows(named=True)):\n    stack = volumes[s['SeriesInstanceUID']]\n    image = pixels(stack[len(stack) // 2])\n    lo, hi = np.percentile(image, [1, 99.5])\n    ax.imshow(np.clip((image - lo) / max(hi - lo, 1e-6), 0, 1), cmap='gray')\n    ax.set_title(f\"{s['series_type'].replace('_', ' ')} · {s['contrast']}\\n{s['n_slices']} slices · {s['pixel_mm']:.2f} mm px · {s['spacing_mm']:.1f} mm apart\",\n                 color=PLANE_COLOUR.get(s['Anatomical_Plane'], 'white'), fontsize=8.5)\n    ax.axis('off')\nfig.suptitle('Middle slice of every series (sorted by position, 1st–99.5th percentile window)', color='white', fontsize=10)\nplt.tight_layout(rect=(0, 0, 1, 0.9))\nplt.show()\n\nstrip = example_hdr.filter(pl.col('series_type') == 'Sagittal_FS')\nstrip = strip if strip.height else example_hdr\nstrip_row = strip.row(0, named=True)\nstack = volumes[strip_row['SeriesInstanceUID']]\nimages = [pixels(d) for d in stack]\nlo, hi = np.percentile(np.stack(images), [1, 99.5])\nn_cols = min(10, max(len(images), 5))\nn_rows = int(np.ceil(len(images) / n_cols))\nsize = 14.5 / n_cols\nfig, axes = plt.subplots(n_rows, n_cols, figsize=(size * n_cols, (size + 0.1) * n_rows), squeeze=False, facecolor='#101418')\nfor i, ax in enumerate(axes.flat):\n    ax.axis('off')\n    if i < len(images):\n        ax.imshow(np.clip((images[i] - lo) / max(hi - lo, 1e-6), 0, 1), cmap='gray')\n        ax.set_title(f'{i + 1} · IN {stack[i].InstanceNumber}', color='#C9D3D6', fontsize=7)\nfig.suptitle(f\"All {len(images)} slice files of {strip_row['series_type'].replace('_', ' ')}, in position order (IN = InstanceNumber stored in the file)\", color='white', fontsize=10)\nplt.tight_layout(rect=(0, 0, 1, 0.95))\nplt.show()\n\nslice_range = sorted({int(example_hdr['n_slices'].min()), int(example_hdr['n_slices'].max())})\ntakeaway(f\"This one exam is {example_hdr.height} stacks of {'–'.join(map(str, slice_range))} slices, cut in three directions and with different contrasts. \"\n         f\"Pixels are about {example_hdr['pixel_mm'].median():.2f} mm but slices are {example_hdr['spacing_mm'].median():.1f} mm apart, and \"\n         f\"{int((example_hdr['instance_order'] == 'reversed').sum())} of {example_hdr.height} series store InstanceNumber against slice position. \"\n         'Nothing marks which slice or series shows a finding: the labels belong to the whole exam.', label='What this exam shows')"},{"cell_type":"markdown","id":"2eda18b9","metadata":{},"source":"<a id=\"section-3\"></a>\n\n## 3. Training targets come from reports; the score comes from radiologists\n\nThe 12 finding columns in `train.csv` are filled on only **58** studies. Every one of the 4,407 studies has a report, so a label for each finding can be **read from the report**\nwith a language model. Rather than re-label, this notebook uses the public community dataset\n[RSNA Knee LLM-read report labels](https://www.kaggle.com/datasets/pilkwang/rsna-knee-llm-labels) by pilkwang (`report_labels_v2.csv`, CC0).\nA language model read each report in its original language and returned, per finding:\n\n- **YES**: the report asserts the finding; **NO**: the report denies it or calls the structure normal; **UNK**: the report does not mention it\n- a score for ranking (higher for more emphatic assertions; not a calibrated probability) and a confidence\n\n| Label source | Studies | What it is | Role |\n|---|---|---|---|\n| Report-read labels (community) | all report-bearing studies | what the radiologist *wrote* | the training targets, and out-of-fold validation on every study |\n| `train.csv` finding columns | 58 | expert *image* read, the same kind of truth as the test set | a check on the report labels, never tuned on |\n| Test truth | ~1,300 hidden | two MSK radiologists + adjudicator; borderline = negative; size / severity thresholds | the score |\n\nFindings the host defines with a size or severity threshold: **ACL, MCL, the three OA findings, Effusion and Baker's cyst**. A report mentions such a finding whatever its size, so a report YES can be an image-read negative."},{"cell_type":"code","execution_count":null,"id":"d11c13f9","metadata":{"_kg_hide-input":true},"outputs":[],"source":"if HAVE_REPORT_LABELS:\n    VERDICT = {c: f'{c}__verdict' for c in FINDINGS}\n    n_rep = reports.join(studies, on='StudyInstanceUID', how='semi').height\n    verdicts = (reports.select(list(VERDICT.values())).unpivot(variable_name='finding', value_name='verdict')\n                  .group_by('finding', 'verdict').len().pivot(on='verdict', index='finding', values='len').fill_null(0)\n                  .with_columns(pl.col('finding').str.replace('__verdict', '')).select('finding', 'YES', 'NO', 'UNK').sort('YES', descending=True))\n    verdict_long = (verdicts.unpivot(index='finding', on=['YES', 'NO', 'UNK'], variable_name='verdict', value_name='studies')\n                      .with_columns(share=pl.col('studies') / reports.height))\n    fig = px.bar(verdict_long.to_pandas(), x='share', y='finding', color='verdict', orientation='h', hover_data={'studies': ':,'},\n                 color_discrete_map={'YES': TEAL, 'NO': GREY, 'UNK': AMBER}, labels=dict(share='share of studies', finding=''),\n                 category_orders={'finding': verdicts['finding'].to_list(), 'verdict': ['YES', 'NO', 'UNK']})\n    fig.update_xaxes(tickformat='.0%')\n    chart(fig, 'What the reports say, per finding', f'{reports.height:,} reports ({n_rep:,} of {studies.height:,} training studies) · UNK = the report does not mention it', height=440)\n\n    reports = reports.with_columns(n_yes=pl.sum_horizontal([(pl.col(v) == 'YES').cast(pl.Int8) for v in VERDICT.values()]))\n    yes_per_study = reports.group_by('n_yes').agg(studies=pl.len()).sort('n_yes').with_columns(share=pl.col('studies') / reports.height)\n    fig = px.bar(yes_per_study.cast({'n_yes': pl.Utf8, 'studies': pl.Float64}).to_pandas(), x='n_yes', y='studies', hover_data={'share': ':.1%'},\n                 text=yes_per_study['share'].map_elements('{:.0%}'.format, return_dtype=pl.Utf8).to_list(),\n                 color_discrete_sequence=[TEAL], labels=dict(n_yes='findings the report asserts'))\n    fig.update_traces(textposition='outside', cliponaxis=False)\n    chart(fig, 'Findings per study', 'label = share of studies')\n\n    top = verdicts.row(0, named=True)\n    most_unk = verdicts.sort('UNK', descending=True).row(0, named=True)\n    shares = reports.select(none=(pl.col('n_yes') == 0).mean(), multiple=(pl.col('n_yes') >= 2).mean()).row(0, named=True)\n    takeaway(f\"{top['finding']} is the finding reports assert most ({top['YES'] / reports.height:.0%} of studies). {shares['multiple']:.0%} of reports assert two or more findings \"\n             f\"and {shares['none']:.0%} none, so this is a multi-label task. Silence is common: {most_unk['finding']} goes unmentioned in {most_unk['UNK'] / reports.height:.0%} of reports, \"\n             'and a report that does not mention a finding is not the same as one that denies it.')\nelse:\n    takeaway('Report-label file not attached: add <code>pilkwang/rsna-knee-llm-labels</code> as an input to run this section.')"},{"cell_type":"markdown","id":"4fe9d0fd","metadata":{},"source":"**Like with like: the same 58 studies, read two ways.** For each finding, the expert positive rate is set against what the reports of those same studies say.\nThe right-hand columns follow the expert-positive cases: how often their report says YES, NO, or nothing."},{"cell_type":"code","execution_count":null,"id":"ae250ab1","metadata":{"_kg_hide-input":true},"outputs":[],"source":"if HAVE_REPORT_LABELS:\n    both = studies.filter(pl.col('StudyInstanceUID').is_in(labelled_ids.to_list())).select('StudyInstanceUID', *FINDINGS).join(\n        reports.select('StudyInstanceUID', *VERDICT.values()), on='StudyInstanceUID')\n    rows = []\n    for c in GROUP_ORDER:\n        v, positive = both[VERDICT[c]], both[c] == 1\n        rows.append({'finding': c, 'group': next(g for g, members in GROUPS.items() if c in members),\n                     'expert_positive': int(positive.sum()), 'expert_rate': positive.mean(),\n                     'report_yes_rate': (v == 'YES').mean(), 'report_unk_rate': (v == 'UNK').mean(),\n                     'positives_report_yes': int((positive & (v == 'YES')).sum()),\n                     'positives_report_no': int((positive & (v == 'NO')).sum()),\n                     'positives_report_silent': int((positive & (v == 'UNK')).sum())})\n    agreement = pl.DataFrame(rows)\n    show(agreement, 'Expert image read vs report, on the same studies', subtitle=f'{both.height} studies with both',\n         percent=['expert_rate', 'report_yes_rate', 'report_unk_rate'], heat=['positives_report_no', 'positives_report_silent'])\n    gaps = agreement.filter(pl.col('expert_positive') >= 5).with_columns(missed=(pl.col('positives_report_no') + pl.col('positives_report_silent')) / pl.col('expert_positive')).sort('missed', descending=True)\n    worst = gaps.row(0, named=True)\n    takeaway(f\"The two readings agree on some findings and part ways on others. {worst['finding']} is the widest gap: the report does not assert it in \"\n             f\"{worst['positives_report_no'] + worst['positives_report_silent']} of {worst['expert_positive']} expert-positive studies \"\n             f\"({worst['positives_report_silent']} silent, {worst['positives_report_no']} denied). With {both.height} studies these rates are indicative, not precise.\")\n    for_the_model('Train on report-read labels as <b>soft</b> targets and keep UNK distinct from NO. Measure progress with out-of-fold AUC on all report-labelled studies; '\n                  'use the 58 only to check that the report labels point the right way. Fitting blends, thresholds or label rules on 58 studies overfits.')"},{"cell_type":"markdown","id":"d267f0e7","metadata":{},"source":"**Findings travel together.** A knee injured by a twisting fall tears the ACL and bruises bone; a worn knee shows meniscal tears alongside cartilage loss; fluid findings share a cause.\nThe heatmap is ordered in those three groups. The table gives the strongest pairs as **lift**: how many times more often two findings occur together than if they were independent."},{"cell_type":"code","execution_count":null,"id":"ec70a906","metadata":{"_kg_hide-input":true},"outputs":[],"source":"if HAVE_REPORT_LABELS:\n    Y = reports.select([(pl.col(VERDICT[c]) == 'YES').cast(pl.Float64) for c in GROUP_ORDER]).to_numpy()\n    conditional = (Y.T @ Y) / np.maximum(Y.sum(axis=0)[:, None], 1)\n    fig = px.imshow(conditional, x=GROUP_ORDER, y=GROUP_ORDER, zmin=0, zmax=1, text_auto='.2f', aspect='auto',\n                    color_continuous_scale=['#FFFFFF', '#BFE0E3', TEAL], labels=dict(x='column finding', y='row finding', color='P'))\n    fig.update_xaxes(tickangle=-45, side='bottom')\n    for edge in np.cumsum([len(g) for g in GROUPS.values()])[:-1] - 0.5:\n        fig.add_hline(y=edge, line_color=INK, line_width=1.5)\n        fig.add_vline(x=edge, line_color=INK, line_width=1.5)\n    chart(fig, 'Findings reported together', 'share of studies with the row finding that also have the column finding · lines separate trauma / degenerative / fluid', height=580)\n\n    expert = both.select([pl.col(c).cast(pl.Float64) for c in GROUP_ORDER]).to_numpy()\n\n    def lift(M, i, j):\n        p = M.mean(axis=0)\n        return (M[:, i] * M[:, j]).mean() / (p[i] * p[j]) if p[i] * p[j] > 0 else None\n\n    pairs = pl.DataFrame([\n        {'finding_a': GROUP_ORDER[i], 'finding_b': GROUP_ORDER[j], 'studies_with_both': int((Y[:, i] * Y[:, j]).sum()),\n         'lift_reports': lift(Y, i, j), 'lift_expert_58': lift(expert, i, j)}\n        for i in range(len(GROUP_ORDER)) for j in range(i + 1, len(GROUP_ORDER))\n    ]).filter(pl.col('studies_with_both') >= 20).sort('lift_reports', descending=True)\n    show(pairs.head(10), 'Strongest pairs', subtitle='lift = P(both) / (P(a)·P(b)); 1 = independent · expert column on 58 studies is noisy', heat=['lift_reports'])\n    best = pairs.row(0, named=True)\n    group_of = {f: g for g, members in GROUPS.items() for f in members}\n    top10 = pairs.head(10)\n    within = sum(group_of[a] == group_of[b] for a, b in zip(top10['finding_a'], top10['finding_b']))\n    takeaway(f\"The strongest pair is {best['finding_a']} with {best['finding_b']} (lift {best['lift_reports']:.1f} in reports\"\n             + (f\", {best['lift_expert_58']:.1f} on the expert studies\" if best['lift_expert_58'] is not None else '')\n             + f\"). {within} of the top {top10.height} pairs sit within one group\"\n             + ('; the others cross groups: ' + ', '.join(f'{a}–{b}' for a, b in zip(top10['finding_a'], top10['finding_b']) if group_of[a] != group_of[b]) + '.'\n                if within < top10.height else '.'))\n    for_the_model('Correlated findings can share evidence (auxiliary heads such as any-trauma or ACL-and-contusion). The same correlation is a shortcut risk: '\n                  'a model that learns \"contusion implies ACL\" will rank ACL well on average and fail on the isolated tears the adjudicated test set still contains.')"},{"cell_type":"markdown","id":"ab90865f","metadata":{},"source":"<a id=\"section-4\"></a>\n\n## 4. The input is a variable set of series, not a volume\n\n`train_series.csv` gives each series an anatomical plane and a fat-suppression flag; plane × flag gives six **series types**.\n\n| Plane | View | Slices run | Read for (host guide and standard practice) |\n|---|---|---|---|\n| Sagittal | from the side | inner to outer side of the knee | ACL, menisci (a tear must reach the surface on two consecutive slices), effusion, patellofemoral cartilage |\n| Coronal | from the front | front to back | MCL, medial and lateral menisci and compartments (OA) |\n| Axial | from above | thigh to shin | patellofemoral cartilage, Baker's cyst, MCL oedema |\n\n**Fat suppression** darkens fat so fluid and bone-marrow oedema stand out, which is why `Fluid_Sensitive` equals `Fat_Suppression` on every series.\nContusions, effusion and synovitis are fluid findings; ligament and meniscus shape and bone anatomy read best without suppression."},{"cell_type":"code","execution_count":null,"id":"67b50a5b","metadata":{"_kg_hide-input":true},"outputs":[],"source":"layouts_all = layouts.group_by('layout', 'n_series').agg(studies=pl.len()).sort('studies', descending=True).with_columns(share=pl.col('studies') / layouts.height)\nshow(layouts_all.head(8).with_columns(cumulative=pl.col('share').cum_sum()), 'The most common sets of series types',\n     subtitle=f\"{layouts_all.height:,} distinct sets across {layouts.height:,} studies\", percent=['share', 'cumulative'])\n\ntype_presence = q('''\n    WITH counts AS (SELECT StudyInstanceUID, series_type, count(*) AS n FROM ts GROUP BY ALL),\n         studies AS (SELECT DISTINCT StudyInstanceUID FROM ts), types AS (SELECT DISTINCT series_type FROM ts)\n    SELECT types.series_type, avg((coalesce(n, 0) >= 1)::INT) AS present, avg((coalesce(n, 0) >= 2)::INT) AS repeated, max(coalesce(n, 0)) AS most_in_one_study\n    FROM studies CROSS JOIN types LEFT JOIN counts USING (StudyInstanceUID, series_type)\n    GROUP BY types.series_type ORDER BY types.series_type\n''').join(train_hdr.group_by('series_type').agg(median_slices=pl.col('n_slices').median()), on='series_type', how='left')\nshow(type_presence, 'Each series type: how often present, how often repeated', subtitle='share of training studies; median slices = files per series folder',\n     percent=['present', 'repeated'])\n\nby_n = layouts.group_by('n_series').agg(studies=pl.len()).sort('n_series').with_columns(share=pl.col('studies') / layouts.height)\nfig = px.bar(by_n.cast({'n_series': pl.Utf8, 'studies': pl.Float64}).to_pandas(), x='n_series', y='studies', hover_data={'share': ':.1%'},\n             text=by_n['share'].map_elements(lambda s: f'{s:.0%}' if s >= 0.005 else '', return_dtype=pl.Utf8).to_list(),\n             color_discrete_sequence=[TEAL], labels=dict(n_series='series in the study'))\nfig.update_traces(textposition='outside', cliponaxis=False)\nchart(fig, 'Series per study', f'{layouts.height:,} training studies · label = share')\n\ntop_layout = layouts_all.row(0, named=True)\nrarest = type_presence.sort('present').row(0, named=True)\ntakeaway(f\"The most common set ({top_layout['layout']}) covers {top_layout['share']:.0%} of studies; there are {layouts_all.height:,} distinct sets. \"\n         f\"{rarest['series_type']} is present in only {rarest['present']:.0%} of studies, and every type is repeated in some. \"\n         'A study is a variable collection of stacks, not one fixed tensor.')\nfor_the_model('Give each series type its own slot with a missing-series mask, decide what a repeated type does (keep both, pick one, sample), and route each finding to the planes '\n              'that show it: a model that reads only sagittal series has to see the MCL and Baker\\'s cyst from the wrong direction.')"},{"cell_type":"markdown","id":"af658452","metadata":{},"source":"<a id=\"section-5\"></a>\n\n## 5. The DICOM headers hold what the CSV does not\n\nEvery slice file carries a header: echo and repetition time (which set the contrast), slice position and orientation, pixel size, scanner and side of the body.\nWithin a series these tags are shared, apart from position and instance number, so the scan in Setup read three files per training series without decoding pixels\nand collapsed them to one row per series."},{"cell_type":"code","execution_count":null,"id":"c604b516","metadata":{"_kg_hide-input":true},"outputs":[],"source":"scanned = q(\"SELECT count(DISTINCT dir_study) AS studies, count(DISTINCT dir_series) AS series, count(*) AS files_read, sum((error IS NOT NULL)::INT) AS read_errors FROM f\")\nshow(scanned.with_columns(slice_files_in_folders=pl.lit(int(train_hdr['n_slices'].sum()))), 'Header scan',\n     subtitle=f'{FILES_PER_SERIES} files per series · {SCAN_S / 60:.1f} min on {workers} worker processes')\n\nplane_pairs = train_hdr.group_by('Anatomical_Plane', 'header_plane').len()\nplane_agree = int(plane_pairs.filter(pl.col('Anatomical_Plane') == pl.col('header_plane'))['len'].sum())\n\nshow(train_hdr.filter(pl.col('Fat_Suppression') == 0).group_by('series_type').agg(\n         series=pl.len(), T1=(pl.col('contrast') == 'T1').mean(), PD=(pl.col('contrast') == 'PD').mean(), T2=(pl.col('contrast') == 'T2').mean(),\n         unknown=(pl.col('contrast') == 'unknown').mean()).sort('series_type'),\n     'What \"non-FS\" actually contains', subtitle='contrast from echo time (TE) and repetition time (TR): TR < 1000 ms and TE < 30 ms → T1; TE < 60 ms → PD; longer → T2',\n     percent=['T1', 'PD', 'T2', 'unknown'])"},{"cell_type":"code","execution_count":null,"id":"934f1417","metadata":{"_kg_hide-input":true},"outputs":[],"source":"MEASURES = {'n_slices': 'slices', 'pixel_mm': 'pixel (mm)', 'spacing_mm': 'slice spacing (mm)', 'coverage_mm': 'coverage (mm)', 'fov_mm': 'field of view (mm)',\n            'obliquity_deg': 'tilt from true plane (°)'}\ngeometry = (train_hdr.filter(pl.col('pixel_mm') > 0).with_columns(depth_to_pixel=pl.col('spacing_mm') / pl.col('pixel_mm'))\n              .group_by('series_type').agg(series=pl.len(), *[pl.col(m).median().round(2).alias(m) for m in MEASURES],\n                                            depth_to_pixel=pl.col('depth_to_pixel').median().round(1), share_3T=(pl.col('field_T') >= 2.5).mean())\n              .sort('series_type'))\nshow(geometry, 'Geometry per series type (medians)', subtitle='depth to pixel = slice spacing ÷ in-plane pixel size', heat=['depth_to_pixel'], percent=['share_3T'])\n\ngeometry_long = (train_hdr.filter(pl.col('series_type').is_not_null())\n                   .unpivot(index='series_type', on=['pixel_mm', 'spacing_mm', 'n_slices'], variable_name='measure', value_name='value')\n                   .drop_nulls('value').with_columns(pl.col('measure').replace_strict(MEASURES)))\nfig = px.box(geometry_long.to_pandas(), x='value', y='series_type', facet_col='measure', points=False, color_discrete_sequence=[TEAL],\n             category_orders={'series_type': sorted(train_hdr['series_type'].drop_nulls().unique())}, labels=dict(series_type='', value=''))\nfig.update_xaxes(matches=None, showticklabels=True)\nfig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))\nchart(fig, 'Pixel size, slice spacing and slice count', 'box = middle half of series, whiskers = typical range', height=380)\n\nscanners = (train_hdr.group_by('StudyInstanceUID').agg(first('vendor'), first('field_T'))\n              .group_by('vendor', 'field_T').agg(studies=pl.len()).sort('studies', descending=True)\n              .with_columns(share=pl.col('studies') / pl.col('studies').sum()))\nshow(scanners, 'Scanner vendor and field strength (tesla)', subtitle='per study', percent=['share'])\n\nratio = train_hdr.filter(pl.col('pixel_mm') > 0).select((pl.col('spacing_mm') / pl.col('pixel_mm')).median()).item()\ntakeaway(f\"The median series has {train_hdr['n_slices'].median():.0f} slices with {train_hdr['pixel_mm'].median():.2f} mm pixels and {train_hdr['spacing_mm'].median():.1f} mm between slices: \"\n         f\"depth is about {ratio:.0f}× coarser than in-plane detail. The CSV plane matches the plane computed from the header orientation on \"\n         f\"{plane_agree:,} of {train_hdr.height:,} series. The non-FS flag hides different contrasts, which only echo and repetition time reveal.\")\nfor_the_model('Read each series with a 2D encoder over slices and model depth across a fixed number of resampled slices (2.5D), rather than a 3D network from scratch on a '\n              'volume this coarse in depth. Normalise intensity per series by percentiles: MRI has no Hounsfield scale, and brightness differs by vendor and field strength.')"},{"cell_type":"markdown","id":"8ea2561c","metadata":{},"source":"**Fields not to trust.** Two header fields look like they answer a question and often do not. `InstanceNumber` is the slice number the scanner wrote; it does not always run in the\ndirection of slice position. `Laterality` should say left or right knee; it is often absent."},{"cell_type":"code","execution_count":null,"id":"14279940","metadata":{"_kg_hide-input":true},"outputs":[],"source":"order = (train_hdr.drop_nulls('instance_position_corr').group_by('vendor', 'Anatomical_Plane')\n           .agg(series=pl.len(), reversed=(pl.col('instance_order') == 'reversed').mean()).sort('vendor', 'Anatomical_Plane'))\norder = order.pivot(on='Anatomical_Plane', index='vendor', values='reversed').join(order.group_by('vendor').agg(series=pl.col('series').sum()), on='vendor').sort('series', descending=True)\nshow(order, 'Share of series whose InstanceNumber runs against slice position', subtitle='by vendor and plane', percent=[c for c in order.columns if c not in ('vendor', 'series')])\n\nside = (train_hdr.group_by('StudyInstanceUID').agg(first('vendor'), has_laterality=pl.col('Laterality').is_not_null().any())\n          .group_by('vendor').agg(studies=pl.len(), laterality_present=pl.col('has_laterality').mean()).sort('studies', descending=True))\nshow(side, 'Share of studies with a Laterality tag', subtitle='by vendor', percent=['laterality_present'])\n\nreversed_share = (train_hdr['instance_order'] == 'reversed').mean()\nlaterality_share = train_hdr.group_by('StudyInstanceUID').agg(present=pl.col('Laterality').is_not_null().any())['present'].mean()\ntakeaway(f'{reversed_share:.0%} of series store InstanceNumber against slice position, and {1 - laterality_share:.0%} of studies have no Laterality tag.')\nfor_the_model('Sort slices by <code>ImagePositionPatient</code> projected on the slice normal, never by InstanceNumber or file name; a reversed stack puts the medial compartment where the lateral one should be. '\n              'Work out the side of the knee from geometry before any left/right mirroring, and swap the medial and lateral labels when you mirror.')"},{"cell_type":"markdown","id":"43839aab","metadata":{},"source":"<a id=\"section-6\"></a>\n\n## 6. A repeated series type is mostly a second contrast, rarely a retake\n\nWhen a study holds two series of the same type, the CSV cannot say why. The headers can. Within each repeated type, every later series is compared with the first,\nand the first rule that applies gives the reason: **different contrast** (echo or repetition time differs by more than 15%), **reformat** (image type says derived),\n**different angle** (slice normals more than 5° apart), **different grid** (pixel size, slice count or spacing differs), otherwise **retake candidate**."},{"cell_type":"code","execution_count":null,"id":"d563e044","metadata":{"_kg_hide-input":true},"outputs":[],"source":"con.register('series_train', train_hdr.to_arrow())\nrepeat_pairs = q('''\n    WITH ranked AS (\n        SELECT *, row_number() OVER (PARTITION BY StudyInstanceUID, series_type ORDER BY SeriesNumber NULLS LAST, acquisition_start_s NULLS LAST, SeriesInstanceUID) AS k\n        FROM series_train)\n    SELECT a.StudyInstanceUID, a.series_type, b.k, a.contrast AS first_contrast, b.contrast AS repeat_contrast,\n           lower(a.SeriesDescription) AS first_description, lower(b.SeriesDescription) AS repeat_description,\n           greatest(a.EchoTime, b.EchoTime) / nullif(least(a.EchoTime, b.EchoTime), 0) AS te_ratio,\n           greatest(a.RepetitionTime, b.RepetitionTime) / nullif(least(a.RepetitionTime, b.RepetitionTime), 0) AS tr_ratio,\n           (a.InversionTime IS NULL) = (b.InversionTime IS NULL) AS same_inversion,\n           degrees(acos(least(1, abs(a.nx * b.nx + a.ny * b.ny + a.nz * b.nz)))) AS angle_deg,\n           abs(a.pixel_mm - b.pixel_mm) < 0.02 AND a.n_slices = b.n_slices AND abs(a.spacing_mm - b.spacing_mm) < 0.1 AS same_grid,\n           a.derived OR b.derived AS derived,\n           b.acquisition_start_s - a.acquisition_start_s AS dt_s\n    FROM ranked a JOIN ranked b ON a.StudyInstanceUID = b.StudyInstanceUID AND a.series_type = b.series_type AND a.k = 1 AND b.k > 1\n''')\nsame_contrast = (pl.col('te_ratio') < 1.15) & (pl.col('tr_ratio') < 1.15) & pl.col('same_inversion')\nREASONS = ['different contrast', 'reformat', 'different angle', 'different grid', 'retake candidate']\nrepeat_pairs = repeat_pairs.with_columns(\n    reason=pl.when(~same_contrast).then(pl.lit(REASONS[0])).when(pl.col('derived')).then(pl.lit(REASONS[1]))\n             .when(pl.col('angle_deg') > 5).then(pl.lit(REASONS[2])).when(~pl.col('same_grid')).then(pl.lit(REASONS[3])).otherwise(pl.lit(REASONS[4])))\nrepeat_pairs.write_csv(OUT / 'repeat_pairs.csv')\n\nif repeat_pairs.height:\n    reason_long = (repeat_pairs.group_by('series_type', 'reason').agg(pairs=pl.len())\n                     .with_columns(share=pl.col('pairs') / pl.col('pairs').sum().over('series_type')))\n    fig = px.bar(reason_long.to_pandas(), x='share', y='series_type', color='reason', orientation='h', hover_data={'pairs': ':,', 'share': ':.1%'},\n                 category_orders={'reason': REASONS, 'series_type': sorted(reason_long['series_type'].unique())},\n                 color_discrete_sequence=['#2D65A8', '#8A6FB0', '#5F7F22', AMBER, '#B0502F'], labels=dict(share='share of repeat pairs', series_type=''))\n    fig.update_xaxes(tickformat='.0%')\n    chart(fig, 'Why a series type repeats', 'each repeat compared with the first series of its type in the same study')\n    show(repeat_pairs.group_by('reason').agg(pairs=pl.len(), median_minutes_apart=(pl.col('dt_s') / 60).median(), median_angle_deg=pl.col('angle_deg').median())\n             .with_columns(share=pl.col('pairs') / repeat_pairs.height).sort('pairs', descending=True),\n         'Repeat pairs by reason', percent=['share'])\n    show(repeat_pairs.filter(pl.col('reason') == 'different contrast').group_by('series_type', 'first_contrast', 'repeat_contrast').agg(pairs=pl.len())\n             .sort('pairs', descending=True).head(10), 'Which contrasts pair up', subtitle='different-contrast repeats, by header-derived contrast', heat=['pairs'])\n    shares = dict(repeat_pairs['reason'].value_counts(normalize=True, name='share').iter_rows())\n    takeaway(f\"{repeat_pairs.height:,} repeat pairs: different contrast {shares.get('different contrast', 0):.0%}, retake candidate {shares.get('retake candidate', 0):.0%}, \"\n             f\"different angle {shares.get('different angle', 0):.0%}, different grid {shares.get('different grid', 0):.0%}, reformat {shares.get('reformat', 0):.0%}. \"\n             + ('A repeated type is mostly extra information, not a duplicate to drop.' if shares.get('retake candidate', 0) < 0.5\n                else 'Most repeats look like retakes of the same acquisition.'))\n    for_the_model('Key series slots by header-derived contrast (for example sagittal PD vs sagittal T1) rather than by the CSV type alone; for true retakes, keep one series.')\nelse:\n    takeaway('No series type repeats within a study in the scanned files.')"},{"cell_type":"markdown","id":"25cff97f","metadata":{},"source":"<a id=\"section-7\"></a>\n\n## 7. The test set: images only, under a clock\n\nThe scoring test set has about 1,300 studies and **no report**: images only, no internet, at most 9 hours of run time."},{"cell_type":"code","execution_count":null,"id":"fe6ac0ba","metadata":{"_kg_hide-input":true},"outputs":[],"source":"N_TEST_HIDDEN = 1300          # competition overview: about 1,300 hidden test studies\nRUN_LIMIT_S = 9 * 3600\nmean_series = layouts['n_series'].mean()\nmean_slices = train_hdr['n_slices'].mean() if train_hdr.height else 30.0\ntotal_slices = N_TEST_HIDDEN * mean_series * mean_slices\nshow(pl.DataFrame({'quantity': ['hidden test studies', 'series per study (train mean)', 'slices per series (train mean)', 'slices to read', 'time per slice if one pass uses the whole 9 h'],\n                   'value': [f'{N_TEST_HIDDEN:,}', f'{mean_series:.1f}', f'{mean_slices:.0f}', f'{total_slices:,.0f}', f'{RUN_LIMIT_S / total_slices * 1000:,.0f} ms']}),\n     'Inference budget, back of the envelope', subtitle='includes DICOM decoding, resampling and every model, fold and augmentation pass')\ntakeaway(f'About {total_slices / 1000:,.0f} thousand slices must be decoded and read in 9 hours, roughly {RUN_LIMIT_S / total_slices * 1000:,.0f} ms each for everything. '\n         'Every extra series slot, fold, resolution step or test-time augmentation divides that.')\nfor_the_model('Set the inference budget before the architecture: time DICOM decoding and one model pass on a Kaggle GPU early, then spend what is left on folds and ensembles.')"},{"cell_type":"markdown","id":"1b6d7d1d","metadata":{},"source":"<a id=\"section-8\"></a>\n\n## 8. Findings and what they mean for a model\n\nNumbers are from the full training set; each is computed in the section linked.\n\n| What the data shows | What it means for a model | What winners of past RSNA imaging competitions did | See |\n|---|---|---|---|\n| **Only ranking counts.** The score is the mean of the 12 per-finding ROC AUCs | No calibration or threshold work; a rare finding weighs as much as a common one | Temperature scaling and clipping do not change AUC | [intro](#competition) |\n| **Targets come from reports, truth from images.** The 12 columns are filled on 58 studies; labels for all 4,407 are read from the reports. On the 58, reports are silent on about half of the expert-positive Synovitis cases | Soft report labels, with not mentioned kept apart from absent; out-of-fold AUC on all report-labelled studies; the 58 as a check only | Breast Cancer 1st place used soft labels; fitting blends on a small labelled set hurt in Abdominal Trauma | [section 3](#section-3) |\n| **Findings co-occur, moderately.** Contusion and fracture are reported together 2.9× as often as chance, medial and lateral OA 2.0×; yet half of reported ACL tears have no reported contusion, and fracture is in 7% of reports but 31% of expert labels | One shared encoder with 12 outputs; auxiliary any-trauma and any-OA targets to give rare findings signal; folds stratified on all 12 findings; out-of-fold AUC checked on isolated cases to catch shortcuts | Abdominal Trauma winners used auxiliary heads | [section 3](#section-3) |\n| **A study is a variable set of series.** 3 to 14 series; the most common set of series types covers 40% of studies | A slot per series type with a missing-series mask and a rule for repeats; route each finding to the planes that show it | Lumbar Spine top teams modelled each series type separately | [section 4](#section-4) |\n| **The CSV hides the contrast.** \"non-FS\" mixes T1, PD and T2; most repeated series types are a second contrast, not a duplicate | Key series slots by header-derived contrast; keep one series of a true retake | — | [sections 5](#section-5) and [6](#section-6) |\n| **Slices are about ten times farther apart than pixels:** about 0.3 mm pixels against about 3.5 mm between slices | A 2D encoder over slices plus a depth model over a fixed slice count (2.5D), not a 3D network from scratch | Cervical Spine 1st and Abdominal Trauma 1st resampled to fixed counts; plain 3D networks from scratch lost in Cervical Spine, Lumbar Spine and Pulmonary Embolism | [section 5](#section-5) |\n| **Two header fields cannot be trusted.** `InstanceNumber` runs against slice position in over a third of series; `Laterality` is absent in about half of studies | Sort slices by position along the slice normal; work out the knee's side from geometry before mirroring, and swap medial and lateral labels when mirroring | Intracranial Hemorrhage 3rd place sorted by position to use neighbouring slices | [section 5](#section-5) |\n| **One patient per study** | Folds split by study cannot leak a patient | Standard in every RSNA solution | [section 1](#section-1) |\n| **Images only, 9 hours offline:** about 200 thousand slices, about 25 s per study for everything | Time decoding and one model pass early: a single model fits easily; the limit falls on folds, ensembles and test-time augmentation | Cervical Spine 1st place used 7.5 of 9 hours | [section 7](#section-7) |\n\nTwo further choices come from past competitions rather than from this data: findings are small parts of a large field of view, so localise the joint and crop\n(all top-5 Lumbar Spine solutions did); and twelve study labels over many slices suit attention plus max pooling across slices (Pulmonary Embolism 1st, Cervical Spine 3rd).\n\n**2D, 2.5D or 3D: how much of a series goes in at once.** All three read the same slices and differ in how many neighbours the network sees together.\n\n```\n2D     one slice             (H, W)       ░ ░ ░ █ ░ ░ ░    strong pretrained 2D backbones; a tear spanning two slices is split up\n2.5D   slice + k neighbours  (k, H, W)    ░ ░ █ █ █ ░ ░    still a 2D network; sees local depth cheaply\n3D     whole series          (D, H, W)    █ █ █ █ █ █ █    needs 3D pretraining or a depth model on top of a 2D encoder\n```"},{"cell_type":"markdown","id":"7381ab4f","metadata":{},"source":"## Credits\n\n- **Competition data:** [RSNA Knee Abnormality Detection](https://www.kaggle.com/competitions/rsna-knee-abnormality-detection)\n- **Challenge background and knee statistics (intro):** [competition discussion post](https://www.kaggle.com/competitions/rsna-knee-abnormality-detection/discussion/733343)\n- **Report-read labels (sections 2 and 3):** [RSNA Knee LLM-read report labels](https://www.kaggle.com/datasets/pilkwang/rsna-knee-llm-labels) by [pilkwang](https://www.kaggle.com/pilkwang), CC0-1.0, file `report_labels_v2.csv`\n- **Libraries:** DuckDB, Polars, pydicom, pandas, Plotly, matplotlib"}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"}},"nbformat":4,"nbformat_minor":5}