tf-bao commited on
Commit
8b9409a
·
verified ·
1 Parent(s): 4dd02ff

Upload Summer-0.5B-S0

Browse files
.gitattributes CHANGED
@@ -1,35 +1,4 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
1
  *.bin filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  *.safetensors filter=lfs diff=lfs merge=lfs -text
3
+ *.pt filter=lfs diff=lfs merge=lfs -text
4
+ *.model filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: transformers
4
+ pipeline_tag: text-generation
5
+ tags:
6
+ - qwen3
7
+ - from-scratch
8
+ - pretraining
9
+ - bilingual
10
+ - piece-tokenizer
11
+ language:
12
+ - en
13
+ - zh
14
+ ---
15
+
16
+ # Summer-0.5B-S0
17
+
18
+ Summer-0.5B-S0 is a **from-scratch** bilingual (Chinese/English) base model:
19
+ 524,336,128 parameters, trained from random initialization on
20
+ **12B tokens** with a self-trained 81,903-piece tokenizer.
21
+
22
+ It is *not* a fine-tune or a tokenizer-swap of any existing model. The
23
+ architecture follows `Qwen/Qwen3-0.6B-Base` (28 layers / hidden 1024 /
24
+ GQA 16:8 / head_dim 128 / tied embeddings / RoPE theta 1e6), but every weight
25
+ starts from `N(0, 0.02)`.
26
+
27
+ ## Stage
28
+
29
+ **S0** — monolingual only. 12B tokens, Chinese/English 50:50, 45,149 steps.
30
+ No parallel or instruction data at any point.
31
+
32
+ ## What to expect
33
+
34
+ **This is a 13B-token model.** For scale: `Qwen3-0.6B-Base` saw 36T tokens —
35
+ about 2,700x more. Treat the numbers below as what that budget buys, not as a
36
+ competitive result.
37
+
38
+ | WMT22 5-shot | BLEU | COMET |
39
+ |---|---|---|
40
+ | zh->en | 0.54 | 0.4638 |
41
+ | en->zh | 3.97 | 0.5872 |
42
+
43
+ **Few-shot translation is essentially zero, and that is the finding.** The
44
+ model ignores the in-context examples entirely — on zh->en it does not even
45
+ switch output language. Language modelling was learned; in-context learning
46
+ was not. 12B monolingual tokens is not enough for ICL to emerge at 0.5B.
47
+
48
+ Its value is as (a) the control for S1, and (b) a starting point for
49
+ mid-training / SFT.
50
+
51
+ ## Tokenizer
52
+
53
+ The tokenizer is a compiled C++ extension, **not** loadable by
54
+ `AutoTokenizer`. The release ships `tokenizer.py` and `example_load.py`:
55
+
56
+ ```bash
57
+ pip install git+https://github.com/Ismantic/PieceTokenizer
58
+ python example_load.py
59
+ ```
60
+
61
+ The model code (`model.py`, `checkpoint.py`) is bundled too — the package
62
+ depends only on `torch` plus the tokenizer extension, not on `transformers`.
63
+
64
+ ## Training
65
+
66
+ Full pipeline, data mixes and every design decision (including the mistakes)
67
+ are documented in <https://github.com/Ismantic/Summer>. Notably
68
+ `docs/WHY.md` records why fp32 master weights are mandatory, why the learning
69
+ rate schedule is WSD rather than cosine, and what the vocabulary swap cost.
70
+
71
+ ## License
72
+
73
+ Apache-2.0. Training corpora are public datasets (FineWeb-Edu, Cosmopedia,
74
+ CCI3-HQ, SkyPile, WMT19, OPUS-100 and others; see `data/source.py` upstream).
75
+ Please observe their respective licenses.
Summer-Tokenizer.dict.txt ADDED
The diff for this file is too large to render. See raw diff
 
Summer-Tokenizer.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9b81cefcaa5d47cd3aa6e653dda0a80f90b7863b3cfff790dfc07c662dda50f
3
+ size 2707732
checkpoint.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """safetensors 读写。**只依赖 torch。**
2
+
3
+ 格式很简单,自己实现比拉一个依赖划算:
4
+
5
+ [0:8] u64 小端,头部 JSON 的字节数 N
6
+ [8:8+N] JSON:{名字: {dtype, shape, data_offsets:[起, 止]}, "__metadata__": {...}}
7
+ [8+N:] 张量数据区,data_offsets 是相对这里的偏移
8
+
9
+ ## 为什么不用 safetensors 库
10
+
11
+ `src/` 只依赖 torch —— 这条约束的意义是让读者能看见每一步在做什么。
12
+ safetensors 的格式本身只有二十来行代码,藏进依赖里反而看不见了。
13
+
14
+ ## 不能改错的地方
15
+
16
+ **读进来的 key 必须与 HF 的 Qwen3ForCausalLM 完全一致。** 已发布的
17
+ `Ismantic/Qwen3-1.7B-Base-ReTok` 是标准 HF 命名,key 对不上不会报错 ——
18
+ `load_state_dict(strict=False)` 会静默跳过,模型照样随机初始化跑起来、
19
+ loss 照样降,只是那份权重白搭了。所以这里默认 `strict=True`。
20
+
21
+ **tie_word_embeddings 的模型没有 `lm_head.weight`。** v18 的 checkpoint 就是
22
+ 这样:310 个张量,只有 `model.embed_tokens.weight` 和 `model.norm.weight` 加
23
+ 28 层 ×11。加载时要把 lm_head 绑回 embed,不能指望文件里有。
24
+ """
25
+ import json
26
+ import struct
27
+ from pathlib import Path
28
+
29
+ import torch
30
+
31
+ # safetensors 的 dtype 名 → torch dtype
32
+ _DTYPES = {
33
+ "BF16": torch.bfloat16, "F16": torch.float16, "F32": torch.float32,
34
+ "F64": torch.float64, "I64": torch.int64, "I32": torch.int32,
35
+ "I16": torch.int16, "I8": torch.int8, "U8": torch.uint8,
36
+ "BOOL": torch.bool,
37
+ }
38
+ _NAMES = {v: k for k, v in _DTYPES.items()}
39
+
40
+
41
+ def read_header(path) -> tuple[dict, int]:
42
+ """返回 (头部 dict, 数据区起始偏移)。不读张量,几微秒。"""
43
+ with open(path, "rb") as f:
44
+ (n,) = struct.unpack("<Q", f.read(8))
45
+ hdr = json.loads(f.read(n))
46
+ return hdr, 8 + n
47
+
48
+
49
+ def load_safetensors(path, device="cpu") -> dict[str, torch.Tensor]:
50
+ """读成 {名字: 张量}。
51
+
52
+ 按 data_offsets 顺序 seek+read,每个张量一份可写内存。
53
+ 不用 mmap:`torch.frombuffer` 不接受只读缓冲区,套一层 `bytearray` 反正
54
+ 要拷一份,mmap 的零拷贝就没了 —— 还得处理 memoryview 挂着导出引用、
55
+ `mm.close()` 抛 BufferError 的麻烦。
56
+ """
57
+ path = Path(path)
58
+ hdr, base = read_header(path)
59
+ hdr.pop("__metadata__", None)
60
+
61
+ # 按文件内偏移排序后顺序读,避免来回 seek
62
+ items = sorted(hdr.items(), key=lambda kv: kv[1]["data_offsets"][0])
63
+ out = {}
64
+ with open(path, "rb") as f:
65
+ for name, spec in items:
66
+ lo, hi = spec["data_offsets"]
67
+ f.seek(base + lo)
68
+ buf = bytearray(hi - lo)
69
+ if f.readinto(buf) != hi - lo:
70
+ raise EOFError(f"{path}: 读 {name} 时文件提前结束")
71
+ t = torch.frombuffer(buf, dtype=_DTYPES[spec["dtype"]])
72
+ out[name] = t.reshape(spec["shape"]).to(device)
73
+ return out
74
+
75
+
76
+ def save_safetensors(tensors: dict[str, torch.Tensor], path, metadata=None) -> None:
77
+ """写出。张量按名字排序,同样的输入产生同样的字节。
78
+
79
+ 共享存储的张量(比如 tie 的 embed / lm_head)要**调用方自己去重** ——
80
+ 这里不猜。写两份不会报错,只是文件大一倍、加载时可能对不上 tie 的预期。
81
+ """
82
+ hdr, blobs, off = {}, [], 0
83
+ for name in sorted(tensors):
84
+ t = tensors[name].detach().cpu().contiguous()
85
+ if t.dtype not in _NAMES:
86
+ raise TypeError(f"{name}: 不支持的 dtype {t.dtype}")
87
+ b = t.view(torch.uint8).reshape(-1).numpy().tobytes()
88
+ hdr[name] = {"dtype": _NAMES[t.dtype], "shape": list(t.shape),
89
+ "data_offsets": [off, off + len(b)]}
90
+ blobs.append(b)
91
+ off += len(b)
92
+ if metadata:
93
+ hdr["__metadata__"] = {str(k): str(v) for k, v in metadata.items()}
94
+
95
+ raw = json.dumps(hdr, separators=(",", ":")).encode("utf-8")
96
+ # 头部按 8 字节对齐,张量数据区才能对齐
97
+ pad = (-len(raw)) % 8
98
+ raw += b" " * pad
99
+
100
+ path = Path(path)
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ with open(path, "wb") as f:
103
+ f.write(struct.pack("<Q", len(raw)))
104
+ f.write(raw)
105
+ for b in blobs:
106
+ f.write(b)
107
+
108
+
109
+ def load_sharded(model_dir, device="cpu") -> dict[str, torch.Tensor]:
110
+ """加载一个模型目录:单文件 `model.safetensors`,或分片 + index.json。"""
111
+ model_dir = Path(model_dir)
112
+ single = model_dir / "model.safetensors"
113
+ if single.exists():
114
+ return load_safetensors(single, device)
115
+
116
+ index = model_dir / "model.safetensors.index.json"
117
+ if index.exists():
118
+ shards = sorted(set(json.loads(index.read_text())["weight_map"].values()))
119
+ out = {}
120
+ for s in shards:
121
+ out.update(load_safetensors(model_dir / s, device))
122
+ return out
123
+ raise FileNotFoundError(f"{model_dir} 下既没有 model.safetensors 也没有 index.json")
config.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "vocab_size": 81903,
3
+ "hidden_size": 1024,
4
+ "intermediate_size": 3072,
5
+ "num_hidden_layers": 28,
6
+ "num_attention_heads": 16,
7
+ "num_key_value_heads": 8,
8
+ "head_dim": 128,
9
+ "rms_norm_eps": 1e-06,
10
+ "rope_theta": 1000000,
11
+ "max_position_embeddings": 32768,
12
+ "tie_word_embeddings": true,
13
+ "torch_dtype": "bfloat16",
14
+ "initializer_range": 0.02,
15
+ "model_type": "qwen3",
16
+ "architectures": [
17
+ "Qwen3ForCausalLM"
18
+ ]
19
+ }
example_load.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """跑通这个发布包 —— 只需要 torch 和 piece_tokenizer。
2
+
3
+ pip install torch
4
+ pip install git+https://github.com/Ismantic/PieceTokenizer
5
+ python example_load.py
6
+ """
7
+ import torch
8
+
9
+ from model import Qwen3ForCausalLM
10
+ from tokenizer import PieceTokenizerWrapper
11
+
12
+ HERE = "."
13
+
14
+ tok = PieceTokenizerWrapper(HERE)
15
+ model = Qwen3ForCausalLM.from_pretrained(
16
+ HERE, device="cuda" if torch.cuda.is_available() else "cpu",
17
+ dtype=torch.bfloat16)
18
+
19
+ prompt = "机器翻译的基本任务是"
20
+ ids = tok.encode(prompt, add_special_tokens=False)
21
+ print(f"{prompt!r} -> {len(ids)} tokens")
22
+
23
+ # 贪心续写 40 步。没有 KV cache —— 每步重算前缀,短续写够用。
24
+ x = torch.tensor([ids], device=next(model.parameters()).device)
25
+ out = []
26
+ with torch.no_grad():
27
+ for _ in range(40):
28
+ nxt = int(model(x)[0, -1].argmax())
29
+ if nxt == tok.eos_token_id:
30
+ break
31
+ out.append(nxt)
32
+ x = torch.cat([x, torch.tensor([[nxt]], device=x.device)], dim=1)
33
+
34
+ print(prompt + tok.decode(out))
example_vllm.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """用 vLLM 跑这个模型。
2
+
3
+ pip install vllm
4
+ pip install git+https://github.com/Ismantic/PieceTokenizer
5
+ python example_vllm.py
6
+
7
+ ## 为什么要 skip_tokenizer_init
8
+
9
+ vLLM 能加载**权重** —— config.json 里 architectures 是 Qwen3ForCausalLM,
10
+ 它用自己那份 Qwen3 实现,按 state_dict 的 key 名灌进去。
11
+
12
+ 但它**认不了这个词表**:81903 的 piece 词表不是 HF 标准格式,AutoTokenizer
13
+ 走不通。所以要 skip_tokenizer_init=True,自己编码好 token id 用 TokensPrompt
14
+ 喂进去,拿回来的 id 自己解码。
15
+
16
+ 推论:`vllm serve` 那种 OpenAI 兼容服务**开箱用不了** —— 它要把文本转成
17
+ token,而它转不了。调用方得自己传 token id。
18
+ """
19
+ import torch
20
+ from vllm import LLM, SamplingParams
21
+ from vllm.inputs import TokensPrompt
22
+
23
+ from tokenizer import PieceTokenizerWrapper
24
+
25
+ HERE = "."
26
+
27
+ tok = PieceTokenizerWrapper(HERE)
28
+ llm = LLM(model=HERE, skip_tokenizer_init=True, dtype="bfloat16",
29
+ gpu_memory_utilization=0.85, max_model_len=4096,
30
+ trust_remote_code=True)
31
+
32
+ prompts = [
33
+ "机器翻译的基本任务是",
34
+ "中文分词的目标是",
35
+ ]
36
+
37
+ outputs = llm.generate(
38
+ [TokensPrompt(prompt_token_ids=tok.encode(p, add_special_tokens=False))
39
+ for p in prompts],
40
+ SamplingParams(temperature=0.0, max_tokens=64,
41
+ stop_token_ids=[tok.eos_token_id]),
42
+ )
43
+
44
+ for prompt, out in zip(prompts, outputs):
45
+ print(f"\n{prompt}", end="")
46
+ print(tok.decode(list(out.outputs[0].token_ids)))
generation_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 1,
3
+ "eos_token_id": 2,
4
+ "pad_token_id": 81899,
5
+ "max_new_tokens": 2048
6
+ }
model.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3 稠密模型,纯 torch 实现。**只依赖 torch。**
2
+
3
+ 替掉 `transformers.Qwen3ForCausalLM`。规模参考(v18 用的 1.7B):
4
+
5
+ 28 层 / hidden 2048 / intermediate 6144 / 16 个 q 头 / 8 个 kv 头(GQA)
6
+ head_dim 128 / RMSNorm eps 1e-6 / RoPE θ=1e6 / SwiGLU / 全程无 bias
7
+ 词表 81903 / 输入输出嵌入绑权重
8
+
9
+ ## 不能改错的地方
10
+
11
+ **state_dict 的 key 必须与 HF 的 `Qwen3ForCausalLM` 完全一致。** 已发布的
12
+ `Ismantic/Qwen3-1.7B-Base-ReTok` 用的是标准 HF 命名;改模块名或嵌套层级会让
13
+ 那份权重全部失配,**而模型照样能随机初始化跑起来、loss 照样降、不报错**。
14
+ `test/test_model_equiv.py` 是这条的防线:同权重、与 transformers 逐层对齐。
15
+
16
+ 对应关系(每层 11 个张量,共 28×11+2=310):
17
+
18
+ model.embed_tokens.weight → embed_tokens
19
+ model.layers.{i}.input_layernorm.weight → layers[i].input_layernorm
20
+ model.layers.{i}.self_attn.{q,k,v,o}_proj.weight
21
+ model.layers.{i}.self_attn.{q,k}_norm.weight ← Qwen3 特有,逐头 RMSNorm
22
+ model.layers.{i}.post_attention_layernorm.weight
23
+ model.layers.{i}.mlp.{gate,up,down}_proj.weight
24
+ model.norm.weight → norm
25
+
26
+ **没有 `lm_head.weight`。** tie_word_embeddings=true,权重与 embed 共享。
27
+
28
+ **RoPE 用 half-split(NeoX)约定,不是 interleave。** 两种都"能跑",但结果
29
+ 完全不同 —— 用错了 loss 会明显偏高而不会报错。见 `_rotate_half`。
30
+
31
+ **RMSNorm 的中间计算在 float32。** 直接用 bf16 算方差会有肉眼可见的偏差,
32
+ 同样不报错。
33
+ """
34
+ import json
35
+ import math
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+ import torch
40
+ import torch.nn as nn
41
+ import torch.nn.functional as F
42
+
43
+
44
+
45
+ def _checkpoint_mod():
46
+ """`src.checkpoint`,或发布包里平铺的 `checkpoint`。
47
+
48
+ 发布包(save/releases/<repo>/)是平铺的 —— model.py 和 checkpoint.py 并列,
49
+ 没有包结构,相对 import 会 ImportError。所以两种都试。
50
+ """
51
+ try:
52
+ from . import checkpoint as _c # 仓库内:src 是 package
53
+ except ImportError: # 发布包内:平铺
54
+ import checkpoint as _c # noqa: I001
55
+ return _c
56
+
57
+
58
+ @dataclass
59
+ class Qwen3Config:
60
+ vocab_size: int = 81903
61
+ hidden_size: int = 2048
62
+ intermediate_size: int = 6144
63
+ num_hidden_layers: int = 28
64
+ num_attention_heads: int = 16
65
+ num_key_value_heads: int = 8
66
+ head_dim: int = 128
67
+ rms_norm_eps: float = 1e-6
68
+ rope_theta: float = 1_000_000.0
69
+ max_position_embeddings: int = 32768
70
+ tie_word_embeddings: bool = True
71
+ torch_dtype: str = "bfloat16"
72
+ # 随机初始化的标准差。只有从零训练(init_weights)会读它;从 checkpoint
73
+ # 加载时权重来自文件,这个值不参与。与 Qwen3 官方 config 的同名字段一致。
74
+ initializer_range: float = 0.02
75
+
76
+ @classmethod
77
+ def from_json(cls, path) -> "Qwen3Config":
78
+ raw = json.loads(Path(path).read_text())
79
+ if raw.get("model_type") not in (None, "qwen3"):
80
+ raise ValueError(f"不是 qwen3 架构:model_type={raw.get('model_type')}")
81
+ # head_dim 在有些 config 里缺省,按 hidden/heads 推
82
+ raw.setdefault("head_dim",
83
+ raw["hidden_size"] // raw["num_attention_heads"])
84
+ raw.setdefault("torch_dtype", raw.get("dtype", "bfloat16"))
85
+ fields = {f for f in cls.__dataclass_fields__}
86
+ return cls(**{k: v for k, v in raw.items() if k in fields})
87
+
88
+ @property
89
+ def dtype(self) -> torch.dtype:
90
+ return {"bfloat16": torch.bfloat16, "float16": torch.float16,
91
+ "float32": torch.float32}[self.torch_dtype]
92
+
93
+
94
+ class RMSNorm(nn.Module):
95
+ """与 `Qwen3RMSNorm` 逐位对齐:方差在 float32 里算,再转回原 dtype 乘权重。"""
96
+
97
+ def __init__(self, dim: int, eps: float):
98
+ super().__init__()
99
+ self.weight = nn.Parameter(torch.ones(dim))
100
+ self.eps = eps
101
+
102
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
103
+ dt = x.dtype
104
+ x = x.to(torch.float32)
105
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
106
+ return self.weight * x.to(dt)
107
+
108
+
109
+ class RotaryEmbedding(nn.Module):
110
+ """RoPE。inv_freq 存成 buffer(非持久化 —— 它是算出来的,不进 state_dict)。"""
111
+
112
+ def __init__(self, head_dim: int, theta: float):
113
+ super().__init__()
114
+ inv = 1.0 / (theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32)
115
+ / head_dim))
116
+ self.register_buffer("inv_freq", inv, persistent=False)
117
+
118
+ @torch.no_grad()
119
+ def forward(self, x: torch.Tensor, position_ids: torch.Tensor):
120
+ # position_ids [B,T] → freqs [B,T,head_dim/2] → emb [B,T,head_dim]
121
+ inv = self.inv_freq[None, :, None].to(x.device).float() # [1,D/2,1]
122
+ pos = position_ids[:, None, :].float() # [B,1,T]
123
+
124
+ # **必须强制 float32,不能让 autocast 把这个 matmul 降到 bf16。**
125
+ # bf16 尾数只有 8 位,整数超过 256 就不精确:257→256、1023→1024。
126
+ # 位置编码一错,长序列上的注意力全歪 —— 而且不报错,短序列还测不出来。
127
+ # 实测代价:seq_len=1024 上 next-token loss 从 2.3331 涨到 2.7605。
128
+ # HF 的 Qwen3RotaryEmbedding 里也有这条防护(注释写着 Force float32)。
129
+ dev = x.device.type if x.device.type != "mps" else "cpu"
130
+ with torch.autocast(device_type=dev, enabled=False):
131
+ freqs = (inv.float() @ pos.float()).transpose(1, 2) # [B,T,D/2]
132
+ emb = torch.cat((freqs, freqs), dim=-1) # [B,T,D]
133
+ cos, sin = emb.cos(), emb.sin()
134
+ return cos.to(x.dtype), sin.to(x.dtype)
135
+
136
+
137
+ def _rotate_half(x: torch.Tensor) -> torch.Tensor:
138
+ """half-split(NeoX)约定:后半取负拼到前面。
139
+
140
+ **不是 interleave(GPT-J)约定。** 两种都能跑通、都不报错,但结果不同。
141
+ HF 的 Qwen3 用的是这个。
142
+ """
143
+ x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2:]
144
+ return torch.cat((-x2, x1), dim=-1)
145
+
146
+
147
+ def _apply_rope(q, k, cos, sin):
148
+ cos, sin = cos.unsqueeze(1), sin.unsqueeze(1) # [B,1,T,D]
149
+ return (q * cos + _rotate_half(q) * sin,
150
+ k * cos + _rotate_half(k) * sin)
151
+
152
+
153
+ def _repeat_kv(x: torch.Tensor, n: int) -> torch.Tensor:
154
+ """GQA:把 kv 头复制 n 份对齐 q 头数。n=1 时直接返回。"""
155
+ if n == 1:
156
+ return x
157
+ b, h, t, d = x.shape
158
+ return x[:, :, None].expand(b, h, n, t, d).reshape(b, h * n, t, d)
159
+
160
+
161
+ class Attention(nn.Module):
162
+ def __init__(self, cfg: Qwen3Config):
163
+ super().__init__()
164
+ self.n_head = cfg.num_attention_heads
165
+ self.n_kv = cfg.num_key_value_heads
166
+ self.head_dim = cfg.head_dim
167
+ self.n_rep = self.n_head // self.n_kv
168
+ self.scaling = self.head_dim ** -0.5
169
+
170
+ h, d = cfg.hidden_size, cfg.head_dim
171
+ self.q_proj = nn.Linear(h, self.n_head * d, bias=False)
172
+ self.k_proj = nn.Linear(h, self.n_kv * d, bias=False)
173
+ self.v_proj = nn.Linear(h, self.n_kv * d, bias=False)
174
+ self.o_proj = nn.Linear(self.n_head * d, h, bias=False)
175
+ # Qwen3 特有:对每个头的 d 维做 RMSNorm,在 RoPE 之前
176
+ self.q_norm = RMSNorm(d, cfg.rms_norm_eps)
177
+ self.k_norm = RMSNorm(d, cfg.rms_norm_eps)
178
+
179
+ def forward(self, x, cos, sin):
180
+ b, t, _ = x.shape
181
+ # 顺序要紧:proj → view 成 [B,T,H,D] → 逐头 norm → transpose → RoPE
182
+ q = self.q_norm(self.q_proj(x).view(b, t, self.n_head, self.head_dim)).transpose(1, 2)
183
+ k = self.k_norm(self.k_proj(x).view(b, t, self.n_kv, self.head_dim)).transpose(1, 2)
184
+ v = self.v_proj(x).view(b, t, self.n_kv, self.head_dim).transpose(1, 2)
185
+
186
+ q, k = _apply_rope(q, k, cos, sin)
187
+ k, v = _repeat_kv(k, self.n_rep), _repeat_kv(v, self.n_rep)
188
+
189
+ o = F.scaled_dot_product_attention(q, k, v, is_causal=True,
190
+ scale=self.scaling)
191
+ return self.o_proj(o.transpose(1, 2).reshape(b, t, -1))
192
+
193
+
194
+ class MLP(nn.Module):
195
+ def __init__(self, cfg: Qwen3Config):
196
+ super().__init__()
197
+ h, i = cfg.hidden_size, cfg.intermediate_size
198
+ self.gate_proj = nn.Linear(h, i, bias=False)
199
+ self.up_proj = nn.Linear(h, i, bias=False)
200
+ self.down_proj = nn.Linear(i, h, bias=False)
201
+
202
+ def forward(self, x):
203
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
204
+
205
+
206
+ class DecoderLayer(nn.Module):
207
+ def __init__(self, cfg: Qwen3Config):
208
+ super().__init__()
209
+ self.self_attn = Attention(cfg)
210
+ self.mlp = MLP(cfg)
211
+ self.input_layernorm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
212
+ self.post_attention_layernorm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
213
+
214
+ def forward(self, x, cos, sin):
215
+ x = x + self.self_attn(self.input_layernorm(x), cos, sin)
216
+ return x + self.mlp(self.post_attention_layernorm(x))
217
+
218
+
219
+ class Qwen3Model(nn.Module):
220
+ """对应 HF 的 `model.*` 那一层前缀。"""
221
+
222
+ def __init__(self, cfg: Qwen3Config):
223
+ super().__init__()
224
+ self.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.hidden_size)
225
+ self.layers = nn.ModuleList(DecoderLayer(cfg) for _ in range(cfg.num_hidden_layers))
226
+ self.norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
227
+ self.rotary_emb = RotaryEmbedding(cfg.head_dim, cfg.rope_theta)
228
+ self.gradient_checkpointing = False
229
+
230
+ def forward(self, input_ids, position_ids=None):
231
+ x = self.embed_tokens(input_ids)
232
+ if position_ids is None:
233
+ position_ids = torch.arange(input_ids.shape[1],
234
+ device=input_ids.device)[None]
235
+ cos, sin = self.rotary_emb(x, position_ids)
236
+ for layer in self.layers:
237
+ if self.gradient_checkpointing and self.training:
238
+ # use_reentrant=False:新式实现,能和 requires_grad=False 的
239
+ # 冻结参数共存(Phase 1 冻结 transformer 时正是这个场景)
240
+ x = torch.utils.checkpoint.checkpoint(
241
+ layer, x, cos, sin, use_reentrant=False)
242
+ else:
243
+ x = layer(x, cos, sin)
244
+ return self.norm(x)
245
+
246
+
247
+ class Qwen3ForCausalLM(nn.Module):
248
+ def __init__(self, cfg: Qwen3Config):
249
+ super().__init__()
250
+ self.config = cfg
251
+ self.model = Qwen3Model(cfg)
252
+ self.lm_head = nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False)
253
+ if cfg.tie_word_embeddings:
254
+ self.lm_head.weight = self.model.embed_tokens.weight
255
+
256
+ def forward(self, input_ids, position_ids=None):
257
+ return self.lm_head(self.model(input_ids, position_ids))
258
+
259
+ def gradient_checkpointing_enable(self, enable: bool = True) -> None:
260
+ self.model.gradient_checkpointing = enable
261
+
262
+ @torch.no_grad()
263
+ def init_weights(self, seed: int | None = None) -> "Qwen3ForCausalLM":
264
+ """从零训练用的随机初始化。**没有这一步就只有 torch 的默认值。**
265
+
266
+ torch 的默认值对 transformer 是错的,而且错得不报错:
267
+
268
+ nn.Embedding → N(0, 1) std 比该有的 0.02 大 **50 倍**
269
+ nn.Linear → U(±1/√fan_in) 均匀分布,且 fan_in 一变标准差就变
270
+
271
+ 嵌入 std=1 意味着第一层的 RMSNorm 之前激活就是 O(1)*50,而 tie 之后
272
+ lm_head 也是同一份权重 —— 初始 logits 的量级直接决定第一步的 loss 和
273
+ 梯度。这类错**照样能训**,只是慢、抖、有时候崩,回头查不出原因。
274
+
275
+ 用的是 Llama / GPT-NeoX 那套:
276
+
277
+ 所有权重 N(0, initializer_range)
278
+ 残差出口 N(0, initializer_range / √(2L)) ← o_proj 和 down_proj
279
+ RMSNorm 全 1
280
+
281
+ 残差出口缩放是为了让 28 层累加之后的方差不随深度线性增长。少了它
282
+ 深层的激活会越叠越大,表现为训练早期的 loss spike。
283
+
284
+ tie 的模型里 `lm_head.weight` 就是 `embed_tokens.weight` 同一个张量,
285
+ `named_parameters()` 默认去重,所以只会被初始化一次 —— 不要改成
286
+ `remove_duplicate=False`,那会按两套 std 初始化同一块内存。
287
+ """
288
+ if seed is not None:
289
+ torch.manual_seed(seed)
290
+ std = self.config.initializer_range
291
+ resid_std = std / math.sqrt(2 * self.config.num_hidden_layers)
292
+ for name, p in self.named_parameters():
293
+ if name.endswith("o_proj.weight") or name.endswith("down_proj.weight"):
294
+ nn.init.normal_(p, mean=0.0, std=resid_std)
295
+ elif "norm" in name or "layernorm" in name:
296
+ nn.init.ones_(p)
297
+ else:
298
+ nn.init.normal_(p, mean=0.0, std=std)
299
+ return self
300
+
301
+ @classmethod
302
+ def from_config(cls, config_path, device="cpu", dtype=None,
303
+ seed: int | None = None) -> "Qwen3ForCausalLM":
304
+ """只读 config.json,随机初始化权重 —— 从零预训练的起点。
305
+
306
+ 与 `from_pretrained` 的区别就是不加载 checkpoint。刻意做成两个入口:
307
+ 「加载失败于是静默随机初始化」是这个项目最怕的一类错。
308
+ """
309
+ cfg = Qwen3Config.from_json(Path(config_path) / "config.json")
310
+ model = cls(cfg).to(dtype or torch.float32)
311
+ model.init_weights(seed=seed)
312
+ if cfg.tie_word_embeddings:
313
+ model.lm_head.weight = model.model.embed_tokens.weight
314
+ return model.to(device)
315
+
316
+ def save_pretrained(self, out_dir) -> None:
317
+ """存成 HF 布局:`config.json` + `model.safetensors`。
318
+
319
+ tie 的模型不写 `lm_head.weight` —— 与上游 checkpoint 一致(310 个张量)。
320
+ 分词器产物(piece.model / dict.txt / token_mapping.json)由调用方负责
321
+ 拷过来,这里不碰文本相关的东西。
322
+ """
323
+ import dataclasses
324
+
325
+ save_safetensors = _checkpoint_mod().save_safetensors
326
+
327
+ out_dir = Path(out_dir)
328
+ out_dir.mkdir(parents=True, exist_ok=True)
329
+ sd = self.state_dict_for_save()
330
+ cfg = dataclasses.asdict(self.config)
331
+ cfg["model_type"] = "qwen3"
332
+ cfg["architectures"] = ["Qwen3ForCausalLM"]
333
+ # torch_dtype 写实际存下来的精度,不写 config 里那个。
334
+ # 从零预训练的参数是 float32,而 config 从模板继承来的可能还是
335
+ # bfloat16 —— 对不上时 vLLM 按 config 说的加载,权重被悄悄转一次,
336
+ # 不报错。以文件里的张量为准才是唯一不会骗人的口径。
337
+ cfg["torch_dtype"] = str(next(iter(sd.values())).dtype).removeprefix("torch.")
338
+ (out_dir / "config.json").write_text(json.dumps(cfg, indent=2))
339
+ save_safetensors(sd, out_dir / "model.safetensors", metadata={"format": "pt"})
340
+
341
+ @classmethod
342
+ def from_pretrained(cls, model_dir, device="cpu", dtype=None):
343
+ """从 HF 格式的目录加载。key 对不上就报错,不静默跳过。"""
344
+ load_sharded = _checkpoint_mod().load_sharded
345
+
346
+ model_dir = Path(model_dir)
347
+ cfg = Qwen3Config.from_json(model_dir / "config.json")
348
+ model = cls(cfg).to(dtype or cfg.dtype)
349
+
350
+ sd = load_sharded(model_dir, device="cpu")
351
+ # tie 的 checkpoint 里没有 lm_head.weight —— 补上指向 embed 的引用,
352
+ # 这样 strict=True 才不会误报缺失
353
+ if cfg.tie_word_embeddings and "lm_head.weight" not in sd:
354
+ sd["lm_head.weight"] = sd["model.embed_tokens.weight"]
355
+ missing, unexpected = model.load_state_dict(sd, strict=False)
356
+ # 手动检查而不是 strict=True:好给出能看懂的报错
357
+ if missing or unexpected:
358
+ raise RuntimeError(
359
+ f"state_dict 对不上 {model_dir}:\n"
360
+ f" 缺少 {len(missing)} 个:{sorted(missing)[:6]}\n"
361
+ f" 多余 {len(unexpected)} 个:{sorted(unexpected)[:6]}\n"
362
+ f" 这类错**不会自己暴露** —— 权重没加载上模型照样跑。")
363
+ if cfg.tie_word_embeddings:
364
+ model.lm_head.weight = model.model.embed_tokens.weight
365
+ return model.to(device).eval()
366
+
367
+ def state_dict_for_save(self) -> dict[str, torch.Tensor]:
368
+ """导出成 HF 格式:tie 的话去掉 `lm_head.weight`,与上游 checkpoint 一致。"""
369
+ sd = self.state_dict()
370
+ if self.config.tie_word_embeddings:
371
+ sd.pop("lm_head.weight", None)
372
+ return sd
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7c081f09d27487588dd88c11e5ef8734a11ca02ae9d7427fbee36b5c3d95a63
3
+ size 1048707384
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch>=2.6
2
+ # 分词器(C++,同时提供 81903 词表):
3
+ # pip install git+https://github.com/Ismantic/PieceTokenizer
special_tokens_map.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<s>",
3
+ "eos_token": "</s>",
4
+ "unk_token": "<unk>",
5
+ "pad_token": "<pad>"
6
+ }
token_mapping.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_vocab_size": 81903,
3
+ "total_vocab_size": 81903,
4
+ "special_tokens": {
5
+ "<pad>": 81899,
6
+ "<user>": 81900,
7
+ "<assistant>": 81901,
8
+ "<system>": 81902
9
+ },
10
+ "bos_id": 1,
11
+ "eos_id": 2,
12
+ "unk_id": 0,
13
+ "pad_id": 81899,
14
+ "user_id": 81900,
15
+ "assistant_id": 81901,
16
+ "system_id": 81902
17
+ }
tokenizer.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PieceTokenizer 的 HuggingFace 风格外壳。
2
+
3
+ ## dict.txt 是必需的,不是可选的
4
+
5
+ 没有中文分词词典时,**中文的 token id 会变** —— 不只是慢。2026-07-27 实测
6
+ (同一 piece.model,带/不带 dict):
7
+
8
+ 中文 22 字 0.169ms → 1.060ms 6.3× id 序列不同(长度都是 22)
9
+ 中文 ×20 3.289ms → 21.125ms 6.4× id 序列不同(长度都是 440)
10
+ 英文 0.110ms → 0.110ms 1.0× 完全相同(dict 只管中文切分)
11
+
12
+ **round-trip 正确会掩盖这个问题**:两种模式 decode 都能还原原文,看起来没事,
13
+ 但喂给模型的 id 已经不是训练时那套了。所以这里缺 dict 直接报错,不静默降级
14
+ (旧版本是 `else: load(model_file)`,不报错 —— 那是个真隐患)。
15
+
16
+ 确实不需要 dict 的场合(比如只查 vocab_size)传 `require_dict=False`。
17
+
18
+ ## 词表从哪来
19
+
20
+ 本仓库**不存词表副本**。规范位置是 clone 的 PieceTokenizer:
21
+
22
+ deps/PieceTokenizer/save/Summer-Tokenizer.pt
23
+ deps/PieceTokenizer/save/Summer-Tokenizer.dict.txt
24
+
25
+ 用 `resolve_assets()` 反查(经 `piece_tokenizer.__file__`)。checkpoint 目录里
26
+ 另有一份 piece.model + dict.txt,与上面逐字节相同(sha256 已核),训练/评测时
27
+ 直接从 checkpoint 目录加载即可。
28
+ """
29
+ import os
30
+ import json
31
+ import piece_tokenizer as pt
32
+
33
+ PIECE_MODEL_NAME = "Summer-Tokenizer.pt"
34
+ PIECE_DICT_NAME = "Summer-Tokenizer.dict.txt"
35
+
36
+
37
+ def resolve_assets():
38
+ """从已安装的 piece_tokenizer 反查词表文件,返回 (piece_model, cn_dict)。
39
+
40
+ 本仓库不留副本,所以要经 `piece_tokenizer.__file__` 找到 clone 的仓库根,
41
+ 再取 `save/` 下那两个文件。找不到就报错 —— 报错好过静默用错词表。
42
+
43
+ `SUMMER_PIECE_MODEL` / `SUMMER_PIECE_DICT` 可以覆盖(与 BERTc 的
44
+ `BERTC_PIECE_MODEL` 同一套做法)。
45
+ """
46
+ env_m = os.environ.get("SUMMER_PIECE_MODEL")
47
+ env_d = os.environ.get("SUMMER_PIECE_DICT")
48
+ if env_m and env_d:
49
+ return env_m, env_d
50
+
51
+ root = os.path.dirname(os.path.abspath(pt.__file__))
52
+ tried = []
53
+ for cand in (root, os.path.dirname(root)):
54
+ save = os.path.join(cand, "save")
55
+ model, cn_dict = (os.path.join(save, PIECE_MODEL_NAME),
56
+ os.path.join(save, PIECE_DICT_NAME))
57
+ tried.append(save)
58
+ if os.path.exists(model) and os.path.exists(cn_dict):
59
+ return model, cn_dict
60
+ raise FileNotFoundError(
61
+ f"找不到 {PIECE_MODEL_NAME} / {PIECE_DICT_NAME}。找过:{tried}\n"
62
+ f" 先跑 `bash prepare/install_deps.sh` clone 并安装 PieceTokenizer。")
63
+
64
+
65
+ # checkpoint 目录里词表文件的名字。**上游名优先。**
66
+ #
67
+ # `Summer-Tokenizer.pt` / `.dict.txt` 与 PieceTokenizer 仓库 `save/` 下的文件
68
+ # 同名 —— 这样从任何一个 checkpoint 都能一眼看出词表出自哪里,不用靠 sha256
69
+ # 去反推(BERTc 一直是这么做的,它的发布包里就叫 `BERTc-Tokenizer.pt`)。
70
+ #
71
+ # `piece.model` / `dict.txt` 是改造之前的名字。已发布的
72
+ # `Ismantic/Qwen3-1.7B-Base-ReTok` 和 v18 的 checkpoint 用的都是旧名,所以
73
+ # **保留为回退**,不然那些目录一个都加载不了。新产出统一用上游名。
74
+ _MODEL_NAMES = (PIECE_MODEL_NAME, "piece.model", "piece_mt.model")
75
+ _DICT_NAMES = (PIECE_DICT_NAME, "dict.txt")
76
+
77
+
78
+ def _first_in(model_dir, names):
79
+ for n in names:
80
+ p = os.path.join(model_dir, n)
81
+ if os.path.exists(p):
82
+ return p
83
+ return None
84
+
85
+
86
+ def has_piece_vocab(model_dir) -> bool:
87
+ """这个目录是 piece 词表的模型吗?
88
+
89
+ 评测入口靠它决定用 PieceTokenizerWrapper 还是 AutoTokenizer。**不要各自
90
+ 硬编码文件名** —— 新产出用上游名 `Summer-Tokenizer.pt`,旧的用
91
+ `piece.model`,漏一个就会静默退回 AutoTokenizer,而 AutoTokenizer 对这个
92
+ 词表走不通,结果是错的。
93
+ """
94
+ return _first_in(model_dir, _MODEL_NAMES) is not None
95
+
96
+
97
+ class PieceTokenizerWrapper:
98
+ def __init__(self, model_dir, require_dict=True):
99
+ """从模型目录加载。
100
+
101
+ 词表文件按 `_MODEL_NAMES` / `_DICT_NAMES` 的顺序找 —— 上游名优先,
102
+ 旧名回退。
103
+ """
104
+ self._tok = pt.Tokenizer()
105
+
106
+ model_file = _first_in(model_dir, _MODEL_NAMES)
107
+ if model_file is None:
108
+ raise FileNotFoundError(
109
+ f"{model_dir} 里找不到词表。试过:{list(_MODEL_NAMES)}")
110
+
111
+ cn_dict = _first_in(model_dir, _DICT_NAMES)
112
+ if cn_dict is not None:
113
+ self._tok.load(model_file, cn_dict)
114
+ elif require_dict:
115
+ raise FileNotFoundError(
116
+ f"{model_dir} 里没有中文分词词典(试过 {list(_DICT_NAMES)})。"
117
+ f"缺了它中文的 token id 会变(不只是慢),而且 decode 照样能"
118
+ f"还原原文、不会报错。\n"
119
+ f" 从 checkpoint 或 PieceTokenizer 的 save/{PIECE_DICT_NAME} "
120
+ f"拷���份过来;确实不需要就传 require_dict=False。")
121
+ else:
122
+ self._tok.load(model_file)
123
+ self.piece_model_path = model_file
124
+ self.cn_dict_path = cn_dict
125
+
126
+ # Load token mapping
127
+ mapping_file = os.path.join(model_dir, "token_mapping.json")
128
+ if os.path.exists(mapping_file):
129
+ with open(mapping_file) as f:
130
+ mapping = json.load(f)
131
+ self.pad_token_id = mapping["pad_id"]
132
+ self.bos_token_id = mapping["bos_id"]
133
+ self.eos_token_id = mapping["eos_id"]
134
+ self.user_token_id = mapping.get("user_id")
135
+ self.assistant_token_id = mapping.get("assistant_id")
136
+ self.system_token_id = mapping.get("system_id")
137
+ else:
138
+ # Fallback to piece_to_id lookups
139
+ self.bos_token_id = self._tok.piece_to_id("<s>")
140
+ self.eos_token_id = self._tok.piece_to_id("</s>")
141
+ self.pad_token_id = self._tok.piece_to_id("<pad>")
142
+ self.user_token_id = self._tok.piece_to_id("<user>")
143
+ self.assistant_token_id = self._tok.piece_to_id("<assistant>")
144
+ self.system_token_id = self._tok.piece_to_id("<system>")
145
+ if self.pad_token_id < 0:
146
+ self.pad_token_id = 0
147
+
148
+ @property
149
+ def vocab_size(self):
150
+ return self._tok.vocab_size()
151
+
152
+ def encode(self, text, add_special_tokens=False):
153
+ ids = self._tok.encode_as_ids(text)
154
+ if add_special_tokens:
155
+ ids = [self.bos_token_id] + ids + [self.eos_token_id]
156
+ return ids
157
+
158
+ def decode(self, ids, skip_special_tokens=True):
159
+ if skip_special_tokens:
160
+ special = {self.bos_token_id, self.eos_token_id, self.pad_token_id,
161
+ self.user_token_id, self.assistant_token_id, self.system_token_id}
162
+ ids = [i for i in ids if i not in special]
163
+ try:
164
+ return self._tok.decode(ids)
165
+ except UnicodeDecodeError:
166
+ # Model emitted byte-fallback piece(s) that don't form valid UTF-8.
167
+ # Per-piece fallback: keep ids that decode cleanly, drop the rest.
168
+ parts = []
169
+ for i in ids:
170
+ try:
171
+ parts.append(self._tok.id_to_piece(i))
172
+ except UnicodeDecodeError:
173
+ continue
174
+ return "".join(parts).replace("▁", " ")
175
+
176
+ def apply_chat_template(self, messages, tokenize=True, add_generation_prompt=False, **kwargs):
177
+ """Build chat-formatted token sequence from messages."""
178
+ ids = []
179
+
180
+ # Check for system message
181
+ start = 0
182
+ if messages and messages[0]["role"] == "system":
183
+ ids.append(self.bos_token_id)
184
+ ids.extend(self._tok.encode_as_ids(messages[0]["content"]))
185
+ ids.append(self.system_token_id)
186
+ start = 1
187
+ else:
188
+ ids.append(self.bos_token_id)
189
+
190
+ for msg in messages[start:]:
191
+ if msg["role"] == "user":
192
+ ids.append(self.user_token_id)
193
+ ids.extend(self._tok.encode_as_ids(msg["content"]))
194
+ elif msg["role"] == "assistant":
195
+ ids.append(self.assistant_token_id)
196
+ ids.extend(self._tok.encode_as_ids(msg["content"]))
197
+ ids.append(self.eos_token_id)
198
+
199
+ if add_generation_prompt:
200
+ ids.append(self.assistant_token_id)
201
+
202
+ if tokenize:
203
+ return ids
204
+ else:
205
+ # Return as string (rarely needed)
206
+ return self._tok.decode(ids)
207
+
208
+ def save_pretrained(self, output_dir):
209
+ """Save tokenizer files to directory (for checkpoint saving)."""
210
+ import shutil
211
+ os.makedirs(output_dir, exist_ok=True)
212
+ # Copy piece.model
213
+ # 从自己加载时的实际路径拷,不猜文件名
214
+ if self.piece_model_path:
215
+ shutil.copy2(self.piece_model_path,
216
+ os.path.join(output_dir, PIECE_MODEL_NAME))
217
+ if self.cn_dict_path:
218
+ shutil.copy2(self.cn_dict_path,
219
+ os.path.join(output_dir, PIECE_DICT_NAME))
220
+ # Save mapping
221
+ mapping = {
222
+ "bos_id": self.bos_token_id,
223
+ "eos_id": self.eos_token_id,
224
+ "pad_id": self.pad_token_id,
225
+ "user_id": self.user_token_id,
226
+ "assistant_id": self.assistant_token_id,
227
+ "system_id": self.system_token_id,
228
+ }
229
+ with open(os.path.join(output_dir, "token_mapping.json"), "w") as f:
230
+ json.dump(mapping, f, indent=2)
tokenizer_config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "qwen3",
3
+ "bos_token": "<s>",
4
+ "eos_token": "</s>",
5
+ "unk_token": "<unk>",
6
+ "pad_token": "<pad>",
7
+ "clean_up_tokenization_spaces": false,
8
+ "tokenizer_class": "PreTrainedTokenizerFast"
9
+ }