diffract-core 0.2.1__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.
Files changed (193) hide show
  1. diffract/__init__.py +52 -0
  2. diffract/configs/fast_speed_without_disk.ini +48 -0
  3. diffract/configs/hybrid.ini +74 -0
  4. diffract/configs/sqlite.ini +62 -0
  5. diffract/containers.py +405 -0
  6. diffract/core/__init__.py +23 -0
  7. diffract/core/cache/__init__.py +24 -0
  8. diffract/core/cache/containers.py +93 -0
  9. diffract/core/cache/interface.py +117 -0
  10. diffract/core/cache/redis_manager.py +464 -0
  11. diffract/core/cache/simple_manager.py +478 -0
  12. diffract/core/compute/__init__.py +46 -0
  13. diffract/core/compute/config.py +48 -0
  14. diffract/core/compute/containers.py +81 -0
  15. diffract/core/compute/decorator.py +125 -0
  16. diffract/core/compute/exceptions.py +31 -0
  17. diffract/core/compute/execution/__init__.py +14 -0
  18. diffract/core/compute/execution/_types.py +70 -0
  19. diffract/core/compute/execution/aggregation.py +74 -0
  20. diffract/core/compute/execution/aggregation_runner.py +362 -0
  21. diffract/core/compute/execution/enums.py +38 -0
  22. diffract/core/compute/execution/executor.py +125 -0
  23. diffract/core/compute/execution/parameter_runner.py +125 -0
  24. diffract/core/compute/execution/restrictions.py +61 -0
  25. diffract/core/compute/execution/strategy.py +118 -0
  26. diffract/core/compute/execution/utils.py +112 -0
  27. diffract/core/compute/extensions/__init__.py +0 -0
  28. diffract/core/compute/extensions/power_law.py +1051 -0
  29. diffract/core/compute/extensions/rmt.py +150 -0
  30. diffract/core/compute/extensions/utils.py +36 -0
  31. diffract/core/compute/field_signature.py +183 -0
  32. diffract/core/compute/kernels/__init__.py +48 -0
  33. diffract/core/compute/kernels/alignment.py +106 -0
  34. diffract/core/compute/kernels/heavy_tailed.py +394 -0
  35. diffract/core/compute/kernels/marchenko_pastur.py +99 -0
  36. diffract/core/compute/kernels/mat_decomposition.py +101 -0
  37. diffract/core/compute/kernels/mat_properties.py +53 -0
  38. diffract/core/compute/kernels/model_quality.py +27 -0
  39. diffract/core/compute/kernels/norms.py +88 -0
  40. diffract/core/compute/kernels/ranks.py +35 -0
  41. diffract/core/compute/kernels/tracy_widom.py +36 -0
  42. diffract/core/compute/metadata.py +67 -0
  43. diffract/core/compute/registry.py +485 -0
  44. diffract/core/constants.py +147 -0
  45. diffract/core/data/__init__.py +32 -0
  46. diffract/core/data/interface.py +304 -0
  47. diffract/core/data/metadata/__init__.py +15 -0
  48. diffract/core/data/metadata/containers.py +60 -0
  49. diffract/core/data/metadata/interface.py +208 -0
  50. diffract/core/data/metadata/sqlite_index.py +604 -0
  51. diffract/core/data/nn/__init__.py +43 -0
  52. diffract/core/data/nn/aggregates/__init__.py +35 -0
  53. diffract/core/data/nn/aggregates/metadata.py +96 -0
  54. diffract/core/data/nn/aggregates/proxy.py +57 -0
  55. diffract/core/data/nn/aggregates/repository.py +87 -0
  56. diffract/core/data/nn/aggregates/view.py +307 -0
  57. diffract/core/data/nn/containers.py +86 -0
  58. diffract/core/data/nn/extractors/__init__.py +49 -0
  59. diffract/core/data/nn/extractors/base.py +300 -0
  60. diffract/core/data/nn/extractors/factory.py +234 -0
  61. diffract/core/data/nn/extractors/flax.py +112 -0
  62. diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
  63. diffract/core/data/nn/extractors/handlers/base.py +110 -0
  64. diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
  65. diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
  66. diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
  67. diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
  68. diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
  69. diffract/core/data/nn/extractors/interface.py +56 -0
  70. diffract/core/data/nn/extractors/numpy.py +94 -0
  71. diffract/core/data/nn/extractors/onnx.py +108 -0
  72. diffract/core/data/nn/extractors/tensorflow.py +99 -0
  73. diffract/core/data/nn/extractors/torch.py +204 -0
  74. diffract/core/data/nn/params/__init__.py +27 -0
  75. diffract/core/data/nn/params/interface.py +402 -0
  76. diffract/core/data/nn/params/metadata.py +110 -0
  77. diffract/core/data/nn/params/proxy.py +93 -0
  78. diffract/core/data/nn/params/repository.py +45 -0
  79. diffract/core/data/nn/params/schema.py +82 -0
  80. diffract/core/data/nn/params/view.py +393 -0
  81. diffract/core/data/proxy.py +246 -0
  82. diffract/core/data/repository.py +227 -0
  83. diffract/core/data/utils.py +33 -0
  84. diffract/core/data/view.py +389 -0
  85. diffract/core/export/__init__.py +27 -0
  86. diffract/core/export/containers.py +47 -0
  87. diffract/core/export/exporters.py +191 -0
  88. diffract/core/export/formatters/__init__.py +23 -0
  89. diffract/core/export/formatters/base.py +68 -0
  90. diffract/core/export/formatters/dict_formatter.py +38 -0
  91. diffract/core/export/formatters/json_formatter.py +58 -0
  92. diffract/core/export/formatters/list_formatter.py +41 -0
  93. diffract/core/export/formatters/pandas_formatter.py +86 -0
  94. diffract/core/export/formatters/polars_formatter.py +86 -0
  95. diffract/core/export/formatters/registry.py +84 -0
  96. diffract/core/export/interface.py +105 -0
  97. diffract/core/parallel/__init__.py +21 -0
  98. diffract/core/parallel/containers.py +57 -0
  99. diffract/core/parallel/execution.py +91 -0
  100. diffract/core/parallel/runtime.py +91 -0
  101. diffract/core/storage/__init__.py +35 -0
  102. diffract/core/storage/base_manager.py +330 -0
  103. diffract/core/storage/containers.py +122 -0
  104. diffract/core/storage/hdf5_manager.py +856 -0
  105. diffract/core/storage/hybrid_manager.py +218 -0
  106. diffract/core/storage/interface.py +222 -0
  107. diffract/core/storage/metadata.py +89 -0
  108. diffract/core/storage/ram_manager.py +159 -0
  109. diffract/core/storage/sqlite_manager.py +1062 -0
  110. diffract/core/storage/zarr_manager.py +992 -0
  111. diffract/core/utils/__init__.py +45 -0
  112. diffract/core/utils/build.py +46 -0
  113. diffract/core/utils/exceptions.py +11 -0
  114. diffract/core/utils/hashing.py +90 -0
  115. diffract/core/utils/imports.py +292 -0
  116. diffract/core/utils/math.py +15 -0
  117. diffract/py.typed +0 -0
  118. diffract/session/__init__.py +24 -0
  119. diffract/session/errors.py +17 -0
  120. diffract/session/field_cache.py +216 -0
  121. diffract/session/namespaces/__init__.py +15 -0
  122. diffract/session/namespaces/compute/__init__.py +219 -0
  123. diffract/session/namespaces/models/__init__.py +282 -0
  124. diffract/session/namespaces/models/parameters/__init__.py +133 -0
  125. diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
  126. diffract/session/namespaces/results/__init__.py +378 -0
  127. diffract/session/namespaces/results/eraser.py +129 -0
  128. diffract/session/namespaces/results/injester.py +249 -0
  129. diffract/session/namespaces/utils/__init__.py +141 -0
  130. diffract/session/namespaces/utils/merger.py +295 -0
  131. diffract/session/namespaces/viz/__init__.py +91 -0
  132. diffract/session/namespaces/viz/_utils.py +14 -0
  133. diffract/session/namespaces/viz/box.py +207 -0
  134. diffract/session/namespaces/viz/grid.py +160 -0
  135. diffract/session/namespaces/viz/heatmap.py +164 -0
  136. diffract/session/namespaces/viz/scatter.py +202 -0
  137. diffract/session/namespaces/viz/sparkline.py +270 -0
  138. diffract/session/namespaces/viz/violin.py +212 -0
  139. diffract/session/session.py +260 -0
  140. diffract/session/utils.py +81 -0
  141. diffract/viz/__init__.py +42 -0
  142. diffract/viz/data/__init__.py +34 -0
  143. diffract/viz/data/detection.py +57 -0
  144. diffract/viz/data/extraction.py +117 -0
  145. diffract/viz/data/filtering.py +57 -0
  146. diffract/viz/data/ordering.py +129 -0
  147. diffract/viz/data/provider.py +99 -0
  148. diffract/viz/data/types.py +98 -0
  149. diffract/viz/plots/__init__.py +37 -0
  150. diffract/viz/plots/base/__init__.py +20 -0
  151. diffract/viz/plots/base/axis.py +219 -0
  152. diffract/viz/plots/base/coloraxis.py +133 -0
  153. diffract/viz/plots/base/configurator.py +18 -0
  154. diffract/viz/plots/base/jitter.py +368 -0
  155. diffract/viz/plots/base/line.py +197 -0
  156. diffract/viz/plots/base/marker.py +278 -0
  157. diffract/viz/plots/base/overlay.py +14 -0
  158. diffract/viz/plots/base/plot.py +110 -0
  159. diffract/viz/plots/base/update.py +50 -0
  160. diffract/viz/plots/boxplot.py +283 -0
  161. diffract/viz/plots/cluster.py +374 -0
  162. diffract/viz/plots/heatmap.py +168 -0
  163. diffract/viz/plots/scatter.py +233 -0
  164. diffract/viz/plots/sparkline.py +405 -0
  165. diffract/viz/plots/subplots/__init__.py +32 -0
  166. diffract/viz/plots/subplots/coloraxis.py +339 -0
  167. diffract/viz/plots/subplots/factory.py +713 -0
  168. diffract/viz/plots/subplots/grid.py +214 -0
  169. diffract/viz/plots/subplots/layout.py +370 -0
  170. diffract/viz/plots/subplots/spec.py +39 -0
  171. diffract/viz/plots/violin.py +298 -0
  172. diffract/viz/renderer.py +513 -0
  173. diffract/viz/styling/__init__.py +78 -0
  174. diffract/viz/styling/palettes/__init__.py +20 -0
  175. diffract/viz/styling/palettes/bundle.py +16 -0
  176. diffract/viz/styling/palettes/color.py +83 -0
  177. diffract/viz/styling/palettes/dashes.py +27 -0
  178. diffract/viz/styling/palettes/symbols.py +42 -0
  179. diffract/viz/styling/resolvers/__init__.py +11 -0
  180. diffract/viz/styling/resolvers/categorical.py +68 -0
  181. diffract/viz/styling/resolvers/color.py +77 -0
  182. diffract/viz/styling/resolvers/numeric.py +108 -0
  183. diffract/viz/styling/sources.py +27 -0
  184. diffract/viz/styling/theme/__init__.py +29 -0
  185. diffract/viz/styling/theme/applier.py +114 -0
  186. diffract/viz/styling/theme/components.py +66 -0
  187. diffract/viz/styling/theme/presets.py +58 -0
  188. diffract/viz/styling/theme/theme.py +36 -0
  189. diffract_core-0.2.1.dist-info/METADATA +530 -0
  190. diffract_core-0.2.1.dist-info/RECORD +193 -0
  191. diffract_core-0.2.1.dist-info/WHEEL +4 -0
  192. diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
  193. diffract_core-0.2.1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,1051 @@
1
+ """Accelerated heavy-tailed distribution fitting with taichi.
2
+
3
+ Implements the fitting procedure of Clauset, Shalizi & Newman (2009) for
4
+ power-law, truncated power-law and exponential tails: maximum-likelihood
5
+ parameter estimates at every xmin candidate, KS-optimal xmin selection,
6
+ and the semi-parametric bootstrap p-value of section 4.1. The scan over
7
+ xmin candidates and the bootstrap replicas are parallelized with taichi.
8
+
9
+ Input data is normalized internally by a power-of-two scale close to its
10
+ median (exact in floating point), so the parameter bounds below are
11
+ relative to the data scale; reported parameters are always in the
12
+ original units. The supported domain is pl_alpha in (1, 8] and
13
+ expon_lambda * scale >= 1e-4; tails outside it (e.g. a power law steeper
14
+ than alpha = 8) yield NaN parameters rather than a clamped estimate.
15
+
16
+ One documented simplification: bootstrap replicas are re-fitted at the
17
+ original xmin (per-replica xmin re-selection is skipped for tractability).
18
+ This biases p-values upward: the test has low power against misspecified
19
+ alternatives and should be used to confirm a plausible fit, not to select
20
+ between candidate distributions.
21
+
22
+ All randomness derives from a counter-based splitmix64 hash keyed by the
23
+ ``seed`` constructor argument, so results are deterministic per call and
24
+ identical across machines and thread schedules.
25
+
26
+ The module owns the process-wide taichi runtime, initialized on import
27
+ with the CPU backend: Metal lacks the f64 arithmetic and the field
28
+ lifecycle this module needs, and the workload is too branchy and too
29
+ small for CUDA to pay off.
30
+
31
+ Taichi fields are pooled process-wide by data size padded to a power of
32
+ two, and the kernels are module-level functions templated on those pooled
33
+ fields. A process that runs thousands of fits therefore compiles each
34
+ kernel a bounded number of times (per pool entry) instead of once per
35
+ fit, and repeated fits skip the per-instance compilation cost entirely.
36
+ :meth:`Fit.close` returns the borrowed fields to the pool; a closed
37
+ instance raises RuntimeError. Up to MAX_MAIN_POOLS_PER_SIZE concurrent
38
+ same-bucket fits share a bucket's entries; the large bootstrap pools are
39
+ capped at MAX_BOOTSTRAP_POOLS (least recently used ones are released),
40
+ and sizes above MAX_POOLED_SIZE / MAX_POOLED_BOOTSTRAP_SIZE are never
41
+ pooled. Outside those limits a fit still works but uses private fields:
42
+ it recompiles its kernels (~0.2 s) and taichi retains the compiled
43
+ artifacts (~2 MB) for the process lifetime.
44
+ """
45
+
46
+ import math
47
+ import threading
48
+ from typing import Any, Literal
49
+
50
+ import numpy as np
51
+
52
+ import diffract.core.utils.imports as import_utils
53
+
54
+ ti = import_utils.require("taichi")
55
+
56
+ ti.init(arch=ti.cpu)
57
+
58
+ # params bounds
59
+ MIN_ALPHA = 1.0
60
+ MAX_ALPHA = 8.0 # empirically determined
61
+ MIN_LAMBDA = 1e-4 # empirically determined
62
+ MIN_KS_DISTANCE = 1e-4 # empirically determined
63
+
64
+ # optimizer
65
+ OPTIM_N_ITERS = 1000
66
+ OBJ_FUNC_THRESHOLD = 1e-6
67
+ BOUNDS_PENALTY = 1e30
68
+ SIMPLEX_SIZE = 3 # 2 parameters + 1
69
+
70
+ # other constants
71
+ P_VALUE_EPS = 1e-2
72
+ P_VALUE_THRESHOLD = 0.1 # from Clauset et al.
73
+ MIN_TAIL_SIZE = 50 # from Clauset et al.
74
+ MIN_BOOTSTRAP_TAIL_SIZE = 2
75
+ MAX_REJECTION_ATTEMPTS = 4096
76
+ F32_SAMPLE_CAP = 3.0e38 # just under f32 max
77
+ MAX_P_VALUE_ELEMENTS = 500_000_000 # ~2 GB of f32 bootstrap samples
78
+
79
+ # field pooling
80
+ MIN_POOL_SIZE = 64
81
+ MAX_POOLED_SIZE = 65_536
82
+ MAX_MAIN_POOLS_PER_SIZE = 4
83
+ MAX_BOOTSTRAP_POOLS = 2
84
+ MAX_POOLED_BOOTSTRAP_SIZE = 16_384
85
+
86
+ BAD_RESULT = -1
87
+
88
+ POWER_LAW = 0
89
+ TRUNCATED_POWER_LAW = 1
90
+ EXPONENTIAL = 2
91
+ _DISTRIBUTION_CODES = {
92
+ "power_law": POWER_LAW,
93
+ "truncated_power_law": TRUNCATED_POWER_LAW,
94
+ "exponential": EXPONENTIAL,
95
+ }
96
+
97
+ DistributionParams = ti.types.struct(xmin=ti.f32, pl_alpha=ti.f32, expon_lambda=ti.f32)
98
+
99
+
100
+ @ti.func
101
+ def _uniform01(seed: ti.u32, a: ti.u32, b: ti.u32, c: ti.u32) -> ti.f32:
102
+ """Deterministic counter-based uniform in [0, 1) (splitmix64 hash)."""
103
+ x = (
104
+ ti.cast(seed, ti.u64)
105
+ + ti.cast(a, ti.u64) * ti.u64(0x9E3779B97F4A7C15)
106
+ + ti.cast(b, ti.u64) * ti.u64(0xBF58476D1CE4E5B9)
107
+ + ti.cast(c, ti.u64) * ti.u64(0x94D049BB133111EB)
108
+ )
109
+ x ^= x >> 30
110
+ x *= ti.u64(0xBF58476D1CE4E5B9)
111
+ x ^= x >> 27
112
+ x *= ti.u64(0x94D049BB133111EB)
113
+ x ^= x >> 31
114
+ # Top 24 bits fill the f32 mantissa exactly, so the result is < 1.0.
115
+ return ti.cast(x >> 40, ti.f32) * ti.f32(1.0 / 16777216.0)
116
+
117
+
118
+ @ti.func
119
+ def _rvs( # noqa: ANN202
120
+ distribution: ti.template(),
121
+ distribution_params: DistributionParams,
122
+ seed: ti.u32,
123
+ iteration: ti.u32,
124
+ item: ti.u32,
125
+ ):
126
+ """Draw one tail sample via inverse-CDF / rejection sampling.
127
+
128
+ Power-law draws are computed in f64 and capped just below the f32
129
+ maximum, so alpha ~ 1 tails stay finite. The TPL rejection sampler
130
+ uses whichever proposal accepts more often and falls back to xmin
131
+ on attempt exhaustion (reachable only for alpha ~ 1 with
132
+ lambda*xmin ~ alpha-1).
133
+ """
134
+ xmin = distribution_params.xmin
135
+ pl_alpha = distribution_params.pl_alpha
136
+ expon_lambda = distribution_params.expon_lambda
137
+
138
+ probability = _uniform01(seed, iteration, item, 2)
139
+ result = ti.cast(BAD_RESULT, ti.f32)
140
+
141
+ if ti.static(distribution == POWER_LAW):
142
+ value = ti.cast(xmin, ti.f64) * ti.cast(1 - probability, ti.f64) ** ti.cast(
143
+ -1 / (pl_alpha - 1), ti.f64
144
+ )
145
+ result = ti.cast(ti.min(value, ti.f64(F32_SAMPLE_CAP)), ti.f32)
146
+ elif ti.static(distribution == TRUNCATED_POWER_LAW):
147
+ # Rejection sampling. Acceptance rates are s**alpha * G for
148
+ # the exponential proposal and (alpha-1) * s**(alpha-1) * G
149
+ # for the power-law one (s = lambda*xmin, common factor G),
150
+ # so the better proposal flips at s = alpha - 1.
151
+ use_pl_proposal = expon_lambda * xmin < pl_alpha - 1
152
+ attempt = ti.u32(3)
153
+ accepted = False
154
+ result = xmin
155
+ for _ in range(MAX_REJECTION_ATTEMPTS):
156
+ candidate = xmin - (1 / expon_lambda) * ti.log(1 - probability)
157
+ acceptance = ti.f32(0.0)
158
+ if use_pl_proposal:
159
+ value = ti.cast(xmin, ti.f64) * ti.cast(
160
+ 1 - probability, ti.f64
161
+ ) ** ti.cast(-1 / (pl_alpha - 1), ti.f64)
162
+ candidate = ti.cast(ti.min(value, ti.f64(F32_SAMPLE_CAP)), ti.f32)
163
+ acceptance = ti.exp(-expon_lambda * (candidate - xmin))
164
+ else:
165
+ acceptance = (candidate / xmin) ** (-pl_alpha)
166
+ if _uniform01(seed, iteration, item, attempt) < acceptance:
167
+ result = candidate
168
+ accepted = True
169
+ probability = _uniform01(seed, iteration, item, attempt + 1)
170
+ attempt += 2
171
+ if accepted:
172
+ break
173
+ else:
174
+ result = xmin - (1 / expon_lambda) * ti.log(1 - probability)
175
+
176
+ return result
177
+
178
+
179
+ @ti.func
180
+ def _cdf( # noqa: ANN202
181
+ distribution: ti.template(),
182
+ value: float,
183
+ distribution_params: DistributionParams,
184
+ ):
185
+ """Conditional model CDF on the tail (x >= xmin), in shifted form."""
186
+ xmin = distribution_params.xmin
187
+ pl_alpha = distribution_params.pl_alpha
188
+ expon_lambda = distribution_params.expon_lambda
189
+
190
+ result = ti.cast(BAD_RESULT, ti.f32)
191
+ if ti.static(distribution == POWER_LAW):
192
+ if pl_alpha < MIN_ALPHA or pl_alpha > MAX_ALPHA:
193
+ result = BAD_RESULT
194
+ else:
195
+ result = 1 - (value / xmin) ** (1 - pl_alpha)
196
+ elif ti.static(distribution == TRUNCATED_POWER_LAW):
197
+ if pl_alpha < MIN_ALPHA or pl_alpha > MAX_ALPHA or expon_lambda < MIN_LAMBDA:
198
+ result = BAD_RESULT
199
+ else:
200
+ shift = expon_lambda * xmin
201
+ survival_xmin = incomplete_gamma(1 - pl_alpha, shift, shift)
202
+ survival_value = incomplete_gamma(1 - pl_alpha, expon_lambda * value, shift)
203
+ result = ti.cast(1.0 - survival_value / survival_xmin, ti.f32)
204
+ elif expon_lambda < MIN_LAMBDA:
205
+ result = BAD_RESULT
206
+ else:
207
+ result = 1 - ti.exp(-expon_lambda * (value - xmin))
208
+
209
+ return result
210
+
211
+
212
+ @ti.func
213
+ def _ks_distance( # noqa: ANN202
214
+ distribution: ti.template(),
215
+ src: ti.template(),
216
+ row: ti.i32,
217
+ data_size: ti.i32,
218
+ distribution_params: DistributionParams,
219
+ min_tail_size: ti.i32,
220
+ ):
221
+ """One-sided KS statistic on the tail (empirical grid i/n)."""
222
+ xmin = distribution_params.xmin
223
+
224
+ tail_size = 0
225
+ for index in range(data_size):
226
+ if src[row, index] < xmin:
227
+ continue
228
+ tail_size += 1
229
+
230
+ bulk_size = data_size - tail_size
231
+
232
+ ks_distance = ti.cast(0.0, ti.f32)
233
+ if tail_size >= min_tail_size:
234
+ for index in range(tail_size):
235
+ value = src[row, bulk_size + index]
236
+
237
+ empirical_cdf = index / tail_size
238
+ model_cdf = _cdf(distribution, value, distribution_params)
239
+ diff = abs(empirical_cdf - model_cdf)
240
+ ks_distance = max(ks_distance, diff)
241
+
242
+ return ks_distance
243
+
244
+
245
+ @ti.func
246
+ def _tpl_negative_log_likelihood( # noqa: ANN202
247
+ xmin, # noqa: ANN001
248
+ mean_log: ti.f32,
249
+ mean_value: ti.f32,
250
+ params_array, # noqa: ANN001
251
+ ):
252
+ """Per-point negative log-likelihood of the truncated power law."""
253
+ pl_alpha = params_array[0]
254
+ expon_lambda = params_array[1]
255
+
256
+ result = ti.cast(BOUNDS_PENALTY, ti.f64)
257
+ if MIN_ALPHA < pl_alpha <= MAX_ALPHA and expon_lambda >= MIN_LAMBDA:
258
+ shift = ti.cast(expon_lambda * xmin, ti.f64)
259
+ normalization = incomplete_gamma(
260
+ 1 - pl_alpha, expon_lambda * xmin, expon_lambda * xmin
261
+ )
262
+ result = (
263
+ (ti.cast(pl_alpha, ti.f64) - 1) * ti.log(ti.cast(expon_lambda, ti.f64))
264
+ + ti.log(normalization)
265
+ - shift
266
+ + ti.cast(pl_alpha * mean_log, ti.f64)
267
+ + ti.cast(expon_lambda * mean_value, ti.f64)
268
+ )
269
+
270
+ return result
271
+
272
+
273
+ @ti.func
274
+ def _nelder_mead_optimizer( # noqa: ANN202
275
+ guess_idx, # noqa: ANN001
276
+ xmin, # noqa: ANN001
277
+ mean_log: ti.f32,
278
+ mean_value: ti.f32,
279
+ initial_params, # noqa: ANN001
280
+ simplex: ti.template(),
281
+ values: ti.template(),
282
+ ):
283
+ """Minimize the TPL negative log-likelihood over (alpha, lambda)."""
284
+ # alias
285
+ idx = guess_idx
286
+ dimension = 2
287
+
288
+ for dim in range(dimension + 1):
289
+ simplex[idx, dim] = initial_params
290
+ if dim > 0:
291
+ simplex[idx, dim][dim - 1] *= 1.5
292
+
293
+ for _iteration in range(OPTIM_N_ITERS):
294
+ for dim in range(dimension + 1):
295
+ values[idx, dim] = _tpl_negative_log_likelihood(
296
+ xmin, mean_log, mean_value, simplex[idx, dim]
297
+ )
298
+
299
+ best_idx = 0
300
+ worst_idx = 0
301
+ for dim in range(dimension + 1):
302
+ if values[idx, dim] < values[idx, best_idx]:
303
+ best_idx = dim
304
+ if values[idx, dim] > values[idx, worst_idx]:
305
+ worst_idx = dim
306
+ second_worst_idx = best_idx
307
+ for dim in range(dimension + 1):
308
+ if dim != worst_idx and values[idx, dim] > values[idx, second_worst_idx]:
309
+ second_worst_idx = dim
310
+
311
+ centroid = ti.Vector([0.0, 0.0])
312
+ for dim in range(dimension + 1):
313
+ if dim != worst_idx:
314
+ centroid += simplex[idx, dim]
315
+ centroid /= dimension
316
+
317
+ # reflection
318
+ reflected_point = centroid + (centroid - simplex[idx, worst_idx])
319
+ reflected_value = _tpl_negative_log_likelihood(
320
+ xmin, mean_log, mean_value, reflected_point
321
+ )
322
+
323
+ if reflected_value < values[idx, best_idx]:
324
+ # expansion
325
+ expanded_point = centroid + 2.0 * (centroid - simplex[idx, worst_idx])
326
+ expanded_value = _tpl_negative_log_likelihood(
327
+ xmin, mean_log, mean_value, expanded_point
328
+ )
329
+ if expanded_value < reflected_value:
330
+ simplex[idx, worst_idx] = expanded_point
331
+ values[idx, worst_idx] = expanded_value
332
+ else:
333
+ simplex[idx, worst_idx] = reflected_point
334
+ values[idx, worst_idx] = reflected_value
335
+ elif reflected_value < values[idx, second_worst_idx]:
336
+ simplex[idx, worst_idx] = reflected_point
337
+ values[idx, worst_idx] = reflected_value
338
+ else:
339
+ # contraction
340
+ if reflected_value < values[idx, worst_idx]:
341
+ simplex[idx, worst_idx] = reflected_point
342
+ values[idx, worst_idx] = reflected_value
343
+ contracted_point = centroid + 0.5 * (simplex[idx, worst_idx] - centroid)
344
+ contracted_value = _tpl_negative_log_likelihood(
345
+ xmin, mean_log, mean_value, contracted_point
346
+ )
347
+ if contracted_value < values[idx, worst_idx]:
348
+ simplex[idx, worst_idx] = contracted_point
349
+ values[idx, worst_idx] = contracted_value
350
+ else:
351
+ # reduction
352
+ for dim in range(dimension + 1):
353
+ if dim != best_idx:
354
+ simplex[idx, dim] = 0.5 * (
355
+ simplex[idx, dim] + simplex[idx, best_idx]
356
+ )
357
+ values[idx, dim] = _tpl_negative_log_likelihood(
358
+ xmin, mean_log, mean_value, simplex[idx, dim]
359
+ )
360
+
361
+ max_value = ti.cast(-np.inf, ti.f64)
362
+ min_value = ti.cast(np.inf, ti.f64)
363
+ for dim in range(dimension + 1):
364
+ max_value = ti.max(max_value, values[idx, dim])
365
+ min_value = ti.min(min_value, values[idx, dim])
366
+
367
+ if max_value - min_value < OBJ_FUNC_THRESHOLD:
368
+ break
369
+
370
+ best_idx = 0
371
+ for dim in range(dimension + 1):
372
+ if values[idx, dim] < values[idx, best_idx]:
373
+ best_idx = dim
374
+
375
+ return simplex[idx, best_idx]
376
+
377
+
378
+ @ti.func
379
+ def _estimate_for_specific_xmin( # noqa: ANN202
380
+ distribution: ti.template(),
381
+ guess_index, # noqa: ANN001
382
+ src: ti.template(),
383
+ row: ti.i32,
384
+ data_size: ti.i32,
385
+ xmin: float,
386
+ simplex: ti.template(),
387
+ values: ti.template(),
388
+ ):
389
+ """Maximum-likelihood parameter estimates for a fixed xmin."""
390
+ tail_size = 0
391
+ tail_log_sum = ti.cast(0.0, ti.f32)
392
+ tail_mean = ti.cast(0.0, ti.f32)
393
+
394
+ for index in range(data_size):
395
+ value = src[row, index]
396
+
397
+ if value < xmin:
398
+ continue
399
+
400
+ tail_size += 1
401
+ tail_log_sum += ti.log(value / xmin)
402
+ tail_mean += value
403
+ tail_mean /= ti.max(tail_size, 1)
404
+
405
+ # Closed-form conditional MLEs (Clauset et al. 2009).
406
+ mle_pl_alpha = 1 + tail_size / ti.max(tail_log_sum, 1e-12)
407
+ mle_expon_lambda = 1 / ti.max(tail_mean - xmin, 1e-9)
408
+
409
+ distribution_params = DistributionParams()
410
+ if ti.static(distribution == POWER_LAW):
411
+ distribution_params = DistributionParams(
412
+ xmin=xmin,
413
+ pl_alpha=mle_pl_alpha,
414
+ expon_lambda=np.nan,
415
+ )
416
+ elif ti.static(distribution == EXPONENTIAL):
417
+ distribution_params = DistributionParams(
418
+ xmin=xmin,
419
+ pl_alpha=np.nan,
420
+ expon_lambda=mle_expon_lambda,
421
+ )
422
+ else:
423
+ # TPL has no closed-form MLE; minimize the negative
424
+ # log-likelihood over (alpha, lambda) with Nelder-Mead. The
425
+ # start point is clamped so the whole initial simplex (x1.5
426
+ # vertex perturbations) lies inside the parameter bounds;
427
+ # otherwise the optimizer would start on the constant
428
+ # BOUNDS_PENALTY plateau and stop immediately.
429
+ mean_log = tail_log_sum / ti.max(tail_size, 1) + ti.log(xmin)
430
+ initial_alpha = ti.min(ti.max(mle_pl_alpha, 1.0 + 1e-4), MAX_ALPHA / 1.5)
431
+ initial_lambda = ti.max(mle_expon_lambda, 2.0 * MIN_LAMBDA)
432
+ solution = _nelder_mead_optimizer(
433
+ guess_index,
434
+ xmin,
435
+ mean_log,
436
+ tail_mean,
437
+ ti.Vector([initial_alpha, initial_lambda], dt=ti.f32),
438
+ simplex,
439
+ values,
440
+ )
441
+
442
+ distribution_params = DistributionParams(
443
+ xmin=xmin,
444
+ pl_alpha=solution[0],
445
+ expon_lambda=solution[1],
446
+ )
447
+
448
+ return distribution_params
449
+
450
+
451
+ @ti.kernel
452
+ def _iter_through_xmins( # noqa: ANN202 - taichi kernels reject the None annotation
453
+ distribution: ti.template(),
454
+ samples: ti.template(),
455
+ pl_alphas: ti.template(),
456
+ expon_lambdas: ti.template(),
457
+ ks_dists: ti.template(),
458
+ optim_simplex: ti.template(),
459
+ optim_values: ti.template(),
460
+ data_size: ti.i32,
461
+ ):
462
+ """Estimate parameters and KS distance for every xmin candidate."""
463
+ for index in range(data_size):
464
+ xmin = samples[0, index]
465
+
466
+ distribution_params = _estimate_for_specific_xmin(
467
+ distribution,
468
+ index,
469
+ samples,
470
+ 0,
471
+ data_size,
472
+ xmin,
473
+ optim_simplex,
474
+ optim_values,
475
+ )
476
+ pl_alphas[index] = distribution_params.pl_alpha
477
+ expon_lambdas[index] = distribution_params.expon_lambda
478
+ ks_dists[index] = _ks_distance(
479
+ distribution, samples, 0, data_size, distribution_params, MIN_TAIL_SIZE
480
+ )
481
+
482
+
483
+ @ti.kernel
484
+ def _generate_synth_datasets( # noqa: ANN202 - taichi kernels reject the None annotation
485
+ distribution: ti.template(),
486
+ samples: ti.template(),
487
+ synth_datasets: ti.template(),
488
+ distribution_params: DistributionParams,
489
+ seed: ti.u32,
490
+ data_size: ti.i32,
491
+ iters: ti.i32,
492
+ tail_size: ti.i32,
493
+ ):
494
+ """Draw bootstrap datasets (body resample + fitted-tail draws)."""
495
+ # Clauset et al. semi-parametric bootstrap: each point comes from
496
+ # the fitted tail with probability tail_size/n, otherwise it is a
497
+ # bootstrap resample of the empirical body (values below xmin).
498
+ tail_probability = tail_size / data_size
499
+ bulk_size = data_size - tail_size
500
+
501
+ for iteration, item in ti.ndrange(iters, data_size):
502
+ coin = _uniform01(seed, iteration, item, 0)
503
+ if coin < tail_probability or bulk_size == 0:
504
+ synth_datasets[iteration, item] = _rvs(
505
+ distribution, distribution_params, seed, iteration, item
506
+ )
507
+ else:
508
+ pick_probability = _uniform01(seed, iteration, item, 1)
509
+ pick = ti.min(ti.cast(pick_probability * bulk_size, ti.i32), bulk_size - 1)
510
+ synth_datasets[iteration, item] = samples[0, pick]
511
+
512
+
513
+ @ti.kernel
514
+ def _compute_p_value_dists( # noqa: ANN202 - taichi kernels reject the None annotation
515
+ distribution: ti.template(),
516
+ synth_datasets: ti.template(),
517
+ p_value_test_dists: ti.template(),
518
+ synth_optim_simplex: ti.template(),
519
+ synth_optim_values: ti.template(),
520
+ distribution_params: DistributionParams,
521
+ data_size: ti.i32,
522
+ iters: ti.i32,
523
+ ):
524
+ """Re-fit each bootstrap dataset and record its KS distance."""
525
+ xmin = distribution_params.xmin
526
+
527
+ for index in range(iters):
528
+ other_params = _estimate_for_specific_xmin(
529
+ distribution,
530
+ index,
531
+ synth_datasets,
532
+ index,
533
+ data_size,
534
+ xmin,
535
+ synth_optim_simplex,
536
+ synth_optim_values,
537
+ )
538
+ other_ks_distance = _ks_distance(
539
+ distribution,
540
+ synth_datasets,
541
+ index,
542
+ data_size,
543
+ other_params,
544
+ MIN_BOOTSTRAP_TAIL_SIZE,
545
+ )
546
+
547
+ p_value_test_dists[index] = other_ks_distance
548
+
549
+
550
+ # region field pools
551
+
552
+
553
+ class _MainFieldPool:
554
+ """Fit-scan taichi fields for one padded data size.
555
+
556
+ Pool entries are kept for the lifetime of the process, so the kernels
557
+ templated on them are compiled once per entry. Retention is ~64 bytes
558
+ per padded element (a few hundred KB at typical bucket sizes); at
559
+ most MAX_MAIN_POOLS_PER_SIZE entries exist per bucket, and sizes
560
+ above MAX_POOLED_SIZE are never pooled.
561
+ """
562
+
563
+ def __init__(self, size: int):
564
+ self.size = size
565
+ self.in_use = False
566
+ builder = ti.FieldsBuilder()
567
+ self.samples = ti.field(dtype=ti.f32)
568
+ self.pl_alphas = ti.field(dtype=ti.f32)
569
+ self.expon_lambdas = ti.field(dtype=ti.f32)
570
+ self.ks_dists = ti.field(dtype=ti.f32)
571
+ self.optim_simplex = ti.Vector.field(2, dtype=ti.f32)
572
+ self.optim_values = ti.field(dtype=ti.f64)
573
+ builder.dense(ti.ij, (1, size)).place(self.samples)
574
+ builder.dense(ti.i, size).place(
575
+ self.pl_alphas, self.expon_lambdas, self.ks_dists
576
+ )
577
+ builder.dense(ti.ij, (size, SIMPLEX_SIZE)).place(
578
+ self.optim_simplex, self.optim_values
579
+ )
580
+ self._tree = builder.finalize()
581
+
582
+ def destroy(self) -> None:
583
+ self._tree.destroy()
584
+
585
+
586
+ class _BootstrapFieldPool:
587
+ """Bootstrap taichi fields for one padded data size.
588
+
589
+ These hold iters x size f32 samples (tens of MB for large fits), so
590
+ only MAX_BOOTSTRAP_POOLS of them are kept; least recently used ones
591
+ are destroyed.
592
+ """
593
+
594
+ def __init__(self, size: int, iters: int):
595
+ self.size = size
596
+ self.iters = iters
597
+ self.in_use = False
598
+ builder = ti.FieldsBuilder()
599
+ self.p_value_test_dists = ti.field(dtype=ti.f32)
600
+ self.synth_optim_simplex = ti.Vector.field(2, dtype=ti.f32)
601
+ self.synth_optim_values = ti.field(dtype=ti.f64)
602
+ self.synth_datasets = ti.field(dtype=ti.f32)
603
+ builder.dense(ti.i, iters).place(self.p_value_test_dists)
604
+ builder.dense(ti.ij, (iters, SIMPLEX_SIZE)).place(
605
+ self.synth_optim_simplex, self.synth_optim_values
606
+ )
607
+ builder.dense(ti.ij, (iters, size)).place(self.synth_datasets)
608
+ self._tree = builder.finalize()
609
+
610
+ def destroy(self) -> None:
611
+ self._tree.destroy()
612
+
613
+
614
+ _MAIN_POOLS: dict[int, list[_MainFieldPool]] = {}
615
+ _BOOTSTRAP_POOLS: dict[int, _BootstrapFieldPool] = {}
616
+ _POOLS_LOCK = threading.Lock()
617
+
618
+
619
+ def _padded_size(data_size: int) -> int:
620
+ return max(MIN_POOL_SIZE, 2 ** math.ceil(math.log2(data_size)))
621
+
622
+
623
+ def _acquire_main_pool(size: int) -> tuple[_MainFieldPool, bool]:
624
+ # A non-pooled instance (oversized bucket, or every entry busy) gets
625
+ # private fields; they are destroyed on close, but the kernels
626
+ # compiled for them stay cached by taichi (~2 MB per instance).
627
+ if size > MAX_POOLED_SIZE:
628
+ return _MainFieldPool(size), False
629
+ with _POOLS_LOCK:
630
+ entries = _MAIN_POOLS.setdefault(size, [])
631
+ for entry in entries:
632
+ if not entry.in_use:
633
+ entry.in_use = True
634
+ return entry, True
635
+ if len(entries) < MAX_MAIN_POOLS_PER_SIZE:
636
+ entry = _MainFieldPool(size)
637
+ entries.append(entry)
638
+ entry.in_use = True
639
+ return entry, True
640
+ return _MainFieldPool(size), False
641
+
642
+
643
+ def _acquire_bootstrap_pool(size: int, iters: int) -> tuple[_BootstrapFieldPool, bool]:
644
+ if size > MAX_POOLED_BOOTSTRAP_SIZE:
645
+ return _BootstrapFieldPool(size, iters), False
646
+ with _POOLS_LOCK:
647
+ pool = _BOOTSTRAP_POOLS.get(size)
648
+ if pool is not None and pool.in_use:
649
+ return _BootstrapFieldPool(size, iters), False
650
+ if pool is not None and pool.iters < iters:
651
+ _BOOTSTRAP_POOLS.pop(size).destroy()
652
+ pool = None
653
+ if pool is None:
654
+ while len(_BOOTSTRAP_POOLS) >= MAX_BOOTSTRAP_POOLS:
655
+ evictable = next(
656
+ (key for key, p in _BOOTSTRAP_POOLS.items() if not p.in_use),
657
+ None,
658
+ )
659
+ if evictable is None:
660
+ return _BootstrapFieldPool(size, iters), False
661
+ _BOOTSTRAP_POOLS.pop(evictable).destroy()
662
+ pool = _BootstrapFieldPool(size, iters)
663
+ else:
664
+ _BOOTSTRAP_POOLS.pop(size)
665
+ _BOOTSTRAP_POOLS[size] = pool # (re-)insert as most recently used
666
+ pool.in_use = True
667
+ return pool, True
668
+
669
+
670
+ def _release_pool(pool, pooled: bool) -> None: # noqa: ANN001
671
+ with _POOLS_LOCK:
672
+ if pooled:
673
+ pool.in_use = False
674
+ else:
675
+ pool.destroy()
676
+
677
+
678
+ # endregion field pools
679
+
680
+
681
+ class Fit:
682
+ """Taichi-based heavy-tailed distribution fitter.
683
+
684
+ Data is rescaled internally by a power of two near its median (exact
685
+ in floating point), which makes the parameter bounds scale-invariant;
686
+ fitted and injected parameters are always in the original data units.
687
+ """
688
+
689
+ def __init__(
690
+ self,
691
+ data, # noqa: ANN001
692
+ distribution: Literal["power_law", "truncated_power_law", "exponential"],
693
+ *,
694
+ seed: int = 42,
695
+ ):
696
+ data = np.asarray(data)
697
+ if data.ndim != 1:
698
+ msg = f"Expected 1-D data, got shape {data.shape}"
699
+ raise ValueError(msg)
700
+
701
+ if data.size == 0:
702
+ msg = "Cannot fit an empty dataset"
703
+ raise ValueError(msg)
704
+
705
+ if not np.all(np.isfinite(data)):
706
+ msg = "Data must contain only finite values"
707
+ raise ValueError(msg)
708
+
709
+ if distribution not in _DISTRIBUTION_CODES:
710
+ msg = f"Unknown distribution {distribution!r}"
711
+ raise ValueError(msg)
712
+
713
+ if not 0 <= seed < 2**32:
714
+ msg = f"seed must fit in an unsigned 32-bit integer, got {seed}"
715
+ raise ValueError(msg)
716
+
717
+ median = float(np.median(data))
718
+ self._scale = 2.0 ** round(math.log2(median)) if median > 0 else 1.0
719
+
720
+ self.data_size = data.size
721
+ self.seed = seed
722
+ self.distribution = distribution
723
+ self._distribution_code = _DISTRIBUTION_CODES[distribution]
724
+
725
+ self.p_value_test_iters = round(0.25 * (P_VALUE_EPS ** (-2)))
726
+
727
+ # The pool region beyond data_size is padding; every kernel loop
728
+ # is bounded by data_size, so its contents never matter.
729
+ buffer = np.full((1, _padded_size(data.size)), np.inf, dtype=np.float32)
730
+ buffer[0, : self.data_size] = np.sort((data / self._scale).astype(np.float32))
731
+
732
+ self._main_pool, self._main_pooled = _acquire_main_pool(buffer.shape[1])
733
+ self._bootstrap_pool = None
734
+ self._bootstrap_pooled = False
735
+ self._main_pool.samples.from_numpy(buffer)
736
+
737
+ # must be available during p_value test
738
+ self.params = None
739
+ self.tail_size = None
740
+
741
+ def close(self) -> None:
742
+ """Return the taichi fields borrowed by this instance to the pool.
743
+
744
+ Idempotent; a closed instance raises RuntimeError from
745
+ :meth:`fit_params` and :meth:`p_value_test`.
746
+ """
747
+ if self._bootstrap_pool is not None:
748
+ _release_pool(self._bootstrap_pool, self._bootstrap_pooled)
749
+ self._bootstrap_pool = None
750
+ if self._main_pool is not None:
751
+ _release_pool(self._main_pool, self._main_pooled)
752
+ self._main_pool = None
753
+
754
+ def __del__(self) -> None: # noqa: D105
755
+ # A bare try/except: module globals (contextlib.suppress included)
756
+ # may already be torn down when the destructor runs at shutdown.
757
+ try: # noqa: SIM105
758
+ self.close()
759
+ except Exception: # noqa: BLE001, S110
760
+ pass
761
+
762
+ def fit_params(self): # noqa: ANN201
763
+ """Fit the distribution, selecting xmin by global KS minimum.
764
+
765
+ Returned parameters are in the original data units.
766
+ """
767
+ if self._main_pool is None:
768
+ msg = "Fit is closed"
769
+ raise RuntimeError(msg)
770
+
771
+ if self.params is None:
772
+ pool = self._main_pool
773
+ _iter_through_xmins(
774
+ self._distribution_code,
775
+ pool.samples,
776
+ pool.pl_alphas,
777
+ pool.expon_lambdas,
778
+ pool.ks_dists,
779
+ pool.optim_simplex,
780
+ pool.optim_values,
781
+ self.data_size,
782
+ )
783
+
784
+ ks_dists = pool.ks_dists.to_numpy()[: self.data_size]
785
+ # A genuine KS statistic lies in (0, 1]; values outside come
786
+ # from degenerate candidates (out-of-bounds parameters).
787
+ mask = (ks_dists > MIN_KS_DISTANCE) & (ks_dists <= 1.0)
788
+
789
+ argmin = int(np.argmin(ks_dists[mask])) if mask.any() else BAD_RESULT
790
+
791
+ if argmin == BAD_RESULT:
792
+ xmin = np.nan
793
+ pl_alpha = np.nan
794
+ expon_lambda = np.nan
795
+ ks_distance = np.nan
796
+ self.tail_size = 0
797
+ else:
798
+ data = pool.samples.to_numpy()[0, : self.data_size]
799
+ xmin = data[mask][argmin]
800
+ pl_alpha = pool.pl_alphas.to_numpy()[: self.data_size][mask][argmin]
801
+ expon_lambda = pool.expon_lambdas.to_numpy()[: self.data_size][mask][
802
+ argmin
803
+ ]
804
+ ks_distance = ks_dists[mask][argmin]
805
+ self.tail_size = int(np.sum(data >= xmin))
806
+
807
+ self.params = {
808
+ "xmin": float(xmin * self._scale),
809
+ "pl_alpha": float(pl_alpha),
810
+ "expon_lambda": float(expon_lambda / self._scale),
811
+ "ks_distance": float(ks_distance),
812
+ "tail_size": self.tail_size,
813
+ }
814
+
815
+ return self.params
816
+
817
+ def set_params(self, **kwargs: Any) -> None:
818
+ """Inject externally estimated parameters (e.g. from powerlaw).
819
+
820
+ Values are in the original data units; the previous parameter set
821
+ is replaced atomically (and kept intact on invalid input).
822
+ """
823
+ allowed = ("xmin", "pl_alpha", "expon_lambda", "ks_distance", "tail_size")
824
+ validated: dict[str, Any] = {}
825
+ for key, value in kwargs.items():
826
+ if key not in allowed:
827
+ msg = f"Unknown fit parameter {key!r}"
828
+ raise ValueError(msg)
829
+ try:
830
+ validated[key] = float(value)
831
+ except (TypeError, ValueError) as error:
832
+ msg = f"Fit parameter {key!r} must be a real number, got {value!r}"
833
+ raise ValueError(msg) from error
834
+
835
+ tail_size = validated.get("tail_size")
836
+ if tail_size is not None:
837
+ if not tail_size.is_integer() or not 0 <= tail_size <= self.data_size:
838
+ msg = (
839
+ f"tail_size must be an integer in [0, {self.data_size}], "
840
+ f"got {kwargs['tail_size']!r}"
841
+ )
842
+ raise ValueError(msg)
843
+ validated["tail_size"] = int(tail_size)
844
+
845
+ xmin = validated.get("xmin")
846
+ if xmin is not None and not (math.isnan(xmin) or xmin > 0):
847
+ msg = f"xmin must be positive, got {kwargs['xmin']!r}"
848
+ raise ValueError(msg)
849
+
850
+ self.params = validated
851
+
852
+ def _required_params_finite(self) -> bool:
853
+ xmin = self.params.get("xmin", np.nan)
854
+ pl_alpha = self.params.get("pl_alpha", np.nan)
855
+ expon_lambda = self.params.get("expon_lambda", np.nan)
856
+
857
+ if not np.isfinite(xmin):
858
+ return False
859
+ if self.distribution in (
860
+ "power_law",
861
+ "truncated_power_law",
862
+ ) and not np.isfinite(pl_alpha):
863
+ return False
864
+ return not (
865
+ self.distribution in ("truncated_power_law", "exponential")
866
+ and not np.isfinite(expon_lambda)
867
+ )
868
+
869
+ def p_value_test(self): # noqa: ANN201
870
+ """Semi-parametric bootstrap plausibility test (Clauset et al. 4.1).
871
+
872
+ Returns (plausible, p_value); p_value is NaN when the tail is too
873
+ small (< MIN_TAIL_SIZE) or the fit produced no valid parameters.
874
+ """
875
+ if self._main_pool is None:
876
+ msg = "Fit is closed"
877
+ raise RuntimeError(msg)
878
+
879
+ if not self.params:
880
+ return False, float("nan")
881
+
882
+ self.tail_size = int(self.params.get("tail_size", 0))
883
+ ks_distance = self.params.get("ks_distance", np.nan)
884
+
885
+ if (
886
+ not self._required_params_finite()
887
+ or not np.isfinite(ks_distance)
888
+ or self.tail_size < MIN_TAIL_SIZE
889
+ ):
890
+ return False, float("nan")
891
+
892
+ if self.p_value_test_iters * self._main_pool.size > MAX_P_VALUE_ELEMENTS:
893
+ msg = (
894
+ f"p_value_test would allocate {self.p_value_test_iters} x "
895
+ f"{self._main_pool.size} float32 bootstrap samples; subsample "
896
+ "the data first"
897
+ )
898
+ raise ValueError(msg)
899
+
900
+ distribution_params = DistributionParams(
901
+ xmin=self.params.get("xmin", np.nan) / self._scale,
902
+ pl_alpha=self.params.get("pl_alpha", np.nan),
903
+ expon_lambda=self.params.get("expon_lambda", np.nan) * self._scale,
904
+ )
905
+
906
+ if self._bootstrap_pool is None:
907
+ self._bootstrap_pool, self._bootstrap_pooled = _acquire_bootstrap_pool(
908
+ self._main_pool.size, self.p_value_test_iters
909
+ )
910
+ boot = self._bootstrap_pool
911
+
912
+ _generate_synth_datasets(
913
+ self._distribution_code,
914
+ self._main_pool.samples,
915
+ boot.synth_datasets,
916
+ distribution_params,
917
+ self.seed,
918
+ self.data_size,
919
+ self.p_value_test_iters,
920
+ self.tail_size,
921
+ )
922
+
923
+ # Bootstrap resampling perturbs both body and tail; taichi has no
924
+ # library sort, numpy row-wise sort is fast and exact. Only the
925
+ # first data_size columns hold samples; the padding is untouched.
926
+ synth = boot.synth_datasets.to_numpy()
927
+ synth[: self.p_value_test_iters, : self.data_size].sort(axis=1)
928
+ boot.synth_datasets.from_numpy(synth)
929
+
930
+ _compute_p_value_dists(
931
+ self._distribution_code,
932
+ boot.synth_datasets,
933
+ boot.p_value_test_dists,
934
+ boot.synth_optim_simplex,
935
+ boot.synth_optim_values,
936
+ distribution_params,
937
+ self.data_size,
938
+ self.p_value_test_iters,
939
+ )
940
+
941
+ dists = boot.p_value_test_dists.to_numpy()[: self.p_value_test_iters]
942
+ p_value = np.sum(dists > ks_distance) / dists.size
943
+ result = p_value > P_VALUE_THRESHOLD
944
+
945
+ return result, float(p_value)
946
+
947
+
948
+ # region utils
949
+
950
+ NUM_GAUSS_NODES = 16
951
+
952
+ gauss_nodes = ti.field(ti.f64, shape=NUM_GAUSS_NODES)
953
+ gauss_nodes.from_numpy(
954
+ np.array(
955
+ [
956
+ 0.0052995325041750337,
957
+ 0.027712488463383700,
958
+ 0.067184398806084122,
959
+ 0.12229779582249845,
960
+ 0.19106187779867811,
961
+ 0.27099161117138635,
962
+ 0.35919822461037054,
963
+ 0.45249374508118123,
964
+ 0.54750625491881877,
965
+ 0.64080177538962946,
966
+ 0.72900838882861365,
967
+ 0.80893812220132189,
968
+ 0.87770220417750155,
969
+ 0.93281560119391588,
970
+ 0.97228751153661630,
971
+ 0.99470046749582497,
972
+ ]
973
+ ).astype(np.float64)
974
+ )
975
+
976
+ gauss_weights = ti.field(ti.f64, shape=NUM_GAUSS_NODES)
977
+ gauss_weights.from_numpy(
978
+ np.array(
979
+ [
980
+ 0.013576229705877048,
981
+ 0.031126761969323946,
982
+ 0.047579255841246393,
983
+ 0.062314485627766938,
984
+ 0.074797994408288368,
985
+ 0.084578259697501267,
986
+ 0.091301707522461792,
987
+ 0.094725305227534251,
988
+ 0.094725305227534251,
989
+ 0.091301707522461792,
990
+ 0.084578259697501267,
991
+ 0.074797994408288368,
992
+ 0.062314485627766938,
993
+ 0.047579255841246393,
994
+ 0.031126761969323946,
995
+ 0.013576229705877048,
996
+ ]
997
+ ).astype(np.float64)
998
+ )
999
+
1000
+ # Below this lower limit the integrand's t**(alpha-1) singularity defeats
1001
+ # the mapped quadrature; the [lower, SPLIT) part is integrated analytically
1002
+ # via the exp(-t) Taylor series instead.
1003
+ GAMMA_SERIES_SPLIT = 0.5
1004
+ GAMMA_SERIES_TERMS = 8
1005
+ GAMMA_EXPONENT_EPS = 1e-6
1006
+
1007
+
1008
+ @ti.func
1009
+ def incomplete_gamma(alpha: ti.f32, lower_limit: ti.f32, shift: ti.f32) -> ti.f64:
1010
+ """Shifted upper incomplete gamma: integral of t**(alpha-1)*exp(shift-t).
1011
+
1012
+ Equals exp(shift) * Gamma(alpha, lower_limit); the shift keeps the
1013
+ result representable when lower_limit is large (conditional CDFs only
1014
+ ever need the ratio of two values with a common shift). Computed in
1015
+ f64 via mapped Gauss-Legendre quadrature plus an analytic series for
1016
+ the near-singular region.
1017
+ """
1018
+ a = ti.cast(alpha, ti.f64)
1019
+ lower = ti.cast(lower_limit, ti.f64)
1020
+ shift64 = ti.cast(shift, ti.f64)
1021
+ start = ti.max(lower, ti.f64(GAMMA_SERIES_SPLIT))
1022
+
1023
+ result = ti.cast(0.0, ti.f64)
1024
+ for node_index in range(NUM_GAUSS_NODES):
1025
+ transformed_node = start + (
1026
+ gauss_nodes[node_index] / (1.0 - gauss_nodes[node_index])
1027
+ )
1028
+ weight = gauss_weights[node_index] / ((1.0 - gauss_nodes[node_index]) ** 2)
1029
+ result += (
1030
+ weight * transformed_node ** (a - 1) * ti.exp(shift64 - transformed_node)
1031
+ )
1032
+
1033
+ if lower < GAMMA_SERIES_SPLIT:
1034
+ # exp(shift) is bounded here: shift <= lower < SPLIT by construction.
1035
+ scale = ti.exp(shift64)
1036
+ coefficient = ti.cast(1.0, ti.f64)
1037
+ for k in range(GAMMA_SERIES_TERMS):
1038
+ if k > 0:
1039
+ coefficient *= -1.0 / k
1040
+ exponent = a + k
1041
+ term = ti.cast(0.0, ti.f64)
1042
+ if ti.abs(exponent) < GAMMA_EXPONENT_EPS:
1043
+ term = ti.log(start / lower)
1044
+ else:
1045
+ term = (start**exponent - lower**exponent) / exponent
1046
+ result += scale * coefficient * term
1047
+
1048
+ return result
1049
+
1050
+
1051
+ # endregion utils