# 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 ``` t-SNE of raw features vs Nori embeddings on a synthetic dataset [`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 ``` t-SNE of raw features vs Nori embeddings on the wine_quality dataset 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() ``` SHAP waterfall plot for a single Nori prediction ## 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() ``` Feature interaction network with a legend: red/pink nodes and lines contribute positively, blue nodes and lines negatively; larger nodes represent stronger main effects and thicker lines represent stronger pairwise interactions ## 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`. Two partial-dependence plots showing average predicted diabetes progression after one year against scaled BMI and blood-pressure values ## 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: Nori feature importance bar chart for the credit-default dataset 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: Glass-box EBM model diagram distilled from Nori on the credit-default dataset ## 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`. InfluxDB 3 Explorer Plugin Library filtered to the Nori Regression plugin by Synthefy Open it and choose **Install Plugin**. Explorer installs the plugin and its two Python dependencies for you. The Nori Regression plugin page in Explorer, showing its arguments and the Install Plugin button 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` | The Deploy Nori Regression Plugin dialog in Explorer, set to an HTTP Endpoint named nori_regress with the model, field, measurement, feature_fields and tags arguments filled in 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: Explorer's Run Plugin dialog with an X-Nori-Api-Key header, showing a 200 response whose body reports twelve rows written 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: The Deploy Nori Regression Plugin dialog in Explorer, set to a schedule of every:15m with the measurement, field, feature_fields, tags and model arguments filled in ```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. The Explorer Plugin Dashboard showing the coolant_fill trigger running on a 15 minute schedule against the ops database ## 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**. Synthefy Nori product page in AWS Marketplace with View purchase options Review the pricing and terms, then choose **Subscribe**. Synthefy Nori purchase options page with Subscribe highlighted **Option 1 — AWS Marketplace:** after subscribing, choose **Launch your software**. AWS Marketplace subscription page with Launch your software highlighted **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**. AWS Marketplace launch page configured for a SageMaker real-time inference 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.