matrixai-will-it-rain / predict.py
robertollweb's picture
MatrixAI reproducible case — bundle, receipts and model card
87d3c54 verified
Raw History Blame Contribute Delete
22.4 kB
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (C) 2026 Roberto Llamosas Conde
"""Standalone inference wrapper for a MatrixAI downloadable model.
This file ships inside an exported MatrixAI bundle as ``predict.py``. It has
**zero dependency on MatrixAI or the Studio** — only ``numpy`` and
``onnxruntime``. Feed RAW, human-readable values; the normalization, categorical
encoding and label mapping that the model was trained with are applied here.
from predict import MatrixAIModel
model = MatrixAIModel() # loads inference_spec.json next to this file
model.predict({"edad": 65, "especialidad": "UCI"})
# -> {"NO": 0.23, "SI": 0.77}
python predict.py --input example_input.json
The contract between this script and the model is ``inference_spec.json``:
For tabular models, ``input_order`` is the exact float32 column order and
``fields`` says how raw values map onto it. Sequence models instead declare
either a ``token_ids`` input (pre-tokenized ids, TRANSFORMER_BLOQUE C5) or a
``text`` input (SECUENCIAS_PRODUCTO C5) — raw text, tokenized HERE with the
embedded ``_ByteTokenizer`` (byte_v1: UTF-8 bytes are the token ids, zero
dependencies, deterministic) — plus an optional padding mask in both cases.
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from typing import Any
import numpy as np
import onnxruntime as ort
_TRUE = {"true", "1", "yes", "si", "sí", "y", "t"}
_FALSE = {"false", "0", "no", "n", "f"}
class MatrixAIModelError(Exception):
"""Raised on a malformed input record or a spec/model mismatch."""
class _ByteTokenizer:
"""SECUENCIAS_PRODUCTO C5 — embedded byte_v1 tokenizer, zero dependency.
Mirrors ``matrixai.text.tokenizer.ByteTokenizer.encode`` exactly (this
file ships standalone, no MatrixAI import): UTF-8 bytes ARE the token
ids (0-255); PAD fills up to ``length``; CLS (if ``add_cls``) takes slot
0, reserving one position. Config comes straight from inference_spec.json
(already validated at export time — the model and the config agree by
construction), so this stays a thin, trusting encoder.
"""
def __init__(self, config: dict[str, Any]) -> None:
if config.get("kind") != "byte_v1":
raise MatrixAIModelError(
f"Unknown tokenizer kind {config.get('kind')!r}; expected 'byte_v1'."
)
self.length = int(config["length"])
self.pad = int(config["pad"])
self.cls = int(config["cls"])
self.add_cls = bool(config.get("add_cls", False))
def encode(self, text: str) -> list[int]:
if not isinstance(text, str):
raise MatrixAIModelError(f"Expected a string to tokenize, got {type(text).__name__}.")
raw = text.encode("utf-8")
limit = self.length - 1 if self.add_cls else self.length
ids = list(raw[:limit])
if self.add_cls:
ids = [self.cls] + ids
if len(ids) < self.length:
ids = ids + [self.pad] * (self.length - len(ids))
return ids
class MatrixAIModel:
"""Load a MatrixAI bundle and run predictions from raw records."""
def __init__(self, spec_path: str | Path | None = None, *, check_hash: bool = True) -> None:
here = Path(__file__).resolve().parent
spec_path = Path(spec_path) if spec_path is not None else here / "inference_spec.json"
if not spec_path.is_absolute():
spec_path = (here / spec_path).resolve()
if not spec_path.exists():
raise MatrixAIModelError(f"inference_spec.json not found at {spec_path}")
self.spec: dict[str, Any] = json.loads(spec_path.read_text(encoding="utf-8"))
self.input_order: list[str] = list(self.spec["input_order"])
self.input_index = {col: i for i, col in enumerate(self.input_order)}
self.fields: dict[str, Any] = self.spec.get("fields", {})
self.output: dict[str, Any] = self.spec.get("output", {})
# SECUENCIAS_PRODUCTO C5: "text" (tokenizador embebido) se une a
# "token_ids" (TRANSFORMER_BLOQUE C5, retrocompatible) — un bundle
# concreto solo declara UNA de las dos, nunca ambas.
sequence_inputs = {
name: entry for name, entry in self.spec.get("input", {}).items()
if entry.get("encoding") in ("token_ids", "text")
}
if len(sequence_inputs) > 1:
raise MatrixAIModelError("predict.py supports exactly one sequence input.")
self.sequence_name: str | None = next(iter(sequence_inputs), None)
self.sequence_entry: dict[str, Any] | None = (
sequence_inputs.get(self.sequence_name) if self.sequence_name else None
)
self._tokenizer: "_ByteTokenizer | None" = None
if self.sequence_entry is not None and self.sequence_entry.get("encoding") == "text":
self._tokenizer = _ByteTokenizer(self.sequence_entry["tokenizer"])
self.input_name: str = self.spec.get("input_name") or self.spec.get("onnx_input")
if not self.input_name:
raise MatrixAIModelError("inference_spec.json does not declare input_name.")
self.mask_input: str | None = self.spec.get("mask_input")
if self.sequence_name and not self.mask_input:
raise MatrixAIModelError("sequence spec does not declare mask_input.")
onnx_path = spec_path.parent / self.spec.get("onnx_file", "model.onnx")
if not onnx_path.exists():
raise MatrixAIModelError(f"ONNX model not found at {onnx_path}")
self.session = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
if check_hash:
self._verify_hash()
# -- public API --------------------------------------------------------
def predict(self, record: Any, *, return_meta: bool = False):
if self.sequence_name:
ids, mask, meta = self._encode_sequence(record)
raw = self.session.run(None, {
self.input_name: np.asarray([ids], dtype=np.int64),
self.mask_input: np.asarray([mask], dtype=np.float32),
})[0]
result = self._decode(np.asarray(raw)[0])
return (result, meta) if return_meta else result
vector, meta = self._encode(record)
x = np.asarray([vector], dtype=np.float32)
raw = self.session.run(None, {self.input_name: x})[0]
result = self._decode(np.asarray(raw)[0])
return (result, meta) if return_meta else result
def predict_batch(self, records: list[Any], *, return_meta: bool = False):
if self.sequence_name:
encoded = [self._encode_sequence(r) for r in records]
ids = np.asarray([row[0] for row in encoded], dtype=np.int64)
masks = np.asarray([row[1] for row in encoded], dtype=np.float32)
raw = self.session.run(None, {
self.input_name: ids,
self.mask_input: masks,
})[0]
raw = np.asarray(raw)
results = [self._decode(raw[i]) for i in range(len(records))]
if return_meta:
return results, [row[2] for row in encoded]
return results
encoded = [self._encode(r) for r in records]
x = np.asarray([vec for vec, _ in encoded], dtype=np.float32)
raw = self.session.run(None, {self.input_name: x})[0]
raw = np.asarray(raw)
results = [self._decode(raw[i]) for i in range(len(records))]
if return_meta:
return results, [meta for _, meta in encoded]
return results
# -- encoding ----------------------------------------------------------
def _encode_sequence(self, record: Any) -> tuple[list[int], list[float], dict[str, Any]]:
"""Validate one sequence row (raw text or pre-tokenized ids,
depending on the spec) and construct its ONNX padding mask."""
assert self.sequence_name is not None and self.sequence_entry is not None
meta: dict[str, Any] = {"spec_version": self.spec.get("spec_version"),
"warnings": [], "clipped": []}
if self._tokenizer is not None:
# SECUENCIAS_PRODUCTO C5: raw text — invariante 1, tokenizado
# AQUÍ con el ByteTokenizer embebido. The mask is DERIVED from
# the tokenized ids (real vs PAD) — never accepted from the
# caller, there is no correct way for a consumer to compute it
# without the tokenizer itself.
if isinstance(record, str):
text: Any = record
elif isinstance(record, dict):
if self.sequence_name not in record:
raise MatrixAIModelError(
f"Missing required text field {self.sequence_name!r}."
)
text = record[self.sequence_name]
unknown = sorted(k for k in record if k != self.sequence_name)
if unknown:
meta["warnings"].append(f"ignored unknown fields: {unknown}")
else:
raise MatrixAIModelError(
"Text input must be a string or an object containing that string."
)
if not isinstance(text, str):
raise MatrixAIModelError(
f"Field {self.sequence_name!r}: expected a string, got {type(text).__name__}."
)
ids = self._tokenizer.encode(text)
mask = [1.0 if i != self._tokenizer.pad else 0.0 for i in ids]
if not any(mask):
raise MatrixAIModelError(
f"Field {self.sequence_name!r}: text produced an empty (all-padding) sequence."
)
if self.spec.get("pool_kind") == "cls" and mask[0] != 1.0:
raise MatrixAIModelError("POOL cls requires mask[0] = 1 (a real token).")
return ids, mask, meta
# Legacy path (TRANSFORMER_BLOQUE C5): pre-tokenized ids.
if isinstance(record, list):
raw_ids = record
raw_mask = None
elif isinstance(record, dict):
if self.sequence_name not in record:
raise MatrixAIModelError(
f"Missing required sequence field {self.sequence_name!r}."
)
raw_ids = record[self.sequence_name]
raw_mask = record.get(self.mask_input)
allowed = {self.sequence_name, self.mask_input}
unknown = sorted(k for k in record if k not in allowed)
if unknown:
meta["warnings"].append(f"ignored unknown fields: {unknown}")
else:
raise MatrixAIModelError(
"Token input must be a list of integers or an object containing that list."
)
length = int(self.sequence_entry["length"])
vocab_size = int(self.sequence_entry["vocab_size"])
if not isinstance(raw_ids, list) or len(raw_ids) != length:
got = len(raw_ids) if isinstance(raw_ids, list) else type(raw_ids).__name__
raise MatrixAIModelError(
f"Field {self.sequence_name!r}: expected a list of {length} token ids, got {got}."
)
ids: list[int] = []
for i, value in enumerate(raw_ids):
if isinstance(value, bool) or not isinstance(value, int):
raise MatrixAIModelError(
f"Field {self.sequence_name!r}[{i}]: expected an integer token id, got {value!r}."
)
if not 0 <= value < vocab_size:
raise MatrixAIModelError(
f"Field {self.sequence_name!r}[{i}]: token id {value} out of range "
f"[0, {vocab_size - 1}]."
)
ids.append(value)
if raw_mask is None:
mask = [1.0] * length
else:
if not isinstance(raw_mask, list) or len(raw_mask) != length:
got = len(raw_mask) if isinstance(raw_mask, list) else type(raw_mask).__name__
raise MatrixAIModelError(
f"Field {self.mask_input!r}: expected a list of {length} mask values, got {got}."
)
mask = []
for i, value in enumerate(raw_mask):
if isinstance(value, bool):
mask.append(1.0 if value else 0.0)
elif isinstance(value, (int, float)) and value in (0, 1):
mask.append(float(value))
else:
raise MatrixAIModelError(
f"Field {self.mask_input!r}[{i}]: mask values must be boolean or 0/1, "
f"got {value!r}."
)
if not any(mask):
raise MatrixAIModelError("Mask must keep at least one real token.")
if self.spec.get("pool_kind") == "cls" and mask[0] != 1.0:
raise MatrixAIModelError("POOL cls requires mask[0] = 1 (a real token).")
return ids, mask, meta
def _encode(self, record: dict[str, Any]) -> tuple[list[float], dict[str, Any]]:
if not isinstance(record, dict):
raise MatrixAIModelError("Input record must be a dict of raw field values.")
meta: dict[str, Any] = {"spec_version": self.spec.get("spec_version"),
"warnings": [], "clipped": []}
unknown = [k for k in record if k not in self.fields]
if unknown:
meta["warnings"].append(f"ignored unknown fields: {sorted(unknown)}")
vector = [0.0] * len(self.input_order)
for field, entry in self.fields.items():
enc = entry["encoding"]
if enc in ("scalar", "scalar01"):
self._encode_scalar(field, entry, record, vector, meta)
elif enc == "one_hot":
self._encode_one_hot(field, entry, record, vector)
elif enc == "embedding_index":
self._encode_embedding(field, entry, record, vector)
else:
raise MatrixAIModelError(f"Field {field!r}: unknown encoding {enc!r}.")
return vector, meta
def _require(self, field: str, record: dict[str, Any]) -> Any:
if field not in record:
raise MatrixAIModelError(f"Missing required field {field!r}.")
return record[field]
def _encode_scalar(self, field, entry, record, vector, meta) -> None:
value = self._require(field, record)
field_type = entry.get("type")
if field_type == "boolean":
number = _parse_bool(field, value)
elif field_type == "integer":
number = float(_parse_int(field, value)) # reject non-integers (3.7 -> error)
else:
number = _parse_number(field, value)
if entry["encoding"] == "scalar":
lo, hi = entry["range"]
span = (hi - lo) or 1.0
normalized = (number - lo) / span
else: # scalar01
normalized = number
clipped = min(1.0, max(0.0, normalized))
if clipped != normalized:
meta["clipped"].append({"field": field, "raw_value": value,
"normalized_value": clipped})
vector[self.input_index[field]] = clipped
def _encode_one_hot(self, field, entry, record, vector) -> None:
value = str(self._require(field, record))
valid = {v["raw"]: v["column"] for v in entry["values"]}
if value not in valid:
raise MatrixAIModelError(
f"Field {field!r}: unknown category {value!r}. "
f"Valid categories: {sorted(valid)}."
)
vector[self.input_index[valid[value]]] = 1.0
def _encode_embedding(self, field, entry, record, vector) -> None:
value = self._require(field, record)
column = entry.get("column", field)
if "vocab" in entry:
vocab = entry["vocab"]
text = str(value)
if text not in vocab:
raise MatrixAIModelError(
f"Field {field!r}: unknown category {text!r}. "
f"Valid categories: {vocab}."
)
index = vocab.index(text)
else:
index = _parse_int(field, value)
size = int(entry["vocab_size"])
if not 0 <= index < size:
raise MatrixAIModelError(
f"Field {field!r}: embedding index {index} out of range [0, {size - 1}]."
)
vector[self.input_index[column]] = float(index)
# -- decoding ----------------------------------------------------------
def _decode(self, raw: Any):
values = np.asarray(raw, dtype=np.float64).ravel()
kind = self.output.get("kind")
labels = self.output.get("labels") or []
if kind == "classification":
return {label: float(values[i]) for i, label in enumerate(labels)}
if kind == "binary_classification":
p = float(values[0])
return {labels[0]: 1.0 - p, labels[1]: p}
if kind == "regression":
value = float(values[0])
# CONTRATO 59 C5: el target se entrena normalizado a [0,1] desde
# el contrato 59 (ver 59_REGRESION_QUE_APRENDE_CONTRACT.md) — la
# salida cruda de la red vive en esa escala. `range` (ausente en
# un bundle exportado antes de este contrato) es el rango de
# dominio real; sin él, se devuelve el valor crudo tal cual
# (retrocompat con bundles antiguos, invariante contrato 42:
# "predice == Studio" también para un bundle exportado ANTES de
# este contrato, que nunca normalizó el target al entrenar).
rng = self.output.get("range")
if rng:
lo, hi = float(rng[0]), float(rng[1])
value = value * (hi - lo) + lo
return value
return {"values": [float(v) for v in values]}
# -- integrity ---------------------------------------------------------
def _verify_hash(self) -> None:
meta = self.session.get_modelmeta()
embedded = dict(getattr(meta, "custom_metadata_map", {}) or {})
for key in ("model_hash", "parameter_schema_hash"):
want = self.spec.get(key)
if not want:
continue # the spec doesn't declare it: nothing to check against
got = embedded.get(f"matrixai_{key}")
if not got:
raise MatrixAIModelError(
f"model.onnx does not carry the embedded {key} that "
f"inference_spec.json expects ({want!r}). This does not look like the "
"model this spec describes; predictions could be wrong. Pass "
"check_hash=False to override."
)
if want != got:
raise MatrixAIModelError(
f"Spec/model mismatch on {key}: inference_spec.json says {want!r} but "
f"model.onnx says {got!r}. This spec does not belong to this model; "
"predictions would be wrong. Pass check_hash=False to override."
)
# -- value parsers ---------------------------------------------------------
def _parse_number(field: str, value: Any) -> float:
if isinstance(value, bool):
return 1.0 if value else 0.0
try:
number = float(value)
except (TypeError, ValueError):
raise MatrixAIModelError(f"Field {field!r}: expected a number, got {value!r}.")
if math.isnan(number) or math.isinf(number):
raise MatrixAIModelError(f"Field {field!r}: value must be finite, got {value!r}.")
return number
def _parse_int(field: str, value: Any) -> int:
number = _parse_number(field, value)
if not float(number).is_integer():
raise MatrixAIModelError(f"Field {field!r}: expected an integer, got {value!r}.")
return int(number)
def _parse_bool(field: str, value: Any) -> float:
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)) and not isinstance(value, bool):
if value in (0, 1):
return float(value)
text = str(value).strip().lower()
if text in _TRUE:
return 1.0
if text in _FALSE:
return 0.0
raise MatrixAIModelError(
f"Field {field!r}: expected a boolean (true/false, 1/0, yes/no, si/no), got {value!r}."
)
# -- CLI --------------------------------------------------------------------
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run a MatrixAI downloadable model on raw input.")
parser.add_argument("--input", required=True, help="JSON file: one record (object) or a list of records.")
parser.add_argument("--spec", default=None, help="Path to inference_spec.json (default: next to this file).")
parser.add_argument("--meta", action="store_true", help="Include inference metadata in the output.")
parser.add_argument("--no-check-hash", action="store_true", help="Skip the spec/model hash check.")
args = parser.parse_args(argv)
model = MatrixAIModel(args.spec, check_hash=not args.no_check_hash)
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
try:
# For a token_ids model a JSON array of integers is one raw sequence;
# batches are arrays of objects/arrays. Tabular semantics stay intact.
is_single_sequence = (
model.sequence_name is not None
and isinstance(payload, list)
and all(isinstance(v, int) and not isinstance(v, bool) for v in payload)
)
if isinstance(payload, list) and not is_single_sequence:
out = model.predict_batch(payload, return_meta=args.meta)
else:
out = model.predict(payload, return_meta=args.meta)
except MatrixAIModelError as exc:
print(f"error: {exc}", file=__import__("sys").stderr)
return 1
if args.meta:
result, meta = out
print(json.dumps({"prediction": result, "meta": meta}, ensure_ascii=False, indent=2))
else:
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(_main())