LukeFP Claude Opus 5 commited on
Commit
4123863
·
1 Parent(s): 439dcd5

Add Gradio app for PhySH discipline and concept classification

Browse files
Files changed (6) hide show
  1. .gitignore +5 -0
  2. DEPLOY.md +58 -0
  3. README.md +76 -1
  4. app.py +233 -0
  5. requirements.txt +13 -0
  6. 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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")