-
Notifications
You must be signed in to change notification settings - Fork 1k
Expand file tree
/
Copy pathevaluate.py
More file actions
509 lines (446 loc) · 18.9 KB
/
Copy pathevaluate.py
File metadata and controls
509 lines (446 loc) · 18.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# Copyright © 2024 Apple Inc.
"""
Adapted from a PyTorch implementation by David Grangier
"""
import argparse
import collections
import copy
import json
import logging
import os
from importlib.metadata import version
from pathlib import Path
from typing import Any, Callable, Optional
import lm_eval
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from lm_eval.api.model import LM
from lm_eval.api.registry import register_model
from lm_eval.models import huggingface
from tqdm import tqdm
from .generate import batch_generate
from .models.cache import make_prompt_cache
from .sample_utils import make_sampler
from .utils import load
DEFAULT_MAX_TOKENS = 8192
def _rstrip_until(s, untils):
"""Limit a string <s> to the first occurrence of any substring in untils."""
l = len(s)
f = [s.find(u) for u in untils]
f = [l if x < 0 else x for x in f]
return s[: min(f)]
def _lstrip(s, pattern):
"""Truncate the prefix of the string after the first occurrence of pattern."""
if (idx := s.find(pattern)) != -1:
return s[idx + len(pattern) :]
return s
def _pad_inputs(inputs):
lengths = np.array([len(x) for x in inputs])
maxlen = lengths.max()
padded = np.stack(
[np.pad(x, (0, maxlen - len(x))) for x in inputs],
axis=0,
)
return mx.array(padded), mx.array(lengths)
def chat_template_fn(**extra_kwargs):
def apply_chat_template(self, chat_history, add_generation_prompt=True) -> str:
return self.tokenizer.apply_chat_template(
chat_history,
tokenize=False,
add_generation_prompt=add_generation_prompt,
continue_final_message=not add_generation_prompt,
**extra_kwargs,
)
return apply_chat_template
@register_model("mlxlm")
class MLXLM(LM):
tokenizer_name = huggingface.HFLM.tokenizer_name
apply_chat_template = chat_template_fn()
def __init__(
self,
path_or_hf_repo: str,
max_tokens: Optional[int] = None,
use_chat_template: Optional[bool] = None,
trust_remote_code: bool = False,
sampler: Optional[Callable[[mx.array], mx.array]] = None,
) -> None:
super().__init__()
tokenizer_config = {"trust_remote_code": True if trust_remote_code else None}
self._model, self.tokenizer = load(
path_or_hf_repo, tokenizer_config=tokenizer_config
)
self._max_tokens = max_tokens
self._batch_size = 8
self.use_chat_template = use_chat_template
if use_chat_template is None:
self.use_chat_template = self.tokenizer.chat_template is not None
self._sampler = sampler
def _process_prompt(self, prompt, step_size: int = 2048):
prompt = mx.array(prompt)[None]
cache = make_prompt_cache(self._model)
for i in range(0, prompt.shape[1], step_size):
logits = self._model(prompt[:, i : i + step_size], cache=cache)
mx.eval([c.state for c in cache])
mx.clear_cache()
logprobs = nn.log_softmax(logits[:, -1, :].astype(mx.float32))
return logprobs, cache
def _score_fn(self, inputs, cache: Optional[Any] = None, step_size: int = 2048):
inputs, lengths = _pad_inputs(inputs)
inputs, targets = inputs[..., :-1], inputs[..., 1:]
cache = cache or make_prompt_cache(self._model)
offset = 0
scores, is_greedy = [], []
for i in range(0, inputs.shape[1], step_size):
inp = inputs[:, i : i + step_size]
T = inp.shape[1]
logits = self._model(inp, cache=cache)
log_probs = nn.log_softmax(logits.astype(mx.float32))
score = mx.take_along_axis(
log_probs, targets[:, i : i + step_size, mx.newaxis], axis=-1
)[..., 0]
ig = targets[:, i : i + step_size] == mx.argmax(logits, axis=-1)
ig = mx.where(mx.arange(offset, T + offset) < lengths[:, None], ig, False)
mx.eval(score, ig)
mx.clear_cache()
is_greedy.append(ig)
scores.append(score)
offset += T
scores = mx.concatenate(scores, axis=1)
is_greedy = mx.concatenate(is_greedy, axis=1)
return scores, lengths, is_greedy
def _tokenize(self, texts):
return [
tuple(
self.tokenizer.encode(t, add_special_tokens=not self.use_chat_template)
)
for t in texts
]
def loglikelihood(self, requests) -> list[tuple[float, bool]]:
"""Compute log-likelihood of generating a continuation from a context.
Downstream tasks should attempt to use loglikelihood instead of other
LM calls whenever possible.
:param requests: list[Instance]
A list of Instance objects, with property `args` which returns a tuple (context, continuation).
`context: str`
Context string. Implementations of LM must be able to handle an
empty context string.
`continuation: str`
The continuation over which log likelihood will be calculated. If
there is a word boundary, the space should be in the continuation.
For example, context="hello" continuation=" world" is correct.
:return: list[tuple[float, bool]]
A list of pairs (logprob, isgreedy)
`logprob: float`
The log probability of `continuation`.
`isgreedy`:
Whether `continuation` would be generated by greedy sampling from `context`.
"""
logging.info("Estimating loglikelihood for %d pairs." % len(requests))
group = mx.distributed.init()
# Group by common prefix
group_reqs = collections.defaultdict(list)
for idx, req in enumerate(requests):
group_reqs[req.args[0]].append((idx, req.args[1]))
questions = list(group_reqs.keys())
responses = []
indices = []
for v in group_reqs.values():
idx, resp = zip(*v)
indices.append(idx)
responses.append(resp)
# split data accross ranks
questions = questions[group.rank() :: group.size()]
responses = responses[group.rank() :: group.size()]
long_completions = 0
scores, is_greedy = [], []
for q, rs in tqdm(zip(questions, responses), total=len(questions)):
prefix = self._tokenize([q])[0]
full_sequences = self._tokenize([q + r for r in rs])
max_completed_l = max(len(s) for s in full_sequences)
# compute truncation length
max_tokens = self._max_tokens or DEFAULT_MAX_TOKENS
truncation = max(0, max_completed_l - max_tokens - 1)
orig_prefix_l = len(prefix)
prefix_l = max(len(prefix) - truncation, 0)
prefix = prefix[len(prefix) - prefix_l :]
# If the entire prompt got truncated ignore the question
if prefix_l == 0:
long_completions += 1
all_scores.extend([-float("inf")] * len(rs))
all_is_greedy.extend([False] * len(rs))
continue
# model scoring, returns num_requests x (logp, is_greedy, length).
logprobs, cache = self._process_prompt(prefix)
max_idx = mx.argmax(logprobs).item()
for s in full_sequences:
inputs = s[len(prefix) :]
# The logprobs from the last token of the prompt are
# for the first input token
scores.append(logprobs[0, inputs[0]].item())
is_greedy.append((inputs[0] == max_idx))
if len(inputs) == 1:
continue
score, _, ig = self._score_fn(
mx.array(inputs)[None, :], cache=copy.deepcopy(cache)
)
scores[-1] += mx.sum(score).item()
is_greedy[-1] &= mx.all(ig).item()
if long_completions > 0:
logging.info(
f"Prefix eliminated for {long_completions} requests with "
+ "completion longer than context."
)
# All gather the results across nodes
num_results = len(requests)
per_group = mx.distributed.all_max(len(scores), stream=mx.cpu).item()
scores = scores + [0] * (per_group - len(scores))
is_greedy = is_greedy + [False] * (per_group - len(is_greedy))
scores = mx.array(scores)
is_greedy = mx.array(is_greedy)
scores = mx.distributed.all_gather(scores, stream=mx.cpu)
is_greedy = mx.distributed.all_gather(is_greedy, stream=mx.cpu)
mx.eval(scores, is_greedy)
# Arrange the indices to match the scores from each node and then
# inverse sort the scores
all_indices = []
for rank in range(group.size()):
rank_indices = [
idx for question in indices[rank :: group.size()] for idx in question
]
rank_indices += [num_results] * (per_group - len(rank_indices))
all_indices.extend(rank_indices)
inv_sort = mx.argsort(mx.array(all_indices))
scores = scores[:num_results][inv_sort]
is_greedy = is_greedy[:num_results][inv_sort]
return list(zip(scores.tolist(), is_greedy.tolist()))
def loglikelihood_rolling(self, requests) -> list[float]:
"""Compute full log-likelihood of a string, with no truncation, for perplexity computation
- We will use the full max context length of the model.
- For inputs that exceed the max context length, we divide the tokenized string into chunks of up to
the max context length.
- IMPORTANT: Each document's loglikelihood/perplexity is computed *separately*, unlike other implementations
which may simply concatenate multiple documents together.
- IMPORTANT: We maximize the amount of context for each prediction. Specifically, for inputs that we break into
multiple chunks, the last input will still a full-sized context.
Example:
Input tokens: [ 0 1 2 3 4 5 6 7 8 9 ]
Prefix: EOT
Max context length: 4
Resulting input/prediction pairs:
INPUT: EOT 0 1 2
PRED: 0 1 2 3
INPUT: 3 4 5 6
PRED: 4 5 6 7
INPUT: 5 6 7 8
PRED: 8 9
Observe that:
1. Each token is predicted exactly once
2. For the last pair, we provide the full context, but only score the last two tokens
:param requests: list[Instance]
A list of Instance objects with property `args` which returns a tuple (context,).
string: str
String for which we are computing overall loglikelihood
:return: list[tuple[float]]
A list of tuples (logprob,)
logprob: float
The log probability of `context` conditioned on the EOT token.
"""
logging.info(
"Estimating loglikelihood rolling for %d sequences." % len(requests)
)
inputs = self._tokenize([req.args[0] for req in requests])
all_scores = []
for i in tqdm(range(0, len(inputs), self._batch_size)):
batch = inputs[i : i + self._batch_size]
scores, lengths, _ = self._score_fn(batch)
mask = mx.arange(scores.shape[-1]) < lengths[:, None]
all_scores.extend((mask * scores).sum(axis=-1).tolist())
return all_scores
def generate_until(self, requests) -> list[str]:
"""Generate greedily until a stopping sequence
:param requests: list[Instance]
A list of Instance objects with property `args` which returns a tuple (context, until).
context: str
Context string
until: [str]
The string sequences to generate until. These string sequences
may each span across multiple tokens, or may be part of one token.
:return: list[str]
A list of strings continuation
continuation: str
The generated continuation.
"""
group = mx.distributed.init()
# split data accross ranks
total_requests = len(requests)
requests = requests[group.rank() :: group.size()]
logging.info("Generating continuation for %d sequences." % len(requests))
contexts, options = zip(*[req.args for req in requests])
# The second element of the tuple contains:
# {'do_sample': False, 'until': ['\n\n'], 'temperature': 0}
# Tokenize all contexts
contexts = [
self.tokenizer.encode(
context, add_special_tokens=not self.use_chat_template
)
for context in contexts
]
# TODO consider multi-token, per-prompt stop conditions
max_tokens = [
self._max_tokens or opt.get("max_gen_tokens", DEFAULT_MAX_TOKENS)
for opt in options
]
completions = batch_generate(
model=self._model,
tokenizer=self.tokenizer,
prompts=contexts,
max_tokens=max_tokens,
verbose=True,
sampler=self._sampler,
).texts
for e, (text, opt) in enumerate(zip(completions, options)):
completions[e] = _rstrip_until(text, opt["until"])
if self.tokenizer.has_thinking:
completions[e] = _lstrip(text, self.tokenizer.think_end)
# Gather the completions
if group.size() > 1:
with mx.stream(mx.cpu):
pad_to = (total_requests + group.size() - 1) // group.size()
pad = pad_to - len(completions)
completions = [list(c.encode("utf-8")) for c in completions]
max_len = mx.array(max(len(c) for c in completions))
max_len = mx.distributed.all_max(max_len).item()
lengths = mx.array([len(c) for c in completions] + [0] * pad)
completions = mx.array(
[c + [0] * (max_len - len(c)) for c in completions]
+ [[0] * max_len] * pad,
mx.uint8,
)
completions = (
mx.distributed.all_gather(completions[None])
.swapaxes(0, 1)
.flatten(0, 1)
.tolist()
)
lengths = (
mx.distributed.all_gather(lengths[None])
.swapaxes(0, 1)
.flatten(0, 1)
.tolist()
)
completions = completions[:total_requests]
lengths = lengths[:total_requests]
completions = [
bytearray(c[:l]).decode() for c, l in zip(completions, lengths)
]
return completions
def main():
parser = argparse.ArgumentParser(
"Evaluate an MLX model using lm-evaluation-harness."
)
parser.add_argument("--model", help="Model to evaluate", required=True)
parser.add_argument("--tasks", nargs="+", required=True)
parser.add_argument(
"--output-dir", default=".", help="Output directory for result files."
)
parser.add_argument("--batch-size", type=int, default=16, help="Batch size")
parser.add_argument("--num-shots", type=int, default=None, help="Number of shots")
parser.add_argument(
"--max-tokens",
type=int,
help="Maximum number of tokens to generate. When set, this value takes"
" precedence over task specific defaults.",
default=None,
)
parser.add_argument(
"--limit",
default=None,
help="Limit the number of examples per task.",
type=int,
)
parser.add_argument("--seed", type=int, default=123, help="Random seed.")
parser.add_argument(
"--fewshot-as-multiturn",
action="store_true",
help="Whether to provide the fewshot examples as a multiturn "
"conversation or a single user turn.",
default=False,
)
parser.add_argument(
"--apply-chat-template",
action=argparse.BooleanOptionalAction,
help="Specifies whether to apply a chat template to the prompt. If "
"the model has a chat template, this defaults to `True`, "
"otherwise `False`.",
default=None,
)
parser.add_argument(
"--chat-template-args",
type=json.loads,
help="""A JSON formatted string of arguments for the tokenizer's
apply_chat_template, e.g. '{"enable_thinking":false}'""",
default="{}",
)
parser.add_argument(
"--confirm-run-unsafe-code",
action="store_true",
help="Confirm that you want to run tasks that execute untrusted code.",
default=False,
)
parser.add_argument(
"--trust-remote-code",
action="store_true",
help="Enable trusting remote code for tokenizer",
)
parser.add_argument("--temp", type=float, default=0.0, help="Sampling temperature")
parser.add_argument("--top-p", type=float, default=1.0, help="Sampling top-p")
parser.add_argument("--top-k", type=int, default=0, help="Sampling top-k")
args = parser.parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Silence tokenizer warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
mx.random.seed(args.seed)
# Initialize the communication if in distributed mode
world = mx.distributed.init()
mx.eval(mx.distributed.all_sum(1, stream=mx.cpu))
if world.size() > 1 and world.rank() == 0:
print(f"Evaluating with {world.size()} nodes")
sampler = make_sampler(
temp=args.temp,
top_p=args.top_p,
top_k=args.top_k,
)
lm = MLXLM(
args.model,
max_tokens=args.max_tokens,
use_chat_template=args.apply_chat_template,
trust_remote_code=args.trust_remote_code,
sampler=sampler,
)
MLXLM.apply_chat_template = chat_template_fn(**args.chat_template_args)
results = lm_eval.simple_evaluate(
model=lm,
tasks=args.tasks,
fewshot_as_multiturn=args.fewshot_as_multiturn,
apply_chat_template=lm.use_chat_template,
num_fewshot=args.num_shots,
limit=args.limit,
random_seed=args.seed,
numpy_random_seed=args.seed,
torch_random_seed=args.seed,
fewshot_random_seed=args.seed,
confirm_run_unsafe_code=args.confirm_run_unsafe_code,
)
file_keys = ["eval", args.model.replace("/", "_"), version("lm_eval")]
if args.num_shots is not None:
file_keys += [f"{args.num_shots:02d}"]
file_keys += args.tasks
filename = "_".join(file_keys)
if world.rank() == 0:
output_path = output_dir / filename
output_path.write_text(json.dumps(results["results"], indent=4))
print("Results:")
for result in results["results"].values():
print(json.dumps(result, indent=4))