Spaces:
No application file
No application file
| #!/usr/bin/env python | |
| """Stage 02 — extract embeddings. | |
| The one GPU-heavy stage: runs the frozen EmbeddingGemma + SigLIP2 encoders | |
| over every row and caches the results as .npy files, so training (stage 03) | |
| never has to re-run the encoders. | |
| Order of operations (deliberate, not incidental): | |
| 1. Load the text encoder, extract text embeddings for train AND test. | |
| 2. Free the text encoder from RAM/VRAM. | |
| 3. Load the image encoder, extract image embeddings for train AND test. | |
| 4. Free the image encoder. | |
| This keeps peak memory to "one encoder at a time" instead of both loaded | |
| simultaneously, and means a crash during image extraction doesn't force | |
| re-running text extraction too. | |
| Every batch is checkpointed to disk (see src/encoders/batch_extract.py) — | |
| if this script crashes or the session times out, re-running the exact same | |
| command resumes from the last checkpoint instead of starting over. | |
| Usage: | |
| python scripts/02_extract_embeddings.py --config configs/base.yaml | |
| """ | |
| import argparse | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| from PIL import Image | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from src.encoders.batch_extract import free_encoder, resumable_batch_encode | |
| from src.encoders.registry import build_image_encoder, build_text_encoder | |
| from src.utils.config import load_config | |
| from src.utils.exceptions import PricePredictorError | |
| from src.utils.logging import get_logger | |
| from src.utils.seed import set_seed | |
| logger = get_logger(__name__) | |
| SPLITS = ("train", "test") | |
| def _load_split_df(processed_dir: Path, split: str) -> pd.DataFrame: | |
| parquet_path = processed_dir / f"{split}_clean.parquet" | |
| if not parquet_path.exists(): | |
| raise PricePredictorError(f"{parquet_path} not found — run scripts/01_preprocess.py first") | |
| return pd.read_parquet(parquet_path) | |
| def run(config_path: str) -> None: | |
| config = load_config(config_path) | |
| set_seed(config["seed"]) | |
| data_cfg = config["data"] | |
| processed_dir = Path(data_cfg["processed_dir"]) | |
| embeddings_dir = Path(data_cfg["embeddings_dir"]) | |
| embeddings_dir.mkdir(parents=True, exist_ok=True) | |
| dfs = {split: _load_split_df(processed_dir, split) for split in SPLITS} | |
| for split, df in dfs.items(): | |
| logger.info("Loaded split=%s: %d rows", split, len(df)) | |
| df[["sample_id"]].to_csv(embeddings_dir / f"{split}_ids.csv", index=False) | |
| if split == "train": | |
| np.save(embeddings_dir / "train_price.npy", df["price"].to_numpy(dtype=np.float32)) | |
| # ---- Phase 1: text embeddings (train + test), then free the encoder ---- | |
| logger.info("=== Phase 1/2: text embeddings ===") | |
| text_cfg = config["encoders"]["text"] | |
| text_encoder = build_text_encoder(text_cfg) | |
| text_output_dim = text_cfg.get("mrl_truncate") or text_cfg["output_dim"] | |
| for split, df in dfs.items(): | |
| texts = df["catalog_content"].tolist() | |
| resumable_batch_encode( | |
| items=texts, | |
| encode_batch_fn=lambda batch: text_encoder.encode(batch).numpy(), | |
| output_dim=text_output_dim, | |
| output_path=str(embeddings_dir / f"{split}_text.npy"), | |
| batch_size=text_cfg["batch_size"], | |
| desc=f"{split} text", | |
| ) | |
| free_encoder(text_encoder) | |
| logger.info("=== Phase 1/2 complete, text encoder freed from memory ===") | |
| # ---- Phase 2: image embeddings (train + test), then free the encoder ---- | |
| logger.info("=== Phase 2/2: image embeddings ===") | |
| image_cfg = config["encoders"]["image"] | |
| image_encoder = build_image_encoder(image_cfg) | |
| image_output_dim = image_cfg["output_dim"] | |
| def _encode_image_batch(paths): | |
| images = [Image.open(p).convert("RGB") for p in paths] | |
| return image_encoder.encode(images).numpy() | |
| for split, df in dfs.items(): | |
| image_paths = df["image_path"].tolist() | |
| resumable_batch_encode( | |
| items=image_paths, | |
| encode_batch_fn=_encode_image_batch, | |
| output_dim=image_output_dim, | |
| output_path=str(embeddings_dir / f"{split}_image.npy"), | |
| batch_size=image_cfg["batch_size"], | |
| desc=f"{split} image", | |
| ) | |
| free_encoder(image_encoder) | |
| logger.info("=== Phase 2/2 complete, image encoder freed from memory ===") | |
| # ---- Final sanity check: every split's text/image/row counts line up ---- | |
| for split, df in dfs.items(): | |
| text_matrix = np.load(embeddings_dir / f"{split}_text.npy") | |
| image_matrix = np.load(embeddings_dir / f"{split}_image.npy") | |
| if text_matrix.shape[0] != len(df) or image_matrix.shape[0] != len(df): | |
| raise PricePredictorError( | |
| f"Embedding count mismatch for split={split}: " | |
| f"text={text_matrix.shape[0]} image={image_matrix.shape[0]} rows={len(df)}" | |
| ) | |
| logger.info( | |
| "Verified split=%s: text %s, image %s", split, text_matrix.shape, image_matrix.shape, | |
| ) | |
| logger.info("All embeddings extracted and verified.") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Stage 02: extract and cache embeddings") | |
| parser.add_argument("--config", default="configs/base.yaml") | |
| args = parser.parse_args() | |
| try: | |
| run(args.config) | |
| except PricePredictorError as e: | |
| logger.error("Embedding extraction failed: %s", e) | |
| logger.error("Progress up to the last checkpoint was saved — re-run this exact command to resume.") | |
| sys.exit(1) | |
| except Exception as e: | |
| logger.exception("Unexpected error during embedding extraction: %s", e) | |
| logger.error("Progress up to the last checkpoint was saved — re-run this exact command to resume.") | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |