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.
- diffract/__init__.py +52 -0
- diffract/configs/fast_speed_without_disk.ini +48 -0
- diffract/configs/hybrid.ini +74 -0
- diffract/configs/sqlite.ini +62 -0
- diffract/containers.py +405 -0
- diffract/core/__init__.py +23 -0
- diffract/core/cache/__init__.py +24 -0
- diffract/core/cache/containers.py +93 -0
- diffract/core/cache/interface.py +117 -0
- diffract/core/cache/redis_manager.py +464 -0
- diffract/core/cache/simple_manager.py +478 -0
- diffract/core/compute/__init__.py +46 -0
- diffract/core/compute/config.py +48 -0
- diffract/core/compute/containers.py +81 -0
- diffract/core/compute/decorator.py +125 -0
- diffract/core/compute/exceptions.py +31 -0
- diffract/core/compute/execution/__init__.py +14 -0
- diffract/core/compute/execution/_types.py +70 -0
- diffract/core/compute/execution/aggregation.py +74 -0
- diffract/core/compute/execution/aggregation_runner.py +362 -0
- diffract/core/compute/execution/enums.py +38 -0
- diffract/core/compute/execution/executor.py +125 -0
- diffract/core/compute/execution/parameter_runner.py +125 -0
- diffract/core/compute/execution/restrictions.py +61 -0
- diffract/core/compute/execution/strategy.py +118 -0
- diffract/core/compute/execution/utils.py +112 -0
- diffract/core/compute/extensions/__init__.py +0 -0
- diffract/core/compute/extensions/power_law.py +1051 -0
- diffract/core/compute/extensions/rmt.py +150 -0
- diffract/core/compute/extensions/utils.py +36 -0
- diffract/core/compute/field_signature.py +183 -0
- diffract/core/compute/kernels/__init__.py +48 -0
- diffract/core/compute/kernels/alignment.py +106 -0
- diffract/core/compute/kernels/heavy_tailed.py +394 -0
- diffract/core/compute/kernels/marchenko_pastur.py +99 -0
- diffract/core/compute/kernels/mat_decomposition.py +101 -0
- diffract/core/compute/kernels/mat_properties.py +53 -0
- diffract/core/compute/kernels/model_quality.py +27 -0
- diffract/core/compute/kernels/norms.py +88 -0
- diffract/core/compute/kernels/ranks.py +35 -0
- diffract/core/compute/kernels/tracy_widom.py +36 -0
- diffract/core/compute/metadata.py +67 -0
- diffract/core/compute/registry.py +485 -0
- diffract/core/constants.py +147 -0
- diffract/core/data/__init__.py +32 -0
- diffract/core/data/interface.py +304 -0
- diffract/core/data/metadata/__init__.py +15 -0
- diffract/core/data/metadata/containers.py +60 -0
- diffract/core/data/metadata/interface.py +208 -0
- diffract/core/data/metadata/sqlite_index.py +604 -0
- diffract/core/data/nn/__init__.py +43 -0
- diffract/core/data/nn/aggregates/__init__.py +35 -0
- diffract/core/data/nn/aggregates/metadata.py +96 -0
- diffract/core/data/nn/aggregates/proxy.py +57 -0
- diffract/core/data/nn/aggregates/repository.py +87 -0
- diffract/core/data/nn/aggregates/view.py +307 -0
- diffract/core/data/nn/containers.py +86 -0
- diffract/core/data/nn/extractors/__init__.py +49 -0
- diffract/core/data/nn/extractors/base.py +300 -0
- diffract/core/data/nn/extractors/factory.py +234 -0
- diffract/core/data/nn/extractors/flax.py +112 -0
- diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
- diffract/core/data/nn/extractors/handlers/base.py +110 -0
- diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
- diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
- diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
- diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
- diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
- diffract/core/data/nn/extractors/interface.py +56 -0
- diffract/core/data/nn/extractors/numpy.py +94 -0
- diffract/core/data/nn/extractors/onnx.py +108 -0
- diffract/core/data/nn/extractors/tensorflow.py +99 -0
- diffract/core/data/nn/extractors/torch.py +204 -0
- diffract/core/data/nn/params/__init__.py +27 -0
- diffract/core/data/nn/params/interface.py +402 -0
- diffract/core/data/nn/params/metadata.py +110 -0
- diffract/core/data/nn/params/proxy.py +93 -0
- diffract/core/data/nn/params/repository.py +45 -0
- diffract/core/data/nn/params/schema.py +82 -0
- diffract/core/data/nn/params/view.py +393 -0
- diffract/core/data/proxy.py +246 -0
- diffract/core/data/repository.py +227 -0
- diffract/core/data/utils.py +33 -0
- diffract/core/data/view.py +389 -0
- diffract/core/export/__init__.py +27 -0
- diffract/core/export/containers.py +47 -0
- diffract/core/export/exporters.py +191 -0
- diffract/core/export/formatters/__init__.py +23 -0
- diffract/core/export/formatters/base.py +68 -0
- diffract/core/export/formatters/dict_formatter.py +38 -0
- diffract/core/export/formatters/json_formatter.py +58 -0
- diffract/core/export/formatters/list_formatter.py +41 -0
- diffract/core/export/formatters/pandas_formatter.py +86 -0
- diffract/core/export/formatters/polars_formatter.py +86 -0
- diffract/core/export/formatters/registry.py +84 -0
- diffract/core/export/interface.py +105 -0
- diffract/core/parallel/__init__.py +21 -0
- diffract/core/parallel/containers.py +57 -0
- diffract/core/parallel/execution.py +91 -0
- diffract/core/parallel/runtime.py +91 -0
- diffract/core/storage/__init__.py +35 -0
- diffract/core/storage/base_manager.py +330 -0
- diffract/core/storage/containers.py +122 -0
- diffract/core/storage/hdf5_manager.py +856 -0
- diffract/core/storage/hybrid_manager.py +218 -0
- diffract/core/storage/interface.py +222 -0
- diffract/core/storage/metadata.py +89 -0
- diffract/core/storage/ram_manager.py +159 -0
- diffract/core/storage/sqlite_manager.py +1062 -0
- diffract/core/storage/zarr_manager.py +992 -0
- diffract/core/utils/__init__.py +45 -0
- diffract/core/utils/build.py +46 -0
- diffract/core/utils/exceptions.py +11 -0
- diffract/core/utils/hashing.py +90 -0
- diffract/core/utils/imports.py +292 -0
- diffract/core/utils/math.py +15 -0
- diffract/py.typed +0 -0
- diffract/session/__init__.py +24 -0
- diffract/session/errors.py +17 -0
- diffract/session/field_cache.py +216 -0
- diffract/session/namespaces/__init__.py +15 -0
- diffract/session/namespaces/compute/__init__.py +219 -0
- diffract/session/namespaces/models/__init__.py +282 -0
- diffract/session/namespaces/models/parameters/__init__.py +133 -0
- diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
- diffract/session/namespaces/results/__init__.py +378 -0
- diffract/session/namespaces/results/eraser.py +129 -0
- diffract/session/namespaces/results/injester.py +249 -0
- diffract/session/namespaces/utils/__init__.py +141 -0
- diffract/session/namespaces/utils/merger.py +295 -0
- diffract/session/namespaces/viz/__init__.py +91 -0
- diffract/session/namespaces/viz/_utils.py +14 -0
- diffract/session/namespaces/viz/box.py +207 -0
- diffract/session/namespaces/viz/grid.py +160 -0
- diffract/session/namespaces/viz/heatmap.py +164 -0
- diffract/session/namespaces/viz/scatter.py +202 -0
- diffract/session/namespaces/viz/sparkline.py +270 -0
- diffract/session/namespaces/viz/violin.py +212 -0
- diffract/session/session.py +260 -0
- diffract/session/utils.py +81 -0
- diffract/viz/__init__.py +42 -0
- diffract/viz/data/__init__.py +34 -0
- diffract/viz/data/detection.py +57 -0
- diffract/viz/data/extraction.py +117 -0
- diffract/viz/data/filtering.py +57 -0
- diffract/viz/data/ordering.py +129 -0
- diffract/viz/data/provider.py +99 -0
- diffract/viz/data/types.py +98 -0
- diffract/viz/plots/__init__.py +37 -0
- diffract/viz/plots/base/__init__.py +20 -0
- diffract/viz/plots/base/axis.py +219 -0
- diffract/viz/plots/base/coloraxis.py +133 -0
- diffract/viz/plots/base/configurator.py +18 -0
- diffract/viz/plots/base/jitter.py +368 -0
- diffract/viz/plots/base/line.py +197 -0
- diffract/viz/plots/base/marker.py +278 -0
- diffract/viz/plots/base/overlay.py +14 -0
- diffract/viz/plots/base/plot.py +110 -0
- diffract/viz/plots/base/update.py +50 -0
- diffract/viz/plots/boxplot.py +283 -0
- diffract/viz/plots/cluster.py +374 -0
- diffract/viz/plots/heatmap.py +168 -0
- diffract/viz/plots/scatter.py +233 -0
- diffract/viz/plots/sparkline.py +405 -0
- diffract/viz/plots/subplots/__init__.py +32 -0
- diffract/viz/plots/subplots/coloraxis.py +339 -0
- diffract/viz/plots/subplots/factory.py +713 -0
- diffract/viz/plots/subplots/grid.py +214 -0
- diffract/viz/plots/subplots/layout.py +370 -0
- diffract/viz/plots/subplots/spec.py +39 -0
- diffract/viz/plots/violin.py +298 -0
- diffract/viz/renderer.py +513 -0
- diffract/viz/styling/__init__.py +78 -0
- diffract/viz/styling/palettes/__init__.py +20 -0
- diffract/viz/styling/palettes/bundle.py +16 -0
- diffract/viz/styling/palettes/color.py +83 -0
- diffract/viz/styling/palettes/dashes.py +27 -0
- diffract/viz/styling/palettes/symbols.py +42 -0
- diffract/viz/styling/resolvers/__init__.py +11 -0
- diffract/viz/styling/resolvers/categorical.py +68 -0
- diffract/viz/styling/resolvers/color.py +77 -0
- diffract/viz/styling/resolvers/numeric.py +108 -0
- diffract/viz/styling/sources.py +27 -0
- diffract/viz/styling/theme/__init__.py +29 -0
- diffract/viz/styling/theme/applier.py +114 -0
- diffract/viz/styling/theme/components.py +66 -0
- diffract/viz/styling/theme/presets.py +58 -0
- diffract/viz/styling/theme/theme.py +36 -0
- diffract_core-0.2.1.dist-info/METADATA +530 -0
- diffract_core-0.2.1.dist-info/RECORD +193 -0
- diffract_core-0.2.1.dist-info/WHEEL +4 -0
- diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
- 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
|