apoorvrajdev commited on
Commit
91a1214
·
1 Parent(s): 131c45c

feat(evaluation): add beam search, metrics pipeline, and stabilized training workflow

Browse files
.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
- --report docs/results/v1.0.0.md \\
9
  --max-samples 500
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
  from __future__ import annotations
13
 
14
- import json
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 corpus_bleu_score
 
 
 
 
 
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 write a Markdown report.",
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
- def main(
 
 
 
 
 
 
 
 
 
 
 
 
49
  config_path: Path,
50
  weights: Path,
51
  tokenizer_dir: Path,
 
 
 
 
 
 
 
52
  report_path: Path | None,
53
  max_samples: int,
 
 
54
  ) -> None:
55
- """Compute corpus BLEU-4 on the val split and (optionally) write a report."""
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, tokenizer_dir=tokenizer_dir, config=config
 
 
 
 
 
 
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
- bleu = corpus_bleu_score(predictions, references)
90
- log.info("evaluation_done", bleu4=bleu, n=len(predictions))
91
- click.echo(f"BLEU-4: {bleu:.2f} (n={len(predictions)})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  if report_path is not None:
94
  report_path.parent.mkdir(parents=True, exist_ok=True)
95
  report_path.write_text(
96
- f"# Evaluation v1\n\n"
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
- def main(config_path: Path, weights: Path, tokenizer_dir: Path, image: Path) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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" # Phase 1b adds "beam"
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
- Phase 1 ships a corpus-BLEU implementation only; Phase 1b expands to CIDEr,
4
- METEOR, and ROUGE-L (which is why this is its own package, not a single file).
 
 
 
 
 
 
 
5
  """
6
 
7
- from captioning.evaluation.bleu import corpus_bleu_score
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- __all__ = ["corpus_bleu_score"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (Phase 1 minimal implementation).
2
-
3
- The IEEE paper reports BLEU ~24 on COCO val. The notebook does not include
4
- the evaluation code that produced this number we add it here so the new
5
- modular pipeline can verify it matches the paper.
6
-
7
- Phase 1 ships *one* metric (corpus BLEU-4 via ``sacrebleu``) on purpose:
8
- * sacrebleu is the de-facto BLEU implementation. NLTK's BLEU has
9
- idiosyncratic smoothing and produces slightly different numbers; we
10
- use sacrebleu so the published number is reproducible by anyone with
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
- # sacrebleu's `corpus_bleu` expects parallel lists, one *per reference
56
- # slot*: refs_by_slot[slot_index][example_index].
57
- max_refs = max(len(r) for r in references) if references else 0
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
- bleu = sacrebleu.corpus_bleu(list(predictions), refs_by_slot)
63
- return float(bleu.score)
 
 
 
 
 
 
 
 
 
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
- * Phase 1b adds beam search; Phase 3 adds a model registry. Both extend
9
- this class, not the functional callsites.
 
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: Literal["greedy"] = "greedy",
 
 
 
 
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: Phase 1 supports only ``"greedy"``. Phase 1b adds
47
- ``"beam"``; this argument is here so the signature is stable.
 
 
 
 
 
 
 
 
 
48
  """
49
- if decode_strategy != "greedy":
50
- raise NotImplementedError(
51
- f"Phase 1 supports decode_strategy='greedy' only, got {decode_strategy!r}"
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
- Args:
68
- weights_path: Path to ``model.h5`` (notebook cell 30 saved this).
69
- tokenizer_dir: Directory containing ``vocab.pkl`` (and ``vocab.json``).
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
- log.info("predictor_loaded", weights=str(weights_path))
90
- return cls(model=model, tokenizer=tokenizer, config=config)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- _ = generate_caption_greedy(self.model, self.tokenizer, dummy, self.config.model.max_length)
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 & accuracy
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
- Behavioural quirk preserved for parity (NOT a bug in our code):
 
 
 
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
- We preserve this so BLEU matches the paper. Phase 1b will fix it in a
13
- deliberate, clearly-marked commit.
 
 
 
 
 
 
 
 
 
 
 
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__(self, cnn_model, encoder, decoder, image_aug=None) -> None:
 
 
 
 
 
 
 
 
 
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
- # Notebook quirk preserved: encoder/decoder always called with
53
- # training=True. The `training` parameter is intentionally unused.
54
- del training # silence linters: this is deliberate
55
- encoder_output = self.encoder(img_embed, training=True)
 
 
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=True, mask=mask)
60
  loss = self.calculate_loss(y_true, y_pred, mask)
61
  acc = self.calculate_accuracy(y_true, y_pred, mask)
62
- return loss, acc
 
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.acc_tracker.update_state(acc)
 
 
 
 
 
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.acc_tracker.update_state(acc)
 
 
 
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
- return ImageCaptioningModel(cnn_model=cnn, encoder=encoder, decoder=decoder, image_aug=aug)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 that orchestrates ``model.fit``.
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 (e.g. by Phase 1b
6
- beam-search evaluators).
7
 
8
- losses.py ``masked_sparse_categorical_crossentropy`` the same loss the notebook uses
9
- callbacks.py ``default_callbacks(config)`` early stopping (and Phase 4 checkpoint hooks)
 
 
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 masked_sparse_categorical_crossentropy
 
 
 
 
 
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
- We expose the loss via a tiny factory rather than a constant so callers don't
12
- have to import TF themselves to get it.
 
 
 
 
 
 
 
 
 
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 is intentionally thin no MLflow integration yet (Phase 2
10
- adds it), no distributed strategy (out of scope for the IEEE notebook).
 
 
 
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 masked_sparse_categorical_crossentropy
 
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
- """Apply the same ``compile`` call the notebook makes (cell 22)."""
 
 
 
 
 
 
 
40
  import tensorflow as tf
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  self.model.compile(
43
- optimizer=tf.keras.optimizers.Adam(learning_rate=self.config.train.learning_rate),
44
- loss=masked_sparse_categorical_crossentropy(),
45
  )
46
  self._compiled = True
47
- log.info("model_compiled", learning_rate=self.config.train.learning_rate)
 
 
 
 
 
 
 
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
+ )