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:
- Gli artefatti del modello nel formato fisico corretto per il runtime di inferenza scelto
- Il file
model-settings.jsonche 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 ALIDAmodel-{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 modellocontent_type— (opzionale) content type di default per la decodifica dei payload (vedi Content type)extra— (opzionale) parametri extra specifici del runtime (es.taskper HuggingFace)
inputs— schema degli input: nome, tipo V2 e shape di ciascuna featureoutputs— 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
{
"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:
"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:
{
"inputs": [...],
"outputs": [{ "name": "predict_proba" }]
}"Nota — output disponibili per runtime"
I nomi degli output che MLServer riconosce dipendono dal runtime:
| Runtime | Output disponibili | Note |
|---|---|---|
mlserver_sklearn.SKLearnModel | predict, predict_proba, transform | predict_proba solo per classificatori; transform solo per pipeline; predict è il default |
mlserver_xgboost.XGBoostModel | predict | Output unico |
mlserver_lightgbm.LightGBMModel | predict | Output unico |
mlserver_catboost.CatBoostModel | predict | Output unico |
mlserver_mlflow.MLflowRuntime | predict | Chiama predict() del pyfunc MLflow; non espone predict_proba separato |
mlserver_huggingface.HuggingFaceRuntime | dipende dal task | L'output viene restituito nel formato del task HuggingFace configurato |
custom (mlserver.MLModel) | definiti dallo sviluppatore | Lo 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 type | Tipo Python risultante | Livello request | Livello input |
|---|---|---|---|
np | numpy.ndarray | ✅ | ✅ |
pd | pandas.DataFrame | ✅ | ❌ |
str | str (UTF-8) | ✅ | ✅ |
base64 | byte decodificati da base64 | ❌ | ✅ |
datetime | datetime.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)
| Tipo | Descrizione | Tipo pandas corrispondente |
|---|---|---|
BOOL | Booleano | bool |
INT8 | Intero con segno a 8 bit | int8 |
INT16 | Intero con segno a 16 bit | int16 |
INT32 | Intero con segno a 32 bit | int32 |
INT64 | Intero con segno a 64 bit | int64 |
UINT8 | Intero senza segno a 8 bit | uint8 |
UINT16 | Intero senza segno a 16 bit | uint16 |
UINT32 | Intero senza segno a 32 bit | uint32 |
UINT64 | Intero senza segno a 64 bit | uint64 |
FP16 | Floating point a 16 bit | float16 |
FP32 | Floating point a 32 bit | float32 |
FP64 | Floating point a 64 bit | float64 |
BYTES | Byte / stringa / categorico / object | object, category |
STRING | Stringa UTF-8 | string |
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à:
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 sceltoRuntime supportati
| Runtime MLServer | format nel metamodello | Formato artefatto |
|---|---|---|
mlserver_sklearn.SKLearnModel | sklearn | .skops (raccomandato), .joblib, .pkl |
mlserver_xgboost.XGBoostModel | xgboost | .json, .bst |
mlserver_lightgbm.LightGBMModel | lightgbm | .txt, .bst |
mlserver_catboost.CatBoostModel | catboost | .cbm |
mlserver_mlflow.MLflowRuntime | mlflow | cartella MLflow (con MLmodel) |
mlserver_huggingface.HuggingFaceRuntime | huggingface | modello locale o da HuggingFace Hub |
classe custom che estende mlserver.MLModel | python | directory 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.
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()).
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()).
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()).
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.
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 suparameters.urioptimum_model— (opzionale)trueper usare modelli ottimizzati con Optimumdevice— (opzionale) device su cui caricare il modello (0per GPU,-1per 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`.
{
"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():
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>:
{
"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:
{
"key": "output-model",
"type": "application",
"mandatory": true,
"invisible": true,
"valueType": "STRING",
"defaultValue": null,
"model": { "format": "sklearn" }
}Esempio completo — Training sklearn con Skops e serving
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()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":
{
"key": "output-model",
"type": "application",
"mandatory": true,
"invisible": true,
"valueType": "STRING",
"defaultValue": null,
"model": { "format": "sklearn" }
}