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.
- problm_solver/__init__.py +9 -0
- problm_solver/adjust_probs.py +583 -0
- problm_solver/analysis/__init__.py +8 -0
- problm_solver/analysis/probabilities.py +51 -0
- problm_solver/candidates.py +173 -0
- problm_solver/cli.py +338 -0
- problm_solver/constants.py +7 -0
- problm_solver/data.py +244 -0
- problm_solver/datasets/__init__.py +15 -0
- problm_solver/datasets/math500.py +43 -0
- problm_solver/llama_interface.py +836 -0
- problm_solver/llama_lowlevel.py +399 -0
- problm_solver/py.typed +0 -0
- problm_solver/random/__init__.py +11 -0
- problm_solver/random/random.py +70 -0
- problm_solver/random/resolve.py +44 -0
- problm_solver/random/types.py +7 -0
- problm_solver/utils.py +28 -0
- problm_solver-1.5.1.dist-info/METADATA +156 -0
- problm_solver-1.5.1.dist-info/RECORD +22 -0
- problm_solver-1.5.1.dist-info/WHEEL +4 -0
- problm_solver-1.5.1.dist-info/entry_points.txt +3 -0
|
@@ -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,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]
|