File size: 7,180 Bytes
10af6f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
"""Registry of LoRA adapters for the Gradio demo.

Loads the pretrained base model once and wraps it with a `PeftModel` that
holds one or more named LoRA adapters. Each adapter also carries its own
classification head (saved separately as ``classifier.pt`` at training time),
so the demo can swap *both* the adapter and the head per request.

Inference modes:
    * Base (ImageNet-1k): ``model.disable_adapter()`` + original head.
    * Adapter N:          ``model.set_adapter(name)`` + the adapter's head.
"""

from __future__ import annotations

import copy
import json
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable

import torch
from torch import nn
from transformers import AutoImageProcessor, AutoModelForImageClassification


BASE_MODEL_ID = "WinKawaks/vit-tiny-patch16-224"
BASE_TASK_NAME = "imagenet"
BASE_TASK_LABEL = "ImageNet-1k (base)"


@dataclass
class AdapterSpec:
    name: str
    repo_id: str
    display_name: str
    description: str = ""
    id2label: dict[int, str] = field(default_factory=dict)


ADAPTERS: list[AdapterSpec] = [
    AdapterSpec(
        name="food101",
        repo_id="turhancan97/vit-tiny-lora-food101",
        display_name="Food-101 (LoRA)",
        description="Fine-tuned LoRA adapter on the Food-101 dataset (101 classes).",
    ),
]


@dataclass
class TaskInfo:
    key: str
    display_name: str
    id2label: dict[int, str]
    is_base: bool


def _fetch_file(repo_id: str, filename: str) -> Path:
    local = Path(repo_id)
    if local.exists() and (local / filename).exists():
        return local / filename
    from huggingface_hub import hf_hub_download

    return Path(hf_hub_download(repo_id=repo_id, filename=filename))


class AdapterRegistry:
    """Loads the base model plus any registered adapters lazily and on demand."""

    def __init__(
        self,
        adapters: Iterable[AdapterSpec] | None = None,
        base_model_id: str = BASE_MODEL_ID,
        device: str | None = None,
    ) -> None:
        if adapters is None:
            adapters = ADAPTERS
        self.base_model_id = base_model_id
        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")

        self.processor = AutoImageProcessor.from_pretrained(base_model_id, use_fast=True)
        self.base_model = AutoModelForImageClassification.from_pretrained(base_model_id)
        self.base_id2label: dict[int, str] = dict(self.base_model.config.id2label)

        self._original_classifier = copy.deepcopy(self.base_model.classifier)

        self._model = None
        self._loaded: dict[str, AdapterSpec] = {}
        self._classifiers: dict[str, nn.Module] = {}
        self._current_adapter: str | None = None

        for spec in adapters:
            self._try_load_adapter(spec)

        if self._model is None:
            self._model = self.base_model
        self._model.to(self.device).eval()
        self._original_classifier.to(self.device).eval()
        for head in self._classifiers.values():
            head.to(self.device).eval()

    @property
    def model(self):
        return self._model

    def available_tasks(self) -> list[TaskInfo]:
        tasks = [TaskInfo(
            key=BASE_TASK_NAME,
            display_name=BASE_TASK_LABEL,
            id2label=self.base_id2label,
            is_base=True,
        )]
        for spec in self._loaded.values():
            tasks.append(TaskInfo(
                key=spec.name,
                display_name=spec.display_name,
                id2label=spec.id2label,
                is_base=False,
            ))
        return tasks

    def adapter_task_keys(self) -> list[str]:
        return list(self._loaded.keys())

    def get_id2label(self, task_key: str) -> dict[int, str]:
        if task_key == BASE_TASK_NAME:
            return self.base_id2label
        return self._loaded[task_key].id2label

    def _classifier_host(self) -> nn.Module:
        """Return the module that actually *owns* the `classifier` submodule.

        `PeftModel` exposes `.classifier` via attribute proxying on top of
        `base_model.model.classifier`, but only the latter is what gets called
        during forward. Traverse until we find a module where `classifier` is
        a registered child (present in `_modules`).
        """
        visited: set[int] = set()
        m = self._model
        while id(m) not in visited:
            visited.add(id(m))
            if "classifier" in getattr(m, "_modules", {}):
                return m
            inner = getattr(m, "model", None) or getattr(m, "base_model", None)
            if inner is None or inner is m:
                break
            m = inner
        return self.base_model

    def _active_classifier(self) -> nn.Module:
        return self._classifier_host().classifier

    def _set_classifier(self, head: nn.Module) -> None:
        self._classifier_host().classifier = head

    @contextmanager
    def use_task(self, task_key: str):
        """Activate the right (adapter, head) pair for a single forward pass."""
        prev_head = self._active_classifier()
        if task_key == BASE_TASK_NAME:
            self._set_classifier(self._original_classifier)
            ctx = self._model.disable_adapter() if hasattr(self._model, "disable_adapter") else _null_ctx()
        else:
            spec = self._loaded[task_key]
            self._set_classifier(self._classifiers[spec.name])
            if hasattr(self._model, "set_adapter") and self._current_adapter != task_key:
                self._model.set_adapter(task_key)
                self._current_adapter = task_key
            ctx = _null_ctx()

        try:
            with ctx:
                yield
        finally:
            self._set_classifier(prev_head)

    def _try_load_adapter(self, spec: AdapterSpec) -> None:
        try:
            labels_path = _fetch_file(spec.repo_id, "labels.json")
            spec.id2label = {int(k): v for k, v in json.loads(labels_path.read_text()).items()}

            classifier_path = _fetch_file(spec.repo_id, "classifier.pt")
            head_state = torch.load(classifier_path, map_location="cpu", weights_only=True)
            hidden_size = self.base_model.config.hidden_size
            head = nn.Linear(hidden_size, len(spec.id2label))
            head.load_state_dict(head_state)

            from peft import PeftModel

            if self._model is None or not hasattr(self._model, "load_adapter"):
                self._model = PeftModel.from_pretrained(
                    self.base_model, spec.repo_id, adapter_name=spec.name,
                )
            else:
                self._model.load_adapter(spec.repo_id, adapter_name=spec.name)

            self._classifiers[spec.name] = head
            self._loaded[spec.name] = spec
            print(f"[adapters] loaded '{spec.name}' from {spec.repo_id} "
                  f"({len(spec.id2label)} classes)")
        except Exception as exc:
            print(f"[adapters] could not load '{spec.name}' from {spec.repo_id}: "
                  f"{type(exc).__name__}: {exc}")


@contextmanager
def _null_ctx():
    yield