#!/usr/bin/env python """Stage 05 — predict on the test set, from cached embeddings. The test set was already fully embedded by stage 02 (test_text.npy, test_image.npy) — re-running EmbeddingGemma/SigLIP2 here would repeat that expensive GPU work for identical output. This script loads the cached embeddings and runs only the small trained model (projections + fusion + head), the same way stages 03/04 do. That's why this is fast: no encoder loading, no HTTP calls to Hugging Face, no per-row image decoding. The live encoder chain (src/inference/predictor.py) still exists and is still correct — but only for genuinely new, never-cached inputs, e.g. a live API request for a brand-new product. See api/main.py / frontend/app.py for that path. Don't use Predictor for a batch that was already embedded. Usage: python scripts/05_predict.py --config configs/base.yaml """ import argparse import sys from pathlib import Path import pandas as pd import torch from torch.utils.data import DataLoader sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src.data.dataset import EmbeddingDataset from src.models.price_model import PriceModel from src.utils.config import load_config from src.utils.exceptions import CheckpointError, PricePredictorError from src.utils.logging import get_logger logger = get_logger(__name__) def run(config_path: str, output_path: str) -> None: config = load_config(config_path) embeddings_dir = Path(config["data"]["embeddings_dir"]) text_path = embeddings_dir / "test_text.npy" image_path = embeddings_dir / "test_image.npy" ids_path = embeddings_dir / "test_ids.csv" for p in (text_path, image_path, ids_path): if not p.exists(): raise PricePredictorError( f"{p} not found — run scripts/02_extract_embeddings.py first. " "This script predicts from cached test embeddings; it does not " "re-run the encoders." ) dataset = EmbeddingDataset(str(text_path), str(image_path)) ids_df = pd.read_csv(ids_path) if len(ids_df) != len(dataset): raise PricePredictorError( f"test_ids.csv has {len(ids_df)} rows but the embeddings have {len(dataset)} — " "these came from different runs. Re-run scripts/02_extract_embeddings.py " "so both are regenerated together." ) loader = DataLoader(dataset, batch_size=256, shuffle=False) model = PriceModel.from_config(config) checkpoint_path = Path(config["checkpoint_dir"]) / "best.pt" if not checkpoint_path.exists(): raise CheckpointError(f"No checkpoint found at {checkpoint_path} — run scripts/03_train.py first") checkpoint = torch.load(checkpoint_path, map_location="cpu") try: model.load_state_dict(checkpoint["model_state_dict"]) except RuntimeError as e: raise CheckpointError( f"Checkpoint at {checkpoint_path} does not match the current model config " f"(e.g. a fusion.type or head.hidden_dims mismatch): {e}" ) from e device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device) model.eval() logger.info("Predicting %d rows from cached embeddings on %s (no encoder loading needed)", len(dataset), device) all_prices = [] n_done = 0 with torch.no_grad(): for text_emb, image_emb in loader: text_emb, image_emb = text_emb.to(device), image_emb.to(device) raw_price = model(text_emb, image_emb) price = torch.clamp(raw_price, min=0.01) all_prices.extend(price.cpu().tolist()) n_done += text_emb.shape[0] logger.info("Predicted %d/%d rows", n_done, len(dataset)) out_df = pd.DataFrame({ "sample_id": ids_df["sample_id"], "price": [round(float(p), 2) for p in all_prices], }) out_df.to_csv(output_path, index=False) logger.info("Wrote predictions to %s (%d rows)", output_path, len(out_df)) def main() -> None: parser = argparse.ArgumentParser(description="Stage 05: predict on the test set from cached embeddings") parser.add_argument("--config", default="configs/base.yaml") parser.add_argument("--output", default="data/raw/test_out.csv") args = parser.parse_args() try: run(args.config, args.output) except PricePredictorError as e: logger.error("Prediction failed: %s", e) sys.exit(1) except Exception as e: logger.exception("Unexpected error during prediction: %s", e) sys.exit(1) if __name__ == "__main__": main()