Spaces:
Sleeping
Sleeping
Ashish Mehta commited on
Commit ·
4687353
1
Parent(s): 8e53dc6
Add data pipeline scripts and configuration files for Visual Genome processing
Browse files- Introduced .gitignore to exclude unnecessary files and directories.
- Added requirements.txt for project dependencies.
- Implemented dataset.yaml with download, extraction, and processing configurations.
- Created multiple scripts for data handling: download, extract, process, and build splits.
- Developed run_baselines.py and eval_baseline.py for baseline model evaluation.
- Enhanced README.md with data pipeline instructions and expected outputs.
- Established src/data module for data utilities and processing functions.
- .gitignore +86 -0
- README.md +40 -1
- configs/dataset.yaml +46 -0
- configs/model.yaml +2 -2
- configs/train.yaml +4 -0
- requirements.txt +12 -0
- scripts/data/build_splits.py +217 -0
- scripts/data/download_visual_genome.py +83 -0
- scripts/data/extract_visual_genome.py +69 -0
- scripts/data/process_visual_genome.py +321 -0
- scripts/data/run_data_pipeline.py +81 -0
- scripts/eval_baseline.py +42 -0
- scripts/run_baselines.py +135 -0
- scripts/train_baseline.py +304 -0
- src/data/__init__.py +1 -0
- src/data/io_utils.py +149 -0
- src/data/normalization.py +93 -0
- src/data/schemas.py +18 -0
- src/train/__init__.py +1 -0
- src/train/config_utils.py +50 -0
- src/train/dataset.py +176 -0
- src/train/encoders.py +45 -0
- src/train/losses.py +8 -0
- src/train/metrics.py +40 -0
- src/train/models.py +53 -0
.gitignore
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python cache and bytecode
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Packaging / build
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Virtual environments
|
| 27 |
+
.venv/
|
| 28 |
+
venv/
|
| 29 |
+
env/
|
| 30 |
+
ENV/
|
| 31 |
+
|
| 32 |
+
# Test / coverage / tooling cache
|
| 33 |
+
.pytest_cache/
|
| 34 |
+
.mypy_cache/
|
| 35 |
+
.ruff_cache/
|
| 36 |
+
.tox/
|
| 37 |
+
.nox/
|
| 38 |
+
.coverage
|
| 39 |
+
.coverage.*
|
| 40 |
+
htmlcov/
|
| 41 |
+
.hypothesis/
|
| 42 |
+
|
| 43 |
+
# Notebook checkpoints
|
| 44 |
+
.ipynb_checkpoints/
|
| 45 |
+
|
| 46 |
+
# Logs and runtime files
|
| 47 |
+
*.log
|
| 48 |
+
*.out
|
| 49 |
+
*.err
|
| 50 |
+
|
| 51 |
+
# IDE / editor files
|
| 52 |
+
.vscode/
|
| 53 |
+
.idea/
|
| 54 |
+
*.swp
|
| 55 |
+
*.swo
|
| 56 |
+
|
| 57 |
+
# OS files
|
| 58 |
+
.DS_Store
|
| 59 |
+
Thumbs.db
|
| 60 |
+
|
| 61 |
+
# Local environment files
|
| 62 |
+
.env
|
| 63 |
+
.env.*
|
| 64 |
+
|
| 65 |
+
# ML data and artifacts
|
| 66 |
+
data/raw/
|
| 67 |
+
data/processed/
|
| 68 |
+
checkpoints/
|
| 69 |
+
runs/
|
| 70 |
+
outputs/
|
| 71 |
+
artifacts/
|
| 72 |
+
|
| 73 |
+
.jpeg
|
| 74 |
+
.jpg
|
| 75 |
+
.png
|
| 76 |
+
.gif
|
| 77 |
+
.bmp
|
| 78 |
+
.tiff
|
| 79 |
+
.ico
|
| 80 |
+
.webp
|
| 81 |
+
.svg
|
| 82 |
+
.heic
|
| 83 |
+
.heif
|
| 84 |
+
.heif-srgb
|
| 85 |
+
.heif-srgb-alpha
|
| 86 |
+
.heif-srgb-alpha-alpha
|
README.md
CHANGED
|
@@ -16,6 +16,8 @@ Current scope starts with `chair` and multi-label attributes like `broken`, `blu
|
|
| 16 |
- `configs/train.yaml` - optimization, staging, losses, checkpointing
|
| 17 |
- `configs/eval.yaml` - metrics, visualization, error analysis
|
| 18 |
- `configs/experiment.yaml` - baseline/main/ablation run definitions
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Config Overview
|
| 21 |
|
|
@@ -79,7 +81,31 @@ Use `plan/plan.md` as the source of truth for quality gates and deliverables.
|
|
| 79 |
|
| 80 |
## How To Run
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
Typical flow:
|
| 85 |
|
|
@@ -90,6 +116,19 @@ Typical flow:
|
|
| 90 |
|
| 91 |
## Expected Outputs
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
- model checkpoints
|
| 94 |
- evaluation reports (mAP/F1/per-attribute)
|
| 95 |
- embedding analysis artifacts (UMAP/retrieval results)
|
|
|
|
| 16 |
- `configs/train.yaml` - optimization, staging, losses, checkpointing
|
| 17 |
- `configs/eval.yaml` - metrics, visualization, error analysis
|
| 18 |
- `configs/experiment.yaml` - baseline/main/ablation run definitions
|
| 19 |
+
- `scripts/data/` - data download, extraction, processing, split pipeline
|
| 20 |
+
- `src/data/` - reusable normalization and IO utilities for data pipeline
|
| 21 |
|
| 22 |
## Config Overview
|
| 23 |
|
|
|
|
| 81 |
|
| 82 |
## How To Run
|
| 83 |
|
| 84 |
+
### 1) Run Chair-Only Data Pipeline (Visual Genome)
|
| 85 |
+
|
| 86 |
+
Default full pipeline:
|
| 87 |
+
|
| 88 |
+
`python scripts/data/run_data_pipeline.py --config configs/dataset.yaml`
|
| 89 |
+
|
| 90 |
+
Run individual stages:
|
| 91 |
+
|
| 92 |
+
- `python scripts/data/run_data_pipeline.py --download-only --config configs/dataset.yaml`
|
| 93 |
+
- `python scripts/data/run_data_pipeline.py --extract-only --config configs/dataset.yaml`
|
| 94 |
+
- `python scripts/data/run_data_pipeline.py --process-only --config configs/dataset.yaml`
|
| 95 |
+
- `python scripts/data/run_data_pipeline.py --split-only --config configs/dataset.yaml`
|
| 96 |
+
|
| 97 |
+
Direct scripts (if needed):
|
| 98 |
+
|
| 99 |
+
- `python scripts/data/download_visual_genome.py --config configs/dataset.yaml`
|
| 100 |
+
- `python scripts/data/extract_visual_genome.py --config configs/dataset.yaml`
|
| 101 |
+
- `python scripts/data/process_visual_genome.py --config configs/dataset.yaml`
|
| 102 |
+
- `python scripts/data/build_splits.py --config configs/dataset.yaml`
|
| 103 |
+
|
| 104 |
+
Important: the processing pipeline is strict chair-only (`primary_object_class: chair`).
|
| 105 |
+
|
| 106 |
+
### 2) Train/Evaluate Models
|
| 107 |
+
|
| 108 |
+
This repo provides dataset + experiment configs. Connect model training to your entrypoints (for example `scripts/train.py` and `scripts/eval.py`).
|
| 109 |
|
| 110 |
Typical flow:
|
| 111 |
|
|
|
|
| 116 |
|
| 117 |
## Expected Outputs
|
| 118 |
|
| 119 |
+
Data artifacts:
|
| 120 |
+
|
| 121 |
+
- `data/processed/visual_genome/samples.parquet` (or `samples.csv` fallback)
|
| 122 |
+
- `data/processed/visual_genome/label_vocab.json`
|
| 123 |
+
- `data/processed/visual_genome/label_frequencies.json`
|
| 124 |
+
- `data/processed/visual_genome/processing_report.json`
|
| 125 |
+
- `data/processed/visual_genome/splits/train.json`
|
| 126 |
+
- `data/processed/visual_genome/splits/val.json`
|
| 127 |
+
- `data/processed/visual_genome/splits/test.json`
|
| 128 |
+
- `data/processed/visual_genome/splits/split_report.json`
|
| 129 |
+
|
| 130 |
+
Training/eval artifacts:
|
| 131 |
+
|
| 132 |
- model checkpoints
|
| 133 |
- evaluation reports (mAP/F1/per-attribute)
|
| 134 |
- embedding analysis artifacts (UMAP/retrieval results)
|
configs/dataset.yaml
CHANGED
|
@@ -11,12 +11,58 @@ dataset:
|
|
| 11 |
image_size: 224
|
| 12 |
num_workers: 8
|
| 13 |
pin_memory: true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
splits:
|
| 16 |
train: 0.8
|
| 17 |
val: 0.1
|
| 18 |
test: 0.1
|
| 19 |
stratify_by_attribute_groups: true
|
|
|
|
|
|
|
| 20 |
split_seed: 42
|
| 21 |
|
| 22 |
attributes:
|
|
|
|
| 11 |
image_size: 224
|
| 12 |
num_workers: 8
|
| 13 |
pin_memory: true
|
| 14 |
+
expected_primary_object_class: chair
|
| 15 |
+
|
| 16 |
+
download:
|
| 17 |
+
enabled: true
|
| 18 |
+
force_download: false
|
| 19 |
+
timeout_seconds: 120
|
| 20 |
+
retries: 3
|
| 21 |
+
chunk_size_bytes: 1048576
|
| 22 |
+
artifacts:
|
| 23 |
+
- name: images_metadata
|
| 24 |
+
url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/image_data.json.zip
|
| 25 |
+
filename: image_data.json.zip
|
| 26 |
+
sha256: null
|
| 27 |
+
- name: objects
|
| 28 |
+
url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/objects.json.zip
|
| 29 |
+
filename: objects.json.zip
|
| 30 |
+
sha256: null
|
| 31 |
+
- name: attributes
|
| 32 |
+
url: https://homes.cs.washington.edu/~ranjay/visualgenome/data/dataset/attributes.json.zip
|
| 33 |
+
filename: attributes.json.zip
|
| 34 |
+
sha256: null
|
| 35 |
+
|
| 36 |
+
extraction:
|
| 37 |
+
enabled: true
|
| 38 |
+
force_extract: false
|
| 39 |
+
safe_extract: true
|
| 40 |
+
marker_file: .extract_complete
|
| 41 |
+
archives:
|
| 42 |
+
- image_data.json.zip
|
| 43 |
+
- objects.json.zip
|
| 44 |
+
- attributes.json.zip
|
| 45 |
+
|
| 46 |
+
processing:
|
| 47 |
+
output_format: parquet
|
| 48 |
+
csv_fallback: true
|
| 49 |
+
processing_report_file: processing_report.json
|
| 50 |
+
label_vocab_file: label_vocab.json
|
| 51 |
+
label_frequency_file: label_frequencies.json
|
| 52 |
+
samples_file_parquet: samples.parquet
|
| 53 |
+
samples_file_csv: samples.csv
|
| 54 |
+
split_dir: splits
|
| 55 |
+
unknown_label_token: __unknown__
|
| 56 |
+
keep_unmapped_attributes: false
|
| 57 |
+
strict_chair_only: true
|
| 58 |
|
| 59 |
splits:
|
| 60 |
train: 0.8
|
| 61 |
val: 0.1
|
| 62 |
test: 0.1
|
| 63 |
stratify_by_attribute_groups: true
|
| 64 |
+
multilabel_stratify: true
|
| 65 |
+
stratify_fallback: iterative_bucket
|
| 66 |
split_seed: 42
|
| 67 |
|
| 68 |
attributes:
|
configs/model.yaml
CHANGED
|
@@ -6,13 +6,13 @@ model:
|
|
| 6 |
encoders:
|
| 7 |
clip:
|
| 8 |
enabled: true
|
| 9 |
-
model_name:
|
| 10 |
pretrained: true
|
| 11 |
output_dim: 512
|
| 12 |
freeze_stage1: true
|
| 13 |
dino:
|
| 14 |
enabled: true
|
| 15 |
-
model_name: vit_base_patch14_dinov2
|
| 16 |
pretrained: true
|
| 17 |
output_dim: 768
|
| 18 |
freeze_stage1: true
|
|
|
|
| 6 |
encoders:
|
| 7 |
clip:
|
| 8 |
enabled: true
|
| 9 |
+
model_name: vit_base_patch16_clip_224.openai
|
| 10 |
pretrained: true
|
| 11 |
output_dim: 512
|
| 12 |
freeze_stage1: true
|
| 13 |
dino:
|
| 14 |
enabled: true
|
| 15 |
+
model_name: vit_base_patch14_dinov2.lvd142m
|
| 16 |
pretrained: true
|
| 17 |
output_dim: 768
|
| 18 |
freeze_stage1: true
|
configs/train.yaml
CHANGED
|
@@ -4,6 +4,10 @@ run:
|
|
| 4 |
experiment_name: clip_dino_gnn_bipartite
|
| 5 |
output_dir: outputs
|
| 6 |
save_every_n_epochs: 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
training:
|
| 9 |
stage1:
|
|
|
|
| 4 |
experiment_name: clip_dino_gnn_bipartite
|
| 5 |
output_dir: outputs
|
| 6 |
save_every_n_epochs: 1
|
| 7 |
+
smoke_profile:
|
| 8 |
+
epochs: 2
|
| 9 |
+
batch_size: 8
|
| 10 |
+
eval_batch_size: 8
|
| 11 |
|
| 12 |
training:
|
| 13 |
stage1:
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
--extra-index-url https://download.pytorch.org/whl/cu121
|
| 2 |
+
|
| 3 |
+
PyYAML
|
| 4 |
+
pandas
|
| 5 |
+
pyarrow
|
| 6 |
+
torch==2.5.1+cu121
|
| 7 |
+
torchvision==0.20.1+cu121
|
| 8 |
+
torchaudio==2.5.1+cu121
|
| 9 |
+
timm
|
| 10 |
+
scikit-learn
|
| 11 |
+
Pillow
|
| 12 |
+
tqdm
|
scripts/data/build_splits.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import random
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 15 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 16 |
+
if str(SRC_DIR) not in sys.path:
|
| 17 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 18 |
+
|
| 19 |
+
from data.io_utils import ensure_dir, load_yaml, write_json # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def to_json_safe(value: Any) -> Any:
|
| 23 |
+
if isinstance(value, dict):
|
| 24 |
+
return {str(k): to_json_safe(v) for k, v in value.items()}
|
| 25 |
+
if isinstance(value, (list, tuple, set)):
|
| 26 |
+
return [to_json_safe(v) for v in value]
|
| 27 |
+
if hasattr(value, "tolist"):
|
| 28 |
+
return to_json_safe(value.tolist())
|
| 29 |
+
if hasattr(value, "item"):
|
| 30 |
+
try:
|
| 31 |
+
return value.item()
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
return value
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def parse_args() -> argparse.Namespace:
|
| 38 |
+
parser = argparse.ArgumentParser(
|
| 39 |
+
description="Build deterministic train/val/test split manifests for chair samples."
|
| 40 |
+
)
|
| 41 |
+
parser.add_argument(
|
| 42 |
+
"--config",
|
| 43 |
+
type=Path,
|
| 44 |
+
default=REPO_ROOT / "configs" / "dataset.yaml",
|
| 45 |
+
help="Path to dataset config yaml.",
|
| 46 |
+
)
|
| 47 |
+
return parser.parse_args()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _load_samples_csv(path: Path) -> List[Dict[str, Any]]:
|
| 51 |
+
rows: List[Dict[str, Any]] = []
|
| 52 |
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
| 53 |
+
reader = csv.DictReader(handle)
|
| 54 |
+
for row in reader:
|
| 55 |
+
rows.append(
|
| 56 |
+
{
|
| 57 |
+
"image_id": int(row["image_id"]),
|
| 58 |
+
"object_id": int(row["object_id"]),
|
| 59 |
+
"object_name": row.get("object_name", "chair"),
|
| 60 |
+
"image_url": row.get("image_url", ""),
|
| 61 |
+
"image_path": row.get("image_path", ""),
|
| 62 |
+
"attributes_raw": json.loads(row.get("attributes_raw", "[]")),
|
| 63 |
+
"attributes_norm": json.loads(row.get("attributes_norm", "[]")),
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_samples(processed_root: Path, processing_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 70 |
+
parquet_path = processed_root / processing_cfg.get("samples_file_parquet", "samples.parquet")
|
| 71 |
+
csv_path = processed_root / processing_cfg.get("samples_file_csv", "samples.csv")
|
| 72 |
+
if parquet_path.exists():
|
| 73 |
+
try:
|
| 74 |
+
import pandas as pd # type: ignore
|
| 75 |
+
|
| 76 |
+
df = pd.read_parquet(parquet_path)
|
| 77 |
+
rows = df.to_dict(orient="records")
|
| 78 |
+
for row in rows:
|
| 79 |
+
row = to_json_safe(row)
|
| 80 |
+
if isinstance(row.get("attributes_raw"), str):
|
| 81 |
+
row["attributes_raw"] = json.loads(row["attributes_raw"])
|
| 82 |
+
if isinstance(row.get("attributes_norm"), str):
|
| 83 |
+
row["attributes_norm"] = json.loads(row["attributes_norm"])
|
| 84 |
+
row["image_id"] = int(row["image_id"])
|
| 85 |
+
row["object_id"] = int(row["object_id"])
|
| 86 |
+
row["attributes_raw"] = to_json_safe(row.get("attributes_raw", []))
|
| 87 |
+
row["attributes_norm"] = to_json_safe(row.get("attributes_norm", []))
|
| 88 |
+
return rows
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
if csv_path.exists():
|
| 92 |
+
return _load_samples_csv(csv_path)
|
| 93 |
+
raise RuntimeError("No processed samples file found (parquet/csv). Run processing first.")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def allocate_counts(total: int, train_ratio: float, val_ratio: float, test_ratio: float) -> Tuple[int, int, int]:
|
| 97 |
+
ratios = [train_ratio, val_ratio, test_ratio]
|
| 98 |
+
names = ["train", "val", "test"]
|
| 99 |
+
base = [int(total * r) for r in ratios]
|
| 100 |
+
remaining = total - sum(base)
|
| 101 |
+
frac = sorted(
|
| 102 |
+
[(i, (total * ratios[i]) - base[i]) for i in range(3)],
|
| 103 |
+
key=lambda x: x[1],
|
| 104 |
+
reverse=True,
|
| 105 |
+
)
|
| 106 |
+
for i in range(remaining):
|
| 107 |
+
base[frac[i % len(frac)][0]] += 1
|
| 108 |
+
counts = dict(zip(names, base))
|
| 109 |
+
return counts["train"], counts["val"], counts["test"]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def signature(labels: Iterable[str]) -> str:
|
| 113 |
+
uniq = sorted(set(labels))
|
| 114 |
+
return "|".join(uniq) if uniq else "__no_label__"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def digest_ids(values: Iterable[int]) -> str:
|
| 118 |
+
joined = ",".join(str(v) for v in sorted(values))
|
| 119 |
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def main() -> None:
|
| 123 |
+
args = parse_args()
|
| 124 |
+
cfg = load_yaml(args.config)
|
| 125 |
+
dataset_cfg = cfg["dataset"]
|
| 126 |
+
split_cfg = cfg.get("splits", {})
|
| 127 |
+
processing_cfg = cfg.get("processing", {})
|
| 128 |
+
|
| 129 |
+
processed_root = REPO_ROOT / dataset_cfg["processed_dir"]
|
| 130 |
+
ensure_dir(processed_root)
|
| 131 |
+
samples = load_samples(processed_root=processed_root, processing_cfg=processing_cfg)
|
| 132 |
+
if not samples:
|
| 133 |
+
raise RuntimeError("No samples available for split generation.")
|
| 134 |
+
|
| 135 |
+
seed = int(split_cfg.get("split_seed", 42))
|
| 136 |
+
random.seed(seed)
|
| 137 |
+
|
| 138 |
+
by_image: Dict[int, Dict[str, Any]] = {}
|
| 139 |
+
for row in samples:
|
| 140 |
+
image_id = int(row["image_id"])
|
| 141 |
+
if image_id not in by_image:
|
| 142 |
+
by_image[image_id] = {"labels": set(), "rows": []}
|
| 143 |
+
by_image[image_id]["rows"].append(row)
|
| 144 |
+
by_image[image_id]["labels"].update(row.get("attributes_norm", []))
|
| 145 |
+
|
| 146 |
+
buckets: Dict[str, List[int]] = defaultdict(list)
|
| 147 |
+
for image_id, payload in by_image.items():
|
| 148 |
+
buckets[signature(payload["labels"])].append(image_id)
|
| 149 |
+
|
| 150 |
+
train_ratio = float(split_cfg.get("train", 0.8))
|
| 151 |
+
val_ratio = float(split_cfg.get("val", 0.1))
|
| 152 |
+
test_ratio = float(split_cfg.get("test", 0.1))
|
| 153 |
+
ratio_sum = train_ratio + val_ratio + test_ratio
|
| 154 |
+
if abs(ratio_sum - 1.0) > 1e-6:
|
| 155 |
+
raise RuntimeError("Split ratios must sum to 1.0")
|
| 156 |
+
|
| 157 |
+
train_ids: List[int] = []
|
| 158 |
+
val_ids: List[int] = []
|
| 159 |
+
test_ids: List[int] = []
|
| 160 |
+
|
| 161 |
+
for _, image_ids in sorted(buckets.items(), key=lambda item: item[0]):
|
| 162 |
+
random.shuffle(image_ids)
|
| 163 |
+
n_train, n_val, n_test = allocate_counts(
|
| 164 |
+
total=len(image_ids),
|
| 165 |
+
train_ratio=train_ratio,
|
| 166 |
+
val_ratio=val_ratio,
|
| 167 |
+
test_ratio=test_ratio,
|
| 168 |
+
)
|
| 169 |
+
train_ids.extend(image_ids[:n_train])
|
| 170 |
+
val_ids.extend(image_ids[n_train : n_train + n_val])
|
| 171 |
+
test_ids.extend(image_ids[n_train + n_val : n_train + n_val + n_test])
|
| 172 |
+
|
| 173 |
+
train_set = set(train_ids)
|
| 174 |
+
val_set = set(val_ids)
|
| 175 |
+
test_set = set(test_ids)
|
| 176 |
+
if train_set & val_set or train_set & test_set or val_set & test_set:
|
| 177 |
+
raise RuntimeError("Leakage detected across train/val/test image IDs.")
|
| 178 |
+
|
| 179 |
+
split_rows = {"train": [], "val": [], "test": []}
|
| 180 |
+
for row in samples:
|
| 181 |
+
row = to_json_safe(row)
|
| 182 |
+
image_id = int(row["image_id"])
|
| 183 |
+
if image_id in train_set:
|
| 184 |
+
split_rows["train"].append(row)
|
| 185 |
+
elif image_id in val_set:
|
| 186 |
+
split_rows["val"].append(row)
|
| 187 |
+
elif image_id in test_set:
|
| 188 |
+
split_rows["test"].append(row)
|
| 189 |
+
|
| 190 |
+
split_dir = processed_root / processing_cfg.get("split_dir", "splits")
|
| 191 |
+
ensure_dir(split_dir)
|
| 192 |
+
for split_name in ("train", "val", "test"):
|
| 193 |
+
write_json(split_dir / f"{split_name}.json", {"samples": split_rows[split_name]})
|
| 194 |
+
|
| 195 |
+
summary = {
|
| 196 |
+
"seed": seed,
|
| 197 |
+
"total_rows": len(samples),
|
| 198 |
+
"total_unique_images": len(by_image),
|
| 199 |
+
"train_rows": len(split_rows["train"]),
|
| 200 |
+
"val_rows": len(split_rows["val"]),
|
| 201 |
+
"test_rows": len(split_rows["test"]),
|
| 202 |
+
"train_unique_images": len(train_set),
|
| 203 |
+
"val_unique_images": len(val_set),
|
| 204 |
+
"test_unique_images": len(test_set),
|
| 205 |
+
"train_image_digest": digest_ids(train_set),
|
| 206 |
+
"val_image_digest": digest_ids(val_set),
|
| 207 |
+
"test_image_digest": digest_ids(test_set),
|
| 208 |
+
"stratify_mode": "multilabel_signature_bucket",
|
| 209 |
+
"fallback_used": str(split_cfg.get("stratify_fallback", "iterative_bucket")),
|
| 210 |
+
"leakage_check_passed": True,
|
| 211 |
+
}
|
| 212 |
+
write_json(split_dir / "split_report.json", summary)
|
| 213 |
+
print("[ok] Split manifests created with leakage check passed.")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
if __name__ == "__main__":
|
| 217 |
+
main()
|
scripts/data/download_visual_genome.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 9 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 10 |
+
if str(SRC_DIR) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 12 |
+
|
| 13 |
+
from data.io_utils import download_file, ensure_dir, load_yaml, verify_checksum # noqa: E402
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def parse_args() -> argparse.Namespace:
|
| 17 |
+
parser = argparse.ArgumentParser(description="Download Visual Genome raw artifacts.")
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--config",
|
| 20 |
+
type=Path,
|
| 21 |
+
default=REPO_ROOT / "configs" / "dataset.yaml",
|
| 22 |
+
help="Path to dataset config yaml.",
|
| 23 |
+
)
|
| 24 |
+
parser.add_argument(
|
| 25 |
+
"--force-download",
|
| 26 |
+
action="store_true",
|
| 27 |
+
help="Force re-download even if files already exist.",
|
| 28 |
+
)
|
| 29 |
+
return parser.parse_args()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
args = parse_args()
|
| 34 |
+
cfg = load_yaml(args.config)
|
| 35 |
+
dataset_cfg = cfg["dataset"]
|
| 36 |
+
download_cfg = cfg.get("download", {})
|
| 37 |
+
|
| 38 |
+
if not download_cfg.get("enabled", True):
|
| 39 |
+
print("Download disabled in config. Nothing to do.")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
raw_root = REPO_ROOT / dataset_cfg["root_dir"]
|
| 43 |
+
ensure_dir(raw_root)
|
| 44 |
+
|
| 45 |
+
timeout_seconds = int(download_cfg.get("timeout_seconds", 120))
|
| 46 |
+
retries = int(download_cfg.get("retries", 3))
|
| 47 |
+
chunk_size = int(download_cfg.get("chunk_size_bytes", 1024 * 1024))
|
| 48 |
+
force_download = args.force_download or bool(download_cfg.get("force_download", False))
|
| 49 |
+
|
| 50 |
+
artifacts = download_cfg.get("artifacts", [])
|
| 51 |
+
if not artifacts:
|
| 52 |
+
raise RuntimeError("No download artifacts configured.")
|
| 53 |
+
|
| 54 |
+
for artifact in artifacts:
|
| 55 |
+
filename = artifact["filename"]
|
| 56 |
+
url = artifact["url"]
|
| 57 |
+
expected_sha256 = artifact.get("sha256")
|
| 58 |
+
target = raw_root / filename
|
| 59 |
+
|
| 60 |
+
if target.exists() and not force_download:
|
| 61 |
+
if verify_checksum(target, expected_sha256):
|
| 62 |
+
print(f"[skip] {filename} already exists and checksum passed.")
|
| 63 |
+
continue
|
| 64 |
+
print(f"[warn] {filename} exists but checksum mismatch. Re-downloading.")
|
| 65 |
+
|
| 66 |
+
print(f"[download] {filename} <- {url}")
|
| 67 |
+
download_file(
|
| 68 |
+
url=url,
|
| 69 |
+
destination=target,
|
| 70 |
+
timeout_seconds=timeout_seconds,
|
| 71 |
+
retries=retries,
|
| 72 |
+
chunk_size=chunk_size,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if not verify_checksum(target, expected_sha256):
|
| 76 |
+
raise RuntimeError(f"Checksum validation failed for {target}")
|
| 77 |
+
print(f"[ok] {filename}")
|
| 78 |
+
|
| 79 |
+
print("All configured Visual Genome artifacts are downloaded.")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
scripts/data/extract_visual_genome.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 9 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 10 |
+
if str(SRC_DIR) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 12 |
+
|
| 13 |
+
from data.io_utils import ensure_dir, extract_archive, load_yaml # noqa: E402
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def parse_args() -> argparse.Namespace:
|
| 17 |
+
parser = argparse.ArgumentParser(description="Extract Visual Genome raw archives.")
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--config",
|
| 20 |
+
type=Path,
|
| 21 |
+
default=REPO_ROOT / "configs" / "dataset.yaml",
|
| 22 |
+
help="Path to dataset config yaml.",
|
| 23 |
+
)
|
| 24 |
+
parser.add_argument(
|
| 25 |
+
"--force-extract",
|
| 26 |
+
action="store_true",
|
| 27 |
+
help="Force extraction even if marker exists.",
|
| 28 |
+
)
|
| 29 |
+
return parser.parse_args()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
args = parse_args()
|
| 34 |
+
cfg = load_yaml(args.config)
|
| 35 |
+
dataset_cfg = cfg["dataset"]
|
| 36 |
+
extraction_cfg = cfg.get("extraction", {})
|
| 37 |
+
|
| 38 |
+
if not extraction_cfg.get("enabled", True):
|
| 39 |
+
print("Extraction disabled in config. Nothing to do.")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
raw_root = REPO_ROOT / dataset_cfg["root_dir"]
|
| 43 |
+
ensure_dir(raw_root)
|
| 44 |
+
|
| 45 |
+
marker_file = raw_root / extraction_cfg.get("marker_file", ".extract_complete")
|
| 46 |
+
force_extract = args.force_extract or bool(extraction_cfg.get("force_extract", False))
|
| 47 |
+
safe_extract = bool(extraction_cfg.get("safe_extract", True))
|
| 48 |
+
|
| 49 |
+
if marker_file.exists() and not force_extract:
|
| 50 |
+
print(f"[skip] Extraction marker found: {marker_file}")
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
archives = extraction_cfg.get("archives", [])
|
| 54 |
+
if not archives:
|
| 55 |
+
raise RuntimeError("No extraction archives configured.")
|
| 56 |
+
|
| 57 |
+
for archive_name in archives:
|
| 58 |
+
archive_path = raw_root / archive_name
|
| 59 |
+
if not archive_path.exists():
|
| 60 |
+
raise RuntimeError(f"Archive missing: {archive_path}")
|
| 61 |
+
print(f"[extract] {archive_name}")
|
| 62 |
+
extract_archive(archive_path=archive_path, output_dir=raw_root, safe_extract=safe_extract)
|
| 63 |
+
|
| 64 |
+
marker_file.write_text("ok\n", encoding="utf-8")
|
| 65 |
+
print(f"[ok] Extraction completed. Marker written at {marker_file}")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
main()
|
scripts/data/process_visual_genome.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, Iterable, List, Tuple
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 12 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 13 |
+
if str(SRC_DIR) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 15 |
+
|
| 16 |
+
from data.io_utils import ensure_dir, load_yaml, write_json # noqa: E402
|
| 17 |
+
from data.normalization import ( # noqa: E402
|
| 18 |
+
build_label_vocab,
|
| 19 |
+
filter_by_min_support,
|
| 20 |
+
normalize_labels,
|
| 21 |
+
normalize_text,
|
| 22 |
+
)
|
| 23 |
+
from data.schemas import SampleRecord # noqa: E402
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def parse_args() -> argparse.Namespace:
|
| 27 |
+
parser = argparse.ArgumentParser(
|
| 28 |
+
description="Process Visual Genome attributes to chair-only normalized samples."
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--config",
|
| 32 |
+
type=Path,
|
| 33 |
+
default=REPO_ROOT / "configs" / "dataset.yaml",
|
| 34 |
+
help="Path to dataset config yaml.",
|
| 35 |
+
)
|
| 36 |
+
return parser.parse_args()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def flatten_attribute_groups(groups_cfg: Dict[str, Iterable[str]]) -> List[str]:
|
| 40 |
+
labels: List[str] = []
|
| 41 |
+
for values in groups_cfg.values():
|
| 42 |
+
labels.extend(values)
|
| 43 |
+
return labels
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def find_json_file(raw_root: Path, target_name: str) -> Path:
|
| 47 |
+
direct = raw_root / target_name
|
| 48 |
+
if direct.exists():
|
| 49 |
+
return direct
|
| 50 |
+
matches = list(raw_root.rglob(target_name))
|
| 51 |
+
if not matches:
|
| 52 |
+
raise RuntimeError(f"Required json file not found: {target_name}")
|
| 53 |
+
return matches[0]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def load_json(path: Path) -> Any:
|
| 57 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 58 |
+
return json.load(handle)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_image_metadata_map(image_data: List[Dict[str, Any]]) -> Dict[int, Dict[str, str]]:
|
| 62 |
+
out: Dict[int, Dict[str, str]] = {}
|
| 63 |
+
for row in image_data:
|
| 64 |
+
image_id = int(row.get("image_id"))
|
| 65 |
+
image_url = str(row.get("url") or "")
|
| 66 |
+
file_name = Path(image_url).name if image_url else ""
|
| 67 |
+
image_path = str(Path("data/raw/visual_genome/images") / file_name) if file_name else ""
|
| 68 |
+
out[image_id] = {"image_url": image_url, "image_path": image_path}
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def as_list(value: Any) -> List[str]:
|
| 73 |
+
if value is None:
|
| 74 |
+
return []
|
| 75 |
+
if isinstance(value, list):
|
| 76 |
+
return [str(v) for v in value]
|
| 77 |
+
return [str(value)]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def object_is_chair(names: List[str], lowercase: bool, lemmatize: bool) -> bool:
|
| 81 |
+
normalized = [
|
| 82 |
+
normalize_text(name, lowercase=lowercase, lemmatize=lemmatize) for name in names
|
| 83 |
+
]
|
| 84 |
+
return "chair" in set(normalized)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def parse_chair_records(
|
| 88 |
+
attributes_data: List[Dict[str, Any]],
|
| 89 |
+
image_meta: Dict[int, Dict[str, str]],
|
| 90 |
+
lowercase: bool,
|
| 91 |
+
lemmatize: bool,
|
| 92 |
+
synonym_map: Dict[str, str],
|
| 93 |
+
keep_unmapped_attributes: bool,
|
| 94 |
+
allowed_labels: List[str],
|
| 95 |
+
) -> Tuple[List[SampleRecord], Dict[str, int]]:
|
| 96 |
+
allowed_set = set(
|
| 97 |
+
normalize_text(label, lowercase=lowercase, lemmatize=lemmatize) for label in allowed_labels
|
| 98 |
+
)
|
| 99 |
+
row_stats = {
|
| 100 |
+
"total_attribute_objects": 0,
|
| 101 |
+
"non_chair_objects_dropped": 0,
|
| 102 |
+
"chair_objects_kept_pre_filter": 0,
|
| 103 |
+
}
|
| 104 |
+
output: List[SampleRecord] = []
|
| 105 |
+
|
| 106 |
+
for image_item in attributes_data:
|
| 107 |
+
image_id = int(image_item.get("image_id"))
|
| 108 |
+
image_attributes = image_item.get("attributes", [])
|
| 109 |
+
metadata = image_meta.get(image_id, {})
|
| 110 |
+
for obj in image_attributes:
|
| 111 |
+
row_stats["total_attribute_objects"] += 1
|
| 112 |
+
names = as_list(obj.get("names"))
|
| 113 |
+
if not object_is_chair(names, lowercase=lowercase, lemmatize=lemmatize):
|
| 114 |
+
row_stats["non_chair_objects_dropped"] += 1
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
object_id = int(obj.get("object_id") or 0)
|
| 118 |
+
raw_attributes = as_list(obj.get("attributes"))
|
| 119 |
+
normalized = normalize_labels(
|
| 120 |
+
labels=raw_attributes,
|
| 121 |
+
lowercase=lowercase,
|
| 122 |
+
lemmatize=lemmatize,
|
| 123 |
+
synonym_map=synonym_map,
|
| 124 |
+
keep_unmapped=keep_unmapped_attributes,
|
| 125 |
+
allowed_labels=allowed_set,
|
| 126 |
+
)
|
| 127 |
+
record = SampleRecord(
|
| 128 |
+
image_id=image_id,
|
| 129 |
+
object_id=object_id,
|
| 130 |
+
object_name="chair",
|
| 131 |
+
image_url=metadata.get("image_url", ""),
|
| 132 |
+
image_path=metadata.get("image_path", ""),
|
| 133 |
+
attributes_raw=raw_attributes,
|
| 134 |
+
attributes_norm=normalized,
|
| 135 |
+
)
|
| 136 |
+
output.append(record)
|
| 137 |
+
row_stats["chair_objects_kept_pre_filter"] += 1
|
| 138 |
+
|
| 139 |
+
return output, row_stats
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def deduplicate_records(records: List[SampleRecord]) -> List[SampleRecord]:
|
| 143 |
+
seen = set()
|
| 144 |
+
out: List[SampleRecord] = []
|
| 145 |
+
for record in records:
|
| 146 |
+
key = (record.image_id, record.object_id)
|
| 147 |
+
if key in seen:
|
| 148 |
+
continue
|
| 149 |
+
seen.add(key)
|
| 150 |
+
out.append(record)
|
| 151 |
+
return out
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def save_samples(
|
| 155 |
+
rows: List[Dict[str, Any]],
|
| 156 |
+
output_format: str,
|
| 157 |
+
csv_fallback: bool,
|
| 158 |
+
parquet_path: Path,
|
| 159 |
+
csv_path: Path,
|
| 160 |
+
) -> str:
|
| 161 |
+
if output_format == "parquet":
|
| 162 |
+
try:
|
| 163 |
+
import pandas as pd # type: ignore
|
| 164 |
+
|
| 165 |
+
df = pd.DataFrame(rows)
|
| 166 |
+
df.to_parquet(parquet_path, index=False)
|
| 167 |
+
return str(parquet_path)
|
| 168 |
+
except Exception as exc: # pragma: no cover
|
| 169 |
+
if not csv_fallback:
|
| 170 |
+
raise RuntimeError(f"Failed to write parquet and csv fallback disabled: {exc}") from exc
|
| 171 |
+
|
| 172 |
+
ensure_dir(csv_path.parent)
|
| 173 |
+
fieldnames = [
|
| 174 |
+
"image_id",
|
| 175 |
+
"object_id",
|
| 176 |
+
"object_name",
|
| 177 |
+
"image_url",
|
| 178 |
+
"image_path",
|
| 179 |
+
"attributes_raw",
|
| 180 |
+
"attributes_norm",
|
| 181 |
+
]
|
| 182 |
+
with csv_path.open("w", encoding="utf-8", newline="") as handle:
|
| 183 |
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
| 184 |
+
writer.writeheader()
|
| 185 |
+
for row in rows:
|
| 186 |
+
row_copy = dict(row)
|
| 187 |
+
row_copy["attributes_raw"] = json.dumps(row_copy["attributes_raw"], ensure_ascii=True)
|
| 188 |
+
row_copy["attributes_norm"] = json.dumps(row_copy["attributes_norm"], ensure_ascii=True)
|
| 189 |
+
writer.writerow(row_copy)
|
| 190 |
+
return str(csv_path)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def main() -> None:
|
| 194 |
+
args = parse_args()
|
| 195 |
+
cfg = load_yaml(args.config)
|
| 196 |
+
dataset_cfg = cfg["dataset"]
|
| 197 |
+
processing_cfg = cfg.get("processing", {})
|
| 198 |
+
filtering_cfg = cfg.get("filtering", {})
|
| 199 |
+
attributes_cfg = cfg.get("attributes", {})
|
| 200 |
+
|
| 201 |
+
strict_chair_only = bool(processing_cfg.get("strict_chair_only", True))
|
| 202 |
+
primary_object = str(cfg["project"].get("primary_object_class", "chair")).strip().lower()
|
| 203 |
+
if strict_chair_only and primary_object != "chair":
|
| 204 |
+
raise RuntimeError("This pipeline currently supports strict chair-only processing.")
|
| 205 |
+
|
| 206 |
+
raw_root = REPO_ROOT / dataset_cfg["root_dir"]
|
| 207 |
+
processed_root = REPO_ROOT / dataset_cfg["processed_dir"]
|
| 208 |
+
ensure_dir(processed_root)
|
| 209 |
+
|
| 210 |
+
attributes_path = find_json_file(raw_root, "attributes.json")
|
| 211 |
+
image_data_path = find_json_file(raw_root, "image_data.json")
|
| 212 |
+
attributes_data = load_json(attributes_path)
|
| 213 |
+
image_data = load_json(image_data_path)
|
| 214 |
+
|
| 215 |
+
lowercase = bool(attributes_cfg.get("lowercase", True))
|
| 216 |
+
lemmatize = bool(attributes_cfg.get("lemmatize", True))
|
| 217 |
+
synonym_map = {
|
| 218 |
+
normalize_text(str(k), lowercase=lowercase, lemmatize=lemmatize): normalize_text(
|
| 219 |
+
str(v), lowercase=lowercase, lemmatize=lemmatize
|
| 220 |
+
)
|
| 221 |
+
for k, v in attributes_cfg.get("synonym_map", {}).items()
|
| 222 |
+
}
|
| 223 |
+
allowed_labels = flatten_attribute_groups(attributes_cfg.get("groups", {}))
|
| 224 |
+
keep_unmapped = bool(processing_cfg.get("keep_unmapped_attributes", False))
|
| 225 |
+
|
| 226 |
+
image_meta = build_image_metadata_map(image_data)
|
| 227 |
+
records, row_stats = parse_chair_records(
|
| 228 |
+
attributes_data=attributes_data,
|
| 229 |
+
image_meta=image_meta,
|
| 230 |
+
lowercase=lowercase,
|
| 231 |
+
lemmatize=lemmatize,
|
| 232 |
+
synonym_map=synonym_map,
|
| 233 |
+
keep_unmapped_attributes=keep_unmapped,
|
| 234 |
+
allowed_labels=allowed_labels,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if bool(filtering_cfg.get("deduplicate_images", True)):
|
| 238 |
+
records = deduplicate_records(records)
|
| 239 |
+
|
| 240 |
+
if bool(filtering_cfg.get("drop_empty_labels", True)):
|
| 241 |
+
records = [r for r in records if len(r.attributes_norm) > 0]
|
| 242 |
+
|
| 243 |
+
min_support = int(attributes_cfg.get("min_support", 1))
|
| 244 |
+
filtered_label_lists, label_freq = filter_by_min_support(
|
| 245 |
+
[r.attributes_norm for r in records],
|
| 246 |
+
min_support=min_support,
|
| 247 |
+
)
|
| 248 |
+
for idx, labels in enumerate(filtered_label_lists):
|
| 249 |
+
records[idx].attributes_norm = labels
|
| 250 |
+
|
| 251 |
+
if bool(filtering_cfg.get("drop_empty_labels", True)):
|
| 252 |
+
records = [r for r in records if len(r.attributes_norm) > 0]
|
| 253 |
+
|
| 254 |
+
max_samples_per_attribute = filtering_cfg.get("max_samples_per_attribute")
|
| 255 |
+
if max_samples_per_attribute is not None:
|
| 256 |
+
per_attr_limit = int(max_samples_per_attribute)
|
| 257 |
+
attr_counts: Dict[str, int] = {}
|
| 258 |
+
limited: List[SampleRecord] = []
|
| 259 |
+
for rec in records:
|
| 260 |
+
if not rec.attributes_norm:
|
| 261 |
+
continue
|
| 262 |
+
can_take = True
|
| 263 |
+
for label in rec.attributes_norm:
|
| 264 |
+
if attr_counts.get(label, 0) >= per_attr_limit:
|
| 265 |
+
can_take = False
|
| 266 |
+
break
|
| 267 |
+
if can_take:
|
| 268 |
+
limited.append(rec)
|
| 269 |
+
for label in rec.attributes_norm:
|
| 270 |
+
attr_counts[label] = attr_counts.get(label, 0) + 1
|
| 271 |
+
records = limited
|
| 272 |
+
|
| 273 |
+
label_freq = {}
|
| 274 |
+
for rec in records:
|
| 275 |
+
for label in set(rec.attributes_norm):
|
| 276 |
+
label_freq[label] = label_freq.get(label, 0) + 1
|
| 277 |
+
label_freq = dict(sorted(label_freq.items()))
|
| 278 |
+
label_vocab = build_label_vocab(label_freq)
|
| 279 |
+
|
| 280 |
+
rows = [record.to_dict() for record in records]
|
| 281 |
+
samples_parquet = processed_root / processing_cfg.get("samples_file_parquet", "samples.parquet")
|
| 282 |
+
samples_csv = processed_root / processing_cfg.get("samples_file_csv", "samples.csv")
|
| 283 |
+
output_format = str(processing_cfg.get("output_format", "parquet")).lower()
|
| 284 |
+
csv_fallback = bool(processing_cfg.get("csv_fallback", True))
|
| 285 |
+
samples_written = save_samples(
|
| 286 |
+
rows=rows,
|
| 287 |
+
output_format=output_format,
|
| 288 |
+
csv_fallback=csv_fallback,
|
| 289 |
+
parquet_path=samples_parquet,
|
| 290 |
+
csv_path=samples_csv,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
write_json(
|
| 294 |
+
processed_root / processing_cfg.get("label_vocab_file", "label_vocab.json"),
|
| 295 |
+
label_vocab,
|
| 296 |
+
)
|
| 297 |
+
write_json(
|
| 298 |
+
processed_root / processing_cfg.get("label_frequency_file", "label_frequencies.json"),
|
| 299 |
+
label_freq,
|
| 300 |
+
)
|
| 301 |
+
report = {
|
| 302 |
+
"raw_attributes_file": str(attributes_path),
|
| 303 |
+
"raw_image_data_file": str(image_data_path),
|
| 304 |
+
"samples_output": samples_written,
|
| 305 |
+
"strict_chair_only": strict_chair_only,
|
| 306 |
+
"primary_object_class": primary_object,
|
| 307 |
+
"total_samples_final": len(records),
|
| 308 |
+
"labels_kept": list(label_vocab.keys()),
|
| 309 |
+
"num_labels_kept": len(label_vocab),
|
| 310 |
+
"min_support": min_support,
|
| 311 |
+
**row_stats,
|
| 312 |
+
}
|
| 313 |
+
write_json(
|
| 314 |
+
processed_root / processing_cfg.get("processing_report_file", "processing_report.json"),
|
| 315 |
+
report,
|
| 316 |
+
)
|
| 317 |
+
print(f"[ok] Processed {len(records)} chair samples.")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
main()
|
scripts/data/run_data_pipeline.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def parse_args() -> argparse.Namespace:
|
| 14 |
+
parser = argparse.ArgumentParser(description="Run chair-only Visual Genome data pipeline.")
|
| 15 |
+
parser.add_argument(
|
| 16 |
+
"--config",
|
| 17 |
+
type=Path,
|
| 18 |
+
default=REPO_ROOT / "configs" / "dataset.yaml",
|
| 19 |
+
help="Path to dataset config yaml.",
|
| 20 |
+
)
|
| 21 |
+
parser.add_argument(
|
| 22 |
+
"--dataset-name",
|
| 23 |
+
type=str,
|
| 24 |
+
default="visual_genome",
|
| 25 |
+
help="Dataset selector. Only visual_genome is supported in this version.",
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument("--download-only", action="store_true")
|
| 28 |
+
parser.add_argument("--extract-only", action="store_true")
|
| 29 |
+
parser.add_argument("--process-only", action="store_true")
|
| 30 |
+
parser.add_argument("--split-only", action="store_true")
|
| 31 |
+
parser.add_argument("--all", action="store_true", help="Run full pipeline (default behavior).")
|
| 32 |
+
return parser.parse_args()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def run_step(script_path: Path, config_path: Path) -> None:
|
| 36 |
+
cmd: List[str] = [sys.executable, str(script_path), "--config", str(config_path)]
|
| 37 |
+
print(f"[run] {' '.join(cmd)}")
|
| 38 |
+
subprocess.run(cmd, check=True, cwd=REPO_ROOT)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main() -> None:
|
| 42 |
+
args = parse_args()
|
| 43 |
+
if args.dataset_name != "visual_genome":
|
| 44 |
+
raise RuntimeError("Only --dataset-name visual_genome is supported currently.")
|
| 45 |
+
|
| 46 |
+
selected_flags = [
|
| 47 |
+
args.download_only,
|
| 48 |
+
args.extract_only,
|
| 49 |
+
args.process_only,
|
| 50 |
+
args.split_only,
|
| 51 |
+
args.all,
|
| 52 |
+
]
|
| 53 |
+
if sum(1 for flag in selected_flags if flag) > 1:
|
| 54 |
+
raise RuntimeError("Use only one stage selector at a time.")
|
| 55 |
+
|
| 56 |
+
scripts_dir = REPO_ROOT / "scripts" / "data"
|
| 57 |
+
steps = {
|
| 58 |
+
"download": scripts_dir / "download_visual_genome.py",
|
| 59 |
+
"extract": scripts_dir / "extract_visual_genome.py",
|
| 60 |
+
"process": scripts_dir / "process_visual_genome.py",
|
| 61 |
+
"split": scripts_dir / "build_splits.py",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if args.download_only:
|
| 65 |
+
pipeline = ["download"]
|
| 66 |
+
elif args.extract_only:
|
| 67 |
+
pipeline = ["extract"]
|
| 68 |
+
elif args.process_only:
|
| 69 |
+
pipeline = ["process"]
|
| 70 |
+
elif args.split_only:
|
| 71 |
+
pipeline = ["split"]
|
| 72 |
+
else:
|
| 73 |
+
pipeline = ["download", "extract", "process", "split"]
|
| 74 |
+
|
| 75 |
+
for step in pipeline:
|
| 76 |
+
run_step(script_path=steps[step], config_path=args.config)
|
| 77 |
+
print("[ok] Data pipeline completed.")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
scripts/eval_baseline.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 12 |
+
if str(SRC_DIR) not in sys.path:
|
| 13 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def parse_args() -> argparse.Namespace:
|
| 17 |
+
parser = argparse.ArgumentParser(description="Show saved metrics for a trained baseline run.")
|
| 18 |
+
parser.add_argument("--run-name", type=str, required=True)
|
| 19 |
+
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "outputs")
|
| 20 |
+
return parser.parse_args()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
args = parse_args()
|
| 25 |
+
metrics_path = args.output_dir / args.run_name / "metrics.json"
|
| 26 |
+
if not metrics_path.exists():
|
| 27 |
+
raise RuntimeError(f"Metrics not found: {metrics_path}")
|
| 28 |
+
with metrics_path.open("r", encoding="utf-8") as handle:
|
| 29 |
+
payload: Dict[str, Any] = json.load(handle)
|
| 30 |
+
report = {
|
| 31 |
+
"run_name": payload.get("run_name"),
|
| 32 |
+
"mode": payload.get("mode"),
|
| 33 |
+
"best_val_map": payload.get("best_val_map"),
|
| 34 |
+
"test_map": payload.get("test_metrics", {}).get("map"),
|
| 35 |
+
"test_macro_f1": payload.get("test_metrics", {}).get("macro_f1"),
|
| 36 |
+
"test_micro_f1": payload.get("test_metrics", {}).get("micro_f1"),
|
| 37 |
+
}
|
| 38 |
+
print(json.dumps(report, indent=2))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
main()
|
scripts/run_baselines.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, List
|
| 11 |
+
|
| 12 |
+
import yaml
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def log(message: str) -> None:
|
| 19 |
+
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
| 20 |
+
print(f"[{ts}] [run_baselines] {message}", flush=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_args() -> argparse.Namespace:
|
| 24 |
+
parser = argparse.ArgumentParser(description="Run Baseline A/B/C experiments.")
|
| 25 |
+
parser.add_argument("--mode", choices=["smoke", "full"], default="smoke")
|
| 26 |
+
parser.add_argument(
|
| 27 |
+
"--experiment-config",
|
| 28 |
+
type=Path,
|
| 29 |
+
default=REPO_ROOT / "configs" / "experiment.yaml",
|
| 30 |
+
)
|
| 31 |
+
parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
|
| 32 |
+
parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
|
| 33 |
+
parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
|
| 34 |
+
parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
|
| 35 |
+
return parser.parse_args()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_yaml(path: Path) -> Dict[str, Any]:
|
| 39 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 40 |
+
payload = yaml.safe_load(handle)
|
| 41 |
+
return payload or {}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def run_train(
|
| 45 |
+
mode: str,
|
| 46 |
+
run_name: str,
|
| 47 |
+
overrides: Dict[str, Any],
|
| 48 |
+
dataset_config: Path,
|
| 49 |
+
model_config: Path,
|
| 50 |
+
train_config: Path,
|
| 51 |
+
eval_config: Path,
|
| 52 |
+
) -> None:
|
| 53 |
+
cmd: List[str] = [
|
| 54 |
+
sys.executable,
|
| 55 |
+
str(REPO_ROOT / "scripts" / "train_baseline.py"),
|
| 56 |
+
"--run-name",
|
| 57 |
+
run_name,
|
| 58 |
+
"--mode",
|
| 59 |
+
mode,
|
| 60 |
+
"--dataset-config",
|
| 61 |
+
str(dataset_config),
|
| 62 |
+
"--model-config",
|
| 63 |
+
str(model_config),
|
| 64 |
+
"--train-config",
|
| 65 |
+
str(train_config),
|
| 66 |
+
"--eval-config",
|
| 67 |
+
str(eval_config),
|
| 68 |
+
"--overrides-json",
|
| 69 |
+
json.dumps(overrides),
|
| 70 |
+
]
|
| 71 |
+
env = dict(**os.environ)
|
| 72 |
+
env["PYTHONUNBUFFERED"] = "1"
|
| 73 |
+
cmd.insert(1, "-u")
|
| 74 |
+
log(f"Launching run '{run_name}' in mode='{mode}'")
|
| 75 |
+
log(f"Python executable: {sys.executable}")
|
| 76 |
+
log(f"Overrides: {json.dumps(overrides)}")
|
| 77 |
+
log(f"Command: {' '.join(cmd)}")
|
| 78 |
+
started = time.time()
|
| 79 |
+
completed = subprocess.run(cmd, check=False, cwd=REPO_ROOT, env=env)
|
| 80 |
+
elapsed = time.time() - started
|
| 81 |
+
log(f"Run '{run_name}' exited with code={completed.returncode} after {elapsed:.1f}s")
|
| 82 |
+
if completed.returncode != 0:
|
| 83 |
+
raise RuntimeError(f"Baseline run failed: {run_name}")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def main() -> None:
|
| 87 |
+
args = parse_args()
|
| 88 |
+
log("Starting baseline orchestrator")
|
| 89 |
+
log(f"Repo root: {REPO_ROOT}")
|
| 90 |
+
log(f"Mode: {args.mode}")
|
| 91 |
+
log(f"Experiment config: {args.experiment_config}")
|
| 92 |
+
experiment_cfg = load_yaml(args.experiment_config)
|
| 93 |
+
baseline_runs = experiment_cfg.get("baseline_runs", [])
|
| 94 |
+
if not baseline_runs:
|
| 95 |
+
raise RuntimeError("No baseline_runs found in experiment config.")
|
| 96 |
+
log(f"Discovered {len(baseline_runs)} baseline runs")
|
| 97 |
+
|
| 98 |
+
output_dir = REPO_ROOT / "outputs"
|
| 99 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
comparison: Dict[str, Any] = {"mode": args.mode, "runs": []}
|
| 101 |
+
|
| 102 |
+
for row in baseline_runs:
|
| 103 |
+
run_name = f"{row['name']}_{args.mode}"
|
| 104 |
+
overrides = row.get("overrides", {})
|
| 105 |
+
log(f"Preparing run: {run_name}")
|
| 106 |
+
run_train(
|
| 107 |
+
mode=args.mode,
|
| 108 |
+
run_name=run_name,
|
| 109 |
+
overrides=overrides,
|
| 110 |
+
dataset_config=args.dataset_config,
|
| 111 |
+
model_config=args.model_config,
|
| 112 |
+
train_config=args.train_config,
|
| 113 |
+
eval_config=args.eval_config,
|
| 114 |
+
)
|
| 115 |
+
metrics_path = output_dir / run_name / "metrics.json"
|
| 116 |
+
log(f"Reading metrics from: {metrics_path}")
|
| 117 |
+
with metrics_path.open("r", encoding="utf-8") as handle:
|
| 118 |
+
metrics = json.load(handle)
|
| 119 |
+
comparison["runs"].append(
|
| 120 |
+
{
|
| 121 |
+
"run_name": run_name,
|
| 122 |
+
"test_map": metrics.get("test_metrics", {}).get("map"),
|
| 123 |
+
"test_macro_f1": metrics.get("test_metrics", {}).get("macro_f1"),
|
| 124 |
+
"test_micro_f1": metrics.get("test_metrics", {}).get("micro_f1"),
|
| 125 |
+
}
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
comparison_path = output_dir / f"baseline_comparison_{args.mode}.json"
|
| 129 |
+
with comparison_path.open("w", encoding="utf-8") as handle:
|
| 130 |
+
json.dump(comparison, handle, indent=2, ensure_ascii=True)
|
| 131 |
+
log(f"Wrote comparison report: {comparison_path}")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
scripts/train_baseline.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict, List, Tuple
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from tqdm import tqdm
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
SRC_DIR = REPO_ROOT / "src"
|
| 18 |
+
if str(SRC_DIR) not in sys.path:
|
| 19 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 20 |
+
|
| 21 |
+
from data.io_utils import write_json # noqa: E402
|
| 22 |
+
from train.config_utils import ( # noqa: E402
|
| 23 |
+
apply_dot_overrides,
|
| 24 |
+
deep_merge,
|
| 25 |
+
load_yaml,
|
| 26 |
+
parse_overrides_json,
|
| 27 |
+
)
|
| 28 |
+
from train.dataset import ( # noqa: E402
|
| 29 |
+
ChairAttributeDataset,
|
| 30 |
+
build_dataloader,
|
| 31 |
+
class_pos_weights,
|
| 32 |
+
load_json,
|
| 33 |
+
split_paths,
|
| 34 |
+
)
|
| 35 |
+
from train.encoders import build_backbones # noqa: E402
|
| 36 |
+
from train.losses import bce_logits_loss # noqa: E402
|
| 37 |
+
from train.metrics import compute_metrics # noqa: E402
|
| 38 |
+
from train.models import BaselineClassifier # noqa: E402
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def log(message: str) -> None:
|
| 42 |
+
ts = time.strftime("%Y-%m-%d %H:%M:%S")
|
| 43 |
+
print(f"[{ts}] [train_baseline] {message}", flush=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_args() -> argparse.Namespace:
|
| 47 |
+
parser = argparse.ArgumentParser(description="Train baseline model (A/B/C).")
|
| 48 |
+
parser.add_argument("--dataset-config", type=Path, default=REPO_ROOT / "configs" / "dataset.yaml")
|
| 49 |
+
parser.add_argument("--model-config", type=Path, default=REPO_ROOT / "configs" / "model.yaml")
|
| 50 |
+
parser.add_argument("--train-config", type=Path, default=REPO_ROOT / "configs" / "train.yaml")
|
| 51 |
+
parser.add_argument("--eval-config", type=Path, default=REPO_ROOT / "configs" / "eval.yaml")
|
| 52 |
+
parser.add_argument("--run-name", type=str, required=True)
|
| 53 |
+
parser.add_argument("--overrides-json", type=str, default="")
|
| 54 |
+
parser.add_argument("--mode", type=str, default="full", choices=["smoke", "full"])
|
| 55 |
+
parser.add_argument("--device", type=str, default="auto", choices=["auto", "cuda", "cpu"])
|
| 56 |
+
return parser.parse_args()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def set_seed(seed: int, deterministic: bool) -> None:
|
| 60 |
+
random.seed(seed)
|
| 61 |
+
np.random.seed(seed)
|
| 62 |
+
torch.manual_seed(seed)
|
| 63 |
+
if torch.cuda.is_available():
|
| 64 |
+
torch.cuda.manual_seed_all(seed)
|
| 65 |
+
torch.backends.cudnn.deterministic = deterministic
|
| 66 |
+
torch.backends.cudnn.benchmark = not deterministic
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_combined_config(args: argparse.Namespace) -> Dict[str, Any]:
|
| 70 |
+
dataset_cfg = load_yaml(args.dataset_config)
|
| 71 |
+
model_cfg = load_yaml(args.model_config)
|
| 72 |
+
train_cfg = load_yaml(args.train_config)
|
| 73 |
+
eval_cfg = load_yaml(args.eval_config)
|
| 74 |
+
combined = deep_merge(dataset_cfg, model_cfg)
|
| 75 |
+
combined = deep_merge(combined, train_cfg)
|
| 76 |
+
combined = deep_merge(combined, eval_cfg)
|
| 77 |
+
overrides = parse_overrides_json(args.overrides_json)
|
| 78 |
+
if overrides:
|
| 79 |
+
combined = apply_dot_overrides(combined, overrides)
|
| 80 |
+
return combined
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_loaders(cfg: Dict[str, Any], batch_size: int, eval_batch_size: int, smoke: bool) -> Tuple[Any, Any, Any, Dict[str, int]]:
|
| 84 |
+
processed_root = REPO_ROOT / cfg["dataset"]["processed_dir"]
|
| 85 |
+
label_vocab_path = processed_root / cfg["processing"]["label_vocab_file"]
|
| 86 |
+
label_freq_path = processed_root / cfg["processing"]["label_frequency_file"]
|
| 87 |
+
cache_dir = REPO_ROOT / cfg["dataset"]["root_dir"] / "images"
|
| 88 |
+
splits = split_paths(processed_root=processed_root, split_dir_name=cfg["processing"]["split_dir"])
|
| 89 |
+
|
| 90 |
+
label_vocab = load_json(label_vocab_path)
|
| 91 |
+
label_freq = load_json(label_freq_path)
|
| 92 |
+
|
| 93 |
+
train_ds = ChairAttributeDataset(splits["train"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 94 |
+
val_ds = ChairAttributeDataset(splits["val"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 95 |
+
test_ds = ChairAttributeDataset(splits["test"], label_vocab_path, REPO_ROOT, cache_dir)
|
| 96 |
+
|
| 97 |
+
num_workers = int(cfg["dataset"].get("num_workers", 0))
|
| 98 |
+
if smoke:
|
| 99 |
+
num_workers = min(num_workers, 2)
|
| 100 |
+
|
| 101 |
+
train_loader = build_dataloader(
|
| 102 |
+
dataset=train_ds,
|
| 103 |
+
batch_size=batch_size,
|
| 104 |
+
shuffle=True,
|
| 105 |
+
num_workers=num_workers,
|
| 106 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 107 |
+
)
|
| 108 |
+
val_loader = build_dataloader(
|
| 109 |
+
dataset=val_ds,
|
| 110 |
+
batch_size=eval_batch_size,
|
| 111 |
+
shuffle=False,
|
| 112 |
+
num_workers=num_workers,
|
| 113 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 114 |
+
)
|
| 115 |
+
test_loader = build_dataloader(
|
| 116 |
+
dataset=test_ds,
|
| 117 |
+
batch_size=eval_batch_size,
|
| 118 |
+
shuffle=False,
|
| 119 |
+
num_workers=num_workers,
|
| 120 |
+
pin_memory=bool(cfg["dataset"].get("pin_memory", True)),
|
| 121 |
+
)
|
| 122 |
+
return train_loader, val_loader, test_loader, {"vocab": label_vocab, "freq": label_freq}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def build_model(cfg: Dict[str, Any], num_labels: int, device: torch.device) -> BaselineClassifier:
|
| 126 |
+
use_clip = bool(cfg["encoders"]["clip"]["enabled"])
|
| 127 |
+
use_dino = bool(cfg["encoders"]["dino"]["enabled"])
|
| 128 |
+
clip_name = str(cfg["encoders"]["clip"].get("model_name", "vit_base_patch16_clip_224.openai"))
|
| 129 |
+
dino_name = str(cfg["encoders"]["dino"].get("model_name", "vit_base_patch14_dinov2.lvd142m"))
|
| 130 |
+
if clip_name == "ViT-B-32":
|
| 131 |
+
clip_name = "vit_base_patch16_clip_224.openai"
|
| 132 |
+
if dino_name == "vit_base_patch14_dinov2":
|
| 133 |
+
dino_name = "vit_base_patch14_dinov2.lvd142m"
|
| 134 |
+
|
| 135 |
+
clip_backbone, dino_backbone, feature_dim = build_backbones(
|
| 136 |
+
use_clip=use_clip,
|
| 137 |
+
use_dino=use_dino,
|
| 138 |
+
clip_model_name=clip_name,
|
| 139 |
+
dino_model_name=dino_name,
|
| 140 |
+
clip_pretrained=bool(cfg["encoders"]["clip"].get("pretrained", True)),
|
| 141 |
+
dino_pretrained=bool(cfg["encoders"]["dino"].get("pretrained", True)),
|
| 142 |
+
)
|
| 143 |
+
hidden_dims = list(cfg["baseline_heads"]["mlp"].get("hidden_dims", [512, 256]))
|
| 144 |
+
dropout = float(cfg["baseline_heads"]["mlp"].get("dropout", 0.2))
|
| 145 |
+
model = BaselineClassifier(
|
| 146 |
+
clip_backbone=clip_backbone,
|
| 147 |
+
dino_backbone=dino_backbone,
|
| 148 |
+
feature_dim=feature_dim,
|
| 149 |
+
hidden_dims=hidden_dims,
|
| 150 |
+
dropout=dropout,
|
| 151 |
+
num_labels=num_labels,
|
| 152 |
+
)
|
| 153 |
+
model.freeze_backbones()
|
| 154 |
+
return model.to(device)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def run_eval(model: BaselineClassifier, loader: Any, device: torch.device, pos_weight: torch.Tensor) -> Dict[str, Any]:
|
| 158 |
+
model.eval()
|
| 159 |
+
all_logits: List[np.ndarray] = []
|
| 160 |
+
all_targets: List[np.ndarray] = []
|
| 161 |
+
losses: List[float] = []
|
| 162 |
+
with torch.no_grad():
|
| 163 |
+
for batch in loader:
|
| 164 |
+
targets = batch["targets"].to(device)
|
| 165 |
+
logits = model(batch["images"], device=device)
|
| 166 |
+
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
|
| 167 |
+
losses.append(float(loss.item()))
|
| 168 |
+
all_logits.append(logits.detach().cpu().numpy())
|
| 169 |
+
all_targets.append(targets.detach().cpu().numpy())
|
| 170 |
+
logits_np = np.concatenate(all_logits, axis=0) if all_logits else np.zeros((0, 1))
|
| 171 |
+
targets_np = np.concatenate(all_targets, axis=0) if all_targets else np.zeros((0, 1))
|
| 172 |
+
metrics = compute_metrics(targets=targets_np, logits=logits_np)
|
| 173 |
+
metrics["loss"] = float(np.mean(losses)) if losses else 0.0
|
| 174 |
+
return metrics
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def train() -> None:
|
| 178 |
+
args = parse_args()
|
| 179 |
+
log(f"Starting run_name={args.run_name} mode={args.mode} device_request={args.device}")
|
| 180 |
+
log(f"dataset_config={args.dataset_config}")
|
| 181 |
+
log(f"model_config={args.model_config}")
|
| 182 |
+
log(f"train_config={args.train_config}")
|
| 183 |
+
cfg = load_combined_config(args)
|
| 184 |
+
run_cfg = cfg.get("run", {})
|
| 185 |
+
seed = int(run_cfg.get("seed", 42))
|
| 186 |
+
deterministic = bool(run_cfg.get("deterministic", True))
|
| 187 |
+
set_seed(seed=seed, deterministic=deterministic)
|
| 188 |
+
log(f"Seed set to {seed}, deterministic={deterministic}")
|
| 189 |
+
|
| 190 |
+
smoke = args.mode == "smoke"
|
| 191 |
+
train_epochs = 2 if smoke else int(cfg["training"]["stage1"].get("epochs", 20))
|
| 192 |
+
batch_size = 8 if smoke else int(cfg["batching"].get("batch_size", 32))
|
| 193 |
+
eval_batch_size = 8 if smoke else int(cfg["batching"].get("eval_batch_size", 64))
|
| 194 |
+
|
| 195 |
+
cuda_available = torch.cuda.is_available()
|
| 196 |
+
selected_device = "cuda" if (args.device == "auto" and cuda_available) else args.device
|
| 197 |
+
if args.device == "auto" and not cuda_available:
|
| 198 |
+
selected_device = "cpu"
|
| 199 |
+
if selected_device == "cuda" and not cuda_available:
|
| 200 |
+
raise RuntimeError(
|
| 201 |
+
"CUDA requested but not available in torch build/environment. "
|
| 202 |
+
"Install CUDA-enabled PyTorch in the active virtualenv."
|
| 203 |
+
)
|
| 204 |
+
device = torch.device(selected_device)
|
| 205 |
+
log(
|
| 206 |
+
f"Resolved device={device.type}, torch_cuda_available={cuda_available}, "
|
| 207 |
+
f"torch_version={torch.__version__}, torch_cuda={torch.version.cuda}"
|
| 208 |
+
)
|
| 209 |
+
log("Building dataloaders from processed manifests...")
|
| 210 |
+
train_loader, val_loader, test_loader, label_info = build_loaders(
|
| 211 |
+
cfg=cfg,
|
| 212 |
+
batch_size=batch_size,
|
| 213 |
+
eval_batch_size=eval_batch_size,
|
| 214 |
+
smoke=smoke,
|
| 215 |
+
)
|
| 216 |
+
log(
|
| 217 |
+
f"Dataloader sizes -> train_batches={len(train_loader)} val_batches={len(val_loader)} "
|
| 218 |
+
f"test_batches={len(test_loader)}"
|
| 219 |
+
)
|
| 220 |
+
label_vocab = label_info["vocab"]
|
| 221 |
+
label_freq = label_info["freq"]
|
| 222 |
+
num_labels = len(label_vocab)
|
| 223 |
+
log(f"Label space size: {num_labels}")
|
| 224 |
+
log("Building encoder backbones (first run may download pretrained weights)...")
|
| 225 |
+
model = build_model(cfg=cfg, num_labels=num_labels, device=device)
|
| 226 |
+
log("Model construction complete")
|
| 227 |
+
pos_weight = class_pos_weights(label_frequencies=label_freq, label_vocab=label_vocab).to(device)
|
| 228 |
+
|
| 229 |
+
lr = float(cfg["training"]["stage1"].get("lr_head", 1e-3))
|
| 230 |
+
weight_decay = float(cfg["optimization"].get("weight_decay", 1e-4))
|
| 231 |
+
optimizer = torch.optim.AdamW(
|
| 232 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 233 |
+
lr=lr,
|
| 234 |
+
weight_decay=weight_decay,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
output_root = REPO_ROOT / run_cfg.get("output_dir", "outputs")
|
| 238 |
+
run_dir = output_root / args.run_name
|
| 239 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 240 |
+
|
| 241 |
+
history: List[Dict[str, Any]] = []
|
| 242 |
+
best_map = -1.0
|
| 243 |
+
best_path = run_dir / "best.pt"
|
| 244 |
+
log(f"Training for epochs={train_epochs}, batch_size={batch_size}, eval_batch_size={eval_batch_size}")
|
| 245 |
+
for epoch in range(1, train_epochs + 1):
|
| 246 |
+
model.train()
|
| 247 |
+
train_losses: List[float] = []
|
| 248 |
+
for batch in tqdm(train_loader, desc=f"{args.run_name} epoch {epoch}/{train_epochs}"):
|
| 249 |
+
targets = batch["targets"].to(device)
|
| 250 |
+
logits = model(batch["images"], device=device)
|
| 251 |
+
loss = bce_logits_loss(logits, targets, pos_weight=pos_weight)
|
| 252 |
+
optimizer.zero_grad(set_to_none=True)
|
| 253 |
+
loss.backward()
|
| 254 |
+
optimizer.step()
|
| 255 |
+
train_losses.append(float(loss.item()))
|
| 256 |
+
|
| 257 |
+
val_metrics = run_eval(model=model, loader=val_loader, device=device, pos_weight=pos_weight)
|
| 258 |
+
epoch_row = {
|
| 259 |
+
"epoch": epoch,
|
| 260 |
+
"train_loss": float(np.mean(train_losses)) if train_losses else 0.0,
|
| 261 |
+
"val_loss": val_metrics["loss"],
|
| 262 |
+
"val_map": val_metrics["map"],
|
| 263 |
+
"val_macro_f1": val_metrics["macro_f1"],
|
| 264 |
+
"val_micro_f1": val_metrics["micro_f1"],
|
| 265 |
+
}
|
| 266 |
+
history.append(epoch_row)
|
| 267 |
+
log(
|
| 268 |
+
f"Epoch {epoch}/{train_epochs} -> train_loss={epoch_row['train_loss']:.4f} "
|
| 269 |
+
f"val_map={epoch_row['val_map']:.4f} val_macro_f1={epoch_row['val_macro_f1']:.4f}"
|
| 270 |
+
)
|
| 271 |
+
if val_metrics["map"] > best_map:
|
| 272 |
+
best_map = val_metrics["map"]
|
| 273 |
+
torch.save(
|
| 274 |
+
{
|
| 275 |
+
"model_state": model.state_dict(),
|
| 276 |
+
"label_vocab": label_vocab,
|
| 277 |
+
"config": cfg,
|
| 278 |
+
},
|
| 279 |
+
best_path,
|
| 280 |
+
)
|
| 281 |
+
log(f"Saved new best checkpoint at epoch {epoch}: {best_path}")
|
| 282 |
+
|
| 283 |
+
log("Running final test evaluation using best checkpoint...")
|
| 284 |
+
checkpoint = torch.load(best_path, map_location=device)
|
| 285 |
+
model.load_state_dict(checkpoint["model_state"])
|
| 286 |
+
test_metrics = run_eval(model=model, loader=test_loader, device=device, pos_weight=pos_weight)
|
| 287 |
+
payload = {
|
| 288 |
+
"run_name": args.run_name,
|
| 289 |
+
"mode": args.mode,
|
| 290 |
+
"num_labels": num_labels,
|
| 291 |
+
"best_val_map": best_map,
|
| 292 |
+
"history": history,
|
| 293 |
+
"test_metrics": test_metrics,
|
| 294 |
+
}
|
| 295 |
+
write_json(run_dir / "metrics.json", payload)
|
| 296 |
+
write_json(run_dir / "label_vocab.json", label_vocab)
|
| 297 |
+
with (run_dir / "overrides.json").open("w", encoding="utf-8") as handle:
|
| 298 |
+
json.dump(parse_overrides_json(args.overrides_json), handle, indent=2, ensure_ascii=True)
|
| 299 |
+
log(f"Training complete for {args.run_name}")
|
| 300 |
+
print(json.dumps({"run_name": args.run_name, "test_map": test_metrics["map"]}, indent=2), flush=True)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
if __name__ == "__main__":
|
| 304 |
+
train()
|
src/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Data pipeline utilities and processing modules."""
|
src/data/io_utils.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import tarfile
|
| 6 |
+
import time
|
| 7 |
+
import zipfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
from urllib.error import HTTPError, URLError
|
| 11 |
+
from urllib.request import Request, urlopen
|
| 12 |
+
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def load_yaml(path: Path) -> Dict[str, Any]:
|
| 17 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 18 |
+
data = yaml.safe_load(handle)
|
| 19 |
+
return data or {}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def ensure_dir(path: Path) -> None:
|
| 23 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
| 27 |
+
digest = hashlib.sha256()
|
| 28 |
+
with path.open("rb") as handle:
|
| 29 |
+
while True:
|
| 30 |
+
block = handle.read(chunk_size)
|
| 31 |
+
if not block:
|
| 32 |
+
break
|
| 33 |
+
digest.update(block)
|
| 34 |
+
return digest.hexdigest()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def write_json(path: Path, payload: Dict[str, Any]) -> None:
|
| 38 |
+
ensure_dir(path.parent)
|
| 39 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 40 |
+
json.dump(payload, handle, indent=2, ensure_ascii=True)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def read_json(path: Path) -> Any:
|
| 44 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 45 |
+
return json.load(handle)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _download_once(
|
| 49 |
+
url: str,
|
| 50 |
+
destination: Path,
|
| 51 |
+
timeout_seconds: int,
|
| 52 |
+
chunk_size: int,
|
| 53 |
+
) -> None:
|
| 54 |
+
temp_file = destination.with_suffix(destination.suffix + ".part")
|
| 55 |
+
resume_pos = temp_file.stat().st_size if temp_file.exists() else 0
|
| 56 |
+
|
| 57 |
+
headers = {}
|
| 58 |
+
if resume_pos > 0:
|
| 59 |
+
headers["Range"] = f"bytes={resume_pos}-"
|
| 60 |
+
|
| 61 |
+
request = Request(url=url, headers=headers)
|
| 62 |
+
with urlopen(request, timeout=timeout_seconds) as response:
|
| 63 |
+
status = getattr(response, "status", 200)
|
| 64 |
+
supports_resume = status == 206 and resume_pos > 0
|
| 65 |
+
mode = "ab" if supports_resume else "wb"
|
| 66 |
+
with temp_file.open(mode) as out:
|
| 67 |
+
while True:
|
| 68 |
+
chunk = response.read(chunk_size)
|
| 69 |
+
if not chunk:
|
| 70 |
+
break
|
| 71 |
+
out.write(chunk)
|
| 72 |
+
temp_file.replace(destination)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def download_file(
|
| 76 |
+
url: str,
|
| 77 |
+
destination: Path,
|
| 78 |
+
timeout_seconds: int = 120,
|
| 79 |
+
retries: int = 3,
|
| 80 |
+
chunk_size: int = 1024 * 1024,
|
| 81 |
+
) -> None:
|
| 82 |
+
ensure_dir(destination.parent)
|
| 83 |
+
attempts = max(1, retries)
|
| 84 |
+
for attempt in range(1, attempts + 1):
|
| 85 |
+
try:
|
| 86 |
+
_download_once(
|
| 87 |
+
url=url,
|
| 88 |
+
destination=destination,
|
| 89 |
+
timeout_seconds=timeout_seconds,
|
| 90 |
+
chunk_size=chunk_size,
|
| 91 |
+
)
|
| 92 |
+
return
|
| 93 |
+
except (HTTPError, URLError, TimeoutError, OSError) as exc:
|
| 94 |
+
if attempt == attempts:
|
| 95 |
+
raise RuntimeError(
|
| 96 |
+
f"Download failed after {attempts} attempts for {url}: {exc}"
|
| 97 |
+
) from exc
|
| 98 |
+
sleep_s = min(10, attempt * 2)
|
| 99 |
+
time.sleep(sleep_s)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def verify_checksum(path: Path, expected_sha256: Optional[str]) -> bool:
|
| 103 |
+
if not expected_sha256:
|
| 104 |
+
return True
|
| 105 |
+
actual = sha256_file(path)
|
| 106 |
+
return actual.lower() == expected_sha256.lower()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _is_within_directory(base_dir: Path, target_path: Path) -> bool:
|
| 110 |
+
try:
|
| 111 |
+
base = base_dir.resolve(strict=False)
|
| 112 |
+
target = target_path.resolve(strict=False)
|
| 113 |
+
target.relative_to(base)
|
| 114 |
+
return True
|
| 115 |
+
except ValueError:
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def extract_archive(archive_path: Path, output_dir: Path, safe_extract: bool = True) -> None:
|
| 120 |
+
ensure_dir(output_dir)
|
| 121 |
+
lower_name = archive_path.name.lower()
|
| 122 |
+
|
| 123 |
+
if zipfile.is_zipfile(archive_path):
|
| 124 |
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
| 125 |
+
members = zf.infolist()
|
| 126 |
+
if safe_extract:
|
| 127 |
+
for member in members:
|
| 128 |
+
target = output_dir / member.filename
|
| 129 |
+
if not _is_within_directory(output_dir, target):
|
| 130 |
+
raise RuntimeError(
|
| 131 |
+
f"Unsafe zip member path detected: {member.filename}"
|
| 132 |
+
)
|
| 133 |
+
zf.extractall(output_dir)
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
if tarfile.is_tarfile(archive_path):
|
| 137 |
+
with tarfile.open(archive_path, "r:*") as tf:
|
| 138 |
+
members = tf.getmembers()
|
| 139 |
+
if safe_extract:
|
| 140 |
+
for member in members:
|
| 141 |
+
target = output_dir / member.name
|
| 142 |
+
if not _is_within_directory(output_dir, target):
|
| 143 |
+
raise RuntimeError(
|
| 144 |
+
f"Unsafe tar member path detected: {member.name}"
|
| 145 |
+
)
|
| 146 |
+
tf.extractall(output_dir)
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
raise RuntimeError(f"Unsupported archive format: {lower_name}")
|
src/data/normalization.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from typing import Dict, Iterable, List, Sequence, Set, Tuple
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
_NON_ALPHA_NUM = re.compile(r"[^a-z0-9 ]+")
|
| 9 |
+
_MULTI_SPACE = re.compile(r"\s+")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def simple_lemma(token: str) -> str:
|
| 13 |
+
if token.endswith("ies") and len(token) > 3:
|
| 14 |
+
return token[:-3] + "y"
|
| 15 |
+
if token.endswith("ses") and len(token) > 3:
|
| 16 |
+
return token[:-2]
|
| 17 |
+
if token.endswith("s") and len(token) > 3:
|
| 18 |
+
return token[:-1]
|
| 19 |
+
return token
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def normalize_text(value: str, lowercase: bool = True, lemmatize: bool = True) -> str:
|
| 23 |
+
text = value.strip()
|
| 24 |
+
if lowercase:
|
| 25 |
+
text = text.lower()
|
| 26 |
+
text = _NON_ALPHA_NUM.sub(" ", text)
|
| 27 |
+
text = _MULTI_SPACE.sub(" ", text).strip()
|
| 28 |
+
if not text:
|
| 29 |
+
return ""
|
| 30 |
+
if lemmatize:
|
| 31 |
+
text = " ".join(simple_lemma(part) for part in text.split(" "))
|
| 32 |
+
return text
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def normalize_label(
|
| 36 |
+
label: str,
|
| 37 |
+
lowercase: bool,
|
| 38 |
+
lemmatize: bool,
|
| 39 |
+
synonym_map: Dict[str, str],
|
| 40 |
+
) -> str:
|
| 41 |
+
normalized = normalize_text(label, lowercase=lowercase, lemmatize=lemmatize)
|
| 42 |
+
if not normalized:
|
| 43 |
+
return ""
|
| 44 |
+
if normalized in synonym_map:
|
| 45 |
+
return synonym_map[normalized]
|
| 46 |
+
return normalized
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def normalize_labels(
|
| 50 |
+
labels: Sequence[str],
|
| 51 |
+
lowercase: bool,
|
| 52 |
+
lemmatize: bool,
|
| 53 |
+
synonym_map: Dict[str, str],
|
| 54 |
+
keep_unmapped: bool,
|
| 55 |
+
allowed_labels: Set[str],
|
| 56 |
+
) -> List[str]:
|
| 57 |
+
out: List[str] = []
|
| 58 |
+
for label in labels:
|
| 59 |
+
normalized = normalize_label(
|
| 60 |
+
label=label,
|
| 61 |
+
lowercase=lowercase,
|
| 62 |
+
lemmatize=lemmatize,
|
| 63 |
+
synonym_map=synonym_map,
|
| 64 |
+
)
|
| 65 |
+
if not normalized:
|
| 66 |
+
continue
|
| 67 |
+
if normalized in allowed_labels or keep_unmapped:
|
| 68 |
+
out.append(normalized)
|
| 69 |
+
return sorted(set(out))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def filter_by_min_support(
|
| 73 |
+
normalized_label_lists: Sequence[Sequence[str]],
|
| 74 |
+
min_support: int,
|
| 75 |
+
) -> Tuple[List[List[str]], Dict[str, int]]:
|
| 76 |
+
counter: Counter[str] = Counter()
|
| 77 |
+
for labels in normalized_label_lists:
|
| 78 |
+
counter.update(set(labels))
|
| 79 |
+
|
| 80 |
+
keep_labels = {name for name, count in counter.items() if count >= min_support}
|
| 81 |
+
filtered: List[List[str]] = []
|
| 82 |
+
for labels in normalized_label_lists:
|
| 83 |
+
filtered.append(sorted([label for label in set(labels) if label in keep_labels]))
|
| 84 |
+
|
| 85 |
+
filtered_counter: Counter[str] = Counter()
|
| 86 |
+
for labels in filtered:
|
| 87 |
+
filtered_counter.update(set(labels))
|
| 88 |
+
return filtered, dict(sorted(filtered_counter.items()))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_label_vocab(freq_map: Dict[str, int]) -> Dict[str, int]:
|
| 92 |
+
labels = sorted(freq_map.keys())
|
| 93 |
+
return {label: idx for idx, label in enumerate(labels)}
|
src/data/schemas.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import asdict, dataclass
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class SampleRecord:
|
| 9 |
+
image_id: int
|
| 10 |
+
object_id: int
|
| 11 |
+
object_name: str
|
| 12 |
+
image_url: str
|
| 13 |
+
image_path: str
|
| 14 |
+
attributes_raw: List[str]
|
| 15 |
+
attributes_norm: List[str]
|
| 16 |
+
|
| 17 |
+
def to_dict(self) -> Dict[str, object]:
|
| 18 |
+
return asdict(self)
|
src/train/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Training utilities for baseline models."""
|
src/train/config_utils.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def load_yaml(path: Path) -> Dict[str, Any]:
|
| 12 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 13 |
+
payload = yaml.safe_load(handle)
|
| 14 |
+
return payload or {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
|
| 18 |
+
merged = copy.deepcopy(base)
|
| 19 |
+
for key, value in update.items():
|
| 20 |
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
| 21 |
+
merged[key] = deep_merge(merged[key], value)
|
| 22 |
+
else:
|
| 23 |
+
merged[key] = copy.deepcopy(value)
|
| 24 |
+
return merged
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def set_dot_key(target: Dict[str, Any], dot_key: str, value: Any) -> None:
|
| 28 |
+
parts = dot_key.split(".")
|
| 29 |
+
current = target
|
| 30 |
+
for part in parts[:-1]:
|
| 31 |
+
if part not in current or not isinstance(current[part], dict):
|
| 32 |
+
current[part] = {}
|
| 33 |
+
current = current[part]
|
| 34 |
+
current[parts[-1]] = value
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def apply_dot_overrides(target: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
|
| 38 |
+
out = copy.deepcopy(target)
|
| 39 |
+
for dot_key, value in overrides.items():
|
| 40 |
+
set_dot_key(out, dot_key, value)
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_overrides_json(raw: str | None) -> Dict[str, Any]:
|
| 45 |
+
if not raw:
|
| 46 |
+
return {}
|
| 47 |
+
payload = json.loads(raw)
|
| 48 |
+
if not isinstance(payload, dict):
|
| 49 |
+
raise ValueError("Overrides JSON must be an object.")
|
| 50 |
+
return payload
|
src/train/dataset.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Tuple
|
| 7 |
+
from urllib.error import HTTPError, URLError
|
| 8 |
+
from urllib.request import Request, urlopen
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from PIL import Image, UnidentifiedImageError
|
| 12 |
+
from torch.utils.data import DataLoader, Dataset
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_json(path: Path) -> Any:
|
| 16 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 17 |
+
return json.load(handle)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ChairAttributeDataset(Dataset):
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
split_path: Path,
|
| 24 |
+
label_vocab_path: Path,
|
| 25 |
+
repo_root: Path,
|
| 26 |
+
image_cache_dir: Path,
|
| 27 |
+
) -> None:
|
| 28 |
+
payload = load_json(split_path)
|
| 29 |
+
if "samples" not in payload:
|
| 30 |
+
raise RuntimeError(f"Invalid split file format: {split_path}")
|
| 31 |
+
self.samples: List[Dict[str, Any]] = payload["samples"]
|
| 32 |
+
self.label_vocab: Dict[str, int] = load_json(label_vocab_path)
|
| 33 |
+
self.num_labels = len(self.label_vocab)
|
| 34 |
+
self.repo_root = repo_root
|
| 35 |
+
self.image_cache_dir = image_cache_dir
|
| 36 |
+
self.image_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
self.corrupt_image_count = 0
|
| 38 |
+
|
| 39 |
+
def __len__(self) -> int:
|
| 40 |
+
return len(self.samples)
|
| 41 |
+
|
| 42 |
+
def _resolve_image_path(self, row: Dict[str, Any]) -> Path:
|
| 43 |
+
local_rel = row.get("image_path", "")
|
| 44 |
+
if local_rel:
|
| 45 |
+
local = self.repo_root / local_rel
|
| 46 |
+
if local.exists():
|
| 47 |
+
return local
|
| 48 |
+
image_id = str(row.get("image_id"))
|
| 49 |
+
return self.image_cache_dir / f"{image_id}.jpg"
|
| 50 |
+
|
| 51 |
+
def _candidate_urls(self, row: Dict[str, Any]) -> List[str]:
|
| 52 |
+
primary = str(row.get("image_url") or "").strip()
|
| 53 |
+
if not primary:
|
| 54 |
+
return []
|
| 55 |
+
candidates = [primary]
|
| 56 |
+
if "/VG_100K/" in primary:
|
| 57 |
+
candidates.append(primary.replace("/VG_100K/", "/VG_100K_2/"))
|
| 58 |
+
elif "/VG_100K_2/" in primary:
|
| 59 |
+
candidates.append(primary.replace("/VG_100K_2/", "/VG_100K/"))
|
| 60 |
+
deduped: List[str] = []
|
| 61 |
+
seen = set()
|
| 62 |
+
for url in candidates:
|
| 63 |
+
if url in seen:
|
| 64 |
+
continue
|
| 65 |
+
seen.add(url)
|
| 66 |
+
deduped.append(url)
|
| 67 |
+
return deduped
|
| 68 |
+
|
| 69 |
+
def _download_if_missing(self, row: Dict[str, Any], path: Path, force: bool = False) -> None:
|
| 70 |
+
if path.exists() and not force:
|
| 71 |
+
return
|
| 72 |
+
urls = self._candidate_urls(row)
|
| 73 |
+
if not urls:
|
| 74 |
+
raise RuntimeError(f"Missing image_url for image_id={row.get('image_id')}")
|
| 75 |
+
last_error: Exception | None = None
|
| 76 |
+
for url in urls:
|
| 77 |
+
try:
|
| 78 |
+
request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
| 79 |
+
with urlopen(request, timeout=30) as response:
|
| 80 |
+
image_bytes = response.read()
|
| 81 |
+
# Validate bytes are actually decodable image data before caching.
|
| 82 |
+
with Image.open(BytesIO(image_bytes)) as probe:
|
| 83 |
+
probe.verify()
|
| 84 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
with path.open("wb") as handle:
|
| 86 |
+
handle.write(image_bytes)
|
| 87 |
+
return
|
| 88 |
+
except (HTTPError, URLError, TimeoutError, OSError, UnidentifiedImageError) as exc:
|
| 89 |
+
last_error = exc
|
| 90 |
+
continue
|
| 91 |
+
raise RuntimeError(
|
| 92 |
+
f"Failed to download valid image for image_id={row.get('image_id')} from candidate URLs."
|
| 93 |
+
) from last_error
|
| 94 |
+
|
| 95 |
+
def _open_local_image(self, path: Path) -> Image.Image:
|
| 96 |
+
with Image.open(path) as image:
|
| 97 |
+
return image.convert("RGB")
|
| 98 |
+
|
| 99 |
+
def _load_image(self, row: Dict[str, Any]) -> Image.Image:
|
| 100 |
+
image_path = self._resolve_image_path(row)
|
| 101 |
+
self._download_if_missing(row, image_path)
|
| 102 |
+
try:
|
| 103 |
+
return self._open_local_image(image_path)
|
| 104 |
+
except (UnidentifiedImageError, OSError):
|
| 105 |
+
self.corrupt_image_count += 1
|
| 106 |
+
if image_path.exists():
|
| 107 |
+
image_path.unlink(missing_ok=True)
|
| 108 |
+
# Retry once by re-downloading. Some VG image URLs occasionally produce
|
| 109 |
+
# partial/bad files; this makes loading resilient instead of crashing workers.
|
| 110 |
+
try:
|
| 111 |
+
self._download_if_missing(row, image_path, force=True)
|
| 112 |
+
return self._open_local_image(image_path)
|
| 113 |
+
except Exception:
|
| 114 |
+
return Image.new("RGB", (224, 224), color=(0, 0, 0))
|
| 115 |
+
|
| 116 |
+
def _labels_to_multihot(self, labels: List[str]) -> torch.Tensor:
|
| 117 |
+
target = torch.zeros(self.num_labels, dtype=torch.float32)
|
| 118 |
+
for label in labels:
|
| 119 |
+
if label in self.label_vocab:
|
| 120 |
+
target[self.label_vocab[label]] = 1.0
|
| 121 |
+
return target
|
| 122 |
+
|
| 123 |
+
def __getitem__(self, idx: int) -> Dict[str, Any]:
|
| 124 |
+
row = self.samples[idx]
|
| 125 |
+
image = self._load_image(row)
|
| 126 |
+
labels = row.get("attributes_norm", [])
|
| 127 |
+
target = self._labels_to_multihot(labels)
|
| 128 |
+
return {
|
| 129 |
+
"image": image,
|
| 130 |
+
"target": target,
|
| 131 |
+
"image_id": int(row["image_id"]),
|
| 132 |
+
"labels": labels,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def collate_samples(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 137 |
+
return {
|
| 138 |
+
"images": [item["image"] for item in batch],
|
| 139 |
+
"targets": torch.stack([item["target"] for item in batch], dim=0),
|
| 140 |
+
"image_ids": [item["image_id"] for item in batch],
|
| 141 |
+
"labels": [item["labels"] for item in batch],
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def build_dataloader(
|
| 146 |
+
dataset: Dataset,
|
| 147 |
+
batch_size: int,
|
| 148 |
+
shuffle: bool,
|
| 149 |
+
num_workers: int,
|
| 150 |
+
pin_memory: bool,
|
| 151 |
+
) -> DataLoader:
|
| 152 |
+
return DataLoader(
|
| 153 |
+
dataset,
|
| 154 |
+
batch_size=batch_size,
|
| 155 |
+
shuffle=shuffle,
|
| 156 |
+
num_workers=num_workers,
|
| 157 |
+
pin_memory=pin_memory,
|
| 158 |
+
collate_fn=collate_samples,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def class_pos_weights(label_frequencies: Dict[str, int], label_vocab: Dict[str, int]) -> torch.Tensor:
|
| 163 |
+
counts = torch.ones(len(label_vocab), dtype=torch.float32)
|
| 164 |
+
for label, idx in label_vocab.items():
|
| 165 |
+
counts[idx] = float(max(1, label_frequencies.get(label, 1)))
|
| 166 |
+
weights = counts.max() / counts
|
| 167 |
+
return weights
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def split_paths(processed_root: Path, split_dir_name: str = "splits") -> Dict[str, Path]:
|
| 171 |
+
split_dir = processed_root / split_dir_name
|
| 172 |
+
return {
|
| 173 |
+
"train": split_dir / "train.json",
|
| 174 |
+
"val": split_dir / "val.json",
|
| 175 |
+
"test": split_dir / "test.json",
|
| 176 |
+
}
|
src/train/encoders.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Tuple
|
| 4 |
+
|
| 5 |
+
import timm
|
| 6 |
+
import torch
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from timm.data import create_transform, resolve_data_config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ImageBackbone(torch.nn.Module):
|
| 12 |
+
def __init__(self, model_name: str, pretrained: bool = True) -> None:
|
| 13 |
+
super().__init__()
|
| 14 |
+
self.model = timm.create_model(model_name, pretrained=pretrained, num_classes=0)
|
| 15 |
+
cfg = resolve_data_config(self.model.pretrained_cfg, model=self.model)
|
| 16 |
+
self.transform = create_transform(**cfg, is_training=False)
|
| 17 |
+
self.output_dim = getattr(self.model, "num_features", None)
|
| 18 |
+
if self.output_dim is None:
|
| 19 |
+
raise RuntimeError(f"Could not infer output dim for {model_name}")
|
| 20 |
+
|
| 21 |
+
def forward_pil(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
|
| 22 |
+
batch = torch.stack([self.transform(img) for img in images], dim=0).to(device)
|
| 23 |
+
return self.model(batch)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_backbones(
|
| 27 |
+
use_clip: bool,
|
| 28 |
+
use_dino: bool,
|
| 29 |
+
clip_model_name: str,
|
| 30 |
+
dino_model_name: str,
|
| 31 |
+
clip_pretrained: bool,
|
| 32 |
+
dino_pretrained: bool,
|
| 33 |
+
) -> Tuple[ImageBackbone | None, ImageBackbone | None, int]:
|
| 34 |
+
clip = None
|
| 35 |
+
dino = None
|
| 36 |
+
feature_dim = 0
|
| 37 |
+
if use_clip:
|
| 38 |
+
clip = ImageBackbone(model_name=clip_model_name, pretrained=clip_pretrained)
|
| 39 |
+
feature_dim += int(clip.output_dim)
|
| 40 |
+
if use_dino:
|
| 41 |
+
dino = ImageBackbone(model_name=dino_model_name, pretrained=dino_pretrained)
|
| 42 |
+
feature_dim += int(dino.output_dim)
|
| 43 |
+
if feature_dim <= 0:
|
| 44 |
+
raise RuntimeError("At least one encoder must be enabled for baseline training.")
|
| 45 |
+
return clip, dino, feature_dim
|
src/train/losses.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def bce_logits_loss(logits: torch.Tensor, targets: torch.Tensor, pos_weight: torch.Tensor) -> torch.Tensor:
|
| 7 |
+
loss_fn = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight)
|
| 8 |
+
return loss_fn(logits, targets)
|
src/train/metrics.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from sklearn.metrics import average_precision_score, f1_score
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _safe_average_precision(y_true: np.ndarray, y_score: np.ndarray) -> float:
|
| 10 |
+
if np.unique(y_true).size < 2:
|
| 11 |
+
return 0.0
|
| 12 |
+
return float(average_precision_score(y_true, y_score))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def compute_metrics(
|
| 16 |
+
targets: np.ndarray,
|
| 17 |
+
logits: np.ndarray,
|
| 18 |
+
threshold: float = 0.5,
|
| 19 |
+
) -> Dict[str, Any]:
|
| 20 |
+
probs = 1.0 / (1.0 + np.exp(-logits))
|
| 21 |
+
preds = (probs >= threshold).astype(np.int32)
|
| 22 |
+
per_attr_ap = []
|
| 23 |
+
per_attr_f1 = []
|
| 24 |
+
for idx in range(targets.shape[1]):
|
| 25 |
+
yt = targets[:, idx]
|
| 26 |
+
yp = preds[:, idx]
|
| 27 |
+
ys = probs[:, idx]
|
| 28 |
+
per_attr_ap.append(_safe_average_precision(yt, ys))
|
| 29 |
+
per_attr_f1.append(float(f1_score(yt, yp, zero_division=0)))
|
| 30 |
+
|
| 31 |
+
macro_f1 = float(f1_score(targets, preds, average="macro", zero_division=0))
|
| 32 |
+
micro_f1 = float(f1_score(targets, preds, average="micro", zero_division=0))
|
| 33 |
+
map_score = float(np.mean(per_attr_ap)) if per_attr_ap else 0.0
|
| 34 |
+
return {
|
| 35 |
+
"map": map_score,
|
| 36 |
+
"macro_f1": macro_f1,
|
| 37 |
+
"micro_f1": micro_f1,
|
| 38 |
+
"per_attribute_ap": per_attr_ap,
|
| 39 |
+
"per_attribute_f1": per_attr_f1,
|
| 40 |
+
}
|
src/train/models.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
from .encoders import ImageBackbone
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BaselineClassifier(torch.nn.Module):
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
clip_backbone: ImageBackbone | None,
|
| 15 |
+
dino_backbone: ImageBackbone | None,
|
| 16 |
+
feature_dim: int,
|
| 17 |
+
hidden_dims: List[int],
|
| 18 |
+
dropout: float,
|
| 19 |
+
num_labels: int,
|
| 20 |
+
) -> None:
|
| 21 |
+
super().__init__()
|
| 22 |
+
self.clip_backbone = clip_backbone
|
| 23 |
+
self.dino_backbone = dino_backbone
|
| 24 |
+
layers: List[torch.nn.Module] = []
|
| 25 |
+
input_dim = feature_dim
|
| 26 |
+
for hidden in hidden_dims:
|
| 27 |
+
layers.append(torch.nn.Linear(input_dim, hidden))
|
| 28 |
+
layers.append(torch.nn.ReLU())
|
| 29 |
+
layers.append(torch.nn.Dropout(dropout))
|
| 30 |
+
input_dim = hidden
|
| 31 |
+
layers.append(torch.nn.Linear(input_dim, num_labels))
|
| 32 |
+
self.classifier = torch.nn.Sequential(*layers)
|
| 33 |
+
|
| 34 |
+
def encode(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
|
| 35 |
+
features = []
|
| 36 |
+
if self.clip_backbone is not None:
|
| 37 |
+
features.append(self.clip_backbone.forward_pil(images, device=device))
|
| 38 |
+
if self.dino_backbone is not None:
|
| 39 |
+
features.append(self.dino_backbone.forward_pil(images, device=device))
|
| 40 |
+
if len(features) == 1:
|
| 41 |
+
return features[0]
|
| 42 |
+
return torch.cat(features, dim=1)
|
| 43 |
+
|
| 44 |
+
def forward(self, images: List[Image.Image], device: torch.device) -> torch.Tensor:
|
| 45 |
+
feats = self.encode(images=images, device=device)
|
| 46 |
+
return self.classifier(feats)
|
| 47 |
+
|
| 48 |
+
def freeze_backbones(self) -> None:
|
| 49 |
+
for backbone in (self.clip_backbone, self.dino_backbone):
|
| 50 |
+
if backbone is None:
|
| 51 |
+
continue
|
| 52 |
+
for param in backbone.parameters():
|
| 53 |
+
param.requires_grad = False
|