apoorvrajdev commited on
Commit
302e907
·
1 Parent(s): 7c00779

docs(plan): add Stage 0 eval-methodology gate to Option B plan

Browse files
Full_Step-By-Step_Plan__Option_B_.txt ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Full Step-By-Step Plan (Option B)
2
+
3
+ ================================================================================
4
+ STAGE 0 — EVALUATION-METHODOLOGY GATE (run BEFORE Stage 1; may make Stage 1
5
+ unnecessary)
6
+ ================================================================================
7
+
8
+ Purpose: before spending 3.5 GPU-hours retraining, test whether the apparent
9
+ gap to the IEEE BLEU-4 ~24 baseline is an evaluation-methodology artefact
10
+ rather than a genuine quality deficit. Two pre-registered, falsifiable tests:
11
+
12
+ Part A (BLEU) — rescore the EXISTING v2.0.0 predictions against all 5
13
+ COCO references (the committed predictions.jsonl has
14
+ only ~1.46 refs/image). Emits a pre-registered band:
15
+ DOMINANT / MAJOR-BUT-PARTIAL / MINOR.
16
+ Part B (qualitative) — blinded categorization of 30 predictions into
17
+ SPECIFIC-CORRECT / GENERIC-CORRECT / PARTIALLY-CORRECT
18
+ / INCORRECT, run WITHOUT seeing Part A's number.
19
+
20
+ Combined verdict (see scripts/categorize_predictions.py docstring) decides
21
+ whether Stage 1 is REQUIRED, OPTIONAL, or UNNECESSARY. If "don't retrain" or
22
+ "ship without retraining", skip Stages 1-2 and go to Stage 7 (reframe).
23
+
24
+ Pre-registration discipline: both scripts embed their predictions/rubric in
25
+ their module docstrings and must be COMMITTED before being run. Part A is run
26
+ and committed first; Part B runs afterward so the BLEU number cannot bias the
27
+ qualitative read.
28
+
29
+ --------------------------------------------------------------------------------
30
+ Cell G0 — locate the COCO annotations file (path varies by dataset mount)
31
+
32
+ ANN=$(find /kaggle/input -maxdepth 6 -name "captions_train2017.json" 2>/dev/null | head -1)
33
+ echo "Annotations: $ANN"
34
+ # If empty, the coco-2017-dataset is not attached — add it in the right sidebar.
35
+
36
+ --------------------------------------------------------------------------------
37
+ Cell G1 — PART A: 5-ref BLEU rescore + pre-registered band
38
+
39
+ # Assumes the repo is already cloned + installed (see Stage 1 Cell 2/4). If
40
+ # running Stage 0 standalone, clone first:
41
+ # !git clone https://github.com/apoorvrajdev/image-captioning-system.git
42
+ # %cd image-captioning-system
43
+ !pip install -q nltk sacrebleu
44
+
45
+ import subprocess
46
+ ANN = subprocess.check_output(
47
+ "find /kaggle/input -maxdepth 6 -name captions_train2017.json 2>/dev/null | head -1",
48
+ shell=True).decode().strip()
49
+ print("Annotations:", ANN)
50
+
51
+ !python -m scripts.rescore_nltk_bleu \
52
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl \
53
+ --coco-annotations "$ANN"
54
+
55
+ # Watch the line: "PRE-REGISTERED BAND (...): <DOMINANT|MAJOR-BUT-PARTIAL|MINOR>".
56
+ # The full table is also written to
57
+ # results/stabilized-beam-w4-lp07-rp12/metrics_5ref.json
58
+ # Commit metrics_5ref.json before running Part B.
59
+
60
+ --------------------------------------------------------------------------------
61
+ Cell G2 — PART B: blinded qualitative worklist (run AFTER Part A is committed)
62
+
63
+ # Blinding: do NOT open metrics_5ref.json before categorizing. This cell only
64
+ # PREPARES the sample (prints 30 predictions + their 5 refs, no metrics). The
65
+ # categorization itself is a judgment step done against the rubric in
66
+ # scripts/categorize_predictions.py — either by you, or by handing the printed
67
+ # worklist to a separate Claude Code turn. It never reads the BLEU output.
68
+
69
+ import subprocess
70
+ ANN = subprocess.check_output(
71
+ "find /kaggle/input -maxdepth 6 -name captions_train2017.json 2>/dev/null | head -1",
72
+ shell=True).decode().strip()
73
+
74
+ !python -m scripts.categorize_predictions \
75
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl \
76
+ --coco-annotations "$ANN"
77
+
78
+ # After judging each sample, write a categories JSONL
79
+ # ({sample_id, category, justification}) and finalize:
80
+ # !python -m scripts.categorize_predictions \
81
+ # --coco-annotations "$ANN" \
82
+ # --categories results/stabilized-beam-w4-lp07-rp12/categories.jsonl
83
+ # Then apply the COMBINED DECISION RULE (categorize docstring) with the Part A
84
+ # band to decide whether to proceed to Stage 1.
85
+
86
+ --------------------------------------------------------------------------------
87
+ Stage 0 decision gate:
88
+ - Combined verdict "don't retrain" / "ship without retraining" -> SKIP Stage 1
89
+ and Stage 2; go straight to Stage 7 (README reframe).
90
+ - Combined verdict "retrain" -> proceed to Stage 1 below.
91
+ - "Flag for human review" -> stop and decide manually before any GPU spend.
92
+
93
+ ================================================================================
94
+
95
+ Stage 1 - Kaggle Training (you do this in a browser):-
96
+
97
+ Step 1.1 - Create the Kaggle notebook
98
+ Go to https://www.kaggle.com -> + Create -> New Notebook
99
+ Right sidebar -> Settings:
100
+ Accelerator: GPU T4 x2
101
+ Internet: ON
102
+ Persistence: Files only
103
+ Add Data -> search awsaf49/coco-2017-dataset -> click Add
104
+ Rename notebook to: image-captioning-baseline-recipe-v3
105
+
106
+ Step 1.2 - Paste these cells in order
107
+ Cell 1 - Confirm dataset path
108
+
109
+ !ls /kaggle/input/datasets/awsaf49/
110
+ !find /kaggle/input -maxdepth 6 -name "captions_train2017.json" 2>/dev/null
111
+
112
+ Cell 2 - Clone repo (already pushed, current main has the right code)
113
+
114
+ !git clone https://github.com/apoorvrajdev/image-captioning-system.git
115
+ %cd image-captioning-system
116
+
117
+ Cell 3 - Install tf-keras legacy shim
118
+
119
+ !pip install -q tf-keras
120
+ import os
121
+ os.environ["TF_USE_LEGACY_KERAS"] = "1"
122
+
123
+ Cell 4 - Install project deps
124
+
125
+ import os
126
+ os.environ["TF_USE_LEGACY_KERAS"] = "1"
127
+
128
+ !sed -i '/^tensorflow/d' requirements.txt
129
+ !pip install -q -r requirements.txt -r requirements-dev.txt -r requirements-eval.txt
130
+ !pip install -q --no-deps -e .
131
+
132
+ import tensorflow as tf
133
+ import tf_keras
134
+ print("TF:", tf.__version__, "| tf_keras:", tf_keras.__version__, "| GPUs:", tf.config.list_physical_devices("GPU"))
135
+
136
+ # Expect: TF 2.19, tf_keras 2.x, 2 GPUs.
137
+
138
+ Cell 5 - Patch base.yaml with Kaggle dataset path
139
+ # NOTE: confirm the real mount from Cell 1's find output; the awsaf49 dataset
140
+ # commonly mounts at /kaggle/input/coco-2017-dataset/coco2017 (no datasets/awsaf49 segment).
141
+
142
+ !sed -i 's|base_path: data/coco2017|base_path: /kaggle/input/datasets/awsaf49/coco-2017-dataset/coco2017|' configs/base.yaml
143
+ !grep base_path configs/base.yaml
144
+
145
+ Cell 6 - TRAIN with base.yaml (the actual training, ~3.3h)
146
+
147
+ import os
148
+ os.environ["CAPTIONING__DATA__BASE_PATH"] = "/kaggle/input/datasets/awsaf49/coco-2017-dataset/coco2017"
149
+
150
+ !python -m scripts.train \
151
+ --config configs/base.yaml \
152
+ --output-dir outputs/runs/baseline
153
+
154
+ # Watch for: Epoch 1 loss ~3.6. EarlyStopping likely fires ~epoch 5-7 with restore_best_weights=True.
155
+
156
+ Cell 7 - Copy artefacts to versioned dir
157
+
158
+ !mkdir -p models/v3.0.0
159
+ !cp outputs/runs/baseline/best.h5 models/v3.0.0/model.h5
160
+ !cp outputs/runs/baseline/vocab.pkl models/v3.0.0/vocab.pkl
161
+ !cp outputs/runs/baseline/vocab.json models/v3.0.0/vocab.json
162
+ !ls -la models/v3.0.0/
163
+
164
+ Cell 8 - Greedy evaluation
165
+
166
+ import os
167
+ os.environ["CAPTIONING__DATA__BASE_PATH"] = "/kaggle/input/datasets/awsaf49/coco-2017-dataset/coco2017"
168
+
169
+ !python -m scripts.evaluate \
170
+ --config configs/base.yaml \
171
+ --weights models/v3.0.0/model.h5 \
172
+ --tokenizer-dir models/v3.0.0 \
173
+ --results-root results \
174
+ --run-id baseline-greedy \
175
+ --model-id inceptionv3-transformer-baseline \
176
+ --decode-strategy greedy \
177
+ --max-samples 500
178
+
179
+ Cell 9 - Beam evaluation (same params as v2.0.0 for apples-to-apples)
180
+
181
+ import os
182
+ os.environ["CAPTIONING__DATA__BASE_PATH"] = "/kaggle/input/datasets/awsaf49/coco-2017-dataset/coco2017"
183
+
184
+ !python -m scripts.evaluate \
185
+ --config configs/base.yaml \
186
+ --weights models/v3.0.0/model.h5 \
187
+ --tokenizer-dir models/v3.0.0 \
188
+ --results-root results \
189
+ --run-id baseline-beam-w4-lp07-rp12 \
190
+ --model-id inceptionv3-transformer-baseline \
191
+ --decode-strategy beam \
192
+ --beam-width 4 \
193
+ --length-penalty 0.7 \
194
+ --repetition-penalty 1.2 \
195
+ --max-samples 500
196
+
197
+ Cell 10 - Qualitative samples (30 random predictions)
198
+
199
+ !python -m scripts.inspect_predictions \
200
+ --config configs/base.yaml \
201
+ --weights models/v3.0.0/model.h5 \
202
+ --tokenizer-dir models/v3.0.0 \
203
+ --decode-strategy beam \
204
+ --beam-width 4 \
205
+ --n-samples 30 \
206
+ --output results/baseline-beam-w4-lp07-rp12/qualitative.jsonl
207
+
208
+ Cell 11 - Print metrics summary + side-by-side vs v2.0.0
209
+
210
+ import json
211
+
212
+ print("=" * 70)
213
+ print("BASELINE RECIPE (v3.0.0) - configs/base.yaml")
214
+ print("=" * 70)
215
+ for run in ("baseline-greedy", "baseline-beam-w4-lp07-rp12"):
216
+ m = json.load(open(f"results/{run}/metrics.json"))
217
+ print(f"\n{run}:")
218
+ for k in ("bleu1", "bleu2", "bleu3", "bleu4", "rouge_l", "meteor", "cider"):
219
+ print(f" {k:10s} = {m[k]:.4f}")
220
+
221
+ print("\n" + "=" * 70)
222
+ print("STABILIZED RECIPE (v2.0.0) - for comparison")
223
+ print("=" * 70)
224
+ print("""
225
+ stabilized-greedy:
226
+ bleu1 = 42.20, bleu4 = 10.57, rouge_l = 37.57, meteor = 15.45, cider = 0.789
227
+ stabilized-beam-w4-lp07-rp12:
228
+ bleu1 = 41.93, bleu4 = 10.39, rouge_l = 36.84, meteor = 15.56, cider = 0.826
229
+ """)
230
+
231
+ Cell 12 - Package handoff zip
232
+
233
+ !mkdir -p /kaggle/working/handoff
234
+ !cp -r results /kaggle/working/handoff/
235
+ !cp -r models /kaggle/working/handoff/
236
+ !cp outputs/runs/baseline/history.json /kaggle/working/handoff/ 2>/dev/null || true
237
+ !cd /kaggle/working && zip -r /kaggle/working/handoff-baseline.zip handoff/
238
+ !ls -la /kaggle/working/handoff-baseline.zip
239
+
240
+ Step 1.3 - Run the notebook
241
+ Hit Run All -> wait ~3.5 hours
242
+ Save Version -> "Save & Run All (Commit)" so the outputs persist
243
+ After completion, Output tab -> download handoff-baseline.zip
244
+
245
+ Stage 2 - Decision Gate (1 minute, you do this looking at Cell 11 output)
246
+ Look at the CIDEr numbers in Cell 11:
247
+
248
+ Outcome / What to do
249
+ base.yaml CIDEr >= 0.88 (significantly better than 0.826) -> Parity validated.
250
+ Proceed to Stage 3. Reframe as ablation in README.
251
+ base.yaml CIDEr 0.83-0.87 (similar to stabilized) -> Mixed signal. Check Cell 10
252
+ qualitative captions -> if they look more specific, still ship. Otherwise
253
+ pause and investigate.
254
+ base.yaml CIDEr < 0.82 (worse than stabilized) -> Surprise. Don't ship. There's
255
+ a hidden divergence we missed.
256
+ Also check Cell 10 qualitative samples - look for confident, specific captions
257
+ ("a woman riding a brown horse on a beach") vs generic ones ("a person on a
258
+ beach"). This matters more than the metric delta for the live demo.
259
+
260
+ Stage 3 - Local Setup (5 min, you do this on your machine)
261
+
262
+ Step 3.1 - Download + extract handoff zip
263
+
264
+ mkdir -p "/d/PROJECT/New folder/handoff-baseline"
265
+ cd "/d/PROJECT/New folder/handoff-baseline"
266
+ unzip -o handoff-baseline.zip
267
+ ls -la handoff/
268
+ ls -la handoff/models/v3.0.0/
269
+ ls -la handoff/results/
270
+
271
+ # Expect: model.h5 (~227MB), vocab.json, vocab.pkl, two baseline-* results dirs, history.json.
272
+
273
+ Step 3.2 - Sanity check the metrics
274
+
275
+ cat handoff/results/baseline-greedy/metrics.json | python -m json.tool
276
+ cat handoff/results/baseline-beam-w4-lp07-rp12/metrics.json | python -m json.tool
277
+
278
+ Stage 4 - Upload to HF Hub (5 min)
279
+
280
+ Step 4.1 - Upload v3.0.0 weights
281
+
282
+ cd "/d/PROJECT/New folder/handoff-baseline/handoff"
283
+ hf auth whoami || hf auth login
284
+ hf upload apoorvrajdev/captioning-inceptionv3-transformer models/v3.0.0/model.h5 model.h5
285
+ hf upload apoorvrajdev/captioning-inceptionv3-transformer models/v3.0.0/vocab.json vocab.json
286
+ hf upload apoorvrajdev/captioning-inceptionv3-transformer models/v3.0.0/vocab.pkl vocab.pkl
287
+
288
+ Step 4.2 - Tag as v3.0.0
289
+
290
+ python -c "from huggingface_hub import HfApi; HfApi().create_tag('apoorvrajdev/captioning-inceptionv3-transformer', tag='v3.0.0')"
291
+
292
+ # Verify at https://huggingface.co/apoorvrajdev/captioning-inceptionv3-transformer/tags
293
+
294
+ Stage 5 - Flip Live Demo to v3.0.0 (2 min, browser)
295
+ Go to https://huggingface.co/spaces/apoorvrajdev/image-captioning-api -> Settings
296
+ Variables and secrets -> find BACKEND_WEIGHTS_HUB_REVISION
297
+ Click edit -> change from v2.0.0 -> v3.0.0 -> Save
298
+ Space auto-rebuilds (~2 min). Watch the Logs tab -> snapshot_download pulling
299
+ v3.0.0 then model_loaded: true.
300
+ Test: https://image-captioning-system.vercel.app -> drop an image -> check quality.
301
+
302
+ Stage 6 - Commit Results to Repo (5 min)
303
+
304
+ cd /d/PROJECT/image-captioning-system-main
305
+ cp -r "/d/PROJECT/New folder/handoff-baseline/handoff/results/baseline-greedy" results/
306
+ cp -r "/d/PROJECT/New folder/handoff-baseline/handoff/results/baseline-beam-w4-lp07-rp12" results/
307
+ git add results/baseline-greedy results/baseline-beam-w4-lp07-rp12
308
+ git status
309
+
310
+ # Suggested commit (you run it):
311
+ # git commit -m "feat(results): add baseline-recipe COCO eval (v3.0.0 checkpoint)"
312
+ # git push origin main
313
+
314
+ Stage 7 - Reframe README as Ablation (no Kaggle needed)
315
+ Once Stage 6 is done (or once the Stage 0 gate says "don't retrain"), rewrite
316
+ the README's "Model Quality" section as a recipe ablation:
317
+ Original recipe (v3.0.0) - constant Adam(1e-3), no label smoothing, EarlyStopping
318
+ Stabilized recipe (v2.0.0) - cosine LR + warmup + label smoothing 0.1 + dropout-off val
319
+ Side-by-side table of BLEU-1..4 / CIDEr / METEOR / ROUGE-L (greedy + beam) for both
320
+ Honest explanation: stabilization tricks designed for large-data regimes hurt
321
+ small-data captioning. The portfolio story becomes "I ran an ablation and
322
+ learned when modern recipes don't help" — and, from Stage 0, "I separated a
323
+ metric-methodology artefact from a genuine quality gap before spending GPU time."
Makefile CHANGED
@@ -115,6 +115,27 @@ eval: ## Evaluate the latest model on COCO val (BLEU, CIDEr, METEOR, ROUGE)
115
  predict: ## CLI single-image inference (usage: make predict IMAGE=path/to/img.jpg)
116
  $(PYTHON) -m scripts.predict --image $(IMAGE)
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  # =============================================================================
119
  # Backend (FastAPI)
120
  # =============================================================================
 
115
  predict: ## CLI single-image inference (usage: make predict IMAGE=path/to/img.jpg)
116
  $(PYTHON) -m scripts.predict --image $(IMAGE)
117
 
118
+ # =============================================================================
119
+ # Evaluation-methodology gate (Phase 1b — run BEFORE Kaggle Stage 1 retrain)
120
+ # =============================================================================
121
+ # Both targets need the official COCO 2017 captions file. Download + extract:
122
+ # curl -L -o ann.zip https://images.cocodataset.org/annotations/annotations_trainval2017.zip
123
+ # unzip ann.zip # -> annotations/captions_train2017.json
124
+ # Then pass its path via COCO_ANNOTATIONS, e.g.:
125
+ # make rescore-5ref COCO_ANNOTATIONS=annotations/captions_train2017.json
126
+
127
+ .PHONY: rescore-5ref
128
+ rescore-5ref: ## Part A: 5-ref BLEU rescore + pre-registered band (needs COCO_ANNOTATIONS=...)
129
+ $(PYTHON) -m scripts.rescore_nltk_bleu \
130
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl \
131
+ --coco-annotations $(COCO_ANNOTATIONS)
132
+
133
+ .PHONY: categorize-30
134
+ categorize-30: ## Part B: blinded 30-sample categorization worklist (needs COCO_ANNOTATIONS=...)
135
+ $(PYTHON) -m scripts.categorize_predictions \
136
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl \
137
+ --coco-annotations $(COCO_ANNOTATIONS)
138
+
139
  # =============================================================================
140
  # Backend (FastAPI)
141
  # =============================================================================
scripts/categorize_predictions.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Blinded qualitative categorization of caption predictions (Part B).
2
+
3
+ This is the qualitative half of the two-part evaluation-methodology gate. It
4
+ tests CIDEr-style image-specificity: does the checkpoint produce captions that
5
+ meaningfully constrain the image, or generic category labels? It runs in a
6
+ SEPARATE Claude Code turn from the BLEU rescore so the BLEU number cannot bias
7
+ the categorization. By construction this script:
8
+
9
+ * does NOT import scripts/rescore_nltk_bleu.py,
10
+ * does NOT read results/.../metrics_5ref.json (or any BLEU output),
11
+ * shares no state with Part A.
12
+
13
+ The blinding is structural: when the sample is drawn from qualitative.jsonl,
14
+ the per-sample metric fields (sentence_bleu4, sentence_rouge_l, flags, ...) are
15
+ dropped; only ``image`` and ``prediction`` are carried forward, then the full
16
+ 5-reference set is joined from the official COCO annotations.
17
+
18
+ Workflow (two modes)
19
+ --------------------
20
+ PREPARE (default, no --categories):
21
+ Select the blinded sample, join 5 COCO refs, PRINT each sample as a
22
+ numbered block, and write a pending --output (category/justification
23
+ = null). The operator/agent then categorizes each sample BY JUDGMENT
24
+ using the rubric below.
25
+ FINALIZE (--categories PATH):
26
+ Read a JSONL of {sample_id, category, justification}, validate every
27
+ category against the four allowed values (invented categories raise),
28
+ enforce the <=25-word justification limit, merge, write the final
29
+ --output, and print category COUNTS (never naked percentages).
30
+
31
+ CATEGORIZATION RUBRIC
32
+ ---------------------
33
+ Each prediction is assigned EXACTLY ONE category:
34
+
35
+ SPECIFIC-CORRECT
36
+ Caption identifies the main subject correctly AND includes at least one
37
+ distinguishing attribute that meaningfully constrains the image — color,
38
+ count, named action, spatial relation, or a named secondary object.
39
+ Example: "a woman riding a brown horse on a beach."
40
+
41
+ GENERIC-CORRECT
42
+ Caption identifies the main subject correctly but lacks any distinguishing
43
+ attribute — the same caption could describe many photos of the category.
44
+ Example: "a person on a beach."
45
+
46
+ PARTIALLY-CORRECT
47
+ Identifies at least one element correctly and gets at least one other
48
+ element wrong (wrong color, wrong count, wrong action, wrong secondary
49
+ object). Example for an image of two skiers: "a man riding skis."
50
+
51
+ INCORRECT
52
+ Misidentifies the main subject, is incoherent, or describes a scene
53
+ clearly absent from all 5 references.
54
+
55
+ COMBINED DECISION RULE (BLEU verdict from Part A + SPECIFIC-CORRECT count from
56
+ Part B):
57
+
58
+ BLEU DOMINANT (>=18) AND SPECIFIC-CORRECT >= 12/30
59
+ -> Strong evidence for the metric-parity reframe. Don't retrain.
60
+ Pivot to Stage 7 reframe in the plan.
61
+
62
+ BLEU MAJOR-BUT-PARTIAL (14-18) AND SPECIFIC-CORRECT >= 15/30
63
+ -> Qualitative is strong enough to ship without retraining.
64
+ Reframe with a "BLEU underestimates this checkpoint" note.
65
+
66
+ BLEU MAJOR-BUT-PARTIAL (14-18) AND SPECIFIC-CORRECT < 10/30
67
+ -> Qualitative confirms BLEU. Retrain (Kaggle Stage 1).
68
+
69
+ BLEU MINOR (<=13)
70
+ -> Retrain regardless of qualitative. Metric gap is too large to argue
71
+ around with 30 samples.
72
+
73
+ Any combination not covered above (e.g. DOMINANT with SPECIFIC-CORRECT <12,
74
+ or MAJOR-BUT-PARTIAL with SPECIFIC-CORRECT 10-14)
75
+ -> Flag for human review. Do not auto-decide.
76
+
77
+ Usage
78
+ -----
79
+ # PREPARE the blinded worklist:
80
+ python -m scripts.categorize_predictions \
81
+ --coco-annotations /path/to/captions_train2017.json
82
+
83
+ # FINALIZE after judgment:
84
+ python -m scripts.categorize_predictions \
85
+ --coco-annotations /path/to/captions_train2017.json \
86
+ --categories results/stabilized-beam-w4-lp07-rp12/categories.jsonl
87
+ """
88
+
89
+ from __future__ import annotations
90
+
91
+ import json
92
+ import random
93
+ from collections import Counter
94
+ from pathlib import Path
95
+
96
+ import click
97
+
98
+ # The four categories are fixed. Renaming or adding to this set (e.g.
99
+ # "ALMOST-SPECIFIC") is forbidden — finalize mode raises on any other value.
100
+ ALLOWED_CATEGORIES = ("SPECIFIC-CORRECT", "GENERIC-CORRECT", "PARTIALLY-CORRECT", "INCORRECT")
101
+ MAX_JUSTIFICATION_WORDS = 25
102
+
103
+ _RESULTS_DIR = Path("results/stabilized-beam-w4-lp07-rp12")
104
+
105
+
106
+ def _image_id(image_path: str) -> int:
107
+ return int(Path(image_path).stem)
108
+
109
+
110
+ def _load_coco_refs(path: Path) -> dict[int, list[str]]:
111
+ data = json.loads(path.read_text(encoding="utf-8"))
112
+ refs: dict[int, list[str]] = {}
113
+ for ann in data["annotations"]:
114
+ refs.setdefault(int(ann["image_id"]), []).append(ann["caption"])
115
+ return refs
116
+
117
+
118
+ def _read_jsonl(path: Path) -> list[dict]:
119
+ rows: list[dict] = []
120
+ with path.open(encoding="utf-8") as f:
121
+ for line in f:
122
+ line = line.strip()
123
+ if line:
124
+ rows.append(json.loads(line))
125
+ return rows
126
+
127
+
128
+ def _select_sample(predictions_path: Path, sample_size: int, seed: int) -> list[dict]:
129
+ """Return [{image, prediction}] for the blinded sample (metrics dropped)."""
130
+ qualitative = _RESULTS_DIR / "qualitative.jsonl"
131
+ if qualitative.exists():
132
+ rows = _read_jsonl(qualitative)
133
+ if len(rows) >= sample_size:
134
+ # Blinding: carry ONLY image + prediction; drop sentence_bleu4,
135
+ # sentence_rouge_l, flags, length_tokens, etc.
136
+ return [
137
+ {"image": r["image"], "prediction": r["prediction"]} for r in rows[:sample_size]
138
+ ]
139
+ rows = _read_jsonl(predictions_path)
140
+ picks = random.Random(seed).sample(rows, k=min(sample_size, len(rows)))
141
+ return [{"image": r["image"], "prediction": r["prediction"]} for r in picks]
142
+
143
+
144
+ def _join_refs(sample: list[dict], coco: dict[int, list[str]]) -> list[dict]:
145
+ """Attach the full COCO ref list; raise (no fallback) on missing ids."""
146
+ missing = [_image_id(s["image"]) for s in sample if _image_id(s["image"]) not in coco]
147
+ if missing:
148
+ raise click.ClickException(
149
+ f"{len(missing)} sample image_id(s) absent from the annotations file. "
150
+ f"First 5: {missing[:5]}. Refusing to proceed with partial references."
151
+ )
152
+ out: list[dict] = []
153
+ for s in sample:
154
+ iid = _image_id(s["image"])
155
+ out.append({"sample_id": str(iid), "prediction": s["prediction"], "refs": coco[iid]})
156
+ return out
157
+
158
+
159
+ @click.command()
160
+ @click.option(
161
+ "--predictions-path",
162
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
163
+ default=_RESULTS_DIR / "predictions.jsonl",
164
+ help="predictions.jsonl (fallback sample source if qualitative.jsonl is absent).",
165
+ )
166
+ @click.option(
167
+ "--coco-annotations",
168
+ required=True,
169
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
170
+ help="captions_train2017.json — full 5-reference set per image.",
171
+ )
172
+ @click.option("--sample-size", type=int, default=30, help="Number of samples to categorize.")
173
+ @click.option("--seed", type=int, default=42, help="RNG seed (only used for the fallback sampler).")
174
+ @click.option(
175
+ "--output",
176
+ "output_path",
177
+ type=click.Path(path_type=Path),
178
+ default=_RESULTS_DIR / "qualitative_categorized.jsonl",
179
+ help="Where the categorized rows are written.",
180
+ )
181
+ @click.option(
182
+ "--categories",
183
+ "categories_path",
184
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
185
+ default=None,
186
+ help="JSONL of {sample_id, category, justification}. Omit for PREPARE mode.",
187
+ )
188
+ def main(
189
+ predictions_path: Path,
190
+ coco_annotations: Path,
191
+ sample_size: int,
192
+ seed: int,
193
+ output_path: Path,
194
+ categories_path: Path | None,
195
+ ) -> None:
196
+ """Prepare a blinded sample (default) or finalize a categorized sample."""
197
+ coco = _load_coco_refs(coco_annotations)
198
+ sample = _select_sample(predictions_path, sample_size, seed)
199
+ joined = _join_refs(sample, coco)
200
+ n = len(joined)
201
+
202
+ if categories_path is None:
203
+ # ---- PREPARE: print blinded worklist, write pending output ---------
204
+ click.echo(f"Blinded worklist: {n} samples (predictions + 5 refs only; NO metrics shown).")
205
+ click.echo("Categorize each per the rubric in this script's docstring, then re-run")
206
+ click.echo("with --categories pointing at a JSONL of {sample_id, category, justification}.")
207
+ click.echo("=" * 80)
208
+ for i, row in enumerate(joined, start=1):
209
+ click.echo(f"[{i:>2}] sample_id={row['sample_id']}")
210
+ click.echo(f" PRED: {row['prediction']}")
211
+ for j, ref in enumerate(row["refs"], start=1):
212
+ click.echo(f" ref{j}: {ref}")
213
+ click.echo("-" * 80)
214
+ pending = [
215
+ {
216
+ "sample_id": r["sample_id"],
217
+ "prediction": r["prediction"],
218
+ "refs": r["refs"],
219
+ "category": None,
220
+ "justification": None,
221
+ }
222
+ for r in joined
223
+ ]
224
+ with output_path.open("w", encoding="utf-8") as f:
225
+ for row in pending:
226
+ f.write(json.dumps(row) + "\n")
227
+ click.echo(f"Wrote pending worklist ({n} rows, categories null): {output_path}")
228
+ return
229
+
230
+ # ---- FINALIZE: validate, merge, write, count ---------------------------
231
+ cat_rows = {r["sample_id"]: r for r in _read_jsonl(categories_path)}
232
+ final: list[dict] = []
233
+ for row in joined:
234
+ sid = row["sample_id"]
235
+ if sid not in cat_rows:
236
+ raise click.ClickException(f"sample_id {sid} missing from {categories_path}.")
237
+ category = cat_rows[sid]["category"]
238
+ justification = cat_rows[sid].get("justification", "")
239
+ if category not in ALLOWED_CATEGORIES:
240
+ raise click.ClickException(
241
+ f"sample_id {sid}: category {category!r} is not one of {ALLOWED_CATEGORIES}. "
242
+ "The rubric's four categories may not be renamed or expanded."
243
+ )
244
+ if len(str(justification).split()) > MAX_JUSTIFICATION_WORDS:
245
+ raise click.ClickException(
246
+ f"sample_id {sid}: justification exceeds {MAX_JUSTIFICATION_WORDS} words."
247
+ )
248
+ final.append(
249
+ {
250
+ "sample_id": sid,
251
+ "prediction": row["prediction"],
252
+ "refs": row["refs"],
253
+ "category": category,
254
+ "justification": justification,
255
+ }
256
+ )
257
+
258
+ with output_path.open("w", encoding="utf-8") as f:
259
+ for row in final:
260
+ f.write(json.dumps(row) + "\n")
261
+
262
+ counts = Counter(r["category"] for r in final)
263
+ click.echo(f"Categorized {n} samples -> {output_path}")
264
+ click.echo("Counts:")
265
+ click.echo(" " + ", ".join(f"{counts.get(c, 0)}/{n} {c}" for c in ALLOWED_CATEGORIES))
266
+ specific = counts.get("SPECIFIC-CORRECT", 0)
267
+ click.echo("")
268
+ click.echo(f"SPECIFIC-CORRECT = {specific}/{n} (input to the COMBINED DECISION RULE)")
269
+ click.echo("Apply the COMBINED DECISION RULE in this script's docstring together with the")
270
+ click.echo("Part A band. This script does NOT read the BLEU output — do not auto-decide here.")
271
+ click.echo(
272
+ f"Note: with N={n}, a proportion carries roughly +/-18% sampling margin; "
273
+ "report counts, not point-estimate percentages."
274
+ )
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
scripts/rescore_nltk_bleu.py ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-off diagnostic: rescore committed predictions under varying BLEU regimes.
2
+
3
+ This script isolates the two evaluation-methodology axes that separate this
4
+ repo's metrics from the IEEE notebook's reported BLEU-4 ~24:
5
+
6
+ * Axis A — aggregation + smoothing (sacrebleu corpus vs NLTK smoothed
7
+ sentence-BLEU). A previous rescore showed this is a wash under
8
+ defensible smoothers when scored against the same references.
9
+ * Axis B — reference count. The committed predictions.jsonl carries only
10
+ ~1.46 references/image (most have a single reference), not COCO's
11
+ canonical 5. This script joins the full 5-reference set from the
12
+ official annotations file and rescores against it.
13
+
14
+ When ``--coco-annotations`` is omitted the script reproduces the original
15
+ ~1.46-ref behaviour, so the two reference-count regimes can be compared
16
+ side-by-side in the same session.
17
+
18
+ The scripts that gate the Kaggle retraining run are split in two — this one
19
+ (BLEU) and ``scripts/categorize_predictions.py`` (blinded qualitative read) —
20
+ and are run in SEPARATE turns so the BLEU number cannot bias the qualitative
21
+ categorization. The two scripts share no code and no state.
22
+
23
+ Usage
24
+ -----
25
+ # 5-ref gating test (the real test):
26
+ python -m scripts.rescore_nltk_bleu \
27
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl \
28
+ --coco-annotations /path/to/captions_train2017.json
29
+
30
+ # 1.46-ref reproduction (omit --coco-annotations):
31
+ python -m scripts.rescore_nltk_bleu \
32
+ --predictions-path results/stabilized-beam-w4-lp07-rp12/predictions.jsonl
33
+
34
+ PRE-REGISTERED BLEU PREDICTION
35
+ ------------------------------
36
+ HYPOTHESIS: Reference-count is the dominant remaining axis of the IEEE eval
37
+ methodology gap (Axis A — aggregation+smoothing — was already shown to be a
38
+ wash by the previous rescore under defensible smoothers).
39
+
40
+ PREDICTION: 5-ref sacrebleu corpus BLEU-4 will land >= 18.
41
+
42
+ DECISION RULE (BLEU-only):
43
+ >= 18 -> DOMINANT. Methodology (reference count) dominates the IEEE gap.
44
+ 14-18 -> MAJOR-BUT-PARTIAL. Methodology is a major but partial factor.
45
+ <= 13 -> MINOR. Methodology contributes ~3 points at most. Checkpoint
46
+ genuinely underperforms IEEE.
47
+
48
+ SECONDARY: 5-ref NLTK method1 should land within ~1 point of 5-ref sacrebleu
49
+ corpus. If it diverges by >3 points, Axis A is not a wash under 5 refs after
50
+ all and the multi-axis analysis above needs revision.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import json
56
+ from collections.abc import Sequence
57
+ from pathlib import Path
58
+
59
+ import click
60
+ from nltk.translate.bleu_score import SmoothingFunction, sentence_bleu
61
+
62
+ # Sentinels the training pipeline wraps captions in (mirrors
63
+ # captioning.preprocessing.caption). Inlined so this one-off stays standalone
64
+ # and TF-free (fast to run as a Kaggle cell).
65
+ START_TOKEN = "[start]"
66
+ END_TOKEN = "[end]"
67
+
68
+ # Caption normalisation identical to captioning.preprocessing.caption
69
+ # .preprocess_caption MINUS the sentinel wrap: lowercase, strip punctuation,
70
+ # collapse whitespace. Applied to raw COCO captions so the 5-ref set lands in
71
+ # the SAME token space as the predictions and the stored 1.46-ref set —
72
+ # otherwise the reference-count axis would be contaminated by a tokenisation
73
+ # mismatch.
74
+ import re # noqa: E402 (kept next to the patterns it owns)
75
+
76
+ _PUNCTUATION_RE = re.compile(r"[^\w\s]")
77
+ _WHITESPACE_RE = re.compile(r"\s+")
78
+
79
+ # Cumulative BLEU-n weight vectors (uniform over the first n orders).
80
+ _CUMULATIVE_WEIGHTS = {
81
+ 1: (1.0, 0.0, 0.0, 0.0),
82
+ 2: (0.5, 0.5, 0.0, 0.0),
83
+ 3: (1 / 3, 1 / 3, 1 / 3, 0.0),
84
+ 4: (0.25, 0.25, 0.25, 0.25),
85
+ }
86
+
87
+ _SMOOTHERS = {
88
+ "method0": SmoothingFunction().method0,
89
+ "method1": SmoothingFunction().method1,
90
+ "method4": SmoothingFunction().method4,
91
+ "method7": SmoothingFunction().method7,
92
+ }
93
+
94
+
95
+ def _normalize(text: str) -> str:
96
+ """Lowercase, strip punctuation, collapse whitespace (no sentinels)."""
97
+ if not text:
98
+ return ""
99
+ text = text.lower()
100
+ text = _PUNCTUATION_RE.sub("", text)
101
+ text = _WHITESPACE_RE.sub(" ", text)
102
+ return text.strip()
103
+
104
+
105
+ def _strip_sentinels(caption: str) -> str:
106
+ """Remove [start]/[end], lowercase, collapse whitespace."""
107
+ if not caption:
108
+ return ""
109
+ cleaned = caption.replace(START_TOKEN, " ").replace(END_TOKEN, " ")
110
+ return _normalize(cleaned)
111
+
112
+
113
+ def _image_id(image_path: str) -> int:
114
+ """COCO image_id from a .../train2017/000000530117.jpg path."""
115
+ stem = Path(image_path).stem
116
+ return int(stem) # raises ValueError on a non-numeric stem (malformed path)
117
+
118
+
119
+ def _load_predictions(path: Path) -> list[dict]:
120
+ rows: list[dict] = []
121
+ with path.open(encoding="utf-8") as f:
122
+ for line in f:
123
+ line = line.strip()
124
+ if line:
125
+ rows.append(json.loads(line))
126
+ return rows
127
+
128
+
129
+ def _load_coco_refs(path: Path) -> dict[int, list[str]]:
130
+ """Build {image_id: [raw captions...]} from captions_train2017.json."""
131
+ data = json.loads(path.read_text(encoding="utf-8"))
132
+ refs: dict[int, list[str]] = {}
133
+ for ann in data["annotations"]:
134
+ refs.setdefault(int(ann["image_id"]), []).append(ann["caption"])
135
+ return refs
136
+
137
+
138
+ def _refs_by_slot(references: Sequence[Sequence[str]]) -> list[list[str]]:
139
+ """Ragged per-example references -> sacrebleu per-slot layout."""
140
+ max_refs = max((len(r) for r in references), default=0)
141
+ return [[refs[i] if i < len(refs) else "" for refs in references] for i in range(max_refs)]
142
+
143
+
144
+ def _sacrebleu_breakdown(preds: list[str], references: list[list[str]]) -> dict[int, float]:
145
+ """sacrebleu corpus BLEU-1..4, same config as captioning.evaluation.bleu."""
146
+ import sacrebleu
147
+
148
+ refs_by_slot = _refs_by_slot(references)
149
+ out: dict[int, float] = {}
150
+ for n in (1, 2, 3, 4):
151
+ scorer = sacrebleu.metrics.BLEU(max_ngram_order=n, effective_order=True)
152
+ out[n] = float(scorer.corpus_score(preds, refs_by_slot).score)
153
+ return out
154
+
155
+
156
+ def _nltk_macro_breakdown(
157
+ hyps: list[list[str]], refs: list[list[list[str]]], smoother
158
+ ) -> dict[int, float]:
159
+ """Macro-averaged sentence BLEU-1..4 (0-100) for a given smoother."""
160
+ sums = {n: 0.0 for n in (1, 2, 3, 4)}
161
+ for hyp, ref_list in zip(hyps, refs, strict=True):
162
+ if not hyp:
163
+ continue
164
+ for n in (1, 2, 3, 4):
165
+ sums[n] += sentence_bleu(
166
+ ref_list, hyp, weights=_CUMULATIVE_WEIGHTS[n], smoothing_function=smoother
167
+ )
168
+ count = len(hyps) or 1
169
+ return {n: 100.0 * sums[n] / count for n in (1, 2, 3, 4)}
170
+
171
+
172
+ def _band(bleu4: float) -> str:
173
+ """Map 5-ref sacrebleu corpus BLEU-4 to the pre-registered band."""
174
+ if bleu4 >= 18.0:
175
+ return "DOMINANT"
176
+ if bleu4 >= 14.0:
177
+ return "MAJOR-BUT-PARTIAL"
178
+ if bleu4 <= 13.0:
179
+ return "MINOR"
180
+ return "BOUNDARY-13-14-REVIEW" # 13 < x < 14 — left undefined by the spec
181
+
182
+
183
+ @click.command()
184
+ @click.option(
185
+ "--predictions-path",
186
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
187
+ default=Path("results/stabilized-beam-w4-lp07-rp12/predictions.jsonl"),
188
+ help="predictions.jsonl from a scripts.evaluate run.",
189
+ )
190
+ @click.option(
191
+ "--coco-annotations",
192
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
193
+ default=None,
194
+ help="captions_train2017.json. When given, scores against all 5 COCO refs; "
195
+ "when omitted, reproduces the ~1.46-ref behaviour.",
196
+ )
197
+ @click.option(
198
+ "--smoother",
199
+ type=click.Choice(list(_SMOOTHERS)),
200
+ default="method1",
201
+ help="Primary NLTK smoother for the headline table (method4 always also shown).",
202
+ )
203
+ def main(predictions_path: Path, coco_annotations: Path | None, smoother: str) -> None:
204
+ """Rescore predictions; in 5-ref mode emit the pre-registered band."""
205
+ rows = _load_predictions(predictions_path)
206
+ five_ref = coco_annotations is not None
207
+
208
+ # ---- Build the active reference set ------------------------------------
209
+ preds: list[str] = []
210
+ refs_sacre: list[list[str]] = [] # normalised strings, per example
211
+ if coco_annotations is not None:
212
+ coco = _load_coco_refs(coco_annotations)
213
+ missing: list[int] = []
214
+ for rec in rows:
215
+ iid = _image_id(rec["image"])
216
+ if iid not in coco:
217
+ missing.append(iid)
218
+ if missing:
219
+ raise click.ClickException(
220
+ f"{len(missing)} prediction image_id(s) absent from "
221
+ f"{coco_annotations}. First 5: {missing[:5]}. "
222
+ "Refusing to fall back to single-ref scoring."
223
+ )
224
+ for rec in rows:
225
+ iid = _image_id(rec["image"])
226
+ preds.append(_normalize(rec["prediction"]))
227
+ refs_sacre.append([_normalize(c) for c in coco[iid]])
228
+ else:
229
+ for rec in rows:
230
+ preds.append(_normalize(rec["prediction"]))
231
+ refs_sacre.append([_strip_sentinels(r) for r in rec["references"]])
232
+
233
+ # NLTK works on token lists.
234
+ hyps_tok = [p.split() for p in preds]
235
+ refs_tok = [[r.split() for r in ref_list if r] for ref_list in refs_sacre]
236
+
237
+ # ---- Reference-count stats (A2) ----------------------------------------
238
+ counts = [len(r) for r in refs_sacre]
239
+ n = len(counts)
240
+ ref_stats = {
241
+ "n_examples": n,
242
+ "mean": round(sum(counts) / n, 4) if n else 0.0,
243
+ "min": min(counts) if counts else 0,
244
+ "max": max(counts) if counts else 0,
245
+ "n_lt_5": sum(1 for c in counts if c < 5),
246
+ }
247
+
248
+ # ---- Metrics -----------------------------------------------------------
249
+ new_sacre = _sacrebleu_breakdown(preds, refs_sacre)
250
+ new_primary = _nltk_macro_breakdown(hyps_tok, refs_tok, _SMOOTHERS[smoother])
251
+ new_method4 = _nltk_macro_breakdown(hyps_tok, refs_tok, _SMOOTHERS["method4"])
252
+
253
+ metrics_path = predictions_path.parent / "metrics.json"
254
+ committed = (
255
+ json.loads(metrics_path.read_text(encoding="utf-8")) if metrics_path.exists() else {}
256
+ )
257
+ committed_bleu = {n_: committed.get(f"bleu{n_}", float("nan")) for n_ in (1, 2, 3, 4)}
258
+
259
+ regime = "5-ref" if five_ref else "1.46-ref"
260
+
261
+ # ---- Headline four-column table (NO method7) ---------------------------
262
+ click.echo(f"Predictions : {predictions_path}")
263
+ click.echo(
264
+ f"Reference set : {regime} "
265
+ f"(mean {ref_stats['mean']}/image, min {ref_stats['min']}, "
266
+ f"max {ref_stats['max']}, {ref_stats['n_lt_5']}/{n} have <5 refs)"
267
+ )
268
+ click.echo("")
269
+ header = (
270
+ f" {'metric':<8}{'committed sacre':>16}{f'new sacre ({regime})':>20}"
271
+ f"{f'NLTK {smoother}':>16}{'NLTK method4':>16}"
272
+ )
273
+ click.echo(header)
274
+ for n_ in (1, 2, 3, 4):
275
+ click.echo(
276
+ f" BLEU-{n_:<3}{committed_bleu[n_]:>16.2f}{new_sacre[n_]:>20.2f}"
277
+ f"{new_primary[n_]:>16.2f}{new_method4[n_]:>16.2f}"
278
+ )
279
+ click.echo("")
280
+
281
+ # ---- Secondary check + band (5-ref only) -------------------------------
282
+ if five_ref:
283
+ delta = new_primary[4] - new_sacre[4]
284
+ axis_a_wash = abs(delta) <= 3.0
285
+ band = _band(new_sacre[4])
286
+ click.echo(f"SECONDARY CHECK: NLTK method1 BLEU-4 - sacrebleu corpus BLEU-4 = {delta:+.2f}")
287
+ click.echo(
288
+ " -> Axis A is a wash under 5 refs (|delta| <= 3)."
289
+ if axis_a_wash
290
+ else " -> Axis A is NOT a wash under 5 refs (|delta| > 3); revise the multi-axis analysis."
291
+ )
292
+ click.echo("")
293
+ click.echo(
294
+ f"PRE-REGISTERED BAND (5-ref sacrebleu corpus BLEU-4 = {new_sacre[4]:.2f}): {band}"
295
+ )
296
+ click.echo("")
297
+
298
+ out = {
299
+ "predictions_path": str(predictions_path),
300
+ "coco_annotations": str(coco_annotations),
301
+ "ref_stats": ref_stats,
302
+ "committed_1p46ref_sacrebleu": {f"bleu{k}": committed_bleu[k] for k in (1, 2, 3, 4)},
303
+ "new_5ref": {
304
+ "sacrebleu_corpus": {f"bleu{k}": new_sacre[k] for k in (1, 2, 3, 4)},
305
+ f"nltk_{smoother}": {f"bleu{k}": new_primary[k] for k in (1, 2, 3, 4)},
306
+ "nltk_method4": {f"bleu{k}": new_method4[k] for k in (1, 2, 3, 4)},
307
+ },
308
+ "band": band,
309
+ "band_basis": "5ref_sacrebleu_corpus_bleu4",
310
+ "secondary_check": {
311
+ "method1_vs_corpus_bleu4_delta": round(delta, 4),
312
+ "axis_a_wash_under_5ref": axis_a_wash,
313
+ },
314
+ }
315
+ out_path = predictions_path.parent / "metrics_5ref.json"
316
+ out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
317
+ click.echo(f"Wrote: {out_path}")
318
+ else:
319
+ click.echo(
320
+ "1.46-ref mode: band + metrics_5ref.json skipped "
321
+ "(pass --coco-annotations to run the gating test)."
322
+ )
323
+
324
+ # ---- Diagnostic: smoother sensitivity (NOT headline) -------------------
325
+ click.echo("")
326
+ click.echo("DIAGNOSTIC (smoother sensitivity — NOT the headline; method7 is known to inflate):")
327
+ click.echo(f" {'smoother':<10}{'BLEU-4':>10}")
328
+ for name in ("method0", "method1", "method4", "method7"):
329
+ b4 = _nltk_macro_breakdown(hyps_tok, refs_tok, _SMOOTHERS[name])[4]
330
+ click.echo(f" {name:<10}{b4:>10.2f}")
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()