Python SDK (mapi-sdk)¶
mapi-sdk is the runtime that operates on your arrays. It runs in runner images and in your
notebook. It never runs in the API server. It does all the work between a spec and a fitted
model that you can score:
- binding
- preprocessing with fitted state
- the train and holdout split
- the model plugin
- the coordinates at score time
- inverse transforms
- summaries and bundles
Fit and score in a notebook¶
The first-model walkthrough shows these steps one by one.
The short version is below. It uses spec, data and schema from that page:
from mapi_sdk import fit_spec
bundle = fit_spec(spec, data, registry=registry, raw_schema=schema)
print(bundle.metrics.values) # holdout metrics, in units sold: {'rmse': ..., 'mape': ..., ...}
print(bundle.metrics.n_holdout) # 48 cells: 3 stores x 2 skus x 8 weeks
from mapi_sdk import score_bundle
future_weeks = pd.date_range(weeks[-1] + pd.Timedelta(weeks=1), periods=4, freq="W-SUN")
plan = xr.Dataset(
{
"price": (dims, np.full((4, 2, 4), 2.9)),
"promo": (dims, np.zeros((4, 2, 4))),
},
# "west" opens next month: the model has never seen it
coords={"store": [*stores, "west"], "sku": skus, "week": future_weeks},
)
forecast = score_bundle(
bundle,
"""
apiVersion: mapi/v1
kind: ScoreRequest
model: endpoint://quickstart/fruit-demand
mode: forecast
horizon: {dim: time, periods: 4}
output: {summary: [median, q10, q90]}
""",
plan,
registry=registry,
)
summary = forecast["summary"].to_dataset()
print(summary["y_hat"].dims) # ('stat', 'store', 'sku', 'week'), in your names
print(summary["y_hat"].sel(stat="median", store="west").values.round(1))
print(forecast.attrs["representation"]) # quantiles: what this model's uncertainty is
print(forecast.attrs["output_space"]) # {"y_hat": "raw"}: log1p undone, back in units sold
print(forecast.attrs["issues"]) # an info notice: 1 new level of 'store' uses 'pooled'
import tempfile
from pathlib import Path
from mapi_sdk import Bundle
out = Path(tempfile.mkdtemp()) / "fruit-demand-v1"
manifest = bundle.write(out)
print(sorted(p.name for p in out.iterdir()))
# ['bundle.zarr.zip', 'diagnostics.json', 'evaluation', 'manifest.json', 'metrics.json',
# 'plots.json'] - `evaluation/` is the train-only fit that produced the holdout metrics
again = Bundle.read(out, registry) # every file's size and hashes are verified here
assert again.bundle_id == bundle.bundle_id
For a model with the draws representation, add draws: 50 to output. Then the tree also
has a draws group. This group has 50 joint draws from the thinning index of the bundle.
score_bundle returns an xr.DataTree. Its root attributes record these items:
- The representation.
- The output kind (
expectedorpredictive). - The output space of each variable, for example
weekly. Inverse transforms stop at the last reshape step. - The retransformation labels for point models.
- All warnings.
Counterfactual requests add baseline/ and delta/ groups.
How the fit works¶
fit.mode: evaluate_then_refit (the default) does these steps:
- Parse the settings of the model (
plugin.parse_settings). A settings rule that the JSON Schema cannot give (a pydanticmodel_validator) refuses the fit here withSETTINGS_INVALID, before the SDK changes the data. - Bind the dataset names to canonical names. Do a check of the data against its schema.
- Run the stateless prefix of the pipeline on all the data, up to the split stage.
- Split the data. Fit the remaining steps and the model on train.
- Score the holdout through the usual score path (with context rows from train). Calculate
evaluate.metrics. - Fit each step and the model again on all the data. Thus, forecasts start from the latest context tails and the latest terminal state. The lineage of the production bundle has a link to the evaluation bundle.
train_only stops after step 5 and keeps the fit on train.
When BindingMap changes the name of a dim, it also changes that name in the grid attr of
each variable (the keys y, x, frame and component). Thus a plot recipe finds the grid
of an output after a data.bind.
How the score works¶
| Phase | What occurs |
|---|---|
| Mode | score_bundle refuses a mode that the fitted model does not support (MODE_NOT_SUPPORTED, with the supported modes in context). This check comes before all work for a mode. For example, a forecast never starts on a model that cannot forecast |
| CoordPlan | The SDK finds typed errors early from the metadata: UNSEEN_LEVELS, COORD_NOT_ALLOWED, HORIZON_EXCEEDS_FIT (409), MISSING_FUTURE_COVARIATE, NON_CONTIGUOUS_EXTENSION, MODE_NOT_SUPPORTED, ... |
| Horizon | A forecast makes the labels of the horizon after the last fitted label. A horizon along a dim with text labels gives COORD_NOT_ALLOWED at /horizon/dim, because text labels have no sequence. The labels must be numbers or dates |
| Entry | The data goes in at inputs_stage (default raw). The SDK skips the steps before this stage and their side tables. |
| Replay | The SDK applies the stored state of each step. Some steps look back (lags). For these steps, the SDK first does a contiguity check. Then it puts the stored context rows of each group before the data. |
| Canonical coords | The SDK puts the model-stage data in the fit order and pads it to the full fit set. It adds new levels at the end with a <dim>__is_new flag. |
| Model | plugin.score(...) returns the predictions group in the representation of the plugin. |
| Back to you | The SDK removes the context rows and selects the requested coords. It inverts the outputs to the output space. It aggregates them (only if this is statistically valid) and makes summaries. Then it binds the names back to your dataset names. |
Run tracking v0.11¶
FitContext, ScoreContext and the NodeContext of workflow nodes have the same logging
methods (mapi_sdk.tracking.TrackingMixin):
| Method | What it does |
|---|---|
log_metric(key, value, step=None, labels=None) |
One metric point. Without step, the point is a scalar and the newest value is kept. |
log_metrics(values, step=None, labels=None) |
Some metrics with one step and one label set. log_metrics({}) does nothing. |
log_param(key, value), log_params(params) |
Params. The same value again does nothing. A different value raises ValueError. |
set_tag(key, value), set_tags(tags) |
Tags. The last value is kept. |
log_line(message, level="info", fields=None) |
A structured log line (stream user). The SDK cuts the message at 4096 characters. A maximum of 16 fields. |
chains(chains) |
The progress of each chain, a list of mapi_spec.run.ChainProgress |
tracking |
True when a tracker records the values |
log_image(key, image, step=None, labels=None, caption=None) |
v0.14. An image: a numpy array (gray, RGB or RGBA), PNG or JPEG bytes, a Pillow image or a DataArray |
log_figure(key, figure, step=None, labels=None, caption=None) |
v0.14. A matplotlib figure, saved as PNG |
log_table(key, table, step=None, labels=None, caption=None) |
v0.14. A DataFrame, a dict of columns or a list of records |
tracking_media |
v0.14. True when the tracker records media (MediaTracker) |
Each method does a check of its input, also when no tracker is attached. A bad key, a bad
label, a negative step or a bad level raises ValueError at the call. The SDK ignores
non-finite metric values. The key and label rules are in mapi_spec.tracking
(check_metric_key, check_labels, check_param_key, check_tag_key).
The values go to a Tracker (mapi_sdk.Tracker, a protocol). All implementations are
thread-safe:
| Tracker | Use |
|---|---|
MetricRecorder(limits=None) |
Keeps all values in memory, with the budgets of TrackingLimits. history() gives the metrics_history dataset (or None). params, tags, lines, last_chains and dropped (the count for each reason) show what it kept and what it did not keep. |
TeeTracker(*trackers) |
Sends each call to all the trackers that are not None |
with_labels(tracker, labels, *, param_prefix="") |
Adds labels to each metric call. The labels of the call have priority. param_prefix goes before each param key (a key that becomes too long is not sent). |
WorkerTracker (mapi_runner) |
Inside a run: sends the values to the platform each second |
Media v0.14¶
Media use a separate, optional protocol: mapi_sdk.MediaTracker (one method,
log_media(item: MediaItem)). A Tracker that does not have it does not get media. Thus
the trackers of earlier releases continue to work. log_image, log_figure and log_table
always do a check of the key, the labels, the step and the caption. They encode the item only
when the tracker is a MediaTracker.
| Name | What it does |
|---|---|
MediaItem |
A frozen item: key, kind, content_type, data, sha256, step, labels, caption, width, height. name is <sha256>.<ext>. |
encode_png(array) |
(png_bytes, width, height) of an array of shape (H, W), (H, W, 1), (H, W, 3) or (H, W, 4). uint8 as is, booleans as 0 and 255, other integers in 0 to 255, floats in 0 to 1 (scaled by 255, NaN becomes 0). Other values raise ValueError. The same array always gives the same bytes. It uses numpy and zlib only. |
mapi_sdk.media.image_item, figure_item, table_item |
Make a MediaItem. They raise ValueError for bad input or an item above the limits of mapi_spec.media. |
MetricRecorder.media keeps the items in memory, within media_items and media_bytes.
The items are not written into the bundle. TeeTracker and with_labels send media to the
trackers that are MediaTrackers (with_labels adds its labels, and the labels of the call
have priority).
The harness connects the trackers:
FitHooks(tracker=...)gets the values of the fit and the params of the spec (spec_params:model,fit.*,data.split,settings.*). The values of the evaluation fit ofevaluate_then_refitget the labelfit=evaluation.fit_spec(..., record_metrics=True)(the default) also records each fit in aMetricRecorder, and writes/model/metrics_historyinto the bundle (see Bundles). Withrecord_metrics=False, the bundle has no such group.score_bundlegiveshooks.trackertoScoreContext.tracker.
from mapi_sdk import FitHooks, MetricRecorder, fit_spec
rec = MetricRecorder()
bundle = fit_spec(spec, data, hooks=FitHooks(tracker=rec))
rec.params["model"] # 'mmm.bayesian@1.2.0'
bundle.model.groups["metrics_history"] # when the model logged stepped metrics
For the full guide, see Track metrics, params and tags.
Image collections, loaders and weights v0.15¶
mapi_sdk.collection reads image collections lazily. mapi_sdk.images decodes images.
mapi_sdk.safetensors and mapi_sdk.checkpoints save network weights. All of them are
exported from mapi_sdk.
| Name | What it does |
|---|---|
Collection |
A collection: info (CollectionInfo), manifest (a DataFrame, one row for each image), source (a BlobSource) and schema. Collection.open(info, manifest, source) validates the columns, the ids, the digests and the packs. manifest_dataset() gives the manifest as a 1-D dataset along the image dim. loader(ids=None) gives a CollectionLoader. |
ImageLoader |
The protocol of a loader: n, dim, ids, labels, classes, size, channels, manifest(), subset(), with_classes(), with_size(), with_mode(), split(), batches() and to_xarray() |
ArrayImageLoader |
An ImageLoader over an in-memory image dataset (pixels(image, y, x, channel)). Pixels can be uint8, or floats in 0 to 1 or 0 to 255. |
CollectionLoader |
An ImageLoader that reads the images from the packs while you iterate. Each read makes sure that the SHA-256 is correct. |
Batch |
ids, pixels (B, H, W, C) uint8, labels, codes (int32, −1 for a missing label or an unknown class) and index |
Augment |
Random changes for each training image: flip_x, flip_y, rotate90, crop_pad, brightness, contrast, noise. Models put it in their settings. |
BlobSource, LocalPackSource |
Where the packs are. The runner gives a source that reads the signed pack URLs in ranges. LocalPackSource({digest: path}) reads local files. |
read_safetensors, write_safetensors |
Read and write safetensors files with numpy only. The writer gives the same bytes for the same tensors. |
weights_dataset, tensors_from_dataset |
Change tensors into the weights dataset (weights(param) and the attribute mapi_weights) and back |
FileCheckpoint, FileCheckpointSink |
Checkpoints as files (weights, optimizer state, meta). MemoryFileCheckpointSink keeps them in memory for tests. |
requested_positions(ds, dim) |
The positions of the rows that a score request asked for (it skips the padded rows of a new_levels dim) |
The order of batches(batch_size, epoch=e, shuffle=True, seed=s) is a function of the seed
and the epoch only. The loader reads blocks of up to 64 images of one pack, puts the blocks
in a random sequence and shuffles the images in windows of 8 blocks. The random changes of
Augment use a generator for each image, from the seed, the epoch and the position. Thus
the batches are the same for each number of threads. shard=(i, n) gives every n-th block
to shard i.
to_xarray(max_samples, size, seed=..., stratify="label") gives the v0.14 image layout:
pixels(image, y, x, channel) uint8 with label(image). When the loader has more than
max_samples images, it keeps a seeded share of each label.
from mapi_sdk import Augment
from mapi_sdk.testing.images import local_collection
collection = local_collection(tmp_path, n_per_class=8, size=12)
loader = collection.loader().with_size((12, 12))
train, val = loader.split(0.25, seed=0)
for batch in train.batches(16, epoch=0, shuffle=True, seed=0, augment=Augment(flip_x=True)):
print(batch.pixels.shape, batch.codes)
fit_spec(spec, collection) and score_bundle(bundle, request, collection) take a
Collection in place of a dataset. A model with capabilities.input: collection gets a
loader in fit_batches and score_batches. Any other model gets
collection_read(collection, spec.data.collection, seed=...). Read
Train on batches.
A model can read a side table (v0.15). CustomModel.side_table_params names the settings
keys whose value is a data.side_tables entry, for example the pretrained weights of
dl.finetune_classifier. The harness gives these tables, raw, in FitContext.side_tables.
A score request never needs a table that only the model reads. Read
Read a side table in a model.
Representations and summaries¶
| Representation | Shape | Summaries | Aggregation |
|---|---|---|---|
draws |
(chain, draw, …) |
mean, sd, median, hdi_<p>, eti_<p>, q<p> |
All (for each draw) |
quantiles |
(quantile, …) |
median, q<p>, eti_<p> (interpolated, never extrapolated) |
None |
interval |
(bound, …) |
interval, point |
None |
point |
(…) |
mean, point |
sum/mean, flagged |
moments |
(moment, …) |
mean, sd, eti_<p> |
None |
If you ask for something that is not valid, the request fails with CAPABILITY_UNSUPPORTED.
For example, you cannot add quantiles together, or get draws from a point model. The SDK does
not return a number that can cause an incorrect decision.
Circular outputs¶
An output can be an angle, for example a direction or a time of day. The model declares it
with OutputDecl.circular: true (the default is false). It also sets a period attr (one
turn, in the units of the output, for example 360, 2π or 24) on the scored array.
sci.circular_regression declares direction_hat and mean_direction circular.
For the draws representation, summarise(..., periods={var: period}) calculates the
summaries on the circle:
meanis the circular mean.sdis the circular standard deviation,sqrt(-2 ln R)converted to the units of the output.median,q<p>,eti_<p>andhdi_<p>come from the wrapped differences between the draws and the circular mean. Thus each interval is centred on the circular mean.- The mean, the median, the quantiles and the interval bounds are in
[0, period). An interval withlowermore thanuppercrosses the seam, for example from 350 to 10 degrees. - The difference between a scenario and its baseline (
delta/summary,delta/draws) is wrapped to[-period/2, period/2)(signed=True). Thus 10 − 350 degrees is 20, not −340.
The summary variable has the attr circular_period (mapi_sdk.summarise.CIRCULAR_ATTR).
If the output has no valid period attr, or if the request aggregates it over a dim, the
summary stays on a line. The result then has a CAPABILITY_UNSUPPORTED warning at
/output/summary.
The holdout metrics of a circular output use the same rules
(compute_metrics(..., period=...)):
- The point is the circular mean of the draws.
mae,rmseandbiasuse the error wrapped to half a turn. Thus 359 degrees against 1 degree is an error of −2 degrees, not 358.coverage_<p>andinterval_width_<p>use central intervals of the draws around the circular mean.crpsuses the arc distance.- The SDK skips
mape,smape,wapeandr2, because they have no meaning on a circle. It also skips all metrics if the representation is notdrawsor theperiodis not valid.skippedgives the reason.
Data on the wire¶
mapi_sdk.wire reads and writes the formats of the platform. The readers think that each
payload can be hostile.
- XrJSON (
to_xrjson/from_xrjson): Thexr.Dataset.to_dict()shape with explicit dtypes andnullfor missing values. It has limits on elements, dims and string length. Use it for small payloads and for curl. - zarr zip (
write_zarr_zip/read_zarr_zip,write_datatree_zip/read_datatree_zip): zarr v3 archives in one object. These rules apply:- Strings are
vlen-utf8. - Datetimes are int64 nanoseconds with explicit units.
- The reader accepts only
zstd/blosccompression. - The writer resets MultiIndexes, and the reader restores them.
- The bytes are deterministic.
- Strings are
- Rows (
stack_rows/unstack_rows): These functions stack dims into rows and remove empty rows. Later, they restore the rows from the recorded row mask. - Arrow IPC (
to_arrow_table,write_arrow_ipc): Tidy tables for UI views. One column for each dim coordinate, then one column for each variable. The schema metadatamapi.attrsholds the attrs of the variables and of the dataset as JSON. The schema metadatamapi.dimsholds the names of the dim columns as a JSON list. A reader uses it to find the key columns. The type of a column does not tell this, for example for a numeric dim such as a grid position in cm.
The SDK never uses pickle.
Data checks in plain language¶
import numpy as np
import pandas as pd
import xarray as xr
from mapi_sdk import infer_schema, validate_dataset
from mapi_spec import DimPolicy
days = pd.date_range("2026-03-01", periods=28, freq="D")
markets = ["DE", "FR", "UK"]
channels = ["tv", "search"]
rng = np.random.default_rng(0)
media = xr.Dataset(
{
"revenue": (("market", "date"), rng.gamma(50.0, 20.0, (3, 28))),
"spend": (("market", "channel", "date"), rng.gamma(5.0, 100.0, (3, 2, 28))),
},
coords={"market": markets, "channel": channels, "date": days},
)
schema = infer_schema(
media,
targets=["revenue"],
policies={"market": "new_levels"},
units={"revenue": "EUR", "spend": "EUR"},
)
print(schema.dims["date"].policy, schema.dims["date"].freq) # extend D
print(schema.dims["channel"].policy) # fixed
print(schema.vars["revenue"].role) # target
broken = media.copy(deep=True)
broken["revenue"].loc[{"market": ["FR", "UK"], "date": days[10:12]}] = np.nan
for issue in validate_dataset(schema, broken):
print(issue.severity, issue.code, "-", issue.message)
# error SCHEMA_INVALID - 2 markets are missing 2 days of revenue in March 2026
Bundles¶
A bundle directory contains bundle.zarr.zip and the files manifest.json,
diagnostics.json, plots.json and metrics.json. bundle.zarr.zip contains these items:
- the spec, the schemas and the binding
- the state and the context of each step
- the groups of the model, with ArviZ names
- native artifacts
- the fit coordinates of each stage
- pinned side tables
- the thinning index
The manifest records CRC32C and MD5 (in the form that GCS reports) and SHA-256 for each file.
Bundle.read verifies them.
metrics.json is a MetricsReport. It has these fields:
| Field | Meaning |
|---|---|
values |
The holdout metrics. null if a metric has no finite value |
skipped |
The metrics that the SDK did not calculate, with the reason |
n_holdout |
The number of holdout values that count |
split, evaluation_bundle, output_space |
The split, the link to the evaluation bundle and the output space of the metrics |
elpd |
{name: ElpdDetail} for elpd_loo and elpd_waic (v0.9) |
The SDK calculates elpd_loo and elpd_waic only when evaluate.metrics lists them and the
bundle has a log_likelihood group. For example, a PyMC model needs
fit.keep: {log_likelihood: true}. An ElpdDetail (mapi_sdk.bundle) has these fields:
elpd: the estimate.se: its standard error.p: the effective number of parameters.n: the number of data points.pointwise: the value for each point, in the order of the flattenedlog_likelihoodvariable. The field isnullabove 10 000 points (mapi_sdk.metrics.MAX_POINTWISE_ELPD).
The workflow node compare.stacking uses the pointwise values. If the estimate is not finite
(a log-likelihood of -inf), the SDK keeps no elpd entry for it. A bundle that has a null
entry from an earlier version still loads.
Missing values by design¶
Some datasets have NaN that are correct, for example the cells of a grid that have no
sensor. A CustomModel lists these canonical variables in the class attribute
structural_missing (the default is empty). The scorecard check data.missing (Few missing
values) does not count their missing values. sci.heat_plate declares temperature.
The check also does not count the variables with the role constant, index or mask. The
message of the check names the variables that it does not count. Gaps in a target, a spend or
a covariate count as before, also with missing: allow.
Run work with an executor¶
InprocExecutor implements the Executor protocol of the platform in your own process. Use it
for notebooks and tests. Never use it for untrusted code:
import asyncio
from mapi_plugin_tabular.datasets import (
STORE_DEMAND_SCHEMA,
STORE_DEMAND_SPEC_YAML,
store_demand_dataset,
)
from mapi_sdk import InprocExecutor
from mapi_spec.executor import ExecState, RunEventType, RunKind, RunSubmit
history = store_demand_dataset(stores=4, skus=2, weeks=60, seed=1)
recent = history.isel(week=slice(-8, None)) # a window of fitted weeks to re-score
executor = InprocExecutor(
# dataset refs in specs and requests resolve to in-memory datasets
resolver={
"dataset://retail/store_sku_weekly@latest": history,
"dataset://retail/last_8_weeks@v1": recent,
},
)
print(executor.caps().isolation.name) # NONE: your own code, in your own process
async def main() -> None:
fit = await executor.submit(
RunSubmit(
run_id="fit-001",
kind=RunKind.FIT,
payload={
"spec": STORE_DEMAND_SPEC_YAML,
"raw_schema": STORE_DEMAND_SCHEMA.model_dump(mode="json"),
},
),
nonce="nonce-fit-0001", # resubmitting the same nonce returns the same run
)
status = await executor.wait(fit, timeout=300)
print(status.state) # succeeded
for event in executor.events(fit): # the same event stream a cloud run will produce
if event.type is RunEventType.PROGRESS:
print(event.seq, event.payload.get("message")) # 2 validated the spec and the data
executor.put_bundle("store-demand@1", executor.result(fit))
score = await executor.submit(
RunSubmit(
run_id="score-001",
kind=RunKind.SCORE,
payload={
"bundle": "store-demand@1",
"request": {
"apiVersion": "mapi/v1",
"kind": "ScoreRequest",
"model": "endpoint://retail/store-demand-xgb",
"mode": "predict",
"data": "dataset://retail/last_8_weeks@v1",
},
},
),
nonce="nonce-score-0001",
)
assert (await executor.wait(score, timeout=300)).state is ExecState.SUCCEEDED
tree = executor.result(score) # an xr.DataTree, exactly what score_bundle returns
print(dict(tree["summary"].to_dataset()["y_hat"].sizes))
# {'stat': 3, 'store_id': 4, 'sku': 2, 'week': 8}
asyncio.run(main()) # in Jupyter: `await main()`
See Runs and executors.
Error codes that you can see¶
Each error is a MapiError with a stable code. Where it is useful, the error also has a JSON
pointer. Text from your data or from a plugin goes in untrusted_text, separate from the
message. These are the error types:
StepError(USER_CODE_ERROR) contains failures in steps.SchemaValidationError(SCHEMA_INVALID) reports problems in the data.BundleError(INTEGRITY_FAILURE) reports bundles that are damaged or that someone changed.RunCancelled(CANCELLED) reports a cooperative cancellation.