# Nori
Source: https://docs.synthefy.com/index
Predict on any table in seconds, with no training.
**Nori** is a tabular foundation model for **regression** via in-context
learning: give it a few labeled rows as context and it predicts new rows in a
single forward pass — no task-specific training or fine-tuning.
```python theme={null}
from synthefy_nori import NoriRegressor
model = NoriRegressor(model="nori-30m") # downloads pretrained weights on first use
model.fit(X_train, y_train) # "fit" just stores the labeled context — no training
preds = model.predict(X_test) # predictions in a single forward pass
```
## Choose how to run it
Install the open-source package and predict on your own machine. No key, no
GPU required.
Send rows to our endpoint and get predictions back. No install, no GPU.
## Why Nori
Pretrained once; predict in seconds. No per-dataset training loop.
Strong predictions from a handful of rows.
A full predictive distribution — quantiles and prediction intervals out of
the box.
A scikit-learn–style `fit` / `predict` estimator.
SHAP, partial dependence, and feature selection built in.
Locally (open-source, GPU or CPU) or via the hosted API.
## Next steps
Pick the right package in one table.
Fit and predict in five lines.
Set up a key for the hosted API.
Email our team.
# Categorical & Ordinal Targets
Source: https://docs.synthefy.com/nori/categorical-targets
Predict labels on a discrete scale (ratings, counts, quality scores) instead of a continuous estimate.
Some regression targets only ever take a handful of values: a 1–5 star rating, a
wine-quality score of 3–8, a count of rooms. Nori still treats these as
regression: it predicts a full distribution over the real line. But by default
`predict` returns the distribution's **mean**, which almost never lands exactly
on one of your levels (you ask for a rating and get `3.87`).
## Quickstart
Pass `discretize=` and predictions are mapped onto the target's **level
lattice** instead, so every returned value is one your target can actually take:
```python theme={null}
import numpy as np
from synthefy_nori import NoriRegressor
rng = np.random.default_rng(0)
X_train = rng.normal(size=(80, 4))
score = X_train @ np.array([1.5, -1.0, 0.5, 0.0])
y_train = np.digitize(score, np.quantile(score, [0.2, 0.4, 0.6, 0.8])) + 1 # ratings 1..5
X_test = rng.normal(size=(5, 4))
model = NoriRegressor(model="nori-30m").fit(X_train, y_train)
print(model.predict(X_test)) # continuous: e.g. 3.87
print(model.predict(X_test, discretize="map-cell")) # labels on {1,...,5}: e.g. 4.0
```
Discretization is **strictly opt-in**: nothing is snapped unless you pass
`discretize=` (a strategy) or `categorical_levels=` (a known level set). There
is no separate flag. Either argument activates the feature, and
`categorical_levels` alone uses the default strategy (`"map-cell"`).
## Choosing a strategy
Nori emits a full predictive distribution per row, and discretization asks:
*which single lattice point best summarizes it?* The right answer depends on the
metric you are scored on. The mode wins on accuracy, the median on MAE, the mean
on quadratic penalties. Pick accordingly:
| `discretize` | What it is | Use it for |
| ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `"map-cell"` (default) | mode of the discrete posterior: integrate the distribution's mass in a cell around each level, take the argmax | accuracy, macro-F1 |
| `"median-cell"` | median of that discrete posterior (first level where cumulative cell mass crosses 0.5) | MAE / ordinal closeness |
| `"snap-mean"` | nearest level to the point mean | quadratically-penalized agreement (QWK) |
| `"snap-median"` | nearest level to the distribution median | MAE, with custom `bar_distribution` checkpoints |
| `"expected-level"` | Σ level·P(level) — a lattice-informed **continuous** expectation, deliberately off-lattice | analysis / sanity checks (≈ the plain mean) |
| `"prior-match"` | rank rows by point prediction, assign labels so predicted frequencies match training priors | calibration experiments only — benchmarked significantly worse; prefer `map-cell` / `median-cell` |
On a 13-dataset benchmark of discrete-target regression tasks (K ≤ 10 levels,
drawn from TALENT / OpenML-CTR23 / TabArena), the ordering above is exactly what
shows up: `map-cell` is the accuracy leader (0.755 vs 0.707 for snapping the
mean), `median-cell` the MAE leader, and `snap-mean` the QWK leader. The gains
are largest on **skewed** targets, where the mean falls between levels or on a
low-probability one, up to +48pp accuracy over mean-snapping on the most skewed
dataset.
If your task is scored by squared error or R², **do not discretize**. The
continuous mean is already optimal for those metrics, and any projection onto
the lattice trades R² away. `discretize=` is for when you need *labels*, not a
better regression score.
## Declaring the level set: `categorical_levels`
`categorical_levels` is the set of values the target can take, the label set in
classification terms. By default it is inferred from the distinct values of the
`y` you fit on, which is leak-safe. Pass it explicitly when your context is
small and may under-cover the true scale. For example, a 1–5 rating whose
context happens to contain no 1s:
```python theme={null}
labels = model.predict(X_test, categorical_levels=[1, 2, 3, 4, 5])
```
Every predicted label is guaranteed to be one of these levels. The values must
be **numeric and order-significant** (the cell-based strategies build
probability cells at the midpoints between adjacent values), hence *levels*,
ordinal terminology, rather than *labels*. String or unordered class labels are
out of scope: Nori is a regressor, not a classifier.
## Works with the sklearn ecosystem
`discretize` and `categorical_levels` are also **estimator parameters**, so
`clone`, `cross_val_score`, and `GridSearchCV` reach them; `predict` kwargs
override the constructor per call:
```python theme={null}
from sklearn.model_selection import GridSearchCV
gs = GridSearchCV(
NoriRegressor(model="nori-30m"),
{"discretize": ["map-cell", "median-cell", "snap-mean"]},
scoring="accuracy",
cv=5,
)
```
The one-shot helper accepts the same arguments: `infer(X_train, y_train, X_test,
discretize="map-cell")`.
## Configuration
Both are constructor parameters *and* `predict` kwargs. A kwarg you actually
pass wins for that call, but `None` means "unset", not "off" — so
`predict(X, discretize=None)` falls back to the constructor's strategy rather
than turning snapping off. Construct without `discretize=` if you want it off.
| Parameter | Default | Description |
| -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- |
| `discretize` | `None` | Strategy name. `None` means no snapping. See [Choosing a strategy](#choosing-a-strategy). |
| `categorical_levels` | `None` | The values the target can take. Inferred from the fitted `y` when `None`. Must be numeric and order-significant. |
## Good to know
* **Failed predictions stay visible.** A `NaN` point prediction stays `NaN`
after discretization rather than becoming a confident label.
* **Checkpoints.** The default Hugging Face checkpoints expose the full quantile
bank, so every strategy works out of the box. Custom `bar_distribution`
checkpoints have no bank, so they are limited to the strategies that read only
the point prediction: `snap-mean`, `snap-median`, and `prior-match`.
* **Hosted API.** Remote requests return point predictions only, so
`SynthefyNoriClient` supports `discretize="snap-mean"` and nothing else. The
other strategies need `mode="local"`.
* **Programmatic strategy list.** The strategy names and one-line descriptions
are importable (`synthefy_nori.discretize.DISCRETIZE_METHODS` /
`DISCRETIZE_METHOD_DESCRIPTIONS`) for building CLIs or UIs on top. The
underlying lattice math (`cell_masses`, `discretize_predictions`,
`snap_to_levels`) is public too.
**Upgrading from an older release?** Earlier versions silently snapped point
predictions whenever the target had ≤ 30 distinct values. That implicit behavior
is now **off by default**: snapping only happens when you ask for it.
Low-cardinality pipelines may see small numeric shifts (typically R² improves).
To restore the legacy behavior, pass
`NoriRegressor(model="nori-30m", discrete_y_snap_max_unique=30)`.
## Next steps
Pass NaNs straight through, and take control of the fill when you want it.
Which features drove the label the model chose.
# Python Client
Source: https://docs.synthefy.com/nori/client
Call Nori from the synthefy package — one client for the hosted API and for local inference, with DataFrames and retries handled for you.
`synthefy` is the lightweight client and workflow package. Install
`synthefy-nori` as well when you want to run the model locally. This page covers
`SynthefyNoriClient`.
Its one class, `SynthefyNoriClient`, runs the same `predict` call against the
hosted service, an Amazon SageMaker endpoint, or a local copy of the model.
`mode="remote"` uses the hosted API, `mode="sagemaker"` uses your AWS
endpoint, and `mode="local"` runs in-process.
Hand it a `DataFrame` and it aligns columns by name and encodes categoricals
for you, rather than making you build a numeric matrix.
Pass a target matrix to predict related numeric outcomes together and draw
samples from their joint distribution.
Timeouts, connection errors, 429s and 5xx are retried with exponential
backoff.
Use this client when you want one API for hosted, SageMaker, and local
execution. Local mode requires `synthefy-nori`; importing base `synthefy` stays
lightweight and never imports Torch. For the lower-level local estimator, use
`NoriRegressor` from the [quickstart](/nori/quickstart#local).
## Quickstart
Install the appropriate distribution from the
[installation guide](/setup/installation), then get a key from the
[API key guide](/setup/api_key).
```python theme={null}
import os
from synthefy import SynthefyNoriClient
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="synthefy/nori-30m",
)
X_train = [[0.0, 1.0], [1.0, 0.0], [0.5, 0.5], [0.2, 0.8]]
y_train = [0.1, 0.9, 0.5, 0.3]
X_test = [[0.3, 0.7], [0.8, 0.2]]
print(client.predict(X_train, y_train, X_test))
```
```
[0.37, 0.72]
```
`predict` returns one float per row of `X_test`, in order. Pass `as_pandas=True`
to get a `pandas.Series` instead.
## Choosing a mode
`mode` decides where the model runs. It defaults to `"remote"`.
| `mode` | Runs | Needs |
| ------------- | ------------------------------- | ------------------------------------------------------------- |
| `"remote"` | the hosted endpoint | an API key; no GPU, no download |
| `"sagemaker"` | your SageMaker endpoint | the `aws` dependencies, an endpoint name, and AWS credentials |
| `"local"` | in-process, via `synthefy-nori` | the local runtime; no key or network |
Choose the mode deliberately. The client never switches because it found local
packages or ambient credentials. Change only `mode` and its backend-specific
configuration when promoting the same workflow:
```python theme={null}
# No key or network. The model is still explicit.
client = SynthefyNoriClient(mode="local", model="nori-30m")
# Hosted production path.
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="synthefy/nori-30m",
)
```
For Marketplace subscription and endpoint creation, follow the
[Amazon SageMaker guide](/nori/sagemaker).
## Predicting from DataFrames
Pass DataFrames and the client builds the numeric matrix for you. `X_test` is
aligned to `X_train` **by column name**, so column order does not matter; a
mismatch in the column *sets* raises.
```python theme={null}
preds = client.predict(df_train, y_train, df_test, as_pandas=True)
```
Non-numeric columns are encoded, fit on `X_train` and applied to `X_test`:
* **Default (`categorical_encoding="ordinal"`)** — each categorical column
becomes one column of integer codes, from `X_train`'s categories in sorted
order. A value seen only in `X_test` maps to `-1`; missing stays `NaN` and is
imputed server-side.
* **`categorical_encoding="onehot"`** — one indicator column per category, and
missing values get their own indicator.
* **`max_categorical_cardinality`** (default `100`) caps how many distinct
values a categorical column may have. Above it the column is **dropped**, with
a warning telling you to encode it yourself.
## Configuration
| Parameter | Default | Description |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `model` | **required** | Size selector (`"nori-6m"`, `"nori-30m"`) or a raw gateway slug. No default; omitting it raises `ValueError`. |
| `mode` | `"remote"` | `"remote"`, `"sagemaker"`, or `"local"`. `"auto"` is rejected. |
| `api_key` | `None` | Remote mode only. Falls back to the `SYNTHEFY_NORI_API_KEY` environment variable. |
| `timeout` | `300.0` | Per-request timeout in seconds. |
| `max_retries` | `2` | Retries on timeouts, connection errors, 429 and 5xx, with exponential backoff. |
| `auth_scheme` | `"Bearer"` | The gateway needs `"Bearer"`; dedicated deployments use `"Api-Key"`. |
| `base_url` / `endpoint` | the hosted gateway | Point at a dedicated deployment instead. |
| `endpoint_name` | `None` | Required with `mode="sagemaker"`; the SageMaker endpoint to invoke. |
| `region_name` | AWS configuration | AWS region containing the SageMaker endpoint. |
| `user_agent` | `None` | Custom `User-Agent` header. |
| Parameter | Default | Description |
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `as_pandas` | `False` | Return a `pandas.Series` instead of a list. |
| `categorical_encoding` | `"ordinal"` | `"ordinal"` or `"onehot"`. |
| `max_categorical_cardinality` | `100` | A non-numeric column with more distinct values than this is dropped, with a warning telling you to encode it yourself. |
| `discretize` | `None` | Remote mode returns point predictions only, so `"snap-mean"` is the only strategy it supports; the others need `mode="local"`. See [Categorical & Ordinal Targets](/nori/categorical-targets). |
| `text_columns` | `None` | Names the free-text columns. See [Installation](/setup/installation#optional-features). |
| `timeout` | inherits the client | Override the timeout for one call. |
| `extra_headers` | `None` | Additional HTTP headers. |
## Next steps
Create the key this client authenticates with.
The model sizes, and running the model directly without the client.
# Embeddings
Source: https://docs.synthefy.com/nori/embeddings
Turn any table row into a target-aware vector with Nori's encoder, then probe, cluster, search, or visualize it.
Every time Nori looks at a row, it builds a dense vector for it, a learned
representation shaped by how that row relates to the target and to the rest of
your table. Pull those vectors out and they become features you can drop into
anything downstream.
Feed the vectors to a kNN, linear probe, or gradient-boosting head, often
matching Nori's own accuracy with a model you fully control.
Nearest-neighbor lookup in embedding space finds rows that are
*behaviorally* alike, not just numerically close.
Cluster on the embeddings to segment rows by what actually drives the
target.
Project to 2-D and watch the target structure appear. See the
[gallery](#see-it-on-real-data) below.
Nori is **regression-only**, so every embedding is conditioned on a continuous
target. Extraction runs on the local `synthefy-nori` package (not the hosted
API) and needs no API key. Weights download from Hugging Face on first use.
## Quickstart
Nothing beyond the base install — see [Installation](/setup/installation).
Embeddings need no extra, and scikit-learn is a core dependency, so
downstream heads work out of the box.
`NoriEmbedding` is a scikit-learn transformer. With `n_fold >= 2`,
`fit_transform` returns **leak-free** training embeddings (each row embedded
by a model that never saw its label), and `transform` embeds unseen rows
with the final full-data model.
```python theme={null}
from synthefy_nori import NoriEmbedding, NoriRegressor
embedder = NoriEmbedding(n_fold=5, shuffle=True, random_state=0,
model=NoriRegressor(model="nori-30m"))
train_emb = embedder.fit_transform(X_train, y_train) # out-of-fold
test_emb = embedder.transform(X_test) # full-data model
```
Both calls return a 3-D array `(n_estimators, n_samples, embed_dim)`, one
slice per preprocessing pipeline in the inference ensemble. Average over
axis 0 (or pick one member) for a matrix any estimator can consume.
```python theme={null}
Z_train = train_emb.mean(axis=0) # (n_train, embed_dim)
Z_test = test_emb.mean(axis=0) # (n_test, embed_dim)
```
The result is a plain 2-D matrix, so any scikit-learn estimator takes it
from here, for example a nearest-neighbor regressor:
```python theme={null}
from sklearn.neighbors import KNeighborsRegressor
from sklearn.metrics import r2_score
knn = KNeighborsRegressor(n_neighbors=10).fit(Z_train, y_train)
print(r2_score(y_test, knn.predict(Z_test)))
```
## Pick your extraction API
The sklearn transformer. Handles the fold logic and the final full-data fit
for you, so training embeddings stay leak-free.
| Method | Returns |
| --------------------------------- | ----------------------------------------------------------- |
| `fit_transform(X_train, y_train)` | training embeddings (out-of-fold when `n_fold >= 2`) |
| `transform(X)` | embeddings for unseen rows, always from the full-data model |
```python theme={null}
from synthefy_nori import NoriEmbedding, NoriRegressor
# Hand it a pre-built estimator to reuse one config across the fold-fits.
embedder = NoriEmbedding(
n_fold=5, shuffle=True, random_state=0,
model=NoriRegressor(model="nori-30m"),
)
Z_train = embedder.fit_transform(X_train, y_train).mean(axis=0)
Z_test = embedder.transform(X_test).mean(axis=0)
```
`transform` never returns cached training embeddings, even if the input
equals the training set. For training-row vectors use `fit_transform` (or
read `embedder.train_embeddings_`).
Direct control over which rows get embedded against which context. Call it
on a fitted `NoriRegressor`; `data_source` selects what comes back.
| `data_source` | Embeds |
| ------------------ | ----------------------------------------------------------------- |
| `"test"` (default) | the query rows you pass as `X` |
| `"train"` | the stored **context** rows, so `X` is ignored and can be omitted |
```python theme={null}
from synthefy_nori import NoriRegressor
model = NoriRegressor(model="nori-30m").fit(X_train, y_train)
test_emb = model.get_embeddings(X_test, data_source="test") # query rows
train_emb = model.get_embeddings(data_source="train") # context rows
```
These come from the full-data model, so `"train"` rows encode their own
labels. For leak-free training embeddings, use `NoriEmbedding` with
`n_fold >= 2`.
## Leak-free or fast: choose per use
The `n_fold` setting is the one decision that matters. It trades a bit of
compute for an honest downstream score.
K-fold cross-validation: each training row is embedded by a fold model that
**did not** have it in context, so the vector never leaks that row's own
label. Unseen rows are then embedded by a single model fit on the full
training set. This is what gives a downstream probe an unbiased estimate of
generalization ([arXiv:2502.17361](https://arxiv.org/abs/2502.17361)).
```python theme={null}
NoriEmbedding(n_fold=5, shuffle=True, random_state=0,
model=NoriRegressor(model="nori-30m"))
```
One model is fit on the whole training set and used for every row. It is
cheaper (a single fit instead of `n_fold + 1`), but the training embeddings
encode their own labels. Fine for visualization or embedding brand-new rows;
**don't** fit a downstream model on vanilla training embeddings and trust
the score.
```python theme={null}
NoriEmbedding(n_fold=0, model=NoriRegressor(model="nori-30m"))
```
## See it on real data
Because the vectors are organized by the target, a 2-D t-SNE of Nori embeddings
separates rows by their target value far more cleanly than a t-SNE of the raw
features. Both runnable examples below produce exactly these plots. To run them,
clone the repo and add the `eval` extra — it brings in matplotlib, plus openml
for the TabArena example. See [contributor extras](/setup/installation).
[`examples/embedding_synthetic.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/embedding_synthetic.py)
runs the workflow on a small synthetic regression target: it extracts
embeddings, runs a kNN probe, and draws the t-SNE. Fast, with no data
download.
```bash theme={null}
python embedding_synthetic.py
```
```text theme={null}
synthetic regression: train=280 test=120 features=5
get_embeddings -> (n_estimators, n_samples, embed_dim) = (8, 120, 128)
=== R2 ===
kNN probe on embeddings : 0.9954
Nori native head (ref.) : 0.9995
```
[`examples/embedding_tabarena.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/embedding_tabarena.py)
runs the same workflow on a real TabArena regression dataset (a Ridge probe
here). Pass `--dataset` to switch, or `--list` to see the options.
```bash theme={null}
python embedding_tabarena.py --dataset wine_quality
```
```text theme={null}
wine_quality: train=3000 test=1500 features=12
get_embeddings -> (n_estimators, n_samples, embed_dim) = (8, 1500, 128)
=== R2 ===
Ridge probe on embeddings : 0.5168
Nori native head (ref.) : 0.5148
```
Rolling your own plot is three lines once you have `Z_test`:
```python theme={null}
from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler
emb_2d = TSNE(n_components=2, init="pca", random_state=0).fit_transform(
StandardScaler().fit_transform(Z_test)
) # scatter emb_2d colored by y_test
```
## Configuration
| Parameter | Default | Description |
| -------------- | ------- | --------------------------------------------------------------------------------- |
| `n_fold` | `0` | CV folds. `0` = vanilla (single model); `>= 2` = out-of-fold training embeddings. |
| `model` | `None` | A pre-configured `NoriRegressor`. Built at `fit` time when `None`. |
| `shuffle` | `False` | Shuffle the K-fold split. |
| `random_state` | `None` | Seed for the split when `shuffle=True`. |
After `fit`, the transformer exposes `model_` (the full-data model) and
`train_embeddings_` (out-of-fold when `n_fold >= 2`).
A standardized ridge probe (good default for the higher-dimensional
embeddings of real data):
```python theme={null}
from sklearn.linear_model import RidgeCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
probe = make_pipeline(StandardScaler(), RidgeCV(alphas=(0.1, 1.0, 10.0, 100.0)))
probe.fit(Z_train, y_train)
```
The same `Z_train` / `Z_test` matrices feed clustering (`KMeans`), nearest-
neighbor search (`NearestNeighbors`), or any 2-D projection.
## Good to know
**Shapes at a glance:** embeddings are `(n_estimators, n_samples, embed_dim)`.
Average over `axis=0` (or select `[0]`) before handing them to a 2-D estimator.
## Next steps
Turn a time series into a table and forecast it locally with Nori.
SHAP values and partial dependence on the same fitted estimator.
# Examples
Source: https://docs.synthefy.com/nori/examples
Worked examples using Synthefy Nori (Tabular).
End-to-end examples on real datasets. Each one runs locally against the
open-source package. See [Installation](/setup/installation) if you haven't
installed it yet.
## Quickstart
Load a dataset, split it, and get predictions. The model figures out the pattern
from your training rows, with no configuration needed.
```python theme={null}
import pandas as pd
from sklearn.datasets import fetch_california_housing
from synthefy_nori import NoriRegressor
X, y = fetch_california_housing(return_X_y=True, as_frame=True)
df = X.copy()
df["price"] = y
target_col = "price"
feature_cols = [c for c in df.columns if c != target_col]
train = df.sample(frac=0.8, random_state=42)
test = df.drop(train.index)
model = NoriRegressor(model="nori-30m").fit(
train[feature_cols].values,
train[target_col].values,
)
test["predicted_price"] = model.predict(test[feature_cols].values)
print(test[["price", "predicted_price"]].head())
```
```
price predicted_price
9 2.611 2.692868
11 2.418 2.749135
13 1.913 1.884583
16 1.525 2.121786
24 1.326 1.822688
```
## Next steps
Predict related numeric targets together and draw from their joint distribution.
Pull Nori's own target-aware row vectors out and use them downstream.
SHAP values, feature interactions, and partial dependence.
# Explainability
Source: https://docs.synthefy.com/nori/explainability
Explain Nori's predictions with SHAP / Shapley values, feature interactions, partial dependence, and feature selection.
Understand *why* Nori made a prediction: which features drove it, how they
interact, and how the model responds across a feature's range. Because
`NoriRegressor` is a scikit-learn estimator, it plugs directly into
[shapiq](https://github.com/mmschlk/shapiq) — a fast SHAP implementation with
native Shapley-interaction support — and the rest of the sklearn
interpretability ecosystem. The helpers in `synthefy_nori.interpretability` are
thin convenience wrappers, so you can also use the underlying tools directly.
[`examples/interpretability_regression.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/interpretability_regression.py)
These examples run locally with the `synthefy-nori` package and its
`interpretability` extra, which pulls in `shapiq`. Follow
[Installation](/setup/installation) before running them.
Nori is **regression-only**, so every method below explains the regression
predictive mean.
## Quickstart
`get_nori_imputation_explainer` builds a `shapiq.TabularExplainer` that removes
features by **imputation** against a background set. The training context stays
fixed across coalitions, so each coalition is a single `predict` call and the
cost is set by `budget`.
```python theme={null}
import matplotlib.pyplot as plt
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from synthefy_nori import NoriRegressor
from synthefy_nori.interpretability.shapiq import get_nori_imputation_explainer
dataset = load_diabetes()
X_train, X_test, y_train, y_test = train_test_split(
dataset.data, dataset.target, test_size=0.2, random_state=0
)
feature_names = list(dataset.feature_names)
model = NoriRegressor(model="nori-30m").fit(X_train, y_train)
# Plain Shapley values sum from the baseline to the prediction.
explainer = get_nori_imputation_explainer(model, X_train, index="SV", max_order=1)
sv = explainer.explain(X_test[:1], budget=1024)
print(sv)
ax = sv.plot_waterfall(
feature_names=feature_names, abbreviate=False, show=False
)
# Leave room for negative-value labels beside the feature names.
left, right = ax.get_xlim()
ax.set_xlim(left - 0.2 * (right - left), right)
ax.set_title("SHAP waterfall: one Nori prediction", fontsize=15, pad=42)
ax.figure.savefig("nori-shap-waterfall.png", dpi=180, bbox_inches="tight")
plt.show()
```
## Feature interactions
Use a separate explainer with `index="k-SII", max_order=2` for **pairwise
interactions**, which you can view as a network:
```python theme={null}
from matplotlib.lines import Line2D
interaction_explainer = get_nori_imputation_explainer(
model, X_train, index="k-SII", max_order=2
)
kv = interaction_explainer.explain(X_test[:1], budget=1024)
fig, ax = kv.plot_network(feature_names=feature_names, show=False)
# Native edge patches have no outline; their alpha scales with |interaction|.
# Scale against the strongest pairwise effect, separately from node effects.
edge_patches = [patch for patch in ax.patches if patch.get_linewidth() == 0]
edge_strengths = [patch.get_alpha() for patch in edge_patches]
max_strength = max(edge_strengths, default=0) or 1
for patch, strength in zip(edge_patches, edge_strengths):
patch.set_linewidth(0.5 + 4.5 * strength / max_strength)
# Keep signed colors opaque and even the weakest connections visible.
for patch in ax.patches:
patch.set_alpha(1.0)
patch.set_edgecolor(patch.get_facecolor())
ax.set_title("Feature interaction network", pad=16)
ax.legend(
handles=[
Line2D([], [], color="#ff0d57", marker="o", linestyle="none",
markersize=9, label="Node: positive main effect"),
Line2D([], [], color="#ff0d57", linewidth=2.5,
label="Line: positive pairwise interaction"),
Line2D([], [], color="#555555", linewidth=0.5,
label="Line width: weaker interaction"),
Line2D([], [], color="#1e88e5", marker="o", linestyle="none",
markersize=9, label="Node: negative main effect"),
Line2D([], [], color="#1e88e5", linewidth=2.5,
label="Line: negative pairwise interaction"),
Line2D([], [], color="#555555", linewidth=5,
label="Line width: stronger interaction"),
],
title=("Positive adds to this prediction; negative subtracts\n"
"Larger nodes indicate stronger main effects"),
loc="upper center", bbox_to_anchor=(0.5, -0.08),
ncol=2, frameon=False, fontsize=10, title_fontsize=11,
)
fig.savefig("nori-shap-interactions.png", dpi=180, bbox_inches="tight")
plt.show()
```
## Partial dependence / ICE
```python theme={null}
from matplotlib.ticker import MaxNLocator
from synthefy_nori.interpretability.pdp import partial_dependence_plots
# Diabetes columns 2 and 3 are bmi and bp; use kind="both" for PDP + ICE.
fig, axes = plt.subplots(2, 1, figsize=(8, 7), layout="constrained")
partial_dependence_plots(
model, X_test, features=[2, 3], kind="average",
feature_names=feature_names, ax=axes,
)
fig.suptitle("Partial dependence: diabetes progression\none year after baseline", fontsize=15)
for ax, label in zip(axes, ["Body mass index (BMI)", "Average blood pressure"]):
ax.set_title(label, fontsize=12)
ax.set_xlabel(f"{label} (centered and scaled)")
ax.set_ylabel("Average predicted diabetes\nprogression score", fontsize=11)
ax.xaxis.set_major_locator(MaxNLocator(5))
ax.yaxis.set_major_locator(MaxNLocator(5))
fig.savefig("nori-pdp.png", dpi=180, bbox_inches="tight")
plt.show()
```
Pass `(i, j)` tuples in `features` for 2-D interaction surfaces. Returns an
sklearn `PartialDependenceDisplay`.
## Feature selection
Find a minimal feature subset that preserves cross-validated performance
(sequential selection):
```python theme={null}
from synthefy_nori.interpretability.feature_selection import feature_selection
res = feature_selection(model, X_train, y_train, n_features_to_select=5, cv=3)
print(res.selected_indices, res.selected_score_mean)
```
`n_features_to_select` accepts an int, a fraction, or `"auto"` (with `tol`). The
result also reports baseline-vs-selected CV scores.
Sequential selection re-fits Nori in-context on every CV split, so keep it to a
few thousand samples and a modest feature count.
## Configuration
| Parameter | Default | Description |
| ----------- | ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `index` | `"k-SII"` | `"SV"` for plain Shapley values, or `"k-SII"` for k-Shapley interactions. |
| `max_order` | `2` | Maximum interaction order. `1` = single-feature attributions, `2` = pairwise. |
| `imputer` | `"baseline"` | `"baseline"` runs one forward per coalition (recommended); `"marginal"` / `"conditional"` are multi-sample and much slower. |
**Budget** trades accuracy for cost. Start at `128` and raise only if the
explanation looks noisy: `<10` features → 64–128; 10–20 → 128–512; 20+ →
512–2048.
## Good to know
* These run on the **local Python package** (`synthefy-nori`), not the hosted
API.
* TabPFN's fast Shapley path reuses a KV-cache across coalitions; Nori's public
package runs one forward per coalition, which is correct and
budget-controlled, just not cache-accelerated.
## Next steps
Distill Nori into a readable, shippable additive model (EBM) in one call.
Predict labels on a discrete scale instead of a continuous estimate.
Target-aware row vectors for probing, clustering, and search.
# Forecasting
Source: https://docs.synthefy.com/nori/forecasting
Forecast time series with custom targets and future-known data.
`NoriTSForecaster` converts one or many time series into regression features
and reconstructs point and quantile forecasts. The quickstart below runs Nori
locally with an explicitly selected model.
## Quickstart
Follow [Installation](/setup/installation#forecasting) and choose the
hosted or local forecasting extra.
Provide `timestamp` and `target` columns, ordered from oldest to newest.
```python theme={null}
import numpy as np
import pandas as pd
from synthefy.nori_ts import NoriTSForecaster
periods = 500
history = pd.DataFrame({
"timestamp": pd.date_range("2024-01-01", periods=periods, freq="h"),
"target": 10 + np.sin(2 * np.pi * np.arange(periods) / 24),
})
```
`predict_df` returns one row per future timestamp.
```python theme={null}
forecaster = NoriTSForecaster(
mode="local",
model="nori-30m",
quantiles=[0.1, 0.5, 0.9],
)
forecast = forecaster.predict_df(history, prediction_length=48)
print(forecast.columns.tolist())
print(forecast.index.names)
print(forecast.shape)
```
```text theme={null}
['target', '0.1', '0.5', '0.9']
['item_id', 'timestamp']
(48, 4)
```
`target` is the median forecast. The other columns contain the requested
quantiles.
## Choose where it runs
The quickstart uses `mode="local"`. To run the same feature preparation and
forecast reconstruction against hosted Nori, change only the backend
configuration:
```python theme={null}
import os
from synthefy import SynthefyNoriClient
from synthefy.nori_ts import NoriTSForecaster
client = SynthefyNoriClient(
mode="remote",
model="synthefy/nori-30m",
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
)
forecaster = NoriTSForecaster(
client=client,
quantiles=[0.1, 0.5, 0.9],
)
forecast = forecaster.predict_df(history, prediction_length=48)
```
There is no separate forecasting endpoint. The workflow prepares numeric
`X_train`, `y_train`, and `X_test` locally, then sends the unchanged regression
contract through the selected client backend. Use `mode="sagemaker"` with
`endpoint_name=` and `region_name=` for your AWS endpoint.
## Multiple series
Add an `item_id` column to forecast several series in one call. Nori fits and
forecasts each series independently.
```python theme={null}
import numpy as np
import pandas as pd
from synthefy.nori_ts import NoriTSForecaster
periods = 300
t = np.arange(periods)
timestamps = pd.date_range("2024-01-01", periods=periods, freq="h")
history = pd.concat(
[
pd.DataFrame({
"item_id": "north",
"timestamp": timestamps,
"target": 10 + np.sin(2 * np.pi * t / 24),
}),
pd.DataFrame({
"item_id": "south",
"timestamp": timestamps,
"target": 20 + np.sin(2 * np.pi * t / 24 + np.pi / 4),
}),
],
ignore_index=True,
)
forecast = NoriTSForecaster(
mode="local",
model="nori-30m",
quantiles=[0.1, 0.5, 0.9],
).predict_df(history, prediction_length=24)
print(forecast.groupby(level="item_id").size().to_dict())
```
```text theme={null}
{'north': 24, 'south': 24}
```
## Future-known covariates
Pass `future_df=` when a numeric feature is known across the forecast horizon.
For example, you know a planned promotion before prediction and can supply it
for both the history and future rows. Use `target_column=` to keep a domain name
such as `sales` in both the input and forecast.
```python theme={null}
import numpy as np
import pandas as pd
from synthefy.nori_ts import NoriTSForecaster
periods = 300
horizon = 24
t = np.arange(periods)
timestamps = pd.date_range("2024-01-01", periods=periods, freq="h")
promotion = ((t % 72) >= 60).astype(float)
history = pd.DataFrame({
"item_id": "store-a",
"timestamp": timestamps,
"sales": 10 + np.sin(2 * np.pi * t / 24) + 3 * promotion,
"promotion": promotion,
})
future_timestamps = pd.date_range(
timestamps[-1] + pd.Timedelta(hours=1), periods=horizon, freq="h"
)
future = pd.DataFrame({
"item_id": "store-a",
"timestamp": future_timestamps,
"promotion": np.r_[np.zeros(12), np.ones(12)],
})
forecast = NoriTSForecaster(
mode="local",
model="nori-30m",
quantiles=[0.1, 0.5, 0.9],
).predict_df(history, future_df=future, target_column="sales")
print(forecast.columns.tolist())
print(forecast.shape)
```
```text theme={null}
['sales', '0.1', '0.5', '0.9']
(24, 4)
```
The target remains univariate; `promotion` is an input feature. Every numeric
history covariate must appear throughout `future_df`. Only provide values you
know at prediction time, such as planned promotions, holidays, weather
forecasts, or scheduled prices. A realized future target or any value observed
only after the forecast timestamp would be target leakage.
`target_column=` accepts one column name per call. To forecast several target
columns, call `predict_df` once for each target. Passing a sequence raises a
clear error before Nori sends a local or hosted request.
## How it works
For each series, the forecaster creates rows indexed by
`(item_id, timestamp)`. Historical rows contain the observed target, and
future rows contain generated time features. Numeric columns supplied in
both the history and horizon are also used as covariates. Nori fits on the
historical rows and predicts the future rows.
The default features include a running index, calendar sine and cosine
values, and seasonal periods detected from the history. You can replace
them with custom feature generators through `features=`.
Set `quantiles=` to choose the returned quantile columns. The `target`
column contains the median forecast.
## Configuration
| Parameter | Default | Description |
| ---------------- | ----------------- | -------------------------------------------------------------------------------------------------- |
| `mode` | **required** | `"local"`, `"remote"`, or `"sagemaker"`; there is no automatic selection. |
| `model` | **required** | Explicit model selector such as `"nori-6m"` or `"nori-30m"`. |
| `client` | `None` | Inject an already configured `SynthefyNoriClient`; do not also pass backend constructor arguments. |
| `api_key` | `None` | Hosted API key, used only with `mode="remote"`. |
| `endpoint_name` | `None` | Required with `mode="sagemaker"`. |
| `region_name` | AWS configuration | AWS region containing the SageMaker endpoint. |
| `context_length` | `4096` | Cap on history rows per series (last-N kept). |
| `quantiles` | `[0.1 … 0.9]` | Forecast quantile levels; the median is the point forecast. |
| `features` | `None` | Feature generators; defaults to the standard set. |
`context_df` needs `timestamp` and one target column. Add `item_id` for
multi-series frames. Pass exactly one of `prediction_length=` or
`future_df=`. Use `target_column=` when the target is not named `target`.
The result is indexed by `(item_id, timestamp)` and contains the median
under the target's original name plus one column per quantile level.
```python theme={null}
forecast = NoriTSForecaster(
mode="local",
model="nori-30m",
quantiles=[0.1, 0.5, 0.9],
).predict_df(
history,
prediction_length=48,
)
```
With `prediction_length=`, the forecaster generates timestamps and drops
history covariates because their future values are unknown. With
`future_df=`, the explicit horizon supplies those future values. Its target
must be absent or entirely missing.
## Good to know
* One `predict_df` call forecasts one target column across one or many
`item_id` series.
* `future_df` must contain the same `item_id` values as history, without
duplicate or overlapping timestamps.
* DataFrame preparation is local in every mode. Remote and SageMaker modes send
only the resulting numeric regression request.
## Next steps
Configure explicit local, hosted, and SageMaker execution.
Add free-text columns to the shared regression preparation path.
Browse more end-to-end Nori workflows.
# Glass-box Models
Source: https://docs.synthefy.com/nori/glassbox
Distill Nori into a readable, shippable glass-box model (EBM) in one call — feature importance, pruning, and an auditable additive model.
Nori is accurate but, like any deep model, a black box. `NoriInterpreter` turns
it into a standalone **glass-box [Explainable Boosting Machine](https://interpret.ml)**
(EBM / GA²M) — an additive model whose entire decision logic is a set of readable
per-feature curves. You keep most of Nori's accuracy but get a model you can
inspect, ship, and audit line by line, which is often what a review or a
regulated deployment actually needs.
[`examples/explainability_credit.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/explainability_credit.py)
Needs the `explainability` extra (`interpret-core`, `joblib`, `scipy`). The
SHAP-based importance path additionally needs the `interpretability` extra
(`shapiq`).
```bash theme={null}
pip install "synthefy-nori[explainability]"
```
## How it works
A single `fit(X, y)` runs three steps and stores every artifact:
Fit Nori and score each raw input column by **permutation importance** — the
drop in held-out skill when that column is shuffled.
Drop the low-importance columns, keeping the smallest subset that still
retains ≥ `retain` (default 95%) of Nori's skill.
Fit an EBM on the surviving features. It learns one shape function per
feature (plus a few pairwise interactions), so the whole model reads as
`baseline + Σ per-feature contributions`.
The task (regression or classification) is auto-detected — Nori itself stays
regression-only and scores the pruning, while the distilled EBM matches the
target type.
## Quickstart
```python theme={null}
from synthefy_nori.explainability import NoriInterpreter
interp = NoriInterpreter().fit(X, y) # full table; model="nori-6m" by default
interp.feature_importances_ # per input column (skill drop when shuffled)
interp.importance_ranking_ # [{feature, index, importance}], most-important first
interp.selected_features_ # the pruned feature set (kept ≥ retain of Nori's skill)
interp.ebm_ # the fitted glass-box model (shippable)
interp.summary() # {task, metric, n_selected, nori/ebm scores, top_features}
interp.plot_model() # draw the glass-box shape functions (below)
interp.predict(X_new) # score with the glass-box on the selected features
```
## Worked example — UCI Credit Default
[`examples/explainability_credit.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/explainability_credit.py)
runs the full flow on the UCI Credit Default table (23 features, \~30k rows,
downloaded fresh). The permutation ranking is dominated by `PAY_0` — the most
recent repayment status — with the credit limit and earlier repayment months
trailing it:
Pruning keeps **7 of 23** features at the 95% bar, and distilling those into an
EBM costs very little: Nori scores **AUC 0.781** on all features, and the
glass-box EBM reaches **0.766** on the 7 it kept. The distilled model is fully
legible — each middle panel is one feature's contribution to the predicted
default probability, the right-hand heatmaps are pairwise interactions, and the
`Σ → σ` node adds the baseline and every contribution into the final prediction:
## Configuration
| Parameter | Default | Description |
| ------------------ | ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `model` | `"nori-6m"` | Nori checkpoint used to measure importance and score the pruning. |
| `retain` | `0.95` | Skill fraction to keep when pruning — the EBM is fit on the smallest subset that holds ≥ this share of Nori's score. |
| `reduce_threshold` | `16` | Only prune when the table has more than this many features; smaller tables keep every column. |
| `test_size` | `0.3` | Held-out fraction used to measure importance and score the pruning sweep. |
| `random_state` | `0` | Seed for the internal split and permutation shuffles. |
For finer control, the same steps are exposed as standalone functions — import
from the submodules: `explainability.importance` (`nori_permutation_importance`,
`nori_shap_importance`), `explainability.ebm` (`fit_ebm`, `ebm_structure`), and
`explainability.pipeline` (`run`, the end-to-end importance → EBM driver).
## Good to know
* Runs on the **local Python package** (`synthefy-nori`), not the hosted API.
* Nori remains the source of truth for accuracy — the EBM is a faithful,
auditable *approximation* on the selected features, so expect a small score
gap (≈0.01 AUC on the credit example).
* For single-prediction attributions, feature interactions, and partial
dependence, see [Explainability](/nori/explainability).
## Next steps
SHAP / Shapley values, interactions, PDP, and feature selection.
Predict labels on a discrete scale instead of a continuous estimate.
# InfluxDB
Source: https://docs.synthefy.com/nori/influxdb
Run Nori regression inside InfluxDB 3 as a Processing Engine plugin, with predictions written back to your database.
Nori runs inside InfluxDB 3 as a plugin. You tell it which column to predict and
which columns to predict it from, and it writes the results back into your
database as a new table.
## How InfluxDB runs it
InfluxDB 3 stores rows in tables, like any database, with one difference that
matters here: every row carries a timestamp, and a column can be empty on some
rows and filled on others. A sensor that stops reporting leaves its column empty
while the columns beside it keep filling.
InfluxDB 3 also has a **Processing Engine**, which runs Python inside the database
itself, either on a schedule or when you call an HTTP endpoint. Nori ships as one
of those plugins,
[`nori_regression`](https://github.com/influxdata/influxdb3_plugins/tree/main/influxdata/nori_regression),
published in InfluxData's plugin repository. There is no service to deploy next to
your database and no data to export.
## What a run does
Take a table of cooling-unit telemetry. Its coolant temperature probe stopped
reporting at 20:00, while motor current, vibration and inlet temperature kept
coming in:
| time | motor\_current | vibration | inlet\_temp | coolant\_temp |
| ----- | -------------- | --------- | ----------- | ------------- |
| 17:00 | 33.43 | 1.387 | 22.88 | 7.41 |
| 18:00 | 32.66 | 1.392 | 22.10 | 6.87 |
| 19:00 | 32.05 | 1.400 | 21.32 | 6.34 |
| 20:00 | 30.07 | 1.309 | 20.60 | |
| 21:00 | 29.96 | 1.325 | 19.99 | |
One run does three things:
1. Reads a window of rows from that table.
2. Takes the rows that **have** `coolant_temp` as its examples, and predicts the
rows that do not, from `motor_current`, `vibration` and `inlet_temp`.
3. Writes the predictions into a **new** table, `cooling_unit_regressed`:
| time | value | model | source | target |
| ----- | ----- | ------------------- | ------------- | ------------- |
| 20:00 | 5.63 | `synthefy/nori-30m` | cooling\_unit | coolant\_temp |
| 21:00 | 5.26 | `synthefy/nori-30m` | cooling\_unit | coolant\_temp |
Your original table is never modified, so a measured value and a predicted one
stay distinguishable. Query and chart `cooling_unit_regressed` like any other
table.
## Before you start
* **InfluxDB 3 Core or Enterprise**, version 3.8.2 or later, started with the
Processing Engine enabled (`influxdb3 serve --plugin-dir /path/to/plugins`).
* HTTPS egress from the InfluxDB host to the Nori gateway.
* Numeric feature columns. Encode categorical columns before calling.
## Set up
Create one from the [API key](/setup/api_key) page if you do not have one
already.
In InfluxDB 3 Explorer, open **Manage Plugins → Plugin Library** and search
for `nori`.
Open it and choose **Install Plugin**. Explorer installs the plugin and its
two Python dependencies for you.
From the CLI instead:
```bash theme={null}
influxdb3 install package influxdata-plugin-utils requests
```
The trigger says which table to read and what to predict. Give it an
**HTTP Endpoint** type so you can run it whenever you want, and add one
argument per row:
| Argument | Value |
| ---------------- | ------------------------------------ |
| `measurement` | `cooling_unit` |
| `field` | `coolant_temp` |
| `feature_fields` | `motor_current vibration inlet_temp` |
| `tags` | `unit:CRAC-07` |
| `model` | `synthefy/nori-30m` |
From the CLI instead:
```bash theme={null}
influxdb3 create trigger \
--database ops \
--path "gh:influxdata/nori_regression/nori_regression.py" \
--trigger-spec "request:nori_regress" \
--trigger-arguments measurement=cooling_unit,field=coolant_temp,feature_fields="motor_current vibration inlet_temp",tags=unit:CRAC-07,model=synthefy/nori-30m \
nori_regress
```
Here `feature_fields` and `tags` take space-separated values, because
`--trigger-arguments` splits pairs on commas.
Pass the key as an `X-Nori-Api-Key` header. It never touches your database.
```bash theme={null}
curl -X POST http://localhost:8181/api/v3/engine/nori_regress \
-H "X-Nori-Api-Key: $NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
```json theme={null}
{"status": "success", "task_id": "...", "result": {"status": "success", "written": 2}}
```
`written` is the number of rows filled, the 20:00 and 21:00 rows in the table
above.
In Explorer, the trigger card has a **Run Now** button that opens a request
builder. Add `X-Nori-Api-Key` under **Headers** and run it there:
Do not use the `Authorization` header. InfluxDB reads that one for its own
request authorization, so it never reaches the plugin.
```sql theme={null}
SELECT time, value, model, target, unit
FROM cooling_unit_regressed
WHERE target = 'coolant_temp'
ORDER BY time
```
Each point carries the tags `model`, `source` and `target`, plus every tag of
the row it was predicted for.
The same run can also take an explicit period, or preview without writing:
```bash Backfill a period theme={null}
curl -X POST http://localhost:8181/api/v3/engine/nori_regress \
-H "X-Nori-Api-Key: $NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"start_time":"2026-01-01T00:00:00Z","end_time":"2026-02-01T00:00:00Z"}'
```
```bash Preview without writing theme={null}
curl -X POST http://localhost:8181/api/v3/engine/nori_regress \
-H "X-Nori-Api-Key: $NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dry_run":true}'
```
## Closing gaps automatically
To keep a column filled without calling anything, give the trigger a schedule
instead of an endpoint. A scheduled run has no caller to carry a header, so the
key has to be on the InfluxDB server, in the environment of the process itself.
Put the key in the InfluxDB server's environment and restart it:
```bash theme={null}
export SYNTHEFY_NORI_API_KEY=""
```
Set it wherever your InfluxDB process gets its environment: `-e` or
`--env-file` for Docker, `environment:` for Compose, `Environment=` in the
systemd unit, or a `secretKeyRef` in Kubernetes. The plugin reads it from the
server, never from trigger arguments or the request body, because both are
written to logs.
Then create the trigger with a frequency rather than an endpoint. The arguments
are the same:
```bash theme={null}
influxdb3 create trigger \
--database ops \
--path "gh:influxdata/nori_regression/nori_regression.py" \
--trigger-spec "every:15m" \
--trigger-arguments measurement=cooling_unit,field=coolant_temp,feature_fields="motor_current vibration inlet_temp",tags=unit:CRAC-07,model=synthefy/nori-30m \
coolant_fill
```
Every 15 minutes it fills any rows missing `coolant_temp` that do not have a
prediction yet, and skips the ones that do, so a repeating schedule does not
pay for the same row twice.
## Parameters
| Parameter | Default | What it does |
| ---------------- | -------- | --------------------------------------------------------------------------------------- |
| `measurement` | required | The table to read. |
| `field` | required | The column to predict. |
| `feature_fields` | required | The columns to predict it from, space-separated. |
| `model` | required | The model slug, for example `synthefy/nori-30m`. See [Models](/nori/quickstart#models). |
| `tags` | none | Filter to one series. Required when the window holds more than one. |
| `window` | `30d` | How far back a run reads, ending at the time it runs. |
| `min_history` | `50` | Rows with a value required before a run will call Nori. |
| `max_train_rows` | `1000` | Cap on the example rows sent per call. |
| `skip_existing` | `true` | Skip rows that already have a prediction. |
The full reference, including retry and batching behaviour, is in the
[plugin documentation](https://github.com/influxdata/influxdb3_plugins/blob/main/influxdata/nori_regression/README.md).
## What a run costs
Each call to the gateway is metered on the same request dimensions as a direct API
call: the example rows sent as context and the number of feature columns, reported
in the `usage` object described in the [Quickstart](/nori/quickstart#api).
Three parameters bound what a schedule spends: `max_train_rows` caps the example
rows per call, `predict_batch_size` caps the number of calls, and `skip_existing`
stops a repeating schedule paying again for rows it has already predicted.
## Good to know
**One series per run.** The plugin counts the distinct tag combinations in the
window and stops before calling Nori if there is more than one, so two series
are never fitted as one. Use `tags` to select the series you want.
**The first call after an idle period is slower.** A model that has scaled to
zero takes from about a minute to several to answer. The plugin's
`request_timeout` defaults to `300s` and retries transient failures.
**A row is predicted when the target is empty and every feature column is
present.** Rows missing a feature are left alone.
## Next steps
Call Nori from Snowflake SQL, with no data export.
Deploy Nori as a SageMaker endpoint in your own AWS account.
The model sizes and the request/response contract.
Call the same models from Python.
# Large Tables
Source: https://docs.synthefy.com/nori/large-data
Serve context tables larger than GPU memory or a bounded row budget, with memory_policy and large_context_policy.
Nori predicts in context, so your table is *input*: every call reads all of
`X_train` and keeps a per-layer key/value cache over those rows. That cache —
not the model itself — is what fills a GPU on a big table. Nori handles it by
walking a ladder of fallbacks, keeping the cache at full precision while it fits
and stepping down only when it must. `memory_policy` lets you decide which steps
it may take, and tells you afterwards which one it used.
`memory_policy` governs *how* the cache holds your context. Above a row-count
threshold, `large_context_policy` governs *which context rows a call actually
uses* — routing across one or more Nori calls instead of
trimming randomly or paying the VRAM cost of the whole table. See
[context routing](#choosing-context-rows-above-a-threshold) below.
The same fields work locally and over the hosted API.
```python Local theme={null}
from synthefy_nori import MemoryPolicy, NoriRegressor
model = NoriRegressor(model="nori-30m", memory_policy=MemoryPolicy(cache_dtype="int8"))
model.fit(X_train, y_train)
predictions = model.predict(X_test)
print(model.memory_report_["rung"]) # which rung actually ran
```
```python Hosted theme={null}
from synthefy import MemoryPolicy, SynthefyNoriClient
client = SynthefyNoriClient(
api_key="YOUR_API_KEY", mode="remote", model="synthefy/nori-30m"
)
predictions = client.predict(
X_train, y_train, X_test,
memory_policy=MemoryPolicy(cache_dtype="int8"),
)
print(client.last_memory_report["rung"]) # which rung actually ran
```
```bash cURL theme={null}
curl -X POST https://inference.baseten.co/predict \
-H "Authorization: Bearer $SYNTHEFY_NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "synthefy/nori-30m",
"X_train": [[0.0, 1.0], [1.0, 0.0]],
"y_train": [0.1, 0.9],
"X_test": [[0.3, 0.7]],
"memory_policy": {"cache_dtype": "int8"}
}'
```
Locally this needs `synthefy-nori` 0.13.0 or newer; see
[Installation](/setup/installation).
## Quickstart
**Omit `memory_policy` entirely and nothing changes** — the defaults are what
almost every call should use. They are: cache at full precision (`bf16`), let it
drop to `int8` only if that is what keeps it on the GPU, spend at most 40% of
VRAM on it, offload to host RAM rather than give up, and shrink the context only
as a last resort. In our tests, changing chunking or batching introduced only
small rounding differences, though predictions were not always bit-for-bit
identical.
Reach for a policy when you want a different trade. A preset covers the common
three:
```python theme={null}
NoriRegressor(model="nori-30m", memory_policy="exact") # disable cache quantization
NoriRegressor(model="nori-30m", memory_policy="max_context") # fit the largest table
NoriRegressor(model="nori-30m", memory_policy="off") # no cache at all
```
### Which lever saves the most memory
Ranked by how much GPU memory each one frees, most first. Figures are from a
400-row × 8-feature context, whose full-precision cache is 0.0122 GiB — yours
scales with rows and features, but the ratios hold.
| Lever | GPU memory | Costs you |
| ---------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `offload_to_host=True` (default) or a small `gpu_budget_*` | drops to **0** — the cache leaves the GPU entirely | latency. Streaming it back per layer measured \~4× slower on this table. Identical to the same resident cached computation. |
| `cache_dtype="int8"` | **\~1.9× smaller** (0.0122 → 0.0065 GiB) | accuracy, but barely: \|ΔR²\| ≈ 6e-6 |
| `context_row_chunk=N` | bounds the **build** spike, not the final cache | speed and possible floating-point rounding differences. This is the one that fixes an out-of-memory error *while* the cache is being created |
| `elements_budget=N` | bounds the per-pass working set | more, smaller passes |
| `cache=False` | **0** — no cache exists | a lot of speed: the context is re-read for every batch |
Note that int8 is \~1.9× and not 4×: the saving is against `bf16`, not `fp32`,
and the per-row scales cost a little back.
### Row chunking
Use `context_row_chunk` when a call dies building the cache rather than holding
it — the projection over all N rows at once is the spike, and this processes N a
slice at a time. It is the lever for "it OOMs on `fit`", where a smaller budget
would not help.
```python theme={null}
NoriRegressor(
model="nori-30m",
memory_policy=MemoryPolicy(context_row_chunk=2048),
)
```
Nori also reaches for this automatically after an out-of-memory retry, so you
rarely have to set it — see the warning in [Good to know](#good-to-know) about
its numerics.
### Capping the budget on a shared GPU
Budgets are fractions of your hardware by default, so one setting travels
between cards. Use the absolute form when a hard ceiling is the actual
requirement, such as a GPU you share with another process:
```python theme={null}
NoriRegressor(
model="nori-30m",
memory_policy=MemoryPolicy(gpu_budget_absolute_gb=8.0),
)
```
### Refusing a shortened context
Shrinking the context is the last resort on the ladder: Nori reaches for it
only when every other fallback still leaves the request too large, and it
never fails outright if there is any way to serve it — see
[Quickstart](#quickstart). Make that an error instead when a silently
shortened context would invalidate your results:
```python theme={null}
NoriRegressor(
model="nori-30m",
memory_policy=MemoryPolicy(allow_subsample=False),
)
```
`allow_subsample=False` raises before any prediction runs, rather than
returning one computed on fewer rows than you asked for. Either way, check
`memory_report_["dropped_context_rows"]`.
## How it works
One `predict` reads the context once and saves a key/value vector per layer, per
feature group, per context row. Query rows then attend to that cache instead of
re-reading the table. The cache is what grows with your data:
```
cache bytes ≈ layers × groups × context_rows × 2 × embed_dim × bytes_per_value
```
Query rows are processed in batches, and the cache is what makes the second
batch cheap. That is also why it is only built when there *is* a second batch —
a query set small enough to fit one pass has nothing to reuse it across, and
reports `no_cache`. That is normal, not a degradation.
When the cache does not fit, Nori takes the cheapest step that works:
| Rung | What happens | Accuracy |
| ------------------- | --------------------------------------------------------- | -------------------------------------------------------------------- |
| `resident_bf16` | full-precision cache in GPU memory | unchunked cached baseline |
| `resident_int8` | quantized so it stays on the GPU | \~1.9× smaller, \|ΔR²\| ≈ 6e-6 |
| `offload_bf16` | full-precision cache in host RAM, streamed back per layer | identical to the same resident cached computation; slower |
| `offload_int8` | quantized *and* in host RAM | as int8 |
| `context_row_chunk` | cache built in row chunks after an out-of-memory retry | see note below |
| `plain_loop` | no cache; the context is re-read for every batch | rounding can differ from cached execution; context may be subsampled |
| `no_cache` | the cached path did not apply | rounding can differ from cached execution |
Only the int8 rungs quantize the cache. Ordinary BF16 offloading moves bytes
unchanged: its reference is the same resident cached computation with the same
context, query chunks, projection chunks, ensemble batching, model, and
numerical backend. The policy can change batching eligibility, so selecting
`offload_bf16` alone does not guarantee identical end-to-end predictions.
Changing chunk sizes, using streaming, or switching between cached and uncached
execution can change floating-point rounding even without quantization.
Subsampling changes the context itself.
The `exact` preset disables cache quantization. It does not guarantee identical
predictions across execution paths or prevent subsampling. Use
`allow_subsample=False` separately when every context row must be retained.
### Reading the report
`memory_report_` locally, `last_memory_report` on the client, `memory_report` in
the raw response. It carries the resolved policy plus what was decided:
| Field | Meaning |
| ---------------------- | ----------------------------------------------------------------------- |
| `rung` | which fallback ran |
| `est_cache_gb` | full-precision cache footprint for this context |
| `resident_gb` | what stayed in GPU memory — `0` if offloaded or uncached |
| `query_chunk` | query rows per forward pass |
| `dropped_context_rows` | context rows discarded to fit — **check this** |
| `clamped` | *hosted only* — fields the server capped rather than honouring verbatim |
| `notes` | *hosted only* — remarks about the policy you sent |
The last two are added by the server when it echoes the report, so they are
present over the API and absent from `memory_report_` locally.
`dropped_context_rows` is the one number worth watching: it is the only accuracy
loss here that is not a rounding-level effect. Set `allow_subsample=False` to
make that case an error instead.
### Reaching the cached path over the API
**Coming soon.** The cache is built only when your query set spans more than
one batch, and today that threshold is more query rows than a single hosted
request can carry — so a hosted call reports `no_cache` however much data you
send. We are raising the hosted request limit; once it lifts, a normal-sized
request reaches the cached path on its own, with nothing extra to set.
## Choosing context rows above a threshold
`memory_policy` decides how the cache holds your context; `large_context_policy`
decides which rows go into it once `X_train` exceeds
`large_context_threshold` (50,000 rows by default). Above that threshold, a
policy routes the request across one or more Nori calls instead
of the cache silently trimming a random subset to fit.
**Off by default** — `large_context_policy=None` keeps behavior byte-for-byte
identical to today. The same built-in policy specs work in local, hosted, and
SageMaker modes:
```python Local theme={null}
from synthefy_nori import NoriRegressor
model = NoriRegressor(model="nori-30m", large_context_policy="cluster_route")
model.fit(X_train, y_train)
predictions = model.predict(X_test)
print(model.large_context_report_["nori_calls"]) # how many calls it made
```
```python Hosted theme={null}
from synthefy import SynthefyNoriClient
client = SynthefyNoriClient(
api_key="YOUR_API_KEY", mode="remote", model="synthefy/nori-30m"
)
predictions = client.predict(
X_train, y_train, X_test,
large_context_policy="cluster_route",
)
print(client.last_large_context_report["nori_calls"])
```
```bash cURL theme={null}
curl -X POST https://inference.baseten.co/predict \
-H "Authorization: Bearer $SYNTHEFY_NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "synthefy/nori-30m",
"X_train": [[0.0, 1.0], [1.0, 0.0]],
"y_train": [0.1, 0.9],
"X_test": [[0.3, 0.7]],
"large_context_policy": "cluster_route"
}'
```
### `cluster_route` — recommended
Clusters the query rows into groups (8 by default) and gives each group its own
local context pool, one Nori call per group. It is the only policy with full
coverage across a broad benchmark sweep — best on nearly half the tables
tested, and it never regressed below a single shared window.
### Choosing between them
Here are some commonly used built-in policies:
| Policy | Nori calls | Use when |
| ------------------ | --------------------- | ------------------------------------------------------------------------------------------ |
| `random` | 1 | the cheapest fallback — one shared context window, no routing |
| `cluster_route` | up to 8 | the default choice for most tables |
| `cluster_route_g4` | up to 4 | cheaper than `cluster_route` on tables it was measured against; not a safe blanket default |
| `safeboost` | one per context shard | opt-in residual boosting with an out-of-fold guard; prefer it to `boost` |
| `boost` | one per stage | only after validating it on your table; measured regressions can be severe |
Hosted and SageMaker support built-ins from the installed Nori version. The
complete current list and configuration options live in
`src/synthefy_nori/inference/policies.py` in the
[Synthefy Nori repository](https://github.com/Synthefy/synthefy-nori). Custom
Python policies are local-only.
### Reading the report
`large_context_report_` locally, `last_large_context_report` on the client,
`large_context_report` in the raw response — present whenever a policy was
requested, even below the threshold, so a deployment silently ignoring the
field is distinguishable from an ordinary small-table request.
| Field | Meaning |
| -------------------- | ---------------------------------------------------------------------- |
| `applied` | whether the table was above the threshold |
| `policy` | the built-in policy string the server resolved and ran |
| `threshold` / `seed` | the bounded controls used for this call |
| `window` | context rows per Nori call |
| `nori_calls` | internal Nori calls this request made |
| `shards_available` | full policy windows the table contains |
| `reused_train_state` | always `false` over the hosted API — see [Good to know](#good-to-know) |
## Configuration
| Preset | Equivalent to | Use when |
| ------------- | -------------------------- | ---------------------------------------------------- |
| `exact` | `allow_quantization=False` | cache quantization must be disabled; offload instead |
| `max_context` | `cache_dtype="int8"` | fit the largest table you can |
| `off` | `cache=False` | isolating a suspected cache problem |
| Field | Default | Meaning |
| -------------------- | -------- | ----------------------------------------------- |
| `cache` | `True` | build a cache at all |
| `cache_dtype` | `"bf16"` | precision the cache starts at |
| `allow_quantization` | `True` | may drop to int8 if bf16 will not stay resident |
Budgets are fractions of your hardware, so one setting travels from a laptop GPU
to an H200. The `*_absolute_gb` overrides exist for a shared GPU, where a hard
ceiling is the requirement rather than a share.
| Field | Default | Meaning |
| ------------------------- | ------- | ------------------------------------------------- |
| `gpu_budget_frac` | `0.4` | share of total VRAM the cache may occupy |
| `gpu_budget_absolute_gb` | `None` | hard VRAM ceiling in GiB, overriding the fraction |
| `offload_to_host` | `True` | may move the cache to host RAM |
| `host_budget_frac` | `0.25` | share of host RAM offload may claim |
| `host_budget_absolute_gb` | `None` | hard host-RAM ceiling in GiB |
Over the hosted API the host-RAM budgets are capped to what the container
survives, and a cap is reported in `clamped` rather than applied silently.
| Field | Default | Meaning |
| ---------------------- | ----------------- | --------------------------------------------------------- |
| `elements_budget` | derived from VRAM | cap on (context + query) rows × features per pass |
| `adaptive_query_chunk` | `True` | halve the query batch and retry on an out-of-memory error |
| `context_row_chunk` | `None` | build the cache in chunks of this many context rows |
| `allow_subsample` | `True` | permit dropping context rows as a last resort |
| Field | Default | Meaning |
| ------------------------- | ------- | --------------------------------------------------------------------------- |
| `large_context_policy` | `None` | a built-in name or parameter string; `None` disables routing |
| `large_context_threshold` | `50000` | context-row count strictly above which the policy engages (1 to 10,000,000) |
| `large_context_seed` | `0` | deterministic policy seed (0 to 2\*\*32 − 1) |
`large_context_cache_entries` (`NoriRegressor` only, default `1`) keeps more
than one policy's context cache resident between calls on the same estimator.
It is not part of the shared client/hosted contract: hosted requests are
one-shot, so there is nothing to retain between them.
## Good to know
**Incoherent settings fail loudly.** Asking for a cache-only field alongside
`cache=False` is rejected rather than ignored, because a policy spelled
correctly that silently does nothing is worse than an error. Settings that work
but probably are not what you meant are honoured and explained in `notes`.
**The report is not a policy.** Feeding a `memory_report` back in as
`memory_policy` is rejected: those are decided outputs, and reusing them as
configuration would skip the coherence checks.
**Row chunking is mathematically equivalent, but not guaranteed
bit-identical.** Changing the projection shape can change floating-point
accumulation order. A single chunk uses the unchunked projection shape; smaller
chunks may return slightly different predictions. Row chunking can also be
reached automatically after an out-of-memory retry. The size of any difference
depends on the model, inputs, precision, and backend; it is not a fixed error
bound.
**Not on Thinking models.** A [Nori Thinking](/nori/quickstart) deployment
rejects both `memory_policy` and `large_context_policy` with a `400`. It
predicts through a test-time-compute wrapper with no separate `fit`/`predict`
step for a policy to attach to, and manages its own inference memory — so a
policy passed in from outside would not reach the run that produces the
answer, and accepting it silently would be worse than refusing it.
**Where a cache cannot help.** Preprocessing (polynomial features and the
adaptive SVD) runs before any of this and has its own memory cost, which
`memory_policy` does not govern. A table that fails there fails regardless of
the policy.
**`large_context_policy` is point-output only.** `output_type="mean"` and
`"median"` are supported; `"quantiles"` and `"full"` fail before inference,
because a routed policy combines point predictions from several Nori calls and
has no single combined predictive distribution.
**Hosted requests fail closed on a stale deployment.** The response always
carries `large_context_report` when a policy was requested, even below the
threshold. If a deployment omits it or reports a different policy, threshold,
or seed, the client raises rather than silently returning an ordinary,
unrouted prediction that looks valid.
**One-shot everywhere.** Every call — local, hosted, or SageMaker — supplies
and fits `X_train` again; there is no hidden upload-once/query-many cache
across requests. `reused_train_state` in the report reflects reuse *within*
one client-owned `NoriRegressor` instance in local mode only, and is always
`false` over the hosted API.
## Next steps
Auth, modes, and the request contract for calling Nori over the API.
Pass NaNs straight through — what the model does with them.
# Missing Values & Imputation
Source: https://docs.synthefy.com/nori/missing-values
How Nori handles NaNs: what happens automatically, and how to impute yourself when you want control.
Real tabular data has holes. Nori accepts them: you can pass `NaN` in your
feature columns, locally or through the hosted API, and get finite predictions
back. No imputation step is required before calling the model.
## Quickstart
```python theme={null}
import numpy as np
from synthefy_nori import NoriRegressor
rng = np.random.default_rng(0)
X_train = rng.normal(size=(60, 4))
y_train = X_train @ np.array([1.0, -2.0, 0.5, 0.0]) + rng.normal(scale=0.1, size=60)
X_train[::7, 1] = np.nan # missing values in the context rows...
X_test = rng.normal(size=(5, 4))
X_test[0, 2] = np.nan # ...and in the query rows
model = NoriRegressor(model="nori-30m").fit(X_train, y_train)
print(model.predict(X_test)) # finite predictions — NaNs handled internally
```
Missing **targets** are different: `y_train` must be numeric and complete. Drop
context rows whose target is missing. A filled-in target is a made-up label, and
it will steer every prediction.
## What happens to a NaN
You don't have to do anything, but it helps to know the defaults:
* **Numeric features** — missing values are imputed inside the model's own
preprocessing (server-side when you call the hosted API). Statistics come from
your context rows, never from the query rows.
* **Categorical features** (DataFrame inputs via the `synthefy` client) — with
the default ordinal encoding, a missing value stays `NaN` and is imputed
server-side alongside the numeric columns; with
`categorical_encoding="onehot"`, missing values get their own indicator column
instead.
* **Columns with no observed values** — a feature that is entirely missing
carries no signal; it is neutralized rather than invented.
## Imputing yourself
Sometimes you want the fill values under your control, to keep them consistent
across models in a comparison, or to encode domain knowledge (for instance, a
missing sensor reading that really means zero). Any imputation works, but the
recipe we use in production and in our own benchmark harness is **per-column
train median, then 0 for columns with no observed values**:
```python theme={null}
import pandas as pd
# df_train / df_test are DataFrames of features
train_medians = df_train.median() # per-column, NaN-skipping
X_train = df_train.fillna(train_medians).fillna(0.0)
X_test = df_test.fillna(train_medians).fillna(0.0) # TRAIN medians on test too
```
Two properties make this the default worth copying:
* **Median, not zero.** On a non-centered feature, zero-filling plants an
often-wild outlier in every incomplete row. The median stays inside the
feature's real range and is robust to skew and outliers, which tabular data
has in abundance.
* **Train statistics only.** The test rows are filled with medians computed from
the *training* rows. Computing fill values from the test set leaks information
about the data you are predicting on and inflates evaluation scores.
If a missing value is *meaningful* in your domain (a blank field on a form, a
sensor that only reports on failure), consider adding a boolean `is_missing`
indicator column before imputing. That keeps the signal while still giving the
model a complete numeric matrix.
## Good to know
* **Benchmarking.** When you compare Nori against other models, apply the
**same** imputation to every model, fit on the training split only. Mixed
preprocessing is the most common source of unreproducible tabular comparisons.
Synthefy's own evaluation loaders follow the recipe above (train-median
imputation with zero fallback), so published numbers match how the model is
preprocessed in production.
* **Meaningful gaps.** If a missing value carries signal in your domain, keep
the `is_missing` indicator from the tip above rather than relying on the fill
value to encode it.
## Next steps
Serve a context table larger than GPU memory, and see which fallback ran.
Call the same model over the hosted API, or locally, from one client.
Model sizes, the hosted API, and the request contract.
# Multi-Target Regression
Source: https://docs.synthefy.com/nori/multi-target-regression
Predict related numeric targets together with joint uncertainty.
Nori predicts several related numeric targets in one fit and one prediction.
Pass a target matrix instead of a vector to get joint means or samples that
preserve dependence across targets.
Fit one `NoriRegressor` on a target matrix and receive every target in the
same output array.
Draw coherent target combinations instead of sampling each marginal in
isolation.
Choose the default copula, a lower-cost independent baseline, or an
autoregressive factorization.
Use the same target-matrix contract with the local estimator or the
lightweight hosted client.
Multi-target regression is available in `synthefy-nori` 0.20.0 and `synthefy`
7.1.0 or later. Follow [Installation](/setup/installation) for the local or
hosted package. Copula support is included in the standard local install.
## Quickstart
Make `Y_train` two-dimensional: one row per context row and one column per
target. Nori uses the recommended `"copula"` strategy by default.
```python theme={null}
import numpy as np
from synthefy_nori import MultiTargetPredictionPolicy, NoriRegressor
X_train = np.array([
[0.0, 0.1],
[0.2, 0.3],
[0.4, 0.4],
[0.6, 0.7],
[0.8, 0.9],
[1.0, 1.1],
])
Y_train = np.column_stack([
2.0 * X_train[:, 0] + X_train[:, 1],
X_train[:, 0] - 3.0 * X_train[:, 1],
])
X_test = np.array([[0.3, 0.4], [0.9, 1.0]])
regressor = NoriRegressor(model="nori-30m").fit(X_train, Y_train)
means = regressor.predict(X_test)
samples = regressor.predict(
X_test,
output_type="samples",
multi_target_prediction_policy=MultiTargetPredictionPolicy(
n_draws=1_000,
random_state=42,
),
)
print(means.shape)
print(samples.shape)
```
```text theme={null}
(2, 2)
(2, 1000, 2)
```
Means have shape `(n_query, n_targets)`. Joint samples have shape
`(n_query, n_draws, n_targets)`, so `samples[i, :, :]` is the joint predictive
distribution for query row `i`.
## How it works
Nori first learns a predictive marginal for each target. The selected strategy
then decides how to join those marginals.
Each column of `Y_train` becomes a Nori regression target, while every
target shares the same feature rows and checkpoint runtime.
Copula estimates dependence from cross-fitted residual ranks. Independent
leaves the marginals separate. Autoregressive conditions later targets on
targets already drawn in a selected order.
Nori maps probability draws through the target marginals to produce
one coherent target vector per draw and query row.
`output_type="samples"` returns every draw. The default `"mean"` output
returns one value per query row and target.
The independent, copula, and autoregressive strategy design was inspired by the
[ScoringBench](https://scoringbench.com/) team and their work on evaluating full
predictive distributions with proper scoring rules.
## Copula — recommended
The default `"copula"` strategy is the product trade-off for most work. It
models cross-target dependence, is invariant to target-column order, and avoids
the draw- and order-scaled query expansion of autoregressive prediction.
## Choosing a strategy
| Strategy | Use it when | Trade-off |
| ------------------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `"copula"` — recommended | You want joint uncertainty without making target order part of the model | Cross-fits residual ranks, so fitting costs more than independent prediction |
| `"independent"` | You need the lowest-cost baseline or do not need cross-target dependence | Joint draws are a product of marginals and do not preserve learned dependence |
| `"autoregressive"` | Order-sensitive conditional structure is worth additional inference cost | Work scales with draws and target orders; results can depend on the factorization |
Autoregressive led the release benchmark's small-draw joint-accuracy screen,
but copula remains the default because it is order-invariant and more
predictable in cost.
For reproducible autoregressive work, provide complete target-index
permutations. Orders define a predictive factorization; they do not claim that
one target causes another.
```python theme={null}
import numpy as np
from synthefy_nori import MultiTargetPredictionPolicy, NoriRegressor
X_train = np.array([
[0.0, 0.1], [0.2, 0.3], [0.4, 0.4],
[0.6, 0.7], [0.8, 0.9], [1.0, 1.1],
])
Y_train = np.column_stack([
2.0 * X_train[:, 0] + X_train[:, 1],
X_train[:, 0] - 3.0 * X_train[:, 1],
])
X_test = np.array([[0.3, 0.4], [0.9, 1.0]])
policy = MultiTargetPredictionPolicy(
autoregressive_orders=[[0, 1], [1, 0]],
n_draws=300,
random_state=42,
)
regressor = NoriRegressor(
model="nori-30m",
multi_target_prediction_strategy="autoregressive",
multi_target_prediction_policy=policy,
).fit(X_train, Y_train)
samples = regressor.predict(X_test, output_type="samples")
print(regressor.target_orders_)
```
Omit `autoregressive_orders` to generate deterministic unique permutations.
Nori caps the requested count at the number of possible unique permutations
instead of repeating an order.
## Hosted prediction
The lightweight client sends the target matrix in one hosted request. It
returns nested lists by default, matching the arrays in the raw HTTP response.
Create a key by following the [API key guide](/setup/api_key).
```python Python theme={null}
import os
import numpy as np
from synthefy import MultiTargetPredictionPolicy, SynthefyNoriClient
X_train = [
[0.0, 0.1], [0.2, 0.3], [0.4, 0.4],
[0.6, 0.7], [0.8, 0.9], [1.0, 1.1],
]
Y_train = [
[0.1, -0.3], [0.7, -0.7], [1.2, -0.8],
[1.9, -1.5], [2.5, -1.9], [3.1, -2.3],
]
X_test = [[0.3, 0.4], [0.9, 1.0]]
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="synthefy/nori-30m",
)
samples = client.predict(
X_train,
Y_train,
X_test,
output_type="samples",
multi_target_prediction_strategy="copula",
multi_target_prediction_policy=MultiTargetPredictionPolicy(
n_draws=300,
random_state=42,
),
)
print(np.asarray(samples).shape)
```
```bash cURL theme={null}
curl -X POST https://inference.baseten.co/predict \
-H "Authorization: Bearer $SYNTHEFY_NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "synthefy/nori-30m",
"task": "regression",
"X_train": [[0.0, 0.1], [0.2, 0.3], [0.4, 0.4], [0.6, 0.7], [0.8, 0.9], [1.0, 1.1]],
"y_train": [[0.1, -0.3], [0.7, -0.7], [1.2, -0.8], [1.9, -1.5], [2.5, -1.9], [3.1, -2.3]],
"X_test": [[0.3, 0.4], [0.9, 1.0]],
"output_type": "samples",
"multi_target_prediction_strategy": "copula",
"multi_target_prediction_policy": {
"n_draws": 300,
"random_state": 42
}
}'
```
Hosted responses echo the strategy that ran. The client checks that handshake
and fails instead of returning plausible output from a deployment that ignored
the requested multi-target controls.
## Configuration
| Parameter | Default | Description |
| ---------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `multi_target_prediction_strategy` | `"copula"` | `"copula"`, `"independent"`, or `"autoregressive"`. It is inert when `y` is one-dimensional. |
| `n_draws` | `300` | Joint Monte Carlo draws. It controls sample output and autoregressive means; independent and copula means use marginal point predictions. |
| `random_state` | `0` | Seed for reproducible copula and autoregressive sampling. Use `None` for a fresh seed. |
| `copula_cv` | `5` | Cross-fitting folds used to estimate copula residual ranks. It cannot exceed the number of training rows. |
| `copula_pit_jitter` | `1e-4` | Uniform jitter applied to tied copula probability transforms. |
| `autoregressive_n_orders` | `3` | Number of automatically generated unique target permutations. |
| `autoregressive_orders` | `None` | Explicit complete target-index permutations. Do not combine it with an explicitly set `autoregressive_n_orders`. |
Pass `MultiTargetPredictionPolicy` at construction for fit-dependent
controls. At prediction time, you can override `n_draws` and
`random_state`; changing copula or target-order controls requires refitting.
Hosted inference applies tighter work bounds and validates them before it
sends a request.
| Output | Shape | Description |
| ----------------------- | ------------------------------- | ------------------------------------------------------------- |
| `output_type="mean"` | `(n_query, n_targets)` | One prediction per query row and target. This is the default. |
| `output_type="samples"` | `(n_query, n_draws, n_targets)` | Joint draws that preserve the selected dependence structure. |
Marginal `"median"`, `"quantiles"`, and `"full"` output are not part of
the first multi-target release. Use joint samples when you need uncertainty.
## Good to know
* A two-dimensional target with at least two columns activates multi-target
regression. One-dimensional targets keep their existing behavior.
* `discretize`, `categorical_levels`, and large-context policies do not compose
with multi-target regression in this release.
* The hosted client records resolved autoregressive orders in
`client.last_target_orders`. The local estimator exposes the same information
as `regressor.target_orders_`.
* Hosted joint samples are three-dimensional nested lists. Convert them with
`np.asarray(samples)` when you want NumPy indexing; `as_pandas=True` is not
supported for three-dimensional samples.
* A memory policy applies to each internal marginal or chain call. Hosted calls
expose the resulting list as `client.last_multi_target_memory_reports`.
## Next steps
Configure hosted, SageMaker, or local execution through one client.
Start with a complete single-target regression workflow.
Predict onto a discrete numeric lattice when the target is ordinal.
# Quickstart
Source: https://docs.synthefy.com/nori/quickstart
Get predictions on tabular data, locally or through the hosted API.
Nori predicts continuous values from tabular data. You give it some labeled rows
as examples, and it predicts on new rows. No training, no fine-tuning required.
[GitHub](https://github.com/Synthefy/synthefy-nori) · 🤗 [Hugging Face](https://huggingface.co/Synthefy/Nori)
There are two ways to use it:
Install the Python package and run inference on your own machine or server.
Send a request to our hosted endpoint. No setup, no GPU required.
***
## Local
Install the open-source package:
```bash theme={null}
pip install synthefy-nori
```
Model weights download automatically from [Hugging
Face](https://huggingface.co/Synthefy/Nori) on first use, and no API key is
needed. It uses a GPU when one is available and falls back to CPU. Always name a
size — `"nori-100m"`, `"nori-30m"`, or `"nori-6m"`. Omitting `model=` raises
instead of silently selecting a variant (see [Models](#models)).
### Your first prediction
`NoriRegressor` is a scikit-learn–style estimator: `fit` stores your labeled
rows as context (there is no training step) and `predict` returns one value per
query row in a single forward pass.
```python theme={null}
from synthefy_nori import NoriRegressor
X_train = [[0.0, 1.0], [1.0, 0.0], [0.5, 0.5], [0.2, 0.8]]
y_train = [0.1, 0.9, 0.5, 0.3]
X_test = [[0.3, 0.7], [0.8, 0.2]]
model = NoriRegressor(model="nori-30m")
model.fit(X_train, y_train)
print(model.predict(X_test))
```
```
[0.37, 0.72]
```
Want one code path for local and hosted execution? Use
`SynthefyNoriClient` and choose `mode="local"` or `mode="remote"`
deliberately. It never switches backends because a package or credential
happens to be present. See the [Python Client guide](/nori/client).
### With a DataFrame
```python theme={null}
import pandas as pd
from synthefy_nori import NoriRegressor
df = pd.read_csv("data.csv")
target_col = "price"
feature_cols = [c for c in df.columns if c != target_col]
train = df.sample(frac=0.8, random_state=42)
test = df.drop(train.index)
model = NoriRegressor(model="nori-30m").fit(train[feature_cols].values, train[target_col].values)
predictions = model.predict(test[feature_cols].values)
```
### Prediction intervals
Nori returns a full predictive distribution, so you get uncertainty for free.
Pass `output_type="quantiles"`:
```python theme={null}
q10, q50, q90 = model.predict(X_test, output_type="quantiles", quantiles=[0.1, 0.5, 0.9])
```
### Handle missing values
You don't need to fill in missing values beforehand. The model handles them
natively.
```python theme={null}
import numpy as np
from synthefy_nori import NoriRegressor
X_train = [[0.0, 1.0], [1.0, np.nan], [0.5, 0.5], [np.nan, 0.8]]
y_train = [0.1, 0.9, 0.5, 0.3]
X_test = [[0.3, 0.7], [0.8, np.nan]]
model = NoriRegressor(model="nori-30m").fit(X_train, y_train)
print(model.predict(X_test))
```
```
[0.38, 0.71]
```
***
## API
The hosted API runs the same model on our infrastructure, so no installation or
GPU is required. Set up your API key in the [API key
guide](/setup/api_key#api-key-for-synthefy-nori).
From Python, use the [`synthefy` client](/nori/client). The same call works in
either place: `mode="remote"` sends your rows to the
hosted endpoint, while `mode="local"` runs the model on your machine. The
client never selects a mode automatically.
### Make a request
Send your labeled rows (`X_train`, `y_train`) and the rows you want to predict
(`X_test`) in a single call:
```python Python theme={null}
# pip install synthefy — see /nori/client for the full client guide
import os
from synthefy import SynthefyNoriClient
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="synthefy/nori-30m",
)
X_train = [[0.0, 1.0], [1.0, 0.0], [0.5, 0.5], [0.2, 0.8]]
y_train = [0.1, 0.9, 0.5, 0.3]
X_test = [[0.3, 0.7], [0.8, 0.2]]
print(client.predict(X_train, y_train, X_test))
```
```bash cURL theme={null}
curl -X POST https://inference.baseten.co/predict \
-H "Authorization: Bearer $SYNTHEFY_NORI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "synthefy/nori-30m",
"task": "regression",
"X_train": [[0.0, 1.0], [1.0, 0.0], [0.5, 0.5], [0.2, 0.8]],
"y_train": [0.1, 0.9, 0.5, 0.3],
"X_test": [[0.3, 0.7], [0.8, 0.2]]
}'
```
The cURL example posts to the generic, slug-routed inference endpoint and
authenticates with your Synthefy Nori API key (`Bearer` scheme).
**Request body**
| Field | Required | Description |
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` | Required | The model slug: `synthefy/nori-6m` (\~6M base), `synthefy/nori-30m` (\~29.2M), or `synthefy/nori-30m-thinking-medium`. Routes the request to the model; omitting it returns a `400`. See [Models](#models). |
| `task` | Optional | `"regression"` or `"reg"`; defaults to `"regression"`. This is a regression-only deployment. |
| `X_train` | Required | Context feature matrix, `n_context × n_features` (array of arrays of numbers). |
| `y_train` | Required | Context targets, aligned 1:1 with `X_train` rows (array of numbers). |
| `X_test` | Required | Query feature matrix, `n_query × n_features` (array of arrays of numbers). |
`null`/`NaN` cells are allowed in `X_train` and `X_test` and are imputed
server-side.
### Request size limit
A single request is capped at **64 MiB** (about 67 MB). Larger requests are
rejected with `HTTP 413 Request Entity Too Large`, before the model sees them.
The limit applies to the whole request, i.e. all of `X_train`, `y_train`, and
`X_test` together. Since size grows with rows × features, you only approach it
with very large inputs (on the order of millions of values).
If you hit the limit, send fewer example rows, use fewer features, or split
`X_test` into smaller batches. You can reuse the same `X_train`/`y_train` across
those calls.
### Response
The client returns one value per row in `X_test` as a plain list:
```python theme={null}
[0.37, 0.72]
```
The underlying HTTP endpoint (used by the cURL example) returns the full
envelope on a `200` (values illustrative):
```json theme={null}
{
"task": "regression",
"predictions": [0.3861955995072203, 0.7150392187985302],
"usage": {
"prompt_tokens": 4,
"completion_tokens": 2,
"total_tokens": 6
}
}
```
Read `predictions`, one value per `X_test` row, in order. The response also
includes the request dimensions used for metering and billing inside an
OpenAI-compatible `usage` object: `prompt_tokens` is the number of context rows,
and `completion_tokens` is the number of feature columns. The Python client
returns only the `predictions` list.
The first request after a scale-to-zero idle starts a replica, loads the baked
checkpoint, and warms the model; it can take \~60–90 seconds. Warm requests
return in \~1–2 seconds.
***
## Models
Select a model with the `model` parameter. Nori, Nori-30M and Nori-100M run
**locally or on the hosted API**, and their names work in both the Python client
(`SynthefyNoriClient`) and the `synthefy-nori` package (`NoriRegressor`).
**Nori Thinking is hosted-API only**.
| Model | Params | `model=` name | Hosted-API id | Availability |
| ------------- | ------- | ---------------------------------------------------- | ----------------------------------- | ------------ |
| Nori (base) | \~6M | `"nori-6m"` | `synthefy/nori-6m` | Local + API |
| Nori-30M | \~29.2M | `"nori-30m"` | `synthefy/nori-30m` | Local + API |
| Nori-100M | \~98.3M | `"nori-100m"` | `synthefy/nori-100m` | Local + API |
| Nori Thinking | \~29.2M | `"nori-30m-thinking"` / `"nori-30m-thinking-medium"` | `synthefy/nori-30m-thinking-medium` | **API only** |
* **`model=` name** — what you pass to `SynthefyNoriClient(model=…)` or
`NoriRegressor(model=…)`.
* **Hosted-API id** — the value in the request body's `model` field on the raw
HTTP endpoint (what the cURL example sends). The client fills it in for you
from the friendly name; local inference doesn't use it.
* **Availability** — *Local + API* runs on your machine (weights download from
Hugging Face on first use) or on the hosted endpoint. *API only* runs on the
hosted endpoint only.
**Nori-100M is the largest Nori.** Its checkpoint is
[`Synthefy/Nori-100M`](https://huggingface.co/Synthefy/Nori-100M) (42 layers,
`embed_dim` 352, 8 heads). Run it locally with
`NoriRegressor(model="nori-100m")`, which pulls the weights from Hugging Face on
first use, or on the hosted API with `model="nori-100m"`. It carries \~3× the
parameters of Nori-30M, so expect a larger download and more GPU memory per
call — see [Large Tables](/nori/large-data) if you run out.
**Nori Thinking is hosted-API only.** It spends extra test-time compute to lift
accuracy, is slower and premium-priced, and has no downloadable checkpoint. Only
the **medium** budget is available today; both `"nori-30m-thinking"` and
`"nori-30m-thinking-medium"` route to it. Selecting Thinking for local inference
(`SynthefyNoriClient(mode="local", …)` or
`NoriRegressor(model="nori-30m-thinking-medium")`) raises an error pointing you
at the hosted API. Use `mode="remote"`.
The current, authoritative list of models your key can call (and their limits)
is returned live by `GET /forecasting-api/nori/available-models`, and shown per
model in the console.
```python theme={null}
# Hosted API — the friendly name selects the deployment
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="nori-30m-thinking-medium", # Thinking: hosted-API only
)
# Local / package — the selector picks which checkpoint to download
from synthefy_nori import NoriRegressor
model = NoriRegressor(model="nori-100m") # 6m/30m/100m run locally; Thinking does not
```
Always name a size: `"nori-6m"`, `"nori-30m"`, or — for local inference —
`"nori-100m"`. Both the `synthefy` client and `NoriRegressor` **require**
`model=` and raise a `ValueError` if you omit it — there is no default, so no
request silently runs on a size you did not choose. In **local** mode the
selector picks the checkpoint (downloaded from Hugging Face on first use). The
raw Hosted-API slug (e.g. `synthefy/nori-30m`) is also accepted by the client
and is what the cURL endpoint expects in the `model` field.
***
## Next steps
Auth, retries, DataFrames and text columns, handled for you.
End-to-end worked examples on real datasets.
Pass NaNs straight through, with no pre-filling required.
SHAP values, feature interactions, and partial dependence.
Deploy on Amazon SageMaker, or call Nori from Snowflake SQL.
Learn more about Synthefy Nori (Tabular).
# Amazon SageMaker
Source: https://docs.synthefy.com/nori/sagemaker
Subscribe to Nori in AWS Marketplace, deploy one GPU endpoint, and call all three models with the Synthefy client.
Deploy Nori in your AWS account from one AWS Marketplace product. One endpoint
serves all three models; select the model in each client request.
## Included models
| `model` | Weights | Outputs | Memory policy |
| -------------------------- | --------------------------------- | ------------------------------------------ | ------------- |
| `nori-6m` | 6M | mean, median, quantiles, full distribution | supported |
| `nori-30m` | 30M | mean, median, quantiles, full distribution | supported |
| `nori-30m-thinking-medium` | 30M with a medium thinking budget | mean | not supported |
There is no primary or default model. Keep the same endpoint and change
`model=` to select another model.
## Before you deploy
* Your AWS account must be allowed to subscribe to Marketplace products.
* Request SageMaker real-time endpoint quota for `ml.g5.xlarge` in the region
where you will deploy Nori.
* Use an AWS role or profile that can create SageMaker models and endpoints.
## Deploy from AWS Marketplace
Open the Synthefy Nori listing in AWS Marketplace and choose **View purchase
options**.
Review the pricing and terms, then choose **Subscribe**.
**Option 1 — AWS Marketplace:** after subscribing, choose **Launch your
software**.
**Option 2 — SageMaker console:** open **Inference → Marketplace model
packages**, select the subscribed Synthefy Nori package version, choose
**Create model**, and select a SageMaker execution role.
From the Marketplace launch page, select **Amazon SageMaker AI console**,
choose your region and **Create real-time inference endpoint**, then choose
**Create endpoint**.
If you used option two, create the endpoint from the SageMaker model. Choose
`ml.g5.xlarge`, set the initial instance count, and wait until the endpoint
status is `InService`.
## Invoke the endpoint
Install the Synthefy client with AWS support from the
[installation guide](/setup/installation).
Use the endpoint name from step three:
```python nori_sagemaker.py theme={null}
from synthefy import SynthefyNoriClient
X_train = [[0.0, 1.0], [1.0, 0.0], [0.5, 0.5], [0.2, 0.8]]
y_train = [0.1, 0.9, 0.5, 0.3]
X_test = [[0.3, 0.7], [0.8, 0.2]]
with SynthefyNoriClient(
mode="sagemaker",
model="nori-30m",
endpoint_name="your-nori-endpoint",
region_name="us-east-1",
) as client:
predictions = client.predict(X_train, y_train, X_test)
print(predictions)
```
The client uses your configured AWS role or profile. To run another included
model, change `model=` and keep the same endpoint name.
For DataFrames, categorical encoding, output types, and retries, see the
[Python Client guide](/nori/client).
Requests are limited to 25 MB.
## Next steps
DataFrames, categorical encoding, retries, and distribution outputs.
Learn the in-context regression request and model sizes.
# Snowflake
Source: https://docs.synthefy.com/nori/snowflake
Run Nori on your Snowflake data directly from SQL, with no data export and no training.
Call Nori from Snowflake SQL to predict numeric targets on your tables. Nori is
**in-context**: you pass some labeled rows as examples and the rows you want
scored, and it returns one prediction per row in a single forward pass, with no
training or fine-tuning.
A SQL function that calls the hosted Nori endpoint. Fastest to set up, and
works today.
Runs Nori inside your Snowflake account via Snowpark Container Services.
Your data never leaves the account. Coming to Snowflake Marketplace.
***
## Hosted connector
A Snowflake external-access function calls the hosted Nori endpoint. The model
runs on Synthefy's cloud; Snowflake sends the rows and gets predictions back.
The rows you pass are sent to the hosted endpoint for scoring. If your
workload requires data to stay inside your Snowflake account, use the [Native
App](#native-app-in-account) instead.
**Prerequisites**
Needed to create the external access integration (`CREATE INTEGRATION`).
See [API key](/setup/api_key).
Any warehouse, to run the function.
### Set up (once)
Run each step in a Snowsight worksheet as `ACCOUNTADMIN`.
```sql Namespace theme={null}
CREATE DATABASE IF NOT EXISTS NORI;
CREATE SCHEMA IF NOT EXISTS NORI.PUBLIC;
```
Replace `` with your key (highlighted line).
```sql Network rule + secret {6} theme={null}
CREATE OR REPLACE NETWORK RULE NORI.PUBLIC.nori_api_network_rule
MODE = EGRESS TYPE = HOST_PORT VALUE_LIST = ('inference.baseten.co:443');
CREATE OR REPLACE SECRET NORI.PUBLIC.nori_api_key
TYPE = GENERIC_STRING
SECRET_STRING = '';
```
```sql Integration theme={null}
CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION nori_api_integration
ALLOWED_NETWORK_RULES = (NORI.PUBLIC.nori_api_network_rule)
ALLOWED_AUTHENTICATION_SECRETS = (NORI.PUBLIC.nori_api_key)
ENABLED = TRUE;
```
Uses only the Python standard library, so there is nothing to install.
```sql nori_predict theme={null}
CREATE OR REPLACE FUNCTION NORI.PUBLIC.nori_predict(x_train ARRAY, y_train ARRAY, x_test ARRAY)
RETURNS ARRAY
LANGUAGE PYTHON
RUNTIME_VERSION = '3.11'
HANDLER = 'predict'
EXTERNAL_ACCESS_INTEGRATIONS = (nori_api_integration)
SECRETS = ('api_key' = NORI.PUBLIC.nori_api_key)
AS
$$
import json, _snowflake, urllib.request
def predict(x_train, y_train, x_test):
api_key = _snowflake.get_generic_secret_string("api_key")
body = json.dumps({
"model": "synthefy/nori-30m", "task": "regression",
"X_train": x_train, "y_train": y_train, "X_test": x_test,
}).encode()
req = urllib.request.Request(
"https://inference.baseten.co/predict", data=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=300) as r:
return json.loads(r.read().decode()).get("predictions")
$$;
```
### Predict
Returns two numbers, confirming the connector works end to end.
```sql Smoke test theme={null}
SELECT NORI.PUBLIC.nori_predict(
[[0.0,1.0],[1.0,0.0],[0.5,0.5],[0.2,0.8]]::ARRAY, -- X_train
[0.1,0.9,0.5,0.3]::ARRAY, -- y_train
[[0.3,0.7],[0.8,0.2]]::ARRAY -- X_test
);
-- [ 0.37, 0.72 ]
```
Rows with a `NULL` target are scored from the rows where it's set. Aggregate
`x_train` and `y_train` with the **same** `ORDER BY` so features and targets
stay aligned.
Set a current schema first (`USE SCHEMA .;`) or fully-qualify your table names, or you'll hit "does not have a current schema".
```sql Table prediction theme={null}
WITH ctx AS ( -- labeled rows
SELECT ARRAY_AGG(ARRAY_CONSTRUCT(feat1, feat2, feat3)) WITHIN GROUP (ORDER BY id) AS x_train,
ARRAY_AGG(target) WITHIN GROUP (ORDER BY id) AS y_train
FROM my_table WHERE target IS NOT NULL
),
qry AS ( -- rows to score
SELECT ARRAY_AGG(ARRAY_CONSTRUCT(feat1, feat2, feat3)) WITHIN GROUP (ORDER BY id) AS x_test,
ARRAY_AGG(id) WITHIN GROUP (ORDER BY id) AS ids
FROM my_table WHERE target IS NULL
),
res AS (
SELECT NORI.PUBLIC.nori_predict(ctx.x_train, ctx.y_train, qry.x_test) AS preds, qry.ids AS ids
FROM ctx, qry
)
SELECT res.ids[f.index]::NUMBER AS id, f.value::FLOAT AS prediction
FROM res, LATERAL FLATTEN(input => res.preds) f
ORDER BY id;
```
```sql setup.sql theme={null}
CREATE DATABASE IF NOT EXISTS NORI;
CREATE SCHEMA IF NOT EXISTS NORI.PUBLIC;
CREATE OR REPLACE NETWORK RULE NORI.PUBLIC.nori_api_network_rule
MODE = EGRESS TYPE = HOST_PORT VALUE_LIST = ('inference.baseten.co:443');
CREATE OR REPLACE SECRET NORI.PUBLIC.nori_api_key
TYPE = GENERIC_STRING SECRET_STRING = '';
CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION nori_api_integration
ALLOWED_NETWORK_RULES = (NORI.PUBLIC.nori_api_network_rule)
ALLOWED_AUTHENTICATION_SECRETS = (NORI.PUBLIC.nori_api_key)
ENABLED = TRUE;
CREATE OR REPLACE FUNCTION NORI.PUBLIC.nori_predict(x_train ARRAY, y_train ARRAY, x_test ARRAY)
RETURNS ARRAY
LANGUAGE PYTHON
RUNTIME_VERSION = '3.11'
HANDLER = 'predict'
EXTERNAL_ACCESS_INTEGRATIONS = (nori_api_integration)
SECRETS = ('api_key' = NORI.PUBLIC.nori_api_key)
AS
$$
import json, _snowflake, urllib.request
def predict(x_train, y_train, x_test):
api_key = _snowflake.get_generic_secret_string("api_key")
body = json.dumps({
"model": "synthefy/nori-30m", "task": "regression",
"X_train": x_train, "y_train": y_train, "X_test": x_test,
}).encode()
req = urllib.request.Request(
"https://inference.baseten.co/predict", data=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req, timeout=300) as r:
return json.loads(r.read().decode()).get("predictions")
$$;
```
***
## Native App (in-account)
For workloads that require data to **stay inside your Snowflake account**, Nori
is also available as a **Snowflake Native App** that runs the model in-account
using **Snowpark Container Services**, so nothing is sent to Synthefy.
The in-account Native App is rolling out via the Snowflake Marketplace.
Contact [support@synthefy.com](mailto:support@synthefy.com) for early access.
### Start the app (once, after install)
Grant the privileges the app requested, then start its service:
```sql Start theme={null}
GRANT CREATE COMPUTE POOL ON ACCOUNT TO APPLICATION nori_app;
GRANT BIND SERVICE ENDPOINT ON ACCOUNT TO APPLICATION nori_app;
CALL nori_app.core.start_service();
```
Wait until the service is READY (the first start pulls the image and boots the
model):
```sql Status theme={null}
SELECT SYSTEM$GET_SERVICE_STATUS('nori_app.svc.nori_service');
```
### Predict
Same signature as the hosted connector, but everything runs in your account:
```sql Smoke test theme={null}
SELECT nori_app.svc.nori_predict_svc(
[[0.0,1.0],[1.0,0.0],[0.5,0.5],[0.2,0.8]]::ARRAY,
[0.1,0.9,0.5,0.3]::ARRAY,
[[0.3,0.7],[0.8,0.2]]::ARRAY
);
-- [ 0.37, 0.72 ]
```
Fully qualified, so it runs without setting a current schema.
```sql Demo theme={null}
CREATE OR REPLACE TABLE NORI.PUBLIC.demo (id INT, feat1 FLOAT, feat2 FLOAT, target FLOAT);
INSERT INTO NORI.PUBLIC.demo VALUES
(1, 0.0, 1.0, 0.1), (2, 1.0, 0.0, 0.9), (3, 0.5, 0.5, 0.5),
(4, 0.2, 0.8, 0.3), (5, 0.3, 0.7, NULL), (6, 0.8, 0.2, NULL);
WITH ctx AS (
SELECT ARRAY_AGG(ARRAY_CONSTRUCT(feat1, feat2)) WITHIN GROUP (ORDER BY id) AS x_train,
ARRAY_AGG(target) WITHIN GROUP (ORDER BY id) AS y_train
FROM NORI.PUBLIC.demo WHERE target IS NOT NULL
),
qry AS (
SELECT ARRAY_AGG(ARRAY_CONSTRUCT(feat1, feat2)) WITHIN GROUP (ORDER BY id) AS x_test,
ARRAY_AGG(id) WITHIN GROUP (ORDER BY id) AS ids
FROM NORI.PUBLIC.demo WHERE target IS NULL
),
res AS (
SELECT nori_app.svc.nori_predict_svc(ctx.x_train, ctx.y_train, qry.x_test) AS preds, qry.ids AS ids
FROM ctx, qry
)
SELECT ids[f.index]::NUMBER AS id, f.value::FLOAT AS prediction
FROM res, LATERAL FLATTEN(input => res.preds) f
ORDER BY id;
-- id 5 -> ~0.37, id 6 -> ~0.72
```
## Good to know
**Numeric features only.** Encode categorical columns (one-hot or ordinal)
before calling.
**Cold start (hosted connector).** The first call after an idle period is slow
while the model warms up; subsequent calls are fast. See the timings in the
[Nori Quickstart](/nori/quickstart#api).
**Size & alignment.** Snowflake `ARRAY` values cap at 16 MB, so sample the
labeled rows for very large context tables. Always aggregate `x_train` and
`y_train` with the same `ORDER BY`.
## Next steps
Deploy Nori as a SageMaker endpoint in your own AWS account.
The model sizes and the request/response contract.
# Text Features
Source: https://docs.synthefy.com/nori/text-features
Hand Nori free-text columns. It embeds them, reduces them to a few numeric columns, and predicts on the widened table. Zero-shot, no training.
Nori reads numeric tables, but real tables often carry free text too: a product
title, a review, a job description. Point Nori at those columns and it turns
them into a handful of extra numeric features the frozen model consumes like any
other column. Nothing is trained: the sentence encoder and Nori both stay
frozen, so the gain comes purely from making text legible at inference time.
Columns your numeric features can't see, like sentiment, topic and phrasing,
become predictive features.
No fine-tuning, no gradients. A frozen sentence encoder plus an unsupervised
SVD; Nori's weights never move.
It's still a `NoriRegressor`. Name the text columns in the constructor and
call `fit` / `predict` on your DataFrame.
Round-trips through `clone` / `GridSearchCV` / `cross_val_score` and
`pickle`, just like the numeric path.
Text support needs the `text` extra, which pulls in `sentence-transformers`. It
is covered by the recommended line in [Installation](/setup/installation).
The import is lazy, so numeric-only use never requires it. Extraction runs on
the local `synthefy-nori` package; the sentence-encoder weights download from
Hugging Face on first use.
## Quickstart
Name the text columns in the constructor and use the estimator exactly as you
would for a numeric table. `X` is a `pandas.DataFrame` that holds numeric,
categorical, **and** text columns together.
```python theme={null}
import pandas as pd
from synthefy_nori import NoriRegressor
# df_train / df_test are DataFrames with numeric, categorical, and text columns
reg = NoriRegressor(model="nori-30m", text_columns=["title", "review"], svd_dim=128)
reg.fit(df_train, y_train)
preds = reg.predict(df_test) # embeds the new text, applies the fit SVD
```
That's the whole API surface: everything else (embedding, SVD, column encoding)
happens inside `fit` / `predict`. Numeric and categorical columns you don't list
as text are handled automatically: numeric passes through, categorical is
label-encoded.
## How it works
The text path is a preprocessing layer in front of the ordinary numeric model.
Nori itself is unchanged.
Each row's text columns are concatenated into a single column-prefixed
string, e.g. `"title: Wireless Mouse. review: works great, tiny lag."`.
A sentence-transformer (MiniLM by default) turns each paragraph into a dense
vector. The encoder is frozen, with no fine-tuning.
The embeddings are reduced to `svd_dim` columns (default 128) with a
TruncatedSVD **fit on the training rows only**. Unsupervised, it never sees
`y`.
The SVD columns are appended to the numeric/categorical block, and the
frozen Nori predicts on the widened table. `predict` re-embeds the query
rows and applies the already-fit SVD, so the train/test transform is
symmetric.
```text theme={null}
text columns
→ one column-prefixed paragraph per row
→ frozen sentence embedding (e.g. MiniLM, 384-d)
→ TruncatedSVD → svd_dim columns (fit on train, unsupervised)
→ appended to the numeric / categorical block
→ NoriRegressor (frozen, in-context) predicts
```
## Does it actually help? A runnable check
[`examples/text_features_synthetic.py`](https://github.com/Synthefy/synthefy-nori/blob/main/examples/text_features_synthetic.py)
builds a small synthetic dataset where the target depends on numeric columns
(`x1`, `x2`), a categorical column (`brand`), **and** the sentiment word buried
in a free-text `review` — signal the numeric columns can't reach. It then fits
Nori twice, with and without the text column, on a held-out split.
```bash theme={null}
python examples/text_features_synthetic.py
```
```text theme={null}
Nori tabular-only (x1, x2, brand) R2 = 0.1530
Nori + text review (svd-64) R2 = 0.9854
text lift +0.8324
```
The review carries most of the target, so holding it out caps the tabular model
at R² ≈ 0.15, while embedding it recovers R² ≈ 0.99. The core of the example:
```python theme={null}
import numpy as np, pandas as pd
from synthefy_nori import NoriRegressor
_SENTIMENTS = [("terrible", -2.0), ("poor", -1.0), ("okay", 0.0),
("good", 1.0), ("excellent", 2.0)]
_BRANDS = {"acme": 0.5, "globex": -0.5, "initech": 0.0}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
x1, x2 = rng.normal(size=n), rng.normal(size=n)
brand = rng.choice(list(_BRANDS), size=n)
idx = rng.integers(0, len(_SENTIMENTS), size=n)
words = [_SENTIMENTS[i][0] for i in idx]
sval = np.array([_SENTIMENTS[i][1] for i in idx])
review = [f"Customer review: the item was {w}." for w in words]
# target depends on numerics, brand, AND the review's sentiment word
y = 2*x1 - 1.5*x2 + np.array([_BRANDS[b] for b in brand]) + 3*sval + rng.normal(0, 0.5, n)
return pd.DataFrame({"x1": x1, "x2": x2, "brand": brand, "review": review}), y
df_tr, y_tr = make_dataset(800, 0)
df_te, y_te = make_dataset(200, 1)
# tabular-only: text_columns=[] keeps numeric + categorical, no embedder
tab = NoriRegressor(model="nori-30m", text_columns=[]).fit(df_tr[["x1", "x2", "brand"]], y_tr)
# + text: embed the review, reduce, append
text = NoriRegressor(model="nori-30m", text_columns=["review"], svd_dim=64).fit(df_tr, y_tr)
```
`text_columns=[]` is a valid fit on a DataFrame that keeps only the numeric and
categorical columns (no encoder loaded), handy for an apples-to-apples baseline
against `text_columns=[...]`.
## Configuration
| Parameter | Default | Description |
| ---------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text_columns` | `None` | `None` → plain numeric array (the ordinary path). A list of column names → embed those columns (`X` must be a DataFrame). `[]` → numeric + categorical only. Columns are named explicitly; Nori never guesses which are text. |
| `svd_dim` | `128` | Number of SVD columns appended. `None` appends the full raw embedding (no reduction). |
| `embedder` | `"minilm"` | Short name (`"minilm"`, `"qwen4b"`, …), a preloaded sentence-transformer object, or a callable `texts -> ndarray`. |
| `text_max_cardinality` | `128` | Caps label encoding for non-numeric columns you did not name in `text_columns`: only the most common values up to this many get their own code, and the rest (plus unseen values) map to one "other" code. It never promotes a column to embedding — only `text_columns` does that. |
| `text_normalize` | `None` | Cosine-normalize embeddings. `None` auto-enables for known LLM encoders; set `True`/`False` to override (needed for a preloaded encoder object). |
`"minilm"` (384-d, fast) is a strong default and needs no extra setup.
Larger encoders trade speed for representation quality:
| Name | Dim | Notes |
| ------------ | ---- | ----------------------- |
| `"minilm"` | 384 | Fast baseline (default) |
| `"qwen0.6b"` | 1024 | Public, still fast |
| `"qwen4b"` | 2560 | Powerful, heavier |
| `"bge-m3"` | 1024 | Public |
You can also pass a preloaded `SentenceTransformer` (reuse one across fits)
or any callable `texts -> (n, dim) ndarray`.
## Good to know
**Under the hood:** the numeric-only path (`text_columns=None`) is unchanged.
High-cardinality columns are embedded rather than label-encoded, categorical
codes are bounded (unseen values map to a single in-range "other" code), and the
SVD is fit on train only, so a fitted text model pickles and clones like any
sklearn estimator.
## Next steps
See which features drove a prediction, text columns included.
Target-aware row vectors for probing, clustering, and search.
# API Key
Source: https://docs.synthefy.com/setup/api_key
Create the key that authenticates hosted Nori requests.
This key authenticates requests to **hosted Nori**. Running Nori on your own
machine with `synthefy-nori` needs no key at all — see
[Installation](/setup/installation).
Anyone who has your key can spend your usage credits.
The [**API Keys** page](https://console.synthefy.com/api-keys) in the
Synthefy Console. Copy the whole key, with no leading or trailing spaces.
**One key at a time.** You can have exactly one Nori key. If one already
exists, the create button is replaced by your active key. To rotate, delete
the existing key first, then create a new one — deleting revokes it
immediately.
## Create your key
Sign in to the Synthefy Console and open the [**API Keys**
page](https://console.synthefy.com/api-keys).
At the top of the page, find the **Synthefy Nori** section, subtitled
"Manage the API key for the Synthefy Nori model".
An active subscription is required before you can create a key. If you're
not subscribed yet, the **Create Synthefy Nori key** button is disabled —
click **Subscribe**, complete checkout in the Stripe tab that opens, then
return to the console. The page refreshes and unlocks key creation.
In the **Synthefy Nori access** card, click **Create Synthefy Nori key**.
A dialog titled **API key created successfully** shows the full key. Copy it
and store it somewhere safe, such as a password manager or secret store.
**The key is shown only once, at creation.** If you lose it, delete the key
and create a new one.
Afterwards the section shows an active-key card with the model name, a masked
key identifier, your rate limits, and **Delete key** / **Manage subscription**
buttons.
## Rate limits
| Limit | Value |
| ----------- | ----------------------- |
| Rate limit | 50 requests per minute |
| Usage limit | 10,000 requests per day |
## Using your key
Pass it to the client explicitly:
```python theme={null}
import os
from synthefy import SynthefyNoriClient
client = SynthefyNoriClient(
api_key=os.environ["SYNTHEFY_NORI_API_KEY"],
mode="remote",
model="synthefy/nori-30m",
)
```
Or set it once in your environment and omit the argument:
```bash theme={null}
export SYNTHEFY_NORI_API_KEY=your_api_key
```
For a full request and response example, including cURL, see the
[Quickstart](/nori/quickstart#api).
## Next steps
Make your first request with the key you just created.
Auth, retries and DataFrames handled for you.
# Installation
Source: https://docs.synthefy.com/setup/installation
Pick the right Synthefy package for what you want to do.
One repository publishes two distributions. Choose the smallest install for
your execution path:
| I want to… | Install | Import |
| ---------------------------------------------- | ------------------------------------------ | ----------------------------------------------- |
| Call hosted Nori | `pip install synthefy` | `from synthefy import SynthefyNoriClient` |
| Call a SageMaker endpoint | `pip install "synthefy[aws]"` | `from synthefy import SynthefyNoriClient` |
| Run regression locally through the same client | `pip install synthefy-nori` | `from synthefy import SynthefyNoriClient` |
| Forecast through hosted Nori | `pip install "synthefy[forecasting]"` | `from synthefy.nori_ts import NoriTSForecaster` |
| Forecast locally | `pip install "synthefy-nori[forecasting]"` | `from synthefy.nori_ts import NoriTSForecaster` |
| Use the low-level local estimator | `pip install synthefy-nori` | `from synthefy_nori import NoriRegressor` |
* **`synthefy`** is the lightweight client and workflow package. Its base
install never installs or imports Torch.
* **`synthefy-nori`** adds the model runtime, weights, training, and local
inference. It depends on a compatible `synthefy` release, so installing the
local runtime also installs the shared client.
Both distributions are built from
[`Synthefy/synthefy-nori`](https://github.com/Synthefy/synthefy-nori).
Hosted code needs only `synthefy`; local execution needs `synthefy-nori`. The
dependency never points from the lightweight package to the heavy runtime.
## Optional features
Choose extras on the distribution that owns the workflow:
```bash theme={null}
# Hosted text preparation, with no local Nori runtime.
pip install "synthefy[text]"
# Local text features and local explainability.
pip install "synthefy-nori[text,interpretability]"
```
### Forecasting
Forecasting now lives in the lightweight `synthefy.nori_ts` namespace and uses
the same explicit client backend for every environment:
```bash theme={null}
# Hosted forecasting.
pip install "synthefy[forecasting]"
# Local forecasting plus the Nori runtime.
pip install "synthefy-nori[forecasting]"
```
Import `NoriTSForecaster` from `synthefy.nori_ts`. See the
[Forecasting guide](/nori/forecasting) for complete local and hosted examples.
### Contributor extras
Only needed if you are working on the package itself, not using it:
| Extra | Adds | Needed for |
| ------- | ------------------- | ------------------------------------ |
| `eval` | matplotlib, openml | reproducing the published benchmarks |
| `train` | wandb, xgboost | training a checkpoint from scratch |
| `dev` | build, pytest, ruff | running the test suite and linter |
## Develop from source
```bash theme={null}
git clone https://github.com/Synthefy/synthefy-nori
cd synthefy-nori
uv sync --extra dev
```
## Next steps
Fit and predict in five lines, locally or through the hosted API.
Only needed for the hosted API. Running locally needs no key.