Initial commit
This commit is contained in:
+32
@@ -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/
|
||||||
@@ -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 {} +
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"data_root": "/absolute/path/to/Data/data",
|
||||||
|
"masks_root": "/absolute/path/to/Data/data_masks"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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_<class_id>_obj_<index>.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. только после этого — расширение постановки на новые данные и более сложные модели.
|
||||||
@@ -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)
|
||||||
@@ -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_<id>_obj_<index>.png`.
|
||||||
|
5. Проверку, что `obj_<index>` совпадает с порядком строк в 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/<subset>/<patient_id>/<image_id>__obj_<id>.png`
|
||||||
|
- `outputs/crops/mask/<subset>/<patient_id>/<image_id>__obj_<id>.png`
|
||||||
|
- `outputs/crops/mask_tight/<subset>/<patient_id>/<image_id>__obj_<id>.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 классификацией и задачей автоматического анализатора.
|
||||||
|
|
||||||
|
Именно это и должно стать центральным результатом первого экспериментального цикла.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
Такая очередность позволяет не терять интерпретируемость на раннем этапе и при этом постепенно приближать работу к задаче полноценного автоматического анализатора препаратов крови в монослое.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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()
|
||||||
@@ -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"(?<!\*)\*([^*]+)\*(?!\*)")
|
||||||
|
|
||||||
|
|
||||||
|
def to_href(target: str) -> 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"<code>{html.escape(match.group(1))}</code>")
|
||||||
|
|
||||||
|
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'<a href="{href}">{label}</a>')
|
||||||
|
|
||||||
|
escaped = LINK_RE.sub(link_replace, escaped)
|
||||||
|
escaped = STRONG_RE.sub(r"<strong>\1</strong>", escaped)
|
||||||
|
escaped = EM_RE.sub(r"<em>\1</em>", 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 = ["<table>", "<thead>", "<tr>"]
|
||||||
|
for cell, align in zip(header, alignments):
|
||||||
|
parts.append(f'<th style="text-align: {align};">{render_inline(cell)}</th>')
|
||||||
|
parts.extend(["</tr>", "</thead>", "<tbody>"])
|
||||||
|
|
||||||
|
for row in body_rows:
|
||||||
|
parts.append("<tr>")
|
||||||
|
for idx, cell in enumerate(row):
|
||||||
|
align = alignments[idx] if idx < len(alignments) else "left"
|
||||||
|
parts.append(f'<td style="text-align: {align};">{render_inline(cell)}</td>')
|
||||||
|
parts.append("</tr>")
|
||||||
|
|
||||||
|
parts.extend(["</tbody>", "</table>"])
|
||||||
|
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"<p>{render_inline(paragraph)}</p>")
|
||||||
|
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"<li>{render_inline(item)}</li>")
|
||||||
|
body_parts.append(f"</{list_tag}>")
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
'<figure class="report-figure">',
|
||||||
|
f'<img src="{src}" alt="{html.escape(alt_text, quote=True)}">',
|
||||||
|
f"<figcaption>{caption}</figcaption>",
|
||||||
|
"</figure>",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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"<h{level}>{heading_text}</h{level}>")
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
'<html lang="ru">',
|
||||||
|
"<head>",
|
||||||
|
' <meta charset="utf-8">',
|
||||||
|
f" <title>{html.escape(title)}</title>",
|
||||||
|
" <style>",
|
||||||
|
css,
|
||||||
|
" </style>",
|
||||||
|
"</head>",
|
||||||
|
"<body>",
|
||||||
|
*body_parts,
|
||||||
|
"</body>",
|
||||||
|
"</html>",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Project source package for experiment E01."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Dataset utilities for experiment E01."""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CLASS_WITH_ID_RE = re.compile(r"^\s*(?P<class_id>\d+(?:\.0+)?)\s+(?P<class_name>.+?)\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())}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MASK_FILENAME_RE = re.compile(
|
||||||
|
r"^(?P<image_id>.+?)_class_(?P<class_id>[^_]+)_obj_(?P<object_id>\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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Evaluation utilities for experiment E01."""
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Feature extraction utilities for experiment E01."""
|
||||||
@@ -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
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Model training utilities for experiment E01."""
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Preprocessing utilities for experiment E01."""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility helpers for experiment E01."""
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user