Spaces:
Running on Zero
Running on Zero
Add Gradio app for PhySH discipline and concept classification
Browse files- .gitignore +5 -0
- DEPLOY.md +58 -0
- README.md +76 -1
- app.py +233 -0
- requirements.txt +13 -0
- test_local.py +42 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
# Weights are pulled from the model repo at runtime — don't commit copies here.
|
| 5 |
+
*.pt
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying this to a Space
|
| 2 |
+
|
| 3 |
+
## 1. Create the Space
|
| 4 |
+
|
| 5 |
+
On huggingface.co → **New** → **Space**:
|
| 6 |
+
|
| 7 |
+
- Owner `LukeFP`, name `physh-topic-classifier`
|
| 8 |
+
- SDK **Gradio**, hardware **CPU basic** (free) — EmbeddingGemma-300m runs on
|
| 9 |
+
CPU in roughly a second per abstract, so a GPU buys little here
|
| 10 |
+
- Visibility public or private, your call
|
| 11 |
+
|
| 12 |
+
## 2. Add the token secret
|
| 13 |
+
|
| 14 |
+
`google/embeddinggemma-300m` is gated. Accept the Gemma license while signed in,
|
| 15 |
+
create a **read** token, then in the Space: Settings → *Variables and secrets* →
|
| 16 |
+
**New secret**, name `HF_TOKEN`, value the token.
|
| 17 |
+
|
| 18 |
+
If the model repo is private too, the same token covers it.
|
| 19 |
+
|
| 20 |
+
## 3. Push
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
cd /Users/firstprinciplesextralaptop2/code/2026.7/physh-topic-classifier-space
|
| 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 |
+
If the Space was created with a README already, `git pull --rebase origin main`
|
| 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 |
+
The build takes a few minutes, mostly `pip install torch`.
|
| 37 |
+
|
| 38 |
+
## 4. Check it
|
| 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 will give confident, coherent labels.
|
| 45 |
+
Once you know which, set `DEFAULT_PROMPT` at the top of `app.py`.
|
| 46 |
+
- **Error mentioning gated repo or 401.** `HF_TOKEN` is missing, wrong, or the
|
| 47 |
+
account behind it hasn't accepted the Gemma license.
|
| 48 |
+
- **First request is slow.** Expected — EmbeddingGemma loads lazily on first use
|
| 49 |
+
so the Space boots fast. It's cached after that.
|
| 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 |
+
```
|
README.md
CHANGED
|
@@ -8,6 +8,81 @@ sdk_version: 6.28.0
|
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: apache-2.0
|
| 12 |
+
models:
|
| 13 |
+
- LukeFP/physh_topic_supervised_classifier
|
| 14 |
+
- google/embeddinggemma-300m
|
| 15 |
---
|
| 16 |
|
| 17 |
+
# PhySH Topic Classifier
|
| 18 |
+
|
| 19 |
+
Paste a physics title and abstract; get back its [PhySH](https://physh.org)
|
| 20 |
+
**disciplines** and **top-level research-area concepts**.
|
| 21 |
+
|
| 22 |
+
## How it works
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
text ──EmbeddingGemma-300m──> 768-d vector
|
| 26 |
+
│
|
| 27 |
+
├──> discipline head 768 → 1024 → 512 → 18 sigmoid
|
| 28 |
+
│ │
|
| 29 |
+
└──> concept head [768 + 18] → 1024 → 512 → 186 sigmoid
|
| 30 |
+
▲
|
| 31 |
+
discipline probabilities
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Both heads are multi-label MLPs with ReLU and dropout 0.3, trained on
|
| 35 |
+
EmbeddingGemma vectors. The concept head is *conditioned* on the discipline
|
| 36 |
+
head's output: its 786-dimensional input is the text embedding concatenated with
|
| 37 |
+
the 18 discipline probabilities (the checkpoint records `use_logits: False`, so
|
| 38 |
+
probabilities rather than logits are what it expects).
|
| 39 |
+
|
| 40 |
+
Weights live in
|
| 41 |
+
[`LukeFP/physh_topic_supervised_classifier`](https://huggingface.co/LukeFP/physh_topic_supervised_classifier)
|
| 42 |
+
and are downloaded at startup, so retraining only requires a push to that repo —
|
| 43 |
+
no change here.
|
| 44 |
+
|
| 45 |
+
| Head | micro-F1 | macro-F1 | avg labels/sample |
|
| 46 |
+
|---|---|---|---|
|
| 47 |
+
| Discipline (18) | 0.799 | 0.683 | 1.41 |
|
| 48 |
+
| Concept (186) | 0.641 | 0.423 | 2.12 |
|
| 49 |
+
|
| 50 |
+
## Setup
|
| 51 |
+
|
| 52 |
+
`google/embeddinggemma-300m` is a gated repo. Accept the Gemma license on the
|
| 53 |
+
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
|
| 60 |
+
match the one used to build the training embeddings — a mismatch degrades
|
| 61 |
+
accuracy quietly instead of erroring. The default is the document prompt
|
| 62 |
+
(`title: none | text: …`); the Advanced panel lets you switch and compare.
|
| 63 |
+
|
| 64 |
+
## Running locally
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
pip install -r requirements.txt
|
| 68 |
+
export HF_TOKEN=hf_...
|
| 69 |
+
python app.py
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Set `PHYSH_WEIGHTS_DIR=/path/to/physh_topic_supervised_classifier` to load the
|
| 73 |
+
`.pt` files from a local clone instead of the Hub.
|
| 74 |
+
|
| 75 |
+
## API
|
| 76 |
+
|
| 77 |
+
Gradio exposes the Space as an API, which is the practical route for batch
|
| 78 |
+
labelling:
|
| 79 |
+
|
| 80 |
+
```python
|
| 81 |
+
from gradio_client import Client
|
| 82 |
+
|
| 83 |
+
client = Client("LukeFP/physh-topic-classifier")
|
| 84 |
+
disciplines, concepts, summary = client.predict(
|
| 85 |
+
"Title and abstract…", 0.5, "document — title: none | text: {}", 8,
|
| 86 |
+
api_name="/classify",
|
| 87 |
+
)
|
| 88 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhySH topic classifier — Gradio Space.
|
| 3 |
+
|
| 4 |
+
Pipeline: text ──EmbeddingGemma-300m──> 768-d vector
|
| 5 |
+
│
|
| 6 |
+
├──> discipline head (768 → 1024 → 512 → 18), sigmoid
|
| 7 |
+
│
|
| 8 |
+
└──> concept head ([768 + 18] → 1024 → 512 → 186), sigmoid
|
| 9 |
+
conditioned on the discipline *probabilities*
|
| 10 |
+
(the checkpoint records use_logits = False)
|
| 11 |
+
|
| 12 |
+
Both heads are multi-label: each output is an independent sigmoid, so a text can
|
| 13 |
+
carry several disciplines and several concepts.
|
| 14 |
+
"""
|
| 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
|
| 24 |
+
from huggingface_hub import hf_hub_download
|
| 25 |
+
|
| 26 |
+
# --------------------------------------------------------------------------- #
|
| 27 |
+
# Config
|
| 28 |
+
# --------------------------------------------------------------------------- #
|
| 29 |
+
|
| 30 |
+
MODEL_REPO = "LukeFP/physh_topic_supervised_classifier"
|
| 31 |
+
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 a gated repo: set HF_TOKEN as a Space secret,
|
| 36 |
+
# using a token from an account that has accepted the Gemma license.
|
| 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.
|
| 40 |
+
LOCAL_WEIGHTS_DIR = os.environ.get("PHYSH_WEIGHTS_DIR")
|
| 41 |
+
|
| 42 |
+
# EmbeddingGemma expects a task-specific prefix, and the prefix used at inference
|
| 43 |
+
# must match the one used to build the training embeddings — a mismatch degrades
|
| 44 |
+
# accuracy silently rather than erroring. Pick the one your training script used.
|
| 45 |
+
PROMPT_TEMPLATES = {
|
| 46 |
+
"document — title: none | text: {}": "title: none | text: {}",
|
| 47 |
+
"classification — task: classification | query: {}": "task: classification | query: {}",
|
| 48 |
+
"none — raw text": "{}",
|
| 49 |
+
}
|
| 50 |
+
DEFAULT_PROMPT = "document — title: none | text: {}"
|
| 51 |
+
|
| 52 |
+
# --------------------------------------------------------------------------- #
|
| 53 |
+
# Model
|
| 54 |
+
# --------------------------------------------------------------------------- #
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class MLPClassifier(nn.Module):
|
| 58 |
+
"""Linear/ReLU/Dropout stack. Layer indices line up with the checkpoints'
|
| 59 |
+
`network.0`, `network.3`, `network.6` keys."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, input_dim: int, hidden_layers: list[int], output_dim: int, dropout: float):
|
| 62 |
+
super().__init__()
|
| 63 |
+
layers: list[nn.Module] = []
|
| 64 |
+
prev = input_dim
|
| 65 |
+
for width in hidden_layers:
|
| 66 |
+
layers += [nn.Linear(prev, width), nn.ReLU(), nn.Dropout(dropout)]
|
| 67 |
+
prev = width
|
| 68 |
+
layers.append(nn.Linear(prev, output_dim))
|
| 69 |
+
self.network = nn.Sequential(*layers)
|
| 70 |
+
|
| 71 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 72 |
+
return self.network(x)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _weights_path(filename: str) -> str:
|
| 76 |
+
if LOCAL_WEIGHTS_DIR:
|
| 77 |
+
return os.path.join(LOCAL_WEIGHTS_DIR, filename)
|
| 78 |
+
return hf_hub_download(MODEL_REPO, filename, token=HF_TOKEN)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def load_head(filename: str) -> tuple[MLPClassifier, dict]:
|
| 82 |
+
ckpt = torch.load(_weights_path(filename), map_location="cpu", weights_only=False)
|
| 83 |
+
cfg = ckpt["model_config"]
|
| 84 |
+
# The discipline head records `input_dim`; the concept head records the two
|
| 85 |
+
# halves of its input separately.
|
| 86 |
+
input_dim = cfg.get("input_dim") or cfg["embedding_dim"] + cfg["discipline_dim"]
|
| 87 |
+
model = MLPClassifier(input_dim, cfg["hidden_layers"], cfg["output_dim"], cfg["dropout"])
|
| 88 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 89 |
+
model.eval()
|
| 90 |
+
return model, ckpt
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
DISCIPLINE_MODEL, DISCIPLINE_CKPT_DATA = load_head(DISCIPLINE_CKPT)
|
| 94 |
+
CONCEPT_MODEL, CONCEPT_CKPT_DATA = load_head(CONCEPT_CKPT)
|
| 95 |
+
|
| 96 |
+
DISCIPLINE_LABELS = [d["label"] for d in DISCIPLINE_CKPT_DATA["class_labels"]]
|
| 97 |
+
CONCEPT_LABELS = [c["label"] for c in CONCEPT_CKPT_DATA["class_labels"]]
|
| 98 |
+
|
| 99 |
+
# The concept head was conditioned on the discipline vector in a specific order.
|
| 100 |
+
# Remap if the two checkpoints ever drift apart.
|
| 101 |
+
_CONDITION_ORDER = [d["discipline_id"] for d in CONCEPT_CKPT_DATA["discipline_labels"]]
|
| 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 |
+
@functools.lru_cache(maxsize=1)
|
| 107 |
+
def get_embedder():
|
| 108 |
+
"""Loaded on first request rather than at import, so the Space boots quickly
|
| 109 |
+
and a missing token surfaces as a readable error instead of a crashed app."""
|
| 110 |
+
from sentence_transformers import SentenceTransformer
|
| 111 |
+
|
| 112 |
+
return SentenceTransformer(EMBED_MODEL, token=HF_TOKEN)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --------------------------------------------------------------------------- #
|
| 116 |
+
# Inference
|
| 117 |
+
# --------------------------------------------------------------------------- #
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@torch.inference_mode()
|
| 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 |
+
prompt = PROMPT_TEMPLATES.get(prompt_choice, PROMPT_TEMPLATES[DEFAULT_PROMPT])
|
| 127 |
+
# prompt="" stops sentence-transformers from also applying the model's own
|
| 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 = {DISCIPLINE_LABELS[i]: float(p) for i, p in enumerate(discipline_probs)}
|
| 137 |
+
concepts = {CONCEPT_LABELS[i]: float(p) for i, p in enumerate(concept_probs)}
|
| 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 |
+
summary,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _summarize(disciplines: dict, concepts: dict, threshold: float) -> str:
|
| 148 |
+
def above(scores):
|
| 149 |
+
hits = sorted((kv for kv in scores.items() if kv[1] >= threshold), key=lambda kv: -kv[1])
|
| 150 |
+
return [f"**{name}** ({score:.2f})" for name, score in hits]
|
| 151 |
+
|
| 152 |
+
d_hits, c_hits = above(disciplines), above(concepts)
|
| 153 |
+
lines = [f"### Above threshold ({threshold:.2f})", ""]
|
| 154 |
+
lines.append("**Disciplines** — " + (", ".join(d_hits) if d_hits else "_none_"))
|
| 155 |
+
lines.append("")
|
| 156 |
+
lines.append("**Concepts** — " + (", ".join(c_hits) if c_hits else "_none_"))
|
| 157 |
+
if not d_hits and not c_hits:
|
| 158 |
+
lines += ["", "_Nothing cleared the threshold. Lower it, or check that the "
|
| 159 |
+
"prompt format under Advanced matches your training setup._"]
|
| 160 |
+
return "\n".join(lines)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --------------------------------------------------------------------------- #
|
| 164 |
+
# UI
|
| 165 |
+
# --------------------------------------------------------------------------- #
|
| 166 |
+
|
| 167 |
+
EXAMPLES = [
|
| 168 |
+
"We report the observation of a superconducting dome in magic-angle twisted "
|
| 169 |
+
"bilayer graphene. Transport measurements below 1.7 K reveal a zero-resistance "
|
| 170 |
+
"state whose critical temperature is tuned continuously by electrostatic gating, "
|
| 171 |
+
"and the phase diagram closely tracks the filling of the flat moire bands.",
|
| 172 |
+
"We present a measurement of the cosmic microwave background lensing power "
|
| 173 |
+
"spectrum from four seasons of data. The reconstruction achieves a 40-sigma "
|
| 174 |
+
"detection and, combined with baryon acoustic oscillation data, constrains the "
|
| 175 |
+
"sum of the neutrino masses.",
|
| 176 |
+
"A variational quantum eigensolver is used to compute ground-state energies of "
|
| 177 |
+
"small molecular Hamiltonians on a superconducting processor. We introduce an "
|
| 178 |
+
"error-mitigation scheme based on zero-noise extrapolation and show that it "
|
| 179 |
+
"recovers chemical accuracy for LiH.",
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
with gr.Blocks(title="PhySH Topic Classifier") as demo:
|
| 183 |
+
gr.Markdown(
|
| 184 |
+
"# PhySH Topic Classifier\n"
|
| 185 |
+
"Paste a physics title and abstract to get its **PhySH disciplines** and "
|
| 186 |
+
"**top-level research-area concepts**. Both heads are multi-label, so several "
|
| 187 |
+
"labels can fire at once.\n\n"
|
| 188 |
+
f"Heads: [`{MODEL_REPO}`](https://huggingface.co/{MODEL_REPO}) · "
|
| 189 |
+
f"Embeddings: [`{EMBED_MODEL}`](https://huggingface.co/{EMBED_MODEL})"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
with gr.Row():
|
| 193 |
+
with gr.Column(scale=3):
|
| 194 |
+
text_input = gr.Textbox(
|
| 195 |
+
label="Title + abstract",
|
| 196 |
+
placeholder="Paste a paper title and abstract…",
|
| 197 |
+
lines=12,
|
| 198 |
+
)
|
| 199 |
+
with gr.Row():
|
| 200 |
+
submit = gr.Button("Classify", variant="primary")
|
| 201 |
+
clear = gr.ClearButton(text_input, value="Clear")
|
| 202 |
+
gr.Examples(examples=[[e] for e in EXAMPLES], inputs=[text_input], label="Try one")
|
| 203 |
+
|
| 204 |
+
with gr.Column(scale=2):
|
| 205 |
+
discipline_out = gr.Label(label="Disciplines (18)", num_top_classes=8)
|
| 206 |
+
concept_out = gr.Label(label="Concepts (186)", num_top_classes=8)
|
| 207 |
+
|
| 208 |
+
summary_out = gr.Markdown()
|
| 209 |
+
|
| 210 |
+
with gr.Accordion("Advanced", open=False):
|
| 211 |
+
threshold = gr.Slider(0.05, 0.95, value=0.5, step=0.05, label="Decision threshold")
|
| 212 |
+
top_k = gr.Slider(3, 20, value=8, step=1, label="How many labels to show")
|
| 213 |
+
prompt_choice = gr.Radio(
|
| 214 |
+
choices=list(PROMPT_TEMPLATES),
|
| 215 |
+
value=DEFAULT_PROMPT,
|
| 216 |
+
label="EmbeddingGemma prompt format",
|
| 217 |
+
info="Must match the prefix used to build the training embeddings. "
|
| 218 |
+
"If predictions look like noise, try the other options.",
|
| 219 |
+
)
|
| 220 |
+
gr.Markdown(
|
| 221 |
+
f"Validation at training time — disciplines: micro-F1 "
|
| 222 |
+
f"{DISCIPLINE_CKPT_DATA['metrics']['micro_f1']:.3f}, concepts: micro-F1 "
|
| 223 |
+
f"{CONCEPT_CKPT_DATA['metrics']['micro_f1']:.3f}."
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
inputs = [text_input, threshold, prompt_choice, top_k]
|
| 227 |
+
outputs = [discipline_out, concept_out, summary_out]
|
| 228 |
+
submit.click(classify, inputs=inputs, outputs=outputs, api_name="classify")
|
| 229 |
+
text_input.submit(classify, inputs=inputs, outputs=outputs)
|
| 230 |
+
|
| 231 |
+
if __name__ == "__main__":
|
| 232 |
+
# Gradio 6 takes the theme on launch(), not on the Blocks constructor.
|
| 233 |
+
demo.launch(theme=gr.themes.Soft())
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.28.0
|
| 2 |
+
|
| 3 |
+
# EmbeddingGemma needs sentence-transformers >= 5.0 / transformers >= 4.56,
|
| 4 |
+
# and does not support float16 activations (float32 on CPU is the default here).
|
| 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
|
test_local.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke test: loads the real checkpoints, stubs out EmbeddingGemma, and runs the
|
| 2 |
+
full chain. Run with PHYSH_WEIGHTS_DIR pointing at the cloned model repo."""
|
| 3 |
+
import os, sys, numpy as np, torch
|
| 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 |
+
app.get_embedder.cache_clear()
|
| 18 |
+
app.get_embedder = lambda: FakeEmbedder()
|
| 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("discipline net:", app.DISCIPLINE_MODEL.network)
|
| 23 |
+
print("concept in_features:", app.CONCEPT_MODEL.network[0].in_features)
|
| 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()][:4])
|
| 29 |
+
print("top concepts: ", [f"{k} {v:.3f}" for k, v in c.items()][:4])
|
| 30 |
+
print("\n--- summary ---\n" + summary)
|
| 31 |
+
|
| 32 |
+
# determinism (dropout must be off) and empty input
|
| 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 |
+
empty = app.classify(" ", 0.5, app.DEFAULT_PROMPT, 8)
|
| 36 |
+
assert empty[0] == {} and "Paste" in empty[2]
|
| 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 |
+
app.demo # Blocks built at import; confirm the UI graph is constructed
|
| 42 |
+
print("gradio Blocks built OK")
|