MiniMind 第 4 篇:《数据工程|Tokenizer 训练 + 预训练 / SFT/DPO 全数据集处理》

承接上一篇内容:我们拆解了 MiniMind 底层核心架构,吃透了 RMSNorm、SwiGLU、RoPE 三大组件的工程实现与优化逻辑。现在,终于轮到 LLM 最关键的「粮草」------ 数据工程。

行业里流传一句话:「LLM 效果三分靠模型,七分靠数据」。对 MiniMind 这种超小模型来说,数据的质量、格式、适配性更是决定性因素 ------26M 参数的模型,根本经不起低质量数据的「误导」。

本篇聚焦「数据全流程」,纯实操视角 + 源码落地:

  1. 亲手训练 MiniMind 专属 Tokenizer,理解 6400 词表的设计逻辑;
  2. 拆解预训练 / SFT/DPO 三类核心数据集的格式、清洗规则、加载流程;
  3. 解决数据下载慢、格式错误、适配失败等新手高频坑;
  4. 教你适配自定义数据集,为后续训练自己的垂域小模型铺路。

建议打开 MiniMind 源码 trainer/train_tokenizer.pyutils/data_utils.py 对照阅读,边看边实操!开源项目地址:https://github.com/jingyaogong/minimind

一、先明确:数据工程对小模型的核心意义

小模型和大模型的「数据需求」完全不同:

  • 大模型:靠海量数据「堆知识」,哪怕有部分噪声,也能靠参数规模稀释;
  • 小模型:参数有限,必须「精准投喂」------ 高质量、高相关性、格式统一的数据,才能让每一个参数都发挥价值。

MiniMind 的数据工程核心目标:用最少的数据,实现最优的效果,具体体现在三点:

  1. Tokenizer 轻量化:6400 词表,避免词嵌入层占用过多参数;
  2. 数据集精简:预训练 / SFT/DPO 数据均经过二次清洗,去重去噪;
  3. 格式统一:所有数据统一为 JSONL 格式,降低训练脚本复杂度。

二、核心部分 1:Tokenizer 训练 ------ 给小模型造一本「专属词典」

2.1 Tokenizer 是什么?(极简理解)

Tokenizer 本质是「语言词典 + 编码规则」:

  • 把自然语言(如「你好,MiniMind」)拆分成模型能理解的「Token 序列」(如 [1024, 3, 5678]);
  • 训练时,模型学习的是 Token 之间的关联,而非直接学习文字;
  • 词典(词表)的大小、拆分规则,直接影响模型的编码效率和参数量。

2.2 MiniMind 为什么不用现成 Tokenizer?偏要自定义训练?

市面上主流 Tokenizer(如 Llama3、Qwen2)的词表都在 32k 以上,对小模型来说有两个致命问题:

  1. 参数量浪费:词嵌入层参数 = 词表大小 × 嵌入维度(如 32k×512=16.38M),占 MiniMind2-Small 总参数(26M)的 63%,导致模型「头重脚轻」;
  2. 编码效率低:大词表适合多语言、复杂文本,MiniMind 聚焦中文对话,无需冗余词汇,小词表编码更快、更精准。

MiniMind 自定义 Tokenizer 的核心设计:

  • 词表大小:6400(仅为 Llama3 的 1/20);
  • 训练算法:SentencePiece(Unigram 模型),适合中文短文本拆分;
  • 核心目标:平衡「编码压缩率」和「模型参数量」。

2.3 亲手训练 MiniMind 风格 Tokenizer(实操步骤)

步骤 1:准备训练数据

训练 Tokenizer 不需要海量数据,100MB-1GB 高质量文本即可。推荐用 MiniMind 预训练数据的子集:

复制代码
# 从预训练数据中抽取前 10 万条文本(足够训练小词表)
head -n 100000 ./dataset/pretrain_hq.jsonl > ./dataset/tokenizer_train_data.jsonl
步骤 2:运行 Tokenizer 训练脚本(源码解析)

MiniMind 训练 Tokenizer 的核心代码在 trainer/train_tokenizer.py,简化后关键逻辑如下:

python 复制代码
import sentencepiece as spm
import json

def train_minimind_tokenizer():
    # 1. 读取训练数据(提取 JSONL 中的 text 字段)
    train_text = []
    with open("./dataset/tokenizer_train_data.jsonl", "r", encoding="utf-8") as f:
        for line in f:
            data = json.loads(line)
            train_text.append(data["text"])
    
    # 2. 写入临时文本文件(SentencePiece 要求输入为纯文本)
    with open("./dataset/tokenizer_temp.txt", "w", encoding="utf-8") as f:
        f.write("\n".join(train_text))
    
    # 3. 配置 SentencePiece 训练参数
    spm.SentencePieceTrainer.Train(
        input="./dataset/tokenizer_temp.txt",
        model_prefix="minimind_tokenizer",  # 输出模型前缀
        vocab_size=6400,  # 词表大小(核心参数)
        model_type="unigram",  # 模型类型(Unigram 适合中文)
        character_coverage=0.9995,  # 字符覆盖度(保证生僻字被包含)
        max_sentence_length=1024,  # 最大句子长度
        pad_id=0,  # PAD token ID
        bos_id=1,  # 句子开始 ID(MiniMind 用 <|im_start|>)
        eos_id=2,  # 句子结束 ID(MiniMind 用 <|im_end|>)
        unk_id=3,  # 未知词 ID
        user_defined_symbols=["<|im_start|>", "<|im_end|>", "<tool_call>", ""]  # 自定义特殊 Token(适配对话/工具调用)
    )
    
    print("Tokenizer 训练完成!生成 minimind_tokenizer.model 和 minimind_tokenizer.vocab")

if __name__ == "__main__":
    train_minimind_tokenizer()
步骤 3:执行训练命令
复制代码
# 进入 trainer 目录
cd trainer
# 运行训练脚本
python train_tokenizer.py
步骤 4:验证 Tokenizer 效果

训练完成后,会生成两个文件:minimind_tokenizer.model(模型文件)和 minimind_tokenizer.vocab(词表文件)。用以下代码测试编码效果:

复制代码
import sentencepiece as spm

# 加载 Tokenizer
tokenizer = spm.SentencePieceProcessor()
tokenizer.Load("minimind_tokenizer.model")

# 测试编码
text = "你好!我是 MiniMind,2 小时就能训练完成。"
tokens = tokenizer.EncodeAsIds(text)
tokens_text = tokenizer.EncodeAsPieces(text)

print("Token ID 序列:", tokens)
print("Token 文本序列:", tokens_text)
print("原始文本:", tokenizer.DecodeIds(tokens))

Token ID 序列: [1024, 3, 5678, 123, 456, 789, ...]
Token 文本序列: ['▁你好', '!', '▁我是', '▁Mini', 'Mind', ',', ...]
原始文本: 你好!我是 MiniMind,2 小时就能训练完成。

2.4 Tokenizer 优化关键技巧(小模型专属)

  1. 词表大小不能太小:低于 4000 会导致「字符级拆分」(如「 MiniMind」拆成「M」「i」「n」),编码效率极低;
  2. 保留高频特殊符号 :对话场景必须加入 <|im_start|> <|im_end|>,工具调用场景加入 <tool_call>
  3. 中文优先:训练数据以中文为主,避免英文词汇占用过多词表空间(MiniMind 中文 Token 占比 85%+);
  4. 避免冗余词汇:过滤低频词(出现次数 <5),减少词表体积。

三、核心部分 2:全数据集处理 ------ 预训练 / SFT/DPO 三阶段适配

MiniMind 支持三类核心数据集,分别对应训练的不同阶段,格式和处理逻辑各有侧重。我们逐一拆解「格式要求 + 清洗规则 + 加载代码」。

3.1 预训练数据:让模型「学知识」

3.1.1 核心作用

预训练是无监督学习,目标是让模型学习语言规律、常识、基础概念(比如「杭州是浙江的省会」「1+1=2」),相当于给模型「喂墨水」。

3.1.2 数据格式(JSONL)

MiniMind 预训练数据统一为单字段 JSONL(每行一个样本):

复制代码
{"text": "如何摆脱拖延症?治愈拖延症并不容易,但以下建议可能有所帮助:1. 分解任务;2. 设定截止时间;3. 减少干扰。"}
{"text": "光合作用是植物利用光能将二氧化碳和水转化为有机物,并释放氧气的过程。"}
3.1.3 数据清洗核心规则(MiniMind 实战标准)

预训练数据质量直接决定模型「知识储备」,MiniMind 对 pretrain_hq.jsonl 的清洗步骤:

  1. 去重:基于文本哈希去重,避免重复内容导致模型过拟合;
  2. 去噪声 :过滤含乱码、特殊符号(如 @#$%^&*)、广告的文本;
  3. 长度筛选:保留字符数 <512 的文本(小模型算力有限,长文本训练效率低);
  4. 质量过滤:过滤无意义文本(如「啊啊啊啊啊」「123456」);
  5. 编码统一:全部转为 UTF-8,避免中文乱码。
3.1.4 加载代码(utils/data_utils.py 核心片段)
python 复制代码
import json
from torch.utils.data import Dataset

class PretrainDataset(Dataset):
    def __init__(self, data_path, tokenizer, max_seq_len=512):
        self.data = []
        # 读取 JSONL 数据
        with open(data_path, "r", encoding="utf-8") as f:
            for line in f:
                try:
                    item = json.loads(line)
                    self.data.append(item["text"])
                except:
                    continue  # 跳过格式错误的样本
        self.tokenizer = tokenizer
        self.max_seq_len = max_seq_len

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        text = self.data[idx]
        # 编码:添加 EOS 标记,截断/填充到 max_seq_len
        tokens = self.tokenizer.EncodeAsIds(text) + [self.tokenizer.eos_id]
        if len(tokens) > self.max_seq_len:
            tokens = tokens[:self.max_seq_len]
        else:
            tokens += [self.tokenizer.pad_id] * (self.max_seq_len - len(tokens))
        return {"input_ids": torch.tensor(tokens, dtype=torch.long)}

# 使用示例
tokenizer = spm.SentencePieceProcessor()
tokenizer.Load("../minimind_tokenizer.model")
dataset = PretrainDataset("../dataset/pretrain_hq.jsonl", tokenizer, max_seq_len=320)
print("预训练数据集样本数:", len(dataset))
3.1.5 推荐数据集
  • 快速训练:pretrain_hq.jsonl(1.6GB,高质量中文文本,适配小模型);
  • 效果增强:可混合 wiki_zh.jsonl(中文维基百科),但需额外清洗。

3.2 SFT 数据:让模型「学对话」

3.2.1 核心作用

SFT(监督微调)是有监督学习,目标是让模型学会「对话逻辑」------ 理解用户指令,给出符合人类习惯的回复,相当于给模型「教规矩」。

3.2.2 数据格式(多轮对话 JSONL)

MiniMind SFT 数据采用 conversations 字段,支持多轮对话:

复制代码
{
  "conversations": [
    {"role": "user", "content": "你好,介绍一下自己"},
    {"role": "assistant", "content": "你好!我是 MiniMind,一个超轻量的开源语言模型,2 小时+3 块钱就能训练完成~"},
    {"role": "user", "content": "推荐一些杭州的特色美食"},
    {"role": "assistant", "content": "杭州的特色美食有西湖醋鱼、龙井虾仁、叫花鸡、片儿川等,每一道都极具江南风味~"}
  ]
}
3.2.3 数据清洗核心规则

SFT 数据质量决定模型「对话体验」,MiniMind 对 sft_mini_512.jsonl 的清洗步骤:

  1. 格式校验 :确保每个 user 对应一个 assistant,无孤立项;
  2. 去重:基于对话内容哈希去重,避免重复对话;
  3. 长度筛选:单轮对话字符数 <512(小模型适配),多轮对话总字符数 <1024;
  4. 内容过滤:过滤含敏感信息、违法内容、无意义回复的样本;
  5. 中文优先:保留中文占比 >80% 的对话,避免英文对话稀释中文能力。
3.2.4 加载代码(utils/data_utils.py 核心片段)
python 复制代码
class SFTDataset(Dataset):
    def __init__(self, data_path, tokenizer, max_seq_len=512):
        self.data = []
        with open(data_path, "r", encoding="utf-8") as f:
            for line in f:
                try:
                    item = json.loads(line)
                    self.data.append(item["conversations"])
                except:
                    continue
        self.tokenizer = tokenizer
        self.max_seq_len = max_seq_len
        self.bos_id = tokenizer.bos_id
        self.eos_id = tokenizer.eos_id

    def __getitem__(self, idx):
        conversations = self.data[idx]
        input_ids = []
        # 拼接对话:<|im_start|>user: 内容<|im_end|><|im_start|>assistant: 内容<|im_end|>
        for turn in conversations:
            role = turn["role"]
            content = turn["content"].strip()
            # 加入角色标记和特殊 Token
            if role == "user":
                input_ids.extend(self.tokenizer.EncodeAsIds(f"<|im_start|>user: {content}<|im_end|>"))
            elif role == "assistant":
                input_ids.extend(self.tokenizer.EncodeAsIds(f"<|im_start|>assistant: {content}<|im_end|>"))
        # 截断/填充
        if len(input_ids) > self.max_seq_len:
            input_ids = input_ids[:self.max_seq_len]
        else:
            input_ids += [self.tokenizer.pad_id] * (self.max_seq_len - len(input_ids))
        return {"input_ids": torch.tensor(input_ids, dtype=torch.long)}

# 使用示例
dataset = SFTDataset("../dataset/sft_mini_512.jsonl", tokenizer, max_seq_len=340)
print("SFT 数据集样本数:", len(dataset))
3.2.5 推荐数据集(按训练速度排序)
  • 极速复现:sft_mini_512.jsonl(1.2GB,1 小时训完);
  • 平衡效果:sft_512.jsonl(7.5GB,6 小时训完);
  • 效果增强:sft_1024.jsonl(5.6GB,4.5 小时训完)+ sft_2048.jsonl(9GB,7.5 小时训完)。

3.3 DPO 数据:让模型「学偏好」

3.3.1 核心作用

DPO(直接偏好优化)是强化学习的简化版,目标是让模型学会「区分好坏」------ 生成符合人类偏好的回复,拒绝低质量、无意义的输出,相当于给模型「立标准」。

3.3.2 数据格式(偏好对比 JSONL)

DPO 数据需要成对的「优质回复(chosen)」和「劣质回复(rejected)」:

复制代码
{
  "chosen": [
    {"role": "user", "content": "解释一下什么是光合作用"},
    {"role": "assistant", "content": "光合作用是植物、藻类和某些细菌利用光能,将二氧化碳和水转化为有机物,并释放氧气的过程,是地球生态系统的基础。"}
  ],
  "rejected": [
    {"role": "user", "content": "解释一下什么是光合作用"},
    {"role": "assistant", "content": "不知道,没听说过。"}
  ]
}
3.3.3 数据清洗核心规则

DPO 数据质量决定模型「偏好对齐程度」,MiniMind 对 dpo.jsonl 的清洗步骤:

  1. 成对校验 :确保 chosenrejecteduser 内容完全一致,仅 assistant 回复不同;
  2. 质量差异 :保证 chosen 回复准确、完整、有逻辑,rejected 回复错误、简略、无意义;
  3. 长度筛选:单轮对话字符数 <3000,避免长文本占用过多显存;
  4. 去重 :基于 user 内容去重,避免重复偏好样本。
3.3.4 加载代码(utils/data_utils.py 核心片段)
python 复制代码
class DPODataset(Dataset):
    def __init__(self, data_path, tokenizer, max_seq_len=3000):
        self.data = []
        with open(data_path, "r", encoding="utf-8") as f:
            for line in f:
                try:
                    item = json.loads(line)
                    self.data.append({
                        "chosen": item["chosen"],
                        "rejected": item["rejected"]
                    })
                except:
                    continue
        self.tokenizer = tokenizer
        self.max_seq_len = max_seq_len

    def _format_conversation(self, conversations):
        """格式化单条对话(同 SFT 格式)"""
        input_ids = []
        for turn in conversations:
            role = turn["role"]
            content = turn["content"].strip()
            if role == "user":
                input_ids.extend(self.tokenizer.EncodeAsIds(f"<|im_start|>user: {content}<|im_end|>"))
            elif role == "assistant":
                input_ids.extend(self.tokenizer.EncodeAsIds(f"<|im_start|>assistant: {content}<|im_end|>"))
        return input_ids[:self.max_seq_len]

    def __getitem__(self, idx):
        item = self.data[idx]
        # 格式化 chosen 和 rejected 对话
        chosen_ids = self._format_conversation(item["chosen"])
        rejected_ids = self._format_conversation(item["rejected"])
        # 填充
        chosen_ids += [self.tokenizer.pad_id] * (self.max_seq_len - len(chosen_ids))
        rejected_ids += [self.tokenizer.pad_id] * (self.max_seq_len - len(rejected_ids))
        return {
            "chosen_ids": torch.tensor(chosen_ids, dtype=torch.long),
            "rejected_ids": torch.tensor(rejected_ids, dtype=torch.long)
        }

# 使用示例
dataset = DPODataset("../dataset/dpo.jsonl", tokenizer, max_seq_len=3000)
print("DPO 数据集样本数:", len(dataset))
3.3.5 推荐数据集
  • 快速训练:dpo.jsonl(55MB,1 小时训完);
  • 效果增强:可混合 magpie_dpo.jsonl(英文偏好数据),但需注意中文占比。

四、实战:适配自定义数据集(垂域模型必备)

很多读者想基于 MiniMind 训练垂域模型(如医疗、法律、客服),核心是适配自定义数据集。以「医疗问答」为例,教你完整流程:

4.1 自定义数据集格式(医疗 SFT 数据)

复制代码
{
  "conversations": [
    {"role": "user", "content": "颈椎病患者选什么高度的枕头合适?"},
    {"role": "assistant", "content": "颈椎病患者选枕头的高度建议与自身拳头高度相当(约 8-12cm),材质优先选记忆棉、乳胶,避免过硬或过软,同时要保证颈椎自然生理曲度。"}
  ]
}

4.2 数据转换脚本(将 CSV/Excel 转为 JSONL)

如果你的数据是 CSV 格式(如 medical_qa.csv,含 userassistant 列),用以下脚本转换:

python 复制代码
import pandas as pd
import json

# 读取 CSV 数据
df = pd.read_csv("medical_qa.csv")
# 转换为 JSONL
with open("lora_medical.jsonl", "w", encoding="utf-8") as f:
    for idx, row in df.iterrows():
        user_content = str(row["user"]).strip()
        assistant_content = str(row["assistant"]).strip()
        # 跳过空值
        if not user_content or not assistant_content:
            continue
        # 构建格式
        item = {
            "conversations": [
                {"role": "user", "content": user_content},
                {"role": "assistant", "content": assistant_content}
            ]
        }
        f.write(json.dumps(item, ensure_ascii=False) + "\n")
print("转换完成!生成 lora_medical.jsonl")

4.3 数据清洗(去重 + 过滤)

python 复制代码
def clean_custom_data(input_path, output_path):
    seen = set()  # 用于去重
    with open(output_path, "w", encoding="utf-8") as out_f:
        with open(input_path, "r", encoding="utf-8") as in_f:
            for line in in_f:
                try:
                    item = json.loads(line)
                    conv = item["conversations"]
                    # 生成对话哈希(去重)
                    conv_str = json.dumps(conv, ensure_ascii=False)
                    if conv_str in seen:
                        continue
                    seen.add(conv_str)
                    # 过滤短回复(<10 字)
                    assistant_content = conv[1]["content"]
                    if len(assistant_content) < 10:
                        continue
                    # 写入清洗后的数据
                    out_f.write(json.dumps(item, ensure_ascii=False) + "\n")
                except:
                    continue
    print(f"清洗完成!生成 {output_path},共 {len(seen)} 条有效样本")

clean_custom_data("lora_medical.jsonl", "lora_medical_cleaned.jsonl")

4.4 加载自定义数据集训练

将清洗后的 lora_medical_cleaned.jsonl 放到 ./dataset 目录,即可用 MiniMind 的 LoRA 训练脚本训练:

复制代码
cd trainer
python train_lora.py --data_path ../dataset/lora_medical_cleaned.jsonl

五、数据工程高频问题解决方案

5.1 数据下载慢 / 超时

  • 解决方案 1:用 ModelScope 镜像(国内访问快):https://www.modelscope.cn/models/gongjy/MiniMind2/datasets
  • 解决方案 2:单独下载所需文件(无需克隆整个数据集仓库),用迅雷 / IDM 加速。

5.2 格式错误导致训练报错

  • 排查步骤:用 jsonlint 检查 JSONL 格式,或运行以下脚本筛选错误样本:

    python 复制代码
    import json
    with open("sft_mini_512.jsonl", "r") as f:
        for idx, line in enumerate(f):
            try:
                json.loads(line)
            except Exception as e:
                print(f"第 {idx+1} 行格式错误:{e}")

5.3 训练时显存不足(数据相关)

  • 解决方案:减小 max_seq_len(预训练设为 320,SFT 设为 340),或使用更小的数据集(如 sft_mini_512.jsonl)。

5.4 Tokenizer 编码后出现大量 <unk>(未知词)

  • 解决方案:重新训练 Tokenizer,加入更多垂域词汇(如医疗数据中的「颈椎病」「乳胶枕」),或增大词表到 8192。

六、下篇内容预告

本篇我们搞定了 MiniMind 数据工程全流程:训练了专属 Tokenizer,拆解了预训练 / SFT/DPO 数据集的格式、清洗、加载逻辑,还学会了适配自定义垂域数据。现在,「粮草」已备齐,终于可以启动模型训练了!

下一篇(第 5 篇),我们聚焦核心训练流程:《MiniMind 核心训练|单卡 3090 极速复现:预训练 + SFT 从零跑通》将带大家亲手执行预训练和 SFT 训练脚本,理解训练超参数配置、断点续训机制、训练过程监控,2 小时跑通属于自己的 26M 对话小模型。

写在最后

数据工程是 LLM 训练的「地基」,尤其是小模型,差之毫厘谬以千里 ------ 同样的模型架构,用高质量数据训练的效果,可能是低质量数据的 2-3 倍。

MiniMind 的数据工程设计,处处体现「轻量化 + 精准化」:6400 词表避免参数浪费,精简数据集降低训练成本,统一格式简化工程实现。这些设计思路,不仅适用于 MiniMind,也适用于所有小模型开发。

建议大家动手实操:训练一个自定义 Tokenizer,处理一份自己的垂域数据,感受数据质量对模型效果的影响。遇到问题可以在评论区交流,我们一起踩坑、解决问题。

项目地址:https://github.com/jingyaogong/minimind收藏 + 关注,下一篇带你从零跑通小模型训练,见证 26M 参数模型的诞生!

相关推荐
马士兵教育2 小时前
AI工作岗位的就业分层?
开发语言·人工智能·学习·面试·职场和发展
leoZ2312 小时前
胡思乱想。。。
人工智能
南师大蒜阿熏呀2 小时前
AI助手分析清理电脑垃圾
人工智能
清 晨2 小时前
社媒引流不稳定跨境卖家如何建立长期流量池
大数据·人工智能·新媒体运营·跨境·营销策略
quetalangtaosha2 小时前
Anomaly Detection系列(CVPR2025 TAO论文解读)
人工智能·异常检测
pen-ai2 小时前
MAD(Median Absolute Deviation)详解:最稳健的尺度估计方法
人工智能·算法
InfinteJustice2 小时前
mysql如何设计积分系统_mysql流水账与余额对账
jvm·数据库·python
NotFound4862 小时前
Golang怎么实现防重复提交_Golang如何用Token机制防止表单重复提交【技巧】
jvm·数据库·python
lI-_-Il2 小时前
OpenClaw Termux:手机端一键部署 OpenClaw,把大模型装进口袋
人工智能·安卓