> ## 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.

# Categorical & Ordinal 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.

<Warning>
  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.
</Warning>

## 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.

<Note>
  **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)`.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Missing Values & Imputation" icon="table-cells" href="/nori/missing-values">
    Pass NaNs straight through, and take control of the fill when you want it.
  </Card>

  <Card title="Explainability" icon="magnifying-glass-chart" href="/nori/explainability">
    Which features drove the label the model chose.
  </Card>
</CardGroup>
