alexwengg commited on
Commit
0a423df
·
verified ·
1 Parent(s): 1e24e2c

Publish GLiNER2.5 multilingual Core ML classification exports

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer/tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: coremltools
4
+ pipeline_tag: text-classification
5
+ tags:
6
+ - coreml
7
+ - gliner2
8
+ - multilingual
9
+ - apple-silicon
10
+ ---
11
+
12
+ # GLiNER2.5 multilingual Core ML classification
13
+
14
+ Core ML conversion of the classification path of
15
+ [Fastino GLiNER2.5 multilingual](https://huggingface.co/fastino/gliner2.5-multi-v1),
16
+ revision `a221b77a8baf4a613b8f8652661d41fa10a5641e` (Apache-2.0).
17
+ The original checkpoint has 287,355,159 parameters. This export includes the
18
+ encoder and trained classification head (278,719,489 parameters); it does not
19
+ include entity, relation, record or span extraction. Use the original checkpoint
20
+ for those tasks.
21
+
22
+ The L128/K8 FP16 package is 576,528,829 bytes. It matched native choices on
23
+ 299 of 300 eligible fixed application requests, with one near tie reversing
24
+ "sports" and "world news". Maximum chosen-confidence difference was 0.009249;
25
+ median Python Core ML call was 8.81 ms on an M5 Pro running macOS 27.0.
26
+ The FP32 reference package is 1,152,027,886 bytes; it matched all 100 sampled
27
+ native choices, with maximum confidence difference 0.00000805 and median call
28
+ 11.24 ms. Four French, Spanish, Chinese and German smoke phrases gave the same
29
+ chosen labels in native, FP16 and FP32. See the report JSON files. These are
30
+ parity checks, not a complete Decision Index run or ANE-only timings.
31
+
32
+ The model uses the upstream GLiNER2 schema and tokenizer supplied here.
33
+ The Core ML minimum target is iOS 17/macOS 14. At most eight labels fit this
34
+ bucket; inputs over capacity require a larger model bucket rather than
35
+ truncation. The included runtime loads only Core ML and tokenizer files:
36
+
37
+ ```bash
38
+ uv sync
39
+ uv run python runtime.py --model-dir . --text '科学家发现了一颗新的行星。' \
40
+ --task topic --labels '["science","sports","politics"]' --precision fp16
41
+ ```
42
+
43
+ Use `--precision fp32` when exact native decisions around narrow margins matter.
44
+ The conversion scripts, pinned dependencies and asset hashes are included.
45
+ Fluid Inference converted the model; Fastino authored the original checkpoint.
assets.lock.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source": "fastino/gliner2.5-multi-v1",
3
+ "revision": "a221b77a8baf4a613b8f8652661d41fa10a5641e",
4
+ "files": {
5
+ "config.json": {
6
+ "size": 3151,
7
+ "sha256": "8b59a0f426a65859c89cd1ea850c3529c09aa3be3a6fafd8eddfdd17b1bf0146"
8
+ },
9
+ "encoder_config/config.json": {
10
+ "size": 857,
11
+ "sha256": "fa4f9ef2903b5369ab172333aae4574e6a476511d7465845cf59f8360ee18716"
12
+ },
13
+ "model.safetensors": {
14
+ "size": 1149461028,
15
+ "sha256": "c1ff4ec0bc00031c15530b8f3c33d3677f27949e6a0cb52e1247a6224b6c5395"
16
+ },
17
+ "tokenizer.json": {
18
+ "size": 16035853,
19
+ "sha256": "c62446df87ae18ec98b133f8f84fc449a07cc89bbf8ef192a4cb5f9c53777a7a"
20
+ },
21
+ "tokenizer_config.json": {
22
+ "size": 645,
23
+ "sha256": "0bf3ea0873234bd9bfdd3853c440395009ac6365a925b91654daed5396d655e1"
24
+ }
25
+ }
26
+ }
compress-coreml.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Palettize a verified GLiNER2 classification Core ML model."""
2
+ import argparse
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import coremltools as ct
8
+ from coremltools.optimize.coreml import OpPalettizerConfig, OptimizationConfig, palettize_weights
9
+
10
+
11
+ def package_bytes(path):
12
+ return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--source", required=True)
18
+ parser.add_argument("--output", required=True)
19
+ parser.add_argument("--bits", type=int, choices=[4, 6, 8], default=8)
20
+ parser.add_argument("--granularity", choices=["per_tensor", "per_grouped_channel"], default="per_tensor")
21
+ parser.add_argument("--group-size", type=int, default=32)
22
+ args = parser.parse_args()
23
+ start = time.perf_counter()
24
+ model = ct.models.MLModel(args.source, skip_model_load=True)
25
+ config = OptimizationConfig(global_config=OpPalettizerConfig(
26
+ mode="kmeans", nbits=args.bits, granularity=args.granularity,
27
+ group_size=args.group_size,
28
+ enable_per_channel_scale=args.granularity == "per_grouped_channel",
29
+ num_kmeans_workers=4,
30
+ ))
31
+ compressed = palettize_weights(model, config=config)
32
+ compressed.short_description = f"{model.short_description}; LUT{args.bits} weights"
33
+ compressed.author = model.author
34
+ compressed.license = model.license
35
+ compressed.user_defined_metadata.update(model.user_defined_metadata)
36
+ compressed.user_defined_metadata["weight_compression"] = f"LUT{args.bits} kmeans"
37
+ compressed.save(args.output)
38
+ report = {"source": args.source, "output": args.output, "granularity": args.granularity,
39
+ "source_bytes": package_bytes(Path(args.source)),
40
+ "output_bytes": package_bytes(Path(args.output)), "compression_seconds": time.perf_counter() - start}
41
+ Path(args.output).with_suffix(".json").write_text(json.dumps(report, indent=2) + "\n")
42
+ print(json.dumps(report, indent=2))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
conversion.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source_model": "fastino/gliner2.5-multi-v1",
3
+ "source_revision": "a221b77a8baf4a613b8f8652661d41fa10a5641e",
4
+ "package": "build/gliner2_multi_classification_fp32_L128_K8.mlpackage",
5
+ "package_bytes": 1152027886,
6
+ "native_total_parameters": 287355159,
7
+ "exported_parameters": 278719489,
8
+ "wrapper_max_logit_error": 0.0,
9
+ "coremltools": "9.0",
10
+ "torch": "2.7.0",
11
+ "cases": [
12
+ {
13
+ "text": "The rocket launched successfully.",
14
+ "native_label": "sports",
15
+ "coreml_label": "sports",
16
+ "native_confidence": 0.5060790777206421,
17
+ "coreml_confidence": 0.5060756206512451,
18
+ "absolute_confidence_error": 3.4570693969726562e-06
19
+ },
20
+ {
21
+ "text": "The team won the football championship.",
22
+ "native_label": "sports",
23
+ "coreml_label": "sports",
24
+ "native_confidence": 0.9999996423721313,
25
+ "coreml_confidence": 0.9999996423721313,
26
+ "absolute_confidence_error": 0.0
27
+ },
28
+ {
29
+ "text": "The budget was approved by parliament.",
30
+ "native_label": "politics",
31
+ "coreml_label": "politics",
32
+ "native_confidence": 0.9999977350234985,
33
+ "coreml_confidence": 0.9999977350234985,
34
+ "absolute_confidence_error": 0.0
35
+ }
36
+ ]
37
+ }
convert-coreml.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert and verify the pinned GLiNER2.5-multi classification decision path."""
2
+ import argparse
3
+ import json
4
+ import math
5
+ from pathlib import Path
6
+
7
+ import coremltools as ct
8
+ import numpy as np
9
+ import torch
10
+ from gliner2 import AutoExtractor
11
+ from huggingface_hub import snapshot_download
12
+ from transformers.models.deberta_v2 import modeling_deberta_v2
13
+
14
+ from export_model import GLiNER2ClassificationExport, coreml_safe_attention_forward
15
+ from preprocessing import native_batch, prepare_classification
16
+
17
+ MODEL_ID = "fastino/gliner2.5-multi-v1"
18
+ MODEL_REVISION = "a221b77a8baf4a613b8f8652661d41fa10a5641e"
19
+ EXAMPLES = [
20
+ ("The rocket launched successfully.", "topic", ["science", "sports", "politics"]),
21
+ ("The team won the football championship.", "topic", ["science", "sports", "politics"]),
22
+ ("The budget was approved by parliament.", "topic", ["science", "sports", "politics"]),
23
+ ]
24
+
25
+ def main():
26
+ parser = argparse.ArgumentParser()
27
+ parser.add_argument("--output-dir", default="build")
28
+ parser.add_argument("--length", type=int, default=128)
29
+ parser.add_argument("--max-options", type=int, default=8)
30
+ parser.add_argument("--precision", choices=["fp16", "fp32"], default="fp16")
31
+ args = parser.parse_args()
32
+ torch.set_num_threads(4)
33
+ source = snapshot_download(
34
+ MODEL_ID, revision=MODEL_REVISION,
35
+ allow_patterns=[
36
+ "config.json", "encoder_config/*", "model.safetensors", "tokenizer.json", "tokenizer_config.json"
37
+ ],
38
+ )
39
+ native = AutoExtractor.from_pretrained(source, map_location="cpu").eval()
40
+ wrapper = GLiNER2ClassificationExport(native).eval()
41
+ text, task, labels = EXAMPLES[0]
42
+ arrays = prepare_classification(native, text, task, labels, args.length, args.max_options)
43
+ tensors = tuple(torch.from_numpy(value) for value in arrays.values())
44
+ with torch.no_grad():
45
+ batch = native_batch(native, text, task, labels, args.length)
46
+ core = native._encode_core(batch)
47
+ expected = native.classifier(core["cls_specs"][0][0]["choice_states"]).squeeze(-1)
48
+ actual = wrapper(*tensors)[0][0, : len(labels)]
49
+ wrapper_error = float((expected - actual).abs().max())
50
+ if wrapper_error > 1e-4:
51
+ raise RuntimeError(f"Wrapper/native logit mismatch: {wrapper_error}")
52
+ # The upstream scale is a constant for a fixed DeBERTa attention head width.
53
+ # Its traced int32 sqrt is rejected by Core ML; freeze the identical float32
54
+ # value while tracing, and restore the upstream implementation immediately.
55
+ original_scale = modeling_deberta_v2.scaled_size_sqrt
56
+ original_rpos = modeling_deberta_v2.build_rpos
57
+ original_attention = modeling_deberta_v2.DisentangledSelfAttention.forward
58
+
59
+ def static_scale(query_layer, scale_factor):
60
+ value = math.sqrt(float(query_layer.shape[-1] * scale_factor))
61
+ return torch.tensor(value, dtype=torch.float32, device=query_layer.device)
62
+
63
+ modeling_deberta_v2.scaled_size_sqrt = static_scale
64
+ # The encoder only uses self-attention: query and key sequence lengths are
65
+ # identical, so the scripted build_rpos returns relative_pos unchanged.
66
+ # Freeze that branch to avoid a Core ML conditional with mismatched ranks.
67
+ modeling_deberta_v2.build_rpos = lambda query, key, relative_pos, buckets, max_pos: relative_pos
68
+ modeling_deberta_v2.DisentangledSelfAttention.forward = coreml_safe_attention_forward
69
+ try:
70
+ with torch.no_grad():
71
+ frozen = wrapper(*tensors)[0][0, : len(labels)]
72
+ frozen_error = float((expected - frozen).abs().max())
73
+ if frozen_error > 1e-4:
74
+ raise RuntimeError(f"Frozen attention scale changed native logits: {frozen_error}")
75
+ traced = torch.jit.trace(wrapper, tensors, check_trace=False)
76
+ finally:
77
+ modeling_deberta_v2.scaled_size_sqrt = original_scale
78
+ modeling_deberta_v2.build_rpos = original_rpos
79
+ modeling_deberta_v2.DisentangledSelfAttention.forward = original_attention
80
+ converted = ct.convert(
81
+ traced, convert_to="mlprogram", minimum_deployment_target=ct.target.iOS17,
82
+ compute_precision=ct.precision.FLOAT16 if args.precision == "fp16" else ct.precision.FLOAT32,
83
+ compute_units=ct.ComputeUnit.CPU_ONLY,
84
+ inputs=[
85
+ ct.TensorType(name="input_ids", shape=(1, args.length), dtype=np.int32),
86
+ ct.TensorType(name="attention_mask", shape=(1, args.length), dtype=np.int32),
87
+ ct.TensorType(name="marker_indices", shape=(1, args.max_options), dtype=np.int32),
88
+ ct.TensorType(name="marker_mask", shape=(1, args.max_options), dtype=np.float32),
89
+ ],
90
+ outputs=[ct.TensorType(name="logits", dtype=np.float32), ct.TensorType(name="probabilities", dtype=np.float32)],
91
+ )
92
+ converted.short_description = "GLiNER2.5-multi native schema classification path"
93
+ converted.author = "Fastino (original); Fluid Inference (Core ML conversion)"
94
+ converted.license = "Apache-2.0"
95
+ converted.user_defined_metadata.update({
96
+ "source_model": MODEL_ID, "source_revision": MODEL_REVISION,
97
+ "scope": "classification only; entity/relation/record extraction heads not exported",
98
+ "length": str(args.length), "max_options": str(args.max_options),
99
+ })
100
+ out = Path(args.output_dir)
101
+ out.mkdir(parents=True, exist_ok=True)
102
+ package = out / f"gliner2_multi_classification_{args.precision}_L{args.length}_K{args.max_options}.mlpackage"
103
+ converted.save(str(package))
104
+ runtime = ct.models.MLModel(str(package), compute_units=ct.ComputeUnit.ALL)
105
+ cases = []
106
+ for text, task, labels in EXAMPLES:
107
+ arrays = prepare_classification(native, text, task, labels, args.length, args.max_options)
108
+ native_output = native.classify_text(text, {task: labels}, include_confidence=True, max_len=args.length)[task]
109
+ prediction = runtime.predict(arrays)
110
+ scores = np.asarray(prediction["probabilities"])[0, : len(labels)]
111
+ choice = labels[int(scores.argmax())]
112
+ if choice != native_output["label"]:
113
+ raise RuntimeError(f"Core ML/native choice mismatch: {choice} != {native_output['label']}")
114
+ cases.append({
115
+ "text": text, "native_label": native_output["label"], "coreml_label": choice,
116
+ "native_confidence": native_output["confidence"], "coreml_confidence": float(scores.max()),
117
+ "absolute_confidence_error": abs(float(scores.max()) - native_output["confidence"]),
118
+ })
119
+ report = {
120
+ "source_model": MODEL_ID, "source_revision": MODEL_REVISION, "package": str(package),
121
+ "package_bytes": sum(f.stat().st_size for f in package.rglob("*") if f.is_file()),
122
+ "native_total_parameters": sum(p.numel() for p in native.parameters()),
123
+ "exported_parameters": sum(p.numel() for p in wrapper.parameters()),
124
+ "wrapper_max_logit_error": wrapper_error, "coremltools": ct.__version__,
125
+ "torch": torch.__version__, "cases": cases,
126
+ }
127
+ (out / "conversion.json").write_text(json.dumps(report, indent=2) + "\n")
128
+ print(json.dumps(report, indent=2))
129
+
130
+ if __name__ == "__main__":
131
+ main()
export_model.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native GLiNER2 classification path with explicit marker routing."""
2
+ import torch
3
+ from torch import nn
4
+ from transformers.models.deberta_v2 import modeling_deberta_v2
5
+
6
+
7
+ def coreml_safe_attention_forward(
8
+ self, hidden_states, attention_mask, output_attentions=False,
9
+ query_states=None, relative_pos=None, rel_embeddings=None,
10
+ ):
11
+ """Native DeBERTa attention with a finite mask sentinel for FP16 Core ML."""
12
+ if query_states is None:
13
+ query_states = hidden_states
14
+ query = self.transpose_for_scores(self.query_proj(query_states), self.num_attention_heads)
15
+ key = self.transpose_for_scores(self.key_proj(hidden_states), self.num_attention_heads)
16
+ value = self.transpose_for_scores(self.value_proj(hidden_states), self.num_attention_heads)
17
+ factor = 1 + int("c2p" in self.pos_att_type) + int("p2c" in self.pos_att_type)
18
+ scale = modeling_deberta_v2.scaled_size_sqrt(query, factor)
19
+ scores = torch.bmm(query, key.transpose(-1, -2) / scale.to(dtype=query.dtype))
20
+ if self.relative_attention:
21
+ relative = self.disentangled_attention_bias(
22
+ query, key, relative_pos, self.pos_dropout(rel_embeddings), factor
23
+ )
24
+ scores = scores + relative
25
+ scores = scores.view(-1, self.num_attention_heads, scores.size(-2), scores.size(-1))
26
+ scores = scores.masked_fill(~attention_mask.bool(), -1e4)
27
+ probabilities = self.dropout(torch.softmax(scores, dim=-1))
28
+ context = torch.bmm(probabilities.view(-1, probabilities.size(-2), probabilities.size(-1)), value)
29
+ context = context.view(-1, self.num_attention_heads, context.size(-2), context.size(-1))
30
+ context = context.permute(0, 2, 1, 3).contiguous()
31
+ context = context.view(context.size()[:-2] + (-1,))
32
+ return (context, probabilities) if output_attentions else (context, None)
33
+
34
+ class GLiNER2ClassificationExport(nn.Module):
35
+ def __init__(self, native: nn.Module):
36
+ super().__init__()
37
+ self.encoder = native.encoder
38
+ self.classifier = native.classifier
39
+ self.temperature = float(native.boundary_settings.classification_temperature)
40
+
41
+ def forward(self, input_ids, attention_mask, marker_indices, marker_mask):
42
+ hidden = self.encoder(input_ids=input_ids.long(), attention_mask=attention_mask.long()).last_hidden_state
43
+ indices = marker_indices.long().unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
44
+ states = hidden.gather(1, indices)
45
+ logits = self.classifier(states).squeeze(-1) / self.temperature
46
+ logits = torch.where(marker_mask > 0.5, logits, torch.full_like(logits, -1e4))
47
+ return logits, torch.softmax(logits, dim=-1)
gliner2_multi_classification_fp16_L128_K8.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ddee410a764552de583f87c2fa383b327f962cd0a3a06129d25463e300df9ec4
3
+ size 990100
gliner2_multi_classification_fp16_L128_K8.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:519b9689dce771f7434aa12ec68ea3ceb47d9675888f54825c0dbf84aea0ac41
3
+ size 575538112
gliner2_multi_classification_fp16_L128_K8.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "5189F522-6EF7-44F9-A58A-C2AE3A2607EE": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "53AB227A-708E-47B2-A73B-1C4A0787D02E": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "5189F522-6EF7-44F9-A58A-C2AE3A2607EE"
18
+ }
gliner2_multi_classification_fp32_L128_K8.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:41d12153f9453d5bc2c0b31c2d9469639b8719e71243e1d9821ed8fe3a5ca987
3
+ size 965317
gliner2_multi_classification_fp32_L128_K8.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:273c343d1a26d66a0feca3325e335e9825dcac6f476f57f1f8e51d594bf4e860
3
+ size 1151061952
gliner2_multi_classification_fp32_L128_K8.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "6FD8F497-0DFB-47AC-A8DE-EFECB501243E": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "DAB0BCC1-3F7E-4D48-836A-E64F9EAD6142": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "6FD8F497-0DFB-47AC-A8DE-EFECB501243E"
18
+ }
preprocessing.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native GLiNER2 schema preprocessing for a fixed Core ML bucket."""
2
+ import numpy as np
3
+ from gliner2 import Schema
4
+ from gliner2.models.base import load_extractor_tokenizer
5
+ from gliner2.processor import SchemaTransformer
6
+ from gliner2.training.trainer import ExtractorCollator
7
+
8
+
9
+ def load_processor(tokenizer_dir: str):
10
+ """Load only the tokenizer and schema formatter needed by the Core ML model."""
11
+ return SchemaTransformer(tokenizer=load_extractor_tokenizer(tokenizer_dir), token_pooling="first")
12
+
13
+ def native_batch(native, text: str, task: str, labels: list[str], length: int):
14
+ schema = Schema().classification(task, labels)
15
+ collator = ExtractorCollator(native.processor, is_training=False, max_len=length, architecture=native.architecture)
16
+ return collator([(text, schema.build())])
17
+
18
+ def prepare_classification(native, text: str, task: str, labels: list[str], length: int, max_options: int):
19
+ return prepare_with_processor(native.processor, text, task, labels, length, max_options)
20
+
21
+
22
+ def prepare_with_processor(processor, text: str, task: str, labels: list[str], length: int, max_options: int):
23
+ if not 1 <= len(labels) <= max_options:
24
+ raise ValueError(f"Expected 1..{max_options} labels, got {len(labels)}")
25
+ schema = Schema().classification(task, labels)
26
+ collator = ExtractorCollator(processor, is_training=False, max_len=length, architecture="boundary")
27
+ batch = collator([(text, schema.build())])
28
+ ids = batch.input_ids.numpy()
29
+ attention = batch.attention_mask.numpy()
30
+ indices = batch.cls_marker_indices.numpy()
31
+ mask = batch.cls_marker_mask.numpy()
32
+ if ids.shape[1] > length or indices.shape[1] != len(labels) or int(mask.sum()) != len(labels):
33
+ raise ValueError("Input exceeds bucket or classification markers were truncated")
34
+ ids = np.pad(ids, ((0, 0), (0, length - ids.shape[1])), constant_values=processor.tokenizer.pad_token_id)
35
+ attention = np.pad(attention, ((0, 0), (0, length - attention.shape[1])))
36
+ indices = np.pad(indices, ((0, 0), (0, max_options - indices.shape[1])))
37
+ mask = np.pad(mask, ((0, 0), (0, max_options - mask.shape[1])))
38
+ return {
39
+ "input_ids": ids.astype(np.int32),
40
+ "attention_mask": attention.astype(np.int32),
41
+ "marker_indices": indices.astype(np.int32),
42
+ "marker_mask": mask.astype(np.float32),
43
+ }
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gliner2-multi-coreml"
3
+ version = "0.1.0"
4
+ description = "Pinned GLiNER 2.5 small decision classifier Core ML export"
5
+ requires-python = ">=3.12,<3.13"
6
+ dependencies = [
7
+ "coremltools==9.0",
8
+ "gliner2[local]==2.0.0",
9
+ "huggingface-hub>=0.34,<1",
10
+ "numpy<2.3",
11
+ "protobuf>=5,<7",
12
+ "sentencepiece>=0.2,<0.3",
13
+ "torch==2.7.0",
14
+ "transformers==4.57.6",
15
+ ]
16
+
17
+ [dependency-groups]
18
+ dev = ["pytest>=8.4", "ruff>=0.13"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+ pythonpath = ["."]
23
+
24
+ [tool.ruff]
25
+ line-length = 120
26
+ target-version = "py312"
27
+
28
+ [tool.ruff.lint]
29
+ select = ["E", "F", "I"]
runtime.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a GLiNER2 Core ML classifier without loading the original model weights."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import coremltools as ct
7
+ import numpy as np
8
+
9
+ from preprocessing import load_processor, prepare_with_processor
10
+
11
+
12
+ def classify(model_dir: str, text: str, task: str, labels: list[str], length: int = 128,
13
+ max_options: int = 8, precision: str = "fp16"):
14
+ model_dir = Path(model_dir)
15
+ package = model_dir / f"gliner2_multi_classification_{precision}_L{length}_K{max_options}.mlpackage"
16
+ processor = load_processor(str(model_dir / "tokenizer"))
17
+ arrays = prepare_with_processor(processor, text, task, labels, length, max_options)
18
+ model = ct.models.MLModel(str(package), compute_units=ct.ComputeUnit.ALL)
19
+ scores = np.asarray(model.predict(arrays)["probabilities"])[0, : len(labels)]
20
+ return {"label": labels[int(scores.argmax())], "confidence": float(scores.max()),
21
+ "probabilities": {label: float(score) for label, score in zip(labels, scores)}}
22
+
23
+
24
+ def main():
25
+ parser = argparse.ArgumentParser()
26
+ parser.add_argument("--model-dir", required=True)
27
+ parser.add_argument("--text", required=True)
28
+ parser.add_argument("--task", default="decision")
29
+ parser.add_argument("--labels", required=True, help="JSON list of label strings")
30
+ parser.add_argument("--length", type=int, default=128)
31
+ parser.add_argument("--max-options", type=int, default=8)
32
+ parser.add_argument("--precision", choices=["fp16", "fp32"], default="fp16")
33
+ args = parser.parse_args()
34
+ print(json.dumps(classify(args.model_dir, args.text, args.task, json.loads(args.labels),
35
+ args.length, args.max_options, args.precision), indent=2))
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
tokenizer/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c62446df87ae18ec98b133f8f84fc449a07cc89bbf8ef192a4cb5f9c53777a7a
3
+ size 16035853
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": true,
3
+ "backend": "tokenizers",
4
+ "bos_token": "[CLS]",
5
+ "cls_token": "[CLS]",
6
+ "do_lower_case": false,
7
+ "eos_token": "[SEP]",
8
+ "extra_special_tokens": [
9
+ "[SEP_STRUCT]",
10
+ "[SEP_TEXT]",
11
+ "[P]",
12
+ "[C]",
13
+ "[E]",
14
+ "[R]",
15
+ "[L]",
16
+ "[EXAMPLE]",
17
+ "[OUTPUT]",
18
+ "[DESCRIPTION]"
19
+ ],
20
+ "is_local": true,
21
+ "local_files_only": false,
22
+ "mask_token": "[MASK]",
23
+ "model_max_length": 1000000000000000019884624838656,
24
+ "pad_token": "[PAD]",
25
+ "sep_token": "[SEP]",
26
+ "split_by_punct": false,
27
+ "tokenizer_class": "DebertaV2Tokenizer",
28
+ "unk_id": 3,
29
+ "unk_token": "[UNK]",
30
+ "vocab_type": "spm"
31
+ }
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
verify-fp16-application300.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "fastino/gliner2.5-multi-v1",
3
+ "revision": "a221b77a8baf4a613b8f8652661d41fa10a5641e",
4
+ "package": "build/gliner2_multi_classification_fp16_L128_K8.mlpackage",
5
+ "selected_manifest": "first eligible rows in source suite order; no gold labels used",
6
+ "counts": {
7
+ "checked": 300,
8
+ "too_many_options": 300,
9
+ "too_long": 75,
10
+ "duplicate_labels": 0,
11
+ "mismatches": 1
12
+ },
13
+ "maximum_confidence_error": 0.00924915075302124,
14
+ "mean_confidence_error": 0.0009126420815785726,
15
+ "model_call_p50_ms": 8.808917016722262,
16
+ "model_call_p95_ms": 13.894708012230694,
17
+ "failures": [
18
+ {
19
+ "suite": "jev.ag_news",
20
+ "index": 100,
21
+ "native": {
22
+ "label": "sports",
23
+ "confidence": 0.4976130723953247
24
+ },
25
+ "coreml": "world news and international politics"
26
+ }
27
+ ]
28
+ }
verify-fp32-application100.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "fastino/gliner2.5-multi-v1",
3
+ "revision": "a221b77a8baf4a613b8f8652661d41fa10a5641e",
4
+ "package": "build/gliner2_multi_classification_fp32_L128_K8.mlpackage",
5
+ "selected_manifest": "first eligible rows in source suite order; no gold labels used",
6
+ "counts": {
7
+ "checked": 100,
8
+ "too_many_options": 300,
9
+ "too_long": 31,
10
+ "duplicate_labels": 0,
11
+ "mismatches": 0
12
+ },
13
+ "maximum_confidence_error": 8.046627044677734e-06,
14
+ "mean_confidence_error": 7.531046867370605e-07,
15
+ "model_call_p50_ms": 11.235417041461915,
16
+ "model_call_p95_ms": 19.665875006467104,
17
+ "failures": []
18
+ }
verify-multilingual-fixtures.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "text": "La sélection a remporté le championnat de football.",
4
+ "native": {
5
+ "label": "sports",
6
+ "confidence": 0.9999890327453613
7
+ },
8
+ "fp16": {
9
+ "label": "sports",
10
+ "confidence": 0.9999892711639404
11
+ },
12
+ "fp32": {
13
+ "label": "sports",
14
+ "confidence": 0.9999890327453613
15
+ }
16
+ },
17
+ {
18
+ "text": "El parlamento aprobó el presupuesto.",
19
+ "native": {
20
+ "label": "politics",
21
+ "confidence": 0.9999994039535522
22
+ },
23
+ "fp16": {
24
+ "label": "politics",
25
+ "confidence": 0.9999994039535522
26
+ },
27
+ "fp32": {
28
+ "label": "politics",
29
+ "confidence": 0.9999994039535522
30
+ }
31
+ },
32
+ {
33
+ "text": "科学家发现了一颗新的行星。",
34
+ "native": {
35
+ "label": "science",
36
+ "confidence": 0.9999904632568359
37
+ },
38
+ "fp16": {
39
+ "label": "science",
40
+ "confidence": 0.9999902248382568
41
+ },
42
+ "fp32": {
43
+ "label": "science",
44
+ "confidence": 0.9999904632568359
45
+ }
46
+ },
47
+ {
48
+ "text": "Das Team gewann die Meisterschaft.",
49
+ "native": {
50
+ "label": "sports",
51
+ "confidence": 0.9999986886978149
52
+ },
53
+ "fp16": {
54
+ "label": "sports",
55
+ "confidence": 0.9999986886978149
56
+ },
57
+ "fp32": {
58
+ "label": "sports",
59
+ "confidence": 0.9999986886978149
60
+ }
61
+ }
62
+ ]
verify.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare the pinned GLiNER2 native classifier with its Core ML export."""
2
+ import argparse
3
+ import json
4
+ import statistics
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import coremltools as ct
9
+ import numpy as np
10
+ import torch
11
+ from gliner2 import AutoExtractor
12
+
13
+ from preprocessing import prepare_classification
14
+
15
+ MODEL_ID = "fastino/gliner2.5-multi-v1"
16
+ MODEL_REVISION = "a221b77a8baf4a613b8f8652661d41fa10a5641e"
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--package", default="build/gliner2_multi_classification_fp16_L128_K8.mlpackage")
22
+ parser.add_argument("--suite", required=True)
23
+ parser.add_argument("--limit", type=int, default=100)
24
+ parser.add_argument("--length", type=int, default=128)
25
+ parser.add_argument("--max-options", type=int, default=8)
26
+ parser.add_argument("--out", default="build/verify.json")
27
+ args = parser.parse_args()
28
+ torch.set_num_threads(4)
29
+ source = Path.home() / ".cache/huggingface/hub/models--fastino--gliner2.5-multi-v1/snapshots" / MODEL_REVISION
30
+ native = AutoExtractor.from_pretrained(str(source), map_location="cpu").eval()
31
+ model = ct.models.MLModel(args.package, compute_units=ct.ComputeUnit.ALL)
32
+ counts = {"checked": 0, "too_many_options": 0, "too_long": 0, "duplicate_labels": 0, "mismatches": 0}
33
+ errors = []
34
+ latencies = []
35
+ failures = []
36
+ for line in Path(args.suite).open():
37
+ row = json.loads(line)
38
+ options = row.get("options") or []
39
+ if len(options) > args.max_options or not options:
40
+ counts["too_many_options"] += 1
41
+ continue
42
+ labels = [(description or key).strip() for key, description in options]
43
+ if len(set(labels)) != len(labels):
44
+ counts["duplicate_labels"] += 1
45
+ continue
46
+ text = row["state"]
47
+ task = "decision"
48
+ try:
49
+ arrays = prepare_classification(native, text, task, labels, args.length, args.max_options)
50
+ except ValueError:
51
+ counts["too_long"] += 1
52
+ continue
53
+ expected = native.classify_text(text, {task: labels}, include_confidence=True, max_len=args.length)[task]
54
+ start = time.perf_counter()
55
+ prediction = model.predict(arrays)
56
+ latencies.append((time.perf_counter() - start) * 1000)
57
+ probabilities = np.asarray(prediction["probabilities"])[0, : len(labels)]
58
+ chosen = labels[int(probabilities.argmax())]
59
+ error = abs(float(probabilities.max()) - float(expected["confidence"]))
60
+ errors.append(error)
61
+ counts["checked"] += 1
62
+ if chosen != expected["label"]:
63
+ counts["mismatches"] += 1
64
+ failures.append({"suite": row["suite"], "index": row["index"], "native": expected, "coreml": chosen})
65
+ if counts["checked"] >= args.limit:
66
+ break
67
+ result = {
68
+ "model": MODEL_ID, "revision": MODEL_REVISION, "package": args.package,
69
+ "selected_manifest": "first eligible rows in source suite order; no gold labels used",
70
+ "counts": counts, "maximum_confidence_error": max(errors, default=None),
71
+ "mean_confidence_error": statistics.mean(errors) if errors else None,
72
+ "model_call_p50_ms": statistics.median(latencies[1:]) if len(latencies) > 1 else None,
73
+ "model_call_p95_ms": sorted(latencies[1:])[int(0.95 * (len(latencies) - 1))] if len(latencies) > 1 else None,
74
+ "failures": failures[:20],
75
+ }
76
+ Path(args.out).parent.mkdir(parents=True, exist_ok=True)
77
+ Path(args.out).write_text(json.dumps(result, indent=2) + "\n")
78
+ print(json.dumps(result, indent=2))
79
+ if counts["checked"] == 0 or counts["mismatches"]:
80
+ raise SystemExit(1)
81
+
82
+ if __name__ == "__main__":
83
+ main()