Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Compare the LoRA and full fine-tuned models side by side. | |
| python train.py --mode lora | |
| python train.py --mode full | |
| python evaluate.py | |
| Writes ``results/comparison.md`` (paste into the README) and | |
| ``results/comparison.csv``, and prints the table. | |
| Everything is measured on the held-out **test** split — never validation, which | |
| was used to pick the best epoch and would therefore give an optimistic number. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import logging | |
| import sys | |
| import time | |
| from collections import Counter | |
| from pathlib import Path | |
| from typing import Any, Sequence | |
| import torch | |
| from sklearn.metrics import classification_report, confusion_matrix, f1_score | |
| from data.dataset import build_dataloaders, label_mappings | |
| from models.classifier import TextClassifier | |
| from train import evaluate_split, pick_device, synchronize | |
| log = logging.getLogger("evaluate") | |
| MODES = ("full", "lora") | |
| def measure_latency( | |
| model: TextClassifier, | |
| loader: torch.utils.data.DataLoader, | |
| device: torch.device, | |
| n_examples: int = 200, | |
| warmup: int = 20, | |
| ) -> float: | |
| """Mean single-example inference latency, in milliseconds. | |
| Measured at **batch size 1** on purpose. That is what a deployed demo | |
| actually does — one user, one text box, one request. Batched throughput is a | |
| different (and much prettier) number that would not reflect the Space. | |
| The warmup iterations are not optional: the first CUDA call pays one-off | |
| context and kernel-compilation costs, and on CPU the first pass populates | |
| caches. Including them inflates the mean by a wide margin. | |
| """ | |
| model.eval() | |
| dataset = loader.dataset | |
| def run_one(i: int) -> None: | |
| item = dataset[i] | |
| # unsqueeze(0): (L,) -> (1, L). The model always expects a batch | |
| # dimension, even when the batch is a single example. | |
| input_ids = item["input_ids"].unsqueeze(0).to(device) | |
| attention_mask = item["attention_mask"].unsqueeze(0).to(device) | |
| with torch.no_grad(): | |
| model(input_ids, attention_mask) | |
| for i in range(min(warmup, len(dataset))): | |
| run_one(i) | |
| n = min(n_examples, len(dataset)) | |
| synchronize(device) | |
| start = time.perf_counter() | |
| for i in range(n): | |
| run_one(i) | |
| synchronize(device) | |
| elapsed = time.perf_counter() - start | |
| return 1000.0 * elapsed / n | |
| def majority_baseline( | |
| loader: torch.utils.data.DataLoader, labels: Sequence[str] | |
| ) -> dict[str, float]: | |
| """Score of a model that always predicts the most common training grade. | |
| The number every other row has to beat. An accuracy figure without this next | |
| to it is not interpretable — on this dataset, always saying "B" scores | |
| around 1.7% on banking77. | |
| """ | |
| ids = list(loader.dataset.labels) | |
| most_common, count = Counter(ids).most_common(1)[0] | |
| predictions = [most_common] * len(ids) | |
| _, id_to_label = label_mappings(labels) | |
| return { | |
| "accuracy": count / len(ids), | |
| "macro_f1": f1_score(ids, predictions, average="macro", zero_division=0), | |
| "class": id_to_label[most_common], | |
| } | |
| def evaluate_mode( | |
| mode: str, | |
| args: argparse.Namespace, | |
| loader: torch.utils.data.DataLoader, | |
| device: torch.device, | |
| ) -> dict[str, Any] | None: | |
| """Load one trained model, score it on the test split, and time it. | |
| Returns None (with a warning) if that mode has not been trained yet, so the | |
| script still works when you have only run one of them. | |
| """ | |
| checkpoint = Path(args.checkpoint_dir) / f"{mode}.pt" | |
| metrics_path = Path(args.results_dir) / f"{mode}_metrics.json" | |
| if not checkpoint.exists() or not metrics_path.exists(): | |
| log.warning("Skipping %s — run: python train.py --mode %s", mode, mode) | |
| return None | |
| train_metrics = json.loads(metrics_path.read_text()) | |
| model = TextClassifier.load(checkpoint, device=device) | |
| _, accuracy, predictions, truths = evaluate_split(model, loader, device) | |
| latency_ms = measure_latency(model, loader, device, args.latency_examples) | |
| return { | |
| "mode": mode, | |
| "test_accuracy": accuracy, | |
| "macro_f1": f1_score(truths, predictions, average="macro", zero_division=0), | |
| "trainable_params": train_metrics["trainable_params"], | |
| "total_params": train_metrics["total_params"], | |
| "trainable_pct": train_metrics["trainable_pct"], | |
| "train_seconds": train_metrics["train_seconds"], | |
| "best_val_acc": train_metrics["best_val_acc"], | |
| "checkpoint_kb": train_metrics["checkpoint_kb"], | |
| "latency_ms": latency_ms, | |
| "lora_rank": train_metrics.get("lora_rank"), | |
| "device": str(device), | |
| "predictions": predictions, | |
| "truths": truths, | |
| } | |
| def build_markdown( | |
| rows: list[dict], baseline: dict, device: torch.device, n_classes: int | |
| ) -> str: | |
| """Render the comparison as a markdown table, ready to paste into the README.""" | |
| lines = [ | |
| "# LoRA vs. full fine-tuning", | |
| "", | |
| f"DistilBERT, {n_classes}-class intent classification. Test split, device: `{device}`.", | |
| "", | |
| "| Model | Test accuracy | Macro F1 | Trainable params | % of total | " | |
| "Train time | Latency / example | Checkpoint |", | |
| "| --- | --- | --- | --- | --- | --- | --- | --- |", | |
| f"| Majority baseline ({baseline['class']}) | {baseline['accuracy']:.1%} | " | |
| f"{baseline['macro_f1']:.3f} | 0 | 0% | — | — | — |", | |
| ] | |
| for row in rows: | |
| name = "Full fine-tuning" if row["mode"] == "full" else f"LoRA (r={row['lora_rank']})" | |
| size = (f"{row['checkpoint_kb'] / 1024:.0f} MB" if row["checkpoint_kb"] > 1024 | |
| else f"{row['checkpoint_kb']:.0f} KB") | |
| lines.append( | |
| f"| {name} | {row['test_accuracy']:.1%} | {row['macro_f1']:.3f} | " | |
| f"{row['trainable_params']:,} | {row['trainable_pct']:.3f}% | " | |
| f"{row['train_seconds']:.1f}s | {row['latency_ms']:.1f} ms | {size} |" | |
| ) | |
| by_mode = {row["mode"]: row for row in rows} | |
| if "lora" in by_mode and "full" in by_mode: | |
| lora, full = by_mode["lora"], by_mode["full"] | |
| param_ratio = full["trainable_params"] / lora["trainable_params"] | |
| acc_delta = (lora["test_accuracy"] - full["test_accuracy"]) * 100 | |
| speedup = full["train_seconds"] / lora["train_seconds"] | |
| size_ratio = full["checkpoint_kb"] / lora["checkpoint_kb"] | |
| lines += [ | |
| "", | |
| "## Takeaways", | |
| "", | |
| f"- LoRA trains **{param_ratio:,.0f}x fewer parameters** " | |
| f"({lora['trainable_params']:,} vs {full['trainable_params']:,}).", | |
| f"- Accuracy difference: **{acc_delta:+.1f} percentage points** " | |
| f"({lora['test_accuracy']:.1%} vs {full['test_accuracy']:.1%}).", | |
| f"- Training was **{speedup:.2f}x** " | |
| f"{'faster' if speedup > 1 else 'slower'} with LoRA.", | |
| f"- The LoRA checkpoint is **{size_ratio:,.0f}x smaller**, which is what " | |
| f"makes free-tier deployment practical.", | |
| "", | |
| "Inference latency is essentially identical by construction: both models run " | |
| "the same 66M-parameter forward pass. LoRA saves on *training* cost and " | |
| "*storage*, not on inference.", | |
| ] | |
| return "\n".join(lines) + "\n" | |
| def build_csv(rows: list[dict], baseline: dict) -> str: | |
| """Render the same numbers as CSV.""" | |
| header = ( | |
| "model,test_accuracy,macro_f1,trainable_params,total_params," | |
| "trainable_pct,train_seconds,latency_ms,checkpoint_kb" | |
| ) | |
| lines = [ | |
| header, | |
| f"majority_baseline,{baseline['accuracy']:.6f},{baseline['macro_f1']:.6f}," | |
| f"0,0,0,,,", | |
| ] | |
| for row in rows: | |
| lines.append( | |
| f"{row['mode']},{row['test_accuracy']:.6f},{row['macro_f1']:.6f}," | |
| f"{row['trainable_params']},{row['total_params']}," | |
| f"{row['trainable_pct']:.6f},{row['train_seconds']:.3f}," | |
| f"{row['latency_ms']:.3f},{row['checkpoint_kb']:.1f}" | |
| ) | |
| return "\n".join(lines) + "\n" | |
| def run(args: argparse.Namespace) -> int: | |
| """Evaluate every trained mode and write the comparison outputs.""" | |
| device = pick_device(args.device) | |
| _, _, test_loader, _, labels = build_dataloaders( | |
| data_path=args.data, batch_size=args.batch_size, | |
| max_length=args.max_length, seed=args.seed, | |
| ) | |
| log.info("Test split: %d examples on %s", len(test_loader.dataset), device) | |
| baseline = majority_baseline(test_loader, labels) | |
| rows = [r for mode in MODES if (r := evaluate_mode(mode, args, test_loader, device))] | |
| if not rows: | |
| log.error("No trained models found. Run train.py first.") | |
| return 1 | |
| out_dir = Path(args.results_dir) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| markdown = build_markdown(rows, baseline, device, len(labels)) | |
| (out_dir / "comparison.md").write_text(markdown) | |
| (out_dir / "comparison.csv").write_text(build_csv(rows, baseline)) | |
| print("\n" + markdown) | |
| if args.per_class: | |
| _ = labels | |
| for row in rows: | |
| print(f"\n Per-class breakdown — {row['mode']}") | |
| print(" " + "-" * 60) | |
| print(classification_report( | |
| row["truths"], row["predictions"], | |
| labels=list(range(len(labels))), target_names=list(labels), | |
| zero_division=0, digits=3, | |
| )) | |
| # A 77x77 confusion matrix is unreadable in a terminal; list the | |
| # most frequent confusions instead, which is what you actually act on. | |
| matrix = confusion_matrix( | |
| row["truths"], row["predictions"], labels=list(range(len(labels))) | |
| ) | |
| confusions = [ | |
| (matrix[i][j], labels[i], labels[j]) | |
| for i in range(len(labels)) for j in range(len(labels)) | |
| if i != j and matrix[i][j] > 0 | |
| ] | |
| print(" Top confusions (true -> predicted)") | |
| for count, true_name, pred_name in sorted(confusions, reverse=True)[:12]: | |
| print(f" {count:>3}x {true_name} -> {pred_name}") | |
| print(f"\n Wrote {out_dir / 'comparison.md'} and {out_dir / 'comparison.csv'}\n") | |
| return 0 | |
| def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: | |
| """Define and parse the command-line interface.""" | |
| p = argparse.ArgumentParser( | |
| description="Compare trained LoRA and full fine-tuned models.", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| p.add_argument("--data", type=Path, default=Path("data/processed/dataset.csv")) | |
| p.add_argument("--results-dir", type=Path, default=Path("results")) | |
| p.add_argument("--checkpoint-dir", type=Path, default=Path("checkpoints")) | |
| p.add_argument("--batch-size", type=int, default=32) | |
| p.add_argument("--max-length", type=int, default=128) | |
| p.add_argument("--seed", type=int, default=42, | |
| help="Must match training, or the test split differs.") | |
| p.add_argument("--latency-examples", type=int, default=200) | |
| p.add_argument("--per-class", action="store_true", | |
| help="Also print per-grade precision/recall and a confusion matrix.") | |
| p.add_argument("--device", default=None) | |
| return p.parse_args(argv) | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| """Entry point. Returns a process exit code.""" | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s") | |
| for noisy in ("httpx", "urllib3", "filelock", "huggingface_hub"): | |
| logging.getLogger(noisy).setLevel(logging.WARNING) | |
| import transformers | |
| transformers.logging.set_verbosity_error() | |
| return run(parse_args(argv)) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |