capit-deploy commited on
Commit
a5ec84d
·
0 Parent(s):

deploy capit backend

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +15 -0
  2. Dockerfile +37 -0
  3. README.md +16 -0
  4. backend/Dockerfile +37 -0
  5. backend/app.py +127 -0
  6. backend/pyproject.toml +29 -0
  7. backend/scripts/bench_blip.py +119 -0
  8. backend/tests/test_app.py +78 -0
  9. backend/uv.lock +0 -0
  10. pipeline/.python-version +1 -0
  11. pipeline/__init__.py +0 -0
  12. pipeline/notebooks/colab_train.ipynb +264 -0
  13. pipeline/pyproject.toml +36 -0
  14. pipeline/scripts/build_vocab.py +21 -0
  15. pipeline/scripts/export_artifact.py +132 -0
  16. pipeline/scripts/make_subsample.py +39 -0
  17. pipeline/scripts/make_train_zip.py +35 -0
  18. pipeline/scripts/overfit_one_batch.py +20 -0
  19. pipeline/scripts/serve_demo.py +34 -0
  20. pipeline/scripts/visualize_attention.py +115 -0
  21. pipeline/src/capit/__init__.py +0 -0
  22. pipeline/src/capit/checkpoint.py +58 -0
  23. pipeline/src/capit/config.py +92 -0
  24. pipeline/src/capit/data/__init__.py +0 -0
  25. pipeline/src/capit/data/dataset.py +78 -0
  26. pipeline/src/capit/data/download.py +105 -0
  27. pipeline/src/capit/data/records.py +29 -0
  28. pipeline/src/capit/data/vocab.py +57 -0
  29. pipeline/src/capit/decode.py +80 -0
  30. pipeline/src/capit/evaluate.py +89 -0
  31. pipeline/src/capit/losses.py +23 -0
  32. pipeline/src/capit/models/__init__.py +0 -0
  33. pipeline/src/capit/models/attention.py +29 -0
  34. pipeline/src/capit/models/decoder.py +93 -0
  35. pipeline/src/capit/models/encoder.py +34 -0
  36. pipeline/src/capit/overfit.py +65 -0
  37. pipeline/src/capit/serving.py +97 -0
  38. pipeline/src/capit/train.py +146 -0
  39. pipeline/tests/test_attention.py +68 -0
  40. pipeline/tests/test_checkpoint.py +63 -0
  41. pipeline/tests/test_dataset.py +109 -0
  42. pipeline/tests/test_decode.py +45 -0
  43. pipeline/tests/test_decoder.py +84 -0
  44. pipeline/tests/test_download.py +62 -0
  45. pipeline/tests/test_encoder.py +87 -0
  46. pipeline/tests/test_evaluate.py +37 -0
  47. pipeline/tests/test_losses.py +39 -0
  48. pipeline/tests/test_overfit.py +25 -0
  49. pipeline/tests/test_serving.py +53 -0
  50. pipeline/tests/test_subsample.py +73 -0
.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # keep the build context tiny — the Dockerfile only needs pipeline/ + backend/ source.
2
+ # weights come from the Hub at build time, not the context.
3
+ .git
4
+ **/.venv
5
+ **/__pycache__
6
+ **/*.egg-info
7
+ **/.pytest_cache
8
+ **/.ruff_cache
9
+ **/.mypy_cache
10
+ data/
11
+ frontend/
12
+ docs/
13
+ assets/
14
+ **/node_modules
15
+ *.zip
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # capit backend — HF Spaces (Docker SDK). Build context = repo root:
2
+ # docker build -f backend/Dockerfile -t capit-api .
3
+ FROM python:3.12-slim
4
+
5
+ # uv, pinned to what the lockfile was authored with
6
+ COPY --from=ghcr.io/astral-sh/uv:0.9.9 /uv /uvx /bin/
7
+
8
+ # HF Spaces require a non-root user with UID 1000; set it up before any COPY
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ ENV HOME=/home/user \
12
+ HF_HOME=/home/user/.cache/huggingface \
13
+ UV_PROJECT_ENVIRONMENT=/home/user/venv \
14
+ UV_LINK_MODE=copy \
15
+ UV_COMPILE_BYTECODE=1 \
16
+ CAPIT_ARTIFACT_REPO=Bukunmi2108/capit-sat \
17
+ PATH=/home/user/venv/bin:$PATH
18
+
19
+ WORKDIR /home/user/app
20
+
21
+ # the capit package (model classes) the backend imports, then the backend project
22
+ COPY --chown=user pipeline/ ./pipeline/
23
+ COPY --chown=user backend/ ./backend/
24
+
25
+ WORKDIR /home/user/app/backend
26
+ RUN uv sync --frozen --no-dev
27
+
28
+ # bake weights into image layers so the Space has no cold-start downloads
29
+ RUN uv run python -c "from huggingface_hub import hf_hub_download as d; d('Bukunmi2108/capit-sat','capit-sat.pt'); d('Bukunmi2108/capit-sat','vocab.json')" \
30
+ && uv run python -c "from transformers import BlipForConditionalGeneration as M, BlipProcessor as P; m='Salesforce/blip-image-captioning-base'; P.from_pretrained(m); M.from_pretrained(m)"
31
+
32
+ # weights are baked above — serve from cache only, no runtime Hub calls (faster cold start)
33
+ ENV HF_HUB_OFFLINE=1 \
34
+ TRANSFORMERS_OFFLINE=1
35
+
36
+ EXPOSE 7860
37
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: capit
3
+ emoji: 🔎
4
+ colorFrom: red
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ short_description: A glass-box image captioner (Show, Attend and Tell) vs BLIP.
10
+ ---
11
+
12
+ # capit — backend
13
+
14
+ Side-by-side captioning API: a from-scratch *Show, Attend and Tell* model (glass box —
15
+ per-word attention + rejected beams) and BLIP (closed box). Code:
16
+ https://github.com/Bukunmi2108/capit
backend/Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # capit backend — HF Spaces (Docker SDK). Build context = repo root:
2
+ # docker build -f backend/Dockerfile -t capit-api .
3
+ FROM python:3.12-slim
4
+
5
+ # uv, pinned to what the lockfile was authored with
6
+ COPY --from=ghcr.io/astral-sh/uv:0.9.9 /uv /uvx /bin/
7
+
8
+ # HF Spaces require a non-root user with UID 1000; set it up before any COPY
9
+ RUN useradd -m -u 1000 user
10
+ USER user
11
+ ENV HOME=/home/user \
12
+ HF_HOME=/home/user/.cache/huggingface \
13
+ UV_PROJECT_ENVIRONMENT=/home/user/venv \
14
+ UV_LINK_MODE=copy \
15
+ UV_COMPILE_BYTECODE=1 \
16
+ CAPIT_ARTIFACT_REPO=Bukunmi2108/capit-sat \
17
+ PATH=/home/user/venv/bin:$PATH
18
+
19
+ WORKDIR /home/user/app
20
+
21
+ # the capit package (model classes) the backend imports, then the backend project
22
+ COPY --chown=user pipeline/ ./pipeline/
23
+ COPY --chown=user backend/ ./backend/
24
+
25
+ WORKDIR /home/user/app/backend
26
+ RUN uv sync --frozen --no-dev
27
+
28
+ # bake weights into image layers so the Space has no cold-start downloads
29
+ RUN uv run python -c "from huggingface_hub import hf_hub_download as d; d('Bukunmi2108/capit-sat','capit-sat.pt'); d('Bukunmi2108/capit-sat','vocab.json')" \
30
+ && uv run python -c "from transformers import BlipForConditionalGeneration as M, BlipProcessor as P; m='Salesforce/blip-image-captioning-base'; P.from_pretrained(m); M.from_pretrained(m)"
31
+
32
+ # weights are baked above — serve from cache only, no runtime Hub calls (faster cold start)
33
+ ENV HF_HUB_OFFLINE=1 \
34
+ TRANSFORMERS_OFFLINE=1
35
+
36
+ EXPOSE 7860
37
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
backend/app.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """capit backend: side-by-side captions from the glass-box SAT model and BLIP.
2
+
3
+ Loads the Stage 4.4 serving artifact (local dir or HF Hub) + BLIP, exposes /health and
4
+ /caption. The SAT path returns per-word attention, the center-crop box, and the rejected beams.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import os
11
+ import time
12
+ from contextlib import asynccontextmanager
13
+ from pathlib import Path
14
+
15
+ import torch
16
+ from fastapi import FastAPI, HTTPException, Query, UploadFile
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from PIL import Image, ImageOps, UnidentifiedImageError
19
+ from transformers import BlipForConditionalGeneration, BlipProcessor
20
+
21
+ from capit.serving import caption as sat_caption
22
+ from capit.serving import center_crop_box, load_artifact, make_transform
23
+
24
+ MAX_BYTES = 8 * 1024 * 1024
25
+ BLIP_MODEL = "Salesforce/blip-image-captioning-base"
26
+ ARTIFACT_REPO = os.environ.get("CAPIT_ARTIFACT_REPO")
27
+ ARTIFACT_DIR = Path(os.environ.get("CAPIT_ARTIFACT_DIR", Path(__file__).resolve().parents[1] / "data" / "artifact"))
28
+ ALLOWED_ORIGINS = os.environ.get("CAPIT_CORS", "http://localhost:5173,http://localhost:3000").split(",")
29
+
30
+ state: dict = {}
31
+
32
+
33
+ def _artifact_paths() -> tuple[Path, Path]:
34
+ """Hub repo (Space; baked into the image cache at build) or a local dir (dev)."""
35
+ if ARTIFACT_REPO:
36
+ from huggingface_hub import hf_hub_download
37
+
38
+ return (
39
+ Path(hf_hub_download(ARTIFACT_REPO, "capit-sat.pt")),
40
+ Path(hf_hub_download(ARTIFACT_REPO, "vocab.json")),
41
+ )
42
+ return ARTIFACT_DIR / "capit-sat.pt", ARTIFACT_DIR / "vocab.json"
43
+
44
+
45
+ @asynccontextmanager
46
+ async def lifespan(app: FastAPI):
47
+ torch.set_num_threads(2)
48
+ artifact, vocab_path = _artifact_paths()
49
+ encoder, decoder, vocab, preprocess = load_artifact(artifact, vocab_path)
50
+ state.update(
51
+ encoder=encoder,
52
+ decoder=decoder,
53
+ vocab=vocab,
54
+ transform=make_transform(preprocess),
55
+ preprocess=preprocess,
56
+ blip_processor=BlipProcessor.from_pretrained(BLIP_MODEL),
57
+ blip=BlipForConditionalGeneration.from_pretrained(BLIP_MODEL).eval(),
58
+ )
59
+ yield
60
+ state.clear()
61
+
62
+
63
+ app = FastAPI(title="Capit", lifespan=lifespan)
64
+ app.add_middleware(
65
+ CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_methods=["GET", "POST"], allow_headers=["*"]
66
+ )
67
+
68
+
69
+ async def _ingest(file: UploadFile) -> Image.Image:
70
+ body = await file.read(MAX_BYTES + 1)
71
+ if len(body) > MAX_BYTES:
72
+ raise HTTPException(413, f"image exceeds {MAX_BYTES // (1024 * 1024)} MB")
73
+ try:
74
+ image = Image.open(io.BytesIO(body))
75
+ image.load()
76
+ except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
77
+ raise HTTPException(422, f"could not decode image: {exc}") from exc
78
+ return (ImageOps.exif_transpose(image) or image).convert("RGB")
79
+
80
+
81
+ @torch.no_grad()
82
+ def _sat(image: Image.Image, beam: int) -> dict:
83
+ t0 = time.perf_counter()
84
+ tensor = state["transform"](image)
85
+ words, alphas, beams = sat_caption(state["encoder"], state["decoder"], state["vocab"], tensor, k=beam)
86
+ decode = state["vocab"].decode
87
+ return {
88
+ "caption": " ".join(words),
89
+ "words": words,
90
+ "attention": [[round(a, 5) for a in row] for row in alphas.tolist()],
91
+ "crop": center_crop_box(image.width, image.height, state["preprocess"]),
92
+ "beams": [{"caption": " ".join(decode(toks)), "score": round(score, 3)} for toks, score in beams],
93
+ "decode_ms": round((time.perf_counter() - t0) * 1000),
94
+ }
95
+
96
+
97
+ @torch.no_grad()
98
+ def _blip(image: Image.Image, beam: int) -> dict:
99
+ t0 = time.perf_counter()
100
+ inputs = state["blip_processor"](image, return_tensors="pt")
101
+ out = state["blip"].generate(**inputs, num_beams=beam, max_new_tokens=30)
102
+ text = state["blip_processor"].decode(out[0], skip_special_tokens=True)
103
+ return {"caption": text, "decode_ms": round((time.perf_counter() - t0) * 1000)}
104
+
105
+
106
+ @app.get("/")
107
+ def root() -> dict:
108
+ return {"message": "Welcome to the Capit captioning API! Visit /docs for usage details."}
109
+
110
+ @app.get("/health")
111
+ def health() -> dict:
112
+ return {"status": "ok"}
113
+
114
+
115
+ @app.post("/caption")
116
+ async def caption_endpoint(
117
+ file: UploadFile,
118
+ model: str = Query("both", pattern="^(sat|blip|both)$"),
119
+ beam: int = Query(3, ge=1, le=10),
120
+ ) -> dict:
121
+ image = await _ingest(file)
122
+ result: dict = {}
123
+ if model in ("sat", "both"):
124
+ result["sat"] = _sat(image, beam)
125
+ if model in ("blip", "both"):
126
+ result["blip"] = _blip(image, beam)
127
+ return result
backend/pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "capit-backend"
3
+ version = "0.1.0"
4
+ description = "capit backend — side-by-side SAT + BLIP captioning API"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "capit",
8
+ "fastapi[standard]",
9
+ "uvicorn",
10
+ "python-multipart",
11
+ "transformers",
12
+ "huggingface-hub",
13
+ "pillow",
14
+ "numpy",
15
+ "torch",
16
+ "torchvision",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest", "httpx"]
21
+
22
+ [tool.uv]
23
+ package = false
24
+
25
+ [tool.uv.sources]
26
+ capit = { path = "../pipeline", editable = true }
27
+
28
+ [[tool.uv.index]]
29
+ url = "https://download.pytorch.org/whl/cpu"
backend/scripts/bench_blip.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark BLIP caption latency + peak memory on CPU (Phase 5, killer gate #3).
2
+
3
+ No published CPU latency exists for blip-image-captioning-base; this measures it directly to
4
+ decide whether it ships as-is on the 2-vCPU HF Space, needs int8 quantization, or a fallback.
5
+ Standalone — no capit import; the backend is its own component.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import resource
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import torch
17
+ from PIL import Image, ImageOps
18
+ from transformers import BlipForConditionalGeneration, BlipProcessor
19
+
20
+ MODEL = "Salesforce/blip-image-captioning-base"
21
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
22
+
23
+
24
+ def _test_images(data_root: Path, n: int) -> list[Path]:
25
+ records = json.loads((data_root / "dataset_flickr8k.json").read_text())["images"]
26
+ test = sorted((r for r in records if r["split"] == "test"), key=lambda r: r["filename"])
27
+ return [data_root / "Images" / r["filename"] for r in test[:n]]
28
+
29
+
30
+ def _peak_rss_mb() -> float:
31
+ return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # ru_maxrss is KB on Linux
32
+
33
+
34
+ def _pct(xs: list[float], q: float) -> float:
35
+ xs = sorted(xs)
36
+ k = (len(xs) - 1) * q
37
+ lo = int(k)
38
+ hi = min(lo + 1, len(xs) - 1)
39
+ return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)
40
+
41
+
42
+ @torch.no_grad()
43
+ def bench(processor, model, paths: list[Path], num_beams: int) -> dict:
44
+ latencies: list[float] = []
45
+ sample = ""
46
+ for i, p in enumerate(paths):
47
+ with Image.open(p) as im:
48
+ image = (ImageOps.exif_transpose(im) or im).convert("RGB")
49
+ t0 = time.perf_counter()
50
+ inputs = processor(image, return_tensors="pt")
51
+ out = model.generate(**inputs, num_beams=num_beams, max_new_tokens=30)
52
+ dt = time.perf_counter() - t0
53
+ if i == 0: # warm-up: first call pays lazy init / caching
54
+ sample = processor.decode(out[0], skip_special_tokens=True)
55
+ continue
56
+ latencies.append(dt)
57
+ return {
58
+ "num_beams": num_beams,
59
+ "n": len(latencies),
60
+ "mean_s": sum(latencies) / len(latencies),
61
+ "p95_s": _pct(latencies, 0.95),
62
+ "min_s": min(latencies),
63
+ "max_s": max(latencies),
64
+ "sample_caption": sample,
65
+ }
66
+
67
+
68
+ def _verdict(mean_s: float) -> str:
69
+ if mean_s <= 5.0:
70
+ return "SHIP AS-IS (comfortable margin under the 10s Space budget)"
71
+ if mean_s <= 10.0:
72
+ return "TIGHT — under budget locally but little Space margin; consider int8 quantization"
73
+ return "MISS — exceeds budget; int8 quantize (OpenVINO/NNCF) then re-bench, else ViT-GPT2 fallback"
74
+
75
+
76
+ def main() -> None:
77
+ parser = argparse.ArgumentParser()
78
+ parser.add_argument("--data-root", default=str(_REPO_ROOT / "data" / "flickr8k"))
79
+ parser.add_argument("--n", type=int, default=20)
80
+ parser.add_argument("--threads", type=int, default=2, help="2 simulates the HF Space's 2 vCPU")
81
+ parser.add_argument("--beams", type=int, nargs="+", default=[1, 3])
82
+ parser.add_argument("--out-json", default=str(_REPO_ROOT / "data" / "blip_bench.json"))
83
+ args = parser.parse_args()
84
+
85
+ torch.set_num_threads(args.threads)
86
+ processor = BlipProcessor.from_pretrained(MODEL)
87
+ model = BlipForConditionalGeneration.from_pretrained(MODEL).eval()
88
+ paths = _test_images(Path(args.data_root), args.n + 1) # +1 for the discarded warm-up
89
+
90
+ runs = [bench(processor, model, paths, b) for b in args.beams]
91
+ peak_rss = _peak_rss_mb()
92
+ headline = max(runs, key=lambda r: r["num_beams"]) # the slowest config is what the UI serves
93
+
94
+ report = {
95
+ "model": MODEL,
96
+ "threads": args.threads,
97
+ "torch_threads_available": torch.get_num_threads(),
98
+ "peak_rss_mb": round(peak_rss, 1),
99
+ "runs": runs,
100
+ "headline_beam": headline["num_beams"],
101
+ "headline_mean_s": round(headline["mean_s"], 2),
102
+ "space_budget_s": 10.0,
103
+ "verdict": _verdict(headline["mean_s"]),
104
+ }
105
+ Path(args.out_json).write_text(json.dumps(report, indent=2))
106
+
107
+ print(f"model: {MODEL} threads: {args.threads} peak RSS: {peak_rss:.0f} MB")
108
+ for r in runs:
109
+ print(
110
+ f" beams={r['num_beams']} mean={r['mean_s']:.2f}s p95={r['p95_s']:.2f}s "
111
+ f"min={r['min_s']:.2f}s max={r['max_s']:.2f}s (n={r['n']})"
112
+ )
113
+ print(f' sample: "{runs[-1]["sample_caption"]}"')
114
+ print(f"verdict (beams={headline['num_beams']}, {headline['mean_s']:.2f}s vs 10s budget): {report['verdict']}")
115
+ print(f"wrote {args.out_json}")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
backend/tests/test_app.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 6.1 — backend API. Needs the local artifact + cached BLIP; skips cleanly otherwise."""
2
+
3
+ import io
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
11
+ from app import ARTIFACT_DIR, app
12
+
13
+ from fastapi.testclient import TestClient
14
+
15
+ pytestmark = pytest.mark.skipif(
16
+ not (ARTIFACT_DIR / "capit-sat.pt").exists(),
17
+ reason="no local serving artifact; run scripts/export_artifact.py first",
18
+ )
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def client():
23
+ with TestClient(app) as c:
24
+ yield c
25
+
26
+
27
+ def _png(size=(64, 48), mode="RGB", exif=None) -> bytes:
28
+ img = Image.new(mode, size, "red")
29
+ buf = io.BytesIO()
30
+ if exif is not None:
31
+ img.save(buf, format="JPEG", exif=exif)
32
+ else:
33
+ img.save(buf, format="PNG")
34
+ return buf.getvalue()
35
+
36
+
37
+ def test_health(client):
38
+ assert client.get("/health").json() == {"status": "ok"}
39
+
40
+
41
+ def test_caption_happy_sat(client):
42
+ r = client.post("/caption?model=sat&beam=3", files={"file": ("x.png", _png(), "image/png")})
43
+ assert r.status_code == 200
44
+ sat = r.json()["sat"]
45
+ assert sat["caption"] and len(sat["words"]) == len(sat["attention"])
46
+ assert all(len(row) == 196 for row in sat["attention"])
47
+ assert set(sat["crop"]) == {"x", "y", "w", "h"}
48
+ assert sat["beams"][0]["caption"]
49
+
50
+
51
+ def test_caption_both_has_blip(client):
52
+ r = client.post("/caption?model=both", files={"file": ("x.png", _png(), "image/png")})
53
+ body = r.json()
54
+ assert r.status_code == 200 and body["blip"]["caption"] and body["sat"]["caption"]
55
+
56
+
57
+ def test_junk_bytes_is_422(client):
58
+ r = client.post("/caption?model=sat", files={"file": ("x.png", b"not an image", "image/png")})
59
+ assert r.status_code == 422
60
+
61
+
62
+ def test_oversized_is_413(client):
63
+ big = b"\xff" * (8 * 1024 * 1024 + 1)
64
+ r = client.post("/caption?model=sat", files={"file": ("x.png", big, "image/png")})
65
+ assert r.status_code == 413
66
+
67
+
68
+ def test_rgba_accepted(client):
69
+ r = client.post("/caption?model=sat", files={"file": ("x.png", _png(mode="RGBA"), "image/png")})
70
+ assert r.status_code == 200
71
+
72
+
73
+ def test_exif_orientation_applied(client):
74
+ exif = Image.Exif()
75
+ exif[0x0112] = 6 # rotate 90 CW: a saved 400x200 displays upright as 200x400 (portrait)
76
+ r = client.post("/caption?model=sat", files={"file": ("x.jpg", _png((400, 200), exif=exif), "image/jpeg")})
77
+ crop = r.json()["sat"]["crop"]
78
+ assert crop["w"] > crop["h"] # portrait-upright crops wider in fractions; ignoring EXIF would flip this
backend/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
pipeline/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
pipeline/__init__.py ADDED
File without changes
pipeline/notebooks/colab_train.ipynb ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# capit — train on Colab (Stage 3.3)\n",
8
+ "\n",
9
+ "A thin launcher: all the logic lives in `train.py` in the repo. This notebook clones it, stages the data, and runs the CLI on the T4.\n",
10
+ "\n",
11
+ "**Before you run:**\n",
12
+ "1. **Runtime → Change runtime type → T4 GPU.**\n",
13
+ "2. Locally: `python pipeline/scripts/make_train_zip.py` → builds `data/flickr8k_colab.zip` (Images + dataset_flickr8k.json + vocab.json).\n",
14
+ "3. **Push your latest code** — the notebook runs what is in git, not your local working tree.\n",
15
+ "\n",
16
+ "Get the zip onto Drive **once** (either the upload cell below, or drag it into `MyDrive/capit/` via the Drive website — more reliable for ~1 GB). After that, reconnects just re-stage from Drive and `--resume auto` continues the run."
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "code",
21
+ "execution_count": 4,
22
+ "metadata": {},
23
+ "outputs": [
24
+ {
25
+ "name": "stdout",
26
+ "output_type": "stream",
27
+ "text": [
28
+ "Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount(\"/content/drive\", force_remount=True).\n"
29
+ ]
30
+ }
31
+ ],
32
+ "source": [
33
+ "from google.colab import drive\n",
34
+ "drive.mount('/content/drive')"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "code",
39
+ "execution_count": 5,
40
+ "metadata": {},
41
+ "outputs": [
42
+ {
43
+ "name": "stdout",
44
+ "output_type": "stream",
45
+ "text": [
46
+ "Cloning into '/content/capit'...\n",
47
+ "remote: Enumerating objects: 149, done.\u001b[K\n",
48
+ "remote: Counting objects: 100% (149/149), done.\u001b[K\n",
49
+ "remote: Compressing objects: 100% (89/89), done.\u001b[K\n",
50
+ "remote: Total 149 (delta 55), reused 132 (delta 38), pack-reused 0 (from 0)\u001b[K\n",
51
+ "Receiving objects: 100% (149/149), 96.33 KiB | 6.02 MiB/s, done.\n",
52
+ "Resolving deltas: 100% (55/55), done.\n",
53
+ "Obtaining file:///content/capit/pipeline\n",
54
+ " Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n",
55
+ " Checking if build backend supports build_editable ... \u001b[?25l\u001b[?25hdone\n",
56
+ " Getting requirements to build editable ... \u001b[?25l\u001b[?25hdone\n",
57
+ " Preparing editable metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
58
+ "Requirement already satisfied: torch in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (2.11.0+cu128)\n",
59
+ "Requirement already satisfied: torchvision in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (0.26.0+cu128)\n",
60
+ "Requirement already satisfied: pillow in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (11.3.0)\n",
61
+ "Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (2.0.2)\n",
62
+ "Requirement already satisfied: nltk in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (3.9.1)\n",
63
+ "Requirement already satisfied: kagglehub in /usr/local/lib/python3.12/dist-packages (from capit==0.1.0) (1.0.1)\n",
64
+ "Requirement already satisfied: kagglesdk<1.0,>=0.1.22 in /usr/local/lib/python3.12/dist-packages (from kagglehub->capit==0.1.0) (0.1.23)\n",
65
+ "Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from kagglehub->capit==0.1.0) (26.2)\n",
66
+ "Requirement already satisfied: pyyaml in /usr/local/lib/python3.12/dist-packages (from kagglehub->capit==0.1.0) (6.0.3)\n",
67
+ "Requirement already satisfied: requests in /usr/local/lib/python3.12/dist-packages (from kagglehub->capit==0.1.0) (2.32.4)\n",
68
+ "Requirement already satisfied: tqdm in /usr/local/lib/python3.12/dist-packages (from kagglehub->capit==0.1.0) (4.67.3)\n",
69
+ "Requirement already satisfied: click in /usr/local/lib/python3.12/dist-packages (from nltk->capit==0.1.0) (8.4.1)\n",
70
+ "Requirement already satisfied: joblib in /usr/local/lib/python3.12/dist-packages (from nltk->capit==0.1.0) (1.5.3)\n",
71
+ "Requirement already satisfied: regex>=2021.8.3 in /usr/local/lib/python3.12/dist-packages (from nltk->capit==0.1.0) (2025.11.3)\n",
72
+ "Requirement already satisfied: filelock in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (3.29.1)\n",
73
+ "Requirement already satisfied: typing-extensions>=4.10.0 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (4.15.0)\n",
74
+ "Requirement already satisfied: setuptools<82 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (75.2.0)\n",
75
+ "Requirement already satisfied: sympy>=1.13.3 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (1.14.0)\n",
76
+ "Requirement already satisfied: networkx>=2.5.1 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (3.6.1)\n",
77
+ "Requirement already satisfied: jinja2 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (3.1.6)\n",
78
+ "Requirement already satisfied: fsspec>=0.8.5 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (2025.3.0)\n",
79
+ "Requirement already satisfied: cuda-toolkit==12.8.1 in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.1)\n",
80
+ "Requirement already satisfied: cuda-bindings<13,>=12.9.4 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (12.9.7)\n",
81
+ "Requirement already satisfied: nvidia-cudnn-cu12==9.19.0.56 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (9.19.0.56)\n",
82
+ "Requirement already satisfied: nvidia-cusparselt-cu12==0.7.1 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (0.7.1)\n",
83
+ "Requirement already satisfied: nvidia-nccl-cu12==2.28.9 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (2.28.9)\n",
84
+ "Requirement already satisfied: nvidia-nvshmem-cu12==3.4.5 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (3.4.5)\n",
85
+ "Requirement already satisfied: triton==3.6.0 in /usr/local/lib/python3.12/dist-packages (from torch->capit==0.1.0) (3.6.0)\n",
86
+ "Requirement already satisfied: nvidia-cublas-cu12==12.8.4.1.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.4.1)\n",
87
+ "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.8.90.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.90)\n",
88
+ "Requirement already satisfied: nvidia-cufft-cu12==11.3.3.83.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (11.3.3.83)\n",
89
+ "Requirement already satisfied: nvidia-cufile-cu12==1.13.1.3.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (1.13.1.3)\n",
90
+ "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.8.90.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.90)\n",
91
+ "Requirement already satisfied: nvidia-curand-cu12==10.3.9.90.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (10.3.9.90)\n",
92
+ "Requirement already satisfied: nvidia-cusolver-cu12==11.7.3.90.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (11.7.3.90)\n",
93
+ "Requirement already satisfied: nvidia-cusparse-cu12==12.5.8.93.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.5.8.93)\n",
94
+ "Requirement already satisfied: nvidia-nvjitlink-cu12==12.8.93.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.93)\n",
95
+ "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.8.93.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.93)\n",
96
+ "Requirement already satisfied: nvidia-nvtx-cu12==12.8.90.* in /usr/local/lib/python3.12/dist-packages (from cuda-toolkit[cublas,cudart,cufft,cufile,cupti,curand,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==12.8.1; platform_system == \"Linux\"->torch->capit==0.1.0) (12.8.90)\n",
97
+ "Requirement already satisfied: cuda-pathfinder~=1.1 in /usr/local/lib/python3.12/dist-packages (from cuda-bindings<13,>=12.9.4->torch->capit==0.1.0) (1.5.5)\n",
98
+ "Requirement already satisfied: protobuf in /usr/local/lib/python3.12/dist-packages (from kagglesdk<1.0,>=0.1.22->kagglehub->capit==0.1.0) (5.29.6)\n",
99
+ "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.12/dist-packages (from sympy>=1.13.3->torch->capit==0.1.0) (1.3.0)\n",
100
+ "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.12/dist-packages (from jinja2->torch->capit==0.1.0) (3.0.3)\n",
101
+ "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/lib/python3.12/dist-packages (from requests->kagglehub->capit==0.1.0) (3.4.7)\n",
102
+ "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.12/dist-packages (from requests->kagglehub->capit==0.1.0) (3.18)\n",
103
+ "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.12/dist-packages (from requests->kagglehub->capit==0.1.0) (2.5.0)\n",
104
+ "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.12/dist-packages (from requests->kagglehub->capit==0.1.0) (2026.5.20)\n",
105
+ "Building wheels for collected packages: capit\n",
106
+ " Building editable for capit (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
107
+ " Created wheel for capit: filename=capit-0.1.0-0.editable-py3-none-any.whl size=1307 sha256=fbe47f272d2e771f315dc44349cd36179ae23791926a0b0626981391c9c3976b\n",
108
+ " Stored in directory: /tmp/pip-ephem-wheel-cache-630q9_cb/wheels/ff/0a/93/9457afa2ecf0d24429d3eb1754c723aeaf950fdbae03614da9\n",
109
+ "Successfully built capit\n",
110
+ "Installing collected packages: capit\n",
111
+ " Attempting uninstall: capit\n",
112
+ " Found existing installation: capit 0.1.0\n",
113
+ " Uninstalling capit-0.1.0:\n",
114
+ " Successfully uninstalled capit-0.1.0\n",
115
+ "Successfully installed capit-0.1.0\n"
116
+ ]
117
+ },
118
+ {
119
+ "data": {
120
+ "application/vnd.colab-display-data+json": {
121
+ "id": "fe04bbce05954a97b04b1aefedeb93e6",
122
+ "pip_warning": {
123
+ "packages": [
124
+ "capit"
125
+ ]
126
+ }
127
+ }
128
+ },
129
+ "metadata": {},
130
+ "output_type": "display_data"
131
+ },
132
+ {
133
+ "name": "stdout",
134
+ "output_type": "stream",
135
+ "text": [
136
+ "capit installed\n"
137
+ ]
138
+ }
139
+ ],
140
+ "source": [
141
+ "# torch/torchvision are preinstalled on Colab; this pulls the small extras (nltk, ...).\n",
142
+ "# No -q: a failed install must be visible, not surface 3 hours later as ModuleNotFoundError.\n",
143
+ "!rm -rf /content/capit && git clone https://github.com/Bukunmi2108/capit.git /content/capit\n",
144
+ "!pip install -e /content/capit/pipeline\n",
145
+ "import capit # fail fast if the install didn't take\n",
146
+ "print(\"capit installed\")"
147
+ ]
148
+ },
149
+ {
150
+ "cell_type": "code",
151
+ "execution_count": 7,
152
+ "metadata": {},
153
+ "outputs": [
154
+ {
155
+ "name": "stdout",
156
+ "output_type": "stream",
157
+ "text": [
158
+ "already on Drive — skipping upload\n"
159
+ ]
160
+ }
161
+ ],
162
+ "source": [
163
+ "import os, shutil\n",
164
+ "dest = '/content/drive/MyDrive/capit/flickr8k_colab.zip'\n",
165
+ "if os.path.exists(dest):\n",
166
+ " print('already on Drive — skipping upload')\n",
167
+ "else:\n",
168
+ " from google.colab import files\n",
169
+ " os.makedirs(os.path.dirname(dest), exist_ok=True)\n",
170
+ " up = files.upload() # pick flickr8k_colab.zip\n",
171
+ " shutil.move(next(iter(up)), dest)\n",
172
+ " print('uploaded and stashed on Drive:', dest)"
173
+ ]
174
+ },
175
+ {
176
+ "cell_type": "code",
177
+ "execution_count": 8,
178
+ "metadata": {},
179
+ "outputs": [
180
+ {
181
+ "name": "stdout",
182
+ "output_type": "stream",
183
+ "text": [
184
+ "8091 images staged\n"
185
+ ]
186
+ }
187
+ ],
188
+ "source": [
189
+ "# Copy OFF the Drive mount to local disk, then unzip (never read images over Drive — slow).\n",
190
+ "# `&&` so unzip only runs if the copy succeeded.\n",
191
+ "!cp /content/drive/MyDrive/capit/flickr8k_colab.zip /content/flickr8k_colab.zip && unzip -q -o /content/flickr8k_colab.zip -d /content/flickr8k\n",
192
+ "import os\n",
193
+ "n = len(os.listdir('/content/flickr8k/Images'))\n",
194
+ "assert n == 8091, f\"expected 8091 images, got {n} — bad/partial zip\"\n",
195
+ "print(n, \"images staged\")"
196
+ ]
197
+ },
198
+ {
199
+ "cell_type": "code",
200
+ "execution_count": 9,
201
+ "metadata": {},
202
+ "outputs": [
203
+ {
204
+ "name": "stdout",
205
+ "output_type": "stream",
206
+ "text": [
207
+ "Downloading: \"https://download.pytorch.org/models/resnet50-11ad3fa6.pth\" to /root/.cache/torch/hub/checkpoints/resnet50-11ad3fa6.pth\n",
208
+ "100% 97.8M/97.8M [00:00<00:00, 147MB/s] \n",
209
+ "epoch 0 loss 4.8991 val_bleu4 8.18 best 8.18 * | a boy in a blue shirt and a white shirt is standing on a skateboard\n",
210
+ "epoch 1 loss 4.0486 val_bleu4 17.58 best 17.58 * | a boy is sitting on a bench\n",
211
+ "epoch 2 loss 3.7289 val_bleu4 17.30 best 17.58 | a man in a red shirt and black pants is sitting on a bench\n",
212
+ "epoch 3 loss 3.5054 val_bleu4 17.85 best 17.85 * | a man in a red shirt is sitting on a skateboard\n",
213
+ "epoch 4 loss 3.3269 val_bleu4 19.32 best 19.32 * | a man in a red shirt is sitting on a skateboard\n",
214
+ "epoch 5 loss 3.1763 val_bleu4 18.90 best 19.32 | a man in a red shirt is sitting on a skateboard\n",
215
+ "epoch 6 loss 3.0429 val_bleu4 18.59 best 19.32 | a man in a red shirt is riding on a skateboard\n",
216
+ "epoch 7 loss 2.9214 val_bleu4 19.62 best 19.62 * | a man in a red shirt is riding on a swing\n",
217
+ "epoch 8 loss 2.8085 val_bleu4 18.76 best 19.62 | a man in a red shirt and blue jeans is sitting on a bench in a <unk>\n",
218
+ "epoch 9 loss 2.7064 val_bleu4 18.01 best 19.62 | a man in a red shirt and blue jeans is sitting on a skateboard\n",
219
+ "epoch 10 loss 2.6112 val_bleu4 18.01 best 19.62 | a man in a red shirt and blue jeans is sitting on a red bench\n",
220
+ "epoch 11 loss 2.5185 val_bleu4 18.08 best 19.62 | a man in a red shirt and blue jeans is <unk> down a <unk> <unk> structure\n",
221
+ "epoch 12 loss 2.4343 val_bleu4 18.57 best 19.62 | a man on a skateboard in a <unk> area\n",
222
+ "epoch 13 loss 2.3560 val_bleu4 17.20 best 19.62 | a man in a red shirt is sitting on a bench in a <unk> area\n",
223
+ "epoch 14 loss 2.2798 val_bleu4 16.75 best 19.62 | a man in a red shirt is sitting on a bench in a <unk> area\n",
224
+ "epoch 15 loss 2.2071 val_bleu4 17.01 best 19.62 | a man on a skateboard on a <unk>\n",
225
+ "epoch 16 loss 2.1434 val_bleu4 17.04 best 19.62 | a man on a skateboard on a <unk> <unk>\n",
226
+ "epoch 17 loss 2.0809 val_bleu4 17.65 best 19.62 | a man on a skateboard in a <unk> area\n",
227
+ "early stop: no val BLEU-4 improvement in 10 epochs (last loss 2.0809)\n"
228
+ ]
229
+ }
230
+ ],
231
+ "source": [
232
+ "!python -m capit.train \\\n",
233
+ " --data-root /content/flickr8k \\\n",
234
+ " --vocab-path /content/flickr8k/vocab.json \\\n",
235
+ " --ckpt-dir /content/drive/MyDrive/capit/checkpoints \\\n",
236
+ " --resume auto"
237
+ ]
238
+ },
239
+ {
240
+ "cell_type": "markdown",
241
+ "metadata": {},
242
+ "source": [
243
+ "## If disconnected\n",
244
+ "Reconnect → re-run **all** cells. The upload cell skips (zip already on Drive), the data cell re-stages, and `--resume auto` loads `latest.pt` from Drive and continues from the next epoch — checkpoints live on Drive, so a disconnect costs minutes, not the run. (train.py refuses to run if `--ckpt-dir` points at an unmounted Drive.)\n",
245
+ "\n",
246
+ "**Exit gate (Stage 3.3):** `best.pt` on Drive with val BLEU-4 (greedy, nltk) ≥ ~14. Download `MyDrive/capit/checkpoints/best.pt` for Phase 4."
247
+ ]
248
+ }
249
+ ],
250
+ "metadata": {
251
+ "accelerator": "GPU",
252
+ "colab": {
253
+ "gpuType": "T4",
254
+ "provenance": []
255
+ },
256
+ "kernelspec": {
257
+ "display_name": "Python 3 (ipykernel)",
258
+ "language": "python",
259
+ "name": "python3"
260
+ }
261
+ },
262
+ "nbformat": 4,
263
+ "nbformat_minor": 0
264
+ }
pipeline/pyproject.toml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "capit"
7
+ version = "0.1.0"
8
+ description = "capit — glass-box image captioner (Show, Attend and Tell)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "torch",
13
+ "torchvision",
14
+ "pillow",
15
+ "numpy",
16
+ "nltk",
17
+ "kagglehub",
18
+ "huggingface-hub",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest",
24
+ "pycocoevalcap",
25
+ "matplotlib",
26
+ "scikit-image",
27
+ ]
28
+
29
+ [[tool.uv.index]]
30
+ url = "https://download.pytorch.org/whl/cpu"
31
+
32
+ [tool.setuptools]
33
+ package-dir = {"" = "src"}
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
pipeline/scripts/build_vocab.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build data/vocab.json from the full Flickr8k train split."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from capit.config import config
8
+ from capit.data.records import train_captions
9
+ from capit.data.vocab import Vocab
10
+
11
+
12
+ def build_vocab() -> None:
13
+ records = json.loads(config.karpathy_json.read_text())["images"]
14
+ vocab = Vocab.build(train_captions(records), config.min_freq)
15
+ config.vocab_path.parent.mkdir(parents=True, exist_ok=True)
16
+ vocab.save(config.vocab_path)
17
+ print(f"vocab: {len(vocab)} words (min_freq={config.min_freq}) -> {config.vocab_path}")
18
+
19
+
20
+ if __name__ == "__main__":
21
+ build_vocab()
pipeline/scripts/export_artifact.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export the serving artifact: best.pt (training checkpoint) -> capit-sat.pt (inference contract)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ import torch
12
+
13
+ from capit.checkpoint import load
14
+ from capit.config import config
15
+ from capit.data.vocab import Vocab
16
+ from capit.models.decoder import Decoder
17
+ from capit.models.encoder import Encoder
18
+ from capit.serving import build_artifact
19
+
20
+
21
+ def _split_counts(karpathy_json: Path) -> dict[str, int]:
22
+ records = json.loads(karpathy_json.read_text())["images"]
23
+ counts: dict[str, int] = {}
24
+ for r in records:
25
+ counts[r["split"]] = counts.get(r["split"], 0) + 1
26
+ return counts
27
+
28
+
29
+ def _scores_table(metrics: dict[str, dict[str, float]]) -> str:
30
+ cols = ["BLEU-1", "BLEU-2", "BLEU-3", "BLEU-4", "CIDEr"]
31
+ lines = ["| beam | " + " | ".join(cols) + " |", "|-----:|" + "|".join(["-------:"] * len(cols)) + "|"]
32
+ for beam in sorted(metrics, key=int):
33
+ s = metrics[beam]
34
+ lines.append(f"| {beam} | " + " | ".join(f"{s[c]:.2f}" for c in cols) + " |")
35
+ return "\n".join(lines)
36
+
37
+
38
+ def _model_card(
39
+ repo_id: str, splits: dict[str, int], best_bleu4: float, best_epoch: int, metrics: dict[str, dict[str, float]]
40
+ ) -> str:
41
+ return f"""---
42
+ license: mit
43
+ language:
44
+ - en
45
+ library_name: pytorch
46
+ pipeline_tag: image-to-text
47
+ tags:
48
+ - image-captioning
49
+ - show-attend-and-tell
50
+ - visual-attention
51
+ datasets:
52
+ - flickr8k
53
+ metrics:
54
+ - bleu
55
+ - cider
56
+ ---
57
+
58
+ # capit-sat
59
+
60
+ Show, Attend and Tell image captioner, trained from scratch on Flickr8k (Karpathy split).
61
+ The glass-box half of [capit](https://github.com/Bukunmi2108/capit) — exposes per-word
62
+ attention, beam candidates, and word-by-word playback.
63
+
64
+ ## Test-set scores (pycocoevalcap, Karpathy test = {splits.get('test', '?')} images)
65
+
66
+ {_scores_table(metrics)}
67
+
68
+ ## Training
69
+
70
+ - Backbone: frozen ResNet-50 (ImageNet). Decoder trained from scratch.
71
+ - Best val BLEU-4 {best_bleu4:.2f} at epoch {best_epoch} (early-stopped); Colab T4.
72
+ - Splits: train {splits.get('train', '?')}, val {splits.get('val', '?')}, test {splits.get('test', '?')}.
73
+
74
+ ## Known limitation
75
+
76
+ Attention is effectively 7x7: ResNet-50 at 224px is natively 7x7 and the encoder upsamples
77
+ to 14x14, so heatmaps are coarse (~32px blocks). Captions are grounded; the spots are
78
+ region-level, not pixel-level.
79
+
80
+ ## Use
81
+
82
+ `huggingface_hub.hf_hub_download("{repo_id}", "capit-sat.pt")` + `vocab.json`, then
83
+ `capit.serving.load_artifact(...)`.
84
+ """
85
+
86
+
87
+ def export(ckpt_path: Path, vocab_path: Path, out_dir: Path, repo_id: str, metrics_json: Path) -> Path:
88
+ if not metrics_json.exists():
89
+ raise FileNotFoundError(
90
+ f"metrics file {metrics_json} not found — generate it first:\n"
91
+ f" uv run python -m capit.evaluate --out-json {metrics_json}"
92
+ )
93
+ metrics = json.loads(metrics_json.read_text())
94
+ vocab = Vocab.load(vocab_path)
95
+ state = load(ckpt_path)
96
+ if state.vocab_sha256 != vocab.sha256():
97
+ raise ValueError(f"vocab mismatch: ckpt {state.vocab_sha256[:8]} != vocab {vocab.sha256()[:8]}")
98
+
99
+ encoder = Encoder(pretrained=True)
100
+ decoder = Decoder(vocab_size=len(vocab))
101
+ decoder.load_state_dict(state.model_state)
102
+ blob = build_artifact(encoder, decoder, vocab)
103
+
104
+ out_dir.mkdir(parents=True, exist_ok=True)
105
+ artifact_path = out_dir / "capit-sat.pt"
106
+ tmp = artifact_path.with_name(artifact_path.name + ".tmp")
107
+ torch.save(blob, tmp)
108
+ os.replace(tmp, artifact_path)
109
+ shutil.copyfile(vocab_path, out_dir / "vocab.json")
110
+
111
+ splits = _split_counts(config.karpathy_json)
112
+ (out_dir / "README.md").write_text(_model_card(repo_id, splits, state.best_bleu4, state.best_epoch, metrics))
113
+ return artifact_path
114
+
115
+
116
+ def main() -> None:
117
+ parser = argparse.ArgumentParser()
118
+ parser.add_argument("--ckpt", default=str(config.ckpt_dir / "best.pt"))
119
+ parser.add_argument("--vocab", default=str(config.vocab_path))
120
+ parser.add_argument("--out-dir", default=str(config.data_root / "artifact"))
121
+ parser.add_argument("--repo-id", default="Bukunmi2108/capit-sat")
122
+ parser.add_argument("--metrics-json", default=str(config.data_root / "eval_results.json"))
123
+ args = parser.parse_args()
124
+
125
+ path = export(Path(args.ckpt), Path(args.vocab), Path(args.out_dir), args.repo_id, Path(args.metrics_json))
126
+ size_mb = path.stat().st_size / 1e6
127
+ print(f"wrote {path} ({size_mb:.1f} MB), vocab.json, README.md to {args.out_dir}")
128
+ print(f"push: hf upload {args.repo_id} {args.out_dir} . --repo-type model")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
pipeline/scripts/make_subsample.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the dev subsample: a small seeded slice of Flickr8k written in the same
2
+ on-disk format (Karpathy JSON + Images/) as the full set."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shutil
8
+
9
+ from capit.config import config
10
+ from capit.data.download import _copy_atomic
11
+ from capit.data.records import select_records
12
+
13
+
14
+ def make_subsample() -> None:
15
+ full = json.loads(config.karpathy_json.read_text())
16
+ selected = select_records(full["images"], config.subsample_counts, config.seed)
17
+
18
+ sources = [(config.images_dir / r["filename"], r) for r in selected]
19
+ missing = [r["filename"] for src, r in sources if not src.is_file()]
20
+ if missing:
21
+ raise FileNotFoundError(
22
+ f"{len(missing)} source images missing under {config.images_dir} "
23
+ f"(rerun the Stage 0.2 download), e.g. {missing[:3]}"
24
+ )
25
+
26
+ if config.subsample_root.exists():
27
+ shutil.rmtree(config.subsample_root)
28
+ config.subsample_images_dir.mkdir(parents=True, exist_ok=True)
29
+ for src, rec in sources:
30
+ _copy_atomic(src, config.subsample_images_dir / rec["filename"])
31
+
32
+ config.subsample_json.write_text(json.dumps({"images": selected, "dataset": full["dataset"]}))
33
+
34
+ tally = {s: sum(r["split"] == s for r in selected) for s in config.subsample_counts}
35
+ print(f"subsample: {len(selected)} images {tally} -> {config.subsample_root}")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ make_subsample()
pipeline/scripts/make_train_zip.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble the full dataset + vocab into one zip for Colab (upload to Drive).
2
+ The zip unpacks to a single CaptionDataset root: Images/ + dataset_flickr8k.json + vocab.json.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ from capit.config import config
12
+
13
+
14
+ def make_train_zip(out_path: Path | None = None) -> Path:
15
+ out = Path(out_path) if out_path else config.data_root / "flickr8k_colab.zip"
16
+ for required in (config.images_dir, config.karpathy_json, config.vocab_path):
17
+ if not required.exists():
18
+ raise FileNotFoundError(f"{required} missing — run the Stage 0.2 download and build_vocab first")
19
+
20
+ jpgs = sorted(config.images_dir.glob("*.jpg"))
21
+ expected = len(json.loads(config.karpathy_json.read_text())["images"])
22
+ if len(jpgs) < expected:
23
+ raise ValueError(f"{len(jpgs)} images in {config.images_dir}, expected >= {expected} (partial download?)")
24
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_STORED) as zf: # jpgs already compressed
25
+ for jpg in jpgs:
26
+ zf.write(jpg, f"Images/{jpg.name}")
27
+ zf.write(config.karpathy_json, "dataset_flickr8k.json")
28
+ zf.write(config.vocab_path, "vocab.json")
29
+
30
+ print(f"wrote {out} ({len(jpgs)} images + dataset_flickr8k.json + vocab.json)")
31
+ return out
32
+
33
+
34
+ if __name__ == "__main__":
35
+ make_train_zip()
pipeline/scripts/overfit_one_batch.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Overfit one batch (killer gate #1) — run and eyeball the decoded captions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from capit.overfit import run_overfit
6
+
7
+
8
+ def main() -> None:
9
+ result = run_overfit()
10
+ curve = result["ce_curve"]
11
+ for s in range(0, len(curve), max(1, len(curve) // 10)):
12
+ print(f"step {s:4d} ce {curve[s]:.4f}")
13
+ print(f"final ce: {result['final_ce']:.4f}\n")
14
+ for got, want in zip(result["decoded"], result["targets"]):
15
+ print(f" target: {' '.join(want)}")
16
+ print(f" greedy: {' '.join(got)}\n")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
pipeline/scripts/serve_demo.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exit gate: knowing only a Hub repo id, download the artifact and caption one image.
2
+
3
+ This is the seed of backend/app.py — it proves capit-sat.pt is a self-sufficient inference
4
+ contract (no checkpoint, no training data, no hardcoded preprocess).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ from huggingface_hub import hf_hub_download
11
+ from PIL import Image, ImageOps
12
+
13
+ from capit.serving import caption, load_artifact, make_transform
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("image")
19
+ parser.add_argument("--repo-id", default="Bukunmi2108/capit-sat")
20
+ args = parser.parse_args()
21
+
22
+ artifact = hf_hub_download(args.repo_id, "capit-sat.pt")
23
+ vocab_path = hf_hub_download(args.repo_id, "vocab.json")
24
+ encoder, decoder, vocab, preprocess = load_artifact(artifact, vocab_path)
25
+ transform = make_transform(preprocess)
26
+
27
+ with Image.open(args.image) as img:
28
+ tensor = transform((ImageOps.exif_transpose(img) or img).convert("RGB"))
29
+ words, _, _ = caption(encoder, decoder, vocab, tensor)
30
+ print(" ".join(words))
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
pipeline/scripts/visualize_attention.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decode one image at beam 3 and render a per-word attention grid over it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import matplotlib
9
+
10
+ matplotlib.use("Agg")
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ import torch
14
+ from PIL import Image, ImageOps
15
+ from skimage.transform import pyramid_expand
16
+ from torchvision import transforms
17
+
18
+ from capit.checkpoint import load
19
+ from capit.config import Config, config
20
+ from capit.data.dataset import CaptionDataset, build_transform
21
+ from capit.data.vocab import END, START, Vocab
22
+ from capit.decode import beam_search
23
+ from capit.models.decoder import Decoder
24
+ from capit.models.encoder import Encoder
25
+
26
+
27
+ def display_transform(cfg: Config = config):
28
+ return transforms.Compose(
29
+ [transforms.Resize(cfg.resize), transforms.CenterCrop(cfg.crop), transforms.ToTensor()]
30
+ )
31
+
32
+
33
+ def load_image(path: Path | str, transform) -> torch.Tensor:
34
+ with Image.open(path) as img:
35
+ img = ImageOps.exif_transpose(img) or img
36
+ return transform(img.convert("RGB"))
37
+
38
+
39
+ def heatmap(alpha: torch.Tensor, upscale: int = 16, sigma: float = 8.0) -> np.ndarray:
40
+ grid = config.encoded_size
41
+ return pyramid_expand(alpha.reshape(grid, grid).numpy(), upscale=upscale, sigma=sigma)
42
+
43
+
44
+ def _label(ax, text: str) -> None:
45
+ ax.text(
46
+ 0.03, 0.95, text, transform=ax.transAxes, fontsize=11, va="top",
47
+ color="black", bbox=dict(facecolor="white", edgecolor="none", pad=1.5),
48
+ )
49
+
50
+
51
+ def overlay_grid(display_img: torch.Tensor, words: list[str], alphas: torch.Tensor, attn_alpha: float = 0.8):
52
+ base = display_img.permute(1, 2, 0).numpy()
53
+ heats = [heatmap(a) for a in alphas]
54
+ n = len(words) + 1
55
+ cols = min(n, 5)
56
+ rows = (n + cols - 1) // cols
57
+ fig, axes = plt.subplots(rows, cols, figsize=(cols * 2.6, rows * 2.6), squeeze=False, facecolor="white")
58
+ axes = axes.reshape(-1)
59
+ axes[0].imshow(base)
60
+ _label(axes[0], "input")
61
+ for i, (word, heat) in enumerate(zip(words, heats), start=1):
62
+ axes[i].imshow(base)
63
+ axes[i].imshow(heat, alpha=attn_alpha, cmap="Greys_r")
64
+ _label(axes[i], word)
65
+ for ax in axes:
66
+ ax.axis("off")
67
+ fig.tight_layout()
68
+ return fig
69
+
70
+
71
+ @torch.no_grad()
72
+ def visualize(image_path: Path | str, ckpt_path: Path | str, out_path: Path | str, k: int = config.beam_k) -> list[str]:
73
+ vocab = Vocab.load(config.vocab_path)
74
+ encoder = Encoder(pretrained=True).eval()
75
+ decoder = Decoder(vocab_size=len(vocab))
76
+ state = load(ckpt_path)
77
+ if state.vocab_sha256 != vocab.sha256():
78
+ raise ValueError(f"vocab mismatch: ckpt {state.vocab_sha256[:8]} != vocab {vocab.sha256()[:8]}")
79
+ decoder.load_state_dict(state.model_state)
80
+ decoder.eval()
81
+
82
+ features = encoder(load_image(image_path, build_transform()).unsqueeze(0))
83
+ tokens, alphas, _ = beam_search(decoder, features, vocab.word2id[START], vocab.word2id[END], k=k)
84
+ words = vocab.decode(tokens)
85
+
86
+ fig = overlay_grid(load_image(image_path, display_transform()), words, alphas)
87
+ fig.savefig(out_path, dpi=120, bbox_inches="tight")
88
+ plt.close(fig)
89
+ return words
90
+
91
+
92
+ def _resolve_image(args) -> Path:
93
+ if args.image:
94
+ return Path(args.image)
95
+ ds = CaptionDataset(args.data_root, "test", Vocab.load(config.vocab_path), build_transform())
96
+ return Path(args.data_root) / "Images" / ds.records[args.index]["filename"]
97
+
98
+
99
+ def main() -> None:
100
+ parser = argparse.ArgumentParser()
101
+ parser.add_argument("--image", help="explicit image path; overrides --index")
102
+ parser.add_argument("--index", type=int, default=0, help="index into the test split")
103
+ parser.add_argument("--data-root", default=str(config.flickr8k_dir))
104
+ parser.add_argument("--ckpt", default=str(config.ckpt_dir / "best.pt"))
105
+ parser.add_argument("--out", default=str(config.data_root / "attention.png"))
106
+ args = parser.parse_args()
107
+
108
+ image_path = _resolve_image(args)
109
+ words = visualize(image_path, args.ckpt, args.out)
110
+ print(f"{image_path.name}: {' '.join(words)}")
111
+ print(f"saved {args.out}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
pipeline/src/capit/__init__.py ADDED
File without changes
pipeline/src/capit/checkpoint.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training checkpoints: atomic save/load of model + optimizer state for resumable runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import torch
11
+ from torch import nn
12
+ from torch.optim import Optimizer
13
+
14
+
15
+ @dataclass
16
+ class TrainState:
17
+ model_state: dict[str, Any]
18
+ optim_state: dict[str, Any]
19
+ epoch: int
20
+ best_bleu4: float
21
+ best_epoch: int
22
+ vocab_sha256: str
23
+
24
+
25
+ def save(
26
+ path: Path | str,
27
+ model: nn.Module,
28
+ optimizer: Optimizer,
29
+ epoch: int,
30
+ best_bleu4: float,
31
+ best_epoch: int,
32
+ vocab_sha256: str,
33
+ ) -> None:
34
+ path = Path(path)
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ state = {
37
+ "model": model.state_dict(),
38
+ "optimizer": optimizer.state_dict(),
39
+ "epoch": epoch,
40
+ "best_bleu4": best_bleu4,
41
+ "best_epoch": best_epoch,
42
+ "vocab_sha256": vocab_sha256,
43
+ }
44
+ tmp = path.with_name(path.name + ".tmp")
45
+ torch.save(state, tmp)
46
+ os.replace(tmp, path)
47
+
48
+
49
+ def load(path: Path | str) -> TrainState:
50
+ state = torch.load(Path(path), map_location="cpu", weights_only=False)
51
+ return TrainState(
52
+ model_state=state["model"],
53
+ optim_state=state["optimizer"],
54
+ epoch=state["epoch"],
55
+ best_bleu4=state["best_bleu4"],
56
+ best_epoch=state.get("best_epoch", state["epoch"]),
57
+ vocab_sha256=state["vocab_sha256"],
58
+ )
pipeline/src/capit/config.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Single source of truth for every hyperparameter in the capit project.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Config:
15
+ seed: int = 13
16
+ data_root: Path = _REPO_ROOT / "data"
17
+ subsample_train: int = 50
18
+ subsample_val: int = 10
19
+ subsample_test: int = 10
20
+ min_freq: int = 5
21
+ resize: int = 256
22
+ crop: int = 224
23
+ imagenet_mean: tuple[float, float, float] = (0.485, 0.456, 0.406)
24
+ imagenet_std: tuple[float, float, float] = (0.229, 0.224, 0.225)
25
+ encoded_size: int = 14
26
+ encoder_dim: int = 2048
27
+ decoder_dim: int = 512
28
+ attention_dim: int = 512
29
+ embed_dim: int = 512
30
+ dropout: float = 0.5
31
+ alpha_c: float = 1.0
32
+ decoder_lr: float = 4e-4
33
+ batch_size: int = 64
34
+ max_epochs: int = 50
35
+ num_workers: int = 2
36
+ grad_clip: float = 5.0
37
+ patience: int = 10
38
+ beam_k: int = 3
39
+ beam_alpha: float = 0.7
40
+ max_decode_len: int = 50
41
+
42
+
43
+ @property
44
+ def flickr8k_dir(self) -> Path:
45
+ return self.data_root / "flickr8k"
46
+
47
+ @property
48
+ def images_dir(self) -> Path:
49
+ return self.flickr8k_dir / "Images"
50
+
51
+ @property
52
+ def captions_txt(self) -> Path:
53
+ return self.flickr8k_dir / "captions.txt"
54
+
55
+ @property
56
+ def karpathy_dir(self) -> Path:
57
+ return self.data_root / "karpathy"
58
+
59
+ @property
60
+ def karpathy_json(self) -> Path:
61
+ return self.karpathy_dir / "dataset_flickr8k.json"
62
+
63
+ @property
64
+ def subsample_counts(self) -> dict[str, int]:
65
+ return {
66
+ "train": self.subsample_train,
67
+ "val": self.subsample_val,
68
+ "test": self.subsample_test,
69
+ }
70
+
71
+ @property
72
+ def subsample_root(self) -> Path:
73
+ return self.data_root / "dev_subsample"
74
+
75
+ @property
76
+ def subsample_images_dir(self) -> Path:
77
+ return self.subsample_root / "Images"
78
+
79
+ @property
80
+ def subsample_json(self) -> Path:
81
+ return self.subsample_root / "dataset_flickr8k.json"
82
+
83
+ @property
84
+ def vocab_path(self) -> Path:
85
+ return self.data_root / "vocab.json"
86
+
87
+ @property
88
+ def ckpt_dir(self) -> Path:
89
+ return self.data_root / "checkpoints"
90
+
91
+
92
+ config = Config()
pipeline/src/capit/data/__init__.py ADDED
File without changes
pipeline/src/capit/data/dataset.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CaptionDataset: (image, caption) pairs and evaluation views over a Flickr8k root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import torch
8
+ from PIL import Image, ImageOps
9
+ from torch.utils.data import Dataset
10
+ from torchvision import transforms
11
+ from capit.config import Config, config
12
+ from capit.data.vocab import END, PAD, START, Vocab
13
+
14
+
15
+ def build_transform(cfg: Config = config):
16
+ return transforms.Compose(
17
+ [
18
+ transforms.Resize(cfg.resize),
19
+ transforms.CenterCrop(cfg.crop),
20
+ transforms.ToTensor(),
21
+ transforms.Normalize(cfg.imagenet_mean, cfg.imagenet_std),
22
+ ]
23
+ )
24
+
25
+
26
+ class CaptionDataset(Dataset):
27
+ def __init__(self, root: Path | str, split: str, vocab: Vocab, transform) -> None:
28
+ self.root = Path(root)
29
+ self.split = split
30
+ self.vocab = vocab
31
+ self.transform = transform
32
+
33
+ records = json.loads((self.root / "dataset_flickr8k.json").read_text())["images"]
34
+ self.records = [r for r in records if r["split"] == split]
35
+ if not self.records:
36
+ available = sorted({r["split"] for r in records})
37
+ raise ValueError(f"split {split!r} matched 0 records under {self.root}; available: {available}")
38
+ self.pairs = [(r, s) for r in self.records for s in r["sentences"]]
39
+ self.max_len = max(len(s["tokens"]) + 2 for r in self.records for s in r["sentences"])
40
+
41
+ ordered = sorted(self.records, key=lambda r: r["filename"])
42
+ self.image_id = {r["filename"]: i for i, r in enumerate(ordered)}
43
+
44
+ def __len__(self) -> int:
45
+ return len(self.pairs)
46
+
47
+ def __getitem__(self, i: int) -> tuple[torch.Tensor, torch.Tensor, int]:
48
+ rec, sent = self.pairs[i]
49
+ image = self._load_image(rec["filename"])
50
+ ids = [self.vocab.word2id[START], *self.vocab.encode(sent["tokens"]), self.vocab.word2id[END]]
51
+ caption_len = len(ids)
52
+ pad = self.max_len - caption_len
53
+ if pad < 0:
54
+ raise ValueError(f"caption len {caption_len} exceeds max_len {self.max_len} for {rec['filename']!r}")
55
+ ids += [self.vocab.word2id[PAD]] * pad
56
+ return image, torch.tensor(ids), caption_len
57
+
58
+ def _load_image(self, filename: str) -> torch.Tensor:
59
+ with Image.open(self.root / "Images" / filename) as img:
60
+ img = ImageOps.exif_transpose(img) or img
61
+ return self.transform(img.convert("RGB"))
62
+
63
+ def references(self) -> dict[int, list[list[str]]]:
64
+ return {
65
+ self.image_id[r["filename"]]: [s["tokens"] for s in r["sentences"]] for r in self.records
66
+ }
67
+
68
+ def iter_images(self):
69
+ for r in self.records:
70
+ yield self.image_id[r["filename"]], self._load_image(r["filename"])
71
+
72
+
73
+ def collate_fn(batch):
74
+ batch = sorted(batch, key=lambda x: x[2], reverse=True)
75
+ images = torch.stack([img for img, _, _ in batch])
76
+ captions = torch.stack([ids for _, ids, _ in batch])
77
+ lengths = torch.tensor([clen for _, _, clen in batch])
78
+ return images, captions, lengths
pipeline/src/capit/data/download.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset acquisition.
2
+
3
+ Two independent, idempotent fetchers that materialize Flickr8k into the canonical ``data/`` root so the directory is self-contained .
4
+
5
+ The Karpathy JSON is the source of truth downstream; the ~91 Kaggle images it does not
6
+ reference are never loaded, and captions.txt is downloaded for completeness but unused.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import os
13
+ import shutil
14
+ import urllib.request
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
+ from capit.config import config
19
+
20
+ KARPATHY_ZIP_URL = (
21
+ "https://cs.stanford.edu/people/karpathy/deepimagesent/caption_datasets.zip"
22
+ )
23
+ KAGGLE_DATASET = "adityajn105/flickr8k"
24
+ _HTTP_TIMEOUT = 60
25
+
26
+
27
+ def _copy_atomic(src: Path, dst: Path) -> None:
28
+ tmp = dst.with_name(dst.name + ".tmp")
29
+ shutil.copy2(src, tmp)
30
+ os.replace(tmp, dst)
31
+
32
+
33
+ def download_flickr8k(dest: Path | None = None) -> Path:
34
+ """Materialize Flickr8k images + captions.txt into ``dest`` (default ``config.flickr8k_dir``)."""
35
+ dest = Path(dest) if dest is not None else config.flickr8k_dir
36
+ images_dir = dest / "Images"
37
+ captions = dest / "captions.txt"
38
+ sentinel = dest / ".complete"
39
+
40
+ if sentinel.exists():
41
+ return dest
42
+
43
+ import kagglehub # lazy: keeps the module importable without the dep installed
44
+
45
+ src = Path(kagglehub.dataset_download(KAGGLE_DATASET))
46
+ src_images = src / "Images"
47
+ if not src_images.is_dir():
48
+ contents = sorted(p.name for p in src.iterdir())
49
+ raise FileNotFoundError(f"expected Images/ under {src}, found: {contents}")
50
+ src_captions = src / "captions.txt"
51
+ if not src_captions.is_file():
52
+ contents = sorted(p.name for p in src.iterdir())
53
+ raise FileNotFoundError(f"expected captions.txt under {src}, found: {contents}")
54
+
55
+ images_dir.mkdir(parents=True, exist_ok=True)
56
+ for jpg in src_images.glob("*.jpg"):
57
+ target = images_dir / jpg.name
58
+ if not target.exists():
59
+ _copy_atomic(jpg, target)
60
+ _copy_atomic(src_captions, captions)
61
+
62
+ sentinel.touch()
63
+ return dest
64
+
65
+
66
+ def download_karpathy_splits(dest: Path | None = None) -> Path:
67
+ """Download Karpathy's caption_datasets.zip and extract dataset_flickr8k.json into ``dest``.
68
+ """
69
+ dest = Path(dest) if dest is not None else config.karpathy_dir
70
+ json_path = dest / "dataset_flickr8k.json"
71
+ if json_path.is_file():
72
+ return json_path
73
+
74
+ dest.mkdir(parents=True, exist_ok=True)
75
+ with urllib.request.urlopen(KARPATHY_ZIP_URL, timeout=_HTTP_TIMEOUT) as resp: # noqa: S310 — trusted https URL
76
+ archive = resp.read()
77
+
78
+ with zipfile.ZipFile(io.BytesIO(archive)) as zf:
79
+ members = [n for n in zf.namelist() if n.endswith("dataset_flickr8k.json")]
80
+ if not members:
81
+ raise FileNotFoundError(
82
+ f"dataset_flickr8k.json not in archive; contents: {zf.namelist()}"
83
+ )
84
+ tmp = json_path.with_name(json_path.name + ".tmp")
85
+ try:
86
+ with zf.open(members[0]) as f_in, open(tmp, "wb") as f_out:
87
+ shutil.copyfileobj(f_in, f_out)
88
+ os.replace(tmp, json_path)
89
+ except BaseException:
90
+ tmp.unlink(missing_ok=True)
91
+ raise
92
+
93
+ return json_path
94
+
95
+
96
+ def main() -> None:
97
+ flickr_dir = download_flickr8k()
98
+ json_path = download_karpathy_splits()
99
+ n_jpg = len(list((flickr_dir / "Images").glob("*.jpg")))
100
+ print(f"flickr8k: {n_jpg} jpgs in {flickr_dir / 'Images'}")
101
+ print(f"karpathy: {json_path}")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
pipeline/src/capit/data/records.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure operations over Karpathy image records (the `images` list of the split JSON)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+
8
+ def train_captions(records: list[dict]) -> list[list[str]]:
9
+ captions = [s["tokens"] for r in records if r["split"] == "train" for s in r["sentences"]]
10
+ if not captions:
11
+ splits = sorted({r["split"] for r in records})
12
+ raise ValueError(f"no train records found; available splits: {splits}")
13
+ return captions
14
+
15
+
16
+ def select_records(records: list[dict], counts: dict[str, int], seed: int) -> list[dict]:
17
+ by_split: dict[str, list[dict]] = {split: [] for split in counts}
18
+ for rec in records:
19
+ if rec["split"] in by_split:
20
+ by_split[rec["split"]].append(rec)
21
+
22
+ rng = random.Random(seed)
23
+ selected: list[dict] = []
24
+ for split, count in counts.items():
25
+ pool = sorted(by_split[split], key=lambda r: r["filename"])
26
+ if len(pool) < count:
27
+ raise ValueError(f"{split}: need {count}, only {len(pool)} available")
28
+ selected.extend(rng.sample(pool, count))
29
+ return selected
pipeline/src/capit/data/vocab.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vocabulary: a word<->id mapping with frequency-filtered tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from collections import Counter
9
+ from pathlib import Path
10
+
11
+ PAD, START, END, UNK = "<pad>", "<start>", "<end>", "<unk>"
12
+ SPECIALS = [PAD, START, END, UNK]
13
+
14
+
15
+ class Vocab:
16
+ def __init__(self, words: list[str]) -> None:
17
+ if list(words[:4]) != SPECIALS:
18
+ raise ValueError(f"ids 0-3 must be {SPECIALS}, got {list(words[:4])}")
19
+ if len(set(words)) != len(words):
20
+ raise ValueError("vocab contains duplicate words")
21
+ self.id2word = list(words)
22
+ self.word2id = {w: i for i, w in enumerate(self.id2word)}
23
+
24
+ @classmethod
25
+ def build(cls, captions: list[list[str]], min_freq: int = 5) -> "Vocab":
26
+ counts: Counter[str] = Counter()
27
+ for tokens in captions:
28
+ counts.update(tokens)
29
+ kept = sorted((w for w, c in counts.items() if c >= min_freq), key=lambda w: (-counts[w], w))
30
+ return cls(SPECIALS + kept)
31
+
32
+ def encode(self, words: list[str]) -> list[int]:
33
+ unk = self.word2id[UNK]
34
+ return [self.word2id.get(w, unk) for w in words]
35
+
36
+ def decode(self, ids: list[int]) -> list[str]:
37
+ n = len(self.id2word)
38
+ for i in ids:
39
+ if not 0 <= i < n:
40
+ raise ValueError(f"decode: id {i} out of range [0, {n})")
41
+ return [self.id2word[i] for i in ids]
42
+
43
+ def __len__(self) -> int:
44
+ return len(self.id2word)
45
+
46
+ def sha256(self) -> str:
47
+ return hashlib.sha256(json.dumps(self.id2word, ensure_ascii=False).encode()).hexdigest()
48
+
49
+ def save(self, path: Path | str) -> None:
50
+ path = Path(path)
51
+ tmp = path.with_name(path.name + ".tmp")
52
+ tmp.write_text(json.dumps(self.id2word, ensure_ascii=False, indent=2))
53
+ os.replace(tmp, path)
54
+
55
+ @classmethod
56
+ def load(cls, path: Path | str) -> "Vocab":
57
+ return cls(json.loads(Path(path).read_text()))
pipeline/src/capit/decode.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Greedy and beam-search decoding with per-word attention.
2
+
3
+ Both operate on a single image's encoder features ``[1, L, encoder_dim]`` and return the caption token ids (specials stripped) plus an aligned ``[T, L]`` alpha tensor — the per-word attention that drives the heatmaps. beam_search also returns the full completed set as ``(tokens, normalized_score)`` pairs, winner first ("the road not taken").
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import torch
9
+ from torch.nn.functional import log_softmax
10
+ from capit.config import config
11
+ from capit.models.decoder import Decoder
12
+
13
+
14
+ @torch.no_grad()
15
+ def greedy(
16
+ decoder: Decoder, features: torch.Tensor, start_id: int, end_id: int, max_len: int = config.max_decode_len
17
+ ) -> tuple[list[int], torch.Tensor]:
18
+ decoder.eval()
19
+ h, c = decoder.init_hidden(features)
20
+ token = features.new_full((1,), start_id, dtype=torch.long)
21
+ ids: list[int] = []
22
+ alphas: list[torch.Tensor] = []
23
+ for _ in range(max_len):
24
+ logits, alpha, h, c = decoder.step(token, h, c, features)
25
+ token = logits.argmax(dim=1)
26
+ if int(token) == end_id:
27
+ break
28
+ ids.append(int(token))
29
+ alphas.append(alpha[0])
30
+ stacked = torch.stack(alphas) if alphas else features.new_zeros(0, features.size(1))
31
+ return ids, stacked
32
+
33
+
34
+ @torch.no_grad()
35
+ def beam_search(
36
+ decoder: Decoder,
37
+ features: torch.Tensor,
38
+ start_id: int,
39
+ end_id: int,
40
+ k: int = config.beam_k,
41
+ max_len: int = config.max_decode_len,
42
+ alpha_norm: float = config.beam_alpha,
43
+ ) -> tuple[list[int], torch.Tensor, list[tuple[list[int], float]]]:
44
+ decoder.eval()
45
+ vocab_size, num_pixels = decoder.fc.out_features, features.size(1)
46
+ feats = features.expand(k, num_pixels, -1)
47
+ h, c = decoder.init_hidden(feats)
48
+ seqs = features.new_full((k, 1), start_id, dtype=torch.long)
49
+ seq_alphas = features.new_zeros(k, 1, num_pixels)
50
+ scores = features.new_zeros(k, 1)
51
+ completed: list[tuple[list[int], torch.Tensor, float]] = []
52
+
53
+ for step in range(max_len):
54
+ logits, alpha, h, c = decoder.step(seqs[:, -1], h, c, feats)
55
+ logp = scores + log_softmax(logits, dim=1)
56
+ flat = logp[0] if step == 0 else logp.reshape(-1) # step 0: all beams identical
57
+ top_scores, top_idx = flat.topk(seqs.size(0))
58
+ beam_idx, word_idx = top_idx // vocab_size, top_idx % vocab_size
59
+
60
+ seqs = torch.cat([seqs[beam_idx], word_idx.unsqueeze(1)], dim=1)
61
+ seq_alphas = torch.cat([seq_alphas[beam_idx], alpha[beam_idx].unsqueeze(1)], dim=1)
62
+ scores, h, c, feats = top_scores.unsqueeze(1), h[beam_idx], c[beam_idx], feats[beam_idx]
63
+
64
+ ended = word_idx == end_id
65
+ for i in ended.nonzero().flatten().tolist():
66
+ completed.append((seqs[i, 1:-1].tolist(), seq_alphas[i, 1:-1], float(scores[i])))
67
+ keep = (~ended).nonzero().flatten()
68
+ if len(keep) == 0:
69
+ break
70
+ seqs, seq_alphas, scores = seqs[keep], seq_alphas[keep], scores[keep]
71
+ h, c, feats = h[keep], c[keep], feats[keep]
72
+
73
+ if not completed: # nothing emitted <end> by max_len → fall back to the best live hypothesis
74
+ i = int(scores.argmax())
75
+ completed.append((seqs[i, 1:].tolist(), seq_alphas[i, 1:], float(scores[i])))
76
+
77
+ ranked = sorted(completed, key=lambda x: x[2] / max(len(x[0]), 1) ** alpha_norm, reverse=True)
78
+ beams = [(toks, score / max(len(toks), 1) ** alpha_norm) for toks, _, score in ranked]
79
+ win_tokens, win_alphas, _ = ranked[0]
80
+ return win_tokens, win_alphas, beams
pipeline/src/capit/evaluate.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official BLEU/CIDEr on the test split.
2
+
3
+ Generates one caption per test image (beam search) and scores it against the 5 references with the pycocoevalcap Python scorers used directly on pre-tokenized strings. Encoder features are cached once and reused across beam widths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import torch
13
+ from pycocoevalcap.bleu.bleu import Bleu
14
+ from pycocoevalcap.cider.cider import Cider
15
+ from capit.checkpoint import load
16
+ from capit.config import config
17
+ from capit.data.dataset import CaptionDataset, build_transform
18
+ from capit.data.vocab import END, START, Vocab
19
+ from capit.decode import beam_search
20
+ from capit.models.decoder import Decoder
21
+ from capit.models.encoder import Encoder
22
+
23
+ METRICS = ["BLEU-1", "BLEU-2", "BLEU-3", "BLEU-4", "CIDEr"]
24
+
25
+
26
+ def score(
27
+ references: dict[int, list[list[str]]], candidates: dict[int, list[str]]
28
+ ) -> dict[str, float]:
29
+ gts = {i: [" ".join(toks) for toks in refs] for i, refs in references.items()}
30
+ res = {i: [" ".join(candidates[i])] for i in references}
31
+ bleu = Bleu(4).compute_score(gts, res, verbose=0)[0]
32
+ cider = Cider().compute_score(gts, res)[0]
33
+ return {
34
+ "BLEU-1": float(bleu[0]) * 100,
35
+ "BLEU-2": float(bleu[1]) * 100,
36
+ "BLEU-3": float(bleu[2]) * 100,
37
+ "BLEU-4": float(bleu[3]) * 100,
38
+ "CIDEr": float(cider) * 100,
39
+ }
40
+
41
+
42
+ @torch.no_grad()
43
+ def evaluate(
44
+ data_root, ckpt_path, beams: tuple[int, ...] = (1, 3, 5)
45
+ ) -> dict[int, dict[str, float]]:
46
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
47
+ vocab = Vocab.load(config.vocab_path)
48
+ encoder = Encoder(pretrained=True).to(device).eval()
49
+ decoder = Decoder(vocab_size=len(vocab)).to(device)
50
+ state = load(ckpt_path)
51
+ if state.vocab_sha256 != vocab.sha256():
52
+ raise ValueError(f"vocab mismatch: ckpt {state.vocab_sha256[:8]} != vocab {vocab.sha256()[:8]}")
53
+ decoder.load_state_dict(state.model_state)
54
+
55
+ ds = CaptionDataset(data_root, "test", vocab, build_transform())
56
+ references = ds.references()
57
+ features = {iid: encoder(img.unsqueeze(0).to(device)) for iid, img in ds.iter_images()}
58
+
59
+ sid, eid = vocab.word2id[START], vocab.word2id[END]
60
+ results: dict[int, dict[str, float]] = {}
61
+ for k in beams:
62
+ candidates = {
63
+ iid: vocab.decode(beam_search(decoder, f, sid, eid, k=k)[0])
64
+ for iid, f in features.items()
65
+ }
66
+ results[k] = score(references, candidates)
67
+ return results
68
+
69
+
70
+ def main() -> None:
71
+ parser = argparse.ArgumentParser()
72
+ parser.add_argument("--data-root", default=str(config.flickr8k_dir))
73
+ parser.add_argument("--ckpt", default=str(config.ckpt_dir / "best.pt"))
74
+ parser.add_argument("--beams", type=int, nargs="+", default=[1, 3, 5])
75
+ parser.add_argument("--out-json", help="write the results table to this path for downstream use")
76
+ args = parser.parse_args()
77
+
78
+ results = evaluate(args.data_root, args.ckpt, tuple(args.beams))
79
+ header = "beam " + " ".join(f"{m:>7}" for m in METRICS)
80
+ print(header)
81
+ print("-" * len(header))
82
+ for k, s in results.items():
83
+ print(f"{k:>4} " + " ".join(f"{s[m]:>7.2f}" for m in METRICS))
84
+ if args.out_json:
85
+ Path(args.out_json).write_text(json.dumps(results, indent=2))
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
pipeline/src/capit/losses.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Caption loss: cross-entropy over real positions + doubly-stochastic attention regularizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from torch.nn.functional import cross_entropy
7
+
8
+ from capit.config import config
9
+
10
+
11
+ def word_cross_entropy(logits: torch.Tensor, captions: torch.Tensor) -> torch.Tensor:
12
+ targets = captions[:, 1 : 1 + logits.size(1)]
13
+ return cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1), ignore_index=0)
14
+
15
+
16
+ def caption_loss(
17
+ logits: torch.Tensor,
18
+ alphas: torch.Tensor,
19
+ captions: torch.Tensor,
20
+ alpha_c: float = config.alpha_c,
21
+ ) -> torch.Tensor:
22
+ reg = alpha_c * ((1 - alphas.sum(dim=1)) ** 2).mean()
23
+ return word_cross_entropy(logits, captions) + reg
pipeline/src/capit/models/__init__.py ADDED
File without changes
pipeline/src/capit/models/attention.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Attention module for processing spatial features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from torch import nn
7
+ from capit.config import config
8
+
9
+ class BahdanauAttention(nn.Module):
10
+ def __init__(
11
+ self,
12
+ encoder_dim: int = config.encoder_dim,
13
+ decoder_dim: int = config.decoder_dim,
14
+ attention_dim: int = config.attention_dim,
15
+ ) -> None:
16
+ super().__init__()
17
+ self.encoder_att = nn.Linear(encoder_dim, attention_dim)
18
+ self.decoder_att = nn.Linear(decoder_dim, attention_dim)
19
+ self.full_att = nn.Linear(attention_dim, 1)
20
+ self.relu = nn.ReLU()
21
+ self.softmax = nn.Softmax(dim=1)
22
+
23
+ def forward(self, features: torch.Tensor, h: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
24
+ att1 = self.encoder_att(features)
25
+ att2 = self.decoder_att(h).unsqueeze(1)
26
+ att = self.full_att(self.relu(att1 + att2)).squeeze(2)
27
+ alpha = self.softmax(att)
28
+ context = (features * alpha.unsqueeze(2)).sum(dim=1)
29
+ return context, alpha
pipeline/src/capit/models/decoder.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decoder: a recurrent neural network for generating captions."""
2
+
3
+ from __future__ import annotations
4
+ import torch
5
+ from torch import nn
6
+ from capit.config import config
7
+ from capit.models.attention import BahdanauAttention
8
+
9
+ class Decoder(nn.Module):
10
+ def __init__(
11
+ self,
12
+ vocab_size: int,
13
+ embed_dim: int = config.embed_dim,
14
+ decoder_dim: int = config.decoder_dim,
15
+ attention_dim: int = config.attention_dim,
16
+ dropout: float = config.dropout,
17
+ ) -> None:
18
+ super().__init__()
19
+ self.attention = BahdanauAttention(config.encoder_dim, decoder_dim, attention_dim)
20
+ self.embedding = nn.Embedding(vocab_size, embed_dim)
21
+ self.dropout = nn.Dropout(dropout)
22
+ self.decode_step = nn.LSTMCell(embed_dim + config.encoder_dim, decoder_dim)
23
+ self.init_h = nn.Linear(config.encoder_dim, decoder_dim)
24
+ self.init_c = nn.Linear(config.encoder_dim, decoder_dim)
25
+ self.f_beta = nn.Linear(decoder_dim, config.encoder_dim)
26
+ self.fc = nn.Linear(decoder_dim, vocab_size)
27
+
28
+ def init_hidden(self, features: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
29
+ mean_features = features.mean(dim=1)
30
+ h = self.init_h(mean_features)
31
+ c = self.init_c(mean_features)
32
+ return h, c
33
+
34
+ def step(
35
+ self, token: torch.Tensor, h: torch.Tensor, c: torch.Tensor, features: torch.Tensor
36
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
37
+ context, alpha = self.attention(features, h)
38
+ context = torch.sigmoid(self.f_beta(h)) * context
39
+ x = torch.cat([self.embedding(token), context], dim=1)
40
+ h, c = self.decode_step(x, (h, c))
41
+ return self.fc(self.dropout(h)), alpha, h, c
42
+
43
+ def forward(
44
+ self, features: torch.Tensor, captions: torch.Tensor, lengths: torch.Tensor
45
+ ) -> tuple[torch.Tensor, torch.Tensor, list[int]]:
46
+ embeddings = self.embedding(captions)
47
+ h, c = self.init_hidden(features)
48
+ decode_lengths = (lengths - 1).tolist()
49
+ if any(a < b for a, b in zip(decode_lengths, decode_lengths[1:])):
50
+ raise ValueError(f"forward expects lengths sorted descending; got {lengths.tolist()}")
51
+ T = max(decode_lengths)
52
+ batch_size, num_pixels, _ = features.size()
53
+ vocab_size = self.fc.out_features
54
+ logits = features.new_zeros(batch_size, T, vocab_size)
55
+ alphas = features.new_zeros(batch_size, T, num_pixels)
56
+ for t in range(T):
57
+ n = sum(l > t for l in decode_lengths)
58
+ context, alpha = self.attention(features[:n], h[:n])
59
+ beta = torch.sigmoid(self.f_beta(h[:n]))
60
+ context = beta * context
61
+ x = torch.cat([embeddings[:n, t], context], dim=1)
62
+ h, c = self.decode_step(x, (h[:n], c[:n]))
63
+ logits[:n, t] = self.fc(self.dropout(h))
64
+ alphas[:n, t] = alpha
65
+ return logits, alphas, decode_lengths
66
+
67
+ @torch.no_grad()
68
+ def greedy(
69
+ self, features: torch.Tensor, start_id: int, end_id: int, max_len: int = 50
70
+ ) -> list[list[int]]:
71
+ was_training = self.training
72
+ self.eval()
73
+ batch_size = features.size(0)
74
+ h, c = self.init_hidden(features)
75
+ prev = features.new_full((batch_size,), start_id, dtype=torch.long)
76
+ outputs: list[list[int]] = [[] for _ in range(batch_size)]
77
+ done = [False] * batch_size
78
+ for _ in range(max_len):
79
+ logits, _, h, c = self.step(prev, h, c, features)
80
+ prev = logits.argmax(dim=1)
81
+ for i in range(batch_size):
82
+ if not done[i]:
83
+ tok = int(prev[i])
84
+ if tok == end_id:
85
+ done[i] = True
86
+ else:
87
+ outputs[i].append(tok)
88
+ if all(done):
89
+ break
90
+ if was_training:
91
+ self.train()
92
+ return outputs
93
+
pipeline/src/capit/models/encoder.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encoder: a frozen ResNet-50 backbone producing [B, 196, 2048] spatial features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ from torch import nn
7
+ from torchvision.models import ResNet50_Weights, resnet50
8
+ from capit.config import config
9
+
10
+
11
+ class Encoder(nn.Module):
12
+ def __init__(self, encoded_size: int = config.encoded_size, pretrained: bool = True) -> None:
13
+ super().__init__()
14
+ weights = ResNet50_Weights.DEFAULT if pretrained else None
15
+ backbone = resnet50(weights=weights)
16
+ self.backbone = nn.Sequential(*list(backbone.children())[:-2])
17
+ self.pool = nn.AdaptiveAvgPool2d((encoded_size, encoded_size))
18
+ for p in self.parameters():
19
+ p.requires_grad = False
20
+ self.eval()
21
+
22
+ def forward(self, images: torch.Tensor) -> torch.Tensor:
23
+ x = self.pool(self.backbone(images))
24
+ return x.flatten(2).permute(0, 2, 1)
25
+
26
+ def fine_tune(self, blocks: tuple[int, ...] = ()) -> None:
27
+ for i in blocks:
28
+ for p in self.backbone[i].parameters():
29
+ p.requires_grad = True
30
+
31
+ def train(self, mode: bool = True) -> "Encoder":
32
+ super().train(mode)
33
+ self.backbone.eval()
34
+ return self
pipeline/src/capit/overfit.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Overfit-one-batch harness (Stage 2.3, killer gate #1).
2
+
3
+ Trains the decoder on ONE batch of N distinct subsample images (encoder frozen, features
4
+ cached once) and reports whether it memorizes them. Trains on the full caption loss
5
+ (CE + doubly-stochastic regularizer); the meaningful signal is the CE term, since the
6
+ regularizer floors near ~0.9 for short captions so a sub-0.5 total is unreachable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ from torch.optim import Adam
13
+
14
+ from capit.config import config
15
+ from capit.data.dataset import CaptionDataset, build_transform, collate_fn
16
+ from capit.data.vocab import END, START, Vocab
17
+ from capit.losses import caption_loss, word_cross_entropy
18
+ from capit.models.decoder import Decoder
19
+ from capit.models.encoder import Encoder
20
+
21
+
22
+ def _distinct_image_batch(ds: CaptionDataset, n: int):
23
+ seen: set[str] = set()
24
+ picks = []
25
+ for idx, (rec, _) in enumerate(ds.pairs):
26
+ if rec["filename"] not in seen:
27
+ seen.add(rec["filename"])
28
+ picks.append(idx)
29
+ if len(picks) == n:
30
+ break
31
+ if len(picks) < n:
32
+ raise ValueError(f"requested {n} distinct images but dataset has only {len(picks)}")
33
+ return collate_fn([ds[i] for i in picks])
34
+
35
+
36
+ def run_overfit(steps: int = 400, n_images: int = 4) -> dict:
37
+ torch.manual_seed(config.seed)
38
+ vocab = Vocab.load(config.vocab_path)
39
+ ds = CaptionDataset(config.subsample_root, "train", vocab, build_transform())
40
+ images, captions, lengths = _distinct_image_batch(ds, n_images)
41
+
42
+ encoder = Encoder(pretrained=True)
43
+ with torch.no_grad():
44
+ features = encoder(images) # frozen → cache once, never recompute
45
+
46
+ decoder = Decoder(vocab_size=len(vocab))
47
+ decoder.train()
48
+ opt = Adam(decoder.parameters(), lr=config.decoder_lr)
49
+
50
+ ce_curve = []
51
+ for step in range(steps):
52
+ logits, alphas, _ = decoder(features, captions, lengths)
53
+ ce = word_cross_entropy(logits, captions)
54
+ loss = caption_loss(logits, alphas, captions)
55
+ if not torch.isfinite(loss):
56
+ raise RuntimeError(f"loss diverged to {loss.item()} at step {step}")
57
+ opt.zero_grad()
58
+ loss.backward()
59
+ opt.step()
60
+ ce_curve.append(ce.item())
61
+
62
+ decoded = decoder.greedy(features, vocab.word2id[START], vocab.word2id[END])
63
+ decoded_words = [vocab.decode(ids) for ids in decoded]
64
+ target_words = [vocab.decode(captions[i, 1 : lengths[i] - 1].tolist()) for i in range(n_images)]
65
+ return {"ce_curve": ce_curve, "final_ce": ce_curve[-1], "decoded": decoded_words, "targets": target_words}
pipeline/src/capit/serving.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serving artifact: build / load the self-contained inference contract (capit-sat.pt).
2
+
3
+ The artifact carries everything inference needs (encoder + decoder weights, model dims, the
4
+ exact preprocess spec, and the vocab hash) and nothing training-only. The backend rebuilds the
5
+ model from this alone — it never imports training code or touches a checkpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import torch
14
+ from torchvision import transforms
15
+
16
+ from capit.config import config
17
+ from capit.data.vocab import END, START, Vocab
18
+ from capit.decode import beam_search
19
+ from capit.models.decoder import Decoder
20
+ from capit.models.encoder import Encoder
21
+
22
+
23
+ def build_artifact(encoder: Encoder, decoder: Decoder, vocab: Vocab) -> dict[str, Any]:
24
+ return {
25
+ "encoder_state": encoder.state_dict(),
26
+ "decoder_state": decoder.state_dict(),
27
+ "config": {
28
+ "vocab_size": len(vocab),
29
+ "encoder_dim": config.encoder_dim,
30
+ "decoder_dim": config.decoder_dim,
31
+ "attention_dim": config.attention_dim,
32
+ "embed_dim": config.embed_dim,
33
+ "encoded_size": config.encoded_size,
34
+ "dropout": config.dropout,
35
+ "beam_k": config.beam_k,
36
+ "beam_alpha": config.beam_alpha,
37
+ "max_decode_len": config.max_decode_len,
38
+ },
39
+ "preprocess": {
40
+ "resize": config.resize,
41
+ "crop": config.crop,
42
+ "mean": list(config.imagenet_mean),
43
+ "std": list(config.imagenet_std),
44
+ },
45
+ "vocab_sha256": vocab.sha256(),
46
+ }
47
+
48
+
49
+ def center_crop_box(width: int, height: int, preprocess: dict[str, Any]) -> dict[str, float]:
50
+ """The Resize(short=resize)+CenterCrop(crop) region, as fractions of the original image.
51
+
52
+ Resize preserves aspect, so resized-image fractions equal original-image fractions. The
53
+ frontend positions the attention overlay over this box (non-square uploads misalign otherwise).
54
+ """
55
+ resize, crop = preprocess["resize"], preprocess["crop"]
56
+ scale = resize / min(width, height)
57
+ rw, rh = width * scale, height * scale
58
+ return {"x": (rw - crop) / 2 / rw, "y": (rh - crop) / 2 / rh, "w": crop / rw, "h": crop / rh}
59
+
60
+
61
+ def make_transform(preprocess: dict[str, Any]):
62
+ return transforms.Compose(
63
+ [
64
+ transforms.Resize(preprocess["resize"]),
65
+ transforms.CenterCrop(preprocess["crop"]),
66
+ transforms.ToTensor(),
67
+ transforms.Normalize(preprocess["mean"], preprocess["std"]),
68
+ ]
69
+ )
70
+
71
+
72
+ def load_artifact(artifact_path: Path | str, vocab_path: Path | str):
73
+ blob = torch.load(artifact_path, map_location="cpu", weights_only=True)
74
+ vocab = Vocab.load(vocab_path)
75
+ if vocab.sha256() != blob["vocab_sha256"]:
76
+ raise ValueError(f"vocab mismatch: vocab {vocab.sha256()[:8]} != artifact {blob['vocab_sha256'][:8]}")
77
+ cfg = blob["config"]
78
+ encoder = Encoder(encoded_size=cfg["encoded_size"], pretrained=False)
79
+ encoder.load_state_dict(blob["encoder_state"])
80
+ encoder.eval()
81
+ decoder = Decoder(
82
+ vocab_size=cfg["vocab_size"],
83
+ embed_dim=cfg["embed_dim"],
84
+ decoder_dim=cfg["decoder_dim"],
85
+ attention_dim=cfg["attention_dim"],
86
+ dropout=cfg["dropout"],
87
+ )
88
+ decoder.load_state_dict(blob["decoder_state"])
89
+ decoder.eval()
90
+ return encoder, decoder, vocab, blob["preprocess"]
91
+
92
+
93
+ @torch.no_grad()
94
+ def caption(encoder: Encoder, decoder: Decoder, vocab: Vocab, image: torch.Tensor, k: int = config.beam_k):
95
+ features = encoder(image.unsqueeze(0))
96
+ tokens, alphas, beams = beam_search(decoder, features, vocab.word2id[START], vocab.word2id[END], k=k)
97
+ return vocab.decode(tokens), alphas, beams
pipeline/src/capit/train.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the capit model.
2
+
3
+ CLI: python -m capit.train --data-root <root> --ckpt-dir <dir> --resume auto
4
+
5
+ Checkpoints the decoder + optimizer every epoch (latest.pt) and on val-BLEU improvement (best.pt); the frozen encoder is reconstructed from ImageNet weights, not stored. Early stops on val BLEU-4 (nltk, a cheap relative signal — reported scores come from Stage 4.2).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ import torch
15
+ from nltk.translate.bleu_score import SmoothingFunction, corpus_bleu
16
+ from torch.nn.utils import clip_grad_norm_
17
+ from torch.optim import Adam
18
+ from torch.utils.data import DataLoader
19
+
20
+ from capit.checkpoint import load, save
21
+ from capit.config import config
22
+ from capit.data.dataset import CaptionDataset, build_transform, collate_fn
23
+ from capit.data.vocab import END, START, Vocab
24
+ from capit.losses import caption_loss
25
+ from capit.models.decoder import Decoder
26
+ from capit.models.encoder import Encoder
27
+
28
+
29
+ def corpus_bleu4(references: dict[int, list[list[str]]], candidates: dict[int, list[str]]) -> float:
30
+ order = list(references)
31
+ if not order:
32
+ raise ValueError("corpus_bleu4: no references (val split empty?)")
33
+ refs = [references[i] for i in order]
34
+ hyps = [candidates[i] for i in order]
35
+ empty = sum(not h for h in hyps)
36
+ if empty:
37
+ print(f"warning: {empty}/{len(hyps)} val captions are empty (BLEU will read low)")
38
+ return cast(float, corpus_bleu(refs, hyps, smoothing_function=SmoothingFunction().method1)) * 100
39
+
40
+
41
+ def _train_one_epoch(encoder, decoder, loader, opt, device) -> float:
42
+ decoder.train()
43
+ total = 0.0
44
+ for images, captions, lengths in loader:
45
+ images, captions = images.to(device), captions.to(device)
46
+ with torch.no_grad():
47
+ features = encoder(images)
48
+ logits, alphas, _ = decoder(features, captions, lengths)
49
+ loss = caption_loss(logits, alphas, captions)
50
+ if not torch.isfinite(loss):
51
+ raise FloatingPointError(f"non-finite loss {loss.item()} — aborting before it corrupts latest.pt")
52
+ opt.zero_grad()
53
+ loss.backward()
54
+ clip_grad_norm_(decoder.parameters(), config.grad_clip)
55
+ opt.step()
56
+ total += loss.item()
57
+ return total / len(loader)
58
+
59
+
60
+ @torch.no_grad()
61
+ def _evaluate(encoder, decoder, ds, vocab, device) -> tuple[float, dict[int, list[str]]]:
62
+ decoder.eval()
63
+ candidates: dict[int, list[str]] = {}
64
+ for image_id, image in ds.iter_images():
65
+ features = encoder(image.unsqueeze(0).to(device))
66
+ ids = decoder.greedy(features, vocab.word2id[START], vocab.word2id[END])[0]
67
+ candidates[image_id] = vocab.decode(ids)
68
+ return corpus_bleu4(ds.references(), candidates), candidates
69
+
70
+
71
+ def train(
72
+ data_root: str | Path,
73
+ ckpt_dir: str | Path,
74
+ resume: str = "auto",
75
+ vocab_path: str | Path | None = None,
76
+ max_epochs: int | None = None,
77
+ num_workers: int | None = None,
78
+ ) -> None:
79
+ max_epochs = config.max_epochs if max_epochs is None else max_epochs
80
+ num_workers = config.num_workers if num_workers is None else num_workers
81
+ torch.manual_seed(config.seed)
82
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
83
+ ckpt_dir = Path(ckpt_dir)
84
+ if "drive/MyDrive" in str(ckpt_dir) and not Path("/content/drive/MyDrive").is_dir():
85
+ raise RuntimeError(f"--ckpt-dir {ckpt_dir} is on Drive but it isn't mounted — run the mount cell first")
86
+ latest_path, best_path = ckpt_dir / "latest.pt", ckpt_dir / "best.pt"
87
+
88
+ vocab = Vocab.load(vocab_path or config.vocab_path)
89
+ transform = build_transform()
90
+ train_ds = CaptionDataset(data_root, "train", vocab, transform)
91
+ val_ds = CaptionDataset(data_root, "val", vocab, transform)
92
+ generator = torch.Generator().manual_seed(config.seed)
93
+ train_loader = DataLoader(
94
+ train_ds,
95
+ batch_size=config.batch_size,
96
+ shuffle=True,
97
+ generator=generator,
98
+ collate_fn=collate_fn,
99
+ num_workers=num_workers,
100
+ )
101
+
102
+ encoder = Encoder(pretrained=True).to(device)
103
+ decoder = Decoder(vocab_size=len(vocab)).to(device)
104
+ opt = Adam(decoder.parameters(), lr=config.decoder_lr)
105
+
106
+ start_epoch, best_bleu4, best_epoch = 0, -1.0, 0
107
+ if resume == "auto" and latest_path.is_file():
108
+ state = load(latest_path)
109
+ if state.vocab_sha256 != vocab.sha256():
110
+ raise ValueError("vocab mismatch: checkpoint vocab_sha256 != current vocab.json")
111
+ decoder.load_state_dict(state.model_state)
112
+ opt.load_state_dict(state.optim_state)
113
+ start_epoch, best_bleu4, best_epoch = state.epoch + 1, state.best_bleu4, state.best_epoch
114
+ print(f"resumed at epoch {start_epoch} (best_bleu4 {best_bleu4:.2f} at epoch {best_epoch})")
115
+
116
+ for epoch in range(start_epoch, max_epochs):
117
+ train_loss = _train_one_epoch(encoder, decoder, train_loader, opt, device)
118
+ val_bleu4, candidates = _evaluate(encoder, decoder, val_ds, vocab, device)
119
+
120
+ improved = val_bleu4 > best_bleu4
121
+ if improved:
122
+ best_bleu4, best_epoch = val_bleu4, epoch
123
+ save(latest_path, decoder, opt, epoch, best_bleu4, best_epoch, vocab.sha256())
124
+ if improved:
125
+ save(best_path, decoder, opt, epoch, best_bleu4, best_epoch, vocab.sha256())
126
+
127
+ sample = " ".join(next(iter(candidates.values())))
128
+ flag = " *" if improved else ""
129
+ print(f"epoch {epoch} loss {train_loss:.4f} val_bleu4 {val_bleu4:.2f} best {best_bleu4:.2f}{flag} | {sample}")
130
+ if epoch - best_epoch >= config.patience:
131
+ print(f"early stop: no val BLEU-4 improvement in {config.patience} epochs (last loss {train_loss:.4f})")
132
+ break
133
+
134
+
135
+ def main() -> None:
136
+ parser = argparse.ArgumentParser()
137
+ parser.add_argument("--data-root", default=str(config.subsample_root))
138
+ parser.add_argument("--ckpt-dir", default=str(config.ckpt_dir))
139
+ parser.add_argument("--vocab-path", default=str(config.vocab_path))
140
+ parser.add_argument("--resume", default="auto", choices=["auto", "none"])
141
+ args = parser.parse_args()
142
+ train(args.data_root, args.ckpt_dir, args.resume, vocab_path=args.vocab_path)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
pipeline/tests/test_attention.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2.2 — BahdanauAttention. Pure tensor tests; no data needed."""
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from capit.config import config
7
+ from capit.models.attention import BahdanauAttention
8
+
9
+ B = 3
10
+ L = config.encoded_size**2 # 196 locations
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _seed():
15
+ torch.manual_seed(config.seed)
16
+
17
+
18
+ def _inputs(requires_grad: bool = False) -> tuple[torch.Tensor, torch.Tensor]:
19
+ features = torch.randn(B, L, config.encoder_dim, requires_grad=requires_grad)
20
+ h = torch.randn(B, config.decoder_dim, requires_grad=requires_grad)
21
+ return features, h
22
+
23
+
24
+ def test_output_shapes():
25
+ context, alpha = BahdanauAttention()(*_inputs())
26
+ assert context.shape == (B, config.encoder_dim)
27
+ assert alpha.shape == (B, L)
28
+
29
+
30
+ def test_alpha_sums_to_one():
31
+ _, alpha = BahdanauAttention()(*_inputs())
32
+ assert torch.allclose(alpha.sum(dim=1), torch.ones(B), atol=1e-5)
33
+
34
+
35
+ def test_alpha_is_a_distribution():
36
+ _, alpha = BahdanauAttention()(*_inputs())
37
+ assert (alpha >= 0).all()
38
+ assert not torch.isnan(alpha).any()
39
+
40
+
41
+ def test_no_nan_in_context():
42
+ context, _ = BahdanauAttention()(*_inputs())
43
+ assert not torch.isnan(context).any()
44
+
45
+
46
+ def test_alpha_depends_on_hidden_state():
47
+ att = BahdanauAttention()
48
+ features, _ = _inputs()
49
+ _, a1 = att(features, torch.randn(B, config.decoder_dim))
50
+ _, a2 = att(features, torch.randn(B, config.decoder_dim))
51
+ assert not torch.allclose(a1, a2)
52
+
53
+
54
+ def test_batch_independence():
55
+ att = BahdanauAttention()
56
+ features, h = _inputs()
57
+ ctx_all, alpha_all = att(features, h)
58
+ ctx_row, alpha_row = att(features[:1], h[:1])
59
+ assert torch.allclose(alpha_all[:1], alpha_row, atol=1e-6)
60
+ assert torch.allclose(ctx_all[:1], ctx_row, atol=1e-6)
61
+
62
+
63
+ def test_gradient_flows_to_both_inputs():
64
+ features, h = _inputs(requires_grad=True)
65
+ context, _ = BahdanauAttention()(features, h)
66
+ context.sum().backward()
67
+ assert features.grad is not None and features.grad.abs().sum() > 0
68
+ assert h.grad is not None and h.grad.abs().sum() > 0
pipeline/tests/test_checkpoint.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 3.1 — checkpoint round-trip including optimizer state. Hermetic, no data."""
2
+
3
+ import torch
4
+ from torch import nn
5
+ from torch.optim import Adam
6
+
7
+ from capit.checkpoint import load, save
8
+
9
+
10
+ def _model() -> nn.Module:
11
+ torch.manual_seed(13)
12
+ return nn.Linear(4, 4)
13
+
14
+
15
+ def _step(model: nn.Module, opt: Adam) -> float:
16
+ loss = model(torch.ones(2, 4)).pow(2).mean()
17
+ opt.zero_grad()
18
+ loss.backward()
19
+ opt.step()
20
+ return loss.item()
21
+
22
+
23
+ def test_round_trip_identity(tmp_path):
24
+ model = _model()
25
+ opt = Adam(model.parameters())
26
+ _step(model, opt)
27
+ path = tmp_path / "ckpt.pt"
28
+ save(path, model, opt, epoch=3, best_bleu4=12.5, best_epoch=2, vocab_sha256="abc")
29
+
30
+ ts = load(path)
31
+ assert (ts.epoch, ts.best_bleu4, ts.best_epoch, ts.vocab_sha256) == (3, 12.5, 2, "abc")
32
+ fresh = nn.Linear(4, 4)
33
+ fresh.load_state_dict(ts.model_state)
34
+ for a, b in zip(fresh.state_dict().values(), model.state_dict().values()):
35
+ assert torch.equal(a, b)
36
+
37
+
38
+ def test_optimizer_state_round_trips(tmp_path):
39
+ model = _model()
40
+ opt = Adam(model.parameters())
41
+ _step(model, opt)
42
+ _step(model, opt)
43
+ path = tmp_path / "ckpt.pt"
44
+ save(path, model, opt, epoch=2, best_bleu4=0.0, best_epoch=0, vocab_sha256="x")
45
+ live = [_step(model, opt), _step(model, opt)]
46
+
47
+ model2 = nn.Linear(4, 4)
48
+ opt2 = Adam(model2.parameters())
49
+ ts = load(path)
50
+ model2.load_state_dict(ts.model_state)
51
+ opt2.load_state_dict(ts.optim_state)
52
+ resumed = [_step(model2, opt2), _step(model2, opt2)]
53
+
54
+ assert live == resumed
55
+
56
+
57
+ def test_save_leaves_no_tmp(tmp_path):
58
+ model = _model()
59
+ opt = Adam(model.parameters())
60
+ path = tmp_path / "ckpt.pt"
61
+ save(path, model, opt, epoch=0, best_bleu4=0.0, best_epoch=0, vocab_sha256="x")
62
+ assert path.exists()
63
+ assert not (tmp_path / "ckpt.pt.tmp").exists()
pipeline/tests/test_dataset.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CaptionDataset.
2
+
3
+ Runs against the dev subsample + built vocab; skips when either is absent.
4
+ """
5
+
6
+ from collections import Counter
7
+ import pytest
8
+ import torch
9
+ from torch.utils.data import DataLoader
10
+
11
+ from capit.config import config
12
+ from capit.data.dataset import CaptionDataset, build_transform, collate_fn
13
+ from capit.data.vocab import END, PAD, START, Vocab
14
+
15
+ pytestmark = pytest.mark.skipif(
16
+ not (config.subsample_json.is_file() and config.vocab_path.is_file()),
17
+ reason="dev subsample or vocab not built",
18
+ )
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def vocab() -> Vocab:
23
+ return Vocab.load(config.vocab_path)
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def train_ds(vocab: Vocab) -> CaptionDataset:
28
+ return CaptionDataset(config.subsample_root, "train", vocab, build_transform())
29
+
30
+
31
+ def test_len_is_five_per_image(train_ds: CaptionDataset):
32
+ n_images = len({r["filename"] for r, _ in train_ds.pairs})
33
+ assert len(train_ds) == 5 * n_images
34
+ assert len(train_ds) == 5 * config.subsample_train
35
+
36
+
37
+ def test_each_image_has_exactly_five_pairs(train_ds: CaptionDataset):
38
+ per_image = Counter(r["filename"] for r, _ in train_ds.pairs)
39
+ assert set(per_image.values()) == {5}
40
+
41
+
42
+ def test_item_shapes_and_dtypes(train_ds: CaptionDataset):
43
+ img, ids, clen = train_ds[0]
44
+ assert img.shape == (3, config.crop, config.crop)
45
+ assert img.dtype == torch.float32
46
+ assert ids.shape == (train_ds.max_len,)
47
+ assert ids.dtype == torch.long
48
+ assert isinstance(clen, int)
49
+
50
+
51
+ def test_start_end_and_padding(train_ds: CaptionDataset, vocab: Vocab):
52
+ _, ids, clen = train_ds[0]
53
+ assert ids[0].item() == vocab.word2id[START]
54
+ assert ids[clen - 1].item() == vocab.word2id[END]
55
+ assert all(ids[j].item() == vocab.word2id[PAD] for j in range(clen, train_ds.max_len))
56
+
57
+
58
+ def test_references_five_per_image(vocab: Vocab):
59
+ ds = CaptionDataset(config.subsample_root, "test", vocab, build_transform())
60
+ refs = ds.references()
61
+ assert len(refs) == config.subsample_test
62
+ assert all(len(r) == 5 for r in refs.values())
63
+
64
+
65
+ def test_references_are_raw_str_tokens(vocab: Vocab):
66
+ ds = CaptionDataset(config.subsample_root, "test", vocab, build_transform())
67
+ refs = ds.references()
68
+ assert all(isinstance(tok, str) for caps in refs.values() for caption in caps for tok in caption)
69
+
70
+
71
+ def test_iter_images_once_per_image(vocab: Vocab):
72
+ ds = CaptionDataset(config.subsample_root, "test", vocab, build_transform())
73
+ ids = [iid for iid, _ in ds.iter_images()]
74
+ assert len(ids) == config.subsample_test
75
+ assert len(set(ids)) == config.subsample_test
76
+
77
+
78
+ def test_image_id_consistent_across_eval_views(vocab: Vocab):
79
+ ds = CaptionDataset(config.subsample_root, "test", vocab, build_transform())
80
+ ref_ids = set(ds.references().keys())
81
+ img_ids = {iid for iid, _ in ds.iter_images()}
82
+ assert img_ids == ref_ids == set(range(config.subsample_test))
83
+
84
+
85
+ def test_unknown_split_raises(vocab: Vocab):
86
+ with pytest.raises(ValueError):
87
+ CaptionDataset(config.subsample_root, "nope", vocab, build_transform())
88
+
89
+
90
+ def test_collate_sorts_descending_with_alignment(train_ds: CaptionDataset, vocab: Vocab):
91
+ items = sorted((train_ds[i] for i in range(8)), key=lambda x: x[2]) # ascending → force re-sort
92
+ images, captions, lengths = collate_fn(items)
93
+ lens = lengths.tolist()
94
+ assert images.shape == (8, 3, config.crop, config.crop)
95
+ assert captions.shape == (8, train_ds.max_len)
96
+ assert lens == sorted(lens, reverse=True)
97
+ assert all(n <= train_ds.max_len for n in lens)
98
+ pad = vocab.word2id[PAD]
99
+ for row, n in enumerate(lens):
100
+ assert int((captions[row] != pad).sum()) == n
101
+
102
+
103
+ def test_determinism_seeded(train_ds: CaptionDataset):
104
+ def first_batch() -> torch.Tensor:
105
+ g = torch.Generator().manual_seed(config.seed)
106
+ loader = DataLoader(train_ds, batch_size=4, shuffle=True, generator=g, collate_fn=collate_fn)
107
+ return next(iter(loader))[1]
108
+
109
+ assert torch.equal(first_batch(), first_batch())
pipeline/tests/test_decode.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4.1 — greedy + beam decoding. Hermetic: random decoder + random features."""
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from capit.config import config
7
+ from capit.decode import beam_search, greedy
8
+ from capit.models.decoder import Decoder
9
+
10
+ V = 30
11
+ L = config.encoded_size**2
12
+ START, END = 1, 2
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _seed():
17
+ torch.manual_seed(config.seed)
18
+
19
+
20
+ def _features() -> torch.Tensor:
21
+ return torch.randn(1, L, config.encoder_dim)
22
+
23
+
24
+ def test_beam_k1_equals_greedy():
25
+ dec = Decoder(vocab_size=V)
26
+ feats = _features()
27
+ g_ids, _ = greedy(dec, feats, START, END)
28
+ b_ids, _, _ = beam_search(dec, feats, START, END, k=1)
29
+ assert b_ids == g_ids
30
+
31
+
32
+ def test_greedy_alphas_align_with_ids():
33
+ dec = Decoder(vocab_size=V)
34
+ ids, alphas = greedy(dec, _features(), START, END)
35
+ assert alphas.shape == (len(ids), L)
36
+ assert not torch.isnan(alphas).any()
37
+
38
+
39
+ def test_beam_k3_caption_and_road_not_taken():
40
+ dec = Decoder(vocab_size=V)
41
+ ids, alphas, beams = beam_search(dec, _features(), START, END, k=3)
42
+ assert alphas.shape == (len(ids), L)
43
+ assert beams[0][0] == ids # winner first
44
+ assert all(isinstance(s, float) for _, s in beams)
45
+ assert END not in ids # specials stripped
pipeline/tests/test_decoder.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2.3 — Decoder. Hermetic tests on random features + synthetic captions."""
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from capit.config import config
7
+ from capit.losses import caption_loss
8
+ from capit.models.decoder import Decoder
9
+
10
+ V = 12
11
+ B = 3
12
+ L = config.encoded_size**2
13
+
14
+ # captions sorted by length descending; PAD=0 START=1 END=2
15
+ CAPTIONS = torch.tensor(
16
+ [
17
+ [1, 5, 6, 7, 8, 2],
18
+ [1, 5, 6, 2, 0, 0],
19
+ [1, 7, 2, 0, 0, 0],
20
+ ]
21
+ )
22
+ LENGTHS = torch.tensor([6, 4, 3])
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def _seed():
27
+ torch.manual_seed(config.seed)
28
+
29
+
30
+ def _features() -> torch.Tensor:
31
+ return torch.randn(B, L, config.encoder_dim)
32
+
33
+
34
+ def test_forward_shapes():
35
+ logits, alphas, decode_lengths = Decoder(vocab_size=V)(_features(), CAPTIONS, LENGTHS)
36
+ assert decode_lengths == [5, 3, 2]
37
+ assert logits.shape == (B, 5, V)
38
+ assert alphas.shape == (B, 5, L)
39
+
40
+
41
+ def test_no_nan():
42
+ logits, alphas, _ = Decoder(vocab_size=V)(_features(), CAPTIONS, LENGTHS)
43
+ assert not torch.isnan(logits).any()
44
+ assert not torch.isnan(alphas).any()
45
+
46
+
47
+ def test_shrinking_leaves_finished_rows_zero():
48
+ logits, alphas, decode_lengths = Decoder(vocab_size=V)(_features(), CAPTIONS, LENGTHS)
49
+ for i, dl in enumerate(decode_lengths):
50
+ assert torch.all(logits[i, dl:] == 0)
51
+ assert torch.all(alphas[i, dl:] == 0)
52
+
53
+
54
+ def test_padded_positions_contribute_zero_loss():
55
+ logits, alphas, decode_lengths = Decoder(vocab_size=V)(_features(), CAPTIONS, LENGTHS)
56
+ base = caption_loss(logits, alphas, CAPTIONS)
57
+ corrupted = logits.clone()
58
+ for i, dl in enumerate(decode_lengths):
59
+ corrupted[i, dl:] = 999.0
60
+ assert torch.isclose(base, caption_loss(corrupted, alphas, CAPTIONS))
61
+
62
+
63
+ def test_forward_rejects_unsorted_batch():
64
+ captions = torch.tensor([[1, 7, 2, 0, 0, 0], [1, 5, 6, 7, 8, 2]]) # ascending lengths
65
+ lengths = torch.tensor([3, 6])
66
+ with pytest.raises(ValueError):
67
+ Decoder(vocab_size=V)(torch.randn(2, L, config.encoder_dim), captions, lengths)
68
+
69
+
70
+ def test_gradient_reaches_decoder_params():
71
+ dec = Decoder(vocab_size=V)
72
+ logits, alphas, _ = dec(_features(), CAPTIONS, LENGTHS)
73
+ caption_loss(logits, alphas, CAPTIONS).backward()
74
+ for p in (dec.embedding.weight, dec.fc.weight, dec.f_beta.weight):
75
+ assert p.grad is not None and p.grad.abs().sum() > 0
76
+
77
+
78
+ def test_greedy_returns_tokens_and_restores_mode():
79
+ dec = Decoder(vocab_size=V)
80
+ dec.train()
81
+ out = dec.greedy(_features(), start_id=1, end_id=2, max_len=10)
82
+ assert len(out) == B
83
+ assert all(isinstance(seq, list) and 2 not in seq for seq in out)
84
+ assert dec.training
pipeline/tests/test_download.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 0.2 exit gate — dataset integrity.
2
+
3
+ The whole module is skipped when the data isn't on disk, so CI never needs the dataset.
4
+ When present, it verifies the counts the rest of the pipeline relies on.
5
+ """
6
+
7
+ import json
8
+ from collections import Counter
9
+ import pytest
10
+ from PIL import Image
11
+ from capit.config import config
12
+
13
+ EXPECTED_JPGS = 8091
14
+ EXPECTED_IMAGE_RECORDS = 8000
15
+ EXPECTED_CAPTIONS_PER_IMAGE = 5
16
+ EXPECTED_SPLITS = {"train": 6000, "val": 1000, "test": 1000}
17
+
18
+
19
+ def _data_present() -> bool:
20
+ return config.images_dir.is_dir() and config.karpathy_json.is_file()
21
+
22
+
23
+ pytestmark = pytest.mark.skipif(
24
+ not _data_present(),
25
+ reason="Flickr8k data not on disk (run: python -m capit.data.download)",
26
+ )
27
+
28
+
29
+ @pytest.fixture(scope="module")
30
+ def karpathy() -> dict:
31
+ with open(config.karpathy_json) as f:
32
+ return json.load(f)
33
+
34
+
35
+ def test_dataset_integrity(karpathy: dict) -> None:
36
+ # 8,091 images on disk (Kaggle mirror).
37
+ jpgs = list(config.images_dir.glob("*.jpg"))
38
+ assert len(jpgs) == EXPECTED_JPGS, f"expected {EXPECTED_JPGS} jpgs, found {len(jpgs)}"
39
+
40
+ # The JSON is the source of truth: 8,000 records, exactly 5 captions each.
41
+ images = karpathy["images"]
42
+ assert len(images) == EXPECTED_IMAGE_RECORDS
43
+ assert all(len(img["sentences"]) == EXPECTED_CAPTIONS_PER_IMAGE for img in images)
44
+
45
+ # Canonical Karpathy split (flickr8k has only train/val/test — no restval).
46
+ splits = dict(Counter(img["split"] for img in images))
47
+ assert splits == EXPECTED_SPLITS, f"split tally {splits} != {EXPECTED_SPLITS}"
48
+
49
+ # Every referenced image exists on disk; the ~91 unreferenced Kaggle images may not.
50
+ disk = {p.name for p in jpgs}
51
+ missing = [img["filename"] for img in images if img["filename"] not in disk]
52
+ assert not missing, f"{len(missing)} referenced images absent, e.g. {missing[:3]}"
53
+
54
+
55
+ def test_spot_check_images_open(karpathy: dict) -> None:
56
+ # 3 images open with PIL and their captions read sensibly.
57
+ for img in karpathy["images"][:3]:
58
+ path = config.images_dir / img["filename"]
59
+ with Image.open(path) as im:
60
+ im.load()
61
+ caption = " ".join(img["sentences"][0]["tokens"])
62
+ assert len(caption.split()) >= 3, f"caption too short: {caption!r}"
pipeline/tests/test_encoder.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encoder.
2
+
3
+ Structural tests run offline (random weights). The non-degenerate-features test needs
4
+ ImageNet weights + a real subsample image and skips when either is absent.
5
+ """
6
+
7
+ import pytest
8
+ import torch
9
+ from capit.config import config
10
+ from capit.models.encoder import Encoder
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def encoder() -> Encoder:
15
+ return Encoder(pretrained=False)
16
+
17
+
18
+ def test_output_shape(encoder: Encoder):
19
+ feats = encoder(torch.randn(2, 3, config.crop, config.crop))
20
+ assert feats.shape == (2, config.encoded_size**2, config.encoder_dim)
21
+
22
+
23
+ def test_all_params_frozen(encoder: Encoder):
24
+ total = sum(p.numel() for p in encoder.parameters())
25
+ frozen = sum(p.numel() for p in encoder.parameters() if not p.requires_grad)
26
+ assert total > 0
27
+ assert frozen == total
28
+
29
+
30
+ def test_no_nan_on_random_input(encoder: Encoder):
31
+ feats = encoder(torch.randn(2, 3, config.crop, config.crop))
32
+ assert not torch.isnan(feats).any()
33
+
34
+
35
+ def test_eval_mode_determinism(encoder: Encoder):
36
+ encoder.eval()
37
+ x = torch.randn(1, 3, config.crop, config.crop)
38
+ with torch.no_grad():
39
+ assert torch.equal(encoder(x), encoder(x))
40
+
41
+
42
+ def test_eval_uses_running_stats_not_batch(encoder: Encoder):
43
+ encoder.eval()
44
+ x = torch.randn(1, 3, config.crop, config.crop)
45
+ others = torch.randn(3, 3, config.crop, config.crop)
46
+ with torch.no_grad():
47
+ alone = encoder(x)
48
+ batched = encoder(torch.cat([x, others]))[:1]
49
+ assert torch.allclose(alone, batched, atol=1e-5)
50
+
51
+
52
+ def test_train_keeps_backbone_in_eval(encoder: Encoder):
53
+ encoder.train()
54
+ assert not encoder.backbone.training
55
+ encoder.eval()
56
+
57
+
58
+ def test_fine_tune_default_keeps_all_frozen():
59
+ enc = Encoder(pretrained=False)
60
+ enc.fine_tune()
61
+ assert all(not p.requires_grad for p in enc.parameters())
62
+
63
+
64
+ def test_fine_tune_unfreezes_exactly_that_block():
65
+ enc = Encoder(pretrained=False)
66
+ enc.fine_tune((7,)) # index 7 = layer4
67
+ trainable = sum(p.numel() for p in enc.parameters() if p.requires_grad)
68
+ layer4 = sum(p.numel() for p in enc.backbone[7].parameters())
69
+ assert trainable == layer4 > 0
70
+
71
+
72
+ @pytest.mark.skipif(
73
+ not (config.subsample_json.is_file() and config.vocab_path.is_file()),
74
+ reason="dev subsample or vocab not built",
75
+ )
76
+ def test_real_image_features_non_degenerate():
77
+ from capit.data.dataset import CaptionDataset, build_transform
78
+ from capit.data.vocab import Vocab
79
+
80
+ vocab = Vocab.load(config.vocab_path)
81
+ ds = CaptionDataset(config.subsample_root, "test", vocab, build_transform())
82
+ _, image = next(ds.iter_images())
83
+ encoder = Encoder(pretrained=True)
84
+ with torch.no_grad():
85
+ feats = encoder(image.unsqueeze(0))
86
+ assert not torch.isnan(feats).any()
87
+ assert feats.std().item() > 0
pipeline/tests/test_evaluate.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4.2 — official BLEU/CIDEr harness. Hermetic scorer sanity + opt-in eval-on-test gate."""
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from capit.config import config
8
+ from capit.evaluate import evaluate, score
9
+
10
+
11
+ def test_score_perfect_match():
12
+ refs = {
13
+ 0: [["a", "brown", "dog", "runs", "fast"], ["the", "dog", "runs", "very", "fast"]],
14
+ 1: [["a", "small", "cat", "sleeps", "quietly"]],
15
+ }
16
+ cands = {0: ["a", "brown", "dog", "runs", "fast"], 1: ["a", "small", "cat", "sleeps", "quietly"]}
17
+ s = score(refs, cands)
18
+ assert s["BLEU-4"] == pytest.approx(100.0, abs=1e-6)
19
+ assert s["BLEU-1"] == pytest.approx(100.0, abs=1e-6)
20
+
21
+
22
+ def test_score_disjoint():
23
+ refs = {0: [["a", "brown", "dog", "runs", "fast"]]}
24
+ cands = {0: ["xyz", "qpr", "lmn", "uvw", "abc"]}
25
+ s = score(refs, cands)
26
+ assert s["BLEU-1"] == pytest.approx(0.0, abs=1e-6)
27
+ assert s["BLEU-4"] == pytest.approx(0.0, abs=1e-6)
28
+
29
+
30
+ @pytest.mark.skipif(
31
+ os.environ.get("RUN_EVAL") != "1" or not (config.ckpt_dir / "best.pt").exists(),
32
+ reason="set RUN_EVAL=1 and provide data/checkpoints/best.pt to run the test-set gate",
33
+ )
34
+ def test_eval_on_test_meets_gate():
35
+ # pycocoevalcap corpus BLEU (unsmoothed) != train.corpus_bleu4 (NLTK smoothed); not directly comparable to best_bleu4.
36
+ results = evaluate(config.flickr8k_dir, config.ckpt_dir / "best.pt", beams=(3,))
37
+ assert results[3]["BLEU-4"] >= 16.0
pipeline/tests/test_losses.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2.3 — caption loss. Hand-built logits pin the shift, padding, and regularizer."""
2
+
3
+ import torch
4
+
5
+ from capit.config import config
6
+ from capit.losses import caption_loss, word_cross_entropy
7
+
8
+ V = 12
9
+
10
+
11
+ def _peaked(rows: list[list[int]]) -> torch.Tensor:
12
+ logits = torch.full((1, len(rows[0]), V), -10.0)
13
+ for t, tok in enumerate(rows[0]):
14
+ logits[0, t, tok] = 10.0
15
+ return logits
16
+
17
+
18
+ def test_cross_entropy_targets_are_shifted_by_one():
19
+ captions = torch.tensor([[1, 5, 7, 2]]) # T=3, shifted targets = [5, 7, 2]
20
+ assert word_cross_entropy(_peaked([[5, 7, 2]]), captions).item() < 0.01
21
+ # predicting the inputs (captions[:, :-1]) instead of the shifted targets must score badly
22
+ assert word_cross_entropy(_peaked([[1, 5, 7]]), captions).item() > 1.0
23
+
24
+
25
+ def test_pad_targets_are_ignored():
26
+ captions = torch.tensor([[1, 5, 2, 0]]) # T=3, targets = [5, 2, 0]; position 2 is PAD
27
+ logits = _peaked([[5, 2, 0]])
28
+ base = word_cross_entropy(logits, captions)
29
+ corrupted = logits.clone()
30
+ corrupted[0, 2] = torch.randn(V) * 50 # corrupt the PAD-target position
31
+ assert torch.isclose(base, word_cross_entropy(corrupted, captions))
32
+
33
+
34
+ def test_regularizer_is_alpha_c_on_zero_attention():
35
+ captions = torch.tensor([[1, 5, 2, 0], [1, 6, 2, 0]])
36
+ logits = torch.zeros(2, 3, V)
37
+ alphas = torch.zeros(2, 3, 196) # time-sum 0 per location → (1-0)^2 = 1
38
+ reg = caption_loss(logits, alphas, captions) - word_cross_entropy(logits, captions)
39
+ assert torch.isclose(reg, torch.tensor(config.alpha_c))
pipeline/tests/test_overfit.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 2.3 exit gate — overfit one batch (killer gate #1).
2
+
3
+ Slow (~3 min): opt-in via RUN_OVERFIT=1, and needs the subsample + vocab. Otherwise the
4
+ gate lives in scripts/overfit_one_batch.py (run + eyeball the decoded captions).
5
+ """
6
+
7
+ import os
8
+
9
+ import pytest
10
+
11
+ from capit.config import config
12
+ from capit.overfit import run_overfit
13
+
14
+ pytestmark = pytest.mark.skipif(
15
+ os.environ.get("RUN_OVERFIT") != "1"
16
+ or not (config.subsample_json.is_file() and config.vocab_path.is_file()),
17
+ reason="slow gate — set RUN_OVERFIT=1 (needs subsample + vocab)",
18
+ )
19
+
20
+
21
+ def test_overfit_one_batch():
22
+ result = run_overfit(steps=200)
23
+ assert result["final_ce"] < 0.5, f"ce {result['final_ce']:.3f}: model failed to memorize"
24
+ matches = sum(d == t for d, t in zip(result["decoded"], result["targets"]))
25
+ assert matches == len(result["targets"]), f"{matches}/{len(result['targets'])} captions reproduced"
pipeline/tests/test_serving.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage 4.4 — serving artifact round-trip. Hermetic: random encoder/decoder, no checkpoint/network."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+ import torch
7
+ from PIL import Image
8
+
9
+ from capit.data.vocab import SPECIALS, Vocab
10
+ from capit.models.decoder import Decoder
11
+ from capit.models.encoder import Encoder
12
+ from capit.serving import build_artifact, caption, load_artifact, make_transform
13
+
14
+
15
+ @pytest.fixture
16
+ def artifact(tmp_path):
17
+ torch.manual_seed(0)
18
+ vocab = Vocab(SPECIALS + ["a", "dog", "runs", "fast"])
19
+ vocab_path = tmp_path / "vocab.json"
20
+ vocab.save(vocab_path)
21
+ encoder = Encoder(pretrained=False).eval()
22
+ decoder = Decoder(vocab_size=len(vocab))
23
+ blob = build_artifact(encoder, decoder, vocab)
24
+ path = tmp_path / "capit-sat.pt"
25
+ torch.save(blob, path)
26
+ return path, vocab_path, encoder, decoder, vocab
27
+
28
+
29
+ def test_round_trip_reproduces_caption(artifact):
30
+ path, vocab_path, encoder, decoder, vocab = artifact
31
+ enc2, dec2, vocab2, preprocess = load_artifact(path, vocab_path)
32
+ assert vocab2.id2word == vocab.id2word
33
+ assert {"resize", "crop", "mean", "std"} <= preprocess.keys()
34
+ torch.manual_seed(1)
35
+ image = torch.rand(3, preprocess["crop"], preprocess["crop"])
36
+ words, alphas, _ = caption(enc2, dec2, vocab2, image)
37
+ ref_words, ref_alphas, _ = caption(encoder, decoder, vocab, image)
38
+ assert words == ref_words
39
+ assert torch.equal(alphas, ref_alphas)
40
+
41
+
42
+ def test_vocab_guard_raises_on_mismatch(artifact, tmp_path):
43
+ path, vocab_path, *_ = artifact
44
+ wrong = tmp_path / "wrong.json"
45
+ wrong.write_text(json.dumps(SPECIALS + ["different", "words", "entirely"]))
46
+ with pytest.raises(ValueError, match="vocab mismatch"):
47
+ load_artifact(path, wrong)
48
+
49
+
50
+ def test_make_transform_shapes_image():
51
+ t = make_transform({"resize": 256, "crop": 224, "mean": [0.5, 0.5, 0.5], "std": [0.5, 0.5, 0.5]})
52
+ out = t(Image.new("RGB", (400, 300)))
53
+ assert out.shape == (3, 224, 224)
pipeline/tests/test_subsample.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dev subsample.
2
+
3
+ Pure selection tests always run; structural checks on the on-disk artifact skip when
4
+ it hasn't been generated.
5
+ """
6
+
7
+ import json
8
+ from collections import Counter
9
+
10
+ import pytest
11
+
12
+ from capit.config import config
13
+ from capit.data.records import select_records
14
+
15
+
16
+ def _records(split: str, n: int) -> list[dict]:
17
+ return [{"filename": f"{split}_{i:03d}.jpg", "split": split, "imgid": i} for i in range(n)]
18
+
19
+
20
+ SYNTH = _records("train", 20) + _records("val", 8) + _records("test", 8)
21
+ COUNTS = {"train": 5, "val": 3, "test": 2}
22
+
23
+
24
+ def test_select_counts_and_splits():
25
+ sel = select_records(SYNTH, COUNTS, seed=13)
26
+ assert len(sel) == 10
27
+ assert Counter(r["split"] for r in sel) == {"train": 5, "val": 3, "test": 2}
28
+
29
+
30
+ def test_select_is_deterministic():
31
+ assert select_records(SYNTH, COUNTS, 13) == select_records(SYNTH, COUNTS, 13)
32
+ assert select_records(SYNTH, COUNTS, 13) != select_records(SYNTH, COUNTS, 14)
33
+
34
+
35
+ def test_select_records_are_verbatim():
36
+ sel = select_records(SYNTH, COUNTS, 13)
37
+ assert all(r in SYNTH for r in sel)
38
+
39
+
40
+ def test_select_raises_when_pool_too_small():
41
+ with pytest.raises(ValueError):
42
+ select_records(SYNTH, {"train": 999}, 13)
43
+
44
+
45
+ def _artifact_present() -> bool:
46
+ return config.subsample_json.is_file() and config.subsample_images_dir.is_dir()
47
+
48
+
49
+ pytestmark_structural = pytest.mark.skipif(
50
+ not _artifact_present(),
51
+ reason="dev subsample not generated (run: python pipeline/scripts/make_subsample.py)",
52
+ )
53
+
54
+
55
+ @pytestmark_structural
56
+ def test_artifact_structure():
57
+ sub = json.loads(config.subsample_json.read_text())
58
+ images = sub["images"]
59
+ assert len(images) == sum(config.subsample_counts.values())
60
+ assert Counter(r["split"] for r in images) == config.subsample_counts
61
+
62
+ jpgs = {p.name for p in config.subsample_images_dir.glob("*.jpg")}
63
+ assert len(jpgs) == len(images)
64
+ assert all(r["filename"] in jpgs for r in images)
65
+
66
+ assert sub["dataset"] == json.loads(config.karpathy_json.read_text())["dataset"]
67
+
68
+
69
+ @pytestmark_structural
70
+ def test_artifact_records_are_verbatim():
71
+ sub = json.loads(config.subsample_json.read_text())
72
+ full = {r["filename"]: r for r in json.loads(config.karpathy_json.read_text())["images"]}
73
+ assert all(r == full[r["filename"]] for r in sub["images"])