Spaces:
Configuration error
Configuration error
Commit ·
91a1214
1
Parent(s): 131c45c
feat(evaluation): add beam search, metrics pipeline, and stabilized training workflow
Browse files- .gitignore +0 -0
- configs/train/stabilized.yaml +84 -0
- docs/STABILIZED_TRAINING_RUNBOOK.md +236 -0
- scripts/evaluate.py +150 -20
- scripts/inspect_predictions.py +128 -0
- scripts/predict.py +33 -1
- src/captioning/config/schema.py +76 -3
- src/captioning/evaluation/__init__.py +45 -5
- src/captioning/evaluation/benchmark.py +128 -0
- src/captioning/evaluation/bleu.py +66 -21
- src/captioning/evaluation/cider.py +81 -0
- src/captioning/evaluation/inspection.py +163 -0
- src/captioning/evaluation/meteor.py +77 -0
- src/captioning/evaluation/rouge.py +75 -0
- src/captioning/evaluation/runner.py +128 -0
- src/captioning/evaluation/tokenization.py +44 -0
- src/captioning/inference/__init__.py +2 -0
- src/captioning/inference/beam.py +240 -0
- src/captioning/inference/predictor.py +81 -22
- src/captioning/models/captioning_model.py +52 -16
- src/captioning/models/factory.py +17 -1
- src/captioning/preprocessing/tokenizer.py +10 -0
- src/captioning/training/__init__.py +16 -6
- src/captioning/training/losses.py +58 -2
- src/captioning/training/schedules.py +107 -0
- src/captioning/training/trainer.py +62 -9
- tests/unit/test_beam_decoder.py +176 -0
- tests/unit/test_config.py +41 -0
- tests/unit/test_evaluation_metrics.py +210 -0
- tests/unit/test_tokenizer.py +12 -0
- tests/unit/test_training_stability.py +136 -0
.gitignore
CHANGED
|
Binary files a/.gitignore and b/.gitignore differ
|
|
|
configs/train/stabilized.yaml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# configs/train/stabilized.yaml — first experimental run with the opt-in
|
| 3 |
+
# training-stability primitives turned on.
|
| 4 |
+
# -----------------------------------------------------------------------------
|
| 5 |
+
# Identical to configs/base.yaml except for the four flags called out in the
|
| 6 |
+
# `train:` section below. Every other field mirrors the IEEE notebook verbatim
|
| 7 |
+
# so this run is comparable to the baseline at the same seed and architecture.
|
| 8 |
+
#
|
| 9 |
+
# Why a complete config (not a thin override)?
|
| 10 |
+
# scripts/train.py only accepts --config; there is no --override merge mode
|
| 11 |
+
# in the CLI (the README mentions one but it's aspirational, not implemented).
|
| 12 |
+
# Duplicating the values here is the smallest correct change that keeps
|
| 13 |
+
# base.yaml itself untouched — which was the explicit requirement for this
|
| 14 |
+
# experiment phase.
|
| 15 |
+
#
|
| 16 |
+
# Usage:
|
| 17 |
+
# python -m scripts.train --config configs/train/stabilized.yaml \
|
| 18 |
+
# --output-dir outputs/runs/stabilized
|
| 19 |
+
#
|
| 20 |
+
# Compare against the baseline by training the same code twice — once with
|
| 21 |
+
# configs/base.yaml, once with this file — and diffing the resulting
|
| 22 |
+
# results/<run_id>/metrics.json files.
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
data:
|
| 26 |
+
base_path: data/coco2017
|
| 27 |
+
annotations_filename: captions_train2017.json
|
| 28 |
+
images_subdir: train2017
|
| 29 |
+
sample_size: 120000 # Same sample as base.yaml — comparability matters
|
| 30 |
+
train_val_split: 0.8
|
| 31 |
+
|
| 32 |
+
model:
|
| 33 |
+
embedding_dim: 512
|
| 34 |
+
units: 512
|
| 35 |
+
max_length: 40
|
| 36 |
+
vocabulary_size: 15000
|
| 37 |
+
encoder_num_heads: 1
|
| 38 |
+
decoder_num_heads: 8
|
| 39 |
+
decoder_dropout_inner: 0.3
|
| 40 |
+
decoder_dropout_outer: 0.5
|
| 41 |
+
decoder_attention_dropout: 0.1
|
| 42 |
+
|
| 43 |
+
train:
|
| 44 |
+
epochs: 10
|
| 45 |
+
batch_size: 64
|
| 46 |
+
buffer_size: 1000
|
| 47 |
+
early_stopping_patience: 3
|
| 48 |
+
seed: 42
|
| 49 |
+
learning_rate: 0.001
|
| 50 |
+
weights_filename: model.h5
|
| 51 |
+
|
| 52 |
+
# ---- the four flags this experiment is actually testing -------------------
|
| 53 |
+
# Label smoothing 0.1 softens the cross-entropy target so the decoder
|
| 54 |
+
# cannot collapse onto a handful of high-frequency tokens. Standard
|
| 55 |
+
# transformer captioning recipe (BLIP, ViT-GPT2, GIT all use it).
|
| 56 |
+
label_smoothing: 0.1
|
| 57 |
+
|
| 58 |
+
# Warmup + cosine decay replaces the bare constant Adam LR. Transformers
|
| 59 |
+
# trained from scratch with no warmup tend to settle into a "safe captions"
|
| 60 |
+
# basin where every output looks like "a man standing ...". Cosine decay
|
| 61 |
+
# then anneals smoothly toward min_learning_rate.
|
| 62 |
+
lr_schedule: cosine
|
| 63 |
+
warmup_steps: 500 # ~1/3 of an epoch at batch 64, sample 120k
|
| 64 |
+
cosine_decay_steps: null # null -> trainer derives from steps_per_epoch * epochs
|
| 65 |
+
min_learning_rate: 0.0
|
| 66 |
+
|
| 67 |
+
# Restore conventional behaviour: dropout OFF during validation, accuracy
|
| 68 |
+
# tracker weighted by token count. This gives a clean val_loss signal so
|
| 69 |
+
# EarlyStopping fires on a real plateau rather than on dropout noise.
|
| 70 |
+
honour_training_flag_in_test_step: true
|
| 71 |
+
|
| 72 |
+
serve:
|
| 73 |
+
max_upload_bytes: 10485760
|
| 74 |
+
decode_strategy: greedy # Decode strategy is selected at evaluate time
|
| 75 |
+
beam_width: 4 # Stored defaults for `scripts.evaluate --decode-strategy beam`
|
| 76 |
+
length_penalty: 0.7
|
| 77 |
+
repetition_penalty: 1.0
|
| 78 |
+
no_repeat_ngram_size: 3
|
| 79 |
+
cors_allowed_origins:
|
| 80 |
+
- http://localhost:3000
|
| 81 |
+
- http://localhost:5173
|
| 82 |
+
- http://localhost:5174
|
| 83 |
+
- http://127.0.0.1:5173
|
| 84 |
+
- http://127.0.0.1:5174
|
docs/STABILIZED_TRAINING_RUNBOOK.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stabilized-config training runbook
|
| 2 |
+
|
| 3 |
+
Hand-off doc for running the first real `configs/train/stabilized.yaml`
|
| 4 |
+
experiment on Kaggle (or any free GPU notebook host). Stays out of the
|
| 5 |
+
package proper because it's a one-shot operational guide, not project
|
| 6 |
+
documentation.
|
| 7 |
+
|
| 8 |
+
## What you'll produce
|
| 9 |
+
|
| 10 |
+
By the end of this runbook you will have, **per decode strategy**:
|
| 11 |
+
|
| 12 |
+
```
|
| 13 |
+
results/<run_id>/
|
| 14 |
+
metrics.json # BLEU-1..4, ROUGE-L, METEOR, CIDEr
|
| 15 |
+
predictions.jsonl # one row per validation image
|
| 16 |
+
diagnostics.jsonl # per-sample length / repetition / sentence BLEU
|
| 17 |
+
run_meta.json # decode flags, timestamp, model id
|
| 18 |
+
report.md # human-readable summary
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Download both directories (`results/<greedy_run_id>/` and
|
| 22 |
+
`results/<beam_run_id>/`) and the trained `models/v1.0.0/` artefacts as
|
| 23 |
+
Kaggle outputs. That's the entire input for the Phase-2-stabilization
|
| 24 |
+
comparison.
|
| 25 |
+
|
| 26 |
+
## Caveats before you start
|
| 27 |
+
|
| 28 |
+
1. **`requirements.txt` pins `tensorflow-cpu==2.15.0`.** On Kaggle GPU you
|
| 29 |
+
must install `tensorflow==2.15.0` instead — see step 3 below. The
|
| 30 |
+
`Keras 3` warning in the original pin comment still applies: do not
|
| 31 |
+
upgrade to 2.16+, save/load semantics for `TextVectorization` change.
|
| 32 |
+
2. **Kaggle session limit is 9 hours per run.** A full 10-epoch pass on
|
| 33 |
+
120k captions at batch 64 fits comfortably on a T4 (well under 9h),
|
| 34 |
+
but plan to checkpoint after every epoch in case the session restarts.
|
| 35 |
+
`default_callbacks(...)` already writes `best.h5` on val_loss
|
| 36 |
+
improvement, so this is handled.
|
| 37 |
+
3. **Free-tier Kaggle gives ~30 GB of attached-dataset space.** COCO 2017
|
| 38 |
+
train2017 is ~19 GB. Use the public `awsaf49/coco-2017-dataset`
|
| 39 |
+
mount; do not re-upload it as your own dataset.
|
| 40 |
+
4. **The `data.base_path` in `stabilized.yaml` points at `data/coco2017`**
|
| 41 |
+
(the project-relative path). On Kaggle the COCO mount is at
|
| 42 |
+
`/kaggle/input/coco-2017-dataset/coco2017`. You override at runtime
|
| 43 |
+
without editing the YAML using the env-var override pattern (step 4).
|
| 44 |
+
|
| 45 |
+
## Kaggle notebook cells
|
| 46 |
+
|
| 47 |
+
Paste each block into a separate cell so you can re-run individual
|
| 48 |
+
steps without restarting.
|
| 49 |
+
|
| 50 |
+
### Cell 1 — Attach the COCO dataset
|
| 51 |
+
|
| 52 |
+
In the Kaggle notebook UI: **+ Add Data → Search "coco-2017" →**
|
| 53 |
+
`awsaf49/coco-2017-dataset`. After attaching, verify:
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
!ls /kaggle/input/coco-2017-dataset/coco2017
|
| 57 |
+
# expect: annotations/ train2017/ val2017/
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Cell 2 — Pull the repo
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
!git clone https://github.com/<your-user>/image-captioning-system.git
|
| 64 |
+
%cd image-captioning-system
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
(If your repo isn't public, upload it as a Kaggle dataset and reference
|
| 68 |
+
it via `/kaggle/input/<dataset-slug>/`.)
|
| 69 |
+
|
| 70 |
+
### Cell 3 — Install with the GPU TF wheel
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
# Replace the CPU TF pin with the GPU-capable wheel. The rest of the
|
| 74 |
+
# pinned versions in requirements.txt are GPU/CPU-agnostic.
|
| 75 |
+
!pip install -q tensorflow==2.15.0
|
| 76 |
+
!pip install -q -r requirements-dev.txt -r requirements-eval.txt
|
| 77 |
+
!pip install -q -e .
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
Verify GPU is visible to TF:
|
| 81 |
+
|
| 82 |
+
```python
|
| 83 |
+
import tensorflow as tf
|
| 84 |
+
print("GPUs:", tf.config.list_physical_devices("GPU"))
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Expected output: at least one GPU listed (typically `T4` or `P100`).
|
| 88 |
+
|
| 89 |
+
### Cell 4 — Train with the stabilized config
|
| 90 |
+
|
| 91 |
+
The `data.base_path` field in `stabilized.yaml` is overridden via
|
| 92 |
+
env-var so the YAML stays unedited:
|
| 93 |
+
|
| 94 |
+
```python
|
| 95 |
+
import os
|
| 96 |
+
os.environ["CAPTIONING__DATA__BASE_PATH"] = "/kaggle/input/coco-2017-dataset/coco2017"
|
| 97 |
+
|
| 98 |
+
!python -m scripts.train \
|
| 99 |
+
--config configs/train/stabilized.yaml \
|
| 100 |
+
--output-dir outputs/runs/stabilized
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Expected wall-clock on a T4: ~30-50 min per epoch, ~5-8 hours for 10
|
| 104 |
+
epochs. EarlyStopping (patience 3) typically fires before epoch 10.
|
| 105 |
+
|
| 106 |
+
What lands in `outputs/runs/stabilized/`:
|
| 107 |
+
- `best.h5` — best val_loss checkpoint (ModelCheckpoint)
|
| 108 |
+
- `model.h5` — final-epoch weights
|
| 109 |
+
- `vocab.pkl/json` — fitted tokenizer
|
| 110 |
+
- `history.json` — train/val loss per epoch
|
| 111 |
+
- `training_log.csv`— CSVLogger output
|
| 112 |
+
|
| 113 |
+
### Cell 5 — Promote the trained checkpoint
|
| 114 |
+
|
| 115 |
+
The evaluation and serving paths expect `models/v1.0.0/model.h5` +
|
| 116 |
+
tokenizer. Promote the best checkpoint there:
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
!mkdir -p models/v1.0.0
|
| 120 |
+
!cp outputs/runs/stabilized/best.h5 models/v1.0.0/model.h5
|
| 121 |
+
!cp outputs/runs/stabilized/vocab.pkl models/v1.0.0/vocab.pkl
|
| 122 |
+
!cp outputs/runs/stabilized/vocab.json models/v1.0.0/vocab.json
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### Cell 6 — Greedy evaluation
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
os.environ["CAPTIONING__DATA__BASE_PATH"] = "/kaggle/input/coco-2017-dataset/coco2017"
|
| 129 |
+
|
| 130 |
+
!python -m scripts.evaluate \
|
| 131 |
+
--config configs/train/stabilized.yaml \
|
| 132 |
+
--weights models/v1.0.0/model.h5 \
|
| 133 |
+
--tokenizer-dir models/v1.0.0 \
|
| 134 |
+
--results-root results \
|
| 135 |
+
--run-id stabilized-greedy \
|
| 136 |
+
--model-id inceptionv3-transformer-stabilized \
|
| 137 |
+
--decode-strategy greedy \
|
| 138 |
+
--max-samples 500
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
If METEOR/Java is unavailable on the Kaggle image (it usually is, but
|
| 142 |
+
the wheel can fail at import):
|
| 143 |
+
|
| 144 |
+
```python
|
| 145 |
+
!apt-get install -y openjdk-11-jre-headless # if METEOR errors out
|
| 146 |
+
# or:
|
| 147 |
+
!python -m scripts.evaluate ... --skip-meteor
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Cell 7 — Beam evaluation
|
| 151 |
+
|
| 152 |
+
```python
|
| 153 |
+
!python -m scripts.evaluate \
|
| 154 |
+
--config configs/train/stabilized.yaml \
|
| 155 |
+
--weights models/v1.0.0/model.h5 \
|
| 156 |
+
--tokenizer-dir models/v1.0.0 \
|
| 157 |
+
--results-root results \
|
| 158 |
+
--run-id stabilized-beam-w4-lp07-nrn3 \
|
| 159 |
+
--model-id inceptionv3-transformer-stabilized \
|
| 160 |
+
--decode-strategy beam \
|
| 161 |
+
--beam-width 4 \
|
| 162 |
+
--length-penalty 0.7 \
|
| 163 |
+
--no-repeat-ngram-size 3 \
|
| 164 |
+
--max-samples 500
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
### Cell 8 — Per-sample inspection (qualitative review)
|
| 168 |
+
|
| 169 |
+
```python
|
| 170 |
+
!python -m scripts.inspect_predictions \
|
| 171 |
+
--config configs/train/stabilized.yaml \
|
| 172 |
+
--weights models/v1.0.0/model.h5 \
|
| 173 |
+
--tokenizer-dir models/v1.0.0 \
|
| 174 |
+
--decode-strategy beam \
|
| 175 |
+
--beam-width 4 \
|
| 176 |
+
--n-samples 30 \
|
| 177 |
+
--output results/stabilized-beam-w4-lp07-nrn3/qualitative.jsonl
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### Cell 9 — Persist outputs
|
| 181 |
+
|
| 182 |
+
Kaggle persists anything under `/kaggle/working/` between sessions and
|
| 183 |
+
makes it downloadable as a notebook output. Move the results there:
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
!mkdir -p /kaggle/working/handoff
|
| 187 |
+
!cp -r results /kaggle/working/handoff/
|
| 188 |
+
!cp -r models /kaggle/working/handoff/
|
| 189 |
+
!ls -la /kaggle/working/handoff/results
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### Cell 10 — Pre-flight summary print
|
| 193 |
+
|
| 194 |
+
```python
|
| 195 |
+
import json
|
| 196 |
+
for run in ("stabilized-greedy", "stabilized-beam-w4-lp07-nrn3"):
|
| 197 |
+
m = json.load(open(f"results/{run}/metrics.json"))
|
| 198 |
+
print(run, "->", {k: m[k] for k in ("bleu1", "bleu4", "rouge_l", "meteor", "cider")})
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
This should print two lines that you copy back into the next chat
|
| 202 |
+
session — that's all I need to do Steps 4-6 of the original request.
|
| 203 |
+
|
| 204 |
+
## What to bring back to this conversation
|
| 205 |
+
|
| 206 |
+
When you return:
|
| 207 |
+
1. The two `metrics.json` files (or just the dicts from Cell 10 — same content).
|
| 208 |
+
2. The two `report.md` files for the human-readable view.
|
| 209 |
+
3. The `diagnostics.jsonl` from at least the beam run, so qualitative
|
| 210 |
+
inspection isn't blind.
|
| 211 |
+
4. (Optional but useful) the `history.json` from the training run — lets
|
| 212 |
+
us check whether warmup+cosine actually flattened the validation
|
| 213 |
+
curve as predicted.
|
| 214 |
+
|
| 215 |
+
With those four artefacts I can do the full quantitative comparison,
|
| 216 |
+
qualitative inspection, and bottleneck analysis you originally asked
|
| 217 |
+
for, with real numbers and no fabrication.
|
| 218 |
+
|
| 219 |
+
## If something goes sideways
|
| 220 |
+
|
| 221 |
+
* **OOM on the T4 during training** — drop `train.batch_size` to 32
|
| 222 |
+
(it's already 64; halving keeps the same effective optimization but
|
| 223 |
+
doubles steps_per_epoch and doubles `warmup_steps` too — the trainer
|
| 224 |
+
auto-derives `cosine_decay_steps`, so just batch_size and warmup_steps
|
| 225 |
+
need adjusting).
|
| 226 |
+
* **METEOR fails to import** — pass `--skip-meteor`; the other four
|
| 227 |
+
metrics still write to `metrics.json`.
|
| 228 |
+
* **Session times out mid-training** — Kaggle saves the working
|
| 229 |
+
directory; re-attach the notebook, `cp` the partial weights from
|
| 230 |
+
`outputs/runs/stabilized/best.h5` to `models/v1.0.0/`, and continue
|
| 231 |
+
with evaluation only. EarlyStopping likely fired anyway.
|
| 232 |
+
* **`scripts/train.py` errors with `FileNotFoundError`** — verify
|
| 233 |
+
`os.environ["CAPTIONING__DATA__BASE_PATH"]` is set in the *same cell*
|
| 234 |
+
as the `!python` call, not a prior cell (Jupyter cell-level env vars
|
| 235 |
+
only propagate to subprocesses started from the same cell on some
|
| 236 |
+
Kaggle images).
|
scripts/evaluate.py
CHANGED
|
@@ -1,25 +1,47 @@
|
|
| 1 |
"""Evaluate a trained model on the COCO validation split.
|
| 2 |
|
| 3 |
Usage:
|
|
|
|
|
|
|
| 4 |
python -m scripts.evaluate \\
|
| 5 |
--config configs/base.yaml \\
|
| 6 |
--weights models/v1.0.0/model.h5 \\
|
| 7 |
--tokenizer-dir models/v1.0.0 \\
|
| 8 |
-
--
|
| 9 |
--max-samples 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
-
import
|
| 15 |
from pathlib import Path
|
|
|
|
| 16 |
|
| 17 |
import click
|
| 18 |
|
| 19 |
from captioning.config import load_config
|
| 20 |
from captioning.data import load_coco_annotations, make_image_level_splits
|
| 21 |
-
from captioning.evaluation import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from captioning.inference import CaptionPredictor
|
|
|
|
| 23 |
from captioning.preprocessing import preprocess_caption
|
| 24 |
from captioning.utils import configure_logging, get_logger, set_global_seed
|
| 25 |
|
|
@@ -32,12 +54,39 @@ log = get_logger(__name__)
|
|
| 32 |
)
|
| 33 |
@click.option("--weights", required=True, type=click.Path(exists=True, path_type=Path))
|
| 34 |
@click.option("--tokenizer-dir", required=True, type=click.Path(exists=True, path_type=Path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
@click.option(
|
| 36 |
"--report",
|
| 37 |
"report_path",
|
| 38 |
default=None,
|
| 39 |
type=click.Path(path_type=Path),
|
| 40 |
-
help="Optional path to
|
| 41 |
)
|
| 42 |
@click.option(
|
| 43 |
"--max-samples",
|
|
@@ -45,14 +94,35 @@ log = get_logger(__name__)
|
|
| 45 |
type=int,
|
| 46 |
help="Cap on validation examples (full val takes hours on CPU).",
|
| 47 |
)
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
config_path: Path,
|
| 50 |
weights: Path,
|
| 51 |
tokenizer_dir: Path,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
report_path: Path | None,
|
| 53 |
max_samples: int,
|
|
|
|
|
|
|
| 54 |
) -> None:
|
| 55 |
-
"""
|
| 56 |
configure_logging()
|
| 57 |
config = load_config(config_path)
|
| 58 |
set_global_seed(config.train.seed)
|
|
@@ -75,8 +145,23 @@ def main(
|
|
| 75 |
refs_by_image.setdefault(img, []).append(cap)
|
| 76 |
image_paths = list(refs_by_image.keys())[:max_samples]
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
predictor = CaptionPredictor.from_artifacts(
|
| 79 |
-
weights_path=weights,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
)
|
| 81 |
predictor.warmup()
|
| 82 |
|
|
@@ -86,25 +171,70 @@ def main(
|
|
| 86 |
predictions.append(predictor.predict_path(path))
|
| 87 |
references.append(refs_by_image[path])
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
if report_path is not None:
|
| 94 |
report_path.parent.mkdir(parents=True, exist_ok=True)
|
| 95 |
report_path.write_text(
|
| 96 |
-
|
| 97 |
-
f"- BLEU-4: **{bleu:.2f}**\n"
|
| 98 |
-
f"- Examples: {len(predictions)}\n"
|
| 99 |
-
f"- Weights: `{weights}`\n",
|
| 100 |
-
encoding="utf-8",
|
| 101 |
-
)
|
| 102 |
-
json.dump(
|
| 103 |
-
{"bleu4": bleu, "n": len(predictions)},
|
| 104 |
-
(report_path.with_suffix(".json")).open("w", encoding="utf-8"),
|
| 105 |
-
indent=2,
|
| 106 |
)
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if __name__ == "__main__":
|
| 110 |
main()
|
|
|
|
| 1 |
"""Evaluate a trained model on the COCO validation split.
|
| 2 |
|
| 3 |
Usage:
|
| 4 |
+
# Full benchmark-ready evaluation (recommended) — writes
|
| 5 |
+
# results/<run_id>/{metrics.json, predictions.jsonl, diagnostics.jsonl, ...}
|
| 6 |
python -m scripts.evaluate \\
|
| 7 |
--config configs/base.yaml \\
|
| 8 |
--weights models/v1.0.0/model.h5 \\
|
| 9 |
--tokenizer-dir models/v1.0.0 \\
|
| 10 |
+
--results-root results \\
|
| 11 |
--max-samples 500
|
| 12 |
+
|
| 13 |
+
# Optional: produce a single Markdown report at a chosen path
|
| 14 |
+
python -m scripts.evaluate ... --report docs/results/v1.0.0.md
|
| 15 |
+
|
| 16 |
+
What this script produces (per run):
|
| 17 |
+
metrics.json — corpus BLEU-1..4, ROUGE-L, METEOR, CIDEr
|
| 18 |
+
predictions.jsonl — image / prediction / references for downstream tools
|
| 19 |
+
diagnostics.jsonl — per-sample length / repetition / sentence BLEU flags
|
| 20 |
+
run_meta.json — model id, decode strategy, beam width, timestamp
|
| 21 |
+
report.md — human-readable summary
|
| 22 |
+
|
| 23 |
+
Phase 3 benchmark code joins multiple ``results/<run_id>/`` directories to
|
| 24 |
+
plot BLEU-4 / CIDEr / latency across models.
|
| 25 |
"""
|
| 26 |
|
| 27 |
from __future__ import annotations
|
| 28 |
|
| 29 |
+
from datetime import datetime, timezone
|
| 30 |
from pathlib import Path
|
| 31 |
+
from typing import cast
|
| 32 |
|
| 33 |
import click
|
| 34 |
|
| 35 |
from captioning.config import load_config
|
| 36 |
from captioning.data import load_coco_annotations, make_image_level_splits
|
| 37 |
+
from captioning.evaluation import (
|
| 38 |
+
RunMeta,
|
| 39 |
+
compute_all_metrics,
|
| 40 |
+
diagnose_many,
|
| 41 |
+
write_run_artifacts,
|
| 42 |
+
)
|
| 43 |
from captioning.inference import CaptionPredictor
|
| 44 |
+
from captioning.inference.predictor import DecodeStrategy
|
| 45 |
from captioning.preprocessing import preprocess_caption
|
| 46 |
from captioning.utils import configure_logging, get_logger, set_global_seed
|
| 47 |
|
|
|
|
| 54 |
)
|
| 55 |
@click.option("--weights", required=True, type=click.Path(exists=True, path_type=Path))
|
| 56 |
@click.option("--tokenizer-dir", required=True, type=click.Path(exists=True, path_type=Path))
|
| 57 |
+
@click.option(
|
| 58 |
+
"--results-root",
|
| 59 |
+
type=click.Path(path_type=Path),
|
| 60 |
+
default=Path("results"),
|
| 61 |
+
help="Parent directory for the per-run sub-folder (results/<run_id>/).",
|
| 62 |
+
)
|
| 63 |
+
@click.option(
|
| 64 |
+
"--run-id",
|
| 65 |
+
type=str,
|
| 66 |
+
default=None,
|
| 67 |
+
help="Sub-folder name under --results-root. Defaults to a UTC timestamp.",
|
| 68 |
+
)
|
| 69 |
+
@click.option(
|
| 70 |
+
"--model-id",
|
| 71 |
+
type=str,
|
| 72 |
+
default="inceptionv3-transformer-v1",
|
| 73 |
+
help="Identifier used by Phase 3 cross-model joining of metrics.",
|
| 74 |
+
)
|
| 75 |
+
@click.option(
|
| 76 |
+
"--decode-strategy",
|
| 77 |
+
type=click.Choice(["greedy", "beam"]),
|
| 78 |
+
default=None,
|
| 79 |
+
help="Override config.serve.decode_strategy for this run.",
|
| 80 |
+
)
|
| 81 |
+
@click.option("--beam-width", type=int, default=None, help="Beam width (only used with beam).")
|
| 82 |
+
@click.option("--length-penalty", type=float, default=None)
|
| 83 |
+
@click.option("--repetition-penalty", type=float, default=None)
|
| 84 |
@click.option(
|
| 85 |
"--report",
|
| 86 |
"report_path",
|
| 87 |
default=None,
|
| 88 |
type=click.Path(path_type=Path),
|
| 89 |
+
help="Optional path to an additional human-readable Markdown report.",
|
| 90 |
)
|
| 91 |
@click.option(
|
| 92 |
"--max-samples",
|
|
|
|
| 94 |
type=int,
|
| 95 |
help="Cap on validation examples (full val takes hours on CPU).",
|
| 96 |
)
|
| 97 |
+
@click.option(
|
| 98 |
+
"--skip-meteor",
|
| 99 |
+
is_flag=True,
|
| 100 |
+
default=False,
|
| 101 |
+
help="Skip METEOR (avoids needing Java).",
|
| 102 |
+
)
|
| 103 |
+
@click.option(
|
| 104 |
+
"--skip-cider",
|
| 105 |
+
is_flag=True,
|
| 106 |
+
default=False,
|
| 107 |
+
help="Skip CIDEr.",
|
| 108 |
+
)
|
| 109 |
+
def main( # — CLI option count is unavoidable
|
| 110 |
config_path: Path,
|
| 111 |
weights: Path,
|
| 112 |
tokenizer_dir: Path,
|
| 113 |
+
results_root: Path,
|
| 114 |
+
run_id: str | None,
|
| 115 |
+
model_id: str,
|
| 116 |
+
decode_strategy: str | None,
|
| 117 |
+
beam_width: int | None,
|
| 118 |
+
length_penalty: float | None,
|
| 119 |
+
repetition_penalty: float | None,
|
| 120 |
report_path: Path | None,
|
| 121 |
max_samples: int,
|
| 122 |
+
skip_meteor: bool,
|
| 123 |
+
skip_cider: bool,
|
| 124 |
) -> None:
|
| 125 |
+
"""Evaluate the model on the val split and write benchmark artefacts."""
|
| 126 |
configure_logging()
|
| 127 |
config = load_config(config_path)
|
| 128 |
set_global_seed(config.train.seed)
|
|
|
|
| 145 |
refs_by_image.setdefault(img, []).append(cap)
|
| 146 |
image_paths = list(refs_by_image.keys())[:max_samples]
|
| 147 |
|
| 148 |
+
effective_strategy = decode_strategy or config.serve.decode_strategy
|
| 149 |
+
effective_beam_width = beam_width if beam_width is not None else config.serve.beam_width
|
| 150 |
+
effective_length_penalty = (
|
| 151 |
+
length_penalty if length_penalty is not None else config.serve.length_penalty
|
| 152 |
+
)
|
| 153 |
+
effective_repetition_penalty = (
|
| 154 |
+
repetition_penalty if repetition_penalty is not None else config.serve.repetition_penalty
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
predictor = CaptionPredictor.from_artifacts(
|
| 158 |
+
weights_path=weights,
|
| 159 |
+
tokenizer_dir=tokenizer_dir,
|
| 160 |
+
config=config,
|
| 161 |
+
decode_strategy=cast("DecodeStrategy", effective_strategy),
|
| 162 |
+
beam_width=effective_beam_width,
|
| 163 |
+
length_penalty=effective_length_penalty,
|
| 164 |
+
repetition_penalty=effective_repetition_penalty,
|
| 165 |
)
|
| 166 |
predictor.warmup()
|
| 167 |
|
|
|
|
| 171 |
predictions.append(predictor.predict_path(path))
|
| 172 |
references.append(refs_by_image[path])
|
| 173 |
|
| 174 |
+
metrics = compute_all_metrics(
|
| 175 |
+
predictions,
|
| 176 |
+
references,
|
| 177 |
+
include_meteor=not skip_meteor,
|
| 178 |
+
include_cider=not skip_cider,
|
| 179 |
+
)
|
| 180 |
+
diagnostics = diagnose_many(image_paths, predictions, references)
|
| 181 |
+
|
| 182 |
+
run_id = run_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 183 |
+
run_dir = Path(results_root) / run_id
|
| 184 |
+
meta = RunMeta(
|
| 185 |
+
model_id=model_id,
|
| 186 |
+
decode_strategy=effective_strategy,
|
| 187 |
+
weights_path=str(weights),
|
| 188 |
+
tokenizer_dir=str(tokenizer_dir),
|
| 189 |
+
n_samples=len(predictions),
|
| 190 |
+
max_length=config.model.max_length,
|
| 191 |
+
beam_width=effective_beam_width if effective_strategy == "beam" else None,
|
| 192 |
+
length_penalty=effective_length_penalty if effective_strategy == "beam" else None,
|
| 193 |
+
repetition_penalty=effective_repetition_penalty,
|
| 194 |
+
)
|
| 195 |
+
write_run_artifacts(
|
| 196 |
+
run_dir,
|
| 197 |
+
metrics=metrics,
|
| 198 |
+
meta=meta,
|
| 199 |
+
images=image_paths,
|
| 200 |
+
predictions=predictions,
|
| 201 |
+
references=references,
|
| 202 |
+
diagnostics=diagnostics,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
log.info(
|
| 206 |
+
"evaluation_done",
|
| 207 |
+
run_dir=str(run_dir),
|
| 208 |
+
n=metrics.n_examples,
|
| 209 |
+
bleu4=metrics.bleu4,
|
| 210 |
+
rouge_l=metrics.rouge_l,
|
| 211 |
+
meteor=metrics.meteor,
|
| 212 |
+
cider=metrics.cider,
|
| 213 |
+
)
|
| 214 |
+
click.echo(f"Run directory: {run_dir}")
|
| 215 |
+
_echo_metric("BLEU-1", metrics.bleu1)
|
| 216 |
+
_echo_metric("BLEU-2", metrics.bleu2)
|
| 217 |
+
_echo_metric("BLEU-3", metrics.bleu3)
|
| 218 |
+
_echo_metric("BLEU-4", metrics.bleu4)
|
| 219 |
+
_echo_metric("ROUGE-L", metrics.rouge_l)
|
| 220 |
+
_echo_metric("METEOR", metrics.meteor)
|
| 221 |
+
_echo_metric("CIDEr", metrics.cider)
|
| 222 |
+
if metrics.errors:
|
| 223 |
+
click.echo(f"Skipped/failed: {sorted(metrics.errors)}")
|
| 224 |
|
| 225 |
if report_path is not None:
|
| 226 |
report_path.parent.mkdir(parents=True, exist_ok=True)
|
| 227 |
report_path.write_text(
|
| 228 |
+
(run_dir / "report.md").read_text(encoding="utf-8"), encoding="utf-8"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
)
|
| 230 |
|
| 231 |
|
| 232 |
+
def _echo_metric(name: str, value: float | None) -> None:
|
| 233 |
+
if value is None:
|
| 234 |
+
click.echo(f"{name}: n/a")
|
| 235 |
+
else:
|
| 236 |
+
click.echo(f"{name}: {value:.2f}")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
if __name__ == "__main__":
|
| 240 |
main()
|
scripts/inspect_predictions.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-sample inspection — print N validation predictions vs. ground truth.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python -m scripts.inspect_predictions \\
|
| 5 |
+
--config configs/base.yaml \\
|
| 6 |
+
--weights models/v1.0.0/model.h5 \\
|
| 7 |
+
--tokenizer-dir models/v1.0.0 \\
|
| 8 |
+
--n-samples 25
|
| 9 |
+
|
| 10 |
+
This script answers the diagnostic question: *when the model is wrong, **how**
|
| 11 |
+
is it wrong?* Each row shows the image filename, the predicted caption, one
|
| 12 |
+
reference caption, sentence-level BLEU-4 / ROUGE-L, the prediction length,
|
| 13 |
+
the longest repeated-token run, and a set of failure flags
|
| 14 |
+
(``empty`` / ``very_short`` / ``repetitive`` / ``under_length``).
|
| 15 |
+
|
| 16 |
+
Output also lands as ``diagnostics.jsonl`` so the same data can be loaded
|
| 17 |
+
into pandas / DuckDB for ad-hoc grouping.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import random
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import cast
|
| 25 |
+
|
| 26 |
+
import click
|
| 27 |
+
|
| 28 |
+
from captioning.config import load_config
|
| 29 |
+
from captioning.data import load_coco_annotations, make_image_level_splits
|
| 30 |
+
from captioning.evaluation import (
|
| 31 |
+
diagnose_many,
|
| 32 |
+
format_diagnostic_row,
|
| 33 |
+
write_diagnostics_jsonl,
|
| 34 |
+
)
|
| 35 |
+
from captioning.inference import CaptionPredictor
|
| 36 |
+
from captioning.inference.predictor import DecodeStrategy
|
| 37 |
+
from captioning.preprocessing import preprocess_caption
|
| 38 |
+
from captioning.utils import configure_logging, get_logger, set_global_seed
|
| 39 |
+
|
| 40 |
+
log = get_logger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@click.command()
|
| 44 |
+
@click.option(
|
| 45 |
+
"--config", "config_path", required=True, type=click.Path(exists=True, path_type=Path)
|
| 46 |
+
)
|
| 47 |
+
@click.option("--weights", required=True, type=click.Path(exists=True, path_type=Path))
|
| 48 |
+
@click.option("--tokenizer-dir", required=True, type=click.Path(exists=True, path_type=Path))
|
| 49 |
+
@click.option("--n-samples", type=int, default=25, help="Number of random val samples to inspect.")
|
| 50 |
+
@click.option(
|
| 51 |
+
"--decode-strategy",
|
| 52 |
+
type=click.Choice(["greedy", "beam"]),
|
| 53 |
+
default=None,
|
| 54 |
+
help="Override decode strategy for this inspection.",
|
| 55 |
+
)
|
| 56 |
+
@click.option("--beam-width", type=int, default=None)
|
| 57 |
+
@click.option(
|
| 58 |
+
"--output",
|
| 59 |
+
"output_path",
|
| 60 |
+
type=click.Path(path_type=Path),
|
| 61 |
+
default=None,
|
| 62 |
+
help="Optional path to write diagnostics.jsonl. Defaults to printing only.",
|
| 63 |
+
)
|
| 64 |
+
@click.option(
|
| 65 |
+
"--seed",
|
| 66 |
+
type=int,
|
| 67 |
+
default=None,
|
| 68 |
+
help="RNG seed for sample selection (defaults to config.train.seed).",
|
| 69 |
+
)
|
| 70 |
+
def main(
|
| 71 |
+
config_path: Path,
|
| 72 |
+
weights: Path,
|
| 73 |
+
tokenizer_dir: Path,
|
| 74 |
+
n_samples: int,
|
| 75 |
+
decode_strategy: str | None,
|
| 76 |
+
beam_width: int | None,
|
| 77 |
+
output_path: Path | None,
|
| 78 |
+
seed: int | None,
|
| 79 |
+
) -> None:
|
| 80 |
+
"""Sample N val images, run inference, and print prediction diagnostics."""
|
| 81 |
+
configure_logging()
|
| 82 |
+
config = load_config(config_path)
|
| 83 |
+
set_global_seed(config.train.seed)
|
| 84 |
+
|
| 85 |
+
df = load_coco_annotations(
|
| 86 |
+
base_path=config.data.base_path,
|
| 87 |
+
annotations_filename=config.data.annotations_filename,
|
| 88 |
+
images_subdir=config.data.images_subdir,
|
| 89 |
+
sample_size=config.data.sample_size,
|
| 90 |
+
seed=config.train.seed,
|
| 91 |
+
caption_preprocessor=preprocess_caption,
|
| 92 |
+
)
|
| 93 |
+
_, _, val_imgs, val_caps = make_image_level_splits(
|
| 94 |
+
df, train_fraction=config.data.train_val_split, seed=config.train.seed
|
| 95 |
+
)
|
| 96 |
+
refs_by_image: dict[str, list[str]] = {}
|
| 97 |
+
for img, cap in zip(val_imgs, val_caps, strict=True):
|
| 98 |
+
refs_by_image.setdefault(img, []).append(cap)
|
| 99 |
+
|
| 100 |
+
rng = random.Random(seed if seed is not None else config.train.seed)
|
| 101 |
+
picks = rng.sample(sorted(refs_by_image.keys()), k=min(n_samples, len(refs_by_image)))
|
| 102 |
+
|
| 103 |
+
effective_strategy = decode_strategy or config.serve.decode_strategy
|
| 104 |
+
effective_beam_width = beam_width if beam_width is not None else config.serve.beam_width
|
| 105 |
+
predictor = CaptionPredictor.from_artifacts(
|
| 106 |
+
weights_path=weights,
|
| 107 |
+
tokenizer_dir=tokenizer_dir,
|
| 108 |
+
config=config,
|
| 109 |
+
decode_strategy=cast("DecodeStrategy", effective_strategy),
|
| 110 |
+
beam_width=effective_beam_width,
|
| 111 |
+
)
|
| 112 |
+
predictor.warmup()
|
| 113 |
+
|
| 114 |
+
predictions = [predictor.predict_path(p) for p in picks]
|
| 115 |
+
references = [refs_by_image[p] for p in picks]
|
| 116 |
+
diagnostics = diagnose_many(picks, predictions, references)
|
| 117 |
+
|
| 118 |
+
for d in diagnostics:
|
| 119 |
+
click.echo(format_diagnostic_row(d))
|
| 120 |
+
click.echo("-" * 80)
|
| 121 |
+
|
| 122 |
+
if output_path is not None:
|
| 123 |
+
write_diagnostics_jsonl(diagnostics, output_path)
|
| 124 |
+
click.echo(f"Wrote diagnostics: {output_path}")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|
scripts/predict.py
CHANGED
|
@@ -1,21 +1,28 @@
|
|
| 1 |
"""CLI single-image inference.
|
| 2 |
|
| 3 |
Usage:
|
|
|
|
| 4 |
python -m scripts.predict \\
|
| 5 |
--config configs/base.yaml \\
|
| 6 |
--weights models/v1.0.0/model.h5 \\
|
| 7 |
--tokenizer-dir models/v1.0.0 \\
|
| 8 |
--image path/to/photo.jpg
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
from pathlib import Path
|
|
|
|
| 14 |
|
| 15 |
import click
|
| 16 |
|
| 17 |
from captioning.config import load_config
|
| 18 |
from captioning.inference import CaptionPredictor
|
|
|
|
| 19 |
from captioning.utils import configure_logging, get_logger
|
| 20 |
|
| 21 |
log = get_logger(__name__)
|
|
@@ -28,7 +35,27 @@ log = get_logger(__name__)
|
|
| 28 |
@click.option("--weights", required=True, type=click.Path(exists=True, path_type=Path))
|
| 29 |
@click.option("--tokenizer-dir", required=True, type=click.Path(exists=True, path_type=Path))
|
| 30 |
@click.option("--image", required=True, type=click.Path(exists=True, path_type=Path))
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
"""Generate a caption for one image."""
|
| 33 |
configure_logging()
|
| 34 |
config = load_config(config_path)
|
|
@@ -37,6 +64,11 @@ def main(config_path: Path, weights: Path, tokenizer_dir: Path, image: Path) ->
|
|
| 37 |
weights_path=weights,
|
| 38 |
tokenizer_dir=tokenizer_dir,
|
| 39 |
config=config,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
)
|
| 41 |
predictor.warmup()
|
| 42 |
caption = predictor.predict_path(image)
|
|
|
|
| 1 |
"""CLI single-image inference.
|
| 2 |
|
| 3 |
Usage:
|
| 4 |
+
# Greedy (default — same as the IEEE notebook)
|
| 5 |
python -m scripts.predict \\
|
| 6 |
--config configs/base.yaml \\
|
| 7 |
--weights models/v1.0.0/model.h5 \\
|
| 8 |
--tokenizer-dir models/v1.0.0 \\
|
| 9 |
--image path/to/photo.jpg
|
| 10 |
+
|
| 11 |
+
# Beam search with explicit parameters
|
| 12 |
+
python -m scripts.predict ... --decode-strategy beam --beam-width 4 \\
|
| 13 |
+
--length-penalty 0.7 --repetition-penalty 1.1 --no-repeat-ngram-size 3
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
from pathlib import Path
|
| 19 |
+
from typing import cast
|
| 20 |
|
| 21 |
import click
|
| 22 |
|
| 23 |
from captioning.config import load_config
|
| 24 |
from captioning.inference import CaptionPredictor
|
| 25 |
+
from captioning.inference.predictor import DecodeStrategy
|
| 26 |
from captioning.utils import configure_logging, get_logger
|
| 27 |
|
| 28 |
log = get_logger(__name__)
|
|
|
|
| 35 |
@click.option("--weights", required=True, type=click.Path(exists=True, path_type=Path))
|
| 36 |
@click.option("--tokenizer-dir", required=True, type=click.Path(exists=True, path_type=Path))
|
| 37 |
@click.option("--image", required=True, type=click.Path(exists=True, path_type=Path))
|
| 38 |
+
@click.option(
|
| 39 |
+
"--decode-strategy",
|
| 40 |
+
type=click.Choice(["greedy", "beam"]),
|
| 41 |
+
default=None,
|
| 42 |
+
help="Override config.serve.decode_strategy for this run.",
|
| 43 |
+
)
|
| 44 |
+
@click.option("--beam-width", type=int, default=None)
|
| 45 |
+
@click.option("--length-penalty", type=float, default=None)
|
| 46 |
+
@click.option("--repetition-penalty", type=float, default=None)
|
| 47 |
+
@click.option("--no-repeat-ngram-size", type=int, default=None)
|
| 48 |
+
def main(
|
| 49 |
+
config_path: Path,
|
| 50 |
+
weights: Path,
|
| 51 |
+
tokenizer_dir: Path,
|
| 52 |
+
image: Path,
|
| 53 |
+
decode_strategy: str | None,
|
| 54 |
+
beam_width: int | None,
|
| 55 |
+
length_penalty: float | None,
|
| 56 |
+
repetition_penalty: float | None,
|
| 57 |
+
no_repeat_ngram_size: int | None,
|
| 58 |
+
) -> None:
|
| 59 |
"""Generate a caption for one image."""
|
| 60 |
configure_logging()
|
| 61 |
config = load_config(config_path)
|
|
|
|
| 64 |
weights_path=weights,
|
| 65 |
tokenizer_dir=tokenizer_dir,
|
| 66 |
config=config,
|
| 67 |
+
decode_strategy=cast("DecodeStrategy | None", decode_strategy),
|
| 68 |
+
beam_width=beam_width,
|
| 69 |
+
length_penalty=length_penalty,
|
| 70 |
+
repetition_penalty=repetition_penalty,
|
| 71 |
+
no_repeat_ngram_size=no_repeat_ngram_size,
|
| 72 |
)
|
| 73 |
predictor.warmup()
|
| 74 |
caption = predictor.predict_path(image)
|
src/captioning/config/schema.py
CHANGED
|
@@ -92,7 +92,15 @@ class ModelConfig(_StrictModel):
|
|
| 92 |
|
| 93 |
|
| 94 |
class TrainConfig(_StrictModel):
|
| 95 |
-
"""Optimisation hyperparameters.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
epochs: int = 10
|
| 98 |
batch_size: int = 64
|
|
@@ -102,16 +110,81 @@ class TrainConfig(_StrictModel):
|
|
| 102 |
learning_rate: float = 1e-3 # Notebook uses Keras Adam default == 1e-3
|
| 103 |
weights_filename: str = "model.h5"
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
class ServeConfig(_StrictModel):
|
| 107 |
"""Settings for the FastAPI backend (Phase 2). Defined here so the schema
|
| 108 |
-
is complete and tests don't have to mock a sub-config's existence.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
max_upload_bytes: int = 10 * 1024 * 1024 # 10 MB
|
| 111 |
-
decode_strategy: str = "greedy"
|
| 112 |
beam_width: int = 3
|
|
|
|
|
|
|
|
|
|
| 113 |
cors_allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"])
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
class AppConfig(BaseSettings):
|
| 117 |
"""Top-level config aggregating every sub-config.
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
class TrainConfig(_StrictModel):
|
| 95 |
+
"""Optimisation hyperparameters.
|
| 96 |
+
|
| 97 |
+
The Phase 1 baseline mirrors the IEEE notebook: constant LR, no label
|
| 98 |
+
smoothing, dropout-active validation (a notebook quirk preserved for
|
| 99 |
+
parity). The fields below the comment line are *opt-in* training-
|
| 100 |
+
stability knobs added during the caption-quality stabilisation phase.
|
| 101 |
+
Defaults keep every existing run byte-for-byte identical to the
|
| 102 |
+
notebook; flipping the flag in YAML opts a run into the modern recipe.
|
| 103 |
+
"""
|
| 104 |
|
| 105 |
epochs: int = 10
|
| 106 |
batch_size: int = 64
|
|
|
|
| 110 |
learning_rate: float = 1e-3 # Notebook uses Keras Adam default == 1e-3
|
| 111 |
weights_filename: str = "model.h5"
|
| 112 |
|
| 113 |
+
# ---- opt-in stability flags (default values preserve notebook parity) ----
|
| 114 |
+
label_smoothing: float = 0.0
|
| 115 |
+
lr_schedule: str = "constant" # "constant" | "cosine"
|
| 116 |
+
warmup_steps: int = 0
|
| 117 |
+
cosine_decay_steps: int | None = None # If None, derived from epochs * steps_per_epoch
|
| 118 |
+
min_learning_rate: float = 0.0
|
| 119 |
+
honour_training_flag_in_test_step: bool = False # parity-quirk override
|
| 120 |
+
|
| 121 |
+
@field_validator("label_smoothing")
|
| 122 |
+
@classmethod
|
| 123 |
+
def _validate_label_smoothing(cls, v: float) -> float:
|
| 124 |
+
if not 0.0 <= v < 1.0:
|
| 125 |
+
raise ValueError(f"label_smoothing must be in [0, 1), got {v}")
|
| 126 |
+
return v
|
| 127 |
+
|
| 128 |
+
@field_validator("lr_schedule")
|
| 129 |
+
@classmethod
|
| 130 |
+
def _validate_lr_schedule(cls, v: str) -> str:
|
| 131 |
+
if v not in {"constant", "cosine"}:
|
| 132 |
+
raise ValueError(f"lr_schedule must be 'constant' or 'cosine', got {v!r}")
|
| 133 |
+
return v
|
| 134 |
+
|
| 135 |
+
@field_validator("warmup_steps")
|
| 136 |
+
@classmethod
|
| 137 |
+
def _validate_warmup_steps(cls, v: int) -> int:
|
| 138 |
+
if v < 0:
|
| 139 |
+
raise ValueError(f"warmup_steps must be >= 0, got {v}")
|
| 140 |
+
return v
|
| 141 |
+
|
| 142 |
|
| 143 |
class ServeConfig(_StrictModel):
|
| 144 |
"""Settings for the FastAPI backend (Phase 2). Defined here so the schema
|
| 145 |
+
is complete and tests don't have to mock a sub-config's existence.
|
| 146 |
+
|
| 147 |
+
Decoding-related defaults are deliberately conservative: ``greedy`` stays
|
| 148 |
+
the default for byte-for-byte parity with the IEEE notebook. Switching to
|
| 149 |
+
beam at deploy time is a one-line YAML override:
|
| 150 |
+
|
| 151 |
+
serve:
|
| 152 |
+
decode_strategy: beam
|
| 153 |
+
beam_width: 4
|
| 154 |
+
length_penalty: 0.7
|
| 155 |
+
repetition_penalty: 1.1
|
| 156 |
+
no_repeat_ngram_size: 3
|
| 157 |
+
"""
|
| 158 |
|
| 159 |
max_upload_bytes: int = 10 * 1024 * 1024 # 10 MB
|
| 160 |
+
decode_strategy: str = "greedy"
|
| 161 |
beam_width: int = 3
|
| 162 |
+
length_penalty: float = 1.0
|
| 163 |
+
repetition_penalty: float = 1.0
|
| 164 |
+
no_repeat_ngram_size: int = 0
|
| 165 |
cors_allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:3000"])
|
| 166 |
|
| 167 |
+
@field_validator("decode_strategy")
|
| 168 |
+
@classmethod
|
| 169 |
+
def _validate_decode_strategy(cls, v: str) -> str:
|
| 170 |
+
if v not in {"greedy", "beam"}:
|
| 171 |
+
raise ValueError(f"decode_strategy must be 'greedy' or 'beam', got {v!r}")
|
| 172 |
+
return v
|
| 173 |
+
|
| 174 |
+
@field_validator("beam_width")
|
| 175 |
+
@classmethod
|
| 176 |
+
def _validate_beam_width(cls, v: int) -> int:
|
| 177 |
+
if v < 1:
|
| 178 |
+
raise ValueError(f"beam_width must be >= 1, got {v}")
|
| 179 |
+
return v
|
| 180 |
+
|
| 181 |
+
@field_validator("repetition_penalty")
|
| 182 |
+
@classmethod
|
| 183 |
+
def _validate_repetition_penalty(cls, v: float) -> float:
|
| 184 |
+
if v < 1.0:
|
| 185 |
+
raise ValueError(f"repetition_penalty must be >= 1.0 (1.0 disables it), got {v}")
|
| 186 |
+
return v
|
| 187 |
+
|
| 188 |
|
| 189 |
class AppConfig(BaseSettings):
|
| 190 |
"""Top-level config aggregating every sub-config.
|
src/captioning/evaluation/__init__.py
CHANGED
|
@@ -1,9 +1,49 @@
|
|
| 1 |
-
"""Evaluation — caption-quality metrics.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from captioning.evaluation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation — caption-quality metrics + per-sample diagnostics.
|
| 2 |
|
| 3 |
+
Available metrics (all corpus-level, 0-100 scale where applicable):
|
| 4 |
+
* BLEU-1..4 — :mod:`bleu`
|
| 5 |
+
* ROUGE-L — :mod:`rouge`
|
| 6 |
+
* METEOR — :mod:`meteor` (requires a JRE on PATH)
|
| 7 |
+
* CIDEr — :mod:`cider` (requires >= 2 examples)
|
| 8 |
+
|
| 9 |
+
:func:`compute_all_metrics` in :mod:`runner` is the single entry point used
|
| 10 |
+
by the CLI and by future Phase 3 benchmark comparisons; per-sample
|
| 11 |
+
diagnostics live in :mod:`inspection`.
|
| 12 |
"""
|
| 13 |
|
| 14 |
+
from captioning.evaluation.benchmark import RunMeta, write_run_artifacts
|
| 15 |
+
from captioning.evaluation.bleu import (
|
| 16 |
+
BleuBreakdown,
|
| 17 |
+
corpus_bleu_breakdown,
|
| 18 |
+
corpus_bleu_score,
|
| 19 |
+
)
|
| 20 |
+
from captioning.evaluation.cider import MIN_SAMPLES_FOR_CIDER, corpus_cider_score
|
| 21 |
+
from captioning.evaluation.inspection import (
|
| 22 |
+
SampleDiagnostics,
|
| 23 |
+
diagnose_many,
|
| 24 |
+
diagnose_sample,
|
| 25 |
+
format_diagnostic_row,
|
| 26 |
+
write_diagnostics_jsonl,
|
| 27 |
+
)
|
| 28 |
+
from captioning.evaluation.meteor import corpus_meteor_score
|
| 29 |
+
from captioning.evaluation.rouge import corpus_rouge_l_score
|
| 30 |
+
from captioning.evaluation.runner import MetricsReport, compute_all_metrics
|
| 31 |
|
| 32 |
+
__all__ = [
|
| 33 |
+
"MIN_SAMPLES_FOR_CIDER",
|
| 34 |
+
"BleuBreakdown",
|
| 35 |
+
"MetricsReport",
|
| 36 |
+
"RunMeta",
|
| 37 |
+
"SampleDiagnostics",
|
| 38 |
+
"compute_all_metrics",
|
| 39 |
+
"corpus_bleu_breakdown",
|
| 40 |
+
"corpus_bleu_score",
|
| 41 |
+
"corpus_cider_score",
|
| 42 |
+
"corpus_meteor_score",
|
| 43 |
+
"corpus_rouge_l_score",
|
| 44 |
+
"diagnose_many",
|
| 45 |
+
"diagnose_sample",
|
| 46 |
+
"format_diagnostic_row",
|
| 47 |
+
"write_diagnostics_jsonl",
|
| 48 |
+
"write_run_artifacts",
|
| 49 |
+
]
|
src/captioning/evaluation/benchmark.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark-ready run artefacts.
|
| 2 |
+
|
| 3 |
+
Every evaluation pass writes a consistent set of files under
|
| 4 |
+
``<run_root>/<run_id>/`` so Phase 3 cross-model comparisons can join them
|
| 5 |
+
without bespoke parsing per model:
|
| 6 |
+
|
| 7 |
+
metrics.json — :class:`MetricsReport` dumped via dataclass-asdict
|
| 8 |
+
predictions.jsonl — one row per (image, prediction, references)
|
| 9 |
+
diagnostics.jsonl — one :class:`SampleDiagnostics` per row
|
| 10 |
+
run_meta.json — model id, decode strategy, n_samples, timestamp
|
| 11 |
+
report.md — Markdown summary humans actually read
|
| 12 |
+
|
| 13 |
+
A "run" is one (model, decode_strategy, dataset_slice) tuple. ``run_id`` is
|
| 14 |
+
a free-form string — the CLI defaults to a timestamp; comparison code groups
|
| 15 |
+
by ``model_id`` to plot bars across models.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from datetime import datetime, timezone
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
from captioning.evaluation.inspection import SampleDiagnostics, write_diagnostics_jsonl
|
| 26 |
+
from captioning.evaluation.runner import MetricsReport
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class RunMeta:
|
| 31 |
+
"""Per-evaluation-run metadata persisted next to metrics."""
|
| 32 |
+
|
| 33 |
+
model_id: str
|
| 34 |
+
decode_strategy: str
|
| 35 |
+
weights_path: str
|
| 36 |
+
tokenizer_dir: str
|
| 37 |
+
n_samples: int
|
| 38 |
+
max_length: int
|
| 39 |
+
beam_width: int | None = None
|
| 40 |
+
length_penalty: float | None = None
|
| 41 |
+
repetition_penalty: float | None = None
|
| 42 |
+
timestamp_utc: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
| 43 |
+
|
| 44 |
+
def to_dict(self) -> dict[str, object]:
|
| 45 |
+
return {
|
| 46 |
+
"model_id": self.model_id,
|
| 47 |
+
"decode_strategy": self.decode_strategy,
|
| 48 |
+
"weights_path": self.weights_path,
|
| 49 |
+
"tokenizer_dir": self.tokenizer_dir,
|
| 50 |
+
"n_samples": self.n_samples,
|
| 51 |
+
"max_length": self.max_length,
|
| 52 |
+
"beam_width": self.beam_width,
|
| 53 |
+
"length_penalty": self.length_penalty,
|
| 54 |
+
"repetition_penalty": self.repetition_penalty,
|
| 55 |
+
"timestamp_utc": self.timestamp_utc,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def write_run_artifacts(
|
| 60 |
+
run_dir: str | Path,
|
| 61 |
+
*,
|
| 62 |
+
metrics: MetricsReport,
|
| 63 |
+
meta: RunMeta,
|
| 64 |
+
images: list[str],
|
| 65 |
+
predictions: list[str],
|
| 66 |
+
references: list[list[str]],
|
| 67 |
+
diagnostics: list[SampleDiagnostics],
|
| 68 |
+
) -> Path:
|
| 69 |
+
"""Write every benchmark artefact to ``run_dir`` and return the directory.
|
| 70 |
+
|
| 71 |
+
Idempotent over a clean ``run_dir``; overwrites existing files inside.
|
| 72 |
+
"""
|
| 73 |
+
out = Path(run_dir)
|
| 74 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
|
| 76 |
+
(out / "metrics.json").write_text(json.dumps(metrics.to_dict(), indent=2), encoding="utf-8")
|
| 77 |
+
(out / "run_meta.json").write_text(json.dumps(meta.to_dict(), indent=2), encoding="utf-8")
|
| 78 |
+
|
| 79 |
+
with (out / "predictions.jsonl").open("w", encoding="utf-8") as f:
|
| 80 |
+
for img, pred, refs in zip(images, predictions, references, strict=True):
|
| 81 |
+
row = {"image": img, "prediction": pred, "references": list(refs)}
|
| 82 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 83 |
+
|
| 84 |
+
write_diagnostics_jsonl(diagnostics, out / "diagnostics.jsonl")
|
| 85 |
+
(out / "report.md").write_text(_render_report_markdown(meta, metrics), encoding="utf-8")
|
| 86 |
+
return out
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _render_report_markdown(meta: RunMeta, m: MetricsReport) -> str:
|
| 90 |
+
"""Render the human-facing Markdown summary of a single run."""
|
| 91 |
+
|
| 92 |
+
def fmt(v: float | None) -> str:
|
| 93 |
+
return "n/a" if v is None else f"{v:.2f}"
|
| 94 |
+
|
| 95 |
+
lines = [
|
| 96 |
+
f"# Evaluation run — {meta.model_id}",
|
| 97 |
+
"",
|
| 98 |
+
f"- Decode strategy: `{meta.decode_strategy}`",
|
| 99 |
+
f"- Weights: `{meta.weights_path}`",
|
| 100 |
+
f"- Tokenizer dir: `{meta.tokenizer_dir}`",
|
| 101 |
+
f"- Samples: **{meta.n_samples}**",
|
| 102 |
+
f"- Timestamp (UTC): {meta.timestamp_utc}",
|
| 103 |
+
]
|
| 104 |
+
if meta.beam_width is not None:
|
| 105 |
+
lines.append(f"- Beam width: {meta.beam_width}")
|
| 106 |
+
if meta.length_penalty is not None:
|
| 107 |
+
lines.append(f"- Length penalty: {meta.length_penalty}")
|
| 108 |
+
if meta.repetition_penalty is not None:
|
| 109 |
+
lines.append(f"- Repetition penalty: {meta.repetition_penalty}")
|
| 110 |
+
lines += [
|
| 111 |
+
"",
|
| 112 |
+
"## Metrics",
|
| 113 |
+
"",
|
| 114 |
+
"| Metric | Value |",
|
| 115 |
+
"|---|---|",
|
| 116 |
+
f"| BLEU-1 | {fmt(m.bleu1)} |",
|
| 117 |
+
f"| BLEU-2 | {fmt(m.bleu2)} |",
|
| 118 |
+
f"| BLEU-3 | {fmt(m.bleu3)} |",
|
| 119 |
+
f"| BLEU-4 | {fmt(m.bleu4)} |",
|
| 120 |
+
f"| ROUGE-L | {fmt(m.rouge_l)} |",
|
| 121 |
+
f"| METEOR | {fmt(m.meteor)} |",
|
| 122 |
+
f"| CIDEr | {fmt(m.cider)} |",
|
| 123 |
+
]
|
| 124 |
+
if m.errors:
|
| 125 |
+
lines += ["", "## Skipped or failed metrics", ""]
|
| 126 |
+
for name, err in m.errors.items():
|
| 127 |
+
lines.append(f"- `{name}`: {err}")
|
| 128 |
+
return "\n".join(lines) + "\n"
|
src/captioning/evaluation/bleu.py
CHANGED
|
@@ -1,21 +1,40 @@
|
|
| 1 |
-
"""Corpus BLEU score
|
| 2 |
-
|
| 3 |
-
The IEEE paper reports BLEU ~24 on COCO val.
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
pip.
|
| 12 |
-
* Phase 1b expands to BLEU-1..4, CIDEr, METEOR, ROUGE-L, all in this
|
| 13 |
-
package, all behind the same ``runner.py`` interface.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
from collections.abc import Sequence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def corpus_bleu_score(
|
|
@@ -38,6 +57,27 @@ def corpus_bleu_score(
|
|
| 38 |
ImportError: If sacrebleu is not installed. Install via the eval
|
| 39 |
extras: ``pip install -e ".[eval]"`` or the requirements file.
|
| 40 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
try:
|
| 42 |
import sacrebleu
|
| 43 |
except ImportError as e:
|
|
@@ -52,12 +92,17 @@ def corpus_bleu_score(
|
|
| 52 |
f"({len(references)}) must have the same length"
|
| 53 |
)
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
refs_by_slot = [
|
| 59 |
-
[refs[i] if i < len(refs) else "" for refs in references] for i in range(max_refs)
|
| 60 |
-
]
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus BLEU score.
|
| 2 |
+
|
| 3 |
+
The IEEE paper reports BLEU-4 ~24 on COCO val. ``sacrebleu`` is the de-facto
|
| 4 |
+
BLEU implementation; NLTK's BLEU has idiosyncratic smoothing and would not
|
| 5 |
+
reproduce the published number across machines.
|
| 6 |
+
|
| 7 |
+
``corpus_bleu_score`` returns BLEU-4 (the default n=4 score) so existing
|
| 8 |
+
callers keep working. ``corpus_bleu_breakdown`` additionally exposes BLEU-1,
|
| 9 |
+
BLEU-2, BLEU-3, BLEU-4 in one pass — useful for the inspection utility and
|
| 10 |
+
for the JSON report consumed by Phase 3 cross-model comparison.
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
from collections.abc import Sequence
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
from captioning.evaluation.tokenization import (
|
| 19 |
+
strip_sentinels_many,
|
| 20 |
+
strip_sentinels_references,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class BleuBreakdown:
|
| 26 |
+
"""Per-n BLEU precisions plus the overall BLEU-4 score (0-100 scale)."""
|
| 27 |
+
|
| 28 |
+
bleu1: float
|
| 29 |
+
bleu2: float
|
| 30 |
+
bleu3: float
|
| 31 |
+
bleu4: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _refs_by_slot(references: Sequence[Sequence[str]]) -> list[list[str]]:
|
| 35 |
+
"""Convert ragged per-example references to sacrebleu's per-slot layout."""
|
| 36 |
+
max_refs = max(len(r) for r in references) if references else 0
|
| 37 |
+
return [[refs[i] if i < len(refs) else "" for refs in references] for i in range(max_refs)]
|
| 38 |
|
| 39 |
|
| 40 |
def corpus_bleu_score(
|
|
|
|
| 57 |
ImportError: If sacrebleu is not installed. Install via the eval
|
| 58 |
extras: ``pip install -e ".[eval]"`` or the requirements file.
|
| 59 |
"""
|
| 60 |
+
return corpus_bleu_breakdown(predictions, references).bleu4
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def corpus_bleu_breakdown(
|
| 64 |
+
predictions: Sequence[str],
|
| 65 |
+
references: Sequence[Sequence[str]],
|
| 66 |
+
) -> BleuBreakdown:
|
| 67 |
+
"""Compute BLEU-1, BLEU-2, BLEU-3, BLEU-4 in a single pass.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
predictions: One generated caption per example.
|
| 71 |
+
references: One *list* of reference captions per example.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
:class:`BleuBreakdown` with all four cumulative BLEU-n scores on the
|
| 75 |
+
0-100 scale (sacrebleu's convention).
|
| 76 |
+
|
| 77 |
+
Raises:
|
| 78 |
+
ImportError: If sacrebleu is not installed.
|
| 79 |
+
ValueError: On mismatched lengths.
|
| 80 |
+
"""
|
| 81 |
try:
|
| 82 |
import sacrebleu
|
| 83 |
except ImportError as e:
|
|
|
|
| 92 |
f"({len(references)}) must have the same length"
|
| 93 |
)
|
| 94 |
|
| 95 |
+
preds = strip_sentinels_many(predictions)
|
| 96 |
+
refs = strip_sentinels_references(references)
|
| 97 |
+
refs_by_slot = _refs_by_slot(refs)
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
# ``corpus_bleu`` only returns BLEU-4. To get cumulative BLEU-1..3 we
|
| 100 |
+
# instantiate ``BLEU`` directly with ``max_ngram_order=n``, which weights
|
| 101 |
+
# the geometric mean over precisions[:n] (same convention as NLTK's
|
| 102 |
+
# cumulative BLEU and the COCO eval scripts).
|
| 103 |
+
bleu_cls = sacrebleu.metrics.BLEU
|
| 104 |
+
scores: list[float] = []
|
| 105 |
+
for n in (1, 2, 3, 4):
|
| 106 |
+
scorer = bleu_cls(max_ngram_order=n, effective_order=True)
|
| 107 |
+
scores.append(float(scorer.corpus_score(preds, refs_by_slot).score))
|
| 108 |
+
return BleuBreakdown(bleu1=scores[0], bleu2=scores[1], bleu3=scores[2], bleu4=scores[3])
|
src/captioning/evaluation/cider.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CIDEr (Consensus-based Image Description Evaluation) corpus metric.
|
| 2 |
+
|
| 3 |
+
CIDEr is the metric the COCO captioning leaderboard ranks by. It computes a
|
| 4 |
+
TF-IDF weighting over n-grams of the references and measures cosine similarity
|
| 5 |
+
to the prediction. Higher is better; correctly trained models score in the
|
| 6 |
+
range 0.6 - 1.4 on COCO val.
|
| 7 |
+
|
| 8 |
+
Implementation notes:
|
| 9 |
+
* We delegate to ``pycocoevalcap`` — the reference implementation used by
|
| 10 |
+
the original CIDEr paper and by every COCO submission.
|
| 11 |
+
* CIDEr's TF-IDF is corpus-level: scoring a *single* example returns 0
|
| 12 |
+
because every n-gram is "common" to that one-document corpus. The
|
| 13 |
+
``runner`` aggregator and the CLI guard against calling CIDEr with
|
| 14 |
+
fewer than ``MIN_SAMPLES_FOR_CIDER`` examples and return ``None``
|
| 15 |
+
in that case.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from collections.abc import Sequence
|
| 21 |
+
|
| 22 |
+
from captioning.evaluation.tokenization import (
|
| 23 |
+
strip_sentinels_many,
|
| 24 |
+
strip_sentinels_references,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# CIDEr's TF-IDF is degenerate below this — every n-gram is "common"
|
| 28 |
+
# to the entire corpus, so the score collapses to 0. We surface ``None``
|
| 29 |
+
# instead of a misleading value below this threshold.
|
| 30 |
+
MIN_SAMPLES_FOR_CIDER = 2
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def corpus_cider_score(
|
| 34 |
+
predictions: Sequence[str],
|
| 35 |
+
references: Sequence[Sequence[str]],
|
| 36 |
+
) -> float:
|
| 37 |
+
"""Compute corpus CIDEr.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
predictions: One generated caption per example.
|
| 41 |
+
references: One *list* of reference captions per example.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
CIDEr in the 0-10 range (pycocoevalcap convention; the typical COCO
|
| 45 |
+
leaderboard value is in [0, 2]).
|
| 46 |
+
|
| 47 |
+
Raises:
|
| 48 |
+
ImportError: If ``pycocoevalcap`` is not installed.
|
| 49 |
+
ValueError: On mismatched lengths or if called with fewer than
|
| 50 |
+
``MIN_SAMPLES_FOR_CIDER`` examples (in which case CIDEr's TF-IDF
|
| 51 |
+
is degenerate and the score is meaningless).
|
| 52 |
+
"""
|
| 53 |
+
if len(predictions) != len(references):
|
| 54 |
+
raise ValueError(
|
| 55 |
+
f"predictions ({len(predictions)}) and references "
|
| 56 |
+
f"({len(references)}) must have the same length"
|
| 57 |
+
)
|
| 58 |
+
if len(predictions) < MIN_SAMPLES_FOR_CIDER:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"CIDEr requires at least {MIN_SAMPLES_FOR_CIDER} examples; "
|
| 61 |
+
f"got {len(predictions)}. TF-IDF is degenerate on smaller corpora."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
from pycocoevalcap.cider.cider import Cider
|
| 66 |
+
except ImportError as e:
|
| 67 |
+
raise ImportError(
|
| 68 |
+
"pycocoevalcap is required for CIDEr evaluation. "
|
| 69 |
+
"Install via `pip install -r requirements-eval.txt`."
|
| 70 |
+
) from e
|
| 71 |
+
|
| 72 |
+
preds = strip_sentinels_many(predictions)
|
| 73 |
+
refs = strip_sentinels_references(references)
|
| 74 |
+
|
| 75 |
+
# pycocoevalcap expects {image_id: [captions]} dicts.
|
| 76 |
+
gts = {str(i): [r for r in ref_list if r] for i, ref_list in enumerate(refs)}
|
| 77 |
+
res = {str(i): [p] for i, p in enumerate(preds)}
|
| 78 |
+
|
| 79 |
+
scorer = Cider()
|
| 80 |
+
score, _ = scorer.compute_score(gts, res)
|
| 81 |
+
return float(score)
|
src/captioning/evaluation/inspection.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-sample inspection utilities for diagnosing weak captions.
|
| 2 |
+
|
| 3 |
+
The aggregate corpus metric tells you *how bad* the model is; this module
|
| 4 |
+
tells you *why*. For each (image, prediction, reference-set) triple it
|
| 5 |
+
records per-sample BLEU-4, sentence-level ROUGE-L, the prediction length,
|
| 6 |
+
the longest repeated token run, and whether the prediction is empty after
|
| 7 |
+
stripping sentinels.
|
| 8 |
+
|
| 9 |
+
Three failure modes the evaluation pass is trying to surface:
|
| 10 |
+
* **Generic captions** — high BLEU-1, low BLEU-4 (n-gram trickle out).
|
| 11 |
+
* **Repetition** — large ``repeat_run`` value.
|
| 12 |
+
* **Early stopping** — ``length_tokens`` far below reference median.
|
| 13 |
+
|
| 14 |
+
Output JSONL is intentionally flat (one line per sample) so it can be loaded
|
| 15 |
+
with ``pandas.read_json(..., lines=True)`` or grep'd from the shell. The
|
| 16 |
+
runner that uses this module writes one such file per evaluation pass
|
| 17 |
+
alongside ``metrics.json`` for the same run.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
from collections.abc import Iterable, Sequence
|
| 24 |
+
from dataclasses import asdict, dataclass
|
| 25 |
+
from itertools import pairwise
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
from captioning.evaluation.tokenization import strip_sentinels
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class SampleDiagnostics:
|
| 33 |
+
"""Inspectable record for one (image, prediction, reference-set) triple."""
|
| 34 |
+
|
| 35 |
+
image: str
|
| 36 |
+
prediction: str
|
| 37 |
+
references: list[str]
|
| 38 |
+
length_tokens: int
|
| 39 |
+
longest_repeat_run: int
|
| 40 |
+
sentence_bleu4: float | None
|
| 41 |
+
sentence_rouge_l: float | None
|
| 42 |
+
flags: list[str]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _longest_repeat_run(tokens: Sequence[str]) -> int:
|
| 46 |
+
"""Return the longest run of immediately-repeated tokens.
|
| 47 |
+
|
| 48 |
+
Example: ``["a", "a", "a", "dog"]`` -> ``3``. Used to flag the classic
|
| 49 |
+
transformer-decoder collapse where the same token is emitted on every step.
|
| 50 |
+
"""
|
| 51 |
+
if not tokens:
|
| 52 |
+
return 0
|
| 53 |
+
best = current = 1
|
| 54 |
+
for prev, cur in pairwise(tokens):
|
| 55 |
+
current = current + 1 if cur == prev else 1
|
| 56 |
+
best = max(best, current)
|
| 57 |
+
return best
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _sentence_bleu4(prediction: str, references: Sequence[str]) -> float | None:
|
| 61 |
+
"""Sentence-level BLEU-4 via sacrebleu's effective-order smoothing."""
|
| 62 |
+
try:
|
| 63 |
+
import sacrebleu
|
| 64 |
+
except ImportError:
|
| 65 |
+
return None
|
| 66 |
+
if not references or not prediction:
|
| 67 |
+
return None
|
| 68 |
+
scorer = sacrebleu.metrics.BLEU(effective_order=True, max_ngram_order=4)
|
| 69 |
+
return float(scorer.sentence_score(prediction, list(references)).score)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _sentence_rouge_l(prediction: str, references: Sequence[str]) -> float | None:
|
| 73 |
+
"""Best-of-references sentence-level ROUGE-L F-measure (0-100 scale)."""
|
| 74 |
+
try:
|
| 75 |
+
from rouge_score import rouge_scorer
|
| 76 |
+
except ImportError:
|
| 77 |
+
return None
|
| 78 |
+
valid_refs = [r for r in references if r]
|
| 79 |
+
if not valid_refs or not prediction:
|
| 80 |
+
return None
|
| 81 |
+
scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
|
| 82 |
+
best = max(scorer.score(r, prediction)["rougeL"].fmeasure for r in valid_refs)
|
| 83 |
+
return float(100.0 * best)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def diagnose_sample(
|
| 87 |
+
image: str,
|
| 88 |
+
prediction: str,
|
| 89 |
+
references: Sequence[str],
|
| 90 |
+
) -> SampleDiagnostics:
|
| 91 |
+
"""Return :class:`SampleDiagnostics` for one prediction-vs-references row."""
|
| 92 |
+
pred_clean = strip_sentinels(prediction)
|
| 93 |
+
ref_clean = [strip_sentinels(r) for r in references if r]
|
| 94 |
+
tokens = pred_clean.split()
|
| 95 |
+
|
| 96 |
+
flags: list[str] = []
|
| 97 |
+
if not pred_clean:
|
| 98 |
+
flags.append("empty")
|
| 99 |
+
if len(tokens) <= 2:
|
| 100 |
+
flags.append("very_short")
|
| 101 |
+
repeat = _longest_repeat_run(tokens)
|
| 102 |
+
if repeat >= 3:
|
| 103 |
+
flags.append("repetitive")
|
| 104 |
+
if ref_clean and tokens and len(tokens) < min(len(r.split()) for r in ref_clean) // 2:
|
| 105 |
+
flags.append("under_length")
|
| 106 |
+
|
| 107 |
+
return SampleDiagnostics(
|
| 108 |
+
image=image,
|
| 109 |
+
prediction=pred_clean,
|
| 110 |
+
references=ref_clean,
|
| 111 |
+
length_tokens=len(tokens),
|
| 112 |
+
longest_repeat_run=repeat,
|
| 113 |
+
sentence_bleu4=_sentence_bleu4(pred_clean, ref_clean),
|
| 114 |
+
sentence_rouge_l=_sentence_rouge_l(pred_clean, ref_clean),
|
| 115 |
+
flags=flags,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def diagnose_many(
|
| 120 |
+
images: Sequence[str],
|
| 121 |
+
predictions: Sequence[str],
|
| 122 |
+
references: Sequence[Sequence[str]],
|
| 123 |
+
) -> list[SampleDiagnostics]:
|
| 124 |
+
"""Vectorised :func:`diagnose_sample` over parallel sequences."""
|
| 125 |
+
if not (len(images) == len(predictions) == len(references)):
|
| 126 |
+
raise ValueError(
|
| 127 |
+
"images, predictions, references must be the same length: "
|
| 128 |
+
f"got {len(images)} / {len(predictions)} / {len(references)}"
|
| 129 |
+
)
|
| 130 |
+
return [
|
| 131 |
+
diagnose_sample(img, pred, refs)
|
| 132 |
+
for img, pred, refs in zip(images, predictions, references, strict=True)
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def write_diagnostics_jsonl(
|
| 137 |
+
diagnostics: Iterable[SampleDiagnostics],
|
| 138 |
+
path: str | Path,
|
| 139 |
+
) -> None:
|
| 140 |
+
"""Write one JSON object per line — pandas/jq friendly.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
diagnostics: An iterable of :class:`SampleDiagnostics` (typically the
|
| 144 |
+
output of :func:`diagnose_many`).
|
| 145 |
+
path: Destination file. Parent directory is created if needed.
|
| 146 |
+
"""
|
| 147 |
+
out = Path(path)
|
| 148 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
with out.open("w", encoding="utf-8") as f:
|
| 150 |
+
for d in diagnostics:
|
| 151 |
+
f.write(json.dumps(asdict(d), ensure_ascii=False) + "\n")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def format_diagnostic_row(d: SampleDiagnostics) -> str:
|
| 155 |
+
"""Return a one-line human-readable summary — used by the CLI tail print."""
|
| 156 |
+
bleu = f"BLEU4={d.sentence_bleu4:5.1f}" if d.sentence_bleu4 is not None else "BLEU4= n/a"
|
| 157 |
+
rouge = f"R-L={d.sentence_rouge_l:5.1f}" if d.sentence_rouge_l is not None else "R-L= n/a"
|
| 158 |
+
flagstr = ",".join(d.flags) if d.flags else "-"
|
| 159 |
+
return (
|
| 160 |
+
f"{Path(d.image).name:35s} "
|
| 161 |
+
f"{bleu} {rouge} len={d.length_tokens:>2} repeat={d.longest_repeat_run:>2} "
|
| 162 |
+
f"flags={flagstr}\n pred: {d.prediction}\n ref : {d.references[0] if d.references else ''}"
|
| 163 |
+
)
|
src/captioning/evaluation/meteor.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""METEOR (Metric for Evaluation of Translation with Explicit Ordering).
|
| 2 |
+
|
| 3 |
+
METEOR is part of the standard COCO captioning report alongside BLEU, ROUGE-L,
|
| 4 |
+
and CIDEr. It complements BLEU by rewarding semantic matches (synonyms,
|
| 5 |
+
stems) rather than only surface n-gram overlap.
|
| 6 |
+
|
| 7 |
+
Implementation notes:
|
| 8 |
+
* We use the ``pycocoevalcap`` METEOR adapter, which shells out to the
|
| 9 |
+
original Java implementation. METEOR therefore needs a JRE on PATH at
|
| 10 |
+
runtime; the import succeeds either way, the Java process is spawned
|
| 11 |
+
lazily on first scoring call.
|
| 12 |
+
* METEOR's process is long-lived and accepts batches over stdin/stdout —
|
| 13 |
+
a single ``compute_score`` call handles the whole corpus in one round
|
| 14 |
+
trip, so this scales to thousands of examples without thrashing the JVM.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from collections.abc import Sequence
|
| 20 |
+
|
| 21 |
+
from captioning.evaluation.tokenization import (
|
| 22 |
+
strip_sentinels_many,
|
| 23 |
+
strip_sentinels_references,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def corpus_meteor_score(
|
| 28 |
+
predictions: Sequence[str],
|
| 29 |
+
references: Sequence[Sequence[str]],
|
| 30 |
+
) -> float:
|
| 31 |
+
"""Compute corpus METEOR via ``pycocoevalcap``.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
predictions: One generated caption per example.
|
| 35 |
+
references: One *list* of reference captions per example.
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
Corpus METEOR in the 0-100 range to match the rest of this package.
|
| 39 |
+
pycocoevalcap returns 0-1; we multiply by 100 for report parity.
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
ImportError: If ``pycocoevalcap`` is not installed.
|
| 43 |
+
ValueError: On mismatched lengths.
|
| 44 |
+
RuntimeError: If the Java METEOR process cannot be launched.
|
| 45 |
+
"""
|
| 46 |
+
if len(predictions) != len(references):
|
| 47 |
+
raise ValueError(
|
| 48 |
+
f"predictions ({len(predictions)}) and references "
|
| 49 |
+
f"({len(references)}) must have the same length"
|
| 50 |
+
)
|
| 51 |
+
if not predictions:
|
| 52 |
+
return 0.0
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
from pycocoevalcap.meteor.meteor import Meteor
|
| 56 |
+
except ImportError as e:
|
| 57 |
+
raise ImportError(
|
| 58 |
+
"pycocoevalcap is required for METEOR evaluation. "
|
| 59 |
+
"Install via `pip install -r requirements-eval.txt`."
|
| 60 |
+
) from e
|
| 61 |
+
|
| 62 |
+
preds = strip_sentinels_many(predictions)
|
| 63 |
+
refs = strip_sentinels_references(references)
|
| 64 |
+
|
| 65 |
+
gts = {str(i): [r for r in ref_list if r] for i, ref_list in enumerate(refs)}
|
| 66 |
+
res = {str(i): [p] for i, p in enumerate(preds)}
|
| 67 |
+
|
| 68 |
+
scorer = Meteor()
|
| 69 |
+
try:
|
| 70 |
+
score, _ = scorer.compute_score(gts, res)
|
| 71 |
+
except Exception as e: # — meteor.py raises bare Exceptions
|
| 72 |
+
raise RuntimeError(
|
| 73 |
+
"METEOR scoring failed. METEOR requires a Java runtime on PATH. "
|
| 74 |
+
f"Underlying error: {e}"
|
| 75 |
+
) from e
|
| 76 |
+
|
| 77 |
+
return float(100.0 * score)
|
src/captioning/evaluation/rouge.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Corpus ROUGE-L for caption evaluation.
|
| 2 |
+
|
| 3 |
+
ROUGE-L measures the longest common subsequence between a prediction and its
|
| 4 |
+
references and is part of the standard COCO captioning report (BLEU, METEOR,
|
| 5 |
+
ROUGE-L, CIDEr).
|
| 6 |
+
|
| 7 |
+
Implementation notes:
|
| 8 |
+
* We use Google's ``rouge_score`` package (the canonical implementation
|
| 9 |
+
since the original perl scripts were retired). It returns precision /
|
| 10 |
+
recall / fmeasure per (prediction, reference) pair.
|
| 11 |
+
* COCO captions ship up to 5 references per image. We take the maximum
|
| 12 |
+
F-measure across references — same convention as pycocoevalcap.
|
| 13 |
+
* The corpus score is the mean of per-sample F-measures, matching how
|
| 14 |
+
sacrebleu and pycocoevalcap aggregate metrics over a dataset.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from collections.abc import Sequence
|
| 20 |
+
|
| 21 |
+
from captioning.evaluation.tokenization import (
|
| 22 |
+
strip_sentinels_many,
|
| 23 |
+
strip_sentinels_references,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def corpus_rouge_l_score(
|
| 28 |
+
predictions: Sequence[str],
|
| 29 |
+
references: Sequence[Sequence[str]],
|
| 30 |
+
) -> float:
|
| 31 |
+
"""Compute corpus ROUGE-L F-measure.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
predictions: One generated caption per example.
|
| 35 |
+
references: One *list* of reference captions per example.
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
Mean ROUGE-L F-measure across examples, in the 0-100 range to match
|
| 39 |
+
sacrebleu's convention (so the report shows BLEU/ROUGE/METEOR/CIDEr
|
| 40 |
+
on comparable scales).
|
| 41 |
+
|
| 42 |
+
Raises:
|
| 43 |
+
ImportError: If ``rouge_score`` is not installed
|
| 44 |
+
(``pip install -r requirements-eval.txt``).
|
| 45 |
+
ValueError: On mismatched lengths or an empty references slot.
|
| 46 |
+
"""
|
| 47 |
+
if len(predictions) != len(references):
|
| 48 |
+
raise ValueError(
|
| 49 |
+
f"predictions ({len(predictions)}) and references "
|
| 50 |
+
f"({len(references)}) must have the same length"
|
| 51 |
+
)
|
| 52 |
+
if not predictions:
|
| 53 |
+
return 0.0
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
from rouge_score import rouge_scorer
|
| 57 |
+
except ImportError as e:
|
| 58 |
+
raise ImportError(
|
| 59 |
+
"rouge_score is required for ROUGE-L evaluation. "
|
| 60 |
+
"Install via `pip install -r requirements-eval.txt`."
|
| 61 |
+
) from e
|
| 62 |
+
|
| 63 |
+
scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True)
|
| 64 |
+
preds = strip_sentinels_many(predictions)
|
| 65 |
+
refs = strip_sentinels_references(references)
|
| 66 |
+
|
| 67 |
+
total = 0.0
|
| 68 |
+
for hypothesis, ref_list in zip(preds, refs, strict=True):
|
| 69 |
+
valid_refs = [r for r in ref_list if r]
|
| 70 |
+
if not valid_refs or not hypothesis:
|
| 71 |
+
continue
|
| 72 |
+
best = max(scorer.score(r, hypothesis)["rougeL"].fmeasure for r in valid_refs)
|
| 73 |
+
total += best
|
| 74 |
+
|
| 75 |
+
return float(100.0 * total / len(preds))
|
src/captioning/evaluation/runner.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single entry point that returns every implemented caption-quality metric.
|
| 2 |
+
|
| 3 |
+
``compute_all_metrics`` is the shared aggregator used by the CLI
|
| 4 |
+
(:mod:`scripts.evaluate`) and the per-sample inspection utility. It produces
|
| 5 |
+
a single :class:`MetricsReport` so downstream code never has to know which
|
| 6 |
+
metrics exist in the package — only how to read fields off the dataclass.
|
| 7 |
+
|
| 8 |
+
Adding a new metric is the four-step pattern this package already follows
|
| 9 |
+
elsewhere:
|
| 10 |
+
1. Implement ``corpus_<metric>_score`` in a sibling module.
|
| 11 |
+
2. Add an entry to :class:`MetricsReport`.
|
| 12 |
+
3. Call it from :func:`compute_all_metrics` (wrapped in a try/except so a
|
| 13 |
+
single broken metric never poisons the whole report).
|
| 14 |
+
4. Add a unit test on a toy fixture.
|
| 15 |
+
|
| 16 |
+
The exception swallowing is deliberate — METEOR needs Java, CIDEr needs
|
| 17 |
+
multiple samples, sacrebleu is always available. We do NOT want one
|
| 18 |
+
unavailable metric to kill the entire evaluation pass; instead we record
|
| 19 |
+
``None`` for that metric and surface a per-metric ``errors`` field so callers
|
| 20 |
+
(and the CLI) can flag the issue without losing the metrics that did work.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from collections.abc import Sequence
|
| 26 |
+
from dataclasses import asdict, dataclass, field
|
| 27 |
+
|
| 28 |
+
from captioning.evaluation.bleu import corpus_bleu_breakdown
|
| 29 |
+
from captioning.evaluation.cider import MIN_SAMPLES_FOR_CIDER, corpus_cider_score
|
| 30 |
+
from captioning.evaluation.meteor import corpus_meteor_score
|
| 31 |
+
from captioning.evaluation.rouge import corpus_rouge_l_score
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class MetricsReport:
|
| 36 |
+
"""Aggregate metric snapshot for one evaluation pass.
|
| 37 |
+
|
| 38 |
+
Every metric is ``float | None`` — ``None`` means the metric was skipped
|
| 39 |
+
(uninstalled, environment missing Java, too few samples for CIDEr, ...).
|
| 40 |
+
The reason for skipping is in :attr:`errors` keyed by metric name.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
n_examples: int
|
| 44 |
+
bleu1: float | None = None
|
| 45 |
+
bleu2: float | None = None
|
| 46 |
+
bleu3: float | None = None
|
| 47 |
+
bleu4: float | None = None
|
| 48 |
+
rouge_l: float | None = None
|
| 49 |
+
meteor: float | None = None
|
| 50 |
+
cider: float | None = None
|
| 51 |
+
errors: dict[str, str] = field(default_factory=dict)
|
| 52 |
+
|
| 53 |
+
def to_dict(self) -> dict[str, object]:
|
| 54 |
+
"""Return a JSON-serialisable dict (``errors`` becomes a sub-object)."""
|
| 55 |
+
return asdict(self)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def compute_all_metrics(
|
| 59 |
+
predictions: Sequence[str],
|
| 60 |
+
references: Sequence[Sequence[str]],
|
| 61 |
+
*,
|
| 62 |
+
include_meteor: bool = True,
|
| 63 |
+
include_cider: bool = True,
|
| 64 |
+
) -> MetricsReport:
|
| 65 |
+
"""Compute every available metric on a single ``(preds, refs)`` corpus.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
predictions: One generated caption per example.
|
| 69 |
+
references: One *list* of reference captions per example.
|
| 70 |
+
include_meteor: Set False to skip METEOR (avoids the JVM spawn —
|
| 71 |
+
helpful in CI where Java isn't installed).
|
| 72 |
+
include_cider: Set False to skip CIDEr (avoids the warning when
|
| 73 |
+
running on tiny corpora; the runner also auto-skips below
|
| 74 |
+
``MIN_SAMPLES_FOR_CIDER``).
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
A :class:`MetricsReport` with every field populated by a corpus
|
| 78 |
+
metric or recorded as failed in ``errors``.
|
| 79 |
+
"""
|
| 80 |
+
if len(predictions) != len(references):
|
| 81 |
+
raise ValueError(
|
| 82 |
+
f"predictions ({len(predictions)}) and references "
|
| 83 |
+
f"({len(references)}) must have the same length"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
errors: dict[str, str] = {}
|
| 87 |
+
bleu1 = bleu2 = bleu3 = bleu4 = None
|
| 88 |
+
rouge_l = meteor = cider = None
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
bleu = corpus_bleu_breakdown(predictions, references)
|
| 92 |
+
bleu1, bleu2, bleu3, bleu4 = bleu.bleu1, bleu.bleu2, bleu.bleu3, bleu.bleu4
|
| 93 |
+
except Exception as e: # — surface, don't crash the run
|
| 94 |
+
errors["bleu"] = repr(e)
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
rouge_l = corpus_rouge_l_score(predictions, references)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
errors["rouge_l"] = repr(e)
|
| 100 |
+
|
| 101 |
+
if include_meteor:
|
| 102 |
+
try:
|
| 103 |
+
meteor = corpus_meteor_score(predictions, references)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
errors["meteor"] = repr(e)
|
| 106 |
+
|
| 107 |
+
if include_cider:
|
| 108 |
+
if len(predictions) < MIN_SAMPLES_FOR_CIDER:
|
| 109 |
+
errors["cider"] = (
|
| 110 |
+
f"skipped: needs >= {MIN_SAMPLES_FOR_CIDER} examples, " f"got {len(predictions)}"
|
| 111 |
+
)
|
| 112 |
+
else:
|
| 113 |
+
try:
|
| 114 |
+
cider = corpus_cider_score(predictions, references)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
errors["cider"] = repr(e)
|
| 117 |
+
|
| 118 |
+
return MetricsReport(
|
| 119 |
+
n_examples=len(predictions),
|
| 120 |
+
bleu1=bleu1,
|
| 121 |
+
bleu2=bleu2,
|
| 122 |
+
bleu3=bleu3,
|
| 123 |
+
bleu4=bleu4,
|
| 124 |
+
rouge_l=rouge_l,
|
| 125 |
+
meteor=meteor,
|
| 126 |
+
cider=cider,
|
| 127 |
+
errors=errors,
|
| 128 |
+
)
|
src/captioning/evaluation/tokenization.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reference/hypothesis tokenisation helpers shared across metric modules.
|
| 2 |
+
|
| 3 |
+
CIDEr, METEOR (via pycocoevalcap) and ROUGE-L all expect *string* inputs but
|
| 4 |
+
each implements its own tokenisation internally. To compare metrics on the
|
| 5 |
+
same footing we strip our sentinel tokens once, up front, before any metric
|
| 6 |
+
sees a caption.
|
| 7 |
+
|
| 8 |
+
These helpers exist because every metric module would otherwise re-implement
|
| 9 |
+
the same ``[start]`` / ``[end]`` stripping inline — and bugs would diverge.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from collections.abc import Iterable, Sequence
|
| 15 |
+
|
| 16 |
+
from captioning.preprocessing.caption import END_TOKEN, START_TOKEN
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def strip_sentinels(caption: str) -> str:
|
| 20 |
+
"""Remove ``[start]`` / ``[end]`` sentinels and collapse whitespace.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
caption: A caption string that may carry our training sentinels.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
The same caption with the sentinels removed and consecutive whitespace
|
| 27 |
+
collapsed to a single space. Empty input returns ``""``.
|
| 28 |
+
"""
|
| 29 |
+
if not caption:
|
| 30 |
+
return ""
|
| 31 |
+
cleaned = caption.replace(START_TOKEN, " ").replace(END_TOKEN, " ")
|
| 32 |
+
return " ".join(cleaned.split())
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def strip_sentinels_many(captions: Iterable[str]) -> list[str]:
|
| 36 |
+
"""Apply :func:`strip_sentinels` to every caption in ``captions``."""
|
| 37 |
+
return [strip_sentinels(c) for c in captions]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def strip_sentinels_references(
|
| 41 |
+
references: Sequence[Sequence[str]],
|
| 42 |
+
) -> list[list[str]]:
|
| 43 |
+
"""Apply :func:`strip_sentinels` to every reference list."""
|
| 44 |
+
return [strip_sentinels_many(refs) for refs in references]
|
src/captioning/inference/__init__.py
CHANGED
|
@@ -10,12 +10,14 @@ explicitly so it works inside a long-lived process (FastAPI lifespan).
|
|
| 10 |
predictor.py ``CaptionPredictor`` — singleton wrapper for the API
|
| 11 |
"""
|
| 12 |
|
|
|
|
| 13 |
from captioning.inference.greedy import generate_caption_greedy
|
| 14 |
from captioning.inference.image_loader import load_image_from_path
|
| 15 |
from captioning.inference.predictor import CaptionPredictor
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
"CaptionPredictor",
|
|
|
|
| 19 |
"generate_caption_greedy",
|
| 20 |
"load_image_from_path",
|
| 21 |
]
|
|
|
|
| 10 |
predictor.py ``CaptionPredictor`` — singleton wrapper for the API
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
from captioning.inference.beam import generate_caption_beam
|
| 14 |
from captioning.inference.greedy import generate_caption_greedy
|
| 15 |
from captioning.inference.image_loader import load_image_from_path
|
| 16 |
from captioning.inference.predictor import CaptionPredictor
|
| 17 |
|
| 18 |
__all__ = [
|
| 19 |
"CaptionPredictor",
|
| 20 |
+
"generate_caption_beam",
|
| 21 |
"generate_caption_greedy",
|
| 22 |
"load_image_from_path",
|
| 23 |
]
|
src/captioning/inference/beam.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Beam-search caption generation.
|
| 2 |
+
|
| 3 |
+
Greedy decoding (the only Phase 1 option) routinely produces generic captions
|
| 4 |
+
because the model's most-likely-next-token at every step rarely lines up with
|
| 5 |
+
the most-likely-*sequence*. Beam search explores multiple partial captions in
|
| 6 |
+
parallel and ranks them by total log-probability, lifting BLEU-4 by 2-5
|
| 7 |
+
points on most transformer captioners without retraining.
|
| 8 |
+
|
| 9 |
+
Algorithm (standard beam search with length and repetition controls):
|
| 10 |
+
* Maintain ``beam_width`` active beams, each a (token-id sequence, score).
|
| 11 |
+
* At each step, batch every active beam through the decoder once, take the
|
| 12 |
+
log-softmax at the current position, apply the repetition penalty and
|
| 13 |
+
the optional no-repeat-ngram block, and pick the global top-K
|
| 14 |
+
candidates across (beam, vocab) pairs.
|
| 15 |
+
* Beams that emit ``[end]`` move into the finished list (their score is
|
| 16 |
+
already final at that point); the search ends when ``beam_width`` beams
|
| 17 |
+
have finished or we hit the max-length budget.
|
| 18 |
+
* Final ranking divides each finished beam's score by
|
| 19 |
+
``len(seq) ** length_penalty`` so the search isn't biased toward very
|
| 20 |
+
short sequences (the classic length problem in beam search).
|
| 21 |
+
|
| 22 |
+
This implementation is intentionally kept *callable* — the same predictor
|
| 23 |
+
class dispatches between :func:`generate_caption_greedy` and this one based
|
| 24 |
+
on ``decode_strategy``. Phase 3 model wrappers (BLIP, ViT-GPT2) can reuse
|
| 25 |
+
the same dispatcher.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import math
|
| 31 |
+
from dataclasses import dataclass, field
|
| 32 |
+
|
| 33 |
+
from captioning.preprocessing.caption import END_TOKEN, START_TOKEN
|
| 34 |
+
from captioning.preprocessing.tokenizer import CaptionTokenizer
|
| 35 |
+
|
| 36 |
+
_LOG_EPSILON = 1e-12
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class _Beam:
|
| 41 |
+
"""One partial caption under exploration."""
|
| 42 |
+
|
| 43 |
+
token_ids: list[int]
|
| 44 |
+
score: float
|
| 45 |
+
finished: bool = False
|
| 46 |
+
history: set[int] = field(default_factory=set)
|
| 47 |
+
|
| 48 |
+
def length(self) -> int:
|
| 49 |
+
"""Number of generated tokens (excludes the seed [start] token)."""
|
| 50 |
+
return max(len(self.token_ids) - 1, 1)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _apply_repetition_penalty(
|
| 54 |
+
log_probs,
|
| 55 |
+
history_ids: set[int],
|
| 56 |
+
penalty: float,
|
| 57 |
+
):
|
| 58 |
+
"""Subtract ``log(penalty)`` from already-seen tokens' log-probabilities.
|
| 59 |
+
|
| 60 |
+
HuggingFace's repetition_penalty (Keskar et al. 2019) divides logits by
|
| 61 |
+
``penalty`` (>1) for tokens already in the context. We work with log-
|
| 62 |
+
probabilities here, so the equivalent operation is to *subtract*
|
| 63 |
+
``log(penalty)`` for positive log-probabilities and add it for negative
|
| 64 |
+
ones — but log-probabilities are always non-positive, so we always make
|
| 65 |
+
seen tokens less likely. That is the correct direction (we want to
|
| 66 |
+
discourage repetition).
|
| 67 |
+
"""
|
| 68 |
+
if penalty <= 1.0 or not history_ids:
|
| 69 |
+
return log_probs
|
| 70 |
+
log_pen = math.log(penalty)
|
| 71 |
+
for tid in history_ids:
|
| 72 |
+
if 0 <= tid < log_probs.shape[-1]:
|
| 73 |
+
log_probs[tid] -= log_pen
|
| 74 |
+
return log_probs
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _blocks_repeat_ngram(seq: list[int], candidate: int, n: int) -> bool:
|
| 78 |
+
"""Return True if appending ``candidate`` would repeat an n-gram in ``seq``."""
|
| 79 |
+
if n <= 0 or len(seq) < n - 1:
|
| 80 |
+
return False
|
| 81 |
+
tail = tuple(seq[-(n - 1) :] + [candidate]) if n > 1 else (candidate,)
|
| 82 |
+
return any(tuple(seq[i : i + n]) == tail for i in range(len(seq) - n + 1))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def generate_caption_beam( # — beam search has many knobs by nature
|
| 86 |
+
model,
|
| 87 |
+
tokenizer: CaptionTokenizer,
|
| 88 |
+
image_tensor,
|
| 89 |
+
max_length: int,
|
| 90 |
+
*,
|
| 91 |
+
beam_width: int = 3,
|
| 92 |
+
length_penalty: float = 1.0,
|
| 93 |
+
repetition_penalty: float = 1.0,
|
| 94 |
+
no_repeat_ngram_size: int = 0,
|
| 95 |
+
) -> str:
|
| 96 |
+
"""Generate a caption using beam search with optional length / repetition control.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
model: An ``ImageCaptioningModel`` whose weights have been loaded.
|
| 100 |
+
tokenizer: Fitted :class:`CaptionTokenizer`.
|
| 101 |
+
image_tensor: ``[299, 299, 3]`` float tensor as produced by
|
| 102 |
+
``inference.load_image_from_path``.
|
| 103 |
+
max_length: Same budget as greedy (``config.model.max_length``); the
|
| 104 |
+
search stops at the first of (all beams finished, length exhausted).
|
| 105 |
+
beam_width: Number of parallel hypotheses. ``1`` reduces to greedy.
|
| 106 |
+
length_penalty: GNMT-style penalty exponent. ``score / len ** alpha``.
|
| 107 |
+
``0.0`` disables it; ``0.6-1.0`` is the common range. Higher values
|
| 108 |
+
favour longer captions.
|
| 109 |
+
repetition_penalty: HuggingFace's CTRL-style penalty. ``1.0`` disables
|
| 110 |
+
it; ``>1.0`` penalises tokens already in the partial caption.
|
| 111 |
+
no_repeat_ngram_size: If ``> 0``, forbids emitting any token that
|
| 112 |
+
would complete an n-gram already present in the partial caption.
|
| 113 |
+
``3`` is a common choice for captioning.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
The best-scoring caption (sentinels stripped, same convention as
|
| 117 |
+
:func:`generate_caption_greedy`).
|
| 118 |
+
"""
|
| 119 |
+
import numpy as np
|
| 120 |
+
import tensorflow as tf
|
| 121 |
+
|
| 122 |
+
# 1. Encode the image once. Beams share the encoded features.
|
| 123 |
+
img = tf.expand_dims(image_tensor, axis=0)
|
| 124 |
+
img_embed = model.cnn_model(img)
|
| 125 |
+
img_encoded = model.encoder(img_embed, training=False)
|
| 126 |
+
|
| 127 |
+
start_id = tokenizer.word_to_id(START_TOKEN)
|
| 128 |
+
end_id = tokenizer.word_to_id(END_TOKEN)
|
| 129 |
+
|
| 130 |
+
# 2. Initialise a single seed beam containing only the [start] token.
|
| 131 |
+
beams: list[_Beam] = [_Beam(token_ids=[start_id], score=0.0, history={start_id})]
|
| 132 |
+
finished: list[_Beam] = []
|
| 133 |
+
|
| 134 |
+
decode_steps = max_length - 1 # decoder is fed sequences of length max_length-1
|
| 135 |
+
|
| 136 |
+
for step in range(decode_steps):
|
| 137 |
+
if not beams:
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
# 3. Batch every active beam into a single decoder forward pass.
|
| 141 |
+
token_batch = np.zeros((len(beams), decode_steps), dtype=np.int64)
|
| 142 |
+
for i, beam in enumerate(beams):
|
| 143 |
+
seq = beam.token_ids[:decode_steps]
|
| 144 |
+
token_batch[i, : len(seq)] = seq
|
| 145 |
+
|
| 146 |
+
token_tensor = tf.convert_to_tensor(token_batch)
|
| 147 |
+
mask = tf.cast(token_tensor != 0, tf.int32)
|
| 148 |
+
# Encoded features must be broadcast to match the beam batch dimension.
|
| 149 |
+
encoded_batch = tf.repeat(img_encoded, repeats=len(beams), axis=0)
|
| 150 |
+
preds = model.decoder(token_tensor, encoded_batch, training=False, mask=mask)
|
| 151 |
+
# preds is [B, T, V]; we read position `step` for each beam.
|
| 152 |
+
step_probs = preds.numpy()[:, step, :]
|
| 153 |
+
step_log_probs = np.log(step_probs + _LOG_EPSILON)
|
| 154 |
+
|
| 155 |
+
# 4. Expand every beam, then keep the global top-K.
|
| 156 |
+
candidates: list[_Beam] = []
|
| 157 |
+
vocab_size = step_log_probs.shape[-1]
|
| 158 |
+
for i, beam in enumerate(beams):
|
| 159 |
+
lp = step_log_probs[i].copy()
|
| 160 |
+
lp = _apply_repetition_penalty(lp, beam.history, repetition_penalty)
|
| 161 |
+
|
| 162 |
+
# Pick a wider candidate pool than beam_width per beam — when most
|
| 163 |
+
# beams want the same token, expansion needs slack to remain diverse.
|
| 164 |
+
pool = min(beam_width * 2, vocab_size)
|
| 165 |
+
top_ids = np.argpartition(-lp, pool - 1)[:pool]
|
| 166 |
+
top_ids = top_ids[np.argsort(-lp[top_ids])]
|
| 167 |
+
|
| 168 |
+
for tid in top_ids:
|
| 169 |
+
tid_int = int(tid)
|
| 170 |
+
if no_repeat_ngram_size > 0 and _blocks_repeat_ngram(
|
| 171 |
+
beam.token_ids, tid_int, no_repeat_ngram_size
|
| 172 |
+
):
|
| 173 |
+
continue
|
| 174 |
+
new_seq = [*beam.token_ids, tid_int]
|
| 175 |
+
new_score = beam.score + float(lp[tid_int])
|
| 176 |
+
new_history = beam.history | {tid_int}
|
| 177 |
+
candidates.append(
|
| 178 |
+
_Beam(
|
| 179 |
+
token_ids=new_seq,
|
| 180 |
+
score=new_score,
|
| 181 |
+
finished=(tid_int == end_id),
|
| 182 |
+
history=new_history,
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# 5. Sort candidates by score and keep the top ``beam_width`` actives.
|
| 187 |
+
candidates.sort(key=lambda b: b.score, reverse=True)
|
| 188 |
+
next_beams: list[_Beam] = []
|
| 189 |
+
for cand in candidates:
|
| 190 |
+
if cand.finished:
|
| 191 |
+
finished.append(cand)
|
| 192 |
+
continue
|
| 193 |
+
next_beams.append(cand)
|
| 194 |
+
if len(next_beams) >= beam_width:
|
| 195 |
+
break
|
| 196 |
+
beams = next_beams
|
| 197 |
+
|
| 198 |
+
# 6. Early termination — we already have enough finished beams and
|
| 199 |
+
# none of the active ones can beat the best finished score (their
|
| 200 |
+
# best-case future log-prob is 0, so length-normalised score won't
|
| 201 |
+
# beat the current top).
|
| 202 |
+
if len(finished) >= beam_width and beams:
|
| 203 |
+
best_finished = max(_length_normalised(b, length_penalty) for b in finished)
|
| 204 |
+
best_active_upper_bound = max(_length_normalised(b, length_penalty) for b in beams)
|
| 205 |
+
if best_active_upper_bound <= best_finished:
|
| 206 |
+
break
|
| 207 |
+
|
| 208 |
+
# 7. Anything still active at the budget cap counts as finished.
|
| 209 |
+
finished.extend(beams)
|
| 210 |
+
if not finished:
|
| 211 |
+
return ""
|
| 212 |
+
|
| 213 |
+
finished.sort(key=lambda b: _length_normalised(b, length_penalty), reverse=True)
|
| 214 |
+
best = finished[0]
|
| 215 |
+
return _detokenize(best.token_ids, tokenizer, end_id)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _length_normalised(beam: _Beam, alpha: float) -> float:
|
| 219 |
+
"""Apply length penalty to a beam score (higher == better)."""
|
| 220 |
+
if alpha == 0.0:
|
| 221 |
+
return beam.score
|
| 222 |
+
return beam.score / (beam.length() ** alpha)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _detokenize(
|
| 226 |
+
token_ids: list[int],
|
| 227 |
+
tokenizer: CaptionTokenizer,
|
| 228 |
+
end_id: int,
|
| 229 |
+
) -> str:
|
| 230 |
+
"""Convert beam token ids back to a clean caption string."""
|
| 231 |
+
words: list[str] = []
|
| 232 |
+
for tid in token_ids:
|
| 233 |
+
if tid == end_id:
|
| 234 |
+
break
|
| 235 |
+
word = tokenizer.decode_id(tid)
|
| 236 |
+
# Skip [start], padding, and OOV ids that decode to empty strings.
|
| 237 |
+
if word in {"", START_TOKEN, END_TOKEN, "[UNK]"}:
|
| 238 |
+
continue
|
| 239 |
+
words.append(word)
|
| 240 |
+
return " ".join(words)
|
src/captioning/inference/predictor.py
CHANGED
|
@@ -5,8 +5,9 @@ Why a class around the existing functions:
|
|
| 5 |
model across every request. A predictor object is the natural home for
|
| 6 |
"loaded model + loaded tokenizer + decoded config".
|
| 7 |
* Tests can construct one with stub objects without monkey-patching globals.
|
| 8 |
-
*
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
Construction is *not* the same as readiness: ``CaptionPredictor.warmup()``
|
| 12 |
runs one inference on a dummy tensor so the first real request doesn't pay
|
|
@@ -19,6 +20,7 @@ from pathlib import Path
|
|
| 19 |
from typing import Literal
|
| 20 |
|
| 21 |
from captioning.config.schema import AppConfig
|
|
|
|
| 22 |
from captioning.inference.greedy import generate_caption_greedy
|
| 23 |
from captioning.inference.image_loader import load_image_from_path
|
| 24 |
from captioning.preprocessing.tokenizer import CaptionTokenizer
|
|
@@ -26,6 +28,8 @@ from captioning.utils.logging import get_logger
|
|
| 26 |
|
| 27 |
log = get_logger(__name__)
|
| 28 |
|
|
|
|
|
|
|
| 29 |
|
| 30 |
class CaptionPredictor:
|
| 31 |
"""Thin wrapper exposing ``predict_path`` / ``predict_tensor`` / ``warmup``."""
|
|
@@ -36,24 +40,41 @@ class CaptionPredictor:
|
|
| 36 |
tokenizer: CaptionTokenizer,
|
| 37 |
config: AppConfig,
|
| 38 |
*,
|
| 39 |
-
decode_strategy:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
) -> None:
|
| 41 |
"""Args:
|
| 42 |
model: Loaded ``ImageCaptioningModel``. Caller is responsible for
|
| 43 |
having called ``model.load_weights(...)`` already.
|
| 44 |
tokenizer: Fitted ``CaptionTokenizer``.
|
| 45 |
config: Validated ``AppConfig`` — ``model.max_length`` is consumed.
|
| 46 |
-
decode_strategy:
|
| 47 |
-
``"beam"``
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
"""
|
| 49 |
-
if decode_strategy
|
| 50 |
-
raise
|
| 51 |
-
|
| 52 |
-
)
|
| 53 |
self.model = model
|
| 54 |
self.tokenizer = tokenizer
|
| 55 |
self.config = config
|
| 56 |
-
self.decode_strategy = decode_strategy
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
@classmethod
|
| 59 |
def from_artifacts(
|
|
@@ -61,17 +82,18 @@ class CaptionPredictor:
|
|
| 61 |
weights_path: str | Path,
|
| 62 |
tokenizer_dir: str | Path,
|
| 63 |
config: AppConfig,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
) -> CaptionPredictor:
|
| 65 |
"""Load weights and tokenizer from disk and return a ready predictor.
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
config: Validated ``AppConfig``. ``model.max_length`` and
|
| 71 |
-
``model.vocabulary_size`` must match the trained weights.
|
| 72 |
-
|
| 73 |
-
Returns:
|
| 74 |
-
A ``CaptionPredictor`` ready for inference.
|
| 75 |
"""
|
| 76 |
from captioning.models.factory import build_caption_model
|
| 77 |
|
|
@@ -86,19 +108,56 @@ class CaptionPredictor:
|
|
| 86 |
cls._dummy_pass(model, config)
|
| 87 |
model.load_weights(str(weights_path))
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
def warmup(self) -> None:
|
| 93 |
"""Run one dummy inference so the first real request is fast."""
|
| 94 |
import tensorflow as tf
|
| 95 |
|
| 96 |
dummy = tf.zeros((299, 299, 3), dtype=tf.float32)
|
| 97 |
-
_ =
|
| 98 |
-
log.info("predictor_warmed_up")
|
| 99 |
|
| 100 |
def predict_tensor(self, image_tensor) -> str:
|
| 101 |
"""Generate a caption from an already-preprocessed image tensor."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
return generate_caption_greedy(
|
| 103 |
self.model,
|
| 104 |
self.tokenizer,
|
|
|
|
| 5 |
model across every request. A predictor object is the natural home for
|
| 6 |
"loaded model + loaded tokenizer + decoded config".
|
| 7 |
* Tests can construct one with stub objects without monkey-patching globals.
|
| 8 |
+
* Multiple decode strategies (greedy, beam) live behind the same
|
| 9 |
+
``predict_tensor`` / ``predict_path`` API — callers do not need to know
|
| 10 |
+
which one is active.
|
| 11 |
|
| 12 |
Construction is *not* the same as readiness: ``CaptionPredictor.warmup()``
|
| 13 |
runs one inference on a dummy tensor so the first real request doesn't pay
|
|
|
|
| 20 |
from typing import Literal
|
| 21 |
|
| 22 |
from captioning.config.schema import AppConfig
|
| 23 |
+
from captioning.inference.beam import generate_caption_beam
|
| 24 |
from captioning.inference.greedy import generate_caption_greedy
|
| 25 |
from captioning.inference.image_loader import load_image_from_path
|
| 26 |
from captioning.preprocessing.tokenizer import CaptionTokenizer
|
|
|
|
| 28 |
|
| 29 |
log = get_logger(__name__)
|
| 30 |
|
| 31 |
+
DecodeStrategy = Literal["greedy", "beam"]
|
| 32 |
+
|
| 33 |
|
| 34 |
class CaptionPredictor:
|
| 35 |
"""Thin wrapper exposing ``predict_path`` / ``predict_tensor`` / ``warmup``."""
|
|
|
|
| 40 |
tokenizer: CaptionTokenizer,
|
| 41 |
config: AppConfig,
|
| 42 |
*,
|
| 43 |
+
decode_strategy: DecodeStrategy = "greedy",
|
| 44 |
+
beam_width: int = 3,
|
| 45 |
+
length_penalty: float = 1.0,
|
| 46 |
+
repetition_penalty: float = 1.0,
|
| 47 |
+
no_repeat_ngram_size: int = 0,
|
| 48 |
) -> None:
|
| 49 |
"""Args:
|
| 50 |
model: Loaded ``ImageCaptioningModel``. Caller is responsible for
|
| 51 |
having called ``model.load_weights(...)`` already.
|
| 52 |
tokenizer: Fitted ``CaptionTokenizer``.
|
| 53 |
config: Validated ``AppConfig`` — ``model.max_length`` is consumed.
|
| 54 |
+
decode_strategy: ``"greedy"`` (argmax per step, byte-for-byte parity
|
| 55 |
+
with the IEEE notebook) or ``"beam"`` (beam search with length
|
| 56 |
+
and repetition controls).
|
| 57 |
+
beam_width: Beam width when ``decode_strategy == "beam"``. Ignored
|
| 58 |
+
for greedy.
|
| 59 |
+
length_penalty: GNMT length penalty; ``0.0`` disables, ``0.6-1.0`` is
|
| 60 |
+
the common range.
|
| 61 |
+
repetition_penalty: HF-style multiplicative penalty on already-seen
|
| 62 |
+
tokens; ``1.0`` disables.
|
| 63 |
+
no_repeat_ngram_size: If > 0, blocks any token that would repeat an
|
| 64 |
+
n-gram already in the partial caption.
|
| 65 |
"""
|
| 66 |
+
if decode_strategy not in {"greedy", "beam"}:
|
| 67 |
+
raise ValueError(f"decode_strategy must be 'greedy' or 'beam', got {decode_strategy!r}")
|
| 68 |
+
if beam_width < 1:
|
| 69 |
+
raise ValueError(f"beam_width must be >= 1, got {beam_width}")
|
| 70 |
self.model = model
|
| 71 |
self.tokenizer = tokenizer
|
| 72 |
self.config = config
|
| 73 |
+
self.decode_strategy: DecodeStrategy = decode_strategy
|
| 74 |
+
self.beam_width = beam_width
|
| 75 |
+
self.length_penalty = length_penalty
|
| 76 |
+
self.repetition_penalty = repetition_penalty
|
| 77 |
+
self.no_repeat_ngram_size = no_repeat_ngram_size
|
| 78 |
|
| 79 |
@classmethod
|
| 80 |
def from_artifacts(
|
|
|
|
| 82 |
weights_path: str | Path,
|
| 83 |
tokenizer_dir: str | Path,
|
| 84 |
config: AppConfig,
|
| 85 |
+
*,
|
| 86 |
+
decode_strategy: DecodeStrategy | None = None,
|
| 87 |
+
beam_width: int | None = None,
|
| 88 |
+
length_penalty: float | None = None,
|
| 89 |
+
repetition_penalty: float | None = None,
|
| 90 |
+
no_repeat_ngram_size: int | None = None,
|
| 91 |
) -> CaptionPredictor:
|
| 92 |
"""Load weights and tokenizer from disk and return a ready predictor.
|
| 93 |
|
| 94 |
+
Decoding knobs fall back to :class:`ServeConfig` defaults when not
|
| 95 |
+
passed explicitly — keeping CLI flags overridable while still letting
|
| 96 |
+
deploy-time YAML drive the production behaviour.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
"""
|
| 98 |
from captioning.models.factory import build_caption_model
|
| 99 |
|
|
|
|
| 108 |
cls._dummy_pass(model, config)
|
| 109 |
model.load_weights(str(weights_path))
|
| 110 |
|
| 111 |
+
resolved_strategy: DecodeStrategy = (
|
| 112 |
+
decode_strategy or config.serve.decode_strategy # type: ignore[assignment]
|
| 113 |
+
)
|
| 114 |
+
log.info(
|
| 115 |
+
"predictor_loaded",
|
| 116 |
+
weights=str(weights_path),
|
| 117 |
+
decode_strategy=resolved_strategy,
|
| 118 |
+
)
|
| 119 |
+
return cls(
|
| 120 |
+
model=model,
|
| 121 |
+
tokenizer=tokenizer,
|
| 122 |
+
config=config,
|
| 123 |
+
decode_strategy=resolved_strategy,
|
| 124 |
+
beam_width=beam_width if beam_width is not None else config.serve.beam_width,
|
| 125 |
+
length_penalty=(
|
| 126 |
+
length_penalty if length_penalty is not None else config.serve.length_penalty
|
| 127 |
+
),
|
| 128 |
+
repetition_penalty=(
|
| 129 |
+
repetition_penalty
|
| 130 |
+
if repetition_penalty is not None
|
| 131 |
+
else config.serve.repetition_penalty
|
| 132 |
+
),
|
| 133 |
+
no_repeat_ngram_size=(
|
| 134 |
+
no_repeat_ngram_size
|
| 135 |
+
if no_repeat_ngram_size is not None
|
| 136 |
+
else config.serve.no_repeat_ngram_size
|
| 137 |
+
),
|
| 138 |
+
)
|
| 139 |
|
| 140 |
def warmup(self) -> None:
|
| 141 |
"""Run one dummy inference so the first real request is fast."""
|
| 142 |
import tensorflow as tf
|
| 143 |
|
| 144 |
dummy = tf.zeros((299, 299, 3), dtype=tf.float32)
|
| 145 |
+
_ = self.predict_tensor(dummy)
|
| 146 |
+
log.info("predictor_warmed_up", decode_strategy=self.decode_strategy)
|
| 147 |
|
| 148 |
def predict_tensor(self, image_tensor) -> str:
|
| 149 |
"""Generate a caption from an already-preprocessed image tensor."""
|
| 150 |
+
if self.decode_strategy == "beam":
|
| 151 |
+
return generate_caption_beam(
|
| 152 |
+
self.model,
|
| 153 |
+
self.tokenizer,
|
| 154 |
+
image_tensor,
|
| 155 |
+
self.config.model.max_length,
|
| 156 |
+
beam_width=self.beam_width,
|
| 157 |
+
length_penalty=self.length_penalty,
|
| 158 |
+
repetition_penalty=self.repetition_penalty,
|
| 159 |
+
no_repeat_ngram_size=self.no_repeat_ngram_size,
|
| 160 |
+
)
|
| 161 |
return generate_caption_greedy(
|
| 162 |
self.model,
|
| 163 |
self.tokenizer,
|
src/captioning/models/captioning_model.py
CHANGED
|
@@ -1,16 +1,30 @@
|
|
| 1 |
"""``ImageCaptioningModel`` — top-level Keras model with custom train/test step.
|
| 2 |
|
| 3 |
-
Mirrors notebook cell 20 verbatim. The model owns its own loss &
|
| 4 |
-
trackers (rather than using compile-time metrics) because the masked
|
| 5 |
arithmetic in ``calculate_loss`` / ``calculate_accuracy`` depends on the
|
| 6 |
caption padding mask, which Keras's standard metric API can't see.
|
| 7 |
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
The notebook's ``compute_loss_and_acc`` hardcodes ``training=True`` on
|
| 10 |
both the encoder and decoder calls, even when invoked from ``test_step``.
|
| 11 |
That means dropout is active during validation in the IEEE results.
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -22,12 +36,23 @@ def _build_captioning_model_class():
|
|
| 22 |
class ImageCaptioningModel(tf.keras.Model):
|
| 23 |
"""Stitches CNN encoder + Transformer encoder + Transformer decoder."""
|
| 24 |
|
| 25 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
super().__init__()
|
| 27 |
self.cnn_model = cnn_model
|
| 28 |
self.encoder = encoder
|
| 29 |
self.decoder = decoder
|
| 30 |
self.image_aug = image_aug
|
|
|
|
|
|
|
| 31 |
self.loss_tracker = tf.keras.metrics.Mean(name="loss")
|
| 32 |
self.acc_tracker = tf.keras.metrics.Mean(name="accuracy")
|
| 33 |
|
|
@@ -49,17 +74,20 @@ def _build_captioning_model_class():
|
|
| 49 |
# --- shared loss/acc step (parity quirk: training=True hardcoded) --
|
| 50 |
|
| 51 |
def compute_loss_and_acc(self, img_embed, captions, training=True):
|
| 52 |
-
#
|
| 53 |
-
#
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
y_input = captions[:, :-1]
|
| 57 |
y_true = captions[:, 1:]
|
| 58 |
mask = y_true != 0
|
| 59 |
-
y_pred = self.decoder(y_input, encoder_output, training=
|
| 60 |
loss = self.calculate_loss(y_true, y_pred, mask)
|
| 61 |
acc = self.calculate_accuracy(y_true, y_pred, mask)
|
| 62 |
-
|
|
|
|
| 63 |
|
| 64 |
# --- Keras hooks ---------------------------------------------------
|
| 65 |
|
|
@@ -70,22 +98,30 @@ def _build_captioning_model_class():
|
|
| 70 |
img_embed = self.cnn_model(imgs)
|
| 71 |
|
| 72 |
with tf.GradientTape() as tape:
|
| 73 |
-
loss, acc = self.compute_loss_and_acc(img_embed, captions)
|
| 74 |
|
| 75 |
train_vars = self.encoder.trainable_variables + self.decoder.trainable_variables
|
| 76 |
grads = tape.gradient(loss, train_vars)
|
| 77 |
self.optimizer.apply_gradients(zip(grads, train_vars, strict=False))
|
| 78 |
self.loss_tracker.update_state(loss)
|
| 79 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
return {"loss": self.loss_tracker.result(), "acc": self.acc_tracker.result()}
|
| 82 |
|
| 83 |
def test_step(self, batch):
|
| 84 |
imgs, captions = batch
|
| 85 |
img_embed = self.cnn_model(imgs)
|
| 86 |
-
loss, acc = self.compute_loss_and_acc(img_embed, captions, training=False)
|
| 87 |
self.loss_tracker.update_state(loss)
|
| 88 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 89 |
return {"loss": self.loss_tracker.result(), "acc": self.acc_tracker.result()}
|
| 90 |
|
| 91 |
@property
|
|
|
|
| 1 |
"""``ImageCaptioningModel`` — top-level Keras model with custom train/test step.
|
| 2 |
|
| 3 |
+
Mirrors notebook cell 20 verbatim by default. The model owns its own loss &
|
| 4 |
+
accuracy trackers (rather than using compile-time metrics) because the masked
|
| 5 |
arithmetic in ``calculate_loss`` / ``calculate_accuracy`` depends on the
|
| 6 |
caption padding mask, which Keras's standard metric API can't see.
|
| 7 |
|
| 8 |
+
Two opt-in fixes layered on top of the baseline, both controlled by the
|
| 9 |
+
constructor (defaults preserve the IEEE notebook quirk exactly):
|
| 10 |
+
|
| 11 |
+
* ``honour_training_flag_in_test_step``
|
| 12 |
The notebook's ``compute_loss_and_acc`` hardcodes ``training=True`` on
|
| 13 |
both the encoder and decoder calls, even when invoked from ``test_step``.
|
| 14 |
That means dropout is active during validation in the IEEE results.
|
| 15 |
+
Setting this flag to True restores the conventional behaviour — dropout
|
| 16 |
+
off in test_step — so val_loss reflects deployment behaviour and early
|
| 17 |
+
stopping fires on a clean signal.
|
| 18 |
+
|
| 19 |
+
* ``correct_masked_accuracy``
|
| 20 |
+
The baseline's accuracy tracker is a ``Mean`` of per-batch ratios, which
|
| 21 |
+
weights small batches the same as large ones. Setting this flag to True
|
| 22 |
+
feeds the per-batch token count as ``sample_weight`` so the reported
|
| 23 |
+
metric is a true global token-level masked accuracy.
|
| 24 |
+
|
| 25 |
+
Both knobs are off by default to keep numeric parity with the notebook; the
|
| 26 |
+
trainer flips them on automatically when the user opts in via
|
| 27 |
+
``train.honour_training_flag_in_test_step``.
|
| 28 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
|
|
|
| 36 |
class ImageCaptioningModel(tf.keras.Model):
|
| 37 |
"""Stitches CNN encoder + Transformer encoder + Transformer decoder."""
|
| 38 |
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
cnn_model,
|
| 42 |
+
encoder,
|
| 43 |
+
decoder,
|
| 44 |
+
image_aug=None,
|
| 45 |
+
*,
|
| 46 |
+
honour_training_flag_in_test_step: bool = False,
|
| 47 |
+
correct_masked_accuracy: bool = False,
|
| 48 |
+
) -> None:
|
| 49 |
super().__init__()
|
| 50 |
self.cnn_model = cnn_model
|
| 51 |
self.encoder = encoder
|
| 52 |
self.decoder = decoder
|
| 53 |
self.image_aug = image_aug
|
| 54 |
+
self.honour_training_flag_in_test_step = honour_training_flag_in_test_step
|
| 55 |
+
self.correct_masked_accuracy = correct_masked_accuracy
|
| 56 |
self.loss_tracker = tf.keras.metrics.Mean(name="loss")
|
| 57 |
self.acc_tracker = tf.keras.metrics.Mean(name="accuracy")
|
| 58 |
|
|
|
|
| 74 |
# --- shared loss/acc step (parity quirk: training=True hardcoded) --
|
| 75 |
|
| 76 |
def compute_loss_and_acc(self, img_embed, captions, training=True):
|
| 77 |
+
# The IEEE notebook hardcoded `training=True` on encoder/decoder
|
| 78 |
+
# calls even from `test_step`, which means dropout is on during
|
| 79 |
+
# validation. Honouring the flag (opt-in) restores the standard
|
| 80 |
+
# behaviour and gives a cleaner val_loss signal.
|
| 81 |
+
effective_training = bool(training) if self.honour_training_flag_in_test_step else True
|
| 82 |
+
encoder_output = self.encoder(img_embed, training=effective_training)
|
| 83 |
y_input = captions[:, :-1]
|
| 84 |
y_true = captions[:, 1:]
|
| 85 |
mask = y_true != 0
|
| 86 |
+
y_pred = self.decoder(y_input, encoder_output, training=effective_training, mask=mask)
|
| 87 |
loss = self.calculate_loss(y_true, y_pred, mask)
|
| 88 |
acc = self.calculate_accuracy(y_true, y_pred, mask)
|
| 89 |
+
mask_count = tf.reduce_sum(tf.cast(mask, tf.float32))
|
| 90 |
+
return loss, acc, mask_count
|
| 91 |
|
| 92 |
# --- Keras hooks ---------------------------------------------------
|
| 93 |
|
|
|
|
| 98 |
img_embed = self.cnn_model(imgs)
|
| 99 |
|
| 100 |
with tf.GradientTape() as tape:
|
| 101 |
+
loss, acc, mask_count = self.compute_loss_and_acc(img_embed, captions)
|
| 102 |
|
| 103 |
train_vars = self.encoder.trainable_variables + self.decoder.trainable_variables
|
| 104 |
grads = tape.gradient(loss, train_vars)
|
| 105 |
self.optimizer.apply_gradients(zip(grads, train_vars, strict=False))
|
| 106 |
self.loss_tracker.update_state(loss)
|
| 107 |
+
if self.correct_masked_accuracy:
|
| 108 |
+
# Weight per-batch accuracy by token count so the epoch
|
| 109 |
+
# average is a true global accuracy, not a mean of ratios.
|
| 110 |
+
self.acc_tracker.update_state(acc, sample_weight=mask_count)
|
| 111 |
+
else:
|
| 112 |
+
self.acc_tracker.update_state(acc)
|
| 113 |
|
| 114 |
return {"loss": self.loss_tracker.result(), "acc": self.acc_tracker.result()}
|
| 115 |
|
| 116 |
def test_step(self, batch):
|
| 117 |
imgs, captions = batch
|
| 118 |
img_embed = self.cnn_model(imgs)
|
| 119 |
+
loss, acc, mask_count = self.compute_loss_and_acc(img_embed, captions, training=False)
|
| 120 |
self.loss_tracker.update_state(loss)
|
| 121 |
+
if self.correct_masked_accuracy:
|
| 122 |
+
self.acc_tracker.update_state(acc, sample_weight=mask_count)
|
| 123 |
+
else:
|
| 124 |
+
self.acc_tracker.update_state(acc)
|
| 125 |
return {"loss": self.loss_tracker.result(), "acc": self.acc_tracker.result()}
|
| 126 |
|
| 127 |
@property
|
src/captioning/models/factory.py
CHANGED
|
@@ -63,4 +63,20 @@ def build_caption_model(
|
|
| 63 |
)
|
| 64 |
cnn = build_cnn_encoder()
|
| 65 |
aug = default_image_augmentation() if use_augmentation else None
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
| 64 |
cnn = build_cnn_encoder()
|
| 65 |
aug = default_image_augmentation() if use_augmentation else None
|
| 66 |
+
|
| 67 |
+
# ``honour_training_flag_in_test_step`` and ``correct_masked_accuracy``
|
| 68 |
+
# default to False so this factory keeps producing notebook-parity models
|
| 69 |
+
# unless the user opts in by flipping the corresponding YAML flag.
|
| 70 |
+
honour_flag = bool(config.train.honour_training_flag_in_test_step)
|
| 71 |
+
# The masked-accuracy correction is harmless under parity (it's a
|
| 72 |
+
# better-weighted average of the same per-batch numbers), so we tie it to
|
| 73 |
+
# the same opt-in flag rather than adding a separate one — keeps the
|
| 74 |
+
# YAML surface minimal.
|
| 75 |
+
return ImageCaptioningModel(
|
| 76 |
+
cnn_model=cnn,
|
| 77 |
+
encoder=encoder,
|
| 78 |
+
decoder=decoder,
|
| 79 |
+
image_aug=aug,
|
| 80 |
+
honour_training_flag_in_test_step=honour_flag,
|
| 81 |
+
correct_masked_accuracy=honour_flag,
|
| 82 |
+
)
|
src/captioning/preprocessing/tokenizer.py
CHANGED
|
@@ -116,6 +116,16 @@ class CaptionTokenizer:
|
|
| 116 |
word = self._idx2word(idx)
|
| 117 |
return word.numpy().decode("utf-8")
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# ---------------------------------------------------------- persistence ---
|
| 120 |
|
| 121 |
def save(self, directory: str | Path) -> None:
|
|
|
|
| 116 |
word = self._idx2word(idx)
|
| 117 |
return word.numpy().decode("utf-8")
|
| 118 |
|
| 119 |
+
def word_to_id(self, word: str) -> int:
|
| 120 |
+
"""Look up a single word's integer id, returning 1 (the OOV id) if absent.
|
| 121 |
+
|
| 122 |
+
Used by beam search to seed beams with the ``[start]`` token without
|
| 123 |
+
going through ``TextVectorization``'s padded-string path.
|
| 124 |
+
"""
|
| 125 |
+
self._require_fit()
|
| 126 |
+
assert self._word2idx is not None
|
| 127 |
+
return int(self._word2idx(word).numpy())
|
| 128 |
+
|
| 129 |
# ---------------------------------------------------------- persistence ---
|
| 130 |
|
| 131 |
def save(self, directory: str | Path) -> None:
|
src/captioning/training/__init__.py
CHANGED
|
@@ -1,21 +1,31 @@
|
|
| 1 |
-
"""Training — losses, callbacks, and the trainer
|
| 2 |
|
| 3 |
The notebook computes loss + masked accuracy inside the model's ``train_step``;
|
| 4 |
we keep that structure for parity but expose the loss function and callbacks
|
| 5 |
-
as standalone modules so they can be unit-tested and reused
|
| 6 |
-
beam-search evaluators).
|
| 7 |
|
| 8 |
-
losses.py ``masked_sparse_categorical_crossentropy``
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
trainer.py ``Trainer.fit()`` — wraps compile + fit + history serialization
|
| 11 |
"""
|
| 12 |
|
| 13 |
from captioning.training.callbacks import default_callbacks
|
| 14 |
-
from captioning.training.losses import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from captioning.training.trainer import Trainer
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
"Trainer",
|
|
|
|
|
|
|
|
|
|
| 19 |
"default_callbacks",
|
|
|
|
| 20 |
"masked_sparse_categorical_crossentropy",
|
| 21 |
]
|
|
|
|
| 1 |
+
"""Training — losses, schedules, callbacks, and the trainer.
|
| 2 |
|
| 3 |
The notebook computes loss + masked accuracy inside the model's ``train_step``;
|
| 4 |
we keep that structure for parity but expose the loss function and callbacks
|
| 5 |
+
as standalone modules so they can be unit-tested and reused.
|
|
|
|
| 6 |
|
| 7 |
+
losses.py ``masked_sparse_categorical_crossentropy`` (baseline) +
|
| 8 |
+
``label_smoothed_crossentropy`` + ``build_loss``
|
| 9 |
+
schedules.py ``WarmupCosineDecay`` + ``build_learning_rate``
|
| 10 |
+
callbacks.py ``default_callbacks(config)`` — early stopping + checkpoint
|
| 11 |
trainer.py ``Trainer.fit()`` — wraps compile + fit + history serialization
|
| 12 |
"""
|
| 13 |
|
| 14 |
from captioning.training.callbacks import default_callbacks
|
| 15 |
+
from captioning.training.losses import (
|
| 16 |
+
build_loss,
|
| 17 |
+
label_smoothed_crossentropy,
|
| 18 |
+
masked_sparse_categorical_crossentropy,
|
| 19 |
+
)
|
| 20 |
+
from captioning.training.schedules import WarmupCosineDecay, build_learning_rate
|
| 21 |
from captioning.training.trainer import Trainer
|
| 22 |
|
| 23 |
__all__ = [
|
| 24 |
"Trainer",
|
| 25 |
+
"WarmupCosineDecay",
|
| 26 |
+
"build_learning_rate",
|
| 27 |
+
"build_loss",
|
| 28 |
"default_callbacks",
|
| 29 |
+
"label_smoothed_crossentropy",
|
| 30 |
"masked_sparse_categorical_crossentropy",
|
| 31 |
]
|
src/captioning/training/losses.py
CHANGED
|
@@ -8,8 +8,17 @@ Why ``reduction="none"``: the model's ``calculate_loss`` (cell 20) does the
|
|
| 8 |
reduction itself, multiplying by the padding mask before averaging. A built-in
|
| 9 |
reduction would average over the padded tokens too, biasing the loss.
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -25,3 +34,50 @@ def masked_sparse_categorical_crossentropy():
|
|
| 25 |
import tensorflow as tf
|
| 26 |
|
| 27 |
return tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False, reduction="none")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
reduction itself, multiplying by the padding mask before averaging. A built-in
|
| 9 |
reduction would average over the padded tokens too, biasing the loss.
|
| 10 |
|
| 11 |
+
For the stabilisation phase we also support label-smoothed cross-entropy.
|
| 12 |
+
Label smoothing replaces the one-hot target ``y_true`` with a mixture of the
|
| 13 |
+
true label and a uniform distribution over the vocabulary:
|
| 14 |
+
|
| 15 |
+
target = (1 - eps) * one_hot(y) + eps / vocab_size
|
| 16 |
+
|
| 17 |
+
The decoder's output is already softmaxed (`Dense(..., activation='softmax')`),
|
| 18 |
+
so the loss reduces to ``-sum(target * log(p), axis=-1)``. Smoothing
|
| 19 |
+
discourages the decoder from collapsing to a few high-probability tokens —
|
| 20 |
+
the most common failure mode of cross-entropy-trained captioners and a
|
| 21 |
+
likely root cause of the generic captions we're trying to fix.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
|
|
|
| 34 |
import tensorflow as tf
|
| 35 |
|
| 36 |
return tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False, reduction="none")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def label_smoothed_crossentropy(label_smoothing: float, vocab_size: int):
|
| 40 |
+
"""Per-token cross-entropy with uniform label smoothing.
|
| 41 |
+
|
| 42 |
+
Returned callable has the same signature as the sparse loss above
|
| 43 |
+
(``loss(y_true, y_pred) -> [B, T]``) so the model's masking machinery in
|
| 44 |
+
``ImageCaptioningModel.calculate_loss`` works unchanged.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
label_smoothing: Smoothing strength in ``[0, 1)``. ``0.0`` reduces to
|
| 48 |
+
the sparse-categorical baseline.
|
| 49 |
+
vocab_size: Size of the output distribution (matches the decoder's
|
| 50 |
+
final ``Dense`` units). Used to compute the uniform component.
|
| 51 |
+
"""
|
| 52 |
+
import tensorflow as tf
|
| 53 |
+
|
| 54 |
+
if label_smoothing == 0.0:
|
| 55 |
+
return masked_sparse_categorical_crossentropy()
|
| 56 |
+
|
| 57 |
+
eps = float(label_smoothing)
|
| 58 |
+
log_eps = tf.constant(1e-12, dtype=tf.float32)
|
| 59 |
+
vocab = int(vocab_size)
|
| 60 |
+
uniform = eps / float(vocab)
|
| 61 |
+
|
| 62 |
+
def loss_fn(y_true, y_pred):
|
| 63 |
+
# y_true: [B, T] int ids; y_pred: [B, T, V] softmax probabilities.
|
| 64 |
+
y_pred = tf.cast(y_pred, tf.float32)
|
| 65 |
+
one_hot = tf.one_hot(tf.cast(y_true, tf.int32), depth=vocab, dtype=tf.float32)
|
| 66 |
+
target = one_hot * (1.0 - eps) + uniform
|
| 67 |
+
# Standard cross-entropy on softmax probs. Add log_eps to avoid log(0)
|
| 68 |
+
# on padding columns where the model would otherwise emit 0.
|
| 69 |
+
return -tf.reduce_sum(target * tf.math.log(y_pred + log_eps), axis=-1)
|
| 70 |
+
|
| 71 |
+
return loss_fn
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_loss(label_smoothing: float, vocab_size: int):
|
| 75 |
+
"""Pick the right loss based on ``label_smoothing``.
|
| 76 |
+
|
| 77 |
+
Convenience wrapper so the trainer never has to branch on the smoothing
|
| 78 |
+
value itself — it always calls ``build_loss(...)`` and the right
|
| 79 |
+
implementation comes back.
|
| 80 |
+
"""
|
| 81 |
+
if label_smoothing == 0.0:
|
| 82 |
+
return masked_sparse_categorical_crossentropy()
|
| 83 |
+
return label_smoothed_crossentropy(label_smoothing, vocab_size)
|
src/captioning/training/schedules.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Learning-rate schedules.
|
| 2 |
+
|
| 3 |
+
The baseline pipeline uses a constant Adam LR (matching the IEEE notebook),
|
| 4 |
+
which is fine for short fine-tuning runs but tends to leave Transformer
|
| 5 |
+
captioners in a mediocre local minimum: the LR is too aggressive at start
|
| 6 |
+
(decoder weights are still random) and too high near convergence (the model
|
| 7 |
+
oscillates around a flat basin instead of settling).
|
| 8 |
+
|
| 9 |
+
The fix the literature converged on is linear warmup followed by cosine
|
| 10 |
+
decay (the GPT/BERT/ViT recipe):
|
| 11 |
+
|
| 12 |
+
lr(step) = peak_lr * step / warmup_steps if step < warmup
|
| 13 |
+
lr(step) = min_lr + (peak_lr - min_lr) * 0.5 *
|
| 14 |
+
(1 + cos(pi * (step - warmup) / decay_steps)) otherwise
|
| 15 |
+
|
| 16 |
+
We implement it as a ``LearningRateSchedule`` so the optimizer can call it
|
| 17 |
+
per-step automatically, without us having to track step counts manually.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _build_warmup_cosine_class():
|
| 24 |
+
"""Lazy-build the schedule class to keep TF off the package import path."""
|
| 25 |
+
import tensorflow as tf
|
| 26 |
+
|
| 27 |
+
class WarmupCosineDecay(tf.keras.optimizers.schedules.LearningRateSchedule):
|
| 28 |
+
"""Linear warmup followed by cosine decay to ``min_learning_rate``.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
peak_learning_rate: Maximum LR reached at the end of warmup.
|
| 32 |
+
warmup_steps: Number of steps to linearly ramp from 0 to peak.
|
| 33 |
+
``0`` disables warmup (starts directly at ``peak``).
|
| 34 |
+
decay_steps: Number of steps over which to cosine-decay from
|
| 35 |
+
``peak`` to ``min_learning_rate`` after warmup.
|
| 36 |
+
min_learning_rate: Floor reached at the end of decay (and held
|
| 37 |
+
thereafter).
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
peak_learning_rate: float,
|
| 43 |
+
warmup_steps: int,
|
| 44 |
+
decay_steps: int,
|
| 45 |
+
min_learning_rate: float = 0.0,
|
| 46 |
+
) -> None:
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.peak_learning_rate = float(peak_learning_rate)
|
| 49 |
+
self.warmup_steps = int(warmup_steps)
|
| 50 |
+
self.decay_steps = max(int(decay_steps), 1)
|
| 51 |
+
self.min_learning_rate = float(min_learning_rate)
|
| 52 |
+
|
| 53 |
+
def __call__(self, step):
|
| 54 |
+
step = tf.cast(step, tf.float32)
|
| 55 |
+
peak = tf.constant(self.peak_learning_rate, dtype=tf.float32)
|
| 56 |
+
floor = tf.constant(self.min_learning_rate, dtype=tf.float32)
|
| 57 |
+
warmup = tf.constant(float(self.warmup_steps), dtype=tf.float32)
|
| 58 |
+
decay = tf.constant(float(self.decay_steps), dtype=tf.float32)
|
| 59 |
+
|
| 60 |
+
# During warmup: linear ramp 0 -> peak.
|
| 61 |
+
warmup_lr = peak * tf.math.divide_no_nan(step, warmup)
|
| 62 |
+
|
| 63 |
+
# After warmup: cosine decay peak -> floor over decay_steps.
|
| 64 |
+
progress = tf.minimum(1.0, tf.math.divide_no_nan(step - warmup, decay))
|
| 65 |
+
cosine = 0.5 * (1.0 + tf.cos(tf.constant(3.141592653589793) * progress))
|
| 66 |
+
decay_lr = floor + (peak - floor) * cosine
|
| 67 |
+
|
| 68 |
+
return tf.where(step < warmup, warmup_lr, decay_lr)
|
| 69 |
+
|
| 70 |
+
def get_config(self) -> dict[str, float | int]:
|
| 71 |
+
return {
|
| 72 |
+
"peak_learning_rate": self.peak_learning_rate,
|
| 73 |
+
"warmup_steps": self.warmup_steps,
|
| 74 |
+
"decay_steps": self.decay_steps,
|
| 75 |
+
"min_learning_rate": self.min_learning_rate,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return WarmupCosineDecay
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
WarmupCosineDecay = _build_warmup_cosine_class()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def build_learning_rate(
|
| 85 |
+
*,
|
| 86 |
+
schedule: str,
|
| 87 |
+
peak_learning_rate: float,
|
| 88 |
+
warmup_steps: int,
|
| 89 |
+
decay_steps: int,
|
| 90 |
+
min_learning_rate: float,
|
| 91 |
+
):
|
| 92 |
+
"""Return either a float (constant LR) or a :class:`WarmupCosineDecay`.
|
| 93 |
+
|
| 94 |
+
The optimizer treats a float as a fixed LR and a ``LearningRateSchedule``
|
| 95 |
+
as a per-step callable — we hide that asymmetry behind this factory so
|
| 96 |
+
the trainer only ever passes ``learning_rate=build_learning_rate(...)``.
|
| 97 |
+
"""
|
| 98 |
+
if schedule == "constant":
|
| 99 |
+
return peak_learning_rate
|
| 100 |
+
if schedule == "cosine":
|
| 101 |
+
return WarmupCosineDecay(
|
| 102 |
+
peak_learning_rate=peak_learning_rate,
|
| 103 |
+
warmup_steps=warmup_steps,
|
| 104 |
+
decay_steps=decay_steps,
|
| 105 |
+
min_learning_rate=min_learning_rate,
|
| 106 |
+
)
|
| 107 |
+
raise ValueError(f"unsupported lr_schedule: {schedule!r}")
|
src/captioning/training/trainer.py
CHANGED
|
@@ -6,8 +6,11 @@ Wraps notebook cells 22 and 23 in a class so:
|
|
| 6 |
* Phase 4 can replace the trainer with a CLI-driven main loop without
|
| 7 |
changing the notebook-equivalent behaviour.
|
| 8 |
|
| 9 |
-
The trainer
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -17,12 +20,31 @@ from pathlib import Path
|
|
| 17 |
|
| 18 |
from captioning.config.schema import AppConfig
|
| 19 |
from captioning.training.callbacks import default_callbacks
|
| 20 |
-
from captioning.training.losses import
|
|
|
|
| 21 |
from captioning.utils.logging import get_logger
|
| 22 |
|
| 23 |
log = get_logger(__name__)
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
class Trainer:
|
| 27 |
"""Thin orchestration layer around an ``ImageCaptioningModel``."""
|
| 28 |
|
|
@@ -35,16 +57,47 @@ class Trainer:
|
|
| 35 |
self.config = config
|
| 36 |
self._compiled = False
|
| 37 |
|
| 38 |
-
def compile(self) -> None:
|
| 39 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
import tensorflow as tf
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
self.model.compile(
|
| 43 |
-
optimizer=tf.keras.optimizers.Adam(learning_rate=
|
| 44 |
-
loss=
|
| 45 |
)
|
| 46 |
self._compiled = True
|
| 47 |
-
log.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def fit(
|
| 50 |
self,
|
|
@@ -68,7 +121,7 @@ class Trainer:
|
|
| 68 |
``history.history`` as a ``dict[str, list[float]]``.
|
| 69 |
"""
|
| 70 |
if not self._compiled:
|
| 71 |
-
self.compile()
|
| 72 |
|
| 73 |
callbacks = default_callbacks(self.config, output_dir=output_dir)
|
| 74 |
log.info("fit_start", epochs=self.config.train.epochs)
|
|
|
|
| 6 |
* Phase 4 can replace the trainer with a CLI-driven main loop without
|
| 7 |
changing the notebook-equivalent behaviour.
|
| 8 |
|
| 9 |
+
The trainer reads the optional training-stability fields off ``TrainConfig``
|
| 10 |
+
(``label_smoothing``, ``lr_schedule``, ``warmup_steps``, ...). With defaults
|
| 11 |
+
in place every existing config produces a byte-identical compile call to the
|
| 12 |
+
notebook; flipping one YAML flag opts a run into the modern recipe without
|
| 13 |
+
touching code.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
|
|
| 20 |
|
| 21 |
from captioning.config.schema import AppConfig
|
| 22 |
from captioning.training.callbacks import default_callbacks
|
| 23 |
+
from captioning.training.losses import build_loss
|
| 24 |
+
from captioning.training.schedules import build_learning_rate
|
| 25 |
from captioning.utils.logging import get_logger
|
| 26 |
|
| 27 |
log = get_logger(__name__)
|
| 28 |
|
| 29 |
|
| 30 |
+
def _infer_steps_per_epoch(dataset) -> int | None:
|
| 31 |
+
"""Best-effort cardinality probe for a ``tf.data.Dataset``.
|
| 32 |
+
|
| 33 |
+
Returns ``None`` when the dataset's cardinality is unknown or infinite.
|
| 34 |
+
Used only to derive ``cosine_decay_steps`` when the user didn't pin it
|
| 35 |
+
explicitly.
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
import tensorflow as tf
|
| 39 |
+
|
| 40 |
+
card = int(tf.data.experimental.cardinality(dataset).numpy())
|
| 41 |
+
except Exception: # — cardinality probing is best-effort
|
| 42 |
+
return None
|
| 43 |
+
if card in (-1, -2): # UNKNOWN, INFINITE
|
| 44 |
+
return None
|
| 45 |
+
return card
|
| 46 |
+
|
| 47 |
+
|
| 48 |
class Trainer:
|
| 49 |
"""Thin orchestration layer around an ``ImageCaptioningModel``."""
|
| 50 |
|
|
|
|
| 57 |
self.config = config
|
| 58 |
self._compiled = False
|
| 59 |
|
| 60 |
+
def compile(self, *, steps_per_epoch: int | None = None) -> None:
|
| 61 |
+
"""Build optimizer + loss from config and call ``model.compile``.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
steps_per_epoch: Used to derive ``cosine_decay_steps`` when the
|
| 65 |
+
config doesn't pin it explicitly. Passing ``None`` falls back
|
| 66 |
+
to the config value (or 1 if neither is set — degenerates to
|
| 67 |
+
immediate floor LR, but still a well-defined schedule).
|
| 68 |
+
"""
|
| 69 |
import tensorflow as tf
|
| 70 |
|
| 71 |
+
train = self.config.train
|
| 72 |
+
# Vocab size lives on the decoder's final Dense layer; pulling it here
|
| 73 |
+
# avoids threading the tokenizer through the trainer just for loss.
|
| 74 |
+
vocab_size = int(self.model.decoder.out.units)
|
| 75 |
+
loss = build_loss(train.label_smoothing, vocab_size)
|
| 76 |
+
|
| 77 |
+
cosine_steps = train.cosine_decay_steps or (
|
| 78 |
+
(steps_per_epoch or 1) * max(train.epochs - 0, 1)
|
| 79 |
+
)
|
| 80 |
+
learning_rate = build_learning_rate(
|
| 81 |
+
schedule=train.lr_schedule,
|
| 82 |
+
peak_learning_rate=train.learning_rate,
|
| 83 |
+
warmup_steps=train.warmup_steps,
|
| 84 |
+
decay_steps=cosine_steps,
|
| 85 |
+
min_learning_rate=train.min_learning_rate,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
self.model.compile(
|
| 89 |
+
optimizer=tf.keras.optimizers.Adam(learning_rate=learning_rate),
|
| 90 |
+
loss=loss,
|
| 91 |
)
|
| 92 |
self._compiled = True
|
| 93 |
+
log.info(
|
| 94 |
+
"model_compiled",
|
| 95 |
+
lr_schedule=train.lr_schedule,
|
| 96 |
+
peak_learning_rate=train.learning_rate,
|
| 97 |
+
warmup_steps=train.warmup_steps,
|
| 98 |
+
cosine_decay_steps=cosine_steps,
|
| 99 |
+
label_smoothing=train.label_smoothing,
|
| 100 |
+
)
|
| 101 |
|
| 102 |
def fit(
|
| 103 |
self,
|
|
|
|
| 121 |
``history.history`` as a ``dict[str, list[float]]``.
|
| 122 |
"""
|
| 123 |
if not self._compiled:
|
| 124 |
+
self.compile(steps_per_epoch=_infer_steps_per_epoch(train_dataset))
|
| 125 |
|
| 126 |
callbacks = default_callbacks(self.config, output_dir=output_dir)
|
| 127 |
log.info("fit_start", epochs=self.config.train.epochs)
|
tests/unit/test_beam_decoder.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Beam-search decoder unit tests.
|
| 2 |
+
|
| 3 |
+
The full TF decoder forward path is exercised by the parity audit and the
|
| 4 |
+
smoke test in ``scripts/predict.py``. Here we test the *algorithmic* pieces
|
| 5 |
+
of beam search in isolation:
|
| 6 |
+
|
| 7 |
+
* Length penalty correctly rescales scores.
|
| 8 |
+
* Repetition penalty downweights seen tokens.
|
| 9 |
+
* n-gram blocker forbids exact-repeat n-grams.
|
| 10 |
+
* Detokeniser strips ``[start]`` / ``[end]`` and stops at ``[end]``.
|
| 11 |
+
|
| 12 |
+
A small fake model is used to verify end-to-end search behaviour without
|
| 13 |
+
loading TensorFlow weights.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from unittest.mock import MagicMock
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
from captioning.inference.beam import (
|
| 24 |
+
_apply_repetition_penalty,
|
| 25 |
+
_Beam,
|
| 26 |
+
_blocks_repeat_ngram,
|
| 27 |
+
_detokenize,
|
| 28 |
+
_length_normalised,
|
| 29 |
+
generate_caption_beam,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_length_penalty_zero_returns_raw_score() -> None:
|
| 34 |
+
b = _Beam(token_ids=[1, 2, 3], score=-5.0)
|
| 35 |
+
assert _length_normalised(b, 0.0) == -5.0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_length_penalty_one_divides_by_length() -> None:
|
| 39 |
+
b = _Beam(token_ids=[1, 2, 3, 4], score=-6.0) # length=3
|
| 40 |
+
assert _length_normalised(b, 1.0) == pytest.approx(-2.0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_repetition_penalty_downweights_seen_tokens() -> None:
|
| 44 |
+
log_probs = np.array([-1.0, -2.0, -3.0, -4.0])
|
| 45 |
+
out = _apply_repetition_penalty(log_probs.copy(), history_ids={1, 3}, penalty=2.0)
|
| 46 |
+
# Penalty subtracts log(2) ~ 0.693 from seen-token log-probs.
|
| 47 |
+
assert out[0] == pytest.approx(-1.0)
|
| 48 |
+
assert out[1] == pytest.approx(-2.0 - np.log(2.0))
|
| 49 |
+
assert out[2] == pytest.approx(-3.0)
|
| 50 |
+
assert out[3] == pytest.approx(-4.0 - np.log(2.0))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_repetition_penalty_one_is_noop() -> None:
|
| 54 |
+
log_probs = np.array([-1.0, -2.0, -3.0])
|
| 55 |
+
out = _apply_repetition_penalty(log_probs.copy(), history_ids={0, 1}, penalty=1.0)
|
| 56 |
+
np.testing.assert_array_equal(out, log_probs)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_blocks_repeat_ngram_detects_repeat() -> None:
|
| 60 |
+
# seq ends with [4, 5]; appending 6 forms trigram [4, 5, 6] not present.
|
| 61 |
+
assert not _blocks_repeat_ngram([1, 2, 3, 4, 5], 6, n=3)
|
| 62 |
+
# Now seq contains [4, 5, 6]; appending 6 still wouldn't form a repeat.
|
| 63 |
+
assert not _blocks_repeat_ngram([4, 5, 6, 4, 5], 7, n=3)
|
| 64 |
+
# seq has [4, 5, 6] AND ends with [4, 5]; appending 6 repeats [4, 5, 6].
|
| 65 |
+
assert _blocks_repeat_ngram([4, 5, 6, 1, 4, 5], 6, n=3)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_blocks_repeat_ngram_zero_size_disables() -> None:
|
| 69 |
+
assert not _blocks_repeat_ngram([1, 1, 1], 1, n=0)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_detokenize_stops_at_end_and_skips_special_tokens() -> None:
|
| 73 |
+
tokenizer = MagicMock()
|
| 74 |
+
# ids: [start]=1, "a"=2, "man"=3, [end]=4
|
| 75 |
+
table = {1: "[start]", 2: "a", 3: "man", 4: "[end]"}
|
| 76 |
+
tokenizer.decode_id = lambda i: table[i]
|
| 77 |
+
out = _detokenize([1, 2, 3, 4, 99], tokenizer, end_id=4)
|
| 78 |
+
assert out == "a man"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---- End-to-end beam search with a fake model -----------------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class _FakeModel:
|
| 85 |
+
"""Decoder fixture that always assigns the highest probability to ``best_id``.
|
| 86 |
+
|
| 87 |
+
The decoder output is the only piece beam search cares about; we stub the
|
| 88 |
+
CNN / encoder to identity-like behaviour so the whole inference pass runs
|
| 89 |
+
without TF being loaded.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
def __init__(self, vocab_size: int, best_id: int) -> None:
|
| 93 |
+
self.vocab_size = vocab_size
|
| 94 |
+
self.best_id = best_id
|
| 95 |
+
|
| 96 |
+
self.cnn_model = MagicMock(side_effect=self._identity_image)
|
| 97 |
+
self.encoder = MagicMock(side_effect=self._identity_encoder)
|
| 98 |
+
self.decoder = MagicMock(side_effect=self._decoder_step)
|
| 99 |
+
|
| 100 |
+
def _identity_image(self, img):
|
| 101 |
+
return img
|
| 102 |
+
|
| 103 |
+
def _identity_encoder(self, x, training):
|
| 104 |
+
return x
|
| 105 |
+
|
| 106 |
+
def _decoder_step(self, tokens, encoded, training, mask):
|
| 107 |
+
import tensorflow as tf
|
| 108 |
+
|
| 109 |
+
batch = int(tf.shape(tokens)[0])
|
| 110 |
+
seq_len = int(tf.shape(tokens)[1])
|
| 111 |
+
probs = np.full((batch, seq_len, self.vocab_size), 1e-3, dtype=np.float32)
|
| 112 |
+
probs[:, :, self.best_id] = 0.999
|
| 113 |
+
# Normalise so each row over vocab sums to ~1.
|
| 114 |
+
probs /= probs.sum(axis=-1, keepdims=True)
|
| 115 |
+
return tf.convert_to_tensor(probs)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_beam_search_emits_caption_when_model_prefers_end_token() -> None:
|
| 119 |
+
import tensorflow as tf
|
| 120 |
+
|
| 121 |
+
tokenizer = MagicMock()
|
| 122 |
+
# vocab: 0=pad, 1=[start], 2=[end], 3="dog"
|
| 123 |
+
word_to_id_table = {"[start]": 1, "[end]": 2}
|
| 124 |
+
decode_id_table = {0: "", 1: "[start]", 2: "[end]", 3: "dog"}
|
| 125 |
+
tokenizer.word_to_id = lambda w: word_to_id_table[w]
|
| 126 |
+
tokenizer.decode_id = lambda i: decode_id_table[i]
|
| 127 |
+
|
| 128 |
+
# Model always predicts "dog" (id=3).
|
| 129 |
+
model = _FakeModel(vocab_size=4, best_id=3)
|
| 130 |
+
image = tf.zeros((299, 299, 3), dtype=tf.float32)
|
| 131 |
+
|
| 132 |
+
caption = generate_caption_beam(
|
| 133 |
+
model,
|
| 134 |
+
tokenizer,
|
| 135 |
+
image,
|
| 136 |
+
max_length=6,
|
| 137 |
+
beam_width=2,
|
| 138 |
+
length_penalty=0.0,
|
| 139 |
+
)
|
| 140 |
+
# With no length penalty and no repetition penalty, the greedy-ish path
|
| 141 |
+
# outputs repeated "dog" until max_length. We just assert it produced
|
| 142 |
+
# *something* and didn't crash.
|
| 143 |
+
assert caption.startswith("dog")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_beam_search_terminates_on_eos() -> None:
|
| 147 |
+
"""Beam search must produce a clean caption when the model emits [end]."""
|
| 148 |
+
import tensorflow as tf
|
| 149 |
+
|
| 150 |
+
tokenizer = MagicMock()
|
| 151 |
+
word_to_id_table = {"[start]": 1, "[end]": 2}
|
| 152 |
+
decode_id_table = {0: "", 1: "[start]", 2: "[end]", 3: "dog"}
|
| 153 |
+
tokenizer.word_to_id = lambda w: word_to_id_table[w]
|
| 154 |
+
tokenizer.decode_id = lambda i: decode_id_table[i]
|
| 155 |
+
|
| 156 |
+
# Step 0: prefer "dog"; step 1+: prefer [end].
|
| 157 |
+
class _EosFakeModel(_FakeModel):
|
| 158 |
+
def _decoder_step(self, tokens, encoded, training, mask):
|
| 159 |
+
batch = int(tf.shape(tokens)[0])
|
| 160 |
+
seq_len = int(tf.shape(tokens)[1])
|
| 161 |
+
probs = np.full((batch, seq_len, self.vocab_size), 1e-3, dtype=np.float32)
|
| 162 |
+
probs[:, 0, 3] = 0.99 # at position 0 prefer "dog"
|
| 163 |
+
for pos in range(1, seq_len):
|
| 164 |
+
probs[:, pos, 2] = 0.99 # afterwards prefer [end]
|
| 165 |
+
probs /= probs.sum(axis=-1, keepdims=True)
|
| 166 |
+
return tf.convert_to_tensor(probs)
|
| 167 |
+
|
| 168 |
+
model = _EosFakeModel(vocab_size=4, best_id=3)
|
| 169 |
+
caption = generate_caption_beam(
|
| 170 |
+
model,
|
| 171 |
+
tokenizer,
|
| 172 |
+
tf.zeros((299, 299, 3), dtype=tf.float32),
|
| 173 |
+
max_length=6,
|
| 174 |
+
beam_width=2,
|
| 175 |
+
)
|
| 176 |
+
assert caption == "dog"
|
tests/unit/test_config.py
CHANGED
|
@@ -87,3 +87,44 @@ def test_modelconfig_independent_of_other_sections() -> None:
|
|
| 87 |
assert m.vocabulary_size == 500
|
| 88 |
# Defaults preserved
|
| 89 |
assert m.max_length == 40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
assert m.vocabulary_size == 500
|
| 88 |
# Defaults preserved
|
| 89 |
assert m.max_length == 40
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ---- Opt-in stability flags ------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_train_stability_defaults_preserve_notebook_parity() -> None:
|
| 96 |
+
t = TrainConfig()
|
| 97 |
+
assert t.label_smoothing == 0.0
|
| 98 |
+
assert t.lr_schedule == "constant"
|
| 99 |
+
assert t.warmup_steps == 0
|
| 100 |
+
assert t.honour_training_flag_in_test_step is False
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_label_smoothing_rejects_out_of_range() -> None:
|
| 104 |
+
with pytest.raises(ValidationError):
|
| 105 |
+
TrainConfig(label_smoothing=1.0)
|
| 106 |
+
with pytest.raises(ValidationError):
|
| 107 |
+
TrainConfig(label_smoothing=-0.1)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_lr_schedule_rejects_unknown() -> None:
|
| 111 |
+
with pytest.raises(ValidationError):
|
| 112 |
+
TrainConfig(lr_schedule="square_wave")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_decode_strategy_validates() -> None:
|
| 116 |
+
from captioning.config.schema import ServeConfig
|
| 117 |
+
|
| 118 |
+
with pytest.raises(ValidationError):
|
| 119 |
+
ServeConfig(decode_strategy="nucleus")
|
| 120 |
+
s = ServeConfig(decode_strategy="beam", beam_width=4)
|
| 121 |
+
assert s.beam_width == 4
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_beam_width_and_repetition_penalty_rejected_out_of_range() -> None:
|
| 125 |
+
from captioning.config.schema import ServeConfig
|
| 126 |
+
|
| 127 |
+
with pytest.raises(ValidationError):
|
| 128 |
+
ServeConfig(beam_width=0)
|
| 129 |
+
with pytest.raises(ValidationError):
|
| 130 |
+
ServeConfig(repetition_penalty=0.5)
|
tests/unit/test_evaluation_metrics.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ROUGE-L, CIDEr, METEOR adapters and the unified runner.
|
| 2 |
+
|
| 3 |
+
We don't validate the upstream implementations — they have their own test
|
| 4 |
+
suites. We *do* validate our adapters: sentinel stripping, ragged references,
|
| 5 |
+
the perfect-prediction bound, and that the unified ``compute_all_metrics``
|
| 6 |
+
correctly records partial failures in ``errors`` rather than crashing the
|
| 7 |
+
whole pass.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from captioning.evaluation import (
|
| 17 |
+
MIN_SAMPLES_FOR_CIDER,
|
| 18 |
+
BleuBreakdown,
|
| 19 |
+
RunMeta,
|
| 20 |
+
compute_all_metrics,
|
| 21 |
+
corpus_bleu_breakdown,
|
| 22 |
+
corpus_bleu_score,
|
| 23 |
+
corpus_cider_score,
|
| 24 |
+
corpus_rouge_l_score,
|
| 25 |
+
diagnose_many,
|
| 26 |
+
diagnose_sample,
|
| 27 |
+
write_diagnostics_jsonl,
|
| 28 |
+
write_run_artifacts,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# ---- BLEU ------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_bleu_breakdown_returns_all_four_orders() -> None:
|
| 35 |
+
refs = [["a man riding a bike"], ["a dog in the park"]]
|
| 36 |
+
preds = ["a man riding a bike", "a dog in the park"]
|
| 37 |
+
result = corpus_bleu_breakdown(preds, refs)
|
| 38 |
+
assert isinstance(result, BleuBreakdown)
|
| 39 |
+
assert result.bleu1 == pytest.approx(100.0)
|
| 40 |
+
assert result.bleu2 == pytest.approx(100.0)
|
| 41 |
+
assert result.bleu4 == pytest.approx(100.0)
|
| 42 |
+
assert corpus_bleu_score(preds, refs) == pytest.approx(result.bleu4)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_bleu_strips_sentinels_before_scoring() -> None:
|
| 46 |
+
refs = [["[start] a man riding a bike [end]"]]
|
| 47 |
+
preds = ["[start] a man riding a bike [end]"]
|
| 48 |
+
assert corpus_bleu_score(preds, refs) == pytest.approx(100.0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---- ROUGE-L ---------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_rouge_l_perfect_matches_score_100() -> None:
|
| 55 |
+
refs = [["a man riding a bike"], ["a dog in the park"]]
|
| 56 |
+
preds = ["a man riding a bike", "a dog in the park"]
|
| 57 |
+
score = corpus_rouge_l_score(preds, refs)
|
| 58 |
+
assert score == pytest.approx(100.0)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_rouge_l_partial_overlap_scores_in_range() -> None:
|
| 62 |
+
refs = [["a man riding a bike on a road"]]
|
| 63 |
+
preds = ["a man on a road"]
|
| 64 |
+
score = corpus_rouge_l_score(preds, refs)
|
| 65 |
+
# Reference has 7 tokens, prediction has 5 tokens, LCS=5
|
| 66 |
+
# P = 5/5 = 1.0, R = 5/7 ≈ 0.71, F ≈ 0.83
|
| 67 |
+
assert 70.0 < score < 90.0
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_rouge_l_picks_best_reference() -> None:
|
| 71 |
+
refs = [["xyz qrs nothing matches", "a man riding a bike"]]
|
| 72 |
+
preds = ["a man riding a bike"]
|
| 73 |
+
score = corpus_rouge_l_score(preds, refs)
|
| 74 |
+
assert score == pytest.approx(100.0)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_rouge_l_length_mismatch_raises() -> None:
|
| 78 |
+
with pytest.raises(ValueError):
|
| 79 |
+
corpus_rouge_l_score(["a"], [["a"], ["b"]])
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---- CIDEr -----------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_cider_requires_minimum_samples() -> None:
|
| 86 |
+
with pytest.raises(ValueError, match="degenerate"):
|
| 87 |
+
corpus_cider_score(["a man"], [["a man"]])
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_cider_returns_positive_for_good_predictions() -> None:
|
| 91 |
+
refs = [
|
| 92 |
+
["a man riding a bike"],
|
| 93 |
+
["a dog in the park"],
|
| 94 |
+
["two children playing"],
|
| 95 |
+
]
|
| 96 |
+
preds = ["a man riding a bike", "a dog in the park", "two children playing"]
|
| 97 |
+
score = corpus_cider_score(preds, refs)
|
| 98 |
+
assert score > 0.0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ---- Runner ----------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_compute_all_metrics_returns_every_field() -> None:
|
| 105 |
+
refs = [["a man riding a bike"], ["a dog in the park"]]
|
| 106 |
+
preds = ["a man riding a bike", "a dog in the park"]
|
| 107 |
+
report = compute_all_metrics(preds, refs, include_meteor=False, include_cider=False)
|
| 108 |
+
assert report.n_examples == 2
|
| 109 |
+
assert report.bleu1 is not None
|
| 110 |
+
assert report.bleu4 is not None
|
| 111 |
+
assert report.rouge_l is not None
|
| 112 |
+
assert report.meteor is None # explicitly skipped
|
| 113 |
+
assert report.cider is None # explicitly skipped
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_compute_all_metrics_skips_cider_on_tiny_corpus() -> None:
|
| 117 |
+
refs = [["a man riding a bike"]]
|
| 118 |
+
preds = ["a man riding a bike"]
|
| 119 |
+
report = compute_all_metrics(preds, refs, include_meteor=False)
|
| 120 |
+
assert report.cider is None
|
| 121 |
+
assert "cider" in report.errors
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_compute_all_metrics_serialises_to_dict() -> None:
|
| 125 |
+
refs = [["a man riding a bike"], ["a dog in the park"]]
|
| 126 |
+
preds = ["a man riding a bike", "a dog in the park"]
|
| 127 |
+
report = compute_all_metrics(preds, refs, include_meteor=False, include_cider=False)
|
| 128 |
+
payload = report.to_dict()
|
| 129 |
+
# JSON-roundtrip must not lose information.
|
| 130 |
+
assert json.loads(json.dumps(payload)) == payload
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---- Inspection -----------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_diagnose_sample_flags_empty_prediction() -> None:
|
| 137 |
+
d = diagnose_sample("img.jpg", "", ["a man riding a bike"])
|
| 138 |
+
assert "empty" in d.flags
|
| 139 |
+
assert d.length_tokens == 0
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_diagnose_sample_flags_repetitive_prediction() -> None:
|
| 143 |
+
d = diagnose_sample("img.jpg", "a a a a man", ["a man riding a bike"])
|
| 144 |
+
assert "repetitive" in d.flags
|
| 145 |
+
assert d.longest_repeat_run == 4
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_diagnose_sample_flags_very_short_prediction() -> None:
|
| 149 |
+
d = diagnose_sample("img.jpg", "a man", ["a man riding a bike"])
|
| 150 |
+
assert "very_short" in d.flags
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_diagnose_many_writes_jsonl(tmp_path) -> None:
|
| 154 |
+
images = ["a.jpg", "b.jpg"]
|
| 155 |
+
preds = ["a man riding a bike", ""]
|
| 156 |
+
refs = [["a man on a bicycle"], ["a dog in the park"]]
|
| 157 |
+
diags = diagnose_many(images, preds, refs)
|
| 158 |
+
out = tmp_path / "diag.jsonl"
|
| 159 |
+
write_diagnostics_jsonl(diags, out)
|
| 160 |
+
lines = out.read_text(encoding="utf-8").splitlines()
|
| 161 |
+
assert len(lines) == 2
|
| 162 |
+
parsed = [json.loads(line) for line in lines]
|
| 163 |
+
assert parsed[0]["image"] == "a.jpg"
|
| 164 |
+
# Empty prediction also flags as ``very_short`` because it has 0 tokens.
|
| 165 |
+
assert "empty" in parsed[1]["flags"]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---- Benchmark scaffolding ------------------------------------------------
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_write_run_artifacts_emits_expected_files(tmp_path) -> None:
|
| 172 |
+
images = ["a.jpg", "b.jpg"]
|
| 173 |
+
preds = ["a man riding a bike", "a dog in the park"]
|
| 174 |
+
refs = [["a man on a bicycle"], ["a dog in the park"]]
|
| 175 |
+
diags = diagnose_many(images, preds, refs)
|
| 176 |
+
report = compute_all_metrics(preds, refs, include_meteor=False, include_cider=False)
|
| 177 |
+
meta = RunMeta(
|
| 178 |
+
model_id="test-model",
|
| 179 |
+
decode_strategy="greedy",
|
| 180 |
+
weights_path="nowhere",
|
| 181 |
+
tokenizer_dir="nowhere",
|
| 182 |
+
n_samples=len(preds),
|
| 183 |
+
max_length=40,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
out_dir = write_run_artifacts(
|
| 187 |
+
tmp_path / "runX",
|
| 188 |
+
metrics=report,
|
| 189 |
+
meta=meta,
|
| 190 |
+
images=images,
|
| 191 |
+
predictions=preds,
|
| 192 |
+
references=refs,
|
| 193 |
+
diagnostics=diags,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
assert (out_dir / "metrics.json").is_file()
|
| 197 |
+
assert (out_dir / "run_meta.json").is_file()
|
| 198 |
+
assert (out_dir / "predictions.jsonl").is_file()
|
| 199 |
+
assert (out_dir / "diagnostics.jsonl").is_file()
|
| 200 |
+
assert (out_dir / "report.md").is_file()
|
| 201 |
+
|
| 202 |
+
predictions_lines = (out_dir / "predictions.jsonl").read_text(encoding="utf-8").splitlines()
|
| 203 |
+
assert len(predictions_lines) == 2
|
| 204 |
+
|
| 205 |
+
metadata = json.loads((out_dir / "run_meta.json").read_text(encoding="utf-8"))
|
| 206 |
+
assert metadata["model_id"] == "test-model"
|
| 207 |
+
assert metadata["n_samples"] == 2
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
_ = MIN_SAMPLES_FOR_CIDER # — exposed re-export, exercised by import
|
tests/unit/test_tokenizer.py
CHANGED
|
@@ -65,3 +65,15 @@ def test_max_length_is_respected(tiny_caption_corpus: list[str]) -> None:
|
|
| 65 |
long_caption = " ".join(["[start]"] + ["word"] * 30 + ["[end]"])
|
| 66 |
ids = tok.encode([long_caption])
|
| 67 |
assert ids.shape == (1, 10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
long_caption = " ".join(["[start]"] + ["word"] * 30 + ["[end]"])
|
| 66 |
ids = tok.encode([long_caption])
|
| 67 |
assert ids.shape == (1, 10)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_word_to_id_round_trips_with_decode(tiny_caption_corpus: list[str]) -> None:
|
| 71 |
+
"""``word_to_id`` is the inverse of ``decode_id`` for in-vocabulary tokens."""
|
| 72 |
+
tok = CaptionTokenizer(vocab_size=200, max_length=20)
|
| 73 |
+
tok.fit(tiny_caption_corpus)
|
| 74 |
+
start_id = tok.word_to_id("[start]")
|
| 75 |
+
end_id = tok.word_to_id("[end]")
|
| 76 |
+
assert isinstance(start_id, int)
|
| 77 |
+
assert start_id != end_id
|
| 78 |
+
assert tok.decode_id(start_id) == "[start]"
|
| 79 |
+
assert tok.decode_id(end_id) == "[end]"
|
tests/unit/test_training_stability.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the opt-in training-stability primitives.
|
| 2 |
+
|
| 3 |
+
Covers:
|
| 4 |
+
* ``label_smoothed_crossentropy`` returns a per-token loss tensor with the
|
| 5 |
+
same shape as the baseline sparse loss, and reduces to it at smoothing=0.
|
| 6 |
+
* ``WarmupCosineDecay`` produces the expected piecewise schedule.
|
| 7 |
+
* ``build_loss`` / ``build_learning_rate`` dispatch correctly on config.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from itertools import pairwise
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
from captioning.training.losses import build_loss, label_smoothed_crossentropy
|
| 18 |
+
from captioning.training.schedules import WarmupCosineDecay, build_learning_rate
|
| 19 |
+
|
| 20 |
+
# ---- Label smoothing -------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_label_smoothed_loss_returns_per_token_shape() -> None:
|
| 24 |
+
import tensorflow as tf
|
| 25 |
+
|
| 26 |
+
vocab = 5
|
| 27 |
+
loss_fn = label_smoothed_crossentropy(0.1, vocab)
|
| 28 |
+
y_true = tf.constant([[1, 2, 0]], dtype=tf.int32)
|
| 29 |
+
y_pred = tf.constant(
|
| 30 |
+
[
|
| 31 |
+
[
|
| 32 |
+
[0.05, 0.85, 0.05, 0.025, 0.025],
|
| 33 |
+
[0.05, 0.05, 0.85, 0.025, 0.025],
|
| 34 |
+
[0.85, 0.05, 0.05, 0.025, 0.025],
|
| 35 |
+
]
|
| 36 |
+
],
|
| 37 |
+
dtype=tf.float32,
|
| 38 |
+
)
|
| 39 |
+
loss = loss_fn(y_true, y_pred).numpy()
|
| 40 |
+
assert loss.shape == (1, 3)
|
| 41 |
+
# The first two tokens are confidently correct → low loss.
|
| 42 |
+
assert loss[0, 0] < 1.0
|
| 43 |
+
assert loss[0, 1] < 1.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_label_smoothing_with_zero_returns_baseline_loss() -> None:
|
| 47 |
+
loss = build_loss(0.0, vocab_size=10)
|
| 48 |
+
# The baseline SparseCategoricalCrossentropy is an instance, not a function.
|
| 49 |
+
import tensorflow as tf
|
| 50 |
+
|
| 51 |
+
assert isinstance(loss, tf.keras.losses.SparseCategoricalCrossentropy)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_label_smoothing_is_higher_than_unsmoothed_on_perfect_prediction() -> None:
|
| 55 |
+
"""Smoothing punishes overconfidence — perfect one-hot prediction gets a
|
| 56 |
+
higher per-token loss with smoothing > 0 than without."""
|
| 57 |
+
import tensorflow as tf
|
| 58 |
+
|
| 59 |
+
vocab = 5
|
| 60 |
+
y_true = tf.constant([[1]], dtype=tf.int32)
|
| 61 |
+
one_hot_pred = tf.constant([[[0.0, 1.0, 0.0, 0.0, 0.0]]], dtype=tf.float32)
|
| 62 |
+
|
| 63 |
+
smoothed = label_smoothed_crossentropy(0.1, vocab)(y_true, one_hot_pred).numpy()
|
| 64 |
+
unsmoothed = -np.log(1.0) # sparse cross-entropy on argmax==y_true is 0
|
| 65 |
+
assert smoothed[0, 0] > unsmoothed + 1e-3
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---- Learning-rate schedule -----------------------------------------------
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_warmup_cosine_zero_at_step_zero() -> None:
|
| 72 |
+
import tensorflow as tf
|
| 73 |
+
|
| 74 |
+
schedule = WarmupCosineDecay(peak_learning_rate=1.0, warmup_steps=10, decay_steps=100)
|
| 75 |
+
assert float(schedule(tf.constant(0, dtype=tf.int64))) == pytest.approx(0.0)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_warmup_cosine_peaks_at_end_of_warmup() -> None:
|
| 79 |
+
import tensorflow as tf
|
| 80 |
+
|
| 81 |
+
schedule = WarmupCosineDecay(peak_learning_rate=1.0, warmup_steps=10, decay_steps=100)
|
| 82 |
+
assert float(schedule(tf.constant(10, dtype=tf.int64))) == pytest.approx(1.0, abs=1e-3)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_warmup_cosine_floors_at_end_of_decay() -> None:
|
| 86 |
+
import tensorflow as tf
|
| 87 |
+
|
| 88 |
+
schedule = WarmupCosineDecay(
|
| 89 |
+
peak_learning_rate=1.0,
|
| 90 |
+
warmup_steps=10,
|
| 91 |
+
decay_steps=100,
|
| 92 |
+
min_learning_rate=0.1,
|
| 93 |
+
)
|
| 94 |
+
final = float(schedule(tf.constant(110, dtype=tf.int64)))
|
| 95 |
+
assert final == pytest.approx(0.1, abs=1e-3)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_warmup_cosine_is_monotone_during_warmup() -> None:
|
| 99 |
+
import tensorflow as tf
|
| 100 |
+
|
| 101 |
+
schedule = WarmupCosineDecay(peak_learning_rate=1.0, warmup_steps=10, decay_steps=100)
|
| 102 |
+
values = [float(schedule(tf.constant(s, dtype=tf.int64))) for s in range(11)]
|
| 103 |
+
assert all(b >= a for a, b in pairwise(values))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_build_learning_rate_returns_float_for_constant() -> None:
|
| 107 |
+
lr = build_learning_rate(
|
| 108 |
+
schedule="constant",
|
| 109 |
+
peak_learning_rate=1e-3,
|
| 110 |
+
warmup_steps=0,
|
| 111 |
+
decay_steps=10,
|
| 112 |
+
min_learning_rate=0.0,
|
| 113 |
+
)
|
| 114 |
+
assert lr == 1e-3
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_build_learning_rate_returns_schedule_for_cosine() -> None:
|
| 118 |
+
lr = build_learning_rate(
|
| 119 |
+
schedule="cosine",
|
| 120 |
+
peak_learning_rate=1e-3,
|
| 121 |
+
warmup_steps=5,
|
| 122 |
+
decay_steps=50,
|
| 123 |
+
min_learning_rate=0.0,
|
| 124 |
+
)
|
| 125 |
+
assert isinstance(lr, WarmupCosineDecay)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_build_learning_rate_rejects_unknown_schedule() -> None:
|
| 129 |
+
with pytest.raises(ValueError, match="unsupported"):
|
| 130 |
+
build_learning_rate(
|
| 131 |
+
schedule="square_wave",
|
| 132 |
+
peak_learning_rate=1.0,
|
| 133 |
+
warmup_steps=0,
|
| 134 |
+
decay_steps=10,
|
| 135 |
+
min_learning_rate=0.0,
|
| 136 |
+
)
|