The Python client v0.2¶
mapi-client (mapi_client, Apache-2.0) is a typed client for the /v1
HTTP API. It uses httpx. Each request and each response is a Pydantic model
from mapi_spec.api. The server uses the same models. Thus, if a field name has a typo, your
editor shows the error before production. Errors come back as mapi_spec.errors.MapiError
subclasses with the stable error code of the server.
The mapi CLI, make seed and the end-to-end tests use this client. This page shows one
script, docs/snippets/client_tour.py. make e2e runs this script against a live stack. The
outputs on this page come from that script. Some outputs are shorter than the real outputs.
uv add mapi-client # or, for xr.Dataset uploads and xr.DataTree results:
uv add "mapi-client[xarray]"
The workspace already has the two packages. The examples need a live stack and the demo files:
- Run
make dev. - Run
uv run python scripts/seed.py --export demo/.
Connect¶
from mapi_client import Client
api = Client() # MAPI_URL / MAPI_API_URL, a profile, or http://127.0.0.1:8000
health = api.ready()
print(api.config.base_url, health.status, health.version)
Client() finds its settings in the same sequence as the CLI:
- The arguments (
Client("http://…", token=…, project=…)). MAPI_URL(orMAPI_API_URL),MAPI_TOKEN,MAPI_PROJECTandMAPI_PROFILE.- A profile in
~/.config/mapi/config.toml. http://127.0.0.1:8000.
api.config.sources tells where each value came from. Use the client as a context manager
(with Client() as api:), or call close().
Upload data¶
versions = {}
for name in ("eu_media_daily", "fx_daily", "holidays_eu", "eu_media_future"):
versions[name] = api.datasets.upload(f"demo/{name}.zarr.zip", dataset=name)
print(versions[name].ref, versions[name].status.value, versions[name].sha256[:19])
dataset://default/eu_media_daily@v2 ready sha256:3062fa82622c
dataset://default/fx_daily@v2 ready sha256:7f27db4a2ac4
dataset://default/holidays_eu@v2 ready sha256:11abf028d5f5
dataset://default/eu_media_future@v2 ready sha256:2db88e1af79d
datasets.upload does all the steps of an upload:
- If the name is new, it makes the dataset.
- It opens an upload session.
- It PUTs the bytes to the signed URL. It does not send your API credentials to that URL.
- It starts the ingest run in the sandbox with a new idempotency key.
- It waits until the version is
ready. If the ingest fails, it raises the error of the ingest.
It accepts a path, bytes or an xr.Dataset (with the xarray extra). CSV and Parquet files
need a mapping=IngestMapping(…). Zarr zips and netCDF files describe themselves. A zip of
images needs IngestMapping(format="image_folder", …) (v0.14, see
Image data). A folder of images goes up as a collection with
datasets.upload_collection (v0.15, see below).
The version has the schema that the ingest run checked:
schema = versions["eu_media_daily"].data_schema # the DataSchema the ingest run checked
print({dim: spec.policy.value for dim, spec in schema.dims.items()})
print({var: spec.role.value for var, spec in schema.vars.items()})
{'date': 'extend', 'market': 'new_levels', 'channel': 'fixed'}
{'spend_local': 'spend', 'revenue_local': 'target'}
Validate a spec¶
with open("demo/eu-geo-mmm.yaml", encoding="utf-8") as fh:
spec_yaml = fh.read()
check = api.specs.validate(spec_yaml)
print("valid" if check.valid else [(i.code, i.pointer) for i in check.issues])
for stage in check.stages: # the schema after every pipeline step, and where the split is
if stage.data_schema is not None:
print(stage.name, sorted(stage.data_schema.dims), "<- split" if stage.split else "")
valid
raw ['channel', 'geo', 'time']
to_eur ['channel', 'geo', 'time']
weekly ['channel', 'geo', 'time'] <- split
hols ['channel', 'geo', 'time']
Validation is synchronous and static. The server never imports plugin code. It returns all
the issues at one time, each with a JSON pointer (/pipeline/2/with/over). It also returns
the schema at each stage. Here, the schema uses the canonical names of the dataset (geo,
time) after binding. The marketing-mix model (mmm.bayesian, on mmm-framework) scales the
data and carries spend over. Thus, the pipeline stops at the currency, weekly and holiday
steps.
Fit and follow the run¶
from mapi_client import RunEnd, RunEventFrame, RunLiveFrame
revision = api.specs.upsert(spec_yaml) # creates the spec, or adds a revision if it changed
run = api.runs.fit(revision.spec_id, revision.revision)
for item in api.runs.stream_events(run.id): # SSE; resumes with Last-Event-ID
match item:
case RunLiveFrame(name="progress", event=event): # live only, never replayed
print(" ", event.payload.get("message"))
case RunEventFrame(name=name, event=event): # persisted, with an event id
p = event.payload # state, warning, diagnostic, metric, complete, error
print(name, p.get("code") or p.get("name") or "", p.get("message") or p.get("value"))
case RunEnd(run=final): # the stream closes after this frame
print("end:", final.state.value)
run = api.runs.wait(run.id, raise_on_failure=True)
print(run.state.value, run.output_model_version_id)
state bootstrap object read and deleted
warning DEV_MODE_ISOLATION worker isolation is reduced (development mode): …
state downloading the dataset
state fitting the pipeline and the model
validated the spec and the data
sampling (pymc)
posterior ready
fit complete
posterior ready
state rendering plots and views
explorer: params
diagnostic RHAT 1.0485957785967657
diagnostic ESS_BULK 70.61247760811287
diagnostic ESS_TAIL 51.394768720375154
diagnostic DIVERGENCES 0.0
diagnostic PARETO_K_HIGH 3.0
metric coverage_90 0.9583333333333334
metric elpd_loo 89.0564020582058
metric mape 2.53957229942099
state uploading outputs
complete None
state complete manifest uploaded
end: succeeded
succeeded mv_01m53hmgdhfby1esy1xe341hvh
(We got this output on a laptop against make dev. The demo spec has very small sampler
settings on purpose. "posterior ready" shows two times. First, the model fits on the training
weeks. Then it does an evaluation on the holdout. Then it fits again on all the weeks.)
runs.stream_events follows the server-sent event stream. It yields four types of items:
| Item | When | Notes |
|---|---|---|
RunSnapshot |
First, and after each change of state | The full Run |
RunEventFrame |
Persisted events: state, warning, diagnostic, metric, checkpoint, complete, error |
Has an id. The stream sends it again after a reconnect. |
RunLiveFrame |
progress and log |
Live only. The stream never sends it again. |
RunTrackingFrame |
tracking (v0.11): the metric series, params or tags changed |
Live only. A TrackingFrame with the changed series summaries. From v0.14, its field media is True when the run logged new images or tables. |
RunEnd |
One time, when the run is terminal | Then the stream closes. |
If the connection stops, the client continues it with Last-Event-ID. Thus, no persisted
event is lost or repeated. runs.wait polls (with backoff) and does not use the stream. With
raise_on_failure=True, it raises the error of the run. runs.logs(run_id) gives the
captured output and the structured user lines page by page (include_user=False gives
only the captured output).
Inspect the model version¶
model = next(m for m in api.models.list() if m.name == "eu-geo-mmm")
mv = api.models.version(model.id, model.latest_version)
print(mv.ref, mv.scorecard.overall if mv.scorecard else None) # pass | warn | fail
print(mv.metrics)
for item in mv.scorecard.items if mv.scorecard else []:
print(f"{item.status:4} {item.code}: {item.message_plain}")
model://default/eu-geo-mmm@v1 fail
{'mape': 2.53957229942099, 'elpd_loo': 89.0564020582058, 'coverage_90': 0.9583333333333334}
warn RHAT: The chains mostly agree, but not fully (R-hat 1.05).
fail ESS: Only 71 independent draws: the numbers are not stable.
pass DIVERGENCES: The sampler explored the model without problems.
pass HOLDOUT_MAPE: The forecast error on the holdout periods was 3%.
pass HOLDOUT_COVERAGE: 96% of held-out values fell inside the 90% range, as expected.
pass NEGATIVE_ROI: No channel is likely to have a negative return.
pass ROI_ABSURD: The highest channel return (4.34 per unit spent) is believable.
pass SHARE_OUTLIER: No single channel explains most of the KPI.
pass BASELINE_SHARE: The baseline (KPI without media) is 84%, a plausible share.
pass PRIOR_OVERLAP: The data moved every parameter away from its prior.
pass DATA_MISSING: Only 0% of the values are missing.
fail DATA_HISTORY: Only 0.6 years of history: too short to separate media from seasonality.
pass SPEND_VARIATION: Every channel's spend varies enough to measure its effect.
The scorecard works correctly here. The demo spec samples 100 draws on 2 chains from about
half a year of weekly data. This keeps the tour fast. The scorecard tells this in plain words:
too few independent draws and too little history. If a scorecard fails, you cannot promote
the model version to an endpoint (PROMOTION_BLOCKED). If a scorecard only warns, an admin
must give an audited reason (the promotion gate).
A ModelVersion also has the signed manifest, the diagnostics, the capabilities, the
lineage and the list of artifacts. You can download each of them:
metrics = api.models.download(model.id, mv.version, "metrics.json") # bytes, CRC32C-checked
api.models.download(model.id, mv.version, "bundle.zarr.zip", dest="eu-geo-mmm.zarr.zip")
The API checks the signature of the manifest before it makes the short-lived signed URL. The
client compares the download with the CRC32C and the size that the object store calculated.
mapi_sdk.bundle.Bundle opens the downloaded bundle.zarr.zip in a notebook.
Score¶
result = api.models.score(
model.id,
mv.version,
{
"model": mv.ref,
"mode": "forecast",
"horizon": {"dim": "time", "periods": 8},
"data": "dataset://default/eu_media_future@latest",
"side_tables": {"fx": "dataset://default/fx_daily@latest"}, # score_policy: supplied
"output": {"kind": "expected", "summary": ["mean", "hdi_90"]},
},
)
print(result.run.state.value, [link.name for link in result.outputs])
summary = result.tree["summary"] # an xr.DataTree (needs the `xarray` extra)
print(result.tree.attrs["output_space"]) # {"y_hat": "model-owned", ...}: weekly EUR
print(summary["y_hat"].sel(stat="mean", market="NL").round(0).values)
succeeded ['plots.json', 'result.json', 'score.zarr.zip', 'summary.json', 'ui/guided/forecast.json', 'ui/summary.arrow']
{"contributions": "model-owned", "y_hat": "model-owned"}
[272645. 254447. 259143. 258366. 265242. 269497. 268112. 266377.]
model-owned means that the model returned its answers in the units of its own stage. The
units attribute of each variable records these units. Here, the units are weekly EUR. The
pipeline converted and resampled the data before the model.
models.score sends the request with Prefer: wait=60. If the run is not complete at that
time, the client sends the same request again with the same idempotency key. The server
answers with the run that it made before. Thus, a slow score never becomes two runs. With the
xarray extra, the client downloads the result tree, does a check of it and puts it in
result.tree.
The request is a ScoreRequest as a dict, a YAML string or the
Pydantic model. The client fills in apiVersion and kind.
Errors¶
from mapi_spec.errors import MapiError, ModeNotSupported
request = {
"model": mv.ref,
"mode": "forecast",
"horizon": {"dim": "time", "periods": 8},
"data": "dataset://default/eu_media_future@latest",
}
try: # the fx side table has `score_policy: supplied`, and this request does not supply it
api.models.score(model.id, mv.version, request)
except MapiError as err:
print(err.code, err) # SIDE_TABLE_COVERAGE, with a JSON pointer: /side_tables/fx
try: # this model cannot filter (that is for state-space models)
api.models.score(model.id, mv.version, {**request, "mode": "filter"})
except ModeNotSupported as err:
print(err.code) # MODE_NOT_SUPPORTED, raised before any run is created
SIDE_TABLE_COVERAGE validation failed with 1 error; first at /side_tables/fx: side table 'fx' has score_policy 'supplied' and must be provided for stage 'raw'
MODE_NOT_SUPPORTED
The client changes each RFC 7807 problem from the server into a MapiError. The error can
also be a subclass, for example ModeNotSupported, ValidationFailed, CoordPlanError,
QuotaExceeded or IntegrityFailure. It has the stable code, the HTTP status, the JSON
pointer and the individual issues. The two requests above failed in the static checks,
before the server made a run. Transport failures raise mapi_client.TransportError. If the
client cannot parse a response, it raises UnexpectedResponse.
The client tries idempotent calls again on 502, 503, 504 and connection errors. Idempotent
calls are GETs, and POSTs with an idempotency key. The client obeys Retry-After
(ClientConfig.max_retries, default 2). For 429 answers, see
Quotas and rate limits.
Projects, tokens and usage v0.3¶
On a multi-tenant API, the client needs credentials and a project
(Tenancy and authentication). The credentials come from an
API token (Client(token=…), MAPI_TOKEN or the profile). If there is no token, they come
from the sign-in that mapi login stored for the profile. The client renews that sign-in
automatically. It sends the sign-in only to the API that made it.
The project comes from Client(project=…), MAPI_PROJECT or the profile (mapi use). The
client sends the project as X-Mapi-Project, only on the requests for a project:
me = api.me()
for tenant in me.tenants:
for project in tenant.projects:
print(f"{project.tenant_id}/{project.project_id}: {project.role.value}")
api.use("acme/growth") # X-Mapi-Project on every project-scoped request from now on
Make a token for a program. The API returns the secret only one time. The secret does not
show in repr:
created = api.tokens.create(
"budget-planner",
["score:geo-mmm-prod", "models:read"],
expires_in_days=30,
rate_limit_per_minute=120,
)
print(created.token.display, created.token.expires_at.date())
secret = created.secret # the only copy: put it in your secret store now
Usage reports and runs have exact costs in integer micro-units of the currency:
report = api.usage.get(period="month", group_by="resource_class")
print(report.totals.amount_micros / 1e6, report.totals.currency)
for quota in report.quotas:
print(quota.limit_name.value, f"{quota.fraction_used:.0%}", quota.resets_at)
run = api.runs.get("run_01jaf3q2x8m9v7k5h4g3f2e1d0")
print(run.cost.amount_micros, run.cost.currency, run.cost.estimated)
api.tenants contains the calls for tenants, projects and members. api.admin contains the
operator endpoints: tenants, quotas, kill switches, executors and the audit log. See the
reference.
Quotas and rate limits¶
A 429 answer has one of two codes:
RATE_LIMITED: The server refused the request before it did work on it. Thus, the client waits and sends the request again, for all methods. It waits forRetry-After, then for an exponential back-off with a small jitter. It raisesRateLimitedonly afterRetryPolicy.rate_limit_retriesattempts. If the server asks for a wait longer thanmax_rate_limit_wait, the client raisesRateLimitedimmediately.QUOTA_EXCEEDED: The client raisesQuotaExceededimmediately.retry_after_of(exc)tells when the quota window starts again. It isNonewhen capacity becomes free only after the work that runs now is complete.
try:
api.runs.fit("spc_01jaf3q2x8m9v7k5h4g3f2e1d0", 1)
except QuotaExceeded as exc:
wait = retry_after_of(exc) # seconds until the quota window resets (None: no reset)
print(exc.code.value, exc.detail, wait)
Use a RetryPolicy to change this behavior. With max_quota_wait, the client can also wait
until a short quota window ends:
patient = Client(
config=config,
retry=RetryPolicy(rate_limit_retries=8, max_rate_limit_wait=300, max_quota_wait=0),
)
Endpoints, webhooks and sharing v0.4¶
The examples in this section come from docs/snippets/client_integrations.py. This script
runs offline against a substitute API. For the concepts, see
Integrations.
Promote a model version to an endpoint¶
endpoint = api.endpoints.promote(
"geo-mmm-prod",
"model://growth/eu-geo-mmm@v3",
expected_revision=3, # 409 CONFLICT if someone else promoted in between
)
print(endpoint.ref, "->", endpoint.model_ref, endpoint.gate.outcome)
A promotion goes through the promotion gate. If
the scorecard of a version warns, the call raises MapiError with code OVERRIDE_REQUIRED.
Then a signed-in project admin must do the call again with override=True, reason="…".
Nobody can override PROMOTION_BLOCKED. api.endpoints also has list, get, history,
rollback(alias, reason, to_revision=…) and delete.
Score an endpoint into xarray¶
api.endpoint(alias) returns a RemoteEndpoint:
schema()tells what to send.validate(ds)does a check of a payload, but does not run it.score(ds)sends anxr.Datasetinline (as XrJSON, up to 5 MB) and returns the scoredxr.Dataset.
geo_mmm = api.endpoint("geo-mmm-prod")
schema = geo_mmm.schema()
print(sorted(schema.snapshot.input_schema.vars))
plan = xr.Dataset(
{"spend_local": (("market", "channel", "date"), np.array([[[1200.0], [5300.0]]]))},
coords={"market": ["de"], "channel": ["search", "tv"], "date": ["2025-12-30"]},
)
result = geo_mmm.score(plan, wait=60) # inline XrJSON in, xr.Dataset out
print(result["revenue_local"].sel(stat="mean").values)
score returns the summary group. This group has a stat dim (mean, hdi_90_lower,
hdi_90_upper and more). For posterior draws along sample, pass group="draws" with
output={"draws": 100}. For a counterfactual, pass group="delta/summary". Each other
EndpointScoreCall field is a keyword, for example mode="forecast",
horizon={"dim": "time", "periods": 8}, scenario=plan_ds and
future_covariates="carry_last".
Like models.score, it sends Prefer: wait=60. It sends the call again with the same
idempotency key until the run is complete. timeout= sets the maximum wait. Thus, a slow
score never makes two runs. api.endpoints.score(alias, call) is the lower-level call. It
returns a ScoreResult with the run, the output links and the downloaded tree.
ScoreResult.summary holds the summary as tables. Each table gives dims (its coordinate
columns) and time_dim (the dim that is time, or None). Inline
payloads need the xarray extra. If the data is larger, upload it as a dataset and send it
as data="dataset://growth/plan@v2".
mapi_client.xrjson.encode(ds) changes an xr.Dataset into the XrJSON payload.
decode(payload) changes the payload back into an xr.Dataset. XrJSON is the wire format of
the SDK. It has dates as ISO strings and NaN as null. The client does a check of the size
before it sends data.
Forecast with your latest actuals¶
api.endpoints.forecast(alias, rows) sends actuals and the dates to
forecast in one call. It does not fit the model
again. rows is a long table in the input layout of the endpoint (schema().input_table).
This layout has the time column, the target and the drivers. rows can be a pandas
DataFrame, a list of row mappings or a mapping of columns. Rows that have a target value
are actuals. The model forecasts the rows after them that have no target value:
import pandas as pd
rows = pd.DataFrame({
"t": ["1961-01-01", "1961-02-01", "1961-03-01", "1961-04-01", "1961-05-01"],
"y": [417, 391, 419, None, None],
})
result = api.endpoints.forecast("ts-forecast", rows)
print(result.conditioning.message)
# Forecast updated with 3 actuals (1961-01-01 to 1961-03-01) after the fitted history, which
# ends 1960-12-01; the model's parameters were not re-estimated. 2 periods forecast from
# 1961-04-01 to 1961-05-01.
You can also give the two parts separately:
actuals=: the rows with the target.future=: the dates to forecast, rows with their driver values, or a number of periods to add after the last actual (future=6).
The client carries empty driver values on future rows forward
(future_covariates="carry_last", the default here). If a model cannot use actuals, it
refuses them with ActualsNotSupported (422 ACTUALS_NOT_SUPPORTED).
api.endpoint(alias).forecast(rows) does the same and gets the schema one time.
mapi_client.forecast_call(schema, rows, ...) only makes the call. ScoreResult.conditioning
is the record for each forecast. If you sent no actual, it has conditioned=False and the
end of the fitted history.
Snippets¶
python = api.endpoints.snippets("geo-mmm-prod", ["python"]).items[0]
print(python.code.splitlines()[2])
api.endpoints.snippets(alias, languages) returns the code that the
Integrations hub shows. The languages are Python,
TypeScript, curl, R, Apps Script and a notebook cell. The Python snippet is the same as the
api.endpoints.score(...) call above. The notebook cell uses the
api.endpoint(alias).score(...) form. The two snippets run without changes. The tests of the
client run them.
Webhooks¶
created = api.webhooks.create(
"https://hooks.example.com/mapi", ["model.version.ready", "endpoint.promoted"]
)
signing_secret = created.secret # the only copy: store it with the receiver
print(created.webhook.id, created.webhook.secret_hint)
create and rotate_secret return the signing secret only one time. The secret does not
show in repr. api.webhooks also has list, get, update, delete,
deliveries(id, status=…) and replay(id, delivery_id). For the checks of deliveries on the
receiver side, see Receiving webhooks.
Render a plot¶
render = api.plots.render(
model_id="mdl_01jaf3c1d2e3f4g5h6j7k8m9n0",
version=3,
plot_id="mmm.response_curves",
format="png",
width=1200,
)
path = api.plots.download_render(render, Path(tmp) / "response_curves.png")
plots.render accepts a PlotSpec, a saved plot (saved_plot_id=) or a plot of a model
version. Like a score, it waits for the render in the sandbox. download_render gets the
file and does a CRC32C check. plots.save, saved, get_saved and delete_saved manage
saved plots. create_embed returns a read-only embed link one time. Also see embeds,
revoke_embed and the public embed(token) view. See Sharing plots.
Sign-up, verification, deletion and export¶
export = api.tenants.export("acme")
archive = api.tenants.download_export("acme", export.id, Path(tmp) / "acme-export.zip")
download_export waits until the export is ready. Then it downloads the export through the
storage client to a temporary file. It never uses your API credentials for this download.
When the size is correct, it renames the file to its final name.
api.tenants also has verification, request_deletion(tenant_id, confirm=tenant_id),
deletion and cancel_deletion. api.signup(...) and api.verify_email(token) are the
public sign-up calls. The client sends them without credentials. Operators have
api.admin.signups, review_signup, set_verification and retry_deletion. See
Deleting and exporting data.
Guided mode¶
api.guided controls Guided mode from code. It has these
calls:
templates()andtemplate(id).detect_roles(template_id, columns=[…] | dataset="dataset://…").draft(template_id=…, name=…, dataset=…, answers={…}). This returns the ingest mapping, theModelSpecand its YAML. It does not store data.compile({...}). This returns theScoreRequestfor a what-if, a forecast or a preset.preview_priors(business_priors, spec_id=…, revision=…). This starts atranslate_priorsrun in the sandbox and waits for it.
Workflows, governance and explorers v0.5¶
All the v0.5 calls are on the same client. The mapi_spec DTOs give their types. The
documentation tests run the tour below, without changes, against a real server.
Help me choose¶
from mapi_client import Client
api = Client() # MAPI_URL, the `mapi login` sign-in (or MAPI_TOKEN) and MAPI_PROJECT
result = api.catalog.recommend(
goal="forecast", data_shape="one_series", drivers="yes", experience="beginner", limit=3
)
for item in result.items: # best first, with the reasons in plain words
print(f"{item.score:.0%} {item.title} ({item.kind.value} {item.id})")
for reason in item.reasons_plain:
print(" +", reason)
for caution in item.cautions_plain:
print(" !", caution)
card = api.catalog.about("ts.sarimax", kind="model") # any item's About card
print(card.summary_plain)
print("use it when:", "; ".join(card.use_when))
print("pick something else when:", "; ".join(card.avoid_when))
Run a workflow and follow it live¶
from pathlib import Path
check = api.workflows.validate(Path("weekly-readiness.yaml")) # on the server; saves nothing
for issue in check.issues: # every problem with a JSON pointer into the document
print(issue.severity.value, issue.pointer, issue.message)
print(" -> ".join(check.order)) # the order the nodes will run in
from mapi_client import WorkflowEnd, WorkflowEventFrame, WorkflowSnapshot
# create the workflow, or save the file as its next revision when it changed
workflow, revision = api.workflows.upsert(Path("weekly-readiness.yaml"))
run = api.workflows.run(
workflow.id,
inputs={"data": "dataset://growth/weekly_sales@latest"}, # pinned to a version at start
params={"drivers": ["price"], "window": 80, "max_p": 2, "max_q": 2},
)
for item in api.workflows.stream_events(run.run_id): # server-sent events, resumable
match item:
case WorkflowSnapshot(run=now):
print(f"{len(now.nodes)} nodes, {now.status.value}")
case WorkflowEventFrame(event=e) if e.type.value == "workflow.branch.decided":
print(f" {e.node_id}: took the {e.data['arm']} arm")
case WorkflowEventFrame(event=e) if e.node_id is not None:
print(f" {e.node_id}: {e.type.value.rsplit('.', 1)[-1]}")
case WorkflowEnd(status=status):
print("run", status.value if status else "ended")
stream_events yields these items:
WorkflowSnapshot: the run with all its nodes. It comes first on each connection.WorkflowEventFrame: a node is queued, started, succeeded, cached, skipped, failed or retrying, or a branch is decided.WorkflowEnd.
If the connection stops, the client continues it with Last-Event-ID.
api.workflows.wait(run_id) polls and does not use the stream. It raises the error of the
node that failed.
run = api.workflows.wait(run.run_id) # raises the run's error if it failed
nodes = {n.node_id: n for n in run.nodes}
print(nodes["adf"].result["verdict_plain"]) # e.g. "The series wanders: consider differencing it."
for d in run.decisions:
print(d.node_id, d.arm, d.inputs) # every branch with the values its condition read
order, d = nodes["order"].result, 1 if nodes["wanders"].result["value"] else 0
print(f"ARIMA({order['best_p']}, {d}, {order['best_q']})") # chosen by information criterion
print(nodes["dm"].result["verdict_plain"]) # is ARIMAX's edge over naive more than luck?
print(nodes["select"].result["reason_plain"]) # kept only if it beats the benchmarks
model_ref = run.outputs["model"].ref # model://growth/wf-weekly-readiness-fit@v1
report = run.outputs["report"].inline # mapi.report/v1: every test, decision and table
Since v0.9, a node can give a plot output (plot.render, model.forecast and
evaluate.residuals). api.workflows.node(run_id, node_id).outputs["plot"].inline is the
PlotSpec document of the plot. The server puts it through the plot sanitizer each time it
sends it. If the plot is not safe, inline is None. The gallery also has recipes for
Bayesian and science models, for example sci_spatial_ladder and bayes_workflow_audit. The
workflow cookbook shows how to run
each one with the client.
Governance¶
gov = nodes["governance"].result # the policy in force, measured on the selected model
print("passed" if gov["passed"] else f"failed: {gov['failed_checks']}")
validation = api.validations.get(gov["validation_id"]) # recorded because submit: true
print(validation.status.value) # validated, or awaiting_approval when a reviewer must sign
for r in validation.results:
print(f"{r.check:28} {r.status.value:7} {r.message_plain}")
api.validations.download_evidence_pack(validation.id, "evidence.html") # for the auditor
import yaml
with open("forecast-policy.yaml", encoding="utf-8") as fh:
policy = api.governance.create_policy(yaml.safe_load(fh))
print(policy.id, policy.version, [c.check for c in policy.checks])
# edit and save the next version (refused with 409 if someone saved one meanwhile)
stricter = api.governance.update_policy(
policy.id, policy.model_copy(update={"approvals_required": 2}), expected_version=1
)
for p in api.governance.policies(): # in force in the project: tenant-wide first
print(p.scope, p.name, f"v{p.version}")
validation = api.models.validate(model_id, version, note="Q4 refresh", wait=True)
print(validation.status.value) # validated, awaiting_approval or checks_failed
for r in validation.results:
print(r.check, r.status.value, r.message_plain, r.suggested_action or "")
if validation.status.value == "checks_failed":
validation = api.validations.resubmit(validation.id, note="refitted on clean data")
# a reviewer, signed in with `mapi login` (never the submitter, never an API token)
reviewer = Client(project=os.environ.get("MAPI_PROJECT"))
for pending in reviewer.validations.list(status="awaiting_approval"):
evidence = list(reviewer.validations.evidence(pending.id))
if all(r.status.value in ("pass", "skipped") for r in evidence):
reviewer.validations.approve(pending.id, comment="Evidence reviewed")
else:
reviewer.validations.reject(pending.id, "The null test is not convincing yet")
Explorers and budget optimisation¶
ds = api.datasets.find("weekly_sales")
assert ds is not None and ds.latest_version is not None
api.datasets.describe(ds.id, "Weekly sales and price by region, from the finance warehouse")
print(api.datasets.about(ds.id).summary_generated) # "Weekly data from ... for 5 regions ..."
explored = api.datasets.explore( # computed in the sandbox; the same request is cached
ds.id,
ds.latest_version,
sections=["overview", "stationarity", "ccf", "stl", "quality"],
kpi="sales",
max_lag=12,
)
for section in explored.index.sections if explored.index else []:
print(section.section, section.status, section.message_plain or "")
stationarity = api.explorations.view(explored, "stationarity") # the view, as JSON
model_id, version = "mdl_01jaf3c1d2e3f4g5h6j7k8m9n0", 3
explored = api.models.explore(
model_id, version, sections=["params", "residuals", "forecast"], horizon=13
)
residuals = api.explorations.view(explored, "residuals")
compared = api.models.compare(
["model://growth/arimax@v3", "model://growth/ets@v1", "model://growth/theta@v2"], horizon=4
)
table = api.explorations.view(compared, "comparison") # metrics, ranking, Diebold-Mariano
plan = api.models.optimize( # mmm-framework's planner on the posterior; waits for the plan
model_id,
version,
budget_change_pct=0, # reallocate today's total
channels={"tv": {"min_multiplier": 0.5, "max_multiplier": 1.5}},
objective="p10", # plan for a cautious outcome, not the average one
frontier={"n_points": 8},
)
assert plan.result is not None
for a in plan.result.allocation:
print(f"{a.channel:10} {a.current_spend:>12,.0f} -> {a.optimal_spend:>12,.0f}")
uplift = plan.result.uplift
print("uplift", uplift.mean if uplift else None, "P(better)", plan.result.prob_positive_uplift)
Schedules and triggers¶
weekly = api.workflows.schedule(
workflow.id,
"0 6 * * 1", # Mondays at 06:00
timezone="Europe/Berlin",
inputs={"data": "dataset://growth/weekly_sales@latest"},
params={"drivers": ["price"]},
)
on_upload = api.workflows.trigger(
workflow.id, "dataset://growth/weekly_sales", "data", debounce_seconds=600
)
print(weekly.next_run_at, on_upload.id)
Track metrics, params and tags v0.11¶
You can add metrics, params and tags to a run, also after the run finished. For example, log
a business KPI that you measured after you deployed the model. The writes need the runner
role. An API token needs the runs:write scope.
run_id = "run_01jaf3q2x8m9v7k5h4g3f2e1d0"
api.runs.log_metric(run_id, "revenue_lift", 0.042, labels={"region": "north"})
api.runs.log_metrics(run_id, {"mae": 3.1, "rmse": 4.2}, labels={"split": "post_launch"})
api.runs.log_params(run_id, {"analyst": "kim", "holdout_weeks": 8})
api.runs.set_tags(run_id, {"stage": "production", "old_tag": None}) # None deletes the tag
api.runs.update(run_id, name="weekly sales, adstock 4", note="Holiday weeks removed.")
with api.runs.track(run_id) as t:
for week, value in weekly_kpi:
t.log_metric("weekly_lift", value, step=week)
| Call | What it does |
|---|---|
runs.update(run_id, name=…, note=…, tags=…, experiment_id=…) |
PATCH /v1/runs/{run_id}. Only the arguments that you give change. None clears the name, the note or (v0.14) the experiment. |
runs.log_metric(run_id, key, value, step=None, labels=None, ts=None) |
Log one point |
runs.log_metrics(run_id, values, step=None, labels=None) |
Log a dict of values, or a list of MetricPointIn. The client sends a maximum of 1000 points in each request. |
runs.log_params(run_id, params) |
Log params (500 in each request). A different value for a stored key raises a CONFLICT error. |
runs.set_tags(run_id, tags) |
Set tags. None deletes a tag. |
runs.track(run_id, flush_every=500) |
A context manager with log_metric, log_metrics, log_param, log_params, set_tag and set_tags. It sends the points in batches and when the block ends. |
runs.metrics(run_id, key=None, attempt=None) |
The series of the run with their summaries (MetricSeries) |
runs.metric_history(run_id, key, max_points=1000, attempt=None, labels=None) |
The points of one key (MetricHistory), downsampled |
runs.params(run_id, prefix=None) |
The params (RunParam) |
runs.list can search by the tracking data. The client does a check of each filters,
order_by and columns value before it sends the request:
for run in api.runs.list(
filters=["metric.rmse < 12", "param.fit.engine = nutpie"],
tags=["stage:candidate"],
order_by="metric.rmse",
columns=["metric.rmse", "param.settings.adstock.l_max"],
):
print(run.id, run.name, run.columns)
The other filters of runs.list are kind, state, spec_id, parent_run_id, name
and (v0.14) experiment_id. For the grammar, see
Search runs. For the logging API inside a model, see
Track metrics, params and tags.
Media, experiments and comparison v0.14¶
# images and tables of a run
api.runs.log_image(run_id, "holdout_map", "map.png", caption="Holdout error by region")
api.runs.log_table(run_id, "lift", {"region": ["north", "south"], "lift": [0.04, 0.02]})
for item in api.runs.media(run_id, key="samples"):
png = api.runs.download_media(run_id, item) # checks the size, CRC32C and SHA-256
# experiments
exp = api.experiments.create("adstock length study", description="Adstock 2, 4 and 8.")
api.experiments.add_runs(exp.id, [run_a, run_b])
run = api.runs.fit(spec_id, revision, experiment_id=exp.id)
api.runs.update(run_c, experiment_id=exp.id) # move one run (None: no experiment)
# compare and save a search
cmp = api.runs.compare([run_a, run_b], keys=["loss"], media_keys=["samples"])
view = api.run_views.create(
"best fits", {"filters": ["metric.rmse < 12"], "order_by": "metric.rmse"},
experiment_id=exp.id,
)
runs = api.runs.list(experiment_id=exp.id, columns=["metric.rmse"])
| Call | What it does |
|---|---|
runs.media(run_id, key=None, kind=None, attempt=None, links=True) |
The images and tables of a run (RunMedia). With links, each item has a signed url. |
runs.media_keys(run_id, attempt=None) |
The media series with their counts and steps (MediaKeySummary) |
runs.get_media(run_id, media_id) |
One item with a new signed url |
runs.download_media(run_id, media) |
The bytes. The client does a check of the size, the CRC32C and the SHA-256. |
runs.log_image(run_id, key, image, step=None, labels=None, caption=None) |
Log PNG or JPEG bytes or a file path. With mapi-sdk, also a numpy array, a Pillow image or a DataArray. A maximum of 4 MiB. |
runs.log_table(run_id, key, table, step=None, labels=None, caption=None) |
Log a DataFrame, a dict of columns or a list of records |
runs.track(run_id) |
Also has log_image and log_table (sent immediately) |
runs.keys(experiment_id=None, prefix=None) |
The metric series, param keys and tag keys of the runs (RunKeys) |
runs.compare(run_ids, keys=(), media_keys=(), max_points=300, params=True) |
Compare 2 to 10 runs (RunCompare) |
runs.create(run, experiment_id=None), runs.fit(spec_id, revision, experiment_id=None) |
Start a run in an experiment |
experiments.list(name=None, archived=None), get, find(name_or_id) |
Find experiments |
experiments.create(name, description=None), update(id, name=…, description=…, archived=…), delete(id) |
Manage experiments |
experiments.add_runs(id, run_ids), remove_runs(id, run_ids) |
Move runs (a maximum of 200 in each request) |
run_views.list(experiment_id=None), find, create(name, query, experiment_id=None), update(id, name=…, query=…), delete(id) |
Saved views of the runs table |
The async client has the same calls. For the web app and the CLI, see Compare runs and use experiments.
Image collections and weights v0.15¶
from pathlib import Path
from mapi_spec import CollectionOptions, IngestMapping
# how many images are new? (the client calculates the SHA-256 of each image)
plan = api.datasets.missing_images(dataset_id, ["9f86d0…", "60303a…"]) # a ds_… id
print(plan.present, len(plan.missing))
# upload a folder: only the missing images, in parts, with a state file to continue later
version = api.datasets.upload_collection(
"product_photos",
Path("photos"),
label_from_folder=True,
options=CollectionOptions(size=(64, 64)),
state_file=Path("photos.upload.json"),
wait=True,
)
print(version.layout, version.collection.n_images, version.collection.new_images)
# pretrained weights as a dataset
weights = api.datasets.upload(
"resnet.safetensors", dataset="tiny-resnet", mapping=IngestMapping(format="safetensors")
)
| Call | What it does |
|---|---|
datasets.upload_collection(dataset, root, *, manifest=None, label_from_folder=False, options=None, base_version=None, part_bytes=DEFAULT_PART_BYTES, state_file=None, message=None, wait=False, timeout=None, progress=None) |
Upload a folder of images as a collection version (DatasetVersion). dataset is a name (the client makes the dataset when it does not exist) or a ds_… id. It uploads only the images that the base version does not have. With state_file, a second call continues the upload: it skips the parts that arrived and renews expired URLs. |
datasets.missing_images(dataset_id, shas, *, base_version=None) |
BlobMissing. The client sends the digests in groups of 5000 and merges the answers. |
datasets.get_upload(dataset_id, upload_id) |
An UploadSession with received |
datasets.renew_upload(dataset_id, upload_id) |
A fresh signed PUT for an upload whose file did not arrive |
datasets.upload_weights(source, *, dataset=None, dataset_id=None, arch=None, message=None, wait=True, timeout=None) |
Upload a .safetensors file of network weights as a weights dataset. arch is a dict or a JSON file, for a file without mapi_arch metadata. See Deep learning. |
mapi_client.collections has the parts of upload_collection that do not need a server:
scan_images(root, manifest, path_column=..., label_from_folder=...),
write_parts(plan, missing, out_dir, part_bytes) and client_manifest_csv(plan). The parts
are zip files with no compression. The same images always give the same bytes.
Async¶
AsyncClient has the same resources and arguments. Use await for the calls. Use
async for for the lists:
import asyncio
from mapi_client import AsyncClient
async def recent_fits(n: int = 3) -> list[str]:
async with AsyncClient() as aapi: # same resources and arguments, awaited
found: list[str] = []
async for run in aapi.runs.list(kind="fit", limit=n): # follows next_cursor lazily
found.append(f"{run.id} {run.state.value}")
if len(found) == n:
break
return found
print(asyncio.run(recent_fits()))
The v0.5 resources work in the same way:
import asyncio
from mapi_client import AsyncClient
async def main() -> None:
async with AsyncClient() as aapi:
wf = await aapi.workflows.find("weekly-readiness")
assert wf is not None
run = await aapi.workflows.run(
wf.id, inputs={"data": "dataset://growth/weekly_sales@latest"}, wait=True
)
async for node in aapi.workflows.nodes(run.run_id):
print(node.node_id, node.status.value)
asyncio.run(main())
Reference¶
| Resource | Calls |
|---|---|
projects |
list, get |
catalog |
get, models, model, steps, plots, schema |
datasets |
create, list, get, find, ensure, create_upload, create_version, versions, version, wait_version, upload, artifact, download; v0.8: upload_files, inspect, inspect_file; v0.15: upload_collection, missing_images, get_upload, renew_upload, upload_weights |
specs |
validate, create, list, get, find, revise, upsert, revisions, revision, dry_run |
runs |
create, fit, get, list, wait, stream_events, events, logs, plots, cancel, resume, retry; v0.11: update, log_metric, log_metrics, log_params, set_tags, track, metrics, metric_history, params; v0.14: media, media_keys, get_media, download_media, log_image, log_table, keys, compare |
experiments (v0.14) |
list, get, find, create, update, delete, add_runs, remove_runs |
run_views (v0.14) |
list, find, create, update, delete |
models |
list, get, versions, version, artifact, download, score |
plots |
list, get, validate; v0.4: save, saved, get_saved, delete_saved, render, render_status, render_output, download_render, create_embed, embeds, revoke_embed, embed |
guided (v0.4) |
templates, template, detect_roles, draft, compile, translate_priors, prior_translation, wait_prior_translation, preview_priors |
endpoints (v0.4) |
list, get, promote, delete, history, rollback, schema, validate, submit, score, snippets; api.endpoint(alias): info, schema, history, snippets, validate, score, score_result |
webhooks (v0.4) |
list, create, get, update, delete, rotate_secret, deliveries, replay |
tenants (v0.3) |
list, get, rename, projects, create_project, update_project, delete_project, members, add_member, set_role, remove_member, usage, audit; v0.4: verification, request_deletion, deletion, cancel_deletion, export, export_status, wait_export, download_export |
tokens (v0.3) |
list, create, get, revoke |
usage (v0.3) |
get (the project), tenant |
admin (v0.3, operators) |
tenants, tenant, create_tenant, update_tenant, quotas, set_quota, delete_quota, kill_switches, set_kill_switches, executors, executor, audit; v0.4: signups, review_signup, set_verification, retry_deletion |
List calls return lazy paginators that follow next_cursor. api.health() and
api.ready() call /healthz and /readyz. api.me() calls /v1/me. api.use(project)
changes the selected project. api.signup(), api.verify_email() and api.endpoint(alias)
are v0.4. mapi_client.webhooks verifies deliveries. mapi_client.xrjson encodes inline
payloads.
The platform optimizes budgets (see Optimise a budget).
For the DTOs, see mapi_spec. For the endpoints, see
the HTTP API reference.