11 KiB
第5节:Features 与数据集加载
我的问题笔记 | 聚焦真正不懂的地方
📚 本节目录
- Q1: Features 是什么?
- Q2: logits 是什么?
- Q3: logits 和 labels 的 shift 对齐
- Q4: loss 计算代码解释
- Q5: create_chat_prompt 做了什么?
- Q6: apply_chat_template 做了什么?
Q1: Features 是什么?
代码 dataset/lm_dataset.py:63
from datasets import load_dataset, Features, Sequence, Value
features = Features({
'conversations': [
{'role': Value('string'), 'content': Value('string'), ...}
]
})
Features 是 HuggingFace datasets 库的一个类,用于定义数据集的表结构(schema)。
Features 的作用
| 作用 | 说明 |
|---|---|
| 类型检查 | 加载数据时检查是否符合声明的类型 |
| 自动转换 | 比如字符串 "123" 能自动转成 int |
| IDE 提示 | 方便看有哪些字段 |
就像数据库的 CREATE TABLE 语句,声明"这个数据集有哪些列、每列是什么类型"。
举例
from datasets import Features, Value, Sequence
# 定义数据结构
features = Features({
'name': Value('string'), # 字符串字段
'age': Value('int32'), # 整数字段
'tags': Sequence(Value('string')), # 字符串数组
})
Features 和 self.samples 的关系
| 概念 | 作用 |
|---|---|
features |
声明数据长什么样(类型检查) |
self.samples |
实际加载的数据 |
就像: features = 填表时的表头,self.samples = 实际填的数据。
load_dataset 加载的数据集长什么样?
self.samples = load_dataset('json', data_files=jsonl_path, split='train', features=features)
把 jsonl 文件加载成 datasets 库的数据集对象。
举例:
# jsonl 文件内容
{"conversations": [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好,有什么帮助?"}]}
{"conversations": [{"role": "user", "content": "你叫什么?"}, {"role": "assistant", "content": "我叫 MiniMind"}]}
# load_dataset 后
self.samples = [
{"conversations": [{"role": "user", ...}, {"role": "assistant", ...}]}, # 第1条
{"conversations": [{"role": "user", ...}, {"role": "assistant", ...}]}, # 第2条
]
Q2: logits 是什么?
代码 model/model_minimind.py
logits = model(input_ids) # shape: [batch, seq_len, vocab_size]
logits 是模型 softmax 之前的原始预测分数。
| 阶段 | 名称 | 说明 |
|---|---|---|
| softmax 前 | logits | 原始分数,可正可负 |
| softmax 后 | probs | 0~1 的概率 |
每个位置的 logits 是什么意思?
输入: [你, 好]
↓
模型
↓
位置 0 的 logits → 根据 "你" 预测下一个词(从 vocab 里选)
位置 1 的 logits → 根据 "你、好" 预测下一个词(从 vocab 里选)
每个位置的 logits 不是从输入序列中选词,是从整个 vocab 词表中选下一个词。
举例:
vocab = ["你", "好", "世", "界", ","] # 词表
输入: [你]
↓
位置 0 的 logits = [1.5, 3.2, -0.3, -0.1, 0.8]
↑你 ↑好 ↑世 ↑界 ↑,
"好" 的分数最高 → 模型预测下一个词是 "好"
logits 可以看成前一部分句子对下一个词的预测分数,分数越高越可能被选中。
这里 logits[0]=1.5 对应 "你",logits[1]=3.2 对应 "好",因为 "好" 分数最高,所以模型预测下一个词是 "好"(而不是 "你")。
Q3: logits 和 labels 的 shift 对齐
代码
logits[..., :-1, :] # 去掉最后一个位置
labels[..., 1:] # 去掉第一个位置
为什么需要 shift?
输入: [t0, t1, t2, t3, t4] # 5个 token
logits: [L0, L1, L2, L3, L4] # 5个位置的预测
L0 预测 t1
L1 预测 t2
L2 预测 t3
L3 预测 t4
L4 预测 t5(没有 t5!)← 无效,要去掉
位置 N 的 logits 预测的是位置 N+1 的 token,所以 loss 计算时 logits 和 labels 要错开一位对齐。
对齐后:
| logits 位置 | 预测目标 |
|---|---|
| logits[0] | labels[1] |
| logits[1] | labels[2] |
| logits[2] | labels[3] |
| logits[3] | labels[4] |
Python 切片验证:
logits = [L0, L1, L2, L3, L4] # 5个元素
logits[:-1] # [L0, L1, L2, L3] ← 去掉了 L4(无效预测)
labels[1:] # [t1, t2, t3, t4] ← 去掉了 t0(不需要预测)
Q4: loss 计算代码解释
代码
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
第1步:shift 对齐
x = logits[..., :-1, :] # [batch, seq_len-1, vocab_size] 去掉最后一个位置
y = labels[..., 1:] # [batch, seq_len-1] 去掉第一个位置
位置 N 的 logits 预测位置 N+1 的 label。
第2步:contiguous()
x = x.contiguous() # 确保内存连续,view 操作需要
y = y.contiguous()
view() 需要张量在内存中是连续存储的。
第3步:展平
x.view(-1, x.size(-1)) # [batch, seq_len-1, vocab_size] → [N, vocab_size]
y.view(-1) # [batch, seq_len-1] → [N]
x.view(-1, x.size(-1)):展成[N, vocab_size],N = batch * (seq_len-1)y.view(-1):展成一维[N]
第4步:交叉熵 loss
交叉熵公式:
H(p, q) = - \sum_{i} p_i \cdot \log(q_i)
| 符号 | 含义 |
|---|---|
p_i |
真实分布(one-hot,目标 token 的位置为 1,其他为 0) |
q_i |
模型预测分布(softmax 后的概率) |
H(p, q) |
交叉熵,越小越好 |
代码实现:
F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
| 参数 | 说明 |
|---|---|
x |
预测 logits,shape [N, vocab_size] |
y |
目标 token ids,shape [N] |
ignore_index=-100 |
label=-100 的位置不计算 loss |
计算过程:
- 对 logits 做 softmax,得到每个词的概率
q_i - 取目标 token 对应的
\log(q_i) - 求和/平均得到 loss
ignore_index=-100 的作用
SFT 训练时,user 的 label 设为 -100,表示"这部分不计算 loss,只训练 assistant"。
# labels 示例
labels = [-100, -100, -100, 1024, 2048, 2068, -100, ...]
# ↑ user部分 ↑ assistant部分 ↑ user部分
# 不算loss 算loss 不算loss
Q5: create_chat_prompt 做了什么?
代码 dataset/lm_dataset.py:71-86
def create_chat_prompt(self, conversations):
messages = []
tools = None
for message in conversations:
message = dict(message)
# 如果是 system 消息且有 tools 字段,解析工具定义
if message.get("role") == "system" and message.get("tools"):
tools = json.loads(message["tools"]) if isinstance(message["tools"], str) else message["tools"]
# 如果有 tool_calls 字段且是字符串,转成对象
if message.get("tool_calls") and isinstance(message.get("tool_calls"), str):
message["tool_calls"] = json.loads(message["tool_calls"])
messages.append(message)
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
tools=tools
)
create_chat_prompt 把原始对话数据转成 prompt 字符串。
输入输出:
输入: [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好,有什么帮助?"}]
输出: "<|im_start|>user\n你好<|im_end|>\n<|im_start|>assistant\n你好,有什么帮助?<|im_end|>\n"
每一步在干嘛:
| 步骤 | 作用 |
|---|---|
message = dict(message) |
确保是字典格式 |
tools = json.loads(...) |
解析 system 消息里的工具定义 |
tool_calls = json.loads(...) |
解析工具调用(如果有) |
apply_chat_template(...) |
用模板转成 prompt 字符串 |
Q6: apply_chat_template 做了什么?
代码 minimind-3/chat_template.jinja
apply_chat_template 内部使用 Jinja2 模板引擎,把 messages 列表转换成特定格式的字符串。
模板核心逻辑(简化版)
{%- for message in messages %}
{%- if message.role == "user" %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{{- '<|im_end|>\n' }}
{%- elif message.role == "tool" %}
{{- '\n<tool_response>\n' + content + '\n</tool_response>' }}
{%- endif %}
{%- endfor %}
输入输出示例
输入(messages 列表):
[
{"role": "system", "content": "你是一个有帮助的助手。"},
{"role": "user", "content": "MiniMind 是什么?"},
{"role": "assistant", "content": "MiniMind 是一个小型语言模型..."}
]
输出(prompt 字符串):
<|im_start|>system
你是一个有帮助的助手。<|im_end|>
<|im_start|>user
MiniMind 是什么?<|im_end|>
<|im_start|>assistant
<think>
</think>
MiniMind 是一个小型语言模型...<|im_end|>
各 role 的格式化规则
| role | 格式 |
|---|---|
system |
<|im_start|>system\n{content}<|im_end|>\n |
user |
<|im_start|>user\n{content}<|im_end|>\n |
assistant |
<|im_start|>assistant\n[<think>\n{reasoning}\n</think>\n\n]{content}<|im_end|>\n |
tool |
<|im_start|>user\n<tool_response>\n{content}\n</tool_response><|im_end|>\n |
add_generation_prompt 参数
add_generation_prompt=False 时,输出到 assistant 回答结束就停。
add_generation_prompt=True 时,还会在最后加上 <|im_start|>assistant\n<think>\n,表示开始生成的位置。
# add_generation_prompt=True 时的输出末尾
<|im_start|>assistant
<think>
</think>
tokenize=False 的作用
| 参数值 | 效果 |
|---|---|
tokenize=False |
返回字符串,用于查看 prompt 格式 |
tokenize=True(默认) |
返回 token ids 列表,直接用于训练 |
关键 special tokens
| 完整字符串 | 含义 | 组成 |
|---|---|---|
<|im_start|>system\n |
system 角色开头 | <|im_start|> (id=1) + "system" + 换行 |
<|im_start|>user\n |
user 角色开头 | <|im_start|> (id=1) + "user" + 换行 |
<|im_start|>assistant\n |
assistant 角色开头 | <|im_start|> (id=1) + "assistant" + 换行 |
<|im_end|> |
消息结束 | id=2 |
注意:<|im_start|> 只是分隔符(id=1),后面跟的 role 名称(system/user/assistant)是普通文本,由 tokenizer 继续编码。