turhancan97 commited on
Commit
10af6f1
·
verified ·
1 Parent(s): 722aeb9

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +36 -0
  2. adapters.py +209 -0
  3. app.py +153 -56
  4. requirements.txt +4 -0
  5. 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: Tiny ViT (ImageNet-1k) image classification with top-k + threshold."""
 
 
 
 
 
 
 
 
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
- MODEL_ID = "WinKawaks/vit-tiny-patch16-224"
35
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
 
 
 
 
 
36
 
37
- processor = AutoImageProcessor.from_pretrained(MODEL_ID)
38
- model = AutoModelForImageClassification.from_pretrained(MODEL_ID).to(DEVICE).eval()
39
- id2label = model.config.id2label
 
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
- @torch.inference_mode()
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
- inputs = processor(images=image, return_tensors="pt").to(DEVICE)
53
- logits = model(**inputs).logits[0]
 
 
 
 
 
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
- with gr.Blocks(title="Tiny ViT — ImageNet-1k classifier", theme=gr.themes.Soft()) as demo:
 
 
 
 
 
 
 
 
 
 
 
66
  gr.Markdown(
67
  f"""
68
- # Tiny ViT ImageNet-1k classifier
69
 
70
- Upload an image and get top-k predictions from
71
- [`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}), a tiny Vision Transformer
72
- (~5.7M params) pretrained on ImageNet-1k.
73
 
74
- Running on **{DEVICE.upper()}**.
 
 
75
  """
76
  )
77
 
78
- with gr.Row():
79
- with gr.Column(scale=1):
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
- top_k = gr.Slider(
88
- minimum=1, maximum=10, value=5, step=1,
89
- label="Top-k", info="Number of predictions to show",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if EXAMPLE_IMAGES:
102
- gr.Examples(
103
- examples=[[p, 5, 0.0] for p in EXAMPLE_IMAGES],
104
- inputs=[image, top_k, threshold],
105
- outputs=output,
106
- fn=classify,
107
- cache_examples=False,
108
- label="Example images",
109
- )
110
-
111
- submit.click(fn=classify, inputs=[image, top_k, threshold], outputs=output)
112
- image.change(fn=classify, inputs=[image, top_k, threshold], outputs=output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
 
115
  if __name__ == "__main__":
116
  demo.launch(
117
- # server_name="0.0.0.0",
118
- # server_port=7860,
119
- # show_api=False,
120
- # share=False,
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()