Skip to content

Commit b499d43

Browse files
authored
fix: preserve guarded sample lifecycle (#1466)
1 parent 26470db commit b499d43

7 files changed

Lines changed: 380 additions & 33 deletions

File tree

autofit/aggregator/base.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22
from abc import ABC, abstractmethod
33
from functools import partial
4+
import logging
45
from typing import List, Optional, Generator
56

67
import autofit as af
78

9+
logger = logging.getLogger(__name__)
10+
811

912
class AggBase(ABC):
1013
def __init__(self, aggregator: af.Aggregator):
@@ -82,13 +85,13 @@ def weights_above_gen_from(self, minimum_weight: float) -> List:
8285
def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]:
8386
samples = fit.samples
8487

85-
weight_list = []
86-
87-
for sample in samples.sample_list:
88-
if sample.weight > minimum_weight:
89-
weight_list.append(sample.weight)
90-
91-
return weight_list
88+
return [
89+
sample.weight
90+
for sample, _ in self._valid_sample_instance_pairs(
91+
samples=samples,
92+
minimum_weight=minimum_weight,
93+
)
94+
]
9295

9396
func = partial(func_gen, minimum_weight=minimum_weight)
9497

@@ -119,22 +122,51 @@ def all_above_weight_gen_from(self, minimum_weight: float) -> Generator:
119122
def func_gen(fit: af.Fit, minimum_weight: float) -> List[object]:
120123
samples = fit.samples
121124

122-
all_above_weight_list = []
123-
124-
for sample in samples.sample_list:
125-
if sample.weight > minimum_weight:
126-
instance = sample.instance_for_model(model=samples.model)
127-
128-
all_above_weight_list.append(
129-
self.object_via_gen_from(fit=fit, instance=instance)
130-
)
131-
132-
return all_above_weight_list
125+
return [
126+
self.object_via_gen_from(fit=fit, instance=instance)
127+
for _, instance in self._valid_sample_instance_pairs(
128+
samples=samples,
129+
minimum_weight=minimum_weight,
130+
)
131+
]
133132

134133
func = partial(func_gen, minimum_weight=minimum_weight)
135134

136135
return self.aggregator.map(func=func)
137136

137+
@staticmethod
138+
def _valid_sample_instance_pairs(samples, minimum_weight: float):
139+
"""Return weighted samples whose model instances still reconstruct.
140+
141+
Constructor validation can become stricter after a result was written.
142+
Such historical points are not usable objects, but they must not make an
143+
entire aggregator query fail. ``FitException`` is the narrow model-point
144+
rejection contract; programming errors continue to propagate.
145+
"""
146+
pairs = []
147+
rejected = 0
148+
149+
for sample in samples.sample_list:
150+
if sample.weight <= minimum_weight:
151+
continue
152+
try:
153+
instance = samples.model.instance_from_vector(
154+
sample.parameter_lists_for_model(model=samples.model)
155+
)
156+
except af.exc.FitException:
157+
rejected += 1
158+
continue
159+
pairs.append((sample, instance))
160+
161+
if rejected:
162+
logger.warning(
163+
"Skipped %d stored sample(s) rejected by current model "
164+
"validation while building aggregator objects.",
165+
rejected,
166+
)
167+
168+
return pairs
169+
138170
def randomly_drawn_via_pdf_gen_from(self, total_samples: int):
139171
"""
140172
Returns a generator which for every result generates a list of objects whose parameter values are drawn

autofit/non_linear/samples/pdf.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import math
2+
import logging
23
import pathlib
34
import warnings
45
from typing import Dict, List, Optional, Tuple, Union
56

67
import numpy as np
78

89
from autonerves import conf
10+
from autofit import exc
911
from autonerves.output import should_output
1012
from autofit.mapper.model import ModelInstance
1113
from autofit.mapper.prior_model.abstract import AbstractPriorModel
@@ -14,6 +16,10 @@
1416
from .samples import Samples
1517
from .summary import SamplesSummary
1618

19+
logger = logging.getLogger(__name__)
20+
21+
VALID_INSTANCE_MAX_ATTEMPTS = 100
22+
1723

1824
class SamplesPDF(Samples):
1925
def __init__(
@@ -312,8 +318,11 @@ def error_magnitudes_at_sigma(self, sigma: float) -> Union[List, ModelInstance]:
312318
lowers = self.values_at_lower_sigma(sigma=sigma, as_instance=False)
313319
return list(map(lambda upper, lower: upper - lower, uppers, lowers))
314320

315-
@to_instance
316-
def draw_randomly_via_pdf(self) -> Union[List, ModelInstance]:
321+
def draw_randomly_via_pdf(
322+
self,
323+
as_instance: bool = True,
324+
as_dict: bool = False,
325+
) -> Union[List, Dict, ModelInstance]:
317326
"""
318327
The parameter vector of an individual sample of the non-linear search drawn randomly from the PDF, returned as
319328
a 1D list.
@@ -322,11 +331,40 @@ def draw_randomly_via_pdf(self) -> Union[List, ModelInstance]:
322331
for non-linear searches like nested sampling).
323332
"""
324333

325-
sample_index = np.random.choice(
326-
a=range(len(self.sample_list)), p=self.weight_list
327-
)
334+
last_error = None
328335

329-
return self.parameter_lists[sample_index][:]
336+
for attempt in range(VALID_INSTANCE_MAX_ATTEMPTS):
337+
sample_index = np.random.choice(
338+
a=range(len(self.sample_list)), p=self.weight_list
339+
)
340+
vector = self.parameter_lists[sample_index][:]
341+
342+
if as_dict:
343+
return {
344+
".".join(path[0]): value for path, value in zip(self.paths, vector)
345+
}
346+
347+
if not as_instance:
348+
return vector
349+
350+
try:
351+
instance = self._instance_from_vector(vector)
352+
except exc.FitException as error:
353+
last_error = error
354+
continue
355+
356+
if attempt > 0:
357+
logger.warning(
358+
"A randomly drawn stored sample can no longer be "
359+
"reconstructed because the model rejected it with "
360+
"FitException; drew another stored sample instead."
361+
)
362+
return instance
363+
364+
raise exc.SamplesException(
365+
"Could not draw a valid model instance from the stored PDF after "
366+
f"{VALID_INSTANCE_MAX_ATTEMPTS} attempts."
367+
) from last_error
330368

331369
def samples_drawn_randomly_via_pdf_from(self, total_draws: int = 100) -> "SamplesPDF":
332370
"""

autofit/non_linear/samples/samples.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,19 +322,71 @@ def max_log_likelihood_index(self) -> int:
322322
return 0
323323
return int(np.nanargmax(log_likelihood_list))
324324

325-
@to_instance
326-
def max_log_likelihood(self) -> List[float]:
325+
def max_log_likelihood(
326+
self,
327+
as_instance: bool = True,
328+
as_dict: bool = False,
329+
) -> Union[List[float], Dict, ModelInstance]:
327330
"""
328331
The parameters of the maximum log likelihood sample of the `NonLinearSearch` returned as a model instance or
329332
list of values.
333+
334+
When an older stored result contains a point which a newer model class
335+
rejects with :class:`FitException`, instance reconstruction falls back
336+
to the next-highest-likelihood valid point. The recorded best vector is
337+
still returned unchanged when ``as_instance=False`` or ``as_dict=True``;
338+
only the request to materialize an object needs this compatibility path.
330339
"""
331340

332341
sample = self.max_log_likelihood_sample
333-
334-
return sample.parameter_lists_for_paths(
342+
vector = sample.parameter_lists_for_paths(
335343
self.paths if sample.is_path_kwargs else self.names
336344
)
337345

346+
if as_dict:
347+
return {".".join(path[0]): value for path, value in zip(self.paths, vector)}
348+
349+
if not as_instance:
350+
return vector
351+
352+
try:
353+
return self._instance_from_vector(vector)
354+
except exc.FitException as error:
355+
last_error = error
356+
357+
valid_sample_candidates = sorted(
358+
(candidate for candidate in self.sample_list if candidate is not sample),
359+
key=lambda candidate: (
360+
float("-inf")
361+
if np.isnan(candidate.log_likelihood)
362+
else candidate.log_likelihood
363+
),
364+
reverse=True,
365+
)
366+
367+
for candidate in valid_sample_candidates:
368+
candidate_vector = candidate.parameter_lists_for_paths(
369+
self.paths if candidate.is_path_kwargs else self.names
370+
)
371+
try:
372+
instance = self._instance_from_vector(candidate_vector)
373+
except exc.FitException as error:
374+
last_error = error
375+
continue
376+
377+
logger.warning(
378+
"The maximum-likelihood stored sample can no longer be "
379+
"reconstructed because the model rejected it with "
380+
"FitException; using the highest-likelihood valid stored "
381+
"sample instead."
382+
)
383+
return instance
384+
385+
raise exc.SamplesException(
386+
"None of the stored samples can be reconstructed as a valid model "
387+
"instance."
388+
) from last_error
389+
338390
@property
339391
def max_log_posterior_sample(self) -> Sample:
340392
return self.sample_list[self.max_log_posterior_index]

autofit/non_linear/search/abstract_search.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Res
759759
samples_summary.instance
760760
except exc.FitException as error:
761761
samples = self._test_mode_samples_after_rejected_fit(
762-
model=model,
762+
samples=samples,
763763
error=error,
764764
)
765765
samples_summary = samples.summary()
@@ -784,7 +784,7 @@ def start_resume_fit(self, analysis: Analysis, model: AbstractPriorModel) -> Res
784784

785785
def _test_mode_samples_after_rejected_fit(
786786
self,
787-
model: AbstractPriorModel,
787+
samples: Samples,
788788
error: exc.FitException,
789789
) -> Samples:
790790
"""Build valid representative samples after a mode-1 rejected result.
@@ -801,8 +801,6 @@ def _test_mode_samples_after_rejected_fit(
801801
rejected point. The fixed seed keeps smoke tests reproducible without
802802
changing the application's global random state.
803803
"""
804-
from autofit.non_linear.samples.pdf import SamplesPDF
805-
806804
logger.warning(
807805
"TEST MODE 1: the reduced search's final sample raised "
808806
f"FitException ({error.__cause__ or error!r}); replacing it with "
@@ -811,6 +809,7 @@ def _test_mode_samples_after_rejected_fit(
811809

812810
rng = np.random.default_rng(seed=0)
813811
last_error = error
812+
model = samples.model
814813

815814
for attempt in range(TEST_MODE_REPRESENTATIVE_MAX_ATTEMPTS):
816815
unit_vector = (
@@ -841,13 +840,14 @@ def _test_mode_samples_after_rejected_fit(
841840
continue
842841

843842
samples_info = {
843+
**(samples.samples_info or {}),
844844
"total_iterations": 1,
845845
"time": 0.0,
846846
"log_evidence": -1.0e99,
847847
}
848848
samples_info.update(self._test_mode_samples_info())
849849

850-
return SamplesPDF(
850+
return samples.from_list_info_and_model(
851851
model=model,
852852
sample_list=sample_list,
853853
samples_info=samples_info,

0 commit comments

Comments
 (0)