From 1f9b2f20bd9c2a2a18131e47c6a94f2c482bfc70 Mon Sep 17 00:00:00 2001 From: Ivan Churilov Date: Fri, 11 Sep 2026 08:28:02 +0300 Subject: [PATCH] Initial commit --- .gitignore | 32 + Makefile | 68 + README.md | 179 +++ configs/datasets/local_paths.template.json | 4 + data_access/README.md | 22 + docs/data_audit.md | 211 +++ docs/reproducibility_guide.md | 398 ++++++ plan/current/experiment_plan_detailed.md | 802 +++++++++++ plan/source_article_plan/README.md | 15 + plan/source_article_plan/experiment_plan.md | 378 ++++++ reports/E01_detailed_report.md | 1046 +++++++++++++++ requirements.txt | 21 + scripts/README.md | 28 + scripts/build_modeling_workset.py | 94 ++ scripts/render_markdown_report_html.py | 328 +++++ scripts/reproducibility_blocks.py | 1006 ++++++++++++++ scripts/run_a1_to_a4.py | 55 + scripts/run_a3_to_a5.py | 44 + scripts/run_a6_to_a11.py | 737 ++++++++++ scripts/run_a9_deep_baselines.py | 295 ++++ scripts/run_all_hypotheses.py | 49 + scripts/run_foundation.py | 36 + scripts/run_h1_interpretable_features.py | 7 + scripts/run_h2_mask_effect.py | 7 + scripts/run_h3_noise_regimes.py | 7 + scripts/run_h4_aggregation.py | 7 + scripts/run_h5_deep_vs_handcrafted.py | 7 + scripts/run_subject_figures_for_articles.py | 269 ++++ src/__init__.py | 1 + src/dataset/__init__.py | 1 + src/dataset/build_index.py | 574 ++++++++ src/dataset/build_patient_summary.py | 102 ++ src/dataset/build_subsets.py | 206 +++ src/dataset/build_tasks.py | 119 ++ src/dataset/class_mapping.py | 41 + src/dataset/make_group_splits.py | 161 +++ src/dataset/parse_detection_labels.py | 39 + src/dataset/parse_masks.py | 43 + src/evaluation/__init__.py | 1 + src/evaluation/aggregate_scores.py | 108 ++ src/evaluation/hypothesis_figures.py | 860 ++++++++++++ src/evaluation/metrics.py | 174 +++ src/evaluation/subject_figure_data.py | 334 +++++ src/evaluation/subject_figure_rendering.py | 558 ++++++++ src/evaluation/subject_figure_selection.py | 1343 +++++++++++++++++++ src/evaluation/visualization.py | 157 +++ src/features/__init__.py | 1 + src/features/extract_color.py | 36 + src/features/extract_morphology.py | 47 + src/features/extract_texture_glcm.py | 50 + src/features/extract_texture_lbp.py | 44 + src/features/extract_texture_wavelet.py | 34 + src/features/feature_registry.py | 317 +++++ src/models/__init__.py | 1 + src/models/baseline_pipeline.py | 240 ++++ src/models/deep_baselines.py | 265 ++++ src/preprocessing/__init__.py | 1 + src/preprocessing/extract_bbox_crops.py | 119 ++ src/preprocessing/extract_mask_crops.py | 121 ++ src/preprocessing/image_io.py | 52 + src/utils/__init__.py | 1 + src/utils/dataset_paths.py | 71 + src/utils/project.py | 83 ++ tests/test_dataset_index.py | 136 ++ 64 files changed, 12593 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 configs/datasets/local_paths.template.json create mode 100644 data_access/README.md create mode 100644 docs/data_audit.md create mode 100644 docs/reproducibility_guide.md create mode 100644 plan/current/experiment_plan_detailed.md create mode 100644 plan/source_article_plan/README.md create mode 100644 plan/source_article_plan/experiment_plan.md create mode 100644 reports/E01_detailed_report.md create mode 100644 requirements.txt create mode 100644 scripts/README.md create mode 100644 scripts/build_modeling_workset.py create mode 100644 scripts/render_markdown_report_html.py create mode 100644 scripts/reproducibility_blocks.py create mode 100644 scripts/run_a1_to_a4.py create mode 100644 scripts/run_a3_to_a5.py create mode 100644 scripts/run_a6_to_a11.py create mode 100644 scripts/run_a9_deep_baselines.py create mode 100644 scripts/run_all_hypotheses.py create mode 100644 scripts/run_foundation.py create mode 100644 scripts/run_h1_interpretable_features.py create mode 100644 scripts/run_h2_mask_effect.py create mode 100644 scripts/run_h3_noise_regimes.py create mode 100644 scripts/run_h4_aggregation.py create mode 100644 scripts/run_h5_deep_vs_handcrafted.py create mode 100644 scripts/run_subject_figures_for_articles.py create mode 100644 src/__init__.py create mode 100644 src/dataset/__init__.py create mode 100644 src/dataset/build_index.py create mode 100644 src/dataset/build_patient_summary.py create mode 100644 src/dataset/build_subsets.py create mode 100644 src/dataset/build_tasks.py create mode 100644 src/dataset/class_mapping.py create mode 100644 src/dataset/make_group_splits.py create mode 100644 src/dataset/parse_detection_labels.py create mode 100644 src/dataset/parse_masks.py create mode 100644 src/evaluation/__init__.py create mode 100644 src/evaluation/aggregate_scores.py create mode 100644 src/evaluation/hypothesis_figures.py create mode 100644 src/evaluation/metrics.py create mode 100644 src/evaluation/subject_figure_data.py create mode 100644 src/evaluation/subject_figure_rendering.py create mode 100644 src/evaluation/subject_figure_selection.py create mode 100644 src/evaluation/visualization.py create mode 100644 src/features/__init__.py create mode 100644 src/features/extract_color.py create mode 100644 src/features/extract_morphology.py create mode 100644 src/features/extract_texture_glcm.py create mode 100644 src/features/extract_texture_lbp.py create mode 100644 src/features/extract_texture_wavelet.py create mode 100644 src/features/feature_registry.py create mode 100644 src/models/__init__.py create mode 100644 src/models/baseline_pipeline.py create mode 100644 src/models/deep_baselines.py create mode 100644 src/preprocessing/__init__.py create mode 100644 src/preprocessing/extract_bbox_crops.py create mode 100644 src/preprocessing/extract_mask_crops.py create mode 100644 src/preprocessing/image_io.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/dataset_paths.py create mode 100644 src/utils/project.py create mode 100644 tests/test_dataset_index.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a392425 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ + +# Local environments and caches +.venv/ +.uv-cache/ +vendor/ + +# OS / editor artifacts +.DS_Store +Thumbs.db + +# Local dataset path configuration +configs/datasets/local_paths.json +configs/datasets/*.local.json +data_access/shared_data + +# Generated experiment outputs +outputs/ + +# Generated report exports +reports/*.html +reports/*.pdf +reports/*.docx + +# Notebook checkpoints +.ipynb_checkpoints/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e1b3e7 --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +SHELL := /bin/sh +PYTHON := ./.venv/bin/python +DATA_ROOT ?= +MASKS_ROOT ?= +PATHS_CONFIG ?= +FORCE ?= +WITH_SUBJECT_FIGURES ?= +SUBJECT_FIGURE_VARIANTS ?= + +RUN_ARGS := + +ifneq ($(strip $(DATA_ROOT)),) +RUN_ARGS += --data-root "$(DATA_ROOT)" +endif + +ifneq ($(strip $(MASKS_ROOT)),) +RUN_ARGS += --masks-root "$(MASKS_ROOT)" +endif + +ifneq ($(strip $(PATHS_CONFIG)),) +RUN_ARGS += --paths-config "$(PATHS_CONFIG)" +endif + +ifneq ($(strip $(FORCE)),) +RUN_ARGS += --force +endif + +ifneq ($(strip $(WITH_SUBJECT_FIGURES)),) +RUN_ARGS += --with-subject-figures +endif + +ifneq ($(strip $(SUBJECT_FIGURE_VARIANTS)),) +RUN_ARGS += --subject-figure-variants $(SUBJECT_FIGURE_VARIANTS) +endif + +.PHONY: foundation h1 h2 h3 h4 h5 all subject-figures test repro-check clean-pyc + +foundation: + $(PYTHON) scripts/run_foundation.py $(RUN_ARGS) + +h1: + $(PYTHON) scripts/run_h1_interpretable_features.py + +h2: + $(PYTHON) scripts/run_h2_mask_effect.py + +h3: + $(PYTHON) scripts/run_h3_noise_regimes.py + +h4: + $(PYTHON) scripts/run_h4_aggregation.py + +h5: + $(PYTHON) scripts/run_h5_deep_vs_handcrafted.py + +all: + $(PYTHON) scripts/run_all_hypotheses.py $(RUN_ARGS) + +subject-figures: + $(PYTHON) scripts/run_subject_figures_for_articles.py --variants $(if $(strip $(SUBJECT_FIGURE_VARIANTS)),$(SUBJECT_FIGURE_VARIANTS),5) $(if $(strip $(FORCE)),--overwrite,) + +test: + $(PYTHON) -m unittest discover -s tests -v + +repro-check: all test + +clean-pyc: + find scripts src tests -type d -name '__pycache__' -prune -exec rm -rf {} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f3ffd2 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# E01 Textural Features Blast Pipeline + +## Постановка задачи + +Проверить, насколько интерпретируемые морфометрические, цветовые и текстурные признаки действительно полезны для автоматизированного обнаружения бластных клеток на микроскопических изображениях препаратов крови, если оценка проводится на реальных данных с разметкой объектов, масками и разделением по пациентам. + +## Предмет проекта + +- объектно-уровневая постановка `blast / non-blast`; +- сравнение признаковых и более сложных моделей на одинаковых данных; +- оценка роли сегментационных масок при извлечении признаков; +- переход от классификации отдельного объекта к анализаторно-ориентированной оценке изображения; +- фиксация ограничений текущих данных и выделение задач, требующих нового корпуса. + +## Текущий фокус + +На первом этапе приоритет отдается только тем частям исследования, которые можно выполнить на локальных данных. + +- object detection-разметка по пациентам; +- автоматически построенные маски объектов; +- patient-level разбиение; +- морфо-текстурные признаки на уровне отдельных клеток; +- image-level агрегирование как приближение к задаче автоматического анализатора. + +Постановки, требующие явного разделения лимфобластов и миелобластов, внешней межлабораторной валидации и case-level клинических меток, вынесены во вторую часть плана. + +## Навигация по проекту + +- [docs/data_audit.md](docs/data_audit.md) — зафиксированный аудит структуры и качества доступных данных. +- [docs/reproducibility_guide.md](docs/reproducibility_guide.md) — подробная инструкция по воспроизводимому запуску `foundation`, `H1-H5` и полного прогона. + +## Воспроизводимый запуск + +Эксперимент организован в виде: + +- одного базового блока `foundation`; +- отдельных блоков `H1-H5` по гипотезам; +- общего блока `all` для полного последовательного прогона. + +Точки входа работают в resumable-режиме: + +- если нужные артефакты уже существуют, они валидируются и переиспользуются; +- если какого-то этапа не хватает, пересчитывается только недостающая часть; +- итоговые короткие summary по каждому блоку сохраняются в [outputs/reports/hypotheses](outputs/reports/hypotheses). + +## Установка и запуск + +Код проверен на CPython 3.12. Для полного прогона требуется около 7 ГБ под +выходные артефакты; лучше иметь не менее 10 ГБ свободного места. Исходные +изображения и маски в репозиторий не входят. + +Из корня проекта создайте окружение и установите зафиксированные зависимости. +Вариант с `uv`: + +```bash +uv venv --python 3.12 .venv +uv pip install --python .venv/bin/python -r requirements.txt +``` + +То же самое через стандартный `venv` и `pip`: + +```bash +python3.12 -m venv .venv +./.venv/bin/python -m pip install --upgrade pip +./.venv/bin/python -m pip install -r requirements.txt +``` + +Подготовьте два каталога: + +- `data_root` — исходные изображения, object detection-разметка и + `predefined_classes.txt`; +- `masks_root` — копия структуры набора с масками в + `images_results/masks_sam`. + +Скопируйте шаблон конфигурации и укажите абсолютные пути к этим каталогам: + +```bash +cp configs/datasets/local_paths.template.json configs/datasets/local_paths.json +``` + +```json +{ + "data_root": "/absolute/path/to/data", + "masks_root": "/absolute/path/to/data_masks" +} +``` + +После этого полный эксперимент и тесты запускаются так: + +```bash +make all +make test +``` + +Команда `make repro-check` последовательно выполняет оба шага. На чистом +рабочем дереве `make all` строит `foundation`, затем H1–H5. При повторном +вызове готовые артефакты проверяются и переиспользуются. Для принудительного +пересчёта всех этапов используйте `make all FORCE=1`. + +Конфигурационный файл не обязателен. Пути можно передать непосредственно: + +```bash +make all DATA_ROOT=/absolute/path/to/data MASKS_ROOT=/absolute/path/to/data_masks +``` + +Или через переменные среды: + +```bash +export E01_DATA_ROOT=/absolute/path/to/data +export E01_MASKS_ROOT=/absolute/path/to/data_masks +make all +``` + +Отдельные блоки запускаются командами: + +```bash +make foundation +make h1 +make h2 +make h3 +make h4 +make h5 +``` + +Основной результат полного запуска — +`outputs/reports/hypotheses/run_all_hypotheses_summary.md`. Численные таблицы +находятся в `outputs/tables`, предсказания — в `outputs/predictions`, графики — +в `outputs/figures`. Для сравнения нового прогона с опубликованным результатом +следует в первую очередь проверить размеры выборок и таблицы +`baseline_model_leaderboard.csv`, `ablation_mask_usage.csv`, +`ablation_dataset_regimes.csv`, `image_level_metrics.csv`, +`patient_level_ranking_metrics.csv` и `deep_vs_handcrafted.csv`. + +Прямые entrypoint-скрипты: + +- [run_foundation.py](scripts/run_foundation.py) +- [run_h1_interpretable_features.py](scripts/run_h1_interpretable_features.py) +- [run_h2_mask_effect.py](scripts/run_h2_mask_effect.py) +- [run_h3_noise_regimes.py](scripts/run_h3_noise_regimes.py) +- [run_h4_aggregation.py](scripts/run_h4_aggregation.py) +- [run_h5_deep_vs_handcrafted.py](scripts/run_h5_deep_vs_handcrafted.py) +- [run_all_hypotheses.py](scripts/run_all_hypotheses.py) + +Какие скрипты считаются публичными точками входа, а какие внутренними служебными шагами, отдельно описано в [scripts/README.md](scripts/README.md). + +## Каталоги проекта + +- `configs/` — конфигурации запуска; локальные пути к данным задаются через `configs/datasets/local_paths.json` и не коммитятся. +- `data_access/` — пояснения по доступу к данным без хранения самих данных в репозитории. +- `docs/` — аналитические материалы и аудит данных. +- `notebooks/` — исследовательские ноутбуки. +- `outputs/dataset_index/` — манифесты пациентов, изображений и объектов. +- `outputs/splits/` — зафиксированные patient-level разбиения. +- `outputs/crops/` — извлеченные объектные кропы. +- `outputs/features/` — таблицы признаков. +- `outputs/checkpoints/` — обученные модели. +- `outputs/predictions/` — предсказания на object-level и image-level. +- `outputs/figures/` и `outputs/tables/` — артефакты для статьи и диссертации. +- `outputs/reports/` и `reports/` — промежуточные отчеты и итоговые summary-файлы. +- `src/` — основной код проекта. +- `tests/` — проверки корректности пайплайна. + +## Что не попадает в GitHub + +В [\.gitignore](.gitignore) вынесены: + +- все содержимое `outputs/`; +- локальное окружение `.venv/` и кэш `.uv-cache/`; +- локальный файл путей `configs/datasets/local_paths.json`; +- служебные и бинарные директории вроде `vendor/`; +- генерируемые экспортные версии отчетов `reports/*.pdf`, `reports/*.docx`, `reports/*.html`. + +## Ключевые рабочие решения + +- Основная постановка для текущих данных: `blast vs non-blast`, а не `lymphoblast vs myeloblast`. +- Главная единица недопущения утечки: пациент. +- В базовый patient-level протокол входят только `122` каталогов с patient id; специальный каталог `part-1` пока рассматривается отдельно до уточнения его происхождения. +- Основной сопоставитель объектов: порядок строк в `images_labels/*.txt` и индекс `obj_XXXX` в имени маски. +- Первичная научная ценность текущего корпуса: воспроизводимая проверка полезности текстурных признаков, влияния масок и реалистичных условий data quality. diff --git a/configs/datasets/local_paths.template.json b/configs/datasets/local_paths.template.json new file mode 100644 index 0000000..3624f12 --- /dev/null +++ b/configs/datasets/local_paths.template.json @@ -0,0 +1,4 @@ +{ + "data_root": "/absolute/path/to/Data/data", + "masks_root": "/absolute/path/to/Data/data_masks" +} diff --git a/data_access/README.md b/data_access/README.md new file mode 100644 index 0000000..8de5366 --- /dev/null +++ b/data_access/README.md @@ -0,0 +1,22 @@ +# Data Access + +Heavyweight datasets are not stored in this repository. + +To run the experiment: + +1. Prepare the original dataset directory `data`. +2. Prepare the masks dataset directory `data_masks`. +3. Copy `configs/datasets/local_paths.template.json` to `configs/datasets/local_paths.json`. +4. Put absolute paths to these two directories into `local_paths.json`. + +After that, run: + +```bash +make all +``` + +or: + +```bash +./.venv/bin/python scripts/run_all_hypotheses.py +``` diff --git a/docs/data_audit.md b/docs/data_audit.md new file mode 100644 index 0000000..d2dc488 --- /dev/null +++ b/docs/data_audit.md @@ -0,0 +1,211 @@ +# Аудит данных для E01 + +Документ фиксирует фактическое состояние данных, доступных на 9 апреля 2026 года, и определяет, какие постановки уже можно реализовывать программно без привлечения новых наборов данных. + +## 1. Источники данных + +- `data` — исходные изображения и object detection-разметка. +- `data_masks` — те же данные с дополнительными масками объектов в `images_results/masks_sam`. + +## 2. Структура одного пациентского каталога + +Для большинства пациентов в `data` используется одинаковая структура: + +- `images/` — исходные изображения; +- `images_labels/` — текстовые файлы разметки объектов; +- `predefined_classes.txt` — словарь классов. + +В `data_masks` для тех же пациентов дополнительно присутствует каталог: + +- `images_results/masks_sam/` — маски отдельных объектов в формате `stem_class__obj_.png`. + +Отдельно нужно зафиксировать специальный случай: + +- в корне обоих наборов присутствует каталог `part-1`, который не соответствует формату пациентского идентификатора; +- в нем находится `33` изображения, `33` label-файла и `477` размеченных объектов; +- в `data_masks` для него есть `475` масок; +- до уточнения происхождения этого каталога его не следует включать в основной patient-level протокол. + +## 3. Проверенные количественные характеристики + +### 3.1. Общий объем + +Для воспроизводимости нужно различать: + +- полный сырой объем каталога; +- основной patient-level корпус, который используется в эксперименте. + +Полный сырой объем: + +- число каталогов верхнего уровня в `data`: `123`; +- число каталогов верхнего уровня в `data_masks`: `123`; +- среди них `122` пациент-идентифицированных каталогов и один специальный каталог `part-1`; +- число изображений: `12 521`; +- число файлов разметки: `12 489`; +- число объектов по detection-разметке: `86 873`. + +Основной patient-level корпус, рекомендуемый для первой фазы эксперимента: + +- число пациентских каталогов: `122`; +- число изображений: `12 488`; +- число файлов разметки: `12 456`; +- число объектов по detection-разметке: `86 396`. + +### 3.2. Наблюдения по разметке + +- пустых файлов разметки: `26`; +- изображений без `.txt`-разметки: `32`; +- такие изображения встречаются только у пациентов `P190315.002` и `P190408.001`; +- файлов разметки без соответствующего изображения не обнаружено; +- в `4 991` строках файлов разметки идентификатор класса записан в формате с плавающей точкой, например `7.0`; при загрузке нужно использовать нормализацию `int(float(class_id))`. + +### 3.3. Наблюдения по маскам + +- всего масок объектов в основном patient-level корпусе: `86 360`; +- изображений, для которых есть хотя бы одна маска: `12 428`; +- изображений без масок: `60`; +- пациентов, у которых есть изображения без масок: `15`. + +Из этих `60` изображений: + +- `32` совпадают с изображениями без detection-разметки; +- остальные `28` изображений имеют разметку, но не имеют полного покрытия масками. + +### 3.4. Проверка соответствия labels и masks + +Для изображений, у которых число строк в `images_labels/*.txt` совпадает с числом файлов `*_obj_XXXX.png`, было проверено соответствие классов и порядка объектов. + +Результат: + +- проверено изображений: `12 395`; +- изображений с полным совпадением порядка объектов: `12 395`; +- изображений с нарушением последовательности `obj_0000, obj_0001, ...`: `0`; +- изображений с несовпадением классов между строкой label-файла и именем маски: `0`. + +Это позволяет принять следующий контракт данных: индекс `obj_XXXX` соответствует порядку строки в detection-разметке. + +## 4. Словарь классов + +Основной словарь классов совпадает у `122` пациентов: + +| id | Класс | +|---:|---| +| 0 | Lymphocyte | +| 1 | Metamyelocyte | +| 2 | Monocyte | +| 3 | Myelocyte | +| 4 | Normoblasts | +| 5 | Rod neutrophil | +| 6 | Segmentonuclear neutrophil | +| 7 | blast | +| 8 | Promyelocyte | +| 9 | Eosinophil | +| 10 | Basophil | +| 11 | Magakariocyte | +| 12 | Bad cells | +| 13 | plasma cells | +| 14 | artifacts | +| 15 | gumpricht shadows | +| 16 | methoses | +| 17 | normoblasts: oxyfilny | +| 18 | macrophages | +| 19 | normoblasts: erythroblasts | +| 20 | normoblasts: basophilic | +| 21 | normoblasts: polychromatophilic | + +У пациента `240612.007` в `predefined_classes.txt` есть дополнительный класс `22 mast cells`, однако фактических объектов этого класса в текущем корпусе не обнаружено. + +## 5. Распределение объектов по классам + +Наиболее представленные классы: + +| id | Класс | Число объектов | +|---:|---|---:| +| 7 | blast | 61 861 | +| 12 | Bad cells | 7 825 | +| 0 | Lymphocyte | 5 660 | +| 15 | gumpricht shadows | 3 600 | +| 6 | Segmentonuclear neutrophil | 1 715 | +| 3 | Myelocyte | 1 052 | +| 2 | Monocyte | 1 038 | +| 17 | normoblasts: oxyfilny | 1 035 | +| 5 | Rod neutrophil | 545 | +| 21 | normoblasts: polychromatophilic | 416 | +| 1 | Metamyelocyte | 414 | +| 9 | Eosinophil | 333 | + +Редкие классы присутствуют, но для самостоятельной устойчивой оценки их объема недостаточно: + +- `Promyelocyte`: `5`; +- `Magakariocyte`: `7`; +- `macrophages`: `7`; +- `plasma cells`: `24`; +- `Basophil`: `28`; +- `artifacts`: `54`. + +## 6. Распределение по пациентам и изображениям + +- пациентов, у которых встречается хотя бы один `blast`: `100`; +- пациентов без `blast`: `22`; +- изображений с хотя бы одним `blast`: `8 618`; +- изображений только с `blast`: `3 508`; +- изображений со смешанным составом `blast + other`: `5 110`; +- изображений без `blast`: `3 812`. + +Это означает, что текущий набор подходит не только для object-level классификации, но и для image-level агрегирования, когда нужно оценивать вероятность наличия бластов на изображении по совокупности обнаруженных объектов. + +## 7. Недопокрытие масками + +Найдено `35` изображений, у которых число строк в detection-разметке не совпадает с числом масок объектов. Всего недостающих масок объектов: `36`. + +Недостающие маски по классам: + +| id | Класс | Число недостающих масок | +|---:|---|---:| +| 7 | blast | 13 | +| 15 | gumpricht shadows | 9 | +| 12 | Bad cells | 5 | +| 2 | Monocyte | 3 | +| 6 | Segmentonuclear neutrophil | 2 | +| 0 | Lymphocyte | 1 | +| 20 | normoblasts: basophilic | 1 | +| 5 | Rod neutrophil | 1 | +| 3 | Myelocyte | 1 | + +Практический вывод: маски покрывают практически весь корпус, поэтому mask-based признаки можно считать основной, а не вспомогательной веткой текущего эксперимента. При этом для воспроизводимости необходимо зафиксировать правило fallback: если маски для объекта нет, данный объект либо исключается из mask-only ветки, либо используется только в bbox-only ветке. + +## 8. Что текущие данные позволяют исследовать + +Текущий корпус уже позволяет: + +- строить основной протокол на `122` пациент-идентифицированных каталогах; +- строить patient-level разбиения; +- извлекать object-level кропы по detection-разметке; +- использовать mask-restricted ROI для признаков; +- сравнивать `bbox-only` и `mask-based` признаки; +- решать бинарную задачу `blast / non-blast`; +- оценивать image-level наличие бластов через агрегирование объектных предсказаний; +- изучать влияние классов качества, артефактов и сложных отрицательных примеров. + +## 9. Что текущие данные не позволяют доказательно исследовать + +На текущем корпусе нельзя считать полноценно подтвержденными следующие постановки: + +- включение каталога `part-1` в основной patient-level корпус без дополнительного уточнения его происхождения; +- различение `lymphoblast / myeloblast`, так как в разметке есть только общий класс `blast`; +- внешнюю межлабораторную валидацию и устойчивость к доменному сдвигу; +- case-level и slide-level клиническую постановку с подтвержденными диагнозами; +- сопоставление результатов модели с экспертной разметкой нуклеол, палочек Ауэра и других специализированных структур. + +## 10. Прямое следствие для плана эксперимента + +Первый этап эксперимента должен быть построен вокруг следующей последовательности: + +1. аудит и нормализация данных; +2. выделение основного patient-level корпуса и исключение `part-1` из базового протокола до уточнения его статуса; +3. формирование индексных таблиц на уровне пациента, изображения и объекта; +4. извлечение кропов и mask-aware областей интереса; +5. object-level `blast / non-blast` baseline; +6. сравнение признаков по режимам `bbox`, `mask`, `bbox+mask`; +7. image-level агрегирование как приближение к задаче автоматического анализатора; +8. только после этого — расширение постановки на новые данные и более сложные модели. diff --git a/docs/reproducibility_guide.md b/docs/reproducibility_guide.md new file mode 100644 index 0000000..e89c319 --- /dev/null +++ b/docs/reproducibility_guide.md @@ -0,0 +1,398 @@ +# E01 Reproducibility Guide + +## 1. Зачем нужна эта структура + +Эксперимент разбит на: + +- один общий блок `foundation`, который готовит данные, split, ROI и признаки; +- пять отдельных блоков `H1-H5`, каждый из которых проверяет одну научную гипотезу; +- один общий блок `all`, который запускает все последовательно. + +Это сделано для того, чтобы человек мог: + +- пересобрать весь эксперимент целиком; +- отдельно запустить только одну гипотезу; +- быстро понять, какие артефакты относятся к какой гипотезе. + +Дополнительно все новые entrypoint-скрипты работают в resumable-режиме: + +- если артефакты уже есть, они не пересчитываются без необходимости; +- если чего-то не хватает, достраивается только отсутствующий этап; +- в summary каждого блока фиксируется, что именно было пересчитано в текущем запуске. + +## 2. Основные команды + +Все команды ниже выполняются из корня клонированного репозитория E01: + +```bash +cd E01_textural_features_blast_pipeline +``` + +Перед запуском нужно подготовить окружение и указать пути к данным. + +### 2.1. Установка зависимостей + +Проект не хранит `.venv` в репозитории. Код проверен на CPython 3.12. После клонирования создайте окружение и установите зависимости: + +```bash +python3.12 -m venv .venv +./.venv/bin/python -m pip install --upgrade pip +./.venv/bin/python -m pip install -r requirements.txt +``` + +### 2.2. Где должны лежать тяжелые данные + +В репозиторий не включаются: + +- исходный датасет `data`; +- датасет масок `data_masks`; +- все артефакты из `outputs/`. + +Ожидается, что у вас уже есть две директории: + +- `data_root` — каталог с исходными изображениями и label-файлами; +- `masks_root` — каталог с масками объектов. + +Проект не требует, чтобы эти каталоги лежали внутри репозитория. Они могут находиться в любом месте файловой системы. + +### 2.3. Как указать пути к данным + +Рекомендуемый способ: + +1. Скопируйте [local_paths.template.json](../configs/datasets/local_paths.template.json) в `configs/datasets/local_paths.json`. +2. Впишите абсолютные пути: + +```json +{ + "data_root": "/absolute/path/to/Data/data", + "masks_root": "/absolute/path/to/Data/data_masks" +} +``` + +Этот файл специально не коммитится. + +Альтернативы: + +- переменные среды: + +```bash +export E01_DATA_ROOT="/absolute/path/to/Data/data" +export E01_MASKS_ROOT="/absolute/path/to/Data/data_masks" +``` + +- аргументы командной строки: + +```bash +./.venv/bin/python scripts/run_all_hypotheses.py --data-root /absolute/path/to/Data/data --masks-root /absolute/path/to/Data/data_masks +``` + +- передача путей прямо через `make`: + +```bash +make all DATA_ROOT=/absolute/path/to/Data/data MASKS_ROOT=/absolute/path/to/Data/data_masks +``` + +После этого можно использовать либо `make`, либо прямой вызов Python. + +### Вариант A. Самый удобный: через `make` + +```bash +make foundation +make h1 +make h2 +make h3 +make h4 +make h5 +make all +make test +make repro-check +``` + +Если вы не хотите создавать `configs/datasets/local_paths.json`, можно сразу передать пути: + +```bash +make foundation DATA_ROOT=/absolute/path/to/Data/data MASKS_ROOT=/absolute/path/to/Data/data_masks +make all DATA_ROOT=/absolute/path/to/Data/data MASKS_ROOT=/absolute/path/to/Data/data_masks +``` + +### Вариант B. Прямой запуск Python + +```bash +./.venv/bin/python scripts/run_foundation.py +./.venv/bin/python scripts/run_h1_interpretable_features.py +./.venv/bin/python scripts/run_h2_mask_effect.py +./.venv/bin/python scripts/run_h3_noise_regimes.py +./.venv/bin/python scripts/run_h4_aggregation.py +./.venv/bin/python scripts/run_h5_deep_vs_handcrafted.py +./.venv/bin/python scripts/run_all_hypotheses.py +./.venv/bin/python -m unittest discover -s tests -v +``` + +Какие скрипты предназначены для прямого запуска человеком, а какие являются внутренними реализациями этапов, указано в [scripts/README.md](../scripts/README.md). + +## 3. Что запускает каждый блок + +### `foundation` + +Файл: + +- [run_foundation.py](../scripts/run_foundation.py) + +Что делает: + +- `A0`: аудит данных и индексы; +- `A1`: подмножества корпуса; +- `A2`: patient-level split; +- `A4`: постановки бинарных задач; +- `A3`: ROI по bbox и маскам; +- `A5`: извлечение признаков. + +Главный смысл: + +- один раз подготовить все базовые артефакты для всех следующих гипотез. + +Что ожидается на выходе: + +- `outputs/dataset_index/` — индексы пациентов, изображений и объектов; +- `outputs/splits/` — patient-level split; +- `outputs/crops/` — bbox и mask ROI; +- `outputs/features/` — таблицы признаков; +- `outputs/reports/` — QC-отчеты этапов `A0-A5`. + +Ключевой summary: + +- [foundation_summary.md](../outputs/reports/hypotheses/foundation_summary.md) + +### `h1` + +Файл: + +- [run_h1_interpretable_features.py](../scripts/run_h1_interpretable_features.py) + +Что делает: + +- `A6`: baseline-модели на признаках; +- `A7`: устойчивость и интерпретация признаков. + +Проверяемая гипотеза: + +- интерпретируемые признаки действительно помогают отличать бласты. + +Что ожидается на выходе: + +- `outputs/tables/object_level_metrics_baselines.csv`; +- `outputs/tables/baseline_model_leaderboard.csv`; +- `outputs/predictions/object_level_baseline_predictions.parquet`; +- `outputs/tables/top_features_stability.csv`; +- `outputs/figures/pr_curves_baselines.png`; +- `outputs/figures/roc_curves_baselines.png`; +- `outputs/reports/feature_interpretation_notes.md`. + +Summary: + +- [h1_interpretable_features_summary.md](../outputs/reports/hypotheses/h1_interpretable_features_summary.md) + +### `h2` + +Файл: + +- [run_h2_mask_effect.py](../scripts/run_h2_mask_effect.py) + +Что делает: + +- масочная часть `A8`. + +Проверяемая гипотеза: + +- маска клетки улучшает признаки и/или качество классификации. + +Что ожидается на выходе: + +- `outputs/tables/ablation_mask_usage.csv`. + +Summary: + +- [h2_mask_effect_summary.md](../outputs/reports/hypotheses/h2_mask_effect_summary.md) + +### `h3` + +Файл: + +- [run_h3_noise_regimes.py](../scripts/run_h3_noise_regimes.py) + +Что делает: + +- режимы корпуса и шумовая часть `A8`. + +Проверяемая гипотеза: + +- шумовые классы и более реалистичный режим корпуса усложняют задачу. + +Что ожидается на выходе: + +- `outputs/tables/ablation_dataset_regimes.csv`; +- `outputs/figures/ablation_summary.png` при совместном запуске с `H2` или через `all`. + +Summary: + +- [h3_noise_regimes_summary.md](../outputs/reports/hypotheses/h3_noise_regimes_summary.md) + +### `h4` + +Файл: + +- [run_h4_aggregation.py](../scripts/run_h4_aggregation.py) + +Что делает: + +- `A10-A11`: image-level и patient-level агрегирование, итоговые фигуры и таблицы. + +Проверяемая гипотеза: + +- object-level score можно превратить в practically useful оценку изображения и пациента. + +Что ожидается на выходе: + +- `outputs/tables/image_level_metrics.csv`; +- `outputs/tables/patient_level_ranking_metrics.csv`; +- `outputs/predictions/image_level_scores.parquet`; +- `outputs/predictions/patient_level_scores.parquet`; +- `outputs/tables/figure_manifest.csv`; +- `outputs/tables/table_manifest.csv`. + +Summary: + +- [h4_aggregation_summary.md](../outputs/reports/hypotheses/h4_aggregation_summary.md) + +### `h5` + +Файл: + +- [run_h5_deep_vs_handcrafted.py](../scripts/run_h5_deep_vs_handcrafted.py) + +Что делает: + +- подготовка `modeling_workset`; +- `A9`: ограниченные deep-baseline. + +Проверяемая гипотеза: + +- lightweight deep-модели можно честно сравнить с handcrafted baseline на тех же данных. + +Что ожидается на выходе: + +- `outputs/dataset_index/task_a43_full_realistic_modeling_workset.parquet`; +- `outputs/crops/roi_manifest_modeling_workset.parquet`; +- `outputs/tables/deep_vs_handcrafted.csv`; +- `outputs/predictions/object_level_deep_predictions.parquet`; +- `outputs/figures/deep_training_curves.png`; +- `outputs/reports/a9_deep_qc.md`. + +Summary: + +- [h5_deep_vs_handcrafted_summary.md](../outputs/reports/hypotheses/h5_deep_vs_handcrafted_summary.md) + +### `all` + +Файл: + +- [run_all_hypotheses.py](../scripts/run_all_hypotheses.py) + +Что делает: + +- последовательно запускает `foundation`, `H1`, `H2`, `H3`, `H4`, `H5`. + +Summary: + +- [run_all_hypotheses_summary.md](../outputs/reports/hypotheses/run_all_hypotheses_summary.md) + +## 4. Порядок запуска по зависимостям + +Если вы хотите выполнять эксперимент по частям, используйте такой порядок: + +1. `foundation` +2. `h1` +3. `h2` +4. `h3` +5. `h4` +6. `h5` + +Почему именно так: + +- `H1-H5` зависят от базовых артефактов `foundation`; +- `H2`, `H3`, `H4`, `H5` используют результаты `H1`; +- `H5` дополнительно строит `modeling_workset`. + +Если нужна полная пересборка без ручного контроля, используйте: + +```bash +make all +``` + +или + +```bash +./.venv/bin/python scripts/run_all_hypotheses.py +``` + +Если артефакты уже собраны, эта команда отработает быстро и просто переиспользует проверенные результаты. + +## 5. Где смотреть результаты + +Основные директории: + +- [outputs/reports/hypotheses](../outputs/reports/hypotheses) — короткие summary по каждому блоку; +- [outputs/tables](../outputs/tables) — численные таблицы метрик; +- [outputs/figures](../outputs/figures) — итоговые графики; +- [outputs/predictions](../outputs/predictions) — предсказания моделей; +- [outputs/checkpoints](../outputs/checkpoints) — сохраненные модели. + +Практически это означает: + +- если нужен “сырой” численный результат — смотрите `outputs/tables/`; +- если нужны предсказания моделей — смотрите `outputs/predictions/`; +- если нужны изображения и графики для статьи или диссертации — смотрите `outputs/figures/`; +- если нужна проверка корректности этапов — смотрите `outputs/reports/` и `outputs/reports/hypotheses/`. + +Подробный человекочитаемый отчет: + +- [E01_detailed_report.md](../reports/E01_detailed_report.md) + +## 6. Как проверять, что все прошло корректно + +Минимальная проверка: + +```bash +make repro-check +``` + +Что это означает: + +- выполнен полный воспроизводимый запуск с пересчётом отсутствующих этапов; +- после этого выполнены локальные тесты. + +Проверенный сценарий публикационного репозитория: + +1. Установить зависимости из `requirements.txt`. +2. Создать `configs/datasets/local_paths.json`. +3. Выполнить `make all`. +4. Выполнить `make test`. +5. Открыть `outputs/reports/hypotheses/run_all_hypotheses_summary.md`. + +Также полезно открыть: + +- [run_all_hypotheses_summary.md](../outputs/reports/hypotheses/run_all_hypotheses_summary.md) + +## 7. Важное ограничение среды + +`xgboost` не входит в обязательные зависимости. Соответствующие конфигурации +автоматически используют `HistGradientBoostingClassifier` из scikit-learn. + +Практическое следствие: + +- конфигурации, которые в таблицах помечены как `xgboost`, фактически выполняются через `HistGradientBoosting` fallback. + +Это ограничение уже отражено в: + +- [a6_baseline_qc.md](../outputs/reports/a6_baseline_qc.md) +- [a9_deep_qc.md](../outputs/reports/a9_deep_qc.md) diff --git a/plan/current/experiment_plan_detailed.md b/plan/current/experiment_plan_detailed.md new file mode 100644 index 0000000..65dfb41 --- /dev/null +++ b/plan/current/experiment_plan_detailed.md @@ -0,0 +1,802 @@ +# Детализированный план эксперимента E01 + +## 1. Назначение документа + +Этот документ является рабочим планом первого экспериментального проекта по итогам обзорной статьи о текстурных признаках. Его задача — перевести обзорные выводы статьи в последовательность конкретных программных шагов, артефактов и проверяемых гипотез. + +План специально разделен на две части: + +- часть A — то, что можно и нужно делать на доступном локальном наборе данных; +- часть B — то, что сохраняется в общем исследовательском замысле, но требует дополнительных данных или дополнительной экспертной разметки. + +## 2. Связь с исходным планом статьи + +Исходный общий план сохранен в [plan/source_article_plan/experiment_plan.md](../../plan/source_article_plan/experiment_plan.md). После анализа реального корпуса данных и структуры разметки уточняется следующая матрица соответствия. + +| Направление исходного плана | Статус на текущих данных | Что именно можно делать сейчас | Что переносится на будущее | +|---|---|---|---| +| Интерпретируемый morpho-texture baseline | Полностью применимо | `blast / non-blast`, сравнение `bbox` и `mask`, patient-level оценка, интерпретация признаков | `lymphoblast / myeloblast` | +| Гибридная модель для малого объема данных и доменного сдвига | Частично применимо | внутреннее сравнение признаковых и легких deep-моделей, low-data режимы внутри одного корпуса | полноценный cross-dataset transfer и внешняя валидация | +| Интерпретируемые deep и transformer-подобные модели | Частично применимо | ограниченные object-level deep baselines на текущем корпусе | подтипизация бластов, MIL с case-level метками, сильная интерпретация по экспертным ROI | +| Анализаторно-ориентированная slide-level схема | Частично применимо | image-level агрегирование как приближение к задаче анализатора | истинный slide-level и case-level workflow | + +## 3. Главная научная цель текущего этапа + +На текущем корпусе нужно строго проверить следующую гипотезу: + +Компактный и интерпретируемый набор морфометрических, цветовых и текстурных признаков, извлеченных из объектов detection-разметки и сегментационных масок, способен обеспечить устойчивое различение `blast / non-blast` на данных разных пациентов и дать практически полезный уровень image-level агрегирования для задач автоматического анализатора. + +## 4. Ограничения текущих данных + +Перед реализацией кода нужно принять следующие ограничения как часть постановки, а не как технические мелочи: + +1. В разметке отсутствует разделение `lymphoblast` и `myeloblast`; следовательно, текущий эксперимент не может служить доказательством для подтипизации бластов. +2. Основной надежный уровень оценки — пациент, а не объект и не изображение. +3. В разметке присутствуют классы качества и артефактов (`Bad cells`, `artifacts`, `gumpricht shadows`), которые нельзя механически смешивать с морфологически корректными клетками без отдельного протокола. +4. Маски покрывают почти весь корпус, но не полностью; нужна двухветочная логика `mask-aware` и `bbox-only fallback`. +5. Часть label-файлов содержит class id в формате `float`, следовательно, парсер должен явно использовать преобразование `int(float(raw_class_id))`. +6. В сыром каталоге присутствует специальная директория `part-1`, не соответствующая формату patient id; до уточнения ее происхождения она должна быть исключена из основного patient-level протокола и рассматриваться только как возможный вспомогательный корпус. + +## 5. Рабочая схема проекта + +Рекомендуется реализовать эксперимент не как один скрипт, а как последовательность самостоятельных этапов с фиксированными артефактами: + +1. Индексация и аудит данных. +2. Формирование канонических разбиений по пациентам. +3. Извлечение объектов и ROI. +4. Вычисление признаков. +5. Обучение признаковых моделей. +6. Ограниченные deep baselines. +7. Image-level агрегирование. +8. Подготовка таблиц, графиков и воспроизводимых отчетов. + +## 6. Часть A. Эксперименты, которые можно сделать на текущих данных + +### A0. Подготовка проектного контракта данных + +#### Цель + +Зафиксировать единый источник истины по пациентам, изображениям, объектам, маскам и классам. + +#### Входы + +- `data` +- `data_masks` + +Для первой фазы основным корпусом считаются только `122` каталогов, которые имеют формат patient id. Директория `part-1` должна индексироваться отдельно и не должна автоматически попадать в основное разбиение. + +#### Что нужно реализовать + +1. Парсер patient-каталогов. +2. Парсер `predefined_classes.txt`. +3. Парсер detection-разметки с нормализацией `int(float(class_id))`. +4. Парсер масок `*_class__obj_.png`. +5. Проверку, что `obj_` совпадает с порядком строк в label-файле. + +#### Выходные артефакты + +- `outputs/dataset_index/patient_inventory.csv` +- `outputs/dataset_index/image_index.parquet` +- `outputs/dataset_index/object_index.parquet` +- `outputs/dataset_index/class_mapping.json` +- `outputs/dataset_index/mask_coverage_report.csv` +- `outputs/reports/data_audit_runtime.md` + +#### Требования к `object_index` + +Каждая строка должна содержать как минимум: + +- `patient_id` +- `image_id` +- `image_path` +- `label_path` +- `mask_path` или `null` +- `object_id_within_image` +- `class_id` +- `class_name` +- `x_center_norm` +- `y_center_norm` +- `width_norm` +- `height_norm` +- `has_mask` +- `is_blast` +- `is_quality_or_artifact` + +#### Критерий завершения этапа + +Артефакт `object_index.parquet` воспроизводимо пересобирается и полностью описывает весь текущий корпус. + +#### Проверяемые результаты этапа + +- построен единый индекс пациентов, изображений и объектов для основного patient-level корпуса; +- для каждого объекта однозначно определены `class_id`, `class_name`, координаты bbox, признак `is_blast` и наличие маски; +- построен отчет о покрытии масками и обо всех найденных несоответствиях между `data` и `data_masks`; +- зафиксирована отдельная запись о специальном каталоге `part-1`, который не смешивается с основным patient-level корпусом. + +#### Как оценить качество результата + +- число пациентских каталогов, изображений, label-файлов и объектов совпадает с зафиксированными значениями из [docs/data_audit.md](../../docs/data_audit.md); +- доля объектов без `class_name`, bbox-координат или `patient_id` равна `0`; +- все пути `image_path`, `label_path`, `mask_path` валидны на файловой системе; +- нет дубликатов по ключу `patient_id + image_id + object_id_within_image`; +- отчет `mask_coverage_report.csv` позволяет отдельно проверить случаи без масок, пустые label-файлы и исключенный каталог `part-1`. + +#### Предлагаемые модули кода + +- `src/dataset/build_index.py` +- `src/dataset/parse_detection_labels.py` +- `src/dataset/parse_masks.py` +- `src/dataset/class_mapping.py` +- `tests/test_dataset_index.py` + +### A1. Формирование аналитических подмножеств + +#### Цель + +Развести разные исследовательские режимы, чтобы не смешивать клинически осмысленную задачу распознавания клеток и задачу устойчивости к плохому качеству и артефактам. + +#### Подмножества, которые нужно сформировать + +1. `full_realistic_set` + Используются все объекты, включая `Bad cells`, `artifacts` и `gumpricht shadows`. + +2. `clean_cell_set` + Исключаются только `Bad cells` и `artifacts`. + Объем по текущей оценке для основного patient-level корпуса: `78 517` объектов. + +3. `strict_morphology_set` + Исключаются `Bad cells`, `artifacts` и `gumpricht shadows`. + Объем по текущей оценке для основного patient-level корпуса: `74 917` объектов. + +4. `mask_ready_set` + Объекты, у которых есть валидная маска. + +#### Почему это важно + +- `full_realistic_set` нужен для анализаторно-ориентированной постановки. +- `clean_cell_set` нужен для более честной оценки распознавания клеток. +- `strict_morphology_set` нужен для сравнения текстурных и морфометрических признаков без сильного влияния шумовых классов. + +#### Выходные артефакты + +- `outputs/dataset_index/subset_full_realistic.parquet` +- `outputs/dataset_index/subset_clean_cell.parquet` +- `outputs/dataset_index/subset_strict_morphology.parquet` +- `outputs/dataset_index/subset_mask_ready.parquet` + +#### Критерий завершения этапа + +Для каждого подмножества автоматически строится таблица распределения классов по пациентам и объектам. + +#### Проверяемые результаты этапа + +- сформированы четыре формальные версии корпуса с прозрачными правилами включения и исключения объектов; +- для каждого подмножества рассчитаны размеры, class distribution и patient coverage; +- зафиксировано, какие именно классы и в каком объеме исключаются при переходе от `full_realistic_set` к `clean_cell_set` и `strict_morphology_set`. + +#### Как оценить качество результата + +- в `clean_cell_set` отсутствуют классы `Bad cells` и `artifacts`; +- в `strict_morphology_set` дополнительно отсутствует класс `gumpricht shadows`; +- число объектов в подмножествах строго совпадает с фильтрационными правилами; +- построенная таблица распределения позволяет проверить, что ни одно подмножество не потеряло пациентов или изображения неожиданным образом; +- для `mask_ready_set` отдельно оценивается доля покрытия масками и список исключенных объектов. + +### A2. Построение patient-level разбиений + +#### Цель + +Исключить утечку информации между выборками и подготовить единый протокол оценки для всех моделей. + +#### Основные правила + +1. Один пациент может входить только в один из наборов `train`, `validation`, `test`. +2. Все модели должны использовать одни и те же финальные разбиения. +3. Внутренняя настройка гиперпараметров должна происходить только внутри train-части. +4. В базовом протоколе участвуют только `122` patient-идентифицированных каталога; `part-1` в это разбиение не входит. + +#### Рекомендуемый протокол + +1. Финальный внешний hold-out по пациентам: + - `70%` пациентов — train; + - `15%` пациентов — validation; + - `15%` пациентов — test. + +2. Внутри train: + - `StratifiedGroupKFold` или собственная стратификация по доле `blast` у пациента; + - минимум `5` повторов для признаковых моделей. + +#### Дополнительные таблицы для стратификации + +Нужно посчитать для каждого пациента: + +- число объектов; +- число `blast`; +- долю `blast`; +- число изображений; +- число изображений с `blast`; +- долю объектов без масок. + +#### Выходные артефакты + +- `outputs/splits/patient_summary.csv` +- `outputs/splits/split_v1.json` +- `outputs/splits/split_v1_train_patients.txt` +- `outputs/splits/split_v1_val_patients.txt` +- `outputs/splits/split_v1_test_patients.txt` +- `outputs/tables/class_distribution_by_split.csv` + +#### Критерий завершения этапа + +Разбиение стабильно, воспроизводимо и используется далее без ручных корректировок. + +#### Проверяемые результаты этапа + +- сформирован единый `split_v1`, используемый всеми последующими моделями; +- для каждого пациента определена принадлежность к `train`, `validation` или `test`; +- рассчитана таблица стратификации по числу объектов, числу `blast`, доле `blast` и числу изображений; +- сохранены текстовые списки пациентов и JSON-описание разбиения. + +#### Как оценить качество результата + +- пересечение множеств пациентов между `train`, `validation` и `test` равно `0`; +- распределение класса `blast` и размера выборки между частями разбиения не содержит грубых перекосов; +- при повторной сборке с тем же `random_state` получается идентичный `split_v1`; +- `part-1` отсутствует во всех split-файлах; +- таблица `class_distribution_by_split.csv` позволяет отдельно проверить баланс по объектам и по пациентам. + +#### Предлагаемые модули кода + +- `src/dataset/build_patient_summary.py` +- `src/dataset/make_group_splits.py` +- `tests/test_group_split_no_leakage.py` + +### A3. Извлечение object-level кропов и областей интереса + +#### Цель + +Подготовить единое представление объекта для разных семейств признаков и моделей. + +#### Нужно реализовать три режима ROI + +1. `bbox_crop` + Прямоугольный кроп по YOLO-рамке. + +2. `mask_crop` + Кроп по bounding box объекта, внутри которого пиксели вне маски зануляются или заменяются фоном. + +3. `mask_tight_crop` + Плотный кроп по минимальному прямоугольнику маски. + +#### Дополнительные решения + +1. Зафиксировать размер выходного изображения для deep baselines, например `224x224`. +2. Для признаковых моделей хранить и исходный масштаб, и нормализованную версию. +3. Сохранять метаданные о масштабировании и паддинге. + +#### Выходные артефакты + +- `outputs/crops/bbox///__obj_.png` +- `outputs/crops/mask///__obj_.png` +- `outputs/crops/mask_tight///__obj_.png` +- `outputs/crops/roi_manifest.parquet` + +#### Контроль качества + +Нужно автоматически проверять: + +- что число экспортированных объектов совпадает с ожидаемым числом строк индекса; +- что геометрия кропа не выходит за пределы исходного изображения; +- что объект с маской действительно имеет ненулевую площадь маски. + +#### Проверяемые результаты этапа + +- экспортированы три согласованные версии ROI: `bbox_crop`, `mask_crop`, `mask_tight_crop`; +- построен единый `roi_manifest.parquet`, связывающий исходный объект, пути к кропам, размеры ROI и режим подготовки; +- сформирован отчет о неэкспортированных или проблемных ROI. + +#### Как оценить качество результата + +- число `bbox_crop` совпадает с числом объектов выбранного подмножества; +- число `mask_crop` и `mask_tight_crop` совпадает с числом объектов, у которых есть валидная маска; +- доля кропов с выходом за границы, нулевой площадью маски или битыми изображениями равна `0`; +- по визуальной выборке можно проверить, что bbox- и mask-кропы действительно содержат нужный объект, а не соседние клетки; +- `roi_manifest.parquet` позволяет автоматически сравнивать количество исходных объектов и количество реально экспортированных ROI. + +#### Предлагаемые модули кода + +- `src/preprocessing/extract_bbox_crops.py` +- `src/preprocessing/extract_mask_crops.py` +- `src/preprocessing/image_io.py` +- `tests/test_crop_extraction.py` + +### A4. Базовая объектная постановка `blast / non-blast` + +#### Цель + +Получить строгий и воспроизводимый baseline, который напрямую отвечает на главный вопрос статьи: полезны ли текстурные признаки в условиях реальных данных и patient-level оценки. + +#### Основная целевая переменная + +- `target_binary = 1`, если `class_id == 7`; +- `target_binary = 0` для остальных объектов выбранного подмножества. + +#### Какие версии задачи нужно решить + +1. `Task A4.1` + `blast / non-blast` на `clean_cell_set`. + +2. `Task A4.2` + `blast / non-blast` на `strict_morphology_set`. + +3. `Task A4.3` + `blast / everything_else` на `full_realistic_set`. + +Последняя постановка особенно важна для автоматического анализатора, потому что она показывает устойчивость модели к артефактам и классам плохого качества. + +#### Артефакты + +- `outputs/dataset_index/task_a41_clean_binary.parquet` +- `outputs/dataset_index/task_a42_strict_binary.parquet` +- `outputs/dataset_index/task_a43_full_realistic_binary.parquet` +- `outputs/tables/task_prevalence_summary.csv` +- `outputs/reports/task_definition_report.md` + +#### Проверяемые результаты этапа + +- подготовлены три бинарные постановки с явно определенной целевой переменной; +- для каждой постановки рассчитаны числа `blast`, `non-blast`, доля положительного класса и список hard-negative классов; +- зафиксировано, какие именно задачи используются для дальнейшего сравнения моделей. + +#### Как оценить качество результата + +- во всех трех файлах задача `blast / non-blast` формируется строго по правилу `class_id == 7`; +- `task_prevalence_summary.csv` позволяет проверить дисбаланс классов и сложность каждой постановки; +- во всех постановках сохраняется patient-level совместимость со `split_v1`; +- по отчету `task_definition_report.md` можно проверить, какие классы формируют трудные отрицательные примеры и насколько различаются режимы `clean`, `strict` и `full_realistic`. + +### A5. Вычисление признаков + +#### Семейства признаков, которые нужно реализовать + +1. Морфометрические признаки: + - площадь bbox; + - площадь маски; + - периметр маски; + - круглотность; + - эксцентриситет; + - отношение площади маски к площади bbox; + - характеристики контура. + +2. Цветовые признаки: + - средние и стандартные отклонения по каналам RGB; + - признаки в HSV и Lab; + - цветовые квантили; + - показатели цветовой неоднородности. + +3. Текстурные признаки: + - GLCM и признаки Харалика; + - LBP; + - статистики градиента; + - признаки Лапласиана; + - фрактальные или мульти-масштабные показатели; + - вейвлет-признаки; + - спектральные признаки Фурье при необходимости. + +#### Режимы расчета признаков + +1. по `bbox_crop`; +2. по `mask_crop`; +3. комбинированно `bbox + mask features`. + +#### Выходные артефакты + +- `outputs/features/features_bbox.parquet` +- `outputs/features/features_mask.parquet` +- `outputs/features/features_combined.parquet` +- `outputs/features/feature_dictionary.md` +- `outputs/tables/feature_missingness.csv` +- `outputs/reports/feature_extraction_qc.md` + +#### Проверяемые результаты этапа + +- для каждого объекта вычислены формальные морфометрические, цветовые и текстурные признаки; +- построены три согласованные матрицы признаков: `bbox`, `mask`, `combined`; +- сформирован словарь признаков с их определениями и зоной вычисления; +- построен отчет о пропусках, константных признаках и аномальных значениях. + +#### Как оценить качество результата + +- число строк в матрицах признаков совпадает с числом объектов соответствующего режима; +- доля пропусков по каждому признаку рассчитана и явно зафиксирована; +- константные и вырожденные признаки отделены от рабочих признаков; +- можно проверить диапазоны значений, число признаков каждого семейства и различия между `bbox`, `mask` и `combined`; +- `feature_extraction_qc.md` должен позволять быстро увидеть, какие признаки нужно удалить или стандартизовать до обучения моделей. + +#### Предлагаемые модули кода + +- `src/features/extract_morphology.py` +- `src/features/extract_color.py` +- `src/features/extract_texture_glcm.py` +- `src/features/extract_texture_lbp.py` +- `src/features/extract_texture_wavelet.py` +- `src/features/feature_registry.py` +- `tests/test_feature_shapes.py` + +### A6. Признаковые baseline-модели + +#### Цель + +Сравнить информативность разных наборов признаков до подключения сложных нейросетевых архитектур. + +#### Последовательность моделей + +1. Logistic Regression +2. Linear SVM +3. RBF SVM +4. Random Forest +5. XGBoost или LightGBM + +#### Для каждой модели нужно сравнить + +1. только морфометрию; +2. только цвет; +3. только текстуру; +4. морфометрия + цвет; +5. морфометрия + текстура; +6. цвет + текстура; +7. полный набор признаков. + +#### Метрики + +Основные: + +- PR-AUC; +- ROC-AUC; +- balanced accuracy; +- macro-F1; +- recall для класса `blast`; +- specificity; +- Matthews correlation coefficient. + +Дополнительные: + +- calibration error; +- Brier score; +- доверительные интервалы по повторам разбиения. + +#### Артефакты + +- `outputs/tables/object_level_metrics_baselines.csv` +- `outputs/tables/feature_group_ablation.csv` +- `outputs/predictions/object_level_baseline_predictions.parquet` +- `outputs/figures/pr_curves_baselines.png` +- `outputs/figures/roc_curves_baselines.png` +- `outputs/tables/baseline_model_leaderboard.csv` +- `outputs/figures/confusion_matrix_best_baseline.png` + +#### Проверяемые результаты этапа + +- обучены все запланированные признаковые baseline-модели на одинаковом `split_v1`; +- получена таблица метрик по каждой модели и каждому режиму признаков; +- построен лидерборд лучших моделей и лучший baseline для дальнейших абляций; +- сохранены object-level вероятности и классовые предсказания для последующего image-level агрегирования. + +#### Как оценить качество результата + +- в `object_level_metrics_baselines.csv` присутствуют все основные метрики и доверительные интервалы; +- `baseline_model_leaderboard.csv` позволяет проверить, какая комбинация `модель + режим признаков + subset` действительно лучшая; +- по confusion matrix и PR/ROC-кривым можно проверить не только среднее качество, но и характер ошибок; +- отдельно оценивается recall класса `blast`, так как именно он является клинически критичным. + +### A7. Отбор признаков и интерпретация + +#### Цель + +Показать не только качество, но и морфологический смысл решения. + +#### Что нужно проверить + +1. Устойчивость ranking признаков между разными фолдами. +2. Согласованность ranking между моделями. +3. Какие именно признаки стабильно усиливают разделение `blast / non-blast`. + +#### Рекомендуемые методы + +- permutation importance; +- SHAP для деревьев и бустинга; +- регуляризованные линейные модели; +- повторяемый отбор признаков с подсчетом частоты выбора. + +#### Артефакты + +- `outputs/tables/top_features_stability.csv` +- `outputs/figures/shap_summary_top_model.png` +- `outputs/figures/feature_importance_stability.png` +- `reports/feature_interpretation_notes.md` + +#### Проверяемые результаты этапа + +- получен устойчивый ranking наиболее информативных признаков; +- зафиксирована повторяемость top-признаков между фолдами и между моделями; +- для лучшей модели построены интерпретационные диаграммы и текстовые пояснения. + +#### Как оценить качество результата + +- `top_features_stability.csv` позволяет проверить частоту появления признака в top-N списках разных запусков; +- по SHAP и permutation importance можно сравнить, совпадает ли ранжирование между разными методами интерпретации; +- в `feature_interpretation_notes.md` должно быть указано, какие признаки имеют ясный морфологический смысл и какие остаются только статистически значимыми; +- результат считается сильным, если ключевые признаки не меняются хаотично от фолда к фолду. + +#### Практический результат + +Этот этап должен дать содержательные формулировки для статьи и диссертации: какие текстурные и морфометрические свойства чаще всего ассоциированы с бластными объектами в данном корпусе. + +### A8. Абляции по роли маски и шумовых классов + +#### Цель + +Отдельно проверить, дают ли маски и разные режимы очистки корпуса заметный прирост качества. + +#### Обязательные абляции + +1. `bbox-only` против `mask-only`. +2. `mask-only` против `bbox + mask`. +3. `clean_cell_set` против `strict_morphology_set`. +4. `strict_morphology_set` против `full_realistic_set`. +5. обучение на полном дисбалансном наборе против обучения с class weights. +6. обучение на полном признаковом наборе против отбора признаков. + +#### Артефакты + +- `outputs/tables/ablation_mask_usage.csv` +- `outputs/tables/ablation_dataset_regimes.csv` +- `outputs/figures/ablation_summary.png` + +#### Проверяемые результаты этапа + +- измерен вклад масок в качество модели; +- измерено влияние очистки корпуса и включения шумовых классов; +- зафиксированы различия между полным набором признаков и урезанными вариантами; +- отдельно проверено влияние class weights и feature selection. + +#### Как оценить качество результата + +- `ablation_mask_usage.csv` должен показывать прирост или потерю качества при переходе между `bbox`, `mask` и `bbox+mask`; +- `ablation_dataset_regimes.csv` должен показывать, насколько ухудшается качество в более реалистичном `full_realistic_set`; +- итоговая фигура `ablation_summary.png` должна позволять быстро увидеть самые важные факторы изменения качества; +- результат считается убедительным, если различия интерпретируемы и воспроизводимы, а не случайны в пределах погрешности. + +### A9. Ограниченные deep baselines на текущем корпусе + +#### Цель + +Не строить сразу сложную нейросетевую историю, а получить честную точку сравнения для признаковых моделей на том же split и тех же данных. + +#### Что можно делать уже сейчас + +1. Легкая CNN на `bbox_crop`. +2. Та же CNN на `mask_crop`. +3. CNN + tabular late fusion на объединенных признаках. + +#### Что нельзя обещать на этом этапе + +- убедительное сравнение с сильными трансформерными моделями из литературы; +- выводы о подтипизации бластов; +- выводы о переносимости на другие лаборатории. + +#### Артефакты + +- `outputs/tables/deep_vs_handcrafted.csv` +- `outputs/predictions/object_level_deep_predictions.parquet` +- `outputs/checkpoints/cnn_bbox_best.pt` +- `outputs/checkpoints/cnn_mask_best.pt` +- `outputs/logs/cnn_training_history.json` +- `outputs/figures/deep_training_curves.png` + +#### Проверяемые результаты этапа + +- обучены ограниченные deep baselines на тех же patient-level split, что и признаковые модели; +- получено прямое сравнение `handcrafted`, `deep` и `late fusion`; +- сохранены лучшие checkpoint-файлы и история обучения. + +#### Как оценить качество результата + +- `deep_vs_handcrafted.csv` позволяет сравнить качество deep-подходов с лучшим признаковым baseline на одних и тех же данных; +- по `deep_training_curves.png` можно проверить переобучение, недообучение и устойчивость обучения; +- по `cnn_training_history.json` можно оценить число эпох до сходимости, лучшую эпоху и разрыв между train и validation; +- deep-этап считается полезным, если он дает либо измеримый прирост качества, либо честно показывает, что в текущем low-complexity режиме интерпретируемые признаки не уступают легкой CNN. + +### A10. Анализаторно-ориентированное image-level агрегирование + +#### Цель + +Сделать переход от классификации отдельного объекта к постановке, более близкой к автоматическому анализатору: содержит ли изображение хотя бы один вероятный бласт и насколько высоко модель ранжирует такие изображения. + +#### Доступные сейчас постановки + +1. `image_contains_blast` + Изображение считается положительным, если среди его объектов есть хотя бы один `blast`. + +2. `top_k_image_ranking` + Изображения сортируются по максимальному или агрегированному blast-score объектов. + +3. `patient_risk_score` + Для пациента агрегируется статистика по всем изображениям: + - максимум blast-score; + - доля изображений с blast-score выше порога; + - средний score по top-K объектам. + +#### Способы агрегирования, которые нужно сравнить + +1. `max object score`; +2. `mean top-3 scores`; +3. `count of objects above threshold`; +4. `soft noisy-or`. + +#### Метрики + +- image-level PR-AUC; +- image-level recall при фиксированной доле ложных тревог; +- patient-level sensitivity; +- top-K recall для изображений с бластами. + +#### Почему это важно + +Этот этап лучше всего связывает текущие данные со статьей и будущей диссертацией: даже не имея настоящих slide-level меток, мы уже можем проверить, насколько object-level модель пригодна как подсистема отбора подозрительных изображений для анализатора. + +#### Артефакты + +- `outputs/tables/image_level_metrics.csv` +- `outputs/tables/patient_level_ranking_metrics.csv` +- `outputs/predictions/image_level_scores.parquet` +- `outputs/predictions/patient_level_scores.parquet` +- `outputs/figures/image_ranking_recall_curve.png` +- `outputs/figures/patient_ranking_curve.png` + +#### Проверяемые результаты этапа + +- object-level модель переведена в image-level и patient-level постановку; +- рассчитаны image-level и patient-level score для всех объектов наблюдения; +- определен лучший способ агрегирования object scores для анализаторно-ориентированного сценария. + +#### Как оценить качество результата + +- `image_level_metrics.csv` позволяет сравнить разные способы агрегирования по PR-AUC и recall при ограниченной ложной тревоге; +- `patient_level_ranking_metrics.csv` позволяет оценить, насколько хорошо пациент с бластами поднимается в верх ранжированного списка; +- `image_ranking_recall_curve.png` и `patient_ranking_curve.png` позволяют визуально проверить пригодность модели для предварительного отбора подозрительных изображений и пациентов; +- этап считается особенно успешным, если даже простой object-level baseline обеспечивает высокое top-K покрытие изображений и пациентов с бластами. + +### A11. Минимальный набор визуализаций для статьи и диссертации + +#### Нужно подготовить + +1. Схему пайплайна обработки данных. +2. Таблицу классов и объема корпуса. +3. Примеры `bbox_crop` и `mask_crop`. +4. График покрытия масками. +5. Таблицы baseline-метрик. +6. График устойчивости top-признаков. +7. Схему image-level агрегирования. + +#### Артефакты + +- `outputs/figures/pipeline_overview.png` +- `outputs/figures/example_rois.png` +- `outputs/figures/mask_coverage.png` +- `outputs/tables/dataset_summary.csv` +- `outputs/tables/figure_manifest.csv` +- `outputs/tables/table_manifest.csv` + +#### Проверяемые результаты этапа + +- подготовлен минимально достаточный комплект иллюстраций и таблиц для статьи и диссертации; +- каждая фигура и таблица привязана к конкретному этапу эксперимента и конкретному исходному файлу; +- оформлен список всех визуальных артефактов, которые можно использовать при написании текста работы. + +#### Как оценить качество результата + +- по `figure_manifest.csv` и `table_manifest.csv` можно проверить полноту пакета визуализаций; +- каждая таблица должна совпадать по числам с соответствующими CSV-артефактами модели и данных; +- каждая фигура должна строиться воспроизводимо из проектных данных, а не вручную; +- итоговый комплект считается достаточным, если по нему уже можно собирать разделы статьи, главы диссертации и презентационные материалы без дополнительного пересчета результатов. + +## 7. Часть B. Эксперименты, требующие дополнительных данных + +### B1. Разделение лимфобластов и миелобластов + +Для этой постановки обязательно нужен новый корпус или доразметка текущего корпуса, где класс `blast` будет разделен минимум на: + +- `lymphoblast`; +- `myeloblast`; +- при необходимости `ambiguous blast-like`. + +Только после этого можно переносить на практику исходную гипотезу статьи о class-specific текстурных различиях лимфобластов и миелобластов. + +### B2. Полноценный эксперимент по доменному сдвигу + +Нужны данные хотя бы из еще одного источника: + +- другая лаборатория; +- другой сканер; +- другая методика окраски; +- другой режим подготовки препаратов. + +Тогда можно сравнивать: + +- handcrafted only; +- deep only; +- hybrid. + +И только в этом случае будет корректно обсуждать устойчивость к междоменному переносу. + +### B3. Slide-level и case-level постановка + +Чтобы реализовать сильную анализаторно-ориентированную схему, требуются: + +- полноценные slide-level метки; +- case-level клинические метки; +- желательно информация о конечном диагнозе или наличии резидуальных бластов на уровне случая. + +Без этого текущий этап может дать только image-level приближение. + +### B4. Экспертная клиническая интерпретация + +Для глубоких моделей и усиленной интерпретации нужны: + +- ручные контуры ядра и цитоплазмы; +- выделение нуклеол; +- при AML — специальные признаки типа палочек Ауэра; +- экспертная blinded-оценка областей, на которые опирается модель. + +### B5. Продвинутые deep и MIL-схемы + +Полноценные transformer-подобные модели, MIL, weak supervision и причинно-структурные архитектуры имеет смысл выносить в отдельный этап только после того, как будет завершен и воспроизводимо оформлен этап A. + +## 8. Приоритеты реализации + +Код следует писать именно в таком порядке: + +1. `A0` — индексация и аудит. +2. `A1` — аналитические подмножества. +3. `A2` — patient-level split. +4. `A3` — извлечение ROI. +5. `A5` — вычисление признаков. +6. `A6` — признаки и baseline-модели. +7. `A7` — интерпретация признаков. +8. `A8` — абляции. +9. `A10` — image-level агрегирование. +10. `A9` — ограниченные deep baselines. + +Такой порядок выбран специально: сначала нужно получить строго воспроизводимый, интерпретируемый и диссертационно полезный baseline, а уже потом усложнять модельную часть. + +## 9. Минимальный первый кодовый спринт + +Чтобы начать реализацию немедленно, первым спринтом нужно закрыть следующие задачи: + +1. Написать сборщик `object_index`. +2. Зафиксировать patient-level split. +3. Реализовать экспорт `bbox_crop` и `mask_crop`. +4. Реализовать первый набор признаков: + - площадь; + - периметр; + - компактность; + - RGB-статистики; + - GLCM; + - LBP. +5. Обучить Logistic Regression и Random Forest на `clean_cell_set`. +6. Построить первые object-level и image-level метрики. + +Именно после этого спринта в проекте уже появится рабочая база, на которую можно последовательно наращивать остальной код. + +## 10. Что этот эксперимент даст для статьи и диссертации + +Если этап A будет выполнен качественно, то он уже сам по себе даст: + +- воспроизводимую оценку роли текстурных признаков; +- сравнение признаковых режимов `bbox`, `mask`, `bbox+mask`; +- практический аргумент в пользу patient-level протокола; +- прикладную картину влияния шумовых классов и качества разметки; +- bridge-результат между object-level классификацией и задачей автоматического анализатора. + +Именно это и должно стать центральным результатом первого экспериментального цикла. diff --git a/plan/source_article_plan/README.md b/plan/source_article_plan/README.md new file mode 100644 index 0000000..2d63052 --- /dev/null +++ b/plan/source_article_plan/README.md @@ -0,0 +1,15 @@ +# Source Article Plan + +Этот каталог содержит архивную копию исходного плана экспериментов, вынесенного из обзорной статьи о текстурных признаках. + +Основной файл: + +- [experiment_plan.md](../../plan/source_article_plan/experiment_plan.md) + +Назначение каталога: + +- сохранить исходную формулировку экспериментов, непосредственно следующую из обзорной статьи; +- использовать ее как traceability-слой при переходе к более прикладному и дата-ориентированному плану; +- не редактировать этот файл как основной рабочий документ. + +Актуальный переработанный рабочий план находится в [plan/current/experiment_plan_detailed.md](../../plan/current/experiment_plan_detailed.md). diff --git a/plan/source_article_plan/experiment_plan.md b/plan/source_article_plan/experiment_plan.md new file mode 100644 index 0000000..6dc993e --- /dev/null +++ b/plan/source_article_plan/experiment_plan.md @@ -0,0 +1,378 @@ +# План экспериментов по теме резидуальных лимфо- и миелобластов + +## 1. Назначение документа + +Этот документ является вынесенным из основной обзорной статьи планом экспериментов. Его задача — перевести выводы обзора литературы в конкретные, проверяемые и пригодные к реализации экспериментальные шаги. Основная статья отвечает на вопрос, что известно из литературы. Этот файл отвечает на вопрос, что именно следует проверять дальше в рамках диссертационной работы. + +## 2. Логика перехода от обзора к экспериментам + +Из обзора вытекают четыре устойчивых наблюдения. + +1. Классические текстурные признаки по-прежнему полезны, если данных мало, если нужна интерпретируемость и если требуется понять, какие именно свойства клетки дают вклад в решение. +2. Нейросетевые модели обычно дают более высокий потолок качества, но сильнее зависят от объема и однородности данных. +3. Интерпретируемость нейросетей сама по себе является отдельной исследовательской задачей, потому что в медицине важно не только качество, но и понимание class-specific признаков лимфобластов и миелобластов. +4. Большинство работ заканчивается на уровне одиночной клетки, тогда как для автоматического анализатора нужно решать задачу на уровне всего препарата. + +Поэтому план экспериментов разделен на четыре направления: + +1. интерпретируемый morpho-texture baseline; +2. гибридная модель для ограниченного объема данных и доменного сдвига; +3. интерпретируемые нейросетевые и transformer-подобные модели для поиска новых признаков; +4. анализаторно-ориентированная slide-level схема поиска и подтверждения бластов. + +## 3. Эксперимент 1. Интерпретируемый morpho-texture baseline + +### 3.1. Исследовательский пробел + +Во многих работах текстурные признаки показывают хорошие результаты, но сравнение с современными нейросетями часто проводится на разных наборах данных или в разных условиях валидации. Из-за этого трудно понять, насколько классические признаки действительно сильны сами по себе. + +### 3.2. Гипотеза + +Компактный набор морфологических, цветовых и текстурных признаков способен обеспечить конкурентоспособное и интерпретируемое различение классов `бласт / не бласт` на изображениях препаратов крови в монослое, если оценка ведется честно: с patient-level разделением и на одинаковых данных для всех baseline-моделей. + +Дополнительная частная гипотеза состоит в том, что разные подсемейства признаков будут по-разному работать для лимфобластов и миелобластов: для лимфобластов больший вклад внесут признаки ядерно-цитоплазматического отношения и внутрядерной однородности, а для миелобластов --- признаки нуклеол, цитоплазматической неоднородности и локальной гранулярности. + +### 3.3. Данные + +- Основной целевой корпус: изображения клеток из препаратов крови в монослое после экспертной разметки. +- Вспомогательные публичные данные: совместимые single-cell blood-smear datasets для предварительной отладки пайплайна и выбора признаков. +- Желательно заранее выделить три подмножества: + - лимфобласты; + - миелобласты; + - небластные клетки и морфологически близкие distractor-классы. + +### 3.4. Независимые переменные + +- Набор зон анализа: + - вся клетка; + - только ядро; + - ядро и цитоплазма по отдельности; + - клетка с явным выделением контура. +- Семейство признаков: + - морфометрические; + - цветовые; + - текстурные; + - объединенные морфо-цвето-текстурные. +- Тип текстурных дескрипторов: + - GLCM и признаки Харалика; + - LBP; + - фрактальные признаки; + - признаки Фурье; + - вейвлет-признаки. +- Тип классификатора: + - SVM; + - Random Forest; + - XGBoost; + - небольшой многослойный персептрон. + +### 3.5. Зависимые переменные + +- accuracy; +- balanced accuracy; +- sensitivity отдельно для лимфобластов и миелобластов; +- specificity; +- macro-F1; +- PR-AUC для редких blast-классов; +- calibration error; +- стабильность ranking признаков между повторными разбиениями; +- согласие между top-признаками модели и экспертной морфологической интерпретацией. + +### 3.6. Baseline-источники + +- `putzu2014leucocyte` +- `rawat2017classification` +- `dincic2021morphological` +- `tarquino2023inmop` +- `dasariraju2020detection` + +### 3.7. Протокол + +1. Построить единый конвейер сегментации клетки, ядра и, если возможно, цитоплазмы. +2. Извлечь полный набор признаков и затем провести два режима оценки: + - без отбора признаков; + - с отбором признаков по устойчивым критериям. +3. Использовать patient-level split, чтобы клетки одного пациента не попадали одновременно в train и test. +4. Выполнить повторяемую перекрестную проверку на обучающей части. +5. Один раз финально оценить модель на отложенном тесте. +6. Для лучших моделей отдельно построить профиль признаков для лимфобластов и миелобластов и проверить, насколько эти профили воспроизводятся у разных пациентов. + +### 3.8. Важные абляции + +- убрать по одному семейству признаков; +- сравнить whole-cell и nucleus-only режимы; +- сравнить цветные и нормализованные по окраске изображения; +- сравнить исходные признаки и признаки после уменьшения размерности. + +### 3.9. Риски + +- ошибка сегментации может замаскировать реальную полезность текстурных признаков; +- число бластов может оказаться слишком малым для устойчивой оценки подтипов; +- часть пограничных незрелых клеток может иметь неоднозначную разметку. + +## 4. Эксперимент 2. Гибридная модель для малого объема данных и доменного сдвига + +### 4.1. Исследовательский пробел + +Современные нейросети часто показывают лучшие результаты на крупных стандартизованных наборах данных, но литература редко проверяет, помогают ли текстурные признаки удерживать качество при переносе между разными источниками изображений. + +### 4.2. Гипотеза + +Гибридная модель, объединяющая нейросетевые embeddings и отобранные текстурные признаки, будет устойчивее, чем чисто нейросетевая и чисто классическая модели, в условиях малого объема данных и межлабораторного доменного сдвига. + +### 4.3. Данные + +- Публичный blood-smear dataset для предобучения или первичного fine-tuning. +- Внутренний диссертационный набор монослойных препаратов для внешней проверки переноса. +- При возможности: второй внешний набор из другой лаборатории или другого сканера. + +### 4.4. Независимые переменные + +- Тип представления: + - handcrafted only; + - deep only; + - hybrid. +- Тип backbone: + - ResNet-подобная сеть; + - более легкая CNN; + - Vision Transformer или Swin-подобная модель; + - замороженный feature extractor против частичного fine-tuning. +- Способ объединения признаков: + - простая конкатенация; + - отбор признаков после конкатенации; + - отдельные классификаторы с последующим late fusion. +- Наличие и тип нормализации окраски. + +### 4.5. Зависимые переменные + +- balanced accuracy на внутреннем и внешнем тесте; +- macro-F1; +- sensitivity для blast-классов; +- expected calibration error; +- устойчивость к смене домена; +- стабильность ranking наиболее важных признаков; +- согласие между объяснениями deep/hybrid-модели и экспертно выделенными областями ядра, нуклеол и цитоплазмы. + +### 4.6. Baseline-источники + +- `acevedo2019recognition` +- `matek2019human` +- `schouten2021tens` +- `hegde2019comparison` +- `sulaiman2023resrandsvm` +- `attallah2024wavelet` +- `park2024blastdiff` +- `katar2023xvit` +- `uzen2024swinconvmixer` + +### 4.7. Протокол + +1. Обучить базовую CNN на публичных данных. +2. Сформировать нейросетевые embeddings для каждой клетки. +3. Параллельно вычислить handcrafted-признаки для тех же клеток. +4. Построить три варианта классификации: + - только handcrafted; + - только embeddings; + - гибрид. +5. Проверить модели в трех режимах: + - обычный hold-out внутри одного домена; + - cross-dataset evaluation; + - low-shot adaptation на малом числе целевых примеров. +6. Для лучших deep и hybrid моделей дополнительно сохранить карты внимания или значимости, чтобы затем оценить их морфологическую правдоподобность. + +### 4.8. Абляции + +- с текстурными признаками и без них; +- с цветовой нормализацией и без нее; +- с ядром и без ядра как отдельной зоны анализа; +- разное число отобранных признаков; +- полное и частичное размораживание нейросети; +- CNN против transformer-backbone при одинаковом объеме данных. + +### 4.9. Риски + +- гибридная модель может переобучаться быстрее, чем чистая CNN; +- при неаккуратном feature selection возможно скрытое подтекание информации из test; +- монослойные препараты могут отличаться от публичных наборов сильнее, чем ожидается; +- трансформер может проигрывать CNN в low-data режиме даже при более сложной архитектуре. + +## 5. Эксперимент 3. Интерпретируемые нейросетевые и transformer-подобные модели для поиска новых признаков + +### 5.1. Исследовательский пробел + +В литературе уже есть сильные deep-модели, но пока недостаточно ясно, какие именно морфологические признаки они используют при различении лимфобластов, миелобластов и небластных клеток. В результате нейросеть часто остается высокоточной, но слабо объяснимой, а ее потенциальная способность открывать новые class-specific признаки почти не используется систематически. + +### 5.2. Гипотеза + +Если сравнивать CNN, transformer-подобные модели и интерпретируемое MIL на одинаковых данных и с одинаковыми разбиениями, то можно получить не только конкурентоспособное качество, но и воспроизводимые объяснения, отражающие морфологически осмысленные различия между лимфобластами и миелобластами. + +### 5.3. Данные + +- Размеченные single-cell изображения с отдельными метками `лимфобласт`, `миелобласт`, `небласт`. +- При наличии: case-level или slide-level набор с более грубыми метками для MIL. +- Экспертные контуры или хотя бы точечные пометки для областей интереса: + - ядро; + - нуклеолы; + - гранулы / палочки Ауэра; + - подозрительные зоны цитоплазмы. + +### 5.4. Независимые переменные + +- Тип архитектуры: + - CNN; + - Vision Transformer / Swin; + - MIL. +- Тип объяснения: + - saliency map; + - Grad-CAM-подобная тепловая карта; + - attention rollout; + - ranking клеток в MIL. +- Тип целевой задачи: + - `бласт / не бласт`; + - `лимфобласт / миелобласт`; + - slide-level `blast-positive / blast-negative`. + +### 5.5. Зависимые переменные + +- balanced accuracy; +- macro-F1; +- sensitivity для лимфобластов и миелобластов; +- IoU или другая мера перекрытия объяснений с экспертными областями интереса; +- устойчивость объяснений между повторами обучения; +- экспертная оценка клинической правдоподобности объяснений; +- частота выявления новых, ранее не формализованных признаковых гипотез. + +### 5.6. Baseline-источники + +- `hehr2023scemila` +- `schouten2021tens` +- `park2024blastdiff` +- `katar2023xvit` +- `iwata2025clinicalreliability` +- `singi2025caremil` +- `jammal2025neurobridgex` +- `jammal2025causalformer` +- `deltadahl2025cytodiffusion` + +### 5.7. Протокол + +1. Обучить CNN, transformer-подобную модель и MIL-вариант на одинаковых patient-level разбиениях. +2. Для каждой модели сгенерировать объяснения одного и того же типа или привести их к сопоставимому виду. +3. Сравнить объяснения с экспертными областями интереса. +4. Отдельно провести blinded review: показать эксперту только explanation maps и спросить, соответствуют ли они биологически правдоподобным признакам. +5. Зафиксировать повторяющиеся class-specific паттерны и проверить, можно ли затем описать их классическими признаками. + +### 5.8. Абляции + +- без цветовой нормализации и с ней; +- без nucleus mask и с ней; +- без специальных explanation loss и с ним, если такой используется; +- CNN и transformer при одинаковом числе параметров; +- cell-level обучение против MIL при более грубых метках. + +### 5.9. Риски + +- explanation maps могут быть визуально убедительными, но нестабильными; +- экспертная интерпретация объяснений может различаться между наблюдателями; +- часть найденных паттернов может оказаться лабораторным артефактом, а не реальной морфологией. + +## 6. Эксперимент 4. Анализаторно-ориентированное обнаружение бластов на уровне препарата + +### 6.1. Исследовательский пробел + +Большая часть литературы заканчивается на уровне одиночной клетки. Для автоматического анализатора этого недостаточно: сначала нужно найти подозрительные клетки на препарате, а уже затем классифицировать их. + +### 6.2. Гипотеза + +Whole-slide или wide-field стратегия поиска кандидатов с последующей blast-focused классификацией обеспечит более высокую чувствительность на уровне препарата, чем фиксированный обзор ограниченного числа клеток, особенно в редких и лейкопеничных случаях. + +### 6.3. Данные + +- Slide-level набор препаратов с экспертным заключением `blast-positive / blast-negative`. +- Отдельное подмножество с клеточными разметками для обучения второго этапа. +- Желательно выделить сложную подгруппу: + - лейкопеничные препараты; + - препараты с редкими бластами; + - препараты с артефактами и плохой окраской. + +### 6.4. Независимые переменные + +- Режим обзора: + - фиксированный обзор ограниченного числа клеток; + - region-focused обзор; + - whole-slide candidate mining. +- Способ формирования кандидатов: + - эвристическая сегментация; + - detector; + - ранний analyzer preclassifier. +- Способ второго этапа: + - handcrafted baseline; + - hybrid model; + - deep classifier. +- Правило передачи кандидатов человеку: + - по blast probability; + - по uncertainty; + - по комбинированному score. + +### 6.5. Зависимые переменные + +- case-level sensitivity; +- false negatives per slide; +- false positives per slide; +- число клеток, переданных на экспертную проверку; +- время анализа одного препарата; +- доля случаев, в которых анализатор не пропустил ни одного бласта. + +### 6.6. Baseline-источники + +- `boldu2019automatic` +- `wang2025advantages` +- `dealmeida2023cytomorphologies` +- `manescu2023apl` +- `singi2025caremil` + +### 6.7. Протокол + +1. Построить двухэтапный pipeline: + - генерация кандидатов на уровне препарата; + - blast-focused классификация кандидатов. +2. Сравнить минимум три режима просмотра: + - ограниченное число клеток; + - локальный поиск по зонам интереса; + - полный скан препарата. +3. Оценивать результат не только по cell-level качеству, но и по итоговой чувствительности на уровне случая. +4. Отдельно анализировать подгруппу лейкопеничных препаратов. +5. Проверять, помогают ли explanation-aware правила ранжирования передавать на верификацию более правдоподобные кандидаты. + +### 6.8. Абляции + +- ограничить число кандидатов, передаваемых на второй этап; +- сравнить ranking по уверенности и по вероятности; +- проверить, помогает ли текстурный score на этапе ранжирования кандидатов; +- сравнить cell-level classifier и slide-level aggregation rule по отдельности. + +### 6.9. Риски + +- разметка на уровне препарата дорога и неполна; +- слабый первый этап может испортить результат даже при сильном втором этапе; +- на редких бластах итоговые метрики могут сильно колебаться. + +## 6. Общие требования к валидности + +Для всех трех направлений необходимо контролировать: + +1. отсутствие утечки изображений одного пациента между train, validation и test; +2. явное разделение внутренней настройки модели и финального теста; +3. учет межлабораторного и межприборного доменного сдвига; +4. отдельную оценку редких классов, а не только общей accuracy; +5. хранение версий разметки и фиксацию спорных клеток; +6. обязательное сравнение как минимум одного классического, одного нейросетевого и одного гибридного baseline. + +## 7. Приоритет реализации + +Если двигаться поэтапно, то оптимальная очередность такая: + +1. сначала завершить Эксперимент 1 и получить интерпретируемый baseline; +2. затем строить Эксперимент 2 как наиболее содержательное сравнение с нейросетями; +3. после стабилизации cell-level моделей переходить к Эксперименту 3. + +Такая очередность позволяет не терять интерпретируемость на раннем этапе и при этом постепенно приближать работу к задаче полноценного автоматического анализатора препаратов крови в монослое. diff --git a/reports/E01_detailed_report.md b/reports/E01_detailed_report.md new file mode 100644 index 0000000..e19888c --- /dev/null +++ b/reports/E01_detailed_report.md @@ -0,0 +1,1046 @@ +# Подробный отчет по эксперименту E01 + +## 1. Что это за эксперимент и зачем он нужен + +Этот эксперимент проверяет очень практичный вопрос: + +можно ли по изображению отдельной клетки крови автоматически понять, является ли она бластной клеткой (`blast`) или нет, если использовать не только "сырые картинки", но и понятные человеку признаки формы, цвета и текстуры. + +Если говорить совсем просто, идея такая: + +- у разных клеток разная форма; +- у них по-разному распределен цвет; +- у них по-разному выглядит внутренняя "текстура" изображения; +- все это можно перевести в числа и дать модели машинного обучения. + +Главная цель эксперимента была сформулирована в плане так: + +компактный и интерпретируемый набор морфометрических, цветовых и текстурных признаков должен устойчиво различать `blast / non-blast` на данных разных пациентов и быть полезным не только для отдельной клетки, но и для оценки целого изображения как приближения к задаче автоматического анализатора. + +Исходный подробный план эксперимента находится здесь: + +- [experiment_plan_detailed.md](../plan/current/experiment_plan_detailed.md) + +## 2. Какие гипотезы проверялись + +В рамках эксперимента проверялись не одна, а сразу несколько связанных гипотез. Ниже каждая гипотеза сформулирована простым языком, а затем объясняется, как именно она проверялась на практике. + +### Гипотеза 1. Признаки формы, цвета и текстуры действительно помогают отличать бласт от небласта + +Смысл гипотезы: + +если клетку описать не только картинкой, но и набором понятных чисел, то по этим числам можно надежно различать `blast / non-blast`. + +Под "понятными числами" здесь имеются в виду: + +- признаки формы; +- признаки цвета; +- признаки текстуры. + +Почему это важно: + +- такой подход легче объяснить врачу и исследователю; +- можно понять, какие именно свойства клетки помогают модели; +- это особенно полезно для диссертации, потому что результат получается интерпретируемым, а не "черным ящиком". + +Как гипотеза проверялась: + +1. Для каждой клетки были вычислены морфометрические, цветовые и текстурные признаки. +2. Далее обучались несколько признаковых моделей: + - `Logistic Regression`; + - `Linear SVM`; + - `RBF SVM`; + - `Random Forest`; + - градиентный бустинг, который в таблицах обозначен как `xgboost`, но в данной среде фактически работал как `HistGradientBoosting` fallback. +3. Каждая модель обучалась не на одном, а на нескольких вариантах признаков: + - только морфометрия; + - только цвет; + - только текстура; + - морфометрия + цвет; + - морфометрия + текстура; + - цвет + текстура; + - полный набор признаков. +4. Проверка проводилась на трех задачах: + - `A4.1 clean_cell`; + - `A4.2 strict_morphology`; + - `A4.3 full_realistic`. + +Что будет считаться подтверждением гипотезы: + +- если хотя бы несколько моделей на patient-level split покажут устойчиво высокие метрики; +- если полный набор признаков или его сильные комбинации будут работать лучше простых вариантов; +- если полезность текстурных признаков будет видна не только в одной случайной модели, а в серии сравнений. + +### Гипотеза 2. Маска клетки улучшает качество признаков и качество модели + +Смысл гипотезы: + +если точно отделить клетку от фона, то признаки будут чище, а значит и классификация станет лучше. + +Это особенно логично для текстурных и цветовых признаков: + +- если в bbox попадает фон; +- если рядом попадает другая клетка; +- если есть мусор или артефакт, + +то числовые признаки уже описывают не только саму клетку, но и лишние объекты. + +Как гипотеза проверялась: + +1. Для каждого объекта были построены три вида ROI: + - `bbox_crop`; + - `mask_crop`; + - `mask_tight_crop`. +2. Затем признаки считались в трех режимах: + - только по `bbox`; + - только по `mask`; + - по объединению `bbox + mask`. +3. После этого на одинаковых split и одинаковых моделях сравнивались результаты: + - `bbox_only`; + - `mask_only`; + - `bbox_plus_mask`. +4. Для этого использовался блок абляций `A8`, а сами модели брались из лучшего признакового family для соответствующей задачи. + +Какие модели участвовали в проверке: + +- главным образом лучшая boosting-модель для каждой задачи, потому что именно она использовалась для сравнений масок; +- дополнительно роль маски косвенно проверялась и в deep-блоке: + - `cnn_bbox`; + - `cnn_mask`; + - `cnn_late_fusion`. + +Что будет считаться подтверждением гипотезы: + +- если `mask_only` работает не хуже `bbox_only` или лучше; +- если `bbox_plus_mask` оказывается лучше одного только `bbox`; +- если эффект наблюдается не на одной задаче, а повторяется хотя бы в нескольких режимах корпуса. + +### Гипотеза 3. Шумовые классы действительно усложняют задачу + +Смысл гипотезы: + +если убрать плохие клетки и артефакты, задача станет проще. Если оставить их, задача станет труднее, но зато ближе к реальной практике. + +Почему это важно: + +- в реальной работе анализатора изображение редко бывает идеально чистым; +- если модель хороша только на "красивых" данных, то пользы от нее мало; +- поэтому нужно отдельно проверить и чистый режим, и реалистичный режим. + +Как гипотеза проверялась: + +1. Было создано 4 версии корпуса: + - `full_realistic`; + - `clean_cell`; + - `strict_morphology`; + - `mask_ready`. +2. Основное сравнение шума проводилось между: + - `clean_cell`; + - `strict_morphology`; + - `full_realistic`. +3. Для каждой версии корпуса были обучены одни и те же признаки и одни и те же модели. +4. Затем в `A8` сравнивались итоговые таблицы: + - насколько падает `PR-AUC`; + - насколько меняется `recall blast`; + - насколько снижается устойчивость модели. + +Какие модели использовались для проверки: + +- весь baseline-блок `A6`, то есть все 5 признаковых моделей; +- в итоговой абляции режимов корпуса сравнение фиксировалось на лучшей boosting-конфигурации. + +Что будет считаться подтверждением гипотезы: + +- если на более чистых корпусах качество выше; +- если на `full_realistic` качество немного падает, но не разрушается полностью; +- если модель остается practically useful даже при наличии артефактов. + +### Гипотеза 4. Object-level оценки можно превратить в полезную оценку изображения и пациента + +Смысл гипотезы: + +даже если модель принимает решение по одной клетке, ее оценки можно объединить и понять: + +- есть ли на изображении хотя бы один подозрительный объект; +- какие изображения стоит проверить в первую очередь; +- какого пациента стоит поднять вверх в списке риска. + +Это важный шаг к задаче автоматического анализатора. + +Как гипотеза проверялась: + +1. Сначала выбиралась лучшая object-level baseline-модель для `full_realistic`. +2. Она выдавала score для каждого объекта. +3. Потом эти score агрегировались на уровень изображения и пациента несколькими способами: + - `max_object_score`; + - `mean_top3_scores`; + - `count_objects_above_threshold`; + - `soft_noisy_or`. +4. Для image-level и patient-level считались отдельные метрики: + - `PR-AUC`; + - `ROC-AUC`; + - `recall`; + - `top-k recall`. + +Какие модели участвовали в проверке: + +- напрямую не все модели, а одна лучшая object-level baseline-модель; +- однако сама эта лучшая модель выбиралась среди всех признаковых baseline-конфигураций блока `A6`. + +Что будет считаться подтверждением гипотезы: + +- если один или несколько способов агрегирования дадут высокие image-level метрики; +- если по ранжированию можно поднимать изображения и пациентов с бластами в верх списка; +- если object-level модель окажется полезной не только на одной клетке, но и как блок более крупной системы. + +### Гипотеза 5. Легкие deep-модели можно честно сравнить с handcrafted-подходом на тех же данных + +Смысл гипотезы: + +часто считается, что нейросети автоматически лучше "ручных" признаков. Эксперимент проверяет, так ли это на данном конкретном корпусе. + +Важно: + +- сравнение должно быть честным; +- одинаковые пациенты должны быть в train/val/test; +- нельзя давать одной модели лучшие данные, чем другой. + +Как гипотеза проверялась: + +1. Для deep-блока был собран специальный `modeling_workset`. +2. Были обучены 3 модели: + - `cnn_bbox` — CNN на `bbox_crop`; + - `cnn_mask` — CNN на `mask_crop`; + - `cnn_late_fusion` — CNN + табличные признаки. +3. Лучший handcrafted baseline из `A6` сравнивался с deep-моделями по тем же test-метрикам: + - `PR-AUC`; + - `ROC-AUC`; + - `balanced accuracy`; + - `macro-F1`; + - `recall blast`; + - `specificity`; + - `MCC`; + - `Brier score`; + - `ECE`. + +Почему отдельно есть `late fusion`: + +- это очень важная промежуточная идея; +- она проверяет, что будет, если не противопоставлять нейросеть и handcrafted-признаки, а объединить их. + +Что будет считаться подтверждением гипотезы: + +- если deep-модели смогут догнать или превзойти лучший handcrafted baseline; +- или, наоборот, если окажется, что на текущем корпусе интерпретируемые признаки уже дают очень сильный результат и легкая CNN их не превосходит. + +### Что в итоге проверялось на самом деле + +Если свести все к одной схеме, то эксперимент проверял три больших вопроса: + +1. Полезны ли вообще объяснимые признаки для распознавания бластов? +2. Какие именно условия помогают сильнее всего: + - маска; + - очистка корпуса; + - определенная группа признаков; + - определенная модель? +3. Можно ли из хорошей object-level модели сделать practically useful систему для image-level и patient-level анализа? + +Именно поэтому в эксперименте использовались сразу несколько семейств моделей: + +- простые линейные модели; +- нелинейные модели на признаках; +- ансамбли деревьев; +- легкие CNN; +- гибридная late-fusion модель. + +Это позволило проверить гипотезы не "на словах", а в серии честных количественных сравнений. + +## 3. На каких данных проводился эксперимент + +### 3.1. Исходные данные + +Использовались данные из: + +- исходный каталог `data`; +- каталог масок `data_masks`. + +Главный принцип был таким: + +- в основной протокол брались только каталоги пациентов нормального формата; +- специальный каталог `part-1` специально не смешивался с основным patient-level корпусом. + +### 3.2. Что получилось после аудита данных + +По итогам этапа `A0` было установлено: + +| Показатель | Значение | +|---|---:| +| Верхнеуровневые каталоги в `data` | 123 | +| Верхнеуровневые каталоги в `data_masks` | 123 | +| Каталоги пациентов в основном протоколе | 122 | +| Изображения в основном протоколе | 12 488 | +| Label-файлы в основном протоколе | 12 456 | +| Объекты в основном протоколе | 86 396 | +| Маски в основном протоколе | 86 360 | +| Изображения с хотя бы одной маской | 12 428 | +| Изображения без масок | 60 | +| Пациенты с бластами | 100 | +| Пациенты без бластов | 22 | + +Подтверждение этих чисел находится в: + +- [data_audit_runtime.md](../outputs/reports/data_audit_runtime.md) + +### 3.3. Какие проблемы были найдены в данных + +Во время аудита были найдены и учтены реальные ограничения: + +- часть label-файлов использовала class id в формате `float`, например `7.0`; +- каталог `part-1` не был включен в основной patient-level протокол; +- `20` объектов имели некорректную геометрию bbox и были исключены из моделирования; +- не у всех объектов была маска. + +### 3.4. Финальный modeling-корпус после геометрической очистки + +После исключения `20` объектов с плохой геометрией осталось: + +| Показатель | Значение | +|---|---:| +| Валидные объекты для моделирования | 86 376 | +| Объекты с корректно сопоставленной маской | 86 250 | +| Объекты без маски | 126 | +| Объекты с `mask_tight_crop` | 86 166 | +| Средняя ширина bbox | 131.41 px | +| Средняя высота bbox | 132.28 px | + +Файлы этапа: + +- [object_index.parquet](../outputs/dataset_index/object_index.parquet) +- [bbox_roi_manifest.parquet](../outputs/crops/bbox_roi_manifest.parquet) +- [roi_manifest.parquet](../outputs/crops/roi_manifest.parquet) + +## 4. Какие версии корпуса были созданы + +Чтобы не смешивать "чистую" биологическую задачу и шумную реальную задачу, корпус был разбит на 4 режима. + +| Подмножество | Что входит | Объектов | Пациентов | Изображений | +|---|---|---:|---:|---:| +| `full_realistic` | все объекты, включая плохие клетки и артефакты | 86 376 | 121 | 12 429 | +| `clean_cell` | без `Bad cells` и `artifacts` | 78 498 | 121 | 11 941 | +| `strict_morphology` | без `Bad cells`, `artifacts`, `gumpricht shadows` | 74 901 | 121 | 11 926 | +| `mask_ready` | только объекты с маской | 86 250 | 121 | 12 427 | + +Это важно понимать так: + +- `full_realistic` — это самый честный режим для реальной практики; +- `clean_cell` — более "чистая" задача; +- `strict_morphology` — еще более строгая постановка, где шум минимизирован; +- `mask_ready` — специальный режим для сравнения bbox и mask. + +Подробные артефакты: + +- [subset_summary.csv](../outputs/tables/subset_summary.csv) +- [subset_class_distribution.csv](../outputs/tables/subset_class_distribution.csv) +- [a1_subset_qc.md](../outputs/reports/a1_subset_qc.md) + +## 5. Как данные были разделены на обучение и тест + +Очень важная часть эксперимента: разделение делалось не по отдельным клеткам, а по пациентам. + +Это означает: + +- если пациент попал в train, то все его клетки и изображения были только в train; +- если пациент попал в test, то модель никогда раньше его не видела. + +Это намного честнее, чем случайно перемешивать клетки. + +### 5.1. Финальное разбиение + +| Часть | Пациентов | Пациентов с объектами | Изображений | Объектов | Бластов | +|---|---:|---:|---:|---:|---:| +| `train` | 85 | 85 | 8 968 | 60 641 | 44 328 | +| `val` | 18 | 17 | 1 672 | 20 200 | 14 368 | +| `test` | 19 | 19 | 1 790 | 5 555 | 3 165 | + +Замечание: + +- в `val` один пациентский каталог есть в протоколе, но не содержит размеченных объектов; +- пересечений между `train`, `val`, `test` нет. + +Файлы: + +- [split_v1.json](../outputs/splits/split_v1.json) +- [patient_summary.csv](../outputs/splits/patient_summary.csv) +- [class_distribution_by_split.csv](../outputs/tables/class_distribution_by_split.csv) +- [a2_split_qc.md](../outputs/reports/a2_split_qc.md) + +## 6. Какие задачи классификации решались + +Во всех задачах положительный класс определялся одинаково: + +`target_binary = 1`, если `class_id == 7`, то есть объект является `blast`. + +Были созданы 3 постановки. + +| Задача | Что сравнивается | Объектов | Бластов | Небластов | Доля положительного класса | +|---|---|---:|---:|---:|---:| +| `A4.1` | `blast / non-blast` на `clean_cell` | 78 498 | 61 851 | 16 647 | 0.7879 | +| `A4.2` | `blast / non-blast` на `strict_morphology` | 74 901 | 61 851 | 13 050 | 0.8258 | +| `A4.3` | `blast / everything_else` на `full_realistic` | 86 376 | 61 851 | 24 525 | 0.7161 | + +В `A4.3` самые важные hard-negative классы: + +- `Bad cells` +- `Lymphocyte` +- `gumpricht shadows` + +Это как раз делает задачу ближе к реальной работе автоматического анализатора. + +Файлы: + +- [task_a41_clean_binary.parquet](../outputs/dataset_index/task_a41_clean_binary.parquet) +- [task_a42_strict_binary.parquet](../outputs/dataset_index/task_a42_strict_binary.parquet) +- [task_a43_full_realistic_binary.parquet](../outputs/dataset_index/task_a43_full_realistic_binary.parquet) +- [task_prevalence_summary.csv](../outputs/tables/task_prevalence_summary.csv) + +## 7. Как из клеток были сделаны изображения для анализа + +Для каждого объекта было создано 3 вида ROI. + +### 7.1. `bbox_crop` + +Обычный прямоугольный фрагмент изображения по рамке детектора. + +Просто говоря: вырезается кусочек вокруг клетки. + +### 7.2. `mask_crop` + +Берется тот же прямоугольный фрагмент, но пиксели вне маски зануляются. + +Просто говоря: оставляем клетку, а все лишнее вокруг стараемся убрать. + +### 7.3. `mask_tight_crop` + +Это еще более плотная версия: вырезается минимальный прямоугольник вокруг самой маски. + +Почему это важно: + +- `bbox_crop` показывает, что модель видит, если мы не очень точно отделяем клетку от окружения; +- `mask_crop` показывает, что будет, если выделение клетки сделано аккуратно; +- сравнение этих режимов позволяет понять, насколько реально полезны маски. + +Примеры ROI: + +![Примеры ROI](../outputs/figures/example_rois.png) + +QC-файлы: + +- [a3_bbox_qc.md](../outputs/reports/a3_bbox_qc.md) +- [a3_mask_qc.md](../outputs/reports/a3_mask_qc.md) + +## 8. Какие признаки вычислялись и что они означают + +Это один из самых важных разделов, потому что вся ценность эксперимента в том, что признаки можно объяснить человеку. + +### 8.1. Морфометрические признаки + +Это признаки формы и размера. + +Примеры: + +- площадь рамки; +- площадь маски; +- периметр; +- круглотность; +- эксцентриситет; +- заполненность bbox маской; +- solidity. + +Понятный смысл: + +- клетка может быть более круглой или более вытянутой; +- она может занимать почти весь bbox или только часть; +- контур может быть более плотным или более "неровным". + +### 8.2. Цветовые признаки + +Это числовое описание цвета клетки. + +Использовались: + +- средние значения по `RGB`; +- стандартные отклонения по `RGB`; +- квантили; +- признаки в пространствах `HSV` и `Lab`. + +Понятный смысл: + +- модель смотрит не просто на "какой цвет", а на распределение цвета; +- например, насколько цвет однородный, насколько он смещен в красную, зеленую или синюю сторону; +- `Lab` удобен тем, что он лучше разделяет яркость и цветовой оттенок. + +### 8.3. Текстурные признаки + +Это признаки "рисунка" внутри клетки. + +Использовались: + +- `GLCM` — анализ того, как часто рядом встречаются пиксели разной яркости; +- `LBP` — анализ локальных микрошаблонов текстуры; +- `Sobel` — оценка силы границ и перепадов; +- `Laplacian` — оценка резких изменений яркости; +- `Wavelet` — анализ структуры изображения на нескольких масштабах. + +Понятный смысл: + +- одни клетки выглядят более "гладкими"; +- другие содержат более неоднородную структуру; +- у некоторых сильнее выражены мелкие детали и переходы. + +### 8.4. Сколько признаков получилось + +На полном корпусе были рассчитаны: + +| Матрица признаков | Число строк | +|---|---:| +| `features_bbox.parquet` | 86 376 | +| `features_mask.parquet` | 86 250 | +| `features_combined.parquet` | 86 376 | + +В объединенной матрице `combined` получилось `216` числовых признаков. + +Из них: + +- `109` признаков имели пропуски; +- `0` признаков были константными. + +Почему были пропуски: + +- часть признаков невозможна без маски; +- если маски нет, соответствующие mask-признаки не вычисляются. + +Файлы: + +- [features_bbox.parquet](../outputs/features/features_bbox.parquet) +- [features_mask.parquet](../outputs/features/features_mask.parquet) +- [features_combined.parquet](../outputs/features/features_combined.parquet) +- [feature_dictionary.csv](../outputs/features/feature_dictionary.csv) +- [feature_missingness.csv](../outputs/tables/feature_missingness.csv) + +## 9. Какие модели использовались + +### 9.1. Признаковые baseline-модели + +Было обучено `105` конфигураций: + +- `3` задачи; +- `5` моделей; +- `7` групп признаков. + +Использовались следующие модели. + +#### Logistic Regression + +Это простая линейная модель. + +Она пытается провести границу между классами, используя взвешенную сумму признаков. + +Плюс: + +- хорошо интерпретируется. + +Минус: + +- не умеет хорошо описывать очень сложные нелинейные зависимости. + +#### Linear SVM + +Тоже линейная модель, но с другой логикой поиска разделяющей границы. + +Плюс: + +- часто хорошо работает на табличных признаках; +- устойчив при большом числе признаков. + +#### RBF SVM + +Это уже нелинейный вариант SVM. + +В коде он реализован через приближение `Nystroem`, чтобы обучение не было слишком тяжелым. + +Плюс: + +- умеет строить более сложную границу между классами. + +#### Random Forest + +Это ансамбль деревьев решений. + +Простое объяснение: + +- строится много деревьев; +- каждое дерево принимает решение по-своему; +- потом они голосуют. + +#### Gradient Boosting + +В таблицах он идет под именем `xgboost`, но есть важная техническая оговорка. + +В этой вычислительной среде настоящий `XGBoost` не загрузился из-за отсутствия `libomp.dylib`, поэтому фактически использовался fallback: + +- `HistGradientBoostingClassifier` + +Это тоже градиентный бустинг по деревьям, но не совсем тот же самый алгоритм, что "настоящий" XGBoost. + +Это важно помнить при чтении результатов. + +### 9.2. Группы признаков + +Для каждой модели сравнивались: + +- только морфометрия; +- только цвет; +- только текстура; +- морфометрия + цвет; +- морфометрия + текстура; +- цвет + текстура; +- полный набор признаков. + +## 10. Какие метрики использовались и как их понимать + +Ниже очень короткое объяснение каждой метрики простым языком. + +### PR-AUC + +Хороша, когда положительный класс важен. + +Здесь это особенно важно, потому что нам нужно находить бласты. + +Чем выше `PR-AUC`, тем лучше модель отделяет важный положительный класс от остальных. + +### ROC-AUC + +Показывает, насколько хорошо модель в целом отличает два класса. + +### Balanced Accuracy + +Обычная accuracy может обманывать на несбалансированных данных. + +Balanced accuracy дает одинаковый вес обоим классам. + +### Macro-F1 + +Это компромисс между точностью и полнотой, рассчитанный так, чтобы оба класса были важны. + +### Recall для `blast` + +Очень важная метрика. + +Она отвечает на вопрос: + +сколько настоящих бластов модель смогла найти. + +### Specificity + +Отвечает на вопрос: + +сколько небластов модель правильно не приняла за бласт. + +### Matthews Correlation Coefficient (MCC) + +Одна из самых честных сводных метрик для бинарной классификации. + +Чем ближе к `1`, тем лучше. + +### Brier Score + +Показывает, насколько хорошо вероятности модели соответствуют реальности. + +Меньше — лучше. + +### Expected Calibration Error (ECE) + +Показывает, насколько уверенность модели совпадает с ее реальной точностью. + +Меньше — лучше. + +## 11. Главные object-level результаты + +### 11.1. Лучшая модель в каждой задаче + +| Задача | Лучшая конфигурация | Validation PR-AUC | Test PR-AUC | Recall blast | Specificity | MCC | +|---|---|---:|---:|---:|---:|---:| +| `A4.1 clean_cell` | `linear_svm + full` | 0.9760 | 0.9319 | 0.8961 | 0.7352 | 0.6452 | +| `A4.2 strict_morphology` | `linear_svm + color_texture` | 0.9848 | 0.9430 | 0.9305 | 0.6745 | 0.6409 | +| `A4.3 full_realistic` | `gradient boosting (labelled xgboost) + full` | 0.9143 | 0.9390 | 0.8847 | 0.7962 | 0.6859 | + +Что это означает простыми словами: + +- на самых "чистых" версиях корпуса очень хорошо работает линейный подход; +- на самой реалистичной задаче лучший результат дал полный набор признаков вместе с бустингом; +- даже в шумном режиме `full_realistic` модель сохраняет высокий `PR-AUC = 0.9390`. + +### 11.2. Почему результат на `full_realistic` особенно важен + +Именно эта задача ближе всего к реальному использованию, потому что здесь сохраняются: + +- плохие клетки; +- артефакты; +- тени Гумпрехта; +- другие небластные объекты. + +То есть модель должна не просто отличить "идеальный бласт" от "идеального небласта", а работать в реальном шумном окружении. + +Подробные файлы: + +- [object_level_metrics_baselines.csv](../outputs/tables/object_level_metrics_baselines.csv) +- [baseline_model_leaderboard.csv](../outputs/tables/baseline_model_leaderboard.csv) + +### 11.3. Кривые качества и матрица ошибок + +PR/ROC-кривые: + +![PR/ROC кривые](../outputs/figures/pr_curves_baselines.png) + +![ROC кривые](../outputs/figures/roc_curves_baselines.png) + +Матрица ошибок лучшего baseline: + +![Confusion matrix](../outputs/figures/confusion_matrix_best_baseline.png) + +## 12. Что показали абляции + +Абляция — это специальный эксперимент, где мы убираем или меняем одну часть системы и смотрим, что изменится. + +### 12.1. Роль маски + +Наиболее показательная задача для практики — `A4.3 full_realistic`. + +| Режим признаков | PR-AUC | ROC-AUC | Recall blast | Specificity | +|---|---:|---:|---:|---:| +| `bbox_only` | 0.9148 | 0.8991 | 0.7938 | 0.8191 | +| `mask_only` | 0.9175 | 0.8984 | 0.8735 | 0.7401 | +| `bbox_plus_mask` | 0.9393 | 0.9259 | 0.9130 | 0.7523 | + +Вывод: + +- маска сама по себе уже полезна; +- лучше всего работает объединение `bbox + mask`; +- значит, информация и из общего контекста клетки, и из аккуратно выделенной области важна одновременно. + +### 12.2. Роль шумовых классов + +Сравнение лучших полных режимов: + +| Задача | PR-AUC | ROC-AUC | Recall blast | +|---|---:|---:|---:| +| `clean_cell` | 0.9518 | 0.9335 | 0.9741 | +| `strict_morphology` | 0.9598 | 0.9287 | 0.9596 | +| `full_realistic` | 0.9390 | 0.9260 | 0.8847 | + +Главный вывод: + +- когда в данных остаются реальные шумовые классы, задача действительно становится сложнее; +- но падение качества не катастрофическое; +- значит, признаки остаются полезными и в более реалистичном режиме. + +### 12.3. Class weights и отбор признаков + +На `full_realistic`: + +| Режим | PR-AUC | Recall blast | MCC | +|---|---:|---:|---:| +| `combined_with_class_weight` | 0.9390 | 0.8847 | 0.6859 | +| `combined_without_class_weight` | 0.9390 | 0.8847 | 0.6859 | +| `combined_top30_features` | 0.9362 | 0.8139 | 0.6699 | + +Интерпретация: + +- в этом запуске class weights не дали видимого изменения; +- компактный набор из `30` лучших признаков почти сохраняет качество по `PR-AUC`, но заметно проседает по `recall blast`; +- это важный практический результат: сильное сжатие модели возможно, но часть чувствительности теряется. + +Файлы: + +- [ablation_mask_usage.csv](../outputs/tables/ablation_mask_usage.csv) +- [ablation_dataset_regimes.csv](../outputs/tables/ablation_dataset_regimes.csv) +- [ablation_summary.png](../outputs/figures/ablation_summary.png) + +## 13. Какие признаки оказались самыми важными + +Для лучшей модели на `full_realistic` был проведен анализ важности признаков. + +Самые стабильные признаки: + +| Признак | Частота попадания в top-20 | Что это примерно значит | +|---|---:|---| +| `mask_lab_a_std` | 3 | насколько по маске меняется цвет по оси green-red | +| `bbox_lab_a_std` | 3 | тот же цветовой разброс, но в bbox | +| `bbox_lab_b_q75` | 3 | верхний квартиль цвета по оси blue-yellow | +| `bbox_hsv_h_mean` | 3 | средний цветовой тон | +| `mask_texture_glcm_correlation_std` | 3 | насколько неоднородна текстурная корреляция | +| `mask_morph_solidity` | 3 | насколько форма клетки плотная и цельная | + +Очень важный общий смысл: + +- признаки цвета оказались не менее важны, чем признаки формы; +- признаки текстуры действительно входят в число устойчиво полезных; +- это хорошо согласуется с исходной научной мотивацией эксперимента. + +Фигуры: + +![Стабильность признаков](../outputs/figures/feature_importance_stability.png) + +![Сводка важности признаков](../outputs/figures/shap_summary_top_model.png) + +Файлы: + +- [top_features_stability.csv](../outputs/tables/top_features_stability.csv) +- [feature_interpretation_notes.md](../outputs/reports/feature_interpretation_notes.md) + +## 14. Что получилось на уровне изображения и пациента + +Это очень важный мост между "классификацией одной клетки" и реальной задачей анализатора. + +### 14.1. Image-level результаты + +На тесте было: + +- `1790` изображений; +- `887` изображений содержали хотя бы один бласт. + +Сравнивались 4 способа агрегирования object-level score: + +- `max_object_score` +- `mean_top3_scores` +- `count_objects_above_threshold` +- `soft_noisy_or` + +Лучшая image-level схема по `PR-AUC`: + +| Метод | PR-AUC | ROC-AUC | Recall blast | Specificity | Recall at FPR 10% | +|---|---:|---:|---:|---:|---:| +| `soft_noisy_or` | 0.9743 | 0.9727 | 0.9076 | 0.9003 | 0.9076 | + +Простое объяснение `soft noisy-or`: + +- если хотя бы один объект на изображении очень похож на бласт, то изображение становится подозрительным; +- при этом метод не реагирует слишком грубо на слабые шумовые сигналы. + +Вывод: + +- object-level модель действительно можно использовать как основу для отбора подозрительных изображений; +- это сильный аргумент в пользу практической полезности эксперимента. + +### 14.2. Patient-level результаты + +На patient-level тест состоял из: + +- `19` пациентов; +- `15` пациентов с бластами. + +Лучшая patient-level схема по `PR-AUC`: + +| Метод | PR-AUC | ROC-AUC | Recall blast | Specificity | +|---|---:|---:|---:|---:| +| `count_objects_above_threshold` | 0.9860 | 0.9333 | 0.6667 | 1.0000 | + +Здесь важно быть осторожным: + +- patient-level тест маленький (`19` пациентов), поэтому числа чувствительны к каждому случаю; +- тем не менее, даже на таком небольшом тесте видно, что пациент с бластами поднимается вверх в ранжированном списке достаточно хорошо. + +Фигуры: + +![Image ranking recall](../outputs/figures/image_ranking_recall_curve.png) + +![Patient ranking curve](../outputs/figures/patient_ranking_curve.png) + +Файлы: + +- [image_level_metrics.csv](../outputs/tables/image_level_metrics.csv) +- [patient_level_ranking_metrics.csv](../outputs/tables/patient_level_ranking_metrics.csv) + +## 15. Deep-baselines: что было сделано и что получилось + +### 15.1. Какие deep-модели сравнивались + +Были обучены 3 легкие модели: + +1. `cnn_bbox` + Модель видит только `bbox_crop`. + +2. `cnn_mask` + Модель видит только `mask_crop`. + +3. `cnn_late_fusion` + Модель видит изображение и одновременно табличные признаки. + +Все они обучались на одном и том же `modeling_workset`. + +### 15.2. Объем данных для deep-этапа + +Для deep-блока использовался рабочий набор: + +- `30 501` mask-ready объектов для `full_realistic`; +- те же объекты использовались для late fusion. + +### 15.3. Графики обучения + +Графики: + +![Графики обучения CNN](../outputs/figures/deep_training_curves.png) + +Краткие факты из истории обучения: + +| Модель | Эпох | Лучшая эпоха | Лучшая val PR-AUC | +|---|---:|---:|---:| +| `cnn_bbox` | 3 | 1 | 0.7742 | +| `cnn_mask` | 3 | 1 | 0.7862 | +| `cnn_late_fusion` | 5 | 3 | 0.8932 | + +Что это значит: + +- чисто визуальные CNN на bbox и mask быстро выходили на максимум и дальше не росли; +- late fusion учился дольше и заметно лучше; +- добавление табличных признаков действительно помогло deep-подходу. + +### 15.4. Сравнение handcrafted и deep + +| Модель | Validation PR-AUC | Test PR-AUC | Test ROC-AUC | Recall blast | MCC | +|---|---:|---:|---:|---:|---:| +| `handcrafted_best_full_realistic` | 0.9143 | 0.9390 | 0.9260 | 0.8847 | 0.6859 | +| `cnn_bbox` | 0.7742 | 0.7491 | 0.7203 | 0.5019 | 0.2426 | +| `cnn_mask` | 0.7862 | 0.7119 | 0.6311 | 0.4168 | 0.1634 | +| `cnn_late_fusion` | 0.8932 | 0.8931 | 0.8690 | 0.8612 | 0.5485 | + +Главный вывод: + +- лучший handcrafted baseline оказался сильнее всех deep-baselines; +- лучшая deep-модель — это `cnn_late_fusion`; +- late fusion заметно лучше чисто визуальных CNN, но все равно не превзошел лучший признаковый подход. + +Это очень важный научный результат: + +на данном корпусе, в данной постановке и при данной вычислительной сложности интерпретируемые признаки не просто "не хуже", а реально сильнее легких CNN. + +Файлы: + +- [deep_vs_handcrafted.csv](../outputs/tables/deep_vs_handcrafted.csv) +- [cnn_training_history.json](../outputs/logs/cnn_training_history.json) +- [a9_deep_qc.md](../outputs/reports/a9_deep_qc.md) + +## 16. Главные выводы простыми словами + +### Вывод 1 + +Да, интерпретируемые признаки формы, цвета и текстуры действительно полезны. + +Это подтверждается высокими значениями `PR-AUC` и устойчивыми результатами на patient-level split. + +### Вывод 2 + +Наиболее сильный и практический результат был получен на задаче `full_realistic`, где сохранены шумовые классы. + +Это особенно ценно, потому что такая постановка ближе к реальным медицинским данным. + +### Вывод 3 + +Маски помогают. + +Связка `bbox + mask` почти везде лучше, чем `bbox` или `mask` по отдельности. + +### Вывод 4 + +Текстурные признаки не оказались "лишними". + +Они входят в число устойчиво полезных признаков вместе с цветовыми и морфометрическими характеристиками. + +### Вывод 5 + +Object-level модель можно достаточно успешно поднимать до image-level и patient-level оценки. + +Это делает эксперимент полезным не только как задачу "классификации клетки", но и как шаг к автоматическому анализатору. + +### Вывод 6 + +Легкие CNN на этом корпусе не превзошли лучший handcrafted baseline. + +Это означает, что простой и объяснимый подход здесь не проигрывает "нейросетевой моде", а наоборот показывает очень сильный результат. + +## 17. Что эксперимент НЕ доказывает + +Важно честно зафиксировать ограничения. + +Этот эксперимент не доказывает: + +- различие `lymphoblast / myeloblast`; +- переносимость на другую лабораторию; +- переносимость на другой сканер или другую окраску; +- полноценную slide-level диагностику; +- превосходство настоящего `XGBoost`, потому что в данной среде фактически использовался fallback `HistGradientBoosting`. + +Это не недостаток отчета, а честная граница применимости результатов. + +## 18. Технические и научные ограничения + +### 18.1. Техническое ограничение среды + +В `.venv` не загрузился реальный `xgboost`, потому что отсутствует `libomp.dylib`. + +Поэтому в тех строках, где модель подписана как `xgboost`, фактически использовался: + +- `HistGradientBoostingClassifier` + +Это честно отражено в таблицах и QC-отчетах. + +### 18.2. Ограничение данных + +- нет разделения бластов на подтипы; +- patient-level test относительно небольшой; +- часть объектов без маски; +- один patient-каталог из основного протокола не содержит размеченных объектов. + +## 19. Где лежат главные артефакты + +### Основные таблицы + +- [object_level_metrics_baselines.csv](../outputs/tables/object_level_metrics_baselines.csv) +- [baseline_model_leaderboard.csv](../outputs/tables/baseline_model_leaderboard.csv) +- [feature_group_ablation.csv](../outputs/tables/feature_group_ablation.csv) +- [ablation_mask_usage.csv](../outputs/tables/ablation_mask_usage.csv) +- [ablation_dataset_regimes.csv](../outputs/tables/ablation_dataset_regimes.csv) +- [image_level_metrics.csv](../outputs/tables/image_level_metrics.csv) +- [patient_level_ranking_metrics.csv](../outputs/tables/patient_level_ranking_metrics.csv) +- [deep_vs_handcrafted.csv](../outputs/tables/deep_vs_handcrafted.csv) + +### Основные рисунки + +- [pipeline_overview.png](../outputs/figures/pipeline_overview.png) +- [example_rois.png](../outputs/figures/example_rois.png) +- [mask_coverage.png](../outputs/figures/mask_coverage.png) +- [pr_curves_baselines.png](../outputs/figures/pr_curves_baselines.png) +- [roc_curves_baselines.png](../outputs/figures/roc_curves_baselines.png) +- [confusion_matrix_best_baseline.png](../outputs/figures/confusion_matrix_best_baseline.png) +- [feature_importance_stability.png](../outputs/figures/feature_importance_stability.png) +- [shap_summary_top_model.png](../outputs/figures/shap_summary_top_model.png) +- [ablation_summary.png](../outputs/figures/ablation_summary.png) +- [image_ranking_recall_curve.png](../outputs/figures/image_ranking_recall_curve.png) +- [patient_ranking_curve.png](../outputs/figures/patient_ranking_curve.png) +- [deep_training_curves.png](../outputs/figures/deep_training_curves.png) + +### QC-отчеты + +- [data_audit_runtime.md](../outputs/reports/data_audit_runtime.md) +- [a1_subset_qc.md](../outputs/reports/a1_subset_qc.md) +- [a2_split_qc.md](../outputs/reports/a2_split_qc.md) +- [a3_bbox_qc.md](../outputs/reports/a3_bbox_qc.md) +- [a3_mask_qc.md](../outputs/reports/a3_mask_qc.md) +- [feature_extraction_qc.md](../outputs/reports/feature_extraction_qc.md) +- [a6_baseline_qc.md](../outputs/reports/a6_baseline_qc.md) +- [a10_a11_qc.md](../outputs/reports/a10_a11_qc.md) +- [a9_deep_qc.md](../outputs/reports/a9_deep_qc.md) + +## 20. Короткий итог в одном абзаце + +Эксперимент показал, что на текущем корпусе данных интерпретируемые морфометрические, цветовые и текстурные признаки действительно позволяют хорошо различать `blast / non-blast` на patient-level split, причем особенно ценно то, что сильный результат сохраняется даже в шумном `full_realistic` режиме. Маски полезны, объединение `bbox + mask` лучше отдельных вариантов, а объектные оценки успешно поднимаются до image-level и patient-level. Легкие CNN оказались слабее лучшего handcrafted baseline, а лучшая deep-модель — это late fusion изображения и табличных признаков. В результате эксперимент уже сейчас дает содержательный, воспроизводимый и понятный для диссертации набор выводов. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f3239ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# Core scientific stack +joblib==1.5.3 +matplotlib==3.10.8 +numpy==2.4.4 +pandas==3.0.2 +Pillow==12.2.0 +pyarrow==23.0.1 +PyWavelets==1.9.0 +scikit-image==0.26.0 +scikit-learn==1.8.0 +scipy==1.17.1 +tqdm==4.67.3 + +# Deep baselines +torch==2.11.0 +torchvision==0.26.0 + +# Optional: +# xgboost is not required for the current full run because the code +# automatically falls back to HistGradientBoostingClassifier when xgboost +# is unavailable or cannot be imported. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8990b40 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,28 @@ +# Scripts Overview + +This directory contains two kinds of scripts. + +## Public entry points + +These scripts are intended to be launched directly by a researcher: + +- `run_foundation.py` - prepares the shared experiment base (`A0-A5`). +- `run_h1_interpretable_features.py` - checks hypothesis H1. +- `run_h2_mask_effect.py` - checks hypothesis H2. +- `run_h3_noise_regimes.py` - checks hypothesis H3. +- `run_h4_aggregation.py` - checks hypothesis H4. +- `run_h5_deep_vs_handcrafted.py` - checks hypothesis H5. +- `run_all_hypotheses.py` - runs the full reproducible experiment end to end. + +## Internal orchestration and stage helpers + +These files are used by the public entry points and are kept in the repository +because they contain reusable implementation details: + +- `reproducibility_blocks.py` - high-level orchestration shared by public runners. +- `run_a1_to_a4.py`, `run_a3_to_a5.py`, `run_a6_to_a11.py`, `run_a9_deep_baselines.py` - lower-level stage runners for the original experiment plan. +- `build_modeling_workset.py` - prepares the modeling subset used by H5. +- `render_markdown_report_html.py` - utility for rendering Markdown reports into HTML. + +If you only want to reproduce the experiment, start with the public entry points +or the `Makefile` targets documented in `README.md` and `docs/reproducibility_guide.md`. diff --git a/scripts/build_modeling_workset.py b/scripts/build_modeling_workset.py new file mode 100644 index 0000000..f4955f6 --- /dev/null +++ b/scripts/build_modeling_workset.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from utils.project import CROPS_ROOT, DATASET_INDEX_ROOT, REPORTS_ROOT, ensure_output_layout, write_markdown + + +KEY_COLUMNS = [ + "patient_id", + "image_id", + "object_id_within_image", + "class_id", + "class_name", + "has_mask", +] + + +def _cap_frame(frame: pd.DataFrame, max_rows: int, random_state: int) -> pd.DataFrame: + if len(frame) <= max_rows: + return frame + sampled_groups: list[pd.DataFrame] = [] + remaining = max_rows + groups = list(frame.groupby("target_binary", sort=True)) + for index, (_, group) in enumerate(groups): + if index == len(groups) - 1: + n_take = min(len(group), remaining) + else: + n_take = max(1, int(round(max_rows * len(group) / len(frame)))) + n_take = min(len(group), n_take) + remaining -= n_take + sampled_groups.append(group.sample(n=n_take, random_state=random_state)) + return pd.concat(sampled_groups, ignore_index=True) + + +def build_modeling_workset() -> dict[str, str]: + ensure_output_layout() + full_task = pd.read_parquet(DATASET_INDEX_ROOT / "task_a43_full_realistic_binary.parquet") + roi_manifest = pd.read_parquet(CROPS_ROOT / "roi_manifest.parquet") + + train_df = _cap_frame(full_task.loc[full_task["split_name"] == "train"].copy(), max_rows=20000, random_state=42) + val_df = _cap_frame(full_task.loc[full_task["split_name"] == "val"].copy(), max_rows=5000, random_state=43) + test_df = full_task.loc[full_task["split_name"] == "test"].copy() + workset_df = pd.concat([train_df, val_df, test_df], ignore_index=True) + + workset_keys = workset_df[KEY_COLUMNS].drop_duplicates() + workset_roi = roi_manifest.merge(workset_keys, on=KEY_COLUMNS, how="inner", validate="one_to_one") + + workset_task_path = DATASET_INDEX_ROOT / "task_a43_full_realistic_modeling_workset.parquet" + workset_roi_path = CROPS_ROOT / "roi_manifest_modeling_workset.parquet" + workset_df.to_parquet(workset_task_path, index=False) + workset_roi.to_parquet(workset_roi_path, index=False) + + report_lines = [ + "# Modeling Workset QC", + "", + f"- Train rows: `{len(train_df)}`", + f"- Validation rows: `{len(val_df)}`", + f"- Test rows: `{len(test_df)}`", + f"- Total workset rows: `{len(workset_df)}`", + f"- ROI rows matched: `{len(workset_roi)}`", + f"- Task artifact: `{workset_task_path}`", + f"- ROI artifact: `{workset_roi_path}`", + ] + report_path = REPORTS_ROOT / "modeling_workset_qc.md" + write_markdown(report_path, report_lines) + + return { + "task_workset_parquet": str(workset_task_path), + "roi_workset_parquet": str(workset_roi_path), + "qc_report_md": str(report_path), + } + + +def main() -> None: + print(json.dumps(build_modeling_workset(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_markdown_report_html.py b/scripts/render_markdown_report_html.py new file mode 100644 index 0000000..c036531 --- /dev/null +++ b/scripts/render_markdown_report_html.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import html +import mimetypes +import re +from pathlib import Path + + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +IMAGE_RE = re.compile(r"^!\[(.*?)\]\((.*?)\)\s*$") +UL_RE = re.compile(r"^-\s+(.*)$") +OL_RE = re.compile(r"^\d+\.\s+(.*)$") +TABLE_RULE_RE = re.compile(r"^\|\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$") +CODE_RE = re.compile(r"`([^`]+)`") +LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +STRONG_RE = re.compile(r"\*\*([^*]+)\*\*") +EM_RE = re.compile(r"(? str: + candidate = Path(target) + if candidate.is_absolute() and candidate.exists(): + return candidate.as_uri() + return html.escape(target, quote=True) + + +def to_image_src(target: str) -> str: + candidate = Path(target) + if candidate.is_absolute() and candidate.exists(): + mime_type, _ = mimetypes.guess_type(candidate.name) + if mime_type: + encoded = base64.b64encode(candidate.read_bytes()).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + return html.escape(target, quote=True) + + +def render_inline(text: str) -> str: + placeholders: list[str] = [] + + def stash(fragment: str) -> str: + placeholders.append(fragment) + return f"@@PLACEHOLDER_{len(placeholders) - 1}@@" + + escaped = html.escape(text) + + def code_replace(match: re.Match[str]) -> str: + return stash(f"{html.escape(match.group(1))}") + + escaped = CODE_RE.sub(code_replace, escaped) + + def link_replace(match: re.Match[str]) -> str: + label = render_inline(match.group(1)) + href = to_href(match.group(2)) + return stash(f'{label}') + + escaped = LINK_RE.sub(link_replace, escaped) + escaped = STRONG_RE.sub(r"\1", escaped) + escaped = EM_RE.sub(r"\1", escaped) + + for index, fragment in enumerate(placeholders): + escaped = escaped.replace(f"@@PLACEHOLDER_{index}@@", fragment) + return escaped + + +def split_table_row(line: str) -> list[str]: + raw_cells = line.strip().strip("|").split("|") + return [cell.strip() for cell in raw_cells] + + +def cell_alignment(spec: str) -> str: + stripped = spec.strip() + left = stripped.startswith(":") + right = stripped.endswith(":") + if left and right: + return "center" + if right: + return "right" + return "left" + + +def render_table(table_lines: list[str]) -> str: + rows = [split_table_row(line) for line in table_lines] + alignments: list[str] = [] + body_rows = rows[1:] + if len(rows) > 1 and TABLE_RULE_RE.match(table_lines[1].strip()): + alignments = [cell_alignment(spec) for spec in rows[1]] + body_rows = rows[2:] + + header = rows[0] + if not alignments: + alignments = ["left"] * len(header) + + parts = ["", "", ""] + for cell, align in zip(header, alignments): + parts.append(f'') + parts.extend(["", "", ""]) + + for row in body_rows: + parts.append("") + for idx, cell in enumerate(row): + align = alignments[idx] if idx < len(alignments) else "left" + parts.append(f'') + parts.append("") + + parts.extend(["", "
{render_inline(cell)}
{render_inline(cell)}
"]) + return "\n".join(parts) + + +def markdown_to_html(markdown_text: str, title: str) -> str: + lines = markdown_text.splitlines() + body_parts: list[str] = [] + paragraph_lines: list[str] = [] + list_items: list[str] = [] + list_tag: str | None = None + table_lines: list[str] = [] + + def flush_paragraph() -> None: + if not paragraph_lines: + return + paragraph = " ".join(line.strip() for line in paragraph_lines) + body_parts.append(f"

{render_inline(paragraph)}

") + paragraph_lines.clear() + + def flush_list() -> None: + nonlocal list_tag + if not list_items or not list_tag: + return + body_parts.append(f"<{list_tag}>") + for item in list_items: + body_parts.append(f"
  • {render_inline(item)}
  • ") + body_parts.append(f"") + list_items.clear() + list_tag = None + + def flush_table() -> None: + if not table_lines: + return + body_parts.append(render_table(table_lines)) + table_lines.clear() + + for line in lines: + stripped = line.strip() + + if not stripped: + flush_paragraph() + flush_list() + flush_table() + continue + + if stripped.startswith("|"): + flush_paragraph() + flush_list() + table_lines.append(stripped) + continue + + flush_table() + + image_match = IMAGE_RE.match(stripped) + if image_match: + flush_paragraph() + flush_list() + alt_text, image_target = image_match.groups() + src = to_image_src(image_target) + caption = render_inline(alt_text) + body_parts.append( + "\n".join( + [ + '
    ', + f'{html.escape(alt_text, quote=True)}', + f"
    {caption}
    ", + "
    ", + ] + ) + ) + continue + + heading_match = HEADING_RE.match(stripped) + if heading_match: + flush_paragraph() + flush_list() + level = len(heading_match.group(1)) + heading_text = render_inline(heading_match.group(2)) + body_parts.append(f"{heading_text}") + continue + + unordered_match = UL_RE.match(stripped) + if unordered_match: + flush_paragraph() + if list_tag not in (None, "ul"): + flush_list() + list_tag = "ul" + list_items.append(unordered_match.group(1)) + continue + + ordered_match = OL_RE.match(stripped) + if ordered_match: + flush_paragraph() + if list_tag not in (None, "ol"): + flush_list() + list_tag = "ol" + list_items.append(ordered_match.group(0).split(". ", 1)[1]) + continue + + if list_items: + flush_list() + paragraph_lines.append(stripped) + + flush_paragraph() + flush_list() + flush_table() + + css = """ + @page { + margin: 22mm 18mm 20mm 22mm; + } + body { + font-family: "Times New Roman", serif; + font-size: 12pt; + line-height: 1.45; + color: #111111; + max-width: 175mm; + margin: 0 auto; + } + h1, h2, h3, h4 { + font-weight: 700; + page-break-after: avoid; + margin-top: 1.1em; + margin-bottom: 0.45em; + } + h1 { + text-align: center; + font-size: 18pt; + margin-top: 0.2em; + } + h2 { + font-size: 15pt; + } + h3 { + font-size: 13pt; + } + p { + margin: 0 0 0.65em 0; + text-align: justify; + } + ul, ol { + margin: 0.2em 0 0.8em 1.5em; + } + li { + margin: 0.2em 0; + } + code { + font-family: "Courier New", monospace; + font-size: 10.5pt; + background: #f2f2f2; + padding: 0.05em 0.25em; + border-radius: 2px; + } + table { + width: 100%; + border-collapse: collapse; + margin: 0.75em 0 1.0em 0; + font-size: 10.5pt; + page-break-inside: avoid; + } + th, td { + border: 1px solid #444444; + padding: 5px 7px; + vertical-align: top; + } + th { + background: #e9edf2; + } + .report-figure { + margin: 1em auto 1.2em auto; + text-align: center; + page-break-inside: avoid; + } + .report-figure img { + max-width: 100%; + height: auto; + border: 1px solid #b5b5b5; + } + .report-figure figcaption { + margin-top: 0.45em; + font-size: 10.5pt; + font-style: italic; + } + a { + color: #0b57d0; + text-decoration: none; + } + """ + + return "\n".join( + [ + "", + '', + "", + ' ', + f" {html.escape(title)}", + " ", + "", + "", + *body_parts, + "", + "", + ] + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render a Markdown report to standalone HTML.") + parser.add_argument("input_markdown", type=Path) + parser.add_argument("output_html", type=Path) + args = parser.parse_args() + + markdown_text = args.input_markdown.read_text(encoding="utf-8") + html_text = markdown_to_html(markdown_text, title=args.input_markdown.stem) + args.output_html.write_text(html_text, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/reproducibility_blocks.py b/scripts/reproducibility_blocks.py new file mode 100644 index 0000000..518fd22 --- /dev/null +++ b/scripts/reproducibility_blocks.py @@ -0,0 +1,1006 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +SCRIPTS_ROOT = PROJECT_ROOT / "scripts" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, SCRIPTS_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from build_modeling_workset import build_modeling_workset +from dataset.build_index import MAIN_PROTOCOL_EXCLUDES, build_index_artifacts +from dataset.build_patient_summary import build_patient_summary +from dataset.build_subsets import build_subsets +from dataset.build_tasks import build_tasks +from dataset.make_group_splits import make_group_splits +from evaluation.hypothesis_figures import ( + generate_h1_figures, + generate_h2_figures, + generate_h3_figures, + generate_h4_figures, + generate_h5_figures, +) +from features.feature_registry import extract_features +from preprocessing.extract_bbox_crops import extract_bbox_crops +from preprocessing.extract_mask_crops import extract_mask_crops +from run_a6_to_a11 import ( + _load_required_artifacts, + run_a10_a11, + run_a6, + run_a7, + run_a8_dataset_regimes, + run_a8_mask_usage, + save_a8_summary_plot, +) +from run_a9_deep_baselines import run_a9_deep_baselines +from run_subject_figures_for_articles import append_execution_log, run_subject_figures +from utils.dataset_paths import resolve_dataset_roots +from utils.project import ( + CROPS_ROOT, + DATASET_INDEX_ROOT, + EXPERIMENT_EXECUTION_LOG_PATH, + FEATURES_ROOT, + FIGURES_ROOT, + PREDICTIONS_ROOT, + PROJECT_ROOT as PROJECT_ROOT_FROM_UTILS, + REPORTS_ROOT, + SUBJECT_FIGURE_CASES_PATH, + SUBJECT_FIGURE_MANIFEST_PATH, + SUBJECT_FIGURES_SUMMARY_MD, + TABLES_ROOT, + ensure_dir, + ensure_output_layout, + local_date_tag, + utc_now_iso, + write_json, + write_markdown, +) + + +assert PROJECT_ROOT == PROJECT_ROOT_FROM_UTILS + +HYPOTHESIS_REPORTS_ROOT = REPORTS_ROOT / "hypotheses" +HYPOTHESIS_FIGURE_MANIFEST_PATH = TABLES_ROOT / "hypothesis_figure_manifest.csv" + +FOUNDATION_REQUIRED = [ + DATASET_INDEX_ROOT / "patient_inventory.csv", + DATASET_INDEX_ROOT / "image_index.parquet", + DATASET_INDEX_ROOT / "object_index.parquet", + DATASET_INDEX_ROOT / "subset_full_realistic.parquet", + DATASET_INDEX_ROOT / "subset_clean_cell.parquet", + DATASET_INDEX_ROOT / "subset_strict_morphology.parquet", + DATASET_INDEX_ROOT / "subset_mask_ready.parquet", + PROJECT_ROOT / "outputs" / "splits" / "split_v1.json", + TABLES_ROOT / "subset_summary.csv", + TABLES_ROOT / "split_summary.csv", + TABLES_ROOT / "task_prevalence_summary.csv", + DATASET_INDEX_ROOT / "task_a41_clean_binary.parquet", + DATASET_INDEX_ROOT / "task_a42_strict_binary.parquet", + DATASET_INDEX_ROOT / "task_a43_full_realistic_binary.parquet", + CROPS_ROOT / "bbox_roi_manifest.parquet", + CROPS_ROOT / "roi_manifest.parquet", + FEATURES_ROOT / "features_bbox.parquet", + FEATURES_ROOT / "features_mask.parquet", + FEATURES_ROOT / "features_combined.parquet", + FEATURES_ROOT / "feature_dictionary.md", + TABLES_ROOT / "feature_missingness.csv", +] + +H1_REQUIRED = [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + TABLES_ROOT / "object_level_metrics_baselines.csv", + PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet", + TABLES_ROOT / "top_features_stability.csv", + REPORTS_ROOT / "feature_interpretation_notes.md", +] + +H2_REQUIRED = [TABLES_ROOT / "ablation_mask_usage.csv", PREDICTIONS_ROOT / "h2_mask_usage_predictions.parquet"] +H3_REQUIRED = [TABLES_ROOT / "ablation_dataset_regimes.csv"] +H4_REQUIRED = [ + TABLES_ROOT / "image_level_metrics.csv", + TABLES_ROOT / "patient_level_ranking_metrics.csv", + TABLES_ROOT / "figure_manifest.csv", + TABLES_ROOT / "table_manifest.csv", +] +H5_REQUIRED = [ + DATASET_INDEX_ROOT / "task_a43_full_realistic_modeling_workset.parquet", + CROPS_ROOT / "roi_manifest_modeling_workset.parquet", + TABLES_ROOT / "deep_vs_handcrafted.csv", + PREDICTIONS_ROOT / "object_level_deep_predictions.parquet", + FIGURES_ROOT / "deep_training_curves.png", +] +SUBJECT_FIGURE_REQUIRED = [ + SUBJECT_FIGURE_CASES_PATH, + SUBJECT_FIGURE_MANIFEST_PATH, + SUBJECT_FIGURES_SUMMARY_MD, +] + + +def _ensure_reports_root() -> None: + ensure_output_layout() + ensure_dir(HYPOTHESIS_REPORTS_ROOT) + + +def _write_summary(slug: str, title: str, lines: list[str], payload: dict[str, object]) -> dict[str, str]: + _ensure_reports_root() + md_path = HYPOTHESIS_REPORTS_ROOT / f"{slug}_summary.md" + json_path = HYPOTHESIS_REPORTS_ROOT / f"{slug}_summary.json" + write_markdown(md_path, [f"# {title}", "", f"Generated at: `{utc_now_iso()}`", "", *lines]) + write_json(json_path, payload) + return {"summary_md": str(md_path), "summary_json": str(json_path)} + + +def _require(paths: list[Path], message: str) -> None: + missing = [str(path) for path in paths if not path.exists()] + if missing: + missing_block = "\n".join(f"- {path}" for path in missing) + raise FileNotFoundError(f"{message}\nMissing artifacts:\n{missing_block}") + + +def _all_exist(paths: list[Path]) -> bool: + return all(path.exists() for path in paths) + + +def _build_figure_records(hypothesis: str, date_tag: str, figure_paths: list[str]) -> list[dict[str, object]]: + return [ + { + "hypothesis": hypothesis, + "date_tag": date_tag, + "figure_index": index, + "filename": Path(path).name, + "path": path, + } + for index, path in enumerate(figure_paths, start=1) + ] + + +def _write_hypothesis_figure_manifest(*figure_groups: list[dict[str, object]]) -> str: + rows: list[dict[str, object]] = [] + for group in figure_groups: + rows.extend(group) + manifest_df = pd.DataFrame(rows) + manifest_df.to_csv(HYPOTHESIS_FIGURE_MANIFEST_PATH, index=False) + return str(HYPOTHESIS_FIGURE_MANIFEST_PATH) + + +def _best_rows_by_task(leaderboard_df: pd.DataFrame) -> pd.DataFrame: + ranked = leaderboard_df.sort_values( + ["task_name", "validation_pr_auc", "test_pr_auc", "test_recall_blast"], + ascending=[True, False, False, False], + ) + return ranked.groupby("task_name", sort=False).head(1).reset_index(drop=True) + + +def _task_display_name(task_name: str) -> str: + mapping = { + "a41_clean_binary": "A4.1 clean_cell", + "a42_strict_binary": "A4.2 strict_morphology", + "a43_full_realistic_binary": "A4.3 full_realistic", + } + return mapping.get(task_name, task_name) + + +def run_foundation( + data_root: str | Path | None = None, + masks_root: str | Path | None = None, + config_path: str | Path | None = None, + force: bool = False, +) -> dict[str, object]: + _ensure_reports_root() + resolved_data_root, resolved_masks_root, dataset_path_source = resolve_dataset_roots( + data_root=data_root, + masks_root=masks_root, + config_path=config_path, + ) + + steps_run: list[str] = [] + + if force or not _all_exist( + [ + DATASET_INDEX_ROOT / "patient_inventory.csv", + DATASET_INDEX_ROOT / "image_index.parquet", + DATASET_INDEX_ROOT / "object_index.parquet", + ] + ): + build_index_artifacts( + data_root=resolved_data_root, + masks_root=resolved_masks_root, + output_root=PROJECT_ROOT / "outputs", + main_protocol_excludes=MAIN_PROTOCOL_EXCLUDES, + ) + steps_run.append("A0") + + if force or not _all_exist( + [ + DATASET_INDEX_ROOT / "subset_full_realistic.parquet", + DATASET_INDEX_ROOT / "subset_clean_cell.parquet", + DATASET_INDEX_ROOT / "subset_strict_morphology.parquet", + DATASET_INDEX_ROOT / "subset_mask_ready.parquet", + TABLES_ROOT / "subset_summary.csv", + ] + ): + build_subsets( + object_index_path=DATASET_INDEX_ROOT / "object_index.parquet", + image_index_path=DATASET_INDEX_ROOT / "image_index.parquet", + ) + steps_run.append("A1") + + patient_summary_path = build_patient_summary( + patient_inventory_path=DATASET_INDEX_ROOT / "patient_inventory.csv", + image_index_path=DATASET_INDEX_ROOT / "image_index.parquet", + object_index_path=DATASET_INDEX_ROOT / "object_index.parquet", + ) + + if force or not _all_exist( + [ + PROJECT_ROOT / "outputs" / "splits" / "split_v1.json", + TABLES_ROOT / "split_summary.csv", + ] + ): + make_group_splits( + patient_summary_path=patient_summary_path, + object_index_path=DATASET_INDEX_ROOT / "object_index.parquet", + random_state=42, + ) + steps_run.append("A2") + + if force or not _all_exist( + [ + DATASET_INDEX_ROOT / "task_a41_clean_binary.parquet", + DATASET_INDEX_ROOT / "task_a42_strict_binary.parquet", + DATASET_INDEX_ROOT / "task_a43_full_realistic_binary.parquet", + TABLES_ROOT / "task_prevalence_summary.csv", + ] + ): + build_tasks(PROJECT_ROOT / "outputs" / "splits" / "split_v1.json") + steps_run.append("A4") + + if force or not (CROPS_ROOT / "bbox_roi_manifest.parquet").exists(): + extract_bbox_crops(DATASET_INDEX_ROOT / "subset_full_realistic.parquet") + steps_run.append("A3_bbox") + + if force or not (CROPS_ROOT / "roi_manifest.parquet").exists(): + extract_mask_crops(CROPS_ROOT / "bbox_roi_manifest.parquet") + steps_run.append("A3_mask") + + if force or not _all_exist( + [ + FEATURES_ROOT / "features_bbox.parquet", + FEATURES_ROOT / "features_mask.parquet", + FEATURES_ROOT / "features_combined.parquet", + FEATURES_ROOT / "feature_dictionary.md", + TABLES_ROOT / "feature_missingness.csv", + ] + ): + extract_features(CROPS_ROOT / "roi_manifest.parquet") + steps_run.append("A5") + + _require(FOUNDATION_REQUIRED, "Foundation validation failed after stepwise preparation.") + + patient_inventory_df = pd.read_csv(DATASET_INDEX_ROOT / "patient_inventory.csv") + image_index_df = pd.read_parquet(DATASET_INDEX_ROOT / "image_index.parquet") + object_index_df = pd.read_parquet(DATASET_INDEX_ROOT / "object_index.parquet") + subset_summary_df = pd.read_csv(TABLES_ROOT / "subset_summary.csv") + split_summary_df = pd.read_csv(TABLES_ROOT / "split_summary.csv") + feature_missingness_df = pd.read_csv(TABLES_ROOT / "feature_missingness.csv") + combined_missingness_df = feature_missingness_df.loc[feature_missingness_df["matrix"].eq("combined")].copy() + bbox_manifest_df = pd.read_parquet(CROPS_ROOT / "bbox_roi_manifest.parquet") + roi_manifest_df = pd.read_parquet(CROPS_ROOT / "roi_manifest.parquet") + features_combined_df = pd.read_parquet(FEATURES_ROOT / "features_combined.parquet") + + main_patient_df = patient_inventory_df.loc[patient_inventory_df["include_in_main_protocol"].eq(True)].copy() + main_image_df = image_index_df.loc[ + image_index_df["include_in_main_protocol"].eq(True) & image_index_df["has_image"].eq(True) + ].copy() + main_object_df = object_index_df.loc[object_index_df["include_in_main_protocol"].eq(True)].copy() + + lines = [ + "## Что запускает этот блок", + "", + "- `A0`: аудит данных и построение индексов.", + "- `A1`: формирование подмножеств `full_realistic`, `clean_cell`, `strict_morphology`, `mask_ready`.", + "- `A2`: patient-level split `train / val / test`.", + "- `A4`: подготовка трех бинарных задач.", + "- `A3`: построение ROI по bbox и маскам.", + "- `A5`: извлечение признаков `bbox`, `mask`, `combined`.", + "", + "## Ключевые результаты", + "", + f"- Dataset path source: `{dataset_path_source}`.", + f"- Original dataset root: `{resolved_data_root}`.", + f"- Masks dataset root: `{resolved_masks_root}`.", + f"- Recomputed steps in this run: `{', '.join(steps_run) if steps_run else 'none; reused existing validated artifacts'}`.", + f"- Main-protocol objects: `{len(main_object_df)}`.", + f"- Main-protocol images: `{len(main_image_df)}`.", + f"- Main-protocol patients: `{len(main_patient_df)}`.", + f"- Full-realistic subset rows: `{int(subset_summary_df.loc[subset_summary_df['subset_name'].eq('full_realistic'), 'object_count'].iloc[0])}`.", + f"- ROI rows in bbox manifest: `{len(bbox_manifest_df)}`.", + f"- ROI rows in mask manifest: `{len(roi_manifest_df)}`.", + f"- Combined feature rows: `{len(features_combined_df)}`.", + f"- Combined numeric features: `{len(combined_missingness_df)}`.", + f"- Combined features with missing values: `{int((combined_missingness_df['missing_fraction'] > 0).sum())}`.", + "", + "## Split summary", + "", + "| Split | Patients | Active patients | Images | Objects | Blast objects |", + "|---|---:|---:|---:|---:|---:|", + ] + for row in split_summary_df.to_dict("records"): + lines.append( + f"| {row['split_name']} | {int(row['patient_count'])} | {int(row['active_patient_count'])} | " + f"{int(row['image_count'])} | {int(row['object_count'])} | {int(row['blast_count'])} |" + ) + lines.extend( + [ + "", + "## Основные артефакты", + "", + f"- Dataset index: `{DATASET_INDEX_ROOT / 'object_index.parquet'}`.", + f"- Split definition: `{PROJECT_ROOT / 'outputs' / 'splits' / 'split_v1.json'}`.", + f"- ROI manifest: `{CROPS_ROOT / 'roi_manifest.parquet'}`.", + f"- Combined features: `{FEATURES_ROOT / 'features_combined.parquet'}`.", + f"- Feature dictionary: `{FEATURES_ROOT / 'feature_dictionary.md'}`.", + ] + ) + + payload = { + "block": "foundation", + "dataset_roots": { + "data_root": str(resolved_data_root), + "masks_root": str(resolved_masks_root), + "source": dataset_path_source, + }, + "recomputed_steps": steps_run, + "patient_summary_csv": str(patient_summary_path), + "main_protocol_summary": { + "patient_count": int(len(main_patient_df)), + "image_count": int(len(main_image_df)), + "object_count": int(len(main_object_df)), + }, + "a3": { + "bbox_manifest_rows": len(bbox_manifest_df), + "roi_manifest_rows": len(roi_manifest_df), + "bbox_roi_manifest_parquet": str(CROPS_ROOT / "bbox_roi_manifest.parquet"), + "roi_manifest_parquet": str(CROPS_ROOT / "roi_manifest.parquet"), + }, + "a5": { + "features_bbox": str(FEATURES_ROOT / "features_bbox.parquet"), + "features_mask": str(FEATURES_ROOT / "features_mask.parquet"), + "features_combined": str(FEATURES_ROOT / "features_combined.parquet"), + "feature_dictionary_md": str(FEATURES_ROOT / "feature_dictionary.md"), + "feature_missingness_csv": str(TABLES_ROOT / "feature_missingness.csv"), + }, + } + payload.update(_write_summary("foundation", "Foundation Reproducibility Summary", lines, payload)) + return payload + + +def run_h1_interpretable_features(force: bool = False) -> dict[str, object]: + _require( + [ + FEATURES_ROOT / "features_combined.parquet", + DATASET_INDEX_ROOT / "task_a41_clean_binary.parquet", + DATASET_INDEX_ROOT / "task_a42_strict_binary.parquet", + DATASET_INDEX_ROOT / "task_a43_full_realistic_binary.parquet", + ], + "H1 requires foundation outputs. Run `scripts/run_foundation.py` first.", + ) + + h1_recomputed: list[str] = [] + if force or not _all_exist( + [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + TABLES_ROOT / "object_level_metrics_baselines.csv", + PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet", + ] + ): + a6_result = run_a6() + h1_recomputed.append("A6") + else: + a6_result = { + "baseline_model_leaderboard_csv": str(TABLES_ROOT / "baseline_model_leaderboard.csv"), + "object_level_metrics_csv": str(TABLES_ROOT / "object_level_metrics_baselines.csv"), + "object_level_predictions_parquet": str(PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet"), + } + + features_combined, _, _, tasks = _load_required_artifacts() + leaderboard_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + if force or not _all_exist( + [ + TABLES_ROOT / "top_features_stability.csv", + REPORTS_ROOT / "feature_interpretation_notes.md", + ] + ): + run_a7(leaderboard_df, tasks["a43_full_realistic_binary"], features_combined) + h1_recomputed.append("A7") + + _require(H1_REQUIRED, "H1 validation failed after preparation.") + best_rows = _best_rows_by_task(leaderboard_df) + top_features_df = pd.read_csv(TABLES_ROOT / "top_features_stability.csv").head(10) + h1_date_tag = local_date_tag() + h1_figure_paths = generate_h1_figures( + leaderboard_df=leaderboard_df, + predictions_df=pd.read_parquet(PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet"), + top_features_df=pd.read_csv(TABLES_ROOT / "top_features_stability.csv"), + date_tag=h1_date_tag, + ) + h1_figure_records = _build_figure_records("H1", h1_date_tag, h1_figure_paths) + + lines = [ + "## Что проверяет блок H1", + "", + "- Полезны ли интерпретируемые признаки формы, цвета и текстуры для различения `blast / non-blast`.", + "- Какие baseline-модели и группы признаков дают лучший результат на patient-level split.", + f"- Recomputed steps in this run: `{', '.join(h1_recomputed) if h1_recomputed else 'none; reused existing validated artifacts'}`.", + f"- Hypothesis figures generated: `{len(h1_figure_paths)}` (`H1_{h1_date_tag}_*.png`).", + "", + "## Лучшие конфигурации по задачам", + "", + "| Task | Best model | Feature group | Validation PR-AUC | Test PR-AUC | Recall blast | Specificity |", + "|---|---|---|---:|---:|---:|---:|", + ] + for row in best_rows.to_dict("records"): + lines.append( + f"| {_task_display_name(str(row['task_name']))} | {row['model_name']} | {row['feature_group']} | " + f"{float(row['validation_pr_auc']):.4f} | {float(row['test_pr_auc']):.4f} | " + f"{float(row['test_recall_blast']):.4f} | {float(row['test_specificity']):.4f} |" + ) + lines.extend( + [ + "", + "## Наиболее стабильные признаки для лучшей full_realistic-модели", + "", + "| Feature | Top-20 frequency | Mean importance | Best rank |", + "|---|---:|---:|---:|", + ] + ) + for row in top_features_df.to_dict("records"): + lines.append( + f"| {row['feature_name']} | {int(row['top20_frequency'])} | " + f"{float(row['mean_importance']):.6f} | {int(row['best_rank'])} |" + ) + lines.extend( + [ + "", + "## Основные артефакты", + "", + f"- Baseline leaderboard: `{TABLES_ROOT / 'baseline_model_leaderboard.csv'}`.", + f"- Object-level metrics: `{TABLES_ROOT / 'object_level_metrics_baselines.csv'}`.", + f"- Predictions: `{PREDICTIONS_ROOT / 'object_level_baseline_predictions.parquet'}`.", + f"- Stable features: `{TABLES_ROOT / 'top_features_stability.csv'}`.", + f"- Interpretation notes: `{REPORTS_ROOT / 'feature_interpretation_notes.md'}`.", + ] + ) + + payload = { + "block": "h1", + "hypothesis": "interpretable_features_help", + "recomputed_steps": h1_recomputed, + "a6": a6_result, + "a7_report_md": str(REPORTS_ROOT / "feature_interpretation_notes.md"), + "figure_date_tag": h1_date_tag, + "hypothesis_figure_paths": h1_figure_paths, + "hypothesis_figure_records": h1_figure_records, + "best_rows": best_rows.to_dict("records"), + "top_features_preview": top_features_df.to_dict("records"), + } + payload.update(_write_summary("h1_interpretable_features", "H1 Interpretable Features Summary", lines, payload)) + return payload + + +def run_h2_mask_effect(force: bool = False) -> dict[str, object]: + _require( + [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + FEATURES_ROOT / "features_bbox.parquet", + FEATURES_ROOT / "features_mask.parquet", + FEATURES_ROOT / "features_combined.parquet", + ], + "H2 requires foundation outputs and H1 baseline results. Run `scripts/run_foundation.py` and `scripts/run_h1_interpretable_features.py` first.", + ) + + leaderboard_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + features_combined, features_bbox, features_mask, tasks = _load_required_artifacts() + + if force or not _all_exist(H2_REQUIRED): + mask_ablation_df = run_a8_mask_usage( + leaderboard_df=leaderboard_df, + tasks=tasks, + features_combined=features_combined, + features_bbox=features_bbox, + features_mask=features_mask, + ) + h2_recomputed = True + else: + mask_ablation_df = pd.read_csv(TABLES_ROOT / "ablation_mask_usage.csv") + h2_recomputed = False + + pivot_df = ( + mask_ablation_df.pivot(index="task_name", columns="label", values="pr_auc") + .reset_index() + .rename_axis(None, axis=1) + ) + pivot_df["delta_mask_vs_bbox"] = pivot_df["mask_only"] - pivot_df["bbox_only"] + pivot_df["delta_bbox_plus_mask_vs_bbox"] = pivot_df["bbox_plus_mask"] - pivot_df["bbox_only"] + h2_date_tag = local_date_tag() + h2_figure_paths = generate_h2_figures(mask_ablation_df=mask_ablation_df, date_tag=h2_date_tag) + h2_figure_records = _build_figure_records("H2", h2_date_tag, h2_figure_paths) + + lines = [ + "## Что проверяет блок H2", + "", + "- Улучшает ли использование маски клетки качество признаков и итоговой классификации.", + f"- Recomputed in this run: `{'A8 mask usage' if h2_recomputed else 'none; reused existing validated artifact'}`.", + f"- Hypothesis figures generated: `{len(h2_figure_paths)}` (`H2_{h2_date_tag}_*.png`).", + "", + "## Сравнение по PR-AUC", + "", + "| Task | bbox_only | mask_only | bbox_plus_mask | mask_only - bbox_only | bbox_plus_mask - bbox_only |", + "|---|---:|---:|---:|---:|---:|", + ] + for row in pivot_df.to_dict("records"): + lines.append( + f"| {_task_display_name(str(row['task_name']))} | {float(row['bbox_only']):.4f} | " + f"{float(row['mask_only']):.4f} | {float(row['bbox_plus_mask']):.4f} | " + f"{float(row['delta_mask_vs_bbox']):.4f} | {float(row['delta_bbox_plus_mask_vs_bbox']):.4f} |" + ) + lines.extend( + [ + "", + "## Основные артефакты", + "", + f"- Mask ablation table: `{TABLES_ROOT / 'ablation_mask_usage.csv'}`.", + f"- Combined ablation figure (если уже собрана совместно с H3): `{FIGURES_ROOT / 'ablation_summary.png'}`.", + ] + ) + + payload = { + "block": "h2", + "hypothesis": "mask_improves_features", + "recomputed_steps": ["A8_mask_usage"] if h2_recomputed else [], + "ablation_mask_usage_csv": str(TABLES_ROOT / "ablation_mask_usage.csv"), + "figure_date_tag": h2_date_tag, + "hypothesis_figure_paths": h2_figure_paths, + "hypothesis_figure_records": h2_figure_records, + "pr_auc_comparison": pivot_df.to_dict("records"), + } + payload.update(_write_summary("h2_mask_effect", "H2 Mask Effect Summary", lines, payload)) + return payload + + +def run_h3_noise_regimes(force: bool = False) -> dict[str, object]: + _require( + [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + TABLES_ROOT / "top_features_stability.csv", + FEATURES_ROOT / "features_combined.parquet", + ], + "H3 requires foundation outputs and H1 interpretation artifacts. Run `scripts/run_foundation.py` and `scripts/run_h1_interpretable_features.py` first.", + ) + + leaderboard_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + features_combined, _, _, tasks = _load_required_artifacts() + + if force or not _all_exist(H3_REQUIRED): + dataset_ablation_df = run_a8_dataset_regimes( + leaderboard_df=leaderboard_df, + tasks=tasks, + features_combined=features_combined, + ) + h3_recomputed = True + else: + dataset_ablation_df = pd.read_csv(TABLES_ROOT / "ablation_dataset_regimes.csv") + h3_recomputed = False + + primary_rows = dataset_ablation_df.loc[ + dataset_ablation_df["label"].eq("combined_with_class_weight") + ].copy() + extras_rows = dataset_ablation_df.loc[ + dataset_ablation_df["task_name"].eq("a43_full_realistic_binary") + & dataset_ablation_df["label"].ne("combined_with_class_weight") + ].copy() + h3_date_tag = local_date_tag() + h3_figure_paths = generate_h3_figures(dataset_ablation_df=dataset_ablation_df, date_tag=h3_date_tag) + h3_figure_records = _build_figure_records("H3", h3_date_tag, h3_figure_paths) + + lines = [ + "## Что проверяет блок H3", + "", + "- Насколько шумовые классы и более реалистичный режим корпуса усложняют задачу.", + f"- Recomputed in this run: `{'A8 dataset regimes' if h3_recomputed else 'none; reused existing validated artifact'}`.", + f"- Hypothesis figures generated: `{len(h3_figure_paths)}` (`H3_{h3_date_tag}_*.png`).", + "", + "## Основные режимы корпуса", + "", + "| Task | PR-AUC | ROC-AUC | Recall blast | Specificity |", + "|---|---:|---:|---:|---:|", + ] + for row in primary_rows.to_dict("records"): + lines.append( + f"| {_task_display_name(str(row['task_name']))} | {float(row['pr_auc']):.4f} | " + f"{float(row['roc_auc']):.4f} | {float(row['recall_blast']):.4f} | {float(row['specificity']):.4f} |" + ) + lines.extend( + [ + "", + "## Дополнительные ablation-проверки для full_realistic", + "", + "| Label | PR-AUC | Recall blast | MCC |", + "|---|---:|---:|---:|", + ] + ) + for row in extras_rows.to_dict("records"): + lines.append( + f"| {row['label']} | {float(row['pr_auc']):.4f} | {float(row['recall_blast']):.4f} | " + f"{float(row['matthews_corrcoef']):.4f} |" + ) + lines.extend( + [ + "", + "## Основные артефакты", + "", + f"- Dataset regime ablation: `{TABLES_ROOT / 'ablation_dataset_regimes.csv'}`.", + f"- Combined ablation figure (появляется после совместного запуска H2 и H3 или `all`): `{FIGURES_ROOT / 'ablation_summary.png'}`.", + ] + ) + + payload = { + "block": "h3", + "hypothesis": "noisy_regimes_make_task_harder", + "recomputed_steps": ["A8_dataset_regimes"] if h3_recomputed else [], + "ablation_dataset_regimes_csv": str(TABLES_ROOT / "ablation_dataset_regimes.csv"), + "figure_date_tag": h3_date_tag, + "hypothesis_figure_paths": h3_figure_paths, + "hypothesis_figure_records": h3_figure_records, + "primary_regime_rows": primary_rows.to_dict("records"), + "full_realistic_extra_rows": extras_rows.to_dict("records"), + } + payload.update(_write_summary("h3_noise_regimes", "H3 Noise Regimes Summary", lines, payload)) + return payload + + +def run_h4_aggregation(force: bool = False) -> dict[str, object]: + _require( + [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet", + ], + "H4 requires H1 outputs. Run `scripts/run_h1_interpretable_features.py` first.", + ) + + if force or not _all_exist(H4_REQUIRED): + a10_a11_result = run_a10_a11() + h4_recomputed = True + else: + a10_a11_result = { + "image_level_metrics_csv": str(TABLES_ROOT / "image_level_metrics.csv"), + "patient_level_ranking_metrics_csv": str(TABLES_ROOT / "patient_level_ranking_metrics.csv"), + "figure_manifest_csv": str(TABLES_ROOT / "figure_manifest.csv"), + "table_manifest_csv": str(TABLES_ROOT / "table_manifest.csv"), + } + h4_recomputed = False + + image_metrics_df = pd.read_csv(TABLES_ROOT / "image_level_metrics.csv") + patient_metrics_df = pd.read_csv(TABLES_ROOT / "patient_level_ranking_metrics.csv") + image_scores_df = pd.read_parquet(PREDICTIONS_ROOT / "image_level_scores.parquet") + patient_scores_df = pd.read_parquet(PREDICTIONS_ROOT / "patient_level_scores.parquet") + best_image = image_metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0] + best_patient = patient_metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0] + h4_date_tag = local_date_tag() + h4_figure_paths = generate_h4_figures( + image_metrics_df=image_metrics_df, + patient_metrics_df=patient_metrics_df, + image_scores_df=image_scores_df, + patient_scores_df=patient_scores_df, + date_tag=h4_date_tag, + ) + h4_figure_records = _build_figure_records("H4", h4_date_tag, h4_figure_paths) + + lines = [ + "## Что проверяет блок H4", + "", + "- Можно ли превратить object-level score в полезную оценку изображения и пациента.", + f"- Recomputed in this run: `{'A10-A11' if h4_recomputed else 'none; reused existing validated artifacts'}`.", + f"- Hypothesis figures generated: `{len(h4_figure_paths)}` (`H4_{h4_date_tag}_*.png`).", + "", + "## Лучший image-level метод", + "", + f"- Method: `{best_image['aggregation_method']}`.", + f"- PR-AUC: `{float(best_image['pr_auc']):.4f}`.", + f"- ROC-AUC: `{float(best_image['roc_auc']):.4f}`.", + f"- Recall at FPR 10%: `{float(best_image['recall_at_fpr_10pct']):.4f}`.", + "", + "## Лучший patient-level метод", + "", + f"- Method: `{best_patient['aggregation_method']}`.", + f"- PR-AUC: `{float(best_patient['pr_auc']):.4f}`.", + f"- ROC-AUC: `{float(best_patient['roc_auc']):.4f}`.", + f"- Recall at FPR 10%: `{float(best_patient['recall_at_fpr_10pct']):.4f}`.", + "", + "## Основные артефакты", + "", + f"- Image-level metrics: `{TABLES_ROOT / 'image_level_metrics.csv'}`.", + f"- Patient-level metrics: `{TABLES_ROOT / 'patient_level_ranking_metrics.csv'}`.", + f"- Image ranking figure: `{FIGURES_ROOT / 'image_ranking_recall_curve.png'}`.", + f"- Patient ranking figure: `{FIGURES_ROOT / 'patient_ranking_curve.png'}`.", + ] + + payload = { + "block": "h4", + "hypothesis": "object_scores_are_useful_after_aggregation", + "recomputed_steps": ["A10_A11"] if h4_recomputed else [], + "a10_a11": a10_a11_result, + "figure_date_tag": h4_date_tag, + "hypothesis_figure_paths": h4_figure_paths, + "hypothesis_figure_records": h4_figure_records, + "best_image_method": best_image.to_dict(), + "best_patient_method": best_patient.to_dict(), + } + payload.update(_write_summary("h4_aggregation", "H4 Aggregation Summary", lines, payload)) + return payload + + +def run_h5_deep_vs_handcrafted(force: bool = False) -> dict[str, object]: + _require( + [ + TABLES_ROOT / "baseline_model_leaderboard.csv", + FEATURES_ROOT / "features_combined.parquet", + CROPS_ROOT / "roi_manifest.parquet", + DATASET_INDEX_ROOT / "task_a43_full_realistic_binary.parquet", + ], + "H5 requires foundation outputs and H1 baseline leaderboard. Run `scripts/run_foundation.py` and `scripts/run_h1_interpretable_features.py` first.", + ) + + h5_recomputed: list[str] = [] + if force or not _all_exist( + [ + DATASET_INDEX_ROOT / "task_a43_full_realistic_modeling_workset.parquet", + CROPS_ROOT / "roi_manifest_modeling_workset.parquet", + ] + ): + workset_result = build_modeling_workset() + h5_recomputed.append("modeling_workset") + else: + workset_result = { + "task_workset_parquet": str(DATASET_INDEX_ROOT / "task_a43_full_realistic_modeling_workset.parquet"), + "roi_workset_parquet": str(CROPS_ROOT / "roi_manifest_modeling_workset.parquet"), + "qc_report_md": str(REPORTS_ROOT / "modeling_workset_qc.md"), + } + + if force or not _all_exist(H5_REQUIRED): + a9_result = run_a9_deep_baselines() + h5_recomputed.append("A9") + else: + a9_result = { + "deep_vs_handcrafted_csv": str(TABLES_ROOT / "deep_vs_handcrafted.csv"), + "deep_predictions_parquet": str(PREDICTIONS_ROOT / "object_level_deep_predictions.parquet"), + "training_curves_png": str(FIGURES_ROOT / "deep_training_curves.png"), + "a9_qc_md": str(REPORTS_ROOT / "a9_deep_qc.md"), + } + + _require(H5_REQUIRED, "H5 validation failed after preparation.") + comparison_df = pd.read_csv(TABLES_ROOT / "deep_vs_handcrafted.csv") + handcrafted_row = comparison_df.loc[comparison_df["source_type"].eq("handcrafted")].iloc[0] + best_deep_row = ( + comparison_df.loc[comparison_df["source_type"].eq("deep")] + .sort_values(["test_pr_auc", "test_roc_auc"], ascending=[False, False]) + .iloc[0] + ) + delta_pr_auc = float(best_deep_row["test_pr_auc"]) - float(handcrafted_row["test_pr_auc"]) + baseline_predictions_df = pd.read_parquet(PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet") + handcrafted_predictions_df = baseline_predictions_df.loc[ + baseline_predictions_df["run_id"].eq(handcrafted_row["reference_run_id"]) + & baseline_predictions_df["split_name"].eq("test") + ].copy() + deep_predictions_df = pd.read_parquet(PREDICTIONS_ROOT / "object_level_deep_predictions.parquet") + with (PROJECT_ROOT / "outputs" / "logs" / "cnn_training_history.json").open("r", encoding="utf-8") as handle: + training_history = json.load(handle) + h5_date_tag = local_date_tag() + h5_figure_paths = generate_h5_figures( + comparison_df=comparison_df, + handcrafted_predictions=handcrafted_predictions_df, + deep_predictions=deep_predictions_df, + training_history=training_history, + date_tag=h5_date_tag, + ) + h5_figure_records = _build_figure_records("H5", h5_date_tag, h5_figure_paths) + + lines = [ + "## Что проверяет блок H5", + "", + "- Как lightweight deep-baseline соотносится с лучшим handcrafted baseline на тех же split и данных.", + f"- Recomputed in this run: `{', '.join(h5_recomputed) if h5_recomputed else 'none; reused existing validated artifacts'}`.", + f"- Hypothesis figures generated: `{len(h5_figure_paths)}` (`H5_{h5_date_tag}_*.png`).", + "", + "## Сравнение лучшего handcrafted и лучшего deep baseline", + "", + f"- Best handcrafted: `{handcrafted_row['model_name']}` with test PR-AUC `{float(handcrafted_row['test_pr_auc']):.4f}`.", + f"- Best deep: `{best_deep_row['model_name']}` with test PR-AUC `{float(best_deep_row['test_pr_auc']):.4f}`.", + f"- PR-AUC delta (deep - handcrafted): `{delta_pr_auc:.4f}`.", + "", + "## Полная таблица сравнения", + "", + "| Model | Source | Validation PR-AUC | Test PR-AUC | Test ROC-AUC | Recall blast | MCC |", + "|---|---|---:|---:|---:|---:|---:|", + ] + for row in comparison_df.to_dict("records"): + lines.append( + f"| {row['model_name']} | {row['source_type']} | {float(row['validation_pr_auc']):.4f} | " + f"{float(row['test_pr_auc']):.4f} | {float(row['test_roc_auc']):.4f} | " + f"{float(row['test_recall_blast']):.4f} | {float(row['test_matthews_corrcoef']):.4f} |" + ) + lines.extend( + [ + "", + "## Основные артефакты", + "", + f"- Modeling workset QC: `{REPORTS_ROOT / 'modeling_workset_qc.md'}`.", + f"- Deep comparison table: `{TABLES_ROOT / 'deep_vs_handcrafted.csv'}`.", + f"- Deep predictions: `{PREDICTIONS_ROOT / 'object_level_deep_predictions.parquet'}`.", + f"- Training curves: `{FIGURES_ROOT / 'deep_training_curves.png'}`.", + ] + ) + + payload = { + "block": "h5", + "hypothesis": "deep_vs_handcrafted", + "recomputed_steps": h5_recomputed, + "workset": workset_result, + "a9": a9_result, + "figure_date_tag": h5_date_tag, + "hypothesis_figure_paths": h5_figure_paths, + "hypothesis_figure_records": h5_figure_records, + "handcrafted_row": handcrafted_row.to_dict(), + "best_deep_row": best_deep_row.to_dict(), + "delta_pr_auc": delta_pr_auc, + } + payload.update(_write_summary("h5_deep_vs_handcrafted", "H5 Deep vs Handcrafted Summary", lines, payload)) + return payload + + +def run_subject_figures_block(force: bool = False, variants: int = 5) -> dict[str, object]: + _require( + [ + PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet", + PREDICTIONS_ROOT / "object_level_deep_predictions.parquet", + PREDICTIONS_ROOT / "image_level_scores.parquet", + PREDICTIONS_ROOT / "patient_level_scores.parquet", + PREDICTIONS_ROOT / "h2_mask_usage_predictions.parquet", + ], + "Subject-figure rendering requires completed H1-H5 outputs.", + ) + if force or not _all_exist(SUBJECT_FIGURE_REQUIRED): + subject_payload = run_subject_figures(variants=variants, overwrite=True) + append_execution_log(subject_payload) + recomputed = True + else: + with SUBJECT_FIGURES_SUMMARY_MD.open("r", encoding="utf-8") as handle: + summary_preview = handle.read() + subject_payload = { + "variants_per_figure": variants, + "cases_csv": str(SUBJECT_FIGURE_CASES_PATH), + "figure_manifest_csv": str(SUBJECT_FIGURE_MANIFEST_PATH), + "summary_md": str(SUBJECT_FIGURES_SUMMARY_MD), + "summary_preview": summary_preview[:400], + } + recomputed = False + + manifest_df = pd.read_csv(SUBJECT_FIGURE_MANIFEST_PATH) + counts_by_figure = manifest_df.groupby("figure_id").size().to_dict() + counts_by_hypothesis = manifest_df.groupby("hypothesis").size().to_dict() + lines = [ + "## Что делает блок subject figures", + "", + "- Генерирует предметные иллюстрации для A01 и A02 на основе реальных клеток, ROI, масок и model outputs.", + f"- Recomputed in this run: `{'yes' if recomputed else 'no; reused existing subject figures'}`.", + f"- Variants per required figure: `{variants}`.", + "", + "## Counts by figure", + "", + ] + for figure_id in sorted(counts_by_figure): + lines.append(f"- `{figure_id}`: `{counts_by_figure[figure_id]}` figure files.") + lines.extend(["", "## Counts by hypothesis", ""]) + for hypothesis in sorted(counts_by_hypothesis): + lines.append(f"- `{hypothesis}`: `{counts_by_hypothesis[hypothesis]}` figure files.") + lines.extend( + [ + "", + "## Main artifacts", + "", + f"- Cases CSV: `{SUBJECT_FIGURE_CASES_PATH}`.", + f"- Figure manifest CSV: `{SUBJECT_FIGURE_MANIFEST_PATH}`.", + f"- Summary MD: `{SUBJECT_FIGURES_SUMMARY_MD}`.", + f"- Execution log: `{EXPERIMENT_EXECUTION_LOG_PATH}`.", + ] + ) + payload = { + "block": "subject_figures", + "recomputed": recomputed, + "variants_per_figure": variants, + "counts_by_figure": counts_by_figure, + "counts_by_hypothesis": counts_by_hypothesis, + **subject_payload, + } + payload.update(_write_summary("subject_figures", "Subject Figures Summary", lines, payload)) + return payload + + +def run_all_hypotheses( + data_root: str | Path | None = None, + masks_root: str | Path | None = None, + config_path: str | Path | None = None, + force: bool = False, + with_subject_figures: bool = False, + subject_figure_variants: int = 5, +) -> dict[str, object]: + foundation = run_foundation(data_root=data_root, masks_root=masks_root, config_path=config_path, force=force) + h1 = run_h1_interpretable_features(force=force) + h2 = run_h2_mask_effect(force=force) + h3 = run_h3_noise_regimes(force=force) + if (TABLES_ROOT / "ablation_mask_usage.csv").exists() and (TABLES_ROOT / "ablation_dataset_regimes.csv").exists(): + save_a8_summary_plot( + pd.read_csv(TABLES_ROOT / "ablation_mask_usage.csv"), + pd.read_csv(TABLES_ROOT / "ablation_dataset_regimes.csv"), + ) + h4 = run_h4_aggregation(force=force) + h5 = run_h5_deep_vs_handcrafted(force=force) + subject_figures = ( + run_subject_figures_block(force=force, variants=subject_figure_variants) if with_subject_figures else None + ) + hypothesis_figure_manifest_csv = _write_hypothesis_figure_manifest( + h1["hypothesis_figure_records"], + h2["hypothesis_figure_records"], + h3["hypothesis_figure_records"], + h4["hypothesis_figure_records"], + h5["hypothesis_figure_records"], + ) + + lines = [ + "## Выполненные блоки", + "", + f"- Foundation: `{foundation['summary_md']}`.", + f"- H1: `{h1['summary_md']}`.", + f"- H2: `{h2['summary_md']}`.", + f"- H3: `{h3['summary_md']}`.", + f"- H4: `{h4['summary_md']}`.", + f"- H5: `{h5['summary_md']}`.", + "", + "## Сводные артефакты полного прогона", + "", + f"- Combined ablation figure: `{FIGURES_ROOT / 'ablation_summary.png'}`.", + f"- Hypothesis figure manifest: `{hypothesis_figure_manifest_csv}`.", + f"- Subject figure manifest: `{SUBJECT_FIGURE_MANIFEST_PATH}`." if with_subject_figures else "- Subject figures were not requested in this run.", + f"- Figure manifest: `{TABLES_ROOT / 'figure_manifest.csv'}`.", + f"- Table manifest: `{TABLES_ROOT / 'table_manifest.csv'}`.", + f"- Full detailed report: `{PROJECT_ROOT / 'reports' / 'E01_detailed_report.md'}`.", + ] + + payload = { + "block": "all", + "foundation": foundation, + "h1": h1, + "h2": h2, + "h3": h3, + "h4": h4, + "h5": h5, + "subject_figures": subject_figures, + "hypothesis_figure_manifest_csv": hypothesis_figure_manifest_csv, + } + payload.update(_write_summary("run_all_hypotheses", "Run-All Hypotheses Summary", lines, payload)) + return payload + + +def print_payload(payload: dict[str, object]) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/scripts/run_a1_to_a4.py b/scripts/run_a1_to_a4.py new file mode 100644 index 0000000..ffdae16 --- /dev/null +++ b/scripts/run_a1_to_a4.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from dataset.build_patient_summary import build_patient_summary +from dataset.build_subsets import build_subsets +from dataset.build_tasks import build_tasks +from dataset.make_group_splits import make_group_splits + + +def main() -> None: + subset_result = build_subsets( + object_index_path=PROJECT_ROOT / "outputs" / "dataset_index" / "object_index.parquet", + image_index_path=PROJECT_ROOT / "outputs" / "dataset_index" / "image_index.parquet", + ) + patient_summary_path = build_patient_summary( + patient_inventory_path=PROJECT_ROOT / "outputs" / "dataset_index" / "patient_inventory.csv", + image_index_path=PROJECT_ROOT / "outputs" / "dataset_index" / "image_index.parquet", + object_index_path=PROJECT_ROOT / "outputs" / "dataset_index" / "object_index.parquet", + ) + split_result = make_group_splits( + patient_summary_path=patient_summary_path, + object_index_path=PROJECT_ROOT / "outputs" / "dataset_index" / "object_index.parquet", + random_state=42, + ) + task_result = build_tasks(PROJECT_ROOT / "outputs" / "splits" / "split_v1.json") + + print( + json.dumps( + { + "a1": subset_result, + "a2": {"patient_summary_csv": str(patient_summary_path), **split_result}, + "a4": task_result, + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_a3_to_a5.py b/scripts/run_a3_to_a5.py new file mode 100644 index 0000000..b99430b --- /dev/null +++ b/scripts/run_a3_to_a5.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from features.feature_registry import extract_features +from preprocessing.extract_bbox_crops import extract_bbox_crops +from preprocessing.extract_mask_crops import extract_mask_crops + + +def main() -> None: + bbox_manifest_df = extract_bbox_crops(PROJECT_ROOT / "outputs" / "dataset_index" / "subset_full_realistic.parquet") + roi_manifest_df = extract_mask_crops(PROJECT_ROOT / "outputs" / "crops" / "bbox_roi_manifest.parquet") + feature_result = extract_features(PROJECT_ROOT / "outputs" / "crops" / "roi_manifest.parquet") + print( + json.dumps( + { + "a3": { + "bbox_manifest_rows": len(bbox_manifest_df), + "roi_manifest_rows": len(roi_manifest_df), + "roi_manifest_path": str(PROJECT_ROOT / "outputs" / "crops" / "roi_manifest.parquet"), + }, + "a5": feature_result, + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_a6_to_a11.py b/scripts/run_a6_to_a11.py new file mode 100644 index 0000000..de78611 --- /dev/null +++ b/scripts/run_a6_to_a11.py @@ -0,0 +1,737 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import joblib +import numpy as np +import pandas as pd +from sklearn.inspection import permutation_importance + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from evaluation.aggregate_scores import aggregate_object_scores, compute_aggregate_metrics +from evaluation.metrics import bootstrap_metric_interval, compute_binary_metrics, optimize_threshold +from evaluation.visualization import ( + save_bar_plot, + save_confusion_matrix, + save_example_rois, + save_pipeline_overview, + save_pr_roc_summary, + save_ranking_curve, +) +from models.baseline_pipeline import ( + FEATURE_GROUPS, + KEY_COLUMNS, + build_model, + feature_columns_by_group, + fit_predict_probability, + merge_task_with_features, +) +from utils.project import CHECKPOINTS_ROOT, CROPS_ROOT, FEATURES_ROOT, FIGURES_ROOT, PREDICTIONS_ROOT, REPORTS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown + + +TASK_FILES = { + "a41_clean_binary": PROJECT_ROOT / "outputs" / "dataset_index" / "task_a41_clean_binary.parquet", + "a42_strict_binary": PROJECT_ROOT / "outputs" / "dataset_index" / "task_a42_strict_binary.parquet", + "a43_full_realistic_binary": PROJECT_ROOT / "outputs" / "dataset_index" / "task_a43_full_realistic_binary.parquet", +} +MODEL_NAMES = [ + "logistic_regression", + "linear_svm", + "rbf_svm", + "random_forest", + "xgboost", +] + + +def _pick_feature_path(base_name: str) -> Path: + candidates = [ + FEATURES_ROOT / f"{base_name}.parquet", + FEATURES_ROOT / f"{base_name}_fast.parquet", + FEATURES_ROOT / f"{base_name}_lite.parquet", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + raise FileNotFoundError(f"None of the candidate feature files exist for {base_name}: {candidates}") + + +def _load_required_artifacts() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, dict[str, pd.DataFrame]]: + features_combined = pd.read_parquet(_pick_feature_path("features_combined")) + features_bbox = pd.read_parquet(_pick_feature_path("features_bbox")) + features_mask = pd.read_parquet(_pick_feature_path("features_mask")) + tasks = {task_name: pd.read_parquet(path) for task_name, path in TASK_FILES.items()} + return features_combined, features_bbox, features_mask, tasks + + +def _split_frame(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + train_df = df.loc[df["split_name"] == "train"].copy() + val_df = df.loc[df["split_name"] == "val"].copy() + test_df = df.loc[df["split_name"] == "test"].copy() + if min(len(train_df), len(val_df), len(test_df)) == 0: + raise ValueError("One of the splits is empty.") + return train_df, val_df, test_df + + +def _cap_frame(frame: pd.DataFrame, max_rows: int, random_state: int) -> pd.DataFrame: + if len(frame) <= max_rows: + return frame + sampled_groups: list[pd.DataFrame] = [] + remaining = max_rows + groups = list(frame.groupby("target_binary", sort=True)) + for index, (_, group) in enumerate(groups): + if index == len(groups) - 1: + n_take = min(len(group), remaining) + else: + n_take = max(1, int(round(max_rows * len(group) / len(frame)))) + n_take = min(len(group), n_take) + remaining -= n_take + sampled_groups.append(group.sample(n=n_take, random_state=random_state)) + return pd.concat(sampled_groups, ignore_index=True) + + +def _select_best_row(leaderboard_df: pd.DataFrame, task_name: str | None = None) -> pd.Series: + frame = leaderboard_df.copy() + if task_name is not None: + frame = frame.loc[frame["task_name"] == task_name].copy() + if len(frame) == 0: + raise ValueError("Leaderboard is empty for selection.") + return frame.sort_values( + ["validation_pr_auc", "test_pr_auc", "test_recall_blast"], + ascending=[False, False, False], + ).iloc[0] + + +def run_a6() -> dict[str, str]: + ensure_output_layout() + features_combined, features_bbox, features_mask, tasks = _load_required_artifacts() + feature_groups = feature_columns_by_group(features_combined) + + metrics_rows: list[dict[str, object]] = [] + prediction_rows: list[dict[str, object]] = [] + checkpoint_rows: list[dict[str, object]] = [] + + for task_name, task_df in tasks.items(): + merged_df = merge_task_with_features(task_df, features_combined) + train_df, val_df, test_df = _split_frame(merged_df) + train_df = _cap_frame(train_df, max_rows=20000, random_state=42) + val_df = _cap_frame(val_df, max_rows=5000, random_state=43) + scale_pos_weight = float((train_df["target_binary"].eq(0).sum()) / max(train_df["target_binary"].eq(1).sum(), 1)) + + for model_name in MODEL_NAMES: + for feature_group_name, feature_columns in feature_groups.items(): + X_train = train_df[feature_columns] + y_train = train_df["target_binary"].astype(int) + X_val = val_df[feature_columns] + y_val = val_df["target_binary"].astype(int) + X_test = test_df[feature_columns] + y_test = test_df["target_binary"].astype(int) + + pipeline, spec = build_model(model_name, scale_pos_weight=scale_pos_weight, use_class_weight=True) + val_score = fit_predict_probability(pipeline, X_train, y_train, X_val) + threshold = optimize_threshold(y_val.to_numpy(), val_score) + test_score = np.asarray(pipeline.predict_proba(X_test)[:, 1], dtype=float) if hasattr(pipeline, "predict_proba") else fit_predict_probability(pipeline, X_train, y_train, X_test) + + val_metrics = compute_binary_metrics(y_val.to_numpy(), val_score, threshold) + test_metrics = compute_binary_metrics(y_test.to_numpy(), test_score, threshold) + ci = bootstrap_metric_interval(y_test.to_numpy(), test_score, threshold, n_bootstrap=60, random_state=42) + + run_id = f"{task_name}__{model_name}__{feature_group_name}" + checkpoint_path = CHECKPOINTS_ROOT / f"{run_id}.joblib" + joblib.dump( + { + "pipeline": pipeline, + "feature_columns": feature_columns, + "threshold": threshold, + "task_name": task_name, + "model_name": model_name, + "feature_group": feature_group_name, + }, + checkpoint_path, + ) + checkpoint_rows.append({"run_id": run_id, "checkpoint_path": str(checkpoint_path)}) + + y_pred = (test_score >= threshold).astype(int) + split_rows = test_df[KEY_COLUMNS + ["split_name", "target_binary"]].copy() + split_rows["run_id"] = run_id + split_rows["task_name"] = task_name + split_rows["model_name"] = model_name + split_rows["implementation"] = spec.implementation + split_rows["feature_group"] = feature_group_name + split_rows["threshold"] = threshold + split_rows["split_name"] = "test" + split_rows["y_true"] = y_test.to_numpy() + split_rows["y_score"] = test_score + split_rows["y_pred"] = y_pred + prediction_rows.extend(split_rows.to_dict("records")) + + metrics_rows.append( + { + "run_id": run_id, + "task_name": task_name, + "model_name": model_name, + "implementation": spec.implementation, + "feature_group": feature_group_name, + "n_features": int(len(feature_columns)), + "threshold": threshold, + "validation_pr_auc": val_metrics.pr_auc, + "validation_roc_auc": val_metrics.roc_auc, + "validation_recall_blast": val_metrics.recall_blast, + "test_pr_auc": test_metrics.pr_auc, + "test_pr_auc_ci_low": ci["pr_auc"][0], + "test_pr_auc_ci_high": ci["pr_auc"][1], + "test_roc_auc": test_metrics.roc_auc, + "test_roc_auc_ci_low": ci["roc_auc"][0], + "test_roc_auc_ci_high": ci["roc_auc"][1], + "test_balanced_accuracy": test_metrics.balanced_accuracy, + "test_balanced_accuracy_ci_low": ci["balanced_accuracy"][0], + "test_balanced_accuracy_ci_high": ci["balanced_accuracy"][1], + "test_macro_f1": test_metrics.macro_f1, + "test_macro_f1_ci_low": ci["macro_f1"][0], + "test_macro_f1_ci_high": ci["macro_f1"][1], + "test_recall_blast": test_metrics.recall_blast, + "test_recall_blast_ci_low": ci["recall_blast"][0], + "test_recall_blast_ci_high": ci["recall_blast"][1], + "test_specificity": test_metrics.specificity, + "test_specificity_ci_low": ci["specificity"][0], + "test_specificity_ci_high": ci["specificity"][1], + "test_matthews_corrcoef": test_metrics.matthews_corrcoef, + "test_matthews_corrcoef_ci_low": ci["matthews_corrcoef"][0], + "test_matthews_corrcoef_ci_high": ci["matthews_corrcoef"][1], + "test_brier_score": test_metrics.brier_score, + "test_brier_score_ci_low": ci["brier_score"][0], + "test_brier_score_ci_high": ci["brier_score"][1], + "test_expected_calibration_error": test_metrics.expected_calibration_error, + "test_expected_calibration_error_ci_low": ci["expected_calibration_error"][0], + "test_expected_calibration_error_ci_high": ci["expected_calibration_error"][1], + } + ) + + metrics_df = pd.DataFrame(metrics_rows).sort_values( + ["task_name", "test_pr_auc", "test_recall_blast"], + ascending=[True, False, False], + ) + predictions_df = pd.DataFrame(prediction_rows) + + if len(metrics_df) != len(TASK_FILES) * len(MODEL_NAMES) * len(FEATURE_GROUPS): + raise ValueError("A6 did not produce the expected number of model runs.") + if predictions_df.empty: + raise ValueError("A6 predictions are empty.") + + object_metrics_path = TABLES_ROOT / "object_level_metrics_baselines.csv" + leaderboard_path = TABLES_ROOT / "baseline_model_leaderboard.csv" + feature_ablation_path = TABLES_ROOT / "feature_group_ablation.csv" + predictions_path = PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet" + checkpoint_manifest_path = TABLES_ROOT / "checkpoint_manifest.csv" + + metrics_df.to_csv(object_metrics_path, index=False) + metrics_df.to_csv(leaderboard_path, index=False) + metrics_df.to_csv(feature_ablation_path, index=False) + predictions_df.to_parquet(predictions_path, index=False) + pd.DataFrame(checkpoint_rows).to_csv(checkpoint_manifest_path, index=False) + + save_pr_roc_summary(predictions_df, FIGURES_ROOT / "pr_curves_baselines.png", FIGURES_ROOT / "roc_curves_baselines.png") + best_full_row = _select_best_row(metrics_df, task_name="a43_full_realistic_binary") + best_test_predictions = predictions_df.loc[ + (predictions_df["run_id"] == best_full_row["run_id"]) & (predictions_df["split_name"] == "test") + ].copy() + save_confusion_matrix( + best_test_predictions["y_true"].to_numpy(), + best_test_predictions["y_pred"].to_numpy(), + FIGURES_ROOT / "confusion_matrix_best_baseline.png", + title=f"Best baseline: {best_full_row['model_name']} | {best_full_row['feature_group']}", + ) + + report_lines = [ + "# A6 Baseline QC", + "", + f"- Total baseline runs: `{len(metrics_df)}`", + f"- Prediction rows saved: `{len(predictions_df)}`", + f"- Leaderboard path: `{leaderboard_path}`", + f"- Best full-realistic run: `{best_full_row['run_id']}`", + ] + write_markdown(REPORTS_ROOT / "a6_baseline_qc.md", report_lines) + + return { + "object_level_metrics_csv": str(object_metrics_path), + "feature_group_ablation_csv": str(feature_ablation_path), + "object_level_predictions_parquet": str(predictions_path), + "baseline_model_leaderboard_csv": str(leaderboard_path), + "pr_curves_png": str(FIGURES_ROOT / "pr_curves_baselines.png"), + "roc_curves_png": str(FIGURES_ROOT / "roc_curves_baselines.png"), + "confusion_matrix_png": str(FIGURES_ROOT / "confusion_matrix_best_baseline.png"), + "a6_qc_report_md": str(REPORTS_ROOT / "a6_baseline_qc.md"), + } + + +def run_a7(leaderboard_df: pd.DataFrame, full_task_df: pd.DataFrame, features_combined: pd.DataFrame) -> None: + best_row = _select_best_row(leaderboard_df, task_name="a43_full_realistic_binary") + feature_group = best_row["feature_group"] + feature_columns = feature_columns_by_group(features_combined)[feature_group] + + merged_df = merge_task_with_features(full_task_df, features_combined) + train_df, val_df, test_df = _split_frame(merged_df) + train_df = _cap_frame(train_df, max_rows=20000, random_state=42) + val_df = _cap_frame(val_df, max_rows=5000, random_state=43) + if len(train_df) > 10000: + train_df = _cap_frame(train_df, max_rows=10000, random_state=44) + scale_pos_weight = float((train_df["target_binary"].eq(0).sum()) / max(train_df["target_binary"].eq(1).sum(), 1)) + pipeline, spec = build_model(str(best_row["model_name"]), scale_pos_weight=scale_pos_weight, use_class_weight=True) + X_train = train_df[feature_columns] + y_train = train_df["target_binary"].astype(int) + if len(test_df) > 2000: + test_df = test_df.sample(n=2000, random_state=42) + X_test = test_df[feature_columns] + y_test = test_df["target_binary"].astype(int) + pipeline.fit(X_train, y_train) + + importance = permutation_importance( + pipeline, + X_test, + y_test, + n_repeats=3, + random_state=42, + n_jobs=1, + scoring="average_precision", + ) + stability_rows: list[dict[str, object]] = [] + for repeat_idx in range(3): + sample_df = train_df.sample(frac=0.6, replace=True, random_state=42 + repeat_idx) + sample_pipeline, _ = build_model(str(best_row["model_name"]), scale_pos_weight=scale_pos_weight, use_class_weight=True) + sample_pipeline.fit(sample_df[feature_columns], sample_df["target_binary"].astype(int)) + sample_importance = permutation_importance( + sample_pipeline, + X_test, + y_test, + n_repeats=2, + random_state=100 + repeat_idx, + n_jobs=1, + scoring="average_precision", + ) + ranking = np.argsort(-sample_importance.importances_mean)[:20] + for rank_position, index in enumerate(ranking, start=1): + stability_rows.append( + { + "repeat_id": repeat_idx, + "feature_name": feature_columns[index], + "importance_mean": float(sample_importance.importances_mean[index]), + "importance_std": float(sample_importance.importances_std[index]), + "rank_position": rank_position, + } + ) + + stability_df = pd.DataFrame(stability_rows) + top_features_df = ( + stability_df.groupby("feature_name") + .agg( + top20_frequency=("feature_name", "size"), + mean_importance=("importance_mean", "mean"), + best_rank=("rank_position", "min"), + ) + .reset_index() + .sort_values(["top20_frequency", "mean_importance"], ascending=[False, False]) + ) + top_features_path = TABLES_ROOT / "top_features_stability.csv" + top_features_df.to_csv(top_features_path, index=False) + + import matplotlib.pyplot as plt + + top_plot_df = top_features_df.head(20).iloc[::-1] + fig, ax = plt.subplots(figsize=(10, 8)) + ax.barh(top_plot_df["feature_name"], top_plot_df["top20_frequency"], color="#4c72b0") + ax.set_title("Feature stability across repeated importance runs") + ax.set_xlabel("Top-20 frequency") + fig.tight_layout() + fig.savefig(FIGURES_ROOT / "feature_importance_stability.png", dpi=180) + plt.close(fig) + + shap_like_df = pd.DataFrame( + { + "feature_name": feature_columns, + "importance_mean": importance.importances_mean, + "importance_std": importance.importances_std, + } + ).sort_values("importance_mean", ascending=False) + top_shap_df = shap_like_df.head(20).iloc[::-1] + fig, ax = plt.subplots(figsize=(10, 8)) + ax.barh(top_shap_df["feature_name"], top_shap_df["importance_mean"], xerr=top_shap_df["importance_std"], color="#55a868") + ax.set_title("Top permutation-importance features for the best model") + ax.set_xlabel("Importance") + fig.tight_layout() + fig.savefig(FIGURES_ROOT / "shap_summary_top_model.png", dpi=180) + plt.close(fig) + + notes_lines = [ + "# Feature Interpretation Notes", + "", + f"- Selected model: `{best_row['run_id']}`", + f"- Implementation: `{spec.implementation}`", + "", + "## Stable top features", + "", + ] + for _, row in top_features_df.head(15).iterrows(): + notes_lines.append( + f"- `{row['feature_name']}`: frequency `{int(row['top20_frequency'])}`, mean importance `{row['mean_importance']:.6f}`." + ) + write_markdown(REPORTS_ROOT / "feature_interpretation_notes.md", notes_lines) + + +def _evaluate_single_configuration( + task_name: str, + task_df: pd.DataFrame, + feature_df: pd.DataFrame, + feature_columns: list[str], + model_name: str, + use_class_weight: bool, + label: str, + return_prediction_df: bool = False, +) -> dict[str, object] | tuple[dict[str, object], pd.DataFrame]: + merged_df = merge_task_with_features(task_df, feature_df) + train_df, val_df, test_df = _split_frame(merged_df) + train_df = _cap_frame(train_df, max_rows=20000, random_state=42) + val_df = _cap_frame(val_df, max_rows=5000, random_state=43) + scale_pos_weight = float((train_df["target_binary"].eq(0).sum()) / max(train_df["target_binary"].eq(1).sum(), 1)) + pipeline, spec = build_model(model_name, scale_pos_weight=scale_pos_weight, use_class_weight=use_class_weight) + val_score = fit_predict_probability(pipeline, train_df[feature_columns], train_df["target_binary"], val_df[feature_columns]) + threshold = optimize_threshold(val_df["target_binary"].to_numpy(), val_score) + test_score = np.asarray(pipeline.predict_proba(test_df[feature_columns])[:, 1], dtype=float) if hasattr(pipeline, "predict_proba") else fit_predict_probability(pipeline, train_df[feature_columns], train_df["target_binary"], test_df[feature_columns]) + metrics = compute_binary_metrics(test_df["target_binary"].to_numpy(), test_score, threshold) + result = { + "task_name": task_name, + "label": label, + "model_name": model_name, + "implementation": spec.implementation, + "use_class_weight": use_class_weight, + "n_rows": int(len(test_df)), + "n_features": int(len(feature_columns)), + "pr_auc": metrics.pr_auc, + "roc_auc": metrics.roc_auc, + "balanced_accuracy": metrics.balanced_accuracy, + "macro_f1": metrics.macro_f1, + "recall_blast": metrics.recall_blast, + "specificity": metrics.specificity, + "matthews_corrcoef": metrics.matthews_corrcoef, + "brier_score": metrics.brier_score, + "expected_calibration_error": metrics.expected_calibration_error, + } + if not return_prediction_df: + return result + + prediction_df = test_df[KEY_COLUMNS + ["split_name", "target_binary"]].copy() + prediction_df["task_name"] = task_name + prediction_df["label"] = label + prediction_df["model_name"] = model_name + prediction_df["implementation"] = spec.implementation + prediction_df["use_class_weight"] = use_class_weight + prediction_df["threshold"] = float(threshold) + prediction_df["y_true"] = test_df["target_binary"].astype(int).to_numpy() + prediction_df["y_score"] = test_score.astype(float) + prediction_df["y_pred"] = (test_score >= threshold).astype(int) + return result, prediction_df + + +def run_a8_mask_usage( + leaderboard_df: pd.DataFrame, + tasks: dict[str, pd.DataFrame], + features_combined: pd.DataFrame, + features_bbox: pd.DataFrame, + features_mask: pd.DataFrame, +) -> pd.DataFrame: + best_full_row = _select_best_row(leaderboard_df, task_name="a43_full_realistic_binary") + best_model_name = str(best_full_row["model_name"]) + combined_full_columns = feature_columns_by_group(features_combined)["full"] + bbox_full_columns = [column for column in features_bbox.columns if column not in KEY_COLUMNS] + mask_full_columns = [column for column in features_mask.columns if column not in KEY_COLUMNS] + + mask_ablation_rows: list[dict[str, object]] = [] + mask_prediction_frames: list[pd.DataFrame] = [] + for task_name, task_df in tasks.items(): + mask_keys = set( + tuple(row) + for row in features_mask[KEY_COLUMNS[:3]].drop_duplicates().itertuples(index=False, name=None) + ) + task_mask_ready = task_df.loc[ + task_df[KEY_COLUMNS[:3]].apply(tuple, axis=1).isin(mask_keys) + ].copy() + for label, feature_df, feature_columns in [ + ("bbox_only", features_bbox, bbox_full_columns), + ("mask_only", features_mask, mask_full_columns), + ("bbox_plus_mask", features_combined, combined_full_columns), + ]: + metrics_row, prediction_df = _evaluate_single_configuration( + task_name=task_name, + task_df=task_mask_ready, + feature_df=feature_df, + feature_columns=feature_columns, + model_name=best_model_name, + use_class_weight=True, + label=label, + return_prediction_df=True, + ) + mask_ablation_rows.append(metrics_row) + mask_prediction_frames.append(prediction_df) + mask_ablation_df = pd.DataFrame(mask_ablation_rows) + mask_ablation_df.to_csv(TABLES_ROOT / "ablation_mask_usage.csv", index=False) + if mask_prediction_frames: + pd.concat(mask_prediction_frames, ignore_index=True).to_parquet( + PREDICTIONS_ROOT / "h2_mask_usage_predictions.parquet", + index=False, + ) + return mask_ablation_df + + +def run_a8_dataset_regimes( + leaderboard_df: pd.DataFrame, + tasks: dict[str, pd.DataFrame], + features_combined: pd.DataFrame, +) -> pd.DataFrame: + best_full_row = _select_best_row(leaderboard_df, task_name="a43_full_realistic_binary") + best_model_name = str(best_full_row["model_name"]) + combined_full_columns = feature_columns_by_group(features_combined)["full"] + + dataset_ablation_rows: list[dict[str, object]] = [] + for task_name, task_df in tasks.items(): + dataset_ablation_rows.append( + _evaluate_single_configuration( + task_name=task_name, + task_df=task_df, + feature_df=features_combined, + feature_columns=combined_full_columns, + model_name=best_model_name, + use_class_weight=True, + label="combined_with_class_weight", + ) + ) + dataset_ablation_rows.append( + _evaluate_single_configuration( + task_name="a43_full_realistic_binary", + task_df=tasks["a43_full_realistic_binary"], + feature_df=features_combined, + feature_columns=combined_full_columns, + model_name=best_model_name, + use_class_weight=False, + label="combined_without_class_weight", + ) + ) + top_features_df = pd.read_csv(TABLES_ROOT / "top_features_stability.csv").head(30) + selected_columns = [column for column in top_features_df["feature_name"].tolist() if column in combined_full_columns] + if not selected_columns: + selected_columns = combined_full_columns[: min(30, len(combined_full_columns))] + dataset_ablation_rows.append( + _evaluate_single_configuration( + task_name="a43_full_realistic_binary", + task_df=tasks["a43_full_realistic_binary"], + feature_df=features_combined, + feature_columns=selected_columns, + model_name=best_model_name, + use_class_weight=True, + label="combined_top30_features", + ) + ) + dataset_ablation_df = pd.DataFrame(dataset_ablation_rows) + dataset_ablation_df.to_csv(TABLES_ROOT / "ablation_dataset_regimes.csv", index=False) + return dataset_ablation_df + + +def save_a8_summary_plot(mask_ablation_df: pd.DataFrame, dataset_ablation_df: pd.DataFrame) -> str: + ablation_plot_df = pd.concat( + [ + mask_ablation_df.assign(ablation_family="mask_usage"), + dataset_ablation_df.assign(ablation_family="dataset_regime"), + ], + ignore_index=True, + ) + save_bar_plot( + ablation_plot_df[["label", "pr_auc"]].drop_duplicates(), + x="label", + y="pr_auc", + hue=None, + path=FIGURES_ROOT / "ablation_summary.png", + title="A8 ablation summary (PR-AUC)", + rotation=35, + ) + return str(FIGURES_ROOT / "ablation_summary.png") + + +def run_a8( + leaderboard_df: pd.DataFrame, + tasks: dict[str, pd.DataFrame], + features_combined: pd.DataFrame, + features_bbox: pd.DataFrame, + features_mask: pd.DataFrame, +) -> dict[str, str]: + mask_ablation_df = run_a8_mask_usage( + leaderboard_df=leaderboard_df, + tasks=tasks, + features_combined=features_combined, + features_bbox=features_bbox, + features_mask=features_mask, + ) + dataset_ablation_df = run_a8_dataset_regimes( + leaderboard_df=leaderboard_df, + tasks=tasks, + features_combined=features_combined, + ) + plot_path = save_a8_summary_plot(mask_ablation_df, dataset_ablation_df) + + return { + "ablation_mask_usage_csv": str(TABLES_ROOT / "ablation_mask_usage.csv"), + "ablation_dataset_regimes_csv": str(TABLES_ROOT / "ablation_dataset_regimes.csv"), + "ablation_summary_png": plot_path, + } + + +def run_a6_a8() -> dict[str, dict[str, str]]: + result_a6 = run_a6() + features_combined, features_bbox, features_mask, tasks = _load_required_artifacts() + leaderboard_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + run_a7(leaderboard_df, tasks["a43_full_realistic_binary"], features_combined) + result_a8 = run_a8(leaderboard_df, tasks, features_combined, features_bbox, features_mask) + return {"a6": result_a6, "a8": result_a8} + + +def run_a10_a11() -> dict[str, str]: + leaderboard_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + predictions_df = pd.read_parquet(PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet") + best_full_row = _select_best_row(leaderboard_df, task_name="a43_full_realistic_binary") + best_predictions = predictions_df.loc[ + (predictions_df["run_id"] == best_full_row["run_id"]) & (predictions_df["split_name"] == "test") + ].copy() + if best_predictions.empty: + raise ValueError("No object-level predictions found for A10.") + + image_score_df = aggregate_object_scores( + best_predictions.assign(image_contains_blast=best_predictions["y_true"]), + threshold=float(best_full_row["threshold"]), + group_columns=["patient_id", "image_id"], + target_column="image_contains_blast", + ) + patient_score_df = aggregate_object_scores( + best_predictions.assign(patient_contains_blast=best_predictions["y_true"]), + threshold=float(best_full_row["threshold"]), + group_columns=["patient_id"], + target_column="patient_contains_blast", + ) + image_metrics_df = compute_aggregate_metrics(image_score_df, target_column="image_contains_blast") + patient_metrics_df = compute_aggregate_metrics(patient_score_df, target_column="patient_contains_blast") + + image_scores_path = PREDICTIONS_ROOT / "image_level_scores.parquet" + patient_scores_path = PREDICTIONS_ROOT / "patient_level_scores.parquet" + image_metrics_path = TABLES_ROOT / "image_level_metrics.csv" + patient_metrics_path = TABLES_ROOT / "patient_level_ranking_metrics.csv" + image_score_df.to_parquet(image_scores_path, index=False) + patient_score_df.to_parquet(patient_scores_path, index=False) + image_metrics_df.to_csv(image_metrics_path, index=False) + patient_metrics_df.to_csv(patient_metrics_path, index=False) + + save_ranking_curve( + image_score_df, + target_column="image_contains_blast", + path=FIGURES_ROOT / "image_ranking_recall_curve.png", + title="Image-level ranking recall", + ) + save_ranking_curve( + patient_score_df, + target_column="patient_contains_blast", + path=FIGURES_ROOT / "patient_ranking_curve.png", + title="Patient-level ranking recall", + ) + + subset_summary_df = pd.read_csv(TABLES_ROOT / "subset_summary.csv").assign(section="subset_summary") + split_summary_df = pd.read_csv(TABLES_ROOT / "split_summary.csv").assign(section="split_summary") + task_prevalence_df = pd.read_csv(TABLES_ROOT / "task_prevalence_summary.csv").assign(section="task_prevalence") + dataset_summary_df = pd.concat( + [ + subset_summary_df.rename(columns={"subset_name": "name"}), + split_summary_df.rename(columns={"split_name": "name"}), + task_prevalence_df.rename(columns={"task_name": "name"}), + ], + ignore_index=True, + sort=False, + ) + dataset_summary_df.to_csv(TABLES_ROOT / "dataset_summary.csv", index=False) + + save_pipeline_overview(FIGURES_ROOT / "pipeline_overview.png") + roi_manifest = pd.read_parquet(CROPS_ROOT / "roi_manifest.parquet") + example_rows = roi_manifest.loc[roi_manifest["mask_crop_path"].notna()].head(4) + save_example_rois( + [Path(path) for path in example_rows["bbox_crop_path"].tolist()], + [Path(path) for path in example_rows["mask_crop_path"].tolist()], + FIGURES_ROOT / "example_rois.png", + ) + + mask_plot_df = subset_summary_df[["subset_name", "mask_ready_ratio"]].copy() + save_bar_plot( + mask_plot_df, + x="subset_name", + y="mask_ready_ratio", + hue=None, + path=FIGURES_ROOT / "mask_coverage.png", + title="Mask coverage by subset", + ) + + figure_manifest = pd.DataFrame( + [ + {"figure_name": "pipeline_overview", "path": str(FIGURES_ROOT / "pipeline_overview.png"), "stage": "A11"}, + {"figure_name": "example_rois", "path": str(FIGURES_ROOT / "example_rois.png"), "stage": "A11"}, + {"figure_name": "mask_coverage", "path": str(FIGURES_ROOT / "mask_coverage.png"), "stage": "A11"}, + {"figure_name": "pr_curves_baselines", "path": str(FIGURES_ROOT / "pr_curves_baselines.png"), "stage": "A6"}, + {"figure_name": "roc_curves_baselines", "path": str(FIGURES_ROOT / "roc_curves_baselines.png"), "stage": "A6"}, + {"figure_name": "feature_importance_stability", "path": str(FIGURES_ROOT / "feature_importance_stability.png"), "stage": "A7"}, + {"figure_name": "ablation_summary", "path": str(FIGURES_ROOT / "ablation_summary.png"), "stage": "A8"}, + {"figure_name": "image_ranking_recall_curve", "path": str(FIGURES_ROOT / "image_ranking_recall_curve.png"), "stage": "A10"}, + {"figure_name": "patient_ranking_curve", "path": str(FIGURES_ROOT / "patient_ranking_curve.png"), "stage": "A10"}, + ] + ) + figure_manifest.to_csv(TABLES_ROOT / "figure_manifest.csv", index=False) + + table_manifest = pd.DataFrame( + [ + {"table_name": path.stem, "path": str(path)} + for path in sorted(TABLES_ROOT.glob("*.csv")) + ] + ) + table_manifest.to_csv(TABLES_ROOT / "table_manifest.csv", index=False) + + report_lines = [ + "# A10-A11 QC", + "", + f"- Best full-realistic baseline: `{best_full_row['run_id']}`", + f"- Image-level rows: `{len(image_score_df)}`", + f"- Patient-level rows: `{len(patient_score_df)}`", + f"- Figure manifest rows: `{len(figure_manifest)}`", + f"- Table manifest rows: `{len(table_manifest)}`", + ] + write_markdown(REPORTS_ROOT / "a10_a11_qc.md", report_lines) + + return { + "image_level_metrics_csv": str(image_metrics_path), + "patient_level_ranking_metrics_csv": str(patient_metrics_path), + "image_level_scores_parquet": str(image_scores_path), + "patient_level_scores_parquet": str(patient_scores_path), + "figure_manifest_csv": str(TABLES_ROOT / "figure_manifest.csv"), + "table_manifest_csv": str(TABLES_ROOT / "table_manifest.csv"), + "a10_a11_qc_md": str(REPORTS_ROOT / "a10_a11_qc.md"), + } + + +def main() -> None: + result_a6_a8 = run_a6_a8() + result_a10_a11 = run_a10_a11() + print(json.dumps({"a6_a8": result_a6_a8, "a10_a11": result_a10_a11}, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_a9_deep_baselines.py b/scripts/run_a9_deep_baselines.py new file mode 100644 index 0000000..b429879 --- /dev/null +++ b/scripts/run_a9_deep_baselines.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +MPLCONFIGDIR = Path(__file__).resolve().parents[1] / "outputs" / "logs" / "matplotlib" +MPLCONFIGDIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from models.baseline_pipeline import KEY_COLUMNS +from models.deep_baselines import save_training_history, train_deep_baseline +from utils.project import CHECKPOINTS_ROOT, FEATURES_ROOT, FIGURES_ROOT, PREDICTIONS_ROOT, REPORTS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown + + +def _cap_frame(frame: pd.DataFrame, max_rows: int, random_state: int) -> pd.DataFrame: + if len(frame) <= max_rows: + return frame + sampled_groups: list[pd.DataFrame] = [] + remaining = max_rows + groups = list(frame.groupby("target_binary", sort=True)) + for index, (_, group) in enumerate(groups): + if index == len(groups) - 1: + n_take = min(len(group), remaining) + else: + n_take = max(1, int(round(max_rows * len(group) / len(frame)))) + n_take = min(len(group), n_take) + remaining -= n_take + sampled_groups.append(group.sample(n=n_take, random_state=random_state)) + return pd.concat(sampled_groups, ignore_index=True) + + +def _split_frame(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + return ( + df.loc[df["split_name"] == "train"].copy(), + df.loc[df["split_name"] == "val"].copy(), + df.loc[df["split_name"] == "test"].copy(), + ) + + +def _prepare_tabular_splits( + train_df: pd.DataFrame, + val_df: pd.DataFrame, + test_df: pd.DataFrame, + feature_columns: list[str], +) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + if not feature_columns: + return train_df, val_df, test_df + + train_df = train_df.copy() + val_df = val_df.copy() + test_df = test_df.copy() + + for frame in [train_df, val_df, test_df]: + for column in feature_columns: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + + medians = train_df[feature_columns].replace([float("inf"), float("-inf")], pd.NA).median() + train_df[feature_columns] = train_df[feature_columns].replace([float("inf"), float("-inf")], pd.NA).fillna(medians) + val_df[feature_columns] = val_df[feature_columns].replace([float("inf"), float("-inf")], pd.NA).fillna(medians) + test_df[feature_columns] = test_df[feature_columns].replace([float("inf"), float("-inf")], pd.NA).fillna(medians) + + means = train_df[feature_columns].mean() + stds = train_df[feature_columns].std().replace(0.0, 1.0).fillna(1.0) + train_df[feature_columns] = (train_df[feature_columns] - means) / stds + val_df[feature_columns] = (val_df[feature_columns] - means) / stds + test_df[feature_columns] = (test_df[feature_columns] - means) / stds + + return train_df, val_df, test_df + + +def _comparison_row_from_handcrafted(best_handcrafted: pd.Series) -> dict[str, object]: + return { + "model_name": "handcrafted_best_full_realistic", + "source_type": "handcrafted", + "reference_run_id": str(best_handcrafted["run_id"]), + "reference_model_name": str(best_handcrafted["model_name"]), + "implementation": str(best_handcrafted["implementation"]), + "feature_group": str(best_handcrafted["feature_group"]), + "validation_pr_auc": float(best_handcrafted["validation_pr_auc"]), + "test_pr_auc": float(best_handcrafted["test_pr_auc"]), + "test_roc_auc": float(best_handcrafted["test_roc_auc"]), + "test_balanced_accuracy": float(best_handcrafted["test_balanced_accuracy"]), + "test_macro_f1": float(best_handcrafted["test_macro_f1"]), + "test_recall_blast": float(best_handcrafted["test_recall_blast"]), + "test_specificity": float(best_handcrafted["test_specificity"]), + "test_matthews_corrcoef": float(best_handcrafted["test_matthews_corrcoef"]), + "test_brier_score": float(best_handcrafted["test_brier_score"]), + "test_expected_calibration_error": float(best_handcrafted["test_expected_calibration_error"]), + } + + +def _comparison_row_from_deep(model_name: str, result) -> dict[str, object]: + return { + "model_name": model_name, + "source_type": "deep", + "reference_run_id": None, + "reference_model_name": model_name, + "implementation": "small_cnn" if model_name != "cnn_late_fusion" else "small_cnn_late_fusion", + "feature_group": "image_only" if model_name != "cnn_late_fusion" else "image_plus_tabular", + "validation_pr_auc": max((float(epoch["val_pr_auc"]) for epoch in result.history), default=float("nan")), + "test_pr_auc": float(result.test_metrics["pr_auc"]), + "test_roc_auc": float(result.test_metrics["roc_auc"]), + "test_balanced_accuracy": float(result.test_metrics["balanced_accuracy"]), + "test_macro_f1": float(result.test_metrics["macro_f1"]), + "test_recall_blast": float(result.test_metrics["recall_blast"]), + "test_specificity": float(result.test_metrics["specificity"]), + "test_matthews_corrcoef": float(result.test_metrics["matthews_corrcoef"]), + "test_brier_score": float(result.test_metrics["brier_score"]), + "test_expected_calibration_error": float(result.test_metrics["expected_calibration_error"]), + } + + +def run_a9_deep_baselines() -> dict[str, str]: + ensure_output_layout() + task_path = PROJECT_ROOT / "outputs" / "dataset_index" / "task_a43_full_realistic_modeling_workset.parquet" + if not task_path.exists(): + task_path = PROJECT_ROOT / "outputs" / "dataset_index" / "task_a43_full_realistic_binary.parquet" + roi_path = PROJECT_ROOT / "outputs" / "crops" / "roi_manifest_modeling_workset.parquet" + if not roi_path.exists(): + roi_path = PROJECT_ROOT / "outputs" / "crops" / "roi_manifest.parquet" + task_df = pd.read_parquet(task_path) + roi_df = pd.read_parquet(roi_path) + feature_path = FEATURES_ROOT / "features_combined.parquet" + if not feature_path.exists(): + feature_path = FEATURES_ROOT / "features_combined_fast.parquet" + if not feature_path.exists(): + feature_path = FEATURES_ROOT / "features_combined_lite.parquet" + feature_df = pd.read_parquet(feature_path) + handcrafted_df = pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + + merged_roi = task_df.merge( + roi_df[KEY_COLUMNS + ["bbox_crop_path", "mask_crop_path"]].copy(), + on=KEY_COLUMNS, + how="inner", + validate="one_to_one", + ) + mask_ready_df = merged_roi.loc[merged_roi["mask_crop_path"].notna()].copy() + fusion_df = mask_ready_df.merge(feature_df, on=KEY_COLUMNS, how="inner", validate="one_to_one") + mask_ready_df = mask_ready_df.loc[mask_ready_df["target_binary"].notna()].copy() + fusion_df = fusion_df.loc[fusion_df["target_binary"].notna()].copy() + tabular_columns = [ + column + for column in feature_df.columns + if column not in KEY_COLUMNS and pd.api.types.is_numeric_dtype(feature_df[column]) + ] + + bbox_train, bbox_val, bbox_test = _split_frame(mask_ready_df) + fusion_train, fusion_val, fusion_test = _split_frame(fusion_df) + bbox_train = _cap_frame(bbox_train, max_rows=5000, random_state=42) + bbox_val = _cap_frame(bbox_val, max_rows=1000, random_state=43) + fusion_train = _cap_frame(fusion_train, max_rows=5000, random_state=42) + fusion_val = _cap_frame(fusion_val, max_rows=1000, random_state=43) + fusion_train, fusion_val, fusion_test = _prepare_tabular_splits( + fusion_train, + fusion_val, + fusion_test, + tabular_columns, + ) + + bbox_result = train_deep_baseline( + bbox_train, + bbox_val, + bbox_test, + image_column="bbox_crop_path", + feature_columns=None, + checkpoint_path=CHECKPOINTS_ROOT / "cnn_bbox_best.pt", + max_epochs=6, + patience=2, + ) + mask_result = train_deep_baseline( + bbox_train, + bbox_val, + bbox_test, + image_column="mask_crop_path", + feature_columns=None, + checkpoint_path=CHECKPOINTS_ROOT / "cnn_mask_best.pt", + max_epochs=6, + patience=2, + ) + fusion_result = train_deep_baseline( + fusion_train, + fusion_val, + fusion_test, + image_column="bbox_crop_path", + feature_columns=tabular_columns, + checkpoint_path=CHECKPOINTS_ROOT / "cnn_late_fusion_best.pt", + max_epochs=6, + patience=2, + ) + + best_handcrafted = handcrafted_df.loc[ + handcrafted_df["task_name"] == "a43_full_realistic_binary" + ].sort_values(["validation_pr_auc", "test_pr_auc"], ascending=[False, False]).iloc[0] + + comparison_rows = [ + _comparison_row_from_handcrafted(best_handcrafted), + _comparison_row_from_deep("cnn_bbox", bbox_result), + _comparison_row_from_deep("cnn_mask", mask_result), + _comparison_row_from_deep("cnn_late_fusion", fusion_result), + ] + deep_vs_path = TABLES_ROOT / "deep_vs_handcrafted.csv" + pd.DataFrame(comparison_rows).to_csv(deep_vs_path, index=False) + + prediction_rows = [] + for model_name, result in [ + ("cnn_bbox", bbox_result), + ("cnn_mask", mask_result), + ("cnn_late_fusion", fusion_result), + ]: + frame = result.test_predictions[KEY_COLUMNS + ["split_name", "target_binary", "y_true", "y_score", "y_pred"]].copy() + frame["model_name"] = model_name + prediction_rows.append(frame) + deep_predictions_path = PREDICTIONS_ROOT / "object_level_deep_predictions.parquet" + pd.concat(prediction_rows, ignore_index=True).to_parquet(deep_predictions_path, index=False) + + history_payload = { + "cnn_bbox": bbox_result.history, + "cnn_mask": mask_result.history, + "cnn_late_fusion": fusion_result.history, + } + history_path = PROJECT_ROOT / "outputs" / "logs" / "cnn_training_history.json" + save_training_history(history_payload, history_path) + + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + for model_name, result in [ + ("cnn_bbox", bbox_result), + ("cnn_mask", mask_result), + ("cnn_late_fusion", fusion_result), + ]: + history_df = pd.DataFrame(result.history) + axes[0].plot(history_df["epoch"], history_df["train_loss"], label=f"{model_name} train") + axes[0].plot(history_df["epoch"], history_df["val_loss"], linestyle="--", label=f"{model_name} val") + axes[1].plot(history_df["epoch"], history_df["val_pr_auc"], label=model_name) + axes[0].set_title("Deep training loss") + axes[1].set_title("Validation PR-AUC") + for axis in axes: + axis.set_xlabel("Epoch") + axis.legend(fontsize=8) + fig.tight_layout() + training_curves_path = FIGURES_ROOT / "deep_training_curves.png" + fig.savefig(training_curves_path, dpi=180) + plt.close(fig) + + report_lines = [ + "# A9 Deep Baseline QC", + "", + f"- Task source: `{task_path}`", + f"- ROI source: `{roi_path}`", + f"- Device: `CPU fallback`", + f"- Mask-ready full-realistic rows: `{len(mask_ready_df)}`", + f"- Fusion rows: `{len(fusion_df)}`", + f"- BBox checkpoint: `{bbox_result.model_path}`", + f"- Mask checkpoint: `{mask_result.model_path}`", + f"- Fusion checkpoint: `{fusion_result.model_path}`", + f"- Deep comparison table: `{deep_vs_path}`", + f"- Best handcrafted reference run: `{best_handcrafted['run_id']}`", + ] + write_markdown(REPORTS_ROOT / "a9_deep_qc.md", report_lines) + + return { + "deep_vs_handcrafted_csv": str(deep_vs_path), + "deep_predictions_parquet": str(deep_predictions_path), + "bbox_checkpoint": bbox_result.model_path, + "mask_checkpoint": mask_result.model_path, + "fusion_checkpoint": fusion_result.model_path, + "history_json": str(history_path), + "training_curves_png": str(training_curves_path), + "a9_qc_md": str(REPORTS_ROOT / "a9_deep_qc.md"), + } + + +def main() -> None: + print(json.dumps(run_a9_deep_baselines(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_all_hypotheses.py b/scripts/run_all_hypotheses.py new file mode 100644 index 0000000..3bd0dae --- /dev/null +++ b/scripts/run_all_hypotheses.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from reproducibility_blocks import print_payload, run_all_hypotheses + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run all E01 reproducibility blocks.") + parser.add_argument("--data-root", type=Path, default=None, help="Absolute path to the original dataset `data`.") + parser.add_argument("--masks-root", type=Path, default=None, help="Absolute path to the masks dataset `data_masks`.") + parser.add_argument( + "--paths-config", + type=Path, + default=None, + help="Optional JSON config with `data_root` and `masks_root`.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Recompute all reproducibility blocks even if cached outputs already exist.", + ) + parser.add_argument( + "--with-subject-figures", + action="store_true", + help="Render subject-oriented H1-H5 figures after the hypothesis blocks.", + ) + parser.add_argument( + "--subject-figure-variants", + type=int, + default=5, + help="Number of candidate subject figures to render per hypothesis.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + print_payload( + run_all_hypotheses( + data_root=args.data_root, + masks_root=args.masks_root, + config_path=args.paths_config, + force=args.force, + with_subject_figures=args.with_subject_figures, + subject_figure_variants=args.subject_figure_variants, + ) + ) diff --git a/scripts/run_foundation.py b/scripts/run_foundation.py new file mode 100644 index 0000000..dac16cd --- /dev/null +++ b/scripts/run_foundation.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from reproducibility_blocks import print_payload, run_foundation + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run E01 foundation block (A0-A5).") + parser.add_argument("--data-root", type=Path, default=None, help="Absolute path to the original dataset `data`.") + parser.add_argument("--masks-root", type=Path, default=None, help="Absolute path to the masks dataset `data_masks`.") + parser.add_argument( + "--paths-config", + type=Path, + default=None, + help="Optional JSON config with `data_root` and `masks_root`.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Recompute foundation artifacts even if cached outputs already exist.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + print_payload( + run_foundation( + data_root=args.data_root, + masks_root=args.masks_root, + config_path=args.paths_config, + force=args.force, + ) + ) diff --git a/scripts/run_h1_interpretable_features.py b/scripts/run_h1_interpretable_features.py new file mode 100644 index 0000000..b0ea130 --- /dev/null +++ b/scripts/run_h1_interpretable_features.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from reproducibility_blocks import print_payload, run_h1_interpretable_features + + +if __name__ == "__main__": + print_payload(run_h1_interpretable_features()) diff --git a/scripts/run_h2_mask_effect.py b/scripts/run_h2_mask_effect.py new file mode 100644 index 0000000..71ac258 --- /dev/null +++ b/scripts/run_h2_mask_effect.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from reproducibility_blocks import print_payload, run_h2_mask_effect + + +if __name__ == "__main__": + print_payload(run_h2_mask_effect()) diff --git a/scripts/run_h3_noise_regimes.py b/scripts/run_h3_noise_regimes.py new file mode 100644 index 0000000..b5450e3 --- /dev/null +++ b/scripts/run_h3_noise_regimes.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from reproducibility_blocks import print_payload, run_h3_noise_regimes + + +if __name__ == "__main__": + print_payload(run_h3_noise_regimes()) diff --git a/scripts/run_h4_aggregation.py b/scripts/run_h4_aggregation.py new file mode 100644 index 0000000..609ee5a --- /dev/null +++ b/scripts/run_h4_aggregation.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from reproducibility_blocks import print_payload, run_h4_aggregation + + +if __name__ == "__main__": + print_payload(run_h4_aggregation()) diff --git a/scripts/run_h5_deep_vs_handcrafted.py b/scripts/run_h5_deep_vs_handcrafted.py new file mode 100644 index 0000000..430bf57 --- /dev/null +++ b/scripts/run_h5_deep_vs_handcrafted.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from reproducibility_blocks import print_payload, run_h5_deep_vs_handcrafted + + +if __name__ == "__main__": + print_payload(run_h5_deep_vs_handcrafted()) diff --git a/scripts/run_subject_figures_for_articles.py b/scripts/run_subject_figures_for_articles.py new file mode 100644 index 0000000..1a90052 --- /dev/null +++ b/scripts/run_subject_figures_for_articles.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from datetime import datetime +from pathlib import Path + +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +from evaluation.subject_figure_rendering import render_subject_figure +from evaluation.subject_figure_selection import build_subject_figure_specs, flatten_subject_specs +from utils.project import ( + A01_ARTICLE_SUBJECT_MANIFEST_PATH, + A01_FIGURES_ROOT, + A01_SUBJECT_FIGURE_CASES_PATH, + A02_ARTICLE_SUBJECT_MANIFEST_PATH, + A02_FIGURES_ROOT, + A02_SUBJECT_FIGURE_CASES_PATH, + EXPERIMENT_EXECUTION_LOG_PATH, + FIGURES_ROOT, + SUBJECT_FIGURE_CASES_PATH, + SUBJECT_FIGURE_MANIFEST_PATH, + SUBJECT_FIGURES_SUMMARY_JSON, + SUBJECT_FIGURES_SUMMARY_MD, + ensure_dir, + local_date_tag, + write_json, + write_markdown, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Render subject-oriented article figures for A01 and A02.") + parser.add_argument("--variants", type=int, default=5, help="Number of candidate variants to render for each required figure.") + parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files for the same date tag.") + return parser.parse_args() + + +def _output_filename(spec: dict[str, object], date_tag: str) -> str: + return ( + f"{spec['figure_basename']}__{spec['hypothesis']}_{spec['hypothesis_slug']}__" + f"{date_tag}__v{int(spec['variant_index']):02d}.png" + ) + + +def _article_figure_root(article_id: str) -> Path: + if article_id == "A01": + return A01_FIGURES_ROOT + if article_id == "A02": + return A02_FIGURES_ROOT + raise ValueError(f"Unsupported article id: {article_id}") + + +def _article_manifest_path(article_id: str) -> Path: + if article_id == "A01": + return A01_ARTICLE_SUBJECT_MANIFEST_PATH + if article_id == "A02": + return A02_ARTICLE_SUBJECT_MANIFEST_PATH + raise ValueError(f"Unsupported article id: {article_id}") + + +def _article_cases_path(article_id: str) -> Path: + if article_id == "A01": + return A01_SUBJECT_FIGURE_CASES_PATH + if article_id == "A02": + return A02_SUBJECT_FIGURE_CASES_PATH + raise ValueError(f"Unsupported article id: {article_id}") + + +def _save_article_manifests(cases_df: pd.DataFrame, manifest_df: pd.DataFrame) -> dict[str, str]: + article_manifest_paths: dict[str, str] = {} + merged = cases_df.merge( + manifest_df[ + [ + "article_id", + "figure_id", + "figure_variant_id", + "figure_basename", + "variant_index", + "hypothesis", + "hypothesis_slug", + "output_path", + "article_figure_path", + ] + ], + on=["article_id", "figure_id", "figure_variant_id", "figure_basename", "variant_index", "hypothesis", "hypothesis_slug"], + how="left", + validate="many_to_one", + ) + ordered_columns = [ + "figure_id", + "panel_id", + "patient_id", + "image_id", + "object_id_within_image", + "class_name", + "target_binary", + "split_name", + "source_table", + "source_path", + "notes", + "article_id", + "figure_variant_id", + "figure_basename", + "variant_index", + "hypothesis", + "hypothesis_slug", + "output_path", + "article_figure_path", + "role", + "figure_kind", + "title", + ] + for article_id in ["A01", "A02"]: + article_df = merged.loc[merged["article_id"].eq(article_id)].copy() + article_df = article_df[[column for column in ordered_columns if column in article_df.columns]] + manifest_path = _article_manifest_path(article_id) + ensure_dir(manifest_path.parent) + article_df.to_csv(manifest_path, index=False) + article_manifest_paths[article_id] = str(manifest_path) + return article_manifest_paths + + +def run_subject_figures(variants: int = 5, overwrite: bool = False) -> dict[str, object]: + specs = build_subject_figure_specs(variant_count=variants) + date_tag = local_date_tag() + rendered_paths: list[str] = [] + article_paths: list[str] = [] + manifest_rows: list[dict[str, object]] = [] + + for spec in specs: + filename = _output_filename(spec, date_tag) + output_path = FIGURES_ROOT / filename + article_path = _article_figure_root(str(spec["article_id"])) / filename + + if output_path.exists() and not overwrite: + rendered_path = str(output_path) + else: + rendered_path = render_subject_figure(spec, output_path) + ensure_dir(article_path.parent) + shutil.copy2(output_path, article_path) + + rendered_paths.append(rendered_path) + article_paths.append(str(article_path)) + manifest_rows.append( + { + "article_id": spec["article_id"], + "figure_id": spec["figure_id"], + "figure_variant_id": spec["figure_variant_id"], + "figure_basename": spec["figure_basename"], + "variant_index": spec["variant_index"], + "figure_kind": spec["figure_kind"], + "hypothesis": spec["hypothesis"], + "hypothesis_slug": spec["hypothesis_slug"], + "date_tag": date_tag, + "output_path": rendered_path, + "article_figure_path": str(article_path), + "title": spec["title"], + } + ) + + cases_df = flatten_subject_specs(specs) + ensure_dir(SUBJECT_FIGURE_CASES_PATH.parent) + cases_df.to_csv(SUBJECT_FIGURE_CASES_PATH, index=False) + for article_id in ["A01", "A02"]: + cases_df.loc[cases_df["article_id"].eq(article_id)].to_csv(_article_cases_path(article_id), index=False) + + manifest_df = pd.DataFrame(manifest_rows) + manifest_df.to_csv(SUBJECT_FIGURE_MANIFEST_PATH, index=False) + article_manifest_paths = _save_article_manifests(cases_df, manifest_df) + + counts_by_article = manifest_df.groupby("article_id").size().to_dict() + counts_by_figure = manifest_df.groupby("figure_id").size().to_dict() + counts_by_hypothesis = manifest_df.groupby("hypothesis").size().to_dict() + + lines = [ + "# Subject Figures Summary", + "", + f"Generated at date tag: `{date_tag}`", + "", + "## Counts by article", + "", + ] + for article_id in sorted(counts_by_article): + lines.append(f"- `{article_id}`: `{counts_by_article[article_id]}` PNG files.") + lines.extend(["", "## Counts by figure", ""]) + for figure_id in sorted(counts_by_figure): + lines.append(f"- `{figure_id}`: `{counts_by_figure[figure_id]}` variants.") + lines.extend(["", "## Counts by hypothesis", ""]) + for hypothesis in sorted(counts_by_hypothesis): + lines.append(f"- `{hypothesis}`: `{counts_by_hypothesis[hypothesis]}` figure files.") + lines.extend( + [ + "", + "## Main artifacts", + "", + f"- Combined cases CSV: `{SUBJECT_FIGURE_CASES_PATH}`", + f"- Combined figure manifest CSV: `{SUBJECT_FIGURE_MANIFEST_PATH}`", + f"- A01 panel manifest CSV: `{A01_ARTICLE_SUBJECT_MANIFEST_PATH}`", + f"- A02 panel manifest CSV: `{A02_ARTICLE_SUBJECT_MANIFEST_PATH}`", + f"- Output figure directory: `{FIGURES_ROOT}`", + f"- Article figure directories: `{A01_FIGURES_ROOT}`, `{A02_FIGURES_ROOT}`", + ] + ) + write_markdown(SUBJECT_FIGURES_SUMMARY_MD, lines) + + payload = { + "date_tag": date_tag, + "variants_per_figure": variants, + "figure_count": len(rendered_paths), + "counts_by_article": counts_by_article, + "counts_by_figure": counts_by_figure, + "counts_by_hypothesis": counts_by_hypothesis, + "cases_csv": str(SUBJECT_FIGURE_CASES_PATH), + "figure_manifest_csv": str(SUBJECT_FIGURE_MANIFEST_PATH), + "article_cases_csv": { + "A01": str(A01_SUBJECT_FIGURE_CASES_PATH), + "A02": str(A02_SUBJECT_FIGURE_CASES_PATH), + }, + "article_manifest_csv": article_manifest_paths, + "summary_md": str(SUBJECT_FIGURES_SUMMARY_MD), + "rendered_paths": rendered_paths, + "article_paths": article_paths, + } + write_json(SUBJECT_FIGURES_SUMMARY_JSON, payload) + return payload + + +def append_execution_log(payload: dict[str, object]) -> None: + ensure_dir(EXPERIMENT_EXECUTION_LOG_PATH.parent) + date_header = datetime.now().strftime("%Y-%m-%d") + if EXPERIMENT_EXECUTION_LOG_PATH.exists(): + original = EXPERIMENT_EXECUTION_LOG_PATH.read_text(encoding="utf-8").rstrip() + "\n" + else: + original = "# Журнал выполнения E01\n" + lines = [ + "", + f"## {date_header}", + "", + "- Доработан воспроизводимый pipeline предметных figure-сюжетов по ТЗ для статей A01 и A02.", + f"- Сгенерированы все требуемые сюжетные фигуры: `{payload['figure_count']}` PNG-файлов, по `{payload['variants_per_figure']}` вариантов на каждый figure id.", + f"- Обновлен общий manifest фигур: `{SUBJECT_FIGURE_MANIFEST_PATH}`.", + f"- Обновлены article-level manifest файлы: `{A01_ARTICLE_SUBJECT_MANIFEST_PATH}` и `{A02_ARTICLE_SUBJECT_MANIFEST_PATH}`.", + f"- Обновлена сводка предметных фигур: `{SUBJECT_FIGURES_SUMMARY_MD}`.", + ] + EXPERIMENT_EXECUTION_LOG_PATH.write_text(original + "\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + args = parse_args() + payload = run_subject_figures(variants=args.variants, overwrite=args.overwrite) + append_execution_log(payload) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..900285d --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Project source package for experiment E01.""" diff --git a/src/dataset/__init__.py b/src/dataset/__init__.py new file mode 100644 index 0000000..0862c2b --- /dev/null +++ b/src/dataset/__init__.py @@ -0,0 +1 @@ +"""Dataset utilities for experiment E01.""" diff --git a/src/dataset/build_index.py b/src/dataset/build_index.py new file mode 100644 index 0000000..558641c --- /dev/null +++ b/src/dataset/build_index.py @@ -0,0 +1,574 @@ +from __future__ import annotations + +import argparse +import csv +import json +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +try: + import pyarrow as pa + import pyarrow.parquet as pq +except ModuleNotFoundError as exc: + raise SystemExit( + "pyarrow is required for A0 because the plan requires parquet outputs. " + "Install it into the experiment environment before running this script." + ) from exc + +from dataset.class_mapping import mapping_signature, normalize_mapping_for_json, parse_predefined_classes +from dataset.parse_detection_labels import parse_label_file +from dataset.parse_masks import build_mask_index + + +MAIN_PROTOCOL_EXCLUDES = {"part-1"} +QUALITY_OR_ARTIFACT_CLASS_IDS = {12, 14, 15} +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} + +EXPECTED_SUMMARY = { + "raw_top_level_dirs_data": 123, + "raw_top_level_dirs_masks": 123, + "raw_images": 12521, + "raw_label_files": 12489, + "raw_objects": 86873, + "raw_mask_files": 86835, + "main_patient_dirs": 122, + "main_images": 12488, + "main_label_files": 12456, + "main_objects": 86396, + "main_empty_label_files": 26, + "main_images_without_labels": 32, + "main_mask_files": 86360, + "main_images_with_any_mask": 12428, + "main_images_without_masks": 60, + "main_checked_images_equal_label_mask_counts": 12395, + "main_images_with_exact_label_mask_order_match": 12395, + "main_float_class_rows": 4991, + "main_blast_patients": 100, + "main_nonblast_patients": 22, + "main_images_with_blast": 8618, + "main_images_only_blast": 3508, + "main_images_blast_mixed": 5110, + "main_images_without_blast": 3812, +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build A0 dataset index artifacts for experiment E01.") + parser.add_argument( + "--data-root", + type=Path, + default=PROJECT_ROOT.parent / "Data" / "data", + help="Path to the data directory containing patient folders.", + ) + parser.add_argument( + "--masks-root", + type=Path, + default=PROJECT_ROOT.parent / "Data" / "data_masks", + help="Path to the mask directory containing patient folders.", + ) + parser.add_argument( + "--output-root", + type=Path, + default=PROJECT_ROOT / "outputs", + help="Experiment outputs root.", + ) + parser.add_argument( + "--exclude-from-main-protocol", + action="append", + default=[], + help="Top-level directories to exclude from the main patient-level protocol.", + ) + return parser.parse_args() + + +def list_image_files(image_dir: Path) -> dict[str, Path]: + if not image_dir.exists(): + return {} + return { + image_path.stem: image_path + for image_path in sorted(image_dir.iterdir()) + if image_path.is_file() and image_path.suffix.lower() in IMAGE_SUFFIXES + } + + +def list_label_files(labels_dir: Path) -> dict[str, Path]: + if not labels_dir.exists(): + return {} + return {label_path.stem: label_path for label_path in sorted(labels_dir.glob("*.txt"))} + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def write_csv(rows: list[dict[str, object]], path: Path) -> None: + ensure_parent(path) + fieldnames = sorted({key for row in rows for key in row.keys()}) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def write_json(payload: dict[str, object], path: Path) -> None: + ensure_parent(path) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def write_parquet(rows: list[dict[str, object]], path: Path) -> None: + ensure_parent(path) + if rows: + table = pa.Table.from_pylist(rows) + else: + table = pa.table({}) + pq.write_table(table, path) + + +def determine_image_status( + has_image: bool, + has_label: bool, + empty_label_file: bool, + label_count: int, + mask_count: int, + class_order_match: bool, +) -> str: + if not has_image and has_label: + return "missing_image" + if has_image and not has_label: + return "missing_label" + if empty_label_file: + return "empty_label_file" + if label_count == 0 and mask_count == 0: + return "no_labels_no_masks" + if mask_count == 0: + return "no_masks" + if label_count != mask_count: + return "mask_count_mismatch" + if not class_order_match: + return "mask_class_order_mismatch" + return "ok" + + +def build_class_mapping_payload( + patient_class_mappings: dict[str, dict[int, str]], + root_reference_mapping: dict[int, str], +) -> tuple[dict[str, object], dict[tuple[tuple[int, str], ...], str]]: + signatures = Counter(mapping_signature(mapping) for mapping in patient_class_mappings.values()) + signature_to_variant_id: dict[tuple[tuple[int, str], ...], str] = {} + + for index, (signature, _) in enumerate( + sorted(signatures.items(), key=lambda item: (-item[1], item[0])), start=1 + ): + signature_to_variant_id[signature] = f"variant_{index:02d}" + + canonical_signature = max(signatures.items(), key=lambda item: item[1])[0] if signatures else () + variant_payload = [] + for signature, count in sorted(signatures.items(), key=lambda item: (-item[1], item[0])): + variant_id = signature_to_variant_id[signature] + variant_mapping = {class_id: class_name for class_id, class_name in signature} + variant_payload.append( + { + "variant_id": variant_id, + "patient_count": count, + "patients": sorted( + patient_id + for patient_id, patient_mapping in patient_class_mappings.items() + if mapping_signature(patient_mapping) == signature + ), + "mapping": normalize_mapping_for_json(variant_mapping), + } + ) + + payload = { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "canonical_mapping": normalize_mapping_for_json({class_id: class_name for class_id, class_name in canonical_signature}), + "root_reference_mapping": normalize_mapping_for_json(root_reference_mapping), + "mapping_variants": variant_payload, + "main_protocol_excludes": sorted(MAIN_PROTOCOL_EXCLUDES), + "notes": [ + "canonical_mapping is selected as the most frequent patient-specific mapping", + "part-1 is excluded from the main patient-level protocol by default", + ], + } + return payload, signature_to_variant_id + + +def summarize_protocol( + patient_inventory_rows: list[dict[str, object]], + image_rows: list[dict[str, object]], + object_rows: list[dict[str, object]], + raw_top_level_dirs_data: int, + raw_top_level_dirs_masks: int, +) -> dict[str, int]: + raw_patients = patient_inventory_rows + main_patients = [row for row in patient_inventory_rows if bool(row["include_in_main_protocol"])] + main_images = [row for row in image_rows if bool(row["include_in_main_protocol"]) and bool(row["has_image"])] + main_objects = [row for row in object_rows if bool(row["include_in_main_protocol"])] + + def sum_field(rows: Iterable[dict[str, object]], field: str) -> int: + return sum(int(row.get(field, 0)) for row in rows) + + summary = { + "raw_top_level_dirs_data": raw_top_level_dirs_data, + "raw_top_level_dirs_masks": raw_top_level_dirs_masks, + "raw_images": sum_field(raw_patients, "images_count"), + "raw_label_files": sum_field(raw_patients, "label_files_count"), + "raw_objects": sum_field(raw_patients, "objects_count"), + "raw_mask_files": sum_field(raw_patients, "mask_files_count"), + "main_patient_dirs": len(main_patients), + "main_images": len(main_images), + "main_label_files": sum_field(main_patients, "label_files_count"), + "main_objects": len(main_objects), + "main_empty_label_files": sum_field(main_patients, "empty_label_files_count"), + "main_images_without_labels": sum_field(main_patients, "images_without_labels_count"), + "main_mask_files": sum_field(main_patients, "mask_files_count"), + "main_images_with_any_mask": sum_field(main_patients, "images_with_any_mask_count"), + "main_images_without_masks": sum_field(main_patients, "images_without_masks_count"), + "main_checked_images_equal_label_mask_counts": sum_field( + main_patients, "checked_images_equal_label_mask_counts" + ), + "main_images_with_exact_label_mask_order_match": sum_field( + main_patients, "images_with_exact_label_mask_order_match" + ), + "main_float_class_rows": sum_field(main_patients, "float_class_rows_count"), + "main_blast_patients": sum(1 for row in main_patients if bool(row["patient_has_blast"])), + "main_nonblast_patients": sum(1 for row in main_patients if not bool(row["patient_has_blast"])), + "main_images_with_blast": sum_field(main_patients, "images_with_blast_count"), + "main_images_only_blast": sum_field(main_patients, "images_only_blast_count"), + "main_images_blast_mixed": sum_field(main_patients, "images_blast_mixed_count"), + "main_images_without_blast": sum_field(main_patients, "images_without_blast_count"), + } + return summary + + +def build_runtime_report( + summary: dict[str, int], + output_paths: dict[str, str], + path: Path, +) -> None: + ensure_parent(path) + lines = [ + "# Runtime Data Audit", + "", + f"Generated at: `{datetime.now(timezone.utc).isoformat()}`", + "", + "## Expected Comparison", + "", + "| Metric | Expected | Actual | Status |", + "|---|---:|---:|---|", + ] + + for key in sorted(EXPECTED_SUMMARY): + actual = summary.get(key, 0) + expected = EXPECTED_SUMMARY[key] + status = "MATCH" if actual == expected else "DIFF" + lines.append(f"| {key} | {expected} | {actual} | {status} |") + + lines.extend( + [ + "", + "## Output Artifacts", + "", + ] + ) + for name, artifact_path in sorted(output_paths.items()): + lines.append(f"- `{name}`: `{artifact_path}`") + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def build_index_artifacts( + data_root: Path, + masks_root: Path, + output_root: Path, + main_protocol_excludes: set[str] | None = None, +) -> dict[str, object]: + excludes = set(main_protocol_excludes or set()) | MAIN_PROTOCOL_EXCLUDES + + data_dirs = { + path.name: path + for path in sorted(data_root.iterdir()) + if path.is_dir() and not path.name.startswith(".") + } + mask_dirs = { + path.name: path + for path in sorted(masks_root.iterdir()) + if path.is_dir() and not path.name.startswith(".") + } + top_level_ids = sorted(set(data_dirs) | set(mask_dirs)) + + patient_class_mappings: dict[str, dict[int, str]] = {} + for top_level_id in top_level_ids: + candidate_paths = [ + data_dirs.get(top_level_id, Path()) / "predefined_classes.txt" if top_level_id in data_dirs else None, + mask_dirs.get(top_level_id, Path()) / "predefined_classes.txt" if top_level_id in mask_dirs else None, + ] + mapping_path = next((path for path in candidate_paths if path is not None and path.exists()), None) + patient_class_mappings[top_level_id] = parse_predefined_classes(mapping_path) if mapping_path else {} + + root_reference_mapping = parse_predefined_classes(data_root / "predefined_classes.txt") + class_mapping_payload, signature_to_variant_id = build_class_mapping_payload( + patient_class_mappings=patient_class_mappings, + root_reference_mapping=root_reference_mapping, + ) + + patient_inventory_rows: list[dict[str, object]] = [] + image_rows: list[dict[str, object]] = [] + object_rows: list[dict[str, object]] = [] + + for patient_id in top_level_ids: + data_dir = data_dirs.get(patient_id) + mask_dir = mask_dirs.get(patient_id) + include_in_main_protocol = patient_id not in excludes + class_mapping = patient_class_mappings.get(patient_id, {}) + class_mapping_variant_id = signature_to_variant_id.get(mapping_signature(class_mapping), "variant_unknown") + + image_files = list_image_files(data_dir / "images") if data_dir else list_image_files(mask_dir / "images") + label_files = ( + list_label_files(data_dir / "images_labels") + if data_dir and (data_dir / "images_labels").exists() + else list_label_files(mask_dir / "images_labels") if mask_dir else {} + ) + mask_entries_by_image = ( + build_mask_index(mask_dir / "images_results" / "masks_sam") if mask_dir else {} + ) + + patient_counts = Counter() + patient_counts["images_count"] = len(image_files) + patient_counts["label_files_count"] = len(label_files) + patient_counts["mask_files_count"] = sum(len(entries) for entries in mask_entries_by_image.values()) + + image_ids = sorted(set(image_files) | set(label_files) | set(mask_entries_by_image)) + + for image_id in image_ids: + image_path = image_files.get(image_id) + label_path = label_files.get(image_id) + mask_entries = mask_entries_by_image.get(image_id, []) + label_entries = parse_label_file(label_path) if label_path else [] + + has_image = image_path is not None + has_label = label_path is not None + empty_label_file = has_label and not label_entries + label_count = len(label_entries) + mask_count = len(mask_entries) + contains_blast = any(int(entry["class_id"]) == 7 for entry in label_entries) + contains_non_blast = any(int(entry["class_id"]) != 7 for entry in label_entries) + count_match = has_label and label_count > 0 and label_count == mask_count + class_order_match = count_match and [ + int(entry["class_id"]) for entry in label_entries + ] == [int(entry["class_id"]) for entry in mask_entries] + has_any_mask = mask_count > 0 + + if has_image and not has_label: + patient_counts["images_without_labels_count"] += 1 + if has_label and not has_image: + patient_counts["labels_without_images_count"] += 1 + if empty_label_file: + patient_counts["empty_label_files_count"] += 1 + if has_any_mask: + patient_counts["images_with_any_mask_count"] += 1 + if has_image and not has_any_mask: + patient_counts["images_without_masks_count"] += 1 + if count_match: + patient_counts["checked_images_equal_label_mask_counts"] += 1 + if class_order_match: + patient_counts["images_with_exact_label_mask_order_match"] += 1 + if has_label and label_count != mask_count: + patient_counts["images_with_count_mismatch"] += 1 + + if has_label and not empty_label_file: + if contains_blast: + patient_counts["images_with_blast_count"] += 1 + if contains_non_blast: + patient_counts["images_blast_mixed_count"] += 1 + else: + patient_counts["images_only_blast_count"] += 1 + else: + patient_counts["images_without_blast_count"] += 1 + + image_rows.append( + { + "patient_id": patient_id, + "include_in_main_protocol": include_in_main_protocol, + "image_id": image_id, + "image_path": str(image_path.resolve()) if image_path else None, + "label_path": str(label_path.resolve()) if label_path else None, + "has_image": has_image, + "has_label": has_label, + "empty_label_file": empty_label_file, + "has_any_mask": has_any_mask, + "label_object_count": label_count, + "mask_object_count": mask_count, + "label_mask_count_match": label_count == mask_count if has_label else False, + "label_mask_class_order_match": class_order_match, + "contains_blast": contains_blast, + "contains_non_blast": contains_non_blast, + "blast_object_count": sum(1 for entry in label_entries if int(entry["class_id"]) == 7), + "non_blast_object_count": sum(1 for entry in label_entries if int(entry["class_id"]) != 7), + "image_status": determine_image_status( + has_image=has_image, + has_label=has_label, + empty_label_file=empty_label_file, + label_count=label_count, + mask_count=mask_count, + class_order_match=class_order_match, + ), + } + ) + + for detection_entry in label_entries: + object_id = int(detection_entry["object_id_within_image"]) + expected_mask = mask_entries[object_id] if object_id < len(mask_entries) else None + has_mask = expected_mask is not None and int(expected_mask["class_id"]) == int(detection_entry["class_id"]) + + class_id = int(detection_entry["class_id"]) + class_name = class_mapping.get(class_id, f"UNKNOWN_CLASS_{class_id}") + patient_counts["objects_count"] += 1 + if bool(detection_entry["class_id_was_float"]): + patient_counts["float_class_rows_count"] += 1 + if class_id == 7: + patient_counts["blast_objects_count"] += 1 + else: + patient_counts["non_blast_objects_count"] += 1 + if not has_mask: + patient_counts["objects_without_masks_count"] += 1 + + object_rows.append( + { + "patient_id": patient_id, + "include_in_main_protocol": include_in_main_protocol, + "image_id": image_id, + "image_path": str(image_path.resolve()) if image_path else None, + "label_path": str(label_path.resolve()) if label_path else None, + "mask_path": expected_mask["mask_path"] if has_mask else None, + "object_id_within_image": object_id, + "raw_class_id": detection_entry["raw_class_id"], + "class_id": class_id, + "class_name": class_name, + "class_id_was_float": bool(detection_entry["class_id_was_float"]), + "x_center_norm": float(detection_entry["x_center_norm"]), + "y_center_norm": float(detection_entry["y_center_norm"]), + "width_norm": float(detection_entry["width_norm"]), + "height_norm": float(detection_entry["height_norm"]), + "has_mask": has_mask, + "mask_status": "matched" if has_mask else "missing", + "mask_class_id": int(expected_mask["class_id"]) if expected_mask is not None else None, + "label_count_for_image": label_count, + "mask_count_for_image": mask_count, + "image_has_any_mask": has_any_mask, + "is_blast": class_id == 7, + "is_quality_or_artifact": class_id in QUALITY_OR_ARTIFACT_CLASS_IDS, + } + ) + + patient_inventory_rows.append( + { + "patient_id": patient_id, + "include_in_main_protocol": include_in_main_protocol, + "has_data_dir": data_dir is not None, + "has_mask_dir": mask_dir is not None, + "images_count": patient_counts["images_count"], + "label_files_count": patient_counts["label_files_count"], + "empty_label_files_count": patient_counts["empty_label_files_count"], + "images_without_labels_count": patient_counts["images_without_labels_count"], + "labels_without_images_count": patient_counts["labels_without_images_count"], + "objects_count": patient_counts["objects_count"], + "float_class_rows_count": patient_counts["float_class_rows_count"], + "mask_files_count": patient_counts["mask_files_count"], + "images_with_any_mask_count": patient_counts["images_with_any_mask_count"], + "images_without_masks_count": patient_counts["images_without_masks_count"], + "checked_images_equal_label_mask_counts": patient_counts["checked_images_equal_label_mask_counts"], + "images_with_exact_label_mask_order_match": patient_counts[ + "images_with_exact_label_mask_order_match" + ], + "images_with_count_mismatch": patient_counts["images_with_count_mismatch"], + "objects_without_masks_count": patient_counts["objects_without_masks_count"], + "blast_objects_count": patient_counts["blast_objects_count"], + "non_blast_objects_count": patient_counts["non_blast_objects_count"], + "patient_has_blast": patient_counts["blast_objects_count"] > 0, + "images_with_blast_count": patient_counts["images_with_blast_count"], + "images_only_blast_count": patient_counts["images_only_blast_count"], + "images_blast_mixed_count": patient_counts["images_blast_mixed_count"], + "images_without_blast_count": patient_counts["images_without_blast_count"], + "class_mapping_size": len(class_mapping), + "class_mapping_variant_id": class_mapping_variant_id, + } + ) + + dataset_index_dir = output_root / "dataset_index" + reports_dir = output_root / "reports" + + patient_inventory_path = dataset_index_dir / "patient_inventory.csv" + image_index_path = dataset_index_dir / "image_index.parquet" + object_index_path = dataset_index_dir / "object_index.parquet" + class_mapping_path = dataset_index_dir / "class_mapping.json" + mask_coverage_report_path = dataset_index_dir / "mask_coverage_report.csv" + runtime_report_path = reports_dir / "data_audit_runtime.md" + + write_csv(patient_inventory_rows, patient_inventory_path) + write_parquet(image_rows, image_index_path) + write_parquet(object_rows, object_index_path) + write_json(class_mapping_payload, class_mapping_path) + write_csv(image_rows, mask_coverage_report_path) + + summary = summarize_protocol( + patient_inventory_rows=patient_inventory_rows, + image_rows=image_rows, + object_rows=object_rows, + raw_top_level_dirs_data=len(data_dirs), + raw_top_level_dirs_masks=len(mask_dirs), + ) + build_runtime_report( + summary=summary, + output_paths={ + "patient_inventory_csv": str(patient_inventory_path), + "image_index_parquet": str(image_index_path), + "object_index_parquet": str(object_index_path), + "class_mapping_json": str(class_mapping_path), + "mask_coverage_report_csv": str(mask_coverage_report_path), + }, + path=runtime_report_path, + ) + + return { + "summary": summary, + "paths": { + "patient_inventory_csv": str(patient_inventory_path), + "image_index_parquet": str(image_index_path), + "object_index_parquet": str(object_index_path), + "class_mapping_json": str(class_mapping_path), + "mask_coverage_report_csv": str(mask_coverage_report_path), + "data_audit_runtime_md": str(runtime_report_path), + }, + } + + +def main() -> None: + args = parse_args() + excludes = set(args.exclude_from_main_protocol or []) | MAIN_PROTOCOL_EXCLUDES + result = build_index_artifacts( + data_root=args.data_root, + masks_root=args.masks_root, + output_root=args.output_root, + main_protocol_excludes=excludes, + ) + print(json.dumps(result["summary"], ensure_ascii=False, indent=2)) + print(json.dumps(result["paths"], ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/dataset/build_patient_summary.py b/src/dataset/build_patient_summary.py new file mode 100644 index 0000000..925690d --- /dev/null +++ b/src/dataset/build_patient_summary.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from utils.project import DATASET_INDEX_ROOT, SPLITS_ROOT, REPORTS_ROOT, ensure_output_layout, write_markdown + +import pandas as pd + + +PATIENT_INVENTORY_PATH = DATASET_INDEX_ROOT / "patient_inventory.csv" +IMAGE_INDEX_PATH = DATASET_INDEX_ROOT / "image_index.parquet" +OBJECT_INDEX_PATH = DATASET_INDEX_ROOT / "object_index.parquet" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build patient summary for A2.") + parser.add_argument("--patient-inventory", type=Path, default=PATIENT_INVENTORY_PATH) + parser.add_argument("--image-index", type=Path, default=IMAGE_INDEX_PATH) + parser.add_argument("--object-index", type=Path, default=OBJECT_INDEX_PATH) + return parser.parse_args() + + +def build_patient_summary(patient_inventory_path: Path, image_index_path: Path, object_index_path: Path) -> Path: + ensure_output_layout() + inventory_df = pd.read_csv(patient_inventory_path) + image_df = pd.read_parquet(image_index_path) + object_df = pd.read_parquet(object_index_path) + + inventory_df = inventory_df.loc[inventory_df["include_in_main_protocol"].eq(True)].copy() + image_df = image_df.loc[image_df["include_in_main_protocol"].eq(True)].copy() + object_df = object_df.loc[object_df["include_in_main_protocol"].eq(True)].copy() + + object_grouped = ( + object_df.groupby("patient_id") + .agg( + objects_count=("object_id_within_image", "size"), + blast_objects_count=("is_blast", "sum"), + mask_missing_objects_count=("has_mask", lambda s: int((~s).sum())), + quality_or_artifact_objects_count=("is_quality_or_artifact", "sum"), + ) + .reset_index() + ) + object_grouped["blast_fraction"] = object_grouped["blast_objects_count"] / object_grouped["objects_count"] + object_grouped["mask_missing_fraction"] = ( + object_grouped["mask_missing_objects_count"] / object_grouped["objects_count"] + ) + + image_grouped = ( + image_df.loc[image_df["has_image"].eq(True)] + .groupby("patient_id") + .agg( + images_count=("image_id", "nunique"), + images_with_blast_count=("contains_blast", "sum"), + images_without_masks_count=("has_any_mask", lambda s: int((~s).sum())), + ) + .reset_index() + ) + image_grouped["images_with_blast_fraction"] = ( + image_grouped["images_with_blast_count"] / image_grouped["images_count"] + ) + image_grouped["images_without_masks_fraction"] = ( + image_grouped["images_without_masks_count"] / image_grouped["images_count"] + ) + + patient_summary_df = ( + inventory_df[["patient_id", "class_mapping_variant_id"]] + .merge(object_grouped, on="patient_id", how="left") + .merge(image_grouped, on="patient_id", how="left") + .fillna(0) + ) + patient_summary_df["patient_has_blast"] = patient_summary_df["blast_objects_count"] > 0 + patient_summary_df["blast_stratum"] = patient_summary_df["patient_has_blast"].map({True: "blast", False: "non_blast"}) + patient_summary_df = patient_summary_df.sort_values(["patient_has_blast", "blast_fraction", "patient_id"], ascending=[False, False, True]) + + output_path = SPLITS_ROOT / "patient_summary.csv" + patient_summary_df.to_csv(output_path, index=False) + + report_lines = [ + "# A2 Patient Summary QC Report", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + f"- Patients in main protocol: `{patient_summary_df['patient_id'].nunique()}`", + f"- Patients with blast: `{int(patient_summary_df['patient_has_blast'].sum())}`", + f"- Patients without blast: `{int((~patient_summary_df['patient_has_blast']).sum())}`", + f"- Mean blast fraction: `{patient_summary_df['blast_fraction'].mean():.6f}`", + f"- Mean mask missing fraction: `{patient_summary_df['mask_missing_fraction'].mean():.6f}`", + ] + write_markdown(REPORTS_ROOT / "a2_patient_summary_qc.md", report_lines) + + return output_path + + +def main() -> None: + args = parse_args() + output_path = build_patient_summary(args.patient_inventory, args.image_index, args.object_index) + print(output_path) + + +if __name__ == "__main__": + main() diff --git a/src/dataset/build_subsets.py b/src/dataset/build_subsets.py new file mode 100644 index 0000000..6282a65 --- /dev/null +++ b/src/dataset/build_subsets.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from utils.project import DATASET_INDEX_ROOT, REPORTS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown + +import numpy as np +import pandas as pd + + +OBJECT_INDEX_PATH = DATASET_INDEX_ROOT / "object_index.parquet" +IMAGE_INDEX_PATH = DATASET_INDEX_ROOT / "image_index.parquet" + +SUBSET_RULES = { + "full_realistic": lambda df: df["include_in_main_protocol"].eq(True) & df["geometry_valid"].eq(True), + "clean_cell": lambda df: df["include_in_main_protocol"].eq(True) + & df["geometry_valid"].eq(True) + & ~df["class_id"].isin([12, 14]), + "strict_morphology": lambda df: df["include_in_main_protocol"].eq(True) + & df["geometry_valid"].eq(True) + & ~df["class_id"].isin([12, 14, 15]), + "mask_ready": lambda df: df["include_in_main_protocol"].eq(True) + & df["geometry_valid"].eq(True) + & df["has_mask"].eq(True), +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build A1 subsets for experiment E01.") + parser.add_argument("--object-index", type=Path, default=OBJECT_INDEX_PATH) + parser.add_argument("--image-index", type=Path, default=IMAGE_INDEX_PATH) + return parser.parse_args() + + +def _geometry_issue_columns(object_df: pd.DataFrame) -> pd.DataFrame: + issue_df = pd.DataFrame(index=object_df.index) + issue_df["finite"] = np.isfinite(object_df["x_center_norm"]) & np.isfinite(object_df["y_center_norm"]) + issue_df["finite"] &= np.isfinite(object_df["width_norm"]) & np.isfinite(object_df["height_norm"]) + issue_df["x_in_range"] = object_df["x_center_norm"].between(0, 1, inclusive="both") + issue_df["y_in_range"] = object_df["y_center_norm"].between(0, 1, inclusive="both") + issue_df["width_in_range"] = object_df["width_norm"].between(0, 1, inclusive="right") + issue_df["height_in_range"] = object_df["height_norm"].between(0, 1, inclusive="right") + return issue_df + + +def _geometry_issue_label(row: pd.Series) -> str: + issues: list[str] = [] + if not bool(row["finite"]): + issues.append("non_finite") + if not bool(row["x_in_range"]): + issues.append("x_out_of_range") + if not bool(row["y_in_range"]): + issues.append("y_out_of_range") + if not bool(row["width_in_range"]): + issues.append("width_out_of_range") + if not bool(row["height_in_range"]): + issues.append("height_out_of_range") + return ",".join(issues) if issues else "ok" + + +def _unique_image_count(frame: pd.DataFrame) -> int: + if frame.empty: + return 0 + return int(frame[["patient_id", "image_id"]].drop_duplicates().shape[0]) + + +def build_subsets(object_index_path: Path, image_index_path: Path) -> dict[str, object]: + ensure_output_layout() + object_df = pd.read_parquet(object_index_path) + image_df = pd.read_parquet(image_index_path) + geometry_checks = _geometry_issue_columns(object_df) + object_df = object_df.copy() + object_df["geometry_valid"] = geometry_checks.all(axis=1) + object_df["geometry_issue"] = geometry_checks.apply(_geometry_issue_label, axis=1) + + subset_paths: dict[str, str] = {} + summary_rows: list[dict[str, object]] = [] + zero_object_patients = sorted( + set(object_df.loc[object_df["include_in_main_protocol"].eq(True), "patient_id"].unique().tolist()) + ) + invalid_geometry_df = object_df.loc[ + object_df["include_in_main_protocol"].eq(True) & object_df["geometry_valid"].ne(True) + ].copy() + invalid_geometry_path = DATASET_INDEX_ROOT / "invalid_geometry_objects.csv" + invalid_geometry_df.to_csv(invalid_geometry_path, index=False) + + for subset_name, predicate in SUBSET_RULES.items(): + subset_df = object_df.loc[predicate(object_df)].copy() + subset_path = DATASET_INDEX_ROOT / f"subset_{subset_name}.parquet" + subset_df.to_parquet(subset_path, index=False) + subset_paths[subset_name] = str(subset_path) + + patient_count = subset_df["patient_id"].nunique() + image_count = _unique_image_count(subset_df) + class_counts = subset_df.groupby(["class_id", "class_name"]).size().reset_index(name="count") + top_classes = ( + class_counts.sort_values(["count", "class_id"], ascending=[False, True]) + .head(10) + .to_dict("records") + ) + summary_rows.append( + { + "subset_name": subset_name, + "object_count": int(len(subset_df)), + "patient_count": int(patient_count), + "image_count": int(image_count), + "has_bad_cells": bool(subset_df["class_id"].eq(12).any()), + "has_artifacts": bool(subset_df["class_id"].eq(14).any()), + "has_gumpricht_shadows": bool(subset_df["class_id"].eq(15).any()), + "mask_ready_ratio": float(subset_df["has_mask"].mean()) if len(subset_df) else 0.0, + "top_classes": top_classes, + } + ) + + summary_df = pd.DataFrame(summary_rows) + summary_df.to_csv(TABLES_ROOT / "subset_summary.csv", index=False) + + class_distribution_rows: list[dict[str, object]] = [] + for subset_name, subset_path in subset_paths.items(): + subset_df = pd.read_parquet(subset_path) + grouped = subset_df.groupby(["class_id", "class_name"]).size().reset_index(name="count") + grouped["subset_name"] = subset_name + class_distribution_rows.extend(grouped.to_dict("records")) + + pd.DataFrame(class_distribution_rows).to_csv(TABLES_ROOT / "subset_class_distribution.csv", index=False) + + excluded_images = _unique_image_count(image_df.loc[image_df["include_in_main_protocol"].eq(False)].copy()) + patient_inventory_df = pd.read_csv(DATASET_INDEX_ROOT / "patient_inventory.csv") + main_protocol_patients = patient_inventory_df.loc[ + patient_inventory_df["include_in_main_protocol"].eq(True), "patient_id" + ].tolist() + zero_object_patient_dirs = sorted(set(main_protocol_patients) - set(zero_object_patients)) + report_lines = [ + "# A1 Subset QC Report", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + "## Summary", + "", + f"- Source object index: `{object_index_path}`", + f"- Source image index: `{image_index_path}`", + f"- Images excluded from main protocol: `{excluded_images}`", + f"- Main protocol patient directories: `{len(main_protocol_patients)}`", + f"- Patient directories with zero labeled objects: `{len(zero_object_patient_dirs)}`", + f"- Zero-object patient directories: `{', '.join(zero_object_patient_dirs) if zero_object_patient_dirs else 'none'}`", + f"- Main-protocol objects with invalid geometry: `{len(invalid_geometry_df)}`", + f"- Invalid-geometry artifact: `{invalid_geometry_path}`", + "", + "## Subset Checks", + "", + "| Subset | Objects | Patients | Images | Has bad cells | Has artifacts | Has gumpricht shadows | Mask-ready ratio |", + "|---|---:|---:|---:|---|---|---|---:|", + ] + for row in summary_rows: + report_lines.append( + f"| {row['subset_name']} | {row['object_count']} | {row['patient_count']} | {row['image_count']} | " + f"{row['has_bad_cells']} | {row['has_artifacts']} | {row['has_gumpricht_shadows']} | {row['mask_ready_ratio']:.4f} |" + ) + + report_lines.extend( + [ + "", + "## Geometry validation", + "", + "| Issue | Count |", + "|---|---:|", + ] + ) + issue_counts = ( + invalid_geometry_df["geometry_issue"].value_counts().sort_values(ascending=False).to_dict() + if len(invalid_geometry_df) + else {} + ) + for issue, count in issue_counts.items(): + report_lines.append(f"| {issue} | {int(count)} |") + + report_lines.extend( + [ + "", + "Note:", + "Object-level subsets can contain fewer patient ids than the main protocol if a patient directory exists but contains zero labeled objects. " + "This is currently expected for the zero-object directories listed above.", + "All A1 subsets additionally exclude objects with non-finite or out-of-range bounding-box geometry; these rows are preserved in invalid_geometry_objects.csv for auditability.", + ] + ) + + write_markdown(REPORTS_ROOT / "a1_subset_qc.md", report_lines) + + return { + "subset_paths": subset_paths, + "invalid_geometry_path": str(invalid_geometry_path), + "summary_path": str(TABLES_ROOT / "subset_summary.csv"), + "class_distribution_path": str(TABLES_ROOT / "subset_class_distribution.csv"), + "qc_report_path": str(REPORTS_ROOT / "a1_subset_qc.md"), + } + + +def main() -> None: + args = parse_args() + result = build_subsets(args.object_index, args.image_index) + print(result) + + +if __name__ == "__main__": + main() diff --git a/src/dataset/build_tasks.py b/src/dataset/build_tasks.py new file mode 100644 index 0000000..0f2076d --- /dev/null +++ b/src/dataset/build_tasks.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from utils.project import DATASET_INDEX_ROOT, REPORTS_ROOT, SPLITS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown + +import pandas as pd + + +SPLIT_JSON_PATH = SPLITS_ROOT / "split_v1.json" + + +TASK_SPECS = { + "a41_clean_binary": "subset_clean_cell.parquet", + "a42_strict_binary": "subset_strict_morphology.parquet", + "a43_full_realistic_binary": "subset_full_realistic.parquet", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build binary task datasets for A4.") + parser.add_argument("--split-json", type=Path, default=SPLIT_JSON_PATH) + return parser.parse_args() + + +def build_tasks(split_json_path: Path) -> dict[str, str]: + ensure_output_layout() + split_payload = json.loads(split_json_path.read_text(encoding="utf-8")) + patient_to_split = {patient_id: "train" for patient_id in split_payload["train_patients"]} + patient_to_split.update({patient_id: "val" for patient_id in split_payload["validation_patients"]}) + patient_to_split.update({patient_id: "test" for patient_id in split_payload["test_patients"]}) + + summary_rows: list[dict[str, object]] = [] + output_paths: dict[str, str] = {} + + for task_name, subset_file in TASK_SPECS.items(): + subset_path = DATASET_INDEX_ROOT / subset_file + task_df = pd.read_parquet(subset_path).copy() + task_df["target_binary"] = task_df["class_id"].eq(7).astype(int) + task_df["split_name"] = task_df["patient_id"].map(patient_to_split) + task_output_path = DATASET_INDEX_ROOT / f"task_{task_name}.parquet" + task_df.to_parquet(task_output_path, index=False) + output_paths[task_name] = str(task_output_path) + + class_grouped = ( + task_df.groupby(["split_name", "target_binary"]) + .size() + .reset_index(name="count") + .sort_values(["split_name", "target_binary"]) + ) + split_counts = { + row["split_name"]: { + "positive": int(class_grouped.loc[(class_grouped["split_name"] == row["split_name"]) & (class_grouped["target_binary"] == 1), "count"].sum()), + "negative": int(class_grouped.loc[(class_grouped["split_name"] == row["split_name"]) & (class_grouped["target_binary"] == 0), "count"].sum()), + } + for row in class_grouped.to_dict("records") + } + + hard_negative_rows = ( + task_df.loc[task_df["target_binary"].eq(0)] + .groupby(["class_id", "class_name"]) + .size() + .reset_index(name="count") + .sort_values(["count", "class_id"], ascending=[False, True]) + .head(10) + ) + + summary_rows.append( + { + "task_name": task_name, + "task_path": str(task_output_path), + "object_count": int(len(task_df)), + "positive_count": int(task_df["target_binary"].sum()), + "negative_count": int((1 - task_df["target_binary"]).sum()), + "positive_fraction": float(task_df["target_binary"].mean()), + "hard_negative_top10": json.dumps(hard_negative_rows.to_dict("records"), ensure_ascii=False), + "train_positive": split_counts.get("train", {}).get("positive", 0), + "train_negative": split_counts.get("train", {}).get("negative", 0), + "val_positive": split_counts.get("val", {}).get("positive", 0), + "val_negative": split_counts.get("val", {}).get("negative", 0), + "test_positive": split_counts.get("test", {}).get("positive", 0), + "test_negative": split_counts.get("test", {}).get("negative", 0), + } + ) + + summary_df = pd.DataFrame(summary_rows) + summary_df.to_csv(TABLES_ROOT / "task_prevalence_summary.csv", index=False) + + report_lines = [ + "# A4 Task Definition Report", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + "## Task summary", + "", + "| Task | Objects | Positive | Negative | Positive fraction |", + "|---|---:|---:|---:|---:|", + ] + for row in summary_rows: + report_lines.append( + f"| {row['task_name']} | {row['object_count']} | {row['positive_count']} | {row['negative_count']} | {row['positive_fraction']:.6f} |" + ) + write_markdown(REPORTS_ROOT / "task_definition_report.md", report_lines) + + output_paths["task_prevalence_summary_csv"] = str(TABLES_ROOT / "task_prevalence_summary.csv") + output_paths["task_definition_report_md"] = str(REPORTS_ROOT / "task_definition_report.md") + return output_paths + + +def main() -> None: + args = parse_args() + result = build_tasks(args.split_json) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/dataset/class_mapping.py b/src/dataset/class_mapping.py new file mode 100644 index 0000000..e8bf07e --- /dev/null +++ b/src/dataset/class_mapping.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +CLASS_WITH_ID_RE = re.compile(r"^\s*(?P\d+(?:\.0+)?)\s+(?P.+?)\s*$") + + +def parse_predefined_classes(path: Path) -> dict[int, str]: + """Parse predefined_classes.txt in either indexed or line-only format.""" + mapping: dict[int, str] = {} + next_index = 0 + + for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line: + continue + + match = CLASS_WITH_ID_RE.match(line) + if match: + class_id = int(float(match.group("class_id"))) + class_name = match.group("class_name").strip() + else: + class_id = next_index + class_name = line + + mapping[class_id] = class_name + next_index = max(next_index, class_id + 1) + + return mapping + + +def mapping_signature(mapping: dict[int, str]) -> tuple[tuple[int, str], ...]: + """Return a hashable signature for a class mapping.""" + return tuple(sorted(mapping.items())) + + +def normalize_mapping_for_json(mapping: dict[int, str]) -> dict[str, str]: + """Convert class mapping keys to strings for JSON serialization.""" + return {str(class_id): class_name for class_id, class_name in sorted(mapping.items())} diff --git a/src/dataset/make_group_splits.py b/src/dataset/make_group_splits.py new file mode 100644 index 0000000..bc533af --- /dev/null +++ b/src/dataset/make_group_splits.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from sklearn.model_selection import train_test_split + +from utils.project import REPORTS_ROOT, SPLITS_ROOT, TABLES_ROOT, ensure_output_layout, write_json, write_markdown + +import pandas as pd + + +PATIENT_SUMMARY_PATH = SPLITS_ROOT / "patient_summary.csv" +OBJECT_INDEX_PATH = Path(__file__).resolve().parents[2] / "outputs" / "dataset_index" / "object_index.parquet" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Create patient-level split_v1 for A2.") + parser.add_argument("--patient-summary", type=Path, default=PATIENT_SUMMARY_PATH) + parser.add_argument("--object-index", type=Path, default=OBJECT_INDEX_PATH) + parser.add_argument("--random-state", type=int, default=42) + return parser.parse_args() + + +def _stratified_patient_split(patient_summary_df: pd.DataFrame, random_state: int) -> tuple[list[str], list[str], list[str]]: + patient_ids = patient_summary_df["patient_id"].to_numpy() + strata = patient_summary_df["patient_has_blast"].astype(int).to_numpy() + + train_ids, temp_ids, train_y, temp_y = train_test_split( + patient_ids, + strata, + test_size=0.30, + random_state=random_state, + stratify=strata, + ) + val_ids, test_ids = train_test_split( + temp_ids, + test_size=0.50, + random_state=random_state, + stratify=temp_y, + ) + return sorted(train_ids.tolist()), sorted(val_ids.tolist()), sorted(test_ids.tolist()) + + +def _split_image_counts(object_df: pd.DataFrame) -> pd.Series: + image_counts = ( + object_df[["split_name", "patient_id", "image_id"]] + .drop_duplicates() + .groupby("split_name") + .size() + ) + return image_counts.astype(int) + + +def make_group_splits(patient_summary_path: Path, object_index_path: Path, random_state: int = 42) -> dict[str, str]: + ensure_output_layout() + patient_summary_df = pd.read_csv(patient_summary_path) + object_df = pd.read_parquet(object_index_path) + object_df = object_df.loc[object_df["include_in_main_protocol"].eq(True)].copy() + + train_ids, val_ids, test_ids = _stratified_patient_split(patient_summary_df, random_state=random_state) + + split_payload = { + "version": "split_v1", + "random_state": random_state, + "train_patients": train_ids, + "validation_patients": val_ids, + "test_patients": test_ids, + } + + split_json_path = SPLITS_ROOT / "split_v1.json" + write_json(split_json_path, split_payload) + for name, values in [("train", train_ids), ("val", val_ids), ("test", test_ids)]: + path = SPLITS_ROOT / f"split_v1_{name}_patients.txt" + path.write_text("\n".join(values) + "\n", encoding="utf-8") + + patient_to_split = {patient_id: "train" for patient_id in train_ids} + patient_to_split.update({patient_id: "val" for patient_id in val_ids}) + patient_to_split.update({patient_id: "test" for patient_id in test_ids}) + object_df["split_name"] = object_df["patient_id"].map(patient_to_split) + + class_distribution = ( + object_df.groupby(["split_name", "class_id", "class_name"]) + .size() + .reset_index(name="count") + .sort_values(["split_name", "count", "class_id"], ascending=[True, False, True]) + ) + class_distribution.to_csv(TABLES_ROOT / "class_distribution_by_split.csv", index=False) + + split_summary = ( + object_df.groupby("split_name") + .agg( + object_count=("object_id_within_image", "size"), + blast_count=("is_blast", "sum"), + active_patient_count=("patient_id", "nunique"), + ) + .reset_index() + ) + split_summary["image_count"] = split_summary["split_name"].map(_split_image_counts(object_df)) + split_summary["blast_fraction"] = split_summary["blast_count"] / split_summary["object_count"] + split_summary["patient_count"] = split_summary["split_name"].map( + {"train": len(train_ids), "val": len(val_ids), "test": len(test_ids)} + ) + split_summary["zero_object_patient_count"] = split_summary["patient_count"] - split_summary["active_patient_count"] + split_summary.to_csv(TABLES_ROOT / "split_summary.csv", index=False) + + report_lines = [ + "# A2 Split QC Report", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + f"- Random state: `{random_state}`", + f"- Train patients: `{len(train_ids)}`", + f"- Validation patients: `{len(val_ids)}`", + f"- Test patients: `{len(test_ids)}`", + "", + "## Split-level summary", + "", + "| Split | Patients in split list | Patients with objects | Zero-object patients | Images | Objects | Blast objects | Blast fraction |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ] + for row in split_summary.to_dict("records"): + report_lines.append( + f"| {row['split_name']} | {int(row['patient_count'])} | {int(row['active_patient_count'])} | {int(row['zero_object_patient_count'])} | " + f"{int(row['image_count'])} | {int(row['object_count'])} | {int(row['blast_count'])} | {float(row['blast_fraction']):.6f} |" + ) + + intersections = { + "train_val": sorted(set(train_ids) & set(val_ids)), + "train_test": sorted(set(train_ids) & set(test_ids)), + "val_test": sorted(set(val_ids) & set(test_ids)), + } + report_lines.extend( + [ + "", + "## Intersection checks", + "", + f"- train ∩ val: `{len(intersections['train_val'])}`", + f"- train ∩ test: `{len(intersections['train_test'])}`", + f"- val ∩ test: `{len(intersections['val_test'])}`", + ] + ) + write_markdown(REPORTS_ROOT / "a2_split_qc.md", report_lines) + + return { + "split_json": str(split_json_path), + "split_summary_csv": str(TABLES_ROOT / "split_summary.csv"), + "class_distribution_csv": str(TABLES_ROOT / "class_distribution_by_split.csv"), + "qc_report_md": str(REPORTS_ROOT / "a2_split_qc.md"), + } + + +def main() -> None: + args = parse_args() + result = make_group_splits(args.patient_summary, args.object_index, args.random_state) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/dataset/parse_detection_labels.py b/src/dataset/parse_detection_labels.py new file mode 100644 index 0000000..52e1412 --- /dev/null +++ b/src/dataset/parse_detection_labels.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + + +def normalize_class_id(raw_class_id: str) -> tuple[int, bool]: + """Normalize class id and report whether it was stored as a float-like token.""" + class_id = int(float(raw_class_id)) + was_float_like = raw_class_id.strip() != str(class_id) + return class_id, was_float_like + + +def parse_label_file(label_path: Path) -> list[dict[str, object]]: + """Parse YOLO-like detection labels into structured rows.""" + rows: list[dict[str, object]] = [] + + for object_id, raw_line in enumerate( + line for line in label_path.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip() + ): + parts = raw_line.split() + if len(parts) < 5: + raise ValueError(f"Malformed detection line in {label_path}: {raw_line!r}") + + class_id, class_id_was_float = normalize_class_id(parts[0]) + rows.append( + { + "object_id_within_image": object_id, + "raw_class_id": parts[0], + "class_id": class_id, + "class_id_was_float": class_id_was_float, + "x_center_norm": float(parts[1]), + "y_center_norm": float(parts[2]), + "width_norm": float(parts[3]), + "height_norm": float(parts[4]), + "raw_line": raw_line, + } + ) + + return rows diff --git a/src/dataset/parse_masks.py b/src/dataset/parse_masks.py new file mode 100644 index 0000000..7a6b2ca --- /dev/null +++ b/src/dataset/parse_masks.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +MASK_FILENAME_RE = re.compile( + r"^(?P.+?)_class_(?P[^_]+)_obj_(?P\d+)\.png$" +) + + +def parse_mask_filename(mask_path: Path) -> dict[str, object] | None: + """Parse mask filename into image id, class id and object index.""" + match = MASK_FILENAME_RE.match(mask_path.name) + if not match: + return None + + return { + "image_id": match.group("image_id"), + "class_id": int(float(match.group("class_id"))), + "object_id_within_image": int(match.group("object_id")), + "mask_path": str(mask_path.resolve()), + } + + +def build_mask_index(mask_dir: Path) -> dict[str, list[dict[str, object]]]: + """Build an image_id -> sorted mask entries index.""" + index: dict[str, list[dict[str, object]]] = {} + if not mask_dir.exists(): + return index + + for mask_path in sorted(mask_dir.glob("*.png")): + parsed = parse_mask_filename(mask_path) + if parsed is None: + continue + + image_id = str(parsed["image_id"]) + index.setdefault(image_id, []).append(parsed) + + for image_id, entries in index.items(): + entries.sort(key=lambda item: int(item["object_id_within_image"])) + + return index diff --git a/src/evaluation/__init__.py b/src/evaluation/__init__.py new file mode 100644 index 0000000..c7e335d --- /dev/null +++ b/src/evaluation/__init__.py @@ -0,0 +1 @@ +"""Evaluation utilities for experiment E01.""" diff --git a/src/evaluation/aggregate_scores.py b/src/evaluation/aggregate_scores.py new file mode 100644 index 0000000..e9c2be0 --- /dev/null +++ b/src/evaluation/aggregate_scores.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Iterable + +import numpy as np +import pandas as pd + +from evaluation.metrics import compute_binary_metrics, ranking_recall_curve, recall_at_fixed_fpr + + +def _soft_noisy_or(scores: Iterable[float]) -> float: + values = np.clip(np.asarray(list(scores), dtype=float), 0.0, 1.0) + if len(values) == 0: + return 0.0 + return float(1.0 - np.prod(1.0 - values)) + + +def aggregate_object_scores( + prediction_df: pd.DataFrame, + threshold: float, + group_columns: list[str], + target_column: str, +) -> pd.DataFrame: + rows: list[dict[str, object]] = [] + for keys, group in prediction_df.groupby(group_columns, sort=False): + scores = group["y_score"].astype(float).to_numpy() + scores_sorted = np.sort(scores)[::-1] + top3 = scores_sorted[: min(3, len(scores_sorted))] + group_key = keys if isinstance(keys, tuple) else (keys,) + key_payload = dict(zip(group_columns, group_key, strict=True)) + image_contains_blast = int(group[target_column].max()) + rows.extend( + [ + { + **key_payload, + "aggregation_method": "max_object_score", + "score": float(scores.max()), + target_column: image_contains_blast, + "n_objects": int(len(scores)), + }, + { + **key_payload, + "aggregation_method": "mean_top3_scores", + "score": float(np.mean(top3)), + target_column: image_contains_blast, + "n_objects": int(len(scores)), + }, + { + **key_payload, + "aggregation_method": "count_objects_above_threshold", + "score": float(np.sum(scores >= threshold)), + target_column: image_contains_blast, + "n_objects": int(len(scores)), + }, + { + **key_payload, + "aggregation_method": "soft_noisy_or", + "score": _soft_noisy_or(scores), + target_column: image_contains_blast, + "n_objects": int(len(scores)), + }, + ] + ) + return pd.DataFrame(rows) + + +def compute_aggregate_metrics(score_df: pd.DataFrame, target_column: str) -> pd.DataFrame: + rows: list[dict[str, object]] = [] + for method, group in score_df.groupby("aggregation_method", sort=False): + y_true = group[target_column].astype(int).to_numpy() + y_score = group["score"].astype(float).to_numpy() + if len(y_score) == 0: + continue + if float(np.nanmax(y_score)) > 1.0 or float(np.nanmin(y_score)) < 0.0: + score_min = float(np.nanmin(y_score)) + score_max = float(np.nanmax(y_score)) + if score_max > score_min: + y_score_prob = (y_score - score_min) / (score_max - score_min) + else: + y_score_prob = np.zeros_like(y_score, dtype=float) + else: + y_score_prob = y_score + threshold = float(np.median(y_score_prob)) + metrics = compute_binary_metrics(y_true, y_score_prob, threshold) + screened_fraction, cumulative_recall = ranking_recall_curve(y_true, y_score) + rows.append( + { + "aggregation_method": method, + "n_groups": int(len(group)), + "positive_groups": int(np.sum(y_true)), + "pr_auc": metrics.pr_auc, + "roc_auc": metrics.roc_auc, + "balanced_accuracy": metrics.balanced_accuracy, + "recall_blast": metrics.recall_blast, + "specificity": metrics.specificity, + "matthews_corrcoef": metrics.matthews_corrcoef, + "brier_score": metrics.brier_score, + "expected_calibration_error": metrics.expected_calibration_error, + "recall_at_fpr_10pct": recall_at_fixed_fpr(y_true, y_score_prob, max_fpr=0.10), + "top_10pct_recall": float(cumulative_recall[np.searchsorted(screened_fraction, 0.10, side="left")]) + if len(cumulative_recall) + else float("nan"), + "top_20pct_recall": float(cumulative_recall[np.searchsorted(screened_fraction, 0.20, side="left")]) + if len(cumulative_recall) + else float("nan"), + } + ) + return pd.DataFrame(rows) diff --git a/src/evaluation/hypothesis_figures.py b/src/evaluation/hypothesis_figures.py new file mode 100644 index 0000000..0e11fa5 --- /dev/null +++ b/src/evaluation/hypothesis_figures.py @@ -0,0 +1,860 @@ +from __future__ import annotations + +import os +from pathlib import Path + +MPLCONFIGDIR = Path(__file__).resolve().parents[2] / "outputs" / "logs" / "matplotlib" +MPLCONFIGDIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from sklearn.metrics import ConfusionMatrixDisplay, PrecisionRecallDisplay, RocCurveDisplay, confusion_matrix + +from evaluation.metrics import ranking_recall_curve +from utils.project import FIGURES_ROOT, ensure_dir + + +TASK_LABELS = { + "a41_clean_binary": "A4.1 clean cell", + "a42_strict_binary": "A4.2 strict morphology", + "a43_full_realistic_binary": "A4.3 full realistic", +} +TASK_ORDER = list(TASK_LABELS.keys()) +FEATURE_GROUP_ORDER = [ + "morphology", + "color", + "texture", + "morphology_color", + "morphology_texture", + "color_texture", + "full", +] +MODEL_ORDER = [ + "logistic_regression", + "linear_svm", + "rbf_svm", + "random_forest", + "xgboost", +] +MASK_LABELS = { + "bbox_only": "bbox only", + "mask_only": "mask only", + "bbox_plus_mask": "bbox + mask", +} +MASK_ORDER = list(MASK_LABELS.keys()) +REGIME_LABELS = { + "combined_with_class_weight": "combined + class weight", + "combined_without_class_weight": "combined without class weight", + "combined_top30_features": "combined top-30 features", +} +REGIME_ORDER = list(REGIME_LABELS.keys()) +AGGREGATION_LABELS = { + "max_object_score": "max object score", + "mean_top3_scores": "mean top-3 scores", + "count_objects_above_threshold": "count > threshold", + "soft_noisy_or": "soft noisy-or", +} +AGGREGATION_ORDER = list(AGGREGATION_LABELS.keys()) +METRIC_LABELS = { + "pr_auc": "PR-AUC", + "roc_auc": "ROC-AUC", + "balanced_accuracy": "Balanced accuracy", + "recall_blast": "Blast recall", + "specificity": "Specificity", + "matthews_corrcoef": "MCC", + "brier_score": "Brier score", + "expected_calibration_error": "ECE", + "recall_at_fpr_10pct": "Recall at FPR 10%", + "top_10pct_recall": "Recall in top-10%", + "top_20pct_recall": "Recall in top-20%", +} + + +def _save(fig: plt.Figure, path: Path) -> str: + ensure_dir(path.parent) + fig.tight_layout() + fig.savefig(path, dpi=180, bbox_inches="tight") + plt.close(fig) + return str(path) + + +def _figure_path(hypothesis: str, date_tag: str, index: int, slug: str) -> Path: + ensure_dir(FIGURES_ROOT) + return FIGURES_ROOT / f"{hypothesis}_{date_tag}_{index:02d}_{slug}.png" + + +def _labeled_categories(values: list[str], mapping: dict[str, str]) -> list[str]: + return [mapping.get(value, value) for value in values] + + +def _prepare_metric_frame( + df: pd.DataFrame, + category_col: str, + value_col: str, + order: list[str] | None = None, +) -> pd.DataFrame: + frame = df[[category_col, value_col]].copy() + frame[value_col] = frame[value_col].astype(float) + if order is not None: + frame[category_col] = pd.Categorical(frame[category_col], categories=order, ordered=True) + frame = frame.sort_values(category_col) + return frame + + +def _save_simple_bar( + df: pd.DataFrame, + category_col: str, + value_col: str, + path: Path, + title: str, + y_label: str, + order: list[str] | None = None, + label_map: dict[str, str] | None = None, + color: str = "#4c72b0", +) -> str: + frame = _prepare_metric_frame(df, category_col, value_col, order=order) + categories = frame[category_col].astype(str).tolist() + labels = _labeled_categories(categories, label_map or {}) + values = frame[value_col].astype(float).to_numpy() + + fig, ax = plt.subplots(figsize=(10, 6)) + bars = ax.bar(labels, values, color=color) + ax.set_title(title) + ax.set_ylabel(y_label) + ax.tick_params(axis="x", rotation=20) + for bar, value in zip(bars, values, strict=False): + vertical_align = "bottom" if value >= 0 else "top" + ax.text(bar.get_x() + bar.get_width() / 2.0, value, f"{value:.3f}", ha="center", va=vertical_align, fontsize=8) + return _save(fig, path) + + +def _save_grouped_bar( + df: pd.DataFrame, + category_col: str, + group_col: str, + value_col: str, + path: Path, + title: str, + y_label: str, + category_order: list[str] | None = None, + group_order: list[str] | None = None, + category_labels: dict[str, str] | None = None, + group_labels: dict[str, str] | None = None, +) -> str: + frame = df[[category_col, group_col, value_col]].copy() + frame[value_col] = frame[value_col].astype(float) + pivot = frame.pivot(index=category_col, columns=group_col, values=value_col) + if category_order is not None: + pivot = pivot.reindex(category_order) + if group_order is not None: + pivot = pivot.reindex(columns=group_order) + categories = [value for value in pivot.index.tolist() if value is not None] + groups = [value for value in pivot.columns.tolist() if value is not None] + x_positions = np.arange(len(categories)) + width = 0.8 / max(len(groups), 1) + + fig, ax = plt.subplots(figsize=(11, 6)) + for idx, group in enumerate(groups): + offsets = x_positions + (idx - (len(groups) - 1) / 2.0) * width + values = pivot[group].astype(float).to_numpy() + bars = ax.bar(offsets, values, width=width, label=(group_labels or {}).get(group, group)) + for bar, value in zip(bars, values, strict=False): + vertical_align = "bottom" if value >= 0 else "top" + ax.text(bar.get_x() + bar.get_width() / 2.0, value, f"{value:.3f}", ha="center", va=vertical_align, fontsize=7) + ax.set_xticks(x_positions) + ax.set_xticklabels(_labeled_categories(categories, category_labels or {}), rotation=15) + ax.set_title(title) + ax.set_ylabel(y_label) + ax.legend(fontsize=8) + return _save(fig, path) + + +def _save_heatmap( + frame: pd.DataFrame, + path: Path, + title: str, + x_label: str, + y_label: str, + value_fmt: str = ".3f", + cmap: str = "Blues", +) -> str: + matrix = frame.astype(float).to_numpy() + fig, ax = plt.subplots(figsize=(11, 6)) + image = ax.imshow(matrix, cmap=cmap, aspect="auto") + ax.set_xticks(np.arange(frame.shape[1])) + ax.set_yticks(np.arange(frame.shape[0])) + ax.set_xticklabels(frame.columns.tolist(), rotation=25, ha="right") + ax.set_yticklabels(frame.index.tolist()) + ax.set_title(title) + ax.set_xlabel(x_label) + ax.set_ylabel(y_label) + for row_idx in range(frame.shape[0]): + for col_idx in range(frame.shape[1]): + value = matrix[row_idx, col_idx] + if np.isnan(value): + continue + ax.text(col_idx, row_idx, format(value, value_fmt), ha="center", va="center", fontsize=8) + fig.colorbar(image, ax=ax, fraction=0.046, pad=0.04) + return _save(fig, path) + + +def _save_metric_heatmap( + df: pd.DataFrame, + index_col: str, + column_col: str, + value_col: str, + path: Path, + title: str, + index_order: list[str] | None = None, + column_order: list[str] | None = None, + index_labels: dict[str, str] | None = None, + column_labels: dict[str, str] | None = None, +) -> str: + pivot = df.pivot(index=index_col, columns=column_col, values=value_col) + if index_order is not None: + pivot = pivot.reindex(index_order) + if column_order is not None: + pivot = pivot.reindex(columns=column_order) + pivot.index = _labeled_categories([str(value) for value in pivot.index.tolist()], index_labels or {}) + pivot.columns = _labeled_categories([str(value) for value in pivot.columns.tolist()], column_labels or {}) + return _save_heatmap( + pivot, + path=path, + title=title, + x_label=column_col, + y_label=index_col, + ) + + +def _save_pr_or_roc_curves( + prediction_df: pd.DataFrame, + run_rows: pd.DataFrame, + path: Path, + curve_type: str, + title: str, +) -> str: + fig, ax = plt.subplots(figsize=(10, 8)) + for _, row in run_rows.iterrows(): + run_df = prediction_df.loc[prediction_df["run_id"].eq(row["run_id"])].copy() + label = f"{TASK_LABELS.get(str(row['task_name']), row['task_name'])} | {row['model_name']} | {row['feature_group']}" + if curve_type == "pr": + PrecisionRecallDisplay.from_predictions(run_df["y_true"], run_df["y_score"], name=label, ax=ax) + else: + RocCurveDisplay.from_predictions(run_df["y_true"], run_df["y_score"], name=label, ax=ax) + ax.set_title(title) + return _save(fig, path) + + +def _save_confusion_from_predictions(prediction_df: pd.DataFrame, path: Path, title: str) -> str: + fig, ax = plt.subplots(figsize=(5, 5)) + ConfusionMatrixDisplay( + confusion_matrix(prediction_df["y_true"], prediction_df["y_pred"], labels=[0, 1]), + display_labels=["non-blast", "blast"], + ).plot(ax=ax, colorbar=False) + ax.set_title(title) + return _save(fig, path) + + +def _save_metric_pair_bar( + df: pd.DataFrame, + category_col: str, + metric_a: str, + metric_b: str, + path: Path, + title: str, + category_order: list[str] | None = None, + category_labels: dict[str, str] | None = None, +) -> str: + frame = df[[category_col, metric_a, metric_b]].copy() + if category_order is not None: + frame[category_col] = pd.Categorical(frame[category_col], categories=category_order, ordered=True) + frame = frame.sort_values(category_col) + categories = frame[category_col].astype(str).tolist() + labels = _labeled_categories(categories, category_labels or {}) + values_a = frame[metric_a].astype(float).to_numpy() + values_b = frame[metric_b].astype(float).to_numpy() + + x_positions = np.arange(len(labels)) + width = 0.35 + fig, ax = plt.subplots(figsize=(10, 6)) + bars_a = ax.bar(x_positions - width / 2.0, values_a, width=width, label=METRIC_LABELS.get(metric_a, metric_a)) + bars_b = ax.bar(x_positions + width / 2.0, values_b, width=width, label=METRIC_LABELS.get(metric_b, metric_b)) + for bars, values in [(bars_a, values_a), (bars_b, values_b)]: + for bar, value in zip(bars, values, strict=False): + vertical_align = "bottom" if value >= 0 else "top" + ax.text(bar.get_x() + bar.get_width() / 2.0, value, f"{value:.3f}", ha="center", va=vertical_align, fontsize=8) + ax.set_xticks(x_positions) + ax.set_xticklabels(labels, rotation=15) + ax.set_title(title) + ax.set_ylabel("Score") + ax.legend(fontsize=8) + return _save(fig, path) + + +def _save_feature_barh( + df: pd.DataFrame, + value_col: str, + path: Path, + title: str, + x_label: str, +) -> str: + plot_df = df.head(20).iloc[::-1].copy() + values = plot_df[value_col].astype(float).to_numpy() + fig, ax = plt.subplots(figsize=(10, 8)) + ax.barh(plot_df["feature_name"], values, color="#55a868") + ax.set_title(title) + ax.set_xlabel(x_label) + return _save(fig, path) + + +def _save_delta_vs_reference( + df: pd.DataFrame, + category_col: str, + baseline_label: str, + compare_labels: list[str], + value_col: str, + path: Path, + title: str, + category_order: list[str] | None = None, + category_labels: dict[str, str] | None = None, + compare_label_map: dict[str, str] | None = None, +) -> str: + pivot = df.pivot(index=category_col, columns="label", values=value_col) + if category_order is not None: + pivot = pivot.reindex(category_order) + delta_df = pd.DataFrame(index=pivot.index) + for label in compare_labels: + delta_df[label] = pivot[label] - pivot[baseline_label] + delta_df = delta_df.reset_index().melt(id_vars=category_col, var_name="delta_label", value_name="delta_value") + return _save_grouped_bar( + delta_df, + category_col=category_col, + group_col="delta_label", + value_col="delta_value", + path=path, + title=title, + y_label=f"Delta {METRIC_LABELS.get(value_col, value_col)}", + category_order=category_order, + group_order=compare_labels, + category_labels=category_labels, + group_labels=compare_label_map, + ) + + +def _save_ranking_curve_panel( + score_df: pd.DataFrame, + target_column: str, + path: Path, + title: str, +) -> str: + fig, ax = plt.subplots(figsize=(8, 6)) + for method in AGGREGATION_ORDER: + method_df = score_df.loc[score_df["aggregation_method"].eq(method)].copy() + if method_df.empty: + continue + screened_fraction, cumulative_recall = ranking_recall_curve( + method_df[target_column].astype(int).to_numpy(), + method_df["score"].astype(float).to_numpy(), + ) + ax.plot(screened_fraction, cumulative_recall, label=AGGREGATION_LABELS.get(method, method)) + ax.set_xlabel("Screened fraction") + ax.set_ylabel("Recall of positive groups") + ax.set_title(title) + ax.legend(fontsize=8) + return _save(fig, path) + + +def _save_best_score_distributions( + image_scores_df: pd.DataFrame, + patient_scores_df: pd.DataFrame, + image_method: str, + patient_method: str, + path: Path, +) -> str: + image_frame = image_scores_df.loc[image_scores_df["aggregation_method"].eq(image_method)].copy() + patient_frame = patient_scores_df.loc[patient_scores_df["aggregation_method"].eq(patient_method)].copy() + + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + for axis, frame, target_col, method, panel_title in [ + (axes[0], image_frame, "image_contains_blast", image_method, "Best image-level method"), + (axes[1], patient_frame, "patient_contains_blast", patient_method, "Best patient-level method"), + ]: + negatives = frame.loc[frame[target_col].eq(0), "score"].astype(float).to_numpy() + positives = frame.loc[frame[target_col].eq(1), "score"].astype(float).to_numpy() + axis.hist(negatives, bins=20, alpha=0.65, label="negative", color="#c44e52") + axis.hist(positives, bins=20, alpha=0.65, label="positive", color="#4c72b0") + axis.set_title(f"{panel_title}\n{AGGREGATION_LABELS.get(method, method)}") + axis.set_xlabel("Aggregated score") + axis.set_ylabel("Count") + axis.legend(fontsize=8) + return _save(fig, path) + + +def _save_training_curves(history_payload: dict[str, list[dict[str, float]]], path: Path) -> str: + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + for model_name, history in history_payload.items(): + history_df = pd.DataFrame(history) + if history_df.empty: + continue + axes[0].plot(history_df["epoch"], history_df["train_loss"], label=f"{model_name} train") + axes[0].plot(history_df["epoch"], history_df["val_loss"], linestyle="--", label=f"{model_name} val") + axes[1].plot(history_df["epoch"], history_df["val_pr_auc"], label=model_name) + axes[0].set_title("Deep baseline loss") + axes[0].set_xlabel("Epoch") + axes[1].set_title("Validation PR-AUC") + axes[1].set_xlabel("Epoch") + for axis in axes: + axis.legend(fontsize=8) + return _save(fig, path) + + +def _save_deep_pr_curve_comparison( + handcrafted_predictions: pd.DataFrame, + deep_predictions: pd.DataFrame, + path: Path, + title: str, +) -> str: + fig, ax = plt.subplots(figsize=(10, 8)) + PrecisionRecallDisplay.from_predictions( + handcrafted_predictions["y_true"], + handcrafted_predictions["y_score"], + name="handcrafted best", + ax=ax, + ) + for model_name, frame in deep_predictions.groupby("model_name", sort=False): + PrecisionRecallDisplay.from_predictions(frame["y_true"], frame["y_score"], name=str(model_name), ax=ax) + ax.set_title(title) + return _save(fig, path) + + +def generate_h1_figures( + leaderboard_df: pd.DataFrame, + predictions_df: pd.DataFrame, + top_features_df: pd.DataFrame, + date_tag: str, +) -> list[str]: + best_rows = ( + leaderboard_df.sort_values(["task_name", "validation_pr_auc", "test_pr_auc"], ascending=[True, False, False]) + .groupby("task_name", sort=False) + .head(1) + .reset_index(drop=True) + ) + top_runs = ( + leaderboard_df.sort_values(["task_name", "validation_pr_auc", "test_pr_auc"], ascending=[True, False, False]) + .groupby("task_name", sort=False) + .head(2) + .reset_index(drop=True) + ) + best_full_row = leaderboard_df.loc[leaderboard_df["task_name"].eq("a43_full_realistic_binary")].sort_values( + ["validation_pr_auc", "test_pr_auc"], + ascending=[False, False], + ).iloc[0] + + figure_paths: list[str] = [] + figure_paths.append( + _save_pr_or_roc_curves( + prediction_df=predictions_df, + run_rows=top_runs, + path=_figure_path("H1", date_tag, 1, "pr_curves_top_runs"), + curve_type="pr", + title="H1: precision-recall curves for top object-level runs", + ) + ) + figure_paths.append( + _save_pr_or_roc_curves( + prediction_df=predictions_df, + run_rows=top_runs, + path=_figure_path("H1", date_tag, 2, "roc_curves_top_runs"), + curve_type="roc", + title="H1: ROC curves for top object-level runs", + ) + ) + + for idx, task_name in enumerate(TASK_ORDER, start=3): + task_frame = leaderboard_df.loc[leaderboard_df["task_name"].eq(task_name)].copy() + pivot = task_frame.pivot(index="model_name", columns="feature_group", values="test_pr_auc") + pivot = pivot.reindex(index=MODEL_ORDER, columns=FEATURE_GROUP_ORDER) + pivot.index = [str(value) for value in pivot.index.tolist()] + pivot.columns = [str(value) for value in pivot.columns.tolist()] + figure_paths.append( + _save_heatmap( + pivot, + path=_figure_path("H1", date_tag, idx, f"{task_name}_pr_auc_heatmap"), + title=f"H1: test PR-AUC for {TASK_LABELS.get(task_name, task_name)}", + x_label="Feature group", + y_label="Model", + ) + ) + + figure_paths.append( + _save_simple_bar( + df=best_rows, + category_col="task_name", + value_col="test_pr_auc", + path=_figure_path("H1", date_tag, 6, "best_pr_auc_by_task"), + title="H1: best object-level PR-AUC by task", + y_label="PR-AUC", + order=TASK_ORDER, + label_map=TASK_LABELS, + color="#8172b3", + ) + ) + figure_paths.append( + _save_metric_pair_bar( + df=best_rows, + category_col="task_name", + metric_a="test_recall_blast", + metric_b="test_specificity", + path=_figure_path("H1", date_tag, 7, "best_recall_specificity_by_task"), + title="H1: recall and specificity for the best run in each task", + category_order=TASK_ORDER, + category_labels=TASK_LABELS, + ) + ) + best_full_predictions = predictions_df.loc[ + predictions_df["run_id"].eq(best_full_row["run_id"]) & predictions_df["split_name"].eq("test") + ].copy() + figure_paths.append( + _save_confusion_from_predictions( + prediction_df=best_full_predictions, + path=_figure_path("H1", date_tag, 8, "best_full_realistic_confusion"), + title=f"H1: confusion matrix for {best_full_row['model_name']} + {best_full_row['feature_group']}", + ) + ) + figure_paths.append( + _save_feature_barh( + df=top_features_df, + value_col="top20_frequency", + path=_figure_path("H1", date_tag, 9, "top_feature_frequency"), + title="H1: stability of top features", + x_label="Top-20 frequency", + ) + ) + figure_paths.append( + _save_feature_barh( + df=top_features_df.sort_values(["mean_importance", "top20_frequency"], ascending=[False, False]), + value_col="mean_importance", + path=_figure_path("H1", date_tag, 10, "top_feature_importance"), + title="H1: mean permutation importance of top features", + x_label="Mean importance", + ) + ) + return figure_paths + + +def generate_h2_figures(mask_ablation_df: pd.DataFrame, date_tag: str) -> list[str]: + metric_specs = [ + (1, "pr_auc", "H2: PR-AUC for bbox and mask feature variants", "PR-AUC"), + (2, "roc_auc", "H2: ROC-AUC for bbox and mask feature variants", "ROC-AUC"), + (3, "balanced_accuracy", "H2: balanced accuracy for bbox and mask feature variants", "Balanced accuracy"), + (4, "recall_blast", "H2: blast recall for bbox and mask feature variants", "Blast recall"), + (5, "specificity", "H2: specificity for bbox and mask feature variants", "Specificity"), + (6, "matthews_corrcoef", "H2: MCC for bbox and mask feature variants", "MCC"), + (7, "brier_score", "H2: Brier score for bbox and mask feature variants", "Brier score"), + (8, "expected_calibration_error", "H2: calibration error for bbox and mask feature variants", "ECE"), + ] + figure_paths: list[str] = [] + for index, metric, title, y_label in metric_specs: + figure_paths.append( + _save_grouped_bar( + df=mask_ablation_df, + category_col="task_name", + group_col="label", + value_col=metric, + path=_figure_path("H2", date_tag, index, metric), + title=title, + y_label=y_label, + category_order=TASK_ORDER, + group_order=MASK_ORDER, + category_labels=TASK_LABELS, + group_labels=MASK_LABELS, + ) + ) + figure_paths.append( + _save_delta_vs_reference( + df=mask_ablation_df, + category_col="task_name", + baseline_label="bbox_only", + compare_labels=["mask_only", "bbox_plus_mask"], + value_col="pr_auc", + path=_figure_path("H2", date_tag, 9, "pr_auc_delta_vs_bbox"), + title="H2: PR-AUC gain relative to bbox-only features", + category_order=TASK_ORDER, + category_labels=TASK_LABELS, + compare_label_map={ + "mask_only": "mask only - bbox only", + "bbox_plus_mask": "bbox + mask - bbox only", + }, + ) + ) + figure_paths.append( + _save_metric_heatmap( + df=mask_ablation_df, + index_col="task_name", + column_col="label", + value_col="pr_auc", + path=_figure_path("H2", date_tag, 10, "pr_auc_heatmap"), + title="H2: PR-AUC heatmap for mask ablation", + index_order=TASK_ORDER, + column_order=MASK_ORDER, + index_labels=TASK_LABELS, + column_labels=MASK_LABELS, + ) + ) + return figure_paths + + +def generate_h3_figures(dataset_ablation_df: pd.DataFrame, date_tag: str) -> list[str]: + primary_df = dataset_ablation_df.loc[dataset_ablation_df["label"].eq("combined_with_class_weight")].copy() + extra_df = dataset_ablation_df.loc[dataset_ablation_df["task_name"].eq("a43_full_realistic_binary")].copy() + metric_specs = [ + (1, "pr_auc", "H3: PR-AUC across corpus regimes", "PR-AUC"), + (2, "roc_auc", "H3: ROC-AUC across corpus regimes", "ROC-AUC"), + (3, "balanced_accuracy", "H3: balanced accuracy across corpus regimes", "Balanced accuracy"), + (4, "recall_blast", "H3: blast recall across corpus regimes", "Blast recall"), + (5, "specificity", "H3: specificity across corpus regimes", "Specificity"), + (6, "matthews_corrcoef", "H3: MCC across corpus regimes", "MCC"), + (7, "brier_score", "H3: Brier score across corpus regimes", "Brier score"), + (8, "expected_calibration_error", "H3: calibration error across corpus regimes", "ECE"), + ] + figure_paths: list[str] = [] + for index, metric, title, y_label in metric_specs: + figure_paths.append( + _save_simple_bar( + df=primary_df, + category_col="task_name", + value_col=metric, + path=_figure_path("H3", date_tag, index, metric), + title=title, + y_label=y_label, + order=TASK_ORDER, + label_map=TASK_LABELS, + color="#64b5cd", + ) + ) + heatmap_df = primary_df.set_index("task_name")[ + ["pr_auc", "roc_auc", "balanced_accuracy", "recall_blast", "specificity"] + ].reindex(TASK_ORDER) + heatmap_df.index = _labeled_categories([str(value) for value in heatmap_df.index.tolist()], TASK_LABELS) + heatmap_df.columns = [METRIC_LABELS.get(column, column) for column in heatmap_df.columns.tolist()] + figure_paths.append( + _save_heatmap( + heatmap_df, + path=_figure_path("H3", date_tag, 9, "primary_metrics_heatmap"), + title="H3: metric heatmap for the main corpus regimes", + x_label="Metric", + y_label="Task", + ) + ) + figure_paths.append( + _save_simple_bar( + df=extra_df, + category_col="label", + value_col="pr_auc", + path=_figure_path("H3", date_tag, 10, "full_realistic_variants_pr_auc"), + title="H3: full-realistic ablation variants", + y_label="PR-AUC", + order=REGIME_ORDER, + label_map=REGIME_LABELS, + color="#dd8452", + ) + ) + return figure_paths + + +def generate_h4_figures( + image_metrics_df: pd.DataFrame, + patient_metrics_df: pd.DataFrame, + image_scores_df: pd.DataFrame, + patient_scores_df: pd.DataFrame, + date_tag: str, +) -> list[str]: + best_image_method = ( + image_metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0]["aggregation_method"] + ) + best_patient_method = ( + patient_metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0]["aggregation_method"] + ) + figure_paths: list[str] = [] + figure_paths.append( + _save_simple_bar( + df=image_metrics_df, + category_col="aggregation_method", + value_col="pr_auc", + path=_figure_path("H4", date_tag, 1, "image_pr_auc"), + title="H4: image-level PR-AUC by aggregation method", + y_label="PR-AUC", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#4c72b0", + ) + ) + figure_paths.append( + _save_simple_bar( + df=image_metrics_df, + category_col="aggregation_method", + value_col="roc_auc", + path=_figure_path("H4", date_tag, 2, "image_roc_auc"), + title="H4: image-level ROC-AUC by aggregation method", + y_label="ROC-AUC", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#55a868", + ) + ) + figure_paths.append( + _save_simple_bar( + df=image_metrics_df, + category_col="aggregation_method", + value_col="balanced_accuracy", + path=_figure_path("H4", date_tag, 3, "image_balanced_accuracy"), + title="H4: image-level balanced accuracy by aggregation method", + y_label="Balanced accuracy", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#c44e52", + ) + ) + figure_paths.append( + _save_simple_bar( + df=image_metrics_df, + category_col="aggregation_method", + value_col="recall_at_fpr_10pct", + path=_figure_path("H4", date_tag, 4, "image_recall_at_fpr10"), + title="H4: image-level recall at FPR 10%", + y_label="Recall at FPR 10%", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#8172b3", + ) + ) + figure_paths.append( + _save_ranking_curve_panel( + score_df=image_scores_df, + target_column="image_contains_blast", + path=_figure_path("H4", date_tag, 5, "image_ranking_curve"), + title="H4: image-level ranking recall curves", + ) + ) + figure_paths.append( + _save_simple_bar( + df=patient_metrics_df, + category_col="aggregation_method", + value_col="pr_auc", + path=_figure_path("H4", date_tag, 6, "patient_pr_auc"), + title="H4: patient-level PR-AUC by aggregation method", + y_label="PR-AUC", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#4c72b0", + ) + ) + figure_paths.append( + _save_simple_bar( + df=patient_metrics_df, + category_col="aggregation_method", + value_col="roc_auc", + path=_figure_path("H4", date_tag, 7, "patient_roc_auc"), + title="H4: patient-level ROC-AUC by aggregation method", + y_label="ROC-AUC", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#55a868", + ) + ) + figure_paths.append( + _save_simple_bar( + df=patient_metrics_df, + category_col="aggregation_method", + value_col="balanced_accuracy", + path=_figure_path("H4", date_tag, 8, "patient_balanced_accuracy"), + title="H4: patient-level balanced accuracy by aggregation method", + y_label="Balanced accuracy", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#c44e52", + ) + ) + figure_paths.append( + _save_simple_bar( + df=patient_metrics_df, + category_col="aggregation_method", + value_col="recall_at_fpr_10pct", + path=_figure_path("H4", date_tag, 9, "patient_recall_at_fpr10"), + title="H4: patient-level recall at FPR 10%", + y_label="Recall at FPR 10%", + order=AGGREGATION_ORDER, + label_map=AGGREGATION_LABELS, + color="#8172b3", + ) + ) + figure_paths.append( + _save_best_score_distributions( + image_scores_df=image_scores_df, + patient_scores_df=patient_scores_df, + image_method=str(best_image_method), + patient_method=str(best_patient_method), + path=_figure_path("H4", date_tag, 10, "best_score_distributions"), + ) + ) + return figure_paths + + +def generate_h5_figures( + comparison_df: pd.DataFrame, + handcrafted_predictions: pd.DataFrame, + deep_predictions: pd.DataFrame, + training_history: dict[str, list[dict[str, float]]], + date_tag: str, +) -> list[str]: + metric_specs = [ + (1, "test_pr_auc", "H5: PR-AUC of handcrafted and deep baselines", "PR-AUC"), + (2, "test_roc_auc", "H5: ROC-AUC of handcrafted and deep baselines", "ROC-AUC"), + (3, "test_balanced_accuracy", "H5: balanced accuracy of handcrafted and deep baselines", "Balanced accuracy"), + (4, "test_recall_blast", "H5: blast recall of handcrafted and deep baselines", "Blast recall"), + (5, "test_specificity", "H5: specificity of handcrafted and deep baselines", "Specificity"), + (6, "test_matthews_corrcoef", "H5: MCC of handcrafted and deep baselines", "MCC"), + (7, "test_brier_score", "H5: Brier score of handcrafted and deep baselines", "Brier score"), + (8, "test_expected_calibration_error", "H5: calibration error of handcrafted and deep baselines", "ECE"), + ] + figure_paths: list[str] = [] + model_order = comparison_df["model_name"].astype(str).tolist() + label_map = { + "handcrafted_best_full_realistic": "handcrafted best", + "cnn_bbox": "CNN bbox", + "cnn_mask": "CNN mask", + "cnn_late_fusion": "CNN late fusion", + } + for index, metric, title, y_label in metric_specs: + figure_paths.append( + _save_simple_bar( + df=comparison_df.rename(columns={metric: "metric_value"}), + category_col="model_name", + value_col="metric_value", + path=_figure_path("H5", date_tag, index, metric.replace("test_", "")), + title=title, + y_label=y_label, + order=model_order, + label_map=label_map, + color="#937860", + ) + ) + figure_paths.append( + _save_training_curves( + history_payload=training_history, + path=_figure_path("H5", date_tag, 9, "training_curves"), + ) + ) + figure_paths.append( + _save_deep_pr_curve_comparison( + handcrafted_predictions=handcrafted_predictions, + deep_predictions=deep_predictions, + path=_figure_path("H5", date_tag, 10, "precision_recall_curves"), + title="H5: precision-recall comparison of handcrafted and deep baselines", + ) + ) + return figure_paths diff --git a/src/evaluation/metrics.py b/src/evaluation/metrics.py new file mode 100644 index 0000000..af8d43e --- /dev/null +++ b/src/evaluation/metrics.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from sklearn.metrics import ( + average_precision_score, + balanced_accuracy_score, + brier_score_loss, + confusion_matrix, + f1_score, + matthews_corrcoef, + roc_auc_score, + roc_curve, +) + + +@dataclass(frozen=True) +class BinaryMetrics: + pr_auc: float + roc_auc: float + balanced_accuracy: float + macro_f1: float + recall_blast: float + specificity: float + matthews_corrcoef: float + brier_score: float + expected_calibration_error: float + + +def expected_calibration_error(y_true: np.ndarray, y_score: np.ndarray, bins: int = 10) -> float: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + bin_edges = np.linspace(0.0, 1.0, bins + 1) + ece = 0.0 + for lower, upper in zip(bin_edges[:-1], bin_edges[1:]): + if upper == 1.0: + mask = (y_score >= lower) & (y_score <= upper) + else: + mask = (y_score >= lower) & (y_score < upper) + if not np.any(mask): + continue + accuracy = float(np.mean(y_true[mask])) + confidence = float(np.mean(y_score[mask])) + weight = float(np.mean(mask)) + ece += abs(accuracy - confidence) * weight + return float(ece) + + +def optimize_threshold(y_true: np.ndarray, y_score: np.ndarray) -> float: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + candidate_thresholds = np.unique(np.clip(y_score, 0.0, 1.0)) + candidate_thresholds = np.concatenate(([0.0], candidate_thresholds, [1.0])) + + best_threshold = 0.5 + best_value = -np.inf + for threshold in candidate_thresholds: + y_pred = (y_score >= threshold).astype(int) + value = matthews_corrcoef(y_true, y_pred) + if np.isnan(value): + value = -1.0 + if value > best_value: + best_value = value + best_threshold = float(threshold) + return best_threshold + + +def compute_binary_metrics(y_true: np.ndarray, y_score: np.ndarray, threshold: float) -> BinaryMetrics: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + y_pred = (y_score >= threshold).astype(int) + + try: + roc_auc = float(roc_auc_score(y_true, y_score)) + except ValueError: + roc_auc = float("nan") + + try: + pr_auc = float(average_precision_score(y_true, y_score)) + except ValueError: + pr_auc = float("nan") + + balanced_accuracy = float(balanced_accuracy_score(y_true, y_pred)) + macro_f1 = float(f1_score(y_true, y_pred, average="macro", zero_division=0)) + matthews = float(matthews_corrcoef(y_true, y_pred)) + brier = float(brier_score_loss(y_true, y_score)) + ece = expected_calibration_error(y_true, y_score) + + tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel() + recall_blast = float(tp / (tp + fn)) if (tp + fn) > 0 else 0.0 + specificity = float(tn / (tn + fp)) if (tn + fp) > 0 else 0.0 + + return BinaryMetrics( + pr_auc=pr_auc, + roc_auc=roc_auc, + balanced_accuracy=balanced_accuracy, + macro_f1=macro_f1, + recall_blast=recall_blast, + specificity=specificity, + matthews_corrcoef=matthews, + brier_score=brier, + expected_calibration_error=ece, + ) + + +def bootstrap_metric_interval( + y_true: np.ndarray, + y_score: np.ndarray, + threshold: float, + n_bootstrap: int = 100, + random_state: int = 42, +) -> dict[str, tuple[float, float]]: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + rng = np.random.default_rng(random_state) + metric_samples: dict[str, list[float]] = { + "pr_auc": [], + "roc_auc": [], + "balanced_accuracy": [], + "macro_f1": [], + "recall_blast": [], + "specificity": [], + "matthews_corrcoef": [], + "brier_score": [], + "expected_calibration_error": [], + } + + if len(y_true) == 0: + return {name: (float("nan"), float("nan")) for name in metric_samples} + + for _ in range(n_bootstrap): + sample_idx = rng.integers(0, len(y_true), size=len(y_true)) + sample_true = y_true[sample_idx] + sample_score = y_score[sample_idx] + if len(np.unique(sample_true)) < 2: + continue + metrics = compute_binary_metrics(sample_true, sample_score, threshold) + for name in metric_samples: + metric_samples[name].append(float(getattr(metrics, name))) + + result: dict[str, tuple[float, float]] = {} + for name, values in metric_samples.items(): + if not values: + result[name] = (float("nan"), float("nan")) + continue + result[name] = ( + float(np.quantile(values, 0.025)), + float(np.quantile(values, 0.975)), + ) + return result + + +def recall_at_fixed_fpr(y_true: np.ndarray, y_score: np.ndarray, max_fpr: float = 0.10) -> float: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + if len(np.unique(y_true)) < 2: + return float("nan") + fpr, tpr, _ = roc_curve(y_true, y_score) + mask = fpr <= max_fpr + if not np.any(mask): + return 0.0 + return float(np.max(tpr[mask])) + + +def ranking_recall_curve(y_true: np.ndarray, y_score: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + y_true = np.asarray(y_true, dtype=int) + y_score = np.asarray(y_score, dtype=float) + order = np.argsort(-y_score) + y_true_sorted = y_true[order] + positive_total = max(int(np.sum(y_true_sorted)), 1) + cumulative_recall = np.cumsum(y_true_sorted) / positive_total + screened_fraction = (np.arange(len(y_true_sorted)) + 1) / max(len(y_true_sorted), 1) + return screened_fraction.astype(float), cumulative_recall.astype(float) diff --git a/src/evaluation/subject_figure_data.py b/src/evaluation/subject_figure_data.py new file mode 100644 index 0000000..179b80d --- /dev/null +++ b/src/evaluation/subject_figure_data.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd + +from models.baseline_pipeline import KEY_COLUMNS +from utils.project import CROPS_ROOT, DATASET_INDEX_ROOT, FEATURES_ROOT, LOGS_ROOT, PREDICTIONS_ROOT, TABLES_ROOT + + +HYPOTHESIS_SUBJECT_SPECS = { + "H1": {"slug": "interpretable_features", "title": "Interpretable object-level features"}, + "H2": {"slug": "mask_effect", "title": "Mask-aware cell representations"}, + "H3": {"slug": "dataset_regimes", "title": "Corpus regime difficulty"}, + "H4": {"slug": "aggregation", "title": "Aggregation from cells to image and patient"}, + "H5": {"slug": "deep_vs_handcrafted", "title": "Deep versus handcrafted on the same cells"}, +} + + +def hypothesis_slug(hypothesis: str) -> str: + return HYPOTHESIS_SUBJECT_SPECS[hypothesis]["slug"] + + +def load_object_index() -> pd.DataFrame: + return pd.read_parquet(DATASET_INDEX_ROOT / "object_index.parquet") + + +def load_image_index() -> pd.DataFrame: + return pd.read_parquet(DATASET_INDEX_ROOT / "image_index.parquet") + + +def load_roi_manifest() -> pd.DataFrame: + return pd.read_parquet(CROPS_ROOT / "roi_manifest.parquet") + + +def load_leaderboard() -> pd.DataFrame: + return pd.read_csv(TABLES_ROOT / "baseline_model_leaderboard.csv") + + +def load_baseline_predictions() -> pd.DataFrame: + return pd.read_parquet(PREDICTIONS_ROOT / "object_level_baseline_predictions.parquet") + + +def load_deep_predictions() -> pd.DataFrame: + return pd.read_parquet(PREDICTIONS_ROOT / "object_level_deep_predictions.parquet") + + +def load_image_scores() -> pd.DataFrame: + return pd.read_parquet(PREDICTIONS_ROOT / "image_level_scores.parquet") + + +def load_patient_scores() -> pd.DataFrame: + return pd.read_parquet(PREDICTIONS_ROOT / "patient_level_scores.parquet") + + +def load_image_level_metrics() -> pd.DataFrame: + return pd.read_csv(TABLES_ROOT / "image_level_metrics.csv") + + +def load_patient_level_metrics() -> pd.DataFrame: + return pd.read_csv(TABLES_ROOT / "patient_level_ranking_metrics.csv") + + +def load_h2_mask_usage_predictions() -> pd.DataFrame: + path = PREDICTIONS_ROOT / "h2_mask_usage_predictions.parquet" + if not path.exists(): + raise FileNotFoundError(f"Missing H2 per-object predictions: {path}") + return pd.read_parquet(path) + + +def load_subset(subset_name: str) -> pd.DataFrame: + return pd.read_parquet(DATASET_INDEX_ROOT / f"subset_{subset_name}.parquet") + + +def load_task_frame(task_name: str) -> pd.DataFrame: + return pd.read_parquet(DATASET_INDEX_ROOT / f"task_{task_name}.parquet") + + +def load_split_lookup() -> pd.DataFrame: + lookup = load_task_frame("a43_full_realistic_binary")[ + ["patient_id", "image_id", "object_id_within_image", "split_name", "target_binary"] + ].drop_duplicates() + return lookup + + +def load_deep_training_history() -> dict[str, list[dict[str, float]]]: + with (LOGS_ROOT / "cnn_training_history.json").open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def load_feature_dictionary() -> pd.DataFrame: + feature_dictionary_path = FEATURES_ROOT / "feature_dictionary.md" + if not feature_dictionary_path.exists(): + return pd.DataFrame() + return pd.DataFrame({"feature_dictionary_path": [str(feature_dictionary_path)]}) + + +def best_full_realistic_row(leaderboard_df: pd.DataFrame | None = None) -> pd.Series: + if leaderboard_df is None: + leaderboard_df = load_leaderboard() + return ( + leaderboard_df.loc[leaderboard_df["task_name"].eq("a43_full_realistic_binary")] + .sort_values(["validation_pr_auc", "test_pr_auc", "test_recall_blast"], ascending=[False, False, False]) + .iloc[0] + ) + + +def best_image_aggregation_method(metrics_df: pd.DataFrame | None = None) -> str: + if metrics_df is None: + metrics_df = load_image_level_metrics() + return str(metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0]["aggregation_method"]) + + +def best_patient_aggregation_method(metrics_df: pd.DataFrame | None = None) -> str: + if metrics_df is None: + metrics_df = load_patient_level_metrics() + return str(metrics_df.sort_values(["pr_auc", "roc_auc"], ascending=[False, False]).iloc[0]["aggregation_method"]) + + +def _augment_object_like_frame(frame: pd.DataFrame) -> pd.DataFrame: + augmented = frame.copy() + if "target_binary" not in augmented.columns and "is_blast" in augmented.columns: + augmented["target_binary"] = augmented["is_blast"].astype(int) + if "bbox_width_px" in augmented.columns and "bbox_height_px" in augmented.columns: + augmented["bbox_area_px"] = augmented["bbox_width_px"].astype(float) * augmented["bbox_height_px"].astype(float) + else: + augmented["bbox_area_px"] = 0.0 + if "mask_area_px" in augmented.columns and "bbox_area_px" in augmented.columns: + augmented["mask_fill_ratio"] = augmented["mask_area_px"].astype(float) / augmented["bbox_area_px"].replace(0.0, pd.NA) + augmented["mask_fill_ratio"] = augmented["mask_fill_ratio"].fillna(0.0) + else: + augmented["mask_fill_ratio"] = 0.0 + augmented["visual_priority"] = augmented["bbox_area_px"].astype(float) + augmented["mask_fill_ratio"].astype(float) * 1000.0 + return augmented + + +def _merge_object_roi(frame: pd.DataFrame) -> pd.DataFrame: + object_index_df = load_object_index() + roi_df = load_roi_manifest() + split_lookup_df = load_split_lookup() + key_columns = ["patient_id", "image_id", "object_id_within_image"] + object_columns = [ + "patient_id", + "image_id", + "object_id_within_image", + "image_path", + "label_path", + "mask_path", + "class_id", + "class_name", + "is_blast", + "is_quality_or_artifact", + "include_in_main_protocol", + ] + roi_columns = [ + "patient_id", + "image_id", + "object_id_within_image", + "bbox_x1", + "bbox_y1", + "bbox_x2", + "bbox_y2", + "bbox_width_px", + "bbox_height_px", + "bbox_crop_path", + "mask_crop_path", + "mask_tight_crop_path", + "mask_area_px", + "mask_bbox_area_px", + ] + merged = frame.copy() + + missing_object_columns = [column for column in object_columns if column not in merged.columns and column not in key_columns] + if missing_object_columns: + merged = merged.merge( + object_index_df[key_columns + missing_object_columns].drop_duplicates(), + on=key_columns, + how="left", + validate="many_to_one", + ) + + missing_roi_columns = [column for column in roi_columns if column not in merged.columns and column not in key_columns] + if missing_roi_columns: + merged = merged.merge( + roi_df[key_columns + missing_roi_columns].drop_duplicates(), + on=key_columns, + how="left", + validate="many_to_one", + ) + if "split_name" not in merged.columns: + merged = merged.merge( + split_lookup_df, + on=key_columns, + how="left", + validate="many_to_one", + ) + elif "target_binary" not in merged.columns: + merged = merged.merge( + split_lookup_df[key_columns + ["target_binary"]], + on=key_columns, + how="left", + validate="many_to_one", + ) + return _augment_object_like_frame(merged) + + +def load_object_roi_frame() -> pd.DataFrame: + object_index_df = load_object_index() + key_columns = ["patient_id", "image_id", "object_id_within_image"] + frame = object_index_df.loc[object_index_df["include_in_main_protocol"].eq(True), key_columns].drop_duplicates().copy() + return _merge_object_roi(frame) + + +def load_best_baseline_subject_frame(split_name: str = "test") -> tuple[pd.Series, pd.DataFrame]: + leaderboard_df = load_leaderboard() + best_row = best_full_realistic_row(leaderboard_df) + predictions_df = load_baseline_predictions() + frame = predictions_df.loc[predictions_df["run_id"].eq(best_row["run_id"])].copy() + if split_name: + frame = frame.loc[frame["split_name"].eq(split_name)].copy() + frame = _merge_object_roi(frame) + frame["true_label"] = frame["y_true"].map({1: "blast", 0: "non-blast"}) + frame["predicted_label"] = frame["y_pred"].map({1: "blast", 0: "non-blast"}) + outcome_map = { + (1, 1): "TP", + (0, 0): "TN", + (0, 1): "FP", + (1, 0): "FN", + } + frame["outcome_label"] = frame.apply( + lambda row: outcome_map[(int(row["y_true"]), int(row["y_pred"]))], + axis=1, + ) + return best_row, frame + + +def load_h2_subject_frame(split_name: str = "test") -> pd.DataFrame: + frame = load_h2_mask_usage_predictions().copy() + if split_name: + frame = frame.loc[frame["split_name"].eq(split_name)].copy() + frame = _merge_object_roi(frame) + frame["true_label"] = frame["y_true"].map({1: "blast", 0: "non-blast"}) + frame["predicted_label"] = frame["y_pred"].map({1: "blast", 0: "non-blast"}) + return frame + + +def load_h3_subject_frames() -> dict[str, pd.DataFrame]: + result: dict[str, pd.DataFrame] = {} + for subset_name in ["clean_cell", "strict_morphology", "full_realistic"]: + subset_df = load_subset(subset_name).copy() + result[subset_name] = _merge_object_roi(subset_df) + return result + + +def load_h4_subject_bundle() -> dict[str, object]: + best_row, best_prediction_frame = load_best_baseline_subject_frame(split_name="test") + image_index_df = load_image_index() + image_scores_df = load_image_scores() + patient_scores_df = load_patient_scores() + image_scores_df = image_scores_df.merge( + image_index_df[ + [ + "patient_id", + "image_id", + "image_path", + "contains_blast", + "contains_non_blast", + "blast_object_count", + "non_blast_object_count", + ] + ].drop_duplicates(), + on=["patient_id", "image_id"], + how="left", + validate="many_to_one", + ) + image_scores_df["split_name"] = "test" + patient_scores_df["split_name"] = "test" + return { + "best_row": best_row, + "best_prediction_frame": best_prediction_frame, + "image_scores_df": image_scores_df, + "patient_scores_df": patient_scores_df, + } + + +def load_h5_subject_frame(split_name: str = "test") -> pd.DataFrame: + best_row, handcrafted_df = load_best_baseline_subject_frame(split_name=split_name) + deep_df = load_deep_predictions().copy() + if split_name: + deep_df = deep_df.loc[deep_df["split_name"].eq(split_name)].copy() + + handcrafted_df = handcrafted_df[ + KEY_COLUMNS + + [ + "split_name", + "y_true", + "y_score", + "y_pred", + "image_path", + "bbox_crop_path", + "mask_crop_path", + "mask_tight_crop_path", + "bbox_area_px", + "visual_priority", + ] + ].copy() + handcrafted_df = handcrafted_df.rename( + columns={ + "y_score": "handcrafted_y_score", + "y_pred": "handcrafted_y_pred", + } + ) + + deep_pivot = ( + deep_df.pivot_table( + index=KEY_COLUMNS + ["split_name", "y_true"], + columns="model_name", + values=["y_score", "y_pred"], + aggfunc="first", + ) + .sort_index(axis=1) + ) + deep_pivot.columns = [f"{model_name}_{value_name}" for value_name, model_name in deep_pivot.columns] + deep_pivot = deep_pivot.reset_index() + + merged = handcrafted_df.merge( + deep_pivot, + on=KEY_COLUMNS + ["split_name", "y_true"], + how="inner", + validate="one_to_one", + ) + merged["best_baseline_run_id"] = str(best_row["run_id"]) + return merged diff --git a/src/evaluation/subject_figure_rendering.py b/src/evaluation/subject_figure_rendering.py new file mode 100644 index 0000000..07bafb2 --- /dev/null +++ b/src/evaluation/subject_figure_rendering.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +MPLCONFIGDIR = Path(__file__).resolve().parents[2] / "outputs" / "logs" / "matplotlib" +MPLCONFIGDIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib import patches +import numpy as np + +from preprocessing.image_io import load_rgb_image +from utils.project import ensure_dir + + +DPI = 220 +BLAST_COLOR = "#d62728" +NON_BLAST_COLOR = "#1f77b4" +TEXT_BOX_EDGE = "#c7c7c7" + + +def _short_text(value: Any, max_length: int = 26) -> str: + text = str(value) + if len(text) <= max_length: + return text + head = max(6, max_length // 2 - 2) + tail = max(6, max_length - head - 3) + return f"{text[:head]}...{text[-tail:]}" + + +def _binary_label(value: int | None) -> str: + if value is None: + return "n/a" + return "blast" if int(value) == 1 else "non-blast" + + +def _save(fig: plt.Figure, output_path: Path) -> str: + ensure_dir(output_path.parent) + fig.tight_layout() + fig.savefig(output_path, dpi=DPI, bbox_inches="tight", facecolor="white") + plt.close(fig) + return str(output_path) + + +def _add_panel_badge(ax: plt.Axes, label: str) -> None: + ax.text( + 0.02, + 0.98, + label, + transform=ax.transAxes, + ha="left", + va="top", + fontsize=12, + fontweight="bold", + color="black", + bbox={"facecolor": "white", "edgecolor": "black", "pad": 2.5}, + ) + + +def _draw_text_box(ax: plt.Axes, title: str, lines: list[str]) -> None: + ax.axis("off") + ax.set_facecolor("white") + ax.add_patch( + patches.FancyBboxPatch( + (0.0, 0.0), + 1.0, + 1.0, + boxstyle="round,pad=0.02,rounding_size=0.02", + linewidth=1.0, + edgecolor=TEXT_BOX_EDGE, + facecolor="white", + transform=ax.transAxes, + ) + ) + ax.text(0.04, 0.96, title, transform=ax.transAxes, ha="left", va="top", fontsize=10, fontweight="bold") + ax.text(0.04, 0.88, "\n".join(lines), transform=ax.transAxes, ha="left", va="top", fontsize=8.5) + + +def _draw_image_panel(ax: plt.Axes, image_path: str, title: str = "", subtitle: str | None = None) -> None: + image = load_rgb_image(image_path) + ax.imshow(image) + ax.axis("off") + if title: + ax.set_title(title, fontsize=10) + if subtitle: + ax.text(0.5, -0.08, subtitle, transform=ax.transAxes, ha="center", va="top", fontsize=8) + + +def _draw_roi_panel( + ax: plt.Axes, + row: dict[str, Any], + *, + image_column: str = "bbox_crop_path", + title: str = "", + subtitle: str | None = None, +) -> None: + image = load_rgb_image(str(row[image_column])) + ax.imshow(image) + ax.axis("off") + if title: + ax.set_title(title, fontsize=9) + if subtitle: + ax.text(0.5, -0.08, subtitle, transform=ax.transAxes, ha="center", va="top", fontsize=7.5) + + +def _draw_smear_with_boxes( + ax: plt.Axes, + image_path: str, + object_rows: list[dict[str, Any]], + *, + title: str, + labels: list[str] | None = None, + show_scores: bool = False, +) -> None: + image = load_rgb_image(image_path) + ax.imshow(image) + ax.axis("off") + ax.set_title(title, fontsize=10) + for index, row in enumerate(object_rows, start=1): + color = BLAST_COLOR if int(row.get("target_binary", row.get("y_true", 0))) == 1 else NON_BLAST_COLOR + x1 = float(row["bbox_x1"]) + y1 = float(row["bbox_y1"]) + width = float(row["bbox_width_px"]) + height = float(row["bbox_height_px"]) + ax.add_patch( + patches.Rectangle((x1, y1), width, height, linewidth=1.6, edgecolor=color, facecolor="none") + ) + label = labels[index - 1] if labels and index - 1 < len(labels) else f"#{index}" + if show_scores and "y_score" in row: + label = f"{label} {float(row['y_score']):.2f}" + ax.text( + x1, + max(2.0, y1 - 4.0), + label, + fontsize=7, + color="white", + bbox={"facecolor": color, "edgecolor": color, "pad": 1.4}, + ) + + +def _score_lines(method_rows: list[dict[str, Any]]) -> list[str]: + lines: list[str] = [] + for row in method_rows: + lines.append(f"{row['aggregation_method']}: {float(row['score']):.3f}") + return lines + + +def _render_a01_f1(spec: dict[str, Any], output_path: Path) -> str: + class_rows = spec["class_rows"] + fig, axes = plt.subplots(len(class_rows), 4, figsize=(11.8, 15.2)) + if len(class_rows) == 1: + axes = np.asarray([axes]) + for row_index, group in enumerate(class_rows): + for col_index in range(4): + row = group["rows"][col_index] + title = group["class_name"] if col_index == 0 else "" + _draw_roi_panel(axes[row_index, col_index], row, title=title, subtitle=str(row["class_name"])) + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a01_f2(spec: dict[str, Any], output_path: Path) -> str: + object_rows = spec["object_rows"] + fig = plt.figure(figsize=(13.2, 10.6)) + grid = fig.add_gridspec(3, 4, height_ratios=[1.2, 1.0, 1.0]) + + ax_a = fig.add_subplot(grid[0, :2]) + ax_b = fig.add_subplot(grid[0, 2:]) + _draw_image_panel( + ax_a, + spec["image_row"]["image_path"], + title=f"Smear image | patient {_short_text(spec['image_row']['patient_id'], 18)}", + ) + _add_panel_badge(ax_a, "A") + _draw_smear_with_boxes( + ax_b, + spec["image_row"]["image_path"], + object_rows, + title=f"Selected bounding boxes | image {_short_text(spec['image_row']['image_id'], 18)}", + labels=[str(index) for index in range(1, len(object_rows) + 1)], + ) + _add_panel_badge(ax_b, "B") + + roi_positions = [(1, 0), (1, 1), (1, 2), (1, 3), (2, 0), (2, 1)] + for slot, (row_index, col_index) in enumerate(roi_positions, start=1): + ax = fig.add_subplot(grid[row_index, col_index]) + if slot <= len(object_rows): + row = object_rows[slot - 1] + _draw_roi_panel(ax, row, title=f"#{slot} {_short_text(row['class_name'], 18)}") + if slot == 1: + _add_panel_badge(ax, "C") + else: + ax.axis("off") + for col_index in [2, 3]: + ax = fig.add_subplot(grid[2, col_index]) + ax.axis("off") + + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a01_f3(spec: dict[str, Any], output_path: Path) -> str: + object_rows = spec["object_rows"] + fig, axes = plt.subplots(len(object_rows), 3, figsize=(12.4, 15.6)) + if len(object_rows) == 1: + axes = np.asarray([axes]) + column_titles = ["bbox crop", "mask crop", "mask-tight crop"] + for column_index, column_title in enumerate(column_titles): + axes[0, column_index].set_title(column_title, fontsize=10) + for row_index, row in enumerate(object_rows): + score_line = ( + f"bbox={float(row['bbox_only_y_score']):.3f} | " + f"mask={float(row['mask_only_y_score']):.3f} | " + f"combined={float(row['bbox_plus_mask_y_score']):.3f}" + ) + _draw_roi_panel( + axes[row_index, 0], + row, + image_column="bbox_crop_path", + title=f"{_short_text(row['class_name'], 20)} | {_binary_label(int(row['target_binary']))}", + subtitle=score_line, + ) + _draw_roi_panel(axes[row_index, 1], row, image_column="mask_crop_path") + _draw_roi_panel(axes[row_index, 2], row, image_column="mask_tight_crop_path") + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a01_f4(spec: dict[str, Any], output_path: Path) -> str: + grid_rows = spec["grid_rows"] + fig, axes = plt.subplots(len(grid_rows), 3, figsize=(12.2, 13.8)) + if len(grid_rows) == 1: + axes = np.asarray([axes]) + column_titles = [ + "clean_cell references", + "objects removed by strict_morphology", + "full_realistic difficult cases", + ] + for column_index, column_title in enumerate(column_titles): + axes[0, column_index].set_title(column_title, fontsize=10) + for row_index, row_group in enumerate(grid_rows): + for column_index, key in enumerate(["clean", "strict_removed", "full_problem"]): + row = row_group[key] + _draw_roi_panel( + axes[row_index, column_index], + row, + title=_short_text(row["class_name"], 22), + subtitle=_binary_label(int(row["target_binary"])) if row.get("target_binary") is not None else None, + ) + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a01_f5(spec: dict[str, Any], output_path: Path) -> str: + outcome_rows = spec["outcome_rows"] + fig, axes = plt.subplots(4, 4, figsize=(12.8, 12.8)) + for row_index, outcome_label in enumerate(["TP", "TN", "FP", "FN"]): + rows = outcome_rows[outcome_label] + for col_index in range(4): + ax = axes[row_index, col_index] + row = rows[col_index] + subtitle = ( + f"true={row['true_label']} | pred={row['predicted_label']}\n" + f"score={float(row['y_score']):.3f}" + ) + title = f"{outcome_label} | {_short_text(row['class_name'], 18)}" if col_index == 0 else _short_text(row["class_name"], 18) + _draw_roi_panel(ax, row, title=title, subtitle=subtitle) + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a01_f6(spec: dict[str, Any], output_path: Path) -> str: + confident_rows = spec["confident_rows"] + disagreement_rows = spec["disagreement_rows"] + fig, axes = plt.subplots(4, 2, figsize=(12.2, 15.6)) + axes[0, 0].set_title("handcrafted confident and correct", fontsize=10) + axes[0, 1].set_title("model disagreement on the same cell", fontsize=10) + for row_index in range(4): + confident = confident_rows[row_index] + disagreement = disagreement_rows[row_index] + confident_subtitle = ( + f"HCF={float(confident['handcrafted_y_score']):.3f} | " + f"BBOX={float(confident['cnn_bbox_y_score']):.3f}\n" + f"MASK={float(confident['cnn_mask_y_score']):.3f} | " + f"FUSION={float(confident['cnn_late_fusion_y_score']):.3f}" + ) + disagreement_subtitle = ( + f"HCF={float(disagreement['handcrafted_y_score']):.3f} | " + f"BBOX={float(disagreement['cnn_bbox_y_score']):.3f}\n" + f"MASK={float(disagreement['cnn_mask_y_score']):.3f} | " + f"FUSION={float(disagreement['cnn_late_fusion_y_score']):.3f}" + ) + _draw_roi_panel( + axes[row_index, 0], + confident, + title=f"{_short_text(confident['class_name'], 18)} | {_binary_label(int(confident['y_true']))}", + subtitle=confident_subtitle, + ) + _draw_roi_panel( + axes[row_index, 1], + disagreement, + title=f"{_short_text(disagreement['class_name'], 18)} | {_binary_label(int(disagreement['y_true']))}", + subtitle=disagreement_subtitle, + ) + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a02_f1(spec: dict[str, Any], output_path: Path) -> str: + object_rows = spec["top_object_rows"] + fig = plt.figure(figsize=(13.6, 10.8)) + grid = fig.add_gridspec(4, 4, height_ratios=[1.15, 1.15, 1.0, 1.0]) + ax_a = fig.add_subplot(grid[:2, :2]) + ax_b = fig.add_subplot(grid[:2, 2:]) + _draw_image_panel( + ax_a, + spec["image_row"]["image_path"], + title=f"Original image | {_short_text(spec['image_row']['patient_id'], 18)}", + subtitle=_short_text(spec["image_row"]["image_id"], 22), + ) + _add_panel_badge(ax_a, "A") + _draw_smear_with_boxes( + ax_b, + spec["image_row"]["image_path"], + object_rows, + title="Top object-level detections on the same image", + labels=[f"#{index}" for index in range(1, len(object_rows) + 1)], + show_scores=True, + ) + _add_panel_badge(ax_b, "B") + for slot in range(6): + row_index = 2 + slot // 3 + col_index = slot % 3 + ax = fig.add_subplot(grid[row_index, col_index]) + row = object_rows[slot] + _draw_roi_panel( + ax, + row, + title=f"#{slot + 1} {_short_text(row['class_name'], 16)}", + subtitle=f"score={float(row['y_score']):.3f}", + ) + if slot == 0: + _add_panel_badge(ax, "C") + ax_d = fig.add_subplot(grid[2:, 3]) + _draw_text_box( + ax_d, + "Image-level aggregation", + _score_lines(spec["method_rows"]) + + ["", f"n_objects={int(spec['image_row']['n_objects'])}", f"spread={float(spec['image_row']['method_spread']):.3f}"], + ) + _add_panel_badge(ax_d, "D") + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a02_f2(spec: dict[str, Any], output_path: Path) -> str: + cases = spec["cases"] + fig, axes = plt.subplots(4, 4, figsize=(13.4, 15.6)) + for row_index, case in enumerate(cases): + image_row = case["image_row"] + top_object_rows = case["top_object_rows"] + _draw_image_panel( + axes[row_index, 0], + image_row["image_path"], + title=f"{case['outcome_label']} | score={float(image_row['score']):.3f}", + subtitle=_short_text(image_row["image_id"], 22), + ) + for col_index in range(1, 4): + ax = axes[row_index, col_index] + if col_index - 1 < len(top_object_rows): + row = top_object_rows[col_index - 1] + _draw_roi_panel( + ax, + row, + title=f"#{col_index} {_short_text(row['class_name'], 14)}", + subtitle=f"score={float(row['y_score']):.3f}", + ) + else: + ax.axis("off") + fig.suptitle(f"{spec['title']} | best method: {spec['best_method']}", fontsize=13) + return _save(fig, output_path) + + +def _render_a02_f3(spec: dict[str, Any], output_path: Path) -> str: + object_rows = spec["top_object_rows"] + fig = plt.figure(figsize=(13.2, 10.8)) + grid = fig.add_gridspec(4, 4, height_ratios=[1.15, 1.15, 1.15, 0.95]) + ax_a = fig.add_subplot(grid[:3, :2]) + _draw_smear_with_boxes( + ax_a, + spec["image_row"]["image_path"], + object_rows, + title=f"Same image under four aggregators | {_short_text(spec['image_row']['image_id'], 22)}", + labels=[f"#{index}" for index in range(1, len(object_rows) + 1)], + show_scores=True, + ) + _add_panel_badge(ax_a, "A") + positions = [(0, 2), (0, 3), (1, 2), (1, 3)] + for slot, (row_index, col_index) in enumerate(positions, start=1): + row = object_rows[slot - 1] + ax = fig.add_subplot(grid[row_index, col_index]) + _draw_roi_panel( + ax, + row, + title=f"#{slot} {_short_text(row['class_name'], 14)}", + subtitle=f"score={float(row['y_score']):.3f}", + ) + if slot == 1: + _add_panel_badge(ax, "B") + method_titles = { + "max_object_score": "max object score", + "mean_top3_scores": "mean top-3 scores", + "count_objects_above_threshold": "count > threshold", + "soft_noisy_or": "soft noisy-or", + } + for index, method_row in enumerate(spec["method_rows"]): + ax = fig.add_subplot(grid[3, index]) + _draw_text_box( + ax, + method_titles.get(method_row["aggregation_method"], method_row["aggregation_method"]), + [f"score = {float(method_row['score']):.3f}"], + ) + if index == 0: + _add_panel_badge(ax, "C") + ax_blank = fig.add_subplot(grid[2, 2:]) + ax_blank.axis("off") + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a02_f4(spec: dict[str, Any], output_path: Path) -> str: + image_panels = spec["image_panels"] + n_rows = len(image_panels) + fig = plt.figure(figsize=(13.4, 3.1 * n_rows + 1.2)) + grid = fig.add_gridspec(n_rows, 4, width_ratios=[1.6, 1.0, 1.0, 1.1]) + summary_ax = fig.add_subplot(grid[:, 3]) + summary_lines = [ + f"patient: {_short_text(spec['patient_row']['patient_id'], 22)}", + f"image method: {spec['best_image_method']}", + f"patient method: {spec['best_patient_method']}", + f"patient score: {float(spec['patient_row']['score']):.3f}", + f"n_images shown: {len(image_panels)}", + f"n_objects total: {int(spec['patient_row']['n_objects'])}", + ] + _draw_text_box(summary_ax, "Patient aggregation", summary_lines) + _add_panel_badge(summary_ax, "D") + for row_index, panel in enumerate(image_panels): + image_row = panel["image_row"] + top_object_rows = panel["top_object_rows"] + ax_image = fig.add_subplot(grid[row_index, 0]) + _draw_image_panel( + ax_image, + image_row["image_path"], + title=f"{_short_text(image_row['image_id'], 18)} | {float(image_row['score']):.3f}", + subtitle=f"contains blast={int(image_row['image_contains_blast'])}", + ) + if row_index == 0: + _add_panel_badge(ax_image, "A") + for object_slot, col_index in enumerate([1, 2], start=0): + ax = fig.add_subplot(grid[row_index, col_index]) + if object_slot < len(top_object_rows): + row = top_object_rows[object_slot] + _draw_roi_panel( + ax, + row, + title=f"#{object_slot + 1} {_short_text(row['class_name'], 14)}", + subtitle=f"score={float(row['y_score']):.3f}", + ) + else: + ax.axis("off") + if row_index == 0 and object_slot == 0: + _add_panel_badge(ax, "B") + if row_index == 0 and object_slot == 1: + _add_panel_badge(ax, "C") + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def _render_a02_f5(spec: dict[str, Any], output_path: Path) -> str: + case_panels = spec["case_panels"] + max_rows = max(len(case["image_panels"]) for case in case_panels) + fig, axes = plt.subplots(max_rows, 6, figsize=(14.3, 4.1 * max_rows + 0.8)) + if max_rows == 1: + axes = np.asarray([axes]) + + for section_index, case in enumerate(case_panels): + patient_row = case["patient_row"] + start_col = 0 if case["case_label"] == "TP" else 3 + section_title = ( + f"{case['case_label']} patient | {_short_text(patient_row['patient_id'], 20)} | " + f"{spec['best_patient_method']}={float(patient_row['score']):.3f}" + ) + axes[0, start_col].set_title(section_title, fontsize=10) + for row_index in range(max_rows): + for col_offset in range(3): + axes[row_index, start_col + col_offset].axis("off") + for row_index, panel in enumerate(case["image_panels"]): + image_row = panel["image_row"] + top_object_rows = panel["top_object_rows"] + image_ax = axes[row_index, start_col] + _draw_image_panel( + image_ax, + image_row["image_path"], + title=f"{_short_text(image_row['image_id'], 16)} | {float(image_row['score']):.3f}", + ) + if row_index == 0: + _add_panel_badge(image_ax, "A" if case["case_label"] == "TP" else "D") + for object_slot, col_offset in enumerate([1, 2], start=0): + ax = axes[row_index, start_col + col_offset] + if object_slot < len(top_object_rows): + row = top_object_rows[object_slot] + _draw_roi_panel( + ax, + row, + title=f"#{object_slot + 1} {_short_text(row['class_name'], 13)}", + subtitle=f"score={float(row['y_score']):.3f}", + ) + else: + ax.axis("off") + if row_index == 0 and object_slot == 0: + _add_panel_badge(ax, "B" if case["case_label"] == "TP" else "E") + if row_index == 0 and object_slot == 1: + _add_panel_badge(ax, "C" if case["case_label"] == "TP" else "F") + fig.suptitle(spec["title"], fontsize=13) + return _save(fig, output_path) + + +def render_subject_figure(spec: dict[str, Any], output_path: Path) -> str: + kind = spec["figure_kind"] + if kind == "a01_f1_class_gallery": + return _render_a01_f1(spec, output_path) + if kind == "a01_f2_smear_to_objects": + return _render_a01_f2(spec, output_path) + if kind == "a01_f3_mask_representations": + return _render_a01_f3(spec, output_path) + if kind == "a01_f4_dataset_regimes": + return _render_a01_f4(spec, output_path) + if kind == "a01_f5_confusion_gallery": + return _render_a01_f5(spec, output_path) + if kind == "a01_f6_same_cells_model_comparison": + return _render_a01_f6(spec, output_path) + if kind == "a02_f1_cells_to_image_score": + return _render_a02_f1(spec, output_path) + if kind == "a02_f2_image_case_studies": + return _render_a02_f2(spec, output_path) + if kind == "a02_f3_same_image_four_aggregators": + return _render_a02_f3(spec, output_path) + if kind == "a02_f4_images_to_patient_score": + return _render_a02_f4(spec, output_path) + if kind == "a02_f5_patient_case_studies": + return _render_a02_f5(spec, output_path) + raise ValueError(f"Unsupported subject figure kind: {kind}") diff --git a/src/evaluation/subject_figure_selection.py b/src/evaluation/subject_figure_selection.py new file mode 100644 index 0000000..fa317e4 --- /dev/null +++ b/src/evaluation/subject_figure_selection.py @@ -0,0 +1,1343 @@ +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from evaluation.subject_figure_data import ( + best_image_aggregation_method, + best_patient_aggregation_method, + hypothesis_slug, + load_best_baseline_subject_frame, + load_h2_subject_frame, + load_h3_subject_frames, + load_h4_subject_bundle, + load_h5_subject_frame, + load_object_roi_frame, +) + + +VARIANT_COUNT = 5 +OBJECT_KEY_COLUMNS = ["patient_id", "image_id", "object_id_within_image"] +AGGREGATION_METHOD_ORDER = [ + "max_object_score", + "mean_top3_scores", + "count_objects_above_threshold", + "soft_noisy_or", +] + + +def _base_spec( + *, + article_id: str, + figure_id: str, + figure_basename: str, + figure_kind: str, + hypothesis: str, + title: str, + variant_index: int, +) -> dict[str, Any]: + return { + "article_id": article_id, + "figure_id": figure_id, + "figure_variant_id": f"{figure_id}_v{variant_index:02d}", + "figure_basename": figure_basename, + "figure_kind": figure_kind, + "hypothesis": hypothesis, + "hypothesis_slug": hypothesis_slug(hypothesis), + "title": title, + "variant_index": variant_index, + } + + +def _panel_row( + *, + panel_id: str, + role: str, + source_table: str, + source_path: str, + patient_id: str | None = None, + image_id: str | None = None, + object_id_within_image: int | None = None, + class_name: str | None = None, + target_binary: int | None = None, + split_name: str | None = None, + notes: str = "", +) -> dict[str, Any]: + return { + "panel_id": panel_id, + "role": role, + "patient_id": patient_id, + "image_id": image_id, + "object_id_within_image": object_id_within_image, + "class_name": class_name, + "target_binary": target_binary, + "split_name": split_name, + "source_table": source_table, + "source_path": source_path, + "notes": notes, + } + + +def _rotate_frame(frame: pd.DataFrame, variant_index: int) -> pd.DataFrame: + ordered = frame.reset_index(drop=True) + if ordered.empty: + return ordered.copy() + offset = (variant_index - 1) % len(ordered) + if offset == 0: + return ordered.copy() + return pd.concat([ordered.iloc[offset:], ordered.iloc[:offset]], ignore_index=True) + + +def _take_rows( + frame: pd.DataFrame, + *, + n_items: int, + variant_index: int, + sort_columns: list[str], + ascending: bool | list[bool], + unique_columns: tuple[str, ...] | None = ("patient_id", "image_id"), + extra_distinct_column: str | None = None, +) -> pd.DataFrame: + if frame.empty or n_items <= 0: + return frame.head(0).copy() + ordered = frame.drop_duplicates(subset=OBJECT_KEY_COLUMNS).sort_values(sort_columns, ascending=ascending).reset_index(drop=True) + rotated = _rotate_frame(ordered, variant_index) + selected: list[dict[str, Any]] = [] + seen_objects: set[tuple[Any, ...]] = set() + seen_unique: set[tuple[Any, ...]] = set() + seen_extra: set[Any] = set() + + def consider(row: pd.Series, enforce_unique: bool, enforce_extra: bool) -> bool: + object_key = tuple(row[column] for column in OBJECT_KEY_COLUMNS) + if object_key in seen_objects: + return False + if enforce_unique and unique_columns is not None: + unique_key = tuple(row[column] for column in unique_columns) + if unique_key in seen_unique: + return False + if enforce_extra and extra_distinct_column is not None: + extra_value = row[extra_distinct_column] + if extra_value in seen_extra: + return False + selected.append(row.to_dict()) + seen_objects.add(object_key) + if unique_columns is not None: + seen_unique.add(tuple(row[column] for column in unique_columns)) + if extra_distinct_column is not None: + seen_extra.add(row[extra_distinct_column]) + return len(selected) >= n_items + + for enforce_unique, enforce_extra in [(True, True), (True, False), (False, False)]: + for _, row in rotated.iterrows(): + if consider(row, enforce_unique=enforce_unique, enforce_extra=enforce_extra): + return pd.DataFrame(selected) + return pd.DataFrame(selected) + + +def _take_group_rows( + frame: pd.DataFrame, + *, + n_items: int, + variant_index: int, + sort_columns: list[str], + ascending: bool | list[bool], + dedupe_columns: list[str], +) -> pd.DataFrame: + if frame.empty or n_items <= 0: + return frame.head(0).copy() + ordered = frame.sort_values(sort_columns, ascending=ascending).drop_duplicates(subset=dedupe_columns).reset_index(drop=True) + rotated = _rotate_frame(ordered, variant_index) + return rotated.head(n_items).copy() + + +def _pick_single_row( + frame: pd.DataFrame, + *, + variant_index: int, + sort_columns: list[str], + ascending: bool | list[bool], + dedupe_columns: list[str], +) -> dict[str, Any]: + selected = _take_group_rows( + frame, + n_items=1, + variant_index=variant_index, + sort_columns=sort_columns, + ascending=ascending, + dedupe_columns=dedupe_columns, + ) + if selected.empty: + raise ValueError("Expected at least one image candidate.") + return selected.iloc[0].to_dict() + + +def _target_binary(row: pd.Series | dict[str, Any]) -> int | None: + value = row.get("target_binary") if isinstance(row, dict) else row.get("target_binary") + if value is None and "y_true" in row: + value = row.get("y_true") + if value is None and "is_blast" in row: + value = row.get("is_blast") + if pd.isna(value): + return None + return int(value) + + +def _note_score(score: float | None, prefix: str = "score") -> str: + if score is None or pd.isna(score): + return prefix + return f"{prefix}={float(score):.3f}" + + +def _group_rows(image_rows: pd.DataFrame, top_n: int) -> list[dict[str, Any]]: + return image_rows.sort_values(["y_score", "visual_priority"], ascending=[False, False]).head(top_n).to_dict("records") + + +def _select_smear_story_objects(image_objects: pd.DataFrame, variant_index: int) -> list[dict[str, Any]]: + positive = _take_rows( + image_objects.loc[image_objects["target_binary"].eq(1)].copy(), + n_items=min(2, int(image_objects["target_binary"].sum())), + variant_index=variant_index, + sort_columns=["visual_priority", "bbox_area_px", "object_id_within_image"], + ascending=[False, False, True], + unique_columns=None, + ) + negative = _take_rows( + image_objects.loc[image_objects["target_binary"].eq(0)].copy(), + n_items=min(4, max(0, len(image_objects) - len(positive))), + variant_index=variant_index + 1, + sort_columns=["visual_priority", "bbox_area_px", "object_id_within_image"], + ascending=[False, False, True], + unique_columns=None, + extra_distinct_column="class_name", + ) + selected = pd.concat([positive, negative], ignore_index=True) + if len(selected) < min(6, len(image_objects)): + remaining = image_objects.loc[ + ~image_objects["object_id_within_image"].isin(selected["object_id_within_image"].tolist()) + ].copy() + filler = _take_rows( + remaining, + n_items=min(6, len(image_objects)) - len(selected), + variant_index=variant_index, + sort_columns=["visual_priority", "bbox_area_px", "object_id_within_image"], + ascending=[False, False, True], + unique_columns=None, + ) + selected = pd.concat([selected, filler], ignore_index=True) + selected = selected.head(min(6, len(image_objects))).copy() + return selected.to_dict("records") + + +def _build_a01_f1_variants(variant_count: int) -> list[dict[str, Any]]: + object_roi_df = load_object_roi_frame() + class_plan = [ + "blast", + "Lymphocyte", + "Monocyte", + "Myelocyte", + "Segmentonuclear neutrophil", + "normoblasts: oxyfilny" if int(object_roi_df["class_name"].eq("normoblasts: oxyfilny").sum()) >= 4 else "Normoblasts", + ] + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + panel_rows: list[dict[str, Any]] = [] + class_rows: list[dict[str, Any]] = [] + for row_index, class_name in enumerate(class_plan, start=1): + selected = _take_rows( + object_roi_df.loc[object_roi_df["class_name"].eq(class_name)].copy(), + n_items=4, + variant_index=variant_index + row_index - 1, + sort_columns=["visual_priority", "bbox_area_px", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ) + records = selected.to_dict("records") + class_rows.append({"class_name": class_name, "rows": records}) + for col_index, row in enumerate(records, start=1): + panel_rows.append( + _panel_row( + panel_id=f"r{row_index:02d}c{col_index:02d}", + role="bbox_roi", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="roi_manifest.parquet", + source_path=str(row["bbox_crop_path"]), + notes="class gallery bbox crop", + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F1", + figure_basename="a01_subject_classes_gallery", + figure_kind="a01_f1_class_gallery", + hypothesis="H1", + title="A01-F1. Object classes in the blast versus non-blast task", + variant_index=variant_index, + ), + "class_rows": class_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a01_f2_variants(variant_count: int) -> list[dict[str, Any]]: + object_roi_df = load_object_roi_frame() + image_candidates = ( + object_roi_df.groupby(["patient_id", "image_id", "image_path"], as_index=False) + .agg( + n_objects=("object_id_within_image", "size"), + n_blasts=("target_binary", "sum"), + class_diversity=("class_name", "nunique"), + mean_priority=("visual_priority", "mean"), + ) + ) + image_candidates["n_non_blasts"] = image_candidates["n_objects"] - image_candidates["n_blasts"] + image_candidates["readability_penalty"] = (image_candidates["n_objects"] - 8).abs() + image_candidates = image_candidates.loc[ + image_candidates["n_objects"].between(6, 18) + & image_candidates["n_blasts"].ge(1) + & image_candidates["n_non_blasts"].ge(2) + ].copy() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + image_row = _pick_single_row( + image_candidates, + variant_index=variant_index, + sort_columns=["class_diversity", "readability_penalty", "mean_priority", "patient_id", "image_id"], + ascending=[False, True, False, True, True], + dedupe_columns=["patient_id", "image_id"], + ) + image_objects = object_roi_df.loc[ + object_roi_df["patient_id"].eq(image_row["patient_id"]) & object_roi_df["image_id"].eq(image_row["image_id"]) + ].copy() + selected_objects = _select_smear_story_objects(image_objects, variant_index=variant_index) + panel_rows = [ + _panel_row( + panel_id="A", + role="smear_image", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + target_binary=None, + split_name="", + source_table="image_index.parquet", + source_path=str(image_row["image_path"]), + notes="original smear image", + ), + _panel_row( + panel_id="B", + role="smear_with_boxes", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + target_binary=None, + split_name="", + source_table="roi_manifest.parquet", + source_path=str(image_row["image_path"]), + notes="smear with selected bounding boxes", + ), + ] + for slot, row in enumerate(selected_objects, start=1): + panel_rows.append( + _panel_row( + panel_id=f"C{slot}", + role="bbox_roi", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="roi_manifest.parquet", + source_path=str(row["bbox_crop_path"]), + notes=f"selected ROI #{slot}", + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F2", + figure_basename="a01_from_smear_to_objects", + figure_kind="a01_f2_smear_to_objects", + hypothesis="H1", + title="A01-F2. From a smear image to object-level cells", + variant_index=variant_index, + ), + "image_row": image_row, + "object_rows": selected_objects, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a01_f3_variants(variant_count: int) -> list[dict[str, Any]]: + h2_df = load_h2_subject_frame(split_name="test") + full_df = h2_df.loc[h2_df["task_name"].eq("a43_full_realistic_binary")].copy() + pivot_df = full_df.pivot_table( + index=[ + "patient_id", + "image_id", + "object_id_within_image", + "class_name", + "split_name", + "target_binary", + "bbox_crop_path", + "mask_crop_path", + "mask_tight_crop_path", + "bbox_area_px", + "visual_priority", + ], + columns="label", + values=["y_score", "y_pred"], + aggfunc="first", + ) + pivot_df.columns = [f"{label}_{value_name}" for value_name, label in pivot_df.columns] + pivot_df = pivot_df.reset_index() + pivot_df = pivot_df.dropna(subset=["bbox_crop_path", "mask_crop_path", "mask_tight_crop_path"]).copy() + pivot_df["score_spread"] = pivot_df[ + ["bbox_only_y_score", "mask_only_y_score", "bbox_plus_mask_y_score"] + ].max(axis=1) - pivot_df[ + ["bbox_only_y_score", "mask_only_y_score", "bbox_plus_mask_y_score"] + ].min(axis=1) + pivot_df["combined_gain"] = pivot_df["bbox_plus_mask_y_score"] - pivot_df["bbox_only_y_score"] + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + positive = _take_rows( + pivot_df.loc[pivot_df["target_binary"].eq(1)].copy(), + n_items=3, + variant_index=variant_index, + sort_columns=["score_spread", "combined_gain", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ) + negative = _take_rows( + pivot_df.loc[pivot_df["target_binary"].eq(0)].copy(), + n_items=3, + variant_index=variant_index + 1, + sort_columns=["score_spread", "combined_gain", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + extra_distinct_column="class_name", + ) + selected = pd.concat([positive, negative], ignore_index=True) + records = selected.to_dict("records") + panel_rows: list[dict[str, Any]] = [] + for row_index, row in enumerate(records, start=1): + for label, source_column in [ + ("bbox", "bbox_crop_path"), + ("mask", "mask_crop_path"), + ("mask_tight", "mask_tight_crop_path"), + ]: + panel_rows.append( + _panel_row( + panel_id=f"r{row_index:02d}_{label}", + role=label, + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="h2_mask_usage_predictions.parquet", + source_path=str(row[source_column]), + notes=( + f"bbox={float(row['bbox_only_y_score']):.3f}; " + f"mask={float(row['mask_only_y_score']):.3f}; " + f"combined={float(row['bbox_plus_mask_y_score']):.3f}" + ), + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F3", + figure_basename="a01_mask_representations_comparison", + figure_kind="a01_f3_mask_representations", + hypothesis="H2", + title="A01-F3. Bounding-box, mask, and mask-tight cell views", + variant_index=variant_index, + ), + "object_rows": records, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a01_f4_variants(variant_count: int) -> list[dict[str, Any]]: + subset_frames = load_h3_subject_frames() + clean_df = subset_frames["clean_cell"].copy() + strict_df = subset_frames["strict_morphology"].copy() + full_df = subset_frames["full_realistic"].copy() + + strict_keys = set(map(tuple, strict_df[OBJECT_KEY_COLUMNS].itertuples(index=False, name=None))) + clean_only_df = clean_df.loc[ + [tuple(values) not in strict_keys for values in clean_df[OBJECT_KEY_COLUMNS].itertuples(index=False, name=None)] + ].copy() + gumpricht_df = clean_only_df.loc[clean_only_df["class_name"].eq("gumpricht shadows")].copy() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + clean_reference_rows: list[dict[str, Any]] = [] + for offset, class_name in enumerate(["blast", "Lymphocyte", "Monocyte", "Myelocyte"], start=0): + selected = _take_rows( + clean_df.loc[clean_df["class_name"].eq(class_name)].copy(), + n_items=1, + variant_index=variant_index + offset, + sort_columns=["visual_priority", "bbox_area_px", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ) + clean_reference_rows.extend(selected.to_dict("records")) + + gumpricht_rows = _take_rows( + gumpricht_df, + n_items=4, + variant_index=variant_index, + sort_columns=["visual_priority", "bbox_area_px", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ).to_dict("records") + + noise_rows = _take_rows( + full_df.loc[full_df["class_name"].isin(["Bad cells", "artifacts"])].copy(), + n_items=2, + variant_index=variant_index, + sort_columns=["visual_priority", "bbox_area_px", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ).to_dict("records") + hard_non_blast_rows = _take_rows( + full_df.loc[ + full_df["class_name"].isin( + [ + "Lymphocyte", + "Monocyte", + "Myelocyte", + "Segmentonuclear neutrophil", + "Normoblasts", + "normoblasts: oxyfilny", + ] + ) + ].copy(), + n_items=2, + variant_index=variant_index + 2, + sort_columns=["visual_priority", "bbox_area_px", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + extra_distinct_column="class_name", + ).to_dict("records") + full_problem_rows = (noise_rows + hard_non_blast_rows)[:4] + + grid_rows = [ + { + "clean": clean_reference_rows[index], + "strict_removed": gumpricht_rows[index], + "full_problem": full_problem_rows[index], + } + for index in range(4) + ] + + panel_rows: list[dict[str, Any]] = [] + for row_index, row_group in enumerate(grid_rows, start=1): + for column_index, (role, row) in enumerate(row_group.items(), start=1): + panel_rows.append( + _panel_row( + panel_id=f"r{row_index:02d}c{column_index:02d}", + role=role, + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="roi_manifest.parquet", + source_path=str(row["bbox_crop_path"]), + notes=role.replace("_", " "), + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F4", + figure_basename="a01_dataset_regimes_subject_gallery", + figure_kind="a01_f4_dataset_regimes", + hypothesis="H3", + title="A01-F4. Subject differences between clean, strict, and full-realistic corpora", + variant_index=variant_index, + ), + "grid_rows": grid_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a01_f5_variants(variant_count: int) -> list[dict[str, Any]]: + _, baseline_df = load_best_baseline_subject_frame(split_name="test") + baseline_df = baseline_df.copy() + baseline_df["decision_margin"] = (baseline_df["y_score"] - baseline_df["threshold"]).abs() + + outcome_sort = { + "TP": (["decision_margin", "y_score", "visual_priority", "patient_id", "image_id", "object_id_within_image"], [False, False, False, True, True, True]), + "TN": (["decision_margin", "y_score", "visual_priority", "patient_id", "image_id", "object_id_within_image"], [False, True, False, True, True, True]), + "FP": (["decision_margin", "y_score", "visual_priority", "patient_id", "image_id", "object_id_within_image"], [False, False, False, True, True, True]), + "FN": (["decision_margin", "y_score", "visual_priority", "patient_id", "image_id", "object_id_within_image"], [False, True, False, True, True, True]), + } + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + outcome_rows: dict[str, list[dict[str, Any]]] = {} + panel_rows: list[dict[str, Any]] = [] + for row_index, outcome_label in enumerate(["TP", "TN", "FP", "FN"], start=1): + sort_columns, ascending = outcome_sort[outcome_label] + selected = _take_rows( + baseline_df.loc[baseline_df["outcome_label"].eq(outcome_label)].copy(), + n_items=4, + variant_index=variant_index + row_index - 1, + sort_columns=sort_columns, + ascending=ascending, + unique_columns=("patient_id", "image_id"), + extra_distinct_column="class_name" if outcome_label in {"TN", "FP"} else None, + ) + records = selected.to_dict("records") + outcome_rows[outcome_label] = records + for col_index, row in enumerate(records, start=1): + panel_rows.append( + _panel_row( + panel_id=f"{outcome_label}{col_index}", + role=outcome_label, + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=( + f"true={row['true_label']}; pred={row['predicted_label']}; " + f"score={float(row['y_score']):.3f}" + ), + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F5", + figure_basename="a01_best_handcrafted_tp_tn_fp_fn", + figure_kind="a01_f5_confusion_gallery", + hypothesis="H1", + title="A01-F5. True and false decisions of the best handcrafted model", + variant_index=variant_index, + ), + "outcome_rows": outcome_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a01_f6_variants(variant_count: int) -> list[dict[str, Any]]: + frame = load_h5_subject_frame(split_name="test").copy() + score_columns = [ + "handcrafted_y_score", + "cnn_bbox_y_score", + "cnn_mask_y_score", + "cnn_late_fusion_y_score", + ] + pred_columns = [ + "handcrafted_y_pred", + "cnn_bbox_y_pred", + "cnn_mask_y_pred", + "cnn_late_fusion_y_pred", + ] + frame["score_spread"] = frame[score_columns].max(axis=1) - frame[score_columns].min(axis=1) + frame["prediction_disagreement"] = frame[pred_columns].nunique(axis=1) + frame["handcrafted_margin"] = (frame["handcrafted_y_score"] - 0.5).abs() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + confident_positive = _take_rows( + frame.loc[frame["y_true"].eq(1) & frame["handcrafted_y_pred"].eq(frame["y_true"])].copy(), + n_items=2, + variant_index=variant_index, + sort_columns=["handcrafted_margin", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ) + confident_negative = _take_rows( + frame.loc[frame["y_true"].eq(0) & frame["handcrafted_y_pred"].eq(frame["y_true"])].copy(), + n_items=2, + variant_index=variant_index + 1, + sort_columns=["handcrafted_margin", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + extra_distinct_column="class_name", + ) + disagreement_positive = _take_rows( + frame.loc[frame["y_true"].eq(1) & frame["prediction_disagreement"].gt(1)].copy(), + n_items=2, + variant_index=variant_index, + sort_columns=["prediction_disagreement", "score_spread", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + ) + disagreement_negative = _take_rows( + frame.loc[frame["y_true"].eq(0) & frame["prediction_disagreement"].gt(1)].copy(), + n_items=2, + variant_index=variant_index + 2, + sort_columns=["prediction_disagreement", "score_spread", "visual_priority", "patient_id", "image_id", "object_id_within_image"], + ascending=[False, False, False, True, True, True], + unique_columns=("patient_id", "image_id"), + extra_distinct_column="class_name", + ) + + confident_rows = pd.concat([confident_positive, confident_negative], ignore_index=True).to_dict("records") + disagreement_rows = pd.concat([disagreement_positive, disagreement_negative], ignore_index=True).to_dict("records") + panel_rows: list[dict[str, Any]] = [] + for row_index, row in enumerate(confident_rows, start=1): + panel_rows.append( + _panel_row( + panel_id=f"confident_{row_index}", + role="confident", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=( + f"hcf={float(row['handcrafted_y_score']):.3f}; " + f"bbox={float(row['cnn_bbox_y_score']):.3f}; " + f"mask={float(row['cnn_mask_y_score']):.3f}; " + f"fusion={float(row['cnn_late_fusion_y_score']):.3f}" + ), + ) + ) + for row_index, row in enumerate(disagreement_rows, start=1): + panel_rows.append( + _panel_row( + panel_id=f"disagreement_{row_index}", + role="disagreement", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or ""), + source_table="object_level_deep_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=( + f"hcf={float(row['handcrafted_y_score']):.3f}; " + f"bbox={float(row['cnn_bbox_y_score']):.3f}; " + f"mask={float(row['cnn_mask_y_score']):.3f}; " + f"fusion={float(row['cnn_late_fusion_y_score']):.3f}" + ), + ) + ) + variants.append( + { + **_base_spec( + article_id="A01", + figure_id="A01-F6", + figure_basename="a01_handcrafted_vs_deep_same_cells", + figure_kind="a01_f6_same_cells_model_comparison", + hypothesis="H5", + title="A01-F6. Handcrafted and deep scores on the same test cells", + variant_index=variant_index, + ), + "confident_rows": confident_rows, + "disagreement_rows": disagreement_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a02_f1_variants(variant_count: int) -> list[dict[str, Any]]: + bundle = load_h4_subject_bundle() + image_scores_df = bundle["image_scores_df"].copy() + best_prediction_frame = bundle["best_prediction_frame"].copy() + + pivot = image_scores_df.pivot_table(index=["patient_id", "image_id"], columns="aggregation_method", values="score").reset_index() + image_meta = image_scores_df[ + ["patient_id", "image_id", "image_path", "blast_object_count", "non_blast_object_count", "n_objects"] + ].drop_duplicates() + pivot = pivot.merge(image_meta, on=["patient_id", "image_id"], how="left", validate="one_to_one") + pivot["method_spread"] = pivot[AGGREGATION_METHOD_ORDER].max(axis=1) - pivot[AGGREGATION_METHOD_ORDER].min(axis=1) + pivot = pivot.loc[ + pivot["n_objects"].between(6, 18) + & pivot["blast_object_count"].ge(1) + & pivot["non_blast_object_count"].ge(1) + ].copy() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + image_row = _pick_single_row( + pivot, + variant_index=variant_index, + sort_columns=["method_spread", "n_objects", "max_object_score", "patient_id", "image_id"], + ascending=[False, True, False, True, True], + dedupe_columns=["patient_id", "image_id"], + ) + top_object_rows = _group_rows( + best_prediction_frame.loc[ + best_prediction_frame["patient_id"].eq(image_row["patient_id"]) + & best_prediction_frame["image_id"].eq(image_row["image_id"]) + ].copy(), + top_n=6, + ) + method_rows = ( + image_scores_df.loc[ + image_scores_df["patient_id"].eq(image_row["patient_id"]) + & image_scores_df["image_id"].eq(image_row["image_id"]) + ] + .copy() + .set_index("aggregation_method") + .reindex(AGGREGATION_METHOD_ORDER) + .reset_index() + .to_dict("records") + ) + panel_rows = [ + _panel_row( + panel_id="A", + role="smear_image", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + split_name="test", + source_table="image_index.parquet", + source_path=str(image_row["image_path"]), + notes="original smear image", + ), + _panel_row( + panel_id="B", + role="smear_with_boxes", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + split_name="test", + source_table="object_level_baseline_predictions.parquet", + source_path=str(image_row["image_path"]), + notes="test image with top scored boxes", + ), + ] + for slot, row in enumerate(top_object_rows, start=1): + panel_rows.append( + _panel_row( + panel_id=f"C{slot}", + role="top_object", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or "test"), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=_note_score(float(row["y_score"])), + ) + ) + for method_row in method_rows: + panel_rows.append( + _panel_row( + panel_id=f"D_{method_row['aggregation_method']}", + role="aggregation_score", + patient_id=str(method_row["patient_id"]), + image_id=str(method_row["image_id"]), + split_name="test", + source_table="image_level_scores.parquet", + source_path="image_level_scores.parquet", + notes=f"{method_row['aggregation_method']}={float(method_row['score']):.3f}", + ) + ) + variants.append( + { + **_base_spec( + article_id="A02", + figure_id="A02-F1", + figure_basename="a02_from_cells_to_image_score", + figure_kind="a02_f1_cells_to_image_score", + hypothesis="H4", + title="A02-F1. From cell scores to an image-level score", + variant_index=variant_index, + ), + "image_row": image_row, + "top_object_rows": top_object_rows, + "method_rows": method_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _image_case_outcomes(image_scores_df: pd.DataFrame, best_method: str) -> pd.DataFrame: + frame = image_scores_df.loc[image_scores_df["aggregation_method"].eq(best_method)].copy() + frame["y_true"] = frame["image_contains_blast"].astype(int) + frame["y_pred"] = frame["score"].ge(0.5).astype(int) + mapping = {(1, 1): "TP", (0, 0): "TN", (0, 1): "FP", (1, 0): "FN"} + frame["outcome_label"] = frame.apply(lambda row: mapping[(int(row["y_true"]), int(row["y_pred"]))], axis=1) + return frame + + +def _build_a02_f2_variants(variant_count: int) -> list[dict[str, Any]]: + bundle = load_h4_subject_bundle() + image_scores_df = bundle["image_scores_df"].copy() + best_prediction_frame = bundle["best_prediction_frame"].copy() + best_method = best_image_aggregation_method() + case_df = _image_case_outcomes(image_scores_df, best_method).copy() + + outcome_sort = { + "TP": (["score", "n_objects", "patient_id", "image_id"], [False, True, True, True]), + "TN": (["score", "n_objects", "patient_id", "image_id"], [True, True, True, True]), + "FP": (["score", "n_objects", "patient_id", "image_id"], [False, True, True, True]), + "FN": (["score", "n_objects", "patient_id", "image_id"], [True, True, True, True]), + } + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + cases: list[dict[str, Any]] = [] + panel_rows: list[dict[str, Any]] = [] + for outcome_label in ["TP", "TN", "FP", "FN"]: + sort_columns, ascending = outcome_sort[outcome_label] + selected_case = _pick_single_row( + case_df.loc[case_df["outcome_label"].eq(outcome_label)].copy(), + variant_index=variant_index, + sort_columns=sort_columns, + ascending=ascending, + dedupe_columns=["patient_id", "image_id"], + ) + top_objects = _group_rows( + best_prediction_frame.loc[ + best_prediction_frame["patient_id"].eq(selected_case["patient_id"]) + & best_prediction_frame["image_id"].eq(selected_case["image_id"]) + ].copy(), + top_n=3, + ) + cases.append({"outcome_label": outcome_label, "image_row": selected_case, "top_object_rows": top_objects}) + panel_rows.append( + _panel_row( + panel_id=f"{outcome_label}_image", + role="case_image", + patient_id=str(selected_case["patient_id"]), + image_id=str(selected_case["image_id"]), + target_binary=int(selected_case["image_contains_blast"]), + split_name="test", + source_table="image_level_scores.parquet", + source_path=str(selected_case["image_path"]), + notes=f"{outcome_label}; {best_method}={float(selected_case['score']):.3f}", + ) + ) + for slot, row in enumerate(top_objects, start=1): + panel_rows.append( + _panel_row( + panel_id=f"{outcome_label}_roi_{slot}", + role="case_object", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or "test"), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=_note_score(float(row["y_score"])), + ) + ) + variants.append( + { + **_base_spec( + article_id="A02", + figure_id="A02-F2", + figure_basename="a02_image_level_case_studies", + figure_kind="a02_f2_image_case_studies", + hypothesis="H4", + title="A02-F2. Image-level true and false cases", + variant_index=variant_index, + ), + "best_method": best_method, + "cases": cases, + "panel_rows": panel_rows, + } + ) + return variants + + +def _build_a02_f3_variants(variant_count: int) -> list[dict[str, Any]]: + bundle = load_h4_subject_bundle() + image_scores_df = bundle["image_scores_df"].copy() + best_prediction_frame = bundle["best_prediction_frame"].copy() + pivot = image_scores_df.pivot_table(index=["patient_id", "image_id"], columns="aggregation_method", values="score").reset_index() + image_meta = image_scores_df[ + ["patient_id", "image_id", "image_path", "blast_object_count", "non_blast_object_count", "n_objects"] + ].drop_duplicates() + pivot = pivot.merge(image_meta, on=["patient_id", "image_id"], how="left", validate="one_to_one") + pivot["method_spread"] = pivot[AGGREGATION_METHOD_ORDER].max(axis=1) - pivot[AGGREGATION_METHOD_ORDER].min(axis=1) + pivot = pivot.loc[pivot["n_objects"].between(6, 18)].copy() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + image_row = _pick_single_row( + pivot, + variant_index=variant_index + 1, + sort_columns=["method_spread", "n_objects", "patient_id", "image_id"], + ascending=[False, True, True, True], + dedupe_columns=["patient_id", "image_id"], + ) + top_object_rows = _group_rows( + best_prediction_frame.loc[ + best_prediction_frame["patient_id"].eq(image_row["patient_id"]) + & best_prediction_frame["image_id"].eq(image_row["image_id"]) + ].copy(), + top_n=4, + ) + method_rows = ( + image_scores_df.loc[ + image_scores_df["patient_id"].eq(image_row["patient_id"]) + & image_scores_df["image_id"].eq(image_row["image_id"]) + ] + .copy() + .set_index("aggregation_method") + .reindex(AGGREGATION_METHOD_ORDER) + .reset_index() + .to_dict("records") + ) + panel_rows = [ + _panel_row( + panel_id="A", + role="smear_with_boxes", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + split_name="test", + source_table="object_level_baseline_predictions.parquet", + source_path=str(image_row["image_path"]), + notes="same image for four aggregation methods", + ) + ] + for slot, row in enumerate(top_object_rows, start=1): + panel_rows.append( + _panel_row( + panel_id=f"C{slot}", + role="top_object", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or "test"), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=_note_score(float(row["y_score"])), + ) + ) + for method_row in method_rows: + panel_rows.append( + _panel_row( + panel_id=f"D_{method_row['aggregation_method']}", + role="aggregation_score", + patient_id=str(method_row["patient_id"]), + image_id=str(method_row["image_id"]), + split_name="test", + source_table="image_level_scores.parquet", + source_path="image_level_scores.parquet", + notes=f"{method_row['aggregation_method']}={float(method_row['score']):.3f}", + ) + ) + variants.append( + { + **_base_spec( + article_id="A02", + figure_id="A02-F3", + figure_basename="a02_four_aggregation_methods_same_image", + figure_kind="a02_f3_same_image_four_aggregators", + hypothesis="H4", + title="A02-F3. One image under four aggregation methods", + variant_index=variant_index, + ), + "image_row": image_row, + "top_object_rows": top_object_rows, + "method_rows": method_rows, + "panel_rows": panel_rows, + } + ) + return variants + + +def _best_image_method_rows(bundle: dict[str, Any], patient_id: str) -> pd.DataFrame: + best_method = best_image_aggregation_method() + image_scores_df = bundle["image_scores_df"].copy() + return image_scores_df.loc[ + image_scores_df["patient_id"].eq(patient_id) & image_scores_df["aggregation_method"].eq(best_method) + ].copy() + + +def _top_patient_images(bundle: dict[str, Any], patient_id: str, max_images: int) -> list[dict[str, Any]]: + patient_images = _best_image_method_rows(bundle, patient_id).sort_values( + ["score", "n_objects", "image_id"], ascending=[False, False, True] + ) + if len(patient_images) >= 4: + selected = patient_images.head(min(max_images, 4)) + else: + selected = patient_images.head(max_images) + return selected.to_dict("records") + + +def _build_a02_f4_variants(variant_count: int) -> list[dict[str, Any]]: + bundle = load_h4_subject_bundle() + patient_scores_df = bundle["patient_scores_df"].copy() + best_prediction_frame = bundle["best_prediction_frame"].copy() + best_patient_method = best_patient_aggregation_method() + patient_candidates = patient_scores_df.loc[ + patient_scores_df["aggregation_method"].eq(best_patient_method) & patient_scores_df["patient_contains_blast"].eq(1) + ].copy() + best_image_method = best_image_aggregation_method() + image_counts = ( + bundle["image_scores_df"] + .loc[bundle["image_scores_df"]["aggregation_method"].eq(best_image_method)] + .groupby("patient_id", as_index=False) + .agg(n_images=("image_id", "size"), max_image_score=("score", "max")) + ) + patient_candidates = patient_candidates.merge(image_counts, on="patient_id", how="left", validate="one_to_one") + patient_candidates = patient_candidates.loc[patient_candidates["n_images"].between(3, 25)].copy() + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + patient_row = _pick_single_row( + patient_candidates, + variant_index=variant_index, + sort_columns=["score", "n_images", "patient_id"], + ascending=[False, True, True], + dedupe_columns=["patient_id"], + ) + image_rows = _top_patient_images(bundle, str(patient_row["patient_id"]), max_images=4) + image_panels: list[dict[str, Any]] = [] + panel_rows: list[dict[str, Any]] = [] + for image_slot, image_row in enumerate(image_rows, start=1): + top_objects = _group_rows( + best_prediction_frame.loc[ + best_prediction_frame["patient_id"].eq(image_row["patient_id"]) + & best_prediction_frame["image_id"].eq(image_row["image_id"]) + ].copy(), + top_n=2, + ) + image_panels.append({"image_row": image_row, "top_object_rows": top_objects}) + panel_rows.append( + _panel_row( + panel_id=f"image_{image_slot}", + role="patient_image", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + target_binary=int(image_row["image_contains_blast"]), + split_name="test", + source_table="image_level_scores.parquet", + source_path=str(image_row["image_path"]), + notes=f"{best_image_method}={float(image_row['score']):.3f}", + ) + ) + for roi_slot, row in enumerate(top_objects, start=1): + panel_rows.append( + _panel_row( + panel_id=f"image_{image_slot}_roi_{roi_slot}", + role="patient_image_object", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or "test"), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=_note_score(float(row["y_score"])), + ) + ) + panel_rows.append( + _panel_row( + panel_id="patient_score", + role="patient_score", + patient_id=str(patient_row["patient_id"]), + split_name="test", + source_table="patient_level_scores.parquet", + source_path="patient_level_scores.parquet", + notes=f"{best_patient_method}={float(patient_row['score']):.3f}", + ) + ) + variants.append( + { + **_base_spec( + article_id="A02", + figure_id="A02-F4", + figure_basename="a02_from_images_to_patient_score", + figure_kind="a02_f4_images_to_patient_score", + hypothesis="H4", + title="A02-F4. From image-level scores to a patient-level score", + variant_index=variant_index, + ), + "patient_row": patient_row, + "best_image_method": best_image_method, + "best_patient_method": best_patient_method, + "image_panels": image_panels, + "panel_rows": panel_rows, + } + ) + return variants + + +def _patient_case_outcomes(patient_scores_df: pd.DataFrame, best_method: str) -> pd.DataFrame: + frame = patient_scores_df.loc[patient_scores_df["aggregation_method"].eq(best_method)].copy() + frame["y_true"] = frame["patient_contains_blast"].astype(int) + frame["y_pred"] = frame["score"].ge(0.5).astype(int) + mapping = {(1, 1): "TP", (0, 0): "TN", (0, 1): "FP", (1, 0): "FN"} + frame["outcome_label"] = frame.apply(lambda row: mapping[(int(row["y_true"]), int(row["y_pred"]))], axis=1) + return frame + + +def _build_a02_f5_variants(variant_count: int) -> list[dict[str, Any]]: + bundle = load_h4_subject_bundle() + patient_scores_df = bundle["patient_scores_df"].copy() + best_prediction_frame = bundle["best_prediction_frame"].copy() + best_patient_method = best_patient_aggregation_method() + best_image_method = best_image_aggregation_method() + patient_cases = _patient_case_outcomes(patient_scores_df, best_patient_method) + + variants: list[dict[str, Any]] = [] + for variant_index in range(1, variant_count + 1): + tp_row = _pick_single_row( + patient_cases.loc[patient_cases["outcome_label"].eq("TP")].copy(), + variant_index=variant_index, + sort_columns=["score", "n_objects", "patient_id"], + ascending=[False, False, True], + dedupe_columns=["patient_id"], + ) + fp_row = _pick_single_row( + patient_cases.loc[patient_cases["outcome_label"].eq("FP")].copy(), + variant_index=variant_index, + sort_columns=["score", "n_objects", "patient_id"], + ascending=[False, False, True], + dedupe_columns=["patient_id"], + ) + + case_panels: list[dict[str, Any]] = [] + panel_rows: list[dict[str, Any]] = [] + for case_label, patient_row in [("TP", tp_row), ("FP", fp_row)]: + image_rows = _top_patient_images(bundle, str(patient_row["patient_id"]), max_images=3) + image_panels: list[dict[str, Any]] = [] + for image_slot, image_row in enumerate(image_rows, start=1): + top_objects = _group_rows( + best_prediction_frame.loc[ + best_prediction_frame["patient_id"].eq(image_row["patient_id"]) + & best_prediction_frame["image_id"].eq(image_row["image_id"]) + ].copy(), + top_n=2, + ) + image_panels.append({"image_row": image_row, "top_object_rows": top_objects}) + panel_rows.append( + _panel_row( + panel_id=f"{case_label}_image_{image_slot}", + role=f"{case_label.lower()}_patient_image", + patient_id=str(image_row["patient_id"]), + image_id=str(image_row["image_id"]), + target_binary=int(image_row["image_contains_blast"]), + split_name="test", + source_table="image_level_scores.parquet", + source_path=str(image_row["image_path"]), + notes=f"{best_image_method}={float(image_row['score']):.3f}", + ) + ) + for roi_slot, row in enumerate(top_objects, start=1): + panel_rows.append( + _panel_row( + panel_id=f"{case_label}_image_{image_slot}_roi_{roi_slot}", + role=f"{case_label.lower()}_patient_object", + patient_id=str(row["patient_id"]), + image_id=str(row["image_id"]), + object_id_within_image=int(row["object_id_within_image"]), + class_name=str(row["class_name"]), + target_binary=_target_binary(row), + split_name=str(row.get("split_name") or "test"), + source_table="object_level_baseline_predictions.parquet", + source_path=str(row["bbox_crop_path"]), + notes=_note_score(float(row["y_score"])), + ) + ) + panel_rows.append( + _panel_row( + panel_id=f"{case_label}_patient_score", + role=f"{case_label.lower()}_patient_score", + patient_id=str(patient_row["patient_id"]), + split_name="test", + source_table="patient_level_scores.parquet", + source_path="patient_level_scores.parquet", + notes=f"{best_patient_method}={float(patient_row['score']):.3f}", + ) + ) + case_panels.append({"case_label": case_label, "patient_row": patient_row, "image_panels": image_panels}) + + variants.append( + { + **_base_spec( + article_id="A02", + figure_id="A02-F5", + figure_basename="a02_patient_level_case_studies", + figure_kind="a02_f5_patient_case_studies", + hypothesis="H4", + title="A02-F5. Patient-level true-positive and false-positive case studies", + variant_index=variant_index, + ), + "best_image_method": best_image_method, + "best_patient_method": best_patient_method, + "case_panels": case_panels, + "panel_rows": panel_rows, + } + ) + return variants + + +def build_subject_figure_specs(variant_count: int = VARIANT_COUNT) -> list[dict[str, Any]]: + specs: list[dict[str, Any]] = [] + specs.extend(_build_a01_f1_variants(variant_count)) + specs.extend(_build_a01_f2_variants(variant_count)) + specs.extend(_build_a01_f3_variants(variant_count)) + specs.extend(_build_a01_f4_variants(variant_count)) + specs.extend(_build_a01_f5_variants(variant_count)) + specs.extend(_build_a01_f6_variants(variant_count)) + specs.extend(_build_a02_f1_variants(variant_count)) + specs.extend(_build_a02_f2_variants(variant_count)) + specs.extend(_build_a02_f3_variants(variant_count)) + specs.extend(_build_a02_f4_variants(variant_count)) + specs.extend(_build_a02_f5_variants(variant_count)) + return specs + + +def flatten_subject_specs(specs: list[dict[str, Any]]) -> pd.DataFrame: + rows: list[dict[str, Any]] = [] + for spec in specs: + for panel_row in spec.get("panel_rows", []): + rows.append( + { + "article_id": spec["article_id"], + "figure_id": spec["figure_id"], + "figure_variant_id": spec["figure_variant_id"], + "figure_basename": spec["figure_basename"], + "hypothesis": spec["hypothesis"], + "hypothesis_slug": spec["hypothesis_slug"], + "variant_index": spec["variant_index"], + "figure_kind": spec["figure_kind"], + "title": spec["title"], + **panel_row, + } + ) + return pd.DataFrame(rows) diff --git a/src/evaluation/visualization.py b/src/evaluation/visualization.py new file mode 100644 index 0000000..c14588a --- /dev/null +++ b/src/evaluation/visualization.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import os +from pathlib import Path + +MPLCONFIGDIR = Path(__file__).resolve().parents[2] / "outputs" / "logs" / "matplotlib" +MPLCONFIGDIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from PIL import Image +from sklearn.metrics import ConfusionMatrixDisplay, PrecisionRecallDisplay, RocCurveDisplay, confusion_matrix + +from evaluation.metrics import ranking_recall_curve + + +def save_pr_roc_summary(prediction_df: pd.DataFrame, pr_path: Path, roc_path: Path) -> None: + top_runs = ( + prediction_df.groupby(["task_name", "model_name", "feature_group"], as_index=False)["y_score"] + .mean() + .sort_values(["task_name", "y_score"], ascending=[True, False]) + .groupby("task_name", group_keys=False) + .head(2) + ) + + fig_pr, ax_pr = plt.subplots(figsize=(10, 8)) + fig_roc, ax_roc = plt.subplots(figsize=(10, 8)) + for _, run in top_runs.iterrows(): + run_df = prediction_df.loc[ + (prediction_df["task_name"] == run["task_name"]) + & (prediction_df["model_name"] == run["model_name"]) + & (prediction_df["feature_group"] == run["feature_group"]) + ] + label = f"{run['task_name']} | {run['model_name']} | {run['feature_group']}" + PrecisionRecallDisplay.from_predictions( + run_df["y_true"], + run_df["y_score"], + name=label, + ax=ax_pr, + ) + RocCurveDisplay.from_predictions( + run_df["y_true"], + run_df["y_score"], + name=label, + ax=ax_roc, + ) + fig_pr.tight_layout() + fig_pr.savefig(pr_path, dpi=180) + plt.close(fig_pr) + + fig_roc.tight_layout() + fig_roc.savefig(roc_path, dpi=180) + plt.close(fig_roc) + + +def save_confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray, path: Path, title: str) -> None: + fig, ax = plt.subplots(figsize=(5, 5)) + ConfusionMatrixDisplay(confusion_matrix(y_true, y_pred, labels=[0, 1]), display_labels=["non-blast", "blast"]).plot( + ax=ax, + colorbar=False, + ) + ax.set_title(title) + fig.tight_layout() + fig.savefig(path, dpi=180) + plt.close(fig) + + +def save_bar_plot(data: pd.DataFrame, x: str, y: str, hue: str | None, path: Path, title: str, rotation: int = 30) -> None: + fig, ax = plt.subplots(figsize=(10, 6)) + if hue is None: + ax.bar(data[x].astype(str), data[y].astype(float)) + else: + pivot = data.pivot(index=x, columns=hue, values=y).fillna(0.0) + pivot.plot(kind="bar", ax=ax) + ax.set_title(title) + ax.tick_params(axis="x", rotation=rotation) + fig.tight_layout() + fig.savefig(path, dpi=180) + plt.close(fig) + + +def save_ranking_curve(score_df: pd.DataFrame, target_column: str, path: Path, title: str) -> None: + fig, ax = plt.subplots(figsize=(8, 6)) + for method, group in score_df.groupby("aggregation_method", sort=False): + screened_fraction, cumulative_recall = ranking_recall_curve( + group[target_column].astype(int).to_numpy(), + group["score"].astype(float).to_numpy(), + ) + ax.plot(screened_fraction, cumulative_recall, label=method) + ax.set_xlabel("Screened fraction") + ax.set_ylabel("Recall of positive groups") + ax.set_title(title) + ax.legend() + fig.tight_layout() + fig.savefig(path, dpi=180) + plt.close(fig) + + +def save_pipeline_overview(path: Path) -> None: + fig, ax = plt.subplots(figsize=(12, 4)) + ax.axis("off") + boxes = [ + (0.05, 0.5, "Data\n(images, labels, masks)"), + (0.23, 0.5, "A0-A2\nindexing and split"), + (0.41, 0.5, "A3-A5\nROI and features"), + (0.59, 0.5, "A6-A9\nobject-level models"), + (0.77, 0.5, "A10\nimage/patient aggregation"), + ] + for x, y, text in boxes: + ax.text( + x, + y, + text, + ha="center", + va="center", + fontsize=12, + bbox={"boxstyle": "round,pad=0.5", "facecolor": "#f2f2f2", "edgecolor": "#333333"}, + transform=ax.transAxes, + ) + for start, end in zip(boxes[:-1], boxes[1:]): + ax.annotate( + "", + xy=(end[0] - 0.06, end[1]), + xytext=(start[0] + 0.08, start[1]), + xycoords=ax.transAxes, + textcoords=ax.transAxes, + arrowprops={"arrowstyle": "->", "linewidth": 2.0}, + ) + fig.tight_layout() + fig.savefig(path, dpi=180) + plt.close(fig) + + +def save_example_rois(bbox_paths: list[Path], mask_paths: list[Path], path: Path) -> None: + pairs = list(zip(bbox_paths[:4], mask_paths[:4], strict=False)) + if not pairs: + raise ValueError("No ROI pairs available for visualization.") + + fig, axes = plt.subplots(len(pairs), 2, figsize=(8, 2.5 * len(pairs))) + if len(pairs) == 1: + axes = np.asarray([axes]) + for row_idx, (bbox_path, mask_path) in enumerate(pairs): + bbox_img = np.asarray(Image.open(bbox_path).convert("RGB")) + mask_img = np.asarray(Image.open(mask_path).convert("RGB")) + axes[row_idx, 0].imshow(bbox_img) + axes[row_idx, 0].set_title("bbox crop") + axes[row_idx, 1].imshow(mask_img) + axes[row_idx, 1].set_title("mask crop") + axes[row_idx, 0].axis("off") + axes[row_idx, 1].axis("off") + fig.tight_layout() + fig.savefig(path, dpi=180) + plt.close(fig) diff --git a/src/features/__init__.py b/src/features/__init__.py new file mode 100644 index 0000000..f3e675a --- /dev/null +++ b/src/features/__init__.py @@ -0,0 +1 @@ +"""Feature extraction utilities for experiment E01.""" diff --git a/src/features/extract_color.py b/src/features/extract_color.py new file mode 100644 index 0000000..0d45d68 --- /dev/null +++ b/src/features/extract_color.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import numpy as np +from skimage.color import rgb2hsv, rgb2lab + + +def _stats(prefix: str, values: np.ndarray) -> dict[str, float]: + return { + f"{prefix}_mean": float(np.mean(values)), + f"{prefix}_std": float(np.std(values)), + f"{prefix}_q25": float(np.quantile(values, 0.25)), + f"{prefix}_q50": float(np.quantile(values, 0.50)), + f"{prefix}_q75": float(np.quantile(values, 0.75)), + } + + +def color_features(image_rgb: np.ndarray, binary_mask: np.ndarray | None = None) -> dict[str, float]: + rgb = image_rgb.astype(np.float32) / 255.0 + if binary_mask is None: + pixels = rgb.reshape(-1, 3) + else: + pixels = rgb[binary_mask] + if pixels.size == 0: + pixels = rgb.reshape(-1, 3) + + hsv_pixels = rgb2hsv(pixels.reshape(-1, 1, 3)).reshape(-1, 3) + lab_pixels = rgb2lab(pixels.reshape(-1, 1, 3)).reshape(-1, 3) + + features: dict[str, float] = {} + for index, channel in enumerate(["r", "g", "b"]): + features.update(_stats(f"color_{channel}", pixels[:, index])) + for index, channel in enumerate(["h", "s", "v"]): + features.update(_stats(f"hsv_{channel}", hsv_pixels[:, index])) + for index, channel in enumerate(["l", "a", "b"]): + features.update(_stats(f"lab_{channel}", lab_pixels[:, index])) + return features diff --git a/src/features/extract_morphology.py b/src/features/extract_morphology.py new file mode 100644 index 0000000..b88e5c1 --- /dev/null +++ b/src/features/extract_morphology.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import numpy as np +from skimage.measure import perimeter, regionprops + + +def morphology_from_mask(binary_mask: np.ndarray) -> dict[str, float]: + features: dict[str, float] = {} + area = float(binary_mask.sum()) + features["morph_area_px"] = area + if area <= 0: + features.update( + { + "morph_perimeter_px": 0.0, + "morph_extent": 0.0, + "morph_eccentricity": 0.0, + "morph_solidity": 0.0, + "morph_roundness": 0.0, + "morph_bbox_fill_ratio": 0.0, + } + ) + return features + + props = regionprops(binary_mask.astype(np.uint8))[0] + perim = float(perimeter(binary_mask, neighborhood=8)) + bbox_area = float((props.bbox[2] - props.bbox[0]) * (props.bbox[3] - props.bbox[1])) + features["morph_perimeter_px"] = perim + features["morph_extent"] = float(props.extent) + features["morph_eccentricity"] = float(props.eccentricity) + features["morph_solidity"] = float(props.solidity) + features["morph_roundness"] = float((4.0 * np.pi * area / (perim ** 2)) if perim > 0 else 0.0) + features["morph_bbox_fill_ratio"] = float(area / bbox_area) if bbox_area > 0 else 0.0 + return features + + +def morphology_from_bbox(width_px: int, height_px: int) -> dict[str, float]: + area = float(width_px * height_px) + perimeter_px = float(2 * (width_px + height_px)) + aspect_ratio = float(width_px / height_px) if height_px > 0 else 0.0 + return { + "morph_bbox_area_px": area, + "morph_bbox_width_px": float(width_px), + "morph_bbox_height_px": float(height_px), + "morph_bbox_perimeter_px": perimeter_px, + "morph_bbox_aspect_ratio": aspect_ratio, + "morph_bbox_diagonal_px": float(np.hypot(width_px, height_px)), + } diff --git a/src/features/extract_texture_glcm.py b/src/features/extract_texture_glcm.py new file mode 100644 index 0000000..dfe0fe1 --- /dev/null +++ b/src/features/extract_texture_glcm.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import numpy as np +from skimage.feature import graycomatrix, graycoprops +from skimage.filters import laplace, sobel + + +def _downsample(gray_image: np.ndarray, binary_mask: np.ndarray | None = None, max_side: int = 96) -> tuple[np.ndarray, np.ndarray | None]: + working = gray_image + mask = binary_mask + max_dim = max(working.shape[:2]) + if max_dim <= max_side: + return working, mask + step = int(np.ceil(max_dim / max_side)) + working = working[::step, ::step] + if mask is not None: + mask = mask[::step, ::step] + return working, mask + + +def texture_glcm_features(gray_image: np.ndarray, binary_mask: np.ndarray | None = None) -> dict[str, float]: + gray_image, binary_mask = _downsample(gray_image, binary_mask) + if binary_mask is not None: + working = gray_image.copy() + working[~binary_mask] = 0 + else: + working = gray_image + + quantized = np.clip((working / 8).astype(np.uint8), 0, 31) + glcm = graycomatrix( + quantized, + distances=[1, 2], + angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4], + levels=32, + symmetric=True, + normed=True, + ) + features: dict[str, float] = {} + for prop in ["contrast", "dissimilarity", "homogeneity", "ASM", "energy", "correlation"]: + values = graycoprops(glcm, prop) + features[f"texture_glcm_{prop.lower()}_mean"] = float(np.mean(values)) + features[f"texture_glcm_{prop.lower()}_std"] = float(np.std(values)) + + sobel_img = sobel(working.astype(np.float32) / 255.0) + laplace_img = laplace(working.astype(np.float32) / 255.0) + features["texture_sobel_mean"] = float(np.mean(sobel_img)) + features["texture_sobel_std"] = float(np.std(sobel_img)) + features["texture_laplace_mean"] = float(np.mean(laplace_img)) + features["texture_laplace_std"] = float(np.std(laplace_img)) + return features diff --git a/src/features/extract_texture_lbp.py b/src/features/extract_texture_lbp.py new file mode 100644 index 0000000..fdf92d9 --- /dev/null +++ b/src/features/extract_texture_lbp.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import numpy as np +from skimage.feature import local_binary_pattern + + +def _downsample(gray_image: np.ndarray, binary_mask: np.ndarray | None = None, max_side: int = 96) -> tuple[np.ndarray, np.ndarray | None]: + working = gray_image + mask = binary_mask + max_dim = max(working.shape[:2]) + if max_dim <= max_side: + return working, mask + step = int(np.ceil(max_dim / max_side)) + working = working[::step, ::step] + if mask is not None: + mask = mask[::step, ::step] + return working, mask + + +def texture_lbp_features(gray_image: np.ndarray, binary_mask: np.ndarray | None = None) -> dict[str, float]: + gray_image, binary_mask = _downsample(gray_image, binary_mask) + working = np.clip(gray_image, 0, 255).astype(np.uint8, copy=False) + lbp_r1 = local_binary_pattern(working, P=8, R=1, method="uniform") + lbp_r2 = local_binary_pattern(working, P=16, R=2, method="uniform") + + if binary_mask is None: + values_r1 = lbp_r1.ravel() + values_r2 = lbp_r2.ravel() + else: + values_r1 = lbp_r1[binary_mask] + values_r2 = lbp_r2[binary_mask] + if values_r1.size == 0: + values_r1 = lbp_r1.ravel() + values_r2 = lbp_r2.ravel() + + hist_r1, _ = np.histogram(values_r1, bins=np.arange(0, 8 + 3), density=True) + hist_r2, _ = np.histogram(values_r2, bins=np.arange(0, 16 + 3), density=True) + + features: dict[str, float] = {} + for idx, value in enumerate(hist_r1): + features[f"texture_lbp_r1_bin_{idx:02d}"] = float(value) + for idx, value in enumerate(hist_r2): + features[f"texture_lbp_r2_bin_{idx:02d}"] = float(value) + return features diff --git a/src/features/extract_texture_wavelet.py b/src/features/extract_texture_wavelet.py new file mode 100644 index 0000000..bafded7 --- /dev/null +++ b/src/features/extract_texture_wavelet.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import numpy as np +import pywt + + +def _downsample(gray_image: np.ndarray, binary_mask: np.ndarray | None = None, max_side: int = 96) -> tuple[np.ndarray, np.ndarray | None]: + working = gray_image + mask = binary_mask + max_dim = max(working.shape[:2]) + if max_dim <= max_side: + return working, mask + step = int(np.ceil(max_dim / max_side)) + working = working[::step, ::step] + if mask is not None: + mask = mask[::step, ::step] + return working, mask + + +def texture_wavelet_features(gray_image: np.ndarray, binary_mask: np.ndarray | None = None) -> dict[str, float]: + gray_image, binary_mask = _downsample(gray_image, binary_mask) + working = gray_image.astype(np.float32) + if binary_mask is not None: + working = working.copy() + working[~binary_mask] = 0.0 + + coeffs2 = pywt.dwt2(working, "haar") + cA, (cH, cV, cD) = coeffs2 + features: dict[str, float] = {} + for name, coeff in [("ca", cA), ("ch", cH), ("cv", cV), ("cd", cD)]: + features[f"wavelet_{name}_mean"] = float(np.mean(coeff)) + features[f"wavelet_{name}_std"] = float(np.std(coeff)) + features[f"wavelet_{name}_energy"] = float(np.mean(np.square(coeff))) + return features diff --git a/src/features/feature_registry.py b/src/features/feature_registry.py new file mode 100644 index 0000000..4272f99 --- /dev/null +++ b/src/features/feature_registry.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from utils.project import CROPS_ROOT, FEATURES_ROOT, REPORTS_ROOT, TABLES_ROOT, ensure_output_layout, write_markdown +from features.extract_color import color_features +from features.extract_morphology import morphology_from_bbox, morphology_from_mask +from features.extract_texture_glcm import texture_glcm_features +from features.extract_texture_lbp import texture_lbp_features +from features.extract_texture_wavelet import texture_wavelet_features +from preprocessing.image_io import load_binary_mask, load_rgb_image + +import numpy as np +import pandas as pd +from joblib import Parallel, delayed + + +ROI_MANIFEST_PATH = CROPS_ROOT / "roi_manifest.parquet" +KEY_COLUMNS = {"patient_id", "image_id", "object_id_within_image", "class_id", "class_name", "has_mask"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Extract A5 feature matrices.") + parser.add_argument("--roi-manifest", type=Path, default=ROI_MANIFEST_PATH) + parser.add_argument("--output-suffix", type=str, default="") + parser.add_argument("--texture-profile", choices=["full", "lite"], default="full") + return parser.parse_args() + + +def _gray(image_rgb: np.ndarray) -> np.ndarray: + return np.dot(image_rgb[..., :3].astype(np.float32), np.array([0.299, 0.587, 0.114], dtype=np.float32)) + + +def _record_key(row: dict[str, object]) -> dict[str, object]: + return { + "patient_id": row["patient_id"], + "image_id": row["image_id"], + "object_id_within_image": int(row["object_id_within_image"]), + "class_id": int(row["class_id"]), + "class_name": row["class_name"], + "has_mask": bool(row["has_mask"]), + } + + +def _texture_bundle( + gray_image: np.ndarray, + binary_mask: np.ndarray | None, + texture_profile: str, +) -> dict[str, float]: + features: dict[str, float] = {} + if texture_profile == "full": + features.update(texture_glcm_features(gray_image, binary_mask=binary_mask)) + features.update(texture_lbp_features(gray_image, binary_mask=binary_mask)) + features.update(texture_wavelet_features(gray_image, binary_mask=binary_mask)) + return features + + features.update(texture_lbp_features(gray_image, binary_mask=binary_mask)) + return features + + +def _feature_family(feature_name: str) -> str: + if feature_name == "mask_missing": + return "quality_flag" + normalized = feature_name + if normalized.startswith("bbox_"): + normalized = normalized[len("bbox_") :] + elif normalized.startswith("mask_"): + normalized = normalized[len("mask_") :] + + if normalized.startswith("morph_"): + return "morphology" + if normalized.startswith(("color_", "hsv_", "lab_")): + return "color" + if normalized.startswith(("texture_", "wavelet_")): + return "texture" + return "other" + + +def _feature_region(feature_name: str, matrix_name: str) -> str: + if feature_name.startswith("bbox_"): + return "bbox_crop" + if feature_name.startswith("mask_"): + return "mask_crop" + if matrix_name == "bbox": + return "bbox_crop" + if matrix_name == "mask": + return "mask_crop" + return "combined" + + +def _feature_description(feature_name: str) -> str: + normalized = feature_name + if normalized.startswith("bbox_"): + normalized = normalized[len("bbox_") :] + elif normalized.startswith("mask_"): + normalized = normalized[len("mask_") :] + + if normalized == "mask_missing": + return "Indicator that mask-derived features are unavailable for this object." + if normalized.startswith("morph_bbox_area_px"): + return "Bounding-box area in pixels." + if normalized.startswith("morph_bbox_width_px"): + return "Bounding-box width in pixels." + if normalized.startswith("morph_bbox_height_px"): + return "Bounding-box height in pixels." + if normalized.startswith("morph_bbox_perimeter_px"): + return "Bounding-box perimeter in pixels." + if normalized.startswith("morph_bbox_aspect_ratio"): + return "Bounding-box aspect ratio." + if normalized.startswith("morph_bbox_diagonal_px"): + return "Bounding-box diagonal length in pixels." + if normalized.startswith("morph_area_px"): + return "Mask area in pixels." + if normalized.startswith("morph_perimeter_px"): + return "Mask perimeter in pixels." + if normalized.startswith("morph_extent"): + return "Mask extent relative to its enclosing box." + if normalized.startswith("morph_eccentricity"): + return "Mask eccentricity from regionprops." + if normalized.startswith("morph_solidity"): + return "Mask solidity from regionprops." + if normalized.startswith("morph_roundness"): + return "Roundness computed as 4*pi*area/perimeter^2." + if normalized.startswith("morph_bbox_fill_ratio"): + return "Ratio between mask area and enclosing bounding-box area." + if normalized.startswith("color_"): + return "RGB channel summary statistic computed on selected pixels." + if normalized.startswith("hsv_"): + return "HSV channel summary statistic computed on selected pixels." + if normalized.startswith("lab_"): + return "Lab channel summary statistic computed on selected pixels." + if normalized.startswith("texture_glcm_"): + return "GLCM/Haralick-style texture statistic." + if normalized.startswith("texture_lbp_"): + return "Local Binary Pattern histogram bin." + if normalized.startswith("texture_sobel_"): + return "Sobel-gradient summary statistic." + if normalized.startswith("texture_laplace_"): + return "Laplacian summary statistic." + if normalized.startswith("wavelet_"): + return "Wavelet coefficient summary statistic." + return "Derived feature from the configured extraction pipeline." + + +def _build_feature_dictionary(matrix_name: str, frame: pd.DataFrame) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for column in frame.columns: + if column in KEY_COLUMNS: + continue + rows.append( + { + "matrix": matrix_name, + "feature_name": column, + "feature_family": _feature_family(column), + "source_region": _feature_region(column, matrix_name), + "description": _feature_description(column), + } + ) + return rows + + +def _build_missingness_rows(matrix_name: str, frame: pd.DataFrame) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for column in frame.columns: + if column in KEY_COLUMNS: + continue + rows.append( + { + "matrix": matrix_name, + "feature_name": column, + "missing_fraction": float(frame[column].isna().mean()), + "missing_count": int(frame[column].isna().sum()), + "is_constant": bool(frame[column].nunique(dropna=False) <= 1), + } + ) + return rows + + +def _extract_single_row( + row: dict[str, object], + texture_profile: str, +) -> tuple[dict[str, object], dict[str, object] | None, dict[str, object]]: + bbox_rgb = load_rgb_image(row["bbox_crop_path"]) + bbox_gray = _gray(bbox_rgb) + + bbox_features = {} + bbox_features.update(morphology_from_bbox(int(row["bbox_width_px"]), int(row["bbox_height_px"]))) + bbox_features.update(color_features(bbox_rgb)) + bbox_features.update(_texture_bundle(bbox_gray, binary_mask=None, texture_profile=texture_profile)) + bbox_prefixed = {f"bbox_{key}": value for key, value in bbox_features.items()} + + bbox_row = {**_record_key(row), **bbox_prefixed} + combined_row = {**_record_key(row), **bbox_prefixed} + + mask_row: dict[str, object] | None = None + if row["has_mask"] and row["mask_crop_path"]: + full_mask = load_binary_mask(row["mask_path"]) + bbox = ( + int(row["bbox_x1"]), + int(row["bbox_y1"]), + int(row["bbox_x2"]), + int(row["bbox_y2"]), + ) + mask_crop = full_mask[bbox[1] : bbox[3], bbox[0] : bbox[2]] + mask_rgb = load_rgb_image(row["mask_crop_path"]) + mask_gray = _gray(mask_rgb) + + mask_features = {} + mask_features.update(morphology_from_mask(mask_crop)) + mask_features.update(color_features(mask_rgb, binary_mask=mask_crop)) + mask_features.update(_texture_bundle(mask_gray, binary_mask=mask_crop, texture_profile=texture_profile)) + mask_prefixed = {f"mask_{key}": value for key, value in mask_features.items()} + mask_row = {**_record_key(row), **mask_prefixed} + combined_row.update(mask_prefixed) + else: + combined_row["mask_missing"] = 1 + + return bbox_row, mask_row, combined_row + + +def extract_features( + roi_manifest_path: Path, + output_suffix: str = "", + texture_profile: str = "full", +) -> dict[str, str]: + ensure_output_layout() + roi_df = pd.read_parquet(roi_manifest_path) + roi_records = roi_df.to_dict("records") + n_jobs = min(8, max(1, os.cpu_count() or 1)) + extracted_rows = Parallel(n_jobs=n_jobs, prefer="threads", batch_size=64)( + delayed(_extract_single_row)(row, texture_profile) for row in roi_records + ) + bbox_rows = [bbox_row for bbox_row, _, _ in extracted_rows] + mask_rows = [mask_row for _, mask_row, _ in extracted_rows if mask_row is not None] + combined_rows = [combined_row for _, _, combined_row in extracted_rows] + + features_bbox_df = pd.DataFrame(bbox_rows) + features_mask_df = pd.DataFrame(mask_rows) + features_combined_df = pd.DataFrame(combined_rows) + + suffix = output_suffix.strip() + features_bbox_path = FEATURES_ROOT / f"features_bbox{suffix}.parquet" + features_mask_path = FEATURES_ROOT / f"features_mask{suffix}.parquet" + features_combined_path = FEATURES_ROOT / f"features_combined{suffix}.parquet" + features_bbox_df.to_parquet(features_bbox_path, index=False) + features_mask_df.to_parquet(features_mask_path, index=False) + features_combined_df.to_parquet(features_combined_path, index=False) + + dictionary_rows = [] + for frame_name, frame in [("bbox", features_bbox_df), ("mask", features_mask_df), ("combined", features_combined_df)]: + dictionary_rows.extend(_build_feature_dictionary(frame_name, frame)) + feature_dictionary_csv = FEATURES_ROOT / f"feature_dictionary{suffix}.csv" + pd.DataFrame(dictionary_rows).to_csv(feature_dictionary_csv, index=False) + dictionary_lines = [ + "# Feature Dictionary", + "", + "| Matrix | Feature name | Family | Source region | Description |", + "|---|---|---|---|---|", + ] + for row in dictionary_rows: + dictionary_lines.append( + f"| {row['matrix']} | {row['feature_name']} | {row['feature_family']} | {row['source_region']} | {row['description']} |" + ) + feature_dictionary_md = FEATURES_ROOT / f"feature_dictionary{suffix}.md" + write_markdown(feature_dictionary_md, dictionary_lines) + + missingness_rows = [] + for frame_name, frame in [("bbox", features_bbox_df), ("mask", features_mask_df), ("combined", features_combined_df)]: + missingness_rows.extend(_build_missingness_rows(frame_name, frame)) + missingness_df = pd.DataFrame(missingness_rows) + missingness_path = TABLES_ROOT / f"feature_missingness{suffix}.csv" + missingness_df.to_csv(missingness_path, index=False) + combined_missingness_df = missingness_df.loc[missingness_df["matrix"].eq("combined")].copy() + + report_lines = [ + "# A5 Feature Extraction QC", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + f"- BBox feature rows: `{len(features_bbox_df)}`", + f"- Mask feature rows: `{len(features_mask_df)}`", + f"- Combined feature rows: `{len(features_combined_df)}`", + f"- Parallel workers used: `{n_jobs}`", + f"- Texture profile: `{texture_profile}`", + f"- Numeric feature count (combined): `{int(len(combined_missingness_df))}`", + f"- Combined features with missing values: `{int((combined_missingness_df['missing_fraction'] > 0).sum())}`", + f"- Combined constant features: `{int(combined_missingness_df['is_constant'].sum())}`", + f"- Total dictionary rows: `{len(dictionary_rows)}`", + ] + qc_report_path = REPORTS_ROOT / f"feature_extraction_qc{suffix}.md" + write_markdown(qc_report_path, report_lines) + + return { + "features_bbox": str(features_bbox_path), + "features_mask": str(features_mask_path), + "features_combined": str(features_combined_path), + "feature_dictionary_csv": str(feature_dictionary_csv), + "feature_dictionary_md": str(feature_dictionary_md), + "feature_missingness_csv": str(missingness_path), + "feature_extraction_qc_md": str(qc_report_path), + } + + +def main() -> None: + args = parse_args() + result = extract_features( + args.roi_manifest, + output_suffix=args.output_suffix, + texture_profile=args.texture_profile, + ) + print(result) + + +if __name__ == "__main__": + main() diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..0bfa5bf --- /dev/null +++ b/src/models/__init__.py @@ -0,0 +1 @@ +"""Model training utilities for experiment E01.""" diff --git a/src/models/baseline_pipeline.py b/src/models/baseline_pipeline.py new file mode 100644 index 0000000..d9e9e37 --- /dev/null +++ b/src/models/baseline_pipeline.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from sklearn.calibration import CalibratedClassifierCV +from sklearn.impute import SimpleImputer +from sklearn.kernel_approximation import Nystroem +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import LinearSVC +from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier + +try: + from xgboost import XGBClassifier + XGBOOST_AVAILABLE = True +except Exception: + XGBClassifier = None + XGBOOST_AVAILABLE = False + + +KEY_COLUMNS = [ + "patient_id", + "image_id", + "object_id_within_image", + "class_id", + "class_name", + "has_mask", +] + + +META_COLUMNS = KEY_COLUMNS + [ + "target_binary", + "split_name", + "geometry_valid", + "geometry_issue", + "mask_missing", +] + + +FEATURE_GROUPS = { + "morphology": ("morphology",), + "color": ("color",), + "texture": ("texture",), + "morphology_color": ("morphology", "color"), + "morphology_texture": ("morphology", "texture"), + "color_texture": ("color", "texture"), + "full": ("morphology", "color", "texture"), +} + + +@dataclass(frozen=True) +class ModelSpec: + name: str + implementation: str + uses_scaling: bool + + +def column_family(column_name: str) -> str | None: + if column_name in META_COLUMNS: + return None + normalized = column_name + if normalized.startswith("bbox_"): + normalized = normalized[len("bbox_") :] + elif normalized.startswith("mask_"): + normalized = normalized[len("mask_") :] + + if normalized.startswith("morph_"): + return "morphology" + if normalized.startswith(("color_", "hsv_", "lab_")): + return "color" + if normalized.startswith(("texture_", "wavelet_")): + return "texture" + return None + + +def feature_columns_by_group(feature_df: pd.DataFrame) -> dict[str, list[str]]: + family_to_columns: dict[str, list[str]] = {"morphology": [], "color": [], "texture": []} + for column in feature_df.columns: + family = column_family(column) + if family is None: + continue + family_to_columns[family].append(column) + + groups: dict[str, list[str]] = {} + for group_name, families in FEATURE_GROUPS.items(): + columns: list[str] = [] + for family in families: + columns.extend(family_to_columns[family]) + groups[group_name] = sorted(columns) + return groups + + +def merge_task_with_features(task_df: pd.DataFrame, feature_df: pd.DataFrame) -> pd.DataFrame: + merged = task_df.merge( + feature_df, + on=KEY_COLUMNS, + how="inner", + validate="one_to_one", + ) + if len(merged) == 0: + raise ValueError("Task-feature merge produced zero rows.") + return merged + + +def build_model(model_name: str, scale_pos_weight: float, use_class_weight: bool = True) -> tuple[Pipeline, ModelSpec]: + common_steps: list[tuple[str, object]] = [("imputer", SimpleImputer(strategy="median"))] + class_weight = "balanced" if use_class_weight else None + + if model_name == "logistic_regression": + pipeline = Pipeline( + steps=[ + *common_steps, + ("scaler", StandardScaler()), + ( + "classifier", + LogisticRegression( + max_iter=1000, + class_weight=class_weight, + solver="lbfgs", + n_jobs=None, + random_state=42, + ), + ), + ] + ) + return pipeline, ModelSpec("logistic_regression", "exact", True) + + if model_name == "linear_svm": + base = LinearSVC( + class_weight=class_weight, + max_iter=5000, + dual="auto", + random_state=42, + ) + pipeline = Pipeline( + steps=[ + *common_steps, + ("scaler", StandardScaler()), + ("classifier", CalibratedClassifierCV(base, cv=3, method="sigmoid")), + ] + ) + return pipeline, ModelSpec("linear_svm", "exact", True) + + if model_name == "rbf_svm": + base = LinearSVC( + class_weight=class_weight, + max_iter=5000, + dual="auto", + random_state=42, + ) + pipeline = Pipeline( + steps=[ + *common_steps, + ("scaler", StandardScaler()), + ("rbf_map", Nystroem(kernel="rbf", gamma=0.5, n_components=128, random_state=42)), + ("classifier", CalibratedClassifierCV(base, cv=3, method="sigmoid")), + ] + ) + return pipeline, ModelSpec("rbf_svm", "nystroem_approximation", True) + + if model_name == "random_forest": + pipeline = Pipeline( + steps=[ + *common_steps, + ( + "classifier", + RandomForestClassifier( + n_estimators=200, + max_depth=None, + min_samples_leaf=2, + class_weight="balanced_subsample" if use_class_weight else None, + n_jobs=-1, + random_state=42, + ), + ), + ] + ) + return pipeline, ModelSpec("random_forest", "exact", False) + + if model_name == "xgboost": + if XGBOOST_AVAILABLE: + pipeline = Pipeline( + steps=[ + *common_steps, + ( + "classifier", + XGBClassifier( + n_estimators=200, + max_depth=6, + learning_rate=0.05, + subsample=0.8, + colsample_bytree=0.8, + reg_lambda=1.0, + min_child_weight=1.0, + objective="binary:logistic", + eval_metric="logloss", + tree_method="hist", + n_jobs=8, + random_state=42, + scale_pos_weight=scale_pos_weight if use_class_weight else 1.0, + ), + ), + ] + ) + return pipeline, ModelSpec("xgboost", "exact", False) + + pipeline = Pipeline( + steps=[ + *common_steps, + ( + "classifier", + HistGradientBoostingClassifier( + max_depth=6, + learning_rate=0.05, + max_iter=200, + random_state=42, + ), + ), + ] + ) + return pipeline, ModelSpec("xgboost", "hist_gradient_boosting_fallback", False) + + raise ValueError(f"Unsupported model: {model_name}") + + +def fit_predict_probability( + pipeline: Pipeline, + X_train: pd.DataFrame, + y_train: pd.Series, + X_eval: pd.DataFrame, +) -> np.ndarray: + pipeline.fit(X_train, y_train) + if hasattr(pipeline, "predict_proba"): + return np.asarray(pipeline.predict_proba(X_eval)[:, 1], dtype=float) + + decision = np.asarray(pipeline.decision_function(X_eval), dtype=float) + return 1.0 / (1.0 + np.exp(-decision)) diff --git a/src/models/deep_baselines.py b/src/models/deep_baselines.py new file mode 100644 index 0000000..60a56bb --- /dev/null +++ b/src/models/deep_baselines.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json +import random +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pandas as pd + +try: + import torch + from torch import nn + from torch.utils.data import DataLoader, Dataset + from torchvision.io import read_image + from torchvision.transforms import v2 +except ModuleNotFoundError as exc: # pragma: no cover - optional runtime dependency + raise SystemExit( + "A9 requires torch and torchvision in the experiment .venv. " + "Install them before running run_a9_deep_baselines.py." + ) from exc + +from evaluation.metrics import compute_binary_metrics, optimize_threshold + + +IMAGE_SIZE = 128 +RANDOM_SEED = 42 + + +def seed_everything(seed: int = RANDOM_SEED) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def choose_device() -> torch.device: + if torch.backends.mps.is_available(): + return torch.device("mps") + if torch.cuda.is_available(): + return torch.device("cuda") + return torch.device("cpu") + + +class CropDataset(Dataset): + def __init__( + self, + frame: pd.DataFrame, + image_column: str, + feature_columns: list[str] | None = None, + train_mode: bool = False, + ) -> None: + self.frame = frame.reset_index(drop=True) + self.image_column = image_column + self.feature_columns = feature_columns or [] + self.train_mode = train_mode + self.transform = v2.Compose( + [ + v2.Resize((IMAGE_SIZE, IMAGE_SIZE), antialias=True), + v2.ToDtype(torch.float32, scale=True), + v2.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]), + ] + ) + + def __len__(self) -> int: + return len(self.frame) + + def __getitem__(self, index: int) -> dict[str, torch.Tensor]: + row = self.frame.iloc[index] + image = read_image(str(row[self.image_column])) + image = self.transform(image) + sample = { + "image": image, + "target": torch.tensor(int(row["target_binary"]), dtype=torch.float32), + } + if self.feature_columns: + tabular_values = row[self.feature_columns].to_numpy(dtype=np.float32, copy=True) + tabular_values = np.nan_to_num(tabular_values, nan=0.0, posinf=0.0, neginf=0.0) + sample["tabular"] = torch.tensor(tabular_values, dtype=torch.float32) + return sample + + +class SmallCNN(nn.Module): + def __init__(self, tabular_dim: int = 0) -> None: + super().__init__() + self.features = nn.Sequential( + nn.Conv2d(3, 32, kernel_size=3, padding=1), + nn.ReLU(inplace=True), + nn.MaxPool2d(2), + nn.Conv2d(32, 64, kernel_size=3, padding=1), + nn.ReLU(inplace=True), + nn.MaxPool2d(2), + nn.Conv2d(64, 128, kernel_size=3, padding=1), + nn.ReLU(inplace=True), + nn.AdaptiveAvgPool2d((1, 1)), + ) + self.image_head = nn.Sequential( + nn.Flatten(), + nn.Linear(128, 64), + nn.ReLU(inplace=True), + nn.Dropout(0.2), + ) + self.tabular_dim = tabular_dim + fusion_dim = 64 + tabular_dim + self.classifier = nn.Sequential( + nn.Linear(fusion_dim, 64), + nn.ReLU(inplace=True), + nn.Dropout(0.2), + nn.Linear(64, 1), + ) + + def forward(self, image: torch.Tensor, tabular: torch.Tensor | None = None) -> torch.Tensor: + image_embedding = self.image_head(self.features(image)) + if self.tabular_dim > 0: + assert tabular is not None + logits = self.classifier(torch.cat([image_embedding, tabular], dim=1)) + else: + logits = self.classifier(image_embedding) + return logits.squeeze(1) + + +@dataclass +class TrainingResult: + model_path: str + history: list[dict[str, float]] + threshold: float + test_predictions: pd.DataFrame + test_metrics: dict[str, float] + + +def _make_loader(frame: pd.DataFrame, image_column: str, feature_columns: list[str] | None, shuffle: bool) -> DataLoader: + dataset = CropDataset(frame, image_column=image_column, feature_columns=feature_columns, train_mode=shuffle) + return DataLoader( + dataset, + batch_size=128, + shuffle=shuffle, + num_workers=0, + pin_memory=False, + ) + + +def _run_epoch( + model: nn.Module, + loader: DataLoader, + device: torch.device, + optimizer: torch.optim.Optimizer | None, +) -> tuple[float, np.ndarray, np.ndarray]: + if optimizer is None: + model.eval() + else: + model.train() + criterion = nn.BCEWithLogitsLoss() + losses: list[float] = [] + all_targets: list[np.ndarray] = [] + all_scores: list[np.ndarray] = [] + + for batch in loader: + image = batch["image"].to(device) + target = batch["target"].to(device) + tabular = batch.get("tabular") + if tabular is not None: + tabular = tabular.to(device) + + if optimizer is not None: + optimizer.zero_grad(set_to_none=True) + logits = model(image, tabular) + loss = criterion(logits, target) + if optimizer is not None: + loss.backward() + optimizer.step() + + losses.append(float(loss.detach().cpu())) + all_targets.append(target.detach().cpu().numpy()) + all_scores.append(torch.sigmoid(logits).detach().cpu().numpy()) + + return float(np.mean(losses)), np.concatenate(all_targets), np.concatenate(all_scores) + + +def train_deep_baseline( + train_df: pd.DataFrame, + val_df: pd.DataFrame, + test_df: pd.DataFrame, + image_column: str, + feature_columns: list[str] | None, + checkpoint_path: Path, + max_epochs: int = 12, + patience: int = 3, +) -> TrainingResult: + seed_everything() + device = choose_device() + model = SmallCNN(tabular_dim=len(feature_columns or [])).to(device) + optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) + + train_loader = _make_loader(train_df, image_column=image_column, feature_columns=feature_columns, shuffle=True) + val_loader = _make_loader(val_df, image_column=image_column, feature_columns=feature_columns, shuffle=False) + test_loader = _make_loader(test_df, image_column=image_column, feature_columns=feature_columns, shuffle=False) + + best_state: dict[str, torch.Tensor] | None = None + best_val_pr_auc = -np.inf + best_threshold = 0.5 + epochs_without_improvement = 0 + history: list[dict[str, float]] = [] + + for epoch_idx in range(1, max_epochs + 1): + train_loss, train_true, train_score = _run_epoch(model, train_loader, device, optimizer) + val_loss, val_true, val_score = _run_epoch(model, val_loader, device, optimizer=None) + threshold = optimize_threshold(val_true, val_score) + train_metrics = compute_binary_metrics(train_true, train_score, 0.5) + val_metrics = compute_binary_metrics(val_true, val_score, threshold) + history.append( + { + "epoch": epoch_idx, + "train_loss": train_loss, + "val_loss": val_loss, + "train_pr_auc": train_metrics.pr_auc, + "val_pr_auc": val_metrics.pr_auc, + "val_recall_blast": val_metrics.recall_blast, + } + ) + if val_metrics.pr_auc > best_val_pr_auc: + best_val_pr_auc = val_metrics.pr_auc + best_threshold = threshold + best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()} + epochs_without_improvement = 0 + else: + epochs_without_improvement += 1 + if epochs_without_improvement >= patience: + break + + if best_state is None: + raise ValueError("Deep model did not produce a valid checkpoint.") + checkpoint_path.parent.mkdir(parents=True, exist_ok=True) + torch.save(best_state, checkpoint_path) + model.load_state_dict(best_state) + + _, test_true, test_score = _run_epoch(model, test_loader, device, optimizer=None) + test_metrics = compute_binary_metrics(test_true, test_score, best_threshold) + test_predictions = test_df.copy() + test_predictions["y_true"] = test_true + test_predictions["y_score"] = test_score + test_predictions["y_pred"] = (test_score >= best_threshold).astype(int) + + return TrainingResult( + model_path=str(checkpoint_path), + history=history, + threshold=best_threshold, + test_predictions=test_predictions, + test_metrics={ + "pr_auc": test_metrics.pr_auc, + "roc_auc": test_metrics.roc_auc, + "balanced_accuracy": test_metrics.balanced_accuracy, + "macro_f1": test_metrics.macro_f1, + "recall_blast": test_metrics.recall_blast, + "specificity": test_metrics.specificity, + "matthews_corrcoef": test_metrics.matthews_corrcoef, + "brier_score": test_metrics.brier_score, + "expected_calibration_error": test_metrics.expected_calibration_error, + }, + ) + + +def save_training_history(history_payload: dict[str, list[dict[str, float]]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(history_payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/src/preprocessing/__init__.py b/src/preprocessing/__init__.py new file mode 100644 index 0000000..f797de5 --- /dev/null +++ b/src/preprocessing/__init__.py @@ -0,0 +1 @@ +"""Preprocessing utilities for experiment E01.""" diff --git a/src/preprocessing/extract_bbox_crops.py b/src/preprocessing/extract_bbox_crops.py new file mode 100644 index 0000000..8bd9b64 --- /dev/null +++ b/src/preprocessing/extract_bbox_crops.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from utils.project import CROPS_ROOT, DATASET_INDEX_ROOT, REPORTS_ROOT, ensure_output_layout, write_markdown +from preprocessing.image_io import crop_array, load_rgb_image, save_rgb_image, yolo_to_bbox_pixels + +import pandas as pd + + +OBJECT_INDEX_PATH = DATASET_INDEX_ROOT / "object_index.parquet" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Extract bbox crops for A3.") + parser.add_argument("--object-index", type=Path, default=OBJECT_INDEX_PATH) + return parser.parse_args() + + +def extract_bbox_crops(object_index_path: Path) -> pd.DataFrame: + ensure_output_layout() + object_df = pd.read_parquet(object_index_path) + object_df = object_df.loc[object_df["include_in_main_protocol"].eq(True)].copy() + geometry_valid = ( + pd.Series(True, index=object_df.index) + & object_df["x_center_norm"].map(lambda value: pd.notna(value) and float("-inf") < float(value) < float("inf")) + & object_df["y_center_norm"].map(lambda value: pd.notna(value) and float("-inf") < float(value) < float("inf")) + & object_df["width_norm"].map(lambda value: pd.notna(value) and float("-inf") < float(value) < float("inf")) + & object_df["height_norm"].map(lambda value: pd.notna(value) and float("-inf") < float(value) < float("inf")) + & object_df["x_center_norm"].between(0, 1, inclusive="both") + & object_df["y_center_norm"].between(0, 1, inclusive="both") + & object_df["width_norm"].between(0, 1, inclusive="right") + & object_df["height_norm"].between(0, 1, inclusive="right") + ) + invalid_geometry_df = object_df.loc[~geometry_valid].copy() + invalid_geometry_path = CROPS_ROOT / "invalid_bbox_objects.csv" + invalid_geometry_df.to_csv(invalid_geometry_path, index=False) + object_df = object_df.loc[geometry_valid].copy() + object_df = object_df.sort_values(["image_path", "object_id_within_image"]) + + rows: list[dict[str, object]] = [] + current_image_path = None + current_image = None + + bbox_root = CROPS_ROOT / "bbox" / "main_protocol" + for row in object_df.to_dict("records"): + image_path = row["image_path"] + if image_path != current_image_path: + current_image = load_rgb_image(image_path) + current_image_path = image_path + + assert current_image is not None + image_height, image_width = current_image.shape[:2] + bbox = yolo_to_bbox_pixels( + image_width=image_width, + image_height=image_height, + x_center_norm=float(row["x_center_norm"]), + y_center_norm=float(row["y_center_norm"]), + width_norm=float(row["width_norm"]), + height_norm=float(row["height_norm"]), + ) + crop = crop_array(current_image, bbox) + bbox_path = ( + bbox_root + / str(row["patient_id"]) + / f"{row['image_id']}__obj_{int(row['object_id_within_image']):04d}.png" + ) + save_rgb_image(crop, bbox_path) + + rows.append( + { + "patient_id": row["patient_id"], + "image_id": row["image_id"], + "image_path": row["image_path"], + "object_id_within_image": int(row["object_id_within_image"]), + "class_id": int(row["class_id"]), + "class_name": row["class_name"], + "bbox_x1": bbox[0], + "bbox_y1": bbox[1], + "bbox_x2": bbox[2], + "bbox_y2": bbox[3], + "bbox_width_px": bbox[2] - bbox[0], + "bbox_height_px": bbox[3] - bbox[1], + "bbox_crop_path": str(bbox_path), + "has_mask": bool(row["has_mask"]), + "mask_path": row["mask_path"], + "image_width_px": image_width, + "image_height_px": image_height, + } + ) + + bbox_manifest_df = pd.DataFrame(rows) + bbox_manifest_df.to_parquet(CROPS_ROOT / "bbox_roi_manifest.parquet", index=False) + unique_image_count = int(bbox_manifest_df[["patient_id", "image_id"]].drop_duplicates().shape[0]) if len(bbox_manifest_df) else 0 + + report_lines = [ + "# A3 BBox Crop QC", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + f"- Objects processed: `{len(bbox_manifest_df)}`", + f"- Objects skipped due to invalid geometry: `{len(invalid_geometry_df)}`", + f"- Unique images processed: `{unique_image_count}`", + f"- BBox crop directory: `{bbox_root}`", + f"- Invalid-geometry artifact: `{invalid_geometry_path}`", + ] + write_markdown(REPORTS_ROOT / "a3_bbox_qc.md", report_lines) + return bbox_manifest_df + + +def main() -> None: + args = parse_args() + manifest_df = extract_bbox_crops(args.object_index) + print({"bbox_manifest_rows": len(manifest_df)}) + + +if __name__ == "__main__": + main() diff --git a/src/preprocessing/extract_mask_crops.py b/src/preprocessing/extract_mask_crops.py new file mode 100644 index 0000000..ae8ee4d --- /dev/null +++ b/src/preprocessing/extract_mask_crops.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from utils.project import CROPS_ROOT, REPORTS_ROOT, ensure_output_layout, write_markdown +from preprocessing.image_io import crop_array, load_binary_mask, load_rgb_image, save_rgb_image + +import numpy as np +import pandas as pd + + +BBOX_MANIFEST_PATH = CROPS_ROOT / "bbox_roi_manifest.parquet" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Extract mask-aware crops for A3.") + parser.add_argument("--bbox-manifest", type=Path, default=BBOX_MANIFEST_PATH) + return parser.parse_args() + + +def _mask_tight_bbox(binary_crop: np.ndarray) -> tuple[int, int, int, int] | None: + ys, xs = np.where(binary_crop) + if len(xs) == 0 or len(ys) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + + +def extract_mask_crops(bbox_manifest_path: Path) -> pd.DataFrame: + ensure_output_layout() + bbox_manifest_df = pd.read_parquet(bbox_manifest_path) + + mask_root = CROPS_ROOT / "mask" / "main_protocol" + mask_tight_root = CROPS_ROOT / "mask_tight" / "main_protocol" + + bbox_manifest_df["mask_crop_path"] = None + bbox_manifest_df["mask_tight_crop_path"] = None + bbox_manifest_df["mask_tight_width_px"] = None + bbox_manifest_df["mask_tight_height_px"] = None + bbox_manifest_df["mask_area_px"] = None + bbox_manifest_df["mask_bbox_area_px"] = bbox_manifest_df["bbox_width_px"] * bbox_manifest_df["bbox_height_px"] + bbox_manifest_df["mask_status"] = np.where(bbox_manifest_df["has_mask"], "pending", "missing") + + current_full_image_path = None + current_full_image = None + for index, row in bbox_manifest_df.loc[bbox_manifest_df["has_mask"].eq(True)].iterrows(): + full_image_path = row.get("image_path") + if pd.isna(full_image_path): + full_image_path = None + + if full_image_path is None: + continue + + if full_image_path != current_full_image_path: + current_full_image = load_rgb_image(full_image_path) + current_full_image_path = full_image_path + + assert current_full_image is not None + full_mask = load_binary_mask(row["mask_path"]) + bbox = (int(row["bbox_x1"]), int(row["bbox_y1"]), int(row["bbox_x2"]), int(row["bbox_y2"])) + image_crop = crop_array(current_full_image, bbox) + mask_crop = crop_array(full_mask.astype(np.uint8), bbox).astype(bool) + masked_rgb = image_crop.copy() + masked_rgb[~mask_crop] = 0 + + mask_crop_path = ( + mask_root + / str(row["patient_id"]) + / f"{row['image_id']}__obj_{int(row['object_id_within_image']):04d}.png" + ) + save_rgb_image(masked_rgb, mask_crop_path) + + tight_bbox = _mask_tight_bbox(mask_crop) + mask_tight_crop_path = None + mask_tight_width = None + mask_tight_height = None + if tight_bbox is not None: + tx1, ty1, tx2, ty2 = tight_bbox + tight_rgb = masked_rgb[ty1:ty2, tx1:tx2] + mask_tight_crop_path = ( + mask_tight_root + / str(row["patient_id"]) + / f"{row['image_id']}__obj_{int(row['object_id_within_image']):04d}.png" + ) + save_rgb_image(tight_rgb, mask_tight_crop_path) + mask_tight_width = tx2 - tx1 + mask_tight_height = ty2 - ty1 + + bbox_manifest_df.at[index, "mask_crop_path"] = str(mask_crop_path) + bbox_manifest_df.at[index, "mask_tight_crop_path"] = str(mask_tight_crop_path) if mask_tight_crop_path else None + bbox_manifest_df.at[index, "mask_tight_width_px"] = mask_tight_width + bbox_manifest_df.at[index, "mask_tight_height_px"] = mask_tight_height + bbox_manifest_df.at[index, "mask_area_px"] = int(mask_crop.sum()) + bbox_manifest_df.at[index, "mask_status"] = "matched" + + output_manifest_path = CROPS_ROOT / "roi_manifest.parquet" + bbox_manifest_df.to_parquet(output_manifest_path, index=False) + + report_lines = [ + "# A3 Mask Crop QC", + "", + f"Generated at: `{pd.Timestamp.utcnow().isoformat()}`", + "", + f"- ROI manifest rows: `{len(bbox_manifest_df)}`", + f"- Rows with matched masks: `{int((bbox_manifest_df['mask_status'] == 'matched').sum())}`", + f"- Rows without masks: `{int((bbox_manifest_df['mask_status'] == 'missing').sum())}`", + f"- Mask crop directory: `{mask_root}`", + f"- Mask tight crop directory: `{mask_tight_root}`", + ] + write_markdown(REPORTS_ROOT / "a3_mask_qc.md", report_lines) + return bbox_manifest_df + + +def main() -> None: + args = parse_args() + manifest_df = extract_mask_crops(args.bbox_manifest) + print({"roi_manifest_rows": len(manifest_df)}) + + +if __name__ == "__main__": + main() diff --git a/src/preprocessing/image_io.py b/src/preprocessing/image_io.py new file mode 100644 index 0000000..7c618f0 --- /dev/null +++ b/src/preprocessing/image_io.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from PIL import Image + + +def load_rgb_image(path: str | Path) -> np.ndarray: + with Image.open(path) as image: + return np.asarray(image.convert("RGB")) + + +def load_binary_mask(path: str | Path) -> np.ndarray: + with Image.open(path) as image: + mask = np.asarray(image.convert("L")) + return mask > 0 + + +def save_rgb_image(array: np.ndarray, path: str | Path) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(array.astype(np.uint8), mode="RGB").save(path) + + +def yolo_to_bbox_pixels( + image_width: int, + image_height: int, + x_center_norm: float, + y_center_norm: float, + width_norm: float, + height_norm: float, +) -> tuple[int, int, int, int]: + bbox_width = max(1, int(round(width_norm * image_width))) + bbox_height = max(1, int(round(height_norm * image_height))) + center_x = x_center_norm * image_width + center_y = y_center_norm * image_height + + x1 = int(round(center_x - bbox_width / 2)) + y1 = int(round(center_y - bbox_height / 2)) + x2 = x1 + bbox_width + y2 = y1 + bbox_height + + x1 = max(0, min(x1, image_width - 1)) + y1 = max(0, min(y1, image_height - 1)) + x2 = max(x1 + 1, min(x2, image_width)) + y2 = max(y1 + 1, min(y2, image_height)) + return x1, y1, x2, y2 + + +def crop_array(array: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray: + x1, y1, x2, y2 = bbox + return array[y1:y2, x1:x2] diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..aadfe3e --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for experiment E01.""" diff --git a/src/utils/dataset_paths.py b/src/utils/dataset_paths.py new file mode 100644 index 0000000..88afb33 --- /dev/null +++ b/src/utils/dataset_paths.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CONFIG_PATH = PROJECT_ROOT / "configs" / "datasets" / "local_paths.json" +TEMPLATE_CONFIG_PATH = PROJECT_ROOT / "configs" / "datasets" / "local_paths.template.json" + + +def _normalize_path(path_value: str | Path, field_name: str) -> Path: + path = Path(path_value).expanduser().resolve() + if not path.exists(): + raise FileNotFoundError(f"Configured path for `{field_name}` does not exist: {path}") + if not path.is_dir(): + raise NotADirectoryError(f"Configured path for `{field_name}` is not a directory: {path}") + return path + + +def _load_config(config_path: Path) -> tuple[Path, Path, str]: + payload = json.loads(config_path.read_text(encoding="utf-8")) + if "data_root" not in payload or "masks_root" not in payload: + raise KeyError( + f"Dataset config must contain `data_root` and `masks_root`: {config_path}" + ) + data_root = _normalize_path(payload["data_root"], "data_root") + masks_root = _normalize_path(payload["masks_root"], "masks_root") + return data_root, masks_root, f"config:{config_path}" + + +def resolve_dataset_roots( + data_root: str | Path | None = None, + masks_root: str | Path | None = None, + config_path: str | Path | None = None, +) -> tuple[Path, Path, str]: + if (data_root is None) ^ (masks_root is None): + raise ValueError("Pass both `data_root` and `masks_root`, or neither of them.") + + if data_root is not None and masks_root is not None: + return ( + _normalize_path(data_root, "data_root"), + _normalize_path(masks_root, "masks_root"), + "cli", + ) + + env_data_root = os.environ.get("E01_DATA_ROOT") + env_masks_root = os.environ.get("E01_MASKS_ROOT") + if env_data_root and env_masks_root: + return ( + _normalize_path(env_data_root, "E01_DATA_ROOT"), + _normalize_path(env_masks_root, "E01_MASKS_ROOT"), + "env", + ) + + candidate_config = Path(config_path).expanduser().resolve() if config_path else DEFAULT_CONFIG_PATH + if candidate_config.exists(): + return _load_config(candidate_config) + + legacy_data_root = PROJECT_ROOT.parent / "Data" / "data" + legacy_masks_root = PROJECT_ROOT.parent / "Data" / "data_masks" + if legacy_data_root.exists() and legacy_masks_root.exists(): + return legacy_data_root.resolve(), legacy_masks_root.resolve(), "legacy_default" + + raise FileNotFoundError( + "Dataset paths are not configured. " + f"Create `{DEFAULT_CONFIG_PATH}` from `{TEMPLATE_CONFIG_PATH}`, " + "or set `E01_DATA_ROOT` and `E01_MASKS_ROOT`, " + "or pass `--data-root` and `--masks-root` to the entrypoint script." + ) diff --git a/src/utils/project.py b/src/utils/project.py new file mode 100644 index 0000000..b7e0d60 --- /dev/null +++ b/src/utils/project.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +OUTPUT_ROOT = PROJECT_ROOT / "outputs" +ARTICLES_ROOT = OUTPUT_ROOT / "article_exports" +A01_ARTICLE_ROOT = ARTICLES_ROOT / "A01_interpretable_blast_object_level" +A02_ARTICLE_ROOT = ARTICLES_ROOT / "A02_blast_image_patient_aggregation" +A01_FIGURES_ROOT = A01_ARTICLE_ROOT / "assets" / "figures" +A02_FIGURES_ROOT = A02_ARTICLE_ROOT / "assets" / "figures" +DATASET_INDEX_ROOT = OUTPUT_ROOT / "dataset_index" +SPLITS_ROOT = OUTPUT_ROOT / "splits" +TABLES_ROOT = OUTPUT_ROOT / "tables" +REPORTS_ROOT = OUTPUT_ROOT / "reports" +PREDICTIONS_ROOT = OUTPUT_ROOT / "predictions" +FIGURES_ROOT = OUTPUT_ROOT / "figures" +FEATURES_ROOT = OUTPUT_ROOT / "features" +CROPS_ROOT = OUTPUT_ROOT / "crops" +LOGS_ROOT = OUTPUT_ROOT / "logs" +CHECKPOINTS_ROOT = OUTPUT_ROOT / "checkpoints" +SUBJECT_FIGURE_CASES_PATH = TABLES_ROOT / "subject_figure_cases.csv" +SUBJECT_FIGURE_MANIFEST_PATH = TABLES_ROOT / "subject_figure_manifest.csv" +A01_SUBJECT_FIGURE_CASES_PATH = TABLES_ROOT / "a01_subject_figure_cases.csv" +A02_SUBJECT_FIGURE_CASES_PATH = TABLES_ROOT / "a02_subject_figure_cases.csv" +A01_ARTICLE_SUBJECT_MANIFEST_PATH = A01_FIGURES_ROOT / "figure_subject_manifest.csv" +A02_ARTICLE_SUBJECT_MANIFEST_PATH = A02_FIGURES_ROOT / "figure_subject_manifest.csv" +SUBJECT_FIGURES_SUMMARY_MD = REPORTS_ROOT / "subject_figures_summary.md" +SUBJECT_FIGURES_SUMMARY_JSON = REPORTS_ROOT / "subject_figures_summary.json" +EXPERIMENT_EXECUTION_LOG_PATH = LOGS_ROOT / "subject_figures_execution_log.md" + + +def ensure_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +def ensure_output_layout() -> None: + for directory in [ + OUTPUT_ROOT, + DATASET_INDEX_ROOT, + SPLITS_ROOT, + TABLES_ROOT, + REPORTS_ROOT, + PREDICTIONS_ROOT, + FIGURES_ROOT, + FEATURES_ROOT, + CROPS_ROOT, + LOGS_ROOT, + CHECKPOINTS_ROOT, + ]: + ensure_dir(directory) + + +def write_markdown(path: Path, lines: list[str]) -> None: + ensure_dir(path.parent) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_json(path: Path, payload: dict) -> None: + ensure_dir(path.parent) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def local_date_tag() -> str: + return datetime.now().strftime("%Y%m%d") diff --git a/tests/test_dataset_index.py b/tests/test_dataset_index.py new file mode 100644 index 0000000..15bfdec --- /dev/null +++ b/tests/test_dataset_index.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = PROJECT_ROOT / "src" +VENDOR_ROOT = PROJECT_ROOT / "vendor" +VENV_SITE_PACKAGES = sorted((PROJECT_ROOT / ".venv" / "lib").glob("python*/site-packages")) + +for candidate in [*VENV_SITE_PACKAGES, SRC_ROOT, VENDOR_ROOT]: + candidate_str = str(candidate) + if candidate.exists() and candidate_str not in sys.path: + sys.path.append(candidate_str) + +import pyarrow.parquet as pq + +from dataset.build_index import build_index_artifacts +from dataset.class_mapping import parse_predefined_classes +from dataset.parse_detection_labels import normalize_class_id, parse_label_file +from dataset.parse_masks import parse_mask_filename + + +class DatasetParsingTests(unittest.TestCase): + def test_parse_predefined_classes_supports_both_formats(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + indexed = Path(tmp_dir) / "indexed.txt" + indexed.write_text("0 Lymphocyte\n7 blast\n15 gumpricht shadows\n", encoding="utf-8") + line_only = Path(tmp_dir) / "line_only.txt" + line_only.write_text("Lymphocyte\nblast\ngumpricht shadows\n", encoding="utf-8") + + self.assertEqual(parse_predefined_classes(indexed), {0: "Lymphocyte", 7: "blast", 15: "gumpricht shadows"}) + self.assertEqual(parse_predefined_classes(line_only), {0: "Lymphocyte", 1: "blast", 2: "gumpricht shadows"}) + + def test_normalize_class_id_detects_float_like_tokens(self) -> None: + self.assertEqual(normalize_class_id("7"), (7, False)) + self.assertEqual(normalize_class_id("7.0"), (7, True)) + + def test_parse_label_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + label_path = Path(tmp_dir) / "sample.txt" + label_path.write_text("7.0 0.5 0.5 0.1 0.1\n12 0.2 0.2 0.05 0.05\n", encoding="utf-8") + rows = parse_label_file(label_path) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["class_id"], 7) + self.assertTrue(rows[0]["class_id_was_float"]) + self.assertEqual(rows[1]["class_id"], 12) + self.assertEqual(rows[1]["object_id_within_image"], 1) + + def test_parse_mask_filename(self) -> None: + parsed = parse_mask_filename(Path("img123_class_7_obj_0004.png")) + self.assertIsNotNone(parsed) + assert parsed is not None + self.assertEqual(parsed["image_id"], "img123") + self.assertEqual(parsed["class_id"], 7) + self.assertEqual(parsed["object_id_within_image"], 4) + + +class BuildIndexIntegrationTests(unittest.TestCase): + def test_build_index_artifacts_on_small_synthetic_dataset(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_root = Path(tmp_dir) + data_root = tmp_root / "data" + masks_root = tmp_root / "data_masks" + output_root = tmp_root / "outputs" + + patient_dir = data_root / "P000001.001" + patient_dir.mkdir(parents=True) + (patient_dir / "images").mkdir() + (patient_dir / "images_labels").mkdir() + (patient_dir / "predefined_classes.txt").write_text( + "0 Lymphocyte\n7 blast\n12 Bad cells\n15 gumpricht shadows\n", encoding="utf-8" + ) + (patient_dir / "images" / "img001.jpg").write_bytes(b"") + (patient_dir / "images_labels" / "img001.txt").write_text( + "7 0.5 0.5 0.1 0.1\n12.0 0.2 0.2 0.05 0.05\n", encoding="utf-8" + ) + + part_dir = data_root / "part-1" + part_dir.mkdir(parents=True) + (part_dir / "images").mkdir() + (part_dir / "images_labels").mkdir() + (part_dir / "predefined_classes.txt").write_text("Lymphocyte\nblast\n", encoding="utf-8") + (part_dir / "images" / "aux001.jpg").write_bytes(b"") + (part_dir / "images_labels" / "aux001.txt").write_text("7 0.5 0.5 0.1 0.1\n", encoding="utf-8") + + mask_patient_dir = masks_root / "P000001.001" + (mask_patient_dir / "images_results" / "masks_sam").mkdir(parents=True) + (mask_patient_dir / "images").mkdir() + (mask_patient_dir / "images_labels").mkdir() + (mask_patient_dir / "predefined_classes.txt").write_text( + "0 Lymphocyte\n7 blast\n12 Bad cells\n15 gumpricht shadows\n", encoding="utf-8" + ) + (mask_patient_dir / "images_results" / "masks_sam" / "img001_class_7_obj_0000.png").write_bytes(b"") + + mask_part_dir = masks_root / "part-1" + (mask_part_dir / "images_results" / "masks_sam").mkdir(parents=True) + (mask_part_dir / "images").mkdir() + (mask_part_dir / "images_labels").mkdir() + (mask_part_dir / "predefined_classes.txt").write_text("Lymphocyte\nblast\n", encoding="utf-8") + + (data_root / "predefined_classes.txt").write_text("Lymphocyte\nblast\nBad cells\n", encoding="utf-8") + + result = build_index_artifacts( + data_root=data_root, + masks_root=masks_root, + output_root=output_root, + main_protocol_excludes={"part-1"}, + ) + + self.assertTrue((output_root / "dataset_index" / "patient_inventory.csv").exists()) + self.assertTrue((output_root / "dataset_index" / "image_index.parquet").exists()) + self.assertTrue((output_root / "dataset_index" / "object_index.parquet").exists()) + self.assertTrue((output_root / "dataset_index" / "class_mapping.json").exists()) + self.assertTrue((output_root / "reports" / "data_audit_runtime.md").exists()) + + object_table = pq.read_table(output_root / "dataset_index" / "object_index.parquet") + image_table = pq.read_table(output_root / "dataset_index" / "image_index.parquet") + self.assertEqual(object_table.num_rows, 3) + self.assertEqual(image_table.num_rows, 2) + + class_mapping_payload = json.loads( + (output_root / "dataset_index" / "class_mapping.json").read_text(encoding="utf-8") + ) + self.assertIn("canonical_mapping", class_mapping_payload) + self.assertEqual(result["summary"]["main_patient_dirs"], 1) + self.assertEqual(result["summary"]["main_objects"], 2) + + +if __name__ == "__main__": + unittest.main()