Skip to content

Commit 82a14ca

Browse files
committed
Allocate the rate and the draws together, not the rate first
Asking for both 'auto' was the most expensive thing a user could ask for: on a 2.2+1.6 injection at the default accuracy it cost 29.6 ms a likelihood call against 6.6 ms for a hand-picked 8192 Hz with the draws chosen, for the same accuracy. That is backwards, and both 'auto' is what a user will write. The cause was the order. choose_sample_rate ran first and spent the whole budget on the rate, since its target is 1/(vsamples*accuracy**2) with vsamples still at its starting value; by the time the draws were sized the budget was already met and the cheap lever had nothing left to buy. The rate is the expensive one - the signal to noise series is rebuilt every call, so four times the rate costs about four times as much, while four times the draws cost tens of a percent - so it should be the lever of last resort, not of first. The accuracy depends only on the product of the two, which is what makes them exchangeable. So when the number of draws is being chosen, the rate is asked only for a resolution the product law can be trusted at and the draws are sized to the rest of the budget. Only if the budget needs more draws than there is room for - MOST_VSAMPLES, or the precalculated pool, which is the real ceiling - does the rate climb, to the resolution the most draws available would need, capped at FLOOR_RESOLVED where the rate stops buying anything at all. That target is either reached or it is not and the next one would be the same number, so the iteration goes round at most twice and then says plainly that the accuracy is out of reach and that a bigger pool, not a higher rate, is the way out. The resolution to stop at is measured rather than picked. Sizing the draws by the law and measuring what comes out: at one sample across the peak the scatter is 2.8 times what was asked, at two samples - the hard floor, where the answer stops being biased - it is still 1.5 times, and at four samples it is 0.99 and 0.82 at two budgets a factor of four apart. Four is where the law starts delivering what it promises, and on this signal it is also where the total cost of a call bottoms out, since the draws needed rise as the rate falls. Hence SANE_RESOLVED = 4. Both 'auto' at the default accuracy now picks 8192 Hz and 13942 draws: 29.59 -> 6.14 ms a call, 4.8x cheaper, with the scatter improving from 0.0058 to 0.0053 against the 0.005 asked for. Where the draws are given explicitly nothing changes: the rate still buys the whole budget on its own, by the same code and with the same message.
1 parent 975dc09 commit 82a14ca

2 files changed

Lines changed: 187 additions & 27 deletions

File tree

pycbc/inference/models/relbin.py

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@
6969
FEWEST_VSAMPLES = 32
7070
MOST_VSAMPLES = 32768
7171

72+
# The least resolution to ask the sample rate for when the number of draws
73+
# is being chosen too. The accuracy depends only on the product of the two,
74+
# so the rate is wanted no finer than the point where that product law can
75+
# be relied on. Measured, it cannot be relied on at the hard floor of two
76+
# samples across the peak: with the draws sized by the law, two samples
77+
# deliver a scatter half again the size asked for and one sample nearly
78+
# three times it, while four samples deliver what they promise, at two
79+
# accuracy budgets a factor of four apart. Four is also where the total
80+
# cost of a call bottoms out, the draws needed rising as the rate falls, so
81+
# nothing is given up by stopping there.
82+
SANE_RESOLVED = 4.0
83+
7284

7385
def setup_bins(f_full, f_lo, f_hi, chi=1.0,
7486
eps=0.1, gammas=None,
@@ -840,6 +852,19 @@ def choose_sample_rate(self, accuracy=None, most=262144.0, **kwargs):
840852
neutron star, and above a few kHz the series is already most of
841853
what the model does.
842854
855+
Where the number of marginalization draws is being chosen as well,
856+
the two are allocated together, because the accuracy depends only
857+
on their product and they are not the same price. Four times the
858+
rate costs about four times as much, while four times the draws
859+
cost tens of a percent, so the cheapest way to a given product is
860+
as much of it as possible from the draws. The rate is then asked
861+
for no more than ``SANE_RESOLVED``, the resolution below which the
862+
product law stops delivering what it promises, and the draws are
863+
sized to the rest of the budget. Only if there is not room for
864+
enough draws does the rate climb further, which it does until
865+
either the budget is met or the resolution stops being what limits
866+
the answer.
867+
843868
Parameters
844869
----------
845870
accuracy : float, optional
@@ -857,24 +882,74 @@ def choose_sample_rate(self, accuracy=None, most=262144.0, **kwargs):
857882
if accuracy is None:
858883
accuracy = self.marginalization_accuracy
859884

860-
# invert marginalization_error for the resolution wanted. Two
861-
# samples is a floor rather than an accuracy: below it the peak
862-
# falls between grid points and the answer is biased, not merely
863-
# noisy, so no scatter budget makes that acceptable.
864-
target = max(2.0, 1.0 / (self.vsamples * accuracy ** 2))
865-
866-
resolved = self.resolved_samples(self.ref_snr)
867-
while resolved < target and self.sample_rate < most:
868-
if resolved >= 2.0:
869-
# resolved well enough to say how much more is needed
870-
steps = max(1, int(numpy.ceil(numpy.log2(target / resolved))))
871-
else:
872-
steps = 1
873-
self.set_sample_rate(min(self.sample_rate * 2 ** steps, most),
874-
**kwargs)
885+
if self.adapt_vsamples:
886+
# the draws will buy the accuracy, so ask the rate only for a
887+
# resolution the product law can be trusted at
888+
ceiling = self.samples_ceiling(**kwargs)
889+
target = SANE_RESOLVED
890+
else:
891+
# invert marginalization_error for the resolution wanted. Two
892+
# samples is a floor rather than an accuracy: below it the peak
893+
# falls between grid points and the answer is biased, not merely
894+
# noisy, so no scatter budget makes that acceptable.
895+
ceiling = None
896+
target = max(2.0, 1.0 / (self.vsamples * accuracy ** 2))
897+
898+
while True:
875899
resolved = self.resolved_samples(self.ref_snr)
900+
while resolved < target and self.sample_rate < most:
901+
if resolved >= 2.0:
902+
# resolved well enough to say how much more is needed
903+
steps = max(1,
904+
int(numpy.ceil(numpy.log2(target / resolved))))
905+
else:
906+
steps = 1
907+
self.set_sample_rate(min(self.sample_rate * 2 ** steps, most),
908+
**kwargs)
909+
resolved = self.resolved_samples(self.ref_snr)
910+
911+
if ceiling is None:
912+
break
913+
914+
# what the draws would have to be to meet the budget here. If
915+
# there is no room for that many the rate has to make up the
916+
# difference after all: ask it for the resolution the most draws
917+
# available would need, and no more than the resolution past
918+
# which the rate buys nothing whatever it is asked. The new
919+
# target is reached or it is not, and either way the one after
920+
# it would be the same number, so this goes round at most twice.
921+
wanted = 1.0 / (accuracy ** 2 * min(resolved, FLOOR_RESOLVED))
922+
if wanted <= ceiling:
923+
break
924+
harder = min(1.0 / (accuracy ** 2 * ceiling), FLOOR_RESOLVED)
925+
if harder <= target:
926+
break
927+
target = harder
876928

877929
rate = self.sample_rate
930+
if ceiling is not None:
931+
if wanted > ceiling:
932+
logging.warning("Could not reach the accuracy asked of the "
933+
"marginalization: %.1f samples across the "
934+
"peak at %s Hz would need %.3g draws for a "
935+
"scatter of %.3g and there is room for %s, "
936+
"which leaves about %.3g. A larger "
937+
"precalculate_marginalization_points, or a "
938+
"looser marginalization_accuracy, is the way "
939+
"out; a higher rate is not",
940+
resolved, rate, wanted, accuracy, ceiling,
941+
1.0 / (ceiling
942+
* min(resolved,
943+
FLOOR_RESOLVED)) ** 0.5)
944+
else:
945+
logging.info("Chose a sample rate of %s Hz, spreading the "
946+
"peak over %.1f samples: enough resolution for "
947+
"the draws to be trusted, and the %.3g asked "
948+
"for is bought with the draws from there, which "
949+
"is much the cheaper of the two",
950+
rate, resolved, accuracy)
951+
return rate
952+
878953
error = self.marginalization_error(resolved)
879954
if resolved < target:
880955
logging.warning("Could not resolve the peak of the likelihood in "
@@ -906,6 +981,20 @@ def choose_sample_rate(self, accuracy=None, most=262144.0, **kwargs):
906981
rate, resolved, error, accuracy)
907982
return rate
908983

984+
def samples_ceiling(self, precalculate_marginalization_points=False,
985+
**kwargs):
986+
"""The most marginalization draws that could be asked for.
987+
988+
Taken from the model's keywords rather than from what has been built
989+
so far, because the rate is chosen before the pool of points exists
990+
and how many draws there is room for is what decides whether the
991+
rate has to climb.
992+
"""
993+
if precalculate_marginalization_points:
994+
return min(MOST_VSAMPLES,
995+
int(float(precalculate_marginalization_points)))
996+
return MOST_VSAMPLES
997+
909998
def wanted_ess(self, accuracy=None):
910999
"""How many effective marginalization samples the accuracy asks for.
9111000

test/test_marg_vsamples.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"""
3434

3535
import copy
36+
import time
3637
import unittest
3738

3839
import numpy
@@ -307,36 +308,106 @@ def test_points_far_from_the_best_do_not_move_it(self):
307308
self.assertLess(float(model.loglr), model.marg_best_loglr - 20.)
308309
self.assertEqual(model.vsamples, settled)
309310

310-
def test_both_knobs_on_auto_reach_the_accuracy_together(self):
311-
"""The configuration someone would actually write.
312-
313-
The resolution is chosen once at setup, from the number of samples
314-
it starts with; the number of samples is then chosen for the
315-
resolution that was settled on, and followed from there. The two
316-
buy the same accuracy at very different prices, so what matters is
317-
that between them they deliver it.
318-
"""
319-
accuracy = 0.03
311+
def auto_rate_model(self, vsamples='auto', accuracy=0.01):
312+
"""The same model with the rate left to the model as well."""
320313
variable = ['distance', 'inclination', 'tc']
321314
prior = JointDistribution(
322315
list(variable), SinAngle(inclination=None),
323316
Uniform(distance=(10, 300)),
324317
Uniform(tc=(TC - HALFWIDTH, TC + HALFWIDTH)))
325-
model = models.RelativeTimeDom(
318+
return models.RelativeTimeDom(
326319
list(variable), copy.deepcopy(self.data),
327320
low_frequency_cutoff=self.flow, psds=self.psds,
328321
static_params=self.static, prior=prior,
329322
fiducial_params={'mass1': INJ['mass1'], 'mass2': INJ['mass2'],
330323
'tc': TC},
331324
epsilon=0.1, marginalize_vector_params='tc', sample_rate='auto',
332325
marginalization_accuracy=accuracy,
333-
marginalize_vector_samples='auto')
326+
marginalize_vector_samples=vsamples)
327+
328+
def cost(self, model, ncall=40):
329+
"""Seconds a likelihood evaluation takes."""
330+
numpy.random.seed(31)
331+
start = time.time()
332+
for _ in range(ncall):
333+
model.update(**self.point)
334+
model.loglr
335+
return (time.time() - start) / ncall
336+
337+
def test_asking_for_both_does_not_buy_the_expensive_one(self):
338+
"""The accuracy depends only on the product of resolution and
339+
draws, and the two are not the same price: the signal to noise
340+
series is rebuilt every call, so the rate is close to linear in
341+
cost, while quadrupling the draws costs tens of a percent. Asking
342+
for both must therefore not cost more than choosing a low rate by
343+
hand and asking only for the draws -- which is what happens if the
344+
rate is allowed to spend the whole budget before the draws are
345+
sized. A factor of two is allowed on a timing taken in one process
346+
on one signal; the point is that it is not the fourfold or more
347+
that leaning on the rate costs.
348+
"""
349+
both = self.auto_rate_model()
350+
byhand = self.model(accuracy=0.01)
351+
self.drive(both, 200)
352+
self.drive(byhand, 200)
353+
together, apart = self.cost(both), self.cost(byhand)
354+
self.assertLess(together, 2.0 * apart,
355+
"%s Hz and %s samples took %.2f ms against %.2f for "
356+
"%s Hz and %s samples"
357+
% (both.sample_rate, both.vsamples, together * 1e3,
358+
apart * 1e3, byhand.sample_rate, byhand.vsamples))
359+
360+
def test_asking_for_both_still_reaches_the_accuracy(self):
361+
"""Spending less on the rate is only worth having if the accuracy
362+
still arrives, so the scatter is measured rather than predicted.
363+
364+
This is also the configuration someone would actually write: the
365+
rate settled once at setup, the draws sized for it and then
366+
followed, and the two together delivering what was asked.
367+
"""
368+
accuracy = 0.01
369+
model = self.auto_rate_model(accuracy=accuracy)
334370
trail = self.drive(model, 400)
335371
self.assertEqual(trail[-100:], [trail[-1]] * 100)
336372
self.assertLess(self.scatter(model), 1.5 * accuracy,
337373
"%s Hz and %s samples"
338374
% (model.sample_rate, model.vsamples))
339375

376+
def test_an_explicit_count_leaves_the_rate_choice_alone(self):
377+
"""A number of draws given explicitly is a number the model may not
378+
change, so the rate is the only thing left to buy the accuracy with
379+
and it has to buy all of it, exactly as before this option existed.
380+
Asking for the draws as well must then cost strictly less rate.
381+
"""
382+
accuracy = 0.01
383+
explicit = self.auto_rate_model(vsamples=1000, accuracy=accuracy)
384+
# the rate alone, against the resolution the accuracy law asks of it
385+
self.assertGreaterEqual(
386+
explicit.resolved_samples(explicit.ref_snr),
387+
1.0 / (1000 * accuracy ** 2))
388+
chosen = self.auto_rate_model(accuracy=accuracy)
389+
self.assertLess(chosen.sample_rate, explicit.sample_rate)
390+
391+
def test_the_rate_climbs_only_when_the_draws_run_out_of_room(self):
392+
"""The rate is the lever of last resort.
393+
394+
While there is room for the draws the resolution asked for is the
395+
least that can be trusted; when the budget needs more draws than
396+
there can be, the rate is the only thing left and it climbs. Which
397+
way round it went is visible in the draws: they are up against their
398+
ceiling in the second case and nowhere near it in the first.
399+
"""
400+
from pycbc.inference.models.relbin import (MOST_VSAMPLES,
401+
SANE_RESOLVED)
402+
loose = self.auto_rate_model(accuracy=0.01)
403+
self.assertLess(loose.resolved, 2.0 * SANE_RESOLVED)
404+
self.assertLess(loose.vsamples, MOST_VSAMPLES / 2)
405+
406+
tight = self.auto_rate_model(accuracy=0.002)
407+
self.assertGreater(tight.resolved, SANE_RESOLVED)
408+
self.assertGreater(tight.sample_rate, loose.sample_rate)
409+
self.assertGreater(tight.vsamples, MOST_VSAMPLES / 2)
410+
340411
def test_an_explicit_number_is_left_alone(self):
341412
"""Asking for a number of samples must still get exactly that."""
342413
model = self.model(vsamples=777)

0 commit comments

Comments
 (0)