Skip to content

Luismpso/AP2

Repository files navigation

🩻 Classificação de Imagens de Endoscopia Biliar

Python Status License

Aprendizagem Profunda | Mestrado em Inteligência Artificial | Universidade do Minho | 2025/26

Classificação multi-classe de imagens fluoroscópicas de CPRE em quatro categorias — Biliary_Leaks, Lithiasis, Normal e Stricture — com replicação fiel do baseline da referência (Monica) em pipeline MONAI puro, ablation sem o equipamento confounder (PCR Eleva), máscaras SAM3 como 4º canal (atenuam o viés do instrumento), um ensemble com explicabilidade Grad-CAM, modelos especialistas com routing por equipamento, VLMs em few-shot e um Conditional VAE para síntese de dados.


📊 Resultados

Comparação de Abordagens

Abordagem Melhor Modelo F1 Macro (test)
CNN — Monica baseline (replica, com PCR) DenseNet121 0.620 ± 0.077
CNN — Baseline (sem PCR em train/val) ResNet50 ablação — ver compare.md
CNN — SAM3 4-canais + ensemble (soft voting) 4 modelos (3× SAM3 4ch + RGB) 0.763
VLM — Few-Shot BiomedCLIP / Qwen2.5-VL
CVAE encoder + Classificador clássico CVAE-512 (latent 4096) + XGBoost / SVM / RF / MLP / LR
Concept Bottleneck BiomedCLIP + CBM

Baseline do paper (MIQR-CC, todos os equipamentos): F1 macro = 0.738

Todos os resultados CNN da replica são mean ± std sobre 3 seeds {1, 24, 42} com o test set sagrado (idêntico em todas as seeds, inclui PCR). O ensemble SAM3 (4 modelos, F1 0.763) e o seu ganho sobre a baseline (eliminação do viés do endoscópio) estão documentados em notebooks/ensemble.ipynb.

📄 Relatório: Ver PDF do Relatório


⚙️ Implementações

CNN — Monica Baseline (replicação fiel)

Replicação bit-a-bit do pipeline do paper de referência (MONAI puro quando preset: monica está activo). 5 modelos:

Modelo Backbone (lib) Imagem Config
ResNet50 torchvision V1 (tv_resnet50) 512 configs/resnet50_monica.yaml
DenseNet121 MONAI (monai_densenet121) 512 configs/densenet121_monica.yaml
EfficientNet-B7 torchvision V1 (tv_efficientnet_b7) 512 configs/efficientnet_b7_monica.yaml
MobileNetV2 torchvision V1 (tv_mobilenet_v2) 512 configs/mobilenetv2_monica.yaml
DeiT-III Small timm (deit3_small_patch16_384) 384 configs/deit3_small_monica.yaml

Pipeline activo quando augmentation.preset: monica:

  • Loss: monai.losses.FocalLoss(to_onehot_y=True) (γ=2)
  • Augmentations: monai.transformsRandRotate(range_x=15) (em radianos = 360° aleatório), RandZoom(0.9-1.1), RandAdjustContrast, RandGaussianNoise(std=0.01), NormalizeIntensity() (z-score per-image), Lambda(repeat_to_3ch)
  • DataLoader: monai.data.DataLoader, batch=4, num_workers=0, pin_memory=False, use_amp=False
  • Determinismo: set_determinism(seed=0) antes de set_seed(42), cudnn.deterministic=True
  • Optimizer: Adam lr=1e-4
  • Scheduler: CosineAnnealingLR(T_max=60)
  • Early stopping: val_F1_macro, patience=10
  • 60 epochs

Dois universos de splits

Estratégia para isolar o efeito do equipamento PCR Eleva:

Pasta Train/Val Test Uso
database/splits/monica/seed{N} inclui PCR inclui PCR (sagrado) Monica baseline — replica o paper exactamente
database/splits/seed{N} sem PCR inclui PCR (sagrado) Baseline limpa — quantifica o efeito confounder

seed42 em monica/ reproduz exactamente o monica/main original. O test set é idêntico em todas as seeds e em ambas as variantes (sagrado, com PCR).

VLMs — Few-Shot

  • BiomedCLIP — CLIP discriminativo pré-treinado em ~15M pares PubMed; zero-shot e linear probe
  • Qwen2.5-VL-7B — geração in-context multi-imagem (few-shot N=1..8), quantizado em 4-bit
  • VILA-M3-8B — backend alternativo via mesmo wrapper
  • Sweep sobre (seed, k, shot_sample, variante de prompt)

CVAE — Síntese de Dados (latente 4096)

Beta-VAE condicional por classe para geração de imagens sintéticas (Biliary_Leaks é a classe mais rara):

  • Latente espacial Cz×h×w com latent_channels=16 e lat_size=16latent_dim = 4096 (2^12) a 512×512
  • FiLM conditioning por classe em todos os blocos do decoder + planos de classe concatenados no encoder
  • VGG16 perceptual loss (relu1_2 / relu2_2 / relu3_3) — empurra para imagens nítidas em vez da média borrada
  • VAE-GAN condicional: discriminador por projeção com spectral norm, hinge loss, feature matching e DiffAugment (anti-memorização do D com poucos dados)
  • AuxClassifier força as 4 classes a ficarem distintas tanto na reconstrução como nas amostras (z~prior)

CVAE encoder + Classificador clássico

Usa o encoder do CVAE já treinado como feature extractor (mu 4096-d, label-agnostic via média sobre as 4 classes possíveis) e classifica com um de vários algoritmos clássicos: xgboost (default), svm (RBF + grid search), random_forest, mlp, logistic.

  • --classifier {xgboost, svm, random_forest, mlp, logistic} — todos partilham a mesma extração de features
  • --feature_mode {mean, concat, single} — controla como agregar mu(x|y) sobre as classes (default mean — sem vazamento da label verdadeira)
  • Compensação de desequilíbrio Biliary_Leaks ≪ Lithiasis: class_weight="balanced" (SVM/RF/LR) ou sample_weight inverso-frequência (XGB); veredito sempre por f1_macro
  • Multi-seed reproduzível via random_state
  • PCA opcional (--pca_dim 256) antes do classificador se as 4096 features puserem o modelo sobre-parametrizado

Outras Abordagens

  • ROI-Crop (Grad-CAM) — localiza lesão via Grad-CAM, corta ROI e reclassifica
  • Concept Bottleneck — BiomedCLIP + classificador linear sobre scores de conceitos clínicos

CNN — Máscaras SAM3 (4 canais)

Segmentação zero-shot com SAM3 (facebook/sam3) por prompts clínicos (ducto, guia, patologia, endoscópio) para focar o ROI e atenuar o viés do instrumento (o modelo aprende o endoscópio em vez da anatomia). A máscara entra como 4º canal (RGB + máscara soft); a 1ª conv é adaptada preservando os pesos pré-treinados (canal extra inicializado pela média RGB).

  • Geração: scripts/generate_sam3_masks.py (mask.png combinada; --multi gera mask_{duct,wire,pathology,device}.png) → database/masks/
  • Pipeline: src/data/dataset_sam3.py (CPRESAMM3Dataset, transforms sincronizados imagem+máscara), src/models/builder_sam3.py, entrypoints train_sam3.py / evaluate_sam3.py
  • Configs: configs/resnet50_sam3*.yaml
  • Exploração visual: notebooks/segmentation.ipynb · impacto na classificação: notebooks/masks.ipynb

Ensemble + Explicabilidade (Grad-CAM)

Soft-voting de várias ResNet50+SAM3 4-canais (melhor membro: resnet50_sam3_4ch_opt, peso ≈ 0.46). Relatório: notebooks/ensemble.ipynb.

Modelo / Ensemble F1-macro (test) Viés do instrumento
Baseline EfficientNet-B7 73.81% muito alto (shortcut no endoscópio)
Ensemble 3 modelos (soft voting) 75.18% mínimo
Ensemble 4 modelos (soft voting) 76.30% eliminado

Código em scripts/ensemble/ (composição, pesos, avaliação, stacking e geração do relatório + Grad-CAMs).

Modelos especialistas + routing

notebooks/pcr.ipynb separa por equipamento e junta no fim: Fase 1 especialista Ziehm Vision (não-PCR), Fase 2 especialista PCR (PCR Eleva, só ~36 imgs — test de 11 intocável), Fase 3 sistema com routing por equipamento (PCR → especialista PCR, resto → Ziehm).


🗃️ Dataset

Dataset MIQR-CC — imagens fluoroscópicas de CPRE, anotadas por classe diagnóstica.

  • 4 classes: Biliary_Leaks, Lithiasis, Normal, Stricture (Benign + Malignant Stricture agregadas — ver notebooks/domain.ipynb)
  • Splits por paciente (zero leakage), 3 seeds: {1, 24, 42}. Test set sagrado: extraído com TEST_SEED=42 fixo e idêntico em todas as seeds + variantes
  • 2 variantes geradas numa única execução do prepare_dataset.py:
    • database/splits/monica/seed{N} — inclui PCR em train/val (replica o paper)
    • database/splits/seed{N} — train/val sem PCR; test mantém PCR (sagrado)
  • monica/seed42 reproduz exactamente o monica/main original (mesmo split que o paper)

📂 Estrutura do Repositório

AP2/
├── configs/                           # Configurações YAML
│   ├── *_monica.yaml                  #   Monica baseline — 5 modelos (resnet50/densenet121/efficientnet_b7/mobilenetv2/deit3_small)
│   ├── resnet50_sam3*.yaml            #   SAM3 4-canais (base / _reg / _frozen)
│   ├── grid/                          #   ~288 configs do grid search (3 modelos × combos × seeds)
│   ├── biomedclip_fewshot.yaml        #   VLM — BiomedCLIP few-shot
│   ├── qwen2_5_vl_7b_fewshot.yaml     #   VLM — Qwen2.5-VL few-shot
│   ├── vila_m3_8b_fewshot.yaml        #   VLM — VILA-M3 few-shot
│   └── cvae.yaml                      #   CVAE — geração de imagens sintéticas
│
├── scripts/
│   ├── prepare_dataset.py             #   Splits por paciente, 2 variantes (--also_no_pcr_out)
│   ├── multiseed.py / aggregate_seeds.py / audit_splits.py / compare_datasets.py
│   ├── gridsearch.py                  #   Grid search retomável (preset MONAI)
│   ├── generate_sam3_masks.py         #   Máscaras SAM3 (combinada; --multi = por-categoria) → database/masks/
│   ├── mirror_masks_to_splits.py      #   Espelha máscaras para a estrutura dos splits
│   ├── train_cvae.py / generate_cvae.py / cvae_separability.py / cvae_classifier.py / cvae_grid*.py
│   ├── run_vlm_fewshot.py / run_biomedclip_fewshot.py / aggregate_vlm_results.py
│   ├── roi_experiment.py / concept_bottleneck.py / augment_experiment.py / metadata_only.py
│   └── ensemble/                      #   Ensemble SAM3 + relatório Grad-CAM (soft voting, save, eval, stacking)
│
├── src/
│   ├── data/
│   │   ├── dataset.py / transforms.py / loaders.py    #   pipeline cv2 + albumentations (default)
│   │   ├── monai_pipeline.py                          #   pipeline MONAI fiel (preset=monica)
│   │   └── dataset_sam3.py / loaders_sam3.py          #   CPRESAMM3Dataset 4-canais (RGB + máscara SAM3)
│   ├── models/
│   │   ├── builder.py                 #   backbones monai/torchvision/timm/SimpleCNN
│   │   ├── builder_sam3.py            #   variantes 4-canais (1ª conv adaptada)
│   │   └── cvae.py                    #   Conditional VAE-GAN
│   ├── interpretability/gradcam.py    #   Grad-CAM (CNN + ViT via reshape_transform)
│   ├── training/                      #   Trainer, losses (focal + monai_focal), métricas, EMA
│   ├── vlm/                           #   Wrappers BiomedCLIP, Qwen2.5-VL, VILA-M3
│   └── utils/                         #   Seed (com set_determinism MONAI), config
│
├── notebooks/
│   ├── domain.ipynb                   #   Contexto clínico das 4 classes
│   ├── exploration.ipynb              #   EDA + metadata-only + pré-proc/augmentation visuais
│   ├── baseline.ipynb                 #   Replica Monica (5 modelos sequenciais) + figuras
│   ├── augmentation.ipynb             #   Efeito da augmentation na ResNet50 (none/light/monica/heavy)
│   ├── masks.ipynb                    #   Impacto das máscaras SAM3 (4º canal) na ResNet50
│   ├── age.ipynb                      #   Fusão da idade (late fusion) na ResNet50
│   ├── pcr.ipynb                      #   Especialistas (Ziehm/PCR) + sistema com routing
│   ├── segmentation.ipynb             #   SAM3 — prompts, máscaras, tensor 4 canais
│   ├── ensemble.ipynb                 #   Ensemble SAM3 (soft-voting) + Grad-CAM
│   ├── conclusion.ipynb               #   Conclusão geral (vs baseline Mónica + Grad-CAM)
│   └── figures/                       #   Figuras exportadas pelos notebooks
│
├── monica/                            #   Notebooks originais da referência — read-only (gitignored)
│
├── database/                          #   Não versionado (gitignored)
│   ├── MIQR-CC-Dataset/               #     dataset original (metadata.csv + raw/ + processed/)
│   ├── splits/                        #     monica/seed{N} (com PCR) + seed{N} (sem PCR), test sagrado
│   └── masks/                         #     máscaras SAM3 por split/classe/imagem
│
├── outputs/                           #   Não versionado (gitignored)
│   ├── baseline/                      #     replica Monica (5 modelos × 3 seeds)
│   ├── ablation/ (pcr, clahe, metadata) · checkpoints/ · reports/ · gridsearch/ · ensemble/
│   └── audit.md · compare.md
│
├── docs/enunciado.pdf
├── train.py / evaluate.py             #   Entrypoints (CNN baseline)
├── train_sam3.py / evaluate_sam3.py   #   Entrypoints (pipeline SAM3 4-canais)
├── env.yaml
└── README.md

🚀 Reprodução (Monica baseline — pronto a correr)

Pré-requisitos

  • Git instalado
  • Anaconda ou Miniconda instalado
  • GPU CUDA recomendada (testado em RTX 5070 Ti, ~30–90 min por run conforme modelo)
  • Os comandos abaixo são para PowerShell (Windows). Em bash usar \ para quebra de linha em vez de uma linha só.

Passos

1. Clonar e configurar ambiente

git clone https://github.com/Luismpso/AP2.git
cd AP2
conda env create -f env.yaml
conda activate dl

CPU-only: trocar o URL no env.yaml para https://download.pytorch.org/whl/cpu. MONAI é dependência obrigatória (já em env.yaml).

2. Colocar o dataset

Colocar o dataset em database/MIQR-CC-Dataset/ (deve conter metadata.csv + raw/ + processed/).

3. Gerar splits (as 2 variantes numa só execução)

python scripts/prepare_dataset.py --dataset_dir database/MIQR-CC-Dataset --out_dir database/splits/monica --also_no_pcr_out database/splits --seeds 1 24 42 --no_exclude --overwrite

Resultado:

  • database/splits/monica/seed{1,24,42}/ — Monica-style (com PCR em train/val + test)
  • database/splits/seed{1,24,42}/ — train/val sem PCR, test idêntico (sagrado)

4. Auditar (zero leakage + test set consistente)

python scripts/audit_splits.py --monica

Gera outputs/audit.md com tabelas: leakage por split, contagens por classe e equipamento, variabilidade do test set entre seeds (deve ser 100% — test sagrado).

5. Treinar e avaliar — baseline MONICA (replica o paper, com PCR)

python scripts/multiseed.py --configs configs/densenet121_monica.yaml configs/resnet50_monica.yaml configs/efficientnet_b7_monica.yaml configs/mobilenetv2_monica.yaml configs/deit3_small_monica.yaml --seeds 1 24 42 --splits_root database/splits/monica --out_dir outputs/baseline --evaluate_after

5 modelos × 3 seeds = 15 runs (treino + evaluate). É resumível: se cair a meio, voltar a correr o mesmo comando salta os runs já feitos. Resultados ficam em outputs/baseline/{checkpoints,reports,figures}/.

6. Ablação — efeito do confounder PCR Eleva (1 modelo, sem PCR em train/val)

Para quantificar o impacto do equipamento PCR Eleva (presente no test sagrado mas removido aqui de train/val), corremos só ResNet50 × 3 seeds:

python scripts/multiseed.py --configs configs/resnet50_monica.yaml --seeds 1 24 42 --splits_root database/splits --out_dir outputs/ablation/pcr --evaluate_after

3 runs (~30 min total). Não é necessário replicar todos os 5 modelos — basta um para a ablação ser informativa no relatório.

7. Agregar mean ± std + comparar baseline vs ablação

python scripts/aggregate_seeds.py --reports_dir outputs/baseline/reports outputs/ablation/pcr/reports --labels baseline ablation_pcr --out outputs/compare.md

Output: outputs/compare.md (tabela markdown com val + test, mean±std) e outputs/compare.md.csv. Compara os 5 modelos da replica com a ResNet50 sem PCR — mostra o "delta" do confounder.

8. (Opcional) VLMs — few-shot

python scripts/run_biomedclip_fewshot.py --config configs/biomedclip_fewshot.yaml
python scripts/run_vlm_fewshot.py --config configs/qwen2_5_vl_7b_fewshot.yaml

9. (Opcional) CVAE — síntese (latente 4096)

python train_cvae.py --config configs/cvae.yaml --data_root database/splits/seed1
python scripts/generate_cvae.py --checkpoint outputs/checkpoints/cvae_512/best.pt --n_per_class 200 --out_dir database/synthetic/cvae_512
python scripts/cvae_separability.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1

10. (Opcional) CVAE encoder + classificador clássico

# XGBoost (default)
python scripts/cvae_classifier.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1 --seeds 42 1 24
# trocar para SVM / RF / MLP / Logistic
python scripts/cvae_classifier.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1 --classifier svm
python scripts/cvae_classifier.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1 --classifier random_forest
python scripts/cvae_classifier.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1 --classifier mlp
# rápido (single conditioning + PCA)
python scripts/cvae_classifier.py --checkpoint outputs/checkpoints/cvae_512/best.pt --data_root database/splits/seed1 --feature_mode single --pca_dim 256

11. (Opcional) Ablação metadata clínica (idade + sexo)

Para decidir se vale a pena fazer fusion CNN + metadata, treina um classificador clássico só com idade + sexo e vê o F1 no test sagrado:

# XGBoost (default)
python scripts/metadata_only.py
# alternativa linear
python scripts/metadata_only.py --classifier logistic
# diagnóstico do confounder (adiciona equipment_model — não reportar como resultado clínico)
python scripts/metadata_only.py --include_equipment

Interpretação:

  • F1 ~ 0.25 (chance) → metadata não traz info → parar aqui, não fazer fusion
  • F1 ≈ 0.30–0.40 → sinal marginal, fusion pode dar +1-2pp
  • F1 ≥ 0.40 → sinal forte, vale a pena implementar fusion CNN + metadata

Resumo guardado em outputs/ablation/metadata/summary.json.

Onde aparecem os resultados

Por seed: outputs/<dir>/reports/<run_name>/

  • metrics.json — campos legacy (test) + bloco splits: {val, test}
  • confusion_matrix_val.png / confusion_matrix_test.png
  • predictions_val.csv / predictions_test.csv
  • eval.log — termina com VAL | acc=... f1m=... / TEST | ...

Agregado: outputs/compare.md + .csv

Notebooks

  • notebooks/domain.ipynb — contexto clínico das 4 classes + agregação Benign/Malignant Stricture
  • notebooks/exploration.ipynb — EDA + pipeline atual vs recomendações + modelo metadata-only (quantifica confound) + visuais de pré-processamento/augmentation
  • notebooks/baseline.ipynb — replica a baseline Monica (5 modelos sequenciais) e exporta gradcam/matrizes/curvas para notebooks/figures/
  • notebooks/augmentation.ipynb — efeito da augmentation na ResNet50 (none/light/monica/heavy): F1, curvas, confusão, Grad-CAM
  • notebooks/masks.ipynb — impacto das máscaras SAM3 como 4º canal (nomask/combined/no_endoscope/separated)
  • notebooks/age.ipynb — fusão da idade (late fusion escalar/MLP) vs imagem-só
  • notebooks/pcr.ipynb — especialistas (Ziehm Vision / PCR Eleva) + sistema com routing por equipamento (test sagrado)
  • notebooks/segmentation.ipynb — SAM3: prompts clínicos, máscaras combinada/por-categoria, tensor 4 canais

🧪 Avaliação em dados novos (guião)

Dois entrypoints na raiz (classes — nomes de pasta exatos: Biliary_Leaks, Lithiasis, Normal, Stricture):

Script Dados Saída
evaluate.py / evaluate_sam3.py rotulados (<root>/test/<Classe>/*.png) F1, accuracy, matriz, predictions.csv
predict.py imagens soltas (sem rótulos) CSV de predições

Os checkpoints estão em models/ (incluídos no zip — ver models/README.md).

Caminho A — Modelo simples 3 canais (corre sempre, sem SAM3)

densenet121 (réplica fiel da Mónica, ~0.62 F1) — só precisa das imagens:

# rotulado (F1/matriz):
python evaluate.py --config configs/densenet121_monica.yaml \
    --checkpoint models/densenet121_monica_seed42.pt --data_root <DADOS> --skip_val

# imagens soltas (só predições):
python predict.py --config configs/densenet121_monica.yaml \
    --checkpoint models/densenet121_monica_seed42.pt --images_dir <PASTA> --out predicoes.csv

Caminho B — Ensemble SAM3 (melhor, F1 ~76%) — precisa de gerar máscaras

4 modelos em soft-voting (reportado 76.30%; reproduzido localmente 75.23%). Precisa de GPU + SAM3 (facebook/sam3) para gerar as máscaras das imagens novas:

# 1. máscaras por-categoria dos dados novos
python scripts/generate_sam3_masks.py --multi --data_root <DADOS> --out_root <MASCARAS>

# 2. avaliar os 4 membros (--tta no baseline 3ch; evaluate_sam3 já tem TTA on)
python evaluate_sam3.py --config configs/resnet50_sam3_4ch_opt.yaml      --checkpoint models/resnet50_sam3_4ch_opt.pt          --data_root <DADOS> --masks_root <MASCARAS>
python evaluate_sam3.py --config configs/resnet50_sam3_4ch_opt_v3.yaml   --checkpoint models/resnet50_sam3_4ch_opt_v3.pt       --data_root <DADOS> --masks_root <MASCARAS>
python evaluate_sam3.py --config configs/resnet50_radimagenet_4ch.yaml   --checkpoint models/multitask_resnet50_imagenet_4ch.pt --data_root <DADOS> --masks_root <MASCARAS>
python evaluate.py --tta --config configs/resnet50_baseline_opt.yaml     --checkpoint models/resnet50_baseline_opt.pt          --data_root <DADOS> --skip_val

# 3. combinar (pesos 4-M: V1 .458 / V3 .241 / Multitask .194 / Baseline .107 — ver notebooks/ensemble.ipynb;
#    scripts/ensemble/ensemble_save.py faz a versão 3-M)

Reproduzir o nosso test (seed42): database/masks_multi/seed42 segue no zip → corre o passo 2 sem --data_root/--masks_root (usa os defaults dos configs). Sem GPU/SAM3, usa o Caminho A (corre só com imagens).


👥 Grupo 1 — MIA

Nome Email
Luís Miguel Pereira Silva PG60390 pg60390@alunos.uminho.pt
Pedro Miguel S. A. Urbano dos Reis PG59908 pg59908@alunos.uminho.pt
Guilherme Lobo Pinto PG60225 pg60225@alunos.uminho.pt
Pedro Alexandre Silva Gomes PG60289 pg60289@alunos.uminho.pt

📜 Licença

Este trabalho é de cariz estritamente académico. Universidade do Minho, Escola de Engenharia, Departamento de Informática.

About

🩻 Deep Learning models for multi-class ERCP classification featuring equipment bias removal via SAM3 and ensembles.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors