> ## Documentation Index
> Fetch the complete documentation index at: https://docs.synthefy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Relational Static Predictions

> Predict a column of each row, such as an account balance, from the rows where it is known. No time window, so no dates needed.

A static prediction fills in a column of your table for the rows where it is
missing: an account's balance, a user's income, whether a transaction was
fraud. The rows where the value is known are the examples, and features still
come from every linked table. Unlike the temporal predictions on
[Relational data](/nori/relational-data), which forecast events after a date, a
static target has no time window, so the tables need no dates. Nothing is
trained.

> **No dates needed**
>
> A database with no time columns works. There are no `anchor_times`,
> `observed_through` or `anchor_time` to choose.

> **Linked tables count**
>
> Features follow foreign keys, such as each account's transactions and its
> branch, the same way they do for forecasts.

> **No leaks**
>
> The target column is removed before features are built, so no feature can
> read any row's value, directly or through another table.

> **Numbers and 0/1 flags**
>
> A numeric column is regression. For a 0/1 column, such as a loan flag, you
> get the probability of a 1.

> [!NOTE]
>
> Static predictions need `nori-rel` 0.4.6 or later; see
> [Installation](/setup/installation). Local mode runs Nori in your process,
> with no API key. The outputs on this page come from a synthetic bank: 1,000
> accounts, 7,046 transactions, no date columns, and 20% of the balances set to
> NULL.

## Quickstart

Connect without registering any time columns, then name the column to predict
and the table it belongs to:

```python theme={null}
from nori_rel import EnvironmentCredential, NoriRelClient

client = NoriRelClient(model="nori-30m")
source = client.connect(
    engine="postgresql",
    credential=EnvironmentCredential(variable="DATABASE_URL"),
    schema_name="bank",  # yours: the schema that holds your tables
    tables=["branches", "accounts", "transactions"],
)
predictions = client.predict(
    source=source,
    query="PREDICT accounts.balance FOR EACH accounts.account_id",
)
print(predictions.head(3))
```

```
   entity_id                      anchor_time    prediction
0         12 2026-10-19 18:34:50.642387+00:00  18514.625034
1         15 2026-10-19 18:34:50.642387+00:00  18587.770872
2         23 2026-10-19 18:34:50.642387+00:00  20495.685460
```

The 800 accounts with a known balance were the examples, and the 200 with a
NULL balance were scored. Against the hidden values, these predictions have an
R² of 0.945 and a mean absolute error of 1,468, where predicting the mean
balance for every account gives 6,530. `anchor_time` is the time of the read.

## How it works

A static target is a column of the `FOR EACH` table, with no time window.
Nori-Rel reads every table once and splits the rows of that table in two:

* **Examples** are the rows whose value is known (not NULL, and not NaN) and
  that pass the query's `WHERE`. The rows being scored are never examples,
  even when their value is known. `max_context_rows` (default 5,000) counts
  only these rows. Above it, Nori-Rel keeps the lowest primary keys, often the
  oldest rows, or the newest rows when you pass `entity_created_at`. To sample
  the whole table instead, see [Choose the examples](#choose-the-examples).
* **Scored rows** are the ones `FOR` names. `FOR EACH` means every row whose
  value is NULL or NaN; `FOR accounts.account_id IN (3, 8)` means those rows,
  known value or not. `query_filter=` narrows them further.

Before features are built, the target column is removed from the tables. The
features then come from the row's other columns and its linked rows, such as
the count and mean amount of its transactions and its branch's city. No
feature can read a balance: not the account's own, and not one reached through
another table, such as a branch's average balance.

Every row's features use the data as of the read: PostgreSQL's transaction
time, or the pinned table versions on Snowflake and Databricks.

## Check predictions against known values

Name rows whose value is known. They are scored and left out of the examples,
so you can compare each prediction with the real value without the model
having seen it:

```python theme={null}
details = client.predict(
    source=source,
    query="PREDICT accounts.balance FOR accounts.account_id IN (1, 2, 3)",
    return_details=True,
)
print(details.metadata["usable_rows"], "examples")
print(details.predictions[["entity_id", "prediction"]])
```

```
797 examples
   entity_id    prediction
0          1   2795.739781
1          2  19680.077928
2          3  10441.089680
```

The actual balances of accounts 1, 2 and 3 are 2,642.87, 21,436.29 and
10,351.17.

## 0/1 targets

A column of 0s and 1s, such as a loan flag, can be predicted as the
probability of a 1. Its values alone don't say whether you want a number or a
probability, so Nori-Rel asks. It checks every stored value, not only the
examples:

```python theme={null}
client.predict(
    source=source, query="PREDICT accounts.has_loan FOR EACH accounts.account_id"
)
```

```
ValueError: accounts.has_loan holds only 0 and 1: pass task='classification' for a probability or task='regression' for a number
```

Pass `task="regression"` with `output_type="mean"`. The mean of a 0/1 label is
the probability of a 1:

```python theme={null}
loans = client.predict(
    source=source,
    query="PREDICT accounts.has_loan FOR EACH accounts.account_id",
    task="regression",
    output_type="mean",
)
```

On the synthetic bank, with 20% of the loan flags hidden, these scores rank the
hidden flags with an AUC of 0.758.

## Values fixed when the row was created

Leave `entity_created_at` out when the value is the row's current state, such
as a balance. Pass it when the value describes the row at the moment it was
created, such as fraud on a transaction. Each row's features then stop at that
row's own time:

```python theme={null}
fraud = client.predict(
    source=source,
    # is_fraud is 1 or 0, so the mean is the probability of fraud.
    query="PREDICT transactions.is_fraud FOR EACH transactions.transaction_id",
    task="regression",
    output_type="mean",
    # A column on transactions holding when each transaction happened.
    entity_created_at="created_at",
)
```

Without it, an old transaction's features would include later events, such as
a chargeback, that a new transaction can't have yet. With it, rows with a NULL
or future time are left out, and the result's `anchor_time` is each row's own
time.

## Choose the examples

By default every row with a known value is an example, up to
`max_context_rows` (default 5,000), taken by lowest primary key. Pass
`context=ContextPolicy(...)` to choose them another way. Every setup below ran
on the synthetic bank; each score is the mean absolute error on the 200 hidden
balances:

| Setup | Examples | MAE |
| - | - | - |
| Every known row (the default) | 800 | 1,468 |
| `max_context_rows=200`: the lowest keys | 200 | 1,686 |
| `ContextPolicy(max_rows=200, time_sampling="random")` | 200 | 1,584 |
| `candidate_sql`: accounts with 8 or more transactions | 339 | 1,613 |

Use every row when it fits. Below, each setup is one call.

### A random sample when you cap the rows (recommended)

On a large table the lowest keys are usually the oldest rows. A random sample
covers the whole table instead, and the same `seed` keeps the same rows on
every run:

```python theme={null}
from nori_rel import ContextPolicy

predictions = client.predict(
    source=source,
    query="PREDICT accounts.balance FOR EACH accounts.account_id",
    context=ContextPolicy(max_rows=200, time_sampling="random", seed=0),
)
```

`context` replaces `max_context_rows`; passing both raises an error.

### Narrow the examples with your own SQL

`candidate_sql` keeps only the examples your SELECT returns, for filters the
query's `WHERE` can't express, such as a join. It reads
`candidates(entity_id, anchor_time)`, the rows Nori-Rel would use, and can only
narrow them:

```python theme={null}
predictions = client.predict(
    source=source,
    query="PREDICT accounts.balance FOR EACH accounts.account_id",
    context=ContextPolicy(
        # Learn only from accounts with at least 8 transactions.
        candidate_sql=(
            "SELECT c.entity_id, c.anchor_time FROM candidates AS c "
            "WHERE (SELECT COUNT(*) FROM bank.transactions AS t "
            "WHERE t.account_id = c.entity_id) >= 8"
        ),
    ),
)
```

Name tables with their schema, as above. To filter on the entity's own
columns, `WHERE` in the query is simpler, for example
`... FOR EACH accounts.account_id WHERE accounts.branch_id = 1`.

### Balance a 0/1 label

`ClassBalance` keeps every positive and a set number of negatives per
positive, so a rare label isn't drowned out. Nori-Rel then maps the predicted
probabilities back to the population's rate. With local regression on a 0/1
column, use `output_type="mean"`:

```python theme={null}
from nori_rel import ClassBalance, ContextPolicy

details = client.predict(
    source=source,
    query="PREDICT accounts.has_loan FOR EACH accounts.account_id",
    task="regression",
    output_type="mean",
    context=ContextPolicy(balance=ClassBalance(negatives_per_positive=1)),
    return_details=True,
)
report = details.metadata["context_policy"]
print(report["labelled_label_counts"], "->", report["kept_label_counts"])
print(
    report["population_positive_rate"],
    report["sample_positive_rate"],
    report["probability_correction_factor"],
)
```

```
{'0': 512, '1': 288} -> {'0': 288, '1': 288}
0.36 0.5 0.5625
```

The balanced sample is half positives, so each predicted probability's odds
are multiplied by 0.5625 to undo that. The corrected predictions average 0.367
against a known rate of 0.360. At a 36% positive rate balancing doesn't help
the ranking: AUC is 0.744 balanced and 0.758 with the natural mix. Balance a
label when positives are rare, and keep the natural mix otherwise.

### See which rows were kept

`details.metadata["context_policy"]` reports the setup every prediction used,
with or without a policy: the policy itself, the label counts before and after
selection (`labelled_label_counts`, `kept_label_counts`), and, for a 0/1 label,
the population and sample positive rates and any correction applied.

## Configuration

> **Arguments for a static target**
>
> | Argument | Static target |
> | - | - |
> | `query` | A column of the `FOR` table, such as `PREDICT accounts.balance FOR EACH accounts.account_id` or `FOR accounts.account_id IN (1, 2)`. It must be numeric and can't be the table's primary key. |
> | `anchor_times`, `anchor_time`, `observed_through` | Not accepted. Values are read as they are now, and there is no window of future events to place. |
> | `entity_created_at` | Optional. Each row's features stop at that row's own time. |
> | `task` | Needed only for a 0/1 column: `task="regression"` with `output_type="mean"` gives the probability of a 1. |
> | `max_context_rows` | The most examples, default 5,000, counted after the NULL and scored rows are taken out. Above it, the lowest primary keys are kept (newest first with `entity_created_at`). |
> | `context` | A `ContextPolicy` that chooses the examples instead of `max_context_rows`: `max_rows`, `time_sampling="random"` with `seed`, `ClassBalance` for a 0/1 label, and `candidate_sql`. See [Choose the examples](#choose-the-examples). |
> | `query_filter` | Narrows the scored rows. A `FOR` row it rejects stays an example. |
> | `feature_strategy` | `"fastdfs"` (the default) or a `FeatureBackend`. |

## Good to know

* Nori-Rel removes only the target column. A column that copies or encodes it,
  such as a `balance_band`, would still leak it: connect a view without it.
* `"noridfs"` and `AgenticSearch` size their features to a window of future
  events, which a static target doesn't have, so they raise an error here.
* A static prediction makes no reusable pipeline: `details.pipeline` is `None`.
* Backtests at a past date aren't supported yet. They need to know when each
  value was recorded, which the database doesn't track.
* To score every known row at once, as in leave-one-out, name the rows in
  batches with `FOR ... IN (...)`; there is no single call for it yet.

## Next steps

> **[Relational data](/nori/relational-data)**
>
> Forecast what happens next from linked tables, with past dates as examples.

> **[Explainability](/nori/explainability)**
>
> SHAP values, interactions, and partial dependence.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.