xscalers 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xscalers-0.1.0/PKG-INFO +544 -0
- xscalers-0.1.0/README.md +511 -0
- xscalers-0.1.0/pyproject.toml +132 -0
- xscalers-0.1.0/src/xscalers/__init__.py +81 -0
- xscalers-0.1.0/src/xscalers/binding.py +54 -0
- xscalers-0.1.0/src/xscalers/compose.py +201 -0
- xscalers-0.1.0/src/xscalers/container.py +138 -0
- xscalers-0.1.0/src/xscalers/core.py +365 -0
- xscalers-0.1.0/src/xscalers/datasets.py +181 -0
- xscalers-0.1.0/src/xscalers/persistence.py +47 -0
- xscalers-0.1.0/src/xscalers/pipeline.py +499 -0
- xscalers-0.1.0/src/xscalers/preprocessing.py +834 -0
- xscalers-0.1.0/src/xscalers/py.typed +0 -0
- xscalers-0.1.0/src/xscalers/serialize.py +167 -0
xscalers-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xscalers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: sklearn-shaped, dim-aware scaling transformers for xarray and pytensor.xtensor
|
|
5
|
+
Keywords: xarray,scaling,preprocessing,pymc,pytensor,panel-data,time-series
|
|
6
|
+
Author: Will Dean
|
|
7
|
+
Author-email: Will Dean <wd60622@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
20
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
21
|
+
Classifier: Operating System :: POSIX
|
|
22
|
+
Classifier: Operating System :: Unix
|
|
23
|
+
Classifier: Operating System :: MacOS
|
|
24
|
+
Requires-Dist: numpy>=2.0
|
|
25
|
+
Requires-Dist: xarray>=2024.10.0
|
|
26
|
+
Requires-Dist: pytensor>=3.0 ; extra == 'pytensor'
|
|
27
|
+
Requires-Python: >=3.12
|
|
28
|
+
Project-URL: Bug Tracker, https://github.com/williambdean/xscalers/issues
|
|
29
|
+
Project-URL: Homepage, https://github.com/williambdean/xscalers
|
|
30
|
+
Project-URL: Repository, https://github.com/williambdean/xscalers
|
|
31
|
+
Provides-Extra: pytensor
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# xscalers
|
|
35
|
+
|
|
36
|
+
[](https://github.com/astral-sh/ruff)
|
|
37
|
+
[](https://github.com/williambdean/xscalers/actions/workflows/ci.yaml)
|
|
38
|
+
[](https://badge.fury.io/py/xscalers)
|
|
39
|
+
|
|
40
|
+
sklearn-shaped, dim-aware scaling transformers for `xarray` and `pytensor.xtensor`.
|
|
41
|
+
|
|
42
|
+
**Fit is eager xarray** — the fitted state is an `xr.Dataset` you can inspect
|
|
43
|
+
and edit — and **transform is pure arithmetic by dimension name** that runs
|
|
44
|
+
identically on `DataArray`s and symbolic `pytensor.xtensor` variables. All v1
|
|
45
|
+
scalers are affine, so model code binds only to a composed `(center, scale)`
|
|
46
|
+
pair. Nothing custom: every container and serialization format is a native
|
|
47
|
+
ecosystem object. Every example below is executed against this README by the
|
|
48
|
+
test suite, so it stays copy-paste-runnable.
|
|
49
|
+
|
|
50
|
+
## Why
|
|
51
|
+
|
|
52
|
+
Hand-writing `(x - mean) / std` is fine until the regression's inputs are an
|
|
53
|
+
`xr.Dataset` with mixed grains and the model is PyMC. Three familiar pains:
|
|
54
|
+
|
|
55
|
+
- sklearn's transformers assume stacked 2-D arrays and hide fitted state in
|
|
56
|
+
`mean_`-style attributes, which neither preserves dims nor hands you the
|
|
57
|
+
fitted constants;
|
|
58
|
+
- the scaling constants fed into a `pytensor.xtensor` graph are usually
|
|
59
|
+
hand-copied, so converting a model contribution back to real terms goes
|
|
60
|
+
silently wrong;
|
|
61
|
+
- xarray itself ships no scaling transformer.
|
|
62
|
+
|
|
63
|
+
xscalers fills exactly that gap: affine scaling over dimension names on
|
|
64
|
+
native xarray objects, fitted state as an inspectable `xr.Dataset` artifact,
|
|
65
|
+
the same arithmetic lifting into a symbolic `pytensor.xtensor` graph, and
|
|
66
|
+
one `unscale` verb that converts model contributions back to real units,
|
|
67
|
+
refusing where the algebra is not affine instead of guessing.
|
|
68
|
+
|
|
69
|
+
The frame is a regression loop you can toggle: point the same pipeline at a
|
|
70
|
+
different transformation (`MeanScaler` vs `ZScore` vs `FixedScaler`, per
|
|
71
|
+
variable) and regenerate the model-ready `xr.Dataset` without touching model
|
|
72
|
+
code, then invert predictions and contributions back to real terms with the
|
|
73
|
+
same fitted state.
|
|
74
|
+
|
|
75
|
+
## The contract
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
class BaseTransformer:
|
|
79
|
+
dims: tuple[str, ...] | None # dims to reduce over; None = all
|
|
80
|
+
def fit(self, X) -> xr.Dataset # fitted state, inspectable + editable
|
|
81
|
+
def transform(self, X, state) -> X # state: Mapping[str, DataArray]
|
|
82
|
+
def inverse_transform(self, X, state) -> X
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Dataset and DataTree inputs are handled per variable — each variable keeps
|
|
86
|
+
its own remaining dims, which is what makes mixed-grain inputs work with no
|
|
87
|
+
special casing.
|
|
88
|
+
|
|
89
|
+
## The basics
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import xarray as xr
|
|
93
|
+
|
|
94
|
+
from xscalers.datasets import load_mixed_grain, load_national
|
|
95
|
+
from xscalers.preprocessing import MeanScaler
|
|
96
|
+
|
|
97
|
+
national: xr.Dataset = load_national()
|
|
98
|
+
|
|
99
|
+
scaler = MeanScaler(("date",))
|
|
100
|
+
state = scaler.fit(national["media"])
|
|
101
|
+
scaled = scaler.transform(national["media"], state)
|
|
102
|
+
|
|
103
|
+
state
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
<xarray.Dataset> Size: 96B
|
|
108
|
+
Dimensions: (channel: 3)
|
|
109
|
+
Coordinates:
|
|
110
|
+
* channel (channel) <U6 72B 'search' 'social' 'fb'
|
|
111
|
+
Data variables:
|
|
112
|
+
mean (channel) float64 24B 49.68 48.81 53.79
|
|
113
|
+
Attributes:
|
|
114
|
+
kind: mean
|
|
115
|
+
dims: ['date']
|
|
116
|
+
scaled_from: media
|
|
117
|
+
spec_sha: af4a187be0e9
|
|
118
|
+
recipe: MeanScaler(dims=['date'])
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
State is a value on the record — inspect it, save it, pass it to another
|
|
122
|
+
process. Hand-editing is a manual override (the stamp records the recipe,
|
|
123
|
+
not your edits).
|
|
124
|
+
|
|
125
|
+
Mixed grains need no special casing: `fit` a Dataset and each variable gets
|
|
126
|
+
its own state:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import xarray as xr
|
|
130
|
+
|
|
131
|
+
from xscalers.compose import VariableTransformer
|
|
132
|
+
from xscalers.datasets import load_mixed_grain
|
|
133
|
+
from xscalers.preprocessing import FixedScaler, MeanScaler, ZScore
|
|
134
|
+
|
|
135
|
+
mixed: xr.Dataset = load_mixed_grain()
|
|
136
|
+
transformer = VariableTransformer(
|
|
137
|
+
{
|
|
138
|
+
"media_national": MeanScaler(dims=("date",)),
|
|
139
|
+
"media_digital": ZScore(dims=("date",)),
|
|
140
|
+
"media_tv": FixedScaler(scale=1_000_000),
|
|
141
|
+
},
|
|
142
|
+
remainder="passthrough",
|
|
143
|
+
)
|
|
144
|
+
state = transformer.fit(mixed) # one Dataset state per variable
|
|
145
|
+
scaled = transformer.transform(mixed, state) # each var scaled by its own recipe
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Derived metrics — new variables, even across grains
|
|
149
|
+
|
|
150
|
+
`Derive` turns selected pieces of existing variables into a *new* variable
|
|
151
|
+
that later steps treat like anything else. The two pieces don't have to come
|
|
152
|
+
from the same variable or the same grain — a national series over a
|
|
153
|
+
product-grain control broadcasts up to product grain, so `tv_per_control`
|
|
154
|
+
below is a product-grain variable built from a national input.
|
|
155
|
+
|
|
156
|
+
Ops are a closed, spec-serializable vocabulary — `op="ratio | diff | product"`
|
|
157
|
+
— and selector pieces take `reduce="sum | mean | first | last"` for
|
|
158
|
+
share-of-total style derivations:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
import xarray as xr
|
|
162
|
+
|
|
163
|
+
from xscalers.compose import VariableTransformer
|
|
164
|
+
from xscalers.datasets import load_mixed_grain
|
|
165
|
+
from xscalers.pipeline import Pipeline
|
|
166
|
+
from xscalers.preprocessing import Derive, FunctionTransformer, Splitter, ZScore
|
|
167
|
+
|
|
168
|
+
mixed: xr.Dataset = load_mixed_grain()
|
|
169
|
+
mixed["media_national"].dims # (date, media) — national grain
|
|
170
|
+
mixed["controls_prod"].dims # (date, product) — product grain
|
|
171
|
+
|
|
172
|
+
pipe = Pipeline(
|
|
173
|
+
[
|
|
174
|
+
(
|
|
175
|
+
"derive",
|
|
176
|
+
Derive(
|
|
177
|
+
name="tv_per_control",
|
|
178
|
+
left=("media_national", {"media": "tv"}),
|
|
179
|
+
right=("controls_prod", {}), # {} = the whole variable
|
|
180
|
+
op="ratio",
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
("log", FunctionTransformer({"tv_per_control": "log"})), # named funcs
|
|
184
|
+
("scale", VariableTransformer({"tv_per_control": ZScore(dims=("date",))})),
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
state = pipe.fit(mixed)
|
|
188
|
+
scaled = pipe.transform(mixed, state)
|
|
189
|
+
scaled["tv_per_control"].dims # (date, product) — broadcast, then scaled
|
|
190
|
+
|
|
191
|
+
back = pipe.inverse_transform(scaled, state)
|
|
192
|
+
import numpy as np
|
|
193
|
+
np.testing.assert_allclose(back["controls_prod"], mixed["controls_prod"])
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Because the z-score reduces over `date` only, each product keeps its own
|
|
197
|
+
mean and standard deviation — the per-variable, per-group treatment that
|
|
198
|
+
mixed-grain data needs, with no reshaping.
|
|
199
|
+
|
|
200
|
+
`inverse_transform` drops the derived variable and restores the inputs — so
|
|
201
|
+
the whole chain (derive → log → z-score — unzscore → exp → un-derive)
|
|
202
|
+
round-trips. Note that a log makes the target path non-affine: `pipe.affine()`
|
|
203
|
+
will refuse such pipelines, and real-terms contributions must go through the
|
|
204
|
+
batched `inverse_transform` instead of the `(β, scale)` shortcut.
|
|
205
|
+
|
|
206
|
+
The same step derives within one variable too, when the variable carries an
|
|
207
|
+
extra coordinate dimension — `controls (date, product, control)` with
|
|
208
|
+
`control = [level, reference, seasonality]`:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
# continues above — `mixed` carries over
|
|
212
|
+
from xscalers.preprocessing import Derive
|
|
213
|
+
|
|
214
|
+
share = Derive(
|
|
215
|
+
name="level_diff", # op="diff" this time:
|
|
216
|
+
left=("controls", {"control": "level"}), # level minus reference,
|
|
217
|
+
right=("controls", {"control": "reference"}),
|
|
218
|
+
op="diff", # not a ratio
|
|
219
|
+
)
|
|
220
|
+
derived = share.transform(mixed, share.fit(mixed))
|
|
221
|
+
derived["level_diff"].dims # (date, product)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Dropping variables and coordinate values
|
|
225
|
+
|
|
226
|
+
`VariableDropper` is the drop verb — the xarray answer to sklego's `ColumnDropper`.
|
|
227
|
+
`vars=` removes whole variables; `labels=` removes values from a coordinate
|
|
228
|
+
dataset-wide (the complement of keeping, which is `Selector`'s job):
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
# continues above — `mixed` carries over
|
|
232
|
+
from xscalers.preprocessing import VariableDropper
|
|
233
|
+
|
|
234
|
+
prune = VariableDropper(vars=["media_national", "controls_prod"], labels={"control": ["reference"]})
|
|
235
|
+
pruned = prune.transform(mixed, prune.fit(mixed))
|
|
236
|
+
"media_national" in pruned.data_vars # False — raw inputs are gone
|
|
237
|
+
"reference" in pruned.coords["control"].values # False — every variable shrank with it
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The motivating shape is pruning raw inputs once a derived metric supersedes
|
|
241
|
+
them, so later steps never have to enumerate what they don't care about:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
# continues above — `mixed` carries over
|
|
245
|
+
from xscalers.compose import VariableTransformer
|
|
246
|
+
from xscalers.pipeline import Pipeline
|
|
247
|
+
from xscalers.preprocessing import Derive, VariableDropper, ZScore
|
|
248
|
+
|
|
249
|
+
pipe = Pipeline(
|
|
250
|
+
[
|
|
251
|
+
(
|
|
252
|
+
"derive",
|
|
253
|
+
Derive(
|
|
254
|
+
name="tv_per_control",
|
|
255
|
+
left=("media_national", {"media": "tv"}),
|
|
256
|
+
right=("controls_prod", {}),
|
|
257
|
+
op="ratio",
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
("prune", VariableDropper(vars=["media_national", "controls_prod"])),
|
|
261
|
+
("scale", VariableTransformer({"tv_per_control": ZScore(dims=("date",))})),
|
|
262
|
+
]
|
|
263
|
+
)
|
|
264
|
+
model_ready = pipe.fit_transform(mixed)
|
|
265
|
+
sorted(model_ready.data_vars) # raw inputs pruned, ratio kept
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Dropping destroys data, so — like `Selector` — `VariableDropper.inverse_transform`
|
|
269
|
+
raises instead of guessing, and `pipe.inverse_transform` refuses for the whole
|
|
270
|
+
chain. Real-terms conversion is unaffected: `pipe.unscale(contrib,
|
|
271
|
+
var="tv_per_control")` still walks that one variable backwards. `fit` validates
|
|
272
|
+
rather than failing late (sklego's contract): unknown names raise `KeyError`,
|
|
273
|
+
dropping *every* variable raises `ValueError`, and a non-`Dataset` input raises
|
|
274
|
+
`TypeError` (DataTree node drops are deferred).
|
|
275
|
+
|
|
276
|
+
If you need the reverse, a pipeline that scales what it keeps, `Splitter` is
|
|
277
|
+
the invertible way to split one variable into differently-treated pieces, and
|
|
278
|
+
`VariableTransformer(..., remainder="drop")` is the leftover-dropping sibling.
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
## Splitting one variable into differently-treated pieces
|
|
282
|
+
|
|
283
|
+
Different treatment *within* one media variable (digital vs TV) works by
|
|
284
|
+
splitting into pieces, scaling each, then recombining. Real inputs are a
|
|
285
|
+
plain Dataset of variables, so pieces become *sibling variables*
|
|
286
|
+
(`media_digital`, `media_linear`), each keeping its slice of the coordinate
|
|
287
|
+
under a piece-specific dim name; `inverse_transform` recombines and restores
|
|
288
|
+
the original coordinate order:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from xscalers.compose import VariableTransformer
|
|
292
|
+
from xscalers.datasets import load_media_channels
|
|
293
|
+
from xscalers.pipeline import Pipeline
|
|
294
|
+
from xscalers.preprocessing import FixedScaler, MeanScaler, Splitter
|
|
295
|
+
|
|
296
|
+
ds = load_media_channels()
|
|
297
|
+
|
|
298
|
+
pipe = Pipeline(
|
|
299
|
+
[
|
|
300
|
+
(
|
|
301
|
+
"split",
|
|
302
|
+
Splitter(
|
|
303
|
+
"media",
|
|
304
|
+
{
|
|
305
|
+
"digital": {"channel": ["search", "social", "fb"]},
|
|
306
|
+
"linear": {"channel": ["tv", "stream"]},
|
|
307
|
+
},
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
(
|
|
311
|
+
"scale",
|
|
312
|
+
VariableTransformer(
|
|
313
|
+
{
|
|
314
|
+
"media_digital": MeanScaler(dims=("date",)),
|
|
315
|
+
"media_linear": FixedScaler(scale=1_000),
|
|
316
|
+
},
|
|
317
|
+
),
|
|
318
|
+
),
|
|
319
|
+
]
|
|
320
|
+
)
|
|
321
|
+
state = pipe.fit(ds)
|
|
322
|
+
scaled = pipe.transform(ds, state)
|
|
323
|
+
|
|
324
|
+
back = pipe.inverse_transform(scaled, state) # pieces recombine; coords restored
|
|
325
|
+
import numpy as np
|
|
326
|
+
np.testing.assert_allclose(back["media"], ds["media"])
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Saving the fitted state
|
|
330
|
+
|
|
331
|
+
State is **data** — saving it is native xarray IO, in any of the three shapes
|
|
332
|
+
you'll meet in practice:
|
|
333
|
+
|
|
334
|
+
**1. A standalone one-file artifact** — `to_netcdf`, next to your recipe:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
import os
|
|
338
|
+
import xarray as xr
|
|
339
|
+
import numpy as np
|
|
340
|
+
from xscalers.datasets import load_national
|
|
341
|
+
from xscalers.preprocessing import MeanScaler
|
|
342
|
+
|
|
343
|
+
national: xr.Dataset = load_national()
|
|
344
|
+
scaler = MeanScaler(dims=("date",))
|
|
345
|
+
state = scaler.fit(national["media"])
|
|
346
|
+
|
|
347
|
+
state.to_netcdf("media_state.nc") # state is a Dataset — native IO
|
|
348
|
+
reloaded = xr.open_dataset("media_state.nc") # ...read back natively
|
|
349
|
+
scaled = scaler.transform(national["media"], reloaded)
|
|
350
|
+
back = scaler.inverse_transform(scaled, reloaded)
|
|
351
|
+
np.testing.assert_allclose(back, national["media"])
|
|
352
|
+
os.remove("media_state.nc") # tidy up after this example
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**2. Alongside your (large) zarr inputs** — append the state tree as groups
|
|
356
|
+
of the existing store; `append_state` exists only because
|
|
357
|
+
`DataTree.to_zarr(group=...)` is not implemented upstream — reading back is
|
|
358
|
+
native:
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
# continues above — `xr`, `mixed` and the fitted `transformer` carry over
|
|
362
|
+
from pathlib import Path
|
|
363
|
+
from xscalers.compose import VariableTransformer
|
|
364
|
+
from xscalers.container import state_tree
|
|
365
|
+
from xscalers.datasets import load_mixed_grain
|
|
366
|
+
from xscalers.persistence import append_state
|
|
367
|
+
from xscalers.preprocessing import MeanScaler
|
|
368
|
+
|
|
369
|
+
store: Path = Path("inputs.zarr") # your existing input store
|
|
370
|
+
mixed: xr.Dataset = load_mixed_grain() # pretend it already lives there
|
|
371
|
+
mixed.to_zarr(store) # ... here we create it first
|
|
372
|
+
|
|
373
|
+
transformer: VariableTransformer = VariableTransformer({"media_national": MeanScaler(dims=("date",))}, remainder="drop")
|
|
374
|
+
state = transformer.fit(mixed)
|
|
375
|
+
append_state(state_tree({"scale": state}), store, group="scalers") # state under "scale"
|
|
376
|
+
|
|
377
|
+
import xarray as xr
|
|
378
|
+
reloaded_tree = xr.open_datatree(store, group="scalers")
|
|
379
|
+
reloaded_state = {
|
|
380
|
+
name: node.dataset for name, node in reloaded_tree.children["scale"].children.items()
|
|
381
|
+
}
|
|
382
|
+
scaled_again = transformer.transform(mixed, reloaded_state)
|
|
383
|
+
import shutil; shutil.rmtree(store) # tidy up after this example
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**3. Next to a pymc model** — the state and the `InferenceData` are separate
|
|
387
|
+
artifacts sharing a directory; recipe (JSON via `to_spec`) is versioned
|
|
388
|
+
config, state is data, never the two mixed:
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
# requires-pymc
|
|
392
|
+
import pymc as pm
|
|
393
|
+
import arviz as az
|
|
394
|
+
from xscalers.datasets import load_national
|
|
395
|
+
from xscalers.preprocessing import MeanScaler
|
|
396
|
+
|
|
397
|
+
national = load_national()
|
|
398
|
+
scaler = MeanScaler(dims=("date",))
|
|
399
|
+
state = scaler.fit(national["target"])
|
|
400
|
+
y = scaler.transform(national["target"], state) # scaled input into the model
|
|
401
|
+
|
|
402
|
+
import xarray as xr
|
|
403
|
+
with pm.Model() as model:
|
|
404
|
+
beta = pm.Normal("beta", 0, 1)
|
|
405
|
+
sigma = pm.HalfNormal("sigma", 1)
|
|
406
|
+
# numpy views into the scaled dataset operate the same as scaled arrays
|
|
407
|
+
pm.Normal("obs", beta * float(y.mean()), sigma, observed=np.asarray(y))
|
|
408
|
+
idata = pm.sample_prior_predictive(draws=10, random_seed=0)
|
|
409
|
+
|
|
410
|
+
import numpy as np
|
|
411
|
+
idata.to_netcdf("posterior.nc") # model results...
|
|
412
|
+
state.to_netcdf("scaler_state.nc") # ...and its scaling, side by side
|
|
413
|
+
idata_back = xr.open_datatree("posterior.nc") # DataTree in, DataTree out
|
|
414
|
+
state_back = xr.open_dataset("scaler_state.nc")
|
|
415
|
+
|
|
416
|
+
# real-terms conversion: the affine pair (center, scale) — MeanScaler is
|
|
417
|
+
# divisive, so its composed view is (center=0, scale=mean):
|
|
418
|
+
posterior_beta: float = float(idata_back["prior"]["beta"].mean())
|
|
419
|
+
contribution_scaled: float = posterior_beta * float(np.asarray(y).mean())
|
|
420
|
+
contributions_original_scale = contribution_scaled * float(state_back["mean"])
|
|
421
|
+
import os
|
|
422
|
+
for name in ("posterior.nc", "scaler_state.nc"):
|
|
423
|
+
os.remove(name)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The fitted pipeline serializes as *recipe + artifact*:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
# continues above — `state` carries over
|
|
430
|
+
import os
|
|
431
|
+
from xscalers.container import state_tree
|
|
432
|
+
|
|
433
|
+
state_tree({"scale": state}).to_netcdf("fitted_state.nc") # whole pipeline: one artifact
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Converting back to real units — one verb
|
|
437
|
+
|
|
438
|
+
Model code never hand-writes `× scale + center`: the affine pair is an
|
|
439
|
+
*internal* detail. `Pipeline.unscale` applies the composed pair per variable
|
|
440
|
+
(eager xarray contributions or symbolic `pytensor.xtensor` in the graph);
|
|
441
|
+
when a pipeline is non-affine (e.g. a log-derived metric), `unscale` walks
|
|
442
|
+
only *that variable's own* steps backwards, stopping at the step that created
|
|
443
|
+
it — so the one call stays honest where hand-written `β·scale` algebra goes
|
|
444
|
+
silently wrong:
|
|
445
|
+
|
|
446
|
+
```python
|
|
447
|
+
import numpy as np
|
|
448
|
+
import xarray as xr
|
|
449
|
+
from xscalers.compose import VariableTransformer
|
|
450
|
+
from xscalers.datasets import load_mixed_grain
|
|
451
|
+
from xscalers.pipeline import Pipeline
|
|
452
|
+
from xscalers.preprocessing import Derive, FixedScaler, FunctionTransformer, ZScore
|
|
453
|
+
import os
|
|
454
|
+
|
|
455
|
+
pipe = Pipeline(
|
|
456
|
+
[
|
|
457
|
+
(
|
|
458
|
+
"derive",
|
|
459
|
+
Derive(
|
|
460
|
+
name="tv_per_control",
|
|
461
|
+
left=("media_national", {"media": "tv"}),
|
|
462
|
+
right=("controls_prod", {}),
|
|
463
|
+
op="ratio",
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
("log", FunctionTransformer({"tv_per_control": "log"})),
|
|
467
|
+
("scale", VariableTransformer({"tv_per_control": FixedScaler(scale=1.0)})),
|
|
468
|
+
]
|
|
469
|
+
)
|
|
470
|
+
state = pipe.fit(load_mixed_grain())
|
|
471
|
+
contrib = xr.DataArray(np.full((104, 4), -0.75), dims=("date", "product"))
|
|
472
|
+
|
|
473
|
+
y_real = pipe.unscale(contrib, var="tv_per_control") # one verb; located = exp(−0.75)
|
|
474
|
+
np.testing.assert_allclose(y_real, np.exp(contrib))
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## The flagship flow: fit on xarray, unscale a pymc graph
|
|
478
|
+
|
|
479
|
+
Requires the optional `pytensor` extra (`pip install "xscalers[pytensor]"`).
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
import pytensor.xtensor as ptx
|
|
483
|
+
from xscalers.compose import VariableTransformer
|
|
484
|
+
from xscalers.datasets import load_national
|
|
485
|
+
from xscalers.pipeline import Pipeline
|
|
486
|
+
from xscalers.preprocessing import MeanScaler
|
|
487
|
+
|
|
488
|
+
scaler = MeanScaler(dims=("date",))
|
|
489
|
+
data = load_national()
|
|
490
|
+
pipe = Pipeline([("scale", VariableTransformer({"media": scaler}))])
|
|
491
|
+
state = pipe.fit(data) # fitted pipeline state
|
|
492
|
+
|
|
493
|
+
contrib = ptx.xtensor("channel_contrib", dims=("date", "channel"))
|
|
494
|
+
spend_real = pipe.unscale(contrib, var="media", state=state) # symbolic: contrib * mean
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
After sampling, contributions arrive as xarray with `(chain, draw, ...)` —
|
|
498
|
+
the same state broadcasts over those extra dims by name.
|
|
499
|
+
|
|
500
|
+
## Writing it out: post-fit step access
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
from xscalers.datasets import load_national
|
|
504
|
+
from xscalers.pipeline import make_pipeline
|
|
505
|
+
from xscalers.preprocessing import MeanScaler
|
|
506
|
+
|
|
507
|
+
pipe = make_pipeline(MeanScaler(dims=("date",)), MeanScaler(dims=("date",)))
|
|
508
|
+
assert [name for name, _ in pipe.steps] == ["meanscaler", "meanscaler-1"]
|
|
509
|
+
pipe.fit(load_national()) # sklearn-style fitted pipeline
|
|
510
|
+
|
|
511
|
+
bound = pipe["meanscaler"] # transformer + its fitted state slice
|
|
512
|
+
scaled_again = bound.transform(load_national()) # one fitted step on fresh data
|
|
513
|
+
back = bound.inverse_transform(scaled_again) # ...and its inverse
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## For sklearn users
|
|
517
|
+
|
|
518
|
+
The vocabulary is deliberately sklearn-shaped:
|
|
519
|
+
|
|
520
|
+
| sklearn | xscalers | notes |
|
|
521
|
+
| --- | --- | --- |
|
|
522
|
+
| `fit` / `transform` / `inverse_transform` | same | states are `xr.Dataset`s, not attributes |
|
|
523
|
+
| `ColumnTransformer` | `VariableTransformer` | per-variable treatment over named data variables |
|
|
524
|
+
| `ColumnDropper` (sklego) | `VariableDropper` | over `drop_vars` / `drop_sel`; `Selector` covers the keeping side |
|
|
525
|
+
| `Pipeline` | `Pipeline` | named steps, chained states, reversed inverse |
|
|
526
|
+
| `make_pipeline` | `make_pipeline` | the composition verb (no `>>` chaining: it hides the recipe/state split) |
|
|
527
|
+
| `FeatureUnion` | `make_union` | many pipelines over one dataset, collision policy, covariates-only by design |
|
|
528
|
+
| `"passthrough"` | `"passthrough"` | identical semantics |
|
|
529
|
+
|
|
530
|
+
The deliberate departure: state is **explicit and passed around**, not hidden
|
|
531
|
+
as `scaler.mean_`, which is what lets you edit fitted values and reuse them
|
|
532
|
+
across backends. The counterpart to `transform` for model code is
|
|
533
|
+
**`unscale`**: one verb, correct for affine pipelines and honest for
|
|
534
|
+
non-affine ones.
|
|
535
|
+
|
|
536
|
+
## Install
|
|
537
|
+
|
|
538
|
+
```text
|
|
539
|
+
pip install xscalers # core (xarray, numpy)
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
## License
|
|
543
|
+
|
|
544
|
+
MIT
|