chenbhao opencode commited on
Commit
45cf443
·
1 Parent(s): 10bcbf6

Fuse minimind/minimind-v/minimind-o into omni with layered architecture

Browse files

Migrate the three MiniMind repos into the omni package using a clean
layered design:

- core/: split the monolithic Transformer kernel into norm/rope/attention/
mlp/block/model modules (replacing the old components.py)
- models/: assemble core components into MiniMindForCausalLM (text),
MiniMindVLM (vision), MiniMindOmni (speech/full-modal, with TalkerModule)
- encoders/: SiglipVisionEncoder (vision/), SenseVoiceAudioEncoder (audio/)
- projectors/: MMVisionProjector / MMAudioProjector bridging encoders to LLM
- serve/: SileroVAD / RealtimeSession realtime speech session layer
- trainers/ + datasets/ + utils/: ported train_sft/pretrain/dpo/etc plus
VLM/Omni trainers, VLMDataset/OmniDataset, init_vlm/omni_model helpers
- examples/: eval_llm/vlm/omni, omni_web_demo, serve_openai_api, convert_model
- README + pyproject updated; all modules import-compile-smoke verified

Co-Authored-By: opencode <noreply@opencode.ai>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +124 -0
  2. configs/example_train.yaml +26 -0
  3. dataset/dataset.md +5 -0
  4. examples/convert_model.py +141 -0
  5. examples/eval_llm.py +94 -0
  6. examples/eval_omni.py +244 -0
  7. examples/eval_toolcall.py +238 -0
  8. examples/eval_vlm.py +79 -0
  9. examples/omni_web_demo.py +512 -0
  10. examples/serve_openai_api.py +249 -0
  11. examples/web_demo.html +748 -0
  12. pyproject.toml +27 -2
  13. src/omni/core/__init__.py +18 -0
  14. src/omni/core/attention.py +55 -0
  15. src/omni/core/block.py +24 -0
  16. src/omni/core/mlp.py +49 -0
  17. src/omni/core/model.py +45 -0
  18. src/omni/core/norm.py +15 -0
  19. src/omni/core/rope.py +37 -0
  20. src/omni/datasets/__init__.py +1 -0
  21. src/omni/datasets/lm_dataset.py +606 -0
  22. src/omni/encoders/__init__.py +3 -0
  23. src/omni/encoders/audio/__init__.py +3 -0
  24. src/omni/encoders/audio/sensevoice.py +70 -0
  25. src/omni/encoders/vision/__init__.py +3 -0
  26. src/omni/encoders/vision/siglip.py +54 -0
  27. src/omni/models/__init__.py +53 -0
  28. src/omni/models/lora.py +61 -0
  29. src/omni/models/minimind.py +113 -0
  30. src/omni/models/omni.py +409 -0
  31. src/omni/models/vlm.py +155 -0
  32. src/omni/projectors/__init__.py +4 -0
  33. src/omni/projectors/audio.py +16 -0
  34. src/omni/projectors/vision.py +17 -0
  35. src/omni/serve/__init__.py +1 -0
  36. src/omni/serve/realtime.py +68 -0
  37. src/omni/trainers/__init__.py +17 -0
  38. src/omni/trainers/agent.py +489 -0
  39. src/omni/trainers/distillation.py +245 -0
  40. src/omni/trainers/dpo.py +225 -0
  41. src/omni/trainers/full_sft.py +170 -0
  42. src/omni/trainers/full_sft_omni.py +257 -0
  43. src/omni/trainers/full_sft_vlm.py +175 -0
  44. src/omni/trainers/grpo.py +331 -0
  45. src/omni/trainers/lora.py +183 -0
  46. src/omni/trainers/ppo.py +434 -0
  47. src/omni/trainers/pretrain.py +169 -0
  48. src/omni/trainers/pretrain_vlm.py +175 -0
  49. src/omni/trainers/rollout_engine.py +218 -0
  50. src/omni/trainers/train_tokenizer.py +168 -0
README.md CHANGED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Omni
2
+
3
+ Omni 是一个以 **多模态 (omni)** 为目标的 LLM 训练 / 推理框架,已完整集成
4
+ [MiniMind](https://github.com/jingyaogong/minimind)(纯文本)、
5
+ [miniMind-V](https://github.com/jingyaogong/minimind-v)(视觉多模态)与
6
+ [miniMind-O](https://github.com/jingyaogong/minimind-o)(语音 / 全模态)三套代码。
7
+
8
+ 项目采用标准 `src/` 布局(`pip install -e .` 即可安装为 `omni` 包),
9
+ 按 **core(组件)/ models(拼装)/ encoders(模态编码器)/ projectors(桥接层)** 分层。
10
+
11
+ ## 设计分层
12
+
13
+ - `core/`:可复用模型**底层组件**,按层级细分为独立模块——
14
+ `norm.py`(`RMSNorm`)、`rope.py`(`precompute_freqs_cis` / `apply_rotary_pos_emb` / `repeat_kv`)、
15
+ `attention.py`(`Attention`)、`mlp.py`(`FeedForward` / `MOEFeedForward`)、
16
+ `block.py`(`MiniMindBlock`)、`model.py`(`MiniMindModel` Transformer 主干)。
17
+ - `models/`:把 `core` 组件**拼装**成成品模型——
18
+ `MiniMindForCausalLM`(文本)、`MiniMindVLM`(视觉)、`MiniMindOmni`(语音/全模态)。
19
+ - `encoders/`:外部模态编码器,按模态分目录——`vision/`(SigLIP)、`audio/`(SenseVoice)。
20
+ - `projectors/`:把 encoder 输出**桥接**到 LLM 隐藏维度的拼接层(`MMVisionProjector`、`MMAudioProjector`)。
21
+ - `serve/`:实时语音会话工程层(`SileroVAD`、`RealtimeSession`)。
22
+
23
+ ## 目录结构
24
+
25
+ ```
26
+ src/omni/
27
+ ├── core/ # 模型底层组件(按层级拆分)
28
+ │ ├── norm.py # RMSNorm
29
+ │ ├── rope.py # precompute_freqs_cis / apply_rotary_pos_emb / repeat_kv
30
+ │ ├── attention.py # Attention
31
+ │ ├── mlp.py # FeedForward / MOEFeedForward
32
+ │ ├── block.py # MiniMindBlock
33
+ │ └── model.py # MiniMindModel(Transformer 主干)
34
+ ├── models/ # 模型拼装
35
+ │ ├── minimind.py # MiniMindConfig + MiniMindForCausalLM
36
+ │ ├── vlm.py # VLMConfig + MiniMindVLM(视觉多模态)
37
+ │ ├── omni.py # OmniConfig + MiniMindOmni + TalkerModule(语音/全模态)
38
+ │ └── lora.py # LoRA 注入 / 保存 / 合并
39
+ ├── encoders/ # 多模态编码器(按模态分目录)
40
+ │ ├── vision/ # SiglipVisionEncoder
41
+ │ └── audio/ # SenseVoiceAudioEncoder
42
+ ├── projectors/ # 多模态桥接层
43
+ │ ├── vision.py # MMVisionProjector
44
+ │ └── audio.py # MMAudioProjector
45
+ ├── trainers/ # 训练脚本(可直接 python -m 运行)
46
+ │ ├── pretrain.py # 文本预训练
47
+ │ ├── full_sft.py # 文本全量 SFT
48
+ │ ├── lora.py / dpo.py / distillation.py / ppo.py / grpo.py / agent.py
49
+ │ ├── rollout_engine.py # torch / sglang 推理引擎
50
+ │ ├── train_tokenizer.py # tokenizer 训练(学习用)
51
+ │ ├── pretrain_vlm.py # 视觉预训练
52
+ │ ├── full_sft_vlm.py # 视觉 SFT
53
+ │ └── full_sft_omni.py # 全模态 SFT
54
+ ├── datasets/ # 数据集(Pretrain/SFT/DPO/RLAIF/Agent/VLM/Omni)
55
+ │ └── lm_dataset.py
56
+ ├── utils/ # 工具
57
+ │ ├── training.py # get_lr / init_model / lm_checkpoint / SkipBatchSampler / LMForRewardModel
58
+ │ ├── multimodal.py # init_vlm_model / vlm_checkpoint / init_omni_model / omni_checkpoint
59
+ │ ├── distributed.py # 分布式初始化
60
+ │ └── checkpoint.py # checkpoint 读写辅助
61
+ ├── serve/ # 实时语音会话(SileroVAD / RealtimeSession)
62
+ └── __init__.py
63
+ examples/ # 推理 / 服务 / 转换脚本
64
+ ├── eval_llm.py # 命令行推理与对话
65
+ ├── eval_vlm.py # 视觉多模态推理
66
+ ├── eval_omni.py # 全模态推理
67
+ ├── serve_openai_api.py # OpenAI 兼容 API 服务
68
+ ├── omni_web_demo.py # 网页演示(含实时语音)
69
+ ├── eval_toolcall.py # 工具调用评测
70
+ └── convert_model.py # torch <-> transformers 权重互转
71
+ weights/MiniMind2/ # tokenizer 与模型配置(从 MiniMind 迁移)
72
+ configs/ # 训练配置(按需补充)
73
+ ```
74
+
75
+ ## 安装
76
+
77
+ ```bash
78
+ pip install -e .
79
+ # 可选依赖:RL 训练 / API 服务 / 演示
80
+ pip install -e ".[rl,serve,demo]"
81
+ ```
82
+
83
+ ## 快速开始
84
+
85
+ ### 推理 / 对话
86
+
87
+ ```bash
88
+ python -m examples.eval_llm --load_from weights/MiniMind2 --weight full_sft
89
+ ```
90
+
91
+ ### 训练
92
+
93
+ 每个训练脚本都是 `omni.trainers` 下的一个模块,直接运行即可:
94
+
95
+ ```bash
96
+ # 预训练
97
+ python -m omni.trainers.pretrain --data_path dataset/pretrain.jsonl
98
+ # 全量 SFT
99
+ python -m omni.trainers.full_sft --data_path dataset/sft.jsonl
100
+ # LoRA 微调
101
+ python -m omni.trainers.lora --data_path dataset/lora.jsonl
102
+ # DPO / 蒸馏 / PPO / GRPO / Agent RL
103
+ python -m omni.trainers.dpo --data_path dataset/dpo.jsonl
104
+ python -m omni.trainers.distillation --data_path dataset/sft.jsonl
105
+ python -m omni.trainers.grpo --data_path dataset/rlaif.jsonl
106
+ python -m omni.trainers.agent --data_path dataset/agent_rl.jsonl
107
+ ```
108
+
109
+ 所有脚本参数与原 MiniMind 保持一致(hidden_size / num_hidden_layers / use_moe / data_path 等)。
110
+
111
+ ## 与原 MiniMind 的差异
112
+
113
+ - 去除所有 `sys.path` 注入 hack,统一使用 `omni.*` 包导入;
114
+ - `trainer_utils.py` 拆分为 `utils/training.py`(训练工具)、`utils/distributed.py`、`utils/checkpoint.py`;
115
+ - 训练脚本从「`if __name__ == '__main__'` 内联」改为可被 `python -m omni.trainers.<name>` 调用的模块;
116
+ - 预留 `encoders/`(vision/audio)、`projectors/`、`core/` 供多模态扩展。
117
+
118
+ ## 多模态扩展方向
119
+
120
+ 在 `models/minimind.py` 的 `MiniMindModel` 之上接入:
121
+
122
+ 1. `encoders/vision` / `encoders/audio` —— 各自的模态编码器;
123
+ 2. `projectors` —— 将编码器输出投影到 LLM 隐藏维度;
124
+ 3. 在 `MiniMindModel.forward` 中把投影特征拼接到 `embed_tokens` 之后。
configs/example_train.yaml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Omni / MiniMind 训练配置示例
2
+ # 用法:将此处参数直接作为命令行参数传给对应训练模块,例如
3
+ # python -m omni.trainers.full_sft --hidden_size 768 --num_hidden_layers 8 --use_moe 0 --data_path dataset/sft.jsonl
4
+
5
+ model:
6
+ hidden_size: 768
7
+ num_hidden_layers: 8
8
+ use_moe: 0 # 1 启用 MoE
9
+ vocab_size: 6400
10
+ max_seq_len: 768
11
+
12
+ train:
13
+ epochs: 2
14
+ batch_size: 16
15
+ learning_rate: 1.0e-5
16
+ accumulation_steps: 1
17
+ grad_clip: 1.0
18
+ dtype: bfloat16
19
+ save_interval: 1000
20
+ log_interval: 100
21
+ from_weight: pretrain # none / pretrain / full_sft / ...
22
+ from_resume: 0
23
+
24
+ paths:
25
+ save_dir: out
26
+ data_path: dataset/sft.jsonl
dataset/dataset.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # MiniMind Datasets
2
+
3
+ 将所有下载的数据集文件放置到当前目录.
4
+
5
+ Place the downloaded dataset file in the current directory.
examples/convert_model.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+
4
+ import torch
5
+ import transformers
6
+ import warnings
7
+ from transformers import AutoTokenizer, AutoModelForCausalLM, Qwen3Config, Qwen3ForCausalLM, Qwen3MoeConfig, Qwen3MoeForCausalLM
8
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
9
+ from omni.models.lora import apply_lora, merge_lora
10
+
11
+ warnings.filterwarnings('ignore', category=UserWarning)
12
+
13
+ def convert_torch2transformers_minimind(torch_path, transformers_path, dtype=torch.float16):
14
+ MiniMindConfig.register_for_auto_class()
15
+ MiniMindForCausalLM.register_for_auto_class("AutoModelForCausalLM")
16
+ lm_model = MiniMindForCausalLM(lm_config)
17
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
18
+ state_dict = torch.load(torch_path, map_location=device)
19
+ lm_model.load_state_dict(state_dict, strict=False)
20
+ lm_model = lm_model.to(dtype) # 转换模型权重精度
21
+ model_params = sum(p.numel() for p in lm_model.parameters() if p.requires_grad)
22
+ print(f'模型参数: {model_params / 1e6} 百万 = {model_params / 1e9} B (Billion)')
23
+ lm_model.save_pretrained(transformers_path, safe_serialization=False)
24
+ tokenizer = AutoTokenizer.from_pretrained('../model/')
25
+ tokenizer.save_pretrained(transformers_path)
26
+ # ======= transformers-5.0的兼容低版本写法 =======
27
+ if int(transformers.__version__.split('.')[0]) >= 5:
28
+ tokenizer_config_path, config_path = os.path.join(transformers_path, "tokenizer_config.json"), os.path.join(transformers_path, "config.json")
29
+ json.dump({**json.load(open(tokenizer_config_path, 'r', encoding='utf-8')), "tokenizer_class": "PreTrainedTokenizerFast", "extra_special_tokens": {}}, open(tokenizer_config_path, 'w', encoding='utf-8'), indent=2, ensure_ascii=False)
30
+ config = json.load(open(config_path, 'r', encoding='utf-8'))
31
+ config['rope_theta'] = lm_config.rope_theta; config['rope_scaling'] = None; del config['rope_parameters']
32
+ json.dump(config, open(config_path, 'w', encoding='utf-8'), indent=2, ensure_ascii=False)
33
+ print(f"模型已保存为 Transformers-MiniMind 格式: {transformers_path}")
34
+
35
+
36
+ # QwenForCausalLM/LlamaForCausalLM结构兼容生态
37
+ def convert_torch2transformers(torch_path, transformers_path, dtype=torch.float16):
38
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
39
+ state_dict = torch.load(torch_path, map_location=device)
40
+ common_config = {
41
+ "vocab_size": lm_config.vocab_size,
42
+ "hidden_size": lm_config.hidden_size,
43
+ "intermediate_size": lm_config.intermediate_size,
44
+ "num_hidden_layers": lm_config.num_hidden_layers,
45
+ "num_attention_heads": lm_config.num_attention_heads,
46
+ "num_key_value_heads": lm_config.num_key_value_heads,
47
+ "head_dim": lm_config.hidden_size // lm_config.num_attention_heads,
48
+ "max_position_embeddings": lm_config.max_position_embeddings,
49
+ "rms_norm_eps": lm_config.rms_norm_eps,
50
+ "rope_theta": lm_config.rope_theta,
51
+ "tie_word_embeddings": lm_config.tie_word_embeddings
52
+ }
53
+ if not lm_config.use_moe:
54
+ qwen_config = Qwen3Config(
55
+ **common_config,
56
+ use_sliding_window=False,
57
+ sliding_window=None
58
+ )
59
+ qwen_model = Qwen3ForCausalLM(qwen_config)
60
+ else:
61
+ qwen_config = Qwen3MoeConfig(
62
+ **common_config,
63
+ num_experts=lm_config.num_experts,
64
+ num_experts_per_tok=lm_config.num_experts_per_tok,
65
+ moe_intermediate_size=lm_config.moe_intermediate_size,
66
+ norm_topk_prob=lm_config.norm_topk_prob
67
+ )
68
+ qwen_model = Qwen3MoeForCausalLM(qwen_config)
69
+ # ======= transformers-5.0的兼容低版本写法 =======
70
+ if int(transformers.__version__.split('.')[0]) >= 5:
71
+ new_sd = {k: v for k, v in state_dict.items() if 'experts.' not in k or 'gate.weight' in k}
72
+ for l in range(lm_config.num_hidden_layers):
73
+ p = f'model.layers.{l}.mlp.experts'
74
+ new_sd[f'{p}.gate_up_proj'] = torch.cat([torch.stack([state_dict[f'{p}.{e}.gate_proj.weight'] for e in range(lm_config.num_experts)]), torch.stack([state_dict[f'{p}.{e}.up_proj.weight'] for e in range(lm_config.num_experts)])], dim=1)
75
+ new_sd[f'{p}.down_proj'] = torch.stack([state_dict[f'{p}.{e}.down_proj.weight'] for e in range(lm_config.num_experts)])
76
+ state_dict = new_sd
77
+
78
+ qwen_model.load_state_dict(state_dict, strict=True)
79
+ qwen_model = qwen_model.to(dtype) # 转换模型权重精度
80
+ qwen_model.save_pretrained(transformers_path)
81
+ model_params = sum(p.numel() for p in qwen_model.parameters() if p.requires_grad)
82
+ print(f'模型参数: {model_params / 1e6} 百万 = {model_params / 1e9} B (Billion)')
83
+ tokenizer = AutoTokenizer.from_pretrained('../model/')
84
+ tokenizer.save_pretrained(transformers_path)
85
+
86
+ # ======= transformers-5.0的兼容低版本写法 =======
87
+ if int(transformers.__version__.split('.')[0]) >= 5:
88
+ tokenizer_config_path, config_path = os.path.join(transformers_path, "tokenizer_config.json"), os.path.join(transformers_path, "config.json")
89
+ json.dump({**json.load(open(tokenizer_config_path, 'r', encoding='utf-8')), "tokenizer_class": "PreTrainedTokenizerFast", "extra_special_tokens": {}}, open(tokenizer_config_path, 'w', encoding='utf-8'), indent=2, ensure_ascii=False)
90
+ config = json.load(open(config_path, 'r', encoding='utf-8'))
91
+ config['rope_theta'] = lm_config.rope_theta; config['rope_scaling'] = None; del config['rope_parameters']
92
+ json.dump(config, open(config_path, 'w', encoding='utf-8'), indent=2, ensure_ascii=False)
93
+ print(f"模型已保存为 Transformers 格式: {transformers_path}")
94
+
95
+
96
+ def convert_transformers2torch(transformers_path, torch_path):
97
+ model = AutoModelForCausalLM.from_pretrained(transformers_path, trust_remote_code=True)
98
+ torch.save({k: v.cpu().half() for k, v in model.state_dict().items()}, torch_path)
99
+ print(f"模型已保存为 PyTorch 格式: {torch_path}")
100
+
101
+
102
+ def convert_merge_base_lora(base_torch_path, lora_path, merged_torch_path):
103
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
104
+ lm_model = MiniMindForCausalLM(lm_config).to(device)
105
+ state_dict = torch.load(base_torch_path, map_location=device)
106
+ lm_model.load_state_dict(state_dict, strict=False)
107
+ apply_lora(lm_model)
108
+ merge_lora(lm_model, lora_path, merged_torch_path)
109
+ print(f"LoRA 已合并并保存为基模结构 PyTorch 格式: {merged_torch_path}")
110
+
111
+
112
+ def convert_jinja_to_json(jinja_path):
113
+ with open(jinja_path, 'r') as f: template = f.read()
114
+ escaped = json.dumps(template)
115
+ print(f'"chat_template": {escaped}')
116
+
117
+
118
+ def convert_json_to_jinja(json_file_path, output_path):
119
+ with open(json_file_path, 'r') as f: config = json.load(f)
120
+ template = config['chat_template']
121
+ with open(output_path, 'w') as f: f.write(template)
122
+ print(f"模板已保存为 jinja 文件: {output_path}")
123
+
124
+
125
+ if __name__ == '__main__':
126
+ lm_config = MiniMindConfig(hidden_size=768, num_hidden_layers=8, max_seq_len=8192, use_moe=False)
127
+
128
+ # convert torch to transformers
129
+ torch_path = f"../out/full_sft_{lm_config.hidden_size}{'_moe' if lm_config.use_moe else ''}.pth"
130
+ transformers_path = '../minimind-3'
131
+ convert_torch2transformers(torch_path, transformers_path)
132
+
133
+ # # merge lora
134
+ # base_torch_path = f"../out/full_sft_{lm_config.hidden_size}{'_moe' if lm_config.use_moe else ''}.pth"
135
+ # lora_path = f"../out/lora_identity_{lm_config.hidden_size}{'_moe' if lm_config.use_moe else ''}.pth"
136
+ # merged_torch_path = f"../out/merge_identity_{lm_config.hidden_size}{'_moe' if lm_config.use_moe else ''}.pth"
137
+ # convert_merge_base_lora(base_torch_path, lora_path, merged_torch_path)
138
+
139
+ # convert_transformers2torch(transformers_path, torch_path)
140
+ # convert_json_to_jinja('../model/tokenizer_config.json', '../model/chat_template.jinja')
141
+ # convert_jinja_to_json('../model/chat_template.jinja')
examples/eval_llm.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import argparse
3
+ import random
4
+ import warnings
5
+ import torch
6
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer
7
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
8
+ from omni.models.lora import * # noqa: F401,F403
9
+ from omni.utils.training import setup_seed, get_model_params
10
+ warnings.filterwarnings('ignore')
11
+
12
+ def init_model(args):
13
+ tokenizer = AutoTokenizer.from_pretrained(args.load_from)
14
+ if 'model' in args.load_from:
15
+ model = MiniMindForCausalLM(MiniMindConfig(
16
+ hidden_size=args.hidden_size,
17
+ num_hidden_layers=args.num_hidden_layers,
18
+ use_moe=bool(args.use_moe),
19
+ inference_rope_scaling=args.inference_rope_scaling
20
+ ))
21
+ moe_suffix = '_moe' if args.use_moe else ''
22
+ ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
23
+ model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True)
24
+ if args.lora_weight != 'None':
25
+ apply_lora(model)
26
+ load_lora(model, f'./{args.save_dir}/{args.lora_weight}_{args.hidden_size}.pth')
27
+ else:
28
+ model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
29
+ get_model_params(model, model.config)
30
+ return model.half().eval().to(args.device), tokenizer
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(description="MiniMind模型推理与对话")
34
+ parser.add_argument('--load_from', default='model', type=str, help="模型加载路径(model=原生torch权重,其他路径=transformers格式)")
35
+ parser.add_argument('--save_dir', default='out', type=str, help="模型权重目录")
36
+ parser.add_argument('--weight', default='full_sft', type=str, help="权重名称前缀(pretrain, full_sft, rlhf, reason, ppo_actor, grpo, spo)")
37
+ parser.add_argument('--lora_weight', default='None', type=str, help="LoRA权重名称(None表示不使用,可选:lora_identity, lora_medical)")
38
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
39
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
40
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
41
+ parser.add_argument('--inference_rope_scaling', default=False, action='store_true', help="启用RoPE位置编码外推(4倍,仅解决位置编码问题)")
42
+ parser.add_argument('--max_new_tokens', default=8192, type=int, help="最大生成长度(注意:并非模型实际长文本能力)")
43
+ parser.add_argument('--temperature', default=0.85, type=float, help="生成温度,控制随机性(0-1,越大越随机)")
44
+ parser.add_argument('--top_p', default=0.95, type=float, help="nucleus采样阈值(0-1)")
45
+ parser.add_argument('--open_thinking', default=0, type=int, help="是否开启自适应思考(0=否,1=是)")
46
+ parser.add_argument('--historys', default=0, type=int, help="携带历史对话轮数(需为偶数,0表示不携带历史)")
47
+ parser.add_argument('--show_speed', default=1, type=int, help="显示decode速度(tokens/s)")
48
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', type=str, help="运行设备")
49
+ args = parser.parse_args()
50
+
51
+ prompts = [
52
+ '你有什么特长?',
53
+ '为什么天空是蓝色的',
54
+ '请用Python写一个计算斐波那契数列的函数',
55
+ '解释一下"光合作用"的基本过程',
56
+ '如果明天下雨,我应该如何出门',
57
+ '比较一下猫和狗作为宠物的优缺点',
58
+ '解释什么是机器学习',
59
+ '推荐一些中国的美食'
60
+ ]
61
+
62
+ conversation = []
63
+ model, tokenizer = init_model(args)
64
+ input_mode = int(input('[0] 自动测试\n[1] 手动输入\n'))
65
+ streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
66
+
67
+ prompt_iter = prompts if input_mode == 0 else iter(lambda: input('💬: '), '')
68
+ for prompt in prompt_iter:
69
+ setup_seed(random.randint(0, 31415926))
70
+ if input_mode == 0: print(f'💬: {prompt}')
71
+ conversation = conversation[-args.historys:] if args.historys else []
72
+ conversation.append({"role": "user", "content": prompt})
73
+ if 'pretrain' in args.weight:
74
+ inputs = tokenizer.bos_token + prompt
75
+ else:
76
+ inputs = tokenizer.apply_chat_template(conversation, tokenize=False, add_generation_prompt=True, open_thinking=bool(args.open_thinking))
77
+
78
+ inputs = tokenizer(inputs, return_tensors="pt", truncation=True).to(args.device)
79
+
80
+ print('🧠: ', end='')
81
+ st = time.time()
82
+ generated_ids = model.generate(
83
+ inputs=inputs["input_ids"], attention_mask=inputs["attention_mask"],
84
+ max_new_tokens=args.max_new_tokens, do_sample=True, streamer=streamer,
85
+ pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id,
86
+ top_p=args.top_p, temperature=args.temperature, repetition_penalty=1
87
+ )
88
+ response = tokenizer.decode(generated_ids[0][len(inputs["input_ids"][0]):], skip_special_tokens=True)
89
+ conversation.append({"role": "assistant", "content": response})
90
+ gen_tokens = len(generated_ids[0]) - len(inputs["input_ids"][0])
91
+ print(f'\n[Speed]: {gen_tokens / (time.time() - st):.2f} tokens/s\n\n') if args.show_speed else print('\n\n')
92
+
93
+ if __name__ == "__main__":
94
+ main()
examples/eval_omni.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import random
4
+ import time
5
+ import warnings
6
+ import torch
7
+ from PIL import Image
8
+ from transformers import AutoTokenizer, AutoModelForCausalLM
9
+ from omni.models import MiniMindOmni, OmniConfig
10
+ from omni.datasets.lm_dataset import OmniDataset
11
+ from omni.utils import setup_seed, log_model_params
12
+ warnings.filterwarnings('ignore')
13
+
14
+
15
+ def init_model(args):
16
+ tokenizer = AutoTokenizer.from_pretrained(args.load_from)
17
+ if 'model' in args.load_from:
18
+ moe_suffix = '_moe' if args.use_moe else ''
19
+ ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
20
+ model = MiniMindOmni(
21
+ OmniConfig(
22
+ hidden_size=args.hidden_size,
23
+ num_hidden_layers=args.num_hidden_layers,
24
+ use_moe=bool(args.use_moe)
25
+ ),
26
+ audio_encoder_path="./model/SenseVoiceSmall",
27
+ vision_model_path="./model/siglip2-base-p32-256-ve"
28
+ )
29
+ model.load_state_dict(torch.load(ckp, map_location=args.device), strict=False)
30
+ else:
31
+ model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
32
+ model.audio_encoder, model.audio_processor = MiniMindOmni.load_sensevoice("./model/SenseVoiceSmall")
33
+ model.vision_encoder, model.vision_processor = MiniMindOmni.load_vision("./model/siglip2-base-p32-256-ve")
34
+ log_model_params(model)
35
+ if model.audio_encoder is not None: model.audio_encoder.to(args.device)
36
+ if model.vision_encoder is not None: model.vision_encoder.to(args.device)
37
+ from transformers import MimiModel
38
+ model.mimi_model = MimiModel.from_pretrained("./model/mimi").eval()
39
+ return model.half().eval().to(args.device), tokenizer
40
+
41
+
42
+ def eval_sample(model, tokenizer, args, idx, prompt, audio_inputs, output_name, pixel_values=None, history=None, audio_lens=None, ref_codes=None, spk_emb=None):
43
+ import soundfile as sf
44
+ from pydub import AudioSegment
45
+ messages = (history or []) + [{"role": "user", "content": prompt}]
46
+ inputs_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, open_thinking=bool(args.open_thinking))
47
+ x = torch.tensor(tokenizer(inputs_text).data['input_ids'], dtype=torch.long, device=args.device)[None, ...]
48
+
49
+ audio_frames = []
50
+ with torch.no_grad():
51
+ res_y = model.generate(x, tokenizer.eos_token_id, max_new_tokens=args.max_new_tokens,
52
+ temperature=args.temperature, top_p=args.top_p, stream=True,
53
+ return_audio_codes=True, open_thinking=bool(args.open_thinking),
54
+ audio_inputs=audio_inputs, audio_lens=audio_lens, pixel_values=pixel_values,
55
+ ref_codes=ref_codes, spk_emb=spk_emb)
56
+ print('📒 [Thinker]: ', end='', flush=True)
57
+ history_idx = 0
58
+ for y, audio_frame in res_y:
59
+ if y is not None:
60
+ answer = tokenizer.decode(y[0].tolist(), skip_special_tokens=True)
61
+ if answer and answer[-1] != '�':
62
+ print(answer[history_idx:], end='', flush=True)
63
+ history_idx = len(answer)
64
+ if audio_frame:
65
+ audio_frames.append(audio_frame)
66
+ print()
67
+
68
+ if audio_frames:
69
+ print(f'🎹 [Talker]: {len(audio_frames)} frames', end=" ")
70
+ if args.decode_audio:
71
+ try:
72
+ codes = [f for f in audio_frames if f and len(f) == 8]
73
+ if not codes:
74
+ print('⚠️ 生成的Mimi codes为空,跳过保存。')
75
+ return
76
+ mimi_codes = torch.tensor(codes, dtype=torch.long).T.unsqueeze(0).to(args.device)
77
+ filtered = torch.where(mimi_codes >= 2049, torch.zeros_like(mimi_codes), mimi_codes)
78
+ audio = model.mimi_model.decode(filtered).audio_values
79
+ output_path = os.path.join(args.output_dir, output_name)
80
+ wav_path = output_path.rsplit('.', 1)[0] + '.wav'
81
+ sf.write(wav_path, audio.squeeze().float().cpu().numpy(), 24000)
82
+ AudioSegment.from_wav(wav_path).export(output_path, format='mp3', bitrate='64k')
83
+ os.remove(wav_path)
84
+ print(f'| Audio decoded to: {output_path}')
85
+ except Exception as e:
86
+ print(f'⚠️ 保存音频失败: {str(e)}')
87
+ else:
88
+ print("(decode_audio=off)\n")
89
+
90
+
91
+ def main():
92
+ parser = argparse.ArgumentParser(description="MiniMind-O Chat")
93
+ parser.add_argument('--load_from', default='model', type=str, help="模型加载路径(model=原生torch权重)")
94
+ parser.add_argument('--save_dir', default='out', type=str, help="模型权重目录")
95
+ parser.add_argument('--weight', default='sft_omni', type=str, help="权重名称前缀")
96
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
97
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
98
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构")
99
+ parser.add_argument('--max_new_tokens', default=512, type=int, help="最大生成长度")
100
+ parser.add_argument('--temperature', default=0.7, type=float, help="Thinker生成温度")
101
+ parser.add_argument('--top_p', default=0.85, type=float, help="nucleus采样阈值")
102
+ parser.add_argument('--output_dir', default='./output_audio/', type=str, help="输出音频保存目录")
103
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', type=str, help="运行设备")
104
+ parser.add_argument('--audio_dir', default='./dataset/eval_omni/', type=str, help="测试音频目录")
105
+ parser.add_argument('--image_dir', default='./dataset/eval_omni/', type=str, help="测试图像目录")
106
+ parser.add_argument('--open_thinking', default=0, type=int, help="是否开启思考模式(0=否,1=是)(思考模式下禁用audio输出)")
107
+ parser.add_argument('--decode_audio', default=1, type=int, help="是否解码音频输出(0=否,1=是)")
108
+ parser.add_argument('--mode', default='0', type=str, help="评估模式:-1=all 0=text 1=multi 2=audio 3=clone 4=image 5=mix(逗号组合,如 2,5)")
109
+ parser.add_argument('--prompt_lang', default=0, type=int, choices=[0, 1, 2], help="问题语言:0=英文 1=中文 2=英文+中文")
110
+ args = parser.parse_args()
111
+ modes = set(args.mode.replace(',', '').replace('-1', '012345'))
112
+
113
+ os.makedirs(args.output_dir, exist_ok=True)
114
+ model, tokenizer = init_model(args)
115
+ setup_seed(int(time.time()) % 31415926)
116
+
117
+ if '0' in modes:
118
+ print('\n\n==================== text -> {text, audio} ====================')
119
+ test_prompts_en = [
120
+ "Tell me an interesting fact about space.", "How do I make a cup of coffee?", "What's the weather like today?",
121
+ "Will it rain tomorrow?", "Tell me a joke.", "Can you sing a song for me?", "Please introduce yourself."
122
+ ]
123
+ test_prompts_zh = [
124
+ "告诉我一个关于太空的有趣事实。", "如何制作一杯咖啡?", "今天的天气怎么样?",
125
+ "明天会下雨吗?", "给我讲个笑话吧", "你能为我唱首歌吗?", "介绍一下你自己"
126
+ ]
127
+ test_prompts = [test_prompts_en, test_prompts_zh, test_prompts_en + test_prompts_zh][args.prompt_lang]
128
+ for idx, prompt in enumerate(test_prompts):
129
+ print(f'\n📝 [text-{idx+1}]: {prompt}')
130
+ eval_sample(model, tokenizer, args, idx, prompt, None, f"text-{idx:02d}.mp3")
131
+
132
+ if '1' in modes:
133
+ print('\n\n==================== multi-turn -> {text, audio} ====================')
134
+ multi_turn_tests_zh = [
135
+ {
136
+ "history": [
137
+ {"role": "user", "content": "你好"},
138
+ {"role": "assistant", "content": "你好!有什么可以帮你的吗?"}
139
+ ],
140
+ "prompt": "我想找点事做,你有什么建议吗?"
141
+ },
142
+ {
143
+ "history": [
144
+ {"role": "user", "content": "你好"},
145
+ {"role": "assistant", "content": "你好!有什么可以帮你的吗?"},
146
+ {"role": "user", "content": "我想找点事做,你有什么建议吗?"},
147
+ {"role": "assistant", "content": "可以听听音乐或者看看书,放松一下心情。"}
148
+ ],
149
+ "prompt": "好的,那我去照做了,谢谢你"
150
+ }
151
+ ]
152
+ multi_turn_tests_en = [
153
+ {
154
+ "history": [
155
+ {"role": "user", "content": "Hello"},
156
+ {"role": "assistant", "content": "Hello! How can I help you?"}
157
+ ],
158
+ "prompt": "I want to find something to do. Do you have any suggestions?"
159
+ },
160
+ {
161
+ "history": [
162
+ {"role": "user", "content": "Hello"},
163
+ {"role": "assistant", "content": "Hello! How can I help you?"},
164
+ {"role": "user", "content": "I want to find something to do. Do you have any suggestions?"},
165
+ {"role": "assistant", "content": "You can listen to music or read a book to relax a little."}
166
+ ],
167
+ "prompt": "Okay, I will try that. Thank you."
168
+ }
169
+ ]
170
+ multi_turn_tests = [multi_turn_tests_en, multi_turn_tests_zh, multi_turn_tests_en + multi_turn_tests_zh][args.prompt_lang]
171
+ for idx, test in enumerate(multi_turn_tests):
172
+ print(f'\n💬 [multi-{idx+1}]')
173
+ for msg in test["history"]: print(f' {msg["role"]}: {msg["content"]}')
174
+ print(f' user: {test["prompt"]}')
175
+ eval_sample(model, tokenizer, args, idx, test["prompt"], None, f"multi-{idx:02d}.mp3", history=test["history"])
176
+
177
+ if '2' in modes:
178
+ print('\n\n==================== audio -> {text, audio} ====================')
179
+ audio_files_en = sorted([f for f in os.listdir(args.audio_dir) if f.startswith('audio-en-') and f.lower().endswith(('.mp3', '.wav'))])
180
+ audio_files_zh = sorted([f for f in os.listdir(args.audio_dir) if f.startswith('audio-zh-') and f.lower().endswith(('.mp3', '.wav'))])
181
+ audio_files = [audio_files_en, audio_files_zh, audio_files_en + audio_files_zh][args.prompt_lang]
182
+ for idx, audio_file in enumerate(audio_files):
183
+ print(f'\n🎤 [audio-{idx+1}]: {audio_file}')
184
+ mel, valid_len = OmniDataset.process_audio(os.path.join(args.audio_dir, audio_file), model.audio_processor)
185
+ audio_inputs = mel.unsqueeze(0).to(args.device)
186
+ audio_lens = torch.tensor([valid_len], device=args.device)
187
+ audio_token_len = valid_len or 1
188
+ prompt = model.config.audio_special_token * audio_token_len
189
+ eval_sample(model, tokenizer, args, idx, prompt, audio_inputs, f"audio-{idx:02d}-{os.path.splitext(audio_file)[0]}.mp3", audio_lens=audio_lens)
190
+
191
+ if '3' in modes:
192
+ print('\n\n==================== clone voice -> {text, audio} ====================')
193
+ clone_prompts_en = ["Hello, please introduce yourself.", "What's the weather like today?", "Tell me a joke."]
194
+ clone_prompts_zh = ["你好,请介绍一下你自己。", "今天天气怎么样?", "给我讲个笑话吧"]
195
+ clone_prompts = [clone_prompts_en, clone_prompts_zh, clone_prompts_en + clone_prompts_zh][args.prompt_lang]
196
+ voices_pt = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'model', 'speaker', 'voices_unseen.pt')
197
+ voices = [('default', None, None)]
198
+ if os.path.exists(voices_pt):
199
+ voice_data = torch.load(voices_pt, map_location=args.device)
200
+ for speaker, v in sorted(voice_data.items()):
201
+ rc = v['ref_codes'].unsqueeze(0).to(args.device)
202
+ se = v['spk_emb'].half().unsqueeze(0).to(args.device) if 'spk_emb' in v else None
203
+ voices.append((speaker, rc, se))
204
+ for speaker, rc, se in voices:
205
+ info = f'ref_codes: {rc.shape[2]} frames, spk_emb: {"+" if se is not None else "-"}' if rc is not None else ('spk_emb only' if se is not None else 'default')
206
+ print(f'\n🎵 [clone: {speaker}] {info}')
207
+ for idx, prompt in enumerate(clone_prompts):
208
+ print(f' 📝 [text-{idx+1}]: {prompt}')
209
+ history = [{"role": "system", "content": "你是一个专业的语音助手,请用给定的音色风格来回答用户的问题。请尽量详细地回答,给出有价值的信息。"}]
210
+ eval_sample(model, tokenizer, args, idx, prompt, None, f"clone-{speaker}-{idx:02d}.mp3", ref_codes=rc, history=history, spk_emb=se)
211
+
212
+ if '4' in modes:
213
+ print('\n\n==================== image -> {text, audio} ====================')
214
+ image_files = sorted([f for f in os.listdir(args.image_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
215
+ for idx, image_file in enumerate(image_files):
216
+ print(f'\n🖼️ [image-{idx+1}]: {image_file}')
217
+ image = Image.open(os.path.join(args.image_dir, image_file)).convert('RGB')
218
+ pixel_values = {k: v.to(args.device) for k, v in model.vision_processor(images=image, return_tensors="pt").items()}
219
+ prompts = [["Please describe this image."], ["请描述这张图片"], ["Please describe this image.", "请描述这张图片"]][args.prompt_lang]
220
+ for lang_idx, prompt_text in enumerate(prompts):
221
+ prompt = prompt_text + "\n\n" + model.config.image_special_token * model.config.image_token_len
222
+ eval_sample(model, tokenizer, args, idx, prompt, None, f"image-{idx:02d}-{lang_idx}-{os.path.splitext(image_file)[0]}.mp3", pixel_values=pixel_values)
223
+
224
+ if '5' in modes:
225
+ print('\n\n==================== text+audio+image -> {text, audio} ====================')
226
+ img_audio_files = sorted([f for f in os.listdir(args.audio_dir) if f.startswith('img-') and f.lower().endswith(('.mp3', '.wav'))])
227
+ image_files = sorted([f for f in os.listdir(args.image_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))])
228
+ text_hints = [["Please answer me: "], ["请回答我:"], ["Please answer me: ", "请回答我:"]][args.prompt_lang]
229
+ for idx, image_file in enumerate(image_files):
230
+ audio_file = random.choice(img_audio_files)
231
+ image = Image.open(os.path.join(args.image_dir, image_file)).convert('RGB')
232
+ pixel_values = {k: v.to(args.device) for k, v in model.vision_processor(images=image, return_tensors="pt").items()}
233
+ for lang_idx, text_hint in enumerate(text_hints):
234
+ print(f'\n🌀 [mix-{idx+1}-{lang_idx}]: {text_hint} | {audio_file} | {image_file}')
235
+ mel, valid_len = OmniDataset.process_audio(os.path.join(args.audio_dir, audio_file), model.audio_processor)
236
+ audio_inputs = mel.unsqueeze(0).to(args.device)
237
+ audio_lens = torch.tensor([valid_len], device=args.device)
238
+ audio_token_len = valid_len or 1
239
+ prompt = text_hint + model.config.audio_special_token * audio_token_len + "\n\n" + model.config.image_special_token * model.config.image_token_len
240
+ eval_sample(model, tokenizer, args, idx, prompt, audio_inputs, f"mix-{idx:02d}-{lang_idx}-{os.path.splitext(image_file)[0]}.mp3", pixel_values=pixel_values, audio_lens=audio_lens)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
examples/eval_toolcall.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import time
5
+ import random
6
+ import argparse
7
+ import warnings
8
+ import torch
9
+ from datetime import datetime
10
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer
11
+ from openai import OpenAI
12
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
13
+ from omni.utils.training import setup_seed, get_model_params
14
+ warnings.filterwarnings('ignore')
15
+
16
+ TOOLS = [
17
+ {"type": "function", "function": {"name": "calculate_math", "description": "计算数学表达式的结果,支持加减乘除、幂运算、开方等", "parameters": {"type": "object", "properties": {"expression": {"type": "string", "description": "数学表达式,如123+456、2**10、sqrt(144)"}}, "required": ["expression"]}}},
18
+ {"type": "function", "function": {"name": "get_current_time", "description": "获取当前日期和时间,支持指定时区", "parameters": {"type": "object", "properties": {"timezone": {"type": "string", "description": "时区名称,如Asia/Shanghai、America/New_York", "default": "Asia/Shanghai"}}, "required": []}}},
19
+ {"type": "function", "function": {"name": "random_number", "description": "生成指定范围内的随机数", "parameters": {"type": "object", "properties": {"min": {"type": "integer", "description": "最小值", "default": 0}, "max": {"type": "integer", "description": "最大值", "default": 100}}, "required": []}}},
20
+ {"type": "function", "function": {"name": "text_length", "description": "计算文本的字符数和单词数", "parameters": {"type": "object", "properties": {"text": {"type": "string", "description": "要统计的文本"}}, "required": ["text"]}}},
21
+ {"type": "function", "function": {"name": "unit_converter", "description": "进行单位换算,支持长度、重量、温度等", "parameters": {"type": "object", "properties": {"value": {"type": "number", "description": "要转换的数值"}, "from_unit": {"type": "string", "description": "源单位,如km、miles、kg、pounds、celsius、fahrenheit"}, "to_unit": {"type": "string", "description": "目标单位"}}, "required": ["value", "from_unit", "to_unit"]}}},
22
+ {"type": "function", "function": {"name": "get_current_weather", "description": "获取指定城市的当前天气信息,包括温度、湿度和天气状况", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "城市名称,如北京、上海、New York"}, "unit": {"type": "string", "description": "温度单位,celsius或fahrenheit", "enum": ["celsius", "fahrenheit"], "default": "celsius"}}, "required": ["location"]}}},
23
+ {"type": "function", "function": {"name": "get_exchange_rate", "description": "查询两种货币之间的实时汇率", "parameters": {"type": "object", "properties": {"from_currency": {"type": "string", "description": "源货币代码,如USD、CNY、EUR"}, "to_currency": {"type": "string", "description": "目标货币代码,如USD、CNY、EUR"}}, "required": ["from_currency", "to_currency"]}}},
24
+ {"type": "function", "function": {"name": "translate_text", "description": "将文本翻译成目标语言", "parameters": {"type": "object", "properties": {"text": {"type": "string", "description": "要翻译的文本"}, "target_language": {"type": "string", "description": "目标语言,如english、chinese、japanese、french"}}, "required": ["text", "target_language"]}}},
25
+ ]
26
+
27
+ MOCK_RESULTS = {
28
+ "calculate_math": lambda args: {"result": str(eval(str(args.get("expression", "0")).replace("^", "**").replace("×", "*").replace("÷", "/").replace("−", "-").replace("²", "**2").replace("³", "**3").replace("(", "(").replace(")", ")")))},
29
+ "get_current_time": lambda args: {"datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "timezone": args.get("timezone", "Asia/Shanghai")},
30
+ "random_number": lambda args: {"result": random.randint(int(args.get("min", 0)), int(args.get("max", 100)))},
31
+ "text_length": lambda args: {"characters": len(args.get("text", "")), "words": len(args.get("text", "").split())},
32
+ "unit_converter": lambda args: {"result": round(float(args.get("value", 0)) * 0.621371, 2), "from": f"{args.get('value', 0)} {args.get('from_unit', '')}", "to": args.get("to_unit", "")},
33
+ "get_current_weather": lambda args: {"city": args.get("location"), "temperature": "22°C", "humidity": "65%", "condition": "晴"},
34
+ "get_exchange_rate": lambda args: {"from": args.get("from_currency", ""), "to": args.get("to_currency", ""), "rate": 7.15},
35
+ "translate_text": lambda args: {"translated": "hello world"},
36
+ }
37
+
38
+ TOOL_MAP = {t["function"]["name"]: t for t in TOOLS}
39
+
40
+ def get_tools(names):
41
+ return [TOOL_MAP[n] for n in names]
42
+
43
+ TEST_CASES = [
44
+ {"prompt": "帮我算一下 256 乘以 37 等于多少", "tools": ["calculate_math", "get_current_time"]},
45
+ {"prompt": "现在几点了?", "tools": ["get_current_time", "random_number"]},
46
+ {"prompt": "帮我把100公里换算成英里", "tools": ["unit_converter", "calculate_math"]},
47
+ {"prompt": "帮我生成一个1到1000的随机数,然后计算它的平方", "tools": ["random_number", "calculate_math", "text_length"]},
48
+ {"prompt": "北京今天天气怎么样?", "tools": ["get_current_weather", "get_current_time"]},
49
+ {"prompt": "查一下美元兑人民币汇率", "tools": ["get_exchange_rate", "get_current_time"]},
50
+ {"prompt": "把'你好世界'翻译成英文", "tools": ["translate_text", "text_length"]},
51
+ {"prompt": "What is the weather in Tokyo? Also convert 30 celsius to fahrenheit.", "tools": ["get_current_weather", "unit_converter", "get_current_time"]},
52
+ ]
53
+
54
+
55
+ def init_model(args):
56
+ tokenizer = AutoTokenizer.from_pretrained(args.load_from)
57
+ if 'model' in args.load_from:
58
+ model = MiniMindForCausalLM(MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe)))
59
+ moe_suffix = '_moe' if args.use_moe else ''
60
+ ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
61
+ model.load_state_dict(torch.load(ckp, map_location=args.device), strict=True)
62
+ else:
63
+ model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
64
+ get_model_params(model, model.config)
65
+ return model.half().eval().to(args.device), tokenizer
66
+
67
+
68
+ def parse_tool_calls(text):
69
+ matches = re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL)
70
+ calls = []
71
+ for m in matches:
72
+ try:
73
+ calls.append(json.loads(m.strip()))
74
+ except Exception:
75
+ pass
76
+ return calls
77
+
78
+
79
+ def parse_tool_call_from_text(content):
80
+ pattern = r'<tool_call>\s*(\{.*?\})\s*</tool_call>'
81
+ matches = re.findall(pattern, content, re.DOTALL)
82
+ if not matches:
83
+ return None
84
+ tool_calls = []
85
+ for i, match in enumerate(matches):
86
+ try:
87
+ data = json.loads(match)
88
+ tool_calls.append({
89
+ "id": f"call_{i}",
90
+ "function": {"name": data.get("name", ""), "arguments": json.dumps(data.get("arguments", {}), ensure_ascii=False)}
91
+ })
92
+ except Exception:
93
+ pass
94
+ return tool_calls if tool_calls else None
95
+
96
+
97
+ def execute_tool(call, arguments=None):
98
+ name = call.get("name", "") if isinstance(call, dict) else call
99
+ try:
100
+ raw_args = call.get("arguments", {}) if isinstance(call, dict) else arguments
101
+ args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
102
+ except Exception:
103
+ args = {}
104
+ fn = MOCK_RESULTS.get(name)
105
+ if not fn:
106
+ return {"error": f"未知工具: {name}"}
107
+ try:
108
+ return fn(args)
109
+ except Exception as e:
110
+ return {"error": f"工具执行失败: {str(e)[:80]}"}
111
+
112
+
113
+ def generate(model, tokenizer, messages, tools, args):
114
+ streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
115
+ input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, tools=tools, open_thinking=False)
116
+ inputs = tokenizer(input_text, return_tensors="pt", truncation=True).to(args.device)
117
+ st = time.time()
118
+ print('🧠: ', end='')
119
+ generated_ids = model.generate(
120
+ inputs["input_ids"], attention_mask=inputs["attention_mask"],
121
+ max_new_tokens=args.max_new_tokens, do_sample=True, streamer=streamer,
122
+ pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id,
123
+ top_p=args.top_p, temperature=args.temperature
124
+ )
125
+ response = tokenizer.decode(generated_ids[0][len(inputs["input_ids"][0]):], skip_special_tokens=True)
126
+ gen_tokens = len(generated_ids[0]) - len(inputs["input_ids"][0])
127
+ print(f'\n[Speed]: {gen_tokens / (time.time() - st):.2f} tokens/s') if args.show_speed else print()
128
+ return response
129
+
130
+
131
+ def chat_api(client, messages, tools, args, stream=True):
132
+ response = client.chat.completions.create(
133
+ model=args.api_model, messages=messages, tools=tools,
134
+ stream=stream, temperature=args.temperature,
135
+ max_tokens=8192, top_p=args.top_p
136
+ )
137
+ if not stream:
138
+ choice = response.choices[0]
139
+ content = choice.message.content or ""
140
+ tool_calls = choice.message.tool_calls
141
+ if not tool_calls:
142
+ tool_calls = parse_tool_call_from_text(content)
143
+ print(f'🧠: {content}')
144
+ return content, tool_calls
145
+ print('🧠: ', end='', flush=True)
146
+ content, tool_calls = "", None
147
+ for chunk in response:
148
+ delta = chunk.choices[0].delta
149
+ if delta.content:
150
+ print(delta.content, end="", flush=True)
151
+ content += delta.content
152
+ if delta.tool_calls:
153
+ if tool_calls is None:
154
+ tool_calls = []
155
+ for tc_chunk in delta.tool_calls:
156
+ idx = tc_chunk.index if tc_chunk.index is not None else len(tool_calls)
157
+ while len(tool_calls) <= idx:
158
+ tool_calls.append({
159
+ "id": "",
160
+ "function": {"name": "", "arguments": ""}
161
+ })
162
+ if tc_chunk.id:
163
+ tool_calls[idx]["id"] += tc_chunk.id
164
+ if tc_chunk.function:
165
+ if tc_chunk.function.name:
166
+ tool_calls[idx]["function"]["name"] += tc_chunk.function.name
167
+ if tc_chunk.function.arguments:
168
+ tool_calls[idx]["function"]["arguments"] += tc_chunk.function.arguments
169
+ print()
170
+ if not tool_calls:
171
+ tool_calls = parse_tool_call_from_text(content)
172
+ return content, tool_calls
173
+
174
+
175
+ def run_case(prompt, tools, args, model=None, tokenizer=None, client=None):
176
+ messages = [{"role": "user", "content": prompt}]
177
+ while True:
178
+ if args.backend == 'local':
179
+ content = generate(model, tokenizer, messages, tools, args)
180
+ tool_calls = parse_tool_calls(content)
181
+ else:
182
+ content, tool_calls = chat_api(client, messages, tools, args, stream=bool(args.stream))
183
+ if not tool_calls:
184
+ break
185
+ tool_calls = [{
186
+ "id": tc.id if hasattr(tc, 'id') else tc.get("id", ""),
187
+ "name": tc.function.name if hasattr(tc, 'function') else tc["function"]["name"],
188
+ "arguments": tc.function.arguments if hasattr(tc, 'function') else tc["function"]["arguments"]
189
+ } for tc in tool_calls] if args.backend == 'api' else tool_calls
190
+ messages.append({"role": "assistant", "content": content} if args.backend == 'local' else {"role": "assistant", "content": content, "tool_calls": [{"id": tc["id"], "type": "function", "function": {"name": tc["name"], "arguments": tc["arguments"]}} for tc in tool_calls]})
191
+ for tc in tool_calls:
192
+ name = tc["name"]
193
+ arguments = tc["arguments"]
194
+ print(f'📞 [Tool Calling]: {name} | args={arguments}')
195
+ result = execute_tool(tc if args.backend == 'local' else name, arguments)
196
+ print(f'✅ [Tool Called]: {json.dumps(result, ensure_ascii=False)}')
197
+ messages.append({"role": "tool", "content": json.dumps(result, ensure_ascii=False)} if args.backend == 'local' else {"role": "tool", "content": json.dumps(result, ensure_ascii=False), "tool_call_id": tc["id"]})
198
+
199
+
200
+ def main():
201
+ parser = argparse.ArgumentParser(description="MiniMind ToolCall评测")
202
+ parser.add_argument('--backend', default='local', choices=['local', 'api'], type=str, help="推理后端(local=本地模型,api=OpenAI兼容接口)")
203
+ parser.add_argument('--load_from', default='../model', type=str, help="模型加载路径(model=原生torch权重,其他路径=transformers格式)")
204
+ parser.add_argument('--save_dir', default='../out', type=str, help="模型权重目录")
205
+ parser.add_argument('--weight', default='full_sft', type=str, help="权重名称前缀(pretrain, full_sft, rlhf, reason, ppo_actor, grpo, spo)")
206
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
207
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
208
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
209
+ parser.add_argument('--max_new_tokens', default=512, type=int, help="最大生成长度")
210
+ parser.add_argument('--temperature', default=0.9, type=float, help="生成温度,控制随机性(0-1,越大越随机)")
211
+ parser.add_argument('--top_p', default=0.9, type=float, help="nucleus采样阈值(0-1)")
212
+ parser.add_argument('--show_speed', default=0, type=int, help="显示decode速度(tokens/s)")
213
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', type=str, help="运行设备")
214
+ parser.add_argument('--api_base_url', default="http://localhost:11434/v1", type=str, help="OpenAI兼容接口的base_url")
215
+ parser.add_argument('--api_key', default='sk-123', type=str, help="OpenAI兼容接口的api_key")
216
+ parser.add_argument('--api_model', default='jingyaogong/minimind-3:latest', type=str, help="API请求时使用的模型名称")
217
+ parser.add_argument('--stream', default=1, type=int, help="API模式下是否流式输出(0=否,1=是)")
218
+ args = parser.parse_args()
219
+
220
+ model = tokenizer = client = None
221
+ if args.backend == 'local': model, tokenizer = init_model(args)
222
+ else: client = OpenAI(api_key=args.api_key, base_url=args.api_base_url)
223
+
224
+ input_mode = int(input('[0] 自动测试\n[1] 手动输入\n'))
225
+
226
+ cases = [{"prompt": case["prompt"], "tools": get_tools(case["tools"]), "tool_names": case["tools"]} for case in TEST_CASES] if input_mode == 0 else iter(lambda: {"prompt": input('💬: '), "tools": TOOLS, "tool_names": [t["function"]["name"] for t in TOOLS]}, {"prompt": "", "tools": TOOLS, "tool_names": []})
227
+ for case in cases:
228
+ if not case["prompt"]: break
229
+ setup_seed(random.randint(0, 31415926))
230
+ if input_mode == 0:
231
+ print(f'📦 可用工具: {case["tool_names"]}\n')
232
+ print(f'💬: {case["prompt"]}')
233
+ run_case(case["prompt"], case["tools"], args, model=model, tokenizer=tokenizer, client=client)
234
+ print('\n' + '-' * 50 + '\n')
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
examples/eval_vlm.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import argparse
3
+ import os
4
+ import warnings
5
+ import torch
6
+ import random
7
+ from PIL import Image
8
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer
9
+ from omni.models import MiniMindVLM, VLMConfig
10
+ from omni.utils import setup_seed, get_vlm_model_params
11
+ warnings.filterwarnings('ignore')
12
+
13
+ def init_model(args):
14
+ tokenizer = AutoTokenizer.from_pretrained(args.load_from, trust_remote_code=True)
15
+ if 'model' in args.load_from:
16
+ moe_suffix = '_moe' if args.use_moe else ''
17
+ ckp = f'./{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
18
+ model = MiniMindVLM(
19
+ VLMConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe)),
20
+ vision_model_path="./model/siglip2-base-p32-256-ve"
21
+ )
22
+ state_dict = torch.load(ckp, map_location=args.device)
23
+ model.load_state_dict({k: v for k, v in state_dict.items() if 'mask' not in k}, strict=False)
24
+ else:
25
+ model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
26
+ model.vision_encoder, model.processor = MiniMindVLM.get_vision_model("./model/siglip2-base-p32-256-ve")
27
+ get_vlm_model_params(model, model.config)
28
+ model = model.eval()
29
+ if "cuda" in args.device: model = model.half()
30
+ return model.to(args.device), tokenizer, model.processor
31
+
32
+
33
+ def main():
34
+ parser = argparse.ArgumentParser(description="MiniMind-V Chat")
35
+ parser.add_argument('--load_from', default='model', type=str, help="模型加载路径(model=原生torch权重,其他路径=transformers格式)")
36
+ parser.add_argument('--save_dir', default='out', type=str, help="模型权重目录")
37
+ parser.add_argument('--weight', default='sft_vlm', type=str, help="权重名称前缀(pretrain_vlm, sft_vlm)")
38
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
39
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
40
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
41
+ parser.add_argument('--max_new_tokens', default=512, type=int, help="最大生成长度")
42
+ parser.add_argument('--temperature', default=0.7, type=float, help="生成温度,控制随机性(0-1,越大越随机)")
43
+ parser.add_argument('--top_p', default=0.85, type=float, help="nucleus采样阈值(0-1)")
44
+ parser.add_argument('--image_dir', default='./dataset/eval_images/', type=str, help="测试图像目录")
45
+ parser.add_argument('--show_speed', default=1, type=int, help="显示decode速度(tokens/s)")
46
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', type=str, help="运行设备")
47
+ parser.add_argument('--open_thinking', default=0, type=int, help="是否开启自适应思考(0=否,1=是)")
48
+ args = parser.parse_args()
49
+
50
+ model, tokenizer, preprocess = init_model(args)
51
+ streamer = TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
52
+ # 自动测试image_dir中的所有图像
53
+ prompt = "<image>\n请描述这张图中的主要物体和场景。"
54
+ for image_file in sorted(os.listdir(args.image_dir)):
55
+ if image_file.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')):
56
+ setup_seed(random.randint(1, 31415926))
57
+ image_path = os.path.join(args.image_dir, image_file)
58
+ image = Image.open(image_path).convert('RGB')
59
+ pixel_values = {k: v.to(args.device) for k, v in MiniMindVLM.image2tensor(image, preprocess).items()}
60
+
61
+ messages = [{"role": "user", "content": prompt.replace('<image>', model.config.image_special_token * model.config.image_token_len)}]
62
+ inputs_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, open_thinking=bool(args.open_thinking))
63
+ inputs = tokenizer(inputs_text, return_tensors="pt", truncation=True).to(args.device)
64
+
65
+ print(f'[图像]: {image_file}')
66
+ print(f"💬: {repr(prompt)}")
67
+ print('🤖: ', end='')
68
+ st = time.time()
69
+ generated_ids = model.generate(
70
+ inputs=inputs["input_ids"], attention_mask=inputs["attention_mask"],
71
+ max_new_tokens=args.max_new_tokens, do_sample=True, streamer=streamer,
72
+ pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id,
73
+ top_p=args.top_p, temperature=args.temperature, pixel_values=pixel_values
74
+ )
75
+ gen_tokens = len(generated_ids[0]) - len(inputs["input_ids"][0])
76
+ print(f'\n[Speed]: {gen_tokens / (time.time() - st):.2f} tokens/s\n\n') if args.show_speed else print('\n\n')
77
+
78
+ if __name__ == "__main__":
79
+ main()
examples/omni_web_demo.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse, os, sys, json, time, math, torch, threading, queue, base64, io, logging, contextlib
2
+ import numpy as np
3
+ from PIL import Image
4
+ from transformers import AutoTokenizer, AutoModelForCausalLM
5
+ from omni.models import MiniMindOmni
6
+ from omni.serve.realtime import RealtimeSession
7
+ from omni.utils import log_model_params
8
+ logging.getLogger().setLevel(logging.ERROR)
9
+
10
+ M = {} # model / tokenizer / device / mimi / asr / cfg
11
+ V = {} # voice_name -> {ref_codes, spk_emb}
12
+ V_builtin, V_unseen, V_manual = [], [], []
13
+ MODEL_LOCK = threading.Lock()
14
+ SAMPLES_PER_FRAME = 1920
15
+ REF_FRAMES = 300
16
+ CLONE_VOICE = 'voice_clone'
17
+ CLONE_FILE = 'voice_clone.pt'
18
+
19
+ # -------- helpers --------
20
+ def sse(d): return f"data: {json.dumps(d)}\n\n"
21
+
22
+ def scan_hf_models(base_dir):
23
+ models = {}
24
+ base_dir = os.path.abspath(base_dir)
25
+ for d in sorted(os.listdir(base_dir), reverse=True):
26
+ full_path = os.path.join(base_dir, d)
27
+ if not os.path.isdir(full_path) or d.startswith('.') or d.startswith('_'):
28
+ continue
29
+ files = set(os.listdir(full_path))
30
+ has_model = bool(files & {'pytorch_model.bin', 'model.safetensors', 'pytorch_model.bin.index.json', 'model.safetensors.index.json'})
31
+ if has_model:
32
+ models[d] = full_path
33
+ return models
34
+
35
+ def asr_run(samples):
36
+ from funasr.utils.postprocess_utils import rich_transcription_postprocess
37
+ r = M['asr'].generate(input=samples, cache={}, language='auto', use_itn=True)
38
+ return rich_transcription_postprocess(r[0]['text']).strip() if r else ''
39
+
40
+ def prep_audio(samples):
41
+ m = M['model']
42
+ proc = m.audio_processor(samples, sampling_rate=16000, return_tensors="pt", return_attention_mask=True)
43
+ mel = proc.input_features.squeeze(0).unsqueeze(0).to(M['device'])
44
+ vlen = proc.attention_mask.sum().item()
45
+ prompt = m.config.audio_special_token * (vlen or 1)
46
+ return mel, torch.tensor([vlen], device=M['device']), prompt
47
+
48
+ def prep_image(b64):
49
+ img = Image.open(io.BytesIO(base64.b64decode(b64))).convert('RGB')
50
+ return {k: v.to(M['device']) for k, v in M['model'].vision_processor(images=img, return_tensors="pt").items()}
51
+
52
+ def build_ids(prompt, history):
53
+ tok, dev, n = M['tokenizer'], M['device'], M['cfg'].max_history_turns
54
+ hist = history[-n:] if n > 0 else []
55
+ msgs = hist + [{"role": "user", "content": prompt}]
56
+ t = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
57
+ return torch.tensor(tok(t).data['input_ids'], dtype=torch.long, device=dev)[None, ...]
58
+
59
+ def _mimi_decode(frames):
60
+ codes = [f for f in frames if f and len(f) == 8]
61
+ if not codes or not M['mimi']: return None
62
+ mc = torch.tensor(codes, dtype=torch.long, device=M['device']).T.unsqueeze(0)
63
+ mc = torch.where(mc >= 2049, torch.zeros_like(mc), mc)
64
+ with torch.no_grad():
65
+ au = M['mimi'].decode(mc).audio_values.squeeze().cpu().numpy()
66
+ return au, mc.shape[-1]
67
+
68
+ def pcm_bytes(frames, ov):
69
+ r = _mimi_decode(frames)
70
+ if r is None: return None
71
+ au, T = r
72
+ if ov > 0: au = au[int(ov * len(au) / T):]
73
+ return (au * 32767).astype('int16').tobytes()
74
+
75
+ def stream_pcm(frames, flush=False):
76
+ """yield (pcm_bytes,) on chunk boundaries or on final flush."""
77
+ if not M['mimi']: return
78
+ cf, ov_max, n = M['cfg'].audio_chunk_frames, M['cfg'].audio_overlap, len(frames)
79
+ if not flush and n >= cf and n % cf == 0:
80
+ ov = min(ov_max, n - cf)
81
+ p = pcm_bytes(frames[-(cf + ov):], ov)
82
+ if p: yield p
83
+ elif flush:
84
+ rem = n % cf
85
+ if rem:
86
+ ov = min(ov_max, n - rem)
87
+ p = pcm_bytes(frames[-(rem + ov):], ov)
88
+ if p: yield p
89
+
90
+ def voice_args(name):
91
+ if name and name != 'default' and name in V:
92
+ v = V[name]
93
+ dev = M['device']
94
+ rc = v['ref_codes'].unsqueeze(0).to(dev)
95
+ se = v['spk_emb'].half().unsqueeze(0).to(dev) if 'spk_emb' in v else None
96
+ return {'ref_codes': rc, 'spk_emb': se}
97
+ return {}
98
+
99
+ def register_voice(name, value, group='manual'):
100
+ V[name] = value
101
+ groups = {'builtin': V_builtin, 'unseen': V_unseen, 'manual': V_manual}
102
+ dst = groups[group]
103
+ if name not in dst:
104
+ dst.append(name)
105
+ for k, lst in groups.items():
106
+ if k != group and name in lst:
107
+ lst.remove(name)
108
+
109
+ def clone_voice_path():
110
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'speaker', CLONE_FILE)
111
+
112
+ def delete_manual_voice(name):
113
+ if name not in V_manual:
114
+ raise RuntimeError('只能删除手动克隆的音色')
115
+ out_path = clone_voice_path()
116
+ saved = torch.load(out_path, map_location='cpu') if os.path.exists(out_path) else {}
117
+ if name in saved:
118
+ saved.pop(name)
119
+ torch.save(saved, out_path)
120
+ V.pop(name, None)
121
+ if name in V_manual:
122
+ V_manual.remove(name)
123
+
124
+ def normalize_voice_name(name):
125
+ name = ' '.join(str(name or '').split())
126
+ if not name:
127
+ name = CLONE_VOICE
128
+ if len(name) > 24:
129
+ raise RuntimeError('音色名太长,建议控制在 24 个字以内')
130
+ if name.lower() == 'default':
131
+ raise RuntimeError('default 是保留名称,请换一个')
132
+ if name in V_builtin or name in V_unseen:
133
+ raise RuntimeError('该名称已被现有音色占用,请换一个')
134
+ return name
135
+
136
+ def validate_clone_audio(w16):
137
+ if w16.numel() < int(16000 * 1.8):
138
+ raise RuntimeError('录音太短,请把整句话读完')
139
+ peak = w16.abs().max().item()
140
+ frame, hop = 800, 400
141
+ if w16.numel() >= frame:
142
+ rms = w16.unfold(0, frame, hop).pow(2).mean(dim=1).sqrt().cpu().numpy()
143
+ else:
144
+ rms = np.array([w16.pow(2).mean().sqrt().item()])
145
+ hi = float(np.quantile(rms, 0.95))
146
+ lo = float(np.quantile(rms, 0.2))
147
+ if hi < 0.008:
148
+ raise RuntimeError('录音太轻,请靠近麦克风一点')
149
+ if hi > 0 and lo / hi > 0.45:
150
+ raise RuntimeError('环境噪声太大,请换安静一点的环境')
151
+ if peak > 0.995:
152
+ raise RuntimeError('录音有爆音,请离麦克风远一点')
153
+
154
+ def build_clone_voice(audio_b64):
155
+ if M.get('mimi') is None or M.get('campplus') is None or M.get('mel_fn') is None:
156
+ raise RuntimeError('Mimi 或 CAM++ 未加载')
157
+ from pydub import AudioSegment
158
+ seg = AudioSegment.from_file(io.BytesIO(base64.b64decode(audio_b64))).set_channels(1).set_sample_width(2)
159
+ if len(seg) < 1000:
160
+ raise RuntimeError('录音太短,至少读 1 秒')
161
+ try:
162
+ seg = seg.speedup(playback_speed=1.5, chunk_size=150, crossfade=25)
163
+ except Exception:
164
+ seg = seg.speedup(playback_speed=1.5)
165
+ seg24 = seg.set_frame_rate(24000)
166
+ seg16 = seg.set_frame_rate(16000)
167
+ w24 = torch.tensor(np.frombuffer(seg24.raw_data, dtype=np.int16).astype(np.float32) / 32768.0)
168
+ w16 = torch.tensor(np.frombuffer(seg16.raw_data, dtype=np.int16).astype(np.float32) / 32768.0)
169
+ validate_clone_audio(w16)
170
+ mimi_dev = next(M['mimi'].parameters()).device
171
+ mimi_dtype = torch.float16 if mimi_dev.type != 'cpu' else torch.float32
172
+ with torch.inference_mode():
173
+ t = w24.unsqueeze(0).unsqueeze(0).to(device=mimi_dev, dtype=mimi_dtype)
174
+ codes = M['mimi'].encode(t).audio_codes
175
+ nf = math.ceil(w24.shape[-1] / SAMPLES_PER_FRAME)
176
+ ref_codes = codes[0, :8, :nf].cpu()[:, :min(nf, REF_FRAMES)]
177
+ with torch.no_grad():
178
+ mel = M['mel_fn'](w16.unsqueeze(0).to(M['device']))
179
+ feat = mel.clamp(min=1e-10).log().transpose(1, 2)
180
+ feat = feat - feat.mean(dim=1, keepdim=True)
181
+ spk_emb = M['campplus'](feat).squeeze(0).cpu()
182
+ return {'ref_codes': ref_codes, 'spk_emb': spk_emb}
183
+
184
+ def run_generate(x, audio_inputs, audio_lens, pixel_values, **kw):
185
+ with MODEL_LOCK, torch.no_grad():
186
+ yield from M['model'].generate(
187
+ x, M['tokenizer'].eos_token_id, stream=True, return_audio_codes=True,
188
+ audio_inputs=audio_inputs, audio_lens=audio_lens, pixel_values=pixel_values, **kw)
189
+
190
+ def load_main_model(model_path, model_name):
191
+ with MODEL_LOCK:
192
+ [sys.modules.pop(k) for k in list(sys.modules) if 'transformers_modules' in k]
193
+ M.pop('model', None); M.pop('tokenizer', None)
194
+ if torch.cuda.is_available(): torch.cuda.empty_cache()
195
+ tok = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
196
+ m = AutoModelForCausalLM.from_pretrained(model_path, trust_remote_code=True)
197
+ vision_encoder, vision_processor = MiniMindOmni.load_vision('../model/siglip2-base-p32-256-ve')
198
+ audio_encoder, audio_processor = MiniMindOmni.load_sensevoice('../model/SenseVoiceSmall')
199
+ object.__setattr__(m, 'vision_encoder', vision_encoder)
200
+ object.__setattr__(m, 'vision_processor', vision_processor)
201
+ object.__setattr__(m, 'audio_encoder', audio_encoder)
202
+ object.__setattr__(m, 'audio_processor', audio_processor)
203
+ m = m.half().eval().to(M['device'])
204
+ if m.audio_encoder: m.audio_encoder.to(M['device'])
205
+ if m.vision_encoder: m.vision_encoder.to(M['device'])
206
+ M['tokenizer'], M['model'], M['model_name'] = tok, m, model_name
207
+ params = sum(p.numel() for p in m.parameters()) / 1e6
208
+ print(f'Loaded model: {model_name} ({params:.2f}M)')
209
+ return round(params, 2)
210
+
211
+ def prepare_turn(text, samples, image_b64, do_asr_for_image):
212
+ """返回 (audio_inputs, audio_lens, pixel_values, prompt_for_model, user_text_for_history, asr_thread, asr_result)"""
213
+ audio_inputs = audio_lens = pixel_values = None
214
+ prompt = text or ''
215
+ user_text = text or ''
216
+ asr_thread, asr_result = None, [None]
217
+ if samples is not None:
218
+ from pydub import AudioSegment
219
+ if image_b64 and do_asr_for_image:
220
+ user_text = asr_run(samples)
221
+ prompt = user_text
222
+ else:
223
+ audio_inputs, audio_lens, prompt = prep_audio(samples)
224
+ if M['cfg'].max_history_turns > 0:
225
+ sa = samples.copy()
226
+ def _a(): asr_result[0] = asr_run(sa)
227
+ asr_thread = threading.Thread(target=_a); asr_thread.start()
228
+ if image_b64:
229
+ pixel_values = prep_image(image_b64)
230
+ m = M['model']
231
+ prompt = (prompt + "\n\n" if prompt else "") + "请描述这张图片\n\n" + m.config.image_special_token * m.config.image_token_len
232
+ return audio_inputs, audio_lens, pixel_values, prompt, user_text, asr_thread, asr_result
233
+
234
+
235
+ # -------- web app (lazy: requires flask/flask_sock at runtime) --------
236
+ def init_web_app():
237
+ from flask import Flask, request, Response, send_from_directory
238
+ from flask_cors import CORS
239
+ from flask_sock import Sock
240
+
241
+ app = Flask(__name__, static_folder='.')
242
+ CORS(app)
243
+ sock = Sock(app)
244
+
245
+ @app.route('/')
246
+ def index(): return send_from_directory('.', 'web_demo.html')
247
+ @app.route('/call')
248
+ def call_page(): return send_from_directory('.', 'web_demo.html')
249
+
250
+ @app.route('/voices')
251
+ def get_voices():
252
+ return json.dumps({'builtin': sorted(V_builtin), 'unseen': sorted(V_unseen), 'manual': sorted(V_manual)})
253
+
254
+ @app.route('/models')
255
+ def get_models():
256
+ return json.dumps({'models': list(M.get('models', {}).keys()), 'current': M.get('model_name')})
257
+
258
+ @app.route('/switch_model', methods=['POST'])
259
+ def switch_model():
260
+ name = (request.json or {}).get('name')
261
+ if name not in M.get('models', {}):
262
+ return Response(json.dumps({'ok': False, 'error': 'unknown model'}), status=400, mimetype='application/json')
263
+ try:
264
+ params = load_main_model(M['models'][name], name)
265
+ return Response(json.dumps({'ok': True, 'model': name, 'params': params}), mimetype='application/json')
266
+ except Exception as e:
267
+ return Response(json.dumps({'ok': False, 'error': str(e)}), status=500, mimetype='application/json')
268
+
269
+ @app.route('/clone_voice', methods=['POST'])
270
+ def clone_voice():
271
+ d = request.json or {}
272
+ if not d.get('audio'):
273
+ return Response(json.dumps({'ok': False, 'error': 'missing audio'}), status=400, mimetype='application/json')
274
+ try:
275
+ name = normalize_voice_name(d.get('name'))
276
+ value = build_clone_voice(d['audio'])
277
+ out_path = clone_voice_path()
278
+ saved = torch.load(out_path, map_location='cpu') if os.path.exists(out_path) else {}
279
+ saved[name] = value
280
+ torch.save(saved, out_path)
281
+ register_voice(name, value, group='manual')
282
+ return Response(json.dumps({'ok': True, 'voice': name, 'path': './model/speaker/' + CLONE_FILE}), mimetype='application/json')
283
+ except Exception as e:
284
+ return Response(json.dumps({'ok': False, 'error': str(e)}), status=500, mimetype='application/json')
285
+
286
+ @app.route('/delete_voice', methods=['POST'])
287
+ def delete_voice():
288
+ d = request.json or {}
289
+ name = ' '.join(str(d.get('name') or '').split())
290
+ if not name:
291
+ return Response(json.dumps({'ok': False, 'error': 'missing name'}), status=400, mimetype='application/json')
292
+ try:
293
+ delete_manual_voice(name)
294
+ return Response(json.dumps({'ok': True, 'voice': name}), mimetype='application/json')
295
+ except Exception as e:
296
+ return Response(json.dumps({'ok': False, 'error': str(e)}), status=500, mimetype='application/json')
297
+
298
+ @app.route('/chat', methods=['POST'])
299
+ def chat():
300
+ d = request.json
301
+ history = d.get('history', [])
302
+ samples = None
303
+ if d.get('audio'):
304
+ seg = AudioSegment.from_file(io.BytesIO(base64.b64decode(d['audio']))).set_frame_rate(16000).set_channels(1).set_sample_width(2)
305
+ samples = np.frombuffer(seg.raw_data, dtype=np.int16).astype(np.float32) / 32768.0
306
+ va = voice_args(d.get('voice', 'default'))
307
+
308
+ def gen():
309
+ audio_inputs, audio_lens, pixel_values, prompt, user_text, asr_th, asr_res = prepare_turn(
310
+ d.get('text', ''), samples, d.get('image'), do_asr_for_image=True)
311
+ x = build_ids(prompt, history)
312
+ asr_sent = False
313
+ if user_text and samples is not None and d.get('image'):
314
+ yield sse({'type': 'user_prompt', 'content': user_text}); asr_sent = True
315
+ frames, text_ttft, audio_ttft = [], None, None
316
+ t0 = time.time(); hi = 0
317
+ for y, af in run_generate(x, audio_inputs, audio_lens, pixel_values,
318
+ max_new_tokens=d.get('max_tokens', 512),
319
+ temperature=d.get('temperature', 1), top_p=0.85, **va):
320
+ if not asr_sent and asr_th and not asr_th.is_alive():
321
+ asr_th.join()
322
+ if asr_res[0]: yield sse({'type': 'user_prompt', 'content': asr_res[0]})
323
+ asr_sent = True
324
+ if y is not None:
325
+ if text_ttft is None:
326
+ text_ttft = (time.time() - t0) * 1000
327
+ yield sse({'type': 'ttft', 'text_ttft': round(text_ttft, 1)})
328
+ ans = M['tokenizer'].decode(y[0].tolist(), skip_special_tokens=True)
329
+ if ans and ans[-1] != '\ufffd' and len(ans) > hi:
330
+ yield sse({'type': 'text', 'content': ans[hi:]}); hi = len(ans)
331
+ if af:
332
+ if audio_ttft is None:
333
+ audio_ttft = (time.time() - t0) * 1000
334
+ yield sse({'type': 'ttft', 'audio_ttft': round(audio_ttft, 1)})
335
+ frames.append(af)
336
+ for pcm in stream_pcm(frames):
337
+ b64 = base64.b64encode(pcm).decode()
338
+ for i in range(0, len(b64), 2000):
339
+ yield sse({'type': 'pcm', 'c': b64[i:i+2000], 'd': i+2000 >= len(b64)})
340
+ for pcm in stream_pcm(frames, flush=True):
341
+ b64 = base64.b64encode(pcm).decode()
342
+ for i in range(0, len(b64), 2000):
343
+ yield sse({'type': 'pcm', 'c': b64[i:i+2000], 'd': i+2000 >= len(b64)})
344
+ if not asr_sent:
345
+ if asr_th:
346
+ asr_th.join()
347
+ if asr_res[0]: yield sse({'type': 'user_prompt', 'content': asr_res[0]})
348
+ else:
349
+ yield sse({'type': 'user_prompt', 'content': prompt})
350
+ yield sse({'type': 'done'})
351
+
352
+ return Response(gen(), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
353
+
354
+ @sock.route('/ws/realtime')
355
+ def realtime(ws):
356
+ session = RealtimeSession(M['vad_path'])
357
+ q = queue.Queue(); alive = [True]; state = {'history': [], 'image': None}
358
+ n_hist = M['cfg'].max_history_turns
359
+
360
+ def push_audio(data):
361
+ return session.push_chunk(np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0)
362
+
363
+ def set_ctx(msg):
364
+ h = msg.get('history') or []
365
+ state['history'] = h[-n_hist:] if n_hist > 0 else []
366
+ if 'image' in msg: state['image'] = msg.get('image')
367
+ if 'voice' in msg: state['voice'] = msg.get('voice', 'default')
368
+
369
+ def poll_interrupt():
370
+ while True:
371
+ try: data = q.get_nowait()
372
+ except queue.Empty: return False
373
+ if isinstance(data, bytes):
374
+ if push_audio(data) == 'interrupt': return True
375
+ ws.send(json.dumps({'type': 'vad', 'speaking': session.speaking}))
376
+ else:
377
+ m = json.loads(data)
378
+ if m.get('type') == 'context': set_ctx(m)
379
+ elif m.get('type') in ('stop', 'end'):
380
+ if m['type'] == 'end': alive[0] = False
381
+ session.interrupt = True; return True
382
+
383
+ def recv_loop():
384
+ while alive[0]:
385
+ try:
386
+ data = ws.receive(timeout=1)
387
+ if data is None: alive[0] = False; break
388
+ q.put(data)
389
+ except: alive[0] = False; break
390
+
391
+ threading.Thread(target=recv_loop, daemon=True).start()
392
+ try:
393
+ while alive[0]:
394
+ try: data = q.get(timeout=0.05)
395
+ except queue.Empty: continue
396
+ if isinstance(data, str):
397
+ m = json.loads(data)
398
+ if m.get('type') == 'context': set_ctx(m)
399
+ elif m.get('type') == 'stop': session.interrupt = True
400
+ elif m.get('type') == 'end': break
401
+ continue
402
+ if session.generating:
403
+ push_audio(data); ws.send(json.dumps({'type': 'vad', 'speaking': session.speaking})); continue
404
+ status = push_audio(data)
405
+ ws.send(json.dumps({'type': 'vad', 'speaking': session.speaking}))
406
+ if status != 'speech_end': continue
407
+
408
+ session.generating = True
409
+ audio = session.get_audio()
410
+ ws.send(json.dumps({'type': 'generating'}))
411
+ audio_inputs, audio_lens, pixel_values, prompt, user_text, asr_th, asr_res = prepare_turn(
412
+ '', audio, state['image'], do_asr_for_image=True)
413
+ if state['image']: state['image'] = None
414
+ x = build_ids(prompt, state['history'])
415
+ va_rt = voice_args(state.get('voice', 'default'))
416
+
417
+ frames, full_text, interrupted = [], '', False
418
+ for y, af in run_generate(x, audio_inputs, audio_lens, pixel_values,
419
+ max_new_tokens=512, temperature=0.7, **va_rt):
420
+ if poll_interrupt() or session.interrupt: interrupted = True; break
421
+ if y is not None:
422
+ ans = M['tokenizer'].decode(y[0].tolist(), skip_special_tokens=True)
423
+ if ans and ans[-1] != '\ufffd' and len(ans) > len(full_text):
424
+ ws.send(json.dumps({'type': 'text', 'content': ans[len(full_text):]})); full_text = ans
425
+ if af:
426
+ frames.append(af)
427
+ for pcm in stream_pcm(frames):
428
+ ws.send(json.dumps({'type': 'pcm', 'data': base64.b64encode(pcm).decode()}))
429
+ if not interrupted:
430
+ for pcm in stream_pcm(frames, flush=True):
431
+ ws.send(json.dumps({'type': 'pcm', 'data': base64.b64encode(pcm).decode()}))
432
+ if asr_th:
433
+ asr_th.join(); user_text = asr_res[0] or user_text
434
+ if n_hist > 0:
435
+ if user_text: state['history'].append({'role': 'user', 'content': user_text})
436
+ if full_text: state['history'].append({'role': 'assistant', 'content': full_text})
437
+ state['history'] = state['history'][-n_hist:]
438
+ ws.send(json.dumps({'type': 'done', 'interrupted': interrupted or session.interrupt}))
439
+ session.generating = False; session.interrupt = False
440
+ finally:
441
+ alive[0] = False
442
+
443
+ return app
444
+
445
+
446
+ def init_model(args):
447
+ M['cfg'] = args; M['device'] = args.device
448
+ with contextlib.redirect_stdout(io.StringIO()):
449
+ from funasr import AutoModel
450
+ from funasr.utils.postprocess_utils import rich_transcription_postprocess
451
+ M['asr'] = AutoModel(model='../model/SenseVoiceSmall', trust_remote_code=True, device=args.device, disable_update=True)
452
+ M['models'] = scan_hf_models(args.load_from)
453
+ if not M['models']:
454
+ raise RuntimeError(f"未在 {os.path.abspath(args.load_from)} 找到 transformers 模型")
455
+ model_name = next(iter(M['models']))
456
+ load_main_model(M['models'][model_name], model_name)
457
+ try:
458
+ import torchaudio
459
+ from transformers import MimiModel
460
+ M['mimi'] = MimiModel.from_pretrained('../model/mimi').eval().to(args.device)
461
+ if args.device != 'cpu': M['mimi'] = M['mimi'].half()
462
+ print('Mimi model loaded')
463
+ except Exception:
464
+ M['mimi'] = None
465
+ try:
466
+ from modelscope.models.audio.sv.DTDNN import CAMPPlus
467
+ M['campplus'] = CAMPPlus(feat_dim=80, embedding_size=192, growth_rate=32, bn_size=4,
468
+ init_channels=128, config_str='batchnorm-relu', memory_efficient=True)
469
+ sd = torch.load('../model/campplus/campplus_cn_common.pt', map_location='cpu')
470
+ M['campplus'].load_state_dict({k: v.float() for k, v in sd.items()})
471
+ M['campplus'] = M['campplus'].eval().to(args.device)
472
+ import torchaudio
473
+ M['mel_fn'] = torchaudio.transforms.MelSpectrogram(
474
+ sample_rate=16000, n_fft=512, win_length=400, hop_length=160,
475
+ n_mels=80, f_min=20, f_max=7600, norm='slaney', mel_scale='slaney',
476
+ ).to(args.device)
477
+ print('CAM++ loaded')
478
+ except Exception as e:
479
+ M['campplus'], M['mel_fn'] = None, None
480
+ print(f'CAM++ load failed: {e}')
481
+ M['vad_path'] = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'vad', 'silero_vad.onnx')
482
+ spk_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'model', 'speaker')
483
+ for fn, group in [('voices.pt', 'builtin'), ('voices_unseen.pt', 'unseen'), (CLONE_FILE, 'manual')]:
484
+ fp = os.path.join(spk_dir, fn)
485
+ if os.path.exists(fp):
486
+ for speaker, v in torch.load(fp, map_location=args.device).items():
487
+ if speaker not in V or fn == CLONE_FILE:
488
+ register_voice(speaker, v, group=group)
489
+ if V: print(f'Loaded {len(V)} voices: builtin={sorted(V_builtin)}, unseen={sorted(V_unseen)}, manual={sorted(V_manual)}')
490
+ log_model_params(M['model'])
491
+ print('Warmup...')
492
+ with torch.no_grad():
493
+ ids = torch.tensor([[1, 2, 3]], device=args.device)
494
+ au = torch.full((1, 8, 3), 2049, dtype=torch.long, device=args.device)
495
+ M['model'].forward(torch.cat((au, ids.unsqueeze(1)), dim=1))
496
+ if M['model'].audio_encoder: M['model'].audio_encoder(torch.zeros(1, 100, 560, device=args.device), torch.tensor([100], device=args.device))
497
+ if M['mimi']: M['mimi'].decode(torch.zeros(1, 8, 1, dtype=torch.long, device=args.device))
498
+ print('Warmup done!')
499
+
500
+
501
+ if __name__ == '__main__':
502
+ p = argparse.ArgumentParser()
503
+ p.add_argument('--load_from', default='./', help='模型权重搜索目录;目录下可放多个 HF 格式模型,WebUI 会自动扫描并允许切换。')
504
+ p.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', help='推理设备;CUDA 可用时默认 cuda。显存不足或排查环境问题时可改为 cpu。')
505
+ p.add_argument('--port', default=7860, type=int, help='WebUI 服务端口;端口被占用或需要同时启动多个实例时调整。')
506
+ p.add_argument('--audio_chunk_frames', default=4, type=int, help='流式播放每次解码的 Mimi frame 数;默认 4 约 320ms。WebUI 播放卡顿时可调大到 8/12,低延迟优先时保持 4。')
507
+ p.add_argument('--audio_overlap', default=2, type=int, help='分块 Mimi 解码的重叠帧数;默认 2 用于缓解块边界断裂。一般不需要调整,边界杂音明显时可适当增大。')
508
+ p.add_argument('--max_history_turns', default=0, type=int, help='对话历史轮数;默认 0 不带历史以降低延迟和显存。需要多轮上下文时调大,但会增加 prefill 成本。')
509
+ args = p.parse_args()
510
+ init_model(args)
511
+ app = init_web_app()
512
+ app.run(host='0.0.0.0', port=args.port, threaded=True)
examples/serve_openai_api.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import re
4
+ import os
5
+
6
+ import time
7
+ import torch
8
+ import warnings
9
+ import uvicorn
10
+
11
+ from threading import Thread
12
+ from queue import Queue
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.responses import StreamingResponse
15
+ from pydantic import BaseModel, Field
16
+ from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer
17
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
18
+ from omni.models.lora import apply_lora, load_lora
19
+
20
+ warnings.filterwarnings('ignore')
21
+
22
+ app = FastAPI()
23
+
24
+
25
+ def init_model(args):
26
+ tokenizer = AutoTokenizer.from_pretrained(args.load_from)
27
+ if 'model' in args.load_from:
28
+ moe_suffix = '_moe' if args.use_moe else ''
29
+ ckp = f'../{args.save_dir}/{args.weight}_{args.hidden_size}{moe_suffix}.pth'
30
+ model = MiniMindForCausalLM(MiniMindConfig(
31
+ hidden_size=args.hidden_size,
32
+ num_hidden_layers=args.num_hidden_layers,
33
+ max_seq_len=args.max_seq_len,
34
+ use_moe=bool(args.use_moe),
35
+ inference_rope_scaling=args.inference_rope_scaling
36
+ ))
37
+ model.load_state_dict(torch.load(ckp, map_location=device), strict=True)
38
+ if args.lora_weight != 'None':
39
+ apply_lora(model)
40
+ load_lora(model, f'../{args.save_dir}/lora/{args.lora_weight}_{args.hidden_size}.pth')
41
+ else:
42
+ model = AutoModelForCausalLM.from_pretrained(args.load_from, trust_remote_code=True)
43
+ print(f'MiniMind模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f} M(illion)')
44
+ return model.half().eval().to(device), tokenizer
45
+
46
+
47
+ class ChatRequest(BaseModel):
48
+ model: str
49
+ messages: list
50
+ temperature: float = 0.7
51
+ top_p: float = 0.92
52
+ max_tokens: int = 8192
53
+ stream: bool = True
54
+ tools: list = Field(default_factory=list)
55
+ open_thinking: bool = False
56
+ chat_template_kwargs: dict = None
57
+
58
+ def get_open_thinking(self) -> bool:
59
+ """兼容多种方式开启 thinking"""
60
+ if self.open_thinking:
61
+ return True
62
+ if self.chat_template_kwargs:
63
+ return self.chat_template_kwargs.get('open_thinking', False) or \
64
+ self.chat_template_kwargs.get('enable_thinking', False)
65
+ return False
66
+
67
+
68
+ class CustomStreamer(TextStreamer):
69
+ def __init__(self, tokenizer, queue):
70
+ super().__init__(tokenizer, skip_prompt=True, skip_special_tokens=True)
71
+ self.queue = queue
72
+ self.tokenizer = tokenizer
73
+
74
+ def on_finalized_text(self, text: str, stream_end: bool = False):
75
+ self.queue.put(text)
76
+ if stream_end:
77
+ self.queue.put(None)
78
+
79
+
80
+ def parse_response(text):
81
+ reasoning_content = None
82
+ think_match = re.search(r'<think>(.*?)</think>', text, re.DOTALL)
83
+ if think_match:
84
+ reasoning_content = think_match.group(1).strip()
85
+ text = re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL)
86
+ elif '</think>' in text:
87
+ parts = text.split('</think>', 1)
88
+ reasoning_content = parts[0].strip()
89
+ text = parts[1].strip() if len(parts) > 1 else ''
90
+ tool_calls = []
91
+ for i, m in enumerate(re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL)):
92
+ try:
93
+ call = json.loads(m.strip())
94
+ tool_calls.append({"id": f"call_{int(time.time())}_{i}", "type": "function", "function": {"name": call.get("name", ""), "arguments": json.dumps(call.get("arguments", {}), ensure_ascii=False)}})
95
+ except Exception:
96
+ pass
97
+ if tool_calls:
98
+ text = re.sub(r'<tool_call>.*?</tool_call>', '', text, flags=re.DOTALL)
99
+ return text.strip(), reasoning_content, tool_calls or None
100
+
101
+
102
+ def generate_stream_response(messages, temperature, top_p, max_tokens, tools=None, open_thinking=False):
103
+ try:
104
+ new_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, tools=tools or None, open_thinking=open_thinking)
105
+ inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)
106
+
107
+ queue = Queue()
108
+ streamer = CustomStreamer(tokenizer, queue)
109
+
110
+ def _generate():
111
+ try:
112
+ model.generate(
113
+ inputs.input_ids,
114
+ max_new_tokens=max_tokens,
115
+ do_sample=True,
116
+ temperature=temperature,
117
+ top_p=top_p,
118
+ attention_mask=inputs.attention_mask,
119
+ pad_token_id=tokenizer.pad_token_id,
120
+ eos_token_id=tokenizer.eos_token_id,
121
+ streamer=streamer
122
+ )
123
+ except Exception as e:
124
+ queue.put({"error": str(e)})
125
+ queue.put(None)
126
+
127
+ Thread(target=_generate).start()
128
+
129
+ full_text = ""
130
+ emitted = 0
131
+ thinking_ended = not bool(open_thinking)
132
+
133
+ while True:
134
+ text = queue.get()
135
+ if text is None:
136
+ break
137
+ if isinstance(text, dict):
138
+ yield json.dumps(text, ensure_ascii=False)
139
+ continue
140
+ full_text += text
141
+
142
+ if not thinking_ended:
143
+ pos = full_text.find('</think>')
144
+ if pos >= 0:
145
+ thinking_ended = True
146
+ new_r = full_text[emitted:pos]
147
+ if new_r:
148
+ yield json.dumps({"choices": [{"delta": {"reasoning_content": new_r}}]}, ensure_ascii=False)
149
+ emitted = pos + len('</think>')
150
+ after = full_text[emitted:].lstrip('\n')
151
+ emitted = len(full_text) - len(after)
152
+ if after:
153
+ yield json.dumps({"choices": [{"delta": {"content": after}}]}, ensure_ascii=False)
154
+ emitted = len(full_text)
155
+ else:
156
+ new_r = full_text[emitted:]
157
+ if new_r:
158
+ yield json.dumps({"choices": [{"delta": {"reasoning_content": new_r}}]}, ensure_ascii=False)
159
+ emitted = len(full_text)
160
+ else:
161
+ new_c = full_text[emitted:]
162
+ if new_c:
163
+ yield json.dumps({"choices": [{"delta": {"content": new_c}}]}, ensure_ascii=False)
164
+ emitted = len(full_text)
165
+
166
+ _, _, tool_calls = parse_response(full_text)
167
+ if tool_calls:
168
+ yield json.dumps({"choices": [{"delta": {"tool_calls": tool_calls}}]}, ensure_ascii=False)
169
+ yield json.dumps({"choices": [{"delta": {}, "finish_reason": "tool_calls" if tool_calls else "stop"}]}, ensure_ascii=False)
170
+
171
+ except Exception as e:
172
+ yield json.dumps({"error": str(e)})
173
+
174
+
175
+ @app.post("/v1/chat/completions")
176
+ async def chat_completions(request: ChatRequest):
177
+ try:
178
+ if request.stream:
179
+ return StreamingResponse(
180
+ (f"data: {chunk}\n\n" for chunk in generate_stream_response(
181
+ messages=request.messages,
182
+ temperature=request.temperature,
183
+ top_p=request.top_p,
184
+ max_tokens=request.max_tokens,
185
+ tools=request.tools,
186
+ open_thinking=request.get_open_thinking()
187
+ )),
188
+ media_type="text/event-stream"
189
+ )
190
+ else:
191
+ new_prompt = tokenizer.apply_chat_template(
192
+ request.messages,
193
+ tokenize=False,
194
+ add_generation_prompt=True,
195
+ tools=request.tools or None,
196
+ open_thinking=request.get_open_thinking()
197
+ )
198
+ inputs = tokenizer(new_prompt, return_tensors="pt", truncation=True).to(device)
199
+ with torch.no_grad():
200
+ generated_ids = model.generate(
201
+ inputs["input_ids"],
202
+ max_length=inputs["input_ids"].shape[1] + request.max_tokens,
203
+ do_sample=True,
204
+ attention_mask=inputs["attention_mask"],
205
+ pad_token_id=tokenizer.pad_token_id,
206
+ eos_token_id=tokenizer.eos_token_id,
207
+ top_p=request.top_p,
208
+ temperature=request.temperature
209
+ )
210
+ answer = tokenizer.decode(generated_ids[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
211
+ content, reasoning_content, tool_calls = parse_response(answer)
212
+ message = {"role": "assistant", "content": content}
213
+ if reasoning_content:
214
+ message["reasoning_content"] = reasoning_content
215
+ if tool_calls:
216
+ message["tool_calls"] = tool_calls
217
+ return {
218
+ "id": f"chatcmpl-{int(time.time())}",
219
+ "object": "chat.completion",
220
+ "created": int(time.time()),
221
+ "model": "minimind",
222
+ "choices": [
223
+ {
224
+ "index": 0,
225
+ "message": message,
226
+ "finish_reason": "tool_calls" if tool_calls else "stop"
227
+ }
228
+ ]
229
+ }
230
+ except Exception as e:
231
+ raise HTTPException(status_code=500, detail=str(e))
232
+
233
+
234
+ if __name__ == "__main__":
235
+ parser = argparse.ArgumentParser(description="Server for MiniMind")
236
+ parser.add_argument('--load_from', default='../model', type=str, help="模型加载路径(model=原生torch权重,其他路径=transformers格式)")
237
+ parser.add_argument('--save_dir', default='out', type=str, help="模型权重目录")
238
+ parser.add_argument('--weight', default='full_sft', type=str, help="权重名称前缀(pretrain, full_sft, dpo, reason, ppo_actor, grpo, spo)")
239
+ parser.add_argument('--lora_weight', default='None', type=str, help="LoRA权重名称(None表示不使用,可选:lora_identity, lora_medical)")
240
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
241
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
242
+ parser.add_argument('--max_seq_len', default=8192, type=int, help="最大序列长度")
243
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
244
+ parser.add_argument('--inference_rope_scaling', default=False, action='store_true', help="启用RoPE位置编码外推(4倍,仅解决位置编码问题)")
245
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu', type=str, help="运行设备")
246
+ args = parser.parse_args()
247
+ device = args.device
248
+ model, tokenizer = init_model(args)
249
+ uvicorn.run(app, host="0.0.0.0", port=8998)
examples/web_demo.html ADDED
@@ -0,0 +1,748 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>MiniMind-O</title>
7
+ <link rel="icon" type="image/png" href="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png">
8
+ <style>
9
+ :root {
10
+ --bg:#1e1e20; --p1:#2a2a2e; --p2:#35353b; --p3:#40404a;
11
+ --bd:#55555e; --bd2:#7a7a85;
12
+ --fg:#ececef; --mu:#9a9aa0; --dim:#8a8a92;
13
+ --u:#7fc5e0; --a:#e5b57a; --danger:#d84545; --cam:#7ad88a;
14
+ --serif:'Georgia','Times New Roman',serif; --mono:'SF Mono',Consolas,monospace;
15
+ }
16
+ *{margin:0;padding:0;box-sizing:border-box}
17
+ html,body{height:100%}
18
+ body{height:100vh;background:var(--bg);color:var(--fg);font-family:var(--serif);display:flex;align-items:center;justify-content:center;overflow:hidden}
19
+ .app{max-width:860px;width:100%;height:calc(100vh - 40px);display:flex;flex-direction:column;padding:20px 24px;background:var(--p1);border:1px solid var(--bd);overflow:hidden}
20
+ .topbar{display:flex;align-items:center;justify-content:space-between;padding:4px 0 12px;flex-shrink:0;gap:10px}
21
+ .brand{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
22
+ .brand>div{min-width:0}
23
+ .brand img{height:34px}
24
+ .brand h1{font-size:1.15rem;font-weight:700;font-style:italic;color:#fff}
25
+ .brand .sub{font-size:.72rem;color:var(--mu);font-style:italic}
26
+ .topright{display:flex;gap:8px;align-items:center}
27
+ .btn{padding:5px 10px;font-size:.75rem;cursor:pointer;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;display:inline-flex;align-items:center;gap:5px}
28
+ .btn:hover:not(:disabled){background:var(--p3);border-color:var(--bd2)}
29
+ .btn:disabled{opacity:.4;cursor:not-allowed}
30
+ .btn.danger{color:var(--danger);border-color:#6a3a3a}
31
+ .btn.danger:hover:not(:disabled){background:var(--danger);border-color:var(--danger);color:#fff}
32
+ .btn svg{width:12px;height:12px;fill:currentColor}
33
+ .model-sel{max-width:150px;padding:5px 8px;font-size:.72rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;outline:none}
34
+ .brand .model-sel{max-width:260px;padding:0 34px 0 0;background:transparent;border:none;font-size:1.15rem;font-weight:700;color:#fff}
35
+ .model-sel:focus{border-color:var(--bd2)}
36
+ .mini-sel{max-width:130px;padding:5px 8px;font-size:.72rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:var(--serif);font-style:italic;outline:none}
37
+ .mini-sel:focus{border-color:var(--bd2)}
38
+ .voice-sel{display:none}
39
+ .more{position:relative}
40
+ .more-pop{top:100%;right:0;left:auto;margin-top:6px;min-width:130px}
41
+ .vgroup{padding:4px 10px;font-size:.6rem;color:var(--mu);font-style:italic}
42
+ .vitem{padding:5px 10px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;font-size:.72rem;font-family:var(--serif);font-style:italic;color:var(--fg);gap:8px}
43
+ .vitem:hover{background:var(--p3)}
44
+ .vitem.on{color:var(--u)}
45
+ .vx{color:var(--mu);cursor:pointer;font-style:normal;font-size:.8rem;padding:0 2px;line-height:1}
46
+ .vx:hover{color:var(--danger)}
47
+ .vadd{padding:5px 10px;font-size:.72rem;color:var(--mu);cursor:pointer;font-family:var(--serif);font-style:italic;border-top:1px solid var(--bd)}
48
+ .vadd:hover{background:var(--p3);color:var(--fg)}
49
+ .status{display:inline-flex;align-items:center;gap:10px;padding:5px 10px;font-size:.65rem;font-family:var(--mono);background:var(--p2);border:1px solid var(--bd);transition:opacity .3s}
50
+ .status.hidden{opacity:0;pointer-events:none}
51
+ .status .row{display:inline-flex;gap:5px;white-space:nowrap}
52
+ .status .lab{color:var(--mu);font-style:italic}
53
+
54
+ .chat{flex:1;overflow-y:auto;padding:12px 0;min-height:0;display:flex;flex-direction:column;gap:18px;scrollbar-width:none;-ms-overflow-style:none}
55
+ .chat::-webkit-scrollbar{display:none}
56
+ .msg{display:flex;flex-direction:column;gap:6px;animation:fi .25s ease}
57
+ .msg.user{align-items:flex-end}
58
+ .msg.user .body{align-items:flex-end}
59
+ @keyframes fi{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
60
+ .meta{font-size:.72rem;font-style:italic}
61
+ .msg.user .meta{color:var(--u)}
62
+ .msg.assistant .meta{color:var(--a)}
63
+ .body{display:flex;flex-direction:column;gap:8px}
64
+ .text{line-height:1.65;white-space:pre-wrap;word-break:break-word}
65
+ .img{display:block;max-width:160px;max-height:160px}
66
+ .msg.user .text:not(.voice){color:var(--u)}
67
+ .voice{font-family:var(--mono);font-size:1.05rem;line-height:1.2;color:var(--bd)}
68
+ .msg.user .voice{color:rgba(127,197,224,.5)}
69
+ .prog{text-decoration:underline dashed;text-decoration-color:var(--bd);text-underline-offset:4px;text-decoration-thickness:1px}
70
+ .prog .tk{cursor:pointer;padding:1px 0;transition:color .08s,background .08s}
71
+ .msg.assistant .prog .tk.played{color:var(--a)}
72
+ .msg.assistant .prog .tk:hover{background:rgba(229,181,122,.2)}
73
+ .msg.assistant .prog.on .tk.cur{background:rgba(229,181,122,.45)}
74
+ .msg.user .prog .tk.played{color:var(--u)}
75
+ .msg.user .prog .tk:hover{background:rgba(127,197,224,.18)}
76
+ .msg.user .prog.on .tk.cur{background:rgba(127,197,224,.35)}
77
+ .player{display:inline-flex;align-items:center;gap:10px;padding:6px 12px;width:320px;max-width:100%;background:var(--p2);border:1px solid var(--bd)}
78
+ .pp{background:transparent;border:none;color:inherit;cursor:pointer;font-family:var(--mono);font-size:.9rem;width:20px;text-align:left}
79
+ .pp:hover{opacity:.75}
80
+ .bar-wrap{flex:1;height:3px;cursor:pointer;background:var(--p1)}
81
+ .bar{height:100%;width:0;transition:width .08s linear}
82
+ .msg.user .bar,.msg.user .pp{background:var(--u);color:var(--u)}
83
+ .msg.assistant .bar,.msg.assistant .pp{background:var(--a);color:var(--a)}
84
+ .msg.user .bar-wrap{background:var(--p1)}
85
+ .time{font-size:.7rem;font-family:var(--mono);min-width:76px;text-align:right;color:var(--mu)}
86
+
87
+ .input-area{flex-shrink:0;padding:10px 0 4px}
88
+ .compose{display:flex;gap:6px}
89
+ .compose button{padding:0 12px;min-width:42px;cursor:pointer;font-size:.85rem;background:var(--p2);border:1px solid var(--bd);color:var(--fg);display:flex;align-items:center;justify-content:center;font-family:inherit}
90
+ .compose button svg{width:16px;height:16px;fill:currentColor}
91
+ .compose button:hover:not(:disabled){background:var(--p3);border-color:var(--bd2)}
92
+ .compose button:disabled{opacity:.4;cursor:not-allowed}
93
+ .compose button.primary{background:var(--fg);color:#1a1a1e;border-color:var(--fg)}
94
+ .compose button.primary:hover:not(:disabled){background:#fff}
95
+ .compose button.rec{animation:pulse 1s infinite;background:var(--danger);color:#fff;border-color:var(--danger)}
96
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
97
+ .text-wrap{flex:1;display:flex;align-items:center;gap:6px;padding:0 6px 0 10px;min-width:0;background:var(--p2);border:1px solid var(--bd)}
98
+ .text-wrap:focus-within{border-color:var(--fg)}
99
+ .text-wrap input{flex:1;min-width:0;padding:10px 4px;font-size:.9rem;outline:none;background:transparent;border:none;color:inherit;font-family:inherit}
100
+ .text-wrap input::placeholder{color:var(--dim)}
101
+ .attach{display:flex;gap:4px;flex-wrap:nowrap;flex-shrink:0;max-width:45%;overflow-x:auto;scrollbar-width:none}
102
+ .attach::-webkit-scrollbar{display:none}
103
+ .attach:empty{display:none}
104
+ .chip{display:inline-flex;align-items:center;gap:6px;padding:3px 7px;font-size:.72rem;background:var(--p1);border:1px solid var(--bd);flex-shrink:0}
105
+ .chip .x{cursor:pointer;opacity:.55;font-size:.85rem}
106
+ .chip .x:hover{opacity:1}
107
+ .chip .play{cursor:pointer}
108
+ .chip img{width:22px;height:22px;object-fit:cover}
109
+ .pop{position:absolute;background:var(--p2);border:1px solid var(--bd);display:none;flex-direction:column;z-index:50;min-width:110px;font-family:var(--serif);font-style:italic;white-space:nowrap}
110
+ .pop.on{display:flex}
111
+ .pop button{padding:5px 10px;background:transparent;border:none;color:var(--fg);cursor:pointer;text-align:left;font-size:.72rem;font-family:inherit;font-style:inherit;line-height:1.2}
112
+ .pop button:hover{background:var(--p3)}
113
+ .snap{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.85);display:none;align-items:center;justify-content:center;flex-direction:column;gap:14px}
114
+ .snap.on{display:flex}
115
+ .snap video{max-width:80vw;max-height:70vh;background:#000;border:1px solid var(--bd)}
116
+ .snap .sr{display:flex;gap:10px}
117
+ .snap .sr button{padding:8px 18px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);cursor:pointer;font-family:var(--serif);font-style:italic}
118
+ .snap .sr button.primary{background:var(--fg);color:#1a1a1e;border-color:var(--fg)}
119
+ .clone-card{width:min(92vw,520px);padding:22px;background:var(--p1);border:1px solid var(--bd);display:flex;flex-direction:column;gap:14px}
120
+ .clone-title{font-size:1rem;font-style:italic;color:#fff}
121
+ .clone-hint{font-size:.76rem;color:var(--mu);font-style:italic;line-height:1.6}
122
+ .clone-name{width:100%;padding:10px 12px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);font-family:inherit;font-size:.85rem;outline:none}
123
+ .clone-name:focus{border-color:var(--fg)}
124
+ .clone-script{padding:12px 14px;background:var(--p2);border:1px solid var(--bd);font-size:1rem;line-height:1.85}
125
+ .clone-stat{min-height:18px;font-size:.72rem;color:var(--mu);font-family:var(--mono)}
126
+
127
+ /* ===== Call mode ===== */
128
+ body>.app,#call{will-change:transform,opacity,filter;transform-origin:center center}
129
+ body>.app{transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1)}
130
+ body.call>.app{opacity:0;transform:scale(.92);filter:blur(8px);pointer-events:none}
131
+ #call{position:fixed;inset:0;z-index:100;background:var(--bg);overflow:hidden;opacity:0;transform:scale(1.08);filter:blur(10px);pointer-events:none;visibility:hidden;transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1),visibility 0s .7s}
132
+ body.call #call{opacity:1;transform:scale(1);filter:blur(0);pointer-events:auto;visibility:visible;transition:opacity .55s cubic-bezier(.22,1,.36,1),transform .7s cubic-bezier(.22,1,.36,1),filter .55s cubic-bezier(.22,1,.36,1),visibility 0s 0s}
133
+ #call .app{max-width:560px;margin:20px auto;padding:18px 22px;position:relative}
134
+ #call .topbar{border-bottom:1px dashed var(--bd)}
135
+ #call .brand h1{font-size:1.05rem}
136
+ #call .brand .model-sel{max-width:230px;padding-right:38px;font-size:1.05rem}
137
+ #call .brand .sub{font-size:.68rem}
138
+ .pill{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;font-size:.7rem;font-family:var(--mono);background:var(--p2);border:1px solid var(--bd);color:var(--mu);font-style:italic;line-height:1.2}
139
+ .pill .dot{width:6px;height:6px;background:var(--mu);border-radius:50%}
140
+ .pill.live{color:var(--u);border-color:#3c5966}
141
+ .pill.live .dot{background:var(--u);box-shadow:0 0 6px var(--u);animation:cp 1.4s ease-in-out infinite}
142
+ @keyframes cp{0%,100%{opacity:1}50%{opacity:.4}}
143
+ #call .btn.active{background:var(--u);color:#1a1a1e;border-color:var(--u)}
144
+
145
+ .hero{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;padding:14px 0;position:relative}
146
+ .orb{position:relative;width:280px;height:280px;max-width:60vh;max-height:60vh;display:flex;align-items:center;justify-content:center;will-change:transform}
147
+ .orb svg{width:100%;height:100%;overflow:visible}
148
+ .blob{fill:none;stroke-linejoin:round;transition:stroke .35s,opacity .45s}
149
+ .blob.l0{stroke:#5a5a64;stroke-width:1.2;opacity:.55}
150
+ .blob.l1{stroke:var(--bd2);stroke-width:1.4;opacity:.85}
151
+ .blob.l2{stroke:var(--fg);stroke-width:1.6}
152
+ .orb.camon .blob.l0,.orb.camon .blob.l1{opacity:0}
153
+ .orb.camon .blob.l2{stroke:var(--cam);filter:drop-shadow(0 0 4px rgba(122,216,138,.45))}
154
+ svg.listen .blob.l2{stroke:var(--u);filter:drop-shadow(0 0 4px rgba(127,197,224,.45))}
155
+ svg.listen .blob.l1{stroke:rgba(127,197,224,.6)}
156
+ svg.speak .blob.l2{stroke:var(--a);filter:drop-shadow(0 0 5px rgba(229,181,122,.5))}
157
+ svg.speak .blob.l1{stroke:rgba(229,181,122,.6)}
158
+ svg.gen .blob.l2{stroke:var(--fg);filter:drop-shadow(0 0 4px rgba(236,236,239,.35))}
159
+ .core{display:none}
160
+ svg.listen .core{fill:var(--u)}
161
+ svg.speak .core{fill:var(--a)}
162
+ .ripple{fill:none;stroke:var(--mu);stroke-width:1;transform-origin:center;animation:rip 1.6s ease-out forwards}
163
+ .ripple.user{stroke:var(--u)}
164
+ .ripple.assistant{stroke:var(--a)}
165
+ @keyframes rip{0%{transform:scale(.55);opacity:.55}100%{transform:scale(1.35);opacity:0}}
166
+ .lbl{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;pointer-events:none;display:flex;flex-direction:column;align-items:center;gap:6px}
167
+ .ic{width:34px;height:34px;color:var(--mu);transition:color .3s;display:flex;align-items:center;justify-content:center}
168
+ .ic svg{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
169
+ .lbl.listen .ic{color:var(--u)}
170
+ .lbl.speak .ic{color:var(--a)}
171
+ .lbl.gen .ic{color:var(--fg)}
172
+ .lbl .st{display:none}
173
+ .ic svg *{transform-box:view-box;transform-origin:12px 12px}
174
+ .lbl.idle .ic .dot{animation:breathe 2.2s ease-in-out infinite;transform-origin:center;transform-box:view-box}
175
+ @keyframes breathe{0%,100%{opacity:.4;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
176
+ .lbl.listen .ic .w{animation:sigin 1.6s ease-out infinite}
177
+ .lbl.listen .ic .w2{animation-delay:.3s}
178
+ .lbl.listen .ic .w3{animation-delay:.6s}
179
+ @keyframes sigin{0%{opacity:0;transform:scale(1.2)}60%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.85)}}
180
+ .lbl.gen .ic svg{animation:spin 1.2s cubic-bezier(.5,.1,.5,.9) infinite}
181
+ @keyframes spin{to{transform:rotate(360deg)}}
182
+ .lbl.speak .ic .a1{animation:radout 1.3s ease-out infinite}
183
+ .lbl.speak .ic .a2{animation:radout 1.3s ease-out .25s infinite}
184
+ .lbl.speak .ic .a3{animation:radout 1.3s ease-out .5s infinite}
185
+ @keyframes radout{0%{opacity:0;transform:scale(.7)}40%{opacity:1}100%{opacity:0;transform:scale(1.15)}}
186
+ .up{display:none}
187
+ .cam{position:absolute;top:50%;left:50%;width:180px;height:180px;object-fit:cover;background:var(--bg);border:1px solid var(--bd);transform:translate(-50%,-50%) scale(.3);opacity:0;transition:transform .5s cubic-bezier(.4,0,.2,1),opacity .3s,border-color .35s,box-shadow .35s;pointer-events:none;will-change:transform,opacity}
188
+ .orb.camon .cam{transform:translate(-50%,-50%) scale(1);opacity:1;border-color:var(--cam);box-shadow:0 0 14px rgba(122,216,138,.35)}
189
+ .orb.camon .core{opacity:0}
190
+ .orb.camon .lbl{top:auto;bottom:-8px;transform:translate(-50%,100%)}
191
+ .orb.camon .lbl .st{font-size:.9rem}
192
+ svg.listen ~ .cam{border-color:var(--u);box-shadow:0 0 14px rgba(127,197,224,.45)}
193
+ svg.speak ~ .cam{border-color:var(--a);box-shadow:0 0 14px rgba(229,181,122,.45)}
194
+
195
+ .latest{width:100%;padding:10px 14px;background:var(--p2);border:1px solid var(--bd);display:flex;flex-direction:column;gap:5px;flex-shrink:0;max-height:120px;overflow-y:auto;scrollbar-width:none}
196
+ .latest::-webkit-scrollbar{display:none}
197
+ .latest .lm{font-family:var(--mono);font-size:.6rem;color:var(--mu);font-style:italic}
198
+ .latest .lt{font-size:.82rem;line-height:1.55;font-style:italic;opacity:.9;white-space:pre-wrap;word-break:break-word}
199
+ .latest.user .lm{color:var(--u)}
200
+ .latest.assistant .lm{color:var(--a)}
201
+ .latest.empty .lt{color:var(--bd)}
202
+ .ctrls{display:flex;justify-content:center;gap:10px;padding:14px 0 2px;margin-top:12px;border-top:1px dashed var(--bd);flex-shrink:0}
203
+ .cbtn{width:72px;height:58px;background:var(--p2);border:1px solid var(--bd);color:var(--fg);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-family:var(--mono);font-size:.58rem;letter-spacing:1px;text-transform:uppercase;transition:all .18s}
204
+ .cbtn svg{width:18px;height:18px;fill:currentColor}
205
+ .cbtn:hover{background:var(--p3);border-color:var(--bd2)}
206
+ .cbtn.active{background:var(--u);color:#1a1a1e;border-color:var(--u)}
207
+ .cbtn.danger{color:var(--danger);border-color:#6a3a3a}
208
+ .cbtn.danger:hover{background:var(--danger);color:#fff}
209
+
210
+ .trans{position:absolute;inset:0;background:var(--p1);z-index:20;display:flex;flex-direction:column;padding:18px 22px;transform:translateY(100%);transition:transform .3s ease;will-change:transform;contain:layout paint}
211
+ .trans.on{transform:translateY(0)}
212
+ .trans .th{display:flex;justify-content:space-between;padding-bottom:10px;border-bottom:1px dashed var(--bd)}
213
+ .trans .tt{font-style:italic;font-size:.95rem;display:flex;gap:10px;align-items:center}
214
+ .trans .cnt{font-family:var(--mono);font-size:.68rem;color:var(--mu)}
215
+ #call .chat:empty::before{content:'no turns yet · say something';display:block;text-align:center;color:var(--bd);font-style:italic;padding:40px 0;font-size:.82rem}
216
+ @media(max-width:540px){
217
+ body{display:block}
218
+ .app{height:100vh;height:100dvh;max-width:none;border:none;padding:14px 16px}
219
+ .brand img{height:28px}
220
+ .brand h1{font-size:1rem}
221
+ .brand .sub{display:none}
222
+ .topbar{gap:6px}
223
+ .status{display:none!important}
224
+ .player{width:100%}
225
+ .vitem,.vadd{padding:8px 12px}
226
+ .compose button{min-width:38px;padding:0 10px}
227
+ .text-wrap input{padding:8px 4px;font-size:.85rem}
228
+ .clone-card{padding:16px}
229
+ .clone-hint{font-size:.7rem}
230
+ #call .app{margin:0;height:100vh;height:100dvh;border:none;padding:14px 16px}
231
+ .orb{width:240px;height:240px}
232
+ .cbtn{width:58px;height:52px}
233
+ }
234
+ </style>
235
+ </head>
236
+ <body>
237
+ <div class="app">
238
+ <div class="topbar">
239
+ <div class="brand">
240
+ <img src="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png" alt="logo">
241
+ <div><select class="model-sel" id="modelSel"></select><div class="sub">multimodal inference · text / image / audio → text + audio</div></div>
242
+ </div>
243
+ <div class="topright">
244
+ <div class="status hidden" id="stat"><div class="row"><span class="lab">text_ttft</span><span id="tt">—</span></div><div class="row"><span class="lab">audio_ttft</span><span id="ta">—</span></div><div class="row"><span class="lab">status</span><span id="ts">idle</span></div></div>
245
+ <select class="voice-sel" id="voiceSel"><option value="default">default</option></select>
246
+ <div class="more">
247
+ <button class="btn" id="moreBtn">default</button>
248
+ <div class="pop more-pop" id="morePop"></div>
249
+ </div>
250
+ <button class="btn" id="callBtn"><svg viewBox="0 0 24 24"><path d="M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.02-.24c1.12.37 2.32.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57.1.35 0 .74-.25 1.02l-2.2 2.2z"/></svg>Call</button>
251
+ </div>
252
+ </div>
253
+ <div class="chat" id="chat"></div>
254
+ <div class="input-area"><div class="compose">
255
+ <div style="position:relative;display:flex">
256
+ <button id="imgBtn" title="image"><svg viewBox="0 0 24 24"><path d="M21 19V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2zm-2 0H5l3.5-4.5 2.5 3.01L14.5 13l4.5 6zM8.5 8A1.5 1.5 0 1 1 7 9.5 1.5 1.5 0 0 1 8.5 8z"/></svg></button>
257
+ <div class="pop" id="imgPop" style="bottom:100%;left:0;margin-bottom:6px"><button id="fileBtn">from file</button><button id="shotBtn">take photo</button></div>
258
+ </div>
259
+ <input type="file" id="imgIn" accept="image/*" hidden>
260
+ <button id="recBtn" title="录音"><svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg></button>
261
+ <div class="text-wrap"><div class="attach" id="att"></div><input type="text" id="txt" placeholder="Message MiniMind-O..." autocomplete="off"></div>
262
+ <button id="sendBtn" class="primary"><svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg></button>
263
+ </div></div>
264
+ </div>
265
+
266
+ <div class="snap" id="snap"><video id="snapV" autoplay playsinline muted></video><div class="sr"><button id="snapCancel">cancel</button><button id="snapOk" class="primary">capture</button></div></div>
267
+ <div class="snap" id="cloneModal"><div class="clone-card"><div class="clone-title">Clone Voice</div><div class="clone-hint">请自然朗读下面这句话。建议 3 到 6 秒,环境尽量安静。录完后会自动做 1.5 倍速、提取 `CAM++` speaker embedding 与 `Mimi` ref codes,然后立即加入音色列表。</div><input class="clone-name" id="cloneName" placeholder="音色名,例如:小王" maxlength="24"><div class="clone-script" id="cloneText">今天阳光很好,欢迎来到我的语音克隆小实验。</div><div class="clone-stat" id="cloneStat">点击 start 开始录音</div><div class="sr"><button id="cloneCancel">cancel</button><button id="cloneRec" class="primary">start</button></div></div></div>
268
+
269
+ <div id="call"><div class="app">
270
+ <div class="topbar">
271
+ <div class="brand"><img src="https://www.modelscope.cn/studio/gongjy/MiniMind/resolve/master/images/logo2.png" alt=""><div><select class="model-sel" id="callModelSel"></select><div class="sub">live voice · bidirectional streaming</div></div></div>
272
+ <div class="topright">
273
+ <select class="mini-sel" id="callVoiceSel"><option value="default">default</option></select>
274
+ <button class="btn" id="logBtn"><svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6zm2-3h8v-2H8v2zm0-4h8v-2H8v2z"/></svg>log</button>
275
+ <span class="pill" id="pill"><span class="dot"></span><span id="connT">connecting</span></span>
276
+ </div>
277
+ </div>
278
+ <div class="hero"><div class="orb" id="orb">
279
+ <svg viewBox="-160 -160 320 320" id="osvg"><g id="rip"></g><path class="blob l0" id="b0"/><path class="blob l1" id="b1"/><path class="blob l2" id="b2"/><circle class="core" id="core" cx="0" cy="0" r="4"/></svg>
280
+ <video class="cam" id="camV" autoplay playsinline muted></video>
281
+ <div class="lbl" id="lbl"><div class="ic" id="stIc"></div><div class="st" id="stT">init</div><div class="up" id="upT">00:00:00</div></div>
282
+ </div></div>
283
+ <div class="latest empty" id="lat"><span class="lm" id="lmE">latest · awaiting first turn</span><span class="lt" id="ltE">speak naturally — model responds with voice and text</span></div>
284
+ <div class="ctrls">
285
+ <button class="cbtn" id="camBtn"><svg viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg><span>cam</span></button>
286
+ <button class="cbtn danger" id="endBtn"><svg viewBox="0 0 24 24"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg><span>end</span></button>
287
+ </div>
288
+ <div class="trans" id="trans"><div class="th"><div class="tt">session transcript <span class="cnt" id="tcnt">0 turns</span></div><button class="btn" id="closeBtn">× close</button></div><div class="chat" id="cchat"></div></div>
289
+ </div></div>
290
+
291
+ <script>
292
+ const $=id=>document.getElementById(id);
293
+ const chat=$('chat'),txt=$('txt'),sendBtn=$('sendBtn'),recBtn=$('recBtn'),imgBtn=$('imgBtn'),imgIn=$('imgIn'),callBtn=$('callBtn'),att=$('att');
294
+ const stat=$('stat'),tt=$('tt'),ta=$('ta'),ts=$('ts'),voiceSel=$('voiceSel'),modelSel=$('modelSel'),callModelSel=$('callModelSel'),callVoiceSel=$('callVoiceSel');
295
+ const moreBtn=$('moreBtn'),morePop=$('morePop');
296
+ const CLONE_PROMPT='今天阳光很好,欢迎来到我的语音克隆小实验。';
297
+ let voiceGroups={builtin:[],unseen:[],manual:[]};
298
+ const closeMore=()=>morePop.classList.remove('on');
299
+ const modelLabel=m=>m;
300
+ async function loadVoices(sel){
301
+ const keep=sel||voiceSel.value||'default';
302
+ const keepCall=callVoiceSel.value||'default';
303
+ voiceSel.innerHTML='<option value="default">default</option>';
304
+ const d=await fetch('/voices').then(r=>r.json());
305
+ voiceGroups={builtin:d.builtin||[],unseen:d.unseen||[],manual:d.manual||[]};
306
+ for(const [label,names] of [['builtin',voiceGroups.builtin],['unseen',voiceGroups.unseen],['manual',voiceGroups.manual]]){
307
+ if(!names.length)continue;
308
+ const g=document.createElement('optgroup');
309
+ g.label=label;
310
+ names.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;g.appendChild(o)});
311
+ voiceSel.appendChild(g);
312
+ }
313
+ if([...voiceSel.options].some(o=>o.value===keep))voiceSel.value=keep;
314
+ callVoiceSel.innerHTML=voiceSel.innerHTML;
315
+ if([...callVoiceSel.options].some(o=>o.value===keepCall))callVoiceSel.value=keepCall;
316
+ renderVoiceList();
317
+ }
318
+ function renderVoiceList(){
319
+ let h='';const sel=voiceSel.value;
320
+ moreBtn.textContent=sel;
321
+ h+=`<div class="vitem${sel==='default'?' on':''}" data-voice="default">default</div>`;
322
+ for(const [g,ns] of [['builtin',voiceGroups.builtin],['unseen',voiceGroups.unseen],['manual',voiceGroups.manual]]){
323
+ if(!ns.length)continue;
324
+ h+=`<div class="vgroup">${g}</div>`;
325
+ for(const v of ns)h+=`<div class="vitem${v===sel?' on':''}" data-voice="${v}"><span>${v}</span>${g==='manual'?`<span class="vx" data-del="${v}">×</span>`:''}</div>`;
326
+ }
327
+ h+='<div class="vadd">+ clone</div>';
328
+ morePop.innerHTML=h;
329
+ }
330
+ loadVoices().catch(()=>{});
331
+ async function loadModels(){
332
+ const d=await fetch('/models').then(r=>r.json());
333
+ modelSel.innerHTML='';callModelSel.innerHTML='';
334
+ for(const m of d.models||[]){const o=document.createElement('option');o.value=m;o.textContent=modelLabel(m);modelSel.appendChild(o);callModelSel.appendChild(o.cloneNode(true))}
335
+ if(d.current){modelSel.value=d.current;callModelSel.value=d.current}
336
+ }
337
+ async function switchModelSelect(sel, oldSel){
338
+ const old=sel.dataset.current||sel.value,name=sel.value;
339
+ const wasCall=document.body.classList.contains('call');
340
+ stopChat();if(callStop)callStop();stat.classList.remove('hidden');ts.textContent='switching';sel.disabled=true;
341
+ try{
342
+ const r=await fetch('/switch_model',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
343
+ const d=await r.json();
344
+ if(!r.ok||!d.ok)throw new Error(d.error||'switch failed');
345
+ modelSel.value=name;callModelSel.value=name;modelSel.dataset.current=name;callModelSel.dataset.current=name;ts.textContent=`loaded ${name}`;if(wasCall)openCall();
346
+ }catch(e){sel.value=old;if(oldSel)oldSel.value=old;ts.textContent='switch failed';alert(e.message)}
347
+ sel.disabled=false;
348
+ }
349
+ modelSel.onchange=()=>switchModelSelect(modelSel,callModelSel);
350
+ callModelSel.onchange=()=>switchModelSelect(callModelSel,modelSel);
351
+ callVoiceSel.onchange=()=>{if(window.callSendCtx)window.callSendCtx()};
352
+ loadModels().then(()=>{modelSel.dataset.current=modelSel.value;callModelSel.dataset.current=callModelSel.value}).catch(()=>{});
353
+
354
+ /* ------------- shared helpers ------------- */
355
+ function decPcm(b64){
356
+ const bin=atob(b64),i16=new Int16Array(bin.length/2);
357
+ for(let i=0;i<i16.length;i++)i16[i]=bin.charCodeAt(i*2)|(bin.charCodeAt(i*2+1)<<8);
358
+ const f32=new Float32Array(i16.length),fade=240;
359
+ for(let i=0;i<i16.length;i++)f32[i]=i16[i]/32768;
360
+ for(let i=0;i<fade&&i<f32.length;i++){f32[i]*=i/fade;f32[f32.length-1-i]*=i/fade;}
361
+ return {i16,f32};
362
+ }
363
+ function buildWav(chunks,sr){
364
+ let n=0;for(const c of chunks)n+=c.length;
365
+ const buf=new ArrayBuffer(44+n*2),v=new DataView(buf),w=(o,s)=>{for(let i=0;i<s.length;i++)v.setUint8(o+i,s.charCodeAt(i))};
366
+ w(0,'RIFF');v.setUint32(4,36+n*2,true);w(8,'WAVE');w(12,'fmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);
367
+ v.setUint32(24,sr,true);v.setUint32(28,sr*2,true);v.setUint16(32,2,true);v.setUint16(34,16,true);w(36,'data');v.setUint32(40,n*2,true);
368
+ let o=44;for(const c of chunks)for(let i=0;i<c.length;i++){v.setInt16(o,c[i],true);o+=2;}
369
+ return new Blob([buf],{type:'audio/wav'});
370
+ }
371
+ const blobToB64=blob=>new Promise(r=>{const fr=new FileReader();fr.onloadend=()=>r(fr.result.split(',')[1]);fr.readAsDataURL(blob)});
372
+ const fmtT=s=>{if(!isFinite(s)||s<0)s=0;s=Math.floor(s);return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`};
373
+ const fmtDur=s=>{s=Math.max(0,Math.floor(s));const h=s/3600|0,m=(s%3600/60)|0,x=s%60;return [h,m,x].map(n=>String(n).padStart(2,'0')).join(':')};
374
+ const waveChars=n=>{const c='▁▂▃▄▅▆▇';let s='';for(let i=0;i<n;i++){const v=(Math.sin(i*.7)+Math.sin(i*.31+1.3)+Math.sin(i*1.9+.4))/3;s+=c[Math.max(0,Math.min(c.length-1,Math.floor((v+1)/2*c.length)))];}return s};
375
+
376
+ function attachProg(audio,el,durHint,initPlayed){
377
+ const raw=el.textContent;if(!raw)return;
378
+ el.innerHTML='';el.classList.add('prog');
379
+ const chars=[...raw],wts=chars.map(c=>/\s/.test(c)?.2:/[,、;;::,]/.test(c)?.5:/[.。!!??…—]/.test(c)?1:1);
380
+ const cum=[0];for(const w of wts)cum.push(cum[cum.length-1]+w);
381
+ const tot=cum[cum.length-1]||1;
382
+ const spans=chars.map(c=>{const s=document.createElement('span');s.className='tk';s.textContent=c;el.appendChild(s);return s});
383
+ if(initPlayed)spans.forEach(s=>s.classList.add('played'));
384
+ const dur=()=>isFinite(audio.duration)&&audio.duration>0?audio.duration:(durHint||0);
385
+ const tOf=i=>dur()*cum[i]/tot;
386
+ const findCur=t=>{const d=dur();if(d<=0)return -1;const tg=t/d*tot;let lo=0,hi=spans.length-1;while(lo<hi){const m=(lo+hi+1)>>1;cum[m]<=tg?lo=m:hi=m-1}return lo};
387
+ spans.forEach((s,i)=>s.onclick=()=>{if(!audio.paused&&s.classList.contains('cur')){audio.pause();return}audio.currentTime=tOf(i);if(audio.paused)audio.play()});
388
+ const upd=()=>{const t=audio.currentTime,ci=!audio.paused?findCur(t):-1;spans.forEach((s,i)=>{s.classList.toggle('played',tOf(i)<=t);s.classList.toggle('cur',i===ci)});el.classList.toggle('on',!audio.paused)};
389
+ ['timeupdate','seeked','play','pause'].forEach(e=>audio.addEventListener(e,upd));
390
+ }
391
+ function mkPlayer(src){
392
+ const w=document.createElement('div');w.className='player';
393
+ w.innerHTML='<button class="pp">▶</button><div class="bar-wrap"><div class="bar"></div></div><span class="time">0:00 / 0:00</span>';
394
+ const a=new Audio(src),pp=w.querySelector('.pp'),bw=w.querySelector('.bar-wrap'),b=w.querySelector('.bar'),tm=w.querySelector('.time');
395
+ pp.onclick=()=>a.paused?a.play():a.pause();
396
+ a.onplay=()=>pp.textContent='❚❚';a.onpause=()=>pp.textContent='▶';
397
+ a.ontimeupdate=()=>{const d=a.duration||0;b.style.width=d?(a.currentTime/d*100)+'%':'0';tm.textContent=`${fmtT(a.currentTime)} / ${fmtT(d)}`};
398
+ a.onended=()=>b.style.width='0';a.onloadedmetadata=()=>tm.textContent=`0:00 / ${fmtT(a.duration)}`;
399
+ bw.onclick=e=>{const r=bw.getBoundingClientRect();if(a.duration)a.currentTime=(e.clientX-r.left)/r.width*a.duration};
400
+ return w;
401
+ }
402
+
403
+ let msgN=0;
404
+ function addMsg(root,role,opts={}){
405
+ const d=document.createElement('div');d.className='msg '+role;msgN++;
406
+ const ts=new Date().toLocaleTimeString('en-US',{hour12:false});
407
+ d.innerHTML=`<div class="meta">[${msgN}] ${role} @ ${ts}</div><div class="body"></div>`;
408
+ const body=d.querySelector('.body');
409
+ if(opts.image){const i=document.createElement('img');i.className='img';i.src=opts.image;body.appendChild(i)}
410
+ if(opts.audioUrl){
411
+ if(role==='user'&&opts.audioDuration){
412
+ const n=Math.max(14,Math.min(80,Math.round(opts.audioDuration/.15)));
413
+ const v=document.createElement('div');v.className='text voice';v.textContent=waveChars(n);body.appendChild(v);
414
+ attachProg(new Audio(opts.audioUrl),v,opts.audioDuration);
415
+ } else body.appendChild(mkPlayer(opts.audioUrl));
416
+ }
417
+ if(opts.text!==undefined){const t=document.createElement('div');t.className='text';t.textContent=opts.text;body.appendChild(t)}
418
+ root.appendChild(d);root.scrollTop=root.scrollHeight;
419
+ return d;
420
+ }
421
+
422
+ /* ------------- Chat mode ------------- */
423
+ let chatHist=[],pendImg=null,pendAud=null,rec=null,recChunks=[],recording=false,gen=false,actx=null,nextT=0,aborter=null,chatPlaying=[],chatEpoch=0;
424
+ function stopChatAudio(){for(const s of chatPlaying)try{s.stop()}catch{}chatPlaying=[];if(actx){actx.close().catch(()=>{});actx=null;nextT=0}}
425
+ function stopChat(){chatEpoch++;if(aborter)try{aborter.abort()}catch{}stopChatAudio();gen=false;sendBtn.disabled=false;ts.textContent='idle'}
426
+
427
+ function renderChips(){
428
+ att.innerHTML='';txt.disabled=!!pendAud;txt.placeholder=pendAud?'(voice attached — text disabled)':'Message MiniMind-O...';
429
+ if(pendImg){const c=document.createElement('span');c.className='chip';c.innerHTML=`<img src="${pendImg.url}"><span>image</span><span class="x">×</span>`;c.querySelector('.x').onclick=()=>{URL.revokeObjectURL(pendImg.url);pendImg=null;renderChips()};att.appendChild(c)}
430
+ if(pendAud){const c=document.createElement('span');c.className='chip';c.innerHTML=`<span class="play">▶ voice · ${pendAud.duration.toFixed(1)}s</span><span class="x">×</span>`;c.querySelector('.play').onclick=()=>new Audio(pendAud.url).play();c.querySelector('.x').onclick=()=>{URL.revokeObjectURL(pendAud.url);pendAud=null;renderChips()};att.appendChild(c)}
431
+ }
432
+
433
+ async function send(){
434
+ if(gen)return;
435
+ stopChatAudio();
436
+ const t=txt.value.trim();if(!t&&!pendImg&&!pendAud)return;
437
+ const o={};if(pendImg)o.image=pendImg.url;if(pendAud){o.audioUrl=pendAud.url;o.audioDuration=pendAud.duration}if(t)o.text=t;
438
+ addMsg(chat,'user',o);
439
+ const payload={text:t||'',audio:pendAud?.b64||null,image:pendImg?.b64||null,temperature:.7,max_tokens:512,history:chatHist,voice:voiceSel.value};
440
+ txt.value='';pendImg=null;pendAud=null;renderChips();
441
+ sendBtn.disabled=true;gen=true;stat.classList.remove('hidden');tt.textContent='—';ta.textContent='—';ts.textContent='sending';
442
+ const ad=addMsg(chat,'assistant',{text:'…'}),ab=ad.querySelector('.body'),at=ab.querySelector('.text');
443
+ let full='',segs=[],acc='';
444
+ try{
445
+ const myEpoch=++chatEpoch;
446
+ aborter=new AbortController();
447
+ const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload),signal:aborter.signal});
448
+ const rd=res.body.getReader(),dec=new TextDecoder();ts.textContent='streaming';
449
+ while(true){
450
+ if(myEpoch!==chatEpoch)throw new DOMException('chat stopped','AbortError');
451
+ const {done,value}=await rd.read();if(done)break;
452
+ for(const ln of dec.decode(value).split('\n')){
453
+ if(myEpoch!==chatEpoch)throw new DOMException('chat stopped','AbortError');
454
+ if(!ln.startsWith('data: '))continue;
455
+ let d;try{d=JSON.parse(ln.slice(6))}catch{continue}
456
+ if(d.type==='user_prompt')chatHist.push({role:'user',content:d.content});
457
+ else if(d.type==='ttft'){if(d.text_ttft!==undefined)tt.textContent=d.text_ttft+' ms';if(d.audio_ttft!==undefined)ta.textContent=d.audio_ttft+' ms'}
458
+ else if(d.type==='text'){full+=d.content;at.textContent=full;chat.scrollTop=chat.scrollHeight}
459
+ else if(d.type==='pcm'){
460
+ acc+=d.c;
461
+ if(d.d){
462
+ if(!actx){actx=new AudioContext({sampleRate:24000});nextT=actx.currentTime+.1}
463
+ const {i16,f32}=decPcm(acc);
464
+ const buf=actx.createBuffer(1,f32.length,24000);buf.getChannelData(0).set(f32);
465
+ const s=actx.createBufferSource();s.buffer=buf;s.connect(actx.destination);
466
+ s.onended=()=>{chatPlaying=chatPlaying.filter(x=>x!==s)};
467
+ chatPlaying.push(s);
468
+ if(nextT<actx.currentTime)nextT=actx.currentTime;
469
+ s.start(nextT);nextT+=buf.duration-.01;segs.push(i16);acc='';
470
+ }
471
+ } else if(d.type==='error')at.textContent='⚠ '+d.content;
472
+ }
473
+ }
474
+ if(segs.length){const url=URL.createObjectURL(buildWav(segs,24000));attachProg(new Audio(url),at,null,true)}
475
+ if(full)chatHist.push({role:'assistant',content:full});
476
+ ts.textContent='done';
477
+ } catch(e){if(e.name!=='AbortError'){at.textContent='⚠ '+e.message;ts.textContent='error'}}
478
+ gen=false;sendBtn.disabled=false;aborter=null;
479
+ }
480
+
481
+ sendBtn.onclick=send;
482
+ txt.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}});
483
+ const imgPop=$('imgPop'),fileBtn=$('fileBtn'),shotBtn=$('shotBtn'),snap=$('snap'),snapV=$('snapV'),snapOk=$('snapOk'),snapCancel=$('snapCancel');
484
+ const cloneModal=$('cloneModal'),cloneText=$('cloneText'),cloneStat=$('cloneStat'),cloneRecBtn=$('cloneRec'),cloneCancelBtn=$('cloneCancel'),cloneName=$('cloneName');
485
+ cloneText.textContent=CLONE_PROMPT;
486
+ moreBtn.onclick=e=>{e.stopPropagation();morePop.classList.toggle('on')};
487
+ morePop.onclick=e=>{
488
+ e.stopPropagation();
489
+ const del=e.target.closest('.vx');
490
+ if(del){const name=del.dataset.del;if(!confirm('确认删除 '+name+'?'))return;fetch('/delete_voice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}).then(r=>r.json()).then(d=>{if(!d.ok)throw new Error(d.error);loadVoices(voiceSel.value===name?'default':voiceSel.value)}).catch(e=>alert(e.message));return}
491
+ const add=e.target.closest('.vadd');
492
+ if(add){closeMore();stopChat();cloneModal.classList.add('on');if(!cloneName.value.trim())cloneName.value='voice_clone';cloneStat.textContent='请自然读完,不用刻意加速';return}
493
+ const item=e.target.closest('.vitem');
494
+ if(item&&item.dataset.voice){voiceSel.value=item.dataset.voice;renderVoiceList();closeMore()}
495
+ };
496
+ imgBtn.onclick=e=>{e.stopPropagation();imgPop.classList.toggle('on')};
497
+ document.addEventListener('click',e=>{if(!imgPop.contains(e.target)&&e.target!==imgBtn)imgPop.classList.remove('on');if(!morePop.contains(e.target)&&e.target!==moreBtn)closeMore()});
498
+ fileBtn.onclick=()=>{imgPop.classList.remove('on');imgIn.click()};
499
+ const setImg=(b64,url)=>{if(pendImg)URL.revokeObjectURL(pendImg.url);pendImg={b64,url};renderChips()};
500
+ imgIn.onchange=()=>{const f=imgIn.files?.[0];if(!f)return;const r=new FileReader();r.onloadend=()=>setImg(r.result.split(',')[1],URL.createObjectURL(f));r.readAsDataURL(f);imgIn.value=''};
501
+ let snapStream=null;
502
+ const closeSnap=()=>{snap.classList.remove('on');if(snapStream){snapStream.getTracks().forEach(t=>t.stop());snapStream=null}snapV.srcObject=null};
503
+ shotBtn.onclick=async()=>{
504
+ imgPop.classList.remove('on');
505
+ try{snapStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'user',width:1280,height:720}});snapV.srcObject=snapStream;snap.classList.add('on')}
506
+ catch(e){alert('camera error: '+e.message)}
507
+ };
508
+ snapCancel.onclick=closeSnap;
509
+ snapOk.onclick=()=>{
510
+ if(!snapV.videoWidth)return;
511
+ const cv=document.createElement('canvas');cv.width=snapV.videoWidth;cv.height=snapV.videoHeight;cv.getContext('2d').drawImage(snapV,0,0);
512
+ const dataUrl=cv.toDataURL('image/jpeg',.9),b64=dataUrl.split(',')[1];
513
+ cv.toBlob(blob=>setImg(b64,URL.createObjectURL(blob)),'image/jpeg',.9);
514
+ closeSnap();
515
+ };
516
+ const CLONE_MAX_SECS=6;
517
+ let cloneRec=null,cloneChunks=[],cloneRecording=false,cloneBusy=false,cloneCanceled=false,cloneStream=null,cloneDeadline=0,cloneTimer=null;
518
+ const stopCloneTimer=()=>{if(cloneTimer){clearInterval(cloneTimer);cloneTimer=null}};
519
+ const resetClone=()=>{stopCloneTimer();if(cloneStream){cloneStream.getTracks().forEach(t=>t.stop());cloneStream=null}cloneRec=null;cloneChunks=[];cloneRecording=false;cloneBusy=false;cloneCanceled=false;cloneDeadline=0;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneRecBtn.disabled=false;cloneCancelBtn.disabled=false};
520
+ const hideClone=()=>{cloneModal.classList.remove('on');resetClone();cloneStat.textContent='点击 start 开始录音'};
521
+ cloneCancelBtn.onclick=()=>{if(cloneBusy)return;if(cloneRecording&&cloneRec){cloneCanceled=true;cloneStat.textContent='已取消';cloneRec.stop()}else hideClone()};
522
+ cloneRecBtn.onclick=async()=>{
523
+ if(cloneBusy)return;
524
+ if(!cloneRecording){
525
+ try{
526
+ cloneStream=await navigator.mediaDevices.getUserMedia({audio:true});
527
+ cloneRec=new MediaRecorder(cloneStream);cloneChunks=[];cloneCanceled=false;
528
+ cloneRec.ondataavailable=e=>cloneChunks.push(e.data);
529
+ cloneRec.onstop=async()=>{
530
+ const canceled=cloneCanceled,blob=new Blob(cloneChunks,{type:(cloneRec&&cloneRec.mimeType)||'audio/webm'});
531
+ resetClone();
532
+ if(canceled){cloneModal.classList.remove('on');cloneStat.textContent='点击 start 开始录音';return}
533
+ cloneBusy=true;cloneRecBtn.disabled=true;cloneCancelBtn.disabled=true;cloneStat.textContent='正在提取音色…';
534
+ try{
535
+ const audio=await blobToB64(blob);
536
+ const res=await fetch('/clone_voice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({audio,name:cloneName.value.trim()})});
537
+ const d=await res.json();
538
+ if(!res.ok||!d.ok)throw new Error(d.error||'clone failed');
539
+ await loadVoices(d.voice);
540
+ voiceSel.value=d.voice;
541
+ cloneStat.textContent='音色已生成,已自动切换到 '+d.voice;
542
+ setTimeout(()=>hideClone(),600);
543
+ }catch(e){cloneBusy=false;cloneRecBtn.disabled=false;cloneCancelBtn.disabled=false;cloneStat.textContent='失败:'+e.message}
544
+ };
545
+ cloneRec.start();cloneRecording=true;cloneRecBtn.classList.add('rec');cloneRecBtn.textContent='stop';cloneDeadline=Date.now()+CLONE_MAX_SECS*1000;
546
+ const tick=()=>{
547
+ const left=Math.max(0,Math.ceil((cloneDeadline-Date.now())/1000));
548
+ cloneStat.textContent=`正在录音,请读完这句话 · ${left}s`;
549
+ if(left<=0&&cloneRecording&&cloneRec){cloneBusy=true;cloneRecording=false;cloneRecBtn.disabled=true;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneStat.textContent='录音结束,正在上传…';cloneRec.stop()}
550
+ };
551
+ tick();
552
+ cloneTimer=setInterval(tick,200);
553
+ }catch(e){alert('mic error: '+e.message)}
554
+ }else{
555
+ stopCloneTimer();cloneBusy=true;cloneRecording=false;cloneRecBtn.disabled=true;cloneRecBtn.classList.remove('rec');cloneRecBtn.textContent='start';cloneStat.textContent='录音结束,正在上传…';cloneRec.stop();
556
+ }
557
+ };
558
+ recBtn.onclick=async()=>{
559
+ if(!recording){
560
+ try{
561
+ const st=await navigator.mediaDevices.getUserMedia({audio:true});
562
+ rec=new MediaRecorder(st);recChunks=[];const t0=Date.now();
563
+ rec.ondataavailable=e=>recChunks.push(e.data);
564
+ rec.onstop=()=>{
565
+ st.getTracks().forEach(t=>t.stop());
566
+ const blob=new Blob(recChunks,{type:'audio/webm'}),dur=(Date.now()-t0)/1000,r=new FileReader();
567
+ r.onloadend=()=>{const b64=r.result.split(',')[1],url=URL.createObjectURL(blob);if(pendAud)URL.revokeObjectURL(pendAud.url);pendAud={b64,url,duration:dur};renderChips()};
568
+ r.readAsDataURL(blob);
569
+ };
570
+ rec.start();recording=true;recBtn.classList.add('rec');
571
+ } catch(e){alert('mic error: '+e.message)}
572
+ } else {rec.stop();recording=false;recBtn.classList.remove('rec')}
573
+ };
574
+
575
+ /* ------------- Call mode ------------- */
576
+ let callInit=false,callStop=null;
577
+ callBtn.onclick=openCall;
578
+ function openCall(){stopChat();document.body.classList.add('call');history.pushState({call:1},'','/call');if(!callInit){callInit=true;initCall()}}
579
+ function closeCall(){document.body.classList.remove('call');if(callStop)callStop();if(location.pathname==='/call')history.pushState({},'','/')}
580
+ window.addEventListener('popstate',()=>location.pathname==='/call'?openCall():closeCall());
581
+ if(location.pathname==='/call')openCall();
582
+
583
+ function initCall(){
584
+ const stT=$('stT'),upT=$('upT'),stIc=$('stIc'),lbl=$('lbl'),osvg=$('osvg'),core=$('core'),ripG=$('rip');
585
+ const ICONS={
586
+ idle:'<svg viewBox="0 0 24 24"><circle class="dot" cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>',
587
+ listen:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path class="w w3" d="M7 8.5a6 6 0 0 0 0 7"/><path class="w w3" d="M17 8.5a6 6 0 0 1 0 7"/><path class="w w2" d="M4 6a9.5 9.5 0 0 0 0 12"/><path class="w w2" d="M20 6a9.5 9.5 0 0 1 0 12"/></svg>',
588
+ gen:'<svg viewBox="0 0 24 24"><circle cx="12" cy="4" r="1.8" fill="currentColor" stroke="none"/><circle cx="18.93" cy="15.5" r="1.4" fill="currentColor" stroke="none" opacity=".65"/><circle cx="5.07" cy="15.5" r="1" fill="currentColor" stroke="none" opacity=".35"/></svg>',
589
+ speak:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path class="a1" d="M8 9a5 5 0 0 1 0 6"/><path class="a1" d="M16 9a5 5 0 0 0 0 6"/><path class="a2" d="M5 6.5a9 9 0 0 1 0 11"/><path class="a2" d="M19 6.5a9 9 0 0 0 0 11"/><path class="a3" d="M2 4a13 13 0 0 1 0 16"/><path class="a3" d="M22 4a13 13 0 0 0 0 16"/></svg>',
590
+ err:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><line x1="8.5" y1="8.5" x2="15.5" y2="15.5"/><line x1="15.5" y1="8.5" x2="8.5" y2="15.5"/></svg>',
591
+ };
592
+ const cchat=$('cchat'),lat=$('lat'),lmE=$('lmE'),ltE=$('ltE');
593
+ const camBtn=$('camBtn'),logBtn=$('logBtn'),endBtn=$('endBtn'),orb=$('orb'),camV=$('camV');
594
+ const pill=$('pill'),connT=$('connT'),tcnt=$('tcnt'),trans=$('trans'),closeBtn=$('closeBtn');
595
+ const blobs=[$('b0'),$('b1'),$('b2')];
596
+
597
+ let ac,ana,mic,camStream=null,camOn=false,hist=[],nextT=0;
598
+ let ws=null,wsActive=false,proc=null,rtc=null,rtSrc=null,playing=[],turns=0,mn=0;
599
+ let curMsg=null,curText='',segs=[],start=0,ripT=0,sVol=0,asstPlay=false,raf=0;
600
+ let spk=false,userBuf=[];
601
+ const RT_SAMPLE_RATE=16000,RT_BLOCK=4096;
602
+ const resamplePcm=(input,inRate,outRate)=>{
603
+ if(!input.length||inRate===outRate)return input;
604
+ const outLen=Math.max(1,Math.round(input.length*outRate/inRate));
605
+ const out=new Float32Array(outLen);
606
+ const step=inRate/outRate;
607
+ for(let i=0;i<outLen;i++){
608
+ const pos=i*step,idx=Math.floor(pos),frac=pos-idx;
609
+ const a=input[idx]||0;
610
+ const b=input[Math.min(idx+1,input.length-1)]||a;
611
+ out[i]=a+(b-a)*frac;
612
+ }
613
+ return out;
614
+ };
615
+ const floatToI16=input=>{const out=new Int16Array(input.length);for(let i=0;i<input.length;i++)out[i]=Math.max(-32768,Math.min(32767,input[i]*32768));return out};
616
+ const flushUser=()=>{
617
+ if(!userBuf.length)return;
618
+ const dur=userBuf.reduce((a,c)=>a+c.length,0)/16000;
619
+ const url=URL.createObjectURL(buildWav(userBuf,16000));
620
+ userBuf=[];
621
+ const d=addCM('user'),body=d.querySelector('.body'),n=Math.max(14,Math.min(80,Math.round(dur/.15)));
622
+ const v=document.createElement('div');v.className='text voice';v.textContent=waveChars(n);body.appendChild(v);
623
+ attachProg(new Audio(url),v,dur);
624
+ setLatest('user',`[voice · ${dur.toFixed(1)}s]`);
625
+ };
626
+
627
+ const N=32,NC=2,NR=5;let shT=0;
628
+ const LAY=[{c:56,r:112,a:1.8,sp:.0005,ph:.6,vc:34,vr:18},{c:80,r:124,a:2.6,sp:.0008,ph:1.3,vc:52,vr:26},{c:104,r:138,a:3.6,sp:.0011,ph:2.1,vc:74,vr:34}];
629
+
630
+ const catmull=pts=>{let d=`M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;const n=pts.length;for(let i=0;i<n;i++){const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n],p3=pts[(i+2)%n];const c1x=p1[0]+(p2[0]-p0[0])/6,c1y=p1[1]+(p2[1]-p0[1])/6,c2x=p2[0]-(p3[0]-p1[0])/6,c2y=p2[1]-(p3[1]-p1[1])/6;d+=`C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `}return d+'Z'};
631
+ const setSt=(cls,t)=>{osvg.setAttribute('class',cls||'');lbl.className='lbl '+(cls||'');stT.textContent=t;const key=cls||(/error/i.test(t)?'err':'idle');stIc.innerHTML=ICONS[key]||ICONS.idle};
632
+ const setConn=(live,t)=>{pill.className='pill'+(live?' live':'');connT.textContent=t};
633
+ const ripple=k=>{const now=performance.now();if(now-ripT<550)return;ripT=now;const c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.setAttribute('cx',0);c.setAttribute('cy',0);c.setAttribute('r',85);c.setAttribute('class','ripple '+k);ripG.appendChild(c);c.addEventListener('animationend',()=>c.remove())};
634
+ const upTimer=setInterval(()=>{if(start)upT.textContent=fmtDur((Date.now()-start)/1000)},1000);
635
+
636
+ const setLatest=(role,t)=>{if(!t)return;lat.classList.remove('empty','user','assistant');lat.classList.add(role);const ts=new Date().toLocaleTimeString('en-US',{hour12:false});lmE.textContent=`latest · ${role} @ ${ts}`;ltE.textContent=t;lat.scrollTop=0};
637
+ const addCM=role=>{const d=document.createElement('div');d.className='msg '+role;mn++;const ts=new Date().toLocaleTimeString('en-US',{hour12:false});d.innerHTML=`<div class="meta">[${mn}] ${role} @ ${ts}</div><div class="body"></div>`;cchat.appendChild(d);cchat.scrollTop=cchat.scrollHeight;return d};
638
+ const playPcm=b64=>{const {i16,f32}=decPcm(b64);segs.push(i16);const buf=ac.createBuffer(1,f32.length,24000);buf.getChannelData(0).set(f32);const s=ac.createBufferSource();s.buffer=buf;s.connect(ac.destination);s.onended=()=>{playing=playing.filter(x=>x!==s);if(!playing.length){asstPlay=false;if(!curMsg)setSt('','idle')}};playing.push(s);asstPlay=true;if(nextT<ac.currentTime)nextT=ac.currentTime;s.start(nextT);nextT+=buf.duration-.01};
639
+ const stopPcm=()=>{for(const s of playing)try{s.stop()}catch{}playing=[];asstPlay=false;if(ac)nextT=ac.currentTime};
640
+ const capCam=()=>{if(!camOn||!camV.videoWidth)return null;const cv=document.createElement('canvas');cv.width=camV.videoWidth;cv.height=camV.videoHeight;cv.getContext('2d').drawImage(camV,0,0);return cv.toDataURL('image/jpeg',.8).split(',')[1]};
641
+ const sendCtx=()=>{if(!ws||ws.readyState!==1)return;ws.send(JSON.stringify({type:'context',history:hist,image:camOn?capCam():null,voice:callVoiceSel.value}))};
642
+ window.callSendCtx=sendCtx;
643
+
644
+ function render(){
645
+ const now=performance.now();let vol=0,fq=null;
646
+ if(ana){const d=new Uint8Array(ana.frequencyBinCount);ana.getByteFrequencyData(d);fq=d;let s=0;for(let i=0;i<d.length;i++)s+=d[i];vol=s/d.length/255}
647
+ if(asstPlay)vol=Math.max(vol,.6+.25*Math.sin(now*.013));
648
+ sVol=sVol*.7+vol*.3;
649
+ const tgt=camOn?1:0;shT+=(tgt-shT)*.08;
650
+ const shN=NC+(NR-NC)*shT,inv=-1/shN;
651
+ for(let li=0;li<LAY.length;li++){
652
+ const L=LAY[li],hw=L.c+(L.r-L.c)*shT,hh=hw,vs=L.vc+(L.vr-L.vc)*shT;
653
+ const pts=new Array(N);
654
+ for(let i=0;i<N;i++){
655
+ const ang=(i/N)*Math.PI*2,cA=Math.cos(ang),sA=Math.sin(ang);
656
+ const n=Math.sin(now*L.sp+i*L.ph)+.55*Math.sin(now*L.sp*1.7+i*L.ph*.7+li);
657
+ let fv=0;if(fq){const idx=Math.floor((i/N)*Math.min(64,fq.length))+li*4;fv=(fq[idx%fq.length]||0)/255}
658
+ if(asstPlay)fv=Math.max(fv,.5+.45*Math.abs(Math.sin(now*.011+i*.55+li*.9)));
659
+ const g=Math.min(1,fv*.65+sVol*.55);
660
+ const tx=Math.pow(Math.abs(cA)/hw,shN),ty=Math.pow(Math.abs(sA)/hh,shN);
661
+ const r=Math.pow(tx+ty,inv)+n*L.a+g*vs;
662
+ pts[i]=[cA*r,sA*r];
663
+ }
664
+ blobs[li].setAttribute('d',catmull(pts));
665
+ }
666
+ core.setAttribute('r',(3+sVol*10).toFixed(1));
667
+ raf=requestAnimationFrame(render);
668
+ }
669
+ raf=requestAnimationFrame(render);
670
+
671
+ async function initMic(){
672
+ try{mic=await navigator.mediaDevices.getUserMedia({audio:true});ac=new AudioContext();const s=ac.createMediaStreamSource(mic);ana=ac.createAnalyser();ana.fftSize=256;s.connect(ana);setSt('','connecting');setConn(false,'connecting')}
673
+ catch(e){setSt('','mic error');setConn(false,'mic error')}
674
+ }
675
+
676
+ camBtn.onclick=async()=>{
677
+ if(camOn){if(camStream){camStream.getTracks().forEach(t=>t.stop());camStream=null}camV.srcObject=null;orb.classList.remove('camon');camBtn.classList.remove('active');camOn=false}
678
+ else{try{camStream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'user',width:640,height:480}});camV.srcObject=camStream;orb.classList.add('camon');camBtn.classList.add('active');camOn=true}catch{setSt('','cam error')}}
679
+ if(wsActive)sendCtx();
680
+ };
681
+ logBtn.onclick=()=>{trans.classList.toggle('on');logBtn.classList.toggle('active',trans.classList.contains('on'))};
682
+ closeBtn.onclick=()=>{trans.classList.remove('on');logBtn.classList.remove('active')};
683
+ endBtn.onclick=closeCall;
684
+
685
+ async function startRT(){
686
+ wsActive=true;if(!mic)await initMic();
687
+ rtc=new AudioContext();rtSrc=rtc.createMediaStreamSource(mic);proc=rtc.createScriptProcessor(4096,1,1);
688
+ let rtAcc=new Float32Array(0);
689
+ const pr=location.protocol==='https:'?'wss':'ws';
690
+ ws=new WebSocket(`${pr}://${location.host}/ws/realtime`);ws.binaryType='arraybuffer';
691
+ ws.onmessage=e=>{
692
+ const m=JSON.parse(e.data);
693
+ if(m.type==='vad'){
694
+ if(m.speaking&&!spk){sendCtx();stopPcm();ripple('user');userBuf=[]}
695
+ if(!m.speaking&&spk)flushUser();
696
+ spk=m.speaking;
697
+ if(m.speaking)setSt('listen','listening');else if(!asstPlay&&!curMsg)setSt('','idle');
698
+ } else if(m.type==='generating'){
699
+ setSt('gen','thinking');if(spk){flushUser();spk=false}
700
+ turns++;tcnt.textContent=turns+(turns===1?' turn':' turns');
701
+ curText='';segs=[];
702
+ const d=addCM('assistant'),body=d.querySelector('.body'),t=document.createElement('div');t.className='text';body.appendChild(t);
703
+ curMsg={div:d,body,textEl:t};stopPcm();
704
+ } else if(m.type==='text'){
705
+ curText+=m.content;if(curMsg)curMsg.textEl.textContent=curText;
706
+ setLatest('assistant',curText);cchat.scrollTop=cchat.scrollHeight;
707
+ } else if(m.type==='pcm'){setSt('speak','speaking');ripple('assistant');playPcm(m.data)}
708
+ else if(m.type==='done'){
709
+ if(curText)hist.push({role:'assistant',content:curText});
710
+ if(m.interrupted)stopPcm();
711
+ if(curMsg&&segs.length){const url=URL.createObjectURL(buildWav(segs,24000));attachProg(new Audio(url),curMsg.textEl,null,true)}
712
+ curMsg=null;if(!asstPlay)setSt('','idle');
713
+ }
714
+ };
715
+ ws.onclose=()=>{if(wsActive)stopRT()};
716
+ ws.onopen=()=>{
717
+ start=Date.now();setConn(true,'live');setSt('','idle');sendCtx();
718
+ proc.onaudioprocess=e=>{
719
+ if(ws.readyState!==1)return;
720
+ const rs=resamplePcm(e.inputBuffer.getChannelData(0),e.inputBuffer.sampleRate,RT_SAMPLE_RATE);
721
+ const merged=new Float32Array(rtAcc.length+rs.length);merged.set(rtAcc);merged.set(rs,rtAcc.length);
722
+ let off=0;
723
+ for(;merged.length-off>=RT_BLOCK;off+=RT_BLOCK){
724
+ const i16=floatToI16(merged.subarray(off,off+RT_BLOCK));
725
+ ws.send(i16.buffer);
726
+ if(spk)userBuf.push(new Int16Array(i16));
727
+ }
728
+ rtAcc=merged.slice(off);
729
+ };
730
+ rtSrc.connect(proc);proc.connect(rtc.destination);
731
+ };
732
+ }
733
+ function stopRT(){wsActive=false;if(ws){ws.close();ws=null}if(proc){proc.disconnect();proc.onaudioprocess=null;proc=null}if(rtSrc){rtSrc.disconnect();rtSrc=null}if(rtc){rtc.close();rtc=null}stopPcm();setSt('','disconnected');setConn(false,'offline')}
734
+
735
+ callStop=()=>{
736
+ stopRT();
737
+ window.callSendCtx=null;
738
+ if(camStream){camStream.getTracks().forEach(t=>t.stop());camStream=null;camOn=false;orb.classList.remove('camon');camV.srcObject=null;camBtn.classList.remove('active')}
739
+ if(mic){mic.getTracks().forEach(t=>t.stop());mic=null}
740
+ if(ac){try{ac.close()}catch{}ac=null}
741
+ if(raf)cancelAnimationFrame(raf);
742
+ clearInterval(upTimer);callInit=false;callStop=null;
743
+ };
744
+ initMic().then(startRT);
745
+ }
746
+ </script>
747
+ </body>
748
+ </html>
pyproject.toml CHANGED
@@ -1,10 +1,35 @@
1
  [project]
2
  name = "omni"
3
  version = "0.1.0"
4
- description = "Add your description here"
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
- dependencies = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  [tool.setuptools.packages.find]
10
  where = ["src"]
 
1
  [project]
2
  name = "omni"
3
  version = "0.1.0"
4
+ description = "MiniMind LLM (and future multimodal) training & inference framework, structured as an installable package."
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
+ dependencies = [
8
+ "torch>=2.6.0",
9
+ "transformers>=4.57.6",
10
+ "datasets>=3.6.0",
11
+ "tokenizers>=0.20.0",
12
+ "tiktoken>=0.10.0",
13
+ "jinja2>=3.1.2",
14
+ "numpy>=1.26.4",
15
+ "safetensors>=0.5.0",
16
+ "accelerate>=1.0.0",
17
+ "sentencepiece>=0.2.0",
18
+ "jieba>=0.42.1",
19
+ "pydantic>=2.11.5",
20
+ "rich>=13.7.1",
21
+ "regex",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ rl = ["swanlab>=0.7.11", "wandb>=0.18.3"]
26
+ serve = ["fastapi>=3.0.3", "uvicorn", "pydantic>=2.11.5", "openai>=1.59.6", "flask>=3.0.3", "flask-cors>=4.0.0"]
27
+ demo = ["streamlit>=1.50.0"]
28
+ all = ["swanlab>=0.7.11", "wandb>=0.18.3", "fastapi>=3.0.3", "uvicorn", "openai>=1.59.6", "flask>=3.0.3", "flask-cors>=4.0.0", "streamlit>=1.50.0", "sglang"]
29
+
30
+ [build-system]
31
+ requires = ["setuptools>=61.0"]
32
+ build-backend = "setuptools.build_meta"
33
 
34
  [tool.setuptools.packages.find]
35
  where = ["src"]
src/omni/core/__init__.py CHANGED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from omni.core.norm import RMSNorm
2
+ from omni.core.rope import precompute_freqs_cis, apply_rotary_pos_emb, repeat_kv
3
+ from omni.core.attention import Attention
4
+ from omni.core.mlp import FeedForward, MOEFeedForward
5
+ from omni.core.block import MiniMindBlock
6
+ from omni.core.model import MiniMindModel
7
+
8
+ __all__ = [
9
+ "RMSNorm",
10
+ "precompute_freqs_cis",
11
+ "apply_rotary_pos_emb",
12
+ "repeat_kv",
13
+ "Attention",
14
+ "FeedForward",
15
+ "MOEFeedForward",
16
+ "MiniMindBlock",
17
+ "MiniMindModel",
18
+ ]
src/omni/core/attention.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from torch import nn
5
+
6
+ from omni.core.norm import RMSNorm
7
+ from omni.core.rope import apply_rotary_pos_emb, repeat_kv
8
+
9
+
10
+ class Attention(nn.Module):
11
+ def __init__(self, config: "MiniMindConfig"):
12
+ super().__init__()
13
+ self.num_key_value_heads = config.num_attention_heads if config.num_key_value_heads is None else config.num_key_value_heads
14
+ self.n_local_heads = config.num_attention_heads
15
+ self.n_local_kv_heads = self.num_key_value_heads
16
+ self.n_rep = self.n_local_heads // self.n_local_kv_heads
17
+ self.head_dim = config.head_dim
18
+ self.is_causal = True
19
+ self.q_proj = nn.Linear(config.hidden_size, config.num_attention_heads * self.head_dim, bias=False)
20
+ self.k_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
21
+ self.v_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
22
+ self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=False)
23
+ self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
24
+ self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
25
+ self.attn_dropout = nn.Dropout(config.dropout)
26
+ self.resid_dropout = nn.Dropout(config.dropout)
27
+ self.dropout = config.dropout
28
+ self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') and config.flash_attn
29
+
30
+ def forward(self, x, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None):
31
+ bsz, seq_len, _ = x.shape
32
+ xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
33
+ xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim)
34
+ xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
35
+ xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
36
+ xq, xk = self.q_norm(xq), self.k_norm(xk)
37
+ cos, sin = position_embeddings
38
+ xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
39
+ if past_key_value is not None:
40
+ xk = torch.cat([past_key_value[0], xk], dim=1)
41
+ xv = torch.cat([past_key_value[1], xv], dim=1)
42
+ past_kv = (xk, xv) if use_cache else None
43
+ xq, xk, xv = (xq.transpose(1, 2), repeat_kv(xk, self.n_rep).transpose(1, 2), repeat_kv(xv, self.n_rep).transpose(1, 2))
44
+ if self.flash and (seq_len > 1) and (not self.is_causal or past_key_value is None) and (attention_mask is None or torch.all(attention_mask == 1)):
45
+ output = F.scaled_dot_product_attention(xq, xk, xv, dropout_p=self.dropout if self.training else 0.0, is_causal=self.is_causal)
46
+ else:
47
+ scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
48
+ if self.is_causal:
49
+ scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len), float("-inf"), device=scores.device).triu(1)
50
+ if attention_mask is not None:
51
+ scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
52
+ output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
53
+ output = output.transpose(1, 2).reshape(bsz, seq_len, -1)
54
+ output = self.resid_dropout(self.o_proj(output))
55
+ return output, past_kv
src/omni/core/block.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+ from omni.core.norm import RMSNorm
4
+ from omni.core.attention import Attention
5
+ from omni.core.mlp import FeedForward, MOEFeedForward
6
+
7
+
8
+ class MiniMindBlock(nn.Module):
9
+ def __init__(self, layer_id: int, config: "MiniMindConfig"):
10
+ super().__init__()
11
+ self.self_attn = Attention(config)
12
+ self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
13
+ self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
14
+ self.mlp = FeedForward(config) if not config.use_moe else MOEFeedForward(config)
15
+
16
+ def forward(self, hidden_states, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None):
17
+ residual = hidden_states
18
+ hidden_states, present_key_value = self.self_attn(
19
+ self.input_layernorm(hidden_states), position_embeddings,
20
+ past_key_value, use_cache, attention_mask
21
+ )
22
+ hidden_states += residual
23
+ hidden_states = hidden_states + self.mlp(self.post_attention_layernorm(hidden_states))
24
+ return hidden_states, present_key_value
src/omni/core/mlp.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+ from torch import nn
4
+ from transformers.activations import ACT2FN
5
+
6
+
7
+ class FeedForward(nn.Module):
8
+ def __init__(self, config: "MiniMindConfig", intermediate_size: int = None):
9
+ super().__init__()
10
+ intermediate_size = intermediate_size or config.intermediate_size
11
+ self.gate_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
12
+ self.down_proj = nn.Linear(intermediate_size, config.hidden_size, bias=False)
13
+ self.up_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
14
+ self.act_fn = ACT2FN[config.hidden_act]
15
+
16
+ def forward(self, x):
17
+ return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
18
+
19
+
20
+ class MOEFeedForward(nn.Module):
21
+ def __init__(self, config: "MiniMindConfig"):
22
+ super().__init__()
23
+ self.config = config
24
+ self.gate = nn.Linear(config.hidden_size, config.num_experts, bias=False)
25
+ self.experts = nn.ModuleList([FeedForward(config, intermediate_size=config.moe_intermediate_size) for _ in range(config.num_experts)])
26
+ self.act_fn = ACT2FN[config.hidden_act]
27
+
28
+ def forward(self, x):
29
+ batch_size, seq_len, hidden_dim = x.shape
30
+ x_flat = x.view(-1, hidden_dim)
31
+ scores = F.softmax(self.gate(x_flat), dim=-1)
32
+ topk_weight, topk_idx = torch.topk(scores, k=self.config.num_experts_per_tok, dim=-1, sorted=False)
33
+ if self.config.norm_topk_prob:
34
+ topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20)
35
+ y = torch.zeros_like(x_flat)
36
+ for i, expert in enumerate(self.experts):
37
+ mask = (topk_idx == i)
38
+ if mask.any():
39
+ token_idx = mask.any(dim=-1).nonzero().flatten()
40
+ weight = topk_weight[mask].view(-1, 1)
41
+ y.index_add_(0, token_idx, (expert(x_flat[token_idx]) * weight).to(y.dtype))
42
+ elif self.training:
43
+ y[0, 0] += 0 * sum(p.sum() for p in expert.parameters())
44
+ if self.training and self.config.router_aux_loss_coef > 0:
45
+ load = F.one_hot(topk_idx, self.config.num_experts).float().mean(0)
46
+ self.aux_loss = (load * scores.mean(0)).sum() * self.config.num_experts * self.config.router_aux_loss_coef
47
+ else:
48
+ self.aux_loss = scores.new_zeros(1).squeeze()
49
+ return y.view(batch_size, seq_len, hidden_dim)
src/omni/core/model.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+ from omni.core.norm import RMSNorm
4
+ from omni.core.rope import precompute_freqs_cis
5
+ from omni.core.block import MiniMindBlock
6
+ from omni.core.mlp import MOEFeedForward
7
+
8
+
9
+ class MiniMindModel(nn.Module):
10
+ def __init__(self, config: "MiniMindConfig"):
11
+ super().__init__()
12
+ self.config = config
13
+ self.vocab_size, self.num_hidden_layers = config.vocab_size, config.num_hidden_layers
14
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
15
+ self.dropout = nn.Dropout(config.dropout)
16
+ self.layers = nn.ModuleList([MiniMindBlock(l, config) for l in range(self.num_hidden_layers)])
17
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
18
+ freqs_cos, freqs_sin = precompute_freqs_cis(dim=config.head_dim, end=config.max_position_embeddings, rope_base=config.rope_theta, rope_scaling=config.rope_scaling)
19
+ self.register_buffer("freqs_cos", freqs_cos, persistent=False)
20
+ self.register_buffer("freqs_sin", freqs_sin, persistent=False)
21
+
22
+ def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, **kwargs):
23
+ batch_size, seq_length = input_ids.shape
24
+ if hasattr(past_key_values, 'layers'):
25
+ past_key_values = None
26
+ past_key_values = past_key_values or [None] * len(self.layers)
27
+ start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
28
+ hidden_states = self.dropout(self.embed_tokens(input_ids))
29
+ if self.freqs_cos[0, 0] == 0:
30
+ freqs_cos, freqs_sin = precompute_freqs_cis(dim=self.config.head_dim, end=self.config.max_position_embeddings, rope_base=self.config.rope_theta, rope_scaling=self.config.rope_scaling)
31
+ self.freqs_cos, self.freqs_sin = freqs_cos.to(hidden_states.device), freqs_sin.to(hidden_states.device)
32
+ position_embeddings = (self.freqs_cos[start_pos:start_pos + seq_length], self.freqs_sin[start_pos:start_pos + seq_length])
33
+ presents = []
34
+ for layer, past_key_value in zip(self.layers, past_key_values):
35
+ hidden_states, present = layer(
36
+ hidden_states,
37
+ position_embeddings,
38
+ past_key_value=past_key_value,
39
+ use_cache=use_cache,
40
+ attention_mask=attention_mask
41
+ )
42
+ presents.append(present)
43
+ hidden_states = self.norm(hidden_states)
44
+ aux_loss = sum([l.mlp.aux_loss for l in self.layers if isinstance(l.mlp, MOEFeedForward)], hidden_states.new_zeros(1).squeeze())
45
+ return hidden_states, presents, aux_loss
src/omni/core/norm.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class RMSNorm(torch.nn.Module):
6
+ def __init__(self, dim: int, eps: float = 1e-5):
7
+ super().__init__()
8
+ self.eps = eps
9
+ self.weight = nn.Parameter(torch.ones(dim))
10
+
11
+ def norm(self, x):
12
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
13
+
14
+ def forward(self, x):
15
+ return (self.weight * self.norm(x.float())).type_as(x)
src/omni/core/rope.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+
4
+
5
+ def precompute_freqs_cis(dim: int, end: int = int(32 * 1024), rope_base: float = 1e6, rope_scaling: dict = None):
6
+ freqs, attn_factor = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)), 1.0
7
+ if rope_scaling is not None: # YaRN
8
+ orig_max, factor, beta_fast, beta_slow, attn_factor = (
9
+ rope_scaling.get("original_max_position_embeddings", 2048), rope_scaling.get("factor", 16),
10
+ rope_scaling.get("beta_fast", 32.0), rope_scaling.get("beta_slow", 1.0), rope_scaling.get("attention_factor", 1.0)
11
+ )
12
+ if end / orig_max > 1.0:
13
+ inv_dim = lambda b: (dim * math.log(orig_max / (b * 2 * math.pi))) / (2 * math.log(rope_base))
14
+ low, high = max(math.floor(inv_dim(beta_fast)), 0), min(math.ceil(inv_dim(beta_slow)), dim // 2 - 1)
15
+ ramp = torch.clamp((torch.arange(dim // 2, device=freqs.device).float() - low) / max(high - low, 0.001), 0, 1)
16
+ freqs = freqs * (1 - ramp + ramp / factor)
17
+ t = torch.arange(end, device=freqs.device)
18
+ freqs = torch.outer(t, freqs).float()
19
+ freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1) * attn_factor
20
+ freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1) * attn_factor
21
+ return freqs_cos, freqs_sin
22
+
23
+
24
+ def apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
25
+ def rotate_half(x):
26
+ return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)
27
+
28
+ q_embed = ((q * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(q) * sin.unsqueeze(unsqueeze_dim))).to(q.dtype)
29
+ k_embed = ((k * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(k) * sin.unsqueeze(unsqueeze_dim))).to(k.dtype)
30
+ return q_embed, k_embed
31
+
32
+
33
+ def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
34
+ bs, slen, num_key_value_heads, head_dim = x.shape
35
+ if n_rep == 1:
36
+ return x
37
+ return (x[:, :, :, None, :].expand(bs, slen, num_key_value_heads, n_rep, head_dim).reshape(bs, slen, num_key_value_heads * n_rep, head_dim))
src/omni/datasets/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from omni.datasets.lm_dataset import * # noqa: F401,F403
src/omni/datasets/lm_dataset.py ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch.utils.data import Dataset, DataLoader
2
+ import torch
3
+ import io
4
+ import json
5
+ import os
6
+ import random
7
+ from datasets import load_dataset, Features, Sequence, Value
8
+ from PIL import Image
9
+ from datasets import Dataset as HFDataset
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+
13
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
14
+
15
+ from omni.models import MiniMindVLM
16
+
17
+
18
+ def pre_processing_chat(conversations, add_system_ratio=0.2):
19
+ if any(conv.get('tools') for conv in conversations):
20
+ return conversations
21
+
22
+ SYSTEM_PROMPTS = [
23
+ "你是一个知识丰富的AI,尽力为用户提供准确的信息。",
24
+ "你是minimind,一个小巧但有用的语言模型。",
25
+ "你是一个专业的AI助手,请提供有价值的回答。",
26
+ "你是minimind,请尽力帮助用户解决问题。",
27
+ "你是一个可靠的AI,请给出准确的回答。",
28
+ "You are a helpful AI assistant.",
29
+ "You are minimind, a lightweight intelligent assistant.",
30
+ "You are a friendly chatbot. Please answer the user's questions carefully.",
31
+ "You are a knowledgeable AI. Try your best to provide accurate information.",
32
+ "You are minimind, a small but useful language model."
33
+ ]
34
+ if conversations[0].get('role') != 'system':
35
+ if random.random() < add_system_ratio:
36
+ return [{'role': 'system', 'content': random.choice(SYSTEM_PROMPTS)}] + conversations
37
+ return conversations
38
+
39
+
40
+ def post_processing_chat(prompt_content, empty_think_ratio=0.2):
41
+ if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
42
+ prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
43
+ return prompt_content
44
+
45
+
46
+ class PretrainDataset(Dataset):
47
+ def __init__(self, data_path, tokenizer, max_length=512):
48
+ super().__init__()
49
+ self.tokenizer = tokenizer
50
+ self.max_length = max_length
51
+ self.samples = load_dataset('json', data_files=data_path, split='train')
52
+
53
+ def __len__(self):
54
+ return len(self.samples)
55
+
56
+ def __getitem__(self, index):
57
+ sample = self.samples[index]
58
+ tokens = self.tokenizer(str(sample['text']), add_special_tokens=False, max_length=self.max_length - 2, truncation=True).input_ids
59
+ tokens = [self.tokenizer.bos_token_id] + tokens + [self.tokenizer.eos_token_id]
60
+ input_ids = tokens + [self.tokenizer.pad_token_id] * (self.max_length - len(tokens))
61
+ input_ids = torch.tensor(input_ids, dtype=torch.long)
62
+ labels = input_ids.clone()
63
+ labels[input_ids == self.tokenizer.pad_token_id] = -100
64
+ return input_ids, labels
65
+
66
+
67
+ class SFTDataset(Dataset):
68
+ def __init__(self, jsonl_path, tokenizer, max_length=1024):
69
+ super().__init__()
70
+ self.tokenizer = tokenizer
71
+ self.max_length = max_length
72
+ features = Features({'conversations': [{'role': Value('string'), 'content': Value('string'), 'reasoning_content': Value('string'), 'tools': Value('string'), 'tool_calls': Value('string')}]})
73
+ self.samples = load_dataset('json', data_files=jsonl_path, split='train', features=features)
74
+ self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
75
+ self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
76
+
77
+ def __len__(self):
78
+ return len(self.samples)
79
+
80
+ def create_chat_prompt(self, conversations):
81
+ messages = []
82
+ tools = None
83
+ for message in conversations:
84
+ message = dict(message)
85
+ if message.get("role") == "system" and message.get("tools"):
86
+ tools = json.loads(message["tools"]) if isinstance(message["tools"], str) else message["tools"]
87
+ if message.get("tool_calls") and isinstance(message["tool_calls"], str):
88
+ message["tool_calls"] = json.loads(message["tool_calls"])
89
+ messages.append(message)
90
+ return self.tokenizer.apply_chat_template(
91
+ messages,
92
+ tokenize=False,
93
+ add_generation_prompt=False,
94
+ tools=tools
95
+ )
96
+
97
+ def generate_labels(self, input_ids):
98
+ labels = [-100] * len(input_ids)
99
+ i = 0
100
+ while i < len(input_ids):
101
+ if input_ids[i:i + len(self.bos_id)] == self.bos_id:
102
+ start = i + len(self.bos_id)
103
+ end = start
104
+ while end < len(input_ids):
105
+ if input_ids[end:end + len(self.eos_id)] == self.eos_id:
106
+ break
107
+ end += 1
108
+ for j in range(start, min(end + len(self.eos_id), self.max_length)):
109
+ labels[j] = input_ids[j]
110
+ i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
111
+ else:
112
+ i += 1
113
+ return labels
114
+
115
+ def __getitem__(self, index):
116
+ sample = self.samples[index]
117
+ conversations = pre_processing_chat(sample['conversations'])
118
+ prompt = self.create_chat_prompt(conversations)
119
+ prompt = post_processing_chat(prompt)
120
+ input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
121
+ input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
122
+ labels = self.generate_labels(input_ids)
123
+ return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
124
+
125
+
126
+ class DPODataset(Dataset):
127
+ def __init__(self, file_path, tokenizer, max_length=4096):
128
+ super().__init__()
129
+ self.tokenizer = tokenizer
130
+ self.max_length = max_length
131
+ self.padding = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
132
+ self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
133
+ self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
134
+ self.samples = load_dataset('json', data_files=file_path, split='train')
135
+
136
+ def __len__(self):
137
+ return len(self.samples)
138
+
139
+ def __getitem__(self, index):
140
+ sample = self.samples[index]
141
+ chosen = sample['chosen']
142
+ rejected = sample['rejected']
143
+ chosen_prompt = self.tokenizer.apply_chat_template(
144
+ chosen, tokenize=False, add_generation_prompt=False
145
+ )
146
+ chosen_prompt = post_processing_chat(chosen_prompt)
147
+
148
+ rejected_prompt = self.tokenizer.apply_chat_template(
149
+ rejected, tokenize=False, add_generation_prompt=False
150
+ )
151
+ rejected_prompt = post_processing_chat(rejected_prompt)
152
+ chosen_encoding = self.tokenizer(
153
+ chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length'
154
+ )
155
+ rejected_encoding = self.tokenizer(
156
+ rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length'
157
+ )
158
+
159
+ chosen_input_ids = chosen_encoding['input_ids']
160
+ chosen_loss_mask = self.generate_loss_mask(chosen_input_ids)
161
+
162
+ rejected_input_ids = rejected_encoding['input_ids']
163
+ rejected_loss_mask = self.generate_loss_mask(rejected_input_ids)
164
+ x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long)
165
+ y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long)
166
+ mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long)
167
+ x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long)
168
+ y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long)
169
+ mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long)
170
+
171
+ return {
172
+ 'x_chosen': x_chosen,
173
+ 'y_chosen': y_chosen,
174
+ 'mask_chosen': mask_chosen,
175
+ 'x_rejected': x_rejected,
176
+ 'y_rejected': y_rejected,
177
+ 'mask_rejected': mask_rejected
178
+ }
179
+
180
+ def generate_loss_mask(self, input_ids):
181
+ loss_mask = [0] * len(input_ids)
182
+ i = 0
183
+ while i < len(input_ids):
184
+ if input_ids[i:i + len(self.bos_id)] == self.bos_id:
185
+ start = i + len(self.bos_id)
186
+ end = start
187
+ while end < len(input_ids):
188
+ if input_ids[end:end + len(self.eos_id)] == self.eos_id:
189
+ break
190
+ end += 1
191
+ for j in range(start, min(end + len(self.eos_id), self.max_length)):
192
+ loss_mask[j] = 1
193
+ i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
194
+ else:
195
+ i += 1
196
+ return loss_mask
197
+
198
+
199
+ class RLAIFDataset(Dataset):
200
+ def __init__(self, jsonl_path, tokenizer, max_length=1024, thinking_ratio=0.5):
201
+ super().__init__()
202
+ self.tokenizer = tokenizer
203
+ self.max_length = max_length
204
+ self.thinking_ratio = thinking_ratio
205
+ self.samples = load_dataset('json', data_files=jsonl_path, split='train')
206
+ self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant', add_special_tokens=False).input_ids
207
+ self.eos_id = tokenizer(f'{tokenizer.eos_token}', add_special_tokens=False).input_ids
208
+
209
+ def __len__(self):
210
+ return len(self.samples)
211
+
212
+ def create_chat_prompt(self, conversations):
213
+ conversations = pre_processing_chat(conversations)
214
+ use_thinking = random.random() < self.thinking_ratio
215
+ return self.tokenizer.apply_chat_template(
216
+ conversations[:-1],
217
+ tokenize=False,
218
+ open_thinking=use_thinking,
219
+ add_generation_prompt=True
220
+ )
221
+
222
+ def __getitem__(self, index):
223
+ sample = self.samples[index]
224
+ prompt = self.create_chat_prompt(sample['conversations'])
225
+
226
+ return {
227
+ 'prompt': prompt,
228
+ 'answer': ""
229
+ }
230
+
231
+
232
+ class AgentRLDataset(Dataset):
233
+ def __init__(self, jsonl_path, tokenizer, max_length=1024):
234
+ super().__init__()
235
+ self.tokenizer = tokenizer
236
+ self.max_length = max_length
237
+ self.samples = []
238
+ with open(jsonl_path, 'r', encoding='utf-8') as f:
239
+ for line in f:
240
+ self.samples.append(json.loads(line.strip()))
241
+
242
+ def __len__(self):
243
+ return len(self.samples)
244
+
245
+ def parse_conversations(self, conversations):
246
+ messages = []
247
+ tools = None
248
+ for message in conversations:
249
+ message = dict(message)
250
+ if message.get("role") == "system" and message.get("tools"):
251
+ tools = json.loads(message["tools"]) if isinstance(message["tools"], str) else message["tools"]
252
+ messages.append(message)
253
+ return messages[:-1], tools
254
+
255
+ def __getitem__(self, index):
256
+ sample = self.samples[index]
257
+ messages, tools = self.parse_conversations(sample['conversations'])
258
+ return {'messages': messages, 'tools': tools, 'gt': sample['gt']}
259
+
260
+
261
+ class VLMDataset(Dataset):
262
+ def __init__(self, parquet_path, tokenizer, preprocess=None, max_length=512, image_special_token='<|image_pad|>', image_token_len=64):
263
+ super().__init__()
264
+ self.dataset = HFDataset.from_parquet(parquet_path)
265
+ self.tokenizer = tokenizer
266
+ self.max_length = max_length
267
+ self.preprocess = preprocess
268
+ self.image_special_token = image_special_token * image_token_len
269
+ self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
270
+ self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
271
+
272
+ def __len__(self):
273
+ return len(self.dataset)
274
+
275
+ def create_chat_prompt(self, conversations):
276
+ messages = []
277
+ for turn in conversations:
278
+ content = turn['content'].replace('<image>', self.image_special_token) if turn.get('role') != 'system' else turn['content']
279
+ messages.append({"role": turn['role'], "content": content})
280
+ tools = conversations[0]["functions"] if (conversations and conversations[0]["role"] == "system" and conversations[0].get("functions")) else None
281
+ return self.tokenizer.apply_chat_template(
282
+ messages,
283
+ tokenize=False,
284
+ add_generation_prompt=False,
285
+ tools=tools
286
+ )
287
+
288
+ def generate_labels(self, input_ids):
289
+ labels = [-100] * len(input_ids)
290
+ i = 0
291
+ while i < len(input_ids):
292
+ if input_ids[i:i + len(self.bos_id)] == self.bos_id:
293
+ start = i + len(self.bos_id)
294
+ end = start
295
+ while end < len(input_ids):
296
+ if input_ids[end:end + len(self.eos_id)] == self.eos_id:
297
+ break
298
+ end += 1
299
+ for j in range(start, min(end + len(self.eos_id), self.max_length)):
300
+ labels[j] = input_ids[j]
301
+ i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
302
+ else:
303
+ i += 1
304
+ return labels
305
+
306
+ def __getitem__(self, index: int):
307
+ row = self.dataset[index]
308
+ conversations = json.loads(row['conversations']) if isinstance(row['conversations'], str) else row['conversations']
309
+ image_bytes = row['image_bytes']
310
+ if not isinstance(image_bytes, list): image_bytes = [image_bytes]
311
+
312
+ conversations = pre_processing_chat(conversations)
313
+ prompt = self.create_chat_prompt(conversations)
314
+ prompt = post_processing_chat(prompt)
315
+ input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
316
+ input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
317
+ labels = self.generate_labels(input_ids)
318
+
319
+ image_inputs_list = [MiniMindVLM.image2tensor(Image.open(io.BytesIO(img)), self.preprocess) for img in image_bytes]
320
+ if hasattr(image_inputs_list[0], 'keys'):
321
+ image_data = {k: torch.cat([inp[k] for inp in image_inputs_list], dim=0) for k in image_inputs_list[0].keys()}
322
+ else:
323
+ image_data = torch.stack(image_inputs_list)
324
+
325
+ return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long), image_data
326
+
327
+
328
+ class OmniDataset(Dataset):
329
+ def __init__(self, data_path, tokenizer, audio_processor=None, vision_processor=None,
330
+ max_length=1200, audio_special_token='<|audio_pad|>', image_special_token='<|image_pad|>',
331
+ audio_stop_token=2050, # <|audio_stop|>
332
+ audio_pad_token=2049, # <|audio_pad|>
333
+ audio_spk_token=2051, # <|audio_spk|>
334
+ audio_vocab_size=2112, # 2048 mimi codes + 64 special tokens
335
+ scheduled_sampling=0.05,
336
+ image_token_len=64):
337
+ super().__init__()
338
+ import pyarrow as pa
339
+ import pyarrow.parquet as pq
340
+ tables = [pa.Table.from_batches(pq.ParquetFile(p.strip()).iter_batches()) for p in data_path.split(',')]
341
+ tables = [t.cast(pa.schema([f.with_type(pa.large_string()) if pa.types.is_string(f.type) else f for f in t.schema])) for t in tables]
342
+ self.table = pa.concat_tables(tables, promote_options='default')
343
+ self.tokenizer = tokenizer
344
+ self.audio_processor = audio_processor
345
+ self.vision_processor = vision_processor
346
+ self.max_length = max_length
347
+ self.audio_token = audio_special_token
348
+ self.image_token_len = image_token_len
349
+ self.image_token = image_special_token * image_token_len
350
+ self.audio_stop_token = audio_stop_token
351
+ self.audio_pad_token = audio_pad_token
352
+ self.audio_spk_token = audio_spk_token
353
+ self.audio_vocab_size = audio_vocab_size
354
+ self.scheduled_sampling_prob = scheduled_sampling
355
+ self.text_vocab_size = len(tokenizer)
356
+ self.image_token_id = tokenizer.encode(image_special_token, add_special_tokens=False)[0]
357
+ self.audio_token_id = tokenizer.encode(audio_special_token, add_special_tokens=False)[0]
358
+ self.think_end_ids = tokenizer.encode('</think>\n\n', add_special_tokens=False)
359
+ self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n', add_special_tokens=False).input_ids
360
+ self.eos_id = tokenizer(f'{tokenizer.eos_token}\n', add_special_tokens=False).input_ids
361
+
362
+ def __len__(self):
363
+ return len(self.table)
364
+
365
+ @staticmethod
366
+ def process_audio(audio_path, audio_processor):
367
+ import soundfile as sf
368
+ import numpy as np
369
+ wav, sr = sf.read(audio_path)
370
+ if wav.ndim > 1: wav = wav.mean(axis=1)
371
+ if sr != 16000:
372
+ import librosa
373
+ wav = librosa.resample(wav.astype(float), orig_sr=sr, target_sr=16000)
374
+ inputs = audio_processor(wav.astype(np.float32), sampling_rate=16000, return_tensors="pt", return_attention_mask=True)
375
+ valid_len = inputs.attention_mask.sum().item()
376
+ return inputs.input_features.squeeze(0), valid_len
377
+
378
+ def augment_wav(self, wav, sr=16000):
379
+ import numpy as np
380
+ from scipy.signal import resample
381
+ if random.random() < 0.5:
382
+ speed = random.uniform(0.7, 1.6)
383
+ wav = resample(wav, int(len(wav) / speed)).astype(np.float32)
384
+ if random.random() < 0.3:
385
+ noise = np.random.randn(len(wav)).astype(np.float32) * random.uniform(0.001, 0.01)
386
+ wav = wav + noise
387
+ if random.random() < 0.3:
388
+ wav = wav * random.uniform(0.8, 1.2)
389
+ if random.random() < 0.2 and len(wav) > sr:
390
+ start = random.randint(0, len(wav) - sr // 4)
391
+ wav[start:start + sr // 4] = 0
392
+ if random.random() < 0.2:
393
+ k = random.choice([3, 5, 7])
394
+ wav = np.convolve(wav, np.ones(k) / k, mode='same').astype(np.float32)
395
+ if random.random() < 0.3:
396
+ ir_len = int(sr * random.uniform(0.05, 0.2))
397
+ ir = np.random.randn(ir_len).astype(np.float32) * np.exp(-np.linspace(0, 10, ir_len))
398
+ ir[0] = 1.0
399
+ ir /= np.sqrt(np.sum(ir ** 2) + 1e-6)
400
+ wav = np.convolve(wav, ir, mode='same').astype(np.float32)
401
+ if random.random() < 0.2:
402
+ pink = np.cumsum(np.random.randn(len(wav))).astype(np.float32)
403
+ pink /= np.max(np.abs(pink)) + 1e-6
404
+ wav = wav + pink * random.uniform(0.003, 0.015)
405
+ return np.clip(wav, -1.0, 1.0).astype(np.float32)
406
+
407
+ def augment_mel(self, fbank):
408
+ import numpy as np
409
+ T, D = fbank.shape
410
+ if random.random() < 0.5:
411
+ f = random.randint(1, 64)
412
+ f0 = random.randint(0, D - f)
413
+ fbank[:, f0:f0 + f] = 0
414
+ if random.random() < 0.5 and T > 1:
415
+ t = random.randint(1, min(10, T))
416
+ t0 = random.randint(0, T - t)
417
+ fbank[t0:t0 + t, :] = 0
418
+ return fbank
419
+
420
+ def load_audio_inputs(self, audio_bytes):
421
+ import soundfile as sf
422
+ import numpy as np
423
+ import io
424
+ import librosa
425
+ if not audio_bytes: return None, 0
426
+ wav, sr = sf.read(io.BytesIO(audio_bytes))
427
+ if wav.ndim > 1: wav = wav.mean(axis=1)
428
+ if sr != 16000: wav = librosa.resample(wav.astype(float), orig_sr=sr, target_sr=16000)
429
+ wav = self.augment_wav(wav.astype(np.float32))
430
+ inputs = self.audio_processor(wav, sampling_rate=16000, return_tensors="pt", return_attention_mask=True)
431
+ valid_len = inputs.attention_mask.sum().item()
432
+ return self.augment_mel(inputs.input_features.squeeze(0)), valid_len
433
+
434
+ def load_image_inputs(self, image_bytes):
435
+ import io
436
+ from PIL import Image
437
+ if not image_bytes or self.vision_processor is None: return None
438
+ image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
439
+ inputs = self.vision_processor(images=image, return_tensors="pt")
440
+ if hasattr(inputs, 'keys'): return {k: v for k, v in inputs.items()}
441
+ return inputs.pixel_values
442
+
443
+ def create_chat_prompt(self, conversations, audio_features_length=0):
444
+ conversations = pre_processing_chat(conversations)
445
+ messages = []
446
+ is_last_user = lambda i: i == max(j for j, t in enumerate(conversations) if t['role'] == 'user')
447
+ for idx, turn in enumerate(conversations):
448
+ role, content = turn['role'], turn['content']
449
+ if role == 'user' and is_last_user(idx) and audio_features_length > 0:
450
+ ap = self.audio_token * audio_features_length
451
+ r = random.random()
452
+ if r < 0.4: content = ap
453
+ elif r < 0.6: content = content
454
+ elif r < 0.8: content = ap + '\n\n' + content
455
+ else: content = content + '\n\n' + ap
456
+ if '<image>' in content:
457
+ r = random.random()
458
+ if r < 0.2: content = '<image>\n' + content.replace('<image>', '').strip()
459
+ elif r < 0.4: content = '<image>\n\n' + content.replace('<image>', '').strip()
460
+ elif r < 0.6: content = content.replace('<image>', '').strip() + '\n' + '<image>'
461
+ else: content = content.replace('<image>', '').strip() + '\n\n' + '<image>'
462
+ messages.append({"role": role, "content": content})
463
+ prompt = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
464
+ return post_processing_chat(prompt)
465
+
466
+ def generate_text_labels(self, input_ids):
467
+ labels = [-100] * len(input_ids)
468
+ ranges = []
469
+ i = 0
470
+ while i < len(input_ids):
471
+ if input_ids[i:i + len(self.bos_id)] == self.bos_id:
472
+ start = i + len(self.bos_id)
473
+ end = start
474
+ while end < len(input_ids):
475
+ if input_ids[end:end + len(self.eos_id)] == self.eos_id:
476
+ break
477
+ end += 1
478
+ ranges.append((start, end))
479
+ for j in range(start, min(end + len(self.eos_id), self.max_length)):
480
+ labels[j] = input_ids[j]
481
+ i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
482
+ else:
483
+ i += 1
484
+ return labels, ranges
485
+
486
+ def apply_scheduled_sampling(self, input_ids, audio_labels, text_labels):
487
+ if self.scheduled_sampling_prob <= 0:
488
+ return input_ids
489
+ audio_mask = (audio_labels != -100).any(dim=0) & (torch.rand(input_ids.size(1)) < self.scheduled_sampling_prob)
490
+ for i in range(8):
491
+ input_ids[i] = torch.where(audio_mask, torch.randint(0, self.audio_vocab_size, input_ids[i].shape), input_ids[i])
492
+ text_mask = (text_labels != -100) & (input_ids[8] != self.image_token_id) & (torch.rand(input_ids.size(1)) < self.scheduled_sampling_prob)
493
+ input_ids[8] = torch.where(text_mask, torch.randint(0, self.text_vocab_size, input_ids[8].shape), input_ids[8])
494
+ return input_ids
495
+
496
+ def __getitem__(self, index: int):
497
+ import numpy as np
498
+ conversations = json.loads(self.table['conversations'][index].as_py())
499
+ question_audios = self.table['question_audios'][index].as_py() if 'question_audios' in self.table.column_names else []
500
+ answer_audios = self.table['answer_audios'][index].as_py() if 'answer_audios' in self.table.column_names else []
501
+ image_bytes = self.table['image_bytes'][index].as_py() if 'image_bytes' in self.table.column_names else []
502
+ if image_bytes and not isinstance(image_bytes, list): image_bytes = [image_bytes]
503
+ ref_audios = self.table['ref_audios'][index].as_py() if 'ref_audios' in self.table.column_names else []
504
+ spk_emb_raw = self.table['spk_emb'][index].as_py() if 'spk_emb' in self.table.column_names else []
505
+
506
+ asst_indices = [i for i, t in enumerate(conversations) if t['role'] == 'assistant']
507
+ if len(asst_indices) > 1:
508
+ rand_idx = random.randint(0, len(asst_indices) - 1)
509
+ for i in range(rand_idx, -1, -1):
510
+ conversations = conversations[:asst_indices[i] + 1]
511
+ test_prompt = self.create_chat_prompt(conversations, 0)
512
+ if len(self.tokenizer(test_prompt).input_ids) + 100 < self.max_length:
513
+ break
514
+
515
+ pixel_values = None
516
+ user_count = sum(1 for t in conversations if t['role'] == 'user')
517
+ if image_bytes and len(image_bytes) > 0 and self.vision_processor:
518
+ pixel_values = self.load_image_inputs(image_bytes[0])
519
+
520
+ audio_inputs, audio_len, audio_features_length = None, 0, 0
521
+ user_count = sum(1 for t in conversations if t['role'] == 'user')
522
+ if question_audios and user_count > 0 and user_count <= len(question_audios) and self.audio_processor:
523
+ audio_bytes = question_audios[user_count - 1]
524
+ if audio_bytes:
525
+ mel, valid_len = self.load_audio_inputs(audio_bytes)
526
+ if mel is not None:
527
+ audio_inputs = mel.unsqueeze(0)
528
+ audio_len = valid_len
529
+ audio_features_length = valid_len or 1
530
+
531
+ if audio_inputs is None and self.audio_processor:
532
+ audio_inputs = torch.zeros(1, 1, 560)
533
+ audio_len = 0
534
+ if pixel_values is None and self.vision_processor:
535
+ pixel_values = {'pixel_values': torch.zeros(1, 3, 256, 256)}
536
+
537
+ last_audio_codes = None
538
+ asst_count = sum(1 for t in conversations if t['role'] == 'assistant')
539
+ if answer_audios and asst_count > 0 and asst_count <= len(answer_audios):
540
+ tokens = answer_audios[asst_count - 1]
541
+ if tokens:
542
+ audio_codes_8layers = [[] for _ in range(8)]
543
+ for i in range(0, len(tokens) - 7, 8):
544
+ for j in range(8): audio_codes_8layers[j].append(tokens[i + j])
545
+ for layer in audio_codes_8layers: layer.append(self.audio_stop_token)
546
+ last_audio_codes = audio_codes_8layers
547
+
548
+ prompt = self.create_chat_prompt(conversations, audio_features_length)
549
+ if pixel_values is not None: prompt = prompt.replace('<image>', self.image_token)
550
+ input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
551
+
552
+ input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
553
+
554
+ text_labels, assistant_ranges = self.generate_text_labels(input_ids)
555
+ for start, end in assistant_ranges[:-1]:
556
+ mask_end = min(end + len(self.eos_id), self.max_length)
557
+ text_labels[start:mask_end] = [-100] * (mask_end - start)
558
+
559
+ Y_audio_layers = [[self.audio_pad_token] * self.max_length for _ in range(8)]
560
+ audio_labels = [[-100] * self.max_length for _ in range(8)]
561
+ if assistant_ranges and last_audio_codes:
562
+ assistant_start, assistant_end = assistant_ranges[-1]
563
+ for pos in range(assistant_start, min(assistant_end, assistant_start + 50)):
564
+ if input_ids[pos:pos + len(self.think_end_ids)] == self.think_end_ids:
565
+ assistant_start = pos + len(self.think_end_ids)
566
+ break
567
+ has_spk = bool(spk_emb_raw)
568
+ has_ref = bool(ref_audios) and random.random() > 0.5
569
+ spk_reserve = 1 if has_spk else 0
570
+ if has_ref:
571
+ ref_codes = [[] for _ in range(8)]
572
+ for i in range(0, len(ref_audios) - 7, 8):
573
+ for j in range(8): ref_codes[j].append(ref_audios[i + j])
574
+ ref_len = len(ref_codes[0])
575
+ ref_start = max(spk_reserve, assistant_start - ref_len)
576
+ for layer_idx in range(8):
577
+ codes = ref_codes[layer_idx][-(assistant_start - ref_start):] if ref_len > (assistant_start - ref_start) else ref_codes[layer_idx]
578
+ for i, code in enumerate(codes):
579
+ Y_audio_layers[layer_idx][ref_start + i] = code
580
+ else:
581
+ ref_start = assistant_start
582
+ if has_spk and ref_start > 0:
583
+ spk_pos = ref_start - 1
584
+ for layer_idx in range(8):
585
+ Y_audio_layers[layer_idx][spk_pos] = self.audio_spk_token
586
+ for layer_idx in range(8):
587
+ codes = last_audio_codes[layer_idx]
588
+ start_pos = assistant_start + layer_idx + 1
589
+ for i, code in enumerate(codes):
590
+ if start_pos + i < self.max_length:
591
+ Y_audio_layers[layer_idx][start_pos + i] = code
592
+ audio_labels[layer_idx][start_pos + i] = code
593
+
594
+ X_audio = torch.tensor([layer[:-1] for layer in Y_audio_layers], dtype=torch.long) # (8, T-1)
595
+ X_text = torch.tensor(input_ids[:-1], dtype=torch.long) # (T-1,)
596
+ input_ids = torch.cat((X_audio, X_text.unsqueeze(0)), dim=0) # (9, T-1)
597
+ text_labels = torch.tensor(text_labels[1:], dtype=torch.long) # (T-1,)
598
+ audio_labels = torch.tensor([layer[1:] for layer in audio_labels], dtype=torch.long) # (8, T-1)
599
+
600
+ input_ids = self.apply_scheduled_sampling(input_ids, audio_labels, text_labels)
601
+ spk_emb = torch.tensor(spk_emb_raw, dtype=torch.float32) if spk_emb_raw else torch.zeros(192)
602
+ return input_ids, text_labels, audio_labels, audio_inputs, audio_len, pixel_values, spk_emb
603
+
604
+
605
+ if __name__ == "__main__":
606
+ pass
src/omni/encoders/__init__.py CHANGED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from omni.encoders.vision import SiglipVisionEncoder
2
+
3
+ __all__ = ["SiglipVisionEncoder"]
src/omni/encoders/audio/__init__.py CHANGED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from omni.encoders.audio.sensevoice import SenseVoiceAudioEncoder, SenseVoiceAudioProcessor
2
+
3
+ __all__ = ["SenseVoiceAudioEncoder", "SenseVoiceAudioProcessor"]
src/omni/encoders/audio/sensevoice.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import contextlib
4
+ import io
5
+ import warnings
6
+ import torch
7
+ import numpy as np
8
+ from torch import nn
9
+ from types import SimpleNamespace
10
+
11
+ from transformers import logging as hf_logging
12
+
13
+
14
+ class SenseVoiceAudioProcessor:
15
+ def __init__(self, frontend):
16
+ self.frontend = frontend
17
+
18
+ def __call__(self, wav, sampling_rate=16000, return_tensors="pt", return_attention_mask=True, **kwargs):
19
+ if isinstance(wav, np.ndarray):
20
+ wav = torch.from_numpy(wav).float()
21
+ if wav.dim() == 1:
22
+ wav = wav.unsqueeze(0)
23
+ with torch.no_grad():
24
+ fbank, flen = self.frontend(wav, torch.tensor([wav.size(1)]))
25
+ return SimpleNamespace(
26
+ input_features=fbank,
27
+ attention_mask=(torch.arange(fbank.size(1)) < flen[0]).long().unsqueeze(0),
28
+ )
29
+
30
+
31
+ class SenseVoiceAudioEncoder(nn.Module):
32
+ """Frozen SenseVoice audio encoder (funasr AutoModel). Returns frame embeddings."""
33
+
34
+ def __init__(self, model_path: str = None):
35
+ super().__init__()
36
+ self.model = None
37
+ self.processor = None
38
+ if model_path is not None and os.path.exists(model_path):
39
+ self.load(model_path)
40
+
41
+ @torch.no_grad()
42
+ def load(self, model_path: str):
43
+ if not os.path.exists(model_path):
44
+ warnings.warn(f"[SenseVoiceAudioEncoder] path not found: {model_path}")
45
+ return
46
+ logging.getLogger().setLevel(logging.ERROR)
47
+ hf_logging.set_verbosity_error()
48
+ with contextlib.redirect_stdout(io.StringIO()):
49
+ from funasr import AutoModel
50
+ m = AutoModel(model=model_path, trust_remote_code=True, disable_update=True, device="cpu")
51
+ encoder, frontend = m.model.encoder, m.kwargs["frontend"]
52
+ for p in encoder.parameters():
53
+ p.requires_grad = False
54
+ self.model = encoder.eval().float()
55
+ self.processor = SenseVoiceAudioProcessor(frontend.eval())
56
+
57
+ @property
58
+ def hidden_size(self) -> int:
59
+ if self.model is None:
60
+ return 0
61
+ return self.model.config.d_model if hasattr(self.model.config, "d_model") else self.model.config.hidden_size
62
+
63
+ @torch.no_grad()
64
+ def encode(self, fbank, audio_lens=None):
65
+ if self.model is None:
66
+ return None
67
+ if audio_lens is None:
68
+ audio_lens = torch.tensor([fbank.size(1)] * fbank.size(0), device=fbank.device)
69
+ emb, _ = self.model(fbank, audio_lens)
70
+ return emb
src/omni/encoders/vision/__init__.py CHANGED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from omni.encoders.vision.siglip import SiglipVisionEncoder
2
+
3
+ __all__ = ["SiglipVisionEncoder"]
src/omni/encoders/vision/siglip.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import torch.nn.functional as F
4
+ import warnings
5
+ from torch import nn
6
+ from typing import Optional
7
+
8
+ from transformers import SiglipVisionModel, SiglipImageProcessor
9
+ from transformers import logging as hf_logging
10
+
11
+
12
+ class SiglipVisionEncoder(nn.Module):
13
+ """Frozen SigLIP vision encoder. Returns last-hidden-state embeddings (B, T, D)."""
14
+
15
+ def __init__(self, model_path: Optional[str] = None):
16
+ super().__init__()
17
+ self.model = None
18
+ self.processor = None
19
+ if model_path is not None and os.path.exists(model_path):
20
+ self.load(model_path)
21
+
22
+ @torch.no_grad()
23
+ def load(self, model_path: str):
24
+ hf_logging.set_verbosity_error()
25
+ try:
26
+ model = SiglipVisionModel.from_pretrained(model_path)
27
+ except (RuntimeError, ValueError):
28
+ warnings.warn(f"[SiglipVisionEncoder] failed to load from {model_path}")
29
+ return
30
+ processor = SiglipImageProcessor.from_pretrained(model_path)
31
+ for param in model.parameters():
32
+ param.requires_grad = False
33
+ self.model = model.eval()
34
+ self.processor = processor
35
+
36
+ @property
37
+ def hidden_size(self) -> int:
38
+ if self.model is None:
39
+ return 0
40
+ return self.model.config.hidden_size
41
+
42
+ def preprocess(self, image):
43
+ if image.mode in ('RGBA', 'LA'):
44
+ image = image.convert('RGB')
45
+ return self.processor(images=image, return_tensors='pt')
46
+
47
+ @torch.no_grad()
48
+ def encode(self, pixel_values):
49
+ if self.model is None:
50
+ return None
51
+ if hasattr(pixel_values, 'keys'):
52
+ pixel_values = {k: (v.squeeze(1) if v.ndim > 2 and v.shape[1] == 1 else v) for k, v in pixel_values.items()}
53
+ outputs = self.model(**pixel_values)
54
+ return outputs.last_hidden_state
src/omni/models/__init__.py CHANGED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from omni.core import (
2
+ RMSNorm,
3
+ Attention,
4
+ FeedForward,
5
+ MOEFeedForward,
6
+ MiniMindBlock,
7
+ MiniMindModel,
8
+ precompute_freqs_cis,
9
+ apply_rotary_pos_emb,
10
+ )
11
+ from omni.models.minimind import (
12
+ MiniMindConfig,
13
+ MiniMindForCausalLM,
14
+ )
15
+ from omni.models.vlm import (
16
+ VLMConfig,
17
+ MiniMindVLM,
18
+ )
19
+ from omni.models.omni import (
20
+ OmniConfig,
21
+ MiniMindOmni,
22
+ TalkerModule,
23
+ )
24
+ from omni.models.lora import (
25
+ LoRA,
26
+ apply_lora,
27
+ load_lora,
28
+ save_lora,
29
+ merge_lora,
30
+ )
31
+
32
+ __all__ = [
33
+ "MiniMindConfig",
34
+ "MiniMindModel",
35
+ "MiniMindForCausalLM",
36
+ "VLMConfig",
37
+ "MiniMindVLM",
38
+ "OmniConfig",
39
+ "MiniMindOmni",
40
+ "TalkerModule",
41
+ "RMSNorm",
42
+ "Attention",
43
+ "FeedForward",
44
+ "MOEFeedForward",
45
+ "MiniMindBlock",
46
+ "precompute_freqs_cis",
47
+ "apply_rotary_pos_emb",
48
+ "LoRA",
49
+ "apply_lora",
50
+ "load_lora",
51
+ "save_lora",
52
+ "merge_lora",
53
+ ]
src/omni/models/lora.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import optim, nn
3
+
4
+
5
+ class LoRA(nn.Module):
6
+ def __init__(self, in_features, out_features, rank):
7
+ super().__init__()
8
+ self.rank = rank
9
+ self.A = nn.Linear(in_features, rank, bias=False)
10
+ self.B = nn.Linear(rank, out_features, bias=False)
11
+ self.A.weight.data.normal_(mean=0.0, std=0.02)
12
+ self.B.weight.data.zero_()
13
+
14
+ def forward(self, x):
15
+ return self.B(self.A(x))
16
+
17
+
18
+ def apply_lora(model, rank=16):
19
+ for name, module in model.named_modules():
20
+ if isinstance(module, nn.Linear) and module.in_features == module.out_features:
21
+ lora = LoRA(module.in_features, module.out_features, rank=rank).to(model.device)
22
+ setattr(module, "lora", lora)
23
+ original_forward = module.forward
24
+
25
+ def forward_with_lora(x, layer1=original_forward, layer2=lora):
26
+ return layer1(x) + layer2(x)
27
+
28
+ module.forward = forward_with_lora
29
+
30
+
31
+ def load_lora(model, path):
32
+ state_dict = torch.load(path, map_location=model.device)
33
+ state_dict = {(k[7:] if k.startswith('module.') else k): v for k, v in state_dict.items()}
34
+
35
+ for name, module in model.named_modules():
36
+ if hasattr(module, 'lora'):
37
+ lora_state = {k.replace(f'{name}.lora.', ''): v for k, v in state_dict.items() if f'{name}.lora.' in k}
38
+ module.lora.load_state_dict(lora_state)
39
+
40
+
41
+ def save_lora(model, path):
42
+ raw_model = getattr(model, '_orig_mod', model)
43
+ state_dict = {}
44
+ for name, module in raw_model.named_modules():
45
+ if hasattr(module, 'lora'):
46
+ clean_name = name[7:] if name.startswith("module.") else name
47
+ lora_state = {f'{clean_name}.lora.{k}': v.cpu().half() for k, v in module.lora.state_dict().items()}
48
+ state_dict.update(lora_state)
49
+ torch.save(state_dict, path)
50
+
51
+
52
+ def merge_lora(model, lora_path, save_path):
53
+ load_lora(model, lora_path)
54
+ raw_model = getattr(model, '_orig_mod', model)
55
+ state_dict = {k: v.cpu().half() for k, v in raw_model.state_dict().items() if '.lora.' not in k}
56
+ for name, module in raw_model.named_modules():
57
+ if isinstance(module, nn.Linear) and '.lora.' not in name:
58
+ state_dict[f'{name}.weight'] = module.weight.data.clone().cpu().half()
59
+ if hasattr(module, 'lora'):
60
+ state_dict[f'{name}.weight'] += (module.lora.B.weight.data @ module.lora.A.weight.data).cpu().half()
61
+ torch.save(state_dict, save_path)
src/omni/models/minimind.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from torch import nn
5
+ from transformers import PreTrainedModel, GenerationMixin, PretrainedConfig
6
+ from transformers.modeling_outputs import MoeCausalLMOutputWithPast
7
+
8
+ from omni.core import MiniMindModel
9
+
10
+
11
+ class MiniMindConfig(PretrainedConfig):
12
+ model_type = "minimind"
13
+
14
+ def __init__(self, hidden_size=768, num_hidden_layers=8, use_moe=False, **kwargs):
15
+ super().__init__(**kwargs)
16
+ self.hidden_size = hidden_size
17
+ self.num_hidden_layers = num_hidden_layers
18
+ self.use_moe = use_moe
19
+ self.dropout = kwargs.get("dropout", 0.0)
20
+ self.vocab_size = kwargs.get("vocab_size", 6400)
21
+ self.bos_token_id = kwargs.get("bos_token_id", 1)
22
+ self.eos_token_id = kwargs.get("eos_token_id", 2)
23
+ self.flash_attn = kwargs.get("flash_attn", True)
24
+ self.num_attention_heads = kwargs.get("num_attention_heads", 8)
25
+ self.num_key_value_heads = kwargs.get("num_key_value_heads", 4)
26
+ self.head_dim = kwargs.get("head_dim", self.hidden_size // self.num_attention_heads)
27
+ self.hidden_act = kwargs.get("hidden_act", 'silu')
28
+ self.intermediate_size = kwargs.get("intermediate_size", math.ceil(hidden_size * math.pi / 64) * 64)
29
+ self.max_position_embeddings = kwargs.get("max_position_embeddings", 32768)
30
+ self.rms_norm_eps = kwargs.get("rms_norm_eps", 1e-6)
31
+ self.rope_theta = kwargs.get("rope_theta", 1e6)
32
+ self.tie_word_embeddings = kwargs.get("tie_word_embeddings", True)
33
+ self.inference_rope_scaling = kwargs.get("inference_rope_scaling", False)
34
+ self.rope_scaling = {
35
+ "beta_fast": 32,
36
+ "beta_slow": 1,
37
+ "factor": 16,
38
+ "original_max_position_embeddings": 2048,
39
+ "attention_factor": 1.0,
40
+ "type": "yarn"
41
+ } if self.inference_rope_scaling else None
42
+ # MoE specific configs (ignored if use_moe = False)
43
+ self.num_experts = kwargs.get("num_experts", 4)
44
+ self.num_experts_per_tok = kwargs.get("num_experts_per_tok", 1)
45
+ self.moe_intermediate_size = kwargs.get("moe_intermediate_size", self.intermediate_size)
46
+ self.norm_topk_prob = kwargs.get("norm_topk_prob", True)
47
+ self.router_aux_loss_coef = kwargs.get("router_aux_loss_coef", 5e-4)
48
+
49
+
50
+ class MiniMindForCausalLM(PreTrainedModel, GenerationMixin):
51
+ config_class = MiniMindConfig
52
+ _tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"}
53
+
54
+ def __init__(self, config: MiniMindConfig = None):
55
+ self.config = config or MiniMindConfig()
56
+ super().__init__(self.config)
57
+ self.model = MiniMindModel(self.config)
58
+ self.lm_head = nn.Linear(self.config.hidden_size, self.config.vocab_size, bias=False)
59
+ if self.config.tie_word_embeddings:
60
+ self.model.embed_tokens.weight = self.lm_head.weight
61
+ self.post_init()
62
+
63
+ def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0, labels=None, **kwargs):
64
+ hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask, past_key_values, use_cache, **kwargs)
65
+ slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
66
+ logits = self.lm_head(hidden_states[:, slice_indices, :])
67
+ loss = None
68
+ if labels is not None:
69
+ x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
70
+ loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
71
+ return MoeCausalLMOutputWithPast(loss=loss, aux_loss=aux_loss, logits=logits, past_key_values=past_key_values, hidden_states=hidden_states)
72
+
73
+ @torch.inference_mode()
74
+ def generate(self, inputs=None, attention_mask=None, max_new_tokens=8192, temperature=0.85, top_p=0.85, top_k=50, eos_token_id=2, streamer=None, use_cache=True, num_return_sequences=1, do_sample=True, repetition_penalty=1.0, **kwargs):
75
+ input_ids = kwargs.pop("input_ids", inputs).repeat(num_return_sequences, 1)
76
+ attention_mask = attention_mask.repeat(num_return_sequences, 1) if attention_mask is not None else None
77
+ past_key_values = kwargs.pop("past_key_values", None)
78
+ finished = torch.zeros(input_ids.shape[0], dtype=torch.bool, device=input_ids.device)
79
+ if streamer:
80
+ streamer.put(input_ids.cpu())
81
+ for _ in range(max_new_tokens):
82
+ past_len = past_key_values[0][0].shape[1] if past_key_values else 0
83
+ outputs = self.forward(input_ids[:, past_len:], attention_mask, past_key_values, use_cache=use_cache, **kwargs)
84
+ attention_mask = torch.cat([attention_mask, attention_mask.new_ones(attention_mask.shape[0], 1)], -1) if attention_mask is not None else None
85
+ logits = outputs.logits[:, -1, :] / temperature
86
+ if repetition_penalty != 1.0:
87
+ for i in range(input_ids.shape[0]):
88
+ seen = torch.unique(input_ids[i])
89
+ score = logits[i, seen]
90
+ logits[i, seen] = torch.where(score > 0, score / repetition_penalty, score * repetition_penalty)
91
+ if top_k > 0:
92
+ logits[logits < torch.topk(logits, top_k)[0][..., -1, None]] = -float('inf')
93
+ if top_p < 1.0:
94
+ sorted_logits, sorted_indices = torch.sort(logits, descending=True)
95
+ mask = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1) > top_p
96
+ mask[..., 1:], mask[..., 0] = mask[..., :-1].clone(), 0
97
+ logits[mask.scatter(1, sorted_indices, mask)] = -float('inf')
98
+ next_token = torch.multinomial(torch.softmax(logits, dim=-1), num_samples=1) if do_sample else torch.argmax(logits, dim=-1, keepdim=True)
99
+ if eos_token_id is not None:
100
+ next_token = torch.where(finished.unsqueeze(-1), next_token.new_full((next_token.shape[0], 1), eos_token_id), next_token)
101
+ input_ids = torch.cat([input_ids, next_token], dim=-1)
102
+ past_key_values = outputs.past_key_values if use_cache else None
103
+ if streamer:
104
+ streamer.put(next_token.cpu())
105
+ if eos_token_id is not None:
106
+ finished |= next_token.squeeze(-1).eq(eos_token_id)
107
+ if finished.all():
108
+ break
109
+ if streamer:
110
+ streamer.end()
111
+ if kwargs.get("return_kv"):
112
+ return {'generated_ids': input_ids, 'past_kv': past_key_values}
113
+ return input_ids
src/omni/models/omni.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import math
3
+ import torch
4
+ import warnings
5
+ import logging
6
+ import contextlib
7
+ import io
8
+ from torch import nn
9
+ from torch.nn import functional as F
10
+ from transformers.modeling_outputs import MoeCausalLMOutputWithPast
11
+ from transformers import SiglipVisionModel, SiglipImageProcessor, logging as hf_logging
12
+
13
+ from omni.core import RMSNorm, precompute_freqs_cis, apply_rotary_pos_emb, MiniMindBlock, MOEFeedForward
14
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
15
+ from omni.encoders.audio import SenseVoiceAudioEncoder
16
+ from omni.encoders.vision import SiglipVisionEncoder
17
+ from omni.projectors import MMVisionProjector, MMAudioProjector
18
+
19
+
20
+ class OmniConfig(MiniMindConfig):
21
+ model_type = "minimind-o"
22
+
23
+ def __init__(self, **kwargs):
24
+ super().__init__(**kwargs)
25
+ self.num_talker_hidden_layers = kwargs.get("num_talker_hidden_layers", 4)
26
+ self.talker_hidden_size = kwargs.get("talker_hidden_size", 768)
27
+ self.audio_ids = kwargs.get("audio_ids", [16])
28
+ self.audio_special_token = kwargs.get("audio_special_token", "<|audio_pad|>")
29
+ self.audio_hidden_size = kwargs.get("audio_hidden_size", 512)
30
+ self.audio_vocab_size = kwargs.get("audio_vocab_size", 2112)
31
+ self.audio_pad_token = kwargs.get("audio_pad_token", 2049)
32
+ self.audio_stop_token = kwargs.get("audio_stop_token", 2050)
33
+ self.audio_spk_token = kwargs.get("audio_spk_token", 2051)
34
+ self.spk_emb_size = kwargs.get("spk_emb_size", 192)
35
+ self.think_end_ids = kwargs.get("think_end_ids", [26, 234, 234])
36
+ self.image_ids = kwargs.get("image_ids", [12])
37
+ self.image_special_token = kwargs.get("image_special_token", "<|image_pad|>")
38
+ self.image_hidden_size = kwargs.get("image_hidden_size", 768)
39
+ self.image_token_len = kwargs.get("image_token_len", 64)
40
+ self.bridge_layer = kwargs.get("bridge_layer", self.num_hidden_layers // 2 - 1)
41
+
42
+
43
+ class TalkerHead(nn.Module):
44
+ def __init__(self, in_features, out_features, num_layers=8, rank=256):
45
+ super().__init__()
46
+ self.num_layers = num_layers
47
+ self.base = nn.Linear(in_features, out_features, bias=False)
48
+ self.adapters = nn.ModuleList([
49
+ nn.Sequential(nn.Linear(in_features, rank, bias=False), nn.GELU(), nn.Linear(rank, out_features, bias=False))
50
+ for _ in range(num_layers)
51
+ ])
52
+
53
+ def forward(self, x):
54
+ base_out = self.base(x)
55
+ return [base_out + adapter(x) for adapter in self.adapters]
56
+
57
+
58
+ class TalkerEmbedding(nn.Module):
59
+ def __init__(self, num_embeddings, embedding_dim, num_layers=8, rank=256):
60
+ super().__init__()
61
+ self.num_layers = num_layers
62
+ self.base = nn.Embedding(num_embeddings, embedding_dim)
63
+ self.adapters = nn.ModuleList([
64
+ nn.Sequential(nn.Embedding(num_embeddings, rank), nn.GELU(), nn.Linear(rank, embedding_dim, bias=False))
65
+ for _ in range(num_layers)
66
+ ])
67
+
68
+ def forward(self, x):
69
+ base_out = self.base(x)
70
+ return sum(base_out[:, i, :] + self.adapters[i](x[:, i, :]) for i in range(len(self.adapters))) / self.num_layers
71
+
72
+
73
+ class TalkerModule(nn.Module):
74
+ def __init__(self, config: OmniConfig):
75
+ super().__init__()
76
+ self.talker_config = MiniMindConfig(hidden_size=config.talker_hidden_size, use_moe=config.use_moe)
77
+ self.layers = nn.ModuleList([MiniMindBlock(l, self.talker_config) for l in range(config.num_talker_hidden_layers)])
78
+ self.norm = RMSNorm(config.talker_hidden_size, eps=config.rms_norm_eps)
79
+ self.lm_head = TalkerHead(config.talker_hidden_size, config.audio_vocab_size)
80
+ self.embed_tokens = TalkerEmbedding(config.audio_vocab_size, config.talker_hidden_size)
81
+ self.codec_proj = nn.Sequential(
82
+ nn.Linear(config.talker_hidden_size, config.talker_hidden_size),
83
+ nn.GELU(),
84
+ nn.Linear(config.talker_hidden_size, config.talker_hidden_size),
85
+ RMSNorm(config.talker_hidden_size, eps=config.rms_norm_eps),
86
+ )
87
+ self.embed_proj = nn.Sequential(
88
+ nn.Linear(config.hidden_size, config.hidden_size),
89
+ nn.GELU(),
90
+ nn.Linear(config.hidden_size, config.talker_hidden_size),
91
+ RMSNorm(config.talker_hidden_size, eps=config.rms_norm_eps),
92
+ )
93
+ self.text_scale, self.audio_scale = nn.Parameter(torch.tensor(3.0)), nn.Parameter(torch.tensor(1.0))
94
+ self.spk_proj = nn.Linear(config.spk_emb_size, config.talker_hidden_size, bias=False)
95
+ freqs_cos, freqs_sin = precompute_freqs_cis(
96
+ dim=self.talker_config.head_dim, end=config.max_position_embeddings,
97
+ rope_base=config.rope_theta, rope_scaling=config.rope_scaling
98
+ )
99
+ self.register_buffer("freqs_cos", freqs_cos, persistent=False)
100
+ self.register_buffer("freqs_sin", freqs_sin, persistent=False)
101
+
102
+
103
+ class MiniMindOmni(MiniMindForCausalLM):
104
+ config_class = OmniConfig
105
+
106
+ def __init__(self, config: OmniConfig = None, audio_encoder_path: str = None, vision_model_path: str = None):
107
+ config = config or OmniConfig()
108
+ super().__init__(config)
109
+ object.__setattr__(self, 'thinker', self.model)
110
+ object.__setattr__(self.model, 'lm_head', self.lm_head)
111
+ self.talker = TalkerModule(config)
112
+ self.audio_proj = MMAudioProjector(config.audio_hidden_size, config.hidden_size)
113
+ self.vision_proj = MMVisionProjector(config.image_hidden_size, config.hidden_size, target_tokens=config.image_token_len)
114
+ self.audio_pad_token, self.audio_stop_token, self.audio_spk_token = config.audio_pad_token, config.audio_stop_token, config.audio_spk_token
115
+ audio_encoder = SenseVoiceAudioEncoder(audio_encoder_path) if audio_encoder_path else SenseVoiceAudioEncoder()
116
+ object.__setattr__(self, 'audio_encoder', audio_encoder)
117
+ object.__setattr__(self, 'audio_processor', audio_encoder.processor)
118
+ vision_encoder = SiglipVisionEncoder(vision_model_path) if vision_model_path else SiglipVisionEncoder()
119
+ object.__setattr__(self, 'vision_encoder', vision_encoder)
120
+ object.__setattr__(self, 'vision_processor', vision_encoder.processor)
121
+
122
+ @staticmethod
123
+ def load_sensevoice(path):
124
+ if not os.path.exists(path):
125
+ warnings.warn(f"[MiniMindOmni] SenseVoice path not found: {path}")
126
+ return None, None
127
+ logging.getLogger().setLevel(logging.ERROR)
128
+ hf_logging.set_verbosity_error()
129
+ with contextlib.redirect_stdout(io.StringIO()):
130
+ from funasr import AutoModel
131
+ m = AutoModel(model=path, trust_remote_code=True, disable_update=True, device="cpu")
132
+ encoder, frontend = m.model.encoder, m.kwargs["frontend"]
133
+ for p in encoder.parameters():
134
+ p.requires_grad = False
135
+ return encoder.eval().float(), SenseVoiceAudioProcessor(frontend.eval())
136
+
137
+ @staticmethod
138
+ def load_vision(path):
139
+ if path is None or not os.path.exists(path):
140
+ warnings.warn(f"[MiniMindOmni] Vision model path not found: {path}. vision_encoder will be None!")
141
+ return None, None
142
+ hf_logging.set_verbosity_error()
143
+ try:
144
+ model = SiglipVisionModel.from_pretrained(path)
145
+ except (RuntimeError, ValueError):
146
+ return None, None
147
+ processor = SiglipImageProcessor.from_pretrained(path)
148
+ for p in model.parameters():
149
+ p.requires_grad = False
150
+ return model.eval(), processor
151
+
152
+ @torch.compiler.disable
153
+ def encode_audio_inputs(self, audio_inputs, audio_lens=None):
154
+ if (audio_inputs is None) or (self.audio_encoder is None) or (not audio_inputs.any()):
155
+ return None
156
+ batch_mask = audio_inputs.flatten(1).any(1)
157
+ enc_dtype = next(self.audio_encoder.parameters()).dtype
158
+ valid_fbank = audio_inputs[batch_mask].to(dtype=enc_dtype)
159
+ if audio_lens is not None:
160
+ valid_lens = audio_lens[batch_mask].to(valid_fbank.device)
161
+ else:
162
+ valid_lens = torch.tensor([valid_fbank.size(1)] * valid_fbank.size(0), device=valid_fbank.device)
163
+ with torch.no_grad():
164
+ emb, _ = self.audio_encoder.model(valid_fbank, valid_lens)
165
+ proj_dtype = next(self.audio_proj.parameters()).dtype
166
+ emb_list = [self.audio_proj(emb[i, :max(1, min(valid_lens[i].item(), emb.size(1)))].unsqueeze(0).to(proj_dtype)).squeeze(0) for i in range(emb.size(0))]
167
+ if batch_mask.all():
168
+ return emb_list
169
+ out = [None] * audio_inputs.size(0)
170
+ j = 0
171
+ for i in range(audio_inputs.size(0)):
172
+ if batch_mask[i]:
173
+ out[i] = emb_list[j]
174
+ j += 1
175
+ return out
176
+
177
+ @torch.compiler.disable
178
+ def inject_audio_features(self, tokens, h, audio_feats, seqlen):
179
+ if audio_feats is None or not self.config.audio_ids:
180
+ return h
181
+ marker = self.config.audio_ids[0]
182
+ out = []
183
+ for b in range(h.size(0)):
184
+ hb, seq, i = h[b], tokens[b].tolist(), 0
185
+ af = audio_feats[b] if audio_feats[b] is not None else None
186
+ while i < len(seq):
187
+ if seq[i] == marker:
188
+ start = i
189
+ while i < len(seq) and seq[i] == marker:
190
+ i += 1
191
+ if af is not None:
192
+ inject_len = min(af.size(0), i - start)
193
+ hb = torch.cat((hb[:start], af[:inject_len], hb[start + inject_len:]), dim=0)
194
+ af = None
195
+ else:
196
+ i += 1
197
+ out.append(hb)
198
+ return torch.stack(out)
199
+
200
+ @torch.compiler.disable
201
+ def get_image_embeddings(self, image_inputs):
202
+ if hasattr(image_inputs, 'keys'):
203
+ image_inputs = {k: (v.squeeze(1) if v.ndim > 2 and v.shape[1] == 1 else v) for k, v in image_inputs.items()}
204
+ pixel_attention_mask = image_inputs.get('pixel_attention_mask')
205
+ if pixel_attention_mask is not None and not pixel_attention_mask.any():
206
+ pv = image_inputs['pixel_values']
207
+ return pv.new_zeros(pv.size(0), pv.size(1), self.config.image_hidden_size)
208
+ with torch.no_grad():
209
+ outputs = self.vision_encoder.model(**image_inputs)
210
+ return outputs.last_hidden_state
211
+
212
+ @torch.compiler.disable
213
+ def encode_image_inputs(self, pixel_values):
214
+ if pixel_values is None or self.vision_encoder is None:
215
+ return None
216
+ mask = pixel_values.flatten(1).any(1)
217
+ if not mask.any():
218
+ return pixel_values.new_zeros(pixel_values.size(0), self.config.image_token_len, self.config.hidden_size)
219
+ with torch.no_grad():
220
+ emb = self.vision_encoder.model(pixel_values=pixel_values[mask]).last_hidden_state
221
+ if emb.dim() == 2:
222
+ emb = emb.unsqueeze(0)
223
+ emb = self.vision_proj(emb)
224
+ if mask.all():
225
+ return emb
226
+ idx = mask.nonzero().view(-1, 1, 1).expand_as(emb)
227
+ return emb.new_zeros(pixel_values.size(0), *emb.shape[1:]).scatter(0, idx, emb)
228
+
229
+ @torch.compiler.disable
230
+ def count_vision_proj(self, tokens, h, vision_tensors=None, seqlen=512):
231
+ if vision_tensors is None or not self.config.image_ids:
232
+ return h
233
+ marker, vf = self.config.image_ids[0], vision_tensors
234
+ if vf.dim() == 3:
235
+ vf = vf.unsqueeze(1)
236
+ out = []
237
+ for b in range(h.size(0)):
238
+ hb, seq, k, i = h[b], tokens[b].tolist(), 0, 0
239
+ while i < len(seq):
240
+ if seq[i] == marker:
241
+ start = i
242
+ while i < len(seq) and seq[i] == marker:
243
+ i += 1
244
+ if k < vf.size(1):
245
+ hb = torch.cat((hb[:start], vf[b][k][:i - start], hb[i:]), dim=0)[:seqlen]
246
+ k += 1
247
+ else:
248
+ i += 1
249
+ out.append(hb)
250
+ return torch.stack(out)
251
+
252
+ def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0,
253
+ audio_inputs=None, audio_lens=None, pixel_values=None, **args):
254
+ if len(input_ids.shape) == 2:
255
+ batch_size, seq_length = input_ids.shape
256
+ text_ids = input_ids
257
+ audio_ids = torch.full((batch_size, 8, seq_length), self.audio_pad_token, dtype=torch.long, device=input_ids.device)
258
+ else:
259
+ batch_size, _, seq_length = input_ids.shape
260
+ text_ids, audio_ids = input_ids[:, 8, :], input_ids[:, :8, :]
261
+ if hasattr(past_key_values, 'layers'):
262
+ past_key_values = None
263
+ n_thinker, n_talker = len(self.thinker.layers), len(self.talker.layers)
264
+ past_key_values = past_key_values or ([None] * (n_thinker + n_talker))
265
+ start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
266
+ if self.thinker.freqs_cos[0, 0] == 0:
267
+ freqs_cos, freqs_sin = precompute_freqs_cis(dim=self.config.head_dim, end=self.config.max_position_embeddings, rope_base=self.config.rope_theta, rope_scaling=self.config.rope_scaling)
268
+ self.thinker.freqs_cos, self.thinker.freqs_sin = freqs_cos.to(input_ids.device), freqs_sin.to(input_ids.device)
269
+ if self.talker.freqs_cos[0, 0] == 0:
270
+ freqs_cos, freqs_sin = precompute_freqs_cis(dim=self.talker.talker_config.head_dim, end=self.config.max_position_embeddings, rope_base=self.config.rope_theta, rope_scaling=self.config.rope_scaling)
271
+ self.talker.freqs_cos, self.talker.freqs_sin = freqs_cos.to(input_ids.device), freqs_sin.to(input_ids.device)
272
+ presents = []
273
+
274
+ hidden_states = self.thinker.dropout(self.thinker.embed_tokens(text_ids))
275
+ position_embeddings = (self.thinker.freqs_cos[start_pos:start_pos + seq_length], self.thinker.freqs_sin[start_pos:start_pos + seq_length])
276
+ if audio_inputs is not None and start_pos == 0:
277
+ audio_features = self.encode_audio_inputs(audio_inputs, audio_lens)
278
+ hidden_states = self.inject_audio_features(text_ids, hidden_states, audio_features, seq_length)
279
+ if pixel_values is not None and start_pos == 0:
280
+ if hasattr(pixel_values, 'keys'):
281
+ img_emb = self.get_image_embeddings(pixel_values).to(hidden_states.dtype)
282
+ vision_tensors = self.vision_proj(img_emb)
283
+ else:
284
+ if len(pixel_values.shape) == 6:
285
+ pixel_values = pixel_values.squeeze(2)
286
+ if len(pixel_values.shape) == 4:
287
+ pixel_values = pixel_values.unsqueeze(1)
288
+ bs, num, c, im_h, im_w = pixel_values.shape
289
+ stack_dim = 1 if bs > 1 else 0
290
+ vision_tensors = torch.stack([self.encode_image_inputs(pixel_values[:, i, :, :, :]) for i in range(num)], dim=stack_dim)
291
+ hidden_states = self.count_vision_proj(tokens=text_ids, h=hidden_states, vision_tensors=vision_tensors, seqlen=seq_length)
292
+ bridge_states = hidden_states
293
+ for i, (layer, past_key_value) in enumerate(zip(self.thinker.layers, past_key_values[:n_thinker])):
294
+ hidden_states, present = layer(hidden_states, position_embeddings, past_key_value=past_key_value, use_cache=use_cache, attention_mask=attention_mask)
295
+ presents.append(present)
296
+ if i == self.config.bridge_layer:
297
+ bridge_states = hidden_states
298
+ h_thinker = self.thinker.norm(hidden_states)
299
+
300
+ talker_emb = self.talker.embed_tokens(audio_ids)
301
+ spk_emb = args.get('spk_emb', None)
302
+ if spk_emb is not None:
303
+ spk_mask = (audio_ids[:, 0, :] == self.audio_spk_token).unsqueeze(-1)
304
+ talker_emb = torch.where(spk_mask, self.talker.spk_proj(spk_emb).unsqueeze(1), talker_emb)
305
+ hidden_states = self.talker.embed_proj(bridge_states) * self.talker.text_scale + self.talker.codec_proj(talker_emb) * self.talker.audio_scale
306
+ talker_pos_emb = (self.talker.freqs_cos[start_pos:start_pos + seq_length], self.talker.freqs_sin[start_pos:start_pos + seq_length])
307
+ for layer, past_key_value in zip(self.talker.layers, past_key_values[n_thinker:]):
308
+ hidden_states, present = layer(hidden_states, talker_pos_emb, past_key_value=past_key_value, use_cache=use_cache, attention_mask=attention_mask)
309
+ presents.append(present)
310
+ h_talker = self.talker.norm(hidden_states)
311
+
312
+ slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
313
+ aux_loss = sum(l.mlp.aux_loss for l in list(self.thinker.layers) + list(self.talker.layers) if isinstance(l.mlp, MOEFeedForward))
314
+ aux_loss += sum(p.sum() for p in self.audio_proj.parameters()) * 0 + sum(p.sum() for p in self.vision_proj.parameters()) * 0 + sum(p.sum() for p in self.talker.lm_head.adapters.parameters()) * 0 + sum(p.sum() for p in self.talker.spk_proj.parameters()) * 0
315
+ text_logits = self.thinker.lm_head(h_thinker[:, slice_indices, :])
316
+ audio_logits = self.talker.lm_head(h_talker[:, slice_indices, :])
317
+
318
+ out = MoeCausalLMOutputWithPast(aux_loss=aux_loss, logits=text_logits, past_key_values=presents)
319
+ out.audio_logits = audio_logits
320
+ return out
321
+
322
+ @torch.inference_mode()
323
+ def generate(self, input_ids, eos_token_id=2, max_new_tokens=1024, temperature=0.75, top_p=0.90,
324
+ stream=False, rp=1., use_cache=True, return_audio_codes=False, **args):
325
+ if stream:
326
+ return self.stream_generate(input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, return_audio_codes, **args)
327
+ tokens = list(self.stream_generate(input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, return_audio_codes, **args))
328
+ return tokens[-1] if tokens else input_ids
329
+
330
+ def stream_generate(self, input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, return_audio_codes=False, **args):
331
+ start_pos, past_kvs, text_finished, first_finished = input_ids.shape[1], None, False, True
332
+ audio_codes = [[] for _ in range(8)]
333
+ audio_stop_pos = [None] * 8
334
+ audio_buffer = torch.full((1, 8, start_pos), self.audio_pad_token, dtype=torch.long, device=input_ids.device)
335
+ spk_emb = args.get('spk_emb', None)
336
+ ref_codes = args.get('ref_codes', None)
337
+ ref_len = ref_codes.shape[2] if ref_codes is not None else 0
338
+ spk_reserve = 1 if spk_emb is not None else 0
339
+ fill_end = start_pos
340
+ fill_start = max(spk_reserve, start_pos - ref_len)
341
+ if ref_codes is not None and fill_start < fill_end:
342
+ audio_buffer[:, :, fill_start:fill_end] = ref_codes[:, :, -(fill_end - fill_start):]
343
+ if spk_emb is not None and fill_start > 0:
344
+ audio_buffer[:, :, fill_start - 1] = self.audio_spk_token
345
+ think_end_step, generated_tokens = None, ([] if args.get('open_thinking', False) else None)
346
+ while input_ids.shape[1] < start_pos + max_new_tokens:
347
+ if past_kvs is None or not use_cache:
348
+ out = self.forward(torch.cat((audio_buffer, input_ids.unsqueeze(1)), dim=1), past_key_values=past_kvs, use_cache=use_cache, **args)
349
+ else:
350
+ out = self.forward(torch.cat((audio_buffer[:, :, -1:], input_ids[:, -1:].unsqueeze(1)), dim=1), past_key_values=past_kvs, use_cache=use_cache, **args)
351
+ past_kvs = out.past_key_values
352
+
353
+ logits = out.logits[0, -1, :].clone() / (temperature + 1e-9)
354
+ if rp != 1.0:
355
+ seen = list(set(input_ids[0].tolist()))
356
+ score = logits[seen]
357
+ logits[seen] = torch.where(score > 0, score / rp, score * rp)
358
+ if top_p and top_p < 1.0:
359
+ sorted_l, sorted_i = torch.sort(logits, descending=True)
360
+ mask = torch.cumsum(F.softmax(sorted_l, dim=-1), dim=-1) > top_p
361
+ mask[1:], mask[0] = mask[:-1].clone(), False
362
+ logits[sorted_i[mask]] = -float('Inf')
363
+ text_token = torch.multinomial(F.softmax(logits, dim=-1), 1).item()
364
+
365
+ if text_finished:
366
+ text_token = args.get('enter_token_id', 201) if first_finished else args.get('pad_token_id', 0)
367
+ first_finished = False
368
+
369
+ step = input_ids.shape[1] - start_pos
370
+ audio_step = step - 1
371
+ if generated_tokens is not None:
372
+ generated_tokens.append(text_token)
373
+ if not think_end_step and generated_tokens[-len(self.config.think_end_ids):] == list(self.config.think_end_ids):
374
+ think_end_step = step + 2
375
+ audio_step = (step - think_end_step) if think_end_step else -1
376
+ for i, al in enumerate(out.audio_logits):
377
+ if audio_step < i:
378
+ audio_codes[i].append(self.audio_pad_token)
379
+ else:
380
+ logits_i = al[0, -1, :].clone() / 0.2
381
+ for prev_code in audio_codes[i][-3:]:
382
+ score = logits_i[prev_code]
383
+ logits_i[prev_code] = torch.where(score > 0, score / 1.05, score * 1.05)
384
+ top_val, top_idx = logits_i.topk(50)
385
+ code = top_idx[torch.multinomial(F.softmax(top_val, dim=-1), 1)].item()
386
+ audio_codes[i].append(code)
387
+ if audio_stop_pos[i] is None and code >= 2048:
388
+ audio_stop_pos[i] = len(audio_codes[i]) - 1
389
+
390
+ if text_finished and all(audio_stop_pos[i] is not None for i in range(8)):
391
+ break
392
+
393
+ input_ids = torch.cat((input_ids, torch.tensor([[text_token]], device=input_ids.device)), dim=1)
394
+ audio_buffer = torch.cat((audio_buffer, torch.full((1, 8, 1), self.audio_pad_token, dtype=torch.long, device=input_ids.device)), dim=2)
395
+ for i in range(min(audio_step + 1, 8)):
396
+ audio_buffer[0, i, -1] = audio_codes[i][-1]
397
+
398
+ audio_frame = None
399
+ if return_audio_codes and audio_step >= 7:
400
+ frame = [audio_codes[i][step - 7 + i] for i in range(8)]
401
+ active_layers = sum(1 for i in range(8) if audio_stop_pos[i] is None or step - 7 + i < audio_stop_pos[i])
402
+ if active_layers >= 8:
403
+ audio_frame = frame
404
+ if not text_finished:
405
+ yield input_ids[:, start_pos:], audio_frame
406
+ if text_token == eos_token_id:
407
+ text_finished = True
408
+ else:
409
+ yield None, audio_frame
src/omni/models/vlm.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import torch.nn.functional as F
4
+ import warnings
5
+ from typing import Optional, Tuple, List, Union
6
+ from torch import nn
7
+ from transformers.modeling_outputs import MoeCausalLMOutputWithPast
8
+
9
+ from omni.core import precompute_freqs_cis, MOEFeedForward
10
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
11
+ from omni.encoders.vision import SiglipVisionEncoder
12
+ from omni.projectors import MMVisionProjector
13
+
14
+ warnings.filterwarnings('ignore')
15
+
16
+
17
+ class VLMConfig(MiniMindConfig):
18
+ model_type = "minimind-v"
19
+
20
+ def __init__(self, image_special_token='<|image_pad|>', image_ids=[12], **kwargs):
21
+ self.image_special_token = image_special_token
22
+ self.image_ids = image_ids
23
+ self.image_hidden_size = kwargs.get("image_hidden_size", 768)
24
+ self.image_token_len = kwargs.get("image_token_len", 64)
25
+ super().__init__(**kwargs)
26
+
27
+
28
+ class MiniMindVLM(MiniMindForCausalLM):
29
+ config_class = VLMConfig
30
+
31
+ def __init__(self, config: VLMConfig = None, vision_model_path: Optional[str] = None):
32
+ self.config = config or VLMConfig()
33
+ super().__init__(self.config)
34
+ self.vision_encoder = SiglipVisionEncoder(vision_model_path) if vision_model_path else SiglipVisionEncoder()
35
+ self.vision_proj = MMVisionProjector(
36
+ self.config.image_hidden_size, self.config.hidden_size, target_tokens=self.config.image_token_len
37
+ )
38
+
39
+ @staticmethod
40
+ def get_image_embeddings(image_inputs, vision_model):
41
+ if vision_model is None:
42
+ return None
43
+ return vision_model.encode(image_inputs)
44
+
45
+ @torch.compiler.disable
46
+ def count_vision_proj(self, tokens, h, vision_tensors=None, seqlen=512):
47
+ if vision_tensors is None or not self.config.image_ids:
48
+ return h
49
+ marker, vf = self.config.image_ids[0], vision_tensors
50
+ if vf.dim() == 3:
51
+ vf = vf.unsqueeze(1)
52
+ out = []
53
+ for b in range(h.size(0)):
54
+ hb, seq, k, i = h[b], tokens[b].tolist(), 0, 0
55
+ while i < len(seq):
56
+ if seq[i] == marker:
57
+ start = i
58
+ while i < len(seq) and seq[i] == marker:
59
+ i += 1
60
+ if k < vf.size(1):
61
+ hb = torch.cat((hb[:start], vf[b][k][:i - start], hb[i:]), dim=0)[:seqlen]
62
+ k += 1
63
+ else:
64
+ i += 1
65
+ out.append(hb)
66
+ return torch.stack(out)
67
+
68
+ def forward(self,
69
+ input_ids: Optional[torch.Tensor] = None,
70
+ attention_mask: Optional[torch.Tensor] = None,
71
+ past_key_values: Optional[List[Tuple[torch.Tensor, torch.Tensor]]] = None,
72
+ use_cache: bool = False,
73
+ logits_to_keep: Union[int, torch.Tensor] = 0,
74
+ labels: Optional[torch.Tensor] = None,
75
+ pixel_values: Optional[torch.FloatTensor] = None,
76
+ **args):
77
+ batch_size, seq_length = input_ids.shape
78
+ if hasattr(past_key_values, 'layers'):
79
+ past_key_values = None
80
+ past_key_values = past_key_values or [None] * len(self.model.layers)
81
+ start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
82
+
83
+ hidden_states = self.model.dropout(self.model.embed_tokens(input_ids))
84
+
85
+ if pixel_values is not None and start_pos == 0:
86
+ if hasattr(pixel_values, 'keys'):
87
+ sample_val = next(iter(pixel_values.values()))
88
+ if sample_val.ndim == 5:
89
+ bs, num = sample_val.shape[:2]
90
+ vision_tensors = self.vision_proj(
91
+ MiniMindVLM.get_image_embeddings(
92
+ {k: v.flatten(0, 1) for k, v in pixel_values.items()}, self.vision_encoder
93
+ )
94
+ ).view(bs, num, self.config.image_token_len, -1)
95
+ else:
96
+ vision_tensors = self.vision_proj(
97
+ MiniMindVLM.get_image_embeddings(pixel_values, self.vision_encoder)
98
+ )
99
+ else:
100
+ if len(pixel_values.shape) == 6:
101
+ pixel_values = pixel_values.squeeze(2)
102
+ bs, num, c, im_h, im_w = pixel_values.shape
103
+ vision_tensors = torch.stack(
104
+ [self.vision_proj(MiniMindVLM.get_image_embeddings(pixel_values[:, i, :, :, :], self.vision_encoder))
105
+ for i in range(num)], dim=1
106
+ )
107
+ hidden_states = self.count_vision_proj(
108
+ tokens=input_ids, h=hidden_states, vision_tensors=vision_tensors, seqlen=input_ids.shape[1]
109
+ )
110
+
111
+ if self.model.freqs_cos[0, 0] == 0:
112
+ freqs_cos, freqs_sin = precompute_freqs_cis(
113
+ dim=self.config.head_dim, end=self.config.max_position_embeddings,
114
+ rope_base=self.config.rope_theta, rope_scaling=self.config.rope_scaling
115
+ )
116
+ self.model.freqs_cos, self.model.freqs_sin = freqs_cos.to(hidden_states.device), freqs_sin.to(hidden_states.device)
117
+ position_embeddings = (
118
+ self.model.freqs_cos[start_pos:start_pos + seq_length],
119
+ self.model.freqs_sin[start_pos:start_pos + seq_length]
120
+ )
121
+
122
+ presents = []
123
+ for layer_idx, (layer, past_key_value) in enumerate(zip(self.model.layers, past_key_values)):
124
+ hidden_states, present = layer(
125
+ hidden_states, position_embeddings,
126
+ past_key_value=past_key_value, use_cache=use_cache, attention_mask=attention_mask
127
+ )
128
+ presents.append(present)
129
+
130
+ hidden_states = self.model.norm(hidden_states)
131
+
132
+ aux_loss = sum([l.mlp.aux_loss for l in self.model.layers if isinstance(l.mlp, MOEFeedForward)],
133
+ hidden_states.new_zeros(1).squeeze())
134
+ aux_loss = aux_loss + sum(p.sum() for p in self.vision_proj.parameters()) * 0
135
+
136
+ slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
137
+ logits = self.lm_head(hidden_states[:, slice_indices, :])
138
+
139
+ loss = None
140
+ if labels is not None:
141
+ shift_logits = logits[..., :-1, :].contiguous()
142
+ shift_labels = labels[..., 1:].contiguous()
143
+ loss = F.cross_entropy(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1), ignore_index=-100)
144
+
145
+ output = MoeCausalLMOutputWithPast(loss=loss, aux_loss=aux_loss, logits=logits, past_key_values=presents, hidden_states=hidden_states)
146
+ return output
147
+
148
+ def generate(self, *args, num_return_sequences=1, **kwargs):
149
+ if num_return_sequences > 1 and 'pixel_values' in kwargs:
150
+ pv = kwargs['pixel_values']
151
+ if hasattr(pv, 'keys'):
152
+ kwargs['pixel_values'] = {k: v.repeat(num_return_sequences, *([1] * (v.ndim - 1))) for k, v in pv.items()}
153
+ else:
154
+ kwargs['pixel_values'] = pv.repeat(num_return_sequences, *([1] * (pv.ndim - 1)))
155
+ return super().generate(*args, num_return_sequences=num_return_sequences, **kwargs)
src/omni/projectors/__init__.py CHANGED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from omni.projectors.vision import MMVisionProjector
2
+ from omni.projectors.audio import MMAudioProjector
3
+
4
+ __all__ = ["MMVisionProjector", "MMAudioProjector"]
src/omni/projectors/audio.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class MMAudioProjector(nn.Module):
6
+ def __init__(self, in_dim, out_dim):
7
+ super().__init__()
8
+ self.mlp = nn.Sequential(
9
+ nn.LayerNorm(in_dim),
10
+ nn.Linear(in_dim, out_dim),
11
+ nn.GELU(),
12
+ nn.Linear(out_dim, out_dim),
13
+ )
14
+
15
+ def forward(self, x):
16
+ return self.mlp(x)
src/omni/projectors/vision.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ from torch import nn
4
+
5
+
6
+ class MMVisionProjector(nn.Module):
7
+ def __init__(self, in_dim, out_dim, source_tokens=64, target_tokens=64):
8
+ super().__init__()
9
+ self.mlp = nn.Sequential(
10
+ nn.LayerNorm(in_dim),
11
+ nn.Linear(in_dim, out_dim),
12
+ nn.GELU(),
13
+ nn.Linear(out_dim, out_dim),
14
+ )
15
+
16
+ def forward(self, x):
17
+ return self.mlp(x)
src/omni/serve/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ import omni.serve
src/omni/serve/realtime.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+
4
+ class SileroVAD:
5
+ def __init__(self, path):
6
+ import onnxruntime as ort
7
+ opts = ort.SessionOptions()
8
+ opts.inter_op_num_threads = opts.intra_op_num_threads = 1
9
+ opts.log_severity_level = 4
10
+ self.session = ort.InferenceSession(path, providers=["CPUExecutionProvider"], sess_options=opts)
11
+ self.h, self.c = np.zeros((2, 1, 64), dtype=np.float32), np.zeros((2, 1, 64), dtype=np.float32)
12
+
13
+ def reset(self):
14
+ self.h[:], self.c[:] = 0, 0
15
+
16
+ def __call__(self, chunk, sr=16000):
17
+ out, self.h, self.c = self.session.run(None, {"input": chunk.reshape(1, -1).astype(np.float32), "h": self.h, "c": self.c, "sr": np.array(sr, dtype="int64")})
18
+ return float(out[0][0])
19
+
20
+
21
+ class RealtimeSession:
22
+ def __init__(self, vad_path, sr=16000, threshold=0.8, min_speech_ms=128, min_silence_ms=800):
23
+ self.vad, self.sr, self.threshold = SileroVAD(vad_path), sr, threshold
24
+ self.min_speech, self.min_silence = int(sr * min_speech_ms / 1000), int(sr * min_silence_ms / 1000)
25
+ self.reset()
26
+
27
+ def reset(self):
28
+ self.vad.reset()
29
+ self.buffer, self.ring, self.speaking, self.generating, self.interrupt = [], [], False, False, False
30
+ self.speech_samples = self.silence_samples = self.tail_silence = 0
31
+
32
+ def push_chunk(self, chunk, W=1024):
33
+ for i in range(0, max(len(chunk), 1), W):
34
+ w = chunk[i:i + W]
35
+ if len(w) < W:
36
+ w = np.pad(w, (0, W - len(w)))
37
+ prob = self.vad(w, self.sr)
38
+ if prob > self.threshold:
39
+ self.silence_samples = self.tail_silence = 0
40
+ self.speech_samples += len(w)
41
+ self.buffer.append(w)
42
+ if self.speech_samples >= self.min_speech and not self.speaking:
43
+ self.speaking = True
44
+ self.buffer = self.ring + self.buffer
45
+ self.ring = []
46
+ if self.generating and self.speaking:
47
+ self.interrupt = True
48
+ return 'interrupt'
49
+ elif self.speaking:
50
+ self.silence_samples += len(w)
51
+ self.tail_silence += 1
52
+ self.buffer.append(w)
53
+ if self.silence_samples >= self.min_silence:
54
+ if self.tail_silence > 1:
55
+ del self.buffer[-(self.tail_silence - 1):]
56
+ self.speaking, self.speech_samples, self.silence_samples, self.tail_silence = False, 0, 0, 0
57
+ return 'speech_end'
58
+ else:
59
+ if self.speech_samples > 0:
60
+ self.buffer.clear()
61
+ self.speech_samples = 0
62
+ self.ring = [w]
63
+ return 'listening'
64
+
65
+ def get_audio(self):
66
+ audio = np.concatenate(self.buffer) if self.buffer else np.array([], dtype=np.float32)
67
+ self.buffer.clear()
68
+ return audio
src/omni/trainers/__init__.py CHANGED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from omni.trainers import pretrain, full_sft, lora, dpo, distillation, ppo, grpo, agent, rollout_engine, train_tokenizer, pretrain_vlm, full_sft_vlm, full_sft_omni
2
+
3
+ __all__ = [
4
+ "pretrain",
5
+ "full_sft",
6
+ "lora",
7
+ "dpo",
8
+ "distillation",
9
+ "ppo",
10
+ "grpo",
11
+ "agent",
12
+ "rollout_engine",
13
+ "train_tokenizer",
14
+ "pretrain_vlm",
15
+ "full_sft_vlm",
16
+ "full_sft_omni",
17
+ ]
src/omni/trainers/agent.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import re
6
+ import gc
7
+ import json
8
+ import math
9
+ import random
10
+ import signal
11
+ import argparse
12
+ import warnings
13
+ import torch
14
+ import torch.nn.functional as F
15
+ import torch.distributed as dist
16
+ from contextlib import nullcontext
17
+ from torch import optim
18
+ from torch.nn.parallel import DistributedDataParallel
19
+ from torch.utils.data import DataLoader, DistributedSampler
20
+ from torch.optim.lr_scheduler import CosineAnnealingLR
21
+ from transformers import AutoTokenizer
22
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
23
+ from omni.datasets.lm_dataset import AgentRLDataset
24
+ from omni.utils.training import Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, SkipBatchSampler, init_model, LMForRewardModel
25
+ from omni.trainers.rollout_engine import create_rollout_engine, compute_per_token_logps
26
+
27
+ warnings.filterwarnings('ignore')
28
+
29
+ # ================================ 工具与 Reward = Start ================================
30
+
31
+ def rep_penalty(text, n=3, cap=0.5):
32
+ toks = re.findall(r"\w+|[^\w\s]", text.lower())
33
+ grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
34
+ return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0
35
+
36
+ # ======== 工具定义 ========
37
+ TOOLS = [
38
+ {"type": "function", "function": {"name": "calculate_math", "description": "计算数学表达式", "parameters": {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]}}},
39
+ {"type": "function", "function": {"name": "unit_converter", "description": "单位换算", "parameters": {"type": "object", "properties": {"value": {"type": "number"}, "from_unit": {"type": "string"}, "to_unit": {"type": "string"}}, "required": ["value", "from_unit", "to_unit"]}}},
40
+ {"type": "function", "function": {"name": "get_current_weather", "description": "获取天气", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}},
41
+ {"type": "function", "function": {"name": "get_current_time", "description": "获取时间", "parameters": {"type": "object", "properties": {"timezone": {"type": "string", "default": "Asia/Shanghai"}}, "required": []}}},
42
+ {"type": "function", "function": {"name": "get_exchange_rate", "description": "查询汇率", "parameters": {"type": "object", "properties": {"from_currency": {"type": "string"}, "to_currency": {"type": "string"}}, "required": ["from_currency", "to_currency"]}}},
43
+ {"type": "function", "function": {"name": "translate_text", "description": "翻译文本", "parameters": {"type": "object", "properties": {"text": {"type": "string"}, "target_language": {"type": "string"}}, "required": ["text", "target_language"]}}},
44
+ ]
45
+
46
+ # ======== 模拟数据 ========
47
+ WEATHER_DATA = {"北京": ("28°C", "晴"), "上海": ("15°C", "多云"), "广州": ("32°C", "闷热"), "深圳": ("30°C", "晴"), "杭州": ("22°C", "阴"), "成都": ("18°C", "小雨"), "武汉": ("25°C", "多云"), "南京": ("20°C", "晴"), "西安": ("16°C", "大风"), "重庆": ("26°C", "阴"), "Tokyo": ("12°C", "晴"), "New York": ("8°C", "多云"), "London": ("5°C", "小雨"), "Paris": ("10°C", "阴"), "Sydney": ("25°C", "晴朗")}
48
+ TIME_DATA = {"Asia/Shanghai": "2025-03-07 14:30:00", "America/New_York": "2025-03-07 01:30:00", "Europe/London": "2025-03-07 06:30:00", "Asia/Tokyo": "2025-03-07 15:30:00", "Europe/Paris": "2025-03-07 07:30:00", "Australia/Sydney": "2025-03-07 17:30:00"}
49
+ EXCHANGE_DATA = {("USD", "CNY"): 7.21, ("EUR", "CNY"): 7.85, ("GBP", "CNY"): 9.12, ("JPY", "CNY"): 0.048, ("USD", "EUR"): 0.92, ("USD", "GBP"): 0.79, ("CNY", "JPY"): 20.83, ("AUD", "CNY"): 4.72}
50
+ TRANSLATE_DATA = {("你好世界", "english"): "Hello World", ("Good morning", "chinese"): "早上好", ("今天天气真好", "english"): "The weather is nice today", ("I love programming", "chinese"): "我喜欢编程", ("机器学习很有趣", "english"): "Machine learning is interesting", ("Happy birthday", "chinese"): "生日快乐"}
51
+ UNIT_DATA = {"km_miles": 0.621371, "miles_km": 1.60934, "kg_pounds": 2.20462, "pounds_kg": 0.453592, "meters_feet": 3.28084, "feet_meters": 0.3048, "celsius_fahrenheit": 1.8, "fahrenheit_celsius": 0.5556}
52
+
53
+ # ======== 模拟执行 ========
54
+ MOCK_RESULTS = {
55
+ "calculate_math": lambda args: {"result": str(eval(str(args.get("expression", "0")).replace("^", "**").replace("×", "*").replace("÷", "/").replace("−", "-").replace("(", "(").replace(")", ")"), {"__builtins__": {}, "math": math}))},
56
+ "unit_converter": lambda args: {"result": round(float(args.get("value", 0)) * UNIT_DATA.get(f"{args.get('from_unit', '').lower()}_{args.get('to_unit', '').lower()}", 1), 4)},
57
+ "get_current_weather": lambda args: (lambda w: {"city": args.get("location"), "temperature": w[0], "humidity": "65%", "condition": w[1]})(WEATHER_DATA.get(args.get("location"), ("22°C", "晴"))),
58
+ "get_current_time": lambda args: {"datetime": TIME_DATA.get(args.get("timezone", "Asia/Shanghai"), "2025-03-07 14:30:00"), "timezone": args.get("timezone", "Asia/Shanghai")},
59
+ "get_exchange_rate": lambda args: {"from": args.get("from_currency"), "to": args.get("to_currency"), "rate": EXCHANGE_DATA.get((args.get("from_currency"), args.get("to_currency")), 1.0)},
60
+ "translate_text": lambda args: {"translated_text": TRANSLATE_DATA.get((args.get("text"), args.get("target_language")), args.get("text", ""))},
61
+ }
62
+
63
+ # ======== 参数校验 ========
64
+ CHECK_ARGS = {
65
+ "calculate_math": lambda a: bool(a.get("expression")),
66
+ "unit_converter": lambda a: a.get("value") is not None and a.get("from_unit") and a.get("to_unit"),
67
+ "get_current_weather": lambda a: bool(a.get("location")),
68
+ "get_current_time": lambda a: True,
69
+ "get_exchange_rate": lambda a: bool(a.get("from_currency")) and bool(a.get("to_currency")),
70
+ "translate_text": lambda a: bool(a.get("text")) and bool(a.get("target_language")),
71
+ }
72
+
73
+ # ======== 工具调用解析与执行 ========
74
+ def parse_tool_calls(text):
75
+ calls = []
76
+ for m in re.findall(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL):
77
+ try: calls.append(json.loads(m.strip()))
78
+ except: pass
79
+ return calls
80
+
81
+ def execute_tool(name, args):
82
+ fn = MOCK_RESULTS.get(name)
83
+ if not fn: return None
84
+ try:
85
+ signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError()))
86
+ signal.alarm(1)
87
+ return fn(args)
88
+ except:
89
+ return None
90
+ finally:
91
+ try: signal.alarm(0)
92
+ except: pass
93
+
94
+ # ======== 多轮 Rollout ========
95
+ def rollout_single(rollout_engine, tokenizer, messages, tools, max_turns=3, max_new_tokens=256, thinking_ratio=0.5, device="cuda"):
96
+ all_outputs = []
97
+ prompt_ids = None
98
+ response_ids = []
99
+ response_mask = []
100
+ response_old_logps = []
101
+ final_context = ""
102
+ unfinished = False
103
+ open_thinking = random.random() < thinking_ratio
104
+ for turn in range(max_turns):
105
+ context = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, tools=tools, open_thinking=open_thinking)
106
+ inputs = tokenizer(context, return_tensors="pt", add_special_tokens=False).to(device)
107
+ context_ids = inputs["input_ids"][0].tolist()
108
+ if prompt_ids is None:
109
+ prompt_ids = context_ids
110
+ rollout_result = rollout_engine.rollout(
111
+ prompt_ids=inputs["input_ids"],
112
+ attention_mask=inputs["attention_mask"],
113
+ num_generations=1,
114
+ max_new_tokens=max_new_tokens,
115
+ temperature=0.8,
116
+ )
117
+ new_ids = rollout_result.completion_ids[0].tolist()
118
+ new_logps = rollout_result.per_token_logps[0].tolist()
119
+ if len(new_ids) != len(new_logps): Logger(f"rollout token/logprob length mismatch: {len(new_ids)} vs {len(new_logps)}")
120
+ pairs = [(t, lp) for t, lp in zip(new_ids, new_logps) if t != tokenizer.pad_token_id and t != tokenizer.eos_token_id]
121
+ new_ids = [t for t, _ in pairs]
122
+ new_logps = [lp for _, lp in pairs]
123
+ new_text = rollout_result.completions[0]
124
+ all_outputs.append(new_text)
125
+ response_ids.extend(new_ids)
126
+ response_mask.extend([1] * len(new_ids))
127
+ response_old_logps.extend(new_logps)
128
+ final_context = context + new_text
129
+ calls = parse_tool_calls(new_text)
130
+ if not calls:
131
+ break
132
+ unfinished = turn == max_turns - 1
133
+ messages.append({"role": "assistant", "content": new_text})
134
+ for call in calls:
135
+ name, raw = call.get("name", ""), call.get("arguments", {})
136
+ if isinstance(raw, str):
137
+ try: raw = json.loads(raw)
138
+ except: raw = {}
139
+ result = execute_tool(name, raw)
140
+ result_str = (json.dumps(result, ensure_ascii=False) if result else '{"error": "tool not found"}')[:2048] # 防止天文数字撑爆tokenizer
141
+ messages.append({"role": "tool", "content": result_str})
142
+
143
+ observe_context = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=not unfinished, tools=tools, open_thinking=open_thinking)
144
+ observe_ids = tokenizer(observe_context, return_tensors="pt", add_special_tokens=False)["input_ids"][0].tolist()
145
+ current_len = len(prompt_ids) + len(response_ids)
146
+ obs_delta = observe_ids[current_len:]
147
+ response_ids.extend(obs_delta)
148
+ response_mask.extend([0] * len(obs_delta))
149
+ response_old_logps.extend([0.0] * len(obs_delta))
150
+ final_context = observe_context
151
+
152
+ final_output = all_outputs[-1] if all_outputs else ""
153
+ prompt_ids = prompt_ids or []
154
+ return final_output, final_context, prompt_ids, response_ids, response_mask, response_old_logps, list(all_outputs), unfinished
155
+
156
+ def rollout_batch(rollout_engine, tokenizer, messages_batch, tools_batch, num_gen, max_turns=3, max_new_tokens=256, thinking_ratio=0.5, device="cuda"):
157
+ all_completions = []
158
+ all_contexts = []
159
+ all_prompt_ids = []
160
+ all_response_ids = []
161
+ all_response_masks = []
162
+ all_response_old_logps = []
163
+ all_turn_outputs = []
164
+ all_unfinished = []
165
+ for messages, tools in zip(messages_batch, tools_batch):
166
+ for _ in range(num_gen):
167
+ msgs_copy = [dict(m) for m in messages]
168
+ completion, context, prompt_ids, response_ids, response_mask, response_old_logps, turn_outputs, unfinished = rollout_single(rollout_engine, tokenizer, msgs_copy, tools, max_turns, max_new_tokens, thinking_ratio, device)
169
+ all_completions.append(completion)
170
+ all_contexts.append(context)
171
+ all_prompt_ids.append(prompt_ids)
172
+ all_response_ids.append(response_ids)
173
+ all_response_masks.append(response_mask)
174
+ all_response_old_logps.append(response_old_logps)
175
+ all_turn_outputs.append(turn_outputs)
176
+ all_unfinished.append(unfinished)
177
+ return all_completions, all_contexts, all_prompt_ids, all_response_ids, all_response_masks, all_response_old_logps, all_turn_outputs, all_unfinished
178
+
179
+ # ======== Reward 计算 ========
180
+ def validate_gt_in_text(text, gt_list):
181
+ text, text_num = str(text), str(text).replace(',', '')
182
+ nums = [float(x) for x in re.findall(r'(?<![\w.])[-+]?\d+(?:\.\d+)?(?![\w.])', text_num)]
183
+ return {g for g in gt_list if ((s := str(g).strip()) and s.lower() in text.lower()) or (re.fullmatch(r'[-+]?\d+(?:\.\d+)?', str(g).strip().replace(',', '')) and any(abs(float(str(g).strip().replace(',', '')) - n) < 1e-6 for n in nums))}
184
+
185
+ def calculate_rewards(prompts, completions, gt_batch, tools_batch, num_gen, reward_model=None, device="cuda", turn_outputs_batch=None, unfinished_batch=None):
186
+ rewards = torch.zeros(len(completions), device=device)
187
+ for idx, response in enumerate(completions):
188
+ reward, answer = 0.0, response
189
+ sample_idx = idx // num_gen
190
+ tools = tools_batch[sample_idx]
191
+ turn_outputs = turn_outputs_batch[idx] if turn_outputs_batch is not None else [response]
192
+ unfinished = unfinished_batch[idx] if unfinished_batch is not None else False
193
+ turn_answers = [turn.split('</think>', 1)[-1].strip() if '</think>' in turn else turn.strip() for turn in turn_outputs]
194
+ answer = turn_answers[-1] if turn_answers else response.strip()
195
+ valid_names = {t['function']['name'] for t in tools} if tools else set()
196
+ tool_calls = []
197
+ for turn_answer in turn_answers: tool_calls.extend(parse_tool_calls(turn_answer)) # 解析tool调用
198
+ reward -= 0.5 * sum(abs(turn.count('<tool_call>') - turn.count('</tool_call>')) for turn in turn_answers) # 标签扣分
199
+ # -------- 无工具调用:格式+reward奖励 --------
200
+ if not tool_calls:
201
+ reward += 0.5 if 5 <= len(response.strip()) <= 800 else -0.5 # 长度分
202
+ if '</think>' in response:
203
+ think, answer = response.split('</think>', 1)
204
+ reward += 1.0 if 20 <= len(think.strip()) <= 300 else -0.5 # 思考长度分
205
+ reward += 0.25 if response.count('</think>') == 1 else -0.25 # 思考闭合分
206
+ answer = answer.strip()
207
+ if reward_model is not None:
208
+ prompt = prompts[sample_idx]
209
+ pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>"
210
+ matches = re.findall(pattern, prompt, re.DOTALL)
211
+ messages = [{"role": role, "content": content.strip()} for role, content in matches]
212
+ score = reward_model.get_score(messages, answer)
213
+ reward += score # RM分
214
+ reward -= rep_penalty(answer)
215
+ rewards[idx] = max(min(reward, 3.0), -3.0) # 总分Clip
216
+ # -------- 有工具调用:执行结果奖励 --------
217
+ else:
218
+ gt = gt_batch[sample_idx]
219
+ valid_call_count = 0
220
+ for tool_call in tool_calls:
221
+ name, raw = tool_call.get("name", ""), tool_call.get("arguments", {})
222
+ if isinstance(raw, str):
223
+ try: raw = json.loads(raw)
224
+ except: raw = {}
225
+ check = CHECK_ARGS.get(name)
226
+ valid_call_count += int(bool(name in valid_names and check and check(raw)))
227
+ tool_gap = abs(valid_call_count - len(gt)) + max(0, len(tool_calls) - valid_call_count) # tool数差值
228
+ reward += 0.5 if tool_gap == 0 else -0.5 * tool_gap # tool对齐分
229
+
230
+ final_text = "" if unfinished else (answer.split('</tool_call>')[-1] if '</tool_call>' in answer else answer)
231
+ verified = validate_gt_in_text(final_text, gt) if gt else set()
232
+ if gt: reward += 2.5 * len(verified) / len(gt) # GT分
233
+ if unfinished: reward -= 0.5 # 未完成扣分
234
+ reward -= rep_penalty(final_text if final_text else answer)
235
+ rewards[idx] = max(min(reward, 3.0), -3.0) # 总分Clip
236
+ return rewards
237
+
238
+ # ================================ 工具与 Reward = End ================================
239
+ def rl_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_model=None, start_step=0, wandb=None, use_sglang=False):
240
+ last_step = start_step
241
+ for step, batch in enumerate(loader, start=start_step + 1):
242
+ messages_batch = batch['messages']
243
+ tools_batch = batch['tools']
244
+ gt_batch = batch['gt']
245
+ last_step = step
246
+
247
+ with torch.no_grad():
248
+ completions, contexts, prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch, turn_outputs_batch, unfinished_batch = rollout_batch(rollout_engine, tokenizer, messages_batch, tools_batch, args.num_generations, max_turns=3, max_new_tokens=args.max_gen_len, thinking_ratio=args.thinking_ratio, device=args.device)
249
+
250
+ prompts = [tokenizer.apply_chat_template(m, tokenize=False, add_generation_prompt=True, tools=t) for m, t in zip(messages_batch, tools_batch)]
251
+ packed_samples = []
252
+ for p, r, m, old_lp in zip(prompt_ids_batch, response_ids_batch, response_masks_batch, response_old_logps_batch):
253
+ ids = p + r
254
+ mask = [0] * len(p) + m
255
+ old_logps = [0.0] * max(len(p) - 1, 0) + old_lp
256
+ if len(ids) > args.max_total_len:
257
+ ids = ids[-args.max_total_len:]
258
+ mask = mask[-args.max_total_len:]
259
+ old_logps = old_logps[-(len(ids) - 1):]
260
+ prompt_len = next((i for i, v in enumerate(mask) if v == 1), len(mask))
261
+ packed_samples.append((ids, mask, prompt_len, old_logps))
262
+ seq_lens = torch.tensor([len(ids) for ids, _, _, _ in packed_samples], device=args.device)
263
+ max_len = seq_lens.max().item()
264
+ input_ids = torch.tensor([ids + [tokenizer.pad_token_id] * (max_len - len(ids)) for ids, _, _, _ in packed_samples], device=args.device)
265
+ prompt_lens = torch.tensor([prompt_len for _, _, prompt_len, _ in packed_samples], device=args.device)
266
+ full_response_masks = torch.tensor([mask + [0] * (max_len - len(mask)) for _, mask, _, _ in packed_samples], device=args.device, dtype=torch.float32)
267
+ old_per_token_logps = torch.tensor([old_logps + [0.0] * ((max_len - 1) - len(old_logps)) for _, _, _, old_logps in packed_samples], device=args.device, dtype=torch.float32)
268
+ full_mask = (input_ids != tokenizer.pad_token_id).long()
269
+
270
+ rewards = calculate_rewards(prompts, completions, gt_batch, tools_batch, args.num_generations, reward_model, device=args.device, turn_outputs_batch=turn_outputs_batch, unfinished_batch=unfinished_batch)
271
+
272
+ model_unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
273
+ with autocast_ctx:
274
+ res = model_unwrapped(input_ids, attention_mask=full_mask)
275
+ aux_loss = res.aux_loss if lm_config.use_moe else torch.tensor(0.0, device=args.device)
276
+ logits = res.logits[:, :-1, :]
277
+ per_token_logps = F.log_softmax(logits, dim=-1).gather(2, input_ids[:, 1:].unsqueeze(-1)).squeeze(-1)
278
+
279
+ with torch.no_grad():
280
+ ref_per_token_logps = compute_per_token_logps(ref_model, input_ids, input_ids.size(1) - 1, attention_mask=full_mask)
281
+
282
+ completion_mask = full_response_masks[:, 1:]
283
+ is_eos = (input_ids[:, 1:] == tokenizer.eos_token_id) & completion_mask.bool()
284
+ eos_idx = torch.full((completion_mask.size(0),), completion_mask.size(1) - 1, device=args.device, dtype=torch.long)
285
+ has_eos = is_eos.any(dim=1)
286
+ eos_idx[has_eos] = is_eos.int().argmax(dim=1)[has_eos]
287
+ pos = torch.arange(completion_mask.size(1), device=args.device).unsqueeze(0)
288
+ completion_mask = completion_mask * (pos <= eos_idx.unsqueeze(1)).float()
289
+ token_counts = completion_mask.sum(dim=1)
290
+ valid_rows = token_counts > 0
291
+
292
+ if args.debug_mode and is_main_process() and step % args.debug_interval == 0:
293
+ for i in range(len(messages_batch)):
294
+ Logger(f"[DEBUG] step={step}, gt[{i}]: {repr(gt_batch[i])}")
295
+ Logger('-'*100)
296
+ for j in range(args.num_generations):
297
+ idx = i * args.num_generations + j
298
+ plen, slen = prompt_lens[idx].item(), seq_lens[idx].item()
299
+ Logger(f"{'=' * 30} [DEBUG] gen[{i}][{j}] CONTEXT_BEGIN {'=' * 30}")
300
+ Logger(contexts[idx])
301
+ Logger(f"{'=' * 31} [DEBUG] gen[{i}][{j}] CONTEXT_END {'=' * 31}")
302
+ Logger(f"[DEBUG] gen[{i}][{j}] prompt_len={plen}, seq_len={slen}")
303
+ tokens = input_ids[idx, plen:slen].tolist()
304
+ text = tokenizer.decode(tokens, skip_special_tokens=False)
305
+ Logger(f"{'=' * 28} [DEBUG] gen[{i}][{j}] COMPLETION_BEGIN [{plen}:{slen}] {'=' * 28}")
306
+ Logger(text)
307
+ Logger(f"{'=' * 29} [DEBUG] gen[{i}][{j}] COMPLETION_END {'=' * 29}")
308
+ Logger(f"[DEBUG] gen[{i}][{j}] reward={rewards[idx].item():.4f}")
309
+ Logger('='*100)
310
+
311
+ grouped_rewards = rewards.view(-1, args.num_generations)
312
+ mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations)
313
+ std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations)
314
+ advantages = (rewards - mean_r) / (std_r + 1e-4)
315
+
316
+ kl_div = ref_per_token_logps - per_token_logps
317
+ per_token_kl = torch.exp(kl_div) - kl_div - 1
318
+ ratio = torch.exp(per_token_logps - old_per_token_logps)
319
+ if args.loss_type == "cispo":
320
+ clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
321
+ per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
322
+ else:
323
+ clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
324
+ per_token_loss1 = ratio * advantages.unsqueeze(1)
325
+ per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
326
+ per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl)
327
+ policy_loss = (((per_token_loss * completion_mask).sum(dim=1)[valid_rows] / token_counts[valid_rows].clamp(min=1)).mean()
328
+ if valid_rows.any() else per_token_loss.sum() * 0.0)
329
+ loss = (policy_loss + aux_loss) / args.accumulation_steps
330
+ loss.backward()
331
+
332
+ if step % args.accumulation_steps == 0:
333
+ if args.grad_clip > 0: torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
334
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
335
+
336
+ if step % args.log_interval == 0 or step == iters:
337
+ pl = loss.item() * args.accumulation_steps
338
+ ar = rewards.mean().item()
339
+ al = token_counts.float().mean().item()
340
+ kl = ((ref_per_token_logps - per_token_logps) * completion_mask).sum().item() / max(token_counts.sum().item(), 1)
341
+ gs = grouped_rewards.std(dim=1, unbiased=False).mean().item()
342
+ am, ast = advantages.mean().item(), advantages.std().item()
343
+ lr = optimizer.param_groups[0]['lr']
344
+ Logger(f'Epoch:[{epoch+1}/{args.epochs}]({step}/{iters}), Reward:{ar:.4f}, KL:{kl:.4f}, GrpStd:{gs:.4f}, AdvStd:{ast:.4f}, Loss:{pl:.4f}, AvgLen:{al:.2f}, AdvMean:{am:.4f}, LR:{lr:.8f}')
345
+ if wandb and is_main_process():
346
+ wandb.log({"reward":ar,"kl_ref":kl,"group_reward_std":gs,"advantages_std":ast,"policy_loss":pl,"avg_response_len":al,"advantages_mean":am,"learning_rate":lr})
347
+
348
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
349
+ model.eval()
350
+ moe_suffix = '_moe' if lm_config.use_moe else ''
351
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
352
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
353
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
354
+ state_dict = raw_model.state_dict()
355
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
356
+ lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer,
357
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scheduler=scheduler)
358
+ model.train()
359
+ del state_dict
360
+
361
+ if step % args.save_interval == 0 or step == iters: rollout_engine.update_policy(model)
362
+
363
+ del per_token_logps, ref_per_token_logps
364
+ del completions, rewards, grouped_rewards, mean_r, std_r, advantages, completion_mask
365
+
366
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
367
+ if args.grad_clip > 0: torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
368
+ optimizer.step(); scheduler.step(); optimizer.zero_grad()
369
+
370
+
371
+ if __name__ == "__main__":
372
+ parser = argparse.ArgumentParser(description="MiniMind Agent RL")
373
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
374
+ parser.add_argument('--save_weight', default='agent', type=str, help="保存权重名称")
375
+ parser.add_argument("--epochs", type=int, default=1, help="训练轮数")
376
+ parser.add_argument("--batch_size", type=int, default=2, help="批次大小")
377
+ parser.add_argument("--learning_rate", type=float, default=3e-7, help="学习率")
378
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
379
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="数据类型 bfloat16/float16")
380
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
381
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
382
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
383
+ parser.add_argument("--log_interval", type=int, default=1, help="日志打印间隔")
384
+ parser.add_argument("--save_interval", type=int, default=10, help="模型保存间隔")
385
+ parser.add_argument('--hidden_size', default=768, type=int, help="模型隐藏层维度")
386
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="模型层数")
387
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE")
388
+ parser.add_argument('--max_seq_len', default=1024, type=int, help="最大序列长度")
389
+ parser.add_argument("--max_gen_len", type=int, default=768, help="单次最大生成长度")
390
+ parser.add_argument("--max_total_len", type=int, default=2500, help="训练侧最终总长度上界")
391
+ parser.add_argument("--data_path", type=str, default="../dataset/agent_rl.jsonl", help="训练数据路径")
392
+ parser.add_argument("--num_generations", type=int, default=4, help="每个prompt生成数量")
393
+ parser.add_argument("--beta", type=float, default=0.1, help="KL散度惩罚系数")
394
+ parser.add_argument("--loss_type", type=str, default="cispo", choices=["grpo", "cispo"], help="loss类型")
395
+ parser.add_argument("--epsilon", type=float, default=0.2, help="GRPO的PPO clip epsilon")
396
+ parser.add_argument("--epsilon_high", type=float, default=5.0, help="epsilon上界")
397
+ parser.add_argument('--from_weight', default='full_sft', type=str, help="加载预训练权重名称")
398
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否从checkpoint恢复")
399
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb记录")
400
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-Agent-RL", help="wandb项目名称")
401
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile")
402
+ parser.add_argument("--debug_mode", action="store_true", help="调试模式")
403
+ parser.add_argument("--debug_interval", type=int, default=20, help="调试日志间隔")
404
+ parser.add_argument("--thinking_ratio", type=float, default=0.1, help="按概率开启thinking(0.0~1.0)")
405
+ parser.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward", help="Reward模型路径")
406
+ parser.add_argument("--rollout_engine", type=str, default="torch", choices=["torch", "sglang"], help="rollout引擎类型")
407
+ parser.add_argument("--sglang_base_url", type=str, default="http://localhost:8998", help="SGLang服务器URL")
408
+ parser.add_argument("--sglang_model_path", type=str, default="../model", help="SGLang tokenizer路径")
409
+ parser.add_argument("--sglang_shared_path", type=str, default="./sglang_ckpt_agent", help="SGLang共享存储路径")
410
+ args = parser.parse_args()
411
+
412
+ local_rank = init_distributed_mode()
413
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
414
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
415
+
416
+ os.makedirs(args.save_dir, exist_ok=True)
417
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers,
418
+ max_seq_len=args.max_seq_len + args.max_gen_len, use_moe=bool(args.use_moe))
419
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume == 1 else None
420
+
421
+ device_type = "cuda" if "cuda" in args.device else "cpu"
422
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
423
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
424
+
425
+ wandb = None
426
+ if args.use_wandb and is_main_process():
427
+ import swanlab as wandb
428
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
429
+ resume = 'must' if wandb_id else None
430
+ wandb.init(project=args.wandb_project, name=f"Agent-RL-E{args.epochs}-B{args.batch_size}-LR{args.learning_rate}", id=wandb_id, resume=resume)
431
+
432
+ model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
433
+
434
+ ref_model, _ = init_model(lm_config, args.from_weight, device=args.device)
435
+ ref_model = ref_model.eval().requires_grad_(False)
436
+
437
+ reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
438
+ Logger(f'Loaded reward model from {args.reward_model_path}')
439
+ # Rollout引擎
440
+ rollout_engine = create_rollout_engine(
441
+ engine_type=args.rollout_engine,
442
+ policy_model=model,
443
+ tokenizer=tokenizer,
444
+ device=args.device,
445
+ autocast_ctx=autocast_ctx,
446
+ sglang_base_url=args.sglang_base_url,
447
+ sglang_model_path=args.sglang_model_path,
448
+ sglang_shared_path=args.sglang_shared_path,
449
+ )
450
+ train_ds = AgentRLDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len)
451
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
452
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
453
+ def collate_fn(batch): return {'messages': [b['messages'] for b in batch], 'tools': [b['tools'] for b in batch], 'gt': [b['gt'] for b in batch]}
454
+ loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler, collate_fn=collate_fn)
455
+ iters = len(loader_for_count)
456
+ total_optimizer_steps = math.ceil(iters / args.accumulation_steps) * args.epochs
457
+ scheduler = CosineAnnealingLR(optimizer, T_max=total_optimizer_steps, eta_min=args.learning_rate / 10)
458
+
459
+ start_epoch, start_step = 0, 0
460
+ if ckp_data:
461
+ model.load_state_dict(ckp_data['model'])
462
+ optimizer.load_state_dict(ckp_data['optimizer'])
463
+ scheduler.load_state_dict(ckp_data['scheduler'])
464
+ start_epoch = ckp_data['epoch']
465
+ start_step = ckp_data.get('step', 0)
466
+
467
+ if args.use_compile == 1:
468
+ model = torch.compile(model)
469
+ Logger('torch.compile enabled')
470
+ rollout_engine.update_policy(model)
471
+ if dist.is_initialized():
472
+ model = DistributedDataParallel(model, device_ids=[local_rank])
473
+ rollout_engine.update_policy(model)
474
+
475
+ for epoch in range(start_epoch, args.epochs):
476
+ train_sampler and train_sampler.set_epoch(epoch)
477
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
478
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
479
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
480
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True, collate_fn=collate_fn)
481
+ if skip > 0:
482
+ Logger(f'Epoch [{epoch+1}/{args.epochs}]: skip {start_step} steps')
483
+ rl_train_epoch(epoch, loader, len(loader) + skip, rollout_engine, ref_model, reward_model, start_step, wandb, use_sglang = (args.rollout_engine == "sglang"))
484
+ else:
485
+ rl_train_epoch(epoch, loader, len(loader), rollout_engine, ref_model, reward_model, 0, wandb, use_sglang = (args.rollout_engine == "sglang"))
486
+
487
+ if dist.is_initialized():
488
+ dist.barrier()
489
+ dist.destroy_process_group()
src/omni/trainers/distillation.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import time
7
+ import warnings
8
+ import torch
9
+ import torch.nn.functional as F
10
+ import torch.distributed as dist
11
+ from contextlib import nullcontext
12
+ from torch import optim
13
+ from torch.nn.parallel import DistributedDataParallel
14
+ from torch.utils.data import DataLoader, DistributedSampler
15
+ from omni.models.minimind import MiniMindConfig
16
+ from omni.datasets.lm_dataset import SFTDataset
17
+ from omni.utils.training import get_lr, Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, init_model, SkipBatchSampler
18
+
19
+ warnings.filterwarnings('ignore')
20
+
21
+
22
+ def distillation_loss(student_logits, teacher_logits, temperature=1.0, reduction='batchmean'):
23
+ with torch.no_grad():
24
+ teacher_probs = F.softmax(teacher_logits / temperature, dim=-1).detach()
25
+
26
+ student_log_probs = F.log_softmax(student_logits / temperature, dim=-1)
27
+
28
+ kl = F.kl_div(
29
+ student_log_probs,
30
+ teacher_probs,
31
+ reduction=reduction
32
+ )
33
+ return (temperature ** 2) * kl
34
+
35
+
36
+ def train_epoch(epoch, loader, iters, teacher_model, lm_config_student, start_step=0, wandb=None, alpha=0.0, temperature=1.0):
37
+ start_time = time.time()
38
+ last_step = start_step
39
+
40
+ if teacher_model is not None:
41
+ teacher_model.eval()
42
+ teacher_model.requires_grad_(False)
43
+
44
+ for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
45
+ last_step = step
46
+ input_ids = input_ids.to(args.device)
47
+ labels = labels.to(args.device)
48
+ loss_mask = (labels[..., 1:] != -100).float()
49
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
50
+ for param_group in optimizer.param_groups:
51
+ param_group['lr'] = lr
52
+
53
+ # 前向传播(学生模型)
54
+ with autocast_ctx:
55
+ res = model(input_ids)
56
+ student_logits = res.logits[..., :-1, :].contiguous()
57
+
58
+ # 教师模型前向传播(只在eval & no_grad)
59
+ if teacher_model is not None:
60
+ with torch.no_grad():
61
+ teacher_logits = teacher_model(input_ids).logits[..., :-1, :].contiguous()
62
+ vocab_size_student = student_logits.size(-1)
63
+ teacher_logits = teacher_logits[..., :vocab_size_student]
64
+
65
+ # ========== 计算损失 ==========
66
+ # 1) Ground-Truth CE Loss
67
+ shift_labels = labels[..., 1:].contiguous()
68
+ loss_mask_flat = loss_mask.view(-1)
69
+ ce_loss = F.cross_entropy(
70
+ student_logits.view(-1, student_logits.size(-1)),
71
+ shift_labels.view(-1),
72
+ ignore_index=-100,
73
+ reduction='none'
74
+ )
75
+ ce_loss_raw = torch.sum(ce_loss * loss_mask_flat) / (loss_mask_flat.sum() + 1e-8)
76
+ if lm_config_student.use_moe: ce_loss = ce_loss_raw + res.aux_loss
77
+ else: ce_loss = ce_loss_raw
78
+
79
+ # 2) Distillation Loss
80
+ if teacher_model is not None:
81
+ distill_loss = distillation_loss(
82
+ student_logits.view(-1, student_logits.size(-1))[loss_mask_flat == 1],
83
+ teacher_logits.view(-1, teacher_logits.size(-1))[loss_mask_flat == 1],
84
+ temperature=temperature
85
+ )
86
+ else:
87
+ distill_loss = torch.tensor(0.0, device=args.device)
88
+
89
+ # 3) 总损失 = alpha * CE + (1-alpha) * Distill
90
+ loss = (alpha * ce_loss + (1 - alpha) * distill_loss) / args.accumulation_steps
91
+
92
+ scaler.scale(loss).backward()
93
+
94
+ if step % args.accumulation_steps == 0:
95
+ scaler.unscale_(optimizer)
96
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
97
+ scaler.step(optimizer)
98
+ scaler.update()
99
+ optimizer.zero_grad(set_to_none=True)
100
+
101
+ if step % args.log_interval == 0 or step == iters:
102
+ spend_time = time.time() - start_time
103
+ current_loss = loss.item() * args.accumulation_steps
104
+ current_ce_loss = ce_loss_raw.item()
105
+ current_aux_loss = res.aux_loss.item() if lm_config_student.use_moe else 0.0
106
+ current_lr = optimizer.param_groups[-1]['lr']
107
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
108
+
109
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, ce: {current_ce_loss:.4f}, aux_loss: {current_aux_loss:.4f}, distill: {distill_loss.item():.4f}, learning_rate: {current_lr:.8f}, epoch_time: {eta_min:.3f}min')
110
+
111
+ if wandb:
112
+ wandb.log({
113
+ "loss": current_loss,
114
+ "ce_loss": current_ce_loss,
115
+ "aux_loss": current_aux_loss,
116
+ "distill_loss": distill_loss.item() if teacher_model is not None else 0.0,
117
+ "learning_rate": current_lr,
118
+ "epoch_time": eta_min
119
+ })
120
+
121
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
122
+ model.eval()
123
+ moe_suffix = '_moe' if lm_config_student.use_moe else ''
124
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config_student.hidden_size}{moe_suffix}.pth'
125
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
126
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
127
+ state_dict = raw_model.state_dict()
128
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
129
+ lm_checkpoint(lm_config_student, weight=args.save_weight, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
130
+ model.train()
131
+ del state_dict
132
+
133
+ del input_ids, labels, loss_mask, res, student_logits, ce_loss, distill_loss, loss
134
+
135
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
136
+ scaler.unscale_(optimizer)
137
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
138
+ scaler.step(optimizer)
139
+ scaler.update()
140
+ optimizer.zero_grad(set_to_none=True)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ # 模拟用moe模型蒸馏dense模型,也可以用更大teacher_hidden_size模型蒸馏更小student_hidden_size的
145
+ parser = argparse.ArgumentParser(description="MiniMind Knowledge Distillation")
146
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
147
+ parser.add_argument('--save_weight', default='full_dist', type=str, help="保存权重的前缀名")
148
+ parser.add_argument("--epochs", type=int, default=6, help="训练轮数")
149
+ parser.add_argument("--batch_size", type=int, default=32, help="batch size")
150
+ parser.add_argument("--learning_rate", type=float, default=5e-6, help="初始学习率")
151
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
152
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
153
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
154
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
155
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
156
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
157
+ parser.add_argument("--save_interval", type=int, default=100, help="模型保存间隔")
158
+ parser.add_argument("--max_seq_len", type=int, default=340, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
159
+ parser.add_argument("--data_path", type=str, default="../dataset/sft_t2t_mini.jsonl", help="训练数据路径")
160
+ parser.add_argument('--student_hidden_size', default=768, type=int, help="学生模型隐藏层维度")
161
+ parser.add_argument('--student_num_layers', default=8, type=int, help="学生模型隐藏层数量")
162
+ parser.add_argument('--teacher_hidden_size', default=768, type=int, help="教师模型隐藏层维度")
163
+ parser.add_argument('--teacher_num_layers', default=8, type=int, help="教师模型隐藏层数量")
164
+ parser.add_argument('--student_use_moe', default=0, type=int, choices=[0, 1], help="学生模型是否使用MoE(0=否,1=是)")
165
+ parser.add_argument('--teacher_use_moe', default=1, type=int, choices=[0, 1], help="教师模型是否使用MoE(0=否,1=是)")
166
+ parser.add_argument('--from_student_weight', default='full_sft', type=str, help="学生模型基于哪个权重")
167
+ parser.add_argument('--from_teacher_weight', default='full_sft', type=str, help="教师模型基于哪个权重")
168
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
169
+ parser.add_argument('--alpha', default=0.5, type=float, help="CE损失权重,总损失=alpha*CE+(1-alpha)*KL")
170
+ parser.add_argument('--temperature', default=1.5, type=float, help="蒸馏温度(推荐范围1.0-2.0)")
171
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
172
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-Distillation", help="wandb项目名")
173
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
174
+ args = parser.parse_args()
175
+
176
+ # ========== 1. 初始化环境和随机种子 ==========
177
+ local_rank = init_distributed_mode()
178
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
179
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
180
+
181
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
182
+ os.makedirs(args.save_dir, exist_ok=True)
183
+ lm_config_student = MiniMindConfig(hidden_size=args.student_hidden_size, num_hidden_layers=args.student_num_layers, use_moe=bool(args.student_use_moe))
184
+ lm_config_teacher = MiniMindConfig(hidden_size=args.teacher_hidden_size, num_hidden_layers=args.teacher_num_layers, use_moe=bool(args.teacher_use_moe))
185
+ ckp_data = lm_checkpoint(lm_config_student, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
186
+
187
+ # ========== 3. 设置混合精度 ==========
188
+ device_type = "cuda" if "cuda" in args.device else "cpu"
189
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
190
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
191
+
192
+ # ========== 4. 配wandb ==========
193
+ wandb = None
194
+ if args.use_wandb and is_main_process():
195
+ import swanlab as wandb
196
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
197
+ resume = 'must' if wandb_id else None
198
+ wandb_run_name = f"MiniMind-Distill-S{args.student_hidden_size}T{args.teacher_hidden_size}-Epoch-{args.epochs}-BS-{args.batch_size}-LR-{args.learning_rate}"
199
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
200
+
201
+ # ========== 5. 定义学生和教师模型 ==========
202
+ model, tokenizer = init_model(lm_config_student, args.from_student_weight, device=args.device)
203
+ Logger(f'学生模型总参数量:{sum(p.numel() for p in model.parameters()) / 1e6:.3f} M')
204
+ teacher_model, _ = init_model(lm_config_teacher, args.from_teacher_weight, device=args.device)
205
+ teacher_model.eval()
206
+ teacher_model.requires_grad_(False)
207
+ Logger(f'教师模型总参数量:{sum(p.numel() for p in teacher_model.parameters()) / 1e6:.3f} M')
208
+ train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
209
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
210
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
211
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
212
+
213
+ # ========== 6. 从ckp恢复状态 ==========
214
+ start_epoch, start_step = 0, 0
215
+ if ckp_data:
216
+ model.load_state_dict(ckp_data['model'])
217
+ optimizer.load_state_dict(ckp_data['optimizer'])
218
+ scaler.load_state_dict(ckp_data['scaler'])
219
+ start_epoch = ckp_data['epoch']
220
+ start_step = ckp_data.get('step', 0)
221
+
222
+ # ========== 7. 编译和分布式包装 ==========
223
+ if args.use_compile == 1:
224
+ model = torch.compile(model)
225
+ Logger('torch.compile enabled')
226
+ if dist.is_initialized():
227
+ model = DistributedDataParallel(model, device_ids=[local_rank])
228
+
229
+ # ========== 8. 开始训练 ==========
230
+ for epoch in range(start_epoch, args.epochs):
231
+ train_sampler and train_sampler.set_epoch(epoch)
232
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
233
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
234
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
235
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
236
+ if skip > 0:
237
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
238
+ train_epoch(epoch, loader, len(loader) + skip, teacher_model, lm_config_student, start_step, wandb, args.alpha, args.temperature)
239
+ else:
240
+ train_epoch(epoch, loader, len(loader), teacher_model, lm_config_student, 0, wandb, args.alpha, args.temperature)
241
+
242
+ # ========== 9. 清理分布进程 ==========
243
+ if dist.is_initialized():
244
+ dist.barrier()
245
+ dist.destroy_process_group()
src/omni/trainers/dpo.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import time
7
+ import warnings
8
+ import torch
9
+ import torch.nn.functional as F
10
+ import torch.distributed as dist
11
+ from contextlib import nullcontext
12
+ from torch import optim
13
+ from torch.nn.parallel import DistributedDataParallel
14
+ from torch.utils.data import DataLoader, DistributedSampler
15
+ from omni.models.minimind import MiniMindConfig
16
+ from omni.datasets.lm_dataset import DPODataset
17
+ from omni.utils.training import get_lr, Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, init_model, SkipBatchSampler
18
+
19
+ warnings.filterwarnings('ignore')
20
+
21
+
22
+ def logits_to_log_probs(logits, labels):
23
+ # logits shape: (batch_size, seq_len, vocab_size)
24
+ # labels shape: (batch_size, seq_len)
25
+ # log_probs shape: (batch_size, seq_len)
26
+ log_probs = F.log_softmax(logits, dim=2)
27
+ log_probs_per_token = torch.gather(log_probs, dim=2, index=labels.unsqueeze(2)).squeeze(-1)
28
+ return log_probs_per_token
29
+
30
+
31
+ def dpo_loss(ref_log_probs, policy_log_probs, mask, beta):
32
+ # ref_log_probs 和 policy_log_probs 都是 shape: (batch_size, seq_len)
33
+ ref_log_probs = (ref_log_probs * mask).sum(dim=1)
34
+ policy_log_probs = (policy_log_probs * mask).sum(dim=1)
35
+
36
+ # 将 chosen 和 rejected 数据分开
37
+ batch_size = ref_log_probs.shape[0]
38
+ chosen_ref_log_probs = ref_log_probs[:batch_size // 2]
39
+ reject_ref_log_probs = ref_log_probs[batch_size // 2:]
40
+ chosen_policy_log_probs = policy_log_probs[:batch_size // 2]
41
+ reject_policy_log_probs = policy_log_probs[batch_size // 2:]
42
+
43
+ pi_logratios = chosen_policy_log_probs - reject_policy_log_probs
44
+ ref_logratios = chosen_ref_log_probs - reject_ref_log_probs
45
+ logits = pi_logratios - ref_logratios
46
+ loss = -F.logsigmoid(beta * logits)
47
+ return loss.mean()
48
+
49
+
50
+ def train_epoch(epoch, loader, iters, ref_model, lm_config, start_step=0, wandb=None, beta=0.1):
51
+ start_time = time.time()
52
+ last_step = start_step
53
+
54
+ for step, batch in enumerate(loader, start=start_step + 1):
55
+ last_step = step
56
+ x_chosen = batch['x_chosen'].to(args.device)
57
+ x_rejected = batch['x_rejected'].to(args.device)
58
+ y_chosen = batch['y_chosen'].to(args.device)
59
+ y_rejected = batch['y_rejected'].to(args.device)
60
+ mask_chosen = batch['mask_chosen'].to(args.device)
61
+ mask_rejected = batch['mask_rejected'].to(args.device)
62
+ x = torch.cat([x_chosen, x_rejected], dim=0)
63
+ y = torch.cat([y_chosen, y_rejected], dim=0)
64
+ mask = torch.cat([mask_chosen, mask_rejected], dim=0)
65
+
66
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
67
+ for param_group in optimizer.param_groups:
68
+ param_group['lr'] = lr
69
+
70
+ with autocast_ctx:
71
+ with torch.no_grad():
72
+ ref_outputs = ref_model(x)
73
+ ref_logits = ref_outputs.logits
74
+ ref_log_probs = logits_to_log_probs(ref_logits, y)
75
+
76
+ outputs = model(x)
77
+ logits = outputs.logits
78
+ policy_log_probs = logits_to_log_probs(logits, y)
79
+
80
+ dpo_loss_val = dpo_loss(ref_log_probs, policy_log_probs, mask, beta=beta)
81
+ loss = dpo_loss_val + outputs.aux_loss
82
+ loss = loss / args.accumulation_steps
83
+
84
+ scaler.scale(loss).backward()
85
+
86
+ if step % args.accumulation_steps == 0:
87
+ scaler.unscale_(optimizer)
88
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
89
+ scaler.step(optimizer)
90
+ scaler.update()
91
+ optimizer.zero_grad(set_to_none=True)
92
+
93
+ if step % args.log_interval == 0 or step == iters:
94
+ spend_time = time.time() - start_time
95
+ current_loss = loss.item() * args.accumulation_steps
96
+ current_dpo_loss = dpo_loss_val.item()
97
+ current_aux_loss = outputs.aux_loss.item()
98
+ current_lr = optimizer.param_groups[-1]['lr']
99
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
100
+
101
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, dpo_loss: {current_dpo_loss:.4f}, aux_loss: {current_aux_loss:.4f}, learning_rate: {current_lr:.8f}, epoch_time: {eta_min:.3f}min')
102
+
103
+ if wandb: wandb.log({"loss": current_loss, "dpo_loss": current_dpo_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
104
+
105
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
106
+ model.eval()
107
+ moe_suffix = '_moe' if lm_config.use_moe else ''
108
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
109
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
110
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
111
+ state_dict = raw_model.state_dict()
112
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
113
+ lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
114
+ model.train()
115
+ del state_dict
116
+
117
+ del x_chosen, x_rejected, y_chosen, y_rejected, mask_chosen, mask_rejected, x, y, mask
118
+ del ref_outputs, ref_logits, ref_log_probs, outputs, logits, policy_log_probs, loss
119
+
120
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
121
+ scaler.unscale_(optimizer)
122
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
123
+ scaler.step(optimizer)
124
+ scaler.update()
125
+ optimizer.zero_grad(set_to_none=True)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ parser = argparse.ArgumentParser(description="MiniMind DPO (Direct Preference Optimization)")
130
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
131
+ parser.add_argument('--save_weight', default='dpo', type=str, help="保存权重的前缀名")
132
+ parser.add_argument("--epochs", type=int, default=1, help="训练轮数")
133
+ parser.add_argument("--batch_size", type=int, default=4, help="batch size")
134
+ parser.add_argument("--learning_rate", type=float, default=4e-8, help="初始学习率(建议<=5e-8避免遗忘)")
135
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
136
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
137
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
138
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
139
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
140
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
141
+ parser.add_argument("--save_interval", type=int, default=100, help="模型保存间隔")
142
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
143
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
144
+ parser.add_argument('--max_seq_len', default=1024, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
145
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
146
+ parser.add_argument("--data_path", type=str, default="../dataset/dpo.jsonl", help="DPO训练数据路径")
147
+ parser.add_argument('--from_weight', default='full_sft', type=str, help="基于哪个权重训练")
148
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
149
+ parser.add_argument('--beta', default=0.15, type=float, help="DPO中的beta参数")
150
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
151
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-DPO", help="wandb项目名")
152
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
153
+ args = parser.parse_args()
154
+
155
+ # ========== 1. 初始化环境和随机种子 ==========
156
+ local_rank = init_distributed_mode()
157
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
158
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
159
+
160
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
161
+ os.makedirs(args.save_dir, exist_ok=True)
162
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
163
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
164
+
165
+ # ========== 3. 设置混合精度 ==========
166
+ device_type = "cuda" if "cuda" in args.device else "cpu"
167
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
168
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
169
+
170
+ # ========== 4. 配wandb ==========
171
+ wandb = None
172
+ if args.use_wandb and is_main_process():
173
+ import swanlab as wandb
174
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
175
+ resume = 'must' if wandb_id else None
176
+ wandb_run_name = f"MiniMind-DPO-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LR-{args.learning_rate}"
177
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
178
+
179
+ # ========== 5. 定义模型和参考模型 ==========
180
+ model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
181
+ Logger(f'策略模型总参数量:{sum(p.numel() for p in model.parameters()) / 1e6:.3f} M')
182
+ # 初始化参考模型(ref_model冻结)
183
+ ref_model, _ = init_model(lm_config, args.from_weight, device=args.device)
184
+ ref_model.eval()
185
+ ref_model.requires_grad_(False)
186
+ Logger(f'参考模型总参数量:{sum(p.numel() for p in ref_model.parameters()) / 1e6:.3f} M')
187
+
188
+ train_ds = DPODataset(args.data_path, tokenizer, max_length=args.max_seq_len)
189
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
190
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
191
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
192
+
193
+ # ========== 6. 从ckp恢复状态 ==========
194
+ start_epoch, start_step = 0, 0
195
+ if ckp_data:
196
+ model.load_state_dict(ckp_data['model'])
197
+ optimizer.load_state_dict(ckp_data['optimizer'])
198
+ scaler.load_state_dict(ckp_data['scaler'])
199
+ start_epoch = ckp_data['epoch']
200
+ start_step = ckp_data.get('step', 0)
201
+
202
+ # ========== 7. 编译和分布式包装 ==========
203
+ if args.use_compile == 1:
204
+ model = torch.compile(model)
205
+ Logger('torch.compile enabled')
206
+ if dist.is_initialized():
207
+ model = DistributedDataParallel(model, device_ids=[local_rank])
208
+
209
+ # ========== 8. 开始训练 ==========
210
+ for epoch in range(start_epoch, args.epochs):
211
+ train_sampler and train_sampler.set_epoch(epoch)
212
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
213
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
214
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
215
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
216
+ if skip > 0:
217
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
218
+ train_epoch(epoch, loader, len(loader) + skip, ref_model, lm_config, start_step, wandb, args.beta)
219
+ else:
220
+ train_epoch(epoch, loader, len(loader), ref_model, lm_config, 0, wandb, args.beta)
221
+
222
+ # ========== 9. 清理分布进程 ==========
223
+ if dist.is_initialized():
224
+ dist.barrier()
225
+ dist.destroy_process_group()
src/omni/trainers/full_sft.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import time
7
+ import warnings
8
+ import torch
9
+ import torch.distributed as dist
10
+ from contextlib import nullcontext
11
+ from torch import optim, nn
12
+ from torch.nn.parallel import DistributedDataParallel
13
+ from torch.utils.data import DataLoader, DistributedSampler
14
+ from omni.models.minimind import MiniMindConfig
15
+ from omni.datasets.lm_dataset import SFTDataset
16
+ from omni.utils.training import get_lr, Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, init_model, SkipBatchSampler
17
+
18
+ warnings.filterwarnings('ignore')
19
+
20
+
21
+ def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
22
+ start_time = time.time()
23
+ last_step = start_step
24
+ for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
25
+ input_ids = input_ids.to(args.device)
26
+ labels = labels.to(args.device)
27
+ last_step = step
28
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
29
+ for param_group in optimizer.param_groups:
30
+ param_group['lr'] = lr
31
+
32
+ with autocast_ctx:
33
+ res = model(input_ids, labels=labels)
34
+ loss = res.loss + res.aux_loss
35
+ loss = loss / args.accumulation_steps
36
+
37
+ scaler.scale(loss).backward()
38
+
39
+ if step % args.accumulation_steps == 0:
40
+ scaler.unscale_(optimizer)
41
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
42
+
43
+ scaler.step(optimizer)
44
+ scaler.update()
45
+
46
+ optimizer.zero_grad(set_to_none=True)
47
+
48
+ if step % args.log_interval == 0 or step == iters:
49
+ spend_time = time.time() - start_time
50
+ current_loss = loss.item() * args.accumulation_steps
51
+ current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0
52
+ current_logits_loss = current_loss - current_aux_loss
53
+ current_lr = optimizer.param_groups[-1]['lr']
54
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
55
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, logits_loss: {current_logits_loss:.4f}, aux_loss: {current_aux_loss:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
56
+ if wandb: wandb.log({"loss": current_loss, "logits_loss": current_logits_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
57
+
58
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
59
+ model.eval()
60
+ moe_suffix = '_moe' if lm_config.use_moe else ''
61
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
62
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
63
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
64
+ state_dict = raw_model.state_dict()
65
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
66
+ lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer,
67
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler)
68
+ model.train()
69
+ del state_dict
70
+
71
+ del input_ids, labels, res, loss
72
+
73
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
74
+ scaler.unscale_(optimizer)
75
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
76
+ scaler.step(optimizer)
77
+ scaler.update()
78
+ optimizer.zero_grad(set_to_none=True)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ parser = argparse.ArgumentParser(description="MiniMind Full SFT")
83
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
84
+ parser.add_argument('--save_weight', default='full_sft', type=str, help="保存权重的前缀名")
85
+ parser.add_argument("--epochs", type=int, default=2, help="训练轮数")
86
+ parser.add_argument("--batch_size", type=int, default=16, help="batch size")
87
+ parser.add_argument("--learning_rate", type=float, default=1e-5, help="初始学习率")
88
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
89
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
90
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
91
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
92
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
93
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
94
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
95
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
96
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
97
+ parser.add_argument('--max_seq_len', default=768, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
98
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
99
+ parser.add_argument("--data_path", type=str, default="../dataset/sft_t2t_mini.jsonl", help="训练数据路径")
100
+ parser.add_argument('--from_weight', default='pretrain', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
101
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
102
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
103
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-Full-SFT", help="wandb项目名")
104
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
105
+ args = parser.parse_args()
106
+
107
+ # ========== 1. 初始化环境和随机种子 ==========
108
+ local_rank = init_distributed_mode()
109
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
110
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
111
+
112
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
113
+ os.makedirs(args.save_dir, exist_ok=True)
114
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
115
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
116
+
117
+ # ========== 3. 设置混合精度 ==========
118
+ device_type = "cuda" if "cuda" in args.device else "cpu"
119
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
120
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
121
+
122
+ # ========== 4. 配wandb ==========
123
+ wandb = None
124
+ if args.use_wandb and is_main_process():
125
+ import swanlab as wandb
126
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
127
+ resume = 'must' if wandb_id else None
128
+ wandb_run_name = f"MiniMind-Full-SFT-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LearningRate-{args.learning_rate}"
129
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
130
+
131
+ # ========== 5. 定义模型、数据、优化器 ==========
132
+ model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
133
+ train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
134
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
135
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
136
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
137
+
138
+ # ========== 6. 从ckp恢复状态 ==========
139
+ start_epoch, start_step = 0, 0
140
+ if ckp_data:
141
+ model.load_state_dict(ckp_data['model'])
142
+ optimizer.load_state_dict(ckp_data['optimizer'])
143
+ scaler.load_state_dict(ckp_data['scaler'])
144
+ start_epoch = ckp_data['epoch']
145
+ start_step = ckp_data.get('step', 0)
146
+
147
+ # ========== 7. 编译和分布式包装 ==========
148
+ if args.use_compile == 1:
149
+ model = torch.compile(model)
150
+ Logger('torch.compile enabled')
151
+ if dist.is_initialized():
152
+ model = DistributedDataParallel(model, device_ids=[local_rank])
153
+
154
+ # ========== 8. 开始训练 ==========
155
+ for epoch in range(start_epoch, args.epochs):
156
+ train_sampler and train_sampler.set_epoch(epoch)
157
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
158
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
159
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
160
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
161
+ if skip > 0:
162
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
163
+ train_epoch(epoch, loader, len(loader) + skip, start_step, wandb)
164
+ else:
165
+ train_epoch(epoch, loader, len(loader), 0, wandb)
166
+
167
+ # ========== 9. 清理分布进程 ==========
168
+ if dist.is_initialized():
169
+ dist.barrier()
170
+ dist.destroy_process_group()
src/omni/trainers/full_sft_omni.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import argparse
3
+ import time
4
+ import warnings
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.distributed as dist
8
+ from contextlib import nullcontext
9
+ from torch import optim
10
+ from torch.nn.parallel import DistributedDataParallel
11
+ from torch.utils.data import DataLoader, DistributedSampler
12
+ from omni.models import OmniConfig, MiniMindOmni
13
+ from omni.datasets.lm_dataset import OmniDataset
14
+ from omni.utils import get_lr, Logger, is_main_process, init_distributed_mode, setup_seed, init_omni_model, omni_checkpoint, SkipBatchSampler, log_model_params
15
+
16
+ warnings.filterwarnings('ignore')
17
+
18
+
19
+ def omni_collate_fn(batch):
20
+ """自定义collate函数,处理变长audio_inputs和pixel_values"""
21
+ input_ids, labels, audio_labels, audio_inputs, audio_lens, pixel_values, spk_emb = zip(*batch)
22
+ input_ids = torch.stack(input_ids)
23
+ labels = torch.stack(labels)
24
+ audio_labels = torch.stack(audio_labels)
25
+ audio_lens = torch.tensor(audio_lens, dtype=torch.long)
26
+ valid_audios = [a for a in audio_inputs if a is not None]
27
+ if valid_audios:
28
+ max_t = max(a.size(1) for a in valid_audios)
29
+ padded = [a if a.size(1) == max_t else torch.nn.functional.pad(a, (0, 0, 0, max_t - a.size(1))) for a in valid_audios]
30
+ audio_inputs = torch.cat(padded, dim=0)
31
+ else:
32
+ audio_inputs = None
33
+ valid_images = [p for p in pixel_values if p is not None]
34
+ if valid_images:
35
+ if hasattr(valid_images[0], 'keys'):
36
+ keys = set.intersection(*[set(d.keys()) for d in valid_images])
37
+ pixel_values = {k: torch.cat([d[k] for d in valid_images], dim=0) for k in keys}
38
+ else:
39
+ pixel_values = torch.cat(valid_images, dim=0)
40
+ else:
41
+ pixel_values = None
42
+ spk_emb = torch.stack(spk_emb)
43
+ return input_ids, labels, audio_labels, audio_inputs, audio_lens, pixel_values, spk_emb
44
+
45
+
46
+ def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
47
+ start_time = time.time()
48
+ last_step = start_step
49
+ for step, (input_ids, labels, audio_labels, audio_inputs, audio_lens, pixel_values, spk_emb) in enumerate(loader, start=start_step + 1):
50
+ input_ids = input_ids.to(args.device)
51
+ labels = labels.to(args.device)
52
+ audio_labels = audio_labels.to(args.device)
53
+ audio_lens = audio_lens.to(args.device)
54
+ if audio_inputs is not None:
55
+ audio_inputs = audio_inputs.to(args.device)
56
+ if pixel_values is not None:
57
+ if hasattr(pixel_values, 'keys'):
58
+ pixel_values = {k: v.to(args.device) for k, v in pixel_values.items()}
59
+ else:
60
+ pixel_values = pixel_values.to(args.device)
61
+ spk_emb = spk_emb.to(args.device)
62
+ last_step = step
63
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
64
+ for param_group in optimizer.param_groups:
65
+ param_group['lr'] = lr
66
+
67
+ with autocast_ctx:
68
+ res = model(input_ids, audio_inputs=audio_inputs, audio_lens=audio_lens, pixel_values=pixel_values, spk_emb=spk_emb)
69
+ loss_fct = nn.CrossEntropyLoss(reduction='none')
70
+
71
+ # Text loss
72
+ text_loss_raw = loss_fct(res.logits.view(-1, res.logits.size(-1)), labels.view(-1))
73
+ text_mask = (labels.view(-1) != -100).float()
74
+ text_loss = (text_loss_raw * text_mask).sum() / (text_mask.sum() + 1e-9)
75
+
76
+ # Audio loss
77
+ audio_loss = res.audio_logits[0].sum() * 0
78
+ for i, al in enumerate(res.audio_logits):
79
+ al_flat = al.view(-1, al.size(-1))
80
+ target_flat = audio_labels[:, i, :].reshape(-1)
81
+ layer_loss = loss_fct(al_flat, target_flat)
82
+ valid_mask = (target_flat != -100).float()
83
+ stop_mask = (target_flat == 2050).float()
84
+ weighted_loss = layer_loss * valid_mask * (1 + stop_mask * 9)
85
+ msum = valid_mask.sum()
86
+ if msum > 0:
87
+ audio_loss = audio_loss + weighted_loss.sum() / (msum + 1e-9)
88
+ audio_loss = audio_loss / 8
89
+
90
+ loss = (text_loss + audio_loss + res.aux_loss) / args.accumulation_steps
91
+
92
+ scaler.scale(loss).backward()
93
+ if step % args.accumulation_steps == 0:
94
+ scaler.unscale_(optimizer)
95
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
96
+ scaler.step(optimizer)
97
+ scaler.update()
98
+ optimizer.zero_grad(set_to_none=True)
99
+
100
+ if step % args.log_interval == 0 or step == iters:
101
+ spend_time = time.time() - start_time
102
+ current_loss = loss.item() * args.accumulation_steps
103
+ text_loss_val = text_loss.item() if isinstance(text_loss, torch.Tensor) else 0
104
+ audio_loss_val = audio_loss.item() if isinstance(audio_loss, torch.Tensor) else 0
105
+ current_lr = optimizer.param_groups[-1]['lr']
106
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
107
+ Logger(f'Epoch:[{epoch+1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, text: {text_loss_val:.4f}, audio: {audio_loss_val:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
108
+ if wandb:
109
+ wandb.log({"loss": current_loss, "text_loss": text_loss_val,
110
+ "audio_loss": audio_loss_val, "lr": current_lr, "epoch_time": eta_min})
111
+
112
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
113
+ model.eval()
114
+ moe_suffix = '_moe' if omni_config.use_moe else ''
115
+ ckp = f'{args.save_dir}/{args.save_weight}_{omni_config.hidden_size}{moe_suffix}.pth'
116
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
117
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
118
+ clean_state_dict = {k: v for k, v in raw_model.state_dict().items() if not k.startswith('audio_encoder.')}
119
+ torch.save({k: v.half().cpu() for k, v in clean_state_dict.items()}, ckp)
120
+ omni_checkpoint(omni_config, weight=args.save_weight, model=model, optimizer=optimizer,
121
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler)
122
+ model.train()
123
+
124
+ del input_ids, labels, audio_labels, audio_inputs, audio_lens, pixel_values, spk_emb, res, loss
125
+
126
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
127
+ scaler.unscale_(optimizer)
128
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
129
+ scaler.step(optimizer)
130
+ scaler.update()
131
+ optimizer.zero_grad(set_to_none=True)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ parser = argparse.ArgumentParser(description="MiniMind-O SFT")
136
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
137
+ parser.add_argument('--save_weight', default='sft_omni', type=str, help="保存权重的前缀名")
138
+ parser.add_argument("--epochs", type=int, default=15, help="训练轮数")
139
+ parser.add_argument("--batch_size", type=int, default=32, help="batch size")
140
+ parser.add_argument("--learning_rate", type=float, default=5e-4, help="初始学习率")
141
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
142
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
143
+ parser.add_argument("--num_workers", type=int, default=4, help="数据加载线程数")
144
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
145
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
146
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
147
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
148
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
149
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
150
+ parser.add_argument('--max_seq_len', default=512, type=int, help="训练的最大截断长度")
151
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构")
152
+ parser.add_argument("--data_path", type=str, default="../dataset/train_t2a_mini.parquet", help="训练数据路径(parquet格式)")
153
+ parser.add_argument("--audio_encoder_dir", type=str, default="../model/SenseVoiceSmall", help="音频encoder路径(SenseVoice)")
154
+ parser.add_argument("--vision_dir", type=str, default="../model/siglip2-base-p32-256-ve", help="CLIP视觉模型路径")
155
+ parser.add_argument('--from_weight', default='llm', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
156
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
157
+ parser.add_argument('--freeze_backbone', default='none', type=str, choices=['none', 'all', 'last1'], help="冻结主干模型: none=全量训练, all=只训练audio层, last1=只训练最后1层+audio层")
158
+ parser.add_argument('--mode', default='all', type=str, choices=['all', 'audio_proj', 'vision_proj'], help="训练模式: all=全量训练, audio_proj=只训练audio_proj, vision_proj=只训练vision_proj")
159
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
160
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-O-SFT", help="wandb项目名")
161
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
162
+ args = parser.parse_args()
163
+
164
+ # ========== 1. 初始化环境和随机种子 ==========
165
+ local_rank = init_distributed_mode()
166
+ if dist.is_initialized():
167
+ args.device = f"cuda:{local_rank}"
168
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
169
+
170
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
171
+ os.makedirs(args.save_dir, exist_ok=True)
172
+ omni_config = OmniConfig(
173
+ hidden_size=args.hidden_size,
174
+ num_hidden_layers=args.num_hidden_layers,
175
+ use_moe=bool(args.use_moe)
176
+ )
177
+ ckp_data = omni_checkpoint(omni_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
178
+
179
+ # ========== 3. 设置混合精度 ==========
180
+ device_type = "cuda" if "cuda" in args.device else "cpu"
181
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
182
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
183
+
184
+ # ========== 4. 配wandb ==========
185
+ wandb = None
186
+ if args.use_wandb and is_main_process():
187
+ import swanlab as wandb
188
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
189
+ resume = 'must' if wandb_id else None
190
+ wandb_run_name = f"MiniMind-O-SFT-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LR-{args.learning_rate}"
191
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
192
+
193
+ # ========== 5. 定义模型、数据、优化器 ==========
194
+ model, tokenizer = init_omni_model(omni_config, from_weight=args.from_weight,
195
+ audio_encoder_path=args.audio_encoder_dir,
196
+ vision_model_path=args.vision_dir,
197
+ save_dir=args.save_dir, device=args.device,
198
+ freeze_backbone=args.freeze_backbone, from_resume=args.from_resume)
199
+
200
+ if args.use_compile == 1:
201
+ model = torch.compile(model)
202
+
203
+ if model.audio_encoder is not None: model.audio_encoder.to(args.device)
204
+ if model.vision_encoder is not None: model.vision_encoder.to(args.device)
205
+
206
+ if args.mode == 'audio_proj':
207
+ for p in model.parameters(): p.requires_grad = False
208
+ for p in model.audio_proj.parameters(): p.requires_grad = True
209
+ elif args.mode == 'vision_proj':
210
+ for p in model.parameters(): p.requires_grad = False
211
+ for p in model.vision_proj.parameters(): p.requires_grad = True
212
+ log_model_params(model)
213
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6
214
+ Logger(f'Trainable: {trainable:.2f}M | Mode: {args.mode} | Freeze: {args.freeze_backbone} | Compile: {"on" if args.use_compile else "off"}')
215
+
216
+ # scheduled_sampling 现在会自动保护 image/audio token 的连续性
217
+ train_ds = OmniDataset(
218
+ args.data_path,
219
+ tokenizer,
220
+ audio_processor=model.audio_processor,
221
+ vision_processor=model.vision_processor,
222
+ max_length=args.max_seq_len,
223
+ image_token_len=model.config.image_token_len
224
+ )
225
+
226
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
227
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
228
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
229
+
230
+ # ========== 6. 从ckp恢复状态 ==========
231
+ start_epoch, start_step = 0, 0
232
+ if ckp_data:
233
+ model.load_state_dict(ckp_data['model'], strict=False)
234
+ optimizer.load_state_dict(ckp_data['optimizer'])
235
+ scaler.load_state_dict(ckp_data['scaler'])
236
+ start_epoch = ckp_data['epoch']
237
+ start_step = ckp_data.get('step', 0)
238
+
239
+ # ========== 7. DDP包模型 ==========
240
+ if dist.is_initialized():
241
+ model = DistributedDataParallel(model, device_ids=[local_rank])
242
+
243
+ # ========== 8. 开始训练 ==========
244
+ for epoch in range(start_epoch, args.epochs):
245
+ train_sampler and train_sampler.set_epoch(epoch)
246
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
247
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
248
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
249
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, collate_fn=omni_collate_fn, num_workers=args.num_workers, pin_memory=True)
250
+ if skip > 0:
251
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
252
+ train_epoch(epoch, loader, len(loader) + skip, start_step, wandb)
253
+ else:
254
+ train_epoch(epoch, loader, len(loader), 0, wandb)
255
+
256
+ # ========== 9. 清理分布进程 ==========
257
+ if dist.is_initialized(): dist.destroy_process_group()
src/omni/trainers/full_sft_vlm.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import time
4
+ import warnings
5
+ import datasets
6
+ import torch
7
+ import torch.distributed as dist
8
+ from contextlib import nullcontext
9
+ from torch import optim, nn
10
+ from torch.nn.parallel import DistributedDataParallel
11
+ from torch.utils.data import DataLoader, DistributedSampler
12
+ from transformers import AutoTokenizer
13
+ from omni.models import MiniMindVLM, VLMConfig
14
+ from omni.datasets.lm_dataset import VLMDataset
15
+ from omni.utils import get_lr, Logger, is_main_process, init_distributed_mode, setup_seed, init_vlm_model, vlm_checkpoint, SkipBatchSampler, vlm_collate_fn
16
+
17
+ warnings.filterwarnings('ignore')
18
+
19
+
20
+ def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
21
+ start_time = time.time()
22
+ last_step = start_step
23
+ for step, (input_ids, labels, pixel_values) in enumerate(loader, start=start_step + 1):
24
+ input_ids = input_ids.to(args.device)
25
+ labels = labels.to(args.device)
26
+ pixel_values = {k: v.to(args.device) for k, v in pixel_values.items()} if isinstance(pixel_values, dict) else pixel_values.to(args.device)
27
+ last_step = step
28
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
29
+ for param_group in optimizer.param_groups:
30
+ param_group['lr'] = lr
31
+
32
+ with autocast_ctx:
33
+ res = model(input_ids, labels=labels, pixel_values=pixel_values)
34
+ loss = res.loss + res.aux_loss
35
+ loss = loss / args.accumulation_steps
36
+
37
+ scaler.scale(loss).backward()
38
+
39
+ if step % args.accumulation_steps == 0:
40
+ scaler.unscale_(optimizer)
41
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
42
+
43
+ scaler.step(optimizer)
44
+ scaler.update()
45
+
46
+ optimizer.zero_grad(set_to_none=True)
47
+
48
+ if step % args.log_interval == 0 or step == iters:
49
+ spend_time = time.time() - start_time
50
+ current_loss = loss.item() * args.accumulation_steps
51
+ current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0
52
+ current_logits_loss = current_loss - current_aux_loss
53
+ current_lr = optimizer.param_groups[-1]['lr']
54
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
55
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, logits_loss: {current_logits_loss:.4f}, aux_loss: {current_aux_loss:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
56
+ if wandb: wandb.log({"loss": current_loss, "logits_loss": current_logits_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
57
+
58
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
59
+ model.eval()
60
+ moe_suffix = '_moe' if vlm_config.use_moe else ''
61
+ ckp = f'{args.save_dir}/{args.save_weight}_{vlm_config.hidden_size}{moe_suffix}.pth'
62
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
63
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
64
+ state_dict = raw_model.state_dict()
65
+ clean_state_dict = {
66
+ key: value for key, value in state_dict.items() if not key.startswith('vision_encoder.')
67
+ }
68
+ clean_state_dict = {k: v.half().cpu() for k, v in clean_state_dict.items()} # 半精度保存并移到CPU
69
+ torch.save(clean_state_dict, ckp)
70
+ vlm_checkpoint(vlm_config, weight=args.save_weight, model=model, optimizer=optimizer,
71
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler)
72
+ model.train()
73
+ del state_dict, clean_state_dict
74
+
75
+ del input_ids, labels, pixel_values, res, loss
76
+
77
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
78
+ scaler.unscale_(optimizer)
79
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
80
+ scaler.step(optimizer)
81
+ scaler.update()
82
+ optimizer.zero_grad(set_to_none=True)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ parser = argparse.ArgumentParser(description="MiniMind-V SFT")
87
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
88
+ parser.add_argument('--save_weight', default='sft_vlm', type=str, help="保存权重的前缀名")
89
+ parser.add_argument("--epochs", type=int, default=2, help="训练轮数")
90
+ parser.add_argument("--batch_size", type=int, default=4, help="batch size")
91
+ parser.add_argument("--learning_rate", type=float, default=5e-6, help="初始学习率")
92
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
93
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
94
+ parser.add_argument("--num_workers", type=int, default=2, help="数据加载线程数")
95
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
96
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
97
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
98
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
99
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
100
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
101
+ parser.add_argument('--max_seq_len', default=768, type=int, help="训练的最大截断长度")
102
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
103
+ parser.add_argument("--data_path", type=str, default="../dataset/sft_i2t.parquet", help="训练数据路径")
104
+ parser.add_argument('--from_weight', default='pretrain_vlm', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
105
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
106
+ parser.add_argument('--freeze_llm', default=1, type=int, choices=[0, 1, 2], help="冻结策略(0=完全可训练,1=冻结+解冻首尾层,2=完全冻结仅训练proj)")
107
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
108
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
109
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-V-SFT", help="wandb项目名")
110
+ args = parser.parse_args()
111
+
112
+ # ========== 1. 初始化环境和随机种子 ==========
113
+ local_rank = init_distributed_mode()
114
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
115
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
116
+
117
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
118
+ os.makedirs(args.save_dir, exist_ok=True)
119
+ vlm_config = VLMConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, max_seq_len=args.max_seq_len, use_moe=bool(args.use_moe))
120
+ ckp_data = vlm_checkpoint(vlm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
121
+
122
+ # ========== 3. 设置混合精度 ==========
123
+ device_type = "cuda" if "cuda" in args.device else "cpu"
124
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
125
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
126
+
127
+ # ========== 4. 配wandb ==========
128
+ wandb = None
129
+ if args.use_wandb and is_main_process():
130
+ import swanlab as wandb
131
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
132
+ resume = 'must' if wandb_id else None
133
+ wandb_run_name = f"MiniMind-V-SFT-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LearningRate-{args.learning_rate}"
134
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
135
+
136
+ # ========== 5. 定义模型、数据、优化器 ==========
137
+ model, tokenizer = init_vlm_model(vlm_config, from_weight=args.from_weight, device=args.device, freeze_llm=args.freeze_llm)
138
+ preprocess = model.vision_encoder.processor
139
+ train_ds = VLMDataset(args.data_path, tokenizer, preprocess=preprocess, image_special_token=vlm_config.image_special_token, image_token_len=vlm_config.image_token_len, max_length=vlm_config.max_seq_len)
140
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
141
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
142
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
143
+
144
+ # ========== 6. 从ckp恢复状态 ==========
145
+ start_epoch, start_step = 0, 0
146
+ if ckp_data:
147
+ model.load_state_dict(ckp_data['model'], strict=False)
148
+ optimizer.load_state_dict(ckp_data['optimizer'])
149
+ scaler.load_state_dict(ckp_data['scaler'])
150
+ start_epoch = ckp_data['epoch']
151
+ start_step = ckp_data.get('step', 0)
152
+
153
+ # ========== 7. 编译和分布式包装 ==========
154
+ if args.use_compile == 1:
155
+ model = torch.compile(model)
156
+ Logger('torch.compile enabled')
157
+ if dist.is_initialized():
158
+ model._ddp_params_and_buffers_to_ignore = {"freqs_cos", "freqs_sin"}
159
+ model = DistributedDataParallel(model, device_ids=[local_rank])
160
+
161
+ # ========== 8. 开始训练 ==========
162
+ for epoch in range(start_epoch, args.epochs):
163
+ train_sampler and train_sampler.set_epoch(epoch)
164
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
165
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
166
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
167
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True, collate_fn=vlm_collate_fn)
168
+ if skip > 0:
169
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
170
+ train_epoch(epoch, loader, len(loader) + skip, start_step, wandb)
171
+ else:
172
+ train_epoch(epoch, loader, len(loader), 0, wandb)
173
+
174
+ # ========== 9. 清理分布进程 ==========
175
+ if dist.is_initialized(): dist.destroy_process_group()
src/omni/trainers/grpo.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import math
7
+ import re
8
+ import gc
9
+ import warnings
10
+ import torch
11
+ import torch.nn.functional as F
12
+ import torch.distributed as dist
13
+ from transformers import AutoTokenizer
14
+ from contextlib import nullcontext
15
+ from torch import optim
16
+ from torch.nn.parallel import DistributedDataParallel
17
+ from torch.utils.data import DataLoader, DistributedSampler
18
+ from torch.optim.lr_scheduler import CosineAnnealingLR
19
+ from transformers import AutoModel
20
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
21
+ from omni.datasets.lm_dataset import RLAIFDataset
22
+ from omni.utils.training import Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, SkipBatchSampler, init_model, LMForRewardModel
23
+ from omni.trainers.rollout_engine import create_rollout_engine
24
+
25
+ warnings.filterwarnings('ignore')
26
+
27
+
28
+ def rep_penalty(text, n=3, cap=0.5):
29
+ toks = re.findall(r"\w+|[^\w\s]", text.lower())
30
+ grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
31
+ return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0
32
+
33
+
34
+ def calculate_rewards(prompts, responses, reward_model):
35
+ rewards = torch.zeros(len(responses), device=args.device)
36
+
37
+ with torch.no_grad():
38
+ reward_model_scores = []
39
+ batch_size = len(prompts)
40
+
41
+ for i in range(batch_size):
42
+ for j in range(args.num_generations):
43
+ response_idx = i * args.num_generations + j
44
+ response = responses[response_idx]
45
+ prompt = prompts[i]
46
+
47
+ pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>"
48
+ matches = re.findall(pattern, prompt, re.DOTALL)
49
+ messages = [{"role": role, "content": content.strip()} for role, content in matches]
50
+ answer = response
51
+ rewards[response_idx] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5
52
+ if '</think>' in response:
53
+ thinking_content, answer_content = response.split('</think>', 1)
54
+ rewards[response_idx] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
55
+ rewards[response_idx] += 0.25 if response.count('</think>') == 1 else -0.25
56
+ answer = answer_content.strip()
57
+ rewards[response_idx] -= rep_penalty(answer)
58
+
59
+ score = reward_model.get_score(messages, answer)
60
+ reward_model_scores.append(score)
61
+
62
+ reward_model_scores = torch.tensor(reward_model_scores, device=args.device)
63
+ rewards += reward_model_scores
64
+
65
+ return rewards
66
+
67
+
68
+ def grpo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, reward_model, start_step=0, wandb=None, use_sglang=False):
69
+ for step, batch in enumerate(loader, start=start_step + 1):
70
+ prompts = batch['prompt'] # list[str], length B
71
+ prompt_inputs = tokenizer(prompts, return_tensors="pt", padding=True, return_token_type_ids=False,
72
+ padding_side="left", add_special_tokens=False).to(args.device)
73
+ if args.max_seq_len:
74
+ prompt_inputs["input_ids"] = prompt_inputs["input_ids"][:, -args.max_seq_len:]
75
+ prompt_inputs["attention_mask"] = prompt_inputs["attention_mask"][:, -args.max_seq_len:]
76
+
77
+ rollout_result = rollout_engine.rollout(
78
+ prompt_ids=prompt_inputs["input_ids"],
79
+ attention_mask=prompt_inputs["attention_mask"],
80
+ num_generations=args.num_generations,
81
+ max_new_tokens=args.max_gen_len,
82
+ temperature=0.8,
83
+ )
84
+ outputs = rollout_result.output_ids
85
+ completion_ids = rollout_result.completion_ids
86
+ completions = rollout_result.completions
87
+ old_per_token_logps = rollout_result.per_token_logps.to(args.device).detach()
88
+ prompt_lens = rollout_result.prompt_lens.to(args.device)
89
+ full_mask = (outputs != tokenizer.pad_token_id).long()
90
+ logp_pos = prompt_lens.unsqueeze(1) - 1 + torch.arange(completion_ids.size(1), device=args.device).unsqueeze(0)
91
+
92
+ rewards = calculate_rewards(prompts, completions, reward_model).to(args.device) # [B*num_gen]
93
+
94
+ model_unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
95
+ with autocast_ctx:
96
+ res = model_unwrapped(outputs, attention_mask=full_mask)
97
+ aux_loss = res.aux_loss if lm_config.use_moe else torch.tensor(0.0, device=args.device)
98
+ per_token_logps = F.log_softmax(res.logits[:, :-1, :], dim=-1).gather(2, outputs[:, 1:].unsqueeze(-1)).squeeze(-1).gather(1, logp_pos)
99
+
100
+ with torch.no_grad():
101
+ ref_per_token_logps = F.log_softmax(ref_model(outputs, attention_mask=full_mask).logits[:, :-1, :], dim=-1).gather(2, outputs[:, 1:].unsqueeze(-1)).squeeze(-1).gather(1, logp_pos)
102
+
103
+ if args.debug_mode and is_main_process() and step % args.debug_interval == 0:
104
+ for i in range(len(prompts)):
105
+ Logger(f"[DEBUG] step={step}, sample[{i}]")
106
+ Logger('-'*100)
107
+ Logger(f"{'=' * 30} [DEBUG] sample[{i}] CONTEXT_BEGIN {'=' * 30}")
108
+ Logger(prompts[i])
109
+ Logger(f"{'=' * 31} [DEBUG] sample[{i}] CONTEXT_END {'=' * 31}")
110
+ for j in range(args.num_generations):
111
+ idx = i * args.num_generations + j
112
+ Logger(f"{'=' * 28} [DEBUG] gen[{j}] RESPONSE_BEGIN {'=' * 28}")
113
+ Logger(completions[idx])
114
+ Logger(f"{'=' * 29} [DEBUG] gen[{j}] RESPONSE_END {'=' * 29}")
115
+ Logger(f"[DEBUG] gen[{j}] reward={rewards[idx].item():.4f}")
116
+ Logger('='*100)
117
+
118
+ grouped_rewards = rewards.view(-1, args.num_generations) # [B, num_gen]
119
+ mean_r = grouped_rewards.mean(dim=1).repeat_interleave(args.num_generations) # [B*num_gen]
120
+ std_r = grouped_rewards.std(dim=1, unbiased=False).repeat_interleave(args.num_generations) # [B*num_gen]
121
+ advantages = (rewards - mean_r) / (std_r + 1e-4) # [B*num_gen]
122
+
123
+ completion_pad_mask = rollout_result.completion_mask.to(args.device).bool()
124
+ is_eos = (completion_ids == tokenizer.eos_token_id) & completion_pad_mask # [B*num_gen, R]
125
+ eos_idx = torch.full((is_eos.size(0),), is_eos.size(1) - 1, dtype=torch.long, device=args.device)
126
+ eos_idx[is_eos.any(dim=1)] = is_eos.int().argmax(dim=1)[is_eos.any(dim=1)]
127
+ completion_mask = ((torch.arange(is_eos.size(1), device=args.device).expand(is_eos.size(0), -1) <= eos_idx.unsqueeze(1)) & completion_pad_mask).int() # [B*num_gen, R]
128
+
129
+ kl_div = ref_per_token_logps - per_token_logps
130
+ per_token_kl = torch.exp(kl_div) - kl_div - 1 # [B*num_gen, R]
131
+ ratio = torch.exp(per_token_logps - old_per_token_logps) # [B*num_gen, R]
132
+ if args.loss_type == "cispo":
133
+ clamped_ratio = torch.clamp(ratio, max=args.epsilon_high).detach()
134
+ per_token_loss = -(clamped_ratio * advantages.unsqueeze(1) * per_token_logps - args.beta * per_token_kl)
135
+ else:
136
+ clipped_ratio = torch.clamp(ratio, 1 - args.epsilon, 1 + args.epsilon)
137
+ per_token_loss1 = ratio * advantages.unsqueeze(1)
138
+ per_token_loss2 = clipped_ratio * advantages.unsqueeze(1)
139
+ per_token_loss = -(torch.min(per_token_loss1, per_token_loss2) - args.beta * per_token_kl)
140
+ policy_loss = ((per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1).clamp(min=1)).mean()
141
+ loss = (policy_loss + aux_loss) / args.accumulation_steps # scalar
142
+ loss.backward()
143
+
144
+ if step % args.accumulation_steps == 0:
145
+ if args.grad_clip > 0:
146
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
147
+ optimizer.step()
148
+ scheduler.step()
149
+ optimizer.zero_grad()
150
+
151
+ if step % args.log_interval == 0 or step == iters:
152
+ policy_loss_val = loss.item() * args.accumulation_steps
153
+ current_aux_loss = aux_loss.item()
154
+ avg_reward_val = rewards.mean().item()
155
+ avg_len_val = completion_mask.sum(dim=1).float().mean().item()
156
+ kl_ref_val = ((ref_per_token_logps - per_token_logps) * completion_mask).sum().item() / max(completion_mask.sum().item(), 1)
157
+ advantages_mean_val = advantages.mean().item()
158
+ advantages_std_val = advantages.std().item()
159
+ current_lr = optimizer.param_groups[0]['lr']
160
+
161
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), '
162
+ f'Reward: {avg_reward_val:.4f}, KL_ref: {kl_ref_val:.4f}, '
163
+ f'Adv Std: {advantages_std_val:.4f}, Adv Mean: {advantages_mean_val:.4f}, '
164
+ f'Actor Loss: {policy_loss_val:.4f}, Avg Response Len: {avg_len_val:.2f}, Learning Rate: {current_lr:.8f}')
165
+
166
+ if wandb and is_main_process():
167
+ wandb.log({
168
+ "reward": avg_reward_val,
169
+ "kl_ref": kl_ref_val,
170
+ "advantages_std": advantages_std_val,
171
+ "advantages_mean": advantages_mean_val,
172
+ "policy_loss": policy_loss_val,
173
+ "avg_response_len": avg_len_val,
174
+ "learning_rate": current_lr
175
+ })
176
+
177
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
178
+ model.eval()
179
+ moe_suffix = '_moe' if lm_config.use_moe else ''
180
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
181
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
182
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
183
+ state_dict = raw_model.state_dict()
184
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
185
+ lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer,
186
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scheduler=scheduler)
187
+ model.train()
188
+ del state_dict
189
+
190
+ if step % args.save_interval == 0 or step == iters: rollout_engine.update_policy(model)
191
+
192
+ del prompt_inputs, outputs, completion_ids, per_token_logps, ref_per_token_logps
193
+ del completions, rewards, grouped_rewards, mean_r, std_r, advantages, completion_mask, completion_pad_mask, prompt_lens, logp_pos
194
+
195
+ if step > start_step and step % args.accumulation_steps != 0:
196
+ if args.grad_clip > 0:
197
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
198
+ optimizer.step()
199
+ scheduler.step()
200
+ optimizer.zero_grad()
201
+
202
+
203
+ if __name__ == "__main__":
204
+ parser = argparse.ArgumentParser(description="MiniMind GRPO (Group Relative Policy Optimization)")
205
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
206
+ parser.add_argument('--save_weight', default='grpo', type=str, help="保存权重的前缀名")
207
+ parser.add_argument("--epochs", type=int, default=1, help="训练轮数")
208
+ parser.add_argument("--batch_size", type=int, default=2, help="batch size")
209
+ parser.add_argument("--learning_rate", type=float, default=3e-7, help="初始学习率")
210
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
211
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
212
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
213
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
214
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
215
+ parser.add_argument("--log_interval", type=int, default=1, help="日志打印间隔")
216
+ parser.add_argument("--save_interval", type=int, default=10, help="模型保存间隔")
217
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
218
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
219
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
220
+ parser.add_argument('--max_seq_len', default=768, type=int, help="Prompt最大长度")
221
+ parser.add_argument("--max_gen_len", type=int, default=1024, help="生成的最大长度")
222
+ parser.add_argument("--data_path", type=str, default="../dataset/rlaif.jsonl", help="RLAIF数据路径")
223
+ parser.add_argument("--num_generations", type=int, default=6, help="每个prompt生成的样本数")
224
+ parser.add_argument("--beta", type=float, default=0.1, help="KL惩罚系数")
225
+ parser.add_argument("--loss_type", type=str, default="cispo", choices=["grpo", "cispo"], help="loss类型")
226
+ parser.add_argument("--epsilon", type=float, default=0.2, help="GRPO的PPO clip epsilon")
227
+ parser.add_argument("--epsilon_high", type=float, default=5.0, help="epsilon上界")
228
+ parser.add_argument('--from_weight', default='full_sft', type=str, help="基于哪个权重训练")
229
+ parser.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward", help="Reward模型路径")
230
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
231
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
232
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-GRPO", help="wandb项目名")
233
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
234
+ parser.add_argument("--debug_mode", action="store_true", help="是否打印训练调试采样")
235
+ parser.add_argument("--debug_interval", type=int, default=20, help="debug模式下每隔多少step打印一次采样")
236
+ parser.add_argument("--thinking_ratio", type=float, default=0.9, help="按概率开启thinking(0.0~1.0)")
237
+ parser.add_argument("--rollout_engine", type=str, default="torch", choices=["torch", "sglang"], help="rollout引擎类型")
238
+ parser.add_argument("--sglang_base_url", type=str, default="http://localhost:8998", help="SGLang服务器URL")
239
+ parser.add_argument("--sglang_model_path", type=str, default="../model", help="SGLang tokenizer路径")
240
+ parser.add_argument("--sglang_shared_path", type=str, default="./sglang_ckpt_grpo", help="SGLang共享存储路径")
241
+ args = parser.parse_args()
242
+
243
+ # ========== 1. 初始化环境和随机种子 ==========
244
+ local_rank = init_distributed_mode()
245
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
246
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
247
+
248
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
249
+ os.makedirs(args.save_dir, exist_ok=True)
250
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers,
251
+ max_seq_len=args.max_seq_len + args.max_gen_len, use_moe=bool(args.use_moe))
252
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
253
+
254
+ # ========== 3. 设置混合精度 ==========
255
+ device_type = "cuda" if "cuda" in args.device else "cpu"
256
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
257
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
258
+
259
+ # ========== 4. 配wandb ==========
260
+ wandb = None
261
+ if args.use_wandb and is_main_process():
262
+ import swanlab as wandb
263
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
264
+ resume = 'must' if wandb_id else None
265
+ wandb_run_name = f"MiniMind-GRPO-Epoch-{args.epochs}-BS-{args.batch_size}-LR-{args.learning_rate}"
266
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
267
+
268
+ # ========== 5. 初始化模型和数据 ==========
269
+ base_weight = args.from_weight
270
+ # Policy模型
271
+ model, tokenizer = init_model(lm_config, base_weight, device=args.device)
272
+ # Reference模型
273
+ ref_model, _ = init_model(lm_config, base_weight, device=args.device)
274
+ ref_model = ref_model.eval().requires_grad_(False)
275
+ # Reward模型
276
+ reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
277
+ # Rollout引擎(可插拔替换,只负责 policy 推理)
278
+ rollout_engine = create_rollout_engine(
279
+ engine_type=args.rollout_engine,
280
+ policy_model=model,
281
+ tokenizer=tokenizer,
282
+ device=args.device,
283
+ autocast_ctx=autocast_ctx,
284
+ sglang_base_url=args.sglang_base_url,
285
+ sglang_model_path=args.sglang_model_path,
286
+ sglang_shared_path=args.sglang_shared_path,
287
+ )
288
+ # 数据和优化器
289
+ train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len, thinking_ratio=args.thinking_ratio)
290
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
291
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
292
+ loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler)
293
+ iters = len(loader_for_count)
294
+ total_optimizer_steps = math.ceil(iters / args.accumulation_steps) * args.epochs
295
+ scheduler = CosineAnnealingLR(optimizer, T_max=total_optimizer_steps, eta_min=args.learning_rate / 10)
296
+
297
+ # ========== 6. 从ckp恢复状态 ==========
298
+ start_epoch, start_step = 0, 0
299
+ if ckp_data:
300
+ model.load_state_dict(ckp_data['model'])
301
+ optimizer.load_state_dict(ckp_data['optimizer'])
302
+ scheduler.load_state_dict(ckp_data['scheduler'])
303
+ start_epoch = ckp_data['epoch']
304
+ start_step = ckp_data.get('step', 0)
305
+
306
+ # ========== 7. 编译和分布式包装 ==========
307
+ if args.use_compile == 1:
308
+ model = torch.compile(model)
309
+ Logger('torch.compile enabled')
310
+ rollout_engine.update_policy(model)
311
+ if dist.is_initialized():
312
+ model = DistributedDataParallel(model, device_ids=[local_rank])
313
+ rollout_engine.update_policy(model)
314
+
315
+ # ========== 8. 开始训练 ==========
316
+ for epoch in range(start_epoch, args.epochs):
317
+ train_sampler and train_sampler.set_epoch(epoch)
318
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
319
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
320
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
321
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
322
+ if skip > 0:
323
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
324
+ grpo_train_epoch(epoch, loader, len(loader) + skip, rollout_engine, ref_model, reward_model, start_step, wandb, use_sglang = (args.rollout_engine == "sglang"))
325
+ else:
326
+ grpo_train_epoch(epoch, loader, len(loader), rollout_engine, ref_model, reward_model, 0, wandb, use_sglang = (args.rollout_engine == "sglang"))
327
+
328
+ # ========== 9. 清理分布进程 ==========
329
+ if dist.is_initialized():
330
+ dist.barrier()
331
+ dist.destroy_process_group()
src/omni/trainers/lora.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import time
7
+ import warnings
8
+ import torch
9
+ import torch.distributed as dist
10
+ from contextlib import nullcontext
11
+ from torch import optim, nn
12
+ from torch.nn.parallel import DistributedDataParallel
13
+ from torch.utils.data import DataLoader, DistributedSampler
14
+ from omni.models.minimind import MiniMindConfig
15
+ from omni.datasets.lm_dataset import SFTDataset
16
+ from omni.models.lora import save_lora, apply_lora
17
+ from omni.utils.training import get_lr, Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, init_model, SkipBatchSampler
18
+
19
+ warnings.filterwarnings('ignore')
20
+
21
+
22
+ def train_epoch(epoch, loader, iters, lora_params, start_step=0, wandb=None):
23
+ start_time = time.time()
24
+ last_step = start_step
25
+ for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
26
+ input_ids = input_ids.to(args.device)
27
+ labels = labels.to(args.device)
28
+ last_step = step
29
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
30
+ for param_group in optimizer.param_groups:
31
+ param_group['lr'] = lr
32
+
33
+ with autocast_ctx:
34
+ res = model(input_ids, labels=labels)
35
+ loss = res.loss + res.aux_loss
36
+ loss = loss / args.accumulation_steps
37
+
38
+ scaler.scale(loss).backward()
39
+
40
+ if step % args.accumulation_steps == 0:
41
+ scaler.unscale_(optimizer)
42
+ torch.nn.utils.clip_grad_norm_(lora_params, args.grad_clip)
43
+ scaler.step(optimizer)
44
+ scaler.update()
45
+ optimizer.zero_grad(set_to_none=True)
46
+
47
+ if step % args.log_interval == 0 or step == iters:
48
+ spend_time = time.time() - start_time
49
+ current_loss = loss.item() * args.accumulation_steps
50
+ current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0
51
+ current_logits_loss = current_loss - current_aux_loss
52
+ current_lr = optimizer.param_groups[-1]['lr']
53
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
54
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, logits_loss: {current_logits_loss:.4f}, aux_loss: {current_aux_loss:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
55
+ if wandb: wandb.log({"loss": current_loss, "logits_loss": current_logits_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
56
+
57
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
58
+ model.eval()
59
+ moe_suffix = '_moe' if lm_config.use_moe else ''
60
+ lora_save_path = f'{args.save_dir}/{args.lora_name}_{lm_config.hidden_size}{moe_suffix}.pth'
61
+ # LoRA只保存LoRA权重
62
+ save_lora(model, lora_save_path)
63
+ lm_checkpoint(lm_config, weight=args.lora_name, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
64
+ model.train()
65
+
66
+ del input_ids, labels, res, loss
67
+
68
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
69
+ scaler.unscale_(optimizer)
70
+ torch.nn.utils.clip_grad_norm_(lora_params, args.grad_clip)
71
+ scaler.step(optimizer)
72
+ scaler.update()
73
+ optimizer.zero_grad(set_to_none=True)
74
+
75
+ if __name__ == "__main__":
76
+ parser = argparse.ArgumentParser(description="MiniMind LoRA Fine-tuning")
77
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
78
+ parser.add_argument("--lora_name", type=str, default="lora_medical", help="LoRA权重名称(如lora_identity/lora_medical等)")
79
+ parser.add_argument("--epochs", type=int, default=10, help="训练轮数")
80
+ parser.add_argument("--batch_size", type=int, default=32, help="batch size")
81
+ parser.add_argument("--learning_rate", type=float, default=1e-4, help="初始学习率")
82
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
83
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
84
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
85
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
86
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
87
+ parser.add_argument("--log_interval", type=int, default=10, help="日志打印间隔")
88
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
89
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
90
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
91
+ parser.add_argument('--max_seq_len', default=340, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
92
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
93
+ parser.add_argument("--data_path", type=str, default="../dataset/lora_medical.jsonl", help="LoRA训练数据路径")
94
+ parser.add_argument('--from_weight', default='full_sft', type=str, help="基于哪个权重训练,默认full_sft")
95
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
96
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
97
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-LoRA", help="wandb项目名")
98
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
99
+ args = parser.parse_args()
100
+
101
+ # ========== 1. 初始化环境和随机种子 ==========
102
+ local_rank = init_distributed_mode()
103
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
104
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
105
+
106
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
107
+ os.makedirs(args.save_dir, exist_ok=True)
108
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
109
+ ckp_data = lm_checkpoint(lm_config, weight=args.lora_name, save_dir='../checkpoints') if args.from_resume==1 else None
110
+
111
+ # ========== 3. 设置混合精度 ==========
112
+ device_type = "cuda" if "cuda" in args.device else "cpu"
113
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
114
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
115
+
116
+ # ========== 4. 配wandb ==========
117
+ wandb = None
118
+ if args.use_wandb and is_main_process():
119
+ import swanlab as wandb
120
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
121
+ resume = 'must' if wandb_id else None
122
+ wandb_run_name = f"MiniMind-LoRA-{args.lora_name}-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LR-{args.learning_rate}"
123
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
124
+
125
+ # ========== 5. 定义模型、应用LoRA、冻结非LoRA参数 ==========
126
+ model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
127
+ apply_lora(model)
128
+
129
+ # 统计参数
130
+ total_params = sum(p.numel() for p in model.parameters())
131
+ lora_params_count = sum(p.numel() for name, p in model.named_parameters() if 'lora' in name)
132
+ Logger(f"LLM 总参数量: {total_params / 1e6:.3f} M")
133
+ Logger(f"LoRA 参数量: {lora_params_count / 1e6:.3f} M")
134
+ Logger(f"LoRA 参数占比: {lora_params_count / total_params * 100:.2f}%")
135
+
136
+ # 冻结非LoRA参数,收集LoRA参数
137
+ lora_params = []
138
+ for name, param in model.named_parameters():
139
+ if 'lora' in name:
140
+ param.requires_grad = True
141
+ lora_params.append(param)
142
+ else:
143
+ param.requires_grad = False
144
+
145
+ # ========== 6. 定义数据和优化器 ==========
146
+ train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
147
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
148
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
149
+ optimizer = optim.AdamW(lora_params, lr=args.learning_rate)
150
+
151
+ # ========== 7. 从ckp恢复状态 ==========
152
+ start_epoch, start_step = 0, 0
153
+ if ckp_data:
154
+ model.load_state_dict(ckp_data['model'], strict=False)
155
+ optimizer.load_state_dict(ckp_data['optimizer'])
156
+ scaler.load_state_dict(ckp_data['scaler'])
157
+ start_epoch = ckp_data['epoch']
158
+ start_step = ckp_data.get('step', 0)
159
+
160
+ # ========== 8. 编译和分布式包装 ==========
161
+ if args.use_compile == 1:
162
+ args.use_compile = 0
163
+ Logger('[LoRA] monkey-patch forward 与 torch.compile 不兼容,use_compile 已自动关闭')
164
+ if dist.is_initialized():
165
+ model = DistributedDataParallel(model, device_ids=[local_rank])
166
+
167
+ # ========== 9. 开始训练 ==========
168
+ for epoch in range(start_epoch, args.epochs):
169
+ train_sampler and train_sampler.set_epoch(epoch)
170
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
171
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
172
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
173
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
174
+ if skip > 0:
175
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
176
+ train_epoch(epoch, loader, len(loader) + skip, lora_params, start_step, wandb)
177
+ else:
178
+ train_epoch(epoch, loader, len(loader), lora_params, 0, wandb)
179
+
180
+ # ========== 10. 清理分布进程 ==========
181
+ if dist.is_initialized():
182
+ dist.barrier()
183
+ dist.destroy_process_group()
src/omni/trainers/ppo.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import math
7
+ import re
8
+ import warnings
9
+ import torch
10
+ import torch.distributed as dist
11
+ import torch.nn.functional as F
12
+ from transformers import AutoTokenizer
13
+ from contextlib import nullcontext
14
+ from torch import optim, nn
15
+ from torch.nn.parallel import DistributedDataParallel
16
+ from torch.utils.data import DataLoader, DistributedSampler
17
+ from torch.nn.utils import clip_grad_norm_
18
+ from torch.optim.lr_scheduler import CosineAnnealingLR
19
+ from omni.models.minimind import MiniMindConfig, MiniMindForCausalLM
20
+ from omni.datasets.lm_dataset import RLAIFDataset
21
+ from omni.utils.training import Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, SkipBatchSampler, init_model, LMForRewardModel
22
+ from omni.trainers.rollout_engine import create_rollout_engine
23
+
24
+ warnings.filterwarnings('ignore')
25
+
26
+
27
+ def rep_penalty(text, n=3, cap=0.5):
28
+ toks = re.findall(r"\w+|[^\w\s]", text.lower())
29
+ grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
30
+ return min(cap, (len(grams) - len(set(grams))) * cap * 2 / len(grams)) if grams else 0.0
31
+
32
+
33
+ # 自定义的Critic模型,继承自MiniMindLM
34
+ class CriticModel(MiniMindForCausalLM):
35
+ def __init__(self, params):
36
+ super().__init__(params)
37
+ # 替换lm_head为输出单一价值的线性层
38
+ self.value_head = nn.Linear(params.hidden_size, 1)
39
+
40
+ def forward(self, input_ids=None, attention_mask=None, **kwargs):
41
+ # 使用基础模型获取隐藏状态
42
+ outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, **kwargs)
43
+ hidden_states = self.model.norm(outputs[0])
44
+ # 使用value_head获取价值估计
45
+ values = self.value_head(hidden_states).squeeze(-1)
46
+ return values
47
+
48
+
49
+ def calculate_rewards(prompts, responses, reward_model):
50
+ rewards = torch.zeros(len(responses), device=args.device)
51
+
52
+ with torch.no_grad():
53
+ reward_model_scores = []
54
+ for i, (prompt, response) in enumerate(zip(prompts, responses)):
55
+ pattern = r"<\|im_start\|>(system|user|assistant)\s+(.*?)<\|im_end\|>"
56
+ matches = re.findall(pattern, prompt, re.DOTALL)
57
+ messages = [{"role": role, "content": content.strip()} for role, content in matches]
58
+ answer = response
59
+ rewards[i] += 0.5 if 20 <= len(response.strip()) <= 800 else -0.5
60
+ if '</think>' in response:
61
+ thinking_content, answer_content = response.split('</think>', 1)
62
+ rewards[i] += 1.0 if 20 <= len(thinking_content.strip()) <= 300 else -0.5
63
+ rewards[i] += 0.25 if response.count('</think>') == 1 else -0.25
64
+ answer = answer_content.strip()
65
+ rewards[i] -= rep_penalty(answer)
66
+
67
+ score = reward_model.get_score(messages, answer)
68
+ reward_model_scores.append(score)
69
+
70
+ reward_model_scores = torch.tensor(reward_model_scores, device=args.device)
71
+ rewards += reward_model_scores
72
+
73
+ return rewards
74
+
75
+
76
+ def ppo_train_epoch(epoch, loader, iters, rollout_engine, ref_model, actor_scheduler, critic_scheduler, reward_model, start_step=0, wandb=None, use_sglang=False):
77
+ actor_model.train()
78
+ critic_model.train()
79
+ grad_accum_step = 0
80
+
81
+ for step, batch in enumerate(loader, start=start_step + 1):
82
+ prompts = batch["prompt"] # list[str], length B
83
+ enc = tokenizer(prompts, return_tensors="pt", padding=True, truncation=True, max_length=args.max_seq_len,
84
+ padding_side="left").to(args.device) # input_ids: [B, P], attention_mask: [B, P]
85
+
86
+ rollout_result = rollout_engine.rollout(
87
+ prompt_ids=enc.input_ids,
88
+ attention_mask=enc.attention_mask,
89
+ num_generations=1,
90
+ max_new_tokens=args.max_gen_len,
91
+ temperature=0.8,
92
+ )
93
+ gen_out = rollout_result.output_ids
94
+ completion_ids = rollout_result.completion_ids
95
+ prompt_lens = rollout_result.prompt_lens.to(args.device)
96
+ responses_text = rollout_result.completions
97
+ old_resp_logp = rollout_result.per_token_logps.to(args.device)
98
+ rewards = calculate_rewards(prompts, responses_text, reward_model) # [B]
99
+
100
+ if args.debug_mode and is_main_process() and step % args.debug_interval == 0:
101
+ for i in range(len(prompts)):
102
+ Logger(f"[DEBUG] step={step}, sample[{i}]")
103
+ Logger('-'*100)
104
+ Logger(f"{'=' * 30} [DEBUG] sample[{i}] CONTEXT_BEGIN {'=' * 30}")
105
+ Logger(prompts[i])
106
+ Logger(f"{'=' * 31} [DEBUG] sample[{i}] CONTEXT_END {'=' * 31}")
107
+ Logger(f"[DEBUG] prompt_len={prompt_lens[i].item()}, response_len={len(responses_text[i])}")
108
+ Logger(f"{'=' * 28} [DEBUG] sample[{i}] RESPONSE_BEGIN {'=' * 28}")
109
+ Logger(responses_text[i])
110
+ Logger(f"{'=' * 29} [DEBUG] sample[{i}] RESPONSE_END {'=' * 29}")
111
+ Logger(f"[DEBUG] reward={rewards[i].item():.4f}")
112
+ Logger('='*100)
113
+
114
+ full_mask = (gen_out != tokenizer.pad_token_id).long() # [B, P+R]
115
+ labels = gen_out[:, 1:].clone() # [B, P+R-1]
116
+ B = len(prompts)
117
+ resp_labels = completion_ids
118
+ resp_idx = torch.arange(resp_labels.size(1), device=gen_out.device).unsqueeze(0)
119
+ logp_pos = prompt_lens.unsqueeze(1) - 1 + resp_idx
120
+ resp_pad_mask = rollout_result.completion_mask.to(args.device).bool()
121
+ resp_lengths = resp_pad_mask.sum(dim=1); valid_resp = resp_lengths > 0; eos_mask = resp_labels.eq(tokenizer.eos_token_id) & resp_pad_mask
122
+ has_eos = eos_mask.any(dim=1); eos_pos = torch.argmax(eos_mask.int(), dim=1)
123
+ resp_lengths = torch.where(has_eos, eos_pos + 1, resp_lengths).long().clamp(min=1)
124
+ resp_policy_mask = ((resp_idx < resp_lengths.unsqueeze(1)) & resp_pad_mask).float()
125
+ resp_value_mask = resp_policy_mask.clone()
126
+
127
+ with torch.no_grad(): # Rollout阶段只需推理获取old_logp和old_values,切断梯度省显存
128
+ critic_for_rollout = critic_model.module if isinstance(critic_model, DistributedDataParallel) else critic_model
129
+ values_seq = critic_for_rollout(input_ids=gen_out, attention_mask=full_mask)
130
+ old_resp_values = values_seq.gather(1, logp_pos) * resp_value_mask
131
+
132
+ ref_resp_logp = F.log_softmax(ref_model(input_ids=gen_out, attention_mask=full_mask).logits[:, :-1], dim=-1).gather(2, labels.unsqueeze(-1)).squeeze(-1).gather(1, logp_pos)
133
+ token_rewards = torch.zeros_like(old_resp_logp)
134
+ last_idx = resp_lengths - 1 # [B]
135
+ token_rewards[torch.arange(B, device=args.device)[valid_resp], last_idx[valid_resp]] += rewards[valid_resp] # 末尾加外部奖励
136
+
137
+ gen_len = old_resp_values.size(1); lastgaelam = torch.zeros(B, device=args.device); advs_rev = []
138
+ for t in reversed(range(gen_len)):
139
+ nv = old_resp_values[:, t + 1] if t < gen_len - 1 else 0.0
140
+ delta = token_rewards[:, t] + args.gamma * nv - old_resp_values[:, t]
141
+ lastgaelam = delta + args.gamma * args.lam * lastgaelam
142
+ advs_rev.append(lastgaelam)
143
+ advantages = torch.stack(advs_rev[::-1], dim=1) # [B, R]
144
+ returns = advantages + old_resp_values # [B, R]
145
+
146
+ adv_mean = (advantages * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
147
+ adv_var = ((advantages - adv_mean) ** 2 * resp_policy_mask).sum() / resp_policy_mask.sum().clamp(min=1)
148
+ advantages = (advantages - adv_mean) * torch.rsqrt(adv_var + 1e-8) * resp_policy_mask
149
+
150
+ mb_size = max(1, min(args.mini_batch_size, B))
151
+ stop_ppo = False
152
+ policy_loss_sum = 0.0
153
+ value_loss_sum = 0.0
154
+ kl_sum = 0.0
155
+ kl_ref_sum = 0.0
156
+ clipfrac_sum = 0.0
157
+ aux_loss_sum = 0.0
158
+ log_count = 0
159
+ actor_unwrapped = actor_model.module if isinstance(actor_model, DistributedDataParallel) else actor_model
160
+ critic_unwrapped = critic_model.module if isinstance(critic_model, DistributedDataParallel) else critic_model
161
+ for ppo_epoch in range(args.ppo_update_iters):
162
+ if stop_ppo:
163
+ break
164
+ b_inds = torch.randperm(B, device=args.device)
165
+ for i in range(0, B, mb_size):
166
+ inds = b_inds[i:i + mb_size]
167
+
168
+ mb_values_seq = critic_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
169
+ mb_resp_values = mb_values_seq.gather(1, logp_pos[inds])
170
+
171
+ with autocast_ctx:
172
+ res = actor_unwrapped(input_ids=gen_out[inds], attention_mask=full_mask[inds])
173
+ aux_loss = res.aux_loss if lm_config.use_moe else torch.tensor(0.0, device=args.device)
174
+
175
+ mb_resp_logp = F.log_softmax(res.logits[:, :-1], dim=-1).gather(2, labels[inds].unsqueeze(-1)).squeeze(-1).gather(1, logp_pos[inds])
176
+
177
+ log_ratio = mb_resp_logp - old_resp_logp[inds]
178
+ approx_kl = (0.5 * (log_ratio ** 2) * resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
179
+
180
+ # 同步各卡的 approx_kl,防止某卡 break 而其它卡继续导致 DDP 死锁
181
+ approx_kl_val = approx_kl.detach().clone()
182
+ if dist.is_initialized():
183
+ dist.all_reduce(approx_kl_val, op=dist.ReduceOp.AVG)
184
+
185
+ if approx_kl_val > args.early_stop_kl:
186
+ stop_ppo = True
187
+
188
+ ratio = torch.exp(log_ratio)
189
+ clipfrac = ((((ratio - 1.0).abs() > args.clip_epsilon).float() * resp_policy_mask[inds]).sum()
190
+ / resp_policy_mask[inds].sum().clamp(min=1))
191
+ kl_ref_penalty = ((torch.exp(ref_resp_logp[inds] - mb_resp_logp) - (ref_resp_logp[inds] - mb_resp_logp) - 1.0)
192
+ * resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
193
+ policy_loss = ((torch.max(-advantages[inds] * ratio,
194
+ -advantages[inds] * torch.clamp(ratio, 1.0 - args.clip_epsilon, 1.0 + args.clip_epsilon))
195
+ * resp_policy_mask[inds]).sum() / resp_policy_mask[inds].sum().clamp(min=1)
196
+ + args.kl_coef * kl_ref_penalty)
197
+ value_loss = 0.5 * (torch.max((mb_resp_values - returns[inds]) ** 2,
198
+ (torch.clamp(mb_resp_values, old_resp_values[inds] - args.cliprange_value,
199
+ old_resp_values[inds] + args.cliprange_value) - returns[inds]) ** 2)
200
+ * resp_value_mask[inds]).sum() / resp_value_mask[inds].sum().clamp(min=1)
201
+
202
+ kl = approx_kl_val
203
+ kl_ref = kl_ref_penalty.detach()
204
+
205
+ # 早停时必须保证 forward-backward 闭环,故只截断 loss 不中断 DDP 通信
206
+ if stop_ppo:
207
+ loss = (policy_loss + args.vf_coef * value_loss + aux_loss) * 0.0
208
+ else:
209
+ loss = (policy_loss + args.vf_coef * value_loss + aux_loss) / args.accumulation_steps
210
+
211
+ loss.backward()
212
+
213
+ policy_loss_sum += policy_loss.item()
214
+ value_loss_sum += value_loss.item()
215
+ kl_sum += kl.item()
216
+ kl_ref_sum += kl_ref.item()
217
+ clipfrac_sum += clipfrac.item()
218
+ aux_loss_sum += aux_loss.item()
219
+ log_count += 1
220
+
221
+ grad_accum_step += 1
222
+
223
+ if grad_accum_step % args.accumulation_steps == 0:
224
+ clip_grad_norm_(actor_model.parameters(), args.grad_clip)
225
+ clip_grad_norm_(critic_model.parameters(), args.grad_clip)
226
+ actor_optimizer.step()
227
+ critic_optimizer.step()
228
+ actor_scheduler.step()
229
+ critic_scheduler.step()
230
+ actor_optimizer.zero_grad()
231
+ critic_optimizer.zero_grad()
232
+
233
+ if grad_accum_step % args.accumulation_steps != 0:
234
+ clip_grad_norm_(actor_model.parameters(), args.grad_clip)
235
+ clip_grad_norm_(critic_model.parameters(), args.grad_clip)
236
+ actor_optimizer.step()
237
+ critic_optimizer.step()
238
+ actor_scheduler.step()
239
+ critic_scheduler.step()
240
+ actor_optimizer.zero_grad()
241
+ critic_optimizer.zero_grad()
242
+
243
+ if step % args.save_interval == 0 or step == iters: rollout_engine.update_policy(actor_model)
244
+
245
+ if is_main_process():
246
+ critic_loss_val = value_loss_sum / max(log_count, 1)
247
+ reward_val = rewards.mean().item()
248
+ approx_kl_val = kl_sum / max(log_count, 1)
249
+ kl_ref_val = kl_ref_sum / max(log_count, 1)
250
+ clipfrac_val = clipfrac_sum / max(log_count, 1)
251
+ avg_len_val = resp_lengths.float().mean().item()
252
+ actor_lr, critic_lr = actor_optimizer.param_groups[0]['lr'], critic_optimizer.param_groups[0]['lr']
253
+
254
+ if wandb is not None:
255
+ wandb.log({
256
+ "reward": reward_val,
257
+ "kl_ref": kl_ref_val,
258
+ "approx_kl": approx_kl_val,
259
+ "clipfrac": clipfrac_val,
260
+ "critic_loss": critic_loss_val,
261
+ "avg_response_len": avg_len_val,
262
+ "actor_lr": actor_lr,
263
+ "critic_lr": critic_lr,
264
+ })
265
+
266
+ Logger(f"Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), "
267
+ f"Reward: {reward_val:.4f}, KL_ref: {kl_ref_val:.4f}, Approx KL: {approx_kl_val:.4f}, "
268
+ f"ClipFrac: {clipfrac_val:.4f}, Critic Loss: {critic_loss_val:.4f}, "
269
+ f"Avg Response Len: {avg_len_val:.2f}, Actor LR: {actor_lr:.8f}, Critic LR: {critic_lr:.8f}")
270
+
271
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
272
+ actor_model.eval()
273
+ moe_suffix = '_moe' if lm_config.use_moe else ''
274
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
275
+ raw_actor = actor_model.module if isinstance(actor_model, DistributedDataParallel) else actor_model
276
+ raw_actor = getattr(raw_actor, '_orig_mod', raw_actor)
277
+ actor_state = raw_actor.state_dict()
278
+ torch.save({k: v.half().cpu() for k, v in actor_state.items()}, ckp)
279
+
280
+ # 使用 lm_checkpoint 保存完整状态(包括 critic)
281
+ lm_checkpoint(lm_config, weight=args.save_weight, model=actor_model, optimizer=actor_optimizer,
282
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints',
283
+ scheduler=actor_scheduler, critic_model=critic_model,
284
+ critic_optimizer=critic_optimizer, critic_scheduler=critic_scheduler)
285
+ actor_model.train()
286
+ del actor_state
287
+
288
+ del enc, gen_out, completion_ids, responses_text, rewards, full_mask, values_seq, advantages
289
+ del labels, resp_labels, resp_idx, resp_pad_mask, valid_resp, eos_mask, has_eos, eos_pos, resp_lengths, resp_policy_mask, resp_value_mask, old_resp_logp, ref_resp_logp
290
+ del kl, kl_ref, policy_loss, value_loss, loss, token_rewards, returns, old_resp_values, prompt_lens, logp_pos
291
+
292
+
293
+ if __name__ == "__main__":
294
+ parser = argparse.ArgumentParser(description="MiniMind PPO (Proximal Policy Optimization)")
295
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
296
+ parser.add_argument('--save_weight', default='ppo_actor', type=str, help="保存权重的前缀名")
297
+ parser.add_argument("--epochs", type=int, default=1, help="训练轮数")
298
+ parser.add_argument("--batch_size", type=int, default=2, help="batch size")
299
+ parser.add_argument("--learning_rate", type=float, default=3e-7, help="Actor学习率")
300
+ parser.add_argument("--critic_learning_rate", type=float, default=5e-7, help="Critic学习率")
301
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
302
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
303
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
304
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度累积步数")
305
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
306
+ parser.add_argument("--log_interval", type=int, default=1, help="日志打印间隔")
307
+ parser.add_argument("--save_interval", type=int, default=10, help="模型保存间隔")
308
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
309
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
310
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
311
+ parser.add_argument('--max_seq_len', default=768, type=int, help="Prompt最大长度")
312
+ parser.add_argument("--max_gen_len", type=int, default=1024, help="生成的最大长度")
313
+ parser.add_argument("--data_path", type=str, default="../dataset/rlaif.jsonl", help="RLAIF数据路径")
314
+ parser.add_argument("--clip_epsilon", type=float, default=0.2, help="PPO裁剪参数")
315
+ parser.add_argument("--vf_coef", type=float, default=0.5, help="Value function系数")
316
+ parser.add_argument("--kl_coef", type=float, default=0.02, help="KL散度惩罚系数")
317
+ parser.add_argument("--gamma", type=float, default=1.0, help="GAE折扣因子")
318
+ parser.add_argument("--lam", type=float, default=0.95, help="GAE lambda参数")
319
+ parser.add_argument("--cliprange_value", type=float, default=0.2, help="Value function裁剪范围")
320
+ parser.add_argument("--ppo_update_iters", type=int, default=2, help="同一批rollout重复更新次数")
321
+ parser.add_argument("--early_stop_kl", type=float, default=0.25, help="PPO early stop 的 KL 阈值")
322
+ parser.add_argument("--mini_batch_size", type=int, default=2, help="PPO每次更新的minibatch大小")
323
+ parser.add_argument('--from_weight', default='full_sft', type=str, help="基于哪个权重训练")
324
+ parser.add_argument("--reward_model_path", type=str, default="../../internlm2-1_8b-reward", help="Reward模型路径")
325
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
326
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
327
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-PPO", help="wandb项目名")
328
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
329
+ parser.add_argument("--debug_mode", action="store_true", help="是否打印训练调试采样")
330
+ parser.add_argument("--debug_interval", type=int, default=20, help="debug模式下每隔多少step打印一次采样")
331
+ parser.add_argument("--thinking_ratio", type=float, default=0.9, help="按概率开启thinking(0.0~1.0)")
332
+ parser.add_argument("--rollout_engine", type=str, default="torch", choices=["torch", "sglang"], help="rollout引擎类型")
333
+ parser.add_argument("--sglang_base_url", type=str, default="http://localhost:8998", help="SGLang服务器URL")
334
+ parser.add_argument("--sglang_model_path", type=str, default="../model", help="SGLang tokenizer路径")
335
+ parser.add_argument("--sglang_shared_path", type=str, default="./sglang_ckpt_ppo", help="SGLang共享存储路径")
336
+ args = parser.parse_args()
337
+
338
+ # ========== 1. 初始化环境和随机种子 ==========
339
+ local_rank = init_distributed_mode()
340
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
341
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
342
+
343
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
344
+ os.makedirs(args.save_dir, exist_ok=True)
345
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
346
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
347
+
348
+ # ========== 3. 设置混合精度 ==========
349
+ device_type = "cuda" if "cuda" in args.device else "cpu"
350
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
351
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
352
+
353
+ # ========== 4. 配wandb ==========
354
+ wandb = None
355
+ if args.use_wandb and is_main_process():
356
+ import swanlab as wandb
357
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
358
+ resume = 'must' if wandb_id else None
359
+ wandb_run_name = f"MiniMind-PPO-Epoch-{args.epochs}-BS-{args.batch_size}-LR-{args.learning_rate}"
360
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
361
+
362
+ # ========== 5. 初始化模型和数据 ==========
363
+ base_weight = args.from_weight
364
+ # Actor模型
365
+ actor_model, tokenizer = init_model(lm_config, base_weight, device=args.device)
366
+ ref_model, _ = init_model(lm_config, base_weight, device=args.device)
367
+ ref_model = ref_model.eval().requires_grad_(False)
368
+ moe_suffix = '_moe' if lm_config.use_moe else ''
369
+ ckp = f'{args.save_dir}/{base_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
370
+ state_dict = torch.load(ckp, map_location=args.device)
371
+ critic_model = CriticModel(lm_config)
372
+ critic_model.load_state_dict(state_dict, strict=False)
373
+ critic_model = critic_model.to(args.device)
374
+ reward_model = LMForRewardModel(args.reward_model_path, device=args.device, dtype=torch.float16)
375
+ # Rollout引擎
376
+ rollout_engine = create_rollout_engine(
377
+ engine_type=args.rollout_engine,
378
+ policy_model=actor_model,
379
+ tokenizer=tokenizer,
380
+ device=args.device,
381
+ autocast_ctx=autocast_ctx,
382
+ sglang_base_url=args.sglang_base_url,
383
+ sglang_model_path=args.sglang_model_path,
384
+ sglang_shared_path=args.sglang_shared_path,
385
+ )
386
+ train_ds = RLAIFDataset(args.data_path, tokenizer, max_length=(args.max_seq_len + args.max_gen_len), thinking_ratio=args.thinking_ratio)
387
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
388
+ actor_optimizer = optim.AdamW(actor_model.parameters(), lr=args.learning_rate)
389
+ critic_optimizer = optim.AdamW(critic_model.parameters(), lr=args.critic_learning_rate)
390
+ loader_for_count = DataLoader(train_ds, batch_size=args.batch_size, sampler=train_sampler)
391
+ iters = len(loader_for_count)
392
+ mb_factor = max(1, math.ceil(args.batch_size / args.mini_batch_size))
393
+ total_optimizer_steps = math.ceil(iters * args.epochs * args.ppo_update_iters * mb_factor / args.accumulation_steps)
394
+ actor_scheduler = CosineAnnealingLR(actor_optimizer, T_max=total_optimizer_steps, eta_min=args.learning_rate / 10)
395
+ critic_scheduler = CosineAnnealingLR(critic_optimizer, T_max=total_optimizer_steps, eta_min=args.critic_learning_rate / 10)
396
+
397
+ start_epoch, start_step = 0, 0
398
+ if ckp_data:
399
+ actor_model.load_state_dict(ckp_data['model'])
400
+ critic_model.load_state_dict(ckp_data['critic_model'])
401
+ actor_optimizer.load_state_dict(ckp_data['optimizer'])
402
+ critic_optimizer.load_state_dict(ckp_data['critic_optimizer'])
403
+ actor_scheduler.load_state_dict(ckp_data['scheduler'])
404
+ critic_scheduler.load_state_dict(ckp_data['critic_scheduler'])
405
+ start_epoch = ckp_data['epoch']
406
+ start_step = ckp_data.get('step', 0)
407
+
408
+ # ========== 7. 编译和分布式包装 ==========
409
+ if args.use_compile == 1:
410
+ actor_model = torch.compile(actor_model)
411
+ Logger('torch.compile enabled')
412
+ rollout_engine.update_policy(actor_model)
413
+ if dist.is_initialized():
414
+ actor_model = DistributedDataParallel(actor_model, device_ids=[local_rank])
415
+ critic_model = DistributedDataParallel(critic_model, device_ids=[local_rank])
416
+ rollout_engine.update_policy(actor_model)
417
+
418
+ # ========== 8. 开始训练 ==========
419
+ for epoch in range(start_epoch, args.epochs):
420
+ train_sampler and train_sampler.set_epoch(epoch)
421
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
422
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
423
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
424
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
425
+ if skip > 0:
426
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
427
+ ppo_train_epoch(epoch, loader, len(loader) + skip, rollout_engine, ref_model, actor_scheduler, critic_scheduler, reward_model, start_step, wandb, use_sglang = (args.rollout_engine == "sglang"))
428
+ else:
429
+ ppo_train_epoch(epoch, loader, len(loader), rollout_engine, ref_model, actor_scheduler, critic_scheduler, reward_model, 0, wandb, use_sglang = (args.rollout_engine == "sglang"))
430
+
431
+ # ========== 9. 清理分布进程 ==========
432
+ if dist.is_initialized():
433
+ dist.barrier()
434
+ dist.destroy_process_group()
src/omni/trainers/pretrain.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
5
+ import argparse
6
+ import time
7
+ import warnings
8
+ import torch
9
+ import torch.distributed as dist
10
+ from contextlib import nullcontext
11
+ from torch import optim, nn
12
+ from torch.nn.parallel import DistributedDataParallel
13
+ from torch.utils.data import DataLoader, DistributedSampler
14
+ from omni.models.minimind import MiniMindConfig
15
+ from omni.datasets.lm_dataset import PretrainDataset
16
+ from omni.utils.training import get_lr, Logger, is_main_process, lm_checkpoint, init_distributed_mode, setup_seed, init_model, SkipBatchSampler
17
+
18
+ warnings.filterwarnings('ignore')
19
+
20
+
21
+ def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
22
+ start_time = time.time()
23
+ last_step = start_step
24
+ for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
25
+ input_ids = input_ids.to(args.device)
26
+ labels = labels.to(args.device)
27
+ last_step = step
28
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
29
+ for param_group in optimizer.param_groups:
30
+ param_group['lr'] = lr
31
+
32
+ with autocast_ctx:
33
+ res = model(input_ids, labels=labels)
34
+ loss = res.loss + res.aux_loss
35
+ loss = loss / args.accumulation_steps
36
+
37
+ scaler.scale(loss).backward()
38
+
39
+ if step % args.accumulation_steps == 0:
40
+ scaler.unscale_(optimizer)
41
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
42
+
43
+ scaler.step(optimizer)
44
+ scaler.update()
45
+
46
+ optimizer.zero_grad(set_to_none=True)
47
+
48
+ if step % args.log_interval == 0 or step == iters:
49
+ spend_time = time.time() - start_time
50
+ current_loss = loss.item() * args.accumulation_steps
51
+ current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0
52
+ current_logits_loss = current_loss - current_aux_loss
53
+ current_lr = optimizer.param_groups[-1]['lr']
54
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
55
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, logits_loss: {current_logits_loss:.4f}, aux_loss: {current_aux_loss:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
56
+ if wandb: wandb.log({"loss": current_loss, "logits_loss": current_logits_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
57
+
58
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
59
+ model.eval()
60
+ moe_suffix = '_moe' if lm_config.use_moe else ''
61
+ ckp = f'{args.save_dir}/{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
62
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
63
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
64
+ state_dict = raw_model.state_dict()
65
+ torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
66
+ lm_checkpoint(lm_config, weight=args.save_weight, model=model, optimizer=optimizer, scaler=scaler, epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints')
67
+ model.train()
68
+ del state_dict
69
+
70
+ del input_ids, labels, res, loss
71
+
72
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
73
+ scaler.unscale_(optimizer)
74
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
75
+ scaler.step(optimizer)
76
+ scaler.update()
77
+ optimizer.zero_grad(set_to_none=True)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ parser = argparse.ArgumentParser(description="MiniMind Pretraining")
82
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
83
+ parser.add_argument('--save_weight', default='pretrain', type=str, help="保存权重的前缀名")
84
+ parser.add_argument("--epochs", type=int, default=2, help="训练轮数")
85
+ parser.add_argument("--batch_size", type=int, default=32, help="batch size")
86
+ parser.add_argument("--learning_rate", type=float, default=5e-4, help="初始学习率")
87
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
88
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
89
+ parser.add_argument("--num_workers", type=int, default=8, help="数据加载线程数")
90
+ parser.add_argument("--accumulation_steps", type=int, default=8, help="梯度累积步数")
91
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
92
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
93
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
94
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
95
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐���层数量")
96
+ parser.add_argument('--max_seq_len', default=340, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)")
97
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
98
+ parser.add_argument("--data_path", type=str, default="../dataset/pretrain_t2t_mini.jsonl", help="预训练数据路径")
99
+ parser.add_argument('--from_weight', default='none', type=str, help="基于哪个权重训练,为none则从头开始")
100
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
101
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
102
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-Pretrain", help="wandb项目名")
103
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
104
+ args = parser.parse_args()
105
+
106
+ # ========== 1. 初始化环境和随机种子 ==========
107
+ local_rank = init_distributed_mode()
108
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
109
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
110
+
111
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
112
+ os.makedirs(args.save_dir, exist_ok=True)
113
+ lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe))
114
+ ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
115
+
116
+ # ========== 3. 设置混合精度 ==========
117
+ device_type = "cuda" if "cuda" in args.device else "cpu"
118
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
119
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
120
+
121
+ # ========== 4. 配wandb ==========
122
+ wandb = None
123
+ if args.use_wandb and is_main_process():
124
+ import swanlab as wandb
125
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
126
+ resume = 'must' if wandb_id else None
127
+ wandb_run_name = f"MiniMind-Pretrain-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LearningRate-{args.learning_rate}"
128
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
129
+
130
+ # ========== 5. 定义模型、数据、优化器 ==========
131
+ model, tokenizer = init_model(lm_config, args.from_weight, device=args.device)
132
+ train_ds = PretrainDataset(args.data_path, tokenizer, max_length=args.max_seq_len)
133
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
134
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
135
+ optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
136
+
137
+ # ========== 6. 从ckp恢复状态 ==========
138
+ start_epoch, start_step = 0, 0
139
+ if ckp_data:
140
+ model.load_state_dict(ckp_data['model'])
141
+ optimizer.load_state_dict(ckp_data['optimizer'])
142
+ scaler.load_state_dict(ckp_data['scaler'])
143
+ start_epoch = ckp_data['epoch']
144
+ start_step = ckp_data.get('step', 0)
145
+
146
+ # ========== 7. 编译和分布式包装 ==========
147
+ if args.use_compile == 1:
148
+ model = torch.compile(model)
149
+ Logger('torch.compile enabled')
150
+ if dist.is_initialized():
151
+ model = DistributedDataParallel(model, device_ids=[local_rank])
152
+
153
+ # ========== 8. 开始训练 ==========
154
+ for epoch in range(start_epoch, args.epochs):
155
+ train_sampler and train_sampler.set_epoch(epoch)
156
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
157
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
158
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
159
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True)
160
+ if skip > 0:
161
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
162
+ train_epoch(epoch, loader, len(loader) + skip, start_step, wandb)
163
+ else:
164
+ train_epoch(epoch, loader, len(loader), 0, wandb)
165
+
166
+ # ========== 9. 清理分布进程 ==========
167
+ if dist.is_initialized():
168
+ dist.barrier()
169
+ dist.destroy_process_group()
src/omni/trainers/pretrain_vlm.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import time
4
+ import warnings
5
+ import datasets
6
+ import torch
7
+ import torch.distributed as dist
8
+ from contextlib import nullcontext
9
+ from torch import optim, nn
10
+ from torch.nn.parallel import DistributedDataParallel
11
+ from torch.utils.data import DataLoader, DistributedSampler
12
+ from transformers import AutoTokenizer
13
+ from omni.models import MiniMindVLM, VLMConfig
14
+ from omni.datasets.lm_dataset import VLMDataset
15
+ from omni.utils import get_lr, Logger, is_main_process, init_distributed_mode, setup_seed, init_vlm_model, vlm_checkpoint, SkipBatchSampler, vlm_collate_fn
16
+
17
+ warnings.filterwarnings('ignore')
18
+
19
+
20
+ def train_epoch(epoch, loader, iters, start_step=0, wandb=None):
21
+ start_time = time.time()
22
+ last_step = start_step
23
+ for step, (input_ids, labels, pixel_values) in enumerate(loader, start=start_step + 1):
24
+ input_ids = input_ids.to(args.device)
25
+ labels = labels.to(args.device)
26
+ pixel_values = {k: v.to(args.device) for k, v in pixel_values.items()} if isinstance(pixel_values, dict) else pixel_values.to(args.device)
27
+ last_step = step
28
+ lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
29
+ for param_group in optimizer.param_groups:
30
+ param_group['lr'] = lr
31
+
32
+ with autocast_ctx:
33
+ res = model(input_ids, labels=labels, pixel_values=pixel_values)
34
+ loss = res.loss + res.aux_loss
35
+ loss = loss / args.accumulation_steps
36
+
37
+ scaler.scale(loss).backward()
38
+
39
+ if step % args.accumulation_steps == 0:
40
+ scaler.unscale_(optimizer)
41
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
42
+
43
+ scaler.step(optimizer)
44
+ scaler.update()
45
+
46
+ optimizer.zero_grad(set_to_none=True)
47
+
48
+ if step % args.log_interval == 0 or step == iters:
49
+ spend_time = time.time() - start_time
50
+ current_loss = loss.item() * args.accumulation_steps
51
+ current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0
52
+ current_logits_loss = current_loss - current_aux_loss
53
+ current_lr = optimizer.param_groups[-1]['lr']
54
+ eta_min = spend_time / max(step - start_step, 1) * (iters - step) // 60
55
+ Logger(f'Epoch:[{epoch + 1}/{args.epochs}]({step}/{iters}), loss: {current_loss:.4f}, logits_loss: {current_logits_loss:.4f}, aux_loss: {current_aux_loss:.4f}, lr: {current_lr:.8f}, epoch_time: {eta_min:.1f}min')
56
+ if wandb: wandb.log({"loss": current_loss, "logits_loss": current_logits_loss, "aux_loss": current_aux_loss, "learning_rate": current_lr, "epoch_time": eta_min})
57
+
58
+ if (step % args.save_interval == 0 or step == iters) and is_main_process():
59
+ model.eval()
60
+ moe_suffix = '_moe' if vlm_config.use_moe else ''
61
+ ckp = f'{args.save_dir}/{args.save_weight}_{vlm_config.hidden_size}{moe_suffix}.pth'
62
+ raw_model = model.module if isinstance(model, DistributedDataParallel) else model
63
+ raw_model = getattr(raw_model, '_orig_mod', raw_model)
64
+ state_dict = raw_model.state_dict()
65
+ clean_state_dict = {
66
+ key: value for key, value in state_dict.items() if not key.startswith('vision_encoder.')
67
+ }
68
+ clean_state_dict = {k: v.half().cpu() for k, v in clean_state_dict.items()} # 半精度保存并移到CPU
69
+ torch.save(clean_state_dict, ckp)
70
+ vlm_checkpoint(vlm_config, weight=args.save_weight, model=model, optimizer=optimizer,
71
+ epoch=epoch, step=step, wandb=wandb, save_dir='../checkpoints', scaler=scaler)
72
+ model.train()
73
+ del state_dict, clean_state_dict
74
+
75
+ del input_ids, labels, pixel_values, res, loss
76
+
77
+ if last_step > start_step and last_step % args.accumulation_steps != 0:
78
+ scaler.unscale_(optimizer)
79
+ torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
80
+ scaler.step(optimizer)
81
+ scaler.update()
82
+ optimizer.zero_grad(set_to_none=True)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ parser = argparse.ArgumentParser(description="MiniMind-V Pretrain")
87
+ parser.add_argument("--save_dir", type=str, default="../out", help="模型保存目录")
88
+ parser.add_argument('--save_weight', default='pretrain_vlm', type=str, help="保存权重的前缀名")
89
+ parser.add_argument("--epochs", type=int, default=2, help="训练轮数")
90
+ parser.add_argument("--batch_size", type=int, default=16, help="batch size")
91
+ parser.add_argument("--learning_rate", type=float, default=4e-4, help="初始学习率")
92
+ parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="训练设备")
93
+ parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型")
94
+ parser.add_argument("--num_workers", type=int, default=2, help="数据加载线程数")
95
+ parser.add_argument("--accumulation_steps", type=int, default=1, help="梯度��积步数")
96
+ parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值")
97
+ parser.add_argument("--log_interval", type=int, default=100, help="日志打印间隔")
98
+ parser.add_argument("--save_interval", type=int, default=1000, help="模型保存间隔")
99
+ parser.add_argument('--hidden_size', default=768, type=int, help="隐藏层维度")
100
+ parser.add_argument('--num_hidden_layers', default=8, type=int, help="隐藏层数量")
101
+ parser.add_argument('--max_seq_len', default=450, type=int, help="训练的最大截断长度")
102
+ parser.add_argument('--use_moe', default=0, type=int, choices=[0, 1], help="是否使用MoE架构(0=否,1=是)")
103
+ parser.add_argument("--data_path", type=str, default="../dataset/pretrain_i2t.parquet", help="训练数据路径")
104
+ parser.add_argument('--from_weight', default='llm', type=str, help="基于哪个权重训练,为none则不基于任何权重训练")
105
+ parser.add_argument('--from_resume', default=0, type=int, choices=[0, 1], help="是否自动检测&续训(0=否,1=是)")
106
+ parser.add_argument('--freeze_llm', default=2, type=int, choices=[0, 1, 2], help="冻结策略(0=完全可训练,1=冻结+解冻首尾层,2=完全冻结仅训练proj)")
107
+ parser.add_argument("--use_compile", default=0, type=int, choices=[0, 1], help="是否使用torch.compile加速(0=否,1=是)")
108
+ parser.add_argument("--use_wandb", action="store_true", help="是否使用wandb")
109
+ parser.add_argument("--wandb_project", type=str, default="MiniMind-V-Pretrain", help="wandb项目名")
110
+ args = parser.parse_args()
111
+
112
+ # ========== 1. 初始化环境和随机种子 ==========
113
+ local_rank = init_distributed_mode()
114
+ if dist.is_initialized(): args.device = f"cuda:{local_rank}"
115
+ setup_seed(42 + (dist.get_rank() if dist.is_initialized() else 0))
116
+
117
+ # ========== 2. 配置目录、模型参数、检查ckp ==========
118
+ os.makedirs(args.save_dir, exist_ok=True)
119
+ vlm_config = VLMConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, max_seq_len=args.max_seq_len, use_moe=bool(args.use_moe))
120
+ ckp_data = vlm_checkpoint(vlm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None
121
+
122
+ # ========== 3. 设置混合精度 ==========
123
+ device_type = "cuda" if "cuda" in args.device else "cpu"
124
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
125
+ autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype)
126
+
127
+ # ========== 4. 配wandb ==========
128
+ wandb = None
129
+ if args.use_wandb and is_main_process():
130
+ import swanlab as wandb
131
+ wandb_id = ckp_data.get('wandb_id') if ckp_data else None
132
+ resume = 'must' if wandb_id else None
133
+ wandb_run_name = f"MiniMind-V-Pretrain-Epoch-{args.epochs}-BatchSize-{args.batch_size}-LearningRate-{args.learning_rate}"
134
+ wandb.init(project=args.wandb_project, name=wandb_run_name, id=wandb_id, resume=resume)
135
+
136
+ # ========== 5. 定义模型、数据、优化器 ==========
137
+ model, tokenizer = init_vlm_model(vlm_config, from_weight=args.from_weight, device=args.device, freeze_llm=args.freeze_llm)
138
+ preprocess = model.vision_encoder.processor
139
+ train_ds = VLMDataset(args.data_path, tokenizer, preprocess=preprocess, image_special_token=vlm_config.image_special_token, image_token_len=vlm_config.image_token_len, max_length=vlm_config.max_seq_len)
140
+ train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None
141
+ scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16'))
142
+ optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=args.learning_rate)
143
+
144
+ # ========== 6. 从ckp恢复状态 ==========
145
+ start_epoch, start_step = 0, 0
146
+ if ckp_data:
147
+ model.load_state_dict(ckp_data['model'], strict=False)
148
+ optimizer.load_state_dict(ckp_data['optimizer'])
149
+ scaler.load_state_dict(ckp_data['scaler'])
150
+ start_epoch = ckp_data['epoch']
151
+ start_step = ckp_data.get('step', 0)
152
+
153
+ # ========== 7. 编译和分布式包装 ==========
154
+ if args.use_compile == 1:
155
+ model = torch.compile(model)
156
+ Logger('torch.compile enabled')
157
+ if dist.is_initialized():
158
+ model._ddp_params_and_buffers_to_ignore = {"freqs_cos", "freqs_sin"}
159
+ model = DistributedDataParallel(model, device_ids=[local_rank])
160
+
161
+ # ========== 8. 开始训练 ==========
162
+ for epoch in range(start_epoch, args.epochs):
163
+ train_sampler and train_sampler.set_epoch(epoch)
164
+ setup_seed(42 + epoch); indices = torch.randperm(len(train_ds)).tolist()
165
+ skip = start_step if (epoch == start_epoch and start_step > 0) else 0
166
+ batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
167
+ loader = DataLoader(train_ds, batch_sampler=batch_sampler, num_workers=args.num_workers, pin_memory=True, collate_fn=vlm_collate_fn)
168
+ if skip > 0:
169
+ Logger(f'Epoch [{epoch + 1}/{args.epochs}]: 跳过前{start_step}个step,从step {start_step + 1}开始')
170
+ train_epoch(epoch, loader, len(loader) + skip, start_step, wandb)
171
+ else:
172
+ train_epoch(epoch, loader, len(loader), 0, wandb)
173
+
174
+ # ========== 9. 清理分布进程 ==========
175
+ if dist.is_initialized(): dist.destroy_process_group()
src/omni/trainers/rollout_engine.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rollout engines for RL training.
2
+
3
+ To use the SGLang backend, first launch the server (with a transformers-format model):
4
+ python -m sglang.launch_server --model-path ./minimind-3 --attention-backend triton --host 0.0.0.0 --port 8998
5
+ """
6
+ import os
7
+ import requests
8
+ import torch
9
+ import torch.distributed as dist
10
+ from abc import ABC, abstractmethod
11
+ from contextlib import nullcontext
12
+ from dataclasses import dataclass
13
+ from typing import List, Optional, Tuple
14
+ from torch import Tensor
15
+ from torch.nn.parallel import DistributedDataParallel
16
+ from transformers import AutoTokenizer
17
+
18
+
19
+ def compute_per_token_logps(model, input_ids: Tensor, n_keep: int, attention_mask: Optional[Tensor] = None) -> Tensor:
20
+ if n_keep <= 0:
21
+ return input_ids.new_empty((input_ids.size(0), 0), dtype=torch.float32)
22
+ unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
23
+ input_ids = input_ids.detach().clone() if input_ids.is_inference() else input_ids
24
+ logits = unwrapped(input_ids, attention_mask=attention_mask, logits_to_keep=n_keep + 1).logits[:, :-1, :]
25
+ per_token_logps = []
26
+ for logits_row, ids_row in zip(logits, input_ids[:, -n_keep:]):
27
+ ids_row = ids_row.detach().clone() if ids_row.is_inference() else ids_row
28
+ per_token_logps.append(
29
+ torch.gather(logits_row.log_softmax(dim=-1), 1, ids_row.unsqueeze(1)).squeeze(1)
30
+ )
31
+ return torch.stack(per_token_logps)
32
+
33
+
34
+ @dataclass
35
+ class RolloutResult:
36
+ output_ids: Tensor
37
+ completion_ids: Tensor
38
+ per_token_logps: Tensor
39
+ completions: List[str]
40
+ prompt_lens: Tensor
41
+ completion_mask: Tensor
42
+
43
+
44
+ class RolloutEngine(ABC):
45
+ tokenizer = None
46
+
47
+ @abstractmethod
48
+ def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
49
+ pass
50
+
51
+ @abstractmethod
52
+ def update_policy(self, model: torch.nn.Module):
53
+ pass
54
+
55
+
56
+ class TorchRolloutEngine(RolloutEngine):
57
+ def __init__(self, policy_model: torch.nn.Module, tokenizer, device: str = "cuda", autocast_ctx=None):
58
+ self.policy_model = policy_model
59
+ self.tokenizer = tokenizer
60
+ self.device = device
61
+ self.autocast_ctx = autocast_ctx
62
+
63
+ def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
64
+ model = self.policy_model.module if isinstance(self.policy_model, DistributedDataParallel) else self.policy_model
65
+ ctx = self.autocast_ctx if self.autocast_ctx else nullcontext()
66
+ with torch.no_grad(), ctx:
67
+ output_ids = model.generate(
68
+ input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
69
+ attention_mask=attention_mask.repeat_interleave(num_generations, dim=0),
70
+ max_new_tokens=max_new_tokens,
71
+ do_sample=True,
72
+ temperature=temperature,
73
+ num_return_sequences=1,
74
+ pad_token_id=self.tokenizer.pad_token_id,
75
+ eos_token_id=self.tokenizer.eos_token_id,
76
+ ).clone()
77
+ prompt_len = prompt_ids.size(1)
78
+ completion_ids = output_ids[:, prompt_len:]
79
+ full_mask = (output_ids != self.tokenizer.pad_token_id).long()
80
+ per_token_logps = compute_per_token_logps(self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask)
81
+ completions = self.tokenizer.batch_decode(completion_ids, skip_special_tokens=True)
82
+ return RolloutResult(output_ids, completion_ids, per_token_logps, completions,
83
+ prompt_ids.new_full((output_ids.size(0),), prompt_len),
84
+ attention_mask.new_ones(output_ids.size(0), completion_ids.size(1)))
85
+
86
+ def update_policy(self, model: torch.nn.Module):
87
+ self.policy_model = model
88
+
89
+
90
+ class SGLangRolloutEngine(RolloutEngine):
91
+ def __init__(self, base_url: str, model_path: str, shared_ckpt_path: str = "./sglang_ckpt", timeout: int = 120):
92
+ self.base_url = base_url.rstrip('/')
93
+ self.shared_ckpt_path = shared_ckpt_path
94
+ self.timeout = timeout
95
+ self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
96
+ self.http = requests
97
+
98
+ def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
99
+ input_ids_list = []
100
+ for ids, mask in zip(prompt_ids, attention_mask):
101
+ valid_ids = ids[mask.bool()].tolist()
102
+ input_ids_list.append(valid_ids)
103
+ all_input_ids = [ids for ids in input_ids_list for _ in range(num_generations)]
104
+
105
+ payload = {
106
+ "input_ids": all_input_ids,
107
+ "sampling_params": {
108
+ "temperature": temperature,
109
+ "max_new_tokens": max_new_tokens,
110
+ "stop_token_ids": [self.tokenizer.eos_token_id] if self.tokenizer.eos_token_id else [],
111
+ },
112
+ "return_logprob": True,
113
+ }
114
+
115
+ resp = self.http.post(f"{self.base_url}/generate", json=payload, timeout=self.timeout)
116
+ resp.raise_for_status()
117
+
118
+ results = resp.json()
119
+ if not isinstance(results, list):
120
+ results = [results]
121
+
122
+ all_output_ids, all_completion_ids, all_logprobs = [], [], []
123
+ completions = []
124
+
125
+ for i, result in enumerate(results):
126
+ meta = result.get("meta_info", {})
127
+ completion_ids = meta.get("output_ids", result.get("output_ids", []))
128
+ raw_logprobs = meta.get("output_token_logprobs", [])
129
+
130
+ logprobs = []
131
+ for item in raw_logprobs:
132
+ if isinstance(item, (list, tuple)) and len(item) >= 1:
133
+ logprobs.append(item[0])
134
+ elif isinstance(item, (int, float)):
135
+ logprobs.append(item)
136
+
137
+ if len(logprobs) < len(completion_ids):
138
+ logprobs = [0.0] * (len(completion_ids) - len(logprobs)) + logprobs
139
+ elif len(logprobs) > len(completion_ids):
140
+ logprobs = logprobs[-len(completion_ids):] if completion_ids else []
141
+ prompt = all_input_ids[i]
142
+ full_output = prompt + completion_ids
143
+ all_output_ids.append(full_output)
144
+ all_completion_ids.append(completion_ids)
145
+ all_logprobs.append(logprobs)
146
+ completions.append(self.tokenizer.decode(completion_ids, skip_special_tokens=True))
147
+
148
+ device = prompt_ids.device
149
+ max_comp_len = max(1, max(len(ids) for ids in all_completion_ids))
150
+ max_out_len = max(len(ids) for ids in all_input_ids) + max_comp_len
151
+
152
+ def pad_to_tensor(seqs, max_len, pad_val=0):
153
+ return torch.tensor([s + [pad_val] * (max_len - len(s)) for s in seqs], device=device)
154
+
155
+ pad_id = self.tokenizer.pad_token_id
156
+ return RolloutResult(
157
+ output_ids=pad_to_tensor(all_output_ids, max_out_len, pad_val=pad_id),
158
+ completion_ids=pad_to_tensor(all_completion_ids, max_comp_len, pad_val=pad_id),
159
+ per_token_logps=pad_to_tensor(all_logprobs, max_comp_len, pad_val=0.0),
160
+ completions=completions,
161
+ prompt_lens=torch.tensor([len(ids) for ids in all_input_ids], device=device),
162
+ completion_mask=torch.tensor([[1] * len(ids) + [0] * (max_comp_len - len(ids)) for ids in all_completion_ids], device=device),
163
+ )
164
+
165
+ def update_policy(self, model: torch.nn.Module):
166
+ ok = True
167
+ if not dist.is_initialized() or dist.get_rank() == 0:
168
+ try:
169
+ unwrapped = model.module if isinstance(model, DistributedDataParallel) else model
170
+ unwrapped = getattr(unwrapped, '_orig_mod', unwrapped)
171
+ abs_path = os.path.abspath(self.shared_ckpt_path)
172
+ state_dict = {k: v.detach().half().cpu() for k, v in unwrapped.state_dict().items()}
173
+ unwrapped.save_pretrained(abs_path, state_dict=state_dict, safe_serialization=False)
174
+ self.tokenizer.save_pretrained(abs_path)
175
+ resp = self.http.post(f"{self.base_url}/update_weights_from_disk", json={"model_path": abs_path}, timeout=self.timeout)
176
+ if resp.status_code != 200:
177
+ print(f"[SGLANG WARNING] update_weights 失败: {resp.status_code}, {resp.text}")
178
+ ok = resp.status_code == 200
179
+ except Exception as e:
180
+ print(f"[SGLANG WARNING] update_weights 异常: {e}")
181
+ ok = False
182
+ if dist.is_initialized():
183
+ ok_t = torch.tensor(int(ok), device=next(model.parameters()).device)
184
+ dist.broadcast(ok_t, src=0)
185
+ dist.barrier()
186
+ ok = bool(ok_t.item())
187
+ if not ok:
188
+ raise RuntimeError("SGLang update_policy failed")
189
+ return ok
190
+
191
+ def flush_cache(self) -> bool:
192
+ resp = self.http.post(f"{self.base_url}/flush_cache", timeout=30)
193
+ return resp.status_code == 200
194
+
195
+ def health(self) -> bool:
196
+ try:
197
+ resp = self.http.get(f"{self.base_url}/health", timeout=5)
198
+ return resp.status_code == 200
199
+ except Exception:
200
+ return False
201
+
202
+
203
+ def create_rollout_engine(
204
+ engine_type: str = "torch",
205
+ policy_model: torch.nn.Module = None,
206
+ tokenizer=None,
207
+ device: str = "cuda",
208
+ autocast_ctx=None,
209
+ sglang_base_url: str = None,
210
+ sglang_model_path: str = None,
211
+ sglang_shared_path: str = None,
212
+ ) -> RolloutEngine:
213
+ if engine_type == "torch":
214
+ return TorchRolloutEngine(policy_model, tokenizer, device, autocast_ctx)
215
+ elif engine_type == "sglang":
216
+ return SGLangRolloutEngine(sglang_base_url, sglang_model_path, sglang_shared_path)
217
+ else:
218
+ raise ValueError(f"不支持的引擎类型: {engine_type}")
src/omni/trainers/train_tokenizer.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 注:不建议再重复训练tokenizer(“词典”),MiniMind已自带,此脚本仅供学习和参考。基于不同词典训练的模型将导致输出完全不统一,降低社区的模型复用性
2
+ # Note: It is not recommended to re-train the tokenizer. MiniMind already includes one. This script is for learning and reference only. Training models with different tokenizers will lead to inconsistent outputs and reduce model reusability in the community.
3
+ import os
4
+ import json
5
+ from tokenizers import decoders, models, pre_tokenizers, trainers, Tokenizer
6
+
7
+ DATA_PATH = '../dataset/sft_t2t_mini.jsonl'
8
+ TOKENIZER_DIR = '../model_learn_tokenizer/'
9
+ VOCAB_SIZE = 6400
10
+ SPECIAL_TOKENS_NUM = 36
11
+
12
+ def get_texts(data_path):
13
+ with open(data_path, 'r', encoding='utf-8', errors='ignore') as f:
14
+ for i, line in enumerate(f):
15
+ if i >= 10000: break # 选10000行测试
16
+ try:
17
+ data = json.loads(line)
18
+ contents = [item.get('content') for item in data.get('conversations', []) if item.get('content')]
19
+ if contents:
20
+ yield "\n".join(contents)
21
+ except json.JSONDecodeError:
22
+ continue
23
+
24
+ def train_tokenizer(data_path, tokenizer_dir, vocab_size, special_tokens_num=SPECIAL_TOKENS_NUM):
25
+ tokenizer = Tokenizer(models.BPE())
26
+ tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
27
+
28
+ special_tokens_list = [
29
+ "<|endoftext|>", "<|im_start|>", "<|im_end|>",
30
+ "<|object_ref_start|>", "<|object_ref_end|>", "<|box_start|>", "<|box_end|>", "<|quad_start|>", "<|quad_end|>",
31
+ "<|vision_start|>", "<|vision_end|>", "<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
32
+ "<|audio_start|>", "<|audio_end|>", "<|audio_pad|>", "<tts_pad>", "<tts_text_bos>", "<tts_text_eod>", "<tts_text_bos_single>"
33
+ ]
34
+
35
+ additional_tokens_list = [
36
+ "<tool_call>", "</tool_call>",
37
+ "<tool_response>", "</tool_response>",
38
+ "<think>", "</think>"
39
+ ]
40
+ num_buffer = special_tokens_num - len(special_tokens_list + additional_tokens_list)
41
+ buffer_tokens = [f"<|buffer{i}|>" for i in range(1, num_buffer + 1)] # 预留一定数量的token位置
42
+ all_special_tokens = special_tokens_list + additional_tokens_list + buffer_tokens
43
+ trainer = trainers.BpeTrainer(
44
+ vocab_size=vocab_size,
45
+ show_progress=True,
46
+ initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
47
+ special_tokens=all_special_tokens
48
+ )
49
+ texts = get_texts(data_path)
50
+ tokenizer.train_from_iterator(texts, trainer=trainer)
51
+ tokenizer.decoder = decoders.ByteLevel()
52
+ tokenizer.add_special_tokens(special_tokens_list)
53
+
54
+ os.makedirs(tokenizer_dir, exist_ok=True)
55
+ tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
56
+ tokenizer.model.save(tokenizer_dir)
57
+ tokenizer_json_path = os.path.join(tokenizer_dir, "tokenizer.json")
58
+ with open(tokenizer_json_path, 'r', encoding='utf-8') as f:
59
+ tokenizer_data = json.load(f)
60
+ for token_info in tokenizer_data.get('added_tokens', []):
61
+ if token_info['content'] not in special_tokens_list:
62
+ token_info['special'] = False
63
+ with open(tokenizer_json_path, 'w', encoding='utf-8') as f:
64
+ json.dump(tokenizer_data, f, ensure_ascii=False, indent=2)
65
+
66
+ added_tokens_decoder = {}
67
+ for i, token in enumerate(all_special_tokens):
68
+ idx = tokenizer.token_to_id(token)
69
+ added_tokens_decoder[str(idx)] = {
70
+ "content": token,
71
+ "lstrip": False,
72
+ "normalized": False,
73
+ "rstrip": False,
74
+ "single_word": False,
75
+ "special": True if token in special_tokens_list else False
76
+ }
77
+
78
+ config = {
79
+ "add_bos_token": False,
80
+ "add_eos_token": False,
81
+ "add_prefix_space": False,
82
+ "added_tokens_decoder": added_tokens_decoder,
83
+ "additional_special_tokens": [t for t in special_tokens_list if t not in ["<|endoftext|>"]],
84
+ "bos_token": "<|im_start|>",
85
+ "clean_up_tokenization_spaces": False,
86
+ "eos_token": "<|im_end|>",
87
+ "legacy": True,
88
+ "model_max_length": 131072,
89
+ "pad_token": "<|endoftext|>",
90
+ "sp_model_kwargs": {},
91
+ "spaces_between_special_tokens": False,
92
+ "unk_token": "<|endoftext|>",
93
+ "image_token": "<|image_pad|>",
94
+ "audio_token": "<|audio_pad|>",
95
+ "video_token": "<|video_pad|>",
96
+ "vision_bos_token": "<|vision_start|>",
97
+ "vision_eos_token": "<|vision_end|>",
98
+ "audio_bos_token": "<|audio_start|>",
99
+ "audio_eos_token": "<|audio_end|>",
100
+ "chat_template": "{%- if tools %}\n {{- '<|im_start|>system\\n' }}\n {%- if messages[0].role == 'system' %}\n {{- messages[0].content + '\\n\\n' }}\n {%- endif %}\n {{- \"# Tools\\n\\nYou may call one or more functions to assist with the user query.\\n\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\\n\\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\\n<tool_call>\\n{\\\"name\\\": <function-name>, \\\"arguments\\\": <args-json-object>}\\n</tool_call><|im_end|>\\n\" }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {{- '<|im_start|>system\\n' + messages[0].content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n {%- set index = (messages|length - 1) - loop.index0 %}\n {%- if ns.multi_step_tool and message.role == \"user\" and message.content is string and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}\n {%- set ns.multi_step_tool = false %}\n {%- set ns.last_query_index = index %}\n {%- endif %}\n{%- endfor %}\n{%- for message in messages %}\n {%- if message.content is string %}\n {%- set content = message.content %}\n {%- else %}\n {%- set content = '' %}\n {%- endif %}\n {%- if (message.role == \"user\") or (message.role == \"system\" and not loop.first) %}\n {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {%- set reasoning_content = '' %}\n {%- if message.reasoning_content is string %}\n {%- set reasoning_content = message.reasoning_content %}\n {%- else %}\n {%- if '</think>' in content %}\n {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n {%- endif %}\n {%- endif %}\n {%- if true %}\n {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content.strip('\\n') + '\\n</think>\\n\\n' + content.lstrip('\\n') }}\n {%- endif %}\n {%- if message.tool_calls %}\n {%- for tool_call in message.tool_calls %}\n {%- if (loop.first and content) or (not loop.first) %}\n {{- '\\n' }}\n {%- endif %}\n {%- if tool_call.function %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {{- '<tool_call>\\n{\"name\": \"' }}\n {{- tool_call.name }}\n {{- '\", \"arguments\": ' }}\n {%- if tool_call.arguments is string %}\n {{- tool_call.arguments }}\n {%- else %}\n {{- tool_call.arguments | tojson }}\n {%- endif %}\n {{- '}\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.first or (messages[loop.index0 - 1].role != \"tool\") %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- content }}\n {{- '\\n</tool_response>' }}\n {%- if loop.last or (messages[loop.index0 + 1].role != \"tool\") %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n {%- if open_thinking is defined and open_thinking is true %}\n {{- '<think>\\n' }}\n {%- else %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}\n{%- endif %}",
101
+ "tokenizer_class": "PreTrainedTokenizerFast"
102
+ }
103
+
104
+ with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w", encoding="utf-8") as f:
105
+ json.dump(config, f, ensure_ascii=False, indent=4)
106
+ print("Tokenizer training completed.")
107
+
108
+ def eval_tokenizer(tokenizer_dir):
109
+ from transformers import AutoTokenizer
110
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir)
111
+ messages = [
112
+ {"role": "system", "content": "你是一个优秀的聊天机器人,总是给我正确的回应!"},
113
+ {"role": "user", "content": '你来自哪里?'},
114
+ {"role": "assistant", "content": '我来自月球'},
115
+ {"role": "user", "content": '你到底来自哪里?'},
116
+ {"role": "assistant", "content": '我来自地球'}
117
+ ]
118
+ new_prompt = tokenizer.apply_chat_template(
119
+ messages,
120
+ tokenize=False
121
+ )
122
+ print('-'*100)
123
+ print(new_prompt)
124
+ print('-'*100)
125
+ print('tokenizer词表长度:', len(tokenizer))
126
+ model_inputs = tokenizer(new_prompt)
127
+ print('encoder长度:', len(model_inputs['input_ids']))
128
+ response = tokenizer.decode(model_inputs['input_ids'], skip_special_tokens=False)
129
+ print('decoder一致性:', response == new_prompt, "\n")
130
+ print('-'*100)
131
+ print('压缩率测试(Chars/Tokens):')
132
+ test_texts = [
133
+ # 中文样本 (约200字)
134
+ "人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器,该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。人工智能从诞生以来,理论和技术日益成熟,应用领域也不断扩大,可以设想,未来人工智能带来的科技产品,将会是人类智慧的“容器”。人工智能可以对人的意识、思维的信息过程的模拟。人工智能不是人的智能,但能像人那样思考、也可能超过人的智能。",
135
+ "星际航行是指在星系内甚至星系间的空间中进行的航行。由于宇宙空间极其广阔,传统的化学火箭动力在恒星间航行时显得力不从心。科学家们提出了多种方案,包括离子推进器、核热火箭、甚至是利用反物质作为能源的设想。此外,曲率驱动和虫洞旅行等科幻概念也在理论物理研究中被反复探讨。尽管目前人类的足迹仅限于月球,但随着核聚变技术和材料科学的突破,前往火星乃至更遥远的太阳系边缘将成为可能。",
136
+ # 英文样本 (约200词/字符)
137
+ "Large language models (LLMs) are a type of artificial intelligence (AI) trained on vast amounts of text data to understand and generate human-like language. These models use deep learning techniques, specifically transformers, to process and predict the next word in a sequence. LLMs like GPT-4, Llama, and Claude have demonstrated remarkable capabilities in coding, translation, and creative writing. However, they also face challenges such as hallucinations, where the model generates factually incorrect information, and the need for significant computational resources.",
138
+ "The development of sustainable energy is crucial for the future of our planet. As climate change continues to impact global weather patterns, transitioning from fossil fuels to renewable sources like solar, wind, and hydroelectric power has become an urgent priority. Innovations in battery storage technology and smart grid management are essential to ensure a reliable energy supply. International cooperation and policy frameworks are also necessary to drive the global shift towards a greener economy and reduce carbon emissions.",
139
+ # 混合样本
140
+ "Python 是一种高级编程语言,以其简洁的语法和强大的生态系统而闻名。It is widely used in data science, machine learning, and web development. 开发者可以利用 NumPy, Pandas, and PyTorch 等库快速构建复杂的应用。学习 Python 的过程非常愉快,因为它的代码读起来就像英语一样。Whether you are a beginner or an expert, Python offers something for everyone.",
141
+ ]
142
+
143
+ total_compression = 0
144
+ for i, text in enumerate(test_texts):
145
+ encoded = tokenizer.encode(text)
146
+ token_count = len(encoded)
147
+ char_count = len(text)
148
+ compression_ratio = char_count / token_count
149
+ total_compression += compression_ratio
150
+ print(f"样本 {i+1} | 字符数: {char_count:4} | Tokens: {token_count:3} | 压缩率: {compression_ratio:.2f}")
151
+
152
+ print(f"平均压缩率: {total_compression / len(test_texts):.2f}")
153
+ print('-'*100)
154
+ print('流式解码(字节缓冲)测试:')
155
+ input_ids = model_inputs['input_ids']
156
+ token_cache = []
157
+ for tid in input_ids:
158
+ token_cache.append(tid)
159
+ current_decode = tokenizer.decode(token_cache)
160
+ if current_decode and '\ufffd' not in current_decode:
161
+ display_ids = token_cache[0] if len(token_cache) == 1 else token_cache
162
+ raw_tokens = [tokenizer.convert_ids_to_tokens(int(t)) for t in (token_cache if isinstance(token_cache, list) else [token_cache])]
163
+ print(f'Token ID: {str(display_ids):15} -> Raw: {str(raw_tokens):20} -> Decode Str: {current_decode}')
164
+ token_cache = []
165
+
166
+ if __name__ == '__main__':
167
+ train_tokenizer(DATA_PATH, TOKENIZER_DIR, VOCAB_SIZE)
168
+ eval_tokenizer(TOKENIZER_DIR)