tbnpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tbnpy/__init__.py +0 -0
- tbnpy/adaptiveMH.py +627 -0
- tbnpy/cpt.py +1027 -0
- tbnpy/inference.py +507 -0
- tbnpy/variable.py +360 -0
- tbnpy-0.1.0.dist-info/METADATA +116 -0
- tbnpy-0.1.0.dist-info/RECORD +10 -0
- tbnpy-0.1.0.dist-info/WHEEL +5 -0
- tbnpy-0.1.0.dist-info/licenses/LICENSE +21 -0
- tbnpy-0.1.0.dist-info/top_level.txt +1 -0
tbnpy/__init__.py
ADDED
|
File without changes
|
tbnpy/adaptiveMH.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Dict, List, Sequence, Tuple, Optional, Set, Any
|
|
3
|
+
|
|
4
|
+
from tbnpy import variable, cpt
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
# -----------------------------
|
|
10
|
+
# Utility: detect variable type
|
|
11
|
+
# -----------------------------
|
|
12
|
+
def is_discrete(var) -> bool:
|
|
13
|
+
return isinstance(var.values, list)
|
|
14
|
+
|
|
15
|
+
def is_continuous(var) -> bool:
|
|
16
|
+
return isinstance(var.values, tuple)
|
|
17
|
+
|
|
18
|
+
def num_categories(var) -> int:
|
|
19
|
+
assert is_discrete(var)
|
|
20
|
+
return len(var.values)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------
|
|
23
|
+
# Adaptive, tensorised MH sampler for hybrid BNs
|
|
24
|
+
# ---------------------------------------------------------
|
|
25
|
+
# Factor adapter: build Cs for a probability object (factor)
|
|
26
|
+
def build_Cs_3d(prob, state: Dict[str, torch.Tensor], evidence_1d: Dict[str, torch.Tensor]) -> torch.Tensor:
|
|
27
|
+
"""
|
|
28
|
+
Build Cs with shape (n_evi, n_chain, n_childs + n_parents) in the column order:
|
|
29
|
+
[child_0, ..., child_{n_childs-1}, parent_0, ..., parent_{n_parents-1}]
|
|
30
|
+
Evidence variables are broadcast to (n_evi, n_chain).
|
|
31
|
+
Latent variables come from `state[var_name]` which must be (n_evi, n_chain).
|
|
32
|
+
|
|
33
|
+
Input:
|
|
34
|
+
prob : probability object
|
|
35
|
+
Must have `prob.childs` and `prob.parents` lists of variable objects.
|
|
36
|
+
state: dict
|
|
37
|
+
state["X"] has shape (n_evi, n_chain)
|
|
38
|
+
evidence_1d: dict
|
|
39
|
+
evidence_1d["E"] has shape (n_evi,)
|
|
40
|
+
"""
|
|
41
|
+
# Infer (n_evi, n_chain) from any state tensor, else from evidence.
|
|
42
|
+
n_evi = None
|
|
43
|
+
n_chain = None
|
|
44
|
+
if len(state) > 0:
|
|
45
|
+
any_t = next(iter(state.values()))
|
|
46
|
+
n_evi, n_chain = any_t.shape
|
|
47
|
+
else:
|
|
48
|
+
# fall back to evidence
|
|
49
|
+
any_e = next(iter(evidence_1d.values()))
|
|
50
|
+
n_evi = any_e.shape[0]
|
|
51
|
+
raise ValueError("State is empty; cannot infer n_chain. Provide initial state for latent variables.")
|
|
52
|
+
|
|
53
|
+
cols: List[torch.Tensor] = []
|
|
54
|
+
|
|
55
|
+
# Child columns
|
|
56
|
+
for v in prob.childs:
|
|
57
|
+
name = v.name
|
|
58
|
+
if name in evidence_1d:
|
|
59
|
+
col = evidence_1d[name].unsqueeze(1).expand(n_evi, n_chain)
|
|
60
|
+
else:
|
|
61
|
+
col = state[name]
|
|
62
|
+
cols.append(col)
|
|
63
|
+
|
|
64
|
+
# Parent columns
|
|
65
|
+
for v in prob.parents:
|
|
66
|
+
name = v.name
|
|
67
|
+
if name in evidence_1d:
|
|
68
|
+
col = evidence_1d[name].unsqueeze(1).expand(n_evi, n_chain)
|
|
69
|
+
else:
|
|
70
|
+
col = state[name]
|
|
71
|
+
cols.append(col)
|
|
72
|
+
|
|
73
|
+
Cs = torch.stack(cols, dim=2) # (n_evi, n_chain, n_childs+n_parents)
|
|
74
|
+
return Cs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def factor_logp_2d(prob, Cs_3d: torch.Tensor) -> torch.Tensor:
|
|
78
|
+
"""
|
|
79
|
+
Evaluate prob.log_prob on Cs_3d (n_evi, n_chain, dim) by flattening.
|
|
80
|
+
Returns (n_evi, n_chain).
|
|
81
|
+
"""
|
|
82
|
+
n_evi, n_chain, dim = Cs_3d.shape
|
|
83
|
+
Cs_flat = Cs_3d.reshape(n_evi * n_chain, dim)
|
|
84
|
+
logp_flat = prob.log_prob(Cs_flat)
|
|
85
|
+
return logp_flat.reshape(n_evi, n_chain)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# -----------------------------
|
|
89
|
+
# Build factor adjacency
|
|
90
|
+
# -----------------------------
|
|
91
|
+
def build_factors_by_var(probs: Dict[str, Any]) -> Dict[str, List[Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Map variable name -> list of probability objects (factors) that depend on it
|
|
94
|
+
(as either child or parent).
|
|
95
|
+
"""
|
|
96
|
+
factors_by_var: Dict[str, List[Any]] = {}
|
|
97
|
+
for _, prob in probs.items():
|
|
98
|
+
for v in list(prob.childs) + list(prob.parents):
|
|
99
|
+
factors_by_var.setdefault(v.name, []).append(prob)
|
|
100
|
+
return factors_by_var
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -----------------------------
|
|
104
|
+
# Proposals (symmetric)
|
|
105
|
+
# -----------------------------
|
|
106
|
+
def propose_discrete_adaptive(
|
|
107
|
+
x: torch.Tensor,
|
|
108
|
+
logits: torch.Tensor,
|
|
109
|
+
alpha: float,
|
|
110
|
+
) -> torch.Tensor:
|
|
111
|
+
"""
|
|
112
|
+
Adaptive symmetric proposal for a discrete variable.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
x : torch.Tensor
|
|
117
|
+
Current state, shape (n_evi, n_chain), integer in [0, K-1]
|
|
118
|
+
logits : torch.Tensor
|
|
119
|
+
Global adaptive logits, shape (K,)
|
|
120
|
+
alpha : float
|
|
121
|
+
Mixing weight for local vs global proposal (0 < alpha < 1)
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
x_new : torch.Tensor
|
|
126
|
+
Proposed state, same shape as x
|
|
127
|
+
"""
|
|
128
|
+
device = x.device
|
|
129
|
+
n_evi, n_chain = x.shape
|
|
130
|
+
K = logits.numel()
|
|
131
|
+
|
|
132
|
+
# ---- Local symmetric proposal: uniform over other states ----
|
|
133
|
+
r = torch.randint(0, K - 1, size=(n_evi, n_chain), device=device)
|
|
134
|
+
x_local = r + (r >= x).to(r.dtype)
|
|
135
|
+
|
|
136
|
+
# ---- Global adaptive proposal (independent of x) ----
|
|
137
|
+
probs = torch.softmax(logits, dim=0)
|
|
138
|
+
x_global = torch.multinomial(
|
|
139
|
+
probs,
|
|
140
|
+
num_samples=n_evi * n_chain,
|
|
141
|
+
replacement=True,
|
|
142
|
+
).reshape(n_evi, n_chain)
|
|
143
|
+
|
|
144
|
+
# ---- Mixture (still symmetric) ----
|
|
145
|
+
use_local = torch.rand((n_evi, n_chain), device=device) < alpha
|
|
146
|
+
x_new = torch.where(use_local, x_local, x_global)
|
|
147
|
+
|
|
148
|
+
# ---- Ensure x_new != x (important for MH efficiency) ----
|
|
149
|
+
same = x_new == x
|
|
150
|
+
if same.any():
|
|
151
|
+
r = torch.randint(0, K - 1, size=(same.sum(),), device=device)
|
|
152
|
+
x_new[same] = r + (r >= x[same]).to(r.dtype)
|
|
153
|
+
|
|
154
|
+
return x_new
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def propose_continuous_rw_gaussian(x: torch.Tensor, sigma: float) -> torch.Tensor:
|
|
158
|
+
"""
|
|
159
|
+
Symmetric Gaussian random-walk proposal.
|
|
160
|
+
x: (n_evi, n_chain) float tensor
|
|
161
|
+
"""
|
|
162
|
+
return x + torch.randn_like(x) * sigma
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -----------------------------
|
|
166
|
+
# Adaptive, Block MH Sampler
|
|
167
|
+
# -----------------------------
|
|
168
|
+
@dataclass
|
|
169
|
+
class AdaptConfig:
|
|
170
|
+
burnin: int = 200
|
|
171
|
+
gamma: float = 0.2 # Robbins–Monro exponent in (0.5, 1]
|
|
172
|
+
target_accept: float = 0.234 # target acceptable rate for block updates
|
|
173
|
+
min_log_sigma: float = -12.0 # clamp for stability
|
|
174
|
+
max_log_sigma: float = 6.0
|
|
175
|
+
alpha: float = 0.5 # for discrete vars, pi(new) = alpha * pi_local(new | old) + (1-alpha) * pi_global(new)
|
|
176
|
+
# pi_local(new | old): uniform leap between states (for symmetry)
|
|
177
|
+
# pi_global(new): distribution of previous sampled states
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class HybridAdaptiveMH:
|
|
181
|
+
"""
|
|
182
|
+
Tensorised Metropolis–Hastings for hybrid (discrete + continuous) BN,
|
|
183
|
+
with many evidence rows and many chains.
|
|
184
|
+
|
|
185
|
+
- Latent variable values are stored in `state[var_name]` with shape (n_evi, n_chain).
|
|
186
|
+
- Evidence is stored in `evidence_1d[var_name]` with shape (n_evi,).
|
|
187
|
+
- Each MH step updates ONE variable at a time (easy and robust).
|
|
188
|
+
(You can extend to blocks later.)
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
probs: Dict[str, Any],
|
|
194
|
+
variables: Dict[str, Any] | Sequence[Any], # list of Variable objects (all variables)
|
|
195
|
+
evidence_df, # pandas DataFrame: shape (n_evi, ...)
|
|
196
|
+
n_chain: int,
|
|
197
|
+
device: str | torch.device = "cpu",
|
|
198
|
+
adapt: AdaptConfig = AdaptConfig()
|
|
199
|
+
):
|
|
200
|
+
self.probs = probs
|
|
201
|
+
if isinstance(variables, dict):
|
|
202
|
+
variables = list(variables.values())
|
|
203
|
+
else:
|
|
204
|
+
self.variables = list(variables)
|
|
205
|
+
self.device = torch.device(device)
|
|
206
|
+
self.adapt = adapt
|
|
207
|
+
|
|
208
|
+
# Evidence as 1D tensors (n_evi,)
|
|
209
|
+
self.n_evi = len(evidence_df)
|
|
210
|
+
self.n_chain = n_chain
|
|
211
|
+
|
|
212
|
+
self.evidence_1d: Dict[str, torch.Tensor] = {}
|
|
213
|
+
for col in evidence_df.columns:
|
|
214
|
+
# keep dtype flexible: discrete evidence should still be numeric indices in df if used
|
|
215
|
+
t = torch.tensor(evidence_df[col].values, device=self.device)
|
|
216
|
+
if t.dtype not in (torch.int64, torch.int32, torch.float32, torch.float64):
|
|
217
|
+
t = t.to(torch.float32)
|
|
218
|
+
self.evidence_1d[col] = t
|
|
219
|
+
|
|
220
|
+
# Determine which vars are evidence vs latent
|
|
221
|
+
self.evidence_vars: Set[str] = set(evidence_df.columns)
|
|
222
|
+
self.latent_vars: List[Any] = [v for v in self.variables if v.name not in self.evidence_vars]
|
|
223
|
+
|
|
224
|
+
# Adjacency: var_name -> factors
|
|
225
|
+
self.factors_by_var = build_factors_by_var(self.probs)
|
|
226
|
+
|
|
227
|
+
# Proposal params for continuous latent vars
|
|
228
|
+
self.log_sigma: Dict[str, torch.Tensor] = {}
|
|
229
|
+
for v in self.latent_vars:
|
|
230
|
+
if is_continuous(v):
|
|
231
|
+
self.log_sigma[v.name] = torch.tensor(0.0, device=self.device) # sigma=1 initially
|
|
232
|
+
|
|
233
|
+
# State placeholders
|
|
234
|
+
self.state: Dict[str, torch.Tensor] = {} # var_name -> (n_evi, n_chain) tensor
|
|
235
|
+
|
|
236
|
+
# Cache: per-factor logp (n_evi, n_chain) for current state (optional but useful)
|
|
237
|
+
self.factor_logp_cache: Dict[int, torch.Tensor] = {} # key by id(prob)
|
|
238
|
+
|
|
239
|
+
# Cache: total logp per evidence×chain and aggregated per chain
|
|
240
|
+
self.logp_evi_chain: Optional[torch.Tensor] = None
|
|
241
|
+
self.logp_chain: Optional[torch.Tensor] = None
|
|
242
|
+
|
|
243
|
+
# Proposal params for discrete latent vars (global adaptive logits)
|
|
244
|
+
self.discrete_logits: Dict[str, torch.Tensor] = {}
|
|
245
|
+
for v in self.latent_vars:
|
|
246
|
+
if is_discrete(v):
|
|
247
|
+
K = num_categories(v)
|
|
248
|
+
self.discrete_logits[v.name] = torch.zeros(
|
|
249
|
+
K, device=self.device, dtype=torch.float32
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Helper: get affected factors for a block of variables
|
|
253
|
+
def _affected_factors_for_block(self, var_names):
|
|
254
|
+
affected = []
|
|
255
|
+
seen = set()
|
|
256
|
+
|
|
257
|
+
for name in var_names:
|
|
258
|
+
for prob in self.factors_by_var.get(name, []):
|
|
259
|
+
pid = id(prob)
|
|
260
|
+
if pid not in seen:
|
|
261
|
+
affected.append(prob)
|
|
262
|
+
seen.add(pid)
|
|
263
|
+
return affected
|
|
264
|
+
|
|
265
|
+
# Initialisation
|
|
266
|
+
def init_state_from_forward_samples(self, probs_copy: Dict[str, Any]):
|
|
267
|
+
"""
|
|
268
|
+
Initialise latent state from a forward-sampling result compatible with your sample_evidence(),
|
|
269
|
+
i.e. each prob in probs_copy has prob.Cs of shape (n_evi, n_sample, ...)
|
|
270
|
+
and prob.ps of shape (n_evi, n_sample).
|
|
271
|
+
We treat n_sample as n_chain here.
|
|
272
|
+
"""
|
|
273
|
+
# Build samples[var_name] from prob.Cs columns (childs first).
|
|
274
|
+
samples: Dict[str, torch.Tensor] = {}
|
|
275
|
+
for prob in probs_copy.values():
|
|
276
|
+
Cs = prob.Cs # (n_evi, n_chain, dim)
|
|
277
|
+
for j, child_var in enumerate(prob.childs):
|
|
278
|
+
samples[child_var.name] = Cs[:, :, j]
|
|
279
|
+
|
|
280
|
+
# Keep only latent vars (evidence vars are fixed)
|
|
281
|
+
for v in self.latent_vars:
|
|
282
|
+
if v.name not in samples:
|
|
283
|
+
raise KeyError(f"Latent variable '{v.name}' not found in forward samples. "
|
|
284
|
+
f"Make sure ancestors include it and it is sampled.")
|
|
285
|
+
x = samples[v.name].to(self.device)
|
|
286
|
+
|
|
287
|
+
# dtype rules
|
|
288
|
+
if is_discrete(v):
|
|
289
|
+
x = x.to(torch.long)
|
|
290
|
+
else:
|
|
291
|
+
x = x.to(torch.float32)
|
|
292
|
+
self.state[v.name] = x
|
|
293
|
+
|
|
294
|
+
# Compute caches
|
|
295
|
+
self._recompute_all_logps()
|
|
296
|
+
|
|
297
|
+
def init_state_random(self, seed: Optional[int] = None):
|
|
298
|
+
"""
|
|
299
|
+
Fallback initializer if you don't want to rely on forward sampling.
|
|
300
|
+
Discrete: uniform over categories
|
|
301
|
+
Continuous: standard normal
|
|
302
|
+
"""
|
|
303
|
+
if seed is not None:
|
|
304
|
+
torch.manual_seed(seed)
|
|
305
|
+
|
|
306
|
+
for v in self.latent_vars:
|
|
307
|
+
if is_discrete(v):
|
|
308
|
+
K = num_categories(v)
|
|
309
|
+
x = torch.randint(0, K, (self.n_evi, self.n_chain), device=self.device, dtype=torch.long)
|
|
310
|
+
else:
|
|
311
|
+
x = torch.randn((self.n_evi, self.n_chain), device=self.device, dtype=torch.float32)
|
|
312
|
+
self.state[v.name] = x
|
|
313
|
+
|
|
314
|
+
self._recompute_all_logps()
|
|
315
|
+
|
|
316
|
+
# Log-probability bookkeeping
|
|
317
|
+
def _recompute_all_logps(self):
|
|
318
|
+
"""
|
|
319
|
+
Compute and cache:
|
|
320
|
+
- per-factor logp (n_evi, n_chain)
|
|
321
|
+
- total logp_evi_chain (n_evi, n_chain)
|
|
322
|
+
- total logp_chain (n_chain,) = sum over evidence rows
|
|
323
|
+
"""
|
|
324
|
+
logp_evi_chain = torch.zeros((self.n_evi, self.n_chain), device=self.device, dtype=torch.float32)
|
|
325
|
+
self.factor_logp_cache.clear()
|
|
326
|
+
|
|
327
|
+
for prob in self.probs.values():
|
|
328
|
+
Cs = build_Cs_3d(prob, self.state, self.evidence_1d)
|
|
329
|
+
logp2d = factor_logp_2d(prob, Cs).to(torch.float32)
|
|
330
|
+
self.factor_logp_cache[id(prob)] = logp2d
|
|
331
|
+
logp_evi_chain += logp2d
|
|
332
|
+
|
|
333
|
+
self.logp_evi_chain = logp_evi_chain
|
|
334
|
+
self.logp_chain = logp_evi_chain.sum(dim=0) # (n_chain,)
|
|
335
|
+
|
|
336
|
+
# One MH update for one variable
|
|
337
|
+
@torch.no_grad()
|
|
338
|
+
def mh_update_block(self, vars: List[variable.Variable], iteration: int) -> torch.Tensor:
|
|
339
|
+
"""
|
|
340
|
+
Metropolis–Hastings update for a block of variables.
|
|
341
|
+
vars: list[Variable]
|
|
342
|
+
Returns accept mask: (n_chain,)
|
|
343
|
+
"""
|
|
344
|
+
var_names = [v.name for v in vars]
|
|
345
|
+
|
|
346
|
+
# 1. Propose block
|
|
347
|
+
proposed = self._propose_block(vars) # proposed: Dict[str, torch.Tensor]; proposed[v.name].shape == (n_evi, n_chain)
|
|
348
|
+
|
|
349
|
+
# 2. Find affected factors
|
|
350
|
+
affected = self._affected_factors_for_block(var_names) # List[ProbabilityObject]
|
|
351
|
+
|
|
352
|
+
delta_evi_chain = torch.zeros(
|
|
353
|
+
(self.n_evi, self.n_chain),
|
|
354
|
+
device=self.device,
|
|
355
|
+
dtype=torch.float32,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# 3. Temporarily swap in proposed values
|
|
359
|
+
old_values = {}
|
|
360
|
+
for name in var_names:
|
|
361
|
+
old_values[name] = self.state[name]
|
|
362
|
+
self.state[name] = proposed[name]
|
|
363
|
+
|
|
364
|
+
# 4. Compute Δ log-probability
|
|
365
|
+
for prob in affected:
|
|
366
|
+
logp_old_2d = self.factor_logp_cache[id(prob)] # self.factor_logp_cache: Dict[int, torch.Tensor];
|
|
367
|
+
# self.factor_logp_cache[id(prob)].shape == (n_evi, n_chain)
|
|
368
|
+
Cs_new = build_Cs_3d(prob, self.state, self.evidence_1d)
|
|
369
|
+
logp_new_2d = factor_logp_2d(prob, Cs_new)
|
|
370
|
+
delta_evi_chain += (logp_new_2d - logp_old_2d)
|
|
371
|
+
|
|
372
|
+
# 5. Restore old values before accept/reject
|
|
373
|
+
for name, x_old in old_values.items():
|
|
374
|
+
self.state[name] = x_old
|
|
375
|
+
|
|
376
|
+
# 6. Aggregate over evidence
|
|
377
|
+
log_alpha = delta_evi_chain.sum(dim=0) # (n_chain,)
|
|
378
|
+
|
|
379
|
+
# 7. Accept / reject
|
|
380
|
+
u = torch.log(torch.rand((self.n_chain,), device=self.device))
|
|
381
|
+
accept = u < log_alpha
|
|
382
|
+
|
|
383
|
+
# 8. Apply accepted updates and refresh caches
|
|
384
|
+
if accept.any():
|
|
385
|
+
for name in var_names:
|
|
386
|
+
self.state[name][:, accept] = proposed[name][:, accept]
|
|
387
|
+
|
|
388
|
+
for prob in affected:
|
|
389
|
+
logp_old_2d = self.factor_logp_cache[id(prob)]
|
|
390
|
+
Cs_upd = build_Cs_3d(prob, self.state, self.evidence_1d)
|
|
391
|
+
logp_upd_2d = factor_logp_2d(prob, Cs_upd)
|
|
392
|
+
self.factor_logp_cache[id(prob)] = logp_upd_2d
|
|
393
|
+
self.logp_evi_chain += (logp_upd_2d - logp_old_2d)
|
|
394
|
+
|
|
395
|
+
self.logp_chain = self.logp_evi_chain.sum(dim=0)
|
|
396
|
+
|
|
397
|
+
# 9. Adaptation (continuous + discrete vars in block)
|
|
398
|
+
if iteration < self.adapt.burnin:
|
|
399
|
+
acc_rate = accept.float().mean().item()
|
|
400
|
+
eta = (iteration + 1.0) ** (-self.adapt.gamma)
|
|
401
|
+
|
|
402
|
+
for v in vars:
|
|
403
|
+
name = v.name
|
|
404
|
+
|
|
405
|
+
# ---- Continuous adaptation (unchanged) ----
|
|
406
|
+
if is_continuous(v):
|
|
407
|
+
target = self.adapt.target_accept
|
|
408
|
+
new_log_sigma = self.log_sigma[name] + eta * (acc_rate - target)
|
|
409
|
+
self.log_sigma[name] = new_log_sigma.clamp(
|
|
410
|
+
self.adapt.min_log_sigma,
|
|
411
|
+
self.adapt.max_log_sigma,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# ---- Discrete adaptation (NEW) ----
|
|
415
|
+
elif is_discrete(v):
|
|
416
|
+
K = num_categories(v)
|
|
417
|
+
|
|
418
|
+
# accepted samples for this variable
|
|
419
|
+
x_acc = self.state[name][:, accept] # (n_evi, n_accept)
|
|
420
|
+
|
|
421
|
+
if x_acc.numel() > 0:
|
|
422
|
+
# empirical frequencies
|
|
423
|
+
hist = torch.bincount(
|
|
424
|
+
x_acc.flatten(),
|
|
425
|
+
minlength=K
|
|
426
|
+
).float()
|
|
427
|
+
|
|
428
|
+
hist = hist / hist.sum().clamp_min(1.0)
|
|
429
|
+
|
|
430
|
+
# Robbins–Monro update of global logits
|
|
431
|
+
pi = torch.softmax(self.discrete_logits[name], dim=0)
|
|
432
|
+
self.discrete_logits[name] += eta * (hist - pi)
|
|
433
|
+
|
|
434
|
+
return accept # accept : torch.BoolTensor of shape (n_chain,)
|
|
435
|
+
|
|
436
|
+
def _build_update_schedule(self, update_blocks):
|
|
437
|
+
if update_blocks is None:
|
|
438
|
+
return [[v] for v in self.latent_vars]
|
|
439
|
+
|
|
440
|
+
name_to_var = {v.name: v for v in self.latent_vars}
|
|
441
|
+
|
|
442
|
+
blocks = []
|
|
443
|
+
used = set()
|
|
444
|
+
|
|
445
|
+
for block_names in update_blocks:
|
|
446
|
+
block_vars = [name_to_var[n] for n in block_names]
|
|
447
|
+
blocks.append(block_vars)
|
|
448
|
+
used.update(block_names)
|
|
449
|
+
|
|
450
|
+
# leftover variables → singletons
|
|
451
|
+
for v in self.latent_vars:
|
|
452
|
+
if v.name not in used:
|
|
453
|
+
blocks.append([v])
|
|
454
|
+
|
|
455
|
+
return blocks
|
|
456
|
+
|
|
457
|
+
# Helper: block proposal
|
|
458
|
+
def _propose_block(self, vars):
|
|
459
|
+
"""
|
|
460
|
+
Propose new values for a block of variables.
|
|
461
|
+
Returns dict {var_name: x_new_tensor}
|
|
462
|
+
"""
|
|
463
|
+
proposed = {}
|
|
464
|
+
for v in vars:
|
|
465
|
+
name = v.name
|
|
466
|
+
x_old = self.state[name]
|
|
467
|
+
|
|
468
|
+
if is_discrete(v):
|
|
469
|
+
K = num_categories(v)
|
|
470
|
+
x_new = propose_discrete_adaptive(x_old, self.discrete_logits[name], self.adapt.alpha)
|
|
471
|
+
else:
|
|
472
|
+
sigma = float(torch.exp(self.log_sigma[name]).clamp_min(1e-12).item())
|
|
473
|
+
x_new = propose_continuous_rw_gaussian(x_old, sigma)
|
|
474
|
+
|
|
475
|
+
proposed[name] = x_new
|
|
476
|
+
|
|
477
|
+
return proposed
|
|
478
|
+
|
|
479
|
+
# Run
|
|
480
|
+
@torch.no_grad()
|
|
481
|
+
def run(
|
|
482
|
+
self,
|
|
483
|
+
n_iter: int,
|
|
484
|
+
update_blocks: Optional[List[List[str]]] = None,
|
|
485
|
+
store_every: int = 0,
|
|
486
|
+
progress_every: int = 100,
|
|
487
|
+
) -> Dict[str, Any]:
|
|
488
|
+
"""
|
|
489
|
+
Run MCMC with optional block updates.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
n_iter : int
|
|
494
|
+
Number of MCMC iterations.
|
|
495
|
+
update_blocks : list[list[str]] or None
|
|
496
|
+
Variable blocks to update together.
|
|
497
|
+
- None → update all latent variables one by one
|
|
498
|
+
- Each inner list is a block of variable names
|
|
499
|
+
- Variables not listed are updated individually
|
|
500
|
+
store_every : int
|
|
501
|
+
If >0, stores thinned samples every `store_every` iterations.
|
|
502
|
+
Warning: storing full (n_evi, n_chain) states can be very large.
|
|
503
|
+
|
|
504
|
+
Returns
|
|
505
|
+
-------
|
|
506
|
+
dict with:
|
|
507
|
+
- 'accept_rate': dict[block_name -> float]
|
|
508
|
+
- 'logp_chain': final (n_chain,)
|
|
509
|
+
- 'logp_evi_chain': final (n_evi, n_chain)
|
|
510
|
+
- 'states_thinned' (optional)
|
|
511
|
+
- 'log_sigma' (continuous proposal scales)
|
|
512
|
+
"""
|
|
513
|
+
# --------------------------------------------------
|
|
514
|
+
# Safety check
|
|
515
|
+
# --------------------------------------------------
|
|
516
|
+
if self.logp_chain is None or self.logp_evi_chain is None:
|
|
517
|
+
raise RuntimeError(
|
|
518
|
+
"State not initialised. Call init_state_from_forward_samples() "
|
|
519
|
+
"or init_state_random()."
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# --------------------------------------------------
|
|
523
|
+
# Build update schedule (blocks)
|
|
524
|
+
# --------------------------------------------------
|
|
525
|
+
name_to_var = {v.name: v for v in self.latent_vars}
|
|
526
|
+
|
|
527
|
+
if update_blocks is None:
|
|
528
|
+
# default: singleton blocks
|
|
529
|
+
blocks = [[v] for v in self.latent_vars]
|
|
530
|
+
else:
|
|
531
|
+
blocks = [] # List[variable.Variable]
|
|
532
|
+
used = set()
|
|
533
|
+
|
|
534
|
+
for block_names in update_blocks:
|
|
535
|
+
block_vars = []
|
|
536
|
+
for nm in block_names:
|
|
537
|
+
if nm not in name_to_var:
|
|
538
|
+
print(
|
|
539
|
+
f"[HybridAdaptiveMH] Skipping '{nm}' in update_blocks: "
|
|
540
|
+
"not a latent variable."
|
|
541
|
+
)
|
|
542
|
+
continue
|
|
543
|
+
block_vars.append(name_to_var[nm])
|
|
544
|
+
blocks.append(block_vars)
|
|
545
|
+
used.update(block_names)
|
|
546
|
+
|
|
547
|
+
# add leftover latent variables as singleton blocks
|
|
548
|
+
for v in self.latent_vars:
|
|
549
|
+
if v.name not in used:
|
|
550
|
+
blocks.append([v])
|
|
551
|
+
|
|
552
|
+
# --------------------------------------------------
|
|
553
|
+
# Bookkeeping
|
|
554
|
+
# --------------------------------------------------
|
|
555
|
+
def block_name(block):
|
|
556
|
+
if len(block) == 1:
|
|
557
|
+
return block[0].name
|
|
558
|
+
return "(" + ",".join(v.name for v in block) + ")"
|
|
559
|
+
|
|
560
|
+
block_names = [block_name(b) for b in blocks] # List[str]
|
|
561
|
+
|
|
562
|
+
accept_counts = {bn: 0.0 for bn in block_names}
|
|
563
|
+
total_updates = {bn: 0.0 for bn in block_names}
|
|
564
|
+
|
|
565
|
+
states_thinned = [] if (store_every and store_every > 0) else None
|
|
566
|
+
|
|
567
|
+
# --------------------------------------------------
|
|
568
|
+
# Main MCMC loop
|
|
569
|
+
# --------------------------------------------------
|
|
570
|
+
total_time = 0.0
|
|
571
|
+
st = time.time()
|
|
572
|
+
for it in range(n_iter):
|
|
573
|
+
for block, bn in zip(blocks, block_names):
|
|
574
|
+
accept = self.mh_update_block(block, iteration=it) # accept.shape == (n_chain,)
|
|
575
|
+
|
|
576
|
+
accept_rate_step = float(accept.float().mean().item())
|
|
577
|
+
accept_counts[bn] += accept_rate_step
|
|
578
|
+
total_updates[bn] += 1.0
|
|
579
|
+
|
|
580
|
+
if states_thinned is not None and ((it + 1) % store_every == 0):
|
|
581
|
+
# WARNING: this can be huge
|
|
582
|
+
snap = {k: v.detach().clone() for k, v in self.state.items()}
|
|
583
|
+
states_thinned.append(snap)
|
|
584
|
+
|
|
585
|
+
# ---- progress ----
|
|
586
|
+
if progress_every > 0 and (it + 1) % progress_every == 0:
|
|
587
|
+
et = time.time()
|
|
588
|
+
time1 = et - st
|
|
589
|
+
total_time += time1
|
|
590
|
+
st = et
|
|
591
|
+
|
|
592
|
+
# running acceptance rates
|
|
593
|
+
acc_str = ", ".join(
|
|
594
|
+
f"{bn}={accept_counts[bn] / max(total_updates[bn], 1):.2f}"
|
|
595
|
+
for bn in block_names
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
print(
|
|
599
|
+
f"[MCMC] {it+1}/{n_iter} iterations completed "
|
|
600
|
+
f"({n_iter - it - 1} remaining) "
|
|
601
|
+
f"{time1:.2f} sec taken, total {total_time:.2f} sec\n"
|
|
602
|
+
f" accept: {acc_str}"
|
|
603
|
+
)
|
|
604
|
+
# --------------------------------------------------
|
|
605
|
+
# Outputs
|
|
606
|
+
# --------------------------------------------------
|
|
607
|
+
accept_rate = {
|
|
608
|
+
bn: accept_counts[bn] / max(total_updates[bn], 1.0)
|
|
609
|
+
for bn in block_names
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
log_sigma_out = {
|
|
613
|
+
k: float(v.detach().cpu().item())
|
|
614
|
+
for k, v in self.log_sigma.items()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
out = {
|
|
618
|
+
"accept_rate": accept_rate,
|
|
619
|
+
"logp_chain": self.logp_chain.detach().clone(),
|
|
620
|
+
"logp_evi_chain": self.logp_evi_chain.detach().clone(),
|
|
621
|
+
"log_sigma": log_sigma_out,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if states_thinned is not None:
|
|
625
|
+
out["states_thinned"] = states_thinned
|
|
626
|
+
|
|
627
|
+
return out
|