• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JohannesBuchner / UltraNest / 9f2dd4f6-0775-47e9-b700-af647027ebfa

22 Apr 2024 12:51PM UTC coverage: 74.53% (+0.3%) from 74.242%
9f2dd4f6-0775-47e9-b700-af647027ebfa

push

circleci

web-flow
Merge pull request #118 from njzifjoiez/fixed-size-vectorised-slice-sampler

vectorised slice sampler of fixed batch size

1329 of 2026 branches covered (65.6%)

Branch coverage included in aggregate %.

79 of 80 new or added lines in 1 file covered. (98.75%)

1 existing line in 1 file now uncovered.

4026 of 5159 relevant lines covered (78.04%)

0.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.44
/ultranest/stepsampler.py
1
"""
2
MCMC-like step sampling
3
-----------------------
4

5
The classes implemented here are generators that, in each iteration,
6
only make one likelihood call. This allows running in parallel a
7
population of samplers that have the same execution time per call,
8
even if they do not terminate at the same number of iterations.
9
"""
10

11
from __future__ import print_function, division
1✔
12
import numpy as np
1✔
13
import matplotlib.pyplot as plt
1✔
14
from .utils import listify as _listify
1✔
15

16

17
def generate_random_direction(ui, region, scale=1):
1✔
18
    """Sample uniform direction vector in unit cube space of length `scale`.
19

20
    Samples a direction from a unit multi-variate Gaussian.
21

22
    Parameters
23
    -----------
24
    ui: array
25
        starting point
26
    region: MLFriends object
27
        current region (not used)
28
    scale: float
29
        length of direction vector
30

31
    Returns
32
    --------
33
    v: array
34
        new direction vector
35
    """
36
    del region
1✔
37
    v = np.random.normal(0, 1, size=len(ui))
1✔
38
    v *= scale / (v**2).sum()**0.5
1✔
39
    return v
1✔
40

41

42
def generate_cube_oriented_direction(ui, region, scale=1):
1✔
43
    """Sample a unit direction vector in direction of a random unit cube axes.
44

45
    Chooses one parameter, randomly uniformly, upon which the slice will be defined.
46

47
    Parameters
48
    -----------
49
    ui: array
50
        starting point
51
    region: MLFriends object
52
        current region (not used)
53
    scale: float
54
        factor to multiple the vector
55

56
    Returns
57
    --------
58
    v: array
59
        new direction vector
60
    """
61
    del region
1✔
62
    ndim = len(ui)
1✔
63
    # choose axis
64
    j = np.random.randint(ndim)
1✔
65
    # use doubling procedure to identify left and right maxima borders
66
    v = np.zeros(ndim)
1✔
67
    v[j] = scale
1✔
68
    return v
1✔
69

70

71
def generate_cube_oriented_differential_direction(ui, region, scale=1):
1✔
72
    """Sample a direction vector on a randomly chose parameter based on two randomly selected live points.
73

74
    Chooses one parameter, randomly uniformly, upon which the slice will be defined.
75
    Guess the length from the difference of two points in that axis.
76

77
    Parameters
78
    -----------
79
    ui: array
80
        starting point
81
    region: MLFriends object
82
        current region
83
    scale: float
84
        factor to multiple the vector
85

86
    Returns
87
    --------
88
    v: array
89
        new direction vector
90
    """
91
    nlive, ndim = region.u.shape
1✔
92
    v = np.zeros(ndim)
1✔
93

94
    # choose axis
95
    j = np.random.randint(ndim)
1✔
96
    # choose pair
97
    while v[j] == 0:
1✔
98
        i = np.random.randint(nlive)
1✔
99
        i2 = np.random.randint(nlive - 1)
1✔
100
        if i2 >= i:
1!
101
            i2 += 1
×
102

103
        v[j] = (region.u[i,j] - region.u[i2,j]) * scale
1✔
104

105
    return v
1✔
106

107

108
def generate_differential_direction(ui, region, scale=1):
1✔
109
    """Sample a vector using the difference between two randomly selected live points.
110

111
    Parameters
112
    -----------
113
    ui: array
114
        starting point
115
    region: MLFriends object
116
        current region
117
    scale: float
118
        factor to multiple the vector
119

120
    Returns
121
    --------
122
    v: array
123
        new direction vector
124
    """
125
    nlive, ndim = region.u.shape
1✔
126
    # choose pair
127
    i = np.random.randint(nlive)
1✔
128
    i2 = np.random.randint(nlive - 1)
1✔
129
    if i2 >= i:
1✔
130
        i2 += 1
1✔
131

132
    # use doubling procedure to identify left and right maxima borders
133
    v = (region.u[i,:] - region.u[i2,:]) * scale
1✔
134
    return v
1✔
135

136

137
def generate_partial_differential_direction(ui, region, scale=1):
1✔
138
    """Sample a vector using the difference between two randomly selected live points.
139

140
    Only 10% of parameters are allowed to vary at a time.
141

142
    Parameters
143
    -----------
144
    ui: array
145
        starting point
146
    region: MLFriends object
147
        current region
148
    scale: float
149
        factor to multiple the vector
150

151
    Returns
152
    --------
153
    v: array
154
        new direction vector
155
    """
156
    nlive, ndim = region.u.shape
1✔
157
    # choose pair
158
    i = np.random.randint(nlive)
1✔
159
    while True:
160
        i2 = np.random.randint(nlive - 1)
1✔
161
        if i2 >= i:
1!
162
            i2 += 1
1✔
163

164
        v = region.u[i] - region.u[i2]
1✔
165

166
        # choose which parameters to be off
167
        mask = np.random.uniform(size=ndim) > 0.1
1✔
168
        # at least one must be free to vary
169
        mask[np.random.randint(ndim)] = False
1✔
170
        v[mask] = 0
1✔
171
        if (v != 0).any():
1!
172
            # repeat if live points are identical
173
            break
1✔
174
    # use doubling procedure to identify left and right maxima borders
175
    # v = np.zeros(ndim)
176
    # v[mask] = (region.u[i,mask] - region.u[i2,mask]) * scale
177
    return v
1✔
178

179

180
def generate_region_oriented_direction(ui, region, scale=1):
1✔
181
    """Sample a vector along one `region` principle axes, chosen at random.
182

183
    The region transformLayer axes are considered (:py:class:`AffineLayer` or :py:class:`ScalingLayer`).
184
    One axis is chosen at random.
185

186
    Parameters
187
    -----------
188
    ui: array
189
        starting point
190
    region: MLFriends object
191
        current region
192
    scale: float
193
        factor to multiple the vector
194

195
    Returns
196
    --------
197
    v: array
198
        new direction vector (in u-space)
199
    """
200
    # choose axis in transformed space:
201
    j = np.random.randint(len(ui))
1✔
202
    v = region.transformLayer.axes[j] * scale
1✔
203
    return v
1✔
204

205

206
def generate_region_random_direction(ui, region, scale=1):
1✔
207
    """Sample a direction vector based on the region covariance.
208

209
    The region transformLayer axes are considered (:py:class:`AffineLayer` or :py:class:`ScalingLayer`).
210
    With this covariance matrix, a random direction is generated.
211
    Generating proceeds by transforming a unit multi-variate Gaussian.
212

213
    Parameters
214
    -----------
215
    ui: array
216
        starting point
217
    region: MLFriends object
218
        current region
219
    scale: float:
220
        length of direction vector (in t-space)
221

222
    Returns
223
    --------
224
    v: array
225
        new direction vector
226
    """
227
    # choose axis in transformed space:
228
    v1 = np.random.normal(0, 1, size=len(ui))
1✔
229
    v1 *= scale / np.linalg.norm(v1)
1✔
230
    v = np.dot(region.transformLayer.axes, v1)
1✔
231
    return v
1✔
232

233

234
def generate_mixture_random_direction(ui, region, scale=1):
1✔
235
    """Sample randomly uniformly from two proposals.
236

237
    Randomly applies either :py:func:`generate_differential_direction`,
238
    which transports far, or :py:func:`generate_region_oriented_direction`,
239
    which is stiffer.
240

241
    Best method according to https://arxiv.org/abs/2211.09426
242

243
    Parameters
244
    -----------
245
    region: MLFriends
246
        region
247
    ui: array
248
        vector of starting point
249
    scale: float
250
        length of the vector.
251

252
    Returns
253
    --------
254
    v: array
255
        new direction vector
256
    """
257
    if np.random.uniform() < 0.5:
1!
258
        # DE proposal
259
        return generate_differential_direction(ui, region, scale=scale)
1✔
260
    else:
261
        # region-oriented random axis proposal
262
        return generate_region_oriented_direction(ui, region, scale=scale)
×
263

264

265
def generate_region_sample_direction(ui, region, scale=1):
1✔
266
    """Sample a point directly from the region, and return the difference vector to the current point.
267

268
    Parameters
269
    -----------
270
    region: MLFriends
271
        region
272
    ui: array
273
        vector of starting point
274
    scale: float
275
        length of the vector.
276

277
    Returns
278
    --------
279
    v: array
280
        new direction vector
281
    """
282
    while True:
283
        upoints = region.sample(nsamples=200)
×
284
        if len(upoints) != 0:
×
285
            break
×
286
    # we only need the first one
287
    u = upoints[0,:]
×
288
    return (u - ui) * scale
×
289

290

291
def _inside_region(region, unew, uold):
1✔
292
    """Check if `unew` is inside region.
293

294
    This is a bit looser than the region, because it adds a
295
    MLFriends ellipsoid around the old point as well.
296
    """
297
    tnew = region.transformLayer.transform(unew)
1✔
298
    told = region.transformLayer.transform(uold)
1✔
299
    mask2 = ((told.reshape((1, -1)) - tnew)**2).sum(axis=1) < region.maxradiussq
1✔
300
    if mask2.all():
1!
301
        return mask2
1✔
302

303
    mask = region.inside(unew)
×
304
    return np.logical_or(mask, mask2)
×
305

306

307
def inside_region(region, unew, uold):
1✔
308
    """Check if `unew` is inside region.
309

310
    Parameters
311
    -----------
312
    region: MLFriends object
313
        current region
314
    unew: array
315
        point to check
316
    uold: array
317
        not used
318

319
    Returns
320
    --------
321
    v: array
322
        boolean whether point is inside the region
323
    """
324
    del uold
1✔
325
    return region.inside(unew)
1✔
326

327

328
def adapt_proposal_total_distances(region, history, mean_pair_distance, ndim):
1✔
329
    # compute mean vector of each proposed jump
330
    # compute total distance of all jumps
331
    tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history]))
1✔
332
    assert len(tproposed.sum(axis=1)) == len(tproposed)
1✔
333
    d2 = ((((tproposed[0] - tproposed)**2).sum(axis=1))**0.5).sum()
1✔
334
    far_enough = d2 > mean_pair_distance / ndim
1✔
335

336
    return far_enough, [d2, mean_pair_distance]
1✔
337

338

339
def adapt_proposal_total_distances_NN(region, history, mean_pair_distance, ndim):
1✔
340
    # compute mean vector of each proposed jump
341
    # compute total distance of all jumps
342
    tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history]))
×
343
    assert len(tproposed.sum(axis=1)) == len(tproposed)
×
344
    d2 = ((((tproposed[0] - tproposed)**2).sum(axis=1))**0.5).sum()
×
345
    far_enough = d2 > region.maxradiussq**0.5
×
346

347
    return far_enough, [d2, region.maxradiussq**0.5]
×
348

349

350
def adapt_proposal_summed_distances(region, history, mean_pair_distance, ndim):
1✔
351
    # compute sum of distances from each jump
352
    tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history]))
1✔
353
    d2 = (((tproposed[1:,:] - tproposed[:-1,:])**2).sum(axis=1)**0.5).sum()
1✔
354
    far_enough = d2 > mean_pair_distance / ndim
1✔
355

356
    return far_enough, [d2, mean_pair_distance]
1✔
357

358

359
def adapt_proposal_summed_distances_NN(region, history, mean_pair_distance, ndim):
1✔
360
    # compute sum of distances from each jump
361
    tproposed = region.transformLayer.transform(np.asarray([u for u, _ in history]))
×
362
    d2 = (((tproposed[1:,:] - tproposed[:-1,:])**2).sum(axis=1)**0.5).sum()
×
363
    far_enough = d2 > region.maxradiussq**0.5
×
364

365
    return far_enough, [d2, region.maxradiussq**0.5]
×
366

367

368
def adapt_proposal_move_distances(region, history, mean_pair_distance, ndim):
1✔
369
    """Compares random walk travel distance to MLFriends radius.
370

371
    Compares in whitened space (t-space), the L2 norm between final
372
    point and starting point to the MLFriends bootstrapped radius.
373

374
    Parameters
375
    ----------
376
    region: MLFriends
377
        built region
378
    history: list
379
        list of tuples, containing visited point and likelihood.
380
    mean_pair_distance: float
381
        not used
382
    ndim: int
383
        dimensionality
384

385
    Returns
386
    -------
387
    far_enough: bool
388
        whether the distance is larger than the radius
389
    info: tuple
390
        distance and radius (both float)
391
    """
392
    # compute distance from start to end
393
    ustart, _ = history[0]
1✔
394
    ufinal, _ = history[-1]
1✔
395
    tstart, tfinal = region.transformLayer.transform(np.vstack((ustart, ufinal)))
1✔
396
    d2 = ((tstart - tfinal)**2).sum()
1✔
397
    far_enough = d2 > region.maxradiussq
1✔
398

399
    return far_enough, [d2**0.5, region.maxradiussq**0.5]
1✔
400

401

402
def adapt_proposal_move_distances_midway(region, history, mean_pair_distance, ndim):
1✔
403
    """Compares first half of the travel distance to MLFriends radius.
404

405
    Compares in whitened space (t-space), the L2 norm between the
406
    middle point of the walk and the starting point,
407
    to the MLFriends bootstrapped radius.
408

409
    Parameters
410
    ----------
411
    region: MLFriends
412
        built region
413
    history: list
414
        list of tuples, containing visited point and likelihood.
415
    mean_pair_distance: float
416
        not used
417
    ndim: int
418
        dimensionality
419

420
    Returns
421
    -------
422
    far_enough: bool
423
        whether the distance is larger than the radius
424
    info: tuple
425
        distance and radius (both float)
426
    """
427
    # compute distance from start to end
428
    ustart, _ = history[0]
1✔
429
    middle = max(1, len(history) // 2)
1✔
430
    ufinal, _ = history[middle]
1✔
431
    tstart, tfinal = region.transformLayer.transform(np.vstack((ustart, ufinal)))
1✔
432
    d2 = ((tstart - tfinal)**2).sum()
1✔
433
    far_enough = d2 > region.maxradiussq
1✔
434

435
    return far_enough, [d2**0.5, region.maxradiussq**0.5]
1✔
436

437

438
def select_random_livepoint(us, Ls, Lmin):
1✔
439
    """Select random live point as chain starting point.
440

441
    Parameters
442
    -----------
443
    us: array
444
        positions of live points
445
    Ls: array
446
        likelihood of live points
447
    Lmin: float
448
        current log-likelihood threshold
449

450
    Returns
451
    -------
452
    i: int
453
        index of live point selected
454
    """
455
    return np.random.randint(len(Ls))
1✔
456

457

458
class IslandPopulationRandomLivepointSelector(object):
1✔
459
    def __init__(self, island_size, exchange_probability=0):
1✔
460
        """Set up multiple isolated islands.
461

462
        To replace dead points, chains are only started from the same
463
        island as the dead point. Island refers to chunks of
464
        live point indices (0,1,2,3 as stored, not sorted).
465
        Each chunk has size ´island_size´.
466

467
        If ´island_size´ is large, for example, the total number of live points,
468
        then clumping can occur more easily. This is the observed behaviour
469
        that a limited random walk is run from one live point, giving
470
        two similar points, then the next dead point replacement is
471
        likely run again from these, giving more and more similar live points.
472
        This gives a run-away process leading to clumps of similar,
473
        highly correlated points.
474

475
        If ´island_size´ is small, for example, 1, then each dead point
476
        is replaced by a chain started from it. This is a problem because
477
        modes can never die out. Nested sampling can then not complete.
478

479
        In a multi-modal run, within a given number of live points,
480
        the number of live points per mode is proportional to the mode's
481
        prior volume, but can fluctuate. If the number of live points
482
        is small, a fluctuation can lead to mode die-out, which cannot
483
        be reversed. Therefore, the number of island members should be
484
        large enough to represent each mode.
485

486
        Parameters
487
        -----------
488
        island_size: int
489
            maximum number of members on each isolated live point
490
            population.
491

492
        exchange_probability: float
493
            Probability that a member from a random island will be picked.
494

495
        """
496
        assert island_size > 0
1✔
497
        self.island_size = island_size
1✔
498
        assert 0 <= exchange_probability <= 1
1✔
499
        self.exchange_probability = exchange_probability
1✔
500

501
    def __call__(self, us, Ls, Lmin):
1✔
502
        """Select live point as chain starting point.
503

504
        Parameters
505
        -----------
506
        us: array
507
            positions of live points
508
        Ls: array
509
            likelihood of live points
510
        Lmin: float
511
            current log-likelihood threshold
512

513
        Returns
514
        -------
515
        i: int
516
            index of live point selected
517
        """
518
        mask_deadpoints = Lmin == Ls
1✔
519
        if not mask_deadpoints.any() or (self.exchange_probability > 0 and np.random.uniform() < self.exchange_probability):
1✔
520
            return np.random.randint(len(Ls))
1✔
521

522
        # find the dead point we should replace
523
        j = np.where(mask_deadpoints)[0][0]
1✔
524
        # start in the same island
525
        island = j // self.island_size
1✔
526
        # pick a random member from the island
527
        return np.random.randint(
1✔
528
            island * self.island_size,
529
            min(len(Ls), (island + 1) * self.island_size))
530

531

532
class StepSampler(object):
1✔
533
    """Base class for a simple step sampler, staggering around.
534

535
    Scales proposal towards a 50% acceptance rate.
536
    """
537

538
    def __init__(
1✔
539
        self, nsteps, generate_direction,
540
        scale=1.0, check_nsteps='move-distance', adaptive_nsteps=False, max_nsteps=1000,
541
        region_filter=False, log=False,
542
        starting_point_selector=select_random_livepoint,
543
    ):
544
        """Initialise sampler.
545

546
        Parameters
547
        -----------
548
        scale: float
549
            initial proposal size
550

551
        nsteps: int
552
            number of accepted steps until the sample is considered independent.
553

554
            To find the right value, see :py:class:`ultranest.calibrator.ReactiveNestedCalibrator`
555

556
        generate_direction: function
557
            direction proposal function.
558

559
            Available are:
560

561
            * :py:func:`generate_cube_oriented_direction` (slice sampling, picking one random parameter to vary)
562
            * :py:func:`generate_random_direction` (hit-and-run sampling, picking a random direction varying all parameters)
563
            * :py:func:`generate_differential_direction` (differential evolution direction proposal)
564
            * :py:func:`generate_region_oriented_direction` (slice sampling, but in the whitened parameter space)
565
            * :py:func:`generate_region_random_direction` (hit-and-run sampling, but in the whitened parameter space)
566
            * :py:class:`SequentialDirectionGenerator` (sequential slice sampling, i.e., iterate deterministically through the parameters)
567
            * :py:class:`SequentialRegionDirectionGenerator` (sequential slice sampling in the whitened parameter space, i.e., iterate deterministically through the principle axes)
568
            * :py:func:`generate_cube_oriented_differential_direction` (like generate_differential_direction, but along only one randomly chosen parameter)
569
            * :py:func:`generate_partial_differential_direction` (differential evolution slice proposal on only 10% of the parameters)
570
            * :py:func:`generate_mixture_random_direction` (combined proposal)
571

572
            Additionally, :py:class:`OrthogonalDirectionGenerator`
573
            can be applied to any generate_direction function.
574

575
            When in doubt, try :py:func:`generate_mixture_random_direction`.
576
            It combines efficient moves along the live point distribution,
577
            with robustness against collapse to a subspace.
578
            :py:func:`generate_cube_oriented_direction` works well too.
579

580
        adaptive_nsteps: False or str
581
            Strategy to adapt the number of steps.
582
            The possible values are the same as for `check_nsteps`.
583

584
            Adapting can give usable results. However, strictly speaking,
585
            detailed balance is not maintained, so the results can be biased.
586
            You can use the stepsampler.logstat property to find out the `nsteps` learned
587
            from one run (third column), and use the largest value for `nsteps`
588
            for a fresh run.
589
            The forth column is the jump distance, the fifth column is the reference distance.
590

591
        check_nsteps: False or str
592
            Method to diagnose the step sampler walks. The options are:
593

594
            * False: no checking
595
            * 'move-distance' (recommended): distance between
596
              start point and final position exceeds the mean distance
597
              between pairs of live points.
598
            * 'move-distance-midway': distance between
599
              start point and position in the middle of the chain
600
              exceeds the mean distance between pairs of live points.
601
            * 'proposal-total-distances': mean square distance of
602
              proposed vectors exceeds the mean distance
603
              between pairs of live points.
604
            * 'proposal-total-distances-NN': mean distance
605
              of chain points from starting point exceeds mean distance
606
              between pairs of live points.
607
            * 'proposal-summed-distances-NN': summed distances
608
              between chain points exceeds mean distance
609
              between pairs of live points.
610
            * 'proposal-summed-distances-min-NN': smallest distance
611
              between chain points exceeds mean distance
612
              between pairs of live points.
613

614
            Each step sampler walk adds one row to stepsampler.logstat.
615
            The jump distance (forth column) should be compared to
616
            the reference distance (fifth column).
617

618
        max_nsteps: int
619
            Maximum number of steps the adaptive_nsteps can reach.
620

621
        region_filter: bool
622
            if True, use region to check if a proposed point can be inside
623
            before calling likelihood.
624

625
        log: file
626
            log file for sampler statistics, such as acceptance rate,
627
            proposal scale, number of steps, jump distance and distance
628
            between live points
629

630
        starting_point_selector: func
631
            function which given the live point positions us,
632
            their log-likelihoods Ls and the current log-likelihood
633
            threshold Lmin, returns the index i of the selected live
634
            point to start a new chain from.
635
            Examples: :py:func:`select_random_livepoint`, which has
636
            always been the default behaviour,
637
            or an instance of :py:class:`IslandPopulationRandomLivepointSelector`.
638

639
        """
640
        self.history = []
1✔
641
        self.nsteps = nsteps
1✔
642
        self.nrejects = 0
1✔
643
        self.scale = scale
1✔
644
        self.max_nsteps = max_nsteps
1✔
645
        self.next_scale = self.scale
1✔
646
        self.nudge = 1.1**(1. / self.nsteps)
1✔
647
        self.nsteps_nudge = 1.01
1✔
648
        self.generate_direction = generate_direction
1✔
649
        check_nsteps_options = {
1✔
650
            False: None,
651
            'move-distance': adapt_proposal_move_distances,
652
            'move-distance-midway': adapt_proposal_move_distances_midway,
653
            'proposal-total-distances': adapt_proposal_total_distances,
654
            'proposal-total-distances-NN': adapt_proposal_total_distances_NN,
655
            'proposal-summed-distances': adapt_proposal_summed_distances,
656
            'proposal-summed-distances-NN': adapt_proposal_summed_distances_NN,
657
        }
658
        adaptive_nsteps_options = dict(check_nsteps_options)
1✔
659

660
        if adaptive_nsteps not in adaptive_nsteps_options.keys():
1✔
661
            raise ValueError("adaptive_nsteps must be one of: %s, not '%s'" % (adaptive_nsteps_options, adaptive_nsteps))
1✔
662
        if check_nsteps not in check_nsteps_options.keys():
1!
663
            raise ValueError("check_nsteps must be one of: %s, not '%s'" % (adaptive_nsteps_options, adaptive_nsteps))
×
664
        self.adaptive_nsteps = adaptive_nsteps
1✔
665
        if self.adaptive_nsteps:
1✔
666
            assert nsteps <= max_nsteps, 'Invalid adapting configuration: provided nsteps=%d exceeds provided max_nsteps=%d' % (nsteps, max_nsteps)
1✔
667
        self.adaptive_nsteps_function = adaptive_nsteps_options[adaptive_nsteps]
1✔
668
        self.check_nsteps = check_nsteps
1✔
669
        self.check_nsteps_function = check_nsteps_options[check_nsteps]
1✔
670
        self.adaptive_nsteps_needs_mean_pair_distance = self.adaptive_nsteps in (
1✔
671
            'proposal-total-distances', 'proposal-summed-distances',
672
        ) or self.check_nsteps in (
673
            'proposal-total-distances', 'proposal-summed-distances',
674
        )
675
        self.starting_point_selector = starting_point_selector
1✔
676
        self.mean_pair_distance = np.nan
1✔
677
        self.region_filter = region_filter
1✔
678
        if log:
1✔
679
            assert hasattr(log, 'write'), 'log argument should be a file, use log=open(filename, "w") or similar'
1✔
680
        self.log = log
1✔
681

682
        self.logstat = []
1✔
683
        self.logstat_labels = ['rejection_rate', 'scale', 'steps']
1✔
684
        if adaptive_nsteps or check_nsteps:
1!
685
            self.logstat_labels += ['jump-distance', 'reference-distance']
1✔
686

687
    def __str__(self):
1✔
688
        """Return string representation."""
689
        if not self.adaptive_nsteps:
1✔
690
            return type(self).__name__ + '(nsteps=%d, generate_direction=%s)' % (self.nsteps, self.generate_direction)
1✔
691
        else:
692
            return type(self).__name__ + '(adaptive_nsteps=%s, generate_direction=%s)' % (self.adaptive_nsteps, self.generate_direction)
1✔
693

694
    def plot(self, filename):
1✔
695
        """Plot sampler statistics.
696

697
        Parameters
698
        -----------
699
        filename: str
700
            Stores plot into ``filename`` and data into
701
            ``filename + ".txt.gz"``.
702
        """
703
        if len(self.logstat) == 0:
1!
704
            return
×
705

706
        plt.figure(figsize=(10, 1 + 3 * len(self.logstat_labels)))
1✔
707
        for i, label in enumerate(self.logstat_labels):
1✔
708
            part = [entry[i] for entry in self.logstat]
1✔
709
            plt.subplot(len(self.logstat_labels), 1, 1 + i)
1✔
710
            plt.ylabel(label)
1✔
711
            plt.plot(part)
1✔
712
            x = []
1✔
713
            y = []
1✔
714
            for j in range(0, len(part), 20):
1✔
715
                x.append(j)
1✔
716
                y.append(np.mean(part[j:j + 20]))
1✔
717
            plt.plot(x, y)
1✔
718
            if np.min(part) > 0:
1!
719
                plt.yscale('log')
1✔
720
        plt.savefig(filename, bbox_inches='tight')
1✔
721
        np.savetxt(filename + '.txt.gz', self.logstat,
1✔
722
                   header=','.join(self.logstat_labels), delimiter=',')
723
        plt.close()
1✔
724

725
    @property
1✔
726
    def mean_jump_distance(self):
1✔
727
        """Geometric mean jump distance."""
728
        if len(self.logstat) == 0:
1✔
729
            return np.nan
1✔
730
        if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels:
1!
731
            return np.nan
×
732
        i = self.logstat_labels.index('jump-distance')
1✔
733
        j = self.logstat_labels.index('reference-distance')
1✔
734
        jump_distances = np.array([entry[i] for entry in self.logstat])
1✔
735
        reference_distances = np.array([entry[j] for entry in self.logstat])
1✔
736
        return np.exp(np.nanmean(np.log(jump_distances / reference_distances + 1e-10)))
1✔
737

738
    @property
1✔
739
    def far_enough_fraction(self):
1✔
740
        """Fraction of jumps exceeding reference distance."""
741
        if len(self.logstat) == 0:
1✔
742
            return np.nan
1✔
743
        if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels:
1!
744
            return np.nan
×
745
        i = self.logstat_labels.index('jump-distance')
1✔
746
        j = self.logstat_labels.index('reference-distance')
1✔
747
        jump_distances = np.array([entry[i] for entry in self.logstat])
1✔
748
        reference_distances = np.array([entry[j] for entry in self.logstat])
1✔
749
        return np.nanmean(jump_distances > reference_distances)
1✔
750

751
    def get_info_dict(self):
1✔
752
        return dict(
1✔
753
            num_logs=len(self.logstat),
754
            rejection_rate=np.nanmean([entry[0] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan,
755
            mean_scale=np.nanmean([entry[1] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan,
756
            mean_nsteps=np.nanmean([entry[2] for entry in self.logstat]) if len(self.logstat) > 0 else np.nan,
757
            mean_distance=self.mean_jump_distance,
758
            frac_far_enough=self.far_enough_fraction,
759
            last_logstat=dict(zip(self.logstat_labels, self.logstat[-1] if len(self.logstat) > 1 else [np.nan] * len(self.logstat_labels)))
760
        )
761

762

763
    def print_diagnostic(self):
1✔
764
        """Print diagnostic of step sampler performance."""
765
        if len(self.logstat) == 0:
1!
766
            print("diagnostic unavailable, no recorded steps found")
×
767
            return
×
768
        if 'jump-distance' not in self.logstat_labels or 'reference-distance' not in self.logstat_labels:
1!
769
            print("turn on check_nsteps in the step sampler for diagnostics")
×
770
            return
×
771
        frac_farenough = self.far_enough_fraction
1✔
772
        average_distance = self.mean_jump_distance
1✔
773
        if frac_farenough < 0.5:
1✔
774
            advice = ': very fishy. Double nsteps and see if fraction and lnZ change)'
1✔
775
        elif frac_farenough < 0.66:
1!
776
            advice = ': fishy. Double nsteps and see if fraction and lnZ change)'
×
777
        else:
778
            advice = ' (should be >50%)'
1✔
779
        print('step sampler diagnostic: jump distance %.2f (should be >1), far enough fraction: %.2f%% %s' % (
1✔
780
            average_distance, frac_farenough * 100, advice))
781

782
    def plot_jump_diagnostic_histogram(self, filename, **kwargs):
1✔
783
        """Plot jump diagnostic histogram."""
784
        if len(self.logstat) == 0:
1!
785
            return
×
786
        if 'jump-distance' not in self.logstat_labels:
1!
787
            return
×
788
        if 'reference-distance' not in self.logstat_labels:
1!
789
            return
×
790
        i = self.logstat_labels.index('jump-distance')
1✔
791
        j = self.logstat_labels.index('reference-distance')
1✔
792
        jump_distances = np.array([entry[i] for entry in self.logstat])
1✔
793
        reference_distances = np.array([entry[j] for entry in self.logstat])
1✔
794
        plt.hist(np.log10(jump_distances / reference_distances + 1e-10), **kwargs)
1✔
795
        ylo, yhi = plt.ylim()
1✔
796
        plt.vlines(np.log10(self.mean_jump_distance), ylo, yhi)
1✔
797
        plt.ylim(ylo, yhi)
1✔
798
        plt.title(self.check_nsteps or self.adaptive_nsteps)
1✔
799
        plt.xlabel('log(relative step distance)')
1✔
800
        plt.ylabel('Frequency')
1✔
801
        plt.savefig(filename, bbox_inches='tight')
1✔
802
        plt.close()
1✔
803

804
    def move(self, ui, region, ndraw=1, plot=False):
1✔
805
        """Move around point ``ui``. Stub to be implemented by child classes."""
806
        raise NotImplementedError()
×
807

808
    def adjust_outside_region(self):
1✔
809
        """Adjust proposal, given that we landed outside region."""
810
        print("ineffective proposal scale (%g). shrinking..." % self.scale)
1✔
811

812
        # Usually the region is very generous.
813
        # Being here means that the scale is very wrong and we are probably stuck.
814
        # Adjust it and restart the chain
815
        self.scale /= self.nudge**10
1✔
816
        self.next_scale /= self.nudge**10
1✔
817
        assert self.scale > 0
1✔
818
        assert self.next_scale > 0
1✔
819
        # reset chain
820
        if self.adaptive_nsteps or self.check_nsteps:
1!
821
            self.logstat.append([-1.0, self.scale, self.nsteps, np.nan, np.nan])
1✔
822
        else:
823
            self.logstat.append([-1.0, self.scale, self.nsteps])
×
824

825
    def adjust_accept(self, accepted, unew, pnew, Lnew, nc):
1✔
826
        """Adjust proposal, given that a new point was found after `nc` calls.
827

828
        Parameters
829
        -----------
830
        accepted: bool
831
            Whether the most recent proposal was accepted
832
        unew: array
833
            new point (in u-space)
834
        pnew: array
835
            new point (in p-space)
836
        Lnew: float
837
            loglikelihood of new point
838
        nc: int
839
            number of likelihood function calls used.
840
        """
841
        if accepted:
1✔
842
            self.next_scale *= self.nudge
1✔
843
            self.history.append((unew.copy(), Lnew.copy()))
1✔
844
        else:
845
            self.next_scale /= self.nudge**10
1✔
846
            self.nrejects += 1
1✔
847
            self.history.append(self.history[-1])
1✔
848
        assert self.next_scale > 0, self.next_scale
1✔
849

850
    def adapt_nsteps(self, region):
1✔
851
        """
852
        Adapt the number of steps.
853

854
        Parameters
855
        -----------
856
        region: MLFriends object
857
            current region
858
        """
859
        if not (self.adaptive_nsteps or self.check_nsteps):
1!
860
            return
×
861
        if len(self.history) < self.nsteps:
1!
862
            # incomplete or aborted for some reason
863
            print("not adapting/checking nsteps, incomplete history", len(self.history), self.nsteps)
×
864
            return
×
865

866
        if self.adaptive_nsteps_needs_mean_pair_distance:
1✔
867
            assert np.isfinite(self.mean_pair_distance)
1✔
868
        ndim = region.u.shape[1]
1✔
869
        if self.check_nsteps:
1!
870
            far_enough, extra_info = self.check_nsteps_function(region, self.history, self.mean_pair_distance, ndim)
1✔
871
            self.logstat[-1] += extra_info
1✔
872
        if not self.adaptive_nsteps:
1✔
873
            return
1✔
874

875
        far_enough, extra_info = self.adaptive_nsteps_function(region, self.history, self.mean_pair_distance, ndim)
1✔
876
        self.logstat[-1] += extra_info
1✔
877

878
        # adjust nsteps
879
        if far_enough:
1✔
880
            self.nsteps = min(self.nsteps - 1, int(self.nsteps / self.nsteps_nudge))
1✔
881
        else:
882
            self.nsteps = max(self.nsteps + 1, int(self.nsteps * self.nsteps_nudge))
1✔
883
        self.nsteps = max(1, min(self.max_nsteps, self.nsteps))
1✔
884

885
    def finalize_chain(self, region=None, Lmin=None, Ls=None):
1✔
886
        """Store chain statistics and adapt proposal.
887

888
        Parameters
889
        -----------
890
        region: MLFriends object
891
            current region
892
        Lmin: float
893
            current loglikelihood threshold
894
        Ls: array
895
            loglikelihood values of the live points
896
        """
897
        self.logstat.append([self.nrejects / self.nsteps, self.scale, self.nsteps])
1✔
898
        if self.log:
1✔
899
            ustart, Lstart = self.history[0]
1✔
900
            ufinal, Lfinal = self.history[-1]
1✔
901
            # mean_pair_distance = region.compute_mean_pair_distance()
902
            mean_pair_distance = self.mean_pair_distance
1✔
903
            tstart, tfinal = region.transformLayer.transform(np.vstack((ustart, ufinal)))
1✔
904
            # L index of start and end
905
            # Ls_sorted = np.sort(Ls)
906
            iLstart = np.sum(Ls > Lstart)
1✔
907
            iLfinal = np.sum(Ls > Lfinal)
1✔
908
            # nearest neighbor index of start and end
909
            itstart = np.argmin((region.unormed - tstart.reshape((1, -1)))**2)
1✔
910
            itfinal = np.argmin((region.unormed - tfinal.reshape((1, -1)))**2)
1✔
911
            np.savetxt(self.log, [_listify(
1✔
912
                [Lmin], ustart, ufinal, tstart, tfinal,
913
                [self.nsteps, region.maxradiussq**0.5, mean_pair_distance,
914
                 iLstart, iLfinal, itstart, itfinal])])
915
            self.log.flush()
1✔
916

917
        if self.adaptive_nsteps or self.check_nsteps:
1!
918
            self.adapt_nsteps(region=region)
1✔
919

920
        if self.next_scale > self.scale * self.nudge**10:
1✔
921
            self.next_scale = self.scale * self.nudge**10
1✔
922
        elif self.next_scale < self.scale / self.nudge**10:
1✔
923
            self.next_scale = self.scale / self.nudge**10
1✔
924
        # print("updating scale: %g -> %g" % (self.scale, self.next_scale))
925
        self.scale = self.next_scale
1✔
926
        self.history = []
1✔
927
        self.nrejects = 0
1✔
928

929
    def new_chain(self, region=None):
1✔
930
        """Start a new path, reset statistics."""
931
        self.history = []
1✔
932
        self.nrejects = 0
1✔
933

934
    def region_changed(self, Ls, region):
1✔
935
        """React to change of region.
936

937
        Parameters
938
        -----------
939
        region: MLFriends object
940
            current region
941
        Ls: array
942
            loglikelihood values of the live points
943
        """
944
        if self.adaptive_nsteps_needs_mean_pair_distance:
1✔
945
            self.mean_pair_distance = region.compute_mean_pair_distance()
1✔
946
            # print("region changed. new mean_pair_distance: %g" % self.mean_pair_distance)
947

948
    def __next__(self, region, Lmin, us, Ls, transform, loglike, ndraw=10, plot=False, tregion=None):
1✔
949
        """Get next point.
950

951
        Parameters
952
        ----------
953
        region: MLFriends
954
            region.
955
        Lmin: float
956
            loglikelihood threshold
957
        us: array of vectors
958
            current live points
959
        Ls: array of floats
960
            current live point likelihoods
961
        transform: function
962
            transform function
963
        loglike: function
964
            loglikelihood function
965
        ndraw: int
966
            number of draws to attempt simultaneously.
967
        plot: bool
968
            whether to produce debug plots.
969
        tregion: :py:class:`WrappingEllipsoid`
970
            optional ellipsoid in transformed space for rejecting proposals
971

972
        """
973
        # find most recent point in history conforming to current Lmin
974
        for j, (uj, Lj) in enumerate(self.history):
1✔
975
            if not Lj > Lmin:
1✔
976
                self.history = self.history[:j]
1✔
977
                # print("wandered out of L constraint; reverting", ui[0])
978
                break
1✔
979
        if len(self.history) > 0:
1✔
980
            ui, Li = self.history[-1]
1✔
981
        else:
982
            # select starting point
983
            self.new_chain(region)
1✔
984
            # choose a new random starting point
985
            # mask = region.inside(us)
986
            # assert mask.any(), ("One of the live points does not satisfies the current region!",
987
            #    region.maxradiussq, region.u, region.unormed, us)
988
            i = self.starting_point_selector(us, Ls, Lmin)
1✔
989
            self.starti = i
1✔
990
            ui = us[i,:]
1✔
991
            # print("starting at", ui[0])
992
            # assert np.logical_and(ui > 0, ui < 1).all(), ui
993
            Li = Ls[i]
1✔
994
            self.history.append((ui.copy(), Li.copy()))
1✔
995
            del i
1✔
996

997
        while True:
998
            unew = self.move(ui, region, ndraw=ndraw, plot=plot)
1✔
999
            # print("proposed: %s -> %s" % (ui, unew))
1000
            if plot:
1!
1001
                plt.plot([ui[0], unew[:,0]], [ui[1], unew[:,1]], '-', color='k', lw=0.5)
×
1002
                plt.plot(ui[0], ui[1], 'd', color='r', ms=4)
×
1003
                plt.plot(unew[:,0], unew[:,1], 'x', color='r', ms=4)
×
1004
            mask = np.logical_and(unew > 0, unew < 1).all(axis=1)
1✔
1005
            if not mask.any():
1✔
1006
                # print("rejected by unit cube")
1007
                self.adjust_outside_region()
1✔
1008
                continue
1✔
1009
            unew = unew[mask,:]
1✔
1010
            nc = 0
1✔
1011
            if self.region_filter:
1✔
1012
                mask = inside_region(region, unew, ui)
1✔
1013
                if not mask.any():
1✔
1014
                    print("rejected by region")
1✔
1015
                    self.adjust_outside_region()
1✔
1016
                    continue
1✔
1017
                unew = unew[mask,:]
1✔
1018
                if tregion is not None:
1!
1019
                    pnew = transform(unew)
×
1020
                    tmask = tregion.inside(pnew)
×
1021
                    unew = unew[tmask,:]
×
1022
                    pnew = pnew[tmask,:]
×
1023

1024
            if len(unew) == 0:
1!
1025
                self.adjust_outside_region()
×
1026
                continue
×
1027
            break
×
1028

1029
        unew = unew[0,:]
1✔
1030
        pnew = transform(unew.reshape((1, -1)))
1✔
1031
        Lnew = loglike(pnew)[0]
1✔
1032
        nc = 1
1✔
1033
        if Lnew > Lmin:
1✔
1034
            if plot:
1!
1035
                plt.plot(unew[0], unew[1], 'o', color='g', ms=4)
×
1036
            self.adjust_accept(True, unew, pnew, Lnew, nc)
1✔
1037
        else:
1038
            self.adjust_accept(False, unew, pnew, Lnew, nc)
1✔
1039

1040
        if len(self.history) > self.nsteps:
1✔
1041
            # print("made %d steps" % len(self.history), Lnew, Lmin)
1042
            u, L = self.history[-1]
1✔
1043
            p = transform(u.reshape((1, -1)))[0]
1✔
1044
            self.finalize_chain(region=region, Lmin=Lmin, Ls=Ls)
1✔
1045
            return u, p, L, nc
1✔
1046

1047
        # do not have a independent sample yet
1048
        return None, None, None, nc
1✔
1049

1050

1051
class MHSampler(StepSampler):
1✔
1052
    """Gaussian Random Walk."""
1053

1054
    def move(self, ui, region, ndraw=1, plot=False):
1✔
1055
        """Move in u-space with a Gaussian proposal.
1056

1057
        Parameters
1058
        ----------
1059
        ui: array
1060
            current point
1061
        ndraw: int
1062
            number of points to draw.
1063
        region:
1064
            ignored
1065
        plot:
1066
            ignored
1067
        """
1068
        # propose in that direction
1069
        direction = self.generate_direction(ui, region, scale=self.scale)
1✔
1070
        jitter = direction * np.random.normal(0, 1, size=(min(10, ndraw), 1))
1✔
1071
        unew = ui.reshape((1, -1)) + jitter
1✔
1072
        return unew
1✔
1073

1074

1075
def CubeMHSampler(*args, **kwargs):
1✔
1076
    """Gaussian Metropolis-Hastings sampler, using unit cube."""
1077
    return MHSampler(*args, **kwargs, generate_direction=generate_random_direction)
1✔
1078

1079

1080
def RegionMHSampler(*args, **kwargs):
1✔
1081
    """Gaussian Metropolis-Hastings sampler, using region."""
1082
    return MHSampler(*args, **kwargs, generate_direction=generate_region_random_direction)
1✔
1083

1084

1085
class SliceSampler(StepSampler):
1✔
1086
    """Slice sampler, respecting the region."""
1087

1088
    def new_chain(self, region=None):
1✔
1089
        """Start a new path, reset slice."""
1090
        self.interval = None
1✔
1091
        self.found_left = False
1✔
1092
        self.found_right = False
1✔
1093
        self.axis_index = 0
1✔
1094

1095
        self.history = []
1✔
1096
        self.nrejects = 0
1✔
1097

1098
    def adjust_accept(self, accepted, unew, pnew, Lnew, nc):
1✔
1099
        """See :py:meth:`StepSampler.adjust_accept`."""
1100
        v, left, right, u = self.interval
1✔
1101
        if not self.found_left:
1✔
1102
            if accepted:
1✔
1103
                self.interval = (v, left * 2, right, u)
1✔
1104
            else:
1105
                self.found_left = True
1✔
1106
        elif not self.found_right:
1✔
1107
            if accepted:
1✔
1108
                self.interval = (v, left, right * 2, u)
1✔
1109
            else:
1110
                self.found_right = True
1✔
1111
                # adjust scale
1112
                if -left > self.next_scale or right > self.next_scale:
1✔
1113
                    self.next_scale *= 1.1
1✔
1114
                else:
1115
                    self.next_scale /= 1.1
1✔
1116
                # print("adjusting after accept...", self.next_scale)
1117
        else:
1118
            if accepted:
1✔
1119
                # start with a new interval next time
1120
                self.interval = None
1✔
1121

1122
                self.history.append((unew.copy(), Lnew.copy()))
1✔
1123
            else:
1124
                self.nrejects += 1
1✔
1125
                # shrink current interval
1126
                if u == 0:
1!
1127
                    pass
×
1128
                elif u < 0:
1✔
1129
                    left = u
1✔
1130
                elif u > 0:
1!
1131
                    right = u
1✔
1132

1133
                self.interval = (v, left, right, u)
1✔
1134

1135
    def adjust_outside_region(self):
1✔
1136
        """Adjust proposal given that we landed outside region."""
1137
        self.adjust_accept(False, unew=None, pnew=None, Lnew=None, nc=0)
1✔
1138

1139
    def move(self, ui, region, ndraw=1, plot=False):
1✔
1140
        """Advance the slice sampling move. see :py:meth:`StepSampler.move`."""
1141
        if self.interval is None:
1✔
1142
            v = self.generate_direction(ui, region)
1✔
1143

1144
            # expand direction until it is surely outside
1145
            left = -self.scale
1✔
1146
            right = self.scale
1✔
1147
            self.found_left = False
1✔
1148
            self.found_right = False
1✔
1149
            u = 0
1✔
1150

1151
            self.interval = (v, left, right, u)
1✔
1152

1153
        else:
1154
            v, left, right, u = self.interval
1✔
1155

1156
        if plot:
1!
1157
            plt.plot([(ui + v * left)[0], (ui + v * right)[0]],
×
1158
                     [(ui + v * left)[1], (ui + v * right)[1]],
1159
                     ':o', color='k', lw=2, alpha=0.3)
1160

1161
        # shrink direction if outside
1162
        if not self.found_left:
1✔
1163
            xj = ui + v * left
1✔
1164

1165
            if not self.region_filter or inside_region(region, xj.reshape((1, -1)), ui):
1✔
1166
                return xj.reshape((1, -1))
1✔
1167
            else:
1168
                self.found_left = True
1✔
1169

1170
        if not self.found_right:
1✔
1171
            xj = ui + v * right
1✔
1172

1173
            if not self.region_filter or inside_region(region, xj.reshape((1, -1)), ui):
1✔
1174
                return xj.reshape((1, -1))
1✔
1175
            else:
1176
                self.found_right = True
1✔
1177

1178
                # adjust scale to final slice length
1179
                if -left > self.next_scale or right > self.next_scale:
1✔
1180
                #if right - left > self.next_scale:
1181
                    self.next_scale *= 1.1
1✔
1182
                else:
1183
                    self.next_scale /= 1.1
1✔
1184
                # print("adjusting scale...", self.next_scale)
1185

1186
        while True:
1187
            u = np.random.uniform(left, right)
1✔
1188
            xj = ui + v * u
1✔
1189

1190
            if not self.region_filter or inside_region(region, xj.reshape((1, -1)), ui):
1✔
1191
                self.interval = (v, left, right, u)
1✔
1192
                return xj.reshape((1, -1))
1✔
1193
            else:
1194
                if u < 0:
1✔
1195
                    left = u
1✔
1196
                else:
1197
                    right = u
1✔
1198
                self.interval = (v, left, right, u)
1✔
1199

1200

1201
def CubeSliceSampler(*args, **kwargs):
1✔
1202
    """Slice sampler, randomly picking region axes."""
1203
    return SliceSampler(*args, **kwargs, generate_direction=SequentialDirectionGenerator())
1✔
1204

1205

1206
def RegionSliceSampler(*args, **kwargs):
1✔
1207
    """Slice sampler, randomly picking region axes."""
1208
    return SliceSampler(*args, **kwargs, generate_direction=generate_region_oriented_direction)
1✔
1209

1210

1211
def BallSliceSampler(*args, **kwargs):
1✔
1212
    """Hit & run sampler. Choose random directions in space."""
1213
    return SliceSampler(*args, **kwargs, generate_direction=generate_random_direction)
×
1214

1215

1216
def RegionBallSliceSampler(*args, **kwargs):
1✔
1217
    """Hit & run sampler. Choose random directions according to region."""
1218
    return SliceSampler(*args, **kwargs, generate_direction=generate_region_random_direction)
×
1219

1220

1221
class SequentialDirectionGenerator(object):
1✔
1222
    """Sequentially proposes one parameter after the next."""
1223
    def __init__(self):
1✔
1224
        """Initialise."""
1225
        self.axis_index = 0
1✔
1226

1227
    def __call__(self, ui, region, scale=1):
1✔
1228
        """Choose the next axis in u-space.
1229

1230
        Parameters
1231
        -----------
1232
        ui: array
1233
            current point (in u-space)
1234
        region: MLFriends object
1235
            pick random two live points for length along axis
1236
        scale: float
1237
            length of direction vector
1238

1239
        Returns
1240
        --------
1241
        v: array
1242
            new direction vector (in u-space)
1243
        """
1244
        nlive, ndim = region.u.shape
1✔
1245
        j = self.axis_index % ndim
1✔
1246
        self.axis_index = j + 1
1✔
1247

1248
        v = np.zeros(ndim)
1✔
1249
        # choose pair of live points
1250
        while v[j] == 0:
1✔
1251
            i = np.random.randint(nlive)
1✔
1252
            i2 = np.random.randint(nlive - 1)
1✔
1253
            if i2 >= i:
1✔
1254
                i2 += 1
1✔
1255

1256
            v[j] = (region.u[i,j] - region.u[i2,j]) * scale
1✔
1257

1258
        return v
1✔
1259

1260
    def __str__(self):
1✔
1261
        return type(self).__name__ + '()'
1✔
1262

1263

1264
class SequentialRegionDirectionGenerator(object):
1✔
1265
    """Sequentially proposes one region axes after the next."""
1266
    def __init__(self):
1✔
1267
        """Initialise."""
1268
        self.axis_index = 0
1✔
1269

1270
    def __call__(self, ui, region, scale=1):
1✔
1271
        """Choose the next axis in t-space.
1272

1273
        Parameters
1274
        -----------
1275
        ui: array
1276
            current point (in u-space)
1277
        region: MLFriends object
1278
            region to use for transformation
1279
        scale: float
1280
            length of direction vector
1281

1282
        Returns
1283
        --------
1284
        v: array
1285
            new direction vector (in u-space)
1286
        """
1287
        ndim = len(ui)
1✔
1288
        ti = region.transformLayer.transform(ui)
1✔
1289

1290
        # choose axis in transformed space:
1291
        j = self.axis_index % ndim
1✔
1292
        self.axis_index = j + 1
1✔
1293
        tv = np.zeros(ndim)
1✔
1294
        tv[j] = 1.0
1✔
1295
        # convert back to unit cube space:
1296
        uj = region.transformLayer.untransform(ti + tv * 1e-3)
1✔
1297
        v = uj - ui
1✔
1298
        v *= scale / (v**2).sum()**0.5
1✔
1299
        return v
1✔
1300

1301
    def __str__(self):
1✔
1302
        return type(self).__name__ + '()'
×
1303

1304

1305
def RegionSequentialSliceSampler(*args, **kwargs):
1✔
1306
    """Slice sampler, sequentially iterating region axes."""
1307
    return SliceSampler(*args, **kwargs, generate_direction=SequentialRegionDirectionGenerator())
×
1308

1309

1310
class OrthogonalDirectionGenerator(object):
1✔
1311
    """Orthogonalizes proposal vectors.
1312

1313
    Samples N proposed vectors by a provided method, then orthogonalizes
1314
    them with Gram-Schmidt (QR decomposition).
1315
    """
1316

1317
    def __init__(self, generate_direction):
1✔
1318
        """Initialise.
1319

1320
        Parameters
1321
        -----------
1322
        generate_direction: function
1323
            direction proposal to orthogonalize
1324
        """
1325
        self.axis_index = 0
1✔
1326
        self.generate_direction = generate_direction
1✔
1327
        self.directions = None
1✔
1328

1329
    def __str__(self):
1✔
1330
        """Return string representation."""
1331
        return type(self).__name__ + '(generate_direction=%s)' % self.generate_direction
×
1332

1333
    def __call__(self, ui, region, scale=1):
1✔
1334
        """Return next orthogonalized vector.
1335

1336
        Parameters
1337
        -----------
1338
        ui: array
1339
            current point (in u-space)
1340
        region: MLFriends object
1341
            region to use for transformation
1342
        scale: float
1343
            length of direction vector
1344

1345
        Returns
1346
        --------
1347
        v: array
1348
            new direction vector (in u-space)
1349
        """
1350
        ndim = len(ui)
1✔
1351
        if self.directions is None or self.axis_index >= ndim:
1✔
1352
            proposed_directions = np.empty((ndim, ndim))
1✔
1353
            for i in range(ndim):
1✔
1354
                proposed_directions[i] = self.generate_direction(ui, region, scale=scale)
1✔
1355
            q, r = np.linalg.qr(proposed_directions)
1✔
1356
            self.directions = np.dot(q, np.diag(np.diag(r)))
1✔
1357
            self.axis_index = 0
1✔
1358

1359
        v = self.directions[self.axis_index]
1✔
1360
        self.axis_index += 1
1✔
1361
        return v
1✔
1362

1363

1364
class SpeedVariableGenerator(object):
1✔
1365
    """Propose directions with only some parameters variable.
1366

1367
    Propose in region direction, but only include some dimensions at a time.
1368
    Completely configurable.
1369
    """
1370

1371
    def __init__(self, step_matrix, generate_direction=generate_region_random_direction):
1✔
1372
        """Initialise sampler.
1373

1374
        Parameters
1375
        -----------
1376
        step_matrix: matrix or list of slices
1377

1378
            **if a bool matrix of shape (n_steps, n_dims):**
1379

1380
            Each row of the matrix indicates which parameters
1381
            should be updated.
1382

1383
            Example::
1384

1385
                [[True, True], [False, True], [False, True]]
1386

1387
            This would update the first parameter 1/3 times, and the second
1388
            parameters every time. Three steps are made until the point
1389
            is considered independent.
1390

1391
            For a full update in every step, use::
1392

1393
                np.ones((n_steps, n_dims), dtype=bool)
1394

1395
            **if a list of slices:**
1396

1397
            Each entry indicates which parameters should be updated.
1398

1399
            Example::
1400

1401
                [Ellipsis, slice(2,10), slice(5,10)]
1402

1403
            This would update the first parameter 1/3 times, parameters
1404
            2-9 2/3 times and parameter 5-9 in every step.
1405
            Three steps are made until the point is considered independent.
1406

1407
        generate_direction: function
1408
            direction proposal function.
1409
        """
1410
        self.step_matrix = step_matrix
1✔
1411
        self.nsteps = len(self.step_matrix)
1✔
1412
        self.axis_index = 0
1✔
1413
        self.generate_direction = generate_direction
1✔
1414

1415
    def __call__(self, ui, region, scale=1):
1✔
1416
        """Generate a slice sampling direction, using only some of the axes.
1417

1418
        Parameters
1419
        -----------
1420
        ui: array
1421
            current point (in u-space)
1422
        region: MLFriends object
1423
            region to use for transformation
1424
        scale: float
1425
            length of direction vector
1426

1427
        Returns
1428
        --------
1429
        v: array
1430
            new direction vector
1431
        """
1432
        ndim = len(ui)
1✔
1433

1434
        v = self.generate_direction(ui=ui, region=region, scale=scale)
1✔
1435
        j = self.axis_index % self.nsteps
1✔
1436
        self.axis_index = j + 1
1✔
1437
        # only update active dimensions
1438
        active_dims = self.step_matrix[j]
1✔
1439
        # project uj onto ui. vary only active dimensions
1440
        uk = np.zeros(ndim)
1✔
1441
        uk[active_dims] = v[active_dims]  # if this fails, user passed a faulty step_matrix
1✔
1442
        return uk
1✔
1443

1444

1445
def SpeedVariableRegionSliceSampler(step_matrix, *args, **kwargs):
1✔
1446
    """Slice sampler, in region axes.
1447

1448
    Updates only some dimensions at a time, completely user-definable.
1449
    """
1450
    generate_direction = kwargs.pop('generate_direction', generate_region_random_direction)
1✔
1451
    return SliceSampler(
1✔
1452
        *args, **kwargs,
1453
        nsteps=kwargs.pop('nsteps', len(step_matrix)),
1454
        generate_direction=SpeedVariableGenerator(
1455
            step_matrix=step_matrix,
1456
            generate_direction=generate_direction
1457
        )
1458
    )
1459

1460

1461
def ellipsoid_bracket(ui, v, ellipsoid_center, ellipsoid_inv_axes, ellipsoid_radius_square):
1✔
1462
    """Find line-ellipsoid intersection points.
1463

1464
    For a line from ui in direction v through an ellipsoid
1465
    centered at ellipsoid_center with axes matrix ellipsoid_inv_axes,
1466
    return the lower and upper intersection parameter.
1467

1468
    Parameters
1469
    -----------
1470
    ui: array
1471
        current point (in u-space)
1472
    v: array
1473
        direction vector
1474
    ellipsoid_center: array
1475
        center of the ellipsoid
1476
    ellipsoid_inv_axes: array
1477
        ellipsoid axes matrix, as computed by :py:class:`WrappingEllipsoid`
1478
    ellipsoid_radius_square: float
1479
        square of the ellipsoid radius
1480

1481
    Returns
1482
    --------
1483
    left: float
1484
        distance to go until ellipsoid is intersected (non-positive)
1485
    right: float
1486
        distance to go until ellipsoid is intersected (non-negative)
1487
    """
1488
    vell = np.dot(v, ellipsoid_inv_axes)
1✔
1489
    # ui in ellipsoid
1490
    xell = np.dot(ui - ellipsoid_center, ellipsoid_inv_axes)
1✔
1491
    a = np.dot(vell, vell)
1✔
1492
    b = 2 * np.dot(vell, xell)
1✔
1493
    c = np.dot(xell, xell) - ellipsoid_radius_square
1✔
1494
    assert c <= 0, ("outside ellipsoid", c)
1✔
1495
    intersect = b**2 - 4 * a * c
1✔
1496
    assert intersect >= 0, ("no intersection", intersect, c)
1✔
1497
    d1 = (-b + intersect**0.5) / (2 * a)
1✔
1498
    d2 = (-b - intersect**0.5) / (2 * a)
1✔
1499
    left = min(0, d1, d2)
1✔
1500
    right = max(0, d1, d2)
1✔
1501
    return left, right
1✔
1502

1503

1504
def crop_bracket_at_unit_cube(ui, v, left, right, epsilon=1e-6):
1✔
1505
    """Find line-cube intersection points.
1506

1507
    A line segment from *ui* in direction *v* from t between *left* <= 0 <= *right*
1508
    will be truncated by the unit cube. Returns the bracket and whether cropping was applied.
1509

1510
    Parameters
1511
    -----------
1512
    ui: array
1513
        current point (in u-space)
1514
    v: array
1515
        direction vector
1516
    left: float
1517
        bracket lower end (non-positive)
1518
    right: float
1519
        bracket upper end (non-negative)
1520
    epsilon: float
1521
        small number to allow for numerical effects
1522

1523
    Returns
1524
    --------
1525
    left: float
1526
        new left
1527
    right: float
1528
        new right
1529
    cropped_left: bool
1530
        whether left was changed
1531
    cropped_right: bool
1532
        whether right was changed
1533
    """
1534
    assert (ui > 0).all(), ui
1✔
1535
    assert (ui < 1).all(), ui
1✔
1536
    leftu = left * v + ui
1✔
1537
    rightu = right * v + ui
1✔
1538
    # print("crop: current ends:", leftu, rightu)
1539
    cropped_left = False
1✔
1540
    leftbelow = leftu <= 0
1✔
1541
    if leftbelow.any():
1✔
1542
        # choose left so that point is > 0 in all axes
1543
        # 0 = left * v + ui
1544
        del left
1✔
1545
        left = (-ui[leftbelow] / v[leftbelow]).max() * (1 - epsilon)
1✔
1546
        del leftu
1✔
1547
        leftu = left * v + ui
1✔
1548
        cropped_left |= True
1✔
1549
        assert (leftu >= 0).all(), leftu
1✔
1550
    leftabove = leftu >= 1
1✔
1551
    if leftabove.any():
1✔
1552
        del left
1✔
1553
        left = ((1 - ui[leftabove]) / v[leftabove]).max() * (1 - epsilon)
1✔
1554
        del leftu
1✔
1555
        leftu = left * v + ui
1✔
1556
        cropped_left |= True
1✔
1557
        assert (leftu <= 1).all(), leftu
1✔
1558

1559
    cropped_right = False
1✔
1560
    rightabove = rightu >= 1
1✔
1561
    if rightabove.any():
1✔
1562
        # choose right so that point is < 1 in all axes
1563
        # 1 = left * v + ui
1564
        del right
1✔
1565
        right = ((1 - ui[rightabove]) / v[rightabove]).min() * (1 - epsilon)
1✔
1566
        del rightu
1✔
1567
        rightu = right * v + ui
1✔
1568
        cropped_right |= True
1✔
1569
        assert (rightu <= 1).all(), rightu
1✔
1570

1571
    rightbelow = rightu <= 0
1✔
1572
    if rightbelow.any():
1✔
1573
        del right
1✔
1574
        right = (-ui[rightbelow] / v[rightbelow]).min() * (1 - epsilon)
1✔
1575
        del rightu
1✔
1576
        rightu = right * v + ui
1✔
1577
        cropped_right |= True
1✔
1578
        assert (rightu >= 0).all(), rightu
1✔
1579

1580
    assert left <= 0 <= right, (left, right)
1✔
1581
    return left, right, cropped_left, cropped_right
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc