| |
| """ |
| NeuroFlow 蒸馏训练 — 用教师生成的数据训练学生模型 |
| |
| 流程: |
| 1. 加载教师数据 (JSONL: {"prompt":..., "completion":...} 或纯文本) |
| 2. 加载 NeuroFlow 学生模型 (NF + LM head) |
| 3. 用 Cross-Entropy 训练学生预测教师文本 (滑动窗口多token预测) |
| 4. 保存 checkpoint (LMH1/LMH2 兼容格式) |
| |
| 用法: |
| python3 -u scripts/train_distill.py \ |
| --teacher-data teacher_data.jsonl \ |
| --nf-model checkpoint/model.nfv1 \ |
| --lm-model checkpoint/lm_head.nfv1 \ |
| --tokenizer configs/tokenizer_128k.json \ |
| --output ./distill_output \ |
| --epochs 5 --lr 5e-6 --batch-size 32 \ |
| --train-nf # 可选: 同时训练NF权重 |
| |
| 注意: 必须使用 python3 -u 运行,或在后台运行时设置 PYTHONUNBUFFERED=1 |
| """ |
|
|
| import os |
| os.environ['PYTHONUNBUFFERED'] = '1' |
|
|
| import argparse, json, struct, sys, time, math |
| import numpy as np |
|
|
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| from infer_full import load_nfv1, load_lmh1, load_tokenizer, encode, layernorm, gelu, softmax |
|
|
|
|
| def forward_with_cache(token_ids, nf_w, lm_w, vocab_size=128000): |
| d_model = nf_w['input_proj.weight'].shape[1] |
| hidden_dim = nf_w['input_proj.weight'].shape[0] |
| cache = {} |
|
|
| x = np.zeros(d_model, dtype=np.float32) |
| copy_len = min(len(token_ids), d_model) |
| for j in range(copy_len): |
| x[j] = float(token_ids[j]) / float(vocab_size) |
|
|
| h = nf_w['input_proj.weight'] @ x + nf_w['input_proj.bias'] |
| cache['input_proj.x'] = x.copy() |
| cache['input_proj.pre_norm'] = h.copy() |
| h = layernorm(h, nf_w['input_proj_norm.weight'], nf_w['input_proj_norm.bias']) |
| cache['input_proj.post_norm'] = h.copy() |
| h = gelu(h) |
| cache['input_proj.post_gelu'] = h.copy() |
|
|
| g1 = gelu(nf_w['sn.gate1.weight'] @ h + nf_w['sn.gate1.bias']) |
| gates = softmax(nf_w['sn.gate2.weight'] @ g1 + nf_w['sn.gate2.bias']) |
| cache['gates'] = gates.copy() |
|
|
| h_ecn = h.copy() |
| cache['ecn.h0'] = h_ecn.copy() |
| for i in range(12): |
| h_ecn = gelu(nf_w[f'ecn.dlpfc{i}.weight'] @ h_ecn + nf_w[f'ecn.dlpfc{i}.bias']) |
| cache['ecn.last'] = h_ecn.copy() |
|
|
| vmpfc = gelu(nf_w['ecn.vmpfc1.weight'] @ h_ecn + nf_w['ecn.vmpfc1.bias']) |
| decision = nf_w['ecn.vmpfc2.weight'] @ vmpfc + nf_w['ecn.vmpfc2.bias'] |
| cache['decision'] = decision.copy() |
|
|
| mem_encoded = nf_w['memory.encode.weight'] @ h + nf_w['memory.encode.bias'] |
| cache['mem_encoded'] = mem_encoded.copy() |
|
|
| dmn_enc = gelu(nf_w['dmn.mem_encoder1.weight'] @ mem_encoded + nf_w['dmn.mem_encoder1.bias']) |
| dmn_latent = nf_w['dmn.mem_encoder2.weight'] @ dmn_enc + nf_w['dmn.mem_encoder2.bias'] |
| cache['dmn_latent'] = dmn_latent.copy() |
|
|
| assoc_outs = [] |
| for i in range(8): |
| a1 = gelu(nf_w[f'dmn.head{i}.1.weight'] @ dmn_latent + nf_w[f'dmn.head{i}.1.bias']) |
| a2 = nf_w[f'dmn.head{i}.2.weight'] @ a1 + nf_w[f'dmn.head{i}.2.bias'] |
| assoc_outs.append(a2) |
| dmn_vision = gelu(nf_w['dmn.future_proj1.weight'] @ np.concatenate(assoc_outs) + nf_w['dmn.future_proj1.bias']) |
| cache['dmn_vision'] = dmn_vision.copy() |
|
|
| mem_bank = nf_w['memory.bank'] |
| att = softmax(mem_encoded @ mem_bank.T) |
| retrieved = att @ mem_bank |
| mem_retrieved = nf_w['memory.retrieve.weight'] @ retrieved + nf_w['memory.retrieve.bias'] |
| cache['mem_retrieved'] = mem_retrieved.copy() |
|
|
| ecn_w = decision * gates[0] |
| dmn_w = dmn_vision * gates[1] |
| dmn_w_pad = np.zeros(hidden_dim, dtype=np.float32) |
| dmn_w_pad[:dmn_w.shape[0]] = dmn_w |
| mem_w = np.zeros(hidden_dim, dtype=np.float32) |
| mem_w[:mem_retrieved.shape[0]] = mem_retrieved |
| combined = np.concatenate([ecn_w, dmn_w_pad, mem_w]) |
| cache['combined'] = combined.copy() |
|
|
| fused = nf_w['output_fusion.down.weight'] @ combined + nf_w['output_fusion.down.bias'] |
| fused_pre_relu = layernorm(fused, nf_w['output_fusion.bn_norm.weight'], nf_w['output_fusion.bn_norm.bias']) |
| cache['fusion.pre_relu'] = fused_pre_relu.copy() |
| fused_relu = np.maximum(0, fused_pre_relu) |
| cache['fusion.post_relu'] = fused_relu.copy() |
| nf_output = nf_w['output_fusion.up.weight'] @ fused_relu + nf_w['output_fusion.up.bias'] |
| nf_output = layernorm(nf_output, nf_w['output_fusion.norm.weight'], nf_w['output_fusion.norm.bias']) |
| cache['nf_output'] = nf_output.copy() |
|
|
| bridge_h = lm_w['bridge.weight'] @ nf_output + lm_w['bridge.bias'] |
| cache['bridge_h'] = bridge_h.copy() |
|
|
| projected = lm_w['w_proj.weight'] @ bridge_h + lm_w['w_proj.bias'] |
| cache['projected'] = projected.copy() |
|
|
| logits = lm_w['w_embed'] @ projected |
| cache['logits'] = logits.copy() |
|
|
| return logits, cache |
|
|
|
|
|
|
| def distill_step(token_ids, nf_w, lm_w, lr, vocab_size=128000, grad_clip=4.0, |
| train_nf=False, max_predictions=0): |
| """单步蒸馏训练: 滑动窗口多token预测 + 反向传播 |
| |
| 对序列中每个位置 t,用 token_ids[:t+1] 前向预测 token_ids[t+1]。 |
| NF模型是"序列→单向量"架构,无法像Transformer那样单次前向获取所有位置hidden states, |
| 因此每个位置需要独立前向。max_predictions 限制每样本预测位置数以控制性能开销。 |
| |
| --train-nf 反向传播路径止于 output_fusion.up/down,未穿过 ECN/DMN/Memory/SN gate。 |
| 设计为渐进式解冻: 先训练 LM head + bridge,再解冻 output_fusion,最后解冻更深层。 |
| """ |
| seq_len = len(token_ids) |
| if seq_len < 2: |
| return 0.0 |
|
|
| total_loss = 0.0 |
| accum_lm_grads = {} |
| accum_nf_grads = {} |
| num_preds = 0 |
|
|
| positions = list(range(seq_len - 1)) |
| if max_predictions > 0 and len(positions) > max_predictions: |
| step = max(1, len(positions) // max_predictions) |
| positions = positions[::step][:max_predictions] |
|
|
| for t in positions: |
| prefix = token_ids[:t + 1] |
| target_id = token_ids[t + 1] |
| if target_id >= vocab_size: |
| continue |
|
|
| logits, cache = forward_with_cache(prefix, nf_w, lm_w, vocab_size) |
|
|
| max_val = logits.max() |
| exp_vals = np.exp(logits - max_val) |
| sum_exp = exp_vals.sum() |
| probs = exp_vals / sum_exp |
|
|
| p_target = max(probs[target_id], 1e-10) |
| total_loss += -math.log(p_target) |
| num_preds += 1 |
|
|
| grad_logits = probs.copy() |
| grad_logits[target_id] -= 1.0 |
|
|
| grad_w_embed = np.outer(grad_logits, cache['projected']) |
| grad_projected = lm_w['w_embed'].T @ grad_logits |
|
|
| grad_w_proj_weight = np.outer(grad_projected, cache['bridge_h']) |
| grad_w_proj_bias = grad_projected.copy() |
| grad_bridge_h = lm_w['w_proj.weight'].T @ grad_projected |
|
|
| grad_bridge_weight = np.outer(grad_bridge_h, cache['nf_output']) |
| grad_bridge_bias = grad_bridge_h.copy() |
|
|
| step_lm_grads = { |
| 'w_embed': grad_w_embed, |
| 'w_proj.weight': grad_w_proj_weight, |
| 'w_proj.bias': grad_w_proj_bias, |
| 'bridge.weight': grad_bridge_weight, |
| 'bridge.bias': grad_bridge_bias, |
| } |
|
|
| for name, grad in step_lm_grads.items(): |
| if name not in accum_lm_grads: |
| accum_lm_grads[name] = np.zeros_like(lm_w[name]) |
| accum_lm_grads[name] += grad |
|
|
| if train_nf: |
| grad_nf_output = lm_w['bridge.weight'].T @ grad_bridge_h |
| grad_fused_relu = nf_w['output_fusion.up.weight'].T @ grad_nf_output |
| grad_fused_pre_relu = grad_fused_relu * (cache['fusion.pre_relu'] > 0).astype(np.float32) |
|
|
| step_nf_grads = { |
| 'output_fusion.up.weight': np.outer(grad_nf_output, cache['fusion.post_relu']), |
| 'output_fusion.up.bias': grad_nf_output.copy(), |
| 'output_fusion.down.weight': np.outer(grad_fused_pre_relu, cache['combined']), |
| 'output_fusion.down.bias': grad_fused_pre_relu.copy(), |
| } |
|
|
| for name, grad in step_nf_grads.items(): |
| if name in nf_w and nf_w[name].shape == grad.shape: |
| if name not in accum_nf_grads: |
| accum_nf_grads[name] = np.zeros_like(nf_w[name]) |
| accum_nf_grads[name] += grad |
|
|
| if num_preds == 0: |
| return 0.0 |
|
|
| total_loss /= num_preds |
|
|
| all_grads = {} |
| for name, grad in accum_lm_grads.items(): |
| all_grads[f'lm.{name}'] = grad / num_preds |
| for name, grad in accum_nf_grads.items(): |
| all_grads[f'nf.{name}'] = grad / num_preds |
|
|
| total_norm = 0.0 |
| for g in all_grads.values(): |
| total_norm += np.sum(g ** 2) |
| total_norm = math.sqrt(total_norm) |
|
|
| clip_scale = 1.0 |
| if total_norm > grad_clip and grad_clip > 0: |
| clip_scale = grad_clip / total_norm |
|
|
| effective_lr = lr * clip_scale |
| for name, grad in all_grads.items(): |
| if name.startswith('lm.'): |
| key = name[3:] |
| if key in lm_w and lm_w[key].shape == grad.shape: |
| lm_w[key] -= effective_lr * grad |
| elif name.startswith('nf.'): |
| key = name[3:] |
| if key in nf_w and nf_w[key].shape == grad.shape: |
| nf_w[key] -= effective_lr * grad |
|
|
| return total_loss |
|
|
|
|
| def save_lmh1(path, lm_w): |
| with open(path, 'wb') as f: |
| f.write(b'LMH1') |
| for name, arr in lm_w.items(): |
| name_bytes = name.encode('utf-8') |
| f.write(struct.pack('<I', len(name_bytes))) |
| f.write(name_bytes) |
| f.write(struct.pack('<I', len(arr.shape))) |
| for d in arr.shape: |
| f.write(struct.pack('<I', d)) |
| data = arr.astype(np.float32).tobytes() |
| f.write(struct.pack('<I', len(data))) |
| f.write(data) |
| f.write(struct.pack('<I', 0)) |
|
|
|
|
| def save_nfv1(path, nf_w): |
| with open(path, 'wb') as f: |
| f.write(b'NFv1') |
| for name, arr in nf_w.items(): |
| name_bytes = name.encode('utf-8') |
| f.write(struct.pack('<I', len(name_bytes))) |
| f.write(name_bytes) |
| f.write(struct.pack('<I', len(arr.shape))) |
| for d in arr.shape: |
| f.write(struct.pack('<I', d)) |
| data = arr.astype(np.float32).tobytes() |
| f.write(struct.pack('<I', len(data))) |
| f.write(data) |
| f.write(struct.pack('<I', 0)) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='NeuroFlow Distillation Training') |
| parser.add_argument('--teacher-data', required=True, help='教师数据 (JSONL/TXT)') |
| parser.add_argument('--nf-model', required=True, help='学生 NF 模型路径') |
| parser.add_argument('--lm-model', required=True, help='学生 LM head 路径') |
| parser.add_argument('--tokenizer', required=True, help='分词器路径') |
| parser.add_argument('--output', default='./distill_output', help='输出目录') |
| parser.add_argument('--epochs', type=int, default=5) |
| parser.add_argument('--lr', type=float, default=5e-6) |
| parser.add_argument('--batch-size', type=int, default=32) |
| parser.add_argument('--save-interval', type=int, default=500) |
| parser.add_argument('--grad-clip', type=float, default=4.0) |
| parser.add_argument('--train-nf', action='store_true', help='同时训练NF权重(默认只训练LM head)') |
| parser.add_argument('--resume', default='', help='断点续训: 指定checkpoint目录') |
| parser.add_argument('--max-predictions', type=int, default=8, help='每样本最大预测位置数(0=全部, 默认8)') |
| args = parser.parse_args() |
|
|
| os.makedirs(args.output, exist_ok=True) |
|
|
| print("加载教师数据...") |
| samples = [] |
| with open(args.teacher_data, 'r', encoding='utf-8') as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| rec = json.loads(line) |
| text = rec.get('prompt', '') + rec.get('completion', '') |
| except json.JSONDecodeError: |
| text = line |
| if len(text) >= 10: |
| samples.append(text) |
| print(f" {len(samples)} 个样本") |
|
|
| print("加载分词器...") |
| vocab, id2token, merge_ranks = load_tokenizer(args.tokenizer) |
| print(f" 词表: {len(vocab)} tokens") |
|
|
| print("分词...") |
| tokenized = [] |
| total_tokens = 0 |
| t0 = time.time() |
| progress_interval = max(1, len(samples) // 20) |
| for si, text in enumerate(samples): |
| ids = encode(text, vocab, merge_ranks, max_len=128) |
| if len(ids) >= 4: |
| tokenized.append(ids) |
| total_tokens += len(ids) |
| if (si + 1) % progress_interval == 0 or si == len(samples) - 1: |
| elapsed = time.time() - t0 |
| pct = (si + 1) * 100 // len(samples) |
| rate = (si + 1) / max(elapsed, 0.01) |
| eta = (len(samples) - si - 1) / max(rate, 0.01) |
| print(f" 分词进度: {si+1}/{len(samples)} ({pct}%) | " |
| f"{rate:.0f} samples/s | ETA {eta:.0f}s | " |
| f"tokens={total_tokens:,}") |
| avg_len = total_tokens / max(len(tokenized), 1) |
| elapsed = time.time() - t0 |
| print(f" 完成: {total_tokens:,} tokens ({avg_len:.0f} avg/sample) | 耗时 {elapsed:.1f}s") |
|
|
| print("加载学生模型...") |
| nf_w = load_nfv1(args.nf_model) |
| lm_w = load_lmh1(args.lm_model) |
| d_model = nf_w['input_proj.weight'].shape[1] |
| hidden_dim = nf_w['input_proj.weight'].shape[0] |
| print(f" NF: {len(nf_w)}层 | LM: {len(lm_w)}层 | d_model={d_model} hidden={hidden_dim}") |
|
|
| start_step = 0 |
| start_epoch = 0 |
| if args.resume: |
| print(f"断点续训: {args.resume}") |
| nf_w = load_nfv1(f"{args.resume}/model.nfv1") |
| lm_w = load_lmh1(f"{args.resume}/lm_head.nfv1") |
| state_path = f"{args.resume}/training_state.json" |
| if os.path.exists(state_path): |
| with open(state_path) as sf: |
| state = json.load(sf) |
| start_step = state.get('step', 0) |
| start_epoch = state.get('epoch', 1) - 1 |
| print(f" 恢复: step={start_step}, epoch={start_epoch+1}") |
|
|
| mode_str = "LM+NF" if args.train_nf else "LM only" |
| print(f"\n开始蒸馏训练 ({args.epochs} epochs, lr={args.lr}, batch={args.batch_size}, " |
| f"grad_clip={args.grad_clip}, mode={mode_str})") |
| print(f" 每样本预测位置数: {args.max_predictions if args.max_predictions > 0 else int(avg_len)} (max_predictions={args.max_predictions})") |
| print("=" * 60) |
|
|
| global_step = start_step |
| for epoch in range(start_epoch, args.epochs): |
| epoch_loss = 0.0 |
| steps = 0 |
|
|
| indices = list(range(len(tokenized))) |
| np.random.shuffle(indices) |
|
|
| for i in range(0, len(indices), args.batch_size): |
| batch_indices = indices[i:i + args.batch_size] |
|
|
| batch_loss = 0.0 |
| for idx in batch_indices: |
| loss = distill_step(tokenized[idx], nf_w, lm_w, args.lr, |
| vocab_size=len(vocab), grad_clip=args.grad_clip, |
| train_nf=args.train_nf, max_predictions=args.max_predictions) |
| batch_loss += loss |
|
|
| batch_loss /= len(batch_indices) |
| epoch_loss += batch_loss |
| steps += 1 |
| global_step += 1 |
|
|
| if steps % 10 == 0: |
| print(f" [Epoch {epoch+1}][Step {global_step}] loss={batch_loss:.4f}") |
|
|
| if args.save_interval > 0 and global_step % args.save_interval == 0: |
| ckpt_dir = f"{args.output}/step_{global_step}" |
| os.makedirs(ckpt_dir, exist_ok=True) |
| save_nfv1(f"{ckpt_dir}/model.nfv1", nf_w) |
| save_lmh1(f"{ckpt_dir}/lm_head.nfv1", lm_w) |
| state = {"step": global_step, "epoch": epoch + 1, "loss": batch_loss, "lr": args.lr} |
| with open(f"{ckpt_dir}/training_state.json", 'w') as sf: |
| json.dump(state, sf, indent=2) |
| print(f" Checkpoint: {ckpt_dir}") |
|
|
| avg_loss = epoch_loss / max(steps, 1) |
| print(f"=== Epoch {epoch+1} 完成, avg_loss={avg_loss:.4f} ===\n") |
|
|
| final_dir = f"{args.output}/final" |
| os.makedirs(final_dir, exist_ok=True) |
| save_nfv1(f"{final_dir}/model.nfv1", nf_w) |
| save_lmh1(f"{final_dir}/lm_head.nfv1", lm_w) |
| print(f"最终模型已保存: {final_dir}") |
| print("蒸馏训练完成") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|