Skip to content

Commit b32e58b

Browse files
authored
Merge pull request #1383 from PyAutoLabs/feature/ep-projection-weights
fix: EP projection — transform samples to base space + use log weights
2 parents bec3734 + 7fd8ea8 commit b32e58b

4 files changed

Lines changed: 96 additions & 7 deletions

File tree

autofit/mapper/prior/abstract.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def instance_for_arguments(
198198
_ = ignore_assertions
199199
return arguments[self]
200200

201-
def project(self, samples, weights):
201+
def project(self, samples, log_weight_list):
202202
"""Project this prior given samples and log weights from a search.
203203
204204
Returns a copy of this prior whose message has been updated to
@@ -208,13 +208,17 @@ def project(self, samples, weights):
208208
----------
209209
samples
210210
Array of sample values for this parameter.
211-
weights
212-
Log weights for each sample.
211+
log_weight_list
212+
Log weights for each sample. These must be log (not linear)
213+
importance weights: the projection exponentiates them for its
214+
weighted moment match. Passing linear weights makes every sample
215+
count almost equally, which drags the projected message towards
216+
the prior bounds (see PyAutoFit#1382).
213217
"""
214218
result = copy(self)
215219
result.message = self.message.project(
216220
samples=samples,
217-
log_weight_list=weights,
221+
log_weight_list=log_weight_list,
218222
id_=self.id,
219223
)
220224
return result

autofit/messages/composed_transform.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,20 @@ def __sub__(self, other):
190190
def project(
191191
self, samples, log_weight_list, **_,
192192
):
193+
"""
194+
Moment-matching projection of weighted samples.
195+
196+
``samples`` arrive in the transformed (physical) space, so they are
197+
mapped through the transform stack into the base message's space
198+
before its moment match — exactly as ``@transform`` does for pdf and
199+
friends. The weights need no Jacobian correction: a weighted
200+
empirical distribution transforms its atoms, not its weights.
201+
(Previously the physical samples were projected directly in base
202+
space, pinning the projected message near a prior bound —
203+
PyAutoFit#1382.)
204+
"""
193205
return TransformedMessage(
194-
self.base_message.project(samples, log_weight_list),
206+
self.base_message.project(self._transform(samples), log_weight_list),
195207
*self.transforms,
196208
id_=self.id,
197209
)

autofit/non_linear/result.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,12 +343,23 @@ def projected_model(self) -> AbstractPriorModel:
343343
Create a new model with the same structure as the previous model,
344344
replacing each prior with a new prior created by calculating sufficient
345345
statistics from samples and corresponding weights for that prior.
346+
347+
`Samples.weight_list` holds *linear* importance weights; the message
348+
projection is an importance-weighted moment match over *log* weights,
349+
so they are converted here. Zero-weight samples map to -inf and drop
350+
out of the moments.
346351
"""
347-
weights = self.samples.weight_list
352+
weights = np.asarray(self.samples.weight_list)
353+
if not np.any(weights > 0.0):
354+
raise ValueError(
355+
"Cannot project a model from samples whose weights are all zero."
356+
)
357+
with np.errstate(divide="ignore"):
358+
log_weight_list = np.log(weights)
348359
arguments = {
349360
prior: prior.project(
350361
samples=np.array(self.samples.values_for_path(path)),
351-
weights=weights,
362+
log_weight_list=log_weight_list,
352363
)
353364
for path, prior in self.samples.model.path_priors_tuples
354365
}

test_autofit/graphical/test_unification.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,68 @@ def test_projected_model():
6161
assert isinstance(projected_model.centre, af.UniformPrior)
6262

6363

64+
def test_projected_model_moments():
65+
"""
66+
Regression test for PyAutoFit#1382: `projected_model` must convert the
67+
samples' linear importance weights to log weights before the message
68+
projection's weighted moment match.
69+
70+
The sample set mimics a nested-sampling run: a broad prior-exploration
71+
phase with negligible weights plus a concentrated posterior with almost
72+
all the weight. The projected prior's moments must match the posterior,
73+
not the prior-phase spread — with linear weights fed in as log weights,
74+
every sample counts almost equally and the projection lands near a
75+
prior bound.
76+
"""
77+
rng = np.random.default_rng(1)
78+
79+
posterior_mean = 2.05
80+
posterior_sigma = 0.03
81+
82+
prior_phase = rng.uniform(1.5, 3.0, size=2000)
83+
posterior_phase = rng.normal(posterior_mean, posterior_sigma, size=500)
84+
values = np.concatenate([prior_phase, posterior_phase])
85+
86+
weights = np.concatenate(
87+
[
88+
np.full(prior_phase.size, 1.0e-9),
89+
np.full(posterior_phase.size, (1.0 - prior_phase.size * 1.0e-9) / 500),
90+
]
91+
)
92+
93+
model = af.Model(
94+
af.ex.Gaussian,
95+
centre=af.UniformPrior(lower_limit=1.5, upper_limit=3.0),
96+
)
97+
# normalization and sigma have UniformPrior(0, 1) under the test config, so
98+
# they get their own in-support draws; the moment assertions are on centre.
99+
unit_values = rng.uniform(0.05, 0.95, size=values.size)
100+
samples = af.Samples(
101+
model,
102+
[
103+
af.Sample(
104+
-1.0,
105+
-1.0,
106+
weight=weight,
107+
kwargs={
108+
("centre",): value,
109+
("normalization",): unit_value,
110+
("sigma",): unit_value,
111+
},
112+
)
113+
for value, unit_value, weight in zip(values, unit_values, weights)
114+
],
115+
)
116+
result = af.mock.MockResult(samples=samples)
117+
118+
projected_centre = result.projected_model.centre
119+
120+
assert projected_centre.mean == pytest.approx(posterior_mean, abs=0.02)
121+
assert float(np.sqrt(projected_centre.variance)) == pytest.approx(
122+
posterior_sigma, rel=0.5
123+
)
124+
125+
64126
def test_uniform_normal(x):
65127
message = TransformedMessage(
66128
UniformNormalMessage,

0 commit comments

Comments
 (0)