feat(confusion-finetune): 引入拼音混淆样本生成与三路交叉注意力架构

This commit is contained in:
songsenand 2026-06-17 09:01:34 +08:00
parent 88955bcfdd
commit bfff409b35
19 changed files with 1748 additions and 63 deletions

289
DESIGN/SampleGen.md Normal file
View File

@ -0,0 +1,289 @@
# 混淆样本生成与候选词解码 (Confusion Sample Generation & Word Candidate Decoding)
> 分支: `feat/confusion-finetune`
> 包管理: [uv](https://github.com/astral-sh/uv)
---
## 1. 背景与动机
### 1.1 原架构的问题
原始训练管线中 `part4`(候选词提示)位于 Encoder 输入:
```
[CLS] part4 | part1 [SEP] part3 [SEP] → BERT Encoder → H, P
```
- **训练-推理不一致**:训练时 30% 样本包含 `part4`,推理时完全不包含
- **Encoder 不可复用**`part4` 改变会污染 prefix 的上下文编码 HONNX 拆分后 encoder 无法独立于候选词运行
### 1.2 目标
- 将 `part4` 从 Encoder 移至 Decoder实现三路 CrossAttention
- 用"拼音相似词混淆"替代随机词,增强模型区分同音/近音字的能力
- Encoder 序列长度从 128 缩减至 88移除 part4 后实际最大需求为 67 tokens
---
## 2. 架构变更
### 2.1 CrossAttention 从双路扩展为三路
```
原架构(双路):
slots_S(Q) × context_H(K,V) → text_attn
slots_S(Q) × pinyin_P(K,V) → pinyin_attn
combined = text_attn + pinyin_attn
新架构(三路):
slots_S(Q) × context_H(K,V) → text_attn
slots_S(Q) × pinyin_P(K,V) → pinyin_attn
slots_S(Q) × part4_E(K,V) → part4_attn ← 新增
combined = text_attn + pinyin_attn + part4_attn
```
每路有独立的 K/V 投影层:
- `k_text_proj` / `v_text_proj`(已有)
- `k_pinyin_proj` / `v_pinyin_proj`(已有)
- `k_part4_proj` / `v_part4_proj`(新增)
文件: `src/model/components.py``CrossAttentionFusion`
### 2.2 Part4 编码 (`_encode_part4`)
```
part4_ids [B, N, L] 例: [128, 5, 6]
→ text_emb (frozen, BERT wordpiece) → [B*N, L, dim]
→ avg_pool (over valid tokens) → [B*N, dim]
→ Linear(dim, dim) (可训练) → [B*N, dim]
→ LayerNorm(dim) → [B*N, dim]
→ reshape → [B, N, dim] = part4_E
```
关键设计决策:
- **复用 Encoder 的 BERT text embedding`text_emb`**——与 context_H 在同一语义空间
- **可训练的投影层**`part4_proj` + `part4_ln`)——允许模型自适应地将候选词映射到 CrossAttention 的有效空间
文件: `src/model/model.py``InputMethodEngine._encode_part4()`
### 2.3 Encoder 序列长度 128 → 88
| 项 | 旧值 | 新值 |
|------|------|------|
| `max_seq_len` | 128 | 88 |
| 实际最大 token 数 | part4(3词/~9t) + prefix(48) + suffix(16) + spec(3) ≈ 76 | prefix(48) + suffix(16) + spec(3) = 67 |
| pos_emb 行数 | 128 | 88 |
| Encoder 自注意力 O(L²) | 128² | 88² (53%) |
### 2.4 数据流总结
```
┌─ part4_ids ──→ text_emb → avg_pool → Linear → part4_E ─┐
│ │
encoder (冻结/训练): │ CrossAttention (三路)
input_ids ──→ H ─────────┼──→ text_attn ──────────────────────────────────────→ ─ 想
pinyin_ids ─→ P ─────────┼──→ pinyin_attn ────────────────────────────────────→ ─ 想
│ part4_attn → fused → MoE → logits
│ │
└──→ part4_E ─────────────────────────────────────────────┘
```
---
## 3. 混淆数据流水线 (新方案)
### 3.1 概述
新方案**完全在 CPU 上运行**,无需 GPU 参与模型推理。流程为两步:
```
┌──────────────────┐ ┌───────────────────────┐ ┌──────────────────────┐
│ pinyin-index │ │ preprocess-model │ │ train-model │
│ │ │ │ │ │
│ 语料分词 + │ → │ 加载 pinyin_index │ → │ 加载 .npz (含 │
│ 姓名词典 + │ │ 低频词生成混淆 part4 │ │ part4_ids) │
│ 随机连接 │ │ 保存为 .npz 分片 │ │ 从头训练 / 微调 │
└──────────────────┘ └───────────────────────┘ └──────────────────────┘
```
### 3.2 步骤 1: pinyin-index
**前置**: 将 [Chinese-Names-Corpus](https://github.com/wainshine/Chinese-Names-Corpus) 克隆到本地:
```bash
git clone https://github.com/wainshine/Chinese-Names-Corpus.git /home/songsenand/DataSet/Chinese-Names-Corpus
```
**输入**: 文本语料HuggingFace 格式)+ Chinese-Names-Corpus 路径
**输出**: `pinyin_index.msgpack`
```
流程:
1. 从 Chinese-Names-Corpus 加载词典:
- 现代人名 (120W): 1,144,226 条
- 性别标注人名 (120W): 1,144,226 条
- 古代人名 (25W): 255,268 条
- 成语 (5W): 50,372 条
- 内嵌地名 (424 个省级/地级/主要城市)
- 去重总计 ~142W 条 → 注入 jieba
2. 流式读取语料jieba 分词HMM=False, 已注词典)
3. 每个词 → 统计词频 + 计算拼音 → 加入 word_freq, word_to_pinyin
4. 连续 2~4 个单字中文词 → 50% 概率随机合并 → 补充罕见多字词
5. 过滤: 词频 ≥ 2 保留
6. pinyin_to_words: 拼音 → 词集合
7. 按拼音长度分桶 → Levenshtein 编辑距离 → 保留 sim ≥ 0.4 的 top-5 → similar_pinyin
8. 序列化为 msgpack
```
**输出格式**:
```python
{
"pinyin_to_words": {
"zhangwei": ["张伟", "章伟", "张维", ...],
...
},
"similar_pinyin": {
"zhangwei": ["zhangwei", "zangwei", "zhangwen", ...],
...
},
"word_to_pinyin": {
"张伟": "zhangwei", ...
},
"word_freq": {
"张伟": 15, "中国": 50000, ...
},
"meta": { "num_words": ..., "num_pinyins": ... }
}
```
文件: `src/model/confusion/pinyin_index.py``PinyinIndexBuilder`
**使用**:
```bash
pinyin-index build \
--corpus "path/to/hf_dataset" \
--name-corpus /home/songsenand/DataSet/Chinese-Names-Corpus \
--output ./banks/pinyin_index.msgpack \
--max-samples 10000000
```
**效果**: jieba 加载词典后单字词占比从 27.04% → 26.19% (降 0.85%), 超越 HanLP (26.55%). 词典修复了 "张志恒" (张|志|恒→张志恒)、"蔡畅" (蔡|畅→蔡畅) 等历史人名拆分问题.
### 3.3 步骤 2: preprocess-model (带混淆)
**输入**: 文本语料 + `pinyin_index.msgpack`
**输出**: .npz 分片(含 part4_ids 字段)
```
流程:
1. 加载 pinyin_index
2. PinyinInputDataset 生成基础样本(含 target_word, word_pinyin
3. 对每个样本:
a. 查询 word_freq[target_word]
b. 若 freq ≤ confusion_max_freq (默认 100) → 低频词, 触发混淆:
- 查 word_to_pinyin[target_word] → pinyin
- 查 pinyin_to_words[pinyin] → 同音词集合
- 查 similar_pinyin[pinyin] → 近音词集合
- 汇总所有候选词
- 随机抽 0~4 个 (60% 概率有候选, 40% 空)
- 50% 概率移除 target_word (防止模型走捷径)
- _build_part4_ids(candidates) → part4_ids tensor
c. 若 freq > confusion_max_freq → 高频词, part4_ids 为空
4. 写入 .npz 分片 (FIELDS + part4_ids)
```
文件: `src/model/preprocess.py``--pinyin-index`, `--confusion-max-freq`
**使用**:
```bash
preprocess-model \
--train-data-path "path/to/hf_dataset" \
--eval-data-path "path/to/eval/dataset" \
--pinyin-index ./banks/pinyin_index.msgpack \
--confusion-max-freq 100 \
--output-dir ./samples \
--num-train-samples 10000000 \
--num-eval-samples 500000 \
--max-seq-length 88
```
### 3.5 编码器冻结 (`--freeze-encoder`)
启用时冻结 `ContextEncoder` 所有参数(包括 `text_emb`, `pinyin_emb`, `TransformerEncoder`, `PinyinLSTMEncoder`),仅训练 Decoder 部分:
- `part4_proj` / `part4_ln`(新增)
- `cross_attn`(含新加的 `k_part4_proj` / `v_part4_proj`
- `slot_memory`
- `moe`
- `slot_attention` / `classifier`
建议学习率: 新模块 `5e-4`,已有模块 `1e-5`(分层 LR
---
## 4. ONNX 导出变更
### 4.1 Decoder 接口
```
原:
decoder(context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask) → logits
新:
decoder(context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask,
part4_ids) → logits
```
### 4.2 动态维度
新增: `"part4_ids": {0: "batch_size", 1: "num_words"}`
文件: `src/model/export_models.py``DecoderExport`, `src/model/onnx_export.py`
---
## 5. CLI 命令参考
| 命令 | 用途 |
|------|------|
| `train-model train` | 从头训练 / 微调(自动检测 .npz → PreProcessedDataset |
| `pinyin-index build` | 从语料构建拼音索引(词→拼音→相似拼音) |
| `preprocess-model` | 生成 .npz 分片(支持 `--pinyin-index` 生成混淆 part4 |
| `train-model export` | 导出 ONNX含 part4_ids 输入) |
---
## 6. 文件清单
### 新建
| 文件 | 职责 |
|------|------|
| `src/model/confusion/__init__.py` | 包导出 |
| `src/model/confusion/pinyin_index.py` | `PinyinIndexBuilder` — 拼音索引构建(词→拼音→相似拼音) |
| `src/model/confusion/cli.py` | CLI: `pinyin-index` |
### 修改
| 文件 | 关键变更 |
|------|----------|
| `src/model/components.py` | `CrossAttentionFusion` + 第三路 attention |
| `src/model/model.py` | `_encode_part4()`, `part4_ids` forward, `freeze_encoder`, `max_seq_len=88` |
| `src/model/dataset.py` | `target_word`/`word_pinyin`/`part4_ids` 字段, 移除 encoder 中 part4, `max_seq_length=88` |
| `src/model/trainer.py` | `collate_fn` + part4_ids, `train_step` 传 part4, checkpoint 含完整模型 config |
| `src/model/export_models.py` | `DecoderExport` + `_encode_part4` + `part4_ids` |
| `src/model/onnx_export.py` | decoder 导出 + `part4_ids` |
| `src/model/preprocess.py` | `--pinyin-index`, `--confusion-max-freq`, 生成含 part4_ids 的 .npz |
| `src/model/preprocessed_dataset.py` | `part4_ids` 字段 + collate 支持 |
| `scripts/finetune_slots.py` | `max_seq_len=88` |
| `pyproject.toml` | CLI 入口 `pinyin-index`, `python-levenshtein` 依赖 |
| `README.md` | §8 混淆数据文档(新流水线) |
---
## 7. 依赖
```bash
uv pip install -e .
uv pip install python-levenshtein
```

146
README.md
View File

@ -53,7 +53,7 @@
| 参数项 | 推荐值 | 说明 |
| :--- | :--- | :--- |
| **序列长度 (L)** | 128 | 上下文窗口大小 [1] |
| **序列长度 (L)** | 88 | 上下文窗口大小 [1] |
| **隐藏层维度** | 512 | Embedding 及 Transformer 内部维度 [1] |
| **Transformer 层数** | 4 | 轻量级骨干,降低延迟 [1] |
| **注意力头数** | 4 | 适配 512 维度的高效配置 [1] |
@ -171,7 +171,7 @@ config = {
"n_layers": 4,
"n_heads": 4,
"num_experts": 20,
"max_seq_len": 128,
"max_seq_len": 88,
# 训练参数
"batch_size": 64, # 根据GPU内存调整
@ -437,6 +437,7 @@ def load_trained_model(checkpoint_path):
2. **安装项目依赖**
```bash
uv pip install -e .
uv pip install python-levenshtein
```
#### 使用传统 pip
@ -837,5 +838,146 @@ train-model export \
- 支持在不同推理引擎上部署
- 提供优化后的推理模型
## 8. 混淆样本生成 (Pinyin Index & Confusion Sample Generation)
### 8.1 设计动机
原始训练中 `part4` 随机从文本分词结果中选取,训练时位于 Encoder 输入层,推理时不包含 `part4`,存在训练/推理分布不一致的问题。
本方案核心改动:
- **part4 从 Encoder 移除**Encoder 只处理光标前后文本,输出纯上下文编码 H
- **part4 进入 Decoder**:通过独立的 CrossAttention 第三路(`k_part4_proj` / `v_part4_proj`)引入候选词信息
- **序列长度从 128 缩减至 88**
- **拼音相似词混淆**替代随机词:用同音/近音词作为 part4增强模型区分同音字的能力
```
Encoder (max_seq_len=88):
input_ids → TransformerEncoder → H, P
Decoder:
H, P, history_slot_ids, part4_ids → CrossAttention(三路) → MoE → logits
text_attn + pinyin_attn + part4_attn
```
### 8.2 混淆数据构建流程 (新流水线)
新方案移除对 GPU 的依赖,全部在 CPU 上完成:
```
┌────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ pinyin-index build │ │ preprocess-model │ │ train-model train │
│ │ │ │ │ │
│ 142W姓名词典注入 │ → │ 加载 pinyin_index │ → │ 加载 .npz (含 │
│ jieba + 语料分词 │ │ 低频词生成混淆 part4 │ │ part4_ids) │
│ + 随机连接 + │ │ 保存为 .npz 分片 │ │ 从头训练 / 微调 │
│ Levenshtein相似度 │ │ │ │ │
└────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
#### 步骤 1: 构建拼音索引 (pinyin-index)
从文本语料构建拼音→词映射,无需模型推理。
**词典准备 (前置)**:将 [Chinese-Names-Corpus](https://github.com/wainshine/Chinese-Names-Corpus) 克隆至本地:
```bash
git clone https://github.com/wainshine/Chinese-Names-Corpus.git /home/songsenand/DataSet/Chinese-Names-Corpus
```
**构建索引**
```bash
pinyin-index build \
--corpus "path/to/hf_dataset" \
--name-corpus /home/songsenand/DataSet/Chinese-Names-Corpus \
--output ./banks/pinyin_index.msgpack \
--max-samples 10000000
```
核心策略:
1. **142W 外挂词典**:从 `Chinese-Names-Corpus` 加载 142 万人名 + 5W 成语 + 424 内嵌地名,注入 jieba大幅减少人名/地名拆分(单字词占比从 27.04% 降至 26.19%,超越 HanLP 的 26.55%
2. **随机连接补位**:词典之外,连续 2~4 个单字词以 50% 概率随机合并,补充罕见多字词
3. **拼音相似度**按拼音长度分桶Levenshtein 编辑距离计算 top-5 相似拼音
产出 `pinyin_index.msgpack`,包含:
- `pinyin_to_words`: 拼音 → 词集合
- `similar_pinyin`: 拼音 → 相似拼音列表
- `word_to_pinyin`: 词 → 拼音
- `word_freq`: 词 → 频率
#### 步骤 2: 生成带混淆的 .npz 样本
```bash
preprocess-model \
--train-data-path "path/to/hf_dataset" \
--eval-data-path "path/to/eval/dataset" \
--pinyin-index ./banks/pinyin_index.msgpack \
--confusion-max-freq 100 \
--output-dir ./samples \
--num-train-samples 10000000 \
--num-eval-samples 500000 \
--max-seq-length 88
```
`--confusion-max-freq` 控制混淆频率阈值:只有词频 ≤100 的低频词才生成混淆 part4高频词不提供 part4因为模型已从海量样本中学会
#### 步骤 3: 训练
```bash
# 从头训练(使用 .npz 分片,自动加载 part4_ids
train-model train \
--train-data-path ./samples/train \
--eval-data-path ./samples/eval \
--output-dir ./output \
--batch-size 128 \
--num-epochs 10 \
--learning-rate 2e-4
# 微调(冻结 Encoder
train-model train \
--train-data-path ./samples/train \
--eval-data-path ./samples/eval \
--resume-from ./output/checkpoints/best_model.pt \
--freeze-encoder \
--output-dir ./finetune_output \
--learning-rate 1e-5 \
--num-epochs 5
```
### 8.3 混淆生成策略
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 混淆比例 | 60% | 低频词样本中提供混淆 part4 的概率 |
| 空比例 | 40% | 低频词样本中 part4 为空的概率 |
| 目标剔除 | 50% | 若目标词在候选列表,一半概率移除,防止模型走捷径 |
| 最大候选数 | 5 | part4 最多 5 个候选词 |
| 频率阈值 | 100 | 词频 ≤100 才触发混淆,高频词跳过 |
### 8.4 编码器冻结 (--freeze-encoder)
启用时冻结 ContextEncoder 的所有参数,仅训练 Decoder 部分(包括 `part4_proj`、`cross_attn.k_part4_proj/v_part4_proj` 及后续模块)。
### 8.5 CLI 命令参考
| 命令 | 用途 |
|------|------|
| `train-model train` | 从头训练 / 微调 |
| `pinyin-index build` | 构建拼音索引(词→拼音→相似拼音) |
| `preprocess-model` | 生成 .npz 分片(支持 `--pinyin-index` 生成混淆 part4 |
| `train-model export` | 导出 ONNX含 part4_ids 输入) |
### 8.6 文件清单
| 文件 | 职责 |
|------|------|
| `src/model/confusion/pinyin_index.py` | `PinyinIndexBuilder` — 拼音索引构建 |
| `src/model/confusion/cli.py` | CLI: `pinyin-index` |
| `src/model/preprocess.py` | `--pinyin-index` 参数,生成含 part4_ids 的 .npz |
| `src/model/preprocessed_dataset.py` | `part4_ids` 字段支持 |
| `src/model/components.py` | `CrossAttentionFusion` 三路 attention |
| `src/model/model.py` | `_encode_part4()`, `freeze_encoder`, `max_seq_len=88` |
| `src/model/trainer.py` | `collate_fn` + part4_ids, `--freeze-encoder` |
## 7. 总结
本方案通过**单流 Transformer 编码**结合**结构化槽位交叉注意力**,并引入**20个专家的 MoE 模块** [1]在保证模型轻量4层 Transformer的同时有效利用了历史输入习惯并提升了模型表达上限。相比暴力拼接或双流架构该设计在工程实现上更优雅在推理效率上更高效是轻量级输入法模型的局部最优解。

View File

@ -0,0 +1,26 @@
{
"jieba": {
"status": "ok",
"cold_start_s": 0.219,
"memory_mb": 39,
"peak_memory_mb": 95,
"chars_per_sec": 1167606,
"description": "jieba (基线, HMM=False)"
},
"LTP": {
"status": "ok",
"cold_start_s": 4.811,
"memory_mb": 839,
"peak_memory_mb": 936,
"chars_per_sec": 2748,
"description": "LTP (pipeline, cws)"
},
"HanLP": {
"status": "ok",
"cold_start_s": 0.161,
"memory_mb": 95,
"peak_memory_mb": 984,
"chars_per_sec": 2523,
"description": "HanLP (COARSE_ELECTRA_SMALL)"
}
}

161
docs/CONFUSION_PIPELINE.md Normal file
View File

@ -0,0 +1,161 @@
# 混淆样本生成流水线 (Confusion Sample Generation)
## 概述
本流水线用于生成带混淆候选词part4的训练样本全部在 CPU 上完成,无需 GPU。
核心思路:用拼音相似词作为 part4 候选,帮助模型学会区分同音/近音字。
## 三步流程
```
Step 1 (CPU): 语料 + 142W 词典 → pinyin-index → pinyin_index.msgpack
Step 2 (CPU): 语料 + pinyin_index.msgpack → preprocess-model → .npz 分片
Step 3 (GPU): .npz 分片 → train-model → 模型
```
---
## Step 1: 构建拼音索引
### 前置准备
将 [Chinese-Names-Corpus](https://github.com/wainshine/Chinese-Names-Corpus) 克隆至本地:
```bash
git clone https://github.com/wainshine/Chinese-Names-Corpus.git ~/DataSet/Chinese-Names-Corpus
```
### 执行
```bash
pinyin-index build \
--corpus "path/to/hf_dataset" \
--name-corpus ~/DataSet/Chinese-Names-Corpus \
--output ./banks/pinyin_index.msgpack \
--max-samples 10000000
```
### 参数说明
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--corpus` | (必需) | HuggingFace 格式语料路径 |
| `--name-corpus` | None | Chinese-Names-Corpus 仓库路径。不提供则仅用内嵌 424 地名 |
| `--output` | `./banks/pinyin_index.msgpack` | 输出路径 |
| `--max-samples` | 10,000,000 | 最大处理文本数 |
| `--top-k-similar` | 5 | 每个拼音保留的相似拼音数 |
| `--min-similarity` | 0.4 | Levenshtein 最小相似度阈值 |
| `--merge-prob` | 0.5 | 连续单字词随机合并概率 |
| `--min-word-freq` | 2 | 最小词频过滤 |
### 词典构成 (~142 万条)
| 来源 | 词条数 | 说明 |
|------|--------|------|
| Chinese_Names_Corpus (120W) | 1,144,226 | 现代中文人名 |
| Chinese_Names_Corpus_Gender (120W) | 1,144,226 | 性别标注人名 |
| Ancient_Names_Corpus (25W) | 255,268 | 古代人名 |
| ChengYu_Corpus (5W) | 50,372 | 成语 |
| 内嵌地名 | 424 | 省级/地级/省会/主要城市/大学 |
**效果**: jieba 加载词典后,单字词占比从 27.04% 降至 26.19%(降 0.85%),超越 HanLP26.55%)。实际修复了 "张志恒"、"蔡畅" 等历史人名拆分问题。
### 输出格式
`pinyin_index.msgpack` 包含四个核心 dict
```python
{
"pinyin_to_words": {"zhangwei": ["张伟", "章伟", ...]}, # pinyin → words
"similar_pinyin": {"zhangwei": ["zangwei", ...]}, # pinyin → similar pinyins
"word_to_pinyin": {"张伟": "zhangwei"}, # word → pinyin
"word_freq": {"张伟": 15, "中国": 50000}, # word → frequency
"meta": {"num_words": ..., "num_pinyins": ...}
}
```
---
## Step 2: 生成带混淆的 .npz 样本
```bash
preprocess-model \
--train-data-path "path/to/hf_dataset" \
--eval-data-path "path/to/eval/dataset" \
--pinyin-index ./banks/pinyin_index.msgpack \
--confusion-max-freq 100 \
--output-dir ./samples \
--num-train-samples 10000000 \
--num-eval-samples 500000 \
--max-seq-length 88
```
### 混淆生成逻辑
对每个训练样本:
```
target_word → 查 word_freq
├─ freq ≤ confusion_max_freq → 低频词 → 触发混淆:
│ 1. 查 word_to_pinyin[target_word] → pinyin
│ 2. 查 pinyin_to_words[pinyin] → 同音词集
│ 3. 查 similar_pinyin[pinyin] → 近音词集
│ 4. 汇总所有候选, 随机抽 1-4 个 (60% 概率)
│ 5. 50% 概率移除 target_word (防捷径)
│ 6. 构建 part4_ids → 写入 .npz
└─ freq > confusion_max_freq → 高频词 → part4_ids 为空
```
### 关键参数
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--pinyin-index` | None | pinyin_index.msgpack 路径,不提供则不生成混淆 |
| `--confusion-max-freq` | 100 | 词频阈值,≤ N 才触发混淆 |
| `--max-seq-length` | 88 | 序列长度 |
---
## Step 3: 训练
### 从头训练
```bash
train-model train \
--train-data-path ./samples/train \
--eval-data-path ./samples/eval \
--output-dir ./output \
--batch-size 128 \
--num-epochs 10 \
--learning-rate 2e-4
```
### 微调 (冻结 Encoder)
```bash
train-model train \
--train-data-path ./samples/train \
--eval-data-path ./samples/eval \
--resume-from ./output/checkpoints/best_model.pt \
--freeze-encoder \
--output-dir ./finetune_output \
--learning-rate 1e-5 \
--num-epochs 5
```
---
## 补充词典
### 添加新词典源
如果要添加更多专业术语词典如法律、医学、IT只需在文本文件中每行一个词然后用 `jieba.add_word()` 加载即可。
也可以修改 `src/model/confusion/pinyin_index.py` 中的 `load_name_dict()` 函数添加新的词典文件。
参考资源:
- [Chinese-Tripitaka/Chinese-medical-dictionary](https://github.com/Chinese-Tripitaka/Chinese-medical-dictionary) — 医学
- [fighting41love/funNLP](https://github.com/fighting41love/funNLP) — 多领域词库
- [THUOCL](http://thuocl.thunlp.org/) — 清华开放中文词库 (IT/财经/医疗)

View File

@ -16,6 +16,7 @@ dependencies = [
"plotly>=5.0.0",
"jieba>=0.42.1",
"pypinyin>=0.55.0",
"python-levenshtein>=0.25.0",
"requests>=2.32.5",
"rich>=14.3.1",
"flask>=3.1.0",
@ -32,6 +33,7 @@ train-model = "model.trainer:app"
monitor-training = "model.monitor:app"
preprocess-model = "model.preprocess:main"
inspect-preprocessed = "model.inspect_preprocessed:main"
pinyin-index = "model.confusion.cli:pinyin_index_app"
[tool.uv]
# 设置当前项目的默认索引源

View File

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
真实数据分词对比: jieba(基线) vs jieba+姓名词典 vs HanLP
用法:
HF_ENDPOINT=https://hf-mirror.com \
python scripts/benchmark_real_data.py \
--data-dir /home/songsenand/DataSet/data/data \
--num-samples 200
"""
import argparse
import json
import os
import random
import sys
import time
import numpy as np
parser = argparse.ArgumentParser()
parser.add_argument("--data-dir", required=True, help="CCI-Data data 目录路径")
parser.add_argument("--num-samples", type=int, default=200, help="抽样数量")
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
np.random.seed(args.seed)
# ── 加载文本 ──────────────────────────────────────────────────────────
data_dir = args.data_dir
jsonl_files = sorted([f for f in os.listdir(data_dir) if f.endswith(".jsonl")])
num_files_to_read = min(50, len(jsonl_files))
selected_files = random.sample(jsonl_files, num_files_to_read)
print(f"{len(jsonl_files)} 个文件中随机选取 {num_files_to_read} 个读取...")
all_texts = []
for fname in selected_files:
with open(os.path.join(data_dir, fname), encoding="utf-8") as f:
for line in f:
obj = json.loads(line)
text = obj.get("text", "")
if text and len(text) >= 20:
all_texts.append(text)
if len(all_texts) >= args.num_samples * 10:
break
sampled = random.sample(all_texts, min(args.num_samples, len(all_texts)))
sample_chars = sum(len(t) for t in sampled)
print(f"抽样 {len(sampled):,} 条, {sample_chars:,} 字符")
print(f"平均长度: {sample_chars / len(sampled):.0f} 字/条\n")
# ── 1. jieba (基线, HMM=False) ───────────────────────────────────────
print("=" * 60)
print(" 1. jieba (HMM=False, 基线)")
print("=" * 60)
import jieba
jieba.setLogLevel(jieba.logging.INFO)
t0 = time.perf_counter()
jieba_results = [list(jieba.cut(t, HMM=False)) for t in sampled]
t1 = time.perf_counter()
jieba_time = t1 - t0
# ── 2. jieba + 姓名词典 (Full Corpus) ───────────────────────────────
print()
print("=" * 60)
print(" 2. jieba + 完整词典 (142W 人名 + 地名 + 成语)")
print("=" * 60)
CORPUS_PATH = "/home/songsenand/DataSet/Chinese-Names-Corpus"
def _load_txt(path, min_len=2, max_len=8, skip=4):
items = set()
with open(path, encoding="utf-8-sig") as f:
for i, line in enumerate(f):
if i < skip:
continue
w = line.strip()
if w and min_len <= len(w) <= max_len:
items.add(w)
return items
def _load_csv(path, col=0, min_len=2, max_len=8, skip=4):
items = set()
with open(path, encoding="utf-8-sig") as f:
for i, line in enumerate(f):
if i < skip:
continue
parts = line.strip().split(",")
if len(parts) > col:
w = parts[col].strip()
if w and min_len <= len(w) <= max_len:
items.add(w)
return items
# 内嵌地名 (与 pinyin_index.py 一致)
_PLACES = [
"北京", "天津", "上海", "重庆", "河北", "山西", "辽宁", "吉林", "黑龙江",
"江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", "湖北", "湖南",
"广东", "海南", "四川", "贵州", "云南", "陕西", "甘肃", "青海", "台湾",
"内蒙古", "广西", "西藏", "宁夏", "新疆", "香港", "澳门",
"石家庄", "太原", "沈阳", "长春", "哈尔滨", "南京", "杭州", "合肥",
"福州", "南昌", "济南", "郑州", "武汉", "长沙", "广州", "南宁",
"成都", "贵阳", "昆明", "拉萨", "西安", "兰州", "西宁", "银川",
"乌鲁木齐", "呼和浩特", "大连", "青岛", "厦门", "深圳", "苏州", "无锡",
"宁波", "温州", "佛山", "东莞", "常州", "南通", "徐州", "烟台", "威海",
"洛阳", "开封", "岳阳", "株洲", "湘潭", "珠海", "汕头", "三亚",
"桂林", "丽江", "大理", "张家界", "黄山", "武夷山", "泰山", "华山",
"清华", "北大", "复旦", "浙大", "南大",
]
all_names = set(_PLACES)
base = f"{CORPUS_PATH}/Chinese_Names_Corpus"
dict_base = f"{CORPUS_PATH}/Chinese_Dict_Corpus"
for label, loader, path in [
("Names(120W)", _load_txt, f"{base}/Chinese_Names_Corpus120W.txt"),
("Names(Gender)", _load_csv, f"{base}/Chinese_Names_Corpus_Gender120W.txt"),
("Names(Ancient)", _load_txt, f"{base}/Ancient_Names_Corpus25W.txt"),
("Chengyu(5W)", _load_txt, f"{dict_base}/ChengYu_Corpus5W.txt"),
]:
items = loader(path, min_len=2, max_len=12)
all_names.update(items)
print(f" {label}: {len(items):,}")
print(f" 加载 {len(all_names):,} 个词条到 jieba 词典...")
for name in all_names:
jieba.add_word(name)
t0 = time.perf_counter()
jieba_dict_results = [list(jieba.cut(t, HMM=False)) for t in sampled]
t2 = time.perf_counter()
jieba_dict_time = t2 - t0
# ── 3. HanLP ─────────────────────────────────────────────────────────
print()
print("=" * 60)
print(" 3. HanLP (COARSE_ELECTRA_SMALL)")
print("=" * 60)
os.environ["CUDA_VISIBLE_DEVICES"] = ""
import hanlp
from loguru import logger
logger.disable("hanlp")
tok = hanlp.load(hanlp.pretrained.tok.COARSE_ELECTRA_SMALL_ZH)
tok(["热身"])
t0 = time.perf_counter()
hanlp_results = []
for i in range(0, len(sampled), 32):
batch = sampled[i : i + 32]
hanlp_results.extend(tok(batch))
pct = min(i + 32, len(sampled)) / len(sampled) * 100
elapsed = time.perf_counter() - t0
print(f" 进度: {pct:.0f}% ({min(i+32, len(sampled))}/{len(sampled)}), 耗时 {elapsed:.0f}s", end="\r")
print()
t3 = time.perf_counter()
hanlp_time = t3 - t0
# ── 统计 ──────────────────────────────────────────────────────────────
def stats(results, label):
single = 0
total = 0
for words in results:
for w in words:
if len(w) == 1 and "\u4e00" <= w <= "\u9fff":
single += 1
total += 1
ratio = single / total if total else 0
return single, total, ratio
j_sc, j_tc, j_ratio = stats(jieba_results, "jieba")
jd_sc, jd_tc, jd_ratio = stats(jieba_dict_results, "jieba+dict")
h_sc, h_tc, h_ratio = stats(hanlp_results, "HanLP")
j_cps = sample_chars / jieba_time
jd_cps = sample_chars / jieba_dict_time
h_cps = sample_chars / hanlp_time
# ── 对比表 ────────────────────────────────────────────────────────────
print()
print("=" * 80)
print(" 📊 对比")
print("=" * 80)
print(f" {'指标':<20s} {'jieba':>15s} {'jieba+词典':>15s} {'HanLP':>15s}")
print(f" {''*20} {''*15} {''*15} {''*15}")
print(f" {'速度 (字/秒)':<20s} {j_cps:>15,.0f} {jd_cps:>15,.0f} {h_cps:>15,.0f}")
print(f" {'耗时 (s)':<20s} {jieba_time:>15.2f} {jieba_dict_time:>15.2f} {hanlp_time:>15.2f}")
print(f" {'单字词占比':<20s} {j_ratio:>14.2%} {jd_ratio:>14.2%} {h_ratio:>14.2%}")
print(f" {'单字词数':<20s} {j_sc:>15,} {jd_sc:>15,} {h_sc:>15,}")
print(f" {'总词数':<20s} {j_tc:>15,} {jd_tc:>15,} {h_tc:>15,}")
jd_vs_h = jd_cps / h_cps
print(f" {'速度比 (dict/HanLP)':<20s} {'':>15s} {'':>15s} {jd_vs_h:>14.0f}x")
print(f" {'单字率降幅 (vs jieba)':<20s} {'':>15s} {j_ratio - jd_ratio:>14.2%} {j_ratio - h_ratio:>14.2%}")
# ── 差异案例 ──────────────────────────────────────────────────────────
print()
print("=" * 80)
print(" 🔍 差异案例 (jieba 拆开 → jieba+词典 不拆)")
print("=" * 80)
shown = 0
for i in range(len(sampled)):
if shown >= 10:
break
j_words = jieba_results[i]
jd_words = jieba_dict_results[i]
if j_words == jd_words:
continue
shown += 1
print(f"\n [{shown}] 原文: {sampled[i][:100]}...")
print(f" jieba : {' | '.join(j_words[:25])}")
print(f" jieba+词典: {' | '.join(jd_words[:25])}")
# ── 汇总 ──────────────────────────────────────────────────────────────
print()
print("=" * 80)
print(f" ✅ 结论:")
print(f" jieba+词典 速度: {jd_cps:,.0f} 字/秒 (几乎无损)")
print(f" 单字词占比: jieba {j_ratio:.2%} → jieba+词典 {jd_ratio:.2%} (降 {j_ratio - jd_ratio:.2%})")
print(f" jieba+词典 vs HanLP: {jd_vs_h:.0f}x 更快")
print("=" * 80)

View File

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
Chinese Word Segmentation Benchmark
比较 jieba / LTP / HanLPLAC 不兼容 Python 3.12
用法:
HF_ENDPOINT=https://hf-mirror.com python scripts/benchmark_segmentation.py
依赖安装独立 venv:
uv venv /tmp/segbench-venv --python 3.12
uv pip install --python /tmp/segbench-venv/bin/python \
psutil ltp hanlp "huggingface-hub<1.0,>=0.26" "transformers>=4.40,<5.0"
"""
import os
import sys
import time
import json
import textwrap
from typing import Dict, List, Tuple, Callable, Optional
import psutil
# ── Test Cases ──────────────────────────────────────────────────────────
TEST_CASES: List[Tuple[str, str, str]] = [
# (类别, 原文, 关注点)
("常见人名", "张伟去了上海找李明", "张伟、李明为人名,不应拆开"),
("常见人名2", "王芳和李刚一起去北京", "王芳、李刚为人名"),
("罕见复姓", "欧阳修与司马光论道饮酒", "欧阳修、司马光为复姓人名"),
("三字人名", "诸葛亮借东风火烧赤壁", "诸葛亮为历史人名"),
("长地名", "乌鲁木齐的天气比呼和浩特好多了", "乌鲁木齐、呼和浩特为地名"),
("品牌名", "使用华为手机拍摄视频发朋友圈", "华为为品牌名"),
("公司名", "字节跳动发布了豆包大模型产品", "字节跳动为公司名"),
("罕见词", "这道菜真是饕餮盛宴令人垂涎", "饕餮为罕见词"),
("谚语", "不到长城非好汉屈指行程二万", "长城为地名"),
("经典歧义", "武汉市长江大桥通车了", "歧义: 武汉/市长/江大桥 vs 武汉市/长江/大桥"),
("混合场景", "张三在北京用华为手机给李四发微信", "综合测试"),
("常见词对照", "今天天气很好中国人民站起来了", "常见词基线,应正常分词"),
("机构名", "中国科学院计算技术研究所发布报告", "机构名不应过度拆分"),
("外来词", "使用ChatGPT生成文本然后发表", "中英混合"),
]
# ── Library Wrappers ────────────────────────────────────────────────────
def setup_jieba():
import jieba
jieba.setLogLevel(jieba.logging.INFO)
return lambda text: list(jieba.cut(text, HMM=False))
def setup_ltp():
from ltp import LTP
model = LTP()
model.pipeline(["热身"], tasks=["cws"])
return lambda text: model.pipeline([text], tasks=["cws"]).cws[0]
def setup_hanlp():
import hanlp
tok = hanlp.load(hanlp.pretrained.tok.COARSE_ELECTRA_SMALL_ZH)
tok("热身")
return lambda text: tok(text)
def setup_lac():
"""LAC 2.1.2 requires paddle.fluid which is removed in Python 3.12."""
from LAC import LAC
lac = LAC(mode="seg")
lac.run("热身")
return lambda text: lac.run(text)
LIBRARIES: Dict[str, Tuple[Callable[[], Optional[Callable]], str]] = {
"jieba": (setup_jieba, "jieba (基线, HMM=False)"),
"LTP": (setup_ltp, "LTP (pipeline, cws)"),
"HanLP": (setup_hanlp, "HanLP (COARSE_ELECTRA_SMALL)"),
}
# ── Benchmark Logic ─────────────────────────────────────────────────────
def measure_memory() -> int:
return psutil.Process(os.getpid()).memory_info().rss // (1024 * 1024)
def run_benchmark() -> Dict:
results = {}
for name, (setup_fn, description) in LIBRARIES.items():
print(f"\n{'='*60}")
print(f" 测试 {name}: {description}")
print(f"{'='*60}")
mem_before = measure_memory()
# Cold start
t0 = time.perf_counter()
try:
seg_fn = setup_fn()
except Exception as e:
print(f" ❌ 初始化失败: {e}")
results[name] = {"status": "failed", "error": str(e)}
continue
cold_time = time.perf_counter() - t0
mem_after = measure_memory()
# Segmentation results
print(f"\n {'原文':<24s} │ 分词结果")
print(f" {''*24}─┼─{''*50}")
seg_outputs = {}
for cat, text, note in TEST_CASES:
words = seg_fn(text)
seg_outputs[text] = words
result_str = " | ".join(words)
if len(result_str) > 50:
result_str = result_str[:47] + "..."
print(f" {text:<24s}{result_str}")
# Speed benchmark (warm runs)
speed_texts = [text for _, text, _ in TEST_CASES]
warm_runs = 5
bench_runs = 100
for _ in range(warm_runs):
for t in speed_texts:
seg_fn(t)
t0 = time.perf_counter()
for _ in range(bench_runs):
for t in speed_texts:
seg_fn(t)
total_time = time.perf_counter() - t0
total_chars = sum(len(t) for t in speed_texts) * bench_runs
chars_per_sec = total_chars / total_time
results[name] = {
"status": "ok",
"cold_start_s": round(cold_time, 3),
"memory_mb": mem_after - mem_before,
"peak_memory_mb": measure_memory(),
"chars_per_sec": int(chars_per_sec),
"seg_outputs": seg_outputs,
"description": description,
}
print(f"\n 📊 冷启动: {cold_time:.2f}s | 速度: {chars_per_sec:,.0f} 字/秒 | 内存增量: {mem_after - mem_before} MB")
return results
# ── Comparison Table ────────────────────────────────────────────────────
def print_comparison(results: Dict):
print(f"\n\n{'='*80}")
print(f" 📋 分词结果对照表")
print(f"{'='*80}")
# Per-sentence comparison
for cat, text, note in TEST_CASES:
print(f"\n [{cat}] {text}")
print(f" 关注: {note}")
for name in results:
if results[name].get("status") != "ok":
continue
words = results[name]["seg_outputs"].get(text, [])
print(f" {name:<6s}: {' | '.join(words)}")
# Performance comparison
print(f"\n\n{'='*80}")
print(f" 📊 性能对比")
print(f"{'='*80}")
print(f" {'':<10s} {'冷启动(s)':>10s} {'速度(字/s)':>14s} {'内存增量(MB)':>14s} {'状态':>8s}")
print(f" {''*10} {''*10} {''*14} {''*14} {''*8}")
for name in ["jieba", "LTP", "HanLP"]:
r = results.get(name, {})
if r.get("status") != "ok":
print(f" {name:<10s} {'':>10s} {'':>14s} {'':>14s} {'失败':>8s}")
continue
print(
f" {name:<10s} {r['cold_start_s']:>10.2f} {r['chars_per_sec']:>14,} "
f"{r['memory_mb']:>14} {'OK':>8s}"
)
# LAC note
print(f"\n LAC 不兼容 Python 3.12(依赖已移除的 paddle.fluid 模块),无法参与对比。")
# Summary
print(f"\n{'='*80}")
print(f" 🏆 结论")
print(f"{'='*80}")
for name in ["jieba", "LTP", "HanLP"]:
r = results.get(name, {})
if r.get("status") != "ok":
continue
desc = r.get("description", name)
print(f" {name}: {desc}")
# ── Main ────────────────────────────────────────────────────────────────
def main():
print("=" * 60)
print(" 中文分词库基准测试")
print(" jieba(基线) vs LTP vs HanLP")
print("=" * 60)
if "HF_ENDPOINT" in os.environ:
print(f" HF_ENDPOINT={os.environ['HF_ENDPOINT']}")
results = run_benchmark()
print_comparison(results)
# Save JSON
out = {k: {kk: vv for kk, vv in v.items() if kk != "seg_outputs"} for k, v in results.items()}
json_path = os.path.join(os.path.dirname(__file__), "..", "benchmark_segmentation.json")
with open(json_path, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
print(f"\n 💾 结果已保存到 {json_path}")
if __name__ == "__main__":
main()

View File

@ -128,7 +128,7 @@ def main():
n_layers = 4
n_heads = 4
num_experts = 10
max_seq_len = 128
max_seq_len = 88
# ================================================================
# 打印配置

View File

@ -240,12 +240,21 @@ class CrossAttentionFusion(nn.Module):
self.v_text_proj = nn.Linear(dim, dim, bias=False)
self.k_pinyin_proj = nn.Linear(dim, dim, bias=False)
self.v_pinyin_proj = nn.Linear(dim, dim, bias=False)
self.k_part4_proj = nn.Linear(dim, dim, bias=False)
self.v_part4_proj = nn.Linear(dim, dim, bias=False)
self.out_proj = nn.Linear(dim, dim, bias=False)
self.ln = nn.LayerNorm(dim)
def forward(
self, slots_S, context_H, pinyin_P, context_mask=None, pinyin_mask=None
self,
slots_S,
context_H,
pinyin_P,
context_mask=None,
pinyin_mask=None,
part4_E=None,
part4_mask=None,
):
"""
Args:
@ -254,6 +263,8 @@ class CrossAttentionFusion(nn.Module):
pinyin_P: [batch, pinyin_len, dim] 拼音序列编码
context_mask: [batch, ctx_len] 文本 padding mask (True for padding)
pinyin_mask: [batch, pinyin_len] 拼音 padding mask (True for padding)
part4_E: [batch, num_part4, dim] 候选词编码 (optional)
part4_mask: [batch, num_part4] 候选词 padding mask (True for padding)
Returns:
fused: [batch, num_slots, dim]
"""
@ -306,6 +317,30 @@ class CrossAttentionFusion(nn.Module):
combined_attn = text_attn + pinyin_attn
if part4_E is not None:
part4_len = part4_E.size(1)
K_part4 = self.k_part4_proj(part4_E)
V_part4 = self.v_part4_proj(part4_E)
K_part4 = K_part4.view(
batch_size, part4_len, self.n_heads, self.head_dim
).transpose(1, 2)
V_part4 = V_part4.view(
batch_size, part4_len, self.n_heads, self.head_dim
).transpose(1, 2)
part4_attn_mask = None
if part4_mask is not None:
part4_attn_mask = (
part4_mask[:, None, None, :]
.float()
.masked_fill(part4_mask[:, None, None, :], -1e9)
)
part4_attn = F.scaled_dot_product_attention(
Q, K_part4, V_part4, attn_mask=part4_attn_mask
)
combined_attn = combined_attn + part4_attn
combined_attn = (
combined_attn.transpose(1, 2)
.contiguous()

View File

@ -0,0 +1,6 @@
from .pinyin_index import PinyinIndexBuilder, load_pinyin_index
__all__ = [
"PinyinIndexBuilder",
"load_pinyin_index",
]

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
import typer
from loguru import logger
from .pinyin_index import PinyinIndexBuilder
pinyin_index_app = typer.Typer(name="pinyin-index")
@pinyin_index_app.command("build")
def build_index(
corpus: str = typer.Option(..., "--corpus", help="文本语料路径 (HuggingFace 格式)"),
output: str = typer.Option("./banks/pinyin_index.msgpack", "--output", "-o", help="输出路径"),
name_corpus: str = typer.Option(None, "--name-corpus", help="Chinese-Names-Corpus 仓库路径"),
max_samples: int = typer.Option(10_000_000, "--max-samples", help="最大处理文本数"),
max_seq_length: int = typer.Option(88, "--max-seq-length", help="最大序列长度"),
top_k_similar: int = typer.Option(5, "--top-k-similar", help="每个拼音保留的相似拼音数"),
min_similarity: float = typer.Option(0.4, "--min-similarity", help="最小相似度阈值"),
merge_prob: float = typer.Option(0.5, "--merge-prob", help="相邻单字词随机合并概率"),
min_word_freq: int = typer.Option(2, "--min-word-freq", help="最小词频过滤阈值"),
):
builder = PinyinIndexBuilder(
max_seq_length=max_seq_length,
top_k_similar=top_k_similar,
min_similarity=min_similarity,
merge_prob=merge_prob,
min_word_freq=min_word_freq,
)
result = builder.build(
corpus_path=corpus,
output_path=output,
max_samples=max_samples,
name_corpus=name_corpus,
)
logger.info(f"拼音索引已保存: {result}")

View File

@ -0,0 +1,343 @@
"""拼音索引构建器 — 替代原 build-bank + build-confusion 流水线。
词典来源:
1. Chinese-Names-Corpus (142W 人名 + 5W 成语)
2. 内嵌地名库 (省级/地级/省会/主要城市, ~600 )
3. 随机连接补位 (连续单字词合并)
用法:
pinyin-index build \
--corpus "path/to/hf_dataset" \
--name-corpus /home/songsenand/DataSet/Chinese-Names-Corpus \
--output ./banks/pinyin_index.msgpack \
--max-samples 10000000
"""
import random
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import msgpack
from loguru import logger
from pypinyin import lazy_pinyin
from tqdm import tqdm
try:
from Levenshtein import distance as levenshtein
except ImportError:
def levenshtein(a: str, b: str) -> int:
m, n = len(a), len(b)
dp = list(range(n + 1))
for i in range(1, m + 1):
prev = dp[0]
dp[0] = i
for j in range(1, n + 1):
tmp = dp[j]
dp[j] = min(prev + (0 if a[i - 1] == b[j - 1] else 1), dp[j - 1] + 1, dp[j] + 1)
prev = tmp
return dp[n]
# ── 内嵌地名库 ──────────────────────────────────────────────────────
_PLACES = [
# 省级 (34)
"北京", "天津", "上海", "重庆", "河北", "山西", "辽宁", "吉林", "黑龙江",
"江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", "湖北", "湖南",
"广东", "海南", "四川", "贵州", "云南", "陕西", "甘肃", "青海", "台湾",
"内蒙古", "广西", "西藏", "宁夏", "新疆", "香港", "澳门",
# 省会/直辖市 (31)
"石家庄", "太原", "沈阳", "长春", "哈尔滨", "南京", "杭州", "合肥",
"福州", "南昌", "济南", "郑州", "武汉", "长沙", "广州", "南宁",
"成都", "贵阳", "昆明", "拉萨", "西安", "兰州", "西宁", "银川",
"乌鲁木齐", "呼和浩特",
# 地级市/重要城市 (~280)
"唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口", "承德", "沧州", "廊坊", "衡水",
"大同", "阳泉", "长治", "晋城", "朔州", "晋中", "运城", "忻州", "临汾", "吕梁",
"大连", "鞍山", "抚顺", "本溪", "丹东", "锦州", "营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛",
"吉林市", "四平", "辽源", "通化", "白山", "松原", "白城", "延边",
"齐齐哈尔", "鸡西", "鹤岗", "双鸭山", "大庆", "伊春", "佳木斯", "七台河", "牡丹江", "黑河", "绥化",
"无锡", "徐州", "常州", "苏州", "南通", "连云港", "淮安", "盐城", "扬州", "镇江", "泰州", "宿迁",
"宁波", "温州", "嘉兴", "湖州", "绍兴", "金华", "衢州", "舟山", "台州", "丽水",
"芜湖", "蚌埠", "淮南", "马鞍山", "淮北", "铜陵", "安庆", "黄山", "滁州", "阜阳", "宿州", "六安", "亳州", "池州", "宣城",
"厦门", "莆田", "三明", "泉州", "漳州", "南平", "龙岩", "宁德",
"景德镇", "萍乡", "九江", "新余", "鹰潭", "赣州", "吉安", "宜春", "抚州", "上饶",
"青岛", "淄博", "枣庄", "东营", "烟台", "潍坊", "济宁", "泰安", "威海", "日照", "临沂", "德州", "聊城", "滨州", "菏泽",
"开封", "洛阳", "平顶山", "安阳", "鹤壁", "新乡", "焦作", "濮阳", "许昌", "漯河", "三门峡", "南阳", "商丘", "信阳", "周口", "驻马店",
"黄石", "十堰", "宜昌", "襄阳", "鄂州", "荆门", "孝感", "荆州", "黄冈", "咸宁", "随州", "恩施",
"株洲", "湘潭", "衡阳", "邵阳", "岳阳", "常德", "张家界", "益阳", "郴州", "永州", "怀化", "娄底", "湘西",
"韶关", "深圳", "珠海", "汕头", "佛山", "江门", "湛江", "茂名", "肇庆", "惠州", "梅州", "汕尾", "河源", "阳江", "清远", "东莞", "中山", "潮州", "揭阳", "云浮",
"柳州", "桂林", "梧州", "北海", "防城港", "钦州", "贵港", "玉林", "百色", "贺州", "河池", "来宾", "崇左",
"自贡", "攀枝花", "泸州", "德阳", "绵阳", "广元", "遂宁", "内江", "乐山", "南充", "眉山", "宜宾", "广安", "达州", "雅安", "巴中", "资阳",
"六盘水", "遵义", "安顺", "毕节", "铜仁", "黔西南", "黔东南", "黔南",
"曲靖", "玉溪", "保山", "昭通", "丽江", "普洱", "临沧", "楚雄", "红河", "文山", "西双版纳", "大理", "德宏", "怒江", "迪庆",
"铜川", "宝鸡", "咸阳", "渭南", "延安", "汉中", "榆林", "安康", "商洛",
"嘉峪关", "金昌", "白银", "天水", "武威", "张掖", "平凉", "酒泉", "庆阳", "定西", "陇南", "临夏", "甘南",
"海东", "海北", "黄南", "海南州", "果洛", "玉树", "海西",
"石嘴山", "吴忠", "固原", "中卫",
"克拉玛依", "吐鲁番", "哈密", "昌吉", "博尔塔拉", "巴音郭楞", "阿克苏", "克孜勒苏", "喀什", "和田", "伊犁", "塔城", "阿勒泰",
# 常见地名补充
"浦东", "滨海", "雄安", "郑东", "福田", "南山", "海淀", "朝阳区", "徐汇",
"大兴", "顺义", "通州", "闵行", "宝山", "嘉定", "松江", "黄埔", "白云",
"番禺", "花都", "龙岗", "宝安", "龙华", "坪山", "高新", "天府", "两江",
"洞庭湖", "太湖", "鄱阳湖", "青海湖", "千岛湖", "泸沽湖",
"黄山", "泰山", "华山", "衡山", "恒山", "嵩山", "峨眉山", "五台山", "九华山", "普陀山", "武当山",
"长江", "黄河", "珠江", "淮河", "海河", "松花江", "辽河",
"故宫", "天坛", "颐和园", "长城", "兵马俑", "西湖", "周庄", "凤凰",
# 大学名称
"清华", "北大", "复旦", "浙大", "南大", "上交", "中科大", "哈工大", "西安交大",
"武汉大学", "中山大学", "四川大学", "山东大学", "厦门大学", "同济", "南开", "天津大学",
"北师大", "华科", "东南大学", "人大", "北航", "北理工",
]
# ── 从 Chinese-Names-Corpus 加载词典 ──────────────────────────────
def _load_txt_names(path: str, min_len: int = 2, max_len: int = 8, skip_lines: int = 4) -> set:
"""从 txt 文件加载词条, 跳过头部注释行"""
items = set()
try:
with open(path, encoding="utf-8-sig") as f:
for i, line in enumerate(f):
if i < skip_lines:
continue
w = line.strip()
if w and min_len <= len(w) <= max_len:
items.add(w)
except FileNotFoundError:
logger.warning(f"File not found: {path}")
return items
def _load_csv_names(path: str, col: int = 0, min_len: int = 2, max_len: int = 8, skip_lines: int = 4) -> set:
"""从 csv 文件加载指定列词条"""
items = set()
try:
with open(path, encoding="utf-8-sig") as f:
for i, line in enumerate(f):
if i < skip_lines:
continue
parts = line.strip().split(",")
if len(parts) > col:
w = parts[col].strip()
if w and min_len <= len(w) <= max_len:
items.add(w)
except FileNotFoundError:
logger.warning(f"File not found: {path}")
return items
def load_name_dict(corpus_path: Optional[str] = None) -> List[str]:
"""加载中文姓名+地名+成语词典, 返回所有词条列表"""
all_words = set(_PLACES)
if corpus_path:
base = Path(corpus_path) / "Chinese_Names_Corpus"
dict_base = Path(corpus_path) / "Chinese_Dict_Corpus"
# 120W 现代人名
names = _load_txt_names(str(base / "Chinese_Names_Corpus120W.txt"))
all_words.update(names)
logger.info(f" Names (120W): {len(names):,}")
# 120W 性别标注人名 (第0列)
names_g = _load_csv_names(str(base / "Chinese_Names_Corpus_Gender120W.txt"), col=0)
all_words.update(names_g)
logger.info(f" Names (Gender): {len(names_g):,}")
# 25W 古代人名
ancient = _load_txt_names(str(base / "Ancient_Names_Corpus25W.txt"))
all_words.update(ancient)
logger.info(f" Names (Ancient): {len(ancient):,}")
# 5W 成语
cy = _load_txt_names(str(dict_base / "ChengYu_Corpus5W.txt"), min_len=4, max_len=12)
all_words.update(cy)
logger.info(f" Chengyu: {len(cy):,}")
else:
# 无外部词典时使用内嵌地名
logger.info(" No name corpus provided, using embedded places only")
result = sorted(all_words)
logger.info(f" Places: {len(_PLACES):,}")
logger.info(f" Total: {len(result):,} unique words")
return result
# ── 拼音工具 ──────────────────────────────────────────────────────────
def _is_chinese(c: str) -> bool:
return "\u4e00" <= c <= "\u9fff"
def _word_to_pinyin(word: str) -> str:
return "".join(lazy_pinyin(word))
def _pinyin_similarity(a: str, b: str) -> float:
if not a or not b:
return 0.0
d = levenshtein(a, b)
return 1.0 - d / max(len(a), len(b))
# ── PinyinIndexBuilder ─────────────────────────────────────────────────
class PinyinIndexBuilder:
def __init__(
self,
max_seq_length: int = 88,
top_k_similar: int = 5,
min_similarity: float = 0.4,
merge_prob: float = 0.5,
min_word_freq: int = 2,
):
self.max_seq_length = max_seq_length
self.top_k_similar = top_k_similar
self.min_similarity = min_similarity
self.merge_prob = merge_prob
self.min_word_freq = min_word_freq
def build(
self,
corpus_path: str,
output_path: str,
max_samples: int = 10_000_000,
name_corpus: Optional[str] = None,
):
import jieba
from datasets import load_dataset
# 加载姓名+地名+成语词典
logger.info("Loading name/place/chengyu dictionaries...")
names = load_name_dict(name_corpus)
for name in names:
jieba.add_word(name)
logger.info(f"Loaded {len(names):,} entries into jieba user dict")
# 流式读取语料
dataset = load_dataset(corpus_path, split="train", streaming=True)
word_freq: Dict[str, int] = defaultdict(int)
word_pinyin_map: Dict[str, str] = {}
n_total = 0
merge_added = 0
logger.info(f"Streaming corpus (max {max_samples:,} samples)...")
pbar = tqdm(total=max_samples, desc="Building index", unit="samples")
for sample in dataset:
if n_total >= max_samples:
break
text = sample.get("text", "")
if not text or len(text) < 10:
continue
words = list(jieba.cut(text, HMM=False))
for w in words:
w = w.strip()
if not w or not any(_is_chinese(c) for c in w):
continue
word_freq[w] += 1
if w not in word_pinyin_map:
word_pinyin_map[w] = _word_to_pinyin(w)
# 连续单字中文词随机合并
i = 0
while i < len(words):
if not (_is_chinese(words[i]) and len(words[i]) == 1):
i += 1
continue
j = i + 1
while j < len(words) and j - i < 4:
if not (_is_chinese(words[j]) and len(words[j]) == 1):
break
j += 1
run_len = j - i
if run_len >= 2 and random.random() < self.merge_prob:
max_merge = min(run_len, 4)
merge_len = random.randint(2, max_merge)
merged = "".join(words[i : i + merge_len])
if merged not in word_freq:
word_freq[merged] += 1
word_pinyin_map[merged] = _word_to_pinyin(merged)
merge_added += 1
i = max(i + 1, j)
n_total += 1
if n_total % 1000 == 0:
pbar.update(1000)
pbar.set_postfix(words=len(word_freq), merged=merge_added)
pbar.close()
words_to_keep = {w for w, f in word_freq.items() if f >= self.min_word_freq}
logger.info(
f"Corpus: {n_total:,} texts, {len(words_to_keep):,} unique words "
f"(min_freq={self.min_word_freq}), merge_added={merge_added:,}"
)
pinyin_to_words: Dict[str, List[str]] = defaultdict(list)
for w in words_to_keep:
py = word_pinyin_map[w]
pinyin_to_words[py].append(w)
unique_pinyins = list(pinyin_to_words.keys())
logger.info(f"Computing pinyin similarity for {len(unique_pinyins):,} pinyin strings...")
buckets: Dict[int, List[Tuple[str, List[str]]]] = defaultdict(list)
for py in unique_pinyins:
buckets[len(py)].append((py, pinyin_to_words[py]))
similar_pinyin: Dict[str, List[str]] = {}
bucket_keys = sorted(buckets.keys())
for k in tqdm(bucket_keys, desc="Pinyin similarity", unit="buckets"):
current = buckets[k]
candidate_keys = [k2 for k2 in bucket_keys if abs(k2 - k) <= 1]
candidates = []
for ck in candidate_keys:
candidates.extend(buckets[ck])
for py, _ in current:
sims = []
for cand_py, _ in candidates:
if cand_py == py:
continue
sim = _pinyin_similarity(py, cand_py)
if sim >= self.min_similarity:
sims.append((cand_py, sim))
sims.sort(key=lambda x: x[1], reverse=True)
similar_pinyin[py] = [s[0] for s in sims[:self.top_k_similar]]
output = {
"pinyin_to_words": {k: v for k, v in pinyin_to_words.items()},
"similar_pinyin": similar_pinyin,
"word_to_pinyin": word_pinyin_map,
"word_freq": {k: v for k, v in word_freq.items() if k in words_to_keep},
"meta": {
"num_texts": n_total,
"num_words": len(words_to_keep),
"num_pinyins": len(unique_pinyins),
"merge_added": merge_added,
"top_k_similar": self.top_k_similar,
"min_similarity": self.min_similarity,
},
}
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(msgpack.packb(output))
total_entries = sum(len(v) for v in similar_pinyin.values())
size_mb = Path(output_path).stat().st_size / (1024 * 1024)
logger.info(
f"Saved: {output_path} ({size_mb:.1f} MB), "
f"{len(words_to_keep):,} words, {len(unique_pinyins):,} pinyins, "
f"{total_entries:,} similarity edges"
)
return output_path
def load_pinyin_index(path: str) -> Dict:
with open(path, "rb") as f:
return msgpack.unpackb(f.read(), raw=False)

View File

@ -63,7 +63,7 @@ class PinyinInputDataset(IterableDataset):
data_path: str,
max_workers: int = -1,
max_iter_length=1e6,
max_seq_length=128,
max_seq_length=88,
text_field: str = "text",
py_style_weight=(9, 2, 1),
shuffle_buffer_size: int = 100000,
@ -76,6 +76,10 @@ class PinyinInputDataset(IterableDataset):
high_freq_repeat: float = 0.1,
data_kwargs: Optional[Dict] = None,
target_labels: Optional[Set[int]] = None,
pinyin_index: Optional[Dict] = None,
confusion_max_freq: int = 100,
part4_ratio: float = 0.6,
part4_target_remove: float = 0.5,
):
# 频率调整参数 - 幂律平滑方案
self.min_freq = 109
@ -91,6 +95,11 @@ class PinyinInputDataset(IterableDataset):
self.data_kwargs = data_kwargs or {}
self.target_labels = target_labels
self.pinyin_index = pinyin_index
self.confusion_max_freq = confusion_max_freq
self.part4_ratio = part4_ratio
self.part4_target_remove = part4_target_remove
jieba.initialize()
self.tokenizer = AutoTokenizer.from_pretrained(
@ -245,6 +254,17 @@ class PinyinInputDataset(IterableDataset):
pinyin_ids = pinyin_ids[:24]
return torch.tensor(pinyin_ids, dtype=torch.long)
def _build_part4_ids(self, part4_str: str, max_words: int = 5, max_word_len: int = 6) -> torch.Tensor:
part4_ids = torch.zeros(max_words, max_word_len, dtype=torch.long)
if not part4_str:
return part4_ids
words = part4_str.split("|")
for i, w in enumerate(words[:max_words]):
tokens = self.tokenizer.encode(w, add_special_tokens=False)[:max_word_len]
for j, t in enumerate(tokens):
part4_ids[i, j] = t
return part4_ids
def _build_single_sample(
self,
label: int,
@ -255,34 +275,51 @@ class PinyinInputDataset(IterableDataset):
part2: str,
pinyin_ids: torch.Tensor,
words: list,
target_word: str = "",
word_pinyin: str = "",
) -> dict:
"""构造单条样本,每次调用都会重新随机采样上下文"""
# part1 长度:高斯分布 N(36, 6^2),截断 [0, min(48, word_start)]
part1_len = min(max(int(random.gauss(36, 6)), 0), 48, word_start)
part1 = text[word_start - part1_len : word_start]
# part3每次重新 roll
part3 = ""
if random.random() > 0.7:
part3 = text[word_end : word_end + random.randint(1, 16)]
# part4每次重新 roll
part4 = ""
if random.random() > 0.7 and words:
if self.pinyin_index is not None and target_word and word_pinyin:
freq = self.pinyin_index.get("word_freq", {}).get(target_word, 0)
if freq <= self.confusion_max_freq:
if random.random() < self.part4_ratio:
candidates = []
seen = {target_word}
pinyin_to_words = self.pinyin_index.get("pinyin_to_words", {})
similar_pinyin = self.pinyin_index.get("similar_pinyin", {})
for py in similar_pinyin.get(word_pinyin, [word_pinyin]):
for w in pinyin_to_words.get(py, []):
if w not in seen:
candidates.append(w)
seen.add(w)
if target_word in candidates and random.random() < self.part4_target_remove:
candidates.remove(target_word)
if candidates:
num = random.randint(1, min(4, len(candidates)))
selected = random.sample(candidates, num)
part4 = "|".join(selected)
elif random.random() > 0.7 and words:
num_words = random.randint(1, 3)
selected_words = random.sample(words, min(num_words, len(words)))
part4 = "|".join(selected_words)
part4_ids = self._build_part4_ids(part4)
encoded = self.tokenizer(
f"{part4}|{part1}",
part3,
part1 if part1 else "",
part3 if part3 else None,
max_length=self.max_seq_length,
truncation=True,
return_token_type_ids=True,
)
# 确保 history 长度为 8
hist = list(history)
if len(hist) > 8:
hist = hist[-8:]
@ -299,6 +336,9 @@ class PinyinInputDataset(IterableDataset):
"suffix": part3,
"pinyin": part2,
"pinyin_ids": pinyin_ids,
"target_word": target_word,
"word_pinyin": word_pinyin,
"part4_ids": part4_ids,
}
def __iter__(self):
@ -448,7 +488,13 @@ class PinyinInputDataset(IterableDataset):
if not should_break and random.random() <= 0.1:
labels.append(0)
# 逐个 label 处理,削峰填谷前置,每次重复重新采样上下文
target_word = text[word_start:word_end]
word_pinyin = "".join(
pinyin_list[i]
for i in range(word_start, word_end)
if self.query_engine.is_chinese_char(text[i])
)
processed_history = []
for label_idx, label in enumerate(labels):
base_repeats = self.adjust_frequency(
@ -481,6 +527,8 @@ class PinyinInputDataset(IterableDataset):
part2=part2,
pinyin_ids=pinyin_ids,
words=words,
target_word=target_word,
word_pinyin=word_pinyin,
)
batch_samples.append(sample)
@ -549,6 +597,12 @@ class PinyinInputDataset(IterableDataset):
# 逐个 label 处理,削峰填谷前置,每次重复重新采样上下文
cont_processed_history = []
cont_end = cont_positions[-1] + 1
cont_target_word = text[cont_start:cont_end]
cont_word_pinyin = "".join(
pinyin_list[i]
for i in range(cont_start, cont_end)
if self.query_engine.is_chinese_char(text[i])
)
for label_idx, label in enumerate(cont_labels):
base_repeats = self.adjust_frequency(
self.sample_freqs.get(label, 0)
@ -580,6 +634,8 @@ class PinyinInputDataset(IterableDataset):
part2=part2_cont,
pinyin_ids=pinyin_ids_cont,
words=words,
target_word=cont_target_word,
word_pinyin=cont_word_pinyin,
)
batch_samples.append(sample)

View File

@ -144,7 +144,7 @@ class ContextEncoderExport(nn.Module):
class DecoderExport(nn.Module):
"""
解码器导出模型
输入: context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask
输入: context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask, part4_ids
输出: logits
"""
@ -155,6 +155,9 @@ class DecoderExport(nn.Module):
moe: MoELayer,
slot_attention: nn.Module,
classifier: nn.Module,
text_emb: nn.Module,
part4_proj: nn.Module,
part4_ln: nn.Module,
num_slots: int = 8,
dim: int = 512,
):
@ -164,10 +167,23 @@ class DecoderExport(nn.Module):
self.moe = moe
self.slot_attention = slot_attention
self.classifier = classifier
self.text_emb = text_emb
self.part4_proj = part4_proj
self.part4_ln = part4_ln
self.num_slots = num_slots
self.dim = dim
def forward(self, context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask):
def _encode_part4(self, part4_ids):
B, N, L = part4_ids.shape
flat = part4_ids.view(B * N, L)
emb = self.text_emb(flat)
valid_mask = (flat != 0).unsqueeze(-1).float()
pooled = (emb * valid_mask).sum(dim=1) / valid_mask.sum(dim=1).clamp(min=1)
pooled = self.part4_ln(self.part4_proj(pooled))
return pooled.view(B, N, self.dim)
def forward(self, context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask,
part4_ids=None):
"""
Args:
context_H: [batch_size, seq_len, dim]
@ -175,20 +191,23 @@ class DecoderExport(nn.Module):
history_slot_ids: [batch_size, num_slots]
context_mask: [batch_size, seq_len] (int32, 1表示padding)
pinyin_mask: [batch_size, pinyin_len] (int32, 1表示padding)
part4_ids: [batch_size, num_words, max_word_len] (optional)
Returns:
logits: [batch_size, vocab_size]
"""
batch_size = context_H.size(0)
# 确保history_slot_ids形状正确
history_slot_ids = history_slot_ids.view(batch_size, self.num_slots)
# 1. 槽位记忆
S = self.slot_memory(history_slot_ids) # [batch_size, num_slots, dim]
S = self.slot_memory(history_slot_ids)
part4_E = None
part4_mask = None
if part4_ids is not None and part4_ids.size(1) > 0:
part4_E = self._encode_part4(part4_ids)
part4_mask = (part4_ids.sum(dim=-1) == 0).to(torch.bool)
# 2. 交叉注意力融合
# 转换mask: int32 -> bool (ONNX中需要bool类型)
context_mask_bool = context_mask.to(torch.bool)
pinyin_mask_bool = pinyin_mask.to(torch.bool)
@ -198,20 +217,17 @@ class DecoderExport(nn.Module):
pinyin_P,
context_mask=context_mask_bool,
pinyin_mask=pinyin_mask_bool,
) # [batch_size, num_slots, dim]
part4_E=part4_E,
part4_mask=part4_mask,
)
# 3. MoE层
moe_out = self.moe(fused) # [batch_size, num_slots, dim]
moe_out = self.moe(fused)
# 4. 槽位注意力池化
slot_scores = self.slot_attention(moe_out).squeeze(
-1
) # [batch_size, num_slots]
slot_weights = torch.softmax(slot_scores, dim=1) # [batch_size, num_slots]
pooled = (moe_out * slot_weights.unsqueeze(-1)).sum(dim=1) # [batch_size, dim]
slot_scores = self.slot_attention(moe_out).squeeze(-1)
slot_weights = torch.softmax(slot_scores, dim=1)
pooled = (moe_out * slot_weights.unsqueeze(-1)).sum(dim=1)
# 5. 分类头
logits = self.classifier(pooled) # [batch_size, vocab_size]
logits = self.classifier(pooled)
return logits
@ -245,7 +261,7 @@ def create_export_models_from_checkpoint(checkpoint_path, device="cpu"):
"n_layers": 4,
"n_heads": 4,
"num_experts": 10,
"max_seq_len": 128,
"max_seq_len": 88,
}
# 创建原始模型
@ -280,6 +296,9 @@ def create_export_models_from_checkpoint(checkpoint_path, device="cpu"):
moe=model.moe,
slot_attention=model.slot_attention,
classifier=model.classifier,
text_emb=model.context_encoder.text_emb,
part4_proj=model.part4_proj,
part4_ln=model.part4_ln,
num_slots=model.num_slots,
dim=model.dim,
)

View File

@ -39,17 +39,17 @@ class InputMethodEngine(nn.Module):
n_layers: int = 4,
n_heads: int = 4,
num_experts: int = 10,
max_seq_len: int = 128,
max_seq_len: int = 88,
compile: bool = False,
moe_mode: str = "all", # "all" / "sparse" / "sparse_allow_graph"
freeze_encoder: bool = False,
):
super().__init__()
self.dim = dim
self.num_slots = num_slots
self.vocab_size = vocab_size
self.max_seq_len = max_seq_len
# 1. 上下文编码器 (ContextEncoder)
# 若 use_pinyin=False则传入 pinyin_vocab_size=1 并固定嵌入为零
self.context_encoder = ContextEncoder(
vocab_size=vocab_size,
pinyin_vocab_size=pinyin_vocab_size,
@ -59,20 +59,15 @@ class InputMethodEngine(nn.Module):
max_len=max_seq_len,
)
# 2. 槽位记忆模块 (SlotMemory)
# 适配历史槽位数量为 num_slots每个槽位对应一个词而非多步
self.slot_memory = SlotMemory(
vocab_size=vocab_size,
max_slots=num_slots,
steps_per_slot=1, # 每个槽位只占一步
steps_per_slot=1,
dim=dim,
)
# 3. 交叉注意力融合 (CrossAttentionFusion)
# 使用 F.scaled_dot_product_attention 实现的版本
self.cross_attn = CrossAttentionFusion(dim=dim, n_heads=n_heads)
# 4. 混合专家层 (MoE)
self.moe = MoELayer(
dim=dim,
num_experts=num_experts,
@ -81,12 +76,17 @@ class InputMethodEngine(nn.Module):
moe_mode=moe_mode,
)
# 5. 槽位注意力池化
self.slot_attention = nn.Linear(dim, 1)
# 6. 分类头
self.classifier = nn.Linear(dim, vocab_size)
self.part4_proj = nn.Linear(dim, dim, bias=False)
self.part4_ln = nn.LayerNorm(dim)
if freeze_encoder:
for p in self.context_encoder.parameters():
p.requires_grad_(False)
if compile:
self.forward = torch.compile(
self.forward,
@ -102,6 +102,7 @@ class InputMethodEngine(nn.Module):
attention_mask: torch.Tensor,
pinyin_ids: torch.Tensor,
history_slot_ids: torch.Tensor,
part4_ids: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""
前向传播
@ -116,12 +117,24 @@ class InputMethodEngine(nn.Module):
H, P = self.context_encoder(input_ids, pinyin_ids, mask=attention_mask)
part4_E = None
part4_mask = None
if part4_ids is not None and part4_ids.size(1) > 0:
part4_E = self._encode_part4(part4_ids)
part4_mask = part4_ids.sum(dim=-1) == 0
S = self.slot_memory(history_slot_ids)
context_mask = attention_mask == 0
pinyin_mask = pinyin_ids == 0
fused = self.cross_attn(
S, H, P, context_mask=context_mask, pinyin_mask=pinyin_mask
S,
H,
P,
context_mask=context_mask,
pinyin_mask=pinyin_mask,
part4_E=part4_E,
part4_mask=part4_mask,
)
fused = fused * slot_mask.unsqueeze(-1)
@ -137,3 +150,12 @@ class InputMethodEngine(nn.Module):
logits = self.classifier(pooled)
return logits
def _encode_part4(self, part4_ids: torch.Tensor) -> torch.Tensor:
B, N, L = part4_ids.shape
flat = part4_ids.view(B * N, L)
emb = self.context_encoder.text_emb(flat)
valid_mask = (flat != 0).unsqueeze(-1).float()
pooled = (emb * valid_mask).sum(dim=1) / valid_mask.sum(dim=1).clamp(min=1)
pooled = self.part4_ln(self.part4_proj(pooled))
return pooled.view(B, N, self.dim)

View File

@ -101,6 +101,9 @@ def export_decoder(
history_slot_ids = torch.randint(
0, 100, (batch_size, num_slots), dtype=torch.long
)
part4_ids = torch.randint(
1, 1000, (batch_size, 5, 6), dtype=torch.long
)
else:
context_H = torch.randn(batch_size, seq_len, dim, dtype=torch.float32)
pinyin_P = torch.randn(batch_size, pinyin_len, dim, dtype=torch.float32)
@ -109,6 +112,9 @@ def export_decoder(
history_slot_ids = torch.randint(
0, 100, (batch_size, num_slots), dtype=torch.long
)
part4_ids = torch.randint(
1, 1000, (batch_size, 5, 6), dtype=torch.long
)
dynamic_axes = {
"context_H": {0: "batch_size", 1: "seq_len"},
@ -116,12 +122,13 @@ def export_decoder(
"history_slot_ids": {0: "batch_size"},
"context_mask": {0: "batch_size", 1: "seq_len"},
"pinyin_mask": {0: "batch_size"},
"part4_ids": {0: "batch_size", 1: "num_words"},
"logits": {0: "batch_size"},
}
torch.onnx.export(
model,
(context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask),
(context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask, part4_ids),
output_path,
input_names=[
"context_H",
@ -129,6 +136,7 @@ def export_decoder(
"history_slot_ids",
"context_mask",
"pinyin_mask",
"part4_ids",
],
output_names=["logits"],
dynamic_axes=dynamic_axes,
@ -147,7 +155,7 @@ def export_decoder(
except Exception as e:
print(f" ONNX 模型验证警告: {e}")
return context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask
return context_H, pinyin_P, history_slot_ids, context_mask, pinyin_mask, part4_ids
def save_example_inputs(output_dir: str, example_inputs_dict: Dict) -> None:

View File

@ -43,6 +43,7 @@ from torch.utils.data import DataLoader
from tqdm import tqdm
from .dataset import PinyinInputDataset
from .confusion.pinyin_index import load_pinyin_index
from .trainer import preprocess_collate_fn, worker_init_fn
FIELDS = [
@ -53,12 +54,16 @@ FIELDS = [
"history_slot_ids",
"pinyin_ids",
]
FIELDS_PART4 = FIELDS + ["part4_ids"]
MAX_PART4_WORDS = 5
MAX_WORD_LEN = 6
def _extract_batch(batch: dict, take: int) -> Dict[str, np.ndarray]:
def _extract_batch(batch: dict, take: int, active_fields: list) -> Dict[str, np.ndarray]:
"""从 DataLoader batch 中提取指定数量的样本,转为 int16 numpy 数组"""
result = {}
for f in FIELDS:
for f in active_fields:
tensor = batch[f][:take]
arr = tensor.numpy().astype(np.int16)
if f == "labels" and arr.ndim > 1 and arr.shape[-1] == 1:
@ -72,8 +77,9 @@ def collect_samples(
num_samples: int,
output_dir: Path,
split_name: str,
max_seq_length: int = 128,
max_seq_length: int = 88,
shard_size: int = 5_000_000,
has_part4: bool = False,
) -> int:
"""
分片流式收集样本每累积 shard_size 个样本保存为一个压缩 .npz 分片
@ -82,8 +88,9 @@ def collect_samples(
"""
split_dir = output_dir / split_name
split_dir.mkdir(parents=True, exist_ok=True)
active_fields = FIELDS_PART4 if has_part4 else FIELDS
shard_buffers: Dict[str, List[np.ndarray]] = {f: [] for f in FIELDS}
shard_buffers: Dict[str, List[np.ndarray]] = {f: [] for f in active_fields}
shard_count = 0
shard_idx = 0
total = 0
@ -97,8 +104,8 @@ def collect_samples(
break
take = min(batch_size, remaining)
extracted = _extract_batch(batch, take)
for f in FIELDS:
extracted = _extract_batch(batch, take, active_fields)
for f in active_fields:
shard_buffers[f].append(extracted[f])
shard_count += take
@ -107,13 +114,13 @@ def collect_samples(
if shard_count >= shard_size:
merged = {}
for f in FIELDS:
for f in active_fields:
merged[f] = np.concatenate(shard_buffers[f], axis=0)
np.savez_compressed(split_dir / f"shard_{shard_idx:06d}.npz", **merged)
logger.debug(f"Saved {split_name} shard {shard_idx}: {shard_count} samples")
shard_idx += 1
shard_buffers = {f: [] for f in FIELDS}
shard_buffers = {f: [] for f in active_fields}
shard_count = 0
del merged
gc.collect()
@ -124,7 +131,7 @@ def collect_samples(
# 写入最后一个不满的分片
if shard_count > 0:
merged = {}
for f in FIELDS:
for f in active_fields:
merged[f] = np.concatenate(shard_buffers[f], axis=0)
np.savez_compressed(split_dir / f"shard_{shard_idx:06d}.npz", **merged)
logger.debug(f"Saved {split_name} shard {shard_idx}: {shard_count} samples")
@ -143,7 +150,7 @@ def collect_samples(
"num_samples": actual_count,
"max_seq_length": max_seq_length,
"dtype": "int16",
"fields": FIELDS,
"fields": active_fields,
"shard_size": shard_size,
"num_shards": num_shards,
"shard_sizes": shard_sizes,
@ -189,8 +196,15 @@ def main():
parser.add_argument(
"--num-workers", type=int, default=2, help="DataLoader worker数量"
)
parser.add_argument("--max-seq-length", type=int, default=128, help="最大序列长度")
parser.add_argument("--max-seq-length", type=int, default=88, help="最大序列长度")
parser.add_argument("--seed", type=int, default=42, help="随机种子")
parser.add_argument(
"--pinyin-index", type=str, default=None, help="拼音索引文件路径pinyin_index.msgpack启用混淆 part4 生成"
)
parser.add_argument(
"--confusion-max-freq", type=int, default=100,
help="混淆频率阈值: 词频 ≤ N 才触发混淆,默认 100"
)
parser.add_argument(
"--shard-size",
type=int,
@ -227,6 +241,13 @@ def main():
for k, v in (item.split(":") for item in args.length_weights.split(","))
}
pinyin_index = None
if args.pinyin_index:
pinyin_index = load_pinyin_index(args.pinyin_index)
console.print(f"[bold cyan]拼音索引已加载: {args.pinyin_index}[/bold cyan]")
console.print(f" 词数: {pinyin_index['meta'].get('num_words', '?'):,}")
console.print(f" 混淆频率阈值: ≤{args.confusion_max_freq}")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
@ -257,6 +278,8 @@ def main():
py_style_weight=py_style_weight,
shuffle_buffer_size=100,
length_weights=length_weights,
pinyin_index=pinyin_index,
confusion_max_freq=args.confusion_max_freq,
)
train_dataloader = DataLoader(
@ -294,6 +317,7 @@ def main():
)
logger.info("开始收集训练数据...")
has_p4 = pinyin_index is not None
train_count = collect_samples(
train_dataloader,
args.num_train_samples,
@ -301,6 +325,7 @@ def main():
"train",
args.max_seq_length,
args.shard_size,
has_part4=has_p4,
)
if train_count < args.num_train_samples:
@ -316,6 +341,7 @@ def main():
"eval",
args.max_seq_length,
args.shard_size,
has_part4=False,
)
if eval_count < args.num_eval_samples:

View File

@ -31,6 +31,8 @@ FIELDS = [
"pinyin_ids",
]
_PART4_FIELDS = FIELDS + ["part4_ids"]
def _read_shard_size(npz_path: Path) -> int:
with zipfile.ZipFile(npz_path, "r") as z:
@ -190,6 +192,7 @@ class PreProcessedDataset(Dataset):
shard_idx = lo - 1
local_idx = idx - self._shard_offsets[shard_idx]
shard_data = self._cache.get(shard_idx, self._load_shard)
has_p4 = "part4_ids" in shard_data
result = {
"input_ids": torch.from_numpy(
shard_data["input_ids"][local_idx].astype(np.int64)
@ -210,6 +213,10 @@ class PreProcessedDataset(Dataset):
shard_data["pinyin_ids"][local_idx].astype(np.int64)
),
}
if has_p4:
result["part4_ids"] = torch.from_numpy(
shard_data["part4_ids"][local_idx].astype(np.int64)
)
else:
result = {
"input_ids": torch.from_numpy(self.input_ids[idx].astype(np.int64)),
@ -233,7 +240,7 @@ def preprocessed_collate_fn(batch):
预处理数据的 collate 函数
不含 string 字段prefix/suffix/pinyin仅处理 tensor 字段
"""
return {
result = {
"input_ids": torch.stack([item["input_ids"] for item in batch]),
"token_type_ids": torch.stack([item["token_type_ids"] for item in batch]),
"attention_mask": torch.stack([item["attention_mask"] for item in batch]),
@ -241,3 +248,6 @@ def preprocessed_collate_fn(batch):
"history_slot_ids": torch.stack([item["history_slot_ids"] for item in batch]),
"pinyin_ids": torch.stack([item["pinyin_ids"] for item in batch]),
}
if "part4_ids" in batch[0]:
result["part4_ids"] = torch.stack([item["part4_ids"] for item in batch])
return result

View File

@ -265,6 +265,7 @@ class Trainer:
attention_mask=attention_mask,
pinyin_ids=pinyin_ids,
history_slot_ids=history_slot_ids,
part4_ids=batch.get("part4_ids"),
)
# 计算损失
@ -362,11 +363,20 @@ class Trainer:
"scaler_state_dict": self.scaler.state_dict(),
"best_eval_loss": self.best_eval_loss,
"config": {
"vocab_size": self.model.vocab_size,
"pinyin_vocab_size": 30,
"dim": self.model.dim,
"num_slots": self.model.num_slots,
"n_layers": self.model.context_encoder.transformer.num_layers,
"n_heads": self.model.cross_attn.n_heads,
"num_experts": self.model.moe.num_experts,
"max_seq_len": self.model.max_seq_len,
"learning_rate": self.learning_rate,
"weight_decay": self.weight_decay,
"warmup_ratio": self.warmup_ratio,
"label_smoothing": self.label_smoothing,
"total_steps": self.total_steps,
"moe_mode": self.model.moe.moe_mode,
},
}
@ -436,11 +446,20 @@ class Trainer:
"scaler_state_dict": self.scaler.state_dict(),
"best_eval_loss": self.best_eval_loss,
"config": {
"vocab_size": self.model.vocab_size,
"pinyin_vocab_size": 30,
"dim": self.model.dim,
"num_slots": self.model.num_slots,
"n_layers": self.model.context_encoder.transformer.num_layers,
"n_heads": self.model.cross_attn.n_heads,
"num_experts": self.model.moe.num_experts,
"max_seq_len": self.model.max_seq_len,
"learning_rate": self.learning_rate,
"weight_decay": self.weight_decay,
"warmup_ratio": self.warmup_ratio,
"label_smoothing": self.label_smoothing,
"total_steps": self.total_steps,
"moe_mode": self.model.moe.moe_mode,
},
}
@ -1089,9 +1108,32 @@ def collate_fn(batch: List[Dict[str, Any]], max_seq_length: int = 0) -> Dict[str
pinyin_ids = torch.stack([item["pinyin_ids"] for item in batch])
prefixes = [item["prefix"] for item in batch]
suffixes = [item["suffix"] for item in batch]
suffixes = [item.get("suffix", "") for item in batch]
pinyins = [item["pinyin"] for item in batch]
target_words = [item.get("target_word", "") for item in batch]
word_pinyins = [item.get("word_pinyin", "") for item in batch]
part4_ids_list = [item.get("part4_ids", torch.zeros(0)) for item in batch]
has_part4 = any(p.numel() > 0 and p.sum() > 0 for p in part4_ids_list)
if has_part4:
part4_n = max(p.shape[0] for p in part4_ids_list if p.numel() > 0)
part4_l = max(p.shape[1] for p in part4_ids_list if p.numel() > 0)
padded_part4_ids = []
for pids in part4_ids_list:
if pids.numel() == 0 or pids.shape[0] < part4_n:
if pids.numel() == 0:
pad = torch.zeros(part4_n, part4_l, dtype=torch.long)
else:
pw = torch.zeros(part4_n - pids.shape[0], pids.shape[1], dtype=torch.long)
pad = torch.cat([pids, pw], dim=0)
padded_part4_ids.append(pad)
else:
padded_part4_ids.append(pids[:part4_n])
part4_ids = torch.stack(padded_part4_ids)
else:
part4_ids = None
return {
"input_ids": input_ids,
"token_type_ids": token_type_ids,
@ -1102,6 +1144,9 @@ def collate_fn(batch: List[Dict[str, Any]], max_seq_length: int = 0) -> Dict[str
"suffix": suffixes,
"pinyin": pinyins,
"pinyin_ids": pinyin_ids,
"target_word": target_words,
"word_pinyin": word_pinyins,
"part4_ids": part4_ids,
}
@ -1143,7 +1188,7 @@ def create_dataloader(
)
logger.info(f"📊 使用标准DataLoaderworker数量: {num_workers}")
fixed_max_seq_length = getattr(dataset, "max_seq_length", 128)
fixed_max_seq_length = getattr(dataset, "max_seq_length", 88)
dataloader = DataLoader(
dataset,
batch_size=batch_size,
@ -1222,6 +1267,11 @@ def train(
"--moe-mode",
help="MoE 计算策略: all全量计算, sparse稀疏计算, sparse_allow_graph稀疏+allow_in_graph",
),
freeze_encoder: bool = typer.Option(
False,
"--freeze-encoder/--no-freeze-encoder",
help="是否冻结编码器(微调模式)",
),
):
"""
训练输入法模型
@ -1245,7 +1295,7 @@ def train(
n_layers = 4
n_heads = 4
num_experts = 10
max_seq_len = 128
max_seq_len = 88
use_pinyin = True # 始终使用拼音
console = Console()
@ -1436,6 +1486,7 @@ def train(
max_seq_len=max_seq_len,
compile=compile,
moe_mode=moe_mode,
freeze_encoder=freeze_encoder,
)
console.print(