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