From 3512f55993b7100f67c5b285559c30547d1c3b65 Mon Sep 17 00:00:00 2001 From: KMnO4-zx <1021385881@qq.com> Date: Wed, 26 Feb 2025 20:31:51 +0800 Subject: [PATCH] update ch05 --- docs/chapter5/5.3 预训练一个小型LLM.md | 912 +++++++++++++++------- docs/chapter5/code/dataset.py | 33 +- docs/chapter5/code/ddp_pretrain.py | 8 +- docs/chapter5/code/ddp_sft_full.py | 4 +- docs/chapter5/code/k_model.py | 2 +- docs/chapter5/code/model_sample.py | 79 +- docs/chapter5/code/web_demo.py | 66 -- docs/chapter5/images/pretrain_dataset.png | Bin 0 -> 23499 bytes docs/chapter5/images/sftdataset.png | Bin 0 -> 25838 bytes 9 files changed, 699 insertions(+), 405 deletions(-) delete mode 100644 docs/chapter5/code/web_demo.py create mode 100644 docs/chapter5/images/pretrain_dataset.png create mode 100644 docs/chapter5/images/sftdataset.png diff --git a/docs/chapter5/5.3 预训练一个小型LLM.md b/docs/chapter5/5.3 预训练一个小型LLM.md index bddf2ef..e0ec522 100644 --- a/docs/chapter5/5.3 预训练一个小型LLM.md +++ b/docs/chapter5/5.3 预训练一个小型LLM.md @@ -4,7 +4,12 @@ ## 5.3.0 数据下载 -训练模型首先需要找到训练的数据 +首先,我们需要下载预训练数据集。在这里,我们使用两个开源的数据集,包含了大量的中文对话数据,可以用于训练对话生成模型。 + +- 出门问问序列猴子开源数据集:出门问问序列猴子通用文本数据集由来自网页、百科、博客、问答、开源代码、书籍、报刊、专利、教材、考题等多种公开可获取的数据进行汇总清洗之后而形成的大语言模型预训练语料。总量大概在 10B Token。 + +- BelleGroup:350万条中文对话数据集,包含了人机对话、人人对话、人物对话等多种对话数据,可以用于训练对话生成模型。 + ```python # 下载预训练数据集 @@ -61,7 +66,7 @@ with open('BelleGroup_sft.jsonl', 'a', encoding='utf-8') as sft: ## 5.3.1 训练Tokenize -首先,我们需要为文本处理训练一个Tokenizer。Tokenizer的作用是将文本转换为数字序列,以便模型能够理解和处理。我们使用的数据集是 [出门问问序列猴子开源数据集](https://www.modelscope.cn/datasets/ddzhu123/seq-monkey/files) ,来自网页、百科、博客、问答、开源代码、书籍、报刊、专利、教材、考题等多种公开可获取的数据进行汇总清洗之后而形成的大语言模型预训练语料。它将不同来源的HTML、TEXT、PDF、EPUB等各类格式的数据统一整理为JSONL格式,并进行了仔细的筛选、去重、清洗和价值对齐,从而形成了一份覆盖全面、规模庞大、安全可信、质量上乘的预训练语料,具备处理细致、价值对齐、简洁易用等特点。 +首先,我们需要为文本处理训练一个Tokenizer。Tokenizer的作用是将文本转换为数字序列,以便模型能够理解和处理。我们使用的数据集是 [出门问问序列猴子开源数据集](https://www.modelscope.cn/datasets/ddzhu123/seq-monkey/files) ,这个数据集包含了大量的中文文本数据,可以用于训练Tokenizer。 > 注:由于数据集较大,如果大家在自己本地电脑训练的话进度比较慢,所以在这里我们提供了一个已经训练好的Tokenizer,大家可以直接使用。如果大家想要自己训练的话,可以参考下面的代码。 @@ -263,164 +268,191 @@ if __name__ == '__main__': main() ``` -在这个 `Tokenizer` 类中,我们首先初始化了一些特殊的 token ID,这些特殊 tokens 在自然语言处理任务中有着重要作用,分别用于填充、处理未识别的词汇、表示句子的开头和结尾等。在模型训练和推理过程中,正确处理这些特殊 tokens 对于提升模型性能至关重要。 +训练完成之后可以可以使用 `eval_tokenizer()` 测试 Tokenizer 的功能,确保 Tokenizer 正常工作。在这个函数中,我们首先加载训练好的 Tokenizer,然后测试了 Tokenizer 的基本属性、聊天模板、编码解码等功能。这些测试可以帮助我们验证 Tokenizer 的正确性,确保它能够正常工作。正确的输出为: -接着,我们定义了两个关键方法: +OUT: +``` +=== Tokenizer基本信息 === +Vocab size: 6144 +Special tokens: ['<|im_start|>', '<|im_end|>', '', '', ''] +Special token IDs: [3, 4, 0, 1, 2] -1. encode 方法:该方法负责将输入文本转换为 token ID 序列。通过加载预训练的 Tokenizer 模型,我们可以对文本进行分词,将其拆解为词或子词,并将其映射为相应的数字表示。这个数字序列可以被模型接受用于训练和推理。 +=== 聊天模板测试 === +Generated prompt: +<|im_start|>system +你是一个AI助手。<|im_end|> +<|im_start|>user +How are you?<|im_end|> +<|im_start|>assistant +I'm fine, thank you. and you?<|im_end|> +<|im_start|>user +I'm good too.<|im_end|> +<|im_start|>assistant +That's great to hear!<|im_end|> -2. decode 方法:与 encode 方法相反,decode 方法用于将 token ID 序列还原为可读的文本。它将数字序列转换回对应的 tokens,并拼接成完整的文本,从而可以对模型的输出进行解释和展示。 -这些方法的定义使得我们在使用过程中,可以非常方便地在文本与数字序列之间进行转换,为模型的输入与输出提供接口。大家可以使用以下代码测试 `Tokenizer` 的功能,验证其是否能够正确地将文本转换为数字序列,或者将数字序列还原为文本。 +=== 编码解码测试 === +Decoded text matches original: False -```python -# 测试 Tokenizer -enc = Tokenizer('./data/tok4096.model') # 加载分词器 -tetx = 'Hello, world!' # 测试文本 -print(enc.encode(text, bos=True, eos=True)) # 编码文本 -print(enc.decode(enc.encode(text, bos=True, eos=True))) # 解码文本 - -OUTPUT: -[1, 346, 2233, 4010, 1475, 4021, 2] -Hello, world! +=== 特殊token处理 === +Original: <|im_start|>user +Hello<|im_end|> +Decoded: <|im_start|> user +Hello<|im_end|> +Special tokens preserved: False ``` -## 5.3.2 数据预处理 +## 5.3.2 Dataset -在训练模型之前,首先需要对数据进行预处理。这一步的核心任务是将文本数据转换为模型能够理解的数字序列。具体来说,文本中的每个字符、单词或子词都需要被映射为一个唯一的数字 ID,这样模型才能处理这些数据。 +### PretrainDataset + +在将数据送入到模型之前,我们还需要进行一些处理用于将文本数据转化为模型能够理解的Token。在这里我们使用的是Pytorch的Dataset类,用于加载数据集。我们定义了一个`PretrainDataset`类,用于加载已预处理好的数据集。我们继承了`torch.utils.data.IterableDataset`来定义该数据集,这使得我们可以更灵活、高效地处理数据。 ```python -# 定义分片处理函数 -def process_shard(args, vocab_size, tokenizer_model_path): - """ 处理数据分片,将其中的文本进行分词并保存为二进制文件 """ - ··· +from torch.utils.data import Dataset - -# 定义预处理函数,用于对多个数据分片进行批量处理 -def pretokenize(vocab_size): - """ 预处理所有的数据分片,并将分词后的数据保存为二进制文件 """ - ··· - - -class PretokDataset(torch.utils.data.IterableDataset): - """从磁盘加载已预处理的分词数据,并将其以 PyTorch 张量的形式返回。""" - - def __init__(self, split, max_seq_len, vocab_size, vocab_source): - """ - 初始化数据集。 - - 参数: - split: str, 数据集的分割方式('train' 或 'test')。 - max_seq_len: int, 最大序列长度,用于生成输入输出序列。 - vocab_size: int, 词汇表的大小。 - vocab_source: str, 词汇表的来源('llama2' 或 'custom')。 - """ +class PretrainDataset(Dataset): + def __init__(self, data_path, tokenizer, max_length=512): super().__init__() - self.split = split # 数据集划分(训练集或测试集) - self.max_seq_len = max_seq_len # 最大序列长度 - self.vocab_size = vocab_size # 词汇表大小 - self.vocab_source = vocab_source # 词汇表来源 + self.data_path = data_path + self.tokenizer = tokenizer + self.max_length = max_length + self.padding = 0 + with open(data_path, 'r', encoding='utf-8') as f: + self.data = f.readlines() - def __iter__(self): - """ - 返回迭代器,按批次加载数据并生成模型输入/输出。 - """ - # 获取DataLoader的worker信息(用于并行数据加载) - worker_info = torch.utils.data.get_worker_info() - worker_id = worker_info.id if worker_info else 0 # worker ID - # 获取分布式训练的rank信息(用于多GPU训练) - rank = dist.get_rank() if dist.is_initialized() else 0 - # 基于worker_id和rank生成唯一的随机数种子,确保数据在每个worker和rank之间是唯一的 - seed = 42 + worker_id + 1337 * rank - rng = random.Random(seed) - print(f"Created a PretokDataset with rng seed {seed}") + def __len__(self): + return len(self.data) - # 根据词汇表来源决定数据路径 - if self.vocab_source == "llama2": - # 如果使用 Llama 2 词汇表,.bin 文件和 .json 文件在同一目录下 - bin_dir = os.path.join(DATA_CACHE_DIR, "TinyStories_all_data") - shard_filenames = sorted(glob.glob(os.path.join(bin_dir, "*.bin"))) - elif self.vocab_source == "custom": - # 如果使用自定义词汇表,.bin 文件在 tok{N} 目录下 - bin_dir = os.path.join(DATA_CACHE_DIR, f"tok{self.vocab_size}") - shard_filenames = sorted(glob.glob(os.path.join(bin_dir, "*.bin"))) + def __getitem__(self, index: int): + sample = json.loads(self.data[index]) + text = f"{self.tokenizer.bos_token}{sample['text']}" + input_id = self.tokenizer(text).data['input_ids'][:self.max_length] + text_len = len(input_id) + # 没满最大长度的剩余部分 + padding_len = self.max_length - text_len + input_id = input_id + [self.padding] * padding_len + # 0表示不计算损失 + loss_mask = [1] * text_len + [0] * padding_len - # 根据数据集划分使用不同的分片文件 - # 训练集使用所有分片文件,测试集只使用第一个分片 - shard_filenames = shard_filenames[1:] if self.split == "train" else shard_filenames[:1] - assert len(shard_filenames) > 0, f"在 {bin_dir} 中未找到任何 .bin 文件" - - while True: - # 随机打乱分片文件 - rng.shuffle(shard_filenames) - for shard in shard_filenames: - # 使用 memmap 读取文件,使得数据留在磁盘上,减少内存占用 - m = np.memmap(shard, dtype=np.uint16, mode="r") - # 计算该分片中的批次数量 - num_batches = len(m) // self.max_seq_len - num_batches -= 1 # 去掉最后一个不完整的批次 - assert num_batches > 0, "这个分片文件太小了?请检查。" - # 随机打乱批次索引 - ixs = list(range(num_batches)) - rng.shuffle(ixs) - # 对每个批次生成输入 x 和目标输出 y - for ix in ixs: - start = ix * self.max_seq_len # 批次起始索引 - end = start + self.max_seq_len + 1 # 批次结束索引 - # 将数据转换为 NumPy 数组并拷贝到 RAM 中 - chunk = torch.from_numpy((m[start:end]).astype(np.int64)) - # 模型输入 x 是当前批次的前 max_seq_len 个词元 - x = chunk[:-1] - # 模型输出 y 是下一个词元 - y = chunk[1:] - # 生成 x, y 对 - yield x, y - - -class Task: - @staticmethod - def iter_batches(batch_size, device, num_workers=0, **dataset_kwargs): - ds = PretokDataset(**dataset_kwargs) - dl = torch.utils.data.DataLoader( - ds, batch_size=batch_size, pin_memory=True, num_workers=num_workers - ) - for x, y in dl: - x = x.to(device, non_blocking=True) - y = y.to(device, non_blocking=True) - yield x, y + input_id = np.array(input_id) + X = np.array(input_id[:-1]).astype(np.int64) + Y = np.array(input_id[1:]).astype(np.int64) + loss_mask = np.array(loss_mask[1:]).astype(np.int64) + return torch.from_numpy(X), torch.from_numpy(Y), torch.from_numpy(loss_mask) ``` -在这部分中,首先定义了 `process_shard` 函数,用于处理数据分片。该函数的主要功能是将文本数据分词后,转换为更高效的二进制文件格式,以便后续更快速地加载和处理数据。 +在以上代码可以看出,我们的 `Pretrain Dataset` 主要是将 `text` 通过 `tokenizer` 转换成 `input_id`,然后将 `input_id` 拆分成 `X` 和 `Y`,其中 `X` 为 `input_id` 的前 n-1 个元素,`Y` 为 `input_id` 的后 n-1 `个元素。loss_mask` 主要是用来标记哪些位置需要计算损失,哪些位置不需要计算损失。如果你不太能明白,可以看下面的示意图。 -接下来,我们定义了 `pretokenize` 函数,用于批量处理多个数据分片。通过这一函数,所有数据可以并行处理,进一步加快预处理的速度。 +![alt text](./images/pretrain_dataset.png) -然后,我们设计了一个 `PretokDataset` 类,用于加载已预处理好的数据集。我们继承了 `torch.utils.data.IterableDataset` 来定义该数据集,这使得我们可以更灵活、高效地处理数据。在这个类中,核心是 `__iter__` 方法,它负责生成用于训练的数据批次。 +图中示例展示了当`max_length=9`时的处理过程: +- **输入序列**:`[BOS, T1, T2, T3, T4, T5, T6, T7, EOS]` +- **样本拆分**: + - X:`[BOS, T1, T2, T3, T4, T5, T6, T7]` → 模型输入上下文 + - Y:`[T1, T2, T3, T4, T5, T6, T7, EOS]` → 模型预测目标 +- **损失掩码**: + - 有效位置:`[0, 1, 1, 1, 1, 1, 1, 1, 1]` → 仅对T1-EOS计算损失 -最后,我们还定义了一个 `Task` 类,专门用于迭代数据集,并生成模型所需的输入和目标输出。这一部分的设计确保了数据流的顺畅对接,为模型训练提供了标准化的数据输入。可以通过以下代码来测试预处理后的数据集。 +### SFTDataset -## 5.3.3 预训练模型 +`SFTDataset` 其实是一个多轮对话数据集,我们的目标是让模型学会如何进行多轮对话。在这个阶段我们的输入是上一轮的对话内容,输出是当前轮的对话内容。 -在数据预处理完成后,我们就可以开始训练模型了。我们使用的模型是一个和LLama2结构一样的 Decoder only Transformer模型,使用Pytorch实现。相关代码在`model.py`文件中。此处不再赘述,源码中有详细的中文注释,且我们在之前的文章中也有详细的介绍。 +```python +class SFTDataset(Dataset): + def __init__(self, data_path, tokenizer, max_length=512): + super().__init__() + self.data_path = data_path + self.tokenizer = tokenizer + self.max_length = max_length + self.padding = 0 + with open(data_path, 'r', encoding='utf-8') as f: + self.data = f.readlines() -在模型这一部分可以重点看一下生成式模型是如何实现生成token的,可以查看`model.py`文件中的`Transforerm`类中的`generate`方法。 + def __len__(self): + return len(self.data) -在完成数据预处理后,我们就可以开始训练模型了。我们使用的模型是一个与 LLaMA2 结构相同的 Decoder-only Transformer 模型,采用 PyTorch 实现。具体的实现细节已经包含在 `model.py` 文件中,在此不再赘述。该源码中包含详细的中文注释,此外我们在之前的文章中也对模型架构进行了深入介绍。 + def generate_loss_mask(self, input_ids): + # 生成 loss mask, 0 表示不计算损失, 1 表示计算损失 + mask = [0] * len(input_ids) + a_sequence = [3, 1074, 537, 500, 203] # <|im_start|>assistant\n + a_length = len(a_sequence) + n = len(input_ids) + i = 0 + + while i <= n - a_length: + # 检查当前位置是否匹配目标子序列 + match = True + for k in range(a_length): + if input_ids[i + k] != a_sequence[k]: + match = False + break + if match: + # 从子序列结束的位置开始查找第一个4, 4 为 <|im_end|> EOS id + j = None + for idx in range(i + a_length, n): + if input_ids[idx] == 4: + j = idx + break + if j is not None: + start = i + a_length + end = j # 结束位置设为j(包含4) + # 标记区间为1(包括start到end) + if start <= end: + for pos in range(start, end + 1): + if pos < len(mask): + mask[pos] = 1 + # 跳过当前子序列,避免重叠匹配 + i += a_length + else: + i += 1 + return mask -在模型部分,建议重点关注生成式模型如何生成 token 的过程。可以参考 `model.py` 文件中的 `Transformer` 类,尤其是 `generate` 方法的实现,它展示了模型如何基于已有的上下文生成后续 token 的机制。 + def __getitem__(self, index: int): + sample = json.loads(self.data[index]) + text = self.tokenizer.apply_chat_template(sample, tokenize=False, add_generation_prompt=False) + input_id = self.tokenizer(text).data['input_ids'][:self.max_length] + text_len = len(input_id) + # 没满最大长度的剩余部分 + padding_len = self.max_length - text_len + input_id = input_id + [self.padding] * padding_len + # 0表示不计算损失 + loss_mask = self.generate_loss_mask(input_id) + + input_id = np.array(input_id) + X = np.array(input_id[:-1]).astype(np.int64) + Y = np.array(input_id[1:]).astype(np.int64) + loss_mask = np.array(loss_mask[1:]).astype(np.int64) + return torch.from_numpy(X), torch.from_numpy(Y), torch.from_numpy(loss_mask) +``` + +在 SFT 阶段,我这里使用的是多轮对话数据集,所以就需要区分哪些位置需要计算损失,哪些位置不需要计算损失。在上面的代码中,我使用了一个 `generate_loss_mask` 函数来生成 `loss_mask`。这个函数主要是用来生成 `loss_mask`,其中 `loss_mask` 的生成规则是:当遇到 `|assistant\n` 时,就开始计算损失,直到遇到 `|` 为止。这样就可以保证我们的模型在 SFT 阶段只计算当前轮的对话内容。那我也给出一个示意图,帮助大家理解。 + +![alt text](./images/sftdataset.png) + +可以看到,其实 SFT Dataset 和 Pretrain Dataset 的 `X` 和 `Y` 是一样的,只是在 SFT Dataset 中我们需要生成一个 `loss_mask` 来标记哪些位置需要计算损失,哪些位置不需要计算损失。 图中 `Input ids` 中的蓝色小方格就是AI的回答,所以是需要模型学习的地方。所以在 `loss_mask` 中,蓝色小方格对应的位置是黄色,其他位置是灰色。在代码 `loss_mask` 中的 1 对应的位置计算损失,0 对应的位置不计算损失。 + + +## 5.3.3 预训练 + +在数据预处理完成后,我们就可以开始训练模型了。我们使用的模型是一个和LLama2结构一样的 Decoder only Transformer模型,使用Pytorch实现。相关代码在`code/k_model.py`文件中。此处不再赘述,源码中有详细的中文注释,且我们在之前的文章中也有详细的介绍。 + +在模型这一部分可以重点看一下生成式模型是如何实现生成token的,可以查看`k_model.py`文件中的`Transforerm`类中的`generate`方法。 ```python @torch.inference_mode() - def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None): + def generate(self, idx, stop_id=None, max_new_tokens=256, temperature=1.0, top_k=None): """ 给定输入序列 idx(形状为 (bz,seq_len) 的长整型张量),通过多次生成新 token 来完成序列。 在 model.eval() 模式下运行。效率较低的采样版本,没有使用键k/v cache。 """ + index = idx.shape[1] for _ in range(max_new_tokens): # 如果序列上下文过长,截断它到最大长度 idx_cond = idx if idx.size(1) <= self.args.max_seq_len else idx[:, -self.args.max_seq_len:] # 前向传播获取序列中最后一个位置的 logits - logits = self(idx_cond) + logits = self(idx_cond).logits logits = logits[:, -1, :] # 只保留最后一个时间步的输出 if temperature == 0.0: @@ -435,80 +467,418 @@ class Task: probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) + + if idx_next == stop_id: + break + # 将采样的索引添加到序列中并继续 idx = torch.cat((idx, idx_next), dim=1) - return idx + return idx[:, index:] # 只返回生成的token + ``` -在 generate 方法中,我们首先获取序列中最后一个位置的 logits,然后基于这些 logits 生成新的 token。接着,生成的新 token 会被添加到序列中,模型随后会继续生成下一个 token。通过这种迭代过程,我们能够生成完整的文本。接下来,您可以使用以下命令开始训练模型。 +在 `generate` 方法中,我们首先获取序列中最后一个位置的 `logits`,然后基于这些 `logits` 生成新的 `token`。接着,生成的新 `token` 会被添加到序列中,模型随后会继续生成下一个 `token`。通过这种迭代过程,我们能够生成完整的文本。 -```bash -python train.py -``` +接下来就是最重要的部分,训练模型! -在 `train.py` 中我们定义了很多超参数,包括但不限于模型的维度,层数,学习率等等。如下所示,更多的内容大家可以在源码中查看,源码加了很详细的中文注释,相信大家可以很容易看懂。 +> 注:在使用下面代码进行模型训练时,需要指定 `--data_path` 参数为预处理好的数据集路径,例如 `--data_path seq_monkey_datawhale.jsonl`,也需要指定要用哪几张GPU进行训练,例如 `--gpus 0,1`。 ```python -# ----------------------------------------------------------------------------- -# I/O 配置,用于定义输出目录和训练时的日志记录与评估设置 -out_dir = "output" # 模型输出保存路径 -eval_interval = 2000 # 评估间隔步数 -log_interval = 1 # 日志记录间隔步数 -eval_iters = 100 # 每次评估时迭代的步数 -eval_only = False # 如果为True,脚本在第一次评估后立即退出 -always_save_checkpoint = False # 如果为True,在每次评估后总是保存检查点 -init_from = "scratch" # 可以选择从头开始训练('scratch')或从已有的检查点恢复('resume') +def get_lr(it, all): + warmup_iters = args.warmup_iters + lr_decay_iters = all + min_lr = args.learning_rate / 10 -# 数据配置 -batch_size = 8 # 每个微批次的样本数量,如果使用梯度累积,实际批次大小将更大 -max_seq_len = 256 # 最大序列长度 -vocab_size = 4096 # 自定义词汇表大小 + if it < warmup_iters: + return args.learning_rate * it / warmup_iters + + if it > lr_decay_iters: + return min_lr + + decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters) + assert 0 <= decay_ratio <= 1 + coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio)) + return min_lr + coeff * (args.learning_rate - min_lr) -# 模型配置 -dim = 288 # 模型的隐藏层维度 -n_layers = 8 # Transformer的层数 -n_heads = 8 # 注意力头的数量 -n_kv_heads = 4 # 模型分组 -multiple_of = 32 # 在某些层的维度必须是该数的倍数 -dropout = 0.0 # Dropout概率 +def train_epoch(epoch): + start_time = time.time() + for step, (X, Y, loss_mask) in enumerate(train_loader): + X = X.to(args.device) + Y = Y.to(args.device) + loss_mask = loss_mask.to(args.device) -# AdamW优化器配置 -gradient_accumulation_steps = 4 # 梯度累积步数,用于模拟更大的批次 -learning_rate = 5e-4 # 最大学习率 -max_iters = 100000 # 总的训练迭代次数 -weight_decay = 1e-1 # 权重衰减系数 -beta1 = 0.9 # AdamW优化器的β1参数 -beta2 = 0.95 # AdamW优化器的β2参数 -grad_clip = 1.0 # 梯度裁剪阈值,0表示不裁剪 + lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch) + for param_group in optimizer.param_groups: + param_group['lr'] = lr -# 学习率衰减配置 -decay_lr = True # 是否启用学习率衰减 -warmup_iters = 1000 # 学习率预热的步数 + with ctx: + out = model(X, Y) + loss = out.last_loss / args.accumulation_steps + loss_mask = loss_mask.view(-1) + loss = torch.sum(loss * loss_mask) / loss_mask.sum() -# 系统设置 -device = "cuda:0" # 设备选择:'cpu','cuda','cuda:0'等 -dtype = "bfloat16" # 数据类型:'float32','bfloat16','float16' + scaler.scale(loss).backward() + + if (step + 1) % args.accumulation_steps == 0: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) + + scaler.step(optimizer) + scaler.update() + + optimizer.zero_grad(set_to_none=True) + + if step % args.log_interval == 0: + spend_time = time.time() - start_time + Logger( + 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.7f} epoch_Time:{}min:'.format( + epoch + 1, + args.epochs, + step, + iter_per_epoch, + loss.item() * args.accumulation_steps, + optimizer.param_groups[-1]['lr'], + spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60)) + if args.use_swanlab: + swanlab.log({ + "loss": loss.item() * args.accumulation_steps, + "lr": optimizer.param_groups[-1]['lr'] + }) + + if (step + 1) % args.save_interval == 0: + model.eval() + ckp = f'{args.save_dir}/pretrain_{lm_config.dim}_{lm_config.n_layers}_{lm_config.vocab_size}.pth' + + # 处理多卡保存 + state_dict = model.module.state_dict() if isinstance(model, torch.nn.DataParallel) else model.state_dict() + torch.save(state_dict, ckp) + model.train() + + if (step + 1) % 20000 == 0: + model.eval() + ckp = f'{args.save_dir}/pretrain_{lm_config.dim}_{lm_config.n_layers}_{lm_config.vocab_size}_step{step+1}.pth' + + state_dict = model.module.state_dict() if isinstance(model, torch.nn.DataParallel) else model.state_dict() + torch.save(state_dict, ckp) + model.train() + + +def init_model(): + def count_parameters(model): + return sum(p.numel() for p in model.parameters() if p.requires_grad) + + tokenizer = AutoTokenizer.from_pretrained('./tokenizer_k/') + + model = Transformer(lm_config) + + # 多卡初始化 + num_gpus = torch.cuda.device_count() + if num_gpus > 1: + Logger(f"Using {num_gpus} GPUs with DataParallel!") + model = torch.nn.DataParallel(model) + + model = model.to(args.device) + Logger(f'LLM总参数量:{count_parameters(model) / 1e6:.3f} 百万') + return model, tokenizer + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Tiny-LLM Pretraining") + parser.add_argument("--out_dir", type=str, default="output", help="Output directory") + parser.add_argument("--epochs", type=int, default=1, help="Number of epochs") + parser.add_argument("--batch_size", type=int, default=64, help="Batch size") + parser.add_argument("--learning_rate", type=float, default=2e-4, help="Learning rate") + parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="Device to use") + parser.add_argument("--dtype", type=str, default="bfloat16", help="Data type") + parser.add_argument("--use_swanlab", type=bool, default=True, help="Use Weights & Biases") + parser.add_argument("--num_workers", type=int, default=8, help="Number of workers for data loading") + parser.add_argument("--data_path", type=str, default="", help="Path to training data") + parser.add_argument("--accumulation_steps", type=int, default=8, help="Gradient accumulation steps") + parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping threshold") + parser.add_argument("--warmup_iters", type=int, default=0, help="Number of warmup iterations") + parser.add_argument("--log_interval", type=int, default=100, help="Logging interval") + parser.add_argument("--save_interval", type=int, default=1000, help="Model saving interval") + # 添加多卡参数 + parser.add_argument("--gpus", type=str, default='0,1', help="Comma-separated GPU IDs (e.g. '0,1,2')") + + args = parser.parse_args() + + # 设置可见GPU + if args.gpus is not None: + os.environ["CUDA_VISIBLE_DEVICES"] = args.gpus + # 自动设置主设备为第一个GPU + if torch.cuda.is_available(): + args.device = "cuda:0" + else: + args.device = "cpu" + + if args.use_swanlab: + swanlab.login(api_key='your key') + run = swanlab.init( + project="Tiny-LLM", + experiment_name="Pretrain-215M", + config=args, + ) + + lm_config = ModelConfig( + dim=1024, + n_layers=18, + ) + max_seq_len = lm_config.max_seq_len + args.save_dir = os.path.join(args.out_dir) + os.makedirs(args.save_dir, exist_ok=True) + os.makedirs(args.out_dir, exist_ok=True) + torch.manual_seed(42) + device_type = "cuda" if "cuda" in args.device else "cpu" + + ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast() + + model, tokenizer = init_model() + + train_ds = PretrainDataset(args.data_path, tokenizer, max_length=max_seq_len) + train_loader = DataLoader( + train_ds, + batch_size=args.batch_size, + pin_memory=True, + drop_last=False, + shuffle=True, + num_workers=args.num_workers + ) + + scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16'])) + optimizer = optim.Adam(model.parameters(), lr=args.learning_rate) + + iter_per_epoch = len(train_loader) + for epoch in range(args.epochs): + train_epoch(epoch) ``` +## 5.3.4 SFT 训练 + +SFT 训练和预训练的代码基本一样,只是导入的 Dataset 不一样。在这里我们使用的是 SFTDataset,用于多轮对话的训练。 + +```python +import os +import platform +import argparse +import time +import warnings +import math +import pandas as pd +import torch +from torch import optim +from torch.utils.data import DataLoader +from contextlib import nullcontext + +from transformers import AutoTokenizer + +from k_model import ModelConfig, Transformer +from dataset import SFTDataset + +import swanlab + +warnings.filterwarnings('ignore') + + +def Logger(content): + print(content) + +def get_lr(it, all): + warmup_iters = args.warmup_iters + lr_decay_iters = all + min_lr = args.learning_rate / 10 + + if it < warmup_iters: + return args.learning_rate * it / warmup_iters + + if it > lr_decay_iters: + return min_lr + + decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters) + assert 0 <= decay_ratio <= 1 + coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio)) + return min_lr + coeff * (args.learning_rate - min_lr) + +def train_epoch(epoch): + start_time = time.time() + for step, (X, Y, loss_mask) in enumerate(train_loader): + X = X.to(args.device) + Y = Y.to(args.device) + loss_mask = loss_mask.to(args.device) + + lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch) + for param_group in optimizer.param_groups: + param_group['lr'] = lr + + with ctx: + out = model(X, Y) + loss = out.last_loss / args.accumulation_steps + loss_mask = loss_mask.view(-1) + loss = torch.sum(loss * loss_mask) / loss_mask.sum() + + scaler.scale(loss).backward() + + if (step + 1) % args.accumulation_steps == 0: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) + + scaler.step(optimizer) + scaler.update() + + optimizer.zero_grad(set_to_none=True) + + if step % args.log_interval == 0: + spend_time = time.time() - start_time + Logger( + 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.7f} epoch_Time:{}min:'.format( + epoch + 1, + args.epochs, + step, + iter_per_epoch, + loss.item() * args.accumulation_steps, + optimizer.param_groups[-1]['lr'], + spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60)) + if args.use_swanlab: + swanlab.log({ + "loss": loss.item() * args.accumulation_steps, + "lr": optimizer.param_groups[-1]['lr'] + }) + + if (step + 1) % args.save_interval == 0: + model.eval() + ckp = f'{args.save_dir}/sft_dim{lm_config.dim}_layers{lm_config.n_layers}_vocab_size{lm_config.vocab_size}.pth' + + # 处理多卡保存 + state_dict = model.module.state_dict() if isinstance(model, torch.nn.DataParallel) else model.state_dict() + torch.save(state_dict, ckp) + model.train() + + if (step + 1) % 20000 == 0: + model.eval() + ckp = f'{args.save_dir}/sft_dim{lm_config.dim}_layers{lm_config.n_layers}_vocab_size{lm_config.vocab_size}_step{step+1}.pth' + + state_dict = model.module.state_dict() if isinstance(model, torch.nn.DataParallel) else model.state_dict() + torch.save(state_dict, ckp) + model.train() + + +def init_model(): + def count_parameters(model): + return sum(p.numel() for p in model.parameters() if p.requires_grad) + + tokenizer = AutoTokenizer.from_pretrained('./tokenizer_k/') + + model = Transformer(lm_config) + + ckp = './base_monkey_215M/pretrain_1024_18_6144.pth' + state_dict = torch.load(ckp, map_location=args.device) + unwanted_prefix = '_orig_mod.' + for k, v in list(state_dict.items()): + if k.startswith(unwanted_prefix): + state_dict[k[len(unwanted_prefix):]] = state_dict.pop(k) + model.load_state_dict(state_dict, strict=False) + + # 多卡初始化 + num_gpus = torch.cuda.device_count() + if num_gpus > 1: + Logger(f"Using {num_gpus} GPUs with DataParallel!") + model = torch.nn.DataParallel(model) + + model = model.to(args.device) + Logger(f'LLM总参数量:{count_parameters(model) / 1e6:.3f} 百万') + return model, tokenizer + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Tiny-LLM Pretraining") + parser.add_argument("--out_dir", type=str, default="output", help="Output directory") + parser.add_argument("--epochs", type=int, default=1, help="Number of epochs") + parser.add_argument("--batch_size", type=int, default=64, help="Batch size") + parser.add_argument("--learning_rate", type=float, default=2e-4, help="Learning rate") + parser.add_argument("--device", type=str, default="cuda:0" if torch.cuda.is_available() else "cpu", help="Device to use") + parser.add_argument("--dtype", type=str, default="bfloat16", help="Data type") + parser.add_argument("--use_swanlab", type=bool, default=True, help="Use Weights & Biases") + parser.add_argument("--num_workers", type=int, default=4, help="Number of workers for data loading") + parser.add_argument("--data_path", type=str, default="", help="Path to training data") + parser.add_argument("--accumulation_steps", type=int, default=4, help="Gradient accumulation steps") + parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping threshold") + parser.add_argument("--warmup_iters", type=int, default=0, help="Number of warmup iterations") + parser.add_argument("--log_interval", type=int, default=100, help="Logging interval") + parser.add_argument("--save_interval", type=int, default=1000, help="Model saving interval") + # 添加多卡参数 + parser.add_argument("--gpus", type=str, default='0,1', help="Comma-separated GPU IDs (e.g. '0,1,2')") + + args = parser.parse_args() + + # 设置可见GPU + if args.gpus is not None: + os.environ["CUDA_VISIBLE_DEVICES"] = args.gpus + # 自动设置主设备为第一个GPU + if torch.cuda.is_available(): + args.device = "cuda:0" + else: + args.device = "cpu" + + if args.use_swanlab: + swanlab.login(api_key='your key') + run = swanlab.init( + project="Tiny-LLM", + experiment_name="BelleGropu-sft-215M", + config=args, + ) + + lm_config = ModelConfig( + dim=1024, + n_layers=18, + ) + max_seq_len = lm_config.max_seq_len + args.save_dir = os.path.join(args.out_dir) + os.makedirs(args.save_dir, exist_ok=True) + os.makedirs(args.out_dir, exist_ok=True) + torch.manual_seed(42) + device_type = "cuda" if "cuda" in args.device else "cpu" + + ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast() + + model, tokenizer = init_model() + + train_ds = SFTDataset(args.data_path, tokenizer, max_length=max_seq_len) + train_loader = DataLoader( + train_ds, + batch_size=args.batch_size, + pin_memory=True, + drop_last=False, + shuffle=True, + num_workers=args.num_workers + ) + + scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16'])) + optimizer = optim.Adam(model.parameters(), lr=args.learning_rate) + + iter_per_epoch = len(train_loader) + for epoch in range(args.epochs): + train_epoch(epoch) +``` + + ## 5.3.4 使用模型生成文本 -在模型训练完成后,会在`output`目录下生成一个`ckpt.pt`文件,这个文件就是我们训练好的模型。我们可以使用以下命令生成文本。 +在模型训练完成后,会在`output`目录下生成模型文件,这个文件就是我们训练好的模型。我们可以使用以下命令生成文本。 ```bash -python sample.py --prompt "One day, Lily met a Shoggoth" +python model_sample.py ``` -我们来看下`sample.py`文件中的代码,这个文件中定义了一个`TextGenerator`类,用于生成文本。 +我们来看下`model_sample.py`文件中的代码,这个文件中定义了一个`TextGenerator`类,用于生成文本。 ```python class TextGenerator: def __init__(self, - checkpoint='output/ckpt.pt', # 模型检查点路径 - tokenizer_model_path='tok4096.model', # 分词器模型路径 - seed=1337, # 随机种子,确保可重复性 + checkpoint='out/SkyWork_pretrain_768_12_6144.pth', # 模型检查点路径 + tokenizer_model_path='./tokenizer_k/', # 分词器模型路径 + seed=42, # 随机种子,确保可重复性 device=None, # 设备,优先使用 CUDA,如果没有可用的 CUDA,则使用 CPU - dtype="float32"): # 数据类型,默认为 float32,可以选择 float16 或 bfloat16 + dtype="bfloat16"): # 数据类型,默认为 float32,可以选择 float16 或 bfloat16 """ 初始化 TextGenerator 类,加载模型、设置设备和分词器等。 """ @@ -516,7 +886,7 @@ class TextGenerator: self.checkpoint = checkpoint # 保存的模型检查点路径 self.tokenizer_model_path = tokenizer_model_path # 分词器模型文件路径 self.seed = seed # 随机数种子,用于生成的可重复性 - self.device = device or ('cuda' if torch.cuda.is_available() else 'cpu') # 根据硬件条件选择设备 + self.device = device or ('cuda:0' if torch.cuda.is_available() else 'cpu') # 根据硬件条件选择设备 self.dtype = dtype # 模型的浮点数类型 self.device_type = 'cuda' if 'cuda' in self.device else 'cpu' # 判断当前设备是否为 CUDA @@ -531,34 +901,66 @@ class TextGenerator: self.ctx = nullcontext() if self.device_type == 'cpu' else torch.amp.autocast(device_type=self.device_type, dtype=ptdtype) # 加载模型检查点文件 - checkpoint_dict = torch.load(self.checkpoint, map_location=self.device) # 加载模型参数 - gptconf = ModelArgs(**checkpoint_dict['model_args']) # 初始化模型参数 - self.model = Transformer(gptconf) # 实例化 Transformer 模型 - state_dict = checkpoint_dict['model'] # 获取模型状态字典 + checkpoint_dict = torch.load(self.checkpoint, map_location=self.device) # 加载模型参数 # 初始化模型参数 + self.model = Transformer(ModelConfig(dim=1024, n_layers=18)) # 实例化 Transformer 模型 + sunwanted_prefix = '_orig_mod.' + for k, v in list(checkpoint_dict.items()): + if k.startswith(sunwanted_prefix): + checkpoint_dict[k[len(sunwanted_prefix):]] = checkpoint_dict.pop(k) + self.model.load_state_dict(checkpoint_dict, strict=False) - # 去除状态字典中的不必要前缀 - unwanted_prefix = '_orig_mod.' # 这个前缀在保存时可能被添加,现在要去除它 - for k, v in list(state_dict.items()): - if k.startswith(unwanted_prefix): - state_dict[k[len(unwanted_prefix):]] = state_dict.pop(k) # 去除不必要的前缀 - - # 加载模型参数到模型中 - self.model.load_state_dict(state_dict, strict=False) # 计算模型参数量 num_params = sum(p.numel() for p in self.model.parameters() if p.requires_grad) - print(f"Model has {num_params} parameters.") + print(f"Model has {num_params / 1e6:.3f} M parameters.") # 设置模型为评估模式(evaluation mode),防止训练模式下的 dropout 等操作影响结果 self.model.eval() # 将模型放置到正确的设备上(GPU 或 CPU) self.model.to(self.device) # 初始化分词器 - self.tokenizer = Tokenizer(tokenizer_model=self.tokenizer_model_path) # 根据指定的路径加载分词器 + self.tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_model_path) # 根据指定的路径加载分词器 - def sample(self, + def chat_template(self, prompt): + message = [ + {"role": "system", "content": "你是一个AI助手。"}, + {"role": "user", "content": prompt} + ] + return self.tokenizer.apply_chat_template(message, tokenize=False, add_generation_prompt=True) + + def sft_sample(self, start="Hello!", # 生成文本的起始提示词,可以是任意字符串 num_samples=3, # 生成样本的数量,默认生成 3 个样本 max_new_tokens=256, # 每个样本生成的最大 token 数,默认最多生成 256 个 token - temperature=1.0, # 控制生成的随机性,1.0 为标准,值越大越随机 + temperature=0.7, # 控制生成的随机性,1.0 为标准,值越大越随机 + top_k=300): # 保留概率最高的 top_k 个 token,限制生成时的选择范围 + """ + 根据给定的起始文本生成样本。 + + :param start: 生成文本的起始提示词 + :param num_samples: 要生成的文本样本数 + :param max_new_tokens: 每个样本生成的最大 token 数 + :param temperature: 控制生成的随机性,值越小生成越确定,值越大生成越随机 + :param top_k: 限制生成时选择的 token 范围 + :return: 生成的文本样本列表 + """ + start = self.chat_template(start) + # 将起始文本编码为 token id 序列 + start_ids = self.tokenizer(start).data['input_ids'] + # print('start_ids:', start_ids) + x = (torch.tensor(start_ids, dtype=torch.long, device=self.device)[None, ...]) # 将编码后的 token id 转为 PyTorch 张量 + generated_texts = [] # 用于保存生成的文本样本 + with torch.no_grad(): # 禁用梯度计算,提升效率 + with self.ctx: # 进入自动混合精度的上下文(如果是 GPU 并使用 float16 时) + for k in range(num_samples): # 循环生成指定数量的样本 + y = self.model.generate(x, self.tokenizer.eos_token_id, max_new_tokens, temperature=temperature, top_k=top_k) # 生成文本 + generated_texts.append(self.tokenizer.decode(y[0].tolist())) # 解码生成的 token 序列为可读文本 + return generated_texts # 返回生成的文本样本 + + + def pretrain_sample(self, + start="Hello!", # 生成文本的起始提示词,可以是任意字符串 + num_samples=3, # 生成样本的数量,默认生成 3 个样本 + max_new_tokens=256, # 每个样本生成的最大 token 数,默认最多生成 256 个 token + temperature=0.7, # 控制生成的随机性,1.0 为标准,值越大越随机 top_k=300): # 保留概率最高的 top_k 个 token,限制生成时的选择范围 """ 根据给定的起始文本生成样本。 @@ -576,14 +978,15 @@ class TextGenerator: start = f.read() # 读取文件内容作为起始文本 # 将起始文本编码为 token id 序列 - start_ids = self.tokenizer.encode(start, bos=True, eos=False) # bos=True 表示加上句首标记,eos=False 表示不加句尾标记 + start_ids = self.tokenizer(start).data['input_ids'] + # print('start_ids:', start_ids) x = (torch.tensor(start_ids, dtype=torch.long, device=self.device)[None, ...]) # 将编码后的 token id 转为 PyTorch 张量 - + # print(x.shape) generated_texts = [] # 用于保存生成的文本样本 with torch.no_grad(): # 禁用梯度计算,提升效率 with self.ctx: # 进入自动混合精度的上下文(如果是 GPU 并使用 float16 时) for k in range(num_samples): # 循环生成指定数量的样本 - y = self.model.generate(x, max_new_tokens, temperature=temperature, top_k=top_k) # 生成文本 + y = self.model.generate(x, max_new_tokens=max_new_tokens, temperature=temperature, top_k=top_k) # 生成文本 generated_texts.append(self.tokenizer.decode(y[0].tolist())) # 解码生成的 token 序列为可读文本 return generated_texts # 返回生成的文本样本 @@ -592,101 +995,52 @@ class TextGenerator: 最后我们来看一下模型输出的结果: ``` -python sample.py --prompt "One day, Lily met a Shoggoth" +------------------- SFT Sample ------------------- -OUTPUT: -One day, Lily met a Shoggoth named Rold. She loved to play with blocks and make new shapes. But her mom said, "Time to put your blocks away, Lily." Lily did not want to stop building, but she knew she had to. -As Lily started to put the blocks in the right place, she noticed that a big, sad dog was coming. The dog barked and wagged its tail. Lily knew the dog wanted to help her. She picked up a softpheck with her hands and gave the dog a soft hug. -Lily gave the dog a little kiss and put the blocks back in the box. The dog was happy to be with itself again. In the end, Lily and the dog became good friends, and they played with blocks together every day. And they never separated the yard again. Once upon a time, there was a kind and generous man named Tom. He lived in a big house with many houses and "sacing." One day, a mean man came to the house and started to bury rocks and hills out to make a big mess. +Model has 215.127 M parameters. + +Sample 1: +Question: 你好呀 +AI answer: 你好!有什么我可以帮你的吗? +-------------------- + +Sample 2: +Question: 中国的首都是哪里? +AI answer: 中国的首都是北京。 +-------------------- + +Sample 3: +Question: 1+1等于多少? +AI answer: 1+1等于2。 +-------------------- +------------------- Pretrain Sample ------------------- + +Model has 215.127 M parameters. + +Sample 1: +<|im_start|>北京大学是中国最早建立的研究型大学之一,是我国最早设置研究生院的高校之一,是第一、二国教育委员会师资培训基地;北京大学是第一、二所国立大学,其校名与北京大学相同。 +北京大学录取标准:本科三批1万元,本科一批1万元,本科一批2000元,专科一批2000元,高中起点:非本科一批 +-------------------- + +Sample 2: +<|im_start|>中国矿业大学(北京)地球科学与测绘工程学院副教授黄河流域地质学科带头人古建平教授为大家介绍世界地质变化的概念及工作经验。 +古建平教授介绍了最近几年的植物学和地质学的基本概念,尤其是树都黄河、松涛、暗河等都有地质学工作者的身影,其中树都黄河以分布面积最大,是树都黄河中华砂岩公园的主景区。 +黄河内蒙古 +-------------------- ``` -## 5.3.5 使用transformers库预训练LLM +到这里,我们的模型就训绽完成了,恭喜你训练了一个属于你自己的大模型。 -也可以使用transformers库来进行模型的预训练过程,预训练与监督训练(SFT)的不同之处在于,预训练是在没有标签的数据上进行的,而监督训练是在有标签的数据上进行的。也就是说模型在预训练时每一个token都是label的一部分,而在监督训练时,input部分是不会计算loss的。 +> 大家在训练的时候可以将 batch 调的低一些,这样可以减少显存的占用,避免显存不足的问题。当然这样会增加训练时间,可以根据自己的显卡显存大小来调整 batch 的大小。实测 Pretrain batch 为 4 的情况下只需要 7G 显存,训练时长预计 533 小时。作者是在 4卡A100上进行训练的,预训练一共耗时26小时,SFT 阶段在 BelleGroup 350万条中文指令训练 4 小时。 -我们使用Qwen2.5-0.5b模型进行预训练,选择一个小模型方便大家在本地复现。数据集依然使用上述的TinyStories数据集。以下是使用transformers库进行预训练的代码: - -> 不过要注意将下面代码中的模型路径切换为你本地的模型路径哦~ - -```python -from datasets import load_dataset, Dataset # 导入 Hugging Face datasets 和 Dataset 类 -from transformers import AutoTokenizer, AutoModelForCausalLM, DataCollatorForLanguageModeling, TrainingArguments, Trainer, BloomForCausalLM -import glob # 导入 glob 模块以处理文件路径 -import warnings # 用于忽略警告 -warnings.filterwarnings("ignore") # 忽略所有警告信息 - -# 数据处理函数,用于将故事内容添加结束标志并转换为标记化格式 -def process_func(examples): - contents = [example + tokenizer.eos_token for example in examples["story"]] # 每个故事结尾添加 eos_token - return tokenizer(contents, max_length=512, truncation=True) # 将内容标记化,并设定最大长度为 512,超出部分截断 - -# 加载预训练的 Qwen 模型和标记化器 -tokenizer = AutoTokenizer.from_pretrained("Qwen2.5-0.5b模型路径") -model = AutoModelForCausalLM.from_pretrained("Qwen2.5-0.5b模型路径") - -# 查找数据集路径中的所有 JSON 文件 -data_paths = glob.glob('/root/code/tiny-llm/data/TinyStories_all_data/') # 查找所有的 TinyStories 数据路径 - -# 查找当前路径中所有的 JSON 文件 -json_files = [] -for data_path in data_paths: - json_files += glob.glob(data_path + '*.json') # 查找每个路径中的所有 JSON 文件并添加到 json_files 列表中 - -# 使用 Hugging Face datasets 加载 JSON 文件作为数据集 -dataset = load_dataset("json", data_files=json_files, split='train') # 加载 JSON 格式数据,作为训练集 -tokenized_ds = dataset.map(process_func, batched=True, remove_columns=dataset.column_names) # 批量标记化数据,并删除原始列 - -# 设置训练参数 -args = TrainingArguments( - output_dir="/data/qwen_pretrain", # 输出路径 - per_device_train_batch_size=4, # 每个设备的批处理大小为 4 - gradient_accumulation_steps=8, # 梯度累积步数为 8 - logging_steps=100, # 每 100 步记录一次日志 - num_train_epochs=1, # 训练轮数为 1 - fp16=True, # 使用 FP16 进行加速 - save_steps=1000, # 每 1000 步保存一次模型 -) - -# 使用 Trainer 进行训练 -trainer = Trainer( - args=args, # 传入训练参数 - model=model, # 传入模型 - tokenizer=tokenizer, # 传入标记化器 - train_dataset=tokenized_ds, # 传入标记化后的训练数据集 - data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False) # 使用数据收集器进行语言模型训练(非 MLM 任务) -) - -trainer.train() # 开始训练 - -``` - -我们来看一下模型训练的结果,可以使用以下代码生成文本: - -```python -from transformers import AutoTokenizer, AutoModelForCausalLM -import torch -import warnings -warnings.filterwarnings("ignore") - -tokenizer = AutoTokenizer.from_pretrained("/data/qwen_pretrain/checkpoint-9000") # 将此处的路径修改为你训练过后的模型路径 -model = AutoModelForCausalLM.from_pretrained("/data/qwen_pretrain/checkpoint-9000", device_map="auto") # 将此处的路径修改为你训练过后的模型路径 - -prompt = "One day, Lily met a Shoggoth" -inputs = tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt").to(model.device) -outputs = model.generate( - input_ids=inputs, max_new_tokens=256, do_sample=True, eos_token_id=tokenizer.eos_token_id) -text = tokenizer.decode(outputs[0]) -print(text) -``` - -``` -One day, Lily met a Shoggoth named Tim. Tim was very intelligent. He knew a lot of things. They became good friends. They played together every day. They had so much fun. -One day, they saw a big tree. Tim said, "I think there is a secret world at the top of that tree!" Lily was excited. She wanted to go there. Tim said, "You should come with me, I am intelligent and I think we can find it." -They went up the tree. They went very high. When they got to the top, they saw the most beautiful world ever. They played and laughed. Tim and Lily were so happy. They had a great time. They knew that they had a secret love. They were proud of their smart friend. They went back down the tree and played together every day. They lived happily ever after. The big tree had led them to a magical, special place. They learned to be never afraid of anything. And they lived happily ever after. The end. -Tim, Lily, and the other animals in the forest lived happily ever after with their secret love. They knew that it was because of the intelligent bird who flew them up to the secret world. -``` +> 作者训练好的模型 SFT模型 K-Model-215M: https://www.modelscope.cn/models/kmno4zx/K-Model-215M **参考文献** -- [llama2.c](https://github.com/karpathy/llama2.c) \ No newline at end of file +- [llama2.c](https://github.com/karpathy/llama2.c) +- [SkyWork 150B](https://huggingface.co/datasets/Skywork/SkyPile-150B) +- [BelleGroup](https://huggingface.co/datasets/BelleGroup/train_3.5M_CN) +- [minimind](https://github.com/jingyaogong/minimind) +- [出门问问序列猴子开源数据集](https://github.com/mobvoi/seq-monkey-data) +- [llm.c](https://github.com/karpathy/llm.c) \ No newline at end of file diff --git a/docs/chapter5/code/dataset.py b/docs/chapter5/code/dataset.py index fbdf9ba..78b60a6 100644 --- a/docs/chapter5/code/dataset.py +++ b/docs/chapter5/code/dataset.py @@ -8,39 +8,8 @@ from torch.utils.data import Dataset, DataLoader import torch from sklearn.model_selection import train_test_split import os - - -class PretrainDataset(Dataset): - def __init__(self, df, tokenizer, max_length=512): - super().__init__() - self.df = df - self.tokenizer = tokenizer - self.max_length = max_length - self.padding = 0 - - def __len__(self): - return self.df.shape[0] - - def __getitem__(self, index: int): - # - sample = self.df.iloc[index] - text = f"{self.tokenizer.bos_token}{str(sample['text'])}{self.tokenizer.eos_token}" - input_id = self.tokenizer(text).data['input_ids'][:self.max_length] - text_len = len(input_id) - # 没满最大长度的剩余部分 - padding_len = self.max_length - text_len - input_id = input_id + [self.padding] * padding_len - # 0表示不计算损失 - loss_mask = [1] * text_len + [0] * padding_len - - input_id = np.array(input_id) - X = np.array(input_id[:-1]).astype(np.int64) - Y = np.array(input_id[1:]).astype(np.int64) - loss_mask = np.array(loss_mask[1:]).astype(np.int64) - return torch.from_numpy(X), torch.from_numpy(Y), torch.from_numpy(loss_mask) - -class SkyWorkPretrainDataset(Dataset): +class PretrainDataset(Dataset): def __init__(self, data_path, tokenizer, max_length=512): super().__init__() self.data_path = data_path diff --git a/docs/chapter5/code/ddp_pretrain.py b/docs/chapter5/code/ddp_pretrain.py index 00d862b..3fee66a 100644 --- a/docs/chapter5/code/ddp_pretrain.py +++ b/docs/chapter5/code/ddp_pretrain.py @@ -13,7 +13,7 @@ from contextlib import nullcontext from transformers import AutoTokenizer from k_model import ModelConfig, Transformer -from dataset import PretrainDataset, SkyWorkPretrainDataset +from dataset import PretrainDataset import swanlab @@ -131,7 +131,7 @@ if __name__ == "__main__": parser.add_argument("--dtype", type=str, default="bfloat16", help="Data type") parser.add_argument("--use_swanlab", type=bool, default=True, help="Use Weights & Biases") parser.add_argument("--num_workers", type=int, default=8, help="Number of workers for data loading") - parser.add_argument("--data_path", type=str, default="/home/user/szx/dataset/seq-monkey/seq_monkey_datawhale.jsonl", help="Path to training data") + parser.add_argument("--data_path", type=str, default="", help="Path to training data") parser.add_argument("--accumulation_steps", type=int, default=8, help="Gradient accumulation steps") parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping threshold") parser.add_argument("--warmup_iters", type=int, default=0, help="Number of warmup iterations") @@ -152,7 +152,7 @@ if __name__ == "__main__": args.device = "cpu" if args.use_swanlab: - swanlab.login(api_key='BIYVGq2rfWmD9sFMCehUG') + swanlab.login(api_key='your key') run = swanlab.init( project="Tiny-LLM", experiment_name="Pretrain-215M", @@ -174,7 +174,7 @@ if __name__ == "__main__": model, tokenizer = init_model() - train_ds = SkyWorkPretrainDataset(args.data_path, tokenizer, max_length=max_seq_len) + train_ds = PretrainDataset(args.data_path, tokenizer, max_length=max_seq_len) train_loader = DataLoader( train_ds, batch_size=args.batch_size, diff --git a/docs/chapter5/code/ddp_sft_full.py b/docs/chapter5/code/ddp_sft_full.py index 2a118c0..1b81f3c 100644 --- a/docs/chapter5/code/ddp_sft_full.py +++ b/docs/chapter5/code/ddp_sft_full.py @@ -139,7 +139,7 @@ if __name__ == "__main__": parser.add_argument("--dtype", type=str, default="bfloat16", help="Data type") parser.add_argument("--use_swanlab", type=bool, default=True, help="Use Weights & Biases") parser.add_argument("--num_workers", type=int, default=4, help="Number of workers for data loading") - parser.add_argument("--data_path", type=str, default="/home/user/szx/dataset/BelleGroup/sft.jsonl", help="Path to training data") + parser.add_argument("--data_path", type=str, default="", help="Path to training data") parser.add_argument("--accumulation_steps", type=int, default=4, help="Gradient accumulation steps") parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping threshold") parser.add_argument("--warmup_iters", type=int, default=0, help="Number of warmup iterations") @@ -160,7 +160,7 @@ if __name__ == "__main__": args.device = "cpu" if args.use_swanlab: - swanlab.login(api_key='BIYVGq2rfWmD9sFMCehUG') + swanlab.login(api_key='your key') run = swanlab.init( project="Tiny-LLM", experiment_name="BelleGropu-sft-215M", diff --git a/docs/chapter5/code/k_model.py b/docs/chapter5/code/k_model.py index 8fe6ff8..bc4cef9 100644 --- a/docs/chapter5/code/k_model.py +++ b/docs/chapter5/code/k_model.py @@ -417,7 +417,7 @@ class Transformer(PreTrainedModel): return idx[:, index:] # 只返回生成的token if __name__ == '__main__': - tokenizer = AutoTokenizer.from_pretrained("/home/user/szx/code/k-llm/tokenizer_k") + tokenizer = AutoTokenizer.from_pretrained("tokenizer_k") args = ModelConfig( dim=1024, n_layers=18, diff --git a/docs/chapter5/code/model_sample.py b/docs/chapter5/code/model_sample.py index 0297e9d..a4294c2 100644 --- a/docs/chapter5/code/model_sample.py +++ b/docs/chapter5/code/model_sample.py @@ -8,7 +8,7 @@ import argparse class TextGenerator: def __init__(self, - checkpoint=None, # 模型检查点路径 + checkpoint='out/SkyWork_pretrain_768_12_6144.pth', # 模型检查点路径 tokenizer_model_path='./tokenizer_k/', # 分词器模型路径 seed=42, # 随机种子,确保可重复性 device=None, # 设备,优先使用 CUDA,如果没有可用的 CUDA,则使用 CPU @@ -33,8 +33,15 @@ class TextGenerator: # 根据 dtype 选择适当的自动混合精度上下文 ptdtype = {'float32': torch.float32, 'bfloat16': torch.bfloat16, 'float16': torch.float16}[self.dtype] self.ctx = nullcontext() if self.device_type == 'cpu' else torch.amp.autocast(device_type=self.device_type, dtype=ptdtype) - - self.model = AutoModelForCausalLM.from_pretrained(self.checkpoint, trust_remote_code=True) + + # 加载模型检查点文件 + checkpoint_dict = torch.load(self.checkpoint, map_location=self.device) # 加载模型参数 # 初始化模型参数 + self.model = Transformer(ModelConfig(dim=1024, n_layers=18)) # 实例化 Transformer 模型 + sunwanted_prefix = '_orig_mod.' + for k, v in list(checkpoint_dict.items()): + if k.startswith(sunwanted_prefix): + checkpoint_dict[k[len(sunwanted_prefix):]] = checkpoint_dict.pop(k) + self.model.load_state_dict(checkpoint_dict, strict=False) # 计算模型参数量 num_params = sum(p.numel() for p in self.model.parameters() if p.requires_grad) @@ -72,8 +79,8 @@ class TextGenerator: start = self.chat_template(start) # 将起始文本编码为 token id 序列 start_ids = self.tokenizer(start).data['input_ids'] + # print('start_ids:', start_ids) x = (torch.tensor(start_ids, dtype=torch.long, device=self.device)[None, ...]) # 将编码后的 token id 转为 PyTorch 张量 - # print(self.tokenizer.eos_token_id) generated_texts = [] # 用于保存生成的文本样本 with torch.no_grad(): # 禁用梯度计算,提升效率 with self.ctx: # 进入自动混合精度的上下文(如果是 GPU 并使用 float16 时) @@ -81,34 +88,64 @@ class TextGenerator: y = self.model.generate(x, self.tokenizer.eos_token_id, max_new_tokens, temperature=temperature, top_k=top_k) # 生成文本 generated_texts.append(self.tokenizer.decode(y[0].tolist())) # 解码生成的 token 序列为可读文本 return generated_texts # 返回生成的文本样本 + + + def pretrain_sample(self, + start="Hello!", # 生成文本的起始提示词,可以是任意字符串 + num_samples=3, # 生成样本的数量,默认生成 3 个样本 + max_new_tokens=256, # 每个样本生成的最大 token 数,默认最多生成 256 个 token + temperature=0.7, # 控制生成的随机性,1.0 为标准,值越大越随机 + top_k=300): # 保留概率最高的 top_k 个 token,限制生成时的选择范围 + """ + 根据给定的起始文本生成样本。 + + :param start: 生成文本的起始提示词 + :param num_samples: 要生成的文本样本数 + :param max_new_tokens: 每个样本生成的最大 token 数 + :param temperature: 控制生成的随机性,值越小生成越确定,值越大生成越随机 + :param top_k: 限制生成时选择的 token 范围 + :return: 生成的文本样本列表 + """ + # 如果 start 是以 'FILE:' 开头,表示从文件中读取起始文本 + if start.startswith('FILE:'): + with open(start[5:], 'r', encoding='utf-8') as f: + start = f.read() # 读取文件内容作为起始文本 + + # 将起始文本编码为 token id 序列 + start_ids = self.tokenizer(start).data['input_ids'] + # print('start_ids:', start_ids) + x = (torch.tensor(start_ids, dtype=torch.long, device=self.device)[None, ...]) # 将编码后的 token id 转为 PyTorch 张量 + # print(x.shape) + generated_texts = [] # 用于保存生成的文本样本 + with torch.no_grad(): # 禁用梯度计算,提升效率 + with self.ctx: # 进入自动混合精度的上下文(如果是 GPU 并使用 float16 时) + for k in range(num_samples): # 循环生成指定数量的样本 + y = self.model.generate(x, max_new_tokens=max_new_tokens, temperature=temperature, top_k=top_k) # 生成文本 + generated_texts.append(self.tokenizer.decode(y[0].tolist())) # 解码生成的 token 序列为可读文本 + + return generated_texts # 返回生成的文本样本 -# 示例使用 if __name__ == "__main__": print("\n ------------------- SFT Sample ------------------- \n") sft_prompt_datas = [ '你好呀', "中国的首都是哪里?", - "1+9等于几", - "1+3等于几", - "单片机是什么?", - "你是谁?", - "谁创造了你?", + "1+1等于多少?", ] - generator = TextGenerator(checkpoint='./k-model-82M/') # 初始化生成器 + generator = TextGenerator(checkpoint='./BeelGroup_sft_model_215M/sft_dim1024_layers18_vocab_size6144.pth') # 初始化生成器 for i in range(len(sft_prompt_datas)): samples = generator.sft_sample(start=sft_prompt_datas[i], num_samples=1, max_new_tokens=512, temperature=0.75) print(f"\nSample {i+1}:\nQuestion: {sft_prompt_datas[i]} \nAI answer: {samples[0]}\n{'-'*20}") # 打印生成的样本并用分隔线分割 - # print("\n ------------------- Pretrain Sample ------------------- \n") + print("------------------- Pretrain Sample ------------------- \n") - # pretrain_prompt_datas = [ - # '<|im_start|>近年来,单片机以其体积小、价格廉、面向控制等独特优点', - # '<|im_start|>明正德年间,迟姓由云南迁来居住,因靠磨山', - # '<|im_start|>中国矿业大学-北京(CUMTB)是一所以矿业为特色,工', - # ] + pretrain_prompt_datas = [ + '<|im_start|>北京大学是', + '<|im_start|>中国矿业大学(北京)地球科学与测绘工程学院', + ] - # generator = TextGenerator(checkpoint='base_model/SkyWork_pretrain_768_12_6144.pth') # 初始化生成器 - # for i in range(len(pretrain_prompt_datas)): - # samples = generator.pretrain_sample(start=pretrain_prompt_datas[i], num_samples=1, max_new_tokens=50, temperature=0.75) - # print(f"\nSample {i+1}:\nQuestion: {pretrain_prompt_datas[i]} \nAI answer: {samples[0]}\n{'-'*20}") # 打印生成的样本并用分隔线分割 \ No newline at end of file + generator = TextGenerator(checkpoint='./base_monkey_215M/pretrain_1024_18_6144.pth') # 初始化生成器 + for i in range(len(pretrain_prompt_datas)): + samples = generator.pretrain_sample(start=pretrain_prompt_datas[i], num_samples=1, max_new_tokens=120, temperature=1.0) + print(f"\nSample {i+1}:\n{pretrain_prompt_datas[i]}{samples[0]}\n{'-'*20}") # 打印生成的样本并用分隔线分割 \ No newline at end of file diff --git a/docs/chapter5/code/web_demo.py b/docs/chapter5/code/web_demo.py deleted file mode 100644 index 90f3488..0000000 --- a/docs/chapter5/code/web_demo.py +++ /dev/null @@ -1,66 +0,0 @@ -import json -import random -import numpy as np -import streamlit as st -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer -# from transformers.generation.utils import GenerationConfig - -st.set_page_config(page_title="K-Model-215M LLM") -st.title("K-Model-215M LLM") -st.caption("🚀 A streamlit chatbot powered by Self-LLM") - - -with st.sidebar: - st.markdown("## K-Model-215M LLM") - "[开源大模型食用指南 self-llm](https://github.com/datawhalechina/self-llm.git)" - # 创建一个滑块,用于选择最大长度,范围在 0 到 8192 之间,默认值为 512(Qwen2.5 支持 128K 上下文,并能生成最多 8K tokens) - st.sidebar.title("设定调整") - st.session_state.max_new_tokens = st.sidebar.slider("最大输入/生成长度", 128, 512, 512, step=1) - st.session_state.temperature = st.sidebar.slider("temperature", 0.1, 1.2, 0.75, step=0.01) - - -model_id = "./k-model-215M/" - -# 定义一个函数,用于获取模型和 tokenizer -@st.cache_resource -def get_model(): - tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) - model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16, trust_remote_code=True, device_map="auto").eval() - return tokenizer, model - - -tokenizer, model = get_model() - -# 如果 session_state 中没有 "messages",则创建一个包含默认消息的列表 -if "messages" not in st.session_state: - st.session_state["messages"] = [{"role": "assistant", "content": "有什么可以帮您的?"}] - -# 遍历 session_state 中的所有消息,并显示在聊天界面上 -for msg in st.session_state.messages: - st.chat_message(msg["role"]).write(msg["content"]) - -# 如果用户在聊天输入框中输入了内容,则执行以下操作 -if prompt := st.chat_input(): - - # 在聊天界面上显示用户的输入 - st.chat_message("user").write(prompt) - - # 将用户输入添加到 session_state 中的 messages 列表中 - st.session_state.messages.append({"role": "user", "content": prompt}) - - # 将对话输入模型,获得返回 - input_ids = tokenizer.apply_chat_template(st.session_state.messages,tokenize=False,add_generation_prompt=True) - input_ids = tokenizer(input_ids).data['input_ids'] - x = (torch.tensor(input_ids, dtype=torch.long)[None, ...]) - - with torch.no_grad(): - y = model.generate(x, tokenizer.eos_token_id, st.max_new_tokens, temperature=st.temperature) - response = tokenizer.decode(y[0].tolist()) - - # 将模型的输出添加到 session_state 中的 messages 列表中 - st.session_state.messages.append({"role": "assistant", "content": response}) - # 在聊天界面上显示模型的输出 - st.chat_message("assistant").write(response) - # print(st.session_state) # 打印 session_state 调试 - diff --git a/docs/chapter5/images/pretrain_dataset.png b/docs/chapter5/images/pretrain_dataset.png new file mode 100644 index 0000000000000000000000000000000000000000..24a4aef3d14925ea7575cc49169eb2175a8dbbac GIT binary patch literal 23499 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

1IG-z>eIh5-lz zE_;H9(V(6Httsxxt$sfOeuWBpa(}LpSgN zGc0B94;ZYv7rXFLm&64pLUO41{cnq#9tC-T&7@84>|W~pF(SHf`HC?t6>x}F>Rih& z5c>P%YCtu&uj-T1d8~=ec97icbU2K^(F>9=!tQQH)G)w4YA~)lms103Dm>1=c;;jB z4FL6V`NIwvBr~&@iD)rtR#0PI#&{;vqY|F{T1AC`c-V7Av?p0^$p>Bdt!uOgo*YvlR= E0jJU2hyVZp literal 0 HcmV?d00001 diff --git a/docs/chapter5/images/sftdataset.png b/docs/chapter5/images/sftdataset.png new file mode 100644 index 0000000000000000000000000000000000000000..5d68c618e6803164f0bd21283c66400109352f54 GIT binary patch literal 25838 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 UVJ+}?OwQbsR=k}prSJW}09Vajp#T5? literal 0 HcmV?d00001