Model card (draft for review)

#1
by davanstrien HF Staff - opened
Files changed (2) hide show
  1. README.md +131 -0
  2. tagger_core.py +200 -0
README.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: fastino/gliner2.5-base-v1
4
+ library_name: gliner2
5
+ pipeline_tag: text-classification
6
+ language:
7
+ - en
8
+ tags:
9
+ - gliner2
10
+ - text-classification
11
+ - multi-label
12
+ - hub-metadata
13
+ - hf-jobs
14
+ ---
15
+
16
+ # Hub task tagger (GLiNER2.5-base)
17
+
18
+ Suggests task tags for a Hugging Face dataset, such as `text-generation`, `robotics` or
19
+ `automatic-speech-recognition`, from its column names and first row. It picks from the 52 task
20
+ tags the Hub offers, and gives a probability for each.
21
+
22
+ [TypeSafe AI's Jev](https://docs.typesafe.ai/introduction) has got people interested in
23
+ "System One" models: models that don't generate text, but read a state and return typed answers
24
+ with probabilities. This is a small, open example of the same idea for one narrow job, fine-tuned
25
+ in 17 minutes on [Hugging Face Jobs](https://huggingface.co/docs/hub/jobs).
26
+
27
+ **Try it:** [davanstrien/hub-task-tagger](https://huggingface.co/spaces/davanstrien/hub-task-tagger).
28
+ Paste a dataset id and see the suggested tags, the owner's tags, and the exact text the model read.
29
+
30
+ ## How to use
31
+
32
+ The model reads a short text built from the dataset viewer's preview: a `Columns:` line with each
33
+ column's name and type, then the first row, cut to 370 tokens. It must be built exactly as in
34
+ training, so this repo ships the builder,
35
+ [`tagger_core.py`](https://huggingface.co/davanstrien/hub-task-tagger-gliner2.5-base/blob/main/tagger_core.py),
36
+ next to the weights. The snippet below downloads it with `huggingface_hub` and uses it.
37
+
38
+ ```python
39
+ import json, math, os, sys
40
+ from huggingface_hub import hf_hub_download
41
+ from tokenizers import Tokenizer
42
+ from gliner2.classification import Classifier, ClassificationSchema, ClassificationConfig
43
+
44
+ repo = "davanstrien/hub-task-tagger-gliner2.5-base"
45
+
46
+ # 1. Build the model's input for a dataset, with the code that built the training data.
47
+ path = hf_hub_download(repo, "tagger_core.py")
48
+ sys.path.insert(0, os.path.dirname(path)) # make the downloaded file importable
49
+ import tagger_core as core
50
+
51
+ builder = core.StateBuilder(Tokenizer.from_file(hf_hub_download("Qwen/Qwen3.5-4B-Base", "tokenizer.json")), 370)
52
+
53
+ first_rows, config, split, error = core.fetch_first_rows("fancyzhx/ag_news")
54
+ state, truncated = builder.build(core.render_first_rows(first_rows))
55
+ print(state)
56
+
57
+ # 2. Score all 52 tags.
58
+ labels = json.load(open(hf_hub_download(repo, "classification_schema.json")))["tasks"][0]["labels"]
59
+ model = Classifier.from_pretrained(repo).eval()
60
+ schema = ClassificationSchema().multi("labels", labels)
61
+ logits = model.batch_score([state[:2000]], schema, config=ClassificationConfig(batch_size=1))[0].tasks["labels"]
62
+
63
+ # 3. Calibrated probabilities: softmax over the 52 logits at temperature 1.30 (fitted on held-out data).
64
+ z = [logits[label] / 1.30 for label in labels]
65
+ m = max(z)
66
+ e = [math.exp(v - m) for v in z]
67
+ probs = sorted(zip(labels, (v / sum(e) for v in e)), key=lambda x: -x[1])
68
+ print(probs[:3])
69
+
70
+ # A set of tags: everything at 0.225 or above, and always the top tag.
71
+ suggested = [label for label, p in probs if p >= 0.225] or [probs[0][0]]
72
+ print(suggested)
73
+ ```
74
+
75
+ The Qwen tokenizer is only used to measure and cut the input to the length the model was trained
76
+ on. `fetch_first_rows` returns an error instead of rows when a dataset has no viewer preview.
77
+
78
+ Install with `pip install "gliner2[local]==2.0.0"`. Weights are fp32. On a free 2-vCPU Space one
79
+ prediction takes about 0.7–1 s; on an L4 GPU in fp16, about 20 ms.
80
+
81
+ ## Training
82
+
83
+ - **Recipe:** [`train-gliner2.py`](https://huggingface.co/datasets/uv-scripts/classification) from
84
+ `uv-scripts/classification`, run as one `hf jobs uv run` command on an `rtx-pro-6000`:
85
+ `--base-model fastino/gliner2.5-base-v1 --labels-file labels.json --label-column labels --label-augmentation off`,
86
+ 5 epochs, seed 0, bf16. About 17 minutes of training, roughly $1.50.
87
+ - **Data:** 16,000 public Hub datasets with owner-declared `task_categories`. Each example is the
88
+ dataset's column names and first row (from the dataset viewer), cut to about 370 tokens, and its
89
+ declared tags (multi-label). Retired tags were mapped to current ones where the task is the same
90
+ (for example `text2text-generation` → `text-generation`); others were dropped.
91
+ - **Split by time and owner:** the evaluation sets are 1,000 (calibration) and 3,000 (development)
92
+ datasets created in the 60 days before 2026-09-22, by owners who have no dataset in the training
93
+ data. Near-duplicate datasets from the same owner cannot leak between training and evaluation.
94
+
95
+ ## Evaluation
96
+
97
+ On the 3,000 development datasets. A prediction counts as correct if it is one of the owner's tags.
98
+
99
+ | | Top-1 | Top-3 | Macro recall (24 tags) |
100
+ |---|---|---|---|
101
+ | This model | **0.695** | 0.881 | 0.525 |
102
+ | Same recipe, second seed | 0.686 | 0.879 | 0.511 |
103
+ | GLiNER2.5-small, same recipe | 0.653 | 0.855 | 0.458 |
104
+ | GLiNER2.5-base, zero-shot | 0.102 | 0.213 | 0.099 |
105
+ | Always "text-generation" | 0.320 | – | – |
106
+
107
+ - **The owners' tags are a noisy target.** In a hand-checked sample, about 1 dataset in 10 was
108
+ missing a tag that fits. Counting 74 card-checked extra tags as correct raises top-1 to 0.720.
109
+ - **As a set** (all tags at probability ≥ 0.225), micro-F1 against the owner's tags is 0.623,
110
+ with 1.21 tags per dataset on average against 1.32 declared.
111
+ - **Confidence:** the temperature and the threshold were fitted on the calibration set. Thresholds
112
+ chosen there held on development within a few points of error, but not exactly: treat the
113
+ probabilities as a good ranking, not as exact rates.
114
+
115
+ ## Limitations
116
+
117
+ - **It only sees the first row.** Datasets whose purpose is not visible from the columns and one
118
+ example (for example, generic tables) are hard.
119
+ - **Tabular tags are weak.** It often confuses `tabular-regression` with `tabular-classification`,
120
+ because the input does not say which column is the target.
121
+ - **Rare tags** (17 of the 52, such as `text-to-3d` or `voice-activity-detection`) had too few
122
+ training examples to measure.
123
+ - **It learned from owners' choices,** including their habits: for example, question-answering
124
+ datasets tagged `text-generation`, or the reverse.
125
+ - Trained and evaluated mostly on English-language metadata.
126
+
127
+ ## Credits
128
+
129
+ Built on [GLiNER2](https://github.com/fastino-ai/GLiNER2) by Fastino. Training, evaluation and the
130
+ demo were run on Hugging Face Jobs and Spaces, with much of the work done by coding agents (Claude)
131
+ and checked by a person. More detail: [GLINER2-NOTES.md](https://huggingface.co/datasets/uv-scripts/classification/blob/main/GLINER2-NOTES.md).
tagger_core.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendering and state building, copied verbatim from the Gradio test Space (demo-space/app.py).
2
+
3
+ That code is itself copied from build_hub_task_rows.py and prepare_hub_tasks.py, and it matched the stored
4
+ training states 200/200. Do not edit the functions below; change the training scripts and re-copy instead.
5
+ """
6
+ import json
7
+ import time
8
+
9
+ import requests
10
+
11
+ # ---- rendering: copied from build_hub_task_rows.py ------------------------------------------------------------
12
+
13
+ SERVER = "https://datasets-server.huggingface.co"
14
+ TAGS_URL = "https://huggingface.co/api/datasets-tags-by-type?type=task_categories"
15
+ RETIRED = {
16
+ "text2text-generation": "text-generation",
17
+ "conversational": "text-generation",
18
+ "language-modeling": "text-generation",
19
+ "sequence-modeling": "text-generation",
20
+ "document-question-answering": "visual-question-answering",
21
+ "multiple-choice-qa": "multiple-choice",
22
+ "topic-classification": "text-classification",
23
+ }
24
+ MEDIA = {"Image": "<image>", "Audio": "<audio>", "Video": "<video>", "Pdf": "<pdf>"}
25
+ NUMERIC = ("int", "uint", "float", "double", "bool")
26
+ CELL_CHARS = 200
27
+ ROWS = 3
28
+ MAX_PREVIEW_TRIES = 3
29
+
30
+ # Anonymous on purpose: the Space shows only what any visitor could read (no gated or private previews).
31
+ session = requests.Session()
32
+
33
+
34
+ def get_json(url, params):
35
+ """(status, json). Retries 429/502/503/504 and network errors, honouring Retry-After; returns the last status."""
36
+ status = None
37
+ for attempt in range(5):
38
+ try:
39
+ r = session.get(url, params=params, timeout=60)
40
+ except requests.RequestException:
41
+ status = "network"
42
+ time.sleep(2 ** attempt)
43
+ continue
44
+ status = r.status_code
45
+ if status in (429, 502, 503, 504):
46
+ wait = r.headers.get("Retry-After", "")
47
+ time.sleep(min(60.0, float(wait)) if wait.replace(".", "", 1).isdigit() else 2 ** attempt)
48
+ continue
49
+ try:
50
+ return status, r.json()
51
+ except ValueError:
52
+ return status, {}
53
+ return status, {}
54
+
55
+
56
+ def type_name(t):
57
+ """Short type for the Columns line: string, int64, Image, ClassLabel: a, b, list<float32>, struct."""
58
+ if isinstance(t, list):
59
+ return f"list<{type_name(t[0])}>" if t else "list"
60
+ if not isinstance(t, dict):
61
+ return "?"
62
+ kind = t.get("_type")
63
+ if kind == "Value":
64
+ return t.get("dtype", "?")
65
+ if kind == "ClassLabel":
66
+ names = [str(n) for n in (t.get("names") or [])]
67
+ if not names:
68
+ return "ClassLabel"
69
+ return "ClassLabel: " + ", ".join(names[:8]) + (", …" if len(names) > 8 else "")
70
+ if kind in ("List", "Sequence", "LargeList"):
71
+ return f"list<{type_name(t.get('feature'))}>"
72
+ if kind:
73
+ return kind
74
+ return "struct"
75
+
76
+
77
+ def is_numeric(t):
78
+ return isinstance(t, dict) and t.get("_type") == "Value" and str(t.get("dtype", "")).startswith(NUMERIC)
79
+
80
+
81
+ def render_value(v, t):
82
+ """A cell value as display text, following the feature type recursively."""
83
+ if v is None:
84
+ return "null"
85
+ if isinstance(t, list):
86
+ t = {"_type": "List", "feature": t[0] if t else None}
87
+ kind = t.get("_type") if isinstance(t, dict) else None
88
+ if kind in MEDIA:
89
+ return MEDIA[kind]
90
+ if kind == "ClassLabel":
91
+ names = t.get("names") or []
92
+ return str(names[v]) if isinstance(v, int) and 0 <= v < len(names) else str(v)
93
+ if kind in ("List", "Sequence", "LargeList"):
94
+ inner = t.get("feature")
95
+ if isinstance(v, dict) and isinstance(inner, dict) and not inner.get("_type"):
96
+ return "{" + ", ".join(f"{k}: {render_value(v.get(k), {'_type': 'List', 'feature': sub})}" for k, sub in inner.items()) + "}"
97
+ if not isinstance(v, list):
98
+ return json.dumps(v, ensure_ascii=False)
99
+ if is_numeric(inner) and len(v) > 4:
100
+ return f"[{len(v)} × {inner.get('dtype')}]"
101
+ shown = [render_value(x, inner) for x in v[:5]]
102
+ return "[" + ", ".join(shown) + (f", … ({len(v)} items)" if len(v) > 5 else "") + "]"
103
+ if kind is None and isinstance(t, dict) and isinstance(v, dict):
104
+ return "{" + ", ".join(f"{k}: {render_value(v.get(k), sub)}" for k, sub in t.items()) + "}"
105
+ if isinstance(v, str):
106
+ return v
107
+ return json.dumps(v, ensure_ascii=False)
108
+
109
+
110
+ def cell_text(v, t, truncated):
111
+ s = " ".join(render_value(v, t).split())
112
+ if len(s) > CELL_CHARS:
113
+ return s[:CELL_CHARS] + "…"
114
+ return s + ("…" if truncated else "")
115
+
116
+
117
+ def fetch_first_rows(dataset_id):
118
+ """(first_rows_json, config, split, error). The fetch half of build_hub_task_rows.preview()."""
119
+ status, sp = get_json(f"{SERVER}/splits", {"dataset": dataset_id})
120
+ splits = sp.get("splits") or []
121
+ if not splits:
122
+ return None, None, None, f"splits:{status} {sp.get('error', '')}".strip()
123
+ last = None
124
+ for s in splits[:MAX_PREVIEW_TRIES]:
125
+ cfg, split = s["config"], s["split"]
126
+ status, fr = get_json(f"{SERVER}/first-rows", {"dataset": dataset_id, "config": cfg, "split": split})
127
+ if not (fr.get("features") or []) or not (fr.get("rows") or []):
128
+ last = f"first-rows:{status} {fr.get('error', '')}".strip()
129
+ continue
130
+ return fr, cfg, split, None
131
+ return None, None, None, last
132
+
133
+
134
+ def render_first_rows(fr):
135
+ """The render half of build_hub_task_rows.preview()."""
136
+ feats, rows = fr["features"], fr["rows"]
137
+ types = {f["name"]: f["type"] for f in feats}
138
+ lines = ["Columns: " + ", ".join(f"{f['name']} ({type_name(f['type'])})" for f in feats), "Example rows:"]
139
+ for i, r in enumerate(rows[:ROWS], 1):
140
+ cut = set(r.get("truncated_cells") or [])
141
+ lines.append(f"{i}. " + " | ".join(f"{k}: {cell_text(v, types.get(k), k in cut)}" for k, v in r["row"].items()))
142
+ return "\n".join(lines)
143
+
144
+
145
+ # ---- state: copied from prepare_hub_tasks.py -----------------------------------------------------------------
146
+
147
+ COLUMNS_SHARE = 0.45
148
+
149
+
150
+ class StateBuilder:
151
+ def __init__(self, tokenizer, max_tokens):
152
+ self.tok, self.max = tokenizer, max_tokens
153
+
154
+ def n(self, s):
155
+ return len(self.tok.encode(s, add_special_tokens=False).ids)
156
+
157
+ def cut(self, s, k):
158
+ """First k tokens of s, with a trailing … when something was removed."""
159
+ ids = self.tok.encode(s, add_special_tokens=False).ids
160
+ if len(ids) <= k:
161
+ return s, False
162
+ return self.tok.decode(ids[: max(0, k - 1)]).rstrip() + "…", True
163
+
164
+ def build(self, text):
165
+ """(state, truncated): Columns line + first example row, each cell getting a fair share of the budget."""
166
+ lines = text.split("\n")
167
+ columns = lines[0]
168
+ first = next((line for line in lines[1:] if line.startswith("1. ")), "")
169
+ truncated = False
170
+ if self.n(columns) > int(self.max * COLUMNS_SHARE):
171
+ columns, truncated = self.cut(columns, int(self.max * COLUMNS_SHARE))
172
+ if first:
173
+ cells = first[3:].split(" | ")
174
+ keys = [c.split(": ", 1)[0] + ": " if ": " in c else "" for c in cells]
175
+ values = [c.split(": ", 1)[1] if ": " in c else c for c in cells]
176
+ overhead = self.n(f"{columns}\nExample row:\n1. ") + sum(self.n(k) for k in keys) + 2 * len(cells)
177
+ budget = self.max - overhead
178
+ sizes = [self.n(v) for v in values]
179
+ if budget < len(cells):
180
+ cells = [k.rstrip(": ") for k in keys]
181
+ truncated = True
182
+ elif sum(sizes) > budget:
183
+ lo, hi = 1, max(sizes)
184
+ while lo < hi:
185
+ mid = (lo + hi + 1) // 2
186
+ if sum(min(s, mid) for s in sizes) <= budget:
187
+ lo = mid
188
+ else:
189
+ hi = mid - 1
190
+ cells = [k + self.cut(v, lo)[0] for k, v in zip(keys, values)]
191
+ truncated = True
192
+ state = f"{columns}\nExample row:\n1. " + " | ".join(cells)
193
+ else:
194
+ state = columns
195
+ if self.n(state) > self.max:
196
+ state, _ = self.cut(state, self.max)
197
+ truncated = True
198
+ return state, truncated
199
+
200
+