Download source/scripts/benchmarks/evaluate_external_lm.py from Voltline/vimeml-tiny-ja-v4.2: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/Voltline/vimeml-tiny-ja-v4.2/resolve/main/source/scripts/benchmarks/evaluate_external_lm.py
- Command line
-
hf download hf://Voltline/vimeml-tiny-ja-v4.2/source/scripts/benchmarks/evaluate_external_lm.py
-
curl -L -o evaluate_external_lm.py https://huggingface.co/Voltline/vimeml-tiny-ja-v4.2/resolve/main/source/scripts/benchmarks/evaluate_external_lm.py
13.9 kB
| """Evaluate a pinned Japanese external LM on the four frozen nonblind pools. | |
| Raw joint-tokenized suffix likelihood, without instructions or answer prompts. | |
| The vocabulary head is applied only at scored positions to fit an 8 GiB GPU. | |
| """ | |
| import argparse | |
| import json | |
| import math | |
| from pathlib import Path | |
| import sys | |
| import time | |
| ROOT = Path(__file__).resolve().parents[2] | |
| # Optional packages are isolated from the existing training environment. | |
| OPTIONAL = ROOT / 'artifacts/external-eval/site-packages' | |
| if OPTIONAL.is_dir(): | |
| sys.path.insert(0, str(OPTIONAL)) | |
| sys.path.insert(0, str(ROOT / 'src')) | |
| import torch | |
| from torch.nn import functional as F | |
| from vimeml.training.data import file_sha, write_json | |
| from vimeml.training.evaluate_ime import common_prefix_length | |
| from vimeml.benchmarks.evaluate_ajimee import eligibility, metrics, summarize | |
| from vimeml.benchmarks.comparison import paired, rows as read_rows | |
| from vimeml.benchmarks.standard_ime import source_metrics | |
| from vimeml.posttraining.infer import TRACKS, read_snapshot | |
| MODELS = { | |
| 'tinyswallow': { | |
| 'id': 'SakanaAI/TinySwallow-1.5B', | |
| 'revision': '535d1322615c9f8171c3d60b10ef6955cc896fe9', | |
| 'directory': 'artifacts/models/external/TinySwallow-1.5B', | |
| 'output': 'outputs/evaluation-tinyswallow-20261016', | |
| }, | |
| 'rakuten-instruct': { | |
| 'id': 'Rakuten/RakutenAI-2.0-mini-instruct', | |
| 'revision': '6d902489587d324b7d5e201299e4e1a169f3a40b', | |
| 'directory': 'artifacts/models/external/RakutenAI-2.0-mini-instruct', | |
| 'output': 'outputs/evaluation-rakuten-instruct-20261016', | |
| }, | |
| 'sarashina': { | |
| 'id': 'sbintuitions/sarashina2.2-1b', | |
| 'revision': '3bb836ad7475ba192926be66651e4730825df7da', | |
| 'directory': 'artifacts/models/external/sarashina2.2-1b', | |
| 'output': 'outputs/evaluation-sarashina-20261016', | |
| }, | |
| } | |
| class TokenizerAdapter: | |
| def __init__(self, tokenizer): | |
| self.tokenizer = tokenizer | |
| def encode(self, text, out_type=int): | |
| if out_type is not int: | |
| raise ValueError('Integer token IDs required') | |
| return self.tokenizer.encode(text, add_special_tokens=False) | |
| def decode(self, ids): | |
| return self.tokenizer.decode(ids, skip_special_tokens=False, clean_up_tokenization_spaces=False) | |
| def id_to_piece(self, ident): | |
| return self.tokenizer.convert_ids_to_tokens(ident) | |
| class ExternalLM: | |
| def __init__(self, model, tokenizer, candidate_batch=8, head_chunk=128): | |
| self.model = model.eval() | |
| self.processor = TokenizerAdapter(tokenizer) | |
| self.device = next(model.parameters()).device | |
| self.bos = model.config.bos_token_id | |
| if type(self.bos) is not int or candidate_batch < 1 or head_chunk < 1: | |
| raise ValueError('Native BOS and positive evaluation batches required') | |
| self.candidate_batch, self.head_chunk = candidate_batch, head_chunk | |
| self.pad = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else self.bos | |
| self.model.config.context_length = 128 | |
| def score(self, context, candidates): | |
| if not candidates or any(not c for c in candidates) or len(set(candidates)) != len(candidates): | |
| raise ValueError('Distinct nonempty candidates required') | |
| context_ids = [self.bos, *self.processor.encode(context)] | |
| sequences = [[self.bos, *self.processor.encode(context + c)] for c in candidates] | |
| common = common_prefix_length([context_ids, *sequences]) | |
| if common < 1 or any(not common < len(s) <= 129 for s in sequences): | |
| raise ValueError('Invalid suffix or context overflow; no truncation') | |
| result = [] | |
| for start in range(0, len(sequences), self.candidate_batch): | |
| group = sequences[start:start + self.candidate_batch] | |
| width = max(len(s) - 1 for s in group) | |
| ids = torch.full((len(group), width), self.pad, dtype=torch.long, device=self.device) | |
| attention = torch.zeros_like(ids) | |
| row_ids, positions, targets, counts = [], [], [], [] | |
| for row, seq in enumerate(group): | |
| ids[row, :len(seq) - 1] = torch.tensor(seq[:-1], device=self.device) | |
| attention[row, :len(seq) - 1] = 1 | |
| suffix = seq[common:] | |
| counts.append(len(suffix)) | |
| row_ids.extend([row] * len(suffix)) | |
| positions.extend(range(common - 1, len(seq) - 1)) | |
| targets.extend(suffix) | |
| hidden = self.model.model(input_ids=ids, attention_mask=attention, | |
| use_cache=False, return_dict=True).last_hidden_state | |
| selected = hidden[torch.tensor(row_ids, device=self.device), | |
| torch.tensor(positions, device=self.device)] | |
| targets = torch.tensor(targets, device=self.device) | |
| values = [] | |
| for offset in range(0, len(selected), self.head_chunk): | |
| logits = self.model.lm_head(selected[offset:offset + self.head_chunk]) | |
| logp = -F.cross_entropy(logits.float(), targets[offset:offset + self.head_chunk], reduction='none') | |
| if not bool(torch.isfinite(logp).all()): | |
| raise FloatingPointError('Non-finite full-vocabulary candidate probability') | |
| values.extend(logp.cpu().tolist()) | |
| offset = 0 | |
| for local, (seq, count) in enumerate(zip(group, counts, strict=True)): | |
| token_logp = values[offset:offset + count] | |
| offset += count | |
| suffix = seq[common:] | |
| result.append({'text': candidates[start + local], | |
| 'log_probability_sum': sum(token_logp), | |
| 'log_probability_mean': sum(token_logp) / count, | |
| 'scored_tokens': count, 'token_ids': suffix, | |
| 'pieces': [self.processor.id_to_piece(t) for t in suffix], | |
| 'token_log_probabilities': token_logp, | |
| 'boundary_retokenized': seq[:len(context_ids)] != context_ids}) | |
| return {'common_prefix_tokens_including_bos': common, | |
| 'context_tokens_including_bos': len(context_ids), 'candidates': result} | |
| def rerank_external(lm, rows, output, track): | |
| for position, row in enumerate(rows): | |
| candidates = row['orders']['azookey'] | |
| row['lm_scores'] = {} | |
| for field, context, methods in ( | |
| ('contextual', row['left_context'], ('lm_context_sum', 'lm_context_mean_secondary')), | |
| ('context_free', '', ('lm_no_context_sum',)), | |
| ): | |
| reason = eligibility(lm, context, candidates) | |
| if reason: | |
| for method in methods: | |
| row['orders'][method] = list(candidates) | |
| row['fallbacks'][method] = reason | |
| continue | |
| # An empty original context gives exactly the same second score. | |
| scored = row['lm_scores'].get('contextual') if field == 'context_free' and not row['left_context'] else None | |
| if scored is None: | |
| scored = lm.score(context, candidates) | |
| row['lm_scores'][field] = scored | |
| for method in methods: | |
| key = 'log_probability_mean' if method.endswith('secondary') else 'log_probability_sum' | |
| row['orders'][method] = [x['text'] for x in sorted(scored['candidates'], key=lambda x: x[key], reverse=True)] | |
| if (position + 1) % 25 == 0 or position + 1 == len(rows): | |
| write_json(output / 'progress.json', {'status': 'running', 'track': track, | |
| 'completed_cases': position + 1, 'total_cases': len(rows)}) | |
| print(f'{track}: {position + 1}/{len(rows)}', flush=True) | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument('--model', choices=tuple(MODELS), default='tinyswallow') | |
| parser.add_argument('--output', type=Path) | |
| args = parser.parse_args() | |
| selected = MODELS[args.model] | |
| model_id, revision = selected['id'], selected['revision'] | |
| model_dir = ROOT / selected['directory'] | |
| output = (args.output or ROOT / selected['output']).resolve() | |
| if (output / 'plan.json').exists(): | |
| parser.error('An evaluation plan already exists; preserve this run and choose a new output') | |
| output.mkdir(parents=True, exist_ok=True) | |
| torch.set_num_threads(4) | |
| torch.backends.cuda.matmul.allow_tf32 = False | |
| torch.backends.cudnn.allow_tf32 = False | |
| if not torch.cuda.is_available() or not torch.cuda.is_bf16_supported(): | |
| raise ValueError('This frozen external evaluation requires a BF16 CUDA GPU') | |
| source = json.loads((ROOT / selected['output'] / 'model-source.json').read_text('utf-8')) | |
| if source['model_id'] != model_id or source['revision'] != revision: | |
| raise ValueError('Pinned official model source changed') | |
| files = {name: file_sha(model_dir / name) for name in source['files'] if name != '.gitattributes'} | |
| frozen = {track: read_snapshot(track) for track in TRACKS} | |
| inputs = {track: {'cases': len(rows), 'sha256': file_sha(ROOT / f'outputs/evaluation-v4-20261015/inputs/{track}.json')} | |
| for track, rows in frozen.items()} | |
| baseline_paths = {'v21': ROOT / 'outputs/evaluation-v4-20261015/v21', | |
| 'v4b': ROOT / 'outputs/evaluation-v4-b12cosine-20261015/best'} | |
| if args.model != 'tinyswallow': | |
| baseline_paths['tinyswallow'] = ROOT / MODELS['tinyswallow']['output'] | |
| baseline_hashes = {name: {track: file_sha(folder / track / 'scores.jsonl') for track in TRACKS} | |
| for name, folder in baseline_paths.items()} | |
| native = json.loads((model_dir / 'config.json').read_text('utf-8')) | |
| plan = {'model_id': model_id, 'revision': revision, 'model_files_sha256': files, | |
| 'tracks': inputs, 'baseline_score_sha256': baseline_hashes, | |
| 'precision': 'BF16 weights/forward; FP32 full-vocabulary CE; no quantization', | |
| 'scoring': 'raw context+candidate joint tokenization; common-prefix suffix logP sum; native BOS; no final EOS', | |
| 'primary': 'lm_context_sum', 'secondary': ['lm_context_mean_secondary', 'lm_no_context_sum'], | |
| 'context_limit': 128, 'native_context_limit': native['max_position_embeddings'], 'candidate_batch': 8, 'head_chunk': 128, | |
| 'instructions': False, 'chat_template': False, 'reading_conditioning': False, | |
| 'stable_ties': True, 'overflow': 'whole-case AzooKey fallback; no truncation; full denominator', | |
| 'training': False, 'blind_read': False, 'test_split_read': False, | |
| 'training_overlap': 'External pretraining/distillation overlap with public benchmark text is unknown', | |
| 'statistics': 'Paired exact McNemar descriptive results; four correlated exploratory tracks, no multiplicity-adjusted claim', | |
| 'script_sha256': file_sha(Path(__file__)), 'torch': str(torch.__version__)} | |
| write_json(output / 'plan.json', plan) | |
| (output / 'evaluation-script.py').write_bytes(Path(__file__).read_bytes()) | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| import transformers | |
| began = time.perf_counter() | |
| tokenizer = AutoTokenizer.from_pretrained(model_dir, local_files_only=True, trust_remote_code=False) | |
| model = AutoModelForCausalLM.from_pretrained(model_dir, local_files_only=True, | |
| trust_remote_code=False, use_safetensors=True, dtype=torch.bfloat16, | |
| attn_implementation='sdpa').to('cuda').eval() | |
| lm = ExternalLM(model, tokenizer) | |
| torch.cuda.synchronize() | |
| metadata = {'model_id': model_id, 'revision': revision, 'parameters': sum(p.numel() for p in model.parameters()), | |
| 'vocab_size': model.config.vocab_size, 'native_bos': lm.bos, | |
| 'gpu': torch.cuda.get_device_name(), 'torch': str(torch.__version__), 'transformers': transformers.__version__, | |
| 'precision': plan['precision'], 'model_loading_seconds': time.perf_counter() - began, | |
| 'weights_allocated_MiB': torch.cuda.memory_allocated() / 2**20} | |
| write_json(output / 'environment.json', metadata) | |
| reports = {} | |
| for track, rows in frozen.items(): | |
| started = time.perf_counter() | |
| rerank_external(lm, rows, output, track) | |
| destination = output / track | |
| destination.mkdir(parents=True, exist_ok=True) | |
| scores = destination / 'scores.jsonl' | |
| scores.write_text(''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in rows), encoding='utf-8') | |
| report = {'status': 'complete', 'model': metadata, 'input': inputs[track], | |
| 'metrics': summarize(rows), 'elapsed_seconds': time.perf_counter() - started, | |
| 'scores_sha256': file_sha(scores)} | |
| if 'source' in rows[0]: | |
| report['source_metrics'] = source_metrics(rows, metrics) | |
| for name, folder in baseline_paths.items(): | |
| comparison = paired(read_rows(folder / track / 'scores.jsonl'), rows) | |
| comparison['paired'].pop('significant_at_0_05', None) | |
| write_json(destination / f'paired-{name}.json', comparison) | |
| write_json(destination / 'metrics.json', report) | |
| reports[track] = report['metrics']['all'] | |
| print(track, 'TOP1', reports[track]['lm_context_sum']['top1_correct'], '/', len(rows), flush=True) | |
| result = {'status': 'complete', 'model': metadata, 'tracks': reports, | |
| 'peak_allocated_MiB': torch.cuda.max_memory_allocated() / 2**20, | |
| 'peak_reserved_MiB': torch.cuda.max_memory_reserved() / 2**20, | |
| 'elapsed_including_load_seconds': time.perf_counter() - began, | |
| 'blind_read': False, 'test_split_read': False} | |
| write_json(output / 'summary.json', result) | |
| write_json(output / 'progress.json', result) | |
| print(json.dumps(result, ensure_ascii=False), flush=True) | |
| if __name__ == '__main__': | |
| main() | |