Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +36 -0
- adapters.py +209 -0
- app.py +153 -56
- requirements.txt +4 -0
- train_lora.py +275 -0
README.md
CHANGED
|
@@ -71,11 +71,47 @@ git push -u origin main
|
|
| 71 |
The Space will build on a free CPU runtime by default. For faster inference you can
|
| 72 |
upgrade the Space hardware to a small GPU (`T4`, `A10G`, etc.) in the Space settings.
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
## Project layout
|
| 75 |
|
| 76 |
```
|
| 77 |
.
|
| 78 |
├── app.py # Gradio Blocks app
|
|
|
|
|
|
|
| 79 |
├── requirements.txt # Python deps
|
| 80 |
├── examples/ # Sample images used in the UI
|
| 81 |
│ ├── bird.jpg
|
|
|
|
| 71 |
The Space will build on a free CPU runtime by default. For faster inference you can
|
| 72 |
upgrade the Space hardware to a small GPU (`T4`, `A10G`, etc.) in the Space settings.
|
| 73 |
|
| 74 |
+
## Training a new LoRA adapter
|
| 75 |
+
|
| 76 |
+
The demo also supports LoRA adapters that add new tasks on top of the frozen
|
| 77 |
+
backbone. Train one with:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
python train_lora.py \
|
| 81 |
+
--rank 8 --alpha 16 --target-modules query value \
|
| 82 |
+
--epochs 5 --batch-size 64 --lr 5e-4 \
|
| 83 |
+
--push-to-hub <your-username>/vit-tiny-lora-food101
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
The script freezes the base weights, injects a low-rank \(\Delta W\) into the
|
| 87 |
+
attention projections, and trains a new classification head. Because the
|
| 88 |
+
original weights are untouched, disabling the adapter at inference time
|
| 89 |
+
recovers the original ImageNet-1k model exactly.
|
| 90 |
+
|
| 91 |
+
Useful flags: `--max-train-samples N` (quick smoke test), `--eval-only`
|
| 92 |
+
(metrics-only pass), `--dataset-id` (any HF image classification dataset with
|
| 93 |
+
`image` / `label` features).
|
| 94 |
+
|
| 95 |
+
## Adapters loaded at runtime
|
| 96 |
+
|
| 97 |
+
`adapters.py` holds the registry of adapters the Gradio app pulls in at
|
| 98 |
+
startup. Add more entries to expose additional tasks in the UI:
|
| 99 |
+
|
| 100 |
+
| Name | Hub repo | Dataset | Classes |
|
| 101 |
+
|-----------|------------------------------------------|-----------|---------|
|
| 102 |
+
| food101 | `turhancan97/vit-tiny-lora-food101` | Food-101 | 101 |
|
| 103 |
+
|
| 104 |
+
Adapters that fail to load (e.g. repo not yet pushed) are logged and skipped;
|
| 105 |
+
the app still starts with whatever is reachable. The UI gains a "Compare: Base
|
| 106 |
+
vs LoRA" tab whenever at least one adapter is loaded.
|
| 107 |
+
|
| 108 |
## Project layout
|
| 109 |
|
| 110 |
```
|
| 111 |
.
|
| 112 |
├── app.py # Gradio Blocks app
|
| 113 |
+
├── adapters.py # LoRA adapter registry
|
| 114 |
+
├── train_lora.py # LoRA fine-tuning CLI
|
| 115 |
├── requirements.txt # Python deps
|
| 116 |
├── examples/ # Sample images used in the UI
|
| 117 |
│ ├── bird.jpg
|
adapters.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Registry of LoRA adapters for the Gradio demo.
|
| 2 |
+
|
| 3 |
+
Loads the pretrained base model once and wraps it with a `PeftModel` that
|
| 4 |
+
holds one or more named LoRA adapters. Each adapter also carries its own
|
| 5 |
+
classification head (saved separately as ``classifier.pt`` at training time),
|
| 6 |
+
so the demo can swap *both* the adapter and the head per request.
|
| 7 |
+
|
| 8 |
+
Inference modes:
|
| 9 |
+
* Base (ImageNet-1k): ``model.disable_adapter()`` + original head.
|
| 10 |
+
* Adapter N: ``model.set_adapter(name)`` + the adapter's head.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import copy
|
| 16 |
+
import json
|
| 17 |
+
from contextlib import contextmanager
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Iterable
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from torch import nn
|
| 24 |
+
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
BASE_MODEL_ID = "WinKawaks/vit-tiny-patch16-224"
|
| 28 |
+
BASE_TASK_NAME = "imagenet"
|
| 29 |
+
BASE_TASK_LABEL = "ImageNet-1k (base)"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class AdapterSpec:
|
| 34 |
+
name: str
|
| 35 |
+
repo_id: str
|
| 36 |
+
display_name: str
|
| 37 |
+
description: str = ""
|
| 38 |
+
id2label: dict[int, str] = field(default_factory=dict)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
ADAPTERS: list[AdapterSpec] = [
|
| 42 |
+
AdapterSpec(
|
| 43 |
+
name="food101",
|
| 44 |
+
repo_id="turhancan97/vit-tiny-lora-food101",
|
| 45 |
+
display_name="Food-101 (LoRA)",
|
| 46 |
+
description="Fine-tuned LoRA adapter on the Food-101 dataset (101 classes).",
|
| 47 |
+
),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class TaskInfo:
|
| 53 |
+
key: str
|
| 54 |
+
display_name: str
|
| 55 |
+
id2label: dict[int, str]
|
| 56 |
+
is_base: bool
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _fetch_file(repo_id: str, filename: str) -> Path:
|
| 60 |
+
local = Path(repo_id)
|
| 61 |
+
if local.exists() and (local / filename).exists():
|
| 62 |
+
return local / filename
|
| 63 |
+
from huggingface_hub import hf_hub_download
|
| 64 |
+
|
| 65 |
+
return Path(hf_hub_download(repo_id=repo_id, filename=filename))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class AdapterRegistry:
|
| 69 |
+
"""Loads the base model plus any registered adapters lazily and on demand."""
|
| 70 |
+
|
| 71 |
+
def __init__(
|
| 72 |
+
self,
|
| 73 |
+
adapters: Iterable[AdapterSpec] | None = None,
|
| 74 |
+
base_model_id: str = BASE_MODEL_ID,
|
| 75 |
+
device: str | None = None,
|
| 76 |
+
) -> None:
|
| 77 |
+
if adapters is None:
|
| 78 |
+
adapters = ADAPTERS
|
| 79 |
+
self.base_model_id = base_model_id
|
| 80 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 81 |
+
|
| 82 |
+
self.processor = AutoImageProcessor.from_pretrained(base_model_id, use_fast=True)
|
| 83 |
+
self.base_model = AutoModelForImageClassification.from_pretrained(base_model_id)
|
| 84 |
+
self.base_id2label: dict[int, str] = dict(self.base_model.config.id2label)
|
| 85 |
+
|
| 86 |
+
self._original_classifier = copy.deepcopy(self.base_model.classifier)
|
| 87 |
+
|
| 88 |
+
self._model = None
|
| 89 |
+
self._loaded: dict[str, AdapterSpec] = {}
|
| 90 |
+
self._classifiers: dict[str, nn.Module] = {}
|
| 91 |
+
self._current_adapter: str | None = None
|
| 92 |
+
|
| 93 |
+
for spec in adapters:
|
| 94 |
+
self._try_load_adapter(spec)
|
| 95 |
+
|
| 96 |
+
if self._model is None:
|
| 97 |
+
self._model = self.base_model
|
| 98 |
+
self._model.to(self.device).eval()
|
| 99 |
+
self._original_classifier.to(self.device).eval()
|
| 100 |
+
for head in self._classifiers.values():
|
| 101 |
+
head.to(self.device).eval()
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def model(self):
|
| 105 |
+
return self._model
|
| 106 |
+
|
| 107 |
+
def available_tasks(self) -> list[TaskInfo]:
|
| 108 |
+
tasks = [TaskInfo(
|
| 109 |
+
key=BASE_TASK_NAME,
|
| 110 |
+
display_name=BASE_TASK_LABEL,
|
| 111 |
+
id2label=self.base_id2label,
|
| 112 |
+
is_base=True,
|
| 113 |
+
)]
|
| 114 |
+
for spec in self._loaded.values():
|
| 115 |
+
tasks.append(TaskInfo(
|
| 116 |
+
key=spec.name,
|
| 117 |
+
display_name=spec.display_name,
|
| 118 |
+
id2label=spec.id2label,
|
| 119 |
+
is_base=False,
|
| 120 |
+
))
|
| 121 |
+
return tasks
|
| 122 |
+
|
| 123 |
+
def adapter_task_keys(self) -> list[str]:
|
| 124 |
+
return list(self._loaded.keys())
|
| 125 |
+
|
| 126 |
+
def get_id2label(self, task_key: str) -> dict[int, str]:
|
| 127 |
+
if task_key == BASE_TASK_NAME:
|
| 128 |
+
return self.base_id2label
|
| 129 |
+
return self._loaded[task_key].id2label
|
| 130 |
+
|
| 131 |
+
def _classifier_host(self) -> nn.Module:
|
| 132 |
+
"""Return the module that actually *owns* the `classifier` submodule.
|
| 133 |
+
|
| 134 |
+
`PeftModel` exposes `.classifier` via attribute proxying on top of
|
| 135 |
+
`base_model.model.classifier`, but only the latter is what gets called
|
| 136 |
+
during forward. Traverse until we find a module where `classifier` is
|
| 137 |
+
a registered child (present in `_modules`).
|
| 138 |
+
"""
|
| 139 |
+
visited: set[int] = set()
|
| 140 |
+
m = self._model
|
| 141 |
+
while id(m) not in visited:
|
| 142 |
+
visited.add(id(m))
|
| 143 |
+
if "classifier" in getattr(m, "_modules", {}):
|
| 144 |
+
return m
|
| 145 |
+
inner = getattr(m, "model", None) or getattr(m, "base_model", None)
|
| 146 |
+
if inner is None or inner is m:
|
| 147 |
+
break
|
| 148 |
+
m = inner
|
| 149 |
+
return self.base_model
|
| 150 |
+
|
| 151 |
+
def _active_classifier(self) -> nn.Module:
|
| 152 |
+
return self._classifier_host().classifier
|
| 153 |
+
|
| 154 |
+
def _set_classifier(self, head: nn.Module) -> None:
|
| 155 |
+
self._classifier_host().classifier = head
|
| 156 |
+
|
| 157 |
+
@contextmanager
|
| 158 |
+
def use_task(self, task_key: str):
|
| 159 |
+
"""Activate the right (adapter, head) pair for a single forward pass."""
|
| 160 |
+
prev_head = self._active_classifier()
|
| 161 |
+
if task_key == BASE_TASK_NAME:
|
| 162 |
+
self._set_classifier(self._original_classifier)
|
| 163 |
+
ctx = self._model.disable_adapter() if hasattr(self._model, "disable_adapter") else _null_ctx()
|
| 164 |
+
else:
|
| 165 |
+
spec = self._loaded[task_key]
|
| 166 |
+
self._set_classifier(self._classifiers[spec.name])
|
| 167 |
+
if hasattr(self._model, "set_adapter") and self._current_adapter != task_key:
|
| 168 |
+
self._model.set_adapter(task_key)
|
| 169 |
+
self._current_adapter = task_key
|
| 170 |
+
ctx = _null_ctx()
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
with ctx:
|
| 174 |
+
yield
|
| 175 |
+
finally:
|
| 176 |
+
self._set_classifier(prev_head)
|
| 177 |
+
|
| 178 |
+
def _try_load_adapter(self, spec: AdapterSpec) -> None:
|
| 179 |
+
try:
|
| 180 |
+
labels_path = _fetch_file(spec.repo_id, "labels.json")
|
| 181 |
+
spec.id2label = {int(k): v for k, v in json.loads(labels_path.read_text()).items()}
|
| 182 |
+
|
| 183 |
+
classifier_path = _fetch_file(spec.repo_id, "classifier.pt")
|
| 184 |
+
head_state = torch.load(classifier_path, map_location="cpu", weights_only=True)
|
| 185 |
+
hidden_size = self.base_model.config.hidden_size
|
| 186 |
+
head = nn.Linear(hidden_size, len(spec.id2label))
|
| 187 |
+
head.load_state_dict(head_state)
|
| 188 |
+
|
| 189 |
+
from peft import PeftModel
|
| 190 |
+
|
| 191 |
+
if self._model is None or not hasattr(self._model, "load_adapter"):
|
| 192 |
+
self._model = PeftModel.from_pretrained(
|
| 193 |
+
self.base_model, spec.repo_id, adapter_name=spec.name,
|
| 194 |
+
)
|
| 195 |
+
else:
|
| 196 |
+
self._model.load_adapter(spec.repo_id, adapter_name=spec.name)
|
| 197 |
+
|
| 198 |
+
self._classifiers[spec.name] = head
|
| 199 |
+
self._loaded[spec.name] = spec
|
| 200 |
+
print(f"[adapters] loaded '{spec.name}' from {spec.repo_id} "
|
| 201 |
+
f"({len(spec.id2label)} classes)")
|
| 202 |
+
except Exception as exc:
|
| 203 |
+
print(f"[adapters] could not load '{spec.name}' from {spec.repo_id}: "
|
| 204 |
+
f"{type(exc).__name__}: {exc}")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@contextmanager
|
| 208 |
+
def _null_ctx():
|
| 209 |
+
yield
|
app.py
CHANGED
|
@@ -1,4 +1,12 @@
|
|
| 1 |
-
"""Gradio demo:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -28,31 +36,40 @@ _gc_utils._json_schema_to_python_type = _safe_json_schema_to_python_type
|
|
| 28 |
|
| 29 |
import gradio as gr
|
| 30 |
import torch
|
| 31 |
-
from PIL import Image
|
| 32 |
-
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
|
| 41 |
EXAMPLES_DIR = Path(__file__).parent / "examples"
|
| 42 |
EXAMPLE_IMAGES = sorted(str(p) for p in EXAMPLES_DIR.glob("*.jpg")) if EXAMPLES_DIR.exists() else []
|
| 43 |
|
| 44 |
|
| 45 |
-
|
| 46 |
-
def classify(image, top_k, threshold):
|
| 47 |
-
if image is None:
|
| 48 |
-
return {}
|
| 49 |
if image.mode != "RGB":
|
| 50 |
image = image.convert("RGB")
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
probs = torch.softmax(logits, dim=-1)
|
| 55 |
|
|
|
|
| 56 |
k = max(1, min(int(top_k), probs.shape[-1]))
|
| 57 |
top = torch.topk(probs, k=k)
|
| 58 |
return {
|
|
@@ -62,60 +79,140 @@ def classify(image, top_k, threshold):
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
gr.Markdown(
|
| 67 |
f"""
|
| 68 |
-
#
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
"""
|
| 76 |
)
|
| 77 |
|
| 78 |
-
with gr.
|
| 79 |
-
with gr.
|
| 80 |
-
image = gr.Image(
|
| 81 |
-
type="pil",
|
| 82 |
-
label="Input image",
|
| 83 |
-
sources=["upload", "clipboard", "webcam"],
|
| 84 |
-
height=360,
|
| 85 |
-
)
|
| 86 |
with gr.Row():
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
)
|
| 91 |
-
threshold = gr.Slider(
|
| 92 |
-
minimum=0.0, maximum=1.0, value=0.0, step=0.01,
|
| 93 |
-
label="Confidence threshold",
|
| 94 |
-
info="Hide predictions below this probability",
|
| 95 |
-
)
|
| 96 |
-
submit = gr.Button("Classify", variant="primary")
|
| 97 |
-
|
| 98 |
-
with gr.Column(scale=1):
|
| 99 |
-
output = gr.Label(num_top_classes=10, label="Predictions")
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
if __name__ == "__main__":
|
| 116 |
demo.launch(
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
)
|
|
|
|
| 1 |
+
"""Gradio demo: ViT image classification with pluggable LoRA adapters.
|
| 2 |
+
|
| 3 |
+
Tabs:
|
| 4 |
+
Classify - pick a task (ImageNet-1k base or any loaded LoRA adapter)
|
| 5 |
+
and get top-k predictions with a confidence threshold.
|
| 6 |
+
Compare: Base vs - side-by-side predictions from the same backbone with
|
| 7 |
+
LoRA the adapter disabled vs enabled. Makes it obvious what
|
| 8 |
+
LoRA added without touching the pretrained weights.
|
| 9 |
+
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 36 |
|
| 37 |
import gradio as gr
|
| 38 |
import torch
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
from adapters import BASE_TASK_NAME, AdapterRegistry
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
registry = AdapterRegistry()
|
| 44 |
+
model = registry.model
|
| 45 |
+
processor = registry.processor
|
| 46 |
+
DEVICE = registry.device
|
| 47 |
|
| 48 |
+
TASKS = registry.available_tasks()
|
| 49 |
+
TASK_CHOICES = [(t.display_name, t.key) for t in TASKS]
|
| 50 |
+
ADAPTER_TASKS = [t for t in TASKS if not t.is_base]
|
| 51 |
+
ADAPTER_CHOICES = [(t.display_name, t.key) for t in ADAPTER_TASKS]
|
| 52 |
|
| 53 |
EXAMPLES_DIR = Path(__file__).parent / "examples"
|
| 54 |
EXAMPLE_IMAGES = sorted(str(p) for p in EXAMPLES_DIR.glob("*.jpg")) if EXAMPLES_DIR.exists() else []
|
| 55 |
|
| 56 |
|
| 57 |
+
def _to_inputs(image):
|
|
|
|
|
|
|
|
|
|
| 58 |
if image.mode != "RGB":
|
| 59 |
image = image.convert("RGB")
|
| 60 |
+
return processor(images=image, return_tensors="pt").to(DEVICE)
|
| 61 |
+
|
| 62 |
|
| 63 |
+
@torch.inference_mode()
|
| 64 |
+
def classify(image, task_key, top_k, threshold):
|
| 65 |
+
if image is None:
|
| 66 |
+
return {}
|
| 67 |
+
inputs = _to_inputs(image)
|
| 68 |
+
with registry.use_task(task_key):
|
| 69 |
+
logits = model(**inputs).logits[0]
|
| 70 |
probs = torch.softmax(logits, dim=-1)
|
| 71 |
|
| 72 |
+
id2label = registry.get_id2label(task_key)
|
| 73 |
k = max(1, min(int(top_k), probs.shape[-1]))
|
| 74 |
top = torch.topk(probs, k=k)
|
| 75 |
return {
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
|
| 82 |
+
def classify_compare(image, adapter_key, top_k, threshold):
|
| 83 |
+
if image is None or not adapter_key:
|
| 84 |
+
return {}, {}
|
| 85 |
+
base = classify(image, BASE_TASK_NAME, top_k, threshold)
|
| 86 |
+
adapter = classify(image, adapter_key, top_k, threshold)
|
| 87 |
+
return base, adapter
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
default_task = ADAPTER_CHOICES[0][1] if ADAPTER_CHOICES else BASE_TASK_NAME
|
| 91 |
+
adapter_names_loaded = [a.display_name for a in ADAPTER_TASKS]
|
| 92 |
+
|
| 93 |
+
with gr.Blocks(title="ViT + LoRA image classifier", theme=gr.themes.Soft()) as demo:
|
| 94 |
gr.Markdown(
|
| 95 |
f"""
|
| 96 |
+
# ViT image classifier with swappable LoRA adapters
|
| 97 |
|
| 98 |
+
Backbone: [`{registry.base_model_id}`](https://huggingface.co/{registry.base_model_id}) (ViT-tiny, ~5.7M params).
|
| 99 |
+
Device: **{DEVICE.upper()}**.
|
| 100 |
+
Loaded adapters: **{', '.join(adapter_names_loaded) if adapter_names_loaded else 'none (base model only)'}**.
|
| 101 |
|
| 102 |
+
Pick a task on the left to run top-k classification. The base weights
|
| 103 |
+
are never mutated: the adapter adds a low-rank \(\\Delta W\) and a new
|
| 104 |
+
classification head, toggled on/off per request.
|
| 105 |
"""
|
| 106 |
)
|
| 107 |
|
| 108 |
+
with gr.Tabs():
|
| 109 |
+
with gr.Tab("Classify"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
with gr.Row():
|
| 111 |
+
with gr.Column(scale=1):
|
| 112 |
+
image = gr.Image(
|
| 113 |
+
type="pil", label="Input image",
|
| 114 |
+
sources=["upload", "clipboard", "webcam"], height=360,
|
| 115 |
+
)
|
| 116 |
+
task = gr.Radio(
|
| 117 |
+
choices=TASK_CHOICES, value=BASE_TASK_NAME,
|
| 118 |
+
label="Task", info="Switch between the base model and any loaded LoRA adapter.",
|
| 119 |
+
)
|
| 120 |
+
with gr.Row():
|
| 121 |
+
top_k = gr.Slider(
|
| 122 |
+
minimum=1, maximum=10, value=5, step=1,
|
| 123 |
+
label="Top-k", info="Number of predictions to show",
|
| 124 |
+
)
|
| 125 |
+
threshold = gr.Slider(
|
| 126 |
+
minimum=0.0, maximum=1.0, value=0.0, step=0.01,
|
| 127 |
+
label="Confidence threshold",
|
| 128 |
+
info="Hide predictions below this probability",
|
| 129 |
+
)
|
| 130 |
+
submit = gr.Button("Classify", variant="primary")
|
| 131 |
+
|
| 132 |
+
with gr.Column(scale=1):
|
| 133 |
+
output = gr.Label(num_top_classes=10, label="Predictions")
|
| 134 |
+
|
| 135 |
+
if EXAMPLE_IMAGES:
|
| 136 |
+
gr.Examples(
|
| 137 |
+
examples=[[p, BASE_TASK_NAME, 5, 0.0] for p in EXAMPLE_IMAGES],
|
| 138 |
+
inputs=[image, task, top_k, threshold],
|
| 139 |
+
outputs=output,
|
| 140 |
+
fn=classify,
|
| 141 |
+
cache_examples=False,
|
| 142 |
+
label="Example images",
|
| 143 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
classify_args = dict(fn=classify, inputs=[image, task, top_k, threshold], outputs=output)
|
| 146 |
+
submit.click(**classify_args)
|
| 147 |
+
image.change(**classify_args)
|
| 148 |
+
task.change(**classify_args)
|
| 149 |
+
top_k.change(**classify_args)
|
| 150 |
+
threshold.change(**classify_args)
|
| 151 |
+
|
| 152 |
+
if ADAPTER_TASKS:
|
| 153 |
+
with gr.Tab("Compare: Base vs LoRA"):
|
| 154 |
+
gr.Markdown(
|
| 155 |
+
"Runs the **same image** through the shared backbone twice: "
|
| 156 |
+
"once with the LoRA adapter disabled (original ImageNet-1k head) "
|
| 157 |
+
"and once with the selected adapter enabled."
|
| 158 |
+
)
|
| 159 |
+
with gr.Row():
|
| 160 |
+
with gr.Column(scale=1):
|
| 161 |
+
image2 = gr.Image(
|
| 162 |
+
type="pil", label="Input image",
|
| 163 |
+
sources=["upload", "clipboard", "webcam"], height=360,
|
| 164 |
+
)
|
| 165 |
+
adapter_sel = gr.Dropdown(
|
| 166 |
+
choices=ADAPTER_CHOICES, value=default_task,
|
| 167 |
+
label="Adapter", interactive=True,
|
| 168 |
+
)
|
| 169 |
+
with gr.Row():
|
| 170 |
+
top_k2 = gr.Slider(
|
| 171 |
+
minimum=1, maximum=10, value=5, step=1, label="Top-k",
|
| 172 |
+
)
|
| 173 |
+
threshold2 = gr.Slider(
|
| 174 |
+
minimum=0.0, maximum=1.0, value=0.0, step=0.01,
|
| 175 |
+
label="Confidence threshold",
|
| 176 |
+
)
|
| 177 |
+
submit2 = gr.Button("Compare", variant="primary")
|
| 178 |
+
|
| 179 |
+
with gr.Row():
|
| 180 |
+
base_out = gr.Label(num_top_classes=10, label="Base (ImageNet-1k)")
|
| 181 |
+
adapter_out = gr.Label(num_top_classes=10, label="Adapter prediction")
|
| 182 |
+
|
| 183 |
+
if EXAMPLE_IMAGES:
|
| 184 |
+
gr.Examples(
|
| 185 |
+
examples=[[p, default_task, 5, 0.0] for p in EXAMPLE_IMAGES],
|
| 186 |
+
inputs=[image2, adapter_sel, top_k2, threshold2],
|
| 187 |
+
outputs=[base_out, adapter_out],
|
| 188 |
+
fn=classify_compare,
|
| 189 |
+
cache_examples=False,
|
| 190 |
+
label="Example images",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
compare_args = dict(
|
| 194 |
+
fn=classify_compare,
|
| 195 |
+
inputs=[image2, adapter_sel, top_k2, threshold2],
|
| 196 |
+
outputs=[base_out, adapter_out],
|
| 197 |
+
)
|
| 198 |
+
submit2.click(**compare_args)
|
| 199 |
+
image2.change(**compare_args)
|
| 200 |
+
adapter_sel.change(**compare_args)
|
| 201 |
+
top_k2.change(**compare_args)
|
| 202 |
+
threshold2.change(**compare_args)
|
| 203 |
+
else:
|
| 204 |
+
with gr.Tab("Compare: Base vs LoRA"):
|
| 205 |
+
gr.Markdown(
|
| 206 |
+
"_No LoRA adapters are currently loaded._\n\n"
|
| 207 |
+
"Train one with `python train_lora.py --push-to-hub <user>/<repo>` "
|
| 208 |
+
"and register it in `adapters.py`."
|
| 209 |
+
)
|
| 210 |
|
| 211 |
|
| 212 |
if __name__ == "__main__":
|
| 213 |
demo.launch(
|
| 214 |
+
server_name="0.0.0.0",
|
| 215 |
+
server_port=7860,
|
| 216 |
+
show_api=False,
|
| 217 |
+
share=False,
|
| 218 |
)
|
requirements.txt
CHANGED
|
@@ -2,3 +2,7 @@ gradio>=5.0.0
|
|
| 2 |
transformers>=4.44.0
|
| 3 |
torch>=2.2.0
|
| 4 |
Pillow>=10.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
transformers>=4.44.0
|
| 3 |
torch>=2.2.0
|
| 4 |
Pillow>=10.0.0
|
| 5 |
+
peft>=0.13.0
|
| 6 |
+
datasets>=2.18.0
|
| 7 |
+
accelerate>=0.30.0
|
| 8 |
+
scikit-learn
|
train_lora.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fine-tune a LoRA adapter on Food-101 for a ViT image classifier.
|
| 2 |
+
|
| 3 |
+
Preserves the original pretrained weights (LoRA is additive) and saves the
|
| 4 |
+
adapter + the new classification head as a single PEFT-format artifact.
|
| 5 |
+
|
| 6 |
+
Example:
|
| 7 |
+
|
| 8 |
+
python train_lora.py \\
|
| 9 |
+
--rank 8 --alpha 16 --target-modules query value \\
|
| 10 |
+
--epochs 5 --batch-size 64 --lr 5e-4 \\
|
| 11 |
+
--push-to-hub turhancan97/vit-tiny-lora-food101
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
from dataclasses import asdict, dataclass
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
from datasets import load_dataset
|
| 25 |
+
from peft import LoraConfig, get_peft_model
|
| 26 |
+
from PIL import Image
|
| 27 |
+
from torchvision import transforms
|
| 28 |
+
from transformers import (
|
| 29 |
+
AutoImageProcessor,
|
| 30 |
+
AutoModelForImageClassification,
|
| 31 |
+
Trainer,
|
| 32 |
+
TrainingArguments,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class Args:
|
| 38 |
+
model_id: str
|
| 39 |
+
dataset_id: str
|
| 40 |
+
output_dir: str
|
| 41 |
+
rank: int
|
| 42 |
+
alpha: int
|
| 43 |
+
dropout: float
|
| 44 |
+
target_modules: list[str]
|
| 45 |
+
lr: float
|
| 46 |
+
batch_size: int
|
| 47 |
+
eval_batch_size: int
|
| 48 |
+
epochs: int
|
| 49 |
+
warmup_ratio: float
|
| 50 |
+
weight_decay: float
|
| 51 |
+
seed: int
|
| 52 |
+
push_to_hub: str | None
|
| 53 |
+
max_train_samples: int | None
|
| 54 |
+
max_eval_samples: int | None
|
| 55 |
+
eval_only: bool
|
| 56 |
+
num_workers: int
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def parse_args() -> Args:
|
| 60 |
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 61 |
+
p.add_argument("--model-id", default="WinKawaks/vit-tiny-patch16-224")
|
| 62 |
+
p.add_argument("--dataset-id", default="food101")
|
| 63 |
+
p.add_argument("--output-dir", default="adapters/vit-tiny-lora-food101")
|
| 64 |
+
p.add_argument("--rank", type=int, default=8)
|
| 65 |
+
p.add_argument("--alpha", type=int, default=16)
|
| 66 |
+
p.add_argument("--dropout", type=float, default=0.1)
|
| 67 |
+
p.add_argument(
|
| 68 |
+
"--target-modules", nargs="+", default=["query", "value"],
|
| 69 |
+
help="Substring patterns matched against module names for LoRA injection.",
|
| 70 |
+
)
|
| 71 |
+
p.add_argument("--lr", type=float, default=5e-4)
|
| 72 |
+
p.add_argument("--batch-size", type=int, default=64)
|
| 73 |
+
p.add_argument("--eval-batch-size", type=int, default=128)
|
| 74 |
+
p.add_argument("--epochs", type=int, default=1)
|
| 75 |
+
p.add_argument("--warmup-ratio", type=float, default=0.03)
|
| 76 |
+
p.add_argument("--weight-decay", type=float, default=0.0)
|
| 77 |
+
p.add_argument("--seed", type=int, default=42)
|
| 78 |
+
p.add_argument("--push-to-hub", default=None, help="e.g. 'user/vit-tiny-lora-food101'")
|
| 79 |
+
p.add_argument("--max-train-samples", type=int, default=None, help="Smoke-test subset size.")
|
| 80 |
+
p.add_argument("--max-eval-samples", type=int, default=None)
|
| 81 |
+
p.add_argument("--eval-only", action="store_true")
|
| 82 |
+
p.add_argument("--num-workers", type=int, default=4)
|
| 83 |
+
ns = p.parse_args()
|
| 84 |
+
return Args(**{k.replace("-", "_"): v for k, v in vars(ns).items()})
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def build_transforms(processor: AutoImageProcessor):
|
| 88 |
+
size = processor.size.get("height") or processor.size.get("shortest_edge") or 224
|
| 89 |
+
mean = processor.image_mean
|
| 90 |
+
std = processor.image_std
|
| 91 |
+
|
| 92 |
+
train_tf = transforms.Compose([
|
| 93 |
+
transforms.RandomResizedCrop(size, scale=(0.8, 1.0)),
|
| 94 |
+
transforms.RandomHorizontalFlip(),
|
| 95 |
+
transforms.ToTensor(),
|
| 96 |
+
transforms.Normalize(mean=mean, std=std),
|
| 97 |
+
])
|
| 98 |
+
eval_tf = transforms.Compose([
|
| 99 |
+
transforms.Resize(int(size * 256 / 224)),
|
| 100 |
+
transforms.CenterCrop(size),
|
| 101 |
+
transforms.ToTensor(),
|
| 102 |
+
transforms.Normalize(mean=mean, std=std),
|
| 103 |
+
])
|
| 104 |
+
return train_tf, eval_tf
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _ensure_rgb(img):
|
| 108 |
+
if isinstance(img, Image.Image):
|
| 109 |
+
return img.convert("RGB") if img.mode != "RGB" else img
|
| 110 |
+
return Image.fromarray(np.asarray(img)).convert("RGB")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def make_transform_fn(tf):
|
| 114 |
+
def _apply(batch):
|
| 115 |
+
batch["pixel_values"] = [tf(_ensure_rgb(img)) for img in batch["image"]]
|
| 116 |
+
return batch
|
| 117 |
+
return _apply
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def collate_fn(examples):
|
| 121 |
+
pixel_values = torch.stack([ex["pixel_values"] for ex in examples])
|
| 122 |
+
labels = torch.tensor([ex["label"] for ex in examples], dtype=torch.long)
|
| 123 |
+
return {"pixel_values": pixel_values, "labels": labels}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def compute_metrics_topk(eval_pred):
|
| 127 |
+
logits, labels = eval_pred
|
| 128 |
+
logits = torch.as_tensor(logits)
|
| 129 |
+
labels = torch.as_tensor(labels)
|
| 130 |
+
top1 = (logits.argmax(dim=-1) == labels).float().mean().item()
|
| 131 |
+
k = min(5, logits.shape[-1])
|
| 132 |
+
topk = logits.topk(k=k, dim=-1).indices
|
| 133 |
+
top5 = (topk == labels.unsqueeze(-1)).any(dim=-1).float().mean().item()
|
| 134 |
+
return {"top1_accuracy": top1, "top5_accuracy": top5}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main():
|
| 138 |
+
args = parse_args()
|
| 139 |
+
torch.manual_seed(args.seed)
|
| 140 |
+
np.random.seed(args.seed)
|
| 141 |
+
|
| 142 |
+
output_dir = Path(args.output_dir)
|
| 143 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 144 |
+
|
| 145 |
+
print(f"[1/5] Loading dataset: {args.dataset_id}")
|
| 146 |
+
ds = load_dataset(args.dataset_id)
|
| 147 |
+
train_split = "train" if "train" in ds else list(ds.keys())[0]
|
| 148 |
+
eval_split = "validation" if "validation" in ds else ("test" if "test" in ds else train_split)
|
| 149 |
+
train_ds = ds[train_split]
|
| 150 |
+
eval_ds = ds[eval_split]
|
| 151 |
+
|
| 152 |
+
label_feature = train_ds.features["label"]
|
| 153 |
+
num_labels = label_feature.num_classes
|
| 154 |
+
id2label = {i: label_feature.int2str(i) for i in range(num_labels)}
|
| 155 |
+
label2id = {v: k for k, v in id2label.items()}
|
| 156 |
+
print(f" train={len(train_ds)} eval={len(eval_ds)} num_labels={num_labels}")
|
| 157 |
+
|
| 158 |
+
if args.max_train_samples:
|
| 159 |
+
train_ds = train_ds.shuffle(seed=args.seed).select(range(min(args.max_train_samples, len(train_ds))))
|
| 160 |
+
if args.max_eval_samples:
|
| 161 |
+
eval_ds = eval_ds.shuffle(seed=args.seed).select(range(min(args.max_eval_samples, len(eval_ds))))
|
| 162 |
+
|
| 163 |
+
print(f"[2/5] Loading base model: {args.model_id}")
|
| 164 |
+
processor = AutoImageProcessor.from_pretrained(args.model_id, use_fast=True)
|
| 165 |
+
base_model = AutoModelForImageClassification.from_pretrained(
|
| 166 |
+
args.model_id,
|
| 167 |
+
num_labels=num_labels,
|
| 168 |
+
id2label=id2label,
|
| 169 |
+
label2id=label2id,
|
| 170 |
+
ignore_mismatched_sizes=True,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
train_tf, eval_tf = build_transforms(processor)
|
| 174 |
+
train_ds.set_transform(make_transform_fn(train_tf))
|
| 175 |
+
eval_ds.set_transform(make_transform_fn(eval_tf))
|
| 176 |
+
|
| 177 |
+
print(f"[3/5] Wrapping with LoRA: rank={args.rank}, alpha={args.alpha}, "
|
| 178 |
+
f"target_modules={args.target_modules}")
|
| 179 |
+
lora_cfg = LoraConfig(
|
| 180 |
+
r=args.rank,
|
| 181 |
+
lora_alpha=args.alpha,
|
| 182 |
+
lora_dropout=args.dropout,
|
| 183 |
+
target_modules=list(args.target_modules),
|
| 184 |
+
bias="none",
|
| 185 |
+
)
|
| 186 |
+
model = get_peft_model(base_model, lora_cfg)
|
| 187 |
+
# PEFT freezes every non-LoRA parameter by default. Unfreeze the classifier
|
| 188 |
+
# so the new task head can be trained. We save it separately after training
|
| 189 |
+
# (rather than via `modules_to_save`) so the adapter artifact stays portable
|
| 190 |
+
# across base models with different original head sizes.
|
| 191 |
+
classifier = model.base_model.model.classifier
|
| 192 |
+
for p in classifier.parameters():
|
| 193 |
+
p.requires_grad_(True)
|
| 194 |
+
trainable, total = model.get_nb_trainable_parameters()
|
| 195 |
+
print(f" trainable params: {trainable:,} / {total:,} ({100 * trainable / total:.2f}%)")
|
| 196 |
+
|
| 197 |
+
training_args = TrainingArguments(
|
| 198 |
+
output_dir=str(output_dir / "trainer"),
|
| 199 |
+
per_device_train_batch_size=args.batch_size,
|
| 200 |
+
per_device_eval_batch_size=args.eval_batch_size,
|
| 201 |
+
learning_rate=args.lr,
|
| 202 |
+
num_train_epochs=args.epochs,
|
| 203 |
+
warmup_ratio=args.warmup_ratio,
|
| 204 |
+
weight_decay=args.weight_decay,
|
| 205 |
+
eval_strategy="epoch",
|
| 206 |
+
save_strategy="epoch",
|
| 207 |
+
save_total_limit=1,
|
| 208 |
+
load_best_model_at_end=True,
|
| 209 |
+
metric_for_best_model="top1_accuracy",
|
| 210 |
+
greater_is_better=True,
|
| 211 |
+
logging_strategy="steps",
|
| 212 |
+
logging_steps=25,
|
| 213 |
+
fp16=torch.cuda.is_available(),
|
| 214 |
+
dataloader_num_workers=args.num_workers,
|
| 215 |
+
remove_unused_columns=False,
|
| 216 |
+
report_to="none",
|
| 217 |
+
seed=args.seed,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
trainer = Trainer(
|
| 221 |
+
model=model,
|
| 222 |
+
args=training_args,
|
| 223 |
+
train_dataset=train_ds,
|
| 224 |
+
eval_dataset=eval_ds,
|
| 225 |
+
data_collator=collate_fn,
|
| 226 |
+
compute_metrics=compute_metrics_topk,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
if not args.eval_only:
|
| 230 |
+
print("[4/5] Training")
|
| 231 |
+
trainer.train()
|
| 232 |
+
else:
|
| 233 |
+
print("[4/5] Skipping training (--eval-only)")
|
| 234 |
+
|
| 235 |
+
print("[5/5] Evaluating on held-out split")
|
| 236 |
+
metrics = trainer.evaluate()
|
| 237 |
+
metrics["eval_samples"] = len(eval_ds)
|
| 238 |
+
print(json.dumps(metrics, indent=2))
|
| 239 |
+
(output_dir / "eval_metrics.json").write_text(json.dumps(metrics, indent=2))
|
| 240 |
+
|
| 241 |
+
print(f"Saving adapter to {output_dir}")
|
| 242 |
+
model.save_pretrained(str(output_dir))
|
| 243 |
+
processor.save_pretrained(str(output_dir))
|
| 244 |
+
(output_dir / "train_args.json").write_text(json.dumps(asdict(args), indent=2))
|
| 245 |
+
(output_dir / "labels.json").write_text(
|
| 246 |
+
json.dumps({str(i): id2label[i] for i in range(num_labels)}, indent=2)
|
| 247 |
+
)
|
| 248 |
+
torch.save(
|
| 249 |
+
{k: v.detach().cpu() for k, v in classifier.state_dict().items()},
|
| 250 |
+
output_dir / "classifier.pt",
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
if args.push_to_hub:
|
| 254 |
+
print(f"Pushing to Hugging Face Hub: {args.push_to_hub}")
|
| 255 |
+
model.push_to_hub(args.push_to_hub)
|
| 256 |
+
processor.push_to_hub(args.push_to_hub)
|
| 257 |
+
try:
|
| 258 |
+
from huggingface_hub import HfApi
|
| 259 |
+
api = HfApi()
|
| 260 |
+
for extra in ["labels.json", "classifier.pt"]:
|
| 261 |
+
api.upload_file(
|
| 262 |
+
path_or_fileobj=str(output_dir / extra),
|
| 263 |
+
path_in_repo=extra,
|
| 264 |
+
repo_id=args.push_to_hub,
|
| 265 |
+
repo_type="model",
|
| 266 |
+
commit_message=f"add {extra}",
|
| 267 |
+
)
|
| 268 |
+
except Exception as exc:
|
| 269 |
+
print(f"Warning: could not upload side-car files: {exc}")
|
| 270 |
+
|
| 271 |
+
print("Done.")
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
main()
|