problm-solver 1.5.1__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.
@@ -0,0 +1,9 @@
1
+ """probLM-solver."""
2
+
3
+ from problm_solver.random import RandomManager
4
+
5
+ PSRandom = RandomManager
6
+
7
+ __all__ = [
8
+ 'PSRandom'
9
+ ]
@@ -0,0 +1,583 @@
1
+ """Implement several adjustment functions for generate_adjusted."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import numpy.typing as npt
11
+ from tqdm import tqdm
12
+
13
+ from problm_solver.candidates import CandidateTokens
14
+ from problm_solver.random import RNGLike, resolve_rng
15
+
16
+ # -- Module-wide setup -- #
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class GenerationContext:
23
+ """All information available to an :data:`AdjustFn` at each generation step.
24
+
25
+ Injected by ``generate_adjusted()`` so that adjustment functions can
26
+ access model-querying capabilities without a direct dependency on
27
+ ``ModelInstance``. All mutable fields are defensive copies.
28
+
29
+ :param token_id_probs: Current top-k candidate token IDs and log-probabilities.
30
+ :param prev_probs: Normalised probabilities of all previously selected
31
+ tokens in this generation. Empty on the first step.
32
+ :param context_tokens: The current token ID sequence (prompt + generated
33
+ tokens so far).
34
+ :param query_next_id: Queries the model for top-k next-token candidates in
35
+ token-ID space given a context token ID list.
36
+ :param query_branch: Generates a complete branch of up to ``depth`` tokens
37
+ from the given context in a single model call and returns the sum of
38
+ per-token log-probabilities. Returns ``0.0`` on immediate EOS.
39
+ :param query_branch_from_live: Generates a complete branch of up to
40
+ ``depth`` tokens from the model's currently loaded live state and
41
+ returns the sum of per-token log-probabilities. Optional.
42
+ """
43
+
44
+ token_id_probs: CandidateTokens
45
+ prev_probs: list[float]
46
+ context_tokens: list[int]
47
+ query_next_id: Callable[[list[int]], CandidateTokens]
48
+ query_branch: Callable[[list[int], int], float]
49
+ query_branch_from_live: Callable[[int], float] | None = None
50
+ base_live_state: Any | None = None
51
+ query_next_ids_from_live: Callable[[int], list[tuple[int, float]]] | None = None
52
+ save_live_state: Callable[[], Any] | None = None
53
+ load_live_state: Callable[[Any], None] | None = None
54
+ eval_tokens: Callable[[list[int]], None] | None = None
55
+
56
+
57
+ # Callable that receives a GenerationContext and returns adjusted
58
+ # token-ID candidates with log-probabilities.
59
+ type AdjustFn = Callable[[GenerationContext], CandidateTokens]
60
+
61
+
62
+ def candidate_tokens_to_id_logprobs(candidates: CandidateTokens) -> dict[int, float]:
63
+ """Convert CandidateTokens to an insertion-ordered token-id logprob map."""
64
+ return {
65
+ int(tid): float(lp)
66
+ for tid, lp in zip(
67
+ candidates.candidate_ids,
68
+ candidates.candidate_logprobs,
69
+ strict=True,
70
+ )
71
+ }
72
+
73
+
74
+ def id_logprobs_to_candidate_tokens(id_logprobs: dict[int, float]) -> CandidateTokens:
75
+ """Convert token-id logprob map to CandidateTokens preserving insertion order."""
76
+ items = list(id_logprobs.items())
77
+ if not items:
78
+ return CandidateTokens(
79
+ candidate_ids=np.empty(0, dtype=np.int32),
80
+ candidate_logprobs=np.empty(0, dtype=np.float64),
81
+ )
82
+
83
+ ids = np.array([tid for tid, _ in items], dtype=np.int32)
84
+ lps = np.array([lp for _, lp in items], dtype=np.float64)
85
+ return CandidateTokens(candidate_ids=ids, candidate_logprobs=lps)
86
+
87
+
88
+ def adjust_identity(context: GenerationContext) -> CandidateTokens:
89
+ """Return token log-probabilities unchanged in token-ID space."""
90
+ return context.token_id_probs
91
+
92
+
93
+ class SampleLowTemp:
94
+ """Adjust token log-probabilities by power-scaling with selection history.
95
+
96
+ At each generation step, the current token probabilities are raised to
97
+ ``alpha`` and multiplied by the product of all previously selected token
98
+ probabilities each also raised to ``alpha``. The result is returned as
99
+ log-probabilities for downstream renormalisation and sampling.
100
+
101
+ :param alpha: Scaling exponent. Values greater than 1 sharpen the
102
+ distribution (favouring already-likely tokens); values between 0
103
+ and 1 flatten it.
104
+
105
+ Example usage::
106
+
107
+ adjust_fn = SampleLowTemp(alpha=2)
108
+ result = adjust_fn(context)
109
+ """
110
+
111
+ def __init__(self, alpha: float) -> None:
112
+ """Initialise with scaling exponent.
113
+
114
+ :param alpha: Exponent applied to current and previous token
115
+ probabilities when computing the adjustment.
116
+ """
117
+ self.alpha = alpha
118
+ self._prev_len = 0
119
+ self._prev_logprob_sum = 0.0
120
+
121
+ def reset(self) -> None:
122
+ """Reset rolling history state for a new generation."""
123
+ self._prev_len = 0
124
+ self._prev_logprob_sum = 0.0
125
+
126
+ def _history_log_shift(self, prev_probs: list[float]) -> float:
127
+ """Return rolling ``alpha * sum(log(prev_probs))`` for history scaling."""
128
+ cur_len = len(prev_probs)
129
+ if cur_len == 0:
130
+ self.reset()
131
+ return 0.0
132
+
133
+ if cur_len < self._prev_len:
134
+ self._prev_logprob_sum = float(np.sum(np.log(np.array(prev_probs, dtype=float))))
135
+ elif cur_len == self._prev_len + 1:
136
+ self._prev_logprob_sum += float(np.log(prev_probs[-1]))
137
+ elif cur_len == self._prev_len:
138
+ self._prev_logprob_sum = float(np.sum(np.log(np.array(prev_probs, dtype=float))))
139
+ else:
140
+ self._prev_logprob_sum = float(np.sum(np.log(np.array(prev_probs, dtype=float))))
141
+
142
+ self._prev_len = cur_len
143
+ return self.alpha * self._prev_logprob_sum
144
+
145
+ def __call__(self, context: GenerationContext) -> CandidateTokens:
146
+ """Apply power-scaling adjustment to the current token-ID distribution."""
147
+ candidate_ids = context.token_id_probs.candidate_ids
148
+ lp = context.token_id_probs.candidate_logprobs.astype(np.float64, copy=True)
149
+ lp -= lp.max()
150
+
151
+ prev_probs = context.prev_probs if context.prev_probs is not None else []
152
+ history_log_shift = self._history_log_shift(prev_probs)
153
+ new_logprobs: npt.NDArray[np.float64] = self.alpha * lp + history_log_shift
154
+ return CandidateTokens(
155
+ candidate_ids=candidate_ids.astype(np.int32, copy=False),
156
+ candidate_logprobs=new_logprobs,
157
+ )
158
+
159
+
160
+ class BranchSampler(ABC):
161
+ """Abstract base class for branch-level sampling strategies.
162
+
163
+ A ``BranchSampler`` typically runs over complete branch proposals. Some
164
+ subclasses may additionally provide token-by-token beam expansion via
165
+ :meth:`future_logprob_from_context`.
166
+ """
167
+
168
+ supports_token_beam = False
169
+
170
+ def reset(self) -> None: # noqa: B027
171
+ """Reset internal state at the start of each candidate-token chain.
172
+
173
+ No-op for stateless samplers. Stateful samplers (e.g.
174
+ :class:`MetropolisSampler`) should override this.
175
+ """
176
+
177
+ @abstractmethod
178
+ def step(
179
+ self,
180
+ proposed_log_prob: float,
181
+ alpha: float = 1.0,
182
+ forward_log_q: float = 0.0,
183
+ reverse_log_q: float = 0.0,
184
+ ) -> float:
185
+ """Process one proposed branch and return the accepted chain state."""
186
+
187
+ @abstractmethod
188
+ def should_continue(self, branch_log_probs: npt.NDArray[np.float64]) -> bool:
189
+ """Return ``True`` if more branch proposals should be sampled."""
190
+
191
+ @abstractmethod
192
+ def future_logprob(self, alpha: float, branch_log_probs: npt.NDArray[np.float64]) -> np.float64:
193
+ """Calculate weighting to token probability from sampled branches."""
194
+
195
+ def future_logprob_from_context(
196
+ self,
197
+ alpha: float,
198
+ base_live_state: Any,
199
+ branch_token_ids: list[int],
200
+ lookahead_depth: int,
201
+ query_next_ids_from_live: Callable[[int], list[tuple[int, float]]],
202
+ save_live_state: Callable[[], Any],
203
+ load_live_state: Callable[[Any], None],
204
+ eval_tokens: Callable[[list[int]], None],
205
+ ) -> np.float64:
206
+ """Optional token-by-token beam expansion hook.
207
+
208
+ Subclasses that implement token-level beam search should override this
209
+ method and set ``supports_token_beam = True``.
210
+ """
211
+ raise NotImplementedError
212
+
213
+
214
+ class MetropolisSampler(BranchSampler):
215
+ """Metropolis-Hastings sampler over complete branch proposals.
216
+
217
+ Given a sequence of proposed branch log-probabilities, maintains an MCMC
218
+ chain and accepts a proposal with probability
219
+
220
+ ``min(1, exp(log p(x') - log p(x) + log q(x|x') - log q(x'|x)))``.
221
+
222
+ Convergence across accepted branch samples is assessed via the standard
223
+ error of the mean (SEM): ``SEM = std(branch_log_probs) / sqrt(n)``.
224
+ Sampling continues until ``SEM < tolerance``, after at least
225
+ ``equil_branches`` samples, and always stops at ``max_branches``.
226
+
227
+ :param equil_branches: Number of accepted samples treated as burn-in
228
+ (equilibration); discarded before checking SEM.
229
+ :param max_branches: Hard upper limit on accepted samples.
230
+ :param tolerance: SEM threshold below which sampling is considered
231
+ converged.
232
+ """
233
+
234
+ def __init__(
235
+ self,
236
+ equil_branches: int = 5,
237
+ max_branches: int = 30,
238
+ tolerance: float = 1e-1,
239
+ rng: RNGLike = None
240
+ ) -> None:
241
+ """Initialise with convergence parameters."""
242
+ self._current_log_prob: float | None = None
243
+ self._equil_branches = equil_branches
244
+ self._max_branches = max_branches
245
+ self._tolerance = tolerance
246
+ self._rng = resolve_rng(rng, stream='adjust.metropolis')
247
+
248
+ def reset(self) -> None:
249
+ """Clear chain state before starting a new candidate-token chain."""
250
+ self._current_log_prob = None
251
+
252
+ def step(
253
+ self,
254
+ proposed_log_prob: float,
255
+ alpha: float = 1.0,
256
+ forward_log_q: float = 0.0,
257
+ reverse_log_q: float = 0.0,
258
+ ) -> float:
259
+ """Apply one Metropolis-Hastings accept/reject step targeting ``p^α``.
260
+
261
+ The log acceptance ratio is
262
+ ``(α-1) * (log p(x') - log p(x)) + log q(x|x') - log q(x'|x)``.
263
+ When the proposal ``q`` is the base model ``p`` the proposal terms
264
+ cancel (``forward_log_q = proposed_log_prob``,
265
+ ``reverse_log_q = current_log_prob``), reducing to
266
+ ``(α-1) * (proposed - current)``.
267
+
268
+ :param proposed_log_prob: Proposed branch log-probability under ``p``.
269
+ :param alpha: Power-distribution exponent.
270
+ :param forward_log_q: ``log q(x'|x)`` for the proposal.
271
+ :param reverse_log_q: ``log q(x|x')`` for the reverse proposal.
272
+ :returns: Accepted chain state's log-probability.
273
+ """
274
+ if self._current_log_prob is None:
275
+ self._current_log_prob = proposed_log_prob
276
+ return self._current_log_prob
277
+
278
+ log_accept_ratio = (
279
+ (alpha - 1) * (proposed_log_prob - self._current_log_prob)
280
+ + reverse_log_q
281
+ - forward_log_q
282
+ )
283
+ if np.log(self._rng.random()) < min(0.0, log_accept_ratio):
284
+ self._current_log_prob = proposed_log_prob
285
+
286
+ return self._current_log_prob
287
+
288
+ def should_continue(self, branch_log_probs: npt.NDArray[np.float64]) -> bool:
289
+ """Return ``True`` if more proposals should be sampled.
290
+
291
+ Uses SEM-based convergence after ``equil_branches`` and before
292
+ ``max_branches``.
293
+
294
+ :param: branch logarithmic probabilities to date.
295
+ """
296
+ return len(branch_log_probs) < self._max_branches
297
+
298
+ def future_logprob(self, alpha: float, branch_log_probs: npt.NDArray[np.float64]) -> np.float64:
299
+ """Monte Carlo mean weight."""
300
+ post_eq = branch_log_probs[self._equil_branches:]
301
+ scaled = alpha * post_eq
302
+ max_lp = np.float64(scaled.max())
303
+ return np.log(np.mean(np.exp(scaled - max_lp))) + max_lp
304
+
305
+
306
+ class BeamSampler(BranchSampler):
307
+ """Token-by-token beam expansion for future-branch scoring.
308
+
309
+ This sampler performs deterministic beam search over lookahead tokens.
310
+ For each candidate token, it repeatedly expands active beams using
311
+ ``query_next_ids_from_live`` and keeps only the top ``beam_width`` cumulative
312
+ log-probability branches at every depth.
313
+
314
+ :param beam_width: Number of active beams retained per depth.
315
+ :param branch_top_k: Number of next-token candidates considered for each
316
+ active beam during expansion.
317
+ """
318
+
319
+ supports_token_beam = True
320
+
321
+ def __init__(self, beam_width: int = 3, branch_top_k: int = 5) -> None:
322
+ """Initialise beam-search width and per-beam expansion width."""
323
+ if beam_width < 1:
324
+ msg = f'beam_width must be >= 1, got {beam_width}'
325
+ raise ValueError(msg)
326
+ if branch_top_k < 1:
327
+ msg = f'branch_top_k must be >= 1, got {branch_top_k}'
328
+ raise ValueError(msg)
329
+
330
+ self.beam_width = beam_width
331
+ self.branch_top_k = branch_top_k
332
+
333
+ def reset(self) -> None:
334
+ """No-op: beam expansion is stateless across candidates."""
335
+
336
+ def step(
337
+ self,
338
+ proposed_log_prob: float,
339
+ alpha: float = 1.0, #noqa:ARG002
340
+ forward_log_q: float = 0.0, #noqa:ARG002
341
+ reverse_log_q: float = 0.0, #noqa:ARG002
342
+ ) -> float:
343
+ """Compatibility no-op; token-beam mode does not use MH transitions."""
344
+ return proposed_log_prob
345
+
346
+ def should_continue(self, branch_log_probs: npt.NDArray[np.float64]) -> bool:
347
+ """Compatibility no-op; token-beam mode controls depth directly."""
348
+ return False
349
+
350
+ def future_logprob(self, alpha: float, branch_log_probs: npt.NDArray[np.float64]) -> np.float64:
351
+ """Return log-mean-exp over supplied branch scores.
352
+
353
+ This method is retained for compatibility, but token-beam mode
354
+ normally uses :meth:`future_logprob_from_context`.
355
+ """
356
+ if len(branch_log_probs) == 0:
357
+ msg = 'branch_log_probs cannot be empty'
358
+ raise ValueError(msg)
359
+
360
+ scaled = alpha * branch_log_probs
361
+ max_lp = np.float64(scaled.max())
362
+ return np.log(np.mean(np.exp(scaled - max_lp))) + max_lp
363
+
364
+ def future_logprob_from_context(
365
+ self,
366
+ alpha: float,
367
+ base_live_state: Any,
368
+ branch_token_ids: list[int],
369
+ lookahead_depth: int,
370
+ query_next_ids_from_live: Callable[[int], list[tuple[int, float]]],
371
+ save_live_state: Callable[[], Any],
372
+ load_live_state: Callable[[Any], None],
373
+ eval_tokens: Callable[[list[int]], None],
374
+ ) -> np.float64:
375
+ """Run token-level beam expansion with KV-cache state reuse."""
376
+ load_live_state(base_live_state)
377
+ if branch_token_ids:
378
+ eval_tokens(branch_token_ids)
379
+ root_state = save_live_state()
380
+
381
+ beams: list[tuple[Any, float]] = [(root_state, 0.0)]
382
+
383
+ for _ in range(lookahead_depth):
384
+ expanded: list[tuple[Any, float]] = []
385
+
386
+ for beam_state, cum_lp in beams:
387
+ load_live_state(beam_state)
388
+ top_next = query_next_ids_from_live(self.branch_top_k)
389
+
390
+ for token_id, token_lp in top_next:
391
+ load_live_state(beam_state)
392
+ eval_tokens([token_id])
393
+ child_state = save_live_state()
394
+ expanded.append((child_state, cum_lp + float(token_lp)))
395
+
396
+ if not expanded:
397
+ break
398
+
399
+ expanded.sort(key=lambda item: item[1], reverse=True)
400
+ beams = expanded[: self.beam_width]
401
+
402
+ if not beams:
403
+ return np.float64(-np.inf)
404
+
405
+ beam_log_probs = np.array([lp for _, lp in beams], dtype=np.float64)
406
+ scaled = alpha * beam_log_probs
407
+ max_lp = np.float64(scaled.max())
408
+ return np.log(np.mean(np.exp(scaled - max_lp))) + max_lp
409
+
410
+
411
+ class SamplePowerDist:
412
+ """Adjust token log-probabilities using future-branch power-distribution sampling.
413
+
414
+ For each candidate next token, repeatedly proposes complete future
415
+ branches of length ``lookahead_depth`` and updates a Markov chain using the
416
+ injected :class:`BranchSampler` (e.g. Metropolis-Hastings), continuing
417
+ until :meth:`~BranchSampler.should_continue` signals convergence. Each
418
+ branch is evaluated in a single model call via
419
+ :attr:`~GenerationContext.query_branch`, rather than token-by-token. The
420
+ accepted branch log-probabilities are kept as a ``numpy`` array and
421
+ combined with the current token's log-probability via log-sum-exp to
422
+ produce the adjusted distribution.
423
+
424
+ Branches that reach EOS before ``lookahead_depth`` are terminated early
425
+ with no penalty — their partial log-probability is used as-is.
426
+
427
+ :param alpha: Scaling exponent applied to the current token log-probability.
428
+ :param lookahead_depth: Maximum number of steps to sample in each branch.
429
+ :param branch_sampler: Strategy used to sample tokens within each branch
430
+ and to determine when enough branches have been collected.
431
+ Must be a :class:`BranchSampler` instance; its
432
+ :meth:`~BranchSampler.reset` method is called at the start of every
433
+ branch.
434
+
435
+ Example usage::
436
+
437
+ sampler = SamplePowerDist(
438
+ alpha=2.0,
439
+ lookahead_depth=3,
440
+ branch_sampler=MetropolisSampler(),
441
+ )
442
+ result = sampler(context)
443
+ """
444
+
445
+ def __init__(
446
+ self,
447
+ alpha: float,
448
+ lookahead_depth: int,
449
+ branch_sampler: BranchSampler,
450
+ ) -> None:
451
+ """Initialise with lookahead parameters and a branch sampler.
452
+
453
+ :param alpha: Scaling exponent for the current token log-probability.
454
+ :param lookahead_depth: Maximum depth of each branch.
455
+ :param branch_sampler: The :class:`BranchSampler` to use within
456
+ branches and for convergence decisions.
457
+ """
458
+ self.alpha = alpha
459
+ self.lookahead_depth = lookahead_depth
460
+ self.branch_sampler = branch_sampler
461
+
462
+ def __call__(self, context: GenerationContext) -> CandidateTokens:
463
+ """Apply power-distribution adjustment using lookahead branch sampling.
464
+
465
+ :param context: The current generation context in token-ID space.
466
+ :returns: Adjusted candidate token IDs with log-probabilities.
467
+ """
468
+ result: dict[int, float] = {}
469
+
470
+ if self.branch_sampler.supports_token_beam:
471
+ if (
472
+ context.base_live_state is None
473
+ or context.query_next_ids_from_live is None
474
+ or context.save_live_state is None
475
+ or context.load_live_state is None
476
+ or context.eval_tokens is None
477
+ ):
478
+ msg = 'Token-beam sampler requires live-state callables in GenerationContext'
479
+ raise ValueError(msg)
480
+
481
+ base_live_state = context.base_live_state
482
+ query_next_ids_from_live = context.query_next_ids_from_live
483
+ save_live_state = context.save_live_state
484
+ load_live_state = context.load_live_state
485
+ eval_tokens = context.eval_tokens
486
+
487
+ def score_future(branch_token_ids: list[int]) -> np.float64:
488
+ return self.branch_sampler.future_logprob_from_context(
489
+ alpha=self.alpha,
490
+ base_live_state=base_live_state,
491
+ branch_token_ids=branch_token_ids,
492
+ lookahead_depth=self.lookahead_depth,
493
+ query_next_ids_from_live=query_next_ids_from_live,
494
+ save_live_state=save_live_state,
495
+ load_live_state=load_live_state,
496
+ eval_tokens=eval_tokens,
497
+ )
498
+ else:
499
+ has_live_branch = (
500
+ context.query_branch_from_live is not None
501
+ and context.base_live_state is not None
502
+ and context.save_live_state is not None
503
+ and context.load_live_state is not None
504
+ and context.eval_tokens is not None
505
+ )
506
+
507
+ if has_live_branch:
508
+ base_live_state = context.base_live_state
509
+ query_branch_from_live = context.query_branch_from_live
510
+ save_live_state = context.save_live_state
511
+ load_live_state = context.load_live_state
512
+ eval_tokens = context.eval_tokens
513
+
514
+ def score_future_from_candidate_id(candidate_id: int) -> np.float64:
515
+ load_live_state(base_live_state)
516
+ eval_tokens([candidate_id])
517
+ candidate_root_state = save_live_state()
518
+
519
+ branch_log_probs_list: list[float] = []
520
+ self.branch_sampler.reset()
521
+
522
+ while True:
523
+ load_live_state(candidate_root_state)
524
+ proposed_branch_log_prob = query_branch_from_live(self.lookahead_depth)
525
+
526
+ accepted_log_prob = self.branch_sampler.step(
527
+ proposed_log_prob=proposed_branch_log_prob,
528
+ alpha=self.alpha,
529
+ )
530
+ branch_log_probs_list.append(accepted_log_prob)
531
+
532
+ if not self.branch_sampler.should_continue(
533
+ np.array(branch_log_probs_list, dtype=np.float64)
534
+ ):
535
+ break
536
+
537
+ branch_log_probs = np.array(branch_log_probs_list, dtype=np.float64)
538
+ return self.branch_sampler.future_logprob(self.alpha, branch_log_probs)
539
+ else:
540
+ def score_future_from_candidate_id(candidate_id: int) -> np.float64:
541
+ branch_ctx = list(context.context_tokens) + [candidate_id]
542
+ branch_log_probs_list: list[float] = []
543
+ self.branch_sampler.reset()
544
+
545
+ while True:
546
+ proposed_branch_log_prob = context.query_branch(
547
+ branch_ctx,
548
+ self.lookahead_depth,
549
+ )
550
+
551
+ accepted_log_prob = self.branch_sampler.step(
552
+ proposed_log_prob=proposed_branch_log_prob,
553
+ alpha=self.alpha,
554
+ )
555
+ branch_log_probs_list.append(accepted_log_prob)
556
+
557
+ if not self.branch_sampler.should_continue(
558
+ np.array(branch_log_probs_list, dtype=np.float64)
559
+ ):
560
+ break
561
+
562
+ branch_log_probs = np.array(branch_log_probs_list, dtype=np.float64)
563
+ return self.branch_sampler.future_logprob(self.alpha, branch_log_probs)
564
+
565
+ candidate_ids = context.token_id_probs.candidate_ids
566
+ candidate_logprobs = context.token_id_probs.candidate_logprobs
567
+
568
+ candidate_bar = tqdm(
569
+ zip(candidate_ids, candidate_logprobs, strict=True),
570
+ desc='candidates',
571
+ total=len(candidate_ids),
572
+ unit='tok',
573
+ leave=False,
574
+ )
575
+ for token_id, log_prob in candidate_bar:
576
+ tid = int(token_id)
577
+ if self.branch_sampler.supports_token_beam:
578
+ future_lp = score_future([tid])
579
+ else:
580
+ future_lp = score_future_from_candidate_id(tid)
581
+ result[tid] = self.alpha * float(log_prob) + float(future_lp)
582
+
583
+ return id_logprobs_to_candidate_tokens(result)
@@ -0,0 +1,8 @@
1
+ """Statistical analysis tools for LLM output data."""
2
+
3
+ from problm_solver.analysis.probabilities import prob_of_token, sample_from_logprobs
4
+
5
+ __all__ = [
6
+ 'prob_of_token',
7
+ 'sample_from_logprobs',
8
+ ]
@@ -0,0 +1,51 @@
1
+ """Utilities for sampling tokens and getting token probabilities."""
2
+
3
+
4
+ import numpy as np
5
+
6
+ from problm_solver.random import RNGLike, resolve_rng
7
+
8
+
9
+ def prob_of_token(token: str, log_probs: dict[str, float]) -> float:
10
+ """Return the normalised probability of a specific token from a log-prob dict.
11
+
12
+ Applies the same shift-exp-normalise procedure as
13
+ :func:`sample_from_logprobs`, then returns the scalar probability for
14
+ the named token rather than sampling.
15
+
16
+ :param token: The token string to look up. Must be a key in ``log_probs``.
17
+ :param log_probs: Mapping of token string to log-probability.
18
+ :returns: The normalised probability of ``token`` in the distribution,
19
+ in the range (0, 1].
20
+ :raises KeyError: If ``token`` is not present in ``log_probs``.
21
+ """
22
+ tokens = list(log_probs.keys())
23
+ lp = np.array([log_probs[t] for t in tokens], dtype=np.float64)
24
+ lp -= lp.max()
25
+ probs = np.exp(lp)
26
+ probs /= probs.sum()
27
+ return float(probs[tokens.index(token)])
28
+
29
+
30
+ def sample_from_logprobs(
31
+ log_probs: dict[str, float],
32
+ rng: RNGLike = None
33
+ ) -> str:
34
+ """Sample a token from a log-probability distribution.
35
+
36
+ Converts log-probabilities to probabilities via ``exp()``, renormalises,
37
+ and returns a single sampled token string.
38
+
39
+ :param log_probs: Mapping of token string to log-probability. Values do
40
+ not need to correspond to a normalised distribution — renormalisation
41
+ is applied before sampling.
42
+ :returns: A single sampled token string drawn from the distribution.
43
+ """
44
+ tokens = list(log_probs.keys())
45
+ lp = np.array([log_probs[t] for t in tokens], dtype=np.float64)
46
+ lp -= lp.max() # shift for numerical stability before exp
47
+ probs = np.exp(lp)
48
+ probs /= probs.sum()
49
+ method_rng = resolve_rng(rng, stream='analysis.sample_from_logprobs')
50
+ idx: int = int(method_rng.choice(len(tokens), p=probs))
51
+ return tokens[idx]