ESFT

ESFT

阶段一:专家筛选 expert_selection.py

In [ ]:
# -*- coding: utf-8 -*-
"""
ESFT-Token 专家筛选(Qwen3-30B-A3B)
对应论文《Let the Expert Stick to His Last》(Wang et al., 2024):
  - 式(7) Token Selection Ratio: 统计每个专家被 Top-K 选中的 token 比例
  - 式(8) 累积阈值筛选: 按相关度降序累积,首个 >= p 的 Top 专家子集

用法:
    python expert_selection.py \
        --data_path data/gov_corpus.jsonl \
        --threshold_p 0.2 \
        --output selected_experts.json

硬件: 单张 80G 卡即可(bf16 权重约 60G),device_map="auto" 也支持多卡。
"""
import argparse
import json

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer


@torch.no_grad()
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--model_path", default="Qwen/Qwen3-30B-A3B")
    ap.add_argument("--data_path", required=True, help="JSONL,每行含 text 字段")
    ap.add_argument("--num_samples", type=int, default=32, help="论文默认采样 32 段")
    ap.add_argument("--seq_len", type=int, default=4096, help="论文默认 L=4096")
    ap.add_argument("--threshold_p", type=float, default=0.2, help="ESFT-Token 论文取 0.2")
    ap.add_argument("--output", default="selected_experts.json")
    args = ap.parse_args()

    tokenizer = AutoTokenizer.from_pretrained(args.model_path)
    model = AutoModelForCausalLM.from_pretrained(
        args.model_path,
        torch_dtype=torch.bfloat16,
        attn_implementation="flash_attention_2",  # 没装 flash-attn 就改 "sdpa"
        device_map="auto",
    )
    model.eval()

    cfg = model.config
    L, E, K = cfg.num_hidden_layers, cfg.num_experts, cfg.num_experts_per_tok
    # Qwen3-30B-A3B: L=48, E=128, K=8

    # ---------- 1. 构造采样数据:拼接语料后切成 num_samples 段定长序列(与论文一致) ----------
    ds = load_dataset("json", data_files=args.data_path, split="train")
    ids = []
    for t in ds["text"]:
        ids.extend(tokenizer(t, add_special_tokens=False)["input_ids"])
        if len(ids) >= args.num_samples * args.seq_len:
            break
    ids = ids[: args.num_samples * args.seq_len]
    samples = torch.tensor(
        [ids[i * args.seq_len:(i + 1) * args.seq_len] for i in range(args.num_samples)]
    )

    # ---------- 2. 给每层 router(gate) 注册 hook,统计各专家被选中的 token 次数 ----------
    counts = [torch.zeros(E, dtype=torch.long) for _ in range(L)]
    total_tokens = 0

    def make_hook(li):
        def hook(module, inputs, output):
            # output: router_logits, shape (tokens, E)
            # 式(7) 中 1(g>0) 即"进入 Top-K"
            topk_idx = torch.topk(output.float(), K, dim=-1).indices
            counts[li] += torch.bincount(topk_idx.reshape(-1).cpu(), minlength=E)

        return hook

    handles = []
    for i in range(L):
        mlp = model.model.layers[i].mlp
        assert hasattr(mlp, "gate"), f"layer {i} 不是 MoE 层"
        handles.append(mlp.gate.register_forward_hook(make_hook(i)))

    embed_device = model.model.embed_tokens.weight.device
    bs = 1  # 显存宽裕可加大
    for i in range(0, samples.size(0), bs):
        batch = samples[i:i + bs].to(embed_device)
        total_tokens += batch.numel()
        model(input_ids=batch)

    for h in handles:
        h.remove()

    # ---------- 3. 式(7) 归一化 + 式(8) 累积阈值筛选 ----------
    selected, stats = {}, []
    for li in range(L):
        r = counts[li].float() / (total_tokens * K)  # 全层专家求和 = 1
        r_sorted, order = torch.sort(r, descending=True)
        cum = torch.cumsum(r_sorted, dim=0)
        n = int((cum < args.threshold_p).sum().item()) + 1  # 首个累积 >= p 的子集
        selected[str(li)] = sorted(order[:n].tolist())
        stats.append(n)

    result = {
        "model": args.model_path,
        "threshold_p": args.threshold_p,
        "num_samples": args.num_samples,
        "seq_len": args.seq_len,
        "total_tokens": total_tokens,
        "num_experts_per_layer": E,
        "top_k": K,
        "avg_selected": sum(stats) / L,
        "min_selected": min(stats),
        "max_selected": max(stats),
        "selected_experts": selected,
    }
    with open(args.output, "w") as f:
        json.dump(result, f, indent=2, ensure_ascii=False)

    print(f"[Done] 每层选中专家: 平均 {sum(stats)/L:.1f} / {E} "
          f"({sum(stats)/L/E:.1%}), 最少 {min(stats)}, 最多 {max(stats)}")
    print(f"[Done] 已保存 -> {args.output}")


if __name__ == "__main__":
    main()

阶段二:LoRA 训练 train_esft_lora.py

In [ ]:
"""
ESFT + LoRA 训练(Qwen3-30B-A3B, Transformers + PEFT + DeepSpeed + FlashAttention)

- mode=lora(默认): 只对 expert_selection.py 选出的专家注入 LoRA,其余全部参数冻结
- mode=esft: 论文原版做法,选中专家全量更新,不加 LoRA(建议 --lr 1e-5)

启动示例(8 卡 ZeRO-2):
    deepspeed --num_gpus 8 train_esft_lora.py \
        --data_path data/gov_sft.jsonl \
        --selected_experts selected_experts.json \
        --deepspeed ds_zero2.json \
        --output_dir ./ckpt_esft_lora

依赖: transformers>=4.51, peft, datasets, accelerate, deepspeed, flash-attn
"""
import argparse
import json

import torch
from datasets import load_dataset
from peft import LoraConfig, get_peft_model
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    DataCollatorForLanguageModeling,
    Trainer,
    TrainingArguments,
)


def parse_args():
    ap = argparse.ArgumentParser()
    ap.add_argument("--model_path", default="Qwen/Qwen3-30B-A3B")
    ap.add_argument("--data_path", required=True, help="JSONL,每行含 text 字段")
    ap.add_argument("--selected_experts", default="selected_experts.json")
    ap.add_argument("--mode", choices=["lora", "esft"], default="lora")
    ap.add_argument("--seq_len", type=int, default=4096)
    ap.add_argument("--output_dir", default="./ckpt_esft_lora")
    ap.add_argument("--deepspeed", default=None)
    ap.add_argument("--lr", type=float, default=1e-4)
    ap.add_argument("--epochs", type=float, default=2.0)
    ap.add_argument("--micro_bs", type=int, default=1)
    ap.add_argument("--grad_accum", type=int, default=16)
    ap.add_argument("--lora_r", type=int, default=16)
    ap.add_argument("--lora_alpha", type=int, default=32)
    return ap.parse_args()


def main():
    args = parse_args()
    with open(args.selected_experts) as f:
        selected = json.load(f)["selected_experts"]  # {"0": [3, 17, ...], ...}

    tokenizer = AutoTokenizer.from_pretrained(args.model_path)
    model = AutoModelForCausalLM.from_pretrained(
        args.model_path,
        torch_dtype=torch.bfloat16,
        attn_implementation="flash_attention_2",
    )
    model.config.use_cache = False
    model.config.output_router_logits = False  # 不需要 MoE 辅助负载均衡 loss
    model.gradient_checkpointing_enable(
        gradient_checkpointing_kwargs={"use_reentrant": False}
    )

    if args.mode == "lora":
        # ---- 只对选中专家的三个投影注入 LoRA;PEFT 默认冻结其余全部参数 ----
        target_modules = [
            f"layers.{li}.mlp.experts.{eid}.{proj}"
            for li, eids in selected.items()
            for eid in eids
            for proj in ("gate_proj", "up_proj", "down_proj")
        ]
        lora_cfg = LoraConfig(
            r=args.lora_r,
            lora_alpha=args.lora_alpha,
            lora_dropout=0.05,
            bias="none",
            task_type="CAUSAL_LM",
            target_modules=target_modules,
        )
        model = get_peft_model(model, lora_cfg)

        # 校验:注入 LoRA 的 Linear 数必须等于目标模块数,防止 peft 版本匹配逻辑差异
        n_lora = sum(1 for n, _ in model.named_parameters() if "lora_A" in n)
        assert n_lora == len(target_modules), (
            f"LoRA 注入数 {n_lora} != 目标模块数 {len(target_modules)},"
            f"请检查 peft 版本的 target_modules 匹配逻辑"
        )
        model.print_trainable_parameters()
    else:
        # ---- 论文原版 ESFT:全模型冻结,仅解冻选中专家的权重 ----
        for p in model.parameters():
            p.requires_grad_(False)
        n_train = 0
        for li, eids in selected.items():
            for eid in eids:
                expert = model.model.layers[int(li)].mlp.experts[eid]
                for p in expert.parameters():
                    p.requires_grad_(True)
                    n_train += p.numel()
        n_total = sum(p.numel() for p in model.parameters())
        print(f"[ESFT] 可训参数 {n_train/1e9:.2f}B / {n_total/1e9:.1f}B "
              f"({n_train/n_total:.1%})")

    # 梯度检查点 + 冻结嵌入时必须加这行,否则第一层收不到梯度
    model.enable_input_require_grads()

    # ---------- 数据 ----------
    ds = load_dataset("json", data_files=args.data_path, split="train")

    def tokenize(batch):
        return tokenizer(batch["text"], truncation=True, max_length=args.seq_len)

    ds = ds.map(tokenize, batched=True, num_proc=8, remove_columns=ds.column_names)
    collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)

    targs = TrainingArguments(
        output_dir=args.output_dir,
        per_device_train_batch_size=args.micro_bs,
        gradient_accumulation_steps=args.grad_accum,
        num_train_epochs=args.epochs,
        learning_rate=args.lr,
        lr_scheduler_type="cosine",
        warmup_ratio=0.03,
        weight_decay=0.0,
        logging_steps=5,
        save_strategy="steps",
        save_steps=500,
        save_total_limit=3,
        bf16=True,
        optim="adamw_torch",
        deepspeed=args.deepspeed,
        report_to="none",
        seed=42,
    )

    trainer = Trainer(
        model=model,
        args=targs,
        train_dataset=ds,
        data_collator=collator,
    )
    trainer.train()

    trainer.save_model(args.output_dir)      # LoRA 模式下只保存 adapter
    tokenizer.save_pretrained(args.output_dir)


if __name__ == "__main__":
    main()

DeepSpeed 配置 ds_zero2.json

{
  "bf16": { "enabled": true },
  "zero_optimization": {
    "stage": 2,
    "offload_optimizer": { "device": "cpu", "pin_memory": true },
    "allgather_partitions": true,
    "allgather_bucket_size": 2e8,
    "overlap_comm": true,
    "reduce_scatter": true,
    "reduce_bucket_size": 2e8,
    "contiguous_gradients": true
  },
  "gradient_accumulation_steps": "auto",
  "train_micro_batch_size_per_gpu": "auto",
  "train_batch_size": "auto",
  "gradient_clipping": "auto",
  "steps_per_print": 10,
  "wall_clock_breakdown": false
}

运行代码

pip install "transformers>=4.51" peft datasets accelerate deepspeed flash-attn

# 1. 专家筛选(单卡即可,几分钟)
python expert_selection.py --data_path data/gov_corpus.jsonl --threshold_p 0.2

# 2. LoRA 训练(8 卡 ZeRO-2)
deepspeed --num_gpus 8 train_esft_lora.py \
    --data_path data/gov_sft.jsonl \
    --selected_experts selected_experts.json \
    --deepspeed ds_zero2.json
In [ ]:
 
In [ ]:
 

评论(0)

评论加载中…