bashkir-roberta-ner / runtime.py
failed09's picture
Release update
16409df verified
Raw History Blame Contribute Delete
3.94 kB
"""Standalone ONNX inference for Bashkir person-name recognition.
The graph returns logits at SentencePiece positions. Training assigned one BIO
label to the first piece of each original word, so this runtime does the same.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import numpy as np
import onnxruntime as ort
import sentencepiece as spm
TAGS = ("O", "B-PER", "I-PER")
WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁёӘәҒғҘҙҠҡҢңӨөҪҫҮүҺһҚқІі][\w'\-]*", re.UNICODE)
class BashkirPersonNER:
def __init__(self, model_dir: str | Path = ".", precision: str = "int8",
providers: list[str] | None = None):
self.directory = Path(model_dir)
config = json.loads((self.directory / "config.json").read_text(encoding="utf-8"))
if precision not in config["models"]:
raise ValueError(f"precision must be one of {sorted(config['models'])}")
self.max_length = int(config["max_position_embeddings"])
self.bos_id = int(config["bos_token_id"])
self.eos_id = int(config["eos_token_id"])
self.sp = spm.SentencePieceProcessor(
model_file=str(self.directory / "spm_bashkir_bert_16k.model")
)
model = self.directory / config["models"][precision]
self.session = ort.InferenceSession(
str(model), providers=providers or ["CPUExecutionProvider"]
)
def predict_tokens(self, tokens: list[str]) -> tuple[list[str], bool]:
"""Return word-level BIO tags and whether input exceeded the context limit.
The caller supplies the same token boundaries used for gold evaluation.
On truncation, the returned tag list covers only its leading words.
"""
if not tokens:
return [], False
ids = [self.bos_id]
positions = []
for word in tokens:
pieces = list(self.sp.encode(word, out_type=int)) or [self.sp.unk_id()]
if len(ids) + len(pieces) + 1 > self.max_length:
break
positions.append(len(ids))
ids.extend(pieces)
if not positions:
return [], True
ids.append(self.eos_id)
logits = self.session.run(
["logits"], {"input_ids": np.asarray([ids], dtype=np.int64)}
)[0][0]
return [TAGS[int(logits[pos].argmax())] for pos in positions], len(positions) < len(tokens)
def predict(self, text: str) -> dict:
"""Return person spans with character offsets into the original text."""
if not isinstance(text, str):
raise TypeError("text must be a string")
matches = list(WORD_RE.finditer(text))
tags, truncated = self.predict_tokens([m.group() for m in matches])
entities = []
start = None
end = None
for match, tag in zip(matches, tags):
if tag == "B-PER":
if start is not None:
entities.append({"text": text[start:end], "start": start, "end": end})
start, end = match.span()
elif tag == "I-PER":
# Current benchmark convention: an orphan I-PER starts a span.
if start is None:
start = match.start()
end = match.end()
elif start is not None:
entities.append({"text": text[start:end], "start": start, "end": end})
start = end = None
if start is not None:
entities.append({"text": text[start:end], "start": start, "end": end})
return {"entities": entities, "truncated": truncated}
if __name__ == "__main__":
import sys
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ner = BashkirPersonNER(Path(__file__).resolve().parent)
for sample in sys.argv[1:]:
print(json.dumps(ner.predict(sample), ensure_ascii=False))