Spaces:
Running on Zero
Running on Zero
Run on ZeroGPU: add @spaces.GPU entry point
Browse files- DEPLOY.md +42 -34
- README.md +6 -1
- app.py +66 -24
- requirements.txt +4 -5
- test_local.py +44 -15
DEPLOY.md
CHANGED
|
@@ -1,58 +1,66 @@
|
|
| 1 |
-
# Deploying
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
|
| 12 |
-
## 2.
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## 3. Push
|
| 21 |
|
| 22 |
```bash
|
| 23 |
-
cd /
|
| 24 |
-
|
| 25 |
-
git init
|
| 26 |
-
git remote add origin https://huggingface.co/spaces/LukeFP/physh-topic-classifier
|
| 27 |
-
git add app.py requirements.txt README.md .gitignore DEPLOY.md test_local.py
|
| 28 |
-
git commit -m "Gradio app for PhySH discipline + concept classification"
|
| 29 |
git push origin main
|
| 30 |
```
|
| 31 |
|
| 32 |
-
|
| 33 |
-
first — the frontmatter in this README is the one you want to keep, since it
|
| 34 |
-
carries the `sdk_version` and `app_file` settings.
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
Open the Space and run one of the built-in examples. Things to look at:
|
| 41 |
-
|
| 42 |
-
- **Predictions look like noise / nothing clears the threshold.** Almost
|
| 43 |
certainly the embedding prompt. Open *Advanced* and try the other two formats;
|
| 44 |
-
the one matching your training pipeline
|
| 45 |
Once you know which, set `DEFAULT_PROMPT` at the top of `app.py`.
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
## Local smoke test
|
| 52 |
|
| 53 |
Runs the real checkpoints through the full chain with a stubbed embedder, so it
|
| 54 |
-
needs no token and no download:
|
| 55 |
|
| 56 |
```bash
|
| 57 |
-
python test_local.py
|
| 58 |
```
|
|
|
|
| 1 |
+
# Deploying to LukeFP/Physh_Classification
|
| 2 |
|
| 3 |
+
The Space repo lives at `~/code/2026.7/Physh_Classification`.
|
| 4 |
|
| 5 |
+
## 1. Add the token secret
|
| 6 |
|
| 7 |
+
`google/embeddinggemma-300m` is gated. Accept the Gemma license while signed in,
|
| 8 |
+
create a **read** token, then on the Space page: Settings → *Variables and
|
| 9 |
+
secrets* → **New secret**, name `HF_TOKEN`, value the token. Without it the Space
|
| 10 |
+
boots fine but the first classification fails with a 401.
|
| 11 |
|
| 12 |
+
## 2. Hardware
|
| 13 |
|
| 14 |
+
On the free tier, Gradio Spaces run on **ZeroGPU**, which stops the container at
|
| 15 |
+
startup unless it finds at least one `@spaces.GPU` function — the
|
| 16 |
+
`No @spaces.GPU function detected during startup` error. `infer()` in `app.py`
|
| 17 |
+
carries that decorator, so ZeroGPU is satisfied.
|
| 18 |
+
|
| 19 |
+
Constraints ZeroGPU imposes, and how `app.py` meets them:
|
| 20 |
|
| 21 |
+
| Constraint | Handling |
|
| 22 |
+
|---|---|
|
| 23 |
+
| `import spaces` must precede `import torch` | It is the first import in `app.py` |
|
| 24 |
+
| Nothing may touch CUDA outside a `@GPU` function | Models load with `device="cpu"`; `.to(device)` happens inside `infer()` |
|
| 25 |
+
| Return values cross a process boundary | `infer()` returns plain `list[float]`, never CUDA tensors |
|
| 26 |
+
| One GPU allocation per call, with a duration budget | `@GPU(duration=60)`; the model is already resident, so only the encode runs |
|
| 27 |
+
|
| 28 |
+
CPU basic (a PRO perk) also works with this code unchanged — `spaces` is an
|
| 29 |
+
optional import and the device is chosen from `torch.cuda.is_available()`.
|
| 30 |
|
| 31 |
## 3. Push
|
| 32 |
|
| 33 |
```bash
|
| 34 |
+
cd ~/code/2026.7/Physh_Classification
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
git push origin main
|
| 36 |
```
|
| 37 |
|
| 38 |
+
The build takes a few minutes, most of it `pip install torch`.
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
## 4. First checks
|
| 41 |
|
| 42 |
+
- **Predictions look like noise, or nothing clears the threshold.** Almost
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
certainly the embedding prompt. Open *Advanced* and try the other two formats;
|
| 44 |
+
the one matching your training pipeline gives confident, coherent labels.
|
| 45 |
Once you know which, set `DEFAULT_PROMPT` at the top of `app.py`.
|
| 46 |
+
(`~/code/2026/embedding_title_abstract` likely has the answer.)
|
| 47 |
+
- **Error mentioning a gated repo, or a 401.** `HF_TOKEN` is missing, wrong, or
|
| 48 |
+
the account behind it hasn't accepted the Gemma license.
|
| 49 |
+
- **First request is slow, later ones fast.** Expected — EmbeddingGemma loads
|
| 50 |
+
lazily on first use so the Space boots quickly. Cached after that.
|
| 51 |
+
|
| 52 |
+
## Updating later
|
| 53 |
+
|
| 54 |
+
Retraining only needs a push to
|
| 55 |
+
[`LukeFP/physh_topic_supervised_classifier`](https://huggingface.co/LukeFP/physh_topic_supervised_classifier);
|
| 56 |
+
the Space picks up new weights on its next restart. Only change this repo if the
|
| 57 |
+
*filenames* change — they're the constants at the top of `app.py`.
|
| 58 |
|
| 59 |
## Local smoke test
|
| 60 |
|
| 61 |
Runs the real checkpoints through the full chain with a stubbed embedder, so it
|
| 62 |
+
needs no token and no model download:
|
| 63 |
|
| 64 |
```bash
|
| 65 |
+
PHYSH_WEIGHTS_DIR=~/code/2026.7/physh_topic_supervised_classifier python test_local.py
|
| 66 |
```
|
README.md
CHANGED
|
@@ -54,6 +54,11 @@ model page, then add a read token as a Space secret named `HF_TOKEN`
|
|
| 54 |
(Settings → Variables and secrets). Without it the Space boots but the first
|
| 55 |
classification fails.
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
### Prompt format
|
| 58 |
|
| 59 |
EmbeddingGemma prepends a task-specific prefix, and the prefix used here must
|
|
@@ -80,7 +85,7 @@ labelling:
|
|
| 80 |
```python
|
| 81 |
from gradio_client import Client
|
| 82 |
|
| 83 |
-
client = Client("LukeFP/
|
| 84 |
disciplines, concepts, summary = client.predict(
|
| 85 |
"Title and abstract…", 0.5, "document — title: none | text: {}", 8,
|
| 86 |
api_name="/classify",
|
|
|
|
| 54 |
(Settings → Variables and secrets). Without it the Space boots but the first
|
| 55 |
classification fails.
|
| 56 |
|
| 57 |
+
This Space runs on **ZeroGPU**: `infer()` carries the `@spaces.GPU` decorator,
|
| 58 |
+
the models are loaded on CPU in the main process, and device placement happens
|
| 59 |
+
inside the decorated function. The same code runs unchanged on CPU hardware —
|
| 60 |
+
`spaces` is optional at import and `torch.cuda.is_available()` picks the device.
|
| 61 |
+
|
| 62 |
### Prompt format
|
| 63 |
|
| 64 |
EmbeddingGemma prepends a task-specific prefix, and the prefix used here must
|
|
|
|
| 85 |
```python
|
| 86 |
from gradio_client import Client
|
| 87 |
|
| 88 |
+
client = Client("LukeFP/Physh_Classification")
|
| 89 |
disciplines, concepts, summary = client.predict(
|
| 90 |
"Title and abstract…", 0.5, "document — title: none | text: {}", 8,
|
| 91 |
api_name="/classify",
|
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
PhySH topic classifier — Gradio Space.
|
| 3 |
|
| 4 |
Pipeline: text ──EmbeddingGemma-300m──> 768-d vector
|
| 5 |
│
|
|
@@ -15,9 +15,24 @@ carry several disciplines and several concepts.
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import functools
|
| 19 |
import os
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import gradio as gr
|
| 22 |
import torch
|
| 23 |
import torch.nn as nn
|
|
@@ -32,8 +47,8 @@ DISCIPLINE_CKPT = "discipline_classifier_gemma_20260130_140842.pt"
|
|
| 32 |
CONCEPT_CKPT = "concept_conditioned_gemma_20260130_140842.pt"
|
| 33 |
EMBED_MODEL = "google/embeddinggemma-300m"
|
| 34 |
|
| 35 |
-
# google/embeddinggemma-300m is
|
| 36 |
-
#
|
| 37 |
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 38 |
|
| 39 |
# Set to a local directory to load the .pt files from disk instead of the Hub.
|
|
@@ -102,14 +117,18 @@ _CONDITION_ORDER = [d["discipline_id"] for d in CONCEPT_CKPT_DATA["discipline_la
|
|
| 102 |
_HEAD_ORDER = [d["discipline_id"] for d in DISCIPLINE_CKPT_DATA["class_labels"]]
|
| 103 |
_REMAP = torch.tensor([_HEAD_ORDER.index(i) for i in _CONDITION_ORDER], dtype=torch.long)
|
| 104 |
|
| 105 |
-
|
| 106 |
-
@
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
| 110 |
from sentence_transformers import SentenceTransformer
|
| 111 |
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
# --------------------------------------------------------------------------- #
|
|
@@ -117,30 +136,53 @@ def get_embedder():
|
|
| 117 |
# --------------------------------------------------------------------------- #
|
| 118 |
|
| 119 |
|
| 120 |
-
@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
def classify(text: str, threshold: float, prompt_choice: str, top_k: int):
|
| 122 |
text = (text or "").strip()
|
| 123 |
if not text:
|
| 124 |
return {}, {}, "Paste some text — a title and abstract work best."
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
# default prefix on top of the one built here.
|
| 129 |
-
vector = get_embedder().encode(prompt.format(text), prompt="", convert_to_numpy=True)
|
| 130 |
-
embedding = torch.as_tensor(vector, dtype=torch.float32).unsqueeze(0)
|
| 131 |
-
|
| 132 |
-
discipline_probs = torch.sigmoid(DISCIPLINE_MODEL(embedding))[0]
|
| 133 |
-
conditioned = torch.cat([embedding, discipline_probs[_REMAP].unsqueeze(0)], dim=1)
|
| 134 |
-
concept_probs = torch.sigmoid(CONCEPT_MODEL(conditioned))[0]
|
| 135 |
|
| 136 |
-
disciplines =
|
| 137 |
-
concepts =
|
| 138 |
|
| 139 |
-
summary = _summarize(disciplines, concepts, threshold)
|
| 140 |
return (
|
| 141 |
dict(sorted(disciplines.items(), key=lambda kv: -kv[1])[:top_k]),
|
| 142 |
dict(sorted(concepts.items(), key=lambda kv: -kv[1])[:top_k]),
|
| 143 |
-
|
| 144 |
)
|
| 145 |
|
| 146 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
PhySH topic classifier — Gradio Space (ZeroGPU).
|
| 3 |
|
| 4 |
Pipeline: text ──EmbeddingGemma-300m──> 768-d vector
|
| 5 |
│
|
|
|
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
|
|
| 18 |
import os
|
| 19 |
|
| 20 |
+
# `spaces` must be imported before torch — it patches CUDA init so the main
|
| 21 |
+
# process stays GPU-free until a @GPU function actually runs. ZeroGPU also scans
|
| 22 |
+
# for at least one decorated function at startup and stops the container if it
|
| 23 |
+
# finds none. The fallback keeps local runs and test_local.py working without it.
|
| 24 |
+
try:
|
| 25 |
+
import spaces
|
| 26 |
+
|
| 27 |
+
GPU = spaces.GPU
|
| 28 |
+
except ImportError: # local development, or CPU hardware
|
| 29 |
+
|
| 30 |
+
def GPU(*dargs, **dkwargs):
|
| 31 |
+
if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
|
| 32 |
+
return dargs[0]
|
| 33 |
+
return lambda fn: fn
|
| 34 |
+
|
| 35 |
+
|
| 36 |
import gradio as gr
|
| 37 |
import torch
|
| 38 |
import torch.nn as nn
|
|
|
|
| 47 |
CONCEPT_CKPT = "concept_conditioned_gemma_20260130_140842.pt"
|
| 48 |
EMBED_MODEL = "google/embeddinggemma-300m"
|
| 49 |
|
| 50 |
+
# google/embeddinggemma-300m is gated: set HF_TOKEN as a Space *secret*, from an
|
| 51 |
+
# account that has accepted the Gemma license. Never commit the token itself.
|
| 52 |
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 53 |
|
| 54 |
# Set to a local directory to load the .pt files from disk instead of the Hub.
|
|
|
|
| 117 |
_HEAD_ORDER = [d["discipline_id"] for d in DISCIPLINE_CKPT_DATA["class_labels"]]
|
| 118 |
_REMAP = torch.tensor([_HEAD_ORDER.index(i) for i in _CONDITION_ORDER], dtype=torch.long)
|
| 119 |
|
| 120 |
+
# EmbeddingGemma is loaded on CPU in the main process — under ZeroGPU nothing may
|
| 121 |
+
# touch CUDA outside a @GPU function, and the fork inherits this copy for free.
|
| 122 |
+
# A load failure is captured rather than raised so the Space still boots and can
|
| 123 |
+
# report the reason in the UI instead of crash-looping.
|
| 124 |
+
_EMBEDDER = None
|
| 125 |
+
_EMBEDDER_ERROR: str | None = None
|
| 126 |
+
try:
|
| 127 |
from sentence_transformers import SentenceTransformer
|
| 128 |
|
| 129 |
+
_EMBEDDER = SentenceTransformer(EMBED_MODEL, token=HF_TOKEN, device="cpu")
|
| 130 |
+
except Exception as exc: # noqa: BLE001 — surfaced to the user verbatim
|
| 131 |
+
_EMBEDDER_ERROR = f"{type(exc).__name__}: {exc}"
|
| 132 |
|
| 133 |
|
| 134 |
# --------------------------------------------------------------------------- #
|
|
|
|
| 136 |
# --------------------------------------------------------------------------- #
|
| 137 |
|
| 138 |
|
| 139 |
+
@GPU(duration=60)
|
| 140 |
+
def infer(text: str, prompt_template: str) -> tuple[list[float], list[float]]:
|
| 141 |
+
"""Embed and run both heads. Returns plain lists — ZeroGPU pickles the return
|
| 142 |
+
value across a process boundary, so nothing CUDA-resident may escape."""
|
| 143 |
+
if _EMBEDDER is None:
|
| 144 |
+
raise gr.Error(
|
| 145 |
+
"EmbeddingGemma failed to load. It is a gated model, so the Space needs "
|
| 146 |
+
"an HF_TOKEN secret from an account that has accepted the Gemma "
|
| 147 |
+
f"license.\n\n{_EMBEDDER_ERROR}"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 151 |
+
embedder = _EMBEDDER.to(device)
|
| 152 |
+
discipline_model = DISCIPLINE_MODEL.to(device)
|
| 153 |
+
concept_model = CONCEPT_MODEL.to(device)
|
| 154 |
+
remap = _REMAP.to(device)
|
| 155 |
+
|
| 156 |
+
with torch.inference_mode():
|
| 157 |
+
vector = embedder.encode(
|
| 158 |
+
prompt_template.format(text),
|
| 159 |
+
prompt="", # stop ST applying the model's own default prefix on top
|
| 160 |
+
convert_to_numpy=True,
|
| 161 |
+
)
|
| 162 |
+
embedding = torch.as_tensor(vector, dtype=torch.float32, device=device).unsqueeze(0)
|
| 163 |
+
|
| 164 |
+
discipline_probs = torch.sigmoid(discipline_model(embedding))[0]
|
| 165 |
+
conditioned = torch.cat([embedding, discipline_probs[remap].unsqueeze(0)], dim=1)
|
| 166 |
+
concept_probs = torch.sigmoid(concept_model(conditioned))[0]
|
| 167 |
+
|
| 168 |
+
return discipline_probs.float().cpu().tolist(), concept_probs.float().cpu().tolist()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
def classify(text: str, threshold: float, prompt_choice: str, top_k: int):
|
| 172 |
text = (text or "").strip()
|
| 173 |
if not text:
|
| 174 |
return {}, {}, "Paste some text — a title and abstract work best."
|
| 175 |
|
| 176 |
+
template = PROMPT_TEMPLATES.get(prompt_choice, PROMPT_TEMPLATES[DEFAULT_PROMPT])
|
| 177 |
+
discipline_scores, concept_scores = infer(text, template)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
+
disciplines = dict(zip(DISCIPLINE_LABELS, discipline_scores))
|
| 180 |
+
concepts = dict(zip(CONCEPT_LABELS, concept_scores))
|
| 181 |
|
|
|
|
| 182 |
return (
|
| 183 |
dict(sorted(disciplines.items(), key=lambda kv: -kv[1])[:top_k]),
|
| 184 |
dict(sorted(concepts.items(), key=lambda kv: -kv[1])[:top_k]),
|
| 185 |
+
_summarize(disciplines, concepts, threshold),
|
| 186 |
)
|
| 187 |
|
| 188 |
|
requirements.txt
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
gradio==6.28.0
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
# EmbeddingGemma needs sentence-transformers >= 5.0 / transformers >= 4.56,
|
| 4 |
-
# and does not support float16 activations
|
| 5 |
sentence-transformers>=5.0
|
| 6 |
transformers>=4.56
|
| 7 |
huggingface_hub>=0.34
|
| 8 |
|
| 9 |
-
# PyPI's torch wheel bundles CUDA (~3 GB) and is wasted on a CPU Space. If build
|
| 10 |
-
# times or disk become a problem, replace the line below with these two:
|
| 11 |
-
# --extra-index-url https://download.pytorch.org/whl/cpu
|
| 12 |
-
# torch>=2.4
|
| 13 |
torch>=2.4
|
|
|
|
| 1 |
gradio==6.28.0
|
| 2 |
|
| 3 |
+
# ZeroGPU: provides the @spaces.GPU decorator the Space is scanned for at startup.
|
| 4 |
+
spaces
|
| 5 |
+
|
| 6 |
# EmbeddingGemma needs sentence-transformers >= 5.0 / transformers >= 4.56,
|
| 7 |
+
# and does not support float16 activations — this app keeps it in float32.
|
| 8 |
sentence-transformers>=5.0
|
| 9 |
transformers>=4.56
|
| 10 |
huggingface_hub>=0.34
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
torch>=2.4
|
test_local.py
CHANGED
|
@@ -1,42 +1,71 @@
|
|
| 1 |
"""Smoke test: loads the real checkpoints, stubs out EmbeddingGemma, and runs the
|
| 2 |
-
full chain
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
os.environ.setdefault(
|
| 6 |
"PHYSH_WEIGHTS_DIR",
|
| 7 |
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "physh_topic_supervised_classifier"),
|
| 8 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import app
|
| 10 |
|
|
|
|
| 11 |
class FakeEmbedder:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
def encode(self, text, prompt=None, convert_to_numpy=True):
|
| 13 |
rng = np.random.default_rng(abs(hash(text)) % (2**32))
|
| 14 |
v = rng.normal(size=768).astype("float32")
|
| 15 |
return v / np.linalg.norm(v) # EmbeddingGemma returns L2-normalised vectors
|
| 16 |
|
| 17 |
-
|
| 18 |
-
app.
|
| 19 |
|
| 20 |
print("disciplines:", len(app.DISCIPLINE_LABELS), "concepts:", len(app.CONCEPT_LABELS))
|
| 21 |
print("remap is identity:", torch.equal(app._REMAP, torch.arange(18)))
|
| 22 |
-
print("
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
d, c, summary = app.classify(app.EXAMPLES[0], 0.5, app.DEFAULT_PROMPT, 8)
|
| 26 |
assert len(d) == 8 and len(c) == 8, (len(d), len(c))
|
| 27 |
assert all(0.0 <= v <= 1.0 for v in {**d, **c}.values())
|
| 28 |
-
print("\ntop disciplines:", [f"{k} {v:.3f}" for k, v in d.items()][:
|
| 29 |
-
print("top concepts: ", [f"{k} {v:.3f}" for k, v in c.items()][:
|
| 30 |
-
print("\n--- summary ---\n" + summary)
|
| 31 |
|
| 32 |
-
#
|
| 33 |
d2, _, _ = app.classify(app.EXAMPLES[0], 0.5, app.DEFAULT_PROMPT, 8)
|
| 34 |
assert d == d2, "eval() mode not applied — outputs are not deterministic"
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
for p in app.PROMPT_TEMPLATES:
|
| 38 |
app.classify("test abstract", 0.5, p, 5)
|
| 39 |
-
print("\nOK: deterministic, empty input handled, all 3 prompt formats run.")
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Smoke test: loads the real checkpoints, stubs out EmbeddingGemma, and runs the
|
| 2 |
+
full chain on CPU. Needs no HF token and downloads nothing.
|
| 3 |
+
|
| 4 |
+
PHYSH_WEIGHTS_DIR=../physh_topic_supervised_classifier python test_local.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
|
| 9 |
os.environ.setdefault(
|
| 10 |
"PHYSH_WEIGHTS_DIR",
|
| 11 |
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "physh_topic_supervised_classifier"),
|
| 12 |
)
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
import app
|
| 18 |
|
| 19 |
+
|
| 20 |
class FakeEmbedder:
|
| 21 |
+
"""Stands in for SentenceTransformer: deterministic unit-norm vectors."""
|
| 22 |
+
|
| 23 |
+
def to(self, device):
|
| 24 |
+
return self
|
| 25 |
+
|
| 26 |
def encode(self, text, prompt=None, convert_to_numpy=True):
|
| 27 |
rng = np.random.default_rng(abs(hash(text)) % (2**32))
|
| 28 |
v = rng.normal(size=768).astype("float32")
|
| 29 |
return v / np.linalg.norm(v) # EmbeddingGemma returns L2-normalised vectors
|
| 30 |
|
| 31 |
+
|
| 32 |
+
app._EMBEDDER = FakeEmbedder()
|
| 33 |
|
| 34 |
print("disciplines:", len(app.DISCIPLINE_LABELS), "concepts:", len(app.CONCEPT_LABELS))
|
| 35 |
print("remap is identity:", torch.equal(app._REMAP, torch.arange(18)))
|
| 36 |
+
print("concept head in_features:", app.CONCEPT_MODEL.network[0].in_features)
|
| 37 |
+
import sys
|
| 38 |
+
print("real `spaces` package in use:", "spaces" in sys.modules)
|
| 39 |
|
| 40 |
d, c, summary = app.classify(app.EXAMPLES[0], 0.5, app.DEFAULT_PROMPT, 8)
|
| 41 |
assert len(d) == 8 and len(c) == 8, (len(d), len(c))
|
| 42 |
assert all(0.0 <= v <= 1.0 for v in {**d, **c}.values())
|
| 43 |
+
print("\ntop disciplines:", [f"{k} {v:.3f}" for k, v in d.items()][:3])
|
| 44 |
+
print("top concepts: ", [f"{k} {v:.3f}" for k, v in c.items()][:3])
|
|
|
|
| 45 |
|
| 46 |
+
# dropout must be off, so repeat calls agree
|
| 47 |
d2, _, _ = app.classify(app.EXAMPLES[0], 0.5, app.DEFAULT_PROMPT, 8)
|
| 48 |
assert d == d2, "eval() mode not applied — outputs are not deterministic"
|
| 49 |
+
|
| 50 |
+
# empty input, and every prompt format
|
| 51 |
+
assert app.classify(" ", 0.5, app.DEFAULT_PROMPT, 8)[0] == {}
|
| 52 |
for p in app.PROMPT_TEMPLATES:
|
| 53 |
app.classify("test abstract", 0.5, p, 5)
|
|
|
|
| 54 |
|
| 55 |
+
# the GPU-decorated entry point returns plain lists (ZeroGPU pickles them)
|
| 56 |
+
dl, cl = app.infer("test", app.PROMPT_TEMPLATES[app.DEFAULT_PROMPT])
|
| 57 |
+
assert isinstance(dl, list) and isinstance(cl, list) and len(dl) == 18 and len(cl) == 186
|
| 58 |
+
assert all(isinstance(x, float) for x in dl)
|
| 59 |
+
|
| 60 |
+
# a missing embedder must surface as a readable error, not an AttributeError
|
| 61 |
+
app._EMBEDDER, app._EMBEDDER_ERROR = None, "401 gated repo"
|
| 62 |
+
try:
|
| 63 |
+
app.infer("test", "{}")
|
| 64 |
+
raise SystemExit("FAIL: expected a gr.Error when the embedder is missing")
|
| 65 |
+
except Exception as exc:
|
| 66 |
+
assert "HF_TOKEN" in str(exc), exc
|
| 67 |
+
app._EMBEDDER = FakeEmbedder()
|
| 68 |
+
|
| 69 |
+
print("\nOK: deterministic, empty input handled, all prompt formats run,")
|
| 70 |
+
print(" infer() returns picklable lists, missing-token error is readable.")
|
| 71 |
+
print("gradio Blocks built OK:", app.demo is not None)
|