Add heldout HF evaluation workflow

This commit is contained in:
yi_lu
2026-07-09 08:39:02 +08:00
parent e1e880cadf
commit 07e108388d
4 changed files with 400 additions and 8 deletions

View File

@@ -289,6 +289,40 @@ runs/pretrain_8192_8gpu_dp8_mbs14_full_recompute_weighted_heldoutval_resume10000
Do not commit TensorBoard logs. Do not commit TensorBoard logs.
## HF Heldout 2.8k Evaluation
After exporting a checkpoint to HF format, run:
```bash
python3 tools/hf_laoyao_moe/eval_heldout_2p8k.py \
--model-dir runs/hf_exports/iter_0107500 \
--data dataset/val/data/heldout_2p8k_sft_prompt_completion.jsonl \
--out-dir runs/hf_eval/heldout_2p8k/iter_0107500 \
--device cuda \
--max-length 2048
```
For CPU smoke tests, use a very small sample count:
```bash
python3 tools/hf_laoyao_moe/eval_heldout_2p8k.py \
--model-dir runs/hf_exports/iter_0107500 \
--max-items 4 \
--device cpu \
--dtype float32 \
--max-length 512
```
This evaluation follows the tokenizer-swap heldout logic:
- Compute prompt-conditioned completion NLL/PPL for all 2.8k examples.
- Parse MCQ choices only where the prompt has explicit `A.`/`B.`/... choices and the gold completion starts with the answer label.
- Score each MCQ choice as a continuation.
- Main accuracy is `mcq_acc_avg_norm`, based on average token logprob.
- `mcq_acc_sum` is auxiliary and can favor shorter choices.
Do not treat all 2.8k examples as MCQ. The set also includes GSM8K, HumanEval/MBPP style code, Chinese dialogue, and English dialogue examples.
## Model Weight Exporting ## Model Weight Exporting
The HF export tools are under: The HF export tools are under:

View File

@@ -2,14 +2,23 @@
这个 repo 用来把 Jiayi/Laoyao 的 2B MoE 小模型实验重构成更可复现的预训练项目:数据配比更合理,模型定义迁移到 NeMo/Megatron 风格配置,训练入口从手写 PyTorch 逻辑迁移到 Megatron/Nemo 体系。 这个 repo 用来把 Jiayi/Laoyao 的 2B MoE 小模型实验重构成更可复现的预训练项目:数据配比更合理,模型定义迁移到 NeMo/Megatron 风格配置,训练入口从手写 PyTorch 逻辑迁移到 Megatron/Nemo 体系。
当前目标不是继续沿用旧的手写 trainer而是把数据、模型、训练三个边界拆开: 当前目标不是继续沿用旧的手写 trainer而是把数据、模型、训练、评测和导出几个边界拆开,形成一个可以反复运行和审计的 ML project。
- `dataset/`: 预训练数据和验证集的构建、manifest、数据落盘位置。 ## Repo 结构
- `model/`: 2B MoE 架构定义,优先用 NeMo/Megatron 配置表达。
- `training/`: 训练 recipe、评估 recipe、并行和优化超参。 | 路径 | 功能 |
- `scripts/`: 数据下载、tokenization、训练恢复、Megatron 推理服务和 HF 导出 smoke 的 shell 入口。 |---|---|
- `tools/`: checkpoint 检查、Megatron DCP 探测、Megatron-to-HF 导出和 HF custom model 推理工具。 | `dataset/pretrain/` | 200B 级预训练数据的下载、清洗、配比、manifest 和 parquet -> Megatron indexed dataset 转换。 |
- `docs/`: 训练/导出过程中沉淀的问题记录。 | `dataset/val/` | heldout 2.8k 验证集源数据和 Megatron validation 数据说明。 |
| `model/` | 2B MoE 架构定义和 Megatron/NeMo 配置说明。 |
| `training/` | Megatron-Bridge 训练 recipe、模型构建参数、数据 blend、validation、并行和优化配置。 |
| `docker/` | NeMo/Megatron 训练镜像,当前使用 flash-attn4 修复后的镜像。 |
| `scripts/` | 数据下载、tokenization、镜像构建、训练启动/恢复、Megatron inference server、查询 smoke。 |
| `tools/` | checkpoint 探测、DCP 元数据检查、Megatron -> HF 导出、HF custom model 生成和 heldout eval。 |
| `docs/` | 训练、推理、导出过程中遇到的问题和修复记录。 |
| `runs/` | checkpoint、TensorBoard、HF export、eval 输出等运行产物;默认不进 git。 |
给 agent 的更详细操作手册见根目录 `AGENTS.md`
## 当前数据计划 ## 当前数据计划
@@ -27,6 +36,35 @@
验证集为 2,800 条,七个能力维度各 400 条science_reasoning、logic、code、chinese_exam、math、chinese_dialogue、english_dialogue。它会用于预训练过程中的 accuracy、perplexity 和 nll 评估。 验证集为 2,800 条,七个能力维度各 400 条science_reasoning、logic、code、chinese_exam、math、chinese_dialogue、english_dialogue。它会用于预训练过程中的 accuracy、perplexity 和 nll 评估。
## 当前主训练配置
g0050 上当前主训练入口:
```bash
cd /ssd/workspace/yi/laoyao_2b_moe
bash scripts/resume_pretrain_8192_8gpu_mbs14.sh
```
核心配置:
- Megatron-Bridge / Megatron-Core backend。
- Docker image: `laoyao/nemo-megatron:26.06-flashattn4`
- `seq_length=8192`
- `micro_batch_size=14`
- `global_batch_size=112`
- `DP=8, TP=1, PP=1, EP=1, CP=1`
- distributed optimizer 开启。
- grad reduce / param gather overlap 开启。
- full activation recompute: `uniform`, `recompute_num_layers=1`
-`2500` iter 保存 checkpoint保留最近 `10` 个。
-`15000` iter 做 heldout validation`eval_iters=10`
当前 run 名称:
```text
pretrain_8192_8gpu_dp8_mbs14_full_recompute_weighted_heldoutval_resume10000
```
## Tokenizer 设计 ## Tokenizer 设计
@@ -81,3 +119,34 @@ repo 路径:
``` ```
数据不再复制到 repo 内;训练直接读取上面的源输出目录。`dataset/pretrain/data/` 仍被 `.gitignore` 忽略,仅用于小规模临时样本。 数据不再复制到 repo 内;训练直接读取上面的源输出目录。`dataset/pretrain/data/` 仍被 `.gitignore` 忽略,仅用于小规模临时样本。
## 模型导出与 heldout 评测
Megatron DCP checkpoint 可导出为 HuggingFace custom model
```bash
python3 tools/hf_laoyao_moe/convert_laoyao_dcp_to_hf.py \
--checkpoint-dir runs/pretrain_8192_8gpu_dp8_mbs14_full_recompute_weighted_heldoutval_resume10000/checkpoints/iter_0107500 \
--tokenizer-dir tokenizer/glm5.2 \
--output-dir runs/hf_exports/iter_0107500
```
HF custom model 当前没有实现 KV cache因此生成和评测脚本均显式 `use_cache=False`
heldout 2.8k HF 评测入口:
```bash
python3 tools/hf_laoyao_moe/eval_heldout_2p8k.py \
--model-dir runs/hf_exports/iter_0107500 \
--data dataset/val/data/heldout_2p8k_sft_prompt_completion.jsonl \
--out-dir runs/hf_eval/heldout_2p8k/iter_0107500 \
--device cuda \
--max-length 2048
```
评测逻辑沿用 tokenizer-swap 实验的口径:
- 所有样本计算 prompt-conditioned completion NLL/PPL、NLL/token、bits/byte。
- 对能解析出候选项的 MCQ 样本,按每个候选 continuation 的 logprob 打分。
- 主 accuracy 是平均 token logprob 归一化后的 `mcq_acc_avg_norm`
- `mcq_acc_sum` 作为辅助指标,因为它更容易偏向短选项。

View File

@@ -32,6 +32,37 @@ python3 tools/hf_laoyao_moe/generate_laoyao_hf.py \
当前 HF custom model 没有实现 KV cache因此生成脚本强制 `use_cache=False`。不要删除这个设置;否则 Transformers 默认 cache path 会导致上下文丢失和异常重复。 当前 HF custom model 没有实现 KV cache因此生成脚本强制 `use_cache=False`。不要删除这个设置;否则 Transformers 默认 cache path 会导致上下文丢失和异常重复。
## Heldout 2.8k 评测
`eval_heldout_2p8k.py` 复用 tokenizer-swap 实验的评测口径:
- 对所有样本计算 prompt-conditioned completion NLL/PPL。
- 对能从 prompt 中解析出 A/B/C/D 候选项、且 gold completion 以候选 label 开头的样本计算 MCQ accuracy。
- 主 accuracy 是候选 continuation 的平均 token logprob accuracy`mcq_acc_avg_norm`
- `mcq_acc_sum` 是总 logprob accuracy容易偏好短答案只作辅助指标。
示例:
```bash
python3 tools/hf_laoyao_moe/eval_heldout_2p8k.py \
--model-dir runs/hf_exports/iter_0107500 \
--data dataset/val/data/heldout_2p8k_sft_prompt_completion.jsonl \
--out-dir runs/hf_eval/heldout_2p8k/iter_0107500 \
--device cuda \
--max-length 2048
```
CPU smoke 可以限制样本数:
```bash
python3 tools/hf_laoyao_moe/eval_heldout_2p8k.py \
--model-dir runs/hf_exports/iter_0107500 \
--max-items 4 \
--device cpu \
--dtype float32 \
--max-length 512
```
## 调试 ## 调试
打印逐 token 结果: 打印逐 token 结果:
@@ -66,4 +97,3 @@ python3 tools/hf_laoyao_moe/generate_laoyao_hf.py \
- tied embedding/output head - tied embedding/output head
- MoE top-k router with `moe_expert_capacity_factor=1.25` - MoE top-k router with `moe_expert_capacity_factor=1.25`
- `use_cache=false` - `use_cache=false`

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer
CHOICE_RE = re.compile(r"(?m)^\s*([A-Z])\s*[\.|\)]\s+(.+?)(?=\n\s*[A-Z]\s*[\.|\)]\s+|\Z)", re.DOTALL)
ANSWER_LABEL_RE = re.compile(r"^\s*([A-Z])\s*[\.|\)]")
def load_jsonl(path: Path, max_items: int = 0, num_shards: int = 1, shard_id: int = 0) -> list[dict[str, Any]]:
rows = []
with path.open("r", encoding="utf-8") as handle:
for idx, line in enumerate(handle):
if not line.strip():
continue
if num_shards > 1 and idx % num_shards != shard_id:
continue
rows.append(json.loads(line))
if max_items and len(rows) >= max_items:
break
return rows
def encode(tokenizer, text: str) -> list[int]:
return tokenizer.encode(text, add_special_tokens=False)
@torch.inference_mode()
def continuation_nll(model, tokenizer, prompt: str, continuation: str, max_length: int) -> dict[str, float] | None:
prompt_ids = encode(tokenizer, prompt)
cont_ids = encode(tokenizer, continuation)
if not cont_ids:
return None
if len(cont_ids) >= max_length:
cont_ids = cont_ids[: max_length - 1]
prompt_ids = []
else:
prompt_budget = max_length - len(cont_ids)
prompt_ids = prompt_ids[-prompt_budget:]
ids = prompt_ids + cont_ids
labels = [-100] * len(prompt_ids) + cont_ids
if len(ids) < 2:
return None
input_ids = torch.tensor([ids], device=model.device, dtype=torch.long)
label_ids = torch.tensor([labels], device=model.device, dtype=torch.long)
logits = model(input_ids=input_ids, use_cache=False).logits
shift_logits = logits[:, :-1, :].contiguous()
shift_labels = label_ids[:, 1:].contiguous()
mask = shift_labels.ne(-100)
if not mask.any():
return None
log_probs = F.log_softmax(shift_logits.float(), dim=-1)
safe_labels = shift_labels.masked_fill(~mask, 0)
token_log_probs = log_probs.gather(-1, safe_labels.unsqueeze(-1)).squeeze(-1)
nll = -token_log_probs[mask].sum().item()
tokens = int(mask.sum().item())
return {"nll": nll, "tokens": tokens}
def score_completion(model, tokenizer, prompt: str, completion: str, max_length: int) -> dict[str, float] | None:
ret = continuation_nll(model, tokenizer, prompt, completion, max_length)
if ret is None:
return None
byte_len = max(1, len(completion.encode("utf-8")))
ret["nll_per_token"] = ret["nll"] / max(1, ret["tokens"])
ret["ppl"] = math.exp(min(50.0, ret["nll_per_token"]))
ret["nll_per_byte"] = ret["nll"] / byte_len
ret["bits_per_byte"] = ret["nll"] / (byte_len * math.log(2))
ret["bytes"] = byte_len
return ret
def parse_mcq(prompt: str, completion: str) -> dict[str, Any] | None:
matches = list(CHOICE_RE.finditer(prompt))
if len(matches) < 2:
return None
labels = [m.group(1) for m in matches]
choices = [f"{m.group(1)}. {m.group(2).strip()}" for m in matches]
answer_match = ANSWER_LABEL_RE.match(completion)
if answer_match is None:
return None
answer_label = answer_match.group(1)
if answer_label not in labels:
return None
first_choice_start = matches[0].start()
mcq_prompt = prompt[:first_choice_start].rstrip()
if not mcq_prompt:
mcq_prompt = prompt.rstrip()
return {
"mcq_prompt": mcq_prompt,
"labels": labels,
"choices": choices,
"answer_idx": labels.index(answer_label),
}
def score_mcq(model, tokenizer, prompt: str, choices: list[str], max_length: int) -> tuple[list[dict[str, float]], int, int]:
scores = []
for choice in choices:
sep = "" if prompt.endswith((" ", "\n")) else "\n"
ret = continuation_nll(model, tokenizer, prompt + sep, choice, max_length)
if ret is None:
scores.append({"sum_logprob": -float("inf"), "avg_logprob": -float("inf"), "tokens": 0})
continue
sum_logprob = -ret["nll"]
avg_logprob = sum_logprob / max(1, ret["tokens"])
scores.append({"sum_logprob": sum_logprob, "avg_logprob": avg_logprob, "tokens": ret["tokens"]})
pred_avg = max(range(len(scores)), key=lambda idx: scores[idx]["avg_logprob"])
pred_sum = max(range(len(scores)), key=lambda idx: scores[idx]["sum_logprob"])
return scores, pred_avg, pred_sum
def mean(values: list[float | int | None]) -> float | None:
xs = [float(x) for x in values if x is not None]
return sum(xs) / len(xs) if xs else None
def summarize(results: list[dict[str, Any]]) -> dict[str, Any]:
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in results:
groups[row["capability"]].append(row)
groups["all"] = results
out = {}
for name, rows in sorted(groups.items()):
mcq_rows = [row for row in rows if row.get("mcq") is not None]
ppl_rows = [row for row in rows if row.get("ppl") is not None]
out[name] = {
"n": len(rows),
"mcq_n": len(mcq_rows),
"mcq_acc_avg_norm": mean([row["mcq"]["correct_avg_norm"] for row in mcq_rows]),
"mcq_acc_sum": mean([row["mcq"]["correct_sum"] for row in mcq_rows]),
"ppl_n": len(ppl_rows),
"ppl_token_mean": mean([row["ppl"]["ppl"] for row in ppl_rows]),
"nll_per_token_mean": mean([row["ppl"]["nll_per_token"] for row in ppl_rows]),
"bits_per_byte_mean": mean([row["ppl"]["bits_per_byte"] for row in ppl_rows]),
"completion_tokens_mean": mean([row["ppl"]["tokens"] for row in ppl_rows]),
}
return out
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate Laoyao HF export on the heldout 2.8k validation set.")
parser.add_argument("--model-dir", required=True, help="HF export directory produced by convert_laoyao_dcp_to_hf.py")
parser.add_argument("--data", default="dataset/val/data/heldout_2p8k_sft_prompt_completion.jsonl")
parser.add_argument("--out-dir", default="runs/hf_eval/heldout_2p8k")
parser.add_argument("--model-label", default=None)
parser.add_argument("--max-items", type=int, default=0)
parser.add_argument("--max-length", type=int, default=2048)
parser.add_argument("--num-shards", type=int, default=1)
parser.add_argument("--shard-id", type=int, default=0)
parser.add_argument("--device", default="cuda")
parser.add_argument("--dtype", choices=["auto", "float16", "bfloat16", "float32"], default="bfloat16")
parser.add_argument("--fix-mistral-regex", action="store_true")
args = parser.parse_args()
dtype = {
"auto": "auto",
"float16": torch.float16,
"bfloat16": torch.bfloat16,
"float32": torch.float32,
}[args.dtype]
tokenizer = AutoTokenizer.from_pretrained(
args.model_dir,
trust_remote_code=True,
fix_mistral_regex=args.fix_mistral_regex,
)
model = AutoModelForCausalLM.from_pretrained(
args.model_dir,
trust_remote_code=True,
torch_dtype=dtype,
)
model.to(args.device)
model.eval()
if hasattr(model.config, "use_cache"):
model.config.use_cache = False
rows = load_jsonl(Path(args.data), args.max_items, args.num_shards, args.shard_id)
label = args.model_label or Path(args.model_dir).name
out_label = label
if args.num_shards > 1:
if args.shard_id < 0 or args.shard_id >= args.num_shards:
raise ValueError("--shard-id must be in [0, --num-shards)")
out_label = f"{label}.shard{args.shard_id:02d}of{args.num_shards:02d}"
results = []
for idx, row in enumerate(rows, 1):
prompt = row["prompt"]
completion = row["completion"]
ppl = score_completion(model, tokenizer, prompt, completion, args.max_length)
mcq_result = None
mcq = parse_mcq(prompt, completion)
if mcq is not None:
scores, pred_avg, pred_sum = score_mcq(model, tokenizer, mcq["mcq_prompt"], mcq["choices"], args.max_length)
mcq_result = {
"answer_idx": mcq["answer_idx"],
"labels": mcq["labels"],
"pred_avg_norm": pred_avg,
"pred_sum": pred_sum,
"correct_avg_norm": int(pred_avg == mcq["answer_idx"]),
"correct_sum": int(pred_sum == mcq["answer_idx"]),
"scores": scores,
}
results.append(
{
"id": row.get("hashes", {}).get("pair_sha256") or f"row_{idx}",
"capability": row.get("capability", "unknown"),
"source_id": row.get("source_id", ""),
"split": row.get("split", ""),
"ppl": ppl,
"mcq": mcq_result,
}
)
if idx % 100 == 0:
print(f"[{label}] evaluated {idx}/{len(rows)}", flush=True)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
summary = {
"model_dir": args.model_dir,
"model_label": label,
"data": args.data,
"max_items": args.max_items,
"max_length": args.max_length,
"num_shards": args.num_shards,
"shard_id": args.shard_id,
"summary": summarize(results),
}
with (out_dir / f"{out_label}.per_item.jsonl").open("w", encoding="utf-8") as handle:
for item in results:
handle.write(json.dumps(item, ensure_ascii=False) + "\n")
with (out_dir / f"{out_label}.summary.json").open("w", encoding="utf-8") as handle:
json.dump(summary, handle, ensure_ascii=False, indent=2)
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()