alexwengg commited on
Commit
4e12f7c
·
verified ·
1 Parent(s): 9683ff9

Publish GLiNER2.5 small Core ML classification exports

Browse files
README.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: coremltools
4
+ pipeline_tag: text-classification
5
+ tags:
6
+ - coreml
7
+ - gliner2
8
+ - apple-silicon
9
+ - neural-engine
10
+ ---
11
+
12
+ # GLiNER2.5 small Core ML classification
13
+
14
+ This is a Core ML conversion of the **classification decision path** of
15
+ [Fastino's GLiNER2.5 small](https://huggingface.co/fastino/gliner2.5-small-v1),
16
+ source revision `7e6f537f10337497069276892a5ef435028252ce`.
17
+ The original model has 73,881,879 parameters and is licensed Apache-2.0.
18
+ This export contains its encoder and trained classification head (70,944,385
19
+ parameters); entity, relation, record, count and span extraction heads are **not**
20
+ included. Use the original checkpoint for those tasks.
21
+
22
+ The FP16 L128/K8 package occupies 151,542,752 bytes. It accepts up to eight
23
+ classification labels, with native GLiNER2 schema rendering. The tokenizer
24
+ files are included in this repository. The Core ML deployment target is iOS 17
25
+ or macOS 14. `preprocessing.py` and `runtime.py` implement the request path
26
+ without loading the original model weights at inference time.
27
+
28
+ A Python example, from the directory containing this README:
29
+
30
+ ```bash
31
+ uv sync
32
+ uv run python runtime.py --model-dir . \
33
+ --text "The rocket launched successfully." \
34
+ --task topic --labels '["science","sports","politics"]'
35
+ ```
36
+
37
+ The model returns a label, confidence and probabilities. Inputs that exceed the
38
+ bucket capacity need a larger bucket; the runtime must not truncate them. The
39
+ runtime requires macOS to execute Core ML prediction.
40
+
41
+ ## Validation
42
+
43
+ On an Apple M5 Pro running macOS 27.0, the FP16 package matched the native
44
+ classifier's chosen label on all 100 eligible requests selected in source order
45
+ from a fixed application suite. The largest chosen-label confidence difference
46
+ was 0.001723. Median Python `MLModel.predict` call time was 7.72 ms, including
47
+ Python/Core ML dispatch; this is not an ANE-only measure. There were 300 rows
48
+ with more than eight options and seven over-length rows before 100 eligible rows
49
+ were collected. The smoke run does not establish a Decision Index score or
50
+ broader application accuracy. `verify-application100.json` contains the exact
51
+ counts and results.
52
+
53
+ An experimental LUT8 per-tensor package occupies 76,211,068 bytes and matched
54
+ all 100 chosen labels, but its largest confidence difference was 0.07859.
55
+ It is not the recommended parity artifact. Grouped-channel LUT8 requires an
56
+ iOS 18 or later Core ML target and has not yet been evaluated.
57
+
58
+ Conversion source, pinned dependencies and verification scripts are included.
59
+ The exporter freezes two static DeBERTa attention expressions and uses a finite
60
+ FP16 mask sentinel; the real-model tests and native/Core ML checks guard this
61
+ rewrite. Upstream authors receive credit for the original model; Fluid
62
+ Inference performed this Core ML conversion.
assets.lock.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source": "fastino/gliner2.5-small-v1",
3
+ "revision": "7e6f537f10337497069276892a5ef435028252ce",
4
+ "files": {
5
+ "config.json": {
6
+ "size": 3152,
7
+ "sha256": "0b7d9e1401ceeb83e992ec66d2f93bff7e5646428f1b4706ec527cf88f53578a"
8
+ },
9
+ "encoder_config/config.json": {
10
+ "size": 856,
11
+ "sha256": "db837d0dc587f5858687ef860c1f400de10f3c3e44f88daef8cbda80d74e4c9c"
12
+ },
13
+ "model.safetensors": {
14
+ "size": 295567700,
15
+ "sha256": "4ee982787ace270d4bf15dbcb28ced38e0aa201372347114ceedd6336055de2b"
16
+ },
17
+ "tokenizer.json": {
18
+ "size": 8341713,
19
+ "sha256": "cbc8ae6037812709c9c26f2a160f8dc48b0440bcb79c8141804259ae2d6adac3"
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-small-v1",
3
+ "source_revision": "7e6f537f10337497069276892a5ef435028252ce",
4
+ "package": "build/gliner2_small_classification_fp16_L128_K8.mlpackage",
5
+ "package_bytes": 151542752,
6
+ "native_total_parameters": 73881879,
7
+ "exported_parameters": 70944385,
8
+ "wrapper_max_logit_error": 4.76837158203125e-07,
9
+ "coremltools": "9.0",
10
+ "torch": "2.7.0",
11
+ "cases": [
12
+ {
13
+ "text": "The rocket launched successfully.",
14
+ "native_label": "science",
15
+ "coreml_label": "science",
16
+ "native_confidence": 0.9989821314811707,
17
+ "coreml_confidence": 0.9989795088768005,
18
+ "absolute_confidence_error": 2.6226043701171875e-06
19
+ },
20
+ {
21
+ "text": "The team won the football championship.",
22
+ "native_label": "sports",
23
+ "coreml_label": "sports",
24
+ "native_confidence": 0.9999417066574097,
25
+ "coreml_confidence": 0.9999417066574097,
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.9999653100967407,
33
+ "coreml_confidence": 0.9999654293060303,
34
+ "absolute_confidence_error": 1.1920928955078125e-07
35
+ }
36
+ ]
37
+ }
convert-coreml.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert and verify the pinned GLiNER2.5-small 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-small-v1"
18
+ MODEL_REVISION = "7e6f537f10337497069276892a5ef435028252ce"
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-small 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_small_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_small_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:c544ebc230a3515195b40556f4e9f625c88dd29e0ca5f8f23a127219e2d26588
3
+ size 596663
gliner2_small_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:91200fe39fdcabfc341288e560de9786df102ba519bcbcd3271320017abb2cd6
3
+ size 150945472
gliner2_small_classification_fp16_L128_K8.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "0BDBE49B-B06F-49EC-8702-7009DEAF64A4": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "FBBC8447-2EAA-4114-BB90-A1ED959442C0": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "FBBC8447-2EAA-4114-BB90-A1ED959442C0"
18
+ }
gliner2_small_classification_lut8_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:1db07dbf4e21947966373ec7df9f9b315cb711a02445c3f72e7ff38ff11b137b
3
+ size 611923
gliner2_small_classification_lut8_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:361194f5c5f530fc0e6f20705f7340f5e1ad6c5a451b5d3fc4d46364a6abbe00
3
+ size 75598528
gliner2_small_classification_lut8_L128_K8.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "1F7B203F-B038-49C4-9982-5CEBB5449266": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "4CB15B68-E62E-4344-86BA-955F68BADB51": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "4CB15B68-E62E-4344-86BA-955F68BADB51"
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-small-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,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, max_options: int = 8):
13
+ model_dir = Path(model_dir)
14
+ package = model_dir / f"gliner2_small_classification_fp16_L{length}_K{max_options}.mlpackage"
15
+ processor = load_processor(str(model_dir / "tokenizer"))
16
+ arrays = prepare_with_processor(processor, text, task, labels, length, max_options)
17
+ model = ct.models.MLModel(str(package), compute_units=ct.ComputeUnit.ALL)
18
+ scores = np.asarray(model.predict(arrays)["probabilities"])[0, : len(labels)]
19
+ return {"label": labels[int(scores.argmax())], "confidence": float(scores.max()),
20
+ "probabilities": {label: float(score) for label, score in zip(labels, scores)}}
21
+
22
+
23
+ def main():
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--model-dir", required=True)
26
+ parser.add_argument("--text", required=True)
27
+ parser.add_argument("--task", default="decision")
28
+ parser.add_argument("--labels", required=True, help="JSON list of label strings")
29
+ parser.add_argument("--length", type=int, default=128)
30
+ parser.add_argument("--max-options", type=int, default=8)
31
+ args = parser.parse_args()
32
+ print(json.dumps(classify(args.model_dir, args.text, args.task, json.loads(args.labels),
33
+ args.length, args.max_options), indent=2))
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
tokenizer/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
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-application100.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "fastino/gliner2.5-small-v1",
3
+ "revision": "7e6f537f10337497069276892a5ef435028252ce",
4
+ "package": "build/gliner2_small_classification_fp16_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": 7,
10
+ "duplicate_labels": 0,
11
+ "mismatches": 0
12
+ },
13
+ "maximum_confidence_error": 0.0017229318618774414,
14
+ "mean_confidence_error": 0.00020694971084594726,
15
+ "model_call_p50_ms": 7.718917040619999,
16
+ "model_call_p95_ms": 9.14695899700746,
17
+ "failures": []
18
+ }
verify-lut8-application100.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "fastino/gliner2.5-small-v1",
3
+ "revision": "7e6f537f10337497069276892a5ef435028252ce",
4
+ "package": "build/gliner2_small_classification_lut8_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": 7,
10
+ "duplicate_labels": 0,
11
+ "mismatches": 0
12
+ },
13
+ "maximum_confidence_error": 0.07858973741531372,
14
+ "mean_confidence_error": 0.004985119104385376,
15
+ "model_call_p50_ms": 7.664750039111823,
16
+ "model_call_p95_ms": 10.965458990540355,
17
+ "failures": []
18
+ }
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-small-v1"
16
+ MODEL_REVISION = "7e6f537f10337497069276892a5ef435028252ce"
17
+
18
+
19
+ def main():
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--package", default="build/gliner2_small_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-small-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()