Spaces:
Running
Running
| """ | |
| Master orchestration script — runs the full VeriFile-X training pipeline | |
| in the correct order on a single machine. | |
| Usage: | |
| python scripts/retrain_all.py # full pipeline | |
| python scripts/retrain_all.py --dry-run # print steps, do nothing | |
| python scripts/retrain_all.py --start-at step3 # resume after a crash | |
| python scripts/retrain_all.py --skip step1,step2 # skip download steps | |
| Steps: | |
| step1 — Download AI datasets (CIFAKE + optionally GAN/DiffusionDB) | |
| step2 — Download real datasets (COCO real + optionally DIV2K / RAISE1K) | |
| step3 — Train EfficientNet-B0 embedding detector | |
| step4 — Build CLIP centroid database | |
| step5 — Build OwnEmbedding centroid database | |
| step6 — Extract 30-signal feature vectors | |
| step7 — Train XGBoost meta-model | |
| step8 — Fit Platt calibration (requires features.csv + manifest.csv with val split) | |
| RTX 4050 (6 GB VRAM) recommended settings are used automatically unless | |
| overridden with the flags documented below. | |
| After completion: | |
| • Copy data/reference/*.pkl and data/reference/*.pt to the running server | |
| • Restart the backend and check GET /health — all models should be "ok" | |
| • Run: pytest backend/tests/ -v -m "not slow" | |
| """ | |
| import os | |
| import sys | |
| # Force UTF-8 output on Windows (cp1252 terminal crashes on box-drawing chars) | |
| if sys.platform == "win32": | |
| import io | |
| sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") | |
| sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") | |
| import time | |
| import shutil | |
| import logging | |
| import argparse | |
| import subprocess | |
| import platform | |
| from pathlib import Path | |
| from datetime import datetime, timedelta | |
| from typing import Optional | |
| # -- Logging to both console and file -------------------------------------- | |
| ROOT = Path(__file__).parents[1] | |
| LOG_FILE = ROOT / "data" / "training_log.txt" | |
| LOG_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)s %(message)s", | |
| datefmt="%H:%M:%S", | |
| handlers=[ | |
| logging.StreamHandler(sys.stdout), | |
| logging.FileHandler(LOG_FILE, mode="a", encoding="utf-8"), | |
| ], | |
| ) | |
| logger = logging.getLogger(__name__) | |
| SCRIPTS = ROOT / "scripts" | |
| DATA = ROOT / "data" | |
| MANIFEST = DATA / "manifest.csv" | |
| FEATURES = DATA / "features.csv" | |
| REFERENCE = DATA / "reference" | |
| # -- Expected output files for each step (used to detect completion) ------- | |
| STEP_OUTPUTS: dict[str, list[Path]] = { | |
| "step1": [DATA / "ai" / "cifake"], | |
| "step2": [DATA / "real" / "coco_val"], | |
| "step3": [REFERENCE / "own_embedding_model.pt"], | |
| "step4": [REFERENCE / "clip_database.pkl"], | |
| "step5": [REFERENCE / "own_centroids.pkl"], | |
| "step6": [FEATURES], | |
| "step7": [REFERENCE / "ensemble_xgb.pkl", | |
| REFERENCE / "ensemble_results.json"], | |
| "step8": [REFERENCE / "platt_params.json"], | |
| } | |
| STEP_NAMES = { | |
| "step1": "Download AI datasets (CIFAKE)", | |
| "step2": "Download real datasets (COCO val2017)", | |
| "step3": "Train EfficientNet-B0 embedding detector", | |
| "step4": "Build CLIP centroid database", | |
| "step5": "Build OwnEmbedding centroid database", | |
| "step6": "Extract 30-signal feature vectors", | |
| "step7": "Train XGBoost meta-model", | |
| "step8": "Fit Platt calibration parameters", | |
| } | |
| # Rough time estimates per step (minutes) — for planning / ETA display | |
| STEP_ETA_MIN = { | |
| "step1": 30, | |
| "step2": 20, | |
| "step3": 90, | |
| "step4": 40, | |
| "step5": 20, | |
| "step6": 180, # depends heavily on --limit | |
| "step7": 15, | |
| "step8": 2, | |
| } | |
| # -- Utilities -------------------------------------------------------------- | |
| def _python() -> str: | |
| """Return the Python executable to use (same as the current interpreter).""" | |
| return sys.executable | |
| def _check_gpu() -> bool: | |
| """Return True if a CUDA-capable GPU is visible to PyTorch.""" | |
| try: | |
| import torch | |
| has_gpu = torch.cuda.is_available() | |
| if has_gpu: | |
| name = torch.cuda.get_device_name(0) | |
| vram = torch.cuda.get_device_properties(0).total_memory // (1024 ** 3) | |
| logger.info(f"GPU: {name} VRAM: {vram} GB") | |
| else: | |
| logger.warning("No CUDA GPU detected — training will be slow on CPU.") | |
| return has_gpu | |
| except ImportError: | |
| logger.warning("PyTorch not importable — cannot check GPU.") | |
| return False | |
| def _check_disk(min_gb: float = 10.0) -> float: | |
| """Return free disk space in GB and warn if below min_gb.""" | |
| try: | |
| usage = shutil.disk_usage(ROOT) | |
| free = usage.free / (1024 ** 3) | |
| total = usage.total / (1024 ** 3) | |
| logger.info(f"Disk: {free:.1f} GB free of {total:.1f} GB total") | |
| if free < min_gb: | |
| logger.warning( | |
| f"Only {free:.1f} GB free — recommend at least {min_gb:.0f} GB. " | |
| "Some steps may fail." | |
| ) | |
| return free | |
| except Exception as exc: | |
| logger.warning(f"Could not check disk space: {exc}") | |
| return 999.0 | |
| def _count_manifest_rows() -> tuple[int, int]: | |
| """Return (real_count, ai_count) from manifest.csv.""" | |
| if not MANIFEST.exists(): | |
| return 0, 0 | |
| real, ai = 0, 0 | |
| try: | |
| import csv | |
| with open(MANIFEST, newline="", encoding="utf-8") as fh: | |
| for row in csv.DictReader(fh): | |
| if row.get("label") == "real": | |
| real += 1 | |
| elif row.get("label") == "ai": | |
| ai += 1 | |
| except Exception: | |
| pass | |
| return real, ai | |
| def _step_already_done(step: str) -> bool: | |
| """ | |
| Return True if all expected outputs for *step* exist (non-empty). | |
| This allows --start-at to skip already-completed steps. | |
| """ | |
| outputs = STEP_OUTPUTS.get(step, []) | |
| if not outputs: | |
| return False | |
| return all( | |
| (p.is_file() and p.stat().st_size > 0) or p.is_dir() | |
| for p in outputs | |
| ) | |
| def _run( | |
| cmd: list[str], | |
| step: str, | |
| dry_run: bool, | |
| env: Optional[dict] = None, | |
| ) -> bool: | |
| """ | |
| Run a subprocess command for *step*. | |
| Returns True on success, False on failure. | |
| Logs the command, elapsed time, and exit code. | |
| """ | |
| cmd_str = " ".join(str(c) for c in cmd) | |
| logger.info(f" CMD: {cmd_str}") | |
| if dry_run: | |
| logger.info(f" [dry-run] skipping execution") | |
| return True | |
| t0 = time.monotonic() | |
| run_env = os.environ.copy() | |
| # Force UTF-8 I/O in child processes — prevents cp1252 crashes on Windows | |
| run_env["PYTHONUTF8"] = "1" | |
| run_env["PYTHONIOENCODING"] = "utf-8" | |
| if env: | |
| run_env.update(env) | |
| result = subprocess.run( | |
| cmd, | |
| cwd=str(ROOT), | |
| env=run_env, | |
| ) | |
| elapsed = time.monotonic() - t0 | |
| if result.returncode == 0: | |
| logger.info(f" [OK] {step} done in {elapsed/60:.1f} min") | |
| return True | |
| else: | |
| logger.error( | |
| f" [FAIL] {step} FAILED (exit code {result.returncode}) " | |
| f"after {elapsed/60:.1f} min" | |
| ) | |
| return False | |
| # -- Individual step runners ------------------------------------------------ | |
| def step1_download_ai(args, dry_run: bool) -> bool: | |
| """Download CIFAKE AI-generated dataset.""" | |
| logger.info("[step1] Downloading AI datasets…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "datasets" / "download_ai.py"), | |
| "--dataset", "cifake"], | |
| step="step1", | |
| dry_run=dry_run, | |
| ) | |
| def step2_download_real(args, dry_run: bool) -> bool: | |
| """Download COCO val2017 real photos.""" | |
| logger.info("[step2] Downloading real datasets…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "datasets" / "download_real.py"), | |
| "--dataset", "coco"], | |
| step="step2", | |
| dry_run=dry_run, | |
| ) | |
| def step3_train_embedding(args, dry_run: bool) -> bool: | |
| """Train EfficientNet-B0 OwnEmbeddingModel.""" | |
| logger.info("[step3] Training embedding detector…") | |
| cmd = [ | |
| _python(), str(SCRIPTS / "train_embedding.py"), | |
| "--epochs", str(args.embed_epochs), | |
| "--batch", str(args.embed_batch), | |
| ] | |
| if args.embed_limit: | |
| cmd += ["--limit", str(args.embed_limit)] | |
| return _run(cmd, step="step3", dry_run=dry_run) | |
| def step4_build_clip_db(args, dry_run: bool) -> bool: | |
| """Build CLIP centroid database.""" | |
| logger.info("[step4] Building CLIP centroid database…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "build_clip_database.py")], | |
| step="step4", | |
| dry_run=dry_run, | |
| ) | |
| def step5_build_centroids(args, dry_run: bool) -> bool: | |
| """Build OwnEmbedding centroid database.""" | |
| logger.info("[step5] Building OwnEmbedding centroids…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "build_centroids.py")], | |
| step="step5", | |
| dry_run=dry_run, | |
| ) | |
| def step6_extract_features(args, dry_run: bool) -> bool: | |
| """Extract 30-signal feature vectors to features.csv.""" | |
| logger.info("[step6] Extracting feature vectors…") | |
| real_cnt, ai_cnt = _count_manifest_rows() | |
| available = min(real_cnt, ai_cnt) * 2 | |
| limit = min(args.feature_limit, available) if available else args.feature_limit | |
| logger.info( | |
| f" manifest: {real_cnt} real, {ai_cnt} AI images available. " | |
| f"Extracting up to {limit} (--feature-limit={args.feature_limit})." | |
| ) | |
| cmd = [ | |
| _python(), str(SCRIPTS / "extract_features.py"), | |
| "--limit", str(limit), | |
| "--workers", str(args.workers), | |
| "--seed", "42", | |
| ] | |
| if args.resume_features and FEATURES.exists(): | |
| cmd.append("--resume") | |
| return _run(cmd, step="step6", dry_run=dry_run) | |
| def step7_train_ensemble(args, dry_run: bool) -> bool: | |
| """Train XGBoost meta-model.""" | |
| logger.info("[step7] Training XGBoost meta-model…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "train_ensemble.py")], | |
| step="step7", | |
| dry_run=dry_run, | |
| ) | |
| def step8_fit_platt(args, dry_run: bool) -> bool: | |
| """Fit Platt calibration parameters on the val split of features.csv.""" | |
| logger.info("[step8] Fitting Platt calibration…") | |
| return _run( | |
| [_python(), str(SCRIPTS / "fit_platt.py"), "--split", "val"], | |
| step="step8", | |
| dry_run=dry_run, | |
| ) | |
| # -- Validation report ------------------------------------------------------ | |
| def validation_report() -> None: | |
| """Print a summary of all model artefacts and their status.""" | |
| logger.info("") | |
| logger.info("=" * 70) | |
| logger.info("Post-training validation report") | |
| logger.info("=" * 70) | |
| files = { | |
| "own_embedding_model.pt": REFERENCE / "own_embedding_model.pt", | |
| "clip_database.pkl": REFERENCE / "clip_database.pkl", | |
| "own_centroids.pkl": REFERENCE / "own_centroids.pkl", | |
| "ensemble_xgb.pkl": REFERENCE / "ensemble_xgb.pkl", | |
| "ensemble_results.json": REFERENCE / "ensemble_results.json", | |
| "platt_params.json": REFERENCE / "platt_params.json", | |
| "features.csv": FEATURES, | |
| } | |
| for name, path in files.items(): | |
| if path.exists(): | |
| size_kb = path.stat().st_size // 1024 | |
| logger.info(f" [OK] {name:<35} {size_kb:>8} KB at {path}") | |
| else: | |
| logger.warning(f" [FAIL] {name:<35} NOT FOUND (expected at {path})") | |
| # Parse ensemble results if available | |
| results_path = REFERENCE / "ensemble_results.json" | |
| if results_path.exists(): | |
| try: | |
| import json | |
| with open(results_path) as fh: | |
| res = json.load(fh) | |
| logger.info("") | |
| logger.info("XGBoost results:") | |
| logger.info(f" CV AUC: {res.get('cv_auc_mean', '?'):.4f} ± {res.get('cv_auc_std', '?'):.4f}") | |
| logger.info(f" Test AUC: {res.get('test_auc', '?'):.4f}") | |
| logger.info(f" Test F1: {res.get('test_f1', '?'):.4f}") | |
| logger.info(f" Test Acc: {res.get('test_accuracy', '?'):.4f}") | |
| except Exception as exc: | |
| logger.warning(f"Could not parse ensemble_results.json: {exc}") | |
| logger.info("") | |
| logger.info("Next steps:") | |
| logger.info(" 1. Restart the backend server") | |
| logger.info(" 2. Check GET /health — all models should show 'ok'") | |
| logger.info(" 3. Run: pytest backend/tests/ -v -m 'not slow'") | |
| logger.info("=" * 70) | |
| # -- Main ------------------------------------------------------------------- | |
| ALL_STEPS = ["step1", "step2", "step3", "step4", "step5", "step6", "step7", "step8"] | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="VeriFile-X — full training pipeline orchestration", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=__doc__, | |
| ) | |
| # -- Pipeline control ------------------------------------------------- | |
| parser.add_argument( | |
| "--start-at", metavar="STEP", | |
| choices=ALL_STEPS, | |
| default=None, | |
| help=( | |
| "Resume pipeline from this step (e.g. step3). " | |
| "All earlier steps are skipped even if their outputs are missing." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--only", metavar="STEP", | |
| choices=ALL_STEPS, | |
| default=None, | |
| help="Run only this single step and exit.", | |
| ) | |
| parser.add_argument( | |
| "--skip", metavar="STEP1,STEP2", | |
| default="", | |
| help="Comma-separated list of steps to skip.", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", action="store_true", | |
| help="Print the commands that would be run without executing them.", | |
| ) | |
| parser.add_argument( | |
| "--skip-done", action="store_true", | |
| help=( | |
| "Skip a step if its expected outputs already exist. " | |
| "Useful for re-running after a mid-pipeline crash." | |
| ), | |
| ) | |
| # -- Hardware / training hyperparameters ------------------------------ | |
| parser.add_argument( | |
| "--embed-epochs", type=int, default=20, | |
| help="Epochs for train_embedding.py. Default: 20 (RTX 4050).", | |
| ) | |
| parser.add_argument( | |
| "--embed-batch", type=int, default=24, | |
| help="Batch size for train_embedding.py. Default: 24 (fits RTX 4050 6 GB).", | |
| ) | |
| parser.add_argument( | |
| "--embed-limit", type=int, default=0, | |
| help="Per-class image limit for embedding training. 0=all.", | |
| ) | |
| parser.add_argument( | |
| "--feature-limit", type=int, default=50000, | |
| help=( | |
| "Max images for extract_features.py (balanced: half real, half AI). " | |
| "Default: 50000. Reduce to 10000 for a quick test run." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--workers", type=int, default=4, | |
| help="Parallel worker processes for feature extraction. Default: 4.", | |
| ) | |
| parser.add_argument( | |
| "--resume-features", action="store_true", | |
| help=( | |
| "Pass --resume to extract_features.py so it appends to an " | |
| "existing features.csv instead of restarting from scratch." | |
| ), | |
| ) | |
| args = parser.parse_args() | |
| # -- Header ------------------------------------------------------------ | |
| logger.info("=" * 70) | |
| logger.info("VeriFile-X — Training Pipeline") | |
| logger.info(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") | |
| logger.info(f"Python: {sys.version.split()[0]} | OS: {platform.system()}") | |
| logger.info(f"Root: {ROOT}") | |
| logger.info(f"Log: {LOG_FILE}") | |
| logger.info("=" * 70) | |
| # -- Pre-flight checks ------------------------------------------------- | |
| _check_gpu() | |
| _check_disk(min_gb=15.0) | |
| # -- Determine which steps to run -------------------------------------- | |
| skip_set: set[str] = set(s.strip() for s in args.skip.split(",") if s.strip()) | |
| if args.only: | |
| run_steps = [args.only] | |
| elif args.start_at: | |
| start_idx = ALL_STEPS.index(args.start_at) | |
| run_steps = ALL_STEPS[start_idx:] | |
| else: | |
| run_steps = ALL_STEPS[:] | |
| run_steps = [s for s in run_steps if s not in skip_set] | |
| if args.dry_run: | |
| logger.info("[dry-run] Steps that would execute:") | |
| total_eta = sum(STEP_ETA_MIN.get(s, 0) for s in run_steps) | |
| for s in run_steps: | |
| logger.info( | |
| f" {s}: {STEP_NAMES[s]} (~{STEP_ETA_MIN.get(s, '?')} min)" | |
| ) | |
| logger.info(f"Estimated total: {total_eta} min ({total_eta/60:.1f} h)") | |
| else: | |
| logger.info(f"Steps to run: {run_steps}") | |
| total_eta = sum(STEP_ETA_MIN.get(s, 0) for s in run_steps) | |
| logger.info( | |
| f"Estimated total: {total_eta} min ({total_eta/60:.1f} h). " | |
| "Actual time depends on data size and hardware." | |
| ) | |
| # -- Step dispatch ----------------------------------------------------- | |
| step_fns = { | |
| "step1": step1_download_ai, | |
| "step2": step2_download_real, | |
| "step3": step3_train_embedding, | |
| "step4": step4_build_clip_db, | |
| "step5": step5_build_centroids, | |
| "step6": step6_extract_features, | |
| "step7": step7_train_ensemble, | |
| "step8": step8_fit_platt, | |
| } | |
| pipeline_start = time.monotonic() | |
| failed_steps: list[str] = [] | |
| for step in run_steps: | |
| logger.info("") | |
| logger.info(f"--- {step}: {STEP_NAMES[step]} ---") | |
| # -- Disk space check before each step ---------------------------- | |
| if not args.dry_run: | |
| free_gb = _check_disk(min_gb=5.0) | |
| if free_gb < 3.0: | |
| logger.error( | |
| f"Less than 3 GB free before {step}. Aborting to protect disk." | |
| ) | |
| sys.exit(1) | |
| # -- Skip-if-done -------------------------------------------------- | |
| if args.skip_done and _step_already_done(step): | |
| logger.info(f" <- Outputs already exist — skipping (--skip-done).") | |
| continue | |
| # -- Run the step -------------------------------------------------- | |
| fn = step_fns[step] | |
| ok = fn(args, dry_run=args.dry_run) | |
| if not ok: | |
| failed_steps.append(step) | |
| logger.error( | |
| f"Step {step} failed. Pipeline aborted.\n" | |
| f" Fix the error above, then re-run with:\n" | |
| f" python scripts/retrain_all.py --start-at {step}" | |
| ) | |
| break | |
| # -- Summary ----------------------------------------------------------- | |
| elapsed_total = time.monotonic() - pipeline_start | |
| logger.info("") | |
| logger.info("=" * 70) | |
| if failed_steps: | |
| logger.error(f"Pipeline FAILED at: {failed_steps}") | |
| logger.info(f"Elapsed: {elapsed_total/60:.1f} min") | |
| logger.info(f"Full log: {LOG_FILE}") | |
| sys.exit(1) | |
| else: | |
| logger.info( | |
| f"Pipeline COMPLETE in {elapsed_total/60:.1f} min " | |
| f"({timedelta(seconds=int(elapsed_total))})" | |
| ) | |
| logger.info(f"Full log: {LOG_FILE}") | |
| if not args.dry_run: | |
| validation_report() | |
| if __name__ == "__main__": | |
| main() | |