"""Computer-Vision block (BONUS): "is this document image a résumé?". A recruiter often receives a résumé as a **scanned image / PDF page**, not clean text. This block validates an uploaded document and gates it into the pipeline: only pages that look like a résumé are admitted; their text is then handed to the NLP→ML pipeline (see :mod:`src.vision` for the inference + OCR bridge). Approach — **transfer learning** (the lecture's CV method), compared head-to-head: 1. **Majority baseline** (DummyClassifier) — the number to beat. 2. **ResNet18** (ImageNet) as a *frozen feature extractor* → LogisticRegression. 3. **MobileNet-V3-Small** (ImageNet) frozen features → LogisticRegression. We freeze the CNN and train only a linear head — fast and reliable on CPU, and a fair comparison of two backbones. Data: ``jordyvl/rvl_cdip_100_examples_per_class`` (a small, balanced slice of the classic RVL-CDIP document corpus; ``resume`` is one of its 16 classes). We collapse the 16 classes to a binary **résumé vs other**. Run: .venv\\Scripts\\python.exe src/cv_model.py CV deps (torch/torchvision) live in ``requirements-cv.txt`` — NOT in the Space. """ from __future__ import annotations import json import sys from datetime import date from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) try: sys.stdout.reconfigure(encoding="utf-8") except Exception: # noqa: BLE001 pass ARTIFACTS = ROOT / "artifacts" EVAL_DIR = ARTIFACTS / "eval" RANDOM_STATE = 42 N_FOLDS = 5 CV_DATASET = "jordyvl/rvl_cdip_100_examples_per_class" POSITIVE_NAME = "resume" BACKBONES = ["resnet18", "mobilenet_v3_small"] BATCH_SIZE = 32 # --------------------------------------------------------------------------- # # Backbone (frozen ImageNet feature extractor) + feature extraction # --------------------------------------------------------------------------- # def load_backbone(name: str): """Return ``(model, preprocess, dim)`` for a frozen torchvision backbone.""" import torch from torchvision import models if name == "resnet18": weights = models.ResNet18_Weights.IMAGENET1K_V1 model = models.resnet18(weights=weights) model.fc = torch.nn.Identity() dim = 512 elif name == "mobilenet_v3_small": weights = models.MobileNet_V3_Small_Weights.IMAGENET1K_V1 model = models.mobilenet_v3_small(weights=weights) model.classifier = torch.nn.Identity() dim = 576 else: raise ValueError(f"Unknown backbone: {name}") model.eval() for p in model.parameters(): # freeze: we only train the linear head p.requires_grad_(False) return model, weights.transforms(), dim def extract_features(images, name: str, batch_size: int = BATCH_SIZE) -> np.ndarray: """Embed a list of PIL images with a frozen backbone (CPU, no grad).""" import torch model, preprocess, _ = load_backbone(name) feats, batch = [], [] def _flush(): if not batch: return with torch.no_grad(): x = torch.stack(batch) feats.append(model(x).cpu().numpy()) batch.clear() for img in images: if img.mode != "RGB": img = img.convert("RGB") # ImageNet transforms expect 3 channels batch.append(preprocess(img)) if len(batch) == batch_size: _flush() _flush() return np.vstack(feats) # --------------------------------------------------------------------------- # # Data → binary (résumé vs other) # --------------------------------------------------------------------------- # def _load_binary(): from datasets import load_dataset ds = load_dataset(CV_DATASET) names = ds["train"].features["label"].names pos = names.index(POSITIVE_NAME) def to_xy(split: str): imgs = list(ds[split]["image"]) y = ["resume" if lab == pos else "other" for lab in ds[split]["label"]] return imgs, y tr_i, tr_y = to_xy("train") va_i, va_y = to_xy("validation") te_i, te_y = to_xy("test") # Train on train+validation; evaluate on the held-out test split. return (tr_i + va_i, tr_y + va_y), (te_i, te_y), names def _round(x, n: int = 4): return None if x is None else round(float(x), n) # --------------------------------------------------------------------------- # # Train + compare + persist # --------------------------------------------------------------------------- # def train_cv_classifier() -> dict: import joblib import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import seaborn as sns from sklearn.dummy import DummyClassifier from sklearn.linear_model import LogisticRegression from sklearn.metrics import ( accuracy_score, classification_report, confusion_matrix, f1_score, ) from sklearn.model_selection import StratifiedKFold, cross_validate print("Loading document images (résumé vs other) ...") (train_imgs, ytr), (test_imgs, yte), class_names = _load_binary() n_pos_tr = sum(v == "resume" for v in ytr) n_pos_te = sum(v == "resume" for v in yte) print(f" train={len(ytr)} (résumé={n_pos_tr}) | test={len(yte)} (résumé={n_pos_te})") print(f" source: {CV_DATASET} (16 classes -> binary)") labels = ["other", "resume"] cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=RANDOM_STATE) # Baseline: predict the majority class regardless of pixels. base = DummyClassifier(strategy="most_frequent").fit(np.zeros((len(ytr), 1)), ytr) bpred = base.predict(np.zeros((len(yte), 1))) results = { "baseline_majority": { "test_accuracy": _round(accuracy_score(yte, bpred)), "test_f1_macro": _round(f1_score(yte, bpred, average="macro")), } } best = {"name": None, "f1": -1.0, "clf": None, "Xte": None, "pred": None} print(f"\nComparing {len(BACKBONES)} frozen backbones " f"(+ baseline), {N_FOLDS}-fold CV on the linear head ...\n") for name in BACKBONES: print(f" [{name}] extracting features ...") Xtr = extract_features(train_imgs, name) Xte = extract_features(test_imgs, name) clf = LogisticRegression(max_iter=2000, class_weight="balanced", C=1.0, random_state=RANDOM_STATE) cvres = cross_validate(clf, Xtr, ytr, cv=cv, scoring=["accuracy", "f1_macro"], n_jobs=-1) clf.fit(Xtr, ytr) pred = clf.predict(Xte) rec = { "feature_dim": int(Xtr.shape[1]), "cv_accuracy_mean": _round(cvres["test_accuracy"].mean()), "cv_f1_macro_mean": _round(cvres["test_f1_macro"].mean()), "cv_f1_macro_std": _round(cvres["test_f1_macro"].std()), "test_accuracy": _round(accuracy_score(yte, pred)), "test_f1_macro": _round(f1_score(yte, pred, average="macro")), } results[name] = rec print(f" CV macro-F1={rec['cv_f1_macro_mean']:.3f} | " f"test acc={rec['test_accuracy']:.3f} macro-F1={rec['test_f1_macro']:.3f}") if rec["test_f1_macro"] > best["f1"]: best.update(name=name, f1=rec["test_f1_macro"], clf=clf, Xte=Xte, pred=pred) print(f"\nBest backbone by test macro-F1: {best['name']}") print("\nPer-class report (best on test):") print(classification_report(yte, best["pred"], labels=labels, digits=3, zero_division=0)) # --- figures ----------------------------------------------------------- # EVAL_DIR.mkdir(parents=True, exist_ok=True) sns.set_theme(style="whitegrid") cm = confusion_matrix(yte, best["pred"], labels=labels) fig, ax = plt.subplots(figsize=(5.5, 4.8)) sns.heatmap(cm, annot=True, fmt="d", cmap="Greens", cbar=False, xticklabels=labels, yticklabels=labels, ax=ax) ax.set_xlabel("Predicted") ax.set_ylabel("True") ax.set_title(f"CV résumé detector — confusion ({best['name']}, test)") fig.tight_layout() fig.savefig(EVAL_DIR / "cv_confusion_matrix.png", dpi=120) plt.close(fig) names_cmp = [n for n in ["baseline_majority", *BACKBONES]] f1s = [results[n]["test_f1_macro"] for n in names_cmp] fig, ax = plt.subplots(figsize=(6.5, 4.2)) ax.bar(names_cmp, f1s, color=["grey", "steelblue", "seagreen"]) for i, v in enumerate(f1s): ax.text(i, v + 0.01, f"{v:.2f}", ha="center") ax.set_ylim(0, 1.05) ax.set_ylabel("Test macro-F1") ax.set_title("CV block — model comparison (résumé vs other)") plt.xticks(rotation=15, ha="right") fig.tight_layout() fig.savefig(EVAL_DIR / "cv_model_comparison.png", dpi=120) plt.close(fig) # --- persist artifacts ------------------------------------------------- # ARTIFACTS.mkdir(exist_ok=True) bundle = { "backbone": best["name"], "clf": best["clf"], "classes": list(best["clf"].classes_), "positive_class": "resume", "img_mode": "RGB", } joblib.dump(bundle, ARTIFACTS / "cv_model.joblib") metrics = { "task": "document_image_resume_vs_other", "data_source": CV_DATASET, "n_train": len(ytr), "n_test": len(yte), "n_resume_train": int(n_pos_tr), "n_resume_test": int(n_pos_te), "best_backbone": best["name"], "models": results, "test_confusion_matrix": {"labels": labels, "matrix": cm.tolist()}, "method": "frozen ImageNet backbone -> LogisticRegression (transfer learning)", } (ARTIFACTS / "cv_metrics.json").write_text( json.dumps(metrics, indent=2, ensure_ascii=False), encoding="utf-8") meta = { "task": "document_image_resume_vs_other", "backbone": best["name"], "classes": list(best["clf"].classes_), "positive_class": "resume", "data_source": CV_DATASET, "trained_at": date.today().isoformat(), } (ARTIFACTS / "cv_metadata.json").write_text( json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\nSaved -> {ARTIFACTS / 'cv_model.joblib'}") print(f"Saved -> {ARTIFACTS / 'cv_metrics.json'}") print(f"Saved -> {EVAL_DIR / 'cv_confusion_matrix.png'} (+ cv_model_comparison.png)") return metrics if __name__ == "__main__": train_cv_classifier() print("\nDONE.")