scales-python 1.4.0.9000__py3-none-any.whl
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.
- scales/__init__.py +295 -0
- scales/_colors.py +272 -0
- scales/_palettes_data.py +595 -0
- scales/_utils.py +579 -0
- scales/bounds.py +512 -0
- scales/breaks.py +627 -0
- scales/breaks_log.py +268 -0
- scales/colour_manip.py +681 -0
- scales/colour_mapping.py +593 -0
- scales/colour_ramp.py +126 -0
- scales/labels.py +2144 -0
- scales/minor_breaks.py +197 -0
- scales/palettes.py +1328 -0
- scales/py.typed +0 -0
- scales/range.py +223 -0
- scales/scale_continuous.py +146 -0
- scales/scale_discrete.py +196 -0
- scales/transforms.py +1338 -0
- scales_python-1.4.0.9000.dist-info/METADATA +73 -0
- scales_python-1.4.0.9000.dist-info/RECORD +22 -0
- scales_python-1.4.0.9000.dist-info/WHEEL +4 -0
- scales_python-1.4.0.9000.dist-info/licenses/LICENSE +3 -0
scales/transforms.py
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scale transformations for continuous data.
|
|
3
|
+
|
|
4
|
+
Python port of the R *scales* package transform system, covering:
|
|
5
|
+
- ``R/transform.R``
|
|
6
|
+
- ``R/transform-numeric.R``
|
|
7
|
+
- ``R/transform-compose.R``
|
|
8
|
+
- ``R/transform-date.R``
|
|
9
|
+
|
|
10
|
+
Each transform is a lightweight object that bundles a forward transform,
|
|
11
|
+
its inverse, optional derivatives, break-generation logic, and a label
|
|
12
|
+
formatter. Pre-built transforms are available for common cases (log,
|
|
13
|
+
sqrt, reverse, Box--Cox, Yeo--Johnson, etc.) and new transforms can be
|
|
14
|
+
created with :func:`new_transform`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from functools import reduce
|
|
22
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import ArrayLike
|
|
26
|
+
|
|
27
|
+
from .breaks import breaks_extended
|
|
28
|
+
from .minor_breaks import regular_minor_breaks
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Core API
|
|
32
|
+
"Transform",
|
|
33
|
+
"new_transform",
|
|
34
|
+
"is_transform",
|
|
35
|
+
"as_transform",
|
|
36
|
+
"trans_breaks",
|
|
37
|
+
"trans_format",
|
|
38
|
+
# Transform constructors
|
|
39
|
+
"transform_identity",
|
|
40
|
+
"transform_log",
|
|
41
|
+
"transform_log10",
|
|
42
|
+
"transform_log2",
|
|
43
|
+
"transform_log1p",
|
|
44
|
+
"transform_exp",
|
|
45
|
+
"transform_sqrt",
|
|
46
|
+
"transform_reverse",
|
|
47
|
+
"transform_reciprocal",
|
|
48
|
+
"transform_asinh",
|
|
49
|
+
"transform_asn",
|
|
50
|
+
"transform_atanh",
|
|
51
|
+
"transform_boxcox",
|
|
52
|
+
"transform_modulus",
|
|
53
|
+
"transform_yj",
|
|
54
|
+
"transform_pseudo_log",
|
|
55
|
+
"transform_logit",
|
|
56
|
+
"transform_probit",
|
|
57
|
+
"transform_probability",
|
|
58
|
+
"transform_date",
|
|
59
|
+
"transform_time",
|
|
60
|
+
"transform_timespan",
|
|
61
|
+
"transform_compose",
|
|
62
|
+
# Legacy aliases
|
|
63
|
+
"trans_new",
|
|
64
|
+
"identity_trans",
|
|
65
|
+
"log_trans",
|
|
66
|
+
"log10_trans",
|
|
67
|
+
"log2_trans",
|
|
68
|
+
"log1p_trans",
|
|
69
|
+
"exp_trans",
|
|
70
|
+
"sqrt_trans",
|
|
71
|
+
"reverse_trans",
|
|
72
|
+
"reciprocal_trans",
|
|
73
|
+
"asinh_trans",
|
|
74
|
+
"asn_trans",
|
|
75
|
+
"atanh_trans",
|
|
76
|
+
"boxcox_trans",
|
|
77
|
+
"modulus_trans",
|
|
78
|
+
"yj_trans",
|
|
79
|
+
"pseudo_log_trans",
|
|
80
|
+
"logit_trans",
|
|
81
|
+
"probit_trans",
|
|
82
|
+
"probability_trans",
|
|
83
|
+
"date_trans",
|
|
84
|
+
"time_trans",
|
|
85
|
+
"timespan_trans",
|
|
86
|
+
"transform_hms",
|
|
87
|
+
"hms_trans",
|
|
88
|
+
"compose_trans",
|
|
89
|
+
"is_trans",
|
|
90
|
+
"as_trans",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Helper: lightweight pretty breaks (fallback when breaks modules absent)
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _pretty_breaks(n: int = 5) -> Callable:
|
|
99
|
+
"""Return a break-generator using Wilkinson's extended algorithm.
|
|
100
|
+
|
|
101
|
+
Mirrors R's ``new_transform`` default
|
|
102
|
+
``breaks = extended_breaks()`` — i.e. ``labeling::extended`` in R,
|
|
103
|
+
:func:`breaks_extended` here. Pure-numpy; no matplotlib dependency.
|
|
104
|
+
"""
|
|
105
|
+
extended = breaks_extended(n=n)
|
|
106
|
+
|
|
107
|
+
def _breaks(limits: Tuple[float, float]) -> np.ndarray:
|
|
108
|
+
return extended(np.asarray(limits, dtype=float))
|
|
109
|
+
|
|
110
|
+
return _breaks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _log_breaks(base: float = 10, n: int = 5) -> Callable:
|
|
114
|
+
"""Return a break-generator suitable for log-scaled data."""
|
|
115
|
+
def _breaks(limits: Tuple[float, float]) -> np.ndarray:
|
|
116
|
+
lo, hi = float(limits[0]), float(limits[1])
|
|
117
|
+
if lo <= 0:
|
|
118
|
+
lo = 1e-10
|
|
119
|
+
if hi <= 0:
|
|
120
|
+
hi = 1.0
|
|
121
|
+
lo_exp = np.floor(np.log(lo) / np.log(base))
|
|
122
|
+
hi_exp = np.ceil(np.log(hi) / np.log(base))
|
|
123
|
+
by = max(1, np.round((hi_exp - lo_exp) / n))
|
|
124
|
+
exponents = np.arange(lo_exp, hi_exp + by, by)
|
|
125
|
+
return base ** exponents
|
|
126
|
+
return _breaks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _default_format() -> Callable:
|
|
130
|
+
"""Return a simple label formatter (converts to string)."""
|
|
131
|
+
def _fmt(x: np.ndarray) -> list[str]:
|
|
132
|
+
out: list[str] = []
|
|
133
|
+
for v in np.asarray(x).flat:
|
|
134
|
+
if np.isnan(v):
|
|
135
|
+
out.append("NA")
|
|
136
|
+
else:
|
|
137
|
+
# Remove trailing zeros for cleanliness
|
|
138
|
+
s = f"{v:g}"
|
|
139
|
+
out.append(s)
|
|
140
|
+
return out
|
|
141
|
+
return _fmt
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Transform class
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
class Transform:
|
|
149
|
+
"""A scale transformation bundling forward/inverse functions and metadata.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
name : str
|
|
154
|
+
Human-readable name for the transform.
|
|
155
|
+
transform_func : callable
|
|
156
|
+
Forward transform ``f(x) -> y``.
|
|
157
|
+
inverse_func : callable
|
|
158
|
+
Inverse transform ``g(y) -> x``.
|
|
159
|
+
d_transform : callable or None
|
|
160
|
+
Derivative of the forward transform.
|
|
161
|
+
d_inverse : callable or None
|
|
162
|
+
Derivative of the inverse transform.
|
|
163
|
+
breaks_func : callable
|
|
164
|
+
Function ``(limits) -> array`` that generates axis breaks.
|
|
165
|
+
minor_breaks_func : callable or None
|
|
166
|
+
Function for generating minor breaks.
|
|
167
|
+
format_func : callable
|
|
168
|
+
Label formatter ``(x) -> list[str]``.
|
|
169
|
+
domain : tuple of float
|
|
170
|
+
``(min, max)`` valid input domain for the forward transform.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
__slots__ = (
|
|
174
|
+
"name",
|
|
175
|
+
"transform_func",
|
|
176
|
+
"inverse_func",
|
|
177
|
+
"d_transform",
|
|
178
|
+
"d_inverse",
|
|
179
|
+
"breaks_func",
|
|
180
|
+
"minor_breaks_func",
|
|
181
|
+
"format_func",
|
|
182
|
+
"domain",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
name: str,
|
|
188
|
+
transform_func: Callable,
|
|
189
|
+
inverse_func: Callable,
|
|
190
|
+
d_transform: Optional[Callable] = None,
|
|
191
|
+
d_inverse: Optional[Callable] = None,
|
|
192
|
+
breaks_func: Optional[Callable] = None,
|
|
193
|
+
minor_breaks_func: Optional[Callable] = None,
|
|
194
|
+
format_func: Optional[Callable] = None,
|
|
195
|
+
domain: Tuple[float, float] = (-np.inf, np.inf),
|
|
196
|
+
) -> None:
|
|
197
|
+
self.name = name
|
|
198
|
+
self.transform_func = transform_func
|
|
199
|
+
self.inverse_func = inverse_func
|
|
200
|
+
self.d_transform = d_transform
|
|
201
|
+
self.d_inverse = d_inverse
|
|
202
|
+
self.breaks_func = breaks_func if breaks_func is not None else _pretty_breaks(5)
|
|
203
|
+
self.minor_breaks_func = minor_breaks_func
|
|
204
|
+
self.format_func = format_func if format_func is not None else _default_format()
|
|
205
|
+
self.domain = (float(domain[0]), float(domain[1]))
|
|
206
|
+
|
|
207
|
+
# Convenience: call the forward / inverse directly
|
|
208
|
+
def transform(self, x: ArrayLike) -> np.ndarray:
|
|
209
|
+
"""Apply the forward transformation."""
|
|
210
|
+
arr = np.asarray(x)
|
|
211
|
+
# Pass string / object / timedelta64 / datetime64 arrays through
|
|
212
|
+
# unchanged — transforms like hms / date parse them themselves.
|
|
213
|
+
if arr.dtype.kind in ("U", "S", "O", "m", "M"):
|
|
214
|
+
return np.asarray(self.transform_func(arr))
|
|
215
|
+
return np.asarray(self.transform_func(np.asarray(x, dtype=float)))
|
|
216
|
+
|
|
217
|
+
def inverse(self, x: ArrayLike) -> np.ndarray:
|
|
218
|
+
"""Apply the inverse transformation."""
|
|
219
|
+
arr = np.asarray(x)
|
|
220
|
+
if arr.dtype.kind in ("U", "S", "O", "m", "M"):
|
|
221
|
+
return np.asarray(self.inverse_func(arr))
|
|
222
|
+
return np.asarray(self.inverse_func(np.asarray(x, dtype=float)))
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return f"Transform: {self.name}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Constructor
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def new_transform(
|
|
233
|
+
name: str,
|
|
234
|
+
transform: Callable,
|
|
235
|
+
inverse: Callable,
|
|
236
|
+
d_transform: Optional[Callable] = None,
|
|
237
|
+
d_inverse: Optional[Callable] = None,
|
|
238
|
+
breaks: Optional[Callable] = None,
|
|
239
|
+
minor_breaks: Optional[Callable] = None,
|
|
240
|
+
format: Optional[Callable] = None,
|
|
241
|
+
domain: Tuple[float, float] = (-np.inf, np.inf),
|
|
242
|
+
) -> Transform:
|
|
243
|
+
"""Create a new :class:`Transform` object.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
name : str
|
|
248
|
+
Human-readable name.
|
|
249
|
+
transform : callable
|
|
250
|
+
Forward transform.
|
|
251
|
+
inverse : callable
|
|
252
|
+
Inverse transform.
|
|
253
|
+
d_transform : callable, optional
|
|
254
|
+
Derivative of forward transform.
|
|
255
|
+
d_inverse : callable, optional
|
|
256
|
+
Derivative of inverse transform.
|
|
257
|
+
breaks : callable, optional
|
|
258
|
+
Break-generation function ``(limits) -> array``.
|
|
259
|
+
minor_breaks : callable, optional
|
|
260
|
+
Minor-break-generation function.
|
|
261
|
+
format : callable, optional
|
|
262
|
+
Label formatter ``(x) -> list[str]``.
|
|
263
|
+
domain : tuple of float, optional
|
|
264
|
+
Valid input range for the forward transform.
|
|
265
|
+
Default ``(-inf, inf)``.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
Transform
|
|
270
|
+
"""
|
|
271
|
+
return Transform(
|
|
272
|
+
name=name,
|
|
273
|
+
transform_func=transform,
|
|
274
|
+
inverse_func=inverse,
|
|
275
|
+
d_transform=d_transform,
|
|
276
|
+
d_inverse=d_inverse,
|
|
277
|
+
breaks_func=breaks,
|
|
278
|
+
minor_breaks_func=minor_breaks,
|
|
279
|
+
format_func=format,
|
|
280
|
+
domain=domain,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Legacy alias
|
|
285
|
+
trans_new = new_transform
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Introspection helpers
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def is_transform(x: Any) -> bool:
|
|
293
|
+
"""Return ``True`` if *x* is a :class:`Transform` instance."""
|
|
294
|
+
return isinstance(x, Transform)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# Legacy alias
|
|
298
|
+
is_trans = is_transform
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# Transform registry / coercion
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
# Populated lazily at first call to ``as_transform``.
|
|
306
|
+
_REGISTRY: Dict[str, Callable[[], Transform]] = {}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _ensure_registry() -> None:
|
|
310
|
+
"""Populate the name -> factory mapping (once)."""
|
|
311
|
+
if _REGISTRY:
|
|
312
|
+
return
|
|
313
|
+
_REGISTRY.update({
|
|
314
|
+
"identity": transform_identity,
|
|
315
|
+
"log": transform_log,
|
|
316
|
+
"log10": transform_log10,
|
|
317
|
+
"log2": transform_log2,
|
|
318
|
+
"log1p": transform_log1p,
|
|
319
|
+
"exp": transform_exp,
|
|
320
|
+
"sqrt": transform_sqrt,
|
|
321
|
+
"reverse": transform_reverse,
|
|
322
|
+
"reciprocal": transform_reciprocal,
|
|
323
|
+
"asinh": transform_asinh,
|
|
324
|
+
"asn": transform_asn,
|
|
325
|
+
"atanh": transform_atanh,
|
|
326
|
+
"logit": transform_logit,
|
|
327
|
+
"probit": transform_probit,
|
|
328
|
+
"pseudo_log": transform_pseudo_log,
|
|
329
|
+
"date": transform_date,
|
|
330
|
+
"time": transform_time,
|
|
331
|
+
"timespan": transform_timespan,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def as_transform(x: Union[str, Transform]) -> Transform:
|
|
336
|
+
"""Coerce *x* to a :class:`Transform`.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
x : str or Transform
|
|
341
|
+
If a string, look up the corresponding built-in transform by
|
|
342
|
+
name. If already a :class:`Transform`, return as-is.
|
|
343
|
+
|
|
344
|
+
Returns
|
|
345
|
+
-------
|
|
346
|
+
Transform
|
|
347
|
+
|
|
348
|
+
Raises
|
|
349
|
+
------
|
|
350
|
+
TypeError
|
|
351
|
+
If *x* is neither a string nor a :class:`Transform`.
|
|
352
|
+
ValueError
|
|
353
|
+
If no built-in transform matches the given name.
|
|
354
|
+
"""
|
|
355
|
+
if isinstance(x, Transform):
|
|
356
|
+
return x
|
|
357
|
+
if isinstance(x, str):
|
|
358
|
+
_ensure_registry()
|
|
359
|
+
# Try exact match, then with common suffixes stripped
|
|
360
|
+
key = x.lower().replace("-", "_")
|
|
361
|
+
if key in _REGISTRY:
|
|
362
|
+
return _REGISTRY[key]()
|
|
363
|
+
# Try stripping "_trans" / "transform_" suffixes/prefixes
|
|
364
|
+
for prefix in ("transform_", ""):
|
|
365
|
+
for suffix in ("_trans", "_transform", ""):
|
|
366
|
+
candidate = key.removeprefix(prefix).removesuffix(suffix)
|
|
367
|
+
if candidate in _REGISTRY:
|
|
368
|
+
return _REGISTRY[candidate]()
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"Unknown transform name {x!r}. Available: "
|
|
371
|
+
f"{sorted(_REGISTRY.keys())}"
|
|
372
|
+
)
|
|
373
|
+
raise TypeError(
|
|
374
|
+
f"Cannot coerce {type(x).__name__!r} to a Transform; "
|
|
375
|
+
f"expected a string or Transform instance."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Legacy alias
|
|
380
|
+
as_trans = as_transform
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# trans_breaks / trans_format
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def trans_breaks(
|
|
388
|
+
trans: Union[str, Transform],
|
|
389
|
+
n: int = 5,
|
|
390
|
+
offset: float = 0,
|
|
391
|
+
) -> Callable:
|
|
392
|
+
"""Generate breaks in transformed space then map back.
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
trans : str or Transform
|
|
397
|
+
The transform to use.
|
|
398
|
+
n : int, optional
|
|
399
|
+
Desired number of breaks (default 5).
|
|
400
|
+
offset : float, optional
|
|
401
|
+
Additive offset applied after transforming limits and removed
|
|
402
|
+
before inverse-transforming breaks (default 0).
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
callable
|
|
407
|
+
A function ``(limits) -> np.ndarray`` that returns break
|
|
408
|
+
positions in the original (data) space.
|
|
409
|
+
"""
|
|
410
|
+
t = as_transform(trans)
|
|
411
|
+
inner_breaks = _pretty_breaks(n)
|
|
412
|
+
|
|
413
|
+
def _breaks(limits: Tuple[float, float]) -> np.ndarray:
|
|
414
|
+
tlimits = t.transform(np.array(limits))
|
|
415
|
+
breaks_t = inner_breaks((tlimits[0] + offset, tlimits[1] + offset))
|
|
416
|
+
return t.inverse(np.asarray(breaks_t) - offset)
|
|
417
|
+
|
|
418
|
+
return _breaks
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def trans_format(
|
|
422
|
+
trans: Union[str, Transform],
|
|
423
|
+
format: Optional[Callable] = None,
|
|
424
|
+
) -> Callable:
|
|
425
|
+
"""Format labels using the inverse of *trans*.
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
trans : str or Transform
|
|
430
|
+
The transform whose inverse maps breaks back to data space.
|
|
431
|
+
format : callable, optional
|
|
432
|
+
A formatter ``(x) -> list[str]``. When *None*, the transform's
|
|
433
|
+
own :attr:`format_func` is used.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
callable
|
|
438
|
+
A function ``(x) -> list[str]`` that first inverse-transforms *x*
|
|
439
|
+
and then formats the result.
|
|
440
|
+
"""
|
|
441
|
+
t = as_transform(trans)
|
|
442
|
+
fmt = format if format is not None else t.format_func
|
|
443
|
+
|
|
444
|
+
def _format(x: ArrayLike) -> list[str]:
|
|
445
|
+
inv = t.inverse(np.asarray(x, dtype=float))
|
|
446
|
+
return fmt(inv)
|
|
447
|
+
|
|
448
|
+
return _format
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ===========================================================================
|
|
452
|
+
# Built-in transforms
|
|
453
|
+
# ===========================================================================
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Identity
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def transform_identity() -> Transform:
|
|
460
|
+
"""No-op (identity) transform."""
|
|
461
|
+
return new_transform(
|
|
462
|
+
name="identity",
|
|
463
|
+
transform=lambda x: x,
|
|
464
|
+
inverse=lambda x: x,
|
|
465
|
+
d_transform=lambda x: np.ones_like(x),
|
|
466
|
+
d_inverse=lambda x: np.ones_like(x),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
identity_trans = transform_identity
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# Log family
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def transform_log(base: float = np.e) -> Transform:
|
|
478
|
+
"""Logarithmic transform with arbitrary *base*.
|
|
479
|
+
|
|
480
|
+
Parameters
|
|
481
|
+
----------
|
|
482
|
+
base : float, optional
|
|
483
|
+
Logarithm base (default ``e``).
|
|
484
|
+
"""
|
|
485
|
+
log_base = np.log(base)
|
|
486
|
+
|
|
487
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
488
|
+
return np.log(x) / log_base
|
|
489
|
+
|
|
490
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
491
|
+
return base ** x
|
|
492
|
+
|
|
493
|
+
def _d_fwd(x: np.ndarray) -> np.ndarray:
|
|
494
|
+
return 1.0 / (x * log_base)
|
|
495
|
+
|
|
496
|
+
def _d_inv(x: np.ndarray) -> np.ndarray:
|
|
497
|
+
return base ** x * log_base
|
|
498
|
+
|
|
499
|
+
name = "log" if base == np.e else f"log-{base:g}"
|
|
500
|
+
return new_transform(
|
|
501
|
+
name=name,
|
|
502
|
+
transform=_fwd,
|
|
503
|
+
inverse=_inv,
|
|
504
|
+
d_transform=_d_fwd,
|
|
505
|
+
d_inverse=_d_inv,
|
|
506
|
+
breaks=_log_breaks(base=base),
|
|
507
|
+
domain=(0, np.inf),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
log_trans = transform_log
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def transform_log10() -> Transform:
|
|
515
|
+
"""Base-10 logarithmic transform."""
|
|
516
|
+
return transform_log(base=10)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
log10_trans = transform_log10
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def transform_log2() -> Transform:
|
|
523
|
+
"""Base-2 logarithmic transform."""
|
|
524
|
+
return transform_log(base=2)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
log2_trans = transform_log2
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def transform_log1p() -> Transform:
|
|
531
|
+
"""``log(1 + x)`` transform."""
|
|
532
|
+
return new_transform(
|
|
533
|
+
name="log1p",
|
|
534
|
+
transform=np.log1p,
|
|
535
|
+
inverse=np.expm1,
|
|
536
|
+
d_transform=lambda x: 1.0 / (1.0 + x),
|
|
537
|
+
d_inverse=lambda x: np.exp(x),
|
|
538
|
+
domain=(-1, np.inf),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
log1p_trans = transform_log1p
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
# Exponential
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def transform_exp(base: float = np.e) -> Transform:
|
|
550
|
+
"""Exponential transform (inverse of :func:`transform_log`).
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
base : float, optional
|
|
555
|
+
Exponent base (default ``e``).
|
|
556
|
+
"""
|
|
557
|
+
log_base = np.log(base)
|
|
558
|
+
|
|
559
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
560
|
+
return base ** x
|
|
561
|
+
|
|
562
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
563
|
+
return np.log(x) / log_base
|
|
564
|
+
|
|
565
|
+
name = "exp" if base == np.e else f"power-{base:g}"
|
|
566
|
+
return new_transform(
|
|
567
|
+
name=name,
|
|
568
|
+
transform=_fwd,
|
|
569
|
+
inverse=_inv,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
exp_trans = transform_exp
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
# Power / root
|
|
578
|
+
# ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def transform_sqrt() -> Transform:
|
|
581
|
+
"""Square-root transform (domain ``[0, inf]``)."""
|
|
582
|
+
return new_transform(
|
|
583
|
+
name="sqrt",
|
|
584
|
+
transform=np.sqrt,
|
|
585
|
+
inverse=np.square,
|
|
586
|
+
d_transform=lambda x: 0.5 / np.sqrt(x),
|
|
587
|
+
d_inverse=lambda x: 2.0 * x,
|
|
588
|
+
domain=(0, np.inf),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
sqrt_trans = transform_sqrt
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ---------------------------------------------------------------------------
|
|
596
|
+
# Reverse
|
|
597
|
+
# ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
def transform_reverse() -> Transform:
|
|
600
|
+
"""Negate values (reverse the axis direction).
|
|
601
|
+
|
|
602
|
+
Matches R's ``transform_reverse``: uses
|
|
603
|
+
``regular_minor_breaks(reverse=True)`` so minor ticks extend toward
|
|
604
|
+
the numerically *smaller* side of each major, suitable for reversed
|
|
605
|
+
axes.
|
|
606
|
+
"""
|
|
607
|
+
return new_transform(
|
|
608
|
+
name="reverse",
|
|
609
|
+
transform=lambda x: -x,
|
|
610
|
+
inverse=lambda x: -x,
|
|
611
|
+
d_transform=lambda x: np.full_like(np.asarray(x, dtype=float), -1.0),
|
|
612
|
+
d_inverse=lambda x: np.full_like(np.asarray(x, dtype=float), -1.0),
|
|
613
|
+
minor_breaks=regular_minor_breaks(reverse=True),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
reverse_trans = transform_reverse
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
# Reciprocal
|
|
622
|
+
# ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
def transform_reciprocal() -> Transform:
|
|
625
|
+
"""Reciprocal transform ``1 / x``."""
|
|
626
|
+
return new_transform(
|
|
627
|
+
name="reciprocal",
|
|
628
|
+
transform=lambda x: 1.0 / x,
|
|
629
|
+
inverse=lambda x: 1.0 / x,
|
|
630
|
+
d_transform=lambda x: -1.0 / x ** 2,
|
|
631
|
+
d_inverse=lambda x: -1.0 / x ** 2,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
reciprocal_trans = transform_reciprocal
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
# Hyperbolic / trigonometric
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def transform_asinh() -> Transform:
|
|
643
|
+
"""Inverse hyperbolic sine transform."""
|
|
644
|
+
return new_transform(
|
|
645
|
+
name="asinh",
|
|
646
|
+
transform=np.arcsinh,
|
|
647
|
+
inverse=np.sinh,
|
|
648
|
+
d_transform=lambda x: 1.0 / np.sqrt(x ** 2 + 1),
|
|
649
|
+
d_inverse=lambda x: np.cosh(x),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
asinh_trans = transform_asinh
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def transform_asn() -> Transform:
|
|
657
|
+
"""Arc-sine-square-root transform: ``asin(sqrt(x))``.
|
|
658
|
+
|
|
659
|
+
Domain ``[0, 1]``.
|
|
660
|
+
"""
|
|
661
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
662
|
+
return 2.0 * np.arcsin(np.sqrt(x))
|
|
663
|
+
|
|
664
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
665
|
+
return np.sin(x / 2.0) ** 2
|
|
666
|
+
|
|
667
|
+
return new_transform(
|
|
668
|
+
name="asn",
|
|
669
|
+
transform=_fwd,
|
|
670
|
+
inverse=_inv,
|
|
671
|
+
domain=(0, 1),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
asn_trans = transform_asn
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def transform_atanh() -> Transform:
|
|
679
|
+
"""Inverse hyperbolic tangent transform.
|
|
680
|
+
|
|
681
|
+
Domain ``(-1, 1)``.
|
|
682
|
+
"""
|
|
683
|
+
return new_transform(
|
|
684
|
+
name="atanh",
|
|
685
|
+
transform=np.arctanh,
|
|
686
|
+
inverse=np.tanh,
|
|
687
|
+
d_transform=lambda x: 1.0 / (1.0 - x ** 2),
|
|
688
|
+
d_inverse=lambda x: 1.0 / np.cosh(x) ** 2,
|
|
689
|
+
domain=(-1, 1),
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
atanh_trans = transform_atanh
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ---------------------------------------------------------------------------
|
|
697
|
+
# Box-Cox
|
|
698
|
+
# ---------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
def transform_boxcox(p: float, offset: float = 0) -> Transform:
|
|
701
|
+
"""Box--Cox power transform.
|
|
702
|
+
|
|
703
|
+
Parameters
|
|
704
|
+
----------
|
|
705
|
+
p : float
|
|
706
|
+
Transformation parameter (power). When ``p == 0`` the transform
|
|
707
|
+
reduces to ``log(x + offset)``.
|
|
708
|
+
offset : float, optional
|
|
709
|
+
Additive offset applied before transformation (default 0).
|
|
710
|
+
|
|
711
|
+
Notes
|
|
712
|
+
-----
|
|
713
|
+
When ``p != 0``: ``((x + offset)^p - 1) / p``.
|
|
714
|
+
When ``p == 0``: ``log(x + offset)``.
|
|
715
|
+
Domain is ``[max(0, -offset), inf)``.
|
|
716
|
+
"""
|
|
717
|
+
if p == 0:
|
|
718
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
719
|
+
return np.log(x + offset)
|
|
720
|
+
|
|
721
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
722
|
+
return np.exp(x) - offset
|
|
723
|
+
|
|
724
|
+
def _d_fwd(x: np.ndarray) -> np.ndarray:
|
|
725
|
+
return 1.0 / (x + offset)
|
|
726
|
+
|
|
727
|
+
def _d_inv(x: np.ndarray) -> np.ndarray:
|
|
728
|
+
return np.exp(x)
|
|
729
|
+
else:
|
|
730
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
731
|
+
return ((x + offset) ** p - 1.0) / p
|
|
732
|
+
|
|
733
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
734
|
+
return (x * p + 1.0) ** (1.0 / p) - offset
|
|
735
|
+
|
|
736
|
+
def _d_fwd(x: np.ndarray) -> np.ndarray:
|
|
737
|
+
return (x + offset) ** (p - 1.0)
|
|
738
|
+
|
|
739
|
+
def _d_inv(x: np.ndarray) -> np.ndarray:
|
|
740
|
+
return (x * p + 1.0) ** (1.0 / p - 1.0)
|
|
741
|
+
|
|
742
|
+
domain_lo = max(0.0, -offset)
|
|
743
|
+
return new_transform(
|
|
744
|
+
name=f"boxcox-{p:g}" if offset == 0 else f"boxcox-{p:g}-{offset:g}",
|
|
745
|
+
transform=_fwd,
|
|
746
|
+
inverse=_inv,
|
|
747
|
+
d_transform=_d_fwd,
|
|
748
|
+
d_inverse=_d_inv,
|
|
749
|
+
domain=(domain_lo, np.inf),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
boxcox_trans = transform_boxcox
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
# Modulus (sign-preserving Box-Cox)
|
|
758
|
+
# ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
def transform_modulus(p: float, offset: float = 1) -> Transform:
|
|
761
|
+
"""Modulus transform (sign-preserving Box--Cox).
|
|
762
|
+
|
|
763
|
+
Parameters
|
|
764
|
+
----------
|
|
765
|
+
p : float
|
|
766
|
+
Power parameter.
|
|
767
|
+
offset : float, optional
|
|
768
|
+
Offset applied inside the absolute value (default 1).
|
|
769
|
+
|
|
770
|
+
Notes
|
|
771
|
+
-----
|
|
772
|
+
When ``p != 0``: ``sign(x) * ((|x| + offset)^p - 1) / p``.
|
|
773
|
+
When ``p == 0``: ``sign(x) * log(|x| + offset)``.
|
|
774
|
+
"""
|
|
775
|
+
if offset < 0:
|
|
776
|
+
raise ValueError("offset must be non-negative for modulus transform")
|
|
777
|
+
|
|
778
|
+
if p == 0:
|
|
779
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
780
|
+
return np.sign(x) * np.log(np.abs(x) + offset)
|
|
781
|
+
|
|
782
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
783
|
+
return np.sign(x) * (np.exp(np.abs(x)) - offset)
|
|
784
|
+
else:
|
|
785
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
786
|
+
return np.sign(x) * ((np.abs(x) + offset) ** p - 1.0) / p
|
|
787
|
+
|
|
788
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
789
|
+
return np.sign(x) * ((np.abs(x) * p + 1.0) ** (1.0 / p) - offset)
|
|
790
|
+
|
|
791
|
+
return new_transform(
|
|
792
|
+
name=f"modulus-{p:g}-{offset:g}",
|
|
793
|
+
transform=_fwd,
|
|
794
|
+
inverse=_inv,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
modulus_trans = transform_modulus
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
# Yeo-Johnson
|
|
803
|
+
# ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
def transform_yj(p: float) -> Transform:
|
|
806
|
+
"""Yeo--Johnson power transform.
|
|
807
|
+
|
|
808
|
+
Parameters
|
|
809
|
+
----------
|
|
810
|
+
p : float
|
|
811
|
+
Transformation parameter.
|
|
812
|
+
|
|
813
|
+
Notes
|
|
814
|
+
-----
|
|
815
|
+
For ``x >= 0``:
|
|
816
|
+
- ``p != 0``: ``((x + 1)^p - 1) / p``
|
|
817
|
+
- ``p == 0``: ``log(x + 1)``
|
|
818
|
+
For ``x < 0``:
|
|
819
|
+
- ``p != 2``: ``-((-x + 1)^(2 - p) - 1) / (2 - p)``
|
|
820
|
+
- ``p == 2``: ``-log(-x + 1)``
|
|
821
|
+
"""
|
|
822
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
823
|
+
out = np.empty_like(x, dtype=float)
|
|
824
|
+
pos = x >= 0
|
|
825
|
+
neg = ~pos
|
|
826
|
+
|
|
827
|
+
if p != 0:
|
|
828
|
+
out[pos] = ((x[pos] + 1.0) ** p - 1.0) / p
|
|
829
|
+
else:
|
|
830
|
+
out[pos] = np.log(x[pos] + 1.0)
|
|
831
|
+
|
|
832
|
+
if p != 2:
|
|
833
|
+
out[neg] = -((-x[neg] + 1.0) ** (2.0 - p) - 1.0) / (2.0 - p)
|
|
834
|
+
else:
|
|
835
|
+
out[neg] = -np.log(-x[neg] + 1.0)
|
|
836
|
+
|
|
837
|
+
return out
|
|
838
|
+
|
|
839
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
840
|
+
out = np.empty_like(x, dtype=float)
|
|
841
|
+
pos = x >= 0
|
|
842
|
+
neg = ~pos
|
|
843
|
+
|
|
844
|
+
if p != 0:
|
|
845
|
+
out[pos] = (x[pos] * p + 1.0) ** (1.0 / p) - 1.0
|
|
846
|
+
else:
|
|
847
|
+
out[pos] = np.exp(x[pos]) - 1.0
|
|
848
|
+
|
|
849
|
+
if p != 2:
|
|
850
|
+
out[neg] = 1.0 - (-(2.0 - p) * x[neg] + 1.0) ** (1.0 / (2.0 - p))
|
|
851
|
+
else:
|
|
852
|
+
# Matches R's inv_neg(x) = 1 - exp(-x). For x < 0, exp(-x) > 1,
|
|
853
|
+
# so the expression is already ≤ 0 — no sign patch needed.
|
|
854
|
+
out[neg] = 1.0 - np.exp(-x[neg])
|
|
855
|
+
|
|
856
|
+
return out
|
|
857
|
+
|
|
858
|
+
return new_transform(
|
|
859
|
+
name=f"yeo-johnson-{p:g}",
|
|
860
|
+
transform=_fwd,
|
|
861
|
+
inverse=_inv,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
yj_trans = transform_yj
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# ---------------------------------------------------------------------------
|
|
869
|
+
# Pseudo-log
|
|
870
|
+
# ---------------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
def transform_pseudo_log(
|
|
873
|
+
sigma: float = 1, base: float = np.e
|
|
874
|
+
) -> Transform:
|
|
875
|
+
"""Pseudo-log transform (smooth transition around zero).
|
|
876
|
+
|
|
877
|
+
Parameters
|
|
878
|
+
----------
|
|
879
|
+
sigma : float, optional
|
|
880
|
+
Scaling parameter controlling the linear region near zero
|
|
881
|
+
(default 1).
|
|
882
|
+
base : float, optional
|
|
883
|
+
Logarithm base (default ``e``).
|
|
884
|
+
|
|
885
|
+
Notes
|
|
886
|
+
-----
|
|
887
|
+
Forward: ``asinh(x / (2 * sigma)) / log(base)``.
|
|
888
|
+
Inverse: ``2 * sigma * sinh(x * log(base))``.
|
|
889
|
+
"""
|
|
890
|
+
log_base = np.log(base)
|
|
891
|
+
|
|
892
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
893
|
+
return np.arcsinh(x / (2.0 * sigma)) / log_base
|
|
894
|
+
|
|
895
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
896
|
+
return 2.0 * sigma * np.sinh(x * log_base)
|
|
897
|
+
|
|
898
|
+
return new_transform(
|
|
899
|
+
name="pseudo_log",
|
|
900
|
+
transform=_fwd,
|
|
901
|
+
inverse=_inv,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
pseudo_log_trans = transform_pseudo_log
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
# ---------------------------------------------------------------------------
|
|
909
|
+
# Probability transforms (logit, probit, general)
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
|
|
912
|
+
def transform_probability(
|
|
913
|
+
distribution: str, *args: Any, **kwargs: Any
|
|
914
|
+
) -> Transform:
|
|
915
|
+
"""Probability transform using a ``scipy.stats`` distribution.
|
|
916
|
+
|
|
917
|
+
Mirrors R's ``scales::transform_probability``: forward is the
|
|
918
|
+
quantile function (``q<dist>`` / ``ppf``), inverse is the CDF
|
|
919
|
+
(``p<dist>`` / ``cdf``). ``d_transform`` is ``1 / pdf(ppf(x))`` and
|
|
920
|
+
``d_inverse`` is ``pdf(x)``.
|
|
921
|
+
|
|
922
|
+
Parameters
|
|
923
|
+
----------
|
|
924
|
+
distribution : str
|
|
925
|
+
Name of a distribution in :mod:`scipy.stats` (e.g. ``"norm"``,
|
|
926
|
+
``"logistic"``).
|
|
927
|
+
*args, **kwargs
|
|
928
|
+
Extra arguments forwarded to the ``scipy.stats`` distribution
|
|
929
|
+
constructor.
|
|
930
|
+
|
|
931
|
+
Returns
|
|
932
|
+
-------
|
|
933
|
+
Transform
|
|
934
|
+
Domain ``(0, 1)``.
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
import scipy.stats as st
|
|
938
|
+
except ImportError as exc:
|
|
939
|
+
raise ImportError(
|
|
940
|
+
"transform_probability requires scipy. "
|
|
941
|
+
"Install it with: pip install scipy"
|
|
942
|
+
) from exc
|
|
943
|
+
|
|
944
|
+
dist = getattr(st, distribution)(*args, **kwargs)
|
|
945
|
+
|
|
946
|
+
return new_transform(
|
|
947
|
+
name=f"prob-{distribution}",
|
|
948
|
+
transform=lambda x: dist.ppf(x),
|
|
949
|
+
inverse=lambda x: dist.cdf(x),
|
|
950
|
+
d_transform=lambda x: 1.0 / dist.pdf(dist.ppf(x)),
|
|
951
|
+
d_inverse=lambda x: dist.pdf(x),
|
|
952
|
+
domain=(0, 1),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
probability_trans = transform_probability
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def transform_logit() -> Transform:
|
|
960
|
+
"""Logit transform: ``log(x / (1 - x))``.
|
|
961
|
+
|
|
962
|
+
Domain ``(0, 1)``.
|
|
963
|
+
"""
|
|
964
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
965
|
+
return np.log(x / (1.0 - x))
|
|
966
|
+
|
|
967
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
968
|
+
ex = np.exp(x)
|
|
969
|
+
return ex / (1.0 + ex)
|
|
970
|
+
|
|
971
|
+
def _d_fwd(x: np.ndarray) -> np.ndarray:
|
|
972
|
+
return 1.0 / (x * (1.0 - x))
|
|
973
|
+
|
|
974
|
+
def _d_inv(x: np.ndarray) -> np.ndarray:
|
|
975
|
+
ex = np.exp(x)
|
|
976
|
+
return ex / (1.0 + ex) ** 2
|
|
977
|
+
|
|
978
|
+
return new_transform(
|
|
979
|
+
name="logit",
|
|
980
|
+
transform=_fwd,
|
|
981
|
+
inverse=_inv,
|
|
982
|
+
d_transform=_d_fwd,
|
|
983
|
+
d_inverse=_d_inv,
|
|
984
|
+
domain=(0, 1),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
logit_trans = transform_logit
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def transform_probit() -> Transform:
|
|
992
|
+
"""Probit transform (normal quantile function).
|
|
993
|
+
|
|
994
|
+
Domain ``(0, 1)``. Requires ``scipy``.
|
|
995
|
+
"""
|
|
996
|
+
try:
|
|
997
|
+
from scipy.stats import norm as _norm
|
|
998
|
+
except ImportError as exc:
|
|
999
|
+
raise ImportError(
|
|
1000
|
+
"transform_probit requires scipy. "
|
|
1001
|
+
"Install it with: pip install scipy"
|
|
1002
|
+
) from exc
|
|
1003
|
+
|
|
1004
|
+
return new_transform(
|
|
1005
|
+
name="probit",
|
|
1006
|
+
transform=lambda x: _norm.ppf(x),
|
|
1007
|
+
inverse=lambda x: _norm.cdf(x),
|
|
1008
|
+
domain=(0, 1),
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
probit_trans = transform_probit
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
# ---------------------------------------------------------------------------
|
|
1016
|
+
# Date / time transforms
|
|
1017
|
+
# ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
# Reference epoch for date conversions (days since 1970-01-01)
|
|
1020
|
+
_EPOCH = np.datetime64("1970-01-01", "D")
|
|
1021
|
+
_EPOCH_NS = np.datetime64("1970-01-01T00:00:00", "ns")
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def transform_date() -> Transform:
|
|
1025
|
+
"""Transform between :class:`datetime.date` / ``datetime64[D]`` and numeric.
|
|
1026
|
+
|
|
1027
|
+
Forward maps dates to float (days since 1970-01-01).
|
|
1028
|
+
Inverse maps float back to ``numpy.datetime64[D]``.
|
|
1029
|
+
"""
|
|
1030
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
1031
|
+
x = np.asarray(x)
|
|
1032
|
+
if np.issubdtype(x.dtype, np.datetime64):
|
|
1033
|
+
return (x - _EPOCH) / np.timedelta64(1, "D")
|
|
1034
|
+
# Already numeric
|
|
1035
|
+
return np.asarray(x, dtype=float)
|
|
1036
|
+
|
|
1037
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
1038
|
+
x = np.asarray(x, dtype=float)
|
|
1039
|
+
return _EPOCH + (x * np.timedelta64(1, "D")).astype("timedelta64[D]")
|
|
1040
|
+
|
|
1041
|
+
def _date_format(x: np.ndarray) -> list[str]:
|
|
1042
|
+
dates = _inv(np.asarray(x, dtype=float))
|
|
1043
|
+
return [str(d) for d in np.asarray(dates).flat]
|
|
1044
|
+
|
|
1045
|
+
return Transform(
|
|
1046
|
+
name="date",
|
|
1047
|
+
transform_func=_fwd,
|
|
1048
|
+
inverse_func=_inv,
|
|
1049
|
+
format_func=_date_format,
|
|
1050
|
+
breaks_func=_pretty_breaks(5),
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
date_trans = transform_date
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def transform_time(tz: Optional[str] = None) -> Transform:
|
|
1058
|
+
"""Transform between ``datetime64`` and numeric (seconds since epoch).
|
|
1059
|
+
|
|
1060
|
+
Parameters
|
|
1061
|
+
----------
|
|
1062
|
+
tz : str or None, optional
|
|
1063
|
+
Timezone name (informational; NumPy datetimes are always UTC).
|
|
1064
|
+
"""
|
|
1065
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
1066
|
+
x = np.asarray(x)
|
|
1067
|
+
if np.issubdtype(x.dtype, np.datetime64):
|
|
1068
|
+
return (x - _EPOCH_NS) / np.timedelta64(1, "s")
|
|
1069
|
+
return np.asarray(x, dtype=float)
|
|
1070
|
+
|
|
1071
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
1072
|
+
x = np.asarray(x, dtype=float)
|
|
1073
|
+
return _EPOCH_NS + (x * np.timedelta64(1, "s")).astype("timedelta64[ns]")
|
|
1074
|
+
|
|
1075
|
+
def _time_format(x: np.ndarray) -> list[str]:
|
|
1076
|
+
dts = _inv(np.asarray(x, dtype=float))
|
|
1077
|
+
return [str(d) for d in np.asarray(dts).flat]
|
|
1078
|
+
|
|
1079
|
+
return Transform(
|
|
1080
|
+
name="time",
|
|
1081
|
+
transform_func=_fwd,
|
|
1082
|
+
inverse_func=_inv,
|
|
1083
|
+
format_func=_time_format,
|
|
1084
|
+
breaks_func=_pretty_breaks(5),
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
time_trans = transform_time
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def transform_timespan(unit: str = "secs") -> Transform:
|
|
1092
|
+
"""Transform between ``timedelta64`` and numeric.
|
|
1093
|
+
|
|
1094
|
+
Parameters
|
|
1095
|
+
----------
|
|
1096
|
+
unit : str, optional
|
|
1097
|
+
Time unit for the numeric representation. One of ``"secs"``,
|
|
1098
|
+
``"mins"``, ``"hours"``, ``"days"``, ``"weeks"`` (default
|
|
1099
|
+
``"secs"``).
|
|
1100
|
+
"""
|
|
1101
|
+
_unit_map = {
|
|
1102
|
+
"secs": ("s", 1.0),
|
|
1103
|
+
"mins": ("s", 60.0),
|
|
1104
|
+
"hours": ("s", 3600.0),
|
|
1105
|
+
"days": ("D", 1.0),
|
|
1106
|
+
"weeks": ("D", 7.0),
|
|
1107
|
+
}
|
|
1108
|
+
if unit not in _unit_map:
|
|
1109
|
+
raise ValueError(
|
|
1110
|
+
f"Unknown unit {unit!r}; choose from {sorted(_unit_map)}"
|
|
1111
|
+
)
|
|
1112
|
+
np_unit, divisor = _unit_map[unit]
|
|
1113
|
+
|
|
1114
|
+
def _fwd(x: np.ndarray) -> np.ndarray:
|
|
1115
|
+
x = np.asarray(x)
|
|
1116
|
+
if np.issubdtype(x.dtype, np.timedelta64):
|
|
1117
|
+
return x / np.timedelta64(1, np_unit) / divisor
|
|
1118
|
+
return np.asarray(x, dtype=float)
|
|
1119
|
+
|
|
1120
|
+
def _inv(x: np.ndarray) -> np.ndarray:
|
|
1121
|
+
x = np.asarray(x, dtype=float)
|
|
1122
|
+
return (x * divisor * np.timedelta64(1, np_unit)).astype(
|
|
1123
|
+
f"timedelta64[{np_unit}]"
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
return Transform(
|
|
1127
|
+
name=f"timespan-{unit}",
|
|
1128
|
+
transform_func=_fwd,
|
|
1129
|
+
inverse_func=_inv,
|
|
1130
|
+
domain=(0, np.inf),
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
timespan_trans = transform_timespan
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def transform_hms() -> Transform:
|
|
1138
|
+
"""Transform clock-time values to/from numeric seconds.
|
|
1139
|
+
|
|
1140
|
+
Mirrors R's ``transform_hms``: forward drops to raw seconds, inverse
|
|
1141
|
+
converts back to an ``"HH:MM:SS"`` string (R uses the ``hms`` class;
|
|
1142
|
+
Python uses a plain string, which is the natural stdlib equivalent).
|
|
1143
|
+
|
|
1144
|
+
Accepted forward inputs:
|
|
1145
|
+
|
|
1146
|
+
* ``float`` / ``int`` — already seconds, returned unchanged.
|
|
1147
|
+
* ``numpy.timedelta64`` — converted to seconds.
|
|
1148
|
+
* ``datetime.time`` — converted via its hour/minute/second fields.
|
|
1149
|
+
* ``str`` in ``"HH:MM:SS"`` or ``"HH:MM:SS.fff"`` — parsed.
|
|
1150
|
+
|
|
1151
|
+
Inverse always returns ``"HH:MM:SS"`` strings, wrapping past 24 h.
|
|
1152
|
+
"""
|
|
1153
|
+
import re
|
|
1154
|
+
from datetime import time as _time
|
|
1155
|
+
|
|
1156
|
+
_pat = re.compile(r"^(\d+):(\d{1,2})(?::(\d{1,2}(?:\.\d+)?))?$")
|
|
1157
|
+
|
|
1158
|
+
def _to_seconds(val: Any) -> float:
|
|
1159
|
+
if val is None:
|
|
1160
|
+
return np.nan
|
|
1161
|
+
if isinstance(val, (int, float, np.integer, np.floating)):
|
|
1162
|
+
return float(val)
|
|
1163
|
+
if isinstance(val, np.timedelta64):
|
|
1164
|
+
return val / np.timedelta64(1, "s")
|
|
1165
|
+
if isinstance(val, _time):
|
|
1166
|
+
return (
|
|
1167
|
+
val.hour * 3600
|
|
1168
|
+
+ val.minute * 60
|
|
1169
|
+
+ val.second
|
|
1170
|
+
+ val.microsecond / 1_000_000
|
|
1171
|
+
)
|
|
1172
|
+
if isinstance(val, str):
|
|
1173
|
+
m = _pat.match(val.strip())
|
|
1174
|
+
if not m:
|
|
1175
|
+
raise ValueError(f"cannot parse {val!r} as HH:MM:SS")
|
|
1176
|
+
h = int(m.group(1))
|
|
1177
|
+
mm = int(m.group(2))
|
|
1178
|
+
ss = float(m.group(3) or 0.0)
|
|
1179
|
+
return h * 3600 + mm * 60 + ss
|
|
1180
|
+
# Last resort: try array
|
|
1181
|
+
return float(val)
|
|
1182
|
+
|
|
1183
|
+
def _fwd(x: ArrayLike) -> np.ndarray:
|
|
1184
|
+
arr = np.asarray(x)
|
|
1185
|
+
if np.issubdtype(arr.dtype, np.timedelta64):
|
|
1186
|
+
# timedelta64 arrays → seconds (float). Division by
|
|
1187
|
+
# np.timedelta64(1, "s") returns a float array even for
|
|
1188
|
+
# non-integer-second inputs (microsecond precision kept).
|
|
1189
|
+
return (arr / np.timedelta64(1, "s")).astype(float)
|
|
1190
|
+
if arr.dtype.kind in ("i", "u", "f"):
|
|
1191
|
+
return arr.astype(float)
|
|
1192
|
+
# Object / string arrays — convert element-wise.
|
|
1193
|
+
out = np.empty(arr.shape, dtype=float)
|
|
1194
|
+
for idx, v in np.ndenumerate(arr):
|
|
1195
|
+
out[idx] = _to_seconds(v)
|
|
1196
|
+
return out
|
|
1197
|
+
|
|
1198
|
+
def _inv(x: ArrayLike) -> np.ndarray:
|
|
1199
|
+
arr = np.asarray(x, dtype=float)
|
|
1200
|
+
out: list[str] = []
|
|
1201
|
+
for v in arr.flat:
|
|
1202
|
+
if not np.isfinite(v):
|
|
1203
|
+
out.append("NA")
|
|
1204
|
+
continue
|
|
1205
|
+
total = float(v)
|
|
1206
|
+
sign = "-" if total < 0 else ""
|
|
1207
|
+
total = abs(total)
|
|
1208
|
+
h = int(total // 3600)
|
|
1209
|
+
rem = total - h * 3600
|
|
1210
|
+
m = int(rem // 60)
|
|
1211
|
+
s = rem - m * 60
|
|
1212
|
+
# Render as HH:MM:SS; seconds keep fractional part when non-zero.
|
|
1213
|
+
if abs(s - round(s)) < 1e-9:
|
|
1214
|
+
out.append(f"{sign}{h:02d}:{m:02d}:{int(round(s)):02d}")
|
|
1215
|
+
else:
|
|
1216
|
+
out.append(f"{sign}{h:02d}:{m:02d}:{s:06.3f}")
|
|
1217
|
+
return np.array(out, dtype=object).reshape(arr.shape)
|
|
1218
|
+
|
|
1219
|
+
return Transform(
|
|
1220
|
+
name="hms",
|
|
1221
|
+
transform_func=_fwd,
|
|
1222
|
+
inverse_func=_inv,
|
|
1223
|
+
domain=(-np.inf, np.inf),
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
hms_trans = transform_hms
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
# ---------------------------------------------------------------------------
|
|
1231
|
+
# Compose
|
|
1232
|
+
# ---------------------------------------------------------------------------
|
|
1233
|
+
|
|
1234
|
+
def transform_compose(*transforms: Union[str, Transform]) -> Transform:
|
|
1235
|
+
"""Compose multiple transforms (applied left to right).
|
|
1236
|
+
|
|
1237
|
+
Faithful port of R's ``scales::transform_compose``:
|
|
1238
|
+
|
|
1239
|
+
* Resolves a conservative domain by pushing the first transform's
|
|
1240
|
+
domain forward through the sequence (intersecting with each
|
|
1241
|
+
transform's own domain at every step) to get the range, then
|
|
1242
|
+
pulling that range back through the inverses.
|
|
1243
|
+
* Composes ``d_transform`` and ``d_inverse`` via the chain rule when
|
|
1244
|
+
*every* transform exposes them; otherwise the composed derivatives
|
|
1245
|
+
are ``None``.
|
|
1246
|
+
* Uses the first transform's ``breaks_func`` for tick generation.
|
|
1247
|
+
|
|
1248
|
+
Parameters
|
|
1249
|
+
----------
|
|
1250
|
+
*transforms : str or Transform
|
|
1251
|
+
One or more transforms to compose. Strings are resolved via
|
|
1252
|
+
:func:`as_transform`.
|
|
1253
|
+
|
|
1254
|
+
Returns
|
|
1255
|
+
-------
|
|
1256
|
+
Transform
|
|
1257
|
+
"""
|
|
1258
|
+
ts = [as_transform(t) for t in transforms]
|
|
1259
|
+
if len(ts) < 1:
|
|
1260
|
+
raise ValueError(
|
|
1261
|
+
"transform_compose must include at least 1 transformer to compose"
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
def _fwd(x: ArrayLike) -> np.ndarray:
|
|
1265
|
+
v = np.asarray(x, dtype=float)
|
|
1266
|
+
for t in ts:
|
|
1267
|
+
v = t.transform(v)
|
|
1268
|
+
return v
|
|
1269
|
+
|
|
1270
|
+
def _inv(x: ArrayLike) -> np.ndarray:
|
|
1271
|
+
v = np.asarray(x, dtype=float)
|
|
1272
|
+
for t in reversed(ts):
|
|
1273
|
+
v = t.inverse(v)
|
|
1274
|
+
return v
|
|
1275
|
+
|
|
1276
|
+
# Domain resolution — matches R's algorithm exactly.
|
|
1277
|
+
t0 = ts[0]
|
|
1278
|
+
rng = np.asarray(
|
|
1279
|
+
t0.transform(np.asarray(t0.domain, dtype=float)), dtype=float
|
|
1280
|
+
)
|
|
1281
|
+
for t in ts[1:]:
|
|
1282
|
+
d_lo, d_hi = float(t.domain[0]), float(t.domain[1])
|
|
1283
|
+
r_lo, r_hi = float(np.min(rng)), float(np.max(rng))
|
|
1284
|
+
lower = max(d_lo, r_lo)
|
|
1285
|
+
upper = min(d_hi, r_hi)
|
|
1286
|
+
if lower <= upper:
|
|
1287
|
+
rng = np.asarray(
|
|
1288
|
+
t.transform(np.asarray([lower, upper], dtype=float)), dtype=float
|
|
1289
|
+
)
|
|
1290
|
+
else:
|
|
1291
|
+
rng = np.array([np.nan, np.nan])
|
|
1292
|
+
break
|
|
1293
|
+
|
|
1294
|
+
# Push range back through inverses to derive composed domain.
|
|
1295
|
+
dom_vals = rng
|
|
1296
|
+
for t in reversed(ts):
|
|
1297
|
+
dom_vals = np.asarray(t.inverse(np.asarray(dom_vals, dtype=float)), dtype=float)
|
|
1298
|
+
|
|
1299
|
+
if np.any(np.isnan(dom_vals)):
|
|
1300
|
+
raise ValueError("Sequence of transformations yields invalid domain")
|
|
1301
|
+
domain = (float(np.min(dom_vals)), float(np.max(dom_vals)))
|
|
1302
|
+
|
|
1303
|
+
has_d_transform = all(t.d_transform is not None for t in ts)
|
|
1304
|
+
has_d_inverse = all(t.d_inverse is not None for t in ts)
|
|
1305
|
+
|
|
1306
|
+
def _d_fwd(x: ArrayLike) -> np.ndarray:
|
|
1307
|
+
# Forward chain rule: derivative = prod_i f'_i(x_i) where x_i is
|
|
1308
|
+
# the value fed into the i-th transform.
|
|
1309
|
+
v = np.asarray(x, dtype=float)
|
|
1310
|
+
deriv = np.ones_like(v, dtype=float)
|
|
1311
|
+
for t in ts:
|
|
1312
|
+
deriv = np.asarray(t.d_transform(v), dtype=float) * deriv
|
|
1313
|
+
v = t.transform(v)
|
|
1314
|
+
return deriv
|
|
1315
|
+
|
|
1316
|
+
def _d_inv(x: ArrayLike) -> np.ndarray:
|
|
1317
|
+
# Inverse chain rule: apply inverses in reverse order.
|
|
1318
|
+
v = np.asarray(x, dtype=float)
|
|
1319
|
+
deriv = np.ones_like(v, dtype=float)
|
|
1320
|
+
for t in reversed(ts):
|
|
1321
|
+
deriv = np.asarray(t.d_inverse(v), dtype=float) * deriv
|
|
1322
|
+
v = t.inverse(v)
|
|
1323
|
+
return deriv
|
|
1324
|
+
|
|
1325
|
+
names = ",".join(t.name for t in ts)
|
|
1326
|
+
|
|
1327
|
+
return new_transform(
|
|
1328
|
+
name=f"composition({names})",
|
|
1329
|
+
transform=_fwd,
|
|
1330
|
+
inverse=_inv,
|
|
1331
|
+
d_transform=_d_fwd if has_d_transform else None,
|
|
1332
|
+
d_inverse=_d_inv if has_d_inverse else None,
|
|
1333
|
+
breaks=t0.breaks_func,
|
|
1334
|
+
domain=domain,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
compose_trans = transform_compose
|