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.
| 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
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.transforms—RandRotate(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 deset_seed(42),cudnn.deterministic=True - Optimizer: Adam lr=1e-4
- Scheduler: CosineAnnealingLR(T_max=60)
- Early stopping: val_F1_macro, patience=10
- 60 epochs
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).
- 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)
Beta-VAE condicional por classe para geração de imagens sintéticas (Biliary_Leaks é a classe mais rara):
- Latente espacial
Cz×h×wcomlatent_channels=16elat_size=16→ latent_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)
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 agregarmu(x|y)sobre as classes (defaultmean— sem vazamento da label verdadeira)- Compensação de desequilíbrio Biliary_Leaks ≪ Lithiasis:
class_weight="balanced"(SVM/RF/LR) ousample_weightinverso-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
- 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
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.pngcombinada;--multigeramask_{duct,wire,pathology,device}.png) →database/masks/ - Pipeline:
src/data/dataset_sam3.py(CPRESAMM3Dataset, transforms sincronizados imagem+máscara),src/models/builder_sam3.py, entrypointstrain_sam3.py/evaluate_sam3.py - Configs:
configs/resnet50_sam3*.yaml - Exploração visual: notebooks/segmentation.ipynb · impacto na classificação: notebooks/masks.ipynb
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).
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 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 comTEST_SEED=42fixo 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/seed42reproduz exactamente omonica/mainoriginal (mesmo split que o paper)
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
- 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ó.
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.yamlparahttps://download.pytorch.org/whl/cpu. MONAI é dependência obrigatória (já emenv.yaml).
Colocar o dataset em database/MIQR-CC-Dataset/ (deve conter metadata.csv + raw/ + processed/).
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)
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).
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}/.
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.
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.
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
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
# 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
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.
Por seed: outputs/<dir>/reports/<run_name>/
metrics.json— campos legacy (test) + blocosplits: {val, test}confusion_matrix_val.png/confusion_matrix_test.pngpredictions_val.csv/predictions_test.csveval.log— termina comVAL | acc=... f1m=.../TEST | ...
Agregado: outputs/compare.md + .csv
- 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
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).
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.csv4 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/seed42segue 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).
| Nome | Nº | |
|---|---|---|
| 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 |
Este trabalho é de cariz estritamente académico. Universidade do Minho, Escola de Engenharia, Departamento de Informática.