Spaces:
Sleeping
Sleeping
capit-deploy commited on
Commit ·
a5ec84d
0
Parent(s):
deploy capit backend
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- Dockerfile +37 -0
- README.md +16 -0
- backend/Dockerfile +37 -0
- backend/app.py +127 -0
- backend/pyproject.toml +29 -0
- backend/scripts/bench_blip.py +119 -0
- backend/tests/test_app.py +78 -0
- backend/uv.lock +0 -0
- pipeline/.python-version +1 -0
- pipeline/__init__.py +0 -0
- pipeline/notebooks/colab_train.ipynb +264 -0
- pipeline/pyproject.toml +36 -0
- pipeline/scripts/build_vocab.py +21 -0
- pipeline/scripts/export_artifact.py +132 -0
- pipeline/scripts/make_subsample.py +39 -0
- pipeline/scripts/make_train_zip.py +35 -0
- pipeline/scripts/overfit_one_batch.py +20 -0
- pipeline/scripts/serve_demo.py +34 -0
- pipeline/scripts/visualize_attention.py +115 -0
- pipeline/src/capit/__init__.py +0 -0
- pipeline/src/capit/checkpoint.py +58 -0
- pipeline/src/capit/config.py +92 -0
- pipeline/src/capit/data/__init__.py +0 -0
- pipeline/src/capit/data/dataset.py +78 -0
- pipeline/src/capit/data/download.py +105 -0
- pipeline/src/capit/data/records.py +29 -0
- pipeline/src/capit/data/vocab.py +57 -0
- pipeline/src/capit/decode.py +80 -0
- pipeline/src/capit/evaluate.py +89 -0
- pipeline/src/capit/losses.py +23 -0
- pipeline/src/capit/models/__init__.py +0 -0
- pipeline/src/capit/models/attention.py +29 -0
- pipeline/src/capit/models/decoder.py +93 -0
- pipeline/src/capit/models/encoder.py +34 -0
- pipeline/src/capit/overfit.py +65 -0
- pipeline/src/capit/serving.py +97 -0
- pipeline/src/capit/train.py +146 -0
- pipeline/tests/test_attention.py +68 -0
- pipeline/tests/test_checkpoint.py +63 -0
- pipeline/tests/test_dataset.py +109 -0
- pipeline/tests/test_decode.py +45 -0
- pipeline/tests/test_decoder.py +84 -0
- pipeline/tests/test_download.py +62 -0
- pipeline/tests/test_encoder.py +87 -0
- pipeline/tests/test_evaluate.py +37 -0
- pipeline/tests/test_losses.py +39 -0
- pipeline/tests/test_overfit.py +25 -0
- pipeline/tests/test_serving.py +53 -0
- 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"])
|