minimind/course/notes/05_features_notes.md

389 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第5节:Features 与数据集加载
> 我的问题笔记 | 聚焦真正不懂的地方
---
## 📚 本节目录
1. [Q1: Features 是什么?](#q1-features-是什么)
2. [Q2: logits 是什么?](#q2-logits-是什么)
3. [Q3: logits 和 labels 的 shift 对齐](#q3-logits-和-labels-的-shift-对齐)
4. [Q4: loss 计算代码解释](#q4-loss-计算代码解释)
5. [Q5: create_chat_prompt 做了什么?](#q5-create_chat_prompt-做了什么)
6. [Q6: apply_chat_template 做了什么?](#q6-apply_chat_template-做了什么)
---
## Q1: Features 是什么?
**代码** `dataset/lm_dataset.py:63`
```python
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** 语句,声明"这个数据集有哪些列、每列是什么类型"。
---
## 举例
```python
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 加载的数据集长什么样?
```python
self.samples = load_dataset('json', data_files=jsonl_path, split='train', features=features)
```
把 jsonl 文件加载成 datasets 库的数据集对象。
**举例**:
```python
# 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`
```python
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 对齐
**代码**
```python
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 切片验证**:
```python
logits = [L0, L1, L2, L3, L4] # 5个元素
logits[:-1] # [L0, L1, L2, L3] ← 去掉了 L4(无效预测)
labels[1:] # [t1, t2, t3, t4] ← 去掉了 t0(不需要预测)
```
---
## Q4: loss 计算代码解释
**代码**
```python
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 对齐
```python
x = logits[..., :-1, :] # [batch, seq_len-1, vocab_size] 去掉最后一个位置
y = labels[..., 1:] # [batch, seq_len-1] 去掉第一个位置
```
位置 N 的 logits 预测位置 N+1 的 label。
### 第2步:contiguous()
```python
x = x.contiguous() # 确保内存连续,view 操作需要
y = y.contiguous()
```
`view()` 需要张量在内存中是连续存储的。
### 第3步:展平
```python
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)$ | 交叉熵,越小越好 |
**代码实现**:
```python
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 |
**计算过程**:
1. 对 logits 做 softmax,得到每个词的概率 $q_i$
2. 取目标 token 对应的 $\log(q_i)$
3. 求和/平均得到 loss
### ignore_index=-100 的作用
SFT 训练时,user 的 label 设为 -100,表示"这部分不计算 loss,只训练 assistant"。
```python
# 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`
```python
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 字符串**。
**输入输出**:
```python
输入: [{"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 列表转换成特定格式的字符串。
### 模板核心逻辑(简化版)
```jinja
{%- 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 列表)**:
```python
[
{"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`,表示开始生成的位置。
```python
# 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 继续编码。