Download predict.py from robertollweb/matrixai-will-it-rain: direct link, hf CLI and curl.
- Browser
- Download file 22.4 kB
-
https://huggingface.co/robertollweb/matrixai-will-it-rain/resolve/main/predict.py
- Command line
-
hf download hf://robertollweb/matrixai-will-it-rain/predict.py
-
curl -L -o predict.py https://huggingface.co/robertollweb/matrixai-will-it-rain/resolve/main/predict.py
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()) | |