Skip to main content

xscalers

Ruff Tests PyPI version

sklearn-shaped, dim-aware scaling transformers for xarray and pytensor.xtensor.

Fit is eager xarray — the fitted state is an xr.Dataset you can inspect and edit — and transform is pure arithmetic by dimension name that runs identically on DataArrays and symbolic pytensor.xtensor variables. All v1 scalers are affine, so model code binds only to a composed (center, scale) pair. Nothing custom: every container and serialization format is a native ecosystem object. Every example below is executed against this README by the test suite, so it stays copy-paste-runnable.

Why

Hand-writing (x - mean) / std is fine until the regression's inputs are an xr.Dataset with mixed grains and the model is PyMC. Three familiar pains:

  • sklearn's transformers assume stacked 2-D arrays and hide fitted state in mean_-style attributes, which neither preserves dims nor hands you the fitted constants;
  • the scaling constants fed into a pytensor.xtensor graph are usually hand-copied, so converting a model contribution back to real terms goes silently wrong;
  • xarray itself ships no scaling transformer.

xscalers fills exactly that gap: affine scaling over dimension names on native xarray objects, fitted state as an inspectable xr.Dataset artifact, the same arithmetic lifting into a symbolic pytensor.xtensor graph, and one unscale verb that converts model contributions back to real units, refusing where the algebra is not affine instead of guessing.

The frame is a regression loop you can toggle: point the same pipeline at a different transformation (MeanScaler vs ZScore vs FixedScaler, per variable) and regenerate the model-ready xr.Dataset without touching model code, then invert predictions and contributions back to real terms with the same fitted state.

The contract

class BaseTransformer:
    dims: tuple[str, ...] | None                       # dims to reduce over; None = all
    def fit(self, X) -> xr.Dataset                     # fitted state, inspectable + editable
    def transform(self, X, state) -> X                 # state: Mapping[str, DataArray]
    def inverse_transform(self, X, state) -> X

Dataset and DataTree inputs are handled per variable — each variable keeps its own remaining dims, which is what makes mixed-grain inputs work with no special casing.

The basics

import xarray as xr

from xscalers.datasets import load_mixed_grain, load_national
from xscalers.preprocessing import MeanScaler

national: xr.Dataset = load_national()

scaler = MeanScaler(("date",))
state = scaler.fit(national["media"])
scaled = scaler.transform(national["media"], state)

state
<xarray.Dataset> Size: 96B
Dimensions:  (channel: 3)
Coordinates:
  * channel  (channel) <U6 72B 'search' 'social' 'fb'
Data variables:
    mean     (channel) float64 24B 49.68 48.81 53.79
Attributes:
    kind:         mean
    dims:         ['date']
    scaled_from:  media
    spec_sha:     af4a187be0e9
    recipe:       MeanScaler(dims=['date'])

State is a value on the record — inspect it, save it, pass it to another process. Hand-editing is a manual override (the stamp records the recipe, not your edits).

Mixed grains need no special casing: fit a Dataset and each variable gets its own state:

import xarray as xr

from xscalers.compose import VariableTransformer
from xscalers.datasets import load_mixed_grain
from xscalers.preprocessing import FixedScaler, MeanScaler, ZScore

mixed: xr.Dataset = load_mixed_grain()
transformer = VariableTransformer(
    {
        "media_national": MeanScaler(dims=("date",)),
        "media_digital": ZScore(dims=("date",)),
        "media_tv": FixedScaler(scale=1_000_000),
    },
    remainder="passthrough",
)
state = transformer.fit(mixed)              # one Dataset state per variable
scaled = transformer.transform(mixed, state)  # each var scaled by its own recipe

Derived metrics — new variables, even across grains

Derive turns selected pieces of existing variables into a new variable that later steps treat like anything else. The two pieces don't have to come from the same variable or the same grain — a national series over a product-grain control broadcasts up to product grain, so tv_per_control below is a product-grain variable built from a national input.

Ops are a closed, spec-serializable vocabulary — op="ratio | diff | product" — and selector pieces take reduce="sum | mean | first | last" for share-of-total style derivations:

import xarray as xr

from xscalers.compose import VariableTransformer
from xscalers.datasets import load_mixed_grain
from xscalers.pipeline import Pipeline
from xscalers.preprocessing import Derive, FunctionTransformer, Splitter, ZScore

mixed: xr.Dataset = load_mixed_grain()
mixed["media_national"].dims                  # (date, media) — national grain
mixed["controls_prod"].dims                    # (date, product) — product grain

pipe = Pipeline(
    [
        (
            "derive",
            Derive(
                name="tv_per_control",
                left=("media_national", {"media": "tv"}),
                right=("controls_prod", {}),   # {} = the whole variable
                op="ratio",
            ),
        ),
        ("log", FunctionTransformer({"tv_per_control": "log"})),  # named funcs
        ("scale", VariableTransformer({"tv_per_control": ZScore(dims=("date",))})),
    ]
)
state = pipe.fit(mixed)
scaled = pipe.transform(mixed, state)
scaled["tv_per_control"].dims                  # (date, product) — broadcast, then scaled

back = pipe.inverse_transform(scaled, state)
import numpy as np
np.testing.assert_allclose(back["controls_prod"], mixed["controls_prod"])

Because the z-score reduces over date only, each product keeps its own mean and standard deviation — the per-variable, per-group treatment that mixed-grain data needs, with no reshaping.

inverse_transform drops the derived variable and restores the inputs — so the whole chain (derive → log → z-score — unzscore → exp → un-derive) round-trips. Note that a log makes the target path non-affine: pipe.affine() will refuse such pipelines, and real-terms contributions must go through the batched inverse_transform instead of the (β, scale) shortcut.

The same step derives within one variable too, when the variable carries an extra coordinate dimension — controls (date, product, control) with control = [level, reference, seasonality]:

# continues above — `mixed` carries over
from xscalers.preprocessing import Derive

share = Derive(
    name="level_diff",                           # op="diff" this time:
    left=("controls", {"control": "level"}), # level minus reference,
    right=("controls", {"control": "reference"}),
    op="diff",                                   # not a ratio
)
derived = share.transform(mixed, share.fit(mixed))
derived["level_diff"].dims                     # (date, product)

Dropping variables and coordinate values

VariableDropper is the drop verb — the xarray answer to sklego's ColumnDropper. vars= removes whole variables; labels= removes values from a coordinate dataset-wide (the complement of keeping, which is Selector's job):

# continues above — `mixed` carries over
from xscalers.preprocessing import VariableDropper

prune = VariableDropper(vars=["media_national", "controls_prod"], labels={"control": ["reference"]})
pruned = prune.transform(mixed, prune.fit(mixed))
"media_national" in pruned.data_vars          # False — raw inputs are gone
"reference" in pruned.coords["control"].values  # False — every variable shrank with it

The motivating shape is pruning raw inputs once a derived metric supersedes them, so later steps never have to enumerate what they don't care about:

# continues above — `mixed` carries over
from xscalers.compose import VariableTransformer
from xscalers.pipeline import Pipeline
from xscalers.preprocessing import Derive, VariableDropper, ZScore

pipe = Pipeline(
    [
        (
            "derive",
            Derive(
                name="tv_per_control",
                left=("media_national", {"media": "tv"}),
                right=("controls_prod", {}),
                op="ratio",
            ),
        ),
        ("prune", VariableDropper(vars=["media_national", "controls_prod"])),
        ("scale", VariableTransformer({"tv_per_control": ZScore(dims=("date",))})),
    ]
)
model_ready = pipe.fit_transform(mixed)
sorted(model_ready.data_vars)                 # raw inputs pruned, ratio kept

Dropping destroys data, so — like Selector — VariableDropper.inverse_transform raises instead of guessing, and pipe.inverse_transform refuses for the whole chain. Real-terms conversion is unaffected: pipe.unscale(contrib, var="tv_per_control") still walks that one variable backwards. fit validates rather than failing late (sklego's contract): unknown names raise KeyError, dropping every variable raises ValueError, and a non-Dataset input raises TypeError (DataTree node drops are deferred).

If you need the reverse, a pipeline that scales what it keeps, Splitter is the invertible way to split one variable into differently-treated pieces, and VariableTransformer(..., remainder="drop") is the leftover-dropping sibling.

Splitting one variable into differently-treated pieces

Different treatment within one media variable (digital vs TV) works by splitting into pieces, scaling each, then recombining. Real inputs are a plain Dataset of variables, so pieces become sibling variables (media_digital, media_linear), each keeping its slice of the coordinate under a piece-specific dim name; inverse_transform recombines and restores the original coordinate order:

from xscalers.compose import VariableTransformer
from xscalers.datasets import load_media_channels
from xscalers.pipeline import Pipeline
from xscalers.preprocessing import FixedScaler, MeanScaler, Splitter

ds = load_media_channels()

pipe = Pipeline(
    [
        (
            "split",
            Splitter(
                "media",
                {
                    "digital": {"channel": ["search", "social", "fb"]},
                    "linear": {"channel": ["tv", "stream"]},
                },
            ),
        ),
        (
            "scale",
            VariableTransformer(
                {
                    "media_digital": MeanScaler(dims=("date",)),
                    "media_linear": FixedScaler(scale=1_000),
                },
            ),
        ),
    ]
)
state = pipe.fit(ds)
scaled = pipe.transform(ds, state)

back = pipe.inverse_transform(scaled, state)   # pieces recombine; coords restored
import numpy as np
np.testing.assert_allclose(back["media"], ds["media"])

Saving the fitted state

State is data — saving it is native xarray IO, in any of the three shapes you'll meet in practice:

1. A standalone one-file artifact — to_netcdf, next to your recipe:

import os
import xarray as xr
import numpy as np
from xscalers.datasets import load_national
from xscalers.preprocessing import MeanScaler

national: xr.Dataset = load_national()
scaler = MeanScaler(dims=("date",))
state = scaler.fit(national["media"])

state.to_netcdf("media_state.nc")                 # state is a Dataset — native IO
reloaded = xr.open_dataset("media_state.nc")      # ...read back natively
scaled = scaler.transform(national["media"], reloaded)
back = scaler.inverse_transform(scaled, reloaded)
np.testing.assert_allclose(back, national["media"])
os.remove("media_state.nc")                       # tidy up after this example

2. Alongside your (large) zarr inputs — append the state tree as groups of the existing store; append_state exists only because DataTree.to_zarr(group=...) is not implemented upstream — reading back is native:

# continues above — `xr`, `mixed` and the fitted `transformer` carry over
from pathlib import Path
from xscalers.compose import VariableTransformer
from xscalers.container import state_tree
from xscalers.datasets import load_mixed_grain
from xscalers.persistence import append_state
from xscalers.preprocessing import MeanScaler

store: Path = Path("inputs.zarr")             # your existing input store
mixed: xr.Dataset = load_mixed_grain()       # pretend it already lives there
mixed.to_zarr(store)                         # ... here we create it first

transformer: VariableTransformer = VariableTransformer({"media_national": MeanScaler(dims=("date",))}, remainder="drop")
state = transformer.fit(mixed)
append_state(state_tree({"scale": state}), store, group="scalers")  # state under "scale"

import xarray as xr
reloaded_tree = xr.open_datatree(store, group="scalers")
reloaded_state = {
    name: node.dataset for name, node in reloaded_tree.children["scale"].children.items()
}
scaled_again = transformer.transform(mixed, reloaded_state)
import shutil; shutil.rmtree(store)          # tidy up after this example

3. Next to a pymc model — the state and the InferenceData are separate artifacts sharing a directory; recipe (JSON via to_spec) is versioned config, state is data, never the two mixed:

# requires-pymc
import pymc as pm
import arviz as az
from xscalers.datasets import load_national
from xscalers.preprocessing import MeanScaler

national = load_national()
scaler = MeanScaler(dims=("date",))
state = scaler.fit(national["target"])
y = scaler.transform(national["target"], state)   # scaled input into the model

import xarray as xr
with pm.Model() as model:
    beta = pm.Normal("beta", 0, 1)
    sigma = pm.HalfNormal("sigma", 1)
    # numpy views into the scaled dataset operate the same as scaled arrays
    pm.Normal("obs", beta * float(y.mean()), sigma, observed=np.asarray(y))
    idata = pm.sample_prior_predictive(draws=10, random_seed=0)

import numpy as np
idata.to_netcdf("posterior.nc")                   # model results...
state.to_netcdf("scaler_state.nc")                # ...and its scaling, side by side
idata_back = xr.open_datatree("posterior.nc")     # DataTree in, DataTree out
state_back = xr.open_dataset("scaler_state.nc")

# real-terms conversion: the affine pair (center, scale) — MeanScaler is
# divisive, so its composed view is (center=0, scale=mean):
posterior_beta: float = float(idata_back["prior"]["beta"].mean())
contribution_scaled: float = posterior_beta * float(np.asarray(y).mean())
contributions_original_scale = contribution_scaled * float(state_back["mean"])
import os
for name in ("posterior.nc", "scaler_state.nc"):
    os.remove(name)

The fitted pipeline serializes as recipe + artifact:

# continues above — `state` carries over
import os
from xscalers.container import state_tree

state_tree({"scale": state}).to_netcdf("fitted_state.nc")   # whole pipeline: one artifact

Converting back to real units — one verb

Model code never hand-writes × scale + center: the affine pair is an internal detail. Pipeline.unscale applies the composed pair per variable (eager xarray contributions or symbolic pytensor.xtensor in the graph); when a pipeline is non-affine (e.g. a log-derived metric), unscale walks only that variable's own steps backwards, stopping at the step that created it — so the one call stays honest where hand-written β·scale algebra goes silently wrong:

import numpy as np
import xarray as xr
from xscalers.compose import VariableTransformer
from xscalers.datasets import load_mixed_grain
from xscalers.pipeline import Pipeline
from xscalers.preprocessing import Derive, FixedScaler, FunctionTransformer, ZScore
import os

pipe = Pipeline(
    [
        (
            "derive",
            Derive(
                name="tv_per_control",
                left=("media_national", {"media": "tv"}),
                right=("controls_prod", {}),
                op="ratio",
            ),
        ),
        ("log", FunctionTransformer({"tv_per_control": "log"})),
        ("scale", VariableTransformer({"tv_per_control": FixedScaler(scale=1.0)})),
    ]
)
state = pipe.fit(load_mixed_grain())
contrib = xr.DataArray(np.full((104, 4), -0.75), dims=("date", "product"))

y_real = pipe.unscale(contrib, var="tv_per_control")   # one verb; located = exp(−0.75)
np.testing.assert_allclose(y_real, np.exp(contrib))

The flagship flow: fit on xarray, unscale a pymc graph

Requires the optional pytensor extra (pip install "xscalers[pytensor]").

import pytensor.xtensor as ptx
from xscalers.compose import VariableTransformer
from xscalers.datasets import load_national
from xscalers.pipeline import Pipeline
from xscalers.preprocessing import MeanScaler

scaler = MeanScaler(dims=("date",))
data = load_national()
pipe = Pipeline([("scale", VariableTransformer({"media": scaler}))])
state = pipe.fit(data)                                     # fitted pipeline state

contrib = ptx.xtensor("channel_contrib", dims=("date", "channel"))
spend_real = pipe.unscale(contrib, var="media", state=state)   # symbolic: contrib * mean

After sampling, contributions arrive as xarray with (chain, draw, ...) — the same state broadcasts over those extra dims by name.

Writing it out: post-fit step access

from xscalers.datasets import load_national
from xscalers.pipeline import make_pipeline
from xscalers.preprocessing import MeanScaler

pipe = make_pipeline(MeanScaler(dims=("date",)), MeanScaler(dims=("date",)))
assert [name for name, _ in pipe.steps] == ["meanscaler", "meanscaler-1"]
pipe.fit(load_national())                      # sklearn-style fitted pipeline

bound = pipe["meanscaler"]                     # transformer + its fitted state slice
scaled_again = bound.transform(load_national())  # one fitted step on fresh data
back = bound.inverse_transform(scaled_again)     # ...and its inverse

For sklearn users

The vocabulary is deliberately sklearn-shaped:

sklearn xscalers notes
fit / transform / inverse_transform same states are xr.Datasets, not attributes
ColumnTransformer VariableTransformer per-variable treatment over named data variables
ColumnDropper (sklego) VariableDropper over drop_vars / drop_sel; Selector covers the keeping side
Pipeline Pipeline named steps, chained states, reversed inverse
make_pipeline make_pipeline the composition verb (no >> chaining: it hides the recipe/state split)
FeatureUnion make_union many pipelines over one dataset, collision policy, covariates-only by design
"passthrough" "passthrough" identical semantics

The deliberate departure: state is explicit and passed around, not hidden as scaler.mean_, which is what lets you edit fitted values and reuse them across backends. The counterpart to transform for model code is unscale: one verb, correct for affine pipelines and honest for non-affine ones.

Install

pip install xscalers            # core (xarray, numpy)

License

MIT

Metadata

Release files for xscalers 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xscalers 0.1.0
File Size Uploaded Provenance
xscalers-0.1.0.tar.gz 35.1 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for xscalers 0.1.0
File Interpreter ABI Platform
xscalers-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 74.5 kB

Release files / xscalers-0.1.0.tar.gz

Download URL xscalers-0.1.0.tar.gz
Size 35.1 kB
Tags Source
SHA-256 checksum
How to use checksums
d9b7a3a940d827d6d4ca1fa74a2c162220eacfd480c5f71fed570f9a1c718dac
BLAKE2b-256 checksum
How to use checksums
959d7ff5e9e9c375e233692d1aece1dd1137a61e3b53cb9bdccb4b99d8e4f411
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / xscalers-0.1.0-py3-none-any.whl

Download URL xscalers-0.1.0-py3-none-any.whl
Size 39.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aa43e6a2f87025cdc54344745c59f031cafc4998a8677548e10e28e8c205253d
BLAKE2b-256 checksum
How to use checksums
a1aef243b51776b7908f5a52923e96ec54669d0f6fda94f87a6c08973f250059
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for xscalers 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page