Skip to content

Sviluppo di un Service con modello servibile ​

Per rendere un modello deployabile tramite ALIDA (vedi Deploy e utilizzo dei modelli), il Service di training deve produrre nella cartella --output-model due elementi obbligatori:

  1. Gli artefatti del modello nel formato fisico corretto per il runtime di inferenza scelto
  2. Il file model-settings.json che descrive a MLServer come caricare il modello e quale schema di input/output esporre

Il file model-settings.json ​

Il file model-settings.json deve essere creato dallo sviluppatore e salvato nella cartella --output-model. Contiene:

  • name — nome del modello, deve seguire la convenzione ALIDA model-{output_model_id}
  • implementation — Python path al runtime MLServer da usare (es. mlserver_sklearn.SKLearnModel)
  • parameters — configurazione del runtime:
    • uri — percorso relativo all'artefatto del modello
    • content_type — (opzionale) content type di default per la decodifica dei payload (vedi Content type)
    • extra — (opzionale) parametri extra specifici del runtime (es. task per HuggingFace)
  • inputs — schema degli input: nome, tipo V2 e shape di ciascuna feature
  • outputs — schema degli output: nome, tipo V2 e shape della predizione

I campi inputs e outputs sono quelli che ALIDA mostra come Model Metadata nella pagina di dettaglio del modello e che l'utente usa per costruire le richieste di inferenza. È responsabilità dello sviluppatore compilarli correttamente.

Struttura generale ​

json
{
  "name": "model-<output_model_id>",
  "implementation": "<runtime-mlserver>",
  "parameters": {
    "uri": "<percorso-artefatto-relativo>"
  },
  "inputs": [
    {
      "name": "<nome-feature>",
      "datatype": "<tipo-v2>",
      "shape": [-1]
    }
  ],
  "outputs": [
    {
      "name": "predict",
      "datatype": "<tipo-v2>",
      "shape": [-1, 1]
    }
  ]
}

Lo shape usa -1 come placeholder per la dimensione batch (numero di campioni variabile). Per una feature scalare per campione usare [-1]; per una feature vettoriale usare [-1, N].

outputs come contratto esposto ​

Il campo outputs del model-settings.json definisce il contratto esposto dal modello: tutto ciò che viene dichiarato lì appare nei Model Metadata nella pagina di dettaglio del modello in ALIDA, ed è ciò che l'utente vede e può richiedere.

Lo sviluppatore ha piena libertà su quali output dichiarare. Ad esempio, per un classificatore sklearn si può scegliere di esporre solo la classe predetta (predict), oppure anche le probabilità per classe (predict_proba), oppure entrambe:

json
"outputs": [
  { "name": "predict",       "datatype": "INT64", "shape": [-1, 1] },
  { "name": "predict_proba", "datatype": "FP64",  "shape": [-1, 3] }
]

L'utente potrà richiedere esplicitamente uno degli output dichiarati nel payload di inferenza:

json
{
  "inputs": [...],
  "outputs": [{ "name": "predict_proba" }]
}

"Nota — output disponibili per runtime"

I nomi degli output che MLServer riconosce dipendono dal runtime:
RuntimeOutput disponibiliNote
mlserver_sklearn.SKLearnModelpredict, predict_proba, transformpredict_proba solo per classificatori; transform solo per pipeline; predict è il default
mlserver_xgboost.XGBoostModelpredictOutput unico
mlserver_lightgbm.LightGBMModelpredictOutput unico
mlserver_catboost.CatBoostModelpredictOutput unico
mlserver_mlflow.MLflowRuntimepredictChiama predict() del pyfunc MLflow; non espone predict_proba separato
mlserver_huggingface.HuggingFaceRuntimedipende dal taskL'output viene restituito nel formato del task HuggingFace configurato
custom (mlserver.MLModel)definiti dallo sviluppatoreLo sviluppatore costruisce la InferenceResponse nel metodo predict()

Per la regressione non dichiarare mai predict_proba negli outputs indipendentemente dal runtime.

Content type ​

Il content_type determina come MLServer decodifica il payload V2 prima di passarlo al modello. I content type disponibili sono:

Content typeTipo Python risultanteLivello requestLivello input
npnumpy.ndarray✅✅
pdpandas.DataFrame✅❌
strstr (UTF-8)✅✅
base64byte decodificati da base64❌✅
datetimedatetime.datetime❌✅

Con np, il campo data è piatto e shape viene usato per fare il reshape del tensore. Adatto per modelli che accettano un array numerico puro con ordine delle colonne già fissato.

Con pd, gli input vengono aggregati in un pandas.DataFrame, dove il nome di ciascun input diventa il nome della colonna. Adatto per pipeline sklearn tabellari che usano i nomi delle colonne per applicare trasformazioni diverse. pd opera solo a livello request e può essere combinato con content type a livello input (es. np per colonne numeriche, str per colonne stringa).

Il content type può essere dichiarato nel model-settings.json (nei parameters a livello request, o nella sezione parameters di ciascun input). Questo diventa il default: le richieste che non specificano un content type esplicito useranno quello dei metadata. Content type specificati esplicitamente nella richiesta hanno sempre precedenza su quelli dei metadata.

"Nota — content type default di sklearn"

Se nessun content type è dichiarato nel metadata o nella richiesta, il runtime sklearn decodifica il payload come NumPy array. Per usare Pandas DataFrame, il developer deve dichiarare esplicitamente `"content_type": "pd"` nei `parameters` del `model-settings.json`.

Tipi di dato supportati (V2 Inference Protocol) ​

TipoDescrizioneTipo pandas corrispondente
BOOLBooleanobool
INT8Intero con segno a 8 bitint8
INT16Intero con segno a 16 bitint16
INT32Intero con segno a 32 bitint32
INT64Intero con segno a 64 bitint64
UINT8Intero senza segno a 8 bituint8
UINT16Intero senza segno a 16 bituint16
UINT32Intero senza segno a 32 bituint32
UINT64Intero senza segno a 64 bituint64
FP16Floating point a 16 bitfloat16
FP32Floating point a 32 bitfloat32
FP64Floating point a 64 bitfloat64
BYTESByte / stringa / categorico / objectobject, category
STRINGStringa UTF-8string

Generazione del model-settings.json ​

Si raccomanda di generare il file programmaticamente a partire dai dati di training, derivando i tipi V2 dai dtype pandas di X e y. Di seguito una funzione di utilità:

python
import json
from pathlib import Path
import pandas as pd


def to_v2_dtype(pd_dtype) -> str:
    """Mappa un dtype pandas al tipo V2 corrispondente."""
    kind = getattr(pd_dtype, "kind", "O")
    if kind == "b":
        return "BOOL"
    if kind in ("i", "u"):
        return "INT64"
    if kind == "f":
        return "FP64" if str(pd_dtype) in ("float64", "Float64") else "FP32"
    return "BYTES"


def create_model_settings(
    model_dir: str,
    X_sample: pd.DataFrame,
    y_sample: pd.Series,
    model_id: str,
    implementation: str,
    uri: str = ".",
    is_regression: bool = False,
    content_type: str = None,
    extra: dict = None,
):
    """
    Genera model-settings.json nella cartella del modello.

    Args:
        model_dir:      cartella output-model ALIDA
        X_sample:       DataFrame con le feature (anche un solo campione)
        y_sample:       Series con il target (anche un solo campione)
        model_id:       valore di args.output_model_id iniettato da ALIDA
        implementation: runtime MLServer (es. "mlserver_sklearn.SKLearnModel")
        uri:            percorso relativo dell'artefatto modello
        is_regression:  True per regressione, False per classificazione
        content_type:   content type per la decodifica del payload (es. "pd", "np")
        extra:          parametri extra per il runtime (es. {"task": "question-answering"})
    """
    inputs = [
        {
            "name": col,
            "datatype": to_v2_dtype(dtype),
            "shape": [-1]
        }
        for col, dtype in zip(X_sample.columns, X_sample.dtypes)
    ]

    if is_regression:
        out_dtype = to_v2_dtype(y_sample.dtype)
        outputs = [{"name": "predict", "datatype": out_dtype, "shape": [-1, 1]}]
    else:
        y_kind = getattr(y_sample.dtype, "kind", "O")
        if y_kind == "b":
            out_dtype = "BOOL"
        elif y_kind in ("i", "u"):
            out_dtype = "INT64"
        else:
            out_dtype = "BYTES"
        outputs = [{"name": "predict", "datatype": out_dtype, "shape": [-1, 1]}]

    parameters = {"uri": uri}
    if content_type is not None:
        parameters["content_type"] = content_type
    if extra is not None:
        parameters["extra"] = extra

    settings = {
        "name": f"model-{model_id}",
        "implementation": implementation,
        "parameters": parameters,
        "inputs": inputs,
        "outputs": outputs
    }

    settings_path = Path(model_dir) / "model-settings.json"
    with open(settings_path, "w", encoding="utf-8") as f:
        json.dump(settings, f, indent=2, ensure_ascii=False)

    return settings_path

"Nota"

La funzione genera un singolo output `predict`. Per esporre output aggiuntivi (es. `predict_proba`), lo sviluppatore può aggiungere manualmente ulteriori elementi alla lista `outputs` dopo la chiamata, o estendere la funzione secondo le proprie esigenze.

Struttura della cartella output attesa ​

<output-model>/
├── model-settings.json      ← obbligatorio
└── <artefatto-modello>      ← dipende dal runtime scelto

Runtime supportati ​

Runtime MLServerformat nel metamodelloFormato artefatto
mlserver_sklearn.SKLearnModelsklearn.skops (raccomandato), .joblib, .pkl
mlserver_xgboost.XGBoostModelxgboost.json, .bst
mlserver_lightgbm.LightGBMModellightgbm.txt, .bst
mlserver_catboost.CatBoostModelcatboost.cbm
mlserver_mlflow.MLflowRuntimemlflowcartella MLflow (con MLmodel)
mlserver_huggingface.HuggingFaceRuntimehuggingfacemodello locale o da HuggingFace Hub
classe custom che estende mlserver.MLModelpythondirectory con file .py + artefatto

Il valore format nel metamodello è usato da ALIDA per il routing interno verso il runtime di serving corretto ed è indipendente dal campo implementation del model-settings.json.


Scikit-learn ​

Runtime: mlserver_sklearn.SKLearnModel

Formato artefatto raccomandato: file .skops, prodotto con la libreria Skops. A differenza di pickle/joblib, Skops non permette l'esecuzione di codice arbitrario durante la deserializzazione.

Sono supportati anche i formati .joblib e .pkl.

python
import skops.io as sio

os.makedirs(args.output_model, exist_ok=True)
model_path = os.path.join(args.output_model, "model.skops")
sio.dump(pipeline, model_path)

create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_sklearn.SKLearnModel",
    uri="model.skops",
    content_type="pd",
    is_regression=False
)

XGBoost ​

Runtime: mlserver_xgboost.XGBoostModel

Formato artefatto: file .json o .bst — formato nativo XGBoost (save_model()).

python
model_path = os.path.join(args.output_model, "model.json")
model.save_model(model_path)

create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_xgboost.XGBoostModel",
    uri="model.json",
    is_regression=False
)

LightGBM ​

Runtime: mlserver_lightgbm.LightGBMModel

Formato artefatto: file .txt o .bst — formato nativo LightGBM (booster_.save_model()).

python
model_path = os.path.join(args.output_model, "model.txt")
model.booster_.save_model(model_path)

create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_lightgbm.LightGBMModel",
    uri="model.txt",
    is_regression=False
)

CatBoost ​

Runtime: mlserver_catboost.CatBoostModel

Formato artefatto: file .cbm — formato nativo CatBoost (save_model()).

python
model_path = os.path.join(args.output_model, "model.cbm")
model.save_model(model_path)

create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_catboost.CatBoostModel",
    uri="model.cbm",
    is_regression=False
)

MLflow (formato nativo) ​

Runtime: mlserver_mlflow.MLflowRuntime

Formato artefatto: cartella MLflow completa contenente il file MLmodel e gli artefatti del flavour. Quando il modello MLflow definisce una signature, MLServer la converte automaticamente in uno schema di metadata V2. Il model-settings.json va comunque creato per specificare il nome del modello e l'implementation.

python
import mlflow

run_id = mlflow.active_run().info.run_id
mlflow.artifacts.download_artifacts(
    run_id=run_id,
    artifact_path="model",
    dst_path=args.output_model
)

create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_mlflow.MLflowRuntime",
    uri=".",
    content_type="pd",
    is_regression=False
)

HuggingFace ​

Runtime: mlserver_huggingface.HuggingFaceRuntime

Il runtime HuggingFace supporta modelli dalla libreria HuggingFace Transformers. La configurazione avviene tramite il campo parameters.extra del model-settings.json:

  • task — (obbligatorio) il tipo di task HuggingFace (es. "question-answering", "text-generation", "text-classification", ecc.)
  • pretrained_model — (opzionale) nome del modello nell'HuggingFace Hub; ha precedenza su parameters.uri
  • optimum_model — (opzionale) true per usare modelli ottimizzati con Optimum
  • device — (opzionale) device su cui caricare il modello (0 per GPU, -1 per CPU)

Per caricare un modello locale, specificare il percorso in parameters.uri. Per caricare un modello dall'HuggingFace Hub, specificare il nome in parameters.extra.pretrained_model.

!!! warning "Attenzione — risorse" I modelli HuggingFace Transformer possono richiedere quantità significative di memoria RAM e/o GPU. Verificare che l'istanza ALIDA disponga di risorse sufficienti per il modello scelto. In fase di registrazione del Service, dichiarare le risorse necessarie tramite le Service Property di tipo Resource (vedi Registrazione Service).

Quando si usa `pretrained_model`, il modello viene scaricato dall'HuggingFace Hub al primo caricamento — questo richiede connettività di rete e spazio disco nel container. Per evitare ritardi al primo deploy, è consigliabile includere gli artefatti del modello direttamente nella cartella `--output-model`.
json
{
  "name": "model-<output_model_id>",
  "implementation": "mlserver_huggingface.HuggingFaceRuntime",
  "parameters": {
    "extra": {
      "task": "text-classification",
      "pretrained_model": "distilbert-base-uncased-finetuned-sst-2-english"
    }
  },
  "inputs": [
    {
      "name": "text_inputs",
      "datatype": "BYTES",
      "shape": [-1]
    }
  ],
  "outputs": [
    {
      "name": "output",
      "datatype": "BYTES",
      "shape": [-1, 1]
    }
  ]
}

Python custom ​

Runtime: classe custom che estende mlserver.MLModel

Per modelli non coperti dai runtime standard, è possibile implementare un runtime custom. Lo sviluppatore deve creare un file Python con una classe che estende mlserver.MLModel e implementa i metodi load() e predict():

python
from mlserver import MLModel
from mlserver.types import InferenceRequest, InferenceResponse
from mlserver.codecs import NumpyCodec
import numpy as np


class MyCustomModel(MLModel):

    async def load(self) -> bool:
        # Caricare il modello da self.settings.parameters.uri
        model_uri = self.settings.parameters.uri
        # ... logica di caricamento ...
        self.ready = True
        return self.ready

    async def predict(self, payload: InferenceRequest) -> InferenceResponse:
        # Decodifica input
        input_data = self.decode(payload.inputs[0])
        # ... logica di inferenza ...
        result = np.array([0])  # esempio
        return InferenceResponse(
            model_name=self.settings.name,
            outputs=[NumpyCodec.encode_output("predict", result)]
        )

Il model-settings.json specifica il runtime come <nome_file>.<NomeClasse>:

json
{
  "name": "model-<output_model_id>",
  "implementation": "my_model.MyCustomModel",
  "parameters": {
    "uri": "."
  },
  "inputs": [...],
  "outputs": [...]
}

Il file .py con la classe deve trovarsi nella stessa cartella del model-settings.json (cartella --output-model).

"Nota"

I metodi `load()` e `predict()` sono **asincroni** (`async`). Tutte le dipendenze Python utilizzate dalla classe devono essere installate nel container del *Service* (dichiarate nel `requirements.txt`). Il file `.py` deve essere autocontenuto o importare solo moduli presenti nell'immagine Docker.

!!! warning "Attenzione — formati da preferire per il serving" I formati basati su serializzazione Python (pickle, joblib) sono supportati ma sconsigliati per modelli destinati al serving. Preferire i formati nativi dei framework (Skops per sklearn, .json per XGBoost, ecc.) che garantiscono maggiore sicurezza nella deserializzazione.

Dichiarazione nel metamodello ​

La porta output-model nel metamodello del Service deve dichiarare il campo model con il valore format appropriato. Questo valore è usato da ALIDA per il routing interno verso il runtime di serving corretto ed è indipendente dal campo implementation del model-settings.json:

json
{
    "key": "output-model",
    "type": "application",
    "mandatory": true,
    "invisible": true,
    "valueType": "STRING",
    "defaultValue": null,
    "model": { "format": "sklearn" }
}

Esempio completo — Training sklearn con Skops e serving ​

python
import argparse

def str2bool(v):
    if isinstance(v, bool):
        return v
    if v.lower() in ('yes', 'true', 't', 'y', '1'):
        return True
    elif v.lower() in ('no', 'false', 'f', 'n', '0', ''):
        return False

parser = argparse.ArgumentParser()

parser.add_argument('--input-dataset', dest='input_dataset', type=str, required=True)
parser.add_argument('--input-dataset.s3_bucket', dest='input_s3_bucket', type=str, required=True)
parser.add_argument('--input-dataset.s3_URL', dest='input_s3_url', type=str, required=True)
parser.add_argument('--input-dataset.s3_ACCESS_KEY', dest='input_access_key', type=str, required=True)
parser.add_argument('--input-dataset.s3_SECRET_KEY', dest='input_secret_key', type=str, required=True)
parser.add_argument('--input-dataset.s3_REGION', dest='input_dataset_region_name', type=str, required=False)
parser.add_argument('--input-dataset.use_ssl', dest='input_use_ssl', type=str2bool, required=True)
parser.add_argument('--input-columns', dest='input_columns', type=str, required=False)

parser.add_argument('--output-model', dest='output_model', type=str, required=True)
parser.add_argument('--output-model.s3_bucket', dest='output_s3_bucket', type=str, required=True)
parser.add_argument('--output-model.s3_URL', dest='output_s3_url', type=str, required=True)
parser.add_argument('--output-model.s3_ACCESS_KEY', dest='output_access_key', type=str, required=True)
parser.add_argument('--output-model.s3_SECRET_KEY', dest='output_secret_key', type=str, required=True)
parser.add_argument('--output-model.s3_REGION', dest='output_model_region_name', type=str, required=False)
parser.add_argument('--output-model.use_ssl', dest='output_use_ssl', type=str2bool, required=True)
parser.add_argument('--output-model.id', dest='output_model_id', type=str, required=True)

parser.add_argument('--label-column', dest='label_column', type=str, required=True)

args, unknown = parser.parse_known_args()
python
import json
import os
from pathlib import Path

import pandas as pd
import skops.io as sio
from minio import Minio
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

from arguments import args


def to_v2_dtype(pd_dtype) -> str:
    kind = getattr(pd_dtype, "kind", "O")
    if kind == "b":
        return "BOOL"
    if kind in ("i", "u"):
        return "INT64"
    if kind == "f":
        return "FP64" if str(pd_dtype) in ("float64", "Float64") else "FP32"
    return "BYTES"


def create_model_settings(model_dir, X_sample, y_sample, model_id, implementation,
                          uri=".", is_regression=False, content_type=None, extra=None):
    inputs = [
        {"name": col, "datatype": to_v2_dtype(dtype), "shape": [-1]}
        for col, dtype in zip(X_sample.columns, X_sample.dtypes)
    ]
    if is_regression:
        out_dtype = to_v2_dtype(y_sample.dtype)
    else:
        y_kind = getattr(y_sample.dtype, "kind", "O")
        out_dtype = "BOOL" if y_kind == "b" else "INT64" if y_kind in ("i", "u") else "BYTES"
    outputs = [{"name": "predict", "datatype": out_dtype, "shape": [-1, 1]}]

    parameters = {"uri": uri}
    if content_type is not None:
        parameters["content_type"] = content_type
    if extra is not None:
        parameters["extra"] = extra

    settings = {
        "name": f"model-{model_id}",
        "implementation": implementation,
        "parameters": parameters,
        "inputs": inputs,
        "outputs": outputs
    }
    settings_path = Path(model_dir) / "model-settings.json"
    with open(settings_path, "w", encoding="utf-8") as f:
        json.dump(settings, f, indent=2, ensure_ascii=False)
    return settings_path


def s3_ls(address, access_key, secret_key, region, bucket_name, folder, extension, use_ssl=False):
    if not folder.endswith("/"):
        folder = folder + "/"
    cleaned = address.replace("http://", "").replace("https://", "")
    client = Minio(cleaned, access_key=access_key, secret_key=secret_key, secure=use_ssl, region=region)
    objects = client.list_objects(bucket_name=bucket_name, prefix=folder)
    files_list = [x._object_name for x in objects if x._object_name.endswith(extension)]
    if len(files_list) == 0:
        raise Exception("Dataset vuoto!")
    return "s3://" + bucket_name + "/" + files_list[0]


# Caricamento dataset
storage_options = {
    'key': args.input_access_key,
    'secret': args.input_secret_key,
    'region': args.input_dataset_region_name,
    'client_kwargs': {'endpoint_url': args.input_s3_url}
}
file_path = s3_ls(
    args.input_s3_url, args.input_access_key, args.input_secret_key,
    args.input_dataset_region_name, args.input_s3_bucket, args.input_dataset, ".csv"
)
dataset = pd.read_csv(file_path, storage_options=storage_options, sep=None, engine='python')

if args.input_columns is not None and args.input_columns.strip() != '*':
    dataset = dataset[[c.strip() for c in args.input_columns.split(",")]].copy()

X = dataset.drop(columns=[args.label_column])
y = dataset[args.label_column]

# Training
pipeline = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", LogisticRegression())
])
pipeline.fit(X, y)

# Salvataggio modello in formato Skops
os.makedirs(args.output_model, exist_ok=True)
model_path = os.path.join(args.output_model, "model.skops")
sio.dump(pipeline, model_path)

# Generazione model-settings.json
create_model_settings(
    model_dir=args.output_model,
    X_sample=X,
    y_sample=y,
    model_id=args.output_model_id,
    implementation="mlserver_sklearn.SKLearnModel",
    uri="model.skops",
    content_type="pd",
    is_regression=False
)

print(f"Modello salvato in {args.output_model}", flush=True)

Il metamodello corrispondente dichiara la porta output con format: "sklearn":

json
{
    "key": "output-model",
    "type": "application",
    "mandatory": true,
    "invisible": true,
    "valueType": "STRING",
    "defaultValue": null,
    "model": { "format": "sklearn" }
}