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.
@@ -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
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
37
+ [![Tests](https://github.com/williambdean/xscalers/actions/workflows/ci.yaml/badge.svg)](https://github.com/williambdean/xscalers/actions/workflows/ci.yaml)
38
+ [![PyPI version](https://badge.fury.io/py/xscalers.svg)](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