jeff-coreml / export.py
alexwengg's picture
Publish validated Jeff GLiFormer Large L128 FP16 Core ML classifier
0deb31c verified
Raw
History Blame Contribute Delete
6.74 kB
"""Validate Jeff's trained classifier graph and export its complete decision path."""
from __future__ import annotations
import argparse
import json
import warnings
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from jeff.backends.torch_backend import TorchBackend
from jeff.core.backend import Group
from jeff_decision import JeffDecision, marker_positions
from trace_compat import finite_fp16_mask, install_trace_compatibility
SOURCE = "knowledgator/gliformer-large-v1"
REVISION = "d0a4e53d09cebe6bc963dd9be319d4279084bb2d"
BUCKET = 128
MAX_CATEGORIES = 8
FIXTURES = (
(
"billing",
"The invoice was charged twice and the customer asks for a refund.",
Group(key="route", labels=("billing: invoice or payment issue", "support: technical product issue"),
name="Choose the correct support queue"),
),
(
"technical",
"The app crashes when I save my project. Please help me recover the file.",
Group(key="route", labels=("billing: invoice or payment issue", "support: technical product issue"),
name="Choose the correct support queue"),
),
(
"three_way",
"Tomorrow at 9 a.m. works well for the appointment.",
Group(key="intent", labels=("schedule: appointment request", "billing: payment issue",
"support: technical issue"), name="Classify the user intent"),
),
(
"boolean",
"I cannot sign in after resetting my password.",
Group(key="answer", labels=("yes", "no"), name="Is this a technical support request?"),
),
)
def make_batch(backend: TorchBackend, text: str, group: Group) -> dict:
tokens, _, _ = backend.model.prepare_inputs([text])
return backend._collator([{
"tokenized_text": tokens[0],
"classification": [{
"name": group.name,
"description": group.description,
"all_labels": list(group.labels),
"true_labels": [],
}],
}])
def model_inputs(batch: dict, config) -> tuple[torch.Tensor, ...]:
ids = batch["input_ids"]
mask = batch["attention_mask"]
if ids.shape[1] > BUCKET:
raise ValueError(f"input has {ids.shape[1]} tokens; L{BUCKET} cannot serve it")
parent, children, count = marker_positions(ids, config, MAX_CATEGORIES)
if count != len(batch["classes_mapping"].cat_mapping[0].cat_class_to_id[0].class_to_id):
raise ValueError("collator category mapping does not match marker count")
pad = BUCKET - ids.shape[1]
return (
F.pad(ids.to(torch.int32), (0, pad)),
F.pad(mask.to(torch.int32), (0, pad)),
parent,
children,
)
@torch.inference_mode()
def native_report(backend: TorchBackend, decision: JeffDecision) -> tuple[list[dict], tuple[torch.Tensor, ...]]:
records = []
first = None
for name, text, group in FIXTURES:
batch = make_batch(backend, text, group)
tensors = model_inputs(batch, backend.model.config)
native = backend.model.model(**batch, include_media=False).cat_logits.detach().float().numpy()[0]
converted = decision(*tensors).detach().float().numpy()[0, :len(group.labels)]
error = float(np.max(np.abs(native - converted)))
records.append({
"name": name,
"token_count": int(batch["attention_mask"].sum()),
"labels": list(group.labels),
"native_logits": native.tolist(),
"decision_logits": converted.tolist(),
"max_logit_error": error,
})
if error > 1e-3:
raise AssertionError(f"{name}: trained graph differs from native logits by {error}")
if first is None:
first = tensors
assert first is not None
return records, first
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--convert", action="store_true")
parser.add_argument("--precision", choices=("fp16", "fp32"), default="fp16")
args = parser.parse_args()
torch.set_num_threads(2)
checkpoint = snapshot_download(SOURCE, revision=REVISION, local_files_only=True)
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message=r"attn_kernel=.*flashdeberta")
backend = TorchBackend(checkpoint, device="cpu", dtype="float32", attn_kernel="eager", batch_size=1)
decision = JeffDecision(backend.model.model).eval()
report, first = native_report(backend, decision)
out = Path("build")
out.mkdir(exist_ok=True)
(out / "native-parity.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({"native_parity": report}, indent=2), flush=True)
if not args.convert:
return
import coremltools as ct
install_trace_compatibility()
with finite_fp16_mask():
patched_report, _ = native_report(backend, decision)
patched_error = max(
abs(before - after)
for baseline, patched in zip(report, patched_report)
for before, after in zip(baseline["decision_logits"], patched["decision_logits"])
)
if patched_error > 1e-3:
raise AssertionError(f"tracing-only mask/attention scale changes native logits by {patched_error}")
print(f"Patched mask/attention max native logit error: {patched_error:.8f}", flush=True)
traced = torch.jit.trace(decision, first, check_trace=False).eval()
traced.save(str(out / "jeff-decision-L128.pt"))
with torch.inference_mode():
expected = decision(*first).detach().numpy()
actual = traced(*first).detach().numpy()
trace_error = float(np.max(np.abs(expected - actual)))
if trace_error > 1e-3:
raise AssertionError(f"TorchScript trace mismatch: {trace_error}")
print(f"TorchScript trace max logit error: {trace_error:.8f}", flush=True)
mlmodel = ct.convert(
traced,
convert_to="mlprogram",
minimum_deployment_target=ct.target.macOS15,
compute_precision=ct.precision.FLOAT16 if args.precision == "fp16" else ct.precision.FLOAT32,
inputs=[
ct.TensorType(name="input_ids", shape=(1, BUCKET), dtype=np.int32),
ct.TensorType(name="attention_mask", shape=(1, BUCKET), dtype=np.int32),
ct.TensorType(name="parent_position", shape=(1, 1), dtype=np.int32),
ct.TensorType(name="category_positions", shape=(1, MAX_CATEGORIES), dtype=np.int32),
],
)
package = out / f"JeffDecision-L128-{args.precision.upper()}.mlpackage"
mlmodel.save(str(package))
print(f"Saved {package}", flush=True)
if __name__ == "__main__":
main()